优化
This commit is contained in:
@@ -10,11 +10,12 @@ vi.mock('@/modules/warehouses', () => ({ listPlatformWarehouses: vi.fn().mockRes
|
||||
vi.mock('../api/outbounds', () => ({ listOutbounds: vi.fn().mockResolvedValue({ records: [], total: 0 }), listOneClickOutboundCandidates: vi.fn().mockResolvedValue([]), listReturnOutboundCandidates: vi.fn().mockResolvedValue([]), createReturnOutbound: api.create }))
|
||||
vi.mock('../api/returnInventory', () => ({ listReturnStock: api.stock, listReturnCartons: api.cartons, getReturnCarton: api.detail }))
|
||||
const sources = [
|
||||
{ cartonMark: 'BOX-A', sourceUnitType: 'BOX', sourceReturnNo: 'RT-1', status: 'AVAILABLE', items: [{ skuCode: 'SKU-A', quantity: 5, availableQuantity: 5 }] },
|
||||
{ cartonMark: 'BOX-B', sourceUnitType: 'BOX', sourceReturnNo: 'RT-1', status: 'AVAILABLE', items: [{ skuCode: 'SKU-A', quantity: 5, availableQuantity: 5 }] },
|
||||
{ cartonMark: 'LOOSE-A', sourceUnitType: 'PARCEL', sourceReturnNo: 'RT-1', status: 'AVAILABLE', items: [{ skuCode: 'SKU-A', quantity: 5, availableQuantity: 5 }] },
|
||||
{ cartonMark: 'LOOSE-B', sourceUnitType: 'PARCEL', sourceReturnNo: 'RT-1', status: 'AVAILABLE', items: [{ skuCode: 'SKU-A', quantity: 5, availableQuantity: 5 }] },
|
||||
{ cartonMark: 'PARCEL', sourceUnitType: 'PARCEL', sourceReturnNo: 'RT-1', status: 'AVAILABLE', items: [{ skuCode: 'SKU-B', quantity: 6, availableQuantity: 6 }] },
|
||||
{ cartonMark: 'PACKED', sourceUnitType: 'BOX', sourceReturnNo: 'RT-1', status: 'AVAILABLE', items: [{ skuCode: 'SKU-A', quantity: 5, availableQuantity: 5 }] },
|
||||
{ cartonMark: 'MIXED', sourceUnitType: 'BOX', sourceReturnNo: 'RT-1', status: 'AVAILABLE', items: [{ skuCode: 'SKU-A', quantity: 3 }, { skuCode: 'SKU-B', quantity: 2 }] },
|
||||
{ cartonMark: 'OTHER-ORDER', sourceUnitType: 'BOX', sourceReturnNo: 'RT-2', status: 'AVAILABLE', items: [{ skuCode: 'SKU-A', quantity: 100 }] },
|
||||
{ cartonMark: 'OTHER-ORDER', sourceUnitType: 'PARCEL', sourceReturnNo: 'RT-2', status: 'AVAILABLE', items: [{ skuCode: 'SKU-A', quantity: 100 }] },
|
||||
]
|
||||
let wrapper: VueWrapper
|
||||
beforeEach(() => {
|
||||
@@ -44,21 +45,24 @@ async function open(sku: string) {
|
||||
}
|
||||
function right() { return wrapper.findAllComponents({ name: 'ElTable' }).filter(table => table.classes().includes('return-result-table'))[1]! }
|
||||
async function selectMarks(marks: string[]) {
|
||||
for (const mark of marks) right().findAllComponents(ElCheckbox).find(check => check.element.closest('tr')?.textContent?.includes(mark === 'PARCEL' ? '包裹散件(按 SKU 拣货)' : mark))!.vm.$emit('change', true)
|
||||
for (const mark of marks) {
|
||||
const rows = right().props('data') as Array<{ cartonMark: string }>
|
||||
right().findAllComponents(ElCheckbox)[rows.findIndex(row => row.cartonMark === mark)]!.vm.$emit('change', true)
|
||||
}
|
||||
await flushPromises()
|
||||
}
|
||||
describe('single SKU outbound source and piece quantity', () => {
|
||||
it('selects packed single-SKU boxes, scopes quantities to the return order and excludes mixed boxes', async () => {
|
||||
it('selects only loose SKU sources scoped to the return order and excludes packed boxes', async () => {
|
||||
await open('SKU-A')
|
||||
expect(right().props('data').map((row: { cartonMark: string }) => row.cartonMark)).toEqual(['BOX-A', 'BOX-B'])
|
||||
expect(right().props('data').map((row: { cartonMark: string }) => row.cartonMark)).toEqual(['LOOSE-A', 'LOOSE-B'])
|
||||
expect(wrapper.text()).not.toContain('OTHER-ORDER')
|
||||
await selectMarks(['BOX-A', 'BOX-B'])
|
||||
await selectMarks(['LOOSE-A', 'LOOSE-B'])
|
||||
expect(wrapper.findComponent(ElInputNumber).props('modelValue')).toBe(10)
|
||||
await wrapper.get('[data-test="submit"]').trigger('click'); await flushPromises()
|
||||
expect(api.create).toHaveBeenCalledWith('C1', expect.objectContaining({ skuCode: 'SKU-A', quantity: 10, cartonMarks: ['BOX-A', 'BOX-B'] }))
|
||||
expect(api.create).toHaveBeenCalledWith('C1', expect.objectContaining({ skuCode: 'SKU-A', quantity: 10, cartonMarks: ['LOOSE-A', 'LOOSE-B'] }))
|
||||
})
|
||||
it('submits six pieces from two five-piece boxes', async () => {
|
||||
await open('SKU-A'); await selectMarks(['BOX-A', 'BOX-B'])
|
||||
it('submits six pieces from two five-piece loose sources', async () => {
|
||||
await open('SKU-A'); await selectMarks(['LOOSE-A', 'LOOSE-B'])
|
||||
wrapper.findComponent(ElInputNumber).vm.$emit('update:modelValue', 6); await flushPromises()
|
||||
await wrapper.get('[data-test="submit"]').trigger('click'); await flushPromises()
|
||||
expect(api.create).toHaveBeenCalledWith('C1', expect.objectContaining({ quantity: 6 }))
|
||||
@@ -77,13 +81,13 @@ describe('single SKU outbound source and piece quantity', () => {
|
||||
expect(right().text()).not.toContain('包裹散件')
|
||||
})
|
||||
it.each([[0,1],[11,10]])('clamps input %s to the allowed bound %s', async (quantity, expected) => {
|
||||
await open('SKU-A'); await selectMarks(['BOX-A', 'BOX-B'])
|
||||
await open('SKU-A'); await selectMarks(['LOOSE-A', 'LOOSE-B'])
|
||||
wrapper.findComponent(ElInputNumber).vm.$emit('update:modelValue', quantity); await flushPromises()
|
||||
await wrapper.get('[data-test="submit"]').trigger('click'); await flushPromises()
|
||||
expect(api.create).toHaveBeenCalledWith('C1', expect.objectContaining({quantity: expected}))
|
||||
})
|
||||
it.each([1.5])('blocks invalid quantity %s before creating an order', async quantity => {
|
||||
await open('SKU-A'); await selectMarks(['BOX-A', 'BOX-B'])
|
||||
await open('SKU-A'); await selectMarks(['LOOSE-A', 'LOOSE-B'])
|
||||
wrapper.findComponent(ElInputNumber).vm.$emit('update:modelValue', quantity); await flushPromises()
|
||||
await wrapper.get('[data-test="submit"]').trigger('click'); await flushPromises()
|
||||
expect(api.create).not.toHaveBeenCalled()
|
||||
|
||||
@@ -4,7 +4,8 @@ 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() }))
|
||||
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: [] }) }))
|
||||
@@ -13,7 +14,7 @@ vi.mock('../api/outbounds', () => ({
|
||||
listOutbounds: vi.fn().mockResolvedValue({ records: [], total: 0 }),
|
||||
listOneClickOutboundCandidates: api.candidates,
|
||||
createOneClickDropshipOutbound: api.create,
|
||||
cancelOutbound: vi.fn(), createReturnOutbound: vi.fn(), listReturnOutboundCandidates: vi.fn(),
|
||||
cancelOutbound: vi.fn(), createReturnOutbound: vi.fn(), listReturnOutboundCandidates: api.returnCandidates,
|
||||
}))
|
||||
|
||||
const fixtures = Array.from({ length: 7 }, (_, index) => ({
|
||||
@@ -48,6 +49,40 @@ async function selectBothPages() {
|
||||
}
|
||||
|
||||
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() {} })
|
||||
|
||||
@@ -68,6 +68,7 @@ function transferCustomerName(row: WarehouseTransferOutboundCandidate) {
|
||||
const selectedReturnStock = computed(() => returnStockRows.value.map((item) => {
|
||||
if (returnPickMode.value !== 'SKU') return item
|
||||
const quantity = selectedReturnCartons.value.reduce((sum, carton) => {
|
||||
if (carton.sourceUnitType !== 'PARCEL') return sum
|
||||
if (selectedReturnNo.value && !(carton.sourceReturnNo || '').split(',').includes(selectedReturnNo.value)) return sum
|
||||
const items = returnCartonDetails.value[carton.cartonMark]?.items
|
||||
return items?.length === 1 && items[0]?.skuCode === item.skuCode ? sum + Number(items[0].availableQuantity ?? items[0].quantity) : sum
|
||||
@@ -88,6 +89,7 @@ const filteredReturnCartons = computed(() => selectedReturnCartons.value.filter(
|
||||
if (returnPickMode.value === 'BOX' && item.sourceUnitType === 'PARCEL') return false
|
||||
if (selectedReturnNo.value && !(item.sourceReturnNo || returnCandidateByMark.value[item.cartonMark]?.returnNo || '').split(',').includes(selectedReturnNo.value)) return false
|
||||
if (returnPickMode.value === 'SKU') {
|
||||
if (item.sourceUnitType !== 'PARCEL') return false
|
||||
const detail = returnCartonDetails.value[item.cartonMark]
|
||||
if (!selectedReturnSkus.value.length || detail?.items?.length !== 1 || detail.items[0]?.skuCode !== selectedReturnSkus.value[0]) return false
|
||||
}
|
||||
@@ -222,7 +224,7 @@ async function submit() {
|
||||
}
|
||||
if (!selectedReturnMarks.value.length) {
|
||||
ElMessage.warning(returnPickMode.value === 'SKU'
|
||||
? filteredReturnCartons.value.length ? '请在右侧选择该 SKU 的来源箱或散件记录' : '该退货单没有此 SKU 的可用单 SKU 箱或散件库存;混合箱请先拆箱'
|
||||
? filteredReturnCartons.value.length ? '请在右侧选择该 SKU 的散件来源记录' : '该退货单没有此 SKU 的可用散件库存;箱内库存请按箱/托出库'
|
||||
: '请选择出库箱号或托盘')
|
||||
return
|
||||
}
|
||||
@@ -346,7 +348,7 @@ onMounted(load)
|
||||
<el-form-item label="收件信息" required><el-input v-model="recipient" placeholder="请输入收件人、电话和地址" /></el-form-item>
|
||||
<el-form-item label="海外仓" required><el-select v-model="selectedWarehouseId" :disabled="!selectedCustomerId" filterable placeholder="请选择海外仓" @change="loadReturnCandidates"><el-option v-for="item in warehouseOptions" :key="item.value" :value="item.value" :label="item.label" /></el-select></el-form-item>
|
||||
<el-form-item label="退货单" required><el-select v-model="selectedReturnNo" filterable placeholder="选择已签收入库的退货单" @change="selectedReturnMarks = []"><el-option v-for="no in returnOrderOptions" :key="no" :label="no" :value="no" /></el-select></el-form-item>
|
||||
<p v-if="returnPickMode === 'SKU'" class="candidate-hint">单 SKU 模式可填写全部或部分件数;已装箱货物由 PDA 扫箱唛按计划件数拣货,剩余数量保留在原箱。</p><el-form-item v-if="returnPickMode === 'SKU'" label="出库数量(件)" required><el-input-number v-model="returnQuantity" :min="1" :max="selectedReturnQuantity || 1" :precision="0" placeholder="选择来源后填写件数" /><span class="candidate-hint">所选来源可用 {{ selectedReturnQuantity }} 件;按来源箱号顺序分配</span></el-form-item><el-form-item label="出库维度"><el-radio-group v-model="returnPickMode" @change="changeReturnPickMode"><el-radio-button value="BOX">箱 / 托</el-radio-button><el-radio-button value="SKU">单 SKU</el-radio-button></el-radio-group></el-form-item>
|
||||
<p v-if="returnPickMode === 'SKU'" class="candidate-hint">单 SKU 只展示该退货单的可用散件库存,可填写全部或部分件数,由 PDA 扫描 SKU 拣货。</p><el-form-item v-if="returnPickMode === 'SKU'" label="出库数量(件)" required><el-input-number v-model="returnQuantity" :min="1" :max="selectedReturnQuantity || 1" :precision="0" placeholder="选择来源后填写件数" /><span class="candidate-hint">所选散件来源可用 {{ selectedReturnQuantity }} 件</span></el-form-item><el-form-item label="出库维度"><el-radio-group v-model="returnPickMode" @change="changeReturnPickMode"><el-radio-button value="BOX">箱 / 托</el-radio-button><el-radio-button value="SKU">单 SKU</el-radio-button></el-radio-group></el-form-item>
|
||||
<el-form-item v-if="returnPickMode === 'BOX'" label="箱托方式"><el-radio-group v-model="returnUnitType" @change="changeReturnUnitType"><el-radio-button value="BOX">按箱号出库</el-radio-button><el-radio-button value="PALLET">按托盘出库</el-radio-button></el-radio-group></el-form-item>
|
||||
<el-form-item v-if="returnPickMode === 'BOX' && returnUnitType === 'PALLET' && returnPallets.length" label="整托选择"><div class="return-pallet-options"><el-checkbox v-for="code in returnPallets" :key="code" :model-value="returnCandidates.filter(item => item.palletCode === code && item.cartonMark).every(item => selectedReturnMarks.includes(item.cartonMark!))" @change="toggleReturnPallet(code, $event)">{{ code }}</el-checkbox></div></el-form-item>
|
||||
<div class="return-lists">
|
||||
|
||||
Reference in New Issue
Block a user