perf: defer container workbench data loading

This commit is contained in:
2026-09-08 20:55:30 +08:00
parent 8910e88563
commit f25839c4b5
5 changed files with 187 additions and 57 deletions
@@ -4,6 +4,9 @@ import { describe, expect, it } from 'vitest'
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainerDetailView.vue'), 'utf8')
const overviewSource = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/components/ContainerOverviewPanel.vue'), 'utf8')
const routeSource = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/routes.ts'), 'utf8')
const loadingRouteSource = readFileSync(resolve(process.cwd(), 'src/modules/loading-lists/routes.ts'), 'utf8')
const navigationSource = readFileSync(resolve(process.cwd(), 'src/core/navigation/adminNavigation.ts'), 'utf8')
describe('ContainerDetailView customs integration', () => {
it('renders the requested detail sections in the confirmed business order', () => {
@@ -33,6 +36,18 @@ describe('ContainerDetailView customs integration', () => {
expect(source).toContain(':sections="[\'pda\']"')
})
it('keeps the confirmed loading list inside the domestic container detail only', () => {
expect(routeSource).toContain("name: 'domestic-container-detail'")
expect(routeSource).toContain("name: 'overseas-container-detail'")
expect(loadingRouteSource).not.toContain("name: 'loading-lists'")
expect(navigationSource).not.toContain("title: '装柜清单'")
expect(source).toContain('<el-tabs v-if="isDomestic"')
expect(source).toContain("v-if=\"isDomestic && activeTab === 'loading-list'\"")
expect(source).toContain("loadingDetail.value = await getConfirmedLoadingList(aggregate.loadingPlanId, resource.warehouseId)")
expect(source).toContain("getContainer(containerId.value, props.workbench), getContainerOverview(containerId.value)")
expect(source).toContain(':can-edit="false"')
})
it('mounts the new container customs panel only after loading completes', () => {
expect(source).toContain("detail.planStatus === 'COMPLETED' && overview?.loadingPlanId && canListCustoms")
expect(source).toContain('<ContainerCustomsPanel')
@@ -75,16 +75,56 @@ describe('ContainersView', () => {
expect(wrapper.text()).not.toContain('报空预警')
})
it('keeps the cross-warehouse list usable when warehouse form options fail', async () => {
it('defers form options until their dialogs open and caches successful loads', async () => {
const router = createTestRouter()
await router.push('/'); await router.isReady()
const wrapper = mount(ContainersView, { attachTo: document.body, global: { plugins: [ElementPlus, router] } })
await flushPromises()
expect(listAllPlatformWarehouses).not.toHaveBeenCalled()
expect(listPlatformPartners).not.toHaveBeenCalled()
expect(listSuppliers).not.toHaveBeenCalled()
expect(listOceanBookings).not.toHaveBeenCalled()
await wrapper.findAll('button').find((button) => button.text().includes('新增模拟柜'))!.trigger('click')
await flushPromises()
expect(listAllPlatformWarehouses).toHaveBeenCalledTimes(1)
expect(listPlatformPartners).not.toHaveBeenCalled()
expect(listSuppliers).not.toHaveBeenCalled()
expect(listOceanBookings).not.toHaveBeenCalled()
wrapper.findAllComponents(AppDialog).find((dialog) => dialog.props('title') === '新增模拟柜')!.vm.$emit('update:modelValue', false)
await wrapper.findAll('button').find((button) => button.text().includes('新增模拟柜'))!.trigger('click')
await flushPromises()
expect(listAllPlatformWarehouses).toHaveBeenCalledTimes(1)
await wrapper.findAll('button').find((button) => button.text().includes('编辑运输信息'))!.trigger('click')
await flushPromises()
expect(listAllPlatformWarehouses).toHaveBeenCalledTimes(1)
expect(listPlatformPartners).toHaveBeenCalledTimes(1)
expect(listSuppliers).toHaveBeenCalledTimes(1)
expect(listOceanBookings).toHaveBeenCalledTimes(1)
wrapper.findAllComponents(AppDialog).find((dialog) => dialog.props('title') === '编辑')!.vm.$emit('update:modelValue', false)
await wrapper.findAll('button').find((button) => button.text().includes('编辑运输信息'))!.trigger('click')
await flushPromises()
expect(listPlatformPartners).toHaveBeenCalledTimes(1)
expect(listSuppliers).toHaveBeenCalledTimes(1)
expect(listOceanBookings).toHaveBeenCalledTimes(1)
wrapper.unmount()
})
it('keeps the cross-warehouse list usable when lazy warehouse form options fail', async () => {
vi.mocked(listAllPlatformWarehouses).mockRejectedValueOnce(new Error('warehouse unavailable'))
const router = createTestRouter()
await router.push('/'); await router.isReady()
const wrapper = mount(ContainersView, { global: { plugins: [ElementPlus, router] } })
await flushPromises()
expect(listAllPlatformWarehouses).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('CB-1')
expect(wrapper.text()).not.toContain('装柜仓库加载失败')
expect(wrapper.find('button[aria-label="查看 CB-1"]').exists()).toBe(true)
await wrapper.findAll('button').find((button) => button.text().includes('新增模拟柜'))!.trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('装柜仓库加载失败')
})
it('uses stable view labels when cabinetNo is missing', async () => {
@@ -98,6 +98,13 @@ const drayageHistoryError = ref<ApiErrorDetails | null>(null)
let listSequence = 0
let detailSequence = 0
let linkSequence = 0
let warehousesLoaded = false
let warehouseOptionsPromise: Promise<void> | null = null
let partnersLoaded = false
let truckingSuppliersLoaded = false
let truckingSuppliersPromise: Promise<Supplier[]> | null = null
let oceanBookingsLoaded = false
let businessOptionsPromise: Promise<void> | null = null
function emptyForm(): ContainerForm {
return {
@@ -345,14 +352,21 @@ function selectDestinationWarehouse(warehouseCode: string) {
}
async function loadWarehouses() {
warehouseLoading.value = true; warehouseError.value = null
try {
const records = await listAllPlatformWarehouses({ status: 'ACTIVE' })
warehouses.value = records.filter((warehouse) => warehouse.warehouseType === 'DOMESTIC')
destinationWarehouses.value = records.filter((warehouse) => warehouse.warehouseType !== 'DOMESTIC')
}
catch (error) { warehouseError.value = getApiErrorDetails(error, '装柜仓库加载失败') }
finally { warehouseLoading.value = false }
if (warehousesLoaded) return
if (warehouseOptionsPromise) return warehouseOptionsPromise
warehouseOptionsPromise = (async () => {
warehouseLoading.value = true; warehouseError.value = null
try {
const records = await listAllPlatformWarehouses({ status: 'ACTIVE' })
warehouses.value = records.filter((warehouse) => warehouse.warehouseType === 'DOMESTIC')
destinationWarehouses.value = records.filter((warehouse) => warehouse.warehouseType !== 'DOMESTIC')
warehousesLoaded = true
}
catch (error) { warehouseError.value = getApiErrorDetails(error, '装柜仓库加载失败') }
finally { warehouseLoading.value = false }
})()
try { await warehouseOptionsPromise }
finally { warehouseOptionsPromise = null }
}
async function loadCustomers() {
customersLoading.value = true
@@ -366,25 +380,48 @@ async function loadCustomers() {
}
}
async function loadBusinessOptions() {
businessOptionsLoading.value = true
businessOptionsError.value = ''
const errors: string[] = []
const [partnerResult, supplierResult, bookingResult] = await Promise.allSettled([
loadAllPartners(),
listSuppliers({ page: 1, size: 200, category: 'TRUCKING', status: 'ACTIVE' }),
loadAllOceanBookings(),
])
if (partnerResult.status === 'fulfilled') partners.value = partnerResult.value
else errors.push(getApiErrorDetails(partnerResult.reason, '船公司与拖车公司加载失败').message)
if (supplierResult.status === 'fulfilled') {
truckingSuppliers.value = supplierResult.value.records.filter((supplier) => supplier.category === 'TRUCKING' && supplier.status === 'ACTIVE')
if (form.id) reconcileTruckingSupplier()
}
else errors.push(getApiErrorDetails(supplierResult.reason, '拖车供应商加载失败').message)
if (bookingResult.status === 'fulfilled') oceanBookings.value = bookingResult.value
else errors.push(getApiErrorDetails(bookingResult.reason, '港口候选加载失败').message)
businessOptionsError.value = errors.join(';')
businessOptionsLoading.value = false
if (partnersLoaded && truckingSuppliersLoaded && oceanBookingsLoaded) return
if (businessOptionsPromise) return businessOptionsPromise
businessOptionsPromise = (async () => {
businessOptionsLoading.value = true
businessOptionsError.value = ''
const errors: string[] = []
const [partnerResult, supplierResult, bookingResult] = await Promise.allSettled([
partnersLoaded ? Promise.resolve(partners.value) : loadAllPartners(),
loadTruckingSuppliers(),
oceanBookingsLoaded ? Promise.resolve(oceanBookings.value) : loadAllOceanBookings(),
])
if (partnerResult.status === 'fulfilled') { partners.value = partnerResult.value; partnersLoaded = true }
else errors.push(getApiErrorDetails(partnerResult.reason, '船公司与拖车公司加载失败').message)
if (supplierResult.status === 'fulfilled') {
if (form.id) reconcileTruckingSupplier()
}
else errors.push(getApiErrorDetails(supplierResult.reason, '拖车供应商加载失败').message)
if (bookingResult.status === 'fulfilled') { oceanBookings.value = bookingResult.value; oceanBookingsLoaded = true }
else errors.push(getApiErrorDetails(bookingResult.reason, '港口候选加载失败').message)
businessOptionsError.value = errors.join(';')
businessOptionsLoading.value = false
})()
try { await businessOptionsPromise }
finally { businessOptionsPromise = null }
}
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')
truckingSuppliers.value = records
truckingSuppliersLoaded = true
return records
})()
try { return await truckingSuppliersPromise }
finally { truckingSuppliersPromise = null }
}
async function ensureTruckingSuppliers(open: unknown = true) {
if (!open) return false
try { await loadTruckingSuppliers(); return true }
catch (error) { ElMessage.error(getApiErrorDetails(error, '拖车供应商加载失败').message); return false }
}
async function loadAllPartners() {
const firstPage = await listPlatformPartners({ page: 1, size: 200, status: 'ACTIVE' })
@@ -398,11 +435,13 @@ async function loadAllOceanBookings() {
const remaining = await Promise.all(Array.from({ length: firstPage.pages - 1 }, (_, index) => listOceanBookings({ page: index + 2, size: 200 })))
return [firstPage, ...remaining].flatMap((pageResult) => pageResult.records)
}
function openSimulation() {
async function openSimulation() {
Object.assign(simulationForm, { channelCode: '', warehouseId: '', containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000 })
simulationChannels.value = []
simulationError.value = null
simulationOpen.value = true
await loadWarehouses()
if (warehouseError.value) simulationError.value = warehouseError.value
}
function selectSimulationContainerType(containerType: string) {
applyContainerSpecification(simulationForm, containerType)
@@ -497,6 +536,7 @@ async function refreshDrayage(containerBookingId: string) {
async function selectRowFleet(source: object, supplierIdValue: unknown) {
const row = source as ContainerBookingResource
const current = drayageFor(row.id)
if (!(await ensureTruckingSuppliers())) return
const supplier = truckingCompanies.value.find((item) => item.id === String(supplierIdValue || ''))
if (!current || !supplier || !canDrayage(row, 'SELECT_FLEET') || drayageSavingIds.value.includes(row.id)) return
drayageSavingIds.value = [...drayageSavingIds.value, row.id]
@@ -583,7 +623,7 @@ async function openEdit(source: object) {
const row = source as ContainerBookingResource
editorError.value = null
try {
const latest = await getContainer(row.id, props.workbench)
const [latest] = await Promise.all([getContainer(row.id, props.workbench), loadWarehouses(), loadBusinessOptions()])
if (!isDomestic.value || !hasAction(latest, 'EDIT_CONTAINER')) return
detail.value = latest
replaceForm(latest)
@@ -646,7 +686,7 @@ watch(linkOpen, (open) => {
linkLoading.value = false
})
onMounted(async () => {
await Promise.all([loadCustomers(), loadWarehouses(), loadBusinessOptions(), loadRows()])
await Promise.all([loadCustomers(), loadRows()])
const linkContainerId = typeof route.query.linkContainerId === 'string' ? route.query.linkContainerId : ''
if (!isDomestic.value || !linkContainerId || !canEdit.value) return
try {
@@ -669,20 +709,26 @@ onMounted(async () => {
<section class="container-page">
<ListPageLayout :ariaLabel="isDomestic ? '送柜报关工作台' : '清关提柜工作台'">
<template #toolbar>
<QueryToolbar v-model="filters" class="surface-panel" ariaLabel="货柜筛选" :fields="queryFields" preference-key="container-loading-query" @search="search" />
<div class="container-query-panel">
<div class="container-scope-tabs" role="tablist" aria-label="货柜完结状态">
<button type="button" role="tab" aria-controls="container-workbench-results" :aria-selected="scope === 'ACTIVE'" :tabindex="scope === 'ACTIVE' ? 0 : -1" :class="['container-scope-tab', { 'is-active': scope === 'ACTIVE' }]" @click="scope = 'ACTIVE'">未完结</button>
<button type="button" role="tab" aria-controls="container-workbench-results" :aria-selected="scope === 'HISTORY'" :tabindex="scope === 'HISTORY' ? 0 : -1" :class="['container-scope-tab', { 'is-active': scope === 'HISTORY' }]" @click="scope = 'HISTORY'">已完结</button>
</div>
<QueryToolbar v-model="filters" class="container-query-toolbar" ariaLabel="货柜筛选" :fields="queryFields" preference-key="container-loading-query" @search="search" />
</div>
</template>
<template v-if="loadError" #state>
<LoadErrorState title="货柜列表加载失败" :message="loadError.message" :loading="loading" @retry="loadRows"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
</template>
<section v-if="!loadError" class="surface-panel table-panel">
<section v-if="!loadError" id="container-workbench-results" class="surface-panel table-panel">
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="total" :loading="loading" stripe preference-key="container-loading-table" empty-text="暂无货柜">
<template #toolbar-actions><el-radio-group v-model="scope" size="small" aria-label="工作台范围"><el-radio-button value="ACTIVE">当前履约</el-radio-button><el-radio-button value="HISTORY">历史货柜</el-radio-button></el-radio-group><el-button v-if="isDomestic && canCreate" type="primary" @click="openSimulation"><Plus :size="16" />新增模拟柜</el-button></template>
<template #toolbar-actions><el-button v-if="isDomestic && canCreate" type="primary" @click="openSimulation"><Plus :size="16" />新增模拟柜</el-button></template>
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canView" size="small" type="primary" plain :aria-label="containerViewLabel(row as ContainerBookingResource)" @click="openDetail(row)"><Eye :size="15" />查看</el-button><template v-if="isDomestic"><el-button v-if="canEdit && hasAction(row as ContainerWorkbenchRow, 'EDIT_CONTAINER')" size="small" @click="openEdit(row)"><Pencil :size="15" />编辑运输信息</el-button><el-button v-if="row.planStatus === 'EDITING' && canSmartLoading" size="small" type="primary" @click="goContainerProcess(row)"><Sparkles :size="15" />智能装柜</el-button><el-button v-else-if="row.planStatus === 'LOADING' && canViewPreload" size="small" type="primary" plain @click="goContainerProcess(row)"><ListChecks :size="15" />预装清单</el-button><el-button v-else-if="row.planStatus === 'COMPLETED' && canViewLoadingList" size="small" type="primary" plain @click="goContainerProcess(row)"><ListChecks :size="15" />装柜清单</el-button></template><el-dropdown v-if="canView" trigger="click" @command="handleMoreAction($event, row)"><el-button size="small" plain aria-label="更多货柜操作" title="更多货柜操作"><MoreHorizontal :size="16" /></el-button><template #dropdown><el-dropdown-menu><el-dropdown-item command="overview"><FileText :size="15" />轨迹与日志</el-dropdown-item><template v-if="isOverseas"><el-dropdown-item command="drayage-history"><FileText :size="15" />码头拖车记录</el-dropdown-item><el-dropdown-item v-if="canDrayage(row as ContainerWorkbenchRow, 'SCHEDULE_PICKUP')" command="drayage:SCHEDULE_PICKUP">预约提柜</el-dropdown-item><el-dropdown-item v-if="canDrayage(row as ContainerWorkbenchRow, 'RECORD_PICKUP')" command="drayage:RECORD_PICKUP">确认实际提柜</el-dropdown-item><el-dropdown-item v-if="canDrayage(row as ContainerWorkbenchRow, 'RECORD_EMPTY_RETURN')" command="drayage:RECORD_EMPTY_RETURN">确认实际还柜</el-dropdown-item><el-dropdown-item v-if="canOverseasDocumentEdit && hasAction(row as ContainerWorkbenchRow, 'MANAGE_DESTINATION_DOCUMENTS')" command="documents"><FileUp :size="15" />清关 / DO / PU</el-dropdown-item></template><el-dropdown-item v-if="isDomestic && canEdit && hasAction(row as ContainerWorkbenchRow, 'EDIT_CONTAINER') && row.sourceType === 'SIMULATION' && !row.oceanBookingId" command="link"><Link2 :size="15" />关联已放舱订舱</el-dropdown-item></el-dropdown-menu></template></el-dropdown></div></template>
<template #emptyReturnWarning="{ row }"><el-tag :type="drayageWarningType(row as ContainerBookingResource)" effect="light" :title="drayageWarningTitle(row as ContainerBookingResource)">{{ drayageWarningText(row as ContainerBookingResource) }}</el-tag></template>
<template #cabinet="{ row }"><strong>{{ row.cabinetNo }}</strong><small>系统生成</small></template>
<template #oceanBooking="{ row }"><span v-if="row.oceanBookingId"><strong>{{ row.billOfLadingNo || '已关联订舱' }}</strong><small>{{ row.containerNo ? `集装箱号:${row.containerNo}` : '集装箱号:待补录' }}</small></span><span v-else>未关联</span></template>
<template #containerNo="{ row }">{{ row.containerNo || '待补录' }}</template>
<template #terminalTruckingCompany="{ row }"><el-select class="fleet-select" :model-value="drayageFor(row.id)?.fleetSupplierId || ''" filterable placeholder="请选择车队" :loading="drayageSavingIds.includes(row.id)" :disabled="!canDrayage(row as ContainerBookingResource, 'SELECT_FLEET') || drayageSavingIds.includes(row.id)" @update:model-value="selectRowFleet(row, $event)"><el-option v-if="drayageFor(row.id)?.fleetSupplierName && !truckingCompanies.some((item) => item.id === drayageFor(row.id)?.fleetSupplierId)" :label="drayageFor(row.id)?.fleetSupplierName || '原码头车队'" :value="drayageFor(row.id)?.fleetSupplierId || ''" disabled /><el-option v-for="supplier in truckingCompanies" :key="supplier.id" :label="supplier.name" :value="supplier.id" /></el-select></template>
<template #terminalTruckingCompany="{ row }"><el-select class="fleet-select" :model-value="drayageFor(row.id)?.fleetSupplierId || ''" filterable placeholder="请选择车队" :loading="drayageSavingIds.includes(row.id)" :disabled="!canDrayage(row as ContainerBookingResource, 'SELECT_FLEET') || drayageSavingIds.includes(row.id)" @visible-change="ensureTruckingSuppliers" @update:model-value="selectRowFleet(row, $event)"><el-option v-if="drayageFor(row.id)?.fleetSupplierName && !truckingCompanies.some((item) => item.id === drayageFor(row.id)?.fleetSupplierId)" :label="drayageFor(row.id)?.fleetSupplierName || '原码头车队'" :value="drayageFor(row.id)?.fleetSupplierId || ''" disabled /><el-option v-for="supplier in truckingCompanies" :key="supplier.id" :label="supplier.name" :value="supplier.id" /></el-select></template>
<template #pickupAppointmentAt="{ row }">{{ formatTime(drayageFor(row.id)?.pickupAppointmentAt) }}</template>
<template #actualPickupAt="{ row }">{{ formatTime(drayageFor(row.id)?.actualPickupAt) }}</template>
<template #estimatedEmptyReturnAt="{ row }">{{ formatTime(drayageFor(row.id)?.estimatedEmptyReturnAt) }}</template>
@@ -757,11 +803,11 @@ onMounted(async () => {
</template>
<style scoped>
.container-page { display: grid; gap: 16px; min-width: 0; }.container-page small { display: block; margin-top: 4px; color: var(--cc-muted); }.row-actions { display: flex; align-items: center; flex-wrap: nowrap; gap: 4px; white-space: nowrap; }.row-actions :deep(.el-button + .el-button) { margin-left: 0; }.form-grid { display: grid; grid-template-columns: minmax(0,1fr); gap: 0; }.form-grid :deep(.el-select),.form-grid :deep(.el-date-editor),.form-grid :deep(.el-input-number),.simulation-form :deep(.el-select),.simulation-form :deep(.el-input-number),.fleet-select { width: 100%; }.details :deep(.el-descriptions__label) { width: 120px; }.dialog-hint { margin: 4px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.6; }.drayage-load-alert { margin-bottom: 12px; }.drayage-summary { margin-bottom: 14px; }
.container-page { display: grid; gap: 16px; min-width: 0; }.container-page small { display: block; margin-top: 4px; color: var(--cc-muted); }.container-query-panel { overflow: hidden; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; }.container-scope-tabs { display: flex; max-width: 100%; overflow-x: auto; margin-bottom: 0; border-bottom: 1px solid #e5e7eb; background: #f9fafb; scrollbar-width: thin; }.container-scope-tab { position: relative; flex: 0 0 auto; min-height: 44px; padding: 0 18px; border: 0; background: transparent; color: var(--cc-muted); font: inherit; font-size: 14px; cursor: pointer; }.container-scope-tab::after { position: absolute; right: 12px; bottom: 0; left: 12px; height: 3px; background: transparent; content: ''; }.container-scope-tab:hover { color: var(--cc-primary); background: #f6f9fd; }.container-scope-tab:focus-visible { outline: 2px solid var(--cc-primary); outline-offset: -2px; }.container-scope-tab.is-active { color: var(--cc-primary); font-weight: 600; }.container-scope-tab.is-active::after { background: var(--cc-primary); }.container-query-toolbar { border: 0; }.row-actions { display: flex; align-items: center; flex-wrap: nowrap; gap: 4px; white-space: nowrap; }.row-actions :deep(.el-button + .el-button) { margin-left: 0; }.form-grid { display: grid; grid-template-columns: minmax(0,1fr); gap: 0; }.form-grid :deep(.el-select),.form-grid :deep(.el-date-editor),.form-grid :deep(.el-input-number),.simulation-form :deep(.el-select),.simulation-form :deep(.el-input-number),.fleet-select { width: 100%; }.details :deep(.el-descriptions__label) { width: 120px; }.dialog-hint { margin: 4px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.6; }.drayage-load-alert { margin-bottom: 12px; }.drayage-summary { margin-bottom: 14px; }
:global(.container-editor-dialog.el-dialog) { display: flex; width: min(820px, calc(100vw - 28px)) !important; max-height: 90vh; margin-block: 5vh !important; overflow: hidden; flex-direction: column; border-radius: 12px; }
:global(.container-editor-dialog .el-dialog__header) { flex: 0 0 auto; margin: 0; padding: 20px 28px; border-bottom: 1px solid #e5eaf1; }
:global(.container-editor-dialog .el-dialog__title) { color: #1e293b; font-size: 18px; font-weight: 700; }
:global(.container-editor-dialog .el-dialog__body) { min-height: 0; padding: 28px; overflow-y: auto; }
:global(.container-editor-dialog .el-dialog__footer) { flex: 0 0 auto; padding: 16px 28px; border-top: 1px solid #e5eaf1; background: #f8fafc; }
@media(max-width:720px){:global(.container-editor-dialog.el-dialog){max-height:calc(100vh - 20px);margin-block:10px!important}:global(.container-editor-dialog .el-dialog__header),:global(.container-editor-dialog .el-dialog__body),:global(.container-editor-dialog .el-dialog__footer){padding-inline:16px}.details :deep(.el-descriptions__body table),.details :deep(tbody),.details :deep(tr),.details :deep(td){display:block;width:100%!important}.row-actions { min-width: 180px; }}
@media(max-width:720px){.container-scope-tab{min-height:46px;padding:0 14px}:global(.container-editor-dialog.el-dialog){max-height:calc(100vh - 20px);margin-block:10px!important}:global(.container-editor-dialog .el-dialog__header),:global(.container-editor-dialog .el-dialog__body),:global(.container-editor-dialog .el-dialog__footer){padding-inline:16px}.details :deep(.el-descriptions__body table),.details :deep(tbody),.details :deep(tr),.details :deep(td){display:block;width:100%!important}.row-actions { min-width: 180px; }}
</style>
@@ -111,18 +111,26 @@ describe('ContainerFeesPanel contract', () => {
expect(listContainerCostDetails).not.toHaveBeenCalledWith('11', expect.anything())
})
it('uses the summary container ID when fee rows are empty', async () => {
it('does not request any Finance data when container fees are empty', async () => {
vi.mocked(listContainerFees).mockResolvedValue([])
await mountPanel(['firstleg:inbound:fee-detail:view'])
expect(listContainerCostDetails).toHaveBeenCalledWith('11', '99')
await mountPanel(['firstleg:inbound:fee-detail:view', 'platform:finance:settlement-ar:list', 'platform:finance:settlement-ap:list'])
expect(listContainerCostDetails).not.toHaveBeenCalled()
expect(listFinanceReceivableSettlements).not.toHaveBeenCalled()
expect(listFinancePayableSettlements).not.toHaveBeenCalled()
expect(getFinanceSettlement).not.toHaveBeenCalled()
})
it('uses the summary container ID when fee rows agree', async () => {
it('loads Finance data only on an explicit single request when fee rows agree', async () => {
vi.mocked(listContainerFees).mockResolvedValue([
{ id: '1', loadingPlanId: '9', containerBookingId: '99', containerNo: null, feeItemId: '1', feeItemName: '费用A', direction: 'RECEIVABLE', supplierId: null, supplierName: null, supplierCategory: null, businessDate: '2026-08-12', amount: 10, currency: 'CNY', reviewStatus: 'PENDING', sourceStatus: 'ACTIVE', sourceVersion: 1, remark: null, reviewerName: null, reviewedAt: null, reviewReason: null },
])
await mountPanel(['firstleg:inbound:fee-detail:view'])
const wrapper = await mountPanel(['firstleg:inbound:fee-detail:view', 'platform:finance:settlement-ar:list'])
expect(listContainerCostDetails).not.toHaveBeenCalled()
expect(listFinanceReceivableSettlements).not.toHaveBeenCalled()
await wrapper.findAll('button').find((button) => button.text() === '加载财务状态')!.trigger('click')
await flushPromises()
expect(listContainerCostDetails).toHaveBeenCalledWith('11', '99')
expect(listFinanceReceivableSettlements).toHaveBeenCalledTimes(1)
})
it('fails closed when fee rows disagree with the summary container', async () => {
@@ -130,7 +138,9 @@ describe('ContainerFeesPanel contract', () => {
{ id: '2', loadingPlanId: '9', containerBookingId: '100', containerNo: null, feeItemId: '2', feeItemName: '费用B', direction: 'PAYABLE', supplierId: '41', supplierName: '承运商A', supplierCategory: 'TRUCKING', businessDate: '2026-08-12', amount: 20, currency: 'CNY', reviewStatus: 'PENDING', sourceStatus: 'ACTIVE', sourceVersion: 1, remark: null, reviewerName: null, reviewedAt: null, reviewReason: null },
])
const wrapper = await mountPanel(['firstleg:inbound:fee-detail:view', 'firstleg:fee-item:view'])
expect(wrapper.text()).toContain('柜次费用加载失败')
await wrapper.findAll('button').find((button) => button.text() === '加载财务状态')!.trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('财务状态加载失败')
expect(listContainerCostDetails).not.toHaveBeenCalled()
})
@@ -161,6 +171,8 @@ describe('ContainerFeesPanel contract', () => {
'firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:inbound:fee-detail:review',
'platform:finance:settlement-ar:list',
])
await wrapper.findAll('button').find((button) => button.text() === '加载财务状态')!.trigger('click')
await flushPromises()
expect(wrapper.findAll('button').some((button) => button.text() === '调整')).toBe(false)
expect(wrapper.findAll('button').some((button) => button.text() === '作废')).toBe(false)
expect(getFinanceSettlement).toHaveBeenCalledWith('ar-1')
@@ -26,7 +26,10 @@ const feeItems = ref<ApplicableFeeItem[]>([])
const suppliers = ref<ContainerFeeSupplierOption[]>([])
const costs = ref<ContainerCostDetail[]>([])
const loading = ref(false)
const financeLoading = ref(false)
const financeLoaded = ref(false)
const error = ref<ApiErrorDetails | null>(null)
const financeError = ref<ApiErrorDetails | null>(null)
const feeItemsError = ref<ApiErrorDetails | null>(null)
const suppliersError = ref<ApiErrorDetails | null>(null)
const feeItemsLoading = ref(false)
@@ -183,21 +186,35 @@ async function load() {
const feeRows = await listContainerFees(props.planId, requestedAccountId)
if (sequence !== loadSequence || requestedAccountId !== accountId.value) return
fees.value = feeRows
await loadSettledFeeIds(requestedAccountId, feeRows, sequence)
if (sequence !== loadSequence || requestedAccountId !== accountId.value) return
settledFeeIds.value = new Set(feeRows.filter((item) => item.settlementMode === 'DIRECT_PAID').map((item) => item.id))
costs.value = []
const containerIds = [...new Set(feeRows.map((item) => item.containerBookingId).filter((value): value is string => Boolean(value)))]
if (containerIds.some((value) => value !== props.containerBookingId)) throw new Error('柜次费用与装柜摘要的柜次标识不一致,已停止查询财务分摊')
const currentContainerId = props.containerBookingId
if (currentContainerId) {
const result = await listContainerCostDetails(requestedAccountId, currentContainerId)
if (sequence !== loadSequence || requestedAccountId !== accountId.value) return
costs.value = result
}
financeLoaded.value = false
financeError.value = null
} catch (cause) { if (sequence === loadSequence && requestedAccountId === accountId.value) error.value = getApiErrorDetails(cause, '柜次费用加载失败') }
finally { if (sequence === loadSequence) loading.value = false }
}
async function loadFinanceDetails() {
if (!accountId.value || !canView.value || !fees.value.length || financeLoading.value) return
const sequence = loadSequence
const requestedAccountId = accountId.value
const feeRows = [...fees.value]
financeLoading.value = true; financeError.value = null
try {
const containerIds = [...new Set(feeRows.map((item) => item.containerBookingId).filter((value): value is string => Boolean(value)))]
if (containerIds.some((value) => value !== props.containerBookingId)) throw new Error('柜次费用与装柜摘要的柜次标识不一致,已停止查询财务分摊')
await loadSettledFeeIds(requestedAccountId, feeRows, sequence)
if (sequence !== loadSequence || requestedAccountId !== accountId.value) return
costs.value = props.containerBookingId ? await listContainerCostDetails(requestedAccountId, props.containerBookingId) : []
if (sequence !== loadSequence || requestedAccountId !== accountId.value) return
financeLoaded.value = true
} catch (cause) {
if (sequence === loadSequence && requestedAccountId === accountId.value) financeError.value = getApiErrorDetails(cause, '财务状态加载失败')
} finally {
if (sequence === loadSequence && requestedAccountId === accountId.value) financeLoading.value = false
}
}
async function loadSettledFeeIds(customerId: string, feeRows: ContainerFeeResource[], sequence: number) {
const settled = new Set(feeRows.filter((item) => item.settlementMode === 'DIRECT_PAID').map((item) => item.id))
settledFeeIds.value = settled
@@ -334,7 +351,7 @@ function initializeCustomer() { accountId.value = customerOptions.value.length =
function clearCustomerContext() {
loadSequence += 1
supplierLoadSequence += 1
fees.value = []; settledFeeIds.value = new Set(); costs.value = []; feeItems.value = []; suppliers.value = []; error.value = null; feeItemsError.value = null; suppliersError.value = null; loading.value = false; feeItemsLoading.value = false; suppliersLoading.value = false; loadedSupplierCategory.value = undefined
fees.value = []; settledFeeIds.value = new Set(); costs.value = []; feeItems.value = []; suppliers.value = []; error.value = null; financeError.value = null; feeItemsError.value = null; suppliersError.value = null; loading.value = false; financeLoading.value = false; financeLoaded.value = false; feeItemsLoading.value = false; suppliersLoading.value = false; loadedSupplierCategory.value = undefined
editorOpen.value = false; rejectOpen.value = false; target.value = null; rejection.value = ''
resetForm()
}
@@ -367,7 +384,7 @@ onMounted(() => void load())
<LoadErrorState v-else-if="error" compact title="柜次费用加载失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<DataTable v-else :data="fees" :columns="columns" :loading="loading" preference-key="container-fees" row-key="id" empty-text="当前客户暂无柜次费用">
<template #toolbar-start><div class="fees-table-title"><strong>费用明细</strong><span>{{ fees.length }} 条</span></div></template>
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canReview && row.reviewStatus === 'PENDING'" link type="success" @click="approve(row as ContainerFeeResource)"><Check :size="15" />通过</el-button><el-button v-if="canReview && row.reviewStatus === 'PENDING'" link type="danger" @click="openReject(row as ContainerFeeResource)"><CircleX :size="15" />驳回</el-button><el-button v-if="canEdit && row.reviewStatus === 'APPROVED' && row.sourceStatus === 'ACTIVE' && !isSettled(row as ContainerFeeResource)" link type="primary" @click="openAdjust(row as ContainerFeeResource)"><Pencil :size="15" />调整</el-button><el-button v-if="canReview && row.reviewStatus === 'APPROVED' && row.sourceStatus === 'ACTIVE' && !isSettled(row as ContainerFeeResource)" link type="danger" @click="voidFee(row as ContainerFeeResource)"><Trash2 :size="15" />作废</el-button></div></template>
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canReview && row.reviewStatus === 'PENDING'" link type="success" @click="approve(row as ContainerFeeResource)"><Check :size="15" />通过</el-button><el-button v-if="canReview && row.reviewStatus === 'PENDING'" link type="danger" @click="openReject(row as ContainerFeeResource)"><CircleX :size="15" />驳回</el-button><el-button v-if="canEdit && financeLoaded && row.reviewStatus === 'APPROVED' && row.sourceStatus === 'ACTIVE' && !isSettled(row as ContainerFeeResource)" link type="primary" @click="openAdjust(row as ContainerFeeResource)"><Pencil :size="15" />调整</el-button><el-button v-if="canReview && financeLoaded && row.reviewStatus === 'APPROVED' && row.sourceStatus === 'ACTIVE' && !isSettled(row as ContainerFeeResource)" link type="danger" @click="voidFee(row as ContainerFeeResource)"><Trash2 :size="15" />作废</el-button></div></template>
<template #direction="{ row }"><el-tag :type="row.direction === 'RECEIVABLE' ? 'success' : 'warning'" effect="plain">{{ row.direction === 'RECEIVABLE' ? '应收' : '应付' }}</el-tag></template>
<template #settlementMode="{ row }">{{ settlementLabel(row as ContainerFeeResource) }}</template>
<template #paidSummary="{ row }">{{ paidSummary(row as ContainerFeeResource) }}</template>
@@ -376,7 +393,7 @@ onMounted(() => void load())
<template #amount="{ row }">{{ money(row.amount) }}</template><template #reviewStatus="{ row }"><StatusBadge :status="row.reviewStatus === 'PENDING' ? '待审核' : row.reviewStatus === 'APPROVED' ? '已通过' : '已驳回'" /></template><template #sourceStatus="{ row }"><StatusBadge :status="row.sourceStatus === 'ACTIVE' ? '有效' : '已作废'" /></template>
</DataTable>
<div v-if="accountId" class="allocation-section"><header><div><h3>财务体积分摊</h3><p>{{ selectedCustomer?.label }} · 仅展示 Finance 已持久化结果</p></div></header><el-empty v-if="!loading && costs.length === 0" description="尚无与该柜次关联的财务分摊结果" :image-size="64" /><article v-for="cost in costs" :key="cost.id" class="cost-block"><div class="cost-summary"><strong>{{ cost.feeItemName || '柜次成本' }}</strong><span>供应商:{{ cost.supplierName || '服务端未返回' }}</span><span>金额:¥{{ money(cost.amount) }}</span></div><DataTable :data="cost.allocations" :columns="allocationColumns" :preference-key="`container-cost-${cost.id}`" row-key="id" empty-text="服务端未返回分摊明细"><template #orderNo="{ row }">{{ orderNumbers.get(row.orderId) || '订单编码待补充' }}</template><template #inboundCode="{ row }">{{ row.inboundId ? (inboundCodes.get(row.inboundId) || '入仓码待补充') : '—' }}</template><template #volume="{ row }">{{ row.volumeCbm.toFixed(3) }}</template><template #rate="{ row }">{{ (row.allocationRate * 100).toFixed(2) }}%</template><template #allocated="{ row }">{{ money(row.allocatedAmount) }}</template></DataTable></article></div>
<div v-if="accountId && fees.length" class="allocation-section"><header><div><h3>财务体积分摊</h3><p>{{ selectedCustomer?.label }} · 仅展示 Finance 已持久化结果</p></div><el-button :loading="financeLoading" @click="loadFinanceDetails">{{ financeLoaded ? '刷新财务状态' : '加载财务状态' }}</el-button></header><LoadErrorState v-if="financeError" compact title="财务状态加载失败" :message="financeError.message" @retry="loadFinanceDetails"><template #details><ApiErrorMeta :details="financeError" /></template></LoadErrorState><el-empty v-else-if="!financeLoaded" description="点击加载财务状态后查看结算与成本分摊" :image-size="64" /><el-empty v-else-if="!financeLoading && costs.length === 0" description="尚无与该柜次关联的财务分摊结果" :image-size="64" /><article v-for="cost in costs" :key="cost.id" class="cost-block"><div class="cost-summary"><strong>{{ cost.feeItemName || '柜次成本' }}</strong><span>供应商:{{ cost.supplierName || '服务端未返回' }}</span><span>金额:¥{{ money(cost.amount) }}</span></div><DataTable :data="cost.allocations" :columns="allocationColumns" :preference-key="`container-cost-${cost.id}`" row-key="id" empty-text="服务端未返回分摊明细"><template #orderNo="{ row }">{{ orderNumbers.get(row.orderId) || '订单编码待补充' }}</template><template #inboundCode="{ row }">{{ row.inboundId ? (inboundCodes.get(row.inboundId) || '入仓码待补充') : '—' }}</template><template #volume="{ row }">{{ row.volumeCbm.toFixed(3) }}</template><template #rate="{ row }">{{ (row.allocationRate * 100).toFixed(2) }}%</template><template #allocated="{ row }">{{ money(row.allocatedAmount) }}</template></DataTable></article></div>
</template>
<AppDialog v-model="editorOpen" :title="target ? '调整已审核费用' : '新增柜次费用'" confirm-text="保存" :confirm-loading="saving" @confirm="save">