Files
cma-management/backend/app/utils/ar_caliber.py
T
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

72 lines
3.2 KiB
Python
Raw 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.
"""应收账龄口径单一事实源(`hanke_ar_aging`)
财务已定死的口径纪律(ERRATA-02 §五 / DQ 台账 DQ-AR-03):
1. **三口径分列,禁跨口径合计**:`全户口径` / `借方应收户口径` / `贷方户单列(净额)`。
字面值逐字匹配——`贷方户单列(净额)` 写成 `贷方户单列` 会漏 42 行。
2. **对外「超期金额」只取 `借方应收户口径`**;贷方户单列不进分母,任何 `caliber=all` 请求 400。
3. **六档互斥**:0-30 / 31-60 / 61-90 / 90-120 / 120-150 / 150+,逐行六档和 ≡ `total_amt`。
4. `opening_amt`(期初)**只展示**,禁作账龄基数、禁进任何分子分母。
5. 负值保留:禁 `ABS()` / `COALESCE(...,0)` 抹平(DQ-AR-04 杨创 `b_0_30=-20`)。
"""
from typing import Optional, Tuple
# 表名(DDL:dama/_reports/prodwrite-cma-hanke-ar-aging-20261020/mirror/03_ddl.sql)
AR_AGING_TABLE = "hanke_ar_aging"
# assignee 派生依赖的只读维度/事实表(§6.2)。三者均为**只读 SELECT**,不写不建。
ZXT_MASTERBILL_TABLE = "zxt_masterbill" # 智享通单据流(unit_id / emp_id / bill_date / abst)
EMPLOYEE_DICT_TABLE = "employee_dict" # 员工字典(id → u_Name);当前库内**未落库**
UNITS_DICT_TABLE = "units_dict" # 往来单位字典(unit_code → unit_id);当前库内**未落库**
# 三口径字面值(逐字匹配,勿改写)
CALIBER_FULL = "全户口径"
CALIBER_DEBIT = "借方应收户口径"
CALIBER_CREDIT_SEPARATE = "贷方户单列(净额)"
CALIBERS: Tuple[str, ...] = (CALIBER_FULL, CALIBER_DEBIT, CALIBER_CREDIT_SEPARATE)
# 对外超期口径/看板默认口径
DEFAULT_CALIBER = CALIBER_DEBIT
# 跨口径合计请求字面值 —— 一律 400
CALIBER_ALL = "all"
# 六档列(顺序即展示顺序):(列名, 档位标签, 档位下界天数)
BUCKETS: Tuple[Tuple[str, str, int], ...] = (
("b_0_30", "0-30", 0),
("b_31_60", "31-60", 31),
("b_61_90", "61-90", 61),
("b_90_120", "90-120", 91),
("b_120_150", "120-150", 121),
("b_150p", "150+", 151),
)
BUCKET_COLUMNS = tuple(b[0] for b in BUCKETS)
# 逾期档位(>30 天)列;最老档下界天数即「逾期 N 天以上」的 N
OVERDUE_BUCKETS: Tuple[Tuple[str, int], ...] = tuple((b[0], b[2]) for b in BUCKETS if b[2] > 0)
# 90 天以上(含 90-120 档)
NINETY_PLUS_COLUMNS = ("b_90_120", "b_120_150", "b_150p")
class CaliberError(ValueError):
"""口径入参非法(未知值 / 跨口径合计)。"""
def validate_caliber(caliber: Optional[str], *, allow_none: bool = False) -> str:
"""校验口径字面值;`all` 与未知值一律拒绝(禁跨口径合计)。"""
if caliber is None or caliber == "":
if allow_none:
return DEFAULT_CALIBER
raise CaliberError("caliber 不能为空")
if caliber == CALIBER_ALL:
raise CaliberError("禁止跨口径合计(caliber=all 不提供):三口径必须分列查询")
if caliber not in CALIBERS:
raise CaliberError(
"未知口径 %r;仅接受字面值:%s" % (caliber, " / ".join(CALIBERS))
)
return caliber
def is_credit_separate(caliber: str) -> bool:
return caliber == CALIBER_CREDIT_SEPARATE