Files
cma-management/backend/scripts/seed_ar_overdue_rule.py
T
fullstack-botandHermes CI Fix 71867c4c7a 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。
2026-10-21 08:42:24 +08:00

158 lines
6.4 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.
#!/usr/bin/env python3
"""播种应收账龄逾期预警规则(`alert_rules.rule_type='ar_overdue'`)— 阶段B 专用
用法:
# 只打印将写入的行(默认 dry-run,阶段A 唯一允许的形式)
python3 backend/scripts/seed_ar_overdue_rule.py --entity-id 1
# 真正写库(阶段B,须另铸门/执行件授权)
python3 backend/scripts/seed_ar_overdue_rule.py --entity-id 1 --apply
# 把参数 JSON 打到 stdout/文件,供阶段A `POST /run-ar-overdue` 的 dry-run body 复演
python3 backend/scripts/seed_ar_overdue_rule.py --entity-id 1 --emit-params /tmp/ar_params.json
阈值与责任人**只存在于本脚本**(业务数字唯一定义处);运行期一律读 DB 行,
代码侧不含任何阈值常量(任务硬约束 §3.4)。
"""
from __future__ import annotations
import argparse
import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from dotenv import load_dotenv # noqa: E402
load_dotenv(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), ".env"))
from app.database import get_session_local # noqa: E402
from app.models import KPIDefinition # noqa: E402
from app.utils import ar_caliber # noqa: E402
RULE_TYPE = "ar_overdue"
DEFAULT_KPI_CODE = "F_AR_002" # 逾期应收账款率(财务口径);库内暂缺时须由财务定绑定
# ── 业务数字唯一定义处(阶段B 放行后如需调阈值:改这里或直接改 DB 行)──
DEFAULT_PARAMS = {
"kpi_code": DEFAULT_KPI_CODE,
"caliber": ar_caliber.CALIBER_DEBIT,
"red": {
# 2026-10-20 财务裁决:90 天占比红线停用(10 期 10/10 恒红、无区分度)→ 键位保留、置 null
"share_90plus_gt": None,
"single_overdue_amt_gt": 50000,
"single_overdue_min_days": 30,
"count_mom_pct_gte": 0.20,
# 150+ 档占比环比上升 = 结构恶化(新增红线 2)
"structure_worsen": True,
},
"yellow": {
"share_90plus_min": 0.15,
"share_90plus_max": 0.25,
"single_overdue_amt_min": 10000,
"single_overdue_amt_max": 50000,
},
# 责任人:逐户派生「近 3 个月主业务员 → 部门负责人」,两路皆失败则该户不生成(skipped[])
"assignee_source": {
"mode": "salesman_then_dept",
"lookback_months": 3,
"dept_fallback": {"渠道": 118, "商务": 114, "直属酒窖": 117},
},
"assignee_by_unit": {},
"exclude_unit_codes": [],
"action_texts": {"red": "催收+停供评估", "yellow": "对账+催收"},
}
def resolve_kpi(db, entity_id: int, kpi_code: str, kpi_id: int | None):
if kpi_id:
return db.query(KPIDefinition).filter(
KPIDefinition.id == kpi_id, KPIDefinition.entity_id == entity_id).first()
return db.query(KPIDefinition).filter(
KPIDefinition.kpi_code == kpi_code, KPIDefinition.entity_id == entity_id).first()
def main() -> int:
ap = argparse.ArgumentParser(description="播种 ar_overdue 预警规则")
ap.add_argument("--entity-id", type=int, required=True)
ap.add_argument("--kpi-code", default=DEFAULT_KPI_CODE)
ap.add_argument("--kpi-id", type=int, default=None, help="显式绑定 KPI(优先于 kpi-code)")
ap.add_argument("--assignee", default=None, help="覆盖 assignee_default(财务指定责任人)")
ap.add_argument("--apply", action="store_true", help="真正写库(阶段B 才允许)")
ap.add_argument("--emit-params", default=None, help="把最终 params JSON 写入指定文件")
args = ap.parse_args()
params = json.loads(json.dumps(DEFAULT_PARAMS)) # deep copy
if args.assignee is not None:
params["assignee_default"] = args.assignee
if args.emit_params:
with open(args.emit_params, "w", encoding="utf-8") as f:
json.dump({"params": params}, f, ensure_ascii=False, indent=2)
print("params 已写出: %s" % args.emit_params)
db = get_session_local()()
try:
kpi = resolve_kpi(db, args.entity_id, args.kpi_code, args.kpi_id)
if not kpi:
print("[BLOCK] entity=%s 内找不到 KPI code=%r(kpi_id=%s)——无法绑定,未写入任何行"
% (args.entity_id, args.kpi_code, args.kpi_id), file=sys.stderr)
print("[INFO] 该账套现存 AR 相关 KPI:", file=sys.stderr)
rows = db.query(KPIDefinition).filter(
KPIDefinition.entity_id == args.entity_id,
KPIDefinition.kpi_code.like("%AR%"),
).order_by(KPIDefinition.id).all()
for r in rows:
print(" id=%s code=%s name=%s" % (r.id, r.kpi_code, r.kpi_name), file=sys.stderr)
print("[INFO] 处理:--kpi-id 显式指定,或财务先在 kpi_definitions 建 F_AR_002。",
file=sys.stderr)
return 2
from app.api.alert_rules import AlertRule
exist = db.query(AlertRule).filter(
AlertRule.entity_id == args.entity_id,
AlertRule.rule_type == RULE_TYPE,
).all()
print("== 将写入的行(%s)==" % ("APPLY" if args.apply else "DRY-RUN"))
print(json.dumps({
"entity_id": args.entity_id,
"kpi_id": kpi.id,
"kpi_code": kpi.kpi_code,
"kpi_name": kpi.kpi_name,
"rule_type": RULE_TYPE,
"trigger_on": "actual",
"enabled": 1,
"params": params,
}, ensure_ascii=False, indent=2))
src = params.get("assignee_source") or {}
if not params.get("assignee_default") and not src.get("mode"):
print("[WARN] 责任人均未配置 → 运行期 fail-closed 400(阶段B 前须由财务给值)")
if exist:
print("[SKIP] entity=%s 已有 %d 条 ar_overdue 规则(id=%s),不重复播种"
% (args.entity_id, len(exist), [r.id for r in exist]))
return 0
if not args.apply:
print("[DRY-RUN] 未写库。阶段B 授权后加 --apply。")
return 0
rule = AlertRule(
entity_id=args.entity_id,
kpi_id=kpi.id,
rule_type=RULE_TYPE,
trigger_on="actual",
enabled=1,
params=params,
)
db.add(rule)
db.commit()
db.refresh(rule)
print("[APPLIED] 已写入 alert_rules.id=%s" % rule.id)
return 0
finally:
db.close()
if __name__ == "__main__":
raise SystemExit(main())