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
@@ -2,7 +2,7 @@
|
||||
预警规则智能化 — 任务6
|
||||
后端组件: alert_rules 模型 + API + 预警引擎
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import text, Column, Integer, String, Text, Float, DateTime, JSON, Boolean, func
|
||||
from typing import Optional, List
|
||||
@@ -26,6 +26,8 @@ from app.utils.cash_thresholds import (
|
||||
CASH_GAP_RULE_TYPE,
|
||||
validate_params as validate_cash_gap_params,
|
||||
)
|
||||
# 应收账龄逾期预警(T1 · 2026-10-20):计算/落库单源在服务层,阈值全走 alert_rules.params
|
||||
from app.services import ar_overdue_alert
|
||||
|
||||
logger = logging.getLogger("alert_rules")
|
||||
|
||||
@@ -38,6 +40,7 @@ VALID_RULE_TYPES = (
|
||||
"forecast_deviation", # 预测偏差(升级2b 2026-08-25)
|
||||
"customer_concentration", # 客户集中度(P2 2026-09-10)
|
||||
"cash_gap_threshold", # 资金缺口阈值配置(G1 2026-10-17,配置行,不进预警生成链)
|
||||
"ar_overdue", # 应收账龄逾期(T1 2026-10-20:90天以上占比 / 逐户逾期额 / 逾期户数环比)
|
||||
)
|
||||
|
||||
# ============================================================
|
||||
@@ -142,6 +145,11 @@ def create_alert_rule(data: dict, db: Session = Depends(get_db), user=Depends(re
|
||||
err = validate_cash_gap_params(data.get("params"))
|
||||
if err:
|
||||
raise HTTPException(400, err)
|
||||
# T1(2026-10-20):ar_overdue 参数只校结构(阈值/责任人全量由 DB 行给,代码不设默认值)
|
||||
if rule_type == ar_overdue_alert.RULE_TYPE:
|
||||
err = ar_overdue_alert.validate_params(data.get("params"))
|
||||
if err:
|
||||
raise HTTPException(400, err)
|
||||
|
||||
rule = AlertRule(
|
||||
entity_id=entity_id,
|
||||
@@ -180,6 +188,12 @@ def update_alert_rule(rule_id: int, data: dict, db: Session = Depends(get_db), e
|
||||
if err:
|
||||
db.rollback()
|
||||
raise HTTPException(400, err)
|
||||
# T1(2026-10-20):ar_overdue 改参同样只校结构(阈值可现场改,无需发版)
|
||||
if rule.rule_type == ar_overdue_alert.RULE_TYPE:
|
||||
err = ar_overdue_alert.validate_params(rule.params)
|
||||
if err:
|
||||
db.rollback()
|
||||
raise HTTPException(400, err)
|
||||
db.commit()
|
||||
db.refresh(rule)
|
||||
return {"data": {c.name: getattr(rule, c.name) for c in AlertRule.__table__.columns}}
|
||||
@@ -307,6 +321,31 @@ def run_all_alert_checks(db: Session = Depends(get_db), entity_id: int = Depends
|
||||
# 不进 KPIValue 比价链(否则未来若绑定到有值的 KPI 会误生成预警)。
|
||||
if rule.rule_type == CASH_GAP_RULE_TYPE:
|
||||
continue
|
||||
# T1(2026-10-20):应收账龄逾期 —— 与 POST /run-ar-overdue 同一计算函数,
|
||||
# 不走 KPIValue 比价链(F_AR_002 无本期 kpi_values 也不得报错)。
|
||||
if rule.rule_type == ar_overdue_alert.RULE_TYPE:
|
||||
rp = rule.params
|
||||
if isinstance(rp, str):
|
||||
rp = json.loads(rp) if rp else {}
|
||||
rp = rp or {}
|
||||
try:
|
||||
ar_overdue_alert.ensure_assignee_configured(rp)
|
||||
res = ar_overdue_alert.compute(db, entity_id, None, rp)
|
||||
except (ar_overdue_alert.AssigneeMissing, ar_overdue_alert.ParamsInvalid) as exc:
|
||||
logger.warning("ar_overdue 规则跳过(rule_id=%s): %s", rule.id, exc)
|
||||
continue
|
||||
if res.get("skipped_reason"):
|
||||
logger.info("ar_overdue 规则无数据跳过(rule_id=%s): %s",
|
||||
rule.id, res["skipped_reason"])
|
||||
continue
|
||||
try:
|
||||
w = ar_overdue_alert.write_alerts(db, entity_id, rule.kpi_id, res)
|
||||
except Exception: # noqa: BLE001 — 单规则失败不拖垮整轮检查
|
||||
db.rollback()
|
||||
logger.exception("ar_overdue 落库失败(rule_id=%s)", rule.id)
|
||||
continue
|
||||
alerts_generated += w["inserted"]
|
||||
continue
|
||||
kpi = kpi_cache.get(rule.kpi_id)
|
||||
if kpi is None:
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == rule.kpi_id).first()
|
||||
@@ -316,7 +355,7 @@ def run_all_alert_checks(db: Session = Depends(get_db), entity_id: int = Depends
|
||||
if not kpi or kpi.entity_id != entity_id:
|
||||
continue
|
||||
|
||||
import json; params = json.loads(rule.params) if isinstance(rule.params, str) else (rule.params or {})
|
||||
params = json.loads(rule.params) if isinstance(rule.params, str) else (rule.params or {})
|
||||
|
||||
# 客户集中度规则(P2 2026-09-10):不依赖同期KPIValue,直接看客户收入占比
|
||||
if rule.rule_type == "customer_concentration":
|
||||
@@ -655,7 +694,7 @@ def _check_forecast_alerts(db: Session, entity_id: int = 1) -> int:
|
||||
continue
|
||||
|
||||
# 检查预测值是否超限
|
||||
import json; params = json.loads(rule.params) if isinstance(rule.params, str) else (rule.params or {})
|
||||
params = json.loads(rule.params) if isinstance(rule.params, str) else (rule.params or {})
|
||||
params["kpi"] = kpi
|
||||
for forecast in latest_forecasts:
|
||||
value = forecast.predicted_cash
|
||||
@@ -979,3 +1018,135 @@ def run_customer_concentration_check(
|
||||
continue
|
||||
db.commit()
|
||||
return {"message": f"客户集中度检查完成: {generated}条", "generated": generated}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# T1(2026-10-20):应收账龄逾期预警 —— 运行入口 + 手工触发
|
||||
# ============================================================
|
||||
def _resolve_ar_kpi(db: Session, entity_id: int, params: dict, rule, override_kpi_id):
|
||||
"""解析 ar_overdue 绑定的 KPI:规则行 → 显式 override(仅 dry-run)→ params.kpi_code。"""
|
||||
if rule is not None and rule.kpi_id:
|
||||
kpi = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.id == rule.kpi_id, KPIDefinition.entity_id == entity_id).first()
|
||||
return (kpi.id if kpi else rule.kpi_id), (kpi.kpi_code if kpi else None), "alert_rules.kpi_id"
|
||||
if override_kpi_id:
|
||||
kpi = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.id == override_kpi_id, KPIDefinition.entity_id == entity_id).first()
|
||||
if not kpi:
|
||||
raise HTTPException(400, f"KPI 不存在或不属于当前账套: kpi_id={override_kpi_id}")
|
||||
return kpi.id, kpi.kpi_code, "query.kpi_id(dry-run override)"
|
||||
code = (params or {}).get("kpi_code")
|
||||
if code:
|
||||
kpi = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.kpi_code == code, KPIDefinition.entity_id == entity_id).first()
|
||||
if kpi:
|
||||
return kpi.id, kpi.kpi_code, f"params.kpi_code={code}"
|
||||
return None, code, f"params.kpi_code={code}(库内不存在)"
|
||||
return None, None, "unresolved"
|
||||
|
||||
|
||||
@router.post("/run-ar-overdue")
|
||||
def run_ar_overdue_check(
|
||||
period: Optional[str] = Query(None, description="账龄基准期 YYYY-MM;缺省=表内 MAX(period)"),
|
||||
dry_run: bool = Query(True, description="默认 true:只算不写。false 走既有写通道插入 kpi_alerts"),
|
||||
kpi_id: Optional[int] = Query(None, description="dry-run 专用:临时绑定 KPI(正式以规则行 kpi_id 为准)"),
|
||||
data: Optional[dict] = Body(None, description="dry-run 专用:{'params': {...}} 覆盖规则参数(证明参数化,不碰 DB)"),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""应收账龄逾期预警运行(T1)。
|
||||
|
||||
参数优先级:dry-run body 覆盖 > `alert_rules` 行(rule_type='ar_overdue')> 无规则 ⇒ 200 skipped。
|
||||
阈值/责任人/排除名单全部来自参数;`assignee` 缺失 ⇒ 400(fail-closed)。
|
||||
`dry_run=false` 时禁止任何参数覆盖(防绕过 DB 配置),且只 INSERT 不 UPDATE/DELETE。
|
||||
"""
|
||||
if is_excluded_entity(db, entity_id):
|
||||
return {"message": f"账套 {entity_id} 为 demo 演示账套,按 R1 不参与预警生成",
|
||||
"generated": 0, "skipped": True, "reason": "R1_demo_entity_excluded"}
|
||||
|
||||
override_params = None
|
||||
if isinstance(data, dict):
|
||||
override_params = data.get("params") if isinstance(data.get("params"), dict) else data.get("params_override")
|
||||
if (override_params is not None or kpi_id is not None) and not dry_run:
|
||||
raise HTTPException(
|
||||
400, "dry_run=false 时禁止参数/KPI 覆盖:阈值与绑定必须来自 alert_rules 行(防绕过 DB 配置)")
|
||||
|
||||
rule = db.query(AlertRule).filter(
|
||||
AlertRule.entity_id == entity_id,
|
||||
AlertRule.rule_type == ar_overdue_alert.RULE_TYPE,
|
||||
AlertRule.enabled == 1,
|
||||
).order_by(AlertRule.id).first()
|
||||
|
||||
params_source = "dry_run.body.params"
|
||||
if override_params is not None:
|
||||
params = override_params
|
||||
elif rule is not None:
|
||||
params = rule.params
|
||||
if isinstance(params, str):
|
||||
params = json.loads(params) if params else {}
|
||||
params = params or {}
|
||||
params_source = f"alert_rules#{rule.id}"
|
||||
else:
|
||||
return {
|
||||
"dry_run": dry_run, "entity_id": entity_id, "period": period,
|
||||
"skipped": True, "skipped_reason": "no_ar_overdue_rule",
|
||||
"hint": "无 rule_type='ar_overdue' 规则行;阶段B 用 backend/scripts/seed_ar_overdue_rule.py 播种,"
|
||||
"或 dry-run 时在 body 传 {'params': {...}} 演练",
|
||||
}
|
||||
|
||||
err = ar_overdue_alert.validate_params(params)
|
||||
if err:
|
||||
raise HTTPException(400, err)
|
||||
try:
|
||||
ar_overdue_alert.ensure_assignee_configured(params)
|
||||
except ar_overdue_alert.AssigneeMissing as e:
|
||||
raise HTTPException(400, str(e))
|
||||
|
||||
resolved_kpi_id, kpi_code, binding = _resolve_ar_kpi(db, entity_id, params, rule, kpi_id)
|
||||
|
||||
try:
|
||||
result = ar_overdue_alert.compute(db, entity_id, period, params)
|
||||
except ar_overdue_alert.ParamsInvalid as e:
|
||||
raise HTTPException(400, str(e))
|
||||
|
||||
hits = ar_overdue_alert.hits(result)
|
||||
blockers = []
|
||||
if resolved_kpi_id is None:
|
||||
blockers.append("kpi_id_unresolved:%s" % (kpi_code or "-"))
|
||||
|
||||
payload = {
|
||||
"dry_run": dry_run,
|
||||
"entity_id": entity_id,
|
||||
"period": result.get("period"),
|
||||
"caliber": result.get("caliber"),
|
||||
"params_source": params_source,
|
||||
"kpi_id": resolved_kpi_id,
|
||||
"kpi_code": kpi_code,
|
||||
"kpi_binding": binding,
|
||||
"skipped_reason": result.get("skipped_reason"),
|
||||
"metrics": result.get("metrics"),
|
||||
"aggregates": result.get("aggregates"),
|
||||
"per_customer": result.get("per_customer"),
|
||||
"skipped": result.get("skipped"),
|
||||
"hit_count": len(hits),
|
||||
"notes": result.get("notes"),
|
||||
}
|
||||
if result.get("skipped_reason"):
|
||||
payload.update({"would_insert": 0, "write_blockers": blockers})
|
||||
return payload
|
||||
|
||||
if dry_run:
|
||||
payload.update({"would_insert": 0 if blockers else len(hits), "write_blockers": blockers})
|
||||
return payload
|
||||
|
||||
if blockers:
|
||||
raise HTTPException(400, "无法落库:" + "; ".join(blockers))
|
||||
written = ar_overdue_alert.write_alerts(db, entity_id, resolved_kpi_id, result)
|
||||
log_op(db, "run_ar_overdue_alert", target_type="alert", target_id=resolved_kpi_id,
|
||||
detail={"period": result.get("period"), "caliber": result.get("caliber"),
|
||||
"hit": len(hits), **written},
|
||||
entity_id=entity_id)
|
||||
db.commit()
|
||||
payload.update({"would_insert": len(hits), "write_blockers": [], **written,
|
||||
"generated": written["inserted"]})
|
||||
return payload
|
||||
|
||||
@@ -0,0 +1,467 @@
|
||||
"""应收账龄看板 API(T2 · 2026-10-20)— prefix `/api/cma/ar-aging`
|
||||
|
||||
数据源:`hanke_ar_aging`(逐户·逐期·三口径分列,财务口径见 `app.utils.ar_caliber`)。
|
||||
|
||||
纪律(全部硬约束):
|
||||
1. **每条查询都带 `entity_id` 过滤**(防两企业混报);本模块只读,无 ORM 写模型/无迁移。
|
||||
2. 三口径**禁合计**:`caliber=all` 或未知字面值 → 400;默认口径 = `借方应收户口径`。
|
||||
3. 缺期显「该期无数据」:`has_data=false` 且金额字段为 `null`(**禁补 0**)。
|
||||
4. `opening_amt`(期初)**只展示**,单独返回并标 `non_aging_base=true`。
|
||||
5. 表不存在/无权限 ⇒ `available=false` + 原因,**HTTP 200 不 500**。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.deps import get_entity_id
|
||||
from app.auth_middleware import require_role
|
||||
from app.utils import ar_caliber
|
||||
|
||||
logger = logging.getLogger("ar_aging")
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/cma/ar-aging",
|
||||
tags=["应收账龄看板"],
|
||||
dependencies=[Depends(require_role("ceo", "finance", "business"))],
|
||||
)
|
||||
|
||||
_CENT = Decimal("0.01")
|
||||
_TABLE = ar_caliber.AR_AGING_TABLE
|
||||
_BUCKET_COLS = ar_caliber.BUCKET_COLUMNS
|
||||
_SORTABLE = {
|
||||
"unit_code", "unit_name", "total_amt", "overdue_amt", "opening_amt", "oldest_days",
|
||||
*_BUCKET_COLS,
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 公共helper
|
||||
# ============================================================
|
||||
def _resolve_caliber(caliber: Optional[str]) -> str:
|
||||
try:
|
||||
return ar_caliber.validate_caliber(caliber, allow_none=True)
|
||||
except ar_caliber.CaliberError as e:
|
||||
raise HTTPException(400, str(e))
|
||||
|
||||
|
||||
def _dec(v: Any) -> Decimal:
|
||||
return Decimal("0") if v is None else Decimal(str(v))
|
||||
|
||||
|
||||
def _money(v: Any) -> Optional[float]:
|
||||
if v is None:
|
||||
return None
|
||||
return float(_dec(v).quantize(_CENT, rounding=ROUND_HALF_UP))
|
||||
|
||||
|
||||
def _money_str(v: Any) -> str:
|
||||
return "{:.2f}".format(_dec(v).quantize(_CENT, rounding=ROUND_HALF_UP))
|
||||
|
||||
|
||||
def _available(db: Session) -> tuple[bool, Optional[str]]:
|
||||
try:
|
||||
db.execute(text("SELECT 1 FROM %s LIMIT 1" % _TABLE)).first()
|
||||
return True, None
|
||||
except Exception as e: # noqa: BLE001 — 表缺失/无权限一律降级
|
||||
db.rollback()
|
||||
logger.warning("hanke_ar_aging 不可用: %s", e)
|
||||
return False, "表 %s 不可用(不存在或无 SELECT 权限): %s" % (_TABLE, str(e).split("\n")[0][:180])
|
||||
|
||||
|
||||
def _periods(db: Session, entity_id: int) -> List[str]:
|
||||
rows = db.execute(
|
||||
text("SELECT DISTINCT period FROM %s WHERE entity_id = :e ORDER BY period" % _TABLE),
|
||||
{"e": entity_id},
|
||||
).all()
|
||||
return [r[0] for r in rows]
|
||||
|
||||
|
||||
def _month_add(period: str, delta: int) -> str:
|
||||
y, m = int(period[:4]), int(period[5:7])
|
||||
idx = y * 12 + (m - 1) + delta
|
||||
return "%04d-%02d" % (idx // 12, idx % 12 + 1)
|
||||
|
||||
|
||||
def _month_range(start: str, end: str) -> List[str]:
|
||||
if start > end:
|
||||
return []
|
||||
out, cur = [], start
|
||||
while cur <= end:
|
||||
out.append(cur)
|
||||
cur = _month_add(cur, 1)
|
||||
return out
|
||||
|
||||
|
||||
def _rows(db: Session, entity_id: int, caliber: str, period: str) -> List[Dict[str, Any]]:
|
||||
sql = (
|
||||
"SELECT id, period, caliber, unit_code, unit_name, opening_amt, total_amt, %s "
|
||||
"FROM %s WHERE entity_id = :e AND caliber = :c AND period = :p "
|
||||
"ORDER BY total_amt DESC, unit_code ASC"
|
||||
% (", ".join(_BUCKET_COLS), _TABLE)
|
||||
)
|
||||
return [dict(r) for r in db.execute(
|
||||
text(sql), {"e": entity_id, "c": caliber, "p": period}).mappings().all()]
|
||||
|
||||
|
||||
def _overdue_amt(row: Dict[str, Any]) -> Decimal:
|
||||
"""逾期额(>30 天)= total_amt − b_0_30;负档位不 ABS/不 COALESCE。"""
|
||||
return _dec(row.get("total_amt")) - _dec(row.get("b_0_30"))
|
||||
|
||||
|
||||
def _oldest_days(row: Dict[str, Any]) -> int:
|
||||
days = 0
|
||||
for col, lower in ar_caliber.OVERDUE_BUCKETS:
|
||||
if _dec(row.get(col)) != 0:
|
||||
days = max(days, lower)
|
||||
return days
|
||||
|
||||
|
||||
def _thresholds(db: Session, entity_id: int) -> Dict[str, Any]:
|
||||
"""看板阈值标注来源:`alert_rules(rule_type='ar_overdue')` 行(无规则 ⇒ 全 null,前端显「阈值未配置」)。
|
||||
|
||||
只读、只取展示用;判定仍以 `app.services.ar_overdue_alert` 单源为准。
|
||||
"""
|
||||
from app.api.alert_rules import AlertRule
|
||||
rule = db.query(AlertRule).filter(
|
||||
AlertRule.entity_id == entity_id,
|
||||
AlertRule.rule_type == "ar_overdue",
|
||||
AlertRule.enabled == 1,
|
||||
).order_by(AlertRule.id).first()
|
||||
if not rule:
|
||||
return {"source": None, "share_90plus_red_gt": None,
|
||||
"share_90plus_yellow_min": None, "share_90plus_yellow_max": None}
|
||||
params = rule.params or {}
|
||||
red = params.get("red") or {}
|
||||
yellow = params.get("yellow") or {}
|
||||
return {
|
||||
"source": "alert_rules#%s" % rule.id,
|
||||
"share_90plus_red_gt": red.get("share_90plus_gt"),
|
||||
"share_90plus_yellow_min": yellow.get("share_90plus_min"),
|
||||
"share_90plus_yellow_max": yellow.get("share_90plus_max"),
|
||||
}
|
||||
|
||||
|
||||
def _enrich(row: Dict[str, Any]) -> Dict[str, Any]:
|
||||
total = _dec(row.get("total_amt"))
|
||||
od = _overdue_amt(row)
|
||||
o90 = sum((_dec(row.get(c)) for c in ar_caliber.NINETY_PLUS_COLUMNS), Decimal("0"))
|
||||
out = {
|
||||
"unit_code": row.get("unit_code"),
|
||||
"unit_name": row.get("unit_name"),
|
||||
"opening_amt": _money(row.get("opening_amt")),
|
||||
"total_amt": _money(total),
|
||||
"buckets": {col: _money(row.get(col)) for col in _BUCKET_COLS},
|
||||
"overdue_amt": _money(od),
|
||||
"overdue_90plus": _money(o90),
|
||||
"oldest_days": _oldest_days(row),
|
||||
"pct_of_unit": (round(float(od / total), 6) if total > 0 else None),
|
||||
"is_overdue": od > 0,
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
# ============================================================
|
||||
# GET /meta
|
||||
# ============================================================
|
||||
@router.get("/meta")
|
||||
def get_meta(db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
"""看板元信息:可用性、口径全集、可用期间、最新期、入表时刻、来源件。"""
|
||||
ok, reason = _available(db)
|
||||
base = {
|
||||
"entity_id": entity_id,
|
||||
"table": _TABLE,
|
||||
"calibers": list(ar_caliber.CALIBERS),
|
||||
"default_caliber": ar_caliber.DEFAULT_CALIBER,
|
||||
"available": ok,
|
||||
"reason": reason,
|
||||
"periods_available": [],
|
||||
"latest_period": None,
|
||||
"case_insensitive_note": "口径字面值逐字匹配;贷方口径为 `贷方户单列(净额)`",
|
||||
}
|
||||
if not ok:
|
||||
return base
|
||||
|
||||
periods = _periods(db, entity_id)
|
||||
base["periods_available"] = periods
|
||||
base["latest_period"] = periods[-1] if periods else None
|
||||
row = db.execute(
|
||||
text("SELECT COUNT(*) n, MAX(loaded_at) loaded, MIN(period) pmin, MAX(period) pmax "
|
||||
"FROM %s WHERE entity_id = :e" % _TABLE),
|
||||
{"e": entity_id},
|
||||
).mappings().first()
|
||||
loaded = row["loaded"]
|
||||
base.update({
|
||||
"row_count": int(row["n"] or 0),
|
||||
"loaded_at": (loaded.isoformat(sep=" ") if hasattr(loaded, "isoformat") else (str(loaded) if loaded else None)),
|
||||
"period_min": row["pmin"],
|
||||
"period_max": row["pmax"],
|
||||
})
|
||||
src = db.execute(
|
||||
text("SELECT DISTINCT source_file FROM %s WHERE entity_id = :e ORDER BY source_file" % _TABLE),
|
||||
{"e": entity_id},
|
||||
).all()
|
||||
base["source_files"] = [r[0] for r in src]
|
||||
if not periods:
|
||||
base["reason"] = "表内该账套无数据"
|
||||
return base
|
||||
|
||||
|
||||
# ============================================================
|
||||
# GET /trend
|
||||
# ============================================================
|
||||
@router.get("/trend")
|
||||
def get_trend(
|
||||
caliber: Optional[str] = Query(None),
|
||||
start: Optional[str] = Query(None, description="起始期 YYYY-MM"),
|
||||
end: Optional[str] = Query(None, description="结束期 YYYY-MM(缺省=表内最新期与当前月取大)"),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""逐期趋势(总额/户数/六档);无数据期 has_data=false 且金额 null(禁补 0)。"""
|
||||
cal = _resolve_caliber(caliber)
|
||||
ok, reason = _available(db)
|
||||
if not ok:
|
||||
return {"available": False, "reason": reason, "entity_id": entity_id,
|
||||
"caliber": cal, "points": []}
|
||||
|
||||
periods = _periods(db, entity_id)
|
||||
if not periods:
|
||||
return {"available": True, "entity_id": entity_id, "caliber": cal,
|
||||
"periods_available": [], "points": [], "reason": "表内该账套无数据"}
|
||||
|
||||
now_period = datetime.now().strftime("%Y-%m")
|
||||
start_p = start or periods[0]
|
||||
end_p = end or max(periods[-1], now_period)
|
||||
for p in (start_p, end_p):
|
||||
if len(p) != 7 or p[4] != "-" or not (p[:4].isdigit() and p[5:].isdigit()):
|
||||
raise HTTPException(400, f"期间格式非法(要求 YYYY-MM): {p}")
|
||||
|
||||
points = []
|
||||
for p in _month_range(start_p, end_p):
|
||||
rows = _rows(db, entity_id, cal, p)
|
||||
if not rows:
|
||||
points.append({
|
||||
"period": p, "has_data": False,
|
||||
"total_amt": None, "customers": None, "overdue_amt": None,
|
||||
"buckets": {col: None for col in _BUCKET_COLS},
|
||||
"note": "该期无数据",
|
||||
})
|
||||
continue
|
||||
total = sum((_dec(r.get("total_amt")) for r in rows), Decimal("0"))
|
||||
points.append({
|
||||
"period": p,
|
||||
"has_data": True,
|
||||
"total_amt": _money(total),
|
||||
"customers": len(rows),
|
||||
"overdue_amt": _money(sum((_overdue_amt(r) for r in rows), Decimal("0"))),
|
||||
"buckets": {col: _money(sum((_dec(r.get(col)) for r in rows), Decimal("0")))
|
||||
for col in _BUCKET_COLS},
|
||||
"note": None,
|
||||
})
|
||||
return {
|
||||
"available": True, "entity_id": entity_id, "caliber": cal,
|
||||
"start": start_p, "end": end_p,
|
||||
"periods_available": periods,
|
||||
"points": points,
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# GET /overview
|
||||
# ============================================================
|
||||
@router.get("/overview")
|
||||
def get_overview(
|
||||
period: Optional[str] = Query(None),
|
||||
caliber: Optional[str] = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""期间概览:合计/户数/六档/逾期额/90天以上占比/环比;opening_amt 单列且标非账龄基数。"""
|
||||
cal = _resolve_caliber(caliber)
|
||||
th = _thresholds(db, entity_id)
|
||||
ok, reason = _available(db)
|
||||
if not ok:
|
||||
return {"available": False, "reason": reason, "entity_id": entity_id,
|
||||
"caliber": cal, "period": period, "has_data": False, "thresholds": th}
|
||||
|
||||
periods = _periods(db, entity_id)
|
||||
if not periods:
|
||||
return {"available": True, "entity_id": entity_id, "caliber": cal,
|
||||
"period": period, "has_data": False, "reason": "表内该账套无数据",
|
||||
"thresholds": th}
|
||||
p = period or periods[-1]
|
||||
rows = _rows(db, entity_id, cal, p)
|
||||
if not rows:
|
||||
return {
|
||||
"available": True, "entity_id": entity_id, "caliber": cal, "period": p,
|
||||
"has_data": False, "reason": "该期无数据",
|
||||
"total_amt": None, "customers": None, "overdue_amt": None,
|
||||
"overdue_90plus": None, "share_90plus": None,
|
||||
"buckets": {col: None for col in _BUCKET_COLS},
|
||||
"opening_amt": None, "opening_amt_non_aging_base": True,
|
||||
"overdue_customers": None, "prev_period": None, "prev_overdue_amt": None,
|
||||
"overdue_amt_mom_pct": None, "overdue_customers_mom_pct": None,
|
||||
"thresholds": th,
|
||||
}
|
||||
|
||||
total = sum((_dec(r.get("total_amt")) for r in rows), Decimal("0"))
|
||||
buckets = {col: sum((_dec(r.get(col)) for r in rows), Decimal("0")) for col in _BUCKET_COLS}
|
||||
od = sum((_overdue_amt(r) for r in rows), Decimal("0"))
|
||||
o90 = sum(buckets[c] for c in ar_caliber.NINETY_PLUS_COLUMNS)
|
||||
overdue_customers = sum(1 for r in rows if _overdue_amt(r) > 0)
|
||||
|
||||
idx = periods.index(p) if p in periods else -1
|
||||
prev_period = periods[idx - 1] if idx > 0 else None
|
||||
prev_rows = _rows(db, entity_id, cal, prev_period) if prev_period else []
|
||||
prev_od = sum((_overdue_amt(r) for r in prev_rows), Decimal("0")) if prev_rows else None
|
||||
prev_customers = (sum(1 for r in prev_rows if _overdue_amt(r) > 0) if prev_rows else None)
|
||||
|
||||
def _mom(cur: Optional[Decimal], prev: Optional[Decimal]) -> Optional[float]:
|
||||
if cur is None or prev is None or prev == 0:
|
||||
return None
|
||||
return round(float((cur - prev) / prev), 6)
|
||||
|
||||
return {
|
||||
"available": True,
|
||||
"entity_id": entity_id,
|
||||
"caliber": cal,
|
||||
"period": p,
|
||||
"has_data": True,
|
||||
"total_amt": _money(total),
|
||||
"customers": len(rows),
|
||||
"buckets": {col: _money(v) for col, v in buckets.items()},
|
||||
"buckets_sum": _money(sum(buckets.values())),
|
||||
"buckets_sum_matches_total": _money(sum(buckets.values())) == _money(total),
|
||||
"overdue_amt": _money(od),
|
||||
"overdue_90plus": _money(o90),
|
||||
"share_90plus": (round(float(o90 / total), 6) if total > 0 else None),
|
||||
"overdue_customers": overdue_customers,
|
||||
# 期初只展示:禁作账龄基数、禁进分子分母(财务定死)
|
||||
"opening_amt": _money(sum((_dec(r.get("opening_amt")) for r in rows), Decimal("0"))),
|
||||
"opening_amt_non_aging_base": True,
|
||||
"prev_period": prev_period,
|
||||
"prev_has_data": bool(prev_rows),
|
||||
"prev_overdue_amt": _money(prev_od) if prev_od is not None else None,
|
||||
"prev_overdue_customers": prev_customers,
|
||||
"overdue_amt_mom_pct": _mom(od, prev_od),
|
||||
"overdue_customers_mom_pct": (
|
||||
_mom(Decimal(overdue_customers), Decimal(prev_customers))
|
||||
if prev_customers is not None else None),
|
||||
"mom_note": None if prev_rows else "上期无数据,不计算环比",
|
||||
"thresholds": th,
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# GET /customers
|
||||
# ============================================================
|
||||
@router.get("/customers")
|
||||
def get_customers(
|
||||
period: Optional[str] = Query(None),
|
||||
caliber: Optional[str] = Query(None),
|
||||
overdue_only: bool = Query(False),
|
||||
sort: str = Query("overdue_amt"),
|
||||
order: str = Query("desc"),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""逐户明细(六档 + 逾期额 + 最老档),服务端排序。"""
|
||||
cal = _resolve_caliber(caliber)
|
||||
if sort not in _SORTABLE:
|
||||
raise HTTPException(400, f"sort 不支持: {sort}(可选 {sorted(_SORTABLE)})")
|
||||
if order not in ("asc", "desc"):
|
||||
raise HTTPException(400, "order 仅接受 asc/desc")
|
||||
ok, reason = _available(db)
|
||||
if not ok:
|
||||
return {"available": False, "reason": reason, "entity_id": entity_id,
|
||||
"caliber": cal, "period": period, "has_data": False, "items": [], "total": 0}
|
||||
|
||||
periods = _periods(db, entity_id)
|
||||
if not periods:
|
||||
return {"available": True, "entity_id": entity_id, "caliber": cal,
|
||||
"period": period, "has_data": False, "items": [], "total": 0}
|
||||
p = period or periods[-1]
|
||||
rows = _rows(db, entity_id, cal, p)
|
||||
if not rows:
|
||||
return {"available": True, "entity_id": entity_id, "caliber": cal, "period": p,
|
||||
"has_data": False, "reason": "该期无数据", "items": [], "total": 0}
|
||||
|
||||
items = [_enrich(r) for r in rows]
|
||||
if overdue_only:
|
||||
items = [i for i in items if i["is_overdue"]]
|
||||
items.sort(key=lambda i: (i.get(sort) is None, i.get(sort)), reverse=(order == "desc"))
|
||||
return {
|
||||
"available": True, "entity_id": entity_id, "caliber": cal, "period": p,
|
||||
"has_data": True, "total": len(items), "sort": sort, "order": order,
|
||||
"overdue_only": overdue_only, "items": items,
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# GET /customers/export
|
||||
# ============================================================
|
||||
@router.get("/customers/export")
|
||||
def export_customers(
|
||||
period: Optional[str] = Query(None),
|
||||
caliber: Optional[str] = Query(None),
|
||||
overdue_only: bool = Query(False),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""逐户明细 CSV(UTF-8 BOM,Excel 直开);首段写「截止期/口径/导出时刻」。"""
|
||||
from urllib.parse import quote
|
||||
from starlette.responses import Response
|
||||
|
||||
cal = _resolve_caliber(caliber)
|
||||
ok, reason = _available(db)
|
||||
if not ok:
|
||||
raise HTTPException(503, reason or "数据表不可用")
|
||||
periods = _periods(db, entity_id)
|
||||
if not periods:
|
||||
raise HTTPException(404, "表内该账套无数据")
|
||||
p = period or periods[-1]
|
||||
rows = _rows(db, entity_id, cal, p)
|
||||
if not rows:
|
||||
raise HTTPException(404, f"该期无数据: {p}")
|
||||
|
||||
items = [_enrich(r) for r in rows]
|
||||
if overdue_only:
|
||||
items = [i for i in items if i["is_overdue"]]
|
||||
items.sort(key=lambda i: (i["overdue_amt"] is None, -(i["overdue_amt"] or 0)))
|
||||
|
||||
buf = io.StringIO()
|
||||
w = csv.writer(buf)
|
||||
w.writerow(["截止期", p, "口径", cal,
|
||||
"导出时刻", datetime.now().strftime("%Y-%m-%d %H:%M:%S")])
|
||||
w.writerow(["单位编码", "单位名称", "期初(非账龄基数)", "期末应收合计",
|
||||
"0-30", "31-60", "61-90", "90-120", "120-150", "150+",
|
||||
"逾期额(>30天)", "90天以上", "最老逾期档(天起)", "逾期占该户%", "是否逾期"])
|
||||
for i in items:
|
||||
w.writerow([
|
||||
i["unit_code"], i["unit_name"], _money_str(i["opening_amt"]),
|
||||
_money_str(i["total_amt"]),
|
||||
*[_money_str(i["buckets"][c]) for c in _BUCKET_COLS],
|
||||
_money_str(i["overdue_amt"]), _money_str(i["overdue_90plus"]),
|
||||
i["oldest_days"],
|
||||
("" if i["pct_of_unit"] is None else "%.2f" % (i["pct_of_unit"] * 100)),
|
||||
"是" if i["is_overdue"] else "否",
|
||||
])
|
||||
|
||||
content = "\ufeff" + buf.getvalue()
|
||||
filename = "应收账龄_%s_%s.csv" % (p, cal)
|
||||
return Response(
|
||||
content=content.encode("utf-8"),
|
||||
media_type="text/csv; charset=utf-8",
|
||||
headers={"Content-Disposition": "attachment; filename=\"ar_aging_%s.csv\"; filename*=UTF-8''%s"
|
||||
% (p, quote(filename))},
|
||||
)
|
||||
+2
-1
@@ -7,7 +7,7 @@ from fastapi.responses import JSONResponse
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from dotenv import load_dotenv
|
||||
from app.database import init_db, get_db
|
||||
from app.api import auth, kpis, kpi_governance, templates, maps, dashboard, data, alerts, ai_analysis, ai_suggestions, alert_rules, users, thresholds, notifications, permissions, action_plans, alignment, org, objectives, versions, budget, cost, predict, reports, security, knowledge, bot_bridge, bot_bridge_v2, bot_suggestions, lead, tenant, customer_dashboard, deviation_push, budget_generate, knowledge_articles, kpi_causality, data_quality, bi_reports, entities, bsc_layers, okr, okr_templates, subjects, driver_budget, bot_kpis, ontology, bot_iron_law, analysis_results, expenses, cash, tax_compliance, verify, growth_quality, products, data_classification, value_sources, zero_based, derivation_rules, cash_classify, cashflow_structure, cash_vouchers_json, ext_input_ledgers, treasury, doc_center, map_completeness, map_summary, map_dimensions
|
||||
from app.api import auth, kpis, kpi_governance, templates, maps, dashboard, data, alerts, ai_analysis, ai_suggestions, alert_rules, users, thresholds, notifications, permissions, action_plans, alignment, org, objectives, versions, budget, cost, predict, reports, security, knowledge, bot_bridge, bot_bridge_v2, bot_suggestions, lead, tenant, customer_dashboard, deviation_push, budget_generate, knowledge_articles, kpi_causality, data_quality, bi_reports, entities, bsc_layers, okr, okr_templates, subjects, driver_budget, bot_kpis, ontology, bot_iron_law, analysis_results, expenses, cash, tax_compliance, verify, growth_quality, products, data_classification, value_sources, zero_based, derivation_rules, cash_classify, cashflow_structure, cash_vouchers_json, ext_input_ledgers, treasury, doc_center, map_completeness, map_summary, map_dimensions, ar_aging
|
||||
from app.utils.cache import clear_all as clear_cache, delete as delete_cache
|
||||
from scripts.erp_sync import run_sync as run_erp_sync
|
||||
from app.auth_middleware import require_auth, require_auth_401
|
||||
@@ -105,6 +105,7 @@ app.include_router(tax_compliance.router)
|
||||
app.include_router(verify.router)
|
||||
app.include_router(ext_input_ledgers.router) # 外部输入台账(M2 准入3 派生,2026-09-15)
|
||||
app.include_router(treasury.router) # 司库(资金)阶段一:账户池/头寸/内控闸门(2026-09-15)
|
||||
app.include_router(ar_aging.router) # 应收账龄看板(T2 2026-10-20:逐户·六档·三口径禁合计)
|
||||
app.include_router(doc_center.router) # 文档中心:md 单一来源(运行期读盘,2026-09-24)
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
|
||||
@@ -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}
|
||||
@@ -0,0 +1,71 @@
|
||||
"""应收账龄口径单一事实源(`hanke_ar_aging`)
|
||||
|
||||
财务已定死的口径纪律(ERRATA-02 §五 / DQ 台账 DQ-AR-03):
|
||||
|
||||
1. **三口径分列,禁跨口径合计**:`全户口径` / `借方应收户口径` / `贷方户单列(净额)`。
|
||||
字面值逐字匹配——`贷方户单列(净额)` 写成 `贷方户单列` 会漏 42 行。
|
||||
2. **对外「超期金额」只取 `借方应收户口径`**;贷方户单列不进分母,任何 `caliber=all` 请求 400。
|
||||
3. **六档互斥**:0-30 / 31-60 / 61-90 / 90-120 / 120-150 / 150+,逐行六档和 ≡ `total_amt`。
|
||||
4. `opening_amt`(期初)**只展示**,禁作账龄基数、禁进任何分子分母。
|
||||
5. 负值保留:禁 `ABS()` / `COALESCE(...,0)` 抹平(DQ-AR-04 杨创 `b_0_30=-20`)。
|
||||
"""
|
||||
from typing import Optional, Tuple
|
||||
|
||||
# 表名(DDL:dama/_reports/prodwrite-cma-hanke-ar-aging-20261020/mirror/03_ddl.sql)
|
||||
AR_AGING_TABLE = "hanke_ar_aging"
|
||||
|
||||
# assignee 派生依赖的只读维度/事实表(§6.2)。三者均为**只读 SELECT**,不写不建。
|
||||
ZXT_MASTERBILL_TABLE = "zxt_masterbill" # 智享通单据流(unit_id / emp_id / bill_date / abst)
|
||||
EMPLOYEE_DICT_TABLE = "employee_dict" # 员工字典(id → u_Name);当前库内**未落库**
|
||||
UNITS_DICT_TABLE = "units_dict" # 往来单位字典(unit_code → unit_id);当前库内**未落库**
|
||||
|
||||
# 三口径字面值(逐字匹配,勿改写)
|
||||
CALIBER_FULL = "全户口径"
|
||||
CALIBER_DEBIT = "借方应收户口径"
|
||||
CALIBER_CREDIT_SEPARATE = "贷方户单列(净额)"
|
||||
CALIBERS: Tuple[str, ...] = (CALIBER_FULL, CALIBER_DEBIT, CALIBER_CREDIT_SEPARATE)
|
||||
|
||||
# 对外超期口径/看板默认口径
|
||||
DEFAULT_CALIBER = CALIBER_DEBIT
|
||||
|
||||
# 跨口径合计请求字面值 —— 一律 400
|
||||
CALIBER_ALL = "all"
|
||||
|
||||
# 六档列(顺序即展示顺序):(列名, 档位标签, 档位下界天数)
|
||||
BUCKETS: Tuple[Tuple[str, str, int], ...] = (
|
||||
("b_0_30", "0-30", 0),
|
||||
("b_31_60", "31-60", 31),
|
||||
("b_61_90", "61-90", 61),
|
||||
("b_90_120", "90-120", 91),
|
||||
("b_120_150", "120-150", 121),
|
||||
("b_150p", "150+", 151),
|
||||
)
|
||||
|
||||
BUCKET_COLUMNS = tuple(b[0] for b in BUCKETS)
|
||||
# 逾期档位(>30 天)列;最老档下界天数即「逾期 N 天以上」的 N
|
||||
OVERDUE_BUCKETS: Tuple[Tuple[str, int], ...] = tuple((b[0], b[2]) for b in BUCKETS if b[2] > 0)
|
||||
# 90 天以上(含 90-120 档)
|
||||
NINETY_PLUS_COLUMNS = ("b_90_120", "b_120_150", "b_150p")
|
||||
|
||||
|
||||
class CaliberError(ValueError):
|
||||
"""口径入参非法(未知值 / 跨口径合计)。"""
|
||||
|
||||
|
||||
def validate_caliber(caliber: Optional[str], *, allow_none: bool = False) -> str:
|
||||
"""校验口径字面值;`all` 与未知值一律拒绝(禁跨口径合计)。"""
|
||||
if caliber is None or caliber == "":
|
||||
if allow_none:
|
||||
return DEFAULT_CALIBER
|
||||
raise CaliberError("caliber 不能为空")
|
||||
if caliber == CALIBER_ALL:
|
||||
raise CaliberError("禁止跨口径合计(caliber=all 不提供):三口径必须分列查询")
|
||||
if caliber not in CALIBERS:
|
||||
raise CaliberError(
|
||||
"未知口径 %r;仅接受字面值:%s" % (caliber, " / ".join(CALIBERS))
|
||||
)
|
||||
return caliber
|
||||
|
||||
|
||||
def is_credit_separate(caliber: str) -> bool:
|
||||
return caliber == CALIBER_CREDIT_SEPARATE
|
||||
@@ -0,0 +1,157 @@
|
||||
#!/usr/bin/env python3
|
||||
"""播种应收账龄逾期预警规则(`alert_rules.rule_type='ar_overdue'`)— 阶段B 专用
|
||||
|
||||
用法:
|
||||
# 只打印将写入的行(默认 dry-run,阶段A 唯一允许的形式)
|
||||
python3 backend/scripts/seed_ar_overdue_rule.py --entity-id 1
|
||||
|
||||
# 真正写库(阶段B,须另铸门/执行件授权)
|
||||
python3 backend/scripts/seed_ar_overdue_rule.py --entity-id 1 --apply
|
||||
|
||||
# 把参数 JSON 打到 stdout/文件,供阶段A `POST /run-ar-overdue` 的 dry-run body 复演
|
||||
python3 backend/scripts/seed_ar_overdue_rule.py --entity-id 1 --emit-params /tmp/ar_params.json
|
||||
|
||||
阈值与责任人**只存在于本脚本**(业务数字唯一定义处);运行期一律读 DB 行,
|
||||
代码侧不含任何阈值常量(任务硬约束 §3.4)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from dotenv import load_dotenv # noqa: E402
|
||||
|
||||
load_dotenv(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), ".env"))
|
||||
|
||||
from app.database import get_session_local # noqa: E402
|
||||
from app.models import KPIDefinition # noqa: E402
|
||||
from app.utils import ar_caliber # noqa: E402
|
||||
|
||||
RULE_TYPE = "ar_overdue"
|
||||
DEFAULT_KPI_CODE = "F_AR_002" # 逾期应收账款率(财务口径);库内暂缺时须由财务定绑定
|
||||
|
||||
# ── 业务数字唯一定义处(阶段B 放行后如需调阈值:改这里或直接改 DB 行)──
|
||||
DEFAULT_PARAMS = {
|
||||
"kpi_code": DEFAULT_KPI_CODE,
|
||||
"caliber": ar_caliber.CALIBER_DEBIT,
|
||||
"red": {
|
||||
# 2026-10-20 财务裁决:90 天占比红线停用(10 期 10/10 恒红、无区分度)→ 键位保留、置 null
|
||||
"share_90plus_gt": None,
|
||||
"single_overdue_amt_gt": 50000,
|
||||
"single_overdue_min_days": 30,
|
||||
"count_mom_pct_gte": 0.20,
|
||||
# 150+ 档占比环比上升 = 结构恶化(新增红线 2)
|
||||
"structure_worsen": True,
|
||||
},
|
||||
"yellow": {
|
||||
"share_90plus_min": 0.15,
|
||||
"share_90plus_max": 0.25,
|
||||
"single_overdue_amt_min": 10000,
|
||||
"single_overdue_amt_max": 50000,
|
||||
},
|
||||
# 责任人:逐户派生「近 3 个月主业务员 → 部门负责人」,两路皆失败则该户不生成(skipped[])
|
||||
"assignee_source": {
|
||||
"mode": "salesman_then_dept",
|
||||
"lookback_months": 3,
|
||||
"dept_fallback": {"渠道": 118, "商务": 114, "直属酒窖": 117},
|
||||
},
|
||||
"assignee_by_unit": {},
|
||||
"exclude_unit_codes": [],
|
||||
"action_texts": {"red": "催收+停供评估", "yellow": "对账+催收"},
|
||||
}
|
||||
|
||||
|
||||
def resolve_kpi(db, entity_id: int, kpi_code: str, kpi_id: int | None):
|
||||
if kpi_id:
|
||||
return db.query(KPIDefinition).filter(
|
||||
KPIDefinition.id == kpi_id, KPIDefinition.entity_id == entity_id).first()
|
||||
return db.query(KPIDefinition).filter(
|
||||
KPIDefinition.kpi_code == kpi_code, KPIDefinition.entity_id == entity_id).first()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description="播种 ar_overdue 预警规则")
|
||||
ap.add_argument("--entity-id", type=int, required=True)
|
||||
ap.add_argument("--kpi-code", default=DEFAULT_KPI_CODE)
|
||||
ap.add_argument("--kpi-id", type=int, default=None, help="显式绑定 KPI(优先于 kpi-code)")
|
||||
ap.add_argument("--assignee", default=None, help="覆盖 assignee_default(财务指定责任人)")
|
||||
ap.add_argument("--apply", action="store_true", help="真正写库(阶段B 才允许)")
|
||||
ap.add_argument("--emit-params", default=None, help="把最终 params JSON 写入指定文件")
|
||||
args = ap.parse_args()
|
||||
|
||||
params = json.loads(json.dumps(DEFAULT_PARAMS)) # deep copy
|
||||
if args.assignee is not None:
|
||||
params["assignee_default"] = args.assignee
|
||||
if args.emit_params:
|
||||
with open(args.emit_params, "w", encoding="utf-8") as f:
|
||||
json.dump({"params": params}, f, ensure_ascii=False, indent=2)
|
||||
print("params 已写出: %s" % args.emit_params)
|
||||
|
||||
db = get_session_local()()
|
||||
try:
|
||||
kpi = resolve_kpi(db, args.entity_id, args.kpi_code, args.kpi_id)
|
||||
if not kpi:
|
||||
print("[BLOCK] entity=%s 内找不到 KPI code=%r(kpi_id=%s)——无法绑定,未写入任何行"
|
||||
% (args.entity_id, args.kpi_code, args.kpi_id), file=sys.stderr)
|
||||
print("[INFO] 该账套现存 AR 相关 KPI:", file=sys.stderr)
|
||||
rows = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.entity_id == args.entity_id,
|
||||
KPIDefinition.kpi_code.like("%AR%"),
|
||||
).order_by(KPIDefinition.id).all()
|
||||
for r in rows:
|
||||
print(" id=%s code=%s name=%s" % (r.id, r.kpi_code, r.kpi_name), file=sys.stderr)
|
||||
print("[INFO] 处理:--kpi-id 显式指定,或财务先在 kpi_definitions 建 F_AR_002。",
|
||||
file=sys.stderr)
|
||||
return 2
|
||||
|
||||
from app.api.alert_rules import AlertRule
|
||||
exist = db.query(AlertRule).filter(
|
||||
AlertRule.entity_id == args.entity_id,
|
||||
AlertRule.rule_type == RULE_TYPE,
|
||||
).all()
|
||||
print("== 将写入的行(%s)==" % ("APPLY" if args.apply else "DRY-RUN"))
|
||||
print(json.dumps({
|
||||
"entity_id": args.entity_id,
|
||||
"kpi_id": kpi.id,
|
||||
"kpi_code": kpi.kpi_code,
|
||||
"kpi_name": kpi.kpi_name,
|
||||
"rule_type": RULE_TYPE,
|
||||
"trigger_on": "actual",
|
||||
"enabled": 1,
|
||||
"params": params,
|
||||
}, ensure_ascii=False, indent=2))
|
||||
src = params.get("assignee_source") or {}
|
||||
if not params.get("assignee_default") and not src.get("mode"):
|
||||
print("[WARN] 责任人均未配置 → 运行期 fail-closed 400(阶段B 前须由财务给值)")
|
||||
|
||||
if exist:
|
||||
print("[SKIP] entity=%s 已有 %d 条 ar_overdue 规则(id=%s),不重复播种"
|
||||
% (args.entity_id, len(exist), [r.id for r in exist]))
|
||||
return 0
|
||||
if not args.apply:
|
||||
print("[DRY-RUN] 未写库。阶段B 授权后加 --apply。")
|
||||
return 0
|
||||
|
||||
rule = AlertRule(
|
||||
entity_id=args.entity_id,
|
||||
kpi_id=kpi.id,
|
||||
rule_type=RULE_TYPE,
|
||||
trigger_on="actual",
|
||||
enabled=1,
|
||||
params=params,
|
||||
)
|
||||
db.add(rule)
|
||||
db.commit()
|
||||
db.refresh(rule)
|
||||
print("[APPLIED] 已写入 alert_rules.id=%s" % rule.id)
|
||||
return 0
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,480 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""应收账龄看板 + 逾期预警(T1/T2 · 2026-10-20 R2)单测
|
||||
|
||||
任务:`project-20261020-ar-overdue-alert-and-aging-board-fullstack`(阶段A)
|
||||
|
||||
口径与边界(全部为硬断言):
|
||||
· 六档互斥:逐行 `b_0_30+…+b_150p == total_amt`;
|
||||
· 逾期额 `o_i = total_amt − b_0_30`,负档位**不** ABS/COALESCE(DQ-AR-04 杨创 -20);
|
||||
· 三口径分列、禁跨口径合计(`caliber=all` → 400);
|
||||
· 缺期(2026-10)⇒ `has_data=false` 且金额 null(禁补 0);
|
||||
· §6 裁决:`red.share_90plus_gt=null` ⇒ 90 天占比红线停用(不回落 0.25);
|
||||
新增「单户 >30 天且 >5 万」逐户红线 + 「150+ 占比环比上升」结构恶化红线;
|
||||
· §6.2 assignee:手工点名 → 近 3 个月主业务员(employee_dict / 同库摘要降级)
|
||||
→ 部门负责人(渠道 118 / 商务 114 / 直属酒窖 117);两路皆失败 ⇒ skipped[],不落空行;
|
||||
· 阈值/责任人全部走 `alert_rules.params`(本套件在本地 SQLite 夹具里改参数复跑,
|
||||
证明「改参数不改代码」);
|
||||
· 写通道只 INSERT,历史行零 UPDATE/DELETE,同 `alert_message` 幂等跳过。
|
||||
|
||||
本套件**不连任何生产库**:`tests/conftest.py` 已把引擎替换为内存 SQLite;
|
||||
`hanke_ar_aging` 非 ORM 模型,故用原始 DDL 在夹具里建表。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.alert_rules import AlertRule
|
||||
from app.models import KPIAlert
|
||||
from app.services import ar_overdue_alert
|
||||
from app.utils import ar_caliber
|
||||
from tests.conftest import create_test_kpi, create_test_user, get_token_for_user, auth_header
|
||||
|
||||
ENT = 1
|
||||
DDL = """
|
||||
CREATE TABLE hanke_ar_aging (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
entity_id INTEGER NOT NULL,
|
||||
period VARCHAR(7) NOT NULL,
|
||||
caliber VARCHAR(24) NOT NULL,
|
||||
unit_code VARCHAR(20) NOT NULL,
|
||||
unit_name VARCHAR(100) NOT NULL,
|
||||
opening_amt NUMERIC(18,2) NOT NULL DEFAULT 0,
|
||||
total_amt NUMERIC(18,2) NOT NULL DEFAULT 0,
|
||||
b_0_30 NUMERIC(18,2) NOT NULL DEFAULT 0,
|
||||
b_31_60 NUMERIC(18,2) NOT NULL DEFAULT 0,
|
||||
b_61_90 NUMERIC(18,2) NOT NULL DEFAULT 0,
|
||||
b_90_120 NUMERIC(18,2) NOT NULL DEFAULT 0,
|
||||
b_120_150 NUMERIC(18,2) NOT NULL DEFAULT 0,
|
||||
b_150p NUMERIC(18,2) NOT NULL DEFAULT 0,
|
||||
source_file VARCHAR(160) NOT NULL DEFAULT 'fixture.csv',
|
||||
source_sha256 VARCHAR(64) NOT NULL DEFAULT 'fixture',
|
||||
loaded_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
|
||||
# §6.1 参数最终形状(含 aggregate_assignee,仅测试用显式给值以验证聚合可落库)
|
||||
BASE_PARAMS = {
|
||||
"kpi_code": "F_AR_002",
|
||||
"caliber": ar_caliber.CALIBER_DEBIT,
|
||||
"red": {"share_90plus_gt": None, "single_overdue_amt_gt": 50000,
|
||||
"single_overdue_min_days": 30, "count_mom_pct_gte": 0.20,
|
||||
"structure_worsen": True},
|
||||
"yellow": {"share_90plus_min": 0.15, "share_90plus_max": 0.25,
|
||||
"single_overdue_amt_min": 10000, "single_overdue_amt_max": 50000},
|
||||
"assignee_source": {
|
||||
"mode": "salesman_then_dept",
|
||||
"lookback_months": 3,
|
||||
"dept_fallback": {"渠道": 118, "商务": 114, "直属酒窖": 117},
|
||||
"dept_by_unit": {"U3": "商务"},
|
||||
"aggregate_assignee": "财务应收会计",
|
||||
},
|
||||
"assignee_by_unit": {},
|
||||
"exclude_unit_codes": [],
|
||||
"action_texts": {"red": "催收+停供评估", "yellow": "对账+催收"},
|
||||
}
|
||||
|
||||
ZXT_DDL = """
|
||||
CREATE TABLE zxt_masterbill (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
entity_id INTEGER,
|
||||
unit_id INTEGER,
|
||||
emp_id INTEGER,
|
||||
bill_type INTEGER DEFAULT 1,
|
||||
bill_date DATE,
|
||||
sum_money NUMERIC(18,2) DEFAULT 0,
|
||||
abst VARCHAR(500)
|
||||
)
|
||||
"""
|
||||
|
||||
|
||||
def _reset_table(db: Session) -> None:
|
||||
db.execute(text("DROP TABLE IF EXISTS hanke_ar_aging"))
|
||||
db.execute(text(DDL))
|
||||
db.commit()
|
||||
|
||||
|
||||
def _add(db: Session, period: str, code: str, name: str, total: float, buckets: list,
|
||||
opening: float = 0.0, caliber: str = ar_caliber.CALIBER_DEBIT, entity_id: int = ENT) -> None:
|
||||
assert abs(sum(buckets) - total) < 0.005, "夹具必须满足六档和 = 合计"
|
||||
db.execute(text(
|
||||
"INSERT INTO hanke_ar_aging (entity_id, period, caliber, unit_code, unit_name, opening_amt,"
|
||||
" total_amt, b_0_30, b_31_60, b_61_90, b_90_120, b_120_150, b_150p)"
|
||||
" VALUES (:e,:p,:c,:code,:name,:op,:tot,:b0,:b1,:b2,:b3,:b4,:b5)"
|
||||
), {"e": entity_id, "p": period, "c": caliber, "code": code, "name": name, "op": opening,
|
||||
"tot": total, "b0": buckets[0], "b1": buckets[1], "b2": buckets[2],
|
||||
"b3": buckets[3], "b4": buckets[4], "b5": buckets[5]})
|
||||
db.commit()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def aging(db: Session):
|
||||
"""两期夹具(2026-08 / 2026-09),含负档位行 + 未逾期户 + 贷方口径干扰行。
|
||||
|
||||
2026-09:合计 173,159;90+ = 63,179(36.48%);逾期户 = U1/U3/U4 共 3 户。
|
||||
2026-08:合计 163,159;90+ = 43,179(26.46%);逾期户 3 户 ⇒ 环比 0%。
|
||||
150+ 占比:2026-08 = 14.21% → 2026-09 = 16.27%(上升 ⇒ 结构恶化红)。
|
||||
"""
|
||||
_reset_table(db)
|
||||
_add(db, "2026-09", "U1", "甲公司", 100000, [20000, 10000, 10000, 20000, 15000, 25000], opening=5000)
|
||||
_add(db, "2026-09", "U2", "乙公司", 40000, [40000, 0, 0, 0, 0, 0])
|
||||
_add(db, "2026-09", "U3", "丙公司", 30000, [0, 30000, 0, 0, 0, 0])
|
||||
# 负档位(DQ-AR-04 同形):total=3159, b_0_30=-20, b_150p=3179 ⇒ o_i=3179(不得 ABS 抹成 3139/3159)
|
||||
_add(db, "2026-09", "U4", "杨创", 3159, [-20, 0, 0, 0, 0, 3179])
|
||||
# 干扰行:若查询漏 caliber 过滤 → 会污染借方口径合计
|
||||
_add(db, "2026-09", "C1", "贷方户", -34010, [0, 0, 0, 0, 0, -34010],
|
||||
caliber=ar_caliber.CALIBER_CREDIT_SEPARATE)
|
||||
_add(db, "2026-08", "U1", "甲公司", 90000, [30000, 10000, 10000, 10000, 10000, 20000])
|
||||
_add(db, "2026-08", "U2", "乙公司", 50000, [50000, 0, 0, 0, 0, 0])
|
||||
_add(db, "2026-08", "U3", "丙公司", 20000, [0, 20000, 0, 0, 0, 0])
|
||||
_add(db, "2026-08", "U4", "杨创", 3159, [-20, 0, 0, 0, 0, 3179])
|
||||
return db
|
||||
|
||||
|
||||
def _staff(db: Session, *, with_employee_dict: bool = True, with_units_dict: bool = True,
|
||||
with_zxt: bool = True) -> None:
|
||||
"""维度夹具:units_dict(U1/U3/U4) + employee_dict(14/32/114) + zxt_masterbill。"""
|
||||
for t in ("units_dict", "employee_dict", "zxt_masterbill"):
|
||||
db.execute(text("DROP TABLE IF EXISTS %s" % t))
|
||||
if with_units_dict:
|
||||
db.execute(text("CREATE TABLE units_dict (unit_code VARCHAR(20), unit_id INTEGER)"))
|
||||
for code, uid in (("U1", 101), ("U3", 103), ("U4", 104)):
|
||||
db.execute(text("INSERT INTO units_dict VALUES (:c,:u)"), {"c": code, "u": uid})
|
||||
if with_employee_dict:
|
||||
db.execute(text("CREATE TABLE employee_dict (id INTEGER, u_Name VARCHAR(64))"))
|
||||
for i, n in ((14, "李世昌"), (32, "兀晨博"), (114, "王经理"), (118, "渠道负责人"),
|
||||
(117, "酒窖负责人")):
|
||||
db.execute(text("INSERT INTO employee_dict VALUES (:i,:n)"), {"i": i, "n": n})
|
||||
if with_zxt:
|
||||
db.execute(text(ZXT_DDL))
|
||||
rows = [
|
||||
(ENT, 101, 14, "2026-08-10", "向【甲公司】出售商品:李世昌"),
|
||||
(ENT, 101, 14, "2026-08-11", "向【甲公司】出售商品:李世昌"),
|
||||
(ENT, 101, 14, "2026-09-05", "向【甲公司】出售商品:李世昌"),
|
||||
(ENT, 101, 32, "2026-09-20", "向【甲公司】出售商品:兀晨博"),
|
||||
]
|
||||
for e, u, m, d, a in rows:
|
||||
db.execute(text("INSERT INTO zxt_masterbill (entity_id, unit_id, emp_id, bill_date, abst)"
|
||||
" VALUES (:e,:u,:m,:d,:a)"),
|
||||
{"e": e, "u": u, "m": m, "d": d, "a": a})
|
||||
db.commit()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def staff(aging: Session):
|
||||
_staff(aging)
|
||||
return aging
|
||||
|
||||
|
||||
# ─────────────────────── 数据口径 ───────────────────────
|
||||
|
||||
def test_six_buckets_equal_total_per_row(aging: Session):
|
||||
rows = aging.execute(text(
|
||||
"SELECT period, unit_code, total_amt, b_0_30+b_31_60+b_61_90+b_90_120+b_120_150+b_150p AS s"
|
||||
" FROM hanke_ar_aging WHERE entity_id=1 AND caliber=:c"
|
||||
), {"c": ar_caliber.CALIBER_DEBIT}).mappings().all()
|
||||
assert len(rows) == 8
|
||||
for r in rows:
|
||||
assert abs(float(r["s"]) - float(r["total_amt"])) < 0.005, r
|
||||
|
||||
|
||||
def test_caliber_literals_are_exact():
|
||||
assert ar_caliber.CALIBERS == ("全户口径", "借方应收户口径", "贷方户单列(净额)")
|
||||
with pytest.raises(ar_caliber.CaliberError):
|
||||
ar_caliber.validate_caliber("贷方户单列") # 少「(净额)」⇒ 会漏 42 行
|
||||
with pytest.raises(ar_caliber.CaliberError):
|
||||
ar_caliber.validate_caliber("all")
|
||||
assert ar_caliber.validate_caliber(None, allow_none=True) == ar_caliber.CALIBER_DEBIT
|
||||
|
||||
|
||||
# ─────────────────────── 计算与分级(§6 阈值) ───────────────────────
|
||||
|
||||
def test_share_90plus_red_disabled_and_structure_worsen(staff: Session):
|
||||
"""§6.1:90 天占比红线置 null ⇒ 不生成该聚合项;150+ 环比上升 ⇒ 结构恶化红。"""
|
||||
res = ar_overdue_alert.compute(staff, ENT, "2026-09", BASE_PARAMS)
|
||||
assert res["skipped_reason"] is None
|
||||
m = res["metrics"]
|
||||
assert m["total_amt"] == pytest.approx(173159.0, abs=0.01) # 贷方口径行未被混入
|
||||
assert m["overdue_90plus"] == pytest.approx(63179.0, abs=0.01)
|
||||
assert m["share_90plus"] == pytest.approx(63179 / 173159, rel=1e-6)
|
||||
assert m["share_150p"] == pytest.approx(28179 / 173159, abs=1e-6) # metrics 保留 6 位
|
||||
assert m["prev_share_150p"] == pytest.approx(23179 / 163159, abs=1e-6)
|
||||
|
||||
keys = [a["key"] for a in res["aggregates"]]
|
||||
assert "share_90plus" not in keys, "红线停用后 36.48% 不应再出聚合项(无区分度)"
|
||||
assert "structure_150p" in keys
|
||||
agg = [a for a in res["aggregates"] if a["key"] == "structure_150p"][0]
|
||||
assert agg["level"] == "red"
|
||||
assert agg["assignee"] == "财务应收会计"
|
||||
for token in ("2026-09", "150+ 档占比", "16.27%", "14.21%", "结构恶化", "催收+停供评估"):
|
||||
assert token in agg["alert_message"], token
|
||||
assert len(agg["alert_message"]) <= 500
|
||||
assert any("90 天占比红线停用" in n for n in res["notes"])
|
||||
|
||||
|
||||
def test_single_overdue_red_and_yellow_households(staff: Session):
|
||||
res = ar_overdue_alert.compute(staff, ENT, "2026-09", BASE_PARAMS)
|
||||
by_code = {c["unit_code"]: c for c in res["per_customer"]}
|
||||
# 单户 >30 天且 >5 万 ⇒ 红
|
||||
assert by_code["U1"]["level"] == "red"
|
||||
assert by_code["U1"]["overdue_amt"] == pytest.approx(80000.0)
|
||||
assert by_code["U1"]["oldest_days"] == 151
|
||||
# 单户 1–5 万 ⇒ 黄
|
||||
assert by_code["U3"]["level"] == "yellow" and by_code["U3"]["oldest_days"] == 31
|
||||
# U4 逾期 3,179 < 1 万 ⇒ 不生成;U2 全额 0-30 ⇒ 不生成
|
||||
assert "U4" not in by_code and "U2" not in by_code
|
||||
for c in res["per_customer"]:
|
||||
assert c["assignee"], "禁落空 assignee"
|
||||
assert c["alert_message"].count("|建议:") == 1
|
||||
assert "|责任人:" in c["alert_message"]
|
||||
|
||||
|
||||
def test_assignee_chain_salesman_then_dept(staff: Session):
|
||||
res = ar_overdue_alert.compute(staff, ENT, "2026-09", BASE_PARAMS)
|
||||
by_code = {c["unit_code"]: c for c in res["per_customer"]}
|
||||
# U1:units_dict → unit_id=101 → 主业务员 emp14 单据最多(3 vs 1)→ 李世昌
|
||||
assert by_code["U1"]["assignee"] == "李世昌"
|
||||
assert "salesman" in by_code["U1"]["assignee_source"]
|
||||
assert by_code["U1"]["emp_id"] == 14
|
||||
# U3:近 3 个月无单据 → 部门负责人 商务 114 → 王经理
|
||||
assert by_code["U3"]["assignee"] == "王经理"
|
||||
assert by_code["U3"]["assignee_source"].startswith("dept:商务")
|
||||
|
||||
|
||||
def test_assignee_manual_map_has_priority(staff: Session):
|
||||
p = json.loads(json.dumps(BASE_PARAMS))
|
||||
p["assignee_by_unit"] = {"U1": "财务点名人甲"}
|
||||
res = ar_overdue_alert.compute(staff, ENT, "2026-09", p)
|
||||
by_code = {c["unit_code"]: c for c in res["per_customer"]}
|
||||
assert by_code["U1"]["assignee"] == "财务点名人甲"
|
||||
assert by_code["U1"]["assignee_source"] == "manual:assignee_by_unit"
|
||||
|
||||
|
||||
def test_assignee_abst_fallback_when_employee_dict_absent(aging: Session):
|
||||
"""employee_dict 缺表 ⇒ 同库单据摘要尾段制单人姓名降级(仍非空、可审计)。"""
|
||||
_staff(aging, with_employee_dict=False)
|
||||
res = ar_overdue_alert.compute(aging, ENT, "2026-09", BASE_PARAMS)
|
||||
by_code = {c["unit_code"]: c for c in res["per_customer"]}
|
||||
assert by_code["U1"]["assignee"] == "李世昌"
|
||||
assert "abst_operator" in by_code["U1"]["assignee_source"]
|
||||
|
||||
|
||||
def test_assignee_unresolved_goes_to_skipped(aging: Session):
|
||||
"""维度表全缺 + 部门无法归类 ⇒ 该户不生成,skipped[] 写原因(禁落空 assignee)。"""
|
||||
_staff(aging, with_employee_dict=False, with_units_dict=False, with_zxt=False)
|
||||
p = json.loads(json.dumps(BASE_PARAMS))
|
||||
p["assignee_source"].pop("dept_by_unit")
|
||||
res = ar_overdue_alert.compute(aging, ENT, "2026-09", p)
|
||||
assert res["per_customer"] == []
|
||||
skipped = {s["unit_code"]: s for s in res["skipped"] if s["scope"] == "customer"}
|
||||
assert set(skipped) == {"U1", "U3"}
|
||||
assert "unit_link_unresolved" in skipped["U1"]["reason"]
|
||||
assert "dept_unclassified" in skipped["U1"]["reason"]
|
||||
assert [h for h in ar_overdue_alert.hits(res) if h.get("unit_code")] == []
|
||||
|
||||
|
||||
def test_assignee_missing_is_fail_closed(aging: Session):
|
||||
p = {"red": {}, "yellow": {}, "assignee_default": "", "assignee_by_unit": {}}
|
||||
with pytest.raises(ar_overdue_alert.AssigneeMissing):
|
||||
ar_overdue_alert.ensure_assignee_configured(p)
|
||||
# 配了逐户派生模式即视为已配置(派生失败按户 skipped,不整体 400)
|
||||
p["assignee_source"] = {"mode": "salesman_then_dept"}
|
||||
ar_overdue_alert.ensure_assignee_configured(p)
|
||||
|
||||
|
||||
def test_level_boundaries_are_param_driven(aging: Session):
|
||||
"""改 DB 参数复跑 ⇒ 命中集合变化,代码零改动(§6.3 第 11 项:single_overdue_amt_gt)。"""
|
||||
_staff(aging)
|
||||
p = json.loads(json.dumps(BASE_PARAMS))
|
||||
assert {c["unit_code"] for c in ar_overdue_alert.compute(aging, ENT, "2026-09", p)["per_customer"]} == {"U1", "U3"}
|
||||
p["red"]["single_overdue_amt_gt"] = 300000 # 5 万 → 30 万 ⇒ U1(8 万) 掉出红线且高于黄线上限
|
||||
res = ar_overdue_alert.compute(aging, ENT, "2026-09", p)
|
||||
assert {c["unit_code"] for c in res["per_customer"]} == {"U3"}
|
||||
p["red"]["single_overdue_amt_gt"] = 50000
|
||||
p["yellow"]["single_overdue_amt_min"] = 100000 # 黄线下限 10 万 ⇒ U3 消失
|
||||
assert {c["unit_code"] for c in ar_overdue_alert.compute(aging, ENT, "2026-09", p)["per_customer"]} == {"U1"}
|
||||
|
||||
|
||||
def test_missing_period_is_skipped_not_zero(aging: Session):
|
||||
res = ar_overdue_alert.compute(aging, ENT, "2026-10", BASE_PARAMS)
|
||||
assert res["skipped_reason"] == "no_data_for_period:2026-10"
|
||||
assert res["aggregates"] == [] and res["per_customer"] == []
|
||||
|
||||
|
||||
def test_default_period_is_max_and_mom_computed(staff: Session):
|
||||
res = ar_overdue_alert.compute(staff, ENT, None, BASE_PARAMS)
|
||||
assert res["period"] == "2026-09"
|
||||
assert res["metrics"]["prev_period"] == "2026-08"
|
||||
assert res["metrics"]["prev_overdue_customers"] == 3
|
||||
assert res["metrics"]["overdue_customers"] == 3
|
||||
assert res["metrics"]["count_mom_pct"] == pytest.approx(0.0)
|
||||
assert "count_mom" not in {a["key"] for a in res["aggregates"]}
|
||||
|
||||
|
||||
def test_no_previous_period_no_mom(aging: Session):
|
||||
res = ar_overdue_alert.compute(aging, ENT, "2026-08", BASE_PARAMS)
|
||||
assert res["metrics"]["prev_period"] is None
|
||||
assert res["metrics"]["count_mom_pct"] is None
|
||||
assert any("不计算环比" in n for n in res["notes"])
|
||||
|
||||
|
||||
def test_exclude_unit_codes(aging: Session):
|
||||
_staff(aging)
|
||||
p = json.loads(json.dumps(BASE_PARAMS))
|
||||
p["exclude_unit_codes"] = ["U1"]
|
||||
res = ar_overdue_alert.compute(aging, ENT, "2026-09", p)
|
||||
assert "U1" not in {c["unit_code"] for c in res["per_customer"]}
|
||||
assert res["metrics"]["total_amt"] < 173159
|
||||
|
||||
|
||||
# ─────────────────────── 落库(只 INSERT) ───────────────────────
|
||||
|
||||
def test_write_alerts_idempotent_and_history_intact(staff: Session):
|
||||
kpi = create_test_kpi(staff, kpi_code="F_AR_002", kpi_name="逾期应收账款率")
|
||||
hist = KPIAlert(entity_id=ENT, kpi_id=kpi.id, alert_level="red",
|
||||
alert_message="历史行-不得被改写", alert_type="actual", status="pending",
|
||||
assignee="old-owner")
|
||||
staff.add(hist)
|
||||
staff.commit()
|
||||
hist_id = hist.id
|
||||
|
||||
res = ar_overdue_alert.compute(staff, ENT, "2026-09", BASE_PARAMS)
|
||||
first = ar_overdue_alert.write_alerts(staff, ENT, kpi.id, res)
|
||||
assert first["inserted"] == len(ar_overdue_alert.hits(res)) > 0
|
||||
second = ar_overdue_alert.write_alerts(staff, ENT, kpi.id, res)
|
||||
assert second == {"inserted": 0, "skipped_duplicate": first["inserted"]}
|
||||
|
||||
rows = staff.query(KPIAlert).filter(KPIAlert.alert_type == "overdue").all()
|
||||
assert len(rows) == first["inserted"]
|
||||
assert all(r.assignee and r.status == "pending" and r.kpi_value_id is None for r in rows)
|
||||
assert all(len(r.alert_message) <= 500 for r in rows)
|
||||
old = staff.get(KPIAlert, hist_id)
|
||||
assert old.alert_message == "历史行-不得被改写" and old.assignee == "old-owner"
|
||||
|
||||
|
||||
# ─────────────────────── HTTP 面(TestClient + 内存库) ───────────────────────
|
||||
|
||||
@pytest.fixture()
|
||||
def fin_headers(client, db):
|
||||
create_test_user(db, username="fin_probe", role="finance")
|
||||
return auth_header(get_token_for_user(client, username="fin_probe"))
|
||||
|
||||
|
||||
def test_api_meta_trend_overview(staff, client, fin_headers):
|
||||
r = client.get("/api/cma/ar-aging/meta", headers=fin_headers)
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["available"] is True
|
||||
assert body["calibers"] == list(ar_caliber.CALIBERS)
|
||||
assert body["latest_period"] == "2026-09"
|
||||
assert body["row_count"] == 9 # 8 借方 + 1 贷方(三口径不混算,仅计数)
|
||||
|
||||
r = client.get("/api/cma/ar-aging/trend", headers=fin_headers,
|
||||
params={"caliber": ar_caliber.CALIBER_DEBIT})
|
||||
assert r.status_code == 200
|
||||
pts = {p["period"]: p for p in r.json()["points"]}
|
||||
assert pts["2026-09"]["has_data"] is True
|
||||
assert pts["2026-10"]["has_data"] is False
|
||||
assert pts["2026-10"]["total_amt"] is None and pts["2026-10"]["customers"] is None
|
||||
|
||||
r = client.get("/api/cma/ar-aging/overview", headers=fin_headers,
|
||||
params={"period": "2026-09", "caliber": ar_caliber.CALIBER_DEBIT})
|
||||
ov = r.json()
|
||||
assert ov["has_data"] is True
|
||||
assert ov["buckets_sum_matches_total"] is True
|
||||
assert ov["opening_amt"] == pytest.approx(5000.0)
|
||||
assert ov["opening_amt_non_aging_base"] is True
|
||||
assert ov["total_amt"] == pytest.approx(173159.0, abs=0.01)
|
||||
|
||||
|
||||
def test_api_caliber_all_rejected(aging, client, fin_headers):
|
||||
for ep in ("overview", "trend", "customers"):
|
||||
r = client.get(f"/api/cma/ar-aging/{ep}", headers=fin_headers, params={"caliber": "all"})
|
||||
assert r.status_code == 400, (ep, r.text)
|
||||
assert "禁止跨口径合计" in r.json()["detail"]
|
||||
|
||||
|
||||
def test_api_customers_sort_and_export(aging, client, fin_headers):
|
||||
r = client.get("/api/cma/ar-aging/customers", headers=fin_headers,
|
||||
params={"period": "2026-09", "caliber": ar_caliber.CALIBER_DEBIT,
|
||||
"overdue_only": "true", "sort": "overdue_amt", "order": "desc"})
|
||||
items = r.json()["items"]
|
||||
assert [i["unit_code"] for i in items][:3] == ["U1", "U3", "U4"]
|
||||
assert items[0]["overdue_amt"] == pytest.approx(80000.0)
|
||||
|
||||
r = client.get("/api/cma/ar-aging/customers/export", headers=fin_headers,
|
||||
params={"period": "2026-09", "caliber": ar_caliber.CALIBER_DEBIT})
|
||||
assert r.status_code == 200
|
||||
assert r.content[:3] == b"\xef\xbb\xbf" # UTF-8 BOM(Excel 直开)
|
||||
lines = r.content.decode("utf-8-sig").splitlines()
|
||||
assert lines[0].startswith("截止期,2026-09,口径,借方应收户口径,导出时刻,")
|
||||
assert "U1" in lines[2] or "U1" in lines[3]
|
||||
|
||||
|
||||
def test_api_run_ar_overdue_rule_params_parameterized(staff, client, db, fin_headers):
|
||||
"""端到端:规则参数来自 DB 行 ⇒ 改 `single_overdue_amt_gt` 复跑命中变化,代码零改动。"""
|
||||
kpi = create_test_kpi(db, kpi_code="F_AR_002", kpi_name="逾期应收账款率")
|
||||
rule = AlertRule(entity_id=ENT, kpi_id=kpi.id, rule_type=ar_overdue_alert.RULE_TYPE,
|
||||
trigger_on="actual", enabled=1, params=BASE_PARAMS)
|
||||
db.add(rule)
|
||||
db.commit()
|
||||
|
||||
r = client.post("/api/cma/alert-rules/run-ar-overdue",
|
||||
headers=fin_headers, params={"period": "2026-09", "dry_run": "true"})
|
||||
assert r.status_code == 200, r.text
|
||||
j = r.json()
|
||||
assert j["params_source"] == "alert_rules#%s" % rule.id
|
||||
assert j["kpi_id"] == kpi.id
|
||||
assert j["write_blockers"] == []
|
||||
assert j["hit_count"] == 3 # 结构恶化聚合红 + U1 红 + U3 黄
|
||||
assert {c["unit_code"] for c in j["per_customer"]} == {"U1", "U3"}
|
||||
assert all(c["assignee"] for c in j["per_customer"])
|
||||
assert j["skipped"] == []
|
||||
assert "structure_150p" in {a["key"] for a in j["aggregates"]}
|
||||
|
||||
params2 = json.loads(json.dumps(BASE_PARAMS))
|
||||
params2["red"]["single_overdue_amt_gt"] = 300000 # 5 万 → 30 万
|
||||
rule.params = params2
|
||||
db.commit()
|
||||
r = client.post("/api/cma/alert-rules/run-ar-overdue",
|
||||
headers=fin_headers, params={"period": "2026-09", "dry_run": "true"})
|
||||
assert r.status_code == 200
|
||||
j2 = r.json()
|
||||
assert {c["unit_code"] for c in j2["per_customer"]} == {"U3"}
|
||||
assert j2["hit_count"] == 2 and j2["hit_count"] < j["hit_count"] # 聚合红 + U3 黄
|
||||
|
||||
|
||||
def test_api_run_ar_overdue_assignee_empty_400(aging, client, db, fin_headers):
|
||||
kpi = create_test_kpi(db, kpi_code="F_AR_002x", kpi_name="逾期应收账款率")
|
||||
p = {"red": {}, "yellow": {}, "assignee_default": "", "assignee_by_unit": {}}
|
||||
db.add(AlertRule(entity_id=ENT, kpi_id=kpi.id, rule_type=ar_overdue_alert.RULE_TYPE,
|
||||
trigger_on="actual", enabled=1, params=p))
|
||||
db.commit()
|
||||
r = client.post("/api/cma/alert-rules/run-ar-overdue",
|
||||
headers=fin_headers, params={"period": "2026-09", "dry_run": "true"})
|
||||
assert r.status_code == 400
|
||||
assert "责任人" in r.json()["detail"]
|
||||
|
||||
# dry_run=false + 参数覆盖 → 400(防绕过 DB 配置)
|
||||
r = client.post("/api/cma/alert-rules/run-ar-overdue", headers=fin_headers,
|
||||
params={"period": "2026-09", "dry_run": "false"},
|
||||
json={"params": BASE_PARAMS})
|
||||
assert r.status_code == 400
|
||||
assert "禁止参数/KPI 覆盖" in r.json()["detail"]
|
||||
|
||||
|
||||
def test_api_meta_degrades_when_table_missing(client, db, fin_headers, monkeypatch):
|
||||
_reset_table(db)
|
||||
db.execute(text("DROP TABLE hanke_ar_aging"))
|
||||
db.commit()
|
||||
import app.api.ar_aging as aa
|
||||
monkeypatch.setattr(aa, "_TABLE", "hanke_ar_aging_missing_zzz")
|
||||
r = client.get("/api/cma/ar-aging/meta", headers=fin_headers)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["available"] is False and "不可用" in body["reason"]
|
||||
r = client.get("/api/cma/ar-aging/trend", headers=fin_headers)
|
||||
assert r.status_code == 200 and r.json()["available"] is False
|
||||
@@ -546,4 +546,19 @@ export const treasuryApi = {
|
||||
listHits: (params?: any) => api.get('/treasury/control-hits', { params }),
|
||||
}
|
||||
|
||||
// ── 应收账龄看板(T2 2026-10-20:逐户·六档·三口径禁合计,缺期不补 0)──
|
||||
export const arAgingApi = {
|
||||
meta: (params?: any) => api.get('/ar-aging/meta', { params }),
|
||||
trend: (params?: any) => api.get('/ar-aging/trend', { params }),
|
||||
overview: (params?: any) => api.get('/ar-aging/overview', { params }),
|
||||
customers: (params?: any) => api.get('/ar-aging/customers', { params }),
|
||||
exportUrl: (params: { period?: string; caliber?: string; overdue_only?: boolean }) => {
|
||||
const q = new URLSearchParams()
|
||||
if (params.period) q.set('period', params.period)
|
||||
if (params.caliber) q.set('caliber', params.caliber)
|
||||
if (params.overdue_only) q.set('overdue_only', 'true')
|
||||
return `/api/cma/ar-aging/customers/export?${q.toString()}`
|
||||
},
|
||||
}
|
||||
|
||||
export default api
|
||||
|
||||
@@ -39,6 +39,7 @@ export const MENU_ITEMS: MenuItem[] = [
|
||||
{ path: '/receivables', label: '应收款催收', icon: 'Money', roles: ['ceo', 'finance', 'business'], group: '🟢 D 执行与控制' },
|
||||
{ path: '/treasury-dashboard', label: '资金总览', icon: 'Money', roles: ['ceo','finance'], group: '🟢 D 执行与控制' },
|
||||
{ path: '/treasury-payment', label: '资金内控', icon: 'Lock', roles: ['ceo','finance'], group: '🟢 D 执行与控制' },
|
||||
{ path: '/ar-aging', label: '应收账龄', icon: 'TrendCharts', roles: ['ceo', 'finance', 'business'], group: '🟡 C 监控与评价' },
|
||||
{ path: '/growth-quality', label: '增长质量诊断', icon: 'TrendCharts', roles: ['ceo', 'finance', 'business', 'it'], group: '🟡 C 监控与评价' },
|
||||
{ path: '/product-matrix', label: '波士顿产品矩阵', icon: 'PieChart', roles: ['ceo', 'finance', 'business', 'it'], group: '🟡 C 监控与评价' },
|
||||
{ path: '/deviations', label: '差异分析', icon: 'DataAnalysis', roles: ['ceo', 'finance', 'business', 'it'], group: '🟢 D 执行与控制' },
|
||||
|
||||
@@ -60,6 +60,7 @@ const routes = [
|
||||
{ path: 'receivables', name: 'Receivables', component: () => import('@/views/Receivables.vue'), meta: { title: '应收款催收', roles: ['ceo', 'finance', 'business'], zone: 'B' } },
|
||||
{ path: 'treasury-dashboard', name: 'TreasuryDashboard', component: () => import('@/views/TreasuryDashboard.vue'), meta: { title: '资金总览', roles: ['ceo','finance'], zone: 'B', editable: true } },
|
||||
{ path: 'treasury-payment', name: 'TreasuryPayment', component: () => import('@/views/TreasuryPayment.vue'), meta: { title: '资金内控', roles: ['ceo','finance'], zone: 'B', editable: true } },
|
||||
{ path: 'ar-aging', name: 'ArAging', component: () => import('@/views/ArAging.vue'), meta: { title: '应收账龄', roles: ['ceo', 'finance', 'business'], zone: 'A' } },
|
||||
{ path: 'growth-quality', name: 'GrowthQuality', component: () => import('@/views/GrowthQuality.vue'), meta: { title: '增长质量诊断', roles: ['ceo', 'finance', 'business', 'it'], zone: 'A' } },
|
||||
{ path: 'product-matrix', name: 'ProductMatrix', component: () => import('@/views/ProductMatrix.vue'), meta: { title: '波士顿产品矩阵', roles: ['ceo', 'finance', 'business', 'it'], zone: 'A' } },
|
||||
// /tax-compliance: B区(页内职责=税务记录/发票/社保人工录入维护;见盘点表)
|
||||
|
||||
@@ -0,0 +1,478 @@
|
||||
<template>
|
||||
<div class="ar-aging-page">
|
||||
<!-- 页头:标题 + 口径后缀(强制显示)+ 数据截止期徽标 + 来源说明 -->
|
||||
<header class="page-head">
|
||||
<div class="head-main">
|
||||
<h2 class="page-title">
|
||||
应收账龄看板<span class="caliber-suffix">({{ caliberLabel }})</span>
|
||||
</h2>
|
||||
<div class="badge-row">
|
||||
<el-tag size="small" effect="plain" :type="overview.has_data === false ? 'warning' : 'success'">
|
||||
数据截止期 {{ displayPeriod || '无' }}
|
||||
</el-tag>
|
||||
<el-tag v-if="meta.loaded_at" size="small" effect="plain">入表 {{ meta.loaded_at }}</el-tag>
|
||||
<el-tag size="small" effect="plain" type="primary">{{ entityLabel }}</el-tag>
|
||||
<el-tag size="small" effect="plain" type="info">口径分列 · 禁跨口径合计</el-tag>
|
||||
</div>
|
||||
<p class="source-note">
|
||||
来源:智享通「往来单位未收款账期统计」逐户导出;六档互斥(0-30/31-60/61-90/90-120/120-150/150+),
|
||||
逐期六档和 = 期末应收合计;期初只展示,不作账龄基数。
|
||||
</p>
|
||||
</div>
|
||||
<div class="head-controls">
|
||||
<el-select v-model="caliber" size="small" style="width:190px" @change="onCaliberChange">
|
||||
<el-option v-for="c in calibers" :key="c" :label="c" :value="c" />
|
||||
</el-select>
|
||||
<el-select
|
||||
v-model="period" size="small" style="width:120px" :loading="loading"
|
||||
placeholder="选择期间" @change="loadPeriodData"
|
||||
>
|
||||
<el-option v-for="p in periodOptions" :key="p" :label="p" :value="p" />
|
||||
</el-select>
|
||||
<el-button size="small" :loading="exporting" @click="doExport">
|
||||
<svg class="ico" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M12 3v12m0 0l-4-4m4 4l4-4M4 21h16" fill="none" stroke="currentColor" stroke-width="1.8"
|
||||
stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
导出逐户 CSV
|
||||
</el-button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 表不可用/无权限:降级可读,不留白、不补 0 -->
|
||||
<el-alert
|
||||
v-if="metaLoaded && meta.available === false"
|
||||
type="warning" :closable="false" show-icon
|
||||
title="该期无数据(表未就绪/无权限)"
|
||||
:description="meta.reason || 'hanke_ar_aging 不可用'"
|
||||
/>
|
||||
|
||||
<template v-else>
|
||||
<!-- 卡片区 -->
|
||||
<el-row :gutter="16" v-loading="loading">
|
||||
<el-col :xs="24" :sm="12" :md="6">
|
||||
<el-card shadow="never" class="metric">
|
||||
<div class="metric-label">应收总额(期末)</div>
|
||||
<div class="metric-value">{{ money(overview.total_amt) }}</div>
|
||||
<div class="metric-sub">
|
||||
{{ overview.has_data ? `往来单位 ${overview.customers} 户` : '该期无数据' }}
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12" :md="6">
|
||||
<el-card shadow="never" class="metric">
|
||||
<div class="metric-label">逾期金额(>30 天)</div>
|
||||
<div class="metric-value">{{ money(overview.overdue_amt) }}</div>
|
||||
<div class="metric-sub">
|
||||
环比
|
||||
<span :class="momClass(overview.overdue_amt_mom_pct)">
|
||||
{{ pctSigned(overview.overdue_amt_mom_pct) }}
|
||||
</span>
|
||||
<span class="dim">{{ overview.mom_note || (overview.prev_period ? `上期 ${overview.prev_period}` : '') }}</span>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12" :md="6">
|
||||
<el-card shadow="never" class="metric">
|
||||
<div class="metric-label">90 天以上占比</div>
|
||||
<div class="metric-value" :style="{ color: shareColor }">{{ pct(overview.share_90plus) }}</div>
|
||||
<div class="metric-sub">
|
||||
<span v-if="thresholds.share_90plus_red_gt != null" class="th-red">
|
||||
红线 >{{ pct(thresholds.share_90plus_red_gt) }}
|
||||
</span>
|
||||
<span v-if="thresholds.share_90plus_yellow_min != null" class="th-yellow">
|
||||
黄线 {{ pct(thresholds.share_90plus_yellow_min) }}–{{ pct(thresholds.share_90plus_yellow_max) }}
|
||||
</span>
|
||||
<span v-if="thresholds.share_90plus_red_gt == null" class="dim">阈值未配置(阶段B 播种后显示)</span>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12" :md="6">
|
||||
<el-card shadow="never" class="metric">
|
||||
<div class="metric-label">逾期户数(>30 天)</div>
|
||||
<div class="metric-value">{{ overview.overdue_customers ?? '该期无数据' }}</div>
|
||||
<div class="metric-sub">
|
||||
环比
|
||||
<span :class="momClass(overview.overdue_customers_mom_pct)">
|
||||
{{ pctSigned(overview.overdue_customers_mom_pct) }}
|
||||
</span>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 图区 -->
|
||||
<el-row :gutter="16" class="chart-row">
|
||||
<el-col :xs="24" :lg="14">
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<div class="card-head">
|
||||
<span>逐期趋势 · 应收总额 / 逾期户数</span>
|
||||
<span class="dim small">{{ trendRange }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<div ref="trendRef" class="chart-box"></div>
|
||||
<p class="chart-note">缺数据期以灰带标注「该期无数据」,曲线断点、不补 0。</p>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xs="24" :lg="10">
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<div class="card-head">
|
||||
<span>六档账龄堆叠</span>
|
||||
<el-tag size="small" :type="bucketsBalanced ? 'success' : 'danger'" effect="plain">
|
||||
六档和=合计 {{ balancedCount }}/{{ dataPointCount }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
<div ref="stackRef" class="chart-box"></div>
|
||||
<p class="chart-note">tooltip 内断言六档和与合计一致;不一致会标红。</p>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 逐户明细 -->
|
||||
<el-card shadow="never" class="table-card">
|
||||
<template #header>
|
||||
<div class="card-head">
|
||||
<span>逐户逾期清单 · {{ displayPeriod || '—' }}({{ caliberLabel }})</span>
|
||||
<div class="table-tools">
|
||||
<el-switch v-model="overdueOnly" size="small" active-text="仅逾期" @change="loadCustomers" />
|
||||
<span class="dim small">共 {{ customers.length }} 户</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<el-table
|
||||
:data="customers" size="small" stripe border
|
||||
:default-sort="{ prop: 'overdue_amt', order: 'descending' }"
|
||||
@sort-change="onSortChange"
|
||||
empty-text="该期无数据(不补 0)"
|
||||
>
|
||||
<el-table-column prop="unit_code" label="单位编码" width="170" sortable="custom" />
|
||||
<el-table-column prop="unit_name" label="单位名称" min-width="130" sortable="custom" />
|
||||
<el-table-column prop="total_amt" label="期末应收合计" width="130" align="right" sortable="custom">
|
||||
<template #default="{ row }">{{ money(row.total_amt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="overdue_amt" label="逾期额(>30天)" width="135" align="right" sortable="custom">
|
||||
<template #default="{ row }">
|
||||
<span :class="{ 'neg': row.overdue_amt > 0 }">{{ money(row.overdue_amt) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="pct_of_unit" label="逾期占该户" width="110" align="right">
|
||||
<template #default="{ row }">{{ pct(row.pct_of_unit) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="oldest_days" label="最老逾期档" width="110" align="center" sortable="custom">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.oldest_days">{{ row.oldest_days }} 天以上</span>
|
||||
<span v-else class="dim">未逾期</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-for="b in buckets" :key="b.col" :prop="`buckets.${b.col}`"
|
||||
:label="b.label" width="108" align="right">
|
||||
<template #default="{ row }">{{ money(row.buckets[b.col]) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import * as echarts from 'echarts'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import api, { arAgingApi } from '@/api'
|
||||
import { sessionEntityName } from '@/utils/entity'
|
||||
|
||||
const CALIBERS = ['借方应收户口径', '全户口径', '贷方户单列(净额)']
|
||||
const BUCKETS = [
|
||||
{ col: 'b_0_30', label: '0-30' },
|
||||
{ col: 'b_31_60', label: '31-60' },
|
||||
{ col: 'b_61_90', label: '61-90' },
|
||||
{ col: 'b_90_120', label: '90-120' },
|
||||
{ col: 'b_120_150', label: '120-150' },
|
||||
{ col: 'b_150p', label: '150+' },
|
||||
]
|
||||
|
||||
const entityLabel = ref(sessionEntityName())
|
||||
const calibers = ref<string[]>(CALIBERS)
|
||||
const caliber = ref(CALIBERS[0])
|
||||
const period = ref('')
|
||||
const periodOptions = ref<string[]>([])
|
||||
const meta = ref<any>({ available: null })
|
||||
const metaLoaded = ref(false)
|
||||
const overview = ref<any>({})
|
||||
const thresholds = computed<any>(() => overview.value.thresholds || {})
|
||||
const trend = ref<any[]>([])
|
||||
const customers = ref<any[]>([])
|
||||
const overdueOnly = ref(false)
|
||||
const loading = ref(false)
|
||||
const exporting = ref(false)
|
||||
const sortState = ref<{ sort: string; order: string }>({ sort: 'overdue_amt', order: 'desc' })
|
||||
|
||||
const caliberLabel = computed(() => caliber.value)
|
||||
const displayPeriod = computed(() => overview.value.period || period.value || '')
|
||||
const buckets = BUCKETS
|
||||
|
||||
let trendChart: any = null
|
||||
let stackChart: any = null
|
||||
|
||||
/** 金额格式化:null/undefined ⇒ 该期无数据(绝不显示 0.00) */
|
||||
function money(v: any): string {
|
||||
if (v === null || v === undefined || v === '') return '该期无数据'
|
||||
const n = Number(v)
|
||||
if (!Number.isFinite(n)) return '该期无数据'
|
||||
return `¥${n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
|
||||
}
|
||||
|
||||
function pct(v: any): string {
|
||||
if (v === null || v === undefined || !Number.isFinite(Number(v))) return '—'
|
||||
return `${(Number(v) * 100).toFixed(2)}%`
|
||||
}
|
||||
|
||||
function pctSigned(v: any): string {
|
||||
if (v === null || v === undefined || !Number.isFinite(Number(v))) return '—'
|
||||
const n = Number(v) * 100
|
||||
return `${n >= 0 ? '+' : ''}${n.toFixed(2)}%`
|
||||
}
|
||||
|
||||
function momClass(v: any): string {
|
||||
if (v === null || v === undefined) return 'dim'
|
||||
return Number(v) > 0 ? 'up' : Number(v) < 0 ? 'down' : 'dim'
|
||||
}
|
||||
|
||||
const shareColor = computed(() => {
|
||||
const s = overview.value?.share_90plus
|
||||
const th: any = thresholds.value
|
||||
if (s === null || s === undefined) return 'inherit'
|
||||
if (th.share_90plus_red_gt != null && Number(s) > Number(th.share_90plus_red_gt)) return '#f56c6c'
|
||||
if (th.share_90plus_yellow_min != null && Number(s) >= Number(th.share_90plus_yellow_min)) return '#e6a23c'
|
||||
return '#67c23a'
|
||||
})
|
||||
|
||||
const dataPoints = computed(() => trend.value.filter(p => p.has_data))
|
||||
const dataPointCount = computed(() => dataPoints.value.length)
|
||||
const balancedCount = computed(() => dataPoints.value.filter(p => {
|
||||
const sum = BUCKETS.reduce((acc, b) => acc + Number(p.buckets?.[b.col] ?? 0), 0)
|
||||
return Math.abs(sum - Number(p.total_amt ?? 0)) < 0.01
|
||||
}).length)
|
||||
const bucketsBalanced = computed(() => dataPointCount.value > 0 && balancedCount.value === dataPointCount.value)
|
||||
const trendRange = computed(() => {
|
||||
if (!trend.value.length) return ''
|
||||
return `${trend.value[0].period} ~ ${trend.value[trend.value.length - 1].period}(${trend.value.length} 期)`
|
||||
})
|
||||
|
||||
async function loadMeta() {
|
||||
const r: any = await arAgingApi.meta()
|
||||
meta.value = r || {}
|
||||
metaLoaded.value = true
|
||||
if (Array.isArray(r?.calibers) && r.calibers.length) calibers.value = r.calibers
|
||||
const periods: string[] = Array.isArray(r?.periods_available) ? r.periods_available : []
|
||||
// 期间候选 = 表内可用期 + 当前月(缺期也要能选中并显「该期无数据」)
|
||||
const nowPeriod = new Date().toISOString().slice(0, 7)
|
||||
const opts = [...periods]
|
||||
if (!opts.includes(nowPeriod)) opts.push(nowPeriod)
|
||||
periodOptions.value = opts.sort().reverse()
|
||||
if (!period.value) period.value = r?.latest_period || opts[0] || ''
|
||||
}
|
||||
|
||||
async function loadTrend() {
|
||||
const r: any = await arAgingApi.trend({ caliber: caliber.value })
|
||||
trend.value = Array.isArray(r?.points) ? r.points : []
|
||||
}
|
||||
|
||||
async function loadPeriodData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [ov, cu] = await Promise.all([
|
||||
arAgingApi.overview({ period: period.value, caliber: caliber.value }),
|
||||
arAgingApi.customers({
|
||||
period: period.value, caliber: caliber.value,
|
||||
overdue_only: overdueOnly.value, sort: sortState.value.sort, order: sortState.value.order,
|
||||
}),
|
||||
])
|
||||
overview.value = (ov as any) || {}
|
||||
customers.value = Array.isArray((cu as any)?.items) ? (cu as any).items : []
|
||||
if (!overview.value.period && period.value) overview.value.period = period.value
|
||||
await nextTick()
|
||||
renderCharts()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '账龄数据加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCustomers() {
|
||||
const r: any = await arAgingApi.customers({
|
||||
period: period.value, caliber: caliber.value,
|
||||
overdue_only: overdueOnly.value, sort: sortState.value.sort, order: sortState.value.order,
|
||||
})
|
||||
customers.value = Array.isArray(r?.items) ? r.items : []
|
||||
}
|
||||
|
||||
function onSortChange({ prop, order }: any) {
|
||||
if (!prop || !order) {
|
||||
sortState.value = { sort: 'overdue_amt', order: 'desc' }
|
||||
} else {
|
||||
sortState.value = { sort: prop, order: order === 'ascending' ? 'asc' : 'desc' }
|
||||
}
|
||||
loadCustomers()
|
||||
}
|
||||
|
||||
async function onCaliberChange() {
|
||||
await Promise.all([loadTrend(), loadPeriodData()])
|
||||
}
|
||||
|
||||
function renderCharts() {
|
||||
const periods = trend.value.map(p => p.period)
|
||||
const missing = trend.value.filter(p => !p.has_data).map(p => p.period)
|
||||
|
||||
if (trendRef.value) {
|
||||
trendChart = trendChart || echarts.init(trendRef.value)
|
||||
trendChart.setOption({
|
||||
grid: { left: 60, right: 60, top: 40, bottom: 40 },
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
formatter: (ps: any[]) => {
|
||||
const p = ps[0]?.axisValue
|
||||
const pt = trend.value.find(t => t.period === p)
|
||||
if (!pt || !pt.has_data) return `${p}<br/>该期无数据`
|
||||
return `${p}<br/>应收总额:${money(pt.total_amt)}<br/>逾期额(>30天):${money(pt.overdue_amt)}<br/>往来单位:${pt.customers} 户`
|
||||
},
|
||||
},
|
||||
legend: { data: ['应收总额', '往来单位户数'], top: 0 },
|
||||
xAxis: { type: 'category', data: periods, axisLabel: { fontSize: 11 } },
|
||||
yAxis: [
|
||||
{ type: 'value', name: '金额(元)', axisLabel: { formatter: (v: number) => `${(v / 10000).toFixed(0)}万` } },
|
||||
{ type: 'value', name: '户数', minInterval: 1 },
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: '应收总额', type: 'line', smooth: false, connectNulls: false,
|
||||
data: trend.value.map(p => (p.has_data ? p.total_amt : null)),
|
||||
itemStyle: { color: '#409EFF' }, areaStyle: { opacity: 0.08 },
|
||||
markArea: missing.length ? {
|
||||
silent: true,
|
||||
itemStyle: { color: 'rgba(144,147,153,0.12)' },
|
||||
label: { show: true, position: 'insideTop', color: '#909399', fontSize: 10, formatter: '该期无数据' },
|
||||
data: missing.map(p => [{ xAxis: p }, { xAxis: p }]),
|
||||
} : undefined,
|
||||
},
|
||||
{
|
||||
name: '往来单位户数', type: 'bar', yAxisIndex: 1,
|
||||
data: trend.value.map(p => (p.has_data ? p.customers : null)),
|
||||
itemStyle: { color: '#c8d6e5' }, barMaxWidth: 18,
|
||||
},
|
||||
],
|
||||
}, true)
|
||||
}
|
||||
|
||||
if (stackRef.value) {
|
||||
stackChart = stackChart || echarts.init(stackRef.value)
|
||||
stackChart.setOption({
|
||||
grid: { left: 60, right: 24, top: 40, bottom: 40 },
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
formatter: (ps: any[]) => {
|
||||
const p = ps[0]?.axisValue
|
||||
const pt = trend.value.find(t => t.period === p)
|
||||
if (!pt || !pt.has_data) return `${p}<br/>该期无数据`
|
||||
const sum = BUCKETS.reduce((acc, b) => acc + Number(pt.buckets?.[b.col] ?? 0), 0)
|
||||
const diff = sum - Number(pt.total_amt ?? 0)
|
||||
const ok = Math.abs(diff) < 0.01
|
||||
const lines = BUCKETS.map((b, i) => `${b.label}:${money(ps[i]?.value)}`)
|
||||
return [
|
||||
`${p}(${caliber.value})`,
|
||||
...lines,
|
||||
`六档之和:${money(sum)}`,
|
||||
`期末合计:${money(pt.total_amt)}`,
|
||||
ok ? '校验:六档和 = 合计' : `校验异常:差额 ${diff.toFixed(2)}`,
|
||||
].join('<br/>')
|
||||
},
|
||||
},
|
||||
legend: { data: BUCKETS.map(b => b.label), top: 0, itemWidth: 12, itemHeight: 8, textStyle: { fontSize: 11 } },
|
||||
xAxis: { type: 'category', data: periods, axisLabel: { fontSize: 11 } },
|
||||
yAxis: { type: 'value', axisLabel: { formatter: (v: number) => `${(v / 10000).toFixed(0)}万` } },
|
||||
series: BUCKETS.map((b, i) => ({
|
||||
name: b.label, type: 'bar', stack: 'aging', barMaxWidth: 26,
|
||||
data: trend.value.map(p => (p.has_data ? p.buckets?.[b.col] : null)),
|
||||
itemStyle: { color: ['#a3d0a3', '#e8c07d', '#e5a45f', '#e0765f', '#c74b4b', '#8f2f2f'][i] },
|
||||
})),
|
||||
}, true)
|
||||
}
|
||||
}
|
||||
|
||||
async function doExport() {
|
||||
exporting.value = true
|
||||
try {
|
||||
const res: any = await api.get('/ar-aging/customers/export', {
|
||||
params: {
|
||||
period: period.value, caliber: caliber.value,
|
||||
overdue_only: overdueOnly.value,
|
||||
},
|
||||
responseType: 'blob',
|
||||
})
|
||||
const url = window.URL.createObjectURL(res)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `应收账龄_${displayPeriod.value || 'noperiod'}_${caliber.value}.csv`
|
||||
a.click()
|
||||
window.URL.revokeObjectURL(url)
|
||||
ElMessage.success('逐户清单已导出')
|
||||
} catch (e: any) {
|
||||
ElMessage.error('导出失败(需 finance/ceo/business 权限且该期有数据)')
|
||||
} finally {
|
||||
exporting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onResize() {
|
||||
trendChart?.resize()
|
||||
stackChart?.resize()
|
||||
}
|
||||
|
||||
const trendRef = ref<HTMLElement | null>(null)
|
||||
const stackRef = ref<HTMLElement | null>(null)
|
||||
|
||||
onMounted(async () => {
|
||||
window.addEventListener('resize', onResize)
|
||||
await loadMeta()
|
||||
await loadTrend()
|
||||
await loadPeriodData()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', onResize)
|
||||
trendChart?.dispose()
|
||||
stackChart?.dispose()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ar-aging-page { max-width: 1440px; margin: 0 auto; padding: 16px; }
|
||||
.page-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; margin-bottom: 16px; }
|
||||
.page-title { font-size: 20px; font-weight: 600; margin: 0 0 8px; }
|
||||
.caliber-suffix { color: #409eff; font-size: 16px; font-weight: 500; }
|
||||
.badge-row { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 6px; }
|
||||
.source-note { font-size: 12px; color: #909399; margin: 0; line-height: 1.5; max-width: 720px; }
|
||||
.head-controls { display: flex; gap: 8px; align-items: center; flex-shrink: 0; }
|
||||
.metric { min-height: 108px; }
|
||||
.metric-label { font-size: 13px; color: #909399; }
|
||||
.metric-value { font-size: 26px; font-weight: 600; margin: 6px 0; word-break: break-all; }
|
||||
.metric-sub { font-size: 12px; color: #909399; }
|
||||
.chart-row { margin-top: 16px; }
|
||||
.chart-box { height: 320px; }
|
||||
.chart-note { font-size: 12px; color: #909399; margin: 6px 0 0; }
|
||||
.card-head { display: flex; justify-content: space-between; align-items: center; gap: 8px; }
|
||||
.table-card { margin-top: 16px; }
|
||||
.table-tools { display: flex; align-items: center; gap: 12px; }
|
||||
.small { font-size: 12px; }
|
||||
.dim { color: #909399; }
|
||||
.up { color: #f56c6c; font-weight: 600; }
|
||||
.down { color: #67c23a; font-weight: 600; }
|
||||
.neg { color: #f56c6c; }
|
||||
.th-red { color: #f56c6c; margin-right: 8px; }
|
||||
.th-yellow { color: #e6a23c; }
|
||||
.ico { width: 14px; height: 14px; margin-right: 4px; vertical-align: -2px; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user