import { flushPromises, mount } from '@vue/test-utils' import { beforeEach, expect, it, vi } from 'vitest' import WorkAppointmentForm from './WorkAppointmentForm.vue' import { getFbaAppointment, updateFbaAppointment, type FbaAppointmentDto } from '@/modules/overseas-warehouse' import { deleteAppointment } from './api' import { ApiResponseError } from '@/shared/api/http' vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) })) vi.mock('element-plus', () => ({ ElMessage: { warning: vi.fn(), success: vi.fn() }, ElMessageBox: { confirm: vi.fn() } })) vi.mock('@/modules/overseas-warehouse', async original => ({ ...await original(), getFbaAppointment: vi.fn(), updateFbaAppointment: vi.fn() })) vi.mock('./api', async original => ({ ...await original(), deleteAppointment: vi.fn() })) const detail: FbaAppointmentDto = { id: '1', appointmentNo: 'AP-001', batchKey: 'FBA_APPOINTMENT:1', version: 3, originWarehouseId: 'warehouse-1', fbaWarehouseCode: 'ONT8', selectedCount: 1, items: [], appointmentStatus: 'SUCCESS', confirmationNo: 'EXT-001', appointmentAt: '2026-10-15T23:30:00', appointmentEndAt: '2026-10-16T01:00:00', appointmentStartUtc: '2026-10-16T06:30:00Z', appointmentEndUtc: '2026-10-16T08:00:00Z', timezone: 'America/Los_Angeles' } function render(cancel = false) { return mount(WorkAppointmentForm, { props: { appointmentNo: 'AP-001', cancel, canWrite: true }, global: { stubs: { ElButton: { props: ['disabled'], template: '' }, FbaAppointmentFields: { name: 'FbaAppointmentFields', props: ['model', 'disabled'], template: '
' }, LoadErrorState: { props: ['message'], template: '
{{ message }}
' }, ApiErrorMeta: true } } }) } beforeEach(() => { vi.clearAllMocks(); vi.mocked(getFbaAppointment).mockResolvedValue(detail); vi.mocked(updateFbaAppointment).mockResolvedValue(detail) }) it('initializes confirmed facts after an initial read failure instead of showing an empty editable plan', async () => { vi.mocked(getFbaAppointment).mockRejectedValueOnce(new Error('来源暂不可用')) const wrapper = render(); await flushPromises() expect(wrapper.findComponent({ name: 'FbaAppointmentFields' }).exists()).toBe(false) await wrapper.get('[role="alert"] button').trigger('click'); await flushPromises() const model = wrapper.getComponent({ name: 'FbaAppointmentFields' }).props('model') expect(model.confirmationNo).toBe('EXT-001'); expect(model.appointmentStatus).toBe('SUCCESS') expect(model.appointmentAt).toBe('2026-10-15T23:30:00'); expect(model.startOffset).toBe('-07:00') expect(wrapper.findAll('button').find(button => button.text() === '保存预约安排')!.attributes('disabled')).toBeDefined() expect(updateFbaAppointment).not.toHaveBeenCalled(); wrapper.unmount() }) it('preserves both dates of an existing cross-day appointment and its explicit offsets in the original business command', async () => { const wrapper = render(); await flushPromises() wrapper.getComponent({ name: 'FbaAppointmentFields' }).props('model').appointmentRemark = '已核对仓库回复' await flushPromises() await wrapper.findAll('button').find(button => button.text() === '保存预约安排')!.trigger('click'); await flushPromises() expect(updateFbaAppointment).toHaveBeenCalledWith('AP-001', expect.objectContaining({ version: 3, details: expect.objectContaining({ appointmentAt: '2026-10-15T23:30:00', appointmentEndAt: '2026-10-16T01:00:00', startOffset: '-07:00', endOffset: '-07:00' }) })) wrapper.unmount() }) it('does not resubmit unchanged appointment facts, including whitespace-only edits', async () => { const wrapper = render(); await flushPromises() const save = wrapper.findAll('button').find(button => button.text() === '保存预约安排')! expect(save.attributes('disabled')).toBeDefined() wrapper.getComponent({ name: 'FbaAppointmentFields' }).props('model').confirmationNo = ' EXT-001 ' await flushPromises(); await save.trigger('click') expect(updateFbaAppointment).not.toHaveBeenCalled() expect(wrapper.text()).toContain('无需重复保存'); wrapper.unmount() }) it('does not edit or cancel replaced snapshots and offers the stable batch context for replacement history', async () => { vi.mocked(getFbaAppointment).mockResolvedValue({ ...detail, replacementAppointmentNos: ['AP-NEW'], replacementBatchKeys: ['FBA_APPOINTMENT:2'], repartitionReason: '分批送仓' }) const wrapper = render(true); await flushPromises() expect(wrapper.text()).toContain('原预约已被替代'); expect(wrapper.text()).toContain('AP-NEW') await wrapper.findAll('button').find(button => button.text() === '确认取消并继续跟进')!.trigger('click') expect(deleteAppointment).not.toHaveBeenCalled() await wrapper.findAll('button').find(button => button.text() === '查看替代批次')!.trigger('click') expect(wrapper.emitted('batch')).toEqual([['FBA_APPOINTMENT:1']]); wrapper.unmount() }) const cancelled: FbaAppointmentDto = { ...detail, version: 4, appointmentStatus: 'CANCELLED', confirmationNo: null, plannedDate: null, plannedAt: null, plannedEndAt: null, appointmentAt: null, appointmentEndAt: null, appointmentStartUtc: null, appointmentEndUtc: null } it('rebooks the cancelled original batch as undated pending without creating or reallocating cargo', async () => { vi.mocked(getFbaAppointment).mockResolvedValueOnce(cancelled).mockResolvedValue({ ...cancelled, version: 5, appointmentStatus: 'PENDING' }) const wrapper = render(); await flushPromises() expect(wrapper.text()).toContain('货物分配仍保留') const model = wrapper.getComponent({ name: 'FbaAppointmentFields' }).props('model') expect(model.appointmentStatus).toBe('PENDING'); expect(model.confirmationNo).toBe(''); expect(model.appointmentAt).toBe('') await wrapper.findAll('button').find(button => button.text() === '保存本批重新预约')!.trigger('click'); await flushPromises() expect(updateFbaAppointment).toHaveBeenCalledWith('AP-001', expect.objectContaining({ version: 4, idempotencyKey: expect.any(String), details: expect.objectContaining({ appointmentStatus: 'PENDING', plannedDate: null, confirmationNo: null, appointmentAt: null, appointmentEndAt: null }) })) const payload = vi.mocked(updateFbaAppointment).mock.calls[0]![1] expect(payload).not.toHaveProperty('items'); expect(payload).not.toHaveProperty('sources') expect(deleteAppointment).not.toHaveBeenCalled(); expect(wrapper.emitted('saved')).toHaveLength(1) expect(wrapper.findAll('button').find(button => button.text() === '保存预约安排')!.attributes('disabled')).toBeDefined() wrapper.unmount() }) it('retries an uncertain rebooking using the identical command, and preserves input on an explicit conflict', async () => { vi.mocked(getFbaAppointment).mockResolvedValueOnce(cancelled).mockResolvedValue({ ...cancelled, version: 6 }) vi.mocked(updateFbaAppointment).mockRejectedValueOnce(new Error('unknown outcome')).mockRejectedValueOnce(new ApiResponseError('外部约号已占用', 'A0409', 409)) const wrapper = render(); await flushPromises() const fields = wrapper.getComponent({ name: 'FbaAppointmentFields' }) Object.assign(fields.props('model'), { appointmentStatus: 'SUCCESS', confirmationNo: 'NEW-EXT', appointmentAt: '2026-10-20T10:00:00' }) await wrapper.findAll('button').find(button => button.text() === '保存本批重新预约')!.trigger('click'); await flushPromises() const first = vi.mocked(updateFbaAppointment).mock.calls[0]![1] expect(fields.props('disabled')).toBe(true) await wrapper.findAll('button').find(button => button.text() === '重试本次保存')!.trigger('click'); await flushPromises() expect(updateFbaAppointment).toHaveBeenNthCalledWith(2, 'AP-001', first) expect(fields.props('model').confirmationNo).toBe('NEW-EXT'); expect(fields.props('disabled')).toBe(false) expect(wrapper.emitted('saved')).toBeUndefined() await wrapper.findAll('button').find(button => button.text() === '保存本批重新预约')!.trigger('click'); await flushPromises() const next = vi.mocked(updateFbaAppointment).mock.calls[2]![1] expect(next.version).toBe(6); expect(next.idempotencyKey).not.toBe(first.idempotencyKey) wrapper.unmount() }) it('cannot cancel twice or rebook with read-only access', async () => { vi.mocked(getFbaAppointment).mockResolvedValue(cancelled) const wrapper = render(true); await flushPromises() expect(wrapper.findAll('button').find(button => button.text() === '确认取消并继续跟进')!.attributes('disabled')).toBeDefined() await wrapper.setProps({ cancel: false, canWrite: false }); await flushPromises() expect(wrapper.findAll('button').find(button => button.text() === '保存本批重新预约')!.attributes('disabled')).toBeDefined() expect(updateFbaAppointment).not.toHaveBeenCalled(); expect(deleteAppointment).not.toHaveBeenCalled(); wrapper.unmount() })