优化
This commit is contained in:
@@ -1,743 +1,127 @@
|
||||
<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 { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { appointmentReturnTarget } from '@/modules/appointment-calendar'
|
||||
import { TransportRouteDialog, routeLocation, type RouteLocation } from '@/modules/overseas-master-data'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
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 DataTable, { type DataTableColumn } from '@/shared/components/DataTable.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'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
|
||||
import { getDispatch, getDispatchBolPdf, listDispatches } from '../api/dispatches'
|
||||
import { dispatchStages, reviewLabels, stageLabel, type BeforeLoadingSection, type DispatchAction, type DispatchListRow, type DispatchStage, type DispatchStatistics, type DispatchStatus, type ReviewStatus } from '../dispatchContracts'
|
||||
import { canDispatchAction, dispatchActionLabels, dispatchActionPermissions } from '../dispatchPresentation'
|
||||
import { formatOverseasDateTime as time } from '../lastMilePresentation'
|
||||
import SmartDispatchDialog from '../components/SmartDispatchDialog.vue'
|
||||
import DispatchWorkflowDialog from '../components/DispatchWorkflowDialog.vue'
|
||||
import { useDispatchCommandRecovery } from '../dispatchCommandRecovery'
|
||||
|
||||
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 route = useRoute(), router = useRouter(), access = useAccessPreviewStore()
|
||||
const auth = useAuthStore(), recovery = useDispatchCommandRecovery(() => auth.user?.id || '')
|
||||
const rows = ref<DispatchListRow[]>([]), statistics = ref<DispatchStatistics | null>(null)
|
||||
const loading = ref(false), loadError = ref<ApiErrorDetails | null>(null), mutationError = 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 stage = ref<DispatchStage>('BEFORE_LOADING'), section = ref<BeforeLoadingSection>('VEHICLE_INFO')
|
||||
const model = ref<QueryModel>({ dispatchNo: String(route.query.dispatchNo || ''), vehiclePlate: '', status: 'ACTIVE', reviewStatus: '' })
|
||||
const operation = ref<'ROUTE' | undefined>()
|
||||
const action = ref<DispatchAction>('EDIT'), actionNo = ref(''), actionOpen = ref(false), createOpen = ref(false), bolLoadingNo = ref('')
|
||||
const routeOpen = ref(false), routeOrigin = ref<RouteLocation>(routeLocation('')), routeDestinations = ref<RouteLocation[]>([])
|
||||
const sections: Array<{ value: BeforeLoadingSection; label: string }> = [{ value: 'VEHICLE_INFO', label: '车辆信息' }, { value: 'DISPATCH_REVIEW', label: '调度审核' }, { value: 'LOADING_REVIEW', label: '理货员审核' }]
|
||||
const showSections = computed(() => stage.value === 'BEFORE_LOADING' && model.value.status === 'ACTIVE')
|
||||
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,
|
||||
},
|
||||
{ key: 'dispatchNo', label: '排车单号', type: 'input', width: 180, placeholder: '请输入排车单号' },
|
||||
{ key: 'vehiclePlate', label: '车牌号', type: 'input', width: 150, placeholder: '请输入车牌号' },
|
||||
{ key: 'status', label: '单据范围', type: 'select', width: 140, options: [{ value: 'ACTIVE', label: '有效排车' }, { value: 'CANCELLED', label: '已取消' }, { value: 'ALL', label: '全部单据' }] },
|
||||
...(showSections.value && section.value !== 'VEHICLE_INFO' ? [{ key: 'reviewStatus', label: '审核结果', type: 'select' as const, width: 140, options: Object.entries(reviewLabels).map(([value, label]) => ({ value, label })) }] : []),
|
||||
])
|
||||
|
||||
const columns: DataTableColumn[] = [
|
||||
const columns = computed<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
|
||||
{ key: 'dispatchNo', prop: 'dispatchNo', label: '排车单号', minWidth: 190 },
|
||||
{ key: 'vehicle', label: '车牌号', minWidth: 130, slotName: 'vehicle' }, { key: 'driver', label: '司机/联系电话', minWidth: 170, slotName: 'driver' },
|
||||
{ key: 'warehouseName', prop: 'warehouseName', label: '起运仓', minWidth: 160 },
|
||||
...(showSections.value && section.value !== 'VEHICLE_INFO' ? [{ key: 'review', label: section.value === 'DISPATCH_REVIEW' ? '调度审核' : '理货员审核', minWidth: 210, slotName: 'review' }] : []),
|
||||
{ key: 'counts', label: '托数/箱数/已拣', minWidth: 160, slotName: 'counts' }, { key: 'planned', label: '计划发车', minWidth: 165, slotName: 'planned' },
|
||||
{ key: 'departure', label: '实际发车', minWidth: 165, slotName: 'departure' }, { key: 'stage', label: '作业阶段', minWidth: 110, slotName: 'stage' },
|
||||
...(stage.value === 'AFTER_LOADING' ? [{ key: 'routeMap', label: '路线', width: 100, slotName: 'routeMap' }] : []),
|
||||
])
|
||||
const preferenceKey = computed(() => `overseas-dispatches-${stage.value}-${section.value}`)
|
||||
let sequence = 0
|
||||
async function load() {
|
||||
const current = ++sequence
|
||||
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
|
||||
}
|
||||
const result = await listDispatches({ page: page.value, size: pageSize.value, stage: stage.value, status: String(model.value.status || 'ACTIVE') as DispatchStatus | 'ALL', dispatchNo: String(model.value.dispatchNo || '').trim() || undefined, vehiclePlate: String(model.value.vehiclePlate || '').trim() || undefined,
|
||||
...(showSections.value ? { beforeLoadingSection: section.value, ...(section.value !== 'VEHICLE_INFO' && model.value.reviewStatus ? { reviewStatus: String(model.value.reviewStatus) as ReviewStatus } : {}) } : {}) })
|
||||
if (current !== sequence) return
|
||||
rows.value = result.records; total.value = result.total; statistics.value = result.statistics
|
||||
} catch (cause) { if (current === sequence) { rows.value = []; total.value = 0; statistics.value = null; loadError.value = getApiErrorDetails(cause, '排车列表加载失败,请重试') } }
|
||||
finally { if (current === sequence) loading.value = false }
|
||||
}
|
||||
|
||||
function search(model: QueryModel) {
|
||||
page.value = 1
|
||||
void load(model)
|
||||
function search(value: QueryModel) { model.value = value; page.value = 1; void load() }
|
||||
function switchStage(value: DispatchStage) { stage.value = value; section.value = 'VEHICLE_INFO'; model.value.reviewStatus = ''; page.value = 1; void load() }
|
||||
function switchSection(value: BeforeLoadingSection) { section.value = value; model.value.reviewStatus = ''; page.value = 1; void load() }
|
||||
function openDetail(row: DispatchListRow) { void router.push({ name: 'overseas-dispatch-detail', params: { dispatchNo: row.dispatchNo } }) }
|
||||
const rowActionOrder: DispatchAction[] = ['EDIT', 'ADD_CARGO', 'REMOVE_CARGO', 'SUBMIT_REVIEW', 'DISPATCH_REVIEW', 'LOADING_REVIEW', 'RETURN_TO_REVIEW', 'LOADING_COMPLETE', 'ARRANGE_DELIVERY', 'DEPART', 'CANCEL']
|
||||
function rowActions(row: DispatchListRow): RowActionItem[] {
|
||||
return [{ key: 'detail', label: '详情', priority: 500 }, ...rowActionOrder.filter(value => canDispatchAction(row.actions, value, access.can)).map((value, index) => ({ key: value, label: dispatchActionLabels[value], priority: 400 - index * 10, tone: value === 'CANCEL' ? 'danger' as const : undefined })), ...(canDispatchAction(row.actions, 'EDIT', access.can) ? [{ key: 'route-order', label: '调整卸货顺序', priority: 190 }] : []), { key: 'bol', label: '下载BOL', priority: 100, loading: bolLoadingNo.value === row.dispatchNo }]
|
||||
}
|
||||
function reset(model: QueryModel) {
|
||||
page.value = 1
|
||||
void load(model)
|
||||
function handleAction(value: string, row: DispatchListRow) {
|
||||
if (value === 'detail') return openDetail(row)
|
||||
if (value === 'bol') return void openBol(row)
|
||||
if (value === 'route-order') { if (!canDispatchAction(row.actions, 'EDIT', access.can)) return; operation.value = 'ROUTE'; action.value = 'EDIT'; actionNo.value = row.dispatchNo; actionOpen.value = true; return }
|
||||
operation.value = undefined
|
||||
const requested = value as DispatchAction
|
||||
if (!canDispatchAction(row.actions, requested, access.can)) return
|
||||
action.value = requested; actionNo.value = row.dispatchNo; actionOpen.value = true
|
||||
}
|
||||
function openDetail(row: DispatchListItem) {
|
||||
void router.push({
|
||||
name: 'overseas-dispatch-detail',
|
||||
params: { dispatchNo: row.dispatchNo },
|
||||
})
|
||||
function recoverPending() {
|
||||
const command = recovery.pending.value
|
||||
if (!command) return
|
||||
if (command.action === 'CREATE') { createOpen.value = true; return }
|
||||
operation.value = command.operation; action.value = command.action; actionNo.value = command.dispatchNo; actionOpen.value = true
|
||||
}
|
||||
async function openBol(row: DispatchListItem) {
|
||||
async function openBol(row: DispatchListRow) {
|
||||
if (bolLoadingNo.value) return
|
||||
const pdfWindow = window.open('', '_blank')
|
||||
if (!pdfWindow) { ElMessage.warning('浏览器拦截了 BOL PDF 窗口,请允许本站打开新窗口'); return }
|
||||
const windowHandle = window.open('', '_blank')
|
||||
if (!windowHandle) { ElMessage.warning('请允许本站打开BOL预览窗口'); 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 = ''
|
||||
}
|
||||
try { const pdf = await getDispatchBolPdf(row.dispatchNo); const url = URL.createObjectURL(pdf); windowHandle.location.href = url; window.setTimeout(() => URL.revokeObjectURL(url), 60_000) }
|
||||
catch (cause) { windowHandle.close(); mutationError.value = getApiErrorDetails(cause, 'BOL读取失败') }
|
||||
finally { bolLoadingNo.value = '' }
|
||||
}
|
||||
function canEdit(row: DispatchListItem) {
|
||||
return access.can(permission.dispatchEdit) && row.actions?.canEdit === true
|
||||
async function openRoute(row: DispatchListRow) {
|
||||
try { const data = await getDispatch(row.dispatchNo); if (data.dispatchNo !== row.dispatchNo) throw new Error('路线排车号不一致'); routeOrigin.value = { kind: 'warehouse', label: row.warehouseName, warehouseId: row.warehouseId }; routeDestinations.value = [...new Set(data.orderDetails.map(item => item.destinationFbaWarehouse).filter((code): code is string => Boolean(code)))].map(code => ({ kind: 'warehouse', label: code, warehouseCode: code })); routeOpen.value = true }
|
||||
catch (cause) { mutationError.value = getApiErrorDetails(cause, '运输路线读取失败') }
|
||||
}
|
||||
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)
|
||||
onBeforeUnmount(() => { sequence++ })
|
||||
</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>
|
||||
<OperationErrorToast :details="mutationError" />
|
||||
<p v-if="recovery.pending.value" role="status">{{ recovery.pending.value.dispatchNo || '新排车单' }} 的{{ dispatchActionLabels[recovery.pending.value.action] }}结果尚待核对。<el-button link :disabled="recovery.busy.value" @click="recoverPending">核对原请求</el-button></p>
|
||||
<DataTable v-model:page="page" v-model:page-size="pageSize" appearance="integrated" :stripe="false" :data="rows" :columns="columns" :loading="loading" :total="total" :preference-key="preferenceKey" row-key="dispatchNo" 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="access.can(dispatchActionPermissions.CREATE)" type="primary" @click="createOpen = true">新建排车</el-button></template>
|
||||
<template #filters><div class="integrated-query-group"><div class="density-status-tabs" role="tablist" aria-label="排车执行阶段"><button v-for="item in dispatchStages" :key="item.value" role="tab" :aria-selected="stage === item.value" :class="{ 'is-active': stage === item.value }" @click="switchStage(item.value)">{{ item.label }} <span>{{ statistics?.stageCounts[item.value] ?? '—' }}</span></button></div><div v-if="showSections" class="density-status-tabs" role="tablist" aria-label="装车前审核分区"><button v-for="item in sections" :key="item.value" role="tab" :aria-selected="section === item.value" :class="{ 'is-active': section === item.value }" @click="switchSection(item.value)">{{ item.label }}</button></div><QueryToolbar v-model="model" :fields="fields" :loading="loading" ariaLabel="排车列表筛选" preference-key="overseas-dispatches-query" :search-on-reset="false" @search="search" @reset="search" /></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>
|
||||
<template #actions="{ row }"><RowActions text-actions :actions="rowActions(row as DispatchListRow)" :more-label="`更多操作 · ${row.dispatchNo}`" @action="handleAction($event, row as DispatchListRow)" /><p v-if="!row.actions">操作资格未返回,请刷新核对</p></template>
|
||||
<template #vehicle="{ row }">{{ row.resources.vehicle?.plate || '—' }}</template><template #driver="{ row }">{{ row.resources.driver?.name || '—' }}<br>{{ row.resources.driver?.phone || '—' }}</template>
|
||||
<template #review="{ row }"><template v-for="review in [section === 'DISPATCH_REVIEW' ? row.reviews.dispatchReview : row.reviews.loadingReview]" :key="review.reviewRound"><div>{{ reviewLabels[review.status as ReviewStatus] }}</div><div v-if="review.reason">{{ review.reason }}</div><div v-if="review.invalidatedReason">{{ review.invalidatedReason }}</div><div>{{ time(review.reviewedAt) }}</div></template></template>
|
||||
<template #counts="{ row }">{{ row.counts.palletCount }}托/{{ row.counts.packageCount }}箱/已拣{{ row.counts.pickedPackageCount }}箱</template><template #planned="{ row }">{{ time(row.plannedDepartureAt) }}</template><template #departure="{ row }">{{ time(row.departedAt) }}</template><template #stage="{ row }">{{ row.status === 'CANCELLED' ? '已取消' : stageLabel(row.stage) }}</template><template #routeMap="{ row }"><el-button link @click="openRoute(row as DispatchListRow)">查看路线</el-button></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>
|
||||
</ListPageLayout></AdminPage>
|
||||
<SmartDispatchDialog v-model="createOpen" :orders="[]" @created="load" />
|
||||
<DispatchWorkflowDialog v-model="actionOpen" :dispatch-no="actionNo" :action="action" :operation="operation" @saved="load" />
|
||||
<TransportRouteDialog v-model="routeOpen" :origin="routeOrigin" :destinations="routeDestinations" />
|
||||
</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>
|
||||
|
||||
Reference in New Issue
Block a user