2026-08-21 11:20:09 +08:00
< script setup lang = "ts" >
2026-09-14 20:03:27 +08:00
import '@/styles/adminDensity.css'
2026-10-09 22:05:51 +08:00
import { TripPreparationDialog , TransportRouteDialog , routeLocation , type RouteLocation } from '@/modules/overseas-master-data'
2026-08-26 15:48:34 +08:00
import { computed , onMounted , reactive , ref } from 'vue'
2026-09-01 14:23:53 +08:00
import { ElMessage , ElMessageBox } from 'element-plus'
2026-08-21 18:16:42 +08:00
import { useRouter } from 'vue-router'
2026-08-21 11:20:09 +08:00
import { getApiErrorDetails , type ApiErrorDetails } from '@/shared/api/http'
import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import DataTable , { type DataTableColumn } from '@/shared/components/DataTable.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar , { type QueryField , type QueryModel } from '@/shared/components/QueryToolbar.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
2026-09-01 14:23:53 +08:00
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
2026-09-18 14:58:40 +08:00
import RowActions , { type RowActionItem } from '@/shared/components/RowActions.vue'
2026-09-01 14:23:53 +08:00
import { useAccessPreviewStore } from '@/core/access/accessPreview'
2026-10-03 04:35:55 +08:00
import { cancelDispatch , getDispatchBolPdf , getDispatchExternalFreight , getDispatchFeeCoverage , listDispatches , removeDispatchItem , transferDispatchToDelivery , updateDispatch , type DispatchItemDto , type DispatchSummaryDto , type ExternalFreightPreview } from '../api/dispatches'
import DeliveryFeeCoveragePanel from '../components/DeliveryFeeCoveragePanel.vue'
import { fullyCoveredServices , coveredServiceLabels , type FeeCoveragePreview , type FeeCoverageSelection } from '../feeCoverageTypes'
2026-08-25 20:57:51 +08:00
import AppDialog from '@/shared/components/AppDialog.vue'
2026-08-21 11:20:09 +08:00
import type { DispatchListItem } from '../types'
2026-08-22 12:24:15 +08:00
import { formatOverseasDateTime } from '../lastMilePresentation'
2026-09-01 14:23:53 +08:00
import { overseasWarehousePermissions as permission } from '../permissions'
2026-09-15 12:17:20 +08:00
import { dispatchVehicleSizeOptions } from '../dispatchVehicleSpecs'
2026-08-21 11:20:09 +08:00
2026-08-21 18:16:42 +08:00
const router = useRouter ()
2026-10-09 17:36:32 +08:00
const routeOpen = ref ( false )
const routeOrigin = ref < RouteLocation >( routeLocation ( '' )), routeDestinations = ref < RouteLocation [] >([])
function openRoute ( row : DispatchListItem ) {
routeOrigin . value = { kind : 'warehouse' , label : row . overseasWarehouse , warehouseId : dispatchSnapshots . get ( row . dispatchNo ) ? . overseasWarehouseId || undefined }
// The service's route field is a comma-separated set of destination warehouse codes, not a stop sequence.
routeDestinations . value = [... new Set ( row . route . split ( ',' ). map ( code => code . trim ()). filter ( Boolean ))]. map ( code => ({ kind : 'warehouse' , label : code , warehouseCode : code }))
routeOpen . value = true
}
2026-09-01 14:23:53 +08:00
const access = useAccessPreviewStore ()
2026-08-21 11:20:09 +08:00
const rows = ref < DispatchListItem [] >([])
2026-10-09 22:05:51 +08:00
const selectedDispatches = ref < DispatchListItem [] >([]), prepareOpen = ref ( false ), prepareWarehouse = ref ( '' ), prepareWarehouseName = ref ( '' ), prepareNumbers = ref < string [] >([])
const canPrepare = computed (() => access . can ( 'platform:fleet:trip:edit' ) && access . can ( 'platform:fleet:trip:list' ))
function beginPreparation () {
const selected = selectedDispatches . value
if ( ! selected . length || selected . some ( row => row . statusCode !== 'PENDING_DEPARTURE' || row . workflow )) { ElMessage . warning ( '请选择尚未关联车次的待发运排车单' ); return }
const warehouses = new Set ( selected . map ( row => dispatchSnapshots . get ( row . dispatchNo ) ? . overseasWarehouseId ))
if ( warehouses . size !== 1 || ! [... warehouses ][ 0 ]) { ElMessage . warning ( '请选择同一起运仓的排车单' ); return }
prepareWarehouse . value = String ([... warehouses ][ 0 ]); prepareWarehouseName . value = selected [ 0 ] ! . overseasWarehouse
prepareNumbers . value = selected . map ( row => row . dispatchNo ); prepareOpen . value = true
}
2026-08-21 11:20:09 +08:00
const loading = ref ( false )
const loadError = ref < ApiErrorDetails | null >( null )
const page = ref ( 1 )
2026-08-22 12:24:15 +08:00
const pageSize = ref ( 10 )
2026-08-21 11:20:09 +08:00
const total = ref ( 0 )
2026-10-08 14:11:54 +08:00
const stage = ref < '' | 'BEFORE_LOADING' | 'LOADING' | 'AFTER_LOADING' | 'CANCELLED' > ( '' )
const stages = [
{ value : '' , label : '全部' }, { value : 'BEFORE_LOADING' , label : '装车前' },
{ value : 'LOADING' , label : '装车中' }, { value : 'AFTER_LOADING' , label : '装车后' },
{ value : 'CANCELLED' , label : '已取消' },
] as const
const section = ref < 'vehicle' | 'dispatchReview' | 'loadingReview' > ( 'vehicle' )
const sections = [
{ value : 'vehicle' , label : '车辆信息' },
{ value : 'dispatchReview' , label : '调度审核' },
{ value : 'loadingReview' , label : '理货员审核' },
] as const
2026-10-09 17:36:32 +08:00
const visibleSections = computed (() => stage . value === 'BEFORE_LOADING' ? sections : sections . filter ( item => item . value === 'vehicle' ))
2026-10-08 14:11:54 +08:00
const reviewLabels = { PENDING : '待审核' , APPROVED : '已通过' , REJECTED : '已打回' }
let loadSequence = 0
function switchStage ( value : typeof stage . value ) {
if ( stage . value === value ) return
stage . value = value
2026-10-09 17:36:32 +08:00
if ( value !== 'BEFORE_LOADING' ) section . value = 'vehicle'
2026-10-08 14:11:54 +08:00
filters . value . status = ''
page . value = 1
void load ()
}
function openTrip ( row : DispatchListItem , tab : 'overview' | 'loading' ) {
if ( ! row . workflow || ! access . can ( 'platform:fleet:trip:list' )) return
void router . push ({ name : 'overseas-master-data-trip-detail' , params : { tripNo : row . workflow . tripNo }, query : { tab } })
}
2026-09-13 21:10:05 +08:00
const filters = ref < QueryModel >({
dispatchNo : '' ,
vehiclePlate : '' ,
status : '' ,
fbaAppointmentNo : '' ,
})
const transferOpen = ref ( false )
const transferRow = ref < DispatchListItem | null >( null )
const transferSaving = ref ( false )
2026-10-03 04:35:55 +08:00
const externalFreight = ref < ExternalFreightPreview | null >( null )
const coveragePreview = ref < FeeCoveragePreview | null >( null )
const coverageSelection = ref < FeeCoverageSelection [] >([])
const coveredServices = computed (() => fullyCoveredServices ( coveragePreview . value , coverageSelection . value , externalFreight . value ? . quotes . map ( row => row . incomingNo ) || []))
const requiresDeliveryFee = computed (() => !! externalFreight . value ? . requiresDeliveryFee && ! coveredServices . value . includes ( 'DELIVERY_FEE' ))
const requiresLoadingFee = computed (() => needsLoadingFee . value && ! coveredServices . value . includes ( 'LOADING_FEE' ))
const requiresAppointmentFee = computed (() => isBuyAppointment . value && ! coveredServices . value . includes ( 'APPOINTMENT_FEE' ))
const freightLoading = ref ( false )
let freightGeneration = 0
2026-09-01 14:23:53 +08:00
const mutationError = ref < ApiErrorDetails | null >( null )
2026-09-13 21:10:05 +08:00
const editOpen = ref ( false )
const editRow = ref < DispatchListItem | null >( null )
const editSaving = ref ( false )
const editItems = ref < DispatchItemDto [] >([])
const removingItemId = ref ( '' )
2026-09-11 10:09:14 +08:00
const bolLoadingNo = ref ( '' )
2026-09-01 14:23:53 +08:00
const dispatchSnapshots = new Map < string , DispatchSummaryDto >()
2026-09-13 21:10:05 +08:00
const editForm = reactive ({
vehiclePlate : '' ,
driverName : '' ,
driverPhone : '' ,
carrierName : '' ,
route : '' ,
departAt : '' ,
fbaAppointmentNo : '' ,
fbaAppointmentStatus : '' ,
vehicleAppointmentStatus : '' ,
estimatedArrivalAt : '' ,
})
const transferForm = reactive ({
plannedDeliveryAt : '' ,
deliveryMethod : '' ,
waybillNo : '' ,
carrierName : '' ,
pickupMethod : '' ,
loadingType : '' ,
vehicleType : '' ,
vehicleSize : '' ,
driverName : '' ,
driverPhone : '' ,
vehiclePlate : '' ,
appointmentAccount : '' ,
sellerName : '' ,
deliveryFee : undefined as number | undefined ,
loadingFee : undefined as number | undefined ,
appointmentFee : undefined as number | undefined ,
remark : '' ,
})
2026-08-26 15:48:34 +08:00
const deliveryMethodOptions = [
2026-09-13 21:10:05 +08:00
{ label : 'FBA自提' , value : 'FBA_PICKUP' },
{ label : '客户自提' , value : 'CUSTOMER_PICKUP' },
{ label : '卡派打托' , value : 'TRUCK_PALLET' },
{ label : '卡派地板' , value : 'TRUCK_FLOOR' },
{ label : '快递' , value : 'EXPRESS' },
{ label : '外配' , value : 'OUTSOURCED' },
{ label : '专车直送' , value : 'DEDICATED_TRUCK' },
{ label : '零担派送' , value : 'LTL' },
2026-08-26 15:48:34 +08:00
]
const pickupMethodOptions = [
2026-09-13 21:10:05 +08:00
{ label : '一提一卸' , value : 'ONE_PICKUP_ONE_DROP' },
{ label : '一提多卸' , value : 'ONE_PICKUP_MULTI_DROP' },
{ label : '多提一卸' , value : 'MULTI_PICKUP_ONE_DROP' },
{ label : '多提多卸' , value : 'MULTI_PICKUP_MULTI_DROP' },
2026-08-26 15:48:34 +08:00
{ label : '仓库自提' , value : 'WAREHOUSE_PICKUP' },
]
2026-09-13 21:10:05 +08:00
const loadingTypeOptions = [
{ label : '托盘' , value : 'PALLET' },
{ label : '地板' , value : 'FLOOR' },
{ label : '混装' , value : 'MIXED' },
]
2026-08-26 15:48:34 +08:00
const vehicleTypeOptions = [ '厢式货车' , '平板车' , '冷藏车' , '集装箱卡车' , '尾板车' ]. map (( label ) => ({ label , value : label }))
2026-09-15 12:17:20 +08:00
const vehicleSizeOptions = dispatchVehicleSizeOptions
2026-08-26 15:48:34 +08:00
const appointmentAccountOptions = [
2026-09-13 21:10:05 +08:00
{ label : '4号账号(美西)' , value : '4' },
{ label : '8号账号(美东)' , value : '8' },
{ label : '10号账号(美中)' , value : '10' },
{ label : '12号账号(加拿大)' , value : '12' },
{ label : '买约' , value : 'BUY_APPOINTMENT' },
2026-08-26 15:48:34 +08:00
]
const isBuyAppointment = computed (() => transferForm . appointmentAccount === 'BUY_APPOINTMENT' )
const needsLoadingFee = computed (() => [ 'FLOOR' , 'MIXED' ]. includes ( transferForm . loadingType ))
2026-08-21 11:20:09 +08:00
const statusOptions = [
{ label : '待发车' , value : 'PENDING_DEPARTURE' },
{ label : '拣货中' , value : 'PICKING' },
{ label : '待发车(拣货完成)' , value : 'READY_TO_DEPART' },
{ label : '待发车(部分完成)' , value : 'PARTIAL_READY_TO_DEPART' },
{ label : '已发车' , value : 'DEPARTED' },
{ label : '已发车(部分完成)' , value : 'PARTIAL_DEPARTED' },
{ label : '已取消' , value : 'CANCELLED' },
]
const fields = computed < QueryField [] >(() => [
2026-09-13 21:10:05 +08:00
{
key : 'dispatchNo' ,
type : 'input' ,
label : '排车单号' ,
placeholder : '请输入排车单号' ,
width : 220 ,
},
{
key : 'vehiclePlate' ,
type : 'input' ,
label : '车牌号' ,
placeholder : '请输入车牌号' ,
width : 180 ,
},
{
key : 'status' ,
type : 'select' ,
label : '车辆状态' ,
placeholder : '全部状态' ,
options : statusOptions ,
width : 180 ,
},
{
key : 'fbaAppointmentNo' ,
type : 'input' ,
label : 'FBA预约号' ,
placeholder : '请输入FBA预约号' ,
width : 220 ,
},
2026-08-21 11:20:09 +08:00
])
const columns : DataTableColumn [] = [
2026-09-10 21:22:06 +08:00
{ key : 'actions' , label : '操作' , width : 380 , fixed : 'left' , slotName : 'actions' },
2026-08-21 11:20:09 +08:00
{ key : 'dispatchNo' , prop : 'dispatchNo' , label : '排车单号' , minWidth : 170 , fixed : 'left' },
2026-10-08 14:11:54 +08:00
{ key : 'workflow' , label : '关联车次' , minWidth : 210 , slotName : 'workflow' },
{ key : 'dispatchReview' , label : '调度审核' , minWidth : 180 , slotName : 'dispatchReview' , singleLine : false },
{ key : 'loadingReview' , label : '理货审核(整车)' , minWidth : 180 , slotName : 'loadingReview' , singleLine : false },
2026-08-21 11:20:09 +08:00
{ key : 'vehiclePlate' , prop : 'vehiclePlate' , label : '车牌号' , minWidth : 130 },
{ key : 'driverName' , prop : 'driverName' , label : '司机' , minWidth : 120 },
{ key : 'driverPhone' , prop : 'driverPhone' , label : '联系电话' , minWidth : 140 },
2026-09-13 21:10:05 +08:00
{
key : 'carrierName' ,
prop : 'carrierName' ,
label : '托车公司名称' ,
minWidth : 160 ,
},
2026-08-21 11:20:09 +08:00
{ key : 'orderNos' , prop : 'orderNos' , label : '关联订单' , minWidth : 220 },
{ key : 'route' , prop : 'route' , label : '路线' , minWidth : 180 },
2026-09-13 21:10:05 +08:00
{
key : 'overseasWarehouse' ,
prop : 'overseasWarehouse' ,
label : '海外仓' ,
minWidth : 160 ,
},
2026-08-21 11:20:09 +08:00
{ key : 'departAt' , prop : 'departAt' , label : '发车时间' , minWidth : 170 },
2026-09-13 21:10:05 +08:00
{
key : 'fbaAppointment' ,
prop : 'fbaAppointment' ,
label : 'FBA预约' ,
minWidth : 180 ,
},
{
key : 'vehicleAppointment' ,
prop : 'vehicleAppointment' ,
label : '车辆预约' ,
minWidth : 130 ,
},
{
key : 'estimatedArrivalAt' ,
prop : 'estimatedArrivalAt' ,
label : '预计到达时间' ,
minWidth : 170 ,
},
{
key : 'palletCount' ,
prop : 'palletCount' ,
label : '托盘数' ,
width : 90 ,
align : 'right' ,
},
{
key : 'packageCount' ,
prop : 'packageCount' ,
label : '箱数' ,
width : 80 ,
align : 'right' ,
},
{
key : 'status' ,
prop : 'status' ,
label : '车辆状态' ,
width : 150 ,
slotName : 'status' ,
},
2026-08-21 11:20:09 +08:00
{ key : 'createdAt' , prop : 'createdAt' , label : '创建时间' , minWidth : 170 },
]
2026-10-08 14:11:54 +08:00
const visibleColumns = computed (() => {
2026-10-09 17:36:32 +08:00
if ( section . value === 'vehicle' ) {
const visible = columns . filter ( column => ! [ 'dispatchReview' , 'loadingReview' ]. includes ( String ( column . key )))
if ( stage . value === 'AFTER_LOADING' ) visible . splice ( visible . findIndex ( column => column . key === 'status' ), 0 ,
{ key : 'routeMap' , label : '路线地图' , width : 120 , slotName : 'routeMap' })
return visible
}
2026-10-08 14:11:54 +08:00
const keys = [ 'actions' , 'dispatchNo' , 'workflow' , section . value , 'vehiclePlate' , 'driverName' , 'route' , 'palletCount' , 'packageCount' , 'status' ]
return keys . map ( key => columns . find ( column => column . key === key ) ! )
})
2026-10-09 22:05:51 +08:00
const preparationColumns = computed < DataTableColumn [] >(() => canPrepare . value ? [ visibleColumns . value [ 0 ] ! , { key : 'selection' , type : 'selection' , width : 45 , selectable : ( row : DispatchListItem ) => row . statusCode === 'PENDING_DEPARTURE' && ! row . workflow }, ... visibleColumns . value . slice ( 1 )] : visibleColumns . value )
2026-10-08 14:11:54 +08:00
const tablePreferenceKey = computed (() => section . value === 'vehicle' ? 'overseas-dispatches' : `overseas-dispatches- ${ section . value } ` )
2026-08-21 11:20:09 +08:00
function text ( value : unknown ) {
const normalized = String ( value ?? '' ). trim ()
return normalized || '—'
}
function number ( value : unknown ) {
const normalized = Number ( value )
return Number . isFinite ( normalized ) ? normalized : 0
}
function statusLabel ( value : unknown ) {
const status = String ( value || '' )
return statusOptions . find (( option ) => option . value === status ) ? . label || text ( value )
}
2026-09-03 17:17:11 +08:00
function appointmentStatusLabel ( value : unknown ) {
const code = String ( value || '' )
const labels : Record < string , string > = {
PENDING : '待预约' ,
BOOKED : '已预约' ,
CONFIRMED : '已确认' ,
CANCELLED : '已取消' ,
}
return labels [ code ] || text ( value )
}
2026-08-21 11:20:09 +08:00
function appointmentLabel ( numberValue : unknown , statusValue : unknown ) {
const appointmentNo = text ( numberValue )
2026-09-03 17:17:11 +08:00
const appointmentStatus = appointmentStatusLabel ( statusValue )
2026-08-21 11:20:09 +08:00
if ( appointmentNo === '—' ) return appointmentStatus
return appointmentStatus === '—' ? appointmentNo : ` ${ appointmentNo } · ${ appointmentStatus } `
}
function toListItem ( dto : DispatchSummaryDto ) : DispatchListItem {
return {
2026-10-08 14:11:54 +08:00
workflow : dto . workflow ,
2026-08-21 11:20:09 +08:00
id : String ( dto . id ),
dispatchNo : text ( dto . dispatchNo ),
vehiclePlate : text ( dto . vehiclePlate ),
driverName : text ( dto . driverName ),
driverPhone : text ( dto . driverPhone ),
carrierName : text ( dto . carrierName ),
orderNos : text ( dto . orderNos ),
route : text ( dto . route ),
2026-08-30 17:43:03 +08:00
overseasWarehouse : text ( dto . overseasWarehouseName || '仓库名称待补充' ),
2026-08-28 14:33:57 +08:00
departAt : formatOverseasDateTime ( dto . departAt ),
2026-08-21 11:20:09 +08:00
fbaAppointment : appointmentLabel ( dto . fbaAppointmentNo , dto . fbaAppointmentStatus ),
2026-09-03 17:17:11 +08:00
vehicleAppointment : appointmentStatusLabel ( dto . vehicleAppointmentStatus ),
2026-08-28 14:33:57 +08:00
estimatedArrivalAt : formatOverseasDateTime ( dto . estimatedArrivalAt ),
2026-08-21 11:20:09 +08:00
status : statusLabel ( dto . status ),
2026-09-01 14:23:53 +08:00
statusCode : String ( dto . status || '' ),
2026-08-21 11:20:09 +08:00
palletCount : number ( dto . palletCount ),
packageCount : number ( dto . packageCount ),
2026-08-22 12:24:15 +08:00
createdAt : formatOverseasDateTime ( dto . createdAt ),
2026-08-21 11:20:09 +08:00
}
}
function dispatchError ( error : unknown ) {
const details = getApiErrorDetails ( error , '排车列表加载失败,请重试' )
return details . code === 'HTTP_404'
2026-09-13 21:10:05 +08:00
? {
... details ,
message : '排车接口未启用或不存在,请确认海外尾程排车接口已发布' ,
}
2026-08-21 11:20:09 +08:00
: details
}
async function load ( model : QueryModel = filters . value ) {
2026-10-08 14:11:54 +08:00
const sequence = ++ loadSequence
2026-08-21 11:20:09 +08:00
Object . assign ( filters . value , model )
loading . value = true
loadError . value = null
try {
const result = await listDispatches ({
page : page . value ,
size : pageSize . value ,
2026-10-08 14:11:54 +08:00
stage : stage . value || undefined ,
2026-08-21 11:20:09 +08:00
dispatchNo : String ( filters . value . dispatchNo || '' ). trim () || undefined ,
vehiclePlate : String ( filters . value . vehiclePlate || '' ). trim () || undefined ,
status : String ( filters . value . status || '' ). trim () || undefined ,
fbaAppointmentNo : String ( filters . value . fbaAppointmentNo || '' ). trim () || undefined ,
})
2026-10-08 14:11:54 +08:00
if ( sequence !== loadSequence ) return
2026-09-01 14:23:53 +08:00
dispatchSnapshots . clear ()
result . records . forEach (( item ) => dispatchSnapshots . set ( String ( item . dispatchNo ), item ))
2026-08-21 11:20:09 +08:00
rows . value = result . records . map ( toListItem )
total . value = result . total
} catch ( error ) {
2026-10-08 14:11:54 +08:00
if ( sequence !== loadSequence ) return
2026-08-21 11:20:09 +08:00
rows . value = []
total . value = 0
loadError . value = dispatchError ( error )
} finally {
2026-10-08 14:11:54 +08:00
if ( sequence === loadSequence ) loading . value = false
2026-08-21 11:20:09 +08:00
}
}
2026-09-13 21:10:05 +08:00
function search ( model : QueryModel ) {
page . value = 1
void load ( model )
}
function reset ( model : QueryModel ) {
page . value = 1
void load ( model )
}
function openDetail ( row : DispatchListItem ) {
void router . push ({
name : 'overseas-dispatch-detail' ,
params : { dispatchNo : row . dispatchNo },
})
}
2026-09-10 21:22:06 +08:00
async function openBol ( row : DispatchListItem ) {
if ( bolLoadingNo . value ) return
2026-09-11 10:09:14 +08:00
const pdfWindow = window . open ( '' , '_blank' )
if ( ! pdfWindow ) { ElMessage . warning ( '浏览器拦截了 BOL PDF 窗口,请允许本站打开新窗口' ); return }
2026-09-10 21:22:06 +08:00
bolLoadingNo . value = row . dispatchNo
mutationError . value = null
try {
2026-09-11 10:09:14 +08:00
const pdf = await getDispatchBolPdf ( row . dispatchNo )
const url = URL . createObjectURL ( pdf )
pdfWindow . location . href = url
window . setTimeout (() => URL . revokeObjectURL ( url ), 60 _000 )
2026-09-10 21:22:06 +08:00
} catch ( cause ) {
2026-09-11 10:09:14 +08:00
pdfWindow . close ()
mutationError . value = getApiErrorDetails ( cause , 'BOL PDF 生成失败,请重试' )
2026-09-10 21:22:06 +08:00
} finally {
bolLoadingNo . value = ''
}
}
2026-09-13 21:10:05 +08:00
function isPendingDeparture ( row : DispatchListItem ) {
return row . statusCode === 'PENDING_DEPARTURE'
}
function canEdit ( row : DispatchListItem ) {
return access . can ( permission . dispatchEdit ) && isPendingDeparture ( row )
}
function canCancel ( row : DispatchListItem ) {
return access . can ( permission . dispatchCancel ) && row . statusCode === 'PENDING_DEPARTURE'
}
function canTransfer ( row : DispatchListItem ) {
return access . can ( permission . dispatchTransferDelivery ) && [ 'PENDING_DEPARTURE' , 'PICKING' , 'READY_TO_DEPART' ]. includes ( row . statusCode )
}
function openEdit ( row : DispatchListItem ) {
if ( ! canEdit ( row )) return
const source = dispatchSnapshots . get ( row . dispatchNo )
editRow . value = row
editItems . value = [...( source ? . items || [])]
Object . assign ( editForm , {
vehiclePlate : source ? . vehiclePlate || '' ,
driverName : source ? . driverName || '' ,
driverPhone : source ? . driverPhone || '' ,
carrierName : source ? . carrierName || '' ,
route : source ? . route || '' ,
departAt : source ? . departAt || '' ,
fbaAppointmentNo : source ? . fbaAppointmentNo || '' ,
fbaAppointmentStatus : source ? . fbaAppointmentStatus || '' ,
vehicleAppointmentStatus : source ? . vehicleAppointmentStatus || '' ,
estimatedArrivalAt : source ? . estimatedArrivalAt || '' ,
})
mutationError . value = null
editOpen . value = true
}
2026-10-03 04:35:55 +08:00
async function openTransfer ( row : DispatchListItem ) {
2026-09-13 21:10:05 +08:00
if ( ! canTransfer ( row )) return
transferRow . value = row
Object . assign ( transferForm , {
plannedDeliveryAt : '' ,
deliveryMethod : '' ,
waybillNo : '' ,
carrierName : row . carrierName === '—' ? '' : row . carrierName ,
pickupMethod : '' ,
loadingType : '' ,
vehicleType : '' ,
vehicleSize : '' ,
driverName : row . driverName === '—' ? '' : row . driverName ,
driverPhone : row . driverPhone === '—' ? '' : row . driverPhone ,
vehiclePlate : row . vehiclePlate === '—' ? '' : row . vehiclePlate ,
appointmentAccount : '' ,
sellerName : '' ,
deliveryFee : undefined ,
loadingFee : undefined ,
appointmentFee : undefined ,
remark : '' ,
})
mutationError . value = null
transferOpen . value = true
2026-10-03 04:35:55 +08:00
externalFreight . value = null
coveragePreview . value = null
coverageSelection . value = []
freightLoading . value = true
const generation = ++ freightGeneration
try {
const [ preview , coverage ] = await Promise . all ([ getDispatchExternalFreight ( row . dispatchNo ), getDispatchFeeCoverage ( row . dispatchNo )])
if ( generation === freightGeneration && transferOpen . value ) { externalFreight . value = preview ; coveragePreview . value = coverage }
} catch ( cause ) {
if ( generation === freightGeneration && transferOpen . value ) mutationError . value = getApiErrorDetails ( cause , '约定运费读取失败,请重新打开转派送' )
} finally {
if ( generation === freightGeneration ) freightLoading . value = false
}
2026-09-13 21:10:05 +08:00
}
async function submitEdit () {
if ( ! editRow . value || ! canEdit ( editRow . value ) || editSaving . value ) return
editSaving . value = true
mutationError . value = null
try {
await updateDispatch ( editRow . value . dispatchNo , {
vehiclePlate : editForm . vehiclePlate . trim () || null ,
driverName : editForm . driverName . trim () || null ,
driverPhone : editForm . driverPhone . trim () || null ,
carrierName : editForm . carrierName . trim () || null ,
departAt : editForm . departAt || null ,
fbaAppointmentNo : editForm . fbaAppointmentNo . trim () || null ,
fbaAppointmentStatus : editForm . fbaAppointmentStatus || null ,
vehicleAppointmentStatus : editForm . vehicleAppointmentStatus || null ,
estimatedArrivalAt : editForm . estimatedArrivalAt || null ,
})
editOpen . value = false
ElMessage . success ( '排车信息已保存' )
await load ()
} catch ( cause ) {
mutationError . value = getApiErrorDetails ( cause , '排车信息保存失败,请重试' )
} finally {
editSaving . value = false
}
}
async function removePallet ( itemValue : unknown ) {
const item = itemValue as DispatchItemDto
if ( ! editRow . value || removingItemId . value ) return
try {
2026-09-14 20:03:27 +08:00
await ElMessageBox . confirm ( `确认从排车单移除托盘 ${ text ( item . palletNo ) } ?移除后可重新排车。` , '移除排车托盘' , { customClass : 'admin-density-confirm' ,
2026-09-13 21:10:05 +08:00
confirmButtonText : '确认移除' ,
cancelButtonText : '返回' ,
type : 'warning' ,
})
} catch {
return
}
removingItemId . value = String ( item . id )
mutationError . value = null
try {
const updated = await removeDispatchItem ( editRow . value . dispatchNo , String ( item . id ))
editItems . value = [...( updated . items || [])]
2026-09-15 14:30:50 +08:00
editForm . route = updated . route || ''
2026-09-13 21:10:05 +08:00
dispatchSnapshots . set ( editRow . value . dispatchNo , updated )
const row = rows . value . find (( value ) => value . dispatchNo === editRow . value ? . dispatchNo )
if ( row ) {
row . palletCount = number ( updated . palletCount )
row . packageCount = number ( updated . packageCount )
row . orderNos = text ( updated . orderNos )
2026-09-15 14:30:50 +08:00
row . route = text ( updated . route )
2026-09-13 21:10:05 +08:00
}
ElMessage . success ( '托盘已移除,可重新排车' )
} catch ( cause ) {
mutationError . value = getApiErrorDetails ( cause , '托盘移除失败,请重试' )
} finally {
removingItemId . value = ''
}
}
async function cancel ( row : DispatchListItem ) {
if ( ! canCancel ( row )) return
try {
2026-09-14 20:03:27 +08:00
await ElMessageBox . confirm ( `确认取消排车单 ${ row . dispatchNo } ?取消后将释放托盘锁。` , '取消排车' , { customClass : 'admin-density-confirm' ,
2026-09-13 21:10:05 +08:00
confirmButtonText : '确认取消' ,
cancelButtonText : '返回' ,
type : 'warning' ,
})
} catch {
return
}
mutationError . value = null
try {
await cancelDispatch ( row . dispatchNo )
ElMessage . success ( '排车单已取消' )
await load ()
} catch ( cause ) {
mutationError . value = getApiErrorDetails ( cause , '排车取消失败,请重试' )
}
}
2026-09-18 14:58:40 +08:00
function rowActions ( row : DispatchListItem ) : RowActionItem [] {
2026-10-08 14:11:54 +08:00
if ( section . value !== 'vehicle' ) {
const reviewPermission = section . value === 'dispatchReview' ? 'platform:fleet:trip:dispatch-review' : 'platform:fleet:trip:loading-review'
return [
{ key : 'review' , label : access . can ( reviewPermission ) ? '前往审核' : '查看审核' , priority : 400 , visible : !! row . workflow && access . can ( 'platform:fleet:trip:list' ) },
{ key : 'detail' , label : '查看' , priority : 300 },
]
}
2026-09-18 14:58:40 +08:00
return [
{ key : 'detail' , label : '查看' , priority : 300 },
{ key : 'bol' , label : '打印BOL' , priority : 200 , loading : bolLoadingNo . value === row . dispatchNo },
{ key : 'edit' , label : '编辑' , priority : 100 , visible : canEdit ( row ) },
{ key : 'transfer' , label : '转派送' , priority : 250 , tone : 'success' , visible : canTransfer ( row ) },
{ key : 'cancel' , label : '取消' , tone : 'danger' , divided : true , visible : canCancel ( row ) },
]
}
function handleRowAction ( command : string , row : DispatchListItem ) {
2026-10-08 14:11:54 +08:00
if ( command === 'review' ) openTrip ( row , section . value === 'dispatchReview' ? 'overview' : 'loading' )
else if ( command === 'detail' ) openDetail ( row )
2026-09-18 14:58:40 +08:00
else if ( command === 'bol' ) void openBol ( row )
else if ( command === 'edit' ) openEdit ( row )
else if ( command === 'transfer' ) openTransfer ( row )
2026-09-13 21:10:05 +08:00
else if ( command === 'cancel' ) void cancel ( row )
}
2026-08-26 15:48:34 +08:00
async function submitTransfer () {
2026-09-01 14:23:53 +08:00
if ( ! transferRow . value || ! canTransfer ( transferRow . value )) return
2026-10-03 04:35:55 +08:00
if ( transferSaving . value || freightLoading . value || ! externalFreight . value ) return
2026-09-13 21:10:05 +08:00
if ( ! transferForm . plannedDeliveryAt || ! transferForm . deliveryMethod || ! transferForm . pickupMethod || ! transferForm . loadingType ) {
ElMessage . warning ( '请填写预计派送时间、派送方式、提货方式和装车类型' )
return
2026-09-08 20:27:47 +08:00
}
2026-10-03 04:35:55 +08:00
if ( requiresDeliveryFee . value && transferForm . deliveryFee == null ) {
2026-09-13 21:10:05 +08:00
ElMessage . warning ( '请填写派送费用' )
return
}
2026-10-03 04:35:55 +08:00
if ( requiresLoadingFee . value && transferForm . loadingFee == null ) {
2026-09-13 21:10:05 +08:00
ElMessage . warning ( '当前装车类型需要填写装车费' )
return
}
2026-10-03 04:35:55 +08:00
if ( isBuyAppointment . value && ( ! transferForm . sellerName . trim () || requiresAppointmentFee . value && transferForm . appointmentFee == null )) {
2026-09-13 21:10:05 +08:00
ElMessage . warning ( '买约时请填写卖家姓名和买约费用' )
return
}
const feeInputs = [
2026-10-03 04:35:55 +08:00
[ 'DELIVERY_FEE' , requiresDeliveryFee . value ? transferForm . deliveryFee : undefined , '派送费用' ],
[ 'LOADING_FEE' , requiresLoadingFee . value ? transferForm . loadingFee : undefined , '装车费' ],
[ 'APPOINTMENT_FEE' , requiresAppointmentFee . value ? transferForm . appointmentFee : undefined , '买约费用' ],
2026-09-13 21:10:05 +08:00
] as const
const fees = feeInputs
. filter (([, amount ]) => amount != null )
. map (([ feeType , amount , feeName ]) => ({
feeType ,
feeName ,
direction : 'RECEIVABLE' as const ,
currency : 'USD' as const ,
amount : Number ( amount ),
remark : feeName ,
}))
2026-08-26 15:48:34 +08:00
transferSaving . value = true
2026-09-13 21:10:05 +08:00
try {
await transferDispatchToDelivery ( transferRow . value . dispatchNo , {
idempotencyKey : `web-transfer- ${ transferRow . value . dispatchNo } - ${ Date . now () } ` ,
plannedDeliveryAt : transferForm . plannedDeliveryAt ,
deliveryMethod : transferForm . deliveryMethod ,
waybillNo : transferForm . waybillNo . trim () || undefined ,
carrierName : transferForm . carrierName . trim () || undefined ,
pickupMethod : transferForm . pickupMethod ,
loadingType : transferForm . loadingType ,
vehicleType : transferForm . vehicleType || undefined ,
vehicleSize : transferForm . vehicleSize || undefined ,
driverName : transferForm . driverName . trim () || undefined ,
driverPhone : transferForm . driverPhone . trim () || undefined ,
vehiclePlate : transferForm . vehiclePlate . trim () || undefined ,
appointmentAccount : transferForm . appointmentAccount || undefined ,
sellerName : isBuyAppointment . value ? transferForm . sellerName . trim () : undefined ,
remark : transferForm . remark . trim () || undefined ,
fees ,
2026-10-03 04:35:55 +08:00
externalFreightHash : externalFreight . value . snapshotHash ,
feeCoverageCargoHash : coverageSelection . value . length ? coveragePreview . value ? . cargoHash : undefined ,
feeCoverage : coverageSelection . value ,
2026-09-13 21:10:05 +08:00
})
transferOpen . value = false
ElMessage . success ( '已生成出库单和派送单' )
await load ()
} catch ( cause ) {
mutationError . value = getApiErrorDetails ( cause , '转派送失败,请重试' )
} finally {
transferSaving . value = false
}
2026-08-26 15:48:34 +08:00
}
2026-08-21 11:20:09 +08:00
onMounted ( load )
</ script >
< template >
2026-09-14 20:03:27 +08:00
< AdminPage class = "admin-density" >< ListPageLayout aria -label =" 海外仓排车列表 " >
2026-10-09 22:05:51 +08:00
< OperationErrorToast :details = "mutationError" />< TripPreparationDialog v-model = "prepareOpen" :warehouse-id="prepareWarehouse" :warehouse-name="prepareWarehouseName" :dispatch-nos="prepareNumbers" @saved="load()" />
< DataTable v -model:page = "page" v -model:page-size = "pageSize" appearance = "integrated" :stripe = "false" :data = "rows" :columns = "preparationColumns" @ selection -change =" selectedDispatches = $ event " :loading = "loading" :total = "total" :preference-key = "tablePreferenceKey" row -key =" id " empty -text =" 暂无排车记录 " @ update :page = "load()" @ update : page -size =" page = 1 ; load ()" >
< template # toolbar -start >< h1 > 排车列表 </ h1 ></ template >< template # toolbar -end >< el-button v-if = "canPrepare" type="primary" :disabled="!selectedDispatches.length" @click="beginPreparation" > 安排车次 </ el -button ></ template >
2026-10-08 14:11:54 +08:00
< template # filters >< div class = "integrated-query-group" >
< div class = "density-status-tabs" role = "tablist" aria -label =" 排车执行阶段 " >
< button v-for = "item in stages" :key="item.value" role="tab" :aria-selected="stage === item.value" :class="{ 'is-active': stage === item.value }" @click="switchStage(item.value)" >{{ item.label }}</ button >
</ div >
< div class = "density-status-tabs" role = "tablist" aria -label =" 排车业务分区 " >
2026-10-09 17:36:32 +08:00
< button v-for = "item in visibleSections" :key="item.value" role="tab" :aria-selected="section === item.value" :class="{ 'is-active': section === item.value }" @click="section = item.value" >{{ item.label }}</ button >
2026-10-08 14:11:54 +08:00
</ div >
< QueryToolbar v-model = "filters" :fields="fields" :loading="loading" ariaLabel="排车列表筛选" preference-key="overseas-dispatches-query" :search-on-reset="false" @search="search" @reset="reset" /></ div ></ template >
2026-09-30 14:01:58 +08:00
< template v-if = "loadError" #state><LoadErrorState title="排车列表加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></ template ></ LoadErrorState ></ template >
< template # actions = "{ row }" >< RowActions text -actions : actions = "rowActions(row as DispatchListItem)" : more -label ="` 更多操作 · $ { ( row as DispatchListItem ) .dispatchNo || ' 当前记录 ' } `" @action ="handleRowAction($event, row as DispatchListItem)" /></ template >
2026-10-08 14:11:54 +08:00
< template # workflow = "{ row }" >
< el-button v-if = "row.workflow && access.can('platform:fleet:trip:list')" link type="primary" @click="openTrip(row as DispatchListItem, 'overview')" >{{ row.workflow.tripNo }} </ el -button >
< span v-else >{{ row.workflow ? .tripNo || ' 未关联车次 ' }}</ span >
</ template >
< template v-for = "review in (['dispatchReview', 'loadingReview'] as const)" :key="review" #[review]="{ row }" >
< template v-if = "(row as DispatchListItem).workflow" >
< div >{{ reviewLabels [( row as DispatchListItem ). workflow ! [ review ]. status ] }}</ div >
< div v-if = "row.workflow[review].status === 'REJECTED'" >{{ row.workflow [ review ] .reason || '—' }}</ div >
< div v-if = "row.workflow[review].reviewedAt" >{{ formatOverseasDateTime ( row.workflow [ review ] .reviewedAt ) }}</ div >
</ template >
< span v-else > 未关联车次 </ span >
</ template >
2026-08-21 11:20:09 +08:00
< template # status = "{ row }" >< StatusBadge : status = "String((row as DispatchListItem).status || '—')" /></ template >
2026-10-09 17:36:32 +08:00
< template # routeMap = "{ row }" >< el-button v-if = "row.dispatchNo" link type="primary" @click="openRoute(row as DispatchListItem)" > 查看路线 </ el -button ></ template >
2026-08-21 11:20:09 +08:00
< template # pagination -left > 共 {{ total }} 条排车记录 </ template >
2026-09-30 14:01:58 +08:00
</ DataTable >
2026-10-09 17:36:32 +08:00
< TransportRouteDialog v-model = "routeOpen" :origin="routeOrigin" :destinations="routeDestinations" />
2026-10-03 04:35:55 +08:00
</ ListPageLayout ></ AdminPage >< AppDialog class = "admin-density-dialog" v-model = "transferOpen" title="转派送" width="min(1120px, calc(100vw - 28px))" confirm-text="确认提交" :confirm-loading="transferSaving" :confirm-disabled="freightLoading || !externalFreight" @confirm="submitTransfer"><el-form class="density-form" label-position="top"><h3>派送信息</h3><div class="transfer-grid"><el-form-item label="排车单号"><el-input :model-value="transferRow?.dispatchNo" readonly /></el-form-item><el-form-item label="预计派送时间" required><el-date-picker v-model="transferForm.plannedDeliveryAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计派送时间" /></el-form-item><el-form-item label="派送方式" required><el-select v-model="transferForm.deliveryMethod" placeholder="请选择"><el-option v-for="option in deliveryMethodOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="快递/车队"><el-input v-model="transferForm.carrierName" placeholder="请输入快递或车队名称" /></el-form-item><el-form-item label="运单号"><el-input v-model="transferForm.waybillNo" placeholder="请输入运单号" /></el-form-item><el-form-item label="提货方式" required><el-select v-model="transferForm.pickupMethod" placeholder="请选择"><el-option v-for="option in pickupMethodOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="装车类型" required><el-select v-model="transferForm.loadingType" placeholder="请选择"><el-option v-for="option in loadingTypeOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="司机/车队名称"><el-input v-model="transferForm.driverName" placeholder="请输入司机或车队名称" /></el-form-item><el-form-item label="联系电话"><el-input v-model="transferForm.driverPhone" placeholder="请输入联系电话" /></el-form-item><el-form-item label="车牌号"><el-input v-model="transferForm.vehiclePlate" placeholder="请输入车牌号" /></el-form-item><el-form-item label="车辆类型"><el-select v-model="transferForm.vehicleType" clearable placeholder="请选择"><el-option v-for="option in vehicleTypeOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="车辆大小"><el-select v-model="transferForm.vehicleSize" clearable placeholder="请选择"><el-option v-for="option in vehicleSizeOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="预约账号"><el-select v-model="transferForm.appointmentAccount" clearable placeholder="请选择"><el-option v-for="option in appointmentAccountOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item v-if="isBuyAppointment" label="卖家姓名" required><el-input v-model="transferForm.sellerName" placeholder="请输入卖家姓名" /></el-form-item></div><DeliveryFeeCoveragePanel v-model="coverageSelection" :preview="coveragePreview" :loading="freightLoading" :disabled="transferSaving || freightLoading" /><p v-if="coveredServices.length">本次全部货物已覆盖:{{ coveredServices.map(service => coveredServiceLabels[service]).join('、') }},无需重复登记对应应收。</p><h3>本次新增费用</h3><p v-if="freightLoading">正在读取约定运费…</p><p v-for="quote in externalFreight?.quotes || []" :key="quote.bookingNo">{{ quote.bookingNo }} · {{ quote.incomingNo }}: {{ quote.quantity }} {{ quote.quantityUnit }} × {{ quote.currency }} {{ quote.unitPrice }} = {{ quote.currency }} {{ quote.amount }}(外接约定运费)</p><div class="transfer-grid"><el-form-item v-if="requiresDeliveryFee" :label="externalFreight?.quotes.length || coverageSelection.length ? '派送费(未覆盖部分,USD) ' : '派送费用(USD) '" required><el-input-number v-model="transferForm.deliveryFee" :min="0" :precision="2" controls-position="right" placeholder="请输入派送费用" /></el-form-item><el-form-item v-if="requiresLoadingFee" label="装车费(USD) " re
2026-09-15 14:30:50 +08:00
< AppDialog class = "admin-density-dialog" v-model = "editOpen" title="编辑排车" width="min(900px, calc(100vw - 48px))" confirm-text="保存" :confirm-loading="editSaving" @confirm="submitEdit"><el-alert :title="`当前状态:${editRow?.status || '—'};普通编辑不会修改排车状态;未生成出库单前可移除托盘。`" type="info" :closable="false" /><el-form class="density-form" label-position="top"><div class="edit-grid"><el-form-item label="车牌号"><el-input v-model="editForm.vehiclePlate" placeholder="请输入车牌号" /></el-form-item><el-form-item label="司机"><el-input v-model="editForm.driverName" placeholder="请输入司机姓名" /></el-form-item><el-form-item label="联系电话"><el-input v-model="editForm.driverPhone" placeholder="请输入联系电话" /></el-form-item><el-form-item label="拖车公司名称"><el-input v-model="editForm.carrierName" placeholder="请输入拖车公司名称" /></el-form-item><el-form-item label="路线"><el-input v-model="editForm.route" readonly placeholder="按已排托盘自动汇总 FBA 仓" /></el-form-item><el-form-item label="发车时间"><el-date-picker v-model="editForm.departAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item><el-form-item label="预计到达时间"><el-date-picker v-model="editForm.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item><el-form-item label="FBA预约号"><el-input v-model="editForm.fbaAppointmentNo" placeholder="请输入 FBA 预约号" /></el-form-item><el-form-item label="FBA预约状态"><el-select v-model="editForm.fbaAppointmentStatus" clearable><el-option label="待预约" value="PENDING" /><el-option label="已预约" value="BOOKED" /><el-option label="已确认" value="CONFIRMED" /><el-option label="已取消" value="CANCELLED" /></el-select></el-form-item><el-form-item label="车辆预约状态"><el-select v-model="editForm.vehicleAppointmentStatus" clearable><el-option label="待预约" value="PENDING" /><el-option label="已预约" value="BOOKED" /><el-option label="已确认" value="CONFIRMED" /><el-option label="已取消" value="CANCELLED" /></el-select></el-form-item></div></el-form><h3>已排车托盘</h3><el-table :data="editItems" border max-height="280" empty-text="暂无托盘"><el-table-column label="操作" width="90" fixed="left"><template #default="{ row }"><el-button class="density-action" data-tone="danger" :loading="removingItemId === String(row.id)" :disabled="Boolean(removingItemId)" @click="removePallet(row)">移除</el-button></template></el-table-column><el-table-column prop="palletNo" label="托盘号" min-width="160" /><el-table-column prop="orderNo" label="订单号" min-width="160" /><el-table-column prop="inboundCode" label="入仓码" min-width="140" /><el-table-column prop="packageCount" label="箱数" width="80" align="right" /></ el-table ></ AppDialog >
2026-08-21 11:20:09 +08:00
</ template >
2026-09-13 21:10:05 +08:00
< style scoped >
. transfer - grid ,
. edit - grid {
display : grid ;
grid - template - columns : repeat ( 3 , minmax ( 0 , 1 fr ));
2026-09-14 20:03:27 +08:00
gap : 0 var ( -- admin - space - 5 );
2026-09-13 21:10:05 +08:00
}
. transfer - grid : deep (. el - select ),
. transfer - grid : deep (. el - date - editor ),
. transfer - grid : deep (. el - input - number ),
. edit - grid : deep (. el - select ),
. edit - grid : deep (. el - date - editor ) {
width : 100 % ;
}
h3 {
2026-09-14 20:03:27 +08:00
margin : var ( -- admin - space - 1 ) 0 var ( -- admin - space - 5 );
2026-09-13 21:10:05 +08:00
}
@ media ( max - width : 900 px ) {
. transfer - grid ,
. edit - grid {
grid - template - columns : 1 fr ;
}
}
</ style >