Files
cma-management/backend/tests/test_cash_summary_snapshot_alignment_20260913.py

174 lines
8.6 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.
"""F4b 断言测试:到期未收款「展示口径」与「落库口径」同源(任务 dama-cashplan-zombie-alert-cleanup-20260913 B 段)
背景:check_cash_alerts() 写库路径已按 get_balance_snapshot_mode() 过滤余额快照批次,
但 forecast_cash_flow_with_plans() 的 summary / suggestions 仍按未过滤集合统计 ⇒
同一次响应里「写入 0 条」与「展示 216 笔 / 200.72 万」并存(DAMA 复核 fullstack-20260913-0703 §3.3)。
本用例锁定:
1) 默认 skip:summary.overdue_receive_count/amount 不含快照批次行;excluded 字段可见
2) per_customer(旧行为):逐户全额计入
3) aggregate(写库侧 1 条/批次):展示按批次数计
4) suggestions 文案与 summary 同口径,且对未计入批次显式标注
5) summarize_overdue_receives() 纯函数口径矩阵(含 upcoming_7d 同源)
"""
from datetime import datetime, timedelta
import pytest
from sqlalchemy.orm import Session
from app.models import CashPlan
from app.utils.cash_forecast_engine import (
BALANCE_SNAPSHOT_SOURCES,
forecast_cash_flow_with_plans,
get_balance_snapshot_mode,
summarize_overdue_receives,
)
SNAP_SRC = "bohai_ar_20260630" # ∈ BALANCE_SNAPSHOT_SOURCES
EID = 1
TODAY = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
def _seed(db: Session):
"""业务逾期 2 笔(20 + 30 万)+ 快照批次逾期 3 笔(10 + 20 + 30 万)"""
rows = [
# 业务行(source 非快照名单)
CashPlan(entity_id=EID, plan_type="receive", status="pending", source="manual",
amount=20.0, counterparty="业务A", plan_date=TODAY - timedelta(days=9)),
CashPlan(entity_id=EID, plan_type="receive", status="pending", source=None,
amount=30.0, counterparty="业务B", plan_date=TODAY - timedelta(days=3)),
# 快照批次行
CashPlan(entity_id=EID, plan_type="receive", status="pending", source=SNAP_SRC,
amount=10.0, counterparty="快照A", plan_date=TODAY - timedelta(days=75)),
CashPlan(entity_id=EID, plan_type="receive", status="pending", source=SNAP_SRC,
amount=20.0, counterparty="快照B", plan_date=TODAY - timedelta(days=75)),
CashPlan(entity_id=EID, plan_type="receive", status="pending", source=SNAP_SRC,
amount=30.0, counterparty="快照C", plan_date=TODAY - timedelta(days=75)),
]
for r in rows:
db.add(r)
db.commit()
return rows
def _overdue_rows(db: Session):
return db.query(CashPlan).filter(
CashPlan.entity_id == EID,
CashPlan.plan_type == "receive",
CashPlan.status == "pending",
CashPlan.plan_date < TODAY,
).all()
# ── 1. 纯函数口径矩阵 ────────────────────────────────────────────────
class TestSummarizeOverdueReceives:
def test_default_is_skip_and_excludes_snapshot(self, db: Session):
assert get_balance_snapshot_mode() == "skip"
rows = _seed(db)
meta = summarize_overdue_receives(_overdue_rows(db))
assert meta["mode"] == "skip"
assert meta["count"] == 2, "skip 下只计业务行"
assert meta["amount"] == 50.0
assert meta["raw_count"] == 5
assert meta["raw_amount"] == 110.0
assert meta["snapshot_rows_excluded"] == 3
assert meta["snapshot_rows_excluded_amount"] == 60.0
assert meta["snapshot_rows_total"] == 3
assert meta["snapshot_batches"] == [{"source": SNAP_SRC, "count": 3, "amount": 60.0}]
def test_per_customer_counts_everything(self, db: Session):
_seed(db)
meta = summarize_overdue_receives(_overdue_rows(db), mode="per_customer")
assert meta["count"] == 5
assert meta["amount"] == 110.0
assert meta["snapshot_rows_excluded"] == 0
def test_aggregate_counts_batches(self, db: Session):
_seed(db)
meta = summarize_overdue_receives(_overdue_rows(db), mode="aggregate")
# 写库侧 aggregate = 1 条/批次 ⇒ 展示 = 2 业务行 + 1 批次 = 3 条,金额全量 110 万
assert meta["count"] == 3
assert meta["amount"] == 110.0
assert meta["snapshot_rows_excluded"] == 2 # 3 行里 2 行被折叠进批次条目
assert len(meta["snapshot_batches"]) == 1
def test_batch_list_is_internal_whitelist(self):
assert SNAP_SRC in BALANCE_SNAPSHOT_SOURCES
# ── 2. 端到端:forecast_cash_flow_with_plans 的 summary/suggestions ──
class TestForecastSummaryAlignment:
def test_skip_mode_summary_excludes_snapshot(self, db: Session):
"""默认 skip:summary 口径 = 落库口径(断言「写入 0 条 / 展示 3 笔」不再发生)"""
_seed(db)
result = forecast_cash_flow_with_plans(EID, db, days=30)
s = result["summary"]
assert s["snapshot_mode"] == "skip"
assert s["overdue_receive_count"] == 2
assert s["overdue_receive_amount"] == 50.0
assert s["snapshot_rows_excluded"] == 3
assert s["overdue_receive_count_raw"] == 5
assert s["overdue_receive_amount_raw"] == 110.0
def test_skip_mode_suggestion_discloses_excluded_batch(self, db: Session):
_seed(db)
result = forecast_cash_flow_with_plans(EID, db, days=30)
msgs = [x["message"] for x in result["suggestions"] if "应收款到期未收" in x["message"]]
assert msgs, "应保留业务行催收建议"
msg = msgs[0]
assert msg.startswith("有2笔应收款到期未收,合计50.0万")
assert "余额快照批次3行/60.0万未计入" in msg
def test_no_business_overdue_means_no_overdue_suggestion(self, db: Session):
"""只有快照批次(博海今日实况):skip 下不产生「到期未收」建议 —— 与 new_alerts=0 一致"""
db.add(CashPlan(entity_id=EID, plan_type="receive", status="pending", source=SNAP_SRC,
amount=198.27, counterparty="快照户", plan_date=TODAY - timedelta(days=75)))
db.commit()
result = forecast_cash_flow_with_plans(EID, db, days=30)
assert result["summary"]["overdue_receive_count"] == 0
assert result["summary"]["snapshot_rows_excluded"] == 1
assert not [x for x in result["suggestions"] if "应收款到期未收" in x["message"]]
def test_per_customer_env_restores_old_display(self, db: Session, monkeypatch):
monkeypatch.setenv("CMA_CASH_SNAPSHOT_MODE", "per_customer")
_seed(db)
result = forecast_cash_flow_with_plans(EID, db, days=30)
s = result["summary"]
assert s["snapshot_mode"] == "per_customer"
assert s["overdue_receive_count"] == 5
assert s["overdue_receive_amount"] == 110.0
assert s["snapshot_rows_excluded"] == 0
def test_aggregate_env_display_matches_write_side(self, db: Session, monkeypatch):
monkeypatch.setenv("CMA_CASH_SNAPSHOT_MODE", "aggregate")
_seed(db)
result = forecast_cash_flow_with_plans(EID, db, days=30)
s = result["summary"]
assert s["snapshot_mode"] == "aggregate"
assert s["overdue_receive_count"] == 3
assert s["overdue_receive_amount"] == 110.0
def test_upcoming_7d_excludes_snapshot_rows(self, db: Session):
"""upcoming_7d 同源:快照行的 plan_date 非真实到期日,skip 下不计入"""
db.add(CashPlan(entity_id=EID, plan_type="receive", status="pending", source="manual",
amount=5.0, counterparty="近7天", plan_date=TODAY + timedelta(days=3)))
db.add(CashPlan(entity_id=EID, plan_type="receive", status="pending", source=SNAP_SRC,
amount=7.0, counterparty="快照7天", plan_date=TODAY + timedelta(days=3)))
db.commit()
result = forecast_cash_flow_with_plans(EID, db, days=30)
assert result["summary"]["upcoming_7d_count"] == 1
def test_entity_isolation_and_status_filter(self, db: Session):
"""口径修正不影响实体隔离/状态过滤(只算本实体 pending 逾期应收)"""
_seed(db)
db.add(CashPlan(entity_id=2, plan_type="receive", status="pending", source="manual",
amount=999.0, counterparty="博海", plan_date=TODAY - timedelta(days=9)))
db.add(CashPlan(entity_id=EID, plan_type="receive", status="completed", source="manual",
amount=888.0, counterparty="已完成", plan_date=TODAY - timedelta(days=9)))
db.commit()
result = forecast_cash_flow_with_plans(EID, db, days=30)
assert result["summary"]["overdue_receive_count"] == 2
assert result["summary"]["overdue_receive_amount"] == 50.0