Release/test 合并上生产 #2

Merged
admin merged 736 commits from release/test into main 2026-09-17 11:50:56 +08:00
7 changed files with 97 additions and 90 deletions
Showing only changes of commit 2d14568caf - Show all commits
@@ -119,6 +119,14 @@ function plan(raw: SmartLoadingPlan): SmartLoadingPlan {
blockedReason: raw.blockedReason || null,
pdaTaskId: optionalId(raw.pdaTaskId),
pdaTaskVersion: raw.pdaTaskVersion == null ? null : numberValue(raw.pdaTaskVersion),
coLoadGroups: (raw.coLoadGroups || []).map((group) => ({
...group,
groupId: id(group.groupId), lockedPlanId: optionalId(group.lockedPlanId),
memberOrderCount: numberValue(group.memberOrderCount),
inboundFragmentCount: numberValue(group.inboundFragmentCount),
fullPackageCount: numberValue(group.fullPackageCount),
memberInboundIds: (group.memberInboundIds || []).map(id),
})),
items: (raw.items || []).map(item),
}
}
@@ -280,6 +288,8 @@ export async function listSmartLoadingWorkbenchContainers(query: SmartLoadingCan
total: numberValue(result.total), pages: numberValue(result.pages),
records: result.records.map((raw) => ({
...raw, bookingId: id(raw.bookingId), warehouseId: id(raw.warehouseId), planId: optionalId(raw.planId),
capacityCbm: raw.capacityCbm == null ? null : numberValue(raw.capacityCbm),
maxWeightKg: raw.maxWeightKg == null ? null : numberValue(raw.maxWeightKg),
version: numberValue(raw.version),
})),
}
@@ -49,7 +49,13 @@ vi.mock('../api/platformContainerLoading', () => ({
removeSmartLoadingGroup: vi.fn(),
pushSmartLoadingPreload: vi.fn(),
}))
vi.mock('../smartLoadingPlanGroups', () => ({ loadSmartLoadingPlanCoLoadGroups: vi.fn().mockResolvedValue(new Map()) }))
vi.mock('@/modules/warehouses', () => ({
listAllPlatformWarehouses: vi.fn().mockResolvedValue([
{ id: '8', warehouseName: '义乌仓', warehouseType: 'DOMESTIC', status: 'ACTIVE' },
{ id: '18', warehouseName: '东莞仓', warehouseType: 'DOMESTIC', status: 'ACTIVE' },
]),
}))
vi.mock('../smartLoadingPlanGroups', () => ({ loadSmartLoadingPlanCoLoadGroups: vi.fn().mockReturnValue(new Map()) }))
const container = {
id: '9007199254740993', planId: '9007199254740994', bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1',
@@ -64,6 +70,7 @@ const plan = {
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: [{ 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: '服装', packageCount: 6, productQuantity: 6, grossWeightKg: 60, volumeCbm: 1.2, loadSequence: 1, version: 1, pickedPackageCount: 0, minPackageCount: 1, canAdjust: true, canRemove: true }],
}
const candidate = {
@@ -89,7 +96,8 @@ function containerPage(records: ContainerBookingResource[] = [container]) {
records: records.map((item) => ({
bookingId: item.id, bookingNo: item.bookingNo, cabinetNo: item.cabinetNo, containerNo: item.containerNo,
warehouseId: item.warehouseId, warehouseName: item.warehouseName || '', channelCode: item.channelCode || '', channelName: item.channelName || '',
containerType: item.containerType, planId: item.planId, planNo: null, planStatus: item.planStatus, version: item.version,
containerType: item.containerType, capacityCbm: item.capacityCbm, maxWeightKg: item.maxWeightKg,
planId: item.planId, planNo: null, planStatus: item.planStatus, version: item.version,
})),
page: 1, size: 200, total: records.length, pages: records.length ? 1 : 0,
} as Awaited<ReturnType<typeof listSmartLoadingWorkbenchContainers>>
@@ -105,7 +113,6 @@ describe('GlobalSmartLoadingView', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
vi.mocked(listContainers).mockResolvedValue({ records: [container], page: 1, size: 100, total: 1, pages: 1 })
mockContainerSearch([container])
vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue(plan)
vi.mocked(listSmartLoadingCandidates).mockResolvedValue(candidatePage([candidate]))
@@ -115,7 +122,6 @@ describe('GlobalSmartLoadingView', () => {
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(listContainers).toHaveBeenCalledWith({ page: 1, size: 100 })
expect(getSmartLoadingPlanByBooking).toHaveBeenCalledWith(container.id)
expect(listSmartLoadingCandidates).toHaveBeenCalledWith({ channelGroupCodes: ['MATSON_CLX'], page: 1, size: 20 })
expect(wrapper.text()).toContain('候选装柜明细')
@@ -145,13 +151,12 @@ describe('GlobalSmartLoadingView', () => {
total: 1,
pages: 1,
})
mockContainerSearch([{ ...container, shippingCompanyId: '32', shippingCompanyName: 'EXX', channelCode: 'EXX_RAIL_NY', channelName: '纽约EXX海铁' }])
vi.mocked(listSmartLoadingCandidates).mockResolvedValue(candidatePage([{ accountId: '1', inboundId: '5', orderId: '7', orderNo: 'ORD-2', inboundCode: 'IN-2', warehouseId: '8', channelCode: 'EXX_RAIL_NY', channelName: '纽约EXX海铁', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', customerName: '另一企业', goodsSummary: '鞋类', availablePackageCount: 12, unitGrossWeightKg: 10, unitNetWeightKg: 9, averageUnitVolumeCbm: 0.2, availableVolumeCbm: 2.4, coLoadGroupId: null, groupNo: null, memberCount: null, groupTotalBoxes: null, locked: null, blockReason: null }]))
mockContainerSearch([container])
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.text()).toContain('EXX')
expect(wrapper.text()).not.toContain('纽约EXX海铁')
expect(wrapper.text()).toContain('美森正班')
expect(wrapper.text()).not.toContain('美森正班海卡包税')
expect(wrapper.text()).not.toContain('缺少渠道快照')
expect(getSmartLoadingPlanByBooking).toHaveBeenCalledWith(container.id)
})
@@ -438,6 +443,8 @@ describe('GlobalSmartLoadingView', () => {
await buttons[0].trigger('click')
await flushPromises()
expect(document.body.textContent).toContain('不能部分输入、单独分配或单独移除')
expect(document.body.textContent).toContain('目标柜次:CB-1 · 集装箱号:MSCU-1')
expect(document.body.textContent).toContain('发货仓:义乌仓 · 渠道:美森正班')
expect(document.body.textContent).not.toContain('本次分配箱数')
;([...document.body.querySelectorAll('button')].find((button) => button.textContent?.includes('确认整组分配')) as HTMLButtonElement).click()
await flushPromises()
@@ -454,6 +461,9 @@ describe('GlobalSmartLoadingView', () => {
inboundId: null,
orderStatusSnapshot: 'PENDING_REVIEW',
inboundCode: 'EXXHK2609020361',
unitGrossWeightKg: null,
averageUnitVolumeCbm: null,
availableVolumeCbm: null,
blockReason: null,
}]))
vi.mocked(assignPendingReviewSmartLoadingOrder).mockResolvedValue(plan)
@@ -466,6 +476,9 @@ describe('GlobalSmartLoadingView', () => {
expect(add.classes()).not.toContain('is-disabled')
await add.trigger('click')
await flushPromises()
expect(document.body.textContent).toContain('平均单箱毛重 待测量')
expect(document.body.textContent).toContain('平均单箱体积 待测量')
expect(document.body.textContent).not.toContain('平均单箱毛重 0 kg')
;([...document.body.querySelectorAll('button')]
.find((button) => button.textContent?.includes('确认加入')) as HTMLButtonElement).click()
await flushPromises()
@@ -522,7 +535,10 @@ describe('GlobalSmartLoadingView', () => {
const fields = wrapper.findComponent(QueryToolbar).props('fields') as Array<{ key: string; options?: Array<{ label: string; value: string }>; props?: Record<string, unknown> }>
expect(fields.map((field) => field.key)).toEqual(['warehouseId', 'customerId', 'inboundCode', 'createdRange', 'orderStatuses'])
expect(fields.find((field) => field.key === 'warehouseId')?.options).toEqual([{ label: '义乌仓', value: '8' }])
expect(fields.find((field) => field.key === 'warehouseId')?.options).toEqual([
{ label: '义乌仓', value: '8' },
{ label: '东莞仓', value: '18' },
])
expect(fields.find((field) => field.key === 'orderStatuses')?.options).toEqual([
{ value: 'PENDING_REVIEW', label: '待审核' },
{ value: 'WAIT_MEASURE', label: '待测量数据' },
@@ -610,7 +626,7 @@ describe('GlobalSmartLoadingView', () => {
expect(getSmartLoadingPlanByBooking).not.toHaveBeenCalled()
expect(wrapper.text()).not.toContain('CB-3')
expect(wrapper.text()).toContain('当前柜次已完成装柜,不可继续编辑')
expect(wrapper.text()).toContain('暂无符合条件的货柜')
expect(wrapper.text()).not.toContain('缺少渠道快照')
})
@@ -661,7 +677,7 @@ describe('GlobalSmartLoadingView', () => {
expect(recommendSmartLoading).toHaveBeenCalledWith(plan.id, 2)
expect(recommendSmartLoading).toHaveBeenCalledWith(plan.id, 3)
expect(getSmartLoadingPlanByBooking).toHaveBeenCalledTimes(3)
expect(getSmartLoadingPlanByBooking).toHaveBeenCalledTimes(2)
expect(warningMessage).not.toHaveBeenCalled()
expect(wrapper.text()).not.toContain('装柜操作失败')
expect(wrapper.text()).not.toContain('request-1')
@@ -4,6 +4,7 @@ import { Plus, RefreshCw, Sparkles, Trash2 } from '@lucide/vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { listAllPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses'
import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
@@ -18,7 +19,6 @@ import {
assignSmartLoadingGroup,
assignSmartLoadingItem,
getSmartLoadingPlanByBooking,
listContainers,
listSmartLoadingCandidates,
listSmartLoadingWorkbenchContainers,
recommendSmartLoading,
@@ -26,7 +26,6 @@ import {
removeSmartLoadingItem,
} from '../api/platformContainerLoading'
import type {
ContainerBookingResource,
SmartLoadingCandidate,
SmartLoadingPlan,
SmartLoadingWorkbenchContainer,
@@ -42,7 +41,7 @@ const props = withDefaults(defineProps<{ embedded?: boolean }>(), { embedded: fa
const access = useAccessPreviewStore()
const containers = ref<SmartLoadingWorkbenchContainer[]>([])
const containerCatalog = ref<ContainerBookingResource[]>([])
const warehouses = ref<PlatformWarehouseSummaryResource[]>([])
const customers = ref<PlatformTenantResource[]>([])
const customersLoading = ref(false)
const selectedContainerId = ref('')
@@ -75,8 +74,8 @@ let containerRequest = 0
const canAssign = computed(() => access.can('platform:firstleg:smart-loading:assign'))
const canDelete = computed(() => access.can('platform:firstleg:smart-loading:delete'))
const selectedContainer = computed(() => containers.value.find((item) => item.bookingId === selectedContainerId.value) || null)
const orchestratableContainers = computed(() => containerCatalog.value.filter((item) => ['WAIT_LOADING', 'EDITING', 'LOADING'].includes(item.planStatus)))
const completedContainerCount = computed(() => containerCatalog.value.filter((item) => item.planStatus === 'COMPLETED').length)
const orchestratableContainers = computed(() => containers.value)
const completedContainerCount = computed(() => 0)
const missingChannelContainerCount = computed(() => orchestratableContainers.value.filter((item) => !item.channelCode).length)
const unmappedChannelContainerCount = computed(() => orchestratableContainers.value.filter((item) => (
item.channelCode && !resolveSmartLoadingChannelGroup(item.channelCode, item.channelName)
@@ -86,17 +85,12 @@ const mappedWorkbenchContainerCount = computed(() => orchestratableContainers.va
)).length)
const channelOptions = computed(() => SMART_LOADING_CHANNEL_GROUPS)
const warehouseOptions = computed(() => {
const options = new Map<string, string>()
for (const item of orchestratableContainers.value) {
if (item.warehouseId && !options.has(item.warehouseId)) options.set(item.warehouseId, item.warehouseName?.trim() || '未命名发货仓')
}
return [...options].map(([value, label]) => ({ value, label }))
return warehouses.value.map((warehouse) => ({ value: String(warehouse.id), label: warehouse.warehouseName }))
})
const editable = computed(() => plan.value?.canEdit === true)
const assignable = computed(() => plan.value?.canAssign === true)
const selectedContainerResource = computed(() => containerCatalog.value.find((item) => item.id === selectedContainerId.value) || null)
const assignedInboundIds = computed(() => new Set((plan.value?.items || []).map((item) => item.inboundId)))
type SmartLoadingCandidateRow = SmartLoadingCandidate & { memberInboundIds: string[]; memberInboundCodes: string[]; memberOrderSummaries: string[]; memberOrderCount: number; inboundFragmentCount: number; totalGrossWeightKg: number; totalVolumeCbm: number }
type SmartLoadingCandidateRow = SmartLoadingCandidate & { memberInboundIds: string[]; memberInboundCodes: string[]; memberOrderSummaries: string[]; memberOrderCount: number; inboundFragmentCount: number; totalGrossWeightKg: number | null; totalVolumeCbm: number | null }
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) {
@@ -113,11 +107,11 @@ function groupMemberLabel(group: SmartLoadingPlanCoLoadGroup) {
: `${group.memberOrderCount} 票 · ${group.inboundFragmentCount} 个入仓片段`
}
function candidateTotalGrossWeight(candidate: SmartLoadingCandidate) {
return Number(candidate.unitGrossWeightKg || 0) * Number(candidate.availablePackageCount || 0)
return candidate.unitGrossWeightKg == null ? null : Number(candidate.unitGrossWeightKg) * Number(candidate.availablePackageCount || 0)
}
function candidateTotalVolume(candidate: SmartLoadingCandidate) {
return candidate.availableVolumeCbm == null
? Number(candidate.averageUnitVolumeCbm || 0) * Number(candidate.availablePackageCount || 0)
? candidate.averageUnitVolumeCbm == null ? null : Number(candidate.averageUnitVolumeCbm) * Number(candidate.availablePackageCount || 0)
: Number(candidate.availableVolumeCbm)
}
const displayedCandidates = computed<SmartLoadingCandidateRow[]>(() => {
@@ -178,8 +172,8 @@ const displayedPlanItems = computed<SmartLoadingPlanRow[]>(() => {
const totalPackages = computed(() => (plan.value?.items || []).reduce((sum, item) => sum + Number(item.packageCount || 0), 0))
const totalVolume = computed(() => (plan.value?.items || []).reduce((sum, item) => sum + Number(item.volumeCbm || 0), 0))
const totalWeight = computed(() => (plan.value?.items || []).reduce((sum, item) => sum + Number(item.grossWeightKg || 0), 0))
const volumeLimit = computed(() => Number(selectedContainerResource.value?.capacityCbm || 0) * 1.5)
const weightLimit = computed(() => Number(selectedContainerResource.value?.maxWeightKg || 0) * 1.5)
const volumeLimit = computed(() => Number(selectedContainer.value?.capacityCbm || 0) * 1.5)
const weightLimit = computed(() => Number(selectedContainer.value?.maxWeightKg || 0) * 1.5)
const volumePercent = computed(() => volumeLimit.value ? totalVolume.value / volumeLimit.value * 100 : 0)
const weightPercent = computed(() => weightLimit.value ? totalWeight.value / weightLimit.value * 100 : 0)
const displayedVolumePercent = computed(() => percentage(volumePercent.value))
@@ -242,6 +236,9 @@ const itemColumns: DataTableColumn[] = [
function formatNumber(value: number, digits = 3) {
return Number.isFinite(value) ? value.toLocaleString('zh-CN', { maximumFractionDigits: digits }) : '0'
}
function formatMeasurement(value: number | null | undefined, unit: string, digits = 3) {
return value == null ? '待测量' : `${formatNumber(Number(value), digits)} ${unit}`
}
function percentage(value: number) { return Number.isFinite(value) ? Number(Math.max(value, 0).toFixed(2)) : 0 }
function candidateRow(source: object) { return source as SmartLoadingCandidateRow }
function itemRow(source: object) { return source as SmartLoadingInboundItem }
@@ -250,17 +247,17 @@ function adjustmentMax(source: object) {
const remaining = candidates.value.find((candidate) => candidate.inboundId === item.inboundId)?.availablePackageCount || 0
return Math.max(item.minPackageCount, item.packageCount + remaining)
}
function displayCabinetNo(container: ContainerBookingResource | SmartLoadingWorkbenchContainer | null | undefined) {
function displayCabinetNo(container: SmartLoadingWorkbenchContainer | null | undefined) {
if (!container) return '待生成'
const cabinetReference = String(container.cabinetNo || '').trim()
return /^\d{15,}$/.test(cabinetReference) ? '待生成' : cabinetReference || '待生成'
}
function hasRealContainerNo(container: ContainerBookingResource | SmartLoadingWorkbenchContainer | null | undefined) {
function hasRealContainerNo(container: SmartLoadingWorkbenchContainer | null | undefined) {
if (!container) return false
const value = String(container.containerNo || '').trim()
return Boolean(value) && !/^(?:MAN|SPLIT)-/i.test(value)
}
function displayActualContainerNo(container: ContainerBookingResource | SmartLoadingWorkbenchContainer | null | undefined) {
function displayActualContainerNo(container: SmartLoadingWorkbenchContainer | null | undefined) {
return hasRealContainerNo(container) ? String(container?.containerNo || '').trim() : '待补录'
}
function syncDrafts(next: SmartLoadingPlan, preserved: Record<string, number> = {}) {
@@ -269,6 +266,7 @@ function syncDrafts(next: SmartLoadingPlan, preserved: Record<string, number> =
}
function acceptPlan(next: SmartLoadingPlan, preserved: Record<string, number> = {}, syncContainerStatus = true) {
plan.value = next
planGroupsByInboundId.value = loadSmartLoadingPlanCoLoadGroups(next)
noDraft.value = false
syncDrafts(next, preserved)
const container = selectedContainer.value
@@ -357,7 +355,6 @@ async function loadSelectedPlan(preserved: Record<string, number> = {}) {
if (request !== selectionRequest) return
if (planResult.value) {
acceptPlan(planResult.value, preserved, false)
planGroupsByInboundId.value = await loadSmartLoadingPlanCoLoadGroups(planResult.value)
syncDrafts(planResult.value, preserved)
}
else noDraft.value = planResult.missing
@@ -398,17 +395,11 @@ async function loadAll(preserved: Record<string, number> = {}) {
loading.value = true
loadError.value = null
try {
const firstPage = await listContainers({ page: 1, size: 100 })
const remainingPages = firstPage.pages > 1
? await Promise.all(Array.from({ length: firstPage.pages - 1 }, (_, index) => listContainers({ page: index + 2, size: 100 })))
: []
containerCatalog.value = [firstPage, ...remainingPages].flatMap((page) => page.records)
warehouses.value = await listAllPlatformWarehouses({ warehouseType: 'DOMESTIC', status: 'ACTIVE' })
const availableChannelCodes = new Set(channelOptions.value.map((item) => item.code))
selectedChannelCodes.value = selectedChannelCodes.value.filter((code) => availableChannelCodes.has(code))
if (!selectedChannelCodes.value.length) {
selectedChannelCodes.value = [channelOptions.value.find((channel) => orchestratableContainers.value.some((item) => (
resolveSmartLoadingChannelGroup(item.channelCode, item.channelName)?.code === channel.code
)))?.code || channelOptions.value[0]?.code || '']
selectedChannelCodes.value = [channelOptions.value[0]?.code || '']
}
await loadWorkbenchData(preserved)
} catch (error) {
@@ -497,7 +488,7 @@ async function assign() {
expectedVersion: plan.value!.version,
}))
assignOpen.value = false
await loadWorkbenchData()
await loadCandidateData()
ElMessage.success(selected.coLoadGroupId ? `同柜不可拆组 ${selected.groupNo} 已整组分配` : '明细已加入当前柜次')
}, '分配明细失败', { preserveAssign: true })
}
@@ -524,7 +515,7 @@ async function recommend() {
if (!plan.value) throw error
acceptPlan(await recommendSmartLoading(plan.value.id, plan.value.version))
}
await loadWorkbenchData()
await loadCandidateData()
ElMessage.success('智能推荐已生成')
}, '智能推荐失败')
}
@@ -540,7 +531,7 @@ async function adjust(source: object) {
if (count > max) { ElMessage.warning(`该明细最多可分配 ${max} 箱`); return }
await mutate(async () => {
acceptPlan(await adjustSmartLoadingItem(plan.value!.id, item.id, count, plan.value!.version, item.version))
await loadWorkbenchData()
await loadCandidateData()
ElMessage.success('箱数已调整')
}, '调整箱数失败', { preserveDrafts: { ...quantityDrafts } })
}
@@ -557,7 +548,7 @@ async function remove(source: object) {
await mutate(async () => {
if (row.coLoadGroup) {
acceptPlan(await removeSmartLoadingGroup(plan.value!.id, row.coLoadGroup.groupId, plan.value!.version))
await loadWorkbenchData()
await loadCandidateData()
ElMessage.success(`同柜不可拆组 ${row.coLoadGroup.groupNo} 已整组移除`)
return
}
@@ -568,7 +559,7 @@ async function remove(source: object) {
current = await removeSmartLoadingItem(current.id, currentItem.id, current.version, currentItem.version)
acceptPlan(current)
}
await loadWorkbenchData()
await loadCandidateData()
ElMessage.success('该入仓码已从当前柜次移除')
}, '移除装柜明细失败', { pickingConflict: true })
}
@@ -595,7 +586,7 @@ async function removeAll() {
acceptPlan(current)
}
}
await loadWorkbenchData()
await loadCandidateData()
ElMessage.success(`已移除 ${itemCount} 个未拣入仓码`)
}, '全部移除失败', { pickingConflict: true })
}
@@ -703,10 +694,10 @@ onMounted(() => { void load(); void loadCustomers() })
<template #customerName="{ row }"><LineClampText :content="row.customerName" /></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 #orderStatusSnapshot="{ row }"><el-tag size="small" effect="plain">{{ candidateStatusLabel(row.orderStatusSnapshot) }}</el-tag></template>
<template #unitGrossWeight="{ row }">{{ formatNumber(Number(row.unitGrossWeightKg || 0), 1) }} kg</template>
<template #candidateTotalWeight="{ row }">{{ formatNumber(Number(row.totalGrossWeightKg || 0), 1) }} kg</template>
<template #averageUnitVolume="{ row }">{{ formatNumber(Number(row.averageUnitVolumeCbm || 0)) }} m³</template>
<template #candidateTotalVolume="{ row }">{{ formatNumber(Number(row.totalVolumeCbm || 0)) }} m³</template>
<template #unitGrossWeight="{ row }">{{ formatMeasurement(row.unitGrossWeightKg, 'kg', 1) }}</template>
<template #candidateTotalWeight="{ row }">{{ formatMeasurement(row.totalGrossWeightKg, 'kg', 1) }}</template>
<template #averageUnitVolume="{ row }">{{ formatMeasurement(row.averageUnitVolumeCbm, 'm³') }}</template>
<template #candidateTotalVolume="{ row }">{{ formatMeasurement(row.totalVolumeCbm, 'm³') }}</template>
<template #cartonIdentityStatus><el-tag size="small" type="info" effect="plain">未核验</el-tag></template>
</DataTable>
</section>
@@ -786,13 +777,15 @@ onMounted(() => { void load(); void loadCustomers() })
<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.customerName || '客户待补充' }}</strong></p>
<p>目标柜次:{{ displayCabinetNo(selectedContainer) }} · 集装箱号:{{ displayActualContainerNo(selectedContainer) }}</p>
<p>发货仓:{{ selectedContainer?.warehouseName || '—' }} · 渠道:{{ resolveSmartLoadingChannelGroup(selectedContainer?.channelCode, selectedContainer?.channelName)?.name || '—' }}</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.inboundCode || '—' }} · 可用 {{ selectedCandidate.availablePackageCount }} 箱 · 平均单箱毛重 {{ formatNumber(Number(selectedCandidate.unitGrossWeightKg || 0), 1) }} kg · 平均单箱体积 {{ formatNumber(Number(selectedCandidate.averageUnitVolumeCbm || 0)) }} m³</p>
<p>入仓码 {{ selectedCandidate.inboundCode || '—' }} · 可用 {{ selectedCandidate.availablePackageCount }} 箱 · 平均单箱毛重 {{ formatMeasurement(selectedCandidate.unitGrossWeightKg, 'kg', 1) }} · 平均单箱体积 {{ formatMeasurement(selectedCandidate.averageUnitVolumeCbm, 'm³') }}</p>
<small>本次只记录该入仓码的计划装柜箱数,具体箱号归属为未核验。</small>
<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>
</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, items: [],
pdaTaskId: null, pdaTaskStatus: null, pdaTaskVersion: null, coLoadGroups: [], 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[]) {
@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from 'vitest'
import { describe, expect, it } from 'vitest'
import { loadSmartLoadingPlanCoLoadGroups } from './smartLoadingPlanGroups'
import type { SmartLoadingPlan } from './types'
@@ -13,24 +13,22 @@ const plan = {
id: '99', planNo: 'LP-99', status: 'EDITING', bookingId: '88', containerNo: null, voyageNo: null, version: 2,
canAssign: true, canEdit: true, blockedReason: null, finalCompletionStatus: 'NOT_STARTED', pdaTaskId: null,
pdaTaskStatus: null, pdaTaskVersion: null,
coLoadGroups: [{ groupId: '9001', groupNo: 'CLG-9001', lockedPlanId: '99', memberOrderCount: 2,
inboundFragmentCount: 2, fullPackageCount: 12, memberInboundIds: ['201', '202'] }],
items: [item, { ...item, id: '702', inboundId: '202', inboundDetailId: '802', orderId: '302', orderNo: 'ORD-2', inboundCode: 'IN-202', packageCount: 7 }],
} satisfies SmartLoadingPlan
describe('smart loading plan co-load groups', () => {
it('rebuilds the locked group from the current inbound association contract', async () => {
const lookup = vi.fn(async (inboundId: string) => ({
inboundId, inboundCode: `IN-${inboundId}`, loadingPlanId: '99', coLoadGroupId: '9001', coLoadGroupNo: 'CLG-9001', items: [],
}))
const result = await loadSmartLoadingPlanCoLoadGroups(plan, lookup)
expect(lookup).toHaveBeenCalledTimes(2)
it('uses the group snapshot already returned with the plan without per-inbound requests', () => {
const result = loadSmartLoadingPlanCoLoadGroups(plan)
expect(result.get('201')).toEqual({
groupId: '9001', groupNo: 'CLG-9001', lockedPlanId: '99', memberOrderCount: 2, inboundFragmentCount: 2, fullPackageCount: 12,
})
expect(result.get('202')).toBe(result.get('201'))
})
it('leaves ordinary plan items ungrouped', async () => {
const result = await loadSmartLoadingPlanCoLoadGroups(plan, async (inboundId) => ({ inboundId, inboundCode: `IN-${inboundId}`, items: [] }))
it('leaves ordinary plan items ungrouped', () => {
const result = loadSmartLoadingPlanCoLoadGroups({ ...plan, coLoadGroups: [] })
expect(result.size).toBe(0)
})
})
@@ -1,5 +1,3 @@
import { getLoadingAssociationSummaryByInboundId, type LoadingAssociationSummary } from '@/shared/api/inboundCodeRelations'
import { groupSmartLoadingItems } from './smartLoadingItems'
import type { SmartLoadingPlan } from './types'
export interface SmartLoadingPlanCoLoadGroup {
@@ -11,37 +9,18 @@ export interface SmartLoadingPlanCoLoadGroup {
fullPackageCount: number
}
type AssociationLookup = (inboundId: string) => Promise<LoadingAssociationSummary>
export async function loadSmartLoadingPlanCoLoadGroups(
plan: SmartLoadingPlan,
lookup: AssociationLookup = getLoadingAssociationSummaryByInboundId,
) {
const inboundRows = groupSmartLoadingItems(plan.items || [])
if (!inboundRows.length) return new Map<string, SmartLoadingPlanCoLoadGroup>()
const associations = await Promise.all(inboundRows.map(async (row) => ({
row,
association: await lookup(row.inboundId),
})))
const membersByGroupId = new Map<string, typeof associations>()
for (const entry of associations) {
const groupId = String(entry.association.coLoadGroupId || '')
if (!groupId) continue
membersByGroupId.set(groupId, [...(membersByGroupId.get(groupId) || []), entry])
}
export function loadSmartLoadingPlanCoLoadGroups(plan: SmartLoadingPlan) {
const groupsByInboundId = new Map<string, SmartLoadingPlanCoLoadGroup>()
for (const [groupId, members] of membersByGroupId) {
for (const source of plan.coLoadGroups || []) {
const group = {
groupId,
groupNo: members.find((entry) => entry.association.coLoadGroupNo)?.association.coLoadGroupNo || '同柜不可拆组',
lockedPlanId: members.find((entry) => entry.association.loadingPlanId)?.association.loadingPlanId || plan.id,
memberOrderCount: new Set(members.map((entry) => entry.row.orderId)).size,
inboundFragmentCount: members.length,
fullPackageCount: members.reduce((sum, entry) => sum + entry.row.packageCount, 0),
groupId: source.groupId,
groupNo: source.groupNo || '同柜不可拆组',
lockedPlanId: source.lockedPlanId || plan.id,
memberOrderCount: source.memberOrderCount,
inboundFragmentCount: source.inboundFragmentCount,
fullPackageCount: source.fullPackageCount,
} satisfies SmartLoadingPlanCoLoadGroup
for (const { row } of members) groupsByInboundId.set(row.inboundId, group)
for (const inboundId of source.memberInboundIds) groupsByInboundId.set(inboundId, group)
}
return groupsByInboundId
}
+11
View File
@@ -302,6 +302,8 @@ export interface SmartLoadingWorkbenchContainer {
channelCode: string
channelName: string
containerType: string
capacityCbm: number | null
maxWeightKg: number | null
planId: string | null
planNo: string | null
planStatus: Exclude<ContainerPlanStatus, 'COMPLETED'>
@@ -358,5 +360,14 @@ export interface SmartLoadingPlan {
pdaTaskId: string | null
pdaTaskStatus: 'LOADING' | 'COMPLETED' | null
pdaTaskVersion: number | null
coLoadGroups: Array<{
groupId: string
groupNo: string
lockedPlanId: string | null
memberOrderCount: number
inboundFragmentCount: number
fullPackageCount: number
memberInboundIds: string[]
}>
items: SmartLoadingItem[]
}