2026-11-01 20:42:53 -05:00
|
|
|
|
"""工作区任务与事实存取:与原有 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
|
|
|
|
|
|
|
2026-11-05 05:47:13 -05:00
|
|
|
|
from app.core.exceptions import BadRequestError, ConflictError, ForbiddenError, NotFoundError
|
2026-11-01 20:42:53 -05:00
|
|
|
|
from app.core.response import PageResult
|
|
|
|
|
|
from app.core.tplus_lock import TPlusBusyError, sync_lock
|
2026-11-05 05:47:13 -05:00
|
|
|
|
from app.models import TPlusFact, TPlusSyncBatch, TPlusSyncRecord, TPlusWorkLine, TPlusWorkRun
|
2026-11-01 20:42:53 -05:00
|
|
|
|
from app.schemas.tplus_workbench import WorkCreateIn
|
2026-11-01 20:42:55 -05:00
|
|
|
|
from app.services.tplus.report_labels import ACTION_LABELS, reason_label, spreadsheet_value
|
2026-11-02 22:20:55 -05:00
|
|
|
|
from app.services.tplus.task_service import _source_database, history_source_database, new_batch_no
|
2026-11-01 20:42:53 -05:00
|
|
|
|
|
2026-11-05 05:47:13 -05:00
|
|
|
|
RULE_VERSION = "2026-11-03.2"
|
2026-11-01 20:42:53 -05:00
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def digest(data):
|
|
|
|
|
|
"""稳定指纹用于固定规则、事实和来源版本。"""
|
|
|
|
|
|
return hashlib.sha256(
|
|
|
|
|
|
json.dumps(
|
|
|
|
|
|
data, ensure_ascii=False, sort_keys=True, default=str, separators=(",", ":")
|
|
|
|
|
|
).encode()
|
|
|
|
|
|
).hexdigest()
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-11-02 22:20:55 -05:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-11-01 20:42:53 -05:00
|
|
|
|
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):
|
|
|
|
|
|
"""与导入共用锁,乐观版本不匹配时拒绝覆盖。"""
|
2026-11-05 05:47:13 -05:00
|
|
|
|
if payload.kind == "CORRECTION":
|
|
|
|
|
|
raise BadRequestError("更正单请使用成对处理入口,不能单独排除或修改一张源单")
|
2026-11-02 22:20:55 -05:00
|
|
|
|
source = _work_source_database(session)
|
2026-11-01 20:42:53 -05:00
|
|
|
|
try:
|
|
|
|
|
|
with sync_lock(session):
|
|
|
|
|
|
session.rollback()
|
2026-11-05 06:32:20 -05:00
|
|
|
|
facts = latest_facts(session, source)
|
|
|
|
|
|
current = facts.get((payload.kind, payload.fact_key))
|
2026-11-01 20:42:53 -05:00
|
|
|
|
if payload.kind == "GUIDANCE" and current and current.data_json.get("is_finalized"):
|
|
|
|
|
|
raise BadRequestError("该发货月份指导成本已封存,后续回款不得修改原指导成本")
|
2026-11-05 06:32:20 -05:00
|
|
|
|
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("该产品发货月份已有封存指导成本,请先核对")
|
2026-11-01 20:42:53 -05:00
|
|
|
|
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):
|
2026-11-02 22:20:55 -05:00
|
|
|
|
source = history_source_database(session)
|
2026-11-01 20:42:53 -05:00
|
|
|
|
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 只排队;同幂等键的不同意图必须被拒绝。"""
|
2026-11-02 22:20:55 -05:00
|
|
|
|
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")
|
2026-11-01 20:42:53 -05:00
|
|
|
|
values = payload.model_dump(mode="json", exclude={"request_key"})
|
2026-11-02 22:20:55 -05:00
|
|
|
|
source = _work_source_database(session)
|
2026-11-01 20:42:53 -05:00
|
|
|
|
key = str(payload.request_key)
|
2026-11-05 05:47:13 -05:00
|
|
|
|
def existing_run(found):
|
2026-11-01 20:42:53 -05:00
|
|
|
|
if (
|
|
|
|
|
|
found.input_json != values
|
|
|
|
|
|
or found.source_database != source
|
|
|
|
|
|
or found.requested_by != actor_id
|
|
|
|
|
|
):
|
|
|
|
|
|
raise BadRequestError("请求键已用于不同任务")
|
|
|
|
|
|
return work_out(found)
|
2026-11-05 05:47:13 -05:00
|
|
|
|
found = session.scalar(select(TPlusWorkRun).where(TPlusWorkRun.request_key == key))
|
|
|
|
|
|
if found:
|
|
|
|
|
|
return existing_run(found)
|
2026-11-01 20:42:53 -05:00
|
|
|
|
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("必须选择已完成的基础导入预览")
|
2026-11-02 22:20:55 -05:00
|
|
|
|
if payload.kind == "BUSINESS_APPLY":
|
2026-11-05 05:47:13 -05:00
|
|
|
|
raise BadRequestError("旧规则业务预览仅供核对,请使用自动业务接入重新生成计划")
|
2026-11-01 20:42:53 -05:00
|
|
|
|
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()
|
2026-11-05 05:47:13 -05:00
|
|
|
|
found = session.scalar(select(TPlusWorkRun).where(TPlusWorkRun.request_key == key))
|
|
|
|
|
|
if found is None:
|
|
|
|
|
|
raise ConflictError() from None
|
|
|
|
|
|
return existing_run(found)
|
2026-11-01 20:42:53 -05:00
|
|
|
|
return work_out(run)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_run(session, number, source=None):
|
2026-11-02 22:20:55 -05:00
|
|
|
|
source = source or history_source_database(session)
|
2026-11-01 20:42:53 -05:00
|
|
|
|
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):
|
2026-11-02 22:20:55 -05:00
|
|
|
|
conditions = [TPlusWorkRun.source_database == history_source_database(session)]
|
2026-11-01 20:42:53 -05:00
|
|
|
|
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,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-11-05 05:47:13 -05:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-11-01 20:42:53 -05:00
|
|
|
|
def list_lines(session, number, query):
|
2026-11-05 05:47:13 -05:00
|
|
|
|
run = get_run(session, number)
|
2026-11-01 20:42:53 -05:00
|
|
|
|
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))
|
2026-11-05 05:47:13 -05:00
|
|
|
|
rows = list(session.scalars(
|
2026-11-01 20:42:53 -05:00
|
|
|
|
select(TPlusWorkLine)
|
|
|
|
|
|
.where(*conditions)
|
|
|
|
|
|
.order_by(TPlusWorkLine.line_no)
|
|
|
|
|
|
.offset(query.offset)
|
|
|
|
|
|
.limit(query.page_size)
|
2026-11-05 05:47:13 -05:00
|
|
|
|
))
|
2026-11-01 20:42:53 -05:00
|
|
|
|
return PageResult(
|
|
|
|
|
|
records=[
|
|
|
|
|
|
{
|
|
|
|
|
|
"line_no": r.line_no,
|
|
|
|
|
|
"entity_type": r.entity_type,
|
|
|
|
|
|
"source_id": r.source_id,
|
|
|
|
|
|
"action": r.action,
|
|
|
|
|
|
"reason": r.reason,
|
2026-11-05 05:47:13 -05:00
|
|
|
|
"data": data,
|
2026-11-01 20:42:53 -05:00
|
|
|
|
}
|
2026-11-05 05:47:13 -05:00
|
|
|
|
for r, data in zip(rows, _display_data(session, run, rows), strict=True)
|
2026-11-01 20:42:53 -05:00
|
|
|
|
],
|
|
|
|
|
|
total=total or 0,
|
|
|
|
|
|
page_num=query.page_num,
|
|
|
|
|
|
page_size=query.page_size,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-11-01 20:42:55 -05:00
|
|
|
|
def fact_history(session, fact_id, query):
|
|
|
|
|
|
"""按事实身份分页查看修改历史,不允许跨账套读取。"""
|
|
|
|
|
|
fact = session.get(TPlusFact, fact_id)
|
2026-11-02 22:20:55 -05:00
|
|
|
|
if fact is None or fact.source_database != history_source_database(session):
|
2026-11-01 20:42:55 -05:00
|
|
|
|
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:
|
2026-11-02 22:20:55 -05:00
|
|
|
|
statement = source_statement(session, history_source_database(session), query.entity)
|
2026-11-01 20:42:55 -05:00
|
|
|
|
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
|
|
|
|
|
|
|
2026-11-05 05:47:13 -05:00
|
|
|
|
run = get_run(session, run_no)
|
2026-11-01 20:42:55 -05:00
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
)
|
2026-11-05 05:47:13 -05:00
|
|
|
|
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)
|
2026-11-01 20:42:55 -05:00
|
|
|
|
|
|
|
|
|
|
return rows()
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-11-05 06:32:20 -05:00
|
|
|
|
def execute_next(session, *, may_claim=None):
|
2026-11-01 20:42:53 -05:00
|
|
|
|
"""调用方持有原有全局锁;中断任务保留明细,禁止不明状态自动应用。"""
|
|
|
|
|
|
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()
|
2026-11-05 06:32:20 -05:00
|
|
|
|
if may_claim is not None and not may_claim(session):
|
|
|
|
|
|
session.rollback()
|
|
|
|
|
|
return None
|
2026-11-01 20:42:53 -05:00
|
|
|
|
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:
|
2026-11-02 22:20:55 -05:00
|
|
|
|
if run.source_database != _work_source_database(session):
|
2026-11-01 20:42:53 -05:00
|
|
|
|
raise WorkbenchError("SOURCE_CHANGED")
|
2026-11-02 22:20:55 -05:00
|
|
|
|
from app.models import User
|
|
|
|
|
|
from app.services.authorization_service import require_permission
|
|
|
|
|
|
|
|
|
|
|
|
require_permission(session, session.get(User, run.requested_by),
|
2026-11-05 05:47:13 -05:00
|
|
|
|
"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_"):
|
2026-11-01 20:42:53 -05:00
|
|
|
|
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 或原始数据进入响应。
|
2026-11-02 22:20:55 -05:00
|
|
|
|
run.error_code = "TASK_AUTHORIZATION_REVOKED" if isinstance(exc, ForbiddenError) else str(exc) if isinstance(exc, WorkbenchError) else "EXECUTION_ERROR"
|
2026-11-05 05:47:13 -05:00
|
|
|
|
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)
|
2026-11-01 20:42:53 -05:00
|
|
|
|
logger.error(
|
|
|
|
|
|
"工作区任务失败 | run_no=%s | exception_type=%s", run.run_no, type(exc).__name__
|
|
|
|
|
|
)
|
|
|
|
|
|
session.commit()
|
|
|
|
|
|
return run.run_no
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class WorkbenchError(ValueError):
|
|
|
|
|
|
"""可公开的固定失败编码;不得传入客户原文或驱动异常。"""
|