Files
cma-management/backend/scripts/seed_ar_overdue_rule.py
T

154 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 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())