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:
120
backend/alembic/versions/p1q2r3s4t5u6_add_product_scraps.py
Normal file
120
backend/alembic/versions/p1q2r3s4t5u6_add_product_scraps.py
Normal 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")
|
||||
Reference in New Issue
Block a user