fix(pda): simplify dispatch review and loading confirmation

This commit is contained in:
2026-10-26 15:51:10 +08:00
parent 2dced722b1
commit 59e81516a4
4 changed files with 35 additions and 26 deletions
+2 -4
View File
@@ -1,19 +1,17 @@
<template>
<view class="loading-documents">
<view class="heading">
<view class="heading-main"><text class="heading-title">装车照片</text><text class="heading-subtitle">每类 1 张,上传后自动保存,可更换</text></view>
<view class="heading-main"><text class="heading-title">装车照片</text></view>
<text class="heading-progress">{{ busy ? '同步中' : snapshot ? `${snapshot.photos.length}/5 已保存` : '待同步' }}</text>
<button class="heading-refresh" :disabled="busy" @tap="load">刷新</button>
</view>
<text v-if="snapshot?.stage === 'LOADING'" class="section-hint">可边装车边上传;确认装车完成且照片齐全后自动进入派送中。</text>
<text v-if="snapshot?.stage === 'AFTER_LOADING'" class="section-hint">上传并保存 5 张装车照片后,本车自动进入派送中。</text>
<view v-if="error" class="error" role="alert"><text>{{ error.message }}</text><text v-if="error.requestId">requestId:{{ error.requestId }}</text><text v-if="error.traceId">traceId:{{ error.traceId }}</text></view>
<view class="photo-list">
<view v-for="(slot, index) in slots" :key="slot" class="photo-card" :class="{ filled: snapshot?.photos.some(item => item.slot === slot) }">
<view class="photo-card-header">
<view class="photo-card-main">
<text class="photo-number">{{ snapshot?.photos.some(item => item.slot === slot) ? '✓' : index + 1 }}</text>
<view class="photo-card-title"><text class="photo-label">{{ slotLabels[slot] }}</text><text class="photo-status">{{ snapshot?.photos.some(item => item.slot === slot) ? '已保存 1 张' : '待上传' }}</text></view>
<view class="photo-card-title"><text class="photo-label">{{ slotLabels[slot] }}</text><text v-if="snapshot?.photos.some(item => item.slot === slot)" class="photo-status">已保存 1 张</text></view>
</view>
<button v-if="editable" class="photo-upload" :aria-label="`${snapshot?.photos.some(item => item.slot === slot) ? '更换' : '上传'}${slotLabels[slot]}`" :disabled="busy" @tap="choose(slot)">{{ snapshot?.photos.some(item => item.slot === slot) ? '更换图片' : '+ 上传图片' }}</button>
</view>
+26 -17
View File
@@ -36,20 +36,25 @@
</view>
<view v-if="current.stage === 'BEFORE_LOADING'" class="fulfillment-card review-card">
<text class="card-title">理货员审核</text>
<text class="task-meta">车牌号:{{ current.resources?.vehicle?.plate || '—' }} · 托车公司:{{ current.resources?.vehicle?.carrierName || '—' }}</text>
<text class="task-meta">司机:{{ current.resources?.driver?.name || '—' }} · 电话:{{ current.resources?.driver?.phone || '—' }}</text>
<text class="task-meta">计划发车(北京时间):{{ reviewTime(current.plannedDepartureAt) }}</text>
<text class="task-meta">预计送达(北京时间):{{ reviewTime(current.estimatedArrivalAt) }}</text>
<text v-for="appointment in current.fbaAppointments || []" :key="appointment.destinationKey" class="task-meta">{{ appointment.destinationWarehouseCode }} · {{ appointment.appointmentNo || '—' }} · {{ appointment.status === 'SUCCESS' ? '已确认' : appointment.status === 'REGISTERED' ? '已登记' : '未填写' }}:{{ reviewTime(appointment.confirmedAt) }}(北京时间)</text>
<text class="task-meta">驾驶证号:{{ current.resources?.driver?.evidence?.licenseNo || '—' }} · 有效期:{{ current.resources?.driver?.evidence?.licenseExpiresOn || '—' }}</text>
<text class="task-meta">调度审核:{{ reviewLabel(current.reviews?.dispatchReview) }} · {{ current.reviews?.dispatchReview?.reviewedAt || '—' }}</text>
<text v-if="current.reviews?.dispatchReview?.reason" class="task-meta">调度意见:{{ current.reviews.dispatchReview.reason }}</text>
<view class="review-facts">
<view class="review-fact"><text class="review-label">车牌号</text><text>{{ current.resources?.vehicle?.plate || '—' }}</text></view>
<view class="review-fact"><text class="review-label">托车公司</text><text>{{ current.resources?.vehicle?.carrierName || '—' }}</text></view>
<view class="review-fact"><text class="review-label">司机</text><text>{{ current.resources?.driver?.name || '—' }}</text></view>
<view class="review-fact"><text class="review-label">电话</text><text>{{ current.resources?.driver?.phone || '—' }}</text></view>
<view class="review-fact"><text class="review-label">计划发车</text><text>{{ reviewTime(current.plannedDepartureAt) }}(北京时间)</text></view>
<view class="review-fact"><text class="review-label">预计送达</text><text>{{ reviewTime(current.estimatedArrivalAt) }}(北京时间)</text></view>
<view v-for="appointment in current.fbaAppointments || []" :key="appointment.destinationKey" class="review-fact"><text class="review-label">{{ appointment.destinationWarehouseCode }}</text><view class="review-appointment"><text>{{ appointment.appointmentNo || '—' }}</text><text>{{ appointment.status === 'SUCCESS' ? '已确认' : appointment.status === 'REGISTERED' ? '已登记' : '未填写' }}:{{ reviewTime(appointment.confirmedAt) }}(北京时间)</text></view></view>
<view class="review-fact"><text class="review-label">驾驶证号</text><text>{{ current.resources?.driver?.evidence?.licenseNo || '—' }}</text></view>
<view class="review-fact"><text class="review-label">有效期</text><text>{{ current.resources?.driver?.evidence?.licenseExpiresOn || '—' }}</text></view>
<view class="review-fact"><text class="review-label">调度审核</text><text>{{ reviewLabel(current.reviews?.dispatchReview) }} · {{ reviewTime(current.reviews?.dispatchReview?.reviewedAt) }}</text></view>
<view v-if="current.reviews?.dispatchReview?.reason" class="review-fact"><text class="review-label">调度意见</text><text>{{ current.reviews.dispatchReview.reason }}</text></view>
</view>
<text v-if="rejectionReason" class="gate-warning">已退回客服处理(车辆信息):{{ rejectionReason }}。修改后须重新提交审核。</text>
<template v-if="canReview">
<text v-if="current.actions.LOADING_REVIEW?.blockCode === 'APPROVAL_BLOCKED'" class="gate-warning">{{ current.actions.LOADING_REVIEW.blockReason }}</text>
<text class="task-meta">请核对车辆、司机及下方货物;通过后才能开始真实拣货。驳回将回到客服处理(车辆信息)。</text>
<textarea :value="reviewReason" maxlength="1000" placeholder="审核意见(驳回时必填)" :disabled="busy || loading" @input="updateReviewReason" />
<view class="count-row"><button :disabled="busy || loading || !reviewReason.trim()" @tap="submitReview('REJECTED')">驳回至客服处理</button><button class="primary-button" :disabled="busy || loading || current.actions.LOADING_REVIEW?.blockCode === 'APPROVAL_BLOCKED'" @tap="submitReview('APPROVED')">审核通过</button></view>
<view class="review-actions"><button :disabled="busy || loading || !reviewReason.trim()" @tap="submitReview('REJECTED')">驳回</button><button class="primary-button" :disabled="busy || loading || current.actions.LOADING_REVIEW?.blockCode === 'APPROVAL_BLOCKED'" @tap="submitReview('APPROVED')">审核通过</button></view>
</template>
<text v-else-if="!rejectionReason" class="gate-warning">{{ current.actions.LOADING_REVIEW?.blockReason || '当前不具备理货审核资格,请先完成客服提交和调度审核' }}</text>
</view>
@@ -72,16 +77,15 @@
<view v-else class="picking-quantity"><text class="picking-instruction">拣货箱数</text><input type="number" aria-label="本次拣货箱数" :value="packages" :placeholder="`请输入 1-${selectedRemaining} 箱`" :disabled="busy" @input="updatePackages" /><text class="task-meta">本次最多可拣 {{ selectedRemaining }} 箱</text></view>
<button class="primary-button" :disabled="!canPick || busy" @tap="submitPick">确认真实拣货</button>
</view>
<view v-if="current.stage === 'LOADING'" class="fulfillment-card gate-card picking-completion"><text class="card-title">确认装车完成</text><text class="picking-instruction">{{ current.pickingReconciled ? 'PDA拣货已核平,可边装车边上传5张装车照片;确认装车完成且照片齐全后自动进入派送中。' : '先完成全部真实拣货,不能补录数量代替核平。' }}</text><text v-if="!gate('LOADING_COMPLETE')" class="gate-warning">{{ current.actions.LOADING_COMPLETE?.blockReason || '尚未获得装车完成资格' }}</text><button class="primary-button" :disabled="!canComplete || busy" @tap="prepareLoading">确认装车完成</button></view>
<view v-if="current.stage === 'LOADING'" class="fulfillment-card gate-card picking-completion"><text v-if="!gate('LOADING_COMPLETE')" class="gate-warning">{{ current.actions.LOADING_COMPLETE?.blockReason || '尚未获得装车完成资格' }}</text><button class="primary-button" :disabled="!canComplete || busy" @tap="prepareLoading">确认装车完成</button></view>
</view>
</template><view v-if="successMessage" class="fulfillment-success" role="status">{{ successMessage }}</view>
</view></scroll-view>
<view v-if="loadingPreview" class="loading-overlay">
<view class="loading-dialog" role="dialog" aria-modal="true" aria-labelledby="loading-confirm-title">
<view class="loading-dialog-heading"><text id="loading-confirm-title" class="loading-dialog-title">确认装车完成</text><text class="loading-dialog-number">{{ loadingPreview.dispatchNo }}</text></view>
<view class="loading-dialog-heading"><text id="loading-confirm-title" class="loading-dialog-title">确认装车</text></view>
<view class="loading-counts"><view class="loading-count"><text class="loading-count-value">{{ loadingRows.length }}<text class="loading-count-unit"> 托</text></text><text class="loading-count-label">已拣托盘</text></view><view class="loading-count"><text class="loading-count-value">{{ loadingRows.reduce((sum, row) => sum + Number(row.pickedPackages || 0), 0) }}<text class="loading-count-unit"> 箱</text></text><text class="loading-count-label">已拣箱数</text></view></view>
<text class="loading-confirm-question">本车货物已全部装车?</text><text class="loading-confirm-note">照片可在“装车拍照”中继续上传</text>
<view class="loading-dialog-actions"><button class="loading-cancel" :disabled="busy" @tap="loadingPreview = null">取消</button><button class="loading-confirm" :disabled="busy" :loading="busy" @tap="completeLoading">确认装车</button></view>
<view class="loading-dialog-actions"><button class="loading-cancel" :disabled="busy" @tap="loadingPreview = null">取消</button><button class="loading-confirm" :disabled="busy" :loading="busy" @tap="completeLoading">确认</button></view>
</view>
</view>
</view>
@@ -244,20 +248,25 @@ export default {
.stage-tab::after { border: 0; }
.stage-tab.active { color: #1262f4; background: #fff; box-shadow: 0 1px 4px rgba(31, 41, 55, .12); }
.stage-tab[disabled] { opacity: .55; }
.review-card textarea { box-sizing: border-box; width: 100%; min-height: 160rpx; padding: 16rpx; border: 1px solid #cbd5e1; border-radius: 8rpx; }
.review-facts { margin: 16rpx 0; }
.review-fact { display: flex; align-items: flex-start; gap: 20rpx; padding: 10rpx 0; color: #243c58; font-size: 26rpx; line-height: 1.5; }
.review-label { flex: 0 0 132rpx; color: #718095; overflow-wrap: anywhere; }
.review-fact > :last-child { flex: 1; min-width: 0; overflow-wrap: anywhere; }
.review-appointment { display: flex; flex-direction: column; gap: 4rpx; }
.review-actions { display: flex; gap: 20rpx; margin-top: 24rpx; }
.review-actions button { flex: 1; min-width: 0; margin: 0; padding: 0 16rpx; min-height: 88rpx; line-height: 88rpx; font-size: 28rpx; }
.review-actions .primary-button { min-height: 88rpx; line-height: 88rpx; font-size: 28rpx; }
.review-card textarea { box-sizing: border-box; width: 100%; height: 160rpx; min-height: 160rpx; margin-top: 20rpx; padding: 16rpx; border: 1px solid #cbd5e1; border-radius: 8rpx; }
.loading-overlay { position: fixed; inset: 0; background: rgba(15, 23, 42, .46); display: flex; align-items: center; justify-content: center; z-index: 100; padding: 40rpx; box-sizing: border-box; }
.loading-dialog { width: 100%; max-width: 620rpx; max-height: 88vh; overflow-y: auto; box-sizing: border-box; background: #fff; border-radius: 28rpx; padding: 36rpx 32rpx 32rpx; box-shadow: 0 24rpx 80rpx rgba(15, 23, 42, .18); }
.loading-dialog-heading { display: flex; flex-direction: column; gap: 10rpx; }
.loading-dialog-title { color: #142c49; font-size: 36rpx; font-weight: 700; line-height: 1.4; }
.loading-dialog-number { color: #7a8aa0; font-size: 26rpx; line-height: 1.5; overflow-wrap: anywhere; }
.loading-counts { display: flex; margin: 28rpx 0; padding: 24rpx 0; border-radius: 20rpx; background: #f3f7ff; }
.loading-count { flex: 1; display: flex; align-items: center; flex-direction: column; gap: 8rpx; }
.loading-count + .loading-count { border-left: 1px solid #dce6f6; }
.loading-count-value { color: #1764ec; font-size: 48rpx; font-weight: 700; line-height: 1.2; }
.loading-count-unit { font-size: 26rpx; font-weight: 500; }
.loading-count-label { color: #72839b; font-size: 24rpx; }
.loading-confirm-question { display: block; color: #253a54; font-size: 30rpx; font-weight: 600; line-height: 1.5; }
.loading-confirm-note { display: block; color: #7a8aa0; font-size: 25rpx; line-height: 1.5; margin-top: 10rpx; }
.loading-dialog-actions { display: flex; gap: 20rpx; margin-top: 32rpx; }
.loading-dialog-actions button { flex: 1; min-width: 0; margin: 0; height: 88rpx; line-height: 88rpx; padding: 0 12rpx; border-radius: 16rpx; font-size: 30rpx; font-weight: 600; }
.loading-dialog-actions button::after { border: 0; }
+3 -2
View File
@@ -62,9 +62,10 @@ test('warehouse transfer shows forecast and actual variance and requires counted
assert.doesNotMatch(page, /本托 SKU 箱数|itemCounts/)
})
test('warehouse transfer retries reuse the pending idempotency key', async () => {
test('warehouse transfer creates a fresh completion key for each explicit attempt', async () => {
const page = await readFile(new URL('../src/pages/warehouse-transfer/warehouse-transfer.vue', import.meta.url), 'utf8')
assert.match(page, /if \(!this\.pendingKey\) this\.pendingKey = `pda-transfer-/)
assert.doesNotMatch(page, /if \(!this\.pendingKey\) this\.pendingKey = `pda-transfer-/)
assert.match(page, /this\.pendingKey = `pda-transfer-/)
assert.match(page, /idempotencyKey: this\.pendingKey/)
assert.equal((page.match(/pendingKey = `pda-transfer-/g) || []).length, 1)
})
+4 -3
View File
@@ -107,12 +107,13 @@ test('transfer counts require valid pieces and measurements and record variance
assert.equal(value.cartonCountReady,true);value.countDiscrepancyReason='多一箱';assert.equal(value.cartonCountReady,true)
assert.equal(value.cartonQuantity,9);assert.equal(value.cartonVolume,'0.012')
})
test('network retry preserves request key and changed content uses a new request',async()=>{
test('failed count unlocks the draft and each explicit retry sends current contents with a fresh key',async()=>{
const {value,scope}=setup();const requests=[]
scope.countWarehouseTransferCartons=async(no,request)=>{requests.push(request);throw new Error('offline')}
await value.confirmCartons();await value.confirmCartons()
assert.equal(requests.length,2);assert.equal(requests[0].idempotencyKey,requests[1].idempotencyKey)
assert.equal(requests.length,2);assert.notEqual(requests[0].idempotencyKey,requests[1].idempotencyKey)
value.cartonBoxes[0].items[0].quantity=8;await value.confirmCartons();assert.notEqual(requests[1].idempotencyKey,requests[2].idempotencyKey)
assert.equal(requests[2].boxes[0].items[0].quantity,8);assert.equal(value.cartonBoxes[0].items[0].quantity,8)
assert.equal(value.countingCartons,false);assert.equal(value.error.message,'offline')
})
test('draft restores additional cartons and discrepancy reason without crossing tasks',()=>{
@@ -221,7 +222,7 @@ for (const answer of [{confirm:false}, {confirm:true,content:' '}, {confirm:tru
await value.submit()
assert.equal(prompts,1);assert.equal(value.submitting,false)
assert.equal(requests.length,answer.confirm && answer.content?.trim()?1:0)
if(requests.length){assert.equal(requests[0].discrepancyReason,'少到一箱');await value.submit();assert.equal(requests[0].idempotencyKey,requests[1].idempotencyKey)}
if(requests.length){assert.equal(requests[0].discrepancyReason,'少到一箱');await value.submit();assert.notEqual(requests[0].idempotencyKey,requests[1].idempotencyKey)}
})
}
test('matching SKU counts with different carton count submit without prompting, and missing photos block before prompting',async()=>{