import ElementPlus from 'element-plus' 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, TruckAppointmentResource } from '../types' import { listSuppliers } from '@/modules/suppliers' 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', truckingScope: 'DOMESTIC', status: 'ACTIVE', contactName: '张调度', phone: '13800000001' }, { id: 'carrier-2', name: '第二车队', category: 'TRUCKING', truckingScope: 'DOMESTIC', status: 'ACTIVE', contactName: '李调度', phone: '13800000002' }, { id: 'overseas-1', name: '海外车队候选', category: 'TRUCKING', truckingScope: 'OVERSEAS', status: 'ACTIVE' }] }), })) vi.mock('@/modules/ocean-bookings', () => ({ getOceanBooking: vi.fn(), })) vi.mock('../api/platformTrucking', () => ({ listTruckAppointmentCandidates: vi.fn() })) const model: SaveTruckAppointmentRequest = { expectedVersion: 0, oceanBookingId: null, carrierId: null, plannedLoadingAt: null, } const candidates = [ { oceanBookingId: 'booking-1', oceanBookingNo: 'OB-1', billOfLadingNo: 'BL-1', warehouseId: 'WH-1', warehouseName: '义乌仓', channelCode: 'SEA', channelName: '海运' }, { oceanBookingId: 'booking-2', oceanBookingNo: 'OB-2', billOfLadingNo: 'BL-2', warehouseId: 'WH-2', warehouseName: '东莞仓', channelCode: 'SEA', channelName: '海运' }, ] function detail(id: string) { const candidate = candidates.find((item) => item.oceanBookingId === id)! return { 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: [], } } function resource(id: string, bookingId: string): TruckAppointmentResource { const candidate = candidates.find((item) => item.oceanBookingId === bookingId)! return { id, status: 'DRAFT', oceanBookingId: bookingId, oceanBookingNo: candidate.oceanBookingNo, billOfLadingNo: candidate.billOfLadingNo, warehouseId: candidate.warehouseId, warehouseName: candidate.warehouseName } } describe('TruckAppointmentForm booking-only behavior', () => { beforeEach(() => { vi.clearAllMocks() 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) }) it.each([false, true])('validates a booking without documents when editing is %s', async (editing) => { vi.mocked(getOceanBooking).mockResolvedValue({ ...detail('booking-1'), documents: [] } as never) const wrapper = mount(TruckAppointmentForm, { props: { modelValue: { ...model, oceanBookingId: 'booking-1', carrierId: 'carrier-1', plannedLoadingAt: '2026-10-25T10:00:00' }, resource: editing ? resource('appointment-1', 'booking-1') : null, }, global: { plugins: [ElementPlus] }, }) await flushPromises() expect(await wrapper.vm.validate()).toBe(true) expect(wrapper.text()).not.toContain('所选订舱缺少有效的订舱确认文件或提单文件') wrapper.unmount() }) it('selects only server-approved released bookings and never exposes container identity', async () => { const wrapper = mount(TruckAppointmentForm, { props: { modelValue: { ...model } }, global: { plugins: [ElementPlus] } }) await flushPromises() const selects = wrapper.findAllComponents({ name: 'ElSelect' }) selects[0]!.vm.$emit('update:modelValue', 'booking-1') await flushPromises() 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('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') expect(emitted).not.toHaveProperty('customerId') expect(emitted).not.toHaveProperty('customerName') wrapper.unmount() }) it('keeps only the latest candidate response when option reloads overlap', async () => { let resolveSlow!: (value: Awaited>) => void let resolveFast!: (value: Awaited>) => 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 } 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() const selects = wrapper.findAllComponents({ name: 'ElSelect' }) selects[0]!.vm.$emit('update:modelValue', 'booking-1') await flushPromises() selects[0]!.vm.$emit('update:modelValue', 'booking-2') await flushPromises() const emitted = wrapper.emitted('update:modelValue')?.at(-1)?.[0] as SaveTruckAppointmentRequest expect(emitted.oceanBookingId).toBe('booking-2') expect(emitted.billOfLadingNo).toBe('BL-2') expect(emitted.warehouseId).toBe('WH-2') const labels = wrapper.findAllComponents({ name: 'ElOption' }).map((option) => String(option.props('label') || '')) expect(labels.some((label) => label.includes('OB-2'))).toBe(true) wrapper.unmount() }) it('ignores stale current-booking details when a cached edit route changes', async () => { let resolveFirst!: (value: ReturnType) => void let resolveSecond!: (value: ReturnType) => 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 firstResource = resource('appointment-1', 'booking-1') const secondResource = resource('appointment-2', 'booking-2') const wrapper = mount(TruckAppointmentForm, { props: { modelValue: { ...model, oceanBookingId: 'booking-1' }, resource: firstResource }, global: { plugins: [ElementPlus] } }) await flushPromises() await wrapper.setProps({ modelValue: { ...model, oceanBookingId: 'booking-2' }, resource: secondResource }) await flushPromises() resolveSecond(detail('booking-2')) await flushPromises() resolveFirst({ ...detail('booking-1'), documents: [] }) await flushPromises() const labels = wrapper.findAllComponents({ name: 'ElOption' }).map((option) => String(option.props('label') || '')) expect(labels.some((label) => label.includes('OB-2') && label.includes('不可重选'))).toBe(true) expect(labels.some((label) => label.includes('OB-1'))).toBe(false) expect(wrapper.text()).not.toContain('所选订舱缺少有效的订舱确认文件或提单文件') 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 current = resource('appointment-1', 'booking-1') const wrapper = mount(TruckAppointmentForm, { props: { modelValue: { ...model, oceanBookingId: 'booking-1' }, resource: current }, global: { plugins: [ElementPlus] } }) await flushPromises() expect(wrapper.text()).toContain('订舱版本冲突') expect(wrapper.text()).toContain('A0409') expect(wrapper.text()).toContain('req-booking') expect(wrapper.text()).toContain('trace-booking') wrapper.unmount() }) 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 current = resource('appointment-1', 'booking-1') const wrapper = mount(TruckAppointmentForm, { props: { modelValue: { ...model, oceanBookingId: 'booking-1' }, resource: current }, global: { plugins: [ElementPlus] } }) await flushPromises() expect(wrapper.text()).toContain('当前绑定订舱已不是放舱状态') expect(listTruckAppointmentCandidates).not.toHaveBeenCalled() wrapper.unmount() }) it('starts with five main fields and optional collapsed dispatch information', async () => { const wrapper = mount(TruckAppointmentForm, { props: { modelValue: { ...model } }, global: { plugins: [ElementPlus] } }) await flushPromises() expect(wrapper.findAll('.appointment-grid > .el-form-item').filter(item => !item.element.closest('details'))).toHaveLength(5) expect((wrapper.get('details').element as HTMLDetailsElement).open).toBe(false) for (const label of ['门店名称', '司机身份证号', '毛重(kg)', '体积(m³)', '在途分钟', '托盘数量', '查看附件引用']) expect(wrapper.text()).not.toContain(label) expect(wrapper.findAllComponents({ name: 'ElSelect' })).toHaveLength(2) expect(wrapper.text()).toContain('元') expect(wrapper.text()).toContain('保存预约') wrapper.unmount() }) it('copies supplier contacts and replaces or clears them when switching suppliers', async () => { const wrapper = mount(TruckAppointmentForm, { props: { modelValue: { ...model } }, global: { plugins: [ElementPlus] } }) await flushPromises() expect(listSuppliers).toHaveBeenCalledWith({ page: 1, size: 200, category: 'TRUCKING', truckingScope: 'DOMESTIC', status: 'ACTIVE' }) const supplier = wrapper.findAllComponents({ name: 'ElSelect' })[1]! expect(supplier.findAllComponents({ name: 'ElOption' }).map(option => option.props('value'))).not.toContain('overseas-1') supplier.vm.$emit('update:modelValue', 'carrier-1') await flushPromises() expect(wrapper.emitted('update:modelValue')!.at(-1)![0]).toMatchObject({ carrierId: 'carrier-1', fleetContact: '张调度', fleetPhone: '13800000001', currency: 'RMB' }) expect(wrapper.get('[aria-label="车队联系方式"]').text()).toContain('张调度') supplier.vm.$emit('update:modelValue', 'carrier-2') await flushPromises() expect(wrapper.emitted('update:modelValue')!.at(-1)![0]).toMatchObject({ carrierId: 'carrier-2', fleetContact: '李调度', fleetPhone: '13800000002' }) supplier.vm.$emit('update:modelValue', '') await flushPromises() expect(wrapper.emitted('update:modelValue')!.at(-1)![0]).toMatchObject({ carrierId: null, carrierName: null, fleetContact: null, fleetPhone: null }) wrapper.unmount() }) it('preserves saved contact overrides and supplementary values when editing', async () => { const wrapper = mount(TruckAppointmentForm, { props: { modelValue: { ...model, oceanBookingId: 'booking-1', carrierId: 'carrier-1', fleetContact: '本次调度', fleetPhone: '13900000000', storeName: '目的门店', grossWeightKg: '120.00' }, resource: resource('appointment-1', 'booking-1') }, global: { plugins: [ElementPlus] } }) await flushPromises() wrapper.findAllComponents({ name: 'ElSelect' })[1]!.vm.$emit('update:modelValue', 'carrier-1') await flushPromises() expect(wrapper.get('[aria-label="车队联系方式"]').text()).toContain('本次调度') expect(wrapper.text()).toContain('其他已存资料') expect(wrapper.findAllComponents({ name: 'ElInput' }).some(input => input.props('modelValue') === '目的门店')).toBe(true) wrapper.unmount() }) it('keeps amount validation at two decimal places on the displayed field', async () => { const valid = { ...model, oceanBookingId: 'booking-1', carrierId: 'carrier-1', plannedLoadingAt: '2026-09-19T09:00:00', payableAmount: '100.00' } const wrapper = mount(TruckAppointmentForm, { props: { modelValue: valid }, global: { plugins: [ElementPlus] } }) await flushPromises() await wrapper.setProps({ modelValue: { ...valid, billOfLadingNo: 'BL-1', payableAmount: '100.001' } }) await flushPromises() expect(wrapper.findAllComponents({ name: 'ElFormItem' }).find(field => field.props('prop') === 'payableAmount')!.findComponent({ name: 'ElInput' }).props('modelValue')).toBe('100.001') const rule = wrapper.findComponent({ name: 'ElForm' }).props('rules').payableAmount[0] const reject = vi.fn() rule.validator(null, '100.001', reject) expect(reject).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining('应付拖车费') })) const accept = vi.fn() rule.validator(null, '100.00', accept) expect(accept).toHaveBeenCalledWith(undefined) wrapper.unmount() }) })