Files
admin-web/src/modules/overseas-warehouse/pages/DispatchesView.vue
T

744 lines
41 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { appointmentReturnTarget, appointmentReturnQuery } from '@/modules/appointment-calendar'
import { TripPreparationDialog, TransportRouteDialog, routeLocation, type RouteLocation } from '@/modules/overseas-master-data'
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useRoute, useRouter } from 'vue-router'
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'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
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'
import AppDialog from '@/shared/components/AppDialog.vue'
import type { DispatchListItem } from '../types'
import { formatOverseasDateTime } from '../lastMilePresentation'
import { overseasWarehousePermissions as permission } from '../permissions'
import { dispatchVehicleSizeOptions } from '../dispatchVehicleSpecs'
const route = useRoute(), router = useRouter()
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
}
const access = useAccessPreviewStore()
const rows = ref<DispatchListItem[]>([])
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
}
const loading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const page = ref(1)
const pageSize = ref(10)
const total = ref(0)
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
const visibleSections = computed(() => stage.value === 'BEFORE_LOADING' ? sections : sections.filter(item => item.value === 'vehicle'))
const reviewLabels = { PENDING: '待审核', APPROVED: '已通过', REJECTED: '已打回' }
let loadSequence = 0
function switchStage(value: typeof stage.value) {
if (stage.value === value) return
stage.value = value
if (value !== 'BEFORE_LOADING') section.value = 'vehicle'
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, ...appointmentReturnQuery(route.query) } })
}
const filters = ref<QueryModel>({
dispatchNo: typeof route.query.dispatchNo === 'string' ? route.query.dispatchNo : '',
vehiclePlate: '',
status: '',
fbaAppointmentNo: '',
})
const transferOpen = ref(false)
const transferRow = ref<DispatchListItem | null>(null)
const transferSaving = ref(false)
const transferIdempotencyKey = ref('')
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
const mutationError = ref<ApiErrorDetails | null>(null)
const editOpen = ref(false)
const editRow = ref<DispatchListItem | null>(null)
const editSaving = ref(false)
const editItems = ref<DispatchItemDto[]>([])
const removingItemId = ref('')
const bolLoadingNo = ref('')
const dispatchSnapshots = new Map<string, DispatchSummaryDto>()
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: '',
})
const deliveryMethodOptions = [
{ 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' },
]
const pickupMethodOptions = [
{ label: '一提一卸', value: 'ONE_PICKUP_ONE_DROP' },
{ label: '一提多卸', value: 'ONE_PICKUP_MULTI_DROP' },
{ label: '多提一卸', value: 'MULTI_PICKUP_ONE_DROP' },
{ label: '多提多卸', value: 'MULTI_PICKUP_MULTI_DROP' },
{ label: '仓库自提', value: 'WAREHOUSE_PICKUP' },
]
const loadingTypeOptions = [
{ label: '托盘', value: 'PALLET' },
{ label: '地板', value: 'FLOOR' },
{ label: '混装', value: 'MIXED' },
]
const vehicleTypeOptions = ['厢式货车', '平板车', '冷藏车', '集装箱卡车', '尾板车'].map((label) => ({ label, value: label }))
const vehicleSizeOptions = dispatchVehicleSizeOptions
const appointmentAccountOptions = [
{ label: '4号账号(美西)', value: '4' },
{ label: '8号账号(美东)', value: '8' },
{ label: '10号账号(美中)', value: '10' },
{ label: '12号账号(加拿大)', value: '12' },
{ label: '买约', value: 'BUY_APPOINTMENT' },
]
const isBuyAppointment = computed(() => transferForm.appointmentAccount === 'BUY_APPOINTMENT')
const needsLoadingFee = computed(() => ['FLOOR', 'MIXED'].includes(transferForm.loadingType))
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[]>(() => [
{
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,
},
])
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 260, fixed: 'left', slotName: 'actions' },
{ key: 'dispatchNo', prop: 'dispatchNo', label: '排车单号', minWidth: 220, fixed: 'left', singleLine: false },
{ key: 'workflow', label: '关联车次', minWidth: 230, slotName: 'workflow', singleLine: false },
{ key: 'dispatchReview', label: '调度审核', minWidth: 180, slotName: 'dispatchReview', singleLine: false },
{ key: 'loadingReview', label: '理货审核(整车)', minWidth: 180, slotName: 'loadingReview', singleLine: false },
{ key: 'vehiclePlate', prop: 'vehiclePlate', label: '车牌号', minWidth: 150, singleLine: false },
{ key: 'driverName', prop: 'driverName', label: '司机', minWidth: 150, singleLine: false },
{ key: 'driverPhone', prop: 'driverPhone', label: '联系电话', minWidth: 140 },
{
key: 'carrierName',
prop: 'carrierName',
label: '托车公司名称',
minWidth: 160,
},
{ key: 'orderNos', prop: 'orderNos', label: '关联订单', minWidth: 260, singleLine: false },
{ key: 'route', prop: 'route', label: '路线', minWidth: 180 },
{
key: 'overseasWarehouse',
prop: 'overseasWarehouse',
label: '海外仓',
minWidth: 160,
},
{ key: 'departAt', prop: 'departAt', label: '发车时间', minWidth: 170 },
{
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',
},
{ key: 'createdAt', prop: 'createdAt', label: '创建时间', minWidth: 170 },
]
const visibleColumns = computed(() => {
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
}
const keys = ['actions', 'dispatchNo', 'workflow', section.value, 'vehiclePlate', 'driverName', 'route', 'palletCount', 'packageCount', 'status']
return keys.map(key => columns.find(column => column.key === key)!)
})
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)
const tablePreferenceKey = computed(() => section.value === 'vehicle' ? 'overseas-dispatches' : `overseas-dispatches-${section.value}`)
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)
}
function appointmentStatusLabel(value: unknown) {
const code = String(value || '')
const labels: Record<string, string> = {
PENDING: '待预约',
BOOKED: '已预约',
CONFIRMED: '已确认',
CANCELLED: '已取消',
}
return labels[code] || text(value)
}
function appointmentLabel(numberValue: unknown, statusValue: unknown) {
const appointmentNo = text(numberValue)
const appointmentStatus = appointmentStatusLabel(statusValue)
if (appointmentNo === '—') return appointmentStatus
return appointmentStatus === '—' ? appointmentNo : `${appointmentNo} · ${appointmentStatus}`
}
function toListItem(dto: DispatchSummaryDto): DispatchListItem {
return {
actions: dto.actions,
workflow: dto.workflow,
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),
overseasWarehouse: text(dto.overseasWarehouseName || '仓库名称待补充'),
departAt: formatOverseasDateTime(dto.departAt),
fbaAppointment: appointmentLabel(dto.fbaAppointmentNo, dto.fbaAppointmentStatus),
vehicleAppointment: appointmentStatusLabel(dto.vehicleAppointmentStatus),
estimatedArrivalAt: formatOverseasDateTime(dto.estimatedArrivalAt),
status: statusLabel(dto.status),
statusCode: String(dto.status || ''),
palletCount: number(dto.palletCount),
packageCount: number(dto.packageCount),
createdAt: formatOverseasDateTime(dto.createdAt),
}
}
function dispatchError(error: unknown) {
const details = getApiErrorDetails(error, '排车列表加载失败,请重试')
return details.code === 'HTTP_404'
? {
...details,
message: '排车接口未启用或不存在,请确认海外尾程排车接口已发布',
}
: details
}
async function load(model: QueryModel = filters.value) {
const sequence = ++loadSequence
Object.assign(filters.value, model)
loading.value = true
loadError.value = null
try {
const result = await listDispatches({
page: page.value,
size: pageSize.value,
stage: stage.value || undefined,
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,
})
if (sequence !== loadSequence) return
dispatchSnapshots.clear()
result.records.forEach((item) => dispatchSnapshots.set(String(item.dispatchNo), item))
rows.value = result.records.map(toListItem)
total.value = result.total
} catch (error) {
if (sequence !== loadSequence) return
rows.value = []
total.value = 0
loadError.value = dispatchError(error)
} finally {
if (sequence === loadSequence) loading.value = false
}
}
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 },
})
}
async function openBol(row: DispatchListItem) {
if (bolLoadingNo.value) return
const pdfWindow = window.open('', '_blank')
if (!pdfWindow) { ElMessage.warning('浏览器拦截了 BOL PDF 窗口,请允许本站打开新窗口'); return }
bolLoadingNo.value = row.dispatchNo
mutationError.value = null
try {
const pdf = await getDispatchBolPdf(row.dispatchNo)
const url = URL.createObjectURL(pdf)
pdfWindow.location.href = url
window.setTimeout(() => URL.revokeObjectURL(url), 60_000)
} catch (cause) {
pdfWindow.close()
mutationError.value = getApiErrorDetails(cause, 'BOL PDF 生成失败,请重试')
} finally {
bolLoadingNo.value = ''
}
}
function canEdit(row: DispatchListItem) {
return access.can(permission.dispatchEdit) && row.actions?.canEdit === true
}
function canCancel(row: DispatchListItem) {
return access.can(permission.dispatchCancel) && row.actions?.canCancel === true
}
function canTransfer(row: DispatchListItem) {
return access.can(permission.dispatchTransferDelivery) && row.actions?.canTransfer === true
}
function canRemovePallet(row: DispatchListItem) {
return access.can(permission.dispatchEdit) && row.actions?.canRemovePallet === true
}
function actionBlockReasons(row: DispatchListItem) {
const actions = row.actions
if (!actions) return ['操作资格暂未返回,请刷新列表']
return [...new Set([actions.editBlockReason, actions.cancelBlockReason, actions.removePalletBlockReason, actions.transferBlockReason].filter(Boolean))]
}
function openOutbound(row: DispatchListItem) {
if (!row.actions?.outboundNo || !access.can(permission.outboundList)) return
void router.push({ name: 'overseas-outbounds', query: { documentNo: row.actions.outboundNo } })
}
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
}
async function openTransfer(row: DispatchListItem) {
if (!canTransfer(row)) return
transferRow.value = row
transferIdempotencyKey.value = `web-transfer-${row.dispatchNo}-${globalThis.crypto?.randomUUID?.() || `${Date.now()}-${Math.random().toString(16).slice(2)}`}`
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
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
}
}
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 || !canRemovePallet(editRow.value) || removingItemId.value) return
try {
await ElMessageBox.confirm(`确认从排车单移除托盘 ${text(item.palletNo)}?移除后可重新排车。`, '移除排车托盘', { customClass: 'admin-density-confirm',
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 || [])]
editForm.route = updated.route || ''
dispatchSnapshots.set(editRow.value.dispatchNo, updated)
const row = rows.value.find((value) => value.dispatchNo === editRow.value?.dispatchNo)
if (row) {
Object.assign(row, toListItem(updated))
editRow.value = row
}
ElMessage.success('托盘已移除,可重新排车')
} catch (cause) {
mutationError.value = getApiErrorDetails(cause, '托盘移除失败,请重试')
} finally {
removingItemId.value = ''
}
}
async function cancel(row: DispatchListItem) {
if (!canCancel(row)) return
try {
await ElMessageBox.confirm(`确认取消排车单 ${row.dispatchNo}?取消后将释放托盘锁。`, '取消排车', { customClass: 'admin-density-confirm',
confirmButtonText: '确认取消',
cancelButtonText: '返回',
type: 'warning',
})
} catch {
return
}
mutationError.value = null
try {
await cancelDispatch(row.dispatchNo)
ElMessage.success('排车单已取消并释放占用,可从入仓单或换标单重新预排')
await load()
} catch (cause) {
mutationError.value = getApiErrorDetails(cause, '排车取消失败,请重试')
}
}
function rowActions(row: DispatchListItem): RowActionItem[] {
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 },
]
}
return [
{ key: 'detail', label: '查看', priority: 300 },
{ key: 'bol', label: '打印BOL', priority: 200, loading: bolLoadingNo.value === row.dispatchNo },
{ key: 'trip', label: '处理原车次', priority: 150, visible: !!row.workflow && access.can('platform:fleet:trip:list') },
{ key: 'outbound', label: '查看原出库', priority: 150, visible: !!row.actions?.outboundNo && access.can(permission.outboundList) },
{ 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) {
if (command === 'review') openTrip(row, section.value === 'dispatchReview' ? 'overview' : 'loading')
else if (command === 'detail') openDetail(row)
else if (command === 'bol') void openBol(row)
else if (command === 'trip') openTrip(row, 'overview')
else if (command === 'outbound') openOutbound(row)
else if (command === 'edit') openEdit(row)
else if (command === 'transfer') openTransfer(row)
else if (command === 'cancel') void cancel(row)
}
async function submitTransfer() {
if (!transferRow.value || !canTransfer(transferRow.value)) return
if (transferSaving.value || freightLoading.value || !externalFreight.value) return
if (!transferForm.plannedDeliveryAt || !transferForm.deliveryMethod || !transferForm.pickupMethod || !transferForm.loadingType) {
ElMessage.warning('请填写预计派送时间、派送方式、提货方式和装车类型')
return
}
if (requiresDeliveryFee.value && transferForm.deliveryFee == null) {
ElMessage.warning('请填写派送费用')
return
}
if (requiresLoadingFee.value && transferForm.loadingFee == null) {
ElMessage.warning('当前装车类型需要填写装车费')
return
}
if (isBuyAppointment.value && (!transferForm.sellerName.trim() || requiresAppointmentFee.value && transferForm.appointmentFee == null)) {
ElMessage.warning('买约时请填写卖家姓名和买约费用')
return
}
const feeInputs = [
['DELIVERY_FEE', requiresDeliveryFee.value ? transferForm.deliveryFee : undefined, '派送费用'],
['LOADING_FEE', requiresLoadingFee.value ? transferForm.loadingFee : undefined, '装车费'],
['APPOINTMENT_FEE', requiresAppointmentFee.value ? transferForm.appointmentFee : undefined, '买约费用'],
] 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,
}))
transferSaving.value = true
try {
const result = await transferDispatchToDelivery(transferRow.value.dispatchNo, {
idempotencyKey: transferIdempotencyKey.value,
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,
externalFreightHash: externalFreight.value.snapshotHash,
feeCoverageCargoHash: coverageSelection.value.length ? coveragePreview.value?.cargoHash : undefined,
feeCoverage: coverageSelection.value,
})
transferOpen.value = false
if (result.outboundStatus === 'CANCELLED') {
ElMessage.warning(`原出库单 ${result.outboundNo} 已取消,本次未生成新单据;请取消原排车后重新预排`)
} else if (result.replayed) {
ElMessage.info(`已存在出库单 ${result.outboundNo} 及关联派送单,本次未重复生成`)
} else {
ElMessage.success('已生成出库单和派送单')
}
await load()
} catch (cause) {
mutationError.value = getApiErrorDetails(cause, '转派送失败,请重试')
} finally {
transferSaving.value = false
}
}
onMounted(load)
</script>
<template>
<AdminPage class="admin-density"><ListPageLayout aria-label="海外仓排车列表">
<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><el-button v-if="appointmentReturnTarget(route.query)" link @click="router.push(appointmentReturnTarget(route.query)!)">返回预约工作</el-button><h1>排车列表</h1></template><template #toolbar-end><el-button v-if="canPrepare" type="primary" :disabled="!selectedDispatches.length" @click="beginPreparation">安排车次</el-button></template>
<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="排车业务分区">
<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>
</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>
<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)" /><p v-for="reason in actionBlockReasons(row as DispatchListItem)" :key="String(reason)">{{ reason }}</p></template>
<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>
<template #status="{ row }"><StatusBadge :status="String((row as DispatchListItem).status || '—')" /></template>
<template #routeMap="{ row }"><el-button v-if="row.dispatchNo" link type="primary" @click="openRoute(row as DispatchListItem)">查看路线</el-button></template>
<template #pagination-left>共 {{ total }} 条排车记录</template>
</DataTable>
<TransportRouteDialog v-model="routeOpen" :origin="routeOrigin" :destinations="routeDestinations" />
</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)" required><el-input-number v-model="transferForm.loadingFee" :min="0" :precision="2" controls-position="right" placeholder="请输入装车费" /></el-form-item><el-form-item v-if="requiresAppointmentFee" label="买约费用(USD)" required><el-input-number v-model="transferForm.appointmentFee" :min="0" :precision="2" controls-position="right" placeholder="请输入买约费用" /></el-form-item></div><el-form-item label="备注"><el-input v-model="transferForm.remark" type="textarea" :rows="3" placeholder="请输入派送备注(选填)" /></el-form-item><el-alert title="原运费仅按上方确认的服务范围引用;外接运费按实收约定带入。下方只登记未覆盖货物或服务的新增应收。" type="info" :closable="false" /></el-form></AppDialog>
<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><p v-if="editRow?.actions?.removePalletBlockReason">{{ editRow.actions.removePalletBlockReason }}</p><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) || !editRow || !canRemovePallet(editRow)" @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>
</template>
<style scoped>
.transfer-grid,
.edit-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0 var(--admin-space-5);
}
.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 {
margin: var(--admin-space-1) 0 var(--admin-space-5);
}
@media (max-width: 900px) {
.transfer-grid,
.edit-grid {
grid-template-columns: 1fr;
}
}
</style>