Files
cma-management/backend/tests/test_receivables_customer_level_20261001.py
Hermes CI Fix a67f4b688f feat(receivables): customer-level rows only + register hanke_ar as balance snapshot batch
ASK-20261001-33: api_receivables counts only rows with a non-blank counterparty (monthly aggregate rows stay in table but out of the collection page cards/aging/detail/owners); BALANCE_SNAPSHOT_SOURCES gains hanke_ar (default skip, no per-customer overdue alerts). New pytest file covers aggregate-row exclusion, e2 baseline digitwise, snapshot registration.
(cherry picked from commit 475f78bc8c5f67f2716c721172a88ff89ec58227)
2026-10-18 16:42:06 +08:00

249 lines
11 KiB
Python
Raw Permalink 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.
"""应收催收页「只认客户级行」口径 + 酣客快照批次登记(ASK-20261001-33,2026-10-01)
背景:酣客 67 行 `source='zxt-sync'` 月度汇总行(counterparty 空/空白)与落库后的 18 行
客户级 `hanke_ar` 同表并存。旧口径把两者求和 ⇒ 催收页出现 ¥1.86 亿假「应收总额」。
本组用例锁定两条契约:
1. `api_receivables` 只统计**客户级行**(counterparty 非空且非空白):汇总行不计入
total / summary / aging / owners;边界含 None / "" / 空格 / 制表符 / 全角空格。
2. 无汇总行的账套(博海 e2 形态:无空 counterparty)逐位不变 —— 判据收窄对既有账套
零影响(同 8 项基线数值)。
3. `hanke_ar` 已登记为余额快照批次(默认 skip ⇒ 不逐户生成伪逾期告警)。
"""
from datetime import datetime, timedelta
import pytest
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
from app.models import CashPlan, KPIAlert
from app.utils.cash_forecast_engine import (
BALANCE_SNAPSHOT_SOURCES,
check_cash_alerts,
get_balance_snapshot_mode,
split_snapshot_plans,
)
from tests.conftest import create_test_kpi, create_test_user, get_token_for_user, auth_header
BASE = "/api/cma/cash"
HANK_END = datetime(2026, 7, 31)
FUTURE = datetime.now() + timedelta(days=90)
def _add_plan(db: Session, **kwargs) -> CashPlan:
defaults = dict(
entity_id=1,
plan_type="receive",
amount=0.0,
plan_date=HANK_END,
status="pending",
source="manual",
paid_amount=0,
)
defaults.update(kwargs)
plan = CashPlan(**defaults)
db.add(plan)
db.commit()
db.refresh(plan)
return plan
def _receivables(client: TestClient, token: str) -> dict:
resp = client.get(f"{BASE}/receivables", headers=auth_header(token))
assert resp.status_code == 200
return resp.json()
class TestAggregateRowsExcluded:
"""① 月度汇总行被排除(客户级行照常计入)"""
def test_aggregate_rows_excluded_from_cards_rows_and_owners(self, client, db):
create_test_user(db)
token = get_token_for_user(client)
# 月度汇总行三形态:NULL / 空白 / 空串(source 均为 zxt-sync 实收历史)
_add_plan(db, counterparty=None, amount=440.61, status="completed",
plan_date=datetime(2021, 3, 31), source="zxt-sync", paid_amount=0)
_add_plan(db, counterparty=" ", amount=100.0, status="completed",
plan_date=datetime(2021, 4, 30), source="zxt-sync", paid_amount=0)
_add_plan(db, counterparty="", amount=50.0, status="pending",
plan_date=HANK_END, source="zxt-sync", owner="", paid_amount=0)
# 客户级行:落库后的酣客 18 行同形态(期末日 2026-07-31 ⇒ 60-90天档)
hanke = _add_plan(db, counterparty="陕西弦鸠商贸有限公司", amount=15.5538,
plan_date=HANK_END, source="hanke_ar")
own = _add_plan(db, counterparty="某客户", amount=10.0, plan_date=FUTURE,
source="manual", owner="张三")
body = _receivables(client, token)
ids = [row["id"] for row in body["data"]]
assert body["total"] == 2
assert ids == [hanke.id, own.id], "汇总行不得出现在明细里"
assert body["summary"]["total_amount"] == 25.55
assert body["summary"]["pending_balance"] == 25.55
assert body["summary"]["completed_amount"] == 0.0, "汇总行不得计入已回款"
assert body["summary"]["completed_count"] == 0
assert body["summary"]["aging"] == {"60-90天": 15.55, "未到期": 10.0}
assert body["owners"] == ["张三"], "汇总行不得贡献负责人筛选项"
@pytest.mark.parametrize("blank", [None, "", " ", "\t", "\u3000"])
def test_blank_counterparty_never_counted(self, client, db, blank):
create_test_user(db)
token = get_token_for_user(client)
_add_plan(db, counterparty=blank, amount=100.0, status="pending",
plan_date=HANK_END, source="zxt-sync")
body = _receivables(client, token)
assert body["total"] == 0
assert body["summary"]["total_amount"] == 0.0
assert body["summary"]["pending_balance"] == 0.0
assert body["summary"]["overdue_count"] == 0
assert body["summary"]["aging"] == {}
assert body["owners"] == []
def test_hanke_before_ingest_is_empty_not_error(self, client, db):
"""酣客未落库现状(仅 67 行汇总、无客户级行):卡片/明细如实为空且不报错。"""
create_test_user(db)
token = get_token_for_user(client)
for month in range(1, 4):
_add_plan(db, counterparty=None, amount=440.61, status="completed",
plan_date=datetime(2021, month, 28), source="zxt-sync")
body = _receivables(client, token)
assert body["total"] == 0
assert body["data"] == []
assert body["summary"]["pending_balance"] == 0.0
assert body["summary"]["completed_amount"] == 0.0
assert body["owners"] == []
def test_customer_rows_only_response_matches_hanke_expectation(self, client, db):
"""酣客落库后(18 行 hanke_ar,期末日 2026-07-31)催收页期望值:单档 60-90天。"""
create_test_user(db)
token = get_token_for_user(client)
yuan = [155538, 125858, 120320, 100791, 66230, 44400, 27860, 18510,
10290, 10219, 10000, 8160, 6940, 5290, 3410, 2000, 530, 500]
assert sum(yuan) == 716846
for idx, amount in enumerate(yuan):
_add_plan(db, counterparty="客户%02d" % idx, amount=round(amount / 10000.0, 4),
plan_date=HANK_END, source="hanke_ar")
body = _receivables(client, token)
assert body["total"] == 18
assert body["summary"]["total_amount"] == 71.68
assert body["summary"]["pending_balance"] == 71.68
assert body["summary"]["completed_amount"] == 0.0
assert body["summary"]["completed_count"] == 0
assert body["summary"]["overdue_count"] == 18
assert body["summary"]["overdue_amount"] == 71.68
assert body["summary"]["aging"] == {"60-90天": 71.68}
class TestE2BaselineUnchanged:
"""② 无汇总行账套(博海 e2 形态)逐位不变 —— 8 项基线数值"""
def test_customer_rows_all_kept_digitwise(self, client, db):
create_test_user(db)
token = get_token_for_user(client)
# 复刻 e2 形态:counterparty 全非空(NULL/空 = 0 行),含快照批次来源与已完成行
_add_plan(db, counterparty="XX学校", amount=1.26, paid_amount=1.26,
status="completed", plan_date=datetime(2026, 4, 20),
source="receivables_migrate")
_add_plan(db, counterparty="博海客户A", amount=100.0, plan_date=datetime(2026, 6, 30),
source="bohai_ar_20260630")
_add_plan(db, counterparty="博海客户B", amount=100.72, plan_date=datetime(2026, 6, 1),
source="bohai_ar")
_add_plan(db, counterparty="客户C", amount=194.5, plan_date=FUTURE,
source="manual", owner="王婧")
body = _receivables(client, token)
assert body["total"] == 4, "无空 counterparty 时一行都不得被排除"
assert body["summary"]["total_amount"] == 396.48
assert body["summary"]["pending_balance"] == 395.22
assert body["summary"]["overdue_count"] == 2
assert body["summary"]["overdue_amount"] == 200.72
assert body["summary"]["completed_amount"] == 1.26
assert body["summary"]["completed_count"] == 1
assert body["summary"]["aging"] == {"90天以上": 200.72, "未到期": 194.5}
assert body["owners"] == ["王婧"]
def test_filter_is_row_shape_not_source_whitelist(self, client, db):
"""判据是行形态:客户级行即便来源是 zxt-sync 也计入(source 不是白名单)。"""
create_test_user(db)
token = get_token_for_user(client)
_add_plan(db, counterparty="智享通客户", amount=12.34, plan_date=FUTURE,
source="zxt-sync")
_add_plan(db, counterparty=None, amount=999.0, plan_date=FUTURE, source="manual")
body = _receivables(client, token)
assert body["total"] == 1
assert body["data"][0]["counterparty"] == "智享通客户"
assert body["summary"]["total_amount"] == 12.34
class TestHankeSnapshotRegistration:
"""③ 快照批次登记(默认 skip ⇒ 不逐户生成伪逾期告警)"""
def test_hanke_ar_registered_and_default_skip(self, monkeypatch):
monkeypatch.delenv("CMA_CASH_SNAPSHOT_SOURCES", raising=False)
monkeypatch.delenv("CMA_CASH_SNAPSHOT_MODE", raising=False)
assert "hanke_ar" in BALANCE_SNAPSHOT_SOURCES
assert get_balance_snapshot_mode() == "skip"
def test_split_snapshot_classifies_hanke_ar(self, monkeypatch):
monkeypatch.delenv("CMA_CASH_SNAPSHOT_SOURCES", raising=False)
class _P:
def __init__(self, source):
self.source = source
self.plan_type = "receive"
rows = [_P("hanke_ar"), _P("manual"), _P("bohai_ar_20260630")]
business, snapshot = split_snapshot_plans(rows)
assert [p.source for p in business] == ["manual"]
assert sorted(p.source for p in snapshot) == ["bohai_ar_20260630", "hanke_ar"]
def _seed_overdue_hanke(self, db: Session, n: int = 3):
for idx in range(n):
_add_plan(db, counterparty="酣客客户%d" % idx, amount=10.0,
plan_date=HANK_END, source="hanke_ar")
def test_default_skip_no_per_customer_overdue_alerts(self, client, db, monkeypatch):
"""默认 skip:18 行形 hanke_ar 快照行不逐户生成伪逾期告警(告警计数等值判据的单元层代理)。"""
monkeypatch.delenv("CMA_CASH_SNAPSHOT_MODE", raising=False)
monkeypatch.delenv("CMA_CASH_SNAPSHOT_SOURCES", raising=False)
create_test_kpi(db)
self._seed_overdue_hanke(db, 3)
res = check_cash_alerts(db, entity_id=1)
assert res["balance_snapshot"]["mode"] == "skip"
assert res["balance_snapshot"]["skipped_rows"] == 3
assert "hanke_ar" in res["balance_snapshot"]["sources"]
assert [a for a in res["alerts"] if a["type"] == "overdue_receive"] == []
assert db.query(KPIAlert).filter(
KPIAlert.alert_type == "cash_plan",
KPIAlert.status.in_(["pending", "processing"]),
).count() == 0
def test_per_customer_mode_control_does_alert(self, client, db, monkeypatch):
"""负对照:显式切 per_customer(旧行为)时同 3 行会逐户告警 —— 证明上一条不是「没数据」。"""
monkeypatch.setenv("CMA_CASH_SNAPSHOT_MODE", "per_customer")
monkeypatch.delenv("CMA_CASH_SNAPSHOT_SOURCES", raising=False)
create_test_kpi(db)
self._seed_overdue_hanke(db, 3)
res = check_cash_alerts(db, entity_id=1)
assert res["balance_snapshot"]["mode"] == "per_customer"
assert len([a for a in res["alerts"] if a["type"] == "overdue_receive"]) == 3
assert db.query(KPIAlert).filter(
KPIAlert.alert_type == "cash_plan",
KPIAlert.status.in_(["pending", "processing"]),
).count() == 3