feat(T+): 完成数据贯通与自动接入闭环
打通 T+ 快照、业务、发货、回款和核算准备链路,保留来源差异并提供待补状态。 新增业务自动接入任务、跨平台读取服务托管、权限复核、来源关联和前端核对页面。 补充迁移、隔离集成测试、浏览器模拟验收及角色授权边界修复。
这个提交包含在:
1 父节点
3efe72c344
当前提交
1e53792ad6
151 个文件已更改
+8842
-401
没有与搜索条件相匹配的文件
@@ -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__
|
||||
)
|
||||
|
||||
在新工单中引用
屏蔽一个用户