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
@@ -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