feat(后端): 添加授权体系、系统监控与试用期计提

按 2026-11-02 需求补齐计划交付后端能力:
- 角色授权:权限目录化,接口统一准入校验,未登记默认拒绝;
  管理员判断改为按数据库角色重新判定,防止权限码持有者绕过
- 系统监控:单进程采集资源、请求与异常指标,分钟级聚合,
  阈值配置化;敏感配置增加 repr 保护,Redis 密码 URL 转义
- 试用期计提:按回款日判定并写入快照,年度结清与调整单联动;
  人员模型补试用期、离职日期与工资版本历史字段
- 计提模板:公式骨架受控,页面维护名称与参数绑定
- T+ 采集:自动化配置表与进度查询

内置角色禁停用保护对齐模块注释;核算规则版本提为常量并
补充批量核算失败堆栈日志。

影响范围:后端全部模块、数据库迁移 V22-V28
验证:uv run ruff check 通过;922 个单元测试全部通过;
集成测试因本机 MySQL 数据卷被其他容器锁定暂未运行
This commit is contained in:
liuwenxin committed 2026-11-03 11:20:55 +08:00
1 parent 02fb06e0af
commit 1461a8d113
149 files changed
+8736 -368

No files matched your search

+20 -5
View File
@@ -89,6 +89,12 @@ def _purge_test_data() -> None:
factory = get_session_factory()
ensure_test_database(factory.kw["bind"].url.database, os.environ.get("PAYROLL_TEST_DATABASE"))
with factory() as session:
from app.models import AuthorizationSetting, ProbationDeferral, ProbationPayment
session.execute(delete(ProbationPayment))
session.execute(delete(ProbationDeferral))
session.execute(update(AuthorizationSetting).values(maker_user_id=None, updated_by=None))
users = list(
session.execute(select(User.id, User.username).where(User.username.like("t\\_%"))).all()
)
@@ -218,7 +224,10 @@ def _purge_test_data() -> None:
.values(company_name="衍水集团", logo_path=None)
)
session.commit()
logo_dir = Path(__file__).resolve().parents[2] / "uploads" / "logo"
logo_dir = get_settings().upload_dir / "logo"
expected_uploads = Path(__file__).resolve().parents[3] / ".ai" / "isolated-uploads" / get_settings().mysql_database
if not logo_dir.resolve().is_relative_to(expected_uploads.resolve()):
raise RuntimeError("测试文件清理目标不在本次隔离目录,拒绝清理")
if logo_dir.is_dir():
for f in logo_dir.iterdir():
f.unlink(missing_ok=True)
@@ -330,8 +339,10 @@ def make_role():
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"])
from app.models import AuthorizationSetting
with get_session_factory()() as session:
session.get(AuthorizationSetting, 1).maker_user_id = user["id"]
session.commit()
user["headers"] = auth_headers(user)
return user
@@ -387,6 +398,10 @@ def legacy_calculation(monkeypatch):
@pytest.fixture
def writer(normal, admin, monkeypatch):
"""指定一个普通角色账号为工资制作人,证明身份授权与管理员角色无关。"""
from app.core.config import get_settings
monkeypatch.setattr(get_settings(), "payroll_maker_user_id", normal["id"])
from app.models import AuthorizationSetting
with get_session_factory()() as session:
session.get(AuthorizationSetting, 1).maker_user_id = normal["id"]
role = session.scalar(select(Role).where(Role.role_code == "payroll_maker"))
session.add(UserRole(user_id=normal["id"], role_id=role.id))
session.commit()
return normal
@@ -228,21 +228,25 @@ class TestSwitchOn:
)
assert resp.status_code == 400
def test_节点准入_领导不能审财务节点_admin不代办(
def test_节点准入_按独立审核权限而非角色名称判定(
self, client: TestClient, admin: dict, finance, leader, switched_on
) -> None:
p = _person(client, admin["headers"])
data = _create_with_plan(client, admin["headers"], p["id"])
order = _latest_order(client, leader["headers"], data["business_no"])
# status=1 是财务节点:领导不行,admin 也不代办
for headers in (leader["headers"], admin["headers"]):
# 领导模板不含财务复核;管理员固定拥有两项审核权限,但仍遵守节点。
for headers, expected in ((leader["headers"], 403), (admin["headers"], 200)):
resp = client.post(
f"{_APPROVALS}/{order['id']}/action",
json={"action": 1},
headers=headers,
)
assert resp.status_code == 403
assert resp.status_code == expected
response = client.post(
f"{_APPROVALS}/{order['id']}/action", json={"action": 1}, headers=finance["headers"]
)
assert response.status_code == 403 # 已进入领导节点,财务模板无领导审核权限。
def test_重复操作已通过单被拒(
self, client: TestClient, admin: dict, finance, leader, switched_on
@@ -0,0 +1,255 @@
"""隔离 MySQL 验证角色配置、用户分配、旧会话即时生效与工资操作解耦。"""
from uuid import uuid4
import pytest
from tests.integration.test_tplus_workbench import workspace as workspace
BASE = "/api/v1/system"
pytestmark = pytest.mark.integration
def current(client, headers):
response = client.get("/api/v1/auth/me", headers=headers)
assert response.status_code == 200, response.text
return response.json()["data"]
def authorization(client, admin, user_id):
response = client.get(f"{BASE}/users/{user_id}/authorization", headers=admin["headers"])
assert response.status_code == 200, response.text
return response.json()["data"]
def save(client, admin, user_id, **changes):
before = authorization(client, admin, user_id)
data = {
"role_codes": before["role_codes"],
"expected_revision": before["revision"],
"reason": "隔离角色分配验收",
} | changes
return client.put(f"{BASE}/users/{user_id}/authorization", headers=admin["headers"], json=data)
def role(client, admin, codes):
response = client.post(
BASE + "/roles",
headers=admin["headers"],
json={"role_code": "t_rbac_" + uuid4().hex[:8], "role_name": "t_权限验收角色"},
)
assert response.status_code == 200, response.text
item = response.json()["data"]
before = client.get(f"{BASE}/roles/{item['id']}/permissions", headers=admin["headers"]).json()[
"data"
]
response = client.put(
f"{BASE}/roles/{item['id']}/permissions",
headers=admin["headers"],
json={
"permission_codes": codes,
"expected_revision": before["revision"],
"reason": "配置测试角色",
},
)
assert response.status_code == 200, response.text
return item
def test_管理员无需绑定制作人即可维护资料和获得工资权限(client, admin):
from sqlalchemy import select
from app.core.db import get_session_factory
from app.models import AuthorizationSetting, BusinessRole, User
from app.services.authorization_service import require_permission
with get_session_factory()() as session:
session.get(AuthorizationSetting, 1).maker_user_id = None
session.commit()
require_permission(session, session.get(User, admin["id"]), "settlements:generate")
business_role_id = session.scalar(
select(BusinessRole.id).where(BusinessRole.role_code == "salesperson")
)
assert business_role_id is not None
info = current(client, admin["headers"])
assert info["can_write_payroll"] and info["maker_required_permissions"] == []
response = client.post(
"/api/v1/base/personnel",
headers=admin["headers"],
json={"personnel_name": "t_角色授权人员", "business_role_id": business_role_id},
)
assert response.status_code == 200, response.text
person = response.json()["data"]["id"]
for path, data in (("regions", {"regions": []}), ("plans", {"plans": []})):
response = client.put(
f"/api/v1/base/personnel/{person}/{path}", headers=admin["headers"], json=data
)
assert response.status_code == 200, response.text
def test_角色配置后分配给用户并在原会话即时生效(client, admin, normal):
assert "base:personnel:regions" not in current(client, normal["headers"])["permissions"]
item = role(client, admin, ["base:personnel:view", "base:personnel:regions"])
assert (
save(client, admin, normal["id"], role_codes=["user", item["role_code"]]).status_code == 200
)
assert "base:personnel:regions" in current(client, normal["headers"])["permissions"]
assert save(client, admin, normal["id"], role_codes=["user"]).status_code == 200
assert "base:personnel:regions" not in current(client, normal["headers"])["permissions"]
def test_旧个人覆盖写入拒绝且不影响既有角色(client, admin, normal):
response = save(
client,
admin,
normal["id"],
overrides=[{"permission_code": "base:personnel:regions", "effect": "ALLOW"}],
)
assert response.status_code == 400
assert current(client, normal["headers"])["roles"] == ["user"]
assert "base:personnel:regions" not in current(client, normal["headers"])["permissions"]
def test_普通用户不能配置角色或读取权限目录(client, normal):
assert client.get(BASE + "/permissions/catalog", headers=normal["headers"]).status_code == 403
assert client.get(BASE + "/roles", headers=normal["headers"]).status_code == 403
def test_过期角色分配版本返回409且不覆盖前次结果(client, admin, normal):
before = authorization(client, admin, normal["id"])
assert save(client, admin, normal["id"]).status_code == 200
response = save(
client, admin, normal["id"], expected_revision=before["revision"], role_codes=[]
)
assert response.status_code == 409
assert "user" in current(client, normal["headers"])["roles"]
def test_工资角色不再需要绑定且旧绑定接口不再写入(client, admin, normal):
assert save(client, admin, normal["id"], role_codes=["payroll_maker"]).status_code == 200
info = current(client, normal["headers"])
assert info["can_write_payroll"] and "payroll:calculate" in info["permissions"]
settings = client.get(BASE + "/authorization-settings", headers=admin["headers"]).json()["data"]
assert settings["mode"] == "roles" and settings["maker_user_id"] is None
response = client.put(
BASE + "/authorization-settings",
headers=admin["headers"],
json={
"maker_user_id": normal["id"],
"expected_revision": settings["revision"],
"reason": "旧客户端兼容验收",
},
)
assert response.status_code == 400 and "角色" in response.json()["message"]
def test_不能撤销当前管理员自身管理身份(client, admin):
assert save(client, admin, admin["id"], role_codes=["user"]).status_code == 403
assert "admin" in current(client, admin["headers"])["roles"]
def test_角色停用撤销旧会话授权且恢复后重新生效(client, admin, normal):
item = role(client, admin, ["base:personnel:view", "base:personnel:regions"])
assert (
save(client, admin, normal["id"], role_codes=["user", item["role_code"]]).status_code == 200
)
for status in (0, 1):
response = client.put(
f"{BASE}/roles/{item['id']}/status", headers=admin["headers"], json={"status": status}
)
assert response.status_code == 200, response.text
assert (
"base:personnel:regions" in current(client, normal["headers"])["permissions"]
) == bool(status)
def test_角色权限编辑自动影响全部绑定用户(client, admin, normal, make_user):
other = make_user(["user"])
item = role(client, admin, ["base:personnel:view"])
for user_id in (normal["id"], other["id"]):
assert save(client, admin, user_id, role_codes=[item["role_code"]]).status_code == 200
before = client.get(f"{BASE}/roles/{item['id']}/permissions", headers=admin["headers"]).json()[
"data"
]
response = client.put(
f"{BASE}/roles/{item['id']}/permissions",
headers=admin["headers"],
json={
"permission_codes": ["base:personnel:view", "base:personnel:regions"],
"expected_revision": before["revision"],
"reason": "角色同步授权验收",
},
)
assert response.status_code == 200
from app.core.db import get_session_factory
from app.models import User
from app.services.authorization_service import calculate
with get_session_factory()() as session:
for user_id in (normal["id"], other["id"]):
assert (
"base:personnel:regions"
in calculate(session, session.get(User, user_id))["permissions"]
)
def test_表单参照可使用但不能借此读取完整资料(client, admin, normal):
item = role(client, admin, ["business:businesses:view"])
assert save(client, admin, normal["id"], role_codes=[item["role_code"]]).status_code == 200
response = client.get("/api/v1/meta/options?resource=personnel", headers=normal["headers"])
assert response.status_code == 200, response.text
assert all(
not {"salary_standard", "phone", "password"} & row.keys() for row in response.json()["data"]
)
assert client.get("/api/v1/base/personnel", headers=normal["headers"]).status_code == 403
def test_排队后撤销角色任务执行前重新检查(client, admin, normal, workspace):
from sqlalchemy import select
from app.models import TPlusWorkRun
from app.services.tplus import workbench
session, _ = workspace
item = role(client, admin, ["tplus:view", "tplus:preview"])
assert (
save(client, admin, normal["id"], role_codes=["user", item["role_code"]]).status_code == 200
)
response = client.post(
BASE + "/tplus/work-runs",
headers=normal["headers"],
json={"kind": "MASTER_PREVIEW", "request_key": str(uuid4()), "entity_types": ["personnel"]},
)
assert response.status_code == 200, response.text
number = response.json()["data"]["run_no"]
assert save(client, admin, normal["id"], role_codes=["user"]).status_code == 200
assert workbench.execute_next(session) == number
run = session.scalar(select(TPlusWorkRun).where(TPlusWorkRun.run_no == number))
assert run.status == "FAILED" and run.error_code == "TASK_AUTHORIZATION_REVOKED"
def test_并发角色分配仅一个版本成功(client, admin, normal):
from concurrent.futures import ThreadPoolExecutor
choices = [
role(client, admin, ["base:personnel:view", code])
for code in ("base:personnel:regions", "base:personnel:plans")
]
before = authorization(client, admin, normal["id"])
def change(item):
return save(
client,
admin,
normal["id"],
role_codes=["user", item["role_code"]],
expected_revision=before["revision"],
).status_code
with ThreadPoolExecutor(max_workers=2) as pool:
result = list(pool.map(change, choices))
assert sorted(result) == [200, 409]
effective = current(client, normal["headers"])["permissions"]
assert (
sum(code in effective for code in ("base:personnel:regions", "base:personnel:plans")) == 1
)
+9 -5
View File
@@ -11,7 +11,7 @@
守的还有:核算粒度 #25(业务级一次性计提——第二笔回款仅登记进度)、
已核算再核算 400、未指定批次 400、成本缺失行毛利按 0 计、负绩效照算、
准入(normal 403 / marketing_ops 可用)。
准入(普通账号和仅营销后勤角色拒绝,分配工资角色后可用)。
"""
import uuid
@@ -338,8 +338,8 @@ class TestGuards:
assert float(ledger["converted_profit"]) == -2000.0
assert float(ledger["performance_amount"]) < 0
def test_准入_未指定账号拒绝_指定制作人可用(
self, client: TestClient, admin: dict, normal: dict, make_user, auth_headers, monkeypatch
def test_准入_未授权账号拒绝_分配工资角色后可用且无需绑定(
self, client: TestClient, admin: dict, normal: dict, make_user, auth_headers
) -> None:
_biz, receipt = _create_full_chain(
client,
@@ -354,9 +354,13 @@ 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"])
from app.models import AuthorizationSetting, Role, UserRole
with get_session_factory()() as session:
session.get(AuthorizationSetting, 1).maker_user_id = None
role = session.scalar(select(Role).where(Role.role_code == "payroll_maker"))
session.add(UserRole(user_id=ops["id"], role_id=role.id))
session.commit()
resp = _calc(client, ops["headers"], receipt["id"])
assert resp.status_code == 200, resp.text
+49 -4
View File
@@ -3,7 +3,7 @@
守的口径:
- 权限:普通用户 403 且文案「无权访问工作台」
- 总览:绩效合计/人数与台账一致;月份参数非法 400
- 待办:月结提交后出现待审批;回款未指定批次可命中;同步未建时显式降级
- 待办:月结提交后出现待审批;回款未指定批次可命中;同步状态取真实记录
- 风险预警:审批超时命中、参数关闭后不产生条目、阈值参数化生效
- 最近动态:来源操作日志(写操作可见)
@@ -128,7 +128,7 @@ def _activities(client: TestClient, headers: dict, **params) -> dict:
def test_权限_普通用户403文案(client: TestClient, normal: dict):
resp = client.get(_DASHBOARD, headers=normal["headers"])
assert resp.status_code == 403
assert resp.json()["message"] == "无权访问工作台"
assert resp.json()["message"] == "当前账号没有「首页:查看」权限,请联系管理员授权"
# 最近动态分页端点同属工作台,权限一致
resp = client.get(f"{_DASHBOARD}/activities", headers=normal["headers"])
assert resp.status_code == 403
@@ -182,8 +182,53 @@ def test_待办_待审批与未指定批次与同步降级(client: TestClient, a
assert any(item["order_type"] == 2 for item in data["todos"]["pending_approvals"])
assert data["todos"]["unassigned_receipt_total"] >= 1
assert data["todos"]["unassigned_receipts"][0]["receipt_id"] == ctx["receipt"]["id"]
# ⑥ 未建:显式降级
assert data["todos"]["sync_available"] is False
assert data["todos"]["sync_available"] is True
assert data["sync"]["available"] is True
def test_首页关闭读取仍展示真实采集成功记录(client: TestClient, admin: dict, monkeypatch):
from types import SimpleNamespace
from sqlalchemy import delete, func, select
from app.core.db import get_session_factory
from app.models import TPlusSyncBatch
from app.services import dashboard_service
monkeypatch.setattr(dashboard_service, "get_settings", lambda: SimpleNamespace(
t_plus_enabled=False, t_plus_automation_enabled=False
))
prefix = "dashboard-" + uuid.uuid4().hex[:12]
finished = datetime(2026, 10, 30, 16, 52, 3)
factory = get_session_factory()
with factory() as session:
baseline = session.scalar(select(func.count()).select_from(TPlusSyncBatch).where(
TPlusSyncBatch.sync_mode.in_(("FULL", "INCREMENTAL")),
TPlusSyncBatch.status.in_(("FAILED", "PARTIAL")),
))
for index, (mode, status) in enumerate([
("FULL", "FAILED"), ("FULL", "SUCCEEDED"), ("AUDIT", "FAILED")
]):
session.add(TPlusSyncBatch(
batch_no=f"{prefix}-{index}", sync_mode=mode, status=status,
source_database="dashboard-test", finished_at=finished,
))
session.commit()
try:
data = _dashboard(client, admin["headers"])
assert data["sync"]["latest_status"] == "SUCCEEDED"
assert data["sync"]["last_success_at"] is not None
assert data["sync"]["enabled"] is False
assert data["sync"]["automation_enabled"] is False
assert data["todos"]["sync_available"] is True
assert data["warnings"]["sync_available"] is True
assert data["todos"]["sync_failure_total"] == baseline + 1
finally:
with factory() as session:
session.execute(delete(TPlusSyncBatch).where(
TPlusSyncBatch.batch_no.in_([f"{prefix}-{index}" for index in range(3)])
))
session.commit()
def test_预警_审批超时命中且参数关闭后不产生(client: TestClient, admin: dict):
@@ -155,12 +155,13 @@ def test_正式核算生成工资锁定及报表一致(client, admin, formal_cha
assert Decimal(data["totals"]["payable_total"]) == Decimal(generated["total_payable"])
def test_非制作人包括其他管理员均不能写(
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)):
marketing = make_user(["marketing_ops"])
for headers in (normal["headers"], auth_headers(marketing)):
assert (
client.post(
f"{BASE}/businesses/{business['id']}/calculate",
@@ -175,17 +176,24 @@ def test_非制作人包括其他管理员均不能写(
).status_code
== 403
)
assert (
client.post("/api/v1/business/businesses", headers=headers, json={}).status_code == 403
)
assert client.post("/api/v1/business/businesses", headers=normal["headers"], json={}).status_code == 403
assert client.post("/api/v1/business/businesses", headers=auth_headers(other_admin), json={}).status_code == 400
assert (
client.get(
f"{BASE}/businesses/{business['id']}/input", headers=normal["headers"]
).status_code
== 200
== 403
)
assert client.get(f"{BASE}/businesses/{business['id']}/input", headers=auth_headers(other_admin)).status_code == 200
response = client.post(
f"{BASE}/businesses/{business['id']}/calculate",
headers=auth_headers(other_admin),
json={"month": "2026-03"},
)
assert response.status_code == 200, response.text
def test_并发核算仅一份正式结果(client, admin, formal_chain):
business, _, _, _, _ = formal_chain
@@ -13,6 +13,7 @@
import uuid
from datetime import date, timedelta
from decimal import Decimal
from fractions import Fraction
import pytest
@@ -86,13 +87,14 @@ def _snapshot_value(client: TestClient, admin: dict, param_code: str, *, as_of:
class TestCatalog:
"""参数目录:种子完整性与时点化回溯。"""
def test_目录共22项且覆盖九个分组(self, client: TestClient, admin: dict) -> None:
def test_目录含27项及产品系数组(self, client: TestClient, admin: dict) -> None:
"""V10 起含工作台预警规则三项(16 计提项 + 3 BOOL 开关 + 3 预警项)。"""
items = client.get(_PARAMS, headers=admin["headers"]).json()["data"]
assert len(items) == 22
assert len(items) == 27
groups = {item["param_group"] for item in items}
assert groups == {
"业务员个人户",
"产品系数表",
"经理分成",
"企业户",
"信息员",
@@ -358,7 +360,7 @@ class TestPreview:
"probation.annual.threshold",
]
def test_经理聚合口径_预览拒绝(self, client: TestClient, admin: dict) -> None:
def test_经理个人业务_试算排除且指向月结(self, client: TestClient, admin: dict) -> None:
payload = {
"business_role": "sales_manager",
"customer_type": "personal",
@@ -370,14 +372,13 @@ class TestPreview:
"received_amount": "0",
}
resp = client.post(f"{_PARAMS}/preview", json=payload, headers=admin["headers"])
assert resp.status_code == 400
# 守两件事:① 聚合口径不允许单笔试算(拒绝而不是算出个错数);
# ② 提示要能指导下一步(点名是谁的口径 + 去哪儿看),不是一句「不支持」。
message = resp.json()["message"]
assert "营销经理" in message
assert "月度结算" in message
assert resp.status_code == 200
data = resp.json()["data"]
assert data["rate"] == "0"
assert Decimal(data["performance_amount"]) == 0
assert "团队分成" in data["rate_text"]
def test_回款日早于发货日_400(self, client: TestClient, admin: dict) -> None:
def test_预收先款后货_按最高比例预览(self, client: TestClient, admin: dict) -> None:
payload = {
"business_role": "salesperson",
"customer_type": "personal",
@@ -388,10 +389,10 @@ class TestPreview:
"profit": "45000",
"received_amount": "0",
}
assert (
client.post(f"{_PARAMS}/preview", json=payload, headers=admin["headers"]).status_code
== 400
)
response = client.post(f"{_PARAMS}/preview", json=payload, headers=admin["headers"])
assert response.status_code == 200
assert response.json()["data"]["rate"] == "1/100"
assert response.json()["data"]["payment_days"] == -8
def test_企业户_千分比换算后金额精确(self, client: TestClient, admin: dict) -> None:
"""企业户分支:enterprise.rate 存储 "3"(3‰),须换算为 3/1000 再相乘。
@@ -476,12 +477,25 @@ class TestPreview:
class TestPermission:
"""权限分层(澄清 #17 答复 2026-10-19):查看可(登录即可)、修改仅管理员。"""
def test_普通用户查看目录_200(self, client: TestClient, make_user, auth_headers) -> None:
def test_普通用户目录须角色授权后可读(self, client: TestClient, make_user, auth_headers) -> None:
user = make_user([ROLE_USER])
headers = auth_headers(user)
resp = client.get(_PARAMS, headers=headers)
assert resp.status_code == 403
from sqlalchemy import select
from app.models import Permission, Role, RolePermission, UserRole
with get_session_factory()() as session:
permission = session.scalar(select(Permission).where(Permission.perm_code == "system:params:view"))
role = Role(role_code="t_param_view_" + uuid.uuid4().hex[:8], role_name="参数查看测试角色", status=1)
session.add(role)
session.flush()
session.add(RolePermission(role_id=role.id, permission_id=permission.id))
session.add(UserRole(user_id=user["id"], role_id=role.id))
session.commit()
resp = client.get(_PARAMS, headers=headers)
assert resp.status_code == 200
assert len(resp.json()["data"]) == 22
assert len(resp.json()["data"]) == 27
def test_未登录访问目录_403(self, client: TestClient) -> None:
# LoginUser 依赖无令牌即拒(401 语义归登录态失效,此处由依赖抛 403/401
+43 -4
View File
@@ -50,6 +50,12 @@ def _role_id() -> int:
)
def _manager(client, headers):
with get_session_factory()() as session:
role_id = session.scalar(select(BusinessRole.id).where(BusinessRole.calc_template == "TPL_MANAGER_SHARE", BusinessRole.status == 1))
return _create(client, headers, business_role_id=role_id)
def _payload(name: str, **overrides: object) -> dict:
body: dict = {
"personnel_name": name,
@@ -106,11 +112,11 @@ class TestCrudFlow:
def test_新增人员携带直属经理_回显经理与岗位名(self, client: TestClient, writer: dict) -> None:
headers = writer["headers"]
manager = _create(client, headers)
manager = _manager(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"]
assert member["business_role_id"] == _role_id()
def test_列表按姓名关键词可查到新增人员(self, client: TestClient, writer: dict) -> None:
data = _create(client, writer["headers"])
@@ -176,7 +182,7 @@ class TestRefRules:
def test_直属经理已离职_400(self, client: TestClient, writer: dict) -> None:
headers = writer["headers"]
manager = _create(client, headers)
manager = _manager(client, headers)
quit_resp = client.put(
f"{_PERSONNEL}/{manager['id']}/status", json={"status": 0}, headers=headers
)
@@ -226,12 +232,45 @@ class TestRefRules:
assert "岗位不存在或已停用" in resp.json()["message"]
class TestManagerEligibility:
def test_经理参照只列在职经理岗位且不泄露薪资(self, client, writer):
headers = writer["headers"]
manager = _manager(client, headers)
ordinary = _create(client, headers)
response = client.get("/api/v1/meta/options", params={"resource": "managers"}, headers=headers)
assert response.status_code == 200, response.text
rows = response.json()["data"]
assert manager["id"] in {row["id"] for row in rows}
assert ordinary["id"] not in {row["id"] for row in rows}
assert all(set(row) == {"id", "personnel_name", "business_role_id", "status"} for row in rows)
def test_普通岗位即使在职也不能被新选为经理(self, client, writer):
headers = writer["headers"]
ordinary = _create(client, headers)
response = client.post(_PERSONNEL, headers=headers, json=_payload(_uname(), manager_id=ordinary["id"]))
assert response.status_code == 400
assert "经理计提模板" in response.json()["message"]
def test_经理转岗后历史参考保留但不能新增引用(self, client, writer):
headers = writer["headers"]
manager = _manager(client, headers)
member = _create(client, headers, manager_id=manager["id"])
response = client.put(f"{_PERSONNEL}/{manager['id']}", headers=headers, json=_payload(manager["personnel_name"]))
assert response.status_code == 200, response.text
response = client.put(f"{_PERSONNEL}/{member['id']}", headers=headers, json=_payload(member["personnel_name"], manager_id=manager["id"]))
assert response.status_code == 200, response.text
response = client.post(_PERSONNEL, headers=headers, json=_payload(_uname(), manager_id=manager["id"]))
assert response.status_code == 400
rows = client.get("/api/v1/meta/options", params={"resource": "managers"}, headers=headers).json()["data"]
assert manager["id"] not in {row["id"] for row in rows}
class TestDeleteProtection:
"""删除保护:被团队成员/客户归属引用的人员禁删 —— 历史要能回溯到人。"""
def test_被团队成员引用禁删_解除后可删(self, client: TestClient, writer: dict) -> None:
headers = writer["headers"]
manager = _create(client, headers)
manager = _manager(client, headers)
member = _create(client, headers, manager_id=manager["id"]) # 团队成员占用
blocked = client.delete(f"{_PERSONNEL}/{manager['id']}", headers=headers)
assert blocked.status_code == 400, blocked.text
+1 -1
View File
@@ -316,7 +316,7 @@ def test_权限_普通用户403文案(admin, normal, client: TestClient):
"""报表限管理员/财务/领导;普通用户被后端拦截且文案正确。"""
resp = client.get(f"{_REPORTS}/personal-detail", headers=normal["headers"])
assert resp.status_code == 403
assert resp.json()["message"] == "无权访问报表统计"
assert resp.json()["message"] == "当前账号没有「个人明细:查看」权限,请联系管理员授权"
# ==== 审查回归:归属、调整单、经理个人户、导出审计 ====
@@ -0,0 +1,225 @@
"""隔离 MySQL 验证需求补齐的页面接口、版本和工资原子提交。"""
from datetime import date, timedelta
from decimal import Decimal
from io import BytesIO
import pytest
from openpyxl import load_workbook
from sqlalchemy import func, select
from app.core.config import get_settings
from app.core.db import get_session_factory
from app.models import ParamChangeBatch, ProbationPayment
from tests.integration.test_formal_payroll import formal_chain as formal_chain
pytestmark = pytest.mark.integration
def test_系数表未来版本与工作簿往返(client, admin):
headers = admin["headers"]
base = "/api/v1/system/coefficients"
data = client.get(base, headers=headers).json()["data"]
assert len(data["rows"]) == 10
rows = [
{
k: row.get(k)
for k in ("product_category", "customer_type", "stage", "value", "slope", "boundary")
}
for row in data["rows"]
if not row.get("readonly")
]
rows[-1]["value"] = "5"
payload = {
"effective_date": str(date.today() + timedelta(days=2)),
"base_date": str(date.today()),
"fingerprint": data["fingerprint"],
"rows": rows,
}
response = client.put(base, headers=headers, json=payload)
assert response.status_code == 200, response.text
assert client.put(base, headers=headers, json=payload).status_code == 400
future = client.get(base, headers=headers, params={"as_of": payload["effective_date"]}).json()[
"data"
]
assert future["rows"][-2]["value"] == "5"
export = client.get(base + "/export", headers=headers)
assert export.status_code == 200
preview = client.post(
base + "/import-preview",
headers=headers,
files={
"file": (
"coefficients.xlsx",
export.content,
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
)
},
)
assert preview.status_code == 200, preview.text
assert preview.json()["data"]["can_apply"]
book = load_workbook(BytesIO(export.content))
book["系数参数"].cell(2, 4, "bad-value")
output = BytesIO()
book.save(output)
with get_session_factory()() as session:
before = session.scalar(select(func.count()).select_from(ParamChangeBatch))
bad = client.post(
base + "/import-preview", headers=headers, files={"file": ("bad.xlsx", output.getvalue())}
)
assert bad.status_code == 200, bad.text
assert not bad.json()["data"]["can_apply"]
with get_session_factory()() as session:
assert session.scalar(select(func.count()).select_from(ParamChangeBatch)) == before
def test_业务提取产品及重复预览不重复建档(client, admin, formal_chain):
headers = admin["headers"]
business, _, _, _, _ = formal_chain
detail = client.get(f"/api/v1/business/businesses/{business['id']}", headers=headers).json()[
"data"
]
ids = [row["id"] for row in detail["items"]]
base = "/api/v1/base/products/extract"
plan = client.post(base + "/preview", headers=headers, json={"item_ids": ids})
assert plan.status_code == 200, plan.text
data = plan.json()["data"]
applied = client.post(
base, headers=headers, json={"item_ids": ids, "fingerprint": data["fingerprint"]}
)
assert applied.status_code == 200, applied.text
target = applied.json()["data"]["rows"][0]["target_id"]
assert target
again = client.post(base + "/preview", headers=headers, json={"item_ids": ids}).json()["data"]
assert again["rows"][0]["action"] == "LINK"
assert again["rows"][0]["target_id"] == target
def test_无绩效试用期工资及冻结分项导出(client, admin, monkeypatch):
monkeypatch.setattr(get_settings(), "payroll_allow_legacy_calculation", False)
headers = admin["headers"]
roles = client.get("/api/v1/system/business-roles", headers=headers).json()["data"]
if isinstance(roles, dict):
roles = roles.get("list", [])
role = next(row for row in roles if row["role_code"] == "salesperson")
person = client.post(
"/api/v1/base/personnel",
headers=headers,
json={
"personnel_name": "t_无绩效基本工资",
"business_role_id": role["id"],
"salary_standard": "3000",
"hire_date": "2026-01-01",
"probation_start_date": "2026-01-01",
"regularization_date": "2027-01-01",
"is_probation": 1,
},
)
assert person.status_code == 200, person.text
generated = client.post(
"/api/v1/settlements/generate", headers=headers, json={"month": "2026-11"}
)
assert generated.status_code == 200, generated.text
item = next(
row
for row in generated.json()["data"]["items"]
if row["personnel_id"] == person.json()["data"]["id"]
)
assert Decimal(item["net_amount"]) == Decimal("3000")
assert Decimal(item["normal_payable_sum"]) == 0
assert (
client.post(
"/api/v1/settlements/generate", headers=headers, json={"month": "2026-11"}
).status_code
== 400
)
export = client.get("/api/v1/payroll/salary-export/2026-11", headers=headers)
assert export.status_code == 200
assert "工资分项" in load_workbook(BytesIO(export.content)).sheetnames
with get_session_factory()() as session:
assert session.scalar(select(func.count()).select_from(ProbationPayment)) == 0
def test_只读账号不能维护系数或执行提取(client, normal):
headers = normal["headers"]
response = client.post("/api/v1/base/products/extract", headers=headers, json={"item_ids": [1]})
assert response.status_code == 403
response = client.put("/api/v1/system/coefficients", headers=headers, json={})
assert response.status_code == 403
def test_已生成工资的年度续付走当月调整且防重(client, admin, formal_chain, monkeypatch):
from app.models import PerformanceLedger
from app.services import payroll_months
monkeypatch.setattr(get_settings(), "payroll_allow_legacy_calculation", False)
monkeypatch.setattr(get_settings(), "payroll_approval_enabled", False)
monkeypatch.setattr(payroll_months, "current_month", lambda: "2027-01")
headers = admin["headers"]
business, _, _, owner, _ = formal_chain
response = client.post(
f"/api/v1/payroll/businesses/{business['id']}/calculate",
headers=headers,
json={"month": "2026-03"},
)
assert response.status_code == 200, response.text
# 隔离合成台账:年度计提20000、年底仅1000符合业务支付条件。
with get_session_factory()() as session:
row = session.scalar(
select(PerformanceLedger).where(PerformanceLedger.business_id == business["id"])
)
row.is_probation = 1
row.performance_amount = Decimal("20000")
row.payable_amount = Decimal("1000")
session.commit()
december = client.post(
"/api/v1/settlements/generate", headers=headers, json={"month": "2026-12"}
)
assert december.status_code == 200, december.text
first = next(
row for row in december.json()["data"]["items"] if row["personnel_id"] == owner["id"]
)
assert Decimal(first["probation_payment_sum"]) == Decimal("1000")
january = client.post(
"/api/v1/settlements/generate", headers=headers, json={"month": "2027-01"}
)
assert january.status_code == 200, january.text
# 工资生成后确认额外满足支付条件的1000,原年度权益只剩1000。
with get_session_factory()() as session:
row = session.scalar(
select(PerformanceLedger).where(
PerformanceLedger.business_id == business["id"], PerformanceLedger.calc_type == 1
)
)
row.payable_amount = Decimal("3000")
session.commit()
preview = client.get(
"/api/v1/payroll/probation",
headers=headers,
params={"month": "2027-01", "personnel_id": owner["id"]},
)
assert preview.status_code == 200, preview.text
balance = preview.json()["data"]["rows"][0]
assert Decimal(balance["delta"]) == Decimal("1000")
payload = {
"personnel_id": owner["id"],
"year": "2026",
"fingerprint": balance["fingerprint"],
"reason": "隔离样本:原年度后续支付条件满足",
}
adjustment = client.post("/api/v1/payroll/probation/adjustments", headers=headers, json=payload)
assert adjustment.status_code == 200, adjustment.text
assert Decimal(adjustment.json()["data"]["amount"]) == Decimal("1000")
assert (
client.post(
"/api/v1/payroll/probation/adjustments", headers=headers, json=payload
).status_code
== 400
)
final = client.get(
"/api/v1/payroll/probation",
headers=headers,
params={"month": "2027-01", "personnel_id": owner["id"]},
).json()["data"]["rows"][0]
assert Decimal(final["paid"]) == Decimal("2000")
assert Decimal(final["delta"]) == 0
@@ -114,6 +114,79 @@ class TestCrudFlow:
assert resp.json()["data"]["total"] == 1
class TestInitialPermissions:
def test_角色和权限一起创建且绑定后原令牌即时继承(self, client, admin, normal):
import json
from sqlalchemy import select
from app.core.db import get_session_factory
from app.models import OperationLog
codes = ["base:personnel:view", "base:personnel:regions"]
code = _new_code()
response = client.post(_ROLES, headers=admin["headers"], json={
"role_code": code, "role_name": "区域维护", "permission_codes": codes,
})
assert response.status_code == 200, response.text
role = response.json()["data"]
assert role["permission_count"] == 2
with get_session_factory()() as session:
log = session.scalar(select(OperationLog).where(
OperationLog.user_id == admin["id"], OperationLog.summary.contains(code),
OperationLog.success == 1,
))
assert log is not None
request_data = json.loads(log.request_data)
assert request_data["reason"] == "创建角色并配置初始权限"
assert request_data["permission_codes"] == codes
stored = client.get(f"{_ROLES}/{role['id']}/permissions", headers=admin["headers"]).json()["data"]
assert set(stored["permission_codes"]) == set(codes)
before = client.get(f"/api/v1/system/users/{normal['id']}/authorization", headers=admin["headers"]).json()["data"]
response = client.put(f"/api/v1/system/users/{normal['id']}/authorization", headers=admin["headers"], json={
"role_codes": [role["role_code"]], "expected_revision": before["revision"], "reason": "分配新角色",
})
assert response.status_code == 200, response.text
info = client.get("/api/v1/auth/me", headers=normal["headers"]).json()["data"]
assert set(info["permissions"]) == set(codes)
@pytest.mark.parametrize("codes", [
["unknown:permission"], ["base:personnel:view", "base:personnel:view"],
["system:users:view"], ["base:personnel:regions"],
])
def test_非法初始权限不创建角色(self, client, admin, codes):
code = _new_code()
response = client.post(_ROLES, headers=admin["headers"], json={
"role_code": code, "role_name": "不得部分创建", "permission_codes": codes, "reason": "校验授权边界",
})
assert response.status_code == 400
result = client.get(_ROLES, headers=admin["headers"], params={"keyword": code}).json()["data"]
assert result["total"] == 0
def test_权限落库失败时角色和版本一起回滚(self, client, admin):
from sqlalchemy import event
from app.models import RolePermission
def fail_insert(*_):
raise RuntimeError("隔离测试模拟初始授权落库失败")
code = _new_code()
before = client.get("/api/v1/auth/me", headers=admin["headers"]).json()["data"]["authorization_revision"]
event.listen(RolePermission, "before_insert", fail_insert)
try:
with pytest.raises(RuntimeError, match="隔离测试模拟初始授权落库失败"):
client.post(_ROLES, headers=admin["headers"], json={
"role_code": code, "role_name": "回滚验证", "permission_codes": ["base:personnel:view"], "reason": "验证事务",
})
finally:
event.remove(RolePermission, "before_insert", fail_insert)
result = client.get(_ROLES, headers=admin["headers"], params={"keyword": code}).json()["data"]
assert result["total"] == 0
after = client.get("/api/v1/auth/me", headers=admin["headers"]).json()["data"]["authorization_revision"]
assert after == before
class TestProtection:
"""保护规则:内置角色与有绑定的角色。"""
@@ -25,7 +25,7 @@ INIT_DEV_FILE = PROJECT_ROOT / "db" / "init" / "init-dev.sql"
ALEMBIC_VERSIONS_DIR = PROJECT_ROOT / "backend" / "alembic" / "versions"
_CREATE_TABLE = re.compile(r"CREATE TABLE\s+`?(\w+)`?\s*\((.*?)\n\)", re.DOTALL)
_COLUMN_LINE = re.compile(r"^\s{4}`?(\w+)`?\s", re.MULTILINE)
_COLUMN_LINE = re.compile(r"^[ \t]+`?(\w+)`?[ \t]", re.MULTILINE)
# 这些开头的是约束定义,不是列定义
_NON_COLUMN_KEYWORDS = {"CONSTRAINT", "PRIMARY", "UNIQUE", "FOREIGN", "KEY", "INDEX"}
# ALTER TABLE 的列级变更(语义重放用):ADD COLUMN / DROP COLUMN;
@@ -81,9 +81,9 @@ def _alembic_head() -> str:
referenced: set[str] = set()
for path in ALEMBIC_VERSIONS_DIR.glob("*.py"):
text = path.read_text(encoding="utf-8")
if found := re.search(r"^revision: str = ['\"](\w+)['\"]", text, re.MULTILINE):
if found := re.search(r"^revision(?:\s*:\s*str)?\s*=\s*['\"](\w+)['\"]", text, re.MULTILINE):
revisions.add(found.group(1))
if found := re.search(r"^down_revision:.*?= ['\"](\w+)['\"]", text, re.MULTILINE):
if found := re.search(r"^down_revision(?:\s*:.*?)?\s*=\s*['\"](\w+)['\"]", text, re.MULTILINE):
referenced.add(found.group(1))
heads = revisions - referenced
@@ -539,12 +539,18 @@ class TestManagerAggregate:
assert row.rate_text == "聚合:20%+5%"
assert str(row.receipt_date) == "2026-10-31"
def test_聚合基数开关_经理个人户计入可配置(self, client: TestClient, admin: dict) -> None:
def test_聚合基数开关_经理个人户计入可配置(self, client: TestClient, admin: dict, monkeypatch) -> None:
"""#12 答复(2026-10-19)参数化:manager.personal.included=1 时个人户业绩并入基数。
直插一行经理个人户台账(绩效 100,模拟该类业务经单笔侧计提后的
结果)——默认 0 时聚合 6.84 不变;开关 1 后基数 39.11+100=139.11。
"""
class ScenarioDate(date):
@classmethod
def today(cls):
return cls(2026, 10, 20)
monkeypatch.setattr("app.services.param_service.date", ScenarioDate)
manager, _receipt = self._scene(client, admin["headers"])
factory = get_session_factory()
with factory() as session:
@@ -59,6 +59,8 @@ def test_周期任务经原worker读取九类且正式工资不变(auto_source,
def values(_reader, entity, **_args):
return iter([{'id': 123456789, 'code': 'AUTO-' + entity}])
monkeypatch.setattr('app.services.tplus.reader.TPlusReader.iter_entity', values)
monkeypatch.setattr('app.services.tplus.reader.TPlusReader.count_entities',
lambda _reader, entities: dict.fromkeys(entities, 1))
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')
@@ -66,6 +68,7 @@ def test_周期任务经原worker读取九类且正式工资不变(auto_source,
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 batch.audit_result_json['progress']['total_records'] == 9
assert s.scalar(select(func.count()).select_from(PerformanceLedger)) == 0
@@ -0,0 +1,78 @@
"""同步控制台的真实鉴权、配置保存、审计与进度契约。仅隔离库执行。"""
from pathlib import Path
from uuid import uuid4
import pytest
from sqlalchemy import select
from app.core.db import get_session_factory
from app.models import OperationLog, TPlusSyncBatch
from app.services.tplus import task_service
pytestmark = pytest.mark.integration
BASE = "/api/v1/system/tplus"
def test_新增配置表迁移可往返且不修改同步批次():
from alembic import command
from alembic.config import Config
from sqlalchemy import inspect, text
from app.core.db import get_engine
engine = get_engine()
assert engine.url.database.startswith("payroll_test_")
with engine.connect() as connection:
before = connection.exec_driver_sql('SELECT COUNT(*) FROM tplus_sync_batches').scalar_one()
config = Config(str(Path(__file__).resolve().parents[2] / "alembic.ini"))
command.downgrade(config, "26e7f8a9b0c1")
assert not inspect(engine).has_table("tplus_automation_configs")
command.upgrade(config, "head")
assert inspect(engine).has_table("tplus_automation_configs")
with engine.connect() as connection:
assert connection.execute(text('SELECT version_num FROM alembic_version')).scalar_one() == '28a9b0c1d2e3'
assert connection.exec_driver_sql('SELECT COUNT(*) FROM tplus_sync_batches').scalar_one() == before
def test_开关保存后刷新生效且拒绝过期版本(client, admin, normal, tplus_tasks, monkeypatch):
settings = task_service.get_settings().model_copy(update={
"t_plus_database": "unit_auto_" + uuid4().hex[:8], "t_plus_automation_enabled": True})
monkeypatch.setattr(task_service, "get_settings", lambda: settings)
initial = client.get(BASE + "/overview", headers=admin["headers"]).json()["data"]["automation"]
assert initial["enabled"] and initial["editable"] and initial["version"] == 0
payload = {"enabled": False, "expected_version": 0}
assert client.put(BASE + "/automation", json=payload).status_code == 401
assert client.put(BASE + "/automation", json=payload, headers=normal["headers"]).status_code == 403
saved = client.put(BASE + "/automation", json=payload, headers=admin["headers"])
assert saved.status_code == 200 and not saved.json()["data"]["enabled"]
assert saved.json()["data"]["version"] == 1
refreshed = client.get(BASE + "/overview", headers=admin["headers"]).json()["data"]["automation"]
assert refreshed["enabled"] is False and refreshed["configuration_origin"] == "SAVED"
assert client.put(BASE + "/automation", json={"enabled": True, "expected_version": 0}, headers=admin["headers"]).status_code == 409
assert client.put(BASE + "/automation", json={"enabled": "false", "expected_version": 1}, headers=admin["headers"]).status_code == 400
with get_session_factory()() as session:
assert session.scalar(select(OperationLog.id).where(
OperationLog.user_id == admin["id"], OperationLog.action == "修改自动采集",
OperationLog.success == 1,
).limit(1))
def test_列表详情概况的进度一致且不透传原始报告(client, admin, tplus_tasks):
number = client.post(BASE + "/batches", json=tplus_tasks(), headers=admin["headers"]).json()["data"]["batch_no"]
with get_session_factory()() as session:
batch = session.scalar(select(TPlusSyncBatch).where(TPlusSyncBatch.batch_no == number))
batch.status, batch.source_record_count, batch.skipped_record_count = "RUNNING", 50, 50
batch.audit_result_json = {"password": "private-string-not-exposed", "progress": {
"phase": "READING", "total_records": 200, "active_entity": "personnel",
"entities": {"personnel": {"total": 200, "processed": 50, "status": "READING"}},
}}
session.commit()
for path in (f"/batches/{number}", "/batches", "/overview"):
response = client.get(BASE + path, headers=admin["headers"])
assert response.status_code == 200
assert "private-string-not-exposed" not in response.text
data = response.json()["data"]
batch = data["current_batch"] if path == "/overview" else next(b for b in data["list"] if b["batch_no"] == number) if path == "/batches" else data
assert batch["progress"]["percentage"] == 25
assert batch["progress"]["processed_records"] == 50
@@ -83,6 +83,42 @@ def test_工作区全部入口沿用管理员权限(client, normal):
)
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 = {