import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils' import { createPinia, setActivePinia } from 'pinia' import ElementPlus, { ElInputNumber } from 'element-plus' import AppDialog from '@/shared/components/AppDialog.vue' import { useAuthStore } from '@/core/auth/auth' import { useAccessPreviewStore } from '@/core/access/accessPreview' import ProcurementWorkflowPanel from './ProcurementWorkflowPanel.vue' import type { PlatformProcurement } from '../procurementTypes' import type { ProcurementWorkflow } from '../api/procurementWorkflow' import { submitProcurementWorkflow } from '../api/procurementWorkflow' import { uploadProcurementFile } from '../api/procurementCommands' vi.mock('../api/procurementWorkflow', () => ({ submitProcurementWorkflow: vi.fn() })) vi.mock('../api/procurementCommands', () => ({ uploadProcurementFile: vi.fn() })) vi.mock('@/core/unsaved-work/useUnsavedWorkRegistration', () => ({ useUnsavedWorkRegistration: () => ({ captureBaseline: vi.fn() }) })) enableAutoUnmount(afterEach) const workflow = (status = 'RECEIVING'): ProcurementWorkflow => ({ orderNo: 'OP-1', version: 4, status, receivedQuantity: 5, stockQuantity: 5, reservedQuantity: 0, availableQuantity: 5, rejectionReason: null, photosNeedUpdate: false, receipts: [], outbounds: [] }) const detail = { customerView: { order: { purchaseQuantity: 20 } }, files: [] } as unknown as PlatformProcurement const beforeOutbound = vi.fn<() => Promise>() function render(status = 'RECEIVING', permissions = ['receive-overseas', 'ship-customer']) { const pinia = createPinia(); setActivePinia(pinia) const auth = useAuthStore(); auth.token = 'test'; auth.permissions = permissions.map(value => `platform:overseas-business:purchase:${value}`); useAccessPreviewStore().initializeAfterLogin() return mount(ProcurementWorkflowPanel, { props: { detail, workflow: workflow(status), customerId: '1001', beforeOutbound }, global: { plugins: [pinia, ElementPlus] } }) } beforeEach(() => { vi.clearAllMocks(); beforeOutbound.mockResolvedValue(null); vi.mocked(submitProcurementWorkflow).mockResolvedValue(workflow()) }) describe('simplified procurement operations', () => { it('allows a rejected purchase to be resubmitted without uploading photos and hides the old reason after success', async () => { const wrapper = render() const rejected = { ...workflow(), rejectionReason: '请核对', photosNeedUpdate: true, receipts: [ { id: 'r-1', quantity: 2, fileIds: ['old-1'], receivedAt: '' }, { id: 'r-2', quantity: 3, fileIds: ['old-2'], receivedAt: '' }, ] } await wrapper.setProps({ workflow: rejected }); await flushPromises() const button = wrapper.findAll('button').find(button => button.text() === '采购完成')! expect(button.attributes('disabled')).toBeUndefined() expect(wrapper.text()).toContain('请核对') await button.trigger('click'); await flushPromises() wrapper.findComponent(AppDialog).vm.$emit('confirm'); await flushPromises() expect(uploadProcurementFile).not.toHaveBeenCalled() expect(submitProcurementWorkflow).toHaveBeenCalledWith('OP-1', '1001', 'request-confirmation', { version: 4, idempotencyKey: expect.any(String) }) expect(wrapper.emitted('changed')).toHaveLength(1) await wrapper.setProps({ workflow: { ...rejected, status: 'WAIT_CUSTOMER_CONFIRM', version: 5 } }) expect(wrapper.text()).not.toContain('客户驳回原因') expect(wrapper.text()).not.toContain('请核对') await wrapper.setProps({ workflow: { ...rejected, status: 'CONFIRMED', version: 6 } }) expect(wrapper.text()).not.toContain('客户驳回原因') expect(wrapper.text()).not.toContain('请核对') }) it('hides the previous rejection after receipt photos are updated and shows a new rejection again', async () => { const wrapper = render() const rejected = { ...workflow(), rejectionReason: '照片不清晰', photosNeedUpdate: true, receipts: [ { id: 'r-1', quantity: 2, fileIds: ['old-1'], receivedAt: '' }, { id: 'r-2', quantity: 3, fileIds: ['old-2'], receivedAt: '' }, ] } await wrapper.setProps({ workflow: rejected }) expect(wrapper.text()).toContain('照片不清晰') await wrapper.setProps({ workflow: { ...rejected, version: 5, rejectionReason: null, photosNeedUpdate: true } }) expect(wrapper.text()).not.toContain('客户驳回原因') expect(wrapper.text()).not.toContain('照片不清晰') expect(wrapper.findAll('button').some(button => button.text() === '重新上传照片')).toBe(true) await wrapper.setProps({ workflow: { ...rejected, version: 6, rejectionReason: null, photosNeedUpdate: false } }) expect(wrapper.findAll('button').some(button => button.text() === '重新上传照片')).toBe(false) await wrapper.setProps({ workflow: { ...rejected, version: 7, rejectionReason: '请补拍完整商品' } }) expect(wrapper.text()).toContain('请补拍完整商品') }) it('accepts the first and subsequent receipt batches using the remaining purchase quantity', async () => { const wrapper = render() const receive = () => wrapper.findAll('button').find(button => button.text() === '登记收货')! await wrapper.setProps({ workflow: { ...workflow(), receivedQuantity: 0, stockQuantity: 0, availableQuantity: 0 } }) await receive().trigger('click'); await flushPromises() const quantity = wrapper.findComponent(ElInputNumber) const increment = quantity.find('.el-input-number__increase') expect(increment.attributes('aria-disabled')).not.toBe('true') await increment.trigger('mousedown', { button: 0 }) document.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })) expect(quantity.props('modelValue')).toBe(2) await quantity.find('input').setValue('10') await quantity.find('input').trigger('blur') expect(quantity.props('modelValue')).toBe(10) const form = wrapper.findComponent({ name: 'ElForm' }).props('model') form.fileIds = ['photo-1'] const dialog = wrapper.findComponent(AppDialog) dialog.vm.$emit('confirm'); await flushPromises() expect(submitProcurementWorkflow).toHaveBeenLastCalledWith('OP-1', '1001', 'receipts', expect.objectContaining({ version: 4, quantity: 10, fileIds: ['photo-1'] })) expect(wrapper.emitted('changed')).toHaveLength(1) await wrapper.setProps({ workflow: { ...workflow(), version: 5, receivedQuantity: 10, stockQuantity: 10, availableQuantity: 10 } }) await receive().trigger('click'); await flushPromises() await wrapper.findComponent(ElInputNumber).find('input').setValue('10') await wrapper.findComponent(ElInputNumber).find('input').trigger('blur') form.fileIds = ['photo-2'] dialog.vm.$emit('confirm'); await flushPromises() expect(submitProcurementWorkflow).toHaveBeenLastCalledWith('OP-1', '1001', 'receipts', expect.objectContaining({ version: 5, quantity: 10, fileIds: ['photo-2'] })) expect(wrapper.emitted('changed')).toHaveLength(2) const calls = vi.mocked(submitProcurementWorkflow).mock.calls expect(calls[0]![3].idempotencyKey).not.toBe(calls[1]![3].idempotencyKey) }) it.each([ { received: 1, input: '10', expected: 10 }, { received: 18, input: '3', expected: 2 }, ])('limits receipts by remaining purchase quantity after $received units were received', async ({ received, input, expected }) => { const wrapper = render() await wrapper.setProps({ workflow: { ...workflow(), receivedQuantity: received, stockQuantity: received, availableQuantity: received } }) await wrapper.findAll('button').find(button => button.text() === '登记收货')!.trigger('click'); await flushPromises() const quantity = wrapper.findComponent(ElInputNumber) await quantity.find('input').setValue(input) await quantity.find('input').trigger('blur') expect(quantity.props('modelValue')).toBe(expected) }) it('allows outbound when the fee check reports missing or unreviewed details', async () => { const wrapper = render('CONFIRMED') const button = wrapper.findAll('button').find(button => button.text() === '创建出库单')! beforeOutbound.mockResolvedValueOnce(true) await button.trigger('click'); await flushPromises() expect(wrapper.findComponent(AppDialog).props('modelValue')).toBe(true) await button.trigger('click'); await flushPromises() expect(wrapper.findComponent(AppDialog).props('modelValue')).toBe(true) expect(beforeOutbound).toHaveBeenCalledTimes(2) }) it('gates receipt and outbound actions with the existing operation permissions', () => { const receipt = render('RECEIVING', []) expect(receipt.findAll('button').find(button => button.text() === '登记收货')!.attributes('disabled')).toBeDefined() expect(receipt.text()).toContain('当前账号未开通采购收货权限') const outbound = render('CONFIRMED', []) expect(outbound.findAll('button').find(button => button.text() === '创建出库单')!.attributes('disabled')).toBeDefined() expect(outbound.text()).toContain('当前账号未开通采购出库及签收权限') expect(render('CONFIRMED').text()).toContain('创建出库单') }) it('requires receipt photos and uploads them before submitting the current batch quantity', async () => { const wrapper = render(); await wrapper.findAll('button').find(button => button.text() === '登记收货')!.trigger('click') const dialog = wrapper.findComponent(AppDialog) dialog.vm.$emit('confirm'); await flushPromises(); expect(submitProcurementWorkflow).not.toHaveBeenCalled() vi.mocked(uploadProcurementFile).mockResolvedValue({ id: '1', fileId: '9007199254740993', category: 'RECEIPT', fileName: 'receipt.png', contentType: 'image/png', sizeBytes: 10, createdAt: '' }) const input = document.querySelector('input[type="file"]') || wrapper.find('input[type="file"]').element Object.defineProperty(input, 'files', { configurable: true, value: [new File(['photo'], 'receipt.png', { type: 'image/png' })] }) input.dispatchEvent(new Event('change', { bubbles: true })); await flushPromises() dialog.vm.$emit('confirm'); await flushPromises() expect(submitProcurementWorkflow).toHaveBeenCalledWith('OP-1', '1001', 'receipts', { version: 4, idempotencyKey: expect.any(String), quantity: 1, fileIds: ['9007199254740993'] }) }) it('retains the outbound input and idempotency key after a failed submit', async () => { vi.mocked(submitProcurementWorkflow).mockRejectedValue(new Error('暂时失败')) const wrapper = render('CONFIRMED'); await wrapper.findAll('button').find(button => button.text() === '创建出库单')!.trigger('click') await flushPromises() const dialog = wrapper.findComponent(AppDialog), form = wrapper.findComponent({ name: 'ElForm' }).props('model') form.trackingNo = 'TRACK-1'; form.quantity = 3 dialog.vm.$emit('confirm'); await flushPromises(); dialog.vm.$emit('confirm'); await flushPromises() expect(submitProcurementWorkflow).toHaveBeenCalledTimes(2) expect(vi.mocked(submitProcurementWorkflow).mock.calls[0]![3]).toEqual(vi.mocked(submitProcurementWorkflow).mock.calls[1]![3]) expect(form.trackingNo).toBe('TRACK-1'); expect(form.quantity).toBe(3) form.quantity = 6; dialog.vm.$emit('confirm'); await flushPromises(); expect(submitProcurementWorkflow).toHaveBeenCalledTimes(2) }) it('only completes the selected delivering batch after signature evidence is supplied', async () => { const wrapper = render('DELIVERING') await wrapper.setProps({ workflow: { ...workflow('DELIVERING'), outbounds: [{ id: '9007199254740993', outboundNo: 'OUT-1', status: 'DELIVERING', quantity: 3, trackingNo: 'TRACK-1', fileIds: [], createdAt: '', completedAt: null }] } }) await flushPromises() await wrapper.findAll('button').find(button => button.text() === '上传签收凭证')!.trigger('click'); await flushPromises() const form = wrapper.findComponent({ name: 'ElForm' }).props('model'), dialog = wrapper.findComponent(AppDialog) dialog.vm.$emit('confirm'); await flushPromises(); expect(submitProcurementWorkflow).not.toHaveBeenCalled() vi.mocked(uploadProcurementFile).mockResolvedValue({ id: '2', fileId: '9007199254740995', category: 'RECEIPT', fileName: 'signed.pdf', contentType: 'application/pdf', sizeBytes: 10, createdAt: '' }) const input = document.querySelector('input[type="file"]') || wrapper.find('input[type="file"]').element expect(input.getAttribute('accept')).toContain('application/pdf') Object.defineProperty(input, 'files', { configurable: true, value: [new File(['signed'], 'signed.pdf', { type: 'application/pdf' })] }) input.dispatchEvent(new Event('change', { bubbles: true })); await flushPromises() expect(uploadProcurementFile).toHaveBeenCalledWith('OP-1', '1001', 'RECEIPT', expect.any(File)) expect(form.fileIds).toEqual(['9007199254740995']) dialog.vm.$emit('confirm'); await flushPromises() expect(submitProcurementWorkflow).toHaveBeenCalledWith('OP-1', '1001', 'outbounds/9007199254740993/complete', { version: 4, idempotencyKey: expect.any(String), fileIds: ['9007199254740995'] }) }) })