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.idMOM 侧数据被清理时
就查不到了;且列表页若每条都跨库查,慢且脆。快照让「报了什么」永远看得见。
为什么**不**存金额:
金额由 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")

View File

@ -33,6 +33,39 @@ class Settings(BaseSettings):
# ---- MOM 仓储系统回调 WebhookTrack 作为接收方,验签用) ----
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 一致。
# ⚠️ 未配置 → 报废提交直接 503Fail-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 使用"""

View 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}>")

View 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 返回非 JSONHTTP {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

View 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/NoneJSON 友好)"""
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

View 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]

View File

@ -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: