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。
72 lines
3.2 KiB
Python
72 lines
3.2 KiB
Python
"""应收账龄口径单一事实源(`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
|