优化
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
|
||||
|
||||
@@ -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,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: '全部客户'")
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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'\"")
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user