diff --git a/src/modules/container-loading/pages/ContainerDetailView.test.ts b/src/modules/container-loading/pages/ContainerDetailView.test.ts index 7bfcd2c0..51f3ff81 100644 --- a/src/modules/container-loading/pages/ContainerDetailView.test.ts +++ b/src/modules/container-loading/pages/ContainerDetailView.test.ts @@ -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(' { expect(source).toContain("detail.planStatus === 'COMPLETED' && overview?.loadingPlanId && canListCustoms") expect(source).toContain(' { 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 () => { diff --git a/src/modules/container-loading/pages/ContainersView.vue b/src/modules/container-loading/pages/ContainersView.vue index 5e931bdf..168a7dd5 100644 --- a/src/modules/container-loading/pages/ContainersView.vue +++ b/src/modules/container-loading/pages/ContainersView.vue @@ -98,6 +98,13 @@ const drayageHistoryError = ref(null) let listSequence = 0 let detailSequence = 0 let linkSequence = 0 +let warehousesLoaded = false +let warehouseOptionsPromise: Promise | null = null +let partnersLoaded = false +let truckingSuppliersLoaded = false +let truckingSuppliersPromise: Promise | null = null +let oceanBookingsLoaded = false +let businessOptionsPromise: Promise | 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 { + 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 () => {
-
+
- + - + @@ -757,11 +803,11 @@ onMounted(async () => { diff --git a/src/modules/loading-lists/components/ContainerFeesPanel.test.ts b/src/modules/loading-lists/components/ContainerFeesPanel.test.ts index d37b6d39..ec0018c4 100644 --- a/src/modules/loading-lists/components/ContainerFeesPanel.test.ts +++ b/src/modules/loading-lists/components/ContainerFeesPanel.test.ts @@ -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') diff --git a/src/modules/loading-lists/components/ContainerFeesPanel.vue b/src/modules/loading-lists/components/ContainerFeesPanel.vue index b2b9766f..c5f12a94 100644 --- a/src/modules/loading-lists/components/ContainerFeesPanel.vue +++ b/src/modules/loading-lists/components/ContainerFeesPanel.vue @@ -26,7 +26,10 @@ const feeItems = ref([]) const suppliers = ref([]) const costs = ref([]) const loading = ref(false) +const financeLoading = ref(false) +const financeLoaded = ref(false) const error = ref(null) +const financeError = ref(null) const feeItemsError = ref(null) const suppliersError = ref(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()) - + @@ -376,7 +393,7 @@ onMounted(() => void load()) -

财务体积分摊

{{ selectedCustomer?.label }} · 仅展示 Finance 已持久化结果

{{ cost.feeItemName || '柜次成本' }}供应商:{{ cost.supplierName || '服务端未返回' }}金额:¥{{ money(cost.amount) }}
+

财务体积分摊

{{ selectedCustomer?.label }} · 仅展示 Finance 已持久化结果

{{ financeLoaded ? '刷新财务状态' : '加载财务状态' }}
{{ cost.feeItemName || '柜次成本' }}供应商:{{ cost.supplierName || '服务端未返回' }}金额:¥{{ money(cost.amount) }}