ci/woodpecker/push/main Pipeline was successful
D2:%d 为最老逾期天数、%s 为 31 天以上逾期合计,旧文案「期末逾期 151 天以上 ¥300,120」 会被读成最老档即 30 万(HKJJ120 150+ 实为 65,115.00)。只改文案,判据/params/历史行零变更。 新增回归用例:HKJJ120 逐档实数 ⇒ 账龄最老 151 天,逾期合计 ¥300,120.00(占该户 85.14%)。
773 lines
33 KiB
Python
773 lines
33 KiB
Python
"""应收账龄逾期预警(`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}
|