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:
committed by
Hermes CI Fix
parent
790be2d6b1
commit
71867c4c7a
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user