Files
cma-management/backend/app/utils/cash_forecast_engine.py
T
wecom-fullstack 885cfddbff
ci/woodpecker/push/main Pipeline failed
feat(warning-p0a): 资金缺口阈值外置 + 复算对拍 + 讲人话(数字守恒) + 数据就绪核验
P0-A(任务 fullstack-20261017-warning-capability-build-p0):
- cash_thresholds.py:阈值迁 per-entity(复用 alert_rules.rule_type=cash_gap_threshold,
  默认 20/10 万不动;无配置/非法/异常一律回落默认,fail-safe 不改预警行为)
- cash_forecast_engine.py:阈值改从配置读取,响应新增 threshold_source 明写来源
- verify_cash_gap_replay.py:同输入双跑逐字段 diff=0 + 手算 3/3 + 端点逐字段对拍(--live)
- cash_gap_narrative.py:复用 ai_brief.call_deepseek,只写原因/建议;
  断言生成文本数字集合 ⊆ 规则层数字集合,违例即不带文案上线
- audit_warning_data_readiness.py:G0 只读核验(cma_reader SELECT only),
  行数/期间/枚举 + 数据质量缺口(期初全 0、负期末、单批次、无员工维度表)

未 push(push 即部署,须项目侧书面放行);未改生产库/未动 cron。
2026-10-17 23:33:51 +08:00

1086 lines
48 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""现金流预测引擎 — 根据历史KPI数据推算未来30天现金流"""
import logging
import os
from datetime import datetime, timedelta
from typing import Optional, TYPE_CHECKING
from sqlalchemy.orm import Session
from app.utils.kpi_value_scope import actual_only_clause
import math
import random
if TYPE_CHECKING:
from app.models import KPIDefinition
logger = logging.getLogger("cma.cash_forecast")
# 默认现金阈值(万元)—— 单一事实源迁至 app/utils/cash_thresholds.py(G1,2026-10-17)
# 此处保留同名常量以兼容既有 import(app/api/cash.py 等);数值未变:黄 20 / 红 10。
from app.utils.cash_thresholds import ( # noqa: E402
DEFAULT_CRITICAL as _CFG_DEFAULT_CRITICAL,
DEFAULT_WARNING as _CFG_DEFAULT_WARNING,
get_cash_gap_thresholds,
describe_source as describe_cash_threshold_source,
)
DEFAULT_CASH_WARNING = _CFG_DEFAULT_WARNING # 黄灯 — 低于20万
DEFAULT_CASH_CRITICAL = _CFG_DEFAULT_CRITICAL # 红灯 — 低于10万
# 历史KPI编码映射(含候选编码,兼容F_*标准编码)
KPI_CODES = {
"operating_cash_flow": "CASH_FLOW_001", # 经营现金流
"receivables": "AR_001", # 应收账款
"payables": "AP_001", # 应付账款
"cash_balance": "CASH_001", # 现金余额
}
# 候选编码列表 — 依次尝试,找不到则回退
KPI_CODE_CANDIDATES = {
"operating_cash_flow": ["CASH_FLOW_001", "F_OP_CFLOW", "F_REVENUE"],
"receivables": ["AR_001", "F_AR_DAYS", "C_AR_BALANCE"],
"payables": ["AP_001", "F_AP_DAYS"],
"cash_balance": ["CASH_001", "CASH_BALANCE", "F_CASH"],
}
# 实体前缀码后缀模式(F2 实体感知 2026-09-13)——标准码全部未命中时,
# 按后缀匹配「该实体自己的编码」(博海口径 BH_AR_BALANCE/BH_CASH_BALANCE/BH_AP_BALANCE)。
# 修复前:博海一条候选都不命中 → 走无 ORDER BY 的兜底 first() →
# 命中 uk_entity_kpi 索引时取字典序最小的 BH_ADM_EXPENSE(437 管理费用),即"应收预警挂管理费用"。
KPI_CODE_SUFFIXES = {
"operating_cash_flow": [], # 无实体前缀变体
"receivables": ["_AR_BALANCE"], # 博海 BH_AR_BALANCE(433)
"payables": ["_AP_BALANCE"], # 博海 BH_AP_BALANCE(441)
"cash_balance": ["_CASH_BALANCE"], # 博海 BH_CASH_BALANCE(434)
}
# ── 余额快照批次(F4,2026-09-13)────────────────────────────
# 这些 source 是「期末余额导入」批次:plan_date 是**导入基准日**(如 2026-06-30 期末),
# 不是真实到期日 → 逐户生成"到期未收款"会永远 overdue,属伪逾期(DAMA 审计 fin-alert-flood-e2)。
BALANCE_SNAPSHOT_SOURCES = {
"bohai_ar_20260630",
"bohai_ap_20260630",
"bohai_prepay_20260630",
"receivables_migrate",
}
# 处理开关:per_customer(逐户,旧行为) / skip(不生成逐户,默认) / aggregate(按批次聚合1条)
_SNAPSHOT_MODE_DEFAULT = "skip"
_SNAPSHOT_MODES = ("per_customer", "skip", "aggregate")
def get_balance_snapshot_mode() -> str:
"""余额快照批次处理模式(环境变量 CMA_CASH_SNAPSHOT_MODE 可覆盖,非法值回落默认)
- per_customer: 旧行为,逐户生成到期未收款预警
- skip : 默认。批次行不生成逐户预警(口径待任总拍板)
- aggregate : 按批次聚合 1 条预警(如"应收逾期210户 合计198.27万")挂应收 KPI
"""
mode = (os.environ.get("CMA_CASH_SNAPSHOT_MODE") or "").strip().lower()
return mode if mode in _SNAPSHOT_MODES else _SNAPSHOT_MODE_DEFAULT
def get_balance_snapshot_sources() -> set:
"""余额快照批次 source 集合(环境变量 CMA_CASH_SNAPSHOT_SOURCES 逗号分隔可追加/覆盖)"""
env = (os.environ.get("CMA_CASH_SNAPSHOT_SOURCES") or "").strip()
if env:
return {s.strip() for s in env.split(",") if s.strip()}
return set(BALANCE_SNAPSHOT_SOURCES)
# ── F4b(2026-09-13):展示口径与落库口径同源 ─────────────────────────
# 背景:check_cash_alerts() 写库路径已按 get_balance_snapshot_mode() 过滤快照批次,
# 但 forecast_cash_flow_with_plans() 的 summary/suggestions 仍按未过滤集合统计 ⇒
# 同一次响应里出现「写入 0 条 / 展示 216 笔 200.72 万」的自相矛盾
# (任务 dama-cashplan-zombie-alert-cleanup-20260913 B 段 / DAMA 复核 fullstack-20260913-0703)。
# 约定:展示(summary/suggestions/upcoming)与写库(check_cash_alerts)**同一判据** ——
# skip → 快照批次行不计入(默认)
# aggregate → 快照批次按 source 聚合计数(与写库侧「1 条/批次」一致)
# per_customer→ 逐户全额计入(旧行为)
# 同时保留可追溯字段(raw / excluded / batches),不隐藏数据。
def split_snapshot_plans(rows: list, sources: Optional[set] = None) -> tuple:
"""把计划行切成 (业务行, 余额快照批次行),判据与写库侧完全一致(source ∈ 快照名单)"""
src = get_balance_snapshot_sources() if sources is None else set(sources)
business = [p for p in rows if (p.source or "") not in src]
snapshot = [p for p in rows if (p.source or "") in src]
return business, snapshot
def summarize_overdue_receives(rows: list, mode: Optional[str] = None,
sources: Optional[set] = None) -> dict:
"""逾期应收展示口径 —— 与 check_cash_alerts() 写库口径同源(F4b)
rows: 逾期未收款计划(CashPlan,不预先过滤快照批次)
返回:
count / amount 计入 summary 与 suggestions 的口径(= 落库口径)
raw_count / raw_amount 未过滤全集(含快照批次),保留可追溯
snapshot_rows_total / _amount 逾期中的快照批次行数与金额
snapshot_rows_excluded / _amount 未按「逐户 1 条」计入展示口径的行数与金额
snapshot_batches 批次分组明细(source/count/amount)
mode / sources 生效口径与名单
"""
mode = mode or get_balance_snapshot_mode()
src = get_balance_snapshot_sources() if sources is None else set(sources)
business, snapshot = split_snapshot_plans(rows, src)
batches = {}
for p in snapshot:
key = p.source or "unknown"
b = batches.setdefault(key, {"source": key, "count": 0, "amount": 0.0})
b["count"] += 1
b["amount"] = round(b["amount"] + (p.amount or 0), 2)
if mode == "per_customer":
display_rows = business + snapshot
excluded_rows, excluded_amount = 0, 0.0
elif mode == "aggregate":
# 与写库侧一致:每 source 聚合为 1 条 → 展示计 len(batches) 条,金额全量计入
display_rows = business
excluded_rows = len(snapshot) - len(batches)
excluded_amount = 0.0
else: # skip(默认)
display_rows = business
excluded_rows = len(snapshot)
excluded_amount = round(sum((p.amount or 0) for p in snapshot), 2)
count = len(display_rows) + (len(batches) if mode == "aggregate" else 0)
amount = round(sum((p.amount or 0) for p in display_rows), 2)
if mode == "aggregate":
amount = round(amount + sum(b["amount"] for b in batches.values()), 2)
return {
"count": count,
"amount": amount,
"raw_count": len(rows),
"raw_amount": round(sum((p.amount or 0) for p in rows), 2),
"snapshot_rows_total": len(snapshot),
"snapshot_rows_total_amount": round(sum((p.amount or 0) for p in snapshot), 2),
"snapshot_rows_excluded": excluded_rows,
"snapshot_rows_excluded_amount": excluded_amount,
"snapshot_batches": [batches[k] for k in sorted(batches)],
"mode": mode,
"sources": sorted(src),
}
def find_kpi(db: Session, entity_id: int, codes: list, suffixes: Optional[list] = None) -> Optional["KPIDefinition"]:
"""按候选编码列表查找KPI,返回第一个命中的(精确码优先 → 实体前缀后缀码 → None)
F2(2026-09-13): 全部查询显式 ORDER BY id,保证确定性(不再依赖 MySQL 执行计划)。
"""
from app.models import KPIDefinition
for code in codes:
kpi = db.query(KPIDefinition).filter(
KPIDefinition.kpi_code == code,
KPIDefinition.entity_id == entity_id,
).order_by(KPIDefinition.id.asc()).first()
if kpi:
return kpi
if suffixes:
rows = db.query(KPIDefinition).filter(
KPIDefinition.entity_id == entity_id,
).order_by(KPIDefinition.id.asc()).all()
for suffix in suffixes:
for row in rows:
if (row.kpi_code or "").upper().endswith(suffix):
return row
return None
def find_fallback_kpi(db: Session, entity_id: int) -> Optional["KPIDefinition"]:
"""兜底 KPI(F2):优先 finance 维度,其次任意;两种都带 ORDER BY id(确定性)
修复前:`db.query(KPIDefinition).filter(entity_id==entity_id).first()` 无排序 →
同一条件两种答案(idx_entity→318 / uk_entity_kpi→437)。
"""
from app.models import KPIDefinition
kpi = db.query(KPIDefinition).filter(
KPIDefinition.entity_id == entity_id,
KPIDefinition.dimension == "finance",
).order_by(KPIDefinition.id.asc()).first()
if kpi:
return kpi
return db.query(KPIDefinition).filter(
KPIDefinition.entity_id == entity_id,
).order_by(KPIDefinition.id.asc()).first()
def get_entity_kpi_history(entity_id: int, kpi_code: str, db: Session, limit_months: int = 6) -> list:
"""获取实体某个KPI的历史值"""
from app.models import KPIDefinition, KPIValue
kpi = find_kpi(db, entity_id, [kpi_code])
if not kpi:
return []
values = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi.id,
actual_only_clause(), # 实测区口径:预估行禁进历史/趋势序列(kpi_value_scope)
KPIValue.actual_value.isnot(None),
).order_by(KPIValue.period.desc()).limit(limit_months).all()
return values
def calc_trend(values: list) -> float:
"""计算趋势系数 — 线性回归斜率 / 均值"""
if len(values) < 2:
return 0.0
vals = [v.actual_value for v in values]
n = len(vals)
avg_x = (n - 1) / 2.0
avg_y = sum(vals) / n
num = sum((i - avg_x) * (vals[i] - avg_y) for i in range(n))
den = sum((i - avg_x) ** 2 for i in range(n))
slope = num / den if den != 0 else 0
return slope / max(abs(avg_y), 1.0) * 100 # 趋势百分比
# ── B1 现金基线拆账套(2026-09-22 dama-workbench-entity-isolation-fullstack-20260922)──
# 背景:`SystemConfig.config_key` 为 unique 单行键 → `cash.current_balance` 全库仅一行,
# 所有账套共用;任一账套导入网银流水(`cash.py::_sync_cash_balance_from_ledger` 按 entity
# 算完余额后丢账套写回)即覆盖其他账套的预测基线。实测事故值 -9.3 被全账套共用。
#
# 治本(方案 A,零 DDL):键名带账套后缀 → `cash.current_balance.<entity_id>`。
# · 读:只读本账套键;本账套未设值 → None(由 KPI 实际值兜底,**不跨账套借用**)。
# 不做「回退旧全局键」——那正是本次污染通路(其余账套会读到别家的基线)。
# · 写:只写本账套键(调用方必须传会话 entity)。
# · 旧值搬运:一次性落到「当前生产账套」名下(脚本
# `scripts/migrate_cash_balance_key_entity_20260922.py`,--check/--apply/--down)。
# 回退路径:--down 删新键(旧全局键原样保留,回退老码即可继续读)。
CASH_BALANCE_KEY_PREFIX = "cash.current_balance"
def cash_balance_key(entity_id: int) -> str:
"""账套维度的现金基线键名:cash.current_balance.<entity_id>"""
return f"{CASH_BALANCE_KEY_PREFIX}.{int(entity_id)}"
def get_current_cash_balance(db: Session, entity_id: int) -> Optional[float]:
"""获取当前现金余额(万元)— 优先级:KPI实际值 > 本账套SystemConfig键 > None
账套隔离(B1,2026-09-22):只读 `cash.current_balance.<entity_id>`。
本账套未设基线 → 返回 None(由 KPI 实际值兜底 / 预测引擎默认值),
**不回退旧全局键 `cash.current_balance`**——回退即恢复跨账套借用。
"""
from app.models import SystemConfig, KPIValue
# C3(2026-09-19 dama-20260919):补传实体前缀后缀候选(与 F2 的应收/应付路径一致)
# 修复前漏传 → 博海/演示企业一条候选都不命中 → 回退 SystemConfig.cash.current_balance(全库唯一键 -9.3)
kpi = find_kpi(db, entity_id, KPI_CODE_CANDIDATES["cash_balance"],
KPI_CODE_SUFFIXES["cash_balance"])
if kpi:
latest_cash = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi.id,
actual_only_clause(), # 实测区口径:预估行禁作现金余额依据(kpi_value_scope)
KPIValue.actual_value.isnot(None),
).order_by(KPIValue.period.desc()).first()
if latest_cash and latest_cash.actual_value is not None:
return float(latest_cash.actual_value)
cfg = db.query(SystemConfig).filter(
SystemConfig.config_key == cash_balance_key(entity_id)
).first()
if cfg and cfg.config_value:
try:
return float(cfg.config_value)
except Exception:
pass
return None
def set_current_cash_balance(db: Session, value: float, entity_id: int) -> float:
"""设置当前现金余额(写入本账套键 `cash.current_balance.<entity_id>`,供预测引擎使用)
entity_id 为**必传**(2026-09-22 B1):签名中不设默认值,强制调用方带上会话账套,
防「漏传 → 落默认账套」重演本次污染。全部调用点见 cash.py(POST /balance、
网银流水导入联动)。
"""
from app.models import SystemConfig
if entity_id is None:
raise ValueError("set_current_cash_balance 必须传 entity_id(账套隔离,2026-09-22 B1)")
key = cash_balance_key(entity_id)
cfg = db.query(SystemConfig).filter(
SystemConfig.config_key == key
).first()
if cfg:
cfg.config_value = str(value)
else:
cfg = SystemConfig(
config_key=key,
config_value=str(value),
description=f"当前现金余额(万元),资金预测基线 — 账套 {int(entity_id)}",
)
db.add(cfg)
db.commit()
return value
def forecast_cash_flow(
entity_id: int,
db: Session,
days: int = 30,
current_cash: Optional[float] = None,
warning_line: Optional[float] = None,
critical_line: Optional[float] = None,
) -> dict:
"""
预测未来30天现金流
算法:
1. 获取历史经营现金流、应收、应付趋势
2. 推算每日现金流入/流出
3. 生成每日预测值+置信区间
阈值(G1,2026-10-17):warning_line / critical_line 传 None 时取本账套配置
(alert_rules: cash_gap_threshold),未配置则默认 20/10 万;显式传入优先(便于复算对拍)。
"""
from app.models import KPIDefinition, KPIValue
if warning_line is None or critical_line is None:
cfg_warning, cfg_critical = get_cash_gap_thresholds(db, entity_id)
warning_line = cfg_warning if warning_line is None else warning_line
critical_line = cfg_critical if critical_line is None else critical_line
# 获取当前现金余额
if current_cash is None:
base_cash = get_current_cash_balance(db, entity_id)
if base_cash is None:
base_cash = 30.0 # 默认假设30万
else:
base_cash = current_cash
# 获取经营现金流历史
ocf_history = get_entity_kpi_history(entity_id, KPI_CODE_CANDIDATES["operating_cash_flow"][0], db) or \
get_entity_kpi_history(entity_id, KPI_CODE_CANDIDATES["operating_cash_flow"][1], db) or \
get_entity_kpi_history(entity_id, KPI_CODE_CANDIDATES["operating_cash_flow"][2], db)
ocf_trend = calc_trend(ocf_history)
# 获取应收历史
ar_history = get_entity_kpi_history(entity_id, KPI_CODE_CANDIDATES["receivables"][0], db) or \
get_entity_kpi_history(entity_id, KPI_CODE_CANDIDATES["receivables"][1], db)
ar_trend = calc_trend(ar_history)
# 获取应付历史
ap_history = get_entity_kpi_history(entity_id, KPI_CODE_CANDIDATES["payables"][0], db) or \
get_entity_kpi_history(entity_id, KPI_CODE_CANDIDATES["payables"][1], db)
ap_trend = calc_trend(ap_history)
# 计算日均现金变化
ocf_avg = sum(v.actual_value for v in ocf_history) / max(len(ocf_history), 1) / 30.0 if ocf_history else 0.5
# 预测逻辑:趋势影响 + 季节性(月底回款高峰)
forecast = []
cash = base_cash
today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
for day_offset in range(1, days + 1):
d = today + timedelta(days=day_offset)
day_of_month = d.day
is_month_end = day_of_month >= 25
# 每日现金变化 = 经营现金流日均值 × (1 + 趋势调整) + 季节因子
trend_factor = 1.0 + ocf_trend / 100.0
daily_change = ocf_avg * trend_factor
# 月底回款高峰
if is_month_end:
daily_change += ocf_avg * 0.3 # 月底多30%回款
# 周末效应
if d.weekday() >= 5:
daily_change *= 0.5 # 周末收支减半
cash += daily_change
# 置信区间:随时间增加而扩大
confidence_band = 1.0 + day_offset * 0.08 # 每过1天,区间扩大8%
std = max(abs(daily_change) * confidence_band, 0.5)
lower = cash - std * 0.5
upper = cash + std * 0.5
# 预警状态
if cash < critical_line:
status = "red"
elif cash < warning_line:
status = "yellow"
else:
status = "green"
forecast.append({
"date": d.strftime("%Y-%m-%d"),
"day_offset": day_offset,
"predicted_cash": round(cash, 2),
"lower_bound": round(max(lower, 0), 2),
"upper_bound": round(upper, 2),
"alert_status": status,
})
# 整体预警
min_cash = min(f["predicted_cash"] for f in forecast)
min_date = next(f["date"] for f in forecast if f["predicted_cash"] == min_cash)
suggestions = []
if min_cash < critical_line:
suggestions.append({
"type": "critical",
"message": f"预计{min_date}现金余额降至{min_cash:.1f}万,低于警戒线{critical_line}万",
"actions": [
"立即催收大额应收账款",
"暂停非必要支出",
"准备短期融资安排",
]
})
elif min_cash < warning_line:
suggestions.append({
"type": "warning",
"message": f"预计{min_date}现金余额降至{min_cash:.1f}万,低于关注线{warning_line}万",
"actions": [
"加快应收账款回款",
"控制采购付款节奏",
"评估短期现金流压力",
]
})
return {
"entity_id": entity_id,
"base_cash": round(base_cash, 2),
"days": days,
"forecast": forecast,
"min_cash": round(min_cash, 2),
"min_cash_date": min_date,
"trends": {
"operating_cash_flow_trend_pct": round(ocf_trend, 2),
"receivables_trend_pct": round(ar_trend, 2),
"payables_trend_pct": round(ap_trend, 2),
},
"suggestions": suggestions,
}
def save_forecast_to_db(entity_id: int, forecast_data: dict, db: Session):
"""将预测结果保存到数据库"""
from app.models import CashForecast
for f in forecast_data["forecast"]:
forecast_date = datetime.strptime(f["date"], "%Y-%m-%d")
cf = CashForecast(
entity_id=entity_id,
forecast_date=forecast_date,
predicted_cash=f["predicted_cash"],
lower_bound=f["lower_bound"],
upper_bound=f["upper_bound"],
alert_status=f["alert_status"],
)
db.add(cf)
db.commit()
def calculate_accuracy(entity_id: int, db: Session) -> list:
"""计算预测准确率 — 对比上期预测 vs 本期实际"""
from app.models import CashForecast, KPIDefinition, KPIValue
# 获取实体最近的预测
forecasts = db.query(CashForecast).filter(
CashForecast.entity_id == entity_id,
).order_by(CashForecast.forecast_date.desc()).limit(90).all()
# 获取实际的现金余额KPI值
cash_kpi = db.query(KPIDefinition).filter(
KPIDefinition.kpi_code == KPI_CODES["cash_balance"],
KPIDefinition.entity_id == entity_id,
).first()
if not cash_kpi or not forecasts:
return []
actuals = db.query(KPIValue).filter(
KPIValue.kpi_id == cash_kpi.id,
actual_only_clause(), # 实测区口径:预估行禁进实际值序列(预测输入)(kpi_value_scope)
KPIValue.actual_value.isnot(None),
).order_by(KPIValue.period.desc()).limit(12).all()
actual_map = {}
for a in actuals:
try:
# period like "2026-07" -> month approx
actual_map[a.period] = a.actual_value
except:
pass
# 按月汇总预测值和实际值,计算准确率
from collections import defaultdict
monthly_forecast = defaultdict(list)
for f in forecasts:
month_key = f.forecast_date.strftime("%Y-%m")
monthly_forecast[month_key].append(f.predicted_cash)
results = []
for month, f_vals in sorted(monthly_forecast.items()):
if month in actual_map:
f_avg = sum(f_vals) / len(f_vals)
a_val = actual_map[month]
mae = abs(f_avg - a_val)
mape = abs((f_avg - a_val) / max(abs(a_val), 1)) * 100
results.append({
"period": month,
"forecast_value": round(f_avg, 2),
"actual_value": round(a_val, 2),
"mae": round(mae, 2),
"mape": round(mape, 2),
})
return results
def generate_scenario_suggestion(alert_type: str, kpi_name: str, extra: dict = None) -> dict:
"""根据预警类型生成情景建议"""
extra = extra or {}
suggestions = {
"cash_low": {
"title": "现金流紧张缓解方案",
"description": f"现金余额低于阈值,建议加快应收账款催收、控制支出、评估短期融资。",
"actions": [
f"催收大额应收账款(预计回款{extra.get('expected_receivables', '待定')}万元)",
"暂停非紧急采购和资本性支出",
"与供应商协商延长账期",
"评估银行短期授信额度",
],
"priority": "high",
},
"cash_critical": {
"title": "现金流危机应对方案",
"description": f"现金余额接近断流,需立即采取紧急措施。",
"actions": [
"立即催收所有到期应收账款",
"暂停所有非必要支出",
"紧急联系银行安排短期贷款",
"评估资产变现可能性",
],
"priority": "high",
},
"cost_high": {
"title": "成本管控优化方案",
"description": f"成本率异常偏高,建议进行成本结构分析和优化。",
"actions": [
"逐项分析成本构成,识别异常项",
"与供应商重新谈判采购价格",
"评估流程优化降本空间",
"建立费用审批红线上限",
],
"priority": "medium",
},
"revenue_drop": {
"title": "收入下滑应对方案",
"description": f"收入出现下滑趋势,建议分析原因并制定恢复计划。",
"actions": [
"分析收入下滑原因(客户流失/价格战/需求变化)",
"制定客户留存和挽回计划",
"评估新产品/新市场机会",
"优化销售激励政策",
],
"priority": "high",
},
}
sug = suggestions.get(alert_type, {
"title": "改善建议",
"description": "根据预警情况制定改善措施。",
"actions": ["分析预警原因", "制定改善计划", "跟踪执行效果"],
"priority": "medium",
})
if extra:
sug["extra"] = extra
return sug
# ══════════════════════════════════════════════════════════════
# 资金缺口预测 — 趋势引擎 + 收付款计划叠加 (资金管理智能体)
# ══════════════════════════════════════════════════════════════
def get_cash_plans(db: Session, entity_id: int, start_date: datetime, end_date: datetime) -> list:
"""获取指定日期范围内的待执行收付款计划"""
from app.models import CashPlan
return db.query(CashPlan).filter(
CashPlan.entity_id == entity_id,
CashPlan.status == "pending",
CashPlan.plan_date >= start_date,
CashPlan.plan_date <= end_date,
).order_by(CashPlan.plan_date.asc()).all()
def _budget_monthly_ocf(db: Session, entity_id: int) -> Optional[float]:
"""获取本月经营现金流预算(万元/月),用于校准预测基线"""
from app.models import BudgetPlan
kpi = find_kpi(db, entity_id, KPI_CODE_CANDIDATES["operating_cash_flow"])
if not kpi:
return None
month_key = datetime.now().strftime("%Y-%m")
bp = db.query(BudgetPlan).filter(
BudgetPlan.kpi_id == kpi.id,
BudgetPlan.period == month_key,
BudgetPlan.status == "active",
).order_by(BudgetPlan.version.desc()).first()
if bp and bp.budget_value is not None:
return float(bp.budget_value)
return None
def forecast_cash_flow_with_plans(
entity_id: int,
db: Session,
days: int = 30,
current_cash: Optional[float] = None,
warning_line: Optional[float] = None,
critical_line: Optional[float] = None,
include_plans: bool = True,
) -> dict:
"""
资金缺口预测 — 在趋势预测基础上叠加收付款计划:
1. 趋势引擎生成基线预测
2. 叠加 cash_plans 的应收(收) / 应付(付)
3. 识别资金缺口日期(余额 < 警戒线)
4. 生成缺口前3天预警点
阈值来源(G1,2026-10-17):None → 本账套配置(alert_rules: cash_gap_threshold)
→ 未配置时默认 20/10 万;显式传入值优先(复算对拍用)。响应内 `threshold_source`
明写本次取值来源,便于页面标注与回执取证。
"""
from collections import defaultdict
from app.models import CashPlan
if warning_line is None or critical_line is None:
cfg_warning, cfg_critical = get_cash_gap_thresholds(db, entity_id)
warning_line = cfg_warning if warning_line is None else warning_line
critical_line = cfg_critical if critical_line is None else critical_line
base = forecast_cash_flow(entity_id, db, days, current_cash,
warning_line=warning_line, critical_line=critical_line)
base_cash = base["base_cash"]
# 预算校准:本月经营现金流预算优先作为基线(万元/月)
budget_ocf = _budget_monthly_ocf(db, entity_id)
if budget_ocf is not None:
base["trends"]["budget_monthly_ocf"] = round(budget_ocf, 2)
# ── 收付款计划加载 ──
today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
end = today + timedelta(days=days)
plan_map = defaultdict(lambda: {"in": 0.0, "out": 0.0, "items": []})
plans = []
if include_plans:
plans = get_cash_plans(db, entity_id, today, end + timedelta(days=1))
for p in plans:
dkey = p.plan_date.strftime("%Y-%m-%d")
item = {
"id": p.id,
"amount": round(p.amount, 2),
"counterparty": p.counterparty or "",
"description": p.description or "",
}
if p.plan_type == "receive":
plan_map[dkey]["in"] += p.amount
plan_map[dkey]["items"].append({"type": "receive", **item})
else:
plan_map[dkey]["out"] += p.amount
plan_map[dkey]["items"].append({"type": "pay", **item})
# ── 逐日重算余额 ──
forecast = []
cash = base_cash
prev_predicted = base_cash
for i, f in enumerate(base["forecast"]):
dkey = f["date"]
# 趋势日净变化(与上一天预测值的差)
trend_delta = f["predicted_cash"] - prev_predicted
prev_predicted = f["predicted_cash"]
pin = round(plan_map[dkey]["in"], 2)
pout = round(plan_map[dkey]["out"], 2)
# 预算校准:有月度预算时用预算日均替代纯趋势增量
if budget_ocf is not None:
trend_delta = budget_ocf / 30.0
if f["day_offset"] % 7 in (5, 6):
trend_delta *= 0.5 # 周末减半
if dkey[-2:] >= "25":
trend_delta *= 1.3 # 月底回款高峰
cash = round(cash + trend_delta + pin - pout, 2)
net_flow = round(trend_delta + pin - pout, 2)
if cash < critical_line:
status = "red"
elif cash < warning_line:
status = "yellow"
else:
status = "green"
forecast.append({
"date": dkey,
"day_offset": f["day_offset"],
"predicted_cash": cash,
"planned_in": pin,
"planned_out": pout,
"trend_delta": round(trend_delta, 2),
"net_flow": net_flow,
"lower_bound": round(max(cash - abs(net_flow) * 0.5 - 0.5, 0), 2),
"upper_bound": round(cash + abs(net_flow) * 0.5 + 0.5, 2),
"alert_status": status,
"gap": cash < warning_line,
"plans": plan_map[dkey]["items"],
})
# ── 资金缺口日期 ──
gap_dates = [
{"date": f["date"], "predicted_cash": f["predicted_cash"],
"gap_amount": round(warning_line - f["predicted_cash"], 2),
"level": "red" if f["predicted_cash"] < critical_line else "yellow"}
for f in forecast if f["gap"]
]
# ── 缺口前3天预警(每个连续缺口区间只预警一次,取区间首日) ──
pre_alerts = []
prev_was_gap = False
for idx, f in enumerate(forecast):
is_gap = f["gap"]
gap_run_start = is_gap and not prev_was_gap
prev_was_gap = is_gap
if not gap_run_start:
continue
# 找到该连续缺口区间的最后一天及区间内最低余额(最严重时点)
run_end = forecast[idx]
run_min = forecast[idx]["predicted_cash"]
run_min_date = forecast[idx]["date"]
for j in range(idx + 1, len(forecast)):
if forecast[j]["gap"]:
run_end = forecast[j]
if forecast[j]["predicted_cash"] < run_min:
run_min = forecast[j]["predicted_cash"]
run_min_date = forecast[j]["date"]
else:
break
for lead in (3, 1): # 缺口前3天(主要)、前1天(紧急)
pre_idx = idx - lead
if pre_idx < 0:
continue
pf = forecast[pre_idx]
if pf["alert_status"] == "green" or lead == 3:
pre_alerts.append({
"alert_date": pf["date"],
"alert_offset": pf["day_offset"],
"gap_date": f["date"],
"gap_cash": f["predicted_cash"],
"gap_amount": round(warning_line - f["predicted_cash"], 2),
"worst_cash": round(run_min, 2),
"worst_date": run_min_date,
"lead_days": lead,
"level": "red" if run_min < critical_line else "yellow",
})
break
# 到期未收款(逾期)
overdue_all = db.query(CashPlan).filter(
CashPlan.entity_id == entity_id,
CashPlan.plan_type == "receive",
CashPlan.status == "pending",
CashPlan.plan_date < today,
).order_by(CashPlan.plan_date.asc()).all()
# F4b(2026-09-13): 展示口径与落库口径(check_cash_alerts)同源 —— 同一份快照批次判据,
# 避免同一次响应内「写入 0 条 / 展示 N 笔」自相矛盾;raw/excluded 字段保留可追溯。
overdue_scope = summarize_overdue_receives(overdue_all)
# 未来7天到期(同样剔除快照批次行:其 plan_date 是导入基准日,不是真实到期日)
upcoming_all = db.query(CashPlan).filter(
CashPlan.entity_id == entity_id,
CashPlan.status == "pending",
CashPlan.plan_date >= today,
CashPlan.plan_date <= today + timedelta(days=7),
).order_by(CashPlan.plan_date.asc()).all()
if overdue_scope["mode"] == "per_customer":
upcoming_7d = upcoming_all
else:
upcoming_7d = [p for p in upcoming_all if (p.source or "") not in set(overdue_scope["sources"])]
# 整体结论
min_cash = min(f["predicted_cash"] for f in forecast) if forecast else base_cash
min_date = next((f["date"] for f in forecast if f["predicted_cash"] == min_cash), "")
suggestions = list(base.get("suggestions", []))
if gap_dates:
first_gap = gap_dates[0]
if first_gap["level"] == "red":
suggestions.insert(0, {
"type": "critical",
"message": f"预计{first_gap['date']}现金余额降至{first_gap['predicted_cash']:.1f}万,低于警戒线{warning_line:.0f}万,存在资金断流风险",
"actions": ["立即催收大额应收账款", "暂停非必要支出", "准备短期融资安排"],
})
else:
suggestions.insert(0, {
"type": "warning",
"message": f"预计{first_gap['date']}现金余额降至{first_gap['predicted_cash']:.1f}万,低于警戒线{warning_line:.0f}万",
"actions": ["加快应收账款回款", "控制采购付款节奏", "评估短期现金流压力"],
})
if overdue_scope["count"]:
total_overdue = overdue_scope["amount"]
msg = f"有{overdue_scope['count']}笔应收款到期未收,合计{total_overdue:.1f}万"
if overdue_scope["snapshot_rows_excluded"]:
msg += (f"(另有余额快照批次{overdue_scope['snapshot_rows_total']}行/"
f"{overdue_scope['snapshot_rows_total_amount']:.1f}万未计入逐户口径:"
f"plan_date 为导入基准日,见 balance_snapshot)")
suggestions.append({
"type": "warning",
"message": msg,
"actions": ["逐笔催收到期应收账款", "评估客户信用风险"],
})
return {
"entity_id": entity_id,
"base_cash": round(base_cash, 2),
"days": days,
"warning_line": warning_line,
"critical_line": critical_line,
"threshold_source": describe_cash_threshold_source(db, entity_id),
"budget_monthly_ocf": budget_ocf,
"forecast": forecast,
"gap_dates": gap_dates,
"pre_alerts": pre_alerts,
"min_cash": round(min_cash, 2),
"min_cash_date": min_date,
"trends": base.get("trends", {}),
"suggestions": suggestions,
"summary": {
"total_planned_in": round(sum(p.amount for p in plans if p.plan_type == "receive"), 2),
"total_planned_out": round(sum(p.amount for p in plans if p.plan_type == "pay"), 2),
"plan_count": len(plans),
# F4b: 展示口径 = 落库口径(快照批次按 mode 处理),不注释掉即视为对外契约
"overdue_receive_count": overdue_scope["count"],
"overdue_receive_amount": overdue_scope["amount"],
"upcoming_7d_count": len(upcoming_7d),
# F4b 透明可追溯字段(不隐藏数据:全集 + 剔除量 + 批次明细)
"overdue_receive_count_raw": overdue_scope["raw_count"],
"overdue_receive_amount_raw": overdue_scope["raw_amount"],
"snapshot_rows_excluded": overdue_scope["snapshot_rows_excluded"],
"snapshot_rows_excluded_amount": overdue_scope["snapshot_rows_excluded_amount"],
"snapshot_rows_total": overdue_scope["snapshot_rows_total"],
"snapshot_rows_total_amount": overdue_scope["snapshot_rows_total_amount"],
"snapshot_batches": overdue_scope["snapshot_batches"],
"snapshot_mode": overdue_scope["mode"],
},
}
def check_cash_alerts(db: Session, entity_id: int = 1) -> dict:
"""资金预警 — 缺口前3天预警 + 到期未收款提醒,写入预警中心(kpi_alerts)
2026-09-13 修复(任务 dama-cashplan-alert-engine-fix-20260913 / DAMA 审计 fin-alert-flood-e2):
F1 两处 KPIAlert 显式写 entity_id(此前不传 → 列默认值 1 → 博海预警错挂酣客工作台)
F2 KPI 选择实体感知(候选码 + 实体前缀后缀码)+ 兜底显式 ORDER BY id
(此前博海走无排序兜底 → 命中 uk_entity_kpi 索引必中 BH_ADM_EXPENSE(437 管理费用))
F3 去重命中时刷新文本/等级/逾期天数/归属(此前直接 continue → 金额与天数永久冻结在首版)
F4 余额快照批次行按开关处理(默认 skip,不再逐户生成伪"到期未收款")
R1(裁定 2026-09-19):demo 演示账套不产生业务预警 → 直接返回 skipped。
"""
import json as _json
from app.models import KPIAlert, CashPlan, KPIDefinition
from app.utils.alert_entity_scope import is_excluded_entity
if is_excluded_entity(db, entity_id):
logger.info("资金预警检查跳过(demo 账套 R1): entity_id=%s", entity_id)
return {"entity_id": entity_id, "skipped": True, "reason": "R1_demo_entity_excluded",
"new_alerts": [], "refreshed": []}
result = forecast_cash_flow_with_plans(entity_id, db, days=30)
new_alerts = []
refreshed = []
snapshot_skipped = []
snapshot_mode = get_balance_snapshot_mode()
snapshot_sources = get_balance_snapshot_sources()
# 兜底KPI(F2):现金KPI → 经营现金流KPI → 该实体 finance 维度首个(全部 ORDER BY id,确定性)
kpi = find_kpi(db, entity_id, KPI_CODE_CANDIDATES["cash_balance"], KPI_CODE_SUFFIXES["cash_balance"]) or \
find_kpi(db, entity_id, KPI_CODE_CANDIDATES["operating_cash_flow"], KPI_CODE_SUFFIXES["operating_cash_flow"]) or \
find_fallback_kpi(db, entity_id)
ar_kpi = find_kpi(db, entity_id, KPI_CODE_CANDIDATES["receivables"], KPI_CODE_SUFFIXES["receivables"]) or kpi
def _exists(msg: str) -> bool:
return db.query(KPIAlert).filter(
KPIAlert.entity_id == entity_id,
KPIAlert.alert_message == msg,
KPIAlert.status.in_(["pending", "processing"]),
).first() is not None
def _to_dict(raw) -> dict:
try:
d = _json.loads(raw) if raw else {}
except Exception:
d = {}
return d if isinstance(d, dict) else {}
def _refresh(existing, kpi_id: int, level: str, msg: str, suggestion: str) -> bool:
"""F3: 按现值刷新已存在的预警(归属/文本/等级/建议),返回是否发生变化"""
changed = False
if existing.entity_id != entity_id:
existing.entity_id = entity_id
changed = True
if existing.kpi_id != kpi_id:
existing.kpi_id = kpi_id
changed = True
if existing.alert_level != level:
existing.alert_level = level
changed = True
if existing.alert_message != msg[:500]:
existing.alert_message = msg[:500]
changed = True
if existing.suggestion != suggestion:
existing.suggestion = suggestion
changed = True
return changed
# ── 1. 缺口前3天预警 ──
if kpi:
for pre in result["pre_alerts"]:
level = pre["level"]
msg = (f"【资金缺口预警】预计{pre['gap_date']}现金余额降至{pre['gap_cash']:.1f}万"
f"(低于警戒线{result['warning_line']:.0f}万,缺口{pre['gap_amount']:.1f}万),"
f"请于{pre['alert_date']}前(提前{pre['lead_days']}天)安排资金")
if _exists(msg):
continue
alert = KPIAlert(
entity_id=entity_id, # F1: 此前未传 → 列默认值 1
kpi_id=kpi.id,
alert_level=level,
alert_message=msg[:500],
alert_type="forecast",
status="pending",
suggestion=_json.dumps({
"actions": ["加快应收账款回款", "控制付款节奏", "评估短期融资"],
"gap_date": pre["gap_date"],
"gap_amount": pre["gap_amount"],
}, ensure_ascii=False),
)
db.add(alert)
new_alerts.append({"type": "gap_forecast", "level": level, "message": msg})
logger.info(f"资金缺口预警: {msg}")
# ── 2. 到期未收款提醒 ──
if ar_kpi:
today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
overdue = db.query(CashPlan).filter(
CashPlan.entity_id == entity_id,
CashPlan.plan_type == "receive",
CashPlan.status == "pending",
CashPlan.plan_date < today,
).order_by(CashPlan.plan_date.asc()).all()
# F4: 余额快照批次(plan_date = 导入基准日,非真实到期日)单独处理
# F4b: 复用 split_snapshot_plans —— 与 summary/suggestions 展示口径同一判据(单一事实源)
overdue, snapshot_rows = split_snapshot_plans(overdue, snapshot_sources)
# 去重索引一次建好(修复原逐行全表扫描),去重键 = suggestion.plan_id / suggestion.snapshot_source
existing_alerts = db.query(KPIAlert).filter(
KPIAlert.alert_type == "cash_plan",
KPIAlert.status.in_(["pending", "processing"]),
).all()
by_plan = {}
by_snapshot = {}
for ea in existing_alerts:
es = _to_dict(ea.suggestion)
if es.get("plan_id") is not None:
by_plan.setdefault(es["plan_id"], ea)
if es.get("snapshot_source"):
by_snapshot.setdefault(es["snapshot_source"], ea)
if snapshot_mode == "per_customer":
# 旧行为:批次行也逐户生成(仅显式切换时启用)
overdue = overdue + snapshot_rows
elif snapshot_mode == "skip":
if snapshot_rows:
snapshot_skipped = [{"plan_id": p.id, "source": p.source,
"amount": p.amount, "plan_date": str(p.plan_date)} for p in snapshot_rows]
logger.info(f"余额快照批次 {len(snapshot_rows)} 行按 skip 模式不生成逐户预警 "
f"(entity={entity_id}, mode={snapshot_mode})")
elif snapshot_mode == "aggregate":
grouped = {}
for p in snapshot_rows:
grouped.setdefault(p.source or "unknown", []).append(p)
for src, rows in sorted(grouped.items()):
total = sum((p.amount or 0) for p in rows)
oldest = min(p.plan_date for p in rows)
days_late = (today - oldest).days
level = "red" if days_late >= 7 else "yellow"
msg = (f"【到期未收款·批次汇总】{src} 应收逾期{len(rows)}户 合计{total:.2f}万 "
f"最早到期{oldest.strftime('%Y-%m-%d')}({days_late}天)")
suggestion = _json.dumps({
"actions": ["核对应收台账", "按客户逐户催收", "确认批次到期口径"],
"snapshot_source": src,
"plan_count": len(rows),
"total_amount": round(total, 2),
"days_late": days_late,
}, ensure_ascii=False)
existing = by_snapshot.get(src)
if existing is not None:
if _refresh(existing, ar_kpi.id, level, msg, suggestion):
refreshed.append({"type": "overdue_snapshot", "snapshot_source": src,
"alert_id": existing.id, "message": msg})
continue
alert = KPIAlert(
entity_id=entity_id,
kpi_id=ar_kpi.id,
alert_level=level,
alert_message=msg[:500],
alert_type="cash_plan",
status="pending",
suggestion=suggestion,
)
db.add(alert)
new_alerts.append({"type": "overdue_snapshot", "level": level, "message": msg})
logger.info(f"到期未收款批次汇总: {msg}")
for p in overdue:
days_late = (today - p.plan_date).days
level = "red" if days_late >= 7 else "yellow"
msg = (f"【到期未收款】应收款{p.counterparty or '客户'} {p.amount:.1f}万 "
f"原计划{p.plan_date.strftime('%Y-%m-%d')}到期,已逾期{days_late}天")
suggestion = _json.dumps({
"actions": ["联系客户催收", "评估坏账风险", "调整信用政策"],
"plan_id": p.id,
"days_late": days_late,
}, ensure_ascii=False)
existing = by_plan.get(p.id)
if existing is not None:
# F3: 命中不 skip —— 按现值刷新金额/逾期天数/等级/归属
if _refresh(existing, ar_kpi.id, level, msg, suggestion):
refreshed.append({"type": "overdue_receive", "plan_id": p.id,
"alert_id": existing.id, "message": msg})
continue
alert = KPIAlert(
entity_id=entity_id, # F1: 此前未传 → 列默认值 1(错挂酣客工作台)
kpi_id=ar_kpi.id,
alert_level=level,
alert_message=msg[:500],
alert_type="cash_plan",
status="pending",
suggestion=suggestion,
)
db.add(alert)
new_alerts.append({"type": "overdue_receive", "level": level, "message": msg})
logger.info(f"到期未收款提醒: {msg}")
db.commit()
return {
"entity_id": entity_id,
"new_alerts": len(new_alerts),
"alerts": new_alerts,
"refreshed_alerts": len(refreshed),
"refreshed": refreshed,
"balance_snapshot": {
"mode": snapshot_mode,
"skipped_rows": len(snapshot_skipped),
"sources": sorted(snapshot_sources),
},
"kpi_selection": {
"ar_kpi_id": ar_kpi.id if ar_kpi else None,
"ar_kpi_code": ar_kpi.kpi_code if ar_kpi else None,
"cash_kpi_id": kpi.id if kpi else None,
"cash_kpi_code": kpi.kpi_code if kpi else None,
},
"gap_dates": result["gap_dates"],
"pre_alerts": result["pre_alerts"],
"summary": result["summary"],
}