fix(pda): guard duplicate submission and invalidate hidden task requests
This commit is contained in:
+25
-13
@@ -282,7 +282,7 @@
|
||||
<uni-popup ref="submitConfirmPopup" type="bottom" background-color="#ffffff" border-radius="16px 16px 0 0" :is-mask-click="true" @change="handleOverlayChange('submitConfirm', $event)">
|
||||
<view class="modal-sheet decision-modal submit-decision-modal" role="dialog" aria-modal="true" aria-label="确认未完成提交" @tap.stop>
|
||||
<PopupHeader :title="`仍有 ${summary.pending} 件未完成`" tone="danger" @close="closeOverlay" />
|
||||
<text class="decision-desc submit-decision-desc">确认提交后,以下待拣商品会被明确标记为“未拣出”,本次演示提交不可撤销。</text>
|
||||
<text class="decision-desc submit-decision-desc">确认提交后,以下待拣商品会标记为“未拣出”,并记录所选原因。</text>
|
||||
<view class="pending-sku-list">
|
||||
<text v-for="item in pendingItems.slice(0, 3)" :key="item.id">{{ item.location ? `${item.sku} · ${item.location}` : item.sku }}</text>
|
||||
<text v-if="pendingItems.length > 3">另有 {{ pendingItems.length - 3 }} 件</text>
|
||||
@@ -293,7 +293,7 @@
|
||||
</view>
|
||||
<view class="modal-actions submit-decision-actions">
|
||||
<button class="modal-cancel-btn" @tap="closeOverlay">继续拣货</button>
|
||||
<button class="modal-confirm-btn danger-confirm" :disabled="!selectedIncompleteReason" @tap="confirmIncompleteSubmit">确认未拣并提交</button>
|
||||
<button class="modal-confirm-btn danger-confirm" :disabled="submitting || !selectedIncompleteReason" @tap="confirmIncompleteSubmit">{{ submitting ? '正在提交…' : '确认未拣并提交' }}</button>
|
||||
</view>
|
||||
</view>
|
||||
</uni-popup>
|
||||
@@ -571,12 +571,15 @@
|
||||
return
|
||||
}
|
||||
if (this._showRefreshPromise) return this._showRefreshPromise
|
||||
this._showRefreshPromise = (async () => {
|
||||
const showGeneration = this.taskRequestId
|
||||
const refreshPromise = (async () => {
|
||||
await this.refreshAvailableBoxes()
|
||||
if (!this._pageVisible || showGeneration !== this.taskRequestId) return
|
||||
await this.syncRemoteTask()
|
||||
})()
|
||||
try { await this._showRefreshPromise }
|
||||
finally { this._showRefreshPromise = null }
|
||||
this._showRefreshPromise = refreshPromise
|
||||
try { await refreshPromise }
|
||||
finally { if (this._showRefreshPromise === refreshPromise) this._showRefreshPromise = null }
|
||||
},
|
||||
onHide() {
|
||||
this._pageVisible = false
|
||||
@@ -584,10 +587,11 @@
|
||||
this.evidencePreviewOpening = false
|
||||
this.evidencePreviewReturning = true
|
||||
}
|
||||
if (!this.cameraPicking && !this.evidencePreviewReturning) this.invalidateTaskSync()
|
||||
this.stopHardwareScanner()
|
||||
},
|
||||
onUnload() { this.stopHardwareScanner(); this.persistSession(); this.clearTimers() },
|
||||
beforeUnmount() { this.stopHardwareScanner(); this.clearTimers() },
|
||||
onUnload() { this._pageVisible = false; this.invalidateTaskSync(); this.evidenceContextId++; this.stopHardwareScanner(); this.persistSession(); this.clearTimers() },
|
||||
beforeUnmount() { this._pageVisible = false; this.invalidateTaskSync(); this.stopHardwareScanner(); this.clearTimers() },
|
||||
onBackPress() {
|
||||
if (this.activeOverlay) { this.closeOverlay(); return true }
|
||||
if (this.boxDropdownOpen) { this.boxDropdownOpen = false; return true }
|
||||
@@ -595,6 +599,11 @@
|
||||
return false
|
||||
},
|
||||
methods: {
|
||||
invalidateTaskSync() { this.taskRequestId++; this.remoteLoading = false; this._showRefreshPromise = null },
|
||||
isTaskSyncActive(requestId, taskId) {
|
||||
return this._pageVisible === true && requestId === this.taskRequestId
|
||||
&& String(this.remoteTaskId || this.currentBox.taskId) === String(taskId)
|
||||
},
|
||||
containerReference,
|
||||
isSelectableTask(task) {
|
||||
return Boolean(task?.recordStatus === 'ACTIVE'
|
||||
@@ -1090,7 +1099,7 @@
|
||||
return ''
|
||||
},
|
||||
async syncRemoteTask() {
|
||||
if (!session.token) return
|
||||
if (!session.token || !this._pageVisible) return
|
||||
const requestedTaskId = this.remoteTaskId || this.currentBox.taskId
|
||||
if (!requestedTaskId) { this.remoteError = '当前没有可进入的拣货任务,请返回任务列表刷新'; return }
|
||||
this.rememberEvidenceDraft()
|
||||
@@ -1114,7 +1123,7 @@
|
||||
try {
|
||||
const warehouseId = this.currentBox.warehouseId
|
||||
const task = await getContainerPickingTask(selector, warehouseId)
|
||||
if (requestId !== this.taskRequestId) return
|
||||
if (!this.isTaskSyncActive(requestId, requestedTaskId)) return
|
||||
if (!task) throw new Error('服务端未返回当前拣货任务')
|
||||
if (this.isMergedContainerRecord(task)) {
|
||||
this.clearMergedTask(task, previousIdentity)
|
||||
@@ -1181,9 +1190,10 @@
|
||||
if (remoteTask.workflowStatus !== 'AWAITING_REVIEW' && this.assignmentVersion === null) throw new Error('任务领取状态尚未同步,请刷新重试')
|
||||
let claimed = null
|
||||
if (remoteTask.workflowStatus === 'UNASSIGNED') {
|
||||
if (!this.isTaskSyncActive(requestId, requestedTaskId)) return
|
||||
const operationId = `claim-${session.currentUser?.id || 'operator'}-${this.remoteTaskId}-${task.loadingPlanId}-${task.containerBookingId}`
|
||||
claimed = await claimContainerPickingTask(this.remoteTaskId, this.assignmentVersion, operationId)
|
||||
if (requestId !== this.taskRequestId) return
|
||||
if (!this.isTaskSyncActive(requestId, requestedTaskId)) return
|
||||
} else if (remoteTask.workflowStatus === 'IN_PROGRESS') {
|
||||
const assigneeId = remoteTask.assignment?.assigneeId
|
||||
if (!assigneeId || String(assigneeId) !== String(session.currentUser?.id || '')) throw new Error('当前任务已由其他作业员领取,请返回任务列表刷新')
|
||||
@@ -1215,9 +1225,10 @@
|
||||
if (addedItemIds.length) this.showToast(`已同步 ${addedItemIds.length} 条新增待拣明细`, 'warning')
|
||||
}
|
||||
await this.loadLoadingEvidence()
|
||||
if (!this.isTaskSyncActive(requestId, requestedTaskId)) return
|
||||
if (this.loadingStageReady) this.activeStage = 'evidence'
|
||||
} catch (error) {
|
||||
if (requestId !== this.taskRequestId) return
|
||||
if (!this.isTaskSyncActive(requestId, requestedTaskId)) return
|
||||
this.debugSubmit('refresh.error', { requestId, message: error.message || '', code: error.code || '', statusCode: error.statusCode || '', requestIdFromServer: error.requestId || '', traceId: error.traceId || '' })
|
||||
this.remoteError = error.network ? '网络不可用,无法开始拣货,恢复网络后请重试' : this.remoteFailureMessage(error, '任务同步失败')
|
||||
} finally { if (requestId === this.taskRequestId) this.remoteLoading = false }
|
||||
@@ -1675,10 +1686,11 @@
|
||||
this.performSubmit()
|
||||
},
|
||||
confirmIncompleteSubmit() {
|
||||
if (!this.selectedIncompleteReason) return
|
||||
this.performSubmit(this.selectedIncompleteReason)
|
||||
if (this.submitting || !this.selectedIncompleteReason) return
|
||||
return this.performSubmit(this.selectedIncompleteReason)
|
||||
},
|
||||
async performSubmit(incompleteReason = '') {
|
||||
if (this.submitting) return
|
||||
const beforeSubmit = this.summary
|
||||
const remoteRequired = Boolean(session.token)
|
||||
this.remoteError = ''
|
||||
|
||||
@@ -292,7 +292,10 @@ test('picking session persists per task identity and clears on logout', async ()
|
||||
assert.match(store, /savePickingSession/)
|
||||
assert.match(store, /resetPickingSession/)
|
||||
assert.match(session, /signOut\(\)[\s\S]*?resetPickingSession\(\)/)
|
||||
assert.match(page, /onUnload\(\) \{ this\.stopHardwareScanner\(\); this\.persistSession\(\)/)
|
||||
const unload = page.match(/onUnload\(\) \{([^}]+)\}/)?.[1] || ''
|
||||
assert.match(unload, /this\.stopHardwareScanner\(\)/)
|
||||
assert.match(unload, /this\.persistSession\(\)/)
|
||||
assert.match(unload, /this\.invalidateTaskSync\(\)/)
|
||||
})
|
||||
|
||||
test('shared visual contracts preserve accessible PDA targets', async () => {
|
||||
|
||||
@@ -36,12 +36,14 @@ test('duplicate onShow shares one load chain while background return refreshes a
|
||||
const page = await read('../pages/picking/picking.vue')
|
||||
const show = page.match(/async onShow\(\) \{[\s\S]*?\r?\n\t\t\},/)?.[0]
|
||||
const hide = page.match(/onHide\(\) \{[\s\S]*?\r?\n\t\t\},/)?.[0]
|
||||
assert.ok(show && hide)
|
||||
const hooks = new Function('requireSession', 'session', `return ({${show}${hide}})`)(() => true, { currentUser: { id: '7' } })
|
||||
const invalidate = page.match(/invalidateTaskSync\(\) \{[^\r\n]+\},/)?.[0]
|
||||
assert.ok(show && hide && invalidate)
|
||||
const hooks = new Function('requireSession', 'session', `return ({${show}${hide}${invalidate}})`)(() => true, { currentUser: { id: '7' } })
|
||||
const calls = []
|
||||
let release
|
||||
const pending = new Promise((resolve) => { release = resolve })
|
||||
const context = { startHardwareScanner() {}, stopHardwareScanner() {}, async refreshAvailableBoxes() { calls.push('pending'); await pending }, async syncRemoteTask() { calls.push('detail+evidence') } }
|
||||
const context = { taskRequestId: 0, invalidateTaskSync: hooks.invalidateTaskSync,
|
||||
startHardwareScanner() {}, stopHardwareScanner() {}, async refreshAvailableBoxes() { calls.push('pending'); await pending }, async syncRemoteTask() { calls.push('detail+evidence') } }
|
||||
const first = hooks.onShow.call(context)
|
||||
const duplicate = hooks.onShow.call(context)
|
||||
assert.deepEqual(calls, ['pending'])
|
||||
@@ -51,6 +53,9 @@ test('duplicate onShow shares one load chain while background return refreshes a
|
||||
await hooks.onShow.call(context)
|
||||
assert.equal(calls.length, 2)
|
||||
hooks.onHide.call(context)
|
||||
assert.equal(context.taskRequestId, 1)
|
||||
assert.equal(context.remoteLoading, false)
|
||||
assert.equal(context._showRefreshPromise, null)
|
||||
await hooks.onShow.call(context)
|
||||
assert.deepEqual(calls, ['pending', 'detail+evidence', 'pending', 'detail+evidence'])
|
||||
const onLoad = page.match(/onLoad\(options = \{\}\) \{[\s\S]*?\r?\n\t\t\},/)?.[0]
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import vm from 'node:vm'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
|
||||
const source = await readFile(new URL('../pages/picking/picking.vue', import.meta.url), 'utf8')
|
||||
const script = source.match(/<script>([\s\S]*?)<\/script>/)[1]
|
||||
const noop = () => {}
|
||||
const deferred = () => { let resolve, reject; const promise = new Promise((yes, no) => { resolve = yes; reject = no }); return { promise, resolve, reject } }
|
||||
function mount(overrides = {}, state = {}) {
|
||||
const globals = { console, setTimeout, clearTimeout }
|
||||
for (const match of script.matchAll(/import\s+(.*?)\s+from\s+['"][^'"]+['"]/g)) {
|
||||
for (const name of match[1].replace(/[{}]/g, '').split(',').map(value => value.trim()).filter(Boolean)) globals[name] = noop
|
||||
}
|
||||
Object.assign(globals, { session: { token: 'offline', currentUser: { id: 'operator' } }, requireSession: () => true,
|
||||
ITEM_STATUS: { PICKED: 'PICKED', PARTIAL: 'PARTIAL', SKIPPED: 'SKIPPED' },
|
||||
isSealedPalletItem: () => false, isRemoteReady: () => true, stableOperationId: () => 'stable-operation',
|
||||
stableTaskOperationIdentity: () => ({}), normalizePickingTask: value => value,
|
||||
createItemStates: () => ({}), summarizeInventoryResources: () => ({}),
|
||||
submitStates: () => ({ states: {}, autoSkippedIds: [] }), getPickSummary: () => ({}), ...overrides })
|
||||
vm.createContext(globals)
|
||||
vm.runInContext(script.replace(/^\s*import .*$/gm, '').replace('export default', 'globalThis.page ='), globals)
|
||||
const page = globals.page
|
||||
const ctx = { taskRequestId: 0, _pageVisible: true, evidenceContextId: 0, remoteTaskId: 'task',
|
||||
currentBox: { taskId: 'task', warehouseId: 'warehouse', inboundId: 'inbound', loadingPlanId: 'plan', containerBookingId: 'container',
|
||||
items: [{ id: 'item', taskItemId: 'item', itemStatus: 'PENDING', qty: 2, version: 0 }] },
|
||||
evidenceFiles: {}, evidenceIdentity: null, sealNo: '', pickingVersion: 1,
|
||||
summary: { pending: 1 }, itemStates: {}, partialQuantities: {}, completedBoxes: {},
|
||||
selectedIncompleteReason: '现场未找到', submitting: false, loadingStageReady: false, pickingCompleted: false }
|
||||
for (const [name, method] of Object.entries(page.methods)) ctx[name] = method.bind(ctx)
|
||||
Object.assign(ctx, { rememberEvidenceDraft: noop, draftIdentity: () => ({}), resetEvidenceState: noop,
|
||||
debugSubmit: noop, debugEvidence: noop, showToast: noop, persistSession: noop, clearTimers: noop,
|
||||
stopHardwareScanner: noop, startHardwareScanner: noop, captureEvidenceIdentity: () => ({}),
|
||||
restoreEvidenceDraft: noop, isSameEvidenceIdentity: () => false, restoreRemoteDraft: noop,
|
||||
itemState: () => 'PENDING', saveCurrentBoxDraft: noop, openOverlay: noop,
|
||||
isTaskConflict: () => false, remoteFailureMessage: () => 'failed', ...state })
|
||||
return { page, ctx }
|
||||
}
|
||||
|
||||
test('double confirmation and direct reentry send exactly one snapshot and one batch', async () => {
|
||||
const snapshot = deferred(), batch = deferred(); let snapshots = 0, batches = 0
|
||||
const { ctx } = mount({ submitContainerPickingBatch: async () => { batches++; return batch.promise } }, {
|
||||
refreshInventorySnapshot: async () => { snapshots++; return snapshot.promise },
|
||||
syncRemoteTask: async function () { this.pickingCompleted = true },
|
||||
})
|
||||
const first = ctx.confirmIncompleteSubmit()
|
||||
ctx.confirmIncompleteSubmit(); await ctx.performSubmit('现场未找到')
|
||||
assert.equal(ctx.submitting, true); assert.equal(snapshots, 1); assert.equal(batches, 0)
|
||||
snapshot.resolve({ snapshotToken: 'snapshot' }); await new Promise(setImmediate)
|
||||
ctx.confirmIncompleteSubmit(); assert.equal(batches, 1)
|
||||
batch.resolve(null); await first
|
||||
assert.equal(snapshots, 1); assert.equal(batches, 1); assert.equal(ctx.submitting, false)
|
||||
assert.match(source, /:disabled="submitting \|\| !selectedIncompleteReason"/)
|
||||
})
|
||||
|
||||
test('failed request releases submit guard and retry keeps operation identity', async () => {
|
||||
const keys = []; let snapshots = 0
|
||||
const { ctx } = mount({ submitContainerPickingBatch: async (_task, _actions, options) => { keys.push(options.operationId); throw new Error('network') } }, {
|
||||
refreshInventorySnapshot: async () => { snapshots++; return { snapshotToken: 'snapshot' } },
|
||||
})
|
||||
await ctx.confirmIncompleteSubmit(); assert.equal(ctx.submitting, false)
|
||||
await ctx.confirmIncompleteSubmit()
|
||||
assert.equal(snapshots, 2); assert.deepEqual(keys, ['stable-operation', 'stable-operation'])
|
||||
})
|
||||
|
||||
const task = () => ({ taskId: 'task', recordStatus: 'ACTIVE', loadingPlanId: 'plan', containerBookingId: 'container',
|
||||
currentContainerNo: 'TEST', version: 1, workflowStatus: 'UNASSIGNED', items: [{ id: 'item', packageCount: 2, itemStatus: 'PENDING' }] })
|
||||
for (const hook of ['onHide', 'onUnload']) {
|
||||
test(`${hook} while detail is pending prevents claim and evidence requests`, async () => {
|
||||
const detail = deferred(); let claims = 0, evidence = 0
|
||||
const { page, ctx } = mount({ getContainerPickingTask: () => detail.promise,
|
||||
claimContainerPickingTask: async () => { claims++; return {} } }, { loadLoadingEvidence: async () => { evidence++ } })
|
||||
const pending = ctx.syncRemoteTask(); page[hook].call(ctx); detail.resolve(task()); await pending
|
||||
assert.equal(claims, 0); assert.equal(evidence, 0); assert.equal(ctx.remoteLoading, false)
|
||||
})
|
||||
}
|
||||
|
||||
test('unload during already-sent claim prevents its response from loading evidence', async () => {
|
||||
const claim = deferred(); let claims = 0, evidence = 0
|
||||
const { page, ctx } = mount({ getContainerPickingTask: async () => task(),
|
||||
claimContainerPickingTask: () => { claims++; return claim.promise } }, { loadLoadingEvidence: async () => { evidence++ } })
|
||||
const pending = ctx.syncRemoteTask(); await new Promise(setImmediate)
|
||||
assert.equal(claims, 1, ctx.remoteError)
|
||||
page.onUnload.call(ctx); claim.resolve({ workflowStatus: 'IN_PROGRESS', version: 2 }); await pending
|
||||
assert.equal(evidence, 0); assert.equal(ctx.remotePick, undefined)
|
||||
})
|
||||
|
||||
test('unload during onShow list fetch prevents starting task sync afterward', async () => {
|
||||
const list = deferred(); let syncs = 0
|
||||
const { page, ctx } = mount({}, { _pageVisible: false, refreshAvailableBoxes: () => list.promise,
|
||||
syncRemoteTask: async () => { syncs++ } })
|
||||
const pending = page.onShow.call(ctx); page.onUnload.call(ctx); list.resolve(); await pending
|
||||
assert.equal(syncs, 0)
|
||||
})
|
||||
|
||||
test('old hidden onShow cannot clear a newer visible refresh or start another sync', async () => {
|
||||
const oldList = deferred(), newList = deferred(); let lists = 0, syncs = 0
|
||||
const { page, ctx } = mount({}, { _pageVisible: false,
|
||||
refreshAvailableBoxes: () => (++lists === 1 ? oldList.promise : newList.promise),
|
||||
syncRemoteTask: async () => { syncs++ } })
|
||||
const oldShow = page.onShow.call(ctx)
|
||||
page.onHide.call(ctx)
|
||||
const newShow = page.onShow.call(ctx), currentPromise = ctx._showRefreshPromise
|
||||
oldList.resolve(); await oldShow
|
||||
assert.equal(syncs, 0); assert.equal(ctx._showRefreshPromise, currentPromise)
|
||||
newList.resolve(); await newShow
|
||||
assert.equal(syncs, 1); assert.equal(ctx._showRefreshPromise, null)
|
||||
})
|
||||
|
||||
for (const mode of ['camera', 'preview']) {
|
||||
test(`${mode} hide/return keeps existing exception and avoids refresh`, async () => {
|
||||
let refreshes = 0
|
||||
const { page, ctx } = mount({}, { cameraPicking: mode === 'camera', evidencePreviewOpening: mode === 'preview',
|
||||
refreshAvailableBoxes: async () => { refreshes++ }, syncRemoteTask: async () => { refreshes++ } })
|
||||
page.onHide.call(ctx); assert.equal(ctx.taskRequestId, 0)
|
||||
await page.onShow.call(ctx); assert.equal(refreshes, 0); assert.equal(ctx._pageVisible, true)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user