料领到产线后在生产中报废,要在 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,
而不是让一个写接口在生产上默默开着。
137 lines
6.7 KiB
Python
137 lines
6.7 KiB
Python
"""生产报废记录 — 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}>")
|