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。
- 前端两张卡合并成一张:按出库单号分组、点开看明细,明细行才有报废/删除。
This commit is contained in:
2026-09-23 15:18:06 +08:00
parent 551819e0e3
commit 5d5aea1015
15 changed files with 2030 additions and 15 deletions

View File

@ -2,7 +2,7 @@
from __future__ import annotations
import uuid
from datetime import datetime
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, model_validator
class ProductCreate(BaseModel):
@ -18,6 +18,14 @@ class ProductCreate(BaseModel):
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}
@ -80,6 +88,139 @@ class ProductResponse(BaseModel):
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
@ -102,6 +243,9 @@ class ProductScanResponse(BaseModel):
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}