Author SHA1 Message Date
admin aabfcfbeb1 fix(pda): 修复海外作业兼容性并完善扫码与装车收尾 2026-11-06 21:05:55 +08:00
ouyangxq 31f2ba0c85 fix(pda): 修复海外作业兼容性并完善扫码与装车收尾 2026-11-06 21:02:54 +08:00
admin fd6c1511ce release: 合并最新 TEST 并发布阿里云灰度
按本轮授权合并经过验证的发布候选,保留 main 已有修复。
2026-11-06 14:07:59 +08:00
14 changed files with 227 additions and 30 deletions
+2
View File
@@ -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),
+3 -2
View File
@@ -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)
}
}
}
+2 -1
View File
@@ -1,7 +1,7 @@
<template>
<view class="receiving-summary">
<view class="receiving-summary-head">
<view class="receiving-summary-identity"><text class="receiving-summary-label">当前柜号</text><text class="receiving-summary-container">{{ session?.containerNo || '—' }}</text></view>
<view class="receiving-summary-identity"><text class="receiving-summary-label">当前柜号</text><text class="receiving-summary-container">{{ session?.containerNo || '—' }}</text><text class="receiving-summary-cabinet">柜次号 {{ session?.cabinetNo || '—' }}</text></view>
<text class="receiving-summary-status">{{ statusLabel }}</text>
</view>
<view class="receiving-summary-context">
@@ -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}
+10 -3
View File
@@ -1,7 +1,7 @@
<template>
<view
class="scan-target-input"
:class="{ 'scan-target-input-active': active }"
:class="{ 'scan-target-input-active': active, 'scan-target-input-large': large }"
@tap="$emit('activate', name)"
>
<view class="scan-target-icon" aria-hidden="true">
@@ -21,7 +21,7 @@
@input="handleInput"
@confirm="handleConfirm"
/>
<text v-if="active" class="scan-target-badge">当前扫码</text>
<text v-if="active || large" class="scan-target-badge">{{ active ? '当前扫码' : '点按选中' }}</text>
</view>
</template>
@@ -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; }
</style>
+2 -2
View File
@@ -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,
+10 -13
View File
@@ -27,7 +27,7 @@
</view>
</template>
<template v-if="current">
<view v-show="activeTab === 'photos'" id="dispatch-photos-panel" role="tabpanel" aria-labelledby="dispatch-photos-tab"><DispatchLoadingDocuments v-if="canShowPhotos" :key="current.dispatchNo" :dispatch-no="current.dispatchNo" @busy-change="photoBusy = $event" @saved="openTask(current.dispatchNo)" /></view>
<view v-show="activeTab === 'photos'" id="dispatch-photos-panel" role="tabpanel" aria-labelledby="dispatch-photos-tab"><DispatchLoadingDocuments v-if="canShowPhotos" :key="current.dispatchNo" :dispatch-no="current.dispatchNo" @busy-change="photoBusy = $event" @saved="onLoadingDocumentsSaved" /></view>
<view v-show="activeTab === 'picking'" id="dispatch-picking-panel" class="picking-workspace" role="tabpanel" aria-labelledby="dispatch-picking-tab">
<DispatchHandoverStatus v-if="departed" :dispatch-no="current.dispatchNo" :record-version="current.recordVersion" />
<view class="fulfillment-card summary-card">
@@ -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 }
}
+7 -6
View File
@@ -21,7 +21,7 @@
<view v-else-if="!candidateError && !candidates.length" class="empty-card"><uni-icons type="info" color="#8090a3" size="34" /><text>暂无待卸货集装箱</text><button class="secondary-button" :disabled="candidateLoading" @tap="loadCandidates">重新加载</button></view>
<view v-for="item in candidates" :key="candidateKey(item)" class="container-card container-card-compact" :class="{ 'container-card-disabled': !item.operable }" role="button" :aria-label="`${item.containerNo},${item.operable ? '查看可操作柜详情' : '查看未齐全柜摘要'}`" hover-class="container-card-pressed" @tap="openContainer(item)">
<view class="card-head container-card-head">
<view class="container-identity"><text class="container-no">{{ item.containerNo }}</text><text v-if="item.operable || item.plannedArrivalAt" class="candidate-arrival">计划到仓:{{ formatPlannedArrival(item.plannedArrivalAt) }}</text></view>
<view class="container-identity"><text class="container-no">{{ item.containerNo }}</text><text class="cabinet-no">柜次号 {{ item.cabinetNo || '—' }}</text><text v-if="item.operable || item.plannedArrivalAt" class="candidate-arrival">计划到仓:{{ formatPlannedArrival(item.plannedArrivalAt) }}</text></view>
<text class="status-tag" :class="item.operable ? statusTone(item.status) : 'status-disabled'">{{ item.operable ? statusLabel(item.status) : '待齐全' }}</text>
</view>
<view v-if="item.operable" class="candidate-facts"><view><strong>{{ item.totalOrderCount }}</strong><text>总订单</text></view><view><strong>{{ item.inboundCodeCount }}</strong><text>入仓码</text></view><view><strong>{{ item.planCount }}</strong><text>已生成计划</text></view><view><strong>{{ item.pendingPlanCount }}</strong><text>未操作</text></view></view>
@@ -42,7 +42,7 @@
</template>
<template v-else>
<view class="readonly-candidate-card">
<view class="card-head container-card-head"><view class="container-identity"><text class="container-no">{{ selected?.containerNo }}</text><text class="candidate-arrival">计划到仓:{{ formatPlannedArrival(selected?.plannedArrivalAt) }}</text></view><text class="status-tag status-disabled">暂不可操作</text></view>
<view class="card-head container-card-head"><view class="container-identity"><text class="container-no">{{ selected?.containerNo }}</text><text class="cabinet-no">柜次号 {{ selected?.cabinetNo || '—' }}</text><text class="candidate-arrival">计划到仓:{{ formatPlannedArrival(selected?.plannedArrivalAt) }}</text></view><text class="status-tag status-disabled">暂不可操作</text></view>
<view class="candidate-facts"><view><strong>{{ selected?.totalOrderCount }}</strong><text>总订单</text></view><view><strong>{{ selected?.inboundCodeCount }}</strong><text>入仓码</text></view><view><strong>{{ selected?.planCount }}</strong><text>已生成计划</text></view><view><strong>{{ selected?.pendingPlanCount }}</strong><text>未操作</text></view></view>
<text class="candidate-progress-copy">已生成 {{ selected?.planCount }} 个入库计划,{{ selected?.pendingPlanCount }} 个未操作<template v-if="selected?.missingInboundCodeCount">,其中 {{ selected.missingInboundCodeCount }} 个缺入仓码</template></text>
<text class="candidate-block-reason">{{ selected?.blockReason || '当前柜入库计划尚未齐全' }}</text>
@@ -98,7 +98,7 @@
</view>
<view class="scan-card">
<text class="section-label">1. 货物码</text>
<ScanTargetInput name="carton" v-model="cartonInput" label="货物码" placeholder="扫描箱码或一件代发DP码" :active="activeTarget === 'carton'" @activate="activeTarget = 'carton'" @submit="resolveCarton" />
<ScanTargetInput name="carton" v-model="cartonInput" label="货物码" placeholder="扫描箱码或一件代发DP码" large :active="activeTarget === 'carton'" @activate="activeTarget = 'carton'" @submit="resolveCarton" />
<button class="secondary-button" :disabled="busy || !cartonInput.trim()" @tap="resolveCarton(cartonInput)">解析货物码</button>
<template v-if="activeInbound">
@@ -111,7 +111,7 @@
</view>
<text class="section-label">2. 托盘码</text>
<ScanTargetInput name="pallet" v-model="palletInput" label="托盘码" placeholder="扫描收货托盘码" :active="activeTarget === 'pallet'" @activate="activeTarget = 'pallet'" @submit="resolvePallet" />
<ScanTargetInput name="pallet" v-model="palletInput" label="托盘码" placeholder="扫描收货托盘码" large :active="activeTarget === 'pallet'" @activate="activeTarget = 'pallet'" @submit="resolvePallet" />
<button class="secondary-button" :disabled="busy || !palletInput.trim()" @tap="resolvePallet(palletInput)">校验托盘</button>
<view v-if="resolvedPallet && palletInput.trim() === resolvedPallet.palletCode" class="resolved-card"><text>{{ resolvedPallet.palletCode }}</text><text>{{ resolvedPallet.compatible ? '预留托盘校验通过,可收货' : '托盘不匹配' }}</text><text v-if="resolvedPallet.locationCode">库位:{{ resolvedPallet.locationCode }}{{ resolvedPallet.locationType === 'SHARED_OVERFLOW' ? '(A0共享溢出位)' : '' }}</text><text v-else>库位:待现场确认</text></view>
<template v-if="activeInbound.destinationType === 'FBA'">
@@ -134,7 +134,7 @@
</template>
<template v-else-if="phase === 'completed'">
<view class="completed-card"><view class="completed-icon">✓</view><text class="completed-title">{{ current?.status === 'COMPLETED_WITH_DIFFERENCE' ? '现场收货已结束,差异待核平' : '当前柜已完成入库' }}</text><text>{{ current?.containerNo }}</text><text>实收 {{ current?.receivedPackages }} / {{ current?.totalPackages }} 箱</text><button class="primary-button" @tap="finishAndReturn">返回待卸货列表</button></view>
<view class="completed-card"><view class="completed-icon">✓</view><text class="completed-title">{{ current?.status === 'COMPLETED_WITH_DIFFERENCE' ? '现场收货已结束,差异待核平' : '当前柜已完成入库' }}</text><text>{{ current?.containerNo }}</text><text class="cabinet-no">柜次号 {{ current?.cabinetNo || '—' }}</text><text>实收 {{ current?.receivedPackages }} / {{ current?.totalPackages }} 箱</text><button class="primary-button" @tap="finishAndReturn">返回待卸货列表</button></view>
</template>
<view v-if="error" class="error-card">
@@ -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 @@
</script>
<style scoped>
.cabinet-no{display:block;max-width:100%;margin-top:3px;color:#526174;font-size:12px;line-height:18px;overflow-wrap:anywhere;word-break:break-all}
.reservation-hint{display:block;margin:6px 0;color:#64748b;font-size:12px;line-height:18px;overflow-wrap:anywhere}
.card-head.container-card-head{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:start;gap:10px}
.container-identity{min-width:0;max-width:100%}
+31
View File
@@ -0,0 +1,31 @@
function zonedTime(instant, timeZone) {
// #ifdef APP-PLUS
if (typeof plus !== 'undefined' && typeof plus.android?.invoke === 'function') {
// Use Android's timezone database, including historical daylight-saving rules.
const zone = plus.android.invoke('java.util.TimeZone', 'getTimeZone', timeZone)
const offset = plus.android.invoke(zone, 'getOffset', instant)
if (typeof offset !== 'number' || !Number.isFinite(offset)) throw new Error('Timezone offset unavailable')
return new Date(instant + offset).toISOString().slice(0, 16).replace('T', ' ')
}
// #endif
if (typeof Intl === 'undefined' || typeof Intl.DateTimeFormat !== 'function') throw new Error('Timezone formatter unavailable')
const formatter = new Intl.DateTimeFormat('en-GB', {
timeZone, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hourCycle: 'h23',
})
const parts = Object.fromEntries(formatter.formatToParts(instant).map(part => [part.type, part.value]))
return `${parts.year}-${parts.month}-${parts.day} ${parts.hour}:${parts.minute}`
}
export function formatDispatchReviewTime(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 '时间无效'
try {
return [['美东', 'America/New_York'], ['美西', 'America/Los_Angeles']]
.map(([label, timeZone]) => `${label} ${zonedTime(instant, timeZone)}`).join('\n')
} catch (_) {
// Keep the supplied instant visible if the runtime cannot convert timezones.
return `UTC ${new Date(instant).toISOString().slice(0, 16).replace('T', ' ')}`
}
}
+10
View File
@@ -25,6 +25,16 @@ test('all registered user-facing pages avoid rendering internal identifiers', as
]) assert.doesNotMatch(visibleExpressions, new RegExp(field.replace('.', '\\.')))
})
test('receiving lists, summaries and completion display the formal cabinet number', async () => {
const [operation, summary] = await Promise.all([
read('../src/pages/operation/operation.vue'), read('../src/components/ReceivingSessionSummary.vue')
])
for (const binding of ['item.cabinetNo', 'selected?.cabinetNo', 'current?.cabinetNo']) {
assert.ok(operation.includes(`柜次号 {{ ${binding} || '—' }}`))
}
assert.ok(summary.includes("柜次号 {{ session?.cabinetNo || '—' }}"))
})
test('operator and warehouse cards never fall back to internal IDs', async () => {
const [home, profile, session] = await Promise.all([
read('../src/pages/home/home.vue'),
+15
View File
@@ -30,6 +30,21 @@ test('five existing slots carry their business names into upload, replace and pr
assert.match(template, /:aria-label="`查看\$\{slotLabels\[slot\]\}大图`"/)
assert.doesNotMatch(template, /装车照片 \{\{ index \+ 1 \}\}/)
})
test('saving the final loading photo reports the confirmed complete snapshot after releasing busy state', async () => {
const completed = data({ recordVersion: '1', complete: true, stage: 'DELIVERING', editable: false,
photos: loadingPhotoSlots.map((slot, index) => ({ ...photo, slot, fileId: String(1001 + index) })) })
let uploads = 0
const { state, events } = component({ uploadLoadingPhoto: async () => { uploads++; return photo }, bindLoadingPhoto: async () => completed })
uni.chooseImage = async () => ({ tempFilePaths: ['final-photo', 'extra-photo'] })
await state.load()
await state.choose(photo.slot)
const savedIndex = events.findIndex(event => event[0] === 'saved')
assert.ok(savedIndex > 0)
assert.deepEqual(events[savedIndex - 1], ['busy-change', false])
assert.deepEqual(events[savedIndex], ['saved', completed])
assert.equal(uploads, 1)
})
test('refresh reads all photos saved by PC and refreshes the task stage without another write', async () => {
let latest = data({ photos: [photo] }), writes = 0, fail = false
const { state, events } = component({ getLoadingDocuments: async () => { if (fail) throw new Error('刷新失败'); return latest },
+99 -3
View File
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import { fulfillment, restoreFulfillmentDraft, updateFulfillmentDraft, resetFulfillmentSession } from '../src/stores/fulfillment.js'
import { requireDispatchVersion, dispatchVersionAtLeast, normalizeDispatchTask } from '../src/api/fulfillment.js'
import { formatDispatchReviewTime } from '../src/utils/dispatch-time.js'
const source = (await readFile(new URL('../src/pages/dispatch/dispatch.vue', import.meta.url), 'utf8')).match(/<script>([\s\S]*?)<\/script>/)[1]
const item = (overrides = {}) => ({ dispatchItemId: 'item-1', sourceKey: 'source-1', sourceBusinessType: 'RELABEL_ORDER', sourceBusinessNo: 'RL-1', customerId: 'customer-1', customerName: '客户甲', palletNo: 'PALLET-1', scheduledPackages: 2, pickedPackages: 0, positions: [{ positionId: 'position-1', availablePackages: 2, allocatedPackages: 0, pickedPackages: 0 }], returnCartons: [], ...overrides })
test('native string quantities normalize without changing Long identity or version', () => {
@@ -25,7 +26,7 @@ const task = (overrides = {}) => normalizeDispatchTask({ taskNo: 'TASK-1', dispa
function storage() { const values = new Map(); globalThis.uni = { setStorageSync: (key, value) => values.set(key, structuredClone(value)), getStorageSync: key => structuredClone(values.get(key)), removeStorageSync: key => values.delete(key), showModal: async () => ({ confirm: true }) }; resetFulfillmentSession(); restoreFulfillmentDraft('operator:warehouse'); return values }
let lastCommand
function page(dependencies = {}) {
const deps = { PortalHeader: {}, ScanTargetInput: {}, DispatchHandoverStatus: {}, DispatchLoadingDocuments: {}, session: { currentUser: {} }, fulfillment, requireDispatchVersion, dispatchVersionAtLeast, updateFulfillmentDraft, playSuccessSound() {}, playErrorSound() {}, toDispatchError: error => ({ message: error.message }), ...dependencies }
const deps = { PortalHeader: {}, ScanTargetInput: {}, DispatchHandoverStatus: {}, DispatchLoadingDocuments: {}, session: { currentUser: {} }, fulfillment, requireDispatchVersion, dispatchVersionAtLeast, updateFulfillmentDraft, formatDispatchReviewTime, playSuccessSound() {}, playErrorSound() {}, toDispatchError: error => ({ message: error.message }), ...dependencies }
for (const [api, action] of [['pickDispatchItem', 'PICK'], ['completeDispatchLoading', 'LOADING_COMPLETE'], ['reviewDispatchLoading', 'LOADING_REVIEW']]) {
const original = deps[api]; if (!original) continue
deps[api] = (...args) => { lastCommand = { dispatchNo: args[0], action, body: args.at(-1) }; return original(...args) }
@@ -296,7 +297,10 @@ test('missing appointment blocks approval while preserving rejection to customer
await state.submitReview('REJECTED'); assert.equal(sent.length, 1); assert.equal(sent[0][1].decision, 'REJECTED')
})
for (const [instant, expected] of [
const reviewTimeExamples = [
['2006-03-20T15:00:00Z', '美东 2006-03-20 10:00\n美西 2006-03-20 07:00'],
['2040-07-01T15:00:00Z', '美东 2040-07-01 11:00\n美西 2040-07-01 08:00'],
['2026-01-01T05:00:00Z', '美东 2026-01-01 00:00\n美西 2025-12-31 21:00'],
['2026-11-06T00:00:00+08:00', '美东 2026-11-05 11:00\n美西 2026-11-05 08:00'],
['2026-11-05T16:00:00Z', '美东 2026-11-05 11:00\n美西 2026-11-05 08:00'],
['2026-07-01T15:00:00Z', '美东 2026-07-01 11:00\n美西 2026-07-01 08:00'],
@@ -306,10 +310,64 @@ for (const [instant, expected] of [
['2026-11-01T05:30:00Z', '美东 2026-11-01 01:30\n美西 2026-10-31 22:30'],
['2026-11-01T07:30:00Z', '美东 2026-11-01 02:30\n美西 2026-11-01 00:30'],
['2026-11-01T10:30:00Z', '美东 2026-11-01 05:30\n美西 2026-11-01 02:30'],
]) test(`review times use eastern and pacific daylight-saving rules for ${instant}`, () => {
]
for (const [instant, expected] of reviewTimeExamples) test(`review times use eastern and pacific daylight-saving rules for ${instant}`, () => {
assert.equal(page().reviewTime(instant), expected)
})
test('review times render without Intl using Android timezone offsets across daylight-saving changes', t => {
const intlDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'Intl')
const plusDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'plus')
const DateTimeFormat = Intl.DateTimeFormat
t.after(() => {
Object.defineProperty(globalThis, 'Intl', intlDescriptor)
if (plusDescriptor) Object.defineProperty(globalThis, 'plus', plusDescriptor)
else delete globalThis.plus
})
delete globalThis.Intl
const calls = []
globalThis.plus = { android: { invoke(target, method, argument) {
calls.push({ target, method, argument })
if (method === 'getTimeZone') {
assert.equal(target, 'java.util.TimeZone')
return { timeZone: argument }
}
assert.equal(method, 'getOffset')
const parts = new DateTimeFormat('en-GB', { timeZone: target.timeZone, timeZoneName: 'longOffset' }).formatToParts(argument)
const offset = parts.find(part => part.type === 'timeZoneName').value.match(/^GMT([+-])(\d{2}):(\d{2})$/)
return (offset[1] === '-' ? -1 : 1) * (Number(offset[2]) * 60 + Number(offset[3])) * 60000
} } }
const state = page()
state.current = reviewTask()
assert.equal(state.canReview, true)
for (const [instant, expected] of reviewTimeExamples) assert.equal(state.reviewTime(instant), expected)
assert.equal(calls.filter(call => call.method === 'getOffset').length, reviewTimeExamples.length * 2)
assert.equal(calls.find(call => call.method === 'getOffset').argument, Date.parse(reviewTimeExamples[0][0]))
})
test('review times show explicit UTC when Intl and the Android timezone bridge are unavailable', t => {
const intlDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'Intl')
const plusDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'plus')
t.after(() => {
Object.defineProperty(globalThis, 'Intl', intlDescriptor)
if (plusDescriptor) Object.defineProperty(globalThis, 'plus', plusDescriptor)
else delete globalThis.plus
})
delete globalThis.Intl
delete globalThis.plus
const state = page()
assert.equal(state.reviewTime('2026-11-06T00:00:00+08:00'), 'UTC 2026-11-05 16:00')
globalThis.plus = { android: { invoke() { return undefined } } }
assert.equal(state.reviewTime('2026-07-01T15:00:00Z'), 'UTC 2026-07-01 15:00')
globalThis.plus.android.invoke = () => { throw new Error('Native bridge unavailable') }
assert.equal(state.reviewTime('2026-07-01T15:00:00Z'), 'UTC 2026-07-01 15:00')
delete globalThis.plus
globalThis.Intl = { DateTimeFormat: function () { return {} } }
assert.equal(state.reviewTime('2026-07-01T15:00:00Z'), 'UTC 2026-07-01 15:00')
assert.equal(state.reviewTime(null), '未填写')
assert.equal(state.reviewTime('2026-11-06T00:00:00'), '时间无效')
})
test('review times preserve missing values and reject invalid or timezone-free timestamps', () => {
const state = page()
for (const missing of [null, undefined, '']) assert.equal(state.reviewTime(missing), '未填写')
@@ -381,6 +439,44 @@ test('photo tab preserves the selected pallet and quantity and ignores picking b
state.selectTab('picking'); assert.equal(state.selectedItem.palletNo, 'PALLET-1'); assert.equal(state.packages, 1)
})
test('saving a complete set of loading photos returns to the refreshed task list', async () => {
storage(); let reads = 0
const rows = [task({ dispatchNo: 'PC/NEXT' })]
const state = page({ listDispatchTasks: async () => { reads++; return rows } })
state.selectTab('photos')
await state.onLoadingDocumentsSaved({ dispatchNo: 'PC/1', complete: true })
assert.equal(state.current, null)
assert.equal(state.activeTab, 'picking')
assert.equal(fulfillment.dispatch.currentNo, '')
assert.deepEqual(state.tasks, rows)
assert.equal(reads, 1)
})
test('partial photo saves stay on photos and callbacks from another task are ignored', async () => {
storage(); let reads = 0
const state = page({ getDispatchTask: async no => { reads++; return task({ dispatchNo: no, recordVersion: 2 }) } })
state.selectTab('photos')
await state.onLoadingDocumentsSaved({ dispatchNo: 'PC/OLD', complete: true })
assert.equal(reads, 0)
assert.equal(state.current.dispatchNo, 'PC/1')
await state.onLoadingDocumentsSaved({ dispatchNo: 'PC/1', complete: false })
assert.equal(reads, 1)
assert.equal(state.activeTab, 'photos')
assert.equal(state.current.dispatchNo, 'PC/1')
})
test('a failed task-list refresh after completing photos stays on the list with a retry', async () => {
storage(); let reads = 0
const state = page({ listDispatchTasks: async () => { if (++reads === 1) throw new Error('列表刷新失败'); return [] } })
await state.onLoadingDocumentsSaved({ dispatchNo: 'PC/1', complete: true })
assert.equal(state.current, null)
assert.equal(state.error.message, '列表刷新失败')
assert.equal(state.loading, false)
await state.retry()
assert.equal(state.error, null)
assert.equal(reads, 2)
})
test('same-task photo refresh stays on photos while a new task starts on picking', async () => {
storage(); const state = page({ getDispatchTask: async no => task({ dispatchNo: no, recordVersion: 2 }) })
state.selectTab('photos'); await state.openTask('PC/1'); assert.equal(state.activeTab, 'photos')
+2
View File
@@ -25,6 +25,7 @@ const inbounds = Array.from({ length: 15 }, (_, index) => ({
test('one container candidate aggregates fifteen independent one-inbound plans', () => {
const candidate = normalizeReceivingCandidate({
sourceContainerBookingId: '2093166483653849089', containerNo: 'C10000000', status: 'WAIT_SIGN',
cabinetNo: ' GC202611060001 ',
overseasWarehouseCode: 'US-LAX-RCV', overseasWarehouseName: '洛杉矶接柜仓',
plannedArrivalAt: '2026-08-29T09:30:00', totalOrderCount: 15, planCount: 15, inboundCodeCount: 15,
missingInboundCodeCount: 0, plannedInboundCodeCount: 15, pendingPlanCount: 0, allPlansReady: true, operable: true, blockReason: '',
@@ -33,6 +34,7 @@ test('one container candidate aggregates fifteen independent one-inbound plans',
})
assert.deepEqual(candidate, {
sourceContainerBookingId: '2093166483653849089', containerNo: 'C10000000', status: 'WAIT_SIGN',
cabinetNo: 'GC202611060001',
overseasWarehouseCode: 'US-LAX-RCV', overseasWarehouseName: '洛杉矶接柜仓',
plannedArrivalAt: '2026-08-29T09:30:00', totalOrderCount: 15, planCount: 15, inboundCodeCount: 15,
missingInboundCodeCount: 0, plannedInboundCodeCount: 15, pendingPlanCount: 0, allPlansReady: true, operable: true, blockReason: '',
+9
View File
@@ -104,6 +104,15 @@ test('domestic loading photos remain cabinet-level read-only references without
assert.equal('fileId' in session.domesticLoadingPhotos[0], false)
})
test('receiving cabinet numbers use only the formal business field and normalize missing values', () => {
for (const normalize of [normalizeReceivingCandidate, normalizeReceivingSession]) {
assert.equal(normalize({ cabinetNo: ' GC202611060001 ' }).cabinetNo, 'GC202611060001')
for (const cabinetNo of [undefined, null, 2093166483653849089, ' ']) {
assert.equal(normalize({ cabinetNo, sourceContainerBookingId: '2093166483653849089', containerNo: 'C10000000' }).cabinetNo, '')
}
}
})
test('receiving adapter exposes calculable candidate facts and workflow errors', () => {
const candidate = normalizeReceivingCandidate({ containerNo: ' C10000000 ', status: 'WAIT_SIGN', planCount: 15, inboundCodeCount: 15, destinationCodes: ['ONT8', 'LAX'], plannedPalletCount: 4, currentAccount: true })
assert.equal(candidate.containerNo, 'C10000000')
+25
View File
@@ -25,6 +25,31 @@ function page(overrides = {}) {
return { vm, definition, deps }
}
test('carton resolution keeps the selected broadcast target until the operator changes it', async t => {
store.resetReceivingSession()
store.setCurrentReceiving(sessionValue())
const { vm } = page({ resolveCurrentCarton: async cartonMark => ({ cartonMark, inboundCode: 'IN-A', destinationType: 'FBA' }) })
t.after(() => vm.suspendReceiving())
vm.activeTarget = 'carton'
store.receiving.resolvedPallet = { palletCode: 'OLD-PALLET', compatible: true, version: 1 }
await vm.resolveCarton('IN-A#1')
assert.equal(vm.activeTarget, 'carton')
assert.equal(vm.resolvedPallet, null)
assert.equal(store.receiving.resolvedCarton.cartonMark, 'IN-A#1')
vm.activeTarget = 'pallet'
await vm.resolveCarton('IN-A#2')
assert.equal(vm.activeTarget, 'pallet')
})
test('receiving broadcast targets use larger tap areas and show an inactive selection hint', () => {
const operation = readFileSync(new URL('../src/pages/operation/operation.vue', import.meta.url), 'utf8')
const input = readFileSync(new URL('../src/components/ScanTargetInput.vue', import.meta.url), 'utf8')
for (const name of ['carton', 'pallet']) assert.match(operation, new RegExp(`<ScanTargetInput name="${name}"[^>]* large `))
assert.match(input, /@tap="\$emit\('activate', name\)"/)
assert.match(input, /scan-target-input-large \{ height: 64px/)
assert.match(input, /active \? '当前扫码' : '点按选中'/)
})
test('camera resume retains all unsigned uploads by role and clears them across session or identity changes', async () => {
store.resetReceivingSession()
const unsigned = sessionValue({ status: 'WAIT_SIGN', photos: [], domesticLoadingPhotos: [] })