feat(stocktake): 欢迎页改为公司维度状态驱动,多设备共享盘点会话

原入口只有一个「开始新盘点」,多台 PDA 各开各的会话,无法协同。

- 新增公司选择器:普通用户锁定并默认本公司(后端本就强制隔离),
  超管必须显式选择,未选时入口按钮禁用;本公司若不在下拉列表中
  则补进去,避免 el-select 显示为空。
- 选定公司后调用 /draft/active-session 探测该公司是否已有进行中的
  盘点。服务端为唯一事实来源,localStorage 仅作缓存。
- 状态驱动渲染入口按钮:
  场景 A(已有活跃会话)→ 绿色「加入正在进行的盘点」,并提示当前
    进度项数;同时保留次要入口「开启新一轮盘点」,否则公司一旦有过
    草稿就永远开不了新一轮,点击走 10 秒确认弹窗。
  场景 B(无活跃会话)→ 蓝色「开始新盘点」。
- 切换公司时清空本地会话并重新探测,避免跨公司串会话。
- 移除此前「刷新后请求 /draft/list 取第一条草稿猜 session_id」的逻辑:
  该列表无会话/用户过滤,取到的是全库 SKU 字典序最小的那条草稿,
  与当前用户和公司都无关。
- 所有盘点请求携带当前公司;导出、生成漏盘、改实盘数等 POST 请求
  亦经 query string 传递公司。
This commit is contained in:
yueli
2026-09-11 11:33:26 +08:00
parent 1d1ca1b89e
commit 32cb5469c4

View File

@ -9,27 +9,70 @@
<h2 class="app-title">库存盲盘系统</h2>
<p class="subtitle">单件自动确认多件弹窗录入</p>
<!-- 公司选择多人协同的隔离维度选同一公司的 PDA 共享同一个 session_id -->
<div class="company-selector">
<el-select
v-model="selectedCompany"
placeholder="请选择盘点公司"
size="large"
class="company-select"
:disabled="!isSuperAdmin"
:loading="companyLoading"
@change="handleCompanyChange"
>
<el-option v-for="c in companyOptions" :key="c" :label="c" :value="c" />
</el-select>
<div class="company-hint">{{ companyHint }}</div>
</div>
<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 v-if="!selectedCompany" type="info" size="large" class="action-btn-full" disabled>
请先选择公司
</el-button>
<!-- 场景 A该公司已有进行中的盘点 绿色加入 -->
<template v-else-if="activeSession">
<el-button
type="success"
size="large"
class="action-btn-full"
@click="joinActiveSession"
:loading="btnLoading"
>
加入正在进行的盘点
</el-button>
<div class="active-session-tip">
当前已有未完成的盘点进度{{ serverDraftCount }}
</div>
<!-- 次要入口缺了它本司一旦有过草稿就永远开不了新一轮 -->
<el-button
link
type="primary"
class="secondary-entry"
@click="startNewSession"
:loading="btnLoading"
>
开启新一轮盘点
</el-button>
</template>
<!-- 场景 B该公司当前无盘点 蓝色开始新盘点 -->
<el-button
v-if="serverDraftCount > 0 && userStore.hasPermission('inventory_stocktake:operation')"
type="warning"
plain
v-else
type="primary"
size="large"
class="action-btn-full"
@click="resumeSession"
@click="startNewSession"
:loading="btnLoading"
>
继续上次盘点 <span class="sub-text">({{ serverDraftCount }})</span>
开始新盘点
</el-button>
</div>
<div class="safe-tip">
<el-icon><Cloudy /></el-icon>
<span>数据实时同步支持断点续传</span>
<span>数据实时同步同公司多设备共享盘点会话</span>
</div>
</div>
</el-card>
@ -119,7 +162,7 @@
<!-- 新增: 防呆确认弹窗 -->
<el-dialog
v-model="showConfirmDialog"
title="⚠️ 确认清除盘点数据"
title="⚠️ 确认开启新一轮盘点"
width="400"
destroy-on-close
:close-on-click-modal="false"
@ -129,16 +172,16 @@
>
<div class="confirm-content">
<el-icon :size="48" color="#E6A23C"><WarningFilled /></el-icon>
<p class="confirm-text">存在未完成记录始新盘点将清除它们确定</p>
<p class="confirm-text">存在未完成的盘点记录启新一轮盘点会生成新的会话<strong>原有数据将保留</strong>之后仍可通过继续上次盘点回到之前的会话确定开始</p>
</div>
<template #footer>
<el-button @click="cancelConfirm">取消</el-button>
<el-button
type="danger"
type="primary"
:disabled="countdown > 0"
@click="confirmClear"
@click="confirmStartNew"
>
{{ countdown > 0 ? `确认清除 (${countdown}s)` : '确认清除' }}
{{ countdown > 0 ? `确认开启 (${countdown}s)` : '确认开启' }}
</el-button>
</template>
</el-dialog>
@ -414,7 +457,7 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { getAllStocktakeItems, getDraftMergedList, updateStocktakeQuantity, scanStockByBarcode } from '@/api/inbound/stock'
import { getAllStocktakeItems, getDraftMergedList, updateStocktakeQuantity, scanStockByBarcode, getStocktakeCompanies } 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'
@ -494,6 +537,62 @@ const listTotalFiltered = ref(0) // 过滤后的总数
// ★ 新增: 会话ID
const currentSessionId = ref<string>('')
// ★ 会话 / 公司持久化
// session_id 由服务端的「活跃会话」接口给出:同一公司的多台 PDA 拿到的是同一个值,
// 这是多人协同的基础。localStorage 只是缓存,服务端才是唯一事实来源。
const SESSION_STORAGE_KEY = 'stocktake_session_id'
const COMPANY_STORAGE_KEY = 'stocktake_company'
// 当前选中的公司 —— 所有盘点请求都必须携带它
const selectedCompany = ref<string>('')
const companyOptions = ref<string[]>([])
const companyLoading = ref(false)
// 该公司的活跃会话:{ session_id, total, last_scan_time };无进行中的盘点则为 null
const activeSession = ref<{ session_id: string; total: number; last_scan_time: string | null } | null>(null)
const isSuperAdmin = computed(() => userStore.role === 'SUPER_ADMIN')
// 选择器下方的说明文字
const companyHint = computed(() => {
if (isSuperAdmin.value) {
return selectedCompany.value ? `当前盘点范围:${selectedCompany.value}` : '请先选择要盘点的公司'
}
if (!selectedCompany.value) return '您的账号未绑定公司,请联系管理员'
return `已锁定为您所属公司:${selectedCompany.value}`
})
const persistSession = (sessionId: string) => {
currentSessionId.value = sessionId || ''
if (sessionId) {
localStorage.setItem(SESSION_STORAGE_KEY, sessionId)
} else {
localStorage.removeItem(SESSION_STORAGE_KEY)
}
}
const clearPersistedSession = () => {
localStorage.removeItem(SESSION_STORAGE_KEY)
currentSessionId.value = ''
}
const persistCompany = (company: string) => {
selectedCompany.value = company || ''
if (company) {
localStorage.setItem(COMPANY_STORAGE_KEY, company)
} else {
localStorage.removeItem(COMPANY_STORAGE_KEY)
}
}
// 统一构造带公司标识的查询参数。
// ★ 必须走 query string后端 get_current_company_filter() 只读 request.args
// 对 POST 请求同样如此 —— body 里的 company_name 会被直接忽略。
const withCompany = (extra: Record<string, any> = {}) => {
const params: Record<string, any> = { ...extra }
if (selectedCompany.value) params.company_name = selectedCompany.value
return params
}
// 获取应盘物资清单(盘点基数)
const fetchAllStockItems = async (page = 1) => {
try {
@ -501,7 +600,8 @@ const fetchAllStockItems = async (page = 1) => {
const res: any = await getAllStocktakeItems({
session_id: currentSessionId.value,
page,
pageSize: 200
pageSize: 200,
company_name: selectedCompany.value || undefined
})
if (res && res.code === 200) {
allStockItems.value = res.data.items || []
@ -538,37 +638,54 @@ const api = {
getDrafts: (sessionId?: string) => request({
url: '/v1/inbound/stock/draft/list',
method: 'get',
params: { session_id: sessionId }
params: withCompany({ session_id: sessionId })
}),
// ★ 该公司最近一次活跃的盘点会话(多人协同:用于「加入正在进行的盘点」)
activeSession: () => request({
url: '/v1/inbound/stock/draft/active-session',
method: 'get',
params: withCompany()
}),
addDraft: (data: any) => request({
url: '/v1/inbound/stock/draft/add',
method: 'post',
params: withCompany(), // ★ 公司走 query stringbody 里的会被后端忽略
data: { ...data, session_id: currentSessionId.value }
}),
// ★ 新增: 开始新会话
startNewSession: () => request({
url: '/v1/inbound/stock/draft/start-new',
method: 'post',
params: withCompany(),
data: {}
}),
// ★ 新增: 结束盘点
finishStocktake: () => request({
url: '/v1/inbound/stock/finish',
method: 'post',
params: withCompany(),
data: { session_id: currentSessionId.value }
}),
// ★ 新增: 获取差异报告
getVarianceReport: () => request({
url: '/v1/inbound/stock/variance-report',
method: 'get',
params: {}
params: withCompany()
}),
// ★ 保留清除功能(用于兼容性)
clearDraft: () => request({
url: '/v1/inbound/stock/draft/clear',
method: 'post',
data: {}
})
// 后端已强制要求 session_id不传会被 400 拒绝(防止误清空他司/他人的草稿)。
// 清除成功后同步丢弃本地持久化的 session_id。
clearDraft: async () => {
const res = await request({
url: '/v1/inbound/stock/draft/clear',
method: 'post',
params: withCompany(),
data: { session_id: currentSessionId.value }
})
clearPersistedSession()
isSessionActive.value = false
return res
}
}
const typeToSourceTable = (type: string): string => {
@ -581,27 +698,90 @@ const typeToSourceTable = (type: string): string => {
}
onMounted(async () => {
await checkServerDraft()
await initEntry()
})
// ★ 入口初始化:加载公司列表 → 确定默认公司 → 查该公司的活跃会话
const initEntry = async () => {
await loadCompanies()
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) {}
if (!isSuperAdmin.value) {
// 普通用户锁定本公司(后端也会强制隔离,这里只是让选择器显示正确)
const myCompany = userStore.companyName
if (myCompany) {
// 本公司可能尚无物料、不在列表中,补进去否则 el-select 显示为空
if (!companyOptions.value.includes(myCompany)) companyOptions.value.unshift(myCompany)
persistCompany(myCompany)
}
} else {
// 超管沿用上次选择;必须显式选一个公司,否则无法确定盘点范围
const saved = localStorage.getItem(COMPANY_STORAGE_KEY)
if (saved && companyOptions.value.includes(saved)) persistCompany(saved)
}
if (selectedCompany.value) await checkServerDraft()
}
// ★ 重写: 开始新盘点 - 使用新 API
const loadCompanies = async () => {
companyLoading.value = true
try {
const res: any = await getStocktakeCompanies()
companyOptions.value = res?.data?.companies || []
} catch (e) {
console.error('获取公司列表失败', e)
companyOptions.value = []
} finally {
companyLoading.value = false
}
}
// 切换公司 = 切换盘点范围 → 必须丢弃上一个公司的会话,再重新探测
const handleCompanyChange = async (val: string) => {
clearPersistedSession()
activeSession.value = null
serverDraftCount.value = 0
persistCompany(val)
await checkServerDraft()
}
// ★ 探测该公司是否有进行中的盘点 —— 服务端是唯一事实来源,
// 这样第二台 PDA 选同一公司后就能看到「加入正在进行的盘点」。
const checkServerDraft = async () => {
if (!selectedCompany.value) {
activeSession.value = null
serverDraftCount.value = 0
return
}
try {
const res: any = await api.activeSession()
const data = res?.data || {}
activeSession.value = data.session_id
? { session_id: data.session_id, total: data.total || 0, last_scan_time: data.last_scan_time || null }
: null
serverDraftCount.value = data.total || 0
} catch (e) {
activeSession.value = null
serverDraftCount.value = 0
}
}
// 进入扫码页的公共收尾:落到 scanning 阶段并拉取盘点基数
const enterScanning = async (message: string) => {
isSessionActive.value = true
localStorage.setItem('stocktake_phase', 'scanning')
await fetchAllStockItems()
await fetchInventoryList()
ElMessage.success(message)
}
// 点击「开始新盘点」或次要入口「开启新一轮盘点」
const startNewSession = async () => {
// ★ 新增: 防呆确认弹窗
if (serverDraftCount.value > 0) {
if (!selectedCompany.value) {
ElMessage.warning('请先选择公司')
return
}
// ★ 该公司已有进行中的盘点 → 二次确认(不删数据,只签发新会话)
if (activeSession.value) {
showConfirmDialog.value = true
countdown.value = 10
if (countdownTimer) clearInterval(countdownTimer)
@ -621,16 +801,12 @@ const doStartNewSession = async () => {
showConfirmDialog.value = false
btnLoading.value = true
try {
// 调用新 API 开始新会话
// ★ 先清掉旧会话,避免签发失败时残留上一个 session_id
clearPersistedSession()
// 后端只签发新 session_id不删除任何历史数据
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('新盘点会话已开始')
persistSession(res?.session_id || res?.data?.session_id || '')
await enterScanning('新盘点会话已开始')
} catch (e: any) {
if (e !== 'cancel') {
ElMessage.error(e?.message || '操作失败')
@ -638,7 +814,24 @@ const doStartNewSession = async () => {
} finally { btnLoading.value = false }
}
const confirmClear = () => {
// ★ 场景 A加入同事已开启的盘点 —— 同一公司共享同一个 session_id
const joinActiveSession = async () => {
if (!activeSession.value?.session_id) {
ElMessage.warning('没有找到进行中的盘点')
await checkServerDraft()
return
}
btnLoading.value = true
try {
persistSession(activeSession.value.session_id)
await enterScanning('已加入正在进行的盘点')
} catch (e: any) {
ElMessage.error(e?.message || '加入失败')
} finally { btnLoading.value = false }
}
// 弹窗确认后开启新一轮盘点(不再删除任何数据,故不叫 confirmClear
const confirmStartNew = () => {
if (countdown.value > 0) return
doStartNewSession()
}
@ -652,53 +845,6 @@ const cancelConfirm = () => {
countdown.value = 0
}
// ★ 重写: 继续上次盘点 - 恢复扫码作业
const resumeSession = async () => {
btnLoading.value = true
try {
// 获取最新的会话(后端已移除 is_finished 字段)
// 后端返回格式已改为 { items: [], total: number }
// ★ 仅检查是否有草稿存在取1条即可无需99999全量
const res: any = await request({
url: '/v1/inbound/stock/draft/list',
method: 'get',
params: { page: 1, limit: 1 }
})
const total = res?.total || 0
const firstDraft = res?.items?.[0]
if (total === 0 || !firstDraft) {
ElMessage.warning('没有找到未完成的盘点记录')
return
}
// ★ 直接从第一条草稿恢复 session_id不再拉取全量草稿
// 避免 limit=500 请求触发后端 query.all() + N+1 性能瓶颈
currentSessionId.value = firstDraft.session_id || ''
// 直接恢复会话状态
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()
@ -735,7 +881,7 @@ const onScanSuccess = async (code: string) => {
// ★ 精确匹配查询后端(替代 pageSize:10 模糊搜索 + find(),避免漏匹配)
loading.value = true
try {
const res: any = await scanStockByBarcode(trimCode)
const res: any = await scanStockByBarcode(trimCode, selectedCompany.value || undefined)
if (!res || !res.data) {
ElMessage.error(`未找到该物料库存: ${trimCode}`)
@ -813,7 +959,7 @@ const handleManualInput = async () => {
loading.value = true
try {
// ★ 精确匹配查询后端(替代 pageSize:10 模糊搜索 + find()
const res: any = await scanStockByBarcode(code)
const res: any = await scanStockByBarcode(code, selectedCompany.value || undefined)
if (!res || !res.data) {
ElMessage.error(`未找到该物料库存: ${code}`)
@ -867,7 +1013,7 @@ const openQtyDialog = async (item: StockItem) => {
const dupRes: any = await request({
url: '/v1/inbound/stock/draft/list',
method: 'get',
params: { session_id: currentSessionId.value, uuid: uuidForQuery, limit: 1 }
params: withCompany({ session_id: currentSessionId.value, uuid: uuidForQuery, limit: 1 })
})
const dupItems = dupRes?.items || dupRes?.data?.items || []
if (dupItems.length > 0 && dupItems[0].quantity !== undefined) {
@ -983,12 +1129,11 @@ const exportToExcel = async () => {
// ===== 调试结束 =====
ElMessage.info('正在生成盘点报告,请稍候...');
// ★ 传递 session_id 参数,用于导出当前会话的未盘点明细
const sessionParam = currentSessionId.value ? `?session_id=${encodeURIComponent(currentSessionId.value)}` : '';
// 使用项目封装的 request 发送请求,确保自动携带 JWT Token
// ★ 传递 session_id + 公司,导出范围限定在当前会话与当前公司
const res: any = await request({
url: '/v1/inbound/stock/export-stocktake' + sessionParam,
url: '/v1/inbound/stock/export-stocktake',
method: 'get',
params: withCompany({ session_id: currentSessionId.value || undefined }),
responseType: 'blob' as any, // 核心:接收二进制文件流
headers: {
'Accept': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
@ -1050,7 +1195,8 @@ const fetchInventoryList = async (silent = false) => {
keyword: listKeyword.value || undefined,
status_filter: listStatusFilter.value || undefined,
page: listPage.value,
pageSize: listLimit.value
pageSize: listLimit.value,
company_name: selectedCompany.value || undefined
})
const data = res?.data || {}
@ -1104,7 +1250,7 @@ const handleQuantityChange = async (row: any, val: number) => {
source_table: row.source_table,
quantity: val,
session_id: currentSessionId.value // ★ 传入会话ID后端按会话隔离更新
})
}, selectedCompany.value || undefined)
ElMessage.success('实盘数已更新')
} catch (e) {
console.error('更新实盘数失败', e)
@ -1153,6 +1299,7 @@ const handleGenerateMissing = async () => {
const res = await request({
url: '/v1/inbound/stock/stocktake/generate-missing',
method: 'post',
params: withCompany(), // ★ 公司走 query string
data: {
session_id: currentSessionId.value // ★ 必须传递 session_id 隔离会话
}
@ -1267,6 +1414,32 @@ const goToVarianceReview = () => {
}
.sub-text { font-size: 12px; opacity: 0.8; margin-left: 5px; }
/* ★ 公司选择器:多人协同的隔离维度 */
.company-selector {
width: 100%;
margin-bottom: 10px;
}
.company-select {
width: 100%;
}
.company-hint {
margin-top: 8px;
font-size: 12px;
color: #909399;
text-align: center;
}
/* 场景 A 的进度提示与次要入口 */
.active-session-tip {
font-size: 12px;
color: #67c23a;
text-align: center;
margin-top: -5px;
}
.secondary-entry {
font-size: 13px;
align-self: center;
}
.safe-tip { margin-top: 40px; font-size: 12px; color: #67c23a; display: flex; align-items: center; justify-content: center; gap: 5px; }
.active-card {