fix: return completed loading tasks to the task list
This commit is contained in:
@@ -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) {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user