feat(T+): 完成数据贯通与自动接入闭环

打通 T+ 快照、业务、发货、回款和核算准备链路,保留来源差异并提供待补状态。
新增业务自动接入任务、跨平台读取服务托管、权限复核、来源关联和前端核对页面。
补充迁移、隔离集成测试、浏览器模拟验收及角色授权边界修复。
这个提交包含在:
liuwenxin 于 2026-11-05 18:47:13 +08:00 提交
1 父节点 3efe72c344
当前提交 1e53792ad6
151 个文件已更改
+8842 -401

没有与搜索条件相匹配的文件

+119 -57
查看文件
@@ -9,15 +9,15 @@ 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.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, TPlusSyncRecord, TPlusWorkLine, TPlusWorkRun
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-10-30.2"
RULE_VERSION = "2026-11-03.2"
logger = logging.getLogger(__name__)
@@ -58,6 +58,8 @@ def latest_facts(session, source):
def save_fact(session, payload, actor_id):
"""与导入共用锁,乐观版本不匹配时拒绝覆盖。"""
if payload.kind == "CORRECTION":
raise BadRequestError("更正单请使用成对处理入口,不能单独排除或修改一张源单")
source = _work_source_database(session)
try:
with sync_lock(session):
@@ -134,8 +136,7 @@ def queue_work(session, payload: WorkCreateIn, actor_id):
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:
def existing_run(found):
if (
found.input_json != values
or found.source_database != source
@@ -143,14 +144,15 @@ def queue_work(session, payload: WorkCreateIn, 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":
preview = get_run(session, payload.preview_no, source)
if preview.kind != "BUSINESS_PREVIEW" or preview.status not in ("SUCCEEDED", "PARTIAL"):
raise BadRequestError("必须选择已完成的正式业务导入预览")
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"):
@@ -171,7 +173,10 @@ def queue_work(session, payload: WorkCreateIn, actor_id):
session.commit()
except IntegrityError:
session.rollback()
return queue_work(session, payload, actor_id)
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)
@@ -225,19 +230,63 @@ def list_work(session, query):
)
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):
get_run(session, number)
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 = session.scalars(
rows = list(session.scalars(
select(TPlusWorkLine)
.where(*conditions)
.order_by(TPlusWorkLine.line_no)
.offset(query.offset)
.limit(query.page_size)
)
))
return PageResult(
records=[
{
@@ -246,9 +295,9 @@ def list_lines(session, number, query):
"source_id": r.source_id,
"action": r.action,
"reason": r.reason,
"data": r.data_json,
"data": data,
}
for r in rows
for r, data in zip(rows, _display_data(session, run, rows), strict=True)
],
total=total or 0,
page_num=query.page_num,
@@ -319,7 +368,7 @@ def report_csv(session, run_no):
import csv
import io
get_run(session, run_no)
run = get_run(session, run_no)
def rows():
buffer = io.StringIO()
@@ -358,46 +407,47 @@ def report_csv(session, run_no):
.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)
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()
@@ -426,8 +476,16 @@ def execute_next(session):
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_"):
"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)
@@ -447,6 +505,10 @@ def execute_next(session):
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__
)