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

296 lines
10 KiB
Python

"""工作区任务与事实存取:与原有 worker 共用执行锁和持久化队列。"""
import hashlib
import json
import logging
from datetime import datetime
from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import defer
from app.core.exceptions import BadRequestError, NotFoundError
from app.core.response import PageResult
from app.core.tplus_lock import TPlusBusyError, sync_lock
from app.models import TPlusFact, TPlusWorkLine, TPlusWorkRun
from app.schemas.tplus_workbench import WorkCreateIn
from app.services.tplus.task_service import _source_database, new_batch_no
RULE_VERSION = "2026-10-30.2"
logger = logging.getLogger(__name__)
def digest(data):
"""稳定指纹用于固定规则、事实和来源版本。"""
return hashlib.sha256(
json.dumps(
data, ensure_ascii=False, sort_keys=True, default=str, separators=(",", ":")
).encode()
).hexdigest()
def latest_facts(session, source):
"""每个事实键只读取最近版本,历史行保持可追溯。"""
ids = (
select(func.max(TPlusFact.id))
.where(TPlusFact.source_database == source)
.group_by(TPlusFact.kind, TPlusFact.fact_key)
)
return {
(f.kind, f.fact_key): f
for f in session.scalars(
select(TPlusFact).where(TPlusFact.id.in_(ids)).order_by(TPlusFact.id)
)
}
def save_fact(session, payload, actor_id):
"""与导入共用锁,乐观版本不匹配时拒绝覆盖。"""
source = _source_database()
try:
with sync_lock(session):
session.rollback()
current = latest_facts(session, source).get((payload.kind, payload.fact_key))
if payload.kind == "GUIDANCE" and current and current.data_json.get("is_finalized"):
raise BadRequestError("该发货月份指导成本已封存,后续回款不得修改原指导成本")
if (current.version if current else 0) != payload.expected_version:
raise BadRequestError("补充资料已经变化,请刷新后再保存")
fact = TPlusFact(
source_database=source,
kind=payload.kind,
fact_key=payload.fact_key,
version=payload.expected_version + 1,
data_json=payload.data,
evidence=payload.evidence,
requested_by=actor_id,
)
session.add(fact)
session.commit()
return fact_out(fact)
except (TPlusBusyError, IntegrityError):
session.rollback()
raise BadRequestError("资料或任务正在更新,请刷新后重试") from None
def fact_out(fact):
"""仅工作区授权接口展示核算必需事实,不返回连接凭据。"""
return {
"id": fact.id,
"kind": fact.kind,
"fact_key": fact.fact_key,
"version": fact.version,
"data": fact.data_json,
"evidence": fact.evidence,
"requested_by": fact.requested_by,
"create_time": fact.create_time,
}
def list_facts(session, query):
source = _source_database()
ids = (
select(func.max(TPlusFact.id))
.where(TPlusFact.source_database == source)
.group_by(TPlusFact.kind, TPlusFact.fact_key)
)
condition = [TPlusFact.id.in_(ids)]
if query.kind:
condition.append(TPlusFact.kind == query.kind)
total = session.scalar(select(func.count()).select_from(TPlusFact).where(*condition))
rows = session.scalars(
select(TPlusFact)
.where(*condition)
.order_by(TPlusFact.id)
.offset(query.offset)
.limit(query.page_size)
)
return PageResult(
records=[fact_out(f) for f in rows],
total=total or 0,
page_num=query.page_num,
page_size=query.page_size,
)
def queue_work(session, payload: WorkCreateIn, actor_id):
"""HTTP 只排队;同幂等键的不同意图必须被拒绝。"""
values = payload.model_dump(mode="json", exclude={"request_key"})
source = _source_database()
key = str(payload.request_key)
found = session.scalar(select(TPlusWorkRun).where(TPlusWorkRun.request_key == key))
if found:
if (
found.input_json != values
or found.source_database != source
or found.requested_by != actor_id
):
raise BadRequestError("请求键已用于不同任务")
return work_out(found)
if payload.kind == "MASTER_APPLY":
preview = get_run(session, payload.preview_no, source)
if preview.kind != "MASTER_PREVIEW" or preview.status not in ("SUCCEEDED", "PARTIAL"):
raise BadRequestError("必须选择已完成的基础导入预览")
if payload.kind.startswith("BUSINESS_"):
from app.models import User
from app.services.payroll_access import ensure_payroll_writer
ensure_payroll_writer(session.get(User, actor_id))
if payload.kind == "BUSINESS_APPLY":
preview = get_run(session, payload.preview_no, source)
if preview.kind != "BUSINESS_PREVIEW" or preview.status not in ("SUCCEEDED", "PARTIAL"):
raise BadRequestError("必须选择已完成的正式业务导入预览")
if payload.replay_no:
previous = get_run(session, payload.replay_no, source)
if previous.kind != "CALC_PREVIEW" or previous.status not in ("SUCCEEDED", "PARTIAL"):
raise BadRequestError("只能重跑已完成的核算预演")
run = TPlusWorkRun(
run_no=new_batch_no(),
request_key=key,
kind=payload.kind,
status="QUEUED",
source_database=source,
requested_by=actor_id,
input_json=values,
)
try:
with session.begin_nested():
session.add(run)
session.flush()
session.commit()
except IntegrityError:
session.rollback()
return queue_work(session, payload, actor_id)
return work_out(run)
def get_run(session, number, source=None):
source = source or _source_database()
run = session.scalar(
select(TPlusWorkRun)
.where(TPlusWorkRun.run_no == number, TPlusWorkRun.source_database == source)
.options(defer(TPlusWorkRun.snapshot_json))
)
if run is None:
raise NotFoundError("工作区运行不存在")
return run
def work_out(run):
return {
"run_no": run.run_no,
"kind": run.kind,
"status": run.status,
"request_key": run.request_key,
"input": run.input_json,
"snapshot_hash": run.snapshot_hash,
"summary": run.summary_json,
"error_code": run.error_code,
"create_time": run.create_time,
"started_at": run.started_at,
"finished_at": run.finished_at,
"formal_payroll_written": False,
}
def list_work(session, query):
conditions = [TPlusWorkRun.source_database == _source_database()]
if query.kind:
conditions.append(TPlusWorkRun.kind == query.kind)
total = session.scalar(select(func.count()).select_from(TPlusWorkRun).where(*conditions))
rows = session.scalars(
select(TPlusWorkRun)
.options(defer(TPlusWorkRun.snapshot_json))
.where(*conditions)
.order_by(TPlusWorkRun.id.desc())
.offset(query.offset)
.limit(query.page_size)
)
return PageResult(
records=[work_out(r) for r in rows],
total=total or 0,
page_num=query.page_num,
page_size=query.page_size,
)
def list_lines(session, number, query):
get_run(session, number)
conditions = [TPlusWorkLine.run_no == number]
if query.action:
conditions.append(TPlusWorkLine.action == query.action)
total = session.scalar(select(func.count()).select_from(TPlusWorkLine).where(*conditions))
rows = session.scalars(
select(TPlusWorkLine)
.where(*conditions)
.order_by(TPlusWorkLine.line_no)
.offset(query.offset)
.limit(query.page_size)
)
return PageResult(
records=[
{
"line_no": r.line_no,
"entity_type": r.entity_type,
"source_id": r.source_id,
"action": r.action,
"reason": r.reason,
"data": r.data_json,
}
for r in rows
],
total=total or 0,
page_num=query.page_num,
page_size=query.page_size,
)
def execute_next(session):
"""调用方持有原有全局锁;中断任务保留明细,禁止不明状态自动应用。"""
for old in session.scalars(select(TPlusWorkRun).where(TPlusWorkRun.status == "RUNNING")):
old.status, old.error_code, old.finished_at = "FAILED", "INTERRUPTED", datetime.now()
session.commit()
run = session.scalar(
select(TPlusWorkRun)
.where(TPlusWorkRun.status == "QUEUED")
.order_by(TPlusWorkRun.id)
.limit(1)
.with_for_update()
)
if run is None:
session.rollback()
return None
run.status, run.started_at = "RUNNING", datetime.now()
session.commit()
try:
if run.source_database != _source_database():
raise WorkbenchError("SOURCE_CHANGED")
if run.kind.startswith("BUSINESS_"):
from app.services.tplus.business_import import execute_business
execute_business(session, run)
elif run.kind.startswith("MASTER_"):
from app.services.tplus.master_import import execute_master
execute_master(session, run)
else:
from app.services.tplus.calculation_preview import execute_preview
execute_preview(session, run, customer_scope=True)
run.finished_at = datetime.now()
session.commit()
except Exception as exc:
session.rollback()
run = get_run(session, run.run_no, run.source_database)
run.status, run.finished_at = "FAILED", datetime.now()
# 不让驱动异常、SQL 或原始数据进入响应。
run.error_code = str(exc) if isinstance(exc, WorkbenchError) else "EXECUTION_ERROR"
logger.error(
"工作区任务失败 | run_no=%s | exception_type=%s", run.run_no, type(exc).__name__
)
session.commit()
return run.run_no
class WorkbenchError(ValueError):
"""可公开的固定失败编码;不得传入客户原文或驱动异常。"""