Files
track/backend/app/schemas/product.py
duxingchen 5d5aea1015 refactor(outbound): 出库单据与领用物料合并成一张表
这两者本来就是同一件事(这台设备对应 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。
- 前端两张卡合并成一张:按出库单号分组、点开看明细,明细行才有报废/删除。
2026-09-23 15:18:06 +08:00

256 lines
12 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 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 出库**明细行** IDtrans_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()