From 190aa37b00ff733a68fac2f3220355e191641d61 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 18 Sep 2026 16:48:44 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/inbound.js | 5 +++-- src/manifest.json | 8 +++++--- src/pages/operation/operation.vue | 11 +++++++---- tests/multi-plan-receiving.test.mjs | 4 ++-- tests/receiving-adapter.test.mjs | 4 ++++ 5 files changed, 21 insertions(+), 11 deletions(-) diff --git a/src/api/inbound.js b/src/api/inbound.js index 1d22e16..1a62731 100644 --- a/src/api/inbound.js +++ b/src/api/inbound.js @@ -14,8 +14,9 @@ export const getCurrentWarehouseProfile = () => { } return warehouseProfileRequest } -export const getReceivingContainer = async (containerNo) => normalizeReceivingSession(await overseasRequest('GET', `${RECEIVING_BASE}/${encodeURIComponent(containerNo)}`)) -export const prepareReceivingContainer = async (containerNo, request) => normalizeReceivingSession(await overseasRequest('POST', `${RECEIVING_BASE}/${encodeURIComponent(containerNo)}/prepare`, request)) +const receivingIdentityQuery = (sourceContainerBookingId) => `sourceContainerBookingId=${encodeURIComponent(sourceContainerBookingId)}` +export const getReceivingContainer = async (containerNo, sourceContainerBookingId) => normalizeReceivingSession(await overseasRequest('GET', `${RECEIVING_BASE}/${encodeURIComponent(containerNo)}?${receivingIdentityQuery(sourceContainerBookingId)}`)) +export const prepareReceivingContainer = async (containerNo, sourceContainerBookingId, request) => normalizeReceivingSession(await overseasRequest('POST', `${RECEIVING_BASE}/${encodeURIComponent(containerNo)}/prepare?${receivingIdentityQuery(sourceContainerBookingId)}`, request)) export const uploadCurrentUnloadPhoto = (filePath) => overseasUpload(current('/unload-photos/files'), filePath) export const downloadCurrentUnloadPhoto = (downloadPath) => overseasDownload(downloadPath, { expectedContentTypePrefix: 'image/' }) export const downloadCurrentDomesticLoadingPhoto = (downloadPath) => overseasDownload(downloadPath, { expectedContentTypePrefix: 'image/' }) diff --git a/src/manifest.json b/src/manifest.json index 1c7949d..6153bb4 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -2,8 +2,8 @@ "name" : "柏悦物流 PDA 海外版", "appid" : "__UNI__936D079", "description" : "柏悦物流海外仓 PDA 作业端", - "versionName" : "0.0.2", - "versionCode" : 2, + "versionName" : "0.0.3", + "versionCode" : 3, "transformPx" : false, "app-plus" : { "usingComponents" : true, @@ -83,5 +83,7 @@ "devServer" : { "port" : 5176 } - } + }, + "locale" : "zh-Hans", + "fallbackLocale" : "zh-Hans" } diff --git a/src/pages/operation/operation.vue b/src/pages/operation/operation.vue index 7d76efc..52de268 100644 --- a/src/pages/operation/operation.vue +++ b/src/pages/operation/operation.vue @@ -13,7 +13,7 @@ {{ candidateError.message || '待卸货集装箱加载失败' }}code={{ candidateError.code }}requestId={{ candidateError.requestId }}traceId={{ candidateError.traceId }} 正在刷新待卸货集装箱… 暂无待卸货集装箱 - + {{ item.containerNo }}计划到仓:{{ formatPlannedArrival(item.plannedArrivalAt) }}{{ item.operable ? statusLabel(item.status) : '待齐全' }}{{ item.operable ? (item.currentAccount ? '继续' : '详情') : '只读' }} › {{ item.totalOrderCount }}总订单{{ item.inboundCodeCount }}入仓码{{ item.planCount }}已生成计划{{ item.pendingPlanCount }}未操作 已生成 {{ item.planCount }} 个入库计划,{{ item.pendingPlanCount }} 个未操作 @@ -144,6 +144,8 @@ import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js' import { completeCurrentReceiving, downloadCurrentDomesticLoadingPhoto, downloadCurrentUnloadPhoto, getCurrentReceiving, getCurrentWarehouseProfile, getReceivingContainer, listReceivingCandidates, prepareReceivingContainer, receiveCurrentBulk, receiveCurrentCarton, receiveCurrentWholePallet, resolveCurrentCarton, resolveCurrentPallet, signCurrentReceiving, uploadCurrentUnloadPhoto } from '@/api/inbound.js' const MAX_UNLOAD_PHOTO_BYTES = 10 * 1024 * 1024 + const MAX_PACKAGES_PER_PALLET = 66 + const PALLET_CAPACITY_MESSAGE = '一个托盘最多只能收66箱,请拆分到其他托盘' export default { components: { PortalHeader, ReceivingSessionSummary, ScanTargetInput }, @@ -168,6 +170,7 @@ onShow() { if (!requireSession()) return; this.user = session.currentUser; this.startScanner(); if (this.inboundMode && (!this.bootstrapped || this.phase === 'candidates')) this.bootstrap() }, onHide() { this.stopScanner() }, onUnload() { this.stopScanner() }, beforeUnmount() { clearTimeout(this._toastTimer); this.stopScanner() }, methods: { + candidateKey(item) { return `${item?.sourceContainerBookingId || ''}:${item?.containerNo || ''}` }, statusLabel(status) { return ({ WAIT_SIGN: '待签收', SIGNED: '已签收', RECEIVING: '收货中', COMPLETED: '已完成', COMPLETED_WITH_DIFFERENCE: '差异待核平' })[status] || status }, statusTone(status) { return status === 'RECEIVING' ? 'status-current' : status === 'COMPLETED' ? 'status-good' : 'status-wait' }, formatPlannedArrival(value) { const text = String(value || '').trim(); return text ? text.replace('T', ' ').slice(0, 16) : '待确认' }, @@ -190,9 +193,9 @@ async bootstrap() { if (this.loading) return; this.loading = true; this.error = null; this.detailOpen = false; try { await this.refreshBoundWarehouse(); try { const current = await getCurrentReceiving(); await this.applyCurrentReceiving(current); this.candidateError = null } catch (error) { if (!isNoCurrentReceiving(error)) throw error; resetReceivingDraft(); await this.loadCandidates(false, false) } this.bootstrapped = true } catch (error) { this.candidateError = error } finally { this.loading = false } }, async recoverCurrentAfterConflict(error, preserveEvidence = false) { if (!isReceivingConflict(error)) return false; try { const current = await getCurrentReceiving(); await this.applyCurrentReceiving(current); this.detailOpen = false; this.error = preserveEvidence ? error : null; this.showToast(`账号已有未完成柜 ${current.containerNo},已返回当前柜`, 'warning'); return true } catch (recoveryError) { if (isNoCurrentReceiving(recoveryError)) { this.detailOpen = false; receiving.selected = null; await this.loadCandidates(false); this.candidateError = preserveEvidence ? error : null; this.error = preserveEvidence ? error : null; this.showToast('柜状态已变化,列表已刷新', 'warning'); return true } this.error = error; return false } }, async loadCandidates(feedback = true, refreshWarehouse = true) { if (this.candidateLoading) return; this.candidateLoading = true; this.candidateError = null; this.error = null; try { if (refreshWarehouse) await this.refreshBoundWarehouse(); const values = await listReceivingCandidates(); receiving.candidates = values.map((item) => this.validateReceivingWarehouse(item, `柜号 ${item.containerNo || '—'}`)); if (feedback) this.showToast('刷新待卸柜成功', 'success') } catch (error) { receiving.candidates = []; this.candidateError = error; if (feedback) { playErrorSound(); this.showToast(error.message || '刷新待卸柜失败', 'error') } } finally { this.candidateLoading = false } }, - async openContainer(candidate) { const containerNo = String(candidate?.containerNo || '').trim(); if (!containerNo || this.busy) return; this.error = null; try { this.validateReceivingWarehouse(candidate, `柜号 ${containerNo}`); if (!candidate.operable) { receiving.selected = candidate; this.detailOpen = true; return } const detail = await this.run('查询柜号', async () => this.validateReceivingWarehouse(await getReceivingContainer(containerNo), `柜号 ${containerNo}`)); receiving.selected = { ...candidate, ...detail, allPlansReady: candidate.allPlansReady, operable: candidate.operable, blockReason: candidate.blockReason }; this.detailOpen = true; playSuccessSound() } catch (error) { if (!await this.recoverCurrentAfterConflict(error, true)) { this.error = error; this.showToast(error.message || '查询柜号失败', 'error') } } }, + async openContainer(candidate) { const containerNo = String(candidate?.containerNo || '').trim(); const sourceContainerBookingId = String(candidate?.sourceContainerBookingId || '').trim(); if (!containerNo || !sourceContainerBookingId || this.busy) return; this.error = null; try { this.validateReceivingWarehouse(candidate, `柜号 ${containerNo}`); if (!candidate.operable) { receiving.selected = candidate; this.detailOpen = true; return } const detail = await this.run('查询柜号', async () => this.validateReceivingWarehouse(await getReceivingContainer(containerNo, sourceContainerBookingId), `柜号 ${containerNo}`)); receiving.selected = { ...candidate, ...detail, sourceContainerBookingId, allPlansReady: candidate.allPlansReady, operable: candidate.operable, blockReason: candidate.blockReason }; this.detailOpen = true; playSuccessSound() } catch (error) { if (!await this.recoverCurrentAfterConflict(error, true)) { this.error = error; this.showToast(error.message || '查询柜号失败', 'error') } } }, cancelDetail() { this.detailOpen = false; receiving.selected = null }, - async prepareContainer() { if (!this.selected?.containerNo || !this.selected?.allPlansReady || !this.selected?.operable || this.busy) return; const key = idempotencyKey('prepare', { containerNo: this.selected.containerNo }); try { this.validateReceivingWarehouse(this.selected, `柜号 ${this.selected.containerNo}`); const value = await this.run('选择当前柜', async () => this.validateReceivingWarehouse(await prepareReceivingContainer(this.selected.containerNo, { idempotencyKey: key.value }), '新建接柜会话')); releaseIdempotencyKey(key.signature); await this.applyCurrentReceiving(value); this.detailOpen = false } catch (error) { if (!await this.recoverCurrentAfterConflict(error, true)) this.error = error } }, + async prepareContainer() { if (!this.selected?.containerNo || !this.selected?.sourceContainerBookingId || !this.selected?.allPlansReady || !this.selected?.operable || this.busy) return; const key = idempotencyKey('prepare', { sourceContainerBookingId: this.selected.sourceContainerBookingId, containerNo: this.selected.containerNo }); try { this.validateReceivingWarehouse(this.selected, `柜号 ${this.selected.containerNo}`); const value = await this.run('选择当前柜', async () => this.validateReceivingWarehouse(await prepareReceivingContainer(this.selected.containerNo, this.selected.sourceContainerBookingId, { idempotencyKey: key.value }), '新建接柜会话')); releaseIdempotencyKey(key.signature); await this.applyCurrentReceiving(value); this.detailOpen = false } catch (error) { if (!await this.recoverCurrentAfterConflict(error, true)) this.error = error } }, choosePhoto(index) { if (this.busy) return; uni.chooseImage({ count: 1, sourceType: ['camera'], success: ({ tempFilePaths, tempFiles }) => { const path = tempFilePaths?.[0]; const size = Number(tempFiles?.[0]?.size || 0); if (size > MAX_UNLOAD_PHOTO_BYTES) { const message = '照片不能超过10MB'; this.error = Object.assign(new Error(message), { code: 'A0400' }); this.showToast(message, 'error'); playErrorSound(); return } if (path) this.replacePhoto(index, path) } }) }, async replacePhoto(index, path) { const previous = receiving.photoSlots[index]; receiving.photoSlots.splice(index, 1, { ...(previous || {}), role: this.photoSteps[index].role, status: 'uploading', errorMessage: '' }); this.busy = true; this.error = null; try { const file = await uploadCurrentUnloadPhoto(path); receiving.photoSlots.splice(index, 1, { ...file, fileId: String(file.fileId), role: this.photoSteps[index].role, localPath: path, uploaded: true, status: 'success' }); this.showToast(`${this.photoSteps[index].label}上传成功`, 'success') } catch (error) { receiving.photoSlots.splice(index, 1, previous?.fileId ? { ...previous, status: 'error', errorMessage: error.message || '重拍失败,已保留原图' } : { role: this.photoSteps[index].role, localPath: path, uploaded: false, status: 'error', errorMessage: error.message || '上传失败,请重试' }); this.error = error; this.showToast(error.message || '照片上传失败', 'error'); playErrorSound() } finally { this.busy = false } }, previewPhoto(index) { const path = this.photoSlots[index]?.localPath; if (!path) return; uni.previewImage({ urls: [path], current: path }) }, @@ -200,7 +203,7 @@ clearReceiptContext() { receiving.resolvedCarton = null; receiving.resolvedPallet = null; this.cartonInput = ''; this.palletInput = ''; this.packageCount = ''; receiving.activeScanTarget = 'carton'; setTimeout(() => this.drainScanQueue(), 0) }, async resolveCarton(raw) { const cartonMark = String(raw?.detail?.value ?? raw ?? this.cartonInput).trim(); if (!cartonMark) return; if (this.busy) return this.handleHardwareScan(cartonMark); try { const outcome = await this.run('处理货物码', async () => { const value = await resolveCurrentCarton(cartonMark); if (value.operationMode !== 'DROPSHIP_PALLET') return { value }; const payload = { sourcePalletNo: value.sourcePalletNo || cartonMark }; const key = idempotencyKey('whole-pallet-receipt', { containerNo: this.current?.containerNo, ...payload }); const result = await receiveCurrentWholePallet({ ...payload, idempotencyKey: key.value }); const current = this.validateReceivingWarehouse(await getCurrentReceiving(), '当前接柜会话'); releaseIdempotencyKey(key.signature); return { value, result, current } }); if (outcome.result) { receiving.lastReceipt = outcome.result; await this.applyCurrentReceiving(outcome.current); this.clearReceiptContext(); playSuccessSound(); return } receiving.resolvedCarton = outcome.value; receiving.resolvedPallet = null; this.cartonInput = outcome.value.cartonMark; this.palletInput = ''; this.packageCount = ''; receiving.activeScanTarget = 'pallet'; playSuccessSound() } catch (error) { console.error('[Overseas PDA cargo] failed', { cartonMark, message: error.message, code: error.code, requestId: error.requestId, traceId: error.traceId }) } }, async resolvePallet(raw) { const palletCode = String(raw?.detail?.value ?? raw ?? this.palletInput).trim(); if (!palletCode || !this.activeInbound || this.busy) return; try { const value = await this.run('校验托盘', () => resolveCurrentPallet(palletCode, this.activeInbound.inboundCode)); receiving.resolvedPallet = value; this.palletInput = value.palletCode; this.packageCount = Number(value.currentReceivedPackages || 0) || ''; if (!value.compatible) throw Object.assign(new Error('托盘不匹配'), { code: 'PALLET_INCOMPATIBLE' }); receiving.activeScanTarget = this.activeInbound.destinationType === 'FBA' ? 'quantity' : 'carton'; playSuccessSound() } catch (error) { if (!this.error) this.error = error } }, - async submitReceipt() { if (!this.canSubmitReceipt) return; const isFba = this.activeInbound.destinationType === 'FBA'; const payload = isFba ? { inboundCode: this.activeInbound.inboundCode, palletCode: this.resolvedPallet.palletCode, packageCount: Number(this.packageCount), expectedPalletVersion: this.resolvedPallet.version } : { cartonMark: this.activeInbound.cartonMark, palletCode: this.resolvedPallet.palletCode }; const key = idempotencyKey(isFba ? 'bulk-receipt' : 'carton-receipt', { containerNo: this.current?.containerNo, groupKey: this.activeInbound.groupKey, ...payload }); try { const result = await this.run('提交实收', async () => { const receipt = isFba ? await receiveCurrentBulk({ ...payload, idempotencyKey: key.value }) : await receiveCurrentCarton({ ...payload, idempotencyKey: key.value }); const current = this.validateReceivingWarehouse(await getCurrentReceiving(), '当前接柜会话'); return { receipt, current } }); releaseIdempotencyKey(key.signature); receiving.lastReceipt = result.receipt; await this.applyCurrentReceiving(result.current); this.clearReceiptContext(); playSuccessSound() } catch (_) {} }, + async submitReceipt() { const isFba = this.activeInbound?.destinationType === 'FBA'; if (isFba && Number(this.packageCount) > MAX_PACKAGES_PER_PALLET) { this.error = Object.assign(new Error(PALLET_CAPACITY_MESSAGE), { code: 'A0409' }); playErrorSound(); this.showToast(PALLET_CAPACITY_MESSAGE, 'error'); return } if (!this.canSubmitReceipt) return; const payload = isFba ? { inboundCode: this.activeInbound.inboundCode, palletCode: this.resolvedPallet.palletCode, packageCount: Number(this.packageCount), expectedPalletVersion: this.resolvedPallet.version } : { cartonMark: this.activeInbound.cartonMark, palletCode: this.resolvedPallet.palletCode }; const key = idempotencyKey(isFba ? 'bulk-receipt' : 'carton-receipt', { containerNo: this.current?.containerNo, groupKey: this.activeInbound.groupKey, ...payload }); try { const result = await this.run('提交实收', async () => { const receipt = isFba ? await receiveCurrentBulk({ ...payload, idempotencyKey: key.value }) : await receiveCurrentCarton({ ...payload, idempotencyKey: key.value }); const current = this.validateReceivingWarehouse(await getCurrentReceiving(), '当前接柜会话'); return { receipt, current } }); releaseIdempotencyKey(key.signature); receiving.lastReceipt = result.receipt; await this.applyCurrentReceiving(result.current); this.clearReceiptContext(); playSuccessSound() } catch (_) {} }, async completeContainer() { if (!this.canFinishOnsite || this.activeTarget === 'quantity' || this.scanQueue.length) return; const payload = { allowDifference: !this.allInboundsReconciled }; const key = idempotencyKey('complete', { containerNo: this.current.containerNo, ...payload }); try { const value = await this.run(this.allInboundsReconciled ? '完成入库' : '结束现场收货', async () => this.validateReceivingWarehouse(await completeCurrentReceiving({ ...payload, idempotencyKey: key.value }), '当前接柜会话')); releaseIdempotencyKey(key.signature); await this.applyCurrentReceiving(value) } catch (_) {} }, async finishAndReturn() { resetReceivingDraft(); await this.loadCandidates(false) }, showToast(message, type) { clearTimeout(this._toastTimer); this.toast = { visible: true, message, type }; this._toastTimer = setTimeout(() => { this.toast.visible = false }, 2600) } diff --git a/tests/multi-plan-receiving.test.mjs b/tests/multi-plan-receiving.test.mjs index e562a7e..4171a31 100644 --- a/tests/multi-plan-receiving.test.mjs +++ b/tests/multi-plan-receiving.test.mjs @@ -116,7 +116,7 @@ test('container UI locks and signs once, then renders the resolved destination g assert.match(inboundApi, /\$\{RECEIVING_BASE\}\/\$\{encodeURIComponent\(containerNo\)\}\/prepare/) assert.match(inboundApi, /current\('\/sign'\)/) - assert.match(operation, /prepareReceivingContainer\(this\.selected\.containerNo, \{ idempotencyKey: key\.value \}\)/) + assert.match(operation, /prepareReceivingContainer\(this\.selected\.containerNo, this\.selected\.sourceContainerBookingId, \{ idempotencyKey: key\.value \}\)/) assert.match(operation, /getCurrentReceiving\(\)[\s\S]*applyCurrentReceiving\(current\)/) assert.match(operation, /applyWarehouseProfile\(await getCurrentWarehouseProfile\(\)\)/) assert.match(operation, /values\.map\(\(item\) => this\.validateReceivingWarehouse/) @@ -139,7 +139,7 @@ test('PDA does not infer container eligibility from partial plan workflow states assert.match(adapter, /allPlansReady: value\.allPlansReady === true/) assert.match(adapter, /operable: value\.operable === true/) assert.match(operation, /if \(!candidate\.operable\) \{ receiving\.selected = candidate; this\.detailOpen = true; return \}/) - assert.match(operation, /if \(!this\.selected\?\.containerNo \|\| !this\.selected\?\.allPlansReady \|\| !this\.selected\?\.operable/) + assert.match(operation, /if \(!this\.selected\?\.containerNo \|\| !this\.selected\?\.sourceContainerBookingId \|\| !this\.selected\?\.allPlansReady \|\| !this\.selected\?\.operable/) assert.match(operation, /recoverCurrentAfterConflict\(error, true\)/) assert.match(operation, /preserveEvidence \? error : null[\s\S]*this\.candidateError = preserveEvidence \? error : null/) assert.doesNotMatch(inbound, /sort\(/) diff --git a/tests/receiving-adapter.test.mjs b/tests/receiving-adapter.test.mjs index 51bdfae..339b3cf 100644 --- a/tests/receiving-adapter.test.mjs +++ b/tests/receiving-adapter.test.mjs @@ -49,6 +49,10 @@ test('first scan and receipt requests confirm server reservations without resour assert.ok(gate) assert.doesNotMatch(gate, /mergeAllowed|assigned|urgent/) assert.match(gate, /resolvedPallet\?\.compatible/) + assert.match(page, /const MAX_PACKAGES_PER_PALLET = 66/) + assert.match(page, /Number\(this\.packageCount\) > MAX_PACKAGES_PER_PALLET/) + assert.match(page, /一个托盘最多只能收66箱,请拆分到其他托盘/) + assert.match(page, /this\.showToast\(PALLET_CAPACITY_MESSAGE, 'error'\)/) }) test('one-click dropship inbound is marked only by an explicit server boolean', () => {