这两者本来就是同一件事(这台设备对应 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。
- 前端两张卡合并成一张:按出库单号分组、点开看明细,明细行才有报废/删除。
158 lines
7.6 KiB
Python
158 lines
7.6 KiB
Python
"""设备出库明细 — 一台设备对应 MOM 的**每一条**出库明细
|
||
|
||
一行 = 设备上的一条 MOM 出库明细(`trans_outbound` 的一行)。
|
||
|
||
═══ 为什么是这张表(合并了原先的两张)═══
|
||
本表合并了原来的 `product_outbounds`(产品 ↔ 出库**单**,单据级)与
|
||
`task_outbound_materials`(任务 ↔ 出库**明细**,明细级)。
|
||
|
||
它们本来就是**同一件事**——「这台设备对应 MOM 的哪些出库单、领了哪些料」——
|
||
却因为粒度不同被拆成两张表、界面上显示成两张卡,用户要面对两个入口、
|
||
两个删除按钮,还要猜「我刚才在那边挂的怎么这边看不见」。那是设计失误。
|
||
|
||
统一到**明细级**,因为只有明细级带 `mom_line_id`(MOM `trans_outbound.id`),
|
||
而报废必须靠它定位到具体哪一条出库明细。单据级的信息(申请单号、备注、撤回)
|
||
作为**冗余列**落在每一条明细上 —— 同单内必然一致,多存几份换取单表自包含。
|
||
|
||
═══ 两种来源(source)═══
|
||
· `manual` —— 人在界面上挂的(网页端/移动端选 MOM 出库单)
|
||
· `webhook` —— MOM 出库回调自动存档(按 SN 匹配到设备后写入)
|
||
两者语义不同、删除规则也不同(本表的 `manual` 可删;`webhook` 是系统事实,
|
||
要撤得去 MOM 撤回,由回调置 `is_revoked`),所以保留 `source` 区分。
|
||
|
||
═══ task_id 为什么可空 ═══
|
||
人工挂载时要选「挂到哪条任务」(一线按任务领料),webhook 存档则**不知道任务**。
|
||
但任务只是**溯源信息**,不再是组织维度 —— 展示、报废、删除一律按**设备**维度走。
|
||
"""
|
||
import uuid
|
||
from datetime import datetime
|
||
from decimal import Decimal
|
||
|
||
from sqlalchemy import (
|
||
Boolean, DateTime, ForeignKey, Index, Numeric, String, Text, text,
|
||
)
|
||
from sqlalchemy.dialects.postgresql import UUID
|
||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||
|
||
from app.models.base import Base
|
||
from app.core.time_utils import get_beijing_time
|
||
|
||
|
||
class ProductOutboundMaterial(Base):
|
||
__tablename__ = "product_outbound_materials"
|
||
__table_args__ = (
|
||
# 同一条 MOM 出库明细不能在一台设备上出现两次(重复提交、前端重放、并发点击、
|
||
# webhook 重推)。**部分索引**:mom_line_id 为空的行(MOM 查不到明细的存档)
|
||
# 不参与 —— NULL 之间不相等,带上它等于给这类行开了后门。
|
||
Index("uq_pom_product_line", "product_id", "mom_line_id",
|
||
unique=True, postgresql_where=text("mom_line_id IS NOT NULL")),
|
||
# 没有明细行的存档:一张单在一台设备上只留一行
|
||
Index("uq_pom_product_no_noline", "product_id", "outbound_no",
|
||
unique=True, postgresql_where=text("mom_line_id IS NULL")),
|
||
)
|
||
|
||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||
|
||
# ---- 物理外键:设备(组织维度,展示/报废/删除都按它走) ----
|
||
product_id: Mapped[uuid.UUID] = mapped_column(
|
||
UUID(as_uuid=True), ForeignKey("products.id"),
|
||
nullable=False, index=True, comment="所属设备ID",
|
||
)
|
||
# 冗余序列号:按 SN 对账/排查时不必 join products
|
||
serial_number: Mapped[str | None] = mapped_column(
|
||
String(16), nullable=True, index=True, comment="设备序列号(冗余,便于按SN对账)",
|
||
)
|
||
# ---- 物理外键:任务(可空,仅溯源用,不参与展示与权限) ----
|
||
task_id: Mapped[uuid.UUID | None] = mapped_column(
|
||
UUID(as_uuid=True), ForeignKey("tasks.id"),
|
||
nullable=True, index=True,
|
||
comment="人工挂载时选的任务(可空,仅溯源用,不参与展示/报废/删除)",
|
||
)
|
||
|
||
# ---- 跨库逻辑外键(MOM 库 trans_outbound.id,无物理约束) ----
|
||
mom_line_id: Mapped[int | None] = mapped_column(
|
||
nullable=True, index=True,
|
||
comment="MOM 出库明细行ID(逻辑外键→MOM trans_outbound.id)。为空=MOM 查不到明细的单据存档",
|
||
)
|
||
outbound_no: Mapped[str] = mapped_column(
|
||
String(100), nullable=False, index=True,
|
||
comment="MOM 出库单号(批量出库多商品共用,故本表按明细行成行)",
|
||
)
|
||
|
||
# ---- 单据级信息(同单内一致,冗余在每条明细上换取单表自包含) ----
|
||
request_no: Mapped[str | None] = mapped_column(
|
||
String(100), nullable=True, comment="MOM 出库申请单号",
|
||
)
|
||
applicant_name: Mapped[str | None] = mapped_column(
|
||
String(100), nullable=True, comment="申请人姓名(MOM 侧解析后传来,不做 ID 反查)",
|
||
)
|
||
remark: Mapped[str | None] = mapped_column(
|
||
Text, nullable=True, comment="出库单备注",
|
||
)
|
||
|
||
# ---- 明细级快照(挂载/回调时从 MOM 拉取,之后 Track 自包含) ----
|
||
sku: Mapped[str | None] = mapped_column(
|
||
String(100), nullable=True, comment="物料SKU(MOM trans_outbound.sku 快照)",
|
||
)
|
||
material_name: Mapped[str | None] = mapped_column(
|
||
String(255), nullable=True, comment="物料名称(经 COALESCE 三表 JOIN 解析后快照)",
|
||
)
|
||
spec_model: Mapped[str | None] = mapped_column(
|
||
String(255), nullable=True, comment="规格型号快照",
|
||
)
|
||
quantity: Mapped[Decimal | None] = mapped_column(
|
||
Numeric(19, 4), nullable=True,
|
||
comment="出库数量(出库单原值,**不是**本设备用量)",
|
||
)
|
||
unit_price: Mapped[Decimal | None] = mapped_column(
|
||
Numeric(19, 2), nullable=True, comment="出库单价",
|
||
)
|
||
outbound_type: Mapped[str | None] = mapped_column(
|
||
String(50), nullable=True,
|
||
comment="出库类型 SALES/USE/PRODUCTION/LOSS/REPAIR(MOM 码表未冻结,只存不判)",
|
||
)
|
||
consumer_name: Mapped[str | None] = mapped_column(
|
||
String(100), nullable=True, comment="领用人/客户(MOM 侧自由填写,非可靠标识)",
|
||
)
|
||
operator_name: Mapped[str | None] = mapped_column(
|
||
String(100), nullable=True, comment="MOM 侧操作员",
|
||
)
|
||
warehouse_location: Mapped[str | None] = mapped_column(
|
||
String(100), nullable=True, comment="出库库位快照",
|
||
)
|
||
outbound_time: Mapped[datetime | None] = mapped_column(
|
||
DateTime(timezone=True), nullable=True,
|
||
comment="MOM 记录的出库时间(写入时已按 +08:00 补全时区)",
|
||
)
|
||
|
||
# ---- 来源 ----
|
||
# manual —— 人在界面上挂的(网页端/移动端选 MOM 出库单)→ **可删**
|
||
# webhook —— MOM 出库回调自动存档 → 系统事实,要撤得去 MOM 撤回,**不可删**
|
||
source: Mapped[str] = mapped_column(
|
||
String(16), nullable=False, default="manual", server_default="manual",
|
||
comment="来源: manual(人工挂载,可删) | webhook(MOM回调自动存档,不可删)",
|
||
)
|
||
|
||
# ---- 撤回(只置位不删行)----
|
||
is_revoked: Mapped[bool] = mapped_column(
|
||
Boolean, nullable=False, default=False, server_default="false",
|
||
comment="该次出库是否已被 MOM 撤回",
|
||
)
|
||
revoked_at: Mapped[datetime | None] = mapped_column(
|
||
DateTime(timezone=True), nullable=True, comment="撤回时间",
|
||
)
|
||
|
||
added_by: Mapped[str | None] = mapped_column(
|
||
String(64), nullable=True, comment="挂载人ID(逻辑外键→MOM sys_user)",
|
||
)
|
||
created_at: Mapped[datetime] = mapped_column(
|
||
DateTime(timezone=True), default=get_beijing_time, comment="本行写入时间",
|
||
)
|
||
|
||
# ---- 关系 ----
|
||
product: Mapped["Product"] = relationship("Product", lazy="selectin")
|
||
|
||
def __repr__(self) -> str:
|
||
return (f"<ProductOutboundMaterial {self.outbound_no} "
|
||
f"line={self.mom_line_id} sku={self.sku}>")
|