Files
fullstack-botandHermes CI Fix 71867c4c7a 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。
2026-10-21 08:42:24 +08:00

468 lines
18 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""应收账龄看板 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))},
)