From 9570e0fd187372324c34552db430453de93e47b4 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 8 Sep 2026 21:09:25 +0800 Subject: [PATCH] perf: use container finance aggregate --- .../loading-lists/api/containerFees.test.ts | 27 ++++++------ .../loading-lists/api/containerFees.ts | 43 ++++++++++++++----- .../components/ContainerFeesPanel.test.ts | 41 ++++++------------ .../components/ContainerFeesPanel.vue | 34 +++------------ 4 files changed, 66 insertions(+), 79 deletions(-) diff --git a/src/modules/loading-lists/api/containerFees.test.ts b/src/modules/loading-lists/api/containerFees.test.ts index 88fdfa14..fd62af8c 100644 --- a/src/modules/loading-lists/api/containerFees.test.ts +++ b/src/modules/loading-lists/api/containerFees.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { http } from '@/shared/api/http' -import { adjustContainerFee, approveContainerFee, approveContainerFeeAllocationGroup, createContainerFee, createContainerFeeAllocationGroup, listContainerCostDetails, listContainerFeeSupplierOptions, listContainerFees, rejectContainerFee, uploadContainerFeePaymentVoucher, voidContainerFee, voidContainerFeeAllocationGroup } from './containerFees' +import { adjustContainerFee, approveContainerFee, approveContainerFeeAllocationGroup, createContainerFee, createContainerFeeAllocationGroup, getContainerSettlementDetails, listContainerFeeSupplierOptions, listContainerFees, rejectContainerFee, uploadContainerFeePaymentVoucher, voidContainerFee, voidContainerFeeAllocationGroup } from './containerFees' import { listSuppliers } from '@/modules/suppliers' vi.mock('@/modules/suppliers', () => ({ listSuppliers: vi.fn() })) @@ -70,20 +70,19 @@ describe('container fee API', () => { expect(listSuppliers).toHaveBeenCalledWith({ status: 'ACTIVE', category: undefined, page: 1, size: 200 }) }) - it('reads allocation rows from Finance and never calculates them', async () => { - const get = vi.spyOn(http, 'get') - .mockResolvedValueOnce({ code: '200', data: [{ id: 81, customerId: 11, containerId: 99 }, { id: 82, customerId: 11, containerId: 100 }] }) - .mockResolvedValueOnce({ code: '200', data: { cost: { id: 81, customerId: 11, containerId: 99, supplierName: '承运商A', feeItemName: '拖车费', payableAmount: 300 }, allocations: [{ id: 1, orderId: 8, volumeCbm: 2, totalVolumeCbm: 5, allocationRate: 0.4, allocatedAmount: 120 }] } }) - const result = await listContainerCostDetails('11', '99') - expect(result[0]).toMatchObject({ supplierName: '承运商A', allocations: [{ orderId: '8', allocatedAmount: 120 }] }) - expect(get).toHaveBeenNthCalledWith(2, '/platform/finance/v1/cost-entries/81', { params: { customerId: '11' } }) - expect(get).toHaveBeenCalledTimes(2) + it('maps settled fees and allocation rows from one container aggregate request', async () => { + const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', data: { + customerId: 11, containerId: 99, settledSourceFeeIds: [7], + costs: [{ costId: 81, costNo: 'COST-81', costType: 'TRUCKING', costOrigin: 'CONTAINER_FEE', sourceFeeId: 7, sourceFeeVersion: 2, containerId: 99, supplierName: '承运商A', feeItemName: '拖车费', amount: 300, currency: 'CNY', status: 'CONFIRMED', allocations: [{ allocationId: 1, orderId: 8, inboundId: 9, volumeCbm: 2, totalVolumeCbm: 5, allocationRate: 0.4, allocatedAmount: 120, roundingAdjustment: false }] }], + } }) + const result = await getContainerSettlementDetails('11', '99') + expect(result).toMatchObject({ customerId: '11', containerId: '99', settledSourceFeeIds: ['7'], costs: [{ id: '81', sourceFeeId: '7', amount: 300, allocations: [{ id: '1', orderId: '8', inboundId: '9', allocatedAmount: 120 }] }] }) + expect(get).toHaveBeenCalledWith('/platform/finance/v1/container-settlement-details', { params: { customerId: '11', containerId: '99' } }) + expect(get).toHaveBeenCalledTimes(1) }) - it('fails closed when Finance detail does not echo the selected customer and container', async () => { - vi.spyOn(http, 'get') - .mockResolvedValueOnce({ code: '200', data: [{ id: 81, customerId: 11, containerId: 99 }] }) - .mockResolvedValueOnce({ code: '200', data: { cost: { id: 81, customerId: 12, containerId: 99 }, allocations: [] } }) - await expect(listContainerCostDetails('11', '99')).rejects.toThrow('Finance 成本详情与柜次或客户上下文不一致') + it('fails closed when the Finance aggregate does not echo the selected customer and container', async () => { + vi.spyOn(http, 'get').mockResolvedValue({ code: '200', data: { customerId: 12, containerId: 99, settledSourceFeeIds: [], costs: [] } }) + await expect(getContainerSettlementDetails('11', '99')).rejects.toThrow('Finance 聚合结果与柜次或客户上下文不一致') }) }) diff --git a/src/modules/loading-lists/api/containerFees.ts b/src/modules/loading-lists/api/containerFees.ts index 75556dcf..e5c648c5 100644 --- a/src/modules/loading-lists/api/containerFees.ts +++ b/src/modules/loading-lists/api/containerFees.ts @@ -113,13 +113,27 @@ export interface ContainerCostAllocation { export interface ContainerCostDetail { id: string + costNo: string + costType: string + costOrigin: string + sourceFeeId: string | null + sourceFeeVersion: number containerId: string | null supplierName: string | null feeItemName: string amount: number + currency: string + status: string allocations: ContainerCostAllocation[] } +export interface ContainerSettlementDetails { + customerId: string + containerId: string + settledSourceFeeIds: string[] + costs: ContainerCostDetail[] +} + const id = (value: unknown) => value == null ? '' : String(value) const numberValue = (value: unknown) => Number.isFinite(Number(value)) ? Number(value) : 0 const supplierCategories: SupplierCategory[] = ['BOOKING', 'CUSTOMS', 'TRUCKING', 'CERTIFICATE'] @@ -229,16 +243,23 @@ export async function uploadContainerFeePaymentVoucher(customerId: string, file: return { fileId, fileName: String(raw.fileName || raw.file_name || file.name) } } -export async function listContainerCostDetails(customerId: string, containerId: string): Promise { - const response = await http.get[]>, ApiEnvelope[]>>('/platform/finance/v1/cost-entries', { params: { customerId } }) - const costs = unwrapApiResponse(response).filter((raw) => id(raw.customerId) === customerId && id(raw.containerId) === containerId) - return Promise.all(costs.map(async (raw) => { - const costId = id(raw.id) - const detailResponse = await http.get; allocations: Record[] }>, ApiEnvelope<{ cost: Record; allocations: Record[] }>>(`/platform/finance/v1/cost-entries/${encodeURIComponent(costId)}`, { params: { customerId } }) - const detail = unwrapApiResponse(detailResponse) - if (id(detail.cost.id) !== costId || id(detail.cost.customerId) !== customerId || id(detail.cost.containerId) !== containerId) { - throw new Error('Finance 成本详情与柜次或客户上下文不一致') +export async function getContainerSettlementDetails(customerId: string, containerId: string): Promise { + const response = await http.get>, ApiEnvelope>>('/platform/finance/v1/container-settlement-details', { params: { customerId, containerId } }) + const raw = unwrapApiResponse(response) + if (id(raw.customerId) !== customerId || id(raw.containerId) !== containerId) throw new Error('Finance 聚合结果与柜次或客户上下文不一致') + const costs = (Array.isArray(raw.costs) ? raw.costs : []).map((value) => { + const cost = value as Record + if (id(cost.containerId) !== containerId) throw new Error('Finance 聚合成本与柜次上下文不一致') + return { + id: id(cost.costId), costNo: String(cost.costNo || ''), costType: String(cost.costType || ''), costOrigin: String(cost.costOrigin || ''), + sourceFeeId: cost.sourceFeeId == null ? null : id(cost.sourceFeeId), sourceFeeVersion: numberValue(cost.sourceFeeVersion), + containerId: cost.containerId == null ? null : id(cost.containerId), supplierName: cost.supplierName == null ? null : String(cost.supplierName), + feeItemName: String(cost.feeItemName || ''), amount: numberValue(cost.amount), currency: String(cost.currency || ''), status: String(cost.status || ''), + allocations: (Array.isArray(cost.allocations) ? cost.allocations : []).map((value) => { + const allocation = value as Record + return { id: id(allocation.allocationId), orderId: id(allocation.orderId), inboundId: allocation.inboundId == null ? null : id(allocation.inboundId), volumeCbm: numberValue(allocation.volumeCbm), totalVolumeCbm: numberValue(allocation.totalVolumeCbm), allocationRate: numberValue(allocation.allocationRate), allocatedAmount: numberValue(allocation.allocatedAmount), roundingAdjustment: Boolean(allocation.roundingAdjustment) } + }), } - return { id: costId, containerId: detail.cost.containerId == null ? null : id(detail.cost.containerId), supplierName: detail.cost.supplierName == null ? null : String(detail.cost.supplierName), feeItemName: String(detail.cost.feeItemName || ''), amount: numberValue(detail.cost.payableAmount), allocations: (detail.allocations || []).map((item) => ({ id: id(item.id), orderId: id(item.orderId), inboundId: item.inboundId == null ? null : id(item.inboundId), volumeCbm: numberValue(item.volumeCbm), totalVolumeCbm: numberValue(item.totalVolumeCbm), allocationRate: numberValue(item.allocationRate), allocatedAmount: numberValue(item.allocatedAmount), roundingAdjustment: Boolean(item.roundingAdjustment) })) } - })) + }) + return { customerId, containerId, settledSourceFeeIds: (Array.isArray(raw.settledSourceFeeIds) ? raw.settledSourceFeeIds : []).map(id), costs } } diff --git a/src/modules/loading-lists/components/ContainerFeesPanel.test.ts b/src/modules/loading-lists/components/ContainerFeesPanel.test.ts index ec0018c4..2023ee3b 100644 --- a/src/modules/loading-lists/components/ContainerFeesPanel.test.ts +++ b/src/modules/loading-lists/components/ContainerFeesPanel.test.ts @@ -6,17 +6,13 @@ import { readFileSync } from 'node:fs' import { resolve } from 'node:path' import { useAccessPreviewStore } from '@/core/access/accessPreview' import { useAuthStore } from '@/core/auth/auth' -import { getFinanceSettlement, listFinancePayableSettlements, listFinanceReceivableSettlements } from '@/modules/finance' -import { adjustContainerFee, createContainerFee, listApplicableFeeItems, listContainerCostDetails, listContainerFeeSupplierOptions, listContainerFees, uploadContainerFeePaymentVoucher } from '../api/containerFees' +import { adjustContainerFee, createContainerFee, getContainerSettlementDetails, listApplicableFeeItems, listContainerFeeSupplierOptions, listContainerFees, uploadContainerFeePaymentVoucher } from '../api/containerFees' import ContainerFeesPanel from './ContainerFeesPanel.vue' vi.mock('../api/containerFees', async (importOriginal) => { const actual = await importOriginal() - return { ...actual, listContainerFees: vi.fn(), listApplicableFeeItems: vi.fn(), listContainerFeeSupplierOptions: vi.fn(), listContainerCostDetails: vi.fn(), createContainerFee: vi.fn(), adjustContainerFee: vi.fn(), uploadContainerFeePaymentVoucher: vi.fn(), approveContainerFee: vi.fn(), rejectContainerFee: vi.fn(), voidContainerFee: vi.fn() } + return { ...actual, listContainerFees: vi.fn(), listApplicableFeeItems: vi.fn(), listContainerFeeSupplierOptions: vi.fn(), getContainerSettlementDetails: vi.fn(), createContainerFee: vi.fn(), adjustContainerFee: vi.fn(), uploadContainerFeePaymentVoucher: vi.fn(), approveContainerFee: vi.fn(), rejectContainerFee: vi.fn(), voidContainerFee: vi.fn() } }) -vi.mock('@/modules/finance', () => ({ - getFinanceSettlement: vi.fn(), listFinancePayableSettlements: vi.fn(), listFinanceReceivableSettlements: vi.fn(), -})) const panel = readFileSync(resolve(process.cwd(), 'src/modules/loading-lists/components/ContainerFeesPanel.vue'), 'utf8') const detail = readFileSync(resolve(process.cwd(), 'src/modules/loading-lists/components/LoadingListDetailDrawer.vue'), 'utf8') @@ -35,11 +31,8 @@ beforeEach(() => { vi.stubGlobal('ResizeObserver', ResizeObserverStub); vi.stubGlobal('localStorage', memoryStorage()); vi.stubGlobal('sessionStorage', memoryStorage()) vi.mocked(listContainerFees).mockReset().mockResolvedValue([]); vi.mocked(listApplicableFeeItems).mockReset().mockResolvedValue([]) vi.mocked(listContainerFeeSupplierOptions).mockReset().mockResolvedValue([]) - vi.mocked(listContainerCostDetails).mockReset().mockResolvedValue([]); vi.mocked(createContainerFee).mockReset() + vi.mocked(getContainerSettlementDetails).mockReset().mockResolvedValue({ customerId: '11', containerId: '99', settledSourceFeeIds: [], costs: [] }); vi.mocked(createContainerFee).mockReset() vi.mocked(adjustContainerFee).mockReset().mockResolvedValue({} as never) - vi.mocked(getFinanceSettlement).mockReset().mockResolvedValue({} as never) - vi.mocked(listFinancePayableSettlements).mockReset().mockResolvedValue([]) - vi.mocked(listFinanceReceivableSettlements).mockReset().mockResolvedValue([]) vi.mocked(uploadContainerFeePaymentVoucher).mockReset().mockResolvedValue({ fileId: '9001', fileName: '付款凭证.pdf' }) document.body.innerHTML = '' }) @@ -84,7 +77,7 @@ describe('ContainerFeesPanel contract', () => { }) it('shows only server allocations and supplier snapshots', () => { - expect(panel).toContain('listContainerCostDetails') + expect(panel).toContain('getContainerSettlementDetails') expect(panel).toContain('仅展示 Finance 已持久化结果') expect(panel).toContain("cost.supplierName || '服务端未返回'") expect(panel).toContain('尚无与该柜次关联的财务分摊结果') @@ -108,29 +101,25 @@ describe('ContainerFeesPanel contract', () => { resolveFirst([{ id: 'old', loadingPlanId: '9', containerBookingId: '99', containerNo: null, feeItemId: '1', feeItemName: '旧客户费用', 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 flushPromises() expect(state.fees).toEqual([]) - expect(listContainerCostDetails).not.toHaveBeenCalledWith('11', expect.anything()) + expect(getContainerSettlementDetails).not.toHaveBeenCalledWith('11', expect.anything()) }) it('does not request any Finance data when container fees are empty', async () => { vi.mocked(listContainerFees).mockResolvedValue([]) - 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() + await mountPanel(['firstleg:inbound:fee-detail:view']) + expect(getContainerSettlementDetails).not.toHaveBeenCalled() }) 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 }, ]) - const wrapper = await mountPanel(['firstleg:inbound:fee-detail:view', 'platform:finance:settlement-ar:list']) - expect(listContainerCostDetails).not.toHaveBeenCalled() - expect(listFinanceReceivableSettlements).not.toHaveBeenCalled() + const wrapper = await mountPanel(['firstleg:inbound:fee-detail:view']) + expect(getContainerSettlementDetails).not.toHaveBeenCalled() await wrapper.findAll('button').find((button) => button.text() === '加载财务状态')!.trigger('click') await flushPromises() - expect(listContainerCostDetails).toHaveBeenCalledWith('11', '99') - expect(listFinanceReceivableSettlements).toHaveBeenCalledTimes(1) + expect(getContainerSettlementDetails).toHaveBeenCalledWith('11', '99') + expect(getContainerSettlementDetails).toHaveBeenCalledTimes(1) }) it('fails closed when fee rows disagree with the summary container', async () => { @@ -141,7 +130,7 @@ describe('ContainerFeesPanel contract', () => { await wrapper.findAll('button').find((button) => button.text() === '加载财务状态')!.trigger('click') await flushPromises() expect(wrapper.text()).toContain('财务状态加载失败') - expect(listContainerCostDetails).not.toHaveBeenCalled() + expect(getContainerSettlementDetails).not.toHaveBeenCalled() }) it('blocks write entry points without a selected customer or edit permission', async () => { @@ -165,17 +154,15 @@ describe('ContainerFeesPanel contract', () => { vi.mocked(listContainerFees).mockResolvedValue([ { id: '1', loadingPlanId: '9', containerBookingId: '99', containerNo: null, feeItemId: '1', feeItemName: '海运费', direction: 'RECEIVABLE', supplierId: null, supplierName: null, supplierCategory: null, businessDate: '2026-08-12', amount: 300, currency: 'CNY', reviewStatus: 'APPROVED', sourceStatus: 'ACTIVE', sourceVersion: 1, remark: null, reviewerName: null, reviewedAt: null, reviewReason: null }, ]) - vi.mocked(listFinanceReceivableSettlements).mockResolvedValue([{ id: 'ar-1', status: 'PAID', paymentStatus: 'PAID' } as never]) - vi.mocked(getFinanceSettlement).mockResolvedValue({ lines: [{ sourceType: 'CONTAINER_FEE', sourceFeeId: '1' }] } as never) + vi.mocked(getContainerSettlementDetails).mockResolvedValue({ customerId: '11', containerId: '99', settledSourceFeeIds: ['1'], costs: [] }) const wrapper = await mountPanel([ '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') + expect(getContainerSettlementDetails).toHaveBeenCalledTimes(1) }) it('hides adjustment and void actions for directly paid payable fees', async () => { diff --git a/src/modules/loading-lists/components/ContainerFeesPanel.vue b/src/modules/loading-lists/components/ContainerFeesPanel.vue index c5f12a94..325a2291 100644 --- a/src/modules/loading-lists/components/ContainerFeesPanel.vue +++ b/src/modules/loading-lists/components/ContainerFeesPanel.vue @@ -9,8 +9,7 @@ import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.v import LoadErrorState from '@/shared/components/LoadErrorState.vue' import StatusBadge from '@/shared/components/StatusBadge.vue' import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' -import { getFinanceSettlement, listFinancePayableSettlements, listFinanceReceivableSettlements } from '@/modules/finance' -import { adjustContainerFee, adjustContainerFeeAllocationGroup, approveContainerFee, approveContainerFeeAllocationGroup, createContainerFee, createContainerFeeAllocationGroup, listApplicableFeeItems, listContainerCostDetails, listContainerFeeSupplierOptions, listContainerFees, rejectContainerFee, rejectContainerFeeAllocationGroup, uploadContainerFeePaymentVoucher, voidContainerFee, voidContainerFeeAllocationGroup, type ApplicableFeeItem, type ContainerCostDetail, type ContainerFeeAllocation, type ContainerFeeSupplierOption, type ContainerFeeResource, type ContainerFeeSaveRequest } from '../api/containerFees' +import { adjustContainerFee, adjustContainerFeeAllocationGroup, approveContainerFee, approveContainerFeeAllocationGroup, createContainerFee, createContainerFeeAllocationGroup, getContainerSettlementDetails, listApplicableFeeItems, listContainerFeeSupplierOptions, listContainerFees, rejectContainerFee, rejectContainerFeeAllocationGroup, uploadContainerFeePaymentVoucher, voidContainerFee, voidContainerFeeAllocationGroup, type ApplicableFeeItem, type ContainerCostDetail, type ContainerFeeAllocation, type ContainerFeeSupplierOption, type ContainerFeeResource, type ContainerFeeSaveRequest } from '../api/containerFees' const props = defineProps<{ planId: string @@ -203,10 +202,13 @@ async function loadFinanceDetails() { 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) : [] + const aggregate = await getContainerSettlementDetails(requestedAccountId, props.containerBookingId) if (sequence !== loadSequence || requestedAccountId !== accountId.value) return + settledFeeIds.value = new Set([ + ...feeRows.filter((item) => item.settlementMode === 'DIRECT_PAID').map((item) => item.id), + ...aggregate.settledSourceFeeIds, + ]) + costs.value = aggregate.costs financeLoaded.value = true } catch (cause) { if (sequence === loadSequence && requestedAccountId === accountId.value) financeError.value = getApiErrorDetails(cause, '财务状态加载失败') @@ -215,28 +217,6 @@ async function loadFinanceDetails() { } } -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 - const listRequests: Array> | Awaited>>> = [] - if (access.can('platform:finance:settlement-ar:list')) listRequests.push(listFinanceReceivableSettlements(customerId)) - if (access.can('platform:finance:settlement-ap:list')) listRequests.push(listFinancePayableSettlements(customerId)) - if (!listRequests.length) return - - const settlementResults = await Promise.allSettled(listRequests) - const paidSettlements = settlementResults.flatMap((result) => result.status === 'fulfilled' - ? result.value.filter((item) => item.status === 'PAID' || item.paymentStatus === 'PAID') - : []) - const detailResults = await Promise.allSettled(paidSettlements.map((item) => getFinanceSettlement(item.id))) - for (const result of detailResults) { - if (result.status !== 'fulfilled') continue - for (const line of result.value.lines) { - if (line.sourceType === 'CONTAINER_FEE' && line.sourceFeeId) settled.add(line.sourceFeeId) - } - } - if (sequence === loadSequence && customerId === accountId.value) settledFeeIds.value = settled -} - function openCreate() { if (!canEdit.value || !canViewFeeItems.value || !accountId.value) return; resetForm(); editorError.value = null; editorOpen.value = true; void loadFeeItems() } async function openAdjust(item: ContainerFeeResource) { if (!canEdit.value || !accountId.value) return