"""应收催收页「只认客户级行」口径 + 酣客快照批次登记(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