补齐源快照、资料预演、正式导入及工资核算的依赖链路。 新增版本化输入、逐段 FIFO、生成锁和结构化当月调整, 并提供默认关闭的周期采集、有限重试及 Windows 托管。 原始客户资料、私有配置及首页改动不纳入本次提交。 BREAKING CHANGE: 升级前需应用新增迁移,并通过 PAYROLL_MAKER_USER_ID 绑定实际工资制作人,否则业务只读。 旧单批次核算默认关闭;历史兼容需显式启用 PAYROLL_ALLOW_LEGACY_CALCULATION。生产启用和真实金额待验收。
161 lines
7.1 KiB
Python
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"]
|