744 lines
41 KiB
Vue
744 lines
41 KiB
Vue
<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>
|