feat(后端): 添加授权体系、系统监控与试用期计提
按 2026-11-02 需求补齐计划交付后端能力: - 角色授权:权限目录化,接口统一准入校验,未登记默认拒绝; 管理员判断改为按数据库角色重新判定,防止权限码持有者绕过 - 系统监控:单进程采集资源、请求与异常指标,分钟级聚合, 阈值配置化;敏感配置增加 repr 保护,Redis 密码 URL 转义 - 试用期计提:按回款日判定并写入快照,年度结清与调整单联动; 人员模型补试用期、离职日期与工资版本历史字段 - 计提模板:公式骨架受控,页面维护名称与参数绑定 - T+ 采集:自动化配置表与进度查询 内置角色禁停用保护对齐模块注释;核算规则版本提为常量并 补充批量核算失败堆栈日志。 影响范围:后端全部模块、数据库迁移 V22-V28 验证:uv run ruff check 通过;922 个单元测试全部通过; 集成测试因本机 MySQL 数据卷被其他容器锁定暂未运行
This commit is contained in:
1 parent
02fb06e0af
commit
1461a8d113
149 files changed
+8736
-368
No files matched your search
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in new issue
Block a user