fix: return completed loading tasks to the task list

This commit is contained in:
2026-11-05 16:30:49 +08:00
parent 63fac79814
commit 500103e734
4 changed files with 108 additions and 6 deletions
+21 -1
View File
@@ -922,8 +922,13 @@
}) })
}, },
persistSession() { persistSession() {
const identity = this.draftIdentity()
const completed = this.remotePick?.taskStatus === 'COMPLETED'
if (completed) this.completedBoxes = { ...this.completedBoxes, [this.currentBoxIndex]: true }
this.saveCurrentBoxDraft() this.saveCurrentBoxDraft()
savePickingSession({ boxes: this.boxes, currentBoxIndex: this.currentBoxIndex, itemStates: this.itemStates, partialQuantities: this.partialQuantities, scannedBoxCodes: this.scannedBoxCodes, boxDrafts: this.boxDrafts, completedBoxes: this.completedBoxes, scanValue: this.scanValue, scanHistory: this.scanHistory }, this.draftIdentity()) // 完成任务仍更新作业记录的内存快照,空身份阻止重新写入其持久化草稿。
savePickingSession({ boxes: this.boxes, currentBoxIndex: this.currentBoxIndex, itemStates: this.itemStates, partialQuantities: this.partialQuantities, scannedBoxCodes: this.scannedBoxCodes, boxDrafts: this.boxDrafts, completedBoxes: this.completedBoxes, scanValue: this.scanValue, scanHistory: this.scanHistory }, completed ? {} : identity)
if (completed) clearPickingDraft(identity)
}, },
leavePicking() { this.persistSession(); this.activeOverlay = ''; navigateBackOr('/pages/home/home') }, leavePicking() { this.persistSession(); this.activeOverlay = ''; navigateBackOr('/pages/home/home') },
clearTimers() { clearTimeout(this._toastTimer); clearTimeout(this._evidencePreviewOpenTimer) }, clearTimers() { clearTimeout(this._toastTimer); clearTimeout(this._evidencePreviewOpenTimer) },
@@ -1186,6 +1191,16 @@
this.currentBox.boxNo = task.currentContainerNo this.currentBox.boxNo = task.currentContainerNo
this.currentBox.currentContainerNo = task.currentContainerNo this.currentBox.currentContainerNo = task.currentContainerNo
const remoteTask = normalizePickingTask(task) const remoteTask = normalizePickingTask(task)
// 刷新确认后台已完成时直接返回任务列表,不再停留在已完成柜子内。
if (remoteTask.taskStatus === 'COMPLETED') {
this.remotePick = remoteTask
this.pickingVersion = remoteTask.pickingVersion ?? remoteTask.version
if (this.activeOverlay) this.closeOverlay()
this.boxDropdownOpen = false
this.persistSession()
navigateBackOr('/pages/tasks/tasks')
return
}
this.assignmentVersion = remoteTask.assignment?.version ?? remoteTask.assignmentVersion ?? (remoteTask.workflowStatus === 'UNASSIGNED' ? 0 : null) this.assignmentVersion = remoteTask.assignment?.version ?? remoteTask.assignmentVersion ?? (remoteTask.workflowStatus === 'UNASSIGNED' ? 0 : null)
if (remoteTask.workflowStatus !== 'AWAITING_REVIEW' && this.assignmentVersion === null) throw new Error('任务领取状态尚未同步,请刷新重试') if (remoteTask.workflowStatus !== 'AWAITING_REVIEW' && this.assignmentVersion === null) throw new Error('任务领取状态尚未同步,请刷新重试')
let claimed = null let claimed = null
@@ -1470,6 +1485,11 @@
} }
this.remoteError = '' this.remoteError = ''
this.showToast('装柜已完成', 'success') this.showToast('装柜已完成', 'success')
// 收起页面内浮层,避免 onBackPress 拦截完成后的程序化返回。
if (this.activeOverlay) this.closeOverlay()
this.boxDropdownOpen = false
this.persistSession()
navigateBackOr('/pages/tasks/tasks')
} catch (error) { } catch (error) {
if (!this.isEvidenceContextActive(contextId, identity)) return if (!this.isEvidenceContextActive(contextId, identity)) return
if (accepted) { if (accepted) {
+4 -2
View File
@@ -63,18 +63,19 @@ test('query failure is not retried as a completion mutation and completed accept
readTask: async () => assert.fail('no query after authoritative completion'), isActive: () => true, onTask() {} }), completed) readTask: async () => assert.fail('no query after authoritative completion'), isActive: () => true, onTask() {} }), completed)
}) })
test('page only announces success without persisting a replay request after authoritative completion', async () => { test('page only returns to the task list after authoritative completion without persisting a replay request', async () => {
const page = await readFile(new URL('../pages/picking/picking.vue', import.meta.url), 'utf8') const page = await readFile(new URL('../pages/picking/picking.vue', import.meta.url), 'utf8')
const method = page.match(/async completeLoadingTask\(\) \{[\s\S]*?\n\t\t\t\},/)?.[0] const method = page.match(/async completeLoadingTask\(\) \{[\s\S]*?\n\t\t\t\},/)?.[0]
assert.ok(method) assert.ok(method)
for (const status of ['COMPLETED', 'COMPLETION_FAILED', 'COMPLETING', 'LOADING', 'QUERY_ERROR', 'NO_RESPONSE']) { for (const status of ['COMPLETED', 'COMPLETION_FAILED', 'COMPLETING', 'LOADING', 'QUERY_ERROR', 'NO_RESPONSE']) {
const messages = [], cleared = [], saved = [] const messages = [], cleared = [], saved = [], destinations = []
const bindings = { const bindings = {
COMPLETION_PHASE, resolveCompletionPhase, shouldReconcileCompletion, COMPLETION_PHASE, resolveCompletionPhase, shouldReconcileCompletion,
isRemoteReady: () => true, restoreLoadingCompletionAttempt: () => null, isRemoteReady: () => true, restoreLoadingCompletionAttempt: () => null,
stableTaskOperationIdentity: () => 'stable-task', stableOperationId: () => 'original-operation', stableTaskOperationIdentity: () => 'stable-task', stableOperationId: () => 'original-operation',
saveLoadingCompletionAttempt: (_, attempt) => saved.push(attempt), saveLoadingCompletionAttempt: (_, attempt) => saved.push(attempt),
clearLoadingCompletionAttempt: identity => cleared.push(identity), clearLoadingCompletionAttempt: identity => cleared.push(identity),
navigateBackOr: url => destinations.push(url),
completeContainerPickingTask: async () => status === 'NO_RESPONSE' ? null : { taskStatus: 'COMPLETING', version: 2 }, completeContainerPickingTask: async () => status === 'NO_RESPONSE' ? null : { taskStatus: 'COMPLETING', version: 2 },
normalizePickingTask: task => task, normalizePickingTask: task => task,
observeLoadingCompletion: async options => { observeLoadingCompletion: async options => {
@@ -99,6 +100,7 @@ test('page only announces success without persisting a replay request after auth
assert.equal(saved.length, 0) assert.equal(saved.length, 0)
assert.equal(cleared.length, 0) assert.equal(cleared.length, 0)
assert.equal(messages.some(message => message.message === '装柜已完成'), status === 'COMPLETED') assert.equal(messages.some(message => message.message === '装柜已完成'), status === 'COMPLETED')
assert.deepEqual(destinations, status === 'COMPLETED' ? ['/pages/tasks/tasks'] : [])
if (status !== 'COMPLETED') assert.ok(context.remoteError) if (status !== 'COMPLETED') assert.ok(context.remoteError)
} }
}) })
+5 -3
View File
@@ -7,12 +7,13 @@ const page = await readFile(new URL('../pages/picking/picking.vue', import.meta.
const method = page.match(/async completeLoadingTask\(\) \{[\s\S]*?\n\t\t\t\},/)[0] const method = page.match(/async completeLoadingTask\(\) \{[\s\S]*?\n\t\t\t\},/)[0]
function completion(overrides = {}, savedAttempt = null) { function completion(overrides = {}, savedAttempt = null) {
let attempt = savedAttempt let attempt = savedAttempt
const calls = [], cleared = [], messages = [] const calls = [], cleared = [], messages = [], destinations = []
const bindings = { const bindings = {
COMPLETION_PHASE, resolveCompletionPhase, shouldReconcileCompletion, COMPLETION_PHASE, resolveCompletionPhase, shouldReconcileCompletion,
isRemoteReady: () => true, stableTaskOperationIdentity: () => 'task', stableOperationId: (_action, _identity, payload) => `complete-v${payload.expectedTaskVersion}`, isRemoteReady: () => true, stableTaskOperationIdentity: () => 'task', stableOperationId: (_action, _identity, payload) => `complete-v${payload.expectedTaskVersion}`,
restoreLoadingCompletionAttempt: () => attempt, saveLoadingCompletionAttempt: (_identity, value) => { attempt = value }, restoreLoadingCompletionAttempt: () => attempt, saveLoadingCompletionAttempt: (_identity, value) => { attempt = value },
clearLoadingCompletionAttempt: identity => { cleared.push(identity); attempt = null }, clearLoadingCompletionAttempt: identity => { cleared.push(identity); attempt = null },
navigateBackOr: url => destinations.push(url),
completeContainerPickingTask: async (_task, payload) => { calls.push(payload); return { taskStatus: 'COMPLETED' } }, completeContainerPickingTask: async (_task, payload) => { calls.push(payload); return { taskStatus: 'COMPLETED' } },
normalizePickingTask: task => task, observeLoadingCompletion: async options => options.initialTask, normalizePickingTask: task => task, observeLoadingCompletion: async options => options.initialTask,
sanitizeUserMessage: value => value, ...overrides sanitizeUserMessage: value => value, ...overrides
@@ -27,15 +28,16 @@ function completion(overrides = {}, savedAttempt = null) {
remoteFailureMessage: error => error.message, showToast: message => messages.push(message) remoteFailureMessage: error => error.message, showToast: message => messages.push(message)
} }
state.complete = behavior.completeLoadingTask.bind(state) state.complete = behavior.completeLoadingTask.bind(state)
return { state, calls, cleared, messages, attempt: () => attempt } return { state, calls, cleared, messages, destinations, attempt: () => attempt }
} }
test('completion in flight blocks a second click and releases busy after the task context changes', async () => { test('completion in flight blocks a second click and releases busy after the task context changes', async () => {
let resolve let resolve
const { state, calls } = completion({ completeContainerPickingTask: (...args) => { calls.push(args); return new Promise(done => { resolve = done }) } }) const { state, calls, destinations } = completion({ completeContainerPickingTask: (...args) => { calls.push(args); return new Promise(done => { resolve = done }) } })
const first = state.complete(); await state.complete(); assert.equal(calls.length, 1) const first = state.complete(); await state.complete(); assert.equal(calls.length, 1)
state.evidenceContextId++; resolve({ taskStatus: 'COMPLETED' }); await first state.evidenceContextId++; resolve({ taskStatus: 'COMPLETED' }); await first
assert.equal(state.submitting, false); assert.equal(state.remotePick.taskStatus, undefined) assert.equal(state.submitting, false); assert.equal(state.remotePick.taskStatus, undefined)
assert.deepEqual(destinations, [])
}) })
test('an old completion response cannot release a newer picking submission', async () => { test('an old completion response cannot release a newer picking submission', async () => {
+78
View File
@@ -2,6 +2,8 @@ import test from 'node:test'
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import vm from 'node:vm' import vm from 'node:vm'
import { readFile } from 'node:fs/promises' import { readFile } from 'node:fs/promises'
import { navigateBackOr } from '../utils/navigation.js'
import { COMPLETION_PHASE, observeLoadingCompletion, resolveCompletionPhase, shouldReconcileCompletion } from '../utils/loading-completion-state.js'
const source = await readFile(new URL('../pages/picking/picking.vue', import.meta.url), 'utf8') const source = await readFile(new URL('../pages/picking/picking.vue', import.meta.url), 'utf8')
const script = source.match(/<script>([\s\S]*?)<\/script>/)[1] const script = source.match(/<script>([\s\S]*?)<\/script>/)[1]
@@ -107,6 +109,82 @@ test('failed request releases submit guard and retry keeps operation identity',
const task = () => ({ taskId: 'task', recordStatus: 'ACTIVE', loadingPlanId: 'plan', containerBookingId: 'container', const task = () => ({ taskId: 'task', recordStatus: 'ACTIVE', loadingPlanId: 'plan', containerBookingId: 'container',
currentContainerNo: 'TEST', version: 1, workflowStatus: 'UNASSIGNED', items: [{ id: 'item', packageCount: 2, itemStatus: 'PENDING' }] }) currentContainerNo: 'TEST', version: 1, workflowStatus: 'UNASSIGNED', items: [{ id: 'item', packageCount: 2, itemStatus: 'PENDING' }] })
test('refreshing a completed task returns to the list before claiming or loading evidence', async () => {
const destinations = []; let claims = 0, evidence = 0
const { ctx } = mount({ getContainerPickingTask: async () => ({ ...task(), taskStatus: 'COMPLETED', workflowStatus: 'COMPLETED' }),
navigateBackOr: url => destinations.push(url),
claimContainerPickingTask: async () => { claims++ } }, { loadLoadingEvidence: async () => { evidence++ } })
await ctx.syncRemoteTask()
assert.deepEqual(destinations, ['/pages/tasks/tasks'])
assert.equal(ctx.remotePick.taskStatus, 'COMPLETED')
assert.equal(claims, 0); assert.equal(evidence, 0); assert.equal(ctx.remoteLoading, false)
})
for (const entry of ['completion', 'refresh']) {
for (const control of ['dropdown', 'overlay']) {
test(`${entry} returns through onBackPress even when the ${control} opens before completion`, async t => {
const previousUni = globalThis.uni, previousPages = globalThis.getCurrentPages
t.after(() => { globalThis.uni = previousUni; globalThis.getCurrentPages = previousPages })
const response = deferred(), destinations = []; let popupClosed = 0
const completed = { ...task(), taskStatus: 'COMPLETED', workflowStatus: 'COMPLETED' }
const { page, ctx } = mount({ navigateBackOr, COMPLETION_PHASE, observeLoadingCompletion,
resolveCompletionPhase, shouldReconcileCompletion,
completeContainerPickingTask: () => response.promise, getContainerPickingTask: () => response.promise },
{ evidenceIdentity: { taskId: 'task', warehouseId: 'warehouse' }, evidenceComplete: true,
currentBoxIndex: 0, activeOverlay: '', boxDropdownOpen: false,
requireEvidenceStage: () => true, isEvidenceContextActive: () => true,
$nextTick: callback => callback(), $refs: { boxListPopup: { open() {}, close() { popupClosed++ } } } })
ctx.openOverlay = page.methods.openOverlay.bind(ctx)
globalThis.getCurrentPages = () => [{ route: 'pages/tasks/tasks' }, { route: 'pages/picking/picking' }]
globalThis.uni = {
// App 将返回钩子的拦截视为成功;仅调用导航函数不能证明页面已返回。
navigateBack() { if (!page.onBackPress.call(ctx)) destinations.push('/pages/tasks/tasks') },
reLaunch: ({ url }) => destinations.push(url)
}
const pending = entry === 'completion' ? ctx.completeLoadingTask() : ctx.syncRemoteTask()
if (control === 'dropdown') ctx.toggleBoxDropdown()
else ctx.openOverlay('boxList')
response.resolve(completed)
await pending
assert.deepEqual(destinations, ['/pages/tasks/tasks'])
assert.equal(ctx.boxDropdownOpen, false); assert.equal(ctx.activeOverlay, '')
assert.equal(popupClosed, control === 'overlay' ? 1 : 0)
})
}
}
test('completed tasks update the real history state while clearing only their own persisted draft', async t => {
const previousUni = globalThis.uni
t.after(() => { globalThis.uni = previousUni })
const moduleUrl = value => `data:text/javascript;base64,${Buffer.from(value).toString('base64')}`
let storeSource = await readFile(new URL('../stores/picking.js', import.meta.url), 'utf8')
for (const path of ['data/demo-data.js', 'utils/picking-state.js', 'utils/task-identity.js']) {
storeSource = storeSource.replace(`@/${path}`, moduleUrl(await readFile(new URL(`../${path}`, import.meta.url), 'utf8')))
}
const store = await import(moduleUrl(storeSource)), storage = new Map(), writes = []
globalThis.uni = { getStorageSync: key => storage.get(key),
setStorageSync: (key, value) => { writes.push(key); storage.set(key, value) }, removeStorageSync: key => storage.delete(key) }
const identity = { operatorId: 'operator', taskId: 'task', loadingPlanId: 'plan', containerBookingId: 'container' }
const { page, ctx } = mount({ clearPickingDraft: store.clearPickingDraft, savePickingSession: store.savePickingSession },
{ remotePick: { taskStatus: 'LOADING' }, draftIdentity: () => identity, currentBoxIndex: 0,
boxes: [{ taskId: 'task', cabinetNo: 'D2126', items: [] }], boxDrafts: {}, scanHistory: [] })
ctx.persistSession = page.methods.persistSession.bind(ctx)
ctx.persistSession()
const draftKey = store.getPickingDraftKey(identity)
assert.equal(storage.has(draftKey), true); assert.equal(store.getPickingOverview().completedCount, 0)
storage.set('another-task-draft', { keep: true })
writes.length = 0
ctx.remotePick = { taskStatus: 'COMPLETED' }
ctx.persistSession()
page.onUnload.call(ctx)
assert.equal(store.getPickingOverview().completedCount, 1)
assert.equal(store.getPickingOverview().completedBoxes[0], true)
assert.equal(store.getPickingOverview().boxes[0].taskId, 'task')
assert.equal(storage.has(draftKey), false); assert.equal(storage.has('another-task-draft'), true)
assert.deepEqual(writes, [])
})
for (const hook of ['onHide', 'onUnload']) { for (const hook of ['onHide', 'onUnload']) {
test(`${hook} while detail is pending prevents claim and evidence requests`, async () => { test(`${hook} while detail is pending prevents claim and evidence requests`, async () => {
const detail = deferred(); let claims = 0, evidence = 0 const detail = deferred(); let claims = 0, evidence = 0