Files
track-LICA/backend/app/schemas/task.py
duxingchen 3286a11bc7 chore: fork from IRIS track 供 LICA 部门独立运行
- 复制来源: /home/yueli/track @ 192c8ee (feature/ai-audit-update)
- 组织隔离目标: LICA
- 端口规划: 前端 8030 / 后端 8031 / 数据库 8032
- 已排除 deploy.sh、deploy_full.sh、docker-compose.prod.yml(IRIS 生产发布脚本)
- 已排除工作区未提交改动,取干净的 192c8ee 状态
2026-09-21 15:56:52 +08:00

220 lines
8.6 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.

"""任务 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