import { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router' import { flushPromises, mount } from '@vue/test-utils' import { beforeEach, describe, expect, it, vi } from 'vitest' import AppointmentFollowupDialog from './AppointmentFollowupDialog.vue' import { getFollowups, saveFollowup, getContainerFollowups, saveContainerFollowup, type PlanningSource, type ContainerFollowupContext } from './planningApi' vi.mock('./planningApi', () => ({ getFollowups: vi.fn(), saveFollowup: vi.fn(), getContainerFollowups: vi.fn(), saveContainerFollowup: vi.fn() })) vi.mock('vue-router', () => ({ onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn() })) vi.mock('element-plus', () => ({ ElMessage: { warning: vi.fn(), success: vi.fn() } })) const history = { history: { records: [], page: 1, size: 10, total: 0, pages: 0 }, versions: { DRAYAGE_CONTACT: 0, TERMINAL_APPOINTMENT: 0, WAREHOUSE_RECEIVING: 0, FBA_RECEIVING: 0 } } const source: PlanningSource = { sourceKey: 'ORDER:1', customerId: 'A', canFollowup: true, blockReasons: [], orderId: '1', inboundCode: 'IN-1', bookingNo: null, containerNo: null, originWarehouseId: 'US-1', originWarehouseName: '美国仓', destinationKey: 'FBA:ONT8', destinationWarehouseCode: 'ONT8', packages: null, sourceVersion: null } function render(canWrite = true, container?: ContainerFollowupContext) { return mount(AppointmentFollowupDialog, { props: container ? { container, canWrite } : { source, coverage: { status: 'UNASSIGNED', appointments: [], missingFields: [], changeReviewRequired: false }, canWrite }, global: { stubs: { AppDialog: { name: 'AppDialog', props: ['confirmDisabled', 'beforeClose'], template: '
' }, ElForm: { template: '
' }, ElFormItem: { template: '' }, ElInput: { props: ['modelValue', 'placeholder'], template: '' }, ElSelect: true, ElOption: true, ElDatePicker: true, DataTable: true, LoadErrorState: true, ApiErrorMeta: true, } } }) } describe('appointment followup recovery', () => { beforeEach(() => { vi.resetAllMocks(); vi.mocked(getFollowups).mockResolvedValue(history) vi.mocked(getContainerFollowups).mockResolvedValue(history) vi.mocked(saveFollowup).mockResolvedValue({ stage: 'FBA_RECEIVING', version: 1, contactedParty: null, result: '已联系', nextAction: null, followupDate: null, operatorName: '运营', createdAt: '2026-10-09T12:00:00Z' }) }) it.each([null, '100'])('saves the correct container stage for warehouse %s without customer-scoped calls', async warehouseId => { const container = { containerBookingId: 'C1', containerNo: 'CONTAINER-A', warehouseId, warehouseName: warehouseId ? '洛杉矶仓' : null, canFollowup: true, blockReasons: [] } const wrapper = render(true, container); await flushPromises() await wrapper.get('input[placeholder="记录对方反馈,不会自动确认预约"]').setValue('同柜统一联系') await wrapper.get('[data-save]').trigger('click'); await flushPromises() expect(saveContainerFollowup).toHaveBeenCalledWith(container, expect.objectContaining({ stage: warehouseId ? 'WAREHOUSE_RECEIVING' : 'DRAYAGE_CONTACT', expectedVersion: 0, result: '同柜统一联系' })) expect(getContainerFollowups).toHaveBeenCalledTimes(2) expect(getFollowups).not.toHaveBeenCalled() expect(saveFollowup).not.toHaveBeenCalled() wrapper.unmount() }) it('describes FBA followup independently of batch action visibility', async () => { const wrapper = render(); await flushPromises() expect(wrapper.text()).toContain('FBA收货跟进') expect(wrapper.text()).not.toContain('同柜多客户共用提柜') wrapper.unmount() }) it('saves contact facts without requiring appointment, inbound plan or driver fields', async () => { const wrapper = render(); await flushPromises() await wrapper.get('input[placeholder="记录对方反馈,不会自动确认预约"]').setValue('已联系,等待回复') await wrapper.get('[data-save]').trigger('click'); await flushPromises() expect(saveFollowup).toHaveBeenCalledWith('ORDER:1', 'A', expect.objectContaining({ stage: 'FBA_RECEIVING', expectedVersion: 0, result: '已联系,等待回复', followupDate: null })) const body = vi.mocked(saveFollowup).mock.calls[0]![2] expect(body).not.toHaveProperty('appointmentNo'); expect(body).not.toHaveProperty('driverId'); expect(body).not.toHaveProperty('operatorId') expect(getFollowups).toHaveBeenCalledTimes(2) }) it('retries an unknown outcome with the exact same body and persisted command key', async () => { vi.mocked(saveFollowup).mockRejectedValueOnce(new Error('network')) const wrapper = render(); await flushPromises() await wrapper.get('input[placeholder="记录对方反馈,不会自动确认预约"]').setValue('等待码头开放') await wrapper.get('[data-save]').trigger('click'); await flushPromises() const first = vi.mocked(saveFollowup).mock.calls[0]![2] await wrapper.get('[data-close]').trigger('click') expect(wrapper.emitted('close')).toBeUndefined() 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(saveFollowup).mock.calls[1]![2]).toEqual(first) expect(first.idempotencyKey).toBeTruthy() }) it('does not allow a write without write permission or a successfully loaded version', async () => { const readonly = render(false); await flushPromises() expect(readonly.get('[data-save]').attributes('disabled')).toBeDefined() vi.mocked(getFollowups).mockRejectedValueOnce(new Error('unavailable')) const failed = render(); await flushPromises() expect(failed.get('[data-save]').attributes('disabled')).toBeDefined() expect(saveFollowup).not.toHaveBeenCalled() }) })