2026-09-01 14:23:55 +08:00
< template >
< view v-if = "user" class="page-shell portal-page fulfillment-page" >
< 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 >
< text class = "fulfillment-subtitle" > 客户与当前海外仓由登录令牌和服务端确定 ; 扫描排车单号或任务中的托盘码开始作业 </ text >
</ view >
2026-09-21 11:17:17 +08:00
< view v-if = "current" class="detail-toolbar"><view><text class="detail-kicker">当前任务</text><text class="detail-title">排车拣货详情</text></view><button :disabled="busy" @tap="closeTask" > ‹ 返回列表 </ button ></ view >
2026-09-01 14:23:55 +08:00
< view class = "fulfillment-card" >
2026-10-08 15:34:47 +08:00
< ScanTargetInput name = "dispatch" :model-value = "scanCode" label = "排车单号 / 托盘码 / 来源箱唛" placeholder = "扫描排车单号、托盘码或退货箱唛" :active = "true" @ update :modelValue = "updateScanCode" @submit ="handleScan" />
2026-09-01 14:23:55 +08:00
< button class = "secondary-button" : disabled = "!scanCode || loading || busy" @tap ="handleScan(scanCode)" >{{ current ? ' 匹配托盘 ' : ' 打开排车任务 ' }}</ button >
< text class = "scan-help" > 网络失败不会清空草稿 , 再次提交会沿用同一幂等键 。 </ text >
</ view >
< view v-if = "loading" class="fulfillment-state" >< text > 正在加载排车任务 … </ text ></ view >
< view v-else-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" class="offline-note" > 网络连接失败 , 未完成作业与幂等键已保留 。 </ text >
< button class = "secondary-button" : disabled = "loading || busy" @tap ="retry" > 重试 </ button >
</ view >
< template v-if = "!loading && !current" >
2026-10-08 18:08:42 +08:00
< view class = "list-toolbar" >< view >< text class = "list-title" > 排车任务 </ text >< text class = "list-count" >{{ tasks . length }} 个任务 </ text ></ view >< button :disabled = "loading" @tap ="loadTasks" > 刷新 </ button ></ view >
2026-09-01 14:23:55 +08:00
< view v-if = "tasks.length" class="task-list" >
< button v-for = "task in tasks" :key="task.dispatchNo" class="task-card" @tap="openTask(task.dispatchNo)" >
2026-09-21 11:17:17 +08:00
< view class = "task-head" >< view class = "task-code-row" >< text class = "business-code" >{{ task . dispatchNo || '—' }}</ text >< text v-if = "task.dispatchNo === recentTaskNo" class="recent-badge">上次作业</text></view><text class="status-badge" >{{ statusLabel ( task.status ) }}</ text ></ view >
2026-10-08 18:08:42 +08:00
< text class = "task-meta" > 任务号 {{ task . taskNo || '—' }} · {{ task . status === 'DEPARTED' ? '原发车车牌' : '车牌' }} {{ task . vehiclePlate || '—' }}</ text >
< text class = "task-enter" > 查看任务详情 › </ text >
2026-09-01 14:23:55 +08:00
</ button >
</ view >
< view v-else-if = "!error" class="fulfillment-state"><text class="state-title" > 暂无排车拣货任务 </ text >< text > 可刷新列表 , 或直接扫描排车单号恢复作业 。 </ text ></ view >
</ template >
< template v-if = "current" >
2026-10-08 18:08:42 +08:00
< DispatchHandoverStatus v-if = "departed" :dispatch-no="current.dispatchNo" :record-version="current.tripRecordVersion" />
2026-09-01 14:23:55 +08:00
< view class = "fulfillment-card summary-card" >
< view class = "task-head" >< text class = "business-code" >{{ current . dispatchNo || '—' }}</ text >< text class = "status-badge" >{{ statusLabel ( current . status ) }}</ text ></ view >
2026-10-08 18:08:42 +08:00
< text class = "task-meta" > 任务号 {{ current . taskNo || '—' }} · {{ departed ? '原发车车牌' : '车牌' }} {{ current . vehiclePlate || '—' }}</ text >
< text v-if = "!current.canPick && !pickingCompleted" class="gate-warning" >{{ current.pickBlockReason || ' 请先通过调度审核再拣货 ' }}</ text >
2026-09-01 14:23:55 +08:00
< view class = "metrics" >< 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.items.length" class="item-list" >
2026-10-08 15:34:47 +08:00
< view v-for = "item in current.items" :key="item.dispatchItemId" class="fulfillment-card item-card" :class="{ selected: selectedItem?.dispatchItemId === item.dispatchItemId }" @tap="selectPallet(item)" >
2026-09-01 14:23:55 +08:00
< view class = "task-head" >< text class = "business-code" >{{ item . palletNo || '未关联托盘码' }}</ text >< text >{{ item . pickedPackages }} / {{ item . scheduledPackages }} 箱 </ text ></ view >
< text class = "task-meta" >{{ routeLabel ( item ) }}</ text >
2026-10-08 15:34:47 +08:00
< text v-for = "carton in item.returnCartons" :key="carton.cartonMark" class="task-meta" >{{ carton.cartonMark }} · {{ carton.status === ' OUTBOUNDED ' ? ' 已拣货 ' : ' 待扫描 ' }}</ text >
2026-09-01 14:23:55 +08:00
< view class = "progress" >< view : style = "{ width: `${itemProgress(item)}%` }" ></ view ></ view >
</ view >
</ view >
< view v-else class = "fulfillment-state" >< text class = "state-title" > 任务没有可拣货明细 </ text >< text > 请联系平台确认排车托盘是否已下发 。 </ text ></ view >
< view v-if = "selectedItem && selectedRemaining > 0" class="fulfillment-card" >
< text class = "card-title" > 本次拣货 : {{ selectedItem . palletNo }}</ text >
2026-10-08 15:34:47 +08:00
< text v-if = "selectedItem.returnCartons.length" class="task-meta" >{{ selectedReturnCarton ? ` 已扫描 $ { selectedReturnCarton.cartonMark } ` : ' 请扫描此托盘上的来源箱唛 , 每次拣货一箱 ' }}</ text >
< view v-else class = "count-row" >< text > 拣货箱数 </ text >< input type = "number" :value = "packages" : placeholder = "`请输入 1-${selectedRemaining} 箱`" @input ="updatePackages" />< text > 最多 {{ selectedRemaining }} 箱 </ text ></ view >
2026-09-01 14:23:55 +08:00
< button class = "primary-button" : disabled = "!canPick || busy" @tap ="submitPick" >{{ busyAction = == ' pick ' ? ' 提交中 …' : ' 确认拣货 ' }}</ button >
</ view >
< view v-if = "canCompleteStage" class="fulfillment-card gate-card" >
< text class = "card-title" > 完成拣货 </ text >
< text v-if = "remainingPackages > 0" class="gate-warning" > 尚有 {{ remainingPackages }} 箱未拣 , 不能完成排车拣货 。 </ text >
< text v-else class = "gate-note" > 全部计划箱数已拣齐 , 可以完成任务 。 </ text >
< button class = "primary-button" : disabled = "!canComplete || busy" @tap ="completePicking" >{{ busyAction = == ' complete ' ? ' 完成中 …' : ' 完成排车拣货 ' }}</ button >
</ view >
< view v-if = "pickingCompleted" class="fulfillment-card gate-card" >
2026-10-08 18:08:42 +08:00
< text class = "card-title" > 整车发车 </ text >
< text v-if = "current.tripNo" class="task-meta" > 车次 {{ current.tripNo }} · 共 {{ current.tripDispatchCount }} 张排车单 </ text >
< text v-if = "current.driverWarning && !departed" class="gate-warning" >{{ current.driverWarning }}</ text >
2026-09-01 14:23:55 +08:00
< text v-if = "!allPicked" class="gate-warning" > 服务端仍显示 {{ remainingPackages }} 箱未拣 , 不允许发车 。 </ text >
< text v-else-if = "departed" class="gate-success" > 该排车任务已执行发车 , 不可重复提交 。 </ text >
2026-09-02 14:01:08 +08:00
< text v-else-if = "!current.canDepart" class="gate-warning" >{{ current.departBlockReason || ' 当前排车暂不满足发车条件 , 请刷新后重试 。' }}</ text >
2026-10-08 18:08:42 +08:00
< text v-else class = "gate-note" > 本车所有排车单已就绪 , 确认后将一并发车 。 </ text >
< button v-if = "!departed" class="depart-button" :disabled="!canDepart || busy" @tap="depart" >{{ busyAction = == ' depart ' ? ' 发车中 …' : ' 确认整车发车 ' }}</ button >
2026-09-01 14:23:55 +08:00
</ view >
</ template >
< view v-if = "successMessage" class="fulfillment-success" role="status" >< text >{{ successMessage }}</ text ></ view >
</ view >
</ scroll-view >
</ view >
</ template >
< script >
import PortalHeader from '@/components/PortalHeader.vue'
import ScanTargetInput from '@/components/ScanTargetInput.vue'
2026-10-08 18:08:42 +08:00
import DispatchHandoverStatus from '@/components/DispatchHandoverStatus.vue'
import { completeDispatchPicking , departDispatch , getDispatchTask , listDispatchTasks , pickDispatchItem , tripDepartureRequest } from '@/api/fulfillment.js'
2026-09-01 14:23:55 +08:00
import { fulfillment , fulfillmentIdempotencyKey , releaseFulfillmentKey , restoreFulfillmentDraft , updateFulfillmentDraft } from '@/stores/fulfillment.js'
import { requireSession , session } from '@/stores/session.js'
import { playErrorSound , playSuccessSound } from '@/utils/audio-feedback.js'
2026-09-08 17:56:56 +08:00
import { toDispatchError } from '@/utils/dispatch-error.js'
2026-09-01 14:23:55 +08:00
import { startScanBroadcast } from '@/utils/scan-broadcast.js'
const finishedStatuses = [ 'COMPLETED' , 'PICKING_COMPLETED' , 'READY_TO_DEPART' , 'DEPARTED' ]
export default {
2026-10-08 18:08:42 +08:00
components : { PortalHeader , ScanTargetInput , DispatchHandoverStatus },
2026-09-21 11:17:17 +08:00
data () { return { user : session . currentUser , tasks : [], current : null , scanCode : '' , loading : false , busy : false , busyAction : '' , error : null , successMessage : '' , bootstrapped : false , resumeOnEntry : false } },
2026-09-01 14:23:55 +08:00
computed : {
2026-09-21 11:17:17 +08:00
recentTaskNo () { return fulfillment . dispatch . currentNo || '' },
2026-09-01 14:23:55 +08:00
packages () { return fulfillment . dispatch . packages },
2026-10-08 15:34:47 +08:00
selectedItem () { return this . current ? . items ? . find (( item ) => fulfillment . dispatch . dispatchItemId ? item . dispatchItemId === fulfillment . dispatch . dispatchItemId : item . palletNo === fulfillment . dispatch . palletCode ) || null },
selectedReturnCarton () { return this . selectedItem ? . returnCartons ? . find (( carton ) => carton . cartonMark === fulfillment . dispatch . cartonCode && carton . status === 'OUTBOUND_PENDING' ) || null },
2026-09-01 14:23:55 +08:00
selectedPosition () { return this . selectedItem ? . positions ? . find (( item ) => item . availablePackages > 0 ) || this . selectedItem ? . positions ? .[ 0 ] || null },
2026-10-08 15:34:47 +08:00
selectedRemaining () { const remaining = this . selectedItem ? . scheduledPackages - this . selectedItem ? . pickedPackages || 0 ; return Math . max ( 0 , this . selectedItem ? . returnCartons ? . length ? remaining : Math . min ( remaining , this . selectedPosition ? . availablePackages || 0 )) },
2026-09-01 14:23:55 +08:00
plannedPackages () { return ( this . current ? . items || []). reduce (( total , item ) => total + item . scheduledPackages , 0 ) },
pickedPackages () { return ( this . current ? . items || []). reduce (( total , item ) => total + item . pickedPackages , 0 ) },
remainingPackages () { return Math . max ( 0 , this . plannedPackages - this . pickedPackages ) },
allPicked () { return this . plannedPackages > 0 && this . remainingPackages === 0 },
pickingCompleted () { return finishedStatuses . includes ( this . current ? . status ) },
departed () { return this . current ? . status === 'DEPARTED' },
2026-10-08 18:08:42 +08:00
canPick () { return this . current ? . canPick === true && ( this . selectedItem ? . returnCartons ? . length ? Boolean ( this . selectedReturnCarton ) : Boolean ( this . selectedPosition ) && Number ( this . packages ) > 0 && Number ( this . packages ) <= this . selectedRemaining ) && ! this . pickingCompleted },
2026-09-01 14:23:55 +08:00
canCompleteStage () { return Boolean ( this . current ) && ! this . pickingCompleted },
2026-10-08 18:08:42 +08:00
canComplete () { return this . current ? . canPick === true && this . plannedPackages > 0 && this . remainingPackages === 0 },
2026-09-02 14:01:08 +08:00
canDepart () { return this . pickingCompleted && this . allPicked && this . current ? . canDepart === true && ! this . departed }
2026-09-01 14:23:55 +08:00
},
2026-09-21 11:17:17 +08:00
onLoad ( options ) { this . resumeOnEntry = options ? . resume === '1' },
2026-09-01 14:23:55 +08:00
onShow () {
if ( ! requireSession ()) return
this . user = session . currentUser
restoreFulfillmentDraft ( ` ${ this . user ? . id || '' } : ${ this . user ? . warehouseId || '' } ` )
this . startScanner ()
2026-09-21 11:17:17 +08:00
if ( ! this . bootstrapped ) {
this . bootstrapped = true
if ( this . resumeOnEntry && fulfillment . dispatch . currentNo ) this . openTask ( fulfillment . dispatch . currentNo )
else this . loadTasks ()
return
}
if ( this . current ? . dispatchNo ) this . openTask ( this . current . dispatchNo )
2026-09-01 14:23:55 +08:00
else this . loadTasks ()
},
onHide () { this . stopScanner () },
beforeUnmount () { this . stopScanner () },
methods : {
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 },
updateScanCode ( value ) { this . scanCode = value },
updatePackages ( event ) { const value = Math . trunc ( Number ( event . detail . value || 0 )); updateFulfillmentDraft ( 'dispatch' , { packages : Number . isFinite ( value ) ? Math . max ( 0 , value ) : 0 }) },
async loadTasks () { await this . run ( '加载排车任务' , async () => { this . tasks = await listDispatchTasks (); this . current = null }, true ) },
async openTask ( dispatchNo ) {
const code = String ( dispatchNo || '' ). trim (); if ( ! code ) return
2026-10-08 15:34:47 +08:00
await this . run ( '恢复排车任务' , async () => { const previousNo = fulfillment . dispatch . currentNo ; this . current = await getDispatchTask ( code ); const same = previousNo === this . current . dispatchNo ; updateFulfillmentDraft ( 'dispatch' , { currentNo : this . current . dispatchNo , palletCode : same ? fulfillment . dispatch . palletCode : '' , dispatchItemId : same ? fulfillment . dispatch . dispatchItemId : '' , cartonCode : same ? fulfillment . dispatch . cartonCode : '' }); this . scanCode = '' }, true )
2026-09-01 14:23:55 +08:00
},
2026-10-08 15:34:47 +08:00
closeTask () { this . current = null ; this . scanCode = '' ; this . error = null ; this . successMessage = '' ; updateFulfillmentDraft ( 'dispatch' , { currentNo : '' , palletCode : '' , dispatchItemId : '' , cartonCode : '' , packages : 1 }); this . loadTasks () },
2026-09-01 14:23:55 +08:00
handleScan ( raw ) {
const code = String ( raw ? . detail ? . value ?? raw ?? this . scanCode ). trim (); if ( ! code || this . busy || this . loading ) return
console . info ( '[Overseas PDA dispatch] scan' , { code , task : this . current ? . dispatchNo || '' })
this . scanCode = code
if ( ! this . current || code === this . current . dispatchNo || code === this . current . taskNo ) return this . openTask ( code === this . current ? . taskNo ? this . current . dispatchNo : code )
2026-10-08 15:34:47 +08:00
const cartonItem = this . current . items . find (( item ) => item . returnCartons ? . some (( carton ) => carton . cartonMark === code ))
if ( cartonItem ) {
if ( cartonItem . returnCartons . find (( carton ) => carton . cartonMark === code ) ? . status !== 'OUTBOUND_PENDING' ) return this . setError ( new Error ( '该箱已拣货或不可出库' ), '扫码未匹配' )
this . selectPallet ( cartonItem ); updateFulfillmentDraft ( 'dispatch' , { cartonCode : code , packages : 1 }); this . scanCode = '' ; playSuccessSound (); return
}
2026-09-01 14:23:55 +08:00
const item = this . current . items . find (( value ) => [ value . palletNo , value . fbaWarehouseCode ]. includes ( code ))
if ( ! item ) return this . setError ( new Error ( '扫码未匹配当前排车任务的托盘或稳定业务码' ), '扫码未匹配' )
this . selectPallet ( item ); this . scanCode = '' ; playSuccessSound ()
},
2026-10-08 15:34:47 +08:00
selectPallet ( item ) { if ( ! item ? . palletNo || item . scheduledPackages <= item . pickedPackages ) return ; updateFulfillmentDraft ( 'dispatch' , { palletCode : item . palletNo , dispatchItemId : item . dispatchItemId , cartonCode : '' , packages : Math . max ( 1 , Math . min ( item . scheduledPackages - item . pickedPackages , item . positions ? .[ 0 ] ? . availablePackages || 1 )) }) },
2026-09-01 14:23:55 +08:00
async submitPick () {
if ( ! this . canPick ) return
2026-10-08 15:34:47 +08:00
const pick = this . selectedReturnCarton ? { positionId : null , cartonMark : this . selectedReturnCarton . cartonMark , packages : 1 , productQuantity : this . selectedReturnCarton . productQuantity } : { positionId : this . selectedPosition . id , packages : Number ( this . packages ), productQuantity : 0 }
const payload = { dispatchNo : this . current . dispatchNo , dispatchItemId : this . selectedItem . dispatchItemId , ... pick }
2026-09-01 14:23:55 +08:00
const key = fulfillmentIdempotencyKey ( 'dispatch' , 'pick' , payload )
await this . run ( '提交排车拣货' , async () => {
2026-10-08 15:34:47 +08:00
await pickDispatchItem ( this . current . dispatchNo , this . selectedItem . dispatchItemId , { ... pick , idempotencyKey : key . value })
this . current = await getDispatchTask ( this . current . dispatchNo ); releaseFulfillmentKey ( 'dispatch' , key . signature ); updateFulfillmentDraft ( 'dispatch' , { palletCode : '' , dispatchItemId : '' , cartonCode : '' , packages : 1 }); this . successMessage = '本次排车拣货已记录' ; playSuccessSound ()
2026-09-01 14:23:55 +08:00
})
},
async completePicking () {
if ( ! this . canComplete ) return
const key = fulfillmentIdempotencyKey ( 'dispatch' , 'complete' , { dispatchNo : this . current . dispatchNo })
await this . run ( '完成排车拣货' , async () => { await completeDispatchPicking ( this . current . dispatchNo , { idempotencyKey : key . value }); this . current = await getDispatchTask ( this . current . dispatchNo ); releaseFulfillmentKey ( 'dispatch' , key . signature ); this . successMessage = '排车拣货任务已完成' ; playSuccessSound () })
},
async depart () {
if ( ! this . canDepart ) return
2026-10-08 18:08:42 +08:00
const key = fulfillmentIdempotencyKey ( 'dispatch' , 'depart' , { tripNo : this . current . tripNo , recordVersion : this . current . tripRecordVersion })
await this . run ( '执行整车发车' , async () => {
const result = await departDispatch ( this . current . dispatchNo , tripDepartureRequest ( this . current , key . value ))
if ( result ? . status !== 'IN_TRANSIT' || result . tripNo !== this . current . tripNo || ! result . dispatches ? . some ( item => item . dispatchNo === this . current . dispatchNo )) throw new Error ( '发车结果未包含当前车次和排车单,请刷新核对' )
this . current = await getDispatchTask ( this . current . dispatchNo )
releaseFulfillmentKey ( 'dispatch' , key . signature ); this . successMessage = `车次 ${ result . tripNo } 已整车发车` ; playSuccessSound ()
})
2026-09-01 14:23:55 +08:00
},
async retry () { if ( this . current ? . dispatchNo ) await this . openTask ( this . current . dispatchNo ); else if ( fulfillment . dispatch . currentNo ) await this . openTask ( fulfillment . dispatch . currentNo ); 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 ( '发车' ) ? 'depart' : action . includes ( '完成' ) ? 'complete' : 'pick' } try { await work () } catch ( error ) { this . setError ( error , ` ${ action } 失败` ) } finally { this . loading = false ; this . busy = false ; this . busyAction = '' } },
2026-09-08 17:56:56 +08:00
setError ( error , fallback ) { this . error = toDispatchError ( error , fallback ); console . error ( '[Overseas PDA dispatch] failed' , this . error ); playErrorSound () },
2026-09-01 14:23:55 +08:00
statusLabel ( status ) { return ({ PDA_PENDING : '待拣货' , PENDING : '待拣货' , PICKING : '拣货中' , IN_PROGRESS : '拣货中' , COMPLETED : '拣货完成' , PICKING_COMPLETED : '拣货完成' , READY_TO_DEPART : '待发车' , DEPARTED : '已发车' , CANCELLED : '已取消' })[ status ] || status || '状态未知' },
routeLabel ( item ) { return item . warehouseRouteLabel || [ item . destinationWarehouseCode , item . destinationWarehouseName , item . fbaWarehouseCode ]. filter ( Boolean ). join ( ' · ' ) || '目的仓未关联' },
itemProgress ( item ) { return item . scheduledPackages ? Math . min ( 100 , Math . round ( item . pickedPackages / item . scheduledPackages * 100 )) : 0 }
}
}
</ script >
< style scoped >
@ import './fulfillment.scss' ;
</ style >