feat(admin): support indivisible coload groups

This commit is contained in:
2026-08-29 10:57:01 +08:00
parent aee562d998
commit 1aa2dfcdff
13 changed files with 621 additions and 38 deletions
@@ -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; }