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
+90 -24
View File
@@ -55,7 +55,9 @@ def validate_params(params: Any) -> Optional[str]:
except ar_caliber.CaliberError as e:
return str(e)
for key in ("red", "yellow"):
if not isinstance(params.get(key), dict):
# 2026-10-21 阶段B:G5 新参数形状为扁平键(share_90_150_* 等),red/yellow 分组可缺省;
# 存在则必须是对象(防误传标量导致阈值静默失效)。
if params.get(key) is not None and not isinstance(params.get(key), dict):
return "params.%s 必须是对象(阈值分组)" % key
for key in ("assignee_by_unit", "action_texts"):
if key in params and params[key] is not None and not isinstance(params[key], dict):
@@ -403,7 +405,10 @@ def _level_mom(mom_pct: Optional[float], params: Dict[str, Any]) -> Optional[str
def _level_customer(overdue_amt: Decimal, oldest_days: int, params: Dict[str, Any]) -> Optional[str]:
red = params.get("red") or {}
yellow = params.get("yellow") or {}
# 阶段B G5 扁平参数:顶层 single_overdue_amt_gt 优先(未配 red 分组时仍可判红线)
red_amt = red.get("single_overdue_amt_gt")
if red_amt is None:
red_amt = params.get("single_overdue_amt_gt")
red_min_days = red.get("single_overdue_min_days")
if red_amt is not None and overdue_amt > _dec(red_amt):
if red_min_days is None or oldest_days >= int(red_min_days):
@@ -586,38 +591,99 @@ def compute(
"count_mom_pct": (round(mom_pct, 6) if mom_pct is not None else None)},
msg, action)
# 聚合判据 3:150+ 档占比环比上升 ⇒ 账龄结构恶化(§6.1 新增红线 2)
share_150 = None
prev_share_150 = None
# 聚合判据 3:账龄结构恶化(G5 2026-10-21 裁定)
# · 新判据(参数化):90–150 天「前池」占比环比上升 ≥ rise_pp_min **且** 该档余额 ≥ amt_min(AND)
# · 兼容旧参数:未配 share_90_150_rise_pp_min 时,沿用 150+ 占比环比上升(red.structure_worsen)
share_90_150 = None
prev_share_90_150 = None
amt_90_150 = None
prev_amt_90_150 = None
if total > 0:
share_150 = float(sum((_dec(r.get("b_150p")) for r in rows), Decimal("0")) / total)
amt_90_150 = sum((_dec(r.get("b_90_120")) + _dec(r.get("b_120_150")) for r in rows),
Decimal("0"))
share_90_150 = float(amt_90_150 / total)
if prev_rows:
prev_total = sum((_dec(r.get("total_amt")) for r in prev_rows), Decimal("0"))
if prev_total > 0:
prev_share_150 = float(
sum((_dec(r.get("b_150p")) for r in prev_rows), Decimal("0")) / prev_total)
structure_on = bool(red_cfg.get("structure_worsen"))
if structure_on:
if share_150 is not None and prev_share_150 is not None and share_150 > prev_share_150:
prev_total_90150 = sum((_dec(r.get("total_amt")) for r in prev_rows), Decimal("0"))
prev_amt_90_150 = sum((_dec(r.get("b_90_120")) + _dec(r.get("b_120_150")) for r in prev_rows),
Decimal("0"))
if prev_total_90150 > 0:
prev_share_90_150 = float(prev_amt_90_150 / prev_total_90150)
rise_pp_min = params.get("share_90_150_rise_pp_min")
amt_min_90_150 = params.get("share_90_150_amt_min")
if rise_pp_min is not None and amt_min_90_150 is not None:
delta_pp = None
if share_90_150 is not None and prev_share_90_150 is not None:
delta_pp = (share_90_150 - prev_share_90_150) * 100.0
hit_90150 = (
delta_pp is not None and delta_pp >= float(rise_pp_min)
and amt_90_150 is not None and amt_90_150 >= _dec(amt_min_90_150)
)
if hit_90150:
action = _action_text(params, "red")
msg = _clip(
"【应收账款逾期】%s 150+ 档占比 %s%%(上期 %s%%)环比上升 %+.2fpp,账龄结构恶化"
"|建议:%s" % (period, _fmt_pct(share_150), _fmt_pct(prev_share_150),
(share_150 - prev_share_150) * 100.0, action or "见规则配置")
"【应收账款逾期】%s 90–150 天档占比 %s%%(上期 %s%%)环比 %+.2fpp ≥ %gpp,"
"该档余额 ¥%s(≥¥%s),账龄前池恶化|建议:%s"
% (period, _fmt_pct(share_90_150), _fmt_pct(prev_share_90_150), delta_pp,
float(rise_pp_min), _fmt_money(amt_90_150), _fmt_money(_dec(amt_min_90_150)),
action or "见规则配置")
)
_push_agg("structure_150p", "red",
{"share_150p": round(share_150, 6),
_push_agg("structure_90_150", "red",
{"share_90_150": round(share_90_150, 6),
"prev_period": prev_period,
"prev_share_150p": (round(prev_share_150, 6)
if prev_share_150 is not None else None),
"delta_pp": round((share_150 - prev_share_150) * 100.0, 4)},
"prev_share_90_150": (round(prev_share_90_150, 6)
if prev_share_90_150 is not None else None),
"delta_pp": round(delta_pp, 4),
"amt_90_150": _money(amt_90_150),
"rise_pp_min": float(rise_pp_min),
"amt_min": float(amt_min_90_150)},
msg, action)
else:
notes.append(
"150+ 占比:本期 %s%% / 上期 %s%% ⇒ 结构恶化判据未触发"
% (_fmt_pct(share_150), _fmt_pct(prev_share_150)))
metrics["share_150p"] = (round(share_150, 6) if share_150 is not None else None)
metrics["prev_share_150p"] = (round(prev_share_150, 6) if prev_share_150 is not None else None)
"90–150 档:本期 %s%% / 上期 %s%%(Δ=%spp,门槛 ≥%gpp)|余额 ¥%s(门槛 ≥¥%s)"
"⇒ 结构恶化判据未触发"
% (_fmt_pct(share_90_150), _fmt_pct(prev_share_90_150),
("%+.2f" % delta_pp) if delta_pp is not None else "-",
float(rise_pp_min),
_fmt_money(amt_90_150) if amt_90_150 is not None else "-",
_fmt_money(_dec(amt_min_90_150))))
metrics["share_90_150"] = (round(share_90_150, 6) if share_90_150 is not None else None)
metrics["prev_share_90_150"] = (round(prev_share_90_150, 6)
if prev_share_90_150 is not None else None)
metrics["amt_90_150"] = (_money(amt_90_150) if amt_90_150 is not None else None)
else:
share_150 = None
prev_share_150 = None
if total > 0:
share_150 = float(sum((_dec(r.get("b_150p")) for r in rows), Decimal("0")) / total)
if prev_rows:
prev_total = sum((_dec(r.get("total_amt")) for r in prev_rows), Decimal("0"))
if prev_total > 0:
prev_share_150 = float(
sum((_dec(r.get("b_150p")) for r in prev_rows), Decimal("0")) / prev_total)
structure_on = bool(red_cfg.get("structure_worsen"))
if structure_on:
if share_150 is not None and prev_share_150 is not None and share_150 > prev_share_150:
action = _action_text(params, "red")
msg = _clip(
"【应收账款逾期】%s 150+ 档占比 %s%%(上期 %s%%)环比上升 %+.2fpp,账龄结构恶化"
"|建议:%s" % (period, _fmt_pct(share_150), _fmt_pct(prev_share_150),
(share_150 - prev_share_150) * 100.0, action or "见规则配置")
)
_push_agg("structure_150p", "red",
{"share_150p": round(share_150, 6),
"prev_period": prev_period,
"prev_share_150p": (round(prev_share_150, 6)
if prev_share_150 is not None else None),
"delta_pp": round((share_150 - prev_share_150) * 100.0, 4)},
msg, action)
else:
notes.append(
"150+ 占比:本期 %s%% / 上期 %s%% ⇒ 结构恶化判据未触发"
% (_fmt_pct(share_150), _fmt_pct(prev_share_150)))
metrics["share_150p"] = (round(share_150, 6) if share_150 is not None else None)
metrics["prev_share_150p"] = (round(prev_share_150, 6)
if prev_share_150 is not None else None)
# ── 逐户:assignee 派生失败 ⇒ 不生成(进 skipped[]),禁落空 assignee ──
for c in customers:
+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: