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))},
|
||||
)
|
||||
Reference in New Issue
Block a user