feat(cashflow): 现金流量表结构分析——三大活动净额八类企业定性判定 (P1)

- 后端: cashflow_structure.py 模块 (GET rules/periods/values/analysis + POST values upsert)
- 判定: O/I/F 三净额符号 → 8类企业定性 (CMA P2 现金流量表分析, 无'现金流肖像'字样)
- 数据: cash_flow_structure_values 表 (entity_id+period 唯一), 官方现金流量表净额按期间注入
- 前端: CashFlowStructure.vue 判定横幅/瀑布图/规则表/入库记录 + MyDialog 录入弹窗
- 菜单: 财务报表分析 → 现金流量表分析 (路由 /cashflow-structure, 角色 ceo/finance/it)
- 测试: tests/test_cashflow_structure.py 7 条通过 (规则8类/判定/覆盖/博海边界6-7/非法期间)
This commit is contained in:
Hermes CI Fix
2026-09-03 01:22:46 +08:00
parent 0a6320f41f
commit e5dd6e380b
7 changed files with 1034 additions and 4 deletions
+576
View File
@@ -0,0 +1,576 @@
<template>
<div class="cashflow-structure-page">
<div class="page-header">
<h2>💰 现金流量表结构分析</h2>
<div class="header-right">
<el-tag size="small" type="info" effect="plain">{{ entityName || '账套' }}</el-tag>
<el-select v-model="period" size="small" style="width:140px" placeholder="选择期间" @change="loadData" :loading="loadingPeriods" clearable>
<el-option v-for="p in periods" :key="p" :value="p" :label="p" />
</el-select>
<el-button size="small" :icon="Refresh" @click="reload" :loading="loading">刷新</el-button>
<el-button v-if="canWrite" size="small" type="primary" @click="openDialog()">录入三大活动净额</el-button>
</div>
</div>
<div v-loading="loading" style="min-height:200px">
<!-- 无数据空态 -->
<el-empty v-if="!dataAvailable && !loading" :description="emptyMessage" style="padding:60px 0">
<el-button v-if="canWrite" type="primary" size="small" @click="openDialog()">录入官方现金流量表净额</el-button>
</el-empty>
<template v-if="dataAvailable">
<!-- 判定结果横幅 -->
<el-row :gutter="16">
<el-col :span="24">
<el-card shadow="never" class="verdict-card">
<div class="verdict-left" v-if="classification.classified">
<div class="verdict-badge">
<span class="verdict-type">类型{{ classification.type_no }}</span>
<span class="verdict-name">{{ classification.health_emoji }} {{ classification.name }}</span>
</div>
<div class="verdict-health">
<el-tag :type="healthTagType" size="small">健康度:{{ classification.health_emoji }} {{ classification.health_label }}</el-tag>
<el-tag v-if="classification.lifecycle" size="small" type="info" effect="plain" style="margin-left:8px">生命周期:{{ classification.lifecycle }}</el-tag>
<el-tag size="small" effect="plain" style="margin-left:8px">{{ source === 'official' ? '📄 官方现金流量表' : '✍️ 人工录入' }}</el-tag>
</div>
</div>
<div class="verdict-left" v-else>
<el-alert type="warning" :closable="false" :title="classification.reason || '净额数据不足,暂无法判定类型'" show-icon />
</div>
<div class="verdict-nets">
<div class="net-item" v-for="n in netList" :key="n.key">
<div class="net-name">{{ n.name }}</div>
<div class="net-value" :style="{ color: netColor(n) }">
{{ fmt(n.value) }}
</div>
<div class="net-sign" :style="{ color: netColor(n) }">
{{ n.sign === '+' ? '进钱 ▲' : n.sign === '-' ? '出钱 ▼' : '为0' }}
</div>
<div class="net-hint">{{ n.hint }}</div>
</div>
</div>
</el-card>
</el-col>
</el-row>
<!-- 瀑布图 + 画像/风险 -->
<el-row :gutter="16" style="margin-top:16px">
<el-col :span="15">
<el-card shadow="never" header="现金流瀑布图 — 期初现金 → 经营 → 投资 → 筹资 → 期末现金">
<div ref="waterfallRef" style="height:300px"></div>
<div v-if="!waterfall" style="color:#909399;font-size:12px;text-align:center;padding:8px">
暂缺期初/期末现金余额,仅显示三大活动净额
</div>
</el-card>
</el-col>
<el-col :span="9">
<el-card shadow="never" header="判定说明(CMA P2 · 现金流量表分析)">
<template v-if="classification.classified">
<p class="desc-p">{{ classification.profile }}</p>
<el-alert type="error" :closable="false" title="风险提示" show-icon>
<span>{{ classification.risk }}</span>
</el-alert>
</template>
<div v-else style="color:#909399;font-size:13px;line-height:1.8">
八类判定需要经营(O)/投资(I)/筹资(F)三大活动净额符号:
<br />黄金法则:长期看经营现金流必须为正,且应大于净利润(利润质量)。
<br />当前账套请先录入官方现金流量表三大活动净额。
</div>
</el-card>
</el-col>
</el-row>
<!-- 八类判定规则表 -->
<el-row :gutter="16" style="margin-top:16px">
<el-col :span="24">
<el-card shadow="never" header="八类企业定性判定规则表(O/I/F 符号组合)">
<el-table :data="rules" size="small" stripe :row-class-name="ruleRowClass">
<el-table-column prop="type_no" label="类型" width="60" align="center">
<template #default="{ row }">
<el-tag :type="row.type_no === currentType ? 'primary' : 'info'" size="small" effect="plain">
{{ row.type_no }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="组合" width="150" align="center">
<template #default="{ row }">
<span class="sign-combo">
<b :style="{ color: row.o_sign === '+' ? '#67C23A' : '#F56C6C' }">O{{ row.o_sign }}</b>
<b :style="{ color: row.i_sign === '+' ? '#67C23A' : '#F56C6C' }">I{{ row.i_sign }}</b>
<b :style="{ color: row.f_sign === '+' ? '#67C23A' : '#F56C6C' }">F{{ row.f_sign }}</b>
</span>
</template>
</el-table-column>
<el-table-column prop="name" label="定性名称" width="120" />
<el-table-column label="健康度" width="120">
<template #default="{ row }">{{ row.health_emoji }} {{ row.health_label }}</template>
</el-table-column>
<el-table-column prop="lifecycle" label="生命周期" width="160" />
<el-table-column prop="profile" label="画像(行为特征)" min-width="300" show-overflow-tooltip />
<el-table-column prop="risk" label="风险提示" min-width="260" show-overflow-tooltip />
</el-table>
</el-card>
</el-col>
</el-row>
<!-- 已录入数据 -->
<el-row :gutter="16" style="margin-top:16px">
<el-col :span="24">
<el-card shadow="never" header="三大活动净额入库记录(万元)">
<el-table :data="valueRows" size="small" stripe>
<el-table-column prop="period" label="期间" width="100" />
<el-table-column label="经营活动净额" width="130" align="right">
<template #default="{ row }"><span :style="{ color: row.operating_net >= 0 ? '#67C23A' : '#F56C6C' }">{{ fmt(row.operating_net) }}</span></template>
</el-table-column>
<el-table-column label="投资活动净额" width="130" align="right">
<template #default="{ row }"><span :style="{ color: row.investing_net >= 0 ? '#67C23A' : '#F56C6C' }">{{ fmt(row.investing_net) }}</span></template>
</el-table-column>
<el-table-column label="筹资活动净额" width="130" align="right">
<template #default="{ row }"><span :style="{ color: row.financing_net >= 0 ? '#67C23A' : '#F56C6C' }">{{ fmt(row.financing_net) }}</span></template>
</el-table-column>
<el-table-column label="期初现金" width="100" align="right"><template #default="{ row }">{{ row.begin_cash != null ? fmt(row.begin_cash) : '-' }}</template></el-table-column>
<el-table-column label="期末现金" width="100" align="right"><template #default="{ row }">{{ row.end_cash != null ? fmt(row.end_cash) : '-' }}</template></el-table-column>
<el-table-column prop="source" label="来源" width="130" />
<el-table-column prop="remark" label="备注" min-width="160" show-overflow-tooltip />
<el-table-column prop="updated_at" label="更新时间" width="150" />
<el-table-column v-if="canWrite" label="操作" width="80" align="center">
<template #default="{ row }">
<el-button size="small" text type="primary" @click="openDialog(row)">更新</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</el-col>
</el-row>
</template>
</div>
<!-- 录入/更新弹窗(MyDialog 项目铁律) -->
<MyDialog v-model="showDialog" title="录入现金流量表三大活动净额" :width="620">
<el-form label-width="140px" size="small">
<el-form-item label="期间" required>
<el-input v-model="form.period" placeholder="YYYY-MM,如 2026-08" style="width:220px" />
<div class="form-tip">官方现金流量表所属期间(月度)</div>
</el-form-item>
<el-form-item label="经营活动净额" required>
<el-input-number v-model="form.operating_net" :precision="2" :step="1" :controls="true" style="width:220px" />
<div class="form-tip">万元;正=造血进钱,负=经营失血</div>
</el-form-item>
<el-form-item label="投资活动净额" required>
<el-input-number v-model="form.investing_net" :precision="2" :step="1" style="width:220px" />
<div class="form-tip">万元;负=扩张投入,正=处置/回报回笼</div>
</el-form-item>
<el-form-item label="筹资活动净额" required>
<el-input-number v-model="form.financing_net" :precision="2" :step="1" style="width:220px" />
<div class="form-tip">万元;正=融资输血,负=还债/分红</div>
</el-form-item>
<el-form-item label="期初现金余额">
<el-input-number v-model="form.begin_cash" :precision="2" :step="1" style="width:220px" />
<div class="form-tip">可选;用于瀑布图(万元)</div>
</el-form-item>
<el-form-item label="期末现金余额">
<el-input-number v-model="form.end_cash" :precision="2" :step="1" style="width:220px" />
<div class="form-tip">可选;用于瀑布图(万元)</div>
</el-form-item>
<el-form-item label="来源">
<el-radio-group v-model="form.source">
<el-radio value="official">官方现金流量表</el-radio>
<el-radio value="manual">人工录入</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="备注">
<el-input v-model="form.remark" type="textarea" :rows="2" placeholder="口径说明 / 数据来源,如:2026年8月官方现金流量表" />
</el-form-item>
</el-form>
<template #footer>
<el-button size="small" @click="showDialog = false">取消</el-button>
<el-button size="small" type="primary" :loading="saving" @click="saveValue">保存</el-button>
</template>
</MyDialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, nextTick } from 'vue'
import { ElMessage } from 'element-plus'
import { Refresh } from '@element-plus/icons-vue'
import * as echarts from 'echarts'
import api from '@/api'
import MyDialog from '@/components/MyDialog.vue'
const entityName = ref(localStorage.getItem('cma_entity_name') || '')
const userRole = ref<string>((JSON.parse(localStorage.getItem('cma_user') || '{}') as any).role || '')
const canWrite = computed(() => ['ceo', 'finance'].includes(userRole.value))
const period = ref('')
const periods = ref<string[]>([])
const loading = ref(false)
const loadingPeriods = ref(false)
const dataAvailable = ref(false)
const emptyMessage = ref('该期间暂无官方现金流量表净额数据')
const source = ref('official')
const nets = ref<any>(null)
const waterfall = ref<any>(null)
const classification = ref<any>({ classified: false })
const rules = ref<any[]>([])
const valueRows = ref<any[]>([])
const showDialog = ref(false)
const saving = ref(false)
const form = ref<any>({
period: '',
operating_net: 0,
investing_net: 0,
financing_net: 0,
begin_cash: null,
end_cash: null,
source: 'official',
remark: '',
})
const waterfallRef = ref<HTMLElement>()
let waterfallChart: echarts.ECharts | null = null
const netList = computed(() => {
const n = nets.value || {}
const meta: any = {
operating: { name: '经营活动 O', hint: '造血:主业赚不赚钱' },
investing: { name: '投资活动 I', hint: '成长:扩张 or 收缩' },
financing: { name: '筹资活动 F', hint: '输血:融资 or 还债' },
}
return ['operating', 'investing', 'financing']
.filter((k) => n[k] && n[k].value != null)
.map((k) => ({ key: k, ...meta[k], ...n[k] }))
})
const currentType = computed(() => (classification.value?.classified ? classification.value.type_no : -1))
const healthTagType = computed(() => {
const label = classification.value?.health_label || ''
if (['最优', '良好'].includes(label)) return 'success'
if (['看增速', '需辨析'].includes(label)) return 'warning'
if (['高风险'].includes(label)) return 'warning'
return 'danger'
})
function fmt(v: any): string {
if (v === null || v === undefined || Number.isNaN(Number(v))) return '-'
return Number(v).toFixed(2)
}
function netColor(n: any): string {
if (n.sign === '+') return '#67C23A'
if (n.sign === '-') return '#F56C6C'
return '#909399'
}
function ruleRowClass({ row }: any) {
return row.type_no === currentType.value ? 'rule-row-current' : ''
}
async function loadPeriods() {
loadingPeriods.value = true
try {
const r: any = await api.get('/cashflow-structure/periods')
periods.value = (r && (r.periods || [])) || []
} catch (_) { /* 静默 */ }
loadingPeriods.value = false
}
async function loadValues() {
try {
const r: any = await api.get('/cashflow-structure/values')
valueRows.value = (r && (r.items || [])) || []
} catch (_) { valueRows.value = [] }
}
async function loadRules() {
try {
const r: any = await api.get('/cashflow-structure/rules')
rules.value = (r && (r.rules || [])) || []
} catch (_) { rules.value = [] }
}
async function loadData() {
loading.value = true
try {
const params: any = {}
if (period.value) params.period = period.value
const d: any = await api.get('/cashflow-structure/analysis', { params })
dataAvailable.value = !!d?.data_available
source.value = d?.source || 'official'
nets.value = d?.nets || null
waterfall.value = d?.waterfall || null
classification.value = d?.classification || { classified: false }
if (!dataAvailable.value) {
emptyMessage.value = d?.message || '该期间暂无官方现金流量表净额数据'
}
if (!period.value && d?.period) period.value = d.period
await nextTick()
renderWaterfall()
} catch (e: any) {
console.error('load cashflow structure analysis failed', e)
dataAvailable.value = false
emptyMessage.value = '加载失败:' + (e?.message || '请稍后重试')
} finally {
loading.value = false
}
}
async function reload() {
await Promise.all([loadPeriods(), loadValues()])
if (!period.value && periods.value.length) period.value = periods.value[0]
await loadData()
}
function renderWaterfall() {
if (!waterfallRef.value) return
if (!waterfallChart) waterfallChart = echarts.init(waterfallRef.value)
const steps = waterfall.value?.steps || []
if (!steps.length) {
waterfallChart.clear()
return
}
const baseData: number[] = []
const valueData: number[] = []
const colorData: string[] = []
const labelData: string[] = []
let cum = 0
steps.forEach((s: any, idx: number) => {
if (idx === 0 && s.type === 'begin') {
baseData.push(0)
valueData.push(Number(s.value) || 0)
colorData.push('#409EFF')
labelData.push(fmt(s.value))
cum = Number(s.value) || 0
} else if (s.type === 'end') {
baseData.push(0)
valueData.push(Number(s.value) || 0)
colorData.push('#409EFF')
labelData.push(fmt(s.value))
cum = Number(s.value) || 0
} else {
const v = Number(s.value) || 0
baseData.push(cum)
valueData.push(v)
colorData.push(v >= 0 ? '#67C23A' : '#F56C6C')
labelData.push((v >= 0 ? '+' : '') + fmt(v))
cum += v
}
})
const names = steps.map((s: any) => s.step)
waterfallChart.setOption({
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
formatter: (params: any[]) => {
const i = params[0]?.dataIndex ?? 0
const s = steps[i]
let tip = `<b>${s.step}(${period.value})</b><br/>`
if (s.type === 'flow') {
const v = Number(s.value) || 0
tip += `净额:<span style="color:${v >= 0 ? '#67C23A' : '#F56C6C'}">${v >= 0 ? '+' : ''}${v.toFixed(2)} 万元</span>`
} else {
tip += `余额:${Number(s.value).toFixed(2)} 万元`
}
return tip
},
},
grid: { left: 20, right: 20, top: 40, bottom: 30, containLabel: true },
xAxis: { type: 'category', data: names, axisLabel: { interval: 0 } },
yAxis: { type: 'value', name: '万元', axisLabel: { formatter: '{value}' } },
series: [
{
name: '底', type: 'bar', stack: 'wf',
itemStyle: { color: 'transparent' },
emphasis: { itemStyle: { color: 'transparent' } },
data: baseData,
silent: true,
},
{
name: '净变动', type: 'bar', stack: 'wf',
data: valueData,
barWidth: 46,
itemStyle: { borderRadius: 2 },
label: {
show: true, position: 'top', formatter: (p: any) => labelData[p.dataIndex],
color: '#333', fontSize: 11,
},
emphasis: { focus: 'series' },
},
],
})
}
function openDialog(row?: any) {
form.value = {
period: row?.period || '',
operating_net: row ? row.operating_net : 0,
investing_net: row ? row.investing_net : 0,
financing_net: row ? row.financing_net : 0,
begin_cash: row?.begin_cash ?? null,
end_cash: row?.end_cash ?? null,
source: row?.source || 'official',
remark: row?.remark || '',
}
showDialog.value = true
}
async function saveValue() {
const f = form.value
if (!/^\d{4}-\d{2}$/.test(f.period || '')) {
ElMessage.warning('期间格式须为 YYYY-MM')
return
}
if ([f.operating_net, f.investing_net, f.financing_net].some((v) => v === null || v === undefined || Number.isNaN(Number(v)))) {
ElMessage.warning('三大活动净额必填')
return
}
saving.value = true
try {
const payload: any = {
period: f.period,
operating_net: Number(f.operating_net),
investing_net: Number(f.investing_net),
financing_net: Number(f.financing_net),
source: f.source,
}
if (f.begin_cash !== null && f.begin_cash !== undefined && !Number.isNaN(Number(f.begin_cash))) payload.begin_cash = Number(f.begin_cash)
if (f.end_cash !== null && f.end_cash !== undefined && !Number.isNaN(Number(f.end_cash))) payload.end_cash = Number(f.end_cash)
if (f.remark) payload.remark = f.remark
const r: any = await api.post('/cashflow-structure/values', payload)
const cls = r?.classification || {}
if (cls?.classified) {
ElMessage.success(`已保存:${r.period} → 类型${cls.type_no} ${cls.name}`)
} else {
ElMessage.success(`已保存:${r.period}${cls?.reason ? '(' + cls.reason + ')' : ''}`)
}
showDialog.value = false
await reload()
} catch (e: any) {
ElMessage.error('保存失败:' + (e?.response?.data?.detail || e?.message || '未知错误'))
} finally {
saving.value = false
}
}
function resizeCharts() {
waterfallChart?.resize()
}
onMounted(async () => {
window.addEventListener('resize', resizeCharts)
await loadRules()
await loadPeriods()
if (periods.value.length && !period.value) period.value = periods.value[0]
await loadValues()
await loadData()
})
</script>
<style scoped>
.cashflow-structure-page {
padding: 4px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
flex-wrap: wrap;
gap: 8px;
}
.page-header h2 {
margin: 0;
font-size: 18px;
}
.header-right {
display: flex;
align-items: center;
gap: 8px;
}
.verdict-card {
border-left: 4px solid #409EFF;
}
.verdict-left {
margin-bottom: 14px;
}
.verdict-badge {
display: flex;
align-items: baseline;
gap: 12px;
margin-bottom: 8px;
}
.verdict-type {
font-size: 26px;
font-weight: 700;
color: #409EFF;
}
.verdict-name {
font-size: 20px;
font-weight: 600;
}
.verdict-health {
display: flex;
align-items: center;
}
.verdict-nets {
display: flex;
gap: 16px;
flex-wrap: wrap;
}
.net-item {
flex: 1;
min-width: 140px;
background: #f7f9fc;
border-radius: 6px;
padding: 10px 14px;
text-align: center;
}
.net-name {
font-size: 12px;
color: #606266;
}
.net-value {
font-size: 22px;
font-weight: 700;
margin: 2px 0;
}
.net-sign {
font-size: 13px;
font-weight: 600;
}
.net-hint {
font-size: 11px;
color: #909399;
margin-top: 2px;
}
.desc-p {
font-size: 13px;
line-height: 1.8;
color: #303133;
margin: 0 0 10px;
}
.sign-combo {
display: inline-flex;
gap: 10px;
font-size: 15px;
letter-spacing: 1px;
}
.form-tip {
font-size: 12px;
color: #909399;
line-height: 1.5;
margin-left: 8px;
}
:deep(.rule-row-current td) {
background: #ecf5ff !important;
}
</style>