This commit is contained in:
2026-09-01 15:46:46 +08:00
parent c59608ff2c
commit adb077a714
4 changed files with 67 additions and 63 deletions
@@ -3,16 +3,17 @@ import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ApiResponseError } from '@/shared/api/http'
import TruckAppointmentForm from './TruckAppointmentForm.vue'
import type { SaveTruckAppointmentRequest } from '../types'
import { getOceanBooking, listOceanBookings } from '@/modules/ocean-bookings'
import type { SaveTruckAppointmentRequest, TruckAppointmentResource } from '../types'
import { getOceanBooking } from '@/modules/ocean-bookings'
import { listTruckAppointmentCandidates } from '../api/platformTrucking'
vi.mock('@/modules/suppliers', () => ({
listSuppliers: vi.fn().mockResolvedValue({ records: [{ id: 'carrier-1', name: '平台拖车公司', category: 'TRUCKING', status: 'ACTIVE' }] }),
}))
vi.mock('@/modules/ocean-bookings', () => ({
listOceanBookings: vi.fn(),
getOceanBooking: vi.fn(),
}))
vi.mock('../api/platformTrucking', () => ({ listTruckAppointmentCandidates: vi.fn() }))
const model: SaveTruckAppointmentRequest = {
expectedVersion: 0,
@@ -22,29 +23,28 @@ const model: SaveTruckAppointmentRequest = {
carrierId: null,
plannedLoadingAt: null,
}
const bookings = [
{ id: 'booking-1', bookingNo: 'OB-1', status: 'RELEASED', billOfLadingNo: 'BL-1', warehouseId: 'WH-1', warehouseName: '义乌仓' },
{ id: 'booking-2', bookingNo: 'OB-2', status: 'RELEASED', billOfLadingNo: 'BL-2', warehouseId: 'WH-2', warehouseName: '东莞仓' },
{ id: 'booking-missing-bill', bookingNo: 'OB-3', status: 'RELEASED', billOfLadingNo: null, warehouseId: 'WH-3', warehouseName: '宁波仓' },
const candidates = [
{ oceanBookingId: 'booking-1', oceanBookingNo: 'OB-1', billOfLadingNo: 'BL-1', warehouseId: 'WH-1', warehouseName: '义乌仓', channelCode: 'SEA', channelName: '海运', requirementId: 'requirement-1', containerType: '40HC', requirementQuantity: 2, containerBookingId: 'container-1', cabinetNo: 'CAB-1', containerNo: 'CBHU8888883', loadingPlanId: 'plan-1', loadingPlanNo: 'LP-1', loadingPlanStatus: 'EDITING', bookingDocumentReady: true },
{ oceanBookingId: 'booking-1', oceanBookingNo: 'OB-1', billOfLadingNo: 'BL-1', warehouseId: 'WH-1', warehouseName: '义乌仓', channelCode: 'SEA', channelName: '海运', requirementId: 'requirement-1', containerType: '40HC', requirementQuantity: 2, containerBookingId: 'container-pending', cabinetNo: 'CAB-X', containerNo: null, loadingPlanId: null, loadingPlanNo: null, loadingPlanStatus: null, bookingDocumentReady: true },
{ oceanBookingId: 'booking-2', oceanBookingNo: 'OB-2', billOfLadingNo: 'BL-2', warehouseId: 'WH-2', warehouseName: '东莞仓', channelCode: 'SEA', channelName: '海运', requirementId: 'requirement-2', containerType: '40GP', requirementQuantity: 1, containerBookingId: 'container-2', cabinetNo: 'CAB-2', containerNo: 'MSCU6639870', loadingPlanId: null, loadingPlanNo: null, loadingPlanStatus: null, bookingDocumentReady: true },
]
function detail(id: string) {
const booking = bookings.find((item) => item.id === id)!
const candidate = candidates.find((item) => item.oceanBookingId === id)!
return {
...booking,
id, bookingNo: candidate.oceanBookingNo, status: 'RELEASED', billOfLadingNo: candidate.billOfLadingNo, warehouseId: candidate.warehouseId, warehouseName: candidate.warehouseName,
documents: [{ id: `doc-${id}`, documentType: 'BILL_OF_LADING', valid: true }],
containers: id === 'booking-1'
? [
{ containerBookingId: 'container-1', cabinetNo: 'CAB-1', containerNo: 'CBHU8888883' },
{ containerBookingId: 'container-pending', cabinetNo: 'CAB-X', containerNo: null, containerType: '40HC' },
]
: [{ containerBookingId: 'container-2', cabinetNo: 'CAB-2', containerNo: 'MSCU6639870' }],
containers: [],
}
}
function resource(id: string, bookingId: string, containerId: string): TruckAppointmentResource {
const candidate = candidates.find((item) => item.oceanBookingId === bookingId && item.containerBookingId === containerId)!
return { id, status: 'DRAFT', oceanBookingId: bookingId, oceanBookingNo: candidate.oceanBookingNo, containerBookingId: containerId, cabinetNo: candidate.cabinetNo, containerNo: candidate.containerNo, relatedContainerType: candidate.containerType }
}
describe('TruckAppointmentForm booking-only behavior', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(listOceanBookings).mockResolvedValue({ records: bookings } as never)
vi.mocked(listTruckAppointmentCandidates).mockResolvedValue({ records: candidates, page: 1, size: 200, total: candidates.length, pages: 1 })
vi.mocked(getOceanBooking).mockImplementation(async (id) => detail(id) as never)
})
@@ -55,7 +55,8 @@ describe('TruckAppointmentForm booking-only behavior', () => {
selects[0]!.vm.$emit('update:modelValue', 'booking-1')
await flushPromises()
expect(getOceanBooking).toHaveBeenCalledWith('booking-1')
expect(listTruckAppointmentCandidates).toHaveBeenCalledWith({ page: 1, size: 200 })
expect(getOceanBooking).not.toHaveBeenCalled()
const labels = wrapper.findAllComponents({ name: 'ElOption' }).map((option) => String(option.props('label') || ''))
expect(labels.some((label) => label.includes('CAB-1') && label.includes('CBHU8888883'))).toBe(true)
expect(labels.some((label) => label.includes('CAB-X') && label.includes('40HC') && label.includes('待补录'))).toBe(true)
@@ -87,19 +88,18 @@ describe('TruckAppointmentForm booking-only behavior', () => {
wrapper.unmount()
})
it('ignores stale booking details when the user switches selections quickly', async () => {
it('ignores stale current-booking details when a cached edit route changes', async () => {
let resolveFirst!: (value: ReturnType<typeof detail>) => void
let resolveSecond!: (value: ReturnType<typeof detail>) => void
vi.mocked(getOceanBooking).mockImplementation((id) => new Promise((resolve) => {
if (id === 'booking-1') resolveFirst = resolve as typeof resolveFirst
else resolveSecond = resolve as typeof resolveSecond
}) as never)
const wrapper = mount(TruckAppointmentForm, { props: { modelValue: { ...model } }, global: { plugins: [ElementPlus] } })
const firstResource = resource('appointment-1', 'booking-1', 'container-1')
const secondResource = resource('appointment-2', 'booking-2', 'container-2')
const wrapper = mount(TruckAppointmentForm, { props: { modelValue: { ...model, oceanBookingId: 'booking-1', containerBookingId: 'container-1' }, resource: firstResource }, global: { plugins: [ElementPlus] } })
await flushPromises()
const bookingSelect = wrapper.findAllComponents({ name: 'ElSelect' })[0]!
bookingSelect.vm.$emit('update:modelValue', 'booking-1')
await flushPromises()
bookingSelect.vm.$emit('update:modelValue', 'booking-2')
await wrapper.setProps({ modelValue: { ...model, oceanBookingId: 'booking-2', containerBookingId: 'container-2' }, resource: secondResource })
await flushPromises()
resolveSecond(detail('booking-2'))
@@ -108,20 +108,17 @@ describe('TruckAppointmentForm booking-only behavior', () => {
await flushPromises()
const labels = wrapper.findAllComponents({ name: 'ElOption' }).map((option) => String(option.props('label') || ''))
expect(labels.some((label) => label.includes('CAB-2'))).toBe(true)
expect(labels.some((label) => label.includes('CAB-2') && label.includes('不可重选'))).toBe(true)
expect(labels.some((label) => label.includes('CAB-1'))).toBe(false)
expect(wrapper.text()).not.toContain('所选订舱缺少有效的订舱确认文件或提单文件')
const emitted = wrapper.emitted('update:modelValue')?.at(-1)?.[0] as SaveTruckAppointmentRequest
expect(emitted.oceanBookingId).toBe('booking-2')
expect(emitted.billOfLadingNo).toBe('BL-2')
expect(listTruckAppointmentCandidates).not.toHaveBeenCalled()
wrapper.unmount()
})
it('preserves the business code and request trace when booking readiness fails', async () => {
vi.mocked(getOceanBooking).mockRejectedValueOnce(new ApiResponseError('订舱版本冲突', 'A0409', 409, undefined, 'req-booking', 'trace-booking'))
const wrapper = mount(TruckAppointmentForm, { props: { modelValue: { ...model } }, global: { plugins: [ElementPlus] } })
await flushPromises()
wrapper.findAllComponents({ name: 'ElSelect' })[0]!.vm.$emit('update:modelValue', 'booking-1')
const current = resource('appointment-1', 'booking-1', 'container-1')
const wrapper = mount(TruckAppointmentForm, { props: { modelValue: { ...model, oceanBookingId: 'booking-1', containerBookingId: 'container-1' }, resource: current }, global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.text()).toContain('订舱版本冲突')
expect(wrapper.text()).toContain('A0409')
@@ -130,26 +127,13 @@ describe('TruckAppointmentForm booking-only behavior', () => {
wrapper.unmount()
})
it('blocks a released booking that has no bill of lading number', async () => {
const wrapper = mount(TruckAppointmentForm, { props: { modelValue: { ...model } }, global: { plugins: [ElementPlus] } })
await flushPromises()
wrapper.findAllComponents({ name: 'ElSelect' })[0]!.vm.$emit('update:modelValue', 'booking-missing-bill')
await flushPromises()
expect(wrapper.text()).toContain('所选订舱缺少有效提单号')
const emitted = wrapper.emitted('update:modelValue')?.at(-1)?.[0] as SaveTruckAppointmentRequest
expect(emitted.billOfLadingNo).toBeNull()
wrapper.unmount()
})
it('rejects a booking that becomes unreleased before its detail is loaded', async () => {
it('flags an edit binding whose booking has become unreleased without adding it to create candidates', async () => {
vi.mocked(getOceanBooking).mockResolvedValueOnce({ ...detail('booking-1'), status: 'DRAFT' } as never)
const wrapper = mount(TruckAppointmentForm, { props: { modelValue: { ...model } }, global: { plugins: [ElementPlus] } })
const current = resource('appointment-1', 'booking-1', 'container-1')
const wrapper = mount(TruckAppointmentForm, { props: { modelValue: { ...model, oceanBookingId: 'booking-1', containerBookingId: 'container-1' }, resource: current }, global: { plugins: [ElementPlus] } })
await flushPromises()
wrapper.findAllComponents({ name: 'ElSelect' })[0]!.vm.$emit('update:modelValue', 'booking-1')
await flushPromises()
expect(wrapper.text()).toContain('所选订舱尚未放舱')
const emitted = wrapper.emitted('update:modelValue')?.at(-1)?.[0] as SaveTruckAppointmentRequest
expect(emitted.containerBookingId).toBeNull()
expect(wrapper.text()).toContain('当前绑定订舱已不是放舱状态')
expect(listTruckAppointmentCandidates).not.toHaveBeenCalled()
wrapper.unmount()
})
@@ -7,21 +7,21 @@ const typesSource = readFileSync(resolve(process.cwd(), 'src/modules/trucking/ty
describe('TruckAppointmentForm interaction contract', () => {
it('requires a released booking before an allocated internal container', () => {
expect(source).toContain("listOceanBookings({ page: 1, size: 200, status: 'RELEASED' })")
expect(source).toContain('listTruckAppointmentCandidates({ page: 1, size: 200 })')
expect(source).toContain("oceanBookingId: [{ required: true, message: '请选择已放舱订舱'")
expect(source).toContain("containerBookingId: [{ required: true, message: '请选择该订舱下已分配的柜次'")
expect(source).toContain("containerBookingId: [{ required: true, message: '请选择该订舱下可预约的柜次'")
expect(source.indexOf('label="订舱"')).toBeLessThan(source.indexOf('label="柜次"'))
expect(source).toContain(':disabled="!draft.oceanBookingId || bookingContainersLoading"')
expect(source).toContain(':disabled="relationLocked || !draft.oceanBookingId || optionsLoading || bookingContainersLoading"')
expect(source).not.toContain('关联订舱(可选)')
expect(source).not.toContain('关联柜次(可选)')
})
it('loads every container allocated to the selected booking without requiring a physical number', () => {
expect(source).toContain('getOceanBooking(bookingId)')
expect(source).toContain('containers.value = booking.containers || []')
expect(source).toContain('bookingDocuments.value = booking.documents || []')
expect(source).toContain('const selectableContainers = computed(() => containers.value)')
expect(source).toContain('该订舱暂无已分配柜次')
it('uses only server-owned create candidates and never rebuilds availability from booking detail', () => {
expect(source).toContain('candidates.value = results[1].value.records')
expect(source).toContain('candidates.value.filter((item) => item.oceanBookingId === draft.oceanBookingId)')
expect(source).toContain('该订舱暂无可预约柜次')
expect(source).not.toContain('booking.containers')
expect(source).not.toContain('listOceanBookings')
expect(source).not.toContain('isIso6346ContainerNumber')
expect(source).not.toContain('listContainers')
})
@@ -29,7 +29,8 @@ describe('TruckAppointmentForm interaction contract', () => {
it('clears an incompatible container whenever the booking changes', () => {
expect(source).toContain('draft.containerBookingId = null')
expect(source).toContain('draft.loadingPlanId = null')
expect(source).toContain('if (!selectableContainers.value.some((item) => item.containerBookingId === draft.containerBookingId))')
expect(source).toContain('draft.containerBookingId = null')
expect(source).toContain('candidate.loadingPlanId')
})
it('freezes warehouse and bill of lading snapshots without retaining customer data', () => {
@@ -48,13 +49,29 @@ describe('TruckAppointmentForm interaction contract', () => {
expect(source).toContain('所选订舱缺少有效提单号')
expect(source).toContain("booking.status !== 'RELEASED'")
expect(source).toContain('bookingContainersError')
expect(source).toContain("getApiErrorDetails(cause, '该订舱柜次加载失败,请重试')")
expect(source).toContain("getApiErrorDetails(cause, '当前绑定订舱资料加载失败,请重试')")
expect(source).toContain('<ApiErrorMeta :details="bookingContainersError" />')
expect(source).toContain('重新加载选项')
expect(source).toContain('重新加载')
expect(source).toContain('请选择有效的合作中拖车供应商')
})
it('freezes the current edit relation and marks its fallback options as non-selectable', () => {
expect(source).toContain('const relationLocked = computed(() => Boolean(props.resource))')
expect(source).toContain('(当前绑定,不可重选)')
expect(source).toContain(':disabled="relationLocked"')
expect(source).toContain('<el-option v-if="selectedContainerFallback"')
expect(source).toContain(':value="selectedContainerFallback.id" disabled')
})
it('guards current booking detail loads against stale route reuse responses', () => {
expect(source).toContain('const sequence = ++bookingLoadSequence')
expect(source).toContain("sequence !== bookingLoadSequence || String(draft.oceanBookingId || '') !== bookingId")
expect(source).toContain('const sequence = ++optionsLoadSequence')
expect(source).toContain('if (sequence !== optionsLoadSequence) return')
expect(source).toContain("watch(() => props.resource?.id")
})
it('labels the internal cabinet, type and pickup-time physical number separately', () => {
expect(source).toContain('柜次号:${item.cabinetNo')
expect(source).toContain('柜型:${item.containerType')
@@ -50,11 +50,12 @@ const candidates = ref<TruckAppointmentCandidate[]>([])
const optionsLoading = ref(false)
const optionsError = ref('')
let bookingLoadSequence = 0
let optionsLoadSequence = 0
const selectableCarriers = computed(() => carriers.value.filter((item) => (
item.status === 'ACTIVE'
)))
const bookings = computed(() => {
const result = new Map<string, { id: string; bookingNo: string; billOfLadingNo: string; warehouseId: string; warehouseName: string | null; bookingDocumentReady: boolean }>()
const result = new Map<string, { id: string; bookingNo: string; billOfLadingNo: string | null; warehouseId: string; warehouseName: string | null; bookingDocumentReady: boolean }>()
for (const item of candidates.value) if (!result.has(item.oceanBookingId)) result.set(item.oceanBookingId, {
id: item.oceanBookingId,
bookingNo: item.oceanBookingNo,
@@ -139,12 +140,14 @@ async function selectBooking(bookingId: string) {
draft.billOfLadingNo = item.billOfLadingNo
}
async function loadOptions() {
const sequence = ++optionsLoadSequence
optionsLoading.value = true; optionsError.value = ''
if (relationLocked.value) candidates.value = []
candidates.value = []
const results = await Promise.allSettled([
listSuppliers({ page: 1, size: 200, category: 'TRUCKING', status: 'ACTIVE' }),
relationLocked.value ? Promise.resolve({ records: [] as TruckAppointmentCandidate[] }) : listTruckAppointmentCandidates({ page: 1, size: 200 }),
])
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')
@@ -179,7 +182,7 @@ watch(() => props.resource?.id, (id, previous) => { if (id && id !== previous) v
<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">
<el-col :xs="24"><el-divider content-position="left">订舱与柜次(必填)</el-divider></el-col>
<el-col class="relation-column" :xs="24" :sm="12"><el-form-item label="订舱" prop="oceanBookingId"><el-select :model-value="draft.oceanBookingId" filterable :disabled="relationLocked" placeholder="请选择仍有可预约柜次的已放舱订舱" :loading="optionsLoading" no-data-text="暂无仍有可预约柜次的已放舱订舱" style="width:100%" @update:model-value="selectBooking(String($event || ''))"><el-option v-if="selectedBookingFallback" :key="selectedBookingFallback.id" :label="selectedBookingFallback.label" :value="selectedBookingFallback.id" disabled /><el-option v-for="item in bookings" :key="item.id" :label="`${item.bookingNo || '订舱号缺失'} / 提单号:${item.billOfLadingNo}`" :value="item.id" /></el-select></el-form-item></el-col>
<el-col class="relation-column" :xs="24" :sm="12"><el-form-item label="订舱" prop="oceanBookingId"><el-select :model-value="draft.oceanBookingId" filterable :disabled="relationLocked" placeholder="请选择仍有可预约柜次的已放舱订舱" :loading="optionsLoading" no-data-text="暂无仍有可预约柜次的已放舱订舱" style="width:100%" @update:model-value="selectBooking(String($event || ''))"><el-option v-if="selectedBookingFallback" :key="selectedBookingFallback.id" :label="selectedBookingFallback.label" :value="selectedBookingFallback.id" disabled /><el-option v-for="item in bookings" :key="item.id" :label="`${item.bookingNo || '订舱号缺失'} / 提单号:${item.billOfLadingNo || '缺失'}`" :value="item.id" /></el-select></el-form-item></el-col>
<el-col class="relation-column" :xs="24" :sm="12"><el-form-item label="柜次" prop="containerBookingId"><el-select :model-value="draft.containerBookingId" filterable :disabled="relationLocked || !draft.oceanBookingId || optionsLoading || bookingContainersLoading" placeholder="请先选择订舱,再选择可预约柜次" :loading="optionsLoading || bookingContainersLoading" :no-data-text="draft.oceanBookingId ? '该订舱暂无可预约柜次' : '请先选择订舱'" style="width:100%" @update:model-value="selectContainer(String($event || ''))"><el-option v-if="selectedContainerFallback" :key="selectedContainerFallback.id" :label="selectedContainerFallback.label" :value="selectedContainerFallback.id" disabled /><el-option v-for="item in selectableContainers" :key="item.containerBookingId" :label="containerOptionLabel(item)" :value="item.containerBookingId" /></el-select><div v-if="bookingContainersError" class="field-error"><span>{{ bookingContainersError.message }}</span><ApiErrorMeta :details="bookingContainersError" /><el-button link type="primary" :loading="bookingContainersLoading" @click="draft.oceanBookingId && loadCurrentBookingContext(String(draft.oceanBookingId))">重新加载</el-button></div><div v-else-if="draft.oceanBookingId && !optionsLoading && !bookingContainersLoading && !selectableContainers.length && !relationLocked" class="field-hint">该订舱当前没有可创建拖车预约的柜次。</div></el-form-item></el-col>
<el-col :xs="24"><el-divider content-position="left">基本信息</el-divider></el-col>
<el-col v-if="draft.oceanBookingId && !bookingContainersLoading && !bookingContainersError && !draft.billOfLadingNo?.trim()" :xs="24"><el-alert title="所选订舱缺少有效提单号,暂不能创建或保存拖车预约" type="warning" :closable="false" show-icon /></el-col>
+1 -1
View File
@@ -207,7 +207,7 @@ export interface TruckAppointmentCandidateQuery {
export interface TruckAppointmentCandidate {
oceanBookingId: string
oceanBookingNo: string
billOfLadingNo: string
billOfLadingNo: string | null
warehouseId: string
warehouseName: string | null
channelCode: string | null