fix: complete October 22 workflow feedback and request failure recovery

This commit is contained in:
2026-10-22 23:06:04 +08:00
parent 5fcc51d73c
commit 51109ac852
13 changed files with 161 additions and 230 deletions
-1
View File
@@ -66,7 +66,6 @@ test('palletization UI accepts carton and pre-generated pallet scans in either o
assert.match(work, /继续装其他入仓单/)
assert.match(work, /selectedPalletNo/)
assert.match(work, /已选择仓库开放托盘/)
assert.match(work, /直接扫描托盘码,只显示当前操作的托盘/)
assert.match(work, /currentPalletDetails/)
assert.match(work, /解除封托/)
assert.match(work, /<uni-popup ref="unsealPopup"/)
-58
View File
@@ -1,58 +0,0 @@
import test from 'node:test'
import assert from 'node:assert/strict'
const { createEvidenceUploadQueue } = await import(new URL('../utils/evidence-upload-queue.js', import.meta.url).href)
test('evidence queue exposes upload progress and successful durable result', async () => {
const changes = []
const queue = createEvidenceUploadQueue({
upload: async (item, progress) => {
progress(45)
return { fileId: 81, fileName: `${item.evidenceType}.jpg` }
},
onChange: (items) => changes.push(items)
})
queue.add({ id: 'empty-1', inboundId: 12, evidenceType: 'PHOTO_STEP_1', localPath: 'tmp/empty.jpg' })
const result = await queue.flush()
assert.equal(result[0].status, 'SUCCEEDED')
assert.equal(result[0].progress, 100)
assert.equal(result[0].result.fileId, 81)
assert.ok(changes.some((items) => items[0].status === 'UPLOADING' && items[0].progress === 45))
})
test('failed evidence remains observable and can be retried without a duplicate entry', async () => {
let attempts = 0
const queue = createEvidenceUploadQueue({
upload: async () => {
attempts += 1
if (attempts === 1) throw new Error('网络中断')
return { fileId: 82 }
}
})
const entry = queue.add({ id: 'half-1', inboundId: 12, evidenceType: 'PHOTO_STEP_2', localPath: 'tmp/half.jpg' })
await queue.flush()
assert.equal(queue.latest('PHOTO_STEP_2').status, 'FAILED')
assert.equal(queue.latest('PHOTO_STEP_2').error, '网络中断')
assert.equal(queue.retry(entry.id), true)
await queue.flush()
assert.equal(queue.latest('PHOTO_STEP_2').status, 'SUCCEEDED')
assert.equal(queue.latest('PHOTO_STEP_2').attempts, 2)
assert.equal(queue.snapshot().length, 1)
})
test('same evidence type appends and explicit void preserves the old photo', async () => {
const queue = createEvidenceUploadQueue({ upload: async () => { throw new Error('失败') } })
queue.add({ id: 'old', inboundId: 12, evidenceType: 'SEAL', localPath: 'tmp/old.jpg' })
await queue.flush()
queue.add({ id: 'new', inboundId: 12, evidenceType: 'SEAL', localPath: 'tmp/new.jpg' })
assert.equal(queue.snapshot().find((item) => item.id === 'old').status, 'FAILED')
assert.equal(queue.latest('SEAL').id, 'new')
assert.equal(queue.voidEntry('old', '照片模糊', 'worker-1'), true)
assert.equal(queue.snapshot().find((item) => item.id === 'old').status, 'VOIDED')
assert.equal(queue.snapshot().find((item) => item.id === 'old').voidReason, '照片模糊')
})
+4 -6
View File
@@ -63,7 +63,7 @@ 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)
})
test('page only announces success and clears the original attempt after authoritative completion', async () => {
test('page only announces success without persisting a replay request after authoritative completion', async () => {
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]
assert.ok(method)
@@ -96,10 +96,8 @@ test('page only announces success and clears the original attempt after authorit
}
await behavior.completeLoadingTask.call(context)
assert.equal(context.submitting, false)
assert.equal(saved.length, 1)
assert.equal(saved[0].operationId, 'original-operation')
assert.equal(saved[0].expectedTaskVersion, 1)
assert.equal(cleared.length, status === 'COMPLETED' ? 1 : 0)
assert.equal(saved.length, 0)
assert.equal(cleared.length, 0)
assert.equal(messages.some(message => message.message === '装柜已完成'), status === 'COMPLETED')
if (status !== 'COMPLETED') assert.ok(context.remoteError)
}
@@ -127,7 +125,7 @@ test('reopened processing and completed tasks keep evidence and completion read-
assert.equal(behavior.evidenceReadOnly.call(retry), false)
assert.match(page, /v-if="evidenceReadOnly"[^>]*role="status"/)
assert.match(page, /v-else class="loading-complete-btn"/)
assert.match(page, /class="evidence-btn evidence-add-btn" :disabled="evidenceReadOnly"/)
assert.match(page, /class="evidence-btn evidence-add-btn" :disabled="evidenceReadOnly \|\| evidenceUploading"/)
})
test('accepted completion is processing and blocks every repeated mutation', () => {
+10 -13
View File
@@ -44,7 +44,7 @@ test('operators receive actionable refresh guidance without internal version wor
assert.match(page, /状态尚未同步,请刷新重试/)
assert.doesNotMatch(page, /缺少拣货版本号|提供装柜版本号/)
const complete = page.match(/async completeLoadingTask\(\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || ''
assert.match(complete, /if \(!this\.requireEvidenceStage\(\)\) return/)
assert.match(complete, /if \(this\.evidenceUploading \|\| !this\.requireEvidenceStage\(\)\) return/)
assert.match(complete, /completeContainerPickingTask\(this\.remoteTaskId/)
assert.match(complete, /expectedTaskVersion/)
assert.doesNotMatch(complete, /completeLoading\(inboundId/)
@@ -64,7 +64,7 @@ test('dynamic pending items do not block switching between picking and evidence'
assert.doesNotMatch(page, /stage === 'evidence' && !this\.loadingStageReady/)
assert.doesNotMatch(page, /请先完成新增待拣明细/)
assert.match(page, /activeStage === 'picking'/)
assert.match(page, /:disabled="submitting \|\| !loadingStageReady \|\| pickingVersion === null/)
assert.match(page, /:disabled="submitting \|\| evidenceUploading \|\| !loadingStageReady \|\| pickingVersion === null/)
})
test('incremental refresh merges new pending items without overwriting submitted server results', () => {
@@ -128,7 +128,7 @@ test('seal photo upload waits for an explicit seal-number save without waiting f
const saver = page.match(/async saveSealNo\(\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || ''
assert.doesNotMatch(saver, /if \(!this\.loadingStageReady\)/)
assert.match(page, /sealReadyToSave\(\)[\s\S]*?hasPendingLocalEvidence\('SEAL'\)/)
assert.match(page, /slot\.type === 'SEAL' \? '请填写封签号,保存后上传' : '照片待自动上传'/)
assert.match(page, /slot\.type === 'SEAL' \? '请填写封签号,保存后上传' : '照片未保存,请重新拍照上传'/)
assert.match(page, /带锁码照上传成功,封签号已保存/)
})
@@ -162,24 +162,21 @@ test('merged or non-active cabinet details are rejected before claim and clear s
test('photos upload, bind, reload and void before picking completion', () => {
assert.match(page, /requireEvidenceStage\(\)[\s\S]*?if \(!this\.pickingCompleted\)[\s\S]*?请先提交拣货完成[\s\S]*?if \(!this\.loadingStageReady\)/)
const queue = page.match(/async queueEvidenceUpload\([^)]*\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || ''
assert.match(queue, /await queue\.flush\(\)[\s\S]*?this\.applyEvidenceResponse\(latest\.result, type, path, identity\)/)
assert.match(queue, /await uploadEvidenceFile\([\s\S]*?this\.applyEvidenceResponse\(result, type, path, identity\)/)
assert.doesNotMatch(queue, /if \(!this\.loadingStageReady\)/)
assert.match(page, /chooseEvidence\(slot\)[\s\S]*?this\.queueEvidenceUpload\(slot\.type, path\)/)
const retry = page.match(/async retryEvidence\([^)]*\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || ''
assert.match(retry, /if \(!this\.requireEvidenceCapture\(\)\) return/)
assert.match(retry, /await queue\.flush\(\)[\s\S]*?this\.applyEvidenceResponse\(latest\.result, type, item\.localPath, identity\)/)
assert.doesNotMatch(retry, /if \(!this\.loadingStageReady\)/)
assert.doesNotMatch(page, /async retryEvidence|async flushPendingEvidence/)
assert.match(page, /async loadLoadingEvidence\(\) \{[\s\S]*?!this\.evidenceIdentity\?\.taskId[\s\S]*?!this\.evidenceIdentity\?\.warehouseId[\s\S]*?getEvidence\(identity\.taskId, identity\.warehouseId\)/)
assert.match(page, /await this\.loadLoadingEvidence\(\)[\s\S]*?if \(this\.loadingStageReady\) this\.activeStage = 'evidence'/)
assert.match(page, /async deleteEvidencePhoto\([^)]*\) \{[\s\S]*?if \(!this\.requireEvidenceMutation\(\)\) return/)
assert.match(page, /async completeLoadingTask\([^)]*\) \{[\s\S]*?if \(!this\.requireEvidenceStage\(\)\) return/)
assert.match(page, /async completeLoadingTask\([^)]*\) \{[\s\S]*?if \(this\.evidenceUploading \|\| !this\.requireEvidenceStage\(\)\) return/)
assert.doesNotMatch(page, /照片已上传,拣货完成后自动保存/)
})
test('refresh preserves local photos and flushes them as soon as evidence identity is ready', () => {
test('refresh preserves local photos without submitting them automatically', () => {
assert.match(page, /this\.rememberEvidenceDraft\(\)/)
assert.match(page, /this\.restoreEvidenceDraft\(\)/)
assert.match(page, /async flushPendingEvidence\(\)[\s\S]*?queueEvidenceUpload/)
assert.doesNotMatch(page, /async flushPendingEvidence/)
assert.match(page, /const localFiles = Object\.fromEntries/)
})
@@ -206,7 +203,7 @@ test('switching cabinets invalidates old sync and evidence upload state', () =>
assert.match(selector, /this\.pickingVersion = null/)
assert.match(selector, /this\.submitting = false/)
assert.match(selector, /this\.resetEvidenceState\(\)/)
assert.match(page, /!this\.isEvidenceContextActive\(contextId, identity\) \|\| queue !== this\.evidenceQueue/)
assert.match(page, /if \(!this\.isEvidenceContextActive\(contextId, identity\)\) return/)
const loader = page.match(/async loadLoadingEvidence\(\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || ''
assert.ok((loader.match(/isEvidenceContextActive\(contextId, identity\)/g) || []).length >= 2)
})
@@ -249,7 +246,7 @@ test('evidence save, void and loading completion ignore stale cabinet responses'
assert.ok((body.match(/isEvidenceContextActive\(contextId, identity\)/g) || []).length >= 2, `${method} should guard success and error writebacks`)
}
const complete = page.match(/async completeLoadingTask\([^)]*\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || ''
assert.match(complete, /finally \{[\s\S]*?isEvidenceContextActive\(contextId, identity\)[\s\S]*?this\.submitting = false/)
assert.match(complete, /finally \{[\s\S]*?this\._submissionOwner === submission[\s\S]*?this\.submitting = false/)
})
test('completed server tasks restore evidence stage without being claimed again', () => {
+92
View File
@@ -0,0 +1,92 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import { COMPLETION_PHASE, resolveCompletionPhase, shouldReconcileCompletion } from '../utils/loading-completion-state.js'
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]
function completion(overrides = {}, savedAttempt = null) {
let attempt = savedAttempt
const calls = [], cleared = [], messages = []
const bindings = {
COMPLETION_PHASE, resolveCompletionPhase, shouldReconcileCompletion,
isRemoteReady: () => true, stableTaskOperationIdentity: () => 'task', stableOperationId: (_action, _identity, payload) => `complete-v${payload.expectedTaskVersion}`,
restoreLoadingCompletionAttempt: () => attempt, saveLoadingCompletionAttempt: (_identity, value) => { attempt = value },
clearLoadingCompletionAttempt: identity => { cleared.push(identity); attempt = null },
completeContainerPickingTask: async (_task, payload) => { calls.push(payload); return { taskStatus: 'COMPLETED' } },
normalizePickingTask: task => task, observeLoadingCompletion: async options => options.initialTask,
sanitizeUserMessage: value => value, ...overrides
}
const behavior = new Function(...Object.keys(bindings), `return ({${method}})`)(...Object.values(bindings))
const state = {
evidenceIdentity: { taskId: 'task', warehouseId: 'warehouse' }, evidenceContextId: 1, remoteTaskId: 'task', remotePick: {},
pickingVersion: 1, evidenceComplete: true, submitting: false, _pageVisible: true,
requireEvidenceStage() { return !this.submitting }, draftIdentity: () => ({ taskId: 'task', loadingPlanId: 'plan', containerBookingId: 'cabinet' }),
isEvidenceContextActive(id) { return id === this.evidenceContextId }, isTaskConflict: error => error.code === 'A0409',
async refreshAfterTaskConflict() { this.pickingVersion = 2 }, debugSubmit() {}, persistSession() {},
remoteFailureMessage: error => error.message, showToast: message => messages.push(message)
}
state.complete = behavior.completeLoadingTask.bind(state)
return { state, calls, cleared, messages, attempt: () => attempt }
}
test('completion in flight blocks a second click and releases busy after the task context changes', async () => {
let resolve
const { state, calls } = completion({ completeContainerPickingTask: (...args) => { calls.push(args); return new Promise(done => { resolve = done }) } })
const first = state.complete(); await state.complete(); assert.equal(calls.length, 1)
state.evidenceContextId++; resolve({ taskStatus: 'COMPLETED' }); await first
assert.equal(state.submitting, false); assert.equal(state.remotePick.taskStatus, undefined)
})
test('an old completion response cannot release a newer picking submission', async () => {
let resolve
const { state } = completion({ completeContainerPickingTask: () => new Promise(done => { resolve = done }) })
const first = state.complete()
state.evidenceContextId++; state._submissionOwner = {}; state.submitting = true
resolve({ taskStatus: 'COMPLETED' }); await first
assert.equal(state.submitting, true)
})
test('timeout unlocks completion and the next click uses the current version', async () => {
const commands = []
const { state, attempt } = completion({ completeContainerPickingTask: async (_task, payload) => {
commands.push(payload); if (commands.length === 1) throw { network: true }; return { taskStatus: 'COMPLETED' }
} })
await state.complete(); assert.equal(state.submitting, false); assert.equal(attempt(), null)
state.pickingVersion = 7; await state.complete()
assert.equal(commands[1].expectedTaskVersion, 7); assert.notEqual(commands[0].operationId, commands[1].operationId)
})
test('first definite conflict releases its stale completion version so the next operation can succeed', async () => {
const commands = []
const { state, cleared } = completion({ completeContainerPickingTask: async (_task, payload) => {
commands.push(payload); if (commands.length === 1) throw { code: 'A0409', message: '任务已更新' }; return { taskStatus: 'COMPLETED' }
} })
await state.complete(); assert.equal(state.submitting, false); assert.equal(cleared.length, 0)
await state.complete(); assert.equal(commands[1].expectedTaskVersion, 2); assert.notEqual(commands[0].operationId, commands[1].operationId)
})
test('old local completion snapshots are ignored and cannot freeze the current task', async () => {
const old = { operationId: 'old', expectedTaskVersion: 1 }
const { state, calls } = completion({}, old)
state.pickingVersion = 8; await state.complete()
assert.equal(calls[0].expectedTaskVersion, 8); assert.notEqual(calls[0].operationId, old.operationId)
assert.equal(state.submitting, false)
})
test('a reopened processing task has an explicit refresh action without a mutation retry', () => {
assert.match(page, /v-if="evidenceReadOnly && loadingCompletionState.phase !== 'COMPLETED'"[^>]*@tap="refreshTask">刷新完成状态/)
})
test('photo failure releases the page and another click uploads the current photo with a fresh key', async () => {
const uploadMethod = page.slice(page.indexOf('async queueEvidenceUpload('), page.indexOf('async saveSealNo('))
const requests = []
const uploadEvidenceFile = async (...args) => { requests.push(args); if (requests.length === 1) throw { network: true, message: 'timeout' }; return { saved: true } }
const behavior = new Function('isRemoteReady', 'uploadEvidenceFile', 'return ({' + uploadMethod + '})')(() => true, uploadEvidenceFile)
const state = { evidenceUploading: false, evidenceReadOnly: false, evidenceIdentity: { taskId: 'task', warehouseId: 'warehouse' }, evidenceContextId: 1, evidenceQueueItems: [], remoteError: '', validEvidenceEntries: () => [], isEvidenceContextActive: () => true, remoteFailureMessage: error => error.message, applyEvidenceResponse() {}, showToast() {} }
await behavior.queueEvidenceUpload.call(state, 'SEAL', 'old.jpg', 'OLD')
assert.equal(state.evidenceUploading, false); assert.equal(state.remoteError, 'timeout')
await behavior.queueEvidenceUpload.call(state, 'SEAL', 'new.jpg', 'NEW')
assert.equal(state.evidenceUploading, false); assert.equal(requests[1][3], 'new.jpg'); assert.equal(requests[1][2].sealNo, 'NEW')
assert.notEqual(requests[1][2].operationId, requests[0][2].operationId)
})
+2 -6
View File
@@ -30,10 +30,6 @@ test('mutable real container number is not part of draft identity or restore val
assert.doesNotMatch(restoreGuard, /containerNo|currentContainerNo|boxNo/)
})
test('completion retry persists the exact accepted operation id and request version', () => {
assert.match(source, /pda-loading-completion/)
assert.match(source, /saveLoadingCompletionAttempt/)
assert.match(source, /restoreLoadingCompletionAttempt/)
assert.match(source, /operationId: String\(attempt\.operationId\)/)
assert.match(source, /expectedTaskVersion: Number\(attempt\.expectedTaskVersion\)/)
test('completion commands are never persisted with ordinary picking drafts', () => {
assert.doesNotMatch(source, /pda-loading-completion|saveLoadingCompletionAttempt|restoreLoadingCompletionAttempt/)
})
+2 -2
View File
@@ -5,12 +5,12 @@ import { readFile } from 'node:fs/promises'
const page = await readFile(new URL('../pages/picking/picking.vue', import.meta.url), 'utf8')
test('saving a seal is single-flight and submits the pending photo through the atomic evidence command', () => {
assert.match(page, /:disabled="evidenceReadOnly \|\| sealSaving \|\| !sealReadyToSave"/)
assert.match(page, /:disabled="evidenceReadOnly \|\| evidenceUploading \|\| sealSaving \|\| !sealReadyToSave"/)
assert.match(page, /sealSaving \? '上传中…' : '保存并上传'/)
assert.match(page, /async saveSealNo\(\) \{[\s\S]*?if \(this\.sealSaving\) return/)
assert.match(page, /find\(\(item\) => item\.localPath && !item\.id\)/)
assert.match(page, /await this\.queueEvidenceUpload\('SEAL', current\.localPath, String\(this\.sealNo\)\.trim\(\)\)/)
assert.match(page, /stableOperationId\('evidence-upload',[\s\S]*?evidenceType: type,[\s\S]*?expectedVersion,[\s\S]*?localPath: path,[\s\S]*?sealNo: sealNo \|\| ''/)
assert.match(page, /operationId: `evidence-\$\{Date.now\(\)\}/)
assert.doesNotMatch(page, /seal-no-[^\n]*Date\.now\(\)/)
assert.match(page, /this\.sealSaving = true[\s\S]*?finally \{[\s\S]*?this\.sealSaving = false/)
})