PayrollManagementSystem/backend/app/services/tplus/business_sync_control.py
liuwenxin 1e53792ad6 feat(T+): 完成数据贯通与自动接入闭环
打通 T+ 快照、业务、发货、回款和核算准备链路,保留来源差异并提供待补状态。
新增业务自动接入任务、跨平台读取服务托管、权限复核、来源关联和前端核对页面。
补充迁移、隔离集成测试、浏览器模拟验收及角色授权边界修复。
2026-11-05 18:47:13 +08:00

142 lines
6.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""业务接入开关与幂等排队;HTTP 不执行导入。"""
from uuid import NAMESPACE_URL, uuid5
from sqlalchemy import inspect, select
from sqlalchemy.exc import IntegrityError
from app.core.exceptions import BadRequestError, ConflictError, ForbiddenError
from app.models import TPlusBusinessSyncConfig, TPlusWorkRun, User
from app.services.authorization_service import require_permission
from app.services.tplus.business_sync import START, preview
from app.services.tplus.task_service import history_source_database, new_batch_no
from app.services.tplus.workbench import WorkbenchError, digest, work_out
def configuration(session):
source = history_source_database(session)
ready = inspect(session.connection()).has_table(TPlusBusinessSyncConfig.__tablename__)
row = session.scalar(select(TPlusBusinessSyncConfig).where(TPlusBusinessSyncConfig.source_database == source)) if ready else None
return {"enabled": bool(row and row.enabled), "start_date": (row.start_date if row else START).isoformat(),
"version": row.version if row else 0, "editable": ready and bool(source),
"last_error": row.last_error if row else None}
def queue(session, actor_id, start_date, request_key):
require_permission(session, session.get(User, actor_id), "tplus:import")
source = history_source_database(session)
if not source:
raise BadRequestError("请先完成 T+ 数据采集")
key = str(request_key)
found = session.scalar(select(TPlusWorkRun).where(TPlusWorkRun.request_key == key))
values = {"start_date": start_date.isoformat()}
def existing_run(found):
if found.kind != "BUSINESS_SYNC" or found.requested_by != actor_id or found.source_database != source or found.input_json != values:
raise BadRequestError("请求键已用于不同任务")
return work_out(found)
if found:
return existing_run(found)
run = TPlusWorkRun(run_no=new_batch_no(), request_key=key, source_database=source,
requested_by=actor_id, kind="BUSINESS_SYNC", status="QUEUED", input_json=values)
try:
with session.begin_nested():
session.add(run)
session.flush()
session.commit()
except IntegrityError:
session.rollback()
found = session.scalar(select(TPlusWorkRun).where(TPlusWorkRun.request_key == key))
if found is None:
raise ConflictError() from None
return existing_run(found)
return work_out(run)
def save(session, payload, actor_id):
require_permission(session, session.get(User, actor_id), "tplus:import")
source = history_source_database(session)
if not source:
raise BadRequestError("请先完成 T+ 数据采集")
row = session.scalar(select(TPlusBusinessSyncConfig).where(TPlusBusinessSyncConfig.source_database == source).with_for_update())
if (row.version if row else 0) != payload.expected_version:
raise ConflictError()
if row is None:
row = TPlusBusinessSyncConfig(source_database=source, updated_by=actor_id)
session.add(row)
row.enabled, row.start_date = int(payload.enabled), payload.start_date
row.version, row.updated_by, row.last_error = payload.expected_version + 1, actor_id, None
try:
session.commit()
except IntegrityError:
session.rollback()
raise ConflictError() from None
if row.enabled:
schedule(session)
return configuration(session)
def schedule(session):
"""仅成功快照触发;去重周期与配置版本绑定,不改变原采集结果。"""
if not inspect(session.connection()).has_table(TPlusBusinessSyncConfig.__tablename__):
return None
source = history_source_database(session)
config = session.scalar(select(TPlusBusinessSyncConfig).where(TPlusBusinessSyncConfig.source_database == source))
if config is None or not config.enabled:
return None
try:
require_permission(session, session.get(User, config.updated_by), "tplus:import")
except ForbiddenError:
config.last_error = "TASK_AUTHORIZATION_REVOKED"
session.commit()
return None
from app.services.tplus.snapshots import source_manifest
try:
manifest = list(source_manifest(session, source).items())
except WorkbenchError:
config.last_error = "SOURCE_REFRESH_REQUIRED"
session.commit()
return None
key = uuid5(NAMESPACE_URL, digest([source, config.version, manifest, "business-sync-v1"]))
result = queue(session, config.updated_by, config.start_date, key)
if result["status"] != "FAILED" and config.last_error is not None:
config.last_error = None
session.commit()
return result
def readonly_preview(session, start_date=START):
return preview(session, history_source_database(session), start_date)
def record_schedule_failure(session, source):
"""保留采集成功状态,同时在接入设置中提示排队异常。"""
config = session.scalar(select(TPlusBusinessSyncConfig).where(
TPlusBusinessSyncConfig.source_database == source,
TPlusBusinessSyncConfig.enabled == 1,
).with_for_update())
if config:
config.last_error = "BUSINESS_SYNC_SCHEDULE_FAILED"
session.commit()
def record_authorization_failure(session, source, actor_id):
"""仅标记仍由该授权人启用的配置,由调用方提交任务失败状态。"""
config = session.scalar(select(TPlusBusinessSyncConfig).where(
TPlusBusinessSyncConfig.source_database == source,
TPlusBusinessSyncConfig.enabled == 1,
TPlusBusinessSyncConfig.updated_by == actor_id,
).with_for_update())
if config:
config.last_error = "TASK_AUTHORIZATION_REVOKED"
def clear_execution_failure(session, source, actor_id):
"""当前授权人重试成功后清除旧错误,由调用方提交任务结果。"""
config = session.scalar(select(TPlusBusinessSyncConfig).where(
TPlusBusinessSyncConfig.source_database == source,
TPlusBusinessSyncConfig.enabled == 1,
TPlusBusinessSyncConfig.updated_by == actor_id,
).with_for_update())
if config:
config.last_error = None