From 5fcc51d73c2ba48f109ca513831f4a5f816dbb92 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 21 Oct 2026 23:20:11 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BC=A9=E7=9F=AD=E8=A3=85=E6=9F=9C=E5=AE=8C?= =?UTF-8?q?=E6=88=90=E7=AD=89=E5=BE=85=E5=B9=B6=E4=BF=9D=E7=95=99=E6=9D=83?= =?UTF-8?q?=E5=A8=81=E7=8A=B6=E6=80=81=E6=9F=A5=E8=AF=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pages/picking/picking.vue | 47 ++++++++++-- tests/loading-completion-state.test.mjs | 96 +++++++++++++++++++++++++ utils/loading-completion-state.js | 21 ++++++ 3 files changed, 159 insertions(+), 5 deletions(-) diff --git a/pages/picking/picking.vue b/pages/picking/picking.vue index b3ae308..efcca95 100644 --- a/pages/picking/picking.vue +++ b/pages/picking/picking.vue @@ -349,7 +349,7 @@ import { isOnline } from '@/utils/network.js' import { stableOperationId } from '@/utils/idempotency.js' import { stableTaskOperationIdentity } from '@/utils/task-identity.js' - import { COMPLETION_PHASE, completionPresentation, resolveCompletionPhase, shouldReconcileCompletion } from '@/utils/loading-completion-state.js' + import { COMPLETION_PHASE, completionPresentation, observeLoadingCompletion, resolveCompletionPhase, shouldReconcileCompletion } from '@/utils/loading-completion-state.js' import { containerReference, formatApiError, sanitizeUserMessage } from '@/utils/user-facing.js' import ScanTargetInput from '@/components/ScanTargetInput.vue' import PopupHeader from '@/components/PopupHeader.vue' @@ -1435,6 +1435,8 @@ const completionAttempt = { operationId, expectedTaskVersion, remark: savedAttempt?.remark || 'PDA确认装柜完成' } saveLoadingCompletionAttempt(this.draftIdentity(), completionAttempt) this.submitting = true + const started = Date.now() + let accepted = false try { const completedTask = await completeContainerPickingTask(this.remoteTaskId, { expectedTaskVersion, @@ -1442,21 +1444,56 @@ remark: completionAttempt.remark }) if (!this.isEvidenceContextActive(contextId, identity)) return + accepted = true + this.debugSubmit('complete.accepted', { elapsedMs: Date.now() - started, taskStatus: completedTask?.taskStatus }) + if (!completedTask) throw new Error('服务端未返回装柜完成结果,请刷新任务') if (completedTask) { this.remotePick = normalizePickingTask({ ...this.remotePick, ...completedTask }) this.pickingVersion = this.remotePick.pickingVersion ?? this.remotePick.version ?? this.pickingVersion } - const phase = resolveCompletionPhase({ taskStatus: completedTask?.taskStatus, commandStatus: completedTask?.commandStatus }) + // 已受理后保留处理中门禁,查询权威任务;不等待整页证据和库存重新加载。 + this.submitting = false + const finalTask = await observeLoadingCompletion({ + initialTask: completedTask, + isActive: () => this._pageVisible === true && this.isEvidenceContextActive(contextId, identity), + readTask: async () => { + const readStarted = Date.now() + const task = await getContainerPickingTask({ taskId: identity.taskId }, identity.warehouseId) + this.debugSubmit('complete.query', { elapsedMs: Date.now() - readStarted, taskStatus: task?.taskStatus }) + if (String(task?.taskId || task?.id || '') !== String(identity.taskId)) throw new Error('装柜完成状态尚未同步,请刷新任务') + return task + }, + onTask: (task) => { + this.remotePick = normalizePickingTask({ ...this.remotePick, ...task }) + this.pickingVersion = this.remotePick.pickingVersion ?? this.remotePick.version ?? this.pickingVersion + } + }) + if (!this.isEvidenceContextActive(contextId, identity) || !finalTask) return + const phase = resolveCompletionPhase({ taskStatus: finalTask.taskStatus || finalTask.workflowStatus, + commandStatus: finalTask.completionCommand?.status || finalTask.commandStatus }) + this.debugSubmit('complete.observed', { elapsedMs: Date.now() - started, phase }) if (phase === COMPLETION_PHASE.PROCESSING) { - this.showToast('完成命令已受理,后台处理中', 'success') + this.remoteError = '完成命令仍在处理中,请稍后刷新任务确认' + this.showToast('完成处理中,请稍后刷新', 'warning') this.persistSession() - navigateBackOr('/pages/tasks/tasks') return } - if (phase === COMPLETION_PHASE.COMPLETED) clearLoadingCompletionAttempt(this.draftIdentity()) + if (phase !== COMPLETION_PHASE.COMPLETED) { + this.remoteError = phase === COMPLETION_PHASE.FAILED ? '装柜完成失败,可使用原操作重试完成' : '提交结果待确认,请刷新任务' + this.showToast(phase === COMPLETION_PHASE.FAILED ? '装柜完成失败' : '提交结果待确认', 'warning') + return + } + clearLoadingCompletionAttempt(this.draftIdentity()) + this.remoteError = '' this.showToast('装柜已完成', 'success') } catch (error) { if (!this.isEvidenceContextActive(contextId, identity)) return + if (accepted) { + this.debugSubmit('complete.unconfirmed', { elapsedMs: Date.now() - started, code: error.code || '', requestId: error.requestId || '', traceId: error.traceId || '' }) + this.remoteError = '完成命令已受理,结果待确认,请刷新任务' + this.showToast('提交结果待确认', 'warning') + return + } if (shouldReconcileCompletion(error)) { this.remoteError = '提交结果待确认,请返回任务列表刷新' this.showToast('提交结果待确认', 'warning') diff --git a/tests/loading-completion-state.test.mjs b/tests/loading-completion-state.test.mjs index dd91eda..32296ad 100644 --- a/tests/loading-completion-state.test.mjs +++ b/tests/loading-completion-state.test.mjs @@ -4,11 +4,107 @@ import { readFile } from 'node:fs/promises' import { COMPLETION_PHASE, completionPresentation, + observeLoadingCompletion, refreshCompletionAttempt, resolveCompletionPhase, shouldReconcileCompletion } from '../utils/loading-completion-state.js' +test('accepted completion queries immediately and returns as soon as the real task completes', async () => { + const reads = [], waits = [], applied = [] + let clock = 0 + const result = await observeLoadingCompletion({ + initialTask: { taskStatus: 'COMPLETING' }, + readTask: async () => { + reads.push(clock) + return { taskStatus: reads.length === 1 ? 'COMPLETING' : 'COMPLETED' } + }, + isActive: () => true, onTask: task => applied.push(task.taskStatus), now: () => clock, + wait: async delay => { waits.push(delay); clock += delay } + }) + assert.deepEqual(reads, [0, 250]) + assert.deepEqual(waits, [250]) + assert.deepEqual(applied, ['COMPLETING', 'COMPLETED']) + assert.equal(result.taskStatus, 'COMPLETED') +}) + +test('completion observation preserves failure, pending and unknown results without inventing success', async () => { + for (const status of ['COMPLETION_FAILED', 'LOADING']) { + const result = await observeLoadingCompletion({ initialTask: { taskStatus: 'COMPLETING' }, + readTask: async () => ({ taskStatus: status }), isActive: () => true, onTask() {}, + wait: async () => assert.fail('terminal or unknown facts must stop polling') }) + assert.equal(result.taskStatus, status) + } + let clock = 0, reads = 0 + const pending = await observeLoadingCompletion({ initialTask: { taskStatus: 'COMPLETING' }, + readTask: async () => { reads++; return { taskStatus: 'COMPLETING' } }, + isActive: () => true, onTask() {}, now: () => clock, maxWaitMs: 500, + wait: async delay => { clock += delay } }) + assert.equal(pending.taskStatus, 'COMPLETING') + assert.equal(reads, 2) +}) + +test('hidden or switched tasks stop completion observation and discard in-flight responses', async () => { + let active = true, applied = 0 + const result = await observeLoadingCompletion({ initialTask: { taskStatus: 'COMPLETING' }, + readTask: async () => { active = false; return { taskStatus: 'COMPLETED' } }, + isActive: () => active, onTask: () => { applied++ } }) + assert.equal(result, null) + assert.equal(applied, 0) + await observeLoadingCompletion({ initialTask: { taskStatus: 'COMPLETING' }, + readTask: async () => assert.fail('hidden task must not query'), isActive: () => false, onTask() {} }) +}) + +test('query failure is not retried as a completion mutation and completed acceptance needs no query', async () => { + await assert.rejects(observeLoadingCompletion({ initialTask: { taskStatus: 'COMPLETING' }, + readTask: async () => { throw new Error('network unavailable') }, isActive: () => true, onTask() {} }), /network/) + const completed = { taskStatus: 'COMPLETED' } + assert.equal(await observeLoadingCompletion({ initialTask: completed, + 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 () => { + 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) + for (const status of ['COMPLETED', 'COMPLETION_FAILED', 'COMPLETING', 'LOADING', 'QUERY_ERROR', 'NO_RESPONSE']) { + const messages = [], cleared = [], saved = [] + const bindings = { + COMPLETION_PHASE, resolveCompletionPhase, shouldReconcileCompletion, + isRemoteReady: () => true, restoreLoadingCompletionAttempt: () => null, + stableTaskOperationIdentity: () => 'stable-task', stableOperationId: () => 'original-operation', + saveLoadingCompletionAttempt: (_, attempt) => saved.push(attempt), + clearLoadingCompletionAttempt: identity => cleared.push(identity), + completeContainerPickingTask: async () => status === 'NO_RESPONSE' ? null : { taskStatus: 'COMPLETING', version: 2 }, + normalizePickingTask: task => task, + observeLoadingCompletion: async options => { + assert.notEqual(status, 'NO_RESPONSE') + assert.equal(options.isActive(), true) + if (status === 'QUERY_ERROR') throw new Error('network error after acceptance') + const task = { taskStatus: status, version: 3 } + options.onTask(task) + return task + } + } + const behavior = new Function(...Object.keys(bindings), `return ({${method}})`)(...Object.values(bindings)) + const context = { + requireEvidenceStage: () => true, evidenceIdentity: { taskId: 'task', warehouseId: 'warehouse' }, + pickingVersion: 1, evidenceComplete: true, evidenceContextId: 1, remoteTaskId: 'task', remotePick: {}, + draftIdentity: () => ({ loadingPlanId: 'plan', containerBookingId: 'cabinet' }), + isEvidenceContextActive: () => true, _pageVisible: true, debugSubmit() {}, persistSession() {}, + showToast: (message, type) => messages.push({ message, type }) + } + 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(messages.some(message => message.message === '装柜已完成'), status === 'COMPLETED') + if (status !== 'COMPLETED') assert.ok(context.remoteError) + } +}) + test('reopened processing and completed tasks keep evidence and completion read-only', async () => { const page = await readFile(new URL('../pages/picking/picking.vue', import.meta.url), 'utf8') const methods = ['loadingCompletionState', 'evidenceReadOnly', 'requireEvidenceStage'].map(name => { diff --git a/utils/loading-completion-state.js b/utils/loading-completion-state.js index 31a23cd..a08b765 100644 --- a/utils/loading-completion-state.js +++ b/utils/loading-completion-state.js @@ -46,6 +46,27 @@ export function shouldReconcileCompletion(error = {}) { return Boolean(error.network || error.timeout || code === 'TIMEOUT' || code === 'REQUEST_TIMEOUT') } +/** 受理后立即查询真实任务,串行短轮询只等待终态,不重发完成命令。 */ +export async function observeLoadingCompletion({ initialTask, readTask, isActive, onTask, + intervalMs = 250, maxWaitMs = 10000, now = Date.now, + wait = (delay) => new Promise((resolve) => setTimeout(resolve, delay)) }) { + const started = now() + let task = initialTask + const phase = () => resolveCompletionPhase({ taskStatus: task?.taskStatus || task?.workflowStatus, + commandStatus: task?.completionCommand?.status || task?.commandStatus }) + while (phase() === COMPLETION_PHASE.PROCESSING && isActive() && now() - started < maxWaitMs) { + const latest = await readTask() + if (!isActive()) return null + if (!latest) throw new Error('服务端未返回装柜完成状态,请刷新任务') + task = latest + onTask(task) + if (phase() !== COMPLETION_PHASE.PROCESSING) break + const remaining = maxWaitMs - (now() - started) + if (remaining > 0) await wait(Math.min(intervalMs, remaining)) + } + return isActive() ? task : null +} + /** * A retry keeps the original operationId and the exact accepted request facts. * expectedTaskVersion participates in requestHash and must not be refreshed.