PayrollManagementSystem/backend/tests/unit/test_tplus_automation.py
liuwenxin 108bf87225 feat(核算)!: 添加 T+ 正式核算与采集托管
补齐源快照、资料预演、正式导入及工资核算的依赖链路。
新增版本化输入、逐段 FIFO、生成锁和结构化当月调整,
并提供默认关闭的周期采集、有限重试及 Windows 托管。

原始客户资料、私有配置及首页改动不纳入本次提交。

BREAKING CHANGE: 升级前需应用新增迁移,并通过
PAYROLL_MAKER_USER_ID 绑定实际工资制作人,否则业务只读。
旧单批次核算默认关闭;历史兼容需显式启用
PAYROLL_ALLOW_LEGACY_CALCULATION。生产启用和真实金额待验收。
2026-11-02 09:42:53 +08:00

161 lines
7.1 KiB
Python

"""周期和自动重试只能生成原始采集,验证重启防重与人工介入边界。"""
from datetime import datetime, timedelta
import pytest
from sqlalchemy import func, select
from app.core.config import Settings
from app.models import TPlusAutomationState, TPlusSyncBatch, TPlusSyncError
from app.services.tplus import automation
from tests.unit.test_tplus_workbench import work_session as work_session
NOW = datetime(2026, 10, 30, 10)
@pytest.fixture
def settings():
return 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)
def run(s, settings, now=NOW):
return automation.tick(s, settings=settings, now=now)
def fail(s, number, *, status="FAILED", code="SOURCE_TIMEOUT", at=NOW):
row = s.scalar(select(TPlusSyncBatch).where(TPlusSyncBatch.batch_no == number))
row.status, row.finished_at = status, at
s.add(TPlusSyncError(batch_no=number, entity_type="system", source_table="system", error_code=code, error_message="固定错误说明"))
s.commit()
def test_默认关闭不会排队(work_session, settings):
result = run(work_session, settings.model_copy(update={"t_plus_automation_enabled": False}))
assert result == {"status": "DISABLED", "queued": False}
assert work_session.scalar(select(func.count()).select_from(TPlusSyncBatch)) == 0
def test_同周期重复检查和重启只有一次九类采集(work_session, settings):
s = work_session
first = run(s, settings)
for _ in range(3):
assert not run(s, settings)["queued"]
batch = s.scalar(select(TPlusSyncBatch))
assert batch.batch_no == first["batch_no"] and batch.sync_mode == "FULL"
assert len(batch.entity_types_json) == 9 and batch.trigger_type == "SCHEDULED"
assert s.scalar(select(func.count()).select_from(TPlusSyncBatch)) == 1
def test_停机多周只补最近一次(work_session, settings):
s = work_session
first = run(s, settings)
batch = s.scalar(select(TPlusSyncBatch))
batch.status, batch.finished_at = "SUCCEEDED", NOW
s.commit()
later = run(s, settings, NOW + timedelta(weeks=4))
assert later["queued"] and later["period"] != first["period"]
assert s.scalar(select(func.count()).select_from(TPlusSyncBatch)) == 2
def test_人工任务占用时不堆积周期任务(work_session, settings):
s = work_session
s.add(TPlusSyncBatch(batch_no="manual", sync_mode="FULL", status="RUNNING", source_database="test_source", entity_types_json=["personnel"]))
s.commit()
assert run(s, settings)["status"] == "DEFERRED"
assert s.scalar(select(func.count()).select_from(TPlusSyncBatch)) == 1
def test_明确超时仅按五十五分钟最多重试两次(work_session, settings):
s = work_session
first = run(s, settings)
fail(s, first["batch_no"])
assert not run(s, settings, NOW + timedelta(minutes=4))["queued"]
second = run(s, settings, NOW + timedelta(minutes=5))
assert second["queued"]
fail(s, second["batch_no"], at=NOW + timedelta(minutes=5))
assert not run(s, settings, NOW + timedelta(minutes=19))["queued"]
third = run(s, settings, NOW + timedelta(minutes=20))
assert third["queued"]
fail(s, third["batch_no"], at=NOW + timedelta(minutes=21))
assert not run(s, settings, NOW + timedelta(hours=2))["queued"]
rows = list(s.scalars(select(TPlusSyncBatch).order_by(TPlusSyncBatch.id)))
assert [(r.trigger_type, r.automatic_attempt) for r in rows] == [("SCHEDULED", 0), ("AUTO_RETRY", 1), ("AUTO_RETRY", 2)]
assert rows[1].parent_batch_no == rows[0].batch_no and rows[2].parent_batch_no == rows[1].batch_no
@pytest.mark.parametrize("status,code", [("PARTIAL", "SOURCE_TIMEOUT"), ("FAILED", "SOURCE_CONFIG_ERROR"), ("FAILED", "STAGE_ERROR"), ("FAILED", "EXECUTION_ERROR")])
def test_部分失败或不明确错误不自动重试(work_session, settings, status, code):
s = work_session
first = run(s, settings)
fail(s, first["batch_no"], status=status, code=code)
assert not run(s, settings, NOW + timedelta(hours=2))["queued"]
assert s.scalar(select(func.count()).select_from(TPlusSyncBatch)) == 1
def test_ISO年周和到期时间边界稳定():
assert automation.due_period(datetime(2027, 1, 1, 10))[0] == "2026-W53"
assert automation.due_period(datetime(2026, 11, 2, 1, 59))[0] == "2026-W44"
assert automation.due_period(datetime(2026, 11, 2, 2))[0] == "2026-W45"
def test_站内提示覆盖排队超时和无成功同步(work_session, settings):
s = work_session
run(s, settings)
batch = s.scalar(select(TPlusSyncBatch))
batch.create_time = NOW - timedelta(minutes=16)
s.commit()
result = automation.overview(s, settings=settings, now=NOW)
assert {r["code"] for r in result["alerts"]} >= {"QUEUE_OVERDUE", "SYNC_STALE", "WORKER_OFFLINE"}
def test_读取心跳不把当前采集写成工资(work_session, settings):
from sqlalchemy.orm import sessionmaker
s = work_session
automation.heartbeat(sessionmaker(s.get_bind()), settings, "owner", "RUNNING", now=NOW)
s.expire_all()
state = s.scalar(select(TPlusAutomationState))
assert state.heartbeat_at == NOW and state.worker_phase == "RUNNING"
assert s.scalar(select(func.count()).select_from(TPlusSyncBatch)) == 0
@pytest.mark.parametrize("status,code", [("SUCCEEDED", None), ("FAILED", "SOURCE_CONFIG_ERROR"), ("FAILED", "SOURCE_TIMEOUT")])
def test_人工重试结果接入周期且不重置自动次数(work_session, settings, monkeypatch, status, code):
from uuid import uuid4
from app.schemas.tplus import TPlusRetryIn
from app.services.tplus import task_service
monkeypatch.setattr(task_service, "get_settings", lambda: settings)
s = work_session
first = run(s, settings)
fail(s, first["batch_no"])
auto = run(s, settings, NOW + timedelta(minutes=5))
fail(s, auto["batch_no"], at=NOW + timedelta(minutes=5))
manual = task_service.retry_task(s, auto["batch_no"], TPlusRetryIn(request_key=uuid4()))
assert run(s, settings, NOW + timedelta(minutes=6))["status"] == "DEFERRED"
if code:
fail(s, manual.batch_no, status=status, code=code, at=NOW + timedelta(minutes=6))
else:
manual.status, manual.finished_at = status, NOW + timedelta(minutes=6)
s.commit()
outcome = run(s, settings, NOW + timedelta(minutes=30))
assert outcome["queued"] == (code == "SOURCE_TIMEOUT")
state = s.scalar(select(TPlusAutomationState))
assert state.retry_attempts == (2 if outcome["queued"] else 1)
if status == "SUCCEEDED":
assert state.last_success_at == manual.finished_at
if outcome["queued"]:
child = s.scalar(select(TPlusSyncBatch).where(TPlusSyncBatch.batch_no == outcome["batch_no"]))
assert child.parent_batch_no == manual.batch_no
def test_停止标记阻止周期排队且不报告进程离线(work_session, settings, tmp_path):
marker = tmp_path / "stop.requested"
marker.touch()
stopped = settings.model_copy(update={"t_plus_stop_file": str(marker)})
assert run(work_session, stopped)["status"] == "STOP_REQUESTED"
assert not automation.overview(work_session, settings=stopped, now=NOW)["alerts"]