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:
@ -11,6 +11,7 @@ from app.models.task import Task, TaskRecord, TASK_STATUS_PENDING, TASK_STATUS_W
|
||||
from app.models.notification import Notification, NOTIFY_TRANSFER, NOTIFY_REJECT
|
||||
from app.core.time_utils import get_beijing_time
|
||||
from app.core.lifecycle import (
|
||||
AFTER_SALES_REPAIR_STEPS,
|
||||
LIFECYCLE_AFTER_SALES,
|
||||
PRODUCTION_ONLY_STEPS,
|
||||
allowed_steps,
|
||||
@ -91,26 +92,61 @@ async def _recalc_product_location(
|
||||
result = await db.execute(stmt)
|
||||
main_task = result.scalar_one_or_none()
|
||||
|
||||
# 上面的排序把 WIP(3) > PENDING(2) 放最前,所以取到的这条即最高优先级:
|
||||
# 它是 WIP/PENDING 说明产品仍有活跃主线任务,否则视为闲置。
|
||||
has_active_main = main_task is not None and main_task.status in (
|
||||
TASK_STATUS_WIP, TASK_STATUS_PENDING,
|
||||
)
|
||||
|
||||
new_location = main_task.assignee_id if main_task else None
|
||||
|
||||
# 🚚 「货发走就离场」——已出库且闲置的设备,厂内不再有它的位置。
|
||||
# 直接完结后若继续停在最后经手人名下(或残留 virtual_warehouse),
|
||||
# 列表里会与「已出库」自相矛盾。置空后前端统一显示「—」。
|
||||
# ⚠️ 只清「已出库」:已入库 / 在库 的设备确实还在仓库里,位置必须保留。
|
||||
if product.overall_status == "已出库" and not has_active_main:
|
||||
new_location = None
|
||||
|
||||
if product.current_location_id != new_location:
|
||||
product.current_location_id = new_location
|
||||
await db.flush() # 唯一的落盘点
|
||||
|
||||
|
||||
# 绝对物理终态 — 描述「设备此刻物理上在哪」的宏观状态,由 MOM 仓储/发货回调驱动。
|
||||
# 接收 / 派发 / 转交任务时【禁止】用任务名覆写它们:否则「已出库」会被
|
||||
# 「发货测试」这类工序名静默抹掉,设备在按终态口径统计的报表里就不再是已出库
|
||||
# (WIP 矩阵因为优先看活跃任务而看不出问题,但全局概览 / 大屏会把它移出已完结)。
|
||||
#
|
||||
# ⚠️ 刻意不含「待仓库收货」:那是「车间完工待实收」的过渡态,设备随后仍会被
|
||||
# 重新派活,宏观状态理应随工序更新。
|
||||
PHYSICAL_TERMINAL_OVERALL = ("已入库", "在库", "已出库")
|
||||
|
||||
|
||||
def _is_physical_terminal(overall: str | None) -> bool:
|
||||
"""该宏观状态是否为「绝对物理终态」(不应被任务名覆写)"""
|
||||
return (overall or "").strip() in PHYSICAL_TERMINAL_OVERALL
|
||||
|
||||
|
||||
async def _mark_after_sales_if_reactivated(
|
||||
db: AsyncSession, product_id: uuid.UUID,
|
||||
steps: str | list[str | None] | None = None,
|
||||
) -> bool:
|
||||
"""出库后又被派发新任务 = 设备回流返厂 → 生命周期切到 AFTER_SALES。
|
||||
"""出库后又接到【明确修机指令】= 设备回流返厂 → 生命周期切到 AFTER_SALES。
|
||||
|
||||
判定依据:产品当前处于「已出库」终态(overall_status == '已出库' 或
|
||||
status == 'OUTBOUND'),却又产生了新的在制任务。
|
||||
判定依据(必须同时满足):
|
||||
1. 产品当前处于「已出库」终态(overall_status == '已出库' 或 status == 'OUTBOUND')
|
||||
2. 本次接到的工序是明确修机指令(售后维修)
|
||||
|
||||
⚠️ 出厂质检(发货测试)【不】触发。设备可能压根没回厂,只是补做发货前测试。
|
||||
历史缺陷:两者混为一谈,导致「已出库 + 发货测试」被打上不可回退的
|
||||
AFTER_SALES 烙印(售后死锁),且设备的物理终态「已出库」被工序名抹掉。
|
||||
|
||||
该标志单向:一旦进入 AFTER_SALES 不再回退,这样前端就能把
|
||||
生产阶段的「发货测试」与回流后的「售后维修」区分开。
|
||||
|
||||
调用时机必须早于调用方改写 overall_status,否则会漏判。
|
||||
|
||||
:param steps: 本次接到的工序名;转交场景可能多分支,故接受列表。
|
||||
返回是否发生了翻转。product 已在本 session 加载时 db.get 直接命中
|
||||
identity map,不产生额外查询。
|
||||
"""
|
||||
@ -118,6 +154,10 @@ async def _mark_after_sales_if_reactivated(
|
||||
if product is None or product.lifecycle_phase == LIFECYCLE_AFTER_SALES:
|
||||
return False
|
||||
|
||||
names = [steps] if isinstance(steps, str) else [s for s in (steps or []) if s]
|
||||
if not any(n.strip() in AFTER_SALES_REPAIR_STEPS for n in names):
|
||||
return False
|
||||
|
||||
is_outbound = (
|
||||
product.overall_status == "已出库"
|
||||
or (product.status or "").upper() == "OUTBOUND"
|
||||
@ -376,11 +416,18 @@ async def create_task(db: AsyncSession, data: TaskCreate) -> TaskResponse:
|
||||
product_result = await db.execute(select(Product).where(Product.id == data.product_id))
|
||||
product = product_result.scalar_one_or_none()
|
||||
if product:
|
||||
# 🔧 出库后再次派发任务 = 设备回流返厂 → 切到售后生命周期(须早于下方改写 overall_status)
|
||||
await _mark_after_sales_if_reactivated(db, data.product_id)
|
||||
# 🔧 出库后又接到【明确修机指令】= 设备回流返厂 → 切到售后生命周期
|
||||
# (须早于下方改写 overall_status)。出厂质检(发货测试)不触发。
|
||||
await _mark_after_sales_if_reactivated(db, data.product_id, data.task_name)
|
||||
# 🔧 选项隔离:售后回流设备禁止被排回「备货 / 生产」等前期工序(防伪造传参)
|
||||
_enforce_step_isolation(product, data.task_name, action="创建任务")
|
||||
if data.task_name and (not data.parent_task_id or data.task_type in ("TRANSFER", "RECOVERY")):
|
||||
# 🔒 只主线任务同步宏观状态;且【绝对物理终态保护】—— 设备已是
|
||||
# 已入库/在库/已出库 时禁止用任务名覆写,否则「已出库」会被工序名抹掉。
|
||||
if (
|
||||
data.task_name
|
||||
and (not data.parent_task_id or data.task_type in ("TRANSFER", "RECOVERY"))
|
||||
and not _is_physical_terminal(product.overall_status)
|
||||
):
|
||||
product.overall_status = "已入库" if "virtual_warehouse" in data.task_name else data.task_name
|
||||
# 🔧 双字段同步:overall_status 改动后必须对齐 status,
|
||||
# 否则出库回流设备的 status 会永远停在 OUTBOUND,污染统计口径
|
||||
@ -675,17 +722,27 @@ async def receive_task(
|
||||
product_result = await db.execute(select(Product).where(Product.id == task.product_id))
|
||||
product = product_result.scalar_one_or_none()
|
||||
if product:
|
||||
# 🔧 出库后任务被重新接收 = 设备回流返厂 → 切到售后生命周期(须早于下方改写 overall_status)
|
||||
await _mark_after_sales_if_reactivated(db, task.product_id)
|
||||
# 🔧 出库后任务被重新接收 = 设备回流返厂 → 切到售后生命周期
|
||||
# (须早于下方改写 overall_status)。出厂质检(发货测试)不触发。
|
||||
await _mark_after_sales_if_reactivated(
|
||||
db, task.product_id, task_name or task.task_name,
|
||||
)
|
||||
# 🔧 选项隔离:接收时选定的工序必须落在该产品当前生命周期阶段的合法集合内
|
||||
#(task_name 为 None 时表示本次未指定工序,跳过校验,不阻断 PC 端「直接接收」)
|
||||
_enforce_step_isolation(product, task_name, action="接收任务")
|
||||
if task.assignee_id:
|
||||
product.current_location_id = task.assignee_id
|
||||
if task_name and (
|
||||
not task.parent_task_id or task.task_type in ("TRANSFER", "RECOVERY")
|
||||
# 🔒 只主线任务同步宏观状态;且【绝对物理终态保护】——
|
||||
# 设备已是 已入库/在库/已出库 时禁止用任务名覆写。
|
||||
# 这是「发货测试 抹掉 已出库」的关键拦截点:若不拦,设备会在
|
||||
# 概览/大屏等按终态口径统计的报表里不再是已出库,而矩阵因优先看
|
||||
# 活跃任务看不出问题(跨报表口径就此打架)。
|
||||
if (
|
||||
task_name
|
||||
and (not task.parent_task_id or task.task_type in ("TRANSFER", "RECOVERY"))
|
||||
and not _is_physical_terminal(product.overall_status)
|
||||
):
|
||||
product.overall_status = task_name # 只主线任务同步宏观状态
|
||||
product.overall_status = task_name
|
||||
# 🔧 双字段同步:无条件对齐一次,顺带治愈历史残留
|
||||
# (如整体已是「发货测试」而 status 还停在 OUTBOUND)
|
||||
sync_product_status(product)
|
||||
@ -861,6 +918,18 @@ async def transfer_task(
|
||||
# 权限校验:本人 或 管理员/主管 可转交
|
||||
_check_permission(task.assignee_id, operator_id, operator_role)
|
||||
|
||||
# ── 🏁 直接完结:额外收紧为「仅管理员/主管」──
|
||||
# 这是【正向】拦截(非管理员一律拒绝),刻意不复用 _check_permission 的反向写法:
|
||||
# 后者在 operator_id 为空时会静默放行,而直接完结是不可逆的收官动作
|
||||
# (不产生下游任务、产品落终态),必须默认拒绝。
|
||||
# ⚠️ 判据只能是 operator_role —— 它来自签名保护的 JWT;
|
||||
# operator_id 是客户端可通过 ?operator_id= 自行填写的,不可作为权限依据。
|
||||
if request.finish_directly and not (operator_role and operator_role in ADMIN_ROLES):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="仅超管和主管有权限进行直接完结操作",
|
||||
)
|
||||
|
||||
# 校验:不能重复完成
|
||||
if task.status == TASK_STATUS_COMPLETED:
|
||||
raise HTTPException(
|
||||
@ -917,13 +986,6 @@ async def transfer_task(
|
||||
)
|
||||
product = product_result.scalar_one_or_none()
|
||||
|
||||
# 🔧 出库后又被转交出新任务 = 设备回流返厂 → 切到售后生命周期。
|
||||
# 必须在阶段校验之前执行,否则"已出库设备被转交到生产工序"会被误放行。
|
||||
# ⚠️ 直接完结【不产生新任务】,不构成"回流返厂"信号,必须跳过:
|
||||
# 否则一台已出库设备的正常收官,会把产品误翻成售后机(该标志单向不可回退)。
|
||||
if product and not finish_directly:
|
||||
await _mark_after_sales_if_reactivated(db, task.product_id)
|
||||
|
||||
# 兼容新旧格式
|
||||
# 🔧 直接完结:不解析任何下家,分支强制为空,直接命中下方"无下家"兜底分支。
|
||||
if finish_directly:
|
||||
@ -943,6 +1005,17 @@ async def transfer_task(
|
||||
has_warehouse = any(a == VIRTUAL_WAREHOUSE for _, a in branches)
|
||||
real_branches = [(tn, a) for tn, a in branches if a != VIRTUAL_WAREHOUSE]
|
||||
|
||||
# 🔧 出库后又接到【明确修机指令】= 设备回流返厂 → 切到售后生命周期。
|
||||
# 必须在下方阶段校验之前执行,否则"已出库设备被转交到生产工序"会被误放行。
|
||||
# (故本调用安排在分支解析之后、_reject_cross_phase_steps 之前。)
|
||||
# ⚠️ 直接完结【不产生新任务】,不构成"回流返厂"信号,必须跳过:
|
||||
# 否则一台已出库设备的正常收官,会把产品误翻成售后机(该标志单向不可回退)。
|
||||
# ⚠️ 出厂质检(发货测试)同样不触发 —— 详见 _mark_after_sales_if_reactivated。
|
||||
if product and not finish_directly:
|
||||
await _mark_after_sales_if_reactivated(
|
||||
db, task.product_id, [tn for tn, _ in real_branches],
|
||||
)
|
||||
|
||||
# 🔧 选项隔离:售后回流设备不允许被转交到「备货 / 生产 / 测试 / 维修」。
|
||||
# 转交允许自由填写工序名(喷漆/老化…),故只精准拦跨阶段词,不用白名单。
|
||||
if product:
|
||||
@ -1014,23 +1087,44 @@ async def transfer_task(
|
||||
task_id=nt.id,
|
||||
))
|
||||
|
||||
# --- 更新 Product 的 current_location_id ---
|
||||
# --- 更新 Product 的 current_location_id / overall_status ---
|
||||
# 🏁 直接完结【不改动 overall_status】:它只是一个「任务闭环动作」,
|
||||
# 不改变产品的物理状态。已出库的设备完结后依然是已出库,
|
||||
# 从而回归 WIP 矩阵的【已出库】列(无活跃任务 → 落终结态判定)。
|
||||
# 下方 has_warehouse / real_branches 两个分支对直接完结均为假,
|
||||
# overall_status 自然原样保留。
|
||||
if product:
|
||||
if has_warehouse and not real_branches:
|
||||
# 转入虚拟仓库池:位置一定改(下一手才能在这里看到并接手),
|
||||
# 但宏观状态要分情况 ——
|
||||
# · 绝对物理终态(已入库 / 在库 / 已出库):禁止改写成「待仓库收货」。
|
||||
# 已出库设备售后返修时选择【入库】是为了「放回池子让下一个人继续转」,
|
||||
# 改成「待仓库收货」会抹掉它的物理终态,且与 MOM 侧「已发货」的记录冲突
|
||||
# (MOM 认为货已出门,Track 却说「等仓库收货」,双方对不上账)。
|
||||
# 已入库 / 在库设备本就在仓库,再入库更不该回退成「待收货」。
|
||||
# · 普通生产设备(备货/生产/测试/维修…):维持原行为,正常置为「待仓库收货」。
|
||||
product.current_location_id = VIRTUAL_WAREHOUSE
|
||||
product.overall_status = "待仓库收货"
|
||||
if not _is_physical_terminal(product.overall_status):
|
||||
product.overall_status = "待仓库收货"
|
||||
elif real_branches:
|
||||
product.current_location_id = real_branches[0][1]
|
||||
if not task.parent_task_id or task.task_type in ("TRANSFER", "RECOVERY"):
|
||||
# 🔒 只主线任务同步宏观状态;且【绝对物理终态保护】——
|
||||
# 设备已是 已入库/在库/已出库 时禁止用任务名覆写,
|
||||
# 否则「已出库」会被「发货测试」这类工序名抹掉。
|
||||
if (
|
||||
(not task.parent_task_id or task.task_type in ("TRANSFER", "RECOVERY"))
|
||||
and not _is_physical_terminal(product.overall_status)
|
||||
):
|
||||
product.overall_status = real_branches[0][0]
|
||||
# 🔧 双字段同步:两个分支统一在这里对齐一次(原先只有入库分支硬编码
|
||||
# 🔧 双字段同步:各分支统一在这里对齐一次(原先只有入库分支硬编码
|
||||
# product.status="COMPLETED",普通转交分支完全没同步 → 残留 OUTBOUND)
|
||||
# 注意顺序:必须在上面的 overall_status 赋值【之后】,否则 status 同步到旧值。
|
||||
sync_product_status(product)
|
||||
|
||||
# 🔧 位置回溯:如果有新任务创建,优先新任务负责人;否则回溯到父任务
|
||||
# 「直接完结」(finish_directly) 与旧版空分支都落到这里:
|
||||
# overall_status 在上面两个分支里都没被赋值,故原样保留
|
||||
# (已出库 / 在库 / 售后维修… 不会被改写成「待仓库收货」)。
|
||||
# current_location_id 回溯到最后一手经手人(业务确认:位置停在经手人合理)。
|
||||
# 注意它只改 location,不碰 overall_status,故上面的终态不会被覆盖。
|
||||
if not real_branches and not has_warehouse:
|
||||
await _recalc_product_location(db, task.product_id, task_id)
|
||||
|
||||
@ -1131,8 +1225,11 @@ async def complete_task(
|
||||
# --- 4. 可选:创建下一步任务(转交) ---
|
||||
next_task = None
|
||||
if request.next_task_name and request.next_assignee_id:
|
||||
# 🔧 出库后又被转交出新任务 = 设备回流返厂 → 切到售后生命周期
|
||||
await _mark_after_sales_if_reactivated(db, task.product_id)
|
||||
# 🔧 出库后又接到【明确修机指令】= 设备回流返厂 → 切到售后生命周期
|
||||
# (出厂质检「发货测试」不触发)
|
||||
await _mark_after_sales_if_reactivated(
|
||||
db, task.product_id, request.next_task_name,
|
||||
)
|
||||
# 🚀 智能父节点继承算法
|
||||
# 主线任务转交 → 保持平级继承(主分支永远在一维主干上)
|
||||
# 协助分支转交 → 认当前任务为父(形成向外无限延伸的孙子节点树枝)
|
||||
|
||||
Reference in New Issue
Block a user