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

+57
View File
@@ -1,5 +1,55 @@
# 后端测试
## 角色授权与工资权限(2026-11-02)
- `tests/unit/test_authorization.py`:验证角色并集、查看依赖、多角色合并、即时撤权、管理员固定权限、旧个人覆盖拒绝、旧全局绑定不影响工资判定和已生成月份锁。
- `tests/integration/test_authorization.py`:隔离 MySQL 覆盖角色创建/授权、用户分配、旧会话即时生效、授权版本冲突、并发分配、排队任务撤权、工资角色免绑定和元数据脱敏。
- 最终复核:完整单元 916 项通过;隔离定点授权、结构同步与工资准入 41 项通过。V26 本地迁移前已备份,迁移后核验旧入口停用、全局制作人引用为空、个人覆盖为 0。
## 人员经理候选与产品体验回归(2026-11-02)
- `test_requirement_completion.py` 与 `test_authorization_policy.py` 相关单元 13 项通过;`test_personnel.py` 与 `test_requirement_completion.py` 隔离 MySQL 集成 29 项通过。
- 新增经理候选测试:普通在职岗位不能新选、经理参照只返回最小字段、经理转岗后旧参考可保留但不能新增引用。已有成功夹具使用真正经理岗位,未放宽校验或跳过用例。
- Ruff、前端类型/构建及 6 项本地真实接口浏览器检查通过;单元和集成日志 `.ai/product-personnel-*.log`,页面结果 `.ai/product-personnel-browser/result.json`。类型检查遇到本机 Node 分配失败后,以较小年轻代配置重跑通过,未降低标准。
- 用户明确要求的本地演示数据通过真实 API 新增并审计,清单 `.ai/product-demo-manifest.json`;未用集成清理夹具操作原开发库,也未核算或生成工资。
## 新增免填原因与确认框宽度(2026-11-02,当前行为)
- 用户要求新增不填写修改原因:创建请求可省略 `reason`,后台审计自动写入“创建角色并配置初始权限”,包含初始权限明细;修改既有权限仍要求原因。
- 相关单元 31 项、隔离 MySQL 初始授权 6 项通过,含自动审计及事务回滚。前端类型/构建、Ruff 通过;8 项真实浏览器检查通过,确认框在 2560/1440/768/375/320 宽度居中并保留默认最大宽度,新增无需原因可保存,修改保护继续生效。
- 证据 `.ai/role-create-no-reason-integration.log`、`.ai/role-create-no-reason/result.json`。下方上一轮“初始原因必填”仅是历史记录,已由本条更新。
## 新建角色同时授权与遮罩关闭修复(2026-11-02)
- `tests/unit/test_role_schemas.py tests/unit/test_authorization.py`:31 项通过,覆盖兼容旧创建载荷、初始权限原因必填及统一授权规则。
- `tests/integration/test_role_management.py tests/integration/test_authorization.py`:31 项隔离 MySQL 回归通过,覆盖创建即授权、绑定后旧令牌生效、未知/重复/管理员专用/缺查看权限拒绝,以及权限写入失败时角色和授权版本一起回滚。
- 前端类型、生产构建和相关 Ruff 通过;开发模式及固定生产构建分别完成 6 项无模拟接口浏览器检查,验证灰色遮罩关闭、未保存提醒、同页创建与权限落库、失败保留输入、768 宽度。
- 日志 `.ai/role-create-permissions-integration.log`;页面结果 `.ai/role-create-ui-dev/result.json`、`.ai/role-create-ui/result.json`。不改写上一轮全量测试数量,不操作生产或真实业务账号。
## 角色授权切换回归(2026-11-02)
- 按用户后续选择切换为“角色配置权限 → 用户分配角色”,取消个人覆盖和全局制作人绑定;月份锁、实际经办人及审批节点保护继续生效。
- 最新完整单元 **893 passed**;角色授权/参数/核算准入/结构同步 **31 passed**;正式工资/审批/月结/T+ 工作区 **52 passed**,共 83 项隔离 MySQL 专项通过。
- 修正旧测试约定:人员必须提供计提岗位,参数查看通过角色授予,未授权角色不能核算,管理员无需单独绑定。未放宽产品校验或跳过测试。
- 日志:`.ai/authorization-role-unit-final.log`、`.ai/authorization-current-recheck.log`、`.ai/authorization-role-sensitive.log`。先前角色切换期间的全量 420 passed / 4 failed 保留原记录,不写成一次性全量全绿。
- Ruff、前端类型及生产构建通过;真实无模拟接口浏览器 6 项通过,见 `.ai/authorization-role-real/result.json`。全部写入测试使用隔离库,未对本地业务库运行清理夹具。
## 客户页面参数说明核验(2026-11-02)
- 参数目录改为使用当前注册表的业务说明,历史参数值与生效日期仍从数据库记录读取;新增回归防止旧数据库说明继续误导页面,且不改写历史数值。
- 命令:`.venv-workbench/Scripts/python.exe -m pytest tests/unit/test_param_registry.py tests/unit/test_formula.py -q --tb=short -p no:cacheprovider`,**59 passed**;相关 Ruff 检查通过。
- 本次说明修订依据最新客户回执:系统审核默认关闭且不伪造通过痕迹,预收款实际发货后计提,低于毛利阈值的负结果仍按原规则计算。未修改公式或参数默认值,未运行业务数据写入测试。
## 系统监控(2026-11-02)
- `tests/unit/test_monitoring.py`、`tests/unit/test_monitor_service.py` 共 18 项通过:路由模板、4xx/5xx、近似 P95、有界缓冲、编号及日志关联、权限、脱敏导出、落库与重启历史、故障恢复、采集未知及后台错误去重。
- 本轮完整单元回归 884 项通过;新增监控 Python 文件 Ruff 检查通过。
- 修复验证发现的问题:遥测失败不得覆盖业务响应;重启首次 CPU 未知和 T+ 状态不可读时,不误标历史故障为恢复;迁移链检测兼容 Alembic 的无类型注解版本声明。
- 真实 MySQL/Redis 与迁移验证仅在显式创建的隔离测试库执行,不对原开发业务库运行集成清理夹具。页面浏览器验证不代替真实依赖验证。
- 真实隔离验证已通过:V23 升级/降级/再升级,原健康接口 7 项,三张新表 ORM/迁移 SQL/初始化 SQL 一致,管理员与普通用户真实鉴权、异常编号唯一关联、无人访问期间持续采集落库、重启后历史查询及业务表未写入。
- 真实页面无拦截联调通过,四页签脚本错误与监控 API 失败均为 0。修复 HTTP 查询参数 `hours` 的字符串转换,补测全部三个时间范围的趋势、接口和事件查询,18 项专项复验通过。
## 审查问题修复核验(2026-11-02)
- 修复正式核算起付比例的日期范围:2026-03-01 前的发货由期初应收承接,不再计入新业务销售额分母;与 FIFO 的启用边界保持一致。
@@ -336,3 +386,10 @@ uv run pytest tests/integration -q # 仅集成
**禁止直接对原业务库执行集成测试。** 运行 `python scripts/run_isolated_tests.py` 创建受限测试库;夹具在执行前及清理前核验显式标记、名称及真实引擎目标。运行器保留隔离库便于复查,不打印临时凭据。
最新完整集成补验:全部29个测试文件分两套独立数据库覆盖,215+184=399项通过,失败0、跳过0。上述旧夹具失败已经修复并关闭;最终结果以本条和开发记录末节为准。完整单元749通过,V18/V19往返及结构9通过。
\n\n## T+ 详情分项空值与去重回归(2026-11-02)\n\n- T+ 任务/工作台/监控相关单元 66 项通过;Ruff、前端类型和构建通过。模拟接口浏览器覆盖创建不弹详情、详情去重、轮询停止、异常与响应式、旧任务空分项说明。真实任务只读验证通过。证据 `.ai/tplus-detail-null-progress-unit.log`、`.ai/tplus-detail-browser/result.json`、`.ai/tplus-detail-live/result.json`。未操作生产或真实写入。\n
## 计提公式模板入库(2026-11-02)
- 模板定义迁移至 `calc_template_definitions`(V28 种子 5 个内置模板);骨架 `FORMULAS` 受控,核算/预览按骨架分派,岗位模板校验改为数据库定义优先、旧库注册表兜底。
- 更新 `test_param_registry` 模板断言:内置模板参数绑定覆盖骨架角色且参数编码已注册。单元 24 项通过;`test_calc_helpers`、`test_param_snapshot`、`test_formal_payroll` 合计 63 项通过;Ruff 与前端类型/构建通过。
- 岗位相关 MySQL 集成需独立测试库环境(`PAYROLL_TEST_DATABASE`),本轮未执行;新增模板 CRUD 集成用例待补。
+8 -1
View File
@@ -41,9 +41,16 @@ def client() -> TestClient:
注意:测试环境未必有 MySQL/Redis 在跑,健康检查会如实返回 false,
因此断言应聚焦于响应结构而非依赖的连通性。
"""
from unittest.mock import patch
from app.main import app
with TestClient(app) as test_client:
# 测试客户端不启动后台采集,避免单测通过环境配置写入现有业务库。
with (
patch.object(app.state.monitor, "start"),
patch.object(app.state.monitor, "stop"),
TestClient(app) as test_client,
):
yield test_client
+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 = {
+165
View File
@@ -0,0 +1,165 @@
"""角色授权并集、即时撤权与独立工资月份锁,不再使用个人覆盖或全局绑定。"""
import pytest
from pydantic import ValidationError
from sqlalchemy import select
from app.core.exceptions import BadRequestError, ForbiddenError
from app.models import Permission, Role, RolePermission, User, UserRole
from app.schemas.authorization import AuthorizationPreviewIn
from app.services import authorization_service as service
from app.services.permission_catalog import CATALOG
from tests.unit.test_tplus_workbench import work_session as work_session
@pytest.fixture
def auth_case(work_session):
session = work_session
user = User(username="auth_test", password="test_hash", status=1)
role = Role(role_code="auth_role", role_name="测试权限", status=1)
session.add_all([user, role])
session.flush()
session.add(UserRole(user_id=user.id, role_id=role.id))
for code in (
"base:personnel:view",
"base:personnel:regions",
"payroll:view",
"payroll:calculate",
):
permission = session.scalar(select(Permission).where(Permission.perm_code == code))
session.add(RolePermission(role_id=role.id, permission_id=permission.id))
session.commit()
return session, user, role
def test_用户权限仅继承角色且来源清晰(auth_case):
session, user, _ = auth_case
result = service.calculate(session, user)
assert set(result["permissions"]) == {
"base:personnel:view",
"base:personnel:regions",
"payroll:view",
"payroll:calculate",
}
assert result["sources"]["payroll:calculate"] == {
"roles": ["auth_role"],
"override": None,
"effective": True,
}
def test_操作缺少模块查看权限时不生效(auth_case):
session, user, role = auth_case
permission = session.scalar(
select(Permission).where(Permission.perm_code == "base:personnel:view")
)
link = session.scalar(
select(RolePermission).where(
RolePermission.role_id == role.id, RolePermission.permission_id == permission.id
)
)
session.delete(link)
session.flush()
assert "base:personnel:regions" not in service.calculate(session, user)["permissions"]
def test_多个角色权限取并集且无需指定制作人(auth_case):
session, user, role = auth_case
other = Role(role_code="auth_extra", role_name="额外职责", status=1)
session.add(other)
session.flush()
permission = session.scalar(
select(Permission).where(Permission.perm_code == "payroll:allocation")
)
session.add(RolePermission(role_id=other.id, permission_id=permission.id))
session.add(UserRole(user_id=user.id, role_id=other.id))
session.flush()
assert service.setting(session).maker_user_id is None
assert "payroll:allocation" in service.calculate(session, user)["permissions"]
service.require_permission(session, user, "payroll:calculate")
service.require_permission(session, user, "payroll:allocation")
role.status = 0
session.flush()
assert "payroll:allocation" not in service.calculate(session, user)["permissions"]
def test_角色停用立即撤销旧会话权限(auth_case):
session, user, role = auth_case
role.status = 0
session.flush()
assert not service.calculate(session, user)["permissions"]
with pytest.raises(ForbiddenError):
service.require_permission(session, user, "payroll:calculate")
def test_管理员无需绑定即可使用已配置工资功能(auth_case):
session, user, _ = auth_case
role = session.scalar(select(Role).where(Role.role_code == "admin"))
user.user_roles.append(UserRole(role_id=role.id))
session.flush()
assert set(service.calculate(session, user)["permissions"]) == set(CATALOG)
assert service.setting(session).maker_user_id is None
service.require_permission(session, user, "payroll:calculate")
def test_多个获授权账号都能核算且旧绑定不影响判定(auth_case):
session, user, role = auth_case
other = User(username="another_operator", password="test_hash", status=1)
session.add(other)
session.flush()
session.add(UserRole(user_id=other.id, role_id=role.id))
service.setting(session).maker_user_id = user.id
session.flush()
for actor in (user, other):
service.require_permission(session, actor, "payroll:calculate")
assert service.session_info(session, actor)["can_write_payroll"]
assert service.session_info(session, actor)["maker_required_permissions"] == []
with pytest.raises(ForbiddenError):
service.require_permission(session, other, "settlements:generate")
def test_旧个人覆盖不再接受且无法另开授权入口(auth_case):
session, user, _ = auth_case
with pytest.raises(ValidationError):
AuthorizationPreviewIn(
role_codes=["auth_role"],
overrides=[{"permission_code": "payroll:calculate", "effect": "DENY"}],
)
with pytest.raises(BadRequestError, match="角色授权"):
service.calculate(session, user, overrides={"payroll:calculate": "DENY"})
def test_普通角色不能获得管理员专用权限(auth_case):
session, user, role = auth_case
permission = session.scalar(
select(Permission).where(Permission.perm_code == "system:roles:update")
)
session.add(RolePermission(role_id=role.id, permission_id=permission.id))
session.flush()
assert "system:roles:update" not in service.calculate(session, user)["permissions"]
def test_停用账号不保留角色权限(auth_case):
session, user, _ = auth_case
user.status = 0
assert not service.calculate(session, user)["permissions"]
def test_取消全局绑定仍保留月份实际经办人与生成锁(auth_case):
from datetime import datetime
from app.models import PayrollMonth
from app.services import payroll_months
session, user, role = auth_case
other = User(username="second_month_operator", password="test_hash", status=1)
session.add(other)
session.flush()
session.add(UserRole(user_id=other.id, role_id=role.id))
session.add(PayrollMonth(month="2091-11", maker_id=user.id, maker_name=user.username, generated_at=datetime(2091, 11, 30)))
session.flush()
assert service.setting(session).maker_user_id is None
with pytest.raises(ForbiddenError, match="其他制作人"):
payroll_months.lock_month(session, "2091-11", other)
with pytest.raises(BadRequestError, match="已锁定"):
payroll_months.ensure_allocation_editable(session, "2091-11", user)
@@ -0,0 +1,34 @@
"""权限覆盖守卫:新接口未登记时必须明确失败。"""
from fastapi.routing import APIRoute
from app.services.authorization_policy import permission_for
from app.services.permission_catalog import CATALOG
def test_全部受保护接口都有权限映射():
from app.main import app
missing=[]
checked = 0
def routes(items):
for item in items:
if isinstance(item, APIRoute):
yield item
elif hasattr(item, 'original_router'):
yield from routes(item.original_router.routes)
for route in routes(app.routes):
if not route.endpoint.__module__.startswith('app.api.v1.'):
continue
checked += 1
module=route.endpoint.__module__.removeprefix('app.api.v1.')
for method in route.methods:
code=permission_for(module,route.endpoint.__name__,method,
params={'code':'personal-detail'},body={'kind':'MASTER_PREVIEW'})
if code is None or (code and code not in CATALOG):
missing.append((method,route.path,code))
assert checked >= 100, '路由枚举未覆盖全部业务接口'
assert not missing, missing
def test_未知接口没有默认放行():
assert permission_for('future_module','new_action','GET') is None
+74
View File
@@ -0,0 +1,74 @@
"""首页采集状态来自真实批次,关闭读取不抹掉历史成功记录。"""
from datetime import datetime
import pytest
from sqlalchemy import Column, DateTime, Integer, MetaData, String, Table, create_engine
from sqlalchemy.orm import Session
from app.core.config import Settings
from app.services import dashboard_service
@pytest.fixture
def sync_database(monkeypatch):
engine = create_engine("sqlite://")
metadata = MetaData()
# 只建 V14 已有列,证明读取状态不要求业务库先升级 V20。
table = Table(
"tplus_sync_batches", metadata,
Column("id", Integer, primary_key=True),
Column("sync_mode", String(16)),
Column("status", String(16)),
Column("finished_at", DateTime),
)
metadata.create_all(engine)
settings = Settings(_env_file=None, app_secret_key="x" * 32)
monkeypatch.setattr(dashboard_service, "get_settings", lambda: settings, raising=False)
with Session(engine) as session:
yield session, table, settings
engine.dispose()
def test_关闭采集仍显示历史成功且不将旧失败冒充当前失败(sync_database):
session, table, _ = sync_database
finished = datetime(2026, 10, 30, 16, 52, 3)
session.execute(table.insert(), [
{"id": 1, "sync_mode": "FULL", "status": "FAILED", "finished_at": finished},
{"id": 2, "sync_mode": "FULL", "status": "SUCCEEDED", "finished_at": finished},
{"id": 3, "sync_mode": "AUDIT", "status": "FAILED", "finished_at": finished},
])
result = dashboard_service._sync_status(session)
assert result["available"] is True
assert result["enabled"] is False and result["automation_enabled"] is False
assert result["latest_status"] == "SUCCEEDED"
assert result["last_success_at"] == finished
assert result["historical_failure_total"] == 1
@pytest.mark.parametrize("status", ["FAILED", "PARTIAL", "QUEUED", "RUNNING"])
def test_首页如实返回最近采集状态(sync_database, status):
session, table, settings = sync_database
settings.t_plus_enabled = True
session.execute(table.insert(), {"id": 1, "sync_mode": "FULL", "status": status})
result = dashboard_service._sync_status(session)
assert result["latest_status"] == status and result["enabled"] is True
assert result["last_success_at"] is None
def test_无记录不显示已采集(sync_database):
session, _, _ = sync_database
result = dashboard_service._sync_status(session)
assert result["available"] is True
assert result["latest_status"] is None and result["last_success_at"] is None
def test_未建同步表时明确返回不可查询():
engine = create_engine("sqlite://")
try:
with Session(engine) as session:
result = dashboard_service._sync_status(session)
assert result["available"] is False
assert result["last_success_at"] is None
finally:
engine.dispose()
+7 -4
View File
@@ -9,7 +9,6 @@ from uuid import uuid4
import pytest
from sqlalchemy import func, select
from app.core.config import get_settings
from app.core.exceptions import BadRequestError, ForbiddenError
from app.models import (
Business,
@@ -55,7 +54,11 @@ def payroll_case(work_session, monkeypatch):
)
s.add(manager)
s.commit()
monkeypatch.setattr(get_settings(), "payroll_maker_user_id", user.id)
from app.models import Role, UserRole
maker_role = s.scalar(select(Role).where(Role.role_code == "payroll_maker"))
user.user_roles.append(UserRole(role_id=maker_role.id))
s.commit()
prepare_calc(s)
apply(s, preview(s, ["personnel"]))
product = Product(
@@ -148,7 +151,7 @@ def imported(case):
return s.scalar(select(Business))
def test_指定账号而非管理员或岗位放行(payroll_case):
def test_工资角色可写且未授权对象不能冒用身份(payroll_case):
_, user, _ = payroll_case
assert can_write_payroll(user)
assert not can_write_payroll(SimpleNamespace(id=user.id + 1, status=1))
@@ -222,7 +225,7 @@ def test_生成失败不留下月份锁(payroll_case):
s, user, _ = payroll_case
from app.services.settlement_service import generate
with pytest.raises(BadRequestError, match="无可结算"):
with pytest.raises(BadRequestError, match=r"无可(?:结算|发薪)"):
generate(s, "2026-03", operator=user)
assert s.scalar(select(PayrollMonth)) is None
+5 -1
View File
@@ -12,7 +12,11 @@ from sqlalchemy.dialects import mysql
from app.models import Base
EXPECTED_TABLES = {
"probation_deferrals", "probation_payments",
"monitor_snapshots", "monitor_request_minutes", "monitor_events",
"authorization_settings", "user_permission_overrides",
"tplus_automation_states",
"tplus_automation_configs",
"payroll_months", "payroll_inputs", "payroll_allocations",
"tplus_source_heads", "tplus_facts", "tplus_source_links", "tplus_work_runs", "tplus_work_lines",
"users",
@@ -22,7 +26,7 @@ EXPECTED_TABLES = {
"role_permissions",
"login_logs",
"operation_logs",
"business_roles",
"business_roles", "calc_template_definitions",
"system_settings",
"approval_orders",
"approval_steps",
+297
View File
@@ -0,0 +1,297 @@
"""使用隔离 SQLite 事务验证监控落库、恢复、权限及脱敏,不接触开发业务库。"""
import json
from datetime import timedelta
from types import SimpleNamespace
import pytest
from fastapi import FastAPI
from fastapi.encoders import jsonable_encoder
from fastapi.testclient import TestClient
from sqlalchemy import Integer, MetaData, create_engine, func, select
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from app.api.deps import get_current_user, get_db
from app.api.v1.system.monitor import router
from app.core.config import Settings
from app.core.exception_handlers import register_exception_handlers
from app.core.monitoring import MonitorMiddleware, Recorder, minute_of, now_local
from app.models.monitor import MonitorEvent, MonitorRequestMinute, MonitorSnapshot
from app.schemas.monitor import MonitorWindow
from app.services import monitor_service as service
from app.services.monitor_collector import MonitorCollector, RuntimeErrorHandler
@pytest.fixture
def monitor_case(monkeypatch):
engine = create_engine(
"sqlite://", poolclass=StaticPool, connect_args={"check_same_thread": False}
)
metadata = MetaData()
for model in (MonitorSnapshot, MonitorRequestMinute, MonitorEvent):
table = model.__table__.to_metadata(metadata)
table.c.id.type = Integer()
metadata.create_all(engine)
factory = sessionmaker(engine, expire_on_commit=False)
settings = Settings(_env_file=None, app_secret_key="x" * 32, monitor_enabled=False)
collector = MonitorCollector(settings, Recorder())
monkeypatch.setattr(collector, "_factory", lambda: factory)
yield collector, factory
engine.dispose()
def seed(collector, at):
collector.recorder.record("GET", "/api/items/{id}", 500, 2200, "a" * 32, at=at)
collector.recorder.record("GET", "/api/items/{id}", 400, 10, "b" * 32, at=at)
collector.samples[minute_of(at)] = [
{"memory_percent": 40, "cpu_percent": 10, "disk_percent": 25}
]
def test_历史跨采集器重启保留且当前分钟不重复统计(monitor_case):
collector, factory = monitor_case
at = minute_of(now_local()) - timedelta(minutes=2)
seed(collector, at)
collector._persist(now_local())
collector._persist(now_local())
with factory() as session:
assert session.scalar(select(func.count()).select_from(MonitorSnapshot)) == 1
assert session.scalar(select(func.count()).select_from(MonitorRequestMinute)) == 1
assert service.overview(session, collector).requests["count"] == 2
restarted = MonitorCollector(collector.settings, Recorder())
trend = service.trends(session, restarted, MonitorWindow())
assert sum(point["request_count"] or 0 for point in trend["points"]) == 2
row = service.interfaces(session, restarted, MonitorWindow())["list"][0]
assert (row["count"], row["errors_5xx"], row["errors_4xx"]) == (2, 1, 1)
assert row["p95_ms"] == 2200
def test_持久化失败保留事件恢复后写入且不影响记录(monitor_case, monkeypatch):
collector, factory = monitor_case
at = minute_of(now_local()) - timedelta(minutes=1)
seed(collector, at)
def fail():
raise RuntimeError("synthetic-db-failure")
monkeypatch.setattr(collector, "_factory", fail)
collector._persist(now_local())
assert len(collector.recorder.events) == 1
assert collector.current()["collection_status"] == "warning"
monkeypatch.setattr(collector, "_factory", lambda: factory)
collector._persist(now_local())
with factory() as session:
assert session.scalar(select(func.count()).select_from(MonitorEvent)) == 1
assert not collector.recorder.events
def test_同类健康异常合并恢复并跨重启复用首次时间(monitor_case):
collector, factory = monitor_case
at = now_local() - timedelta(minutes=3)
bad = [{"name": "MySQL", "status": "error", "message": "连接检查失败。"}]
collector._evaluate(at, {}, bad, {})
collector._persist(at)
restarted = MonitorCollector(collector.settings, Recorder())
restarted._factory = lambda: factory
restarted._evaluate(at + timedelta(minutes=1), {}, bad, {})
restarted._persist(at + timedelta(minutes=1))
with factory() as session:
row = session.scalar(select(MonitorEvent))
assert row.first_seen == at and row.occurrences == 2 and row.resolved_at is None
assert session.scalar(select(func.count()).select_from(MonitorEvent)) == 1
restarted._evaluate(at + timedelta(minutes=2), {}, [], {})
restarted._persist(at + timedelta(minutes=2))
with factory() as session:
assert session.scalar(select(MonitorEvent)).resolved_at == at + timedelta(minutes=2)
def test_资源持续五分钟才告警且未知不算恢复(monitor_case):
collector, _ = monitor_case
at = now_local() - timedelta(minutes=6)
collector._evaluate(at, {"cpu_percent": 90}, [], {})
assert not collector.conditions
collector._evaluate(at + timedelta(minutes=5), {"cpu_percent": 90}, [], {})
assert "CPU_HIGH" in collector.conditions
collector._evaluate(at + timedelta(minutes=6), {}, [], {})
assert "CPU_HIGH" in collector.conditions
collector._evaluate(at + timedelta(minutes=7), {"cpu_percent": 30}, [], {})
assert "CPU_HIGH" not in collector.conditions
def test_低请求量不报错误率且四百不触发系统故障(monitor_case):
collector, _ = monitor_case
at = now_local()
for i in range(19):
collector.recorder.record("GET", "/items", 500, 1, str(i), at=at)
collector._evaluate(at, {}, [], {})
assert "HTTP_ERROR_RATE" not in collector.conditions
collector.recorder.record("GET", "/items", 400, 1, "x", at=at)
collector._evaluate(at, {}, [], {})
assert "HTTP_ERROR_RATE" in collector.conditions
def test_过期清理仅限监控表且保留活动异常(monitor_case):
collector, factory = monitor_case
old = now_local() - timedelta(days=8)
seed(collector, old)
collector._evaluate(old, {"disk_percent": 99}, [], {})
collector._persist(now_local())
with factory() as session:
assert session.scalar(select(func.count()).select_from(MonitorSnapshot)) == 0
assert session.scalar(select(func.count()).select_from(MonitorRequestMinute)) == 0
assert session.scalar(select(func.count()).select_from(MonitorEvent)) == 1
assert session.scalar(select(MonitorEvent)).code == "DISK_HIGH"
def make_api(collector, factory):
app = FastAPI()
app.state.monitor = collector
app.include_router(router, prefix="/api/v1/system")
register_exception_handlers(app)
app.add_middleware(MonitorMiddleware, recorder=collector.recorder)
def database():
with factory() as session:
yield session
app.dependency_overrides[get_db] = database
return app
def test_未登录与普通用户均不能读监控(monitor_case, monkeypatch):
collector, factory = monitor_case
app = make_api(collector, factory)
client = TestClient(app)
assert client.get("/api/v1/system/monitor/overview").status_code == 401
app.dependency_overrides[get_current_user] = lambda: SimpleNamespace(id=1)
monkeypatch.setattr("app.api.deps.user_repository.get_role_codes", lambda _: ["user"])
for path in ("overview", "trends", "interfaces", "events", "events/1", "diagnostic"):
assert client.get(f"/api/v1/system/monitor/{path}").status_code == 403
def test_管理员契约查询约束与脱敏导出(monitor_case, monkeypatch):
collector, factory = monitor_case
seed(collector, now_local() - timedelta(minutes=1))
collector._persist(now_local())
app = make_api(collector, factory)
app.dependency_overrides[get_current_user] = lambda: SimpleNamespace(id=1)
monkeypatch.setattr("app.api.deps.user_repository.get_role_codes", lambda _: ["admin"])
client = TestClient(app)
for path in ("overview", "trends", "interfaces", "events", "events/1"):
response = client.get(f"/api/v1/system/monitor/{path}")
assert response.status_code == 200, response.text
assert response.json()["code"] == 200
assert client.get("/api/v1/system/monitor/trends?hours=999").status_code == 400
for hours in (1, 24, 168):
for endpoint in ("trends", "interfaces", "events"):
assert client.get(f"/api/v1/system/monitor/{endpoint}?hours={hours}").status_code == 200
assert client.get("/api/v1/system/monitor/events?request_id=invalid").status_code == 400
assert client.get("/api/v1/system/monitor/events?pageSize=201").status_code == 400
result = client.get("/api/v1/system/monitor/events?request_id=" + "a" * 32).json()["data"]
assert result["total"] == 1
export = client.get("/api/v1/system/monitor/diagnostic")
assert "attachment" in export.headers["content-disposition"]
assert not any(
field in export.text
for field in ("password", "secret", "token", "traceback", "request_data", "payload_json")
)
def test_未处理异常编号匹配日志且只记录一次(monitor_case, caplog):
collector, factory = monitor_case
app = make_api(collector, factory)
@app.get("/explode")
def explode():
raise RuntimeError("synthetic-private-details")
response = TestClient(app, raise_server_exceptions=False).get("/explode")
request_id = response.headers["x-request-id"]
assert response.status_code == 500
assert "synthetic-private-details" not in response.text
assert len(collector.recorder.events) == 1
assert collector.recorder.events[0]["request_id"] == request_id
assert any(getattr(record, "request_id", None) == request_id for record in caplog.records)
assert "synthetic-private-details" not in json.dumps(
jsonable_encoder(list(collector.recorder.events))
)
def test_后台日志摘要不读取异常原文且请求日志不重复(monitor_case):
import logging
from app.core.monitoring import REQUEST_ID
collector, _ = monitor_case
handler = RuntimeErrorHandler(collector.recorder)
record = logging.LogRecord("app.jobs", logging.ERROR, "", 0, "password=synthetic", (), None)
handler.emit(record)
token = REQUEST_ID.set("a" * 32)
try:
handler.emit(record)
finally:
REQUEST_ID.reset(token)
assert len(collector.recorder.events) == 1
assert "synthetic" not in str(collector.recorder.events)
def test_过期采集不继续显示依赖正常(monitor_case):
collector, factory = monitor_case
collector.state.update(
collected_at=now_local() - timedelta(minutes=2),
dependencies=[
{"name": "MySQL", "status": "normal", "message": "正常", "checked_at": now_local()}
],
)
with factory() as session:
result = service.overview(session, collector)
assert result.status == "unknown"
assert result.dependencies[0].status == "unknown"
def test_重启首次CPU未知不恢复已有告警且健康后恢复(monitor_case):
collector, factory = monitor_case
at = now_local() - timedelta(minutes=7)
collector._evaluate(at, {"cpu_percent": 99}, [], {})
collector._evaluate(at + timedelta(minutes=5), {"cpu_percent": 99}, [], {})
collector._persist(at + timedelta(minutes=5))
restarted = MonitorCollector(collector.settings, Recorder())
restarted._factory = lambda: factory
restarted._evaluate(at + timedelta(minutes=6), {}, [], {})
restarted._persist(at + timedelta(minutes=6))
with factory() as session:
assert session.scalar(select(MonitorEvent)).resolved_at is None
restarted._evaluate(now_local(), {"cpu_percent": 30}, [], {})
restarted._persist(now_local())
with factory() as session:
assert session.scalar(select(MonitorEvent)).resolved_at is not None
def test_T加状态未知不误报恢复(monitor_case):
collector, factory = monitor_case
at = now_local() - timedelta(minutes=1)
collector._evaluate(
at,
{},
[],
{"enabled": True, "alerts": [{"code": "WORKER_OFFLINE", "message": "采集进程心跳中断"}]},
)
collector._persist(at)
collector._evaluate(now_local(), {}, [], {"status": "unknown", "alerts": []})
collector._persist(now_local())
with factory() as session:
assert session.scalar(select(MonitorEvent)).resolved_at is None
def test_监控权限登记且不能授予普通角色(monitor_case):
from app.services.authorization_policy import permission_for
from app.services.authorization_service import calculate
from app.services.permission_catalog import CATALOG
_, factory = monitor_case
assert CATALOG["system:monitor:view"]["admin_only"]
assert permission_for("system.monitor", "diagnostic", "GET") == "system:monitor:view"
with factory() as session:
user = SimpleNamespace(id=1, status=1)
assert "system:monitor:view" in calculate(session, user, roles=["admin"])["permissions"]
+71
View File
@@ -0,0 +1,71 @@
"""验证遥测口径、有界内存和请求关联,防止监控干扰业务及泄漏原始地址。"""
from datetime import datetime, timedelta
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.core.monitoring import REQUEST_ID, MonitorMiddleware, Recorder, merge_metrics
def test_合并直方图计算P95且四百不计系统错误():
recorder = Recorder()
at = datetime(2026, 11, 2, 10)
for status, elapsed in [(200, 10), (400, 100), (403, 200), (500, 750)]:
recorder.record("GET", "/items/{id}", status, elapsed, "r", at=at)
result = merge_metrics(recorder.metrics()[at].values())
assert (result["count"], result["errors_4xx"], result["errors_5xx"]) == (4, 2, 1)
assert result["error_rate"] == 25
assert result["p95_ms"] == 750
assert merge_metrics([])["p95_ms"] is None
def test_中间件生成编号使用模板排除自身且不读取正文():
app, recorder = FastAPI(), Recorder()
app.add_middleware(MonitorMiddleware, recorder=recorder)
@app.get("/items/{id}")
def item(id: str):
return {"request_id": REQUEST_ID.get()}
@app.get("/api/v1/system/monitor/overview")
def monitor():
return {}
client = TestClient(app)
response = client.get("/items/private-id?token=never-record")
assert response.headers["x-request-id"] == response.json()["request_id"]
client.get("/api/v1/system/monitor/overview")
client.get("/private-route")
values = next(iter(recorder.metrics().values()))
assert set(values) == {("GET", "/items/{id}"), ("GET", "/__unmatched__")}
assert "private" not in str(recorder.metrics())
assert REQUEST_ID.get() == "-"
def test_内存分钟路由和事件队列均有界():
recorder = Recorder()
at = datetime(2026, 11, 2, 10)
for i in range(2100):
recorder.record("GET", f"/route/{i}", 500, 1, str(i), at=at)
assert len(recorder.events) == 2000 and recorder.dropped_events == 100
assert len(recorder.metrics()[at]) == 201
for i in range(11):
recorder.record("GET", "/ok", 200, 1, "r", at=at + timedelta(minutes=i))
assert len(recorder.metrics()) == 10
def test_遥测内部失败不改变业务响应(monkeypatch):
app, recorder = FastAPI(), Recorder()
app.add_middleware(MonitorMiddleware, recorder=recorder)
@app.get("/ok")
def ok():
return {"ok": True}
def broken(*args, **kwargs):
raise RuntimeError("synthetic-recorder-failure")
monkeypatch.setattr(recorder, "record", broken)
response = TestClient(app).get("/ok")
assert response.status_code == 200 and response.json() == {"ok": True}
+50 -11
View File
@@ -6,11 +6,12 @@
"""
from fractions import Fraction
from types import SimpleNamespace
import pytest
from app.core.exceptions import BadRequestError
from app.services.calc_templates import TEMPLATES, get_template, required_params
from app.services.calc_templates import FORMULAS, TEMPLATES, get_formula
from app.services.param_registry import (
PARAM_REGISTRY,
get_spec,
@@ -22,6 +23,8 @@ from app.services.param_registry import (
# + V7 调整单开关;V8 撤试用期统一工资参数 #16、增经理个人户基数开关 #12;
# V10 增工作台预警规则三项 #10)
EXPECTED_CODES = {
"coefficient.personal.first", "coefficient.personal.start", "coefficient.personal.slope",
"coefficient.personal.floor", "coefficient.personal.boundary",
"salesperson.rate.start",
"salesperson.rate.slope",
"salesperson.rate.floor",
@@ -47,11 +50,44 @@ EXPECTED_CODES = {
}
def test_目录说明使用当前规则且不改写已有参数值(monkeypatch):
"""旧数据库的说明可更新,历史生效值和日期必须保持原样。"""
from datetime import date
from app.services import param_service
spec = get_spec("approval.monthly_settlement.enabled")
definition = SimpleNamespace(
param_code=spec.code,
param_name=spec.name,
param_group=spec.group,
value_type=spec.value_type,
unit=spec.unit,
dimension_type=spec.dimension_type,
default_value=spec.default_value,
value_min=spec.value_min,
value_max=spec.value_max,
description="旧说明:关闭后自动记录通过痕迹",
)
monkeypatch.setattr(param_service.param_repository, "list_definitions", lambda *a, **k: [definition])
monkeypatch.setattr(
param_service.param_repository,
"resolve_values",
lambda *a: [(spec.code, "", "1", date(2026, 1, 1))],
)
item = param_service.get_catalog(None, as_of=date(2026, 11, 2))[0]
assert item.description == spec.description
assert item.values[0].value_text == "1"
assert item.values[0].effective_date == date(2026, 1, 1)
class TestRegistry:
"""注册表元数据完整性(与迁移种子同源)。"""
def test_注册表共22项(self) -> None:
assert len(PARAM_REGISTRY) == 22
def test_注册表共27项(self) -> None:
assert len(PARAM_REGISTRY) == 27
def test_编码集合与迁移种子一致(self) -> None:
assert set(PARAM_REGISTRY) == EXPECTED_CODES
@@ -161,20 +197,23 @@ class TestValidateAndNormalize:
class TestTemplates:
"""公式模板注册表:有限集合,声明参数必须真实存在。"""
"""公式模板:骨架受控,内置注册表的参数绑定必须真实存在。"""
def test_共五个模板(self) -> None:
def test_共五个内置模板且五个骨架(self) -> None:
assert len(TEMPLATES) == 5
assert len(FORMULAS) == 5
def test_模板声明参数均在注册表内(self) -> None:
def test_内置模板参数绑定均在注册表内且覆盖骨架角色(self) -> None:
for tpl in TEMPLATES.values():
for code in tpl.params:
spec = get_formula(tpl.formula_type)
assert set(tpl.params) == set(spec.roles), tpl.code
for code in tpl.params.values():
assert code in PARAM_REGISTRY, f"{tpl.code} 声明了未注册参数 {code}"
def test_取模板声明参数(self) -> None:
params = required_params("TPL_SALESPERSON_DECAY")
assert "salesperson.rate.slope" in params
params = TEMPLATES["TPL_SALESPERSON_DECAY"].params
assert params["slope"] == "salesperson.rate.slope"
def test_未知模板抛ValueError_属代码缺陷(self) -> None:
def test_未知骨架抛ValueError(self) -> None:
with pytest.raises(ValueError):
get_template("TPL_NOT_EXIST")
get_formula("NOT_EXIST")
@@ -10,7 +10,7 @@ import pytest
from app.api.v1 import payroll as payroll_api
from app.api.v1.system import tplus_workbench as workbench_api
from app.core.config import get_settings
from app.core.config import Settings, get_settings
from app.core.exceptions import NotFoundError
from app.core.response import PageQuery
from app.models import (
@@ -67,6 +67,10 @@ def test_月份状态兼容未生成和历史工资(work_session, state):
@pytest.fixture
def source_config(monkeypatch):
settings = Settings(
_env_file=None, app_secret_key="x" * 32, t_plus_database="test_source"
)
monkeypatch.setattr(task_service, "get_settings", lambda: settings)
monkeypatch.setattr(
task_service,
"resolve_tplus_settings",
@@ -172,7 +176,7 @@ def test_正式输入及分配调整接口保留原台账与当月归属(payroll
assert s.get(MonthlySettlement, settlement.id).total_payable == settlement.total_payable
def test_报告保留完整字段前导零与公式防护(work_session):
def test_报告保留完整字段前导零与公式防护(work_session, source_config):
s = work_session
s.add(
TPlusWorkRun(
@@ -0,0 +1,320 @@
"""需求补齐的业务金额回归,覆盖配置实际消费而非页面存在性。"""
from datetime import date
from decimal import Decimal
from fractions import Fraction
from io import BytesIO
import pytest
from openpyxl import load_workbook
from app.core.exceptions import BadRequestError
from app.schemas.business import BusinessItemIn
from app.schemas.coefficient import CoefficientRowIn
from app.services import coefficient_service
from app.services.param_service import ParamSnapshot
from app.services.rate_rules import decide
from app.services.tplus.calculation_preview import _parameters
from tests.unit.test_formal_payroll import imported
from tests.unit.test_formal_payroll import payroll_case as payroll_case
from tests.unit.test_tplus_fifo import inputs, run
from tests.unit.test_tplus_workbench import work_session as work_session
def test_企业户分类及信息员金额(monkeypatch):
snapshot = ParamSnapshot.from_rows(
date(2026, 11, 2),
[
("enterprise.rate", "", "3", date(2026, 8, 13)),
("enterprise.rate", "municipal_water", "2", date(2026, 8, 13)),
("enterprise.rate", "rail_transit", "5", date(2026, 8, 13)),
],
)
monkeypatch.setattr(
"app.services.tplus.calculation_preview.param_service.resolve_snapshot",
lambda *args: snapshot,
)
params = _parameters(None, {"2026-11-02"})["2026-11-02"]
assert decide(
params,
category="municipal_water",
customer_type="enterprise",
role_code="salesperson",
days=1,
)[0] == Fraction(2, 1000)
assert decide(
params, category="rail_transit", customer_type="enterprise", role_code="salesperson", days=1
)[0] == Fraction(5, 1000)
group, ship, cash, facts, values = inputs()
ship.update(amount="10000", cost_total="5000", customer_type="enterprise")
cash["amount"] = "10000"
facts["personnel"]["001"]["role_code"] = "info_officer"
values["2026-03-10"]["info"] = "1"
result = run(group, [ship], [cash], facts, values)
assert result["lines"][0]["performance"] == "10.00"
assert result["lines"][0]["calculation_base"] == "10000.00"
def test_指导单位成本自动换算且冲突拒绝():
data = {
"product_name": "测试",
"product_category": "municipal_water",
"quantity": "10",
"sale_price": "100",
"guided_price": "50",
}
assert BusinessItemIn(**data).cost_amount == "500.00"
with pytest.raises(ValueError, match="不一致"):
BusinessItemIn(**data, cost_amount="501")
def test_系数工作簿往返与派生冲突(monkeypatch):
snapshot = ParamSnapshot.from_rows(date.today(), [])
monkeypatch.setattr(
coefficient_service.param_service, "resolve_snapshot", lambda *args: snapshot
)
content = coefficient_service.workbook(None)
preview = coefficient_service.import_preview(None, content)
assert preview["can_apply"], preview["errors"]
assert len(preview["rows"]) == 8
book = load_workbook(BytesIO(content))
book["系数参数"].cell(2, 7, "999999")
output = BytesIO()
book.save(output)
assert not coefficient_service.import_preview(None, output.getvalue())["can_apply"]
def test_非法系数整批拒绝():
row = CoefficientRowIn(
product_category="municipal_water",
customer_type="personal",
stage="linear",
value="1",
slope="1",
boundary=360,
)
with pytest.raises(BadRequestError):
coefficient_service.items_for([row])
def test_分界整数小数原文及预收优先级():
params = {"start": "10", "first": "11", "slope": "4/360", "floor": "4", "boundary": "360.0", "enterprise": "3"}
rate, _, _ = decide(params, category="municipal_water", customer_type="personal", role_code="salesperson", days=360)
assert rate == Fraction(6, 1000)
rate, _, _ = decide(params, category="municipal_water", customer_type="enterprise", role_code="salesperson", days=-8, prepayment=True)
assert rate == Fraction(11, 1000)
rate, _, _ = decide(params, category="municipal_water", customer_type="enterprise", role_code="sales_manager", days=1)
assert rate == 0
def test_基本工资仅计一次及试用期年度结清(payroll_case):
from sqlalchemy import select
from app.models import MonthlySettlement, PerformanceLedger, Personnel
from app.services import payroll_calculation, probation_service, salary_completion
session, user, _ = payroll_case
business = imported(payroll_case)
payroll_calculation.calculate(session, business.id, "2026-03", operator=user)
row = session.scalar(select(PerformanceLedger))
person = session.get(Personnel, row.personnel_id)
person.salary_standard = Decimal("3000")
person.probation_start_date = date(2026, 3, 1)
person.regularization_date = date(2027, 1, 1)
row.is_probation = 1
row.performance_amount = Decimal("20000")
row.payable_amount = Decimal("1000")
session.flush()
before, _ = salary_completion.build_items(session, [row], "2026-11")
item = next(i for i in before if i.personnel_id == person.id)
assert item.basic_amount == Decimal("3000")
assert item.net_amount == Decimal("3000")
assert item.payable_sum == 0
assert item.deferred_sum == Decimal("1000")
december, _ = salary_completion.build_items(session, [row], "2026-12")
item = next(i for i in december if i.personnel_id == person.id)
assert item.probation_payment_sum == Decimal("1000")
assert item.net_amount == Decimal("4000")
settlement = MonthlySettlement(
settlement_month="2026-12", status=3, submitter_id=user.id, submitter_name=user.username
)
session.add(settlement)
session.flush()
probation_service.persist_payments(session, settlement, december)
session.flush()
assert probation_service.preview(session, "2027-01")["rows"][0]["delta"] == "0.00"
row.payable_amount = Decimal("3000")
session.flush()
assert Decimal(probation_service.preview(session, "2027-01")["rows"][0]["delta"]) == Decimal(
"1000"
)
row.performance_amount = Decimal("18000")
session.flush()
assert Decimal(probation_service.preview(session, "2027-01")["rows"][0]["delta"]) == Decimal(
"-1000"
)
def test_无绩效在职人员有基本工资(payroll_case):
from sqlalchemy import select
from app.models import Personnel
from app.services import salary_completion
session, _, _ = payroll_case
person = session.scalar(select(Personnel).where(Personnel.source_no == "001"))
if person is None:
person = session.scalar(select(Personnel))
person.salary_standard = Decimal("3000")
person.is_probation = 1
session.flush()
items, ids = salary_completion.build_items(session, [], "2026-11")
item = next(i for i in items if i.personnel_id == person.id)
assert item.net_amount == Decimal("3000")
assert ids == []
def test_最近有效发货顺序与人工保护(work_session):
from app.models import Product
from app.services.product_completion import recent_trades
from app.services.product_service import _to_out
from tests.unit.test_tplus_workbench import source
source(
work_session,
"shipments",
[
{"id": 20, "code": "SH-20", "voucherdate": "2026-11-01", "voucherState": 189},
{"id": 21, "code": "SH-21", "voucherdate": "2026-11-01", "voucherState": 189},
{
"id": 22,
"code": "SH-22",
"voucherdate": "2026-11-02",
"voucherState": 189,
"isCounteractDelivery": 1,
},
],
)
source(
work_session,
"shipment_items",
[
{
"id": 1,
"source_parent_id": 20,
"idinventory": 9,
"quantity": "2",
"taxPrice": "12.1234",
},
{
"id": 2,
"source_parent_id": 21,
"idinventory": 9,
"quantity": "3",
"taxPrice": "22.1234",
},
{"id": 3, "source_parent_id": 22, "idinventory": 9, "quantity": "4", "taxPrice": "99"},
],
)
trade = recent_trades(work_session, "test_source", ["9"])["9"]
assert trade["source_no"] == "SH-21"
assert trade["quantity"] == "3"
product = Product(
product_name="测试来源",
spec_model="",
product_category="municipal_water",
source_trade_json=trade,
stock_quantity=Decimal("7"),
sale_price=Decimal("88"),
quantity_price_manual=1,
status=1,
)
work_session.add(product)
work_session.flush()
assert _to_out(product).stock_quantity == "7"
product.quantity_price_manual = 0
assert _to_out(product).sale_price == "22.1234"
def test_产品提取冲突不覆盖已有档案(payroll_case):
from sqlalchemy import select
from app.models import BusinessItem, Product
from app.services.product_completion import extraction_plan
session, _, _ = payroll_case
business = imported(payroll_case)
item = session.scalar(select(BusinessItem).where(BusinessItem.business_id == business.id))
target = session.get(Product, item.product_id)
item.product_id = None
target.unit = "不同单位"
session.flush()
plan = extraction_plan(session, [item.id])
assert plan["rows"][0]["action"] == "CONFLICT"
assert target.unit == "不同单位"
def test_历史工资标准及离职按目标日期解析(work_session):
from types import SimpleNamespace
from app.services import personnel_salary
person = SimpleNamespace(
salary_standard=Decimal("9000"),
business_role_id=1,
hire_date=date(2026, 1, 1),
resignation_date=date(2026, 10, 1),
probation_start_date=date(2026, 1, 1),
regularization_date=date(2026, 4, 1),
is_probation=0,
status=0,
salary_history_json=[
{
"effective_date": "2026-01-01",
"salary_standard": "3000",
"business_role_id": "1",
"hire_date": "2026-01-01",
"resignation_date": "2026-10-01",
"probation_start_date": "2026-01-01",
"regularization_date": "2026-04-01",
"is_probation": "1",
}
],
)
profile = personnel_salary.resolve(person, date(2026, 3, 31))
assert profile.salary_standard == Decimal("3000")
assert personnel_salary.employed(person, profile, date(2026, 3, 31))
assert not personnel_salary.employed(person, profile, date(2026, 10, 31))
def test_驳回的后续结清不阻塞早月且待审占用分列(payroll_case):
from sqlalchemy import select
from app.models import AdjustmentOrder, PerformanceLedger, ProbationPayment
from app.services import payroll_calculation, probation_service, salary_completion
session, user, _ = payroll_case
business = imported(payroll_case)
payroll_calculation.calculate(session, business.id, "2026-03", operator=user)
ledger = session.scalar(select(PerformanceLedger))
ledger.is_probation = 1
ledger.performance_amount = Decimal("20000")
ledger.payable_amount = Decimal("3000")
order = AdjustmentOrder(order_no="TZ-test-probation", personnel_id=ledger.personnel_id,
personnel_name=ledger.personnel_name, settlement_month="2027-02", amount=Decimal("1000"),
direction=1, reason="测试驳回释放", status=3, submitter_id=user.id, submitter_name=user.username)
session.add(order)
session.flush()
session.add(ProbationPayment(adjustment_order_id=order.id, personnel_id=ledger.personnel_id,
year="2026", month="2027-02", amount=Decimal("1000"), snapshot_json={}))
session.flush()
items, _ = salary_completion.build_items(session, [], "2027-01")
assert next(i for i in items if i.personnel_id == ledger.personnel_id).probation_payment_sum == Decimal("2000")
order.status = 1
session.flush()
data = probation_service.preview(session, "2027-02")["rows"][0]
assert Decimal(data["paid"]) == 0
assert Decimal(data["reserved"]) == Decimal("1000")
assert Decimal(data["delta"]) == Decimal("1000")
with pytest.raises(BadRequestError, match="后续月份"):
salary_completion.build_items(session, [], "2027-01")
+19
View File
@@ -54,6 +54,25 @@ class TestRoleCodePattern:
assert _create(role_code=" finance ").role_code == "finance"
class TestInitialPermissions:
def test_旧创建请求兼容且默认不授权(self):
assert _create().permission_codes is None
def test_初始授权无需手动填写修改原因(self):
payload = _create(permission_codes=["base:personnel:view"])
assert payload.reason is None
@pytest.mark.parametrize("reason", ["", " "])
def test_显式提供的原因不能是空白(self, reason):
with pytest.raises(ValidationError):
_create(permission_codes=["base:personnel:view"], reason=reason)
def test_初始权限和原因保留(self):
payload = _create(permission_codes=["base:personnel:view"], reason=" 增加资料查看职责 ")
assert payload.permission_codes == ["base:personnel:view"]
assert payload.reason == "增加资料查看职责"
class TestRoleUpdate:
"""编辑模型:不含 role_code。"""
@@ -0,0 +1,94 @@
"""自动采集保存值由独立维护读取,关闭不破坏正在运行的任务。"""
from datetime import datetime
import pytest
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.core.config import Settings
from app.core.exceptions import ConflictError
from app.models import OperationLog, TPlusAutomationConfig, TPlusSyncBatch, User
from app.schemas.tplus import TPlusAutomationUpdate
from app.services.tplus import automation, automation_config, task_service
from tests.unit.test_tplus_workbench import work_session as work_session
@pytest.fixture
def source_settings(monkeypatch):
settings = Settings(_env_file=None, app_secret_key="x" * 32, t_plus_enabled=True,
t_plus_database="test_source", t_plus_user="reader", t_plus_odbc_driver="unit",
t_plus_automation_enabled=True)
monkeypatch.setattr(task_service, "get_settings", lambda: settings)
return settings
def update(s, enabled, version=0):
actor = task_service.TaskActor(s.get(User, 1))
return automation_config.save(s, TPlusAutomationUpdate(
enabled=enabled, expected_version=version), actor)
def test_初次沿用环境且页面关闭后独立维护不再排队(work_session, source_settings):
s = work_session
initial = automation_config.configuration(s, source_settings)
assert initial == {"enabled": True, "version": 0, "editable": True,
"configuration_origin": "ENVIRONMENT"}
result = update(s, False)
assert result["enabled"] is False and result["version"] == 1
with Session(s.get_bind()) as other:
assert automation.tick(other, settings=source_settings) == {"status": "DISABLED", "queued": False}
assert s.scalar(select(TPlusSyncBatch)) is None
assert s.scalar(select(OperationLog).where(OperationLog.action == "修改自动采集")) is not None
def test_保存开启覆盖环境关闭且同周不重复(work_session, source_settings):
settings = source_settings.model_copy(update={"t_plus_automation_enabled": False})
update(work_session, True)
with Session(work_session.get_bind()) as other:
first = automation.tick(other, settings=settings, now=datetime(2026, 11, 2, 3))
assert first["queued"]
assert not automation.tick(other, settings=settings, now=datetime(2026, 11, 2, 4))["queued"]
def test_关闭不终止活动批次且仍可手动排队(work_session, source_settings):
s = work_session
s.add(TPlusSyncBatch(batch_no="running", source_database="test_source", sync_mode="FULL",
status="RUNNING", entity_types_json=["personnel"]))
s.commit()
update(s, False)
assert s.scalar(select(TPlusSyncBatch.status)) == "RUNNING"
assert automation.tick(s, settings=source_settings)["status"] == "DISABLED"
from uuid import uuid4
from app.schemas.tplus import TPlusCreateIn
queued = task_service.create_task(s, TPlusCreateIn(request_key=uuid4(), entity_types=["personnel"]))
assert queued.status == "QUEUED"
def test_旧版本不能覆盖新的开关(work_session, source_settings):
s = work_session
update(s, False)
with pytest.raises(ConflictError):
update(s, True, 0)
assert not bool(s.scalar(select(TPlusAutomationConfig.enabled)))
result = update(s, True, 1)
assert result["enabled"] and result["version"] == 2
def test_不同源的开关不互相覆盖(work_session, source_settings):
update(work_session, False)
other = source_settings.model_copy(update={"t_plus_database": "other_source"})
assert automation_config.configuration(work_session, other)["enabled"] is True
def test_没有采集权限不能保存(work_session, source_settings, monkeypatch):
from app.core.exceptions import ForbiddenError
def denied(*args):
raise ForbiddenError()
monkeypatch.setattr(automation_config, "require_permission", denied)
with pytest.raises(ForbiddenError):
update(work_session, False)
assert work_session.scalar(select(TPlusAutomationConfig)) is None
+80
View File
@@ -0,0 +1,80 @@
"""真实分页进度在每页提交后可读;总量未知或失败时不伪造成功。"""
from datetime import datetime
from unittest.mock import Mock
from sqlalchemy import select
from app.models import TPlusSyncBatch
from app.services.tplus import progress
from app.services.tplus.sync_service import TPlusSyncService
from tests.unit.test_tplus_sync import target as target
def test_每页提交真实已读数及类别(target):
service = TPlusSyncService(Mock(), target)
service.reader = Mock()
service.reader.count_entities.return_value = {"products": 2}
def rows(*args, **kwargs):
yield {"id": 1}
batch = target.scalar(select(TPlusSyncBatch))
item = progress.public(batch)
assert item["processed_records"] == 1 and item["total_records"] == 2
assert item["percentage"] == 50.0 and item["active_entity"] == "products"
yield {"id": 2}
service.reader.iter_entity.side_effect = rows
batch = service.run(entity_types=["products"], batch_size=1)
item = progress.public(batch)
assert batch.status == "SUCCEEDED" and item["percentage"] == 100.0
assert item["entities"][0]["status"] == "COMPLETED"
def test_总量统计失败仍可读且执行中总量未知(target):
source = Mock()
service = TPlusSyncService(source, target)
service.reader = Mock()
service.reader.count_entities.side_effect = RuntimeError("不得公开的数据库详情")
def rows(*args, **kwargs):
batch = target.scalar(select(TPlusSyncBatch))
assert progress.public(batch)["percentage"] is None
yield {"id": 1}
service.reader.iter_entity.side_effect = rows
batch = service.run(entity_types=["products"])
assert batch.status == "SUCCEEDED"
source.rollback.assert_called_once()
def test_读取中断保留进度及错误终态(target):
service = TPlusSyncService(Mock(), target)
service.reader = Mock()
service.reader.count_entities.return_value = {"products": 4}
def rows(*args, **kwargs):
yield {"id": 1}
raise TimeoutError("敏感详情")
service.reader.iter_entity.side_effect = rows
batch = service.run(entity_types=["products"], batch_size=1)
item = progress.public(batch)
assert batch.status == "FAILED" and item["percentage"] == 25.0
assert item["phase"] == "FAILED" and item["entities"][0]["status"] == "FAILED"
def test_旧批次未知总量不显示编造百分比():
batch = TPlusSyncBatch(batch_no="old", sync_mode="FULL", status="RUNNING",
source_record_count=1000, entity_types_json=["personnel"], started_at=datetime.now())
item = progress.public(batch)
assert item["percentage"] is None and item["total_records"] is None
assert item["processed_records"] == 1000
def test_读到预统计总量仍等终态才显示百分之百():
batch = TPlusSyncBatch(batch_no="b", sync_mode="FULL", status="RUNNING",
source_record_count=10, entity_types_json=["products"], started_at=datetime.now(),
audit_result_json={"progress": {"total_records": 10}})
assert progress.public(batch)["percentage"] == 99.9
batch.status = "SUCCEEDED"
assert progress.public(batch)["percentage"] == 100.0
@@ -15,6 +15,16 @@ def _create(**kwargs):
return TPlusCreateIn(request_key=uuid4(), entity_types=["products"], **kwargs)
def test_关闭源读取仍禁止新建采集任务(tplus_session, monkeypatch):
from app.core.config import Settings
settings = Settings(_env_file=None, app_secret_key="x" * 32)
monkeypatch.setattr(task_service, "get_settings", lambda: settings)
with pytest.raises(BadRequestError, match="未启用"):
task_service.create_task(tplus_session, _create())
assert tplus_session.scalar(select(func.count()).select_from(TPlusSyncBatch)) == 0
def test_同键同意图复用同一批次(tplus_session):
request = _create()
first = task_service.create_task(tplus_session, request)
+108 -1
View File
@@ -24,7 +24,7 @@ from app.models import (
TPlusSyncRecord,
TPlusWorkRun,
)
from app.schemas.tplus_workbench import FactIn, WorkCreateIn
from app.schemas.tplus_workbench import FactIn, WorkCreateIn, WorkQuery
from app.services.tplus import workbench
from app.services.tplus.mapping import get_mapping
from app.services.tplus.master_import import execute_master
@@ -47,6 +47,30 @@ def work_session(monkeypatch):
monkeypatch.setattr(workbench, "_source_database", lambda: "test_source")
monkeypatch.setattr(workbench, "sync_lock", lambda s: nullcontext())
with Session(engine, expire_on_commit=False) as session:
from app.models import (
AuthorizationSetting,
Permission,
Role,
RolePermission,
User,
UserRole,
)
from app.services.permission_catalog import CATALOG, ROLE_DEFAULTS
session.add(AuthorizationSetting(id=1, revision=1))
actor = User(id=1, username="test_operator", password="test_hash_only", status=1)
session.add(actor)
permission_rows = {code: Permission(perm_code=code, perm_name=code, perm_type=2, status=1)
for code in CATALOG}
session.add_all(permission_rows.values())
session.flush()
for code, grants in ROLE_DEFAULTS.items():
role = Role(role_code=code, role_name=code, status=1)
session.add(role)
session.flush()
session.add_all([RolePermission(role_id=role.id, permission_id=permission_rows[p].id) for p in grants])
if code == "admin":
actor.user_roles.append(UserRole(role_id=role.id))
session.add(
BusinessRole(
role_name="营销员",
@@ -139,6 +163,89 @@ def prepare_person(session):
)
def test_关闭源读取仍可查询历史资料和任务(work_session, monkeypatch):
from app.core.config import Settings
from app.services.tplus import task_service
s = work_session
prepare_person(s)
run = preview(s, ["personnel"])
settings = Settings(_env_file=None, app_secret_key="x" * 32)
monkeypatch.setattr(task_service, "get_settings", lambda: settings)
def disabled_source():
raise RuntimeError("T+ 数据源未启用")
monkeypatch.setattr(workbench, "_source_database", disabled_source)
facts = workbench.list_facts(s, WorkQuery())
assert facts.total == 1
assert workbench.list_work(s, WorkQuery()).total == 1
assert workbench.get_run(s, run.run_no).run_no == run.run_no
assert workbench.fact_history(s, facts.records[0]["id"], WorkQuery()).total == 1
assert workbench.list_lines(s, run.run_no, WorkQuery()).total == 1
assert "仅供核对" in "".join(workbench.report_csv(s, run.run_no))
def test_关闭源读取有多个账套时拒绝猜测(work_session, monkeypatch):
from app.core.config import Settings
from app.core.exceptions import BadRequestError
from app.services.tplus import task_service
prepare_person(work_session)
work_session.add(TPlusFact(
source_database="another_source", kind="PRODUCT", fact_key="9", version=1,
data_json={"category": "municipal_water", "unit": "件"},
evidence="其他账套测试", requested_by=1,
))
work_session.commit()
settings = Settings(_env_file=None, app_secret_key="x" * 32)
monkeypatch.setattr(task_service, "get_settings", lambda: settings)
with pytest.raises(BadRequestError, match="多个账套"):
workbench.list_facts(work_session, WorkQuery())
def test_历史查询显式账套范围不能串账(work_session, monkeypatch):
from app.core.config import Settings
from app.services.tplus import task_service
prepare_person(work_session)
settings = Settings(
_env_file=None, app_secret_key="x" * 32, t_plus_database="another_source"
)
monkeypatch.setattr(task_service, "get_settings", lambda: settings)
assert workbench.list_facts(work_session, WorkQuery()).total == 0
def test_关闭读取时概况仍显示最近成功采集(work_session, monkeypatch):
from app.core.config import Settings
from app.services.tplus import query_service, task_service
prepare_person(work_session)
settings = Settings(_env_file=None, app_secret_key="x" * 32)
monkeypatch.setattr(task_service, "get_settings", lambda: settings)
overview = query_service.overview(work_session)
assert overview.is_configured is False
assert "仍可查看" in overview.configuration_message
assert overview.automation.enabled is False
assert overview.automation.last_success_at is not None
assert overview.total_staged_records >= 0
def test_关闭SQL源读取仍可维护已存账套补充资料(work_session, monkeypatch):
from app.core.config import Settings
from app.core.exceptions import BadRequestError
from app.services.tplus import task_service
prepare_person(work_session)
settings = Settings(_env_file=None, app_secret_key="x" * 32)
monkeypatch.setattr(task_service, "get_settings", lambda: settings)
def disabled_source():
raise BadRequestError("T+ 数据源未启用")
monkeypatch.setattr(workbench, "_source_database", disabled_source)
data = fact(work_session, "PRODUCT", "9", {"category":"municipal_water", "unit":"件"})
assert data["version"] == 1
def test_人员真实导入且重跑不重复_保留人工工资与岗位(work_session):
s = work_session
prepare_person(s)