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