diff --git a/src/modules/unified-orders/api/coLoadGroups.test.ts b/src/modules/unified-orders/api/coLoadGroups.test.ts index 5cf365b1..23242413 100644 --- a/src/modules/unified-orders/api/coLoadGroups.test.ts +++ b/src/modules/unified-orders/api/coLoadGroups.test.ts @@ -25,12 +25,12 @@ describe('co-load group API', () => { expect(result.members.map((member) => member.tenantId)).toEqual(['2093000000000000004', '2093000000000000007']) }) - it('cancels only the whole group with optimistic version and an optional reason', async () => { + it('cancels the whole group by its visible inbound code without exposing group identifiers', async () => { const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', message: 'ok', data: { ...group, status: 'CANCELLED', version: 1 } }) - await cancelPlatformCoLoadGroup('2093000000000000001', 0, '路线调整') + await cancelPlatformCoLoadGroup('NYEXX2608310001', '路线调整') expect(post).toHaveBeenCalledWith( - '/platform/v1/first-leg/coload-groups/2093000000000000001/cancel', - { expectedVersion: 0, reason: '路线调整' }, + '/platform/v1/first-leg/coload-groups/by-inbound-code/NYEXX2608310001/cancel', + { reason: '路线调整' }, { headers: { 'Idempotency-Key': expect.any(String) } }, ) }) diff --git a/src/modules/unified-orders/api/coLoadGroups.ts b/src/modules/unified-orders/api/coLoadGroups.ts index df3844e0..051b290c 100644 --- a/src/modules/unified-orders/api/coLoadGroups.ts +++ b/src/modules/unified-orders/api/coLoadGroups.ts @@ -81,10 +81,10 @@ export async function createPlatformCoLoadGroup(orderIds: string[]) { return normalizeGroup(unwrapApiResponse(response)) } -export async function cancelPlatformCoLoadGroup(groupId: string, expectedVersion: number, reason?: string | null) { +export async function cancelPlatformCoLoadGroup(inboundCode: string, reason?: string | null) { const response = await http.post, ApiEnvelope>( - `${basePath}/${encodeURIComponent(groupId)}/cancel`, - { expectedVersion, reason: reason?.trim() || null }, + `${basePath}/by-inbound-code/${encodeURIComponent(inboundCode)}/cancel`, + { reason: reason?.trim() || null }, { headers: { 'Idempotency-Key': operationId('admin-coload-cancel') } }, ) return normalizeGroup(unwrapApiResponse(response)) diff --git a/src/modules/unified-orders/pages/UnifiedOrdersView.coLoad.test.ts b/src/modules/unified-orders/pages/UnifiedOrdersView.coLoad.test.ts index 9778b627..75cacb90 100644 --- a/src/modules/unified-orders/pages/UnifiedOrdersView.coLoad.test.ts +++ b/src/modules/unified-orders/pages/UnifiedOrdersView.coLoad.test.ts @@ -1,8 +1,9 @@ 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 { createPlatformCoLoadGroup, getPlatformCoLoadGroupByInboundCode } from '@/modules/unified-orders/api/coLoadGroups' +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() }) })) @@ -17,7 +18,7 @@ vi.mock('@/modules/unified-orders/api/unifiedOrders', () => ({ unifiedOrderStatusOptions: [{ value: 'WAIT_SPLIT', label: '待分柜' }], })) vi.mock('@/modules/unified-orders/api/coLoadGroups', () => ({ - createPlatformCoLoadGroup: vi.fn(), getPlatformCoLoadGroupByInboundCode: vi.fn(), + cancelPlatformCoLoadGroup: vi.fn(), createPlatformCoLoadGroup: vi.fn(), getPlatformCoLoadGroupByInboundCode: vi.fn(), })) const rows = ['5001', '5002'].map((orderId, index) => ({ @@ -38,10 +39,10 @@ function mountView() { ListPageLayout: { template: '
' }, QueryToolbar: { template: '
' }, DataTable: { name: 'DataTable', props: ['data'], emits: ['selection-change'], template: '
' }, - AppDialog: { name: 'AppDialog', props: ['modelValue', 'confirmText'], emits: ['confirm', 'update:modelValue'], 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': { template: '' }, + 'el-button': { props: ['disabled', 'loading'], emits: ['click'], template: '' }, 'el-tag': { template: '' }, 'el-alert': { props: ['title'], template: '
{{ title }}
' }, } }, @@ -52,6 +53,7 @@ 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 () => { @@ -69,6 +71,22 @@ describe('UnifiedOrdersView co-load group behavior', () => { expect(listPlatformUnifiedOrders).toHaveBeenCalledTimes(1) }) + it.each([ + ['平台收货仓不同', [{ ...rows[0], receivingWarehouseId: '8' }, { ...rows[1], receivingWarehouseId: '9' }], '所选订单的平台收货仓不同,不能设为同柜组'], + ['渠道不同', [{ ...rows[0], channelName: 'NY-EXX' }, { ...rows[1], channelName: 'LA-SEA' }], '所选订单的渠道不同,不能设为同柜组'], + ['已有同柜组', [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('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() @@ -104,6 +122,59 @@ describe('UnifiedOrdersView co-load group behavior', () => { 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], diff --git a/src/modules/unified-orders/pages/UnifiedOrdersView.test.ts b/src/modules/unified-orders/pages/UnifiedOrdersView.test.ts index 94c1b52b..3fee5bae 100644 --- a/src/modules/unified-orders/pages/UnifiedOrdersView.test.ts +++ b/src/modules/unified-orders/pages/UnifiedOrdersView.test.ts @@ -174,6 +174,10 @@ describe('UnifiedOrdersView contract', () => { expect(createSource).not.toContain('splitStatus') expect(createSource).not.toContain('relation') expect(createSource).toContain('createPlatformCoLoadGroup(selectedOrders.value.map((row) => String(row.orderId)))') + expect(source).toContain(':disabled="loading || !canCreateCoLoadGroup"') + expect(source).toContain('所选订单的平台收货仓不同,不能设为同柜组') + expect(source).toContain('所选订单的渠道不同,不能设为同柜组') + expect(source).toContain('所选订单中已有同柜组,请先解除原同柜组') }) it('uses inbound code as the only list identifier and opens a business-only group dialog', () => { @@ -194,6 +198,10 @@ describe('UnifiedOrdersView contract', () => { expect(source).not.toContain('visibleOrderNo') expect(source).not.toContain('订单编码') expect(source).not.toContain('客户 ID') + expect(source).toContain("coLoadDetailSummary?.status === 'ACTIVE'") + expect(source).toContain('解除同柜组') + expect(source).toContain('cancelPlatformCoLoadGroup(inboundCode') + expect(source).toContain(' { diff --git a/src/modules/unified-orders/pages/UnifiedOrdersView.vue b/src/modules/unified-orders/pages/UnifiedOrdersView.vue index 8ce46415..4146c801 100644 --- a/src/modules/unified-orders/pages/UnifiedOrdersView.vue +++ b/src/modules/unified-orders/pages/UnifiedOrdersView.vue @@ -16,7 +16,7 @@ import { type UnifiedOrderMainStatus, type UnifiedOrderResource, } from '@/modules/unified-orders/api/unifiedOrders' -import { createPlatformCoLoadGroup, getPlatformCoLoadGroupByInboundCode, type CoLoadBusinessSummary, type CoLoadGroup } from '@/modules/unified-orders/api/coLoadGroups' +import { cancelPlatformCoLoadGroup, createPlatformCoLoadGroup, getPlatformCoLoadGroupByInboundCode, type CoLoadBusinessSummary, type CoLoadGroup } from '@/modules/unified-orders/api/coLoadGroups' import OrderFulfillmentTreePanel from '@/modules/unified-orders/components/OrderFulfillmentTreePanel.vue' import InboundFeeDetailManager from '@/modules/unified-orders/components/InboundFeeDetailManager.vue' import { useAccessPreviewStore } from '@/core/access/accessPreview' @@ -131,6 +131,8 @@ const coLoadDetailInboundCode = ref('') const coLoadDetailSummary = ref(null) const coLoadDetailLoading = ref(false) const coLoadDetailError = ref(null) +const coLoadCancelError = ref(null) +const coLoadCancelSaving = ref(false) const coLoadSaving = ref(false) const coLoadError = ref(null) const approvalOrder = ref(null) @@ -813,6 +815,19 @@ function handleOrderSelectionChange(rows: UnifiedOrderResource[]) { selectedApprovalOrders.value = rows.filter(canApprove) } +const coLoadSelectionProblem = computed(() => { + if (selectedOrders.value.length < 2) return null + if (selectedOrders.value.some((row) => row.coLoadGroup)) return '所选订单中已有同柜组,请先解除原同柜组' + const warehouseIds = selectedOrders.value.map((row) => String(row.receivingWarehouseId || '').trim()) + if (warehouseIds.some((value) => !value)) return '所选订单缺少平台收货仓,请刷新后重试' + if (new Set(warehouseIds).size > 1) return '所选订单的平台收货仓不同,不能设为同柜组' + const channels = selectedOrders.value.map((row) => String(row.channelName || '').trim()) + if (channels.some((value) => !value)) return '所选订单缺少渠道,请刷新后重试' + if (new Set(channels).size > 1) return '所选订单的渠道不同,不能设为同柜组' + return null +}) +const canCreateCoLoadGroup = computed(() => selectedOrders.value.length >= 2 && !coLoadSelectionProblem.value) + function coLoadGroup(row: UnifiedOrderResource) { return row.coLoadGroup || null } @@ -860,18 +875,22 @@ function openCoLoadDetail(row: UnifiedOrderResource) { coLoadDetailInboundCode.value = row.inboundCode?.trim() || '' coLoadDetailOpen.value = true coLoadDetailSummary.value = null + coLoadCancelError.value = null coLoadDetailError.value = coLoadDetailInboundCode.value ? null : getApiErrorDetails(new Error('同柜组成员入仓码尚未返回'), '同柜组成员入仓码尚未返回,请刷新后重试') if (coLoadDetailInboundCode.value) void loadCoLoadDetail() } function openCoLoadCreate() { - if (selectedOrders.value.length < 2) return + if (!canCreateCoLoadGroup.value) { + if (coLoadSelectionProblem.value) ElMessage.warning(coLoadSelectionProblem.value) + return + } coLoadError.value = null coLoadCreateOpen.value = true } async function createCoLoadGroup() { - if (selectedOrders.value.length < 2 || coLoadSaving.value) return + if (!canCreateCoLoadGroup.value || coLoadSaving.value) return coLoadSaving.value = true coLoadError.value = null try { @@ -886,6 +905,33 @@ async function createCoLoadGroup() { } } +async function cancelCoLoadGroup() { + const inboundCode = coLoadDetailInboundCode.value + if (!inboundCode || coLoadDetailSummary.value?.status !== 'ACTIVE' || coLoadCancelSaving.value) return + try { + await ElMessageBox.confirm( + '确认解除整个同柜组吗?解除后这些订单可重新选择成员建组;不会拆分订单或子单。', + '解除同柜组', + { confirmButtonText: '确认解除', cancelButtonText: '取消', type: 'warning' }, + ) + } catch (error) { + if (error === 'cancel' || error === 'close') return + throw error + } + coLoadCancelSaving.value = true + coLoadCancelError.value = null + try { + await cancelPlatformCoLoadGroup(inboundCode, '重新调整同柜组成员') + coLoadDetailOpen.value = false + ElMessage.success('同柜组已整组解除') + await loadOrders() + } catch (error) { + coLoadCancelError.value = getApiErrorDetails(error, '解除同柜组失败') + } finally { + coLoadCancelSaving.value = false + } +} + async function batchApproveOrders() { const selected = [...selectedApprovalOrders.value] if (!selected.length || batchApprovalSaving.value) return @@ -1372,7 +1418,10 @@ onBeforeUnmount(() => { - +
{{ row.inboundCode || '待生成入仓码' }}
- + + @@ -1522,6 +1573,8 @@ onBeforeUnmount(() => { .coload-detail-table td strong,.coload-detail-table td span { display: block; overflow-wrap: anywhere; } .coload-detail-table td span { color: var(--cc-muted); } .coload-detail-table :deep(.el-button) { min-height: 24px; padding: 2px 0; } +.coload-toolbar-action { display: flex; min-width: 0; align-items: center; gap: 8px; } +.coload-toolbar-action small { max-width: 360px; overflow: hidden; color: var(--el-color-danger); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; } .subsequent-fulfillment { min-height: 72px; margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--cc-border); } .subsequent-fulfillment h3 { margin: 0 0 10px; font-size: 15px; } .fulfillment-facts { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); margin: 0 0 10px; border: 1px solid var(--cc-border); border-radius: 6px; overflow: hidden; }