PayrollManagementSystem/backend/app/services/tplus/workbench.py
liuwenxin 1461a8d113 feat(后端): 添加授权体系、系统监控与试用期计提
按 2026-11-02 需求补齐计划交付后端能力:
- 角色授权:权限目录化,接口统一准入校验,未登记默认拒绝;
  管理员判断改为按数据库角色重新判定,防止权限码持有者绕过
- 系统监控:单进程采集资源、请求与异常指标,分钟级聚合,
  阈值配置化;敏感配置增加 repr 保护,Redis 密码 URL 转义
- 试用期计提:按回款日判定并写入快照,年度结清与调整单联动;
  人员模型补试用期、离职日期与工资版本历史字段
- 计提模板:公式骨架受控,页面维护名称与参数绑定
- T+ 采集:自动化配置表与进度查询

内置角色禁停用保护对齐模块注释;核算规则版本提为常量并
补充批量核算失败堆栈日志。

影响范围:后端全部模块、数据库迁移 V22-V28
验证:uv run ruff check 通过;922 个单元测试全部通过;
集成测试因本机 MySQL 数据卷被其他容器锁定暂未运行
2026-11-03 11:20:55 +08:00

459 lines
16 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.

"""工作区任务与事实存取:与原有 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, ForbiddenError, NotFoundError
from app.core.response import PageResult
from app.core.tplus_lock import TPlusBusyError, sync_lock
from app.models import TPlusFact, 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-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 _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):
"""与导入共用锁,乐观版本不匹配时拒绝覆盖。"""
source = _work_source_database(session)
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 = 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)
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 == "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 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 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 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
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 row in session.scalars(statement).yield_per(200):
data = row.data_json
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 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):
"""调用方持有原有全局锁;中断任务保留明细,禁止不明状态自动应用。"""
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 != _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") else "tplus:preview")
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 = "TASK_AUTHORIZATION_REVOKED" if isinstance(exc, ForbiddenError) else 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):
"""可公开的固定失败编码;不得传入客户原文或驱动异常。"""