From f32f6e74309edf210388a10c579ae647797ac733 Mon Sep 17 00:00:00 2001 From: wecom-fullstack Date: Fri, 2 Oct 2026 21:02:50 +0800 Subject: [PATCH] =?UTF-8?q?feat(receivables):=20=E5=82=AC=E6=94=B6?= =?UTF-8?q?=E9=A1=B5=E5=8F=AA=E8=AE=A4=E5=AE=A2=E6=88=B7=E7=BA=A7=E8=A1=8C?= =?UTF-8?q?=20=E2=80=94=20=E6=9C=88=E5=BA=A6=E6=B1=87=E6=80=BB=E8=A1=8C?= =?UTF-8?q?=E4=B8=8D=E8=AE=A1=E5=85=A5=E5=8D=A1=E7=89=87/=E8=B4=A6?= =?UTF-8?q?=E9=BE=84/=E6=98=8E=E7=BB=86/owners?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 口径实现逐字节取自既有已批准件 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、无数据迁移 --- backend/app/api/cash.py | 38 +++++++++++++++++++++++++++++++------- 1 file changed, 31 insertions(+), 7 deletions(-) diff --git a/backend/app/api/cash.py b/backend/app/api/cash.py index 748518f2..5a7bfd88 100644 --- a/backend/app/api/cash.py +++ b/backend/app/api/cash.py @@ -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,