"""应收账龄看板 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))}, )