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
|