料领到产线后在生产中报废,要在 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,
而不是让一个写接口在生产上默默开着。
137 lines
7.1 KiB
Python
137 lines
7.1 KiB
Python
"""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
|