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")
|
||||
@ -33,6 +33,39 @@ class Settings(BaseSettings):
|
||||
# ---- MOM 仓储系统回调 Webhook(Track 作为接收方,验签用) ----
|
||||
TRACK_WEBHOOK_KEY: str | None = None # MOM 回调 POST 时 Header X-API-Key 须等于此值
|
||||
|
||||
# ---- MOM 内部接口(Track 作为**调用方**,发起生产报废) ----
|
||||
# ⚠️ 这是 Track **唯一**一处主动写 MOM 的通道。
|
||||
# 读数据一律继续走直连 MOM 库(app/core/mom_database.py)—— 不要因为有了
|
||||
# 这个客户端就把「读」也搬过来:MOM 的查询接口要 JWT + permission_required,
|
||||
# 且对非特权账号按 consumer_name 做行级隔离,服务账号只能拿到自己名下的数据。
|
||||
# 而「写」必须走接口:跨库直写会绕过 MOM 的全部业务校验、权限与审批。
|
||||
MOM_INTERNAL_API_URL: str = "http://inventory_api:8000"
|
||||
# 请求头 X-API-Key 的值,须与 MOM 侧 config.MOM_INTERNAL_API_KEY 一致。
|
||||
# ⚠️ 未配置 → 报废提交直接 503(Fail-Closed),**不静默降级**:报废是写操作,
|
||||
# 静默失败会让用户以为报上去了,实际 MOM 里什么都没有。
|
||||
MOM_INTERNAL_API_KEY: str | None = None
|
||||
|
||||
# ---- 组织隔离 ----
|
||||
# 同一套代码部署给不同部门时,只需改这两个值(+ compose 里的项目名/容器名/端口)。
|
||||
# 全仓库的部门过滤点只有四处:登录、人员列表、物料选择器、MOM 出库单查询。
|
||||
ORG_DEPARTMENT: str = "IRIS" # MOM sys_user.department 的取值
|
||||
MATERIAL_CATEGORY_PREFIX: str = "IRIS/" # MOM material_base.category 的部门前缀
|
||||
|
||||
# ---- MOM 出库单的跨部门领用人例外 ----
|
||||
# 出库单的公司隔离靠 material_base.category 前缀(见上)。
|
||||
# 但这几个领用人(MOM trans_outbound.consumer_name,**纯姓名**,不带账号后缀)
|
||||
# 经手的单据,即使物料分类不属于本部门,本实例也要能看见 —— 他们跨两个部门
|
||||
# 领料,只按物料前缀过滤会把他们的单整批漏掉。
|
||||
# ⚠️ 这是**放行**条件(SQL 里是 OR),与界面筛选(AND,只收窄)方向相反,
|
||||
# 两者的集合运算必须分开写,混在一起就变成范围放大。
|
||||
# ⚠️ 留空即关闭该例外,退化成纯前缀过滤。
|
||||
EXTRA_VISIBLE_CONSUMERS: str = "依锐思,石利LICA"
|
||||
|
||||
@property
|
||||
def EXTRA_VISIBLE_CONSUMERS_LIST(self) -> list[str]:
|
||||
"""将逗号分隔的字符串解析为姓名 list(去空白、丢空项)"""
|
||||
return [n.strip() for n in self.EXTRA_VISIBLE_CONSUMERS.split(",") if n.strip()]
|
||||
|
||||
@property
|
||||
def CORS_ORIGINS_LIST(self) -> list[str]:
|
||||
"""将 JSON 字符串解析为 Python list,供 CORSMiddleware 使用"""
|
||||
|
||||
136
backend/app/models/product_scrap.py
Normal file
136
backend/app/models/product_scrap.py
Normal file
@ -0,0 +1,136 @@
|
||||
"""生产报废记录 — 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}>")
|
||||
136
backend/app/services/mom_scrap_client.py
Normal file
136
backend/app/services/mom_scrap_client.py
Normal file
@ -0,0 +1,136 @@
|
||||
"""MOM 内部接口客户端 —— Track 主动调用 MOM 的**唯一**通道
|
||||
|
||||
═══════════════════════════════════════════════════════════════════════════
|
||||
为什么这里用 HTTP,而「读」却直连 MOM 库
|
||||
═══════════════════════════════════════════════════════════════════════════
|
||||
Track 读 MOM 一律走 `app/core/mom_database.py` 直连只读库(见 mom_outbound_service
|
||||
的模块头论证:MOM 的查询接口要 JWT + permission_required,且对非特权账号按
|
||||
`consumer_name` 做行级隔离,服务账号只能拿到自己名下的数据)。
|
||||
|
||||
但「写」不能直连库:跳过 MOM 的业务校验、权限与审批流,会写出 MOM 自己都不认的数据。
|
||||
所以走 MOM 为此新开的内部接口(X-API-Key 鉴权,不走 JWT —— Track 没有也不需要
|
||||
MOM 账号,申请人身份由请求体显式携带)。
|
||||
|
||||
⚠️ 别因为有了本模块就把「读」也搬过来。两条路各有各的理由,不要合并。
|
||||
|
||||
═══════════════════════════════════════════════════════════════════════════
|
||||
失败语义(对用户要诚实)
|
||||
═══════════════════════════════════════════════════════════════════════════
|
||||
报废是**写**操作,静默失败最伤人 —— 用户以为报上去了,MOM 里其实什么都没有。
|
||||
所以这里不吞任何错误:连不上、鉴权失败、被 MOM 拒绝,都以带中文原因的形式抛出去,
|
||||
由端点转成用户看得懂的提示。
|
||||
"""
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 超时:MOM 侧要做「退回 + 建报废申请」两次写库,给宽一点。
|
||||
# 但也不能无限等 —— 请求挂住时用户会一直转圈,宁可失败让他重试(有幂等兜底)。
|
||||
_TIMEOUT = httpx.Timeout(30.0, connect=10.0)
|
||||
|
||||
_PATH = "/api/v1/internal/production-scrap"
|
||||
|
||||
|
||||
class MomScrapError(Exception):
|
||||
"""调 MOM 报废接口失败。
|
||||
|
||||
message 是**给用户看的中文原因**,端点直接把它转成响应 detail,
|
||||
不要再包一层「报废失败: ...」——
|
||||
MOM 返回的文案本身已经说清了(如「退回数量(9999)超出可退额度(5)」)。
|
||||
"""
|
||||
|
||||
def __init__(self, message: str, *, mom_status_code: int | None = None,
|
||||
mom_code: int | None = None):
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.mom_status_code = mom_status_code
|
||||
self.mom_code = mom_code
|
||||
|
||||
|
||||
async def submit_production_scrap(*, outbound_id: int, return_qty: float,
|
||||
track_ref: str, applicant_id: int,
|
||||
reason: str | None = None,
|
||||
operator: str = "Track系统") -> dict:
|
||||
"""提交生产报废 → MOM 的 `POST /api/v1/internal/production-scrap`。
|
||||
|
||||
一次调用完成「退回(不良品) → 在管不良品 → 提交报废申请(待审批)」。
|
||||
返回 MOM 的 `data` 段(含 `scrap_request_no` / `defective_goods_id` / `duplicate`)。
|
||||
|
||||
:param outbound_id: MOM `trans_outbound.id`,即 Track 侧的 `mom_line_id`
|
||||
:param track_ref: Track 侧生成的唯一单据号(幂等锚点),重试必须传同一个
|
||||
:param applicant_id: MOM `sys_user.id`。Track 的 `user.sub` 就是它,
|
||||
所以 MOM 里显示的申请人就是**实际操作人本人**,不是服务账号
|
||||
"""
|
||||
api_key = (settings.MOM_INTERNAL_API_KEY or "").strip()
|
||||
if not api_key:
|
||||
# Fail-Closed:不静默降级成「假装成功」
|
||||
raise MomScrapError(
|
||||
"报废功能未启用:Track 未配置 MOM_INTERNAL_API_KEY,请联系管理员"
|
||||
)
|
||||
|
||||
base_url = (settings.MOM_INTERNAL_API_URL or "").rstrip("/")
|
||||
if not base_url:
|
||||
raise MomScrapError("报废功能未启用:Track 未配置 MOM_INTERNAL_API_URL")
|
||||
|
||||
payload = {
|
||||
# 公司 = 部门。MOM 会拿它跟出库物料实际所属公司强校验,不符直接拒绝。
|
||||
'company_name': settings.ORG_DEPARTMENT,
|
||||
'outbound_id': int(outbound_id),
|
||||
'return_qty': float(return_qty),
|
||||
# ★ 恒为 True:本流程 = 退回并提交报废申请。
|
||||
# false 那个分支(只登记为在管不良品)留给以后按需开放。
|
||||
'submit_scrap': True,
|
||||
'track_ref': track_ref,
|
||||
# 生产损耗。分类**必须显式传**,不能让 MOM 从来源推导 ——
|
||||
# 生产报废与 MOM 手工报的不良品退回共用同一张 trans_defective_goods 表,
|
||||
# 一推导就会把生产损失静默算成库存损失。
|
||||
'reason_category': 'PRODUCTION',
|
||||
'reason': (reason or '').strip() or None,
|
||||
'applicant_id': int(applicant_id),
|
||||
'operator': operator,
|
||||
}
|
||||
|
||||
url = f"{base_url}{_PATH}"
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||
resp = await client.post(url, json=payload, headers={'X-API-Key': api_key})
|
||||
except httpx.TimeoutException:
|
||||
logger.warning(f"[MomScrap] 调用 MOM 超时 url={url} track_ref={track_ref}")
|
||||
raise MomScrapError("提交报废超时:MOM 未在 30 秒内响应,请稍后用同一单据重试")
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"[MomScrap] 连接 MOM 失败 url={url}: {e}")
|
||||
raise MomScrapError(f"无法连接 MOM 报废接口:{e}")
|
||||
|
||||
# MOM 统一信封 {code, msg, data};非 JSON 响应说明打到了别的东西(如 nginx 错误页)
|
||||
try:
|
||||
body = resp.json()
|
||||
except ValueError:
|
||||
logger.error(f"[MomScrap] MOM 返回非 JSON(HTTP {resp.status_code}):{resp.text[:200]}")
|
||||
raise MomScrapError(f"MOM 报废接口返回异常(HTTP {resp.status_code})")
|
||||
|
||||
mom_code = body.get('code')
|
||||
mom_msg = (body.get('msg') or '').strip()
|
||||
|
||||
if resp.status_code != 200 or mom_code != 200:
|
||||
# MOM 的文案本身就是中文且具体(含数量、额度等),直接透传,
|
||||
# 不要在前面再加一层「报废失败:」,那只会把真正的信息挤到后面。
|
||||
logger.warning(
|
||||
f"[MomScrap] MOM 拒绝 HTTP {resp.status_code} code={mom_code} "
|
||||
f"track_ref={track_ref}: {mom_msg}"
|
||||
)
|
||||
raise MomScrapError(
|
||||
mom_msg or f"MOM 报废接口返回 HTTP {resp.status_code}",
|
||||
mom_status_code=resp.status_code, mom_code=mom_code,
|
||||
)
|
||||
|
||||
data = body.get('data') or {}
|
||||
logger.info(
|
||||
f"[MomScrap] 受理成功 track_ref={track_ref} outbound={outbound_id} "
|
||||
f"qty={return_qty} duplicate={data.get('duplicate')} "
|
||||
f"request_no={(data.get('scrap') or {}).get('request_no')}"
|
||||
)
|
||||
return data
|
||||
159
backend/app/services/mom_scrap_service.py
Normal file
159
backend/app/services/mom_scrap_service.py
Normal file
@ -0,0 +1,159 @@
|
||||
"""MOM 报废单只读查询 — 直连 MOM 库(跨库,无 ORM)
|
||||
|
||||
Track 侧只存**回执号**(`product_scraps.scrap_request_no`),状态与金额一律实时
|
||||
回查 MOM。为什么不落一份到 Track:
|
||||
|
||||
· 状态会变。MOM 里审批、执行之后 Track 不会收到通知(报废没有回调),
|
||||
本地存的那份立刻就过期,而「到底批没批、执行没执行」正是用户要看的。
|
||||
· 金额**取决于执行时的实际扫码量**。MOM 允许少扫(合法子集),
|
||||
所以「受理量」≠「执行量」,金额必须在 MOM 执行那一刻才算得准。
|
||||
本地另算一份就是第二份口径,迟早对不上。
|
||||
|
||||
写法照 mom_outbound_service(同步 psycopg2 + text() + MomSessionLocal),
|
||||
调用方一律 `run_in_threadpool` 包出去,别阻塞事件循环。
|
||||
"""
|
||||
import logging
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.core.mom_database import MomSessionLocal
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# MOM scrap_approval.status → 中文。与 MOM 的 scrap_approval_service 逐字对齐,
|
||||
# 不要自创说法(用户在 MOM 界面看到的和 Track 上看的不一致会让人怀疑数据错了)。
|
||||
MOM_SCRAP_STATUS_LABELS = {
|
||||
0: '待审批',
|
||||
1: '已通过(待执行)',
|
||||
2: '已驳回',
|
||||
3: '已执行',
|
||||
4: '已撤回',
|
||||
}
|
||||
|
||||
|
||||
def describe_status(code) -> str:
|
||||
"""状态码 → 中文。未知/空值返回空串(前端兜底显示 '-')。"""
|
||||
if code is None:
|
||||
return ''
|
||||
try:
|
||||
return MOM_SCRAP_STATUS_LABELS.get(int(code), '')
|
||||
except (TypeError, ValueError):
|
||||
return ''
|
||||
|
||||
|
||||
def fetch_scrap_status(request_nos: list[str]) -> dict[str, dict]:
|
||||
"""按报废申请单号批量查 MOM 的审批状态与执行金额。
|
||||
|
||||
返回 `{request_no: {...}}`;查不到的**不出现在结果里**(调用方按「缺失 = MOM
|
||||
侧还没有/已清理」处理,不要伪造成一个空状态)。
|
||||
|
||||
金额字段说明(都是 Decimal → float):
|
||||
· `cost_at_scrap` 报废成本(单价)
|
||||
· `total_loss` **报废损失 = 单价 × 实报废数量**,这是「统计生产报废金额」要的数
|
||||
· `scrapped_quantity` 实际报废数量(执行时扫码量,可能小于受理量)
|
||||
|
||||
⚠️ 单号可能一张都没执行(还在待审批),此时 trans_scrap 里**毫无痕迹**,
|
||||
金额是 0 而不是「缺失」—— 用 `executed` 标志区分这两种情况,
|
||||
别让前端把「还没批」显示成「损失 0 元」。
|
||||
"""
|
||||
nos = [str(n).strip() for n in (request_nos or []) if str(n or '').strip()]
|
||||
if not nos:
|
||||
return {}
|
||||
|
||||
db = MomSessionLocal()
|
||||
try:
|
||||
# ---- 1. 申请单头:状态 / 审批人 / 执行人 ----
|
||||
head_rows = db.execute(
|
||||
text("""
|
||||
SELECT request_no, status, applicant_id,
|
||||
actual_approver_id, approved_at,
|
||||
executor_name, executed_at, reject_reason,
|
||||
remark, reason_category
|
||||
FROM scrap_approval
|
||||
WHERE request_no = ANY(:nos)
|
||||
"""),
|
||||
{"nos": nos},
|
||||
).fetchall()
|
||||
|
||||
out: dict[str, dict] = {}
|
||||
for r in head_rows:
|
||||
status = r.status
|
||||
out[r.request_no] = {
|
||||
'request_no': r.request_no,
|
||||
'status': status,
|
||||
'status_label': describe_status(status),
|
||||
'applicant_id': r.applicant_id,
|
||||
'approver_name': _user_name(db, r.actual_approver_id),
|
||||
'approved_at': _iso(r.approved_at),
|
||||
'executor_name': r.executor_name or '',
|
||||
'executed_at': _iso(r.executed_at),
|
||||
'reject_reason': r.reject_reason or '',
|
||||
'reason_category': r.reason_category or '',
|
||||
# 只有 status==3 才真的执行过、台账里才有金额
|
||||
'executed': status == 3,
|
||||
'cost_at_scrap': None,
|
||||
'total_loss': None,
|
||||
'scrapped_quantity': None,
|
||||
}
|
||||
|
||||
# ---- 2. 报废流水:实际报废量与损失金额(只有执行过才有行) ----
|
||||
if out:
|
||||
ledger_rows = db.execute(
|
||||
text("""
|
||||
SELECT scrap_request_no,
|
||||
count(*) AS line_count,
|
||||
sum(quantity) AS scrapped_quantity,
|
||||
max(cost_at_scrap) AS cost_at_scrap,
|
||||
sum(total_loss) AS total_loss
|
||||
FROM trans_scrap
|
||||
WHERE scrap_request_no = ANY(:nos)
|
||||
GROUP BY scrap_request_no
|
||||
"""),
|
||||
{"nos": list(out.keys())},
|
||||
).fetchall()
|
||||
for r in ledger_rows:
|
||||
item = out.get(r.scrap_request_no)
|
||||
if item is None:
|
||||
continue
|
||||
item['scrapped_quantity'] = _f(r.scrapped_quantity)
|
||||
item['cost_at_scrap'] = _f(r.cost_at_scrap)
|
||||
item['total_loss'] = _f(r.total_loss)
|
||||
|
||||
return out
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _f(v):
|
||||
"""Decimal/None → float/None(JSON 友好)"""
|
||||
return float(v) if v is not None else None
|
||||
|
||||
|
||||
def _iso(dt):
|
||||
"""MOM 的时间列是 naive 北京时间,补 +08:00 再交给上层。
|
||||
|
||||
⚠️ 少这一步,前端会按**本地时区**解释这个 naive 串,非北京时区的人看到的
|
||||
时间就是错的。与 mom_outbound_service._as_beijing 同一处理。
|
||||
"""
|
||||
if dt is None:
|
||||
return None
|
||||
from datetime import timedelta, timezone
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone(timedelta(hours=8)))
|
||||
return dt.isoformat()
|
||||
|
||||
|
||||
def _user_name(db, user_id):
|
||||
"""MOM sys_user.id → 中文姓名('张三/zhangsan01' 取 '/' 前那段)。"""
|
||||
if not user_id:
|
||||
return ''
|
||||
try:
|
||||
row = db.execute(
|
||||
text("SELECT username FROM sys_user WHERE id = :uid"),
|
||||
{"uid": int(user_id)},
|
||||
).fetchone()
|
||||
except Exception:
|
||||
return ''
|
||||
if not row or not row.username:
|
||||
return ''
|
||||
return row.username.split('/')[0] if '/' in row.username else row.username
|
||||
248
backend/app/services/product_scrap_service.py
Normal file
248
backend/app/services/product_scrap_service.py
Normal file
@ -0,0 +1,248 @@
|
||||
"""生产报废 — Track 侧业务逻辑
|
||||
|
||||
用户在产品详情页看到这台设备领用的料,对某一条发起报废;Track 转调 MOM 的
|
||||
内部接口完成「退回(不良品) → 在管不良品 → 提交报废申请」,再把回执存下来。
|
||||
|
||||
═══ 授权模型(刻意的,不是漏掉的)═══
|
||||
**可见范围跟设备走,责任归属跟实际发生走。**
|
||||
|
||||
料是领给这台设备的,不是领给某个人的。一台设备会经历多个任务、多个人的手
|
||||
(生产领料 → 装配 → 测试)。测试时摔坏的外壳是生产的人领的、挂在生产任务下 ——
|
||||
如果只允许「原领用人」报废,测试得回头找生产的人来提单,而生产的人压根不知道
|
||||
这事,流程上讲不通。
|
||||
|
||||
所以:**任何能看到这台设备的人,都能报它上面任何一条料**。
|
||||
跨设备的防护不靠隐藏,靠 `_load_mounted_material` 的归属校验 ——
|
||||
`mom_line_id` 必须确实挂在这台设备上,报不了别的设备的料。
|
||||
滥报由 MOM 侧的主管审批兜底(谁报的、报了谁的料,审批页全看得到)。
|
||||
|
||||
前端对「报别人的料」加一道确认(判据是 consumer_name ≠ 当前用户),
|
||||
那是**防误操作的提示**,不是权限 —— 后端不会因为这条拒绝。
|
||||
"""
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.models.product import Product
|
||||
from app.models.product_scrap import ProductScrap
|
||||
from app.models.product_outbound_material import ProductOutboundMaterial
|
||||
from app.schemas.product import ProductScrapResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 生产报废恒用这个分类码(与 MOM 侧 scrap_approval.SCRAP_CATEGORY_LABELS 对齐)。
|
||||
# ★ 必须显式传、不能由 MOM 从来源推导:生产报废与 MOM 手工报的不良品退回共用
|
||||
# 同一张 trans_defective_goods 表,一推导就会把生产损失静默算成库存损失。
|
||||
SCRAP_CATEGORY_PRODUCTION = "PRODUCTION"
|
||||
|
||||
|
||||
def _source_ref(track_ref: str) -> str:
|
||||
"""幂等锚点:`<公司>:<Track单据号>`,与发给 MOM 的值同一口径。
|
||||
|
||||
带公司前缀是因为 IRIS 与 LICA 各自独立跑一套 Track,工单号可能重号。
|
||||
"""
|
||||
return f"{settings.ORG_DEPARTMENT}:{track_ref.strip()}"
|
||||
|
||||
|
||||
async def _load_mounted_material(
|
||||
db: AsyncSession, product_id: uuid.UUID, mom_line_id: int,
|
||||
) -> ProductOutboundMaterial:
|
||||
"""取出该设备上挂载的这条出库明细,顺带完成**归属校验**。
|
||||
|
||||
这是跨设备乱报的唯一防线:可见范围是整台设备,不能靠「查不到」来防,
|
||||
必须显式确认这条 `mom_line_id` 就挂在这台设备上。
|
||||
⚠️ 不校验的话,前端随便改个数就能报废任意一台设备的料。
|
||||
"""
|
||||
row = (
|
||||
await db.execute(
|
||||
select(ProductOutboundMaterial)
|
||||
.where(
|
||||
ProductOutboundMaterial.product_id == product_id,
|
||||
ProductOutboundMaterial.mom_line_id == mom_line_id,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
).scalars().first()
|
||||
if row is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="这条出库物料没有挂在这台设备上,无法报废",
|
||||
)
|
||||
return row
|
||||
|
||||
|
||||
async def list_product_scraps(
|
||||
db: AsyncSession, product_id: uuid.UUID,
|
||||
) -> list[ProductScrapResponse]:
|
||||
"""列出该产品的生产报废记录(按提交时间倒序),并**实时回查 MOM** 补状态与金额。"""
|
||||
rows = (
|
||||
await db.execute(
|
||||
select(ProductScrap)
|
||||
.where(ProductScrap.product_id == product_id)
|
||||
.order_by(ProductScrap.created_at.desc())
|
||||
)
|
||||
).scalars().all()
|
||||
return await _enrich_with_mom(rows)
|
||||
|
||||
|
||||
async def _enrich_with_mom(rows: list[ProductScrap]) -> list[ProductScrapResponse]:
|
||||
"""把 MOM 的实时状态/金额贴到本地记录上。
|
||||
|
||||
★ 回查失败**不能让整个列表挂掉**:MOM 短暂不可用时,用户至少要能看到
|
||||
「我报过什么」,只是状态暂时显示不出来。所以这里 catch 住、降级成本地快照。
|
||||
"""
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
|
||||
from app.services import mom_scrap_service
|
||||
|
||||
result: list[ProductScrapResponse] = []
|
||||
live: dict[str, dict] = {}
|
||||
if rows:
|
||||
try:
|
||||
live = await run_in_threadpool(
|
||||
mom_scrap_service.fetch_scrap_status,
|
||||
[r.scrap_request_no for r in rows],
|
||||
)
|
||||
except Exception as e:
|
||||
# 降级:用本地快照,并在日志里留痕(静默降级会让人以为 MOM 没执行)
|
||||
logger.warning(f"[ProductScrap] 回查 MOM 状态失败,降级用本地快照: {e}")
|
||||
|
||||
for r in rows:
|
||||
item = ProductScrapResponse.model_validate(r)
|
||||
info = live.get(r.scrap_request_no)
|
||||
if info:
|
||||
item.mom_status = info.get('status', r.mom_status)
|
||||
item.mom_status_label = info.get('status_label') or ''
|
||||
item.mom_approved_at = info.get('approved_at')
|
||||
item.mom_executor_name = info.get('executor_name') or ''
|
||||
item.mom_executed = bool(info.get('executed'))
|
||||
item.total_loss = info.get('total_loss')
|
||||
item.scrapped_quantity = info.get('scrapped_quantity')
|
||||
else:
|
||||
# MOM 里查不到这张单(被清理 / 回查失败降级)→ 用本地快照,
|
||||
# 但**不伪造金额**:total_loss 保持 None,前端显示「—」而不是 0
|
||||
item.mom_status = r.mom_status
|
||||
item.mom_status_label = mom_scrap_service.describe_status(r.mom_status)
|
||||
item.mom_executed = False
|
||||
result.append(item)
|
||||
return result
|
||||
|
||||
|
||||
async def submit_product_scrap(
|
||||
db: AsyncSession, product_id: uuid.UUID, *, mom_line_id: int, quantity: float,
|
||||
track_ref: str, reason: str | None, current_user: dict,
|
||||
) -> ProductScrapResponse:
|
||||
"""提交一条生产报废。
|
||||
|
||||
幂等:同一个 `track_ref` 重发**不会**产生第二条 MOM 报废单,
|
||||
命中已有记录直接返回(网络超时后重试是常态,用户不该为这付两次代价)。
|
||||
"""
|
||||
track_ref = (track_ref or '').strip()
|
||||
if not track_ref:
|
||||
raise HTTPException(status_code=400, detail="track_ref 为必填(幂等锚点)")
|
||||
if not quantity or float(quantity) <= 0:
|
||||
raise HTTPException(status_code=400, detail="报废数量必须大于 0")
|
||||
|
||||
product = (
|
||||
await db.execute(select(Product).where(Product.id == product_id))
|
||||
).scalars().first()
|
||||
if product is None:
|
||||
raise HTTPException(status_code=404, detail="产品不存在")
|
||||
|
||||
source_ref = _source_ref(track_ref)
|
||||
|
||||
# ---- 1. 幂等:这个单据号已经受理过 → 直接回已有的那条 ----
|
||||
existing = (
|
||||
await db.execute(
|
||||
select(ProductScrap).where(ProductScrap.source_ref == source_ref)
|
||||
)
|
||||
).scalars().first()
|
||||
if existing is not None:
|
||||
logger.info(f"[ProductScrap] track_ref 重复提交,返回已有记录 {source_ref}")
|
||||
return (await _enrich_with_mom([existing]))[0]
|
||||
|
||||
# ---- 2. 归属校验 + 取快照(快照只信后端自己查到的,不信前端传的) ----
|
||||
material = await _load_mounted_material(db, product_id, int(mom_line_id))
|
||||
|
||||
# ---- 3. 申请人:当前登录人。Track 的 sub 就是 MOM sys_user.id,
|
||||
# 所以 MOM 里显示的申请人就是本人,不需要服务账号、也不会串人 ----
|
||||
try:
|
||||
applicant_id = int(current_user.get("sub"))
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(status_code=401, detail="登录状态异常,请重新登录")
|
||||
|
||||
operator = current_user.get("display_name") or current_user.get("username") or "Track系统"
|
||||
|
||||
# ---- 4. 调 MOM(唯一的写通道,失败直接抛,不静默吞) ----
|
||||
from app.services.mom_scrap_client import MomScrapError, submit_production_scrap
|
||||
|
||||
try:
|
||||
data = await submit_production_scrap(
|
||||
outbound_id=int(mom_line_id),
|
||||
return_qty=float(quantity),
|
||||
track_ref=track_ref,
|
||||
applicant_id=applicant_id,
|
||||
reason=reason,
|
||||
operator=operator,
|
||||
)
|
||||
except MomScrapError as e:
|
||||
# MOM 的文案已经是中文且具体,直接转给用户
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=e.message)
|
||||
|
||||
scrap_info = data.get('scrap') or {}
|
||||
request_no = scrap_info.get('request_no')
|
||||
if not request_no:
|
||||
# MOM 回 200 却没给单号 = 契约被破坏,必须炸出来而不是存一条空记录
|
||||
logger.error(f"[ProductScrap] MOM 返回缺少 scrap.request_no: {data}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail="MOM 已受理但未返回报废单号,请到 MOM 报废审批页确认",
|
||||
)
|
||||
|
||||
# ---- 5. 落库 ----
|
||||
row = ProductScrap(
|
||||
product_id=product_id,
|
||||
serial_number=product.serial_number,
|
||||
task_id=material.task_id,
|
||||
mom_line_id=int(mom_line_id),
|
||||
# 快照取自 Track 已挂的出库物料(当初由后端查 MOM 写入),不是前端传的
|
||||
outbound_no=material.outbound_no,
|
||||
material_name=material.material_name,
|
||||
spec_model=material.spec_model,
|
||||
sku=material.sku,
|
||||
consumer_name=material.consumer_name,
|
||||
quantity=quantity,
|
||||
reason_category=SCRAP_CATEGORY_PRODUCTION,
|
||||
reason=(reason or '').strip() or None,
|
||||
scrap_request_no=request_no,
|
||||
defective_goods_id=data.get('defective_goods_id'),
|
||||
mom_status=int(scrap_info.get('status') or 0),
|
||||
source_ref=source_ref,
|
||||
submitted_by=current_user.get("username"),
|
||||
)
|
||||
db.add(row)
|
||||
try:
|
||||
await db.commit()
|
||||
except IntegrityError:
|
||||
# 并发穿透了第 1 步的预检 —— 唯一约束兜底,回滚后返回已有那条
|
||||
await db.rollback()
|
||||
existing = (
|
||||
await db.execute(
|
||||
select(ProductScrap).where(ProductScrap.source_ref == source_ref)
|
||||
)
|
||||
).scalars().first()
|
||||
if existing is not None:
|
||||
return (await _enrich_with_mom([existing]))[0]
|
||||
raise
|
||||
|
||||
await db.refresh(row)
|
||||
logger.info(
|
||||
f"[ProductScrap] 提交成功 {request_no} product={product.serial_number} "
|
||||
f"line={mom_line_id} qty={quantity} by={row.submitted_by}"
|
||||
)
|
||||
return (await _enrich_with_mom([row]))[0]
|
||||
@ -51,6 +51,13 @@ services:
|
||||
MOM_DB_PORT: "5432"
|
||||
# 🚀 MOM 仓储系统回调 Webhook 验签 Key(与 MOM 侧 TRACK_WEBHOOK_KEY 保持一致)
|
||||
TRACK_WEBHOOK_KEY: 2ce5fedb48fde3fd7e0abf67472a5027b03e9ae6f19cf768
|
||||
# 🚀 MOM 内部接口 —— Track **主动调 MOM** 发起生产报废(唯一一处主动写 MOM)
|
||||
# ⚠️ 与上面的 TRACK_WEBHOOK_KEY 是**两把不同的钥匙**:
|
||||
# 上面那把是「MOM 发给 Track 时的验签凭证」,这把是「Track 发给 MOM 的凭证」,
|
||||
# 方向相反、权限不同(这把能发起报废审批),必须能独立轮换。
|
||||
# ⚠️ 未配置 → 报废提交直接 503,**不静默降级**(写操作静默失败最伤人)
|
||||
MOM_INTERNAL_API_URL: http://inventory_api:8000
|
||||
MOM_INTERNAL_API_KEY: ${MOM_INTERNAL_API_KEY:-}
|
||||
ports:
|
||||
- "8011:8000"
|
||||
volumes:
|
||||
|
||||
Reference in New Issue
Block a user