From cc176f6e8b40772bcfa656ad9a11bd0459feca47 Mon Sep 17 00:00:00 2001 From: yueli Date: Fri, 11 Sep 2026 13:20:15 +0800 Subject: [PATCH] =?UTF-8?q?fix(stocktake):=20=E6=89=AB=E7=A0=81=E8=90=BD?= =?UTF-8?q?=E5=BA=93=E6=94=B9=E4=B8=BA=E7=A1=AE=E8=AE=A4=E5=90=8E=E5=8F=8D?= =?UTF-8?q?=E9=A6=88=EF=BC=8C=E5=B9=B6=E5=A2=9E=E5=8A=A0=E6=9C=80=E8=BF=91?= =?UTF-8?q?=E6=89=AB=E7=A0=81=E8=AE=B0=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 【修复静默丢数据】 原 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。 --- .../src/views/stock/stocktake/index.vue | 130 ++++++++++++++++-- 1 file changed, 119 insertions(+), 11 deletions(-) diff --git a/inventory-web/src/views/stock/stocktake/index.vue b/inventory-web/src/views/stock/stocktake/index.vue index f6a5e98..37a6f3b 100644 --- a/inventory-web/src/views/stock/stocktake/index.vue +++ b/inventory-web/src/views/stock/stocktake/index.vue @@ -153,6 +153,26 @@
+ +
+
+ 最近扫码 + 最新 {{ recentScans.length }} 条 +
+ +
+
@@ -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([]) const listStatusFilter = ref<'all' | 'counted' | 'uncounted'>('all') const allStockItems = ref([]) // 全量应盘物资(盘点基数) const totalStockCount = ref(0) // ★ 全量应盘物资总数(不受limit限制) -const totalScannedCount = ref(0) // ★ 后端去重的真实已盘数量 -const allScannedDrafts = ref([]) // 全量草稿记录(脱离分页和过滤) +// ★ 后端去重的真实已盘数量。唯一写入点是 fetchInventoryList(merged-list), +// 见下方注释 —— 不要再从 all-items 写入,避免两个接口各自为政 +const totalScannedCount = ref(0) const listTotalFiltered = ref(0) // 过滤后的总数 +// ★ 最近扫码记录:平板大屏给工人的即时反馈,扫完瞥一眼就能确认有没有扫错/漏扫 +const RECENT_SCAN_LIMIT = 10 +const recentScans = ref>([]) + // ★ 新增: 会话ID const currentSessionId = ref('') @@ -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-list(fetchInventoryList)为准,避免两个接口双写。 } } 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}`) - // ★★★ 异步保存到后端,不阻塞 UI(fire-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; }