import ElementPlus, { ElMessageBox } from 'element-plus' import { DOMWrapper, enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import RelabelOrdersView from './RelabelOrdersView.vue' import RelabelOrderDetailView from './RelabelOrderDetailView.vue' import AppDialog from '@/shared/components/AppDialog.vue' import LoadErrorState from '@/shared/components/LoadErrorState.vue' import { ApiResponseError } from '@/shared/api/http' import type { RelabelDetailDto } from '../api/relabelOrders' const api = vi.hoisted(() => ({ list: vi.fn(), detail: vi.fn(), create: vi.fn(), status: vi.fn(), customers: vi.fn(), warehouses: vi.fn(), pdf: vi.fn(), upload: vi.fn(), documents: vi.fn(), complete: vi.fn(), task: vi.fn(), rework: vi.fn() })) const can = vi.hoisted(() => vi.fn((_code: string) => true)) vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) })) vi.mock('vue-router', () => ({ useRoute: () => ({ params: { orderId: 'RL-1' } }), useRouter: () => ({ push: vi.fn() }) })) vi.mock('@/modules/iam', () => ({ listPlatformTenants: api.customers })) vi.mock('../api/relabelOrders', () => ({ listRelabelOrders: api.list, getRelabelOrder: api.detail, createRelabelOrder: api.create, changeRelabelOrderStatus: api.status, listRelabelWarehouseOptions: api.warehouses, downloadRelabelPdf: api.pdf, uploadRelabelPdf: api.upload, downloadRelabelDocument: api.documents, confirmRelabelCompletion: api.complete, getRelabelReviewTask: api.task, returnRelabelForRework: api.rework, downloadRelabelCompletionPhoto: vi.fn() })) enableAutoUnmount(afterEach) afterEach(() => { ElMessageBox.close(); vi.unstubAllGlobals() }) function detail(): RelabelDetailDto { return { application: { id: '1', relabelNo: 'RL-1', customerOrderNo: 'CO-1', status: 'PENDING_REVIEW', chargeAmount: 20, totalPackageCount: 1, totalFileId: '9007199254740993', totalFileName: '总换标.pdf' }, items: [{ id: '9007199254740994', skuCode: 'SKU-A', productNameZh: '商品甲', shipmentNo: 'SHIP-1', labelRequirement: '换SKU标', relabelFileId: '9007199254740995', relabelFileName: '明细换标.pdf', destinationFbaWarehouseCode: null, destinationWarehouseId: '9007199254740999', skuItems: [{ skuCode: 'SKU-A', newSkuCode: 'NEW-A', quantity: 10 }, { skuCode: 'SKU-B', newSkuCode: 'NEW-B', quantity: 5 }] }], history: [] } } beforeEach(() => { vi.resetAllMocks(); can.mockReturnValue(true) vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }) api.list.mockResolvedValue({ records: [{ id: '1', relabelNo: 'RL-1', status: 'PENDING_REVIEW' }], total: 1 }) api.detail.mockImplementation(async () => detail()); api.create.mockImplementation(async () => detail()); api.status.mockResolvedValue(null) api.customers.mockResolvedValue({ records: [{ id: 'C1', tenantName: '客户甲', status: 'ACTIVE' }], pages: 1 }) api.warehouses.mockResolvedValue({ records: [{ id: 'W1', warehouseCode: 'W-1', warehouseName: '海外收货仓', status: 'ACTIVE' }] }) api.pdf.mockResolvedValue(new Blob(['pdf'], { type: 'application/pdf' })); api.upload.mockImplementation(async () => detail()) api.complete.mockResolvedValue(null); api.task.mockResolvedValue({ version: 0 }); api.rework.mockResolvedValue(null) URL.createObjectURL = vi.fn().mockReturnValue('blob:relabel-pdf'); URL.revokeObjectURL = vi.fn() }) function field(wrapper: Pick, label: string) { return wrapper.findAllComponents({ name: 'ElFormItem' }).find(item => item.props('label') === label)! } async function openForm(action = '新增换标单') { const wrapper = mount(RelabelOrdersView, { global: { plugins: [ElementPlus] } }); await flushPromises(); await wrapper.findAll('button').find(button => button.text() === action)!.trigger('click'); await flushPromises(); return { wrapper, dialog: wrapper.findComponent(AppDialog) } } async function fill(dialog: VueWrapper) { for (const [label, value] of [['关联订单', 'CO-1'], ['商品SKU', 'SKU-1'], ['中文品名', '商品一'], ['联系人', '联系人'], ['联系电话', '测试电话'], ['货件号', 'SHIP-1'], ['新SKU', 'NEW-1']]) await field(dialog, label!).get('input').setValue(value!) for (const [label, value] of [['客户', 'C1'], ['公共海外收货仓', 'W1'], ['贴标要求', '换SKU标']]) field(dialog, label!).getComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', value) field(dialog, '箱数').getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 2) field(dialog, '换标费用(CNY)').getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 20) field(dialog, '截止时间').getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-11-01T10:00:00'); await flushPromises() } async function openDetail() { const wrapper = mount(RelabelOrderDetailView, { global: { plugins: [ElementPlus] } }); await flushPromises(); return { wrapper, dialog: wrapper.findComponent(AppDialog) } } async function chooseFile(input: ReturnType, file: File) { Object.defineProperty(input.element, 'files', { value: [file], configurable: true }); await input.trigger('change'); await flushPromises() } describe('native relabel operation dialogs', () => { it('locks candidate loading and retains all fields through fullscreen creation', async () => { let resolve!: (value: { records: Array<{ id: string; tenantName: string; status: string }>; pages: number }) => void api.customers.mockReturnValueOnce(new Promise(done => { resolve = done })) const { dialog } = await openForm() expect(dialog.props('confirmLoading')).toBe(true); expect(dialog.findComponent({ name: 'ElDialog' }).props('showClose')).toBe(false) expect(field(dialog, '关联订单').get('input').attributes('disabled')).toBeDefined() resolve({ records: [{ id: 'C1', tenantName: '客户甲', status: 'ACTIVE' }], pages: 1 }); await flushPromises() expect(dialog.get('[aria-label="换标单生成方式"]').findAll('input')).toHaveLength(0) await fill(dialog); await field(dialog, '备注').get('textarea').setValue(' 本次备注 ') await dialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises() expect((field(dialog, '关联订单').get('input').element as HTMLInputElement).value).toBe('CO-1') dialog.vm.$emit('confirm'); await flushPromises() expect(api.create).toHaveBeenCalledExactlyOnceWith('C1', { customerOrderNo: 'CO-1', operationWarehouseId: 'W1', labelDeadline: '2026-11-01T10:00:00', contactName: '联系人', contactPhone: '测试电话', chargeAmount: 20, remark: '本次备注', idempotencyKey: expect.any(String), items: [{ skuCode: 'SKU-1', productNameZh: '商品一', totalPackageCount: 2, notifiedPackageCount: 2, shipmentNo: 'SHIP-1', newSkuCode: 'NEW-1', labelRequirement: '换SKU标' }] }) }) it('prevents duplicate creation and retains inputs and the original creation key after failure', async () => { let reject!: (cause: Error) => void api.create.mockReturnValueOnce(new Promise((_done, fail) => { reject = fail })) const { dialog } = await openForm(); await fill(dialog) dialog.vm.$emit('confirm'); dialog.vm.$emit('confirm'); await flushPromises() expect(api.create).toHaveBeenCalledOnce(); expect(field(dialog, '商品SKU').get('input').attributes('disabled')).toBeDefined() const key = api.create.mock.calls[0]![1].idempotencyKey reject(new ApiResponseError('创建未确认', 'A0503', 503, null, 'relabel-create-request')); await flushPromises() expect(dialog.text()).toContain('relabel-create-request'); expect((field(dialog, '商品SKU').get('input').element as HTMLInputElement).value).toBe('SKU-1') dialog.vm.$emit('confirm'); await flushPromises(); expect(api.create.mock.calls[1]![1].idempotencyKey).toBe(key) }) it('shows every original SKU as plain facts and requires a rejection reason', async () => { const { dialog } = await openForm('审核') expect(dialog.props('title')).toBe('审核换标单 · RL-1') const facts = dialog.get('[aria-label="换标审核原始资料"]') expect(facts.findAll('input, textarea, select')).toHaveLength(0) expect(facts.text()).toContain('SKU-A、SKU-B'); expect(facts.text()).toContain('NEW-A × 10 件;NEW-B × 5 件') const action = field(dialog, '审核动作').getComponent({ name: 'ElSelect' }) expect(action.findAllComponents({ name: 'ElOption' }).map(option => option.props('value'))).toEqual(['PENDING_PROCESSING', 'REJECTED']) action.vm.$emit('update:modelValue', 'REJECTED'); dialog.vm.$emit('confirm'); await flushPromises(); expect(api.status).not.toHaveBeenCalled() await field(dialog, '备注').get('textarea').setValue('驳回原因'); dialog.vm.$emit('confirm'); await flushPromises() expect(api.status).toHaveBeenCalledExactlyOnceWith('RL-1', 'REJECTED', '驳回原因') }) it('shows independent resource failures and reloads each original candidate source', async () => { api.customers.mockRejectedValueOnce(new ApiResponseError('客户失败', 'A0503', 503, null, 'customer-request')) api.warehouses.mockRejectedValueOnce(new ApiResponseError('仓库失败', 'A0503', 503, null, 'warehouse-request')) const { dialog } = await openForm(); expect(dialog.text()).toContain('customer-request'); expect(dialog.text()).toContain('warehouse-request') expect(dialog.props('confirmDisabled')).toBe(true); dialog.vm.$emit('confirm'); await flushPromises(); expect(api.create).not.toHaveBeenCalled() dialog.findAllComponents(LoadErrorState).find(item => item.props('title') === '客户候选读取失败')!.vm.$emit('retry'); await flushPromises() dialog.findComponent(LoadErrorState).vm.$emit('retry'); await flushPromises() expect(api.customers).toHaveBeenLastCalledWith({ page: 1, size: 200, status: 'ACTIVE' }); expect(api.warehouses).toHaveBeenLastCalledWith() expect(dialog.findComponent(LoadErrorState).exists()).toBe(false) }) it.each(['作废', '确认换标完成', '退回重新贴标'] as const)('keeps the original %s confirmation, business reference and command', async action => { const review = detail(); review.application.status = 'CUSTOMER_CONFIRM_PENDING'; review.completionEvidence = { taskId: '1', photoFileIds: ['11'] } api.detail.mockResolvedValueOnce(review) const { wrapper } = await openDetail() await wrapper.findAll('button').find(button => button.text().includes(action))!.trigger('click'); await flushPromises() const element = Array.from(document.querySelectorAll('.el-message-box')).at(-1) expect(element).toBeDefined(); const box = new DOMWrapper(element!) expect(box.text()).toContain('RL-1'); expect(element!.classList.contains('admin-density-confirm')).toBe(true) if (action === '退回重新贴标') await box.get('textarea').setValue(' 需要重新贴标 ') await box.get('.el-message-box__btns .el-button--primary').trigger('click'); await flushPromises() if (action === '作废') expect(api.status).toHaveBeenCalledExactlyOnceWith('RL-1', 'CANCELLED', '平台作废换标单') else if (action === '确认换标完成') expect(api.complete).toHaveBeenCalledExactlyOnceWith('RL-1') else expect(api.rework).toHaveBeenCalledExactlyOnceWith('RL-1', 0, '需要重新贴标', expect.any(String)) }) it('locks the competing review action until completion confirmation finishes', async () => { const review = detail(); review.application.status = 'CUSTOMER_CONFIRM_PENDING'; review.completionEvidence = { taskId: '1', photoFileIds: ['11'] } api.detail.mockResolvedValueOnce(review) let resolve!: (value: null) => void api.complete.mockReturnValueOnce(new Promise(done => { resolve = done })) const { wrapper } = await openDetail() await wrapper.findAll('button').find(button => button.text().includes('确认换标完成'))!.trigger('click'); await flushPromises() const element = Array.from(document.querySelectorAll('.el-message-box')).at(-1)! await new DOMWrapper(element).get('.el-message-box__btns .el-button--primary').trigger('click'); await flushPromises() const rework = wrapper.findAll('button').find(button => button.text().includes('退回重新贴标'))! expect(rework.attributes('disabled')).toBeDefined(); await rework.trigger('click'); await flushPromises() expect(api.complete).toHaveBeenCalledOnce(); expect(api.task).not.toHaveBeenCalled() resolve(null); await flushPromises() }) it('keeps readonly file previews available while hiding upload and internal warehouse identifiers', async () => { can.mockImplementation(code => code.endsWith(':list')) const { wrapper, dialog } = await openDetail() expect(wrapper.findAll('input[type="file"]')).toHaveLength(0); expect(wrapper.text()).not.toContain('9007199254740999') expect(wrapper.get('.relabel-mark-list table th').text()).toBe('操作(明细PDF)') await wrapper.findAll('button').find(button => button.text().includes('直接预览'))!.trigger('click'); await flushPromises() expect(api.pdf).toHaveBeenLastCalledWith('RL-1', '9007199254740993', 'TOTAL', undefined) expect(dialog.props('fullscreenable')).toBe(true); expect(dialog.get('iframe').attributes('src')).toBe('blob:relabel-pdf') await dialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises() expect(dialog.findComponent({ name: 'ElDialog' }).props('fullscreen')).toBe(true) dialog.vm.$emit('update:modelValue', false); await flushPromises(); expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:relabel-pdf') await wrapper.get('.relabel-mark-list').findAll('button').find(button => button.text() === '预览')!.trigger('click'); await flushPromises() expect(api.pdf).toHaveBeenLastCalledWith('RL-1', '9007199254740995', 'ITEM', '9007199254740994') }) it('discards closed preview responses and retries failed reads with the same file scope', async () => { let resolve!: (value: Blob) => void api.pdf.mockReturnValueOnce(new Promise(done => { resolve = done })).mockRejectedValueOnce(new ApiResponseError('读取失败', 'A0503', 503, null, 'pdf-request')) const { wrapper, dialog } = await openDetail(); const button = wrapper.findAll('button').find(item => item.text().includes('直接预览'))! await button.trigger('click'); await flushPromises(); expect(dialog.props('loading')).toBe(true); expect(dialog.find('iframe').exists()).toBe(false) dialog.vm.$emit('update:modelValue', false); await flushPromises(); resolve(new Blob(['late'], { type: 'application/pdf' })); await flushPromises() expect(URL.createObjectURL).not.toHaveBeenCalled() await button.trigger('click'); await flushPromises(); expect(dialog.text()).toContain('pdf-request') dialog.findComponent(LoadErrorState).vm.$emit('retry'); await flushPromises() expect(api.pdf).toHaveBeenLastCalledWith('RL-1', '9007199254740993', 'TOTAL', undefined); expect(dialog.get('iframe').attributes('src')).toBe('blob:relabel-pdf') }) it('uploads total and item PDFs through their original scope and blocks duplicate uploads', async () => { const withoutItemFile = detail(); withoutItemFile.items[0]!.relabelFileId = null; withoutItemFile.items[0]!.relabelFileName = null api.detail.mockResolvedValueOnce(withoutItemFile) let resolve!: (value: RelabelDetailDto) => void api.upload.mockReturnValueOnce(new Promise(done => { resolve = done })).mockResolvedValueOnce(withoutItemFile) const { wrapper } = await openDetail(); const file = new File(['pdf'], 'new.pdf', { type: 'application/pdf' }) const input = wrapper.get('[aria-label="总换标文件"] input[type="file"]') await chooseFile(input, file); expect(input.attributes('disabled')).toBeDefined() await input.trigger('change'); await flushPromises(); expect(api.upload).toHaveBeenCalledExactlyOnceWith('RL-1', file, 'TOTAL', undefined) resolve(withoutItemFile); await flushPromises() await chooseFile(wrapper.get('.relabel-mark-list input[type="file"]'), file) expect(api.upload).toHaveBeenLastCalledWith('RL-1', file, 'ITEM', '9007199254740994') }) it('preserves PDF file checks and shows inline upload diagnostics without changing the current details', async () => { api.upload.mockRejectedValueOnce(new ApiResponseError('上传失败', 'A0503', 503, null, 'upload-request')) const { wrapper } = await openDetail(); const input = wrapper.get('[aria-label="总换标文件"] input[type="file"]') await chooseFile(input, new File(['png'], 'bad.png', { type: 'image/png' })); expect(api.upload).not.toHaveBeenCalled() const tooBig = new File(['pdf'], 'large.pdf', { type: 'application/pdf' }); Object.defineProperty(tooBig, 'size', { value: 50 * 1024 * 1024 + 1 }) await chooseFile(input, tooBig); expect(api.upload).not.toHaveBeenCalled() await chooseFile(input, new File(['pdf'], 'new.pdf', { type: 'application/pdf' })) expect(wrapper.text()).toContain('upload-request'); expect(wrapper.text()).toContain('总换标.pdf'); expect(input.attributes('disabled')).toBeUndefined() }) })