feat(ar): 逾期预警判据 G5——90-150 前池 5pp/10万 + 责任人点名优先(阶段B 受批面)
ci/woodpecker/push/main Pipeline was successful
ci/woodpecker/push/main Pipeline was successful
This commit is contained in:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user