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/)
+})