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: