Files
admin-web/src/modules/overseas-warehouse/pages/OutboundsView.selection.test.ts
T
2026-10-10 16:12:38 +08:00

204 lines
13 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, { ElCheckbox, ElInput, ElPagination, ElSelect } from 'element-plus'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import OutboundsView from './OutboundsView.vue'
import { listPlatformTenants } from '@/modules/iam'
const api = vi.hoisted(() => ({ candidates: vi.fn(), create: vi.fn(), warehouses: vi.fn(), returnCandidates: vi.fn(), stock: vi.fn(), cartons: vi.fn(), carton: vi.fn() }))
vi.mock('../api/returnInventory', () => ({ listReturnStock: api.stock, listReturnCartons: api.cartons, getReturnCarton: api.carton }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn().mockResolvedValue({ records: [] }) }))
vi.mock('@/modules/warehouses', () => ({ listPlatformWarehouses: api.warehouses }))
vi.mock('../api/outbounds', () => ({
listOutbounds: vi.fn().mockResolvedValue({ records: [], total: 0 }),
listOneClickOutboundCandidates: api.candidates,
createOneClickDropshipOutbound: api.create,
cancelOutbound: vi.fn(), createReturnOutbound: vi.fn(), listReturnOutboundCandidates: api.returnCandidates,
}))
const fixtures = Array.from({ length: 7 }, (_, index) => ({
inventoryPositionId: `position-${index}`, orderNo: `ORDER-${index}`, customerId: 'customer-a',
customerName: '客户甲', overseasWarehouseId: 'warehouse-a', palletNo: `PALLET-${index}`,
inboundCode: `IN-${index}`, skuCode: index < 6 ? 'MATCH' : 'OTHER', productName: '测试品名', availablePackages: index + 1,
grossWeightKg: (index + 1) * 10, volumeWeightKg: (index + 1) * 20, volumeCbm: index + 1,
}))
let wrapper: VueWrapper
async function clickButton(text: string) {
const button = wrapper.findAll('button').find(item => item.text().includes(text))
expect(button, text).toBeDefined()
await button!.trigger('click')
await flushPromises()
}
async function openCreate() {
wrapper = mount(OutboundsView, { global: { plugins: [ElementPlus], stubs: {
AppDialog: { props: ['modelValue'], emits: ['confirm'], template: '<section v-if="modelValue"><slot /><button data-test="submit-outbound" @click="$emit(\'confirm\')">提交出库</button></section>' },
} } })
await flushPromises()
await clickButton('新建出库单')
}
function candidatePagination() { return wrapper.findAllComponents(ElPagination).find(item => item.props('total') === 7)! }
function pageCheckbox() { return wrapper.findAllComponents(ElCheckbox).find(item => item.props('ariaLabel') === '全选本页订单')! }
async function selectBothPages() {
pageCheckbox().vm.$emit('change', true)
await flushPromises()
candidatePagination().vm.$emit('update:current-page', 2)
await flushPromises()
pageCheckbox().vm.$emit('change', true)
await flushPromises()
}
describe('outbound candidate search and selection', () => {
it('limits single SKU quantities and selectable sources to loose stock of the selected return, excluding pallet and box contents', async () => {
vi.mocked(listPlatformTenants).mockResolvedValue({ records: [{ id: 'customer-a', tenantName: '客户甲', status: 'ACTIVE' }], page: 1, size: 200, total: 1, pages: 1 } as Awaited<ReturnType<typeof listPlatformTenants>>)
const sources = [
{ cartonMark: 'P-A', sourceUnitType: 'PALLET', skuCode: 'SKU-A', quantity: 5 },
{ cartonMark: 'P-B', sourceUnitType: 'PALLET', skuCode: 'SKU-B', quantity: 6 },
{ cartonMark: 'BOX-A', sourceUnitType: 'BOX', skuCode: 'SKU-A', quantity: 3 },
{ cartonMark: 'SP-A', sourceUnitType: 'PARCEL', skuCode: 'SKU-A', quantity: 1 },
{ cartonMark: 'SP-B', sourceUnitType: 'PARCEL', skuCode: 'SKU-B', quantity: 5 },
{ cartonMark: 'SP-OTHER', sourceUnitType: 'PARCEL', skuCode: 'SKU-A', quantity: 20 },
].map(row => ({ ...row, status: 'AVAILABLE', sourceReturnNo: row.cartonMark === 'SP-OTHER' ? 'RT-OTHER' : 'RT-MIXED' }))
api.returnCandidates.mockResolvedValue([])
api.stock.mockResolvedValue({ records: [{ skuCode: 'SKU-A', availableQuantity: 29 }, { skuCode: 'SKU-B', availableQuantity: 11 }] })
api.cartons.mockResolvedValue({ records: sources })
api.carton.mockImplementation(async (mark: string) => { const row = sources.find(row => row.cartonMark === mark)!; return { ...row, items: [{ skuCode: row.skuCode, quantity: row.quantity, availableQuantity: row.quantity }] } })
await openCreate()
const type = wrapper.findComponent({ name: 'ElRadioGroup' })
type.vm.$emit('update:modelValue', 'RETURN_OUTBOUND'); type.vm.$emit('change'); await flushPromises()
const customer = wrapper.findAllComponents(ElSelect).find(row => row.props('placeholder') === '请选择客户')!
customer.vm.$emit('update:modelValue', 'customer-a'); customer.vm.$emit('change'); await flushPromises()
const order = wrapper.findAllComponents(ElSelect).find(row => row.props('placeholder') === '选择已签收入库的退货单')!
order.vm.$emit('update:modelValue', 'RT-MIXED'); order.vm.$emit('change'); await flushPromises()
const mode = wrapper.findAllComponents({ name: 'ElRadioGroup' }).find(row => row.text().includes('单 SKU'))!
mode.vm.$emit('update:modelValue', 'SKU'); mode.vm.$emit('change'); await flushPromises()
const stock = wrapper.findAll('.return-list-card')[0]!
expect(stock.findAll('tbody tr').map(row => row.text().replace(/\s/g, ''))).toEqual(['SKU-A1', 'SKU-B5'])
stock.findAllComponents(ElCheckbox)[0]!.vm.$emit('change', true); await flushPromises()
const sourceTable = wrapper.findAll('.return-list-card')[1]!
expect(sourceTable.findAll('tbody tr')).toHaveLength(1)
expect(sourceTable.text()).toContain('SKU-A × 1')
sourceTable.findAllComponents(ElCheckbox)[0]!.vm.$emit('change', true); await flushPromises()
expect(wrapper.text()).toContain('所选散件来源可用 1 件')
mode.vm.$emit('update:modelValue', 'BOX'); mode.vm.$emit('change'); await flushPromises()
expect(wrapper.findAll('.return-list-card')[1]!.findAll('tbody tr')).toHaveLength(3)
})
beforeEach(() => {
vi.mocked(listPlatformTenants).mockReset().mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 })
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
vi.stubGlobal('localStorage', { getItem: () => null, setItem: () => undefined, removeItem: () => undefined })
api.candidates.mockReset().mockResolvedValue(fixtures.map(item => ({ ...item })))
api.create.mockReset().mockResolvedValue({})
api.warehouses.mockReset().mockResolvedValue({ records: [
{ id: 'warehouse-a', warehouseName: '洛杉矶仓', warehouseCode: 'US-LAX-01', warehouseType: 'OVERSEAS', status: 'ACTIVE' },
{ id: 'warehouse-b', warehouseName: '纽约仓', warehouseCode: 'US-NY-01', warehouseType: 'OVERSEAS', status: 'ACTIVE' },
] })
})
afterEach(() => { wrapper?.unmount(); vi.unstubAllGlobals() })
it('offers enabled customers from later pages when creating a return outbound', async () => {
vi.mocked(listPlatformTenants)
.mockResolvedValueOnce({ records: [{ id: 'customer-a', tenantName: '柏悦物流demo2', status: 'ACTIVE' }], page: 1, size: 200, total: 201, pages: 2 } as Awaited<ReturnType<typeof listPlatformTenants>>)
.mockResolvedValueOnce({ records: [{ id: 'customer-b', tenantName: '柏悦物流', status: 'ACTIVE' }], page: 2, size: 200, total: 201, pages: 2 } as Awaited<ReturnType<typeof listPlatformTenants>>)
await openCreate()
expect(listPlatformTenants).toHaveBeenNthCalledWith(1, { page: 1, size: 200, status: 'ACTIVE' })
expect(listPlatformTenants).toHaveBeenNthCalledWith(2, { page: 2, size: 200, status: 'ACTIVE' })
const type = wrapper.findComponent({ name: 'ElRadioGroup' })
type.vm.$emit('update:modelValue', 'RETURN_OUTBOUND')
type.vm.$emit('change', 'RETURN_OUTBOUND')
await flushPromises()
const customer = wrapper.findAllComponents(ElSelect).find(select => select.props('placeholder') === '请选择客户')!
expect(customer.findAllComponents({ name: 'ElOption' }).map(option => option.props('label'))).toEqual(['柏悦物流demo2', '柏悦物流'])
customer.vm.$emit('update:modelValue', 'customer-b')
await flushPromises()
expect(customer.props('modelValue')).toBe('customer-b')
})
it('selects and deselects the current page while preserving other pages, then submits all selected quantities', async () => {
await openCreate()
expect(wrapper.text()).toContain('可出毛重(kg)')
expect(wrapper.text()).toContain('可出材积重(kg)')
expect(wrapper.text()).toContain('可出体积(m³)')
pageCheckbox().vm.$emit('change', true)
await flushPromises()
expect(wrapper.text()).toContain('已选择 5 条')
candidatePagination().vm.$emit('update:current-page', 2)
await flushPromises()
expect(pageCheckbox().props('modelValue')).toBe(false)
pageCheckbox().vm.$emit('change', true)
await flushPromises()
expect(wrapper.text()).toContain('已选择 7 条')
pageCheckbox().vm.$emit('change', false)
await flushPromises()
expect(wrapper.text()).toContain('已选择 5 条')
await wrapper.get('[data-test="submit-outbound"]').trigger('click')
await flushPromises()
expect(api.create).toHaveBeenCalledWith('customer-a', expect.objectContaining({
items: fixtures.slice(0, 5).map(item => ({ inventoryPositionId: item.inventoryPositionId, packages: item.availablePackages, sku: item.skuCode, cartonMarks: [] })),
}))
})
it('keeps searches on one row without bulk buttons and preserves filtered selection across pages', async () => {
await openCreate()
expect(wrapper.get('.dropship-search-row').findAll('.el-form-item')).toHaveLength(2)
expect(wrapper.find('.candidate-selection').exists()).toBe(false)
expect(wrapper.text()).not.toContain('全选筛选结果')
expect(wrapper.text()).not.toContain('清空选择')
const query = wrapper.findAllComponents(ElInput).find(item => item.props('ariaLabel') === '搜索可出库订单')!
await query.get('input').setValue('MATCH')
await flushPromises()
expect(candidatePagination().props('total')).toBe(7)
await query.get('input').trigger('keyup', { key: 'Enter' })
await flushPromises()
const filteredPagination = wrapper.findAllComponents(ElPagination).find(item => item.props('total') === 6)!
expect(filteredPagination).toBeDefined()
pageCheckbox().vm.$emit('change', true)
await flushPromises()
filteredPagination.vm.$emit('update:current-page', 2)
await flushPromises()
pageCheckbox().vm.$emit('change', true)
await flushPromises()
expect(wrapper.text()).toContain('已选择 6 条')
await wrapper.get('.candidate-query').findAll('button').find(item => item.text() === '重置')!.trigger('click')
await flushPromises()
candidatePagination().vm.$emit('update:current-page', 2)
await flushPromises()
expect(pageCheckbox().props('indeterminate')).toBe(true)
pageCheckbox().vm.$emit('change', false)
await flushPromises()
expect(wrapper.get('.candidate-pagination').text()).toContain('已选择 5 条')
})
it('resets the page after page-size changes and clears selection when changing searchable warehouses', async () => {
await openCreate()
await selectBothPages()
const pagination = candidatePagination()
pagination.vm.$emit('update:current-page', 2)
await flushPromises()
pagination.vm.$emit('update:page-size', 20)
pagination.vm.$emit('size-change', 20)
await flushPromises()
expect(pagination.props('currentPage')).toBe(1)
expect(wrapper.text()).toContain('第 1 / 1 页')
expect(wrapper.text()).toContain('已选择 7 条')
const warehouse = wrapper.findAllComponents(ElSelect).find(item => item.props('ariaLabel') === '搜索海外仓')!
expect(warehouse.props('filterable')).toBe(true)
warehouse.vm.$emit('update:modelValue', 'warehouse-b')
warehouse.vm.$emit('change', 'warehouse-b')
await flushPromises()
expect(api.candidates).toHaveBeenLastCalledWith(undefined, { warehouseId: 'warehouse-b' })
expect(wrapper.text()).toContain('已选择 0 条')
})
it('warns about a multi-customer bulk selection and preserves the submit restriction', async () => {
api.candidates.mockResolvedValue([{ ...fixtures[0] }, { ...fixtures[1], customerId: 'customer-b', customerName: '客户乙' }])
await openCreate()
pageCheckbox().vm.$emit('change', true)
await flushPromises()
expect(wrapper.text()).toContain('当前选择包含多个客户')
await wrapper.get('[data-test="submit-outbound"]').trigger('click')
await flushPromises()
expect(api.create).not.toHaveBeenCalled()
})
})