feat: 直接完结通道与报表口径收口(后端)

【直接完结:保留动作,剥离状态】
- transfer_task 权限硬拦截:仅 SUPER_ADMIN / SUPERVISOR。判据取签名保护的
  operator_role,刻意不用客户端可通过 ?operator_id= 伪造的 operator_id
- 直接完结【不再改写】overall_status —— 它只是任务闭环动作,不改变物理状态。
  已出库设备完结后依然是「已出库」,回归 WIP 矩阵的已出库列
- 物理终态保护:create_task / receive_task / transfer_task 三处写入点统一加
  _is_physical_terminal 守卫,禁止用任务名覆写 已入库/在库/已出库。
  历史缺陷:「发货测试」会把设备的「已出库」标识静默抹掉,跨报表口径随之打架

【位置与工序口径】
- _recalc_product_location:已出库且闲置 → 位置清空(货发走就离场)。
  只清「已出库」;已入库/在库的设备确实还在仓库里,位置必须保留
- MOM 出库回调同步清空 current_location_id
- 新增 ProductResponse.current_step:只认活跃主干任务,无活跃任务时返回
  宏观终态或空。不再让已 COMPLETED 的历史工序(扫码出库/测试…)冒充"当前工序"

【售后烙印去死锁】
- 拆分 OUTBOUND_QC_STEPS(发货测试) / AFTER_SALES_REPAIR_STEPS(售后维修)
- resolve_phase_for_step 与 _mark_after_sales_if_reactivated 双双改为仅
  「售后维修」触发 AFTER_SALES,解除「已出库 + 发货测试」被永久烙印的死锁
- PRODUCTION_OVERALL_STEPS 补入「发货测试」,使出厂质检在生产阶段合法
  (否则 _enforce_step_isolation 会把已出库设备的发货测试直接 400)

【游魂收口】
- get_wip_matrix / get_wip_matrix_detail 的 is_terminal 增加"名下再无任何活跃
  任务则视同终结态",让被直接完结的生产设备接受时间筛选,不再恒挂在看板上
  冒充在制。两处必须一字不差同步,否则会出现"矩阵有数、下钻为空"
This commit is contained in:
2026-09-17 17:05:42 +08:00
parent e707f3673d
commit d666bbd4ed
7 changed files with 234 additions and 38 deletions

View File

@ -673,6 +673,8 @@ async def get_all_products(
# 🔧 动态主干状态名:只从主干任务中获取最高优先级任务的 task_name宏观状态名
overall_names: dict[uuid.UUID, str] = {}
# 🔧 【当前工序】专用:仅「活跃主干任务」的工序名(见下方 main_stmt 循环填充)
active_step_map: dict[uuid.UUID, str] = {}
if product_ids:
from sqlalchemy import and_, func as sa_func, case as sa_case
main_where = and_(
@ -694,7 +696,7 @@ async def get_all_products(
.group_by(Task.product_id)
).subquery("mp")
main_stmt = (
select(Task.product_id, Task.task_name)
select(Task.product_id, Task.task_name, Task.status)
.join(max_prio, and_(
Task.product_id == max_prio.c.product_id,
prio_expr == max_prio.c.prio,
@ -706,6 +708,11 @@ async def get_all_products(
main_result = await db.execute(main_stmt)
for row in main_result:
overall_names[row[0]] = row[1]
# 🔧 供【当前工序】专用:只收【活跃】主干任务的工序名。
# overall_names 保持原语义不动(它连 COMPLETED 的任务也收,
# 是"最新主干任务名",被 overall_status 复用,改动影响面太大)。
if row[2] in ("WIP", "PENDING"):
active_step_map[row[0]] = row[1]
# 🔧 当前位置汇总所有活跃任务WIP/PENDING不分主线/分支)的负责人,去重保序
active_assignees_map: dict[uuid.UUID, list[str]] = {}
@ -825,6 +832,28 @@ async def get_all_products(
return "COMPLETED"
return macro_map.get(p.id) or "PENDING"
# 宏观终态 —— 表达的是"货在哪",不是"在做什么"。
# 这类值即使当前没有活跃任务也必须照常展示(业务要求保留物理标识)。
_TERMINAL_OVERALL = ("待仓库收货", "已入库", "在库", "已出库")
def _resolve_current_step(p: Product) -> str:
"""【当前工序】—— 只反映"此刻在做什么",绝不拿已完结的历史工序冒充。
· 有活跃主干任务(WIP/PENDING) → 该任务工序名
· 否则产品处于宏观终态 → 该终态("货在哪",保留)
· 否则(活已干完、只剩工序名残留)→ ""(前端显示「—」)
历史缺陷ProductResponse.overall_status 会被"最新主干任务名"覆盖
(见上方 overall_names于是已 COMPLETED 的「扫码出库 / 测试 / 发货测试」
会长期冒充"当前工序",误导管理者以为活还在干。
"""
raw = (p.overall_status or "").strip()
if not raw:
return ""
if raw in _TERMINAL_OVERALL:
return raw
return active_step_map.get(p.id, "")
return [
ProductResponse(
id=p.id,
@ -849,6 +878,7 @@ async def get_all_products(
),
macro_status=_resolve_macro_status(p),
overall_status=overall_names.get(p.id) or p.overall_status,
current_step=_resolve_current_step(p),
status=p.status,
lifecycle_phase=p.lifecycle_phase,
created_at=p.created_at,