Files
cma-management/backend/app/services/ar_overdue_alert.py
T

773 lines
33 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""应收账龄逾期预警(`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')`,同
`(entity_id, kpi_id, alert_type, alert_message)` 已存在则跳过(`alert_message`
含 period+对象 ⇒ 天然去重键)。**零 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
from decimal import Decimal, ROUND_HALF_UP
from typing import Any, Dict, List, Optional, Tuple
from sqlalchemy import text
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()]
def write_alerts(db: Session, entity_id: int, kpi_id: int, result: Dict[str, Any]) -> Dict[str, int]:
"""把命中集合写成 `kpi_alerts(alert_type='overdue')`;历史行零触碰。"""
inserted = 0
skipped = 0
for h in hits(result):
msg = h.get("alert_message") or ""
if not msg:
continue
exists = 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 exists:
skipped += 1
continue
db.add(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,
suggestion=h.get("suggestion") or None,
status="pending",
assignee=h.get("assignee") or None,
))
inserted += 1
if inserted:
db.commit()
return {"inserted": inserted, "skipped_duplicate": skipped}