按 2026-11-02 需求补齐计划交付后端能力: - 角色授权:权限目录化,接口统一准入校验,未登记默认拒绝; 管理员判断改为按数据库角色重新判定,防止权限码持有者绕过 - 系统监控:单进程采集资源、请求与异常指标,分钟级聚合, 阈值配置化;敏感配置增加 repr 保护,Redis 密码 URL 转义 - 试用期计提:按回款日判定并写入快照,年度结清与调整单联动; 人员模型补试用期、离职日期与工资版本历史字段 - 计提模板:公式骨架受控,页面维护名称与参数绑定 - T+ 采集:自动化配置表与进度查询 内置角色禁停用保护对齐模块注释;核算规则版本提为常量并 补充批量核算失败堆栈日志。 影响范围:后端全部模块、数据库迁移 V22-V28 验证:uv run ruff check 通过;922 个单元测试全部通过; 集成测试因本机 MySQL 数据卷被其他容器锁定暂未运行
459 lines
16 KiB
Python
459 lines
16 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, 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):
|
||
"""可公开的固定失败编码;不得传入客户原文或驱动异常。"""
|