Files
track/backend/alembic/versions/p1q2r3s4t5u6_add_product_scraps.py
duxingchen 551819e0e3 feat(scrap): Track 侧生产报废 —— 提交、回查、金额
料领到产线后在生产中报废,要在 MOM 里走报废流程并能统计金额。

- mom_scrap_client:Track **唯一**一处主动写 MOM 的通道。读仍走直连只读库
  (MOM 查询接口有权限与行级隔离),写必须走接口(跨库直写会绕过 MOM 的
  全部业务校验、权限与审批)。
- product_scrap_service:归属校验是关键 —— 可见范围是整台设备、不是「谁领的」,
  不能靠隐藏来防,必须在写入前确认这条 mom_line_id 就挂在这台设备上。
  申请人直接用当前登录人(Track 的 sub 就是 MOM sys_user.id),
  MOM 里显示的就是本人,不需要服务账号也不会串人。
- 幂等:track_ref 由前端在打开弹层时生成一次、重试复用;网络超时后重试
  不该在 MOM 里多报一张单。
- 状态与金额**实时回查 MOM**,不在本地存副本:报废没有回调,本地那份立刻
  就过期;且金额取决于执行时的实际扫码量(MOM 允许少扫),受理量 ≠ 执行量。
  ⚠️ 未执行时 total_loss 是 null 不是 0 —— 0 会让人以为「这东西不值钱」。
- MOM_INTERNAL_API_KEY 走环境变量且不给默认值:未配置时报废提交 503,
  而不是让一个写接口在生产上默默开着。
2026-09-23 15:18:06 +08:00

121 lines
6.7 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.

"""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:
幂等锚点,格式 <公司>:<Track单据号>,与发给 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="幂等锚点 <公司>:<Track单据号>,随请求发给 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")