优化
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import { getReconciliationStatement, listFinanceStatements } from '../api/finance'
|
||||
import CustomerStatementsPanel from './CustomerStatementsPanel.vue'
|
||||
|
||||
vi.mock('../api/finance', () => ({
|
||||
getReconciliationStatement: vi.fn(),
|
||||
listFinanceStatements: vi.fn(),
|
||||
}))
|
||||
|
||||
const statement = {
|
||||
id: 'statement-1', customerId: '8', billNo: 'ST202608001', customerName: '柏悦物流',
|
||||
billDate: '2026-08-01', dueDate: '2026-08-31', totalAmount: 1200, receivedAmount: 200,
|
||||
outstandingAmount: 1000, currency: 'CNY' as const, status: 'ISSUED' as const, version: 1,
|
||||
}
|
||||
|
||||
function mountPanel(permissions: string[]) {
|
||||
const pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
const auth = useAuthStore()
|
||||
auth.token = 'token'
|
||||
auth.permissions = permissions
|
||||
useAccessPreviewStore().initializeAfterLogin()
|
||||
return mount(CustomerStatementsPanel, {
|
||||
props: { customerId: '8', customerName: '柏悦物流' },
|
||||
global: { plugins: [pinia, ElementPlus] },
|
||||
})
|
||||
}
|
||||
|
||||
describe('CustomerStatementsPanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(listFinanceStatements).mockResolvedValue([statement])
|
||||
})
|
||||
|
||||
it('loads only the selected customer statements', async () => {
|
||||
const wrapper = mountPanel(['platform:finance:customer-statement:list'])
|
||||
await flushPromises()
|
||||
|
||||
expect(listFinanceStatements).toHaveBeenCalledWith('8')
|
||||
expect(wrapper.text()).toContain('ST202608001')
|
||||
expect(wrapper.text()).toContain('1,200.00')
|
||||
})
|
||||
|
||||
it('does not query finance without statement permission', async () => {
|
||||
const wrapper = mountPanel([])
|
||||
await flushPromises()
|
||||
|
||||
expect(listFinanceStatements).not.toHaveBeenCalled()
|
||||
expect(wrapper.text()).toContain('暂无财务账单查看权限')
|
||||
})
|
||||
|
||||
it('loads the real reconciliation detail from the statement row', async () => {
|
||||
vi.mocked(getReconciliationStatement).mockResolvedValue({
|
||||
...statement, paidAmount: 200, remark: '', periodStart: '2026-07-01', periodEnd: '2026-07-31',
|
||||
creditDays: 30, settled: false, lines: [{ statementLineId: 'line-1', sourceFeeId: 'fee-1', sourceFeeVersion: 1, orderId: null, inboundId: 'inbound-1', feeItemName: '入库操作费', amount: 1200 }],
|
||||
paymentHistory: [], revisions: [],
|
||||
})
|
||||
const wrapper = mountPanel(['platform:finance:customer-statement:list'])
|
||||
await flushPromises()
|
||||
await wrapper.get('button[aria-label="查看对账单ST202608001详情"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(getReconciliationStatement).toHaveBeenCalledWith('statement-1')
|
||||
expect(wrapper.text()).toContain('入库操作费')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,101 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { Eye, FileText } from '@lucide/vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import AppDrawer from '@/shared/components/AppDrawer.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { getReconciliationStatement, listFinanceStatements } from '../api/finance'
|
||||
import { financeStatusLabel, financeStatusTone } from '../status'
|
||||
import type { FinanceReconciliationDetail, FinanceStatementReadModel } from '../types'
|
||||
|
||||
const props = defineProps<{ customerId: string; customerName: string }>()
|
||||
const access = useAccessPreviewStore()
|
||||
const rows = ref<FinanceStatementReadModel[]>([])
|
||||
const loading = ref(false)
|
||||
const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const detailOpen = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const detail = ref<FinanceReconciliationDetail | null>(null)
|
||||
const detailError = ref<ApiErrorDetails | null>(null)
|
||||
let requestSerial = 0
|
||||
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 92, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'billNo', prop: 'billNo', label: '对账单号', minWidth: 180 },
|
||||
{ key: 'billDate', prop: 'billDate', label: '账单日期', width: 120 },
|
||||
{ key: 'dueDate', prop: 'dueDate', label: '到期日', width: 120 },
|
||||
{ key: 'totalAmount', label: '应收(CNY)', width: 130, align: 'right', slotName: 'totalAmount' },
|
||||
{ key: 'receivedAmount', label: '已收(CNY)', width: 130, align: 'right', slotName: 'receivedAmount' },
|
||||
{ key: 'outstandingAmount', label: '未收(CNY)', width: 130, align: 'right', slotName: 'outstandingAmount' },
|
||||
{ key: 'status', label: '状态', width: 110, slotName: 'status' },
|
||||
]
|
||||
|
||||
async function load() {
|
||||
const customerId = props.customerId.trim()
|
||||
const serial = ++requestSerial
|
||||
rows.value = []
|
||||
loadError.value = null
|
||||
if (!customerId || !access.can('platform:finance:customer-statement:list')) return
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await listFinanceStatements(customerId)
|
||||
if (serial === requestSerial && customerId === props.customerId.trim()) rows.value = result
|
||||
} catch (error) {
|
||||
if (serial === requestSerial) loadError.value = getApiErrorDetails(error, '客户财务账单加载失败,请稍后重试')
|
||||
} finally {
|
||||
if (serial === requestSerial) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function openDetail(row: FinanceStatementReadModel) {
|
||||
detailOpen.value = true
|
||||
detailLoading.value = true
|
||||
detail.value = null
|
||||
detailError.value = null
|
||||
try { detail.value = await getReconciliationStatement(row.id) }
|
||||
catch (error) { detailError.value = getApiErrorDetails(error, '对账单详情加载失败,请稍后重试') }
|
||||
finally { detailLoading.value = false }
|
||||
}
|
||||
|
||||
function formatAmount(value: number) {
|
||||
return value.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
}
|
||||
|
||||
watch(() => props.customerId, () => { void load() }, { immediate: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="customer-statements-panel">
|
||||
<div class="panel-title"><div><h2>财务账单</h2><p>{{ customerName }}的客户对账单</p></div><FileText :size="19" /></div>
|
||||
<div v-if="!access.can('platform:finance:customer-statement:list')" class="module-empty"><FileText :size="30" /><strong>暂无财务账单查看权限</strong><p>请联系管理员开通客户对账单查看权限。</p></div>
|
||||
<LoadErrorState v-else-if="loadError" title="客户财务账单加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
|
||||
<DataTable v-else :data="rows" :columns="columns" :loading="loading" row-key="id" aria-label="客户财务账单列表" empty-text="当前客户暂无财务账单" border stripe>
|
||||
<template #actions="{ row }"><el-button link type="primary" :aria-label="`查看对账单${(row as FinanceStatementReadModel).billNo}详情`" @click="openDetail(row as FinanceStatementReadModel)"><Eye :size="15" />详情</el-button></template>
|
||||
<template #totalAmount="{ row }">{{ formatAmount((row as FinanceStatementReadModel).totalAmount) }}</template>
|
||||
<template #receivedAmount="{ row }">{{ formatAmount((row as FinanceStatementReadModel).receivedAmount) }}</template>
|
||||
<template #outstandingAmount="{ row }">{{ formatAmount((row as FinanceStatementReadModel).outstandingAmount) }}</template>
|
||||
<template #status="{ row }"><el-tag :type="financeStatusTone((row as FinanceStatementReadModel).status)">{{ financeStatusLabel((row as FinanceStatementReadModel).status) }}</el-tag></template>
|
||||
<template #pagination-left>币种:CNY</template>
|
||||
</DataTable>
|
||||
|
||||
<AppDrawer v-model="detailOpen" title="客户对账单详情" size="min(820px, 94vw)" :loading="detailLoading">
|
||||
<LoadErrorState v-if="detailError" title="详情加载失败" :message="detailError.message" :loading="detailLoading"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
|
||||
<template v-else-if="detail">
|
||||
<el-descriptions :column="2" border><el-descriptions-item label="对账单号">{{ detail.billNo }}</el-descriptions-item><el-descriptions-item label="客户">{{ detail.customerName }}</el-descriptions-item><el-descriptions-item label="状态">{{ financeStatusLabel(detail.status) }}</el-descriptions-item><el-descriptions-item label="金额">{{ detail.totalAmount }} CNY</el-descriptions-item><el-descriptions-item label="账期">{{ detail.periodStart }} 至 {{ detail.periodEnd }}</el-descriptions-item><el-descriptions-item label="未收金额">{{ detail.outstandingAmount }} CNY</el-descriptions-item></el-descriptions>
|
||||
<el-table :data="detail.lines" size="small" border stripe><el-table-column prop="feeItemName" label="费用项目" /><el-table-column prop="amount" label="账单金额(CNY)" /></el-table>
|
||||
<el-table v-if="detail.revisions.length" :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>
|
||||
</template>
|
||||
</AppDrawer>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.customer-statements-panel { min-width:0; }
|
||||
.panel-title { display:flex; align-items:flex-start; justify-content:space-between; gap:16px; margin-bottom:18px; padding-bottom:14px; border-bottom:1px solid var(--cc-border); }
|
||||
.panel-title h2 { margin:0; color:#344054; font-size:16px; }.panel-title p { margin:5px 0 0; color:var(--cc-muted); font-size:11px; }.panel-title > svg { color:var(--cc-primary); }
|
||||
.module-empty { display:flex; min-height:260px; align-items:center; justify-content:center; flex-direction:column; gap:7px; color:#98a2b3; text-align:center; }.module-empty strong { color:#475467; font-size:13px; }.module-empty p { max-width:520px; margin:0; color:var(--cc-muted); font-size:12px; line-height:1.7; }
|
||||
:deep(.el-descriptions) { margin-bottom:18px; }:deep(.el-table + .el-table) { margin-top:18px; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user