Files
track/backend/app/schemas/task.py

281 lines
12 KiB
Python
Raw Normal View History

"""任务 Pydantic Schemas — 支持无限嵌套子任务、裂变转交、驳回返工"""
from __future__ import annotations
import uuid
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, Field, field_validator, model_validator
# ============================================================
# 请求模型
# ============================================================
class TaskCreate(BaseModel):
"""创建任务"""
product_id: uuid.UUID = Field(..., description="所属产品ID")
parent_task_id: uuid.UUID | None = Field(None, description="父任务ID(用于嵌套子任务/裂变分支)")
task_name: str = Field(..., max_length=200, description="任务名称")
assignee_id: str | None = Field(None, max_length=64, description="负责人ID(逻辑外键→老系统)")
notify_parent_on_complete: bool = Field(False, description="完成后是否通知父任务")
is_rework: bool = Field(False, description="是否为返工任务")
remark: str | None = Field(None, max_length=2000, description="初始描述/交接备注")
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
# 创建时一并挂载的 MOM 出库明细行 ID(MOM trans_outbound.id)。
# 粒度是**明细行**,但前端是按整张出库单勾选的 —— 提交时把该单的全部明细
# ID 一起带过来。
# ⚠️ 默认空列表:移动端的 doCreateFirstTask 仍在调本接口且不带该字段,
# 必须保持「不传就等同于不挂载」的行为不变。
mom_line_ids: list[int] = Field(
default_factory=list,
description="创建时挂载的 MOM 出库明细行ID(trans_outbound.id)",
)
model_config = {"from_attributes": True}
class TaskUpdate(BaseModel):
"""更新任务"""
task_name: str | None = Field(None, max_length=200)
assignee_id: str | None = Field(None, max_length=64)
status: str | None = Field(None, max_length=50, description="任务状态")
notify_parent_on_complete: bool | None = Field(None)
model_config = {"from_attributes": True}
class TaskCompleteRequest(BaseModel):
"""完成任务请求 — 携带转交信息(保留兼容旧版单步转交)"""
operator_id: str | None = Field(None, max_length=64, description="操作人ID")
next_assignee_id: str | None = Field(None, max_length=64, description="下一步任务负责人ID")
next_task_name: str | None = Field(None, max_length=200, description="下一步任务名称")
remark: str | None = Field(None, description="完成备注")
class SubtaskCreate(BaseModel):
"""创建子任务"""
task_name: str = Field(..., max_length=200, description="子任务名称")
assignee_id: str | None = Field(None, max_length=64, description="负责人ID")
notify_parent_on_complete: bool = Field(False, description="完成后是否通知父任务")
class TaskRejectRequest(BaseModel):
"""品质驳回请求 — 驳回原因必填;异常图片【选填】
(编号错误、工序选错等场景无需拍照举证,故不强制传图)"""
reason: str = Field(..., min_length=1, max_length=500, description="驳回原因(必填)")
images: list[str] = Field(
default_factory=list, max_length=9,
description="异常图片 URL 列表(选填,可传空数组,最多 9 张)",
)
@field_validator("reason", mode="before")
@classmethod
def _strip_reason(cls, v):
"""先 strip 再交给 min_length 校验:否则纯空格(' ')能凑够长度绕过必填。
顺带保证落库的 Task.reject_reason / TaskRecord.remark 不带首尾空白。
非字符串原样返回,让 Pydantic 抛出正常的类型错误。"""
return v.strip() if isinstance(v, str) else v
@field_validator("images")
@classmethod
def _validate_images(cls, v: list[str]) -> list[str]:
"""剥离空串(前端可能提交 [''] 之类的占位),并做总长度上限校验,
避免落库时才撞上 TaskRecord.images 的 String(4000) 上限。图片选填,允许为空。"""
urls = [u.strip() for u in v if u and u.strip()]
if sum(len(u) for u in urls) > 3500:
raise ValueError("异常图片 URL 总长度超限,请减少图片数量")
return urls
class TaskTransferBranch(BaseModel):
"""裂变分支"""
task_name: str = Field(..., max_length=200, description="工序名称")
assignees: list[str] = Field(
default_factory=list, min_length=0,
description=(
"接收人列表。允许为空数组,但仅当 finish_directly=True 时合法——"
"空分支不产生任何下游任务(见 TaskTransferRequest 的校验)。"
),
)
class TaskTransferRequest(BaseModel):
"""完工裂变转交请求 — 支持多分支 next_tasks 和旧版单线兼容"""
next_assignees: list[str] | None = Field(None, description="[旧版] 下一道工序接收人列表")
next_task_name: str | None = Field(None, max_length=200, description="[旧版] 下一道工序名称")
next_tasks: list[TaskTransferBranch] | None = Field(None, description="[新版] 多分支任务列表")
note: str | None = Field(None, description="交接备注")
finish_directly: bool = Field(
False,
description=(
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 增加"名下再无任何活跃 任务则视同终结态",让被直接完结的生产设备接受时间筛选,不再恒挂在看板上 冒充在制。两处必须一字不差同步,否则会出现"矩阵有数、下钻为空"
2026-09-17 17:05:42 +08:00
"直接完结:闭环当前任务但【不产生任何下游任务】。"
"它只是一个任务闭环动作,【不改动】产品的 overall_status —— "
"已出库的设备完结后依然是已出库(不入库,也不会变成「待仓库收货」)。"
"权限:仅 SUPER_ADMIN / SUPERVISOR 可调用,其余角色 403。"
"置 True 时忽略 next_tasks / next_assignees。"
),
)
@model_validator(mode="after")
def _reject_silent_empty_branch(self):
"""空 assignees 分支会让「转交」静默退化成「直接完结」——任务闭环了却没人接手,
是个丢件级隐患。想直接完结必须显式传 finish_directly=true,不能靠漏填凑合。"""
if self.finish_directly:
return self
if any(not b.assignees for b in (self.next_tasks or [])):
raise ValueError(
"分支 assignees 不能为空;若意图是「直接完结该任务」,"
"请改为传 finish_directly=true"
)
return self
class TaskRecordCreate(BaseModel):
"""任务进度记录 — 备注 + 图片"""
remark: str = Field("", max_length=2000, description="备注文本")
images: list[str] = Field(default_factory=list, description="图片 URL 列表")
class TaskRecordResponse(BaseModel):
id: int
task_id: uuid.UUID
remark: str | None = None
images: list[str] = []
created_at: datetime | None = None
model_config = {"from_attributes": True}
@field_validator("images", mode="before")
@classmethod
def _parse_images(cls, v):
"""处理 DB 中 images 的 JSON 字符串 → list 反序列化(不污染 ORM 对象)"""
import json
if isinstance(v, str):
try:
return json.loads(v)
except (json.JSONDecodeError, TypeError):
return []
if v is None:
return []
return v
# ============================================================
# 响应模型
# ============================================================
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
class TaskOutboundMaterialResponse(BaseModel):
"""任务挂载的一条 MOM 出库物料明细(挂载时从 MOM 取的快照)
一次挂载会展开成多行(挂一张出库单 = 该单的全部明细各一行),
前端按 outbound_no 分组展示。
"""
id: int
# ★ 料挂在哪条任务上。前端按任务分组展示时必须拿它做 key ——
# 不能用 task_name:同一台设备可能有两个同名任务(例如两道「生产」),
# 按名字分会把它们并成一组,看起来像一条任务领了两遍料。
task_id: uuid.UUID
mom_line_id: int # MOM trans_outbound.id,供反查比对
outbound_no: str # MOM 出库单号
sku: str | None = None
material_name: str | None = None
spec_model: str | None = None
# 用 float 而非 Decimal:Pydantic v2 会把 Decimal 序列化成字符串,
# 前端拿到 "5.0000" 不好直接用。数量量级很小(实测 1~186),float 足够。
quantity: float | None = None # 出库单原值,**不是**本任务用量
unit_price: float | None = None
outbound_type: str | None = None
# 出库类型中文名。与 MOM 出库单查询(mom_outbounds)同一套码表、同一份实现,
# 由服务端下发 —— 前端不再自建映射,否则两边会开始漂移。
# 这里用 model_validator 自动派生而不是每个构造点手填:构造点有 3 处
# (task_service 两处 + product_service 一处),漏一个就是空白徽标。
outbound_type_label: str = ""
consumer_name: str | None = None # 领用人/客户
operator_name: str | None = None
warehouse_location: str | None = None
outbound_time: datetime | None = None
added_by: str | None = None # 挂载人(逻辑外键→MOM sys_user)
created_at: datetime
model_config = {"from_attributes": True}
@model_validator(mode="after")
def _fill_outbound_type_label(self):
"""出库类型码 → 中文名(PRODUCTION→生产出库 等)。
在 schema 上统一派生,而不是让 3 个构造点各自记得填 ——
漏一个就是空白徽标,而且不会报错,只能靠肉眼发现。
延迟 import:schemas 被 services 依赖,模块级 import 会形成环。
"""
if not self.outbound_type_label and self.outbound_type:
from app.services.mom_outbound_service import describe_outbound_type
self.outbound_type_label = describe_outbound_type(self.outbound_type)
return self
class TaskSummaryResponse(BaseModel):
"""任务摘要 — 扫码时用,不含嵌套子任务"""
id: uuid.UUID
product_id: uuid.UUID
parent_task_id: uuid.UUID | None
task_name: str
assignee_id: str | None
status: str
notify_parent_on_complete: bool
is_rework: bool = False
task_type: str | None = None
remark: str | None = None
reject_reason: str | None = None
received_at: datetime | None = None
completed_at: datetime | None = None
created_at: datetime
created_by: str | None = None # 谁创建的(从task_logs追溯)
model_config = {"from_attributes": True}
class TaskResponse(BaseModel):
"""任务详情响应 — 递归包含所有子任务"""
id: uuid.UUID
product_id: uuid.UUID
product_sn: str = ""
product_material: str = ""
parent_task_id: uuid.UUID | None
task_name: str
assignee_id: str | None
status: str
notify_parent_on_complete: bool
is_rework: bool = False
task_type: str | None = None
remark: str | None = None
reject_reason: str | None = None
received_at: datetime | None = None
completed_at: datetime | None = None
created_at: datetime
child_tasks: list[TaskResponse] = []
records: list[TaskRecordResponse] = []
created_by: str | None = None # 谁创建的(从task_logs追溯)
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
# 本任务挂载的 MOM 出库物料(明细级快照)。创建任务时可选、之后可追加,
# 见 models/task_outbound_material.py。
outbound_materials: list[TaskOutboundMaterialResponse] = []
model_config = {"from_attributes": True}
class TaskCompleteResponse(BaseModel):
"""任务完成响应"""
completed_task: TaskResponse
next_task: TaskResponse | None = None
message: str
class TaskTransferResponse(BaseModel):
"""裂变转交响应"""
completed_task: TaskResponse
created_tasks: list[TaskResponse] = []
message: str
class TaskListResponse(BaseModel):
"""任务列表响应"""
tasks: list[TaskResponse]
total: int