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