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