diff --git a/src/api/receiving-adapter.js b/src/api/receiving-adapter.js index daf8d13..b568bb2 100644 --- a/src/api/receiving-adapter.js +++ b/src/api/receiving-adapter.js @@ -95,3 +95,36 @@ export function isNoCurrentReceiving(error) { export function isReceivingConflict(error) { return ['409', 'A0409', 'B0409'].includes(String(error?.code || '')) } + +export function toReceivingError(error, fallback = '海外仓作业失败') { + const code = error?.code + const rawMessage = String(error?.message || '').trim() + const palletConflictMessages = { + 'Pallet is not idle': '该托盘已被占用,当前不可用于收货,请更换可用托盘', + 'Pallet location was not found': '该托盘未关联有效库位,请联系管理员维护后重试', + 'Pallet virtual location is not active': '该托盘所在虚拟库位不可用,请更换托盘或联系管理员', + 'Pallet location is not idle': '该托盘所在库位已被占用,请更换可用托盘', + 'Pallet resource availability could not be verified': '无法确认该托盘是否可用,请更换托盘或联系管理员', + 'Pallet does not match an available destination pallet in this inbound code': '该托盘不适用于当前入仓码,请更换符合目的仓要求的可用托盘', + 'Pallet is scrapped': '该托盘已报废,请更换可用托盘', + 'Pallet belongs to another overseas warehouse': '该托盘属于其他海外仓,当前仓不可使用', + 'Pallet FBA destination does not match the inbound code': '该托盘的FBA目的仓与当前入仓码不一致,请更换托盘', + 'FBA-designated pallet cannot receive an OVERSEAS destination carton': 'FBA专用托盘不能接收海外仓目的箱,请更换托盘' + } + const conflictMessage = ['409', 'A0409', 'B0409'].includes(String(code || '')) + ? palletConflictMessages[rawMessage] + : '' + const palletUnavailable = String(code || '') === 'S0503' + && rawMessage === 'Warehouse pallet service returned no usable response' + const mapped = Object.assign(new Error( + conflictMessage || (palletUnavailable + ? '该托盘当前不可用于收货,请更换可用托盘或联系管理员检查托盘状态' + : rawMessage || fallback) + ), { + code, + requestId: error?.requestId, + traceId: error?.traceId, + network: Boolean(error?.network) + }) + return mapped +} diff --git a/src/pages/dispatch/dispatch.vue b/src/pages/dispatch/dispatch.vue index 19193da..83c4b24 100644 --- a/src/pages/dispatch/dispatch.vue +++ b/src/pages/dispatch/dispatch.vue @@ -88,6 +88,7 @@ import { fulfillment, fulfillmentIdempotencyKey, releaseFulfillmentKey, restoreFulfillmentDraft, updateFulfillmentDraft } from '@/stores/fulfillment.js' import { requireSession, session } from '@/stores/session.js' import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js' + import { toDispatchError } from '@/utils/dispatch-error.js' import { startScanBroadcast } from '@/utils/scan-broadcast.js' const finishedStatuses = ['COMPLETED', 'PICKING_COMPLETED', 'READY_TO_DEPART', 'DEPARTED'] @@ -163,7 +164,7 @@ }, async retry() { if (this.current?.dispatchNo) await this.openTask(this.current.dispatchNo); else if (fulfillment.dispatch.currentNo) await this.openTask(fulfillment.dispatch.currentNo); else await this.loadTasks() }, async run(action, work, loading = false) { if (this.busy || this.loading) return; this.error = null; this.successMessage = ''; if (loading) this.loading = true; else { this.busy = true; this.busyAction = action.includes('发车') ? 'depart' : action.includes('完成') ? 'complete' : 'pick' } try { await work() } catch (error) { this.setError(error, `${action}失败`) } finally { this.loading = false; this.busy = false; this.busyAction = '' } }, - setError(error, fallback) { const forbidden = ['403', 'A0403'].includes(String(error?.code || '')); this.error = { message: forbidden ? '当前账号没有海外仓 PDA 作业权限,请联系管理员授权' : error?.message || fallback, code: error?.code, requestId: error?.requestId, traceId: error?.traceId, network: Boolean(error?.network) }; console.error('[Overseas PDA dispatch] failed', this.error); playErrorSound() }, + setError(error, fallback) { this.error = toDispatchError(error, fallback); console.error('[Overseas PDA dispatch] failed', this.error); playErrorSound() }, statusLabel(status) { return ({ PDA_PENDING: '待拣货', PENDING: '待拣货', PICKING: '拣货中', IN_PROGRESS: '拣货中', COMPLETED: '拣货完成', PICKING_COMPLETED: '拣货完成', READY_TO_DEPART: '待发车', DEPARTED: '已发车', CANCELLED: '已取消' })[status] || status || '状态未知' }, routeLabel(item) { return item.warehouseRouteLabel || [item.destinationWarehouseCode, item.destinationWarehouseName, item.fbaWarehouseCode].filter(Boolean).join(' · ') || '目的仓未关联' }, itemProgress(item) { return item.scheduledPackages ? Math.min(100, Math.round(item.pickedPackages / item.scheduledPackages * 100)) : 0 } diff --git a/src/pages/operation/operation.vue b/src/pages/operation/operation.vue index ccd223c..d9764e4 100644 --- a/src/pages/operation/operation.vue +++ b/src/pages/operation/operation.vue @@ -114,7 +114,7 @@ import PortalHeader from '@/components/PortalHeader.vue' import ReceivingSessionSummary from '@/components/ReceivingSessionSummary.vue' import ScanTargetInput from '@/components/ScanTargetInput.vue' - import { assertReceivingWarehouse, isNoCurrentReceiving, isReceivingConflict, RECEIVING_PHOTO_ROLES } from '@/api/receiving-adapter.js' + import { assertReceivingWarehouse, isNoCurrentReceiving, isReceivingConflict, RECEIVING_PHOTO_ROLES, toReceivingError } from '@/api/receiving-adapter.js' import { applyWarehouseProfile, session, requireSession } from '@/stores/session.js' import { idempotencyKey, receiving, receivingPhase, receivingProgress, releaseIdempotencyKey, resetReceivingDraft, setCurrentReceiving } from '@/stores/receiving.js' import { startScanBroadcast } from '@/utils/scan-broadcast.js' @@ -153,7 +153,7 @@ photoStateLabel(photo) { if (photo?.status === 'uploading') return '正在上传…'; if (photo?.status === 'error') return photo.errorMessage || (photo.fileId ? '重拍失败,已保留原图' : '上传失败,请重试'); return photo?.fileId ? '点击左侧照片可查看大图' : '请拍摄现场照片' }, startScanner() { this.stopScanner(); this._stopScanner = startScanBroadcast({ onScan: (value) => this.handleHardwareScan(value), onError: (error) => { this.error = error; this.showToast('硬件扫码接收器异常,请检查扫描服务', 'warning') } }) }, stopScanner() { if (typeof this._stopScanner === 'function') this._stopScanner(); this._stopScanner = null }, handleHardwareScan(value) { console.info('[Overseas PDA scan] dispatch', { value, phase: this.phase, target: this.activeTarget, matchedInbound: this.activeInbound?.inboundCode }); if (!this.inboundMode) return this.showToast('该功能缺少海外 PDA 专用接口,扫码未提交', 'warning'); if (this.phase === 'candidates') return this.showToast('请直接点击待卸货集装箱进入详情', 'warning'); if (this.phase !== 'receive') return; if (this.activeTarget === 'pallet' && this.activeInbound) { this.palletInput = value; return this.resolvePallet(value) } this.cartonInput = value; return this.resolveCarton(value) }, - async run(action, work) { this.busy = true; this.error = null; try { const result = await work(); this.showToast(`${action}成功`, 'success'); return result } catch (error) { this.error = error; playErrorSound(); this.showToast(error.message || `${action}失败`, 'error'); throw error } finally { this.busy = false } }, + async run(action, work) { this.busy = true; this.error = null; try { const result = await work(); this.showToast(`${action}成功`, 'success'); return result } catch (error) { const mappedError = toReceivingError(error, `${action}失败`); this.error = mappedError; playErrorSound(); this.showToast(mappedError.message, 'error'); throw mappedError } finally { this.busy = false } }, setScanTargetForCurrent(value) { receiving.activeScanTarget = value?.status === 'WAIT_SIGN' ? 'container' : 'carton' }, async restorePhotoPreviews() { await Promise.all(receiving.photoSlots.filter((photo) => photo?.downloadPath && !photo.localPath).map(async (photo) => { photo.status = 'restoring'; try { photo.localPath = await downloadCurrentUnloadPhoto(photo.downloadPath); photo.status = 'success' } catch (error) { photo.status = 'error'; photo.errorMessage = error.message || '照片加载失败'; this.error = error } })) }, async applyCurrentReceiving(value) { this.validateReceivingWarehouse(value, '当前接柜会话'); setCurrentReceiving(value); await this.restorePhotoPreviews(); this.setScanTargetForCurrent(value) }, diff --git a/src/utils/dispatch-error.js b/src/utils/dispatch-error.js new file mode 100644 index 0000000..df1d285 --- /dev/null +++ b/src/utils/dispatch-error.js @@ -0,0 +1,23 @@ +const FORBIDDEN_CODES = new Set(['403', 'A0403']) +const CONFLICT_CODES = new Set(['409', 'A0409', 'B0409']) +const VEHICLE_PLATE_REQUIRED = 'Vehicle plate is required before PDA push or picking' + +export function toDispatchError(error, fallback = '排车作业失败') { + const code = String(error?.code || '') + const originalMessage = String(error?.message || '').trim() + let message = originalMessage || fallback + + if (FORBIDDEN_CODES.has(code)) { + message = '当前账号没有海外仓 PDA 作业权限,请联系管理员授权' + } else if (CONFLICT_CODES.has(code) && originalMessage === VEHICLE_PLATE_REQUIRED) { + message = '请先在平台维护车牌信息后再拣货' + } + + return { + message, + code: error?.code, + requestId: error?.requestId, + traceId: error?.traceId, + network: Boolean(error?.network) + } +} diff --git a/tests/fulfillment-workflows.test.mjs b/tests/fulfillment-workflows.test.mjs index 00dc3a0..f811c1c 100644 --- a/tests/fulfillment-workflows.test.mjs +++ b/tests/fulfillment-workflows.test.mjs @@ -9,6 +9,7 @@ import { pickDispatchItem, pickOutboundTask, resolveOutboundTask } from '../src/api/fulfillment.js' import { session } from '../src/stores/session.js' +import { toDispatchError } from '../src/utils/dispatch-error.js' const read = (path) => readFile(new URL(path, import.meta.url), 'utf8') @@ -126,13 +127,13 @@ test('pages expose loading empty error offline and permission diagnostics with g assert.match(page, /暂无/) assert.match(page, /requestId[\s\S]*traceId/) assert.match(page, /error\.network[\s\S]*幂等键/) - assert.match(page, /A0403[\s\S]*没有海外仓 PDA 作业权限/) assert.match(page, /fulfillmentIdempotencyKey/) assert.match(page, /releaseFulfillmentKey/) assert.match(page, /if \(!this\.can/) assert.doesNotMatch(page, /模拟成功|静默成功|customerId:|warehouseId:|locationId:/) } assert.match(dispatch, /completeDispatchPicking/) + assert.match(dispatch, /toDispatchError/) assert.match(dispatch, /this\.remainingPackages === 0/) assert.doesNotMatch(dispatch, /partialReason|reason \}/) assert.match(dispatch, /this\.pickingCompleted && this\.allPicked && this\.current\?\.canDepart === true && !this\.departed/) @@ -153,6 +154,27 @@ test('pages expose loading empty error offline and permission diagnostics with g assert.doesNotMatch(dispatch + outbound, /DeliveryOrder|deliveryStatus|customerVisit|\/deliveries/) }) +test('dispatch vehicle-plate conflict uses actionable Chinese copy and preserves diagnostics', () => { + const error = toDispatchError({ + code: 'A0409', + message: 'Vehicle plate is required before PDA push or picking', + requestId: 'faccb2ee-5e05-4904-9bef-bc983638b47b', + traceId: '43ca73abd84fab3e2992aa50e48fb48f' + }, '提交排车拣货失败') + assert.deepEqual(error, { + message: '请先在平台维护车牌信息后再拣货', + code: 'A0409', + requestId: 'faccb2ee-5e05-4904-9bef-bc983638b47b', + traceId: '43ca73abd84fab3e2992aa50e48fb48f', + network: false + }) + + const unknown = toDispatchError({ code: 'A0409', message: 'Another dispatch conflict', requestId: 'request-2' }, '失败') + assert.equal(unknown.message, 'Another dispatch conflict') + assert.equal(unknown.requestId, 'request-2') + assert.equal(toDispatchError({ code: 'A0403', message: 'Forbidden' }).message, '当前账号没有海外仓 PDA 作业权限,请联系管理员授权') +}) + test('receiving conflict recovery keeps request and trace evidence visible on narrow screens', async () => { const operation = await read('../src/pages/operation/operation.vue') assert.match(operation, /openContainer\(candidate\)[\s\S]*recoverCurrentAfterConflict\(error, true\)/) diff --git a/tests/receiving-adapter.test.mjs b/tests/receiving-adapter.test.mjs index 54d4f9d..c0e830a 100644 --- a/tests/receiving-adapter.test.mjs +++ b/tests/receiving-adapter.test.mjs @@ -1,6 +1,6 @@ import test from 'node:test' import assert from 'node:assert/strict' -import { isNoCurrentReceiving, isReceivingConflict, normalizeReceivingCandidate, normalizeReceivingSession, RECEIVING_PHOTO_ROLES } from '../src/api/receiving-adapter.js' +import { isNoCurrentReceiving, isReceivingConflict, normalizeReceivingCandidate, normalizeReceivingSession, RECEIVING_PHOTO_ROLES, toReceivingError } from '../src/api/receiving-adapter.js' test('receiving adapter preserves the three signed photo roles in workflow order', () => { const snowflakeFileId = '2092476334483931138' @@ -27,3 +27,29 @@ test('receiving adapter exposes calculable candidate facts and workflow errors', assert.equal(isNoCurrentReceiving({ code: 404 }), true) assert.equal(isReceivingConflict({ code: 'B0409' }), true) }) + +test('unusable warehouse pallet error is actionable Chinese and retains diagnostics', () => { + const mapped = toReceivingError({ + code: 'S0503', + message: 'Warehouse pallet service returned no usable response', + requestId: '4dd3028c-9112-429d-a9d2-4f71233c5c64', + traceId: 'c01eeddb77afc5c018828c3cb8ccc1ec' + }, '校验托盘失败') + assert.equal(mapped.message, '该托盘当前不可用于收货,请更换可用托盘或联系管理员检查托盘状态') + assert.equal(mapped.code, 'S0503') + assert.equal(mapped.requestId, '4dd3028c-9112-429d-a9d2-4f71233c5c64') + assert.equal(mapped.traceId, 'c01eeddb77afc5c018828c3cb8ccc1ec') + + const occupied = toReceivingError({ + code: 'A0409', + message: 'Pallet is not idle', + requestId: 'occupied-request', + traceId: 'occupied-trace' + }) + assert.equal(occupied.message, '该托盘已被占用,当前不可用于收货,请更换可用托盘') + assert.equal(occupied.requestId, 'occupied-request') + assert.equal(occupied.traceId, 'occupied-trace') + + const unknown = toReceivingError({ code: 'S0503', message: 'Another warehouse failure' }, '校验托盘失败') + assert.equal(unknown.message, 'Another warehouse failure') +})