#!/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())