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

148 lines
14 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 from 'element-plus'
import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import OutboundsView from './OutboundsView.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { ApiResponseError } from '@/shared/api/http'
import type { OneClickOutboundCandidate } from '../api/outbounds'
const api = vi.hoisted(() => ({ list: vi.fn(), candidates: vi.fn(), create: vi.fn(), returns: vi.fn(), createReturn: vi.fn(), transfers: vi.fn(), bind: vi.fn(), stock: vi.fn(), cartons: vi.fn(), carton: vi.fn(), tenants: vi.fn(), warehouses: vi.fn() }))
const can = vi.hoisted(() => vi.fn((_code: string) => true))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
vi.mock('vue-router', () => ({ useRoute: () => ({ query: {} }), useRouter: () => ({ push: vi.fn() }) }))
vi.mock('@/modules/iam', () => ({ listPlatformTenants: api.tenants }))
vi.mock('@/modules/warehouses', () => ({ listPlatformWarehouses: api.warehouses }))
vi.mock('../api/outbounds', () => ({ listOutbounds: api.list, listOneClickOutboundCandidates: api.candidates, createOneClickDropshipOutbound: api.create, listReturnOutboundCandidates: api.returns, createReturnOutbound: api.createReturn, listWarehouseTransferOutboundCandidates: api.transfers, bindWarehouseTransferDestination: api.bind, cancelOutbound: vi.fn() }))
vi.mock('../api/returnInventory', () => ({ listReturnStock: api.stock, listReturnCartons: api.cartons, getReturnCarton: api.carton }))
enableAutoUnmount(afterEach)
afterEach(() => vi.unstubAllGlobals())
const candidate = (index = 0): OneClickOutboundCandidate => ({ inventoryPositionId: '9007199254740993' + index, customerId: 'C1', customerName: '用于验证长文本省略的完整客户名称', orderNo: 'ORDER-' + index, inboundCode: 'IN-' + index, overseasWarehouseId: 'W1', palletNo: 'PALLET-' + index, skuCode: 'SKU-' + index, productName: '中文品名', availablePackages: index + 2, grossWeightKg: 20, volumeWeightKg: 30, volumeCbm: 0.2 })
beforeEach(() => {
vi.resetAllMocks(); can.mockReturnValue(true)
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
api.list.mockResolvedValue({ records: [], total: 0 })
api.candidates.mockResolvedValue([candidate()]); api.create.mockResolvedValue({ outboundNo: 'OUT-1' }); api.createReturn.mockResolvedValue({ outboundNo: 'OUT-2' }); api.bind.mockResolvedValue([])
api.tenants.mockResolvedValue({ records: [{ id: 'C1', tenantName: '客户甲', status: 'ACTIVE' }], pages: 1 })
api.warehouses.mockResolvedValue({ records: [{ id: 'W1', warehouseName: '洛杉矶仓', warehouseCode: 'US-LAX-01', warehouseType: 'OVERSEAS', status: 'ACTIVE' }, { id: 'FBA1', warehouseName: 'FBA目的仓', warehouseCode: 'ABE5', warehouseType: 'FBA', status: 'ACTIVE' }] })
api.transfers.mockResolvedValue([]); api.returns.mockResolvedValue([{ returnNo: 'RT-1', cartonMark: 'RC-1', palletCode: 'P-1' }])
api.stock.mockResolvedValue({ records: [{ skuCode: 'SKU-A', availableQuantity: 6 }], total: 1 })
api.cartons.mockResolvedValue({ records: [{ cartonMark: 'RC-1', sourceReturnNo: 'RT-1', sourceUnitType: 'BOX', status: 'AVAILABLE' }], total: 1 })
api.carton.mockResolvedValue({ cartonMark: 'RC-1', items: [{ skuCode: 'SKU-A', quantity: 4 }, { skuCode: 'SKU-B', quantity: 2 }] })
})
async function render(open = true) {
const wrapper = mount(OutboundsView, { global: { plugins: [ElementPlus] } }); await flushPromises()
if (open) { await wrapper.findAll('button').find(button => button.text() === '新建出库单')!.trigger('click'); await flushPromises() }
return { wrapper, dialog: wrapper.findComponent(AppDialog) }
}
function field(wrapper: Pick<VueWrapper, 'findAllComponents'>, label: string) { return wrapper.findAllComponents({ name: 'ElFormItem' }).find(item => item.props('label') === label)! }
async function chooseMode(dialog: VueWrapper, value: string) { const control = field(dialog, '出库类型').getComponent({ name: 'ElRadioGroup' }); control.vm.$emit('update:modelValue', value); control.vm.$emit('change', value); await flushPromises() }
async function choose(dialog: VueWrapper, label: string, value: string) { const control = field(dialog, label).getComponent({ name: 'ElSelect' }); control.vm.$emit('update:modelValue', value); control.vm.$emit('change', value); await flushPromises() }
async function selectFirst(dialog: VueWrapper) { await dialog.get('.el-table__body-wrapper input[type="checkbox"]').setValue(true); await flushPromises() }
describe('native outbound creation dialogs', () => {
it('keeps continuous scope controls, full candidate facts, paging and fullscreen input', async () => {
const { dialog } = await render()
expect(dialog.props('width')).toBe('min(1760px, calc(100vw - 32px))')
expect(dialog.get('.create-outbound-form').attributes('class')).toContain('density-dialog-sections')
expect(dialog.get('.dropship-search-row').findAll('.el-form-item')).toHaveLength(2)
const table = dialog.findComponent({ name: 'ElTable' })
expect(table.props('maxHeight')).toBeUndefined()
expect(table.findAllComponents({ name: 'ElTableColumn' }).find(column => column.props('prop') === 'customerName')!.props('showOverflowTooltip')).toBe(true)
expect(table.text()).toContain('可出毛重(kg)'); expect(table.text()).toContain('可出材积重(kg)'); expect(table.text()).toContain('可出体积(m³)')
await field(dialog, '备注').get('textarea').setValue('本次出库备注')
await dialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
expect(dialog.findComponent({ name: 'ElDialog' }).props('fullscreen')).toBe(true)
expect((field(dialog, '备注').get('textarea').element as HTMLTextAreaElement).value).toBe('本次出库备注')
await selectFirst(dialog); dialog.vm.$emit('confirm'); await flushPromises()
expect(api.create).toHaveBeenCalledExactlyOnceWith('C1', { recipient: '一件代发订单直出', items: [{ inventoryPositionId: candidate().inventoryPositionId, packages: 2, sku: 'SKU-0', cartonMarks: [] }], remark: '本次出库备注', idempotencyKey: expect.stringMatching(/^web-dropship-/) })
})
it('locks editing, closing and submission while candidates are loading', async () => {
let resolve!: (value: OneClickOutboundCandidate[]) => void
api.candidates.mockReturnValue(new Promise(done => { resolve = done }))
const { dialog } = await render()
expect(dialog.props('confirmLoading')).toBe(true)
expect(dialog.findComponent({ name: 'ElDialog' }).props()).toMatchObject({ showClose: false, closeOnPressEscape: false })
expect(field(dialog, '海外仓').getComponent({ name: 'ElSelect' }).props('disabled')).toBe(true)
expect(dialog.findComponent({ name: 'ElPagination' }).props('disabled')).toBe(true)
dialog.vm.$emit('confirm'); await flushPromises(); expect(api.create).not.toHaveBeenCalled()
resolve([candidate()]); await flushPromises(); expect(dialog.props('confirmLoading')).toBe(false)
})
it('prevents duplicate creation and retains failed inputs, selection and diagnostics', async () => {
let reject!: (cause: Error) => void
api.create.mockReturnValue(new Promise((_resolve, fail) => { reject = fail }))
const { dialog } = await render(); await selectFirst(dialog)
await field(dialog, '备注').get('textarea').setValue('失败后保留')
dialog.vm.$emit('confirm'); dialog.vm.$emit('confirm'); await flushPromises()
expect(api.create).toHaveBeenCalledOnce()
expect(dialog.props('confirmLoading')).toBe(true)
expect(dialog.get('.el-table__body-wrapper input[type="checkbox"]').attributes('disabled')).toBeDefined()
expect(field(dialog, '备注').get('textarea').attributes('disabled')).toBeDefined()
expect(dialog.findComponent({ name: 'ElPagination' }).props('disabled')).toBe(true)
reject(new ApiResponseError('创建失败', 'A0503', 503, null, 'outbound-request')); await flushPromises()
expect(dialog.props('modelValue')).toBe(true); expect(dialog.text()).toContain('outbound-request')
expect(dialog.text()).toContain('已选择 1 条')
expect((field(dialog, '备注').get('textarea').element as HTMLTextAreaElement).value).toBe('失败后保留')
})
it('starts a new form after cancel without leaking recipient, remark or return mode', async () => {
const { wrapper, dialog } = await render(); await chooseMode(dialog, 'RETURN_OUTBOUND')
await field(dialog, '收件信息').get('input').setValue('上一单收件信息')
await field(dialog, '备注').get('textarea').setValue('上一单备注')
const mode = field(dialog, '箱托方式').getComponent({ name: 'ElRadioGroup' }); mode.vm.$emit('update:modelValue', 'PALLET'); mode.vm.$emit('change', 'PALLET'); await flushPromises()
await dialog.findAll('button').find(button => button.text() === '取消')!.trigger('click'); await flushPromises()
await wrapper.findAll('button').find(button => button.text() === '新建出库单')!.trigger('click'); await flushPromises()
expect(field(dialog, '出库类型').getComponent({ name: 'ElRadioGroup' }).props('modelValue')).toBe('ONE_CLICK_DROPSHIP')
await chooseMode(dialog, 'RETURN_OUTBOUND')
expect((field(dialog, '收件信息').get('input').element as HTMLInputElement).value).toBe('')
expect((field(dialog, '备注').get('textarea').element as HTMLTextAreaElement).value).toBe('')
expect(field(dialog, '箱托方式').getComponent({ name: 'ElRadioGroup' }).props('modelValue')).toBe('BOX')
})
it('keeps whole multi-SKU boxes and original return scope without partial quantity inputs', async () => {
const { dialog } = await render(); await chooseMode(dialog, 'RETURN_OUTBOUND'); await choose(dialog, '客户', 'C1'); await choose(dialog, '退货单', 'RT-1')
await field(dialog, '收件信息').get('input').setValue('本次收件信息')
expect(dialog.findAllComponents({ name: 'ElInputNumber' })).toHaveLength(0)
const cards = dialog.findAll('.return-list-card'); expect(cards).toHaveLength(2)
expect(cards[1]!.text()).toContain('SKU-A × 4,SKU-B × 2')
for (const table of dialog.findAllComponents({ name: 'ElTable' })) expect(table.props('maxHeight')).toBeUndefined()
await cards[1]!.get('.el-table__body-wrapper input[type="checkbox"]').setValue(true); dialog.vm.$emit('confirm'); await flushPromises()
expect(api.createReturn).toHaveBeenCalledExactlyOnceWith('C1', { recipient: '本次收件信息', overseasWarehouseId: 'W1', returnNo: 'RT-1', cartonMarks: ['RC-1'], unitType: 'BOX', remark: undefined, idempotencyKey: expect.stringMatching(/^web-return-/) })
})
it('binds grouped private destinations while hiding internal customer identifiers', async () => {
const source = { customerId: '90071992547409930', transferNo: 'WT-1', overseasWarehouseId: 'W1', transferStatus: 'WAIT_FBA_DESTINATION', needRelabel: false, packageCount: 3 }
api.transfers.mockResolvedValue([source, { ...source, packageCount: 4 }])
const { dialog } = await render(); await chooseMode(dialog, 'WAREHOUSE_TRANSFER_OUTBOUND')
expect(dialog.text()).toContain('客户名称待补充'); expect(dialog.text()).not.toContain(source.customerId)
const table = dialog.findComponent({ name: 'ElTable' }); expect(table.props('data')).toMatchObject([{ transferNo: 'WT-1', packageCount: 7 }])
const type = field(dialog, '目的地类型').getComponent({ name: 'ElRadioGroup' }); type.vm.$emit('update:modelValue', 'PRIVATE'); await flushPromises()
dialog.vm.$emit('confirm'); await flushPromises(); expect(api.bind).not.toHaveBeenCalled()
for (const [label, value] of [['私人仓地址', '完整地址'], ['邮编', '90210'], ['联系人', '收件人'], ['联系电话', '测试电话']]) await field(dialog, label!).get('input').setValue(value!)
dialog.vm.$emit('confirm'); await flushPromises()
expect(api.bind).toHaveBeenCalledExactlyOnceWith({ transfers: [{ customerId: source.customerId, transferNo: 'WT-1' }], destinationType: 'PRIVATE', destinationWarehouseId: undefined, destinationWarehouseCode: undefined, destinationWarehouseName: undefined, privateDestinationAddress: '完整地址', privateDestinationPostalCode: '90210', privateDestinationContactName: '收件人', privateDestinationContactPhone: '测试电话', idempotencyKey: expect.stringMatching(/^web-transfer-destination-/) })
expect(api.create).not.toHaveBeenCalled(); expect(api.createReturn).not.toHaveBeenCalled()
})
it('shows candidate diagnostics, blocks submission and retries the same query', async () => {
api.candidates.mockRejectedValueOnce(new ApiResponseError('候选暂不可用', 'A0503', 503, null, 'candidate-request')).mockResolvedValueOnce([candidate()])
const { dialog } = await render(); expect(dialog.text()).toContain('candidate-request'); expect(dialog.props('confirmDisabled')).toBe(true)
dialog.vm.$emit('confirm'); await flushPromises(); expect(api.create).not.toHaveBeenCalled()
dialog.findComponent(LoadErrorState).vm.$emit('retry'); await flushPromises()
expect(api.candidates).toHaveBeenLastCalledWith(undefined, { warehouseId: 'W1' })
expect(dialog.findComponent(LoadErrorState).exists()).toBe(false); expect(dialog.props('confirmDisabled')).toBe(false)
})
it('preserves scope and inputs when retrying a failed platform resource read', async () => {
api.tenants.mockRejectedValueOnce(new ApiResponseError('客户资料暂不可用', 'A0503', 503, null, 'resource-request'))
const { dialog } = await render(); expect(dialog.text()).toContain('resource-request')
await field(dialog, '备注').get('textarea').setValue('重读时保留'); await selectFirst(dialog)
dialog.findComponent(LoadErrorState).vm.$emit('retry'); await flushPromises()
expect(dialog.findComponent(LoadErrorState).exists()).toBe(false); expect(dialog.text()).toContain('已选择 1 条')
expect((field(dialog, '备注').get('textarea').element as HTMLTextAreaElement).value).toBe('重读时保留')
})
it('keeps creation permission separate from list access', async () => {
can.mockImplementation(code => !code.endsWith(':create'))
const { wrapper, dialog } = await render(false)
expect(wrapper.findAll('button').some(button => button.text() === '新建出库单')).toBe(false)
dialog.vm.$emit('confirm'); await flushPromises()
expect(api.candidates).not.toHaveBeenCalled(); expect(api.create).not.toHaveBeenCalled()
})
})