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

1206 lines
38 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>
<!-- 新增: 查看差异报告按钮 -->
<el-button
type="info"
plain
size="large"
class="action-btn-full"
@click="goToVarianceReview"
>
📋 差异审核 <span class="sub-text">(查看历史差异)</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">
<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="12">
<el-button type="primary" plain size="large" class="w-100 action-btn" @click="openInventoryList" :icon="Search">
盘点明细
</el-button>
</el-col>
<el-col :span="12">
<el-button v-if="userStore.hasPermission('inventory_stocktake:operation')" type="danger" size="large" class="w-100 action-btn" @click="openFinishDialog" :icon="Checked">
结束盘点
</el-button>
</el-col>
</el-row>
</div>
</el-card>
<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">批号:</span>
<span class="value">{{ 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>
<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">
<div class="search-bar">
<el-input v-model="searchKeyword" placeholder="搜索 SKU/名称..." :prefix-icon="Search" clearable />
<el-select v-model="filterType" placeholder="状态" style="width: 100px; margin-left: 8px;">
<el-option label="全部" value="all" />
<el-option label="已盘" value="scanned" />
<el-option label="未盘" value="missing" />
</el-select>
</div>
<div class="table-container">
<el-table
:data="filteredList"
height="100%"
stripe
border
row-key="uniqueKey"
style="width: 100%"
>
<el-table-column prop="name" label="名称" min-width="90" show-overflow-tooltip />
<el-table-column prop="sku" label="SKU" width="110" show-overflow-tooltip />
<el-table-column prop="batch_no" label="批次" width="85" show-overflow-tooltip>
<template #default="scope">
{{ scope.row.serial_number || scope.row.batch_no || '-' }}
</template>
</el-table-column>
<el-table-column label="实盘" width="60" align="center">
<template #default="scope">
<span v-if="scope.row.scanned" class="actual-qty-text">{{ scope.row.qty_actual }}</span>
<span v-else class="text-gray">-</span>
</template>
</el-table-column>
<el-table-column label="操作" width="90" align="center" fixed="right">
<template #default="scope">
<el-button
v-if="userStore.hasPermission('inventory_stocktake:operation')"
type="primary"
link
icon="Edit"
@click.stop="openQtyDialog(scope.row)"
>
修改
</el-button>
</template>
</el-table-column>
</el-table>
</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-table
:data="varianceList"
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-column label="操作" width="120" align="center" fixed="right">
<template #default="scope">
<el-button
v-if="!scope.row.is_processed && userStore.hasPermission('inventory_stocktake:operation')"
type="primary"
size="small"
@click="handleAdjust(scope.row)"
>
确认平账
</el-button>
<span v-else class="text-gray">-</span>
</template>
</el-table-column>
</el-table>
<el-empty v-if="varianceList.length === 0" description="暂无待审核的差异记录" />
</div>
<template #footer>
<div class="dialog-footer">
<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, nextTick } from 'vue'
import { getAllStock } 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 } from '@element-plus/icons-vue'
import request from '@/utils/request'
import { useUserStore } from '@/stores/user'
import * as XLSX from 'xlsx'
const userStore = useUserStore()
const currentUser = userStore.username || 'admin'
interface StockItem {
id: number
name: string
standard: string
sku: string
batch_no: 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 showVarianceDialog = ref(false)
const allData = ref<StockItem[]>([])
const scannedMap = ref<Map<string, number>>(new Map())
const borrowedQuantities = ref<Record<string, number>>({})
// ★ 新增: 会话ID
const currentSessionId = ref<string>('')
const varianceLoading = ref(false)
const filterType = ref('all')
const searchKeyword = ref('')
const currentItem = ref<StockItem | null>(null)
const inputQty = ref<number | undefined>(undefined)
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: {}
}),
// ★ 新增: 单条库存调整
adjustStock: (draftId: number, remark: string) => request({
url: '/v1/inbound/stock/adjust',
method: 'post',
data: { draft_id: draftId, operator_name: currentUser, remark: remark }
}),
// ★ 保留清除功能(用于兼容性)
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 ''
}
}
async function fetchBorrowedQuantities(items: StockItem[]): Promise<void> {
const payload = items.filter(i => i.source_table && i.stock_id).map(i => ({
source_table: i.source_table,
stock_id: i.stock_id
}))
if (payload.length === 0) return
try {
const res = await request({
url: '/v1/inbound/stock/borrowed-quantities',
method: 'post',
data: { items: payload }
})
// res is map of key->qty
borrowedQuantities.value = { ...borrowedQuantities.value, ...res }
} catch (e) {
console.error('获取借出数量失败', e)
}
}
onMounted(async () => {
await checkServerDraft()
})
const checkServerDraft = async () => {
try {
// 获取草稿数量(后端已移除 is_finished 字段,直接返回所有记录)
const res: any = await request({
url: '/v1/inbound/stock/draft/list',
method: 'get',
params: {}
})
serverDraftCount.value = (res && res.length) || 0
} catch (e) {}
}
// ★ 重写: 开始新盘点 - 使用新 API
const startNewSession = async () => {
try {
if (serverDraftCount.value > 0) {
await ElMessageBox.confirm('存在未完成记录,开始新盘点将清除它们,确定吗?', '警告', { type: 'warning' })
}
btnLoading.value = true
// 调用新 API 开始新会话
const res: any = await api.startNewSession()
currentSessionId.value = res.session_id || ''
scannedMap.value.clear()
await loadData()
isSessionActive.value = true
ElMessage.success('新盘点会话已开始')
} catch (e: any) {
if (e !== 'cancel') {
ElMessage.error(e?.message || '操作失败')
}
} finally { btnLoading.value = false }
}
// ★ 重写: 继续上次盘点
const resumeSession = async () => {
btnLoading.value = true
try {
// 获取最新的会话(后端已移除 is_finished 字段)
const drafts: any = await request({
url: '/v1/inbound/stock/draft/list',
method: 'get',
params: {}
})
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]
const map = new Map<string, number>()
drafts.forEach((d: any) => {
if (d.session_id === currentSessionId.value) {
map.set(d.uuid, d.quantity !== undefined ? parseFloat(d.quantity) : 1)
}
})
scannedMap.value = map
await loadData()
isSessionActive.value = true
} catch (e) {
ElMessage.error('恢复失败')
} finally { btnLoading.value = false }
}
const pauseSession = () => {
isSessionActive.value = false
checkServerDraft()
ElMessage.success('进度已保存')
}
// ★★★ 核心修改:数据加载映射修复 ★★★
const loadData = async () => {
loading.value = true
try {
const res = await getAllStock()
const list: StockItem[] = []
const processItem = (item: any, type: string) => {
const stock = parseFloat(item.stock_quantity || item.qty_stock || 0)
if (stock <= 0) return
const name = item.material_name || item.product_name || item.name || '未知物品'
const uuid = item.uuid || item.sku || ''
const isScanned = scannedMap.value.has(uuid)
list.push({
...item,
name: name,
// ★ 修复点:优先读取 spec_model因为数据库是这个字段
standard: item.spec_model || item.standard || item.model || '',
sku: item.sku || '',
batch_no: item.batch_no || item.batch_number || '',
serial_number: item.serial_number || '',
uuid: uuid,
bar_code: item.bar_code || item.barcode || '',
qty_stock: stock,
qty_actual: isScanned ? scannedMap.value.get(uuid)! : 0,
scanned: isScanned,
uniqueKey: `${type}_${item.id}`,
source_table: typeToSourceTable(type),
stock_id: item.id
})
}
if (res.materials) res.materials.forEach((i: any) => processItem(i, 'material'))
if (res.semis) res.semis.forEach((i: any) => processItem(i, 'semi'))
if (res.products) res.products.forEach((i: any) => processItem(i, 'product'))
allData.value = list
await fetchBorrowedQuantities(list)
} catch (e) {
ElMessage.error('数据加载失败')
} finally { loading.value = false }
}
const onScanSuccess = (code: string) => {
if (!code || loading.value) return
const trimCode = code.trim()
if (!/^[A-Za-z0-9\-\.]+$/.test(trimCode)) {
ElMessage.warning(`识别到异常字符:${trimCode}`)
return
}
if (trimCode.length < 3) {
ElMessage.warning('扫描结果过短,请对准重试')
return
}
const item = allData.value.find(i => i.uuid === trimCode || i.bar_code === trimCode)
if (item) {
if (navigator.vibrate) navigator.vibrate(100)
// ★★★ 核心修改:扫码成功后立即关闭全屏扫码,弹出填数对话框
showCamera.value = false
// 无论是否多批次,都弹出对话框让用户确认数量
openQtyDialog(item)
} else {
ElMessage.error(`不在库条码: ${trimCode}`)
if (navigator.vibrate) navigator.vibrate([200, 50, 200])
}
}
// 开启全屏扫码
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
loading.value = true
try {
const item = allData.value.find(i => i.uuid === code || i.bar_code === code)
if (item) {
if (navigator.vibrate) navigator.vibrate(100)
openQtyDialog(item)
} else {
ElMessage.error(`不在库条码: ${code}`)
}
} finally {
barcodeInput.value = ''
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
updateAndSync(currentItem.value, val)
showQtyDialog.value = false
ElMessage.success(`已记录实盘: ${val}`)
// ★★★ 核心修改:确认数量后自动重新打开全屏扫码,实现无缝闭环
nextTick(() => {
if (hasPermission) {
showCamera.value = true
}
})
}
const updateAndSync = async (item: StockItem, quantity: number) => {
item.scanned = true
item.qty_actual = quantity
scannedMap.value.set(item.uuid, quantity)
syncStatus.value = 'syncing'
try {
await api.addDraft({ uuid: item.uuid, quantity: quantity })
syncStatus.value = 'success'
} catch (e) {
syncStatus.value = 'failed'
}
}
const closeOverlays = () => {
showList.value = false
showQtyDialog.value = false
}
// --- 导出 Excel 逻辑 ---
const exportToExcel = () => {
try {
// 1. 已盘点 Sheet
const scannedData = allData.value.filter(i => i.scanned).map(item => {
const key = item.source_table && item.stock_id ? `${item.source_table}_${item.stock_id}` : ''
const borrowedQty = borrowedQuantities.value[key] || 0
const actualTotal = item.qty_actual + borrowedQty
const diff = actualTotal - item.qty_stock
const result = diff === 0 ? '正常' : diff < 0 ? '盘亏/差异' : '盘盈'
return {
'物品名称': item.name,
'类型': item.type || item.material_type || '-',
'类别': item.category || '-',
'规格型号': item.spec_model || item.standard || '-', // ★ 双重保险
'SKU': item.sku,
'批次/SN': item.serial_number || item.batch_no || '-',
'单位': item.unit || '个',
'单价': item.price || item.unit_price || 0,
'账面库存': parseFloat(item.qty_stock as any),
'实盘数量': item.qty_actual,
'借出未还数量': borrowedQty,
'盘点结果': result,
'差异数': diff
}
})
// 2. 未盘点 Sheet
const missingData = allData.value.filter(i => !i.scanned).map(item => {
const key = item.source_table && item.stock_id ? `${item.source_table}_${item.stock_id}` : ''
const borrowedQty = borrowedQuantities.value[key] || 0
return {
'物品名称': item.name,
'类型': item.type || item.material_type || '-',
'类别': item.category || '-',
'规格型号': item.spec_model || item.standard || '-', // ★ 双重保险
'SKU': item.sku,
'批次/SN': item.serial_number || item.batch_no || '-',
'单位': item.unit || '个',
'单价': item.price || item.unit_price || 0,
'账面库存': parseFloat(item.qty_stock as any),
'借出未还数量': borrowedQty,
'状态': '未盘点'
}
})
const wb = XLSX.utils.book_new()
const ws1 = XLSX.utils.json_to_sheet(scannedData)
const ws2 = XLSX.utils.json_to_sheet(missingData)
const wscols = [
{wch: 20}, {wch: 10}, {wch: 10}, {wch: 15},
{wch: 15}, {wch: 15}, {wch: 5}, {wch: 8},
{wch: 8}, {wch: 8}, {wch: 8}, {wch: 8}, {wch: 8}
]
ws1['!cols'] = wscols
ws2['!cols'] = wscols
XLSX.utils.book_append_sheet(wb, ws1, "已盘点明细")
XLSX.utils.book_append_sheet(wb, ws2, "未盘点明细")
const fileName = `库存盘点报告_${new Date().toISOString().slice(0,10)}.xlsx`
XLSX.writeFile(wb, fileName)
ElMessage.success('Excel 报表已生成')
} catch (e) {
console.error(e)
ElMessage.error('导出失败,请检查 xlsx 插件是否安装')
}
}
const filteredList = computed(() => {
let result = allData.value
if (filterType.value === 'scanned') result = result.filter(i => i.scanned)
else if (filterType.value === 'missing') result = result.filter(i => !i.scanned)
if (searchKeyword.value) {
const kw = searchKeyword.value.toLowerCase()
result = result.filter(i =>
i.name.toLowerCase().includes(kw) ||
i.uuid.includes(kw) ||
(i.sku && i.sku.toLowerCase().includes(kw)) ||
(i.batch_no && i.batch_no.toLowerCase().includes(kw))
)
}
return result
})
const stats = computed(() => {
const total = allData.value.length
const scanned = allData.value.filter(i => i.scanned).length
const varianceItems = allData.value.filter(i =>
!i.scanned || (i.scanned && i.qty_actual !== parseFloat(i.qty_stock as any))
).length
return { total, scanned, varianceItems }
})
const varianceList = computed(() => {
return allData.value
.filter(i => !i.scanned || (i.scanned && i.qty_actual !== parseFloat(i.qty_stock as any)))
.map(i => ({
...i,
// 映射字段名以匹配模板
stock_name: i.name,
stock_spec: i.standard,
stock_location: i.location || i.warehouse_loc || '',
stock_qty: i.qty_stock,
quantity: i.qty_actual,
diff_qty: i.qty_actual - parseFloat(i.qty_stock as any)
}))
})
const openInventoryList = () => { showList.value = true }
const openFinishDialog = () => {
if (stats.value.total === 0) return
showFinishDialog.value = true
}
const finishStocktake = async () => {
// ====== 强制重置状态,防止锁死 ======
console.log('--- [结束盘点] 1. 函数触发,当前状态: ---', {
printing: printing.value,
btnLoading: btnLoading.value,
sessionId: currentSessionId.value,
isSessionActive: isSessionActive.value
})
printing.value = false
btnLoading.value = false
try {
console.log('--- [结束盘点] 2. 准备弹窗确认 ---')
await ElMessageBox.confirm('确定要结束本次盘点吗?结束后将进入差异审核流程。', '结束确认', {
type: 'warning', confirmButtonText: '确定结束', cancelButtonText: '取消'
})
console.log('--- [结束盘点] 3. 用户确认结束盘点 ---')
printing.value = true
console.log('--- [结束盘点] 4. 开始调用 API, session_id:', currentSessionId.value)
// 调用结束盘点 API
const res: any = await api.finishStocktake()
console.log('--- [结束盘点] 5. API 返回:', res)
// 结束会话
scannedMap.value.clear()
isSessionActive.value = false
showFinishDialog.value = false
console.log('--- [结束盘点] 6. 会话已清理 ---')
// 刷新服务器草稿计数
await checkServerDraft()
console.log('--- [结束盘点] 7. 草稿计数已刷新 ---')
ElMessage.success('盘点已结束,请查看差异报告进行审核')
console.log('--- [结束盘点] 8. 成功提示已显示 ---')
// ====== 强行打开差异审核对话框 ======
console.log('--- [结束盘点] 9. 准备打开差异面板 ---')
showVarianceDialog.value = true
console.log('--- [结束盘点] 10. showVarianceDialog 已设为 true ---')
} catch (e: any) {
console.error('--- [结束盘点] 捕获异常:', e)
// 判断用户取消
if (e === 'cancel' || String(e).includes('cancel')) {
console.log('--- [结束盘点] 用户取消操作 ---')
return
}
// 其他错误需要提示用户
const errMsg = e?.message || e?.data?.message || String(e) || '结束盘点失败'
console.error('--- [结束盘点] 错误详情:', errMsg)
ElMessage.error(errMsg)
} finally {
printing.value = false
btnLoading.value = false
console.log('--- [结束盘点] 11. finally 块执行完成loading 已重置 ---')
}
}
// ★ 新增: 打开差异审核对话框
const openVarianceDialog = async () => {
varianceLoading.value = true
showVarianceDialog.value = true
// varianceList 已通过 computed 自动计算,无需额外 API 调用
varianceLoading.value = false
}
// ★ 新增: 确认平账
const handleAdjust = async (row: any) => {
try {
await ElMessageBox.confirm(
`确定要对 "${row.uuid}" 进行平账调整吗?\n\n差异: ${row.diff_qty > 0 ? '盘盈 +' : '盘亏 '}${row.diff_qty}`,
'确认平账',
{ type: 'warning', confirmButtonText: '确认调整', cancelButtonText: '取消' }
)
const remark = `盘点差异调整 - ${row.diff_qty > 0 ? '盘盈入库' : '盘亏出库'}`
const res: any = await api.adjustStock(row.id, remark)
ElMessage.success(res.message || '调整成功')
// 刷新差异列表
await openVarianceDialog()
} catch (e: any) {
if (e !== 'cancel') ElMessage.error(e?.message || '操作失败')
}
}
// ★ 新增: 跳转到差异审核页面
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%;
}
}
</style>