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