PayrollManagementSystem/backend/app/services/tplus/workbench.py
liuwenxin b8920c0d28 fix(T+): 补齐指导成本与读取服务保护
补充业务详情指导成本入口、来源版本和历史封存保护,接入月份锁并完善权限复核。
同步接入读取服务运行状态到页面,补充前端控制、回归测试和服务端只读连接授权校验。

定向隔离测试:89 项通过,1 条既有 Starlette 弃用警告。
2026-11-05 19:32:20 +08:00

536 lines
20 KiB
Python
Raw Permalink 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.

"""工作区任务与事实存取:与原有 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, ConflictError, ForbiddenError, NotFoundError
from app.core.response import PageResult
from app.core.tplus_lock import TPlusBusyError, sync_lock
from app.models import TPlusFact, TPlusSyncBatch, TPlusSyncRecord, TPlusWorkLine, TPlusWorkRun
from app.schemas.tplus_workbench import WorkCreateIn
from app.services.tplus.report_labels import ACTION_LABELS, reason_label, spreadsheet_value
from app.services.tplus.task_service import _source_database, history_source_database, new_batch_no
RULE_VERSION = "2026-11-03.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 _work_source_database(session):
"""工作区使用本地已存资料;SQL Server 读取开关只约束真正的源采集。"""
try:
return _source_database()
except BadRequestError:
source = history_source_database(session)
if source is None:
raise BadRequestError("尚无可用的源账套,请先配置账套并完成源数据采集") from None
return source
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):
"""与导入共用锁,乐观版本不匹配时拒绝覆盖。"""
if payload.kind == "CORRECTION":
raise BadRequestError("更正单请使用成对处理入口,不能单独排除或修改一张源单")
source = _work_source_database(session)
try:
with sync_lock(session):
session.rollback()
facts = latest_facts(session, source)
current = facts.get((payload.kind, payload.fact_key))
if payload.kind == "GUIDANCE" and current and current.data_json.get("is_finalized"):
raise BadRequestError("该发货月份指导成本已封存,后续回款不得修改原指导成本")
if payload.kind == "GUIDANCE":
if payload.data.get("is_finalized"):
raise BadRequestError("请在业务详情中核对来源并封存指导成本")
if any(
kind == "GUIDANCE"
and fact.data_json.get("is_finalized")
and fact.data_json.get("product_id") == payload.data.get("product_id")
and fact.data_json.get("month") == payload.data.get("month")
for (kind, _), fact in facts.items()
):
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 = history_source_database(session)
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 只排队;同幂等键的不同意图必须被拒绝。"""
from app.models import User
from app.services.authorization_service import require_permission
require_permission(session, session.get(User, actor_id),
"tplus:import" if payload.kind.endswith("_APPLY") else "tplus:preview")
values = payload.model_dump(mode="json", exclude={"request_key"})
source = _work_source_database(session)
key = str(payload.request_key)
def existing_run(found):
if (
found.input_json != values
or found.source_database != source
or found.requested_by != actor_id
):
raise BadRequestError("请求键已用于不同任务")
return work_out(found)
found = session.scalar(select(TPlusWorkRun).where(TPlusWorkRun.request_key == key))
if found:
return existing_run(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 == "BUSINESS_APPLY":
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()
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 get_run(session, number, source=None):
source = source or history_source_database(session)
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 == history_source_database(session)]
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 _display_data(session, run, rows):
"""按原记录版本补齐基础档案展示字段;不改历史结果或使用最新源数据。"""
if run.kind not in ("MASTER_PREVIEW", "MASTER_APPLY"):
return [row.data_json for row in rows]
record_ids = {row.data_json.get("record_id") for row in rows} - {None}
records = {
record.id: record
for record in session.scalars(
select(TPlusSyncRecord)
.join(TPlusSyncBatch, TPlusSyncBatch.batch_no == TPlusSyncRecord.batch_no)
.where(
TPlusSyncRecord.id.in_(record_ids),
TPlusSyncBatch.source_database == run.source_database,
)
)
} if record_ids else {}
roster = {}
if any(row.reason == "ROSTER_SOURCE_MISSING" for row in rows):
roster = {
fact["key"]: fact["data"]
for fact in (run.snapshot_json or {}).get("facts", [])
if fact.get("kind") == "PERSONNEL"
}
result = []
for row in rows:
data = dict(row.data_json)
record = records.get(data.get("record_id"))
if record and record.entity_type == row.entity_type and record.source_id == row.source_id:
source = record.payload_json
fields = {"source_name": source.get("name"), "source_no": record.source_no}
if row.entity_type == "products":
fields.update(source_specification=source.get("specification"),
source_unit=source.get("unit_name"))
for key, value in fields.items():
if data.get(key) is None and value is not None:
data[key] = value
elif row.reason == "ROSTER_SOURCE_MISSING":
person = roster.get(row.source_id, {})
data.setdefault("source_name", person.get("name"))
data.setdefault("source_no", row.source_id)
result.append(data)
return result
def list_lines(session, number, query):
run = 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 = list(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": data,
}
for r, data in zip(rows, _display_data(session, run, rows), strict=True)
],
total=total or 0,
page_num=query.page_num,
page_size=query.page_size,
)
def fact_history(session, fact_id, query):
"""按事实身份分页查看修改历史,不允许跨账套读取。"""
fact = session.get(TPlusFact, fact_id)
if fact is None or fact.source_database != history_source_database(session):
raise NotFoundError("补充资料不存在")
conditions = [
TPlusFact.source_database == fact.source_database,
TPlusFact.kind == fact.kind,
TPlusFact.fact_key == fact.fact_key,
]
total = session.scalar(select(func.count()).select_from(TPlusFact).where(*conditions))
rows = session.scalars(
select(TPlusFact)
.where(*conditions)
.order_by(TPlusFact.version.desc())
.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 source_choices(session, query):
"""仅提供挑选资料所需的源编号和名称,不返回电话、地址和原始载荷。"""
from app.services.tplus.snapshots import source_statement
try:
statement = source_statement(session, history_source_database(session), query.entity)
except WorkbenchError:
raise BadRequestError("请先完整读取相关源数据") from None
if query.search:
escaped = query.search.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
statement = statement.where(TPlusSyncRecord.source_no.like(f"%{escaped}%", escape="\\"))
total = session.scalar(select(func.count()).select_from(statement.subquery()))
rows = session.scalars(
statement.order_by(TPlusSyncRecord.source_id).offset(query.offset).limit(query.page_size)
)
records = [
{
"source_id": r.source_id,
"source_no": r.source_no,
"name": str(r.payload_json.get("name") or ""),
"parent_id": str(r.payload_json.get("source_parent_id") or ""),
"date": str(
r.payload_json.get("voucherdate") or r.payload_json.get("voucherDate") or ""
)[:10],
}
for r in rows
]
return PageResult(
records=records, total=total or 0, page_num=query.page_num, page_size=query.page_size
)
def report_csv(session, run_no):
"""导出全部预演明细,防止源单号被电子表格当作公式执行。"""
import csv
import io
run = get_run(session, run_no)
def rows():
buffer = io.StringIO()
writer = csv.writer(buffer)
yield "\ufeff"
writer.writerow(
[
"运行编号",
"行号",
"类型",
"源ID或平账组",
"处理结果",
"源编号",
"名称",
"回款单号",
"关联凭证示例",
"发货单号",
"匹配金额",
"分配后该明细剩余应收",
"回款天数",
"指导成本分摊",
"折算利润",
"利润门槛(百分数)",
"计提比例",
"计提金额",
"归属工号",
"原因",
"说明",
]
)
yield buffer.getvalue()
buffer.seek(0)
buffer.truncate(0)
statement = (
select(TPlusWorkLine)
.where(TPlusWorkLine.run_no == run_no)
.order_by(TPlusWorkLine.line_no)
)
for page in session.scalars(statement).partitions(200):
for row, data in zip(page, _display_data(session, run, page), strict=True):
values = data.get("values") or {}
fields = [
run_no,
row.line_no,
row.entity_type,
row.source_id,
ACTION_LABELS.get(row.action, row.action),
data.get("source_no", ""),
values.get("personnel_name")
or values.get("product_name")
or values.get("company_name")
or data.get("source_name")
or "",
*[
data.get(k, "")
for k in (
"receipt_no",
"accounting_voucher_code",
"shipment_no",
"amount",
"shipment_remaining_after",
"days",
"allocated_cost",
"converted_profit",
"profit_threshold",
"rate_percent",
"performance",
"personnel_id",
)
],
reason_label(row.reason)
or ("已导入且数据无变化" if run.kind in ("MASTER_PREVIEW", "MASTER_APPLY") and row.action == "SKIP"
else "经理本人业务,已排除绩效汇总" if data.get("excluded_manager_own") else ""),
"仅供核对,未写入正式工资",
]
writer.writerow([spreadsheet_value(v) for v in fields])
yield buffer.getvalue()
buffer.seek(0)
buffer.truncate(0)
return rows()
def execute_next(session, *, may_claim=None):
"""调用方持有原有全局锁;中断任务保留明细,禁止不明状态自动应用。"""
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()
if may_claim is not None and not may_claim(session):
session.rollback()
return None
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 != _work_source_database(session):
raise WorkbenchError("SOURCE_CHANGED")
from app.models import User
from app.services.authorization_service import require_permission
require_permission(session, session.get(User, run.requested_by),
"tplus:import" if run.kind.endswith("_APPLY") or run.kind == "BUSINESS_SYNC" else "tplus:preview")
if run.kind == "BUSINESS_SYNC":
from app.services.tplus.business_sync import execute_sync
from app.services.tplus.business_sync_control import clear_execution_failure
execute_sync(session, run)
clear_execution_failure(session, run.source_database, run.requested_by)
elif run.kind == "BUSINESS_APPLY":
raise WorkbenchError("LEGACY_BUSINESS_APPLY_DISABLED")
elif 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 = "TASK_AUTHORIZATION_REVOKED" if isinstance(exc, ForbiddenError) else str(exc) if isinstance(exc, WorkbenchError) else "EXECUTION_ERROR"
if run.kind == "BUSINESS_SYNC" and isinstance(exc, ForbiddenError):
from app.services.tplus.business_sync_control import record_authorization_failure
record_authorization_failure(session, run.source_database, run.requested_by)
logger.error(
"工作区任务失败 | run_no=%s | exception_type=%s", run.run_no, type(exc).__name__
)
session.commit()
return run.run_no
class WorkbenchError(ValueError):
"""可公开的固定失败编码;不得传入客户原文或驱动异常。"""