174 lines
8.6 KiB
Python
174 lines
8.6 KiB
Python
"""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
|