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:
@ -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>
|
||||
|
||||
Reference in New Issue
Block a user