diff --git a/api/pda-loading.js b/api/pda-loading.js index 534890b..36c76a3 100644 --- a/api/pda-loading.js +++ b/api/pda-loading.js @@ -31,7 +31,6 @@ function request(options) { if (session.requestId) headers['X-Request-Id'] = session.requestId return new Promise((resolve, reject) => { - debugLog('api.request', { method: options.method || 'GET', url: options.url }) uni.request({ ...options, url: `${baseUrl}${options.url}`, @@ -39,12 +38,7 @@ function request(options) { success(response) { const body = response.data || {} const code = String(body.code ?? response.statusCode) - debugLog('api.response', { - method: options.method || 'GET', url: options.url, statusCode: response.statusCode, code, - message: body.message || '', requestId: body.requestId || response.header?.['X-Request-Id'] || response.header?.['x-request-id'] || '', - traceId: body.traceId || response.header?.['X-Trace-Id'] || response.header?.['x-trace-id'] || '', - dataKeys: body.data && typeof body.data === 'object' ? Object.keys(body.data) : [] - }) + if (code !== '200') { const error = new Error(body.message || '服务端请求失败') error.code = body.code || response.statusCode @@ -57,7 +51,6 @@ function request(options) { resolve(body.data) }, fail(error) { - debugLog('api.network-error', { method: options.method || 'GET', url: options.url, errMsg: error.errMsg || '' }) const next = new Error(error.errMsg || '网络连接失败') next.network = true reject(next) @@ -289,6 +282,7 @@ export function uploadEvidenceFile(inboundId, evidenceType, localPath, tenantId, const headers = {} if (session.token) headers.Authorization = `Bearer ${session.token}` if (session.requestId) headers['X-Request-Id'] = session.requestId + debugLog('evidence.upload.start', { inboundId, evidenceType, tenantId: tenant, hasLocalPath: Boolean(localPath) }) return new Promise((resolve, reject) => { const task = uni.uploadFile({ url: `${baseUrl}/files/loading-evidence/${encodeURIComponent(tenant)}/${encodeURIComponent(inboundId)}?evidenceType=${encodeURIComponent(evidenceType)}`, @@ -299,10 +293,11 @@ export function uploadEvidenceFile(inboundId, evidenceType, localPath, tenantId, let body = response.data try { body = typeof body === 'string' ? JSON.parse(body) : body } catch (_) { /* handled below */ } const code = String(body?.code ?? response.statusCode) + debugLog('evidence.upload.response', { inboundId, evidenceType, statusCode: response.statusCode, code, message: body?.message || '', requestId: body?.requestId || response.header?.['X-Request-Id'] || response.header?.['x-request-id'] || '', traceId: body?.traceId || response.header?.['X-Trace-Id'] || response.header?.['x-trace-id'] || '', fileId: body?.data?.fileId || null }) if (code !== '200') { const error = new Error(body?.message || '文件上传失败'); error.code = body?.code || response.statusCode; reject(error); return } resolve(body?.data || body) }, - fail(error) { const next = new Error(error.errMsg || '网络连接失败'); next.network = true; reject(next) } + fail(error) { debugLog('evidence.upload.error', { inboundId, evidenceType, message: error.errMsg || '' }); const next = new Error(error.errMsg || '网络连接失败'); next.network = true; reject(next) } }) if (task && typeof task.onProgressUpdate === 'function' && typeof onProgress === 'function') task.onProgressUpdate(({ progress }) => onProgress(progress)) }) diff --git a/pages/picking/picking.vue b/pages/picking/picking.vue index 4bb7876..941bd90 100644 --- a/pages/picking/picking.vue +++ b/pages/picking/picking.vue @@ -375,7 +375,7 @@ selectedIncompleteReason: '', completionSummary: { picked: 0, partial: 0, skipped: 0, autoSkipped: 0 }, evidenceSlots: EVIDENCE_SLOTS, - evidenceFiles: {}, evidenceQueueItems: [], evidenceQueue: null, evidenceContextId: 0, evidenceIdentity: null, sealNo: '', loadingVersion: null, pickingVersion: null, remotePick: null, + evidenceFiles: {}, evidenceQueueItems: [], evidenceQueue: null, evidenceContextId: 0, evidenceIdentity: null, cameraPicking: false, sealNo: '', loadingVersion: null, pickingVersion: null, remotePick: null, remoteTaskId: null, assignmentVersion: null, submitId: '', taskRequestId: 0, remoteError: '', remoteLoading: false, submitting: false, @@ -500,6 +500,10 @@ onShow() { if (!requireSession()) return this.startHardwareScanner() + if (this.cameraPicking) { + this.debugEvidence('choose.resume', { evidenceIdentity: this.evidenceIdentity }) + return + } this.syncRemoteTask() }, onHide() { this.stopHardwareScanner() }, @@ -512,6 +516,9 @@ return false }, methods: { + debugEvidence(event, payload = {}) { + try { console.info(`[PDA] evidence.${event}`, payload) } catch (_) { /* console may be unavailable on a device */ } + }, debugSubmit(event, payload = {}) { try { console.info('[PDA] picking.submit', { @@ -743,6 +750,10 @@ this.pickScannedBox(result.item, value) }, evidenceEntries(type) { return Array.isArray(this.evidenceFiles[type]) ? this.evidenceFiles[type].filter((item) => item.status !== 'VOID' && item.status !== 'VOIDED') : (this.evidenceFiles[type] ? [this.evidenceFiles[type]] : []) }, + evidenceItems(result) { + if (Array.isArray(result?.evidences)) return result.evidences + return Array.isArray(result?.evidences?.evidences) ? result.evidences.evidences : [] + }, 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 }, evidenceQueueEntry(type) { @@ -869,20 +880,28 @@ return this.evidenceEntry(type) ? '继续拍照' : '拍照' }, chooseEvidence(slot) { - if (!this.requireEvidenceStage()) return - if (!isOnline()) { this.showToast('当前网络不可用,请联网后再拍照上传', 'warning'); return } - if (typeof uni.chooseImage !== 'function') { this.showToast('当前环境不支持选择照片', 'warning'); return } + const evidenceType = slot?.type || null + this.debugEvidence('choose.start', { evidenceType, evidenceIdentity: this.evidenceIdentity, loadingStageReady: this.loadingStageReady, hasChooseImage: typeof uni.chooseImage === 'function' }) + if (!this.requireEvidenceStage()) { this.debugEvidence('choose.blocked', { evidenceType, reason: 'stage-not-ready', evidenceIdentity: this.evidenceIdentity, loadingVersion: this.loadingVersion }); return } + if (!isOnline()) { this.debugEvidence('choose.blocked', { evidenceType, reason: 'offline' }); this.showToast('当前网络不可用,请联网后再拍照上传', 'warning'); return } + if (typeof uni.chooseImage !== 'function') { this.debugEvidence('choose.blocked', { evidenceType, reason: 'chooseImage-unavailable' }); this.showToast('当前环境不支持选择照片', 'warning'); return } const contextId = this.evidenceContextId const identity = { ...this.evidenceIdentity } + this.cameraPicking = true + this.debugEvidence('choose.open-camera', { evidenceType, contextId, evidenceIdentity: identity }) uni.chooseImage({ count: 1, sizeType: ['compressed'], sourceType: ['camera'], success: (result) => { + this.cameraPicking = false if (!this.isEvidenceContextActive(contextId, identity)) return const path = result.tempFilePaths && result.tempFilePaths[0] - if (!path) return + if (!path) { this.debugEvidence('camera.empty', { evidenceType: slot.type, evidenceIdentity: identity }); return } + this.debugEvidence('camera.selected', { evidenceType: slot.type, evidenceIdentity: identity }) const next = { evidenceType: slot.type, fileName: path.split('/').pop() || path, localPath: path, uploadStatus: 'PENDING' } this.evidenceFiles = { ...this.evidenceFiles, [slot.type]: [...(this.evidenceFiles[slot.type] || []), next] } this.queueEvidenceUpload(slot.type, path) - }, fail: () => { + }, fail: (error) => { + this.cameraPicking = false if (!this.isEvidenceContextActive(contextId, identity)) return + this.debugEvidence('camera.error', { evidenceType: slot.type, evidenceIdentity: identity, message: error?.errMsg || '' }) this.showToast('未选择照片,可稍后重试', 'warning') } }) }, @@ -893,9 +912,11 @@ const identity = { ...this.evidenceIdentity } const queue = this.evidenceQueue const entry = queue.add({ ...identity, tenantId: this.currentBox.tenantId, evidenceType: type, localPath: path }) + this.debugSubmit('evidence.queue.start', { evidenceType: type, queueId: entry.id, evidenceIdentity: identity }) await queue.flush() if (!this.isEvidenceContextActive(contextId, identity) || queue !== this.evidenceQueue) return const latest = queue.snapshot().find((item) => item.id === entry.id) + this.debugSubmit('evidence.queue.result', { evidenceType: type, queueId: entry.id, status: latest?.status || 'missing', fileId: latest?.result?.fileId || null, error: latest?.error || '' }) if (latest?.status !== 'SUCCEEDED') { this.remoteError = latest?.error || '文件上传失败'; return } const current = this.evidenceEntry(type) || {} this.evidenceFiles = { ...this.evidenceFiles, [type]: [...(this.evidenceFiles[type] || []).slice(0, -1), { ...current, fileId: latest.result?.fileId, fileName: latest.result?.fileName || current.fileName, uploadStatus: 'SUCCEEDED' }] } @@ -928,10 +949,12 @@ const current = this.evidenceEntry(type) || {} if (!current.fileId) { this.showToast('照片尚未上传完成', 'warning'); return } const payload = { expectedVersion: current.version || 0, evidenceType: type, fileId: current.fileId || null, fileName: current.fileName || null, sealNo: type === 'SEAL' ? this.sealNo : null, remark: null, operationId: operationId || `evidence-${identity.taskId || identity.inboundId}-${type}-${current.fileId || 'seal'}` } + this.debugSubmit('evidence.bind.start', { evidenceType: type, fileId: current.fileId, expectedVersion: payload.expectedVersion, evidenceIdentity: identity }) try { const result = await upsertEvidence(inboundId, payload, tenantId) if (!this.isEvidenceContextActive(contextId, identity)) return - this.evidenceFiles = (result && result.evidences ? result.evidences : []).reduce((all, item) => { const list = (all[item.evidenceType] || []).filter((entry) => entry.id || entry.fileId !== item.fileId); const index = list.findIndex((entry) => entry.id === item.id); all[item.evidenceType] = index >= 0 ? list.map((entry, i) => i === index ? item : entry) : [...list, item]; return all }, this.evidenceFiles) + this.debugSubmit('evidence.bind.success', { evidenceType: type, fileId: current.fileId, evidenceCount: result?.evidences?.length || 0, evidenceIdentity: identity }) + this.evidenceFiles = this.evidenceItems(result).reduce((all, item) => { const list = (all[item.evidenceType] || []).filter((entry) => entry.id || entry.fileId !== item.fileId); const index = list.findIndex((entry) => entry.id === item.id); all[item.evidenceType] = index >= 0 ? list.map((entry, i) => i === index ? item : entry) : [...list, item]; return all }, this.evidenceFiles) this.showToast(`${type === 'SEAL' ? '封签' : '照片'}已保存`, 'success') } catch (error) { if (!this.isEvidenceContextActive(contextId, identity)) return diff --git a/tests/pda-loading-api.test.mjs b/tests/pda-loading-api.test.mjs index ac046a9..c790765 100644 --- a/tests/pda-loading-api.test.mjs +++ b/tests/pda-loading-api.test.mjs @@ -61,6 +61,11 @@ test('picking page exposes five evidence slots, retry and remote completion stat assert.match(page, /evidenceComplete/) }) +test('PDA evidence binding unwraps the nested loading action response before rendering saved files', () => { + assert.match(page, /evidenceItems\(result\)[\s\S]*?Array\.isArray\(result\?\.evidences\?\.evidences\)[\s\S]*?result\.evidences\.evidences/) + assert.match(page, /this\.evidenceFiles = this\.evidenceItems\(result\)\.reduce/) +}) + test('PDA cabinet picking adapter claims online and submits one atomic batch', () => { assert.match(source, /\/first-leg\/pda-loading\/tasks\/pending/) assert.match(source, /\/first-leg\/pda-loading\/tasks\?/) diff --git a/tests/picking-evidence-stage.test.mjs b/tests/picking-evidence-stage.test.mjs index bed17d4..d4199e0 100644 --- a/tests/picking-evidence-stage.test.mjs +++ b/tests/picking-evidence-stage.test.mjs @@ -45,7 +45,7 @@ test('evidence mutations defensively reject calls before picking completion', () assert.match(page, /requireEvidenceStage\(\)[\s\S]*?if \(!this\.pickingCompleted\)[\s\S]*?请先提交拣货完成[\s\S]*?if \(!this\.loadingStageReady\)/) for (const method of ['chooseEvidence', 'queueEvidenceUpload', 'retryEvidence', 'saveEvidence', 'saveSealNo', 'voidEvidencePhoto', 'completeLoadingTask']) { const body = page.match(new RegExp(`(?:async )?${method}\\([^)]*\\) \\{[\\s\\S]*?\\n\\t\\t\\t\\},`))?.[0] || '' - assert.match(body, /if \(!this\.requireEvidenceStage\(\)\) return/, `${method} should guard the evidence stage`) + assert.match(body, /if \(!this\.requireEvidenceStage\(\)\)(?: return| \{)/, `${method} should guard the evidence stage`) } }) @@ -69,6 +69,15 @@ test('camera callbacks cannot append evidence after switching cabinets', () => { assert.match(chooser, /isEvidenceContextActive\(contextId, identity\)[\s\S]*?this\.evidenceFiles =/) }) +test('returning from the camera does not refresh and invalidate the active evidence context', () => { + assert.match(page, /cameraPicking: false/) + assert.match(page, /onShow\(\) \{[\s\S]*?if \(this\.cameraPicking\)[\s\S]*?choose\.resume[\s\S]*?return[\s\S]*?this\.syncRemoteTask\(\)/) + const chooser = page.match(/chooseEvidence\(slot\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || '' + assert.match(chooser, /this\.cameraPicking = true/) + assert.match(chooser, /success: \(result\) => \{[\s\S]*?this\.cameraPicking = false/) + assert.match(chooser, /fail: \(error\) => \{[\s\S]*?this\.cameraPicking = false/) +}) + test('evidence save, void and loading completion ignore stale cabinet responses', () => { for (const [method, awaitedCall] of [ ['saveEvidence', 'upsertEvidence'],