From 1aa2dfcdffecc0e661c094abef57a2e01c791a13 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 29 Aug 2026 10:57:01 +0800 Subject: [PATCH] feat(admin): support indivisible coload groups --- .../api/platformContainerLoading.test.ts | 23 ++- .../api/platformContainerLoading.ts | 20 +++ .../pages/GlobalSmartLoadingView.test.ts | 10 +- .../pages/SmartLoadingWorkbenchView.test.ts | 94 +++++++++++- .../pages/SmartLoadingWorkbenchView.vue | 140 ++++++++++++++++-- src/modules/container-loading/types.ts | 6 + .../unified-orders/api/coLoadGroups.test.ts | 49 ++++++ .../unified-orders/api/coLoadGroups.ts | 100 +++++++++++++ src/modules/unified-orders/index.ts | 9 ++ .../pages/UnifiedOrdersView.coLoad.test.ts | 92 ++++++++++++ .../pages/UnifiedOrdersView.requests.test.ts | 5 + .../pages/UnifiedOrdersView.test.ts | 4 +- .../pages/UnifiedOrdersView.vue | 107 ++++++++++++- 13 files changed, 621 insertions(+), 38 deletions(-) create mode 100644 src/modules/unified-orders/api/coLoadGroups.test.ts create mode 100644 src/modules/unified-orders/api/coLoadGroups.ts create mode 100644 src/modules/unified-orders/pages/UnifiedOrdersView.coLoad.test.ts diff --git a/src/modules/container-loading/api/platformContainerLoading.test.ts b/src/modules/container-loading/api/platformContainerLoading.test.ts index 63c8ec64..f1b5def6 100644 --- a/src/modules/container-loading/api/platformContainerLoading.test.ts +++ b/src/modules/container-loading/api/platformContainerLoading.test.ts @@ -1,8 +1,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { http } from '@/shared/api/http' import { - adjustSmartLoadingItem, assignSmartLoadingItem, createSmartLoadingPlan, getContainer, - listContainers, listSmartLoadingCandidates, pushSmartLoadingPreload, removeSmartLoadingItem, + adjustSmartLoadingItem, assignSmartLoadingGroup, assignSmartLoadingItem, createSmartLoadingPlan, getContainer, + listContainers, listSmartLoadingCandidates, pushSmartLoadingPreload, removeSmartLoadingGroup, removeSmartLoadingItem, getContainerClearance, downloadContainerClearance, uploadContainerClearance, completeContainerClearance, getContainerOverview, markContainerException, cancelContainerException, uploadContainerDocument, downloadContainerDocument, downloadContainerPdaPhoto, getCustomerContainerTransitSummary, @@ -66,9 +66,9 @@ describe('platform container loading API', () => { it('normalizes every long id in candidates and plans', async () => { vi.mocked(http.get) .mockResolvedValueOnce({ code: '200', data: container }) - .mockResolvedValueOnce({ code: '200', data: [{ accountId: 1, inboundId: 2, orderId: 4, inboundCode: 'IN-1', warehouseId: 5, destinationWarehouseId: 6, channelCode: 'NY_EXX', channelName: '纽约EXX海铁', goodsSummary: '服装、鞋类', availablePackageCount: '7', unitGrossWeightKg: '12.5', unitNetWeightKg: '11.8', averageUnitVolumeCbm: '0.187', availableVolumeCbm: '1.309' }] }) + .mockResolvedValueOnce({ code: '200', data: [{ accountId: 1, inboundId: 2, orderId: 4, inboundCode: 'IN-1', warehouseId: 5, destinationWarehouseId: 6, channelCode: 'NY_EXX', channelName: '纽约EXX海铁', goodsSummary: '服装、鞋类', availablePackageCount: '7', unitGrossWeightKg: '12.5', unitNetWeightKg: '11.8', averageUnitVolumeCbm: '0.187', availableVolumeCbm: '1.309', coLoadGroupId: 9007199254740999n, groupNo: 'CLG-9007199254740999', memberCount: '3', groupTotalBoxes: '21', locked: false, blockReason: null }] }) expect((await getContainer('9')).id).toBe(String(container.id)) - expect(await listSmartLoadingCandidates('7', '5')).toMatchObject([{ accountId: '1', inboundId: '2', inboundCode: 'IN-1', destinationWarehouseId: '6', channelCode: 'NY_EXX', channelName: '纽约EXX海铁', goodsSummary: '服装、鞋类', availablePackageCount: 7, unitGrossWeightKg: 12.5, unitNetWeightKg: 11.8, averageUnitVolumeCbm: 0.187, availableVolumeCbm: 1.309 }]) + expect(await listSmartLoadingCandidates('7', '5')).toMatchObject([{ accountId: '1', inboundId: '2', inboundCode: 'IN-1', destinationWarehouseId: '6', channelCode: 'NY_EXX', channelName: '纽约EXX海铁', goodsSummary: '服装、鞋类', availablePackageCount: 7, unitGrossWeightKg: 12.5, unitNetWeightKg: 11.8, averageUnitVolumeCbm: 0.187, availableVolumeCbm: 1.309, coLoadGroupId: '9007199254740999', groupNo: 'CLG-9007199254740999', memberCount: 3, groupTotalBoxes: 21, locked: false, blockReason: null }]) expect(http.get).toHaveBeenLastCalledWith('/platform/v1/first-leg/smart-loading/inbound-candidates', { params: { bookingId: '7', warehouseId: '5' } }) }) @@ -90,6 +90,21 @@ describe('platform container loading API', () => { expect(vi.mocked(http.post).mock.calls[2]?.[2]).toMatchObject({ headers: { 'Idempotency-Key': expect.stringContaining('push-preload-') } }) }) + it('uses only the atomic group assign and whole-group remove endpoints for co-load groups', async () => { + vi.mocked(http.post).mockResolvedValue({ code: '200', data: plan }) + vi.mocked(http.delete).mockResolvedValue({ code: '200', data: plan }) + await assignSmartLoadingGroup('10', '2093000000000000006', 5) + await removeSmartLoadingGroup('10', '2093000000000000001', 6) + expect(http.post).toHaveBeenCalledWith( + '/platform/v1/first-leg/smart-loading/plans/10/group-assignments', + { inboundId: '2093000000000000006', expectedVersion: 5, operationId: expect.stringContaining('assign-coload-group-') }, + ) + expect(http.delete).toHaveBeenCalledWith( + '/platform/v1/first-leg/smart-loading/plans/10/group-assignments/2093000000000000001', + { data: { expectedPlanVersion: 6, operationId: expect.stringContaining('remove-coload-group-') } }, + ) + }) + it('uses stable container id and versioned clearance document actions', async () => { vi.spyOn(http, 'get').mockResolvedValue({ code: '200', data: { id: 4, documentType: 'CUSTOMS_CLEARANCE', fileId: 8, fileName: 'clearance.pdf', version: 2 } }) vi.spyOn(http, 'post').mockResolvedValue({ code: '200', data: { id: 4, documentType: 'CUSTOMS_CLEARANCE', fileId: 8, fileName: 'clearance.pdf', version: 3 } }) diff --git a/src/modules/container-loading/api/platformContainerLoading.ts b/src/modules/container-loading/api/platformContainerLoading.ts index c63bc4a3..13be4f90 100644 --- a/src/modules/container-loading/api/platformContainerLoading.ts +++ b/src/modules/container-loading/api/platformContainerLoading.ts @@ -235,6 +235,10 @@ export async function listSmartLoadingCandidates(bookingId: string, warehouseId: return unwrapApiResponse(response).map((raw) => ({ ...raw, accountId: id(raw.accountId), inboundId: id(raw.inboundId), orderId: id(raw.orderId), warehouseId: id(raw.warehouseId), destinationWarehouseId: optionalId(raw.destinationWarehouseId), + coLoadGroupId: optionalId(raw.coLoadGroupId), + memberCount: raw.memberCount == null ? null : numberValue(raw.memberCount), + groupTotalBoxes: raw.groupTotalBoxes == null ? null : numberValue(raw.groupTotalBoxes), + locked: raw.locked == null ? null : Boolean(raw.locked), availablePackageCount: numberValue(raw.availablePackageCount), unitGrossWeightKg: raw.unitGrossWeightKg == null ? null : numberValue(raw.unitGrossWeightKg), unitNetWeightKg: raw.unitNetWeightKg == null ? null : numberValue(raw.unitNetWeightKg), @@ -263,6 +267,14 @@ export async function assignSmartLoadingItem(planId: string, payload: { inboundI return plan(unwrapApiResponse(response)) } +export async function assignSmartLoadingGroup(planId: string, inboundId: string, expectedVersion: number) { + const response = await http.post, ApiEnvelope>( + `${smartLoadingPath}/plans/${encodeURIComponent(planId)}/group-assignments`, + { inboundId, expectedVersion, operationId: operationId('assign-coload-group') }, + ) + return plan(unwrapApiResponse(response)) +} + export async function recommendSmartLoading(planId: string, expectedVersion: number) { const response = await http.post, ApiEnvelope>(`${smartLoadingPath}/plans/${encodeURIComponent(planId)}/recommend`, { expectedVersion, operationId: operationId('recommend') }) return plan(unwrapApiResponse(response)) @@ -278,6 +290,14 @@ export async function removeSmartLoadingItem(planId: string, itemId: string, exp return plan(unwrapApiResponse(response)) } +export async function removeSmartLoadingGroup(planId: string, groupId: string, expectedPlanVersion: number) { + const response = await http.delete, ApiEnvelope>( + `${smartLoadingPath}/plans/${encodeURIComponent(planId)}/group-assignments/${encodeURIComponent(groupId)}`, + { data: { expectedPlanVersion, operationId: operationId('remove-coload-group') } }, + ) + return plan(unwrapApiResponse(response)) +} + export async function pushSmartLoadingPreload(planId: string, expectedVersion: number) { const response = await http.post, ApiEnvelope>(`${smartLoadingPath}/plans/${encodeURIComponent(planId)}/push-preload`, { expectedVersion }, { headers: { 'Idempotency-Key': operationId('push-preload') } }) return plan(unwrapApiResponse(response)) diff --git a/src/modules/container-loading/pages/GlobalSmartLoadingView.test.ts b/src/modules/container-loading/pages/GlobalSmartLoadingView.test.ts index 8f94a453..b559a619 100644 --- a/src/modules/container-loading/pages/GlobalSmartLoadingView.test.ts +++ b/src/modules/container-loading/pages/GlobalSmartLoadingView.test.ts @@ -61,7 +61,7 @@ describe('GlobalSmartLoadingView', () => { vi.clearAllMocks() vi.mocked(listContainers).mockResolvedValue({ records: [container], page: 1, size: 100, total: 1, pages: 1 }) vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue(plan) - vi.mocked(listSmartLoadingCandidates).mockResolvedValue([{ accountId: '1', inboundId: '5', orderId: '7', orderNo: 'ORD-2', inboundCode: 'IN-2', warehouseId: '8', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', customerName: '另一企业', goodsSummary: '鞋类', availablePackageCount: 12, unitGrossWeightKg: 10, unitNetWeightKg: 9, averageUnitVolumeCbm: 0.2, availableVolumeCbm: 2.4 }]) + vi.mocked(listSmartLoadingCandidates).mockResolvedValue([{ accountId: '1', inboundId: '5', orderId: '7', orderNo: 'ORD-2', inboundCode: 'IN-2', warehouseId: '8', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', customerName: '另一企业', goodsSummary: '鞋类', availablePackageCount: 12, unitGrossWeightKg: 10, unitNetWeightKg: 9, averageUnitVolumeCbm: 0.2, availableVolumeCbm: 2.4, coLoadGroupId: null, groupNo: null, memberCount: null, groupTotalBoxes: null, locked: null, blockReason: null }]) }) it('loads real containers, candidates and the selected plan into the three-column workbench', async () => { @@ -94,7 +94,7 @@ describe('GlobalSmartLoadingView', () => { total: 1, pages: 1, }) - vi.mocked(listSmartLoadingCandidates).mockResolvedValue([{ accountId: '1', inboundId: '5', orderId: '7', orderNo: 'ORD-2', inboundCode: 'IN-2', warehouseId: '8', channelCode: 'EXX_RAIL_NY', channelName: '纽约EXX海铁', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', customerName: '另一企业', goodsSummary: '鞋类', availablePackageCount: 12, unitGrossWeightKg: 10, unitNetWeightKg: 9, averageUnitVolumeCbm: 0.2, availableVolumeCbm: 2.4 }]) + vi.mocked(listSmartLoadingCandidates).mockResolvedValue([{ accountId: '1', inboundId: '5', orderId: '7', orderNo: 'ORD-2', inboundCode: 'IN-2', warehouseId: '8', channelCode: 'EXX_RAIL_NY', channelName: '纽约EXX海铁', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', customerName: '另一企业', goodsSummary: '鞋类', availablePackageCount: 12, unitGrossWeightKg: 10, unitNetWeightKg: 9, averageUnitVolumeCbm: 0.2, availableVolumeCbm: 2.4, coLoadGroupId: null, groupNo: null, memberCount: null, groupTotalBoxes: null, locked: null, blockReason: null }]) const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } }) await flushPromises() @@ -302,8 +302,8 @@ describe('GlobalSmartLoadingView', () => { it('matches candidate goods by their own formal top-level channel code', async () => { vi.mocked(listSmartLoadingCandidates).mockResolvedValue([ - { accountId: '1', inboundId: '5', orderId: '7', orderNo: 'ORD-2', inboundCode: 'IN-MATCH', warehouseId: '8', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', customerName: '匹配企业', goodsSummary: '匹配货物', availablePackageCount: 12, unitGrossWeightKg: 10, unitNetWeightKg: 9, averageUnitVolumeCbm: 0.2, availableVolumeCbm: 2.4 }, - { accountId: '2', inboundId: '8', orderId: '10', orderNo: 'ORD-3', inboundCode: 'IN-OTHER', warehouseId: '8', channelCode: 'OTHER', channelName: '其他渠道', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', customerName: '其他企业', goodsSummary: '其他货物', availablePackageCount: 8, unitGrossWeightKg: 10, unitNetWeightKg: 9, averageUnitVolumeCbm: 0.2, availableVolumeCbm: 1.6 }, + { accountId: '1', inboundId: '5', orderId: '7', orderNo: 'ORD-2', inboundCode: 'IN-MATCH', warehouseId: '8', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', customerName: '匹配企业', goodsSummary: '匹配货物', availablePackageCount: 12, unitGrossWeightKg: 10, unitNetWeightKg: 9, averageUnitVolumeCbm: 0.2, availableVolumeCbm: 2.4, coLoadGroupId: null, groupNo: null, memberCount: null, groupTotalBoxes: null, locked: null, blockReason: null }, + { accountId: '2', inboundId: '8', orderId: '10', orderNo: 'ORD-3', inboundCode: 'IN-OTHER', warehouseId: '8', channelCode: 'OTHER', channelName: '其他渠道', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', customerName: '其他企业', goodsSummary: '其他货物', availablePackageCount: 8, unitGrossWeightKg: 10, unitNetWeightKg: 9, averageUnitVolumeCbm: 0.2, availableVolumeCbm: 1.6, coLoadGroupId: null, groupNo: null, memberCount: null, groupTotalBoxes: null, locked: null, blockReason: null }, ]) const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } }) await flushPromises() @@ -450,7 +450,7 @@ describe('GlobalSmartLoadingView', () => { }) it('limits adjusted package count to the assigned count plus remaining availability', async () => { - vi.mocked(listSmartLoadingCandidates).mockResolvedValue([{ accountId: '1', inboundId: '2', orderId: '4', orderNo: 'ORD-1', inboundCode: 'IN-1', warehouseId: '8', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', customerName: '示例企业', goodsSummary: '服装', availablePackageCount: 4, unitGrossWeightKg: 10, unitNetWeightKg: 9, averageUnitVolumeCbm: 0.2, availableVolumeCbm: 0.8 }]) + vi.mocked(listSmartLoadingCandidates).mockResolvedValue([{ accountId: '1', inboundId: '2', orderId: '4', orderNo: 'ORD-1', inboundCode: 'IN-1', warehouseId: '8', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', customerName: '示例企业', goodsSummary: '服装', availablePackageCount: 4, unitGrossWeightKg: 10, unitNetWeightKg: 9, averageUnitVolumeCbm: 0.2, availableVolumeCbm: 0.8, coLoadGroupId: null, groupNo: null, memberCount: null, groupTotalBoxes: null, locked: null, blockReason: null }]) const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } }) await flushPromises() diff --git a/src/modules/container-loading/pages/SmartLoadingWorkbenchView.test.ts b/src/modules/container-loading/pages/SmartLoadingWorkbenchView.test.ts index bff9d0eb..acd00a40 100644 --- a/src/modules/container-loading/pages/SmartLoadingWorkbenchView.test.ts +++ b/src/modules/container-loading/pages/SmartLoadingWorkbenchView.test.ts @@ -1,4 +1,4 @@ -import ElementPlus from 'element-plus' +import ElementPlus, { ElMessageBox } from 'element-plus' import { AxiosError } from 'axios' import { createPinia, setActivePinia } from 'pinia' import { createMemoryHistory, createRouter } from 'vue-router' @@ -7,13 +7,15 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { readFileSync } from 'node:fs' import { resolve } from 'node:path' import SmartLoadingWorkbenchView from './SmartLoadingWorkbenchView.vue' -import { getContainer, getSmartLoadingPlanByBooking, listSmartLoadingCandidates, recommendSmartLoading } from '../api/platformContainerLoading' +import { assignSmartLoadingGroup, assignSmartLoadingItem, getContainer, getSmartLoadingPlanByBooking, listSmartLoadingCandidates, recommendSmartLoading, removeSmartLoadingGroup } from '../api/platformContainerLoading' +import { batchQueryPlatformCoLoadGroups } from '@/modules/unified-orders' vi.mock('../api/platformContainerLoading', () => ({ getContainer: vi.fn(), getSmartLoadingPlanByBooking: vi.fn(), listSmartLoadingCandidates: vi.fn(), - createSmartLoadingPlan: vi.fn(), assignSmartLoadingItem: vi.fn(), recommendSmartLoading: vi.fn(), - adjustSmartLoadingItem: vi.fn(), removeSmartLoadingItem: vi.fn(), pushSmartLoadingPreload: vi.fn(), + createSmartLoadingPlan: vi.fn(), assignSmartLoadingItem: vi.fn(), assignSmartLoadingGroup: vi.fn(), recommendSmartLoading: vi.fn(), + adjustSmartLoadingItem: vi.fn(), removeSmartLoadingItem: vi.fn(), removeSmartLoadingGroup: vi.fn(), pushSmartLoadingPreload: vi.fn(), })) +vi.mock('@/modules/unified-orders', () => ({ batchQueryPlatformCoLoadGroups: vi.fn().mockResolvedValue([]) })) vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) })) const container = { @@ -30,6 +32,7 @@ const planResponse = { canAssign: true, canEdit: true, blockedReason: null, finalCompletionStatus: 'NOT_STARTED' as const, pdaTaskId: null, pdaTaskStatus: null, pdaTaskVersion: null, items: [], } +const candidate = { accountId: '1', inboundId: '2', orderId: '4', orderNo: 'ORD-1', inboundCode: 'IN-1', warehouseId: '8', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', customerName: '示例企业', goodsSummary: '服装', availablePackageCount: 12, unitGrossWeightKg: 10, unitNetWeightKg: 9, averageUnitVolumeCbm: 0.2, availableVolumeCbm: 2.4, coLoadGroupId: null, groupNo: null, memberCount: null, groupTotalBoxes: null, locked: null, blockReason: null } describe('SmartLoadingWorkbenchView', () => { beforeEach(() => { @@ -37,7 +40,7 @@ describe('SmartLoadingWorkbenchView', () => { vi.clearAllMocks() vi.mocked(getContainer).mockResolvedValue(container) vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue(planResponse) - vi.mocked(listSmartLoadingCandidates).mockResolvedValue([{ accountId: '1', inboundId: '2', orderId: '4', orderNo: 'ORD-1', inboundCode: 'IN-1', warehouseId: '8', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', customerName: '示例企业', goodsSummary: '服装', availablePackageCount: 12, unitGrossWeightKg: 10, unitNetWeightKg: 9, averageUnitVolumeCbm: 0.2, availableVolumeCbm: 2.4 }]) + vi.mocked(listSmartLoadingCandidates).mockResolvedValue([candidate]) }) it('restores the real draft and explains candidate capacity', async () => { @@ -114,7 +117,86 @@ describe('SmartLoadingWorkbenchView', () => { it('keeps the package count editor inside its table cell without overflow ellipsis', () => { const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/SmartLoadingWorkbenchView.vue'), 'utf8') - expect(source).toContain('
{ + vi.mocked(listSmartLoadingCandidates).mockResolvedValue([ + { ...candidate, inboundId: '201', orderId: '301', inboundCode: 'IN-201', availablePackageCount: 4, availableVolumeCbm: 0.8, coLoadGroupId: '9007199254740999', groupNo: 'CLG-9007199254740999', memberCount: 2, groupTotalBoxes: 10, locked: false, blockReason: null }, + { ...candidate, accountId: '2', inboundId: '202', orderId: '302', inboundCode: 'IN-202', availablePackageCount: 6, availableVolumeCbm: 1.2, coLoadGroupId: '9007199254740999', groupNo: 'CLG-9007199254740999', memberCount: 2, groupTotalBoxes: 10, locked: false, blockReason: null }, + ]) + vi.mocked(assignSmartLoadingGroup).mockResolvedValue(planResponse) + const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/container-loading/:containerId/smart-loading', component: SmartLoadingWorkbenchView }] }) + await router.push(`/container-loading/${container.id}/smart-loading`); await router.isReady() + const wrapper = mount(SmartLoadingWorkbenchView, { attachTo: document.body, global: { plugins: [ElementPlus, router] } }) + await flushPromises() + expect(wrapper.text()).toContain('CLG-9007199254740999 · 2 票') + expect(wrapper.text()).toContain('整组 10 箱') + expect(wrapper.findAll('button').filter((button) => button.text().includes('整组分配'))).toHaveLength(1) + await wrapper.findAll('button').find((button) => button.text().includes('整组分配'))!.trigger('click') + await flushPromises() + ;([...document.body.querySelectorAll('button')].find((button) => button.textContent?.includes('确认整组分配')) as HTMLButtonElement).click() + await flushPromises() + expect(assignSmartLoadingGroup).toHaveBeenCalledWith(planResponse.id, '201', planResponse.version) + expect(assignSmartLoadingItem).not.toHaveBeenCalled() + wrapper.unmount() + }) + + it('shows an exact three-member group block and prevents individual assignment', async () => { + vi.mocked(listSmartLoadingCandidates).mockResolvedValue(['401', '402', '403'].map((inboundId, index) => ({ + ...candidate, inboundId, orderId: `5${index}`, inboundCode: `IN-${inboundId}`, coLoadGroupId: '9900', groupNo: 'CLG-9900', + memberCount: 3, groupTotalBoxes: 18, locked: false, blockReason: '同柜不可拆组成员候选不齐,缺少 1 票', + }))) + const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/container-loading/:containerId/smart-loading', component: SmartLoadingWorkbenchView }] }) + await router.push(`/container-loading/${container.id}/smart-loading`); await router.isReady() + const wrapper = mount(SmartLoadingWorkbenchView, { global: { plugins: [ElementPlus, router] } }) + await flushPromises() + expect(wrapper.text()).toContain('CLG-9900 · 3 票') + expect(wrapper.text()).toContain('同柜不可拆组成员候选不齐,缺少 1 票') + const button = wrapper.findAll('button').find((item) => item.text().includes('整组分配'))! + expect(button.attributes('disabled')).toBeDefined() + expect(wrapper.text()).not.toContain('本次计划装柜箱数') + }) + + it('keeps an ordinary ungrouped inbound on the existing partial-count assignment', async () => { + vi.mocked(assignSmartLoadingItem).mockResolvedValue(planResponse) + const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/container-loading/:containerId/smart-loading', component: SmartLoadingWorkbenchView }] }) + await router.push(`/container-loading/${container.id}/smart-loading`); await router.isReady() + const wrapper = mount(SmartLoadingWorkbenchView, { attachTo: document.body, global: { plugins: [ElementPlus, router] } }) + await flushPromises() + await wrapper.findAll('button').find((button) => button.text() === '分配')!.trigger('click') + await flushPromises() + expect(document.body.textContent).toContain('本次计划装柜箱数') + ;([...document.body.querySelectorAll('button')].find((button) => button.textContent?.trim() === '确认分配') as HTMLButtonElement).click() + await flushPromises() + expect(assignSmartLoadingItem).toHaveBeenCalledWith(planResponse.id, { inboundId: '2', packageCount: 1, expectedVersion: planResponse.version }) + expect(assignSmartLoadingGroup).not.toHaveBeenCalled() + wrapper.unmount() + }) + + it('removes a locked group once instead of looping through member items', async () => { + const items = ['201', '202'].map((inboundId, index) => ({ + id: `70${index}`, loadingPlanId: '99', accountId: `${index + 1}`, inboundId, inboundDetailId: `80${index}`, orderId: `30${index}`, + orderNo: `ORD-${index}`, inboundCode: `IN-${inboundId}`, customerName: `客户${index}`, destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', + skuCode: `SKU-${index}`, productNameZh: `货物${index}`, packageCount: 5, productQuantity: 50, grossWeightKg: 50, volumeCbm: 1, + loadSequence: index + 1, version: 1, pickedPackageCount: 0, minPackageCount: 1, canAdjust: true, canRemove: true, + })) + vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue({ ...planResponse, items }) + vi.mocked(batchQueryPlatformCoLoadGroups).mockResolvedValue(items.map((item) => ({ orderId: item.orderId, inboundId: item.inboundId, group: { + groupId: '9900', groupNo: 'CLG-9900', status: 'LOCKED', warehouseId: '8', channelCode: 'ZBYHK', destinationType: 'WAREHOUSE', destinationWarehouseId: '9', destinationCode: 'LAX9', lockedPlanId: '99', version: 1, + members: items.map((member) => ({ tenantId: member.accountId, orderId: member.orderId, inboundId: member.inboundId, fullPackageCount: 5 })), fullPackageCount: 10, createdAt: '2026-08-29T10:00:00', updatedAt: '2026-08-29T10:30:00', + } }))) + vi.mocked(removeSmartLoadingGroup).mockResolvedValue({ ...planResponse, version: 3, items: [] }) + vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue('confirm' as never) + const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/container-loading/:containerId/smart-loading', component: SmartLoadingWorkbenchView }] }) + await router.push(`/container-loading/${container.id}/smart-loading`); await router.isReady() + const wrapper = mount(SmartLoadingWorkbenchView, { global: { plugins: [ElementPlus, router] } }) + await flushPromises() + expect(wrapper.text()).toContain('CLG-9900 · 2 票') + await wrapper.findAll('button').find((button) => button.text().includes('移除整组'))!.trigger('click') + await flushPromises() + expect(removeSmartLoadingGroup).toHaveBeenCalledWith('99', '9900', 2) + expect(ElMessageBox.confirm).toHaveBeenCalledWith(expect.stringContaining('组内成员不能单独移除'), '移除同柜不可拆组', expect.any(Object)) + }) }) diff --git a/src/modules/container-loading/pages/SmartLoadingWorkbenchView.vue b/src/modules/container-loading/pages/SmartLoadingWorkbenchView.vue index 48e62040..a9e21aa7 100644 --- a/src/modules/container-loading/pages/SmartLoadingWorkbenchView.vue +++ b/src/modules/container-loading/pages/SmartLoadingWorkbenchView.vue @@ -4,6 +4,7 @@ import { useRoute, useRouter } from 'vue-router' import { ArrowLeft, ListChecks, Plus, RefreshCw, Save, Sparkles, Trash2, Upload } from '@lucide/vue' import { ElMessage, ElMessageBox } from 'element-plus' import { useAccessPreviewStore } from '@/core/access/accessPreview' +import { batchQueryPlatformCoLoadGroups, type CoLoadGroup } from '@/modules/unified-orders' import AdminPage from '@/shared/components/AdminPage.vue' import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue' import AppDialog from '@/shared/components/AppDialog.vue' @@ -11,9 +12,9 @@ import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.v import LoadErrorState from '@/shared/components/LoadErrorState.vue' import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' import { - adjustSmartLoadingItem, assignSmartLoadingItem, createSmartLoadingPlan, getContainer, + adjustSmartLoadingItem, assignSmartLoadingGroup, assignSmartLoadingItem, createSmartLoadingPlan, getContainer, getSmartLoadingPlanByBooking, listSmartLoadingCandidates, pushSmartLoadingPreload, - recommendSmartLoading, removeSmartLoadingItem, + recommendSmartLoading, removeSmartLoadingGroup, removeSmartLoadingItem, } from '../api/platformContainerLoading' import type { ContainerBookingResource, SmartLoadingCandidate, SmartLoadingPlan } from '../types' import { groupSmartLoadingItems, type SmartLoadingInboundItem } from '../smartLoadingItems' @@ -34,6 +35,7 @@ const assignOpen = ref(false) const selectedCandidate = ref(null) const assignForm = reactive({ packageCount: 1 }) const quantityDrafts = reactive>({}) +const planGroupsByInboundId = ref(new Map()) const canCreate = computed(() => access.can('platform:firstleg:smart-loading:create')) const canAssign = computed(() => access.can('platform:firstleg:smart-loading:assign')) @@ -43,7 +45,71 @@ const canViewLoadingList = computed(() => access.can('platform:firstleg:loading- const editable = computed(() => plan.value?.canEdit === true) const assignable = computed(() => plan.value?.canAssign === true) const assignedInboundIds = computed(() => new Set((plan.value?.items || []).map((item) => item.inboundId))) -const displayedPlanItems = computed(() => groupSmartLoadingItems(plan.value?.items || [])) +type SmartLoadingCandidateRow = SmartLoadingCandidate & { sourceCandidates: SmartLoadingCandidate[]; memberInboundCodes: string[] } +const displayedCandidates = computed(() => { + const rows: SmartLoadingCandidateRow[] = [] + const groups = new Map() + for (const candidate of candidates.value) { + if (!candidate.coLoadGroupId) { + rows.push({ ...candidate, sourceCandidates: [candidate], memberInboundCodes: [candidate.inboundCode || '入仓码暂缺'] }) + continue + } + groups.set(candidate.coLoadGroupId, [...(groups.get(candidate.coLoadGroupId) || []), candidate]) + } + for (const members of groups.values()) { + const first = members[0] + rows.push({ + ...first, + inboundCode: members.map((member) => member.inboundCode).filter(Boolean).join('、'), + customerName: [...new Set(members.map((member) => member.customerName).filter(Boolean))].join('、'), + goodsSummary: [...new Set(members.map((member) => member.goodsSummary).filter(Boolean))].join('、'), + availablePackageCount: first.groupTotalBoxes || members.reduce((sum, member) => sum + member.availablePackageCount, 0), + availableVolumeCbm: members.reduce((sum, member) => sum + Number(member.availableVolumeCbm || 0), 0), + sourceCandidates: members, + memberInboundCodes: members.map((member) => member.inboundCode || '入仓码暂缺'), + }) + } + return rows +}) +type SmartLoadingPlanRow = SmartLoadingInboundItem & { coLoadGroup: CoLoadGroup | null; memberInboundCodes: string[] } +const displayedPlanItems = computed(() => { + const inboundRows = groupSmartLoadingItems(plan.value?.items || []) + const result: SmartLoadingPlanRow[] = [] + const grouped = new Map() + for (const row of inboundRows) { + const group = planGroupsByInboundId.value.get(row.inboundId) + if (!group) { + result.push({ ...row, coLoadGroup: null, memberInboundCodes: [row.inboundCode || '入仓码暂缺'] }) + continue + } + grouped.set(group.groupId, [...(grouped.get(group.groupId) || []), row]) + } + for (const rows of grouped.values()) { + const first = rows[0] + const group = planGroupsByInboundId.value.get(first.inboundId)! + result.push({ + ...first, + id: `coload:${group.groupId}`, + inboundCode: rows.map((row) => row.inboundCode).filter(Boolean).join('、'), + customerName: [...new Set(rows.map((row) => row.customerName).filter(Boolean))].join('、'), + skuCode: [...new Set(rows.map((row) => row.skuCode).filter(Boolean))].join('、'), + productNameZh: [...new Set(rows.map((row) => row.productNameZh).filter(Boolean))].join('、'), + packageCount: rows.reduce((sum, row) => sum + row.packageCount, 0), + productQuantity: rows.reduce((sum, row) => sum + Number(row.productQuantity || 0), 0), + grossWeightKg: rows.reduce((sum, row) => sum + Number(row.grossWeightKg || 0), 0), + volumeCbm: rows.reduce((sum, row) => sum + Number(row.volumeCbm || 0), 0), + pickedPackageCount: rows.reduce((sum, row) => sum + row.pickedPackageCount, 0), + minPackageCount: rows.reduce((sum, row) => sum + row.minPackageCount, 0), + canAdjust: false, + canRemove: rows.every((row) => row.canRemove), + sourceItems: rows.flatMap((row) => row.sourceItems), + detailCount: rows.reduce((sum, row) => sum + row.detailCount, 0), + coLoadGroup: group, + memberInboundCodes: rows.map((row) => row.inboundCode || '入仓码暂缺'), + }) + } + return result +}) const totalVolume = computed(() => (plan.value?.items || []).reduce((sum, item) => sum + Number(item.volumeCbm || 0), 0)) const totalWeight = computed(() => (plan.value?.items || []).reduce((sum, item) => sum + Number(item.grossWeightKg || 0), 0)) const volumeLimit = computed(() => Number(container.value?.capacityCbm || 0) * 1.1) @@ -57,6 +123,7 @@ const itemColumns: DataTableColumn[] = [ { key: 'actions', label: '操作', width: 180, fixed: 'left', slotName: 'actions' }, { key: 'sequence', prop: 'loadSequence', label: '顺序', width: 75 }, { key: 'order', label: '入仓码', minWidth: 190, slotName: 'order' }, + { key: 'coLoadGroup', label: '同柜不可拆', minWidth: 210, slotName: 'coLoadGroup' }, { key: 'customerName', prop: 'customerName', label: '客户', minWidth: 150 }, { key: 'goods', label: '货物', minWidth: 190, slotName: 'goods' }, { key: 'packageCount', label: '分配箱数', width: 150, slotName: 'packageCount' }, @@ -66,6 +133,7 @@ const itemColumns: DataTableColumn[] = [ const candidateColumns: DataTableColumn[] = [ { key: 'actions', label: '操作', width: 110, fixed: 'left', slotName: 'actions' }, { key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 190, fixed: 'left', showOverflowTooltip: true }, + { key: 'coLoadGroup', label: '同柜不可拆', minWidth: 210, slotName: 'coLoadGroup' }, { key: 'customerName', prop: 'customerName', label: '客户', minWidth: 150 }, { key: 'goodsSummary', prop: 'goodsSummary', label: '货物摘要', minWidth: 180, showOverflowTooltip: true }, { key: 'availablePackageCount', prop: 'availablePackageCount', label: '可用箱数', width: 110 }, @@ -76,7 +144,7 @@ const candidateColumns: DataTableColumn[] = [ function formatNumber(value: number, digits = 3) { return Number.isFinite(value) ? value.toLocaleString('zh-CN', { maximumFractionDigits: digits }) : '0' } function percentage(value: number) { return Number.isFinite(value) ? Number(Math.max(value, 0).toFixed(2)) : 0 } -function candidateRow(source: object) { return source as SmartLoadingCandidate } +function candidateRow(source: object) { return source as SmartLoadingCandidateRow } function itemRow(source: object) { return source as SmartLoadingInboundItem } function explainCandidate(source: object) { const candidate = candidateRow(source) @@ -100,9 +168,20 @@ function syncDrafts(preserved: Record = {}) { for (const item of displayedPlanItems.value) quantityDrafts[item.id] = preserved[item.id] ?? item.packageCount } function acceptPlan(next: SmartLoadingPlan, preserved: Record = {}) { plan.value = next; noDraft.value = false; syncDrafts(preserved) } +async function loadPlanGroups() { + planGroupsByInboundId.value = new Map() + const orderIds = [...new Set((plan.value?.items || []).map((item) => item.orderId))] + if (!orderIds.length || !canAssign.value) return + const groups = await batchQueryPlatformCoLoadGroups(orderIds) + const byInbound = new Map() + for (const info of groups) for (const member of info.group.members) byInbound.set(member.inboundId, info.group) + planGroupsByInboundId.value = byInbound + syncDrafts() +} async function load() { loading.value = true; loadError.value = null; noDraft.value = false + planGroupsByInboundId.value = new Map() try { container.value = await getContainer(containerId.value) if (container.value.planStatus === 'COMPLETED') { @@ -110,7 +189,7 @@ async function load() { candidates.value = [] return } - try { acceptPlan(await getSmartLoadingPlanByBooking(containerId.value)) } + try { acceptPlan(await getSmartLoadingPlanByBooking(containerId.value)); await loadPlanGroups() } catch (error) { const detail = getApiErrorDetails(error) if (detail.code === 'A0404') { plan.value = null; noDraft.value = true } @@ -136,17 +215,35 @@ async function createPlan() { catch (error) { await handleMutationError(error, '创建排柜计划失败') } finally { mutating.value = false } } -function openAssign(source: object) { const candidate = candidateRow(source); selectedCandidate.value = candidate; assignForm.packageCount = Math.min(candidate.availablePackageCount, 1); assignOpen.value = true } +function openAssign(source: object) { + const candidate = candidateRow(source) + if (candidateBlocked(candidate)) return + selectedCandidate.value = candidate + assignForm.packageCount = candidate.coLoadGroupId ? Number(candidate.groupTotalBoxes || candidate.availablePackageCount) : Math.min(candidate.availablePackageCount, 1) + assignOpen.value = true +} async function assign() { if (!plan.value || !selectedCandidate.value || assignForm.packageCount < 1) return + if (selectedCandidate.value.coLoadGroupId && (selectedCandidate.value.blockReason || selectedCandidate.value.locked)) return mutating.value = true mutationError.value = null try { - acceptPlan(await assignSmartLoadingItem(plan.value.id, { inboundId: selectedCandidate.value.inboundId, packageCount: assignForm.packageCount, expectedVersion: plan.value.version })) - assignOpen.value = false; ElMessage.success('货物已分配') + const selected = selectedCandidate.value + const next = selected.coLoadGroupId + ? await assignSmartLoadingGroup(plan.value.id, selected.inboundId, plan.value.version) + : await assignSmartLoadingItem(plan.value.id, { inboundId: selected.inboundId, packageCount: assignForm.packageCount, expectedVersion: plan.value.version }) + acceptPlan(next) + await loadPlanGroups() + assignOpen.value = false + ElMessage.success(selected.coLoadGroupId ? `同柜不可拆组 ${selected.groupNo} 已整组分配` : '货物已分配') } catch (error) { await handleMutationError(error, '分配失败') } finally { mutating.value = false } } +function candidateBlocked(candidate: SmartLoadingCandidateRow) { + return Boolean(candidate.blockReason) || candidate.locked === true + || candidate.sourceCandidates.some((member) => assignedInboundIds.value.has(member.inboundId)) + || candidate.availablePackageCount < 1 +} async function recommend() { if (!plan.value) return mutating.value = true @@ -180,11 +277,21 @@ async function adjust(source: object) { async function remove(source: object) { const item = itemRow(source) if (!plan.value || !item.canRemove) return - await ElMessageBox.confirm(`确认从计划移除入仓码“${item.inboundCode || '—'}”的 ${item.packageCount} 箱吗?`, '移除装柜明细', { type: 'warning', confirmButtonText: '移除', cancelButtonText: '取消' }).catch(() => false).then(async (confirmed) => { + const row = source as SmartLoadingPlanRow + const message = row.coLoadGroup + ? `确认整组移除 ${row.coLoadGroup.groupNo}(${row.coLoadGroup.members.length} 票、${row.packageCount} 箱)吗?组内成员不能单独移除。` + : `确认从计划移除入仓码“${item.inboundCode || '—'}”的 ${item.packageCount} 箱吗?` + await ElMessageBox.confirm(message, row.coLoadGroup ? '移除同柜不可拆组' : '移除装柜明细', { type: 'warning', confirmButtonText: row.coLoadGroup ? '移除整组' : '移除', cancelButtonText: '取消' }).catch(() => false).then(async (confirmed) => { if (!confirmed || !plan.value) return mutating.value = true mutationError.value = null try { + if (row.coLoadGroup) { + acceptPlan(await removeSmartLoadingGroup(plan.value!.id, row.coLoadGroup.groupId, plan.value!.version)) + await load() + ElMessage.success(`同柜不可拆组 ${row.coLoadGroup.groupNo} 已整组移除`) + return + } let current = plan.value! for (const original of item.sourceItems) { const currentItem = current.items.find((value) => value.id === original.id) @@ -265,22 +372,24 @@ onMounted(load) - + + - + - + - + + -

{{ selectedCandidate.inboundCode || '入仓码暂缺' }}

{{ selectedCandidate.goodsSummary || '货物摘要暂缺' }} · 可用 {{ selectedCandidate.availablePackageCount }} 箱

只记录数量,具体上柜箱号未核验。
+

{{ selectedCandidate.groupNo || selectedCandidate.inboundCode || '入仓码暂缺' }}

@@ -310,5 +419,8 @@ onMounted(load) .workbench :deep(.package-count-editor .el-input-number) { width: 100%; min-width: 0; } .assign-dialog p { margin: 0 0 10px; } .assign-dialog :deep(.el-input-number) { width: 100%; } +.candidate-group { display: grid; width: fit-content; gap: 3px; } +.candidate-group > span,.candidate-group > small { color: var(--cc-muted); font-size: 11px; } +.candidate-group .candidate-block { color: var(--el-color-danger); } @media (max-width: 760px) { .workbench-command-bar,.header-actions { width: 100%; flex-direction: column; align-items: stretch; } .workbench-identities { flex-direction: column; align-items: stretch; gap: 10px; } .summary-band,.capacity-grid { grid-template-columns: 1fr; } .meter-title { align-items: flex-start; flex-direction: column; } .header-actions .el-button { min-height: 44px; margin-left: 0; } } diff --git a/src/modules/container-loading/types.ts b/src/modules/container-loading/types.ts index ec2be37d..0b204ec7 100644 --- a/src/modules/container-loading/types.ts +++ b/src/modules/container-loading/types.ts @@ -248,6 +248,12 @@ export interface SmartLoadingCandidate { unitNetWeightKg: number | null averageUnitVolumeCbm: number | null availableVolumeCbm: number | null + coLoadGroupId: string | null + groupNo: string | null + memberCount: number | null + groupTotalBoxes: number | null + locked: boolean | null + blockReason: string | null } export interface SmartLoadingItem { diff --git a/src/modules/unified-orders/api/coLoadGroups.test.ts b/src/modules/unified-orders/api/coLoadGroups.test.ts new file mode 100644 index 00000000..ffa41ec8 --- /dev/null +++ b/src/modules/unified-orders/api/coLoadGroups.test.ts @@ -0,0 +1,49 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { http } from '@/shared/api/http' +import { batchQueryPlatformCoLoadGroups, cancelPlatformCoLoadGroup, createPlatformCoLoadGroup } from './coLoadGroups' + +const group = { + groupId: 2093000000000000001n, groupNo: 'CLG-2093000000000000001', status: 'ACTIVE', + warehouseId: 2093000000000000002n, channelCode: 'NY-EXX', destinationType: 'WAREHOUSE', + destinationWarehouseId: 2093000000000000003n, destinationCode: 'ONT8', lockedPlanId: null, + version: 0, fullPackageCount: 18, createdAt: '2026-08-29T10:00:00', updatedAt: '2026-08-29T10:00:00', + members: [ + { tenantId: 2093000000000000004n, orderId: 2093000000000000005n, inboundId: 2093000000000000006n, fullPackageCount: 8 }, + { tenantId: 2093000000000000007n, orderId: 2093000000000000008n, inboundId: 2093000000000000009n, fullPackageCount: 10 }, + ], +} + +describe('co-load group API', () => { + beforeEach(() => vi.restoreAllMocks()) + + it('creates one group from two stable order ids with idempotency', async () => { + const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', message: 'ok', data: group }) + const orderIds = ['2093000000000000005', '2093000000000000008'] + const result = await createPlatformCoLoadGroup(orderIds) + expect(post).toHaveBeenCalledWith('/platform/v1/first-leg/coload-groups', { orderIds }, { headers: { 'Idempotency-Key': expect.any(String) } }) + expect(result.groupId).toBe('2093000000000000001') + expect(result.members.map((member) => member.orderId)).toEqual(orderIds) + expect(result.members.map((member) => member.tenantId)).toEqual(['2093000000000000004', '2093000000000000007']) + }) + + it('cancels only the whole group with optimistic version and an optional reason', async () => { + const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', message: 'ok', data: { ...group, status: 'CANCELLED', version: 1 } }) + await cancelPlatformCoLoadGroup('2093000000000000001', 0, '路线调整') + expect(post).toHaveBeenCalledWith( + '/platform/v1/first-leg/coload-groups/2093000000000000001/cancel', + { expectedVersion: 0, reason: '路线调整' }, + { headers: { 'Idempotency-Key': expect.any(String) } }, + ) + }) + + it('batch-enriches three list rows and normalizes every long id without numeric coercion', async () => { + const third = { ...group.members[0], orderId: 2093000000000000010n, inboundId: 2093000000000000011n } + const threeMemberGroup = { ...group, fullPackageCount: 23, members: [...group.members, third] } + const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', message: 'ok', data: threeMemberGroup.members.map((member) => ({ orderId: member.orderId, inboundId: member.inboundId, group: threeMemberGroup })) }) + const orderIds = ['2093000000000000005', '2093000000000000008', '2093000000000000010'] + const result = await batchQueryPlatformCoLoadGroups(orderIds) + expect(post).toHaveBeenCalledWith('/platform/v1/first-leg/coload-groups/batch-query', { orderIds }) + expect(result).toHaveLength(3) + expect(result[2]).toMatchObject({ orderId: '2093000000000000010', inboundId: '2093000000000000011', group: { groupId: '2093000000000000001', fullPackageCount: 23 } }) + }) +}) diff --git a/src/modules/unified-orders/api/coLoadGroups.ts b/src/modules/unified-orders/api/coLoadGroups.ts new file mode 100644 index 00000000..2fdde984 --- /dev/null +++ b/src/modules/unified-orders/api/coLoadGroups.ts @@ -0,0 +1,100 @@ +import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' + +export type CoLoadGroupStatus = 'ACTIVE' | 'LOCKED' | 'CONSUMED' | 'CANCELLED' + +export interface CoLoadGroupMember { + tenantId: string + orderId: string + inboundId: string + fullPackageCount: number +} + +export interface CoLoadGroup { + groupId: string + groupNo: string + status: CoLoadGroupStatus + warehouseId: string + channelCode: string + destinationType: string + destinationWarehouseId: string | null + destinationCode: string | null + lockedPlanId: string | null + version: number + members: CoLoadGroupMember[] + fullPackageCount: number + createdAt: string + updatedAt: string +} + +export interface OrderCoLoadGroupInfo { + orderId: string + inboundId: string + group: CoLoadGroup +} + +type RawId = string | number +type RawCoLoadGroup = Omit & { + groupId: RawId + warehouseId: RawId + destinationWarehouseId: RawId | null + lockedPlanId: RawId | null + members: Array & { tenantId: RawId; orderId: RawId; inboundId: RawId }> +} +type RawOrderCoLoadGroupInfo = Omit & { + orderId: RawId + inboundId: RawId + group: RawCoLoadGroup +} + +const basePath = '/platform/v1/first-leg/coload-groups' +const optionalId = (value: RawId | null) => value == null ? null : String(value) +const operationId = (prefix: string) => `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}` + +function normalizeGroup(raw: RawCoLoadGroup): CoLoadGroup { + return { + ...raw, + groupId: String(raw.groupId), + warehouseId: String(raw.warehouseId), + destinationWarehouseId: optionalId(raw.destinationWarehouseId), + lockedPlanId: optionalId(raw.lockedPlanId), + version: Number(raw.version), + fullPackageCount: Number(raw.fullPackageCount), + members: (raw.members || []).map((member) => ({ + ...member, + tenantId: String(member.tenantId), + orderId: String(member.orderId), + inboundId: String(member.inboundId), + fullPackageCount: Number(member.fullPackageCount), + })), + } +} + +export async function createPlatformCoLoadGroup(orderIds: string[]) { + const response = await http.post, ApiEnvelope>( + basePath, + { orderIds }, + { headers: { 'Idempotency-Key': operationId('admin-coload-create') } }, + ) + return normalizeGroup(unwrapApiResponse(response)) +} + +export async function cancelPlatformCoLoadGroup(groupId: string, expectedVersion: number, reason?: string | null) { + const response = await http.post, ApiEnvelope>( + `${basePath}/${encodeURIComponent(groupId)}/cancel`, + { expectedVersion, reason: reason?.trim() || null }, + { headers: { 'Idempotency-Key': operationId('admin-coload-cancel') } }, + ) + return normalizeGroup(unwrapApiResponse(response)) +} + +export async function batchQueryPlatformCoLoadGroups(orderIds: string[]) { + const response = await http.post, ApiEnvelope>( + `${basePath}/batch-query`, + { orderIds }, + ) + return unwrapApiResponse(response).map((item) => ({ + orderId: String(item.orderId), + inboundId: String(item.inboundId), + group: normalizeGroup(item.group), + })) +} diff --git a/src/modules/unified-orders/index.ts b/src/modules/unified-orders/index.ts index bd9a6eda..bb658ed6 100644 --- a/src/modules/unified-orders/index.ts +++ b/src/modules/unified-orders/index.ts @@ -4,6 +4,15 @@ export { type UnifiedOrderFeeDetailResource, type UnifiedOrderResource, } from './api/unifiedOrders' +export { + batchQueryPlatformCoLoadGroups, + cancelPlatformCoLoadGroup, + createPlatformCoLoadGroup, + type CoLoadGroup, + type CoLoadGroupMember, + type CoLoadGroupStatus, + type OrderCoLoadGroupInfo, +} from './api/coLoadGroups' export { unifiedOrderFulfillmentRoleLabel, diff --git a/src/modules/unified-orders/pages/UnifiedOrdersView.coLoad.test.ts b/src/modules/unified-orders/pages/UnifiedOrdersView.coLoad.test.ts new file mode 100644 index 00000000..30ba44e1 --- /dev/null +++ b/src/modules/unified-orders/pages/UnifiedOrdersView.coLoad.test.ts @@ -0,0 +1,92 @@ +import { flushPromises, shallowMount } from '@vue/test-utils' +import { ElMessageBox } from 'element-plus' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ApiResponseError } from '@/shared/api/http' +import { listPlatformUnifiedOrders } from '@/modules/unified-orders/api/unifiedOrders' +import { batchQueryPlatformCoLoadGroups, cancelPlatformCoLoadGroup, createPlatformCoLoadGroup } 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', () => ({ + batchQueryPlatformCoLoadGroups: vi.fn(), cancelPlatformCoLoadGroup: vi.fn(), createPlatformCoLoadGroup: 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}`, + channelName: 'NY-EXX', 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, +})) +const group = { + groupId: '9001', groupNo: 'CLG-9001', status: 'ACTIVE' as const, warehouseId: '8', channelCode: 'NY-EXX', destinationType: 'WAREHOUSE', + destinationWarehouseId: '9', destinationCode: 'ONT8', lockedPlanId: null, version: 0, fullPackageCount: 10, + members: rows.map((row) => ({ tenantId: String(row.tenantId), orderId: String(row.orderId), inboundId: String(row.inboundId), fullPackageCount: 5 })), + createdAt: '2026-08-29T10:00:00', updatedAt: '2026-08-29T10:00:00', +} + +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'], emits: ['confirm', 'update:modelValue'], template: '
' }, + ApiErrorMeta: { props: ['details'], template: '
{{ details.code }} {{ details.requestId }} {{ details.traceId }}
' }, + 'el-button': { 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.mocked(batchQueryPlatformCoLoadGroups).mockResolvedValue([]) + }) + + 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(listPlatformUnifiedOrders).toHaveBeenCalledTimes(1) + }) + + it('shows group number and member count and cancels only the whole ACTIVE group', async () => { + vi.mocked(batchQueryPlatformCoLoadGroups).mockResolvedValue(rows.map((row) => ({ orderId: row.orderId, inboundId: String(row.inboundId), group }))) + vi.mocked(cancelPlatformCoLoadGroup).mockResolvedValue({ ...group, status: 'CANCELLED', version: 1 }) + vi.spyOn(ElMessageBox, 'prompt').mockResolvedValue({ value: '计划调整', action: 'confirm' } as never) + const wrapper = mountView() + await flushPromises() + expect(wrapper.text()).toContain('CLG-9001') + expect(wrapper.text()).toContain('2 票 · 未锁定') + await wrapper.findAll('button').find((button) => button.text().includes('取消同柜组'))!.trigger('click') + await flushPromises() + expect(cancelPlatformCoLoadGroup).toHaveBeenCalledWith('9001', 0, '计划调整') + expect(ElMessageBox.prompt).toHaveBeenCalledWith(expect.stringContaining('不支持单独移除成员'), '取消同柜不可拆组', expect.any(Object)) + }) +}) diff --git a/src/modules/unified-orders/pages/UnifiedOrdersView.requests.test.ts b/src/modules/unified-orders/pages/UnifiedOrdersView.requests.test.ts index d5870506..4cfd0d2d 100644 --- a/src/modules/unified-orders/pages/UnifiedOrdersView.requests.test.ts +++ b/src/modules/unified-orders/pages/UnifiedOrdersView.requests.test.ts @@ -12,6 +12,11 @@ vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn().mockResolvedValue 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/coLoadGroups', () => ({ + batchQueryPlatformCoLoadGroups: vi.fn().mockResolvedValue([]), + cancelPlatformCoLoadGroup: vi.fn(), + createPlatformCoLoadGroup: vi.fn(), +})) vi.mock('@/modules/unified-orders/api/unifiedOrders', () => ({ addPlatformUnifiedOrderNote: vi.fn(), approvePlatformUnifiedOrder: vi.fn(), diff --git a/src/modules/unified-orders/pages/UnifiedOrdersView.test.ts b/src/modules/unified-orders/pages/UnifiedOrdersView.test.ts index dfc317e5..c1b71c2b 100644 --- a/src/modules/unified-orders/pages/UnifiedOrdersView.test.ts +++ b/src/modules/unified-orders/pages/UnifiedOrdersView.test.ts @@ -85,7 +85,7 @@ describe('UnifiedOrdersView contract', () => { expect(source).toContain("{ key: 'actions', label: '操作', width: 300, fixed: 'left'") expect(source).toContain("key: 'selection', type: 'selection'") expect(source).not.toContain('批量审核接口待接入') - expect(source).toContain('@selection-change="handleApprovalSelectionChange"') + expect(source).toContain('@selection-change="handleOrderSelectionChange"') expect(source).toContain('batchApprovePlatformUnifiedOrders') expect(source).toContain('批量审核通过') expect(source).toContain('title="订单列表暂时无法加载"') @@ -95,7 +95,7 @@ describe('UnifiedOrdersView contract', () => { expect(source).toContain('v-model:page-size="pageSize"') const columnSource = source.slice(source.indexOf('const columns ='), source.indexOf('function emptyFilters')) const expectedColumnKeys = [ - 'actions', 'orderNo', 'inboundCode', 'mainStatus', 'customerName', 'receivingWarehouseName', + 'actions', 'orderNo', 'inboundCode', 'coLoadGroup', 'mainStatus', 'customerName', 'receivingWarehouseName', 'destinationWarehouseName', 'channelName', 'totalPackageCount', 'totalPalletCount', 'requestedPickupAt', 'locationCodes', 'measurementReviewStatus', 'actualGrossWeightKg', 'actualVolumeCbm', 'totalNetWeightKg', 'chargeableWeightKg', 'chargeableVolumeCbm', 'expectedShipDate', 'expectedArrivalAt', 'feeReviewStatus', 'skuNos', 'totalQuantity', 'expressCompany', 'expressTrackingNo', diff --git a/src/modules/unified-orders/pages/UnifiedOrdersView.vue b/src/modules/unified-orders/pages/UnifiedOrdersView.vue index 6da2dd6d..66bc5fc3 100644 --- a/src/modules/unified-orders/pages/UnifiedOrdersView.vue +++ b/src/modules/unified-orders/pages/UnifiedOrdersView.vue @@ -1,7 +1,7 @@