Files
track/backend/app/models/product_scrap.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

137 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.

"""生产报废记录 — 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="幂等锚点 <公司>:<Track单据号>,随请求发给 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"<ProductScrap {self.scrap_request_no} sn={self.serial_number} "
f"line={self.mom_line_id}>")