From 551819e0e3ddd253433193e0f7036e9b11d98647 Mon Sep 17 00:00:00 2001 From: duxingchen Date: Wed, 23 Sep 2026 15:18:06 +0800 Subject: [PATCH] =?UTF-8?q?feat(scrap):=20Track=20=E4=BE=A7=E7=94=9F?= =?UTF-8?q?=E4=BA=A7=E6=8A=A5=E5=BA=9F=20=E2=80=94=E2=80=94=20=E6=8F=90?= =?UTF-8?q?=E4=BA=A4=E3=80=81=E5=9B=9E=E6=9F=A5=E3=80=81=E9=87=91=E9=A2=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 料领到产线后在生产中报废,要在 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, 而不是让一个写接口在生产上默默开着。 --- .../p1q2r3s4t5u6_add_product_scraps.py | 120 +++++++++ backend/app/core/config.py | 33 +++ backend/app/models/product_scrap.py | 136 ++++++++++ backend/app/services/mom_scrap_client.py | 136 ++++++++++ backend/app/services/mom_scrap_service.py | 159 +++++++++++ backend/app/services/product_scrap_service.py | 248 ++++++++++++++++++ docker-compose.yml | 7 + 7 files changed, 839 insertions(+) create mode 100644 backend/alembic/versions/p1q2r3s4t5u6_add_product_scraps.py create mode 100644 backend/app/models/product_scrap.py create mode 100644 backend/app/services/mom_scrap_client.py create mode 100644 backend/app/services/mom_scrap_service.py create mode 100644 backend/app/services/product_scrap_service.py diff --git a/backend/alembic/versions/p1q2r3s4t5u6_add_product_scraps.py b/backend/alembic/versions/p1q2r3s4t5u6_add_product_scraps.py new file mode 100644 index 0000000..816b86f --- /dev/null +++ b/backend/alembic/versions/p1q2r3s4t5u6_add_product_scraps.py @@ -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: + 幂等锚点,格式 <公司>:,与发给 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="幂等锚点 <公司>:,随请求发给 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") diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 4fef81d..58b4299 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -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 使用""" diff --git a/backend/app/models/product_scrap.py b/backend/app/models/product_scrap.py new file mode 100644 index 0000000..5e9135e --- /dev/null +++ b/backend/app/models/product_scrap.py @@ -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="幂等锚点 <公司>:,随请求发给 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"") diff --git a/backend/app/services/mom_scrap_client.py b/backend/app/services/mom_scrap_client.py new file mode 100644 index 0000000..d209052 --- /dev/null +++ b/backend/app/services/mom_scrap_client.py @@ -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 diff --git a/backend/app/services/mom_scrap_service.py b/backend/app/services/mom_scrap_service.py new file mode 100644 index 0000000..b56718e --- /dev/null +++ b/backend/app/services/mom_scrap_service.py @@ -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 diff --git a/backend/app/services/product_scrap_service.py b/backend/app/services/product_scrap_service.py new file mode 100644 index 0000000..047b9b4 --- /dev/null +++ b/backend/app/services/product_scrap_service.py @@ -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: + """幂等锚点:`<公司>:`,与发给 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] diff --git a/docker-compose.yml b/docker-compose.yml index 723d7e8..0c39487 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: