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:
@ -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[]>([]) // 全量草稿记录(脱离分页和过滤)
|
||||
// ★ 后端去重的真实已盘数量。唯一写入点是 fetchInventoryList(merged-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-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; }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user