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/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 @@