import ElementPlus, { ElInputNumber } from 'element-plus' import { flushPromises, mount } from '@vue/test-utils' import { beforeEach, expect, it, vi } from 'vitest' import AppDialog from '@/shared/components/AppDialog.vue' import DispatchAppointmentAllocationsDialog from './DispatchAppointmentAllocationsDialog.vue' import * as api from '../api/dispatches' import type { AppointmentAllocations } from '../dispatchContracts' import { command } from '../dispatchTestFixtures' const access = vi.hoisted(() => ({ can: true })) vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => access.can }) })) vi.mock('../api/dispatches', () => ({ getDispatchAppointmentAllocations: vi.fn(), saveDispatchAppointmentAllocations: vi.fn() })) function facts(): AppointmentAllocations { return { dispatchNo: 'PC/1', recordVersion: '0', edit: { allowed: true, blockCode: null, blockReason: null }, items: ['11', '12'].map((id, index) => ({ dispatchItemId: id, planPalletId: `${index + 21}`, customerId: '101', customerName: '客户甲', palletNo: `PALLET-${index + 1}`, allocations: [], sources: [{ sourceKey: 'ORDER:1', orderNo: 'WC-1', inboundCode: 'IN-1', destinationKey: 'FBA:ACY2', packageCount: 10, blockReasons: [], options: [{ batchKey: 'FBA_APPOINTMENT:9', batchNo: 'YY-9', recordVersion: '0', availablePackages: 20, confirmationNo: 'CONF-9', appointmentAt: '2026-10-26T10:00:00', appointmentEndAt: null, timezone: 'America/New_York' }] }], })) } } async function open() { const wrapper = mount(DispatchAppointmentAllocationsDialog, { props: { modelValue: true, dispatchNo: 'PC/1' }, global: { plugins: [ElementPlus] } }) await flushPromises(); return wrapper } beforeEach(() => { vi.clearAllMocks(); access.can = true vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }) vi.mocked(api.getDispatchAppointmentAllocations).mockResolvedValue(facts()) vi.mocked(api.saveDispatchAppointmentAllocations).mockResolvedValue(command('EDIT')) }) it('links both pallets to the confirmed appointment on the original dispatch with explicit version zero', async () => { const wrapper = await open() expect(wrapper.text()).toContain('WC-1'); expect(wrapper.text()).toContain('IN-1'); expect(wrapper.text()).toContain('America/New_York') expect(wrapper.text()).not.toContain('ORDER:1') const dialog = wrapper.findComponent(AppDialog) expect(dialog.props('confirmDisabled')).toBe(false) dialog.vm.$emit('confirm'); dialog.vm.$emit('confirm'); await flushPromises() expect(api.saveDispatchAppointmentAllocations).toHaveBeenCalledTimes(1) expect(api.saveDispatchAppointmentAllocations).toHaveBeenCalledWith('PC/1', expect.objectContaining({ recordVersion: '0', dispatchItemIds: ['11', '12'], batchAllocations: [expect.objectContaining({ planPalletId: '21', packageCount: 10, recordVersion: '0' }), expect.objectContaining({ planPalletId: '22', packageCount: 10, recordVersion: '0' })] })) expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount() }) it('counts a shared source quota once across pallets and rejects incomplete allocation', async () => { const value = facts(); value.items.forEach(item => { item.sources[0]!.options[0]!.availablePackages = 15 }) vi.mocked(api.getDispatchAppointmentAllocations).mockResolvedValue(value) const wrapper = await open(); const dialog = wrapper.findComponent(AppDialog) expect(dialog.props('confirmDisabled')).toBe(true); expect(wrapper.text()).toContain('本票剩余可排量不足') wrapper.findAllComponents(ElInputNumber)[0]!.vm.$emit('update:modelValue', 5); await flushPromises() expect(dialog.props('confirmDisabled')).toBe(true); expect(wrapper.text()).toContain('须完整分配 10 箱') dialog.vm.$emit('confirm'); await flushPromises(); expect(api.saveDispatchAppointmentAllocations).not.toHaveBeenCalled(); wrapper.unmount() }) it('preserves edited quantities after rejection and retries with current versions and a new command key', async () => { const value = facts(); value.items = value.items.slice(0, 1) value.items[0]!.sources[0]!.options.push({ ...value.items[0]!.sources[0]!.options[0]!, batchKey: 'FBA_APPOINTMENT:10', batchNo: 'YY-10' }) vi.mocked(api.getDispatchAppointmentAllocations).mockResolvedValue(value) const wrapper = await open(); const inputs = wrapper.findAllComponents(ElInputNumber) inputs[0]!.vm.$emit('update:modelValue', 6); inputs[1]!.vm.$emit('update:modelValue', 4); await flushPromises() const latest = structuredClone(value); latest.recordVersion = '2'; latest.items[0]!.sources[0]!.options.forEach(option => { option.recordVersion = '3' }) vi.mocked(api.saveDispatchAppointmentAllocations).mockRejectedValueOnce(new Error('版本已变化')) vi.mocked(api.getDispatchAppointmentAllocations).mockResolvedValue(latest) wrapper.findComponent(AppDialog).vm.$emit('confirm'); await flushPromises() expect(wrapper.findAllComponents(ElInputNumber).map(input => input.props('modelValue'))).toEqual([6, 4]) expect(wrapper.emitted('saved')).toBeUndefined() wrapper.findComponent(AppDialog).vm.$emit('confirm'); await flushPromises() const calls = vi.mocked(api.saveDispatchAppointmentAllocations).mock.calls expect(calls).toHaveLength(2); expect(calls[1]![1].idempotencyKey).not.toBe(calls[0]![1].idempotencyKey) expect(calls[1]![1]).toMatchObject({ recordVersion: '2', batchAllocations: [{ recordVersion: '3', packageCount: 6 }, { recordVersion: '3', packageCount: 4 }] }) wrapper.unmount() }) it('retains an unavailable former choice after a failed save until the user clears it', async () => { const wrapper = await open() vi.mocked(api.saveDispatchAppointmentAllocations).mockRejectedValueOnce(new Error('预约已变化')) const latest = facts(); latest.items[0]!.sources[0]!.options = [] vi.mocked(api.getDispatchAppointmentAllocations).mockResolvedValue(latest) wrapper.findComponent(AppDialog).vm.$emit('confirm'); await flushPromises() expect(wrapper.text()).toContain('已失效'); expect(wrapper.findAllComponents(ElInputNumber)[0]!.props('modelValue')).toBe(10) expect(wrapper.findComponent(AppDialog).props('confirmDisabled')).toBe(true); wrapper.unmount() }) it.each(['permission', 'gate', 'unconfirmed'] as const)('does not save when blocked by %s', async mode => { const value = facts() if (mode === 'permission') access.can = false if (mode === 'gate') value.edit = { allowed: false, blockCode: 'NOT_ELIGIBLE', blockReason: '本车已有真实扫码' } if (mode === 'unconfirmed') value.items[0]!.sources[0] = { ...value.items[0]!.sources[0]!, options: [], blockReasons: ['请先确认送仓时间'] } vi.mocked(api.getDispatchAppointmentAllocations).mockResolvedValue(value) const wrapper = await open(); expect(wrapper.findComponent(AppDialog).props('confirmDisabled')).toBe(true) wrapper.findComponent(AppDialog).vm.$emit('confirm'); await flushPromises(); expect(api.saveDispatchAppointmentAllocations).not.toHaveBeenCalled(); wrapper.unmount() })