Files
admin-web/src/modules/appointment-calendar/WorkAppointmentForm.test.ts
T

94 lines
9.1 KiB
TypeScript

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<typeof import('@/modules/overseas-warehouse')>(), getFbaAppointment: vi.fn(), updateFbaAppointment: vi.fn() }))
vi.mock('./api', async original => ({ ...await original<typeof import('./api')>(), 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: '<button :disabled="disabled"><slot /></button>' }, FbaAppointmentFields: { name: 'FbaAppointmentFields', mounted() { this.$emit('confirmationReady', true) }, props: ['model', 'disabled'], template: '<div />' }, LoadErrorState: { props: ['message'], template: '<section role="alert">{{ message }}<button @click="$emit(\'retry\')">重试读取</button></section>' }, 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 original cargo using a new confirmed appointment without reallocating cargo', async () => {
vi.mocked(getFbaAppointment).mockResolvedValueOnce(cancelled).mockResolvedValue({ ...detail, version: 5 })
const wrapper = render(); await flushPromises()
expect(wrapper.text()).toContain('货物分配仍保留')
const model = wrapper.getComponent({ name: 'FbaAppointmentFields' }).props('model')
expect(model.appointmentStatus).toBe('SUCCESS'); expect(model.confirmationNo).toBe(''); expect(model.appointmentAt).toBe('')
Object.assign(model, { confirmationNo: 'NEW-EXT', appointmentAt: '2026-10-20T10:00:00' })
wrapper.getComponent({ name: 'FbaAppointmentFields' }).vm.$emit('confirmationReady', true)
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: 'SUCCESS', plannedDate: null, confirmationNo: 'NEW-EXT', appointmentAt: '2026-10-20T10:00:00', 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()
})