feat(scan): 出库/借库扫码页接入草稿,切换单据不清空

交互
----
无需「暂停」按钮 —— 在下拉框切换单据这个动作本身就是暂停:
  切走 → 自动存当前单据的进度
  切回 → 自动恢复,并提示「已恢复上次的扫码进度(N 项)」
下拉框对扫到一半的单据显示橙色「已扫 N」徽标,不必逐个点开试。
提交成功后自动清除该单据的草稿。

修复的三个 bug
--------------
1) 切换时把 A 的内容存到了 B 名下
   v-model="selectedRequestId" 的 computed setter 会**先于** @change 把
   selectedRequest 改成新单,故 handleRequestChange 里读到的是新单。
   新增 activeRequestId ref 记录「界面上真正显示的是哪张单」,
   保存时显式传入离开的那张单的 ID。

2) 切回时把目标单的旧草稿删了
   原先写了「购物车为空则清除草稿」,但切换瞬间购物车必然为空,
   于是切回 A 时触发了清除。现改为空清单只跳过保存、不清除;
   清理由「提交成功」或「用户点清空列表」显式触发。

3) 恢复后名称/规格为空、出库数显示 NaN
   draftPayload 只存了 4 个字段(stock_id/source_table/sku/quantity),
   而购物车表格绑定的是 name/spec_model/available_quantity/out_quantity
   —— 全都没存。现保存完整快照,并在恢复时归一化
   (out_quantity ?? quantity)以兼容已存在的旧草稿。

补充:恢复后刷新实时库存
------------------------
草稿里的 available_quantity 是扫描那一刻的快照,跨时间恢复可能已过期
(期间别人出库/借出会消耗可用量)。恢复后复用 /alternatives 端点拉一次
实时可用量:数量超了会明确提示「N 项物料的实际库存已少于你扫的数量」,
避免工人扫满后到提交时才被后端拒绝。失败不阻断,沿用草稿快照。

另:离开页面(路由跳转)时存草稿并弹确认;beforeunload 用 sendBeacon
尽力保存(该路径无法带 Authorization 头,可能失败,但防抖保存已覆盖
绝大部分内容)。
This commit is contained in:
yueli
2026-09-10 17:21:24 +08:00
parent ec66c33b06
commit a4a9afb6db
3 changed files with 501 additions and 5 deletions

View File

@ -355,10 +355,11 @@
<script setup lang="ts">
import { ref, reactive, nextTick, onUnmounted, computed } from 'vue'
import { onBeforeRouteLeave } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Scissor, EditPen, Delete, CameraFilled, Close, Refresh, Select, LocationInformation } from '@element-plus/icons-vue'
import QrScanner from '@/components/QrScanner/index.vue'
import { getStockByBarcode, getStockAlternatives } from '@/api/outbound'
import { getStockByBarcode, getStockAlternatives, getScanDraft, saveScanDraft, clearScanDraft } from '@/api/outbound'
import { dispatchBorrow, getBorrowApprovalList } from '@/api/transaction'
import { uploadFile } from '@/api/common/upload'
import { useUserStore } from '@/stores/user'
@ -486,7 +487,12 @@ const loadApprovalRequests = async () => {
}
// ★ 切换审批单时:带出申请时填写的归还日期/长期借用,并清空购物车和签名,防止跨单据污染
const handleApprovalChange = (val: number | null) => {
const handleApprovalChange = async (val: number | null) => {
// ★ 关键:selectedApproval 已被 computed 更新为新单,
// 故用 activeApprovalId 拿到「刚离开的那张单」来保存
const leavingId = activeApprovalId.value
await saveDraftNow(leavingId)
if (!val) {
selectedApprovalId.value = null
}
@ -510,6 +516,140 @@ const handleApprovalChange = (val: number | null) => {
signatureFile.value = null
signaturePreviewUrl.value = ''
barcodeInput.value = ''
activeApprovalId.value = val // ★ 切换到新单
// ★ 载入目标单据的草稿(之前扫到一半则自动恢复)
if (val) await restoreDraft(val)
}
// ============================================================================
// ★ 扫码草稿(与扫码出库页同款)
//
// 场景:一张单几十项,扫到一半被更紧急的单打断,回来接着扫。
// 库存在申请审批通过时已预占,暂停期间不会被他人抢走,故草稿只记「扫到哪了」。
// 隔离:后端按 (user_id, 单据ID) 隔离,一人一单互不影响。
// ============================================================================
let draftTimer: ReturnType<typeof setTimeout> | null = null
// ★ 必须保存购物车行的**全部展示字段**,不能只存定位信息。
// 原先只存 4 个字段,恢复后名称/库存为空、借用数显示 NaN。
const draftPayload = () => cartItems.value.map((it: any) => ({
id: it.id,
source_table: it.source_table,
sku: it.sku || '',
name: it.name || '',
spec_model: it.spec_model || '',
warehouse_location: it.warehouse_location || '',
barcode: it.barcode || '',
available_quantity: Number(it.available_quantity) || 0,
price: Number(it.price) || 0,
out_quantity: Number(it.out_quantity) || 0,
}))
// 监听中的单据ID
//
// ★ 为什么需要它:selectedApproval 是由 selectedApprovalId 派生的 computed,
// 切换时它**立刻**变成新单。若在 handleApprovalChange 里读 selectedApproval,
// 拿到的已经是新单 —— 会把 A 的内容存到 B 名下。用一个独立的 ref 记录
// 「当前界面上显示的是哪张单」,保存时以此为准。
const activeApprovalId = ref<number | null>(null)
const saveDraftNow = async (requestId?: number | null) => {
if (draftTimer) { clearTimeout(draftTimer); draftTimer = null }
const rid = requestId ?? activeApprovalId.value
if (!rid) return
const items = draftPayload()
// ★ 空清单不清除草稿:切换单据瞬间购物车必然为空,若此时清除会误删
// 目标单的旧草稿(这正是"切回来就没了"的直接原因)。清理由提交成功触发。
if (items.length === 0) return
try {
await saveScanDraft('borrow', rid, items, selectedApproval.value?.request_no)
} catch (e) {
console.warn('保存草稿失败', e) // 不阻断作业
}
}
const scheduleSaveDraft = () => {
if (draftTimer) clearTimeout(draftTimer)
draftTimer = setTimeout(() => saveDraftNow(), 800)
}
const restoreDraft = async (requestId: number) => {
try {
const res: any = await getScanDraft('borrow', requestId)
const items = res?.data?.items || []
if (!items.length) return
// 归一化:老草稿用 quantity,新草稿用 out_quantity;缺失字段补默认值,
// 避免购物车出现 NaN(NaN 提交时会被兜底成 1,造成静默的数量错误)
cartItems.value = items.map((it: any) => ({
...it,
id: it.id ?? it.stock_id,
out_quantity: Number(it.out_quantity ?? it.quantity) || 0,
available_quantity: Number(it.available_quantity) || 0,
price: Number(it.price) || 0,
name: it.name || '',
spec_model: it.spec_model || '',
}))
ElMessage.success(`已恢复上次的扫码进度(${items.length} 项)`)
// ★ 刷新库存:草稿里的 available_quantity 是扫描那一刻的快照,
// 期间别人出库/借出会消耗可用量,需重新拉取实时值。
await refreshStockFromDraft()
} catch (e) {
console.warn('恢复草稿失败', e)
}
}
/**
* 用实时库存刷新购物车行的「库存」列(与扫码出库页同款)。
* 复用 /alternatives 端点;失败不阻断,沿用草稿快照。
*/
const refreshStockFromDraft = async () => {
const plan = selectedApproval.value?.items || []
const baseIds = [...new Set(plan.map((p: any) => p.base_id).filter(Boolean))]
if (!baseIds.length) return
try {
const results = await Promise.all(
(baseIds as number[]).map(bid => getStockAlternatives(bid).catch(() => null))
)
const latestByKey = new Map<string, number>()
for (const r of results) {
for (const a of ((r as any)?.data?.items || [])) {
latestByKey.set(`${a.source_table}_${a.stock_id}`, Number(a.available_quantity) || 0)
}
}
if (!latestByKey.size) return
let changed = 0
for (const row of cartItems.value) {
const key = `${row.source_table}_${row.id}`
if (!latestByKey.has(key)) continue
const latest = latestByKey.get(key)!
if (latest !== Number(row.available_quantity)) {
row.available_quantity = latest
changed++
}
}
const over = cartItems.value.filter(
(r: any) => Number(r.out_quantity) > Number(r.available_quantity)
)
if (over.length > 0) {
ElMessage.warning(
`${over.length} 项物料的实际库存已少于你扫的数量` +
`(如 ${over[0].name || over[0].sku}),请核对后再提交`
)
} else if (changed > 0) {
ElMessage.info(`已刷新 ${changed} 项物料的实时库存`)
}
} catch (e) {
console.warn('刷新库存失败', e)
}
}
// ★ 扫码校验:比对扫描物料是否在审批计划清单内,且累计数量不超过审批上限
@ -679,6 +819,7 @@ const handleManualInput = async () => {
out_quantity: 1,
price: 0
})
scheduleSaveDraft() // ★ 自动存草稿(防抖),中途离开可恢复
ElMessage.success(`添加成功: ${item.name}`)
if (navigator.vibrate) navigator.vibrate(100)
barcodeInput.value = ''
@ -734,6 +875,8 @@ const submitForm = async () => {
if (!formRef.value) return
if (cartItems.value.length === 0) return ElMessage.warning('请先添加物品')
if (!selectedApprovalId.value) return ElMessage.warning('请选择关联的审批申请单')
// 记下单据ID:清空 cartItems 后可能被重置,草稿清除需要它
const submittedRequestId = selectedApprovalId.value
await formRef.value.validate(async (valid: boolean) => {
if (!valid) {
@ -782,6 +925,12 @@ const submitForm = async () => {
})
ElMessage.success('借用成功')
// ★ 提交成功 → 清除该单据的草稿,避免下次打开恢复出已提交的内容
if (submittedRequestId) {
clearScanDraft('borrow', submittedRequestId).catch(() => {})
}
cartItems.value = []
form.borrower_name = ''
form.expected_return_time = ''
@ -883,7 +1032,46 @@ onMounted(() => {
onUnmounted(() => {
if (signaturePreviewUrl.value) URL.revokeObjectURL(signaturePreviewUrl.value)
if (draftTimer) clearTimeout(draftTimer)
window.removeEventListener('beforeunload', handleBeforeUnload)
})
// ★ 离开页面前立即存草稿(防抖中未落盘的内容会丢)
onBeforeRouteLeave(async (_to, _from, next) => {
if (draftTimer) { clearTimeout(draftTimer); draftTimer = null }
const hasItems = cartItems.value.length > 0 && selectedApproval.value?.id
if (hasItems) {
await saveDraftNow()
try {
await ElMessageBox.confirm(
`当前单据【${selectedApproval.value.request_no}】已扫 ${cartItems.value.length} 项尚未提交。\n\n` +
`已自动保存为草稿,下次选择该单据时可继续扫码。\n确认离开吗?`,
'离开确认',
{ confirmButtonText: '离开', cancelButtonText: '留下继续', type: 'warning' }
)
} catch (e) {
return next(false)
}
}
next()
})
// 刷新/关闭浏览器时尽力保存(sendBeacon 不阻塞卸载)
const handleBeforeUnload = () => {
const req = selectedApproval.value
if (!req?.id || cartItems.value.length === 0) return
try {
const token = localStorage.getItem('token') || ''
const blob = new Blob([JSON.stringify({
biz_type: 'borrow', request_id: req.id, request_no: req.request_no,
items: draftPayload(),
})], { type: 'application/json' })
navigator.sendBeacon?.(`/api/v1/scan-draft?token=${encodeURIComponent(token)}`, blob)
} catch (e) { /* 尽力而为 */ }
}
onMounted(() => { window.addEventListener('beforeunload', handleBeforeUnload) })
</script>
<style scoped>