Files
admin-web/src/modules/overseas-warehouse/pages/RelabelDialogs.test.ts
T

168 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<VueWrapper, 'findAllComponents'>, 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<VueWrapper['get']>, 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<HTMLElement>('.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<HTMLElement>('.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()
})
})