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

@ -34,6 +34,13 @@ services:
MAIL_DEFAULT_SENDER: wms@iris-rs.cn MAIL_DEFAULT_SENDER: wms@iris-rs.cn
MAIL_USE_SSL: "true" MAIL_USE_SSL: "true"
MAIL_USE_TLS: "false" 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: depends_on:
- db - db

View File

@ -2,6 +2,8 @@
from flask import Blueprint, request, jsonify from flask import Blueprint, request, jsonify
from app.services.inbound.product_service import ProductInboundService from app.services.inbound.product_service import ProductInboundService
from app.utils.decorators import permission_required, audit_log 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 import traceback
# === 这一行非常关键,绝对不能丢!=== # === 这一行非常关键,绝对不能丢!===
@ -136,6 +138,57 @@ def submit():
traceback.print_exc() traceback.print_exc()
return jsonify({"code": 500, "msg": str(e)}), 500 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']) @inbound_product_bp.route('/<int:id>', methods=['PUT'])
@permission_required('inbound_product:operation') @permission_required('inbound_product:operation')
@audit_log( @audit_log(

View File

@ -2,6 +2,8 @@
from flask import Blueprint, request, jsonify from flask import Blueprint, request, jsonify
from app.services.inbound.semi_service import SemiInboundService from app.services.inbound.semi_service import SemiInboundService
from app.utils.decorators import permission_required, audit_log 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 import traceback
# === 这一行非常关键,绝对不能丢!=== # === 这一行非常关键,绝对不能丢!===
@ -130,6 +132,57 @@ def submit():
traceback.print_exc() traceback.print_exc()
return jsonify({"code": 500, "msg": str(e)}), 500 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']) @inbound_semi_bp.route('/<int:id>', methods=['PUT'])
@permission_required('inbound_semi:operation') @permission_required('inbound_semi:operation')
@audit_log( @audit_log(

View File

@ -13,6 +13,7 @@ import json
import numpy as np import numpy as np
from app.utils.ai_vision import extract_and_embed from app.utils.ai_vision import extract_and_embed
from app.services.image_embedding_service import ImageEmbeddingService from app.services.image_embedding_service import ImageEmbeddingService
from app.services.track_webhook_service import notify_track, get_current_operator
class ProductInboundService: class ProductInboundService:
@ -212,6 +213,16 @@ class ProductInboundService:
new_stock.id, new_stock.id,
photo_list 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 return new_stock
except Exception as e: except Exception as e:
db.session.rollback() db.session.rollback()

View File

@ -13,6 +13,7 @@ import json
import numpy as np import numpy as np
from app.utils.ai_vision import extract_and_embed from app.utils.ai_vision import extract_and_embed
from app.services.image_embedding_service import ImageEmbeddingService from app.services.image_embedding_service import ImageEmbeddingService
from app.services.track_webhook_service import notify_track, get_current_operator
class SemiInboundService: class SemiInboundService:
@ -249,6 +250,16 @@ class SemiInboundService:
new_stock.id, new_stock.id,
arrival_list 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 return new_stock
except Exception as e: except Exception as e:
db.session.rollback() 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)

View File

@ -69,3 +69,19 @@ class Config:
MAIL_DEFAULT_SENDER = os.getenv('MAIL_DEFAULT_SENDER', 'wms@iris-rs.cn') MAIL_DEFAULT_SENDER = os.getenv('MAIL_DEFAULT_SENDER', 'wms@iris-rs.cn')
# 是否启用邮件发送功能(开发环境可设为 false 禁用) # 是否启用邮件发送功能(开发环境可设为 false 禁用)
MAIL_ENABLED = os.getenv('MAIL_ENABLED', 'true').lower() in ('true', '1', 'yes') 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', '')

View File

@ -84,4 +84,13 @@ export function calculateBomCost(params: {bom_code: string, bom_version: string}
method: 'get', method: 'get',
params params
}) })
}
// [新增] Track 扫码入库查询:根据身份证查询产品信息并匹配物料
export function lookupTrackProduct(code: string) {
return request({
url: '/inbound/product/track-lookup',
method: 'get',
params: { code }
})
} }

View File

@ -86,4 +86,13 @@ export function calculateBomCost(params: {bom_code: string, bom_version: string}
method: 'get', method: 'get',
params params
}) })
}
// [新增] Track 扫码入库查询:根据身份证查询产品信息并匹配物料
export function lookupTrackProduct(code: string) {
return request({
url: '/inbound/semi/track-lookup',
method: 'get',
params: { code }
})
} }

View File

@ -0,0 +1,205 @@
<template>
<el-dialog
v-model="visible"
title="扫码入库"
width="min(520px, 92vw)"
destroy-on-close
:close-on-click-modal="false"
append-to-body
@opened="onOpened"
@closed="onClosed"
>
<div class="track-scan-panel">
<!-- 扫码输入框支持扫码枪回车 / 手动输入 -->
<el-input
ref="inputRef"
v-model="inputCode"
placeholder="扫描或输入身份证后回车"
size="large"
clearable
autofocus
:disabled="loading"
@keyup.enter="doQuery"
>
<template #prefix><el-icon><Scissor /></el-icon></template>
<template #append><el-button :loading="loading" @click="doQuery">查询</el-button></template>
</el-input>
<!-- 摄像头触发区 -->
<div class="camera-trigger" @click="showCamera = true">
<el-icon :size="40" color="#409EFF"><CameraFilled /></el-icon>
<span>点击开启摄像头扫码</span>
</div>
<el-alert type="info" :closable="false" show-icon>
扫码 Track 产品身份证自动带出物料序列号并填充入库表单
</el-alert>
</div>
<!-- 全屏摄像头扫码层 -->
<div v-if="showCamera" class="fullscreen-scanner-overlay">
<div class="scanner-header">
<el-button circle icon="Close" @click="showCamera = false" class="close-btn" />
<span class="scanner-title">扫码模式</span>
<div class="scanner-placeholder"></div>
</div>
<div class="scanner-body">
<QrScanner @decode="onScanSuccess" />
</div>
<div class="scanner-footer">
<p>请将条码/二维码放入镜头范围</p>
<p v-if="loading" class="current-count">查询中...</p>
</div>
</div>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, computed, nextTick } from 'vue'
import { ElMessage } from 'element-plus'
import { Scissor, CameraFilled } from '@element-plus/icons-vue'
import QrScanner from '@/components/QrScanner/index.vue'
import { lookupTrackProduct as lookupSemi } from '@/api/inbound/semi'
import { lookupTrackProduct as lookupProduct } from '@/api/inbound/product'
interface Props {
modelValue: boolean
/** 入库类型: semi=半成品, product=成品 */
inboundType: 'semi' | 'product'
}
const props = withDefaults(defineProps<Props>(), { inboundType: 'semi' })
const emit = defineEmits<{
(e: 'update:modelValue', val: boolean): void
(e: 'fill', data: { track: any; material: any }): void
}>()
const visible = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v)
})
const inputRef = ref<InstanceType<typeof import('element-plus')['ElInput']> | null>(null)
const inputCode = ref('')
const loading = ref(false)
const showCamera = ref(false)
const onOpened = () => {
nextTick(() => inputRef.value?.focus())
}
const onClosed = () => {
inputCode.value = ''
showCamera.value = false
loading.value = false
}
/** 校验扫码内容格式,避免误扫 */
const sanitizeCode = (raw: string): string => {
return (raw || '').trim().replace(/[^A-Za-z0-9\-\.]/g, '')
}
const doQuery = async () => {
const code = inputCode.value.trim()
if (!code) return
if (loading.value) return
loading.value = true
try {
const lookupFn = props.inboundType === 'product' ? lookupProduct : lookupSemi
const res: any = await lookupFn(code)
if (res.code !== 200) {
ElMessage.error(res.msg || '扫码查询失败')
return
}
const { track, material } = res.data
const typeLabel = props.inboundType === 'product' ? '成品' : '半成品'
const needCategory = props.inboundType === 'product' ? '/成品' : '/半成品'
if (!material.category || !material.category.includes(needCategory)) {
ElMessage.error(`扫码产品【${material.name || ''}】不是${typeLabel},无法进行${typeLabel}入库,请检查产品类别`)
return
}
emit('fill', { track, material })
visible.value = false
} catch (e: any) {
ElMessage.error(e?.response?.data?.msg || e.message || '扫码查询失败')
} finally {
loading.value = false
}
}
/** QrScanner 全屏扫码回调 */
const onScanSuccess = (code: string) => {
inputCode.value = sanitizeCode(code)
showCamera.value = false
doQuery()
}
</script>
<style scoped>
.track-scan-panel { padding: 4px 2px; }
.camera-trigger {
height: 120px;
background: #f5f7fa;
border: 1px dashed #dcdfe6;
border-radius: 8px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
color: #909399;
margin: 16px 0 12px;
cursor: pointer;
transition: all 0.3s;
}
.camera-trigger:active { background: #e6e8eb; }
.camera-trigger span { margin-top: 5px; font-size: 13px; }
/* 全屏扫码层 */
.fullscreen-scanner-overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: #000;
z-index: 9999;
display: flex;
flex-direction: column;
}
.scanner-header {
height: 60px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 15px;
background: rgba(0, 0, 0, 0.6);
color: #fff;
position: absolute;
top: 0;
width: 100%;
z-index: 10;
}
.scanner-title { font-size: 16px; font-weight: bold; }
.close-btn { background: rgba(255, 255, 255, 0.2); border: none; color: #fff; }
.scanner-body {
flex: 1;
width: 100%;
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
:deep(.qr-scanner-container) { width: 100% !important; height: 100% !important; border-radius: 0 !important; }
.scanner-footer {
position: absolute;
bottom: 0;
width: 100%;
padding: 20px;
background: rgba(0, 0, 0, 0.6);
color: #fff;
text-align: center;
z-index: 10;
}
.current-count { color: #67c23a; font-weight: bold; margin-top: 5px; font-size: 16px; }
</style>

View File

@ -265,6 +265,18 @@
<div class="dialog-scroll-container"> <div class="dialog-scroll-container">
<el-form :model="form" label-width="110px" ref="formRef" :rules="rules" size="default" class="stylish-form"> <el-form :model="form" label-width="110px" ref="formRef" :rules="rules" size="default" class="stylish-form">
<!-- 扫码入库Track 身份证自动带出物料、序列号与订单号) -->
<div v-if="dialogStatus === 'create'" class="scan-inbound-banner">
<el-button type="primary" size="large" @click="trackScanVisible = true">
<el-icon><Camera /></el-icon>
<span style="margin-left: 6px;">扫码入库</span>
</el-button>
<span class="scan-banner-tip">
<el-icon><InfoFilled /></el-icon>
扫描 Track 产品身份证,自动带出物料与序列号
</span>
</div>
<div class="form-card basic-card"> <div class="form-card basic-card">
<div class="card-title"> <div class="card-title">
<div style="display: flex; align-items: center; gap: 8px;"> <div style="display: flex; align-items: center; gap: 8px;">
@ -579,6 +591,8 @@
<!-- 智能扫码弹窗 --> <!-- 智能扫码弹窗 -->
<SmartScannerDialog v-model="scannerDialogVisible" @confirm="handleScannerConfirm" /> <SmartScannerDialog v-model="scannerDialogVisible" @confirm="handleScannerConfirm" />
<!-- 顶部扫码入库面板 (Track 身份证自动填充物料+序列号+订单号) -->
<TrackScanDialog v-model="trackScanVisible" inbound-type="product" @fill="handleTrackScanFill" />
</div> </div>
</template> </template>
@ -604,6 +618,7 @@ import { uploadFile, deleteFile } from '@/api/inbound/buy'
import WebRtcCamera from '@/components/Camera/WebRtcCamera.vue' import WebRtcCamera from '@/components/Camera/WebRtcCamera.vue'
import WarehouseSelector from '@/components/WarehouseSelector.vue' import WarehouseSelector from '@/components/WarehouseSelector.vue'
import SmartScannerDialog from '@/components/SmartScannerDialog.vue' import SmartScannerDialog from '@/components/SmartScannerDialog.vue'
import TrackScanDialog from '@/components/TrackScanDialog.vue'
import { getLabelPreview, executePrint } from '@/api/common/print' import { getLabelPreview, executePrint } from '@/api/common/print'
import { getWarehouseTree } from '@/api/common/warehouse' import { getWarehouseTree } from '@/api/common/warehouse'
import { usePasteUpload } from '@/hooks/usePasteUpload' import { usePasteUpload } from '@/hooks/usePasteUpload'
@ -741,6 +756,8 @@ const inspection_url = ref('')
// 智能扫码弹窗 // 智能扫码弹窗
const scannerDialogVisible = ref(false) const scannerDialogVisible = ref(false)
// 顶部扫码入库面板 (Track 身份证自动填充物料+序列号+订单号)
const trackScanVisible = ref(false)
// 库位级联选择器数据 // 库位级联选择器数据
const warehouseOptions = ref<any[]>([]) const warehouseOptions = ref<any[]>([])
@ -1377,7 +1394,7 @@ const handleCameraConfirm = async (file: File) => {
} }
}; };
// 智能扫码 // 智能扫码 (序列号旁,仅填序列号)
const openScanner = () => { const openScanner = () => {
scannerDialogVisible.value = true scannerDialogVisible.value = true
} }
@ -1388,6 +1405,15 @@ const handleScannerConfirm = (result: string) => {
ElMessage.success('序列号已提取') 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 // 快速基于此物料创建 BOM
const createBomForMaterial = () => { const createBomForMaterial = () => {
if (!form.base_id) return ElMessage.warning('请先锁定物料基础信息') 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; } :deep(.table-header-gray th) { background-color: #f8f9fb !important; color: #606266; }
.tag-sn { color: #409EFF; font-weight: bold; font-family: monospace; } .tag-sn { color: #409EFF; font-weight: bold; font-family: monospace; }
.stock-num { font-weight: bold; font-size: 15px; } .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; } .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 { 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; } .card-title .icon { font-size: 18px; }

View File

@ -298,6 +298,18 @@
<div class="dialog-scroll-container"> <div class="dialog-scroll-container">
<el-form :model="form" label-width="100px" ref="formRef" :rules="rules" size="default" class="stylish-form"> <el-form :model="form" label-width="100px" ref="formRef" :rules="rules" size="default" class="stylish-form">
<!-- 扫码入库Track 身份证自动带出物料与序列号) -->
<div v-if="dialogStatus === 'create'" class="scan-inbound-banner">
<el-button type="primary" size="large" @click="trackScanVisible = true">
<el-icon><Camera /></el-icon>
<span style="margin-left: 6px;">扫码入库</span>
</el-button>
<span class="scan-banner-tip">
<el-icon><InfoFilled /></el-icon>
扫描 Track 产品身份证,自动带出物料与序列号
</span>
</div>
<div class="form-card basic-card"> <div class="form-card basic-card">
<div class="card-title"> <div class="card-title">
<div style="display: flex; align-items: center; gap: 8px;"> <div style="display: flex; align-items: center; gap: 8px;">
@ -623,6 +635,8 @@
<!-- 智能扫码弹窗 --> <!-- 智能扫码弹窗 -->
<SmartScannerDialog v-model="scannerDialogVisible" @confirm="handleScannerConfirm" /> <SmartScannerDialog v-model="scannerDialogVisible" @confirm="handleScannerConfirm" />
<!-- 顶部扫码入库面板 (Track 身份证自动填充物料+序列号) -->
<TrackScanDialog v-model="trackScanVisible" inbound-type="semi" @fill="handleTrackScanFill" />
</div> </div>
</template> </template>
@ -648,6 +662,7 @@ import { uploadFile, deleteFile } from '@/api/inbound/buy'
import WebRtcCamera from '@/components/Camera/WebRtcCamera.vue' import WebRtcCamera from '@/components/Camera/WebRtcCamera.vue'
import WarehouseSelector from '@/components/WarehouseSelector.vue' import WarehouseSelector from '@/components/WarehouseSelector.vue'
import SmartScannerDialog from '@/components/SmartScannerDialog.vue' import SmartScannerDialog from '@/components/SmartScannerDialog.vue'
import TrackScanDialog from '@/components/TrackScanDialog.vue'
import {getLabelPreview, executePrint} from '@/api/common/print' import {getLabelPreview, executePrint} from '@/api/common/print'
import { getWarehouseTree } from '@/api/common/warehouse' import { getWarehouseTree } from '@/api/common/warehouse'
import { usePasteUpload } from '@/hooks/usePasteUpload' import { usePasteUpload } from '@/hooks/usePasteUpload'
@ -808,6 +823,8 @@ const quality_report_url = ref('')
// 智能扫码弹窗 // 智能扫码弹窗
const scannerDialogVisible = ref(false) const scannerDialogVisible = ref(false)
// 顶部扫码入库面板 (Track 身份证自动填充物料+序列号)
const trackScanVisible = ref(false)
// 库位级联选择器数据 // 库位级联选择器数据
const warehouseOptions = ref<any[]>([]) const warehouseOptions = ref<any[]>([])
@ -1470,7 +1487,7 @@ const handleCameraConfirm = async (file: File) => {
} }
}; };
// 智能扫码 // 智能扫码 (序列号旁,仅填序列号)
const openScanner = () => { const openScanner = () => {
scannerDialogVisible.value = true scannerDialogVisible.value = true
} }
@ -1481,6 +1498,14 @@ const handleScannerConfirm = (result: string) => {
ElMessage.success('序列号已提取') 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 // 快速基于此物料创建 BOM
const createBomForMaterial = () => { const createBomForMaterial = () => {
if (!form.base_id) return ElMessage.warning('请先锁定物料基础信息') if (!form.base_id) return ElMessage.warning('请先锁定物料基础信息')
@ -1625,6 +1650,10 @@ onMounted(() => {
/* [修改] 增加 min-height */ /* [修改] 增加 min-height */
.dialog-scroll-container { padding: 20px; max-height: 70vh; overflow-y: auto; overflow-x: hidden; min-height: 450px; } .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; } .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 { 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; } .card-title .icon { margin-right: 8px; font-size: 18px; color: #409EFF; }