This commit is contained in:
2026-08-13 12:22:41 +08:00
parent 835ac0ad12
commit 8a8c4fa190
26 changed files with 572 additions and 148 deletions
+5 -5
View File
@@ -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' },
],
},
{
@@ -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 })
@@ -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<ContainerPa
return { ...result, records: (result.records || []).map(container), page: numberValue(result.page, query.page), size: numberValue(result.size, query.size), total: numberValue(result.total), pages: numberValue(result.pages) }
}
export async function getCustomerContainerTransitSummary(customerId: string): Promise<CustomerContainerTransitSummary> {
const response = await http.get<ApiEnvelope<Record<string, unknown>>, ApiEnvelope<Record<string, unknown>>>(
`/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<ContainerBookingResource>, ApiEnvelope<ContainerBookingResource>>(`${containerPath}/${encodeURIComponent(containerId)}`)
return container(unwrapApiResponse(response))
@@ -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')
})
})
@@ -0,0 +1,41 @@
<script setup lang="ts">
defineProps<{
title: string
value: string
unit: string
detail: string
loading?: boolean
failed?: boolean
unauthorized?: boolean
tone: 'blue' | 'green' | 'amber'
}>()
</script>
<template>
<article class="operating-metric-card" :aria-label="title" :aria-busy="loading || undefined">
<span class="metric-icon" :class="`is-${tone}`" aria-hidden="true"><slot name="icon" /></span>
<div class="metric-content">
<p>{{ title }}</p>
<strong v-if="loading" class="metric-loading" aria-label="加载中">—</strong>
<strong v-else-if="failed || unauthorized">—</strong>
<strong v-else>{{ value }}<small>{{ unit }}</small></strong>
<span :class="{ 'is-error': failed }">{{ unauthorized ? '暂无查看权限' : failed ? '加载失败' : loading ? '加载中…' : detail }}</span>
</div>
</article>
</template>
<style scoped>
.operating-metric-card { display:flex; min-width:0; align-items:center; gap:14px; padding:22px 24px; border-right:1px solid var(--cc-border); }
.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; }
.metric-content { min-width:0; }
p,span { margin:0; color:var(--cc-muted); font-size:12px; }
strong { display:block; min-width:0; margin:5px 0 4px; color:#172033; font-size:26px; line-height:1; overflow-wrap:anywhere; }
small { margin-left:5px; color:#475467; font-size:12px; font-weight:600; white-space:nowrap; }
.is-error { color:#d92d20; }
.metric-loading { color:#98a2b3; }
@media (max-width:720px) { .operating-metric-card { border-right:0; border-bottom:1px solid var(--cc-border); } }
@media (max-width:360px) { .operating-metric-card { gap:10px; padding:18px 14px; }.metric-icon { width:40px; height:40px; }strong { font-size:23px; } }
</style>
@@ -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: '<div>财务账单</div>' },
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<HTMLButtonElement>('.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')
})
})
@@ -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<T> = { loading: boolean; failed: boolean; data: T | null }
const route = useRoute()
const router = useRouter()
@@ -54,7 +58,9 @@ const loadError = ref<ApiErrorDetails | null>(null)
const customer = ref<PlatformTenantResource | null>(null)
const qualifications = ref<PlatformTenantQualification[]>([])
const members = ref<PlatformTenantMember[]>([])
const orderCount = ref<number | null>(null)
const orderStatistics = ref<MetricState<Awaited<ReturnType<typeof getPlatformTenantOrderStatistics>>>>({ loading: false, failed: false, data: null })
const financeStatistics = ref<MetricState<Awaited<ReturnType<typeof getFinanceCustomerAccountSummary>>>>({ loading: false, failed: false, data: null })
const transitStatistics = ref<MetricState<Awaited<ReturnType<typeof getCustomerContainerTransitSummary>>>>({ loading: false, failed: false, data: null })
const customerOrders = ref<PlatformFirstLegOrderResource[]>([])
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<void>[] = []
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>[] = []
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) => {
<template #summary>
<section class="overview-strip" aria-label="客户概况">
<article><span class="metric-icon is-blue"><Ship :size="20" /></span><div><p>累计运单</p><strong>{{ orderCount ?? '—' }}<small>票</small></strong><span>{{ access.can('platform:order:list') ? '来自头程订单服务' : '暂无运单查看权限' }}</span></div></article>
<article><span class="metric-icon is-green"><UsersRound :size="20" /></span><div><p>企业用户</p><strong>{{ enterpriseMembers.length }}<small>人</small></strong><span>{{ activeEnterpriseMembers }} 人正常使用</span></div></article>
<article><span class="metric-icon is-amber"><FileCheck2 :size="20" /></span><div><p>资质文件</p><strong>{{ qualifications.length }}<small>份</small></strong><span>{{ approvedQualifications }} 份已通过</span></div></article>
<CustomerOperatingMetricCard title="累计运单" :value="String(orderStatistics.data?.totalSubmittedOrders ?? '')" unit="票" :detail="`本月 ${orderStatistics.data?.currentMonthSubmittedOrders ?? 0} 票`" tone="blue" :loading="orderStatistics.loading" :failed="orderStatistics.failed" :unauthorized="!access.can('platform:order:list')"><template #icon><Ship :size="20" /></template></CustomerOperatingMetricCard>
<CustomerOperatingMetricCard title="累计应收" :value="cnyWan(financeStatistics.data?.totalReceivable)" unit="万元" :detail="`未结清 ${cnyWan(financeStatistics.data?.outstandingReceivable)} 万元`" tone="green" :loading="financeStatistics.loading" :failed="financeStatistics.failed" :unauthorized="!access.can('platform:finance:customer-account:list')"><template #icon><CircleDollarSign :size="20" /></template></CustomerOperatingMetricCard>
<CustomerOperatingMetricCard title="在途柜量" :value="decimalText(transitStatistics.data?.totalTeu)" unit="TEU" :detail="`${transitStatistics.data?.containerCount ?? 0} 柜${transitStatistics.data?.unknownContainerCount ? ` · 另有 ${transitStatistics.data.unknownContainerCount} 柜柜型未知,未计入TEU` : ''}`" tone="amber" :loading="transitStatistics.loading" :failed="transitStatistics.failed" :unauthorized="!access.can('platform:firstleg:container:list')"><template #icon><Container :size="20" /></template></CustomerOperatingMetricCard>
</section>
</template>
@@ -406,7 +447,6 @@ watch(orderPage, (page, previousPage) => {
<section v-else-if="activeTab === 'business'" class="detail-panel module-panel">
<div class="panel-title"><div><h2>业务概况</h2><p>基于当前已接入服务的客户业务摘要</p></div><BarChart3 :size="19" /></div>
<div class="business-summary"><div><Ship :size="20" /><span>累计运单</span><strong>{{ orderCount ?? '—' }} 票</strong></div><div><UsersRound :size="20" /><span>活跃企业用户</span><strong>{{ activeEnterpriseMembers }} 人</strong></div><div><FileCheck2 :size="20" /><span>有效资质</span><strong>{{ approvedQualifications }} 份</strong></div></div>
<div class="contract-note"><strong>趋势、港口与收入分析暂未接入</strong><p>原型中的近 6 个月趋势、运输方式分布、目的港和收入贡献依赖后续统计与财务服务;当前不展示模拟图表。</p></div>
</section>
@@ -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; } }
</style>
@@ -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()
+1 -1
View File
@@ -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',
+12 -3
View File
@@ -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([])
})
+28 -1
View File
@@ -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<Fin
}
}
export async function getFinanceCustomerAccountSummary(customerId: string): Promise<FinanceCustomerAccountSummaryResource> {
const response = await http.get<ApiEnvelope<RawResource>, ApiEnvelope<RawResource>>(`${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<FinanceFeePoolItem[]> {
const response = await http.get<ApiEnvelope<RawResource[]>, ApiEnvelope<RawResource[]>>(`${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),
+1
View File
@@ -1,2 +1,3 @@
export { financeRoutes } from './routes'
export { default as CustomerStatementsPanel } from './components/CustomerStatementsPanel.vue'
export { getFinanceCustomerAccountSummary, type FinanceCustomerAccountSummaryResource } from './api/finance'
@@ -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<string, string>()
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())
@@ -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() }
})
</script>
@@ -69,9 +69,9 @@ const selectedRows = ref<ArapRow[]>([])
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() }
})
</script>
@@ -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<void> }).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<void> }).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<void> }).openDetail(statement)
await flushPromises()
expect(document.body.textContent).toContain('已选择 1 项')
expect(document.body.textContent).toContain('明细加载失败')
expect(document.body.textContent).toContain('重试')
})
})
@@ -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<string, FinanceReconciliationDetail>()
const lineSourceCache = new Map<string, StatementLineSource>()
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<FinanceStatementReadModel | null>(null)
const adjustReason = ref('')
const adjustLines = ref<AdjustmentRow[]>([])
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<QueryField[]>(() => [
] },
{ 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<string, string>)[method] || method || '—'
}
function revisionActionLabel(action: string) {
return ({ SUBMIT: '提交审核', APPROVE: '审核通过', REJECT: '审核驳回', ISSUE: '出账', ADJUST: '调整', VOID: '作废', REOPEN: '重开' } as Record<string, string>)[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()
<section class="surface-panel table-panel">
<DataTable :data="filteredRows" :columns="columns" :loading="loading" aria-label="客户对账单列表" row-key="id" empty-text="暂无客户对账单" preference-key="finance-customer-statements" :row-class-name="selectedRowClass" @row-click="openDetail">
<template #toolbar-actions>
<el-button :disabled="loading || !filteredRows.length" @click="exportStatements"><Download :size="15" />导出</el-button>
<el-button :loading="exportBusy" :disabled="loading || exportBusy || !filteredRows.length" @click="exportStatements"><Download :size="15" />导出</el-button>
<el-button v-if="canCreate" type="primary" :disabled="!canGenerate" :title="canGenerate ? '生成对账单' : '缺少客户列表权限,无法选择客户'" @click="openCreate"><Plus :size="15" />生成对账单</el-button>
</template>
<template #actions="{ row }">
@@ -352,32 +389,35 @@ onMounted(() => { if (canListCustomers.value) void loadCustomers(); void load()
<section class="surface-panel statement-detail-panel" aria-label="所选对账单联动明细">
<header class="statement-detail-header">
<div><strong>已选择 {{ detail ? 1 : 0 }} 项</strong><span v-if="detail">{{ detail.billNo }} · {{ detail.customerName }}</span></div>
<el-button v-if="detail" link @click="selectedId = ''; detail = null">清空</el-button>
<div><strong>已选择 {{ selectedId ? 1 : 0 }} 项</strong><span v-if="detail">{{ detail.billNo }} · {{ detail.customerName }}</span></div>
<el-button v-if="selectedId" link @click="clearSelection">清空</el-button>
</header>
<el-tabs v-model="activeDetailTab">
<el-tab-pane label="账单明细表" name="lines">
<LoadErrorState v-if="detailError" title="明细加载失败" :message="detailError.message" :loading="detailLoading" @retry="selectedId && openDetail(rows.find((row) => row.id === selectedId)!)"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
<LoadErrorState v-if="detailError" title="明细加载失败" :message="detailError.message" :loading="detailLoading" @retry="retrySelectedDetail"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
<el-skeleton v-else-if="detailLoading" :rows="4" animated />
<template v-else-if="detail">
<el-table :data="detail.lines" size="small" border stripe empty-text="该对账单暂无账单明细">
<el-table-column label="应收/应付" width="110"><template #default>应收</template></el-table-column>
<el-table-column label="客户" min-width="170"><template #default>{{ detail.customerName }}</template></el-table-column>
<el-table-column label="入仓号" min-width="140"><template #default="{ row }">{{ lineSourceCache.get(row.sourceFeeId)?.inboundCode || row.inboundId || '—' }}</template></el-table-column>
<el-table-column label="柜次号" min-width="140"><template #default="{ row }">{{ lineSourceCache.get(row.sourceFeeId)?.containerNo || '—' }}</template></el-table-column>
<el-table-column label="柜次号" min-width="140"><template #default="{ row }">{{ lineSourceCache.get(row.sourceFeeId)?.containerNo || (row.containerId ? `柜次ID ${row.containerId}` : '—') }}</template></el-table-column>
<el-table-column label="业务发生日" width="130"><template #default="{ row }">{{ lineSourceCache.get(row.sourceFeeId)?.businessDate || '—' }}</template></el-table-column>
<el-table-column label="来源类型" min-width="140"><template #default="{ row }">{{ lineSourceCache.get(row.sourceFeeId)?.sourceType || '—' }}</template></el-table-column>
<el-table-column label="来源类型" min-width="140"><template #default="{ row }">{{ financeSourceTypeLabel(lineSourceCache.get(row.sourceFeeId)?.sourceType) }}</template></el-table-column>
<el-table-column prop="feeItemName" label="费用项目" min-width="150" />
<el-table-column label="计费量" width="110"><template #default="{ row }">{{ lineSourceCache.get(row.sourceFeeId)?.quantity ?? '—' }}</template></el-table-column>
<el-table-column label="单价(CNY)" width="120"><template #default="{ row }">{{ lineSourceCache.get(row.sourceFeeId)?.unitPrice ?? '—' }}</template></el-table-column>
<el-table-column prop="sourceAmount" label="来源金额(CNY)" width="150" />
<el-table-column prop="amount" label="账单金额(CNY)" width="150" />
<el-table-column prop="sourceFeeId" label="来源费用ID" min-width="140" />
<el-table-column prop="sourceFeeVersion" label="来源版本" width="110" />
<el-table-column prop="sourceHash" label="来源快照Hash" min-width="190" />
<el-table-column prop="adjustmentReason" label="调整说明" min-width="180" />
<el-table-column label="对账单号" min-width="180"><template #default>{{ detail.billNo }}</template></el-table-column>
<el-table-column label="结算起始日" width="130"><template #default>{{ detail.periodStart || '—' }}</template></el-table-column>
<el-table-column label="结算结束日" width="130"><template #default>{{ detail.periodEnd || '—' }}</template></el-table-column>
</el-table>
<el-collapse v-if="detail.revisions.length" class="revision-collapse"><el-collapse-item title="调整记录" name="revisions"><el-table :data="detail.revisions" size="small" border stripe><el-table-column prop="revisionNo" label="修订" width="72" /><el-table-column prop="action" label="动作" width="130" /><el-table-column prop="beforeAmount" label="调整前" /><el-table-column prop="afterAmount" label="调整后" /><el-table-column prop="reason" label="原因" /><el-table-column prop="occurredAt" label="发生时间" min-width="170" /></el-table></el-collapse-item></el-collapse>
<el-collapse v-if="detail.revisions.length" class="revision-collapse"><el-collapse-item title="调整记录" name="revisions"><el-table :data="detail.revisions" size="small" border stripe><el-table-column prop="revisionNo" label="修订" width="72" /><el-table-column label="动作" width="130"><template #default="{ row }">{{ revisionActionLabel(row.action) }}</template></el-table-column><el-table-column prop="beforeAmount" label="调整前" /><el-table-column prop="afterAmount" label="调整后" /><el-table-column prop="reason" label="原因" /><el-table-column prop="occurredAt" label="发生时间" min-width="170" /></el-table></el-collapse-item></el-collapse>
</template>
<el-empty v-else description="请在主表中选择一张对账单" />
</el-tab-pane>
@@ -385,7 +425,7 @@ onMounted(() => { if (canListCustomers.value) void loadCustomers(); void load()
<el-table v-if="detail" :data="detail.paymentHistory" size="small" border stripe empty-text="该对账单暂无有效收款记录">
<el-table-column prop="transactionDate" label="支付日期" min-width="130" />
<el-table-column prop="amount" label="支付金额(CNY)" min-width="150" />
<el-table-column prop="method" label="支付方式" min-width="130" />
<el-table-column label="支付方式" min-width="130"><template #default="{ row }">{{ paymentMethodLabel(row.method) }}</template></el-table-column>
<el-table-column prop="referenceNo" label="支付流水号" min-width="170" />
<el-table-column prop="createdBy" label="登记人" min-width="130" />
<el-table-column prop="occurredAt" label="登记时间" min-width="170" />
@@ -66,10 +66,9 @@ const receiptFile = ref<File | null>(null)
const receiptForm = ref({ receivedDate: new Date().toISOString().slice(0, 10), amount: 0, paymentMethod: 'BANK_TRANSFER' as 'BANK_TRANSFER' | 'CASH' | 'OTHER', remark: '' })
const actionBusy = ref(false)
const viewPermission = computed(() => props.kind === 'receivable' ? 'platform:finance:settlement-ar:view' : 'platform:finance:settlement-ap:view')
const createPermission = computed(() => props.kind === 'receivable' ? 'platform:finance:settlement-ar:create' : 'platform:finance:settlement-ap:create')
const listPermission = computed(() => props.kind === 'receivable' ? 'platform:finance:settlement-ar:list' : 'platform:finance:settlement-ap:list')
const canView = computed(() => access.can(listPermission.value) || access.can(viewPermission.value))
const canView = computed(() => access.can(listPermission.value))
const canListCustomers = computed(() => access.can('platform:customer:list'))
const canCreate = computed(() => access.can(createPermission.value))
const canPay = computed(() => props.kind === 'payable' && access.can('platform:finance:payment:create'))
@@ -346,6 +345,78 @@ onMounted(() => {
<template>
<AdminPage as="div" class="finance-settlement-page" spacing="normal">
<el-result v-if="!canView" icon="warning" :title="`无权访问${title}`" sub-title="请联系管理员开通结算单查看权限" />
<section v-else-if="detailMode" class="settlement-detail-page" :aria-label="`${title}详情`">
<header class="settlement-detail-header">
<div class="settlement-detail-heading">
<el-button aria-label="返回列表" title="返回列表" @click="backToList"><ArrowLeft :size="17" /></el-button>
<div><div class="settlement-detail-breadcrumb">财务管理 / {{ title }} / {{ detail?.settlementNo || settlementId }}</div><h2>{{ title }}详情</h2></div>
<el-tag v-if="detail" :type="financeStatusTone(detail.status)">{{ financeStatusLabel(detail.status) }}</el-tag>
</div>
<div class="settlement-detail-actions">
<el-button v-if="detail" @click="exportDetail"><Download :size="15" />导出</el-button>
<el-button v-if="detail && canReceive && ['ISSUED','PARTIALLY_PAID'].includes(detail.status)" type="primary" :loading="receiptBusy" @click="openReceiptFromDetail"><HandCoins :size="15" />部分收款</el-button>
<el-button v-if="detail && canPay && ['ISSUED','PARTIALLY_PAID'].includes(detail.status)" type="primary" @click="openPaymentFromDetail"><CreditCard :size="15" />部分付款</el-button>
<el-button v-if="detail && canVoid && detail.status === 'ISSUED'" type="danger" :loading="actionBusy" @click="performAction(detailActionRow(), 'VOID')"><XCircle :size="15" />作废</el-button>
<el-button v-if="detail && canVoid && detail.status === 'VOIDED'" :loading="actionBusy" @click="performAction(detailActionRow(), 'REOPEN')"><RotateCcw :size="15" />重开</el-button>
</div>
</header>
<LoadErrorState v-if="detailError" :title="`${title}详情加载失败`" :message="detailError.message" :loading="detailLoading" @retry="loadDetail(String(settlementId || ''))"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
<el-skeleton v-else-if="detailLoading" :rows="10" animated />
<template v-else-if="detail">
<section class="settlement-detail-summary" aria-label="结算金额汇总">
<div class="summary-item"><span>{{ kind === 'receivable' ? '应收金额' : '应付金额' }}</span><strong>{{ money(detail.amount) }}</strong></div>
<div class="summary-item"><span>{{ kind === 'receivable' ? '已收金额' : '已付金额' }}</span><strong class="is-success">{{ money(detail.paidAmount) }}</strong></div>
<div class="summary-item"><span>未结余额</span><strong class="is-danger">{{ money(detail.outstandingAmount) }}</strong></div>
<div class="summary-item"><span>到期日</span><strong class="is-date">{{ displayDate(detail.dueDate) }}</strong></div>
</section>
<section class="settlement-detail-info surface-panel">
<h3>基本信息</h3>
<el-descriptions :column="2" border>
<el-descriptions-item label="结算单号">{{ detail.settlementNo }}</el-descriptions-item>
<el-descriptions-item :label="kind === 'receivable' ? '客户' : '供应商'">{{ detailPartyName() }}</el-descriptions-item>
<el-descriptions-item label="币种">CNY</el-descriptions-item>
<el-descriptions-item label="收付款状态"><el-tag :type="financeStatusTone(detail.status)">{{ financeStatusLabel(detail.status) }}</el-tag></el-descriptions-item>
<el-descriptions-item label="关联账单">{{ detail.reconciliationStatementId || '—' }}</el-descriptions-item>
<el-descriptions-item label="业务期间">{{ detail.businessPeriod || '—' }}</el-descriptions-item>
<el-descriptions-item label="生成日期">{{ displayDate(detail.generatedDate) }}</el-descriptions-item>
<el-descriptions-item label="到期日">{{ displayDate(detail.dueDate) }}</el-descriptions-item>
</el-descriptions>
</section>
<section class="detail-section surface-panel">
<h3>费用明细</h3>
<el-table :data="detail.lines" size="small" border stripe empty-text="暂无费用明细">
<el-table-column prop="feeItemName" label="费用类型" min-width="150" />
<el-table-column prop="sourceType" label="来源类型" min-width="140" />
<el-table-column prop="inboundCode" label="入仓码" min-width="130"><template #default="scope">{{ scope.row.inboundCode || '—' }}</template></el-table-column>
<el-table-column prop="containerNo" label="柜次号" min-width="130"><template #default="scope">{{ scope.row.containerNo || '—' }}</template></el-table-column>
<el-table-column prop="businessDate" label="业务发生日" width="120"><template #default="scope">{{ displayDate(scope.row.businessDate) }}</template></el-table-column>
<el-table-column prop="sourceVersion" label="来源版本" width="100" />
<el-table-column prop="amount" label="金额(CNY)" width="130" align="right"><template #default="scope">{{ money(scope.row.amount) }}</template></el-table-column>
</el-table>
</section>
<section class="detail-section surface-panel">
<h3>{{ kind === 'receivable' ? '收款记录' : '付款记录' }}</h3>
<el-table :data="detail.transactions" size="small" border stripe :empty-text="kind === 'receivable' ? '暂无收款记录' : '暂无付款记录'">
<el-table-column prop="transactionDate" :label="kind === 'receivable' ? '收款日期' : '付款日期'" width="130" />
<el-table-column prop="amount" label="金额(CNY)" width="130" align="right"><template #default="scope">{{ money(scope.row.amount) }}</template></el-table-column>
<el-table-column prop="method" :label="kind === 'receivable' ? '收款方式' : '付款方式'" min-width="130" />
<el-table-column label="凭证" width="100"><template #default="scope">{{ scope.row.voucherFileIds.length ? `${scope.row.voucherFileIds.length} 个` : '—' }}</template></el-table-column>
<el-table-column prop="remark" label="备注" min-width="180"><template #default="scope">{{ scope.row.remark || '—' }}</template></el-table-column>
</el-table>
</section>
<section class="detail-section surface-panel">
<h3>修订记录</h3>
<el-table :data="detail.revisions" size="small" border stripe empty-text="暂无修订记录">
<el-table-column prop="revisionNo" label="版本" width="90" />
<el-table-column prop="action" label="动作" min-width="120" />
<el-table-column prop="beforeAmount" label="调整前(CNY)" width="140" align="right"><template #default="scope">{{ money(scope.row.beforeAmount) }}</template></el-table-column>
<el-table-column prop="afterAmount" label="调整后(CNY)" width="140" align="right"><template #default="scope">{{ money(scope.row.afterAmount) }}</template></el-table-column>
<el-table-column prop="reason" label="原因" min-width="180"><template #default="scope">{{ scope.row.reason || '—' }}</template></el-table-column>
<el-table-column prop="occurredAt" label="时间" min-width="160" />
</el-table>
</section>
</template>
</section>
<ListPageLayout v-else :aria-label="title">
<template #toolbar>
<QueryToolbar v-model="filters" class="surface-panel" :ariaLabel="`${title}筛选`" :fields="fields" :preference-key="`finance-settlement-${kind}-query`" @search="load" @reset="load" />
@@ -388,59 +459,6 @@ onMounted(() => {
</template>
</ListPageLayout>
<AppDrawer v-model="detailOpen" :title="`${title}详情`" size="min(900px, 96vw)">
<LoadErrorState v-if="detailError" :title="`${title}详情加载失败`" :message="detailError.message" :loading="detailLoading"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
<el-skeleton v-else-if="detailLoading" :rows="8" animated />
<div v-else-if="detail" class="detail-content">
<el-descriptions :column="2" border>
<el-descriptions-item label="结算单号">{{ detail.settlementNo }}</el-descriptions-item>
<el-descriptions-item :label="kind === 'receivable' ? '客户' : '供应商'">{{ kind === 'receivable' ? customerName(detail.customerId) : selectedSupplierName() }}</el-descriptions-item>
<el-descriptions-item label="币种">CNY</el-descriptions-item>
<el-descriptions-item label="状态"><el-tag :type="financeStatusTone(detail.status)">{{ financeStatusLabel(detail.status) }}</el-tag></el-descriptions-item>
<el-descriptions-item :label="kind === 'receivable' ? '应收金额' : '应付金额'">{{ money(detail.amount) }}</el-descriptions-item>
<el-descriptions-item :label="kind === 'receivable' ? '已收' : '已付'">{{ money(detail.paidAmount) }}</el-descriptions-item>
<el-descriptions-item label="余额">{{ money(detail.outstandingAmount) }}</el-descriptions-item>
<el-descriptions-item v-if="detail.reconciliationStatementId" label="关联对账单">{{ detail.reconciliationStatementId }}</el-descriptions-item>
</el-descriptions>
<section class="detail-section">
<h3>费用明细</h3>
<el-table :data="detail.lines" size="small" border stripe empty-text="暂无费用明细">
<el-table-column prop="feeItemName" label="费用类型" min-width="150" />
<el-table-column prop="sourceType" label="来源类型" min-width="140" />
<el-table-column prop="inboundCode" label="入仓码" min-width="130"><template #default="scope">{{ scope.row.inboundCode || '—' }}</template></el-table-column>
<el-table-column prop="containerNo" label="柜次号" min-width="130"><template #default="scope">{{ scope.row.containerNo || '—' }}</template></el-table-column>
<el-table-column prop="businessDate" label="业务发生日" width="120"><template #default="scope">{{ displayDate(scope.row.businessDate) }}</template></el-table-column>
<el-table-column prop="sourceVersion" label="来源版本" width="100" />
<el-table-column prop="amount" label="金额(CNY)" width="130" align="right"><template #default="scope">{{ money(scope.row.amount) }}</template></el-table-column>
</el-table>
</section>
<section class="detail-section">
<h3>{{ kind === 'receivable' ? '收款记录' : '付款记录' }}</h3>
<el-table :data="detail.transactions" size="small" border stripe :empty-text="kind === 'receivable' ? '暂无收款记录' : '暂无付款记录'">
<el-table-column prop="transactionDate" :label="kind === 'receivable' ? '收款日期' : '付款日期'" width="130" />
<el-table-column prop="amount" label="金额(CNY)" width="130" align="right"><template #default="scope">{{ money(scope.row.amount) }}</template></el-table-column>
<el-table-column prop="method" :label="kind === 'receivable' ? '收款方式' : '付款方式'" min-width="130" />
<el-table-column label="凭证" width="100"><template #default="scope">{{ scope.row.voucherFileIds.length ? `${scope.row.voucherFileIds.length} 个` : '—' }}</template></el-table-column>
<el-table-column prop="remark" label="备注" min-width="180"><template #default="scope">{{ scope.row.remark || '—' }}</template></el-table-column>
</el-table>
</section>
<section class="detail-section">
<h3>修订记录</h3>
<el-table :data="detail.revisions" size="small" border stripe empty-text="暂无修订记录">
<el-table-column prop="revisionNo" label="版本" width="90" />
<el-table-column prop="action" label="动作" min-width="120" />
<el-table-column prop="beforeAmount" label="调整前(CNY)" width="140" align="right"><template #default="scope">{{ money(scope.row.beforeAmount) }}</template></el-table-column>
<el-table-column prop="afterAmount" label="调整后(CNY)" width="140" align="right"><template #default="scope">{{ money(scope.row.afterAmount) }}</template></el-table-column>
<el-table-column prop="reason" label="原因" min-width="180"><template #default="scope">{{ scope.row.reason || '—' }}</template></el-table-column>
<el-table-column prop="occurredAt" label="时间" min-width="160" />
</el-table>
</section>
</div>
</AppDrawer>
<AppDialog v-model="createOpen" :title="`生成${title}`" width="min(560px, 94vw)" :confirm-loading="createBusy" @confirm="submitCreate">
<el-form label-position="top">
<el-form-item v-if="kind === 'receivable'" label="已出账对账单"><el-select v-model="createForm.reconciliationStatementId" filterable><el-option v-for="item in statementOptions" :key="item.id" :label="`${item.billNo} · ${item.outstandingAmount} CNY`" :value="item.id" /></el-select></el-form-item>
@@ -449,11 +467,15 @@ onMounted(() => {
</AppDialog>
<AppDialog v-model="paymentOpen" title="登记付款" width="min(560px, 94vw)" :confirm-loading="paymentBusy" @confirm="submitPayment">
<el-form label-position="top"><el-form-item label="付款日期"><el-date-picker v-model="paymentForm.transactionDate" type="date" value-format="YYYY-MM-DD" /></el-form-item><el-form-item label="付款金额(CNY)"><el-input-number v-model="paymentForm.amount" :min="0.01" :max="detail?.outstandingAmount || 0.01" :precision="2" /></el-form-item><el-form-item label="付款方式"><el-select v-model="paymentForm.method"><el-option label="银行转账" value="BANK_TRANSFER" /><el-option label="现金" value="CASH" /><el-option label="其他" value="OTHER" /></el-select></el-form-item><el-form-item label="凭证号"><el-input v-model="paymentForm.referenceNo" /></el-form-item><el-form-item label="付款凭证"><el-upload :auto-upload="false" :limit="1" accept="image/*" :on-change="selectPaymentFile"><el-button>选择图片</el-button></el-upload></el-form-item><el-form-item label="备注"><el-input v-model="paymentForm.remark" type="textarea" /></el-form-item></el-form>
<div v-if="detail" class="partial-summary"><div><span>结算单号</span><strong>{{ detail.settlementNo }}</strong></div><div><span>供应商</span><strong>{{ detailPartyName() }}</strong></div><div><span>应付金额</span><strong>{{ money(detail.amount) }}</strong></div><div><span>已付金额</span><strong class="is-success">{{ money(detail.paidAmount) }}</strong></div><div><span>当前余额</span><strong class="is-danger">{{ money(detail.outstandingAmount) }}</strong></div></div>
<el-form label-position="top"><el-form-item label="付款日期"><el-date-picker v-model="paymentForm.transactionDate" type="date" value-format="YYYY-MM-DD" /></el-form-item><el-form-item label="本次付款金额(CNY)"><el-input-number v-model="paymentForm.amount" :min="0.01" :max="detail?.outstandingAmount || 0.01" :precision="2" /></el-form-item><el-form-item label="付款方式"><el-select v-model="paymentForm.method"><el-option label="银行转账" value="BANK_TRANSFER" /><el-option label="现金" value="CASH" /><el-option label="其他" value="OTHER" /></el-select></el-form-item><el-form-item label="银行流水号/凭证号"><el-input v-model="paymentForm.referenceNo" /></el-form-item><el-form-item label="付款凭证"><el-upload :auto-upload="false" :limit="1" accept="image/*" :on-change="selectPaymentFile"><el-button>选择图片</el-button></el-upload></el-form-item><el-form-item label="备注"><el-input v-model="paymentForm.remark" type="textarea" /></el-form-item></el-form>
<div v-if="detail" class="remaining-preview"><span>登记后剩余余额</span><strong>{{ remainingAfter(paymentForm.amount) }}</strong></div>
</AppDialog>
<AppDialog v-model="receiptOpen" title="登记收款" width="min(560px, 94vw)" :confirm-loading="receiptBusy" @confirm="submitReceipt">
<el-form label-position="top"><el-form-item label="到账日期"><el-date-picker v-model="receiptForm.receivedDate" type="date" value-format="YYYY-MM-DD" /></el-form-item><el-form-item label="收款金额(CNY)"><el-input-number v-model="receiptForm.amount" :min="0.01" :max="detail?.outstandingAmount || 0.01" :precision="2" /></el-form-item><el-form-item label="收款方式"><el-select v-model="receiptForm.paymentMethod"><el-option label="银行转账" value="BANK_TRANSFER" /><el-option label="现金" value="CASH" /><el-option label="其他" value="OTHER" /></el-select></el-form-item><el-form-item label="收款凭证"><el-upload :auto-upload="false" :limit="1" accept="image/*" :on-change="selectReceiptFile"><el-button>选择图片</el-button></el-upload></el-form-item><el-form-item label="备注"><el-input v-model="receiptForm.remark" type="textarea" /></el-form-item></el-form>
<div v-if="detail" class="partial-summary"><div><span>结算单号</span><strong>{{ detail.settlementNo }}</strong></div><div><span>客户公司</span><strong>{{ detailPartyName() }}</strong></div><div><span>应收金额</span><strong>{{ money(detail.amount) }}</strong></div><div><span>已收金额</span><strong class="is-success">{{ money(detail.paidAmount) }}</strong></div><div><span>当前余额</span><strong class="is-danger">{{ money(detail.outstandingAmount) }}</strong></div></div>
<el-form label-position="top"><el-form-item label="收款日期"><el-date-picker v-model="receiptForm.receivedDate" type="date" value-format="YYYY-MM-DD" /></el-form-item><el-form-item label="本次收款金额(CNY)"><el-input-number v-model="receiptForm.amount" :min="0.01" :max="detail?.outstandingAmount || 0.01" :precision="2" /></el-form-item><el-form-item label="收款方式"><el-select v-model="receiptForm.paymentMethod"><el-option label="银行转账" value="BANK_TRANSFER" /><el-option label="现金" value="CASH" /><el-option label="其他" value="OTHER" /></el-select></el-form-item><el-form-item label="收款凭证"><el-upload :auto-upload="false" :limit="1" accept="image/*" :on-change="selectReceiptFile"><el-button>选择图片</el-button></el-upload></el-form-item><el-form-item label="备注"><el-input v-model="receiptForm.remark" type="textarea" /></el-form-item></el-form>
<div v-if="detail" class="remaining-preview"><span>登记后剩余余额</span><strong>{{ remainingAfter(receiptForm.amount) }}</strong></div>
</AppDialog>
</AdminPage>
</template>
@@ -466,13 +488,30 @@ onMounted(() => {
.summary-item strong { display: block; margin-top: 8px; color: var(--cc-text-primary, #1f2937); font-size: 24px; line-height: 1.2; }
.summary-item strong.is-success { color: #16803c; }
.summary-item strong.is-danger { color: #c2413a; }
.detail-content { min-width: 0; }
.detail-section { margin-top: 22px; }
.detail-section h3 { margin: 0 0 10px; color: var(--cc-text-primary, #1f2937); font-size: 15px; font-weight: 600; }
.settlement-detail-page { display: grid; min-width: 0; gap: 14px; }
.settlement-detail-header { display: flex; align-items: center; justify-content: space-between; gap: 16px; min-width: 0; }
.settlement-detail-heading, .settlement-detail-actions { display: flex; align-items: center; gap: 10px; min-width: 0; flex-wrap: wrap; }
.settlement-detail-heading h2 { margin: 2px 0 0; font-size: 18px; letter-spacing: 0; }
.settlement-detail-breadcrumb { color: var(--cc-text-secondary, #64748b); font-size: 12px; }
.settlement-detail-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; }
.summary-item strong.is-date { font-size: 20px; }
.settlement-detail-info, .detail-section { min-width: 0; padding: 16px; }
.settlement-detail-info h3, .detail-section h3 { margin: 0 0 12px; color: var(--cc-text-primary, #1f2937); font-size: 15px; font-weight: 600; }
.partial-summary { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-bottom: 18px; padding: 14px; border: 1px solid var(--el-color-primary-light-7); border-radius: 8px; background: var(--el-color-primary-light-9); }
.partial-summary div { min-width: 0; }
.partial-summary span, .partial-summary strong { display: block; overflow-wrap: anywhere; }
.partial-summary span { color: var(--cc-text-secondary, #64748b); font-size: 12px; }
.partial-summary strong { margin-top: 4px; color: var(--cc-text-primary, #1f2937); font-size: 15px; }
.partial-summary strong.is-success { color: #16803c; }
.partial-summary strong.is-danger { color: #c2413a; }
.remaining-preview { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 14px; border-radius: 8px; color: var(--cc-text-secondary, #64748b); background: var(--el-fill-color-light); }
.remaining-preview strong { color: #b45309; font-size: 16px; }
:deep(.el-select), :deep(.el-date-editor) { width: 100%; }
@media (max-width: 720px) {
.settlement-summary { grid-template-columns: 1fr; }
.settlement-summary, .settlement-detail-summary, .partial-summary { grid-template-columns: 1fr; }
.summary-item strong { font-size: 21px; }
:deep(.el-drawer__footer .el-button) { min-height: 44px; }
.settlement-detail-header { align-items: flex-start; flex-direction: column; }
.settlement-detail-actions { width: 100%; }
.settlement-detail-actions :deep(.el-button) { min-height: 40px; }
}
</style>
@@ -87,7 +87,7 @@ let receiptBillsRequest = 0
let costOptionsRequest = 0
const title = computed(() => ({ bills: '客户对账单', receipts: '结算单应收', costs: '结算单应付' }[props.mode]))
const viewPermission = computed(() => ({ bills: 'platform:finance:bill:view', receipts: 'platform:finance:receipt:view', costs: 'platform:finance:cost:view' }[props.mode]))
const viewPermission = computed(() => ({ bills: 'platform:finance:customer-statement:list', receipts: 'platform:finance:settlement-ar:list', costs: 'platform:finance:settlement-ap:list' }[props.mode]))
const createPermission = computed(() => ({ bills: 'platform:finance:bill:create', receipts: 'platform:finance:receipt:create', costs: 'platform:finance:cost:create' }[props.mode]))
const canView = computed(() => access.can(viewPermission.value))
const canCreate = computed(() => Boolean(createPermission.value) && access.can(createPermission.value))
@@ -3,17 +3,31 @@ import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
const settlement = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/FinanceFormalSettlementPage.vue'), 'utf8')
const financeRoutes = readFileSync(resolve(process.cwd(), 'src/modules/finance/routes.ts'), 'utf8')
const statements = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/FinanceCustomerStatementsView.vue'), 'utf8')
const arap = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/FinanceCustomerArapView.vue'), 'utf8')
const manual = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/FinanceManualPage.vue'), 'utf8')
describe('finance presentation governance', () => {
it('shows customer names instead of internal customer IDs in formal settlements', () => {
expect(settlement).toContain('customerName(detail.customerId)')
expect(settlement).toContain('customerName(detail.value.customerId, detail.value.customerName)')
expect(settlement).toContain('detailPartyName()')
expect(settlement).toContain('activeCustomers.value.map')
expect(settlement).not.toContain('{{ selected.customerId }}')
})
it('uses prototype-defined standalone settlement details and modal partial payments', () => {
expect(financeRoutes).toContain("finance/settlement-receivables/:settlementId")
expect(financeRoutes).toContain("finance/settlement-payables/:settlementId")
expect(financeRoutes).toContain('showPageHeading: false')
expect(settlement).toContain('class="settlement-detail-page"')
expect(settlement).toContain('返回列表')
expect(settlement).not.toContain('AppDrawer')
expect(settlement).toContain('登记后剩余余额')
expect(settlement).toContain('remainingAfter(receiptForm.amount)')
expect(settlement).toContain('remainingAfter(paymentForm.amount)')
})
it('uses optional all-customer filters without a customer-selection blocker', () => {
for (const source of [settlement, statements, arap]) {
expect(source).toContain("placeholder: '全部客户'")
+9 -9
View File
@@ -1,13 +1,13 @@
import type { RouteRecordRaw } from 'vue-router'
export const financeRoutes = [
{ path: 'finance/customer-accounts', name: 'finance-customer-accounts', component: () => import('./pages/FinanceCustomerAccountsView.vue'), meta: { title: '客户账户', description: '查看客户 CNY 应收台账并维护独立账期', permission: 'platform:finance:customer-account:list', permissionAnyOf: ['platform:finance:account:view'] } },
{ path: 'finance/customer-accounts/:customerId', name: 'finance-customer-account-detail', component: () => import('./pages/FinanceCustomerAccountsView.vue'), meta: { title: '客户账户详情', description: '查看客户派生财务汇总与账户流水', permission: 'platform:finance:customer-account:list', permissionAnyOf: ['platform:finance:account:view'], showPageHeading: false } },
{ path: 'finance/customer-arap', name: 'finance-customer-arap', component: () => import('./pages/FinanceCustomerArapView.vue'), meta: { title: '客户应收应付', description: '按客户追溯入库单、订单和柜次费用来源', permission: 'platform:finance:customer-arap:list', permissionAnyOf: ['platform:finance:bill:view'] } },
{ path: 'finance/customer-arap/:feeSourceId', name: 'finance-customer-arap-detail', component: () => import('./pages/FinanceCustomerArapView.vue'), meta: { title: '费用来源详情', description: '查看费用来源、审核进度、关联业务与操作记录', permission: 'platform:finance:customer-arap:list', permissionAnyOf: ['platform:finance:bill:view'], showPageHeading: false } },
{ path: 'finance/customer-statements', name: 'finance-customer-statements', component: () => import('./pages/FinanceCustomerStatementsView.vue'), meta: { title: '客户对账单', description: '维护 CNY 客户对账单并在出账后冻结', permission: 'platform:finance:customer-statement:list', permissionAnyOf: ['platform:finance:bill:view'] } },
{ path: 'finance/settlement-receivables', name: 'finance-settlement-receivables', component: () => import('./pages/FinanceSettlementReceivablesView.vue'), meta: { title: '结算单应收', description: '登记客户收款、凭证及对应对账单金额', permission: 'platform:finance:settlement-ar:list', permissionAnyOf: ['platform:finance:settlement-ar:view'] } },
{ path: 'finance/settlement-receivables/:settlementId', name: 'finance-settlement-receivable-detail', component: () => import('./pages/FinanceSettlementReceivablesView.vue'), meta: { title: '应收结算单详情', description: '查看应收结算信息、费用明细和收款记录', permission: 'platform:finance:settlement-ar:list', permissionAnyOf: ['platform:finance:settlement-ar:view'], showPageHeading: false } },
{ path: 'finance/settlement-payables', name: 'finance-settlement-payables', component: () => import('./pages/FinanceSettlementPayablesView.vue'), meta: { title: '结算单应付', description: '登记单票及整柜公共成本并追溯业务来源', permission: 'platform:finance:settlement-ap:list', permissionAnyOf: ['platform:finance:settlement-ap:view'] } },
{ path: 'finance/settlement-payables/:settlementId', name: 'finance-settlement-payable-detail', component: () => import('./pages/FinanceSettlementPayablesView.vue'), meta: { title: '应付结算单详情', description: '查看应付结算信息、费用明细和付款记录', permission: 'platform:finance:settlement-ap:list', permissionAnyOf: ['platform:finance:settlement-ap:view'], showPageHeading: false } },
{ path: 'finance/customer-accounts', name: 'finance-customer-accounts', component: () => import('./pages/FinanceCustomerAccountsView.vue'), meta: { title: '客户账户', description: '查看客户 CNY 应收台账并维护独立账期', permission: 'platform:finance:customer-account:list' } },
{ path: 'finance/customer-accounts/:customerId', name: 'finance-customer-account-detail', component: () => import('./pages/FinanceCustomerAccountsView.vue'), meta: { title: '客户账户详情', description: '查看客户派生财务汇总与账户流水', permission: 'platform:finance:customer-account:list', showPageHeading: false } },
{ path: 'finance/customer-arap', name: 'finance-customer-arap', component: () => import('./pages/FinanceCustomerArapView.vue'), meta: { title: '客户应收应付', description: '按客户追溯入库单、订单和柜次费用来源', permission: 'platform:finance:customer-arap:list' } },
{ path: 'finance/customer-arap/:feeSourceId', name: 'finance-customer-arap-detail', component: () => import('./pages/FinanceCustomerArapView.vue'), meta: { title: '费用来源详情', description: '查看费用来源、审核进度、关联业务与操作记录', permission: 'platform:finance:customer-arap:list', showPageHeading: false } },
{ path: 'finance/customer-statements', name: 'finance-customer-statements', component: () => import('./pages/FinanceCustomerStatementsView.vue'), meta: { title: '客户对账单', description: '维护 CNY 客户对账单并在出账后冻结', permission: 'platform:finance:customer-statement:list' } },
{ path: 'finance/settlement-receivables', name: 'finance-settlement-receivables', component: () => import('./pages/FinanceSettlementReceivablesView.vue'), meta: { title: '结算单应收', description: '登记客户收款、凭证及对应对账单金额', permission: 'platform:finance:settlement-ar:list' } },
{ path: 'finance/settlement-receivables/:settlementId', name: 'finance-settlement-receivable-detail', component: () => import('./pages/FinanceSettlementReceivablesView.vue'), meta: { title: '应收结算单详情', description: '查看应收结算信息、费用明细和收款记录', permission: 'platform:finance:settlement-ar:list', showPageHeading: false } },
{ path: 'finance/settlement-payables', name: 'finance-settlement-payables', component: () => import('./pages/FinanceSettlementPayablesView.vue'), meta: { title: '结算单应付', description: '登记单票及整柜公共成本并追溯业务来源', permission: 'platform:finance:settlement-ap:list' } },
{ path: 'finance/settlement-payables/:settlementId', name: 'finance-settlement-payable-detail', component: () => import('./pages/FinanceSettlementPayablesView.vue'), meta: { title: '应付结算单详情', description: '查看应付结算信息、费用明细和付款记录', permission: 'platform:finance:settlement-ap:list', showPageHeading: false } },
] satisfies RouteRecordRaw[]
+4
View File
@@ -279,10 +279,14 @@ export interface FinanceBillLine {
statementLineId?: string
sourceFeeId: string
sourceFeeVersion: number
sourceHash?: string
orderId: string | null
inboundId: string | null
containerId?: string | null
feeItemName: string
sourceAmount?: number
amount: number
adjustmentReason?: string
}
export interface FinanceBillDetail extends FinanceBillResource {
@@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import {
getPlatformTenantOrderStatistics,
acceptPlatformFirstLegOrder,
addPlatformFirstLegOrderNote,
approvePlatformFirstLegOrder,
@@ -24,6 +25,15 @@ describe('platform first-leg order API', () => {
vi.mocked(http.put).mockReset()
})
it('uses the submitted master-order statistics contract and preserves the long customer id', async () => {
vi.mocked(http.get).mockResolvedValue({ code: '200', data: { tenantId: '9007199254740993', 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' } })
const result = await getPlatformTenantOrderStatistics('9007199254740993')
expect(http.get).toHaveBeenCalledWith('/platform/v1/orders/first-leg/tenant-statistics', { params: { tenantId: '9007199254740993' } })
expect(result).toMatchObject({ tenantId: '9007199254740993', totalSubmittedOrders: 12, currentMonthSubmittedOrders: 3, zoneId: 'Asia/Shanghai' })
})
it('queries the cross-tenant review queue with one server-paginated request', async () => {
const page = { records: [], page: 2, size: 20, total: 0, pages: 0 }
vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: page })
@@ -105,6 +105,16 @@ export interface PlatformFirstLegOrderPageResource {
pages: number
}
export interface PlatformTenantOrderStatistics {
tenantId: string
totalSubmittedOrders: number
currentMonthSubmittedOrders: number
monthStart: string
nextMonthStart: string
zoneId: 'Asia/Shanghai' | string
asOf: string
}
type PlatformFirstLegOrderPagePayload = Omit<PlatformFirstLegOrderPageResource, 'page' | 'size' | 'total' | 'pages'> & {
page: number | string
size: number | string
@@ -150,6 +160,14 @@ export async function listPlatformFirstLegOrders(query: PlatformFirstLegOrderQue
} satisfies PlatformFirstLegOrderPageResource
}
export async function getPlatformTenantOrderStatistics(tenantId: string): Promise<PlatformTenantOrderStatistics> {
const result = await getData<Omit<PlatformTenantOrderStatistics, 'tenantId'> & { tenantId: number | string }>(
'/platform/v1/orders/first-leg/tenant-statistics',
{ tenantId },
)
return { ...result, tenantId: String(result.tenantId) }
}
export function getPlatformFirstLegOrder(tenantId: string, orderId: string) {
return getData<PlatformFirstLegOrderDetailResource>(`/platform/v1/orders/first-leg/${orderId}`, { tenantId })
}
+2
View File
@@ -1,5 +1,7 @@
export {
getPlatformTenantOrderStatistics,
listPlatformFirstLegOrders,
type PlatformFirstLegOrderResource,
type PlatformFirstLegOrderStatus,
type PlatformTenantOrderStatistics,
} from './api/platformFirstLegOrders'
@@ -0,0 +1,15 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
const source = readFileSync(resolve(process.cwd(), 'src/modules/notifications/pages/MyMessagesView.vue'), 'utf8')
describe('MyMessagesView permissions', () => {
it('keeps inbox viewing separate from read operations', () => {
expect(source).toContain("access.can('platform:notification:read')")
expect(source).toContain("access.can('platform:notification:read-all')")
expect(source).toContain("detail.value.status==='UNREAD'&&canMarkRead.value")
expect(source).toContain('v-if="canMarkAllRead"')
expect(source).toContain("v-if=\"canMarkRead&&(row as InboxItem).status==='UNREAD'\"")
})
})