feat: MOM 扫码入库对接 Track 产品身份证自动填充
- 半成品/成品入库弹窗顶部新增'扫码入库'按钮,打开扫码面板(扫码枪/摄像头) - 扫码 Track 身份证后自动填充物料信息与序列号,不再二次搜索 - 后端新增 track_query_service(httpx 调 Track lookup)、track-lookup 接口 - 入库成功后 notify_track 通知 Track 闭环
This commit is contained in:
@ -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
|
||||
|
||||
|
||||
@ -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', '')
|
||||
|
||||
@ -85,3 +85,12 @@ export function calculateBomCost(params: {bom_code: string, bom_version: string}
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
// [新增] Track 扫码入库查询:根据身份证查询产品信息并匹配物料
|
||||
export function lookupTrackProduct(code: string) {
|
||||
return request({
|
||||
url: '/inbound/product/track-lookup',
|
||||
method: 'get',
|
||||
params: { code }
|
||||
})
|
||||
}
|
||||
@ -87,3 +87,12 @@ export function calculateBomCost(params: {bom_code: string, bom_version: string}
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
// [新增] Track 扫码入库查询:根据身份证查询产品信息并匹配物料
|
||||
export function lookupTrackProduct(code: string) {
|
||||
return request({
|
||||
url: '/inbound/semi/track-lookup',
|
||||
method: 'get',
|
||||
params: { code }
|
||||
})
|
||||
}
|
||||
205
inventory-web/src/components/TrackScanDialog.vue
Normal file
205
inventory-web/src/components/TrackScanDialog.vue
Normal 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>
|
||||
@ -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; }
|
||||
|
||||
@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user