2026-10-13 01:39:09 -04:00
|
|
|
|
"""回款管理接口集成测试(需要 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
|
2026-10-14 22:58:44 -04:00
|
|
|
|
from tests.integration.factories import create_business as _create_business
|
2026-10-13 01:39:09 -04:00
|
|
|
|
|
|
|
|
|
|
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]
|