test(admin): cover booking-scoped trucking

This commit is contained in:
2026-09-01 16:35:40 +08:00
parent 5706645032
commit 4ad8537113
8 changed files with 98 additions and 13 deletions
@@ -41,6 +41,16 @@ describe('truck appointment API', () => {
}, { headers: { 'Idempotency-Key': expect.stringContaining('truck-appointment-pickup-9001-') } })
})
it.each(['SUBMIT', 'DISPATCH', 'PICKUP'] as const)('sends %s without any container field', async (action) => {
vi.mocked(http.post).mockResolvedValue({ data: { id: '9001', status: 'PENDING_DISPATCH' } })
await performTruckAppointmentAction('9001', action, { expectedVersion: 4, reason: '演示流转' })
const body = vi.mocked(http.post).mock.calls.at(-1)?.[1] as Record<string, unknown>
expect(body).toEqual({ expectedVersion: 4, reason: '演示流转' })
expect(body).not.toHaveProperty('containerNo')
expect(body).not.toHaveProperty('containerBookingId')
expect(body).not.toHaveProperty('loadingPlanId')
})
it('drops legacy customer fields instead of forwarding hidden compatibility data', async () => {
vi.mocked(http.post).mockResolvedValueOnce({ data: { id: '9001', status: 'DRAFT', attachmentCount: 0 } })
const payload = { carrierId: '200', oceanBookingId: '300', containerBookingId: '400', loadingPlanId: '500', plannedLoadingAt: '2026-08-10T10:00:00', expectedVersion: 0, currency: 'EUR', receivableAmount: '999.00', customerId: 'legacy-customer', customerName: '旧客户快照' }
@@ -57,6 +57,7 @@ describe('TruckAppointmentForm booking-only behavior', () => {
const labels = wrapper.findAllComponents({ name: 'ElOption' }).map((option) => String(option.props('label') || ''))
expect(labels.some((label) => label.includes('OB-1') && label.includes('BL-1'))).toBe(true)
expect(labels.some((label) => label.includes('CAB-'))).toBe(false)
expect(candidates[0]).not.toHaveProperty('containerBookingId')
const emitted = wrapper.emitted('update:modelValue')?.at(-1)?.[0] as SaveTruckAppointmentRequest
expect(emitted.oceanBookingId).toBe('booking-1')
expect(emitted.billOfLadingNo).toBe('BL-1')
@@ -65,6 +66,30 @@ describe('TruckAppointmentForm booking-only behavior', () => {
wrapper.unmount()
})
it('keeps only the latest candidate response when option reloads overlap', async () => {
let resolveSlow!: (value: Awaited<ReturnType<typeof listTruckAppointmentCandidates>>) => void
let resolveFast!: (value: Awaited<ReturnType<typeof listTruckAppointmentCandidates>>) => void
vi.mocked(listTruckAppointmentCandidates)
.mockResolvedValueOnce({ records: candidates, page: 1, size: 200, total: candidates.length, pages: 1 })
.mockImplementationOnce(() => new Promise((resolve) => { resolveSlow = resolve }))
.mockImplementationOnce(() => new Promise((resolve) => { resolveFast = resolve }))
const wrapper = mount(TruckAppointmentForm, { props: { modelValue: { ...model } }, global: { plugins: [ElementPlus] } })
await flushPromises()
const vm = wrapper.vm as unknown as { loadOptions: () => Promise<void> }
const slow = vm.loadOptions()
const fast = vm.loadOptions()
resolveFast({ records: [candidates[1]!], page: 1, size: 200, total: 1, pages: 1 })
await fast
resolveSlow({ records: [candidates[0]!], page: 1, size: 200, total: 1, pages: 1 })
await slow
await flushPromises()
const labels = wrapper.findAllComponents({ name: 'ElOption' }).map((option) => String(option.props('label') || ''))
expect(labels.some((label) => label.includes('OB-2'))).toBe(true)
expect(labels.some((label) => label.includes('OB-1'))).toBe(false)
wrapper.unmount()
})
it('replaces frozen booking snapshots when switching the selected booking', async () => {
const wrapper = mount(TruckAppointmentForm, { props: { modelValue: { ...model } }, global: { plugins: [ElementPlus] } })
await flushPromises()
@@ -69,9 +69,10 @@ describe('TruckAppointmentForm interaction contract', () => {
})
it('keeps container identity out of the booking-scoped form', () => {
expect(source).not.toContain('柜次号:')
expect(source).not.toContain('集装箱号:')
expect(source).toContain('真实柜次及集装箱号在货柜与预装流程维护')
expect(source).not.toContain('柜次')
expect(source).not.toContain('装柜计划')
expect(source).not.toContain('集装箱号')
expect(source).toContain('货柜资料在货柜与预装流程维护')
})
it('keeps the form usable at 390px', () => {
@@ -94,7 +95,7 @@ describe('TruckAppointmentForm interaction contract', () => {
expect(source).not.toContain('应收拖车费')
})
it('uses four compact desktop columns while retaining two-column relations and wide text fields', () => {
it('keeps the 1920 desktop contract compact without horizontal form overflow', () => {
expect(source).toContain('@media (min-width: 1200px)')
expect(source).toContain('max-width:25%; flex:0 0 25%')
expect(source).toContain('class="relation-column"')
@@ -33,7 +33,7 @@ const phoneRule = (label: string) => ({ validator: (_rule: unknown, value: unkno
}, trigger: 'blur' })
const rules: FormRules = {
oceanBookingId: [{ required: true, message: '请选择已放舱订舱', trigger: 'change' }, { validator: (_rule, value, callback) => {
if (!(bookings.value.some((item) => item.id === String(value)) || (relationLocked.value && selectedBookingFallback.value?.id === String(value)))) return callback(new Error('请选择仍有可预约柜次的已放舱订舱'))
if (!(bookings.value.some((item) => item.id === String(value)) || (relationLocked.value && selectedBookingFallback.value?.id === String(value)))) return callback(new Error('请选择可创建拖车单的已放舱订舱'))
if (bookingContainersError.value) return callback(new Error('订舱资料状态加载失败,请重试'))
if (!draft.billOfLadingNo?.trim()) return callback(new Error('所选订舱缺少有效提单号'))
return callback(bookingDocumentReady.value ? undefined : new Error('所选订舱缺少有效的订舱确认文件或提单文件'))
@@ -124,7 +124,7 @@ async function loadOptions() {
if (sequence !== optionsLoadSequence) return
if (results[0].status === 'fulfilled') carriers.value = results[0].value.records.filter((item) => item.category === 'TRUCKING' && item.status === 'ACTIVE')
if (results[1].status === 'fulfilled') candidates.value = results[1].value.records
const failed = ['承运商', '可预约订舱柜次'].filter((_, index) => results[index]?.status === 'rejected')
const failed = ['承运商', '可用订舱'].filter((_, index) => results[index]?.status === 'rejected')
if (failed.length) optionsError.value = `${failed.join('、')}选项加载失败`
optionsLoading.value = false
if (relationLocked.value && draft.oceanBookingId) await loadCurrentBookingContext(String(draft.oceanBookingId))
@@ -140,7 +140,7 @@ async function loadOptions() {
}
async function validate() { return formRef.value?.validate() }
async function submitForm() { if (props.submitting) return; if (await formRef.value?.validate()) emit('submit') }
defineExpose({ validate })
defineExpose({ validate, loadOptions })
onMounted(() => { void loadOptions() })
watch(() => props.readonly, (readonly) => { if (!readonly && !carriers.value.length) void loadOptions() })
watch(() => props.resource?.id, (id, previous) => { if (id && id !== previous) void loadOptions() })
@@ -148,7 +148,7 @@ watch(() => props.resource?.id, (id, previous) => { if (id && id !== previous) v
<template>
<el-form ref="formRef" :model="draft" :rules="rules" label-position="top" :disabled="readonly || submitting" class="truck-form">
<div class="relation-note"><span>一张已放舱订舱对应一张有效拖车单;仓库和提单号由订舱冻结资料带入,真实柜次及集装箱号在货柜与预装流程维护,最终资格仍由服务端校验。</span><div v-if="$slots['relation-note-actions']" class="relation-note__actions"><slot name="relation-note-actions" /></div></div>
<div class="relation-note"><span>一张已放舱订舱对应一张有效拖车单;仓库和提单号由订舱冻结资料带入,货柜资料在货柜与预装流程维护,最终资格仍由服务端校验。</span><div v-if="$slots['relation-note-actions']" class="relation-note__actions"><slot name="relation-note-actions" /></div></div>
<el-alert v-if="relationWarning" type="warning" :closable="false" show-icon :title="relationWarning" class="relation-alert" />
<el-alert v-if="optionsError" type="warning" :closable="false" show-icon :title="optionsError" class="relation-alert"><el-button link type="warning" :loading="optionsLoading" @click="loadOptions">重新加载选项</el-button></el-alert>
<el-row :gutter="16">
@@ -67,6 +67,14 @@ describe('TruckAppointmentDetailView status contract', () => {
expect(source).toContain('v-if="relatedError"')
})
it('never exposes container identity or operator database ids', () => {
expect(source).not.toContain('cabinetNo')
expect(source).not.toContain('containerNo')
expect(source).not.toContain('containerBookingId')
expect(source).not.toContain('loadingPlanId')
expect(source).not.toContain('item.operatorId')
})
it('reloads a cached detail after an error without showing stale resource data', () => {
expect(source).toContain("resource.value = null; bookingReadiness.value = null; history.value = []; relatedError.value = ''")
expect(source).toContain('onActivated(() => {')
@@ -92,7 +92,7 @@ onActivated(() => {
<LoadErrorState v-if="error && !resource" title="详情加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<template v-else-if="resource">
<section class="surface-panel form-panel"><TruckAppointmentForm v-model="form" :resource="resource" :readonly="!canEdit" :submitting="saving" @submit="submit" @attachments="attachmentOpen = true" /></section>
<section class="surface-panel history-section"><h3>状态历史</h3><el-empty v-if="!history.length" description="暂无状态历史" :image-size="48" /><ol v-else><li v-for="(item, index) in history" :key="item.id || `${item.createdAt}-${index}`"><strong>{{ truckAppointmentActionLabel(item.action) }}</strong><span>{{ truckAppointmentStatusLabel(item.fromStatus) }} → {{ truckAppointmentStatusLabel(item.toStatus) }}</span><small>{{ item.operatorName || item.operatorId || '系统' }} · {{ item.createdAt || item.occurredAt || '—' }}<template v-if="item.reason"> · {{ item.reason }}</template></small></li></ol></section>
<section class="surface-panel history-section"><h3>状态历史</h3><el-empty v-if="!history.length" description="暂无状态历史" :image-size="48" /><ol v-else><li v-for="(item, index) in history" :key="item.id || `${item.createdAt}-${index}`"><strong>{{ truckAppointmentActionLabel(item.action) }}</strong><span>{{ truckAppointmentStatusLabel(item.fromStatus) }} → {{ truckAppointmentStatusLabel(item.toStatus) }}</span><small>{{ item.operatorName || '系统' }} · {{ item.createdAt || item.occurredAt || '—' }}<template v-if="item.reason"> · {{ item.reason }}</template></small></li></ol></section>
<TruckAttachmentDialog v-model="attachmentOpen" :appointment-id="id" :attachments="resource.attachments || []" :can-manage="canManageAttachments" @changed="loadRelated" />
</template>
</DetailPageLayout>
@@ -2,6 +2,7 @@ import ElementPlus from 'element-plus'
import { createPinia, setActivePinia } from 'pinia'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ApiResponseError } from '@/shared/api/http'
import TruckAppointmentsView from './TruckAppointmentsView.vue'
import { listTruckAppointments } from '../api/platformTrucking'
import type { TruckAppointmentResource } from '../types'
@@ -62,10 +63,10 @@ describe('TruckAppointmentsView', () => {
const wrapper = mount(TruckAppointmentsView, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.find('button[aria-label="查看 9002"]').exists()).toBe(true)
expect(wrapper.find('button[aria-label="查看拖车预约详情"]').exists()).toBe(true)
const labels = wrapper.findAll('button[aria-label]').map((button) => button.attributes('aria-label'))
expect(labels.every((label) => Boolean(label) && !/undefined|null/.test(label ?? ''))).toBe(true)
expect(labels.every((label) => !label?.includes('9002'))).toBe(true)
expect(source).toContain("appointmentActionLabel('编辑', row as TruckAppointmentResource)")
})
@@ -91,6 +92,48 @@ describe('TruckAppointmentsView', () => {
expect(source.match(/empty-text=/g)).toHaveLength(1)
})
it('renders the server empty state', async () => {
vi.mocked(listTruckAppointments).mockResolvedValue({ records: [], page: 1, size: 20, total: 0, pages: 1 })
const wrapper = mount(TruckAppointmentsView, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.text()).toContain('暂无符合筛选条件的拖车预约')
})
it('marks the table busy while the list request is pending', async () => {
let resolveLoad!: (value: Awaited<ReturnType<typeof listTruckAppointments>>) => void
vi.mocked(listTruckAppointments).mockImplementationOnce(() => new Promise((resolve) => { resolveLoad = resolve }))
const wrapper = mount(TruckAppointmentsView, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.find('section[aria-busy="true"]').exists()).toBe(true)
resolveLoad({ records: [row], page: 1, size: 20, total: 1, pages: 1 })
await flushPromises()
expect(wrapper.find('section[aria-busy="false"]').exists()).toBe(true)
})
it('keeps a recoverable load error and retries without losing the page', async () => {
vi.mocked(listTruckAppointments).mockRejectedValueOnce(new ApiResponseError('候选服务暂不可用', 'A0503', 503, undefined, 'req-truck', 'trace-truck'))
const wrapper = mount(TruckAppointmentsView, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.text()).toContain('候选服务暂不可用')
expect(wrapper.text()).toContain('req-truck')
vi.mocked(listTruckAppointments).mockResolvedValueOnce({ records: [row], page: 1, size: 20, total: 1, pages: 1 })
await wrapper.get('button').trigger('click')
await flushPromises()
expect(listTruckAppointments).toHaveBeenCalledTimes(2)
expect(wrapper.text()).toContain('TA-9001')
})
it('shows create only with the independent create permission', async () => {
let wrapper = mount(TruckAppointmentsView, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.text()).not.toContain('新增拖车')
wrapper.unmount()
useAuthStore().permissions = ['platform:firstleg:truck-appointment:list', 'platform:firstleg:truck-appointment:create']
wrapper = mount(TruckAppointmentsView, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.text()).toContain('新增拖车')
})
it('shows the two frequent row actions directly without a more menu', () => {
expect(source).not.toContain('platform:firstleg:truck-appointment:view')
expect(source).toContain("access.can('platform:firstleg:truck-appointment:edit') && ['DRAFT', 'PENDING_DISPATCH', 'RESCHEDULED'].includes(row.status)")
@@ -47,9 +47,7 @@ function formatAmount(value?: string | null, currency?: string | null) {
return currency ? `${display} ${currency}` : display
}
function appointmentActionLabel(action: '查看' | '编辑', row: TruckAppointmentResource) {
const identifier = [row.appointmentNo, row.id]
.map((value) => String(value ?? '').trim())
.find(Boolean)
const identifier = String(row.appointmentNo ?? '').trim()
return identifier ? `${action} ${identifier}` : `${action}拖车预约详情`
}
function statusLabel(value: unknown) {