diff --git a/docker-compose.yml b/docker-compose.yml index 3969324..f1f13a2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -34,6 +34,13 @@ services: MAIL_DEFAULT_SENDER: wms@iris-rs.cn MAIL_USE_SSL: "true" MAIL_USE_TLS: "false" + # Track 系统 Webhook 通知(未配置则后端静默跳过,不通知) + TRACK_WEBHOOK_URL: ${TRACK_WEBHOOK_URL:-} + TRACK_WEBHOOK_KEY: ${TRACK_WEBHOOK_KEY:-} + # Track 系统查询接口(扫码入库拉取产品信息) + TRACK_API_URL: ${TRACK_API_URL:-} + # Track 出库 webhook(发货出库时通知 Track 标记"已出库") + TRACK_OUTBOUND_WEBHOOK_URL: ${TRACK_OUTBOUND_WEBHOOK_URL:-} depends_on: - db diff --git a/inventory-backend/app/api/v1/inbound/product.py b/inventory-backend/app/api/v1/inbound/product.py index 4b55cd7..c7062a7 100644 --- a/inventory-backend/app/api/v1/inbound/product.py +++ b/inventory-backend/app/api/v1/inbound/product.py @@ -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('/', methods=['PUT']) @permission_required('inbound_product:operation') @audit_log( diff --git a/inventory-backend/app/api/v1/inbound/semi.py b/inventory-backend/app/api/v1/inbound/semi.py index 4aaed8e..faa7e58 100644 --- a/inventory-backend/app/api/v1/inbound/semi.py +++ b/inventory-backend/app/api/v1/inbound/semi.py @@ -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('/', methods=['PUT']) @permission_required('inbound_semi:operation') @audit_log( diff --git a/inventory-backend/app/services/inbound/product_service.py b/inventory-backend/app/services/inbound/product_service.py index e723d47..99a7db7 100644 --- a/inventory-backend/app/services/inbound/product_service.py +++ b/inventory-backend/app/services/inbound/product_service.py @@ -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() diff --git a/inventory-backend/app/services/inbound/semi_service.py b/inventory-backend/app/services/inbound/semi_service.py index 48b0510..c169738 100644 --- a/inventory-backend/app/services/inbound/semi_service.py +++ b/inventory-backend/app/services/inbound/semi_service.py @@ -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() diff --git a/inventory-backend/app/services/track_query_service.py b/inventory-backend/app/services/track_query_service.py new file mode 100644 index 0000000..4219919 --- /dev/null +++ b/inventory-backend/app/services/track_query_service.py @@ -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 diff --git a/inventory-backend/app/services/track_webhook_service.py b/inventory-backend/app/services/track_webhook_service.py new file mode 100644 index 0000000..4703697 --- /dev/null +++ b/inventory-backend/app/services/track_webhook_service.py @@ -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) diff --git a/inventory-backend/config.py b/inventory-backend/config.py index 7b37d16..b23cd7c 100644 --- a/inventory-backend/config.py +++ b/inventory-backend/config.py @@ -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', '') diff --git a/inventory-web/src/api/inbound/product.ts b/inventory-web/src/api/inbound/product.ts index 73b602a..3443def 100644 --- a/inventory-web/src/api/inbound/product.ts +++ b/inventory-web/src/api/inbound/product.ts @@ -84,4 +84,13 @@ export function calculateBomCost(params: {bom_code: string, bom_version: string} method: 'get', params }) +} + +// [新增] Track 扫码入库查询:根据身份证查询产品信息并匹配物料 +export function lookupTrackProduct(code: string) { + return request({ + url: '/inbound/product/track-lookup', + method: 'get', + params: { code } + }) } \ No newline at end of file diff --git a/inventory-web/src/api/inbound/semi.ts b/inventory-web/src/api/inbound/semi.ts index 061130f..053d952 100644 --- a/inventory-web/src/api/inbound/semi.ts +++ b/inventory-web/src/api/inbound/semi.ts @@ -86,4 +86,13 @@ export function calculateBomCost(params: {bom_code: string, bom_version: string} method: 'get', params }) +} + +// [新增] Track 扫码入库查询:根据身份证查询产品信息并匹配物料 +export function lookupTrackProduct(code: string) { + return request({ + url: '/inbound/semi/track-lookup', + method: 'get', + params: { code } + }) } \ No newline at end of file diff --git a/inventory-web/src/components/TrackScanDialog.vue b/inventory-web/src/components/TrackScanDialog.vue new file mode 100644 index 0000000..c24c737 --- /dev/null +++ b/inventory-web/src/components/TrackScanDialog.vue @@ -0,0 +1,205 @@ + + + + + diff --git a/inventory-web/src/views/stock/inbound/product.vue b/inventory-web/src/views/stock/inbound/product.vue index c14ea17..4866fb5 100644 --- a/inventory-web/src/views/stock/inbound/product.vue +++ b/inventory-web/src/views/stock/inbound/product.vue @@ -265,6 +265,18 @@
+ +
+ + + 扫码入库 + + + + 扫描 Track 产品身份证,自动带出物料与序列号 + +
+
@@ -579,6 +591,8 @@ + +
@@ -604,6 +618,7 @@ import { uploadFile, deleteFile } from '@/api/inbound/buy' import WebRtcCamera from '@/components/Camera/WebRtcCamera.vue' import WarehouseSelector from '@/components/WarehouseSelector.vue' import SmartScannerDialog from '@/components/SmartScannerDialog.vue' +import TrackScanDialog from '@/components/TrackScanDialog.vue' import { getLabelPreview, executePrint } from '@/api/common/print' import { getWarehouseTree } from '@/api/common/warehouse' import { usePasteUpload } from '@/hooks/usePasteUpload' @@ -741,6 +756,8 @@ const inspection_url = ref('') // 智能扫码弹窗 const scannerDialogVisible = ref(false) +// 顶部扫码入库面板 (Track 身份证自动填充物料+序列号+订单号) +const trackScanVisible = ref(false) // 库位级联选择器数据 const warehouseOptions = ref([]) @@ -1377,7 +1394,7 @@ const handleCameraConfirm = async (file: File) => { } }; -// 智能扫码 +// 智能扫码 (序列号旁,仅填序列号) const openScanner = () => { scannerDialogVisible.value = true } @@ -1388,6 +1405,15 @@ const handleScannerConfirm = (result: string) => { ElMessage.success('序列号已提取') } +// Track 扫码入库:扫码面板扫到身份证后,自动带出物料基础信息 + 序列号 + 订单号 +const handleTrackScanFill = async (data: { track: any; material: any }) => { + const { track, material } = data + await onMaterialSelected(material) + form.serial_number = track.serial_number + if (track.order_no) form.order_id = track.order_no + ElMessage.success('扫码入库信息已自动填充,请核对后补充库位/数量等') +} + // 快速基于此物料创建 BOM const createBomForMaterial = () => { if (!form.base_id) return ElMessage.warning('请先锁定物料基础信息') @@ -1525,6 +1551,10 @@ watch([() => form.unit_total_cost, () => form.in_quantity], ([unit, qty]) => { :deep(.table-header-gray th) { background-color: #f8f9fb !important; color: #606266; } .tag-sn { color: #409EFF; font-weight: bold; font-family: monospace; } .stock-num { font-weight: bold; font-size: 15px; } +/* 扫码入库横幅 */ +.scan-inbound-banner { background: linear-gradient(90deg, #ecf5ff, #f0f9eb); border: 1px dashed #409EFF; border-radius: 8px; padding: 14px 20px; margin-bottom: 20px; display: flex; align-items: center; gap: 14px; } +.scan-banner-tip { font-size: 13px; color: #606266; display: flex; align-items: center; gap: 4px; } + .form-card { background: #fff; border-radius: 8px; margin-bottom: 20px; border: 1px solid #e4e7ed; overflow: hidden; } .card-title { background: #fcfcfc; padding: 10px 20px; border-bottom: 1px solid #ebeef5; font-weight: 600; display: flex; align-items: center; gap: 8px; } .card-title .icon { font-size: 18px; } diff --git a/inventory-web/src/views/stock/inbound/semi.vue b/inventory-web/src/views/stock/inbound/semi.vue index 282b505..6594c9c 100644 --- a/inventory-web/src/views/stock/inbound/semi.vue +++ b/inventory-web/src/views/stock/inbound/semi.vue @@ -298,6 +298,18 @@
+ +
+ + + 扫码入库 + + + + 扫描 Track 产品身份证,自动带出物料与序列号 + +
+
@@ -623,6 +635,8 @@ + +
@@ -648,6 +662,7 @@ import { uploadFile, deleteFile } from '@/api/inbound/buy' import WebRtcCamera from '@/components/Camera/WebRtcCamera.vue' import WarehouseSelector from '@/components/WarehouseSelector.vue' import SmartScannerDialog from '@/components/SmartScannerDialog.vue' +import TrackScanDialog from '@/components/TrackScanDialog.vue' import {getLabelPreview, executePrint} from '@/api/common/print' import { getWarehouseTree } from '@/api/common/warehouse' import { usePasteUpload } from '@/hooks/usePasteUpload' @@ -808,6 +823,8 @@ const quality_report_url = ref('') // 智能扫码弹窗 const scannerDialogVisible = ref(false) +// 顶部扫码入库面板 (Track 身份证自动填充物料+序列号) +const trackScanVisible = ref(false) // 库位级联选择器数据 const warehouseOptions = ref([]) @@ -1470,7 +1487,7 @@ const handleCameraConfirm = async (file: File) => { } }; -// 智能扫码 +// 智能扫码 (序列号旁,仅填序列号) const openScanner = () => { scannerDialogVisible.value = true } @@ -1481,6 +1498,14 @@ const handleScannerConfirm = (result: string) => { ElMessage.success('序列号已提取') } +// Track 扫码入库:扫码面板扫到身份证后,自动带出物料基础信息 + 序列号 +const handleTrackScanFill = async (data: { track: any; material: any }) => { + const { track, material } = data + await onMaterialSelected(material) + form.serial_number = track.serial_number + ElMessage.success('扫码入库信息已自动填充,请核对后补充库位/数量等') +} + // 快速基于此物料创建 BOM const createBomForMaterial = () => { if (!form.base_id) return ElMessage.warning('请先锁定物料基础信息') @@ -1625,6 +1650,10 @@ onMounted(() => { /* [修改] 增加 min-height */ .dialog-scroll-container { padding: 20px; max-height: 70vh; overflow-y: auto; overflow-x: hidden; min-height: 450px; } +/* 扫码入库横幅 */ +.scan-inbound-banner { background: linear-gradient(90deg, #ecf5ff, #f0f9eb); border: 1px dashed #409EFF; border-radius: 8px; padding: 14px 20px; margin-bottom: 20px; display: flex; align-items: center; gap: 14px; } +.scan-banner-tip { font-size: 13px; color: #606266; display: flex; align-items: center; gap: 4px; } + .stylish-form .form-card { background: #fff; border-radius: 8px; border: 1px solid #e4e7ed; margin-bottom: 20px; } .card-title { background: #fcfcfc; padding: 12px 20px; border-bottom: 1px solid #ebeef5; font-weight: 600; font-size: 15px; color: #303133; display: flex; align-items: center; } .card-title .icon { margin-right: 8px; font-size: 18px; color: #409EFF; }