feat(ar): 应收账龄看板 + 逾期预警 overdue 接入(阶段A)

T1 逾期预警(§6 财务 2026-10-20 23:23 口径):
- alert_rules 支持 rule_type=ar_overdue;阈值/责任人/排除名单全量走 params(代码零业务常量)
- 90 天占比红线 key 保留但默认 null 停用(不回落 0.25);黄线 15%-25% 保留
- 新增逐户红线「>30 天且 >5 万」+ 聚合红线「150+ 占比环比上升 = 结构恶化」
- assignee 派生:manual → 近 3 个月主业务员(zxt_masterbill emp_id → employee_dict.u_Name,
  缺表时降级同库摘要制单人姓名)→ 部门负责人(渠道 118 / 商务 114 / 直属酒窖 117);
  两路皆失败 ⇒ 该户不生成、进 skipped[],禁落空 assignee
- POST /run-ar-overdue(dry_run 默认 true)+ check-all 分支;只 INSERT 同一 message 幂等

T2 账龄看板:
- GET /api/cma/ar-aging/{meta,trend,overview,customers,customers/export};每条查询带 entity_id
- 三口径分列、caliber=all → 400;缺期 has_data=false 且金额 null(不补 0)
- ArAging.vue:口径后缀/截止期徽标/趋势断点/六档堆叠/逐户可排序导出

单测 21 项全通过(SQLite 夹具,不连生产库);vite build 通过。
阶段A 零生产写:未 push、未部署、未 --apply、未 dry_run=false。
This commit is contained in:
fullstack-bot
2026-10-21 08:42:24 +08:00
committed by Hermes CI Fix
parent 790be2d6b1
commit 71867c4c7a
11 changed files with 2552 additions and 4 deletions
+706
View File
@@ -0,0 +1,706 @@
"""应收账龄逾期预警(`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"):
if 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 {}
red_amt = red.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:150+ 档占比环比上升 ⇒ 账龄结构恶化(§6.1 新增红线 2)
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}