Files
KCGL/inventory-web/src/views/stock/stocktake/index.vue

1483 lines
47 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="app-container mobile-optimized">
<el-card v-if="!isSessionActive" class="main-card idle-card" shadow="never">
<div class="idle-content">
<div class="icon-area">
<el-icon :size="80" color="#409EFF"><VideoPlay /></el-icon>
</div>
<h2 class="app-title">库存盲盘系统</h2>
<p class="subtitle">单件自动确认多件弹窗录入</p>
<div class="idle-actions">
<el-button v-if="userStore.hasPermission('inventory_stocktake:operation')" type="primary" size="large" class="action-btn-full" @click="startNewSession" :loading="btnLoading">
开始新盘点
</el-button>
<el-button
v-if="serverDraftCount > 0 && userStore.hasPermission('inventory_stocktake:operation')"
type="warning"
plain
size="large"
class="action-btn-full"
@click="resumeSession"
:loading="btnLoading"
>
继续上次盘点 <span class="sub-text">({{ serverDraftCount }})</span>
</el-button>
</div>
<div class="safe-tip">
<el-icon><Cloudy /></el-icon>
<span>数据实时同步支持断点续传</span>
</div>
</div>
</el-card>
<el-card v-else class="main-card active-card" shadow="never" v-loading="loading">
<template #header>
<div class="header-row">
<div class="title-box">
<span class="title-text">📷 盲盘作业中</span>
<el-tag v-if="syncStatus === 'success'" type="success" size="small" effect="dark" round>已同步</el-tag>
<el-tag v-else-if="syncStatus === 'syncing'" type="warning" size="small" effect="dark" round>同步中...</el-tag>
<el-tag v-else type="danger" size="small" effect="dark" round>同步失败</el-tag>
</div>
<el-button v-if="userStore.hasPermission('inventory_stocktake:operation')" type="info" text bg size="small" @click="pauseSession" :icon="VideoPause">
暂停
</el-button>
</div>
</template>
<div class="scan-section">
<div v-if="hasPermission" class="camera-placeholder" @click="openFullscreenScanner">
<el-icon :size="40" color="#409EFF"><CameraFilled /></el-icon>
<span class="text">点击开启全屏扫码</span>
</div>
<div v-else class="camera-placeholder" style="background-color: #f5f5f5; cursor: not-allowed;">
<el-icon :size="40" color="#909399"><CameraFilled /></el-icon>
<span class="text">无扫码权限</span>
</div>
<div class="input-box">
<el-input
v-model="barcodeInput"
placeholder="扫描或输入条码回车"
@keyup.enter="handleManualInput"
clearable
ref="barcodeRef"
size="large"
:disabled="!hasPermission"
>
<template #prefix>
<el-icon><EditPen /></el-icon>
</template>
<template #append>
<el-button @click="handleManualInput" :disabled="!hasPermission">添加</el-button>
</template>
</el-input>
</div>
</div>
<div class="stats-dashboard" @click="openInventoryList" v-loading="listLoading">
<div class="stat-card">
<div class="stat-val">{{ stats.total }}</div>
<div class="stat-label">总品项</div>
</div>
<div class="stat-card success">
<div class="stat-val">{{ stats.scanned }}</div>
<div class="stat-label">已盘</div>
</div>
<div class="stat-card warning">
<div class="stat-val">{{ stats.total - stats.scanned }}</div>
<div class="stat-label">未盘</div>
</div>
<div class="stat-arrow"><el-icon><ArrowRight /></el-icon></div>
</div>
<div class="main-actions">
<el-row :gutter="10">
<el-col :span="8">
<el-button type="success" size="large" class="w-100 action-btn" @click="exportToExcel" :icon="Download">
导出Excel
</el-button>
</el-col>
<el-col :span="8">
<el-button type="primary" plain size="large" class="w-100 action-btn" @click="openInventoryList" :icon="Search">
盘点明细
</el-button>
</el-col>
<el-col :span="8">
<el-button v-if="userStore.hasPermission('inventory_stocktake:operation')" type="danger" size="large" class="w-100 action-btn" @click="handleGenerateMissing" :icon="Checked">
结束盘点并生成差异
</el-button>
</el-col>
</el-row>
</div>
</el-card>
<!-- 新增: 防呆确认弹窗 -->
<el-dialog
v-model="showConfirmDialog"
title="⚠️ 确认清除盘点数据"
width="400"
destroy-on-close
:close-on-click-modal="false"
:close-on-press-escape="false"
show-close
align-center
>
<div class="confirm-content">
<el-icon :size="48" color="#E6A23C"><WarningFilled /></el-icon>
<p class="confirm-text">存在未完成记录开始新盘点将清除它们确定吗</p>
</div>
<template #footer>
<el-button @click="cancelConfirm">取消</el-button>
<el-button
type="danger"
:disabled="countdown > 0"
@click="confirmClear"
>
{{ countdown > 0 ? `确认清除 (${countdown}s)` : '确认清除' }}
</el-button>
</template>
</el-dialog>
<el-dialog
v-model="showQtyDialog"
title="🔢 录入实盘数量"
width="90%"
align-center
:close-on-click-modal="false"
destroy-on-close
class="qty-dialog"
>
<div v-if="currentItem" class="qty-content">
<div class="item-info">
<div class="info-row">
<span class="label">名称:</span>
<span class="value">{{ currentItem.name }}</span>
</div>
<div class="info-row">
<span class="label">规格:</span>
<span class="value">{{ currentItem.standard || '-' }}</span>
</div>
<div class="info-row">
<span class="label">SKU:</span>
<span class="value">{{ currentItem.sku }}</span>
</div>
<div class="info-row">
<span class="label">序列号(SN):</span>
<span class="value">{{ currentItem.sn || currentItem.batch_no || '-' }}</span>
</div>
</div>
<el-divider><el-icon><Edit /></el-icon> {{ currentItem.scanned ? '修改实盘数' : '录入实盘数' }}</el-divider>
<div class="input-area">
<el-input-number
v-model="inputQty"
:min="0"
:precision="0"
size="large"
style="width: 100%"
ref="qtyInputRef"
placeholder="请输入实际点数"
class="large-control-input"
/>
<p class="unit-text">单位: {{ currentItem.unit || '个' }}</p>
</div>
<!-- 新增: 备注输入框 -->
<div class="remark-area" style="margin-top: 15px;">
<el-form-item label="备注:" label-width="60px">
<el-input
v-model="inputRemark"
placeholder="选填,可填写差异原因说明"
type="textarea"
:rows="2"
clearable
/>
</el-form-item>
</div>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="showQtyDialog = false">取消</el-button>
<el-button v-if="userStore.hasPermission('inventory_stocktake:operation')" type="primary" @click="handleManualConfirm" size="large">确认数量</el-button>
</div>
</template>
</el-dialog>
<el-drawer
v-model="showList"
title="📦 盘点清单"
direction="btt"
size="100%"
destroy-on-close
class="inventory-drawer"
>
<div class="drawer-layout" v-loading="listLoading">
<!-- 搜索和状态筛选 -->
<div class="search-bar" style="display: flex; align-items: center; gap: 10px; margin-bottom: 15px;">
<el-input v-model="listKeyword" placeholder="搜索 SKU/名称..." :prefix-icon="Search" clearable @keyup.enter="handleListSearch" @clear="handleListSearch" style="width: 240px" />
<el-button type="primary" @click="handleListSearch">搜索</el-button>
<el-radio-group v-model="listStatusFilter" @change="handleListSearch" size="small">
<el-radio-button value="all">全部</el-radio-button>
<el-radio-button value="counted">已盘</el-radio-button>
<el-radio-button value="uncounted">未盘</el-radio-button>
</el-radio-group>
<span style="margin-left: auto; color: #909399; font-size: 13px;">
总品项: {{ stats.total }} | 已盘: {{ stats.scanned }} | 未盘: {{ stats.total - stats.scanned }}
</span>
</div>
<div class="table-container">
<el-table
:data="filteredListData"
height="600"
stripe
border
row-key="uniqueKey"
style="width: 100%"
>
<el-table-column prop="sku" label="SKU" width="140" show-overflow-tooltip />
<el-table-column prop="material_name" label="名称" min-width="120" show-overflow-tooltip />
<el-table-column prop="spec_model" label="规格" width="120" show-overflow-tooltip />
<!-- 盲盘隐藏账面数和差异列 -->
<!-- <el-table-column prop="stock_qty" label="账面数" width="80" align="center" /> -->
<el-table-column label="实盘数" width="100" align="center">
<template #default="{ row }">
<el-input-number
v-model="row.quantity"
:min="0"
:step="1"
size="small"
controls-position="right"
@change="(val) => handleQuantityChange(row, val)"
:disabled="!userStore.hasPermission('inventory_stocktake:operation')"
/>
</template>
</el-table-column>
<!-- <el-table-column label="差异" width="80" align="center">
<template #default="{ row }">
<span :style="{ color: row.diff_qty > 0 ? '#67C23A' : row.diff_qty < 0 ? '#F56C6C' : '#909399' }">
{{ row.diff_qty > 0 ? '+' : '' }}{{ row.diff_qty || 0 }}
</span>
</template>
</el-table-column> -->
<el-table-column prop="remark" label="备注" min-width="120" show-overflow-tooltip>
<template #default="{ row }">
<el-input v-model="row.remark" placeholder="备注" size="small" @blur="handleRemarkChange(row)" />
</template>
</el-table-column>
</el-table>
</div>
<!-- 分页 -->
<div class="drawer-footer" style="display: flex; justify-content: flex-end; padding: 10px;">
<el-pagination
v-model:current-page="listPage"
v-model:page-size="listLimit"
:page-sizes="[20, 50, 100, 200]"
:total="listTotalFiltered"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleListLimitChange"
@current-change="handleListPageChange"
/>
</div>
<div class="drawer-footer">
<el-button @click="showList = false" style="width: 100%">关闭列表</el-button>
</div>
</div>
</el-drawer>
<!-- 全屏扫码 Overlay -->
<div v-if="showCamera" class="fullscreen-scanner-overlay">
<div class="scanner-header">
<el-button circle icon="Close" @click="closeScanner" 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="stats.scanned > 0" class="current-count">已盘点: {{ stats.scanned }} </p>
</div>
</div>
<!-- 新增: 盘点差异审核对话框 -->
<el-dialog
v-model="showVarianceDialog"
title="📋 盘点差异审核"
width="95%"
align-center
:close-on-click-modal="false"
destroy-on-close
class="variance-dialog"
>
<div v-loading="varianceLoading">
<el-alert
title="差异审核说明"
type="info"
:closable="false"
show-icon
style="margin-bottom: 15px;"
>
<template #default>
以下列表显示所有已结束盘点但尚未平账的差异记录请核实后进行后续处理
</template>
</el-alert>
<!-- 新增: 差异列表搜索 -->
<el-input
v-model="searchSku"
placeholder="输入 SKU/条码/名称快速定位"
clearable
style="width: 250px; margin-bottom: 15px;"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-table
:data="filteredVarianceList"
height="500"
border
stripe
style="width: 100%"
>
<el-table-column prop="uuid" label="SKU/条码" width="140" show-overflow-tooltip />
<el-table-column prop="stock_name" label="物品名称" min-width="120" show-overflow-tooltip />
<el-table-column prop="stock_spec" label="规格型号" width="120" show-overflow-tooltip />
<el-table-column prop="stock_location" label="库位" width="100" />
<el-table-column label="账面库存" width="80" align="center">
<template #default="scope">
{{ scope.row.stock_qty }}
</template>
</el-table-column>
<el-table-column label="实盘数量" width="80" align="center">
<template #default="scope">
{{ scope.row.quantity }}
</template>
</el-table-column>
<el-table-column label="差异" width="100" align="center">
<template #default="scope">
<el-tag
:type="scope.row.diff_qty > 0 ? 'success' : 'danger'"
size="large"
effect="dark"
>
{{ scope.row.diff_qty > 0 ? '+' : '' }}{{ scope.row.diff_qty }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="差异类型" width="80" align="center">
<template #default="scope">
<el-tag :type="scope.row.diff_qty > 0 ? 'success' : 'danger'">
{{ scope.row.diff_qty > 0 ? '盘盈' : '盘亏' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="80" align="center">
<template #default="scope">
<el-tag v-if="scope.row.is_processed" type="info">已处理</el-tag>
<el-tag v-else type="warning">待审核</el-tag>
</template>
</el-table-column>
</el-table>
<el-empty v-if="filteredVarianceList.length === 0" :description="searchSku ? '未找到匹配的差异记录' : '暂无待审核的差异记录'" />
</div>
<template #footer>
<div class="dialog-footer">
<!-- 后悔药返回继续扫码 -->
<el-button type="primary" plain @click="returnToScan">
🔄 返回继续扫码
</el-button>
<el-button @click="showVarianceDialog = false">关闭</el-button>
<el-button type="success" @click="exportToExcel" :icon="Download">导出差异报告</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { getStockList, getAllStocktakeItems, updateStocktakeQuantity } from '@/api/inbound/stock'
import QrScanner from '@/components/QrScanner/index.vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search, VideoPlay, VideoPause, List, Checked, Download, ArrowRight, Cloudy, Edit, EditPen, CameraFilled, Close, WarningFilled } from '@element-plus/icons-vue'
import request from '@/utils/request'
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
const currentUser = userStore.userInfo?.display_name || userStore.userInfo?.username || userStore.username || '未知用户'
interface StockItem {
id: number
name: string
standard: string
sku: string
batch_no: string
sn?: string // ★ 序列号
serial_number?: string
uuid: string
bar_code: string
qty_stock: number
qty_actual: number
scanned: boolean
uniqueKey: string
unit?: string
type?: string
category?: string
price?: number
source_table?: string
stock_id?: number
[key: string]: any
}
const loading = ref(false)
const btnLoading = ref(false)
const printing = ref(false)
const isSessionActive = ref(false)
const serverDraftCount = ref(0)
const syncStatus = ref<'success' | 'syncing' | 'failed'>('success')
// 新增:扫码相关状态
const showCamera = ref(false)
const barcodeInput = ref('')
const barcodeRef = ref()
const hasPermission = userStore.hasPermission('inventory_stocktake:operation')
const showList = ref(false)
const showFinishDialog = ref(false)
const showQtyDialog = ref(false)
// ★ 新增: 防呆确认弹窗
const showConfirmDialog = ref(false)
// ★ 新增: 盘点开始防呆倒计时
const countdown = ref(0)
let countdownTimer: any = null
// ★ 新增: 差异审核对话框
const showVarianceDialog = ref(false)
// ★ 新增: 差异列表搜索
const searchSku = ref('')
// ★ 新增: 盘点清单弹窗分页
const listPage = ref(1)
const listLimit = ref(20)
const listTotal = ref(0)
const listKeyword = ref('')
const listLoading = ref(false)
const listData = ref<any[]>([])
const listStatusFilter = ref<'all' | 'counted' | 'uncounted'>('all')
const allStockItems = ref<any[]>([]) // 全量应盘物资(盘点基数)
const totalStockCount = ref(0) // ★ 全量应盘物资总数不受limit限制
const totalScannedCount = ref(0) // ★ 后端去重的真实已盘数量
const allScannedDrafts = ref<any[]>([]) // 全量草稿记录(脱离分页和过滤)
const listTotalFiltered = ref(0) // 过滤后的总数
// ★ 新增: 会话ID
const currentSessionId = ref<string>('')
// 获取应盘物资清单(盘点基数)
const fetchAllStockItems = async () => {
try {
// ★ 必须传递 session_id用于隔离会话
const res: any = await getAllStocktakeItems({ session_id: currentSessionId.value })
if (res && res.code === 200) {
allStockItems.value = res.data.items || []
// ★ 使用返回的 total 获取真实总数,而不是受限的数组长度
totalStockCount.value = res.data.total || allStockItems.value.length
}
} catch (e) {
console.error('获取应盘物资清单失败', e)
}
}
// 过滤后的列表数据(直接使用已过滤的 listData
const filteredListData = computed(() => listData.value)
// 统计信息:从全量数据中计算(脱离视图依赖)
const stats = computed(() => {
const total = allStockItems.value.length
if (total === 0) return { total: 0, scanned: 0, varianceItems: 0 }
return {
total,
scanned: totalScannedCount.value,
varianceItems: 0
}
})
const varianceLoading = ref(false)
const currentItem = ref<StockItem | null>(null)
const inputQty = ref<number | undefined>(undefined)
const inputRemark = ref('')
const qtyInputRef = ref()
const api = {
getDrafts: (sessionId?: string) => request({
url: '/v1/inbound/stock/draft/list',
method: 'get',
params: { session_id: sessionId }
}),
addDraft: (data: any) => request({
url: '/v1/inbound/stock/draft/add',
method: 'post',
data: { ...data, session_id: currentSessionId.value }
}),
// ★ 新增: 开始新会话
startNewSession: () => request({
url: '/v1/inbound/stock/draft/start-new',
method: 'post',
data: {}
}),
// ★ 新增: 结束盘点
finishStocktake: () => request({
url: '/v1/inbound/stock/finish',
method: 'post',
data: { session_id: currentSessionId.value }
}),
// ★ 新增: 获取差异报告
getVarianceReport: () => request({
url: '/v1/inbound/stock/variance-report',
method: 'get',
params: {}
}),
// ★ 保留清除功能(用于兼容性)
clearDraft: () => request({
url: '/v1/inbound/stock/draft/clear',
method: 'post',
data: {}
})
}
const typeToSourceTable = (type: string): string => {
switch (type) {
case 'material': return 'stock_buy'
case 'semi': return 'stock_semi'
case 'product': return 'stock_product'
default: return ''
}
}
onMounted(async () => {
await checkServerDraft()
})
const checkServerDraft = async () => {
try {
// 获取草稿数量(后端已移除 is_finished 字段,直接返回所有记录)
const res: any = await request({
url: '/v1/inbound/stock/draft/list',
method: 'get',
params: { page: 1, limit: 1 } // 只获取一条记录来获取总数
})
// 后端返回格式已改为 { items: [], total: number }
serverDraftCount.value = (res && res.total) || 0
} catch (e) {}
}
// ★ 重写: 开始新盘点 - 使用新 API
const startNewSession = async () => {
// ★ 新增: 防呆确认弹窗
if (serverDraftCount.value > 0) {
showConfirmDialog.value = true
countdown.value = 10
if (countdownTimer) clearInterval(countdownTimer)
countdownTimer = setInterval(() => {
countdown.value--
if (countdown.value <= 0) {
clearInterval(countdownTimer!)
countdownTimer = null
}
}, 1000)
return
}
await doStartNewSession()
}
const doStartNewSession = async () => {
showConfirmDialog.value = false
btnLoading.value = true
try {
// 调用新 API 开始新会话
const res: any = await api.startNewSession()
currentSessionId.value = res.session_id || ''
isSessionActive.value = true
// ★ 标记当前阶段为 scanning扫码中
localStorage.setItem('stocktake_phase', 'scanning')
// ★ 立即加载统计基数
await fetchAllStockItems()
await fetchInventoryList()
ElMessage.success('新盘点会话已开始')
} catch (e: any) {
if (e !== 'cancel') {
ElMessage.error(e?.message || '操作失败')
}
} finally { btnLoading.value = false }
}
const confirmClear = () => {
if (countdown.value > 0) return
doStartNewSession()
}
const cancelConfirm = () => {
showConfirmDialog.value = false
if (countdownTimer) {
clearInterval(countdownTimer)
countdownTimer = null
}
countdown.value = 0
}
// ★ 重写: 继续上次盘点 - 恢复扫码作业
const resumeSession = async () => {
btnLoading.value = true
try {
// 获取最新的会话(后端已移除 is_finished 字段)
// 后端返回格式已改为 { items: [], total: number }
const res: any = await request({
url: '/v1/inbound/stock/draft/list',
method: 'get',
params: { page: 1, limit: 99999 } // ★ 获取全量草稿数据
})
const drafts = res && res.items ? res.items : []
if (!drafts || drafts.length === 0) {
ElMessage.warning('没有找到未完成的盘点记录')
return
}
// 恢复已扫描的数据 - 简化为直接激活会话
// 从草稿中获取 session_id
const sessionIds = [...new Set(drafts.map((d: any) => d.session_id))]
currentSessionId.value = sessionIds[0]
// 直接恢复会话状态
isSessionActive.value = true
// ★ 立即加载统计基数
await fetchAllStockItems()
await fetchInventoryList()
// ★ 智能路由:根据本地记忆的阶段决定下一步
const phase = localStorage.getItem('stocktake_phase')
if (phase === 'review') {
// 如果记忆中已经是核对阶段,直接打开差异弹窗
await openVarianceDialog()
ElMessage.info('已恢复差异审核')
} else {
// 默认打开扫码镜头
ElMessage.success('已恢复扫码,继续盘点')
}
} catch (e) {
ElMessage.error('恢复失败')
} finally { btnLoading.value = false }
}
const pauseSession = () => {
isSessionActive.value = false
checkServerDraft()
ElMessage.success('进度已保存')
}
// ★ 后悔药:从差异面板返回继续扫码
const returnToScan = () => {
showVarianceDialog.value = false
isSessionActive.value = true
// ★ 标记当前阶段为 scanning扫码中
localStorage.setItem('stocktake_phase', 'scanning')
ElMessage.info('继续扫码,发现漏扫的物料')
}
// ★★★ 核心修改:扫码成功后实时查询后端匹配 ★★★
const onScanSuccess = async (code: string) => {
if (!code || loading.value) return
const trimCode = code.trim()
// 将扫到的条码同步显示在输入框中
barcodeInput.value = trimCode
if (!/^[A-Za-z0-9\-\.]+$/.test(trimCode)) {
ElMessage.warning(`识别到异常字符:${trimCode}`)
return
}
if (trimCode.length < 3) {
ElMessage.warning('扫描结果过短,请对准重试')
return
}
// 实时查询后端匹配
loading.value = true
try {
const res: any = await getStockList({
page: 1,
pageSize: 10,
keyword: trimCode
})
if (!res || !res.data || !res.data.list || res.data.list.length === 0) {
ElMessage.error(`未找到该物料库存: ${trimCode}`)
if (navigator.vibrate) navigator.vibrate([200, 50, 200])
return
}
// 查找匹配的物料
const foundItem = res.data.list.find((i: any) =>
i.uuid === trimCode || i.sku === trimCode || i.barcode === trimCode || i.bar_code === trimCode
)
if (!foundItem) {
ElMessage.error(`未找到该物料库存: ${trimCode}`)
if (navigator.vibrate) navigator.vibrate([200, 50, 200])
return
}
if (navigator.vibrate) navigator.vibrate(100)
// 关闭全屏扫码,弹出填数对话框
showCamera.value = false
// 处理数据格式
const stock = parseFloat(foundItem.stock_quantity || foundItem.qty_stock || 0)
const type = foundItem.stock_type || foundItem.type || 'material'
const item: StockItem = {
...foundItem,
name: foundItem.name || foundItem.material_name || foundItem.product_name || '未知物品',
standard: foundItem.spec_model || foundItem.standard || foundItem.model || '',
sku: foundItem.sku || '',
uuid: foundItem.uuid || foundItem.sku || '',
bar_code: foundItem.bar_code || foundItem.barcode || '',
qty_stock: stock,
qty_actual: 1,
scanned: true,
uniqueKey: `${type}_${foundItem.id}`,
source_table: typeToSourceTable(type),
stock_id: foundItem.id
}
openQtyDialog(item)
} catch (e) {
ElMessage.error('查询库存失败')
console.error(e)
} finally {
loading.value = false
}
}
// 开启全屏扫码
const openFullscreenScanner = () => {
if (!hasPermission) {
ElMessage.warning('无扫码权限')
return
}
showCamera.value = true
}
// 关闭全屏扫码
const closeScanner = () => {
showCamera.value = false
}
// 手动输入条码 - 实时查询后端
const handleManualInput = async () => {
const code = barcodeInput.value.trim()
if (!code) return
if (code.length < 3) {
ElMessage.warning('输入内容过短,请输入完整条码')
return
}
loading.value = true
try {
const res: any = await getStockList({
page: 1,
pageSize: 10,
keyword: code
})
if (!res || !res.data || !res.data.list || res.data.list.length === 0) {
ElMessage.error(`未找到该物料库存: ${code}`)
return
}
const foundItem = res.data.list.find((i: any) =>
i.uuid === code || i.sku === code || i.barcode === code || i.bar_code === code
)
if (!foundItem) {
ElMessage.error(`未找到该物料库存: ${code}`)
return
}
if (navigator.vibrate) navigator.vibrate(100)
const stock = parseFloat(foundItem.stock_quantity || foundItem.qty_stock || 0)
const type = foundItem.stock_type || foundItem.type || 'material'
const item: StockItem = {
...foundItem,
name: foundItem.name || foundItem.material_name || foundItem.product_name || '未知物品',
standard: foundItem.spec_model || foundItem.standard || foundItem.model || '',
sku: foundItem.sku || '',
uuid: foundItem.uuid || foundItem.sku || '',
bar_code: foundItem.bar_code || foundItem.barcode || '',
qty_stock: stock,
qty_actual: 1,
scanned: true,
uniqueKey: `${type}_${foundItem.id}`,
source_table: typeToSourceTable(type),
stock_id: foundItem.id
}
openQtyDialog(item)
} catch (e) {
ElMessage.error('查询库存失败')
console.error(e)
} finally {
loading.value = false
}
}
const openQtyDialog = (item: StockItem) => {
currentItem.value = item
inputQty.value = item.scanned ? item.qty_actual : 1
showQtyDialog.value = true
nextTick(() => {
const inputEl = document.querySelector('.qty-dialog input') as HTMLInputElement
if(inputEl) inputEl.focus()
})
}
const handleManualConfirm = () => {
if (!currentItem.value) return
const val = inputQty.value === undefined ? 0 : inputQty.value
const remark = inputRemark.value
// ★★★ 直接保存到后端,不使用本地缓存 ★★★
showQtyDialog.value = false
inputRemark.value = ''
ElMessage.success(`已记录实盘: ${val}`)
// ★★★ 异步保存到后端,不阻塞 UIfire-and-forget★★★
syncToBackend(currentItem.value.uuid, val, remark)
}
// ★★★ 乐观更新:异步保存到后端,不阻塞 UI ★★★
const syncToBackend = (uuid: string, quantity: number, remark: string) => {
syncStatus.value = 'syncing'
api.addDraft({ uuid, quantity, remark })
.then(() => {
syncStatus.value = 'success'
// 静默刷新统计数字
fetchInventoryList(true)
})
.catch(() => {
syncStatus.value = 'failed'
})
}
const updateAndSync = async (item: StockItem, quantity: number, remark: string = '') => {
// 直接保存到后端,不使用本地缓存
item.scanned = true
item.qty_actual = quantity
}
const closeOverlays = () => {
showList.value = false
showQtyDialog.value = false
}
// --- 导出 Excel 逻辑 (调用后端API) ---
const exportToExcel = async () => {
try {
// ===== 调试代码 =====
console.warn('---- 触发了导出 Excel ----');
// ===== 调试结束 =====
ElMessage.info('正在生成盘点报告,请稍候...');
// ★ 传递 session_id 参数,用于导出当前会话的未盘点明细
const sessionParam = currentSessionId.value ? `?session_id=${encodeURIComponent(currentSessionId.value)}` : '';
// 使用项目封装的 request 发送请求,确保自动携带 JWT Token
const res: any = await request({
url: '/v1/inbound/stock/export-stocktake' + sessionParam,
method: 'get',
responseType: 'blob' as any, // 核心:接收二进制文件流
headers: {
'Accept': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
}
});
// 触发静默下载
const blob = new Blob([res], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
const dateStr = new Date().toISOString().split('T')[0];
link.download = `盘点差异报告_${dateStr}.xlsx`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
ElMessage.success('报告导出成功');
} catch (error) {
console.error('导出失败:', error);
ElMessage.error('导出失败,请重试');
}
}
const varianceList = computed(() => {
return listData.value
.filter(i => !i.quantity || i.quantity !== i.stock_quantity)
.map(i => ({
...i,
// 映射字段名以匹配模板
stock_name: i.name,
stock_spec: i.standard,
stock_location: i.location || i.warehouse_loc || '',
stock_qty: i.stock_quantity,
quantity: i.quantity,
diff_qty: i.quantity - i.stock_quantity
}))
})
// ★ 新增: 本地搜索过滤后的差异列表
const filteredVarianceList = computed(() => {
if (!searchSku.value) return varianceList.value
const kw = searchSku.value.toLowerCase()
return varianceList.value.filter(i =>
(i.uuid && i.uuid.toLowerCase().includes(kw)) ||
(i.sku && i.sku.toLowerCase().includes(kw)) ||
(i.stock_name && i.stock_name.toLowerCase().includes(kw)) ||
(i.stock_spec && i.stock_spec.toLowerCase().includes(kw))
)
})
// ★ 新增: 获取盘点清单数据(合并全量应盘物资 + 已盘点记录)
// silent: 是否静默模式(不显示 loading不报错
const fetchInventoryList = async (silent = false) => {
if (!silent) listLoading.value = true
try {
// 1. 获取已盘点记录
const res: any = await request({
url: '/v1/inbound/stock/draft/list',
method: 'get',
params: {
page: 1,
limit: 99999, // ★ 获取全量草稿数据
keyword: listKeyword.value,
session_id: currentSessionId.value // ★ 必须传递 session_id 隔离会话
}
})
const scannedDrafts = res?.items || []
// ★ 使用返回的 total 获取真实已盘数量,而不是受限的数组长度
const totalScanned = res?.total || scannedDrafts.length
// 保存全量草稿记录用于全局统计
allScannedDrafts.value = scannedDrafts
// 直接读取后端算好的去重已盘数
totalScannedCount.value = res?.total_scanned || 0
// 2. 使用全量应盘物资列表
// 对于每个应盘物资,检查是否有对应的盘点记录
let mergedData = allStockItems.value.map(item => {
// 查找对应的盘点记录
const draft = scannedDrafts.find((d: any) =>
d.source_table === item.source_table && d.stock_id === item.id
)
return {
id: draft?.id || null,
stock_id: item.id,
source_table: item.source_table,
uniqueKey: `${item.source_table}_${item.id}`, // ★ 绝对唯一键解决row-key冲突
sku: item.sku,
material_name: item.material_name,
spec_model: item.spec_model,
stock_qty: item.stock_qty, // 账面数(盲盘时隐藏)
quantity: draft?.quantity ?? draft?.qty_actual ?? 0, // 兼容后端字段名
diff_qty: draft ? (draft.quantity - item.stock_qty) : -item.stock_qty, // 差异
remark: draft?.remark || '',
warehouse_location: item.warehouse_location
}
})
// 3. 关键词过滤
if (listKeyword.value) {
const kw = listKeyword.value.toLowerCase()
mergedData = mergedData.filter((item: any) =>
item.sku?.toLowerCase().includes(kw) ||
item.material_name?.toLowerCase().includes(kw)
)
}
// 4. 状态过滤
if (listStatusFilter.value === 'counted') {
mergedData = mergedData.filter((item: any) => item.quantity > 0)
} else if (listStatusFilter.value === 'uncounted') {
mergedData = mergedData.filter((item: any) => !item.quantity || item.quantity === 0)
}
// 5. 分页
listTotalFiltered.value = mergedData.length
const start = (listPage.value - 1) * listLimit.value
listData.value = mergedData.slice(start, start + listLimit.value)
} catch (e) {
if (!silent) ElMessage.error('获取盘点清单失败')
} finally {
if (!silent) listLoading.value = false
}
}
// ★ 修改: 打开盘点清单弹窗
const openInventoryList = async () => {
showList.value = true
listPage.value = 1
listKeyword.value = ''
listStatusFilter.value = 'all'
// 如果基数未加载则先加载,否则只刷新已盘点记录
if (allStockItems.value.length === 0) {
await fetchAllStockItems()
}
await fetchInventoryList()
}
// ★ 新增: 盘点清单搜索
const handleListSearch = () => {
listPage.value = 1
fetchInventoryList()
}
// ★ 新增: 盘点清单分页变化
const handleListPageChange = (page: number) => {
listPage.value = page
fetchInventoryList()
}
const handleListLimitChange = (limit: number) => {
listLimit.value = limit
listPage.value = 1
fetchInventoryList()
}
// ★ 新增: 更新实盘数量(手动修改)
const handleQuantityChange = async (row: any, val: number) => {
try {
await updateStocktakeQuantity({
stock_id: row.stock_id,
source_table: row.source_table,
quantity: val
})
ElMessage.success('实盘数已更新')
} catch (e) {
console.error('更新实盘数失败', e)
ElMessage.error('更新失败')
// 重新获取列表数据
fetchInventoryList()
}
}
// ★ 新增: 更新备注
const handleRemarkChange = async (row: any) => {
// 备注更新可以批量处理或直接调用后端接口
// 这里暂时只更新本地数据,实际项目中可以调用后端保存
console.log('备注更新:', row.remark, row.id)
}
// ★ 修改:结束盘点按钮直接调用 finishStocktake跳过二次确认弹窗
const openFinishDialog = () => {
if (stats.value.total === 0) {
ElMessage.warning('暂无盘点数据')
return
}
// 直接执行结束盘点流程
finishStocktake()
}
// ★ 新增:结束盘点(计算漏盘)- 将未扫描的库存标记为全额盘亏
const handleGenerateMissing = async () => {
if (stats.value.total === 0) {
ElMessage.warning('暂无盘点数据')
return
}
try {
await ElMessageBox.confirm(
'确认结束当前盘点吗?系统将自动把所有未扫描到的库存标记为全额盘亏!',
'提示',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
)
btnLoading.value = true
const res = await request({
url: '/v1/inbound/stock/stocktake/generate-missing',
method: 'post',
data: {
session_id: currentSessionId.value // ★ 必须传递 session_id 隔离会话
}
})
if (res.code === 200) {
ElMessage.success(`成功生成 ${res.data.count} 条漏盘记录`)
// 刷新差异列表
await checkServerDraft()
} else {
ElMessage.error(res.msg || '生成漏盘数据失败')
}
} catch (e) {
if (e !== 'cancel') {
ElMessage.error('生成漏盘数据失败')
}
} finally {
btnLoading.value = false
}
}
// ★ 重写: 结束盘点 - 纯前端状态流转,不再调用后端
const finishStocktake = async () => {
try {
// ★ 二次确认:防止误触
await ElMessageBox.confirm(
'确认仓库已全部盘点完毕,结束当前扫码并开始核对差异吗?',
'结束盘点确认',
{
confirmButtonText: '确定结束',
cancelButtonText: '继续扫码',
type: 'warning'
}
)
// 用户确认后,执行 UI 状态流转
btnLoading.value = true
await checkServerDraft() // 确保最新数据
isSessionActive.value = false // 收起扫码面板
showFinishDialog.value = false
ElMessage.success('盘点扫描结束,请核对差异')
await openVarianceDialog() // 自动弹出差异审核列表
// ★ 标记当前阶段为 review差异审核
localStorage.setItem('stocktake_phase', 'review')
} catch (e: any) {
if (e === 'cancel' || e === 'close' || String(e).includes('cancel')) {
// 用户取消,停留在扫码界面继续盘点
return
}
ElMessage.error(e?.message || '操作失败')
} finally {
btnLoading.value = false
}
}
// ★ 新增: 打开差异审核对话框
const openVarianceDialog = async () => {
varianceLoading.value = true
showVarianceDialog.value = true
// varianceList 已通过 computed 自动计算,无需额外 API 调用
varianceLoading.value = false
}
// ★ 新增: 跳转到差异审核页面
const goToVarianceReview = () => {
openVarianceDialog()
}
</script>
<style scoped>
.app-container.mobile-optimized {
width: 100%;
height: 100vh;
display: flex;
flex-direction: column;
padding: 0;
background-color: #f5f7fa;
}
.idle-card {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
border: none;
border-radius: 0;
}
.idle-content { width: 100%; max-width: 400px; padding: 20px; }
.app-title { font-size: 24px; margin-bottom: 10px; color: #303133; }
.subtitle { color: #909399; margin-bottom: 40px; }
.icon-area { margin-bottom: 20px; }
.idle-actions {
display: flex;
flex-direction: column;
gap: 15px;
margin-top: 20px;
width: 100%;
}
.action-btn-full {
width: 100%;
height: 50px;
font-size: 16px;
font-weight: bold;
margin: 0 !important;
}
.sub-text { font-size: 12px; opacity: 0.8; margin-left: 5px; }
.safe-tip { margin-top: 40px; font-size: 12px; color: #67c23a; display: flex; align-items: center; justify-content: center; gap: 5px; }
.active-card {
flex: 1;
display: flex;
flex-direction: column;
border: none;
border-radius: 0;
overflow: hidden;
}
:deep(.el-card__body) {
flex: 1;
display: flex;
flex-direction: column;
padding: 10px;
overflow: hidden;
}
.header-row { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;}
.title-box { display: flex; align-items: center; gap: 8px; font-weight: bold; font-size: 16px; }
.scanner-container { height: 35vh; background: #000; border-radius: 12px; overflow: hidden; margin-bottom: 15px; position: relative; flex-shrink: 0;}
.camera-active-box { width: 100%; height: 100%; }
.scan-overlay-tip { position: absolute; bottom: 15px; left: 0; width: 100%; text-align: center; color: rgba(255,255,255,0.9); font-size: 13px; text-shadow: 0 1px 3px rgba(0,0,0,0.8); }
.camera-paused-box { width: 100%; height: 100%; display: flex; flex-direction: column; justify-content: center; align-items: center; background: #f2f3f5; color: #909399; cursor: pointer; }
.stats-dashboard { display: flex; align-items: center; background: #fff; border: 1px solid #ebeef5; border-radius: 8px; padding: 15px; margin-bottom: 20px; cursor: pointer; flex-shrink: 0; box-shadow: 0 2px 8px rgba(0,0,0,0.05); }
.stat-card { flex: 1; text-align: center; }
.stat-val { font-size: 20px; font-weight: 800; line-height: 1.2; }
.stat-label { font-size: 11px; color: #909399; }
.stat-card.success .stat-val { color: #67c23a; }
.stat-card.warning .stat-val { color: #e6a23c; }
.stat-card.error .stat-val { color: #f56c6c; }
.stat-arrow { width: 20px; color: #c0c4cc; }
.w-100 { width: 100%; }
.action-btn { font-weight: bold; height: 48px; }
.drawer-layout { height: 100%; display: flex; flex-direction: column; padding: 10px; background: #fff;}
.search-bar { display: flex; margin-bottom: 10px; flex-shrink: 0; }
.table-container {
flex: 1;
overflow: hidden;
border: 1px solid #ebeef5;
border-radius: 4px;
}
.drawer-footer { margin-top: 10px; flex-shrink: 0; }
.qty-content { padding: 10px 0; }
.item-info { background: #f5f7fa; padding: 10px; border-radius: 6px; margin-bottom: 20px; }
.info-row { display: flex; justify-content: space-between; margin-bottom: 8px; font-size: 14px; }
.info-row .label { color: #909399; }
.info-row .value { font-weight: bold; color: #303133; }
.input-area { text-align: center; margin-top: 10px; }
.unit-text { margin-top: 5px; font-size: 12px; color: #909399; }
.actual-qty-text { font-weight: bold; color: #409EFF; font-size: 16px; }
.text-gray { color: #ccc; }
.text-red { color: #f56c6c; font-weight: bold; }
.report-summary { background: #f5f7fa; padding: 15px; border-radius: 6px; margin-bottom: 15px; }
.summary-row { display: flex; justify-content: space-between; margin-bottom: 15px; color: #606266; font-size: 13px; }
.summary-stats { display: flex; justify-content: space-between; text-align: center; }
.s-item .num { font-size: 18px; font-weight: bold; }
.missing-list-header { font-weight: bold; margin-bottom: 8px; font-size: 13px; border-left: 3px solid #f56c6c; padding-left: 8px; }
.dialog-footer { display: flex; justify-content: space-between; align-items: center; margin-top: 10px; }
.footer-right { display: flex; gap: 10px; }
/* ★★★ 新增:专为平板优化的超大数字输入框样式 ★★★ */
.large-control-input {
height: 60px; /* 增加整体输入框高度 */
}
/* 放大左右两边的加减按钮,并增加点击区域 */
:deep(.large-control-input .el-input-number__decrease),
:deep(.large-control-input .el-input-number__increase) {
width: 70px !important; /* 显著加大按钮宽度 */
font-size: 28px !important; /* 放大加号和减号图标 */
background-color: #f0f2f5; /* 稍微加深背景色,让触控区更明显 */
}
/* 给中间的数字输入区留出左右按钮的空间 */
:deep(.large-control-input .el-input__wrapper) {
padding-left: 80px !important;
padding-right: 80px !important;
}
/* 放大中间数字部分的字体和高度,保持协调 */
:deep(.large-control-input .el-input__inner) {
font-size: 24px !important;
height: 58px !important;
}
/* ★★★ 新增:扫码区域样式 ★★★ */
.scan-section {
margin-bottom: 15px;
}
.camera-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 180px;
background: linear-gradient(135deg, #ecf5ff 0%, #d9ecff 100%);
border: 2px dashed #409EFF;
border-radius: 8px;
cursor: pointer;
transition: all 0.3s;
}
.camera-placeholder:hover {
background: linear-gradient(135deg, #d9ecff 0%, #b3d8ff 100%);
transform: scale(1.01);
}
.camera-placeholder .text {
margin-top: 10px;
color: #409EFF;
font-size: 14px;
}
.input-box {
margin-top: 15px;
}
.input-box :deep(.el-input__wrapper) {
box-shadow: 0 0 0 1px #dcdfe6 inset;
}
.input-box :deep(.el-input__wrapper:hover) {
box-shadow: 0 0 0 1px #c0c4cc inset;
}
.input-box :deep(.el-input__wrapper.is-focus) {
box-shadow: 0 0 0 1px #409EFF inset;
}
/* ★★★ 全屏扫码 Overlay 样式 ★★★ */
.fullscreen-scanner-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: #000;
z-index: 2000;
display: flex;
flex-direction: column;
}
.scanner-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 15px;
background: rgba(0, 0, 0, 0.8);
color: #fff;
}
.scanner-title {
font-size: 18px;
font-weight: bold;
}
.close-btn {
background: rgba(255, 255, 255, 0.2);
border: none;
color: #fff;
}
.scanner-placeholder {
width: 40px;
}
.scanner-body {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
.scanner-footer {
padding: 20px;
text-align: center;
background: rgba(0, 0, 0, 0.8);
color: #fff;
}
.scanner-footer p {
margin: 5px 0;
}
.current-count {
font-size: 16px;
font-weight: bold;
color: #409EFF;
}
@media (max-width: 768px) {
.app-container {
padding: 5px;
}
.title-box {
font-size: 16px;
}
.camera-placeholder {
height: 120px;
}
.bottom-actions {
flex-direction: column;
}
.bottom-actions .el-button {
width: 100%;
}
}
.confirm-content {
text-align: center;
padding: 20px 0;
}
.confirm-content .confirm-text {
margin-top: 20px;
font-size: 16px;
color: #303133;
}
</style>