feat(核算)!: 添加 T+ 正式核算与采集托管
补齐源快照、资料预演、正式导入及工资核算的依赖链路。 新增版本化输入、逐段 FIFO、生成锁和结构化当月调整, 并提供默认关闭的周期采集、有限重试及 Windows 托管。 原始客户资料、私有配置及首页改动不纳入本次提交。 BREAKING CHANGE: 升级前需应用新增迁移,并通过 PAYROLL_MAKER_USER_ID 绑定实际工资制作人,否则业务只读。 旧单批次核算默认关闭;历史兼容需显式启用 PAYROLL_ALLOW_LEGACY_CALCULATION。生产启用和真实金额待验收。
This commit is contained in:
1 parent
1b074576fe
commit
108bf87225
154 files changed
+9515
-645
No files matched your search
@@ -19,6 +19,7 @@
|
||||
评级用例会删改种子构造重叠/相邻场景,恢复保证跨文件幂等
|
||||
"""
|
||||
|
||||
import os
|
||||
import uuid
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
@@ -81,7 +82,12 @@ def _purge_test_data() -> None:
|
||||
``t\\_%`` 中的下划线做了转义 —— 不转义时 `_` 是 LIKE 的单字符通配,
|
||||
会把 tn... 这类无关账号一并卷入。
|
||||
"""
|
||||
from app.core.config import get_settings
|
||||
from tests.isolation import ensure_test_database
|
||||
|
||||
ensure_test_database(get_settings().mysql_database, os.environ.get("PAYROLL_TEST_DATABASE"))
|
||||
factory = get_session_factory()
|
||||
ensure_test_database(factory.kw["bind"].url.database, os.environ.get("PAYROLL_TEST_DATABASE"))
|
||||
with factory() as session:
|
||||
users = list(
|
||||
session.execute(select(User.id, User.username).where(User.username.like("t\\_%"))).all()
|
||||
@@ -104,6 +110,11 @@ def _purge_test_data() -> None:
|
||||
# ---- M3-core:核算台账 / 月结 / 调整单(必须先于回款/业务/人员删除——
|
||||
# ledger.receipt_id/personnel_id/settlement_id 外键 RESTRICT 会挡住后面
|
||||
# 的单据与人员清理;仅测试写入,全量清)----
|
||||
from app.models import PayrollAllocation, PayrollInput, PayrollMonth, TPlusAutomationState
|
||||
session.execute(delete(TPlusAutomationState))
|
||||
session.execute(delete(PayrollAllocation))
|
||||
session.execute(delete(PayrollInput))
|
||||
session.execute(delete(PayrollMonth))
|
||||
session.execute(delete(PerformanceLedger))
|
||||
session.execute(delete(SettlementItem))
|
||||
session.execute(delete(MonthlySettlement))
|
||||
@@ -216,6 +227,11 @@ def _purge_test_data() -> None:
|
||||
@pytest.fixture(autouse=True)
|
||||
def _purge_after_each_test():
|
||||
"""每个集成用例结束后兜底清理,避免残留影响后续用例的计数断言。"""
|
||||
from app.core.config import get_settings
|
||||
from tests.isolation import ensure_test_database
|
||||
|
||||
ensure_test_database(get_settings().mysql_database, os.environ.get("PAYROLL_TEST_DATABASE"))
|
||||
ensure_test_database(get_session_factory().kw["bind"].url.database, os.environ.get("PAYROLL_TEST_DATABASE"))
|
||||
yield
|
||||
_purge_test_data()
|
||||
|
||||
@@ -311,9 +327,11 @@ def make_role():
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def admin(make_user, auth_headers) -> dict:
|
||||
def admin(make_user, auth_headers, monkeypatch) -> dict:
|
||||
"""管理员账号(含请求头)。"""
|
||||
user = make_user([ROLE_ADMIN])
|
||||
from app.core.config import get_settings
|
||||
monkeypatch.setattr(get_settings(), "payroll_maker_user_id", user["id"])
|
||||
user["headers"] = auth_headers(user)
|
||||
return user
|
||||
|
||||
@@ -357,3 +375,18 @@ def tplus_tasks(monkeypatch):
|
||||
db.execute(delete(TPlusSyncError).where(TPlusSyncError.batch_no.in_(numbers)))
|
||||
db.execute(delete(TPlusSyncBatch).where(TPlusSyncBatch.batch_no.in_(numbers)))
|
||||
db.commit()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def legacy_calculation(monkeypatch):
|
||||
"""明确启用旧引擎兼容回归;生产和正式工资测试默认关闭。"""
|
||||
from app.core.config import get_settings
|
||||
monkeypatch.setattr(get_settings(), "payroll_allow_legacy_calculation", True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def writer(normal, admin, monkeypatch):
|
||||
"""指定一个普通角色账号为工资制作人,证明身份授权与管理员角色无关。"""
|
||||
from app.core.config import get_settings
|
||||
monkeypatch.setattr(get_settings(), "payroll_maker_user_id", normal["id"])
|
||||
return normal
|
||||
@@ -22,7 +22,7 @@ from fastapi.testclient import TestClient
|
||||
|
||||
from tests.integration.factories import business_payload, customer
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("legacy_calculation")]
|
||||
|
||||
_BUSINESSES = "/api/v1/business/businesses"
|
||||
_APPROVALS = "/api/v1/system/approvals"
|
||||
|
||||
@@ -33,7 +33,7 @@ from tests.integration.test_approval_flow import (
|
||||
_set_switch,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("legacy_calculation")]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
@@ -91,11 +91,11 @@ def _log_count(username: str, *, success: bool) -> int:
|
||||
return int(session.execute(stmt).scalar_one())
|
||||
|
||||
|
||||
def _baseline(normal: dict) -> tuple[int, int]:
|
||||
def _baseline(writer: dict) -> tuple[int, int]:
|
||||
"""POST 前的「创建业务」成功/失败日志条数基线(免受既有数据干扰)。"""
|
||||
return (
|
||||
_log_count(normal["username"], success=True),
|
||||
_log_count(normal["username"], success=False),
|
||||
_log_count(writer["username"], success=True),
|
||||
_log_count(writer["username"], success=False),
|
||||
)
|
||||
|
||||
|
||||
@@ -103,12 +103,12 @@ class TestAuditRetry:
|
||||
"""重试成功与持续失败两条路径的审计记录数。"""
|
||||
|
||||
def test_首次撞号重试成功_审计恰一条真成功(
|
||||
self, quiet_client: TestClient, normal: dict, monkeypatch
|
||||
self, quiet_client: TestClient, writer: dict, monkeypatch
|
||||
) -> None:
|
||||
client = quiet_client
|
||||
customer_id = _customer(client, normal["headers"])
|
||||
customer_id = _customer(client, writer["headers"])
|
||||
first = client.post(
|
||||
_BUSINESSES, json=_business_payload(customer_id), headers=normal["headers"]
|
||||
_BUSINESSES, json=_business_payload(customer_id), headers=writer["headers"]
|
||||
)
|
||||
assert first.status_code == 200, first.text
|
||||
existing_no = first.json()["data"]["business_no"]
|
||||
@@ -123,25 +123,25 @@ class TestAuditRetry:
|
||||
return real_generate(session)
|
||||
|
||||
monkeypatch.setattr(business_service, "_generate_no", flaky_generate)
|
||||
base_ok, base_fail = _baseline(normal)
|
||||
base_ok, base_fail = _baseline(writer)
|
||||
|
||||
second = client.post(
|
||||
_BUSINESSES, json=_business_payload(customer_id), headers=normal["headers"]
|
||||
_BUSINESSES, json=_business_payload(customer_id), headers=writer["headers"]
|
||||
)
|
||||
assert second.status_code == 200, second.text
|
||||
assert calls["n"] == 2, "重试应恰好再取一次号"
|
||||
# 修复前的症状:外层审计正常退出记 success=True(假成功),重试成功
|
||||
# 又记一条 —— 成功日志增量为 2;修复后成功恰 +1、失败 +0
|
||||
assert _log_count(normal["username"], success=True) == base_ok + 1
|
||||
assert _log_count(normal["username"], success=False) == base_fail
|
||||
assert _log_count(writer["username"], success=True) == base_ok + 1
|
||||
assert _log_count(writer["username"], success=False) == base_fail
|
||||
|
||||
def test_持续冲突_最终失败且审计恰一条失败(
|
||||
self, quiet_client: TestClient, normal: dict, monkeypatch
|
||||
self, quiet_client: TestClient, writer: dict, monkeypatch
|
||||
) -> None:
|
||||
client = quiet_client
|
||||
customer_id = _customer(client, normal["headers"])
|
||||
customer_id = _customer(client, writer["headers"])
|
||||
first = client.post(
|
||||
_BUSINESSES, json=_business_payload(customer_id), headers=normal["headers"]
|
||||
_BUSINESSES, json=_business_payload(customer_id), headers=writer["headers"]
|
||||
)
|
||||
assert first.status_code == 200, first.text
|
||||
existing_no = first.json()["data"]["business_no"]
|
||||
@@ -150,10 +150,10 @@ class TestAuditRetry:
|
||||
return existing_no
|
||||
|
||||
monkeypatch.setattr(business_service, "_generate_no", always_conflict)
|
||||
base_ok, base_fail = _baseline(normal)
|
||||
base_ok, base_fail = _baseline(writer)
|
||||
|
||||
second = client.post(
|
||||
_BUSINESSES, json=_business_payload(customer_id), headers=normal["headers"]
|
||||
_BUSINESSES, json=_business_payload(customer_id), headers=writer["headers"]
|
||||
)
|
||||
# 未捕获 IntegrityError 由全局 handler 收敛 400(数据完整性冲突)——
|
||||
# 完整性冲突对用户是「操作被拒绝」而非「服务端异常」,400 更贴近事实
|
||||
@@ -161,8 +161,8 @@ class TestAuditRetry:
|
||||
assert "完整性冲突" in second.json()["message"]
|
||||
# 修复前的症状:return _retry_create 无界递归直至 RecursionError,
|
||||
# 每层各写一条记录;修复后重试有上限,最终失败仅一条失败日志
|
||||
assert _log_count(normal["username"], success=False) == base_fail + 1
|
||||
assert _log_count(normal["username"], success=True) == base_ok
|
||||
assert _log_count(writer["username"], success=False) == base_fail + 1
|
||||
assert _log_count(writer["username"], success=True) == base_ok
|
||||
|
||||
|
||||
def _receipt_log_count(username: str, *, success: bool) -> int:
|
||||
@@ -199,12 +199,12 @@ class TestReceiptAuditRetry:
|
||||
"""
|
||||
|
||||
def test_首次撞号重试成功_审计恰一条真成功(
|
||||
self, quiet_client: TestClient, normal: dict, monkeypatch
|
||||
self, quiet_client: TestClient, writer: dict, monkeypatch
|
||||
) -> None:
|
||||
client = quiet_client
|
||||
business = create_business(client, normal["headers"])
|
||||
business = create_business(client, writer["headers"])
|
||||
first = client.post(
|
||||
_RECEIPTS, json=_receipt_payload(business["id"]), headers=normal["headers"]
|
||||
_RECEIPTS, json=_receipt_payload(business["id"]), headers=writer["headers"]
|
||||
)
|
||||
assert first.status_code == 200, first.text
|
||||
existing_no = first.json()["data"]["receipt_batch_no"]
|
||||
@@ -219,24 +219,24 @@ class TestReceiptAuditRetry:
|
||||
return real_generate(session)
|
||||
|
||||
monkeypatch.setattr(receipt_service, "_generate_no", flaky_generate)
|
||||
base_ok = _receipt_log_count(normal["username"], success=True)
|
||||
base_fail = _receipt_log_count(normal["username"], success=False)
|
||||
base_ok = _receipt_log_count(writer["username"], success=True)
|
||||
base_fail = _receipt_log_count(writer["username"], success=False)
|
||||
|
||||
second = client.post(
|
||||
_RECEIPTS, json=_receipt_payload(business["id"]), headers=normal["headers"]
|
||||
_RECEIPTS, json=_receipt_payload(business["id"]), headers=writer["headers"]
|
||||
)
|
||||
assert second.status_code == 200, second.text
|
||||
assert calls["n"] == 2, "重试应恰好再取一次号"
|
||||
assert _receipt_log_count(normal["username"], success=True) == base_ok + 1
|
||||
assert _receipt_log_count(normal["username"], success=False) == base_fail
|
||||
assert _receipt_log_count(writer["username"], success=True) == base_ok + 1
|
||||
assert _receipt_log_count(writer["username"], success=False) == base_fail
|
||||
|
||||
def test_持续冲突_最终失败且审计恰一条失败(
|
||||
self, quiet_client: TestClient, normal: dict, monkeypatch
|
||||
self, quiet_client: TestClient, writer: dict, monkeypatch
|
||||
) -> None:
|
||||
client = quiet_client
|
||||
business = create_business(client, normal["headers"])
|
||||
business = create_business(client, writer["headers"])
|
||||
first = client.post(
|
||||
_RECEIPTS, json=_receipt_payload(business["id"]), headers=normal["headers"]
|
||||
_RECEIPTS, json=_receipt_payload(business["id"]), headers=writer["headers"]
|
||||
)
|
||||
assert first.status_code == 200, first.text
|
||||
existing_no = first.json()["data"]["receipt_batch_no"]
|
||||
@@ -245,12 +245,12 @@ class TestReceiptAuditRetry:
|
||||
return existing_no
|
||||
|
||||
monkeypatch.setattr(receipt_service, "_generate_no", always_conflict)
|
||||
base_ok = _receipt_log_count(normal["username"], success=True)
|
||||
base_fail = _receipt_log_count(normal["username"], success=False)
|
||||
base_ok = _receipt_log_count(writer["username"], success=True)
|
||||
base_fail = _receipt_log_count(writer["username"], success=False)
|
||||
|
||||
second = client.post(
|
||||
_RECEIPTS, json=_receipt_payload(business["id"]), headers=normal["headers"]
|
||||
_RECEIPTS, json=_receipt_payload(business["id"]), headers=writer["headers"]
|
||||
)
|
||||
assert second.status_code == 400, second.text
|
||||
assert _receipt_log_count(normal["username"], success=False) == base_fail + 1
|
||||
assert _receipt_log_count(normal["username"], success=True) == base_ok
|
||||
assert _receipt_log_count(writer["username"], success=False) == base_fail + 1
|
||||
assert _receipt_log_count(writer["username"], success=True) == base_ok
|
||||
@@ -43,7 +43,7 @@ from app.models import (
|
||||
from app.models.business import BUSINESS_STATUS_ACTIVE
|
||||
from app.models.customer import OWNER_TYPE_BUSINESS
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("legacy_calculation")]
|
||||
|
||||
_BUSINESSES = "/api/v1/business/businesses"
|
||||
_PERSONNEL = "/api/v1/base/personnel"
|
||||
@@ -199,8 +199,8 @@ class TestCreate:
|
||||
"""创建:编号契约、行校验与销售额固化 —— 编号序号错乱或行金额经前端
|
||||
传值,列表检索与 M3 取数都会错乱。"""
|
||||
|
||||
def test_创建业务_编号格式与当日序号递增(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
def test_创建业务_编号格式与当日序号递增(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
customer = _customer(client, headers)
|
||||
first = _create(client, headers, customer["id"])
|
||||
second = _create(client, headers, customer["id"])
|
||||
@@ -218,18 +218,18 @@ class TestCreate:
|
||||
assert first["status"] == BUSINESS_STATUS_ACTIVE
|
||||
assert first["customer_name"] == customer["company_name"]
|
||||
|
||||
def test_空明细_400(self, client: TestClient, normal: dict) -> None:
|
||||
customer = _customer(client, normal["headers"])
|
||||
def test_空明细_400(self, client: TestClient, writer: dict) -> None:
|
||||
customer = _customer(client, writer["headers"])
|
||||
resp = client.post(
|
||||
_BUSINESSES, json=_payload(customer["id"], items=[]), headers=normal["headers"]
|
||||
_BUSINESSES, json=_payload(customer["id"], items=[]), headers=writer["headers"]
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
|
||||
def test_行金额乘积显式两位固化_回显与库值一致(self, client: TestClient, normal: dict) -> None:
|
||||
def test_行金额乘积显式两位固化_回显与库值一致(self, client: TestClient, writer: dict) -> None:
|
||||
"""数量(2位)×单价(4位) 可产 6 位小数:显式 HALF_UP 固化,与 MySQL 的
|
||||
DECIMAL(14,2) 舍入方向一致 —— 直写不 quantize 会被库端静默舍入,
|
||||
创建响应(读内存值)与复查(读库值)前后显示不一致。"""
|
||||
headers = normal["headers"]
|
||||
headers = writer["headers"]
|
||||
customer = _customer(client, headers)
|
||||
data = _create(
|
||||
client,
|
||||
@@ -250,43 +250,43 @@ class TestCreate:
|
||||
).scalar_one()
|
||||
assert db_amount == Decimal("5.17")
|
||||
|
||||
def test_明细行数量为0_400(self, client: TestClient, normal: dict) -> None:
|
||||
customer = _customer(client, normal["headers"])
|
||||
def test_明细行数量为0_400(self, client: TestClient, writer: dict) -> None:
|
||||
customer = _customer(client, writer["headers"])
|
||||
resp = client.post(
|
||||
_BUSINESSES,
|
||||
json=_payload(customer["id"], items=[_item(quantity="0")]),
|
||||
headers=normal["headers"],
|
||||
headers=writer["headers"],
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "数量" in resp.json()["message"], resp.text
|
||||
|
||||
def test_非法产品类别_400(self, client: TestClient, normal: dict) -> None:
|
||||
def test_非法产品类别_400(self, client: TestClient, writer: dict) -> None:
|
||||
"""类别是折算阈值的行级锚点:脏类别取不到阈值参数。"""
|
||||
customer = _customer(client, normal["headers"])
|
||||
customer = _customer(client, writer["headers"])
|
||||
resp = client.post(
|
||||
_BUSINESSES,
|
||||
json=_payload(customer["id"], items=[_item(product_category="water")]),
|
||||
headers=normal["headers"],
|
||||
headers=writer["headers"],
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "产品类别不合法" in resp.json()["message"], resp.text
|
||||
|
||||
def test_销售单价非数字_400(self, client: TestClient, normal: dict) -> None:
|
||||
customer = _customer(client, normal["headers"])
|
||||
def test_销售单价非数字_400(self, client: TestClient, writer: dict) -> None:
|
||||
customer = _customer(client, writer["headers"])
|
||||
resp = client.post(
|
||||
_BUSINESSES,
|
||||
json=_payload(customer["id"], items=[_item(sale_price="abc")]),
|
||||
headers=normal["headers"],
|
||||
headers=writer["headers"],
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "须为有效数字" in resp.json()["message"], resp.text
|
||||
|
||||
def test_销售额由服务层固化_入参sale_amount不生效(
|
||||
self, client: TestClient, normal: dict
|
||||
self, client: TestClient, writer: dict
|
||||
) -> None:
|
||||
"""schema 无 sale_amount 入参,塞了也必须被忽略 —— 行金额只能由
|
||||
数量×单价计算,否则前端传值直接改业务销售额。"""
|
||||
headers = normal["headers"]
|
||||
headers = writer["headers"]
|
||||
customer = _customer(client, headers)
|
||||
item = _item(quantity="3", sale_price="12.34", sale_amount="88888.00")
|
||||
data = _create(client, headers, customer["id"], items=[item])
|
||||
@@ -295,18 +295,18 @@ class TestCreate:
|
||||
assert Decimal(detail["items"][0]["sale_amount"]) == Decimal("37.02")
|
||||
assert Decimal(detail["total_sale_amount"]) == Decimal("37.02")
|
||||
|
||||
def test_特殊业务缺说明_400(self, client: TestClient, normal: dict) -> None:
|
||||
customer = _customer(client, normal["headers"])
|
||||
def test_特殊业务缺说明_400(self, client: TestClient, writer: dict) -> None:
|
||||
customer = _customer(client, writer["headers"])
|
||||
resp = client.post(
|
||||
_BUSINESSES,
|
||||
json=_payload(customer["id"], is_special=1),
|
||||
headers=normal["headers"],
|
||||
headers=writer["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"]
|
||||
def test_特殊业务有说明创建成功_回显说明(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
customer = _customer(client, headers)
|
||||
data = _create(client, headers, customer["id"], is_special=1, special_note="公司参与结算")
|
||||
assert data["is_special"] == 1
|
||||
@@ -317,8 +317,8 @@ class TestEdit:
|
||||
"""编辑:全量替换明细并单点重算汇总 —— 三处冗余(行 SUM / 业务冗余列 /
|
||||
数据库 SUM)不一致就说明重算有旁路。"""
|
||||
|
||||
def test_编辑全量替换明细_三处汇总一致(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
def test_编辑全量替换明细_三处汇总一致(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
customer = _customer(client, headers)
|
||||
data = _create(
|
||||
client,
|
||||
@@ -365,10 +365,10 @@ class TestEdit:
|
||||
assert sum(db_sale) == db_business.total_sale_amount == Decimal("10000.00")
|
||||
|
||||
def test_编辑端点塞owner不生效_归属唯一走ownership(
|
||||
self, client: TestClient, normal: dict
|
||||
self, client: TestClient, writer: dict
|
||||
) -> None:
|
||||
"""归属刻意不出编辑端点:否则「改归属无留痕」的旁路破坏 append-only。"""
|
||||
headers = normal["headers"]
|
||||
headers = writer["headers"]
|
||||
customer = _customer(client, headers)
|
||||
owner = _personnel(client, headers)
|
||||
someone = _personnel(client, headers)
|
||||
@@ -387,10 +387,10 @@ class TestEdit:
|
||||
]
|
||||
assert logs["total"] == 0, "编辑端点改归属却无留痕"
|
||||
|
||||
def test_终态业务编辑_400且明细未动(self, client: TestClient, normal: dict) -> None:
|
||||
def test_终态业务编辑_400且明细未动(self, client: TestClient, writer: dict) -> None:
|
||||
"""终态门槛与发货/回款写准入同口径:明细/汇总是 M3 核算基线,终态
|
||||
业务禁改 —— 父单据若放行,发货/回款服务「禁写终态」的防线就被绕过。"""
|
||||
headers = normal["headers"]
|
||||
headers = writer["headers"]
|
||||
customer = _customer(client, headers)
|
||||
data = _create(client, headers, customer["id"])
|
||||
_terminate(client, headers, data["id"])
|
||||
@@ -412,10 +412,10 @@ class TestEdit:
|
||||
assert len(detail["items"]) == 1
|
||||
assert Decimal(detail["items"][0]["quantity"]) == Decimal("100")
|
||||
|
||||
def test_二次分配方案合计超100_保存400(self, client: TestClient, normal: dict) -> None:
|
||||
def test_二次分配方案合计超100_保存400(self, client: TestClient, writer: dict) -> None:
|
||||
"""口径(澄清 #1/#2 答复 2026-10-19):分配绩效、余额归合同负责人——
|
||||
超 100% 会把负责人份额算成负数,保存侧先拦(核算侧另有防御)。"""
|
||||
headers = normal["headers"]
|
||||
headers = writer["headers"]
|
||||
customer = _customer(client, headers)
|
||||
owner = _personnel(client, headers)
|
||||
part = _personnel(client, headers)
|
||||
@@ -435,8 +435,8 @@ class TestEdit:
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "超过 100%" in resp.json()["message"]
|
||||
|
||||
def test_二次分配参与人含负责人_保存400(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
def test_二次分配参与人含负责人_保存400(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
customer = _customer(client, headers)
|
||||
owner = _personnel(client, headers)
|
||||
data = _create(client, headers, customer["id"], owner_personnel_id=owner["id"])
|
||||
@@ -452,8 +452,8 @@ class TestEdit:
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "含合同负责营销人员" in resp.json()["message"]
|
||||
|
||||
def test_二次分配方案合法比例_保存成功且回显(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
def test_二次分配方案合法比例_保存成功且回显(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
customer = _customer(client, headers)
|
||||
owner = _personnel(client, headers)
|
||||
part = _personnel(client, headers)
|
||||
@@ -479,8 +479,8 @@ class TestEdit:
|
||||
class TestStatus:
|
||||
"""状态机:终态不可逆是终态单据禁写的前提;留痕从创建首条记起。"""
|
||||
|
||||
def test_终止留痕_终态禁再变更(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
def test_终止留痕_终态禁再变更(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
customer = _customer(client, headers)
|
||||
data = _create(client, headers, customer["id"])
|
||||
|
||||
@@ -514,14 +514,14 @@ class TestStatus:
|
||||
assert (rows[0]["old_status"], rows[0]["new_status"]) == (1, 1)
|
||||
assert (rows[1]["old_status"], rows[1]["new_status"]) == (1, 3)
|
||||
assert rows[1]["change_reason"] == "客户取消订单"
|
||||
assert rows[1]["operator_name"] == normal["username"]
|
||||
assert rows[1]["operator_name"] == writer["username"]
|
||||
|
||||
|
||||
class TestOwnership:
|
||||
"""归属变更:主表更新 + 留痕同事务;两者全相同则属无效操作。"""
|
||||
|
||||
def test_归属变更_主表更新与留痕同生(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
def test_归属变更_主表更新与留痕同生(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
customer = _customer(client, headers)
|
||||
old_owner = _personnel(client, headers)
|
||||
data = _create(client, headers, customer["id"], owner_personnel_id=old_owner["id"])
|
||||
@@ -555,7 +555,7 @@ class TestOwnership:
|
||||
assert row["old_owner_name"] == old_owner["personnel_name"]
|
||||
assert row["new_owner_name"] == new_owner["personnel_name"]
|
||||
assert row["change_reason"] == "人员调动"
|
||||
assert row["operator_name"] == normal["username"]
|
||||
assert row["operator_name"] == writer["username"]
|
||||
|
||||
# owner_type 由 DB 固化断言(响应 DTO 不含该列;2=业务归属)
|
||||
factory = get_session_factory()
|
||||
@@ -567,8 +567,8 @@ class TestOwnership:
|
||||
f"owner_type 应为业务归属(2):{log.owner_type}"
|
||||
)
|
||||
|
||||
def test_归属未变化_400(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
def test_归属未变化_400(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
customer = _customer(client, headers)
|
||||
owner = _personnel(client, headers)
|
||||
data = _create(client, headers, customer["id"], owner_personnel_id=owner["id"])
|
||||
@@ -580,8 +580,8 @@ class TestOwnership:
|
||||
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"]
|
||||
def test_创建时负责人员已离职_400(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
quit_owner = _personnel(client, headers)
|
||||
off = client.put(
|
||||
f"{_PERSONNEL}/{quit_owner['id']}/status", json={"status": 0}, headers=headers
|
||||
@@ -596,8 +596,8 @@ class TestOwnership:
|
||||
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"]
|
||||
def test_归属端点负责人员不存在_400(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
customer = _customer(client, headers)
|
||||
data = _create(client, headers, customer["id"])
|
||||
resp = client.put(
|
||||
@@ -613,8 +613,8 @@ class TestCustomerProtect:
|
||||
"""客户引用保护:发货/回款单据 FK 挂在业务上,换客户或删除会让单据
|
||||
语义断裂 —— 服务层检查是唯一防线。"""
|
||||
|
||||
def test_有发货或回款时换客户_400(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
def test_有发货或回款时换客户_400(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
customer = _customer(client, headers)
|
||||
other = _customer(client, headers)
|
||||
data = _create(client, headers, customer["id"])
|
||||
@@ -658,8 +658,8 @@ class TestCustomerProtect:
|
||||
assert blocked_receipt.status_code == 400, blocked_receipt.text
|
||||
assert "已有发货或回款记录" in blocked_receipt.json()["message"], blocked_receipt.text
|
||||
|
||||
def test_有发货或回款禁删_400(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
def test_有发货或回款禁删_400(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
customer = _customer(client, headers)
|
||||
data = _create(client, headers, customer["id"])
|
||||
|
||||
@@ -693,8 +693,8 @@ class TestCustomerProtect:
|
||||
assert blocked_receipt.status_code == 400, blocked_receipt.text
|
||||
assert "请改为已终止而非删除" in blocked_receipt.json()["message"], blocked_receipt.text
|
||||
|
||||
def test_空业务删除成功_连带清理明细与留痕(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
def test_空业务删除成功_连带清理明细与留痕(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
customer = _customer(client, headers)
|
||||
owner = _personnel(client, headers)
|
||||
new_owner = _personnel(client, headers)
|
||||
@@ -745,10 +745,10 @@ class TestGrossProfit:
|
||||
"""毛利润数据视图(只读派生):锁定金标准口径 —— 率用精确分数、
|
||||
折算用化简式(率 0 / 销售额 0 防除零)、成本缺失行不进类别汇总。"""
|
||||
|
||||
def test_毛利润基准值_率与折算锁定精确分数(self, client: TestClient, normal: dict) -> None:
|
||||
def test_毛利润基准值_率与折算锁定精确分数(self, client: TestClient, writer: dict) -> None:
|
||||
"""销售 10000 / 成本 30 → 利润 9970、率 997/1000、市政阈值 30% →
|
||||
折算 10000 ×(997/1000 − 30/100) = 6970。"""
|
||||
headers = normal["headers"]
|
||||
headers = writer["headers"]
|
||||
customer = _customer(client, headers)
|
||||
data = _create(
|
||||
client,
|
||||
@@ -778,10 +778,10 @@ class TestGrossProfit:
|
||||
assert cats[0]["profit_rate"] == "997/1000"
|
||||
assert Decimal(cats[0]["converted_profit"]) == Decimal("6970")
|
||||
|
||||
def test_毛利润_销售额0_全0不抛错_防除零(self, client: TestClient, normal: dict) -> None:
|
||||
def test_毛利润_销售额0_全0不抛错_防除零(self, client: TestClient, writer: dict) -> None:
|
||||
"""单价空 → 行销售额 0:率分母为 0 时必须返回 0 而非抛除零异常,
|
||||
否则录入期(补价前)打开毛利润视图就是 500。"""
|
||||
headers = normal["headers"]
|
||||
headers = writer["headers"]
|
||||
customer = _customer(client, headers)
|
||||
data = _create(
|
||||
client,
|
||||
@@ -805,9 +805,9 @@ class TestGrossProfit:
|
||||
assert Decimal(cats[0]["converted_profit"]) == Decimal("0")
|
||||
|
||||
def test_毛利润_成本缺失行_派生为空不计入类别汇总(
|
||||
self, client: TestClient, normal: dict
|
||||
self, client: TestClient, writer: dict
|
||||
) -> None:
|
||||
headers = normal["headers"]
|
||||
headers = writer["headers"]
|
||||
customer = _customer(client, headers)
|
||||
data = _create(
|
||||
client,
|
||||
@@ -854,11 +854,11 @@ class TestGrossProfit:
|
||||
assert Decimal(cats[0]["converted_profit"]) == Decimal("3400")
|
||||
|
||||
def test_毛利润asOf按业务日期取参数时点值_旧日期取旧值(
|
||||
self, client: TestClient, normal: dict, threshold_batch
|
||||
self, client: TestClient, writer: dict, threshold_batch
|
||||
) -> None:
|
||||
"""改参数后按旧业务日期取值必须回溯旧阈值 —— 落库即漂移的「假快照」
|
||||
靠 asOf 回溯参数快照,M3 台账才是法定固化点。"""
|
||||
headers = normal["headers"]
|
||||
headers = writer["headers"]
|
||||
customer = _customer(client, headers)
|
||||
data = _create(
|
||||
client,
|
||||
@@ -893,13 +893,13 @@ class TestGrossProfit:
|
||||
class TestAudit:
|
||||
"""写操作(含校验失败的)都在 operation_logs 留痕 —— 审计是验收项。"""
|
||||
|
||||
def test_新增业务成功留审计(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
def test_新增业务成功留审计(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
customer = _customer(client, headers)
|
||||
_create(client, headers, customer["id"])
|
||||
logs = [
|
||||
log
|
||||
for log in _logs_of(normal["username"])
|
||||
for log in _logs_of(writer["username"])
|
||||
if log.module == MODULE_BUSINESS and log.action == ACTION_CREATE
|
||||
]
|
||||
assert logs, "新增业务未留操作日志"
|
||||
@@ -907,17 +907,17 @@ class TestAudit:
|
||||
assert latest.success == 1
|
||||
assert "创建业务" in latest.summary
|
||||
|
||||
def test_归属人员不存在失败留审计(self, client: TestClient, normal: dict) -> None:
|
||||
customer = _customer(client, normal["headers"])
|
||||
def test_归属人员不存在失败留审计(self, client: TestClient, writer: dict) -> None:
|
||||
customer = _customer(client, writer["headers"])
|
||||
resp = client.post(
|
||||
_BUSINESSES,
|
||||
json=_payload(customer["id"], owner_personnel_id=999999999),
|
||||
headers=normal["headers"],
|
||||
headers=writer["headers"],
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
failed = [
|
||||
log
|
||||
for log in _logs_of(normal["username"])
|
||||
for log in _logs_of(writer["username"])
|
||||
if log.module == MODULE_BUSINESS and log.success == 0
|
||||
]
|
||||
assert failed, "失败操作未留痕"
|
||||
|
||||
@@ -26,7 +26,7 @@ from app.models import PerformanceLedger, Personnel, Receipt, SecondaryAllocatio
|
||||
from app.models.receipt import SETTLEMENT_DONE
|
||||
from tests.integration.factories import business_payload, customer
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("legacy_calculation")]
|
||||
|
||||
_CALC = "/api/v1/calc"
|
||||
_BUSINESSES = "/api/v1/business/businesses"
|
||||
@@ -338,8 +338,8 @@ class TestGuards:
|
||||
assert float(ledger["converted_profit"]) == -2000.0
|
||||
assert float(ledger["performance_amount"]) < 0
|
||||
|
||||
def test_准入_normal403_marketing_ops可用(
|
||||
self, client: TestClient, admin: dict, normal: dict, make_user, auth_headers
|
||||
def test_准入_未指定账号拒绝_指定制作人可用(
|
||||
self, client: TestClient, admin: dict, normal: dict, make_user, auth_headers, monkeypatch
|
||||
) -> None:
|
||||
_biz, receipt = _create_full_chain(
|
||||
client,
|
||||
@@ -354,6 +354,9 @@ class TestGuards:
|
||||
|
||||
ops = make_user(["marketing_ops"])
|
||||
ops["headers"] = auth_headers(ops)
|
||||
from app.core.config import get_settings
|
||||
assert _calc(client, ops["headers"], receipt["id"]).status_code == 403
|
||||
monkeypatch.setattr(get_settings(), "payroll_maker_user_id", ops["id"])
|
||||
resp = _calc(client, ops["headers"], receipt["id"])
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
@@ -426,7 +429,8 @@ class TestRecalc:
|
||||
)
|
||||
assert _calc(client, admin["headers"], receipt["id"]).status_code == 200
|
||||
|
||||
fin = finance
|
||||
assert client.post(f"{_CALC}/receipts/{receipt['id']}/recalc", json={"reason": "非制作人拒绝"}, headers=finance["headers"]).status_code == 403
|
||||
fin = admin
|
||||
resp = client.post(
|
||||
f"{_CALC}/receipts/{receipt['id']}/recalc",
|
||||
json={"reason": "成本录入修正"},
|
||||
@@ -726,7 +730,7 @@ class TestSecondaryAllocation:
|
||||
resp = client.post(
|
||||
f"{_CALC}/receipts/{receipt['id']}/recalc",
|
||||
json={"reason": "用例:分摊重算"},
|
||||
headers=finance["headers"],
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
|
||||
@@ -122,38 +122,38 @@ class TestAuth:
|
||||
class TestCrudFlow:
|
||||
"""正向链路与组合唯一 ——「公司名称+负责人」是业务唯一标识。"""
|
||||
|
||||
def test_新增客户带初始归属_回显归属人员姓名(self, client: TestClient, normal: dict) -> None:
|
||||
owner = _personnel(client, normal["headers"])
|
||||
data = _create_customer(client, normal["headers"], owner_personnel_id=owner["id"])
|
||||
def test_新增客户带初始归属_回显归属人员姓名(self, client: TestClient, writer: dict) -> None:
|
||||
owner = _personnel(client, writer["headers"])
|
||||
data = _create_customer(client, writer["headers"], owner_personnel_id=owner["id"])
|
||||
assert data["owner_personnel_id"] == owner["id"]
|
||||
assert data["owner_personnel_name"] == owner["personnel_name"]
|
||||
assert data["customer_type"] == CUSTOMER_TYPE_PERSONAL
|
||||
|
||||
def test_同公司名不同负责人创建成功(self, client: TestClient, normal: dict) -> None:
|
||||
def test_同公司名不同负责人创建成功(self, client: TestClient, writer: dict) -> None:
|
||||
company = _company()
|
||||
first = _create_customer(client, normal["headers"], company_name=company)
|
||||
first = _create_customer(client, writer["headers"], company_name=company)
|
||||
second = client.post(
|
||||
_CUSTOMERS,
|
||||
json=_cpayload(company, owner_name="李老板"),
|
||||
headers=normal["headers"],
|
||||
headers=writer["headers"],
|
||||
)
|
||||
assert second.status_code == 200, second.text
|
||||
assert second.json()["data"]["id"] != first["id"]
|
||||
|
||||
def test_重名同负责人_400(self, client: TestClient, normal: dict) -> None:
|
||||
def test_重名同负责人_400(self, client: TestClient, writer: dict) -> None:
|
||||
company = _company()
|
||||
_create_customer(client, normal["headers"], company_name=company)
|
||||
_create_customer(client, writer["headers"], company_name=company)
|
||||
dup = client.post(
|
||||
_CUSTOMERS,
|
||||
json=_cpayload(company, owner_name="张老板"),
|
||||
headers=normal["headers"],
|
||||
headers=writer["headers"],
|
||||
)
|
||||
assert dup.status_code == 400, dup.text
|
||||
assert "该公司名称与负责人的客户已存在" in dup.json()["message"]
|
||||
|
||||
def test_删除被业务引用的客户_400引导处理单据(self, client: TestClient, normal: dict) -> None:
|
||||
def test_删除被业务引用的客户_400引导处理单据(self, client: TestClient, writer: dict) -> None:
|
||||
"""业务表就位后删除保护补齐:FK RESTRICT 不能以 500 暴露。"""
|
||||
headers = normal["headers"]
|
||||
headers = writer["headers"]
|
||||
customer = _create_customer(client, headers)
|
||||
resp = client.post(
|
||||
_BUSINESSES,
|
||||
@@ -175,20 +175,20 @@ class TestCrudFlow:
|
||||
assert blocked.status_code == 400, blocked.text
|
||||
assert "已存在关联业务" in blocked.json()["message"]
|
||||
|
||||
def test_客户分类非法_400(self, client: TestClient, normal: dict) -> None:
|
||||
def test_客户分类非法_400(self, client: TestClient, writer: dict) -> None:
|
||||
resp = client.post(
|
||||
_CUSTOMERS,
|
||||
json=_cpayload(_company(), customer_type="not_a_type"),
|
||||
headers=normal["headers"],
|
||||
headers=writer["headers"],
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "客户分类不合法" in resp.json()["message"]
|
||||
|
||||
def test_编辑其他字段不触归属_塞归属字段也被忽略(
|
||||
self, client: TestClient, normal: dict
|
||||
self, client: TestClient, writer: dict
|
||||
) -> None:
|
||||
"""归属只有 /ownership 一条写路径 —— 编辑端点防「改归属不留痕」旁路。"""
|
||||
headers = normal["headers"]
|
||||
headers = writer["headers"]
|
||||
owner = _personnel(client, headers)
|
||||
stranger = _personnel(client, headers)
|
||||
customer = _create_customer(client, headers, owner_personnel_id=owner["id"])
|
||||
@@ -220,8 +220,8 @@ class TestCrudFlow:
|
||||
class TestOwnership:
|
||||
"""归属变更:主表更新与留痕同事务;校验失败不留脏数据。"""
|
||||
|
||||
def test_归属变更_主表更新且留痕新增(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
def test_归属变更_主表更新且留痕新增(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
old = _personnel(client, headers)
|
||||
new = _personnel(client, headers)
|
||||
customer = _create_customer(client, headers, owner_personnel_id=old["id"])
|
||||
@@ -243,10 +243,10 @@ class TestOwnership:
|
||||
assert log.old_owner_personnel_id == int(old["id"])
|
||||
assert log.new_owner_personnel_id == int(new["id"])
|
||||
assert log.change_reason == "区域调整"
|
||||
assert log.operator_name == normal["username"]
|
||||
assert log.operator_name == writer["username"]
|
||||
|
||||
def test_变更后归属人员已离职_400(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
def test_变更后归属人员已离职_400(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
target = _personnel(client, headers)
|
||||
client.put(f"{_PERSONNEL}/{target['id']}/status", json={"status": 0}, headers=headers)
|
||||
customer = _create_customer(client, headers)
|
||||
@@ -259,8 +259,8 @@ class TestOwnership:
|
||||
assert "归属人员不存在或已离职" in resp.json()["message"]
|
||||
assert _latest_log(int(customer["id"])) is None, "失败变更不应留痕"
|
||||
|
||||
def test_新旧归属相同_400(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
def test_新旧归属相同_400(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
owner = _personnel(client, headers)
|
||||
customer = _create_customer(client, headers, owner_personnel_id=owner["id"])
|
||||
resp = client.put(
|
||||
@@ -271,19 +271,19 @@ class TestOwnership:
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "归属未变化" in resp.json()["message"]
|
||||
|
||||
def test_双方均为空_400(self, client: TestClient, normal: dict) -> None:
|
||||
def test_双方均为空_400(self, client: TestClient, writer: dict) -> None:
|
||||
"""无归属客户再清一次归属也属「未变化」——不产生空转留痕。"""
|
||||
customer = _create_customer(client, normal["headers"])
|
||||
customer = _create_customer(client, writer["headers"])
|
||||
resp = client.put(
|
||||
f"{_CUSTOMERS}/{customer['id']}/ownership",
|
||||
json={"new_owner_personnel_id": None},
|
||||
headers=normal["headers"],
|
||||
headers=writer["headers"],
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "归属未变化" in resp.json()["message"]
|
||||
|
||||
def test_变更为空即清空归属_主表与留痕一致(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
def test_变更为空即清空归属_主表与留痕一致(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
owner = _personnel(client, headers)
|
||||
customer = _create_customer(client, headers, owner_personnel_id=owner["id"])
|
||||
resp = client.put(
|
||||
@@ -306,8 +306,8 @@ class TestOwnership:
|
||||
class TestOwnershipLogs:
|
||||
"""留痕分页:新旧归属人姓名由联表带出,倒序可追溯变更轨迹。"""
|
||||
|
||||
def test_留痕分页带新旧归属人姓名并倒序(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
def test_留痕分页带新旧归属人姓名并倒序(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
first = _personnel(client, headers)
|
||||
second = _personnel(client, headers)
|
||||
customer = _create_customer(client, headers, owner_personnel_id=first["id"])
|
||||
@@ -343,11 +343,11 @@ class TestOwnershipLogs:
|
||||
class TestAudit:
|
||||
"""写操作(含归属变更)留痕,摘要要能定位到客户。"""
|
||||
|
||||
def test_新增客户留审计(self, client: TestClient, normal: dict) -> None:
|
||||
data = _create_customer(client, normal["headers"])
|
||||
def test_新增客户留审计(self, client: TestClient, writer: dict) -> None:
|
||||
data = _create_customer(client, writer["headers"])
|
||||
logs = [
|
||||
log
|
||||
for log in _logs_of(normal["username"])
|
||||
for log in _logs_of(writer["username"])
|
||||
if log.module == MODULE_CUSTOMER and log.action == ACTION_CREATE
|
||||
]
|
||||
assert logs, "新增客户未留操作日志"
|
||||
@@ -355,8 +355,8 @@ class TestAudit:
|
||||
assert latest.success == 1
|
||||
assert data["company_name"] in latest.summary
|
||||
|
||||
def test_归属变更审计摘要含客户名(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
def test_归属变更审计摘要含客户名(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
old = _personnel(client, headers)
|
||||
new = _personnel(client, headers)
|
||||
customer = _create_customer(client, headers, owner_personnel_id=old["id"])
|
||||
@@ -369,7 +369,7 @@ class TestAudit:
|
||||
|
||||
logs = [
|
||||
log
|
||||
for log in _logs_of(normal["username"])
|
||||
for log in _logs_of(writer["username"])
|
||||
if log.module == MODULE_CUSTOMER and log.success == 1
|
||||
]
|
||||
assert logs, "归属变更未留操作日志"
|
||||
@@ -387,9 +387,9 @@ class TestRating:
|
||||
"""
|
||||
|
||||
def test_保存评级_回显总分与时间_且编辑端点不含评分(
|
||||
self, client: TestClient, normal: dict
|
||||
self, client: TestClient, writer: dict
|
||||
) -> None:
|
||||
headers = normal["headers"]
|
||||
headers = writer["headers"]
|
||||
customer = _create_customer(client, headers)
|
||||
assert customer["rating_score"] is None, "新客户应无评分"
|
||||
|
||||
@@ -408,8 +408,8 @@ class TestRating:
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["data"]["rating_score"] == 60
|
||||
|
||||
def test_评分越界_400(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
def test_评分越界_400(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
customer = _create_customer(client, headers)
|
||||
resp = client.put(
|
||||
f"{_CUSTOMERS}/{customer['id']}/rating", json={"score": 101}, headers=headers
|
||||
@@ -417,9 +417,9 @@ class TestRating:
|
||||
assert resp.status_code == 400, resp.text
|
||||
|
||||
def test_按评分排序_未评分垫底_升序降序都不压过低分(
|
||||
self, client: TestClient, normal: dict
|
||||
self, client: TestClient, writer: dict
|
||||
) -> None:
|
||||
headers = normal["headers"]
|
||||
headers = writer["headers"]
|
||||
high = _create_customer(client, headers)
|
||||
low = _create_customer(client, headers)
|
||||
unrated = _create_customer(client, headers)
|
||||
|
||||
@@ -17,7 +17,7 @@ from decimal import Decimal
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("legacy_calculation")]
|
||||
|
||||
_DASHBOARD = "/api/v1/dashboard"
|
||||
_PERSONNEL = "/api/v1/base/personnel"
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
"""真实MySQL验证正式核算、工资生成锁、身份权限及冻结报表。"""
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from decimal import Decimal
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app.core.db import get_session_factory
|
||||
from app.models import (
|
||||
AdjustmentOrder,
|
||||
ApprovalOrder,
|
||||
ApprovalStep,
|
||||
BusinessRole,
|
||||
PayrollMonth,
|
||||
PerformanceLedger,
|
||||
Personnel,
|
||||
)
|
||||
from tests.integration.factories import business_payload, customer
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
BASE = "/api/v1/payroll"
|
||||
|
||||
|
||||
def post(client, path, headers, data):
|
||||
response = client.post(path, headers=headers, json=data)
|
||||
assert response.status_code == 200, response.text
|
||||
return response.json()["data"]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def formal_chain(client, admin):
|
||||
headers = admin["headers"]
|
||||
with get_session_factory()() as s:
|
||||
roles = {r.role_code: r.id for r in s.scalars(select(BusinessRole))}
|
||||
persons = []
|
||||
for code in ("sales_manager", "salesperson"):
|
||||
persons.append(
|
||||
post(
|
||||
client,
|
||||
"/api/v1/base/personnel",
|
||||
headers,
|
||||
{
|
||||
"personnel_name": "t_正式工资_" + uuid4().hex[:8],
|
||||
"business_role_id": roles[code],
|
||||
"salary_standard": "0",
|
||||
},
|
||||
)
|
||||
)
|
||||
manager, owner = persons
|
||||
cust = customer(client, headers)
|
||||
item = {
|
||||
"product_name": "t_正式产品",
|
||||
"spec_model": "",
|
||||
"product_category": "municipal_water",
|
||||
"quantity": "2",
|
||||
"sale_price": "50",
|
||||
"guided_price": "25",
|
||||
}
|
||||
business = post(
|
||||
client,
|
||||
"/api/v1/business/businesses",
|
||||
headers,
|
||||
business_payload(cust["id"], owner_personnel_id=owner["id"], items=[item]),
|
||||
)
|
||||
shipment = post(
|
||||
client,
|
||||
"/api/v1/business/shipments",
|
||||
headers,
|
||||
{
|
||||
"business_id": business["id"],
|
||||
"ship_date": "2026-03-10",
|
||||
"freight_amount": "0",
|
||||
"items": [
|
||||
{
|
||||
"product_name": item["product_name"],
|
||||
"spec_model": "",
|
||||
"quantity": "2",
|
||||
"sale_price": "50",
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
detail = client.get(f"/api/v1/business/shipments/{shipment['id']}", headers=headers).json()[
|
||||
"data"
|
||||
]
|
||||
receipt = post(
|
||||
client,
|
||||
"/api/v1/business/receipts",
|
||||
headers,
|
||||
{"business_id": business["id"], "receipt_date": "2026-03-10", "amount": "100"},
|
||||
)
|
||||
setup = {
|
||||
"month": "2026-03",
|
||||
"expected_version": 0,
|
||||
"evidence": "隔离样本已逐笔核实",
|
||||
"opening_balance": "0",
|
||||
"history_confirmed": True,
|
||||
"guidance_finalized": True,
|
||||
"shipment_ownerships": [
|
||||
{
|
||||
"shipment_item_id": detail["items"][0]["id"],
|
||||
"personnel_id": owner["id"],
|
||||
"manager_id": manager["id"],
|
||||
"unit_cost": "25",
|
||||
}
|
||||
],
|
||||
}
|
||||
post(client, f"{BASE}/businesses/{business['id']}/input", headers, setup)
|
||||
return business, receipt, setup, owner, manager
|
||||
|
||||
|
||||
def test_正式核算生成工资锁定及报表一致(client, admin, formal_chain):
|
||||
business, _, setup, owner, manager = formal_chain
|
||||
headers = admin["headers"]
|
||||
result = post(
|
||||
client, f"{BASE}/businesses/{business['id']}/calculate", headers, {"month": "2026-03"}
|
||||
)
|
||||
assert result["performance"] == "0.20" and result["payable"] == "0.20"
|
||||
generated = post(client, "/api/v1/settlements/generate", headers, {"month": "2026-03"})
|
||||
assert generated["status"] == 3 and not generated["approval_required"]
|
||||
with get_session_factory()() as s:
|
||||
assert s.scalar(select(func.count()).select_from(ApprovalOrder)) == 0
|
||||
assert s.scalar(select(func.count()).select_from(ApprovalStep)) == 0
|
||||
assert Decimal(generated["total_payable"]) == Decimal("0.24")
|
||||
assert client.get(BASE + "/months/2026-03", headers=headers).json()["data"]["allocation_locked"]
|
||||
setup["expected_version"] = 1
|
||||
assert (
|
||||
client.post(
|
||||
f"{BASE}/businesses/{business['id']}/input", headers=headers, json=setup
|
||||
).status_code
|
||||
== 400
|
||||
)
|
||||
assert (
|
||||
client.post(
|
||||
f"{BASE}/businesses/{business['id']}/calculate",
|
||||
headers=headers,
|
||||
json={"month": "2026-03", "reason": "不允许绕过生成锁"},
|
||||
).status_code
|
||||
== 400
|
||||
)
|
||||
with get_session_factory()() as s:
|
||||
s.get(Personnel, owner["id"]).manager_id = None
|
||||
s.commit()
|
||||
report = client.get(
|
||||
"/api/v1/reports/department-summary",
|
||||
headers=headers,
|
||||
params={"settlement_month": "2026-03"},
|
||||
)
|
||||
assert report.status_code == 200, report.text
|
||||
data = report.json()["data"]
|
||||
team = next(t for t in data["teams"] if t["manager_id"] == manager["id"])
|
||||
assert Decimal(team["team_base"]) == Decimal("0.20")
|
||||
assert Decimal(data["totals"]["payable_total"]) == Decimal(generated["total_payable"])
|
||||
|
||||
|
||||
def test_非制作人包括其他管理员均不能写(
|
||||
client, admin, normal, make_user, auth_headers, formal_chain
|
||||
):
|
||||
business, _, setup, _, _ = formal_chain
|
||||
other_admin = make_user(["admin"])
|
||||
for headers in (normal["headers"], auth_headers(other_admin)):
|
||||
assert (
|
||||
client.post(
|
||||
f"{BASE}/businesses/{business['id']}/calculate",
|
||||
headers=headers,
|
||||
json={"month": "2026-03"},
|
||||
).status_code
|
||||
== 403
|
||||
)
|
||||
assert (
|
||||
client.post(
|
||||
f"{BASE}/businesses/{business['id']}/input", headers=headers, json=setup
|
||||
).status_code
|
||||
== 403
|
||||
)
|
||||
assert (
|
||||
client.post("/api/v1/business/businesses", headers=headers, json={}).status_code == 403
|
||||
)
|
||||
assert (
|
||||
client.get(
|
||||
f"{BASE}/businesses/{business['id']}/input", headers=normal["headers"]
|
||||
).status_code
|
||||
== 200
|
||||
)
|
||||
|
||||
|
||||
def test_并发核算仅一份正式结果(client, admin, formal_chain):
|
||||
business, _, _, _, _ = formal_chain
|
||||
|
||||
def call():
|
||||
return client.post(
|
||||
f"{BASE}/businesses/{business['id']}/calculate",
|
||||
headers=admin["headers"],
|
||||
json={"month": "2026-03"},
|
||||
).status_code
|
||||
|
||||
with ThreadPoolExecutor(max_workers=2) as pool:
|
||||
statuses = list(pool.map(lambda _: call(), range(2)))
|
||||
assert sorted(statuses) == [200, 400]
|
||||
with get_session_factory()() as s:
|
||||
rows = list(
|
||||
s.scalars(
|
||||
select(PerformanceLedger).where(
|
||||
PerformanceLedger.business_id == business["id"],
|
||||
PerformanceLedger.is_active == 1,
|
||||
)
|
||||
)
|
||||
)
|
||||
assert len(rows) == 1
|
||||
|
||||
|
||||
def test_生成失败不锁定且重新生成不解锁(client, admin, formal_chain):
|
||||
business, _, _, _, _ = formal_chain
|
||||
headers = admin["headers"]
|
||||
assert (
|
||||
client.post(
|
||||
"/api/v1/settlements/generate", headers=headers, json={"month": "2026-03"}
|
||||
).status_code
|
||||
== 400
|
||||
)
|
||||
with get_session_factory()() as s:
|
||||
control = s.scalar(select(PayrollMonth))
|
||||
assert control is None or control.generated_at is None
|
||||
post(client, f"{BASE}/businesses/{business['id']}/calculate", headers, {"month": "2026-03"})
|
||||
post(client, "/api/v1/settlements/generate", headers, {"month": "2026-03"})
|
||||
assert (
|
||||
client.post(
|
||||
"/api/v1/settlements/generate", headers=headers, json={"month": "2026-03"}
|
||||
).status_code
|
||||
== 400
|
||||
)
|
||||
assert client.get(BASE + "/months/2026-03", headers=headers).json()["data"]["allocation_locked"]
|
||||
|
||||
|
||||
def test_保留可选审核_默认关闭_开启后原节点可用(
|
||||
client, admin, formal_chain, make_user, auth_headers, monkeypatch
|
||||
):
|
||||
from app.core.config import get_settings
|
||||
|
||||
monkeypatch.setattr(get_settings(), "payroll_approval_enabled", True)
|
||||
business, _, _, _, _ = formal_chain
|
||||
headers = admin["headers"]
|
||||
post(client, f"{BASE}/businesses/{business['id']}/calculate", headers, {"month": "2026-03"})
|
||||
generated = post(client, "/api/v1/settlements/generate", headers, {"month": "2026-03"})
|
||||
assert generated["status"] == 1 and generated["approval_required"]
|
||||
post(client, f"/api/v1/settlements/{generated['id']}/submit", headers, {})
|
||||
with get_session_factory()() as s:
|
||||
order = s.scalar(
|
||||
select(ApprovalOrder).where(
|
||||
ApprovalOrder.ref_id == generated["id"], ApprovalOrder.order_type == 2
|
||||
)
|
||||
)
|
||||
order_id = order.id
|
||||
finance = make_user(["finance"])
|
||||
leader = make_user(["leader"])
|
||||
first = post(
|
||||
client, f"/api/v1/system/approvals/{order_id}/action", auth_headers(finance), {"action": 1}
|
||||
)
|
||||
assert first["status"] == 2
|
||||
last = post(
|
||||
client, f"/api/v1/system/approvals/{order_id}/action", auth_headers(leader), {"action": 1}
|
||||
)
|
||||
assert last["status"] == 3
|
||||
|
||||
|
||||
def test_分配调整固定当月且关闭审核不留审批痕迹(client, admin, formal_chain):
|
||||
from app.services.payroll_months import current_month
|
||||
|
||||
business, _, _, owner, manager = formal_chain
|
||||
headers = admin["headers"]
|
||||
result = post(
|
||||
client, f"{BASE}/businesses/{business['id']}/calculate", headers, {"month": "2026-03"}
|
||||
)
|
||||
post(client, "/api/v1/settlements/generate", headers, {"month": "2026-03"})
|
||||
adjustment = post(
|
||||
client,
|
||||
BASE + "/allocation-adjustments",
|
||||
headers,
|
||||
{
|
||||
"ledger_id": result["ledger_ids"][0],
|
||||
"personnel_id": owner["id"],
|
||||
"direction": 1,
|
||||
"amount": "0.01",
|
||||
"reason": "逐笔核对差额",
|
||||
},
|
||||
)
|
||||
with get_session_factory()() as s:
|
||||
order = s.get(AdjustmentOrder, adjustment["id"])
|
||||
assert order.status == 2 and order.settlement_month == current_month()
|
||||
assert order.attribution_json["manager_id"] == manager["id"]
|
||||
assert s.scalar(select(func.count()).select_from(ApprovalOrder)) == 0
|
||||
assert s.scalar(select(func.count()).select_from(ApprovalStep)) == 0
|
||||
|
||||
|
||||
def test_无正式资料时默认拒绝旧核算(client, admin, formal_chain):
|
||||
_, _receipt, _, _, _ = formal_chain
|
||||
# 使用没有正式准备的新业务,验证旧批次入口默认关闭,而不是依赖角色拒绝。
|
||||
business = post(
|
||||
client,
|
||||
"/api/v1/business/businesses",
|
||||
admin["headers"],
|
||||
business_payload(customer(client, admin["headers"])["id"]),
|
||||
)
|
||||
empty_receipt = post(
|
||||
client,
|
||||
"/api/v1/business/receipts",
|
||||
admin["headers"],
|
||||
{"business_id": business["id"], "receipt_date": "2026-03-10", "amount": "100"},
|
||||
)
|
||||
response = client.post(
|
||||
f"/api/v1/calc/receipts/{empty_receipt['id']}/calc", headers=admin["headers"]
|
||||
)
|
||||
assert response.status_code == 400 and "正式核算" in response.json()["message"]
|
||||
@@ -90,51 +90,51 @@ class TestAuth:
|
||||
class TestCrudFlow:
|
||||
"""正向链路:建 → 查(列表/详情)→ 改 → 状态切换 → 删。"""
|
||||
|
||||
def test_新增人员默认在职且工资标准字符串直出(self, client: TestClient, normal: dict) -> None:
|
||||
data = _create(client, normal["headers"])
|
||||
def test_新增人员默认在职且工资标准字符串直出(self, client: TestClient, writer: dict) -> None:
|
||||
data = _create(client, writer["headers"])
|
||||
assert data["status"] == 1, "新增人员应默认在职"
|
||||
assert data["is_probation"] == 0
|
||||
# 金额红线:str 进 str 出,不得经 float
|
||||
assert data["salary_standard"] == "3500.50"
|
||||
|
||||
detail = client.get(f"{_PERSONNEL}/{data['id']}", headers=normal["headers"])
|
||||
detail = client.get(f"{_PERSONNEL}/{data['id']}", headers=writer["headers"])
|
||||
assert detail.status_code == 200, detail.text
|
||||
body = detail.json()["data"]
|
||||
assert body["salary_standard"] == "3500.50"
|
||||
assert body["regions"] == []
|
||||
assert body["plans"] == []
|
||||
|
||||
def test_新增人员携带直属经理_回显经理与岗位名(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
def test_新增人员携带直属经理_回显经理与岗位名(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
manager = _create(client, headers)
|
||||
member = _create(client, headers, manager_id=manager["id"])
|
||||
assert member["manager_id"] == manager["id"]
|
||||
assert member["manager_name"] == manager["personnel_name"]
|
||||
assert member["role_name"] == manager["role_name"]
|
||||
|
||||
def test_列表按姓名关键词可查到新增人员(self, client: TestClient, normal: dict) -> None:
|
||||
data = _create(client, normal["headers"])
|
||||
def test_列表按姓名关键词可查到新增人员(self, client: TestClient, writer: dict) -> None:
|
||||
data = _create(client, writer["headers"])
|
||||
listed = client.get(
|
||||
_PERSONNEL, params={"keyword": data["personnel_name"]}, headers=normal["headers"]
|
||||
_PERSONNEL, params={"keyword": data["personnel_name"]}, headers=writer["headers"]
|
||||
)
|
||||
assert listed.status_code == 200, listed.text
|
||||
rows = listed.json()["data"]["list"]
|
||||
assert [r["id"] for r in rows] == [data["id"]]
|
||||
|
||||
def test_编辑人员全量替换生效(self, client: TestClient, normal: dict) -> None:
|
||||
data = _create(client, normal["headers"])
|
||||
def test_编辑人员全量替换生效(self, client: TestClient, writer: dict) -> None:
|
||||
data = _create(client, writer["headers"])
|
||||
updated = client.put(
|
||||
f"{_PERSONNEL}/{data['id']}",
|
||||
json=_payload(f"t_人员_改_{uuid.uuid4().hex[:6]}", phone="13900001111", is_probation=1),
|
||||
headers=normal["headers"],
|
||||
headers=writer["headers"],
|
||||
)
|
||||
assert updated.status_code == 200, updated.text
|
||||
body = updated.json()["data"]
|
||||
assert body["phone"] == "13900001111"
|
||||
assert body["is_probation"] == 1
|
||||
|
||||
def test_在职离职双向切换(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
def test_在职离职双向切换(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
data = _create(client, headers)
|
||||
off = client.put(f"{_PERSONNEL}/{data['id']}/status", json={"status": 0}, headers=headers)
|
||||
assert off.status_code == 200, off.text
|
||||
@@ -143,11 +143,11 @@ class TestCrudFlow:
|
||||
assert on.status_code == 200, on.text
|
||||
assert on.json()["data"]["status"] == 1
|
||||
|
||||
def test_删除无引用人员成功(self, client: TestClient, normal: dict) -> None:
|
||||
data = _create(client, normal["headers"])
|
||||
resp = client.delete(f"{_PERSONNEL}/{data['id']}", headers=normal["headers"])
|
||||
def test_删除无引用人员成功(self, client: TestClient, writer: dict) -> None:
|
||||
data = _create(client, writer["headers"])
|
||||
resp = client.delete(f"{_PERSONNEL}/{data['id']}", headers=writer["headers"])
|
||||
assert resp.status_code == 200, resp.text
|
||||
detail = client.get(f"{_PERSONNEL}/{data['id']}", headers=normal["headers"])
|
||||
detail = client.get(f"{_PERSONNEL}/{data['id']}", headers=writer["headers"])
|
||||
assert detail.status_code == 404
|
||||
assert "人员不存在" in detail.json()["message"]
|
||||
|
||||
@@ -155,27 +155,27 @@ class TestCrudFlow:
|
||||
class TestRefRules:
|
||||
"""岗位/经理引用校验 —— 核算锚点不悬空、不环引用。"""
|
||||
|
||||
def test_编辑时直属经理是自己_400(self, client: TestClient, normal: dict) -> None:
|
||||
data = _create(client, normal["headers"])
|
||||
def test_编辑时直属经理是自己_400(self, client: TestClient, writer: dict) -> None:
|
||||
data = _create(client, writer["headers"])
|
||||
resp = client.put(
|
||||
f"{_PERSONNEL}/{data['id']}",
|
||||
json=_payload(data["personnel_name"], manager_id=data["id"]),
|
||||
headers=normal["headers"],
|
||||
headers=writer["headers"],
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "直属经理不能是自己" in resp.json()["message"]
|
||||
|
||||
def test_直属经理不存在_400(self, client: TestClient, normal: dict) -> None:
|
||||
def test_直属经理不存在_400(self, client: TestClient, writer: dict) -> None:
|
||||
resp = client.post(
|
||||
_PERSONNEL,
|
||||
json=_payload(_uname(), manager_id=999999999),
|
||||
headers=normal["headers"],
|
||||
headers=writer["headers"],
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "直属经理不存在或已离职" in resp.json()["message"]
|
||||
|
||||
def test_直属经理已离职_400(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
def test_直属经理已离职_400(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
manager = _create(client, headers)
|
||||
quit_resp = client.put(
|
||||
f"{_PERSONNEL}/{manager['id']}/status", json={"status": 0}, headers=headers
|
||||
@@ -189,16 +189,16 @@ class TestRefRules:
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "直属经理不存在或已离职" in resp.json()["message"]
|
||||
|
||||
def test_岗位不存在_400(self, client: TestClient, normal: dict) -> None:
|
||||
def test_岗位不存在_400(self, client: TestClient, writer: dict) -> None:
|
||||
resp = client.post(
|
||||
_PERSONNEL,
|
||||
json=_payload(_uname(), business_role_id=999999999),
|
||||
headers=normal["headers"],
|
||||
headers=writer["headers"],
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "岗位不存在或已停用" in resp.json()["message"]
|
||||
|
||||
def test_岗位已停用_400(self, client: TestClient, normal: dict) -> None:
|
||||
def test_岗位已停用_400(self, client: TestClient, writer: dict) -> None:
|
||||
"""停用岗位不能挂人:岗位决定计提模板,挂上去核算会取不到公式。"""
|
||||
code = f"t_br_{uuid.uuid4().hex[:8]}"
|
||||
factory = get_session_factory()
|
||||
@@ -220,7 +220,7 @@ class TestRefRules:
|
||||
resp = client.post(
|
||||
_PERSONNEL,
|
||||
json=_payload(_uname(), business_role_id=role_id),
|
||||
headers=normal["headers"],
|
||||
headers=writer["headers"],
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "岗位不存在或已停用" in resp.json()["message"]
|
||||
@@ -229,8 +229,8 @@ class TestRefRules:
|
||||
class TestDeleteProtection:
|
||||
"""删除保护:被团队成员/客户归属引用的人员禁删 —— 历史要能回溯到人。"""
|
||||
|
||||
def test_被团队成员引用禁删_解除后可删(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
def test_被团队成员引用禁删_解除后可删(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
manager = _create(client, headers)
|
||||
member = _create(client, headers, manager_id=manager["id"]) # 团队成员占用
|
||||
blocked = client.delete(f"{_PERSONNEL}/{manager['id']}", headers=headers)
|
||||
@@ -244,9 +244,9 @@ class TestDeleteProtection:
|
||||
assert freed.status_code == 200, freed.text
|
||||
|
||||
def test_被客户归属引用禁删_离职不放宽_解除后可删(
|
||||
self, client: TestClient, normal: dict
|
||||
self, client: TestClient, writer: dict
|
||||
) -> None:
|
||||
headers = normal["headers"]
|
||||
headers = writer["headers"]
|
||||
owner = _create(client, headers)
|
||||
created = client.post(
|
||||
_CUSTOMERS,
|
||||
@@ -277,9 +277,9 @@ class TestDeleteProtection:
|
||||
freed = client.delete(f"{_PERSONNEL}/{owner['id']}", headers=headers)
|
||||
assert freed.status_code == 200, freed.text
|
||||
|
||||
def test_被业务归属引用禁删_负责或协助任一即拦(self, client: TestClient, normal: dict) -> None:
|
||||
def test_被业务归属引用禁删_负责或协助任一即拦(self, client: TestClient, writer: dict) -> None:
|
||||
"""业务表就位后删除保护补齐:负责/协助引用任一即拦,FK 不以 500 暴露。"""
|
||||
headers = normal["headers"]
|
||||
headers = writer["headers"]
|
||||
owner = _create(client, headers)
|
||||
assistant = _create(client, headers)
|
||||
resp_customer = client.post(
|
||||
@@ -328,9 +328,9 @@ class TestSubTableReplace:
|
||||
"""辐射区域/拓展计划为全量替换语义 —— 做成追加会让编辑翻倍脏数据。"""
|
||||
|
||||
def test_辐射区域设置后详情回显一致_再次替换生效(
|
||||
self, client: TestClient, normal: dict
|
||||
self, client: TestClient, writer: dict
|
||||
) -> None:
|
||||
headers = normal["headers"]
|
||||
headers = writer["headers"]
|
||||
data = _create(client, headers)
|
||||
resp = client.put(
|
||||
f"{_PERSONNEL}/{data['id']}/regions",
|
||||
@@ -357,8 +357,8 @@ class TestSubTableReplace:
|
||||
detail2 = client.get(f"{_PERSONNEL}/{data['id']}", headers=headers).json()["data"]
|
||||
assert [r["region_name"] for r in detail2["regions"]] == ["沈阳市沈河区"]
|
||||
|
||||
def test_辐射区域替换为空列表即清空(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
def test_辐射区域替换为空列表即清空(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
data = _create(client, headers)
|
||||
client.put(
|
||||
f"{_PERSONNEL}/{data['id']}/regions",
|
||||
@@ -373,9 +373,9 @@ class TestSubTableReplace:
|
||||
assert detail["regions"] == []
|
||||
|
||||
def test_拓展计划设置后详情回显一致_再次替换生效(
|
||||
self, client: TestClient, normal: dict
|
||||
self, client: TestClient, writer: dict
|
||||
) -> None:
|
||||
headers = normal["headers"]
|
||||
headers = writer["headers"]
|
||||
data = _create(client, headers)
|
||||
resp = client.put(
|
||||
f"{_PERSONNEL}/{data['id']}/plans",
|
||||
@@ -405,8 +405,8 @@ class TestSubTableReplace:
|
||||
detail2 = client.get(f"{_PERSONNEL}/{data['id']}", headers=headers).json()["data"]
|
||||
assert [p["plan_period"] for p in detail2["plans"]] == ["2026Q3"]
|
||||
|
||||
def test_拓展计划替换为空列表即清空(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
def test_拓展计划替换为空列表即清空(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
data = _create(client, headers)
|
||||
client.put(
|
||||
f"{_PERSONNEL}/{data['id']}/plans",
|
||||
@@ -424,11 +424,11 @@ class TestSubTableReplace:
|
||||
class TestAudit:
|
||||
"""写操作(含校验失败的)都在 operation_logs 留痕 —— 审计是验收项。"""
|
||||
|
||||
def test_新增人员成功留审计(self, client: TestClient, normal: dict) -> None:
|
||||
data = _create(client, normal["headers"])
|
||||
def test_新增人员成功留审计(self, client: TestClient, writer: dict) -> None:
|
||||
data = _create(client, writer["headers"])
|
||||
logs = [
|
||||
log
|
||||
for log in _logs_of(normal["username"])
|
||||
for log in _logs_of(writer["username"])
|
||||
if log.module == MODULE_PERSONNEL and log.action == ACTION_CREATE
|
||||
]
|
||||
assert logs, "新增人员未留操作日志"
|
||||
@@ -436,16 +436,16 @@ class TestAudit:
|
||||
assert latest.success == 1
|
||||
assert data["personnel_name"] in latest.summary
|
||||
|
||||
def test_校验失败也留审计(self, client: TestClient, normal: dict) -> None:
|
||||
def test_校验失败也留审计(self, client: TestClient, writer: dict) -> None:
|
||||
resp = client.post(
|
||||
_PERSONNEL,
|
||||
json=_payload(_uname(), manager_id=999999999),
|
||||
headers=normal["headers"],
|
||||
headers=writer["headers"],
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
failed = [
|
||||
log
|
||||
for log in _logs_of(normal["username"])
|
||||
for log in _logs_of(writer["username"])
|
||||
if log.module == MODULE_PERSONNEL and log.success == 0
|
||||
]
|
||||
assert failed, "失败操作未留痕"
|
||||
|
||||
@@ -122,8 +122,8 @@ class TestAuth:
|
||||
class TestCrudFlow:
|
||||
"""正向链路:建 → 查 → 改(含换类别)→ 删。"""
|
||||
|
||||
def test_新增产品默认启用且数量单价字符串直出(self, client: TestClient, normal: dict) -> None:
|
||||
data = _create(client, normal["headers"])
|
||||
def test_新增产品默认启用且数量单价字符串直出(self, client: TestClient, writer: dict) -> None:
|
||||
data = _create(client, writer["headers"])
|
||||
assert data["status"] == 1, "新产品应默认启用"
|
||||
assert data["spec_model"] == "DN100"
|
||||
# 金额红线:str 进 str 出,且数值与入参精确相等(Decimal 比较,不经 float)
|
||||
@@ -132,34 +132,34 @@ class TestCrudFlow:
|
||||
assert isinstance(data["stock_quantity"], str)
|
||||
assert Decimal(data["stock_quantity"]) == Decimal("100")
|
||||
|
||||
def test_详情与列表均能查到新增产品(self, client: TestClient, normal: dict) -> None:
|
||||
data = _create(client, normal["headers"])
|
||||
detail = client.get(f"{_PRODUCTS}/{data['id']}", headers=normal["headers"])
|
||||
def test_详情与列表均能查到新增产品(self, client: TestClient, writer: dict) -> None:
|
||||
data = _create(client, writer["headers"])
|
||||
detail = client.get(f"{_PRODUCTS}/{data['id']}", headers=writer["headers"])
|
||||
assert detail.status_code == 200, detail.text
|
||||
assert detail.json()["data"]["id"] == data["id"]
|
||||
|
||||
listed = client.get(
|
||||
_PRODUCTS, params={"keyword": data["product_name"]}, headers=normal["headers"]
|
||||
_PRODUCTS, params={"keyword": data["product_name"]}, headers=writer["headers"]
|
||||
)
|
||||
assert listed.status_code == 200, listed.text
|
||||
assert [r["id"] for r in listed.json()["data"]["list"]] == [data["id"]]
|
||||
|
||||
def test_同名称不同规格创建成功(self, client: TestClient, normal: dict) -> None:
|
||||
def test_同名称不同规格创建成功(self, client: TestClient, writer: dict) -> None:
|
||||
name = _pname()
|
||||
first = _create(client, normal["headers"], product_name=name, spec_model="DN100")
|
||||
first = _create(client, writer["headers"], product_name=name, spec_model="DN100")
|
||||
second = client.post(
|
||||
_PRODUCTS,
|
||||
json=_payload(name, spec_model="DN200"),
|
||||
headers=normal["headers"],
|
||||
headers=writer["headers"],
|
||||
)
|
||||
assert second.status_code == 200, second.text
|
||||
assert second.json()["data"]["id"] != first["id"]
|
||||
|
||||
def test_删除产品成功(self, client: TestClient, normal: dict) -> None:
|
||||
data = _create(client, normal["headers"])
|
||||
resp = client.delete(f"{_PRODUCTS}/{data['id']}", headers=normal["headers"])
|
||||
def test_删除产品成功(self, client: TestClient, writer: dict) -> None:
|
||||
data = _create(client, writer["headers"])
|
||||
resp = client.delete(f"{_PRODUCTS}/{data['id']}", headers=writer["headers"])
|
||||
assert resp.status_code == 200, resp.text
|
||||
detail = client.get(f"{_PRODUCTS}/{data['id']}", headers=normal["headers"])
|
||||
detail = client.get(f"{_PRODUCTS}/{data['id']}", headers=writer["headers"])
|
||||
assert detail.status_code == 404
|
||||
assert "产品不存在" in detail.json()["message"]
|
||||
|
||||
@@ -168,29 +168,29 @@ class TestDeleteProtection:
|
||||
"""删除保护:被业务/发货明细引用的产品禁删(customer 先例同构)。"""
|
||||
|
||||
def test_被业务明细引用_400且产品仍在(
|
||||
self, client: TestClient, normal: dict, make_business_for_product
|
||||
self, client: TestClient, writer: dict, make_business_for_product
|
||||
) -> None:
|
||||
product = _create(client, normal["headers"])
|
||||
business = make_business_for_product(normal["headers"], product["id"])
|
||||
resp = client.delete(f"{_PRODUCTS}/{product['id']}", headers=normal["headers"])
|
||||
product = _create(client, writer["headers"])
|
||||
business = make_business_for_product(writer["headers"], product["id"])
|
||||
resp = client.delete(f"{_PRODUCTS}/{product['id']}", headers=writer["headers"])
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "引用" in resp.json()["message"]
|
||||
# 产品仍在(引导改走停用而非物理删除)
|
||||
still = client.get(f"{_PRODUCTS}/{product['id']}", headers=normal["headers"])
|
||||
still = client.get(f"{_PRODUCTS}/{product['id']}", headers=writer["headers"])
|
||||
assert still.status_code == 200
|
||||
# 业务明细行确实引用了该产品(护栏:确认保护不是空转)
|
||||
detail = client.get(f"{_BUSINESSES}/{business['id']}", headers=normal["headers"]).json()[
|
||||
detail = client.get(f"{_BUSINESSES}/{business['id']}", headers=writer["headers"]).json()[
|
||||
"data"
|
||||
]
|
||||
assert detail["items"][0]["product_id"] == product["id"]
|
||||
|
||||
def test_被发货明细引用_400(
|
||||
self, client: TestClient, normal: dict, make_business_for_product
|
||||
self, client: TestClient, writer: dict, make_business_for_product
|
||||
) -> None:
|
||||
product = _create(client, normal["headers"])
|
||||
make_business_for_product(normal["headers"], product["id"])
|
||||
product = _create(client, writer["headers"])
|
||||
make_business_for_product(writer["headers"], product["id"])
|
||||
# 在业务明细引用之外再挂一笔发货明细(同样引用 products.id)
|
||||
businesses = client.get("/api/v1/business/businesses", headers=normal["headers"]).json()[
|
||||
businesses = client.get("/api/v1/business/businesses", headers=writer["headers"]).json()[
|
||||
"data"
|
||||
]
|
||||
business_id = businesses["list"][0]["id"]
|
||||
@@ -208,68 +208,68 @@ class TestDeleteProtection:
|
||||
}
|
||||
],
|
||||
},
|
||||
headers=normal["headers"],
|
||||
headers=writer["headers"],
|
||||
)
|
||||
assert shipment.status_code == 200, shipment.text
|
||||
# 此前业务明细引用已令删除被拒,此处再证发货引用同一防线
|
||||
resp = client.delete(f"{_PRODUCTS}/{product['id']}", headers=normal["headers"])
|
||||
resp = client.delete(f"{_PRODUCTS}/{product['id']}", headers=writer["headers"])
|
||||
assert resp.status_code == 400, resp.text
|
||||
|
||||
|
||||
class TestValidationRules:
|
||||
"""类别值域与组合唯一 —— 核算阈值锚点不脏、业务标识不重复。"""
|
||||
|
||||
def test_重名同规格_400(self, client: TestClient, normal: dict) -> None:
|
||||
def test_重名同规格_400(self, client: TestClient, writer: dict) -> None:
|
||||
name = _pname()
|
||||
_create(client, normal["headers"], product_name=name)
|
||||
_create(client, writer["headers"], product_name=name)
|
||||
dup = client.post(
|
||||
_PRODUCTS,
|
||||
json=_payload(name, spec_model="DN100"),
|
||||
headers=normal["headers"],
|
||||
headers=writer["headers"],
|
||||
)
|
||||
assert dup.status_code == 400, dup.text
|
||||
assert "已存在同名称同规格的产品" in dup.json()["message"]
|
||||
|
||||
def test_规格缺省归一空串且重名同空规格仍拦截(self, client: TestClient, normal: dict) -> None:
|
||||
def test_规格缺省归一空串且重名同空规格仍拦截(self, client: TestClient, writer: dict) -> None:
|
||||
"""组合唯一键对 NULL 不去重:缺省规格若归 NULL 会放行重名同规格数据。"""
|
||||
name = _pname()
|
||||
first = _create(client, normal["headers"], product_name=name, spec_model=None)
|
||||
first = _create(client, writer["headers"], product_name=name, spec_model=None)
|
||||
assert first["spec_model"] == ""
|
||||
dup = client.post(
|
||||
_PRODUCTS,
|
||||
json=_payload(name, spec_model=None),
|
||||
headers=normal["headers"],
|
||||
headers=writer["headers"],
|
||||
)
|
||||
assert dup.status_code == 400, dup.text
|
||||
assert "已存在同名称同规格的产品" in dup.json()["message"]
|
||||
|
||||
def test_产品类别非法_400(self, client: TestClient, normal: dict) -> None:
|
||||
def test_产品类别非法_400(self, client: TestClient, writer: dict) -> None:
|
||||
resp = client.post(
|
||||
_PRODUCTS,
|
||||
json=_payload(_pname(), product_category="not_a_category"),
|
||||
headers=normal["headers"],
|
||||
headers=writer["headers"],
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "产品类别不合法" in resp.json()["message"]
|
||||
|
||||
def test_销售单价为负数_400(self, client: TestClient, normal: dict) -> None:
|
||||
def test_销售单价为负数_400(self, client: TestClient, writer: dict) -> None:
|
||||
resp = client.post(
|
||||
_PRODUCTS,
|
||||
json=_payload(_pname(), sale_price="-1"),
|
||||
headers=normal["headers"],
|
||||
headers=writer["headers"],
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
|
||||
def test_销售单价非数字_400(self, client: TestClient, normal: dict) -> None:
|
||||
def test_销售单价非数字_400(self, client: TestClient, writer: dict) -> None:
|
||||
resp = client.post(
|
||||
_PRODUCTS,
|
||||
json=_payload(_pname(), sale_price="abc"),
|
||||
headers=normal["headers"],
|
||||
headers=writer["headers"],
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
|
||||
def test_编辑改类别为合法值成功(self, client: TestClient, normal: dict) -> None:
|
||||
data = _create(client, normal["headers"])
|
||||
def test_编辑改类别为合法值成功(self, client: TestClient, writer: dict) -> None:
|
||||
data = _create(client, writer["headers"])
|
||||
updated = client.put(
|
||||
f"{_PRODUCTS}/{data['id']}",
|
||||
json=_payload(
|
||||
@@ -277,7 +277,7 @@ class TestValidationRules:
|
||||
product_category=PRODUCT_CATEGORY_RAIL_TRANSIT,
|
||||
sale_price="15.8",
|
||||
),
|
||||
headers=normal["headers"],
|
||||
headers=writer["headers"],
|
||||
)
|
||||
assert updated.status_code == 200, updated.text
|
||||
body = updated.json()["data"]
|
||||
@@ -288,8 +288,8 @@ class TestValidationRules:
|
||||
class TestStatusSwitch:
|
||||
"""启用/停用独立于编辑端点 —— 编辑表单不携带状态,防误触停用。"""
|
||||
|
||||
def test_停用再启用切换(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
def test_停用再启用切换(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
data = _create(client, headers)
|
||||
off = client.put(f"{_PRODUCTS}/{data['id']}/status", json={"status": 0}, headers=headers)
|
||||
assert off.status_code == 200, off.text
|
||||
@@ -304,11 +304,11 @@ class TestStatusSwitch:
|
||||
class TestAudit:
|
||||
"""写操作(含失败)留痕 —— 审计是验收项。"""
|
||||
|
||||
def test_新增产品成功留审计(self, client: TestClient, normal: dict) -> None:
|
||||
data = _create(client, normal["headers"])
|
||||
def test_新增产品成功留审计(self, client: TestClient, writer: dict) -> None:
|
||||
data = _create(client, writer["headers"])
|
||||
logs = [
|
||||
log
|
||||
for log in _logs_of(normal["username"])
|
||||
for log in _logs_of(writer["username"])
|
||||
if log.module == MODULE_PRODUCT and log.action == ACTION_CREATE
|
||||
]
|
||||
assert logs, "新增产品未留操作日志"
|
||||
@@ -316,18 +316,18 @@ class TestAudit:
|
||||
assert latest.success == 1
|
||||
assert data["product_name"] in latest.summary
|
||||
|
||||
def test_重名失败留审计(self, client: TestClient, normal: dict) -> None:
|
||||
def test_重名失败留审计(self, client: TestClient, writer: dict) -> None:
|
||||
name = _pname()
|
||||
_create(client, normal["headers"], product_name=name)
|
||||
_create(client, writer["headers"], product_name=name)
|
||||
dup = client.post(
|
||||
_PRODUCTS,
|
||||
json=_payload(name, spec_model="DN100"),
|
||||
headers=normal["headers"],
|
||||
headers=writer["headers"],
|
||||
)
|
||||
assert dup.status_code == 400, dup.text
|
||||
failed = [
|
||||
log
|
||||
for log in _logs_of(normal["username"])
|
||||
for log in _logs_of(writer["username"])
|
||||
if log.module == MODULE_PRODUCT and log.success == 0
|
||||
]
|
||||
assert failed, "失败操作未留痕"
|
||||
|
||||
@@ -122,8 +122,8 @@ class TestOverlapGuardLock:
|
||||
class TestList:
|
||||
"""列表直出种子基线:判定表是前端评级计算器的唯一数据源。"""
|
||||
|
||||
def test_种子四等级全量返回_按sort_order升序(self, client: TestClient, normal: dict) -> None:
|
||||
resp = client.get(_GRADES, headers=normal["headers"])
|
||||
def test_种子四等级全量返回_按sort_order升序(self, client: TestClient, writer: dict) -> None:
|
||||
resp = client.get(_GRADES, headers=writer["headers"])
|
||||
assert resp.status_code == 200, resp.text
|
||||
rows = resp.json()["data"]
|
||||
assert [(r["grade_name"], r["sort_order"]) for r in rows] == [
|
||||
@@ -140,11 +140,11 @@ class TestCreate:
|
||||
任一缺失都会算错等级。"""
|
||||
|
||||
def test_删除种子后新增相邻区间_成功且判定表更新(
|
||||
self, client: TestClient, normal: dict
|
||||
self, client: TestClient, writer: dict
|
||||
) -> None:
|
||||
"""种子铺满 0-100,任何新区间都与某条重叠 —— 先删 C(60-74),
|
||||
新增 60-74 与 D(0-59)/B(75-89) **边界相邻不算重叠**(正例)。"""
|
||||
headers = normal["headers"]
|
||||
headers = writer["headers"]
|
||||
client.delete(f"{_GRADES}/{_seed_id(client, headers, 'C')}", headers=headers)
|
||||
resp = client.post(
|
||||
_GRADES, json=_gpayload("t_E", "60", "74", sort_order=5), headers=headers
|
||||
@@ -158,48 +158,48 @@ class TestCreate:
|
||||
rows = client.get(_GRADES, headers=headers).json()["data"]
|
||||
assert _names(client, headers) == ["A", "B", "D", "t_E"], rows
|
||||
|
||||
def test_区间倒挂_400(self, client: TestClient, normal: dict) -> None:
|
||||
resp = client.post(_GRADES, json=_gpayload(_tname(), "80", "10"), headers=normal["headers"])
|
||||
def test_区间倒挂_400(self, client: TestClient, writer: dict) -> None:
|
||||
resp = client.post(_GRADES, json=_gpayload(_tname(), "80", "10"), headers=writer["headers"])
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "分值区间不合法" in resp.json()["message"], resp.text
|
||||
|
||||
def test_分值超上限_400(self, client: TestClient, normal: dict) -> None:
|
||||
def test_分值超上限_400(self, client: TestClient, writer: dict) -> None:
|
||||
resp = client.post(
|
||||
_GRADES, json=_gpayload(_tname(), "95", "150"), headers=normal["headers"]
|
||||
_GRADES, json=_gpayload(_tname(), "95", "150"), headers=writer["headers"]
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "分值区间不合法" in resp.json()["message"], resp.text
|
||||
|
||||
def test_分值负下限_400(self, client: TestClient, normal: dict) -> None:
|
||||
resp = client.post(_GRADES, json=_gpayload(_tname(), "-5", "10"), headers=normal["headers"])
|
||||
def test_分值负下限_400(self, client: TestClient, writer: dict) -> None:
|
||||
resp = client.post(_GRADES, json=_gpayload(_tname(), "-5", "10"), headers=writer["headers"])
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "分值区间不合法" in resp.json()["message"], resp.text
|
||||
|
||||
def test_等级名称已存在_400(self, client: TestClient, normal: dict) -> None:
|
||||
def test_等级名称已存在_400(self, client: TestClient, writer: dict) -> None:
|
||||
"""先删 D 清出空隙:重叠校验在名称唯一校验之前,不清空隙会先撞
|
||||
「与等级 D 重叠」而走不到名称校验。"""
|
||||
headers = normal["headers"]
|
||||
headers = writer["headers"]
|
||||
_drop_seed(client, headers, "D")
|
||||
resp = client.post(_GRADES, json=_gpayload("A", "10", "20"), 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:
|
||||
def test_新增与种子重叠_400点名冲突等级(self, client: TestClient, writer: dict) -> None:
|
||||
"""85-95 与种子 A(90-100) 闭区间相交 → 400 并点名 A,前端据此定位行。"""
|
||||
resp = client.post(_GRADES, json=_gpayload(_tname(), "85", "95"), headers=normal["headers"])
|
||||
resp = client.post(_GRADES, json=_gpayload(_tname(), "85", "95"), headers=writer["headers"])
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "分值区间与等级 A 重叠" in resp.json()["message"], resp.text
|
||||
# 失败不落库:判定表仍是 4 条种子
|
||||
assert _names(client, normal["headers"]) == ["A", "B", "C", "D"]
|
||||
assert _names(client, writer["headers"]) == ["A", "B", "C", "D"]
|
||||
|
||||
|
||||
class TestEdit:
|
||||
"""编辑以修改后的区间对全部既有等级(排除自身)两两相交 —— 编辑路与
|
||||
新增路共用重叠约束,两处都要拦。"""
|
||||
|
||||
def test_编辑与其他等级重叠_400点名冲突等级(self, client: TestClient, normal: dict) -> None:
|
||||
def test_编辑与其他等级重叠_400点名冲突等级(self, client: TestClient, writer: dict) -> None:
|
||||
"""把 B 编辑为 60-65:与 C(60-74) 相交 → 点名 C;与 D(0-59)/A 均分离。"""
|
||||
headers = normal["headers"]
|
||||
headers = writer["headers"]
|
||||
b_id = _seed_id(client, headers, "B")
|
||||
resp = client.put(
|
||||
f"{_GRADES}/{b_id}",
|
||||
@@ -211,10 +211,10 @@ class TestEdit:
|
||||
# 失败不落库:种子保持原样
|
||||
assert _names(client, headers) == ["A", "B", "C", "D"]
|
||||
|
||||
def test_编辑相邻区间_成功且判定表更新(self, client: TestClient, normal: dict) -> None:
|
||||
def test_编辑相邻区间_成功且判定表更新(self, client: TestClient, writer: dict) -> None:
|
||||
"""删除 C 后把 D 编辑为 0-74:与 B(75-89) **边界相邻不算重叠**
|
||||
(74 与 75 相邻),这是种子铺法的正例。"""
|
||||
headers = normal["headers"]
|
||||
headers = writer["headers"]
|
||||
client.delete(f"{_GRADES}/{_seed_id(client, headers, 'C')}", headers=headers)
|
||||
d_id = _seed_id(client, headers, "D")
|
||||
resp = client.put(
|
||||
@@ -228,8 +228,8 @@ class TestEdit:
|
||||
assert [(r["grade_name"], r["sort_order"]) for r in rows] == [("A", 1), ("B", 2), ("D", 4)]
|
||||
assert Decimal(rows[2]["max_score"]) == Decimal("74"), rows[2]
|
||||
|
||||
def test_编辑回显改名与分数(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
def test_编辑回显改名与分数(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
_drop_seed(client, headers, "D") # 清出 0-59 空隙供测试等级落脚
|
||||
grade_name = _tname()
|
||||
created = client.post(
|
||||
@@ -261,8 +261,8 @@ class TestDelete:
|
||||
"""删除允许清空(评级是纯计算器、评分记录不落库)—— 删除后判定表
|
||||
立即反映,二次删除 404。"""
|
||||
|
||||
def test_删除等级后判定表更新_二次删除404(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
def test_删除等级后判定表更新_二次删除404(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
_drop_seed(client, headers, "D") # 清出 0-59 空隙供测试等级落脚
|
||||
grade_name = _tname()
|
||||
created = client.post(
|
||||
@@ -283,8 +283,8 @@ class TestDelete:
|
||||
class TestAudit:
|
||||
"""写操作(含校验失败的)都在 operation_logs 留痕 —— 审计是验收项。"""
|
||||
|
||||
def test_新增等级成功留审计(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
def test_新增等级成功留审计(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
_drop_seed(client, headers, "D") # 清出 0-59 空隙供测试等级落脚
|
||||
grade_name = _tname()
|
||||
resp = client.post(
|
||||
@@ -295,7 +295,7 @@ class TestAudit:
|
||||
assert resp.status_code == 200, resp.text
|
||||
logs = [
|
||||
log
|
||||
for log in _logs_of(normal["username"])
|
||||
for log in _logs_of(writer["username"])
|
||||
if log.module == MODULE_RATING and log.action == ACTION_CREATE
|
||||
]
|
||||
assert logs, "新增评级等级未留操作日志"
|
||||
@@ -303,12 +303,12 @@ class TestAudit:
|
||||
assert latest.success == 1
|
||||
assert grade_name in latest.summary
|
||||
|
||||
def test_重叠失败留审计(self, client: TestClient, normal: dict) -> None:
|
||||
resp = client.post(_GRADES, json=_gpayload(_tname(), "85", "95"), headers=normal["headers"])
|
||||
def test_重叠失败留审计(self, client: TestClient, writer: dict) -> None:
|
||||
resp = client.post(_GRADES, json=_gpayload(_tname(), "85", "95"), headers=writer["headers"])
|
||||
assert resp.status_code == 400, resp.text
|
||||
failed = [
|
||||
log
|
||||
for log in _logs_of(normal["username"])
|
||||
for log in _logs_of(writer["username"])
|
||||
if log.module == MODULE_RATING and log.success == 0
|
||||
]
|
||||
assert failed, "失败操作未留痕"
|
||||
|
||||
@@ -122,8 +122,8 @@ class TestAuth:
|
||||
class TestCreate:
|
||||
"""创建:HK 批次契约、金额红线、回款天数派生与业务合计回写。"""
|
||||
|
||||
def test_新增回款_批次号格式_未指定批次_无派生(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
def test_新增回款_批次号格式_未指定批次_无派生(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
business = _create_business(client, headers)
|
||||
data = _create(client, headers, business["id"])
|
||||
|
||||
@@ -140,10 +140,10 @@ class TestCreate:
|
||||
assert data["settlement_status"] == 0
|
||||
assert Decimal(data["amount"]) == Decimal("1200")
|
||||
|
||||
def test_指定批次_回款天数发货日首日计第1天(self, client: TestClient, normal: dict) -> None:
|
||||
def test_指定批次_回款天数发货日首日计第1天(self, client: TestClient, writer: dict) -> None:
|
||||
"""发货 2026-10-05 / 回款 2026-11-02 → 29 天(首日计第 1 天,
|
||||
需规「26 天」示例已作废)。"""
|
||||
headers = normal["headers"]
|
||||
headers = writer["headers"]
|
||||
business = _create_business(client, headers)
|
||||
shipment = _create_shipment(client, headers, business["id"], "2026-10-05")
|
||||
data = _create(
|
||||
@@ -158,8 +158,8 @@ class TestCreate:
|
||||
assert data["shipment_no"] == shipment["shipment_no"]
|
||||
assert data["payment_days"] == 29, data
|
||||
|
||||
def test_新增回款_业务合计回写_删除后回退(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
def test_新增回款_业务合计回写_删除后回退(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
business = _create_business(client, headers)
|
||||
data = _create(client, headers, business["id"])
|
||||
|
||||
@@ -170,35 +170,35 @@ class TestCreate:
|
||||
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"])
|
||||
def test_业务不存在_404(self, client: TestClient, writer: dict) -> None:
|
||||
resp = client.post(_RECEIPTS, json=_rpayload(999999999), headers=writer["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"]
|
||||
def test_终态业务禁新增回款_400(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["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:
|
||||
def test_金额负数_400(self, client: TestClient, writer: dict) -> None:
|
||||
"""一期禁负数金额(红字/退款冲销需求出现时先补设计再放开)。"""
|
||||
business = _create_business(client, normal["headers"])
|
||||
business = _create_business(client, writer["headers"])
|
||||
resp = client.post(
|
||||
_RECEIPTS, json=_rpayload(business["id"], amount="-1"), headers=normal["headers"]
|
||||
_RECEIPTS, json=_rpayload(business["id"], amount="-1"), headers=writer["headers"]
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "不能为负数" in resp.json()["message"], resp.text
|
||||
|
||||
def test_金额三位小数_400(self, client: TestClient, normal: dict) -> None:
|
||||
def test_金额三位小数_400(self, client: TestClient, writer: dict) -> None:
|
||||
"""小数位超限直接拒绝而非静默舍入 —— 舍入即算错钱。"""
|
||||
business = _create_business(client, normal["headers"])
|
||||
business = _create_business(client, writer["headers"])
|
||||
resp = client.post(
|
||||
_RECEIPTS,
|
||||
json=_rpayload(business["id"], amount="12.345"),
|
||||
headers=normal["headers"],
|
||||
headers=writer["headers"],
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "最多两位小数" in resp.json()["message"], resp.text
|
||||
@@ -208,8 +208,8 @@ class TestCrossBusiness:
|
||||
"""发货批次同业务强校验 —— 两类失败(不存在/跨业务)统一文案,防响应
|
||||
差异探测发货单存在性。"""
|
||||
|
||||
def test_跨业务指定发货批次_400(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
def test_跨业务指定发货批次_400(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
business1 = _create_business(client, headers)
|
||||
shipment = _create_shipment(client, headers, business1["id"], str(date.today()))
|
||||
business2 = _create_business(client, headers)
|
||||
@@ -222,9 +222,9 @@ class TestCrossBusiness:
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "发货批次不属于该业务,无法指定" in resp.json()["message"], resp.text
|
||||
|
||||
def test_编辑时指定跨业务发货批次_400(self, client: TestClient, normal: dict) -> None:
|
||||
def test_编辑时指定跨业务发货批次_400(self, client: TestClient, writer: dict) -> None:
|
||||
"""编辑路径(全量替换)同样强校验,无「未变即免检」旁路。"""
|
||||
headers = normal["headers"]
|
||||
headers = writer["headers"]
|
||||
business1 = _create_business(client, headers)
|
||||
shipment = _create_shipment(client, headers, business1["id"], str(date.today()))
|
||||
business2 = _create_business(client, headers)
|
||||
@@ -242,8 +242,8 @@ class TestCrossBusiness:
|
||||
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"]
|
||||
def test_编辑回款_全量替换_派生值实时刷新(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
business = _create_business(client, headers)
|
||||
shipment = _create_shipment(client, headers, business["id"], "2026-10-05")
|
||||
data = _create(client, headers, business["id"])
|
||||
@@ -279,8 +279,8 @@ class TestCrossBusiness:
|
||||
class TestSettled:
|
||||
"""已核算回款禁改禁删(M3 核算防重锚点)—— 台账固化后源数据禁漂移。"""
|
||||
|
||||
def test_已核算禁改_400(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
def test_已核算禁改_400(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
business = _create_business(client, headers)
|
||||
data = _create(client, headers, business["id"])
|
||||
_settle(data["id"])
|
||||
@@ -297,8 +297,8 @@ class TestSettled:
|
||||
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"]
|
||||
def test_已核算禁删_400(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
business = _create_business(client, headers)
|
||||
data = _create(client, headers, business["id"])
|
||||
_settle(data["id"])
|
||||
@@ -312,9 +312,9 @@ class TestFilter:
|
||||
"""核算状态筛选(M3 核算消费的入口口径)。"""
|
||||
|
||||
def test_settlement_status筛选_0只回未核算_1只回已核算(
|
||||
self, client: TestClient, normal: dict
|
||||
self, client: TestClient, writer: dict
|
||||
) -> None:
|
||||
headers = normal["headers"]
|
||||
headers = writer["headers"]
|
||||
business = _create_business(client, headers)
|
||||
pending = _create(client, headers, business["id"], amount="500.00")
|
||||
settled = _create(client, headers, business["id"], amount="700.00")
|
||||
@@ -343,13 +343,13 @@ class TestFilter:
|
||||
class TestAudit:
|
||||
"""写操作(含校验失败的)都在 operation_logs 留痕 —— 审计是验收项。"""
|
||||
|
||||
def test_新增回款成功留审计(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
def test_新增回款成功留审计(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
business = _create_business(client, headers)
|
||||
data = _create(client, headers, business["id"])
|
||||
logs = [
|
||||
log
|
||||
for log in _logs_of(normal["username"])
|
||||
for log in _logs_of(writer["username"])
|
||||
if log.module == MODULE_RECEIPT and log.action == ACTION_CREATE
|
||||
]
|
||||
assert logs, "新增回款未留操作日志"
|
||||
@@ -358,8 +358,8 @@ class TestAudit:
|
||||
assert "新增回款" in latest.summary
|
||||
assert data["receipt_batch_no"] in latest.summary
|
||||
|
||||
def test_跨业务指定失败留审计(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
def test_跨业务指定失败留审计(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
business1 = _create_business(client, headers)
|
||||
shipment = _create_shipment(client, headers, business1["id"], str(date.today()))
|
||||
business2 = _create_business(client, headers)
|
||||
@@ -371,7 +371,7 @@ class TestAudit:
|
||||
assert resp.status_code == 400, resp.text
|
||||
failed = [
|
||||
log
|
||||
for log in _logs_of(normal["username"])
|
||||
for log in _logs_of(writer["username"])
|
||||
if log.module == MODULE_RECEIPT and log.success == 0
|
||||
]
|
||||
assert failed, "失败操作未留痕"
|
||||
|
||||
@@ -15,7 +15,7 @@ from decimal import Decimal
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("legacy_calculation")]
|
||||
|
||||
_REPORTS = "/api/v1/reports"
|
||||
_PERSONNEL = "/api/v1/base/personnel"
|
||||
|
||||
@@ -19,7 +19,7 @@ from fastapi.testclient import TestClient
|
||||
|
||||
from tests.integration.factories import business_payload, customer
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("legacy_calculation")]
|
||||
|
||||
_BUSINESSES = "/api/v1/business/businesses"
|
||||
_PERSONNEL = "/api/v1/base/personnel"
|
||||
|
||||
@@ -21,7 +21,7 @@ from app.core.db import get_session_factory
|
||||
from app.models import MonthlySettlement, PerformanceLedger
|
||||
from tests.integration.factories import business_payload, customer
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("legacy_calculation")]
|
||||
|
||||
_SETTLEMENTS = "/api/v1/settlements"
|
||||
_CALC = "/api/v1/calc"
|
||||
|
||||
@@ -97,8 +97,8 @@ class TestCreate:
|
||||
"""创建:SH 编号契约、行校验、行金额固化与双重汇总回写 —— 业务级汇总
|
||||
是 M3 起付判定的直接消费源,错写就是算错钱。"""
|
||||
|
||||
def test_创建发货单_行金额固化与双重汇总回写(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
def test_创建发货单_行金额固化与双重汇总回写(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
business = _create_business(client, headers)
|
||||
data = _create(
|
||||
client,
|
||||
@@ -130,53 +130,53 @@ class TestCreate:
|
||||
assert Decimal(business_detail["total_ship_amount"]) == Decimal("1400")
|
||||
assert Decimal(business_detail["total_freight_amount"]) == Decimal("88.50")
|
||||
|
||||
def test_业务不存在_404(self, client: TestClient, normal: dict) -> None:
|
||||
resp = client.post(_SHIPMENTS, json=_spayload(999999999), headers=normal["headers"])
|
||||
def test_业务不存在_404(self, client: TestClient, writer: dict) -> None:
|
||||
resp = client.post(_SHIPMENTS, json=_spayload(999999999), headers=writer["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"]
|
||||
def test_终态业务禁新发货_400(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
business = _create_business(client, headers)
|
||||
_terminate(client, headers, business["id"])
|
||||
resp = client.post(_SHIPMENTS, json=_spayload(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"])
|
||||
def test_明细为空_400(self, client: TestClient, writer: dict) -> None:
|
||||
business = _create_business(client, writer["headers"])
|
||||
resp = client.post(
|
||||
_SHIPMENTS, json=_spayload(business["id"], items=[]), headers=normal["headers"]
|
||||
_SHIPMENTS, json=_spayload(business["id"], items=[]), headers=writer["headers"]
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
|
||||
def test_明细行数量为0_400(self, client: TestClient, normal: dict) -> None:
|
||||
business = _create_business(client, normal["headers"])
|
||||
def test_明细行数量为0_400(self, client: TestClient, writer: dict) -> None:
|
||||
business = _create_business(client, writer["headers"])
|
||||
resp = client.post(
|
||||
_SHIPMENTS,
|
||||
json=_spayload(business["id"], items=[_item(quantity="0")]),
|
||||
headers=normal["headers"],
|
||||
headers=writer["headers"],
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "数量" in resp.json()["message"], resp.text
|
||||
|
||||
def test_运费负数_400(self, client: TestClient, normal: dict) -> None:
|
||||
def test_运费负数_400(self, client: TestClient, writer: dict) -> None:
|
||||
"""运费是金额源数据:负值会让业务运费合计与报表直接错乱。"""
|
||||
business = _create_business(client, normal["headers"])
|
||||
business = _create_business(client, writer["headers"])
|
||||
resp = client.post(
|
||||
_SHIPMENTS,
|
||||
json=_spayload(business["id"], freight_amount="-8.00"),
|
||||
headers=normal["headers"],
|
||||
headers=writer["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"])
|
||||
def test_运费三位小数_400(self, client: TestClient, writer: dict) -> None:
|
||||
business = _create_business(client, writer["headers"])
|
||||
resp = client.post(
|
||||
_SHIPMENTS,
|
||||
json=_spayload(business["id"], freight_amount="8.333"),
|
||||
headers=normal["headers"],
|
||||
headers=writer["headers"],
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "最多两位小数" in resp.json()["message"], resp.text
|
||||
@@ -185,8 +185,8 @@ class TestCreate:
|
||||
class TestEdit:
|
||||
"""编辑(明细全量替换)与两处汇总同步 —— 业务级汇总漏算即漂移。"""
|
||||
|
||||
def test_编辑发货单_两处汇总同步(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
def test_编辑发货单_两处汇总同步(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
business = _create_business(client, headers)
|
||||
data = _create(
|
||||
client,
|
||||
@@ -223,8 +223,8 @@ class TestEdit:
|
||||
class TestDelete:
|
||||
"""删除保护与汇总回退:被回款指定禁删;删除后业务级汇总必须回算。"""
|
||||
|
||||
def test_被回款指定禁删_解除后可删_汇总回退(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
def test_被回款指定禁删_解除后可删_汇总回退(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
business = _create_business(client, headers)
|
||||
data = _create(
|
||||
client,
|
||||
@@ -264,8 +264,8 @@ class TestDelete:
|
||||
assert Decimal(business_detail["total_ship_amount"]) == Decimal("0")
|
||||
assert Decimal(business_detail["total_freight_amount"]) == Decimal("0")
|
||||
|
||||
def test_删除未被指定的发货单_业务级汇总回退(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
def test_删除未被指定的发货单_业务级汇总回退(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
business = _create_business(client, headers)
|
||||
data = _create(
|
||||
client,
|
||||
@@ -289,8 +289,8 @@ class TestTerminal:
|
||||
"""终态业务的发货单禁改禁删 —— 发货的运费/金额是业务级汇总与 M3 核算
|
||||
的源数据,终态后不得变动。"""
|
||||
|
||||
def test_终态业务发货单禁改_400(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
def test_终态业务发货单禁改_400(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
business = _create_business(client, headers)
|
||||
data = _create(client, headers, business["id"])
|
||||
_terminate(client, headers, business["id"])
|
||||
@@ -306,8 +306,8 @@ class TestTerminal:
|
||||
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"]
|
||||
def test_终态业务发货单禁删_400(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
business = _create_business(client, headers)
|
||||
data = _create(client, headers, business["id"])
|
||||
_terminate(client, headers, business["id"])
|
||||
@@ -320,13 +320,13 @@ class TestTerminal:
|
||||
class TestAudit:
|
||||
"""写操作(含校验失败的)都在 operation_logs 留痕 —— 审计是验收项。"""
|
||||
|
||||
def test_新增发货单成功留审计(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
def test_新增发货单成功留审计(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
business = _create_business(client, headers)
|
||||
data = _create(client, headers, business["id"])
|
||||
logs = [
|
||||
log
|
||||
for log in _logs_of(normal["username"])
|
||||
for log in _logs_of(writer["username"])
|
||||
if log.module == MODULE_SHIPMENT and log.action == ACTION_CREATE
|
||||
]
|
||||
assert logs, "新增发货单未留操作日志"
|
||||
@@ -335,15 +335,15 @@ class TestAudit:
|
||||
assert "新增发货单" in latest.summary
|
||||
assert data["shipment_no"] in latest.summary
|
||||
|
||||
def test_终态禁新发货失败留审计(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
def test_终态禁新发货失败留审计(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
business = _create_business(client, headers)
|
||||
_terminate(client, headers, business["id"])
|
||||
resp = client.post(_SHIPMENTS, json=_spayload(business["id"]), headers=headers)
|
||||
assert resp.status_code == 400, resp.text
|
||||
failed = [
|
||||
log
|
||||
for log in _logs_of(normal["username"])
|
||||
for log in _logs_of(writer["username"])
|
||||
if log.module == MODULE_SHIPMENT and log.success == 0
|
||||
]
|
||||
assert failed, "失败操作未留痕"
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
"""真实MySQL验证周期竞争、重试、worker消费和独立长任务心跳。"""
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from contextlib import nullcontext
|
||||
from datetime import datetime, timedelta
|
||||
from time import sleep
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import delete, func, select, text
|
||||
|
||||
from app.core.config import Settings
|
||||
from app.core.db import get_session_factory
|
||||
from app.models import (
|
||||
PerformanceLedger,
|
||||
TPlusAutomationState,
|
||||
TPlusSourceHead,
|
||||
TPlusSyncBatch,
|
||||
TPlusSyncError,
|
||||
TPlusSyncRecord,
|
||||
)
|
||||
from app.services.tplus import automation, worker
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
NOW = datetime(2026, 10, 30, 10)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auto_source(monkeypatch):
|
||||
source = 'auto_test_' + uuid4().hex[:12]
|
||||
settings = Settings(_env_file=None, app_secret_key='x' * 32, t_plus_enabled=True,
|
||||
t_plus_database=source, t_plus_user='reader', t_plus_odbc_driver='unit', t_plus_automation_enabled=True)
|
||||
monkeypatch.setattr(automation, 'get_settings', lambda: settings)
|
||||
monkeypatch.setattr('app.services.tplus.task_service.get_settings', lambda: settings)
|
||||
yield settings
|
||||
with get_session_factory()() as s:
|
||||
numbers = list(s.scalars(select(TPlusSyncBatch.batch_no).where(TPlusSyncBatch.source_database == source)))
|
||||
s.execute(delete(TPlusSourceHead).where(TPlusSourceHead.source_database == source))
|
||||
s.execute(delete(TPlusSyncRecord).where(TPlusSyncRecord.batch_no.in_(numbers)))
|
||||
s.execute(delete(TPlusSyncError).where(TPlusSyncError.batch_no.in_(numbers)))
|
||||
s.execute(delete(TPlusSyncBatch).where(TPlusSyncBatch.source_database == source))
|
||||
s.execute(delete(TPlusAutomationState).where(TPlusAutomationState.source_database == source))
|
||||
s.commit()
|
||||
|
||||
|
||||
def test_并发周期检查只排队一个完整采集(auto_source):
|
||||
def call():
|
||||
with get_session_factory()() as s:
|
||||
return automation.tick(s, settings=auto_source, now=NOW)
|
||||
with ThreadPoolExecutor(max_workers=3) as pool:
|
||||
results = list(pool.map(lambda _: call(), range(3)))
|
||||
assert sum(r['queued'] for r in results) == 1
|
||||
with get_session_factory()() as s:
|
||||
rows = list(s.scalars(select(TPlusSyncBatch).where(TPlusSyncBatch.source_database == auto_source.t_plus_database)))
|
||||
assert len(rows) == 1 and len(rows[0].entity_types_json) == 9
|
||||
|
||||
|
||||
def test_周期任务经原worker读取九类且正式工资不变(auto_source, monkeypatch):
|
||||
def values(_reader, entity, **_args):
|
||||
return iter([{'id': 123456789, 'code': 'AUTO-' + entity}])
|
||||
monkeypatch.setattr('app.services.tplus.reader.TPlusReader.iter_entity', values)
|
||||
with get_session_factory()() as s:
|
||||
result = automation.tick(s, settings=auto_source, now=NOW)
|
||||
number = worker.run_once(s, source_factory=lambda: nullcontext(None), worker_owner='integration-owner')
|
||||
assert number == result['batch_no']
|
||||
batch = s.scalar(select(TPlusSyncBatch).where(TPlusSyncBatch.batch_no == number))
|
||||
assert batch.status == 'SUCCEEDED' and batch.source_record_count == 9
|
||||
assert batch.trigger_type == 'SCHEDULED'
|
||||
assert s.scalar(select(func.count()).select_from(PerformanceLedger)) == 0
|
||||
|
||||
|
||||
def test_长查询期间心跳使用独立会话(auto_source):
|
||||
with get_session_factory()() as s, automation.WorkerHeartbeat(s, interval=0.1, owner='long-task'):
|
||||
with get_session_factory()() as inspect_session:
|
||||
first = inspect_session.scalar(select(TPlusAutomationState).where(TPlusAutomationState.source_database == auto_source.t_plus_database)).heartbeat_at
|
||||
sleep(1.3)
|
||||
with get_session_factory()() as inspect_session:
|
||||
state = inspect_session.scalar(select(TPlusAutomationState).where(TPlusAutomationState.source_database == auto_source.t_plus_database))
|
||||
assert state.heartbeat_at > first and state.worker_phase == 'RUNNING'
|
||||
|
||||
|
||||
def test_自动重试保留父批次并受两次上限约束(auto_source):
|
||||
with get_session_factory()() as s:
|
||||
first = automation.tick(s, settings=auto_source, now=NOW)
|
||||
current = first['batch_no']
|
||||
for i in range(3):
|
||||
batch = s.scalar(select(TPlusSyncBatch).where(TPlusSyncBatch.batch_no == current))
|
||||
batch.status, batch.finished_at = 'FAILED', NOW + timedelta(hours=i)
|
||||
s.add(TPlusSyncError(batch_no=current, entity_type='system', source_table='system', error_code='SOURCE_TIMEOUT', error_message='固定超时说明'))
|
||||
s.commit()
|
||||
outcome = automation.tick(s, settings=auto_source, now=NOW + timedelta(hours=i, minutes=20))
|
||||
assert outcome['queued'] == (i < 2)
|
||||
if i < 2:
|
||||
child = s.scalar(select(TPlusSyncBatch).where(TPlusSyncBatch.batch_no == outcome['batch_no']))
|
||||
assert child.parent_batch_no == current
|
||||
current = child.batch_no
|
||||
assert s.scalar(select(func.count()).select_from(TPlusSyncBatch).where(TPlusSyncBatch.source_database == auto_source.t_plus_database)) == 3
|
||||
|
||||
|
||||
def test_人工恢复成功后不再对原批次自动重试(auto_source):
|
||||
from app.schemas.tplus import TPlusRetryIn
|
||||
from app.services.tplus import task_service
|
||||
|
||||
with get_session_factory()() as s:
|
||||
first = automation.tick(s, settings=auto_source, now=NOW)
|
||||
original = s.scalar(select(TPlusSyncBatch).where(TPlusSyncBatch.batch_no == first['batch_no']))
|
||||
original.status, original.finished_at = 'FAILED', NOW
|
||||
s.add(TPlusSyncError(batch_no=original.batch_no, entity_type='system', source_table='system',
|
||||
error_code='SOURCE_TIMEOUT', error_message='固定说明'))
|
||||
s.commit()
|
||||
manual = task_service.retry_task(s, original.batch_no, TPlusRetryIn(request_key=uuid4()))
|
||||
manual.status, manual.finished_at = 'SUCCEEDED', NOW + timedelta(minutes=2)
|
||||
s.commit()
|
||||
assert not automation.tick(s, settings=auto_source, now=NOW + timedelta(minutes=10))['queued']
|
||||
state = s.scalar(select(TPlusAutomationState).where(TPlusAutomationState.source_database == auto_source.t_plus_database))
|
||||
assert state.current_batch_no == manual.batch_no and state.last_success_at == manual.finished_at
|
||||
|
||||
|
||||
def test_人工与自动竞争同一失败批次只新增一个重试(auto_source):
|
||||
from app.core.exceptions import BadRequestError
|
||||
from app.schemas.tplus import TPlusRetryIn
|
||||
from app.services.tplus import task_service
|
||||
|
||||
with get_session_factory()() as s:
|
||||
first = automation.tick(s, settings=auto_source, now=NOW)
|
||||
original = s.scalar(select(TPlusSyncBatch).where(TPlusSyncBatch.batch_no == first['batch_no']))
|
||||
original.status, original.finished_at = 'FAILED', NOW
|
||||
s.add(TPlusSyncError(batch_no=original.batch_no, entity_type='system', source_table='system',
|
||||
error_code='SOURCE_TIMEOUT', error_message='固定说明'))
|
||||
s.commit()
|
||||
|
||||
def manual():
|
||||
with get_session_factory()() as s:
|
||||
try:
|
||||
return task_service.retry_task(s, first['batch_no'], TPlusRetryIn(request_key=uuid4())).batch_no
|
||||
except BadRequestError:
|
||||
return '已有自动重试'
|
||||
|
||||
def scheduled():
|
||||
with get_session_factory()() as s:
|
||||
return automation.tick(s, settings=auto_source, now=NOW + timedelta(minutes=10))
|
||||
|
||||
with ThreadPoolExecutor(max_workers=2) as pool:
|
||||
futures = [pool.submit(manual), pool.submit(scheduled)]
|
||||
for future in futures:
|
||||
future.result(timeout=20)
|
||||
with get_session_factory()() as s:
|
||||
assert s.scalar(select(func.count()).select_from(TPlusSyncBatch).where(
|
||||
TPlusSyncBatch.source_database == auto_source.t_plus_database)) == 2
|
||||
|
||||
|
||||
def test_数据库状态默认值没有多余引号(auto_source):
|
||||
key, database = automation.source_identity(auto_source)
|
||||
with get_session_factory()() as s:
|
||||
s.execute(text("INSERT INTO tplus_automation_states (source_key,source_database) VALUES (:key,:database)"),
|
||||
{"key": key, "database": database})
|
||||
s.commit()
|
||||
assert s.scalar(select(TPlusAutomationState.worker_phase).where(TPlusAutomationState.source_key == key)) == 'STOPPED'
|
||||
@@ -0,0 +1,334 @@
|
||||
"""MySQL 上验证新工作区权限、队列、乐观版本和正式表隔离。"""
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import delete, func, select
|
||||
|
||||
from app.core.db import get_session_factory
|
||||
from app.models import (
|
||||
MonthlySettlement,
|
||||
PerformanceLedger,
|
||||
Receipt,
|
||||
TPlusFact,
|
||||
TPlusSourceHead,
|
||||
TPlusSyncBatch,
|
||||
TPlusSyncRecord,
|
||||
TPlusWorkLine,
|
||||
TPlusWorkRun,
|
||||
)
|
||||
from app.services.tplus import workbench, worker
|
||||
from app.services.tplus.mapping import get_mapping
|
||||
from app.services.tplus.source_heads import observe_rows
|
||||
from app.services.tplus.staging import stage_row
|
||||
|
||||
BASE = "/api/v1/system/tplus"
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def workspace(monkeypatch):
|
||||
from app.core.config import Settings
|
||||
|
||||
source = "wb_test_" + uuid4().hex[:16]
|
||||
settings = Settings(
|
||||
_env_file=None,
|
||||
app_secret_key="x" * 32,
|
||||
t_plus_enabled=True,
|
||||
t_plus_database=source,
|
||||
t_plus_user="reader",
|
||||
t_plus_odbc_driver="unit",
|
||||
)
|
||||
monkeypatch.setattr("app.services.tplus.task_service.get_settings", lambda: settings)
|
||||
monkeypatch.setattr(workbench, "_source_database", lambda: source)
|
||||
monkeypatch.setattr("app.api.v1.system.tplus_workbench._source_database", lambda: source)
|
||||
with get_session_factory()() as db:
|
||||
yield db, source
|
||||
db.rollback()
|
||||
numbers = list(
|
||||
db.scalars(select(TPlusWorkRun.run_no).where(TPlusWorkRun.source_database == source))
|
||||
)
|
||||
batches = list(
|
||||
db.scalars(
|
||||
select(TPlusSyncBatch.batch_no).where(TPlusSyncBatch.source_database == source)
|
||||
)
|
||||
)
|
||||
db.execute(delete(TPlusWorkLine).where(TPlusWorkLine.run_no.in_(numbers)))
|
||||
db.execute(delete(TPlusWorkRun).where(TPlusWorkRun.source_database == source))
|
||||
db.execute(delete(TPlusFact).where(TPlusFact.source_database == source))
|
||||
db.execute(delete(TPlusSourceHead).where(TPlusSourceHead.source_database == source))
|
||||
db.execute(delete(TPlusSyncRecord).where(TPlusSyncRecord.batch_no.in_(batches)))
|
||||
db.execute(delete(TPlusSyncBatch).where(TPlusSyncBatch.batch_no.in_(batches)))
|
||||
db.commit()
|
||||
|
||||
|
||||
def test_工作区全部入口沿用管理员权限(client, normal):
|
||||
for path in (
|
||||
"/facts",
|
||||
"/work-runs",
|
||||
"/source-records?entity=personnel",
|
||||
"/work-runs/example/report",
|
||||
):
|
||||
assert client.get(BASE + path, headers=normal["headers"]).status_code == 403
|
||||
assert (
|
||||
client.post(
|
||||
BASE + "/work-runs",
|
||||
headers=normal["headers"],
|
||||
json={
|
||||
"kind": "MASTER_PREVIEW",
|
||||
"entity_types": ["personnel"],
|
||||
"request_key": str(uuid4()),
|
||||
},
|
||||
).status_code
|
||||
== 403
|
||||
)
|
||||
|
||||
|
||||
def test_资料乐观锁和历史在MySQL生效(client, admin, workspace):
|
||||
db, _ = workspace
|
||||
payload = {
|
||||
"kind": "PRODUCT",
|
||||
"fact_key": "9",
|
||||
"expected_version": 0,
|
||||
"evidence": "隔离测试:资料版本",
|
||||
"data": {"category": "municipal_water", "unit": "件"},
|
||||
}
|
||||
first = client.post(BASE + "/facts", headers=admin["headers"], json=payload)
|
||||
assert first.status_code == 200
|
||||
assert client.post(BASE + "/facts", headers=admin["headers"], json=payload).status_code == 400
|
||||
payload.update(expected_version=1, evidence="隔离测试:修改分类")
|
||||
payload["data"]["category"] = "rail_transit"
|
||||
second = client.post(BASE + "/facts", headers=admin["headers"], json=payload)
|
||||
assert second.json()["data"]["version"] == 2
|
||||
result = client.get(
|
||||
BASE + f"/facts/{first.json()['data']['id']}/history", headers=admin["headers"]
|
||||
)
|
||||
assert result.json()["data"]["total"] == 2
|
||||
assert db.scalar(select(func.count()).select_from(TPlusFact)) >= 2
|
||||
|
||||
|
||||
def test_MySQL队列预览和CSV不改正式工资(client, admin, workspace):
|
||||
db, source = workspace
|
||||
batch_no = "WB" + uuid4().hex[:20]
|
||||
source_id = str(100000000 + int(uuid4().hex[:8], 16))
|
||||
batch = TPlusSyncBatch(
|
||||
batch_no=batch_no,
|
||||
source_database=source,
|
||||
sync_mode="FULL",
|
||||
status="SUCCEEDED",
|
||||
entity_types_json=["products"],
|
||||
audit_result_json={"observed_entities": ["products"], "source_head_version": 1},
|
||||
)
|
||||
db.add(batch)
|
||||
db.flush()
|
||||
row = {
|
||||
"id": int(source_id),
|
||||
"code": "=SOURCE",
|
||||
"name": "t_workbench_product",
|
||||
"unit_name": "件",
|
||||
}
|
||||
stage_row(db, batch_no=batch_no, entity_type="products", source_table="AA_Inventory", row=row)
|
||||
observe_rows(db, batch, get_mapping("products"), [row])
|
||||
db.commit()
|
||||
before = {
|
||||
m.__tablename__: db.scalar(select(func.count()).select_from(m))
|
||||
for m in (Receipt, PerformanceLedger, MonthlySettlement)
|
||||
}
|
||||
payload = {"kind": "MASTER_PREVIEW", "request_key": str(uuid4()), "entity_types": ["products"]}
|
||||
first = client.post(BASE + "/work-runs", headers=admin["headers"], json=payload).json()["data"]
|
||||
assert (
|
||||
client.post(BASE + "/work-runs", headers=admin["headers"], json=payload).json()["data"][
|
||||
"run_no"
|
||||
]
|
||||
== first["run_no"]
|
||||
)
|
||||
assert worker.run_once(db) == first["run_no"]
|
||||
db.expire_all()
|
||||
detail = client.get(BASE + f"/work-runs/{first['run_no']}", headers=admin["headers"]).json()[
|
||||
"data"
|
||||
]
|
||||
assert detail["summary"]["counts"] == {"PENDING": 1}
|
||||
assert detail["formal_payroll_written"] is False
|
||||
report = client.get(BASE + f"/work-runs/{first['run_no']}/report", headers=admin["headers"])
|
||||
assert report.status_code == 200 and "未写入正式工资" in report.text
|
||||
response = client.get(BASE + "/source-records?entity=products", headers=admin["headers"])
|
||||
assert response.status_code == 200, response.json()
|
||||
choices = response.json()["data"]
|
||||
assert choices["total"] == 1 and choices["list"][0]["source_id"] == source_id
|
||||
assert before == {
|
||||
m.__tablename__: db.scalar(select(func.count()).select_from(m))
|
||||
for m in (Receipt, PerformanceLedger, MonthlySettlement)
|
||||
}
|
||||
|
||||
|
||||
def test_MySQL完整预演冻结重跑且正式表不变(client, admin, workspace):
|
||||
db, source = workspace
|
||||
n = 100000000 + int(uuid4().hex[:8], 16)
|
||||
ship_id, line_id, cash_id = map(str, (n, n + 1, n + 2))
|
||||
entities = {
|
||||
"shipments": [
|
||||
{
|
||||
"id": n,
|
||||
"code": "SH-TEST",
|
||||
"idcustomer": 8,
|
||||
"voucherdate": "2026-03-10",
|
||||
"voucherState": 189,
|
||||
"isCancel": 428,
|
||||
}
|
||||
],
|
||||
"shipment_items": [
|
||||
{
|
||||
"id": n + 1,
|
||||
"source_parent_id": n,
|
||||
"idinventory": 9,
|
||||
"taxAmount": "100",
|
||||
"quantity": "2",
|
||||
}
|
||||
],
|
||||
"receipts": [
|
||||
{
|
||||
"id": n + 2,
|
||||
"code": "RC-TEST",
|
||||
"idpartner": 8,
|
||||
"voucherDate": "2026-03-10",
|
||||
"isReceiveFlag": 1,
|
||||
"voucherstate": 189,
|
||||
"amount": "100",
|
||||
"accounting_voucher_count": 1,
|
||||
"valid_accounting_voucher_count": 1,
|
||||
"accounting_voucher_id": 123,
|
||||
"accounting_voucher_code": "GL-TEST-UNREVIEWED",
|
||||
},
|
||||
{
|
||||
"id": n + 3,
|
||||
"code": "RC-NOT-GENERATED",
|
||||
"idpartner": 8,
|
||||
"voucherDate": "2026-03-10",
|
||||
"isReceiveFlag": 1,
|
||||
"voucherstate": 189,
|
||||
"amount": "45",
|
||||
"accounting_voucher_count": 0,
|
||||
"valid_accounting_voucher_count": 0,
|
||||
},
|
||||
],
|
||||
}
|
||||
for entity, rows in entities.items():
|
||||
batch_no = "WB" + uuid4().hex[:20]
|
||||
batch = TPlusSyncBatch(
|
||||
batch_no=batch_no,
|
||||
source_database=source,
|
||||
sync_mode="FULL",
|
||||
status="SUCCEEDED",
|
||||
entity_types_json=[entity],
|
||||
audit_result_json={"observed_entities": [entity]},
|
||||
)
|
||||
db.add(batch)
|
||||
db.flush()
|
||||
for row in rows:
|
||||
stage_row(
|
||||
db,
|
||||
batch_no=batch_no,
|
||||
entity_type=entity,
|
||||
source_table=get_mapping(entity).source_table,
|
||||
row=row,
|
||||
)
|
||||
observe_rows(db, batch, get_mapping(entity), rows)
|
||||
db.commit()
|
||||
facts = [
|
||||
(
|
||||
"PERSONNEL",
|
||||
"001",
|
||||
{"code": "001", "name": "测试人员", "role_code": "salesperson", "status": 1},
|
||||
),
|
||||
("PRODUCT", "9", {"category": "municipal_water", "unit": "件"}),
|
||||
("CUSTOMER", "8", {"customer_type": "personal", "owner_name": "测试负责人"}),
|
||||
(
|
||||
"GUIDANCE",
|
||||
"price9",
|
||||
{"product_id": "9", "month": "2026-03", "basis": "UNIT", "amount": "25", "unit": "件"},
|
||||
),
|
||||
(
|
||||
"SHIPMENT",
|
||||
line_id,
|
||||
{
|
||||
"sales_amount_excludes_freight": False,
|
||||
"freight_amount": "10",
|
||||
"business_key": "test",
|
||||
"personnel_id": "001",
|
||||
},
|
||||
),
|
||||
(
|
||||
"GROUP",
|
||||
"g",
|
||||
{
|
||||
"customer_id": "8",
|
||||
"shipment_ids": [ship_id],
|
||||
"receipt_ids": [cash_id, str(n + 3)],
|
||||
"scope_confirmed": True,
|
||||
"opening_confirmed": True,
|
||||
"opening_balance": "0",
|
||||
"historical_settlement_confirmed": True,
|
||||
"guidance_month_basis": "SHIPMENT",
|
||||
},
|
||||
),
|
||||
]
|
||||
for kind, key, data in facts:
|
||||
r = client.post(
|
||||
BASE + "/facts",
|
||||
headers=admin["headers"],
|
||||
json={
|
||||
"kind": kind,
|
||||
"fact_key": key,
|
||||
"expected_version": 0,
|
||||
"evidence": "隔离测试资料,不是真实客户规则",
|
||||
"data": data,
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200, r.json()
|
||||
before = {
|
||||
m.__tablename__: db.scalar(select(func.count()).select_from(m))
|
||||
for m in (Receipt, PerformanceLedger, MonthlySettlement)
|
||||
}
|
||||
|
||||
def execute(**values):
|
||||
r = client.post(
|
||||
BASE + "/work-runs",
|
||||
headers=admin["headers"],
|
||||
json={"kind": "CALC_PREVIEW", "request_key": str(uuid4()), **values},
|
||||
)
|
||||
assert r.status_code == 200, r.json()
|
||||
number = r.json()["data"]["run_no"]
|
||||
assert worker.run_once(db) == number
|
||||
return client.get(BASE + f"/work-runs/{number}", headers=admin["headers"]).json()["data"]
|
||||
|
||||
first = execute(month="2026-03", group_keys=["g"])
|
||||
assert first["status"] == "SUCCEEDED", first
|
||||
assert first["summary"]["groups"]["g"]["prepayment_remaining"] == "10.00"
|
||||
assert first["summary"]["groups"]["g"]["receipt_total"] == "100.00"
|
||||
assert first["summary"]["groups"]["g"]["allocated_total"] == "100.00"
|
||||
assert first["summary"]["groups"]["g"]["excluded_receipt_total"] == "45.00"
|
||||
lines = client.get(
|
||||
BASE + f"/work-runs/{first['run_no']}/lines", headers=admin["headers"]
|
||||
).json()["data"]["list"]
|
||||
shipment_line = next(row for row in lines if row["data"].get("kind") == "SHIPMENT")
|
||||
assert shipment_line["data"]["accounting_voucher_code"] == "GL-TEST-UNREVIEWED"
|
||||
assert any(row["action"] == "EXCLUDED" for row in lines)
|
||||
r = client.post(
|
||||
BASE + "/facts",
|
||||
headers=admin["headers"],
|
||||
json={
|
||||
"kind": "GUIDANCE",
|
||||
"fact_key": "price9",
|
||||
"expected_version": 1,
|
||||
"evidence": "后续价格变化不应修改原运行",
|
||||
"data": {**facts[3][2], "amount": "40"},
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
repeated = execute(replay_no=first["run_no"])
|
||||
assert repeated["snapshot_hash"] == first["snapshot_hash"]
|
||||
assert repeated["summary"] == first["summary"]
|
||||
assert before == {
|
||||
m.__tablename__: db.scalar(select(func.count()).select_from(m))
|
||||
for m in (Receipt, PerformanceLedger, MonthlySettlement)
|
||||
}
|
||||
Reference in new issue
Block a user