import { flushPromises, shallowMount } from '@vue/test-utils' import { beforeEach, describe, expect, it, vi } from 'vitest' import { ElMessageBox } from 'element-plus' import { ApiResponseError } from '@/shared/api/http' import { listPlatformUnifiedOrders } from '@/modules/unified-orders/api/unifiedOrders' import { cancelPlatformCoLoadGroup, createPlatformCoLoadGroup, getPlatformCoLoadGroupByInboundCode } from '@/modules/unified-orders/api/coLoadGroups' import UnifiedOrdersView from './UnifiedOrdersView.vue' vi.mock('vue-router', () => ({ useRoute: () => ({ params: {}, query: {} }), useRouter: () => ({ push: vi.fn() }) })) vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) })) vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn().mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 }) })) vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn().mockResolvedValue([]) })) vi.mock('@/modules/inbounds', () => ({ downloadWeightImage: vi.fn(), getInboundMeasurement: vi.fn() })) vi.mock('@/modules/unified-orders/workflows', () => ({ UnifiedOrderWorkflowPanel: { template: '
' } })) vi.mock('@/modules/unified-orders/api/unifiedOrders', () => ({ addPlatformUnifiedOrderNote: vi.fn(), approvePlatformUnifiedOrder: vi.fn(), batchApprovePlatformUnifiedOrders: vi.fn(), getPlatformUnifiedOrder: vi.fn(), listPlatformUnifiedOrderFeeDetails: vi.fn(), listPlatformUnifiedOrders: vi.fn(), unifiedOrderStatusOptions: [{ value: 'WAIT_SPLIT', label: '待分柜' }], })) vi.mock('@/modules/unified-orders/api/coLoadGroups', () => ({ cancelPlatformCoLoadGroup: vi.fn(), createPlatformCoLoadGroup: vi.fn(), getPlatformCoLoadGroupByInboundCode: vi.fn(), })) const rows = ['5001', '5002'].map((orderId, index) => ({ orderId, tenantId: `600${index}`, orderNo: `ORD-${index + 1}`, mainStatus: 'WAIT_SPLIT' as const, nextAction: null, relation: null, internalOrderStatus: 'WAIT_SPLIT', customerId: `600${index}`, customerName: `客户${index + 1}`, channelCode: 'NY_EXX', channelName: '纽约渠道', receivingWarehouseId: '8', receivingWarehouseName: '义乌仓', destinationWarehouseId: '9', destinationWarehouseName: 'ONT8', inboundId: `700${index}`, inboundVersion: 1, inboundCode: `IN-${index + 1}`, expectedShipDate: null, totalPackageCount: 5, locationCodes: null, actualGrossWeightKg: 10, actualVolumeCbm: 1, measurement: null, preload: null, loading: null, delivery: null, transport: null, createdAt: null, updatedAt: null, expectedArrivalAt: null, feeReviewStatus: null, requestedPickupAt: null, totalPalletCount: null, chargeableVolumeCbm: null, volumetricWeightKg: null, totalNetWeightKg: null, measurementCompletedAt: null, inboundCompletedAt: null, expressCompany: null, expressTrackingNo: null, skuNos: null, totalQuantity: null, shipmentId: null, referenceId: null, coLoadGroup: null, })) function mountView() { return shallowMount(UnifiedOrdersView, { global: { directives: { loading: () => undefined }, stubs: { AdminPage: { template: '
' }, ListPageLayout: { template: '
' }, QueryToolbar: { template: '
' }, DataTable: { name: 'DataTable', props: ['data'], emits: ['selection-change'], template: '
' }, AppDialog: { name: 'AppDialog', props: ['modelValue', 'confirmText', 'confirmDisabled', 'confirmLoading'], emits: ['confirm', 'update:modelValue'], methods: { close() { this.$emit('update:modelValue', false) } }, template: '
' }, ApiErrorMeta: { props: ['details'], template: '
{{ details.code }} {{ details.requestId }} {{ details.traceId }}
' }, CopyButton: { props: ['value', 'label'], template: '' }, 'el-button': { props: ['disabled', 'loading'], emits: ['click'], template: '' }, 'el-tag': { template: '' }, 'el-alert': { props: ['title'], template: '
{{ title }}
' }, } }, }) } describe('UnifiedOrdersView co-load group behavior', () => { beforeEach(() => { vi.clearAllMocks() vi.mocked(listPlatformUnifiedOrders).mockResolvedValue({ records: rows, page: 1, size: 20, total: 2, pages: 1 }) vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue({ action: 'confirm', value: '' } as never) }) it('keeps both selected orders and backend tracing evidence when create validation fails', async () => { vi.mocked(createPlatformCoLoadGroup).mockRejectedValue(new ApiResponseError('成员路线不一致', 'A0409', 409, undefined, 'request-coload', 'trace-coload')) const wrapper = mountView() await flushPromises() await wrapper.get('[data-testid="select"]').trigger('click') await wrapper.findAll('button').find((button) => button.text().includes('设为同柜不可拆组'))!.trigger('click') await wrapper.get('[data-testid="dialog-confirm"]').trigger('click') await flushPromises() expect(createPlatformCoLoadGroup).toHaveBeenCalledWith(['5001', '5002']) expect(wrapper.text()).toContain('设为同柜不可拆组(2)') expect(wrapper.text()).toContain('成员路线不一致') expect(wrapper.text()).toContain('A0409 request-coload trace-coload') expect(wrapper.text()).not.toContain('5001') expect(wrapper.text()).not.toContain('5002') expect(listPlatformUnifiedOrders).toHaveBeenCalledTimes(1) }) it.each([ ['平台收货仓不同', [{ ...rows[0], receivingWarehouseId: '8', receivingWarehouseName: '义乌仓' }, { ...rows[1], receivingWarehouseId: '9', receivingWarehouseName: '东莞仓' }], '所选订单的平台收货仓不同:IN-1(仓库:义乌仓;渠道:纽约渠道)、IN-2(仓库:东莞仓;渠道:纽约渠道),不能设为同柜组'], ['渠道不同', [{ ...rows[0], channelCode: 'NY_EXX', channelName: '纽约渠道' }, { ...rows[1], channelCode: 'LA_SEA', channelName: '洛杉矶渠道' }], '所选订单的渠道不同:IN-1(仓库:义乌仓;渠道:纽约渠道)、IN-2(仓库:义乌仓;渠道:洛杉矶渠道),不能设为同柜组'], ['已有同柜组', [rows[0], { ...rows[1], coLoadGroup: { status: 'ACTIVE', memberCount: 2, memberInboundCodes: ['IN-2', 'IN-3'] } }], '所选订单中已有同柜组,请先解除原同柜组'], ])('blocks create immediately when %s', async (_case, selectedRows, reason) => { vi.mocked(listPlatformUnifiedOrders).mockResolvedValue({ records: selectedRows, page: 1, size: 20, total: 2, pages: 1 }) const wrapper = mountView() await flushPromises() await wrapper.get('[data-testid="select"]').trigger('click') const createButton = wrapper.findAll('button').find((button) => button.text().includes('设为同柜不可拆组'))! expect(createButton.attributes('disabled')).toBeDefined() expect(wrapper.text()).toContain(reason) await createButton.trigger('click') expect(createPlatformCoLoadGroup).not.toHaveBeenCalled() }) it.each([ ['平台收货仓', [{ ...rows[0], receivingWarehouseId: null, receivingWarehouseName: null }, rows[1]], '以下入仓码缺少平台收货仓:IN-1(仓库:未配置仓库;渠道:纽约渠道),请刷新后重试'], ['渠道', [{ ...rows[0], channelCode: null, channelName: null }, rows[1]], '以下入仓码缺少渠道:IN-1(仓库:义乌仓;渠道:未配置渠道),请刷新后重试'], ])('lists the affected inbound code and display names when %s is missing', async (_case, selectedRows, reason) => { vi.mocked(listPlatformUnifiedOrders).mockResolvedValue({ records: selectedRows, page: 1, size: 20, total: 2, pages: 1 }) const wrapper = mountView() await flushPromises() await wrapper.get('[data-testid="select"]').trigger('click') expect(wrapper.text()).toContain(reason) expect(createPlatformCoLoadGroup).not.toHaveBeenCalled() }) it('allows different display names for the same channel code', async () => { vi.mocked(listPlatformUnifiedOrders).mockResolvedValue({ records: [rows[0], { ...rows[1], channelName: '纽约渠道(新名称)' }], page: 1, size: 20, total: 2, pages: 1 }) vi.mocked(createPlatformCoLoadGroup).mockResolvedValue({ groupId: '9001', groupNo: 'CLG-1', status: 'ACTIVE', warehouseId: '8', channelCode: 'NY_EXX', lockedPlanId: null, version: 1, members: [], fullPackageCount: 10, createdAt: '', updatedAt: '' }) const wrapper = mountView() await flushPromises() await wrapper.get('[data-testid="select"]').trigger('click') await wrapper.findAll('button').find((button) => button.text().includes('设为同柜不可拆组'))!.trigger('click') await wrapper.get('[data-testid="dialog-confirm"]').trigger('click') await flushPromises() expect(createPlatformCoLoadGroup).toHaveBeenCalledWith(['5001', '5002']) }) it('blocks identical display names when channel codes differ', async () => { vi.mocked(listPlatformUnifiedOrders).mockResolvedValue({ records: [rows[0], { ...rows[1], channelCode: 'LA_SEA', channelName: '纽约渠道' }], page: 1, size: 20, total: 2, pages: 1 }) const wrapper = mountView() await flushPromises() await wrapper.get('[data-testid="select"]').trigger('click') expect(wrapper.text()).toContain('所选订单的渠道不同:IN-1(仓库:义乌仓;渠道:纽约渠道)、IN-2(仓库:义乌仓;渠道:纽约渠道),不能设为同柜组') expect(createPlatformCoLoadGroup).not.toHaveBeenCalled() }) it('uses inbound codes as the only row identifier and shows an explicit pending state', async () => { vi.mocked(listPlatformUnifiedOrders).mockResolvedValue({ records: [rows[0], { ...rows[1], inboundCode: null }], page: 1, size: 20, total: 2, pages: 1 }) const wrapper = mountView() await flushPromises() expect(wrapper.text()).toContain('IN-1') expect(wrapper.text()).toContain('待生成入仓码') expect(wrapper.text()).not.toContain('ORD-1') expect(wrapper.text()).not.toContain('ORD-2') expect(wrapper.text()).not.toContain('5001') expect(wrapper.text()).not.toContain('7000') expect(wrapper.text()).not.toContain('履约详情') expect(wrapper.text()).not.toContain('关联数据') }) it('shows a business-only group summary and all server-provided inbound codes in the dialog', async () => { const rowsWithGroup = rows.map((row) => ({ ...row, coLoadGroup: { status: 'ACTIVE', memberCount: 2, memberInboundCodes: ['IN-1', 'IN-1-B', 'IN-2'] } })) vi.mocked(listPlatformUnifiedOrders).mockResolvedValue({ records: rowsWithGroup, page: 1, size: 20, total: 2, pages: 1 }) vi.mocked(getPlatformCoLoadGroupByInboundCode).mockResolvedValue({ status: 'ACTIVE', memberCount: 3, members: [{ inboundCode: 'IN-1' }, { inboundCode: 'IN-1-B' }, { inboundCode: 'IN-2' }] }) const wrapper = mountView() await flushPromises() expect(wrapper.text()).toContain('同柜组 · 2 票 · 未锁定') expect(wrapper.text()).not.toContain('CLG-9001') await wrapper.get('[aria-label="查看同柜组 · 2 票 · 未锁定成员"]').trigger('click') await flushPromises() expect(getPlatformCoLoadGroupByInboundCode).toHaveBeenCalledWith('IN-1') expect(wrapper.text()).toContain('3 个成员') expect(wrapper.text()).toContain('IN-1') expect(wrapper.text()).toContain('IN-1-B') expect(wrapper.text()).toContain('IN-2') expect(wrapper.text()).not.toContain('ORD-1') expect(wrapper.text()).not.toContain('ORD-2') expect(wrapper.text()).not.toContain('7003') expect(wrapper.text()).not.toContain('5001') }) it('cancels an ACTIVE group by the clicked member inbound code and preserves dialog error evidence', async () => { const rowsWithGroup = rows.map((row) => ({ ...row, coLoadGroup: { status: 'ACTIVE', memberCount: 2, memberInboundCodes: ['IN-1', 'IN-2'] } })) vi.mocked(listPlatformUnifiedOrders).mockResolvedValue({ records: rowsWithGroup, page: 1, size: 20, total: 2, pages: 1 }) vi.mocked(getPlatformCoLoadGroupByInboundCode).mockResolvedValue({ status: 'ACTIVE', memberCount: 2, members: [{ inboundCode: 'IN-1' }, { inboundCode: 'IN-2' }] }) vi.mocked(cancelPlatformCoLoadGroup).mockRejectedValueOnce(new ApiResponseError('组已被并发锁定', 'A0409', 409, undefined, 'request-cancel', 'trace-cancel')) const wrapper = mountView() await flushPromises() await wrapper.get('[aria-label="查看同柜组 · 2 票 · 未锁定成员"]').trigger('click') await flushPromises() const cancelButton = wrapper.findAll('button').find((button) => button.text() === '解除同柜组')! expect(cancelButton.exists()).toBe(true) await cancelButton.trigger('click') await flushPromises() expect(ElMessageBox.confirm).toHaveBeenCalledWith(expect.stringContaining('解除整个同柜组'), '解除同柜组', expect.objectContaining({ confirmButtonText: '确认解除' })) expect(cancelPlatformCoLoadGroup).toHaveBeenCalledWith('IN-1', '重新调整同柜组成员') expect(wrapper.text()).toContain('组已被并发锁定') expect(wrapper.text()).toContain('A0409 request-cancel trace-cancel') expect(wrapper.findAllComponents({ name: 'AppDialog' }).some((dialog) => dialog.props('modelValue') === true)).toBe(true) expect(wrapper.text()).not.toContain('CLG-') expect(wrapper.text()).not.toContain('5001') }) it('closes and refreshes after one successful whole-group cancellation request', async () => { const rowsWithGroup = rows.map((row) => ({ ...row, coLoadGroup: { status: 'ACTIVE', memberCount: 2, memberInboundCodes: ['IN-1', 'IN-2'] } })) vi.mocked(listPlatformUnifiedOrders).mockResolvedValue({ records: rowsWithGroup, page: 1, size: 20, total: 2, pages: 1 }) vi.mocked(getPlatformCoLoadGroupByInboundCode).mockResolvedValue({ status: 'ACTIVE', memberCount: 2, members: [{ inboundCode: 'IN-1' }, { inboundCode: 'IN-2' }] }) vi.mocked(cancelPlatformCoLoadGroup).mockResolvedValue({} as never) const wrapper = mountView() await flushPromises() await wrapper.get('[aria-label="查看同柜组 · 2 票 · 未锁定成员"]').trigger('click') await flushPromises() await wrapper.findAll('button').find((button) => button.text() === '解除同柜组')!.trigger('click') await flushPromises() expect(cancelPlatformCoLoadGroup).toHaveBeenCalledTimes(1) expect(cancelPlatformCoLoadGroup).toHaveBeenCalledWith('IN-1', '重新调整同柜组成员') expect(listPlatformUnifiedOrders).toHaveBeenCalledTimes(2) expect(wrapper.findAllComponents({ name: 'AppDialog' }).every((dialog) => dialog.props('modelValue') !== true)).toBe(true) }) it.each(['LOCKED', 'CONSUMED'])('does not offer group cancellation for %s', async (status) => { const rowsWithGroup = rows.map((row) => ({ ...row, coLoadGroup: { status, memberCount: 2, memberInboundCodes: ['IN-1', 'IN-2'] } })) vi.mocked(listPlatformUnifiedOrders).mockResolvedValue({ records: rowsWithGroup, page: 1, size: 20, total: 2, pages: 1 }) vi.mocked(getPlatformCoLoadGroupByInboundCode).mockResolvedValue({ status: status as 'LOCKED' | 'CONSUMED', memberCount: 2, members: [{ inboundCode: 'IN-1' }, { inboundCode: 'IN-2' }] }) const wrapper = mountView() await flushPromises() await wrapper.get(`[aria-label="查看同柜组 · 2 票 · ${status === 'LOCKED' ? '已锁定' : '已消费'}成员"]`).trigger('click') await flushPromises() expect(wrapper.findAll('button').some((button) => button.text() === '解除同柜组')).toBe(false) expect(cancelPlatformCoLoadGroup).not.toHaveBeenCalled() }) it.each([ ['undefined', undefined], ['null', null], ['blank', ' '], ])('keeps the member dialog readable when a normalized inbound code is %s', async (_case, inboundCode) => { const rowsWithGroup = rows.map((row) => ({ ...row, coLoadGroup: { status: 'ACTIVE', memberCount: 2, memberInboundCodes: ['IN-1', 'IN-2'] } })) vi.mocked(listPlatformUnifiedOrders).mockResolvedValue({ records: rowsWithGroup, page: 1, size: 20, total: 2, pages: 1 }) vi.mocked(getPlatformCoLoadGroupByInboundCode).mockResolvedValue({ status: 'ACTIVE', memberCount: 2, members: [{ inboundCode: inboundCode as unknown as string }, { inboundCode: 'IN-2' }] }) const wrapper = mountView() await flushPromises() await wrapper.get('[aria-label="查看同柜组 · 2 票 · 未锁定成员"]').trigger('click') await flushPromises() expect(wrapper.text()).toContain('同柜组成员入仓码尚未返回,请刷新后重试') expect(wrapper.text()).not.toContain('CLG-9001') expect(wrapper.text()).not.toContain('ORD-1') expect(wrapper.text()).not.toContain('5001') }) })