import { inject, onBeforeUnmount } from 'vue' import { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router' import { flushPromises, mount } from '@vue/test-utils' import { beforeEach, describe, expect, it, vi } from 'vitest' import AppointmentCalendarView from './AppointmentCalendarView.vue' import { createFbaAppointment, getFbaAppointment, updateFbaAppointment, listFbaAppointmentCalendar, listInboundOrders } from '@/modules/overseas-warehouse' import { listArrivalCalendar, listPickupCalendar } from './api' import { listTripCalendar } from './tripCalendar' vi.mock('./tripCalendar', async (original) => ({...(await original()),listTripCalendar:vi.fn()})) import { resolvePlanningSource, saveFollowup, listUnscheduledAppointments } from './planningApi' const routing = vi.hoisted(() => ({ query: {} as Record, canSourceRead: true, canPickup: true, canTrips: true, canWrite: true, containerPending: false })) vi.mock('./planningApi', () => ({ resolvePlanningSource: vi.fn(), listPlanningCargo: vi.fn(), getFollowups: vi.fn(), saveFollowup: vi.fn(), listUnscheduledAppointments: vi.fn() })) import { listPlatformWarehouses } from '@/modules/warehouses' vi.mock('@/modules/warehouses', () => ({ listPlatformWarehouses: vi.fn() })) vi.mock('vue-router', () => ({ onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn(), useRoute: () => routing, useRouter: () => ({ push: vi.fn() }) })) vi.mock('./api', () => ({ listArrivalCalendar: vi.fn(), listPickupCalendar: vi.fn(), deleteAppointment: vi.fn() })) vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => (permission !== 'write' || routing.canWrite) && (permission !== 'source-read' || routing.canSourceRead) && (permission !== 'platform:firstleg:terminal-drayage:list' || routing.canPickup) && (permission !== 'platform:fleet:trip:list' || routing.canTrips) }) })) vi.mock('@/modules/overseas-warehouse', async (importOriginal) => ({ ...(await importOriginal()), createFbaAppointment: vi.fn(), getFbaAppointment: vi.fn(), listFbaAppointmentCalendar: vi.fn(), listInboundOrders: vi.fn(), updateFbaAppointment: vi.fn(), overseasWarehousePermissions: { inboundOrderAppointment: 'write', lastMileOrderList: 'source-read' }, })) vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn(), warning: vi.fn() }, ElMessageBox: { confirm: vi.fn() } })) const candidates = [ { lineId: '9007199254740993', customerId: 'A', overseasWarehouseId: '100', fbaWarehouseCode: 'ONT8', planId: '1', planNo: 'IN-A', orderId: '10' }, { lineId: '9007199254740994', customerId: 'B', overseasWarehouseId: '100', fbaWarehouseCode: 'ONT8', planId: '2', planNo: 'IN-B', orderId: '20' }, ] function render(openCalendar = true) { const wrapper = mount(AppointmentCalendarView, { global: { directives: { loading: {} }, stubs: { AppointmentCancellationDialog: {name:'AppointmentCancellationDialog',template:'
取消弹窗
'}, AppointmentCancellationHistory: {name:'AppointmentCancellationHistory',template:'
取消历史详情
'}, AppointmentCancellationRecords: {name:'AppointmentCancellationRecords',template:''}, AdminPage: { template: '
' }, AppointmentContainerPanel: { name: 'AppointmentContainerPanel', setup() { const register = inject<(guard: () => boolean) => () => void>('appointment-register-leave-guard')! const unregister = register(() => !routing.containerPending) onBeforeUnmount(unregister) }, template: '
整柜跟进工作区
' }, AppointmentCalendarPanel: { name: 'AppointmentCalendarPanel', mounted() { this.$emit('layers', { arrivals: true, trips: true }) }, props: ['events', 'incomplete', 'notices'], template: '
' }, AppDialog: { name: 'AppDialog', props: ['modelValue', 'showConfirm', 'beforeClose'], template: '
' }, ElForm: { template: '
' }, ElFormItem: { template: '' }, ElInput: { props: ['modelValue', 'placeholder'], template: '' }, ElDatePicker: { props: ['modelValue'], template: '' }, ElButton: { template: '' }, ElSelect: { name: 'ElSelect', emits: ['update:modelValue', 'change'], props: ['modelValue', 'remoteMethod'], template: '' }, ElOption: { props: ['value', 'label'], template: '' }, DataTable: { name: 'DataTable', props: ['data'], template: '
' }, LoadErrorState: true, ApiErrorMeta: true, AppointmentFollowupDialog: true, AppointmentCargoPanel: { name: 'AppointmentCargoPanel', props: ['externalSource'], template: '
合批工作区
' }, } } }) if (openCalendar) void wrapper.findAll('button').find(button => button.text() === '预约日历')!.trigger('click') return wrapper } describe('appointment calendar data integration', () => { beforeEach(() => { vi.resetAllMocks() routing.query = {}; routing.canSourceRead = true; routing.canPickup = true; routing.canTrips = true; routing.canWrite = true; routing.containerPending = false vi.mocked(listPlatformWarehouses).mockResolvedValue({ records: ['ONT8', 'LAX9'].map(code => ({ id: code, tenantId: 0, warehouseCode: code, warehouseName: code, warehouseType: 'FBA' as const, status: 'ACTIVE' as const, internalCode: null, countryCode: 'US', timezone: 'America/Los_Angeles', contactName: null, contactPhone: null, contactEmail: null, remark: null })), page: 1, size: 50, pages: 1, total: 2 }) vi.mocked(listFbaAppointmentCalendar).mockResolvedValue([]) vi.mocked(listUnscheduledAppointments).mockResolvedValue({records: [], total: 0}) vi.mocked(listArrivalCalendar).mockResolvedValue([]) vi.mocked(listTripCalendar).mockResolvedValue({records:[],unresolvedRecords:[]}) vi.mocked(listPickupCalendar).mockResolvedValue({records: [], truncated: false, unclassifiedCount: 0, scopeBasis: 'US_DESTINATION_WAREHOUSE', timeBasis: 'SOURCE_LOCAL_UNSPECIFIED'}) vi.mocked(listInboundOrders).mockResolvedValue({ records: candidates, total: 2, page: 1, size: 10, pages: 1 }) vi.mocked(createFbaAppointment).mockResolvedValue({ version: 0, id: '1', appointmentNo: 'APT-AB', plannedDate: '2026-10-09', appointmentAt: null, originWarehouseId: '100', appointmentStatus: 'PENDING', selectedCount: 2, fbaWarehouseCode: 'ONT8', items: [] }) }) it('opens a dated calendar appointment read-only for list-only users', async () => { routing.canWrite=false const record={version:0,id:'1',appointmentNo:'AP-DATED',plannedDate:'2026-10-15',originWarehouseId:'100',appointmentStatus:'PENDING',selectedCount:1,fbaWarehouseCode:'ABE3',items:[]} vi.mocked(listFbaAppointmentCalendar).mockResolvedValue([record]) vi.mocked(getFbaAppointment).mockResolvedValue(record) const wrapper=render();await flushPromises() const panel=wrapper.findComponent({name:'AppointmentCalendarPanel'}) expect(panel.props('events')).toEqual(expect.arrayContaining([expect.objectContaining({key:'AP-DATED',canView:true,canEdit:false})])) panel.vm.$emit('view',{key:'AP-DATED'});await flushPromises() expect(getFbaAppointment).toHaveBeenCalledWith('AP-DATED') expect(wrapper.findComponent({name:'FbaAppointmentFields'}).props('disabled')).toBe(true) expect(wrapper.find('[data-save]').exists()).toBe(false) wrapper.findComponent({name:'AppDialog'}).vm.$emit('confirm');await flushPromises() expect(updateFbaAppointment).not.toHaveBeenCalled();wrapper.unmount() }) it('uses the dedicated cancellation dialog for an undated appointment instead of editing its status', async () => { const record = { version: 3, id: '1', appointmentNo: 'AP-UNDATED', originWarehouseId: '100', appointmentStatus: 'PENDING', selectedCount: 1, fbaWarehouseCode: 'ABE3', items: [] } vi.mocked(listUnscheduledAppointments).mockResolvedValue({records: [record], total: 1}) vi.mocked(getFbaAppointment).mockResolvedValue(record) const wrapper=render(); await flushPromises() await wrapper.findAll('button').find(button=>button.text()==='查看 / 继续')!.trigger('click'); await flushPromises() expect(wrapper.find('option[value="CANCELLED"]').exists()).toBe(false) await wrapper.get('[data-cancel-editing]').trigger('click'); await flushPromises() expect(wrapper.findComponent({name:'AppointmentCancellationDialog'}).attributes('appointment-no')).toBe('AP-UNDATED') expect(wrapper.find('[data-save]').exists()).toBe(false) expect(updateFbaAppointment).not.toHaveBeenCalled(); wrapper.unmount() }) it('loads cancellation records only after expansion and opens cancelled appointments read-only', async () => { const record = { version: 1, id: '1', appointmentNo: 'AP-CANCEL', originWarehouseId: '100', appointmentStatus: 'CANCELLED', selectedCount: 1, fbaWarehouseCode: 'ABE3', items: [] } vi.mocked(getFbaAppointment).mockResolvedValue(record) const wrapper=render();await flushPromises() expect(wrapper.findComponent({name:'AppointmentCancellationRecords'}).exists()).toBe(false) const records=wrapper.findAll('details').find(d=>d.find('summary').text()==='取消记录')! ;(records.element as HTMLDetailsElement).open=true;await records.trigger('toggle') await wrapper.get('[data-history-view]').trigger('click');await flushPromises() expect(getFbaAppointment).toHaveBeenCalledWith('AP-CANCEL') expect(wrapper.find('[data-save]').exists()).toBe(false) expect(wrapper.findComponent({name:'FbaAppointmentFields'}).props('disabled')).toBe(true) expect(wrapper.findComponent({name:'AppointmentCancellationHistory'}).exists()).toBe(false) const history=wrapper.findAll('details').find(d=>d.find('summary').text()==='取消历史')! ;(history.element as HTMLDetailsElement).open=true;await history.trigger('toggle') expect(wrapper.findComponent({name:'AppointmentCancellationHistory'}).exists()).toBe(true) wrapper.findAllComponents({name:'AppDialog'}).at(-1)!.vm.$emit('confirm');await flushPromises() expect(updateFbaAppointment).not.toHaveBeenCalled();wrapper.unmount() }) it('refreshes an expanded cancellation list after a successful cancellation', async () => { vi.mocked(listFbaAppointmentCalendar).mockResolvedValue([{version:0,id:'1',appointmentNo:'AP-CANCEL',originWarehouseId:'100',appointmentStatus:'PENDING',selectedCount:1,fbaWarehouseCode:'ABE3',items:[],plannedDate:'2026-10-15'}]) const wrapper=render();await flushPromises() const records=wrapper.findAll('details').find(d=>d.find('summary').text()==='取消记录')! ;(records.element as HTMLDetailsElement).open=true;await records.trigger('toggle') const previous=wrapper.getComponent({name:'AppointmentCancellationRecords'}).vm wrapper.getComponent({name:'AppointmentCalendarPanel'}).vm.$emit('delete',{key:'AP-CANCEL'});await flushPromises() wrapper.getComponent({name:'AppointmentCancellationDialog'}).vm.$emit('cancelled');await flushPromises() expect(wrapper.getComponent({name:'AppointmentCancellationRecords'}).vm).not.toBe(previous) expect(wrapper.findComponent({name:'AppointmentCancellationDialog'}).exists()).toBe(false) wrapper.unmount() }) it('keeps the container view mounted while a child has an unresolved command', async () => { const wrapper = render(); await flushPromises() const tabs = wrapper.get('[aria-label="预约工作视图"]').findAll('button') await tabs[1]!.trigger('click'); await flushPromises() routing.containerPending = true await tabs[0]!.trigger('click') expect(tabs[1]!.attributes('aria-pressed')).toBe('true') expect(wrapper.findComponent({ name: 'AppointmentContainerPanel' }).exists()).toBe(true) routing.containerPending = false await tabs[0]!.trigger('click') expect(tabs[0]!.attributes('aria-pressed')).toBe('true') expect(wrapper.findComponent({ name: 'AppointmentContainerPanel' }).exists()).toBe(false) wrapper.unmount() }) it('opens an unscheduled appointment read-only without allowing a save', async () => { routing.canWrite = false const record = { version: 0, id: '1', appointmentNo: 'AP-READ', originWarehouseId: '100', appointmentStatus: 'PENDING', selectedCount: 1, fbaWarehouseCode: 'ONT8', items: [] } vi.mocked(listUnscheduledAppointments).mockResolvedValue({ records: [record], total: 1 }) vi.mocked(getFbaAppointment).mockResolvedValue(record) const wrapper = render(); await flushPromises() await wrapper.findAll('button').find(b => b.text() === '查看 / 继续')!.trigger('click'); await flushPromises() expect(wrapper.find('[data-save]').exists()).toBe(false) const dialog = wrapper.findAllComponents({ name: 'AppDialog' }).at(-1)! dialog.vm.$emit('confirm'); await flushPromises() expect(updateFbaAppointment).not.toHaveBeenCalled() expect(wrapper.findComponent({ name: 'FbaAppointmentFields' }).props('disabled')).toBe(true) wrapper.unmount() }) it('puts calendar first and opens it by default without reloading when switching back', async () => { const wrapper = render(false); await flushPromises() const tabs = wrapper.get('[aria-label="预约工作视图"]').findAll('button') expect(tabs.map(button => button.text())).toEqual(['预约日历', '提柜与到仓', '送仓与排车']) expect(tabs[0]!.attributes('aria-pressed')).toBe('true') expect(wrapper.text()).not.toContain('美国预约总览') expect(listFbaAppointmentCalendar).toHaveBeenCalledTimes(1) expect(listArrivalCalendar).toHaveBeenCalledTimes(1) await tabs[1]!.trigger('click'); await flushPromises() expect(listFbaAppointmentCalendar).toHaveBeenCalledTimes(1) await tabs[0]!.trigger('click'); await flushPromises() expect(listFbaAppointmentCalendar).toHaveBeenCalledTimes(1) }) it('distinguishes missing country data from truncation instead of suggesting a narrower date range', async () => { vi.mocked(listPickupCalendar).mockResolvedValueOnce({ records: [], truncated: false, unclassifiedCount: 74, scopeBasis: 'US_DESTINATION_WAREHOUSE', timeBasis: 'SOURCE_LOCAL_UNSPECIFIED' }) const wrapper = render(); await flushPromises() expect(wrapper.findComponent({ name: 'AppointmentCalendarPanel' }).props('notices').join(' ')).toContain('74 条提柜记录') expect(wrapper.text()).not.toContain('缩小日期范围') }) it('shows the original pickup time once without claiming a port time zone and retains partial-source warnings', async () => { vi.mocked(listPickupCalendar).mockResolvedValueOnce({records: [{containerBookingId: '123',bookingNo: 'CB-TEST',containerNo: 'TEST-CONT',destinationPortName: 'Port',destinationWarehouseCode: 'US-W',fleetSupplierName: 'Fleet',pickupAppointmentAt: '2026-10-09T11:00:00',actualPickupAt: null}],truncated: false,unclassifiedCount: 1,scopeBasis: 'US_DESTINATION_WAREHOUSE',timeBasis: 'SOURCE_LOCAL_UNSPECIFIED'}) const wrapper=render(); await flushPromises() const panel=wrapper.findComponent({name:'AppointmentCalendarPanel'}) expect(panel.props('events')).toEqual([expect.objectContaining({type:'pickup',date:'2026-10-09',time:'11:00',sourcePath:'/container-loading/overseas/123'})]) expect(panel.props('incomplete')).toBe(true) expect(wrapper.findComponent({ name: 'AppointmentCalendarPanel' }).props('notices').join(' ')).toContain('目的仓国家待核实') expect(listPickupCalendar).toHaveBeenCalledTimes(1) }) it('does not request pickup records without pickup list permission and discloses the omitted source', async () => { routing.canPickup=false const wrapper=render(); await flushPromises() expect(listPickupCalendar).not.toHaveBeenCalled() expect(wrapper.findComponent({ name: 'AppointmentCalendarPanel' }).props('notices').join(' ')).toContain('无提柜查看权限') expect(wrapper.findComponent({name:'AppointmentCalendarPanel'}).props('incomplete')).toBe(true) }) it('keeps successful arrival data visible when the pickup source fails', async () => { vi.mocked(listPickupCalendar).mockRejectedValueOnce(new Error('pickup unavailable')) vi.mocked(listArrivalCalendar).mockResolvedValueOnce([{containerBookingId:'1',bookingNo:'CB-1',arrivalAt:'2026-10-09T09:00:00',arrivalType:'ESTIMATED'}]) const wrapper=render(); await flushPromises() const panel=wrapper.findComponent({name:'AppointmentCalendarPanel'}) expect(panel.props('events')).toEqual([expect.objectContaining({type:'arrival',key:'arrival-1'})]) expect(panel.props('incomplete')).toBe(true) }) it('omits trip requests without trip list permission and keeps other sources usable', async () => { routing.canTrips=false const wrapper=render();await flushPromises() expect(listTripCalendar).not.toHaveBeenCalled();expect(listArrivalCalendar).toHaveBeenCalledTimes(1) expect(wrapper.findComponent({ name: 'AppointmentCalendarPanel' }).props('notices').join(' ')).toContain('无车次查看权限') }) it('keeps unresolved trip origins outside daily slots and clearly labels their UTC source time', async () => { vi.mocked(listTripCalendar).mockResolvedValueOnce({records:[],unresolvedRecords:[{sourceType:'TRIP_PLAN',tripNo:'TR-UNKNOWN',status:'PLANNED',originWarehouseId:null,originWarehouseCode:null,originWarehouseName:null,originCountryCode:null,originTimezone:null,originWarehouseStatus:null,plannedDepartureAt:'2026-10-10T12:00:00Z',expectedDriverEndAt:null,departedAt:null,completedAt:null,stops:[],incompleteReasons:['ORIGIN_WAREHOUSE_UNKNOWN']}]}) const wrapper=render();await flushPromises() expect(wrapper.findComponent({name:'AppointmentCalendarPanel'}).props('events')).toEqual([]) expect(wrapper.findAll('.appointment-extra summary').map(node => node.text())).not.toContain('待核实地点的车次(1)') expect(wrapper.text()).not.toContain('原计划时间保留 UTC') await wrapper.findAll('button').find(button => button.text() === '查看待核实车次(1)')!.trigger('click') expect(wrapper.text()).toContain('原计划时间保留 UTC') expect(listTripCalendar).toHaveBeenCalledTimes(1) }) it('creates one shared appointment for two customers without sending a fabricated header customer', async () => { const wrapper = render() await flushPromises() expect(listFbaAppointmentCalendar).toHaveBeenCalledTimes(1) expect(listArrivalCalendar).toHaveBeenCalledTimes(1) expect(listInboundOrders).not.toHaveBeenCalled() await wrapper.get('[data-create]').trigger('click') await flushPromises() await wrapper.get('select[aria-label="预约仓库"]').setValue('ONT8') await flushPromises() await wrapper.get('[data-select]').trigger('click') expect(wrapper.find('input[placeholder="填写仓库提供的预约编号"]').exists()).toBe(false) await wrapper.get('[data-save]').trigger('click') await flushPromises() expect(createFbaAppointment).toHaveBeenCalledTimes(1) expect(createFbaAppointment).toHaveBeenCalledWith(expect.objectContaining({ originWarehouseId: '100', fbaWarehouseCode: 'ONT8', lineIds: ['9007199254740993', '9007199254740994'], idempotencyKey: expect.any(String), details: expect.objectContaining({plannedDate: '2026-10-09', appointmentStatus: 'PENDING', appointmentAt: null, confirmationNo: null}), })) expect(vi.mocked(createFbaAppointment).mock.calls[0]![0]).not.toHaveProperty('customerId') expect(listInboundOrders).toHaveBeenCalledTimes(1) expect(listFbaAppointmentCalendar).toHaveBeenCalledTimes(2) }) it('only searches active FBA warehouses and loads cargo automatically after selection', async () => { const wrapper = render() await flushPromises() await wrapper.get('[data-create]').trigger('click') await flushPromises() expect(listPlatformWarehouses).toHaveBeenCalledWith(expect.objectContaining({ warehouseType: 'FBA', status: 'ACTIVE', size: 50 })) expect(wrapper.find('input[placeholder="例如 ONT8"]').exists()).toBe(false) expect(wrapper.text()).not.toContain('查询可预约货物') expect(wrapper.find('details').attributes('open')).toBeUndefined() await wrapper.get('select[aria-label="预约仓库"]').setValue('ONT8') await flushPromises() expect(listInboundOrders).toHaveBeenLastCalledWith({ page: 1, size: 10, fbaWarehouseCode: 'ONT8' }) }) it('keeps a pending unknown save retry on the same immutable command', async () => { vi.mocked(createFbaAppointment).mockRejectedValueOnce(new Error('response lost')) const wrapper = render(); await flushPromises() await wrapper.get('[data-create]').trigger('click'); await flushPromises() await wrapper.get('select[aria-label="预约仓库"]').setValue('ONT8'); await flushPromises() await wrapper.get('[data-select]').trigger('click') await wrapper.get('[data-save]').trigger('click'); await flushPromises() const first = vi.mocked(createFbaAppointment).mock.calls[0]![0] await wrapper.get('[data-close]').trigger('click') expect(wrapper.find('[data-save]').exists()).toBe(true) await wrapper.get('[data-create]').trigger('click') expect((wrapper.get('select[aria-label="预约仓库"]').element as HTMLSelectElement).value).toBe('ONT8') const done = vi.fn() wrapper.findAllComponents({ name: 'AppDialog' }).at(-1)!.props('beforeClose')(done) expect(done).not.toHaveBeenCalled() for (const hook of [onBeforeRouteLeave, onBeforeRouteUpdate]) { const guard = vi.mocked(hook).mock.calls.at(-1)![0] as () => boolean expect(guard()).toBe(false) } await wrapper.get('[data-save]').trigger('click'); await flushPromises() expect(vi.mocked(createFbaAppointment).mock.calls[1]![0]).toEqual(first) expect(first.details.appointmentAt).toBeNull() expect(first.details.confirmationNo).toBeNull() expect(first.idempotencyKey).toBeTruthy() }) it('keeps undated arrangements outside calendar time slots', async () => { vi.mocked(listUnscheduledAppointments).mockResolvedValueOnce({ records: [{ id:'1', version:0, appointmentNo:'AP-UNSCHEDULED', originWarehouseId:'100', appointmentStatus:'PENDING', selectedCount:2, fbaWarehouseCode:'ONT8', items:[] }], total:1 }) const wrapper=render(); await flushPromises() expect(wrapper.text()).toContain('未定时安排(1)') expect(wrapper.findComponent({name:'AppointmentCalendarPanel'}).props('events')).toEqual([]) expect(wrapper.findComponent({name:'DataTable'}).props('data')[0]).not.toHaveProperty('appointmentAt') }) it('changing or clearing the warehouse clears prior cargo selection and blocks saving it', async () => { const wrapper = render() await flushPromises() await wrapper.get('[data-create]').trigger('click') await flushPromises() await wrapper.get('select[aria-label="预约仓库"]').setValue('ONT8') await flushPromises() await wrapper.get('[data-select]').trigger('click') expect(wrapper.find('input[placeholder="填写仓库提供的预约编号"]').exists()).toBe(false) await wrapper.get('select[aria-label="预约仓库"]').setValue('') await wrapper.get('[data-save]').trigger('click') expect(createFbaAppointment).not.toHaveBeenCalled() expect(listInboundOrders).toHaveBeenCalledTimes(1) expect(wrapper.find('[data-select]').exists()).toBe(false) }) it('discards cargo responses from the previously selected warehouse', async () => { let resolveOld!: (value: Awaited>) => void vi.mocked(listInboundOrders).mockImplementationOnce(() => new Promise(resolve => { resolveOld = resolve })) const wrapper = render() await flushPromises() await wrapper.get('[data-create]').trigger('click') await flushPromises() await wrapper.get('select[aria-label="预约仓库"]').setValue('ONT8') vi.mocked(listInboundOrders).mockResolvedValueOnce({ records: [], total: 0, page: 1, size: 10, pages: 0 }) await wrapper.get('select[aria-label="预约仓库"]').setValue('LAX9') await flushPromises() resolveOld({ records: candidates, total: 2, page: 1, size: 10, pages: 1 }) await flushPromises() expect(wrapper.findComponent({ name: 'DataTable' }).props('data')).toEqual([]) }) it('searches by keyword and exposes warehouse failures without accepting free text', async () => { const wrapper = render() await flushPromises() await wrapper.get('[data-create]').trigger('click') await flushPromises() const select = wrapper.findAllComponents({ name: 'ElSelect' }).find(item => item.props('remoteMethod'))! vi.mocked(listPlatformWarehouses).mockRejectedValueOnce(new Error('仓库暂不可用')) await select.props('remoteMethod')(' LAX ') await flushPromises() expect(listPlatformWarehouses).toHaveBeenLastCalledWith(expect.objectContaining({ keyword: 'LAX', warehouseType: 'FBA', status: 'ACTIVE' })) expect(wrapper.findComponent({ name: 'LoadErrorState' }).props('title')).toBe('仓库加载失败') expect(wrapper.findAll('select[aria-label="预约仓库"] option')).toHaveLength(1) expect(listInboundOrders).not.toHaveBeenCalled() }) it('keeps available arrivals and marks the result incomplete when appointments fail', async () => { vi.mocked(listFbaAppointmentCalendar).mockRejectedValueOnce(new Error('预约暂不可用')) vi.mocked(listArrivalCalendar).mockResolvedValueOnce([{ containerBookingId: '1', bookingNo: 'C-1', arrivalAt: '2026-10-09T09:00:00', arrivalType: 'ESTIMATED' }]) const wrapper = render() await flushPromises() const calendar = wrapper.findComponent({ name: 'AppointmentCalendarPanel' }) expect(calendar.props('incomplete')).toBe(true) expect(calendar.props('events')).toHaveLength(1) expect(calendar.props('events')[0].type).toBe('arrival') expect(wrapper.findComponent({ name: 'LoadErrorState' }).props('title')).toBe('送仓预约加载失败') }) it('keeps delivery appointments when the arrival source fails', async () => { vi.mocked(listArrivalCalendar).mockRejectedValueOnce(new Error('到港暂不可用')) vi.mocked(listFbaAppointmentCalendar).mockResolvedValueOnce([{ version: 0, id: '1', appointmentNo: 'APT-AB', plannedDate: '2026-10-09', appointmentAt: null, originWarehouseId: '100', appointmentStatus: 'PENDING', selectedCount: 2, fbaWarehouseCode: 'ONT8', items: [] }]) const wrapper = render() await flushPromises() const calendar = wrapper.findComponent({ name: 'AppointmentCalendarPanel' }) expect(calendar.props('incomplete')).toBe(true) expect(calendar.props('events')[0].key).toBe('APT-AB') expect(wrapper.findComponent({ name: 'LoadErrorState' }).props('title')).toBe('船舶到港加载失败') }) it('resolves an external booking deep link through the backend before opening followup', async () => { routing.query = { sourceKey: 'BOOKING:B-1', customerId: 'A', destinationKey: 'FBA:ONT8' } vi.mocked(resolvePlanningSource).mockResolvedValueOnce({ source: { sourceKey: 'BOOKING:B-1', customerId: 'A', orderId: null, inboundCode: null, bookingNo: 'B-1', containerNo: null, originWarehouseId: '100', originWarehouseName: '美国仓', destinationKey: 'FBA:ONT8', destinationWarehouseCode: 'ONT8', packages: null, sourceVersion: 'BOOKING:1', canFollowup: true, blockReasons: [] }, coverage: { status: 'SOURCE_NOT_MATERIALIZED', appointments: [], missingFields: ['REAL_CARGO_MATERIALS'], changeReviewRequired: false } }) const wrapper = render(); await flushPromises() expect(resolvePlanningSource).toHaveBeenCalledWith(routing.query) const dialog = wrapper.findComponent({ name: 'AppointmentFollowupDialog' }) expect(dialog.props('source').bookingNo).toBe('B-1') expect(dialog.props('source').orderId).toBeNull() expect(dialog.props('coverage').status).toBe('SOURCE_NOT_MATERIALIZED') expect(createFbaAppointment).not.toHaveBeenCalled(); expect(saveFollowup).not.toHaveBeenCalled() expect(dialog.props('canArrange')).toBe(true) dialog.vm.$emit('arrange');await flushPromises() expect(wrapper.findComponent({name:'AppointmentFollowupDialog'}).exists()).toBe(false) expect(wrapper.findComponent({name:'AppointmentCargoPanel'}).props('externalSource')).toMatchObject({sourceKey:'BOOKING:B-1',bookingNo:'B-1',orderId:null}) }) it('does not trust stale destination query data or open followup after a source error', async () => { routing.query = { sourceKey: 'BOOKING:B-1', customerId: 'A', destinationKey: 'FBA:OLD' } vi.mocked(resolvePlanningSource).mockRejectedValueOnce(new Error('目的地已变化')) const wrapper = render(); await flushPromises() expect(wrapper.findComponent({ name: 'AppointmentFollowupDialog' }).exists()).toBe(false) expect(wrapper.findComponent({ name: 'LoadErrorState' }).props('title')).toBe('预约货源核对失败') }) it('does not request deep-link cargo without the underlying source permission', async () => { routing.query = { sourceKey: 'BOOKING:B-1', customerId: 'A', destinationKey: 'FBA:ONT8' }; routing.canSourceRead = false const wrapper = render(); await flushPromises() expect(resolvePlanningSource).not.toHaveBeenCalled() expect(wrapper.findComponent({ name: 'AppointmentFollowupDialog' }).exists()).toBe(false) }) })