feat: 售后回流判定与工序选项隔离守卫(建单/接收/转交)
This commit is contained in:
@ -9,6 +9,14 @@ from sqlalchemy.orm import selectinload
|
|||||||
from app.models.task import Task, TaskRecord, TASK_STATUS_PENDING, TASK_STATUS_WIP, TASK_STATUS_COMPLETED, TASK_STATUS_REJECTED, TASK_STATUS_CANCELED, TASK_STATUS_ARCHIVED
|
from app.models.task import Task, TaskRecord, TASK_STATUS_PENDING, TASK_STATUS_WIP, TASK_STATUS_COMPLETED, TASK_STATUS_REJECTED, TASK_STATUS_CANCELED, TASK_STATUS_ARCHIVED
|
||||||
from app.models.notification import Notification, NOTIFY_TRANSFER, NOTIFY_REJECT
|
from app.models.notification import Notification, NOTIFY_TRANSFER, NOTIFY_REJECT
|
||||||
from app.core.time_utils import get_beijing_time
|
from app.core.time_utils import get_beijing_time
|
||||||
|
from app.core.lifecycle import (
|
||||||
|
LIFECYCLE_AFTER_SALES,
|
||||||
|
PRODUCTION_ONLY_STEPS,
|
||||||
|
allowed_steps,
|
||||||
|
is_step_allowed,
|
||||||
|
phase_label,
|
||||||
|
resolve_phase_for_step,
|
||||||
|
)
|
||||||
from app.models.product import Product
|
from app.models.product import Product
|
||||||
from app.models.task_log import TaskLog
|
from app.models.task_log import TaskLog
|
||||||
from app.schemas.task import (
|
from app.schemas.task import (
|
||||||
@ -88,6 +96,95 @@ async def _recalc_product_location(
|
|||||||
await db.flush() # 唯一的落盘点
|
await db.flush() # 唯一的落盘点
|
||||||
|
|
||||||
|
|
||||||
|
async def _mark_after_sales_if_reactivated(
|
||||||
|
db: AsyncSession, product_id: uuid.UUID,
|
||||||
|
) -> bool:
|
||||||
|
"""出库后又被派发新任务 = 设备回流返厂 → 生命周期切到 AFTER_SALES。
|
||||||
|
|
||||||
|
判定依据:产品当前处于「已出库」终态(overall_status == '已出库' 或
|
||||||
|
status == 'OUTBOUND'),却又产生了新的在制任务。
|
||||||
|
|
||||||
|
该标志单向:一旦进入 AFTER_SALES 不再回退,这样前端就能把
|
||||||
|
生产阶段的「发货测试」与回流后的「售后维修」区分开。
|
||||||
|
|
||||||
|
调用时机必须早于调用方改写 overall_status,否则会漏判。
|
||||||
|
|
||||||
|
返回是否发生了翻转。product 已在本 session 加载时 db.get 直接命中
|
||||||
|
identity map,不产生额外查询。
|
||||||
|
"""
|
||||||
|
product = await db.get(Product, product_id)
|
||||||
|
if product is None or product.lifecycle_phase == LIFECYCLE_AFTER_SALES:
|
||||||
|
return False
|
||||||
|
|
||||||
|
is_outbound = (
|
||||||
|
product.overall_status == "已出库"
|
||||||
|
or (product.status or "").upper() == "OUTBOUND"
|
||||||
|
)
|
||||||
|
if not is_outbound:
|
||||||
|
return False
|
||||||
|
|
||||||
|
product.lifecycle_phase = LIFECYCLE_AFTER_SALES
|
||||||
|
await db.flush()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _enforce_step_isolation(
|
||||||
|
product: Product, step: str | None, *, action: str,
|
||||||
|
) -> None:
|
||||||
|
"""选项隔离守卫 — 售后回流设备禁止被重新排产回「备货 / 生产」等前期工序。
|
||||||
|
|
||||||
|
行为两步:
|
||||||
|
1. 选定售后专属工序(发货测试 / 售后维修)→ 设备随即进入售后生命周期。
|
||||||
|
这是「无历史记录的老设备」进入售后阶段的入口。
|
||||||
|
2. 再校验该工序在当前阶段是否合法,非法直接 400 —— 前端下拉被绕过、
|
||||||
|
请求被伪造时,仍在此拦下。
|
||||||
|
|
||||||
|
必须在 _mark_after_sales_if_reactivated 之后调用,这样出库回流的设备
|
||||||
|
已处于 AFTER_SALES,自然排不回前期工序。
|
||||||
|
|
||||||
|
注意:「待确认」与仓库虚拟节点属建单占位符,由 is_step_allowed 直接放行,
|
||||||
|
否则建单 / 转交入库整条链路会被卡死。
|
||||||
|
"""
|
||||||
|
phase = resolve_phase_for_step(product.lifecycle_phase, step)
|
||||||
|
if not is_step_allowed(phase, step):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=(
|
||||||
|
f"产品当前处于{phase_label(phase)},{action}不允许使用工序「{step}」。"
|
||||||
|
f"该阶段可选:{'、'.join(allowed_steps(phase))}"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if phase != product.lifecycle_phase:
|
||||||
|
product.lifecycle_phase = phase
|
||||||
|
|
||||||
|
|
||||||
|
def _reject_cross_phase_steps(
|
||||||
|
phase: str | None, steps: list[str | None], *, action: str,
|
||||||
|
) -> None:
|
||||||
|
"""跨阶段工序拦截 — 用于「转交」这类自由文本工序名路径。
|
||||||
|
|
||||||
|
转交对话框允许用户手打工序名(喷漆 / 老化 / 待确认…),所以不能用
|
||||||
|
白名单,否则会误伤合法命名;但"售后回流设备被转交到【生产】"必须挡住。
|
||||||
|
因此这里只拒绝**生产阶段专属**词(备货 / 生产 / 测试 / 维修)。
|
||||||
|
|
||||||
|
「在库 / 已入库 / 已出库」两阶段通用,占位符同样放行。
|
||||||
|
"""
|
||||||
|
if (phase or "") != LIFECYCLE_AFTER_SALES:
|
||||||
|
return
|
||||||
|
bad = sorted({
|
||||||
|
s.strip() for s in steps
|
||||||
|
if s and s.strip() in PRODUCTION_ONLY_STEPS
|
||||||
|
})
|
||||||
|
if bad:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=(
|
||||||
|
f"产品当前处于{phase_label(LIFECYCLE_AFTER_SALES)},{action}不允许使用前期工序:"
|
||||||
|
f"{'、'.join(bad)}。该阶段可选:{'、'.join(allowed_steps(LIFECYCLE_AFTER_SALES))}"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _check_permission(task_assignee_id: str | None, operator_id: str | None, operator_role: str | None = None) -> None:
|
def _check_permission(task_assignee_id: str | None, operator_id: str | None, operator_role: str | None = None) -> None:
|
||||||
"""权限校验:本人 或 管理员/主管 可操作"""
|
"""权限校验:本人 或 管理员/主管 可操作"""
|
||||||
if operator_role and operator_role in ADMIN_ROLES:
|
if operator_role and operator_role in ADMIN_ROLES:
|
||||||
@ -277,6 +374,10 @@ async def create_task(db: AsyncSession, data: TaskCreate) -> TaskResponse:
|
|||||||
product_result = await db.execute(select(Product).where(Product.id == data.product_id))
|
product_result = await db.execute(select(Product).where(Product.id == data.product_id))
|
||||||
product = product_result.scalar_one_or_none()
|
product = product_result.scalar_one_or_none()
|
||||||
if product:
|
if product:
|
||||||
|
# 🔧 出库后再次派发任务 = 设备回流返厂 → 切到售后生命周期(须早于下方改写 overall_status)
|
||||||
|
await _mark_after_sales_if_reactivated(db, data.product_id)
|
||||||
|
# 🔧 选项隔离:售后回流设备禁止被排回「备货 / 生产」等前期工序(防伪造传参)
|
||||||
|
_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")):
|
||||||
product.overall_status = "已入库" if "virtual_warehouse" in data.task_name else data.task_name
|
product.overall_status = "已入库" if "virtual_warehouse" in data.task_name else data.task_name
|
||||||
# 派发给人 → 产品离开仓库
|
# 派发给人 → 产品离开仓库
|
||||||
@ -569,6 +670,11 @@ async def receive_task(
|
|||||||
product_result = await db.execute(select(Product).where(Product.id == task.product_id))
|
product_result = await db.execute(select(Product).where(Product.id == task.product_id))
|
||||||
product = product_result.scalar_one_or_none()
|
product = product_result.scalar_one_or_none()
|
||||||
if product:
|
if product:
|
||||||
|
# 🔧 出库后任务被重新接收 = 设备回流返厂 → 切到售后生命周期(须早于下方改写 overall_status)
|
||||||
|
await _mark_after_sales_if_reactivated(db, task.product_id)
|
||||||
|
# 🔧 选项隔离:接收时选定的工序必须落在该产品当前生命周期阶段的合法集合内
|
||||||
|
#(task_name 为 None 时表示本次未指定工序,跳过校验,不阻断 PC 端「直接接收」)
|
||||||
|
_enforce_step_isolation(product, task_name, action="接收任务")
|
||||||
if task.assignee_id:
|
if task.assignee_id:
|
||||||
product.current_location_id = task.assignee_id
|
product.current_location_id = task.assignee_id
|
||||||
if task_name and (
|
if task_name and (
|
||||||
@ -770,6 +876,18 @@ async def transfer_task(
|
|||||||
images="[]"))
|
images="[]"))
|
||||||
|
|
||||||
# --- 动作 2:解析下家 & 裂变 ---
|
# --- 动作 2:解析下家 & 裂变 ---
|
||||||
|
# 🔧 提前查产品:下面的阶段隔离校验依赖它的生命周期阶段。
|
||||||
|
# (通知也需要 product_sn,原本在创建任务之后才查,提前不增加查询次数)
|
||||||
|
product_result = await db.execute(
|
||||||
|
select(Product).where(Product.id == task.product_id)
|
||||||
|
)
|
||||||
|
product = product_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
# 🔧 出库后又被转交出新任务 = 设备回流返厂 → 切到售后生命周期。
|
||||||
|
# 必须在阶段校验之前执行,否则"已出库设备被转交到生产工序"会被误放行。
|
||||||
|
if product:
|
||||||
|
await _mark_after_sales_if_reactivated(db, task.product_id)
|
||||||
|
|
||||||
# 兼容新旧格式
|
# 兼容新旧格式
|
||||||
if request.next_tasks:
|
if request.next_tasks:
|
||||||
branches = [
|
branches = [
|
||||||
@ -786,6 +904,13 @@ async def transfer_task(
|
|||||||
has_warehouse = any(a == VIRTUAL_WAREHOUSE for _, a in branches)
|
has_warehouse = any(a == VIRTUAL_WAREHOUSE for _, a in branches)
|
||||||
real_branches = [(tn, a) for tn, a in branches if a != VIRTUAL_WAREHOUSE]
|
real_branches = [(tn, a) for tn, a in branches if a != VIRTUAL_WAREHOUSE]
|
||||||
|
|
||||||
|
# 🔧 选项隔离:售后回流设备不允许被转交到「备货 / 生产 / 测试 / 维修」。
|
||||||
|
# 转交允许自由填写工序名(喷漆/老化…),故只精准拦跨阶段词,不用白名单。
|
||||||
|
if product:
|
||||||
|
_reject_cross_phase_steps(
|
||||||
|
product.lifecycle_phase, [tn for tn, _ in real_branches], action="转交",
|
||||||
|
)
|
||||||
|
|
||||||
is_fission = len(real_branches) > 1 or (request.next_tasks and len(request.next_tasks) > 1)
|
is_fission = len(real_branches) > 1 or (request.next_tasks and len(request.next_tasks) > 1)
|
||||||
is_child_task = task.parent_task_id is not None
|
is_child_task = task.parent_task_id is not None
|
||||||
|
|
||||||
@ -820,11 +945,6 @@ async def transfer_task(
|
|||||||
# 批量 flush 以生成 ID
|
# 批量 flush 以生成 ID
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
|
||||||
# 提前查询产品(通知需要 product_sn)
|
|
||||||
product_result = await db.execute(
|
|
||||||
select(Product).where(Product.id == task.product_id)
|
|
||||||
)
|
|
||||||
product = product_result.scalar_one_or_none()
|
|
||||||
product_sn = product.serial_number if product else ""
|
product_sn = product.serial_number if product else ""
|
||||||
|
|
||||||
for nt in created_tasks:
|
for nt in created_tasks:
|
||||||
@ -941,6 +1061,8 @@ async def complete_task(
|
|||||||
# --- 4. 可选:创建下一步任务(转交) ---
|
# --- 4. 可选:创建下一步任务(转交) ---
|
||||||
next_task = None
|
next_task = None
|
||||||
if request.next_task_name and request.next_assignee_id:
|
if request.next_task_name and request.next_assignee_id:
|
||||||
|
# 🔧 出库后又被转交出新任务 = 设备回流返厂 → 切到售后生命周期
|
||||||
|
await _mark_after_sales_if_reactivated(db, task.product_id)
|
||||||
# 🚀 智能父节点继承算法
|
# 🚀 智能父节点继承算法
|
||||||
# 主线任务转交 → 保持平级继承(主分支永远在一维主干上)
|
# 主线任务转交 → 保持平级继承(主分支永远在一维主干上)
|
||||||
# 协助分支转交 → 认当前任务为父(形成向外无限延伸的孙子节点树枝)
|
# 协助分支转交 → 认当前任务为父(形成向外无限延伸的孙子节点树枝)
|
||||||
|
|||||||
Reference in New Issue
Block a user