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

@ -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 }
})
}

View File

@ -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 }
})
}

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">
<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="card-title">
<div style="display: flex; align-items: center; gap: 8px;">
@ -579,6 +591,8 @@
<!-- 智能扫码弹窗 -->
<SmartScannerDialog v-model="scannerDialogVisible" @confirm="handleScannerConfirm" />
<!-- 顶部扫码入库面板 (Track 身份证自动填充物料+序列号+订单号) -->
<TrackScanDialog v-model="trackScanVisible" inbound-type="product" @fill="handleTrackScanFill" />
</div>
</template>
@ -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<any[]>([])
@ -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; }

View File

@ -298,6 +298,18 @@
<div class="dialog-scroll-container">
<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="card-title">
<div style="display: flex; align-items: center; gap: 8px;">
@ -623,6 +635,8 @@
<!-- 智能扫码弹窗 -->
<SmartScannerDialog v-model="scannerDialogVisible" @confirm="handleScannerConfirm" />
<!-- 顶部扫码入库面板 (Track 身份证自动填充物料+序列号) -->
<TrackScanDialog v-model="trackScanVisible" inbound-type="semi" @fill="handleTrackScanFill" />
</div>
</template>
@ -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<any[]>([])
@ -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; }