将月份状态、资料输出、调整单编排及工作区查询、 CSV 生成移至现有服务层,路由仅绑定参数并返回响应。 保持接口路径、参数、响应模型和权限依赖一致, 补充九项内存库行为回归并同步测试记录。
443 lines
15 KiB
Python
443 lines
15 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, 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, 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 fact_history(session, fact_id, query):
|
|
"""按事实身份分页查看修改历史,不允许跨账套读取。"""
|
|
fact = session.get(TPlusFact, fact_id)
|
|
if fact is None or fact.source_database != _source_database():
|
|
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, _source_database(), 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 != _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):
|
|
"""可公开的固定失败编码;不得传入客户原文或驱动异常。"""
|