This commit is contained in:
2026-08-31 10:48:23 +08:00
parent 0b9d328bdf
commit 50c3a7e1ba
13 changed files with 343 additions and 184 deletions
@@ -17,7 +17,6 @@ import {
removeSmartLoadingGroup,
removeSmartLoadingItem,
} from '../api/platformContainerLoading'
import { batchQueryPlatformCoLoadGroups } from '@/modules/unified-orders'
const { routerPush } = vi.hoisted(() => ({ routerPush: vi.fn() }))
@@ -31,9 +30,6 @@ vi.mock('@/core/access/accessPreview', () => ({
vi.mock('@/modules/iam', () => ({
listPlatformTenants: vi.fn().mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 }),
}))
vi.mock('@/modules/unified-orders', () => ({
batchQueryPlatformCoLoadGroups: vi.fn().mockResolvedValue([]),
}))
vi.mock('../api/platformContainerLoading', () => ({
listContainers: vi.fn(),
getSmartLoadingPlanByBooking: vi.fn(),
@@ -47,6 +43,7 @@ vi.mock('../api/platformContainerLoading', () => ({
removeSmartLoadingGroup: vi.fn(),
pushSmartLoadingPreload: vi.fn(),
}))
vi.mock('../smartLoadingPlanGroups', () => ({ loadSmartLoadingPlanCoLoadGroups: vi.fn().mockResolvedValue(new Map()) }))
const container = {
id: '9007199254740993', planId: '9007199254740994', bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1',
@@ -70,7 +67,6 @@ describe('GlobalSmartLoadingView', () => {
vi.clearAllMocks()
vi.mocked(listContainers).mockResolvedValue({ records: [container], page: 1, size: 100, total: 1, pages: 1 })
vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue(plan)
vi.mocked(batchQueryPlatformCoLoadGroups).mockResolvedValue([])
vi.mocked(listSmartLoadingCandidates).mockResolvedValue([{ accountId: '1', inboundId: '5', orderId: '7', orderNo: 'ORD-2', inboundCode: 'IN-2', warehouseId: '8', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', customerName: '另一企业', goodsSummary: '鞋类', availablePackageCount: 12, unitGrossWeightKg: 10, unitNetWeightKg: 9, averageUnitVolumeCbm: 0.2, availableVolumeCbm: 2.4, coLoadGroupId: null, groupNo: null, memberCount: null, groupTotalBoxes: null, locked: null, blockReason: null }])
})
@@ -244,36 +240,6 @@ describe('GlobalSmartLoadingView', () => {
expect(assignSmartLoadingGroup).not.toHaveBeenCalled()
})
it('aggregates assigned members into one locked group row and removes the group once', async () => {
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', lockedPlanId: '99', version: 1,
members: items.map((item) => ({ tenantId: item.accountId, orderId: item.orderId, inboundId: item.inboundId, inboundCode: item.inboundCode, 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([...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 票 · 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') === 13)).toBe(false)
const removeButton = wrapper.findAll('button').find((button) => button.text().includes('移除整组'))!
await removeButton.trigger('click')
await flushPromises()
expect(removeSmartLoadingGroup).toHaveBeenCalledWith(plan.id, group.groupId, plan.version)
expect(removeSmartLoadingItem).not.toHaveBeenCalled()
})
it('keeps the package count editor inside its table cell without overflow ellipsis', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/GlobalSmartLoadingView.vue'), 'utf8')
expect(source).toContain('<div v-else class="package-count-editor"><el-input-number')
@@ -5,7 +5,6 @@ import { Plus, RefreshCw, Save, Sparkles, Trash2, Upload } from '@lucide/vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { batchQueryPlatformCoLoadGroups, type CoLoadGroup } from '@/modules/unified-orders'
import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
@@ -32,6 +31,7 @@ import type {
SmartLoadingPlan,
} from '../types'
import { groupSmartLoadingItems, type SmartLoadingInboundItem } from '../smartLoadingItems'
import { loadSmartLoadingPlanCoLoadGroups, type SmartLoadingPlanCoLoadGroup } from '../smartLoadingPlanGroups'
const props = withDefaults(defineProps<{ embedded?: boolean }>(), { embedded: false })
@@ -55,7 +55,7 @@ const assignOpen = ref(false)
const selectedCandidate = ref<SmartLoadingCandidateRow | null>(null)
const assignForm = reactive({ packageCount: 1 })
const quantityDrafts = reactive<Record<string, number>>({})
const planGroupsByInboundId = ref(new Map<string, CoLoadGroup>())
const planGroupsByInboundId = ref(new Map<string, SmartLoadingPlanCoLoadGroup>())
const query = ref<QueryModel>({ customerId: '', inboundCode: '' })
const appliedQuery = ref<QueryModel>({ ...query.value })
let selectionRequest = 0
@@ -95,10 +95,10 @@ function memberOrderSummaries<T extends { orderId: string; orderNo?: string | nu
}
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} 个入仓片段`
function groupMemberLabel(group: SmartLoadingPlanCoLoadGroup) {
return group.inboundFragmentCount === group.memberOrderCount
? `${group.memberOrderCount} 票`
: `${group.memberOrderCount} 票 · ${group.inboundFragmentCount} 个入仓片段`
}
const displayedCandidates = computed<SmartLoadingCandidateRow[]>(() => {
const rows: SmartLoadingCandidateRow[] = []
@@ -128,7 +128,7 @@ const displayedCandidates = computed<SmartLoadingCandidateRow[]>(() => {
}
return rows
})
type SmartLoadingPlanRow = SmartLoadingInboundItem & { coLoadGroup: CoLoadGroup | null; memberInboundCodes: string[]; memberOrderSummaries: string[] }
type SmartLoadingPlanRow = SmartLoadingInboundItem & { coLoadGroup: SmartLoadingPlanCoLoadGroup | null; memberInboundCodes: string[]; memberOrderSummaries: string[] }
const displayedPlanItems = computed<SmartLoadingPlanRow[]>(() => {
const inboundRows = groupSmartLoadingItems(plan.value?.items || [])
const result: SmartLoadingPlanRow[] = []
@@ -261,18 +261,6 @@ function acceptPlan(next: SmartLoadingPlan, preserved: Record<string, number> =
if (syncContainerStatus) container.planStatus = next.status
}
}
async function loadPlanGroups(request = selectionRequest) {
planGroupsByInboundId.value = new Map()
const currentPlan = plan.value
const orderIds = [...new Set((currentPlan?.items || []).map((item) => item.orderId))]
if (!currentPlan || !orderIds.length || !canAssign.value) return
const groups = await batchQueryPlatformCoLoadGroups(orderIds)
if (request !== selectionRequest || plan.value?.id !== currentPlan.id) return
const byInbound = new Map<string, CoLoadGroup>()
for (const info of groups) for (const member of info.group.members) byInbound.set(member.inboundId, info.group)
planGroupsByInboundId.value = byInbound
syncDrafts(currentPlan)
}
function clearSelectionData() {
plan.value = null
candidates.value = []
@@ -303,7 +291,8 @@ async function loadSelectedData(preserved: Record<string, number> = {}) {
candidates.value = candidateResult
if (planResult.value) {
acceptPlan(planResult.value, preserved, false)
await loadPlanGroups(request)
planGroupsByInboundId.value = await loadSmartLoadingPlanCoLoadGroups(planResult.value)
syncDrafts(planResult.value, preserved)
}
else noDraft.value = planResult.missing
} catch (error) {
@@ -8,14 +8,13 @@ import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import SmartLoadingWorkbenchView from './SmartLoadingWorkbenchView.vue'
import { assignSmartLoadingGroup, assignSmartLoadingItem, getContainer, getSmartLoadingPlanByBooking, listSmartLoadingCandidates, recommendSmartLoading, removeSmartLoadingGroup } from '../api/platformContainerLoading'
import { batchQueryPlatformCoLoadGroups } from '@/modules/unified-orders'
vi.mock('../api/platformContainerLoading', () => ({
getContainer: vi.fn(), getSmartLoadingPlanByBooking: vi.fn(), listSmartLoadingCandidates: vi.fn(),
createSmartLoadingPlan: vi.fn(), assignSmartLoadingItem: vi.fn(), assignSmartLoadingGroup: vi.fn(), recommendSmartLoading: vi.fn(),
adjustSmartLoadingItem: vi.fn(), removeSmartLoadingItem: vi.fn(), removeSmartLoadingGroup: vi.fn(), pushSmartLoadingPreload: vi.fn(),
}))
vi.mock('@/modules/unified-orders', () => ({ batchQueryPlatformCoLoadGroups: vi.fn().mockResolvedValue([]) }))
vi.mock('../smartLoadingPlanGroups', () => ({ loadSmartLoadingPlanCoLoadGroups: vi.fn().mockResolvedValue(new Map()) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
const container = {
@@ -178,31 +177,4 @@ describe('SmartLoadingWorkbenchView', () => {
wrapper.unmount()
})
it('removes a locked group once instead of looping through member items', async () => {
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([...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, inboundCode: member.inboundCode, 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)
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/container-loading/:containerId/smart-loading', component: SmartLoadingWorkbenchView }] })
await router.push(`/container-loading/${container.id}/smart-loading`); await router.isReady()
const wrapper = mount(SmartLoadingWorkbenchView, { global: { plugins: [ElementPlus, router] } })
await flushPromises()
expect(wrapper.text()).toContain('CLG-9900 · 2 票 · 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))
})
})
@@ -4,7 +4,6 @@ import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft, ListChecks, Plus, RefreshCw, Save, Sparkles, Trash2, Upload } from '@lucide/vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { batchQueryPlatformCoLoadGroups, type CoLoadGroup } from '@/modules/unified-orders'
import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
@@ -18,6 +17,7 @@ import {
} from '../api/platformContainerLoading'
import type { ContainerBookingResource, SmartLoadingCandidate, SmartLoadingPlan } from '../types'
import { groupSmartLoadingItems, type SmartLoadingInboundItem } from '../smartLoadingItems'
import { loadSmartLoadingPlanCoLoadGroups, type SmartLoadingPlanCoLoadGroup } from '../smartLoadingPlanGroups'
const route = useRoute()
const router = useRouter()
@@ -35,7 +35,7 @@ const assignOpen = ref(false)
const selectedCandidate = ref<SmartLoadingCandidateRow | null>(null)
const assignForm = reactive({ packageCount: 1 })
const quantityDrafts = reactive<Record<string, number>>({})
const planGroupsByInboundId = ref(new Map<string, CoLoadGroup>())
const planGroupsByInboundId = ref(new Map<string, SmartLoadingPlanCoLoadGroup>())
const canCreate = computed(() => access.can('platform:firstleg:smart-loading:create'))
const canAssign = computed(() => access.can('platform:firstleg:smart-loading:assign'))
@@ -56,10 +56,10 @@ function memberOrderSummaries<T extends { orderId: string; orderNo?: string | nu
}
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} 个入仓片段`
function groupMemberLabel(group: SmartLoadingPlanCoLoadGroup) {
return group.inboundFragmentCount === group.memberOrderCount
? `${group.memberOrderCount} 票`
: `${group.memberOrderCount} 票 · ${group.inboundFragmentCount} 个入仓片段`
}
const displayedCandidates = computed<SmartLoadingCandidateRow[]>(() => {
const rows: SmartLoadingCandidateRow[] = []
@@ -89,7 +89,7 @@ const displayedCandidates = computed<SmartLoadingCandidateRow[]>(() => {
}
return rows
})
type SmartLoadingPlanRow = SmartLoadingInboundItem & { coLoadGroup: CoLoadGroup | null; memberInboundCodes: string[]; memberOrderSummaries: string[] }
type SmartLoadingPlanRow = SmartLoadingInboundItem & { coLoadGroup: SmartLoadingPlanCoLoadGroup | null; memberInboundCodes: string[]; memberOrderSummaries: string[] }
const displayedPlanItems = computed<SmartLoadingPlanRow[]>(() => {
const inboundRows = groupSmartLoadingItems(plan.value?.items || [])
const result: SmartLoadingPlanRow[] = []
@@ -187,15 +187,10 @@ function syncDrafts(preserved: Record<string, number> = {}) {
for (const item of displayedPlanItems.value) quantityDrafts[item.id] = preserved[item.id] ?? item.packageCount
}
function acceptPlan(next: SmartLoadingPlan, preserved: Record<string, number> = {}) { plan.value = next; noDraft.value = false; syncDrafts(preserved) }
async function loadPlanGroups() {
planGroupsByInboundId.value = new Map()
const orderIds = [...new Set((plan.value?.items || []).map((item) => item.orderId))]
if (!orderIds.length || !canAssign.value) return
const groups = await batchQueryPlatformCoLoadGroups(orderIds)
const byInbound = new Map<string, CoLoadGroup>()
for (const info of groups) for (const member of info.group.members) byInbound.set(member.inboundId, info.group)
planGroupsByInboundId.value = byInbound
syncDrafts()
async function acceptPlanWithGroups(next: SmartLoadingPlan, preserved: Record<string, number> = {}) {
acceptPlan(next, preserved)
planGroupsByInboundId.value = await loadSmartLoadingPlanCoLoadGroups(next)
syncDrafts(preserved)
}
async function load() {
@@ -208,7 +203,7 @@ async function load() {
candidates.value = []
return
}
try { acceptPlan(await getSmartLoadingPlanByBooking(containerId.value)); await loadPlanGroups() }
try { await acceptPlanWithGroups(await getSmartLoadingPlanByBooking(containerId.value)) }
catch (error) {
const detail = getApiErrorDetails(error)
if (detail.code === 'A0404') { plan.value = null; noDraft.value = true }
@@ -230,7 +225,7 @@ function openOperationalList() {
async function createPlan() {
mutating.value = true
mutationError.value = null
try { acceptPlan(await createSmartLoadingPlan(containerId.value, null)); ElMessage.success('排柜计划已创建') }
try { await acceptPlanWithGroups(await createSmartLoadingPlan(containerId.value, null)); ElMessage.success('排柜计划已创建') }
catch (error) { await handleMutationError(error, '创建排柜计划失败') }
finally { mutating.value = false }
}
@@ -251,8 +246,7 @@ async function assign() {
const next = selected.coLoadGroupId
? await assignSmartLoadingGroup(plan.value.id, selected.inboundId, plan.value.version)
: await assignSmartLoadingItem(plan.value.id, { inboundId: selected.inboundId, packageCount: assignForm.packageCount, expectedVersion: plan.value.version })
acceptPlan(next)
await loadPlanGroups()
await acceptPlanWithGroups(next)
assignOpen.value = false
ElMessage.success(selected.coLoadGroupId ? `同柜不可拆组 ${selected.groupNo} 已整组分配` : '货物已分配')
} catch (error) { await handleMutationError(error, '分配失败') }
@@ -269,13 +263,13 @@ async function recommend() {
mutationError.value = null
try {
try {
acceptPlan(await recommendSmartLoading(plan.value.id, plan.value.version))
await acceptPlanWithGroups(await recommendSmartLoading(plan.value.id, plan.value.version))
} catch (error) {
const details = getApiErrorDetails(error, '智能推荐失败')
if (details.code !== 'A0409' || !/(版本|并发|已被修改|已变化)/.test(details.message)) throw error
await load()
if (!plan.value) throw error
acceptPlan(await recommendSmartLoading(plan.value.id, plan.value.version))
await acceptPlanWithGroups(await recommendSmartLoading(plan.value.id, plan.value.version))
}
ElMessage.success('智能建议已生成,请人工核对后再推送')
}
@@ -289,7 +283,7 @@ async function adjust(source: object) {
if (!Number.isInteger(count) || count < item.minPackageCount) { ElMessage.warning(`分配箱数不能低于已拣下限 ${item.minPackageCount} 箱`); return }
mutating.value = true
mutationError.value = null
try { acceptPlan(await adjustSmartLoadingItem(plan.value.id, item.id, count, plan.value.version, item.version)); ElMessage.success('箱数已调整') }
try { await acceptPlanWithGroups(await adjustSmartLoadingItem(plan.value.id, item.id, count, plan.value.version, item.version)); ElMessage.success('箱数已调整') }
catch (error) { await handleMutationError(error, '调整失败') }
finally { mutating.value = false }
}
@@ -306,7 +300,7 @@ async function remove(source: object) {
mutationError.value = null
try {
if (row.coLoadGroup) {
acceptPlan(await removeSmartLoadingGroup(plan.value!.id, row.coLoadGroup.groupId, plan.value!.version))
await acceptPlanWithGroups(await removeSmartLoadingGroup(plan.value!.id, row.coLoadGroup.groupId, plan.value!.version))
await load()
ElMessage.success(`同柜不可拆组 ${row.coLoadGroup.groupNo} 已整组移除`)
return
@@ -316,7 +310,7 @@ async function remove(source: object) {
const currentItem = current.items.find((value) => value.id === original.id)
if (!currentItem) continue
current = await removeSmartLoadingItem(current.id, currentItem.id, current.version, currentItem.version)
acceptPlan(current)
await acceptPlanWithGroups(current)
}
await load()
ElMessage.success('该入仓码已从装柜方案移除')
@@ -331,7 +325,7 @@ async function pushPreload() {
if (!confirmed || !plan.value) return
mutating.value = true
mutationError.value = null
try { acceptPlan(await pushSmartLoadingPreload(plan.value.id, plan.value.version)); ElMessage.success('预装清单已生成,请继续完成集装箱号和预装确认') }
try { await acceptPlanWithGroups(await pushSmartLoadingPreload(plan.value.id, plan.value.version)); ElMessage.success('预装清单已生成,请继续完成集装箱号和预装确认') }
catch (error) { await handleMutationError(error, '推送失败') }
finally { mutating.value = false }
}
@@ -0,0 +1,36 @@
import { describe, expect, it, vi } from 'vitest'
import { loadSmartLoadingPlanCoLoadGroups } from './smartLoadingPlanGroups'
import type { SmartLoadingPlan } from './types'
const item = {
id: '701', loadingPlanId: '99', accountId: '1', inboundId: '201', inboundDetailId: '801', orderId: '301',
orderNo: 'ORD-1', inboundCode: 'IN-201', customerName: '客户一', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9',
skuCode: 'SKU-1', productNameZh: '货物一', packageCount: 5, productQuantity: 50, grossWeightKg: 50, volumeCbm: 1,
loadSequence: 1, version: 1, pickedPackageCount: 0, minPackageCount: 1, canAdjust: true, canRemove: true,
}
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,
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)
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: [] }))
expect(result.size).toBe(0)
})
})
@@ -0,0 +1,47 @@
import { getLoadingAssociationSummaryByInboundId, type LoadingAssociationSummary } from '@/shared/api/inboundCodeRelations'
import { groupSmartLoadingItems } from './smartLoadingItems'
import type { SmartLoadingPlan } from './types'
export interface SmartLoadingPlanCoLoadGroup {
groupId: string
groupNo: string
lockedPlanId: string
memberOrderCount: number
inboundFragmentCount: number
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])
}
const groupsByInboundId = new Map<string, SmartLoadingPlanCoLoadGroup>()
for (const [groupId, members] of membersByGroupId) {
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),
} satisfies SmartLoadingPlanCoLoadGroup
for (const { row } of members) groupsByInboundId.set(row.inboundId, group)
}
return groupsByInboundId
}
@@ -1,14 +1,14 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import { batchQueryPlatformCoLoadGroups, cancelPlatformCoLoadGroup, createPlatformCoLoadGroup } from './coLoadGroups'
import { cancelPlatformCoLoadGroup, createPlatformCoLoadGroup, getPlatformCoLoadGroupByInboundCode } from './coLoadGroups'
const group = {
groupId: 2093000000000000001n, groupNo: 'CLG-2093000000000000001', status: 'ACTIVE',
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, inboundCode: 'NYEXX2608310001', fullPackageCount: 8 },
{ tenantId: 2093000000000000007n, orderId: 2093000000000000008n, inboundId: 2093000000000000009n, inboundCode: 'NYEXX2608310002', fullPackageCount: 10 },
{ tenantId: 2093000000000000004n, orderId: 2093000000000000005n, inboundId: 2093000000000000006n, fullPackageCount: 8 },
{ tenantId: 2093000000000000007n, orderId: 2093000000000000008n, inboundId: 2093000000000000009n, fullPackageCount: 10 },
],
}
@@ -23,7 +23,6 @@ describe('co-load group API', () => {
expect(result.groupId).toBe('2093000000000000001')
expect(result.members.map((member) => member.orderId)).toEqual(orderIds)
expect(result.members.map((member) => member.tenantId)).toEqual(['2093000000000000004', '2093000000000000007'])
expect(result.members.map((member) => member.inboundCode)).toEqual(['NYEXX2608310001', 'NYEXX2608310002'])
})
it('cancels only the whole group with optimistic version and an optional reason', async () => {
@@ -36,46 +35,26 @@ 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, 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', 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, inboundCode: 'NYEXX2608310001-B', 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', inboundCode: 'NYEXX2608310001', fullPackageCount: 8 }),
expect.objectContaining({ orderId: '2093000000000000005', inboundId: '2093000000000000012', inboundCode: 'NYEXX2608310001-B', fullPackageCount: 3 }),
expect.objectContaining({ orderId: '2093000000000000008', inboundId: '2093000000000000009', inboundCode: 'NYEXX2608310002', fullPackageCount: 10 }),
])
it('loads the business-only group summary by inbound code without platform batch-query', async () => {
const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'ok', data: {
status: 'LOCKED', memberCount: '2', members: [{ inboundCode: ' NYEXX2608310001 ' }, { inboundCode: 'NYEXX2608310002' }],
} })
const post = vi.spyOn(http, 'post')
const result = await getPlatformCoLoadGroupByInboundCode('NYEXX2608310001')
expect(get).toHaveBeenCalledWith('/platform/v1/first-leg/coload-groups/by-inbound-code/NYEXX2608310001')
expect(result).toEqual({ status: 'LOCKED', memberCount: 2, members: [{ inboundCode: 'NYEXX2608310001' }, { inboundCode: 'NYEXX2608310002' }] })
expect(post).not.toHaveBeenCalled()
})
it('normalizes missing and blank member inbound codes without exposing technical ids as fallbacks', async () => {
const incompleteGroup = {
...group,
const incompleteSummary = {
status: 'ACTIVE', memberCount: 4,
members: [
{ ...group.members[0], inboundCode: undefined },
{ ...group.members[1], inboundCode: null },
{ ...group.members[1], inboundId: 2093000000000000012n, inboundCode: ' ' },
{ ...group.members[1], inboundId: 2093000000000000013n, inboundCode: ' NYEXX2608310003 ' },
{ inboundCode: undefined }, { inboundCode: null }, { inboundCode: ' ' }, { inboundCode: ' NYEXX2608310003 ' },
],
}
vi.spyOn(http, 'post').mockResolvedValue({ code: '200', message: 'ok', data: incompleteGroup })
const result = await createPlatformCoLoadGroup(['2093000000000000005', '2093000000000000008'])
vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'ok', data: incompleteSummary })
const result = await getPlatformCoLoadGroupByInboundCode('NYEXX2608310001')
expect(result.members.map((member) => member.inboundCode)).toEqual(['', '', '', 'NYEXX2608310003'])
expect(result.members.map((member) => member.inboundCode)).not.toContain('2093000000000000012')
})
@@ -1,9 +1,8 @@
import { flushPromises, shallowMount } from '@vue/test-utils'
import { ElMessageBox } from 'element-plus'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ApiResponseError } from '@/shared/api/http'
import { listPlatformUnifiedOrders } from '@/modules/unified-orders/api/unifiedOrders'
import { batchQueryPlatformCoLoadGroups, cancelPlatformCoLoadGroup, createPlatformCoLoadGroup } from '@/modules/unified-orders/api/coLoadGroups'
import { createPlatformCoLoadGroup, getPlatformCoLoadGroupByInboundCode } from '@/modules/unified-orders/api/coLoadGroups'
import UnifiedOrdersView from './UnifiedOrdersView.vue'
vi.mock('vue-router', () => ({ useRoute: () => ({ params: {}, query: {} }), useRouter: () => ({ push: vi.fn() }) }))
@@ -18,7 +17,7 @@ vi.mock('@/modules/unified-orders/api/unifiedOrders', () => ({
unifiedOrderStatusOptions: [{ value: 'WAIT_SPLIT', label: '待分柜' }],
}))
vi.mock('@/modules/unified-orders/api/coLoadGroups', () => ({
batchQueryPlatformCoLoadGroups: vi.fn(), cancelPlatformCoLoadGroup: vi.fn(), createPlatformCoLoadGroup: vi.fn(),
createPlatformCoLoadGroup: vi.fn(), getPlatformCoLoadGroupByInboundCode: vi.fn(),
}))
const rows = ['5001', '5002'].map((orderId, index) => ({
@@ -30,13 +29,8 @@ const rows = ['5001', '5002'].map((orderId, index) => ({
createdAt: null, updatedAt: null, expectedArrivalAt: null, feeReviewStatus: null, requestedPickupAt: null, totalPalletCount: null,
chargeableVolumeCbm: null, volumetricWeightKg: null, totalNetWeightKg: null, measurementCompletedAt: null, inboundCompletedAt: null,
expressCompany: null, expressTrackingNo: null, skuNos: null, totalQuantity: null, shipmentId: null, referenceId: null,
coLoadGroup: null,
}))
const group = {
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), inboundCode: String(row.inboundCode), fullPackageCount: 5 })),
createdAt: '2026-08-29T10:00:00', updatedAt: '2026-08-29T10:00:00',
}
function mountView() {
return shallowMount(UnifiedOrdersView, {
global: { directives: { loading: () => undefined }, stubs: {
@@ -58,7 +52,6 @@ describe('UnifiedOrdersView co-load group behavior', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(listPlatformUnifiedOrders).mockResolvedValue({ records: rows, page: 1, size: 20, total: 2, pages: 1 })
vi.mocked(batchQueryPlatformCoLoadGroups).mockResolvedValue([])
})
it('keeps both selected orders and backend tracing evidence when create validation fails', async () => {
@@ -86,24 +79,22 @@ describe('UnifiedOrdersView co-load group behavior', () => {
expect(wrapper.text()).not.toContain('ORD-2')
expect(wrapper.text()).not.toContain('5001')
expect(wrapper.text()).not.toContain('7000')
expect(wrapper.text()).not.toContain('履约详情')
expect(wrapper.text()).not.toContain('关联数据')
})
it('shows a business-only group summary and all server-provided inbound codes in the dialog', async () => {
const multiInboundGroup = {
...group,
fullPackageCount: 13,
members: [group.members[0], { ...group.members[0], inboundId: '7003', inboundCode: 'IN-1-B', 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 rowsWithGroup = rows.map((row) => ({ ...row, coLoadGroup: { status: 'ACTIVE', memberCount: 2, memberInboundCodes: ['IN-1', 'IN-1-B', 'IN-2'] } }))
vi.mocked(listPlatformUnifiedOrders).mockResolvedValue({ records: rowsWithGroup, page: 1, size: 20, total: 2, pages: 1 })
vi.mocked(getPlatformCoLoadGroupByInboundCode).mockResolvedValue({ status: 'ACTIVE', memberCount: 3, members: [{ inboundCode: 'IN-1' }, { inboundCode: 'IN-1-B' }, { inboundCode: 'IN-2' }] })
const wrapper = mountView()
await flushPromises()
expect(wrapper.text()).toContain('同柜组 · 2 票 · 未锁定')
expect(wrapper.text()).not.toContain('CLG-9001')
await wrapper.get('[aria-label="查看同柜组 · 2 票 · 未锁定成员"]').trigger('click')
await flushPromises()
expect(getPlatformCoLoadGroupByInboundCode).toHaveBeenCalledWith('IN-1')
expect(wrapper.text()).toContain('3 个成员')
expect(wrapper.text()).toContain('13 箱')
expect(wrapper.text()).toContain('IN-1')
expect(wrapper.text()).toContain('IN-1-B')
expect(wrapper.text()).toContain('IN-2')
@@ -111,10 +102,6 @@ describe('UnifiedOrdersView co-load group behavior', () => {
expect(wrapper.text()).not.toContain('ORD-2')
expect(wrapper.text()).not.toContain('7003')
expect(wrapper.text()).not.toContain('5001')
await wrapper.findAll('button').find((button) => button.text().includes('取消同柜组'))!.trigger('click')
await flushPromises()
expect(cancelPlatformCoLoadGroup).toHaveBeenCalledWith('9001', 0, '计划调整')
expect(ElMessageBox.prompt).toHaveBeenCalledWith(expect.stringContaining('不支持单独移除成员'), '取消同柜不可拆组', expect.any(Object))
})
it.each([
@@ -122,14 +109,13 @@ describe('UnifiedOrdersView co-load group behavior', () => {
['null', null],
['blank', ' '],
])('keeps the member dialog readable when a normalized inbound code is %s', async (_case, inboundCode) => {
const incompleteGroup = {
...group,
members: [{ ...group.members[0], inboundCode: inboundCode as unknown as string }, group.members[1]],
}
vi.mocked(batchQueryPlatformCoLoadGroups).mockResolvedValue(rows.map((row) => ({ orderId: row.orderId, group: incompleteGroup })))
const rowsWithGroup = rows.map((row) => ({ ...row, coLoadGroup: { status: 'ACTIVE', memberCount: 2, memberInboundCodes: ['IN-1', 'IN-2'] } }))
vi.mocked(listPlatformUnifiedOrders).mockResolvedValue({ records: rowsWithGroup, page: 1, size: 20, total: 2, pages: 1 })
vi.mocked(getPlatformCoLoadGroupByInboundCode).mockResolvedValue({ status: 'ACTIVE', memberCount: 2, members: [{ inboundCode: inboundCode as unknown as string }, { inboundCode: 'IN-2' }] })
const wrapper = mountView()
await flushPromises()
await wrapper.get('[aria-label="查看同柜组 · 2 票 · 未锁定成员"]').trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('同柜组成员入仓码尚未返回,请刷新后重试')
expect(wrapper.text()).not.toContain('CLG-9001')
expect(wrapper.text()).not.toContain('ORD-1')
@@ -0,0 +1,115 @@
import { flushPromises, shallowMount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { getLoadingAssociationSummary, getOverseasInboundCodeRelationship } from '@/shared/api/inboundCodeRelations'
import { getPlatformUnifiedOrder } from '@/modules/unified-orders/api/unifiedOrders'
import UnifiedOrdersView from './UnifiedOrdersView.vue'
vi.mock('vue-router', () => ({
useRoute: () => ({ params: { orderId: '5001' }, query: { tenantId: '6001' } }),
useRouter: () => ({ push: vi.fn() }),
}))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn().mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 }) }))
vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn().mockResolvedValue([]) }))
vi.mock('@/modules/inbounds', () => ({
downloadWeightImage: vi.fn(), getInboundMeasurement: vi.fn(),
InboundFeePanel: { template: '<div />' }, InboundMeasurementDialog: { template: '<div />' }, MeasurementEvidenceThumbnail: { template: '<div />' },
}))
vi.mock('@/modules/unified-orders/workflows', () => ({ UnifiedOrderWorkflowPanel: { template: '<div />' } }))
vi.mock('@/modules/unified-orders/api/coLoadGroups', () => ({
createPlatformCoLoadGroup: vi.fn(), getPlatformCoLoadGroupByInboundCode: vi.fn(),
}))
vi.mock('@/shared/api/inboundCodeRelations', () => ({
getLoadingAssociationSummary: vi.fn(), getOverseasInboundCodeRelationship: vi.fn(),
}))
vi.mock('@/modules/unified-orders/api/unifiedOrders', () => ({
addPlatformUnifiedOrderNote: vi.fn(), approvePlatformUnifiedOrder: vi.fn(), batchApprovePlatformUnifiedOrders: vi.fn(),
getPlatformUnifiedOrder: vi.fn(), listPlatformUnifiedOrderFeeDetails: vi.fn(), listPlatformUnifiedOrders: vi.fn(),
unifiedOrderStatusOptions: [],
}))
const detail = {
summary: {
orderId: '5001', tenantId: '6001', orderNo: 'ORD-HIDDEN', mainStatus: 'WAIT_SPLIT', nextAction: null, relation: null,
internalOrderStatus: 'WAIT_SPLIT', customerId: '6001', customerName: '测试客户', channelName: '纽约渠道',
receivingWarehouseId: '8', receivingWarehouseName: '义乌仓', destinationWarehouseId: '9', destinationWarehouseName: 'ONT8',
inboundId: '7001', inboundVersion: 1, inboundCode: 'NYEXX2608310001', expectedShipDate: null, totalPackageCount: 2,
locationCodes: null, actualGrossWeightKg: null, actualVolumeCbm: null, measurement: null, preload: null, loading: null, delivery: null, transport: null,
coLoadGroup: null, createdAt: null, updatedAt: null, expectedArrivalAt: null, feeReviewStatus: null, requestedPickupAt: null,
totalPalletCount: null, chargeableVolumeCbm: null, volumetricWeightKg: null, totalNetWeightKg: null,
measurementCompletedAt: null, inboundCompletedAt: null, expressCompany: null, expressTrackingNo: null, skuNos: null,
totalQuantity: null, shipmentId: null, referenceId: null,
},
order: { order: null, cargoes: [], parties: [], internalNotes: [], statusHistory: [] },
timeline: [],
}
function mountDetail() {
return shallowMount(UnifiedOrdersView, {
global: {
directives: { loading: () => undefined },
stubs: {
AdminPage: { template: '<main><slot /></main>' },
DetailPageLayout: { template: '<section><slot /></section>' },
OrderFulfillmentTreePanel: { template: '<div data-testid="fulfillment-tree" />' },
LoadErrorState: { props: ['title', 'message'], template: '<div>{{ title }} {{ message }}<slot name="details" /></div>' },
ApiErrorMeta: true,
StatusBadge: true,
'el-button': { template: '<button @click="$emit(\'click\')"><slot /></button>' },
},
},
})
}
describe('UnifiedOrdersView downstream fulfillment detail', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(getPlatformUnifiedOrder).mockResolvedValue(detail as never)
vi.mocked(getLoadingAssociationSummary).mockResolvedValue({
inboundId: '7001', inboundCode: 'NYEXX2608310001', loadingPlanId: null, loadingPlanNo: null,
containerBookingId: null, cabinetNo: null, containerNo: null, loadingStatus: null, taskId: null,
pdaTaskStatus: null, coLoadGroupId: null, coLoadGroupNo: null, items: [],
})
vi.mocked(getOverseasInboundCodeRelationship).mockResolvedValue({
inboundCode: 'NYEXX2608310001', found: false, customer: null, overseasWarehouse: null,
overseasReceivingStatus: null, taillegOrders: [], inboundPlans: [], palletCodes: [], inventory: [],
})
})
it('shows a compact empty state in order detail when no downstream relationship exists', async () => {
const wrapper = mountDetail()
await flushPromises()
await wrapper.findAll('.detail-tabs button').find((button) => button.text().includes('拆分履约'))!.trigger('click')
await flushPromises()
expect(getLoadingAssociationSummary).toHaveBeenCalledWith('NYEXX2608310001')
expect(getOverseasInboundCodeRelationship).toHaveBeenCalledWith('NYEXX2608310001')
expect(wrapper.text()).toContain('暂无后续履约数据')
expect(wrapper.text()).not.toContain('新增关联')
})
it('renders actual loading and overseas business identifiers inside order detail', async () => {
vi.mocked(getLoadingAssociationSummary).mockResolvedValue({
inboundId: '7001', inboundCode: 'NYEXX2608310001', loadingPlanId: '9001', loadingPlanNo: 'LP-260831-001',
containerBookingId: '9002', cabinetNo: 'CAB-260831-001', containerNo: 'MSCU1234567', loadingStatus: 'LOADING',
taskId: '9003', pdaTaskStatus: 'IN_PROGRESS', coLoadGroupId: null, coLoadGroupNo: null, items: [],
})
vi.mocked(getOverseasInboundCodeRelationship).mockResolvedValue({
inboundCode: 'NYEXX2608310001', found: true, customer: null,
overseasWarehouse: { warehouseId: '9004', warehouseCode: 'LAX', warehouseName: '洛杉矶海外仓' },
overseasReceivingStatus: 'RECEIVING', taillegOrders: [{ orderNo: 'LL-260831-001' }],
inboundPlans: [{ planNo: 'IP-260831-001' }], palletCodes: ['PLT-LAX-001'],
inventory: [{ skuCode: 'SKU-001', packageCount: 2 }],
})
const wrapper = mountDetail()
await flushPromises()
await wrapper.findAll('.detail-tabs button').find((button) => button.text().includes('拆分履约'))!.trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('LP-260831-001')
expect(wrapper.text()).toContain('MSCU1234567')
expect(wrapper.text()).toContain('LL-260831-001')
expect(wrapper.text()).toContain('IP-260831-001')
expect(wrapper.text()).toContain('PLT-LAX-001')
expect(wrapper.text()).not.toContain('9001')
expect(wrapper.text()).not.toContain('9004')
})
})
@@ -13,9 +13,9 @@ vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn().mock
vi.mock('@/modules/inbounds', () => ({ downloadWeightImage: vi.fn(), getInboundMeasurement: vi.fn() }))
vi.mock('@/modules/unified-orders/workflows', () => ({ UnifiedOrderWorkflowPanel: { template: '<div />' } }))
vi.mock('@/modules/unified-orders/api/coLoadGroups', () => ({
batchQueryPlatformCoLoadGroups: vi.fn().mockResolvedValue([]),
cancelPlatformCoLoadGroup: vi.fn(),
createPlatformCoLoadGroup: vi.fn(),
getPlatformCoLoadGroupByInboundCode: vi.fn(),
}))
vi.mock('@/modules/unified-orders/api/unifiedOrders', () => ({
addPlatformUnifiedOrderNote: vi.fn(),
@@ -183,10 +183,10 @@ describe('UnifiedOrdersView contract', () => {
expect(source).toContain("inboundCode || '待生成入仓码'")
expect(source).toContain('class="coload-tag-trigger"')
expect(source).toContain('coLoadGroupSummary(coLoadGroup(row as UnifiedOrderResource)!)')
expect(source).not.toContain('coLoadDetailGroup.groupNo')
expect(source).not.toContain('coLoadDetailGroup')
expect(source).not.toContain('<span v-else>—</span></template>\n <template #orderStatus')
expect(source).toContain('v-for="member in coLoadDetailMembers"')
expect(source).toContain('<th>入仓码</th><th>箱数</th>')
expect(source).toContain('<thead><tr><th>入仓码</th></tr></thead>')
expect(source).toContain('{{ member.inboundCode }}')
expect(source).toContain('同柜组成员入仓码尚未返回,请刷新后重试')
expect(source).toContain('coLoadDetailHasMissingInboundCodes')
@@ -196,11 +196,12 @@ describe('UnifiedOrdersView contract', () => {
expect(source).not.toContain('客户 ID')
})
it('names the cross-domain relationship entry as fulfillment details', () => {
expect(source).toContain('>履约详情</el-button>')
expect(source).toContain('aria-label="查看该入仓码的订单、装柜、尾程与库存关系"')
expect(source).toContain('title="查看该入仓码的订单、装柜、尾程与库存关系"')
it('does not expose a generic relationship probe in list rows', () => {
expect(source).not.toContain('>履约详情</el-button>')
expect(source).not.toContain('>关联数据</el-button>')
expect(source).not.toContain('openInboundRelationship')
expect(source).not.toContain('inboundRelationshipDialogOpen')
expect(source).not.toContain('<InboundCodeRelationshipDialog')
})
it('keeps editable query values separate from the applied list request', () => {
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch, type Component } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ArrowLeft, Box, ChevronDown, ChevronUp, CircleCheck, Eye, GitBranch, History, ListChecks, MoreHorizontal, PackageOpen, Ruler, SlidersHorizontal, StickyNote, WalletCards } from '@lucide/vue'
import { ArrowLeft, Box, ChevronDown, ChevronUp, CircleCheck, Eye, GitBranch, History, Link2, ListChecks, MoreHorizontal, PackageOpen, Ruler, SlidersHorizontal, StickyNote, WalletCards } from '@lucide/vue'
import { useRoute, useRouter } from 'vue-router'
import {
getPlatformUnifiedOrder,
@@ -23,6 +23,7 @@ import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { listAllPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { getLoadingAssociationSummary, getOverseasInboundCodeRelationship, type LoadingAssociationSummary, type OverseasInboundCodeRelationship } from '@/shared/api/inboundCodeRelations'
import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
@@ -153,10 +154,16 @@ const cargoImagePreviewUrls = new Set<string>()
const measurementDialogOpen = ref(false)
const smartLoadingOpen = ref(false)
const measurementOrder = ref<UnifiedOrderResource | null>(null)
const loadingAssociation = ref<LoadingAssociationSummary | null>(null)
const overseasRelationship = ref<OverseasInboundCodeRelationship | null>(null)
const loadingAssociationError = ref<ApiErrorDetails | null>(null)
const overseasRelationshipError = ref<ApiErrorDetails | null>(null)
const subsequentFulfillmentLoading = ref(false)
let detailLoadingKey = ''
let detailRequestSerial = 0
let measurementRequestSerial = 0
let measurementPreviewRequestSerial = 0
let subsequentFulfillmentRequestSerial = 0
const showAdvancedFilters = ref(false)
const filters = ref<FilterModel>(emptyFilters())
const appliedFilters = ref<FilterModel>(emptyFilters())
@@ -588,6 +595,36 @@ async function loadMeasurementDetails() {
function selectDetailTab(key: string) {
activeDetailTab.value = key
if (key === 'measurement') void loadMeasurementDetails()
if (key === 'fulfillment') void loadSubsequentFulfillment()
}
const hasLoadingAssociation = computed(() => Boolean(loadingAssociation.value
&& (loadingAssociation.value.loadingPlanNo || loadingAssociation.value.containerNo || loadingAssociation.value.cabinetNo
|| loadingAssociation.value.loadingStatus || loadingAssociation.value.pdaTaskStatus)))
const hasOverseasRelationship = computed(() => Boolean(overseasRelationship.value?.found
&& (overseasRelationship.value.overseasWarehouse || overseasRelationship.value.overseasReceivingStatus
|| overseasRelationship.value.taillegOrders.length || overseasRelationship.value.inboundPlans.length
|| overseasRelationship.value.palletCodes.length || overseasRelationship.value.inventory.length)))
const hasSubsequentFulfillment = computed(() => hasLoadingAssociation.value || hasOverseasRelationship.value)
async function loadSubsequentFulfillment() {
const inboundCode = detail.value?.summary.inboundCode?.trim() || ''
const serial = ++subsequentFulfillmentRequestSerial
loadingAssociation.value = null
overseasRelationship.value = null
loadingAssociationError.value = null
overseasRelationshipError.value = null
if (!inboundCode) return
subsequentFulfillmentLoading.value = true
const tasks: Promise<void>[] = []
if (can('platform:firstleg:smart-loading:list')) tasks.push(getLoadingAssociationSummary(inboundCode)
.then((value) => { if (serial === subsequentFulfillmentRequestSerial) loadingAssociation.value = value })
.catch((error) => { if (serial === subsequentFulfillmentRequestSerial) loadingAssociationError.value = getApiErrorDetails(error, '装柜与PDA履约数据加载失败') }))
if (can('platform:overseas-warehouse:inventory:list')) tasks.push(getOverseasInboundCodeRelationship(inboundCode)
.then((value) => { if (serial === subsequentFulfillmentRequestSerial) overseasRelationship.value = value })
.catch((error) => { if (serial === subsequentFulfillmentRequestSerial) overseasRelationshipError.value = getApiErrorDetails(error, '尾程与库存履约数据加载失败') }))
await Promise.all(tasks)
if (serial === subsequentFulfillmentRequestSerial) subsequentFulfillmentLoading.value = false
}
function toggleMeasurementSection(section: keyof typeof measurementCollapsed) {
@@ -989,6 +1026,7 @@ watch(detailRouteKey, (value) => {
void loadDetail()
} else {
detailRequestSerial += 1
subsequentFulfillmentRequestSerial += 1
detail.value = null
detailError.value = null
detailLoading.value = false
@@ -1170,6 +1208,26 @@ onBeforeUnmount(() => {
<template v-else-if="activeDetailTab === 'fulfillment'">
<div class="panel-title"><div><h2>拆分履约</h2><p>根单、已完成分片与当前余量</p></div><ListChecks :size="19" /></div>
<OrderFulfillmentTreePanel :tenant-id="String(detail.summary.tenantId)" :order-id="String(detail.summary.orderId)" :inbound-code="detail.summary.inboundCode" />
<section v-loading="subsequentFulfillmentLoading" class="subsequent-fulfillment" aria-label="后续履约数据">
<h3>后续履约数据</h3>
<LoadErrorState v-if="loadingAssociationError" title="装柜与PDA履约数据加载失败" :message="loadingAssociationError.message" @retry="loadSubsequentFulfillment"><template #details><ApiErrorMeta :details="loadingAssociationError" /></template></LoadErrorState>
<LoadErrorState v-if="overseasRelationshipError" title="尾程与库存履约数据加载失败" :message="overseasRelationshipError.message" @retry="loadSubsequentFulfillment"><template #details><ApiErrorMeta :details="overseasRelationshipError" /></template></LoadErrorState>
<dl v-if="hasLoadingAssociation" class="fulfillment-facts">
<div><dt>装柜计划</dt><dd>{{ loadingAssociation?.loadingPlanNo || '—' }}</dd></div>
<div><dt>柜号</dt><dd>{{ loadingAssociation?.containerNo || loadingAssociation?.cabinetNo || '—' }}</dd></div>
<div><dt>装柜状态</dt><dd>{{ loadingAssociation?.loadingStatus || '—' }}</dd></div>
<div><dt>PDA状态</dt><dd>{{ loadingAssociation?.pdaTaskStatus || '—' }}</dd></div>
</dl>
<dl v-if="hasOverseasRelationship" class="fulfillment-facts">
<div><dt>海外仓</dt><dd>{{ overseasRelationship?.overseasWarehouse?.warehouseName || '尚未分配海外仓' }}</dd></div>
<div><dt>海外收货状态</dt><dd>{{ overseasRelationship?.overseasReceivingStatus || '—' }}</dd></div>
<div class="wide"><dt>尾程订单</dt><dd>{{ overseasRelationship?.taillegOrders.map((item) => item.orderNo).join('、') || '—' }}</dd></div>
<div class="wide"><dt>入库计划</dt><dd>{{ overseasRelationship?.inboundPlans.map((item) => item.planNo).join('、') || '—' }}</dd></div>
<div class="wide"><dt>托盘码</dt><dd>{{ overseasRelationship?.palletCodes.join('、') || '—' }}</dd></div>
<div><dt>库存SKU</dt><dd>{{ overseasRelationship?.inventory.length || 0 }} 个</dd></div>
</dl>
<p v-if="!subsequentFulfillmentLoading && !loadingAssociationError && !overseasRelationshipError && !hasSubsequentFulfillment" class="fulfillment-empty">暂无后续履约数据</p>
</section>
</template>
<template v-else-if="activeDetailTab === 'workflow'">
@@ -1464,6 +1522,14 @@ onBeforeUnmount(() => {
.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; }
.subsequent-fulfillment { min-height: 72px; margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--cc-border); }
.subsequent-fulfillment h3 { margin: 0 0 10px; font-size: 15px; }
.fulfillment-facts { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); margin: 0 0 10px; border: 1px solid var(--cc-border); border-radius: 6px; overflow: hidden; }
.fulfillment-facts > div { min-width: 0; padding: 9px 10px; border-right: 1px solid var(--cc-border); background: #fff; }
.fulfillment-facts > div.wide { grid-column: span 2; }
.fulfillment-facts dt { color: var(--cc-muted); font-size: 12px; }
.fulfillment-facts dd { min-width: 0; margin: 4px 0 0; overflow-wrap: anywhere; font-size: 13px; }
.fulfillment-empty { margin: 0; padding: 12px; border: 1px dashed var(--cc-border); border-radius: 6px; color: var(--cc-muted); text-align: center; }
.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; }
+8
View File
@@ -59,6 +59,14 @@ export async function getLoadingAssociationSummary(inboundCode: string) {
return unwrapApiResponse(response)
}
export async function getLoadingAssociationSummaryByInboundId(inboundId: string) {
const response = await http.get<ApiEnvelope<LoadingAssociationSummary>, ApiEnvelope<LoadingAssociationSummary>>(
'/platform/v1/first-leg/loading-associations/summary',
{ params: { inboundId } },
)
return unwrapApiResponse(response)
}
export async function getOverseasInboundCodeRelationship(inboundCode: string) {
const response = await overseasHttp.get<ApiEnvelope<OverseasInboundCodeRelationship>, ApiEnvelope<OverseasInboundCodeRelationship>>(
`/platform/v1/last-leg/inventory/inbound-code-relationships/${encodeURIComponent(inboundCode)}`,