diff --git a/pages/picking/picking.vue b/pages/picking/picking.vue index ae1d63a..9bc73ab 100644 --- a/pages/picking/picking.vue +++ b/pages/picking/picking.vue @@ -148,7 +148,7 @@ - + {{ remoteError }} @@ -387,7 +387,7 @@ selectedIncompleteReason: '', completionSummary: { picked: 0, partial: 0, skipped: 0, autoSkipped: 0 }, evidenceSlots: EVIDENCE_SLOTS, - evidenceFiles: {}, evidenceDrafts: {}, evidencePreviewCache: {}, evidenceQueueItems: [], evidenceQueue: null, evidenceContextId: 0, evidenceIdentity: null, cameraPicking: false, evidencePreviewOpening: false, evidencePreviewReturning: false, sealNo: '', loadingVersion: null, pickingVersion: null, remotePick: null, + evidenceFiles: {}, evidenceDrafts: {}, evidencePreviewCache: {}, evidenceQueueItems: [], evidenceQueue: null, evidenceContextId: 0, evidenceIdentity: null, cameraPicking: false, evidencePreviewOpening: false, evidencePreviewReturning: false, sealNo: '', sealSaving: false, loadingVersion: null, pickingVersion: null, remotePick: null, remoteTaskId: null, assignmentVersion: null, submitId: '', taskRequestId: 0, remoteError: '', remoteLoading: false, submitting: false, @@ -503,6 +503,9 @@ }, evidenceComplete() { return this.evidenceSlots.every((slot) => this.validEvidenceEntries(slot.type).length > 0) && Boolean(String(this.sealNo || '').trim()) + }, + sealReadyToSave() { + return Boolean(String(this.sealNo || '').trim()) && this.hasUnboundUploadedEvidence('SEAL') } }, created() { @@ -791,6 +794,7 @@ this.evidenceFiles = {} this.evidenceQueueItems = [] this.sealNo = '' + this.sealSaving = false this.evidenceQueue = createEvidenceUploadQueue({ upload: (item, onProgress) => uploadEvidenceFile(item.inboundId, item.evidenceType, item.localPath, item.tenantId, onProgress), onChange: (items) => { if (this.evidenceContextId === contextId) this.evidenceQueueItems = items } @@ -1014,8 +1018,14 @@ uni.previewImage({ current, urls, fail: () => { this.evidencePreviewOpening = false } }) }, evidenceItems(result) { - if (Array.isArray(result?.evidences)) return result.evidences - return Array.isArray(result?.evidences?.evidences) ? result.evidences.evidences : [] + const items = Array.isArray(result?.evidences) ? result.evidences : (Array.isArray(result?.evidences?.evidences) ? result.evidences.evidences : []) + const seen = new Set() + return items.filter((item) => { + const key = `${item.evidenceType || ''}:${item.fileId || `id-${item.id || ''}`}` + if (seen.has(key)) return false + seen.add(key) + return true + }) }, validEvidenceEntries(type) { return this.evidenceEntries(type).filter((item) => item.id && item.fileId && item.uploadStatus !== 'PENDING') }, evidenceEntry(type) { const list = this.evidenceEntries(type); return list.length ? list[list.length - 1] : null }, @@ -1352,10 +1362,19 @@ } }, async saveSealNo() { + if (this.sealSaving) return if (!String(this.sealNo || '').trim()) { this.showToast('请输入封签号', 'warning'); return } this.rememberEvidenceDraft() if (!this.loadingStageReady) { this.showToast('封签号已暂存,装柜状态同步后自动保存', 'success'); return } - await this.saveEvidence('SEAL', `seal-no-${this.evidenceIdentity?.taskId || this.evidenceIdentity?.inboundId}-${Date.now()}`) + const current = [...this.evidenceEntries('SEAL')].reverse().find((item) => item.fileId && !item.id) + if (!current) { this.showToast('当前封签已保存,无需重复提交', 'success'); return } + const operationId = stableOperationId('seal', stableTaskOperationIdentity(this.draftIdentity()), { fileId: String(current.fileId), sealNo: String(this.sealNo).trim() }) + this.sealSaving = true + try { + await this.saveEvidence('SEAL', operationId, current.localPath || null) + } finally { + this.sealSaving = false + } }, async voidEvidencePhoto(photo) { if (!this.requireEvidenceStage()) return diff --git a/tests/seal-save-idempotency.test.mjs b/tests/seal-save-idempotency.test.mjs new file mode 100644 index 0000000..fed88dd --- /dev/null +++ b/tests/seal-save-idempotency.test.mjs @@ -0,0 +1,21 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +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 reuses a stable photo-bound operation', () => { + assert.match(page, /:disabled="sealSaving \|\| !sealReadyToSave"/) + assert.match(page, /sealSaving \? '保存中…' : '保存封签'/) + assert.match(page, /async saveSealNo\(\) \{[\s\S]*?if \(this\.sealSaving\) return/) + assert.match(page, /find\(\(item\) => item\.fileId && !item\.id\)/) + assert.match(page, /stableOperationId\('seal',[\s\S]*?fileId: String\(current\.fileId\)[\s\S]*?sealNo: String\(this\.sealNo\)\.trim\(\)/) + assert.doesNotMatch(page, /seal-no-[^\n]*Date\.now\(\)/) + assert.match(page, /this\.sealSaving = true[\s\S]*?finally \{[\s\S]*?this\.sealSaving = false/) +}) + +test('server-returned evidence is deduplicated by type and file id', () => { + assert.match(page, /const seen = new Set\(\)/) + assert.match(page, /item\.evidenceType[\s\S]*?item\.fileId/) + assert.match(page, /if \(seen\.has\(key\)\) return false/) +})