Files
track/backend/app/services/mom_scrap_client.py
duxingchen 551819e0e3 feat(scrap): Track 侧生产报废 —— 提交、回查、金额
料领到产线后在生产中报废,要在 MOM 里走报废流程并能统计金额。

- mom_scrap_client:Track **唯一**一处主动写 MOM 的通道。读仍走直连只读库
  (MOM 查询接口有权限与行级隔离),写必须走接口(跨库直写会绕过 MOM 的
  全部业务校验、权限与审批)。
- product_scrap_service:归属校验是关键 —— 可见范围是整台设备、不是「谁领的」,
  不能靠隐藏来防,必须在写入前确认这条 mom_line_id 就挂在这台设备上。
  申请人直接用当前登录人(Track 的 sub 就是 MOM sys_user.id),
  MOM 里显示的就是本人,不需要服务账号也不会串人。
- 幂等:track_ref 由前端在打开弹层时生成一次、重试复用;网络超时后重试
  不该在 MOM 里多报一张单。
- 状态与金额**实时回查 MOM**,不在本地存副本:报废没有回调,本地那份立刻
  就过期;且金额取决于执行时的实际扫码量(MOM 允许少扫),受理量 ≠ 执行量。
  ⚠️ 未执行时 total_loss 是 null 不是 0 —— 0 会让人以为「这东西不值钱」。
- MOM_INTERNAL_API_KEY 走环境变量且不给默认值:未配置时报废提交 503,
  而不是让一个写接口在生产上默默开着。
2026-09-23 15:18:06 +08:00

137 lines
7.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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