"""产品 Pydantic Schemas""" from __future__ import annotations import uuid from datetime import datetime from pydantic import BaseModel, Field, model_validator class ProductCreate(BaseModel): """创建产品 — serial_number 由后端自动生成 16 位 HEX""" # MOM 物料挂载(必填) material_id: str = Field(..., max_length=64, description="MOM物料ID(必选)") material_name: str = Field("", max_length=255, description="物料名称") spec_model: str = Field("", max_length=255, description="规格型号") category: str = Field("", max_length=100, description="物料类别") material_type: str = Field("", max_length=100, description="物料类型") # 可选 external_serial: str | None = Field(None, max_length=64, description="产品序列号(用户自定义,选填)") order_id: uuid.UUID | None = Field(None, description="所属订单ID(选填)") order_no: str | None = Field(None, max_length=64, description="订单号(自由键入,选填)") parent_product_id: uuid.UUID | None = Field(None, description="父产品ID") # 建档时一并挂钩的 MOM 出库**明细行** ID(trans_outbound.id)。 # 前端按整张出库单勾选,提交时把该单全部明细 ID 带过来;后端归并回单据后 # 写进 product_outbounds(一行 = 一张单,source=manual)。 # ⚠️ 默认空列表:其它调用方(旧前端、脚本)不带该字段,必须保持行为不变。 mom_line_ids: list[int] = Field( default_factory=list, description="建档时挂钩的 MOM 出库明细行ID(trans_outbound.id)", ) model_config = {"from_attributes": True} class ProductUpdate(BaseModel): """更新产品""" serial_number: str | None = Field(None, min_length=16, max_length=16) external_serial: str | None = Field(None, max_length=64) material_id: str | None = Field(None, max_length=64) material_name: str | None = Field(None, max_length=255) spec_model: str | None = Field(None, max_length=255) order_no: str | None = Field(None, max_length=64, description="订单号") status: str | None = Field(None, max_length=50, description="产品状态") parent_product_id: uuid.UUID | None = Field(None) model_config = {"from_attributes": True} class ProductResponse(BaseModel): """产品响应""" id: uuid.UUID serial_number: str external_serial: str | None = None order_id: uuid.UUID | None = None order_no: str = "" material_id: str | None material_name: str | None = None spec_model: str | None = None category: str | None = None material_type: str | None = None parent_product_id: uuid.UUID | None current_location_id: str | None = None current_location_name: str | None = None macro_status: str | None = None # 🔧 后端预计算的任务树状态(免前端逐条展开) overall_status: str | None = None # 【当前工序】—— 只反映"此刻在做什么",绝不拿历史工序冒充。 # · 有活跃主干任务(WIP/PENDING) → 该任务工序名 # · 否则产品处于宏观终态(待仓库收货/已入库/在库/已出库) → 该终态(表达"货在哪") # · 否则(活已干完、只剩工序名残留)→ ""(前端显示「—」) # ⚠️ 必须与 overall_status 分开:ProductResponse.overall_status 会被"最新主干任务名" # 覆盖(见 product_service 的 overall_names),于是已 COMPLETED 的历史工序 # (扫码出库 / 测试 / 发货测试…)会被当成"当前工序"长期展示。 current_step: str = "" status: str # 🔧 生命周期阶段:PRODUCTION(生产制造/发货测试) | AFTER_SALES(出库后返厂售后维修) lifecycle_phase: str = "PRODUCTION" created_at: datetime # 🔧 最新动态 — 该产品活跃任务的最新记录 latest_record_time: datetime | None = None latest_record_content: str | None = None latest_record_has_images: bool = False latest_record_assignee_id: str | None = None # 🔧 最新记录操作人(消除并发张冠李戴) latest_record_assignee_name: str | None = None # 🔧 当前人滞留时长 — 活跃任务(WIP/PENDING)最早接手时间到现在的时长(小时) active_duration_hours: float | None = None # 🔧 生产总天数(自创建至今) production_days: int = 0 # 自然天 production_days_workdays: int = 0 # 工作日(排除周末/节假日) model_config = {"from_attributes": True} class ProductOutboundResponse(BaseModel): """产品出库记录 — 来自 MOM 出库回调的单据存档 一次出库一行,按出库时间倒序返回。**已撤回的记录照常留在列表里** (is_revoked=True),由前端打标记 —— 不在后端过滤掉,「出过又撤了」 本身就是要看得见的历史。 """ id: uuid.UUID outbound_no: str # MOM 出库单号 request_no: str | None = None # MOM 出库申请单号 consumer_name: str | None = None # 领用人/客户 applicant_name: str | None = None # 申请人姓名 operator: str | None = None # MOM 侧实际扫码出库人 outbound_type: str | None = None # SALES / USE / PRODUCTION(只展示,不做业务判断) outbound_time: datetime | None = None # MOM 记录的出库时间 remark: str | None = None is_revoked: bool = False revoked_at: datetime | None = None # 这一行怎么来的:webhook(MOM 回调自动存档) | manual(人工在界面挂的) source: str = "webhook" created_at: datetime # 本行写入时间 model_config = {"from_attributes": True} class ProductOutboundMaterialResponse(BaseModel): """设备的一条 MOM 出库明细(合并后的统一形态) 一行 = 设备上的一条出库明细。`mom_line_id` 为空表示这条是**单据级存档** (MOM 回调时查不到明细),能看、能标撤回,但**不能报废** —— 报废要用它定位。 """ id: int product_id: uuid.UUID serial_number: str | None = None task_id: uuid.UUID | None = None # 仅溯源,不参与展示/报废/删除 mom_line_id: int | None = None # MOM trans_outbound.id;为空=无明细的存档 outbound_no: str # ---- 单据级(同单内一致,冗余在每条明细上) ---- request_no: str | None = None applicant_name: str | None = None remark: str | None = None # ---- 明细级快照 ---- sku: str | None = None material_name: str | None = None spec_model: str | None = None quantity: float | None = None # 出库单原值,**不是**本设备用量 unit_price: float | None = None outbound_type: str | None = None outbound_type_label: str = "" # 服务端下发的中文名 consumer_name: str | None = None # 领用人/客户 operator_name: str | None = None warehouse_location: str | None = None outbound_time: datetime | None = None # ---- 来源与撤回 ---- source: str = "manual" # manual(可删) | webhook(系统事实,不可删) is_revoked: bool = False revoked_at: datetime | None = None added_by: str | None = None # 谁挂上去的(Track 用户名) # 谁挂上去的(中文姓名)。由服务端解析下发 —— 前端不做 username→姓名映射, # 否则移动端/网页端各抄一份,迟早漂移。 added_by_name: str = "" created_at: datetime model_config = {"from_attributes": True} @model_validator(mode="after") def _fill_type_label(self): """出库类型码 → 中文名。统一在 schema 派生,避免各构造点漏填。""" 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 ProductScrapCreate(BaseModel): """提交生产报废的请求体。 ⚠️ 只传 `mom_line_id`,物料快照一律由后端从 Track 已挂的出库物料里取 —— 不接受前端传快照,否则前端可以伪造「报废了什么」。 """ # MOM trans_outbound.id,也就是 Track 侧 task_outbound_materials.mom_line_id。 # 后端会校验它**确实挂在本产品上** —— 这是跨设备乱报的唯一防线 # (可见范围是整台设备、不是「谁领的」,所以不能靠隐藏来防)。 mom_line_id: int # 本次报废数量。上限由 MOM 判(不能超过该出库明细的可退额度), # 这里不重复校验,避免两处口径漂移。 quantity: float # 用户填的原因说明(选填) reason: str | None = None # 幂等锚点:前端在**打开弹层时**生成一次,重试时复用同一个。 # 后端拼成 <公司>: 发给 MOM,两边同一口径。 track_ref: str class ProductScrapResponse(BaseModel): """生产报废记录 — Track 发起、MOM 受理的报废单。 `mom_status` / `total_loss` 是**实时回查 MOM** 的结果,不是本地快照 (本地那列只在 MOM 暂时查不到时兜底)。 · `mom_status_label`:「待审批 / 已通过(待执行)/ 已执行 / 已驳回 / 已撤回」 · `mom_executed=False` 时 `total_loss` 是 **None 而不是 0** —— 区分「还没执行」和「执行了但损失为 0」,别让用户把未审批看成 0 元损失 """ id: uuid.UUID product_id: uuid.UUID serial_number: str | None = None task_id: uuid.UUID | None = None mom_line_id: int # 快照:MOM 侧数据被清理后仍要能显示「报了什么」 outbound_no: str | None = None material_name: str | None = None spec_model: str | None = None sku: str | None = None consumer_name: str | None = None # 原领用人,前端据此提示「代报」 quantity: float reason_category: str = "PRODUCTION" reason: str | None = None scrap_request_no: str defective_goods_id: int | None = None submitted_by: str | None = None created_at: datetime # ---- 以下为实时回查 MOM 的结果 ---- mom_status: int = 0 mom_status_label: str = "" mom_approved_at: str | None = None mom_executor_name: str = "" mom_executed: bool = False total_loss: float | None = None # 报废损失(单价 × 实报废数量) scrapped_quantity: float | None = None model_config = {"from_attributes": True} class ProductScanResponse(BaseModel): """扫码查询响应 — 产品信息 + 完整任务树(递归嵌套)""" id: uuid.UUID serial_number: str external_serial: str | None = None order_id: uuid.UUID | None = None order_no: str = "" material_id: str | None material_name: str | None = None spec_model: str | None = None category: str | None = None material_type: str | None = None parent_product_id: uuid.UUID | None current_location_id: str | None = None overall_status: str | None = None status: str # 🔧 生命周期阶段:PRODUCTION(生产制造/发货测试) | AFTER_SALES(出库后返厂售后维修) lifecycle_phase: str = "PRODUCTION" created_at: datetime top_level_tasks: list[TaskSummaryResponse] = [] task_tree: list[TaskResponse] = [] assignee_names: dict[str, str] = {} # 🔧 username→中文姓名映射 # 🔧 出库单据存档(来自 MOM 出库回调),按出库时间倒序。 # 本次功能上线前出库的设备没有存档,这里是空列表 —— 不是错误。 outbound_records: list[ProductOutboundMaterialResponse] = [] model_config = {"from_attributes": True} # 延迟导入,避免循环引用 from app.schemas.task import TaskSummaryResponse, TaskResponse # noqa: E402 ProductScanResponse.model_rebuild()