feat(核算)!: 添加 T+ 正式核算与采集托管

补齐源快照、资料预演、正式导入及工资核算的依赖链路。
新增版本化输入、逐段 FIFO、生成锁和结构化当月调整,
并提供默认关闭的周期采集、有限重试及 Windows 托管。

原始客户资料、私有配置及首页改动不纳入本次提交。

BREAKING CHANGE: 升级前需应用新增迁移,并通过
PAYROLL_MAKER_USER_ID 绑定实际工资制作人,否则业务只读。
旧单批次核算默认关闭;历史兼容需显式启用
PAYROLL_ALLOW_LEGACY_CALCULATION。生产启用和真实金额待验收。
This commit is contained in:
liuwenxin committed 2026-11-02 09:42:53 +08:00
1 parent 1b074576fe
commit 108bf87225
154 files changed
+9515 -645

No files matched your search

+10
View File
@@ -316,3 +316,13 @@ uv run pytest tests/integration -q # 仅集成
| 报表统计 | ✅ 已完成 | 2026-10-19 完成并补齐回归;2026-10-20 e2e `report_flow` 12/12 实跑通过,全量 862 passed |
> 系统管理模块(用户/角色/日志查询)已于 2026-10-10 完成并补齐用例,见上表。
## 2026-10-30 正式工资闭环与新回执验证
新增 `test_formal_payroll.py`(单元与MySQL集成)、隔离库保护用例;旧基础数据用例显式使用writer身份,历史公式/比例审批兼容用例显式启用legacy_calculation,不用测试放宽生产授权。
最终完整单元749项通过。隔离完整集成基线396通过、1个旧夹具权限失败,已修复并在75项受影响模块回归中通过;不将此记录冒充最终代码一次性全量集成通过。V18/V19迁移往返及结构9项通过。覆盖默认无审批痕迹、可选审核开启、客户名头合并FIFO、发货月成本封存、逐段金额与防重、未生成工资月份级联重算、固化分管和当月调整。详细证据见 `docs/正式工资闭环开发记录_2026-10-30.md`。
**禁止直接对原业务库执行集成测试。** 运行 `python scripts/run_isolated_tests.py` 创建受限测试库;夹具在执行前及清理前核验显式标记、名称及真实引擎目标。运行器保留隔离库便于复查,不打印临时凭据。
最新完整集成补验:全部29个测试文件分两套独立数据库覆盖,215+184=399项通过,失败0、跳过0。上述旧夹具失败已经修复并关闭;最终结果以本条和开发记录末节为准。完整单元749通过,V18/V19往返及结构9通过。
+34 -1
View File
@@ -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
+33 -33
View File
@@ -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
+69 -69
View File
@@ -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, "失败操作未留痕"
+9 -5
View File
@@ -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
+41 -41
View File
@@ -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)
+1 -1
View File
@@ -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"]
+48 -48
View File
@@ -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, "失败操作未留痕"
+49 -49
View File
@@ -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, "失败操作未留痕"
+29 -29
View File
@@ -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, "失败操作未留痕"
+34 -34
View File
@@ -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, "失败操作未留痕"
+1 -1
View File
@@ -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"
+34 -34
View File
@@ -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)
}
+12
View File
@@ -0,0 +1,12 @@
"""清理夹具只能运行在显式指定的独立测试库中。"""
import re
def ensure_test_database(database: str, expected: str | None) -> None:
if (
not expected
or database != expected
or not re.fullmatch(r"payroll_(?:wbtest|test)_[0-9a-f]{8,32}", database)
):
raise RuntimeError("拒绝清理非隔离测试库:请通过隔离测试运行器设置 PAYROLL_TEST_DATABASE")
+465
View File
@@ -0,0 +1,465 @@
"""正式链路使用独立内存库,验证金额、防重、生成锁与历史归属。"""
import copy
from datetime import date
from decimal import Decimal
from types import SimpleNamespace
from uuid import uuid4
import pytest
from sqlalchemy import func, select
from app.core.config import get_settings
from app.core.exceptions import BadRequestError, ForbiddenError
from app.models import (
Business,
BusinessRole,
Customer,
PayrollAllocation,
PayrollInput,
PayrollMonth,
PerformanceLedger,
Personnel,
Product,
Receipt,
ShipmentItem,
TPlusSourceLink,
TPlusWorkRun,
User,
)
from app.services import manager_share, payroll_calculation, payroll_months
from app.services.payroll_access import can_write_payroll
from app.services.tplus.business_import import execute_business
from app.services.tplus.workbench import digest
from tests.unit.test_tplus_workbench import apply, fact, prepare_calc, preview
from tests.unit.test_tplus_workbench import work_session as work_session
@pytest.fixture
def payroll_case(work_session, monkeypatch):
s = work_session
user = User(username="test_maker", password="test_hash_only", status=1)
s.add(user)
role = BusinessRole(
role_name="经理",
role_code="sales_manager",
calc_template="SALES_MANAGER",
status=1,
sort_order=2,
)
s.add(role)
s.flush()
manager = Personnel(
personnel_name="测试经理", business_role_id=role.id, status=1, source_no="90001"
)
s.add(manager)
s.commit()
monkeypatch.setattr(get_settings(), "payroll_maker_user_id", user.id)
prepare_calc(s)
apply(s, preview(s, ["personnel"]))
product = Product(
product_name="测试产品",
spec_model="测试规格",
unit="件",
product_category="municipal_water",
status=1,
)
customer = Customer(company_name="测试客户", owner_name="负责人", customer_type="personal")
s.add_all([product, customer])
s.flush()
s.add_all(
[
TPlusSourceLink(
source_database="test_source",
entity_type="products",
source_id="9",
target_id=product.id,
),
TPlusSourceLink(
source_database="test_source",
entity_type="customers",
source_id="8",
target_id=customer.id,
),
]
)
s.commit()
s.add(
TPlusSourceLink(
source_database="test_source",
entity_type="personnel",
source_id="90001",
target_id=manager.id,
)
)
s.commit()
fact(
s,
"PERSONNEL",
"90001",
{
"code": "90001",
"name": manager.personnel_name,
"role_code": "sales_manager",
"status": 1,
},
)
fact(
s,
"MANAGER",
"manager-biz",
{"business_key": "biz", "manager_id": "90001", "effective_date": "2026-03-01"},
)
from app.models import TPlusFact
guidance = s.scalar(select(TPlusFact).where(TPlusFact.kind == "GUIDANCE"))
guidance.data_json = {**guidance.data_json, "is_finalized": True}
s.commit()
return s, user, manager
def business_work(s, user, kind, previous=None):
run = TPlusWorkRun(
run_no=uuid4().hex,
request_key=str(uuid4()),
source_database="test_source",
requested_by=user.id,
kind=kind,
status="RUNNING",
input_json={"preview_no": previous.run_no}
if previous
else {"month": "2026-03", "group_keys": ["g"]},
)
s.add(run)
s.flush()
execute_business(s, run)
s.commit()
return run
def imported(case):
s, user, _manager = case
pre = business_work(s, user, "BUSINESS_PREVIEW")
assert pre.status == "SUCCEEDED", pre.summary_json
assert pre.summary_json["counts"] == {"CREATE": 1}
run = business_work(s, user, "BUSINESS_APPLY", pre)
assert run.status == "SUCCEEDED", run.summary_json
return s.scalar(select(Business))
def test_指定账号而非管理员或岗位放行(payroll_case):
_, user, _ = payroll_case
assert can_write_payroll(user)
assert not can_write_payroll(SimpleNamespace(id=user.id + 1, status=1))
assert not can_write_payroll(None)
def test_正式导入防重与来源可追溯(payroll_case):
s, user, _ = payroll_case
business = imported(payroll_case)
assert s.scalar(select(func.count()).select_from(Receipt)) == 1
assert s.scalar(select(func.count()).select_from(ShipmentItem)) == 1
again = business_work(s, user, "BUSINESS_PREVIEW")
assert again.summary_json["counts"] == {"SKIP": 1}
business_work(s, user, "BUSINESS_APPLY", again)
assert s.scalar(select(func.count()).select_from(Business)) == 1
record = payroll_calculation.latest_input(s, business.id)
assert record.data_json["source_facts"]
def test_正式FIFO台账金额与逐段过程守恒(payroll_case):
s, user, manager = payroll_case
business = imported(payroll_case)
result = payroll_calculation.calculate(s, business.id, "2026-03", operator=user)
assert result["performance"] == "0.20"
assert result["payable"] == "0.20"
ledger = s.get(PerformanceLedger, result["ledger_ids"][0])
assert ledger.snapshot_json["manager_id"] == manager.id
assert s.scalar(select(PayrollAllocation)).amount == Decimal("100.00")
with pytest.raises(BadRequestError, match="已有核算"):
payroll_calculation.calculate(s, business.id, "2026-03", operator=user)
def test_输入变化使旧预览失效(payroll_case):
s, user, _ = payroll_case
pre = business_work(s, user, "BUSINESS_PREVIEW")
product = s.scalar(select(Product))
product.product_category = "rail_transit"
s.commit()
with pytest.raises(ValueError, match="PREVIEW_STALE"):
business_work(s, user, "BUSINESS_APPLY", pre)
s.rollback()
assert s.scalar(select(func.count()).select_from(Business)) == 0
def test_经理汇总读取固化业务分管(payroll_case):
s, user, manager = payroll_case
business = imported(payroll_case)
payroll_calculation.calculate(s, business.id, "2026-03", operator=user)
rows = list(s.scalars(select(PerformanceLedger)))
owner = s.get(Personnel, rows[0].personnel_id)
owner.manager_id = None
s.commit()
buckets = manager_share.collect_team_buckets(s, rows, False)
assert buckets[manager.id]["performance"] == Decimal("0.20")
def test_生成标记独立于审批状态(payroll_case):
s, user, _ = payroll_case
control = payroll_months.lock_month(s, "2026-03", user)
from datetime import datetime
control.generated_at = datetime(2026, 3, 31)
s.commit()
with pytest.raises(BadRequestError, match="已锁定"):
payroll_months.ensure_allocation_editable(s, "2026-03", user)
s.rollback()
assert payroll_months.ensure_allocation_editable(s, "2026-04", user).generated_at is None
def test_生成失败不留下月份锁(payroll_case):
s, user, _ = payroll_case
from app.services.settlement_service import generate
with pytest.raises(BadRequestError, match="无可结算"):
generate(s, "2026-03", operator=user)
assert s.scalar(select(PayrollMonth)) is None
def test_非制作人无法生成工资(payroll_case):
s, user, _ = payroll_case
with pytest.raises(ForbiddenError):
payroll_months.lock_month(s, "2026-03", SimpleNamespace(id=user.id + 1, status=1))
def replace_input(s, record, values):
newer = PayrollInput(
business_id=record.business_id,
version=record.version + 1,
source_database=record.source_database,
group_key=record.group_key,
fingerprint=digest(values),
data_json=values,
requested_by=record.requested_by,
)
s.add(newer)
s.commit()
return newer
@pytest.mark.parametrize(
"amount,performance,payable,start",
[("50", "0.10", "0.00", False), ("60", "0.12", "0.04", True), ("100", "0.20", "0.20", True)],
)
def test_起付百分之六十与平方权重(payroll_case, amount, performance, payable, start):
s, user, _ = payroll_case
business = imported(payroll_case)
record = payroll_calculation.latest_input(s, business.id)
data = copy.deepcopy(record.data_json)
data["receipts"][0]["amount"] = amount
receipt = s.scalar(select(Receipt))
receipt.amount = Decimal(amount)
replace_input(s, record, data)
result = payroll_calculation.calculate(s, business.id, "2026-03", operator=user)
assert (result["performance"], result["payable"], result["start_payment_ok"]) == (
performance,
payable,
start,
)
def test_跨月续算仅计应付差额且离职未发给接手人(payroll_case):
s, user, _manager = payroll_case
business = imported(payroll_case)
record = payroll_calculation.latest_input(s, business.id)
data = copy.deepcopy(record.data_json)
receipt = s.scalar(select(Receipt))
receipt.amount = Decimal("80")
data["receipts"][0]["amount"] = "80"
record = replace_input(s, record, data)
march = payroll_calculation.calculate(s, business.id, "2026-03", operator=user)
assert march["payable"] == "0.10"
old = s.get(PerformanceLedger, march["ledger_ids"][0])
new_person = Personnel(
personnel_name="接手业务员",
business_role_id=s.get(Personnel, old.personnel_id).business_role_id,
status=1,
)
s.add(new_person)
s.flush()
second = Receipt(
business_id=business.id,
receipt_batch_no="HK-NEXT",
receipt_date=date(2026, 4, 10),
amount=Decimal("20"),
is_barter=0,
settlement_status=0,
)
s.add(second)
s.flush()
data = copy.deepcopy(record.data_json)
data["receipts"].append(
{
"source_id": "11",
"source_no": "HK-NEXT",
"date": "2026-04-10",
"group_key": "g",
"amount": "20",
"voucher_confirmed": True,
}
)
data["maps"]["receipts"]["11"] = second.id
data["maps"]["personnel"]["next-person"] = new_person.id
data["facts"]["personnel"]["next-person"] = {"role_code": "salesperson", "status": 1}
data["facts"]["handovers"] = [
{
"mode": "RESIGNATION",
"business_key": "biz",
"from_personnel_id": "00043",
"to_personnel_id": "next-person",
"effective_date": "2026-04-01",
}
]
replace_input(s, record, data)
april = payroll_calculation.calculate(s, business.id, "2026-04", operator=user)
assert april["payable"] == "0.10"
assert old.payable_amount == Decimal("0.10") and old.personnel_id != new_person.id
assert all(
s.get(PerformanceLedger, lid).personnel_id == new_person.id for lid in april["ledger_ids"]
)
assert sum(s.scalars(select(PerformanceLedger.payable_amount))) == Decimal("0.20")
rebuilt = payroll_calculation.calculate(
s, business.id, "2026-03", operator=user, reason="复核分次回款"
)
assert rebuilt["recalculated_months"] == ["2026-03", "2026-04"]
active = list(s.scalars(select(PerformanceLedger).where(PerformanceLedger.is_active == 1)))
assert sum(r.payable_amount for r in active) == Decimal("0.20")
assert all(r.calc_reason == "复核分次回款" for r in active)
def test_已核算源金额变化不静默改工资(payroll_case):
s, user, _ = payroll_case
business = imported(payroll_case)
receipt = s.scalar(select(Receipt))
receipt.amount = Decimal("99")
s.commit()
with pytest.raises(BadRequestError, match="不一致"):
payroll_calculation.calculate(s, business.id, "2026-03", operator=user)
assert s.scalar(select(func.count()).select_from(PerformanceLedger)) == 0
def test_同客户不同合同合并平账_一款对应多发货(payroll_case):
from tests.unit.test_tplus_workbench import source
s, user, _ = payroll_case
source(
s,
"shipments",
[
{
"id": 20,
"code": "SH-ONE",
"idcustomer": 8,
"voucherdate": "2026-03-10",
"voucherState": 189,
},
{
"id": 22,
"code": "SH-TWO",
"idcustomer": 8,
"voucherdate": "2026-03-11",
"voucherState": 189,
},
],
)
source(
s,
"shipment_items",
[
{
"id": 21,
"source_parent_id": 20,
"idinventory": 9,
"taxAmount": "100",
"quantity": "2",
},
{
"id": 23,
"source_parent_id": 22,
"idinventory": 9,
"taxAmount": "100",
"quantity": "2",
},
],
)
source(
s,
"receipts",
[
{
"id": 10,
"code": "RC-ONE",
"idpartner": 8,
"voucherDate": "2026-03-12",
"isReceiveFlag": 1,
"voucherstate": 189,
"amount": "200",
}
],
)
fact(
s,
"SHIPMENT",
"23",
{
"sales_amount_excludes_freight": True,
"business_key": "second-contract",
"personnel_id": "00043",
},
)
fact(
s,
"MANAGER",
"second-manager",
{"business_key": "second-contract", "manager_id": "90001", "effective_date": "2026-03-01"},
)
business = imported(payroll_case)
assert s.scalar(select(func.count()).select_from(Business)) == 1
result = payroll_calculation.calculate(s, business.id, "2026-03", operator=user)
assert len(result["ledger_ids"]) == 2
assert sum(s.scalars(select(PayrollAllocation.amount))) == Decimal("200.00")
record = payroll_calculation.latest_input(s, business.id)
assert record.data_json["group"]["shipment_ids"] == ["20", "22"]
def test_封存发货月指导成本不随后续回款改变(payroll_case):
from app.models import TPlusFact
from app.schemas.tplus_workbench import FactIn
from app.services.tplus import workbench
s, user, _ = payroll_case
cost = s.scalar(select(TPlusFact).where(TPlusFact.kind == "GUIDANCE"))
with pytest.raises(BadRequestError, match="已封存"):
workbench.save_fact(
s,
FactIn(
kind="GUIDANCE",
fact_key=cost.fact_key,
expected_version=cost.version,
evidence="试图修改已封存月份",
data={**cost.data_json, "amount": "30"},
),
user.id,
)
def test_同客户旧项目期初不一致必须先合并事实(payroll_case):
from app.models import TPlusFact
s, user, _ = payroll_case
group = s.scalar(select(TPlusFact).where(TPlusFact.kind == "GROUP")).data_json
fact(s, "GROUP", "old-contract-group", {**group, "opening_balance": "10"})
run = business_work(s, user, "BUSINESS_PREVIEW")
assert run.status == "PARTIAL"
assert run.summary_json["counts"] == {"PENDING": 1}
assert s.scalar(select(func.count()).select_from(Business)) == 0
+3
View File
@@ -12,6 +12,9 @@ from sqlalchemy.dialects import mysql
from app.models import Base
EXPECTED_TABLES = {
"tplus_automation_states",
"payroll_months", "payroll_inputs", "payroll_allocations",
"tplus_source_heads", "tplus_facts", "tplus_source_links", "tplus_work_runs", "tplus_work_lines",
"users",
"roles",
"user_roles",
@@ -0,0 +1,23 @@
"""隔离库校验拒绝开发库、相似前缀及遗漏授权标记。"""
import pytest
from tests.isolation import ensure_test_database
@pytest.mark.parametrize(
"database,expected",
[
("payroll", "payroll"),
("payroll_test_prod", "payroll_test_prod"),
("payroll_wbtest_1234abcd", None),
("payroll_wbtest_1234abcd", "payroll_wbtest_5678abcd"),
],
)
def test_拒绝非隔离目标(database, expected):
with pytest.raises(RuntimeError, match="拒绝清理"):
ensure_test_database(database, expected)
def test_接受显式隔离库():
ensure_test_database("payroll_wbtest_1234abcd", "payroll_wbtest_1234abcd")
+160
View File
@@ -0,0 +1,160 @@
"""周期和自动重试只能生成原始采集,验证重启防重与人工介入边界。"""
from datetime import datetime, timedelta
import pytest
from sqlalchemy import func, select
from app.core.config import Settings
from app.models import TPlusAutomationState, TPlusSyncBatch, TPlusSyncError
from app.services.tplus import automation
from tests.unit.test_tplus_workbench import work_session as work_session
NOW = datetime(2026, 10, 30, 10)
@pytest.fixture
def settings():
return Settings(_env_file=None, app_secret_key="x" * 32, t_plus_enabled=True,
t_plus_database="test_source", t_plus_user="reader", t_plus_odbc_driver="unit",
t_plus_automation_enabled=True)
def run(s, settings, now=NOW):
return automation.tick(s, settings=settings, now=now)
def fail(s, number, *, status="FAILED", code="SOURCE_TIMEOUT", at=NOW):
row = s.scalar(select(TPlusSyncBatch).where(TPlusSyncBatch.batch_no == number))
row.status, row.finished_at = status, at
s.add(TPlusSyncError(batch_no=number, entity_type="system", source_table="system", error_code=code, error_message="固定错误说明"))
s.commit()
def test_默认关闭不会排队(work_session, settings):
result = run(work_session, settings.model_copy(update={"t_plus_automation_enabled": False}))
assert result == {"status": "DISABLED", "queued": False}
assert work_session.scalar(select(func.count()).select_from(TPlusSyncBatch)) == 0
def test_同周期重复检查和重启只有一次九类采集(work_session, settings):
s = work_session
first = run(s, settings)
for _ in range(3):
assert not run(s, settings)["queued"]
batch = s.scalar(select(TPlusSyncBatch))
assert batch.batch_no == first["batch_no"] and batch.sync_mode == "FULL"
assert len(batch.entity_types_json) == 9 and batch.trigger_type == "SCHEDULED"
assert s.scalar(select(func.count()).select_from(TPlusSyncBatch)) == 1
def test_停机多周只补最近一次(work_session, settings):
s = work_session
first = run(s, settings)
batch = s.scalar(select(TPlusSyncBatch))
batch.status, batch.finished_at = "SUCCEEDED", NOW
s.commit()
later = run(s, settings, NOW + timedelta(weeks=4))
assert later["queued"] and later["period"] != first["period"]
assert s.scalar(select(func.count()).select_from(TPlusSyncBatch)) == 2
def test_人工任务占用时不堆积周期任务(work_session, settings):
s = work_session
s.add(TPlusSyncBatch(batch_no="manual", sync_mode="FULL", status="RUNNING", source_database="test_source", entity_types_json=["personnel"]))
s.commit()
assert run(s, settings)["status"] == "DEFERRED"
assert s.scalar(select(func.count()).select_from(TPlusSyncBatch)) == 1
def test_明确超时仅按五十五分钟最多重试两次(work_session, settings):
s = work_session
first = run(s, settings)
fail(s, first["batch_no"])
assert not run(s, settings, NOW + timedelta(minutes=4))["queued"]
second = run(s, settings, NOW + timedelta(minutes=5))
assert second["queued"]
fail(s, second["batch_no"], at=NOW + timedelta(minutes=5))
assert not run(s, settings, NOW + timedelta(minutes=19))["queued"]
third = run(s, settings, NOW + timedelta(minutes=20))
assert third["queued"]
fail(s, third["batch_no"], at=NOW + timedelta(minutes=21))
assert not run(s, settings, NOW + timedelta(hours=2))["queued"]
rows = list(s.scalars(select(TPlusSyncBatch).order_by(TPlusSyncBatch.id)))
assert [(r.trigger_type, r.automatic_attempt) for r in rows] == [("SCHEDULED", 0), ("AUTO_RETRY", 1), ("AUTO_RETRY", 2)]
assert rows[1].parent_batch_no == rows[0].batch_no and rows[2].parent_batch_no == rows[1].batch_no
@pytest.mark.parametrize("status,code", [("PARTIAL", "SOURCE_TIMEOUT"), ("FAILED", "SOURCE_CONFIG_ERROR"), ("FAILED", "STAGE_ERROR"), ("FAILED", "EXECUTION_ERROR")])
def test_部分失败或不明确错误不自动重试(work_session, settings, status, code):
s = work_session
first = run(s, settings)
fail(s, first["batch_no"], status=status, code=code)
assert not run(s, settings, NOW + timedelta(hours=2))["queued"]
assert s.scalar(select(func.count()).select_from(TPlusSyncBatch)) == 1
def test_ISO年周和到期时间边界稳定():
assert automation.due_period(datetime(2027, 1, 1, 10))[0] == "2026-W53"
assert automation.due_period(datetime(2026, 11, 2, 1, 59))[0] == "2026-W44"
assert automation.due_period(datetime(2026, 11, 2, 2))[0] == "2026-W45"
def test_站内提示覆盖排队超时和无成功同步(work_session, settings):
s = work_session
run(s, settings)
batch = s.scalar(select(TPlusSyncBatch))
batch.create_time = NOW - timedelta(minutes=16)
s.commit()
result = automation.overview(s, settings=settings, now=NOW)
assert {r["code"] for r in result["alerts"]} >= {"QUEUE_OVERDUE", "SYNC_STALE", "WORKER_OFFLINE"}
def test_读取心跳不把当前采集写成工资(work_session, settings):
from sqlalchemy.orm import sessionmaker
s = work_session
automation.heartbeat(sessionmaker(s.get_bind()), settings, "owner", "RUNNING", now=NOW)
s.expire_all()
state = s.scalar(select(TPlusAutomationState))
assert state.heartbeat_at == NOW and state.worker_phase == "RUNNING"
assert s.scalar(select(func.count()).select_from(TPlusSyncBatch)) == 0
@pytest.mark.parametrize("status,code", [("SUCCEEDED", None), ("FAILED", "SOURCE_CONFIG_ERROR"), ("FAILED", "SOURCE_TIMEOUT")])
def test_人工重试结果接入周期且不重置自动次数(work_session, settings, monkeypatch, status, code):
from uuid import uuid4
from app.schemas.tplus import TPlusRetryIn
from app.services.tplus import task_service
monkeypatch.setattr(task_service, "get_settings", lambda: settings)
s = work_session
first = run(s, settings)
fail(s, first["batch_no"])
auto = run(s, settings, NOW + timedelta(minutes=5))
fail(s, auto["batch_no"], at=NOW + timedelta(minutes=5))
manual = task_service.retry_task(s, auto["batch_no"], TPlusRetryIn(request_key=uuid4()))
assert run(s, settings, NOW + timedelta(minutes=6))["status"] == "DEFERRED"
if code:
fail(s, manual.batch_no, status=status, code=code, at=NOW + timedelta(minutes=6))
else:
manual.status, manual.finished_at = status, NOW + timedelta(minutes=6)
s.commit()
outcome = run(s, settings, NOW + timedelta(minutes=30))
assert outcome["queued"] == (code == "SOURCE_TIMEOUT")
state = s.scalar(select(TPlusAutomationState))
assert state.retry_attempts == (2 if outcome["queued"] else 1)
if status == "SUCCEEDED":
assert state.last_success_at == manual.finished_at
if outcome["queued"]:
child = s.scalar(select(TPlusSyncBatch).where(TPlusSyncBatch.batch_no == outcome["batch_no"]))
assert child.parent_batch_no == manual.batch_no
def test_停止标记阻止周期排队且不报告进程离线(work_session, settings, tmp_path):
marker = tmp_path / "stop.requested"
marker.touch()
stopped = settings.model_copy(update={"t_plus_stop_file": str(marker)})
assert run(work_session, stopped)["status"] == "STOP_REQUESTED"
assert not automation.overview(work_session, settings=stopped, now=NOW)["alerts"]
+1 -1
View File
@@ -79,7 +79,7 @@ def test_T加查询文件覆盖六类实体() -> None:
def test_T加高风险映射保持待确认() -> None:
assert get_mapping("businesses").status is MappingStatus.PENDING
assert "成本口径" in get_mapping("businesses").unresolved
assert "指导价取值时点" in get_mapping("businesses").unresolved
def test_未知T加实体被拒绝() -> None:
@@ -0,0 +1,15 @@
"""只接受明确驱动状态,不把含密码的未知异常当作可重试错误。"""
import pytest
from app.services.tplus.errors import classify_source_error
@pytest.mark.parametrize("state,expected", [("08S01", "SOURCE_CONNECTION_LOST"), ("HYT00", "SOURCE_TIMEOUT"), ("28000", "SOURCE_AUTH_ERROR"), ("42S22", "SOURCE_SCHEMA_ERROR"), ("42000", "READ_ERROR")])
def test_源错误分类只依赖固定状态(state, expected):
assert classify_source_error(Exception(state, "password=不可回显")) == expected
def test_未知异常不宽泛自动重试():
assert classify_source_error(RuntimeError("secret=不可回显")) == "READ_ERROR"
assert classify_source_error(TimeoutError()) == "SOURCE_TIMEOUT"
+252
View File
@@ -0,0 +1,252 @@
"""预演只分配资金,覆盖先旧后新、预收与缺条件的核算边界。"""
from datetime import date
from decimal import Decimal
import pytest
from app.services.tplus.fifo import money, preview_group
def inputs():
"""明确口径的简单业务,测试不依赖真实客户数据。"""
group = {
"key": "g",
"scope_confirmed": True,
"opening_confirmed": True,
"opening_balance": "0",
"historical_settlement_confirmed": True,
}
ship = {
"source_id": "1",
"line_id": "1",
"date": "2026-03-10",
"group_key": "g",
"amount": "100",
"cost_total": "50",
"category": "municipal_water",
"personnel_id": "001",
"customer_type": "personal",
"business_key": "business1",
}
cash = {
"source_id": "2",
"date": "2026-03-10",
"group_key": "g",
"amount": "100",
"voucher_confirmed": True,
}
facts = {"personnel": {"001": {"role_code": "salesperson"}}}
params = {
"2026-03-10": {
"thresholds": {"municipal_water": "30"},
"start": "10",
"slope": "4/360",
"floor": "4",
"boundary": 360,
"enterprise": "3",
}
}
return group, ship, cash, facts, params
def run(group, ships, cash, facts, params, until=date(2026, 3, 31)):
return preview_group(group, ships, cash, facts, params, until)
def test_跨月预演区分累计与当月计提():
g, s, c, f, p = inputs()
result = run(g, [s], [c], f, p, date(2026, 4, 30))
assert Decimal(result["performance_total"]) > 0
assert result["month_performance_total"] == "0.00"
assert result["lines"][0]["shipment_remaining_after"] == "0.00"
def test_经理本人业务交接后仍排除():
g, s, c, f, p = inputs()
f["personnel"]["001"]["role_code"] = "sales_manager"
f["personnel"]["002"] = {"role_code": "salesperson"}
f["handovers"] = [
{
"business_key": "business1",
"mode": "RESIGNATION",
"effective_date": "2026-03-15",
"from_personnel_id": "001",
"to_personnel_id": "002",
}
]
line = run(g, [s], [c], f, p)["lines"][0]
assert line["excluded_manager_own"] and line["performance"] == "0.00"
def test_未知运费影响同组顺序时不猜零费用():
g, s, c, f, p = inputs()
unknown = {**s, "pending": ["FREIGHT_PENDING"]}
result = run(g, [unknown, {**s, "source_id": "2"}], [c], f, p)
assert result["status"] == "PENDING"
assert result["lines"] == [] and result["reasons"] == ["FREIGHT_PENDING"]
def test_范围外的未来负向单据不阻塞本月():
g, s, c, f, p = inputs()
future = {**s, "source_id": "99", "date": "2026-05-01", "amount": "-100"}
result = run(g, [s, future], [c], f, p)
assert result["status"] == "SUCCEEDED"
assert result["receipt_total"] == "100.00"
def test_一款多发货金额守恒且同日按源id排序():
g, s, c, f, p = inputs()
result = run(
g,
[
{**s, "source_id": "9", "amount": "60", "cost_total": "30"},
{**s, "source_id": "3", "amount": "60", "cost_total": "30"},
],
[c],
f,
p,
)
assert [r["shipment_id"] for r in result["lines"]] == ["3", "9"]
assert result["receipt_total"] == result["allocated_total"] == "100.00"
assert result["shipment_remaining"] == "20.00"
def test_多款一发货和成本分摊尾差():
g, s, c, f, p = inputs()
s = {**s, "amount": "3", "cost_total": "1"}
cash = [{**c, "source_id": str(i), "amount": "1"} for i in range(3)]
result = run(g, [s], cash, f, p)
assert sum(Decimal(r["allocated_cost"]) for r in result["lines"]) == Decimal("1")
assert result["shipment_remaining"] == "0.00"
def test_旧应收优先冲平且不算新绩效():
g, s, c, f, p = inputs()
g["opening_balance"] = "70"
result = run(g, [s], [c], f, p)
assert result["lines"][0]["kind"] == "OPENING"
assert result["lines"][0]["performance"] is None
assert result["lines"][1]["amount"] == "30.00"
assert result["opening_remaining"] == "0.00"
def test_明确未生成凭证的款排除且不阻塞合格回款():
g, s, c, f, p = inputs()
excluded = {
**c,
"source_id": "99",
"amount": "45",
"voucher_confirmed": False,
"exclusion": "NOT_GENERATED_VOUCHER",
}
result = run(g, [s], [excluded, c], f, p)
assert result["status"] == "SUCCEEDED"
assert result["receipt_total"] == result["allocated_total"] == "100.00"
assert result["excluded_receipt_total"] == "45.00"
line = next(row for row in result["lines"] if row["kind"] == "EXCLUDED")
assert line["performance"] is None and line["reasons"] == ["NOT_GENERATED_VOUCHER"]
def test_离职人员缺交接依据不猜绩效归属():
g, s, c, f, p = inputs()
f["personnel"]["001"]["status"] = 0
line = run(g, [s], [c], f, p)["lines"][0]
assert line["status"] == "PENDING" and line["performance"] is None
assert "HANDOVER_PENDING" in line["reasons"]
def test_预收参数取值时点不明确时不猜测():
g, s, c, f, p = inputs()
c["date"] = "2026-03-01"
line = run(g, [s], [c], f, p)["lines"][0]
assert line["status"] == "PENDING"
assert "PREPAYMENT_PARAMETERS_PENDING" in line["reasons"]
def test_预收未发货不计提_发货后最高比例():
g, s, c, f, p = inputs()
c["date"] = "2026-03-01"
p["prepayment_parameter_basis"] = "SHIPMENT"
before = run(g, [s], [c], f, p, date(2026, 3, 5))
assert before["prepayment_remaining"] == "100.00"
after = run(g, [s], [c], f, p)
assert after["lines"][0]["rate"] == "1/100"
assert after["lines"][0]["effective_date"] == "2026-03-10"
@pytest.mark.parametrize(
"missing,reason", [("cost_total", "GUIDANCE_PENDING"), ("category", "PRODUCT_CATEGORY_PENDING")]
)
def test_缺必要字段不按零计算(missing, reason):
g, s, c, f, p = inputs()
s.pop(missing)
result = run(g, [s], [c], f, p)
assert result["lines"][0]["performance"] is None
assert reason in result["lines"][0]["reasons"]
def test_凭证状态未确认阻止本组分配():
g, s, c, f, p = inputs()
c["voucher_confirmed"] = False
assert run(g, [s], [c], f, p)["reasons"] == ["VOUCHER_STATE_PENDING"]
def test_经理本人业务排除():
g, s, c, f, p = inputs()
f["personnel"]["001"]["role_code"] = "sales_manager"
assert run(g, [s], [c], f, p)["lines"][0]["performance"] == "0.00"
def test_负单据不进入普通fifo():
g, s, c, f, p = inputs()
c["amount"] = "-1"
assert run(g, [s], [c], f, p)["reasons"] == ["NEGATIVE_OR_ZERO_DOCUMENT"]
def test_已核对更正发货保留源记录但排除绩效():
g, s, c, f, p = inputs()
correction = {
**s,
"source_id": "99",
"amount": "-100",
"performance_excluded": True,
"exclusion_reason": "CORRECTION_EXCLUDED",
"correction_pair_key": "pair-1",
}
result = run(g, [correction, s], [c], f, p)
excluded = next(row for row in result["lines"] if row["kind"] == "EXCLUDED")
assert result["status"] == "SUCCEEDED"
assert result["excluded_shipment_total"] == "-100.00"
assert excluded["reasons"] == ["CORRECTION_EXCLUDED"]
assert result["performance_total"] != "0.00"
def test_禁止跨组及重复输入():
g, s, c, f, p = inputs()
with pytest.raises(ValueError, match="跨"):
run(g, [s], [{**c, "group_key": "other"}], f, p)
with pytest.raises(ValueError, match="重复"):
run(g, [s, s], [c], f, p)
def test_离职交接转给新人员_在职交接保留发货人():
g, s, c, f, p = inputs()
f["personnel"]["002"] = {"role_code": "salesperson"}
handover = {
"business_key": "business1",
"mode": "RESIGNATION",
"effective_date": "2026-03-05",
"from_personnel_id": "001",
"to_personnel_id": "002",
}
f["handovers"] = [handover]
assert run(g, [s], [c], f, p)["lines"][0]["personnel_id"] == "002"
handover["mode"] = "ACTIVE"
assert run(g, [s], [c], f, p)["lines"][0]["personnel_id"] == "001"
def test_输入金额拒绝浮点与无穷():
for value in [1.1, "NaN", "Infinity"]:
with pytest.raises(ValueError):
money(value)
@@ -0,0 +1,40 @@
"""恢复库核对包不能用缺记录证明零运费、已发绩效或已确认零期初。"""
from app.services.tplus.materials import QUERIES, render_markdown, summarize
def records():
data = {name: [] for name in QUERIES}
data["customer"] = [{"id": 9395, "code": "0060201", "name": "测试客户", "customerType": None}]
return data
def test_缺初始化记录只形成候选余额():
report = summarize(records(), 9395, "2026-03")
assert report["opening_candidate"] == "0.00" and not report["opening_confirmed"]
assert not report["ready_for_amount"] and not report["formal_payroll_written"]
assert {m["key"] for m in report["missing"]} >= {"GUIDANCE", "FREIGHT", "HISTORICAL_PAYROLL"}
def test_2017初始化不直接当作切换余额():
data = records()
data["opening"] = [{"origDate": "2017-05-31", "amount": "10000"}]
report = summarize(data, 9395, "2026-03")
assert report["opening_candidate"] is None
assert "2026-02-28" in report["opening_explanation"]
def test_超过范围明确标记核对包不完整():
data = records()
data["items"] = [{"id": i} for i in range(201)]
report = summarize(data, 9395, "2026-03")
assert report["truncated"] == ["items"]
assert len(report["evidence"]["items"]) == 200
def test_报告明确待补不制造实际金额():
report = summarize(records(), 9395, "2026-03")
report["evidence_hash"] = "test"
output = render_markdown(report)
assert "候选期初余额:0.00" in output
assert "实际工资金额尚未验收" in output
@@ -80,7 +80,8 @@ def test_多订单和成本日期仍需业务确认不自动选择():
result = _audit().run()
assert result["order_coverage"]["multiple_orders"] == 2
keys = {item["key"] for item in result["pending_confirmations"]}
assert {"cost_basis", "receipt_date", "ship_date", "allocation"} <= keys
assert {"cost_basis", "voucher_state", "opening_balance", "allocation"} <= keys
assert not {"receipt_date", "ship_date"} & keys
def test_SQL查询有固定白名单且不接受自定义表名():
+16
View File
@@ -108,3 +108,19 @@ def test_未确认时间增量不能创建误导性批次(target):
with pytest.raises(ValueError, match="增量水位语义尚未确认"):
service.run(entity_types=["receipts"], watermark=datetime(2026, 10, 27))
assert target.scalar(select(func.count()).select_from(TPlusSyncBatch)) == 0
def test_已读数据后明确超时仍标记可重试失败(target):
service = _service(target, [])
def source(entity, **kwargs):
if entity == "products":
return iter([{"id": 1}])
raise TimeoutError("敏感驱动信息不得回显")
service.reader.iter_entity.side_effect = source
batch = service.run(entity_types=["products", "customers", "personnel"])
assert batch.status == "FAILED"
assert batch.staged_record_count == 1
assert service.reader.iter_entity.call_count == 2
assert target.scalar(select(TPlusSyncError.error_code)) == "SOURCE_TIMEOUT"
+822
View File
@@ -0,0 +1,822 @@
"""真实事务验证导入、资料版本和预演隔离;SQLite 仅替代存储。"""
from contextlib import nullcontext
from datetime import datetime
from decimal import Decimal
from uuid import uuid4
import pytest
from pydantic import ValidationError
from sqlalchemy import DefaultClause, Integer, MetaData, create_engine, func, select, text
from sqlalchemy.orm import Session
from app.models import (
Base,
BusinessRole,
Customer,
PerformanceLedger,
Personnel,
Product,
TPlusFact,
TPlusSourceHead,
TPlusSourceLink,
TPlusSyncBatch,
TPlusSyncRecord,
TPlusWorkRun,
)
from app.schemas.tplus_workbench import FactIn, WorkCreateIn
from app.services.tplus import workbench
from app.services.tplus.mapping import get_mapping
from app.services.tplus.master_import import execute_master
from app.services.tplus.source_heads import observe_rows
from app.services.tplus.staging import stage_row
@pytest.fixture
def work_session(monkeypatch):
engine = create_engine("sqlite://")
metadata = MetaData()
for model_table in Base.metadata.sorted_tables:
table = model_table.to_metadata(metadata)
for column in table.columns:
if column.type.__class__.__name__ in ("BIGINT", "TINYINT"):
column.type = Integer()
if column.name == "update_time":
column.server_default = DefaultClause(text("CURRENT_TIMESTAMP"))
metadata.create_all(engine)
monkeypatch.setattr(workbench, "_source_database", lambda: "test_source")
monkeypatch.setattr(workbench, "sync_lock", lambda s: nullcontext())
with Session(engine, expire_on_commit=False) as session:
session.add(
BusinessRole(
role_name="营销员",
role_code="salesperson",
calc_template="SALESPERSON",
sort_order=1,
status=1,
)
)
session.commit()
yield session
engine.dispose()
def source(session, entity, rows, batch_no=None):
number = batch_no or uuid4().hex[:24]
batch = TPlusSyncBatch(
batch_no=number,
sync_mode="FULL",
status="SUCCEEDED",
source_database="test_source",
entity_types_json=[entity],
audit_result_json={"source_head_version": 1, "observed_entities": [entity]},
finished_at=datetime.now(),
)
session.add(batch)
session.flush()
for row in rows:
stage_row(
session,
batch_no=number,
entity_type=entity,
source_table=get_mapping(entity).source_table,
row=row,
)
observe_rows(session, batch, get_mapping(entity), rows)
session.commit()
return batch
def fact(session, kind, key, data):
return workbench.save_fact(
session,
FactIn(
kind=kind, fact_key=key, data=data, evidence="测试用明确客户答复", expected_version=0
),
1,
)
def preview(session, entities, ids=None):
run = TPlusWorkRun(
run_no=uuid4().hex[:24],
request_key=str(uuid4()),
source_database="test_source",
requested_by=1,
kind="MASTER_PREVIEW",
status="RUNNING",
input_json={"entity_types": entities, "source_ids": ids or []},
)
session.add(run)
execute_master(session, run)
session.commit()
return run
def apply(session, run):
task = TPlusWorkRun(
run_no=uuid4().hex[:24],
request_key=str(uuid4()),
source_database="test_source",
requested_by=1,
kind="MASTER_APPLY",
status="RUNNING",
input_json={"preview_no": run.run_no},
)
session.add(task)
execute_master(session, task)
session.commit()
return task
def prepare_person(session):
source(session, "personnel", [{"id": 1, "code": "00043", "name": "测试人员"}])
fact(
session,
"PERSONNEL",
"00043",
{"code": "00043", "name": "测试人员", "role_code": "salesperson", "status": 1},
)
def test_人员真实导入且重跑不重复_保留人工工资与岗位(work_session):
s = work_session
prepare_person(s)
result = apply(s, preview(s, ["personnel"]))
assert result.summary_json["counts"]["CREATE"] == 1
person = s.scalar(select(Personnel))
assert person.source_no == "00043" and person.manager_id is None
person.salary_standard = Decimal("1234.56")
person.is_probation = 1
s.commit()
again = preview(s, ["personnel"])
assert again.summary_json["counts"] == {"SKIP": 1}
apply(s, again)
assert s.scalar(select(func.count()).select_from(Personnel)) == 1
assert person.salary_standard == Decimal("1234.56") and person.is_probation == 1
assert s.scalar(select(func.count()).select_from(PerformanceLedger)) == 0
@pytest.mark.parametrize("change", ["source", "fact", "target"])
def test_旧预览源或人工资料或目标变化必须拒绝(work_session, change):
s = work_session
prepare_person(s)
first = preview(s, ["personnel"])
if change == "source":
source(s, "personnel", [{"id": 1, "code": "00043", "name": "新名字"}])
elif change == "fact":
s.add(
TPlusFact(
source_database="test_source",
kind="PRODUCT",
fact_key="1",
version=1,
data_json={"category": "rail_transit", "unit": "件"},
evidence="新资料",
requested_by=1,
)
)
s.commit()
else:
# 新增相同身份的人工人员也应使原 CREATE 预览过期。
s.add(Personnel(personnel_name="测试人员", business_role_id=1))
s.commit()
from app.services.tplus.workbench import WorkbenchError
with pytest.raises(WorkbenchError, match="PREVIEW_STALE"):
apply(s, first)
def test_源A变B再变A指向本次观察的A(work_session):
s = work_session
for name in ("A", "B", "A"):
source(s, "customers", [{"id": 1, "name": name}])
head = s.scalar(select(TPlusSourceHead))
assert s.get(TPlusSyncRecord, head.record_id).payload_json["name"] == "A"
assert s.scalar(select(func.count()).select_from(TPlusSyncRecord)) == 2
def test_没有当前读取证据不能将历史staging直接导入(work_session):
from app.services.tplus.workbench import WorkbenchError
with pytest.raises(WorkbenchError, match="SOURCE_REFRESH_REQUIRED"):
preview(work_session, ["personnel"])
def test_空客户分类及产品类别保持待补_不取Tplus成本(work_session):
s = work_session
source(s, "products", [{"id": 1, "name": "产品", "unit_name": "件", "invSCost": "123"}])
source(s, "customers", [{"id": 2, "name": "客户", "partnerType": 211, "customerType": None}])
run = preview(s, ["products", "customers"])
assert run.summary_json["counts"] == {"PENDING": 2}
apply(s, run)
assert s.scalar(select(func.count()).select_from(Product)) == 0
assert s.scalar(select(func.count()).select_from(Customer)) == 0
def test_客户改名按源ID更新_人工分类不覆盖(work_session):
s = work_session
fact(s, "CUSTOMER", "2", {"customer_type": "personal", "owner_name": "客户负责人"})
source(s, "customers", [{"id": 2, "name": "旧名", "partnerType": 211}])
apply(s, preview(s, ["customers"]))
customer = s.scalar(select(Customer))
customer.customer_type = "enterprise"
s.commit()
source(s, "customers", [{"id": 2, "name": "新名", "partnerType": 211}])
apply(s, preview(s, ["customers"]))
assert customer.company_name == "新名" and customer.customer_type == "enterprise"
assert s.scalar(select(func.count()).select_from(Customer)) == 1
def test_客户供应商混合性质不导入(work_session):
s = work_session
source(s, "customers", [{"id": 2, "name": "混合", "partnerType": 228}])
assert preview(s, ["customers"]).summary_json["counts"] == {"SKIP": 1}
def test_未关联的同名目标不按名字合并(work_session):
s = work_session
prepare_person(s)
s.add(Personnel(personnel_name="测试人员", business_role_id=1))
s.commit()
run = preview(s, ["personnel"])
assert run.summary_json["counts"] == {"CONFLICT": 1}
assert s.scalar(select(func.count()).select_from(TPlusSourceLink)) == 0
def test_重复源工号不生成两个人(work_session):
s = work_session
prepare_person(s)
source(
s,
"personnel",
[
{"id": 1, "code": "00043", "name": "测试人员"},
{"id": 2, "code": "00043", "name": "测试人员"},
],
)
assert preview(s, ["personnel"]).summary_json["counts"] == {"CONFLICT": 2}
def test_资料版本冲突不覆盖_历史保留(work_session):
from app.core.exceptions import BadRequestError
s = work_session
data = {"category": "rail_transit", "unit": "件"}
fact(s, "PRODUCT", "1", data)
with pytest.raises(BadRequestError):
fact(s, "PRODUCT", "1", data)
workbench.save_fact(
s,
FactIn(kind="PRODUCT", fact_key="1", data=data, expected_version=1, evidence="核对后补充"),
1,
)
assert s.scalar(select(func.count()).select_from(TPlusFact)) == 2
def test_更正事实要求排除或原业务归属():
with pytest.raises(ValidationError):
FactIn(
kind="CORRECTION",
fact_key="1",
evidence="客户确认",
data={"entity": "SHIPMENT", "pair_key": "pair-1"},
)
shipment = FactIn(
kind="CORRECTION",
fact_key="1",
evidence="客户确认",
data={
"entity": "SHIPMENT",
"pair_key": "pair-1",
"exclude_performance": True,
},
)
assert shipment.data["exclude_performance"] is True
receipt = FactIn(
kind="CORRECTION",
fact_key="2",
evidence="客户确认",
data={
"entity": "RECEIPT",
"pair_key": "pair-1",
"redirect_business_key": "group-a",
},
)
assert receipt.data["redirect_business_key"] == "group-a"
def test_资料入口拒绝任意字段与非有限金额():
with pytest.raises(ValidationError):
FactIn(
kind="PRODUCT",
fact_key="1",
evidence="依据",
data={"category": "rail_transit", "unit": "件", "password": "not-allowed"},
)
with pytest.raises(ValidationError):
FactIn(
kind="GUIDANCE",
fact_key="1",
evidence="依据",
data={
"product_id": "1",
"month": "2026-03",
"basis": "UNIT",
"amount": "NaN",
"unit": "件",
},
)
def test_工作请求幂等且不同意图不复用(work_session):
from app.core.exceptions import BadRequestError
s = work_session
payload = WorkCreateIn(kind="MASTER_PREVIEW", request_key=uuid4(), entity_types=["personnel"])
a = workbench.queue_work(s, payload, 1)
assert workbench.queue_work(s, payload, 1)["run_no"] == a["run_no"]
with pytest.raises(BadRequestError):
workbench.queue_work(s, payload.model_copy(update={"entity_types": ["customers"]}), 1)
def test_工作区接口拒绝未登录请求(client):
for url in (
"/api/v1/system/tplus/facts",
"/api/v1/system/tplus/work-runs",
"/api/v1/system/tplus/source-records?entity=personnel",
):
assert client.get(url).status_code == 401
def prepare_calc(session):
"""完整准备一个已确认口径的隔离组,不借用真实客户数据。"""
prepare_person(session)
source(
session,
"shipments",
[
{
"id": 20,
"code": "SH-DEMO",
"idcustomer": 8,
"voucherdate": "2026-03-10",
"voucherState": 189,
}
],
)
source(
session,
"shipment_items",
[{"id": 21, "source_parent_id": 20, "idinventory": 9, "taxAmount": "100", "quantity": "2"}],
)
source(
session,
"receipts",
[
{
"id": 10,
"code": "RC-DEMO",
"idpartner": 8,
"voucherDate": "2026-03-10",
"isReceiveFlag": 1,
"voucherstate": 189,
"amount": "100",
}
],
)
fact(session, "PRODUCT", "9", {"category": "municipal_water", "unit": "件"})
fact(session, "CUSTOMER", "8", {"customer_type": "personal", "owner_name": "负责人"})
fact(
session,
"GUIDANCE",
"price9-03",
{"product_id": "9", "month": "2026-03", "basis": "UNIT", "amount": "25", "unit": "件"},
)
fact(session, "RECEIPT", "10", {"voucher_confirmed": True, "voucher_reference": "测试会计凭证"})
fact(
session,
"SHIPMENT",
"21",
{"sales_amount_excludes_freight": True, "business_key": "biz", "personnel_id": "00043"},
)
group = {
"customer_id": "8",
"shipment_ids": ["20"],
"receipt_ids": ["10"],
"scope_confirmed": True,
"opening_confirmed": True,
"opening_balance": "0",
"historical_settlement_confirmed": True,
"guidance_month_basis": "SHIPMENT",
}
fact(session, "GROUP", "g", group)
session.commit()
return group
def calc(session, keys=None):
from app.services.tplus.calculation_preview import execute_preview
run = TPlusWorkRun(
run_no=uuid4().hex[:24],
request_key=str(uuid4()),
kind="CALC_PREVIEW",
source_database="test_source",
requested_by=1,
status="RUNNING",
input_json={"month": "2026-03", "group_keys": keys or ["g"]},
)
session.add(run)
execute_preview(session, run)
session.commit()
return run
def test_真实暂存组装预演结果可复现_正式表不变(work_session):
s = work_session
prepare_calc(s)
first = calc(s)
second = calc(s)
assert first.snapshot_hash == second.snapshot_hash
assert first.summary_json == second.summary_json
assert first.summary_json["groups"]["g"]["performance_total"] == "0.20"
assert s.scalar(select(func.count()).select_from(PerformanceLedger)) == 0
from app.models import MonthlySettlement, Receipt
assert s.scalar(select(func.count()).select_from(Receipt)) == 0
assert s.scalar(select(func.count()).select_from(MonthlySettlement)) == 0
def test_一个组资料缺失不阻塞另一组计算(work_session):
s = work_session
prepare_calc(s)
fact(
s,
"GROUP",
"missing",
{
"customer_id": "9",
"shipment_ids": ["9000"],
"receipt_ids": [],
"scope_confirmed": True,
"opening_confirmed": True,
"opening_balance": "0",
"historical_settlement_confirmed": True,
},
)
run = calc(s, ["g", "missing"])
assert run.status == "PARTIAL"
assert run.summary_json["groups"]["g"]["performance_total"] == "0.20"
assert run.summary_json["groups"]["missing"]["reasons"] == ["GROUP_SOURCE_MISSING"]
def test_跨客户和跨组重复消耗被拦截(work_session):
s = work_session
group = prepare_calc(s)
fact(s, "GROUP", "other", group)
run = calc(s)
assert run.summary_json["groups"]["g"]["reasons"] == ["OVERLAPPING_GROUPS"]
assert run.status == "PARTIAL"
def test_重叠组不阻塞无关正常组(work_session):
s = work_session
prepare_calc(s)
for key in ("bad1", "bad2"):
fact(s, "GROUP", key, {"customer_id": "999", "shipment_ids": ["9999"], "receipt_ids": []})
run = calc(s, ["g", "bad1", "bad2"])
assert run.summary_json["groups"]["g"]["performance_total"] == "0.20"
assert run.summary_json["groups"]["bad1"]["reasons"] == ["OVERLAPPING_GROUPS"]
assert run.summary_json["groups"]["bad2"]["reasons"] == ["OVERLAPPING_GROUPS"]
def test_已核销不能代替凭证确认(work_session):
s = work_session
prepare_calc(s)
existing = workbench.latest_facts(s, "test_source")[("RECEIPT", "10")]
workbench.save_fact(
s,
FactIn(
kind="RECEIPT",
fact_key="10",
expected_version=existing.version,
evidence="凭证尚未生成",
data={"voucher_confirmed": False, "voucher_reference": "仅已核销"},
),
1,
)
assert calc(s).summary_json["groups"]["g"]["reasons"] == ["VOUCHER_STATE_PENDING"]
@pytest.mark.parametrize(
"count,valid,manual,confirmed,reason",
[
(1, 1, None, True, None),
(0, 0, None, False, None),
(1, 0, None, False, "ACCOUNTING_VOUCHER_INVALID"),
(2, 1, None, False, "ACCOUNTING_VOUCHER_INVALID"),
(1, 1, False, False, "VOUCHER_EVIDENCE_CONFLICT"),
(0, 0, True, False, "VOUCHER_EVIDENCE_CONFLICT"),
],
)
def test_生成凭证按真实总账关联核实而非审核状态(count, valid, manual, confirmed, reason):
from types import SimpleNamespace
from app.services.tplus.calculation_preview import _voucher_evidence
raw = {
"accounting_voucher_count": count,
"valid_accounting_voucher_count": valid,
"accounting_voucher_id": 123 if valid else None,
"accounting_voucher_code": "GL-EXAMPLE",
"accounting_voucher_state": 181,
}
fact = (
SimpleNamespace(data_json={"voucher_confirmed": manual, "voucher_reference": "人工核对"})
if manual is not None
else None
)
allowed, evidence, pending = _voucher_evidence(raw, fact)
assert allowed is confirmed
assert evidence["basis"] == "GL_SrcVoucherInfo/GL_Doc"
if reason:
assert reason in pending
else:
assert pending == []
def test_总账已生成无需逐单手工勾选且凭证编号可追溯(work_session):
from app.models import TPlusWorkLine
s = work_session
prepare_calc(s)
original = workbench.latest_facts(s, "test_source")[("RECEIPT", "10")]
s.delete(original)
source(
s,
"receipts",
[
{
"id": 10,
"code": "RC-DEMO",
"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-EXAMPLE",
}
],
)
run = calc(s)
assert run.status == "SUCCEEDED"
line = s.scalar(select(TPlusWorkLine).where(TPlusWorkLine.run_no == run.run_no))
assert line.data_json["accounting_voucher_code"] == "GL-EXAMPLE"
assert line.data_json["voucher_evidence"]["basis"] == "GL_SrcVoucherInfo/GL_Doc"
def test_新回执固定发货月指导成本_历史空开关不再阻塞(work_session):
s = work_session
group = prepare_calc(s)
group["guidance_month_basis"] = None
workbench.save_fact(
s,
FactIn(
kind="GROUP", fact_key="g", expected_version=1, evidence="历史空开关由2026-10-30客户答复替代", data=group
),
1,
)
assert calc(s).summary_json["groups"]["g"]["status"] == "SUCCEEDED"
from app.models import TPlusWorkLine
assert (
s.scalar(select(TPlusWorkLine).where(TPlusWorkLine.entity_type == "group")).data_json[
"performance"
]
== "0.20"
)
def test_后续读取中断不发布部分当前指针(work_session):
from app.services.tplus.snapshots import source_records
from app.services.tplus.workbench import WorkbenchError
s = work_session
prepare_person(s)
s.add(
TPlusSyncBatch(
batch_no=uuid4().hex[:24],
sync_mode="FULL",
status="FAILED",
source_database="test_source",
entity_types_json=["personnel"],
)
)
s.commit()
with pytest.raises(WorkbenchError, match="SOURCE_REFRESH_REQUIRED"):
source_records(s, "test_source", "personnel")
def test_冻结重跑不读取后来修改的指导价(work_session):
from app.services.tplus.calculation_preview import execute_preview
s = work_session
prepare_calc(s)
first = calc(s)
old = workbench.latest_facts(s, "test_source")[("GUIDANCE", "price9-03")]
workbench.save_fact(
s,
FactIn(
kind="GUIDANCE",
fact_key="price9-03",
expected_version=old.version,
evidence="新资料不应改变冻结预演",
data={**old.data_json, "amount": "49"},
),
1,
)
replay = TPlusWorkRun(
run_no=uuid4().hex[:24],
request_key=str(uuid4()),
kind="CALC_PREVIEW",
source_database="test_source",
requested_by=1,
status="RUNNING",
input_json={"replay_no": first.run_no},
)
s.add(replay)
execute_preview(s, replay)
s.commit()
assert first.snapshot_hash == replay.snapshot_hash
assert first.summary_json == replay.summary_json
assert calc(s).snapshot_hash != first.snapshot_hash
@pytest.mark.parametrize("settle_state", [428, 180, 21, 283, 1645, 1646])
def test_真实结清状态枚举不误判为作废(work_session, settle_state):
s = work_session
prepare_calc(s)
source(
s,
"shipments",
[
{
"id": 20,
"code": "SH-DEMO",
"idcustomer": 8,
"voucherdate": "2026-03-10",
"voucherState": 189,
"isCancel": settle_state,
"isCounteractDelivery": 0,
}
],
)
run = calc(s)
assert run.status == "SUCCEEDED"
assert run.summary_json["groups"]["g"]["performance_total"] == "0.20"
@pytest.mark.parametrize(
"state,reverse,expected",
[(181, 0, "SHIPMENT_STATE_PENDING"), (189, 1, "NEGATIVE_EVENT_REVIEW_REQUIRED")],
)
def test_非生效或红冲的销货仍被阻止(work_session, state, reverse, expected):
from app.models import TPlusWorkLine
s = work_session
prepare_calc(s)
source(
s,
"shipments",
[
{
"id": 20,
"code": "SH-DEMO",
"idcustomer": 8,
"voucherdate": "2026-03-10",
"voucherState": state,
"isCancel": 428,
"isCounteractDelivery": reverse,
}
],
)
run = calc(s)
assert run.status == "PARTIAL"
line = s.scalar(select(TPlusWorkLine).where(TPlusWorkLine.run_no == run.run_no))
assert expected in line.data_json["reasons"]
def test_运费明确后从含税销售额扣除(work_session):
s = work_session
prepare_calc(s)
old = workbench.latest_facts(s, "test_source")[("SHIPMENT", "21")]
workbench.save_fact(
s,
FactIn(
kind="SHIPMENT",
fact_key="21",
expected_version=1,
evidence="例单核实含10元运费",
data={**old.data_json, "sales_amount_excludes_freight": False, "freight_amount": "10"},
),
1,
)
result = calc(s)
assert result.summary_json["groups"]["g"]["prepayment_remaining"] == "10.00"
ship = result.snapshot_json["calculation_inputs"]["g"]["shipments"][0]
assert ship["amount"] == "90" and ship["gross_tax_amount"] == "100"
def test_预演采用已导入基础记录的人工分类(work_session):
s = work_session
prepare_calc(s)
product = Product(
product_name="人工分类产品", spec_model="", unit="件", product_category="rail_transit"
)
customer = Customer(
company_name="人工分类客户", owner_name="负责人", customer_type="enterprise"
)
s.add_all([product, customer])
s.flush()
s.add_all(
[
TPlusSourceLink(
source_database="test_source",
entity_type="products",
source_id="9",
target_id=product.id,
),
TPlusSourceLink(
source_database="test_source",
entity_type="customers",
source_id="8",
target_id=customer.id,
),
]
)
s.commit()
run = calc(s)
ship = run.snapshot_json["calculation_inputs"]["g"]["shipments"][0]
assert ship["category"] == "rail_transit" and ship["customer_type"] == "enterprise"
assert ship["classification_evidence"]["product_target_id"] == product.id
@pytest.mark.parametrize("value", ["", "abc", "1,200", "NaN", "-1"])
def test_非法指导价被校验拒绝(value):
with pytest.raises(ValidationError):
FactIn(
kind="GUIDANCE",
fact_key="price",
evidence="输入校验",
data={
"product_id": "9",
"month": "2026-03",
"basis": "UNIT",
"unit": "件",
"amount": value,
},
)
def test_同名人员不同状态在预览即标为冲突(work_session):
s = work_session
source(
s,
"personnel",
[{"id": 1, "code": "001", "name": "重名"}, {"id": 2, "code": "002", "name": "重名"}],
)
for code, status in (("001", 1), ("002", 0)):
fact(
s,
"PERSONNEL",
code,
{"code": code, "name": "重名", "role_code": "salesperson", "status": status},
)
assert preview(s, ["personnel"]).summary_json["counts"] == {"CONFLICT": 2}
def test_报告文本防公式注入且保留前导零():
from app.services.tplus.report_labels import spreadsheet_value
assert spreadsheet_value("00043") == "'00043"
assert spreadsheet_value("\t=1+1") == "'\t=1+1"
assert spreadsheet_value("普通单号-1") == "普通单号-1"
def test_未确认日期更正不开放():
with pytest.raises(ValidationError, match="日期更正规则尚未确认"):
FactIn(
kind="SHIPMENT",
fact_key="21",
evidence="未确认的建议",
data={
"sales_amount_excludes_freight": True,
"business_key": "b",
"personnel_id": "001",
"date_override": "2026-03-01",
},
)
@@ -0,0 +1,35 @@
"""停止标记控制真实CLI领取边界,不中断正在完成的单次处理。"""
from argparse import Namespace
from contextlib import nullcontext
from types import SimpleNamespace
from unittest.mock import Mock
import pytest
from app import cli
from app.core.config import Settings
@pytest.mark.parametrize("already_stopped", [False, True])
def test_停止后不再领取下一任务(monkeypatch, tmp_path, already_stopped):
marker = tmp_path / "stop.requested"
if already_stopped:
marker.touch()
settings = Settings(_env_file=None, app_secret_key="x" * 32)
monkeypatch.setattr(cli, "get_settings", lambda: settings)
monkeypatch.setattr(cli, "get_session_factory", lambda: lambda: nullcontext(object()))
def finish(_session, **_kwargs):
marker.touch()
return "completed"
consume = Mock(side_effect=finish)
pulse = Mock()
monkeypatch.setattr(cli, "run_once", consume)
monkeypatch.setattr(cli, "get_batch", lambda *_: SimpleNamespace(status="SUCCEEDED"))
monkeypatch.setattr("app.services.tplus.automation.heartbeat", pulse)
result = cli._tplus_worker(Namespace(connection_file=None, poll_seconds=2, watch=True, stop_file=str(marker)))
assert result == 0
assert consume.call_count == (0 if already_stopped else 1)
assert pulse.call_args.args[3] == "STOPPED"