Release/test 合并上生产 #2

Merged
admin merged 736 commits from release/test into main 2026-09-17 11:50:56 +08:00
9 changed files with 214 additions and 67 deletions
Showing only changes of commit de1b956d43 - Show all commits
@@ -205,15 +205,17 @@ describe('GlobalSmartLoadingView', () => {
it('aggregates a two-order candidate group and calls only whole-group assignment', async () => {
vi.mocked(listSmartLoadingCandidates).mockResolvedValue([
{ accountId: '1', inboundId: '201', orderId: '301', orderNo: 'ORD-201', inboundCode: 'NYEXX2608290051', 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: '2093539779795795969', groupNo: 'CLG-2093539779795795969', memberCount: 2, groupTotalBoxes: 10, locked: false, blockReason: null },
{ accountId: '2', inboundId: '202', orderId: '302', orderNo: 'ORD-202', inboundCode: 'NYEXX2608290052', warehouseId: '8', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', customerName: '客户二', goodsSummary: '货物二', availablePackageCount: 6, unitGrossWeightKg: 10, unitNetWeightKg: 9, averageUnitVolumeCbm: 0.2, availableVolumeCbm: 1.2, coLoadGroupId: '2093539779795795969', groupNo: 'CLG-2093539779795795969', memberCount: 2, groupTotalBoxes: 10, locked: false, blockReason: null },
{ accountId: '1', inboundId: '201', orderId: '301', orderNo: 'ORD-201', inboundCode: 'NYEXX2608290051', 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: '2093539779795795969', groupNo: 'CLG-2093539779795795969', memberCount: 3, groupTotalBoxes: 12, locked: false, blockReason: null },
{ accountId: '1', inboundId: '203', orderId: '301', orderNo: 'ORD-201', inboundCode: 'NYEXX2608290051-B', warehouseId: '8', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', destinationWarehouseId: '10', destinationWarehouseCode: 'JFK8', destinationWarehouseName: 'JFK8', customerName: '客户一', goodsSummary: '货物一', availablePackageCount: 2, unitGrossWeightKg: 10, unitNetWeightKg: 9, averageUnitVolumeCbm: 0.2, availableVolumeCbm: 0.4, coLoadGroupId: '2093539779795795969', groupNo: 'CLG-2093539779795795969', memberCount: 3, groupTotalBoxes: 12, locked: false, blockReason: null },
{ accountId: '2', inboundId: '202', orderId: '302', orderNo: 'ORD-202', inboundCode: 'NYEXX2608290052', warehouseId: '8', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', customerName: '客户二', goodsSummary: '货物二', availablePackageCount: 6, unitGrossWeightKg: 10, unitNetWeightKg: 9, averageUnitVolumeCbm: 0.2, availableVolumeCbm: 1.2, coLoadGroupId: '2093539779795795969', groupNo: 'CLG-2093539779795795969', memberCount: 3, groupTotalBoxes: 12, locked: false, blockReason: null },
])
vi.mocked(assignSmartLoadingGroup).mockResolvedValue(plan)
const wrapper = mount(GlobalSmartLoadingView, { attachTo: document.body, global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.text()).toContain('CLG-2093539779795795969 · 2 票')
expect(wrapper.text()).toContain('NYEXX2608290051、NYEXX2608290052')
expect(wrapper.text()).toContain('3 个入仓片段')
expect(wrapper.text()).toContain('ORD-201:NYEXX2608290051、NYEXX2608290051-B')
const buttons = wrapper.findAll('button').filter((button) => button.text().includes('整组分配'))
expect(buttons).toHaveLength(1)
await buttons[0].trigger('click')
@@ -243,25 +245,27 @@ describe('GlobalSmartLoadingView', () => {
})
it('aggregates assigned members into one locked group row and removes the group once', async () => {
const items = ['201', '202'].map((inboundId, index) => ({
const baseItems = ['201', '202'].map((inboundId, index) => ({
...plan.items[0], id: `70${index}`, accountId: `${index + 1}`, inboundId, inboundDetailId: `80${index}`, orderId: `30${index}`, orderNo: `ORD-${index}`, inboundCode: `NYEXX260829005${index + 1}`, customerName: `客户${index}`, packageCount: 5, grossWeightKg: 50, volumeCbm: 1, loadSequence: index + 1,
}))
const items = [baseItems[0], { ...baseItems[0], id: '703', inboundId: '203', inboundDetailId: '803', inboundCode: 'NYEXX2608290051-B', packageCount: 3, grossWeightKg: 30, volumeCbm: 0.6 }, baseItems[1]]
const groupedPlan = { ...plan, items }
const group = {
groupId: '2093539779795795969', groupNo: 'CLG-2093539779795795969', status: 'LOCKED' as const, warehouseId: '8', channelCode: 'ZBYHK', destinationType: 'WAREHOUSE', destinationWarehouseId: '9', destinationCode: 'LAX9', lockedPlanId: '99', version: 1,
members: items.map((item) => ({ tenantId: item.accountId, orderId: item.orderId, inboundId: item.inboundId, fullPackageCount: 5 })), fullPackageCount: 10, createdAt: '2026-08-29T10:00:00', updatedAt: '2026-08-29T10:30:00',
groupId: '2093539779795795969', groupNo: 'CLG-2093539779795795969', status: 'LOCKED' as const, warehouseId: '8', channelCode: 'ZBYHK', lockedPlanId: '99', version: 1,
members: items.map((item) => ({ tenantId: item.accountId, orderId: item.orderId, inboundId: item.inboundId, fullPackageCount: item.packageCount })), fullPackageCount: 13, createdAt: '2026-08-29T10:00:00', updatedAt: '2026-08-29T10:30:00',
}
vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValueOnce(groupedPlan).mockResolvedValue({ ...groupedPlan, version: 3, items: [] })
vi.mocked(batchQueryPlatformCoLoadGroups).mockResolvedValue(items.map((item) => ({ orderId: item.orderId, inboundId: item.inboundId, group })))
vi.mocked(batchQueryPlatformCoLoadGroups).mockResolvedValue([...new Set(items.map((item) => item.orderId))].map((orderId) => ({ orderId, group })))
vi.mocked(removeSmartLoadingGroup).mockResolvedValue({ ...groupedPlan, version: 3, items: [] })
vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue('confirm' as never)
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.text()).toContain('CLG-2093539779795795969 · 2 票')
expect(wrapper.text()).toContain('固定 10 箱 · 已锁定')
expect(wrapper.text()).toContain('CLG-2093539779795795969 · 2 票 · 3 个入仓片段')
expect(wrapper.text()).toContain('ORD-0:NYEXX2608290051、NYEXX2608290051-B')
expect(wrapper.text()).toContain('固定 13 箱 · 已锁定')
expect(wrapper.text()).toContain('整组固定全箱数,不允许单票调整')
expect(wrapper.findAllComponents(ElInputNumber).some((input) => input.props('modelValue') === 10)).toBe(false)
expect(wrapper.findAllComponents(ElInputNumber).some((input) => input.props('modelValue') === 13)).toBe(false)
const removeButton = wrapper.findAll('button').find((button) => button.text().includes('移除整组'))!
await removeButton.trigger('click')
await flushPromises()
@@ -84,13 +84,28 @@ const editable = computed(() => plan.value?.canEdit === true)
const assignable = computed(() => plan.value?.canAssign === true)
const selectedPlanStatus = computed(() => selectedContainer.value?.planStatus || null)
const assignedInboundIds = computed(() => new Set((plan.value?.items || []).map((item) => item.inboundId)))
type SmartLoadingCandidateRow = SmartLoadingCandidate & { sourceCandidates: SmartLoadingCandidate[]; memberInboundCodes: string[] }
type SmartLoadingCandidateRow = SmartLoadingCandidate & { sourceCandidates: SmartLoadingCandidate[]; memberInboundCodes: string[]; memberOrderSummaries: string[]; memberOrderCount: number; inboundFragmentCount: number }
function memberOrderSummaries<T extends { orderId: string; orderNo?: string | null; inboundCode?: string | null }>(members: T[]) {
const orders = new Map<string, { label: string; inboundCodes: string[] }>()
for (const member of members) {
const current = orders.get(member.orderId) || { label: member.orderNo || member.orderId, inboundCodes: [] }
const inboundCode = member.inboundCode || '入仓码暂缺'
if (!current.inboundCodes.includes(inboundCode)) current.inboundCodes.push(inboundCode)
orders.set(member.orderId, current)
}
return [...orders.values()].map((order) => `${order.label}:${order.inboundCodes.join('、')}`)
}
function groupOrderCount(group: CoLoadGroup) { return new Set(group.members.map((member) => member.orderId)).size }
function groupMemberLabel(group: CoLoadGroup) {
const orderCount = groupOrderCount(group)
return group.members.length === orderCount ? `${orderCount} 票` : `${orderCount} 票 · ${group.members.length} 个入仓片段`
}
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 || '入仓码暂缺'] })
rows.push({ ...candidate, sourceCandidates: [candidate], memberInboundCodes: [candidate.inboundCode || '入仓码暂缺'], memberOrderSummaries: memberOrderSummaries([candidate]), memberOrderCount: 1, inboundFragmentCount: 1 })
continue
}
groups.set(candidate.coLoadGroupId, [...(groups.get(candidate.coLoadGroupId) || []), candidate])
@@ -106,11 +121,14 @@ const displayedCandidates = computed<SmartLoadingCandidateRow[]>(() => {
availableVolumeCbm: members.reduce((sum, member) => sum + Number(member.availableVolumeCbm || 0), 0),
sourceCandidates: members,
memberInboundCodes: members.map((member) => member.inboundCode || '入仓码暂缺'),
memberOrderSummaries: memberOrderSummaries(members),
memberOrderCount: new Set(members.map((member) => member.orderId)).size,
inboundFragmentCount: Number(first.memberCount || members.length),
})
}
return rows
})
type SmartLoadingPlanRow = SmartLoadingInboundItem & { coLoadGroup: CoLoadGroup | null; memberInboundCodes: string[] }
type SmartLoadingPlanRow = SmartLoadingInboundItem & { coLoadGroup: CoLoadGroup | null; memberInboundCodes: string[]; memberOrderSummaries: string[] }
const displayedPlanItems = computed<SmartLoadingPlanRow[]>(() => {
const inboundRows = groupSmartLoadingItems(plan.value?.items || [])
const result: SmartLoadingPlanRow[] = []
@@ -118,7 +136,7 @@ const displayedPlanItems = computed<SmartLoadingPlanRow[]>(() => {
for (const row of inboundRows) {
const group = planGroupsByInboundId.value.get(row.inboundId)
if (!group) {
result.push({ ...row, coLoadGroup: null, memberInboundCodes: [row.inboundCode || '入仓码暂缺'] })
result.push({ ...row, coLoadGroup: null, memberInboundCodes: [row.inboundCode || '入仓码暂缺'], memberOrderSummaries: memberOrderSummaries([row]) })
continue
}
grouped.set(group.groupId, [...(grouped.get(group.groupId) || []), row])
@@ -143,6 +161,7 @@ const displayedPlanItems = computed<SmartLoadingPlanRow[]>(() => {
detailCount: rows.reduce((sum, row) => sum + row.detailCount, 0),
coLoadGroup: group,
memberInboundCodes: rows.map((row) => row.inboundCode || '入仓码暂缺'),
memberOrderSummaries: memberOrderSummaries(rows),
})
}
return result
@@ -430,7 +449,7 @@ async function remove(source: object) {
if (!plan.value || !item.canRemove) return
const row = source as SmartLoadingPlanRow
const confirmed = await ElMessageBox.confirm(row.coLoadGroup
? `确认整组移除 ${row.coLoadGroup.groupNo}(${row.coLoadGroup.members.length} 票、${row.packageCount} 箱)吗?组内成员不能单独移除。`
? `确认整组移除 ${row.coLoadGroup.groupNo}(${groupMemberLabel(row.coLoadGroup)}、${row.packageCount} 箱)吗?组内所有入仓片段将联动移除。`
: `确认从当前柜次移除入仓码“${item.inboundCode || '—'}”的 ${item.packageCount} 箱吗?`, row.coLoadGroup ? '移除同柜不可拆组' : '移除装柜明细', {
type: 'warning', confirmButtonText: row.coLoadGroup ? '移除整组' : '移除', cancelButtonText: '取消',
}).catch(() => false)
@@ -579,7 +598,7 @@ onMounted(() => { void load(); void loadCustomers() })
@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 #coLoadGroup="{ row }"><div v-if="row.coLoadGroupId" class="candidate-group"><el-tag size="small" type="warning">{{ row.groupNo }} · {{ row.memberOrderCount }} 票 · {{ row.inboundFragmentCount }} 个入仓片段</el-tag><span v-for="summary in row.memberOrderSummaries" :key="summary">{{ summary }}</span><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 #unitGrossWeight="{ row }">{{ formatNumber(Number(row.unitGrossWeightKg || 0), 1) }} kg</template>
<template #averageUnitVolume="{ row }">{{ formatNumber(Number(row.averageUnitVolumeCbm || 0)) }} m³</template>
<template #cartonIdentityStatus><el-tag size="small" type="info" effect="plain">未核验</el-tag></template>
@@ -658,7 +677,7 @@ onMounted(() => { void load(); void loadCustomers() })
</div>
<DataTable :data="displayedPlanItems" :columns="itemColumns" :loading="selectionLoading || mutating" stripe preference-key="global-smart-loading-items" column-settings-mode="hide" empty-text="当前柜次暂无装柜明细">
<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">多规格;改箱数请移除后重加</small><small v-if="row.pickedPackageCount > 0">已拣 {{ row.pickedPackageCount }} 箱</small></div></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.memberInboundCodes.join('、') }}</span><span>固定 {{ row.coLoadGroup.fullPackageCount }} 箱 · {{ row.coLoadGroup.lockedPlanId ? '已锁定' : '未锁定' }}</span></div><span v-else>普通单票</span></template>
<template #coLoadGroup="{ row }"><div v-if="row.coLoadGroup" class="candidate-group"><el-tag size="small" type="warning">{{ row.coLoadGroup.groupNo }} · {{ groupMemberLabel(row.coLoadGroup) }}</el-tag><span v-for="summary in row.memberOrderSummaries" :key="summary">{{ summary }}</span><span>固定 {{ row.coLoadGroup.fullPackageCount }} 箱 · {{ row.coLoadGroup.lockedPlanId ? '已锁定' : '未锁定' }}</span></div><span v-else>普通单票</span></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" :max="adjustmentMax(row)" :step="1" :disabled="!canAssign || !editable || !row.canAdjust" controls-position="right" /></div></template>
<template #weight="{ row }">{{ formatNumber(Number(row.grossWeightKg || 0), 1) }} kg</template>
<template #volume="{ row }">{{ formatNumber(Number(row.volumeCbm || 0)) }} m³</template>
@@ -671,9 +690,9 @@ onMounted(() => { void load(); void loadCustomers() })
<div v-if="selectedCandidate" class="assign-dialog">
<p><strong>{{ selectedCandidate.groupNo || selectedCandidate.customerName || '客户待补充' }}</strong></p>
<template v-if="selectedCandidate.coLoadGroupId">
<p>组内 {{ selectedCandidate.memberCount }} 票,共 {{ selectedCandidate.groupTotalBoxes }} 箱</p>
<p>成员入仓码:{{ selectedCandidate.memberInboundCodes.join('、') }}</p>
<el-alert :title="selectedCandidate.blockReason || '同柜且不可拆:全部成员按完整箱数一次加入,不能部分输入、单独分配或单独移除。'" :type="selectedCandidate.blockReason ? 'error' : 'warning'" :closable="false" />
<p>组内 {{ selectedCandidate.memberOrderCount }} 票、{{ selectedCandidate.inboundFragmentCount }} 个入仓片段,共 {{ selectedCandidate.groupTotalBoxes }} 箱</p>
<p v-for="summary in selectedCandidate.memberOrderSummaries" :key="summary">{{ summary }}</p>
<el-alert :title="selectedCandidate.blockReason || '同柜且不可拆:全部订单及其入仓片段按完整箱数一次加入,不能部分输入、单独分配或单独移除。'" :type="selectedCandidate.blockReason ? 'error' : 'warning'" :closable="false" />
</template>
<template v-else>
<p>入仓码 {{ selectedCandidate.inboundCode || '—' }} · 可用 {{ selectedCandidate.availablePackageCount }} 箱 · 平均单箱毛重 {{ formatNumber(Number(selectedCandidate.unitGrossWeightKg || 0), 1) }} kg · 平均单箱体积 {{ formatNumber(Number(selectedCandidate.averageUnitVolumeCbm || 0)) }} m³</p>
@@ -123,8 +123,9 @@ describe('SmartLoadingWorkbenchView', () => {
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 },
{ ...candidate, inboundId: '201', orderId: '301', inboundCode: 'IN-201', availablePackageCount: 4, availableVolumeCbm: 0.8, coLoadGroupId: '9007199254740999', groupNo: 'CLG-9007199254740999', memberCount: 3, groupTotalBoxes: 12, locked: false, blockReason: null },
{ ...candidate, inboundId: '203', orderId: '301', orderNo: 'ORD-1', inboundCode: 'IN-203', availablePackageCount: 2, availableVolumeCbm: 0.4, coLoadGroupId: '9007199254740999', groupNo: 'CLG-9007199254740999', memberCount: 3, groupTotalBoxes: 12, 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: 3, groupTotalBoxes: 12, locked: false, blockReason: null },
])
vi.mocked(assignSmartLoadingGroup).mockResolvedValue(planResponse)
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/container-loading/:containerId/smart-loading', component: SmartLoadingWorkbenchView }] })
@@ -132,7 +133,9 @@ describe('SmartLoadingWorkbenchView', () => {
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.text()).toContain('3 个入仓片段')
expect(wrapper.text()).toContain('ORD-1:IN-201、IN-203')
expect(wrapper.text()).toContain('整组 12 箱')
expect(wrapper.findAll('button').filter((button) => button.text().includes('整组分配'))).toHaveLength(1)
await wrapper.findAll('button').find((button) => button.text().includes('整组分配'))!.trigger('click')
await flushPromises()
@@ -176,16 +179,17 @@ describe('SmartLoadingWorkbenchView', () => {
})
it('removes a locked group once instead of looping through member items', async () => {
const items = ['201', '202'].map((inboundId, index) => ({
const baseItems = ['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,
}))
const items = [baseItems[0], { ...baseItems[0], id: '703', inboundId: '203', inboundDetailId: '803', inboundCode: 'IN-203', packageCount: 3, productQuantity: 30, grossWeightKg: 30, volumeCbm: 0.6 }, baseItems[1]]
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(batchQueryPlatformCoLoadGroups).mockResolvedValue([...new Set(items.map((item) => item.orderId))].map((orderId) => ({ orderId, group: {
groupId: '9900', groupNo: 'CLG-9900', status: 'LOCKED', warehouseId: '8', channelCode: 'ZBYHK', lockedPlanId: '99', version: 1,
members: items.map((member) => ({ tenantId: member.accountId, orderId: member.orderId, inboundId: member.inboundId, fullPackageCount: member.packageCount })), fullPackageCount: 13, 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)
@@ -193,10 +197,12 @@ describe('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 票')
expect(wrapper.text()).toContain('CLG-9900 · 2 票 · 3 个入仓片段')
expect(wrapper.text()).toContain('ORD-0:IN-201、IN-203')
expect(wrapper.text()).toContain('全部入仓片段联动,固定 13 箱')
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))
expect(ElMessageBox.confirm).toHaveBeenCalledWith(expect.stringContaining('组内所有入仓片段将联动移除'), '移除同柜不可拆组', expect.any(Object))
})
})
@@ -32,7 +32,7 @@ const loadError = ref<ApiErrorDetails | null>(null)
const mutationError = ref<ApiErrorDetails | null>(null)
const noDraft = ref(false)
const assignOpen = ref(false)
const selectedCandidate = ref<SmartLoadingCandidate | null>(null)
const selectedCandidate = ref<SmartLoadingCandidateRow | null>(null)
const assignForm = reactive({ packageCount: 1 })
const quantityDrafts = reactive<Record<string, number>>({})
const planGroupsByInboundId = ref(new Map<string, CoLoadGroup>())
@@ -45,13 +45,28 @@ 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)))
type SmartLoadingCandidateRow = SmartLoadingCandidate & { sourceCandidates: SmartLoadingCandidate[]; memberInboundCodes: string[] }
type SmartLoadingCandidateRow = SmartLoadingCandidate & { sourceCandidates: SmartLoadingCandidate[]; memberInboundCodes: string[]; memberOrderSummaries: string[]; memberOrderCount: number; inboundFragmentCount: number }
function memberOrderSummaries<T extends { orderId: string; orderNo?: string | null; inboundCode?: string | null }>(members: T[]) {
const orders = new Map<string, { label: string; inboundCodes: string[] }>()
for (const member of members) {
const current = orders.get(member.orderId) || { label: member.orderNo || member.orderId, inboundCodes: [] }
const inboundCode = member.inboundCode || '入仓码暂缺'
if (!current.inboundCodes.includes(inboundCode)) current.inboundCodes.push(inboundCode)
orders.set(member.orderId, current)
}
return [...orders.values()].map((order) => `${order.label}:${order.inboundCodes.join('、')}`)
}
function groupOrderCount(group: CoLoadGroup) { return new Set(group.members.map((member) => member.orderId)).size }
function groupMemberLabel(group: CoLoadGroup) {
const orderCount = groupOrderCount(group)
return group.members.length === orderCount ? `${orderCount} 票` : `${orderCount} 票 · ${group.members.length} 个入仓片段`
}
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 || '入仓码暂缺'] })
rows.push({ ...candidate, sourceCandidates: [candidate], memberInboundCodes: [candidate.inboundCode || '入仓码暂缺'], memberOrderSummaries: memberOrderSummaries([candidate]), memberOrderCount: 1, inboundFragmentCount: 1 })
continue
}
groups.set(candidate.coLoadGroupId, [...(groups.get(candidate.coLoadGroupId) || []), candidate])
@@ -67,11 +82,14 @@ const displayedCandidates = computed<SmartLoadingCandidateRow[]>(() => {
availableVolumeCbm: members.reduce((sum, member) => sum + Number(member.availableVolumeCbm || 0), 0),
sourceCandidates: members,
memberInboundCodes: members.map((member) => member.inboundCode || '入仓码暂缺'),
memberOrderSummaries: memberOrderSummaries(members),
memberOrderCount: new Set(members.map((member) => member.orderId)).size,
inboundFragmentCount: Number(first.memberCount || members.length),
})
}
return rows
})
type SmartLoadingPlanRow = SmartLoadingInboundItem & { coLoadGroup: CoLoadGroup | null; memberInboundCodes: string[] }
type SmartLoadingPlanRow = SmartLoadingInboundItem & { coLoadGroup: CoLoadGroup | null; memberInboundCodes: string[]; memberOrderSummaries: string[] }
const displayedPlanItems = computed<SmartLoadingPlanRow[]>(() => {
const inboundRows = groupSmartLoadingItems(plan.value?.items || [])
const result: SmartLoadingPlanRow[] = []
@@ -79,7 +97,7 @@ const displayedPlanItems = computed<SmartLoadingPlanRow[]>(() => {
for (const row of inboundRows) {
const group = planGroupsByInboundId.value.get(row.inboundId)
if (!group) {
result.push({ ...row, coLoadGroup: null, memberInboundCodes: [row.inboundCode || '入仓码暂缺'] })
result.push({ ...row, coLoadGroup: null, memberInboundCodes: [row.inboundCode || '入仓码暂缺'], memberOrderSummaries: memberOrderSummaries([row]) })
continue
}
grouped.set(group.groupId, [...(grouped.get(group.groupId) || []), row])
@@ -106,6 +124,7 @@ const displayedPlanItems = computed<SmartLoadingPlanRow[]>(() => {
detailCount: rows.reduce((sum, row) => sum + row.detailCount, 0),
coLoadGroup: group,
memberInboundCodes: rows.map((row) => row.inboundCode || '入仓码暂缺'),
memberOrderSummaries: memberOrderSummaries(rows),
})
}
return result
@@ -279,7 +298,7 @@ async function remove(source: object) {
if (!plan.value || !item.canRemove) return
const row = source as SmartLoadingPlanRow
const message = row.coLoadGroup
? `确认整组移除 ${row.coLoadGroup.groupNo}(${row.coLoadGroup.members.length} 票、${row.packageCount} 箱)吗?组内成员不能单独移除。`
? `确认整组移除 ${row.coLoadGroup.groupNo}(${groupMemberLabel(row.coLoadGroup)}、${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
@@ -374,7 +393,7 @@ onMounted(load)
<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 && !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 #coLoadGroup="{ row }"><div v-if="row.coLoadGroup" class="candidate-group"><el-tag size="small" type="warning">{{ row.coLoadGroup.groupNo }} · {{ groupMemberLabel(row.coLoadGroup) }}</el-tag><span v-for="summary in row.memberOrderSummaries" :key="summary">{{ summary }}</span><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 }"><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>
@@ -382,14 +401,14 @@ onMounted(load)
<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="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 #coLoadGroup="{ row }"><div v-if="row.coLoadGroupId" class="candidate-group"><el-tag size="small" type="warning">{{ row.groupNo }} · {{ row.memberOrderCount }} 票 · {{ row.inboundFragmentCount }} 个入仓片段</el-tag><span v-for="summary in row.memberOrderSummaries" :key="summary">{{ summary }}</span><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="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>
<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.memberOrderCount }} 票、{{ selectedCandidate.inboundFragmentCount }} 个入仓片段,共 {{ selectedCandidate.groupTotalBoxes }} 箱</p><p v-for="summary in selectedCandidate.memberOrderSummaries" :key="summary">{{ summary }}</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>
@@ -4,8 +4,7 @@ import { batchQueryPlatformCoLoadGroups, cancelPlatformCoLoadGroup, createPlatfo
const group = {
groupId: 2093000000000000001n, groupNo: 'CLG-2093000000000000001', status: 'ACTIVE',
warehouseId: 2093000000000000002n, channelCode: 'NY-EXX', destinationType: 'WAREHOUSE',
destinationWarehouseId: 2093000000000000003n, destinationCode: 'ONT8', lockedPlanId: null,
warehouseId: 2093000000000000002n, channelCode: 'NY-EXX', 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 },
@@ -39,11 +38,28 @@ describe('co-load group API', () => {
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 post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', message: 'ok', data: threeMemberGroup.members.map((member) => ({ orderId: member.orderId, 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 } })
expect(result[2]).toMatchObject({ orderId: '2093000000000000010', group: { groupId: '2093000000000000001', fullPackageCount: 23 } })
expect(result[2]).not.toHaveProperty('inboundId')
})
it('keeps every inbound fragment when one order has multiple current inbounds', async () => {
const fragment = { ...group.members[0], inboundId: 2093000000000000012n, fullPackageCount: 3 }
const multiInboundGroup = { ...group, fullPackageCount: 21, members: [group.members[0], fragment, group.members[1]] }
const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', message: 'ok', data: [
{ orderId: group.members[0].orderId, group: multiInboundGroup },
{ orderId: group.members[1].orderId, group: multiInboundGroup },
] })
const result = await batchQueryPlatformCoLoadGroups(['2093000000000000005', '2093000000000000008'])
expect(post).toHaveBeenCalledTimes(1)
expect(result[0].group.members).toEqual([
expect.objectContaining({ orderId: '2093000000000000005', inboundId: '2093000000000000006', fullPackageCount: 8 }),
expect.objectContaining({ orderId: '2093000000000000005', inboundId: '2093000000000000012', fullPackageCount: 3 }),
expect.objectContaining({ orderId: '2093000000000000008', inboundId: '2093000000000000009', fullPackageCount: 10 }),
])
})
})
+2 -10
View File
@@ -15,9 +15,6 @@ export interface CoLoadGroup {
status: CoLoadGroupStatus
warehouseId: string
channelCode: string
destinationType: string
destinationWarehouseId: string | null
destinationCode: string | null
lockedPlanId: string | null
version: number
members: CoLoadGroupMember[]
@@ -28,21 +25,18 @@ export interface CoLoadGroup {
export interface OrderCoLoadGroupInfo {
orderId: string
inboundId: string
group: CoLoadGroup
}
type RawId = string | number
type RawCoLoadGroup = Omit<CoLoadGroup, 'groupId' | 'warehouseId' | 'destinationWarehouseId' | 'lockedPlanId' | 'members'> & {
type RawCoLoadGroup = Omit<CoLoadGroup, 'groupId' | 'warehouseId' | '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'> & {
type RawOrderCoLoadGroupInfo = Omit<OrderCoLoadGroupInfo, 'orderId' | 'group'> & {
orderId: RawId
inboundId: RawId
group: RawCoLoadGroup
}
@@ -55,7 +49,6 @@ function normalizeGroup(raw: RawCoLoadGroup): CoLoadGroup {
...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),
@@ -94,7 +87,6 @@ export async function batchQueryPlatformCoLoadGroups(orderIds: string[]) {
)
return unwrapApiResponse(response).map((item) => ({
orderId: String(item.orderId),
inboundId: String(item.inboundId),
group: normalizeGroup(item.group),
}))
}
@@ -32,8 +32,7 @@ const rows = ['5001', '5002'].map((orderId, index) => ({
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,
groupId: '9001', groupNo: 'CLG-9001', status: 'ACTIVE' as const, warehouseId: '8', channelCode: 'NY-EXX', 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',
}
@@ -44,9 +43,10 @@ function mountView() {
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>' },
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="orderNo" :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>' },
CopyButton: { props: ['value', 'label'], template: '<button :data-copy="value">复制 ID</button>' },
'el-button': { template: '<button @click="$emit(\'click\')"><slot /></button>' },
'el-tag': { template: '<span><slot /></span>' },
'el-alert': { props: ['title'], template: '<div>{{ title }}</div>' },
@@ -77,13 +77,24 @@ describe('UnifiedOrdersView co-load group behavior', () => {
})
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 })
const multiInboundGroup = {
...group,
fullPackageCount: 13,
members: [group.members[0], { ...group.members[0], inboundId: '7003', fullPackageCount: 3 }, group.members[1]],
}
vi.mocked(batchQueryPlatformCoLoadGroups).mockResolvedValue(rows.map((row) => ({ orderId: row.orderId, group: multiInboundGroup })))
vi.mocked(cancelPlatformCoLoadGroup).mockResolvedValue({ ...multiInboundGroup, 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 票 · 未锁定')
expect(wrapper.text()).toContain('CLG-9001 · 2 票 · 未锁定')
await wrapper.get('[aria-label="查看同柜组 CLG-9001 详情"]').trigger('click')
expect(wrapper.text()).toContain('2 票 · 3 个入仓片段 · 未锁定')
expect(wrapper.text()).toContain('13 箱')
expect(wrapper.text()).toContain('7003')
expect(wrapper.findAll('[data-copy="7003"]')).toHaveLength(1)
expect(wrapper.findAll('[data-copy="5001"]')).toHaveLength(2)
await wrapper.findAll('button').find((button) => button.text().includes('取消同柜组'))!.trigger('click')
await flushPromises()
expect(cancelPlatformCoLoadGroup).toHaveBeenCalledWith('9001', 0, '计划调整')
@@ -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', 'coLoadGroup', 'mainStatus', 'customerName', 'receivingWarehouseName',
'actions', 'orderNo', 'inboundCode', 'mainStatus', 'customerName', 'receivingWarehouseName',
'destinationWarehouseName', 'channelName', 'totalPackageCount', 'totalPalletCount', 'requestedPickupAt', 'locationCodes', 'measurementReviewStatus', 'actualGrossWeightKg',
'actualVolumeCbm', 'totalNetWeightKg', 'chargeableWeightKg', 'chargeableVolumeCbm', 'expectedShipDate',
'expectedArrivalAt', 'feeReviewStatus', 'skuNos', 'totalQuantity', 'expressCompany', 'expressTrackingNo',
@@ -108,6 +108,8 @@ describe('UnifiedOrdersView contract', () => {
]) expect(columnSource).not.toContain(`key: '${uncontractedKey}'`)
expect(columnSource.indexOf("key: 'actions'")).toBeLessThan(columnSource.indexOf("key: 'orderNo'"))
expect(columnSource.indexOf("key: 'orderNo'")).toBeLessThan(columnSource.indexOf("key: 'inboundCode'"))
expect(columnSource).not.toContain("key: 'coLoadGroup'")
expect(columnSource).toContain("key: 'orderNo', prop: 'orderNo', label: '订单号', minWidth: 230, slotName: 'orderNo'")
expect(columnSource).toContain("{ key: 'mainStatus', prop: 'mainStatus', label: '订单主状态'")
expect(columnSource).not.toContain("key: 'loadingStatus'")
expect(columnSource).not.toContain("key: 'businessDate'")
@@ -160,6 +162,33 @@ describe('UnifiedOrdersView contract', () => {
expect(source).toContain('<SmartLoadingDialog v-model="smartLoadingOpen" />')
})
it('states only the domestic warehouse and channel qualification without local destination or split blocking', () => {
const createSource = source.slice(source.indexOf('function openCoLoadCreate'), source.indexOf('async function batchApproveOrders'))
const dialogSource = source.slice(source.indexOf('<AppDialog v-model="coLoadCreateOpen"'), source.indexOf('<AppDrawer v-model="relationOpen"'))
expect(dialogSource).toContain('同一国内仓、同一渠道')
expect(dialogSource).toContain('目的仓或目的港可以不同')
expect(dialogSource).toContain('资格由服务端按完整入仓片段校验')
expect(dialogSource).not.toContain('拆子单')
expect(createSource).not.toContain('destinationWarehouse')
expect(createSource).not.toContain('destinationPort')
expect(createSource).not.toContain('splitStatus')
expect(createSource).not.toContain('relation')
expect(createSource).toContain('createPlatformCoLoadGroup(selectedOrders.value.map((row) => String(row.orderId)))')
})
it('embeds a clickable compact group tag under the order code without an empty placeholder column', () => {
expect(source).not.toContain("{ key: 'coLoadGroup', label: '同柜组'")
expect(source).toContain('<template #orderNo="{ row }"><div class="order-code-cell">')
expect(source).toContain('class="coload-tag-trigger"')
expect(source).toContain('coLoadGroupOrderCount(coLoadGroup(row as UnifiedOrderResource)!)')
expect(source).not.toContain('<span v-else>—</span></template>\n <template #orderStatus')
expect(source).toContain('v-for="member in coLoadDetailGroup.members"')
expect(source).toContain('入仓码 / 入仓 ID')
expect(source).toContain('订单编码 / ID')
expect(source).toContain('<CopyButton :value="member.inboundId"')
expect(source).toContain('<CopyButton :value="member.orderId"')
})
it('keeps editable query values separate from the applied list request', () => {
expect(source).toContain('const appliedFilters = ref<FilterModel>(emptyFilters())')
expect(source).toContain('const activeFilters = appliedFilters.value')
@@ -31,6 +31,7 @@ import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import AppDrawer from '@/shared/components/AppDrawer.vue'
import CopyButton from '@/shared/components/CopyButton.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
@@ -130,6 +131,7 @@ const selectedApprovalOrders = ref<UnifiedOrderResource[]>([])
const selectedOrders = ref<UnifiedOrderResource[]>([])
const coLoadGroupsByOrderId = ref(new Map<string, CoLoadGroup>())
const coLoadCreateOpen = ref(false)
const coLoadDetailGroup = ref<CoLoadGroup | null>(null)
const coLoadSaving = ref(false)
const coLoadError = ref<ApiErrorDetails | null>(null)
const approvalOrder = ref<UnifiedOrderResource | null>(null)
@@ -256,9 +258,8 @@ const columns = computed<DataTableColumn[]>(() => [
key: 'selection', type: 'selection', width: 48, fixed: 'left',
selectable: () => !batchApprovalSaving.value && !coLoadSaving.value,
} satisfies DataTableColumn] : []),
{ key: 'orderNo', prop: 'orderNo', label: '订单号', minWidth: 180, slotName: 'field' },
{ key: 'orderNo', prop: 'orderNo', label: '订单号', minWidth: 230, slotName: 'orderNo' },
{ 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' },
@@ -791,6 +792,33 @@ function coLoadGroup(row: UnifiedOrderResource) {
return coLoadGroupsByOrderId.value.get(String(row.orderId)) || null
}
function coLoadGroupOrderCount(group: CoLoadGroup) {
return new Set(group.members.map((member) => member.orderId)).size
}
function coLoadGroupMemberLabel(group: CoLoadGroup) {
const orderCount = coLoadGroupOrderCount(group)
return group.members.length === orderCount
? `${orderCount} 票`
: `${orderCount} 票 · ${group.members.length} 个入仓片段`
}
function coLoadGroupStatusLabel(group: CoLoadGroup) {
return group.status === 'ACTIVE' ? '未锁定' : group.status === 'LOCKED' ? '已锁定' : group.status === 'CONSUMED' ? '已消费' : '已取消'
}
function openCoLoadDetail(group: CoLoadGroup) {
coLoadDetailGroup.value = group
}
function visibleOrderNo(orderId: string) {
return records.value.find((row) => String(row.orderId) === orderId)?.orderNo || ''
}
function visibleInboundCode(inboundId: string) {
return records.value.find((row) => String(row.inboundId || '') === inboundId)?.inboundCode || ''
}
function openCoLoadCreate() {
if (selectedOrders.value.length < 2) return
coLoadError.value = null
@@ -804,7 +832,7 @@ async function createCoLoadGroup() {
try {
const group = await createPlatformCoLoadGroup(selectedOrders.value.map((row) => String(row.orderId)))
coLoadCreateOpen.value = false
ElMessage.success(`已创建同柜不可拆组 ${group.groupNo},共 ${group.members.length} 票`)
ElMessage.success(`已创建同柜不可拆组 ${group.groupNo},共 ${coLoadGroupMemberLabel(group)}`)
await loadOrders()
} catch (error) {
coLoadError.value = getApiErrorDetails(error, '创建同柜不可拆组失败')
@@ -819,7 +847,7 @@ async function cancelCoLoadGroup(row: UnifiedOrderResource) {
let reason = ''
try {
const result = await ElMessageBox.prompt(
`将整组取消 ${group.groupNo}(${group.members.length} 票)。不支持单独移除成员;如需调整,请取消后重新建组。`,
`将整组取消 ${group.groupNo}(${coLoadGroupMemberLabel(group)})。不支持单独移除成员;如需调整,请取消后重新建组。`,
'取消同柜不可拆组',
{ confirmButtonText: '确认整组取消', cancelButtonText: '返回', inputPlaceholder: '取消原因(选填)', inputType: 'textarea' },
)
@@ -1336,7 +1364,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 #orderNo="{ row }"><div class="order-code-cell"><strong>{{ (row as UnifiedOrderResource).orderNo || (row as UnifiedOrderResource).orderId }}</strong><button v-if="coLoadGroup(row as UnifiedOrderResource)" type="button" class="coload-tag-trigger" :aria-label="`查看同柜组 ${coLoadGroup(row as UnifiedOrderResource)?.groupNo} 详情`" @click.stop="openCoLoadDetail(coLoadGroup(row as UnifiedOrderResource)!)"><el-tag size="small" :type="coLoadGroup(row as UnifiedOrderResource)?.status === 'ACTIVE' ? 'warning' : 'info'">{{ coLoadGroup(row as UnifiedOrderResource)?.groupNo }} · {{ coLoadGroupOrderCount(coLoadGroup(row as UnifiedOrderResource)!) }} 票 · {{ coLoadGroupStatusLabel(coLoadGroup(row as UnifiedOrderResource)!) }}</el-tag></button></div></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>
@@ -1355,10 +1383,21 @@ onBeforeUnmount(() => {
</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} 票必须全量进入同一柜,不能部分装柜、拆子单或单独移除成员。`" />
<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>
<AppDialog :model-value="Boolean(coLoadDetailGroup)" :title="coLoadDetailGroup ? `同柜组 ${coLoadDetailGroup.groupNo}` : '同柜组详情'" :show-confirm="false" cancel-text="关闭" width="min(760px, calc(100vw - 32px))" @update:model-value="coLoadDetailGroup = null">
<template v-if="coLoadDetailGroup">
<div class="coload-detail-summary"><el-tag size="small" :type="coLoadDetailGroup.status === 'ACTIVE' ? 'warning' : 'info'">{{ coLoadGroupOrderCount(coLoadDetailGroup) }} 票 · {{ coLoadDetailGroup.members.length }} 个入仓片段 · {{ coLoadGroupStatusLabel(coLoadDetailGroup) }}</el-tag><span>共 {{ coLoadDetailGroup.fullPackageCount }} 箱</span></div>
<div class="coload-detail-scroll">
<table class="coload-detail-table">
<thead><tr><th>入仓码 / 入仓 ID</th><th>订单编码 / ID</th><th>箱数</th></tr></thead>
<tbody><tr v-for="member in coLoadDetailGroup.members" :key="`${member.orderId}:${member.inboundId}`"><td><strong>{{ visibleInboundCode(member.inboundId) || member.inboundId }}</strong><span v-if="visibleInboundCode(member.inboundId)">ID {{ member.inboundId }}</span><CopyButton :value="member.inboundId" label="入仓 ID" button-text="复制 ID" size="small" text /></td><td><strong>{{ visibleOrderNo(member.orderId) || member.orderId }}</strong><span v-if="visibleOrderNo(member.orderId)">ID {{ member.orderId }}</span><CopyButton :value="member.orderId" label="订单 ID" button-text="复制 ID" size="small" text /></td><td>{{ member.fullPackageCount }} 箱</td></tr></tbody>
</table>
</div>
</template>
</AppDialog>
</template>
<AppDrawer v-model="relationOpen" title="父子订单关联" size="min(860px, 94vw)" :show-footer="false" destroy-on-close>
@@ -1426,10 +1465,22 @@ onBeforeUnmount(() => {
.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; }
.order-code-cell { display: grid; justify-items: start; gap: 4px; }
.coload-tag-trigger { margin: 0; padding: 0; border: 0; background: transparent; cursor: pointer; }
.coload-tag-trigger:focus-visible { outline: 2px solid var(--el-color-primary); outline-offset: 2px; border-radius: 3px; }
.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; }
.coload-detail-summary { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 10px; }
.coload-detail-scroll { overflow-x: auto; border: 1px solid var(--cc-border); border-radius: 4px; }
.coload-detail-table { width: 100%; border-collapse: collapse; font-size: 12px; }
.coload-detail-table th,.coload-detail-table td { padding: 8px 10px; border-bottom: 1px solid var(--cc-border); text-align: left; vertical-align: top; }
.coload-detail-table th { background: var(--cc-surface-muted); color: var(--cc-muted); font-weight: 600; }
.coload-detail-table tbody tr:last-child td { border-bottom: 0; }
.coload-detail-table td { min-width: 210px; }
.coload-detail-table td:last-child { min-width: 80px; white-space: nowrap; }
.coload-detail-table td strong,.coload-detail-table td span { display: block; overflow-wrap: anywhere; }
.coload-detail-table td span { color: var(--cc-muted); }
.coload-detail-table :deep(.el-button) { min-height: 24px; padding: 2px 0; }
.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; }