refactor(admin): scope truck orders to bookings

This commit is contained in:
2026-09-01 16:14:16 +08:00
parent adb077a714
commit 9fcea1ca1c
13 changed files with 118 additions and 204 deletions
@@ -11,7 +11,7 @@ vi.mock('vue-router', () => ({ useRoute: () => ({ query: routing.query }), useRo
vi.mock('../api/platformTrucking', () => ({ createTruckAppointment: vi.fn(), listTruckAppointments: vi.fn() }))
const existing = (status: TruckAppointmentResource['status']): TruckAppointmentResource => ({
id: 'appointment-1', appointmentNo: 'TA-260901-0003', status, containerBookingId: 'container-1', attachments: [],
id: 'appointment-1', appointmentNo: 'TA-260901-0003', status, oceanBookingId: 'booking-1', attachments: [],
})
const page = (records: TruckAppointmentResource[]) => ({ records, page: 1, size: 20, total: records.length, pages: 1 })
const formStub = {
@@ -26,7 +26,7 @@ type ViewModel = {
lookupExistingAppointment: (id: string, autoNavigate?: boolean) => Promise<void>
existingAppointment: TruckAppointmentResource | null
existingLookupError: { message: string } | null
checkedContainerBookingId: string
checkedOceanBookingId: string
}
function mountView() {
@@ -43,19 +43,19 @@ describe('TruckAppointmentCreateView existing appointment reuse', () => {
vi.mocked(createTruckAppointment).mockResolvedValue(existing('DRAFT'))
})
it('automatically reuses a DRAFT appointment for a query-prefilled container', async () => {
Object.assign(routing.query, { oceanBookingId: 'booking-1', containerBookingId: 'container-1' })
it('automatically reuses a DRAFT appointment for a query-prefilled booking', async () => {
Object.assign(routing.query, { oceanBookingId: 'booking-1' })
vi.mocked(listTruckAppointments).mockResolvedValue(page([existing('DRAFT')]))
const wrapper = mountView()
await flushPromises()
expect(listTruckAppointments).toHaveBeenCalledWith({ containerBookingId: 'container-1', page: 1, size: 20 })
expect(listTruckAppointments).toHaveBeenCalledWith({ oceanBookingId: 'booking-1', page: 1, size: 20 })
expect(routing.replace).toHaveBeenCalledWith({ name: 'trucking-detail', params: { id: 'appointment-1' }, query: { edit: '1' } })
expect(createTruckAppointment).not.toHaveBeenCalled()
wrapper.unmount()
})
it.each(['COMPLETED', 'CANCELLED', 'EXCEPTION'] as const)('allows a new appointment after terminal status %s', async (status) => {
Object.assign(routing.query, { containerBookingId: 'container-1' })
Object.assign(routing.query, { oceanBookingId: 'booking-1' })
vi.mocked(listTruckAppointments).mockResolvedValue(page([existing(status)]))
const wrapper = mountView()
await flushPromises()
@@ -65,7 +65,7 @@ describe('TruckAppointmentCreateView existing appointment reuse', () => {
})
it('keeps request evidence and retry state when existing-appointment lookup fails', async () => {
Object.assign(routing.query, { containerBookingId: 'container-1' })
Object.assign(routing.query, { oceanBookingId: 'booking-1' })
vi.mocked(listTruckAppointments).mockRejectedValue(new ApiResponseError('预约查询失败', 'A0500', 500, undefined, 'req-existing', 'trace-existing'))
const wrapper = mountView()
await flushPromises()
@@ -77,32 +77,32 @@ describe('TruckAppointmentCreateView existing appointment reuse', () => {
wrapper.unmount()
})
it('ignores a stale lookup response after the selected container changes', async () => {
it('ignores a stale lookup response after the selected booking changes', async () => {
let resolveFirst!: (value: ReturnType<typeof page>) => void
let resolveSecond!: (value: ReturnType<typeof page>) => void
vi.mocked(listTruckAppointments).mockImplementation((query = {}) => new Promise((resolve) => {
const { containerBookingId } = query
if (containerBookingId === 'container-1') resolveFirst = resolve
const { oceanBookingId } = query
if (oceanBookingId === 'booking-1') resolveFirst = resolve
else resolveSecond = resolve
}))
const wrapper = mountView()
const vm = wrapper.vm as unknown as ViewModel
vm.form.containerBookingId = 'container-1'
vm.form.oceanBookingId = 'booking-1'
await wrapper.vm.$nextTick()
vm.form.containerBookingId = 'container-2'
vm.form.oceanBookingId = 'booking-2'
await wrapper.vm.$nextTick()
resolveSecond(page([]))
await flushPromises()
resolveFirst(page([existing('DRAFT')]))
await flushPromises()
expect(vm.checkedContainerBookingId).toBe('container-2')
expect(vm.checkedOceanBookingId).toBe('booking-2')
expect(vm.existingAppointment).toBeNull()
expect(routing.replace).not.toHaveBeenCalled()
wrapper.unmount()
})
it('suppresses duplicate create clicks while the first request is pending', async () => {
Object.assign(routing.query, { containerBookingId: 'container-1' })
Object.assign(routing.query, { oceanBookingId: 'booking-1' })
let resolveCreate!: (value: TruckAppointmentResource) => void
vi.mocked(createTruckAppointment).mockImplementation(() => new Promise((resolve) => { resolveCreate = resolve }))
const wrapper = mountView()
@@ -5,10 +5,11 @@ import { resolve } from 'node:path'
const source = readFileSync(resolve(process.cwd(), 'src/modules/trucking/pages/TruckAppointmentCreateView.vue'), 'utf8')
describe('TruckAppointmentCreateView booking-bound creation contract', () => {
it('starts with required booking and container choices empty until the user selects them', () => {
it('starts with only the required booking choice empty until the user selects it', () => {
expect(source).toContain('carrierId: null')
expect(source).not.toContain('customerId')
expect(source).toContain('oceanBookingId: initialOceanBookingId || null, containerBookingId: initialContainerBookingId || null, loadingPlanId: null')
expect(source).toContain('oceanBookingId: initialOceanBookingId || null, plannedLoadingAt: null')
expect(source).not.toContain('initialContainerBookingId')
expect(source).toContain('plannedLoadingAt: null')
expect(source).toContain("payableAmount: '0.00', currency: 'RMB'")
expect(source).not.toContain('receivableAmount')
@@ -26,10 +27,10 @@ describe('TruckAppointmentCreateView booking-bound creation contract', () => {
expect(source).toContain('if (saving.value || existingLookupLoading.value) return')
})
it('reuses non-terminal appointments by container and separates lookup failures from booking-file errors', () => {
expect(source).toContain("listTruckAppointments({ containerBookingId: normalizedId, page: 1, size: 20 })")
it('reuses non-terminal appointments by booking and separates lookup failures from booking-file errors', () => {
expect(source).toContain("listTruckAppointments({ oceanBookingId: normalizedId, page: 1, size: 20 })")
expect(source).toContain("new Set(['COMPLETED', 'CANCELLED', 'EXCEPTION'])")
expect(source).toContain('该柜已有拖车预约')
expect(source).toContain('该订舱已有拖车单')
expect(source).toContain("getApiErrorDetails(cause, '已有拖车预约查询失败,请稍后重试')")
expect(source).toContain('<ApiErrorMeta :details="existingLookupError" />')
expect(source).toContain("router.replace({ name: 'trucking-detail'")
@@ -13,13 +13,12 @@ import TruckAttachmentDialog from '../components/TruckAttachmentDialog.vue'
import { createTruckAppointment, listTruckAppointments } from '../api/platformTrucking'
import { truckAppointmentStatusLabel, type SaveTruckAppointmentRequest, type TruckAppointmentResource } from '../types'
const route = useRoute(); const router = useRouter(); const saving = ref(false); const attachmentOpen = ref(false); const error = ref<ApiErrorDetails | null>(null)
const initialContainerBookingId = String(route.query.containerBookingId || '').trim()
const initialOceanBookingId = String(route.query.oceanBookingId || '').trim()
const form = reactive<SaveTruckAppointmentRequest>({ expectedVersion: 0, carrierId: null, oceanBookingId: initialOceanBookingId || null, containerBookingId: initialContainerBookingId || null, loadingPlanId: null, plannedLoadingAt: null, payableAmount: '0.00', currency: 'RMB' })
const form = reactive<SaveTruckAppointmentRequest>({ expectedVersion: 0, carrierId: null, oceanBookingId: initialOceanBookingId || null, plannedLoadingAt: null, payableAmount: '0.00', currency: 'RMB' })
const existingAppointment = ref<TruckAppointmentResource | null>(null)
const existingLookupLoading = ref(false)
const existingLookupError = ref<ApiErrorDetails | null>(null)
const checkedContainerBookingId = ref('')
const checkedOceanBookingId = ref('')
const navigatingToExisting = ref(false)
let lookupSerial = 0
const terminalStatuses = new Set(['COMPLETED', 'CANCELLED', 'EXCEPTION'])
@@ -33,23 +32,23 @@ async function openExisting() {
try { await router.replace({ name: 'trucking-detail', params: { id: existingAppointment.value.id }, query: existingCanEdit.value ? { edit: '1' } : undefined }) }
finally { navigatingToExisting.value = false }
}
async function lookupExistingAppointment(containerBookingId: string, autoNavigate = false) {
const normalizedId = containerBookingId.trim()
async function lookupExistingAppointment(oceanBookingId: string, autoNavigate = false) {
const normalizedId = oceanBookingId.trim()
const serial = ++lookupSerial
existingAppointment.value = null
existingLookupError.value = null
checkedContainerBookingId.value = ''
checkedOceanBookingId.value = ''
if (!normalizedId) { existingLookupLoading.value = false; return }
existingLookupLoading.value = true
try {
const result = await listTruckAppointments({ containerBookingId: normalizedId, page: 1, size: 20 })
if (serial !== lookupSerial || String(form.containerBookingId || '') !== normalizedId) return
checkedContainerBookingId.value = normalizedId
const result = await listTruckAppointments({ oceanBookingId: normalizedId, page: 1, size: 20 })
if (serial !== lookupSerial || String(form.oceanBookingId || '') !== normalizedId) return
checkedOceanBookingId.value = normalizedId
existingAppointment.value = result.records.find((item) => !terminalStatuses.has(item.status)) || null
if (autoNavigate && existingAppointment.value) await openExisting()
} catch (cause) {
if (serial !== lookupSerial || String(form.containerBookingId || '') !== normalizedId) return
checkedContainerBookingId.value = normalizedId
if (serial !== lookupSerial || String(form.oceanBookingId || '') !== normalizedId) return
checkedOceanBookingId.value = normalizedId
existingLookupError.value = getApiErrorDetails(cause, '已有拖车预约查询失败,请稍后重试')
} finally {
if (serial === lookupSerial) existingLookupLoading.value = false
@@ -57,23 +56,23 @@ async function lookupExistingAppointment(containerBookingId: string, autoNavigat
}
async function submit() {
if (saving.value || existingLookupLoading.value) return
const containerBookingId = String(form.containerBookingId || '')
if (containerBookingId && checkedContainerBookingId.value !== containerBookingId) await lookupExistingAppointment(containerBookingId)
if (String(form.containerBookingId || '') !== containerBookingId || existingLookupLoading.value || existingAppointment.value || existingLookupError.value) return
const oceanBookingId = String(form.oceanBookingId || '')
if (oceanBookingId && checkedOceanBookingId.value !== oceanBookingId) await lookupExistingAppointment(oceanBookingId)
if (String(form.oceanBookingId || '') !== oceanBookingId || existingLookupLoading.value || existingAppointment.value || existingLookupError.value) return
saving.value = true; error.value = null
try { await createTruckAppointment({ ...form }); ElMessage.success('拖车预约已创建'); router.push({ name: 'trucking' }) }
catch (err) { error.value = getApiErrorDetails(err, '创建失败,请稍后重试') }
finally { saving.value = false }
}
watch(() => form.containerBookingId, (value) => { void lookupExistingAppointment(String(value || ''), Boolean(initialContainerBookingId && String(value || '') === initialContainerBookingId)) }, { immediate: true })
defineExpose({ form, submit, lookupExistingAppointment, existingAppointment, existingLookupError, checkedContainerBookingId })
watch(() => form.oceanBookingId, (value) => { void lookupExistingAppointment(String(value || ''), Boolean(initialOceanBookingId && String(value || '') === initialOceanBookingId)) }, { immediate: true })
defineExpose({ form, submit, lookupExistingAppointment, existingAppointment, existingLookupError, checkedOceanBookingId })
</script>
<template>
<AdminPage as="div" class="truck-editor-page">
<DetailPageLayout aria-label="新增拖车预约">
<LoadErrorState v-if="error" title="拖车预约创建失败" :message="error.message" :show-retry="false"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<el-alert v-if="existingAppointment" type="warning" :closable="false" show-icon><template #title>该柜已有拖车预约 {{ existingAppointment.appointmentNo || existingAppointment.id }}({{ truckAppointmentStatusLabel(existingAppointment.status) }})</template><el-button type="primary" link :loading="navigatingToExisting" @click="openExisting">{{ existingCanEdit ? '继续编辑' : '查看预约' }}</el-button></el-alert>
<LoadErrorState v-if="existingLookupError" title="已有拖车预约查询失败" :message="existingLookupError.message" compact @retry="lookupExistingAppointment(String(form.containerBookingId || ''))"><template #details><ApiErrorMeta :details="existingLookupError" /></template></LoadErrorState>
<el-alert v-if="existingAppointment" type="warning" :closable="false" show-icon><template #title>该订舱已有拖车单 {{ existingAppointment.appointmentNo || existingAppointment.id }}({{ truckAppointmentStatusLabel(existingAppointment.status) }})</template><el-button type="primary" link :loading="navigatingToExisting" @click="openExisting">{{ existingCanEdit ? '继续编辑' : '查看拖车单' }}</el-button></el-alert>
<LoadErrorState v-if="existingLookupError" title="已有拖车单查询失败" :message="existingLookupError.message" compact @retry="lookupExistingAppointment(String(form.oceanBookingId || ''))"><template #details><ApiErrorMeta :details="existingLookupError" /></template></LoadErrorState>
<section class="surface-panel form-panel"><TruckAppointmentForm :model-value="form" :submitting="saving" :submit-blocked="submitBlocked" @update:model-value="updateForm" @submit="submit" @attachments="attachmentOpen = true"><template #relation-note-actions><el-button @click="router.push({ name: 'trucking' })"><ArrowLeft :size="16" />返回列表</el-button></template></TruckAppointmentForm></section>
<TruckAttachmentDialog v-model="attachmentOpen" :attachments="[]" />
</DetailPageLayout>
@@ -32,8 +32,9 @@ describe('TruckAppointmentDetailView status contract', () => {
expect(source).toContain("statuses.indexOf(status) === index")
})
it('initializes the required booking and container associations explicitly', () => {
expect(source).toContain("carrierId: null, oceanBookingId: null, containerBookingId: null, plannedLoadingAt: null")
it('initializes the required booking association explicitly', () => {
expect(source).toContain("carrierId: null, oceanBookingId: null, plannedLoadingAt: null")
expect(source).not.toContain('containerBookingId')
expect(source).not.toContain('customerId')
expect(source).toContain('TRUCK_APPOINTMENT_SAVE_FIELDS.map')
expect(source).toContain('normalizeTruckAppointmentCurrency(result.currency)')
@@ -41,10 +42,9 @@ describe('TruckAppointmentDetailView status contract', () => {
expect(source).toContain("error.value = getApiErrorDetails(err, '保存失败,请稍后重试')")
})
it('blocks dispatch until the released booking, allocated internal container and frozen documents are present', () => {
it('blocks dispatch until the released booking and frozen documents are present', () => {
expect(source).toContain("return '确认派车前必须关联已放舱订舱'")
expect(source).toContain("return '确认派车前必须关联该订舱下的柜次'")
expect(source).not.toContain("return '确认派车前关联柜次必须录入真实 ISO 集装箱号'")
expect(source).not.toContain('关联柜次')
expect(source).toContain("return '确认派车前所选订舱必须填写提单号'")
expect(source).toContain("return '确认派车前所选订舱必须上传有效的订舱确认文件或提单文件'")
expect(source).toContain('getOceanBooking(resource.value.oceanBookingId)')
@@ -52,13 +52,10 @@ describe('TruckAppointmentDetailView status contract', () => {
expect(source).not.toContain("item.attachmentType === 'BILL_OF_LADING'")
})
it('collects a real ISO container number only when dispatched work starts pickup', () => {
expect(source).toContain("resource.value?.status === 'DISPATCHED' && status === 'PICKUP_IN_PROGRESS'")
expect(source).toContain("ElMessageBox.prompt('提柜后可填写,也可稍后在预装补录。'")
expect(source).toContain('inputValue: normalizeIso6346ContainerNumber(resource.value.containerNo)')
expect(source).toContain("inputValidator: (value) => !normalizeIso6346ContainerNumber(value) || isIso6346ContainerNumber(value) || '请输入有效的 ISO 6346 集装箱号'")
expect(source).toContain('return { containerNo: normalizeIso6346ContainerNumber(value) || null }')
expect(source).toContain('performTruckAppointmentAction(id.value, action, { expectedVersion: form.expectedVersion || 0, ...actionPayload })')
it('keeps pickup booking-scoped and leaves real container maintenance to container/preload flows', () => {
expect(source).not.toContain('ElMessageBox.prompt')
expect(source).not.toContain('normalizeIso6346ContainerNumber')
expect(source).toContain('performTruckAppointmentAction(id.value, action, { expectedVersion: form.expectedVersion || 0 })')
expect(source).toContain('await ElMessageBox.confirm(`确认将状态改为“${truckAppointmentStatusLabels[status]}”?`')
})
@@ -14,14 +14,13 @@ import { getTruckAppointment, listTruckAppointmentAttachments, listTruckAppointm
import TruckAppointmentForm from '../components/TruckAppointmentForm.vue'
import TruckAttachmentDialog from '../components/TruckAttachmentDialog.vue'
import { TRUCK_APPOINTMENT_SAVE_FIELDS, normalizeTruckAppointmentCurrency, truckAppointmentActionLabel, truckAppointmentStatusLabel, truckAppointmentStatusLabels, type SaveTruckAppointmentRequest, type TruckAppointmentResource, type TruckAppointmentStatus, type TruckAppointmentStatusHistory } from '../types'
import { isIso6346ContainerNumber, normalizeIso6346ContainerNumber } from '../iso6346'
const route = useRoute(); const router = useRouter(); const loading = ref(false); const saving = ref(false); const statusSaving = ref(false); const attachmentOpen = ref(false); const resource = ref<TruckAppointmentResource | null>(null); const error = ref<ApiErrorDetails | null>(null); const form = reactive<SaveTruckAppointmentRequest>({ expectedVersion: 0, carrierId: null, oceanBookingId: null, containerBookingId: null, plannedLoadingAt: null })
const route = useRoute(); const router = useRouter(); const loading = ref(false); const saving = ref(false); const statusSaving = ref(false); const attachmentOpen = ref(false); const resource = ref<TruckAppointmentResource | null>(null); const error = ref<ApiErrorDetails | null>(null); const form = reactive<SaveTruckAppointmentRequest>({ expectedVersion: 0, carrierId: null, oceanBookingId: null, plannedLoadingAt: null })
const history = ref<TruckAppointmentStatusHistory[]>([])
const bookingReadiness = ref<OceanBookingDetail | null>(null)
const relatedError = ref('')
let activatedOnce = false
const access = useAccessPreviewStore(); const editing = computed(() => route.query.edit === '1'); const id = computed(() => String(route.params.id)); const canEdit = computed(() => access.can('platform:firstleg:truck-appointment:edit') && ['DRAFT', 'PENDING_DISPATCH', 'RESCHEDULED'].includes(resource.value?.status || '') && editing.value); const canChangeStatus = computed(() => access.can('platform:firstleg:truck-appointment:operate')); const canManageAttachments = computed(() => access.can('platform:firstleg:truck-appointment:edit'))
const relationSnapshot = computed(() => resource.value ? [resource.value.oceanBookingNo, resource.value.cabinetNo, resource.value.containerNo, resource.value.warehouseName].filter(Boolean).join(' / ') || '无历史关联' : '')
const relationSnapshot = computed(() => resource.value ? [resource.value.oceanBookingNo, resource.value.billOfLadingNo, resource.value.warehouseName].filter(Boolean).join(' / ') || '无订舱关联' : '')
async function loadRelated() {
if (!resource.value) return
relatedError.value = ''
@@ -50,28 +49,17 @@ const dispatchBlockReason = computed(() => {
if (!resource.value || !['PENDING_DISPATCH', 'RESCHEDULED'].includes(resource.value.status)) return ''
if (!form.oceanBookingId) return '确认派车前必须关联已放舱订舱'
if (bookingReadiness.value && bookingReadiness.value.status !== 'RELEASED') return '确认派车前必须关联已放舱订舱'
if (!form.containerBookingId) return '确认派车前必须关联该订舱下的柜次'
if (!form.billOfLadingNo?.trim()) return '确认派车前所选订舱必须填写提单号'
if (bookingReadiness.value && !bookingReadiness.value.documents.some((document) => document.valid && ['BOOKING_CONFIRMATION', 'BILL_OF_LADING'].includes(document.documentType))) return '确认派车前所选订舱必须上传有效的订舱确认文件或提单文件'
return ''
})
async function confirmStatusChange(status: TruckAppointmentStatus) {
if (resource.value?.status === 'DISPATCHED' && status === 'PICKUP_IN_PROGRESS') {
const { value } = await ElMessageBox.prompt('提柜后可填写,也可稍后在预装补录。', '开始提柜', {
confirmButtonText: '确认提柜', cancelButtonText: '取消', type: 'warning',
inputValue: normalizeIso6346ContainerNumber(resource.value.containerNo),
inputPlaceholder: '选填,例如 MSCU6639870',
inputValidator: (value) => !normalizeIso6346ContainerNumber(value) || isIso6346ContainerNumber(value) || '请输入有效的 ISO 6346 集装箱号',
})
return { containerNo: normalizeIso6346ContainerNumber(value) || null }
}
await ElMessageBox.confirm(`确认将状态改为“${truckAppointmentStatusLabels[status]}”?`, '状态变更确认', { confirmButtonText: '确认', cancelButtonText: '取消', type: 'warning' })
return {}
}
async function changeStatus(status: TruckAppointmentStatus) {
if (!resource.value || status === resource.value.status || statusSaving.value) return
if (status === 'DISPATCHED' && dispatchBlockReason.value) { error.value = { message: dispatchBlockReason.value }; return }
try { const actionPayload = await confirmStatusChange(status); statusSaving.value = true; if (status === 'RESCHEDULED') { await rescheduleTruckAppointment(id.value, form.plannedLoadingAt || '', form.expectedVersion || 0, '后台改约') } else { const action = actionByTarget[status]; if (!action) throw new Error('当前状态没有可用动作'); await performTruckAppointmentAction(id.value, action, { expectedVersion: form.expectedVersion || 0, ...actionPayload }) } ElMessage.success('状态已更新'); await load() } catch (err) { if (err !== 'cancel' && !(err instanceof Error && err.message === 'cancel')) error.value = getApiErrorDetails(err, '状态更新失败,请稍后重试') } finally { statusSaving.value = false }
try { await confirmStatusChange(status); statusSaving.value = true; if (status === 'RESCHEDULED') { await rescheduleTruckAppointment(id.value, form.plannedLoadingAt || '', form.expectedVersion || 0, '后台改约') } else { const action = actionByTarget[status]; if (!action) throw new Error('当前状态没有可用动作'); await performTruckAppointmentAction(id.value, action, { expectedVersion: form.expectedVersion || 0 }) } ElMessage.success('状态已更新'); await load() } catch (err) { if (err !== 'cancel' && !(err instanceof Error && err.message === 'cancel')) error.value = getApiErrorDetails(err, '状态更新失败,请稍后重试') } finally { statusSaving.value = false }
}
onMounted(load)
onActivated(() => {
@@ -89,7 +77,7 @@ onActivated(() => {
<section class="truck-overview surface-panel" aria-label="拖车预约概览">
<div class="truck-overview__heading">
<el-button link type="primary" @click="router.push({ name: 'trucking' })"><ArrowLeft :size="16" />返回列表</el-button>
<div class="truck-overview__identity"><h1>{{ resource?.appointmentNo || '拖车预约详情' }}</h1><p>{{ resource ? [resource.oceanBookingNo, resource.containerNo, resource.orderNo].filter(Boolean).join(' · ') || '订舱与柜次信息缺失' : '查看拖车预约详情' }}</p></div>
<div class="truck-overview__identity"><h1>{{ resource?.appointmentNo || '拖车预约详情' }}</h1><p>{{ resource ? [resource.oceanBookingNo, resource.billOfLadingNo, resource.orderNo].filter(Boolean).join(' · ') || '订舱信息缺失' : '查看拖车预约详情' }}</p></div>
<span v-if="resource" class="truck-overview__status">当前状态:<strong>{{ truckAppointmentStatusLabel(resource.status) }}</strong></span>
<div v-if="resource && canChangeStatus" class="status-actions"><el-button v-for="status in availableTargets" :key="status" size="small" :type="status === 'CANCELLED' ? 'danger' : status === 'EXCEPTION' ? 'warning' : 'primary'" plain :loading="statusSaving" @click="changeStatus(status)">{{ truckAppointmentStatusLabels[status] }}</el-button></div>
</div>
@@ -19,8 +19,8 @@ vi.mock('@/modules/iam', () => ({
}))
const row: TruckAppointmentResource = {
id: '9001', appointmentNo: 'TA-9001', status: 'DISPATCHED', orderNo: 'ORD-1', oceanBookingNo: 'OB-1', containerBookingId: '77', cabinetNo: 'CAB-1',
containerNo: 'CONT-1', warehouseName: '义乌仓', shippingCompanyName: '船公司A', bookingAgentName: '代理A', carrierName: '宁波运输', driverName: '王强', vehicleNo: '浙B12345',
id: '9001', appointmentNo: 'TA-9001', status: 'DISPATCHED', orderNo: 'ORD-1', oceanBookingNo: 'OB-1', billOfLadingNo: 'BL-1',
warehouseName: '义乌仓', shippingCompanyName: '船公司A', bookingAgentName: '代理A', carrierName: '宁波运输', driverName: '王强', vehicleNo: '浙B12345',
plannedLoadingAt: '2026-08-10T10:00:00', payableAmount: '3200.00', currency: 'RMB', attachmentCount: 2, attachments: [],
}
@@ -37,15 +37,13 @@ describe('TruckAppointmentsView', () => {
it('loads the real list and renders relationship snapshots', async () => {
const wrapper = mount(TruckAppointmentsView, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(listTruckAppointments).toHaveBeenCalledWith({ page: 1, size: 20, keyword: '', status: '', containerBookingId: '', containerType: '' })
expect(listTruckAppointments).toHaveBeenCalledWith({ page: 1, size: 20, keyword: '', status: '' })
expect(wrapper.text()).toContain('TA-9001')
expect(wrapper.text()).toContain('CONT-1')
expect(wrapper.text()).toContain('CAB-1')
expect(wrapper.text()).toContain('OB-1')
expect(wrapper.text()).toContain('BL-1')
expect(wrapper.text()).toContain('义乌仓')
expect(wrapper.text()).toContain('船公司A')
expect(wrapper.text()).toContain('代理A')
expect(wrapper.text()).not.toContain('77')
expect(wrapper.text()).toContain('宁波运输')
expect(wrapper.find('button[aria-label="查看 TA-9001"]').exists()).toBe(true)
})
@@ -129,7 +127,8 @@ describe('TruckAppointmentsView', () => {
expect(source.indexOf("key: 'payableAmount'")).toBeLessThan(source.indexOf("key: 'plannedLoadingAt'"))
expect(source).not.toContain('receivableAmount')
expect(source).not.toContain('应收拖车费')
expect(source).toContain("row.containerNo || '待补录'")
expect(source).not.toContain("key: 'containerNo'")
expect(source).not.toContain("key: 'cabinetNo'")
expect(source).toContain("toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })")
expect(source).toContain("key: 'attachmentCount'")
})
@@ -16,20 +16,16 @@ import { TRUCK_APPOINTMENT_STATUSES, truckAppointmentStatusLabel, truckAppointme
const router = useRouter(); const rows = ref<TruckAppointmentResource[]>([]); const loading = ref(false); const page = ref(1); const pageSize = ref(20); const total = ref(0); const loadError = ref<ApiErrorDetails | null>(null)
const access = useAccessPreviewStore()
const filters = ref({ keyword: '', status: '' as TruckAppointmentStatus | '', containerBookingId: '', containerType: '' }); const applied = ref({ ...filters.value })
const filters = ref({ keyword: '', status: '' as TruckAppointmentStatus | '' }); const applied = ref({ ...filters.value })
const fields = computed<QueryField[]>(() => [
{ key: 'keyword', type: 'input', label: '关键词', placeholder: '派车单号、车牌号、运输公司' },
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', options: TRUCK_APPOINTMENT_STATUSES.map((value) => ({ value, label: truckAppointmentStatusLabels[value] })) },
{ key: 'containerType', type: 'select', label: '柜型', placeholder: '全部柜型', options: [
{ value: '20GP', label: '20GP' }, { value: '40GP', label: '40GP' }, { value: '40HC', label: '40HC' },
] },
])
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 150, fixed: 'left', slotName: 'actions' },
{ key: 'appointmentNo', prop: 'appointmentNo', label: '派车单号', minWidth: 170 },
{ key: 'oceanBookingNo', prop: 'oceanBookingNo', label: '订舱号', minWidth: 150, formatter: (row: TruckAppointmentResource) => row.oceanBookingNo || '—' },
{ key: 'cabinetNo', prop: 'cabinetNo', label: '柜次号', minWidth: 150, formatter: (row: TruckAppointmentResource) => row.cabinetNo || '—' },
{ key: 'containerNo', prop: 'containerNo', label: '集装箱号', minWidth: 145, formatter: (row: TruckAppointmentResource) => row.containerNo || '待补录' },
{ key: 'billOfLadingNo', prop: 'billOfLadingNo', label: '提单号', minWidth: 160, formatter: (row: TruckAppointmentResource) => row.billOfLadingNo || '—' },
{ key: 'warehouseName', prop: 'warehouseName', label: '装柜仓库', minWidth: 150, formatter: (row: TruckAppointmentResource) => row.warehouseName || '—' },
{ key: 'shippingCompanyName', prop: 'shippingCompanyName', label: '船公司', minWidth: 150, formatter: (row: TruckAppointmentResource) => row.shippingCompanyName || '—' },
{ key: 'bookingAgentName', prop: 'bookingAgentName', label: '订舱代理', minWidth: 150, formatter: (row: TruckAppointmentResource) => row.bookingAgentName || '—' },
@@ -60,7 +56,7 @@ function statusLabel(value: unknown) {
return truckAppointmentStatusLabel(typeof value === 'string' ? value : null)
}
async function load() { loading.value = true; loadError.value = null; try { const result = await listTruckAppointments({ page: page.value, size: pageSize.value, ...applied.value } as TruckAppointmentQuery); rows.value = result.records; total.value = result.total } catch (error) { rows.value = []; total.value = 0; loadError.value = getApiErrorDetails(error, '拖车预约加载失败,请稍后重试') } finally { loading.value = false } }
function search(model: QueryModel) { applied.value = { keyword: String(model.keyword || ''), status: (model.status || '') as TruckAppointmentStatus | '', containerBookingId: '', containerType: String(model.containerType || '') }; if (page.value === 1) void load(); else page.value = 1 }
function search(model: QueryModel) { applied.value = { keyword: String(model.keyword || ''), status: (model.status || '') as TruckAppointmentStatus | '' }; if (page.value === 1) void load(); else page.value = 1 }
onMounted(() => { void load() })
function openDetail(row: TruckAppointmentResource, edit = false) { router.push({ name: 'trucking-detail', params: { id: String(row.id) }, query: edit ? { edit: '1' } : undefined }) }
</script>