按 2026-11-02 需求补齐计划交付后端能力: - 角色授权:权限目录化,接口统一准入校验,未登记默认拒绝; 管理员判断改为按数据库角色重新判定,防止权限码持有者绕过 - 系统监控:单进程采集资源、请求与异常指标,分钟级聚合, 阈值配置化;敏感配置增加 repr 保护,Redis 密码 URL 转义 - 试用期计提:按回款日判定并写入快照,年度结清与调整单联动; 人员模型补试用期、离职日期与工资版本历史字段 - 计提模板:公式骨架受控,页面维护名称与参数绑定 - T+ 采集:自动化配置表与进度查询 内置角色禁停用保护对齐模块注释;核算规则版本提为常量并 补充批量核算失败堆栈日志。 影响范围:后端全部模块、数据库迁移 V22-V28 验证:uv run ruff check 通过;922 个单元测试全部通过; 集成测试因本机 MySQL 数据卷被其他容器锁定暂未运行
370 lines
13 KiB
Python
370 lines
13 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)
|
|
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_关闭源读取仍可查看工作区历史但不能新建采集(client, admin, workspace, monkeypatch):
|
|
from app.core.config import Settings
|
|
from app.services.tplus import task_service
|
|
|
|
db, source = workspace
|
|
fact = TPlusFact(
|
|
source_database=source, kind="PRODUCT", fact_key="9", version=1,
|
|
data_json={"category": "municipal_water", "unit": "件"},
|
|
evidence="只读历史查询测试", requested_by=admin["id"],
|
|
)
|
|
run = TPlusWorkRun(
|
|
run_no="HISTORY" + uuid4().hex[:18], request_key=str(uuid4()),
|
|
source_database=source, kind="MASTER_PREVIEW", status="SUCCEEDED",
|
|
requested_by=admin["id"], input_json={"entity_types": ["products"]},
|
|
)
|
|
db.add_all([fact, run])
|
|
db.commit()
|
|
settings = Settings(
|
|
_env_file=None, app_secret_key="x" * 32, t_plus_database=source,
|
|
t_plus_enabled=False,
|
|
)
|
|
monkeypatch.setattr(task_service, "get_settings", lambda: settings)
|
|
for path in (
|
|
"/overview", "/facts", f"/facts/{fact.id}/history", "/work-runs",
|
|
f"/work-runs/{run.run_no}", f"/work-runs/{run.run_no}/lines",
|
|
f"/work-runs/{run.run_no}/report",
|
|
):
|
|
response = client.get(BASE + path, headers=admin["headers"])
|
|
assert response.status_code == 200, (path, response.text)
|
|
response = client.post(BASE + "/batches", headers=admin["headers"], json={
|
|
"entity_types": ["personnel"], "request_key": str(uuid4()),
|
|
})
|
|
assert response.status_code == 400
|
|
assert "未启用" in response.json()["message"]
|
|
|
|
|
|
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)
|
|
}
|