PayrollManagementSystem/backend/app/services/tplus/query_service.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

129 lines
5.5 KiB
Python

"""同步管理只读查询;只公开受控元数据,不公开源数据和原异常。"""
from app.core.exceptions import NotFoundError
from app.core.response import PageResult
from app.core.tplus_db import resolve_tplus_settings
from app.repositories import tplus_repository as repository
from app.schemas.tplus import (
TPlusBatchDetailOut,
TPlusBatchOut,
TPlusErrorOut,
TPlusOverviewOut,
)
from app.services.tplus import task_service
from app.services.tplus.automation import overview as automation_overview
from app.services.tplus.errors import MESSAGES
from app.services.tplus.mapping import DETAIL_MAPPINGS, TPLUS_MAPPINGS
from app.services.tplus.relationship_audit import public_report
ERROR_MESSAGES = {
"READ_ERROR": "源数据读取失败,请检查只读连接和字段映射",
"STAGE_ERROR": "原始数据保存失败,请核对字段类型后重试",
"INTERRUPTED": "执行进程中断,可重新读取,已保存数据会自动去重",
"EXECUTION_ERROR": "任务执行失败,请检查只读连接和字段映射",
"TYPE_UNRESOLVED": "关联核验所需的单据类型缺失或不唯一",
}
_ENTITY_TYPES = {item.entity_type for item in (*TPLUS_MAPPINGS, *DETAIL_MAPPINGS)}
ERROR_MESSAGES.update(MESSAGES)
def batch_out(batch, active_parents: set[str]) -> TPlusBatchOut:
"""只使用元数据字段组装输出,历史批次范围保持未知。"""
entities = batch.entity_types_json
scope_valid = (
isinstance(entities, list)
and bool(entities)
and all(isinstance(item, str) and item in _ENTITY_TYPES for item in entities)
)
return TPlusBatchOut(
batch_no=batch.batch_no,
task_type="AUDIT" if batch.sync_mode == "AUDIT" else "READ",
status=batch.status,
entity_types=entities if scope_valid else None,
source_record_count=batch.source_record_count,
staged_record_count=batch.staged_record_count,
skipped_record_count=batch.skipped_record_count,
failed_record_count=batch.failed_record_count,
parent_batch_no=batch.parent_batch_no,
create_time=batch.create_time,
started_at=batch.started_at,
finished_at=batch.finished_at,
can_retry=batch.status in ("FAILED", "PARTIAL")
and (batch.sync_mode == "AUDIT" or (batch.sync_mode == "FULL" and scope_valid))
and batch.batch_no not in active_parents,
error_summary="任务有异常,请查看异常明细"
if batch.status in ("FAILED", "PARTIAL")
else None,
trigger_type=getattr(batch, "trigger_type", "MANUAL") or "MANUAL",
automatic_attempt=getattr(batch, "automatic_attempt", 0) or 0,
)
def get_batch(session, number: str) -> TPlusBatchDetailOut:
"""按需加载报告,未找到时统一返回 404。"""
batch = repository.get_batch(session, number, with_result=True)
if batch is None:
raise NotFoundError("同步批次不存在")
out = batch_out(batch, repository.active_retry_parents(session, [number]))
return TPlusBatchDetailOut(
**out.model_dump(), audit_result=public_report(batch.audit_result_json)
)
def list_batches(session, query):
"""分页批次并批量查询重试状态。"""
rows, total = repository.list_batches(session, query)
active = repository.active_retry_parents(session, [row.batch_no for row in rows])
return PageResult[TPlusBatchOut](
records=[batch_out(row, active) for row in rows],
total=total,
page_num=query.page_num,
page_size=query.page_size,
)
def _safe_id(value) -> str | None:
if isinstance(value, str) and value.isascii() and value.isdigit() and len(value) <= 10:
return value if int(value) <= 2147483647 else None
return None
def list_errors(session, number, query):
"""错误按编码生成固定说明,避免驱动异常泄漏连接参数。"""
if repository.get_batch(session, number) is None:
raise NotFoundError("同步批次不存在")
rows, total = repository.list_errors(session, number, query)
records = [
TPlusErrorOut(
id=row.id,
entity_type=row.entity_type if row.entity_type in _ENTITY_TYPES else "system",
source_id=_safe_id(row.source_id),
error_code=row.error_code if row.error_code in ERROR_MESSAGES else "UNKNOWN",
error_message=ERROR_MESSAGES.get(row.error_code, "任务未完成,请核对配置后重试"),
retry_count=row.retry_count,
create_time=row.create_time,
)
for row in rows
]
return PageResult[TPlusErrorOut](
records=records, total=total, page_num=query.page_num, page_size=query.page_size
)
def overview(session) -> TPlusOverviewOut:
"""只校验配置,不在页面查询期间建立 T+ 连接。"""
try:
resolve_tplus_settings(task_service.get_settings())
configured, message = True, "只读配置已就绪,实际连接结果以任务执行为准"
except RuntimeError:
configured, message = False, "T+ 只读配置未就绪,请联系管理员配置"
latest = repository.latest_audit(session)
return TPlusOverviewOut(
is_configured=configured,
configuration_message=message,
status_counts=repository.status_counts(session),
total_staged_records=repository.total_staged_records(session),
latest_audit=public_report(latest.audit_result_json) if latest else None,
automation=automation_overview(session, settings=task_service.get_settings()),
)