任务 dama-workbench-entity-isolation-fullstack-20260922(DAMA 诊断 / 项目Bot batch5 §B / 任总 2026-09-22「都批」) B1 现金基线拆账套(零 DDL) - cash_forecast_engine: 键 cash.current_balance → cash.current_balance.<entity_id>; 读只读本账套键、不做旧键回退;set_* 签名 entity_id 必传(漏传 TypeError) - cash.py: POST /balance 挂 get_entity_id;网银流水联动写回带账套 B3(a) alignment_config 拆账套(零 DDL) - alignment.py: GET/POST /config、GET /tree 只读 alignment_config.<entity_id>;未配置=configured:false B3(b) 预警 KPI 归属兜底 - bot_bridge_v2: 去掉 kpi_id or 1 兜底;本账套无 KPI 定义 → 跳过建预警 + OperationLog 留痕 (kpi_alerts.kpi_id NOT NULL ⇒ 方案A 置 NULL 不可行,走方案B 跳过+审计) B2 缺列五表分表处置 - notification_logs: 经 alert_id → kpi_alerts.entity_id join 归属(无父键行不返回) - notification_channels / okr_templates: 集团共享口径写入 docstring(有意不过滤) - system_configs: 由 B1/B3(a) 拆键覆盖;kpi_data_quality_logs 沿用 batch5 的 join 收口 P0 语义收敛 - tenant.py: current_tenant 收敛为项目Bot A2A 状态记录,GET 仅 it/ceo;POST 补 operation_logs 审计 证据:新增/改写用例 22 条;全量 1370 passed(基线 1348)
1053 lines
45 KiB
Python
1053 lines
45 KiB
Python
"""现金流预测引擎 — 根据历史KPI数据推算未来30天现金流"""
|
||
import logging
|
||
import os
|
||
from datetime import datetime, timedelta
|
||
from typing import Optional, TYPE_CHECKING
|
||
from sqlalchemy.orm import Session
|
||
import math
|
||
import random
|
||
|
||
if TYPE_CHECKING:
|
||
from app.models import KPIDefinition
|
||
|
||
logger = logging.getLogger("cma.cash_forecast")
|
||
|
||
# 默认现金阈值(万元)
|
||
DEFAULT_CASH_WARNING = 20.0 # 黄灯 — 低于20万
|
||
DEFAULT_CASH_CRITICAL = 10.0 # 红灯 — 低于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,
|
||
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,
|
||
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,
|
||
) -> dict:
|
||
"""
|
||
预测未来30天现金流
|
||
|
||
算法:
|
||
1. 获取历史经营现金流、应收、应付趋势
|
||
2. 推算每日现金流入/流出
|
||
3. 生成每日预测值+置信区间
|
||
"""
|
||
from app.models import KPIDefinition, KPIValue
|
||
|
||
# 获取当前现金余额
|
||
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 < DEFAULT_CASH_CRITICAL:
|
||
status = "red"
|
||
elif cash < DEFAULT_CASH_WARNING:
|
||
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 < DEFAULT_CASH_CRITICAL:
|
||
suggestions.append({
|
||
"type": "critical",
|
||
"message": f"预计{min_date}现金余额降至{min_cash:.1f}万,低于警戒线{DEFAULT_CASH_CRITICAL}万",
|
||
"actions": [
|
||
"立即催收大额应收账款",
|
||
"暂停非必要支出",
|
||
"准备短期融资安排",
|
||
]
|
||
})
|
||
elif min_cash < DEFAULT_CASH_WARNING:
|
||
suggestions.append({
|
||
"type": "warning",
|
||
"message": f"预计{min_date}现金余额降至{min_cash:.1f}万,低于关注线{DEFAULT_CASH_WARNING}万",
|
||
"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,
|
||
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: float = DEFAULT_CASH_WARNING,
|
||
critical_line: float = DEFAULT_CASH_CRITICAL,
|
||
include_plans: bool = True,
|
||
) -> dict:
|
||
"""
|
||
资金缺口预测 — 在趋势预测基础上叠加收付款计划:
|
||
1. 趋势引擎生成基线预测
|
||
2. 叠加 cash_plans 的应收(收) / 应付(付)
|
||
3. 识别资金缺口日期(余额 < 警戒线)
|
||
4. 生成缺口前3天预警点
|
||
"""
|
||
from collections import defaultdict
|
||
from app.models import CashPlan
|
||
|
||
base = forecast_cash_flow(entity_id, db, days, current_cash)
|
||
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,
|
||
"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"],
|
||
}
|