154 lines
6.4 KiB
Python
154 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 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())
|