Release/test 合并上生产 #2

Merged
admin merged 736 commits from release/test into main 2026-09-17 11:50:56 +08:00
13 changed files with 621 additions and 38 deletions
Showing only changes of commit 1aa2dfcdff - Show all commits
@@ -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>
+6
View File
@@ -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),
}))
}
+9
View File
@@ -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; }