This commit is contained in:
2026-08-17 15:41:50 +08:00
parent 6bbbcb7750
commit bab911028d
2 changed files with 150 additions and 24 deletions
+108 -14
View File
@@ -33,8 +33,9 @@
</view>
<view class="stage-switch" role="tablist" aria-label="作业阶段">
<button class="stage-tab" :class="{ active: activeStage === 'picking' }" role="tab" :aria-selected="activeStage === 'picking'" @tap="selectStage('picking')">拣货作业</button>
<button class="stage-tab" :class="{ active: activeStage === 'evidence' }" role="tab" :aria-selected="activeStage === 'evidence'" @tap="selectStage('evidence')">装柜取证<text v-if="!loadingStageReady" class="stage-tab-hint">照片可先暂存</text></button>
<button class="stage-tab" :class="{ active: activeStage === 'evidence' }" :disabled="!loadingStageReady" role="tab" :aria-selected="activeStage === 'evidence'" @tap="selectStage('evidence')">装柜取证</button>
</view>
<view v-if="!loadingStageReady" class="evidence-stage-hint" role="status">请先处理并提交全部待拣明细;任务增量更新后,已拍证据会按当前柜次保留。</view>
<view v-if="activeStage === 'picking'" class="picking-stage">
<view class="scan-section">
@@ -123,7 +124,7 @@
</view>
</view>
<view v-if="activeStage === 'evidence'" class="loading-evidence-section">
<view v-if="activeStage === 'evidence' && loadingStageReady" class="loading-evidence-section">
<view class="section-heading-row">
<view><text class="section-title">装柜证据</text><text class="section-subtitle">空仓、半仓、满仓、锁仓及带锁码照</text></view>
<text class="sync-state" :class="remoteSyncState.tone">{{ remoteSyncState.label }}</text>
@@ -148,9 +149,8 @@
<input v-model="sealNo" class="seal-input" placeholder="输入封签号(必填)" maxlength="64" />
<button class="evidence-btn seal-btn" @tap="saveSealNo">保存封签</button>
</view>
<view v-if="!loadingStageReady" class="evidence-stage-hint" role="status">当前可先拍照留存;拣货完成并同步装柜状态后,照片会自动上传绑定。</view>
<view v-if="remoteError" class="remote-error-row"><text class="remote-error" role="alert">{{ remoteError }}</text><button class="evidence-retry-btn" :disabled="remoteLoading" @tap="refreshTask">刷新任务</button></view>
<button class="loading-complete-btn" :disabled="submitting || pickingVersion === null || loadingVersion === null || !evidenceComplete" @tap="completeLoadingTask">确认装柜完成</button>
<button class="loading-complete-btn" :disabled="submitting || !loadingStageReady || pickingVersion === null || loadingVersion === null || !evidenceComplete" @tap="completeLoadingTask">确认装柜完成</button>
<view v-if="loadingVersion === null" class="remote-error-row">
<text class="version-hint" role="status">状态尚未同步,请刷新重试</text>
<button class="evidence-retry-btn" :disabled="remoteLoading" @tap="refreshTask">刷新任务</button>
@@ -421,8 +421,11 @@
return this.boxes.length - this.completedCount
},
pickingCompleted() {
if (this.remotePick) {
const hasServerPending = this.currentBox.items.some((item) => ['PENDING', 'PICKING'].includes(item.itemStatus))
return isPickingCompleteTask(this.remotePick) && !hasServerPending
}
return Boolean(this.completedBoxes[this.currentBoxIndex])
|| isPickingCompleteTask(this.remotePick)
},
pickingOperationReady() {
return this.remotePick?.workflowStatus === 'IN_PROGRESS'
@@ -543,6 +546,10 @@
},
methods: {
selectStage(stage) {
if (stage === 'evidence' && !this.loadingStageReady) {
this.showToast('请先完成新增待拣明细', 'warning')
return
}
this.activeStage = stage
},
debugEvidence(event, payload = {}) {
@@ -563,6 +570,32 @@
})
} catch (_) { /* console may be unavailable on a device */ }
},
isTaskConflict(error) {
return String(error?.code || '') === 'A0409' || Number(error?.statusCode) === 409
},
async refreshAfterTaskConflict(message) {
const requestedTaskId = this.remoteTaskId || this.currentBox.taskId
this.submitId = ''
this.submitting = false
this.remoteError = message
this.showToast(message, 'warning')
await this.syncRemoteTask()
if (String(this.remoteTaskId || '') === String(requestedTaskId || '') && this.remotePick?.taskStatus === 'LOADING') {
try { await this.refreshInventorySnapshot() } catch (error) { this.debugSubmit('refresh.inventory-error', { message: error?.message || '', code: error?.code || '' }) }
}
if (String(this.remoteTaskId || '') === String(requestedTaskId || '') && !this.remoteError) this.remoteError = message
},
async refreshInventorySnapshot() {
const warehouseId = this.currentBox.warehouseId || session.currentUser?.warehouseId || session.currentUser?.warehouse
const snapshot = await getPickingInventorySnapshot(this.remoteTaskId, warehouseId)
if (!snapshot?.snapshotToken) throw new Error('库存快照未同步,请刷新任务后重试')
if (this.pickingVersion !== null && String(snapshot.version) !== String(this.pickingVersion)) {
const error = new Error('库存已变化,请刷新后重试')
error.code = 'A0409'
throw error
}
return snapshot
},
captureEvidenceIdentity() {
return {
taskId: this.remoteTaskId || this.currentBox.taskId || null,
@@ -699,11 +732,26 @@
const restored = restorePickingSession(this.draftIdentity())
if (!restored) return false
const itemIds = new Set(this.currentBox.items.map((item) => String(item.id)))
this.itemStates = Object.fromEntries(Object.entries(restored.itemStates || {}).filter(([id]) => itemIds.has(String(id))))
this.partialQuantities = Object.fromEntries(Object.entries(restored.partialQuantities || {}).filter(([id]) => itemIds.has(String(id))))
this.scannedBoxCodes = { ...(restored.scannedBoxCodes || {}) }
const serverStates = { ...this.itemStates }
const serverPartialQuantities = { ...this.partialQuantities }
Object.entries(restored.itemStates || {}).forEach(([id, status]) => {
if (!itemIds.has(String(id)) || serverStates[id] !== ITEM_STATUS.PENDING || status === ITEM_STATUS.PENDING) return
const item = this.currentBox.items.find((entry) => String(entry.id) === String(id))
const partial = Number(restored.partialQuantities?.[id] || 0)
if (status === ITEM_STATUS.PARTIAL && (!partial || partial >= Number(item?.qty || 0))) return
serverStates[id] = status
if (partial > 0) serverPartialQuantities[id] = partial
})
this.itemStates = serverStates
this.partialQuantities = serverPartialQuantities
this.scannedBoxCodes = Object.fromEntries(Object.entries(restored.scannedBoxCodes || {}).filter(([, id]) => itemIds.has(String(id))))
this.boxDrafts = restored.boxDrafts || {}
this.completedBoxes = restored.completedBoxes || {}
if (this.remotePick && !isPickingCompleteTask(this.remotePick)) {
const completedBoxes = { ...this.completedBoxes }
delete completedBoxes[this.currentBoxIndex]
this.completedBoxes = completedBoxes
}
this.scanHistory = this.normalizeScanHistory(restored.scanHistory)
return true
},
@@ -911,6 +959,13 @@
if (!requestedTaskId) { this.remoteError = '当前没有可进入的拣货任务,请返回任务列表刷新'; return }
this.rememberEvidenceDraft()
const previousIdentity = this.draftIdentity()
const previousTaskVersion = this.pickingVersion
const previousItemIds = new Set(this.currentBox.items.map((item) => String(item.id)))
const previousEvidenceState = {
identity: this.evidenceIdentity ? { ...this.evidenceIdentity } : null,
files: Object.fromEntries(Object.entries(this.evidenceFiles).map(([type, entries]) => [type, (Array.isArray(entries) ? entries : [entries]).map((entry) => ({ ...entry }))])),
sealNo: this.sealNo
}
const requestId = ++this.taskRequestId
const selector = { taskId: requestedTaskId }
this.currentBox.inboundId = null
@@ -1004,9 +1059,26 @@
this.loadingVersion = this.remotePick.inboundVersion ?? this.remotePick.loadingVersion ?? null
this.evidenceIdentity = this.captureEvidenceIdentity()
this.restoreEvidenceDraft()
if (this.isSameEvidenceIdentity(previousEvidenceState.identity, this.evidenceIdentity)) {
this.evidenceFiles = { ...this.evidenceFiles, ...previousEvidenceState.files }
if (previousEvidenceState.sealNo) this.sealNo = previousEvidenceState.sealNo
}
this.debugSubmit('refresh.ready', { requestId, workflowStatus: this.remotePick.workflowStatus, taskStatus: this.remotePick.taskStatus, assignmentVersion: this.assignmentVersion, loadingVersion: this.loadingVersion, evidenceIdentity: this.evidenceIdentity })
this.assignmentVersion = this.remotePick.assignment?.version ?? this.remotePick.assignmentVersion ?? this.assignmentVersion
if (!this.pickingCompleted) this.restoreRemoteDraft()
if (!this.pickingCompleted) {
const completedBoxes = { ...this.completedBoxes }
delete completedBoxes[this.currentBoxIndex]
this.completedBoxes = completedBoxes
this.activeStage = 'picking'
this.completionSummary = { picked: 0, partial: 0, skipped: 0, autoSkipped: 0 }
}
const addedItemIds = this.currentBox.items.map((item) => String(item.id)).filter((id) => !previousItemIds.has(id))
const taskVersionChanged = previousTaskVersion !== null && String(previousTaskVersion) !== String(this.pickingVersion)
if (taskVersionChanged || addedItemIds.length) {
this.debugSubmit('refresh.incremental', { previousTaskVersion, taskVersion: this.pickingVersion, addedItemIds, pickingCompleted: this.pickingCompleted })
if (addedItemIds.length) this.showToast(`已同步 ${addedItemIds.length} 条新增待拣明细`, 'warning')
}
if (this.loadingStageReady) await this.loadLoadingEvidence()
} catch (error) {
if (requestId !== this.taskRequestId) return
@@ -1119,8 +1191,12 @@
},
async flushPendingEvidence() {
if (!this.loadingStageReady) return
const pending = this.evidenceSlots.flatMap((slot) => this.evidenceEntries(slot.type).filter((entry) => entry.localPath && !entry.fileId).map((entry) => ({ type: slot.type, path: entry.localPath })))
for (const item of pending) await this.queueEvidenceUpload(item.type, item.path)
const pending = this.evidenceSlots.flatMap((slot) => this.evidenceEntries(slot.type).filter((entry) => !entry.id && entry.localPath).map((entry) => ({ type: slot.type, path: entry.localPath, fileId: entry.fileId })))
for (const item of pending) {
if (!this.loadingStageReady) return
if (item.fileId) await this.saveEvidence(item.type, null, item.path)
else await this.queueEvidenceUpload(item.type, item.path)
}
},
async retryEvidence(type) {
if (!this.requireEvidenceStage()) return
@@ -1161,6 +1237,10 @@
this.showToast(`${type === 'SEAL' ? '封签' : '照片'}已保存`, 'success')
} catch (error) {
if (!this.isEvidenceContextActive(contextId, identity)) return
if (this.isTaskConflict(error)) {
await this.refreshAfterTaskConflict(error.message || '任务内容已更新,请完成新增待拣项后重试')
return
}
this.remoteError = error.network ? '网络失败,证据未提交,请重试' : (error.message || '证据保存失败')
this.showToast('证据保存失败', 'error')
}
@@ -1186,6 +1266,10 @@
this.showToast('照片已作废,可重新拍摄', 'success')
} catch (error) {
if (!this.isEvidenceContextActive(contextId, identity)) return
if (this.isTaskConflict(error)) {
await this.refreshAfterTaskConflict(error.message || '任务内容已更新,请刷新后重试')
return
}
this.remoteError = error.network ? '网络失败,照片未作废,请重试' : (error.message || '照片作废失败')
this.showToast('照片作废失败', 'error')
}
@@ -1220,6 +1304,10 @@
this.showToast('已确认装柜完成', 'success')
} catch (error) {
if (!this.isEvidenceContextActive(contextId, identity)) return
if (this.isTaskConflict(error)) {
await this.refreshAfterTaskConflict(error.message || '装柜前置条件已变化,请刷新后重试')
return
}
this.remoteError = error.network ? '网络失败,装柜完成未提交' : (error.message || '装柜完成失败')
this.showToast('装柜完成失败', 'error')
}
@@ -1377,10 +1465,11 @@
const submitId = this.submitId || stableOperationId('submit', operationIdentity, { itemStates: this.itemStates, partialQuantities: this.partialQuantities, incompleteReason })
this.submitId = submitId
const warehouseId = this.currentBox.warehouseId || session.currentUser?.warehouseId || session.currentUser?.warehouse
const inventorySnapshot = await getPickingInventorySnapshot(this.remoteTaskId, warehouseId)
const inventorySnapshot = await this.refreshInventorySnapshot()
this.debugSubmit('submit.inventory-snapshot', { snapshotTokenLength: inventorySnapshot?.snapshotToken ? String(inventorySnapshot.snapshotToken).length : 0, taskVersion: inventorySnapshot?.version ?? null })
if (!inventorySnapshot?.snapshotToken) throw new Error('库存快照未同步,请刷新任务后重试')
const actions = this.currentBox.items.map((item) => {
if (item.itemStatus && !['PENDING', 'PICKING'].includes(item.itemStatus)) return null
const status = this.itemState(item)
const itemId = item.taskItemId ?? item.remoteItemId ?? item.id
if (status === ITEM_STATUS.PICKED) return { type: 'PICK', itemId, pickedPackageCount: Number(item.qty || 0), locationIds: item.locationIds || [], version: Number(item.version || 0) }
@@ -1394,17 +1483,22 @@
this.debugSubmit('submit.success', { itemCount: actions.length, workflowStatus: submittedTask?.workflowStatus || null, taskStatus: submittedTask?.taskStatus || null })
if (submittedTask) {
this.remotePick = normalizePickingTask({ ...this.remotePick, ...submittedTask })
this.pickingVersion = this.remotePick.pickingVersion ?? this.remotePick.version ?? this.pickingVersion
this.loadingVersion = submittedTask.inboundVersion ?? submittedTask.loadingVersion ?? this.remotePick.inboundVersion ?? this.remotePick.loadingVersion ?? this.loadingVersion
this.evidenceIdentity = this.captureEvidenceIdentity()
}
// Legacy inbound completePicking(expectedVersion: Number(this.pickingVersion)) remains adapter-only; cabinet tasks use item actions.
} catch (error) {
this.debugSubmit('submit.error', { message: error.message || '', code: error.code || '', statusCode: error.statusCode || '', requestIdFromServer: error.requestId || '', traceId: error.traceId || '', network: Boolean(error.network) })
const versionConflict = error.code === 'A0409' || error.statusCode === 409
const versionConflict = this.isTaskConflict(error)
const inventoryConflict = versionConflict && /库存/.test(error.message || '')
const conflictMessage = inventoryConflict ? '库存已变化,请刷新后重试' : '任务版本已变化,请刷新后重试'
this.remoteError = versionConflict ? conflictMessage : (error.network ? '网络失败,拣货完成未提交,请重试' : (error.message || '拣货完成失败'))
this.showToast(versionConflict ? conflictMessage : '拣货完成未提交,请查看错误提示', 'error')
if (versionConflict) {
await this.refreshAfterTaskConflict(conflictMessage)
return
}
this.remoteError = error.network ? '网络失败,拣货完成未提交,请重试' : (error.message || '拣货完成失败')
this.showToast('拣货完成未提交,请查看错误提示', 'error')
return
} finally { this.submitting = false }
}
+42 -10
View File
@@ -5,10 +5,10 @@ import { readFile } from 'node:fs/promises'
const page = await readFile(new URL('../pages/picking/picking.vue', import.meta.url), 'utf8')
test('picking and photo workspaces are independently selectable', () => {
assert.match(page, /v-if="activeStage === 'evidence'" class="loading-evidence-section"/)
assert.match(page, /v-if="activeStage === 'evidence' && loadingStageReady" class="loading-evidence-section"/)
assert.match(page, /isPickingCompleteTask\(task\)[\s\S]*?AWAITING_REVIEW[\s\S]*?COMPLETED/)
assert.match(page, /pickingCompleted\(\)[\s\S]*?completedBoxes\[this\.currentBoxIndex\][\s\S]*?isPickingCompleteTask\(this\.remotePick\)/)
assert.match(page, /照片可先暂存/)
assert.match(page, /pickingCompleted\(\)[\s\S]*?if \(this\.remotePick\)[\s\S]*?hasServerPending[\s\S]*?isPickingCompleteTask\(this\.remotePick\) && !hasServerPending/)
assert.match(page, /请先处理并提交全部待拣明细/)
})
test('loading evidence is not requested before picking completion', () => {
@@ -36,17 +36,49 @@ test('AWAITING_REVIEW alone cannot request or mutate server evidence without a s
assert.doesNotMatch(page, /if \(this\.pickingCompleted\) await this\.loadLoadingEvidence\(\)/)
})
test('picking and evidence are separate operator-selected stages', () => {
test('dynamic pending items withdraw evidence until the server task is picking-complete again', () => {
assert.match(page, /class="stage-switch" role="tablist"/)
assert.match(page, /@tap="selectStage\('picking'\)"/)
assert.match(page, /@tap="selectStage\('evidence'\)"/)
assert.match(page, /selectStage\(stage\) \{[\s\S]*?this\.activeStage = stage/)
assert.match(page, /selectStage\(stage\) \{[\s\S]*?stage === 'evidence' && !this\.loadingStageReady[\s\S]*?请先完成新增待拣明细[\s\S]*?return[\s\S]*?this\.activeStage = stage/)
assert.match(page, /class="stage-tab"[\s\S]*?:disabled="!loadingStageReady"[\s\S]*?装柜取证/)
assert.match(page, /activeStage === 'picking'/)
assert.doesNotMatch(page, /activeStage === 'picking' \|\| !loadingStageReady/)
assert.match(page, /requireEvidenceCapture\(\)[\s\S]*?evidenceIdentity\?\.inboundId/)
assert.match(page, /if \(this\.loadingStageReady\) this\.queueEvidenceUpload/)
assert.match(page, /照片已暂存,装柜状态同步后自动上传/)
assert.match(page, /本地暂存,待自动上传/)
assert.match(page, /:disabled="submitting \|\| !loadingStageReady \|\| pickingVersion === null/)
})
test('incremental refresh merges new pending items without overwriting submitted server results', () => {
const restore = page.match(/restoreRemoteDraft\(\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || ''
assert.match(restore, /const serverStates = \{ \.\.\.this\.itemStates \}/)
assert.match(restore, /serverStates\[id\] !== ITEM_STATUS\.PENDING/)
assert.match(restore, /status === ITEM_STATUS\.PARTIAL[\s\S]*?partial >= Number\(item\?\.qty/)
assert.match(restore, /this\.remotePick && !isPickingCompleteTask\(this\.remotePick\)[\s\S]*?delete completedBoxes\[this\.currentBoxIndex\]/)
assert.match(page, /const previousTaskVersion = this\.pickingVersion/)
assert.match(page, /const previousItemIds = new Set/)
assert.match(page, /previousEvidenceState[\s\S]*?isSameEvidenceIdentity\(previousEvidenceState\.identity, this\.evidenceIdentity\)[\s\S]*?this\.evidenceFiles = \{ \.\.\.this\.evidenceFiles, \.\.\.previousEvidenceState\.files \}/)
assert.match(page, /if \(!this\.pickingCompleted\)[\s\S]*?delete completedBoxes\[this\.currentBoxIndex\][\s\S]*?this\.activeStage = 'picking'/)
assert.match(page, /refresh\.incremental[\s\S]*?addedItemIds/)
})
test('incremental batch submits only current server-pending items and refreshes stale inventory conflicts', () => {
const submit = page.match(/async performSubmit\([^)]*\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || ''
assert.match(submit, /item\.itemStatus && !\['PENDING', 'PICKING'\]\.includes\(item\.itemStatus\)\) return null/)
assert.match(submit, /const versionConflict = this\.isTaskConflict\(error\)/)
assert.match(submit, /await this\.refreshAfterTaskConflict\(conflictMessage\)/)
assert.match(submit, /const inventorySnapshot = await this\.refreshInventorySnapshot\(\)/)
assert.match(page, /refreshAfterTaskConflict\(message\)[\s\S]*?this\.submitId = ''[\s\S]*?await this\.syncRemoteTask\(\)[\s\S]*?await this\.refreshInventorySnapshot\(\)/)
assert.match(page, /refreshInventorySnapshot\(\)[\s\S]*?getPickingInventorySnapshot\(this\.remoteTaskId, warehouseId\)[\s\S]*?String\(snapshot\.version\) !== String\(this\.pickingVersion\)[\s\S]*?error\.code = 'A0409'/)
assert.match(page, /this\.pickingVersion = this\.remotePick\.pickingVersion \?\? this\.remotePick\.version/)
})
test('final completion preserves backend gate errors and refreshes after an atomic A0409', () => {
const complete = page.match(/async completeLoadingTask\(\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || ''
assert.match(complete, /this\.isTaskConflict\(error\)[\s\S]*?await this\.refreshAfterTaskConflict\(error\.message \|\| '装柜前置条件已变化,请刷新后重试'\)/)
const save = page.match(/async saveEvidence\([^)]*\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || ''
assert.match(save, /this\.isTaskConflict\(error\)[\s\S]*?refreshAfterTaskConflict/)
const flush = page.match(/async flushPendingEvidence\(\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || ''
assert.match(flush, /!entry\.id && entry\.localPath/)
assert.match(flush, /if \(item\.fileId\) await this\.saveEvidence\(item\.type, null, item\.path\)/)
assert.match(flush, /else await this\.queueEvidenceUpload\(item\.type, item\.path\)/)
})
test('evidence identity is bound to task, inbound, loading plan and container booking', () => {