feat(admin): support indivisible coload groups
This commit is contained in:
@@ -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 } })
|
||||
|
||||
@@ -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<SmartLoadingPlan>, ApiEnvelope<SmartLoadingPlan>>(
|
||||
`${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<SmartLoadingPlan>, ApiEnvelope<SmartLoadingPlan>>(`${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<SmartLoadingPlan>, ApiEnvelope<SmartLoadingPlan>>(
|
||||
`${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<SmartLoadingPlan>, ApiEnvelope<SmartLoadingPlan>>(`${smartLoadingPath}/plans/${encodeURIComponent(planId)}/push-preload`, { expectedVersion }, { headers: { 'Idempotency-Key': operationId('push-preload') } })
|
||||
return plan(unwrapApiResponse(response))
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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('<div class="package-count-editor"><el-input-number')
|
||||
expect(source).toContain('<div v-else class="package-count-editor"><el-input-number')
|
||||
expect(source).toContain('.workbench :deep(.package-count-editor .el-input-number) { width: 100%; min-width: 0; }')
|
||||
})
|
||||
|
||||
it('aggregates a two-order group and calls only the atomic whole-group assignment', async () => {
|
||||
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))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<SmartLoadingCandidate | null>(null)
|
||||
const assignForm = reactive({ packageCount: 1 })
|
||||
const quantityDrafts = reactive<Record<string, number>>({})
|
||||
const planGroupsByInboundId = ref(new Map<string, CoLoadGroup>())
|
||||
|
||||
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<SmartLoadingCandidateRow[]>(() => {
|
||||
const rows: SmartLoadingCandidateRow[] = []
|
||||
const groups = new Map<string, SmartLoadingCandidate[]>()
|
||||
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<SmartLoadingPlanRow[]>(() => {
|
||||
const inboundRows = groupSmartLoadingItems(plan.value?.items || [])
|
||||
const result: SmartLoadingPlanRow[] = []
|
||||
const grouped = new Map<string, SmartLoadingInboundItem[]>()
|
||||
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<string, number> = {}) {
|
||||
for (const item of displayedPlanItems.value) quantityDrafts[item.id] = preserved[item.id] ?? item.packageCount
|
||||
}
|
||||
function acceptPlan(next: SmartLoadingPlan, preserved: Record<string, number> = {}) { 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<string, CoLoadGroup>()
|
||||
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)
|
||||
<el-alert v-else-if="!plan.canEdit" :type="plan.finalCompletionStatus === 'COMPLETED' ? 'success' : 'info'" :closable="false" show-icon :title="plan.finalCompletionStatus === 'IN_PROGRESS' ? '正在最终确认,装柜方案暂时只读' : plan.finalCompletionStatus === 'COMPLETED' ? '装柜已完成,方案永久只读' : '当前装柜方案只读'" :description="plan.blockedReason || '请刷新计划获取最新作业状态。'" />
|
||||
<DataTable :data="displayedPlanItems" :columns="itemColumns" :loading="loading || mutating" stripe preference-key="smart-loading-items" column-settings-mode="hide" empty-text="暂无已分配货物">
|
||||
<template #toolbar-start><div><h2>{{ displayContainerNo(container) }} 装柜方案</h2><p>智能建议必须经人工核对。</p></div></template>
|
||||
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canAssign && editable && row.canAdjust" size="small" type="primary" plain :disabled="quantityDrafts[row.id] === row.packageCount" @click="adjust(row)"><Save :size="15" />保存箱数</el-button><el-button v-if="canDelete && editable && row.canRemove" size="small" type="danger" plain @click="remove(row)"><Trash2 :size="15" />移除整票</el-button><small v-if="row.detailCount > 1 && editable">含 {{ row.detailCount }} 个规格;改箱数请移除后重加</small><small v-if="row.pickedPackageCount > 0">已拣 {{ row.pickedPackageCount }} 箱</small></div></template>
|
||||
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canAssign && editable && row.canAdjust && !row.coLoadGroup" size="small" type="primary" plain :disabled="quantityDrafts[row.id] === row.packageCount" @click="adjust(row)"><Save :size="15" />保存箱数</el-button><el-button v-if="canDelete && editable && row.canRemove" size="small" type="danger" plain @click="remove(row)"><Trash2 :size="15" />{{ row.coLoadGroup ? '移除整组' : '移除整票' }}</el-button><small v-if="row.coLoadGroup">整组固定全箱数,不允许单票调整</small><small v-else-if="row.detailCount > 1 && editable">含 {{ row.detailCount }} 个规格;改箱数请移除后重加</small><small v-if="row.pickedPackageCount > 0">已拣 {{ row.pickedPackageCount }} 箱</small></div></template>
|
||||
<template #order="{ row }"><strong>{{ row.inboundCode || '—' }}</strong></template>
|
||||
<template #coLoadGroup="{ row }"><div v-if="row.coLoadGroup" class="candidate-group"><el-tag size="small" type="warning">{{ row.coLoadGroup.groupNo }} · {{ row.coLoadGroup.members.length }} 票</el-tag><span>全部成员联动,固定 {{ row.coLoadGroup.fullPackageCount }} 箱</span></div><span v-else>普通单票</span></template>
|
||||
<template #goods="{ row }"><strong>{{ row.productNameZh || '未命名货物' }}</strong><small>{{ row.skuCode || '无 SKU' }}</small></template>
|
||||
<template #packageCount="{ row }"><div class="package-count-editor"><el-input-number v-model="quantityDrafts[row.id]" :min="row.minPackageCount" :step="1" :disabled="!canAssign || !editable || !row.canAdjust" controls-position="right" /></div></template>
|
||||
<template #packageCount="{ row }"><strong v-if="row.coLoadGroup">{{ row.packageCount }} 箱(整组固定)</strong><div v-else class="package-count-editor"><el-input-number v-model="quantityDrafts[row.id]" :min="row.minPackageCount" :step="1" :disabled="!canAssign || !editable || !row.canAdjust" controls-position="right" /></div></template>
|
||||
<template #volume="{ row }">{{ formatNumber(Number(row.volumeCbm || 0)) }} m³</template><template #weight="{ row }">{{ formatNumber(Number(row.grossWeightKg || 0),1) }} kg</template>
|
||||
</DataTable>
|
||||
<DataTable :data="candidates" :columns="candidateColumns" :loading="loading || mutating" stripe preference-key="smart-loading-candidates" column-settings-mode="hide" empty-text="当前仓库暂无可排柜货物">
|
||||
<DataTable :data="displayedCandidates" :columns="candidateColumns" :loading="loading || mutating" stripe preference-key="smart-loading-candidates" column-settings-mode="hide" empty-text="当前仓库暂无可排柜货物">
|
||||
<template #toolbar-start><div><h2>候选货物</h2><p>系统基于装柜仓库、物流渠道、可用箱数及 110% 容量上限校验。</p></div></template>
|
||||
<template #actions="{ row }"><el-button v-if="canAssign && assignable" size="small" type="primary" plain :disabled="assignedInboundIds.has(row.inboundId) || row.availablePackageCount < 1" @click="openAssign(row)"><Plus :size="15" />分配</el-button></template>
|
||||
<template #actions="{ row }"><el-button v-if="canAssign && assignable" size="small" type="primary" plain :disabled="candidateBlocked(row as SmartLoadingCandidateRow)" @click="openAssign(row)"><Plus :size="15" />{{ row.coLoadGroupId ? '整组分配' : '分配' }}</el-button></template>
|
||||
<template #coLoadGroup="{ row }"><div v-if="row.coLoadGroupId" class="candidate-group"><el-tag size="small" type="warning">{{ row.groupNo }} · {{ row.memberCount }} 票</el-tag><span>整组 {{ row.groupTotalBoxes }} 箱,必须全量进入同一柜</span><small v-if="row.blockReason" class="candidate-block">{{ row.blockReason }}</small><small v-else-if="row.locked">该组已锁定,不能重复分配</small></div><span v-else>普通单票</span></template>
|
||||
<template #averageUnitVolume="{ row }">{{ formatNumber(Number(row.averageUnitVolumeCbm || 0)) }} m³</template><template #unitGrossWeight="{ row }">{{ formatNumber(Number(row.unitGrossWeightKg || 0),1) }} kg</template>
|
||||
<template #cartonIdentityStatus><el-tag size="small" type="info" effect="plain">未核验</el-tag></template>
|
||||
</DataTable>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
<AppDialog v-model="assignOpen" title="按入仓码分配箱数" :confirm-loading="mutating" confirm-text="确认分配" @confirm="assign"><div v-if="selectedCandidate" class="assign-dialog"><p><strong>{{ selectedCandidate.inboundCode || '入仓码暂缺' }}</strong></p><p>{{ selectedCandidate.goodsSummary || '货物摘要暂缺' }} · 可用 {{ selectedCandidate.availablePackageCount }} 箱</p><el-form label-position="top"><el-form-item label="本次计划装柜箱数" required><el-input-number v-model="assignForm.packageCount" :min="1" :max="selectedCandidate.availablePackageCount" :step="1" controls-position="right" /></el-form-item></el-form><el-alert :title="explainCandidate(selectedCandidate)" type="info" :closable="false" /><small>只记录数量,具体上柜箱号未核验。</small></div></AppDialog>
|
||||
<AppDialog v-model="assignOpen" :title="selectedCandidate?.coLoadGroupId ? '整组加入当前柜次' : '按入仓码分配箱数'" :confirm-loading="mutating" :confirm-disabled="Boolean(selectedCandidate?.blockReason || selectedCandidate?.locked)" :confirm-text="selectedCandidate?.coLoadGroupId ? '确认整组分配' : '确认分配'" @confirm="assign"><div v-if="selectedCandidate" class="assign-dialog"><p><strong>{{ selectedCandidate.groupNo || selectedCandidate.inboundCode || '入仓码暂缺' }}</strong></p><template v-if="selectedCandidate.coLoadGroupId"><p>组内 {{ selectedCandidate.memberCount }} 票,共 {{ selectedCandidate.groupTotalBoxes }} 箱</p><el-alert :title="selectedCandidate.blockReason || '同柜且不可拆:全部成员按完整箱数一次加入,不能部分输入或单独移除。'" :type="selectedCandidate.blockReason ? 'error' : 'warning'" :closable="false" /></template><template v-else><p>{{ selectedCandidate.goodsSummary || '货物摘要暂缺' }} · 可用 {{ selectedCandidate.availablePackageCount }} 箱</p><el-form label-position="top"><el-form-item label="本次计划装柜箱数" required><el-input-number v-model="assignForm.packageCount" :min="1" :max="selectedCandidate.availablePackageCount" :step="1" controls-position="right" /></el-form-item></el-form><el-alert :title="explainCandidate(selectedCandidate)" type="info" :closable="false" /><small>只记录数量,具体上柜箱号未核验。</small></template></div></AppDialog>
|
||||
</section>
|
||||
</AdminPage>
|
||||
</template>
|
||||
@@ -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; } }
|
||||
</style>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 } })
|
||||
})
|
||||
})
|
||||
@@ -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<CoLoadGroup, 'groupId' | 'warehouseId' | 'destinationWarehouseId' | 'lockedPlanId' | 'members'> & {
|
||||
groupId: RawId
|
||||
warehouseId: RawId
|
||||
destinationWarehouseId: RawId | null
|
||||
lockedPlanId: RawId | null
|
||||
members: Array<Omit<CoLoadGroupMember, 'tenantId' | 'orderId' | 'inboundId'> & { tenantId: RawId; orderId: RawId; inboundId: RawId }>
|
||||
}
|
||||
type RawOrderCoLoadGroupInfo = Omit<OrderCoLoadGroupInfo, 'orderId' | 'inboundId' | 'group'> & {
|
||||
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<RawCoLoadGroup>, ApiEnvelope<RawCoLoadGroup>>(
|
||||
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<RawCoLoadGroup>, ApiEnvelope<RawCoLoadGroup>>(
|
||||
`${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<RawOrderCoLoadGroupInfo[]>, ApiEnvelope<RawOrderCoLoadGroupInfo[]>>(
|
||||
`${basePath}/batch-query`,
|
||||
{ orderIds },
|
||||
)
|
||||
return unwrapApiResponse(response).map((item) => ({
|
||||
orderId: String(item.orderId),
|
||||
inboundId: String(item.inboundId),
|
||||
group: normalizeGroup(item.group),
|
||||
}))
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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: '<div />' } }))
|
||||
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: '<main><slot /></main>' },
|
||||
ListPageLayout: { template: '<section><slot name="toolbar" /><slot name="state" /><slot /></section>' },
|
||||
QueryToolbar: { template: '<div />' },
|
||||
DataTable: { name: 'DataTable', props: ['data'], emits: ['selection-change'], template: '<section><slot name="toolbar-actions" /><button data-testid="select" @click="$emit(\'selection-change\', data)">勾选两票</button><template v-if="data?.length"><slot name="actions" :row="data[0]" /><slot name="coLoadGroup" :row="data[0]" /></template></section>' },
|
||||
AppDialog: { name: 'AppDialog', props: ['modelValue', 'confirmText'], emits: ['confirm', 'update:modelValue'], template: '<section v-if="modelValue"><slot /><button data-testid="dialog-confirm" @click="$emit(\'confirm\')">{{ confirmText }}</button></section>' },
|
||||
ApiErrorMeta: { props: ['details'], template: '<div>{{ details.code }} {{ details.requestId }} {{ details.traceId }}</div>' },
|
||||
'el-button': { template: '<button @click="$emit(\'click\')"><slot /></button>' },
|
||||
'el-tag': { template: '<span><slot /></span>' },
|
||||
'el-alert': { props: ['title'], template: '<div>{{ title }}</div>' },
|
||||
} },
|
||||
})
|
||||
}
|
||||
|
||||
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))
|
||||
})
|
||||
})
|
||||
@@ -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: '<div />' } }))
|
||||
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(),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch, type Component } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { ArrowLeft, Box, ChevronDown, ChevronUp, CircleCheck, Eye, GitBranch, History, ListChecks, MoreHorizontal, PackageOpen, Ruler, SlidersHorizontal, StickyNote, WalletCards } from '@lucide/vue'
|
||||
import { ArrowLeft, Box, ChevronDown, ChevronUp, CircleCheck, Eye, GitBranch, History, Link2, ListChecks, MoreHorizontal, PackageOpen, Ruler, SlidersHorizontal, StickyNote, Unlink, WalletCards } from '@lucide/vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import {
|
||||
getPlatformUnifiedOrder,
|
||||
@@ -15,6 +15,12 @@ import {
|
||||
type UnifiedOrderMainStatus,
|
||||
type UnifiedOrderResource,
|
||||
} from '@/modules/unified-orders/api/unifiedOrders'
|
||||
import {
|
||||
batchQueryPlatformCoLoadGroups,
|
||||
cancelPlatformCoLoadGroup,
|
||||
createPlatformCoLoadGroup,
|
||||
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'
|
||||
@@ -121,6 +127,11 @@ const approvalLoadingId = ref('')
|
||||
const approvalSaving = ref(false)
|
||||
const batchApprovalSaving = ref(false)
|
||||
const selectedApprovalOrders = ref<UnifiedOrderResource[]>([])
|
||||
const selectedOrders = ref<UnifiedOrderResource[]>([])
|
||||
const coLoadGroupsByOrderId = ref(new Map<string, CoLoadGroup>())
|
||||
const coLoadCreateOpen = ref(false)
|
||||
const coLoadSaving = ref(false)
|
||||
const coLoadError = ref<ApiErrorDetails | null>(null)
|
||||
const approvalOrder = ref<UnifiedOrderResource | null>(null)
|
||||
const noteOpen = ref(false)
|
||||
const noteSaving = ref(false)
|
||||
@@ -241,12 +252,13 @@ const queryFields = computed(() => showAdvancedFilters.value
|
||||
|
||||
const columns = computed<DataTableColumn[]>(() => [
|
||||
{ key: 'actions', label: '操作', width: 300, fixed: 'left', slotName: 'actions' },
|
||||
...(can('platform:order:approve') ? [{
|
||||
...(can('platform:order:approve') || can('platform:firstleg:smart-loading:assign') ? [{
|
||||
key: 'selection', type: 'selection', width: 48, fixed: 'left',
|
||||
selectable: (row: UnifiedOrderResource) => canApprove(row) && !batchApprovalSaving.value,
|
||||
selectable: () => !batchApprovalSaving.value && !coLoadSaving.value,
|
||||
} satisfies DataTableColumn] : []),
|
||||
{ key: 'orderNo', prop: 'orderNo', label: '订单号', minWidth: 180, slotName: 'field' },
|
||||
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 190, slotName: 'inboundCode' },
|
||||
{ key: 'coLoadGroup', label: '同柜组', minWidth: 190, slotName: 'coLoadGroup' },
|
||||
{ key: 'mainStatus', prop: 'mainStatus', label: '订单主状态', minWidth: 130, slotName: 'mainStatus' },
|
||||
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 150, slotName: 'field' },
|
||||
{ key: 'receivingWarehouseName', prop: 'receivingWarehouseName', label: '平台收货仓', minWidth: 150, slotName: 'field' },
|
||||
@@ -694,8 +706,11 @@ async function loadWarehouses() {
|
||||
|
||||
async function loadOrders() {
|
||||
selectedApprovalOrders.value = []
|
||||
selectedOrders.value = []
|
||||
coLoadGroupsByOrderId.value = new Map()
|
||||
loading.value = true
|
||||
loadError.value = null
|
||||
coLoadError.value = null
|
||||
try {
|
||||
const activeFilters = appliedFilters.value
|
||||
const range = activeFilters.createdRange
|
||||
@@ -722,6 +737,14 @@ async function loadOrders() {
|
||||
})
|
||||
records.value = result.records
|
||||
total.value = result.total
|
||||
if (can('platform:firstleg:smart-loading:assign') && result.records.length) {
|
||||
try {
|
||||
const groups = await batchQueryPlatformCoLoadGroups(result.records.map((row) => String(row.orderId)))
|
||||
coLoadGroupsByOrderId.value = new Map(groups.map((item) => [item.orderId, item.group]))
|
||||
} catch (error) {
|
||||
coLoadError.value = getApiErrorDetails(error, '同柜不可拆组信息加载失败')
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
records.value = []
|
||||
total.value = 0
|
||||
@@ -759,10 +782,62 @@ function openMeasurement(row: UnifiedOrderResource) {
|
||||
measurementDialogOpen.value = true
|
||||
}
|
||||
|
||||
function handleApprovalSelectionChange(rows: UnifiedOrderResource[]) {
|
||||
function handleOrderSelectionChange(rows: UnifiedOrderResource[]) {
|
||||
selectedOrders.value = rows
|
||||
selectedApprovalOrders.value = rows.filter(canApprove)
|
||||
}
|
||||
|
||||
function coLoadGroup(row: UnifiedOrderResource) {
|
||||
return coLoadGroupsByOrderId.value.get(String(row.orderId)) || null
|
||||
}
|
||||
|
||||
function openCoLoadCreate() {
|
||||
if (selectedOrders.value.length < 2) return
|
||||
coLoadError.value = null
|
||||
coLoadCreateOpen.value = true
|
||||
}
|
||||
|
||||
async function createCoLoadGroup() {
|
||||
if (selectedOrders.value.length < 2 || coLoadSaving.value) return
|
||||
coLoadSaving.value = true
|
||||
coLoadError.value = null
|
||||
try {
|
||||
const group = await createPlatformCoLoadGroup(selectedOrders.value.map((row) => String(row.orderId)))
|
||||
coLoadCreateOpen.value = false
|
||||
ElMessage.success(`已创建同柜不可拆组 ${group.groupNo},共 ${group.members.length} 票`)
|
||||
await loadOrders()
|
||||
} catch (error) {
|
||||
coLoadError.value = getApiErrorDetails(error, '创建同柜不可拆组失败')
|
||||
} finally {
|
||||
coLoadSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelCoLoadGroup(row: UnifiedOrderResource) {
|
||||
const group = coLoadGroup(row)
|
||||
if (!group || group.status !== 'ACTIVE' || coLoadSaving.value) return
|
||||
let reason = ''
|
||||
try {
|
||||
const result = await ElMessageBox.prompt(
|
||||
`将整组取消 ${group.groupNo}(${group.members.length} 票)。不支持单独移除成员;如需调整,请取消后重新建组。`,
|
||||
'取消同柜不可拆组',
|
||||
{ confirmButtonText: '确认整组取消', cancelButtonText: '返回', inputPlaceholder: '取消原因(选填)', inputType: 'textarea' },
|
||||
)
|
||||
reason = result.value
|
||||
} catch { return }
|
||||
coLoadSaving.value = true
|
||||
coLoadError.value = null
|
||||
try {
|
||||
await cancelPlatformCoLoadGroup(group.groupId, group.version, reason)
|
||||
ElMessage.success(`同柜不可拆组 ${group.groupNo} 已整组取消`)
|
||||
await loadOrders()
|
||||
} catch (error) {
|
||||
coLoadError.value = getApiErrorDetails(error, '取消同柜不可拆组失败')
|
||||
} finally {
|
||||
coLoadSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function batchApproveOrders() {
|
||||
const selected = [...selectedApprovalOrders.value]
|
||||
if (!selected.length || batchApprovalSaving.value) return
|
||||
@@ -816,17 +891,19 @@ function clearMeasurementOrder() {
|
||||
measurementOrder.value = null
|
||||
}
|
||||
|
||||
type UnifiedOrderRowAction = 'note' | 'approve' | 'measurement'
|
||||
type UnifiedOrderRowAction = 'note' | 'approve' | 'measurement' | 'cancelCoLoad'
|
||||
const unifiedOrderRowActionIcons: Record<UnifiedOrderRowAction, Component> = {
|
||||
note: StickyNote,
|
||||
approve: CircleCheck,
|
||||
measurement: Ruler,
|
||||
cancelCoLoad: Unlink,
|
||||
}
|
||||
function unifiedOrderSecondaryActions(row: UnifiedOrderResource): UnifiedOrderRowAction[] {
|
||||
return [
|
||||
...(can('platform:order:note') ? ['note' as const] : []),
|
||||
...(canApprove(row) ? ['approve' as const] : []),
|
||||
...(row.mainStatus === 'WAIT_MEASURE' && row.inboundId && can('platform:firstleg:inbound:measure:view') ? ['measurement' as const] : []),
|
||||
...(can('platform:firstleg:smart-loading:assign') && coLoadGroup(row)?.status === 'ACTIVE' ? ['cancelCoLoad' as const] : []),
|
||||
]
|
||||
}
|
||||
function directUnifiedOrderActions(row: UnifiedOrderResource) {
|
||||
@@ -841,15 +918,17 @@ function moreUnifiedOrderActionsLabel(row: UnifiedOrderResource) {
|
||||
function unifiedOrderActionLabel(action: UnifiedOrderRowAction) {
|
||||
if (action === 'note') return '添加备注'
|
||||
if (action === 'approve') return '审核通过'
|
||||
if (action === 'cancelCoLoad') return '取消同柜组'
|
||||
return '测量作业'
|
||||
}
|
||||
function unifiedOrderActionType(action: UnifiedOrderRowAction) {
|
||||
return action === 'approve' ? 'success' : 'primary'
|
||||
return action === 'approve' ? 'success' : action === 'cancelCoLoad' ? 'danger' : 'primary'
|
||||
}
|
||||
function runMoreAction(command: string | number | object, row: UnifiedOrderResource) {
|
||||
if (command === 'note') openNoteDialog(row)
|
||||
if (command === 'approve') openApproval(row)
|
||||
if (command === 'measurement') openMeasurement(row)
|
||||
if (command === 'cancelCoLoad') void cancelCoLoadGroup(row)
|
||||
}
|
||||
|
||||
function closeDetail() {
|
||||
@@ -1223,9 +1302,11 @@ onBeforeUnmount(() => {
|
||||
|
||||
<template #default>
|
||||
<div v-if="!loadError" id="unified-orders-results" class="surface-panel table-panel table-card" role="tabpanel">
|
||||
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="records" :columns="columns" preference-key="unified-orders-table-v2" :loading="loading" :total="total" :page-sizes="[20, 50, 100]" row-key="orderId" border stripe empty-text="暂无符合条件的订单" aria-label="统一订单列表" @selection-change="handleApprovalSelectionChange">
|
||||
<div v-if="coLoadError" class="coload-error" role="alert"><span>{{ coLoadError.message }}</span><ApiErrorMeta :details="coLoadError" /></div>
|
||||
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="records" :columns="columns" preference-key="unified-orders-table-v2" :loading="loading" :total="total" :page-sizes="[20, 50, 100]" row-key="orderId" border stripe empty-text="暂无符合条件的订单" aria-label="统一订单列表" @selection-change="handleOrderSelectionChange">
|
||||
<template #toolbar-actions>
|
||||
<el-button v-if="can('platform:order:approve')" type="success" :loading="batchApprovalSaving" :disabled="loading || !selectedApprovalOrders.length" @click="batchApproveOrders"><CircleCheck :size="16" />批量审核通过<span v-if="selectedApprovalOrders.length">({{ selectedApprovalOrders.length }})</span></el-button>
|
||||
<el-button v-if="can('platform:firstleg:smart-loading:assign')" type="primary" plain :loading="coLoadSaving" :disabled="loading || selectedOrders.length < 2" @click="openCoLoadCreate"><Link2 :size="16" />设为同柜不可拆组<span v-if="selectedOrders.length">({{ selectedOrders.length }})</span></el-button>
|
||||
<el-button v-if="can('platform:firstleg:smart-loading:list')" type="primary" @click="smartLoadingOpen = true"><PackageOpen :size="16" />智能装柜</el-button>
|
||||
</template>
|
||||
<template #actions="{ row }">
|
||||
@@ -1255,6 +1336,7 @@ onBeforeUnmount(() => {
|
||||
><GitBranch :size="14" />{{ orderRelationLabel(row as UnifiedOrderResource) }}</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<template #coLoadGroup="{ row }"><div v-if="coLoadGroup(row as UnifiedOrderResource)" class="coload-cell"><el-tag size="small" :type="coLoadGroup(row as UnifiedOrderResource)?.status === 'ACTIVE' ? 'warning' : 'info'">{{ coLoadGroup(row as UnifiedOrderResource)?.groupNo }}</el-tag><span>{{ coLoadGroup(row as UnifiedOrderResource)?.members.length }} 票 · {{ coLoadGroup(row as UnifiedOrderResource)?.status === 'ACTIVE' ? '未锁定' : coLoadGroup(row as UnifiedOrderResource)?.status === 'LOCKED' ? '已锁定' : '已消费' }}</span></div><span v-else>—</span></template>
|
||||
<template #orderStatus="{ row }"><StatusBadge :status="orderStatusLabel((row as UnifiedOrderResource).internalOrderStatus || (row as UnifiedOrderResource).mainStatus)" /></template>
|
||||
<template #measurementReviewStatus="{ row }"><StatusBadge :status="measurementStatusLabel((row as UnifiedOrderResource).measurement?.reviewStatus)" /></template>
|
||||
<template #field="{ row, column }"><LineClampText v-if="['customerRemark', 'latestInternalNote'].includes(String(column.property))" :content="displayField(row as UnifiedOrderResource, column.property)" :lines="2" /><template v-else>{{ displayField(row as UnifiedOrderResource, column.property) }}</template></template>
|
||||
@@ -1272,6 +1354,11 @@ onBeforeUnmount(() => {
|
||||
</template>
|
||||
</ListPageLayout>
|
||||
<SmartLoadingDialog v-model="smartLoadingOpen" />
|
||||
<AppDialog v-model="coLoadCreateOpen" title="设为同柜不可拆组" confirm-text="确认建组" :confirm-loading="coLoadSaving" :confirm-disabled="selectedOrders.length < 2" @confirm="createCoLoadGroup">
|
||||
<el-alert type="warning" :closable="false" show-icon :title="`所选 ${selectedOrders.length} 票必须全量进入同一柜,不能部分装柜、拆子单或单独移除成员。`" />
|
||||
<div class="coload-selection"><span v-for="row in selectedOrders" :key="String(row.orderId)">{{ row.orderNo || row.inboundCode || row.orderId }}</span></div>
|
||||
<div v-if="coLoadError" class="coload-error" role="alert"><span>{{ coLoadError.message }}</span><ApiErrorMeta :details="coLoadError" /></div>
|
||||
</AppDialog>
|
||||
</template>
|
||||
|
||||
<AppDrawer v-model="relationOpen" title="父子订单关联" size="min(860px, 94vw)" :show-footer="false" destroy-on-close>
|
||||
@@ -1337,6 +1424,12 @@ onBeforeUnmount(() => {
|
||||
.status-tab.is-active::after { background: var(--cc-primary); }
|
||||
.orders-query-toolbar { border: 0; }
|
||||
.table-card { overflow: hidden; }
|
||||
.coload-error { margin: 8px 12px; padding: 8px 10px; border: 1px solid var(--el-color-danger-light-5); border-radius: 4px; background: var(--el-color-danger-light-9); color: var(--el-color-danger); font-size: 12px; }
|
||||
.coload-error :deep(.api-error-meta) { margin-top: 5px; }
|
||||
.coload-cell { display: grid; width: fit-content; gap: 3px; }
|
||||
.coload-cell span { color: var(--cc-muted); font-size: 11px; }
|
||||
.coload-selection { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 12px; }
|
||||
.coload-selection > span { padding: 4px 7px; border: 1px solid var(--cc-border); border-radius: 4px; background: var(--cc-surface-muted); font-size: 12px; }
|
||||
.table-card :deep(.el-table) { --el-table-border-color: #e5e7eb; --el-table-header-bg-color: #f9fafb; --el-table-row-hover-bg-color: #f3f7fb; --el-table-tr-bg-color: #fff; color: #374151; font-size: 12px; }
|
||||
.table-card :deep(.el-table th.el-table__cell) { height: 42px; padding: 0; background: #f9fafb; color: #374151; font-size: 12px; font-weight: 600; }
|
||||
.table-card :deep(.el-table td.el-table__cell) { min-height: 42px; padding: 9px 0; }
|
||||
|
||||
Reference in New Issue
Block a user