缩短装柜完成等待并保留权威状态查询
This commit is contained in:
@@ -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')
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user