diff --git a/src/modules/overseas-warehouse/api/outbounds.ts b/src/modules/overseas-warehouse/api/outbounds.ts index d7f4b87c..187b7c49 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; 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; 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 0b7b24ab..a93e0cf0 100644 --- a/src/modules/overseas-warehouse/api/relabelOrders.ts +++ b/src/modules/overseas-warehouse/api/relabelOrders.ts @@ -137,7 +137,7 @@ export interface ReturnPackingPlanBox { export interface ReturnPackingPlanPayload { returnNos: string[] - mode: 'BOX' | 'SKU' | 'CUSTOMER_INSTRUCTION' | 'WAREHOUSE_RECOMMENDATION' + mode: 'BOX' | 'SKU' boxes: ReturnPackingPlanBox[] remark?: string idempotencyKey: string diff --git a/src/modules/overseas-warehouse/pages/LastMileOrdersView.behavior.test.ts b/src/modules/overseas-warehouse/pages/LastMileOrdersView.behavior.test.ts index 94db1bc1..2cd7ef0d 100644 --- a/src/modules/overseas-warehouse/pages/LastMileOrdersView.behavior.test.ts +++ b/src/modules/overseas-warehouse/pages/LastMileOrdersView.behavior.test.ts @@ -30,7 +30,8 @@ import LastMileOrdersView from './LastMileOrdersView.vue' const canAccess = vi.hoisted(() => vi.fn<(permissionCode: string) => boolean>(() => true)) vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: canAccess }) })) vi.mock('@/shared/downloadCsv', () => ({ saveCsv: vi.fn() })) -vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) })) +const routerPush = vi.hoisted(() => vi.fn()) +vi.mock('vue-router', () => ({ useRouter: () => ({ push: routerPush }) })) vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn() })) vi.mock('../api/lastMileOrders', () => ({ listLastMileOrders: vi.fn(), listContainerPlanProgress: vi.fn(), getContainerPlanProgress: vi.fn(), @@ -1179,13 +1180,12 @@ describe('LastMileOrdersView pending order and container conversion', () => { it('keeps the return packing entry and excludes return orders from manual inbound generation', async () => { const wrapper = await mountView() - await selectRows(wrapper, [selectedRow({ orderTypeCode: 'RETURN_RELABEL', currentStatusCode: 'IN_WAREHOUSE', sourceOrderNo: 'RETURN-1' })]) + await selectRows(wrapper, [selectedRow({ orderTypeCode: 'RETURN_RELABEL', currentStatusCode: 'INBOUND_COMPLETED', sourceOrderNo: 'RETURN-1' })]) const actions = wrapper.get('.data-table__business-actions') expect(actions.text()).not.toContain('生成入库计划') const packing = actions.findAll('button').find(action => action.text().includes('创建退货合箱计划(1)'))! await packing.trigger('click') await flushPromises() - const dialog = wrapper.findAllComponents(AppDialog).find(candidate => candidate.props('title') === '退货合箱计划')! - expect(dialog.props('modelValue')).toBe(true) + expect(routerPush).toHaveBeenCalledWith({ name: 'overseas-return-inventory', query: expect.objectContaining({ packing: '1', returnNos: 'RETURN-1' }) }) }) }) diff --git a/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue b/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue index 0f1231e6..f73473c0 100644 --- a/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue +++ b/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue @@ -21,7 +21,7 @@ import { businessOrderLink } from '../businessOrderLink' import OrderReferenceText from '../components/OrderReferenceText.vue' import { reviewCargoDiscard } from '../api/lastMileOrders' import WarehouseTransferReviewDialog from '../components/WarehouseTransferReviewDialog.vue' -import { confirmRelabelCompletion, createReturnPackingPlan, getRelabelReviewTask, returnRelabelForRework, type ReturnPackingPlanBox } from '../api/relabelOrders' +import { confirmRelabelCompletion, getRelabelReviewTask, returnRelabelForRework } from '../api/relabelOrders' import { useAccessPreviewStore } from '@/core/access/accessPreview' import { overseasWarehousePermissions as permission } from '../permissions' import { lastMileStatusLabel, packageDifferenceText, toLastMileOrderListItem } from '../lastMilePresentation' @@ -49,34 +49,20 @@ const containerDetail = ref(null) const transferReviewOpen = ref(false) const transferReviewTarget = ref(null) const selectedRows = ref([]) -const packingPlanOpen = ref(false) -const packingPlanLoading = ref(false) -const packingPlanMode = ref<'CUSTOMER_INSTRUCTION' | 'WAREHOUSE_RECOMMENDATION'>('CUSTOMER_INSTRUCTION') -const packingPlanRemark = ref('') -const packingPlanBoxes = ref([]) -const selectedReturnRows = computed(() => selectedRows.value.filter((row) => row.orderTypeCode === 'RETURN_RELABEL' && ['COMPLETED', 'RECEIVED', 'IN_WAREHOUSE', '已完成入库'].includes(String(row.currentStatusCode || row.status || '')))) +const selectedReturnRows = computed(() => selectedRows.value.filter((row) => row.orderTypeCode === 'RETURN_RELABEL' && ['INBOUND_COMPLETED', 'COMPLETED', 'RECEIVED', 'IN_WAREHOUSE', '已完成入库'].includes(String(row.currentStatusCode || row.status || '')))) function openReturnPackingPlan() { - if (!selectedReturnRows.value.length) { ElMessage.warning('请勾选已入库的退货订单'); return } - packingPlanMode.value = 'CUSTOMER_INSTRUCTION'; packingPlanRemark.value = '' - packingPlanBoxes.value = [{ boxNo: 1, items: [{ skuCode: '', quantity: 0 }] }] - packingPlanOpen.value = true + const selected = selectedReturnRows.value + if (!selected.length) { ElMessage.warning('请勾选已入库的退货订单'); return } + if (new Set(selected.map(row => row.customerId)).size !== 1 || new Set(selected.map(row => row.overseasWarehouseId)).size !== 1) { + ElMessage.warning('合箱请选择同一客户、同一海外仓的退货订单'); return + } + void router.push({ name: 'overseas-return-inventory', query: { + packing: '1', customerId: selected[0]!.customerId, overseasWarehouseId: selected[0]!.overseasWarehouseId, + returnNos: selected.map(row => row.sourceOrderNo || row.orderNo).join(','), + } }) } -function addReturnPackingBox() { packingPlanBoxes.value.push({ boxNo: packingPlanBoxes.value.length + 1, items: [{ skuCode: '', quantity: 0 }] }) } -function removeReturnPackingBox(index: number) { if (packingPlanBoxes.value.length > 1) packingPlanBoxes.value.splice(index, 1) } -function addReturnPackingItem(box: ReturnPackingPlanBox) { box.items.push({ skuCode: '', quantity: 0 }) } -function removeReturnPackingItem(box: ReturnPackingPlanBox, index: number) { if (box.items.length > 1) box.items.splice(index, 1) } -async function submitReturnPackingPlan() { - const boxes = packingPlanBoxes.value.map((box) => ({ ...box, items: box.items.filter((item) => item.skuCode.trim() && Number(item.quantity) > 0) })).filter((box) => box.items.length) - if (!boxes.length) { ElMessage.warning('至少配置一个目标箱'); return } - packingPlanLoading.value = true - try { - const result = await createReturnPackingPlan({ returnNos: selectedReturnRows.value.map((row) => String(row.sourceOrderNo || row.orderNo)).filter(Boolean), mode: packingPlanMode.value, boxes, remark: packingPlanRemark.value.trim() || undefined, idempotencyKey: globalThis.crypto?.randomUUID?.() || `return-packing-${Date.now()}` }) - ElMessage.success(`合箱计划 ${result.planNo} 已推送 PDA`); packingPlanOpen.value = false; selectedRows.value = [] - } catch (error) { ElMessage.error(getApiErrorDetails(error, '合箱计划推送失败,请重试').message) } - finally { packingPlanLoading.value = false } -} -const actionLoading = ref(false) const exportLoading = ref(false) +const actionLoading = ref(false) async function exportOrders() { exportLoading.value = true; try { await submitExport('LAST_MILE_ORDER_LIST', '尾程订单', orderQuery()) } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '尾程订单导出失败,请重试').message) } finally { exportLoading.value = false } } const actionError = ref(null) const actionTarget = ref(null) @@ -1132,7 +1118,6 @@ onMounted(() => { void load() }) - 客户合箱指令仓库自主合箱
第 {{ index + 1 }} 箱删除箱
移除
+ 添加 SKU
+ 添加箱
diff --git a/src/modules/overseas-warehouse/pages/OutboundsView.returnSku.test.ts b/src/modules/overseas-warehouse/pages/OutboundsView.returnSku.test.ts new file mode 100644 index 00000000..84a31009 --- /dev/null +++ b/src/modules/overseas-warehouse/pages/OutboundsView.returnSku.test.ts @@ -0,0 +1,91 @@ +import ElementPlus, { ElCheckbox, ElInputNumber, ElSelect } from 'element-plus' +import { mount, flushPromises, type VueWrapper } from '@vue/test-utils' +import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest' +import OutboundsView from './OutboundsView.vue' +const api = vi.hoisted(() => ({ create: vi.fn(), stock: vi.fn(), cartons: vi.fn(), detail: vi.fn() })) +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: [{ id: 'C1', tenantName: '客户一' }], total: 1, pages: 1 }) })) +vi.mock('@/modules/warehouses', () => ({ listPlatformWarehouses: vi.fn().mockResolvedValue({ records: [{ id: 'W1', warehouseName: '洛杉矶仓', warehouseCode: 'US-LAX-01', warehouseType: 'OVERSEAS', status: 'ACTIVE' }] }) })) +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: 'PARCEL', sourceUnitType: 'PARCEL', sourceReturnNo: 'RT-1', status: 'AVAILABLE', items: [{ skuCode: 'SKU-B', quantity: 6, availableQuantity: 6 }] }, + { 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 }] }, +] +let wrapper: VueWrapper +beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }) + vi.stubGlobal('localStorage', { getItem: () => null, setItem: () => undefined, removeItem: () => undefined }) + api.stock.mockResolvedValue({ records: [{ skuCode: 'SKU-A', availableQuantity: 113 }, { skuCode: 'SKU-B', availableQuantity: 8 }], total: 2 }) + api.cartons.mockResolvedValue({ records: sources, total: sources.length }) + api.detail.mockImplementation(async mark => sources.find(row => row.cartonMark === mark)) + api.create.mockResolvedValue({}) +}) +afterEach(() => { wrapper?.unmount(); vi.unstubAllGlobals() }) +async function open(sku: string) { + wrapper = mount(OutboundsView, { global: { plugins: [ElementPlus], stubs: { AppDialog: { props: ['modelValue'], emits: ['confirm'], template: '
' } } } }) + await flushPromises() + await wrapper.findAll('button').find(button => button.text().includes('新建出库单'))!.trigger('click'); await flushPromises() + 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') === '请选择客户')! + customer.vm.$emit('update:modelValue', 'C1'); customer.vm.$emit('change', 'C1'); await flushPromises() + 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[]) { + for (const mark of marks) right().findAllComponents(ElCheckbox).find(check => check.element.closest('tr')?.textContent?.includes(mark === 'PARCEL' ? '包裹散件(按 SKU 拣货)' : 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 () => { + await open('SKU-A') + expect(right().props('data').map((row: { cartonMark: string }) => row.cartonMark)).toEqual(['BOX-A', 'BOX-B']) + expect(wrapper.text()).not.toContain('OTHER-ORDER') + await selectMarks(['BOX-A', 'BOX-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'] })) + }) + it('submits six pieces from two five-piece boxes', async () => { + await open('SKU-A'); await selectMarks(['BOX-A', 'BOX-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 })) + }) + 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'] })) + }) + 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,10]])('clamps input %s to the allowed bound %s', async (quantity, expected) => { + await open('SKU-A'); await selectMarks(['BOX-A', 'BOX-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']) + 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() + }) +}) diff --git a/src/modules/overseas-warehouse/pages/OutboundsView.vue b/src/modules/overseas-warehouse/pages/OutboundsView.vue index 3f087d6b..2985199d 100644 --- a/src/modules/overseas-warehouse/pages/OutboundsView.vue +++ b/src/modules/overseas-warehouse/pages/OutboundsView.vue @@ -1,6 +1,6 @@