feat(receivables): 催收页只认客户级行 — 月度汇总行不计入卡片/账龄/明细/owners

- 口径实现逐字节取自既有已批准件 475f78b(ASK-20261001-33, wt/hanke-ar-customer-level):
  _is_customer_level_row(plan) = counterparty 非空且非空白(.strip() 覆盖制表符/全角空格)
  用在两处且仅两处:明细循环 + owners 查询 ⇒ 四条消费面同一判据
- 数据零变更:cash_plans 一行不删不改(source='zxt-sync' 的 67 行月度汇总行=正确通道数据,保留)
- 不新写第二份实现;不改 frontend/src/api/index.ts 全局拦截器;无 DDL、无数据迁移
This commit is contained in:
wecom-fullstack
2026-10-02 21:15:42 +08:00
parent 29dc5c0bdc
commit f32f6e7430
+31 -7
View File
@@ -31,6 +31,20 @@ router = APIRouter(
)
def _is_customer_level_row(plan) -> bool:
"""行形态判据(催收页唯一口径,ASK-20261001-33):**客户级行** = 客户列非空且非空白。
月度汇总行(`counterparty` 为 NULL / 空白,如 `source='zxt-sync'` 的 67 行实收历史)
**不是客户级应收**,不得计入催收页的卡片 / 账龄 / 明细 / `owners`——否则求和会得到
¥1.86 亿的假「应收总额」(67 行汇总 18,596.71 万 与 18 行客户级 71.68 万混加)。
判据取**行形态**而非 `source` 白名单:`source` 是数据来源标记(`zxt-sync` = 智享通通道
注入标记,来源正确、不得丢弃),不能用来区分行语义。
`str.strip()` 覆盖 SQL `TRIM()` 不处理的制表符 / 全角空格等空白变体。
"""
return bool((plan.counterparty or "").strip())
def _aging_bucket(days_late: int) -> str:
"""账龄区间(按逾期天数):未到期 / 0-30天 / 30-60天 / 60-90天 / 90天以上"""
if days_late <= 0:
@@ -565,7 +579,11 @@ def api_receivables(
keyword: str = Query(None, description="客户/说明关键字"),
db: Session = Depends(get_db),
):
"""应收催收视图 — 应收余额 + 账龄(30/60/90) + 逾期状态 + 负责人筛选"""
"""应收催收视图 — 应收余额 + 账龄(30/60/90) + 逾期状态 + 负责人筛选
口径(ASK-20261001-33):**只认客户级行**——月度汇总行(`counterparty` 空/空白)
不计入卡片 / 账龄 / 明细 / `owners`。
"""
query = db.query(CashPlan).filter(
CashPlan.entity_id == entity_id,
CashPlan.plan_type == "receive",
@@ -586,6 +604,8 @@ def api_receivables(
completed_amount = 0.0
completed_count = 0
for p in plans:
if not _is_customer_level_row(p):
continue # 月度汇总行(客户列为空/空白)不计入催收页
d = _plan_dict(p)
if status:
if status == "overdue":
@@ -609,12 +629,16 @@ def api_receivables(
completed_amount += d["amount"]
completed_count += 1
owners = [r[0] for r in db.query(CashPlan.owner).filter(
CashPlan.entity_id == entity_id,
CashPlan.plan_type == "receive",
CashPlan.owner.isnot(None),
CashPlan.owner != "",
).distinct().order_by(CashPlan.owner.asc()).all()]
# owners 与明细同一判据:月度汇总行(客户列为空/空白)不得贡献筛选项
owners = sorted({
row.owner for row in db.query(CashPlan.owner, CashPlan.counterparty).filter(
CashPlan.entity_id == entity_id,
CashPlan.plan_type == "receive",
CashPlan.owner.isnot(None),
CashPlan.owner != "",
).distinct().all()
if _is_customer_level_row(row)
})
return {
"entity_id": entity_id,