From a7a663f6cd5945f5809feb8d2bba886bb4c61794 Mon Sep 17 00:00:00 2001 From: Hermes CI Fix Date: Wed, 21 Oct 2026 11:21:22 +0800 Subject: [PATCH] =?UTF-8?q?feat(ar):=20=E9=80=BE=E6=9C=9F=E9=A2=84?= =?UTF-8?q?=E8=AD=A6=E5=88=A4=E6=8D=AE=20G5=E2=80=94=E2=80=9490-150=20?= =?UTF-8?q?=E5=89=8D=E6=B1=A0=205pp/10=E4=B8=87=20+=20=E8=B4=A3=E4=BB=BB?= =?UTF-8?q?=E4=BA=BA=E7=82=B9=E5=90=8D=E4=BC=98=E5=85=88=EF=BC=88=E9=98=B6?= =?UTF-8?q?=E6=AE=B5B=20=E5=8F=97=E6=89=B9=E9=9D=A2=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/services/ar_overdue_alert.py | 114 ++++++++++++++++++----- backend/scripts/seed_ar_overdue_rule.py | 48 +++++----- frontend/components.d.ts | 1 + 3 files changed, 113 insertions(+), 50 deletions(-) diff --git a/backend/app/services/ar_overdue_alert.py b/backend/app/services/ar_overdue_alert.py index fd5ef0a2..26f96484 100644 --- a/backend/app/services/ar_overdue_alert.py +++ b/backend/app/services/ar_overdue_alert.py @@ -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: diff --git a/backend/scripts/seed_ar_overdue_rule.py b/backend/scripts/seed_ar_overdue_rule.py index f2176f78..af2b0202 100644 --- a/backend/scripts/seed_ar_overdue_rule.py +++ b/backend/scripts/seed_ar_overdue_rule.py @@ -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: diff --git a/frontend/components.d.ts b/frontend/components.d.ts index 58e6e1c8..40148acf 100644 --- a/frontend/components.d.ts +++ b/frontend/components.d.ts @@ -15,6 +15,7 @@ declare module 'vue' { BotSourceTag: typeof import('./src/components/BotSourceTag.vue')['default'] BudgetCollectPanel: typeof import('./src/components/BudgetCollectPanel.vue')['default'] BulletChart: typeof import('./src/components/charts/BulletChart.vue')['default'] + CaliberBadge: typeof import('./src/components/CaliberBadge.vue')['default'] CashClassifyPanel: typeof import('./src/components/CashClassifyPanel.vue')['default'] ConnectionLines: typeof import('./src/components/strategy/ConnectionLines.vue')['default'] ConnectionManagerDialog: typeof import('./src/components/map-canvas/ConnectionManagerDialog.vue')['default']