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:
committed by
Hermes CI Fix
parent
790be2d6b1
commit
71867c4c7a
@@ -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}
|
||||
Reference in New Issue
Block a user