From 31f2ba0c850f3455ef943dc574987fb6fa68aabc Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Fri, 6 Nov 2026 21:02:54 +0800
Subject: [PATCH] =?UTF-8?q?fix(pda):=20=E4=BF=AE=E5=A4=8D=E6=B5=B7?=
=?UTF-8?q?=E5=A4=96=E4=BD=9C=E4=B8=9A=E5=85=BC=E5=AE=B9=E6=80=A7=E5=B9=B6?=
=?UTF-8?q?=E5=AE=8C=E5=96=84=E6=89=AB=E7=A0=81=E4=B8=8E=E8=A3=85=E8=BD=A6?=
=?UTF-8?q?=E6=94=B6=E5=B0=BE?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/api/receiving-adapter.js | 2 +
src/components/DispatchLoadingDocuments.vue | 5 +-
src/components/ReceivingSessionSummary.vue | 3 +-
src/components/ScanTargetInput.vue | 13 ++-
src/manifest.json | 4 +-
src/pages/dispatch/dispatch.vue | 23 ++---
src/pages/operation/operation.vue | 13 +--
src/utils/dispatch-time.js | 31 ++++++
tests/business-identifiers-ui.test.mjs | 10 ++
tests/dispatch-loading-documents.test.mjs | 15 +++
tests/dispatch-workflow.test.mjs | 102 +++++++++++++++++++-
tests/multi-plan-receiving.test.mjs | 2 +
tests/receiving-adapter.test.mjs | 9 ++
tests/receiving-lifecycle.test.mjs | 25 +++++
14 files changed, 227 insertions(+), 30 deletions(-)
create mode 100644 src/utils/dispatch-time.js
diff --git a/src/api/receiving-adapter.js b/src/api/receiving-adapter.js
index e7e0925..eda9108 100644
--- a/src/api/receiving-adapter.js
+++ b/src/api/receiving-adapter.js
@@ -62,6 +62,7 @@ export function normalizeReceivingCandidate(value = {}) {
...value,
sourceContainerBookingId: typeof value.sourceContainerBookingId === 'string' ? value.sourceContainerBookingId.trim() : '',
containerNo: String(value.containerNo || '').trim(),
+ cabinetNo: text(value.cabinetNo),
overseasWarehouseCode: text(value.overseasWarehouseCode),
overseasWarehouseName: text(value.overseasWarehouseName),
plannedArrivalAt: typeof value.plannedArrivalAt === 'string' && value.plannedArrivalAt.trim() ? value.plannedArrivalAt.trim() : null,
@@ -95,6 +96,7 @@ export function normalizeReceivingSession(value) {
...value,
overseasWarehouseCode: text(value.overseasWarehouseCode),
overseasWarehouseName: text(value.overseasWarehouseName),
+ cabinetNo: text(value.cabinetNo),
photos: RECEIVING_PHOTO_ROLES.map((role) => photos.find((photo) => photo?.role === role && photo?.fileId)).filter(Boolean),
domesticLoadingPhotos,
totalPackages: number(value.totalPackages),
diff --git a/src/components/DispatchLoadingDocuments.vue b/src/components/DispatchLoadingDocuments.vue
index 1160a5c..9c641ce 100644
--- a/src/components/DispatchLoadingDocuments.vue
+++ b/src/components/DispatchLoadingDocuments.vue
@@ -91,10 +91,11 @@ export default {
throw cause
}
saved++; await this.previews()
+ if (!this.editable) break
}
uni.showToast({ title: this.snapshot.stage === 'DELIVERING' && this.snapshot.complete ? '资料齐全,已进入派送中' : `已保存${saved}张照片`, icon: 'none' })
})
- if ((saved || reconciled) && this.active) this.$emit('saved')
+ if ((saved || reconciled) && this.active) this.$emit('saved', this.snapshot)
},
preview(url) { uni.previewImage({ current: url, urls: [url] }) },
async viewPdf(photo) {
@@ -117,7 +118,7 @@ export default {
try { this.ensureScope(); saved = await work() === true }
catch (cause) { if (this.active && !String(cause.errMsg || '').includes('cancel')) { this.error = toDispatchError(cause, '装车资料操作失败,请重新操作') } }
finally { if (this.active) { this.busy = false; this.$emit('busy-change', false) } }
- if (saved && notify && this.active) this.$emit('saved')
+ if (saved && notify && this.active) this.$emit('saved', this.snapshot)
}
}
}
diff --git a/src/components/ReceivingSessionSummary.vue b/src/components/ReceivingSessionSummary.vue
index 23f5d88..24cea55 100644
--- a/src/components/ReceivingSessionSummary.vue
+++ b/src/components/ReceivingSessionSummary.vue
@@ -1,7 +1,7 @@
- 当前柜号{{ session?.containerNo || '—' }}
+ 当前柜号{{ session?.containerNo || '—' }}柜次号 {{ session?.cabinetNo || '—' }}
{{ statusLabel }}
@@ -49,6 +49,7 @@
.receiving-summary-head{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:start;gap:10px}
.receiving-summary-progress>view:first-child{display:flex;align-items:center;justify-content:space-between;gap:10px}
.receiving-summary-identity{min-width:0;max-width:100%}
+ .receiving-summary-cabinet{display:block;margin-top:3px;color:#526174;font-size:12px;line-height:18px;overflow-wrap:anywhere;word-break:break-all}
.receiving-summary-container{width:100%;min-width:0;max-width:100%;white-space:normal;overflow-wrap:anywhere;word-break:break-all}
.receiving-summary-label,.receiving-summary-container,.metric-value,.metric-label{display:block}.receiving-summary-label{color:#718095;font-size:9px;line-height:13px}.receiving-summary-container{margin-top:1px;color:#17324d;font-size:17px;font-weight:850;line-height:22px}
.receiving-summary-status{flex-shrink:0;padding:4px 8px;border-radius:99px;color:#1668dc;font-size:9px;font-weight:700;background:#e3efff}
diff --git a/src/components/ScanTargetInput.vue b/src/components/ScanTargetInput.vue
index cc72228..a288086 100644
--- a/src/components/ScanTargetInput.vue
+++ b/src/components/ScanTargetInput.vue
@@ -1,7 +1,7 @@
@@ -21,7 +21,7 @@
@input="handleInput"
@confirm="handleConfirm"
/>
- 当前扫码
+ {{ active ? '当前扫码' : '点按选中' }}
@@ -33,7 +33,8 @@
modelValue: { type: String, default: '' },
label: { type: String, default: '扫码输入框' },
placeholder: { type: String, default: '扫描或人工输入' },
- active: Boolean
+ active: Boolean,
+ large: Boolean
},
methods: {
handleInput(event) { this.$emit('update:modelValue', event.detail.value) },
@@ -55,4 +56,10 @@
.scan-target-line { position: absolute; left: 4px; top: 10px; width: 14px; height: 2px; background: currentColor; }
.scan-target-value { box-sizing: border-box; width: 100%; height: 46px; padding: 0 82px 0 42px; border: 0; color: #27384d; font-size: 15px; background: transparent; }
.scan-target-badge { position: absolute; right: 8px; top: 12px; padding: 3px 6px; border-radius: 4px; color: #135ee8; font-size: 10px; font-weight: 700; line-height: 16px; pointer-events: none; background: #e7f0ff; }
+ .scan-target-input-large { height: 64px; }
+ .scan-target-input-large .scan-target-value { height: 62px; padding-right: 108px; padding-left: 48px; font-size: 16px; }
+ .scan-target-input-large.scan-target-input-active .scan-target-value { height: 60px; }
+ .scan-target-input-large .scan-target-icon { left: 15px; top: 20px; }
+ .scan-target-input-large.scan-target-input-active .scan-target-icon { left: 14px; top: 19px; }
+ .scan-target-input-large .scan-target-badge { right: 10px; top: 18px; padding: 4px 8px; font-size: 12px; line-height: 20px; }
diff --git a/src/manifest.json b/src/manifest.json
index 3577175..2b5afe2 100644
--- a/src/manifest.json
+++ b/src/manifest.json
@@ -2,8 +2,8 @@
"name" : "柏悦物流 PDA 海外版",
"appid" : "__UNI__936D079",
"description" : "柏悦物流海外仓 PDA 作业端",
- "versionName" : "0.1.0",
- "versionCode" : 10,
+ "versionName" : "0.1.1",
+ "versionCode" : 11,
"transformPx" : false,
"app-plus" : {
"usingComponents" : true,
diff --git a/src/pages/dispatch/dispatch.vue b/src/pages/dispatch/dispatch.vue
index f6aa225..136c138 100644
--- a/src/pages/dispatch/dispatch.vue
+++ b/src/pages/dispatch/dispatch.vue
@@ -27,7 +27,7 @@
-
+
@@ -103,6 +103,7 @@ import { fulfillment, restoreFulfillmentDraft, updateFulfillmentDraft } from '@/
import { requireSession, session } from '@/stores/session.js'
import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js'
import { toDispatchError } from '@/utils/dispatch-error.js'
+import { formatDispatchReviewTime } from '@/utils/dispatch-time.js'
import { startScanBroadcast } from '@/utils/scan-broadcast.js'
export default {
components: { PortalHeader, ScanTargetInput, DispatchHandoverStatus, DispatchLoadingDocuments },
@@ -138,7 +139,13 @@ export default {
updatePackages(event) { const value = Number(event.detail.value); updateFulfillmentDraft('dispatch', { packages: Number.isFinite(value) ? value : 0 }) },
async loadTasks() { const generation = ++this.generation; await this.run(async () => { const result = await listDispatchTasks(); if (generation === this.generation) { this.tasks = result; this.current = null } }, true) },
async openTask(no) { const generation = ++this.generation; await this.run(async () => { const value = await getDispatchTask(no); if (generation !== this.generation) return; if (value.dispatchNo !== no) throw new Error('返回排车与扫描业务号不一致'); const same = fulfillment.dispatch.currentNo === no; if (this.current?.dispatchNo !== no || this.current?.recordVersion !== value.recordVersion) this.reviewReason = ''; if (this.current?.dispatchNo !== no || !['LOADING', 'AFTER_LOADING', 'DELIVERING', 'DELIVERED'].includes(value.stage)) this.activeTab = 'picking'; this.current = value; this.sourceChoices = []; this.choiceCartonCode = ''; updateFulfillmentDraft('dispatch', { currentNo: no, palletCode: same ? fulfillment.dispatch.palletCode : '', dispatchItemId: same ? fulfillment.dispatch.dispatchItemId : '', sourceKey: same ? fulfillment.dispatch.sourceKey : '', cartonCode: same ? fulfillment.dispatch.cartonCode : '', packages: same ? fulfillment.dispatch.packages : 1 }); this.scanCode = '' }, true) },
- closeTask() { if (this.busy) return; this.current = null; this.activeTab = 'picking'; this.reviewReason = ''; this.loadingPreview = null; this.scanCode = ''; this.error = null; this.successMessage = ''; updateFulfillmentDraft('dispatch', { currentNo: '', palletCode: '', dispatchItemId: '', sourceKey: '', cartonCode: '', packages: 1 }); this.loadTasks() },
+ closeTask() { if (this.busy) return; this.current = null; this.activeTab = 'picking'; this.reviewReason = ''; this.loadingPreview = null; this.scanCode = ''; this.error = null; this.successMessage = ''; updateFulfillmentDraft('dispatch', { currentNo: '', palletCode: '', dispatchItemId: '', sourceKey: '', cartonCode: '', packages: 1 }); return this.loadTasks() },
+ async onLoadingDocumentsSaved(snapshot) {
+ const no = this.current?.dispatchNo
+ if (!no || snapshot?.dispatchNo !== no) return
+ if (snapshot.complete === true) { await this.closeTask(); return }
+ await this.openTask(no)
+ },
handleScan(raw) {
const code = String(raw?.detail?.value ?? raw ?? this.scanCode).trim()
if (!code || this.busy || this.loading || this.loadingPreview || this.activeTab !== 'picking') return
@@ -255,17 +262,7 @@ export default {
async run(work, loading = false) { if (this.busy || this.loading) return; this.clearScanNotice(); this.error = null; this.successMessage = ''; if (loading) this.loading = true; else this.requestBusy = true; try { await work() } catch (cause) { this.setError(cause, '排车操作失败') } finally { this.loading = false; this.requestBusy = false } },
setError(cause, fallback) { this.clearScanNotice(); this.error = toDispatchError(cause, fallback); playErrorSound(); if (typeof uni !== 'undefined') uni.showToast?.({ title: this.error.message || fallback, icon: 'none', duration: 3000 }) },
stageLabel(task) { if (task.status === 'CANCELLED') return '已取消'; if (task.stage === 'BEFORE_LOADING') { if (task.actions?.LOADING_REVIEW?.allowed === true) return '待理货审核'; if ([task.reviews?.dispatchReview, task.reviews?.loadingReview].some(review => review?.status === 'REJECTED')) return '已退回客服处理' }; return ({ BEFORE_LOADING: '装车前', LOADING: '装车中', AFTER_LOADING: '装车后', DELIVERING: '派送中', DELIVERED: '派送完成' })[task.stage] || '状态待核实' },
- reviewTime(value) {
- if (!value) return '未填写'
- const instant = typeof value === 'string' && /(?:Z|[+-]\d{2}:\d{2})$/i.test(value) ? Date.parse(value) : NaN
- if (!Number.isFinite(instant)) return '时间无效'
- return [['美东', 'America/New_York'], ['美西', 'America/Los_Angeles']].map(([label, timeZone]) => {
- const parts = Object.fromEntries(new Intl.DateTimeFormat('en-GB', {
- timeZone, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hourCycle: 'h23',
- }).formatToParts(instant).map(part => [part.type, part.value]))
- return `${label} ${parts.year}-${parts.month}-${parts.day} ${parts.hour}:${parts.minute}`
- }).join('\n')
- },
+ reviewTime(value) { return formatDispatchReviewTime(value) },
reviewLabel(review) { return ({ NOT_SUBMITTED: '未提交', PENDING: '待审核', APPROVED: '已通过', REJECTED: '已驳回' })[review?.status] || '待核对' },
routeLabel(item) { return [item.destination?.warehouseCode, item.destination?.warehouseName, item.destination?.address].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 e791f3c..a4de9f1 100644
--- a/src/pages/operation/operation.vue
+++ b/src/pages/operation/operation.vue
@@ -21,7 +21,7 @@
暂无待卸货集装箱
- {{ item.containerNo }}计划到仓:{{ formatPlannedArrival(item.plannedArrivalAt) }}
+ {{ item.containerNo }}柜次号 {{ item.cabinetNo || '—' }}计划到仓:{{ formatPlannedArrival(item.plannedArrivalAt) }}
{{ item.operable ? statusLabel(item.status) : '待齐全' }}
{{ item.totalOrderCount }}总订单{{ item.inboundCodeCount }}入仓码{{ item.planCount }}已生成计划{{ item.pendingPlanCount }}未操作
@@ -42,7 +42,7 @@
- {{ selected?.containerNo }}计划到仓:{{ formatPlannedArrival(selected?.plannedArrivalAt) }}暂不可操作
+ {{ selected?.containerNo }}柜次号 {{ selected?.cabinetNo || '—' }}计划到仓:{{ formatPlannedArrival(selected?.plannedArrivalAt) }}暂不可操作
{{ selected?.totalOrderCount }}总订单{{ selected?.inboundCodeCount }}入仓码{{ selected?.planCount }}已生成计划{{ selected?.pendingPlanCount }}未操作
已生成 {{ selected?.planCount }} 个入库计划,{{ selected?.pendingPlanCount }} 个未操作,其中 {{ selected.missingInboundCodeCount }} 个缺入仓码
{{ selected?.blockReason || '当前柜入库计划尚未齐全' }}
@@ -98,7 +98,7 @@
1. 货物码
-
+
@@ -111,7 +111,7 @@
2. 托盘码
-
+
{{ resolvedPallet.palletCode }}{{ resolvedPallet.compatible ? '预留托盘校验通过,可收货' : '托盘不匹配' }}库位:{{ resolvedPallet.locationCode }}{{ resolvedPallet.locationType === 'SHARED_OVERFLOW' ? '(A0共享溢出位)' : '' }}库位:待现场确认
@@ -134,7 +134,7 @@
- ✓{{ current?.status === 'COMPLETED_WITH_DIFFERENCE' ? '现场收货已结束,差异待核平' : '当前柜已完成入库' }}{{ current?.containerNo }}实收 {{ current?.receivedPackages }} / {{ current?.totalPackages }} 箱
+ ✓{{ current?.status === 'COMPLETED_WITH_DIFFERENCE' ? '现场收货已结束,差异待核平' : '当前柜已完成入库' }}{{ current?.containerNo }}柜次号 {{ current?.cabinetNo || '—' }}实收 {{ current?.receivedPackages }} / {{ current?.totalPackages }} 箱
@@ -265,7 +265,7 @@
async previewPhoto(index) { const photo = this.photoSlots[index]; if (!photo) return; try { const path = await restoreReceivingPhoto(photo, downloadCurrentUnloadPhoto); if (path) uni.previewImage({ urls: [path], current: path }) } catch (error) { this.error = error } },
async signContainer() { if (!this.photosReady) return; const photos = this.photoSlots.map((item, index) => ({ role: this.photoSteps[index].role, fileId: String(item.fileId) })); const key = idempotencyKey('sign', { containerNo: this.current.containerNo, photos }); try { const value = await this.run('签收当前柜', async () => this.validateReceivingWarehouse(await signCurrentReceiving({ photos, idempotencyKey: key.value }), '当前接柜会话')); releaseIdempotencyKey(key.signature); await this.applyCurrentReceiving(value); this.clearReceiptContext() } catch (_) {} },
clearReceiptContext() { receiving.resolvedCarton = null; receiving.resolvedPallet = null; this.cartonInput = ''; this.palletInput = ''; this.packageCount = ''; receiving.activeScanTarget = 'carton'; this.scheduleScanDrain() },
- async resolveCarton(raw) { const cartonMark = String(raw?.detail?.value ?? raw ?? this.cartonInput).trim(); if (!cartonMark || !this._pageActive) return; const scanEpoch = this._scanEpoch || 0; 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 }; if (!this._pageActive || scanEpoch !== (this._scanEpoch || 0)) throw new Error('页面已离开,请返回后重新扫码确认收货'); 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 resolveCarton(raw) { const cartonMark = String(raw?.detail?.value ?? raw ?? this.cartonInput).trim(); if (!cartonMark || !this._pageActive) return; const scanEpoch = this._scanEpoch || 0; 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 }; if (!this._pageActive || scanEpoch !== (this._scanEpoch || 0)) throw new Error('页面已离开,请返回后重新扫码确认收货'); 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 = ''; 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
@@ -297,6 +297,7 @@