This commit is contained in:
2026-09-16 11:06:36 +08:00
parent d75e8381b6
commit 78a0d7916c
22 changed files with 197 additions and 51 deletions
@@ -159,6 +159,14 @@ function plan(raw: SmartLoadingPlan): SmartLoadingPlan {
fullPackageCount: numberValue(group.fullPackageCount),
memberInboundIds: (group.memberInboundIds || []).map(id),
})),
palletMembers: (raw.palletMembers || []).map((pallet) => ({
palletId: id(pallet.palletId),
members: (pallet.members || []).map((member) => ({
...member,
accountId: id(member.accountId), inboundId: id(member.inboundId), orderId: id(member.orderId),
availablePackageCount: numberValue(member.availablePackageCount),
})),
})),
items: (raw.items || []).map(item),
}
}
@@ -15,4 +15,18 @@ describe('sealed pallet composition', () => {
expect(wrapper.text()).not.toContain('13 箱')
expect(wrapper.findAll('button')).toHaveLength(0)
})
it('renders the same composition when it comes from an assigned plan pallet', () => {
const wrapper = mount(SmartLoadingCandidateInbounds, { props: {
sealedPallet: true,
fallbackInboundCode: 'I180',
members: [
{ accountId: '1', inboundId: '180', orderId: '280', orderNo: 'O180', inboundCode: 'I180', customerName: '客户', availablePackageCount: 6, orderStatusSnapshot: null, expectedArrivalAt: null },
{ accountId: '1', inboundId: '181', orderId: '281', orderNo: 'O181', inboundCode: 'I181', customerName: '客户', availablePackageCount: 5, orderStatusSnapshot: null, expectedArrivalAt: null },
],
} })
expect(wrapper.get('summary').text()).toBe('2 个入仓码 · 展开')
expect(wrapper.text()).toContain('I180:6 箱')
expect(wrapper.text()).toContain('I181:5 箱')
})
})
@@ -1,15 +1,26 @@
<script setup lang="ts">
import type { SmartLoadingCandidate } from '../types'
import { computed } from 'vue'
import type { SmartLoadingCandidate, SmartLoadingCandidateGroupMember } from '../types'
defineProps<{ candidate: SmartLoadingCandidate }>()
const props = defineProps<{
candidate?: SmartLoadingCandidate
members?: SmartLoadingCandidateGroupMember[]
sealedPallet?: boolean
fallbackInboundCode?: string | null
}>()
const displayedMembers = computed(() => props.members || props.candidate?.groupMembers || [])
const isSealedPallet = computed(() => props.candidate
? props.candidate.unitType === 'SEALED_PALLET'
: props.sealedPallet === true)
const inboundCode = computed(() => props.fallbackInboundCode ?? props.candidate?.inboundCode)
</script>
<template>
<details v-if="candidate.unitType === 'SEALED_PALLET' && candidate.groupMembers.length">
<summary>{{ candidate.groupMembers.length }} 个入仓码 · 展开</summary>
<div v-for="member in candidate.groupMembers" :key="`${member.accountId}:${member.inboundId}`">
<details v-if="isSealedPallet && displayedMembers.length">
<summary>{{ displayedMembers.length }} 个入仓码 · 展开</summary>
<div v-for="member in displayedMembers" :key="`${member.accountId}:${member.inboundId}`">
{{ member.inboundCode || '—' }}:{{ member.availablePackageCount }} 箱
</div>
</details>
<span v-else>{{ candidate.inboundCode || '—' }}</span>
<span v-else>{{ inboundCode || '—' }}</span>
</template>
@@ -467,7 +467,7 @@ describe('ContainersView', () => {
})
it('updates an ACTIVE TRUCKING fleet inline and records actual empty return once', async () => {
vi.mocked(listSuppliers).mockResolvedValue({ records: [{ id: '502', name: '新车队', category: 'TRUCKING', status: 'ACTIVE' } as never], page: 1, size: 200, total: 1, pages: 1 })
vi.mocked(listSuppliers).mockResolvedValue({ records: [{ id: '502', name: '新车队', category: 'TRUCKING', truckingScope: 'OVERSEAS', status: 'ACTIVE' } as never], page: 1, size: 200, total: 1, pages: 1 })
const router = createTestRouter()
await router.push('/'); await router.isReady()
const wrapper = mount(ContainersView, { props: { workbench: 'OVERSEAS' }, global: { plugins: [ElementPlus, router] } })
@@ -514,11 +514,11 @@ describe('ContainersView', () => {
const oldSearch = vm.searchTerminalFleets('旧关键词')
const latestSearch = vm.searchTerminalFleets('新车队')
expect(listSuppliers).toHaveBeenNthCalledWith(1, { page: 1, size: 20, category: 'TRUCKING', status: 'ACTIVE', keyword: '旧关键词' })
expect(listSuppliers).toHaveBeenNthCalledWith(2, { page: 1, size: 20, category: 'TRUCKING', status: 'ACTIVE', keyword: '新车队' })
resolveLatest({ records: [{ id: '503', name: '新车队', category: 'TRUCKING', status: 'ACTIVE' } as never], page: 1, size: 20, total: 1, pages: 1 })
expect(listSuppliers).toHaveBeenNthCalledWith(1, { page: 1, size: 20, category: 'TRUCKING', truckingScope: 'OVERSEAS', status: 'ACTIVE', keyword: '旧关键词' })
expect(listSuppliers).toHaveBeenNthCalledWith(2, { page: 1, size: 20, category: 'TRUCKING', truckingScope: 'OVERSEAS', status: 'ACTIVE', keyword: '新车队' })
resolveLatest({ records: [{ id: '503', name: '新车队', category: 'TRUCKING', truckingScope: 'OVERSEAS', status: 'ACTIVE' } as never, { id: '504', name: '国内车队', category: 'TRUCKING', truckingScope: 'DOMESTIC', status: 'ACTIVE' } as never], page: 1, size: 20, total: 2, pages: 1 })
await latestSearch
resolveOld({ records: [{ id: '502', name: '旧车队', category: 'TRUCKING', status: 'ACTIVE' } as never], page: 1, size: 20, total: 1, pages: 1 })
resolveOld({ records: [{ id: '502', name: '旧车队', category: 'TRUCKING', truckingScope: 'OVERSEAS', status: 'ACTIVE' } as never], page: 1, size: 20, total: 1, pages: 1 })
await oldSearch
expect(vm.terminalFleetOptions).toEqual([expect.objectContaining({ id: '503', name: '新车队' })])
wrapper.unmount()
@@ -742,7 +742,7 @@ describe('ContainersView', () => {
})
it('backfills a legacy trucking supplier name and submits the frozen id-name pair', async () => {
vi.mocked(listSuppliers).mockResolvedValue({ records: [{ id: '9007199254740998', tenantId: '1', name: '测试拖车供应商', category: 'TRUCKING', carrierCodes: [], region: 'CN', legalRepresentative: '张三', contactName: '李四', phone: '13800000000', email: null, address: null, businessLicenseNo: null, licenseFileId: null, status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: null, createdBy: null, createdAt: '2026-08-25T10:00:00', updatedBy: null, updatedAt: '2026-08-25T10:00:00', deleted: false, version: 0 }], page: 1, size: 200, total: 1, pages: 1 })
vi.mocked(listSuppliers).mockResolvedValue({ records: [{ id: '9007199254740998', tenantId: '1', name: '测试拖车供应商', category: 'TRUCKING', truckingScope: 'OVERSEAS', carrierCodes: [], region: 'CN', legalRepresentative: '张三', contactName: '李四', phone: '13800000000', email: null, address: null, businessLicenseNo: null, licenseFileId: null, status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: null, createdBy: null, createdAt: '2026-08-25T10:00:00', updatedBy: null, updatedAt: '2026-08-25T10:00:00', deleted: false, version: 0 }], page: 1, size: 200, total: 1, pages: 1 })
vi.mocked(getContainer).mockResolvedValueOnce({ id: '9001', allowedActions: ['EDIT_CONTAINER'], planStatus: 'WAIT_LOADING', containerType: '40HQ', warehouseId: '8', terminalTruckingCompanyId: null, terminalTruckingCompany: '测试拖车供应商' } as never)
vi.mocked(updateContainer).mockResolvedValue({} as never)
const router = createTestRouter()
@@ -762,7 +762,7 @@ describe('ContainersView', () => {
})
it('clears the trucking supplier id and name together', async () => {
vi.mocked(listSuppliers).mockResolvedValue({ records: [{ id: '9007199254740998', tenantId: '1', name: '测试拖车供应商', category: 'TRUCKING', carrierCodes: [], region: 'CN', legalRepresentative: '张三', contactName: '李四', phone: '13800000000', email: null, address: null, businessLicenseNo: null, licenseFileId: null, status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: null, createdBy: null, createdAt: '2026-08-25T10:00:00', updatedBy: null, updatedAt: '2026-08-25T10:00:00', deleted: false, version: 0 }], page: 1, size: 200, total: 1, pages: 1 })
vi.mocked(listSuppliers).mockResolvedValue({ records: [{ id: '9007199254740998', tenantId: '1', name: '测试拖车供应商', category: 'TRUCKING', truckingScope: 'OVERSEAS', carrierCodes: [], region: 'CN', legalRepresentative: '张三', contactName: '李四', phone: '13800000000', email: null, address: null, businessLicenseNo: null, licenseFileId: null, status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: null, createdBy: null, createdAt: '2026-08-25T10:00:00', updatedBy: null, updatedAt: '2026-08-25T10:00:00', deleted: false, version: 0 }], page: 1, size: 200, total: 1, pages: 1 })
vi.mocked(getContainer).mockResolvedValueOnce({ id: '9001', allowedActions: ['EDIT_CONTAINER'], planStatus: 'WAIT_LOADING', containerType: '40HQ', warehouseId: '8', terminalTruckingCompanyId: '9007199254740998', terminalTruckingCompany: '测试拖车供应商' } as never)
vi.mocked(updateContainer).mockResolvedValue({} as never)
const router = createTestRouter()
@@ -822,9 +822,9 @@ describe('ContainersView trucking supplier contract', () => {
it('uses active TRUCKING suppliers without allowing free-text carrier names', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')
expect(source).toContain("listSuppliers({ page: 1, size: 200, category: 'TRUCKING', status: 'ACTIVE' })")
expect(source).toContain("page: 1, size: 20, category: 'TRUCKING', status: 'ACTIVE', keyword: keyword.trim() || undefined")
expect(source).toContain("supplier.category === 'TRUCKING' && supplier.status === 'ACTIVE'")
expect(source).toContain("listSuppliers({ page: 1, size: 200, category: 'TRUCKING', truckingScope: 'OVERSEAS', status: 'ACTIVE' })")
expect(source).toContain("page: 1, size: 20, category: 'TRUCKING', truckingScope: 'OVERSEAS', status: 'ACTIVE', keyword: keyword.trim() || undefined")
expect(source).toContain("supplier.category === 'TRUCKING' && supplier.truckingScope === 'OVERSEAS' && supplier.status === 'ACTIVE'")
expect(source).toContain('filterable remote reserve-keyword remote-show-suffix')
expect(source).toContain(':remote-method="searchTerminalFleets"')
expect(source).toContain('selectTruckingSupplier')
@@ -197,7 +197,7 @@ const canViewPreload = computed(() => access.can('platform:firstleg:preload:list
const canViewLoadingList = computed(() => access.can('platform:firstleg:loading-list:list'))
const editorTitle = computed(() => form.id ? '编辑' : '新增')
const shippingCompanies = computed(() => partners.value.filter((partner) => partner.status === 'ACTIVE' && partner.types.includes('CARRIER')))
const truckingCompanies = computed(() => truckingSuppliers.value.filter((supplier) => supplier.status === 'ACTIVE' && supplier.category === 'TRUCKING'))
const truckingCompanies = computed(() => truckingSuppliers.value.filter((supplier) => supplier.status === 'ACTIVE' && supplier.category === 'TRUCKING' && supplier.truckingScope === 'OVERSEAS'))
interface PortOption { value: string; code: string | null; name: string | null }
function portValue(code: string | null | undefined, name: string | null | undefined) { return code || name ? JSON.stringify([code || '', name || '']) : '' }
function collectPorts(direction: 'departure' | 'destination'): PortOption[] {
@@ -465,8 +465,8 @@ async function loadTruckingSuppliers(): Promise<Supplier[]> {
if (truckingSuppliersLoaded) return truckingSuppliers.value
if (truckingSuppliersPromise) return truckingSuppliersPromise
truckingSuppliersPromise = (async () => {
const result = await listSuppliers({ page: 1, size: 200, category: 'TRUCKING', status: 'ACTIVE' })
const records = result.records.filter((supplier) => supplier.category === 'TRUCKING' && supplier.status === 'ACTIVE')
const result = await listSuppliers({ page: 1, size: 200, category: 'TRUCKING', truckingScope: 'OVERSEAS', status: 'ACTIVE' })
const records = result.records.filter((supplier) => supplier.category === 'TRUCKING' && supplier.truckingScope === 'OVERSEAS' && supplier.status === 'ACTIVE')
truckingSuppliers.value = records
truckingSuppliersLoaded = true
return records
@@ -479,10 +479,10 @@ async function searchTerminalFleets(keyword = '') {
terminalFleetLoading.value = true
try {
const result = await listSuppliers({
page: 1, size: 20, category: 'TRUCKING', status: 'ACTIVE', keyword: keyword.trim() || undefined,
page: 1, size: 20, category: 'TRUCKING', truckingScope: 'OVERSEAS', status: 'ACTIVE', keyword: keyword.trim() || undefined,
})
if (sequence !== terminalFleetSearchSequence) return
terminalFleetOptions.value = result.records.filter((supplier) => supplier.category === 'TRUCKING' && supplier.status === 'ACTIVE')
terminalFleetOptions.value = result.records.filter((supplier) => supplier.category === 'TRUCKING' && supplier.truckingScope === 'OVERSEAS' && supplier.status === 'ACTIVE')
} catch (error) {
if (sequence !== terminalFleetSearchSequence) return
terminalFleetOptions.value = []
@@ -77,6 +77,7 @@ const plan = {
canAssign: true, canEdit: true, blockedReason: null, finalCompletionStatus: 'NOT_STARTED' as const,
pdaTaskId: null, pdaTaskStatus: null, pdaTaskVersion: null,
coLoadGroups: [],
palletMembers: [],
items: [{ id: '101', loadingPlanId: '99', accountId: '1', inboundId: '2', inboundDetailId: '3', orderId: '4', orderNo: 'ORD-1', inboundCode: 'IN-1', customerName: '示例企业', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', skuCode: 'SKU-1', productNameZh: '服装', unitType: 'PACKAGE' as const, palletId: null, palletNo: null, indivisible: false, packageCount: 6, productQuantity: 6, grossWeightKg: 60, volumeCbm: 1.2, loadSequence: 1, version: 1, pickedPackageCount: 0, minPackageCount: 1, canAdjust: true, canRemove: true }],
}
const candidate = {
@@ -953,6 +954,31 @@ describe('GlobalSmartLoadingView', () => {
expect(removeSmartLoadingItem).not.toHaveBeenCalled()
})
it('shows every inbound code and its box count for an assigned sealed pallet', async () => {
const palletItem = {
...plan.items[0], inboundId: '180', inboundCode: 'I180', unitType: 'SEALED_PALLET' as const,
palletId: '801', palletNo: 'DP-260914-0025', indivisible: true, packageCount: 11,
pickedPackageCount: 0, minPackageCount: 11, canAdjust: false, canRemove: true,
}
vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue({
...plan,
items: [palletItem],
palletMembers: [{ palletId: '801', members: [
{ accountId: '1', inboundId: '180', orderId: '280', orderNo: 'O180', inboundCode: 'I180', customerName: '客户', availablePackageCount: 6, orderStatusSnapshot: null, expectedArrivalAt: null },
{ accountId: '1', inboundId: '181', orderId: '281', orderNo: 'O181', inboundCode: 'I181', customerName: '客户', availablePackageCount: 5, orderStatusSnapshot: null, expectedArrivalAt: null },
] }],
})
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
await flushPromises()
const row = wrapper.findAll('.plan-pane .el-table__body-wrapper tr').find((candidateRow) => candidateRow.text().includes('DP-260914-0025'))
expect(row).toBeDefined()
expect(row!.text()).toContain('2 个入仓码 · 展开')
expect(row!.text()).toContain('I180:6 箱')
expect(row!.text()).toContain('I181:5 箱')
})
it('renders final completion capability as read-only with the server reason', async () => {
vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue({
...plan,
@@ -31,6 +31,7 @@ import {
} from '../api/platformContainerLoading'
import type {
SmartLoadingCandidate,
SmartLoadingCandidateGroupMember,
SmartLoadingPlan,
SmartLoadingWorkbenchContainer,
} from '../types'
@@ -146,7 +147,10 @@ const displayedCandidates = computed<SmartLoadingCandidateRow[]>(() => {
}
})
})
type SmartLoadingPlanRow = SmartLoadingInboundItem & { coLoadGroup: SmartLoadingPlanCoLoadGroup | null; memberInboundCodes: string[]; memberOrderSummaries: string[] }
const planPalletMembersByPalletId = computed(() => new Map(
(plan.value?.palletMembers || []).map((entry) => [entry.palletId, entry.members]),
))
type SmartLoadingPlanRow = SmartLoadingInboundItem & { coLoadGroup: SmartLoadingPlanCoLoadGroup | null; palletMembers: SmartLoadingCandidateGroupMember[]; memberInboundCodes: string[]; memberOrderSummaries: string[] }
const displayedPlanItems = computed<SmartLoadingPlanRow[]>(() => {
const inboundRows = groupSmartLoadingItems(plan.value?.items || [])
const result: SmartLoadingPlanRow[] = []
@@ -154,7 +158,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 || '入仓码暂缺'], memberOrderSummaries: memberOrderSummaries([row]) })
result.push({ ...row, coLoadGroup: null, palletMembers: row.palletId ? planPalletMembersByPalletId.value.get(row.palletId) || [] : [], memberInboundCodes: [row.inboundCode || '入仓码暂缺'], memberOrderSummaries: memberOrderSummaries([row]) })
continue
}
grouped.set(group.groupId, [...(grouped.get(group.groupId) || []), row])
@@ -178,6 +182,7 @@ const displayedPlanItems = computed<SmartLoadingPlanRow[]>(() => {
sourceItems: rows.flatMap((row) => row.sourceItems),
detailCount: rows.reduce((sum, row) => sum + row.detailCount, 0),
coLoadGroup: group,
palletMembers: [],
memberInboundCodes: rows.map((row) => row.inboundCode || '入仓码暂缺'),
memberOrderSummaries: memberOrderSummaries(rows),
})
@@ -243,7 +248,7 @@ const candidateColumns: DataTableColumn[] = [
]
const itemColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 90, fixed: 'left', slotName: 'actions' },
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 140, fixed: 'left', showOverflowTooltip: true },
{ key: 'inboundCode', label: '入仓码', minWidth: 220, fixed: 'left', slotName: 'planInbounds' },
{ key: 'palletNo', prop: 'palletNo', label: '托盘号', minWidth: 145, showOverflowTooltip: true },
{ key: 'coLoadGroup', label: '装柜规则', minWidth: 170, slotName: 'coLoadGroup' },
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 70, slotName: 'customerName' },
@@ -821,6 +826,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="canDelete && editable && row.canRemove" size="small" type="danger" text :disabled="mutating" @click="remove(row)"><Trash2 :size="15" />删除</el-button></div></template>
<template #planInbounds="{ row }"><SmartLoadingCandidateInbounds :members="row.palletMembers" :sealed-pallet="row.unitType === 'SEALED_PALLET'" :fallback-inbound-code="row.inboundCode" /></template>
<template #customerName="{ row }"><LineClampText :content="row.customerName" /></template>
<template #coLoadGroup="{ row }"><details v-if="row.coLoadGroup" class="candidate-group"><summary>整组加入 · 不可拆</summary><el-tag size="small" type="warning"><LineClampText :content="`${row.coLoadGroup.groupNo} · ${groupMemberLabel(row.coLoadGroup)}`" /></el-tag><LineClampText :content="row.memberOrderSummaries.join(';')" /><LineClampText :content="`固定 ${row.coLoadGroup.fullPackageCount} 箱 · ${row.coLoadGroup.lockedPlanId ? '已锁定' : '未锁定'}`" /></details><LineClampText v-else :content="row.unitType === 'SEALED_PALLET' ? '整托加入 · 不可拆' : '按箱加入'" /></template>
<template #packageCount="{ row }"><LineClampText v-if="row.unitType === 'SEALED_PALLET'" :content="`${row.packageCount} 箱(整托固定)`" /><LineClampText v-else-if="row.coLoadGroup" :content="`${row.packageCount} 箱(整组固定)`" /><div v-else class="package-count-editor"><el-input-number v-if="editingQuantityId === row.id" v-model="quantityDrafts[row.id]" :min="row.minPackageCount" :max="adjustmentMax(row)" :step="1" :controls="false" :disabled="mutating" placeholder="请输入使用箱数" @blur="adjust(row)" @keyup.enter="adjust(row)" /><div v-else class="package-count-summary"><div class="package-count-used"><strong>使用:{{ row.packageCount }}</strong><el-button v-if="canAssign && editable && row.canAdjust" link circle :disabled="mutating" aria-label="调整箱数" @click="startQuantityEdit(row)"><Pencil :size="14" /></el-button></div><small v-if="(row.remainingPackageCount ?? 0) > 0">未用:{{ row.remainingPackageCount }}</small></div><LineClampText v-if="row.detailCount > 1 && editable" content="多规格,需删除后重加" /><LineClampText v-if="row.pickedPackageCount > 0" :content="`已拣 ${row.pickedPackageCount} 箱`" /></div></template>
@@ -29,7 +29,7 @@ const container = {
const planResponse = {
id: '99', bookingId: container.id, planNo: 'LP-99', status: 'EDITING' as const, containerNo: 'MSCU-1', voyageNo: null, version: 2,
canAssign: true, canEdit: true, blockedReason: null, finalCompletionStatus: 'NOT_STARTED' as const,
pdaTaskId: null, pdaTaskStatus: null, pdaTaskVersion: null, coLoadGroups: [], items: [],
pdaTaskId: null, pdaTaskStatus: null, pdaTaskVersion: null, coLoadGroups: [], palletMembers: [], 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 }
function candidatePage(records: unknown[]) {
@@ -78,6 +78,33 @@ describe('SmartLoadingWorkbenchView', () => {
expect(wrapper.text()).toContain('150% 容量上限')
})
it('shows the full inbound composition for an assigned sealed pallet', async () => {
vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue({
...planResponse,
items: [{
id: '101', loadingPlanId: '99', accountId: '1', inboundId: '180', inboundDetailId: '201', orderId: '280',
orderNo: 'O180', inboundCode: 'I180', customerName: '客户', destinationWarehouseId: '9',
destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', skuCode: 'SKU-1', productNameZh: '货物',
unitType: 'SEALED_PALLET', palletId: '801', palletNo: 'DP-260914-0025', indivisible: true,
packageCount: 11, productQuantity: 110, grossWeightKg: 109.2, volumeCbm: 1.87, loadSequence: 1,
version: 1, pickedPackageCount: 0, minPackageCount: 11, canAdjust: false, canRemove: true,
}],
palletMembers: [{ palletId: '801', members: [
{ accountId: '1', inboundId: '180', orderId: '280', orderNo: 'O180', inboundCode: 'I180', customerName: '客户', availablePackageCount: 6, orderStatusSnapshot: null, expectedArrivalAt: null },
{ accountId: '1', inboundId: '181', orderId: '281', orderNo: 'O181', inboundCode: 'I181', customerName: '客户', availablePackageCount: 5, orderStatusSnapshot: null, expectedArrivalAt: null },
] }],
})
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()
const row = wrapper.findAll('.el-table__body-wrapper tr').find((candidateRow) => candidateRow.text().includes('DP-260914-0025'))
expect(row).toBeDefined()
expect(row!.text()).toContain('I180:6 箱')
expect(row!.text()).toContain('I181:5 箱')
})
it('uses loading plan capabilities to keep PDA increments editable', async () => {
vi.mocked(getContainer).mockResolvedValue({ ...container, planStatus: 'LOADING' })
vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue({ ...planResponse, status: 'LOADING', pdaTaskId: '700', pdaTaskStatus: 'LOADING', pdaTaskVersion: 4 })
@@ -18,7 +18,7 @@ import {
getSmartLoadingPlanByBooking, listSmartLoadingCandidates,
recommendSmartLoading, removeSmartLoadingGroup, removeSmartLoadingItem,
} from '../api/platformContainerLoading'
import type { ContainerBookingResource, SmartLoadingCandidate, SmartLoadingPlan } from '../types'
import type { ContainerBookingResource, SmartLoadingCandidate, SmartLoadingCandidateGroupMember, SmartLoadingPlan } from '../types'
import { groupSmartLoadingItems, type SmartLoadingInboundItem } from '../smartLoadingItems'
import { loadSmartLoadingPlanCoLoadGroups, type SmartLoadingPlanCoLoadGroup } from '../smartLoadingPlanGroups'
import { getSmartLoadingChannelGroup } from '../smartLoadingChannelGroups'
@@ -80,7 +80,10 @@ const displayedCandidates = computed<SmartLoadingCandidateRow[]>(() => {
}
})
})
type SmartLoadingPlanRow = SmartLoadingInboundItem & { coLoadGroup: SmartLoadingPlanCoLoadGroup | null; memberInboundCodes: string[]; memberOrderSummaries: string[] }
const planPalletMembersByPalletId = computed(() => new Map(
(plan.value?.palletMembers || []).map((entry) => [entry.palletId, entry.members]),
))
type SmartLoadingPlanRow = SmartLoadingInboundItem & { coLoadGroup: SmartLoadingPlanCoLoadGroup | null; palletMembers: SmartLoadingCandidateGroupMember[]; memberInboundCodes: string[]; memberOrderSummaries: string[] }
const displayedPlanItems = computed<SmartLoadingPlanRow[]>(() => {
const inboundRows = groupSmartLoadingItems(plan.value?.items || [])
const result: SmartLoadingPlanRow[] = []
@@ -88,7 +91,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 || '入仓码暂缺'], memberOrderSummaries: memberOrderSummaries([row]) })
result.push({ ...row, coLoadGroup: null, palletMembers: row.palletId ? planPalletMembersByPalletId.value.get(row.palletId) || [] : [], memberInboundCodes: [row.inboundCode || '入仓码暂缺'], memberOrderSummaries: memberOrderSummaries([row]) })
continue
}
grouped.set(group.groupId, [...(grouped.get(group.groupId) || []), row])
@@ -114,6 +117,7 @@ const displayedPlanItems = computed<SmartLoadingPlanRow[]>(() => {
sourceItems: rows.flatMap((row) => row.sourceItems),
detailCount: rows.reduce((sum, row) => sum + row.detailCount, 0),
coLoadGroup: group,
palletMembers: [],
memberInboundCodes: rows.map((row) => row.inboundCode || '入仓码暂缺'),
memberOrderSummaries: memberOrderSummaries(rows),
})
@@ -384,7 +388,7 @@ onMounted(load)
<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 density-actions"><el-button data-tone="danger" class="density-action" v-if="canDelete && editable && row.canRemove" size="small" type="danger" plain @click="remove(row)">删除</el-button></div></template>
<template #order="{ row }"><strong>{{ row.inboundCode || '—' }}</strong></template>
<template #order="{ row }"><SmartLoadingCandidateInbounds :members="row.palletMembers" :sealed-pallet="row.unitType === 'SEALED_PALLET'" :fallback-inbound-code="row.inboundCode" /></template>
<template #customerName="{ row }"><LineClampText :content="row.customerName" /></template>
<template #coLoadGroup="{ row }"><details v-if="row.coLoadGroup" class="candidate-group"><summary>整组加入 · 不可拆</summary><el-tag size="small" type="warning"><LineClampText :content="`${row.coLoadGroup.groupNo} · ${groupMemberLabel(row.coLoadGroup)}`" /></el-tag><LineClampText :content="row.memberOrderSummaries.join(';')" /><LineClampText :content="`全部入仓片段联动,固定 ${row.coLoadGroup.fullPackageCount} 箱`" /></details><LineClampText v-else :content="row.unitType === 'SEALED_PALLET' ? '整托加入 · 不可拆' : '按箱加入'" /></template>
<template #goods="{ row }"><div class="goods-cell"><LineClampText :content="row.productNameZh || '未命名货物'" /><LineClampText class="goods-cell__sku" :content="row.skuCode || '无 SKU'" /></div></template>
@@ -15,6 +15,7 @@ const plan = {
pdaTaskStatus: null, pdaTaskVersion: null,
coLoadGroups: [{ groupId: '9001', groupNo: 'CLG-9001', lockedPlanId: '99', memberOrderCount: 2,
inboundFragmentCount: 2, fullPackageCount: 12, memberInboundIds: ['201', '202'] }],
palletMembers: [],
items: [item, { ...item, id: '702', inboundId: '202', inboundDetailId: '802', orderId: '302', orderNo: 'ORD-2', inboundCode: 'IN-202', packageCount: 7 }],
} satisfies SmartLoadingPlan
+4
View File
@@ -454,5 +454,9 @@ export interface SmartLoadingPlan {
fullPackageCount: number
memberInboundIds: string[]
}>
palletMembers: Array<{
palletId: string
members: SmartLoadingCandidateGroupMember[]
}>
items: SmartLoadingItem[]
}