"""任务 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="初始描述/交接备注") # 创建时一并挂载的 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=( "直接完结:闭环当前任务但【不产生任何下游任务】。" "它只是一个任务闭环动作,【不改动】产品的 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 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追溯) # 本任务挂载的 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