Files
track/backend/app/models/product_outbound_material.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

158 lines
7.6 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.

"""设备出库明细 — 一台设备对应 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}>")