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。
468 lines
18 KiB
Python
468 lines
18 KiB
Python
"""应收账龄看板 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))},
|
||
)
|