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:
@ -918,9 +918,13 @@ async def get_wip_matrix(
|
||||
- task_name: 按当前工序聚合(dimension_key 为工序名,附主负责人)
|
||||
"""
|
||||
from datetime import timezone as dt_timezone
|
||||
from sqlalchemy.orm import aliased
|
||||
from app.models.task import Task
|
||||
from app.models.product import Product
|
||||
|
||||
# 外层已 join 了 tasks,内层判活跃任务必须换别名,否则语义无歧义
|
||||
_ActiveTask = aliased(Task, name="active_task")
|
||||
|
||||
# 每台设备按主任务创建时间倒序,取第一条即「最新主任务」
|
||||
result = await db.execute(
|
||||
select(
|
||||
@ -936,6 +940,15 @@ async def get_wip_matrix(
|
||||
Product.overall_status,
|
||||
Product.status,
|
||||
Product.lifecycle_phase,
|
||||
# 🔧 该产品名下是否还有 ANY 活跃任务(含协助分支)——
|
||||
# 注意:这比下面的 is_active 更宽。is_active 只看"最新一条主干任务",
|
||||
# 而这里扫描该产品的全部任务,用于 is_terminal 兜底判定。
|
||||
# ⚠️ 必须用别名:外层 FROM 里已有一个 tasks,同名会让 SQLAlchemy
|
||||
# 自动关联掉内层 FROM 而报 "no FROM clauses"。别名后语义才无歧义。
|
||||
select(_ActiveTask.id).where(
|
||||
_ActiveTask.product_id == Product.id,
|
||||
_ActiveTask.status.in_(["WIP", "PENDING"]),
|
||||
).exists().label("has_any_active"),
|
||||
)
|
||||
.outerjoin(Task, Task.product_id == Product.id)
|
||||
.where(
|
||||
@ -952,7 +965,7 @@ async def get_wip_matrix(
|
||||
# 每台设备 → (spec, 当前工序/负责人, 当前负责人ID)
|
||||
device_cur: dict[str, tuple] = {}
|
||||
seen: set[str] = set()
|
||||
for pid, spec, product_name, task_name, assignee, tstatus, created, completed, loc, overall_status, product_status, lifecycle in rows:
|
||||
for pid, spec, product_name, task_name, assignee, tstatus, created, completed, loc, overall_status, product_status, lifecycle, has_any_active in rows:
|
||||
if pid in seen:
|
||||
continue
|
||||
seen.add(pid)
|
||||
@ -975,6 +988,13 @@ async def get_wip_matrix(
|
||||
or overall_status in ("待仓库收货", "已入库", "在库", "已出库")
|
||||
or str(product_status).upper() in ("ARCHIVED", "OUTBOUND")
|
||||
or task_name in ("扫码入库", "扫码出库")
|
||||
# 🔧 兜底(2026-09-17):设备名下【再无任何活跃任务】= 真的没活了 → 视同终结态。
|
||||
# 历史缺陷:生产设备被【直接完结】后 overall_status 停在工序名(如「测试」),
|
||||
# 既不满足上面任何一条,又因 is_terminal=False 对时间筛选完全免疫 ——
|
||||
# 于是它永远挂在看板上冒充"在制"(报表游魂)。
|
||||
# 补上这一条后,它照常接受 since~until 过滤,不再污染在制口径。
|
||||
# 仍保留原有的终结态判定(本条件是「或」,只会让更多设备被正确归类为终结)。
|
||||
or not has_any_active
|
||||
)
|
||||
if is_terminal:
|
||||
if anchor is not None and anchor.tzinfo is None:
|
||||
@ -1094,11 +1114,15 @@ async def get_wip_matrix_detail(
|
||||
按其在库三态(已完成/已入库/已出库)或工序名归类到 process。
|
||||
"""
|
||||
from datetime import timezone as dt_timezone
|
||||
from sqlalchemy.orm import aliased
|
||||
from app.models.task import Task
|
||||
from app.models.product import Product
|
||||
from app.models.holiday import Holiday
|
||||
from app.core.time_utils import get_beijing_time, to_beijing, working_duration_hours
|
||||
|
||||
# 与外层 join 的 tasks 区分开(理由见 get_wip_matrix)
|
||||
_ActiveTask = aliased(Task, name="active_task")
|
||||
|
||||
hres = await db.execute(select(Holiday.day))
|
||||
holidays = {r[0] for r in hres}
|
||||
|
||||
@ -1119,6 +1143,12 @@ async def get_wip_matrix_detail(
|
||||
Task.created_at,
|
||||
Task.completed_at,
|
||||
Task.received_at,
|
||||
# 🔧 与 get_wip_matrix 同口径:该产品名下是否还有 ANY 活跃任务
|
||||
# (同样必须用别名,理由见 get_wip_matrix 处注释)
|
||||
select(_ActiveTask.id).where(
|
||||
_ActiveTask.product_id == Product.id,
|
||||
_ActiveTask.status.in_(["WIP", "PENDING"]),
|
||||
).exists().label("has_any_active"),
|
||||
)
|
||||
.outerjoin(Task, Task.product_id == Product.id)
|
||||
.where(
|
||||
@ -1137,7 +1167,7 @@ async def get_wip_matrix_detail(
|
||||
matched: list[WipMatrixDetailRow] = []
|
||||
raw_names: set[str] = set()
|
||||
|
||||
for pid, serial, ext, mat_name, spec, loc, overall, pstatus, lifecycle, task_name, assignee, tstatus, created, completed, received in rows:
|
||||
for pid, serial, ext, mat_name, spec, loc, overall, pstatus, lifecycle, task_name, assignee, tstatus, created, completed, received, has_any_active in rows:
|
||||
if pid in seen:
|
||||
continue
|
||||
seen.add(pid)
|
||||
@ -1154,11 +1184,15 @@ async def get_wip_matrix_detail(
|
||||
is_active = tstatus in ("WIP", "PENDING")
|
||||
|
||||
# ── 动静分离:仅终结(收口/在库/出库)设备按 since~until 过滤;在制设备无条件全量 ──
|
||||
# ⚠️ 必须与 get_wip_matrix 一字不差同步,否则会出现
|
||||
# 「矩阵格子里有数、点进来下钻却是空的」。
|
||||
is_terminal = (not is_active) and (
|
||||
loc == "virtual_warehouse"
|
||||
or overall in ("待仓库收货", "已入库", "在库", "已出库")
|
||||
or str(pstatus).upper() in ("ARCHIVED", "OUTBOUND")
|
||||
or task_name in ("扫码入库", "扫码出库")
|
||||
# 🔧 兜底:名下再无任何活跃任务 → 视同终结态,接受时间筛选(防报表游魂)
|
||||
or not has_any_active
|
||||
)
|
||||
if is_terminal:
|
||||
if anchor is not None and anchor.tzinfo is None:
|
||||
|
||||
Reference in New Issue
Block a user