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

148 lines
14 KiB
TypeScript
Raw Normal View History

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()
})
})