@@ -9,7 +9,7 @@
< 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 >
< 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"><DispatchHandoverStatus v-if="departed" :dispatch-no="current.dispatchNo" :record-version="current.recordVersion" / >
< template v-if = "current"><DispatchLoadingDocuments v-if="['AFTER_LOADING', 'DELIVERING', 'DELIVERED'].includes(current.stage)" :key="current.dispatchNo" :dispatch-no="current.dispatchNo" @busy-change="busy = $event" @saved="openTask(current.dispatchNo)" />< 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 v-if = "current.stage === 'BEFORE_LOADING'" class="fulfillment-card review-card" >
< text class = "card-title" > 理货员审核 < / text >
@@ -30,7 +30,7 @@
< view v-if = "!current.items.length" class="fulfillment-state" > 本车暂无货物 , 空单不能核平 、 装车或发车 。 < / 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拣货已核平,确认装车完成后直接 进入派送中。' : '先完成全部真实拣货,不能补录数量代替核平。' }}</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'" 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 >
< / template > < view v-if = "successMessage" class="fulfillment-success" role="status" > {{ successMessage }} < / view >
< / view > < / scroll -view >
< view v-if = "loadingPreview" class="loading-overlay"><view class="loading-dialog" role="dialog" aria-label="核对真实装载"><text class="card-title">核对真实装载 · {{ loadingPreview.dispatchNo }}</text><scroll-view scroll-y class="loading-dialog-body"><view v-for="row in loadingRows" :key="row.warehousePalletId" class="fulfillment-card"><text class="business-code">{{ row.palletNo || '—' }}</text><text>实际托位</text><input type="number" :value="row.palletSpaces ?? ''" placeholder="请确认实际托位" :disabled="busy" @input="setMeasurement(row, 'palletSpaces', $event)" /><text>实测重量(kg)</text><input type="digit" :value="row.weightKg ?? ''" placeholder="请输入实测重量,参考值不自动确认" :disabled="busy" @input="setMeasurement(row, 'weightKg', $event)" /><text>实测体积(m³)</text><input type="digit" :value="row.volumeCbm ?? ''" placeholder="请输入实测体积" :disabled="busy" @input="setMeasurement(row, 'volumeCbm', $event)" /><text>已匹配卸货顺序:{{ row.deliveryIndex ?? '尚未匹配,请后台核对' }}</text></view><view v-for="row in loadingPreview.pallets" :key="row.warehousePalletId"><text v-for="evidence in row.evidence" :key="evidence.field + evidence.sourceType">{{ evidence.actual ? '真实测量依据' : '仅供参考' }} {{ evidence.sourceBusinessNo || '—' }}: {{ evidence.value ?? '—' }}</text></view></scroll-view><view class="count-row"><button :disabled="busy" @tap="loadingPreview = null">返回</button><button :disabled="busy || Boolean(pendingCommand)" @tap="completeLoading" > 确认实际装车完成 < / button > < / view > < / view > < / view >
@@ -40,6 +40,7 @@
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 { requireSession , session } from '@/stores/session.js'
@@ -47,7 +48,7 @@ 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 } ,
components : { PortalHeader , ScanTargetInput , DispatchHandoverStatus , DispatchLoadingDocuments } ,
data ( ) { return { user : session . currentUser , tasks : [ ] , current : null , scanCode : '' , loading : false , busy : 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 } ,
@@ -123,12 +124,12 @@ export default {
await this . recoverCommand ( )
} 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' && result . stage !== 'DELIVER ING' || 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' ? '装车完成已确认,本车已进入派送中 ' : command . action === 'LOADING_REVIEW' ? command . body . decision === 'APPROVED' ? '理货审核已通过,可以开始真实拣货' : '已退回客服处理(车辆信息),等待修改并重新提交' : '排车操作已确认' ; playSuccessSound ( ) } ) } ,
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' && result . stage !== 'AFTER_LOAD ING' || 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' ? '装车完成已确认,请补齐装车资料 ' : 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 } } ,
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 : '装车中' , DELIVERING : '派送中' , DELIVERED : '派送完成' } ) [ task . stage ] || '状态待核实' } ,
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 ] || '状态待核实' } ,
reviewLabel ( review ) { return ( { NOT _SUBMITTED : '未提交' , PENDING : '待审核' , APPROVED : '已通过' , REJECTED : '已驳回' } ) [ review ? . status ] || '待核对' } ,
routeLabel ( item ) { return [ item . destination ? . warehouseCode , item . destination ? . warehouseName , item . destination ? . address ] . filter ( Boolean ) . join ( ' · ' ) || '目的仓未关联' } , itemProgress ( item ) { return item . scheduledPackages ? Math . min ( 100 , Math . round ( item . pickedPackages / item . scheduledPackages * 100 ) ) : 0 }
}