This commit is contained in:
2026-08-13 11:57:44 +08:00
parent 7beca3f985
commit 835ac0ad12
87 changed files with 3721 additions and 295 deletions
@@ -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>