fix(stocktake): 扫码落库改为确认后反馈,并增加最近扫码记录

【修复静默丢数据】
原 syncToBackend 是乐观更新:addDraft 还没返回就先弹「已记录实盘」,
UI 提示早于落库确认。网络抖动时工人以为扫进去了,这条实盘记录实际已丢,
而状态标签还可能被后续成功的请求覆盖成绿色,失败也无人重试。

现改为:
- 只有 addDraft 真正 resolve 后才提示成功、记流水、刷统计
- catch 时用 10 秒停留 + 可关闭的错误提示,明确告知
  「网络异常,上一笔条码 [XXX] 录入失败,请重试!」

【新增最近扫码记录】
统计看板下方增加 recentScans(最多 10 条),落库成功后 unshift
物料名称/规格/数量/时间,用轻量列表展示并高亮最新一条,
供安卓平板大屏即时确认。切换/加入会话时清空,避免看到上一轮残留。

【统一统计口径】
totalScannedCount 原被 all-items 与 merged-list 两处写入。核对后端
两个接口的 SQL 实际完全一致(均为 COUNT(DISTINCT (source_table, stock_id))
且都包含 system 自动漏盘记录),故数值本身不会分歧;但仍去掉 all-items
那一处写入,统一由 merged-list(fetchInventoryList)作为唯一来源。

【清理死代码】
删除从未使用的 allScannedDrafts 与 listTotal。
This commit is contained in:
yueli
2026-09-11 13:20:15 +08:00
parent 5b0c7c1382
commit cc176f6e8b

View File

@ -153,6 +153,26 @@
<div class="stat-arrow"><el-icon><ArrowRight /></el-icon></div>
</div>
<!-- 最近扫码记录平板大屏的即时反馈扫完瞥一眼就能确认是否扫错/漏扫 -->
<div v-if="recentScans.length" class="recent-scans">
<div class="recent-scans-title">
<span>最近扫码</span>
<span class="recent-scans-hint">最新 {{ recentScans.length }} </span>
</div>
<ul class="recent-scans-list">
<li
v-for="(r, i) in recentScans"
:key="`${r.uuid}-${i}`"
:class="{ 'is-latest': i === 0 }"
>
<span class="rs-time">{{ r.time }}</span>
<span class="rs-name">{{ r.name }}</span>
<span class="rs-spec">{{ r.spec }}</span>
<span class="rs-qty">×{{ r.quantity }}</span>
</li>
</ul>
</div>
<div class="main-actions">
<el-row :gutter="10">
<el-col :span="8">
@ -537,17 +557,27 @@ 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[]>([]) // 全量草稿记录(脱离分页和过滤)
// ★ 后端去重的真实已盘数量。唯一写入点是 fetchInventoryListmerged-list
// 见下方注释 —— 不要再从 all-items 写入,避免两个接口各自为政
const totalScannedCount = ref(0)
const listTotalFiltered = ref(0) // 过滤后的总数
// ★ 最近扫码记录:平板大屏给工人的即时反馈,扫完瞥一眼就能确认有没有扫错/漏扫
const RECENT_SCAN_LIMIT = 10
const recentScans = ref<Array<{
uuid: string
name: string
spec: string
quantity: number
time: string
}>>([])
// ★ 新增: 会话ID
const currentSessionId = ref<string>('')
@ -637,7 +667,8 @@ const fetchAllStockItems = async (page = 1) => {
allStockItems.value = res.data.items || []
// ★ 使用返回的 total 获取真实总数,而不是数组长度
totalStockCount.value = res.data.total || allStockItems.value.length
totalScannedCount.value = res.data.total_scanned || 0
// 注意:这里刻意不再写 totalScannedCount
// 该值统一以 merged-listfetchInventoryList为准避免两个接口双写。
}
} catch (e) {
console.error('获取应盘物资清单失败', e)
@ -805,6 +836,8 @@ const checkServerDraft = async () => {
const enterScanning = async (message: string) => {
isSessionActive.value = true
localStorage.setItem('stocktake_phase', 'scanning')
// 最近扫码流水属于「本次作业」,换会话/重新加入都要清空,避免看到上一个会话的残留
recentScans.value = []
await fetchAllStockItems()
await fetchInventoryList()
ElMessage.success(message)
@ -1099,29 +1132,56 @@ const handleManualConfirm = () => {
if (!currentItem.value) return
const val = inputQty.value === undefined ? 0 : inputQty.value
const remark = inputRemark.value
const item = currentItem.value
// ★★★ 直接保存到后端,不使用本地缓存 ★★★
// 先收起弹窗;成功提示延后到落库确认之后(不再乐观提示)
showQtyDialog.value = false
inputRemark.value = ''
ElMessage.success(`已记录实盘: ${val}`)
// ★★★ 异步保存到后端,不阻塞 UIfire-and-forget★★★
syncToBackend(currentItem.value.uuid, val, remark)
syncToBackend(item, val, remark)
}
// ★★★ 乐观更新:异步保存到后端,不阻塞 UI ★★★
const syncToBackend = (uuid: string, quantity: number, remark: string) => {
// ★ 落库成功后才记入最近流水
const pushRecentScan = (item: StockItem, quantity: number) => {
recentScans.value.unshift({
uuid: item.uuid,
name: item.name,
spec: item.standard || '',
quantity,
time: new Date().toLocaleTimeString('zh-CN', { hour12: false })
})
if (recentScans.value.length > RECENT_SCAN_LIMIT) {
recentScans.value.length = RECENT_SCAN_LIMIT
}
}
// ★ 保存到后端并等待确认。
// 不再「乐观更新」:只有 addDraft 真正 resolve 后才提示成功、记流水、刷统计。
// 否则请求失败时工人已看到「已记录」,会以为扫进去了,而这条实盘记录其实丢了。
const syncToBackend = (item: StockItem, quantity: number, remark: string) => {
const uuid = item.uuid
syncStatus.value = 'syncing'
api.addDraft({ uuid, quantity, remark })
.then(() => {
syncStatus.value = 'success'
ElMessage.success(`已录入:${item.name} × ${quantity}`)
pushRecentScan(item, quantity)
// 静默刷新统计数字
fetchInventoryList(true)
// ★ 扫码成功:该物品置顶到当前视图第一行并高亮
pinScannedItem(uuid)
})
.catch(() => {
.catch((e: any) => {
syncStatus.value = 'failed'
// ★ 这一笔确实没进库,且系统不会自动重试 —— 必须让工人看到并手动重扫。
// 停留久一点 + 给关闭按钮,避免连续扫码时提示被后续消息挤掉。
ElMessage({
type: 'error',
duration: 10000,
showClose: true,
message: `网络异常,上一笔条码 [${uuid}] 录入失败,请重试!`
})
console.error('扫码落库失败', e)
})
}
@ -1518,6 +1578,54 @@ const goToVarianceReview = () => {
.stat-card.error .stat-val { color: #f56c6c; }
.stat-arrow { width: 20px; color: #c0c4cc; }
/* ★ 最近扫码记录:平板大屏用,行高放大便于一瞥确认 */
.recent-scans {
background: #fff;
border: 1px solid #ebeef5;
border-radius: 8px;
padding: 10px 12px;
margin-bottom: 20px;
flex-shrink: 0;
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
}
.recent-scans-title {
display: flex;
justify-content: space-between;
align-items: baseline;
font-size: 13px;
font-weight: 600;
color: #303133;
margin-bottom: 6px;
}
.recent-scans-hint { font-size: 11px; font-weight: 400; color: #909399; }
.recent-scans-list {
list-style: none;
margin: 0;
padding: 0;
max-height: 220px;
overflow-y: auto;
}
.recent-scans-list li {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border-radius: 4px;
font-size: 14px;
line-height: 1.4;
}
.recent-scans-list li:nth-child(odd) { background: #fafafa; }
/* 最新一条高亮,扫完立刻能对上 */
.recent-scans-list li.is-latest {
background: #f0f9eb;
border-left: 3px solid #67c23a;
font-weight: 600;
}
.rs-time { flex: 0 0 auto; font-size: 12px; color: #909399; font-variant-numeric: tabular-nums; }
.rs-name { flex: 1 1 auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #303133; }
.rs-spec { flex: 0 1 auto; max-width: 30%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; color: #909399; }
.rs-qty { flex: 0 0 auto; font-weight: 700; color: #67c23a; font-variant-numeric: tabular-nums; }
.w-100 { width: 100%; }
.action-btn { font-weight: bold; height: 48px; }