220 lines
8.6 KiB
Python
220 lines
8.6 KiB
Python
|
|
"""任务 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="初始描述/交接备注")
|
|||
|
|
|
|||
|
|
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=(
|
|||
|
|
"直接完结:闭环当前任务但【不产生任何下游任务】。"
|
|||
|
|
"它只是一个任务闭环动作,【不改动】产品的 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
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ============================================================
|
|||
|
|
# 响应模型
|
|||
|
|
# ============================================================
|
|||
|
|
|
|||
|
|
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追溯)
|
|||
|
|
|
|||
|
|
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
|