"""回款管理接口集成测试(需要 MySQL 与 Redis)。 守的是回款的数据完整性三道线 —— ① 发货批次同业务强校验:跨业务指定会让 回款天数与台账归属漂移;② settlement_status 是 M3 核算防重锚点(需规 2.3.3「每笔回款仅核算一次」),已核算禁改禁删缺失会让台账与源数据漂移; ③ total_received_amount 回写是 M3 起付判定的直接消费源,删行漏算即漂移。 payment_days 为只读派生值,锁定「发货日首日计第 1 天」口径。 运行前置:``docker compose -f docker/docker-compose.dev.yml up -d`` 数据卫生:人员/公司/产品名一律 ``t_`` 前缀由 conftest 兜底清理;回款批次 编号系统生成(HK 前缀),conftest 按编号前缀全量清;「已核算」的造数走 直接改库(M2 只写 0、置位是 M3 职责),M3 核算置位语义不绕开。 """ import re import uuid from datetime import date from decimal import Decimal import pytest from fastapi.testclient import TestClient from sqlalchemy import select from app.core.constants import ACTION_CREATE, MODULE_RECEIPT from app.core.db import get_session_factory from app.models import OperationLog, Receipt from app.models.receipt import SETTLEMENT_DONE from tests.integration.factories import create_business as _create_business pytestmark = pytest.mark.integration _BUSINESSES = "/api/v1/business/businesses" _SHIPMENTS = "/api/v1/business/shipments" _RECEIPTS = "/api/v1/business/receipts" def _sitem(**overrides: object) -> dict: body: dict = { "product_name": f"t_产品_{uuid.uuid4().hex[:8]}", "quantity": "1", "sale_price": "500.00", } body.update(overrides) return body def _spayload(business_id: int, ship_date: str, **overrides: object) -> dict: body: dict = { "business_id": business_id, "ship_date": ship_date, "items": [_sitem()], } body.update(overrides) return body def _create_shipment(client: TestClient, headers: dict, business_id: int, ship_date: str) -> dict: resp = client.post(_SHIPMENTS, json=_spayload(business_id, ship_date), headers=headers) assert resp.status_code == 200, resp.text return resp.json()["data"] def _rpayload(business_id: int, **overrides: object) -> dict: body: dict = { "business_id": business_id, "receipt_date": str(date.today()), "amount": "1200.00", } body.update(overrides) return body def _create(client: TestClient, headers: dict, business_id: int, **overrides: object) -> dict: resp = client.post(_RECEIPTS, json=_rpayload(business_id, **overrides), headers=headers) assert resp.status_code == 200, resp.text return resp.json()["data"] def _terminate(client: TestClient, headers: dict, business_id: int) -> None: """把业务流转到已终止(终态禁写场景的造数步骤)。""" resp = client.put( f"{_BUSINESSES}/{business_id}/status", json={"status": 3, "reason": f"t_终止_{uuid.uuid4().hex[:6]}"}, headers=headers, ) assert resp.status_code == 200, resp.text def _settle(receipt_id: int) -> None: """已核算造数:M2 只写 0,置 1 由 M3 核算负责 —— 测试直接改库模拟置位, 不绕开「M2 不置位」的实现约定。""" factory = get_session_factory() with factory() as session: row = session.get(Receipt, receipt_id) assert row is not None, "回款行不存在" row.settlement_status = SETTLEMENT_DONE session.commit() def _logs_of(username: str) -> list[OperationLog]: """取某操作人的审计行(按 id 倒序,最新在前)。""" factory = get_session_factory() with factory() as session: return list( session.execute( select(OperationLog) .where(OperationLog.username == username) .order_by(OperationLog.id.desc()) ).scalars() ) class TestAuth: """鉴权:业务数据属业务操作不要求管理员,但仍是登录后接口。""" def test_未登录访问回款列表_401(self, client: TestClient) -> None: resp = client.get(_RECEIPTS) assert resp.status_code == 401, resp.text class TestCreate: """创建:HK 批次契约、金额红线、回款天数派生与业务合计回写。""" def test_新增回款_批次号格式_未指定批次_无派生(self, client: TestClient, normal: dict) -> None: headers = normal["headers"] business = _create_business(client, headers) data = _create(client, headers, business["id"]) # 批次号格式:HK{YYYYMMDD}-{4位当日序号}(日期取当日) expected_prefix = f"HK{date.today():%Y%m%d}-" assert re.fullmatch(r"HK\d{8}-\d{4}", data["receipt_batch_no"]), data["receipt_batch_no"] assert data["receipt_batch_no"].startswith(expected_prefix), data["receipt_batch_no"] # 未指定批次(shipment_id 空):批次可暂不指定,回款天数无从派生 assert data["shipment_id"] is None assert data["shipment_no"] is None assert data["payment_days"] is None # M2 只写 0:置 1 由 M3 核算负责 assert data["settlement_status"] == 0 assert Decimal(data["amount"]) == Decimal("1200") def test_指定批次_回款天数发货日首日计第1天(self, client: TestClient, normal: dict) -> None: """发货 2026-10-05 / 回款 2026-11-02 → 29 天(首日计第 1 天, 需规「26 天」示例已作废)。""" headers = normal["headers"] business = _create_business(client, headers) shipment = _create_shipment(client, headers, business["id"], "2026-10-05") data = _create( client, headers, business["id"], shipment_id=shipment["id"], receipt_date="2026-11-02", amount="500.00", ) assert data["shipment_id"] == shipment["id"] assert data["shipment_no"] == shipment["shipment_no"] assert data["payment_days"] == 29, data def test_新增回款_业务合计回写_删除后回退(self, client: TestClient, normal: dict) -> None: headers = normal["headers"] business = _create_business(client, headers) data = _create(client, headers, business["id"]) after = client.get(f"{_BUSINESSES}/{business['id']}", headers=headers).json()["data"] assert Decimal(after["total_received_amount"]) == Decimal("1200") assert client.delete(f"{_RECEIPTS}/{data['id']}", headers=headers).status_code == 200 rollback = client.get(f"{_BUSINESSES}/{business['id']}", headers=headers).json()["data"] assert Decimal(rollback["total_received_amount"]) == Decimal("0") def test_业务不存在_404(self, client: TestClient, normal: dict) -> None: resp = client.post(_RECEIPTS, json=_rpayload(999999999), headers=normal["headers"]) assert resp.status_code == 404, resp.text assert "业务不存在" in resp.json()["message"], resp.text def test_终态业务禁新增回款_400(self, client: TestClient, normal: dict) -> None: headers = normal["headers"] business = _create_business(client, headers) _terminate(client, headers, business["id"]) resp = client.post(_RECEIPTS, json=_rpayload(business["id"]), headers=headers) assert resp.status_code == 400, resp.text assert "业务已完成或已终止,禁止新增回款" in resp.json()["message"], resp.text def test_金额负数_400(self, client: TestClient, normal: dict) -> None: """一期禁负数金额(红字/退款冲销需求出现时先补设计再放开)。""" business = _create_business(client, normal["headers"]) resp = client.post( _RECEIPTS, json=_rpayload(business["id"], amount="-1"), headers=normal["headers"] ) assert resp.status_code == 400, resp.text assert "不能为负数" in resp.json()["message"], resp.text def test_金额三位小数_400(self, client: TestClient, normal: dict) -> None: """小数位超限直接拒绝而非静默舍入 —— 舍入即算错钱。""" business = _create_business(client, normal["headers"]) resp = client.post( _RECEIPTS, json=_rpayload(business["id"], amount="12.345"), headers=normal["headers"], ) assert resp.status_code == 400, resp.text assert "最多两位小数" in resp.json()["message"], resp.text class TestCrossBusiness: """发货批次同业务强校验 —— 两类失败(不存在/跨业务)统一文案,防响应 差异探测发货单存在性。""" def test_跨业务指定发货批次_400(self, client: TestClient, normal: dict) -> None: headers = normal["headers"] business1 = _create_business(client, headers) shipment = _create_shipment(client, headers, business1["id"], str(date.today())) business2 = _create_business(client, headers) resp = client.post( _RECEIPTS, json=_rpayload(business2["id"], shipment_id=shipment["id"]), headers=headers, ) assert resp.status_code == 400, resp.text assert "发货批次不属于该业务,无法指定" in resp.json()["message"], resp.text def test_编辑时指定跨业务发货批次_400(self, client: TestClient, normal: dict) -> None: """编辑路径(全量替换)同样强校验,无「未变即免检」旁路。""" headers = normal["headers"] business1 = _create_business(client, headers) shipment = _create_shipment(client, headers, business1["id"], str(date.today())) business2 = _create_business(client, headers) data = _create(client, headers, business2["id"]) resp = client.put( f"{_RECEIPTS}/{data['id']}", json={ "shipment_id": shipment["id"], "receipt_date": str(date.today()), "amount": "1200.00", }, headers=headers, ) assert resp.status_code == 400, resp.text assert "发货批次不属于该业务,无法指定" in resp.json()["message"], resp.text def test_编辑回款_全量替换_派生值实时刷新(self, client: TestClient, normal: dict) -> None: headers = normal["headers"] business = _create_business(client, headers) shipment = _create_shipment(client, headers, business["id"], "2026-10-05") data = _create(client, headers, business["id"]) assert data["payment_days"] is None specified = client.put( f"{_RECEIPTS}/{data['id']}", json={ "shipment_id": shipment["id"], "receipt_date": "2026-11-02", "amount": "300.00", }, headers=headers, ) assert specified.status_code == 200, specified.text body = specified.json()["data"] assert body["payment_days"] == 29, "指定批次后回款天数按发货日首日派生" assert body["shipment_no"] == shipment["shipment_no"] cleared = client.put( f"{_RECEIPTS}/{data['id']}", json={ "shipment_id": None, "receipt_date": "2026-11-02", "amount": "300.00", }, headers=headers, ) assert cleared.status_code == 200, cleared.text assert cleared.json()["data"]["payment_days"] is None, "清除指定后派生值回 null" class TestSettled: """已核算回款禁改禁删(M3 核算防重锚点)—— 台账固化后源数据禁漂移。""" def test_已核算禁改_400(self, client: TestClient, normal: dict) -> None: headers = normal["headers"] business = _create_business(client, headers) data = _create(client, headers, business["id"]) _settle(data["id"]) resp = client.put( f"{_RECEIPTS}/{data['id']}", json={ "shipment_id": None, "receipt_date": str(date.today()), "amount": "9999.00", }, headers=headers, ) assert resp.status_code == 400, resp.text assert "已核算回款禁止修改" in resp.json()["message"], resp.text def test_已核算禁删_400(self, client: TestClient, normal: dict) -> None: headers = normal["headers"] business = _create_business(client, headers) data = _create(client, headers, business["id"]) _settle(data["id"]) resp = client.delete(f"{_RECEIPTS}/{data['id']}", headers=headers) assert resp.status_code == 400, resp.text assert "已核算回款禁止删除" in resp.json()["message"], resp.text class TestFilter: """核算状态筛选(M3 核算消费的入口口径)。""" def test_settlement_status筛选_0只回未核算_1只回已核算( self, client: TestClient, normal: dict ) -> None: headers = normal["headers"] business = _create_business(client, headers) pending = _create(client, headers, business["id"], amount="500.00") settled = _create(client, headers, business["id"], amount="700.00") _settle(settled["id"]) resp0 = client.get( _RECEIPTS, params={"business_id": business["id"], "settlement_status": 0}, headers=headers, ) assert resp0.status_code == 200, resp0.text rows0 = resp0.json()["data"]["list"] assert [r["id"] for r in rows0] == [pending["id"]], rows0 assert rows0[0]["settlement_status"] == 0 resp1 = client.get( _RECEIPTS, params={"business_id": business["id"], "settlement_status": 1}, headers=headers, ) assert resp1.status_code == 200, resp1.text rows1 = resp1.json()["data"]["list"] assert [r["id"] for r in rows1] == [settled["id"]], rows1 class TestAudit: """写操作(含校验失败的)都在 operation_logs 留痕 —— 审计是验收项。""" def test_新增回款成功留审计(self, client: TestClient, normal: dict) -> None: headers = normal["headers"] business = _create_business(client, headers) data = _create(client, headers, business["id"]) logs = [ log for log in _logs_of(normal["username"]) if log.module == MODULE_RECEIPT and log.action == ACTION_CREATE ] assert logs, "新增回款未留操作日志" latest = logs[0] assert latest.success == 1 assert "新增回款" in latest.summary assert data["receipt_batch_no"] in latest.summary def test_跨业务指定失败留审计(self, client: TestClient, normal: dict) -> None: headers = normal["headers"] business1 = _create_business(client, headers) shipment = _create_shipment(client, headers, business1["id"], str(date.today())) business2 = _create_business(client, headers) resp = client.post( _RECEIPTS, json=_rpayload(business2["id"], shipment_id=shipment["id"]), headers=headers, ) assert resp.status_code == 400, resp.text failed = [ log for log in _logs_of(normal["username"]) if log.module == MODULE_RECEIPT and log.success == 0 ] assert failed, "失败操作未留痕" assert "发货批次不属于该业务" in (failed[0].error_message or ""), failed[0]