PayrollManagementSystem/backend/tests/integration/test_receipts.py

379 lines
15 KiB
Python
Raw Normal View History

"""回款管理接口集成测试(需要 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]