fix(alerts): cash_plan 到期未收款预警引擎修复 — entity归属/KPI实体感知/余额快照批次/去重刷新/端点鉴权 (dama-cashplan-alert-engine-fix-20260913)

F1 两处 KPIAlert 显式写 entity_id(列默认 1 → 博海预警错挂酣客);顺带修 deviation_engine/
   alert_generator/deviation_push/bot_bridge/bot_bridge_v2 同表创建点
F2 KPI 选择实体感知:候选码 ∪ 实体前缀后缀码(_AR_BALANCE/_CASH_BALANCE/_AP_BALANCE)
   + 兜底 ORDER BY id(此前命中 uk_entity_kpi 必中 BH_ADM_EXPENSE 437 管理费用)
F3 去重命中改为刷新 alert_message/等级/suggestion.days_late/归属(此前 continue → 文本冻结首版)
F4 余额快照批次(bohai_*_20260630 / receivables_migrate)开关:默认 skip 不逐户,另备
   aggregate(按批次聚合1条) / per_customer(旧行为)
F5 POST /api/cma/admin/alerts/check 补鉴权(无 token 401)+ 可选 entity_id 只跑指定账套
This commit is contained in:
Hermes CI Fix
2026-09-13 00:37:10 +08:00
parent a145ab1da1
commit 59e65a68b9
9 changed files with 741 additions and 39 deletions
+1 -1
View File
@@ -713,7 +713,7 @@ def bot_kpi_value_with_check(data: dict, db: Session = Depends(get_db), bot: dic
KPIAlert.kpi_id == kpi_id, KPIAlert.kpi_value_id == kv.id,
KPIAlert.alert_level == level, KPIAlert.status == "pending").first()
if not dup:
db.add(KPIAlert(kpi_id=kpi_id, kpi_value_id=kv.id, alert_level=level,
db.add(KPIAlert(entity_id=entity_id, kpi_id=kpi_id, kpi_value_id=kv.id, alert_level=level,
alert_message=msg, status="pending", alert_type="bot"))
alerts.append({"rule_id": rule.id, "rule_type": rule.rule_type,
"level": level, "message": msg})
+1
View File
@@ -380,6 +380,7 @@ def receive_mpm_result(
# 创建预警
alert_msg = f"⚠️ {kpi_name}异常: {field_value:.1f}% (阈值: {threshold}%) [entity={entity.short_name or entity.name}, period={period}]"
alert = KPIAlert(
entity_id=entity_id, # 2026-09-13 F1: 显式写归属
kpi_id=kpi_id or 1, # fallback to first KPI if not found
alert_level="red",
alert_message=alert_msg,
+1
View File
@@ -131,6 +131,7 @@ def push_deviation_to_map(
if alert_level:
alert = KPIAlert(
entity_id=kpi.entity_id or 1, # 2026-09-13 F1: 显式写归属
kpi_id=kpi.id,
alert_level=alert_level,
alert_message=alert_msg,
+36 -6
View File
@@ -1,10 +1,12 @@
"""管理会计OS — 主入口"""
import logging
from fastapi import FastAPI, Request, Depends
from typing import Optional
from fastapi import FastAPI, Request, Depends, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from dotenv import load_dotenv
from app.database import init_db
from app.database import init_db, get_db
from app.api import auth, kpis, kpi_governance, templates, maps, dashboard, data, alerts, ai_analysis, ai_suggestions, alert_rules, users, thresholds, notifications, permissions, action_plans, alignment, org, objectives, versions, budget, cost, predict, reports, security, knowledge, bot_bridge, bot_bridge_v2, bot_suggestions, lead, tenant, customer_dashboard, deviation_push, budget_generate, knowledge_articles, kpi_causality, data_quality, bi_reports, entities, bsc_layers, okr, okr_templates, subjects, driver_budget, bot_kpis, ontology, bot_iron_law, analysis_results, expenses, cash, tax_compliance, verify, growth_quality, products, data_classification, value_sources, zero_based, derivation_rules, cash_classify, cashflow_structure, cash_vouchers_json
from app.utils.cache import clear_all as clear_cache, delete as delete_cache
from scripts.erp_sync import run_sync as run_erp_sync
@@ -114,6 +116,22 @@ _ERP_CHANNEL_DISABLED_REASON = ("ERP直连同步通道已于2026-08-31停用(e
"财务原始数据真实通道为 zxt 智享通注入(source_type='zxt-sync'),本端点不再可用。")
# ── admin 端点鉴权(2026-09-13)──────────────────────────────
# 未带 token → 显式 401(缺少认证),校验逻辑仍完全委托 require_auth(无效 token → 401)。
# 说明:FastAPI 的 HTTPBearer(auto_error=True) 对"缺 header"返回 403;本端点验收要求 401,
# 故用 auto_error=False 的实例自行判空,不改动全局 require_auth(避免其他端点行为漂移)。
_optional_bearer = HTTPBearer(auto_error=False)
def require_auth_401(
credentials: Optional[HTTPAuthorizationCredentials] = Depends(_optional_bearer),
db=Depends(get_db),
):
if credentials is None or not credentials.credentials:
raise HTTPException(status_code=401, detail="未认证:缺少 Bearer token")
return require_auth(credentials=credentials, db=db)
@app.post("/api/cma/admin/erp-sync")
def admin_erp_sync(user=Depends(require_auth), kpi_codes: str = None):
"""[已停用] 手动触发ERP数据同步 — ERP 直连通道 2026-08-31 停用,返回 410"""
@@ -127,8 +145,12 @@ def admin_erp_sync_dry_run(user=Depends(require_auth), kpi_codes: str = None):
@app.post("/api/cma/admin/alerts/check")
def admin_check_alerts():
"""手动触发预警检查(含资金管理预警:缺口前3天 + 到期未收款)"""
def admin_check_alerts(entity_id: Optional[int] = None, user=Depends(require_auth_401)):
"""手动触发预警检查(含资金管理预警:缺口前3天 + 到期未收款)
- 2026-09-13 安全修复:补鉴权(与同文件 /admin/erp-sync 等 admin 端点一致);
未带 token 显式返回 401(require_auth_401,语义修正:401=未认证 / 403=无权)
- 新增可选 entity_id:只跑指定实体(不传=全部 active 实体,兼容原行为)
"""
from app.database import get_session_local
from scripts.alert_generator import generate_and_push
from app.utils.cash_forecast_engine import check_cash_alerts
@@ -136,10 +158,18 @@ def admin_check_alerts():
db = get_session_local()()
try:
result = generate_and_push(db)
# 资金管理预警 — 对每个激活企业执行
# 资金管理预警 — 指定实体或全部激活企业
cash_result = None
try:
entities = db.query(Entity).filter(Entity.status == "active").all()
if entity_id is not None:
target = db.query(Entity).filter(
Entity.id == entity_id, Entity.status == "active").first()
if not target:
return JSONResponse(status_code=404,
content={"detail": f"企业不存在或未激活: entity_id={entity_id}"})
entities = [target]
else:
entities = db.query(Entity).filter(Entity.status == "active").all()
cash_list = []
for e in entities:
cash_list.append(check_cash_alerts(db, entity_id=e.id))
+218 -32
View File
@@ -1,5 +1,6 @@
"""现金流预测引擎 — 根据历史KPI数据推算未来30天现金流"""
import logging
import os
from datetime import datetime, timedelta
from typing import Optional, TYPE_CHECKING
from sqlalchemy.orm import Session
@@ -31,20 +32,92 @@ KPI_CODE_CANDIDATES = {
"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)
}
def find_kpi(db: Session, entity_id: int, codes: list) -> Optional["KPIDefinition"]:
"""按候选编码列表查找KPI,返回第一个命中的"""
# ── 余额快照批次(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)
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,
).first()
).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
@@ -623,25 +696,65 @@ def forecast_cash_flow_with_plans(
def check_cash_alerts(db: Session, entity_id: int = 1) -> dict:
"""资金预警 — 缺口前3天预警 + 到期未收款提醒,写入预警中心(kpi_alerts)"""
"""资金预警 — 缺口前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,不再逐户生成伪"到期未收款")
"""
import json as _json
from app.models import KPIAlert, CashPlan, KPIDefinition
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:现金KPI → 经营现金流KPI → 该实体任意KPI
kpi = find_kpi(db, entity_id, KPI_CODE_CANDIDATES["cash_balance"]) or \
find_kpi(db, entity_id, KPI_CODE_CANDIDATES["operating_cash_flow"]) or \
db.query(KPIDefinition).filter(KPIDefinition.entity_id == entity_id).first()
ar_kpi = find_kpi(db, entity_id, KPI_CODE_CANDIDATES["receivables"]) or kpi
# 兜底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"]:
@@ -652,6 +765,7 @@ def check_cash_alerts(db: Session, entity_id: int = 1) -> dict:
if _exists(msg):
continue
alert = KPIAlert(
entity_id=entity_id, # F1: 此前未传 → 列默认值 1
kpi_id=kpi.id,
alert_level=level,
alert_message=msg[:500],
@@ -676,40 +790,99 @@ def check_cash_alerts(db: Session, entity_id: int = 1) -> dict:
CashPlan.status == "pending",
CashPlan.plan_date < today,
).order_by(CashPlan.plan_date.asc()).all()
# F4: 余额快照批次(plan_date = 导入基准日,非真实到期日)单独处理
snapshot_rows = [p for p in overdue if (p.source or "") in snapshot_sources]
overdue = [p for p in overdue if (p.source or "") not in 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}天未收回")
# 2026-08-30修复: 按plan_id去重(消息含"已逾期X天"动态文本,全文匹配会每天生成重复预警)
existing_plan_alert = db.query(KPIAlert).filter(
KPIAlert.alert_type == "cash_plan",
KPIAlert.status.in_(["pending", "processing"]),
).all()
dup = False
for ea in existing_plan_alert:
try:
es = _json.loads(ea.suggestion) if ea.suggestion else {}
if isinstance(es, dict) and es.get("plan_id") == p.id:
dup = True
break
except Exception:
continue
if dup:
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="red" if days_late >= 7 else "yellow",
alert_level=level,
alert_message=msg[:500],
alert_type="cash_plan",
status="pending",
suggestion=_json.dumps({
"actions": ["联系客户催收", "评估坏账风险", "调整信用政策"],
"plan_id": p.id,
"days_late": days_late,
}, ensure_ascii=False),
suggestion=suggestion,
)
db.add(alert)
new_alerts.append({"type": "overdue_receive", "level": alert.alert_level, "message": msg})
new_alerts.append({"type": "overdue_receive", "level": level, "message": msg})
logger.info(f"到期未收款提醒: {msg}")
db.commit()
@@ -717,6 +890,19 @@ def check_cash_alerts(db: Session, entity_id: int = 1) -> dict:
"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"],
+2
View File
@@ -341,6 +341,7 @@ def run_deviation_check(db_session, period: str = None) -> int:
if not existing:
alert = KPIAlert(
entity_id=kpi.entity_id or 1, # 2026-09-13 F1: 显式写归属(原缺省=1 串账套)
kpi_id=kpi.id,
alert_level=result["kpi_alert_level"],
alert_message=f"[差异预警] {alert_msg}",
@@ -364,6 +365,7 @@ def run_deviation_check(db_session, period: str = None) -> int:
if not existing_trend:
alert = KPIAlert(
entity_id=kpi.entity_id or 1, # 2026-09-13 F1: 显式写归属
kpi_id=kpi.id,
alert_level=trend["level"],
alert_message=f"[趋势预警] {trend_alert_msg}",