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,
  而不是让一个写接口在生产上默默开着。
This commit is contained in:
2026-09-23 15:18:06 +08:00
parent e45c97bd1f
commit 551819e0e3
7 changed files with 839 additions and 0 deletions

View File

@ -0,0 +1,120 @@
"""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")