diff --git a/src/modules/overseas-warehouse/api/outbounds.ts b/src/modules/overseas-warehouse/api/outbounds.ts index 5dd50015..b1b7b9e2 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; items?: Array<{ cartonMark: 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[]; 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/api/relabelOrders.ts b/src/modules/overseas-warehouse/api/relabelOrders.ts index a93e0cf0..fa450867 100644 --- a/src/modules/overseas-warehouse/api/relabelOrders.ts +++ b/src/modules/overseas-warehouse/api/relabelOrders.ts @@ -142,6 +142,7 @@ export interface ReturnPackingPlanPayload { remark?: string idempotencyKey: string sourceCartonMarks?: string[] + sourceDeductions?: Array<{ cartonMark: string; skuCode: string; quantity: number }> } export interface ReturnPackingPlanDto extends Omit { diff --git a/src/modules/overseas-warehouse/api/returnInventory.ts b/src/modules/overseas-warehouse/api/returnInventory.ts index ffcdeb35..d51eb293 100644 --- a/src/modules/overseas-warehouse/api/returnInventory.ts +++ b/src/modules/overseas-warehouse/api/returnInventory.ts @@ -3,7 +3,7 @@ import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' import { normalizeOverseasPage, type OverseasPagePayload } from './lastMileOrders' export interface ReturnStockRow { customerId: string; overseasWarehouseId: string; skuCode: string; productNameZh?: string; totalQuantity: number; availableQuantity: number; looseAvailableQuantity?: number; cartonQuantity: number; updatedAt: string } -export interface ReturnCartonRow { customerId: string; overseasWarehouseId: string; cartonMark: string; sourceReturnNo?: string; sourceUnitType?: 'PALLET' | 'BOX' | 'PARCEL' | string; sourceBatchNo?: string; status: string; fbaCartonMark?: string; destinationWarehouseCode?: string; destinationWarehouseName?: string; relabelNo?: string; relabelStatus?: string; skuTypes: number; totalQuantity: number; length?: number; width?: number; height?: number; weight?: number; createdAt: string } +export interface ReturnCartonRow { customerId: string; overseasWarehouseId: string; cartonMark: string; sourceReturnNo?: string; sourceUnitType?: 'PALLET' | 'BOX' | 'PARCEL' | string; sourceBatchNo?: string; status: string; fbaCartonMark?: string; destinationWarehouseCode?: string; destinationWarehouseName?: string; relabelNo?: string; relabelStatus?: string; relabelledQuantity?: number; relabelReservedQuantity?: number; skuTypes: number; totalQuantity: number; length?: number; width?: number; height?: number; weight?: number; createdAt: string } export interface ReturnCartonMergeHistory { planNo?: string; sourceCartonMark: string; targetCartonMark: string; returnNo: string; skuCode: string; quantity: number; createdAt: string } export interface ReturnCartonDetail extends ReturnCartonRow { customerId: string; items: Array<{ skuCode: string; productNameZh?: string; quantity: number; availableQuantity?: number }>; mergeHistory?: ReturnCartonMergeHistory[] } export interface ReturnCartonLabel extends ReturnCartonDetail { printableHtml: string } diff --git a/src/modules/overseas-warehouse/pages/OutboundsView.returnSku.test.ts b/src/modules/overseas-warehouse/pages/OutboundsView.returnSku.test.ts index 3f3bea3a..4cdf6484 100644 --- a/src/modules/overseas-warehouse/pages/OutboundsView.returnSku.test.ts +++ b/src/modules/overseas-warehouse/pages/OutboundsView.returnSku.test.ts @@ -38,10 +38,7 @@ async function open(sku: string) { const order = wrapper.findAllComponents(ElSelect).find(select => select.props('placeholder') === '选择已签收入库的退货单')! order.vm.$emit('update:modelValue', 'RT-1'); order.vm.$emit('change', 'RT-1'); await flushPromises() await wrapper.get('input[placeholder="请输入收件人、电话和地址"]').setValue('本地测试收件信息') - const mode = wrapper.findAllComponents({ name: 'ElRadioGroup' }).find(group => group.text().includes('单 SKU'))! - mode.vm.$emit('update:modelValue', 'SKU'); mode.vm.$emit('change', 'SKU'); await flushPromises() - 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))!.vm.$emit('change', true); await flushPromises() + } function right() { return wrapper.findAllComponents({ name: 'ElTable' }).filter(table => table.classes().includes('return-result-table'))[1]! } async function selectMarks(marks: string[]) { @@ -51,59 +48,24 @@ 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 () => { +describe('physical return outbound', () => { + it('submits whole multi-SKU parcels and boxes without a direct partial payload', 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() + expect(wrapper.findAllComponents(ElInputNumber)).toHaveLength(0) 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('选择来源后填写件数') + expect(api.create).toHaveBeenCalledWith('C1', expect.objectContaining({ cartonMarks: ['LOOSE-A', 'PARCEL'], unitType: 'BOX' })) + const request = api.create.mock.calls[0][1] + expect(request.items).toBeUndefined(); expect(request.skuCode).toBeUndefined(); expect(request.quantity).toBeUndefined() }) - it('selects only loose SKU sources scoped to the return order and excludes packed boxes', async () => { + it('includes physical parcels while excluding other return orders', 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.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({ items: [{ cartonMark: 'LOOSE-A', skuCode: 'SKU-A', quantity: 5 }, { cartonMark: 'LOOSE-B', skuCode: 'SKU-A', quantity: 5 }], cartonMarks: ['LOOSE-A', 'LOOSE-B'] })) + const marks = right().props('data').map((row: { cartonMark: string }) => row.cartonMark) + expect(marks).toContain('PARCEL'); expect(marks).toContain('LOOSE-A'); expect(marks).not.toContain('OTHER-ORDER') + expect(wrapper.text()).toContain('合箱') }) - it('submits six pieces from two five-piece loose sources', async () => { - await open('SKU-A'); await selectMarks(['LOOSE-A', 'LOOSE-B']) - 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({ 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({ 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') - const mode = wrapper.findAllComponents({ name: 'ElRadioGroup' }).find(group => group.text().includes('单 SKU'))! - mode.vm.$emit('update:modelValue', 'BOX'); mode.vm.$emit('change', 'BOX'); await flushPromises() - expect(right().props('data').some((row: { sourceUnitType: string }) => row.sourceUnitType === 'PARCEL')).toBe(false) - expect(right().text()).not.toContain('包裹散件') - }) - 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({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']) - wrapper.findComponent(ElInputNumber).vm.$emit('update:modelValue', quantity); await flushPromises() + it('blocks submission without a physical parcel or carton', async () => { + await open('SKU-A') await wrapper.get('[data-test="submit"]').trigger('click'); await flushPromises() expect(api.create).not.toHaveBeenCalled() }) diff --git a/src/modules/overseas-warehouse/pages/OutboundsView.selection.test.ts b/src/modules/overseas-warehouse/pages/OutboundsView.selection.test.ts index cce1b5d2..6ad71fc8 100644 --- a/src/modules/overseas-warehouse/pages/OutboundsView.selection.test.ts +++ b/src/modules/overseas-warehouse/pages/OutboundsView.selection.test.ts @@ -49,7 +49,7 @@ 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 () => { + it('offers whole physical parcels and boxes scoped to the selected return', async () => { vi.mocked(listPlatformTenants).mockResolvedValue({ records: [{ id: 'customer-a', tenantName: '客户甲', status: 'ACTIVE' }], page: 1, size: 200, total: 1, pages: 1 } as Awaited>) const sources = [ { cartonMark: 'P-A', sourceUnitType: 'PALLET', skuCode: 'SKU-A', quantity: 5 }, @@ -70,18 +70,13 @@ describe('outbound candidate search and selection', () => { 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.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) + expect(sourceTable.findAll('tbody tr')).toHaveLength(5) + expect(sourceTable.text()).toContain('SP-A') + expect(sourceTable.text()).toContain('BOX-A') + expect(sourceTable.text()).not.toContain('SP-OTHER') + expect(wrapper.findComponent({ name: 'ElInputNumber' }).exists()).toBe(false) + expect(wrapper.text()).toContain('合箱') }) beforeEach(() => { vi.mocked(listPlatformTenants).mockReset().mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 }) diff --git a/src/modules/overseas-warehouse/pages/OutboundsView.vue b/src/modules/overseas-warehouse/pages/OutboundsView.vue index 43cdcc63..4adc9670 100644 --- a/src/modules/overseas-warehouse/pages/OutboundsView.vue +++ b/src/modules/overseas-warehouse/pages/OutboundsView.vue @@ -36,9 +36,6 @@ const selectedDestinationWarehouseId = ref('') const deliveryAddress = ref(''); const postalCode = ref(''); const contactName = ref(''); const contactPhone = ref('') const returnStockRows = ref([]) const returnCartonRows = ref([]) -const selectedReturnSkus = ref([]) -const returnPickMode = ref<'BOX' | 'SKU'>('BOX') -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') @@ -65,16 +62,7 @@ function transferCustomerName(row: WarehouseTransferOutboundCandidate) { const customer = tenantOptions.value.find(item => String(item.id) === String(row.customerId)) return customer?.legalName?.trim() || customer?.tenantName?.trim() || `客户 ${row.customerId}` } -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 - }, 0) - return { ...item, availableQuantity: quantity } -}).filter(item => Number(item.availableQuantity || 0) > 0)) +const selectedReturnStock = computed(() => returnStockRows.value.filter(item => Number(item.availableQuantity || 0) > 0)) const selectedReturnCartons = computed(() => returnCartonRows.value.filter((item) => item.status === 'AVAILABLE')) const returnOrderOptions = computed(() => [...new Set([ ...returnCandidates.value.flatMap(item => (item.returnNo || '').split(',')), @@ -86,25 +74,16 @@ const queryTokens = (value: string) => value.split(/[,,\n]+/).map((item) => it const matchesAnyToken = (value: string, query: string) => { const tokens = queryTokens(query); const normalized = value.replace(/\s+/g, '').toLowerCase(); return !tokens.length || tokens.some((token) => normalized.includes(token)) } const filteredReturnStocks = computed(() => selectedReturnStock.value.filter((item) => matchesAnyToken(`${item.skuCode},${item.productNameZh || ''}`, returnSkuQuery.value))) const filteredReturnCartons = computed(() => selectedReturnCartons.value.filter((item) => { - 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 || !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) })) 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[]) -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 = {} + selectedReturnMarks.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()))) @@ -154,8 +133,8 @@ async function loadInventoryOptions() { async function openCreate() { createOpen.value = true; outboundType.value = 'ONE_CLICK_DROPSHIP'; selectedCustomerId.value = ''; selectedWarehouseId.value = ''; selectedDropshipIds.value = []; selectedTransferPalletIds.value = []; transferCandidates.value = []; destinationType.value = 'FBA'; selectedDestinationWarehouseId.value = ''; deliveryAddress.value = ''; postalCode.value = ''; contactName.value = ''; contactPhone.value = ''; dropshipCandidates.value = []; dropshipQueryInput.value = ''; dropshipQuery.value = ''; dropshipPage.value = 1; returnSkuQueryInput.value = ''; returnSkuQuery.value = ''; returnCartonQueryInput.value = ''; returnCartonQuery.value = ''; returnSkuPage.value = 1; returnCartonPage.value = 1; await loadInventoryOptions(); await loadDropshipCandidates() } async function loadDropshipCandidates() { if (!selectedWarehouseId.value) { dropshipCandidates.value = []; return }; dropshipPage.value = 1; inventoryLoading.value = true; try { dropshipCandidates.value = await listOneClickOutboundCandidates(selectedCustomerId.value || undefined, { warehouseId: selectedWarehouseId.value }) } catch (cause) { dropshipCandidates.value = []; ElMessage.error(getApiErrorDetails(cause, '一件代发订单加载失败').message) } finally { inventoryLoading.value = false } } async function changeDropshipWarehouse() { selectedDropshipIds.value = []; await loadDropshipCandidates() } -async function selectCustomer() { selectedPositionId.value = ''; selectedDropshipIds.value = []; selectedReturnNo.value = ''; selectedReturnMarks.value = []; selectedReturnSkus.value = []; returnCandidates.value = []; returnStockRows.value = []; returnCartonRows.value = []; returnCartonDetails.value = {}; dropshipCandidates.value = []; dropshipPage.value = 1; if (outboundType.value === 'RETURN_OUTBOUND') { if (selectedCustomerId.value && selectedWarehouseId.value) await loadReturnCandidates(); return }; await loadDropshipCandidates() } -async function changeOutboundType() { selectedReturnNo.value = ''; selectedReturnMarks.value = []; selectedReturnSkus.value = []; returnCandidates.value = []; returnStockRows.value = []; returnCartonRows.value = []; returnCartonDetails.value = {}; selectedDropshipIds.value = []; selectedTransferPalletIds.value = []; dropshipCandidates.value = []; transferCandidates.value = []; if (outboundType.value === 'ONE_CLICK_DROPSHIP') await loadDropshipCandidates(); if (outboundType.value === 'WAREHOUSE_TRANSFER_OUTBOUND') await loadTransferCandidates() } +async function selectCustomer() { selectedPositionId.value = ''; selectedDropshipIds.value = []; selectedReturnNo.value = ''; selectedReturnMarks.value = []; returnCandidates.value = []; returnStockRows.value = []; returnCartonRows.value = []; returnCartonDetails.value = {}; dropshipCandidates.value = []; dropshipPage.value = 1; if (outboundType.value === 'RETURN_OUTBOUND') { if (selectedCustomerId.value && selectedWarehouseId.value) await loadReturnCandidates(); return }; await loadDropshipCandidates() } +async function changeOutboundType() { selectedReturnNo.value = ''; selectedReturnMarks.value = []; returnCandidates.value = []; returnStockRows.value = []; returnCartonRows.value = []; returnCartonDetails.value = {}; selectedDropshipIds.value = []; selectedTransferPalletIds.value = []; dropshipCandidates.value = []; transferCandidates.value = []; if (outboundType.value === 'ONE_CLICK_DROPSHIP') await loadDropshipCandidates(); if (outboundType.value === 'WAREHOUSE_TRANSFER_OUTBOUND') await loadTransferCandidates() } async function loadTransferCandidates() { inventoryLoading.value = true try { @@ -181,7 +160,7 @@ function transferDestinationLabel(row: WarehouseTransferOutboundCandidate) { function transferHasDestination(row: WarehouseTransferOutboundCandidate) { return Boolean(row.destinationWarehouseCode?.trim() || row.privateDestinationAddress?.trim()) } -async function loadReturnCandidates() { if (!selectedCustomerId.value || !selectedWarehouseId.value) return; inventoryLoading.value = true; try { const [candidates, stock, cartons] = await Promise.all([listReturnOutboundCandidates(selectedCustomerId.value, selectedWarehouseId.value), listReturnStock({ page: 1, size: 200, customerId: selectedCustomerId.value, overseasWarehouseId: selectedWarehouseId.value, status: 'AVAILABLE' }), listReturnCartons({ page: 1, size: 200, customerId: selectedCustomerId.value, overseasWarehouseId: selectedWarehouseId.value, status: 'AVAILABLE' })]); returnCandidates.value = candidates; returnStockRows.value = stock.records; returnCartonRows.value = cartons.records; const details = await Promise.all(cartons.records.map(async (row) => [row.cartonMark, await getReturnCarton(row.cartonMark)] as const)); returnCartonDetails.value = Object.fromEntries(details); selectedReturnSkus.value = []; selectedReturnMarks.value = [] } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '客户库存加载失败').message) } finally { inventoryLoading.value = false } } +async function loadReturnCandidates() { if (!selectedCustomerId.value || !selectedWarehouseId.value) return; inventoryLoading.value = true; try { const [candidates, stock, cartons] = await Promise.all([listReturnOutboundCandidates(selectedCustomerId.value, selectedWarehouseId.value), listReturnStock({ page: 1, size: 200, customerId: selectedCustomerId.value, overseasWarehouseId: selectedWarehouseId.value, status: 'AVAILABLE' }), listReturnCartons({ page: 1, size: 200, customerId: selectedCustomerId.value, overseasWarehouseId: selectedWarehouseId.value, status: 'AVAILABLE' })]); returnCandidates.value = candidates; returnStockRows.value = stock.records; returnCartonRows.value = cartons.records; const details = await Promise.all(cartons.records.map(async (row) => [row.cartonMark, await getReturnCarton(row.cartonMark)] as const)); returnCartonDetails.value = Object.fromEntries(details); selectedReturnMarks.value = [] } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '客户库存加载失败').message) } finally { inventoryLoading.value = false } } function dropshipCandidateKey(item: OneClickOutboundCandidate) { return `${item.inventoryPositionId}:${item.skuCode || ''}` } function isDropshipSelected(item: OneClickOutboundCandidate) { return selectedDropshipIds.value.includes(dropshipCandidateKey(item)) } function toggleDropshipCandidate(item: OneClickOutboundCandidate, checked: boolean | string | number) { const key = dropshipCandidateKey(item); selectedDropshipIds.value = checked ? [...new Set([...selectedDropshipIds.value, key])] : selectedDropshipIds.value.filter(value => value !== key) } @@ -196,15 +175,9 @@ 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 ? [...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) - 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!) @@ -226,24 +199,13 @@ 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') { - 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 - } - } + // Return outbound is physical-unit based. Partial SKU quantities must go + // through the PDA packing flow and produce a new labelled carton first. if (!selectedReturnMarks.value.length) { - ElMessage.warning(returnPickMode.value === 'SKU' - ? filteredReturnCartons.value.length ? '请在右侧选择该 SKU 的散件来源记录' : '该退货单没有此 SKU 的可用散件库存;箱内库存请按箱/托出库' - : '请选择出库箱号或托盘') + ElMessage.warning('请选择出库包裹/箱号或托盘') return } - 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()}` }) + await createReturnOutbound(selectedCustomerId.value, { recipient: recipient.value.trim(), overseasWarehouseId: selectedWarehouseId.value, returnNo: selectedReturnNo.value, cartonMarks: selectedReturnMarks.value, unitType: 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))) @@ -363,21 +325,21 @@ onMounted(load) -

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

箱 / 托单 SKU - 按箱号出库按托盘出库 -
{{ code }}
+

整包裹、整箱或整托出库。包裹或箱子只出部分商品时,请先在退货合箱作业生成新箱并贴标,再选择新箱出库。

+ 按包裹 / 箱号出库按托盘出库 +
{{ code }}
SKU库存按 SKU 查询
查询
- -
{{ returnPickMode === 'SKU' ? `已选择 ${selectedReturnSkus.length} 个 SKU` : 'SKU 库存' }}
+ +
SKU 库存
-
{{ returnPickMode === 'SKU' ? 'SKU 来源明细' : '箱号列表' }}支持多个箱号或 SKU,用英文逗号分隔
+
包裹 / 箱号列表支持多个箱号或 SKU,用英文逗号分隔
查询
- -
已选择 {{ selectedReturnMarks.length }} 个{{ returnPickMode === 'SKU' ? '来源' : '箱号' }}
+ +
已选择 {{ selectedReturnMarks.length }} 个包裹 / 箱
diff --git a/src/modules/overseas-warehouse/pages/ReturnInventoryManagementView.test.ts b/src/modules/overseas-warehouse/pages/ReturnInventoryManagementView.test.ts index ccb7ddd6..a0274faa 100644 --- a/src/modules/overseas-warehouse/pages/ReturnInventoryManagementView.test.ts +++ b/src/modules/overseas-warehouse/pages/ReturnInventoryManagementView.test.ts @@ -5,7 +5,7 @@ import ElementPlus from 'element-plus' import DataTable from '@/shared/components/DataTable.vue' import { UI_PREFERENCE_SCOPE, preferenceStorageKey } from '@/shared/utils/uiLayoutPreference' import { getReturnCarton, listReturnCartons, listReturnInventoryMovements, listReturnStock, listReturnPackingSources } from '../api/returnInventory' -import { listReturnPackingPlans } from '../api/relabelOrders' +import { createReturnPackingPlan, listReturnPackingPlans } from '../api/relabelOrders' import ReturnInventoryManagementView from './ReturnInventoryManagementView.vue' import { listPlatformTenants } from '@/modules/iam' const routeQuery = vi.hoisted(() => ({} as Record)) @@ -56,6 +56,29 @@ afterEach(() => { }) describe('ReturnInventoryManagementView shared list', () => { + it('keeps partial quantities bound to each physical parcel and box when they share a SKU', async () => { + vi.mocked(listReturnPackingSources).mockResolvedValue({ records: [ + { ...carton, cartonMark: 'PARCEL-A', sourceUnitType: 'PARCEL', sourceReturnNo: 'RETURN-1', items: [{ skuCode: 'SKU-A', quantity: 5, availableQuantity: 5 }] }, + { ...carton, cartonMark: 'BOX-A', sourceUnitType: 'BOX', sourceReturnNo: 'RETURN-1', items: [{ skuCode: 'SKU-A', quantity: 4, availableQuantity: 4 }] }, + ], page: 1, size: 200, total: 2, pages: 1 }) + const wrapper = await mountView('inventory') + await wrapper.findAll('button').find(button => button.text().includes('创建合箱计划'))!.trigger('click'); await flushPromises() + const form = wrapper.findAllComponents({ name: 'ElForm' }).find(form => form.classes().includes('packing-form'))! + const customer = form.getComponent({ name: 'ElSelect' }) + customer.vm.$emit('update:modelValue', 'customer-1'); customer.vm.$emit('change', 'customer-1'); await flushPromises() + const mode = form.getComponent({ name: 'ElRadioGroup' }) + mode.vm.$emit('update:modelValue', 'SKU'); mode.vm.$emit('change', 'SKU'); await flushPromises() + const rows = form.findAll('.packing-sku-row') + expect(rows).toHaveLength(2) + rows.forEach(row => row.getComponent({ name: 'ElCheckbox' }).vm.$emit('change', true)); await flushPromises() + rows[1]!.getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 2); await flushPromises() + wrapper.findAllComponents({ name: 'AppDialog' }).find(dialog => dialog.props('modelValue'))!.vm.$emit('confirm'); await flushPromises() + expect(createReturnPackingPlan).toHaveBeenCalledWith(expect.objectContaining({ + mode: 'SKU', sourceCartonMarks: ['PARCEL-A', 'BOX-A'], + sourceDeductions: [{ cartonMark: 'PARCEL-A', skuCode: 'SKU-A', quantity: 1 }, { cartonMark: 'BOX-A', skuCode: 'SKU-A', quantity: 2 }], + boxes: [{ items: [{ skuCode: 'SKU-A', quantity: 3 }] }], + })) + }) it('prefills the selected return orders after loading sources from the tail-order entry', async () => { Object.assign(routeQuery, { packing: '1', customerId: 'customer-1', overseasWarehouseId: 'warehouse-1', returnNos: 'RETURN-1' }) vi.mocked(listReturnPackingSources).mockResolvedValue({ records: [{ ...carton, sourceReturnNo: 'RETURN-1', sourceUnitType: 'BOX', items: [{ skuCode: 'SKU-TEST', quantity: 8, availableQuantity: 8 }] }], page: 1, size: 200, total: 1, pages: 1 }) @@ -248,3 +271,11 @@ describe('ReturnInventoryManagementView shared list', () => { expect(document.body.textContent).toContain('SKU-TEST') }) }) + +it('箱列表分别显示后端实际完成量与未完成申请占用量', async () => { + vi.mocked(listReturnCartons).mockResolvedValue({ records: [{ ...carton, relabelStatus: 'PARTIALLY_COMPLETED', relabelledQuantity: 3, relabelReservedQuantity: 2 }], page: 1, size: 20, total: 1, pages: 1 }) + const wrapper = await mountView('cartons') + expect(wrapper.text()).toContain('部分完成') + expect(wrapper.text()).toContain('实际完成 3 件') + expect(wrapper.text()).toContain('处理中占用 2 件') +}) diff --git a/src/modules/overseas-warehouse/pages/ReturnInventoryManagementView.vue b/src/modules/overseas-warehouse/pages/ReturnInventoryManagementView.vue index 75969f9b..9fbfa53d 100644 --- a/src/modules/overseas-warehouse/pages/ReturnInventoryManagementView.vue +++ b/src/modules/overseas-warehouse/pages/ReturnInventoryManagementView.vue @@ -1,4 +1,5 @@