import { flushPromises, shallowMount } from '@vue/test-utils' import { defineComponent, h } from 'vue' import { beforeEach, expect, it, vi } from 'vitest' import { ElMessage } from 'element-plus' import SmartDispatchDialog from './SmartDispatchDialog.vue' import { listDispatchCandidates, listDispatchDrafts, preArrangeDispatch, preArrangeDraftDispatch, unlockDispatchDraftPallet, deleteDispatchDraftContainer, type DispatchPalletCandidateDto, type DispatchDraftDto } from '../api/dispatches' vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) })) vi.mock('element-plus', () => ({ ElMessage: { warning: vi.fn(), success: vi.fn(), error: vi.fn() } })) vi.mock('../api/dispatches', () => ({ listDispatchCandidates: vi.fn(), listDispatchDrafts: vi.fn(), preArrangeDispatch: vi.fn(), preArrangeDraftDispatch: vi.fn(), createDispatchDraftContainer: vi.fn(), deleteDispatchDraftContainer: vi.fn(), lockDispatchDraftPallet: vi.fn(), unlockDispatchDraftPallet: vi.fn(), })) const shell = defineComponent({ setup(_, { slots }) { return () => h('section', [slots.default?.(), slots.footer?.()]) } }) const button = defineComponent({ props: { disabled: Boolean }, emits: ['click'], setup(props, { slots, emit }) { return () => h('button', { disabled: props.disabled, onClick: () => emit('click') }, slots.default?.()) } }) const retry = defineComponent({ emits: ['retry'], setup(_, { emit }) { return () => h('button', { 'data-test': 'retry', onClick: () => emit('retry') }, '重试加载') } }) const plan: DispatchPalletCandidateDto = { orderId: '1', orderNo: 'LM-A', planPalletId: '11', sourceType: 'INBOUND_PLAN', warehousePalletId: '101', overseasWarehouseId: '92', customerId: 'A', palletNo: 'PAL-A', packageCount: 2, sourceStage: 'RECEIVED' } const returned: DispatchPalletCandidateDto = { orderId: '2', orderNo: 'LM-B', returnReceiptId: '22', returnReceiptNo: 'RSR-B', sourceType: 'RETURN_RECEIPT', warehousePalletId: '102', overseasWarehouseId: '92', customerId: 'A', palletNo: 'PAL-B', packageCount: 2, sourceStage: 'RECEIVED' } beforeEach(() => vi.resetAllMocks()) function mountDraft() { return shallowMount(SmartDispatchDialog, { props: { modelValue: true, orders: [] }, global: { stubs: { AppDialog: shell, ElButton: button, LoadErrorState: retry, ElDialog: true, ElInput: true, ElOption: true, ElSelect: true, ElTable: true, ElTableColumn: true, ElTag: true, ElDropdown: true, ElDropdownMenu: true, ElDropdownItem: true, ElEmpty: true, ElRadio: true, ElRadioGroup: true }, } }) } const secondPlan = { ...plan, orderId: '3', orderNo: 'LM-C', planPalletId: '13', palletNo: 'PAL-C' } const thirdPlan = { ...plan, orderId: '4', orderNo: 'LM-D', planPalletId: '14', palletNo: 'PAL-D' } const twoGroups: DispatchDraftDto = { draftToken: 'original-draft', containers: [ { id: 'container-1', capacity: 30, palletIds: ['11', '13'] }, { id: 'container-2', capacity: 30, palletIds: ['14', 'RETURN:22'] }, { id: 'empty-container', capacity: 15, palletIds: [] }, ] } const expectedBatch = { draftToken: 'original-draft', groups: [ { draftContainerId: 'container-1', planPalletIds: ['11', '13'], returnReceiptIds: [] }, { draftContainerId: 'container-2', planPalletIds: ['14'], returnReceiptIds: ['22'] }, ] } const goodResult = [{ draftContainerId: 'container-2', dispatch: { id: '102', dispatchNo: 'DS-2' } }, { draftContainerId: 'container-1', dispatch: { id: '101', dispatchNo: 'DS-1' } }] async function submitDraft(wrapper: ReturnType) { await wrapper.findAll('button').find(node => node.text() === '预排车')!.trigger('click') await flushPromises() } it('keeps two explicit groups in one atomic request, omits empty containers and accepts reordered matching results', async () => { vi.mocked(listDispatchDrafts).mockResolvedValue([twoGroups]) vi.mocked(listDispatchCandidates).mockResolvedValue([plan, secondPlan, thirdPlan, returned]) vi.mocked(preArrangeDraftDispatch).mockResolvedValue(goodResult) const wrapper = mountDraft(); await flushPromises(); await submitDraft(wrapper) expect(preArrangeDraftDispatch).toHaveBeenCalledExactlyOnceWith(expectedBatch) expect(preArrangeDispatch).not.toHaveBeenCalled() expect(wrapper.emitted('created')).toHaveLength(1) expect(wrapper.emitted('update:modelValue')).toEqual([[false]]) wrapper.unmount() }) it.each([ ['one collapsed result', goodResult.slice(0, 1)], ['foreign container', [{ ...goodResult[0], draftContainerId: 'foreign' }, goodResult[1]]], ['duplicate container', [goodResult[0], goodResult[0]]], ['duplicate dispatch', [goodResult[0], { ...goodResult[1], dispatch: goodResult[0].dispatch }]], ['missing business number', [goodResult[0], { ...goodResult[1], dispatch: { id: '101', dispatchNo: '' } }]], ])('retains the original groups and token after %s', async (_label, result) => { vi.mocked(listDispatchDrafts).mockResolvedValue([twoGroups]) vi.mocked(listDispatchCandidates).mockResolvedValue([plan, secondPlan, thirdPlan, returned]) vi.mocked(preArrangeDraftDispatch).mockResolvedValue(result) const wrapper = mountDraft(); await flushPromises(); await submitDraft(wrapper) expect(wrapper.emitted('created')).toBeUndefined() expect(wrapper.emitted('update:modelValue')).toBeUndefined() expect(wrapper.text()).toContain('托盘 4 · 箱数 8') await submitDraft(wrapper) expect(preArrangeDraftDispatch).toHaveBeenNthCalledWith(2, expectedBatch) expect(preArrangeDispatch).not.toHaveBeenCalled() wrapper.unmount() }) it('retains all groups on batch rejection without issuing a flat request', async () => { vi.mocked(listDispatchDrafts).mockResolvedValue([twoGroups]) vi.mocked(listDispatchCandidates).mockResolvedValue([plan, secondPlan, thirdPlan, returned]) vi.mocked(preArrangeDraftDispatch).mockRejectedValue(new Error('草稿来源已变化')) const wrapper = mountDraft(); await flushPromises(); await submitDraft(wrapper) expect(preArrangeDraftDispatch).toHaveBeenCalledTimes(1) expect(preArrangeDispatch).not.toHaveBeenCalled() expect(wrapper.emitted('created')).toBeUndefined() expect(wrapper.text()).toContain('托盘 4 · 箱数 8') wrapper.unmount() }) it('does not submit a physical pallet fallback when its actual plan source identity is missing', async () => { vi.mocked(listDispatchDrafts).mockResolvedValue([{ draftToken: 'original-draft', containers: [ { id: 'container', capacity: 30, palletIds: ['101'] }, ] }]) vi.mocked(listDispatchCandidates).mockResolvedValue([{ ...plan, planPalletId: undefined }]) const wrapper = mountDraft(); await flushPromises(); await submitDraft(wrapper) expect(preArrangeDraftDispatch).not.toHaveBeenCalled() expect(preArrangeDispatch).not.toHaveBeenCalled() expect(ElMessage.warning).toHaveBeenCalledWith(expect.stringContaining('来源标识缺失')) expect(wrapper.emitted('update:modelValue')).toBeUndefined() wrapper.unmount() }) it('does not send a second batch while the original command is pending', async () => { vi.mocked(listDispatchDrafts).mockResolvedValue([twoGroups]) vi.mocked(listDispatchCandidates).mockResolvedValue([plan, secondPlan, thirdPlan, returned]) let resolveCommand!: (result: typeof goodResult) => void vi.mocked(preArrangeDraftDispatch).mockReturnValue(new Promise(resolve => { resolveCommand = resolve })) const wrapper = mountDraft(); await flushPromises(); await submitDraft(wrapper); await submitDraft(wrapper) expect(preArrangeDraftDispatch).toHaveBeenCalledExactlyOnceWith(expectedBatch) expect(wrapper.emitted('created')).toBeUndefined() resolveCommand(goodResult); await flushPromises() expect(wrapper.emitted('created')).toHaveLength(1) wrapper.unmount() }) it('keeps group membership frozen while the submitted batch is awaiting its response', async () => { vi.mocked(listDispatchDrafts).mockResolvedValue([twoGroups]) vi.mocked(listDispatchCandidates).mockResolvedValue([plan, secondPlan, thirdPlan, returned]) let resolveCommand!: (result: typeof goodResult) => void vi.mocked(preArrangeDraftDispatch).mockReturnValue(new Promise(resolve => { resolveCommand = resolve })) const wrapper = mountDraft(); await flushPromises(); await submitDraft(wrapper) await wrapper.findAll('button').find(node => node.text() === '移除')!.trigger('click') await wrapper.findAll('button').find(node => node.attributes('aria-label') === '删除装车车型1')!.trigger('click') await flushPromises() expect(unlockDispatchDraftPallet).not.toHaveBeenCalled() expect(deleteDispatchDraftContainer).not.toHaveBeenCalled() expect(wrapper.text()).toContain('托盘 4 · 箱数 8') resolveCommand(goodResult); await flushPromises() expect(wrapper.emitted('created')).toHaveLength(1) wrapper.unmount() }) it('retains unresolved sources when retry hydration still cannot recover the complete original draft', async () => { vi.mocked(listDispatchDrafts).mockResolvedValue([twoGroups]) vi.mocked(listDispatchCandidates).mockResolvedValue([plan, returned]) const wrapper = mountDraft(); await flushPromises(); await submitDraft(wrapper) expect(preArrangeDraftDispatch).not.toHaveBeenCalled() expect(preArrangeDispatch).not.toHaveBeenCalled() expect(ElMessage.warning).toHaveBeenCalledWith(expect.stringContaining('原选择已保留')) expect(wrapper.emitted('update:modelValue')).toBeUndefined() wrapper.unmount() }) it.each([ { ...twoGroups, containers: [{ id: 'c1', capacity: 30, palletIds: ['11'] }, { id: 'c2', capacity: 30, palletIds: ['11'] }] }, { ...twoGroups, containers: [{ id: 'c1', capacity: 30, palletIds: ['11'] }, { id: 'c1', capacity: 30, palletIds: ['13'] }] }, { ...twoGroups, containers: [{ id: '', capacity: 30, palletIds: ['11'] }] }, ])('rejects duplicate sources or invalid container identities before writing', async draft => { vi.mocked(listDispatchDrafts).mockResolvedValue([draft]) vi.mocked(listDispatchCandidates).mockResolvedValue([plan, secondPlan]) const wrapper = mountDraft(); await flushPromises(); await submitDraft(wrapper) expect(preArrangeDraftDispatch).not.toHaveBeenCalled() expect(preArrangeDispatch).not.toHaveBeenCalled() expect(wrapper.emitted('update:modelValue')).toBeUndefined() wrapper.unmount() }) it('rejects a relabel order split across multiple containers before either API command', async () => { const relabel: DispatchPalletCandidateDto = { ...plan, sourceStage: 'RELABEL', orderRefs: 'RL-1' } vi.mocked(listDispatchDrafts).mockResolvedValue([{ draftToken: 'original-draft', containers: [ { id: 'c1', capacity: 30, palletIds: ['11'] }, { id: 'c2', capacity: 30, palletIds: ['13'] }, ] }]) vi.mocked(listDispatchCandidates).mockResolvedValue([relabel, { ...secondPlan, sourceStage: 'RELABEL', orderRefs: 'RL-1' }]) const wrapper = mountDraft(); await flushPromises(); await submitDraft(wrapper) expect(preArrangeDispatch).not.toHaveBeenCalled() expect(preArrangeDraftDispatch).not.toHaveBeenCalled() expect(ElMessage.warning).toHaveBeenCalledWith(expect.stringContaining('一组排车')) expect(wrapper.emitted('update:modelValue')).toBeUndefined() wrapper.unmount() }) it('keeps one complete relabel order on the single-dispatch endpoint', async () => { vi.mocked(listDispatchDrafts).mockResolvedValue([{ draftToken: 'original-draft', containers: [ { id: 'c1', capacity: 30, palletIds: ['11', '13'] }, ] }]) vi.mocked(listDispatchCandidates).mockResolvedValue([plan, secondPlan].map(p => ({ ...p, sourceStage: 'RELABEL', orderRefs: 'RL-1' }))) vi.mocked(preArrangeDispatch).mockResolvedValue({ id: '1', dispatchNo: 'DS-RL' }) const wrapper = mountDraft(); await flushPromises(); await submitDraft(wrapper) expect(preArrangeDispatch).toHaveBeenCalledExactlyOnceWith({ planPalletIds: ['11', '13'], returnReceiptIds: [], relabelNo: 'RL-1' }) expect(preArrangeDraftDispatch).not.toHaveBeenCalled() expect(wrapper.emitted('created')).toHaveLength(1) wrapper.unmount() }) it('retains every restored selection on candidate failure and submits both sources only after retry hydration', async () => { vi.mocked(preArrangeDraftDispatch).mockResolvedValue([{ draftContainerId: 'container', dispatch: { id: '1', dispatchNo: 'DS-1' } }]) vi.mocked(listDispatchDrafts).mockResolvedValue([{ draftToken: 'original-draft', containers: [{ id: 'container', capacity: 30, palletIds: ['11', 'RETURN:22'] }] }]) vi.mocked(listDispatchCandidates) .mockResolvedValueOnce([plan]) .mockRejectedValueOnce(new Error('恢复来源失败')) .mockResolvedValueOnce([plan]) .mockResolvedValueOnce([plan, returned]) const wrapper = shallowMount(SmartDispatchDialog, { props: { modelValue: true, orders: [] }, global: { stubs: { AppDialog: shell, ElButton: button, LoadErrorState: retry, ElDialog: true, ElInput: true, ElOption: true, ElSelect: true, ElTable: true, ElTableColumn: true, ElTag: true, ElDropdown: true, ElDropdownMenu: true, ElDropdownItem: true, ElEmpty: true, ElRadio: true, ElRadioGroup: true }, } }) await flushPromises() const submit = () => wrapper.findAll('button').find(node => node.text() === '预排车')! await submit().trigger('click') await flushPromises() expect(preArrangeDispatch).not.toHaveBeenCalled() expect(preArrangeDraftDispatch).not.toHaveBeenCalled() expect(ElMessage.warning).toHaveBeenCalledWith(expect.stringContaining('原选择已保留')) expect(wrapper.emitted('update:modelValue')).toBeUndefined() await wrapper.get('[data-test="retry"]').trigger('click') await flushPromises() await submit().trigger('click') await flushPromises() expect(preArrangeDraftDispatch).toHaveBeenCalledExactlyOnceWith({ draftToken: 'original-draft', groups: [{ draftContainerId: 'container', planPalletIds: ['11'], returnReceiptIds: ['22'] }] }) expect(preArrangeDispatch).not.toHaveBeenCalled() expect(listDispatchCandidates).toHaveBeenLastCalledWith({ draftToken: 'original-draft' }) expect(wrapper.emitted('created')).toHaveLength(1) wrapper.unmount() })