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