feat(ar): 应收账龄看板 + 逾期预警 overdue 接入(阶段A)

T1 逾期预警(§6 财务 2026-10-20 23:23 口径):
- alert_rules 支持 rule_type=ar_overdue;阈值/责任人/排除名单全量走 params(代码零业务常量)
- 90 天占比红线 key 保留但默认 null 停用(不回落 0.25);黄线 15%-25% 保留
- 新增逐户红线「>30 天且 >5 万」+ 聚合红线「150+ 占比环比上升 = 结构恶化」
- assignee 派生:manual → 近 3 个月主业务员(zxt_masterbill emp_id → employee_dict.u_Name,
  缺表时降级同库摘要制单人姓名)→ 部门负责人(渠道 118 / 商务 114 / 直属酒窖 117);
  两路皆失败 ⇒ 该户不生成、进 skipped[],禁落空 assignee
- POST /run-ar-overdue(dry_run 默认 true)+ check-all 分支;只 INSERT 同一 message 幂等

T2 账龄看板:
- GET /api/cma/ar-aging/{meta,trend,overview,customers,customers/export};每条查询带 entity_id
- 三口径分列、caliber=all → 400;缺期 has_data=false 且金额 null(不补 0)
- ArAging.vue:口径后缀/截止期徽标/趋势断点/六档堆叠/逐户可排序导出

单测 21 项全通过(SQLite 夹具,不连生产库);vite build 通过。
阶段A 零生产写:未 push、未部署、未 --apply、未 dry_run=false。
This commit is contained in:
fullstack-bot
2026-10-21 08:42:24 +08:00
committed by Hermes CI Fix
parent 790be2d6b1
commit 71867c4c7a
11 changed files with 2552 additions and 4 deletions
+174 -3
View File
@@ -2,7 +2,7 @@
预警规则智能化 — 任务6
后端组件: alert_rules 模型 + API + 预警引擎
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import APIRouter, Body, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import text, Column, Integer, String, Text, Float, DateTime, JSON, Boolean, func
from typing import Optional, List
@@ -26,6 +26,8 @@ from app.utils.cash_thresholds import (
CASH_GAP_RULE_TYPE,
validate_params as validate_cash_gap_params,
)
# 应收账龄逾期预警(T1 · 2026-10-20):计算/落库单源在服务层,阈值全走 alert_rules.params
from app.services import ar_overdue_alert
logger = logging.getLogger("alert_rules")
@@ -38,6 +40,7 @@ VALID_RULE_TYPES = (
"forecast_deviation", # 预测偏差(升级2b 2026-08-25)
"customer_concentration", # 客户集中度(P2 2026-09-10)
"cash_gap_threshold", # 资金缺口阈值配置(G1 2026-10-17,配置行,不进预警生成链)
"ar_overdue", # 应收账龄逾期(T1 2026-10-20:90天以上占比 / 逐户逾期额 / 逾期户数环比)
)
# ============================================================
@@ -142,6 +145,11 @@ def create_alert_rule(data: dict, db: Session = Depends(get_db), user=Depends(re
err = validate_cash_gap_params(data.get("params"))
if err:
raise HTTPException(400, err)
# T1(2026-10-20):ar_overdue 参数只校结构(阈值/责任人全量由 DB 行给,代码不设默认值)
if rule_type == ar_overdue_alert.RULE_TYPE:
err = ar_overdue_alert.validate_params(data.get("params"))
if err:
raise HTTPException(400, err)
rule = AlertRule(
entity_id=entity_id,
@@ -180,6 +188,12 @@ def update_alert_rule(rule_id: int, data: dict, db: Session = Depends(get_db), e
if err:
db.rollback()
raise HTTPException(400, err)
# T1(2026-10-20):ar_overdue 改参同样只校结构(阈值可现场改,无需发版)
if rule.rule_type == ar_overdue_alert.RULE_TYPE:
err = ar_overdue_alert.validate_params(rule.params)
if err:
db.rollback()
raise HTTPException(400, err)
db.commit()
db.refresh(rule)
return {"data": {c.name: getattr(rule, c.name) for c in AlertRule.__table__.columns}}
@@ -307,6 +321,31 @@ def run_all_alert_checks(db: Session = Depends(get_db), entity_id: int = Depends
# 不进 KPIValue 比价链(否则未来若绑定到有值的 KPI 会误生成预警)。
if rule.rule_type == CASH_GAP_RULE_TYPE:
continue
# T1(2026-10-20):应收账龄逾期 —— 与 POST /run-ar-overdue 同一计算函数,
# 不走 KPIValue 比价链(F_AR_002 无本期 kpi_values 也不得报错)。
if rule.rule_type == ar_overdue_alert.RULE_TYPE:
rp = rule.params
if isinstance(rp, str):
rp = json.loads(rp) if rp else {}
rp = rp or {}
try:
ar_overdue_alert.ensure_assignee_configured(rp)
res = ar_overdue_alert.compute(db, entity_id, None, rp)
except (ar_overdue_alert.AssigneeMissing, ar_overdue_alert.ParamsInvalid) as exc:
logger.warning("ar_overdue 规则跳过(rule_id=%s): %s", rule.id, exc)
continue
if res.get("skipped_reason"):
logger.info("ar_overdue 规则无数据跳过(rule_id=%s): %s",
rule.id, res["skipped_reason"])
continue
try:
w = ar_overdue_alert.write_alerts(db, entity_id, rule.kpi_id, res)
except Exception: # noqa: BLE001 — 单规则失败不拖垮整轮检查
db.rollback()
logger.exception("ar_overdue 落库失败(rule_id=%s)", rule.id)
continue
alerts_generated += w["inserted"]
continue
kpi = kpi_cache.get(rule.kpi_id)
if kpi is None:
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == rule.kpi_id).first()
@@ -316,7 +355,7 @@ def run_all_alert_checks(db: Session = Depends(get_db), entity_id: int = Depends
if not kpi or kpi.entity_id != entity_id:
continue
import json; params = json.loads(rule.params) if isinstance(rule.params, str) else (rule.params or {})
params = json.loads(rule.params) if isinstance(rule.params, str) else (rule.params or {})
# 客户集中度规则(P2 2026-09-10):不依赖同期KPIValue,直接看客户收入占比
if rule.rule_type == "customer_concentration":
@@ -655,7 +694,7 @@ def _check_forecast_alerts(db: Session, entity_id: int = 1) -> int:
continue
# 检查预测值是否超限
import json; params = json.loads(rule.params) if isinstance(rule.params, str) else (rule.params or {})
params = json.loads(rule.params) if isinstance(rule.params, str) else (rule.params or {})
params["kpi"] = kpi
for forecast in latest_forecasts:
value = forecast.predicted_cash
@@ -979,3 +1018,135 @@ def run_customer_concentration_check(
continue
db.commit()
return {"message": f"客户集中度检查完成: {generated}条", "generated": generated}
# ============================================================
# T1(2026-10-20):应收账龄逾期预警 —— 运行入口 + 手工触发
# ============================================================
def _resolve_ar_kpi(db: Session, entity_id: int, params: dict, rule, override_kpi_id):
"""解析 ar_overdue 绑定的 KPI:规则行 → 显式 override(仅 dry-run)→ params.kpi_code。"""
if rule is not None and rule.kpi_id:
kpi = db.query(KPIDefinition).filter(
KPIDefinition.id == rule.kpi_id, KPIDefinition.entity_id == entity_id).first()
return (kpi.id if kpi else rule.kpi_id), (kpi.kpi_code if kpi else None), "alert_rules.kpi_id"
if override_kpi_id:
kpi = db.query(KPIDefinition).filter(
KPIDefinition.id == override_kpi_id, KPIDefinition.entity_id == entity_id).first()
if not kpi:
raise HTTPException(400, f"KPI 不存在或不属于当前账套: kpi_id={override_kpi_id}")
return kpi.id, kpi.kpi_code, "query.kpi_id(dry-run override)"
code = (params or {}).get("kpi_code")
if code:
kpi = db.query(KPIDefinition).filter(
KPIDefinition.kpi_code == code, KPIDefinition.entity_id == entity_id).first()
if kpi:
return kpi.id, kpi.kpi_code, f"params.kpi_code={code}"
return None, code, f"params.kpi_code={code}(库内不存在)"
return None, None, "unresolved"
@router.post("/run-ar-overdue")
def run_ar_overdue_check(
period: Optional[str] = Query(None, description="账龄基准期 YYYY-MM;缺省=表内 MAX(period)"),
dry_run: bool = Query(True, description="默认 true:只算不写。false 走既有写通道插入 kpi_alerts"),
kpi_id: Optional[int] = Query(None, description="dry-run 专用:临时绑定 KPI(正式以规则行 kpi_id 为准)"),
data: Optional[dict] = Body(None, description="dry-run 专用:{'params': {...}} 覆盖规则参数(证明参数化,不碰 DB)"),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""应收账龄逾期预警运行(T1)。
参数优先级:dry-run body 覆盖 > `alert_rules` 行(rule_type='ar_overdue')> 无规则 ⇒ 200 skipped。
阈值/责任人/排除名单全部来自参数;`assignee` 缺失 ⇒ 400(fail-closed)。
`dry_run=false` 时禁止任何参数覆盖(防绕过 DB 配置),且只 INSERT 不 UPDATE/DELETE。
"""
if is_excluded_entity(db, entity_id):
return {"message": f"账套 {entity_id} 为 demo 演示账套,按 R1 不参与预警生成",
"generated": 0, "skipped": True, "reason": "R1_demo_entity_excluded"}
override_params = None
if isinstance(data, dict):
override_params = data.get("params") if isinstance(data.get("params"), dict) else data.get("params_override")
if (override_params is not None or kpi_id is not None) and not dry_run:
raise HTTPException(
400, "dry_run=false 时禁止参数/KPI 覆盖:阈值与绑定必须来自 alert_rules 行(防绕过 DB 配置)")
rule = db.query(AlertRule).filter(
AlertRule.entity_id == entity_id,
AlertRule.rule_type == ar_overdue_alert.RULE_TYPE,
AlertRule.enabled == 1,
).order_by(AlertRule.id).first()
params_source = "dry_run.body.params"
if override_params is not None:
params = override_params
elif rule is not None:
params = rule.params
if isinstance(params, str):
params = json.loads(params) if params else {}
params = params or {}
params_source = f"alert_rules#{rule.id}"
else:
return {
"dry_run": dry_run, "entity_id": entity_id, "period": period,
"skipped": True, "skipped_reason": "no_ar_overdue_rule",
"hint": "无 rule_type='ar_overdue' 规则行;阶段B 用 backend/scripts/seed_ar_overdue_rule.py 播种,"
"或 dry-run 时在 body 传 {'params': {...}} 演练",
}
err = ar_overdue_alert.validate_params(params)
if err:
raise HTTPException(400, err)
try:
ar_overdue_alert.ensure_assignee_configured(params)
except ar_overdue_alert.AssigneeMissing as e:
raise HTTPException(400, str(e))
resolved_kpi_id, kpi_code, binding = _resolve_ar_kpi(db, entity_id, params, rule, kpi_id)
try:
result = ar_overdue_alert.compute(db, entity_id, period, params)
except ar_overdue_alert.ParamsInvalid as e:
raise HTTPException(400, str(e))
hits = ar_overdue_alert.hits(result)
blockers = []
if resolved_kpi_id is None:
blockers.append("kpi_id_unresolved:%s" % (kpi_code or "-"))
payload = {
"dry_run": dry_run,
"entity_id": entity_id,
"period": result.get("period"),
"caliber": result.get("caliber"),
"params_source": params_source,
"kpi_id": resolved_kpi_id,
"kpi_code": kpi_code,
"kpi_binding": binding,
"skipped_reason": result.get("skipped_reason"),
"metrics": result.get("metrics"),
"aggregates": result.get("aggregates"),
"per_customer": result.get("per_customer"),
"skipped": result.get("skipped"),
"hit_count": len(hits),
"notes": result.get("notes"),
}
if result.get("skipped_reason"):
payload.update({"would_insert": 0, "write_blockers": blockers})
return payload
if dry_run:
payload.update({"would_insert": 0 if blockers else len(hits), "write_blockers": blockers})
return payload
if blockers:
raise HTTPException(400, "无法落库:" + "; ".join(blockers))
written = ar_overdue_alert.write_alerts(db, entity_id, resolved_kpi_id, result)
log_op(db, "run_ar_overdue_alert", target_type="alert", target_id=resolved_kpi_id,
detail={"period": result.get("period"), "caliber": result.get("caliber"),
"hit": len(hits), **written},
entity_id=entity_id)
db.commit()
payload.update({"would_insert": len(hits), "write_blockers": [], **written,
"generated": written["inserted"]})
return payload