"""应收账龄逾期预警(`alert_rules.rule_type='ar_overdue'`)计算与落库 设计要点(任务 project-20261020-ar-overdue-alert-and-aging-board-fullstack): * **阈值/责任人/排除名单全部来自 `alert_rules.params`(DB 行)**,本模块不出现任何 业务阈值数字;缺键即该判据关闭(显式失效,不回落隐式默认值)。 * **只 INSERT**:命中写 `kpi_alerts(alert_type='overdue')`,去重键为**生成侧语义键** `dedupe_key = period|scope|ident`(单户 `cust|`、聚合 `agg|<族名>`), 由唯一索引兜底;文案口径变化不再产生重复行。历史行 `dedupe_key IS NULL` 走只读反解兼容。 **零 UPDATE / 零 DELETE**。 * `assignee` 必填(`assignee_by_unit[unit_code]` → `assignee_default`),两者皆空 ⇒ `AssigneeMissing`(调用方转 400,fail-closed)——禁落空 assignee 的行。 * 口径:只读 `借方应收户口径`(`app.utils.ar_caliber`),负档位不抹平。 """ from __future__ import annotations import calendar import logging import re from decimal import Decimal, ROUND_HALF_UP from typing import Any, Dict, List, Optional, Tuple from sqlalchemy import text from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session from app.models import KPIAlert from app.utils import ar_caliber logger = logging.getLogger("ar_overdue_alert") RULE_TYPE = "ar_overdue" ALERT_TYPE = "overdue" MESSAGE_MAX = 500 _CENT = Decimal("0.01") class AssigneeMissing(Exception): """责任人未配置(fail-closed:拒绝运行,不落空 assignee 行)。""" class ParamsInvalid(Exception): """规则参数结构非法。""" # ============================================================ # 参数校验(只校结构,不设默认阈值) # ============================================================ def validate_params(params: Any) -> Optional[str]: """返回错误文案;None = 合法。""" if params is None: return "params 不能为空(ar_overdue 规则参数全量走 DB,代码不设业务默认值)" if not isinstance(params, dict): return "params 必须是 JSON 对象" try: ar_caliber.validate_caliber(params.get("caliber") or ar_caliber.DEFAULT_CALIBER) except ar_caliber.CaliberError as e: return str(e) for key in ("red", "yellow"): # 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): return "params.%s 必须是对象" % key if "exclude_unit_codes" in params and params["exclude_unit_codes"] is not None: v = params["exclude_unit_codes"] if not isinstance(v, (list, tuple)): return "params.exclude_unit_codes 必须是数组(排他名单)" return None def _params_caliber(params: Dict[str, Any]) -> str: return params.get("caliber") or ar_caliber.DEFAULT_CALIBER def _action_text(params: Dict[str, Any], level: str) -> str: texts = params.get("action_texts") or {} return str(texts.get(level) or texts.get("default") or "") # ============================================================ # assignee 解析(任务 §6.2 定值;全部只读 SELECT,禁止落空 assignee 行) # 优先级:assignee_by_unit 手工点名 → 近 3 个月主业务员 → 部门负责人 # 两路皆失败 ⇒ 该户不生成预警,进 `skipped[]`(含原因) # ============================================================ ASSIGNEE_MODES = ("salesman_then_dept", "manual_then_salesman_then_dept", "dept_only") def _source_cfg(params: Dict[str, Any]) -> Dict[str, Any]: src = params.get("assignee_source") return src if isinstance(src, dict) else {} def _assignee_by_unit(params: Dict[str, Any], unit_code: Optional[str]) -> Optional[str]: by_unit = params.get("assignee_by_unit") or {} if unit_code is None: return None v = by_unit.get(str(unit_code)) return str(v).strip() if v else None def ensure_assignee_configured(params: Dict[str, Any]) -> None: """责任人来源整体缺失 ⇒ 400 fail-closed(§4.8 负测)。 §6.2 允许逐户派生(主业务员 → 部门负责人),因此「配了 assignee_source.mode」 也算已配置;派生失败的**单户**进 skipped[],不落空 assignee 行。 完全没有任何来源(无 default / 无 by_unit / 无 mode)才拒绝整体运行。 """ if str(params.get("assignee_default") or "").strip(): return if params.get("assignee_by_unit"): return if str(_source_cfg(params).get("mode") or "") in ASSIGNEE_MODES: return raise AssigneeMissing( "ar_overdue 规则未配置任何责任人来源:params.assignee_default / assignee_by_unit / " "assignee_source.mode 均为空,按 fail-closed 拒绝运行(禁止落空 assignee 的预警行)" ) def _has_table(db: Session, name: str) -> bool: """表是否可读(方言无关,不依赖 information_schema)。""" try: db.execute(text("SELECT 1 FROM %s LIMIT 1" % name)) return True except Exception: # noqa: BLE001 — 缺表/无权限一律降级 return False def _employee_name(db: Session, emp_id: Any) -> Optional[str]: """employee_dict.u_Name(只读)。缺表/无该 id ⇒ None(调用方继续降级)。""" try: emp = int(emp_id) except (TypeError, ValueError): return None if emp <= 0 or not _has_table(db, ar_caliber.EMPLOYEE_DICT_TABLE): return None try: row = db.execute( text("SELECT u_Name FROM %s WHERE id = :i" % ar_caliber.EMPLOYEE_DICT_TABLE), {"i": emp}, ).first() return str(row[0]).strip() if row and row[0] else None except Exception: # noqa: BLE001 return None def _operator_from_abst(abst: Any) -> Optional[str]: """单据摘要尾段制单人姓名(`…:姓名` / `…元;姓名`)——employee_dict 缺表时的同库只读替代源。 摘要里常混入金额/银行/备注(如 `…【建设银行】270000.00元;兀晨博`),因此逐段 取尾段后做**形状校验**:纯中文姓名、长度 ≤ 20、不含数字/括号/金额符号,否则判失败。 """ if not abst: return None s = str(abst).strip() for sep in (":", ";"): if sep in s: s = s.rsplit(sep, 1)[-1] s = s.strip(" \u3000;;") if not s or len(s) > 20: return None if any(ch.isdigit() for ch in s): return None if any(ch in s for ch in "【】[]()()元,,.。::"): return None return s def _period_window(period: str, months: int) -> Tuple[str, str]: """账龄期 → 业务员回看窗口 [期末前 (months-1) 个月 1 日, 期末]。""" y, m = int(period[:4]), int(period[5:7]) ly, lm = y, m - (months - 1) while lm <= 0: lm += 12 ly -= 1 end_day = calendar.monthrange(y, m)[1] return "%04d-%02d-01" % (ly, lm), "%04d-%02d-%02d" % (y, m, end_day) def _resolve_unit_ref(db: Session, entity_id: int, unit_code: Any, unit_name: Any, start: str, end: str) -> Tuple[Optional[int], str, str]: """unit_code → zxt_masterbill.unit_id。返回 (unit_id, 来源, 失败原因)。""" code = str(unit_code or "").strip() if _has_table(db, ar_caliber.UNITS_DICT_TABLE): for code_col in ("unit_code", "u_code", "code"): try: row = db.execute( text("SELECT unit_id FROM %s WHERE %s = :c" % (ar_caliber.UNITS_DICT_TABLE, code_col)), {"c": code}, ).first() if row and row[0] is not None: return int(row[0]), "units_dict.%s" % code_col, "" except Exception: # noqa: BLE001 — 列名不匹配则试下一列 continue if code.isdigit(): return int(code), "numeric_code", "" if unit_name: try: row = db.execute( text("SELECT unit_id, COUNT(*) AS n FROM %s WHERE entity_id = :e " "AND abst LIKE :kw AND bill_date >= :s AND bill_date <= :d " "GROUP BY unit_id ORDER BY n DESC LIMIT 1" % ar_caliber.ZXT_MASTERBILL_TABLE), {"e": entity_id, "kw": "%%%s%%" % unit_name, "s": start, "d": end}, ).first() if row and row[0] is not None: return int(row[0]), "abst_name_match", "" except Exception: # noqa: BLE001 pass return None, "", "unit_link_unresolved(code=%s)" % (code or "-") def _salesman_name(db: Session, entity_id: int, unit_id: int, start: str, end: str, allow_abst_fallback: bool) -> Tuple[Optional[str], Optional[int], str]: """该户近窗主业务员(单据数最多者;同数取最近单据)。""" try: row = db.execute( text("SELECT emp_id, COUNT(*) AS n FROM %s WHERE entity_id = :e AND unit_id = :u " "AND bill_date >= :s AND bill_date <= :d AND emp_id IS NOT NULL AND emp_id > 0 " "GROUP BY emp_id ORDER BY n DESC, MAX(bill_date) DESC LIMIT 1" % ar_caliber.ZXT_MASTERBILL_TABLE), {"e": entity_id, "u": unit_id, "s": start, "d": end}, ).first() except Exception as exc: # noqa: BLE001 return None, None, "salesman_query_failed:%s" % type(exc).__name__ if not row or row[0] is None: return None, None, "no_recent_bill(unit_id=%s)" % unit_id emp_id = int(row[0]) name = _employee_name(db, emp_id) if name: return name, emp_id, "" if not allow_abst_fallback: return None, emp_id, "salesman_name_unresolved(emp_id=%s, employee_dict 缺表)" % emp_id try: row2 = db.execute( text("SELECT abst FROM %s WHERE entity_id = :e AND unit_id = :u AND emp_id = :m " "AND bill_date >= :s AND bill_date <= :d ORDER BY bill_date DESC, id DESC LIMIT 1" % ar_caliber.ZXT_MASTERBILL_TABLE), {"e": entity_id, "u": unit_id, "m": emp_id, "s": start, "d": end}, ).first() name = _operator_from_abst(row2[0] if row2 else None) except Exception: # noqa: BLE001 name = None if name: return name, emp_id, "" return None, emp_id, "salesman_name_unresolved(emp_id=%s, employee_dict 缺表且摘要无姓名)" % emp_id def _dept_assignee(db: Session, unit_code: Any, params: Dict[str, Any]) -> Tuple[Optional[str], str]: """部门负责人兜底(§6.2:渠道 118 / 商务 114 / 直属酒窖 117)。 部门归属无法自动判定时**不猜**:要求 params.assignee_source.dept_by_unit / dept_by_prefix / dept_default 之一给出部门键,否则返回原因。 """ src = _source_cfg(params) fb = src.get("dept_fallback") or {} code = str(unit_code or "") key = (src.get("dept_by_unit") or {}).get(code) or src.get("dept_default") if key is None: for prefix, k in (src.get("dept_by_prefix") or {}).items(): if code.startswith(str(prefix)): key = k break if key is None: return None, "dept_unclassified" if key not in fb: return None, "dept_key_unmapped:%s" % key name = _employee_name(db, fb[key]) if name: return name, "dept:%s(id=%s)" % (key, fb[key]) return None, "dept_head_name_unresolved:%s(id=%s, employee_dict 缺表)" % (key, fb[key]) def resolve_assignee(db: Session, entity_id: int, unit_code: Any, unit_name: Any, params: Dict[str, Any], period: str) -> Dict[str, Any]: """单户 assignee 解析结果:{name, source, emp_id, reason}。name=None ⇒ 不生成该户。""" manual = _assignee_by_unit(params, str(unit_code) if unit_code is not None else None) if manual: return {"name": manual, "source": "manual:assignee_by_unit", "emp_id": None, "reason": ""} src = _source_cfg(params) mode = str(src.get("mode") or "") reasons: List[str] = [] if mode in ASSIGNEE_MODES: months = int(src.get("lookback_months") or 3) start, end = _period_window(period, months) if mode != "dept_only": unit_id, link, link_reason = _resolve_unit_ref( db, entity_id, unit_code, unit_name, start, end) if unit_id is not None: name, emp_id, why = _salesman_name( db, entity_id, unit_id, start, end, bool(src.get("abst_operator_fallback", True))) if name: return {"name": name, "source": "salesman:%s(emp_id=%s, %s)" % (link, emp_id, "employee_dict" if _has_table(db, ar_caliber.EMPLOYEE_DICT_TABLE) else "abst_operator"), "emp_id": emp_id, "reason": ""} reasons.append(why) else: reasons.append(link_reason) name, why = _dept_assignee(db, unit_code, params) if name: return {"name": name, "source": why, "emp_id": None, "reason": ""} reasons.append(why) return {"name": None, "source": "", "emp_id": None, "reason": "; ".join(r for r in reasons if r)} default = str(params.get("assignee_default") or "").strip() if default: return {"name": default, "source": "assignee_default", "emp_id": None, "reason": ""} return {"name": None, "source": "", "emp_id": None, "reason": "no_assignee_source"} def aggregate_assignee(params: Dict[str, Any]) -> Tuple[Optional[str], str]: """聚合预警的责任人(整体口径):assignee_default → assignee_source.aggregate_assignee。""" d = str(params.get("assignee_default") or "").strip() if d: return d, "assignee_default" a = str(_source_cfg(params).get("aggregate_assignee") or "").strip() if a: return a, "assignee_source.aggregate_assignee" return None, "aggregate_assignee_unresolved" # ============================================================ # 取数与计算 # ============================================================ def _dec(v: Any) -> Decimal: if v is None: return Decimal("0") return Decimal(str(v)) def _money(v: Decimal) -> float: return float(v.quantize(_CENT, rounding=ROUND_HALF_UP)) def _fmt_money(v: Decimal) -> str: return "{:,.2f}".format(v.quantize(_CENT, rounding=ROUND_HALF_UP)) def _fmt_pct(ratio: Optional[float]) -> str: if ratio is None: return "-" return "{:.2f}".format(ratio * 100.0) _SELECT_COLS = ( "unit_code, unit_name, opening_amt, total_amt, " + ", ".join(ar_caliber.BUCKET_COLUMNS) ) def _periods(db: Session, entity_id: int, caliber: str) -> List[str]: rows = db.execute( text( "SELECT DISTINCT period FROM %s WHERE entity_id = :e AND caliber = :c ORDER BY period" % ar_caliber.AR_AGING_TABLE ), {"e": entity_id, "c": caliber}, ).all() return [r[0] for r in rows] def _rows_for_period(db: Session, entity_id: int, caliber: str, period: str) -> List[Dict[str, Any]]: sql = ( "SELECT %s FROM %s WHERE entity_id = :e AND caliber = :c AND period = :p " "ORDER BY total_amt DESC, unit_code ASC" % (_SELECT_COLS, ar_caliber.AR_AGING_TABLE) ) return [dict(r) for r in db.execute(text(sql), {"e": entity_id, "c": caliber, "p": period}).mappings().all()] def _oldest_overdue_days(row: Dict[str, Any]) -> int: """最老非零逾期档的下界天数(31/61/91/121/151);无逾期 = 0。""" days = 0 for col, lower in ar_caliber.OVERDUE_BUCKETS: if _dec(row.get(col)) != 0: days = max(days, lower) return days def _level_share(share: Optional[float], params: Dict[str, Any]) -> Optional[str]: red = params.get("red") or {} yellow = params.get("yellow") or {} gt = red.get("share_90plus_gt") if gt is not None and share is not None and share > float(gt): return "red" ymin, ymax = yellow.get("share_90plus_min"), yellow.get("share_90plus_max") if share is not None and ymin is not None and ymax is not None: if float(ymin) <= share <= float(ymax): return "yellow" return None def _level_mom(mom_pct: Optional[float], params: Dict[str, Any]) -> Optional[str]: red = params.get("red") or {} gte = red.get("count_mom_pct_gte") if gte is not None and mom_pct is not None and mom_pct >= float(gte): return "red" return None 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): return "red" ymin = yellow.get("single_overdue_amt_min") ymax = yellow.get("single_overdue_amt_max") if ymin is not None and ymax is not None: if _dec(ymin) <= overdue_amt <= _dec(ymax): return "yellow" return None def _clip(msg: str) -> str: return msg if len(msg) <= MESSAGE_MAX else msg[: MESSAGE_MAX - 1] + "…" def compute( db: Session, entity_id: int, period: Optional[str] = None, params: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: """计算逾期预警命中集合(纯读,不写库)。 返回: {period, caliber, metrics, aggregates[], per_customer[], skipped[], skipped_reason, notes[]} `skipped_reason` 非空表示本次不生成任何预警(缺期/无数据/分母非正…)。 `skipped[]` 记录 assignee 解析失败而未生成的户/聚合项(§6.2 禁落空 assignee)。 """ params = params or {} err = validate_params(params) if err: raise ParamsInvalid(err) caliber = _params_caliber(params) notes: List[str] = [] out: Dict[str, Any] = { "period": period, "caliber": caliber, "metrics": {}, "aggregates": [], "per_customer": [], "skipped": [], "skipped_reason": None, "notes": notes, } # ── 期间解析:默认表内 MAX(period);缺期直接 skipped(不补 0)── periods = _periods(db, entity_id, caliber) if not periods: out["skipped_reason"] = "no_data_in_table:%s/%s" % (entity_id, caliber) return out if not period: period = periods[-1] out["period"] = period notes.append("period 未指定 → 取表内 MAX(period)=%s" % period) if period not in periods: out["skipped_reason"] = "no_data_for_period:%s" % period return out rows = _rows_for_period(db, entity_id, caliber, period) if not rows: out["skipped_reason"] = "no_data_for_period:%s" % period return out exclude = set(params.get("exclude_unit_codes") or []) if exclude: before = len(rows) rows = [r for r in rows if str(r.get("unit_code")) not in exclude] notes.append("排除名单命中 %d 行(exclude_unit_codes)" % (before - len(rows))) if not rows: out["skipped_reason"] = "all_rows_excluded" return out total = sum((_dec(r.get("total_amt")) for r in rows), Decimal("0")) overdue_90plus = sum((_dec(r.get(c)) for r in rows for c in ar_caliber.NINETY_PLUS_COLUMNS), Decimal("0")) share = (float(overdue_90plus / total) if total > 0 else None) if total <= 0: notes.append("分母 SUM(total_amt)=%.2f ≤ 0 → 跳过聚合占比判据(禁除零)" % float(total)) # 逐户:逾期额 = total_amt - b_0_30(等价 31-60..150+ 之和,负档位不抹平) customers: List[Dict[str, Any]] = [] n_overdue = 0 for r in rows: o_i = _dec(r.get("total_amt")) - _dec(r.get("b_0_30")) oldest = _oldest_overdue_days(r) if o_i > 0: n_overdue += 1 level = _level_customer(o_i, oldest, params) if o_i > 0 else None customers.append({"row": r, "overdue_amt": o_i, "oldest_days": oldest, "level": level}) # 环比:上一个有数据期(同口径同算法);上期无数据 ⇒ 不算环比(文案写明,不得当 0) idx = periods.index(period) prev_period = periods[idx - 1] if idx > 0 else None n_prev = None prev_rows: List[Dict[str, Any]] = [] if prev_period: prev_rows = _rows_for_period(db, entity_id, caliber, prev_period) if exclude: prev_rows = [r for r in prev_rows if str(r.get("unit_code")) not in exclude] n_prev = sum( 1 for r in prev_rows if (_dec(r.get("total_amt")) - _dec(r.get("b_0_30"))) > 0 ) mom_pct = None if n_prev and n_prev > 0: mom_pct = (n_overdue - n_prev) / n_prev else: notes.append("上期无数据(%s)→ 不计算环比" % (prev_period or "无更早期间")) metrics = { "period": period, "caliber": caliber, "total_amt": _money(total), "overdue_90plus": _money(overdue_90plus), "share_90plus": (round(share, 6) if share is not None else None), "overdue_amt": _money(sum((c["overdue_amt"] for c in customers), Decimal("0"))), "customers": len(rows), "overdue_customers": n_overdue, "prev_period": prev_period, "prev_overdue_customers": n_prev, "count_mom_pct": (round(mom_pct, 6) if mom_pct is not None else None), } out["metrics"] = metrics # ── 聚合判据(§6.1):90 天占比(red 键 null ⇒ 停用)/ 逾期户数环比 / 150+ 结构恶化 ── agg_assignee, agg_src = aggregate_assignee(params) def _push_agg(key: str, level: str, metrics_dict: Dict[str, Any], msg: str, action: str) -> None: blocked = None if agg_assignee else agg_src out["aggregates"].append({ "key": key, "level": level, "metrics": metrics_dict, "alert_message": msg, "suggestion": action, "assignee": agg_assignee or "", "assignee_source": agg_src, "blocked": blocked, }) if blocked: out["skipped"].append({"scope": "aggregate", "key": key, "level": level, "reason": blocked}) red_cfg = params.get("red") or {} yellow_cfg = params.get("yellow") or {} red_gt = red_cfg.get("share_90plus_gt") if share is not None and red_gt is None: notes.append("red.share_90plus_gt 为 null ⇒ 90 天占比红线停用(不回落 0.25)") # 聚合判据 1:90 天以上占比(红线停用时只剩黄线 15%–25%) if share is not None: level = _level_share(share, params) if level: action = _action_text(params, level) thr = ("红线 >%g%%" % (float(red_gt) * 100)) if red_gt is not None else "红线停用(DB 置 null)" msg = _clip( "【应收账款逾期】%s %s 90 天以上 ¥%s,占应收总额 %s%%(%s)|建议:%s" % (period, caliber, _fmt_money(overdue_90plus), _fmt_pct(share), thr, action or "见规则配置") ) _push_agg("share_90plus", level, {"overdue_90plus": _money(overdue_90plus), "total_amt": _money(total), "share_90plus": round(share, 6)}, msg, action) # 聚合判据 2:逾期户数环比 level = _level_mom(mom_pct, params) if level: action = _action_text(params, level) gte = red_cfg.get("count_mom_pct_gte") msg = _clip( "【应收账款逾期】%s 逾期户数 %d 户,环比 %s%%(红线 +%s%%)|建议:%s" % (period, n_overdue, ("%+.2f" % (mom_pct * 100)) if mom_pct is not None else "-", ("%g" % (float(gte) * 100)) if gte is not None else "-", action or "见规则配置") ) _push_agg("count_mom", level, {"overdue_customers": n_overdue, "prev_overdue_customers": n_prev, "count_mom_pct": (round(mom_pct, 6) if mom_pct is not None else None)}, msg, action) # 聚合判据 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: 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_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 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_90_150", "red", {"share_90_150": round(share_90_150, 6), "prev_period": prev_period, "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( "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: if not c["level"]: continue r = c["row"] who = resolve_assignee(db, entity_id, r.get("unit_code"), r.get("unit_name"), params, period) if not who["name"]: out["skipped"].append({ "scope": "customer", "unit_code": r.get("unit_code"), "unit_name": r.get("unit_name"), "level": c["level"], "overdue_amt": _money(c["overdue_amt"]), "oldest_days": c["oldest_days"], "reason": who["reason"], }) continue action = _action_text(params, c["level"]) unit_total = _dec(r.get("total_amt")) pct = (float(c["overdue_amt"] / unit_total) if unit_total > 0 else None) msg = _clip( "【应收账款逾期】%s(%s)%s 账龄最老 %d 天,逾期合计 ¥%s(占该户 %s%%)|建议:%s|责任人:%s" % ( r.get("unit_name"), r.get("unit_code"), period, c["oldest_days"], _fmt_money(c["overdue_amt"]), _fmt_pct(pct), action or "见规则配置", who["name"], ) ) out["per_customer"].append({ "unit_code": r.get("unit_code"), "unit_name": r.get("unit_name"), "overdue_amt": _money(c["overdue_amt"]), "total_amt": _money(unit_total), "oldest_days": c["oldest_days"], "pct_of_unit": (round(pct, 6) if pct is not None else None), "level": c["level"], "alert_message": msg, "suggestion": action, "assignee": who["name"], "assignee_source": who["source"], "emp_id": who["emp_id"], }) return out # ============================================================ # 落库(只 INSERT;同 message 幂等跳过) # ============================================================ def hits(result: Dict[str, Any]) -> List[Dict[str, Any]]: """可落库命中项:只收 assignee 非空的(§6.2 禁落空 assignee 行)。""" all_hits = list(result.get("aggregates") or []) + list(result.get("per_customer") or []) return [h for h in all_hits if str(h.get("assignee") or "").strip()] # ============================================================ # 去重键(生成侧语义键):period|scope|ident # ============================================================ _AGG_KEY_FAMILY = { "share_90plus": "share_90plus", "count_mom": "count_mom", "structure_90_150": "structure", # 族名,不跟随判据代次 "structure_150p": "structure", } _CUST_MSG_RE = re.compile(r"(([A-Za-z0-9_\-]+))(\d{4}-\d{2})") _PERIOD_RE = re.compile(r"(\d{4}-\d{2})") def _agg_family(key: str) -> str: if key in _AGG_KEY_FAMILY: return _AGG_KEY_FAMILY[key] return str(key).split("_")[0] or "unknown" def _item_dedupe_key(item: Dict[str, Any], period: str) -> Optional[str]: """生成侧幂等键;不解析文案。 - 单户:`|cust|` - 聚合:`|agg|<族名>`(structure_90_150 / structure_150p 同归 structure) - 无法判定 → None(调用方退回文案逐字比较,方向安全) """ p = str(item.get("period") or period or "").strip() if not p: return None unit_code = str(item.get("unit_code") or "").strip() if unit_code: return "%s|cust|%s" % (p, unit_code) key = str(item.get("key") or "").strip() if key: return "%s|agg|%s" % (p, _agg_family(key)) return None def _key_from_message(msg: str) -> Optional[str]: """历史兼容:从旧文案只读反解 dedupe_key(仅用于 dedupe_key IS NULL 的行)。 反解失败返回 None ⇒ 视为未命中 ⇒ 照常插入(fail-open,多插一条而非吞真实告警)。 """ if not msg: return None m = _CUST_MSG_RE.search(msg) if m: return "%s|cust|%s" % (m.group(2), m.group(1)) pm = _PERIOD_RE.search(msg) if not pm: return None period = pm.group(1) if "90 天以上" in msg and "占应收总额" in msg: return "%s|agg|share_90plus" % period if "逾期户数" in msg and "环比" in msg: return "%s|agg|count_mom" % period if "账龄" in msg and ("结构" in msg or "90–150" in msg or "150 天以上" in msg): return "%s|agg|structure" % period return None def _legacy_keys(db: Session, entity_id: int, kpi_id: int) -> List[str]: """取历史行(dedupe_key IS NULL)反解出的键集合(边界层,量级=历史行数)。""" rows = db.query(KPIAlert.alert_message).filter( KPIAlert.entity_id == entity_id, KPIAlert.kpi_id == kpi_id, KPIAlert.alert_type == ALERT_TYPE, KPIAlert.dedupe_key.is_(None), ).all() keys = [] for (m,) in rows: k = _key_from_message(m or "") if k: keys.append(k) return keys def count_would_insert(db: Session, entity_id: int, kpi_id: Optional[int], result: Dict[str, Any]) -> int: """dry-run 用:按与 write_alerts 完全相同的判重口径,算出真正会插入的条数。""" if kpi_id is None: return 0 period = str(result.get("period") or "") existing = { k for (k,) in db.query(KPIAlert.dedupe_key).filter( KPIAlert.entity_id == entity_id, KPIAlert.kpi_id == kpi_id, KPIAlert.alert_type == ALERT_TYPE, KPIAlert.dedupe_key.isnot(None), ).all() if k } existing.update(_legacy_keys(db, entity_id, kpi_id)) seen = set() n = 0 for h in hits(result): msg = h.get("alert_message") or "" if not msg: continue dk = _item_dedupe_key(h, period) if dk is None: # 无键 ⇒ 退回文案逐字(与旧行为一致) n += 1 continue if dk in existing or dk in seen: continue seen.add(dk) n += 1 return n def write_alerts(db: Session, entity_id: int, kpi_id: int, result: Dict[str, Any]) -> Dict[str, int]: """把命中集合写成 `kpi_alerts(alert_type='overdue')`;历史行零触碰。 判重三级:① dedupe_key 命中 ② 历史 NULL 行反解键命中 ③ 无命中 → INSERT。 并发兜底:唯一索引 + savepoint 捕获 IntegrityError 计跳过。 """ inserted = 0 skipped = 0 period = str(result.get("period") or "") existing = { k for (k,) in db.query(KPIAlert.dedupe_key).filter( KPIAlert.entity_id == entity_id, KPIAlert.kpi_id == kpi_id, KPIAlert.alert_type == ALERT_TYPE, KPIAlert.dedupe_key.isnot(None), ).all() if k } existing.update(_legacy_keys(db, entity_id, kpi_id)) for h in hits(result): msg = h.get("alert_message") or "" if not msg: continue dk = _item_dedupe_key(h, period) if dk is not None: if dk in existing: skipped += 1 continue else: # 无键(理论上不会发生):退回逐字判重(旧行为) dup = db.query(KPIAlert.id).filter( KPIAlert.entity_id == entity_id, KPIAlert.kpi_id == kpi_id, KPIAlert.alert_type == ALERT_TYPE, KPIAlert.alert_message == msg, ).first() if dup: skipped += 1 continue row = KPIAlert( entity_id=entity_id, kpi_id=kpi_id, kpi_value_id=None, alert_level=h.get("level") or "yellow", alert_message=msg, alert_type=ALERT_TYPE, dedupe_key=dk, suggestion=h.get("suggestion") or None, status="pending", assignee=h.get("assignee") or None, ) sp = db.begin_nested() try: db.add(row) db.flush() sp.commit() inserted += 1 if dk: existing.add(dk) except IntegrityError: sp.rollback() skipped += 1 if inserted: db.commit() return {"inserted": inserted, "skipped_duplicate": skipped}