"""生产报废记录 — Track 发起的「领用物料在生产中报废」 回答两个问题:**这台设备的哪条料报废了**、**在 MOM 里对应哪张报废单**。 与 `product_outbounds`(产品 ↔ MOM 出库单)的分工: · 那张表回答「这台设备对应 MOM 的哪张出库单」(货从哪来); · 本表回答「这台设备上的哪条料废了、废了多少、MOM 怎么处理的」(货怎么没的)。 ═══ 为什么挂在**产品**维度,而不是任务维度 ═══ 料是领给**这台设备**的,不是领给某个人的。一台设备会经历多个任务、多个人的手 (生产领料 → 装配 → 测试),测试时摔坏了外壳——那条外壳是生产的人领的, 挂在生产任务下。若本表挂任务维度,测试在自己的任务里根本看不到它, 「谁发现谁报」就无从落地。 所以:**可见范围跟设备走**(打开这台设备就能看到它全部的料), **责任归属跟实际发生走**(谁发现谁报,applicant 记在 MOM 报废单上)。 跨设备的防护不靠"隐藏",靠写入前校验 `mom_line_id` 确实挂在这台设备上(见 service)。 ═══ 为什么不存金额 ═══ 报废金额由 MOM 在执行报废时算(`trans_scrap.total_loss` = 单价 × 数量), 且**取决于执行时的实际扫码量**(MOM 允许少扫,受理量 ≠ 执行量)。 在 Track 侧另存一份就是第二份口径,迟早对不上。展示/统计时按 `scrap_request_no` 实时回查 MOM(见 mom_scrap_service)。 """ import uuid from datetime import datetime from decimal import Decimal from sqlalchemy import DateTime, ForeignKey, Numeric, String, Text, UniqueConstraint 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 ProductScrap(Base): __tablename__ = "product_scraps" # 幂等锚点:外部单据号(Track 生成,随请求发给 MOM)。同一个号重发必须命中 # 同一行,而不是插出第二行 —— 用户点两下提交、或网络超时后重试都会走到这里。 __table_args__ = ( UniqueConstraint("source_ref", name="uq_product_scrap_source_ref"), ) id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, ) # ---- 物理外键(关联本库 products) ---- 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对账)", ) # 料挂在哪条 Track 任务上(可空:允许直接按设备报,不强制挂任务)。 # 只作溯源用,**不参与可见性判断** —— 见模块头「为什么挂在产品维度」。 task_id: Mapped[uuid.UUID | None] = mapped_column( UUID(as_uuid=True), ForeignKey("tasks.id"), nullable=True, index=True, comment="料所属的Track任务(可空,仅溯源用,不参与可见性判断)", ) # ---- 报废对象:MOM 出库明细行 ---- # = MOM trans_outbound.id,也就是 task_outbound_materials.mom_line_id。 # ★ 这是跨库逻辑外键(无物理约束),MOM 侧数据被清理时可能查不到。 mom_line_id: Mapped[int] = mapped_column( nullable=False, index=True, comment="报废对象:MOM trans_outbound.id(出库明细行)", ) # ---- 快照(MOM 行被删也要能显示,且列表页不必跨库查询) ---- outbound_no: Mapped[str | None] = mapped_column( String(100), nullable=True, comment="MOM 出库单号(快照)", ) material_name: Mapped[str | None] = mapped_column( String(255), nullable=True, comment="物料名称(快照)", ) spec_model: Mapped[str | None] = mapped_column( String(255), nullable=True, comment="规格型号(快照)", ) sku: Mapped[str | None] = mapped_column( String(100), nullable=True, comment="SKU(快照)", ) consumer_name: Mapped[str | None] = mapped_column( String(100), nullable=True, comment="原领用人(快照)。前端据此判断「报别人的料要额外确认」", ) quantity: Mapped[Decimal] = mapped_column( Numeric(19, 4), nullable=False, comment="本次报废数量", ) reason_category: Mapped[str] = mapped_column( String(50), nullable=False, default="PRODUCTION", server_default="PRODUCTION", comment="报废原因分类码。生产报废恒为 PRODUCTION(生产损耗),与 MOM 侧码表一致", ) reason: Mapped[str | None] = mapped_column( Text, nullable=True, comment="报废原因说明(用户填写)", ) # ---- MOM 回执 ---- scrap_request_no: Mapped[str] = mapped_column( String(100), nullable=False, index=True, comment="MOM 报废申请单号(APR-SCRAP-...)。状态与金额都按它回查 MOM", ) defective_goods_id: Mapped[int | None] = mapped_column( nullable=True, comment="MOM 在管不良品台账 id(退回时生成)", ) # MOM 报废单状态快照:0待审批 1已通过 2已驳回 3已执行 4已撤回。 # ⚠️ 这是**写入当时**的快照,会过期(MOM 里审批、执行后 Track 不知道)。 # 展示时以实时回查为准,本列只用于「MOM 暂时查不到时不至于没得显示」。 mom_status: Mapped[int] = mapped_column( nullable=False, default=0, server_default="0", comment="MOM 报废单状态快照(0待审批/1已通过/2已驳回/3已执行/4已撤回),展示时以实时回查为准", ) # ---- 幂等与归属 ---- source_ref: Mapped[str] = mapped_column( String(100), nullable=False, comment="幂等锚点 <公司>:,随请求发给 MOM,两边同一口径", ) submitted_by: Mapped[str | None] = mapped_column( String(64), nullable=True, index=True, comment="提交人 Track 用户名(即 MOM 账号)。MOM 侧报废单的申请人就是他本人", ) 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"")