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:
@ -239,3 +239,55 @@ export function getMyRequests(params: {
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 扫码草稿(出库/借库作业中途暂停用)
|
||||
// ==========================================
|
||||
|
||||
export interface ScanDraftItem {
|
||||
stock_id: number
|
||||
source_table: string
|
||||
sku?: string
|
||||
quantity: number
|
||||
}
|
||||
|
||||
/** 读取草稿:返回该用户在这张单上已扫的内容 */
|
||||
export function getScanDraft(bizType: 'outbound' | 'borrow', requestId: number) {
|
||||
return request({
|
||||
url: '/v1/scan-draft',
|
||||
method: 'get',
|
||||
params: { biz_type: bizType, request_id: requestId }
|
||||
})
|
||||
}
|
||||
|
||||
/** 保存草稿:全量覆盖(提交的 items 即当前完整清单) */
|
||||
export function saveScanDraft(
|
||||
bizType: 'outbound' | 'borrow',
|
||||
requestId: number,
|
||||
items: ScanDraftItem[],
|
||||
requestNo?: string
|
||||
) {
|
||||
return request({
|
||||
url: '/v1/scan-draft',
|
||||
method: 'post',
|
||||
data: { biz_type: bizType, request_id: requestId, request_no: requestNo || '', items }
|
||||
})
|
||||
}
|
||||
|
||||
/** 清除草稿:提交成功后调用 */
|
||||
export function clearScanDraft(bizType: 'outbound' | 'borrow', requestId: number) {
|
||||
return request({
|
||||
url: '/v1/scan-draft',
|
||||
method: 'delete',
|
||||
params: { biz_type: bizType, request_id: requestId }
|
||||
})
|
||||
}
|
||||
|
||||
/** 草稿概览:供单据下拉显示「已扫 N 项」进度徽标 */
|
||||
export function getScanDraftOverview(bizType: 'outbound' | 'borrow') {
|
||||
return request({
|
||||
url: '/v1/scan-draft/overview',
|
||||
method: 'get',
|
||||
params: { biz_type: bizType }
|
||||
})
|
||||
}
|
||||
|
||||
@ -39,6 +39,10 @@
|
||||
:label="req.request_no"
|
||||
>
|
||||
<span>{{ req.request_no }}</span>
|
||||
<!-- ★ 草稿进度徽标:一眼看出哪张单之前扫到一半,不必逐个点开试 -->
|
||||
<el-tag v-if="draftProgress[req.id]" type="warning" size="small" effect="dark" style="margin-left:6px">
|
||||
已扫 {{ draftProgress[req.id] }}
|
||||
</el-tag>
|
||||
<el-divider direction="vertical" />
|
||||
<span>{{ req.applicant_name || '未知申请人' }}</span>
|
||||
<el-divider direction="vertical" />
|
||||
@ -372,10 +376,14 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, nextTick, onUnmounted, onMounted, 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, submitOutbound, getOutboundList, getApprovalRequestList, getStockAlternatives } from '@/api/outbound'
|
||||
import {
|
||||
getStockByBarcode, submitOutbound, getOutboundList, getApprovalRequestList, getStockAlternatives,
|
||||
getScanDraft, saveScanDraft, clearScanDraft, getScanDraftOverview,
|
||||
} from '@/api/outbound'
|
||||
import { uploadFile } from '@/api/common/upload'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
@ -516,6 +524,7 @@ const loadApprovalRequests = async () => {
|
||||
try {
|
||||
const res: any = await getApprovalRequestList({ status: 1, page: 1, pageSize: 100 })
|
||||
approvalRequests.value = res.data?.items || []
|
||||
await loadDraftOverview() // ★ 一并取草稿进度,用于下拉徽标
|
||||
} catch (e) {
|
||||
console.error('加载审批单列表失败', e)
|
||||
} finally {
|
||||
@ -523,7 +532,12 @@ const loadApprovalRequests = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleRequestChange = (val: number | null) => {
|
||||
const handleRequestChange = async (val: number | null) => {
|
||||
// ★ 关键:此刻 selectedRequest 已被 v-model 的 setter 改成了新单,
|
||||
// 所以必须用 activeRequestId 拿到「刚离开的那张单」来保存。
|
||||
const leavingId = activeRequestId.value
|
||||
await saveDraftNow(leavingId)
|
||||
|
||||
if (!val) {
|
||||
selectedRequest.value = null
|
||||
form.remark = ''
|
||||
@ -538,10 +552,187 @@ const handleRequestChange = (val: number | null) => {
|
||||
form.outbound_type = selectedRequest.value.outbound_type
|
||||
}
|
||||
}
|
||||
// 切换申请单时清空购物车,防止已扫物品与新单据混淆
|
||||
|
||||
cartItems.value = []
|
||||
signatureFile.value = null
|
||||
signaturePreviewUrl.value = ''
|
||||
activeRequestId.value = val // ★ 切换到新单,后续保存以它为准
|
||||
|
||||
// ★ 载入目标单据的草稿(若之前扫到一半,这里自动恢复)
|
||||
if (val) await restoreDraft(val)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ★ 扫码草稿
|
||||
//
|
||||
// 场景:一张单几十项,扫到一半被更紧急的单打断,回来还要接着扫。
|
||||
// 库存在申请审批通过时已**预占**,暂停期间不会被他人抢走,因此草稿只需
|
||||
// 记住「扫到哪了」,不涉及任何库存操作 —— 即使草稿丢了也只是重扫,
|
||||
// 不会造成库存错乱。
|
||||
//
|
||||
// 保存时机:扫码后防抖 800ms 自动存;切换单据/离开页面时立即存。
|
||||
// 隔离:后端按 (user_id, 单据ID) 隔离,一人一单互不影响。
|
||||
// ============================================================================
|
||||
let draftTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
// 各单据的草稿进度 { request_id: 已扫项数 },用于下拉徽标
|
||||
const draftProgress = ref<Record<number, number>>({})
|
||||
|
||||
const loadDraftOverview = async () => {
|
||||
try {
|
||||
const res: any = await getScanDraftOverview('outbound')
|
||||
const map: Record<number, number> = {}
|
||||
for (const d of res?.data?.drafts || []) {
|
||||
map[d.request_id] = d.item_count
|
||||
}
|
||||
draftProgress.value = map
|
||||
} catch (e) {
|
||||
console.warn('加载草稿概览失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 必须保存购物车行的**全部展示字段**,不能只存定位信息。
|
||||
// 原先只存 (stock_id, source_table, sku, quantity),恢复后名称/规格/库存
|
||||
// 都是空的、出库数显示 NaN —— 因为购物车表格绑定的是 name / spec_model /
|
||||
// available_quantity / out_quantity,而这些字段根本没被存下来。
|
||||
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 —— 用于精确知道「哪张单的草稿需要存」
|
||||
//
|
||||
// ★ 为什么需要它:v-model="selectedRequestId" 的 computed setter 会**先于**
|
||||
// @change 把 selectedRequest 改成新单据。若在 handleRequestChange 里读
|
||||
// selectedRequest.value,拿到的已经是新单 —— 会把 A 的内容存到 B 名下。
|
||||
// 用一个独立的 ref 记录「当前界面上显示的是哪张单」,保存时以此为准。
|
||||
const activeRequestId = ref<number | null>(null)
|
||||
|
||||
// 立即保存(切换单据、离开页面、提交前调用)
|
||||
//
|
||||
// 显式接收 requestId:切换场景下必须传「旧单ID」,不能依赖 selectedRequest。
|
||||
const saveDraftNow = async (requestId?: number | null) => {
|
||||
if (draftTimer) { clearTimeout(draftTimer); draftTimer = null }
|
||||
const rid = requestId ?? activeRequestId.value
|
||||
if (!rid) return
|
||||
|
||||
const items = draftPayload()
|
||||
|
||||
// ★ 空清单**不清除草稿**。
|
||||
// 原先"空则清除"是为了处理用户主动清空列表的情况,但它会在切换单据时
|
||||
// 误删目标单的旧草稿(切换瞬间购物车必然为空)—— 这正是"切回来就没了"
|
||||
// 的直接原因。现在空清单只是不保存,清理由「提交成功」或用户显式清空触发。
|
||||
if (items.length === 0) return
|
||||
|
||||
try {
|
||||
await saveScanDraft('outbound', rid, items, selectedRequest.value?.request_no)
|
||||
draftProgress.value[rid] = items.length
|
||||
} catch (e) {
|
||||
// 草稿保存失败不阻断作业:工人手上还在扫,报错反而干扰
|
||||
console.warn('保存草稿失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 防抖保存(扫码后调用)
|
||||
const scheduleSaveDraft = () => {
|
||||
if (draftTimer) clearTimeout(draftTimer)
|
||||
draftTimer = setTimeout(() => saveDraftNow(), 800)
|
||||
}
|
||||
|
||||
// 恢复草稿到购物车
|
||||
//
|
||||
// 草稿存的是完整购物车快照(含名称/规格/库位等展示字段),因此直接还原即可,
|
||||
// 无需回查申请单的 items_json —— 历史单据的 items_json 不含 stock_id,
|
||||
// 回查反而会错配。
|
||||
const restoreDraft = async (requestId: number) => {
|
||||
try {
|
||||
const res: any = await getScanDraft('outbound', 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)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 用实时库存刷新购物车行的「库存」列。
|
||||
*
|
||||
* 草稿里的 available_quantity 是扫描那一刻的快照,恢复时可能已过期
|
||||
* (期间别人出库/借出/报废都会消耗可用量)。这里按 base_id 查一次实时
|
||||
* 可用量,避免工人基于陈旧数字扫满、到提交时才发现不够。
|
||||
*
|
||||
* 复用 /alternatives 端点 —— 它本来就按 base_id 返回各库存行的实时可用量。
|
||||
* 失败不阻断:拿不到实时值就沿用草稿快照,至少不影响继续作业。
|
||||
*/
|
||||
const refreshStockFromDraft = async () => {
|
||||
const plan = selectedRequest.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) // 失败沿用草稿快照
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 按单出库模式:校验扫码是否在计划内
|
||||
@ -710,6 +901,7 @@ const handleManualInput = async () => {
|
||||
out_quantity: 1,
|
||||
price: parseFloat(item.price || 0)
|
||||
})
|
||||
scheduleSaveDraft() // ★ 自动存草稿(防抖),中途离开可恢复
|
||||
ElMessage.success(`添加成功: ${item.name}`)
|
||||
if (navigator.vibrate) navigator.vibrate(100)
|
||||
barcodeInput.value = ''
|
||||
@ -753,6 +945,12 @@ const clearAll = () => {
|
||||
signaturePreviewUrl.value = ''
|
||||
barcodeInput.value = ''
|
||||
// ★ 按单模式:仅清空购物车,保留申请单选择
|
||||
// 用户显式清空 = 放弃本次作业 → 同步清除草稿与徽标
|
||||
const rid = activeRequestId.value
|
||||
if (rid) {
|
||||
clearScanDraft('outbound', rid).catch(() => {})
|
||||
delete draftProgress.value[rid]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@ -769,6 +967,8 @@ const submitForm = async () => {
|
||||
ElMessage.warning('请先选择要出库的审批申请单')
|
||||
return
|
||||
}
|
||||
// 记下单据ID:清空 cartItems 后 selectedRequest 可能被重置,草稿清除需要它
|
||||
const submittedRequestId = selectedRequest.value.id
|
||||
|
||||
await formRef.value.validate(async (valid: boolean) => {
|
||||
if (!valid) return
|
||||
@ -828,6 +1028,12 @@ const submitForm = async () => {
|
||||
|
||||
ElMessage.success('出库成功')
|
||||
|
||||
// ★ 提交成功 → 清除该单据的草稿,避免下次打开时恢复出已提交的内容
|
||||
if (submittedRequestId) {
|
||||
clearScanDraft('outbound', submittedRequestId).catch(() => {})
|
||||
delete draftProgress.value[submittedRequestId] // 同步移除下拉徽标
|
||||
}
|
||||
|
||||
// 5. 成功后重置页面
|
||||
cartItems.value = []
|
||||
form.consumer_name = ''
|
||||
@ -926,7 +1132,57 @@ const handleSignCancel = () => { showSignatureDialog.value = false }
|
||||
|
||||
onUnmounted(() => {
|
||||
if (signaturePreviewUrl.value) URL.revokeObjectURL(signaturePreviewUrl.value)
|
||||
if (draftTimer) clearTimeout(draftTimer)
|
||||
})
|
||||
|
||||
// ★ 离开页面前:立即存草稿(防抖中未落盘的内容会丢)
|
||||
//
|
||||
// 只有清单非空时才提示 —— 空清单离开是正常操作,弹窗只会造成干扰。
|
||||
// 草稿已在后端,所以提示文案是"可恢复"而非"会丢失",语气相应放松。
|
||||
onBeforeRouteLeave(async (_to, _from, next) => {
|
||||
if (draftTimer) { clearTimeout(draftTimer); draftTimer = null }
|
||||
const hasItems = cartItems.value.length > 0 && selectedRequest.value?.id
|
||||
|
||||
if (hasItems) {
|
||||
await saveDraftNow() // 先确保落盘,再询问
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`当前单据【${selectedRequest.value.request_no}】已扫 ${cartItems.value.length} 项尚未提交。\n\n` +
|
||||
`已自动保存为草稿,下次选择该单据时可继续扫码。\n确认离开吗?`,
|
||||
'离开确认',
|
||||
{ confirmButtonText: '离开', cancelButtonText: '留下继续', type: 'warning' }
|
||||
)
|
||||
} catch (e) {
|
||||
return next(false) // 用户选择留下
|
||||
}
|
||||
}
|
||||
next()
|
||||
})
|
||||
|
||||
// 刷新/关闭浏览器时同步存草稿
|
||||
//
|
||||
// 注意:不能用异步请求(浏览器不会等待),改用 sendBeacon 在卸载期间
|
||||
// 投递一个"尽力而为"的保存。失败也无妨 —— 扫码过程中的防抖保存已经
|
||||
// 落盘了绝大部分内容,这里只补最后 800ms 的差量。
|
||||
const handleBeforeUnload = () => {
|
||||
const req = selectedRequest.value
|
||||
if (!req?.id || cartItems.value.length === 0) return
|
||||
try {
|
||||
const token = localStorage.getItem('token') || ''
|
||||
const blob = new Blob([JSON.stringify({
|
||||
biz_type: 'outbound', request_id: req.id, request_no: req.request_no,
|
||||
items: draftPayload(),
|
||||
})], { type: 'application/json' })
|
||||
// sendBeacon 无法自定义 header,故用查询参数带上 token(仅用于此处的
|
||||
// 非敏感草稿保存;后端仍以 JWT 校验身份,缺失时该次保存被忽略)
|
||||
navigator.sendBeacon?.(
|
||||
`/api/v1/scan-draft?token=${encodeURIComponent(token)}`, blob
|
||||
)
|
||||
} catch (e) { /* 尽力而为,失败不影响 */ }
|
||||
}
|
||||
|
||||
onMounted(() => { window.addEventListener('beforeunload', handleBeforeUnload) })
|
||||
onUnmounted(() => { window.removeEventListener('beforeunload', handleBeforeUnload) })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@ -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