Files
cma-management/frontend/src/views/CashFlowStructure.vue
T
Hermes CI Fix e5dd6e380b 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/非法期间)
2026-09-03 01:22:46 +08:00

577 lines
22 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>