这两者本来就是同一件事(这台设备对应 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。
- 前端两张卡合并成一张:按出库单号分组、点开看明细,明细行才有报废/删除。
256 lines
12 KiB
Python
256 lines
12 KiB
Python
"""产品 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
|
||
# 幂等锚点:前端在**打开弹层时**生成一次,重试时复用同一个。
|
||
# 后端拼成 <公司>:<track_ref> 发给 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()
|