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