import { flushPromises, mount } from '@vue/test-utils' import { beforeEach, expect, it, vi } from 'vitest' import AppointmentBatchForm from './AppointmentBatchForm.vue' import { getAppointmentBatch, repartitionAppointments } from './batchApi' import { getWorkItems } from './workApi' import { ApiResponseError } from '@/shared/api/http' import { batchContext } from './batch.test-support' import { workItem } from './work.test-support' import type { LeaveGuard } from './useWorkDraft' vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) })) vi.mock('element-plus', () => ({ ElMessage: { warning: vi.fn(), success: vi.fn() }, ElMessageBox: { confirm: vi.fn().mockResolvedValue(true) } })) vi.mock('./batchApi', async original => ({ ...await original(), getAppointmentBatch: vi.fn(), repartitionAppointments: vi.fn() })) vi.mock('./workApi', async original => ({ ...await original(), getWorkItems: vi.fn() })) let guard: LeaveGuard function render() { return mount(AppointmentBatchForm, { props: { batchKey: 'FBA_APPOINTMENT:1' }, global: { provide: { 'appointment-work-leave-guard': (value: LeaveGuard) => { guard = value; return () => undefined } }, stubs: { ElButton: { props: ['nativeType', 'disabled'], template: '' }, LoadErrorState: { props: ['message'], template: '
{{ message }}
' }, ApiErrorMeta: true, CargoVerificationForm: true, } } }) } beforeEach(() => { vi.clearAllMocks(); vi.mocked(getAppointmentBatch).mockResolvedValue(batchContext()) vi.mocked(repartitionAppointments).mockResolvedValue({ replacedBatchKeys: ['FBA_APPOINTMENT:1'], replacedAppointmentNos: ['AP-001'], appointments: [batchContext('AP-NEW-1', 'FBA_APPOINTMENT:11').appointment, batchContext('AP-NEW-2', 'FBA_APPOINTMENT:12').appointment] }) vi.mocked(getWorkItems).mockResolvedValue({ records: [workItem({ batchKey: 'FBA_APPOINTMENT:2' })], page: 1, size: 8, total: 1, pages: 1 }) }) it('submits two conservative allocations without fake confirmation times and renders replacement business numbers', async () => { const wrapper = render(); await flushPromises() await wrapper.get('[aria-label="来源1分出箱数"]').setValue(20) await wrapper.findAll('textarea')[0]!.setValue('分两次送仓'); await wrapper.get('form').trigger('submit'); await flushPromises() const request = vi.mocked(repartitionAppointments).mock.calls[0]![0] expect(request.originals).toEqual([{ appointmentNo: 'AP-001', version: 3 }]) expect(request.batches.map(row => row.plannedDate)).toEqual([null, null]) expect(request.batches[0]!.allocations.map(row => row.packageCount)).toEqual([50, 30]) expect(request.batches[1]!.allocations.map(row => row.packageCount)).toEqual([20]) expect(request).not.toHaveProperty('appointmentAt') expect(wrapper.text()).toContain('AP-NEW-1'); expect(wrapper.text()).not.toContain('FBA_APPOINTMENT:') expect(wrapper.emitted('updated')).toHaveLength(1); wrapper.unmount() }) it('splits only the entered 60-box later batch into 40/20, never reallocating its original 160-box source', async () => { const context = batchContext('AP-LATER-60', 'FBA_APPOINTMENT:1') context.appointment.items = [{ ...context.appointment.items[0]!, packageCount: 60 }] context.sources = [{ ...context.sources[0]!, packageCount: 60, originalPackages: 160 }] vi.mocked(getAppointmentBatch).mockResolvedValue(context) const wrapper = render(); await flushPromises() await wrapper.get('[aria-label="来源1分出箱数"]').setValue(20) await wrapper.findAll('textarea')[0]!.setValue('本批40先安排,20待排') await wrapper.get('form').trigger('submit'); await flushPromises() const request = vi.mocked(repartitionAppointments).mock.calls[0]![0] expect(request.originals).toEqual([{ appointmentNo: 'AP-LATER-60', version: 3 }]) expect(request.batches.map(batch => batch.allocations.map(row => row.packageCount))).toEqual([[40], [20]]) expect(request.batches.flatMap(batch => batch.allocations).every(row => row.customerId === 'customer-a' && row.sourceKey === 'ORDER:1')).toBe(true) wrapper.unmount() }) it('locks an unknown outcome and retries the same immutable repartition command', async () => { vi.mocked(repartitionAppointments).mockRejectedValueOnce(new Error('unknown outcome')) const wrapper = render(); await flushPromises() await wrapper.get('[aria-label="来源1分出箱数"]').setValue(20); await wrapper.findAll('textarea')[0]!.setValue('分批') await wrapper.get('form').trigger('submit'); await flushPromises() const first = vi.mocked(repartitionAppointments).mock.calls[0]![0] expect(await guard()).toBe(false); expect(wrapper.get('fieldset').attributes('disabled')).toBeDefined() await wrapper.get('form').trigger('submit'); await flushPromises() expect(repartitionAppointments).toHaveBeenNthCalledWith(2, first); wrapper.unmount() }) it('keeps edited allocations after a version conflict and requires a fresh explicit submit', async () => { vi.mocked(repartitionAppointments).mockRejectedValueOnce(new ApiResponseError('版本变化', 'A0409', 409)) const wrapper = render(); await flushPromises() await wrapper.get('[aria-label="来源1分出箱数"]').setValue(20); await wrapper.findAll('textarea')[0]!.setValue('保留分批原因') await wrapper.get('form').trigger('submit'); await flushPromises() expect(getAppointmentBatch).toHaveBeenCalledTimes(2); expect(repartitionAppointments).toHaveBeenCalledTimes(1) expect((wrapper.get('[aria-label="来源1分出箱数"]').element as HTMLInputElement).value).toBe('20') expect((wrapper.findAll('textarea')[0]!.element as HTMLTextAreaElement).value).toBe('保留分批原因'); wrapper.unmount() }) it('shows full appointment cargo when confirmed or replaced batches have no repartition sources', async () => { const context = batchContext(); context.sources = []; context.allowedActions = []; context.blockReasons = ['原批次已有正式确认']; context.appointment.appointmentStatus = 'SUCCESS' vi.mocked(getAppointmentBatch).mockResolvedValue(context) const wrapper = render(); await flushPromises() expect(wrapper.get('[aria-label="原批次货物快照"]').text()).toContain('IN-A') expect(wrapper.text()).toContain('FBA-B'); expect(wrapper.text()).toContain('当前批次无分批资格') await wrapper.get('form').trigger('submit'); expect(repartitionAppointments).not.toHaveBeenCalled(); wrapper.unmount() }) it('loads a candidate context before merging and preserves both original versions', async () => { const other = batchContext('AP-002', 'FBA_APPOINTMENT:2'); other.appointment.version = 6 vi.mocked(getAppointmentBatch).mockImplementation(async key => key === 'FBA_APPOINTMENT:2' ? other : batchContext()) const wrapper = render(); await flushPromises() await wrapper.findAll('button').find(button => button.text() === '同仓合并安排')!.trigger('click'); await flushPromises() await wrapper.findAll('button').find(button => button.text() === '核对并选择')!.trigger('click'); await flushPromises() await wrapper.findAll('textarea')[0]!.setValue('同仓合并'); await wrapper.get('form').trigger('submit'); await flushPromises() expect(repartitionAppointments).toHaveBeenCalledWith(expect.objectContaining({ originals: [{ appointmentNo: 'AP-001', version: 3 }, { appointmentNo: 'AP-002', version: 6 }], batches: [expect.objectContaining({ allocations: [expect.objectContaining({ customerId: 'customer-a', packageCount: 140 }), expect.objectContaining({ customerId: 'customer-b', packageCount: 60 })] })] })) wrapper.unmount() }) it('shows every terminal branch with only the original customer/source/destination quantities and opens the real batch', async () => { const context = batchContext() const source = context.appointment.items[0]! context.appointment.items = [source] context.appointment.replacementBatchKeys = ['FBA_APPOINTMENT:2', 'FBA_APPOINTMENT:3'] context.appointment.replacementAppointmentNos = ['AP-INTERMEDIATE', 'AP-20'] context.sources = []; context.allowedActions = [] const forty = batchContext('AP-40', 'FBA_APPOINTMENT:4').appointment forty.appointmentStatus = 'SUCCESS' forty.items = [ { ...source, packageCount: 40 }, { ...source, id: 'foreign-customer', customerId: 'other-customer', inboundCode: 'FOREIGN-CUSTOMER', packageCount: 900 }, { ...source, id: 'foreign-source', sourceKey: 'ORDER:OTHER', inboundCode: 'FOREIGN-SOURCE', packageCount: 800 }, { ...source, id: 'foreign-destination', destinationKey: 'FBA:OTHER', inboundCode: 'FOREIGN-DESTINATION', packageCount: 700 }, ] const twenty = batchContext('AP-20', 'FBA_APPOINTMENT:3').appointment twenty.appointmentStatus = 'CANCELLED'; twenty.items = [{ ...source, packageCount: 20 }] context.currentBatches = [forty, twenty] vi.mocked(getAppointmentBatch).mockResolvedValue(context) const wrapper = render(); await flushPromises() const destinations = wrapper.get('[aria-label="当前货物去向"]') expect(destinations.findAll('article')).toHaveLength(2) expect(destinations.text()).toContain('40 箱'); expect(destinations.text()).toContain('20 箱') expect(destinations.text()).toContain('已取消,分配保留') expect(destinations.text()).not.toContain('FOREIGN-'); expect(destinations.text()).not.toContain('AP-INTERMEDIATE') expect(destinations.text()).not.toContain('FBA_APPOINTMENT:') await destinations.findAll('button').find(button => button.text() === '查看本批预约')!.trigger('click') expect(wrapper.emitted('appointment')).toEqual([['AP-40']]) await destinations.findAll('button').find(button => button.text() === '查看完整批次')!.trigger('click'); await flushPromises() expect(getAppointmentBatch).toHaveBeenLastCalledWith('FBA_APPOINTMENT:4') expect(repartitionAppointments).not.toHaveBeenCalled(); wrapper.unmount() }) it('does not create zero-quantity destinations or convert unknown quantities to zero', async () => { const context = batchContext() context.appointment.replacementBatchKeys = ['FBA_APPOINTMENT:2'] context.sources = []; context.allowedActions = [] const unknown = batchContext('AP-UNKNOWN', 'FBA_APPOINTMENT:2').appointment unknown.items = [{ ...context.appointment.items[0]!, packageCount: null }] const empty = batchContext('AP-EMPTY', 'FBA_APPOINTMENT:3').appointment empty.items = [{ ...context.appointment.items[0]!, packageCount: 0 }] context.currentBatches = [unknown, empty] vi.mocked(getAppointmentBatch).mockResolvedValue(context) const wrapper = render(); await flushPromises() const destinations = wrapper.get('[aria-label="当前货物去向"]') expect(destinations.findAll('article')).toHaveLength(1) expect(destinations.text()).toContain('箱数待核实'); expect(destinations.text()).not.toContain('0 箱') expect(destinations.text()).not.toContain('AP-EMPTY'); wrapper.unmount() }) it('does not fall back to historical replacement links when current destinations are empty', async () => { const context = batchContext() context.appointment.replacementBatchKeys = ['FBA_APPOINTMENT:2'] context.appointment.replacementAppointmentNos = ['AP-OLD-REPLACEMENT'] context.sources = []; context.allowedActions = []; context.currentBatches = [] vi.mocked(getAppointmentBatch).mockResolvedValue(context) const wrapper = render(); await flushPromises() const destinations = wrapper.get('[aria-label="当前货物去向"]') expect(destinations.findAll('article')).toHaveLength(0) expect(destinations.text()).toContain('原快照不代表当前待办') expect(destinations.text()).not.toContain('AP-OLD-REPLACEMENT'); wrapper.unmount() }) it('keeps every 40/20 destination after the entire customer has left the original current items', async () => { const context = batchContext(), originalItem = context.appointment.items[0]! context.appointment.items = [context.appointment.items[1]!] context.sources = [context.sources[1]!] const forty = batchContext('AP-40', 'FBA_APPOINTMENT:40').appointment forty.items = [{ ...originalItem, packageCount: 40 }, { ...originalItem, id: 'unrelated', sourceKey: 'ORDER:OTHER', inboundCode: 'IN-UNRELATED', packageCount: 999 }] const twenty = batchContext('AP-20', 'FBA_APPOINTMENT:20').appointment twenty.items = [{ ...originalItem, packageCount: 20 }] context.currentBatches = [context.appointment, forty, twenty] vi.mocked(getAppointmentBatch).mockResolvedValue(context) const wrapper = render(); await flushPromises() const destinations = wrapper.get('[aria-label="当前货物去向"]') expect(destinations.text()).toContain('40 箱'); expect(destinations.text()).toContain('20 箱') expect(destinations.text()).not.toContain('IN-UNRELATED') expect(wrapper.findAll('[aria-label="来源1分出箱数"]')).toHaveLength(1) expect((wrapper.get('[aria-label="来源1分出箱数"]').attributes('max'))).toBe('30') wrapper.unmount() }) it('retries a new destination without exposing the prior batch or retaining its allocation inputs', async () => { const original = batchContext() original.appointment.replacementBatchKeys = ['FBA_APPOINTMENT:2']; original.sources = []; original.allowedActions = [] const current = batchContext('AP-CURRENT', 'FBA_APPOINTMENT:2') current.appointment.plannedDate = '2026-10-22' original.currentBatches = [current.appointment] vi.mocked(getAppointmentBatch).mockResolvedValueOnce(original).mockRejectedValueOnce(new Error('新批次暂不可用')).mockResolvedValue(current) const wrapper = render(); await flushPromises() await wrapper.findAll('button').find(button => button.text() === '查看完整批次')!.trigger('click'); await flushPromises() expect(wrapper.text()).not.toContain('AP-001'); expect(wrapper.find('form').exists()).toBe(false) await wrapper.get('[role="alert"] button').trigger('click'); await flushPromises() expect(wrapper.text()).toContain('AP-CURRENT') expect((wrapper.findAll('input[type="date"]')[0]!.element as HTMLInputElement).value).toBe('2026-10-22') expect((wrapper.get('[aria-label="来源1分出箱数"]').element as HTMLInputElement).value).toBe('0') expect(getAppointmentBatch).toHaveBeenLastCalledWith('FBA_APPOINTMENT:2') expect(repartitionAppointments).not.toHaveBeenCalled(); wrapper.unmount() })