补齐源快照、资料预演、正式导入及工资核算的依赖链路。 新增版本化输入、逐段 FIFO、生成锁和结构化当月调整, 并提供默认关闭的周期采集、有限重试及 Windows 托管。 原始客户资料、私有配置及首页改动不纳入本次提交。 BREAKING CHANGE: 升级前需应用新增迁移,并通过 PAYROLL_MAKER_USER_ID 绑定实际工资制作人,否则业务只读。 旧单批次核算默认关闭;历史兼容需显式启用 PAYROLL_ALLOW_LEGACY_CALCULATION。生产启用和真实金额待验收。
335 lines
12 KiB
Python
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)
|
|
}
|