"""add_product_scraps Revision ID: p1q2r3s4t5u6 Revises: o1p2q3r4s5t6 Create Date: 2026-09-23 生产报废记录(product_scraps) -------------------------------------------------------------------------- Track 发起的「领用物料在生产中报废」。料一经出库领用,那条库存行的可用量 就已经扣掉了,所以走不了 MOM 的标准库存行报废;MOM 自己的答案是逆向物流的 「从出库单退回(不良品)」→ 在管不良品 → 报废。Track 侧通过 MOM 的内部接口 (/api/v1/internal/production-scrap)一次调用完成,本表存回执与关联。 为什么挂在**产品**维度而不是任务维度: 料是领给这台**设备**的,不是领给某个人的。一台设备会经历多个任务、多个人的 手(生产领料 → 装配 → 测试)。测试时摔坏的外壳是生产的人领的、挂在生产任务 下 —— 若本表挂任务维度,测试在自己的任务里根本看不到它,「谁发现谁报」就落 不了地。所以可见范围跟设备走,责任归属跟实际发生走(applicant 记在 MOM 单上)。 跨设备的防护不靠隐藏,靠写入前校验 mom_line_id 确实挂在这台设备上。 为什么存快照(outbound_no / material_name / spec_model / sku / consumer_name): mom_line_id 是跨库逻辑外键(指向 MOM trans_outbound.id),MOM 侧数据被清理时 就查不到了;且列表页若每条都跨库查,慢且脆。快照让「报了什么」永远看得见。 为什么**不**存金额: 金额由 MOM 在执行报废时算(trans_scrap.total_loss = 单价 × 数量),且取决于 执行时**实际扫码量**(MOM 允许少扫,受理量 ≠ 执行量)。在 Track 侧另存一份 就是第二份口径,迟早对不上。展示/统计一律按 scrap_request_no 实时回查 MOM。 唯一约束 source_ref: 幂等锚点,格式 <公司>:,与发给 MOM 的值同一口径。 用户点两下、或超时后重试,必须命中同一行而不是插出第二行。 本迁移只建表,不写入任何数据。 """ from typing import Sequence, Union import sqlalchemy as sa from alembic import op from sqlalchemy.dialects.postgresql import UUID revision: str = "p1q2r3s4t5u6" down_revision: Union[str, None] = "o1p2q3r4s5t6" branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: op.create_table( "product_scraps", sa.Column("id", UUID(as_uuid=True), nullable=False, comment="主键"), sa.Column("product_id", UUID(as_uuid=True), sa.ForeignKey("products.id"), nullable=False, comment="所属产品ID"), sa.Column("serial_number", sa.String(16), nullable=True, comment="产品序列号(冗余,便于按SN对账)"), sa.Column("task_id", UUID(as_uuid=True), sa.ForeignKey("tasks.id"), nullable=True, comment="料所属的Track任务(可空,仅溯源用,不参与可见性判断)"), sa.Column("mom_line_id", sa.Integer(), nullable=False, comment="报废对象:MOM trans_outbound.id(出库明细行)"), sa.Column("outbound_no", sa.String(100), nullable=True, comment="MOM 出库单号(快照)"), sa.Column("material_name", sa.String(255), nullable=True, comment="物料名称(快照)"), sa.Column("spec_model", sa.String(255), nullable=True, comment="规格型号(快照)"), sa.Column("sku", sa.String(100), nullable=True, comment="SKU(快照)"), sa.Column("consumer_name", sa.String(100), nullable=True, comment="原领用人(快照)。前端据此判断「报别人的料要额外确认」"), sa.Column("quantity", sa.Numeric(19, 4), nullable=False, comment="本次报废数量"), sa.Column("reason_category", sa.String(50), nullable=False, server_default="PRODUCTION", comment="报废原因分类码。生产报废恒为 PRODUCTION(生产损耗)"), sa.Column("reason", sa.Text(), nullable=True, comment="报废原因说明(用户填写)"), sa.Column("scrap_request_no", sa.String(100), nullable=False, comment="MOM 报废申请单号(APR-SCRAP-...)。状态与金额按它回查 MOM"), sa.Column("defective_goods_id", sa.Integer(), nullable=True, comment="MOM 在管不良品台账 id(退回时生成)"), sa.Column("mom_status", sa.Integer(), nullable=False, server_default="0", comment="MOM 报废单状态快照(0待审批/1已通过/2已驳回/3已执行/4已撤回)," "展示时以实时回查为准"), sa.Column("source_ref", sa.String(100), nullable=False, comment="幂等锚点 <公司>:,随请求发给 MOM,两边同一口径"), sa.Column("submitted_by", sa.String(64), nullable=True, comment="提交人 Track 用户名(即 MOM 账号),MOM 侧报废单的申请人就是他本人"), sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, comment="本行写入时间"), sa.PrimaryKeyConstraint("id"), sa.UniqueConstraint("source_ref", name="uq_product_scrap_source_ref"), comment="生产报废记录 — Track 发起的领用物料报废,关联 MOM 报废申请单", ) # 产品详情按 product_id 拉报废记录 op.create_index(op.f("ix_product_scraps_product_id"), "product_scraps", ["product_id"], unique=False) # 按 SN 排查/对账 op.create_index(op.f("ix_product_scraps_serial_number"), "product_scraps", ["serial_number"], unique=False) # 溯源到任务 op.create_index(op.f("ix_product_scraps_task_id"), "product_scraps", ["task_id"], unique=False) # 按报废对象反查:这条料什么时候报过废 op.create_index(op.f("ix_product_scraps_mom_line_id"), "product_scraps", ["mom_line_id"], unique=False) # 回查 MOM 状态/金额 op.create_index(op.f("ix_product_scraps_scrap_request_no"), "product_scraps", ["scrap_request_no"], unique=False) # 「我提交的报废」 op.create_index(op.f("ix_product_scraps_submitted_by"), "product_scraps", ["submitted_by"], unique=False) def downgrade() -> None: for name in ( "ix_product_scraps_submitted_by", "ix_product_scraps_scrap_request_no", "ix_product_scraps_mom_line_id", "ix_product_scraps_task_id", "ix_product_scraps_serial_number", "ix_product_scraps_product_id", ): op.drop_index(op.f(name), table_name="product_scraps") op.drop_table("product_scraps")