From 8a8c4fa1905f0942f4f5c7624344051fd7fe01ab Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 13 Aug 2026 12:22:41 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core/navigation/adminNavigation.ts | 10 +- .../api/platformContainerLoading.test.ts | 10 ++ .../api/platformContainerLoading.ts | 21 +++ .../CustomerOperatingMetricCard.test.ts | 26 +++ .../CustomerOperatingMetricCard.vue | 41 +++++ .../pages/CustomerDetailView.test.ts | 62 +++++-- .../customers/pages/CustomerDetailView.vue | 93 ++++++---- .../customers/pages/CustomersView.test.ts | 4 +- src/modules/customers/routes.ts | 2 +- src/modules/finance/api/finance.test.ts | 15 +- src/modules/finance/api/finance.ts | 29 +++- src/modules/finance/index.ts | 1 + .../pages/FinanceCustomerAccountsView.test.ts | 52 +++++- .../pages/FinanceCustomerAccountsView.vue | 6 +- .../finance/pages/FinanceCustomerArapView.vue | 6 +- .../FinanceCustomerStatementsView.test.ts | 30 +++- .../pages/FinanceCustomerStatementsView.vue | 64 +++++-- .../pages/FinanceFormalSettlementPage.vue | 163 +++++++++++------- .../finance/pages/FinanceManualPage.vue | 2 +- .../finance/pages/FinancePresentation.test.ts | 16 +- src/modules/finance/routes.ts | 18 +- src/modules/finance/types.ts | 4 + .../api/platformFirstLegOrders.test.ts | 10 ++ .../api/platformFirstLegOrders.ts | 18 ++ src/modules/first-leg-orders/index.ts | 2 + .../pages/MyMessagesView.test.ts | 15 ++ 26 files changed, 572 insertions(+), 148 deletions(-) create mode 100644 src/modules/customers/components/CustomerOperatingMetricCard.test.ts create mode 100644 src/modules/customers/components/CustomerOperatingMetricCard.vue create mode 100644 src/modules/notifications/pages/MyMessagesView.test.ts diff --git a/src/core/navigation/adminNavigation.ts b/src/core/navigation/adminNavigation.ts index 9bcb7cfc..789de5e9 100644 --- a/src/core/navigation/adminNavigation.ts +++ b/src/core/navigation/adminNavigation.ts @@ -140,11 +140,11 @@ export const adminNavigation: AdminNavigationNode[] = [ title: '财务管理', icon: PrototypeFinanceIcon, children: [ - { key: 'finance-customer-accounts', title: '客户账户', icon: CircleDollarSign, path: '/finance/customer-accounts', permission: 'platform:finance:customer-account:list', permissionAnyOf: ['platform:finance:account:view'] }, - { key: 'finance-customer-arap', title: '客户应收应付', icon: Database, path: '/finance/customer-arap', permission: 'platform:finance:customer-arap:list', permissionAnyOf: ['platform:finance:bill:view'] }, - { key: 'finance-customer-statements', title: '客户对账单', icon: FileCheck2, path: '/finance/customer-statements', permission: 'platform:finance:customer-statement:list', permissionAnyOf: ['platform:finance:bill:view'] }, - { key: 'finance-settlement-receivables', title: '结算单应收', icon: CircleDollarSign, path: '/finance/settlement-receivables', permission: 'platform:finance:settlement-ar:list', permissionAnyOf: ['platform:finance:settlement-ar:view'] }, - { key: 'finance-settlement-payables', title: '结算单应付', icon: Database, path: '/finance/settlement-payables', permission: 'platform:finance:settlement-ap:list', permissionAnyOf: ['platform:finance:settlement-ap:view'] }, + { key: 'finance-customer-accounts', title: '客户账户', icon: CircleDollarSign, path: '/finance/customer-accounts', permission: 'platform:finance:customer-account:list' }, + { key: 'finance-customer-arap', title: '客户应收应付', icon: Database, path: '/finance/customer-arap', permission: 'platform:finance:customer-arap:list' }, + { key: 'finance-customer-statements', title: '客户对账单', icon: FileCheck2, path: '/finance/customer-statements', permission: 'platform:finance:customer-statement:list' }, + { key: 'finance-settlement-receivables', title: '结算单应收', icon: CircleDollarSign, path: '/finance/settlement-receivables', permission: 'platform:finance:settlement-ar:list' }, + { key: 'finance-settlement-payables', title: '结算单应付', icon: Database, path: '/finance/settlement-payables', permission: 'platform:finance:settlement-ap:list' }, ], }, { diff --git a/src/modules/container-loading/api/platformContainerLoading.test.ts b/src/modules/container-loading/api/platformContainerLoading.test.ts index a6a60432..cd7557fd 100644 --- a/src/modules/container-loading/api/platformContainerLoading.test.ts +++ b/src/modules/container-loading/api/platformContainerLoading.test.ts @@ -5,6 +5,7 @@ import { listContainers, listSmartLoadingCandidates, pushSmartLoadingPreload, removeSmartLoadingItem, getContainerClearance, uploadContainerClearance, completeContainerClearance, getContainerOverview, markContainerException, cancelContainerException, uploadContainerDocument, downloadContainerDocument, downloadContainerPdaPhoto, + getCustomerContainerTransitSummary, updateContainer, updateContainerTransport, } from './platformContainerLoading' @@ -19,6 +20,15 @@ const plan = { id: 9007199254740993, bookingId: 9007199254740994, version: '3', describe('platform container loading API', () => { beforeEach(() => vi.clearAllMocks()) + it('uses the customer transit summary and preserves fractional TEU', async () => { + vi.mocked(http.get).mockResolvedValueOnce({ code: '200', data: { customerId: '9007199254740993', containerCount: 2, totalTeu: '2.25', unknownContainerCount: 0, asOf: '2026-08-13T12:00:00', definition: '已开船未到仓' } }) + + const result = await getCustomerContainerTransitSummary('9007199254740993') + + expect(http.get).toHaveBeenCalledWith('/platform/v1/customers/9007199254740993/container-transit-summary') + expect(result).toMatchObject({ customerId: '9007199254740993', containerCount: 2, totalTeu: '2.25', unknownContainerCount: 0 }) + }) + it('normalizes paged container ids and numeric metadata', async () => { vi.mocked(http.get).mockResolvedValueOnce({ code: '200', data: { records: [container], page: '1', size: '20', total: '1', pages: '1' } }) const result = await listContainers({ page: 1, size: 20 }) diff --git a/src/modules/container-loading/api/platformContainerLoading.ts b/src/modules/container-loading/api/platformContainerLoading.ts index e21c0497..58c056b8 100644 --- a/src/modules/container-loading/api/platformContainerLoading.ts +++ b/src/modules/container-loading/api/platformContainerLoading.ts @@ -1,4 +1,13 @@ import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' + +export interface CustomerContainerTransitSummary { + customerId: string + containerCount: number + totalTeu: number | string + unknownContainerCount: number + asOf: string + definition: string +} import type { ContainerBookingResource, ContainerPage, @@ -90,6 +99,18 @@ export async function listContainers(query: ContainerQuery): Promise { + const response = await http.get>, ApiEnvelope>>( + `/platform/v1/customers/${encodeURIComponent(customerId)}/container-transit-summary`, + ) + const result = unwrapApiResponse(response) + return { + customerId: id(result.customerId), containerCount: numberValue(result.containerCount), + totalTeu: String(result.totalTeu ?? '0'), unknownContainerCount: numberValue(result.unknownContainerCount), + asOf: id(result.asOf), definition: id(result.definition), + } +} + export async function getContainer(containerId: string) { const response = await http.get, ApiEnvelope>(`${containerPath}/${encodeURIComponent(containerId)}`) return container(unwrapApiResponse(response)) diff --git a/src/modules/customers/components/CustomerOperatingMetricCard.test.ts b/src/modules/customers/components/CustomerOperatingMetricCard.test.ts new file mode 100644 index 00000000..6f67c2d5 --- /dev/null +++ b/src/modules/customers/components/CustomerOperatingMetricCard.test.ts @@ -0,0 +1,26 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import CustomerOperatingMetricCard from './CustomerOperatingMetricCard.vue' + +describe('CustomerOperatingMetricCard', () => { + const props = { title: '累计应收', value: '12.3456', unit: '万元', detail: '未结清 0.125 万元', tone: 'green' as const } + + it('keeps the supplied decimal precision', () => { + const wrapper = mount(CustomerOperatingMetricCard, { props }) + expect(wrapper.text()).toContain('12.3456万元') + expect(wrapper.text()).toContain('未结清 0.125 万元') + }) + + it('shows a dash instead of zero for failures', () => { + const wrapper = mount(CustomerOperatingMetricCard, { props: { ...props, failed: true } }) + expect(wrapper.text()).toContain('—') + expect(wrapper.text()).toContain('加载失败') + expect(wrapper.text()).not.toContain('12.3456') + }) + + it('shows the permission state without exposing a value', () => { + const wrapper = mount(CustomerOperatingMetricCard, { props: { ...props, unauthorized: true } }) + expect(wrapper.text()).toContain('暂无查看权限') + expect(wrapper.text()).not.toContain('12.3456') + }) +}) diff --git a/src/modules/customers/components/CustomerOperatingMetricCard.vue b/src/modules/customers/components/CustomerOperatingMetricCard.vue new file mode 100644 index 00000000..b25f5029 --- /dev/null +++ b/src/modules/customers/components/CustomerOperatingMetricCard.vue @@ -0,0 +1,41 @@ + + + + + diff --git a/src/modules/customers/pages/CustomerDetailView.test.ts b/src/modules/customers/pages/CustomerDetailView.test.ts index 6cbab4ad..c0e1298a 100644 --- a/src/modules/customers/pages/CustomerDetailView.test.ts +++ b/src/modules/customers/pages/CustomerDetailView.test.ts @@ -6,11 +6,12 @@ import { downloadTenantQualification, getPlatformTenant, listPlatformTenantMembers, - listPlatformTenantOrderCounts, listTenantQualifications, type PlatformTenantResource, } from '@/modules/iam' -import { listPlatformFirstLegOrders, type PlatformFirstLegOrderResource } from '@/modules/first-leg-orders' +import { getPlatformTenantOrderStatistics, listPlatformFirstLegOrders, type PlatformFirstLegOrderResource } from '@/modules/first-leg-orders' +import { getFinanceCustomerAccountSummary } from '@/modules/finance' +import { getCustomerContainerTransitSummary } from '@/modules/container-loading' import { useAccessPreviewStore } from '@/core/access/accessPreview' import { useAuthStore } from '@/core/auth/auth' import CustomerDetailView from './CustomerDetailView.vue' @@ -36,15 +37,22 @@ vi.mock('@/modules/iam', () => ({ downloadTenantQualification: vi.fn(), getPlatformTenant: vi.fn(), listPlatformTenantMembers: vi.fn(), - listPlatformTenantOrderCounts: vi.fn(), listTenantQualifications: vi.fn(), qualificationFileName: vi.fn((documentUrl: string) => documentUrl.split('/').at(-1) || '资质文件'), })) vi.mock('@/modules/first-leg-orders', () => ({ + getPlatformTenantOrderStatistics: vi.fn(), listPlatformFirstLegOrders: vi.fn(), })) +vi.mock('@/modules/finance', () => ({ + CustomerStatementsPanel: { template: '
财务账单
' }, + getFinanceCustomerAccountSummary: vi.fn(), +})) + +vi.mock('@/modules/container-loading', () => ({ getCustomerContainerTransitSummary: vi.fn() })) + class ResizeObserverStub implements ResizeObserver { observe() {} unobserve() {} @@ -77,7 +85,7 @@ describe('CustomerDetailView', () => { setActivePinia(pinia) const auth = useAuthStore() auth.token = 'token' - auth.permissions = ['platform:customer:view', 'platform:user:list', 'platform:order:list'] + auth.permissions = ['platform:customer:list', 'platform:user:list', 'platform:order:list', 'platform:finance:customer-account:list', 'platform:firstleg:container:list'] useAccessPreviewStore().initializeAfterLogin() vi.mocked(getPlatformTenant).mockResolvedValue(tenant) vi.mocked(listTenantQualifications).mockResolvedValue([{ @@ -88,7 +96,9 @@ describe('CustomerDetailView', () => { membershipId: 'member-1', userId: 'user-1', authUserId: 'auth-1', username: 'merchant_admin', displayName: '张三', phone: '13800138000', email: 'ops@example.com', memberType: 'OWNER', memberNo: null, status: 'ACTIVE', roleIds: [], roleNames: ['企业管理员'], }]) - vi.mocked(listPlatformTenantOrderCounts).mockResolvedValue([{ tenantId: tenant.id, orderCount: 12 }]) + vi.mocked(getPlatformTenantOrderStatistics).mockResolvedValue({ tenantId: String(tenant.id), totalSubmittedOrders: 12, currentMonthSubmittedOrders: 3, monthStart: '2026-08-01T00:00:00+08:00', nextMonthStart: '2026-09-01T00:00:00+08:00', zoneId: 'Asia/Shanghai', asOf: '2026-08-13T12:00:00+08:00' }) + vi.mocked(getFinanceCustomerAccountSummary).mockResolvedValue({ customerId: String(tenant.id), currency: 'CNY', totalReceivable: '123456.78', receivedAmount: '100000', outstandingReceivable: '23456.78', pendingBilling: '0', outstandingPayable: '0', asOf: '2026-08-13T04:00:00Z', lastChangedAt: null }) + vi.mocked(getCustomerContainerTransitSummary).mockResolvedValue({ customerId: String(tenant.id), containerCount: 2, totalTeu: '2.25', unknownContainerCount: 0, asOf: '2026-08-13T12:00:00', definition: '已开船未到仓' }) vi.mocked(listPlatformFirstLegOrders).mockResolvedValue({ records: [{ id: 'order-1', tenantId: tenant.id, orderNo: 'BY20260807001', customerId: null, customerName: tenant.tenantName, @@ -103,20 +113,25 @@ describe('CustomerDetailView', () => { vi.mocked(downloadTenantQualification).mockResolvedValue(new Blob(['image'], { type: 'image/png' })) }) - it('renders enterprise users and includes the owner in customer statistics', async () => { + it('renders three operating metrics and keeps enterprise users in detail content', async () => { mount(CustomerDetailView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } }) await flushPromises() expect([...document.querySelectorAll('.detail-tabs button')].map((button) => button.getAttribute('aria-label'))).toEqual([ '基本信息', '业务概况', '运单记录', '财务账单', '计费规则', '操作日志', ]) - expect(document.body.textContent).toContain('企业用户1人') - expect(document.body.textContent).toContain('1 人正常使用') + expect(document.body.textContent).toContain('累计运单12票本月 3 票') + expect(document.body.textContent).toContain('累计应收12.345678万元未结清 2.345678 万元') + expect(document.body.textContent).toContain('在途柜量2.25TEU2 柜') + expect([...document.querySelectorAll('.overview-strip article')]).toHaveLength(3) + expect(document.querySelector('.overview-strip')?.textContent).not.toContain('企业用户') expect(document.body.textContent).toContain('merchant_admin') expect(document.body.textContent).toContain('企业管理员') expect(listPlatformTenantMembers).toHaveBeenCalledWith(tenant.id) expect(document.body.textContent).toContain('已替换') - expect(document.body.textContent).toContain('12票') + expect(getPlatformTenantOrderStatistics).toHaveBeenCalledWith(tenant.id) + expect(getFinanceCustomerAccountSummary).toHaveBeenCalledWith(tenant.id) + expect(getCustomerContainerTransitSummary).toHaveBeenCalledWith(tenant.id) const ordersTab = document.querySelector('.detail-tabs button[aria-label="运单记录"]') ordersTab?.click() @@ -190,7 +205,7 @@ describe('CustomerDetailView', () => { vi.mocked(getPlatformTenant).mockImplementation((tenantId) => tenantId === '8' ? firstTenantRequest : Promise.resolve(secondTenant)) vi.mocked(listTenantQualifications).mockResolvedValue([]) vi.mocked(listPlatformTenantMembers).mockResolvedValue([]) - vi.mocked(listPlatformTenantOrderCounts).mockImplementation(async (tenantIds) => [{ tenantId: tenantIds[0], orderCount: 0 }]) + vi.mocked(getPlatformTenantOrderStatistics).mockImplementation(async (tenantId) => ({ tenantId, totalSubmittedOrders: 0, currentMonthSubmittedOrders: 0, monthStart: '2026-08-01', nextMonthStart: '2026-09-01', zoneId: 'Asia/Shanghai', asOf: '2026-08-13' })) vi.mocked(listPlatformFirstLegOrders).mockResolvedValue({ records: [], page: 1, size: 10, total: 0, pages: 0 }) mount(CustomerDetailView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } }) @@ -206,4 +221,31 @@ describe('CustomerDetailView', () => { expect(document.body.textContent).toContain('远航供应链') expect(document.body.textContent).not.toContain('柏悦物流') }) + + it('does not request metrics without their independent permissions', async () => { + const auth = useAuthStore() + auth.permissions = ['platform:customer:list', 'platform:user:list'] + useAccessPreviewStore().initializeAfterLogin() + + mount(CustomerDetailView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } }) + await flushPromises() + + expect(getPlatformTenantOrderStatistics).not.toHaveBeenCalled() + expect(getFinanceCustomerAccountSummary).not.toHaveBeenCalled() + expect(getCustomerContainerTransitSummary).not.toHaveBeenCalled() + expect(document.querySelector('.overview-strip')?.textContent?.match(/暂无查看权限/g)).toHaveLength(3) + }) + + it('keeps metric failures independent and never renders them as zero', async () => { + vi.mocked(getFinanceCustomerAccountSummary).mockRejectedValueOnce(new Error('finance unavailable')) + + mount(CustomerDetailView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } }) + await flushPromises() + + const cards = [...document.querySelectorAll('.overview-strip article')] + expect(cards[0]?.textContent).toContain('12票') + expect(cards[1]?.textContent).toContain('—加载失败') + expect(cards[1]?.textContent).not.toContain('0万元') + expect(cards[2]?.textContent).toContain('2.25TEU') + }) }) diff --git a/src/modules/customers/pages/CustomerDetailView.vue b/src/modules/customers/pages/CustomerDetailView.vue index 7dc32925..9cb17c11 100644 --- a/src/modules/customers/pages/CustomerDetailView.vue +++ b/src/modules/customers/pages/CustomerDetailView.vue @@ -3,7 +3,7 @@ import { computed, onBeforeUnmount, ref, watch } from 'vue' import { useRoute, useRouter } from 'vue-router' import { ArrowLeft, BarChart3, Building2, Eye, - FileCheck2, FileText, Pencil, Power, + CircleDollarSign, Container, FileCheck2, FileText, Pencil, Power, Ship, UserRound, UsersRound, } from '@lucide/vue' import { ElMessage, ElMessageBox } from 'element-plus' @@ -12,7 +12,6 @@ import { downloadTenantQualification, getPlatformTenant, listPlatformTenantMembers, - listPlatformTenantOrderCounts, listTenantQualifications, qualificationFileName, type PlatformTenantQualification, @@ -21,10 +20,13 @@ import { type PlatformTenantStatus, } from '@/modules/iam' import { + getPlatformTenantOrderStatistics, listPlatformFirstLegOrders, type PlatformFirstLegOrderResource, type PlatformFirstLegOrderStatus, } from '@/modules/first-leg-orders' +import { getFinanceCustomerAccountSummary } from '@/modules/finance' +import { getCustomerContainerTransitSummary } from '@/modules/container-loading' import { getApiErrorDetails, getApiErrorMessage, type ApiErrorDetails } from '@/shared/api/http' import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue' import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue' @@ -39,9 +41,11 @@ import CustomerBillingRulesPanel from '@/modules/customers/components/CustomerBi import CustomerOverseasStorageFeePanel from '@/modules/customers/CustomerOverseasStorageFeePanel.vue' import { CustomerStatementsPanel } from '@/modules/finance' import { CustomerAuditLogsPanel } from '@/modules/audit' +import CustomerOperatingMetricCard from '@/modules/customers/components/CustomerOperatingMetricCard.vue' type DetailTab = 'basic' | 'business' | 'orders' | 'statements' | 'billing' | 'logs' type PreviewState = { url: string; kind: 'image' | 'pdf'; loading: boolean; failed: boolean } +type MetricState = { loading: boolean; failed: boolean; data: T | null } const route = useRoute() const router = useRouter() @@ -54,7 +58,9 @@ const loadError = ref(null) const customer = ref(null) const qualifications = ref([]) const members = ref([]) -const orderCount = ref(null) +const orderStatistics = ref>>>({ loading: false, failed: false, data: null }) +const financeStatistics = ref>>>({ loading: false, failed: false, data: null }) +const transitStatistics = ref>>>({ loading: false, failed: false, data: null }) const customerOrders = ref([]) const customerOrderTotal = ref(0) const orderPage = ref(1) @@ -81,9 +87,7 @@ const tabs: Array<{ key: DetailTab; label: string; icon: string }> = [ { key: 'logs', label: '操作日志', icon: '📝' }, ] -const approvedQualifications = computed(() => qualifications.value.filter((item) => item.status === 'APPROVED').length) const enterpriseMembers = computed(() => members.value.filter((member) => ['OWNER', 'EMPLOYEE'].includes(member.memberType))) -const activeEnterpriseMembers = computed(() => enterpriseMembers.value.filter((member) => member.status === 'ACTIVE').length) const orderColumns: DataTableColumn[] = [ { key: 'actions', label: '操作', width: 92, fixed: 'left', slotName: 'actions' }, { key: 'status', label: '状态', width: 106, slotName: 'status' }, @@ -134,6 +138,25 @@ function transportLabel(order: PlatformFirstLegOrderResource) { return '陆运' } function formattedDate(value?: string | null) { return value ? value.replace('T', ' ').slice(0, 16) : '—' } +function decimalText(value: number | string | null | undefined) { + const raw = String(value ?? '').trim() + if (!/^-?\d+(\.\d+)?$/.test(raw)) return '—' + const normalized = raw.replace(/^(-?)0+(?=\d)/, '$1').replace(/\.0+$/, '').replace(/(\.\d*?)0+$/, '$1') + return normalized === '-0' ? '0' : normalized +} +function cnyWan(value: number | string | null | undefined) { + const raw = decimalText(value) + if (raw === '—') return raw + const negative = raw.startsWith('-') + const [integer, fraction = ''] = (negative ? raw.slice(1) : raw).split('.') + const digits = `${integer}${fraction}`.replace(/^0+(?=\d)/, '') || '0' + const scale = fraction.length + 4 + const padded = digits.padStart(scale + 1, '0') + const whole = padded.slice(0, -scale) || '0' + const decimals = padded.slice(-scale).replace(/0+$/, '') + const result = `${negative && digits !== '0' ? '-' : ''}${whole}${decimals ? `.${decimals}` : ''}` + return result +} async function loadCustomerOrders(page = orderPage.value, requestedTenantId = tenantId.value) { if (!access.can('platform:order:list')) return @@ -145,7 +168,6 @@ async function loadCustomerOrders(page = orderPage.value, requestedTenantId = te if (disposed || serial !== orderRequestSerial || requestedTenantId !== tenantId.value) return customerOrders.value = result.records customerOrderTotal.value = result.total - if (orderCount.value === null) orderCount.value = result.total } catch (error) { if (disposed || serial !== orderRequestSerial || requestedTenantId !== tenantId.value) return customerOrders.value = [] @@ -155,6 +177,30 @@ async function loadCustomerOrders(page = orderPage.value, requestedTenantId = te } } +async function loadOperatingStatistics(requestedTenantId: string, serial: number) { + const tasks: Promise[] = [] + const active = () => !disposed && serial === detailRequestSerial && requestedTenantId === tenantId.value + if (access.can('platform:order:list')) { + orderStatistics.value = { loading: true, failed: false, data: null } + tasks.push(getPlatformTenantOrderStatistics(requestedTenantId).then((data) => { + if (active()) orderStatistics.value = { loading: false, failed: false, data } + }).catch(() => { if (active()) orderStatistics.value = { loading: false, failed: true, data: null } })) + } + if (access.can('platform:finance:customer-account:list')) { + financeStatistics.value = { loading: true, failed: false, data: null } + tasks.push(getFinanceCustomerAccountSummary(requestedTenantId).then((data) => { + if (active()) financeStatistics.value = { loading: false, failed: false, data } + }).catch(() => { if (active()) financeStatistics.value = { loading: false, failed: true, data: null } })) + } + if (access.can('platform:firstleg:container:list')) { + transitStatistics.value = { loading: true, failed: false, data: null } + tasks.push(getCustomerContainerTransitSummary(requestedTenantId).then((data) => { + if (active()) transitStatistics.value = { loading: false, failed: false, data } + }).catch(() => { if (active()) transitStatistics.value = { loading: false, failed: true, data: null } })) + } + await Promise.all(tasks) +} + function clearPreviews() { for (const preview of Object.values(previews.value)) if (preview.url) URL.revokeObjectURL(preview.url) previews.value = {} @@ -200,7 +246,9 @@ async function loadDetail() { customer.value = null qualifications.value = [] members.value = [] - orderCount.value = null + orderStatistics.value = { loading: false, failed: false, data: null } + financeStatistics.value = { loading: false, failed: false, data: null } + transitStatistics.value = { loading: false, failed: false, data: null } customerOrders.value = [] customerOrderTotal.value = 0 customerOrdersError.value = null @@ -218,24 +266,17 @@ async function loadDetail() { url: '', kind: qualificationName(item).toLowerCase().endsWith('.pdf') ? 'pdf' : 'image', loading: false, failed: false, } satisfies PreviewState])) - const optional: Promise[] = [] + void loadOperatingStatistics(requestedTenantId, serial) if (access.can('platform:order:list')) { if (orderPage.value !== 1) skipNextOrderPageLoad = true orderPage.value = 1 - optional.push(listPlatformTenantOrderCounts([requestedTenantId]).then((records) => { - if (disposed || serial !== detailRequestSerial || requestedTenantId !== tenantId.value) return - orderCount.value = Number(records.find((item) => String(item.tenantId) === requestedTenantId)?.orderCount ?? 0) - }).catch(() => { - if (!disposed && serial === detailRequestSerial && requestedTenantId === tenantId.value) orderCount.value = null - })) - optional.push(loadCustomerOrders(1, requestedTenantId)) + void loadCustomerOrders(1, requestedTenantId) } else { customerOrdersLoading.value = false customerOrdersError.value = null customerOrders.value = [] customerOrderTotal.value = 0 } - await Promise.all(optional) } catch (error) { if (disposed || serial !== detailRequestSerial || requestedTenantId !== tenantId.value) return customer.value = null @@ -342,9 +383,9 @@ watch(orderPage, (page, previousPage) => { @@ -406,7 +447,6 @@ watch(orderPage, (page, previousPage) => {

业务概况

基于当前已接入服务的客户业务摘要

-
累计运单{{ orderCount ?? '—' }} 票
活跃企业用户{{ activeEnterpriseMembers }} 人
有效资质{{ approvedQualifications }} 份
趋势、港口与收入分析暂未接入

原型中的近 6 个月趋势、运输方式分布、目的港和收入贡献依赖后续统计与财务服务;当前不展示模拟图表。

@@ -450,13 +490,7 @@ watch(orderPage, (page, previousPage) => { .header-actions { display:flex; flex:0 0 auto; gap:8px; } .header-actions :deep(.el-button + .el-button) { margin-left:0; } .overview-strip { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); overflow:hidden; border:1px solid rgba(255,255,255,.5); border-radius:12px; background:rgba(255,255,255,.95); box-shadow:0 3px 12px rgba(15,23,42,.05); } -.overview-strip article { display:flex; min-width:0; align-items:center; gap:14px; padding:22px 24px; border-right:1px solid var(--cc-border); } -.overview-strip article:last-child { border-right:0; } -.metric-icon { display:grid; width:44px; height:44px; flex:0 0 auto; place-items:center; border-radius:12px; } -.metric-icon.is-blue { background:linear-gradient(135deg,rgba(102,126,234,.15),rgba(118,75,162,.1)); color:#667eea; }.metric-icon.is-green { background:linear-gradient(135deg,rgba(34,197,94,.15),rgba(22,163,74,.1)); color:#16a34a; }.metric-icon.is-amber { background:linear-gradient(135deg,rgba(245,158,11,.15),rgba(234,88,12,.1)); color:#ea580c; } -.overview-strip p,.overview-strip span { margin:0; color:var(--cc-muted); font-size:12px; } -.overview-strip strong { display:block; margin:5px 0 4px; color:#172033; font-size:26px; line-height:1; } -.overview-strip small { margin-left:5px; color:#475467; font-size:12px; font-weight:600; } +.overview-strip :deep(.operating-metric-card:last-child) { border-right:0; } .detail-tabs { display:flex; gap:0; overflow-x:auto; overflow-y:hidden; border-bottom:1px solid rgba(228,228,231,.5); border-radius:20px 20px 0 0; background:rgba(255,255,255,.95); box-shadow:0 -4px 16px rgba(0,0,0,.04); scrollbar-width:thin; } .detail-tabs button { display:inline-flex; min-width:max-content; min-height:0; flex:0 0 auto; align-items:center; justify-content:center; gap:8px; padding:16px 28px; border:0; border-bottom:3px solid transparent; background:transparent; color:#71717a; font-size:15px; font-weight:600; white-space:nowrap; cursor:pointer; transition:all .3s cubic-bezier(.4,0,.2,1); } .detail-tab-icon { font-size:18px; line-height:1; } @@ -484,10 +518,9 @@ table { width:100%; min-width:720px; border-collapse:collapse; font-size:12px; } .user-identity { display:flex; align-items:center; gap:9px; }.user-identity > span { display:grid; width:34px; height:34px; flex:0 0 auto; place-items:center; border-radius:50%; background:#eef1ff; color:var(--cc-primary); font-weight:700; }.user-identity strong,.user-identity small { display:block; }.user-identity small { margin-top:3px; color:var(--cc-muted); font-size:10px; }.role-list { display:flex; flex-wrap:wrap; gap:5px; } .member-contact span,.member-contact small { display:block; }.member-contact small { margin-top:3px; color:var(--cc-muted); font-size:10px; } .module-panel { min-height:360px; }.module-empty { min-height:260px; }.module-empty > svg { color:#8797b2; }.module-empty p { max-width:520px; font-size:12px; } -.business-summary { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); overflow:hidden; border:1px solid var(--cc-border); border-radius:12px; }.business-summary > div { display:grid; min-height:130px; place-items:center; align-content:center; gap:8px; border-right:1px solid var(--cc-border); }.business-summary > div:last-child { border-right:0; }.business-summary svg { color:var(--cc-primary); }.business-summary span { color:var(--cc-muted); font-size:11px; }.business-summary strong { color:#25324a; font-size:20px; } -.contract-note { margin-top:18px; padding:16px 18px; border-radius:10px; background:#f8fafc; }.contract-note strong { color:#344054; font-size:12px; }.contract-note p { margin:5px 0 0; color:var(--cc-muted); font-size:11px; line-height:1.7; } +.contract-note { padding:16px 18px; border-radius:10px; background:#f8fafc; }.contract-note strong { color:#344054; font-size:12px; }.contract-note p { margin:5px 0 0; color:var(--cc-muted); font-size:11px; line-height:1.7; } .qualification-preview { display:grid; min-height:320px; max-height:72vh; overflow:auto; place-items:center; border-radius:10px; background:#f8fafc; }.qualification-preview img { display:block; max-width:100%; height:auto; }.qualification-preview iframe { width:100%; min-height:68vh; border:0; background:#fff; } @media (max-width:960px) { .basic-layout { grid-template-columns:1fr; }.contact-panel,.user-panel { grid-column:auto; }.qualification-panel { order:3; }.user-panel { order:4; } } -@media (max-width:720px) { .detail-header { align-items:flex-start; flex-wrap:wrap; }.detail-page-toolbar { width:100%; flex-basis:100%; }.company-mark { width:40px; height:40px; }.header-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); width:100%; }.header-actions :deep(.el-tag) { grid-column:1 / -1; justify-self:start; }.header-actions :deep(.el-button) { min-height:44px; }.overview-strip { grid-template-columns:1fr; }.overview-strip article { border-right:0; border-bottom:1px solid var(--cc-border); }.overview-strip article:last-child { border-bottom:0; }.detail-panel { padding:16px; }.info-grid { grid-template-columns:1fr; }.info-grid > div.wide { grid-column:auto; }.business-summary { grid-template-columns:1fr; }.business-summary > div { min-height:100px; border-right:0; border-bottom:1px solid var(--cc-border); }.business-summary > div:last-child { border-bottom:0; } } +@media (max-width:720px) { .detail-header { align-items:flex-start; flex-wrap:wrap; }.detail-page-toolbar { width:100%; flex-basis:100%; }.company-mark { width:40px; height:40px; }.header-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); width:100%; }.header-actions :deep(.el-tag) { grid-column:1 / -1; justify-self:start; }.header-actions :deep(.el-button) { min-height:44px; }.overview-strip { grid-template-columns:1fr; }.overview-strip :deep(.operating-metric-card:last-child) { border-bottom:0; }.detail-panel { padding:16px; }.info-grid { grid-template-columns:1fr; }.info-grid > div.wide { grid-column:auto; } } @media (max-width:420px) { .company-mark { display:none; }.header-actions { grid-template-columns:1fr; }.header-actions :deep(.el-tag) { grid-column:auto; }.qualification-list button { grid-template-columns:36px minmax(0,1fr) auto; }.qualification-list .file-date,.qualification-list button > svg { display:none; } } diff --git a/src/modules/customers/pages/CustomersView.test.ts b/src/modules/customers/pages/CustomersView.test.ts index 1f10afbe..c279a189 100644 --- a/src/modules/customers/pages/CustomersView.test.ts +++ b/src/modules/customers/pages/CustomersView.test.ts @@ -130,7 +130,7 @@ describe('CustomersView', () => { setActivePinia(pinia) const auth = useAuthStore() auth.token = 'token' - auth.permissions = ['platform:customer:list', 'platform:customer:view', 'platform:customer:edit', 'platform:customer:export', 'platform:download:list'] + auth.permissions = ['platform:customer:list', 'platform:customer:edit', 'platform:customer:export', 'platform:download:list'] useAccessPreviewStore().initializeAfterLogin() vi.mocked(listPlatformTenants).mockResolvedValue({ records: [{ ...fullTenant, contactPhone: '138****8000', contactEmail: 'o***@example.com' }], @@ -339,7 +339,7 @@ describe('CustomersView', () => { it('hides customer export when the stable export permission is absent', async () => { const auth = useAuthStore() - auth.permissions = ['platform:customer:list', 'platform:customer:view'] + auth.permissions = ['platform:customer:list'] useAccessPreviewStore().initializeAfterLogin() mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } }) await flushPromises() diff --git a/src/modules/customers/routes.ts b/src/modules/customers/routes.ts index 492d7d4a..d80ab3f6 100644 --- a/src/modules/customers/routes.ts +++ b/src/modules/customers/routes.ts @@ -17,7 +17,7 @@ export const customerRoutes = [ path: 'customers/:tenantId', name: 'customer-detail', component: () => import('@/modules/customers/pages/CustomerDetailView.vue'), - meta: { title: '客户详情', description: '查看企业资料、关联用户和业务记录', permission: 'platform:customer:list', permissionAnyOf: ['platform:customer:view'], showPageHeading: false }, + meta: { title: '客户详情', description: '查看企业资料、关联用户和业务记录', permission: 'platform:customer:list', showPageHeading: false }, }, { path: 'customers/reviews', diff --git a/src/modules/finance/api/finance.test.ts b/src/modules/finance/api/finance.test.ts index 05877433..8c0f8972 100644 --- a/src/modules/finance/api/finance.test.ts +++ b/src/modules/finance/api/finance.test.ts @@ -1,12 +1,21 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { http } from '@/shared/api/http' -import { actionReconciliationStatement, adjustFinanceBill, createFinanceCost, createFinanceReceipt, createFinanceSettlementPayment, createPayableSettlement, createReceivableSettlement, createReconciliationStatement, getFinanceBill, getFinanceCustomerAccount, getFinanceCustomerTerms, getFinanceFeeSource, getFinanceSettlement, getReconciliationStatement, issueFinanceBill, listFinanceAccountLedger, listFinanceAccounts, listFinanceBillRevisions, listFinanceFeePool, listFinancePayableSettlements, listFinanceReceivableSettlements, listFinanceStatements, reviewFinanceFeeSource, updateFinanceBill, updateFinanceCustomerTerms, updateFinanceFeeSourceSettlementInclusion, uploadFinanceVoucher, voidFinanceBill } from './finance' +import { actionReconciliationStatement, adjustFinanceBill, createFinanceCost, createFinanceReceipt, createFinanceSettlementPayment, createPayableSettlement, createReceivableSettlement, createReconciliationStatement, getFinanceBill, getFinanceCustomerAccount, getFinanceCustomerAccountSummary, getFinanceCustomerTerms, getFinanceFeeSource, getFinanceSettlement, getReconciliationStatement, issueFinanceBill, listFinanceAccountLedger, listFinanceAccounts, listFinanceBillRevisions, listFinanceFeePool, listFinancePayableSettlements, listFinanceReceivableSettlements, listFinanceStatements, reviewFinanceFeeSource, updateFinanceBill, updateFinanceCustomerTerms, updateFinanceFeeSourceSettlementInclusion, uploadFinanceVoucher, voidFinanceBill } from './finance' describe('finance api', () => { beforeEach(() => { vi.restoreAllMocks() }) + it('uses only the formal settlement customer summary and keeps money as decimal strings', async () => { + const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', data: { customerId: '9007199254740993', currency: 'CNY', totalReceivable: '123456.780000', receivedAmount: '100000.000000', outstandingReceivable: '23456.780000', pendingBilling: '0.000000', outstandingPayable: '0.000000', asOf: '2026-08-13T04:00:00Z', lastChangedAt: null } }) + + const result = await getFinanceCustomerAccountSummary('9007199254740993') + + expect(get).toHaveBeenCalledWith('/platform/finance/v1/customer-accounts/9007199254740993/summary') + expect(result).toMatchObject({ customerId: '9007199254740993', currency: 'CNY', totalReceivable: '123456.780000', outstandingReceivable: '23456.780000' }) + }) + it('uses the customer statement read-model path', async () => { const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'ok', data: [{ id: 1, customerId: 10001, billNo: 'B-1', customerName: '客户A', billDate: '2026-08-11', dueDate: '2026-09-10', totalAmount: 100, receivedAmount: 0, outstandingAmount: 100, status: 'DRAFT', version: 2 }], @@ -107,7 +116,7 @@ describe('finance api', () => { }) it('uses the frozen G3 reconciliation create and action contracts', async () => { - const detail = { statementId: 501, statementNo: 'REC-501', customerId: 10001, customerName: '客户A', periodStart: '2026-08-01', periodEnd: '2026-08-31', creditDays: 30, currency: 'CNY', totalReceivable: 100, receivedAmount: 0, outstandingAmount: 100, settled: false, status: 'DRAFT', version: 0, lines: [{ statementLineId: 601, feeSourceId: 42, sourceVersion: 3, feeItemName: '运费', statementAmount: 100 }], paymentHistory: [], revisions: [] } + const detail = { statementId: 501, statementNo: 'REC-501', customerId: 10001, customerName: '客户A', periodStart: '2026-08-01', periodEnd: '2026-08-31', creditDays: 30, currency: 'CNY', totalReceivable: 100, receivedAmount: 0, outstandingAmount: 100, settled: false, status: 'DRAFT', version: 0, createdBy: 7, createdAt: '2026-08-01T09:00:00', updatedBy: 8, updatedAt: '2026-08-02T10:00:00', lines: [{ statementLineId: 601, feeSourceId: 42, sourceVersion: 3, sourceHash: 'hash-42', containerId: 31, feeItemName: '运费', sourceAmount: 120, statementAmount: 100, adjustmentReason: '优惠调整' }], paymentHistory: [], revisions: [] } const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', message: 'success', data: detail }) const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'success', data: detail }) @@ -118,7 +127,7 @@ describe('finance api', () => { expect(post.mock.calls[0]).toEqual(['/platform/finance/v1/statements', expect.objectContaining({ customerId: '10001', currency: 'CNY', generationMode: 'BY_CUTOFF' }), { headers: { 'Idempotency-Key': expect.any(String) } }]) expect(post.mock.calls[1]).toEqual(['/platform/finance/v1/reconciliation-statements/501/actions', { action: 'SUBMIT', expectedVersion: 0 }, { headers: { 'Idempotency-Key': expect.any(String) } }]) expect(get).toHaveBeenCalledWith('/platform/finance/v1/reconciliation-statements/501') - expect(created).toMatchObject({ id: '501', billNo: 'REC-501', customerId: '10001', totalAmount: 100, lines: [{ statementLineId: '601', sourceFeeId: '42', amount: 100 }] }) + expect(created).toMatchObject({ id: '501', billNo: 'REC-501', customerId: '10001', totalAmount: 100, createdBy: '7', createdAt: '2026-08-01T09:00:00', updatedBy: '8', updatedAt: '2026-08-02T10:00:00', lines: [{ statementLineId: '601', sourceFeeId: '42', sourceHash: 'hash-42', containerId: '31', sourceAmount: 120, amount: 100, adjustmentReason: '优惠调整' }] }) expect(loaded.revisions).toEqual([]) }) diff --git a/src/modules/finance/api/finance.ts b/src/modules/finance/api/finance.ts index bc31676f..74f90b5f 100644 --- a/src/modules/finance/api/finance.ts +++ b/src/modules/finance/api/finance.ts @@ -2,6 +2,17 @@ import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' import type { CreatePayableSettlementPayload, CreateReceivableSettlementPayload, CreateReconciliationPayload, CreateSettlementPaymentPayload, FinanceAccountEntry, FinanceAccountSummary, FinanceBillDetail, FinanceBillLine, FinanceBillResource, FinanceBillRevision, FinanceCostResource, FinanceCustomerAccountReadModel, FinanceCustomerTerms, FinanceFeePoolItem, FinanceFeeSourceDetail, FinanceLedgerEntry, FinancePayableSettlement, FinanceReceivableSettlement, FinanceReceiptResource, FinanceReconciliationDetail, FinanceSettlementDetail, FinanceStatementReadModel, FinanceVoucherResource, ReconciliationActionPayload } from '../types' const basePath = '/platform/finance/v1' +export interface FinanceCustomerAccountSummaryResource { + customerId: string + currency: 'CNY' + totalReceivable: number | string + receivedAmount: number | string + outstandingReceivable: number | string + pendingBilling: number | string + outstandingPayable: number | string + asOf: string + lastChangedAt: string | null +} export const financePhase1Capabilities = { accountList: true, accountLedger: true, @@ -64,6 +75,18 @@ export async function getFinanceCustomerAccount(customerId: string): Promise { + const response = await http.get, ApiEnvelope>(`${basePath}/customer-accounts/${customerId}/summary`) + const item = unwrapApiResponse(response) + return { + customerId: stringValue(item.customerId), currency: 'CNY', + totalReceivable: stringValue(item.totalReceivable), receivedAmount: stringValue(item.receivedAmount), + outstandingReceivable: stringValue(item.outstandingReceivable), pendingBilling: stringValue(item.pendingBilling), + outstandingPayable: stringValue(item.outstandingPayable), asOf: stringValue(item.asOf), + lastChangedAt: item.lastChangedAt == null ? null : stringValue(item.lastChangedAt), + } +} + export async function listFinanceFeePool(customerId?: string, direction?: 'RECEIVABLE' | 'PAYABLE'): Promise { const response = await http.get, ApiEnvelope>(`${basePath}/fee-pool`, { params: { customerId: customerId?.trim() || undefined, direction } }) return unwrapApiResponse(response).map((item) => { @@ -128,7 +151,11 @@ export async function updateFinanceFeeSourceSettlementInclusion(feeSourceId: str function normalizeReconciliationDetail(item: RawResource): FinanceReconciliationDetail { const lines = (Array.isArray(item.lines) ? item.lines : []).map((entry) => { const row = entry as RawResource - return { statementLineId: stringValue(row.statementLineId), sourceFeeId: stringValue(row.feeSourceId), sourceFeeVersion: numberValue(row.sourceVersion), orderId: null, inboundId: row.inboundId == null ? null : stringValue(row.inboundId), feeItemName: stringValue(row.feeItemName), amount: numberValue(row.statementAmount) } + return { + statementLineId: stringValue(row.statementLineId), sourceFeeId: stringValue(row.feeSourceId), sourceFeeVersion: numberValue(row.sourceVersion), sourceHash: stringValue(row.sourceHash) || undefined, + orderId: null, inboundId: row.inboundId == null ? null : stringValue(row.inboundId), containerId: row.containerId == null ? null : stringValue(row.containerId), + feeItemName: stringValue(row.feeItemName), sourceAmount: numberValue(row.sourceAmount), amount: numberValue(row.statementAmount), adjustmentReason: stringValue(row.adjustmentReason) || undefined, + } }) return { id: stringValue(item.statementId), billNo: stringValue(item.statementNo), customerId: stringValue(item.customerId), customerName: stringValue(item.customerName), diff --git a/src/modules/finance/index.ts b/src/modules/finance/index.ts index 4da06a7b..6cc4b774 100644 --- a/src/modules/finance/index.ts +++ b/src/modules/finance/index.ts @@ -1,2 +1,3 @@ export { financeRoutes } from './routes' export { default as CustomerStatementsPanel } from './components/CustomerStatementsPanel.vue' +export { getFinanceCustomerAccountSummary, type FinanceCustomerAccountSummaryResource } from './api/finance' diff --git a/src/modules/finance/pages/FinanceCustomerAccountsView.test.ts b/src/modules/finance/pages/FinanceCustomerAccountsView.test.ts index 24bfbce7..108ee48f 100644 --- a/src/modules/finance/pages/FinanceCustomerAccountsView.test.ts +++ b/src/modules/finance/pages/FinanceCustomerAccountsView.test.ts @@ -1,6 +1,28 @@ import { readFileSync } from 'node:fs' import { resolve } from 'node:path' -import { describe, expect, it } from 'vitest' +import ElementPlus from 'element-plus' +import { createPinia, setActivePinia } from 'pinia' +import { flushPromises, mount } from '@vue/test-utils' +import { createMemoryHistory, createRouter } from 'vue-router' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { useAccessPreviewStore } from '@/core/access/accessPreview' +import { useAuthStore } from '@/core/auth/auth' +import { listFinanceAccountLedger, listFinanceAccounts } from '../api/finance' +import FinanceCustomerAccountsView from './FinanceCustomerAccountsView.vue' + +vi.mock('../api/finance', () => ({ + listFinanceAccounts: vi.fn(), + listFinanceAccountLedger: vi.fn(), + getFinanceCustomerTerms: vi.fn(), + updateFinanceCustomerTerms: vi.fn(), +})) + +class ResizeObserverStub implements ResizeObserver { observe() {} unobserve() {} disconnect() {} } + +function memoryStorage(): Storage { + const values = new Map() + return { get length() { return values.size }, clear: () => values.clear(), getItem: (key) => values.get(key) ?? null, key: (index) => [...values.keys()][index] ?? null, removeItem: (key) => values.delete(key), setItem: (key, value) => values.set(key, value) } +} describe('FinanceCustomerAccountsView delivery', () => { const page = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/FinanceCustomerAccountsView.vue'), 'utf8') @@ -25,4 +47,32 @@ describe('FinanceCustomerAccountsView delivery', () => { expect(page).toContain("listFinanceAccounts({ page: currentPage, size: 200 })") expect(page).not.toContain('listFinanceAccounts({ customerId') }) + + beforeEach(() => { + vi.stubGlobal('ResizeObserver', ResizeObserverStub) + vi.stubGlobal('localStorage', memoryStorage()) + vi.stubGlobal('sessionStorage', memoryStorage()) + vi.mocked(listFinanceAccounts).mockReset().mockResolvedValue({ records: [{ customerId: '10001', customerCode: 'C-001', customerName: '客户A', contactName: '张三', contactPhone: '13800000000', outstandingReceivable: 100, pendingBilling: 20, receivedInPeriod: 30, outstandingPayable: 40, status: 'ACTIVE', lastChangedAt: '2026-08-13 10:00:00' }], total: 1, pages: 1 }) + vi.mocked(listFinanceAccountLedger).mockReset().mockResolvedValue({ records: [], total: 0, pages: 1 }) + }) + + it('restores account summary and ledger after opening the detail URL directly', async () => { + const pinia = createPinia(); setActivePinia(pinia) + const auth = useAuthStore(); auth.token = 'token'; auth.permissions = ['platform:finance:account:view'] + useAccessPreviewStore().initializeAfterLogin() + const router = createRouter({ history: createMemoryHistory(), routes: [ + { path: '/finance/customer-accounts', name: 'finance-customer-accounts', component: FinanceCustomerAccountsView }, + { path: '/finance/customer-accounts/:customerId', name: 'finance-customer-account-detail', component: FinanceCustomerAccountsView }, + ] }) + await router.push('/finance/customer-accounts/10001'); await router.isReady() + const wrapper = mount(FinanceCustomerAccountsView, { global: { plugins: [pinia, ElementPlus, router] } }) + await flushPromises() + + expect(wrapper.text()).toContain('客户A · C-001') + expect(listFinanceAccounts).toHaveBeenCalledWith({ page: 1, size: 200 }) + expect(listFinanceAccountLedger).toHaveBeenCalledWith('10001', expect.objectContaining({ page: 1, size: 20 })) + wrapper.unmount() + }) }) + +afterAll(() => vi.unstubAllGlobals()) diff --git a/src/modules/finance/pages/FinanceCustomerAccountsView.vue b/src/modules/finance/pages/FinanceCustomerAccountsView.vue index 0e415e89..e473a524 100644 --- a/src/modules/finance/pages/FinanceCustomerAccountsView.vue +++ b/src/modules/finance/pages/FinanceCustomerAccountsView.vue @@ -40,8 +40,8 @@ const ledgerPage = ref(1) const ledgerPageSize = ref(20) const ledgerTotal = ref(0) -const canView = computed(() => access.can('platform:finance:customer-account:list') || access.can('platform:finance:account:view')) -const canViewTerms = computed(() => access.can('platform:finance:customer-terms:view')) +const canView = computed(() => access.can('platform:finance:customer-account:list')) +const canViewTerms = computed(() => canView.value) const canUpdateTerms = computed(() => canViewTerms.value && access.can('platform:finance:customer-terms:update')) const queryFields: QueryField[] = [ { key: 'keyword', type: 'input', label: '关键词', width: 260, placeholder: '客户编号/客户名称/联系人/手机号', props: { 'aria-label': '关键词' } }, @@ -164,7 +164,7 @@ onMounted(() => { }) watch(() => [route.name, String(route.params.customerId || '')], ([name, customerId], previous) => { if (!previous) return - if (name === 'finance-customer-account-detail' && customerId) void restoreAccountDetail(customerId) + if (name === 'finance-customer-account-detail' && customerId) void restoreAccountDetail(String(customerId)) else { detailOpen.value = false; selected.value = null; ledger.value = []; void load() } }) diff --git a/src/modules/finance/pages/FinanceCustomerArapView.vue b/src/modules/finance/pages/FinanceCustomerArapView.vue index d794d97e..d63c804d 100644 --- a/src/modules/finance/pages/FinanceCustomerArapView.vue +++ b/src/modules/finance/pages/FinanceCustomerArapView.vue @@ -69,9 +69,9 @@ const selectedRows = ref([]) const page = ref(1) const pageSize = ref(20) -const canView = computed(() => access.can('platform:finance:customer-arap:list') || access.can('platform:finance:bill:view')) +const canView = computed(() => access.can('platform:finance:customer-arap:list')) const canListCustomers = computed(() => canView.value || access.can('platform:customer:list')) -const canViewSource = computed(() => access.can('platform:finance:fee-source:view') || access.can('platform:finance:bill:view')) +const canViewSource = computed(() => canView.value) const canReviewSource = computed(() => access.can('platform:finance:fee-source:review')) const canSettleSource = computed(() => access.can('platform:finance:fee-source:settlement')) const canCreateStatement = computed(() => access.can('platform:finance:reconciliation:create')) @@ -194,7 +194,7 @@ onMounted(async () => { }) watch(() => [route.name, String(route.params.feeSourceId || '')], ([name, feeSourceId], previous) => { if (!previous) return - if (name === 'finance-customer-arap-detail' && feeSourceId) void restoreFeeSourceDetail(feeSourceId) + if (name === 'finance-customer-arap-detail' && feeSourceId) void restoreFeeSourceDetail(String(feeSourceId)) else { detailOpen.value = false; selected.value = null; detail.value = null; void load() } }) diff --git a/src/modules/finance/pages/FinanceCustomerStatementsView.test.ts b/src/modules/finance/pages/FinanceCustomerStatementsView.test.ts index 0559ea58..886660ba 100644 --- a/src/modules/finance/pages/FinanceCustomerStatementsView.test.ts +++ b/src/modules/finance/pages/FinanceCustomerStatementsView.test.ts @@ -29,12 +29,12 @@ const statement: FinanceStatementReadModel = { const detail: FinanceReconciliationDetail = { ...statement, paidAmount: 100, periodStart: '2026-08-01', periodEnd: '2026-08-31', creditDays: 30, settled: false, remark: '八月账单', createdBy: '创建人A', createdAt: '2026-08-01 09:00:00', updatedBy: '更新人B', updatedAt: '2026-08-20 11:00:00', - lines: [{ statementLineId: '601', sourceFeeId: '81', sourceFeeVersion: 2, orderId: '11', inboundId: '21', feeItemName: '运费', amount: 300 }], + lines: [{ statementLineId: '601', sourceFeeId: '81', sourceFeeVersion: 2, sourceHash: 'hash-81', orderId: '11', inboundId: '21', containerId: '31', feeItemName: '运费', sourceAmount: 320, amount: 300, adjustmentReason: '优惠调整' }], paymentHistory: [{ transactionId: '701', transactionType: 'RECEIPT', transactionDate: '2026-08-20', amount: 100, method: 'BANK_TRANSFER', referenceNo: 'PAY-701', createdBy: '财务员', occurredAt: '2026-08-20 10:00:00' }], revisions: [], } -async function mountPage(permissions = ['platform:finance:bill:view']) { +async function mountPage(permissions = ['platform:finance:bill:view', 'platform:finance:fee-source:view']) { const pinia = createPinia(); setActivePinia(pinia) const auth = useAuthStore(); auth.token = 'token'; auth.permissions = permissions useAccessPreviewStore().initializeAfterLogin() @@ -75,6 +75,8 @@ describe('FinanceCustomerStatementsView', () => { expect(document.body.textContent).toContain('CONT-31') expect(document.body.textContent).toContain('IN-21') expect(document.body.textContent).toContain('来源版本') + expect(document.body.textContent).toContain('hash-81') + expect(document.body.textContent).toContain('优惠调整') expect(document.body.textContent).toContain('创建人A') expect(document.body.textContent).toContain('2026-08-20 11:00:00') expect(document.querySelector('.el-drawer')).toBeNull() @@ -95,10 +97,11 @@ describe('FinanceCustomerStatementsView', () => { expect(vm.filteredRows).toHaveLength(0) }) - it('exports the currently filtered real statement rows as CSV', async () => { + it('hydrates and exports the currently filtered real statement rows as CSV', async () => { const clicked = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}) const wrapper = await mountPage() - ;(wrapper.vm as unknown as { exportStatements(): void }).exportStatements() + await (wrapper.vm as unknown as { exportStatements(): Promise }).exportStatements() + expect(getReconciliationStatement).toHaveBeenCalledWith('501') expect(URL.createObjectURL).toHaveBeenCalledWith(expect.any(Blob)) expect(clicked).toHaveBeenCalledOnce() }) @@ -112,6 +115,15 @@ describe('FinanceCustomerStatementsView', () => { expect(listPlatformTenants).not.toHaveBeenCalled() }) + it('does not request fee-source details without its permission and keeps frozen trace fields visible', async () => { + const wrapper = await mountPage(['platform:finance:bill:view']) + await (wrapper.vm as unknown as { openDetail(row: FinanceStatementReadModel): Promise }).openDetail(statement) + await flushPromises() + expect(getFinanceFeeSource).not.toHaveBeenCalled() + expect(document.body.textContent).toContain('柜次ID 31') + expect(document.body.textContent).toContain('hash-81') + }) + it('surfaces detail hydration failures instead of silently returning an empty filtered list', async () => { const wrapper = await mountPage() vi.mocked(getReconciliationStatement).mockRejectedValueOnce(new Error('detail unavailable')) @@ -119,4 +131,14 @@ describe('FinanceCustomerStatementsView', () => { await flushPromises() expect(document.body.textContent).toContain('筛选字段加载失败') }) + + it('keeps the failed selected row visible and exposes a detail retry state', async () => { + const wrapper = await mountPage() + vi.mocked(getReconciliationStatement).mockRejectedValueOnce(new Error('detail unavailable')) + await (wrapper.vm as unknown as { openDetail(row: FinanceStatementReadModel): Promise }).openDetail(statement) + await flushPromises() + expect(document.body.textContent).toContain('已选择 1 项') + expect(document.body.textContent).toContain('明细加载失败') + expect(document.body.textContent).toContain('重试') + }) }) diff --git a/src/modules/finance/pages/FinanceCustomerStatementsView.vue b/src/modules/finance/pages/FinanceCustomerStatementsView.vue index 654b96dc..ada965cf 100644 --- a/src/modules/finance/pages/FinanceCustomerStatementsView.vue +++ b/src/modules/finance/pages/FinanceCustomerStatementsView.vue @@ -13,7 +13,7 @@ import ListPageLayout from '@/shared/components/ListPageLayout.vue' import LoadErrorState from '@/shared/components/LoadErrorState.vue' import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue' import { actionReconciliationStatement, createReconciliationStatement, getFinanceFeeSource, getReconciliationStatement, listFinanceStatements } from '../api/finance' -import { financeStatusLabel, financeStatusTone } from '../status' +import { financeSourceTypeLabel, financeStatusLabel, financeStatusTone } from '../status' import type { FinanceReconciliationDetail, FinanceStatementReadModel, ReconciliationActionPayload } from '../types' interface AdjustmentRow { @@ -62,6 +62,7 @@ const detailCache = new Map() const lineSourceCache = new Map() const createOpen = ref(false) const createBusy = ref(false) +const exportBusy = ref(false) const actionBusy = ref(false) const createForm = ref({ periodStart: new Date().toISOString().slice(0, 8) + '01', periodEnd: new Date().toISOString().slice(0, 10), remark: '' }) const adjustOpen = ref(false) @@ -70,8 +71,9 @@ const adjustStatement = ref(null) const adjustReason = ref('') const adjustLines = ref([]) -const canView = computed(() => access.can('platform:finance:customer-statement:list') || access.can('platform:finance:bill:view')) +const canView = computed(() => access.can('platform:finance:customer-statement:list')) const canListCustomers = computed(() => access.can('platform:customer:list')) +const canViewFeeSource = computed(() => access.can('platform:finance:customer-arap:list')) const canCreate = computed(() => access.can('platform:finance:reconciliation:create')) const canReview = computed(() => access.can('platform:finance:reconciliation:review')) const canIssue = computed(() => access.can('platform:finance:reconciliation:issue')) @@ -85,7 +87,7 @@ const fields = computed(() => [ ] }, { key: 'billNo', type: 'input', label: '对账单号', width: 210, placeholder: '请输入对账单号' }, { key: 'customerId', type: 'select', label: '客户', width: 240, placeholder: '全部客户', props: { filterable: true, clearable: true }, options: customers.value.filter((item) => item.status === 'ACTIVE').map((item) => ({ label: item.tenantName, value: String(item.id) })) }, - { key: 'containerNo', type: 'input', label: '柜次号', width: 190, placeholder: '请输入柜次号' }, + { key: 'containerNo', type: 'input', label: '柜次号', width: 190, placeholder: canViewFeeSource.value ? '请输入柜次号' : '缺少费用来源查看权限', props: { disabled: !canViewFeeSource.value } }, { key: 'periodStartRange', type: 'daterange', label: '结算起始日', width: 280, props: { valueFormat: 'YYYY-MM-DD', startPlaceholder: '开始日期', endPlaceholder: '结束日期' } }, { key: 'periodEndRange', type: 'daterange', label: '结算结束日', width: 280, props: { valueFormat: 'YYYY-MM-DD', startPlaceholder: '开始日期', endPlaceholder: '结束日期' } }, { key: 'settled', type: 'select', label: '是否结清', width: 160, placeholder: '全部', options: [{ label: '是', value: 'true' }, { label: '否', value: 'false' }] }, @@ -149,6 +151,7 @@ const filteredRows = computed(() => rows.value.filter((row) => { })) async function hydrateLineSources(current: FinanceReconciliationDetail) { + if (!canViewFeeSource.value) return await Promise.all(current.lines.map(async (line) => { if (!line.sourceFeeId || lineSourceCache.has(line.sourceFeeId)) return try { @@ -235,12 +238,43 @@ async function openDetail(row: FinanceStatementReadModel) { } } +function clearSelection() { + selectedId.value = '' + detail.value = null + detailError.value = null +} + +function retrySelectedDetail() { + const row = rows.value.find((item) => item.id === selectedId.value) + if (row) void openDetail(row) +} + function selectedRowClass({ row }: { row: StatementRow }) { return row.id === selectedId.value ? 'is-selected-statement' : '' } +function paymentMethodLabel(method: string) { + return ({ BANK_TRANSFER: '银行转账', CASH: '现金', OTHER: '其他' } as Record)[method] || method || '—' +} + +function revisionActionLabel(action: string) { + return ({ SUBMIT: '提交审核', APPROVE: '审核通过', REJECT: '审核驳回', ISSUE: '出账', ADJUST: '调整', VOID: '作废', REOPEN: '重开' } as Record)[action] || action || '—' +} + function csvCell(value: unknown) { return `"${String(value ?? '').replaceAll('"', '""')}"` } -function exportStatements() { +async function exportStatements() { + exportBusy.value = true + try { + const hydrated = await Promise.all(rows.value.map(async (row) => { + const current = await fetchDetail(row.id) + return detailToRow(row, current) + })) + rows.value = hydrated + } catch (cause) { + error.value = getApiErrorDetails(cause, '导出前加载完整对账单字段失败') + exportBusy.value = false + return + } const header = ['审核状态', '对账单号', '客户', '结算起始日', '结算结束日', '账期(天)', '货币', '总应收金额', '已收金额', '未收金额', '最后收款时间', '是否结清', '备注', '创建人', '创建时间', '更新人', '更新时间'] const body = filteredRows.value.map((row) => [financeStatusLabel(row.status), row.billNo, row.customerName, row.periodStart, row.periodEnd, row.creditDays ?? '', row.currency, row.totalAmount, row.receivedAmount, row.outstandingAmount, row.lastPaymentAt, row.settled === null ? '' : (row.settled ? '是' : '否'), row.remark, row.createdBy || '', row.createdAt || '', row.updatedBy || '', row.updatedAt || '']) const blob = new Blob([`\uFEFF${[header, ...body].map((line) => line.map(csvCell).join(',')).join('\r\n')}`], { type: 'text/csv;charset=utf-8' }) @@ -250,6 +284,7 @@ function exportStatements() { anchor.download = `客户对账单-${new Date().toISOString().slice(0, 10)}.csv` anchor.click() URL.revokeObjectURL(url) + exportBusy.value = false ElMessage.success(`已导出 ${body.length} 条客户对账单`) } @@ -260,6 +295,8 @@ function openCreate() { async function createStatement() { if (!filters.value.customerId) return ElMessage.warning('请先选择客户') + if (!createForm.value.periodStart || !createForm.value.periodEnd) return ElMessage.warning('请选择完整账期') + if (createForm.value.periodStart > createForm.value.periodEnd) return ElMessage.warning('账期开始日期不能晚于结束日期') createBusy.value = true try { await createReconciliationStatement({ customerId: String(filters.value.customerId), currency: 'CNY', generationMode: 'BY_CUTOFF', periodStart: createForm.value.periodStart, periodEnd: createForm.value.periodEnd, remark: createForm.value.remark || undefined }) @@ -328,7 +365,7 @@ onMounted(() => { if (canListCustomers.value) void loadCustomers(); void load()