Files
KCGL/inventory-web/src/views/stock/stocktake/index.vue
dxc 38f0bbe41d feat: add RBAC for inventory stocktake module
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-27 13:36:10 +08:00

695 lines
24 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 type="primary" size="large" class="action-btn-full" @click="startNewSession" :loading="btnLoading">
开始新盘点
</el-button>
<el-button
v-if="serverDraftCount > 0"
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="scanner-container">
<div v-if="!showList && !showQtyDialog" class="camera-active-box">
<QrScanner @decode="onScanSuccess" />
<div class="scan-overlay-tip">扫描条码</div>
</div>
<div v-else class="camera-paused-box" @click="closeOverlays">
<el-icon :size="50" color="#909399"><EditPen /></el-icon>
<p>操作中...<br>点击返回扫描</p>
</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="请输入实际点数"
/>
<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>
<el-dialog v-model="showFinishDialog" title="📊 盘点结算" width="90%" align-center :close-on-click-modal="false" class="preview-dialog">
<div class="report-summary">
<div class="summary-row"><span>截止时间:</span><span>{{ new Date().toLocaleString() }}</span></div>
<div class="summary-stats">
<div class="s-item"><div class="num">{{ stats.total }}</div><div class="txt">总数</div></div>
<div class="s-item success"><div class="num">{{ stats.scanned }}</div><div class="txt">已盘</div></div>
<div class="s-item error"><div class="num">{{ stats.total - stats.scanned }}</div><div class="txt">未盘</div></div>
</div>
</div>
<div class="missing-list-header">差异/未盘预览</div>
<el-table :data="varianceList" height="300" border size="small" style="margin-bottom: 10px;">
<el-table-column prop="name" label="名称" show-overflow-tooltip />
<el-table-column prop="batch_no" label="批次" width="90" show-overflow-tooltip />
<el-table-column label="账/实" width="80" align="center">
<template #default="scope">
{{ parseFloat(scope.row.qty_stock) }} /
<span :class="{'text-red': scope.row.scanned && scope.row.qty_actual !== scope.row.qty_stock}">
{{ scope.row.scanned ? scope.row.qty_actual : '未' }}
</span>
</template>
</el-table-column>
</el-table>
<template #footer>
<div class="dialog-footer">
<el-button @click="showFinishDialog = false">返回修改</el-button>
<div class="footer-right">
<el-button type="success" @click="exportToExcel" :icon="Download">导出Excel</el-button>
<el-button v-if="userStore.hasPermission('inventory_stocktake:operation')" type="danger" @click="finishStocktake" :loading="printing" :icon="Checked">结束</el-button>
</div>
</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 } 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
[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 showList = ref(false)
const showFinishDialog = ref(false)
const showQtyDialog = ref(false)
const allData = ref<StockItem[]>([])
const scannedMap = ref<Map<string, number>>(new Map())
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: () => request({ url: '/v1/inbound/stock/draft/list', method: 'get', params: { user_id: currentUser } }),
addDraft: (data: any) => request({ url: '/v1/inbound/stock/draft/add', method: 'post', data: { ...data, user_id: currentUser } }),
clearDraft: () => request({ url: '/v1/inbound/stock/draft/clear', method: 'post', data: { user_id: currentUser } })
}
onMounted(async () => {
await checkServerDraft()
})
const checkServerDraft = async () => {
try {
const res: any = await api.getDrafts()
serverDraftCount.value = (res && res.length) || 0
} catch (e) {}
}
const startNewSession = async () => {
try {
if (serverDraftCount.value > 0) {
await ElMessageBox.confirm('存在未完成记录,开始新盘点将清除它们,确定吗?', '警告', { type: 'warning' })
}
btnLoading.value = true
await api.clearDraft()
scannedMap.value.clear()
await loadData()
isSessionActive.value = true
} catch (e) {} finally { btnLoading.value = false }
}
const resumeSession = async () => {
btnLoading.value = true
try {
const drafts: any = await api.getDrafts()
const map = new Map<string, number>()
drafts.forEach((d: any) => {
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}`
})
}
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
} catch (e) {
ElMessage.error('数据加载失败')
} finally { loading.value = false }
}
const onScanSuccess = (code: string) => {
if (!code) return
const trimCode = code.trim()
if (!/^[A-Za-z0-9\-\.]+$/.test(trimCode)) {
ElMessage.warning(`识别到异常字符:${trimCode}`)
return
}
const item = allData.value.find(i => i.uuid === trimCode || i.bar_code === trimCode)
if (item) {
if (navigator.vibrate) navigator.vibrate(100)
const isBatchMultiple = (item.batch_no && item.batch_no.length > 0) && (item.qty_stock > 1);
if (isBatchMultiple) {
openQtyDialog(item)
} else {
if (item.scanned) {
openQtyDialog(item)
} else {
updateAndSync(item, 1)
ElMessage.success(`自动确认: ${item.name} +1`)
}
}
} else {
ElMessage.error(`不在库条码: ${trimCode}`)
if (navigator.vibrate) navigator.vibrate([200, 50, 200])
}
}
const openQtyDialog = (item: StockItem) => {
currentItem.value = item
inputQty.value = item.scanned ? item.qty_actual : undefined
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}`)
}
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 => ({
'物品名称': 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,
'盘点结果': item.qty_stock === item.qty_actual ? '相符' : '差异',
'差异数': item.qty_actual - item.qty_stock
}))
// 2. 未盘点 Sheet
const missingData = allData.value.filter(i => !i.scanned).map(item => ({
'物品名称': 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),
'状态': '未盘点'
}))
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}
]
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))
)
})
const openInventoryList = () => { showList.value = true }
const openFinishDialog = () => {
if (stats.value.total === 0) return
showFinishDialog.value = true
}
const finishStocktake = async () => {
try {
await ElMessageBox.confirm('确定要结束本次盘点吗?结束后当前进度将清空。', '结束确认', {
type: 'warning', confirmButtonText: '确定结束', cancelButtonText: '取消'
})
printing.value = true
await api.clearDraft()
scannedMap.value.clear()
isSessionActive.value = false
showFinishDialog.value = false
checkServerDraft()
ElMessage.success('盘点已完成,会话已结束')
} catch (e) {
if (e !== 'cancel') ElMessage.error('操作失败')
} finally { printing.value = false }
}
</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; }
</style>