Files
track/backend/app/api/v1/endpoints/tasks.py
duxingchen 5d5aea1015 refactor(outbound): 出库单据与领用物料合并成一张表
这两者本来就是同一件事(这台设备对应 MOM 的哪些出库单、领了哪些料),
却因为粒度不同被拆成两张表、界面上两张卡:用户要面对两个入口两个删除按钮,
还会问「我在那边挂的怎么这边看不见」。更糟的是**单据级那张没有 mom_line_id,
挂上去的料根本报不了废**。

- 新建 product_outbound_materials,统一到**明细级**(只有它带 mom_line_id,
  而报废要用它定位)。单据级信息(申请单号/备注/撤回)作为冗余列落在每条明细上。
  task_id 改为可空 —— 任务只是溯源信息,不再是组织维度,展示/报废/删除按设备走。
- 接口从 7 个收敛成 3 个(GET/POST/DELETE /products/{id}/outbound-materials,
  外加整单删 by-order)。任务级那套连同 TaskResponse.outbound_materials 一起删掉:
  保留第二个入口只会让「同一个东西两个地方」重新长出来。
- MOM 回调存档改为按 outbound_no 去 MOM **现查明细**逐行落 —— 不查的话
  这台设备「领了什么料」永远是空的,也就报不了废。查不到时退化成单据级存档,
  宁可显示「有这张单但看不到明细」,也不要静默丢掉这张单。
- 扫码响应补 outbound_materials(附「谁挂上去的」中文名,服务端解析)。
  ⚠️ 依赖 task_tree_loader 的 selectinload —— 异步 session 下懒加载会
  MissingGreenlet。
- 前端两张卡合并成一张:按出库单号分组、点开看明细,明细行才有报废/删除。
2026-09-23 15:18:06 +08:00

337 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""任务 API 端点 — 核心业务:接收、驳回返工、裂变转交、无限嵌套子任务"""
from __future__ import annotations
import uuid
from fastapi import APIRouter, Depends, Query, Body
from pydantic import BaseModel, Field
from app.services.auth_service import get_current_user
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.schemas.task import (
TaskCreate,
TaskUpdate,
TaskCompleteRequest,
TaskRejectRequest,
TaskTransferRequest,
TaskTransferBranch,
SubtaskCreate,
TaskRecordCreate,
TaskResponse,
TaskCompleteResponse,
TaskTransferResponse,
TaskSummaryResponse,
TaskListResponse,
)
from app.services import task_service
router = APIRouter(prefix="/tasks", tags=["任务管理"])
# ============================================================
# 任务 CRUD
# ============================================================
@router.get("/", response_model=TaskListResponse)
async def list_tasks(
product_id: str | None = Query(None, description="按产品ID筛选"),
assignee_id: str | None = Query(None, description="按负责人ID筛选逻辑外键→老系统"),
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""获取任务列表,可按产品/负责人筛选(只返回顶层任务)"""
pid = uuid.UUID(product_id) if product_id else None
return await task_service.get_all_tasks(db, product_id=pid, assignee_id=assignee_id, skip=skip, limit=limit)
@router.get("/{task_id}", response_model=TaskResponse)
async def get_task(
task_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""
获取任务详情 — 递归包含所有层级的子任务。
前端可根据此结果渲染完整的任务树。
"""
return await task_service.get_task(db, uuid.UUID(task_id))
@router.post("/", response_model=TaskResponse, status_code=201)
async def create_task_endpoint(
data: TaskCreate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""创建任务
`mom_line_ids` 非空时,会在**同一事务**里把对应的 MOM 出库明细挂到新任务上,
所以不存在「任务建好了但物料没挂上」的中间态。
"""
return await task_service.create_task(
db, data, operator_id=current_user.get("username"),
)
# 注:原先这里有「任务挂载 MOM 出库物料」的三个端点
# GET/POST /tasks/{id}/outbound-materials、DELETE .../{material_id})。
# 物料已统一为**设备级**,这三个端点连同 task_service 里的实现一起删除 ——
# 挂载/查看/删除/报废一律走:
# GET/POST /products/{id}/outbound-materials
# DELETE /products/{id}/outbound-materials/{material_id}
# 保留任务级入口只会让「同一个东西两个地方」重新长出来。
@router.patch("/{task_id}", response_model=TaskResponse)
async def update_task_endpoint(
task_id: str,
data: TaskUpdate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""更新任务"""
return await task_service.update_task(db, uuid.UUID(task_id), data)
# ============================================================
# 核心卡点逻辑:任务完成 / 转交
# ============================================================
@router.post("/{task_id}/complete", response_model=TaskCompleteResponse)
async def complete_task_endpoint(
task_id: str,
request: TaskCompleteRequest,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""
**核心接口:完成任务 + 可选创建下一步任务(转交)**
卡点逻辑:
1. 检查当前任务是否已完成(幂等保护)
2. 查询所有 `notify_parent_on_complete=True` 的子任务
→ 如果存在未完成的,返回 HTTP 400「请等待相关子任务完成」
3. 全部通过后,标记任务为 completed写入操作日志
4. 若提供了 `next_task_name` + `next_assignee_id`,自动创建下一步任务
典型场景:
- 某个加工步骤完成,需要检查所有必须的前置工序(子任务)是否已完成
- 完成后自动创建下一步任务并指定负责人
"""
return await task_service.complete_task(
db, uuid.UUID(task_id), request,
operator_role=current_user.get("role"),
)
# ============================================================
# 核心业务 0结束分支终止当前节点不创建下游
# ============================================================
@router.post("/{task_id}/end", response_model=TaskResponse)
async def end_task_endpoint(
task_id: str,
operator_id: str | None = Query(None),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""
**结束当前分支:标记任务为 COMPLETED不创建下游任务。**
用于工人认为工序已完结、无需转交下一人的场景。
"""
return await task_service.end_task(
db, uuid.UUID(task_id),
operator_id or current_user.get("username", "") or None,
operator_role=current_user.get("role"),
)
# ============================================================
# 核心业务 0.3:撤回转交 (PENDING → 删除 + 恢复父任务)
# ============================================================
@router.post("/{task_id}/recall", response_model=TaskResponse)
async def recall_task_endpoint(
task_id: str,
operator_id: str | None = Query(None),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""
**撤回转交:删除 PENDING 子任务,恢复父任务为 WIP。**
适用场景:转交后发现选错人,在对方接收前撤回。
"""
return await task_service.recall_task(
db, uuid.UUID(task_id),
operator_id or current_user.get("username", "") or None,
operator_role=current_user.get("role"),
)
# ============================================================
# 核心业务 0.5:并发派发协助分支 (WIP → 不改变状态,创建子任务)
# ============================================================
class SpawnRequest(BaseModel):
task_name: str = Field(..., max_length=200, description="工序名称")
assignee_id: str | None = Field(None, max_length=64, description="负责人ID")
remark: str | None = Field(None, max_length=2000, description="派发备注")
@router.post("/{task_id}/spawn", response_model=TaskResponse, status_code=201)
async def spawn_subtask_endpoint(
task_id: str,
data: SpawnRequest,
operator_id: str | None = Query(None),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""
**派发协助分支:在当前任务下创建并行子任务,父任务状态保持不变。**
用于 WIP 期间工人需要其他人协助协同的场景。
"""
return await task_service.spawn_subtask(
db, uuid.UUID(task_id), data,
operator_id or current_user.get("username", "") or None)
# ============================================================
# 核心业务 1确认接收 (PENDING → WIP)
# ============================================================
@router.post("/{task_id}/receive", response_model=TaskResponse)
async def receive_task_endpoint(
task_id: str,
operator_id: str | None = Query(None, description="操作人ID"),
remark: str | None = Body(None, description="接收备注", embed=True),
task_name: str | None = Body(None, description="接收人选定的工序名称", embed=True),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""
**确认接收任务。工人选定工序名称后接收。**
校验:只有状态为 PENDING 的任务可接收。
动作:状态改为 WIP记录 received_at更新 task_name同步产品宏观状态。
"""
return await task_service.receive_task(
db, uuid.UUID(task_id),
operator_id or current_user.get("username", "") or None,
remark, task_name,
operator_role=current_user.get("role"),
)
# ============================================================
# 核心业务 2品质驳回 (→ REJECTED + 返工闭环)
# ============================================================
@router.post("/{task_id}/reject", response_model=TaskResponse)
async def reject_task_endpoint(
task_id: str,
request: TaskRejectRequest,
operator_id: str | None = Query(None, description="操作人ID"),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""
**品质驳回:将任务标记为 REJECTED自动创建返工任务。**
防呆闭环逻辑:
1. 将当前任务状态改为 REJECTED记录 reject_reason 和 completed_at。
2. 查找上一道工序的负责人(父任务的 assignee_id
3. 为该负责人新建返工任务is_rework=True, status=PENDING
"""
return await task_service.reject_task(
db, uuid.UUID(task_id), request,
operator_id or current_user.get("username", "") or None,
operator_role=current_user.get("role"),
)
# ============================================================
# 核心业务 3完工并裂变转交 (→ COMPLETED + 多路裂变)
# ============================================================
@router.post("/{task_id}/transfer", response_model=TaskTransferResponse)
async def transfer_task_endpoint(
task_id: str,
request: TaskTransferRequest,
operator_id: str | None = Query(None, description="操作人ID"),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""
**完工并裂变转交:完成当前任务,批量创建下一道工序任务。**
动作 1闭环当前节点
- 将当前任务状态改为 COMPLETED记录 completed_at。
动作 2解析下家
- 遍历 next_assignees 列表。
- 如果包含 'virtual_warehouse',则将 Product 的 current_location_id 设为仓库。
- 为每一个 assignee_id 新建 PENDING 任务。
裂变逻辑:
- next_assignees > 1 → 多路裂变,新任务挂在当前任务下形成树状分支。
- 当前任务是子任务 → 单路转交也保持在同一父任务下。
- 否则 → 顶层同级转交。
"""
return await task_service.transfer_task(
db, uuid.UUID(task_id), request,
operator_id or current_user.get("username", "") or None,
operator_role=current_user.get("role"),
)
# ============================================================
# 无限层级子任务
# ============================================================
@router.post("/{task_id}/subtasks", response_model=TaskResponse, status_code=201)
async def create_subtask_endpoint(
task_id: str,
data: SubtaskCreate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""
**创建子任务:支持无限层级嵌套。**
新子任务将自动继承父任务的 product_id。
若父任务已完成,拒绝创建。
"""
return await task_service.create_subtask(
db, uuid.UUID(task_id), data
)
# ============================================================
# 查询产品顶层任务(便捷接口)
# ============================================================
@router.get("/by-product/{product_id}", response_model=list[TaskSummaryResponse])
async def get_tasks_by_product(
product_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""获取指定产品的顶层任务列表(不含子任务嵌套)"""
return await task_service.get_top_level_tasks(db, uuid.UUID(product_id))
# ============================================================
# 任务进度记录 — 备注/传图
# ============================================================
@router.patch("/{task_id}/records", response_model=TaskResponse)
async def add_task_record_endpoint(
task_id: str,
data: TaskRecordCreate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""追加进度记录(备注+图片),不改变任务状态"""
return await task_service.add_task_record(db, uuid.UUID(task_id), data, current_user)