PayrollManagementSystem/backend/app/services/tplus/business_sync_control.py

142 lines
6.1 KiB
Python
Raw Normal View History

"""业务接入开关与幂等排队;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