fix: complete October 22 workflow feedback and request failure recovery
This commit is contained in:
+111
-39
@@ -3,28 +3,38 @@
|
||||
<PortalHeader title="排车作业" :user="user" />
|
||||
<scroll-view class="fulfillment-scroll" scroll-y><view class="portal-content fulfillment-content">
|
||||
<view class="fulfillment-heading"><text class="fulfillment-title">理货审核、真实拣货与装车</text></view>
|
||||
<view v-if="pendingCommand" class="fulfillment-card gate-card"><text>排车 {{ pendingCommand.dispatchNo }} 的原操作结果待核对,请保留原请求。</text><button :disabled="busy || loading" @tap="recoverCommand">核对并重试原请求</button><button v-if="pendingCommand.rejected" :disabled="busy" @tap="discardRejected">按明确拒绝结果重新核对</button></view>
|
||||
<view v-if="current" class="detail-toolbar"><text class="detail-title">{{ current.dispatchNo }}</text><button :disabled="busy" @tap="closeTask">‹ 返回列表</button></view>
|
||||
<view v-if="current" class="stage-switch" role="tablist" aria-label="排车作业">
|
||||
<button id="dispatch-picking-tab" class="stage-tab" :class="{ active: activeTab === 'picking' }" role="tab" :aria-selected="activeTab === 'picking'" aria-controls="dispatch-picking-panel" :disabled="busy || loading || Boolean(pendingCommand)" @tap="selectTab('picking')">拣货作业</button>
|
||||
<button id="dispatch-photos-tab" class="stage-tab" :class="{ active: activeTab === 'photos' }" role="tab" :aria-selected="activeTab === 'photos'" aria-controls="dispatch-photos-panel" :disabled="!canShowPhotos || busy || loading || Boolean(pendingCommand)" @tap="selectTab('photos')">装车拍照</button>
|
||||
<button id="dispatch-picking-tab" class="stage-tab" :class="{ active: activeTab === 'picking' }" role="tab" :aria-selected="activeTab === 'picking'" aria-controls="dispatch-picking-panel" :disabled="busy || loading" @tap="selectTab('picking')">拣货作业</button>
|
||||
<button id="dispatch-photos-tab" class="stage-tab" :class="{ active: activeTab === 'photos' }" role="tab" :aria-selected="activeTab === 'photos'" aria-controls="dispatch-photos-panel" :disabled="!canShowPhotos || busy || loading" @tap="selectTab('photos')">装车拍照</button>
|
||||
</view>
|
||||
<view v-if="!current || activeTab === 'picking'" class="fulfillment-card"><ScanTargetInput name="dispatch" :model-value="scanCode" label="排车单号/实物托盘码/来源箱唛" placeholder="扫描业务单号或实物码" :active="activeTab === 'picking'" @update:modelValue="scanCode = $event" @submit="handleScan" /><button :disabled="busy || loading || !scanCode || Boolean(pendingCommand)" @tap="handleScan(scanCode)">匹配扫描</button></view>
|
||||
<view v-if="!current || activeTab === 'picking'" class="fulfillment-card dispatch-scan-card">
|
||||
<text class="card-title">{{ current ? '扫码拣货' : '扫码查询排车' }}</text>
|
||||
<ScanTargetInput name="dispatch" :model-value="scanCode" label="排车单号/实物托盘码/来源箱唛" placeholder="扫描业务单号或实物码" :active="activeTab === 'picking'" @update:modelValue="scanCode = $event" @submit="handleScan" />
|
||||
<button class="secondary-button" :disabled="busy || loading || !scanCode" @tap="handleScan(scanCode)">匹配扫描</button>
|
||||
</view>
|
||||
<view v-if="loading" class="fulfillment-state"><text>正在加载排车事实…</text></view>
|
||||
<view v-if="error" class="fulfillment-error" role="alert"><text class="state-title">{{ error.message }}</text><text v-if="error.code">错误码:{{ error.code }}</text><text v-if="error.requestId">requestId:{{ error.requestId }}</text><text v-if="error.traceId">traceId:{{ error.traceId }}</text><text v-if="error.network">网络中断,原请求和作业草稿已保留。</text><button :disabled="busy || loading" @tap="retry">重新核对</button></view>
|
||||
<view v-if="error" class="fulfillment-error" role="alert"><text class="state-title">{{ error.message }}</text><text v-if="error.code">错误码:{{ error.code }}</text><text v-if="error.requestId">requestId:{{ error.requestId }}</text><text v-if="error.traceId">traceId:{{ error.traceId }}</text><text v-if="error.network">网络中断,请稍后重新操作。</text><button :disabled="busy || loading" @tap="retry">重新核对</button></view>
|
||||
<template v-if="!current && !loading"><view class="list-toolbar"><text class="list-title">排车任务</text><button :disabled="busy" @tap="loadTasks">刷新</button></view><view v-if="!tasks.length && !error" class="fulfillment-state">暂无排车任务,可扫描排车单号读取。</view><button v-for="task in tasks" :key="task.dispatchNo" class="task-card" @tap="openTask(task.dispatchNo)"><text class="business-code">{{ task.dispatchNo }}</text><text v-if="task.dispatchNo === recentTaskNo" class="recent-badge">上次作业</text><text class="status-badge">{{ stageLabel(task) }}</text><text class="task-meta">任务号 {{ task.taskNo || '—' }}</text></button></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="busy = $event" @saved="openTask(current.dispatchNo)" /></view>
|
||||
<view v-show="activeTab === 'picking'" id="dispatch-picking-panel" role="tabpanel" aria-labelledby="dispatch-picking-tab">
|
||||
<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 === '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"><view class="task-head"><text class="business-code">{{ current.dispatchNo }}</text><text class="status-badge">{{ stageLabel(current) }}</text></view><text class="task-meta">任务号 {{ current.taskNo || '—' }}</text><text v-if="!gate('PICK') && current.stage === 'LOADING'" class="gate-warning">{{ current.actions.PICK?.blockReason || '拣货资格尚未允许,请核对两级审核及当前作业状态' }}</text><view class="metrics"><view><text class="metric-value">{{ plannedPackages }}</text><text>计划箱数</text></view><view><text class="metric-value">{{ pickedPackages }}</text><text>真实已拣</text></view><view><text class="metric-value">{{ remainingPackages }}</text><text>未拣箱数</text></view></view></view>
|
||||
<view class="fulfillment-card summary-card">
|
||||
<text class="card-title">任务摘要</text>
|
||||
<view class="task-head"><text class="business-code">{{ current.dispatchNo }}</text><text class="status-badge">{{ stageLabel(current) }}</text></view>
|
||||
<text class="task-meta">任务号 {{ current.taskNo || '—' }}</text>
|
||||
<text v-if="!gate('PICK') && current.stage === 'LOADING'" class="gate-warning">{{ current.actions.PICK?.blockReason || '拣货资格尚未允许,请核对两级审核及当前作业状态' }}</text>
|
||||
<text class="picking-section-label">拣货进度</text>
|
||||
<view class="metrics" aria-label="拣货进度"><view><text class="metric-value">{{ plannedPackages }}</text><text>计划箱数</text></view><view><text class="metric-value success">{{ pickedPackages }}</text><text>真实已拣</text></view><view><text class="metric-value warning">{{ remainingPackages }}</text><text>未拣箱数</text></view></view>
|
||||
</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, index) in current.fbaAppointments || []" :key="appointment.batchKey + '-' + index" class="task-meta">{{ appointment.destinationWarehouseCode }} · {{ appointment.appointmentNo || appointment.batchNo || '—' }} · {{ appointment.status === 'SUCCESS' ? '已确认' : '未确认' }}:{{ reviewTime(appointment.confirmedAt) }}(北京时间)</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>
|
||||
@@ -32,17 +42,31 @@
|
||||
<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 || Boolean(pendingCommand)" @input="updateReviewReason" />
|
||||
<view class="count-row"><button :disabled="busy || loading || Boolean(pendingCommand) || !reviewReason.trim()" @tap="submitReview('REJECTED')">驳回至客服处理</button><button class="primary-button" :disabled="busy || loading || Boolean(pendingCommand) || current.actions.LOADING_REVIEW?.blockCode === 'APPROVAL_BLOCKED'" @tap="submitReview('APPROVED')">审核通过</button></view>
|
||||
<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>
|
||||
</template>
|
||||
<text v-else-if="!rejectionReason" class="gate-warning">{{ current.actions.LOADING_REVIEW?.blockReason || '当前不具备理货审核资格,请先完成客服提交和调度审核' }}</text>
|
||||
</view>
|
||||
<view v-for="item in current.items" :key="item.dispatchItemId" class="fulfillment-card item-card" :class="{ selected: selectedItem?.dispatchItemId === item.dispatchItemId }" @tap="selectPallet(item)"><view class="task-head"><text class="business-code">{{ item.palletNo || '未关联托盘码' }}</text><text>{{ item.pickedPackages }}/{{ item.scheduledPackages }}箱</text></view><text class="task-meta">{{ item.sourceBusinessType === 'RELABEL_ORDER' ? '换标单号' : '来源业务号' }} {{ item.sourceBusinessNo || '—' }} · {{ item.customerName || '—' }} · {{ routeLabel(item) }}</text><text v-for="carton in item.returnCartons" :key="carton.returnCartonId" class="task-meta">{{ carton.cartonMark }} · {{ carton.status === 'OUTBOUNDED' ? '已拣货' : '待扫描' }}</text><view class="progress"><view :style="{ width: `${itemProgress(item)}%` }"></view></view></view>
|
||||
<text v-if="current.items.length" class="picking-section-title">托盘与货物来源</text>
|
||||
<view v-for="item in current.items" :key="item.dispatchItemId" class="fulfillment-card item-card picking-item" :class="{ selected: selectedItem?.dispatchItemId === item.dispatchItemId }" @tap="selectPallet(item)">
|
||||
<view class="task-head"><text class="business-code">{{ item.palletNo || '未关联托盘码' }}</text><text v-if="selectedItem?.dispatchItemId === item.dispatchItemId" class="picking-selected">当前选择</text></view>
|
||||
<view class="picking-fact"><text class="picking-fact-label">{{ item.sourceBusinessType === 'RELABEL_ORDER' ? '换标单号' : '来源业务号' }}</text><text>{{ item.sourceBusinessNo || '—' }}</text></view>
|
||||
<view class="picking-fact"><text class="picking-fact-label">客户</text><text>{{ item.customerName || '—' }}</text></view>
|
||||
<view class="picking-fact"><text class="picking-fact-label">目的地</text><text>{{ routeLabel(item) }}</text></view>
|
||||
<text v-for="carton in item.returnCartons" :key="carton.returnCartonId" class="task-meta picking-carton">{{ carton.cartonMark }} · {{ carton.status === 'OUTBOUNDED' ? '已拣货' : '待扫描' }}</text>
|
||||
<text class="picking-item-progress">已拣 {{ item.pickedPackages }} / {{ item.scheduledPackages }} 箱</text>
|
||||
<view class="progress"><view :style="{ width: `${itemProgress(item)}%` }"></view></view>
|
||||
</view>
|
||||
<view v-if="!current.items.length" class="fulfillment-state">本车暂无货物,空单不能核平、装车或发车。</view>
|
||||
<view v-if="current.stage === 'LOADING' && gate('PICK') && selectedItem && selectedItem.scheduledPackages > selectedItem.pickedPackages && selectedRemaining === 0" class="fulfillment-card gate-card"><text class="gate-warning">该托盘暂无可拣来源,请核对是否已完成实收,或货物是否冻结、被占用及来源发生变化。</text><button :disabled="busy || loading || Boolean(pendingCommand)" @tap="retry">刷新托盘实收信息</button></view>
|
||||
<view v-if="sourceChoices.length > 1" class="fulfillment-card"><text class="card-title">请选择本次真实拣货来源</text><button v-for="item in sourceChoices" :key="item.dispatchItemId" :disabled="busy || Boolean(pendingCommand)" @tap="chooseSource(item)">{{ item.sourceBusinessNo || '—' }} · {{ item.customerName || '—' }} · 可拣{{ pickablePackages(item) }}箱</button></view>
|
||||
<view v-if="current.stage === 'LOADING' && gate('PICK') && selectedItem && selectedRemaining > 0" class="fulfillment-card"><text class="card-title">本次拣货:{{ selectedItem.palletNo || '来源箱唛' }}</text><text v-if="selectedItem.returnCartons.length">{{ selectedReturnCarton ? `已扫 ${selectedReturnCarton.cartonMark}` : '请扫描来源箱唛,每次一箱' }}</text><view v-else class="count-row"><text>拣货箱数</text><input type="number" :value="packages" :placeholder="`请输入 1-${selectedRemaining} 箱`" :disabled="busy || Boolean(pendingCommand)" @input="updatePackages" /><text>最多{{ selectedRemaining }}箱</text></view><button class="primary-button" :disabled="!canPick || busy || Boolean(pendingCommand)" @tap="submitPick">确认真实拣货</button></view>
|
||||
<view v-if="current.stage === 'LOADING'" class="fulfillment-card gate-card"><text class="card-title">确认装车完成</text><text>{{ 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 || Boolean(pendingCommand)" @tap="prepareLoading">确认装车完成</button></view>
|
||||
<view v-if="current.stage === 'LOADING' && gate('PICK') && selectedItem && selectedItem.scheduledPackages > selectedItem.pickedPackages && selectedRemaining === 0" class="fulfillment-card gate-card"><text class="gate-warning">该托盘暂无可拣来源,请核对是否已完成实收,或货物是否冻结、被占用及来源发生变化。</text><button :disabled="busy || loading" @tap="retry">刷新托盘实收信息</button></view>
|
||||
<view v-if="sourceChoices.length > 1" class="fulfillment-card picking-source-choices"><text class="card-title">请选择本次真实拣货来源</text><button v-for="item in sourceChoices" :key="item.dispatchItemId" :disabled="busy" @tap="chooseSource(item)"><text class="business-code">{{ item.sourceBusinessNo || '—' }}</text><text class="task-meta">客户:{{ item.customerName || '—' }}</text><text class="picking-item-progress">可拣 {{ pickablePackages(item) }} 箱</text></button></view>
|
||||
<view v-if="current.stage === 'LOADING' && gate('PICK') && selectedItem && selectedRemaining > 0" class="fulfillment-card picking-current">
|
||||
<text class="card-title">当前拣货</text><text class="business-code">{{ selectedItem.palletNo || '来源箱唛' }}</text>
|
||||
<text v-if="selectedItem.returnCartons.length" class="picking-instruction">{{ selectedReturnCarton ? `已扫 ${selectedReturnCarton.cartonMark}` : '请扫描来源箱唛,每次一箱' }}</text>
|
||||
<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>
|
||||
</template><view v-if="successMessage" class="fulfillment-success" role="status">{{ successMessage }}</view>
|
||||
</view></scroll-view>
|
||||
@@ -51,7 +75,7 @@
|
||||
<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-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 || Boolean(pendingCommand)" :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>
|
||||
@@ -61,17 +85,18 @@ import PortalHeader from '@/components/PortalHeader.vue'
|
||||
import ScanTargetInput from '@/components/ScanTargetInput.vue'
|
||||
import DispatchHandoverStatus from '@/components/DispatchHandoverStatus.vue'
|
||||
import DispatchLoadingDocuments from '@/components/DispatchLoadingDocuments.vue'
|
||||
import { reviewDispatchLoading, completeDispatchLoading, getDispatchTask, getDispatchLoading, getDispatchCommand, listDispatchTasks, pickDispatchItem, requireDispatchVersion, dispatchVersionAtLeast } from '@/api/fulfillment.js'
|
||||
import { fulfillment, beginDispatchCommand, patchDispatchCommand, finishDispatchCommand, discardRejectedDispatchCommand, restoreFulfillmentDraft, updateFulfillmentDraft } from '@/stores/fulfillment.js'
|
||||
import { reviewDispatchLoading, completeDispatchLoading, getDispatchTask, getDispatchLoading, listDispatchTasks, pickDispatchItem, requireDispatchVersion } from '@/api/fulfillment.js'
|
||||
import { fulfillment, restoreFulfillmentDraft, updateFulfillmentDraft } from '@/stores/fulfillment.js'
|
||||
import { requireSession, session } from '@/stores/session.js'
|
||||
import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js'
|
||||
import { toDispatchError } from '@/utils/dispatch-error.js'
|
||||
import { startScanBroadcast } from '@/utils/scan-broadcast.js'
|
||||
export default {
|
||||
components: { PortalHeader, ScanTargetInput, DispatchHandoverStatus, DispatchLoadingDocuments },
|
||||
data() { return { user: session.currentUser, tasks: [], current: null, activeTab: 'picking', scanCode: '', loading: false, busy: false, error: null, successMessage: '', bootstrapped: false, resumeOnEntry: false, generation: 0, loadingPreview: null, loadingRows: [], sourceChoices: [], choiceCartonCode: '', reviewReason: '' } },
|
||||
data() { return { user: session.currentUser, tasks: [], current: null, activeTab: 'picking', scanCode: '', loading: false, requestBusy: false, photoBusy: false, error: null, successMessage: '', bootstrapped: false, resumeOnEntry: false, generation: 0, loadingPreview: null, loadingRows: [], sourceChoices: [], choiceCartonCode: '', reviewReason: '' } },
|
||||
computed: {
|
||||
recentTaskNo() { return fulfillment.dispatch.currentNo || '' }, pendingCommand() { return fulfillment.dispatch.pendingCommand }, packages() { return fulfillment.dispatch.packages },
|
||||
busy() { return this.requestBusy || this.photoBusy },
|
||||
recentTaskNo() { return fulfillment.dispatch.currentNo || '' }, packages() { return fulfillment.dispatch.packages },
|
||||
selectedItem() { return this.current?.items.find(row => row.dispatchItemId === fulfillment.dispatch.dispatchItemId && row.sourceKey === fulfillment.dispatch.sourceKey) || null },
|
||||
selectedReturnCarton() { const matches = this.selectedItem?.returnCartons.filter(row => row.cartonMark === fulfillment.dispatch.cartonCode && row.status === 'OUTBOUND_PENDING') || []; return matches.length === 1 ? matches[0] : null },
|
||||
selectedPosition() { return this.selectedItem && this.selectedItem.scheduledPackages > this.selectedItem.pickedPackages ? this.selectedItem.positions.find(row => row.availablePackages > 0) || null : null },
|
||||
@@ -88,17 +113,16 @@ export default {
|
||||
onShow() { if (!requireSession()) return; this.user = session.currentUser; restoreFulfillmentDraft(`${this.user?.id || ''}:${this.user?.warehouseId || ''}`); this.startScanner(); if (!this.bootstrapped) { this.bootstrapped = true; if (this.resumeOnEntry && fulfillment.dispatch.currentNo) this.openTask(fulfillment.dispatch.currentNo); else this.loadTasks() } else if (this.current?.dispatchNo) this.openTask(this.current.dispatchNo); else this.loadTasks() },
|
||||
onHide() { this.stopScanner(); this.generation++ }, beforeUnmount() { this.stopScanner(); this.generation++ },
|
||||
methods: {
|
||||
selectTab(tab) { if (this.busy || this.loading || this.pendingCommand || !['picking', 'photos'].includes(tab) || (tab === 'photos' && !this.canShowPhotos)) return; this.activeTab = tab },
|
||||
selectTab(tab) { if (this.busy || this.loading || !['picking', 'photos'].includes(tab) || (tab === 'photos' && !this.canShowPhotos)) return; this.activeTab = tab },
|
||||
gate(action) { return this.current?.actions?.[action]?.allowed === true },
|
||||
startScanner() { this.stopScanner(); this._stopScanner = startScanBroadcast({ onScan: value => this.handleScan(value), onError: error => this.setError(error, '扫码广播注册失败') }) }, stopScanner() { this._stopScanner?.(); this._stopScanner = null },
|
||||
updatePackages(event) { if (this.pendingCommand) return; const value = Number(event.detail.value); updateFulfillmentDraft('dispatch', { packages: Number.isFinite(value) ? value : 0 }) },
|
||||
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) { if (this.pendingCommand && this.pendingCommand.dispatchNo !== no) return this.setError(new Error('请先核对原排车请求,不能切到另一张单执行'), '操作未完成'); 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) },
|
||||
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() },
|
||||
handleScan(raw) {
|
||||
const code = String(raw?.detail?.value ?? raw ?? this.scanCode).trim()
|
||||
if (!code || this.busy || this.loading || this.activeTab !== 'picking') return
|
||||
if (this.pendingCommand) return this.setError(new Error('请先核对原排车操作结果'), '原请求未完成')
|
||||
this.error = null; this.successMessage = ''; this.sourceChoices = []; this.choiceCartonCode = ''
|
||||
updateFulfillmentDraft('dispatch', { palletCode: '', dispatchItemId: '', sourceKey: '', cartonCode: '', packages: 1 })
|
||||
if (!this.current || code === this.current.dispatchNo || code === this.current.taskNo) return this.openTask(code === this.current?.taskNo ? this.current.dispatchNo : code)
|
||||
@@ -112,7 +136,7 @@ export default {
|
||||
this.chooseSource(matches[0])
|
||||
},
|
||||
chooseSource(item) {
|
||||
if (this.pendingCommand || this.busy || !this.sourceChoices.some(row => row.dispatchItemId === item.dispatchItemId)) return
|
||||
if (this.busy || !this.sourceChoices.some(row => row.dispatchItemId === item.dispatchItemId)) return
|
||||
const cartonCode = this.choiceCartonCode
|
||||
this.selectPallet(item)
|
||||
if (fulfillment.dispatch.dispatchItemId !== item.dispatchItemId) return
|
||||
@@ -124,33 +148,55 @@ export default {
|
||||
return Math.min(remaining, item.returnCartons.length ? item.returnCartons.filter(row => row.status === 'OUTBOUND_PENDING').length : item.positions.reduce((sum, row) => sum + row.availablePackages, 0))
|
||||
},
|
||||
selectPallet(item) {
|
||||
if (this.current?.stage !== 'LOADING' || !this.gate('PICK') || this.pendingCommand || this.busy || !item?.dispatchItemId || item.scheduledPackages <= item.pickedPackages) return
|
||||
if (this.current?.stage !== 'LOADING' || !this.gate('PICK') || this.busy || !item?.dispatchItemId || item.scheduledPackages <= item.pickedPackages) return
|
||||
if (!item.sourceKey || item.sourceBusinessType === 'RELABEL_ORDER' && !item.sourceBusinessNo) return this.setError(new Error('来源业务身份未完整返回,请重新读取任务'), '请选择明细')
|
||||
const actualPackages = item.returnCartons.length ? 1 : Math.min(item.scheduledPackages - item.pickedPackages, item.positions.find(row => row.availablePackages > 0)?.availablePackages || 0)
|
||||
updateFulfillmentDraft('dispatch', { palletCode: item.palletNo, dispatchItemId: item.dispatchItemId, sourceKey: item.sourceKey, cartonCode: '', packages: actualPackages })
|
||||
this.sourceChoices = []; this.choiceCartonCode = ''; this.error = null
|
||||
},
|
||||
async submitPick() { if (!this.canPick || this.pendingCommand) return; try { const body = { recordVersion: requireDispatchVersion(this.current.recordVersion), positionId: this.selectedReturnCarton ? null : this.selectedPosition.positionId, packages: this.selectedReturnCarton ? 1 : Number(this.packages), productQuantity: this.selectedReturnCarton?.productQuantity || 0, cartonMark: this.selectedReturnCarton?.cartonMark || null, remark: null }; beginDispatchCommand(this.current.dispatchNo, 'PICK', { ...body, dispatchItemId: this.selectedItem.dispatchItemId }); await this.recoverCommand() } catch (cause) { this.setError(cause, '拣货未提交') } },
|
||||
async prepareLoading() { if (!this.canComplete || this.pendingCommand) return; await this.run(async () => { const data = await getDispatchLoading(this.current.dispatchNo); if (data.dispatchNo !== this.current.dispatchNo || data.pickingReconciled !== true || data.loadingComplete?.allowed !== true) throw new Error(data.loadingComplete?.blockReason || '装车前置未满足'); requireDispatchVersion(data.recordVersion); this.loadingPreview = data; this.loadingRows = data.pallets }) },
|
||||
async completeLoading() { if (!this.loadingPreview || this.pendingCommand || this.busy) return; try { if (!this.loadingRows.length || this.loadingRows.some(row => row.deliveryIndex == null || !Number.isInteger(row.deliveryIndex) || row.deliveryIndex < 1)) throw new Error('托盘卸货站尚未匹配,请后台核对'); beginDispatchCommand(this.loadingPreview.dispatchNo, 'LOADING_COMPLETE', { recordVersion: requireDispatchVersion(this.loadingPreview.recordVersion), manifestHash: this.loadingPreview.manifestHash, remark: null }); await this.recoverCommand() } catch (cause) { this.setError(cause, '装车完成未确认') } },
|
||||
updateReviewReason(event) { if (this.busy || this.loading || this.pendingCommand) return; this.reviewReason = String(event.detail.value || '') },
|
||||
async submitPick() { if (!this.canPick) return; try { const body = { recordVersion: requireDispatchVersion(this.current.recordVersion), positionId: this.selectedReturnCarton ? null : this.selectedPosition.positionId, packages: this.selectedReturnCarton ? 1 : Number(this.packages), productQuantity: this.selectedReturnCarton?.productQuantity || 0, cartonMark: this.selectedReturnCarton?.cartonMark || null, remark: null }; await this.submitCommand(this.current.dispatchNo, 'PICK', { ...body, dispatchItemId: this.selectedItem.dispatchItemId }) } catch (cause) { this.setError(cause, '拣货未提交') } },
|
||||
async prepareLoading() { if (!this.canComplete) return; await this.run(async () => { const data = await getDispatchLoading(this.current.dispatchNo); if (data.dispatchNo !== this.current.dispatchNo || data.pickingReconciled !== true || data.loadingComplete?.allowed !== true) throw new Error(data.loadingComplete?.blockReason || '装车前置未满足'); requireDispatchVersion(data.recordVersion); this.loadingPreview = data; this.loadingRows = data.pallets }) },
|
||||
async completeLoading() { if (!this.loadingPreview || this.busy) return; try { if (!this.loadingRows.length || this.loadingRows.some(row => row.deliveryIndex == null || !Number.isInteger(row.deliveryIndex) || row.deliveryIndex < 1)) throw new Error('托盘卸货站尚未匹配,请后台核对'); await this.submitCommand(this.loadingPreview.dispatchNo, 'LOADING_COMPLETE', { recordVersion: requireDispatchVersion(this.loadingPreview.recordVersion), manifestHash: this.loadingPreview.manifestHash, remark: null }) } catch (cause) { this.setError(cause, '装车完成未确认') } },
|
||||
updateReviewReason(event) { if (this.busy || this.loading) return; this.reviewReason = String(event.detail.value || '') },
|
||||
async submitReview(decision) {
|
||||
if (!this.canReview || this.busy || this.loading || this.pendingCommand || !['APPROVED', 'REJECTED'].includes(decision)) return
|
||||
if (!this.canReview || this.busy || this.loading || !['APPROVED', 'REJECTED'].includes(decision)) return
|
||||
if (decision === 'APPROVED' && this.current.actions.LOADING_REVIEW?.blockCode === 'APPROVAL_BLOCKED') return this.setError(new Error(this.current.actions.LOADING_REVIEW.blockReason), '预约及时间尚未核对')
|
||||
const reason = this.reviewReason.trim()
|
||||
if (decision === 'REJECTED' && !reason) return this.setError(new Error('请填写驳回原因,退回客服处理'), '审核未提交')
|
||||
try {
|
||||
const no = this.current.dispatchNo, version = requireDispatchVersion(this.current.recordVersion)
|
||||
const confirmation = await uni.showModal({ title: decision === 'APPROVED' ? '确认理货审核通过' : '确认退回客服处理', content: decision === 'APPROVED' ? '请确认车辆、司机和货物已核对,通过后进入装车中。' : `驳回原因:${reason}。本单将回到客服处理(车辆信息),重新提交后再逐级审核。` })
|
||||
if (!confirmation.confirm || this.current?.dispatchNo !== no || this.current?.recordVersion !== version || !this.canReview || this.pendingCommand) return
|
||||
beginDispatchCommand(no, 'LOADING_REVIEW', { recordVersion: version, decision, reason: reason || null })
|
||||
await this.recoverCommand()
|
||||
if (!confirmation.confirm || this.current?.dispatchNo !== no || this.current?.recordVersion !== version || !this.canReview) return
|
||||
await this.submitCommand(no, 'LOADING_REVIEW', { recordVersion: version, decision, reason: reason || null })
|
||||
} catch (cause) { this.setError(cause, '理货审核未提交') }
|
||||
},
|
||||
async recoverCommand() { if (!this.pendingCommand || this.busy || this.loading) return; const command = JSON.parse(JSON.stringify(this.pendingCommand)), scope = fulfillment.scopeKey, previousAttempt = command.attempted; await this.run(async () => { const ensureScope = () => { if (scope !== fulfillment.scopeKey) throw new Error('作业身份已变化,请重新进入任务') }; let result = command.result; if (!result && previousAttempt) { try { result = await getDispatchCommand(command.dispatchNo, command.body.idempotencyKey) } catch (cause) { if (!['404', 'A0404', 'HTTP_404'].includes(String(cause.code))) throw cause } ensureScope() } if (!result) { patchDispatchCommand({ attempted: true }); try { const { dispatchItemId, ...body } = command.body; result = command.action === 'PICK' ? await pickDispatchItem(command.dispatchNo, dispatchItemId, body) : command.action === 'LOADING_COMPLETE' ? await completeDispatchLoading(command.dispatchNo, body) : command.action === 'LOADING_REVIEW' ? await reviewDispatchLoading(command.dispatchNo, body) : null } catch (cause) { ensureScope(); if (!previousAttempt && !cause.network && /^(?:A0|HTTP_)?(?:400|401|403|404|409|410|422)$/.test(String(cause.code))) patchDispatchCommand({ rejected: true }); throw cause } } ensureScope(); if (!result || result.dispatchNo !== command.dispatchNo || result.action !== command.action || result.idempotencyKey !== command.body.idempotencyKey || !result.result || command.action === 'LOADING_COMPLETE' && !['AFTER_LOADING', 'DELIVERING'].includes(result.stage) || command.action === 'LOADING_REVIEW' && result.stage !== (command.body.decision === 'APPROVED' ? 'LOADING' : 'BEFORE_LOADING')) throw new Error('返回结果未确认原排车动作,请保留原请求核对'); requireDispatchVersion(result.recordVersion); patchDispatchCommand({ result }); const latest = await getDispatchTask(command.dispatchNo); ensureScope(); if (latest.dispatchNo !== command.dispatchNo) throw new Error('恢复详情与原排车单不一致'); if (!dispatchVersionAtLeast(latest.recordVersion, result.recordVersion)) throw new Error('任务版本早于已确认动作,请重读原请求'); this.current = latest; updateFulfillmentDraft('dispatch', { currentNo: latest.dispatchNo, dispatchItemId: '', palletCode: '', cartonCode: '', packages: 1 }); finishDispatchCommand(command.body.idempotencyKey); this.loadingPreview = null; if (command.action === 'LOADING_REVIEW') this.reviewReason = ''; this.successMessage = command.action === 'PICK' ? '本次真实拣货已记录' : command.action === 'LOADING_COMPLETE' ? latest.stage === 'DELIVERING' ? '装车完成、资料齐全,已进入派送中' : '装车完成已确认,请补齐装车资料' : command.action === 'LOADING_REVIEW' ? command.body.decision === 'APPROVED' ? '理货审核已通过,可以开始真实拣货' : '已退回客服处理(车辆信息),等待修改并重新提交' : '排车操作已确认'; playSuccessSound() }) },
|
||||
discardRejected() { if (this.busy || !this.pendingCommand?.rejected) return; discardRejectedDispatchCommand(); this.retry() },
|
||||
async retry() { if (this.pendingCommand) await this.recoverCommand(); else if (this.current?.dispatchNo) await this.openTask(this.current.dispatchNo); else await this.loadTasks() },
|
||||
async run(work, loading = false) { if (this.busy || this.loading) return; this.error = null; this.successMessage = ''; if (loading) this.loading = true; else this.busy = true; try { await work() } catch (cause) { this.setError(cause, '排车操作失败') } finally { this.loading = false; this.busy = false } },
|
||||
async submitCommand(no, action, body) {
|
||||
if (this.busy || this.loading) return
|
||||
const scope = fulfillment.scopeKey
|
||||
await this.run(async () => {
|
||||
const ensureScope = () => { if (scope !== fulfillment.scopeKey || this.current?.dispatchNo !== no) throw new Error('作业身份已变化,请重新进入任务') }
|
||||
ensureScope()
|
||||
const { dispatchItemId, ...payload } = body
|
||||
payload.idempotencyKey = `pda-dispatch-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
const result = action === 'PICK' ? await pickDispatchItem(no, dispatchItemId, payload)
|
||||
: action === 'LOADING_COMPLETE' ? await completeDispatchLoading(no, payload) : await reviewDispatchLoading(no, payload)
|
||||
ensureScope()
|
||||
if (!result || result.dispatchNo !== no || result.action !== action || result.idempotencyKey !== payload.idempotencyKey || !result.result) throw new Error('操作结果未确认,请刷新任务查看')
|
||||
requireDispatchVersion(result.recordVersion)
|
||||
if (action === 'LOADING_COMPLETE' && !['AFTER_LOADING', 'DELIVERING'].includes(result.stage) || action === 'LOADING_REVIEW' && result.stage !== (body.decision === 'REJECTED' ? 'BEFORE_LOADING' : 'LOADING')) throw new Error('返回结果未确认本次操作,请刷新任务查看')
|
||||
this.loadingPreview = null
|
||||
if (action === 'LOADING_REVIEW') this.reviewReason = ''
|
||||
const current = await getDispatchTask(no); ensureScope()
|
||||
if (current.dispatchNo !== no) throw new Error('任务与当前排车单不一致')
|
||||
if (!dispatchVersionAtLeast(current.recordVersion, result.recordVersion)) throw new Error('详情版本早于已确认操作,请刷新任务查看')
|
||||
this.current = current
|
||||
updateFulfillmentDraft('dispatch', { currentNo: no, dispatchItemId: '', palletCode: '', cartonCode: '', packages: 1 })
|
||||
this.successMessage = action === 'PICK' ? '本次真实拣货已记录' : action === 'LOADING_COMPLETE' ? '装车完成已确认' : body.decision === 'REJECTED' ? '已退回客服处理' : '理货审核已提交'
|
||||
playSuccessSound()
|
||||
})
|
||||
},
|
||||
async retry() { if (this.current?.dispatchNo) await this.openTask(this.current.dispatchNo); else await this.loadTasks() },
|
||||
async run(work, loading = false) { if (this.busy || this.loading) return; 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.error = toDispatchError(cause, fallback); playErrorSound() },
|
||||
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 date = new Date(new Date(value).getTime() + 8 * 60 * 60 * 1000); return Number.isNaN(date.getTime()) ? '时间无效' : date.toISOString().slice(0, 16).replace('T', ' ') },
|
||||
@@ -161,6 +207,32 @@ export default {
|
||||
</script>
|
||||
<style scoped>
|
||||
@import './fulfillment.scss';
|
||||
.dispatch-scan-card .card-title { margin-bottom: 12px; font-size: 16px; }
|
||||
.picking-workspace .card-title { font-size: 16px; }
|
||||
.picking-workspace .task-head { flex-wrap: wrap; align-items: flex-start; margin-top: 10px; }
|
||||
.picking-workspace .business-code { min-width: 0; font-size: 16px; line-height: 1.5; }
|
||||
.picking-workspace .task-meta { font-size: 13px; line-height: 1.6; overflow-wrap: anywhere; }
|
||||
.picking-workspace .status-badge { font-size: 12px; }
|
||||
.picking-workspace .gate-warning { font-size: 13px; line-height: 1.6; }
|
||||
.picking-section-label, .picking-section-title { display: block; margin: 16px 0 10px; color: #536982; font-size: 14px; font-weight: 700; }
|
||||
.picking-workspace .metrics { margin-top: 0; padding: 14px 0; }
|
||||
.picking-workspace .metrics view { font-size: 12px; }
|
||||
.picking-workspace .metric-value { font-size: 26px; }
|
||||
.picking-workspace .picking-item { margin-bottom: 12px; }
|
||||
.picking-item .task-head { margin-top: 0; }
|
||||
.picking-selected { padding: 4px 8px; border-radius: 6px; color: #135ee8; background: #e6f0ff; font-size: 12px; }
|
||||
.picking-fact { display: flex; align-items: flex-start; gap: 10px; margin-top: 10px; color: #243c58; font-size: 14px; line-height: 1.6; }
|
||||
.picking-fact-label { flex: 0 0 74px; color: #718095; }
|
||||
.picking-fact > text:last-child { flex: 1; min-width: 0; overflow-wrap: anywhere; }
|
||||
.picking-item-progress { display: block; margin-top: 12px; color: #135ee8; font-size: 14px; font-weight: 700; }
|
||||
.picking-source-choices button { min-height: 48px; margin-top: 12px; padding: 12px; text-align: left; line-height: 1.5; background: #f3f7ff; }
|
||||
.picking-source-choices .business-code { display: block; }
|
||||
.picking-current { border-color: #9dc0f4; }
|
||||
.picking-current > .business-code { display: block; margin-top: 10px; }
|
||||
.picking-instruction { display: block; margin-top: 10px; color: #536982; font-size: 14px; line-height: 1.6; overflow-wrap: anywhere; }
|
||||
.picking-quantity input { box-sizing: border-box; width: 100%; height: 48px; margin-top: 10px; padding: 0 12px; border: 2px solid #135ee8; border-radius: 8px; color: #17314f; font-size: 18px; background: #fff; }
|
||||
.picking-workspace .primary-button, .dispatch-scan-card .secondary-button { min-height: 48px; font-size: 16px; line-height: 48px; }
|
||||
.picking-completion { margin-top: 16px; }
|
||||
.stage-switch { position: sticky; top: 0; z-index: 2; display: flex; gap: 8rpx; margin-bottom: 20rpx; padding: 8rpx; border: 1px solid #dbe5f0; border-radius: 20rpx; background: #eef2f7; }
|
||||
.stage-tab { flex: 1; margin: 0; min-height: 84rpx; padding: 0 16rpx; border-radius: 14rpx; color: #64748b; font-size: 28rpx; font-weight: 600; line-height: 84rpx; background: transparent; }
|
||||
.stage-tab::after { border: 0; }
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
import ScanTargetInput from '@/components/ScanTargetInput.vue'
|
||||
import { moveInventory, resolveWarehouseLocation, resolveWarehousePallet } from '@/api/warehouse-operations.js'
|
||||
import { session, requireSession } from '@/stores/session.js'
|
||||
import { releaseWarehouseKey, resetWarehouseDomain, restoreWarehouseWork, updateWarehouseWork, warehouseIdempotencyKey, warehouseWork } from '@/stores/warehouse-work.js'
|
||||
import { resetWarehouseDomain, restoreWarehouseWork, updateWarehouseWork, warehouseIdempotencyKey, warehouseWork } from '@/stores/warehouse-work.js'
|
||||
import { startScanBroadcast } from '@/utils/scan-broadcast.js'
|
||||
import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js'
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
async restoreDraftFacts() { this.restoring = true; try { if (this.sourceCode) await this.resolveSource(this.sourceCode, false); if (this.targetCode) await this.resolveTarget(this.targetCode, false) } finally { this.restoring = false } },
|
||||
async resolveSource(raw, sound = true) { const code = String(raw?.detail?.value ?? raw ?? this.sourceCode).trim(); if (!code || this.busy) return; this.sourceCode = code; this.resolving = 'source'; this.error = null; try { this.source = await resolveWarehousePallet(code); this.sourceCode = this.source.palletCode; if (sound) playSuccessSound(); this.activeTarget = 'target' } catch (error) { this.setError(error, '来源托盘查询失败') } finally { this.resolving = '' } },
|
||||
async resolveTarget(raw, sound = true) { const code = String(raw?.detail?.value ?? raw ?? this.targetCode).trim(); if (!code || this.busy) return; this.targetCode = code; this.resolving = 'target'; this.error = null; try { this.target = await resolveWarehouseLocation(code); this.targetCode = this.target.locationCode; if (sound) playSuccessSound() } catch (error) { this.setError(error, '目标库位校验失败') } finally { this.resolving = '' } },
|
||||
async submitMove() { if (!this.canMove || this.busy) return; const payload = { sourcePalletCode: this.source.palletCode, targetLocationCode: this.target.locationCode, expectedPalletVersion: Number(this.source.version || 0), reason: this.reason.trim() }; const key = warehouseIdempotencyKey('move', 'move', payload); this.busy = true; this.busyAction = 'move'; this.error = null; this.result = null; try { this.result = await moveInventory({ ...payload, idempotencyKey: key.value }); releaseWarehouseKey('move', key.signature); playSuccessSound(); updateWarehouseWork('move', { sourcePalletCode: '', targetLocationCode: '', reason: '' }); this.source = null; this.target = null } catch (error) { this.setError(error, '整托移库失败'); if (isConflict(error)) { releaseWarehouseKey('move', key.signature); await this.refreshFacts(false); if (this.error) this.error.message += ',已刷新最新资源版本,请重新确认' } } finally { this.busy = false; this.busyAction = '' } },
|
||||
async submitMove() { if (!this.canMove || this.busy) return; const payload = { sourcePalletCode: this.source.palletCode, targetLocationCode: this.target.locationCode, expectedPalletVersion: Number(this.source.version || 0), reason: this.reason.trim() }; const key = warehouseIdempotencyKey('move', 'move', payload); this.busy = true; this.busyAction = 'move'; this.error = null; this.result = null; try { this.result = await moveInventory({ ...payload, idempotencyKey: key.value }); playSuccessSound(); updateWarehouseWork('move', { sourcePalletCode: '', targetLocationCode: '', reason: '' }); this.source = null; this.target = null } catch (error) { this.setError(error, '整托移库失败'); if (isConflict(error)) { await this.refreshFacts(false); if (this.error) this.error.message += ',已刷新最新资源版本,请重新确认' } } finally { this.busy = false; this.busyAction = '' } },
|
||||
async refreshFacts(clearError = true) { const retainedError = clearError ? null : this.error; const sourceCode = this.sourceCode; const targetCode = this.targetCode; this.source = null; this.target = null; try { if (sourceCode) this.source = await resolveWarehousePallet(sourceCode); if (targetCode) this.target = await resolveWarehouseLocation(targetCode); this.error = retainedError } catch (error) { this.setError(error, '最新托盘或库位状态读取失败') } },
|
||||
resetPage() { resetWarehouseDomain('move'); this.source = null; this.target = null; this.result = null; this.error = null; this.activeTarget = 'source' },
|
||||
setError(error, fallback) { this.error = { message: error?.message || fallback, code: error?.code, requestId: error?.requestId, traceId: error?.traceId }; playErrorSound() },
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
import PortalHeader from '@/components/PortalHeader.vue'
|
||||
import ScanTargetInput from '@/components/ScanTargetInput.vue'
|
||||
import { listCargoDiscardPhotos, uploadCargoDiscardPhoto, downloadCargoDiscardPhoto, completeOutboundTask, confirmCargoDiscard, getOutboundTask, listCargoDiscardTasks, listOutboundTasks, normalizeCargoDiscardTask, outboundPickPayload, pickOutboundPallet, pickOutboundTask, resolveCargoDiscardPallet, resolveOutboundTask } from '@/api/fulfillment.js'
|
||||
import { fulfillment, fulfillmentIdempotencyKey, releaseFulfillmentKey, restoreFulfillmentDraft, updateFulfillmentDraft } from '@/stores/fulfillment.js'
|
||||
import { fulfillment, fulfillmentIdempotencyKey, restoreFulfillmentDraft, updateFulfillmentDraft } from '@/stores/fulfillment.js'
|
||||
import { requireSession, session } from '@/stores/session.js'
|
||||
import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js'
|
||||
import { startScanBroadcast } from '@/utils/scan-broadcast.js'
|
||||
@@ -214,17 +214,17 @@
|
||||
async submitPick() {
|
||||
if (!this.canPick) return
|
||||
const payload = outboundPickPayload(this.selectedItem, this.selectedPosition, this.packages, this.selectedScanCode); const key = fulfillmentIdempotencyKey('outbound', 'pick', { taskNo: this.current.taskNo, ...payload })
|
||||
await this.run('提交出库拣货', async () => { await pickOutboundTask(this.current.taskNo, { ...payload, idempotencyKey: key.value }); this.current = await getOutboundTask(this.current.taskNo); releaseFulfillmentKey('outbound', key.signature); this.selectedItem = null; this.selectedPosition = null; this.selectedScanCode = ''; updateFulfillmentDraft('outbound', { scanCode: '', packages: 1 }); this.successMessage = '本次出库拣货已记录'; playSuccessSound() })
|
||||
await this.run('提交出库拣货', async () => { await pickOutboundTask(this.current.taskNo, { ...payload, idempotencyKey: key.value }); this.current = await getOutboundTask(this.current.taskNo); this.selectedItem = null; this.selectedPosition = null; this.selectedScanCode = ''; updateFulfillmentDraft('outbound', { scanCode: '', packages: 1 }); this.successMessage = '本次出库拣货已记录'; playSuccessSound() })
|
||||
},
|
||||
async submitPalletPick(palletNo) {
|
||||
const code = String(palletNo || '').trim(); if (!code || !this.taskOperable) return
|
||||
const key = fulfillmentIdempotencyKey('outbound', 'pallet-pick', { taskNo: this.current.taskNo, palletNo: code })
|
||||
await this.run('整托拣货', async () => { await pickOutboundPallet(this.current.taskNo, code, { idempotencyKey: key.value }); this.current = await getOutboundTask(this.current.taskNo); releaseFulfillmentKey('outbound', key.signature); this.selectedItem = null; this.selectedPosition = null; updateFulfillmentDraft('outbound', { scanCode: '', packages: 1 }); this.successMessage = `${code} 整托拣货已完成`; playSuccessSound() })
|
||||
await this.run('整托拣货', async () => { await pickOutboundPallet(this.current.taskNo, code, { idempotencyKey: key.value }); this.current = await getOutboundTask(this.current.taskNo); this.selectedItem = null; this.selectedPosition = null; updateFulfillmentDraft('outbound', { scanCode: '', packages: 1 }); this.successMessage = `${code} 整托拣货已完成`; playSuccessSound() })
|
||||
},
|
||||
async completeTask() {
|
||||
if (!this.canComplete) return
|
||||
const key = fulfillmentIdempotencyKey('outbound', 'complete', { taskNo: this.current.taskNo })
|
||||
await this.run('完成出库任务', async () => { await completeOutboundTask(this.current.taskNo, { idempotencyKey: key.value }); this.current = await getOutboundTask(this.current.taskNo); releaseFulfillmentKey('outbound', key.signature); this.successMessage = '出库任务已完成'; playSuccessSound() })
|
||||
await this.run('完成出库任务', async () => { await completeOutboundTask(this.current.taskNo, { idempotencyKey: key.value }); this.current = await getOutboundTask(this.current.taskNo); this.successMessage = '出库任务已完成'; playSuccessSound() })
|
||||
},
|
||||
async retry() { if (this.discardCurrent) { if (this.discardPalletCode) await this.resolveDiscardPallet(this.discardPalletCode); return } if (this.current?.taskNo) await this.openTask(this.current.taskNo); else if (fulfillment.outbound.currentTaskNo) await this.openTask(fulfillment.outbound.currentTaskNo); else if (this.scanCode) await this.openScannedTask(this.scanCode); else await this.loadTasks() },
|
||||
async run(action, work, loading = false) { if (this.busy || this.loading) return; this.error = null; this.successMessage = ''; if (loading) this.loading = true; else { this.busy = true; this.busyAction = action.includes('完成') ? 'complete' : 'pick' } try { await work() } catch (error) { this.setError(error, `${action}失败`) } finally { this.loading = false; this.busy = false; this.busyAction = '' } },
|
||||
|
||||
@@ -235,7 +235,7 @@
|
||||
async submitOperation() {
|
||||
if (!this.canSubmit) return
|
||||
this.submitting = true; this.error = null; this.result = null
|
||||
if (!this.pendingKey) this.pendingKey = idempotencyKey(this.mode)
|
||||
this.pendingKey = idempotencyKey(this.mode)
|
||||
try {
|
||||
const source = expectation(this.sources[0])
|
||||
const target = this.target ? expectation(this.target) : null
|
||||
|
||||
@@ -91,7 +91,7 @@
|
||||
import ScanTargetInput from '@/components/ScanTargetInput.vue'
|
||||
import { claimRelabelTask, completeRelabelTask, getRelabelTask, listRelabelCandidates, relabelUnit, reportRelabelException, reviewRelabelTask, skipRelabelUnit, uploadRelabelPhoto } from '@/api/relabel.js'
|
||||
import { session, requireSession } from '@/stores/session.js'
|
||||
import { releaseWarehouseKey, restoreWarehouseWork, updateWarehouseWork, warehouseIdempotencyKey, warehouseWork } from '@/stores/warehouse-work.js'
|
||||
import { restoreWarehouseWork, updateWarehouseWork, warehouseIdempotencyKey, warehouseWork } from '@/stores/warehouse-work.js'
|
||||
import { startScanBroadcast } from '@/utils/scan-broadcast.js'
|
||||
import { scanRelabelSku, scanRelabelCarton, completeReturnRelabelSku, listRelabelDocuments, downloadRelabelDocument, downloadRelabelPhoto } from '@/api/relabel.js'
|
||||
import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js'
|
||||
@@ -207,8 +207,7 @@
|
||||
const key = warehouseIdempotencyKey('relabel', 'sku-scan', { relabelNo: this.current.relabelNo, ...payload })
|
||||
await this.run('sku-scan', async () => {
|
||||
const value = await scanRelabelSku(this.current.relabelNo, { ...payload, idempotencyKey: key.value })
|
||||
this.applyCurrent(value); releaseWarehouseKey('relabel', key.signature)
|
||||
const confirmed = value.items.find(item => item.itemId === row.item.itemId)?.skuItems?.[row.index]
|
||||
this.applyCurrent(value); const confirmed = value.items.find(item => item.itemId === row.item.itemId)?.skuItems?.[row.index]
|
||||
this.lastSku = confirmed ? `${confirmed.skuCode} 换标 ${confirmed.newSkuCode} · ${confirmed.quantity} 件` : '已保存,请重新读取标签绑定信息'
|
||||
this.quickSku = ''; this.quickTargetLabel = ''; this.quickActualQuantity = ''; this.skuChoices = []; this.activeTarget = 'quick-sku'
|
||||
await new Promise(resolve => uni.showModal({ title: '请粘贴对应 SKU 标签', content: `${row.item.cartonMark}\n${this.lastSku}`, showCancel: false, confirmText: '知道了', complete: resolve }))
|
||||
@@ -246,7 +245,7 @@
|
||||
if (!Number.isSafeInteger(quantity) || quantity < 1 || quantity > remaining) return this.setError(new Error('本来源实际贴标数量必填,须为 1 至 ' + remaining + ' 件'))
|
||||
const payload = { itemId: item.itemId, cartonMark: item.cartonMark, skuCode: item.skuCode, quantity, expectedVersion: this.current.version }
|
||||
const key = warehouseIdempotencyKey('relabel', 'return-bulk-sku', { relabelNo: this.current.relabelNo, ...payload })
|
||||
await this.run('return-bulk-sku', async () => { this.applyCurrent(await completeReturnRelabelSku(this.current.relabelNo, { ...payload, idempotencyKey: key.value })); releaseWarehouseKey('relabel', key.signature); delete this.bulkQuantities[item.itemId]; this.successMessage = item.cartonMark + ' · ' + item.skuCode + ' 已登记 ' + quantity + ' 件' }, true, key)
|
||||
await this.run('return-bulk-sku', async () => { this.applyCurrent(await completeReturnRelabelSku(this.current.relabelNo, { ...payload, idempotencyKey: key.value })); delete this.bulkQuantities[item.itemId]; this.successMessage = item.cartonMark + ' · ' + item.skuCode + ' 已登记 ' + quantity + ' 件' }, true, key)
|
||||
},
|
||||
async loadPhoto(fileId) {
|
||||
const no = this.current?.relabelNo
|
||||
@@ -293,7 +292,6 @@
|
||||
await this.run('carton-scan', async () => {
|
||||
const result = await scanRelabelCarton(payload.relabelNo, { cartonMark: mark, expectedVersion: payload.expectedVersion, idempotencyKey: key.value })
|
||||
this.applyCurrent(result)
|
||||
releaseWarehouseKey('relabel', key.signature)
|
||||
const confirmedItem = result.items.find((row) => row.itemId === item.itemId)
|
||||
this.lastCarton = { cartonMark: mark, fbaCartonMark: confirmedItem?.fbaCartonMark || item.fbaCartonMark, destinationFbaWarehouseCode: confirmedItem?.destinationFbaWarehouseCode || item.destinationFbaWarehouseCode }
|
||||
this.quickCarton = ''
|
||||
@@ -318,7 +316,7 @@
|
||||
focusNextSku(index) { const next = index + 1; this.activeTarget = next < this.unitDraft.skuVerifications.length ? `sku-new-${next}` : 'old' },
|
||||
remaining(item) { const processed = this.taskTab === 'sku' ? item.skuProcessedPackages : item.cartonProcessedPackages; const completed = item.operationType === 'CARTON_AND_SKU' ? Number(processed || 0) : Number(processed ?? item.relabelledPackages); return Math.max(0, item.expectedPackages - completed - item.skippedPackages - item.exceptionPackages) },
|
||||
changeAction(value) { this.action = value; this.newLabel = ''; this.reason = ''; this.activeTarget = 'old' },
|
||||
async claimTask() { if (!this.current) return; const payload = { relabelNo: this.current.relabelNo }; const key = warehouseIdempotencyKey('relabel', 'claim', payload); await this.run('claim', async () => { this.applyCurrent(await claimRelabelTask(this.current.relabelNo, { idempotencyKey: key.value })); releaseWarehouseKey('relabel', key.signature); this.successMessage = '换标任务已认领,可进入换标执行'; await this.loadCandidates() }, true, key) },
|
||||
async claimTask() { if (!this.current) return; const payload = { relabelNo: this.current.relabelNo }; const key = warehouseIdempotencyKey('relabel', 'claim', payload); await this.run('claim', async () => { this.applyCurrent(await claimRelabelTask(this.current.relabelNo, { idempotencyKey: key.value })); this.successMessage = '换标任务已认领,可进入换标执行'; await this.loadCandidates() }, true, key) },
|
||||
async promptActiveTask(active) {
|
||||
if (this.busy) return
|
||||
this.busy = true
|
||||
@@ -360,20 +358,20 @@
|
||||
if (!confirmed) return
|
||||
this.taskCode = item.relabelNo
|
||||
const payload = { relabelNo: item.relabelNo }; const key = warehouseIdempotencyKey('relabel', 'claim', payload); let claimed = false
|
||||
await this.run('claim', async () => { this.applyCurrent(await claimRelabelTask(item.relabelNo, { idempotencyKey: key.value })); releaseWarehouseKey('relabel', key.signature); claimed = true }, true, key)
|
||||
await this.run('claim', async () => { this.applyCurrent(await claimRelabelTask(item.relabelNo, { idempotencyKey: key.value })); claimed = true }, true, key)
|
||||
if (claimed) uni.redirectTo({ url: `/pages/relabel/relabel?op=relabelExecute&task=${encodeURIComponent(item.relabelNo)}` })
|
||||
},
|
||||
async openReview(item) { if (this.busy || this.stage !== 'review' || item.taskStatus !== 'WAIT_REVIEW') return; await this.openTask(item.relabelNo) },
|
||||
async submitUnit() { if (!this.canSubmitUnit || this.busy) return; const base = { itemId: this.selectedItem.itemId, oldLabel: this.oldLabel.trim(), expectedVersion: this.current.version }; const payload = { relabelNo: this.current.relabelNo, action: this.action, ...base, newLabel: this.newLabel.trim(), skuVerifications: this.unitDraft.skuVerifications.map((sku) => ({ ...sku, skuCode: sku.skuCode.trim(), newSkuCode: sku.newSkuCode.trim() })), reason: this.reason.trim(), exceptionCode: this.exceptionCode }; const key = warehouseIdempotencyKey('relabel', 'unit', payload); await this.run('unit', async () => { let value; if (this.action === 'SKIP') value = await skipRelabelUnit(this.current.relabelNo, { ...base, reason: this.reason.trim(), idempotencyKey: key.value }); else if (this.action === 'EXCEPTION') value = await reportRelabelException(this.current.relabelNo, { ...base, exceptionCode: this.exceptionCode, reason: this.reason.trim(), idempotencyKey: key.value }); else value = await relabelUnit(this.current.relabelNo, { ...base, newLabel: this.newLabel.trim(), skuVerifications: payload.skuVerifications, idempotencyKey: key.value }); releaseWarehouseKey('relabel', key.signature); this.applyCurrent(value); this.oldLabel = ''; this.newLabel = ''; this.reason = ''; this.activeTarget = 'old'; this.successMessage = '本箱处理结果已保存' }, true, key) },
|
||||
async submitUnit() { if (!this.canSubmitUnit || this.busy) return; const base = { itemId: this.selectedItem.itemId, oldLabel: this.oldLabel.trim(), expectedVersion: this.current.version }; const payload = { relabelNo: this.current.relabelNo, action: this.action, ...base, newLabel: this.newLabel.trim(), skuVerifications: this.unitDraft.skuVerifications.map((sku) => ({ ...sku, skuCode: sku.skuCode.trim(), newSkuCode: sku.newSkuCode.trim() })), reason: this.reason.trim(), exceptionCode: this.exceptionCode }; const key = warehouseIdempotencyKey('relabel', 'unit', payload); await this.run('unit', async () => { let value; if (this.action === 'SKIP') value = await skipRelabelUnit(this.current.relabelNo, { ...base, reason: this.reason.trim(), idempotencyKey: key.value }); else if (this.action === 'EXCEPTION') value = await reportRelabelException(this.current.relabelNo, { ...base, exceptionCode: this.exceptionCode, reason: this.reason.trim(), idempotencyKey: key.value }); else value = await relabelUnit(this.current.relabelNo, { ...base, newLabel: this.newLabel.trim(), skuVerifications: payload.skuVerifications, idempotencyKey: key.value }); this.applyCurrent(value); this.oldLabel = ''; this.newLabel = ''; this.reason = ''; this.activeTarget = 'old'; this.successMessage = '本箱处理结果已保存' }, true, key) },
|
||||
choosePhoto() { if (this.busy || !this.current || this.photoFileIds.length >= 20) return; const no = this.current.relabelNo; uni.chooseImage({ count: Math.min(9, 20 - this.photoFileIds.length), sourceType: ['camera', 'album'], success: async ({ tempFilePaths, tempFiles }) => { if (this.current?.relabelNo !== no) return; await this.run('photo', async () => { for (const [index, path] of (tempFilePaths || []).entries()) { const selected = tempFiles?.[index]?.file || tempFiles?.[index]; if (Number(tempFiles?.[index]?.size || 0) > 10 * 1024 * 1024) throw new Error('照片不能超过 10MB'); const uploadSource = typeof Blob !== 'undefined' && selected instanceof Blob ? selected : path; const file = await uploadRelabelPhoto(no, uploadSource); this.photoUrls[String(file.fileId)] = path; updateWarehouseWork('relabel', { photoTaskNo: no, photoFileIds: [...this.photoFileIds, String(file.fileId)], photoNames: [...this.photoNames, file.fileName || '换标现场照片'] }) } this.successMessage = '现场照片已上传' }, false) } }) },
|
||||
async uploadPhoto(path) { await this.run('photo', async () => { const file = await uploadRelabelPhoto(this.current.relabelNo, path); updateWarehouseWork('relabel', { photoFileIds: [...this.photoFileIds, String(file.fileId)], photoNames: [...this.photoNames, file.fileName || '换标现场照片'] }); this.successMessage = '现场照片已上传' }, false) },
|
||||
async completeTask() { if (this.busy || this.completionBlockReason) return; const payload = { photoFileIds: [...this.photoFileIds], expectedVersion: this.current.version, remark: this.remark.trim(), manualCompletion: !this.allProcessed }; const no = this.current.relabelNo; if (payload.manualCompletion) { const confirmed = await new Promise((resolve) => uni.showModal({ title: '手动完成换标', content: '未处理箱将登记异常,关联尾程订单将标为异常。确认提交?', success: (result) => resolve(result.confirm), fail: () => resolve(false) })); if (!confirmed || this.current?.relabelNo !== no || this.current.version !== payload.expectedVersion) return } const key = warehouseIdempotencyKey('relabel', 'complete', { relabelNo: no, ...payload }); let completed = false; await this.run('complete', async () => { await completeRelabelTask(no, { ...payload, idempotencyKey: key.value }); releaseWarehouseKey('relabel', key.signature); updateWarehouseWork('relabel', { currentNo: '', photoFileIds: [], photoNames: [], remark: '' }); completed = true }, true, key); if (completed) this.goToClaimList() },
|
||||
async completeTask() { if (this.busy || this.completionBlockReason) return; const payload = { photoFileIds: [...this.photoFileIds], expectedVersion: this.current.version, remark: this.remark.trim(), manualCompletion: !this.allProcessed }; const no = this.current.relabelNo; if (payload.manualCompletion) { const confirmed = await new Promise((resolve) => uni.showModal({ title: '手动完成换标', content: '未处理箱将登记异常,关联尾程订单将标为异常。确认提交?', success: (result) => resolve(result.confirm), fail: () => resolve(false) })); if (!confirmed || this.current?.relabelNo !== no || this.current.version !== payload.expectedVersion) return } const key = warehouseIdempotencyKey('relabel', 'complete', { relabelNo: no, ...payload }); let completed = false; await this.run('complete', async () => { await completeRelabelTask(no, { ...payload, idempotencyKey: key.value }); updateWarehouseWork('relabel', { currentNo: '', photoFileIds: [], photoNames: [], remark: '' }); completed = true }, true, key); if (completed) this.goToClaimList() },
|
||||
goToClaimList() { this.taskCode = ''; uni.redirectTo({ url: '/pages/relabel/relabel?op=relabelReceive' }) },
|
||||
closeCurrent() { this.current = null; this.selectedItem = null; this.error = null; this.successMessage = ''; this.activeTarget = 'task'; this.taskCode = '' },
|
||||
async review(decision) { if (!this.current || this.current.taskStatus !== 'WAIT_REVIEW') return; const payload = { relabelNo: this.current.relabelNo, decision, expectedVersion: this.current.version, remark: this.remark.trim() }; const key = warehouseIdempotencyKey('relabel', 'review', payload); await this.run('review', async () => { this.applyCurrent(await reviewRelabelTask(this.current.relabelNo, { decision, expectedVersion: this.current.version, idempotencyKey: key.value, remark: this.remark.trim() || undefined })); releaseWarehouseKey('relabel', key.signature); this.remark = ''; this.successMessage = decision === 'APPROVE' ? '换标复核已通过' : '换标任务已退回重做'; await this.loadCandidates() }, true, key) },
|
||||
async review(decision) { if (!this.current || this.current.taskStatus !== 'WAIT_REVIEW') return; const payload = { relabelNo: this.current.relabelNo, decision, expectedVersion: this.current.version, remark: this.remark.trim() }; const key = warehouseIdempotencyKey('relabel', 'review', payload); await this.run('review', async () => { this.applyCurrent(await reviewRelabelTask(this.current.relabelNo, { decision, expectedVersion: this.current.version, idempotencyKey: key.value, remark: this.remark.trim() || undefined })); this.remark = ''; this.successMessage = decision === 'APPROVE' ? '换标复核已通过' : '换标任务已退回重做'; await this.loadCandidates() }, true, key) },
|
||||
async retry() { if (this.current?.relabelNo) await this.openTask(this.current.relabelNo, false); else await this.loadCandidates() },
|
||||
async reloadCurrent() { if (!this.current?.relabelNo) return; this.verifiedReturnCarton = ''; this.returnCartonCode = ''; this.returnTargetLabels = {}; this.bulkQuantities = {}; this.quickTargetLabel = ''; this.quickActualQuantity = ''; this.applyCurrent(await getRelabelTask(this.current.relabelNo)) },
|
||||
async run(action, work, refreshOnConflict = false, key = null) { if (this.busy) return; this.busy = true; this.busyAction = action; this.error = null; this.successMessage = ''; try { await work(); if (!['detail'].includes(action)) playSuccessSound() } catch (error) { this.setError(error, `${this.title}失败`); if (refreshOnConflict && isConflict(error) && !isRelabelFundsBlocked(error)) { if (key) releaseWarehouseKey('relabel', key.signature); try { await this.reloadCurrent(); this.error.message += ',已刷新任务版本,请重新确认' } catch (_) {} } } finally { this.busy = false; this.busyAction = '' } },
|
||||
async run(action, work, refreshOnConflict = false, key = null) { if (this.busy) return; this.busy = true; this.busyAction = action; this.error = null; this.successMessage = ''; try { await work(); if (!['detail'].includes(action)) playSuccessSound() } catch (error) { this.setError(error, `${this.title}失败`); if (refreshOnConflict && isConflict(error) && !isRelabelFundsBlocked(error)) { try { await this.reloadCurrent(); this.error.message += ',已刷新任务版本,请重新确认' } catch (_) {} } } finally { this.busy = false; this.busyAction = '' } },
|
||||
setError(error, fallback = '换标作业失败') {
|
||||
this.error = { message: relabelErrorMessage(error, fallback), code: error?.code, requestId: error?.requestId, traceId: error?.traceId }
|
||||
playErrorSound()
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
import { completePackingPlan, findPackingOrders, listPackingPlans, getPackingPlan, getReturnCartonLabel, scanReturnCarton, packReturnSkus } from '@/api/return-packing.js'
|
||||
import { defaultPackingBoxes, packingBoxPayload, validPackingAllocation } from '@/utils/return-packing-allocation.js'
|
||||
import { session, requireSession } from '@/stores/session.js'
|
||||
import { releaseWarehouseKey, resetWarehouseDomain, restoreWarehouseWork, updateWarehouseWork, warehouseIdempotencyKey, warehouseWork } from '@/stores/warehouse-work.js'
|
||||
import { resetWarehouseDomain, restoreWarehouseWork, updateWarehouseWork, warehouseIdempotencyKey, warehouseWork } from '@/stores/warehouse-work.js'
|
||||
import { startScanBroadcast } from '@/utils/scan-broadcast.js'
|
||||
import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js'
|
||||
|
||||
@@ -108,7 +108,7 @@
|
||||
const key = warehouseIdempotencyKey('returnPacking', 'pack', { ...payload, planNo: this.activePlan?.planNo })
|
||||
await this.run(async () => {
|
||||
const value = this.activePlan ? await completePackingPlan(this.activePlan.planNo, { boxes: payload.boxes, idempotencyKey: key.value }) : await packReturnSkus({ ...payload, idempotencyKey: key.value })
|
||||
this.packingCompleted(value); releaseWarehouseKey('returnPacking', key.signature); await this.loadPlans()
|
||||
this.packingCompleted(value); await this.loadPlans()
|
||||
}, '退货合箱失败')
|
||||
},
|
||||
distributeBoxes() { updateWarehouseWork('returnPacking', { targetBoxes: defaultPackingBoxes(Object.entries(this.packing.quantities || {}).map(([skuCode,quantity]) => ({skuCode,quantity}))) }) },
|
||||
|
||||
@@ -2,20 +2,18 @@
|
||||
<view v-if="user" class="page-shell portal-page work-page">
|
||||
<PortalHeader title="退货入库" :user="user" />
|
||||
<scroll-view class="work-scroll" scroll-y><view class="portal-content work-content">
|
||||
<view v-if="!current && !result && !receiptBatch" class="work-card">
|
||||
<view v-if="!current && !result" class="work-card">
|
||||
<text class="card-title">扫描 SKU 69 码</text>
|
||||
<ScanTargetInput name="lookup" v-model="lookupCode" label="SKU 69 码" placeholder="扫描商品 69 码,自动带出退货单号" :active="activeTarget === 'lookup'" @activate="activeTarget = 'lookup'" @submit="lookup" />
|
||||
|
||||
</view>
|
||||
|
||||
<view v-if="!current && candidates !== null && !result && !receiptBatch" class="section-gap">
|
||||
<view v-if="!current && candidates !== null && !result" class="section-gap">
|
||||
<view class="row-head candidate-heading"><text class="card-title">匹配退货单</text><text class="meta-line">{{ candidates.length }} 个候选</text></view>
|
||||
<view v-if="!candidates.length" class="empty-box"><text class="empty-copy">{{ entryMode === 'SKU' ? '当前绑定仓没有包含该 SKU 的待签收退货单' : '当前绑定仓没有匹配的待签收或签收中退货单' }}</text></view>
|
||||
<view v-else class="task-list"><button v-for="item in candidates" :key="item.returnNo" class="task-card candidate-card" @tap="selectCandidate(item)"><view class="row-head"><text class="business-code">{{ item.returnNo }}</text><text class="status-pill">{{ statusLabel(item.status) }}</text></view><text class="meta-line">客户参考号:{{ item.customerReferenceNo || '—' }}</text><view class="metric-row"><view class="metric"><strong>{{ item.skuReceivedQuantity }}/{{ item.skuExpectedQuantity }}</strong><text>该 SKU</text></view><view class="metric"><strong>{{ item.orderReceivedQuantity }}/{{ item.orderExpectedQuantity }}</strong><text>整单进度</text></view><text class="select-link">选择 ›</text></view></button></view>
|
||||
</view>
|
||||
|
||||
<view v-if="receiptBatch && !result" class="work-card"><text class="card-title">待完成{{ receiptBatch.unitType === 'PARCEL' ? '包裹' : '按箱' }}签收</text><text class="business-code">{{ receiptBatch.returnNo }}</text><text class="hint">已确认 {{ receiptBatch.entries.filter(entry => entry.result).length }} / {{ receiptBatch.entries.length }} {{ receiptBatch.unitType === 'PARCEL' ? '包裹' : '箱' }}。请继续本批签收,已确认的包裹/箱不会重新入库;本批内容暂不可修改。</text><button class="primary" :disabled="busy" @tap="confirmReceipt">{{ busy ? '提交中…' : '继续本批签收' }}</button></view>
|
||||
<template v-if="current && !result && !receiptBatch">
|
||||
<template v-if="current && !result">
|
||||
<view class="work-card receipt-order-card"><view class="row-head"><text class="card-title">退货单</text><button class="link-button" @tap="clearCurrent">更换退货单</button></view><view class="receipt-order-line"><text class="business-code">{{ current.returnNo }}</text><text class="status-pill">{{ statusLabel(current.status) }}</text></view></view>
|
||||
|
||||
<view class="work-card">
|
||||
@@ -55,7 +53,7 @@
|
||||
|
||||
<view v-if="result" class="success-box result-card"><text class="success-title">退货签收成功</text><view v-if="result.labels?.length"><view v-for="(label,index) in result.labels" :key="label.cartonMark" class="item-card"><text class="business-code">第 {{ index + 1 }} {{ result.unitType === 'PARCEL' ? '包裹' : '箱' }} · {{ label.cartonMark }}</text><button class="secondary" @tap="printReturnCarton(label)">打印包裹/箱标签</button><text class="meta-line">签收已默认贴标</text></view></view><view v-else-if="result.printableHtml" class="item-card"><text class="business-code">{{ result.unitType === 'PALLET' ? '托盘标签' : '箱唛' }} · {{ result.cartonMark }}</text><button class="secondary" @tap="printReturnCarton(result)">打印{{ result.unitType === 'PALLET' ? '托盘标签' : '箱唛' }}</button></view><button v-if="result.unitType === 'PALLET' && result.labels?.length" class="secondary" :disabled="busy" @tap="goCount">去箱内清点</button><text v-if="result.palletCode" class="result-carton">托盘:{{ result.palletCode }}</text><text class="meta-line">签收单:{{ result.receiptNo }}</text><text class="meta-line">计费批次:{{ result.receiptBatchNo }}</text><view class="metric-row"><view class="metric"><strong>{{ result.receivedQuantity }}/{{ result.expectedQuantity }}</strong><text>整单累计</text></view><view class="metric"><strong>{{ result.fullyMatched ? '已完成' : '签收中' }}</strong><text>退货单状态</text></view></view><button class="primary" :disabled="busy" @tap="nextReceipt">{{ result.continuationReturnNo ? (unitType === 'PALLET' ? '新增托盘,继续签收剩余货物' : '继续签收本单剩余货物') : '签收下一单' }}</button><button v-if="unitType === 'PALLET' && result.continuationReturnNo" class="secondary" :disabled="busy" @tap="continueScanning">继续签收</button></view>
|
||||
|
||||
<view v-if="error" class="error-box" role="alert"><text class="error-title">{{ error.message }}</text><text v-if="error.code">错误码:{{ error.code }}</text><text v-if="error.requestId">requestId:{{ error.requestId }}</text><text v-if="error.traceId">traceId:{{ error.traceId }}</text><button v-if="current && !receiptBatch" class="secondary" :disabled="busy" @tap="reloadCurrent">刷新退货单</button></view>
|
||||
<view v-if="error" class="error-box" role="alert"><text class="error-title">{{ error.message }}</text><text v-if="error.code">错误码:{{ error.code }}</text><text v-if="error.requestId">requestId:{{ error.requestId }}</text><text v-if="error.traceId">traceId:{{ error.traceId }}</text><button v-if="current" class="secondary" :disabled="busy" @tap="reloadCurrent">刷新退货单</button></view>
|
||||
</view></scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
@@ -71,9 +69,8 @@
|
||||
|
||||
export default {
|
||||
components: { PortalHeader, ScanTargetInput },
|
||||
data() { return { user: session.currentUser, current: null, candidates: null, result: null, selectedSku: '', palletCheck: null, newPalletHtml: '', unitType: 'PALLET', unitCode: '', boxRows: [{ items: {}, length: '', width: '', height: '', weight: '', originalPackaging: true, confirmed: false }], palletLength: '', palletWidth: '', palletHeight: '', palletWeight: '', unitOptions: [{ value: 'PALLET', label: '托盘' }, { value: 'BOX', label: '箱子' }, { value: 'PARCEL', label: '包裹' }], photoFileIds: [], photoNames: [], photoUrls: {}, activeTarget: 'lookup', busy: false, error: null } },
|
||||
data() { return { user: session.currentUser, current: null, candidates: null, result: null, receiptResults: [], selectedSku: '', palletCheck: null, newPalletHtml: '', unitType: 'PALLET', unitCode: '', boxRows: [{ items: {}, length: '', width: '', height: '', weight: '', originalPackaging: true, confirmed: false }], palletLength: '', palletWidth: '', palletHeight: '', palletWeight: '', unitOptions: [{ value: 'PALLET', label: '托盘' }, { value: 'BOX', label: '箱子' }, { value: 'PARCEL', label: '包裹' }], photoFileIds: [], photoNames: [], photoUrls: {}, activeTarget: 'lookup', busy: false, error: null } },
|
||||
computed: {
|
||||
receiptBatch() { return warehouseWork.returnReceipt.pendingBatch },
|
||||
entryMode: { get() { return warehouseWork.returnReceipt.entryMode }, set(value) { updateWarehouseWork('returnReceipt', { entryMode: value }) } },
|
||||
lookupCode: { get() { return warehouseWork.returnReceipt.lookupCode }, set(value) { updateWarehouseWork('returnReceipt', { lookupCode: value }) } },
|
||||
startedBy: { get() { return warehouseWork.returnReceipt.startedBy }, set(value) { updateWarehouseWork('returnReceipt', { startedBy: value }) } },
|
||||
@@ -89,7 +86,7 @@
|
||||
unitPlaceholder() { return this.unitType === 'PALLET' ? '扫描托盘标签' : this.unitType === 'BOX' ? '扫描箱唛或录入箱号' : '扫描包裹面单或录入包裹码' }
|
||||
,palletVolume() { const l = Number(this.palletLength) || 0; const w = Number(this.palletWidth) || 0; const h = Number(this.palletHeight) || 0; return l > 0 && w > 0 && h > 0 ? (l * w * h).toFixed(2) : '—' }
|
||||
},
|
||||
onShow() { if (!requireSession()) return; this.user = session.currentUser; restoreWarehouseWork(`${this.user.id}:${this.user.warehouseId}`); this.entryMode = 'SKU'; this.startScanner(); if (!this.receiptBatch && warehouseWork.returnReceipt.currentReturnNo && !this.current && !this.busy) this.openOrder(warehouseWork.returnReceipt.currentReturnNo, 'SKU', false) },
|
||||
onShow() { if (!requireSession()) return; this.user = session.currentUser; restoreWarehouseWork(`${this.user.id}:${this.user.warehouseId}`); this.entryMode = 'SKU'; this.startScanner(); if (warehouseWork.returnReceipt.currentReturnNo && !this.current && !this.busy) this.openOrder(warehouseWork.returnReceipt.currentReturnNo, 'SKU', false) },
|
||||
onHide() { this.stopScanner() }, onUnload() { this.stopScanner() }, beforeUnmount() { this.stopScanner() },
|
||||
methods: {
|
||||
goCount() { const mark = this.result?.labels?.[0]?.cartonMark; if (mark) uni.navigateTo({ url: '/pages/return-counting/return-counting?cartonMark=' + encodeURIComponent(mark) }) },
|
||||
@@ -114,12 +111,12 @@
|
||||
acceptUnit(raw) { const code = String(raw?.detail?.value ?? raw ?? '').trim(); if (!code) return; this.unitCode = code; if (this.unitType !== 'PARCEL') this.palletCode = `${this.unitType}:${code}`; this.activeTarget = this.unitType === 'PARCEL' ? 'unit' : 'tracking'; playSuccessSound() },
|
||||
startScanner() { this.stopScanner(); this._stopScanner = startScanBroadcast({ onScan: (value) => this.handleScan(value), onError: (error) => this.setError(error, '扫码广播注册失败') }) },
|
||||
stopScanner() { if (this._stopScanner) this._stopScanner(); this._stopScanner = null },
|
||||
handleScan(value) { if (this.result) return; if (this.busy || this.receiptBatch) return; if (!this.current) { this.lookupCode = value; this.lookup(value); return } if (this.activeTarget === 'unit') this.acceptUnit(value); else this.acceptTracking(value) },
|
||||
handleScan(value) { if (this.result) return; if (this.busy) return; if (!this.current) { this.lookupCode = value; this.lookup(value); return } if (this.activeTarget === 'unit') this.acceptUnit(value); else this.acceptTracking(value) },
|
||||
switchMode(mode) { if (this.busy) return; this.entryMode = mode; this.lookupCode = ''; this.candidates = null; this.error = null; this.activeTarget = 'lookup' },
|
||||
async lookup(raw) { const value = String(raw?.detail?.value ?? raw ?? this.lookupCode).trim(); if (!value || this.busy) return; this.lookupCode = value; this.error = null; this.result = null; if (this.entryMode === 'SKU') await this.run(async () => { const response = await findReturnSkuCandidates(value); this.candidates = response.candidates; this.selectedSku = response.skuCode; if (this.candidates.length === 1) await this.openOrderValue(this.candidates[0].returnNo, 'SKU'); else if (this.candidates.length) playSuccessSound() }, '查询 SKU 候选失败'); else await this.openOrder(value, 'RETURN_NO') },
|
||||
async selectCandidate(item) { if (!item?.returnNo || this.busy) return; this.selectedSku = this.lookupCode; await this.openOrder(item.returnNo, 'SKU') },
|
||||
async openOrder(returnNo, startedBy, feedback = true) { await this.run(async () => { await this.openOrderValue(returnNo, startedBy, feedback) }, '读取退货单失败') },
|
||||
async openOrderValue(returnNo, startedBy, feedback = true) { const value = await startReturnReceipt(returnNo); if (!value) { this.current = null; this.candidates = []; updateWarehouseWork('returnReceipt', { currentReturnNo: '', quantities: {}, boxCounts: {} }); this.activeTarget = 'lookup'; return } this.current = value; this.palletCheck = null; this.candidates = null; this.startedBy = startedBy; this.photoFileIds = (value.photos || []).map((item) => String(item.fileId)); this.photoNames = (value.photos || []).map((item) => item.fileName || '退货打托照片'); this.photoUrls = {}; await Promise.all(this.photoFileIds.map(async (fileId) => { try { this.photoUrls[fileId] = await downloadReturnPalletPhoto(value.returnNo, fileId) } catch (_) { /* 单张照片失败不阻断作业 */ } })); updateWarehouseWork('returnReceipt', { currentReturnNo: value.returnNo, startedBy, lookupCode: value.returnNo, palletCode: '', quantities: this.validQuantities(value), boxCounts: {} }); this.activeTarget = 'pallet'; if (feedback) playSuccessSound() },
|
||||
async openOrderValue(returnNo, startedBy, feedback = true) { const previous = this.current; const value = await startReturnReceipt(returnNo); if (!value) { this.resetReceiptInput(); this.current = null; this.candidates = []; updateWarehouseWork('returnReceipt', { currentReturnNo: '', quantities: {}, boxCounts: {} }); this.activeTarget = 'lookup'; return } if (!previous || previous.returnNo !== value.returnNo || JSON.stringify(previous.expectedItems) !== JSON.stringify(value.expectedItems)) this.resetReceiptInput(); this.current = value; this.palletCheck = null; this.candidates = null; this.startedBy = startedBy; this.photoFileIds = (value.photos || []).map((item) => String(item.fileId)); this.photoNames = (value.photos || []).map((item) => item.fileName || '退货打托照片'); this.photoUrls = {}; await Promise.all(this.photoFileIds.map(async (fileId) => { try { this.photoUrls[fileId] = await downloadReturnPalletPhoto(value.returnNo, fileId) } catch (_) { /* 单张照片失败不阻断作业 */ } })); updateWarehouseWork('returnReceipt', { currentReturnNo: value.returnNo, startedBy, lookupCode: value.returnNo, palletCode: '', quantities: this.validQuantities(value), boxCounts: {} }); this.activeTarget = 'pallet'; if (feedback) playSuccessSound() },
|
||||
validQuantities(order) { const source = warehouseWork.returnReceipt.quantities || {}; return Object.fromEntries(order.expectedItems.map((item) => [item.skuCode, Math.min(Math.max(0, Number(source[item.skuCode]) || 0), this.remaining(item))])) },
|
||||
quantity(skuCode) { return Math.max(0, Number(warehouseWork.returnReceipt.quantities?.[skuCode]) || 0) },
|
||||
boxCount(skuCode) { return Math.max(0, Number(warehouseWork.returnReceipt.boxCounts?.[skuCode]) || 0) },
|
||||
@@ -132,39 +129,35 @@
|
||||
printPallet() { if (!this.newPalletHtml) return; /* #ifdef H5 */ const view = window.open('', '_blank'); if (view) { view.document.write(this.newPalletHtml); view.document.close(); view.focus(); view.print() } else this.setError(new Error('请允许打开标签打印窗口'), '打印失败'); /* #endif */ /* #ifndef H5 */ this.setError(new Error('当前设备未配置打印服务'), '打印失败'); /* #endif */ },
|
||||
printReturnCarton(label) { if (!label?.printableHtml) return; /* #ifdef H5 */ const view = window.open('', '_blank'); if (view) { view.document.write(label.printableHtml); view.document.close(); view.focus(); view.print() } else this.setError(new Error('请允许打开标签打印窗口'), '打印失败'); /* #endif */ /* #ifndef H5 */ this.setError(new Error('当前设备未配置打印服务'), '打印失败'); /* #endif */ },
|
||||
acceptTracking(raw) { this.trackingNo = String(raw?.detail?.value ?? raw ?? '').trim(); playSuccessSound() },
|
||||
async confirmReceipt() { if (this.busy) return; if (this.receiptBatch) { await this.run(() => this.resumeReceiptBatch(), '退货入库失败'); return } const totalBoxCount = this.palletBoxCount; if (this.unitType === 'PALLET' && totalBoxCount <= 0) { this.setError(new Error('请先填写各 SKU 的箱数'), '托盘签收校验失败'); return } if (!this.canConfirm || this.busy || (this.unitType !== 'PALLET' && !this.allBoxRowsConfirmed)) return; if (this.unitType !== 'PALLET' && !this.photoFileIds.length) { this.setError(new Error('请先拍照或上传签收照片,再确认签收入库'), '签收校验失败'); return } const willComplete = this.current.expectedItems.every((item) => (this.unitType !== 'PALLET' ? this.boxRows.reduce((sum, box) => sum + (Number(box.items[item.skuCode]) || 0), 0) : this.quantity(item.skuCode)) >= this.remaining(item)); if (willComplete && !this.photoFileIds.length) { const modal = await new Promise((resolve) => uni.showModal({ title: '缺少退货打托照片', content: '本次签收将完成整单入库,请先拍照并上传后再确认打托。', confirmText: '去拍照', cancelText: '取消', success: resolve })); if (modal?.confirm) this.error = { message: '请先拍照并上传退货打托照片后再确认' }; return }
|
||||
async confirmReceipt() { if (this.busy) return; const totalBoxCount = this.palletBoxCount; if (this.unitType === 'PALLET' && totalBoxCount <= 0) { this.setError(new Error('请先填写各 SKU 的箱数'), '托盘签收校验失败'); return } if (!this.canConfirm || this.busy || (this.unitType !== 'PALLET' && !this.allBoxRowsConfirmed)) return; if (this.unitType !== 'PALLET' && !this.photoFileIds.length) { this.setError(new Error('请先拍照或上传签收照片,再确认签收入库'), '签收校验失败'); return } const willComplete = this.current.expectedItems.every((item) => (this.unitType !== 'PALLET' ? this.boxRows.reduce((sum, box) => sum + (Number(box.items[item.skuCode]) || 0), 0) : this.quantity(item.skuCode)) >= this.remaining(item)); if (willComplete && !this.photoFileIds.length) { const modal = await new Promise((resolve) => uni.showModal({ title: '缺少退货打托照片', content: '本次签收将完成整单入库,请先拍照并上传后再确认打托。', confirmText: '去拍照', cancelText: '取消', success: resolve })); if (modal?.confirm) this.error = { message: '请先拍照并上传退货打托照片后再确认' }; return }
|
||||
await this.run(async () => {
|
||||
const rows = this.unitType !== 'PALLET' ? this.boxRows : [null]
|
||||
let expectedReceivedQuantity = this.current.expectedItems.reduce((sum, item) => sum + Number(item.receivedQuantity || 0), 0)
|
||||
const entries = rows.map((box, index) => {
|
||||
const payload = { returnNo: this.current.returnNo, trackingNo: (this.unitType === 'PARCEL' ? this.unitCode.trim() : this.trackingNo.trim()) || undefined, startedBy: this.startedBy, unitType: this.unitType, unitCode: this.unitType === 'PARCEL' || this.unitType === 'BOX' ? undefined : undefined, palletCode: this.unitType === 'PALLET' || this.unitType === 'BOX' ? this.palletCode.trim() || undefined : undefined, length: this.unitType === 'PALLET' ? Number(this.palletLength) : Number(box.length), width: this.unitType === 'PALLET' ? Number(this.palletWidth) : Number(box.width), height: this.unitType === 'PALLET' ? Number(this.palletHeight) : Number(box.height), weight: this.unitType === 'PALLET' ? Number(this.palletWeight) : Number(box.weight), originalPackaging: this.unitType === 'PALLET' ? undefined : box.originalPackaging, totalBoxCount: this.unitType === 'PALLET' ? totalBoxCount : undefined, items: (this.unitType !== 'PALLET' ? this.selectedItems.map((item) => ({ skuCode: item.skuCode, receivedQuantity: Number(box.items[item.skuCode]) || 0, boxCount: this.unitType === 'BOX' ? 1 : undefined })) : this.selectedItems.map((item) => ({ skuCode: item.skuCode, receivedQuantity: this.quantity(item.skuCode), boxCount: this.boxCount(item.skuCode) }))).filter((item) => item.receivedQuantity > 0) }
|
||||
payload.expectedReceivedQuantity = expectedReceivedQuantity
|
||||
expectedReceivedQuantity += payload.items.reduce((sum, item) => sum + item.receivedQuantity, 0)
|
||||
const key = warehouseIdempotencyKey('returnReceipt', `confirm-${index}`, payload)
|
||||
return { request: { ...payload, idempotencyKey: key.value }, result: null }
|
||||
})
|
||||
if (this.unitType !== 'PALLET') {
|
||||
updateWarehouseWork('returnReceipt', { pendingBatch: JSON.parse(JSON.stringify({ returnNo: this.current.returnNo, unitType: this.unitType, entries })) })
|
||||
await this.resumeReceiptBatch()
|
||||
} else {
|
||||
this.finishReceipt([await confirmReturnReceipt(entries[0].request)])
|
||||
}
|
||||
}, '退货入库失败')
|
||||
},
|
||||
async resumeReceiptBatch() {
|
||||
const batch = this.receiptBatch
|
||||
const scopeKey = warehouseWork.scopeKey
|
||||
if (!batch) return
|
||||
for (let index = 0; index < batch.entries.length; index++) {
|
||||
if (warehouseWork.scopeKey !== scopeKey) throw new Error('作业账号或仓库已改变,请重新进入作业')
|
||||
if (batch.entries[index].result) continue
|
||||
// Persist the immutable request before every attempt; storage failures must stop submission.
|
||||
updateWarehouseWork('returnReceipt', { pendingBatch: batch })
|
||||
const result = await confirmReturnReceipt(batch.entries[index].request)
|
||||
if (warehouseWork.scopeKey !== scopeKey) throw new Error('作业账号或仓库已改变,请重新进入作业')
|
||||
if (!result) throw new Error('服务端未返回签收结果,请重试本批签收')
|
||||
batch.entries[index].result = result
|
||||
updateWarehouseWork('returnReceipt', { pendingBatch: batch })
|
||||
}
|
||||
this.finishReceipt(batch.entries.map(entry => entry.result))
|
||||
},
|
||||
const scopeKey = warehouseWork.scopeKey
|
||||
for (const entry of entries) {
|
||||
const result = await confirmReturnReceipt(entry.request)
|
||||
if (warehouseWork.scopeKey !== scopeKey) throw new Error('作业账号或仓库已改变,请重新进入作业')
|
||||
if (!result) throw new Error('服务端未返回签收结果')
|
||||
this.receiptResults.push(result)
|
||||
if (this.unitType !== 'PALLET') {
|
||||
this.boxRows.shift()
|
||||
for (const received of entry.request.items) {
|
||||
const item = this.current.expectedItems.find(item => item.skuCode === received.skuCode)
|
||||
if (item) item.receivedQuantity = Number(item.receivedQuantity || 0) + received.receivedQuantity
|
||||
}
|
||||
}
|
||||
}
|
||||
this.finishReceipt(this.receiptResults)
|
||||
this.receiptResults = []
|
||||
}, '退货入库失败')
|
||||
},
|
||||
finishReceipt(results) {
|
||||
const last = results[results.length - 1]
|
||||
this.result = { ...last, continuationReturnNo: !last.fullyMatched ? this.current?.returnNo || '' : '', labels: results.flatMap(item => item.labels?.length ? item.labels : item.printableHtml && item.cartonMark ? [{ cartonMark: item.cartonMark, printableHtml: item.printableHtml }] : []) }
|
||||
@@ -193,10 +186,17 @@
|
||||
},
|
||||
previewPhoto(fileId) { const urls = this.photoFileIds.map(id => this.photoUrls[id]).filter(Boolean); if (this.photoUrls[fileId]) uni.previewImage({ current: this.photoUrls[fileId], urls }) },
|
||||
removePhoto(index) { const [id] = this.photoFileIds.splice(index, 1); this.photoNames.splice(index, 1); if (id) delete this.photoUrls[id] },
|
||||
// 服务端实收变化后重新录入下一份实物,不能给旧草稿换上新库存快照。
|
||||
resetReceiptInput() {
|
||||
this.boxRows = [{ items: {}, length: '', width: '', height: '', weight: '', originalPackaging: true, confirmed: false }]
|
||||
this.unitCode = ''; this.palletLength = ''; this.palletWidth = ''; this.palletHeight = ''; this.palletWeight = ''
|
||||
this.palletCheck = null; this.newPalletHtml = ''
|
||||
updateWarehouseWork('returnReceipt', { quantities: {}, boxCounts: {}, palletCode: '', trackingNo: '' })
|
||||
},
|
||||
async reloadCurrent() { if (!this.current?.returnNo || this.busy) return; await this.openOrder(this.current.returnNo, this.startedBy, false) },
|
||||
clearCurrent() { if (this.busy || this.receiptBatch) return; resetWarehouseDomain('returnReceipt'); this.current = null; this.candidates = null; this.error = null; this.activeTarget = 'lookup' },
|
||||
clearCurrent() { if (this.busy) return; this.receiptResults = []; resetWarehouseDomain('returnReceipt'); this.current = null; this.candidates = null; this.error = null; this.activeTarget = 'lookup' },
|
||||
continueScanning() {
|
||||
if (this.busy || this.receiptBatch) return
|
||||
if (this.busy) return
|
||||
this.clearCurrent()
|
||||
this.result = null
|
||||
this.entryMode = 'SKU'; this.lookupCode = ''; this.selectedSku = ''
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
import ScanTargetInput from '@/components/ScanTargetInput.vue'
|
||||
import { countStocktake, createStocktake, getStocktake, listStocktakes, submitStocktake } from '@/api/warehouse-operations.js'
|
||||
import { session, requireSession } from '@/stores/session.js'
|
||||
import { releaseWarehouseKey, restoreWarehouseWork, updateWarehouseWork, warehouseIdempotencyKey, warehouseWork } from '@/stores/warehouse-work.js'
|
||||
import { restoreWarehouseWork, updateWarehouseWork, warehouseIdempotencyKey, warehouseWork } from '@/stores/warehouse-work.js'
|
||||
import { startScanBroadcast } from '@/utils/scan-broadcast.js'
|
||||
import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js'
|
||||
|
||||
@@ -61,15 +61,15 @@
|
||||
async bootstrap() { await this.loadTasks(); const saved = warehouseWork.stocktake.taskNo; if (saved) await this.openTask(saved, false) },
|
||||
async loadTasks() { if (this.loading) return; this.loading = true; this.error = null; try { this.tasks = await listStocktakes('IN_PROGRESS') } catch (error) { this.setError(error, '盘点任务加载失败') } finally { this.loading = false } },
|
||||
changeScope(value) { this.scopeType = value; this.scopeCode = '' },
|
||||
async createTask(raw) { const code = String(raw?.detail?.value ?? raw ?? this.scopeCode).trim(); if (!code || this.busy) return; this.scopeCode = code; const payload = { scopeType: this.scopeType, scopeCode: code }; const key = warehouseIdempotencyKey('stocktake', 'create', payload); await this.run('create', async () => { const value = await createStocktake({ ...payload, idempotencyKey: key.value }); releaseWarehouseKey('stocktake', key.signature); this.applyCurrent(value); this.scopeCode = ''; await this.loadTasks(); this.successMessage = '盘点任务已创建' }) },
|
||||
async createTask(raw) { const code = String(raw?.detail?.value ?? raw ?? this.scopeCode).trim(); if (!code || this.busy) return; this.scopeCode = code; const payload = { scopeType: this.scopeType, scopeCode: code }; const key = warehouseIdempotencyKey('stocktake', 'create', payload); await this.run('create', async () => { const value = await createStocktake({ ...payload, idempotencyKey: key.value }); this.applyCurrent(value); this.scopeCode = ''; await this.loadTasks(); this.successMessage = '盘点任务已创建' }) },
|
||||
async openTask(taskNo, feedback = true) { if (!taskNo || this.busy) return; await this.run('detail', async () => { const value = await getStocktake(taskNo); this.applyCurrent(value); if (feedback) playSuccessSound() }, false) },
|
||||
applyCurrent(value) { this.current = value; this.current.items = this.current.items.map((item) => ({ ...item, inputPackages: item.actualPackages })); updateWarehouseWork('stocktake', { taskNo: value.task.taskNo }) },
|
||||
validCount(item) { const value = Number(item.inputPackages); return Number.isInteger(value) && value >= 0 },
|
||||
async saveCount(item) { if (!this.validCount(item) || this.busy) return; const payload = { taskNo: this.current.task.taskNo, positionId: item.positionId, actualPackages: Number(item.inputPackages), expectedVersion: item.version }; const key = warehouseIdempotencyKey('stocktake', 'count', payload); this.busyItem = item.positionId; await this.run('count', async () => { await countStocktake(this.current.task.taskNo, { positionId: item.positionId, actualPackages: payload.actualPackages, expectedVersion: item.version, idempotencyKey: key.value }); releaseWarehouseKey('stocktake', key.signature); await this.reloadCurrent(); this.successMessage = '实盘数量已保存' }, true, key) },
|
||||
async submitTask() { if (!this.allCounted || !this.reason.trim() || this.busy) return; const payload = { taskNo: this.current.task.taskNo, expectedVersion: this.current.task.version, reason: this.reason.trim() }; const key = warehouseIdempotencyKey('stocktake', 'submit', payload); await this.run('submit', async () => { const value = await submitStocktake(this.current.task.taskNo, { expectedVersion: payload.expectedVersion, reason: payload.reason, idempotencyKey: key.value }); releaseWarehouseKey('stocktake', key.signature); this.applyCurrent(value); this.reason = ''; this.successMessage = '盘点已提交'; await this.loadTasks() }, true, key) },
|
||||
async saveCount(item) { if (!this.validCount(item) || this.busy) return; const payload = { taskNo: this.current.task.taskNo, positionId: item.positionId, actualPackages: Number(item.inputPackages), expectedVersion: item.version }; const key = warehouseIdempotencyKey('stocktake', 'count', payload); this.busyItem = item.positionId; await this.run('count', async () => { await countStocktake(this.current.task.taskNo, { positionId: item.positionId, actualPackages: payload.actualPackages, expectedVersion: item.version, idempotencyKey: key.value }); await this.reloadCurrent(); this.successMessage = '实盘数量已保存' }, true, key) },
|
||||
async submitTask() { if (!this.allCounted || !this.reason.trim() || this.busy) return; const payload = { taskNo: this.current.task.taskNo, expectedVersion: this.current.task.version, reason: this.reason.trim() }; const key = warehouseIdempotencyKey('stocktake', 'submit', payload); await this.run('submit', async () => { const value = await submitStocktake(this.current.task.taskNo, { expectedVersion: payload.expectedVersion, reason: payload.reason, idempotencyKey: key.value }); this.applyCurrent(value); this.reason = ''; this.successMessage = '盘点已提交'; await this.loadTasks() }, true, key) },
|
||||
async reloadCurrent() { if (this.current?.task?.taskNo) this.applyCurrent(await getStocktake(this.current.task.taskNo)) },
|
||||
async retry() { this.error = null; if (this.current?.task?.taskNo) await this.openTask(this.current.task.taskNo, false); else await this.loadTasks() },
|
||||
async run(action, work, refreshOnConflict = false, key = null) { if (this.busy) return; this.busy = true; this.busyAction = action; this.error = null; this.successMessage = ''; try { await work(); playSuccessSound() } catch (error) { this.setError(error, '盘点作业失败'); if (refreshOnConflict && isConflict(error)) { if (key) releaseWarehouseKey('stocktake', key.signature); try { await this.reloadCurrent(); this.error.message += ',已刷新最新版本,请重新确认' } catch (_) {} } } finally { this.busy = false; this.busyAction = ''; this.busyItem = '' } },
|
||||
async run(action, work, refreshOnConflict = false, key = null) { if (this.busy) return; this.busy = true; this.busyAction = action; this.error = null; this.successMessage = ''; try { await work(); playSuccessSound() } catch (error) { this.setError(error, '盘点作业失败'); if (refreshOnConflict && isConflict(error)) { try { await this.reloadCurrent(); this.error.message += ',已刷新最新版本,请重新确认' } catch (_) {} } } finally { this.busy = false; this.busyAction = ''; this.busyItem = '' } },
|
||||
setError(error, fallback) { this.error = { message: error?.message || fallback, code: error?.code, requestId: error?.requestId, traceId: error?.traceId }; playErrorSound() },
|
||||
scopeLabel(value) { return value === 'PALLET' ? '托盘盘点' : '库位盘点' },
|
||||
statusLabel(value) { return ({ IN_PROGRESS: '进行中', SUBMITTED: '已提交' })[value] || value || '状态未知' }
|
||||
|
||||
@@ -196,7 +196,7 @@ export default {
|
||||
},
|
||||
watch: {
|
||||
countDiscrepancyReason() { this.saveCartonDraft() },
|
||||
cartonBoxes: { deep: true, handler() { if (this.selected && !this.cartonsCounted) uni.setStorageSync(this.cartonDraftKey(), { boxes: this.cartonBoxes, deletedBoxes: this.deletedCartonBoxes, forecastMarks: (this.selected.cartons || []).map(box => box.cartonMark), discrepancyReason: this.countDiscrepancyReason, key: this.cartonRequestKey, signature: this.cartonSignature }) } },
|
||||
cartonBoxes: { deep: true, handler() { if (this.selected && !this.cartonsCounted) uni.setStorageSync(this.cartonDraftKey(), { boxes: this.cartonBoxes, deletedBoxes: this.deletedCartonBoxes, forecastMarks: (this.selected.cartons || []).map(box => box.cartonMark), discrepancyReason: this.countDiscrepancyReason }) } },
|
||||
pallets: { deep: true, handler() { if (!this.submitting) this.pendingKey = '' } }
|
||||
},
|
||||
onShow() { if (requireSession()) { this.user = session.currentUser; restoreWarehouseWork(`${this.user.id}:${this.user.warehouseId}`); this.startScanner(); this.loadCandidates().then(() => { const activeNo = String(warehouseWork.warehouseTransfer.currentNo || ''); const taskNo = this.pendingTaskNo || activeNo; if (this.entryMode === 'work' && taskNo) { const active = this.candidates.find((item) => String(item.transferNo) === taskNo); if (active) { const fromExplicitTask = Boolean(this.pendingTaskNo); this.pendingTaskNo = ''; void this.select(active, fromExplicitTask) } } }) } },
|
||||
@@ -277,7 +277,7 @@ export default {
|
||||
if (!target || target.received === false || !this.validCartonValues(source)) return;
|
||||
this.copyCartonValues(source, target); this.saveCartonDraft();
|
||||
},
|
||||
saveCartonDraft() { if (this.selected && !this.cartonsCounted) uni.setStorageSync(this.cartonDraftKey(), { boxes: this.cartonBoxes, deletedBoxes: this.deletedCartonBoxes, forecastMarks: (this.selected.cartons || []).map(box => box.cartonMark), discrepancyReason: this.countDiscrepancyReason, key: this.cartonRequestKey, signature: this.cartonSignature }) },
|
||||
saveCartonDraft() { if (this.selected && !this.cartonsCounted) uni.setStorageSync(this.cartonDraftKey(), { boxes: this.cartonBoxes, deletedBoxes: this.deletedCartonBoxes, forecastMarks: (this.selected.cartons || []).map(box => box.cartonMark), discrepancyReason: this.countDiscrepancyReason }) },
|
||||
cartonDraftKey() { return `transfer-carton-count:${session.apiBase}:${this.user?.id}:${this.user?.warehouseId}:${this.selected?.transferNo}` },
|
||||
restoreCartons() {
|
||||
const rows = (this.selected?.cartons || []).filter(box => box.status !== 'NOT_RECEIVED').map(box => ({ ...box, transferPalletId: box.transferPalletId || (this.generatedPallets.length === 1 ? this.generatedPallets[0].draftId : ''), received: box.status !== 'NOT_RECEIVED', items: box.items?.length ? box.items.map(line => ({ skuCode: line.skuCode, quantity: line.quantity })) : [{ skuCode: this.selected.items.find(item => String(item.id) === String(box.transferItemId))?.skuCode || this.declaredSkuCodes[0] || '', quantity: box.quantity ?? '' }] }));
|
||||
@@ -286,7 +286,7 @@ export default {
|
||||
this.countDiscrepancyReason = this.selected?.cartons?.find(box => box.discrepancyReason)?.discrepancyReason || ''; this.cartonRequestKey = ''; this.cartonSignature = '';
|
||||
if (!this.cartonsCounted && Array.isArray(draft?.boxes) && draft.boxes.every(box => box.items?.length && box.items.every(line => this.declaredSkuCodes.includes(line.skuCode)) && (box.cartonMark || this.selected.items.some(item => String(item.id) === String(box.transferItemId)))) && draft.boxes.every(box => !box.cartonMark || (this.selected?.cartons || []).some(row => row.cartonMark === box.cartonMark))) {
|
||||
const knownMarks = new Set(draft.forecastMarks || draft.boxes.map(box => box.cartonMark));
|
||||
this.cartonBoxes = [...draft.boxes, ...rows.filter(box => !knownMarks.has(box.cartonMark))]; this.countDiscrepancyReason = draft.discrepancyReason || ''; this.cartonRequestKey = draft.key || ''; this.cartonSignature = draft.signature || '';
|
||||
this.cartonBoxes = [...draft.boxes, ...rows.filter(box => !knownMarks.has(box.cartonMark))]; this.countDiscrepancyReason = draft.discrepancyReason || ''; this.cartonRequestKey = ''; this.cartonSignature = '';
|
||||
} else this.cartonBoxes = rows;
|
||||
this.cartonBoxes.forEach(box => { if (this.cartonsCounted || (box.status === 'COUNTED' && this.validCartonValues(box))) box.confirmedSignature = this.cartonConfirmationSignature(box) });
|
||||
this.receivingPalletId = this.generatedPallets[0]?.draftId || ''; this.cartonPalletFilter = ''; this.batchPalletId = this.receivingPalletId; this.batchCountOpen = false; this.batchSelectedIndexes = []; this.batchCountMessage = ''; this.batchSku = this.batchSkuCodes[0] || ''; this.batchBox = newBatchBox(); this.batchBox.items[0].skuCode = this.batchSku;
|
||||
@@ -353,8 +353,8 @@ export default {
|
||||
try {
|
||||
const boxes = this.receivedCartonBoxes.map(box => ({ cartonMark: box.cartonMark || null, transferItemId: box.cartonMark ? box.transferItemId : this.selected.items.find(item => item.skuCode === box.items[0]?.skuCode)?.id, transferPalletId: box.transferPalletId, length: Number(box.length), width: Number(box.width), height: Number(box.height), weight: Number(box.weight), items: box.items.map(line => ({ skuCode: line.skuCode, quantity: Number(line.quantity) })) }));
|
||||
const signature = JSON.stringify({ boxes, discrepancyReason: this.countDiscrepancyReason, forecastPackageCount: Number(this.selected.packageCount) });
|
||||
if (!this.cartonRequestKey || signature !== this.cartonSignature) { this.cartonRequestKey = `transfer-count-${Date.now()}-${Math.random().toString(36).slice(2)}`; this.cartonSignature = signature }
|
||||
uni.setStorageSync(this.cartonDraftKey(), { boxes: this.cartonBoxes, deletedBoxes: this.deletedCartonBoxes, forecastMarks: (this.selected.cartons || []).map(box => box.cartonMark), discrepancyReason: this.countDiscrepancyReason, key: this.cartonRequestKey, signature });
|
||||
{ this.cartonRequestKey = `transfer-count-${Date.now()}-${Math.random().toString(36).slice(2)}`; this.cartonSignature = signature }
|
||||
uni.setStorageSync(this.cartonDraftKey(), { boxes: this.cartonBoxes, deletedBoxes: this.deletedCartonBoxes, forecastMarks: (this.selected.cartons || []).map(box => box.cartonMark), discrepancyReason: this.countDiscrepancyReason });
|
||||
const detail = await countWarehouseTransferCartons(this.selected.transferNo, { idempotencyKey: this.cartonRequestKey, boxes, discrepancyReason: this.countDiscrepancyReason, forecastPackageCount: Number(this.selected.packageCount) });
|
||||
if (!detail.cartons?.length || !detail.cartons.every(box => box.countedAt)) throw new Error('清点结果未确认,请刷新核实');
|
||||
uni.setStorageSync(this.cartonDraftKey(), { deletedBoxes: this.deletedCartonBoxes }); this.selected = detail; this.pallets = detail.pallets.map(restoredPallet); this.restoreCartons();
|
||||
@@ -544,7 +544,7 @@ export default {
|
||||
this.countDiscrepancyReason = reason
|
||||
this.saveCartonDraft()
|
||||
}
|
||||
if (!this.pendingKey) this.pendingKey = `pda-transfer-${this.selected.transferNo}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
|
||||
this.pendingKey = `pda-transfer-${this.selected.transferNo}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
|
||||
const payload = { idempotencyKey: this.pendingKey, photoFileIds: [...this.photoFileIds], discrepancyReason: this.hasSkuReceiptDifference ? this.countDiscrepancyReason : null }
|
||||
const transferNo = this.selected.transferNo; const result = await palletizeWarehouseTransfer(transferNo, payload); this.completedTransferNo = transferNo; this.completedDestination = result?.fbaWarehouseCode || this.selected.fbaWarehouseCode || ''; this.completedDestinationType = result?.destinationType || this.selected.destinationType || ''; this.completedPallets = (result?.pallets || []).filter((pallet) => pallet?.palletNo); this.success = true; resetWarehouseDomain('warehouseTransfer'); this.selected = null; this.pendingKey = ''; this.pallets = [newPallet()]; this.photoFileIds = []; this.photoUrls = {}; this.photoNames = []; await this.loadCandidates()
|
||||
} catch (e) { this.error = e } finally { this.submitting = false }
|
||||
|
||||
Reference in New Issue
Block a user