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,