diff --git a/src/modules/overseas-warehouse/api/outbounds.ts b/src/modules/overseas-warehouse/api/outbounds.ts index 187b7c49..5dd50015 100644 --- a/src/modules/overseas-warehouse/api/outbounds.ts +++ b/src/modules/overseas-warehouse/api/outbounds.ts @@ -52,7 +52,7 @@ export async function listReturnOutboundCandidates(customerId: string, warehouse const response = await http.get, ApiEnvelope>('/platform/v1/last-leg/outbound-orders/return/candidates', { params: { customerId, warehouseId } }) return unwrapApiResponse(response) } -export async function createReturnOutbound(customerId: string, payload: { recipient: string; overseasWarehouseId: string; returnNo: string; cartonMarks: string[]; skuCode?: string; quantity?: number; unitType?: 'BOX' | 'PALLET'; remark?: string; idempotencyKey: string }) { +export async function createReturnOutbound(customerId: string, payload: { recipient: string; overseasWarehouseId: string; returnNo: string; cartonMarks: string[]; skuCode?: string; quantity?: number; items?: Array<{ cartonMark: string; skuCode: string; quantity: number }>; unitType?: 'BOX' | 'PALLET'; remark?: string; idempotencyKey: string }) { const response = await http.post, ApiEnvelope>('/platform/v1/last-leg/outbound-orders/return', payload, { params: { customerId } }) return unwrapApiResponse(response) } diff --git a/src/modules/overseas-warehouse/pages/OutboundsView.returnSku.test.ts b/src/modules/overseas-warehouse/pages/OutboundsView.returnSku.test.ts index d91791da..3f3bea3a 100644 --- a/src/modules/overseas-warehouse/pages/OutboundsView.returnSku.test.ts +++ b/src/modules/overseas-warehouse/pages/OutboundsView.returnSku.test.ts @@ -52,26 +52,41 @@ async function selectMarks(marks: string[]) { await flushPromises() } describe('single SKU outbound source and piece quantity', () => { + it('creates one outbound with multiple SKUs and independent quantities on source rows', async () => { + await open('SKU-A') + const left = wrapper.findAllComponents({ name: 'ElTable' }).filter(table => table.classes().includes('return-result-table'))[0]! + left.findAllComponents(ElCheckbox).find(check => check.element.closest('tr')?.textContent?.includes('SKU-B'))!.vm.$emit('change', true); await flushPromises() + expect(right().props('data')).toHaveLength(3) + await selectMarks(['LOOSE-A', 'PARCEL']) + const inputs = wrapper.findAllComponents(ElInputNumber) + inputs[0]!.vm.$emit('update:modelValue', 3) + inputs[2]!.vm.$emit('update:modelValue', 2) + await flushPromises() + await wrapper.get('[data-test="submit"]').trigger('click'); await flushPromises() + expect(api.create).toHaveBeenCalledWith('C1', expect.objectContaining({ items: [{ cartonMark: 'LOOSE-A', skuCode: 'SKU-A', quantity: 3 }, { cartonMark: 'PARCEL', skuCode: 'SKU-B', quantity: 2 }] })) + expect(api.create.mock.calls[0][1].skuCode).toBeUndefined() + expect(wrapper.text()).not.toContain('选择来源后填写件数') + }) 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(['LOOSE-A', 'LOOSE-B']) expect(wrapper.text()).not.toContain('OTHER-ORDER') await selectMarks(['LOOSE-A', 'LOOSE-B']) - expect(wrapper.findComponent(ElInputNumber).props('modelValue')).toBe(10) + expect(wrapper.findAllComponents(ElInputNumber).map(input => input.props('modelValue'))).toEqual([5, 5]) await wrapper.get('[data-test="submit"]').trigger('click'); await flushPromises() - expect(api.create).toHaveBeenCalledWith('C1', expect.objectContaining({ skuCode: 'SKU-A', quantity: 10, cartonMarks: ['LOOSE-A', 'LOOSE-B'] })) + expect(api.create).toHaveBeenCalledWith('C1', expect.objectContaining({ items: [{ cartonMark: 'LOOSE-A', skuCode: 'SKU-A', quantity: 5 }, { cartonMark: 'LOOSE-B', skuCode: 'SKU-A', quantity: 5 }], cartonMarks: ['LOOSE-A', 'LOOSE-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() + wrapper.findAllComponents(ElInputNumber)[1]!.vm.$emit('update:modelValue', 1); await flushPromises() await wrapper.get('[data-test="submit"]').trigger('click'); await flushPromises() - expect(api.create).toHaveBeenCalledWith('C1', expect.objectContaining({ quantity: 6 })) + expect(api.create).toHaveBeenCalledWith('C1', expect.objectContaining({ items: [{ cartonMark: 'LOOSE-A', skuCode: 'SKU-A', quantity: 5 }, { cartonMark: 'LOOSE-B', skuCode: 'SKU-A', quantity: 1 }] })) }) it('allows loose PARCEL sources even when the box/pallet candidate endpoint excludes them', async () => { await open('SKU-B'); expect(right().props('data').map((row: { cartonMark: string }) => row.cartonMark)).toEqual(['PARCEL']) await selectMarks(['PARCEL']); wrapper.findComponent(ElInputNumber).vm.$emit('update:modelValue', 2); await flushPromises() await wrapper.get('[data-test="submit"]').trigger('click'); await flushPromises() - expect(api.create).toHaveBeenCalledWith('C1', expect.objectContaining({ skuCode: 'SKU-B', quantity: 2, cartonMarks: ['PARCEL'] })) + expect(api.create).toHaveBeenCalledWith('C1', expect.objectContaining({ items: [{ cartonMark: 'PARCEL', skuCode: 'SKU-B', quantity: 2 }], cartonMarks: ['PARCEL'] })) }) it('never offers a parcel as a physical box in box/pallet mode', async () => { await open('SKU-B') @@ -80,11 +95,11 @@ describe('single SKU outbound source and piece quantity', () => { expect(right().props('data').some((row: { sourceUnitType: string }) => row.sourceUnitType === 'PARCEL')).toBe(false) expect(right().text()).not.toContain('包裹散件') }) - it.each([[0,1],[11,10]])('clamps input %s to the allowed bound %s', async (quantity, expected) => { + it.each([[0,1],[11,5]])('clamps input %s to the allowed bound %s', async (quantity, expected) => { 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})) + expect(api.create).toHaveBeenCalledWith('C1', expect.objectContaining({items: expect.arrayContaining([expect.objectContaining({cartonMark: 'LOOSE-A', quantity: expected})])})) }) it.each([1.5])('blocks invalid quantity %s before creating an order', async quantity => { await open('SKU-A'); await selectMarks(['LOOSE-A', 'LOOSE-B']) diff --git a/src/modules/overseas-warehouse/pages/OutboundsView.selection.test.ts b/src/modules/overseas-warehouse/pages/OutboundsView.selection.test.ts index 308067a5..cce1b5d2 100644 --- a/src/modules/overseas-warehouse/pages/OutboundsView.selection.test.ts +++ b/src/modules/overseas-warehouse/pages/OutboundsView.selection.test.ts @@ -79,7 +79,7 @@ describe('outbound candidate search and selection', () => { 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 件') + expect(wrapper.findComponent({ name: 'ElInputNumber' }).props('modelValue')).toBe(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) }) diff --git a/src/modules/overseas-warehouse/pages/OutboundsView.test.ts b/src/modules/overseas-warehouse/pages/OutboundsView.test.ts index e8efdefb..98157ae4 100644 --- a/src/modules/overseas-warehouse/pages/OutboundsView.test.ts +++ b/src/modules/overseas-warehouse/pages/OutboundsView.test.ts @@ -79,7 +79,7 @@ describe('OutboundsView permissions and navigation', () => { expect(source).toContain('@click="searchReturnSkus"') expect(source).toContain('@click="searchReturnCartons"') expect(source).toContain(':page-sizes="[5, 10, 20, 50]"') - expect(source).toContain('width="min(1320px, calc(100vw - 32px))"') + expect(source).toContain('width="min(1760px, calc(100vw - 32px))" fullscreenable') }) it('resolves warehouse-transfer customer names from the platform customer list', () => { diff --git a/src/modules/overseas-warehouse/pages/OutboundsView.vue b/src/modules/overseas-warehouse/pages/OutboundsView.vue index e7e21a5e..43cdcc63 100644 --- a/src/modules/overseas-warehouse/pages/OutboundsView.vue +++ b/src/modules/overseas-warehouse/pages/OutboundsView.vue @@ -38,7 +38,7 @@ const returnStockRows = ref([]) const returnCartonRows = ref([]) const selectedReturnSkus = ref([]) const returnPickMode = ref<'BOX' | 'SKU'>('BOX') -const returnQuantity = ref() +const returnSourceQuantities = ref>({}) const returnSkuQueryInput = ref(''); const returnSkuQuery = ref('') const returnCartonQueryInput = ref(''); const returnCartonQuery = ref('') const returnSkuPage = ref(1); const returnCartonPage = ref(1); const returnUnitType = ref<'BOX' | 'PALLET'>('BOX') @@ -91,7 +91,7 @@ const filteredReturnCartons = computed(() => selectedReturnCartons.value.filter( 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 + if (!selectedReturnSkus.value.length || detail?.items?.length !== 1 || !selectedReturnSkus.value.includes(detail.items[0]?.skuCode || '')) return false } const detailSkus = returnCartonDetails.value[item.cartonMark]?.items?.map((detail) => detail.skuCode).join(',') || '' return matchesAnyToken(`${item.cartonMark},${item.fbaCartonMark || ''},${detailSkus}`, returnCartonQuery.value) @@ -99,11 +99,13 @@ const filteredReturnCartons = computed(() => selectedReturnCartons.value.filter( const pagedReturnStocks = computed(() => filteredReturnStocks.value.slice((returnSkuPage.value - 1) * returnSkuPageSize.value, returnSkuPage.value * returnSkuPageSize.value)) const pagedReturnCartons = computed(() => filteredReturnCartons.value.slice((returnCartonPage.value - 1) * returnCartonPageSize.value, returnCartonPage.value * returnCartonPageSize.value)) const effectiveReturnMarks = computed(() => [...new Set(selectedReturnMarks.value)].filter(Boolean) as string[]) -const selectedReturnQuantity = computed(() => effectiveReturnMarks.value.reduce((sum, mark) => { - const line = returnCartonDetails.value[mark]?.items.find(item => item.skuCode === selectedReturnSkus.value[0]) - return sum + Number(line?.availableQuantity ?? line?.quantity ?? 0) -}, 0)) -watch(selectedReturnQuantity, quantity => { returnQuantity.value = quantity || undefined }) +function returnSourceAvailable(mark: string) { + const item = returnCartonDetails.value[mark]?.items[0] + return Number(item?.availableQuantity ?? item?.quantity ?? 0) +} +watch([selectedReturnNo, selectedCustomerId, selectedWarehouseId], () => { + selectedReturnSkus.value = []; selectedReturnMarks.value = []; returnSourceQuantities.value = {} +}) const returnMarksModel = computed({ get: () => selectedReturnMarks.value, set: (value: string[]) => { selectedReturnMarks.value = value } }) const filteredDropshipCandidates = computed(() => dropshipCandidates.value.filter(item => !dropshipQuery.value.trim() || [item.orderNo, item.inboundCode, item.customerName, item.palletNo, item.skuCode, item.productName].join(' ').toLowerCase().includes(dropshipQuery.value.trim().toLowerCase()))) const pagedDropshipCandidates = computed(() => filteredDropshipCandidates.value.slice((dropshipPage.value - 1) * dropshipPageSize.value, dropshipPage.value * dropshipPageSize.value)) @@ -194,10 +196,16 @@ function resetDropshipCandidates() { dropshipQueryInput.value = ''; dropshipQuer function normalizeBatchQuery(value: string) { return value.split(/[,,\n]+/).map((item) => item.replace(/\s+/g, '')).filter(Boolean).join(',') } function searchReturnSkus() { returnSkuQueryInput.value = normalizeBatchQuery(returnSkuQueryInput.value); returnSkuQuery.value = returnSkuQueryInput.value; returnSkuPage.value = 1 } function searchReturnCartons() { returnCartonQueryInput.value = normalizeBatchQuery(returnCartonQueryInput.value); returnCartonQuery.value = returnCartonQueryInput.value; returnCartonPage.value = 1 } -function toggleReturnSku(sku: string, checked: boolean | string | number) { selectedReturnSkus.value = checked ? [sku] : []; selectedReturnMarks.value = [] } +function toggleReturnSku(sku: string, checked: boolean | string | number) { + selectedReturnSkus.value = checked ? [...new Set([...selectedReturnSkus.value, sku])] : selectedReturnSkus.value.filter(value => value !== sku) + if (!checked) selectedReturnMarks.value = selectedReturnMarks.value.filter(mark => returnCartonDetails.value[mark]?.items[0]?.skuCode !== sku) +} function changeReturnPickMode() { selectedReturnSkus.value = []; selectedReturnMarks.value = []; returnUnitType.value = 'BOX' } function changeReturnUnitType() { selectedReturnMarks.value = [] } -function toggleReturnCarton(cartonMark: string, checked: boolean | string | number) { selectedReturnMarks.value = checked ? [...new Set([...selectedReturnMarks.value, cartonMark])] : selectedReturnMarks.value.filter((value) => value !== cartonMark) } +function toggleReturnCarton(cartonMark: string, checked: boolean | string | number) { + selectedReturnMarks.value = checked ? [...new Set([...selectedReturnMarks.value, cartonMark])] : selectedReturnMarks.value.filter(value => value !== cartonMark) + if (checked && returnPickMode.value === 'SKU') returnSourceQuantities.value[cartonMark] = returnSourceAvailable(cartonMark) +} function toggleReturnPallet(palletCode: string, checked: boolean | string | number) { const marks = returnCandidates.value.filter(item => item.palletCode === palletCode && item.cartonMark).map(item => item.cartonMark!) selectedReturnMarks.value = checked ? [...new Set([...selectedReturnMarks.value, ...marks])] : selectedReturnMarks.value.filter(mark => !marks.includes(mark)) @@ -218,9 +226,16 @@ async function submit() { } else if (outboundType.value === 'RETURN_OUTBOUND') { if (!recipient.value.trim()) { ElMessage.warning('请填写收件信息'); return } if (!selectedReturnNo.value) { ElMessage.warning('请选择退货单'); return } - if (returnPickMode.value === 'SKU' && selectedReturnSkus.value.length !== 1) { ElMessage.warning('请选择一个 SKU'); return } - if (returnPickMode.value === 'SKU' && (!Number.isSafeInteger(returnQuantity.value) || Number(returnQuantity.value) <= 0 || Number(returnQuantity.value) > selectedReturnQuantity.value)) { - ElMessage.warning(`请填写 1-${selectedReturnQuantity.value} 件的出库数量`); return + if (returnPickMode.value === 'SKU') { + if (!selectedReturnSkus.value.length) { ElMessage.warning('请至少选择一个 SKU'); return } + if (selectedReturnSkus.value.some(sku => !effectiveReturnMarks.value.some(mark => returnCartonDetails.value[mark]?.items[0]?.skuCode === sku))) { + ElMessage.warning('请为每个已选 SKU 选择散件来源并填写出库数量'); return + } + if (effectiveReturnMarks.value.some(mark => !filteredReturnCartons.value.some(row => row.cartonMark === mark) + || !Number.isSafeInteger(returnSourceQuantities.value[mark]) || Number(returnSourceQuantities.value[mark]) <= 0 + || Number(returnSourceQuantities.value[mark]) > returnSourceAvailable(mark))) { + ElMessage.warning('请在明细中填写大于0且不超过来源可用数量的整数'); return + } } if (!selectedReturnMarks.value.length) { ElMessage.warning(returnPickMode.value === 'SKU' @@ -228,7 +243,7 @@ async function submit() { : '请选择出库箱号或托盘') return } - await createReturnOutbound(selectedCustomerId.value, { recipient: recipient.value.trim(), overseasWarehouseId: selectedWarehouseId.value, returnNo: selectedReturnNo.value, cartonMarks: selectedReturnMarks.value, skuCode: returnPickMode.value === 'SKU' ? selectedReturnSkus.value[0] : undefined, quantity: returnPickMode.value === 'SKU' ? returnQuantity.value : undefined, unitType: returnPickMode.value === 'SKU' ? 'BOX' : returnUnitType.value, remark: remark.value.trim() || undefined, idempotencyKey: `web-return-${Date.now()}` }) + await createReturnOutbound(selectedCustomerId.value, { recipient: recipient.value.trim(), overseasWarehouseId: selectedWarehouseId.value, returnNo: selectedReturnNo.value, cartonMarks: selectedReturnMarks.value, items: returnPickMode.value === 'SKU' ? effectiveReturnMarks.value.map(mark => ({ cartonMark: mark, skuCode: returnCartonDetails.value[mark]!.items[0]!.skuCode, quantity: Number(returnSourceQuantities.value[mark]) })) : undefined, unitType: returnPickMode.value === 'SKU' ? 'BOX' : returnUnitType.value, remark: remark.value.trim() || undefined, idempotencyKey: `web-return-${Date.now()}` }) ElMessage.success('退货出库单和待派送单已创建,作业已推送 PDA') } else { const selected = dropshipCandidates.value.filter(item => selectedDropshipIds.value.includes(dropshipCandidateKey(item))) @@ -266,7 +281,7 @@ onMounted(load) - + @@ -348,7 +363,7 @@ onMounted(load) -

单 SKU 只展示该退货单的可用散件库存,可填写全部或部分件数,由 PDA 扫描 SKU 拣货。

所选散件来源可用 {{ selectedReturnQuantity }} 件箱 / 托单 SKU +

单 SKU 只展示该退货单的可用散件库存,可填写全部或部分件数,由 PDA 扫描 SKU 拣货。

箱 / 托单 SKU 按箱号出库按托盘出库
{{ code }}
@@ -361,7 +376,7 @@ onMounted(load)
{{ returnPickMode === 'SKU' ? 'SKU 来源明细' : '箱号列表' }}支持多个箱号或 SKU,用英文逗号分隔
查询
- +
已选择 {{ selectedReturnMarks.length }} 个{{ returnPickMode === 'SKU' ? '来源' : '箱号' }}
@@ -394,7 +409,7 @@ onMounted(load) .candidate-pagination :deep(.el-pagination__sizes), .return-pagination :deep(.el-pagination__sizes) { flex: 0 0 128px; } .candidate-pagination :deep(.el-pagination__sizes .el-select), .return-pagination :deep(.el-pagination__sizes .el-select) { width: 128px; } .candidate-pagination :deep(.el-pagination__jump .el-input) { width: 56px; } -.return-lists { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: var(--admin-space-5); width: 100%; margin: var(--admin-space-1) 0 var(--admin-space-5); } +.return-lists { display: grid; grid-template-columns: minmax(0, 0.85fr) minmax(0, 1.15fr); gap: var(--admin-space-5); width: 100%; margin: var(--admin-space-1) 0 var(--admin-space-5); } .return-pallet-options { display: flex; flex-wrap: wrap; gap: 8px 16px; } .return-list-card { min-width: 0; padding: var(--admin-space-5); border: 1px solid var(--el-border-color-light); border-radius: var(--admin-radius); background: var(--el-fill-color-extra-light); } .list-title { display: flex; align-items: baseline; justify-content: space-between; gap: var(--admin-space-5); margin-bottom: var(--admin-space-5); }