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

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

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

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

No files matched your search

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