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。
158 lines
6.4 KiB
Python
158 lines
6.4 KiB
Python
#!/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())
|