PayrollManagementSystem/backend/tests/integration/test_tplus_workbench.py
liuwenxin 108bf87225 feat(核算)!: 添加 T+ 正式核算与采集托管
补齐源快照、资料预演、正式导入及工资核算的依赖链路。
新增版本化输入、逐段 FIFO、生成锁和结构化当月调整,
并提供默认关闭的周期采集、有限重试及 Windows 托管。

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

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

335 lines
12 KiB
Python

"""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)
}