feat: MOM 扫码入库对接 Track 产品身份证自动填充
- 半成品/成品入库弹窗顶部新增'扫码入库'按钮,打开扫码面板(扫码枪/摄像头) - 扫码 Track 身份证后自动填充物料信息与序列号,不再二次搜索 - 后端新增 track_query_service(httpx 调 Track lookup)、track-lookup 接口 - 入库成功后 notify_track 通知 Track 闭环
This commit is contained in:
@ -2,6 +2,8 @@
|
||||
from flask import Blueprint, request, jsonify
|
||||
from app.services.inbound.product_service import ProductInboundService
|
||||
from app.utils.decorators import permission_required, audit_log
|
||||
from app.models.base import MaterialBase
|
||||
from app.services.track_query_service import lookup_product
|
||||
import traceback
|
||||
|
||||
# === 这一行非常关键,绝对不能丢!===
|
||||
@ -136,6 +138,57 @@ def submit():
|
||||
traceback.print_exc()
|
||||
return jsonify({"code": 500, "msg": str(e)}), 500
|
||||
|
||||
@inbound_product_bp.route('/track-lookup', methods=['GET'])
|
||||
@permission_required('inbound_product')
|
||||
def track_lookup():
|
||||
"""扫码入库:根据 Track 身份证查询产品信息,并匹配 MOM 物料库"""
|
||||
try:
|
||||
code = (request.args.get('code') or '').strip()
|
||||
if not code:
|
||||
return jsonify({"code": 400, "msg": "code 参数不能为空"}), 400
|
||||
|
||||
track_info = lookup_product(code)
|
||||
if not track_info:
|
||||
return jsonify({"code": 404, "msg": "未在 Track 系统中找到该身份证对应的产品"}), 404
|
||||
|
||||
material_id = track_info.get('material_id')
|
||||
material = None
|
||||
if material_id:
|
||||
try:
|
||||
material_id = int(material_id)
|
||||
except (TypeError, ValueError):
|
||||
material_id = None
|
||||
if material_id:
|
||||
material = MaterialBase.query.filter(
|
||||
MaterialBase.id == material_id,
|
||||
MaterialBase.is_enabled == True
|
||||
).first()
|
||||
if material is None:
|
||||
return jsonify({
|
||||
"code": 404,
|
||||
"msg": "扫码产品的物料在 MOM 物料库中不存在,请先在【基础信息】中完善该物料后再入库"
|
||||
}), 404
|
||||
|
||||
return jsonify({
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"track": track_info,
|
||||
"material": {
|
||||
"id": material.id,
|
||||
"company_name": material.company_name,
|
||||
"name": material.name,
|
||||
"spec": material.spec_model,
|
||||
"category": material.category,
|
||||
"unit": material.unit,
|
||||
"type": material.material_type,
|
||||
}
|
||||
}
|
||||
})
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return jsonify({"code": 500, "msg": str(e)}), 500
|
||||
|
||||
@inbound_product_bp.route('/<int:id>', methods=['PUT'])
|
||||
@permission_required('inbound_product:operation')
|
||||
@audit_log(
|
||||
|
||||
@ -2,6 +2,8 @@
|
||||
from flask import Blueprint, request, jsonify
|
||||
from app.services.inbound.semi_service import SemiInboundService
|
||||
from app.utils.decorators import permission_required, audit_log
|
||||
from app.models.base import MaterialBase
|
||||
from app.services.track_query_service import lookup_product
|
||||
import traceback
|
||||
|
||||
# === 这一行非常关键,绝对不能丢!===
|
||||
@ -130,6 +132,57 @@ def submit():
|
||||
traceback.print_exc()
|
||||
return jsonify({"code": 500, "msg": str(e)}), 500
|
||||
|
||||
@inbound_semi_bp.route('/track-lookup', methods=['GET'])
|
||||
@permission_required('inbound_semi')
|
||||
def track_lookup():
|
||||
"""扫码入库:根据 Track 身份证查询产品信息,并匹配 MOM 物料库"""
|
||||
try:
|
||||
code = (request.args.get('code') or '').strip()
|
||||
if not code:
|
||||
return jsonify({"code": 400, "msg": "code 参数不能为空"}), 400
|
||||
|
||||
track_info = lookup_product(code)
|
||||
if not track_info:
|
||||
return jsonify({"code": 404, "msg": "未在 Track 系统中找到该身份证对应的产品"}), 404
|
||||
|
||||
material_id = track_info.get('material_id')
|
||||
material = None
|
||||
if material_id:
|
||||
try:
|
||||
material_id = int(material_id)
|
||||
except (TypeError, ValueError):
|
||||
material_id = None
|
||||
if material_id:
|
||||
material = MaterialBase.query.filter(
|
||||
MaterialBase.id == material_id,
|
||||
MaterialBase.is_enabled == True
|
||||
).first()
|
||||
if material is None:
|
||||
return jsonify({
|
||||
"code": 404,
|
||||
"msg": "扫码产品的物料在 MOM 物料库中不存在,请先在【基础信息】中完善该物料后再入库"
|
||||
}), 404
|
||||
|
||||
return jsonify({
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"track": track_info,
|
||||
"material": {
|
||||
"id": material.id,
|
||||
"company_name": material.company_name,
|
||||
"name": material.name,
|
||||
"spec": material.spec_model,
|
||||
"category": material.category,
|
||||
"unit": material.unit,
|
||||
"type": material.material_type,
|
||||
}
|
||||
}
|
||||
})
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return jsonify({"code": 500, "msg": str(e)}), 500
|
||||
|
||||
@inbound_semi_bp.route('/<int:id>', methods=['PUT'])
|
||||
@permission_required('inbound_semi:operation')
|
||||
@audit_log(
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
|
||||
63
inventory-backend/app/services/track_query_service.py
Normal file
63
inventory-backend/app/services/track_query_service.py
Normal 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
|
||||
78
inventory-backend/app/services/track_webhook_service.py
Normal file
78
inventory-backend/app/services/track_webhook_service.py
Normal 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)
|
||||
@ -69,3 +69,19 @@ class Config:
|
||||
MAIL_DEFAULT_SENDER = os.getenv('MAIL_DEFAULT_SENDER', 'wms@iris-rs.cn')
|
||||
# 是否启用邮件发送功能(开发环境可设为 false 禁用)
|
||||
MAIL_ENABLED = os.getenv('MAIL_ENABLED', 'true').lower() in ('true', '1', 'yes')
|
||||
|
||||
# =========================================================
|
||||
# 7. Track 系统 Webhook 通知配置 (发送侧)
|
||||
# =========================================================
|
||||
# Track 系统 Webhook 接收地址(未配置则静默跳过,不通知)
|
||||
TRACK_WEBHOOK_URL = os.getenv('TRACK_WEBHOOK_URL', '')
|
||||
# 与 Track 接收端严格对齐的 API Key(请求头字段名必须为 X-API-Key)
|
||||
TRACK_WEBHOOK_KEY = os.getenv('TRACK_WEBHOOK_KEY', '')
|
||||
|
||||
# =========================================================
|
||||
# 8. Track 系统查询接口配置 (拉取侧)
|
||||
# =========================================================
|
||||
# Track 后端基础地址(扫码入库时用 httpx 查询产品信息)
|
||||
TRACK_API_URL = os.getenv('TRACK_API_URL', 'http://track_backend:8000')
|
||||
# Track 出库 webhook 接收地址(发货出库时通知 Track 标记"已出库")
|
||||
TRACK_OUTBOUND_WEBHOOK_URL = os.getenv('TRACK_OUTBOUND_WEBHOOK_URL', '')
|
||||
|
||||
Reference in New Issue
Block a user