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

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

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

单测 21 项全通过(SQLite 夹具,不连生产库);vite build 通过。
阶段A 零生产写:未 push、未部署、未 --apply、未 dry_run=false。
This commit is contained in:
fullstack-bot
2026-10-21 08:42:24 +08:00
committed by Hermes CI Fix
parent 790be2d6b1
commit 71867c4c7a
11 changed files with 2552 additions and 4 deletions
+174 -3
View File
@@ -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
+467
View File
@@ -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))},
)