feat(ar): 逾期预警判据 G5——90-150 前池 5pp/10万 + 责任人点名优先(阶段B 受批面)
ci/woodpecker/push/main Pipeline was successful

This commit is contained in:
Hermes CI Fix
2026-10-21 11:21:22 +08:00
parent 71867c4c7a
commit a7a663f6cd
3 changed files with 113 additions and 50 deletions
+22 -26
View File
@@ -34,34 +34,18 @@ from app.utils import ar_caliber # noqa: E402
RULE_TYPE = "ar_overdue"
DEFAULT_KPI_CODE = "F_AR_002" # 逾期应收账款率(财务口径);库内暂缺时须由财务定绑定
# ── 业务数字唯一定义处(阶段B 放行后如需调阈值:改这里或直接改 DB 行)──
# ── 业务数字唯一定义处(阶段B G5 2026-10-21 裁定:停用 150+ 占比,改 90–150 前池)──
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},
},
"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": {},
"exclude_unit_codes": [],
"action_texts": {"red": "催收+停供评估", "yellow": "对账+催收"},
"assignee_source": {"aggregate_assignee": "", "escalate": "任富海"},
}
@@ -81,9 +65,20 @@ def main() -> int:
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()
params = json.loads(json.dumps(DEFAULT_PARAMS)) # deep copy
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:
@@ -125,7 +120,8 @@ def main() -> int:
"params": params,
}, ensure_ascii=False, indent=2))
src = params.get("assignee_source") or {}
if not params.get("assignee_default") and not src.get("mode"):
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: