#!/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 G5 2026-10-21 裁定:停用 150+ 占比,改 90–150 前池)── DEFAULT_PARAMS = { "kpi_code": DEFAULT_KPI_CODE, "caliber": ar_caliber.CALIBER_DEBIT, "share_90plus_gt": None, "share_150p_gt": None, "share_90_150_rise_pp_min": 5.0, "share_90_150_amt_min": 100000, "overdue_amt_display_only": True, "single_overdue_amt_gt": 50000, "assignee_by_unit": {}, "assignee_source": {"aggregate_assignee": "", "escalate": "任富海"}, } 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 写入指定文件") ap.add_argument("--params-file", default=None, help="整份 params JSON(与 DB 行逐字一致;给了就覆盖 DEFAULT_PARAMS)") args = ap.parse_args() if args.params_file: with open(args.params_file, encoding="utf-8") as f: raw = json.load(f) params = raw.get("params", raw) if isinstance(raw, dict) else raw if not isinstance(params, dict): print("[BLOCK] --params-file 必须是 JSON 对象或 {params:{...}}", file=sys.stderr) return 3 params = json.loads(json.dumps(params, ensure_ascii=False)) # deep copy else: 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") or params.get("assignee_by_unit") or src.get("mode") or src.get("aggregate_assignee")): 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())