feat: separate container fee workbenches

This commit is contained in:
2026-09-08 22:03:03 +08:00
parent 9570e0fd18
commit 653ce1c511
11 changed files with 325 additions and 48 deletions
@@ -7,6 +7,7 @@ const overviewSource = readFileSync(resolve(process.cwd(), 'src/modules/containe
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')
const lastLegFeesSource = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/components/ContainerLastLegFeesPanel.vue'), 'utf8')
describe('ContainerDetailView customs integration', () => {
it('renders the requested detail sections in the confirmed business order', () => {
@@ -41,13 +42,35 @@ describe('ContainerDetailView customs integration', () => {
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('<el-tabs v-model="activeTab"')
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('separates domestic first-leg fees and connects the overseas fee lifecycle lazily', () => {
expect(source).toContain('label="头程计费" name="first-leg-fees"')
expect(source).toContain('label="尾程计费" name="last-leg-fees"')
expect(source).toContain('v-if="isDomestic && firstLegFeesOpened"')
expect(source).toContain('v-show="activeTab === \'first-leg-fees\'"')
expect(source).toContain('title="头程计费" :plan-id="overview.loadingPlanId" :container-booking-id="detail.id"')
expect(source).toContain('v-if="isOverseas && lastLegFeesOpened"')
expect(source).toContain('<ContainerLastLegFeesPanel :container-booking-id="detail.id" :container-no="detail.containerNo" />')
expect(source).toContain("if (isDomestic.value && activeTab.value === 'loading-list') await loadLoadingDetail(resource, aggregate)")
expect(source).toContain("tabName === 'loading-list' || tabName === 'first-leg-fees'")
expect(lastLegFeesSource.indexOf('label="操作"')).toBeLessThan(lastLegFeesSource.indexOf('label="客户"'))
expect(lastLegFeesSource).toContain('show-overflow-tooltip')
expect(lastLegFeesSource).toContain('尾程计费加载失败')
expect(lastLegFeesSource).toContain('empty-text="当前货柜暂无尾程费用"')
expect(lastLegFeesSource).toContain('v-loading="loading"')
expect(lastLegFeesSource).toContain('Promise.all([listOverseasContainerFees(props.containerBookingId), listOverseasContainerCustomers(props.containerBookingId), listContainerFeeSupplierOptions(null)])')
expect(lastLegFeesSource).toContain("form.settlementMode === 'DIRECT_PAID'")
expect(lastLegFeesSource).toContain('uploadContainerFeePaymentVoucher(form.customerId, file.raw)')
expect(lastLegFeesSource).toContain("access.can('platform:finance:fee-source:edit')")
expect(lastLegFeesSource).toContain("access.can('platform:finance:fee-source:review')")
})
it('mounts the new container customs panel only after loading completes', () => {
expect(source).toContain("detail.planStatus === 'COMPLETED' && overview?.loadingPlanId && canListCustoms")
expect(source).toContain('<ContainerCustomsPanel')
@@ -11,7 +11,7 @@ import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import { CarrierScheduleQueryPanel } from '@/modules/port-schedules'
import { getConfirmedLoadingList, getPreloadPlan, LoadingListDetailDrawer, type LoadingDetail } from '@/modules/loading-lists'
import { ContainerFeesPanel, getConfirmedLoadingList, getPreloadPlan, LoadingListDetailDrawer, type LoadingDetail } from '@/modules/loading-lists'
import {
confirmContainerOutboundDispatch,
getContainerOutboundDispatch,
@@ -19,6 +19,7 @@ import {
type ContainerOutboundGateItem,
} from '@/modules/outbound-dispatch'
import ContainerCustomsPanel from '../components/ContainerCustomsPanel.vue'
import ContainerLastLegFeesPanel from '../components/ContainerLastLegFeesPanel.vue'
import ContainerOverviewPanel from '../components/ContainerOverviewPanel.vue'
import {
cancelContainerException,
@@ -44,6 +45,24 @@ const containerId = computed(() => String(route.params.containerId || ''))
const isDomestic = computed(() => props.workbench === 'DOMESTIC')
const isOverseas = computed(() => props.workbench === 'OVERSEAS')
const activeTab = ref(route.query.tab === 'loading-list' ? 'loading-list' : 'overview')
const firstLegFeesOpened = ref(false)
const lastLegFeesOpened = ref(false)
const feeCustomers = computed(() => {
const customers = new Map<string, string | null>()
for (const item of loadingDetail.value?.items || []) if (item.accountId) customers.set(item.accountId, item.customerName)
return [...customers].map(([accountId, customerName]) => ({ accountId, customerName }))
})
const feeBusinessReferences = computed(() => (loadingDetail.value?.items || []).map((item) => ({
accountId: item.accountId, customerName: item.customerName, orderId: item.orderId, orderNo: item.orderNo,
inboundId: item.inboundId, inboundCode: item.inboundCode,
})))
async function rememberWorkbenchTab(tabName: string | number) {
if (tabName === 'first-leg-fees') firstLegFeesOpened.value = true
if (tabName === 'last-leg-fees') lastLegFeesOpened.value = true
if (isDomestic.value && (tabName === 'loading-list' || tabName === 'first-leg-fees') && detail.value && overview.value) {
await loadLoadingDetail(detail.value, overview.value)
}
}
const workbenchDetail = ref<ContainerWorkbenchRow | null>(null)
const hasAction = (action: ContainerWorkbenchAction) => Boolean(workbenchDetail.value?.allowedActions?.includes(action))
const canEdit = computed(() => isDomestic.value && access.can('platform:firstleg:container:edit') && hasAction('EDIT_CONTAINER'))
@@ -90,6 +109,7 @@ const outboundError = ref<ApiErrorDetails | null>(null)
const showPassedGates = ref(false)
const carrierRefreshKey = ref(0)
const loadingDetail = ref<LoadingDetail | null>(null)
const loadingDetailLoading = ref(false)
const loadingDetailError = ref<ApiErrorDetails | null>(null)
const selectedDocument = computed(() => overview.value?.documents.find((item) => item.documentType === documentType.value) || null)
@@ -173,7 +193,7 @@ async function load() {
const [resource, aggregate] = await Promise.all([getContainer(containerId.value, props.workbench), getContainerOverview(containerId.value)])
workbenchDetail.value = resource; detail.value = resource; overview.value = aggregate
if (activeTab.value === 'loading-list' && resource.planStatus !== 'COMPLETED') activeTab.value = 'overview'
if (isDomestic.value) await loadLoadingDetail(resource, aggregate)
if (isDomestic.value && activeTab.value === 'loading-list') await loadLoadingDetail(resource, aggregate)
if (isDomestic.value && resource.planStatus === 'COMPLETED' && canViewOutboundDispatch.value) await loadOutboundDispatch()
} catch (cause) { error.value = getApiErrorDetails(cause, '货柜详情加载失败') }
finally { loading.value = false }
@@ -188,8 +208,10 @@ async function loadOutboundDispatch() {
}
async function loadLoadingDetail(resource: ContainerBookingResource, aggregate: ContainerOverview) {
if (loadingDetail.value || loadingDetailLoading.value) return
loadingDetail.value = null; loadingDetailError.value = null
if (!isDomestic.value || !aggregate.loadingPlanId || !resource.warehouseId) return
loadingDetailLoading.value = true
try {
if (resource.planStatus === 'COMPLETED') {
if (!canViewLoadingList.value) return
@@ -200,6 +222,8 @@ async function loadLoadingDetail(resource: ContainerBookingResource, aggregate:
loadingDetail.value = await getPreloadPlan(aggregate.loadingPlanId, resource.warehouseId)
} catch (cause) {
loadingDetailError.value = getApiErrorDetails(cause, '装柜清单加载失败')
} finally {
loadingDetailLoading.value = false
}
}
async function refreshAfterCustoms() {
@@ -383,21 +407,21 @@ onMounted(async () => { await load(); if (isOverseas.value && route.query.action
</section>
<LoadErrorState v-if="error" title="货柜详情加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<template v-else-if="detail">
<el-tabs v-if="isDomestic" v-model="activeTab" class="workbench-tabs"><el-tab-pane label="送柜报关" name="overview" /><el-tab-pane v-if="detail.planStatus === 'COMPLETED' && canViewLoadingList" label="装柜清单" name="loading-list" /></el-tabs>
<el-tabs v-model="activeTab" class="workbench-tabs" @tab-change="rememberWorkbenchTab"><el-tab-pane :label="isDomestic ? '送柜报关' : '清关提柜'" name="overview" /><el-tab-pane v-if="isDomestic && detail.planStatus === 'COMPLETED' && canViewLoadingList" label="装柜清单" name="loading-list" /><el-tab-pane v-if="isDomestic && overview?.loadingPlanId" label="头程计费" name="first-leg-fees" /><el-tab-pane v-if="isOverseas" label="尾程计费" name="last-leg-fees" /></el-tabs>
<LoadErrorState v-if="overviewError" compact title="履约摘要加载失败" :message="overviewError.message" :show-retry="false" />
<section v-if="isOverseas || activeTab === 'overview'" class="detail-section surface-panel" aria-labelledby="container-information-heading">
<section v-if="activeTab === 'overview'" class="detail-section surface-panel" aria-labelledby="container-information-heading">
<h2 id="container-information-heading">货柜信息</h2>
<div class="detail-fields"><dl><div><dt>真实订舱</dt><dd><span>{{ detail.oceanBookingId ? (detail.billOfLadingNo || '已关联') : '未关联' }}</span><el-button v-if="canLinkOceanBooking" link type="primary" @click="openOceanBookingLink"><Link2 :size="14" />关联已放舱订舱</el-button></dd></div><div><dt>异常状态</dt><dd>{{ detail.exceptionActive ? '异常' : '正常' }}</dd></div><div><dt>目的仓</dt><dd>{{ detail.destinationWarehouseName || detail.destinationWarehouseCode || '—' }}</dd></div><div><dt>拖车公司</dt><dd>{{ detail.terminalTruckingCompany || '—' }}</dd></div></dl></div>
<ContainerOverviewPanel v-if="isDomestic && overview" :overview="overview" :sections="['exception']" :can-manage-documents="canEdit" :can-manage-customs="canManageCustoms" :can-manage-exception="canEdit" @manage-exception="openException" />
</section>
<section v-if="isOverseas || activeTab === 'overview'" class="detail-section surface-panel" aria-labelledby="container-voyage-heading">
<section v-if="activeTab === 'overview'" class="detail-section surface-panel" aria-labelledby="container-voyage-heading">
<h2 id="container-voyage-heading">船名航次信息</h2>
<div class="detail-fields"><dl><div><dt>船公司</dt><dd>{{ detail.shippingCompanyName || '—' }}</dd></div><div><dt>船名 / 航次</dt><dd>{{ [detail.vesselName, detail.voyageNo].filter(Boolean).join(' / ') || '—' }}</dd></div><div><dt>预计开船</dt><dd>{{ formatTime(detail.estimatedSailingAt) }}</dd></div><div><dt>预计到港</dt><dd>{{ formatTime(detail.estimatedPortArrivalAt) }}</dd></div></dl></div>
<div class="carrier-panel"><CarrierScheduleQueryPanel :key="carrierRefreshKey" :ocean-booking-id="detail.oceanBookingId" :container-booking-id="detail.id" :warehouse-timeline="overview?.timeline || []" /></div>
</section>
<section v-if="isOverseas || activeTab === 'overview'" class="detail-section surface-panel" aria-labelledby="container-customs-heading">
<section v-if="activeTab === 'overview'" class="detail-section surface-panel" aria-labelledby="container-customs-heading">
<h2 id="container-customs-heading">{{ isDomestic ? '出口报关信息' : '目的港清关与提柜资料' }}</h2>
<ContainerOverviewPanel v-if="isOverseas && overview" :overview="overview" :sections="['documents']" :can-manage-documents="canManageDestinationDocuments" :can-manage-customs="canManageDestinationDocuments" :can-manage-exception="false" :document-busy="documentDownloading" :pickup-code-busy="pickupCodeSaving" @upload-document="openDocument" @download-document="downloadDocument" @save-pickup-code="savePickupCode" />
<el-alert v-if="documentRefreshWarning" type="warning" :closable="false" :title="documentRefreshWarning.message"><template #default><ApiErrorMeta :details="documentRefreshWarning" /></template></el-alert>
@@ -425,16 +449,26 @@ onMounted(async () => { await load(); if (isOverseas.value && route.query.action
</div>
</section>
<section v-if="isOverseas" class="detail-section surface-panel" aria-labelledby="container-overseas-receiving-heading">
<section v-if="isOverseas && activeTab === 'overview'" class="detail-section surface-panel" aria-labelledby="container-overseas-receiving-heading">
<h2 id="container-overseas-receiving-heading">提柜、卸柜与还空</h2>
<div class="detail-fields"><dl><div><dt>码头拖车状态</dt><dd>{{ workbenchDetail?.statusSummary?.terminalDrayageStatus || '—' }}</dd></div><div><dt>码头车队</dt><dd>{{ workbenchDetail?.terminalDrayage?.fleetSupplierName || '—' }}</dd></div><div><dt>预约提柜</dt><dd>{{ formatTime(workbenchDetail?.terminalDrayage?.pickupAppointmentAt) }}</dd></div><div><dt>实际提柜</dt><dd>{{ formatTime(workbenchDetail?.terminalDrayage?.actualPickupAt) }}</dd></div><div><dt>卸柜完成</dt><dd>{{ formatTime(workbenchDetail?.actualFacts?.unloadingCompletedAt as string | null) }}</dd></div><div><dt>实际还柜</dt><dd>{{ formatTime(workbenchDetail?.terminalDrayage?.actualEmptyReturnAt) }}</dd></div></dl></div>
</section>
<section v-if="isDomestic && activeTab === 'loading-list'" class="detail-section surface-panel" aria-labelledby="container-loading-list-heading">
<header class="loading-list-heading"><div><h2 id="container-loading-list-heading">装柜清单</h2><p>清单明细、PDA 证据、费用与报关关联均以当前柜次为边界。</p></div><el-button v-if="loadingDetail" @click="printLoadingList">打印装柜清单</el-button></header>
<header class="loading-list-heading"><div><h2 id="container-loading-list-heading">装柜清单</h2><p>清单明细、PDA 证据与报关关联均以当前柜次为边界。</p></div><el-button v-if="loadingDetail" @click="printLoadingList">打印装柜清单</el-button></header>
<ContainerOverviewPanel v-if="overview" :overview="overview" :sections="['pda']" :can-manage-documents="false" :can-manage-customs="false" :can-manage-exception="false" />
<ContainerCustomsPanel v-if="overview?.loadingPlanId && canListCustoms" :loading-plan-id="overview.loadingPlanId" :can-list="canListCustoms" :can-manage="canManageCustoms" :departure-state="customsDepartureState" @refreshed="refreshAfterCustoms" />
<LoadingListDetailDrawer mode="confirmed" :loading="loading" :detail="loadingDetail" :error="loadingDetailError" :can-edit="false" @back="activeTab = 'overview'" @retry="overview && loadLoadingDetail(detail, overview)" />
<LoadingListDetailDrawer mode="confirmed" :loading="loadingDetailLoading" :detail="loadingDetail" :error="loadingDetailError" :can-edit="false" @back="activeTab = 'overview'" @retry="overview && loadLoadingDetail(detail, overview)" />
</section>
<section v-if="isDomestic && firstLegFeesOpened" v-show="activeTab === 'first-leg-fees'" v-loading="loadingDetailLoading" class="detail-section surface-panel" aria-label="头程计费">
<LoadErrorState v-if="loadingDetailError" compact title="头程计费归属明细加载失败" :message="loadingDetailError.message" :loading="loadingDetailLoading" @retry="overview && loadLoadingDetail(detail, overview)"><template #details><ApiErrorMeta :details="loadingDetailError" /></template></LoadErrorState>
<ContainerFeesPanel v-else-if="overview?.loadingPlanId && loadingDetail" title="头程计费" :plan-id="overview.loadingPlanId" :container-booking-id="detail.id" :customers="feeCustomers" :business-references="feeBusinessReferences" />
<el-empty v-else-if="!loadingDetailLoading" description="当前柜次暂无可用装柜计划或费用归属明细" :image-size="72" />
</section>
<section v-if="isOverseas && lastLegFeesOpened" v-show="activeTab === 'last-leg-fees'" class="detail-section surface-panel" aria-label="尾程计费">
<ContainerLastLegFeesPanel :container-booking-id="detail.id" :container-no="detail.containerNo" />
</section>
</template>