import { flushPromises, mount } from '@vue/test-utils' import { defineComponent, h } from 'vue' import { ElForm, ElFormItem, ElInput, ElInputNumber } from 'element-plus' import { beforeEach, expect, it, vi } from 'vitest' import { ApiResponseError } from '@/shared/api/http' import ExceptionProcessingPanel from './ExceptionProcessingPanel.vue' import { getExceptionDetail, commandException, uploadExceptionEvidence, type ExceptionDetail, normalizeExceptionDetail } from '../api/exceptionOrders' import { getPallet } from '../api/pallets' import { getOverseasLocation } from '../api/locations' const can = vi.hoisted(() => vi.fn(() => true)) vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) })) vi.mock('../api/exceptionOrders', async original => ({ ...await original(), getExceptionDetail: vi.fn(), commandException: vi.fn(), uploadExceptionEvidence: vi.fn() })) vi.mock('../api/pallets', () => ({ getPallet: vi.fn(), listPallets: vi.fn() })) vi.mock('../api/locations', () => ({ getOverseasLocation: vi.fn() })) const shell = defineComponent({ setup(_, { slots }) { return () => h('div', slots.default?.()) } }) const field = defineComponent({ props: ['modelValue'], emits: ['update:modelValue'], setup(props, { emit }) { return () => h('input', { value: props.modelValue, onInput: (event: Event) => emit('update:modelValue', (event.target as HTMLInputElement).value) }) } }) const quantity = defineComponent({ props: ['modelValue'], emits: ['update:modelValue'], setup(props, { emit }) { return () => h('input', { value: props.modelValue, onInput: (event: Event) => emit('update:modelValue', Number((event.target as HTMLInputElement).value)) }) } }) function snapshot(number = 'EX-1', version = 1, remaining = 4): ExceptionDetail { return normalizeExceptionDetail({ order: { id: '1', exceptionNo: number, customerId: 'C', exceptionType: 'RETURN', returnDirection: 'OVERSEAS_WAREHOUSE', overseasWarehouseId: '92', status: remaining ? 'PROCESSING' : 'COMPLETED', version: String(version), allowedActions: remaining ? ['COMPLETE_EXCEPTION'] : [], reason: '拒收', exceptionAt: '', updatedAt: '', evidenceFileIds: ['91'] }, returnSources: remaining ? [{ outboundPalletItemId: '10', inventoryPositionId: '20', palletNo: 'ORIGINAL', skuCode: 'SKU', maxReturnPackages: String(remaining) }] : [], returnReceipts: [] } as unknown as ExceptionDetail) } function open(realControls = false) { return mount(ExceptionProcessingPanel, { props: { customerId: 'C', exceptionNo: 'EX-1', active: true }, global: { components: { ElForm, ElFormItem, ElInput, ElInputNumber }, directives: { loading: () => {} }, stubs: { ElDescriptions: shell, ElDescriptionsItem: shell, ElForm: realControls ? false : shell, ElFormItem: realControls ? false : shell, ElInput: realControls ? false : field, ElInputNumber: realControls ? false : quantity, ElSelect: field, ElOption: true, RouterLink: { template: '' }, ElButton: { template: '' } }, } }) } async function fill(wrapper: ReturnType, amount = 2) { if (wrapper.find('[data-test="history-0"]').exists()) await wrapper.get('[data-test="history-0"]').setValue(true) await wrapper.get('[data-test="notes"]').setValue('本批实际收到') await wrapper.get('[data-test="quantity-0"]').setValue(amount) await wrapper.get('[data-test="target-0"]').setValue('30'); await flushPromises() } it('completed detail retains multiple receipt batches and redispatches as read-only history without commands', async () => { const completed = snapshot('EX-1', 7, 0) completed.returnReceipts = [ { receiptNo: 'RSR-1', receivedPackages: 2, receivedAt: '2026-10-03T23:24:13', redispatches: [ { dispatchNo: 'DS-1', status: 'DEPARTED', selectedPackages: 1, pickedPackages: 1, deliveries: [{ deliveryNo: 'DL-1', status: 'DELIVERED' }] }, { dispatchNo: 'DS-2', status: 'PENDING_DEPARTURE', selectedPackages: 1, pickedPackages: 0, deliveries: [] }, ] }, { receiptNo: 'RSR-2', receivedPackages: 1, receivedAt: '2026-10-03T23:25:13', redispatches: [] }, ] vi.mocked(getExceptionDetail).mockResolvedValue(completed) const wrapper = open(); await flushPromises() for (const text of ['RSR-1', 'RSR-2', 'DS-1', 'DS-2', 'DL-1', '尚未关联重派排车', '已发车', '待发车']) expect(wrapper.text()).toContain(text) expect(wrapper.findAll('.exception-return-batch')).toHaveLength(2) expect(wrapper.find('[data-test="start"]').exists()).toBe(false) expect(wrapper.find('[data-test="complete"]').exists()).toBe(false) expect(wrapper.find('[data-test="cancel"]').exists()).toBe(false) expect(commandException).not.toHaveBeenCalled(); expect(uploadExceptionEvidence).not.toHaveBeenCalled() wrapper.unmount() }) it('related business numbers remain visible without independent list permissions, but no navigation links are offered', async () => { can.mockReturnValue(false) const completed = snapshot('EX-1', 7, 0) completed.returnReceipts = [{ receiptNo: 'RSR-1', receivedPackages: 2, receivedAt: '', redispatches: [ { dispatchNo: 'DS-1', status: 'DEPARTED', selectedPackages: 2, pickedPackages: 2, deliveries: [{ deliveryNo: 'DL-1', status: 'DELIVERED' }] }, ] }] vi.mocked(getExceptionDetail).mockResolvedValue(completed) const wrapper = open(); await flushPromises() expect(wrapper.text()).toContain('DS-1'); expect(wrapper.text()).toContain('DL-1') expect(wrapper.find('.exception-return-history a').exists()).toBe(false) expect(commandException).not.toHaveBeenCalled() wrapper.unmount() }) beforeEach(() => { vi.resetAllMocks(); can.mockReturnValue(true) vi.mocked(getExceptionDetail).mockResolvedValue(snapshot()) vi.mocked(getPallet).mockResolvedValue({ id: '30', warehouseId: '92', locationId: '40', palletCode: 'TARGET' } as Awaited>) vi.mocked(getOverseasLocation).mockResolvedValue({ id: '40', warehouseId: '92', locationCode: 'LOC' } as Awaited>) }) it('real receipt quantity control becomes accessible after starting processing', async () => { const pending = snapshot('EX-1', 0, 2) pending.order.status = 'PENDING'; pending.order.allowedActions = ['START_PROCESSING']; pending.returnSources = [] vi.mocked(getExceptionDetail).mockResolvedValueOnce(pending).mockResolvedValueOnce(snapshot('EX-1', 1, 2)) vi.mocked(commandException).mockResolvedValue({ exceptionNo: 'EX-1', version: 1, status: 'PROCESSING', restoredPackages: 0, replayed: false }) const wrapper = open(true); try { await flushPromises() await wrapper.get('[data-test="start"]').trigger('click'); await flushPromises() const input = wrapper.getComponent(ElInputNumber).get('input') expect((input.element as HTMLInputElement).disabled).toBe(false) expect(input.attributes('aria-disabled')).toBe('false') } finally { wrapper.unmount() } }) it('real quantity control reflects in-flight lock and unlocks after a partial receipt reload', async () => { vi.mocked(getExceptionDetail).mockResolvedValueOnce(snapshot('EX-1', 1, 2)).mockResolvedValueOnce(snapshot('EX-1', 2, 1)) let finish!: (value: Awaited>) => void vi.mocked(commandException).mockReturnValue(new Promise(resolve => { finish = resolve })) const wrapper = open(true); try { await flushPromises() await wrapper.get('[data-test="history-0"]').setValue(true) wrapper.getComponent(ElInputNumber).vm.$emit('update:modelValue', 1) await wrapper.get('[data-test="target-0"]').setValue('30'); await flushPromises() await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises() expect((wrapper.getComponent(ElInputNumber).get('input').element as HTMLInputElement).disabled).toBe(true) expect(wrapper.getComponent(ElInputNumber).get('input').attributes('aria-disabled')).toBe('true') finish({ exceptionNo: 'EX-1', version: 2, status: 'PROCESSING', restoredPackages: 1, replayed: false }); await flushPromises() const input = wrapper.getComponent(ElInputNumber).get('input') expect((input.element as HTMLInputElement).disabled).toBe(false) expect(input.attributes('aria-disabled')).toBe('false') expect(wrapper.text()).toContain('剩余可收 1 箱') expect(commandException).toHaveBeenCalledTimes(1) } finally { wrapper.unmount() } }) it('submits two actual batches using refreshed remaining sources and independent command keys', async () => { vi.mocked(getExceptionDetail).mockResolvedValueOnce(snapshot()).mockResolvedValueOnce(snapshot('EX-1', 2, 2)).mockResolvedValueOnce(snapshot('EX-1', 3, 0)) vi.mocked(commandException).mockResolvedValueOnce({ exceptionNo: 'EX-1', version: 2, status: 'PROCESSING', restoredPackages: 2, replayed: false }) .mockResolvedValueOnce({ exceptionNo: 'EX-1', version: 3, status: 'COMPLETED', restoredPackages: 2, replayed: false }) const wrapper = open(); await flushPromises(); await fill(wrapper) await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises() expect(wrapper.text()).toContain('仍有待收货物'); expect(wrapper.text()).toContain('剩余可收 2 箱') await fill(wrapper); await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises() const first = vi.mocked(commandException).mock.calls[0]!, second = vi.mocked(commandException).mock.calls[1]! expect(first.slice(0, 3)).toEqual(['C', 'EX-1', 'complete']) expect(first[3].receipts?.[0]).toEqual({ outboundPalletItemId: '10', inventoryPositionId: '20', receivedPackages: 2, targetPalletId: '30', targetPalletNo: 'TARGET', targetLocationId: '40', targetLocationCode: 'LOC' }) expect(first[3].expectedVersion).toBe(1); expect(second[3].expectedVersion).toBe(2) expect(getPallet).toHaveBeenCalledTimes(2); expect(getOverseasLocation).toHaveBeenCalledTimes(2) expect(getOverseasLocation).toHaveBeenNthCalledWith(1, '92', '40') expect(second[3].idempotencyKey).not.toBe(first[3].idempotencyKey); expect(wrapper.find('[data-test="complete"]').exists()).toBe(false) wrapper.unmount() }) it('receives the native original carton whole into a verified target without inventing inventory', async () => { const carton = snapshot('EX-1', 1, 1) carton.returnSources = [{ outboundPalletItemId: '10', inventoryPositionId: null, returnCartonId: '9007199254740997', cartonMark: 'RETURN-CARTON-1', maxReturnPackages: 1 }] vi.mocked(getExceptionDetail).mockResolvedValueOnce(carton).mockResolvedValue(snapshot('EX-1', 2, 0)) vi.mocked(commandException).mockResolvedValue({ exceptionNo: 'EX-1', version: 2, status: 'COMPLETED', restoredPackages: 1, replayed: false }) const wrapper = open(); await flushPromises() expect(wrapper.text()).toContain('原退货箱 RETURN-CARTON-1(须整箱实收)') expect(wrapper.text()).not.toContain('9007199254740997') await fill(wrapper, 0.5); await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises() expect(commandException).not.toHaveBeenCalled() await fill(wrapper, 1); await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises() expect(commandException).toHaveBeenCalledExactlyOnceWith('C', 'EX-1', 'complete', expect.objectContaining({ receipts: [{ outboundPalletItemId: '10', returnCartonId: '9007199254740997', receivedPackages: 1, targetPalletId: '30', targetPalletNo: 'TARGET', targetLocationId: '40', targetLocationCode: 'LOC' }] })) expect(getOverseasLocation).toHaveBeenCalledWith('92', '40') wrapper.unmount() }) it('retains the exact command after unknown failure even if the visible form is changed', async () => { vi.mocked(commandException).mockRejectedValueOnce(new Error('network lost')).mockResolvedValueOnce({ exceptionNo: 'EX-1', version: 2, status: 'PROCESSING', restoredPackages: 2, replayed: true }) const wrapper = open(); await flushPromises(); await fill(wrapper) await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises() const payload = JSON.parse(JSON.stringify(vi.mocked(commandException).mock.calls[0]![3])) expect(wrapper.text()).toContain('已保留原请求') await wrapper.get('[data-test="notes"]').setValue('changed') await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises() expect(vi.mocked(commandException).mock.calls[1]![3]).toEqual(payload) wrapper.unmount() }) it('refreshes a definite stale version and requires a reviewed new submission', async () => { vi.mocked(getExceptionDetail).mockResolvedValueOnce(snapshot()).mockResolvedValueOnce(snapshot('EX-1', 7, 2)) vi.mocked(commandException).mockRejectedValueOnce(new ApiResponseError('版本变化', 'A0409', 409)) const wrapper = open(); await flushPromises(); await fill(wrapper) await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises() expect(wrapper.text()).toContain('已重新读取当前版本'); expect(commandException).toHaveBeenCalledTimes(1) await fill(wrapper); await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises() expect(vi.mocked(commandException).mock.calls[1]![3].expectedVersion).toBe(7) expect(vi.mocked(commandException).mock.calls[1]![3].idempotencyKey).not.toBe(vi.mocked(commandException).mock.calls[0]![3].idempotencyKey) wrapper.unmount() }) it('has no processing or upload controls without independent permission', async () => { can.mockReturnValue(false); const wrapper = open(); await flushPromises() expect(wrapper.find('[data-test="complete"]').exists()).toBe(false); expect(wrapper.find('[data-test="evidence"]').exists()).toBe(false) expect(commandException).not.toHaveBeenCalled(); expect(uploadExceptionEvidence).not.toHaveBeenCalled(); wrapper.unmount() }) it('ignores an old detail response after switching exceptions', async () => { let resolve!: (value: ExceptionDetail) => void vi.mocked(getExceptionDetail).mockImplementationOnce(() => new Promise(done => { resolve = done })).mockResolvedValueOnce(snapshot('EX-2', 2, 1)) const wrapper = open(); await wrapper.setProps({ exceptionNo: 'EX-2' }); await flushPromises() resolve(snapshot('EX-1')); await flushPromises() expect(wrapper.text()).toContain('EX-2'); expect(wrapper.text()).not.toContain('EX-1'); wrapper.unmount() }) it('a late successful command never replaces the new exception detail', async () => { let resolve!: (value: Awaited>) => void vi.mocked(commandException).mockImplementationOnce(() => new Promise(done => { resolve = done })) const wrapper = open(); await flushPromises(); await fill(wrapper); await wrapper.get('[data-test="complete"]').trigger('click') vi.mocked(getExceptionDetail).mockResolvedValue(snapshot('EX-2', 2, 1)) await wrapper.setProps({ exceptionNo: 'EX-2' }); await flushPromises() resolve({ exceptionNo: 'EX-1', version: 2, status: 'COMPLETED', restoredPackages: 2, replayed: false }); await flushPromises() expect(wrapper.text()).toContain('EX-2'); expect(wrapper.emitted('changed')).toBeUndefined(); wrapper.unmount() }) it('starts or cancels only when returned actions permit it, with the authoritative version', async () => { const pending = snapshot(); pending.order.status = 'PENDING'; pending.order.allowedActions = ['START_PROCESSING', 'CANCEL_EXCEPTION']; pending.order.version = 4 vi.mocked(getExceptionDetail).mockResolvedValue(pending) vi.mocked(commandException).mockResolvedValue({ exceptionNo: 'EX-1', version: 5, status: 'PROCESSING', restoredPackages: 0, replayed: false }) const wrapper = open(); await flushPromises() await wrapper.get('[data-test="start"]').trigger('click'); await flushPromises() expect(vi.mocked(commandException).mock.calls[0]![2]).toBe('start-processing') expect(vi.mocked(commandException).mock.calls[0]![3].expectedVersion).toBe(4) await wrapper.get('[data-test="notes"]').setValue('退回申请有误') vi.mocked(commandException).mockResolvedValue({ exceptionNo: 'EX-1', version: 5, status: 'CANCELLED', restoredPackages: 0, replayed: false }) await wrapper.get('[data-test="cancel"]').trigger('click'); await flushPromises() expect(vi.mocked(commandException).mock.calls[1]![3].reason).toBe('退回申请有误') wrapper.unmount() }) it('uploads owned evidence and includes the resulting file in the completion request', async () => { vi.mocked(uploadExceptionEvidence).mockResolvedValue({ fileId: '991', fileName: 'receipt.txt' }) const wrapper = open(); await flushPromises() const input = wrapper.get('[data-test="evidence"]'), file = new File(['proof'], 'receipt.txt') Object.defineProperty(input.element, 'files', { value: [file], configurable: true }) await input.trigger('change'); await flushPromises() expect(uploadExceptionEvidence).toHaveBeenCalledWith('C', 'EX-1', file) await fill(wrapper); await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises() expect(vi.mocked(commandException).mock.calls[0]![3].evidenceFileIds).toContain('991') wrapper.unmount() }) it('rejects a target pallet whose real warehouse differs from the exception warehouse', async () => { vi.mocked(getPallet).mockResolvedValue({ id: '30', warehouseId: 'OTHER', locationId: '40', palletCode: 'WRONG' } as Awaited>) const wrapper = open(); await flushPromises(); await fill(wrapper) await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises() expect(commandException).not.toHaveBeenCalled(); expect(getOverseasLocation).not.toHaveBeenCalled() wrapper.unmount() }) it('uses standard audit text for actual warehouse receipt when the optional note is empty', async () => { const wrapper = open(); await flushPromises(); await fill(wrapper) await wrapper.get('[data-test="notes"]').setValue('') await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises() expect(vi.mocked(commandException).mock.calls[0]![3].handlingResult).toBe('确认本次退回入库') wrapper.unmount() }) it('allows cancellation and ordinary completion with standard audit text and still requires evidence', async () => { const state = snapshot(); state.order.status = 'PENDING'; state.order.allowedActions = ['CANCEL_EXCEPTION']; state.order.exceptionType = 'DAMAGED' vi.mocked(getExceptionDetail).mockResolvedValueOnce(state) const wrapper = open(); await flushPromises(); await wrapper.get('[data-test="cancel"]').trigger('click'); await flushPromises() expect(vi.mocked(commandException).mock.calls[0]![3].reason).toBe('取消异常处理') const ordinary = snapshot('EX-2'); ordinary.order.exceptionType = 'DAMAGED'; ordinary.returnSources = []; ordinary.order.evidenceFileIds = [] vi.mocked(getExceptionDetail).mockResolvedValue(ordinary) await wrapper.setProps({ exceptionNo: 'EX-2' }); await flushPromises(); await wrapper.get('[data-test="complete"]').trigger('click') expect(commandException).toHaveBeenCalledTimes(1) ordinary.order.evidenceFileIds = ['91'] await wrapper.setProps({ active: false }); await wrapper.setProps({ active: true }); await flushPromises() await wrapper.get('[data-test="history-0"]').setValue(true) await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises() expect(vi.mocked(commandException).mock.calls[1]![3].handlingResult).toBe('确认异常处理完成') expect(vi.mocked(commandException).mock.calls[1]![3].receipts).toEqual([]) wrapper.unmount() }) it('closing and reopening cannot replay an in-flight command or lose a later unknown batch', async () => { let rejectFirst!: (reason: Error) => void vi.mocked(commandException).mockImplementationOnce(() => new Promise((_resolve, reject) => { rejectFirst = reject })) const wrapper = open(); await flushPromises(); await fill(wrapper) await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises() const originalKey = vi.mocked(commandException).mock.calls[0]![3].idempotencyKey await wrapper.setProps({ active: false }); await wrapper.setProps({ active: true }); await flushPromises() await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises() expect(commandException).toHaveBeenCalledTimes(1) rejectFirst(new Error('R1 outcome unknown')); await flushPromises() vi.mocked(getExceptionDetail).mockResolvedValue(snapshot('EX-1', 2, 2)) vi.mocked(commandException).mockResolvedValueOnce({ exceptionNo: 'EX-1', version: 2, status: 'PROCESSING', restoredPackages: 2, replayed: true }) await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises() expect(vi.mocked(commandException).mock.calls[1]![3].idempotencyKey).toBe(originalKey) await fill(wrapper) vi.mocked(commandException).mockRejectedValueOnce(new Error('next batch unknown')).mockRejectedValueOnce(new Error('still unknown')) await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises() const newKey = vi.mocked(commandException).mock.calls[2]![3].idempotencyKey expect(newKey).not.toBe(originalKey) await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises() expect(vi.mocked(commandException).mock.calls[3]![3].idempotencyKey).toBe(newKey) wrapper.unmount() }) it('twenty-one historical files do not get resubmitted automatically with the new batch evidence', async () => { const history = snapshot(); history.order.exceptionType = 'DAMAGED'; history.returnSources = [] history.order.evidenceFileIds = Array.from({ length: 21 }, (_, index) => String(100 + index)) vi.mocked(getExceptionDetail).mockResolvedValue(history) vi.mocked(uploadExceptionEvidence).mockResolvedValue({ fileId: '991', fileName: 'new-evidence.txt' }) const wrapper = open(); await flushPromises() expect(wrapper.text()).toContain('21 份历史证据') const input = wrapper.get('[data-test="evidence"]') Object.defineProperty(input.element, 'files', { value: [new File(['proof'], 'new-evidence.txt')] }) await input.trigger('change'); await flushPromises(); await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises() expect(vi.mocked(commandException).mock.calls[0]![3].evidenceFileIds).toEqual(['991']) wrapper.unmount() })