feat: MOM 扫码入库对接 Track 产品身份证自动填充

- 半成品/成品入库弹窗顶部新增'扫码入库'按钮,打开扫码面板(扫码枪/摄像头)
- 扫码 Track 身份证后自动填充物料信息与序列号,不再二次搜索
- 后端新增 track_query_service(httpx 调 Track lookup)、track-lookup 接口
- 入库成功后 notify_track 通知 Track 闭环
This commit is contained in:
yueli
2026-09-01 13:52:56 +08:00
parent 9bc3c94dd1
commit bee037f925
13 changed files with 576 additions and 2 deletions

View File

@ -13,6 +13,7 @@ import json
import numpy as np
from app.utils.ai_vision import extract_and_embed
from app.services.image_embedding_service import ImageEmbeddingService
from app.services.track_webhook_service import notify_track, get_current_operator
class ProductInboundService:
@ -212,6 +213,16 @@ class ProductInboundService:
new_stock.id,
photo_list
)
# 真实扫码入库成功,异步通知 Track 系统Webhook
notify_track({
'event': 'inbound.created',
'source_table': 'stock_product',
'sku': material.spec_model or material.name,
'serial_number': new_stock.serial_number,
'quantity': float(new_stock.in_quantity or 0),
'operator': get_current_operator(),
})
return new_stock
except Exception as e:
db.session.rollback()

View File

@ -13,6 +13,7 @@ import json
import numpy as np
from app.utils.ai_vision import extract_and_embed
from app.services.image_embedding_service import ImageEmbeddingService
from app.services.track_webhook_service import notify_track, get_current_operator
class SemiInboundService:
@ -249,6 +250,16 @@ class SemiInboundService:
new_stock.id,
arrival_list
)
# 真实扫码入库成功,异步通知 Track 系统Webhook
notify_track({
'event': 'inbound.created',
'source_table': 'stock_semi',
'sku': material.spec_model or material.name,
'serial_number': new_stock.serial_number,
'quantity': float(new_stock.in_quantity or 0),
'operator': get_current_operator(),
})
return new_stock
except Exception as e:
db.session.rollback()

View File

@ -0,0 +1,63 @@
# app/services/track_query_service.py
"""
Track 系统产品查询服务(拉取侧)
扫码入库时,通过 httpx GET 调用 Track 的对外查询接口 /api/v1/external/products/lookup
根据身份证serial_number获取产品基础信息。
容错策略:
- 未配置 TRACK_API_URL / TRACK_WEBHOOK_KEY 时返回 None。
- 全包裹 try-except任何异常连接拒绝/超时/网络错误/4xx/5xx仅记录日志
返回 None 交给上层决定如何提示,绝不抛出异常阻断业务。
"""
import logging
import httpx
from flask import current_app
logger = logging.getLogger(__name__)
# 请求超时(秒):查询属于同步交互,超时后由上层提示失败
_QUERY_TIMEOUT = 5.0
def lookup_product(code):
"""
根据身份证16 位 serial_number或业务序列号查询 Track 产品信息。
返回 Track 查询接口的 data 字典(含 serial_number/external_serial/material_id/
sku/material_name/spec_model/material_type/order_no未命中或异常返回 None。
"""
try:
base_url = (current_app.config.get('TRACK_API_URL') or '').strip()
api_key = (current_app.config.get('TRACK_WEBHOOK_KEY') or '').strip()
if not base_url:
logger.info("[TrackQuery] 未配置 TRACK_API_URL跳过查询")
return None
if not api_key:
logger.info("[TrackQuery] 未配置 TRACK_WEBHOOK_KEY跳过查询")
return None
url = f'{base_url}/api/v1/external/products/lookup'
headers = {
'Content-Type': 'application/json',
# ★ 鉴权字段名必须为 X-API-Key与 Track 接收端严格对齐
'X-API-Key': api_key,
}
resp = httpx.get(url, params={'code': code}, headers=headers, timeout=_QUERY_TIMEOUT)
if resp.status_code == 200:
data = resp.json()
track_info = (data or {}).get('data')
if track_info:
logger.info("[TrackQuery] 查询命中 code=%s", code)
return track_info
logger.info("[TrackQuery] 查询返回空数据 code=%s status=%s", code, resp.status_code)
return None
logger.info(
"[TrackQuery] 查询未命中 code=%s status=%s body=%s",
code, resp.status_code, resp.text[:200],
)
return None
except Exception as e:
logger.error("[TrackQuery] 查询失败 code=%s, err=%s", code, e)
return None

View File

@ -0,0 +1,78 @@
# app/services/track_webhook_service.py
"""
Track 系统 Webhook 通知服务(发送侧)
真实扫码入库成功后,通过后台线程 + httpx 异步 POST 通知 Track 系统。
容错策略:
- 未配置 TRACK_WEBHOOK_URL 时静默跳过,不通知。
- 全包裹 try-except任何异常连接拒绝/超时/网络错误)仅记录 logger.error
绝不抛出异常阻断入库主业务流程。
"""
import logging
import threading
import httpx
from flask import current_app
logger = logging.getLogger(__name__)
# 请求超时(秒):后台线程,宁可超时失败也不能拖住业务
_WEBHOOK_TIMEOUT = 5.0
def _post_to_track(payload, url, api_key):
"""后台线程中执行的真正 POST 请求(含详细日志)"""
headers = {
'Content-Type': 'application/json',
# ★ 鉴权字段名必须为 X-API-Key与 Track 接收端严格对齐
'X-API-Key': api_key,
}
try:
resp = httpx.post(url, json=payload, headers=headers, timeout=_WEBHOOK_TIMEOUT)
logger.info(
"[TrackWebhook] event=%s source_table=%s status=%s",
payload.get('event'), payload.get('source_table'), resp.status_code,
)
except Exception as e:
logger.error("[TrackWebhook] 发送失败 url=%s, err=%s", url, e)
def get_current_operator():
"""从 JWT 中安全获取当前操作人姓名(失败返回空字符串,不抛异常)"""
try:
from flask_jwt_extended import get_jwt
claims = get_jwt()
if not claims:
return ''
# 优先显示名(如"张三"回退到账号ID
return claims.get('display_name') or claims.get('username') or ''
except Exception:
return ''
def notify_track(payload, url=None):
"""
异步通知 Track 系统业务事件(入库/出库等)。
调用方在 db.session.commit() 成功后调用,本函数绝不抛出异常:
- 未配置 URL / Key 为空 -> 静默跳过
- 网络异常 / 超时 -> 仅记录日志
:param url: 指定 Track webhook 地址;不传则用默认 TRACK_WEBHOOK_URL入库
"""
try:
url = (url or current_app.config.get('TRACK_WEBHOOK_URL') or '').strip()
api_key = (current_app.config.get('TRACK_WEBHOOK_KEY') or '').strip()
if not url:
logger.info("[TrackWebhook] 未配置 webhook URL跳过通知")
return
thread = threading.Thread(
target=_post_to_track,
args=(payload, url, api_key),
daemon=True, # 守护线程:不阻塞进程退出
name='track_webhook_notify',
)
thread.start()
except Exception as e:
logger.error("[TrackWebhook] 通知调用失败(不影响业务): %s", e)