Files
cma-management/frontend/src/views/CostIntelligence.vue
T
Hermes CI Fix 8ec846c6df feat: 预测性成本智能·宏观敏感性因素联动(IMA 2026.7完整版)
- 内置3宏观因素: 原油价格/美元汇率/CPI通胀率
- 敏感性引擎: 按KPI类别推断弹性(成本类油价0.15/利润类0.12/营收类0.08), 方向+因素涨KPI涨
- 负值KPI(亏损)方向反转修复: 油价涨→净利更亏
- API: GET /predict/kpi-forecast/sensitivity?pct=10 → KPI×因素矩阵(±pct调整后预测)
- 前端: 敏感性幅度选择(±5/10/20%) + 敏感性矩阵表(同向/反向+↑↓调整值)
- 诚实标注: 模型弹性(规则推断,非历史回归), 后续可用宏观历史数据回归校准
- 验证: Chrome实测页面+API矩阵, pytest 46 passed
2026-08-25 00:44:45 +08:00

349 lines
12 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="cost-intelligence">
<!-- 顶部控制条模型选择 / 预测期数 / 刷新 -->
<el-card shadow="never" class="ctrl-card">
<div class="ctrl-bar">
<div class="ctrl-left">
<span class="ctrl-label">预测模型</span>
<el-select v-model="model" style="width: 150px" @change="loadData">
<el-option label="线性回归" value="linear" />
<el-option label="移动平均" value="moving_average" />
</el-select>
<span class="ctrl-label" style="margin-left: 16px;">预测期数</span>
<el-select v-model="periods" style="width: 100px" @change="loadData">
<el-option label="3期" :value="3" />
<el-option label="6期" :value="6" />
<el-option label="12期" :value="12" />
</el-select>
<span class="ctrl-label" style="margin-left: 16px;">敏感性幅度</span>
<el-select v-model="sensPct" style="width: 90px" @change="loadSensitivity">
<el-option label="±5%" :value="5" />
<el-option label="±10%" :value="10" />
<el-option label="±20%" :value="20" />
</el-select>
<el-button type="primary" :loading="loading" style="margin-left: 16px;" @click="loadAll">刷新预测</el-button>
</div>
<div class="ctrl-right">
<span class="mapping-hint">基于 kpi_values 历史数据 · 线性回归/移动平均 · 置信度诚实标注</span>
</div>
</div>
</el-card>
<!-- 概览统计 -->
<el-row :gutter="12" style="margin-top: 12px;">
<el-col :span="6">
<div class="stat-card"><div class="s-label">可预测KPI</div><div class="s-value blue">{{ list.length }}</div></div>
</el-col>
<el-col :span="6">
<div class="stat-card"><div class="s-label">高置信度(high)</div><div class="s-value green">{{ confCount('high') }}</div></div>
</el-col>
<el-col :span="6">
<div class="stat-card"><div class="s-label">上升趋势(up)</div><div class="s-value red">{{ trendCount('up') }}</div></div>
</el-col>
<el-col :span="6">
<div class="stat-card"><div class="s-label">下降趋势(down)</div><div class="s-value orange">{{ trendCount('down') }}</div></div>
</el-col>
</el-row>
<!-- 主体KPI列表 + 趋势图 -->
<el-row :gutter="12" style="margin-top: 12px;">
<el-col :span="9">
<el-card shadow="never">
<template #header>
<div class="card-header">
<span>📊 财务KPI预测结果</span>
<el-tag size="small" type="info">{{ model === 'linear' ? '线性回归' : '移动平均' }}</el-tag>
</div>
</template>
<el-table :data="list" size="small" border stripe highlight-current-row
height="520" @row-click="onSelect" @current-change="onSelect">
<el-table-column prop="kpi.name" label="KPI名称" min-width="120" show-overflow-tooltip />
<el-table-column label="最近值" width="90" align="right">
<template #default="{ row }">{{ fmtVal(lastHistory(row)) }}{{ row.kpi.unit }}</template>
</el-table-column>
<el-table-column label="下一期预测" width="110" align="right">
<template #default="{ row }">
<span :style="{ color: trendColor(row.trend), fontWeight: 600 }">{{ fmtVal(row.next_target) }}{{ row.kpi.unit }}</span>
</template>
</el-table-column>
<el-table-column label="趋势" width="70" align="center">
<template #default="{ row }">
<el-tag size="small" :type="trendTagType(row.trend)">{{ trendLabel(row.trend) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="置信度" width="80" align="center">
<template #default="{ row }">
<el-tag size="small" :type="confTagType(row.confidence)">{{ confLabel(row.confidence) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="变化" width="80" align="right">
<template #default="{ row }">
<span :style="{ color: row.trend_pct >= 0 ? '#F56C6C' : '#67C23A' }">{{ row.trend_pct >= 0 ? '+' : '' }}{{ row.trend_pct?.toFixed?.(1) ?? '-' }}%</span>
</template>
</el-table-column>
</el-table>
</el-card>
</el-col>
<el-col :span="15">
<el-card shadow="never">
<template #header>
<div class="card-header">
<span>📈 趋势与预测</span>
<el-tag v-if="selected" size="small" type="primary">{{ selected.kpi.name }}</el-tag>
<span v-else class="empty-hint">点击左侧KPI查看趋势</span>
</div>
</template>
<div ref="chartRef" class="trend-chart"></div>
</el-card>
</el-col>
</el-row>
<!-- 宏观敏感性联动IMA 2026.7 -->
<el-card shadow="never" style="margin-top: 12px;">
<template #header>
<div class="card-header">
<span>🌐 宏观敏感性因素联动</span>
<el-tag size="small" type="warning">模型弹性规则推断非历史回归</el-tag>
<span class="empty-hint">因素变动 {{ sensPct }}% 对预测值的影响</span>
</div>
</template>
<el-table :data="sensList" size="small" border stripe max-height="360">
<el-table-column prop="kpi.name" label="KPI" min-width="110" show-overflow-tooltip />
<el-table-column label="类别" width="70" align="center">
<template #default="{ row }">{{ catLabel(row.category) }}</template>
</el-table-column>
<el-table-column label="基准预测" width="100" align="right">
<template #default="{ row }">{{ fmtVal(row.next_target) }}</template>
</el-table-column>
<el-table-column v-for="f in sensFactors" :key="f.key" :label="f.name" min-width="150" align="center">
<template #default="{ row }">
<template v-if="factorOf(row, f.key)">
<span :style="{ color: factorOf(row, f.key).direction === '+' ? '#F56C6C' : '#67C23A' }">
{{ factorOf(row, f.key).direction === '+' ? '同向' : '反向' }}
</span>
<span class="sens-val">{{ fmtVal(factorOf(row, f.key).adj_up) }} {{ fmtVal(factorOf(row, f.key).adj_down) }}</span>
</template>
</template>
</el-table-column>
</el-table>
<div class="note-line" style="margin-top:8px;">
弹性系数按KPI类别推断成本类对油价最敏感0.15利润类0.12营收类0.08),MVP规则模型后续可用宏观历史数据回归校准
</div>
</el-card>
<!-- 预测说明 -->
<el-card shadow="never" style="margin-top: 12px;">
<div class="note-line">
<b>说明</b>本模块基于 kpi_values 历史数据做趋势预测线性回归/移动平均),输出预测值+趋势方向+模型置信度
<el-tag size="small" type="warning" style="margin-left:6px;">置信度 low 表示历史数据不足或波动大预测仅供参考不构成决策依据</el-tag>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { predictApi } from '../api/index'
const model = ref('linear')
const periods = ref(3)
const loading = ref(false)
const list = ref<any[]>([])
const selected = ref<any>(null)
const chartRef = ref<HTMLElement | null>(null)
let chart: any = null
// ── 宏观敏感性联动 ──
const sensPct = ref(10)
const sensList = ref<any[]>([])
const sensFactors = ref<any[]>([])
function catLabel(c: string) {
return { profit: '利润', revenue: '营收', cost: '成本', cash: '现金流' }[c] || c
}
function factorOf(row: any, key: string) {
return (row.factors || []).find((f: any) => f.factor_key === key)
}
function lastHistory(row: any) {
const h = row.history || []
return h.length ? h[h.length - 1].value : null
}
function fmtVal(v: any) {
if (v === null || v === undefined) return '-'
return Number(v).toFixed(2)
}
function trendLabel(t: string) {
return { up: '上升', down: '下降', flat: '平稳' }[t] || t
}
function trendTagType(t: string) {
return { up: 'danger', down: 'success', flat: 'info' }[t] || 'info'
}
function trendColor(t: string) {
return { up: '#F56C6C', down: '#67C23A', flat: '#909399' }[t] || '#909399'
}
function confLabel(c: string) {
return { high: '高', medium: '中', low: '低' }[c] || c
}
function confTagType(c: string) {
return { high: 'success', medium: 'warning', low: 'info' }[c] || 'info'
}
function confCount(c: string) {
return list.value.filter(x => x.confidence === c).length
}
function trendCount(t: string) {
return list.value.filter(x => x.trend === t).length
}
async function loadData() {
loading.value = true
try {
const r: any = await predictApi.kpiForecastFinance({ model: model.value, periods: periods.value })
list.value = r.data || []
if (list.value.length && !selected.value) {
selected.value = list.value[0]
} else if (list.value.length) {
const still = list.value.find(x => x.kpi?.code === selected.value?.kpi?.code)
selected.value = still || list.value[0]
}
await nextTick()
renderChart()
} catch (e: any) {
console.error('预测加载失败', e)
} finally {
loading.value = false
}
}
async function loadSensitivity() {
try {
const r: any = await predictApi.kpiForecastSensitivity({ pct: sensPct.value, periods: periods.value, model: model.value })
sensList.value = r.data || []
sensFactors.value = r.factors || []
} catch (e: any) {
console.error('敏感性加载失败', e)
}
}
async function loadAll() {
await Promise.all([loadData(), loadSensitivity()])
}
function onSelect(row: any) {
selected.value = row
renderChart()
}
function renderChart() {
if (!chartRef.value) return
if (!chart) {
import('echarts').then(echarts => {
chart = echarts.init(chartRef.value!)
draw()
})
return
}
draw()
}
function draw() {
if (!chart || !selected.value) return
const row = selected.value
const hist = row.history || []
const fc = row.forecast || []
const labels = [...hist.map((h: any) => h.period), ...fc.map((f: any) => f.period)]
const histVals = hist.map((h: any) => h.value)
const fcVals = fc.map((f: any) => f.value ?? f.next_value)
chart.setOption({
tooltip: { trigger: 'axis' },
legend: { data: ['历史', '预测'] },
grid: { left: 50, right: 20, top: 40, bottom: 40 },
xAxis: { type: 'category', data: labels },
yAxis: { type: 'value', scale: true },
series: [
{ name: '历史', type: 'line', smooth: true, data: histVals, itemStyle: { color: '#409EFF' }, areaStyle: { opacity: 0.08 } },
{ name: '预测', type: 'line', smooth: true, data: fcVals, itemStyle: { color: '#E6A23C' }, lineStyle: { type: 'dashed' } },
],
})
}
function resizeChart() {
chart?.resize()
}
onMounted(() => {
loadAll()
window.addEventListener('resize', resizeChart)
})
onBeforeUnmount(() => {
window.removeEventListener('resize', resizeChart)
chart?.dispose()
})
</script>
<style scoped>
.cost-intelligence {
padding: 16px 20px;
}
.ctrl-bar {
display: flex;
align-items: center;
justify-content: space-between;
}
.ctrl-left {
display: flex;
align-items: center;
}
.ctrl-label {
font-size: 13px;
color: #606266;
margin-right: 8px;
}
.mapping-hint {
font-size: 12px;
color: #909399;
}
.stat-card {
background: #FAFBFC;
border: 1px solid #EBEEF5;
border-radius: 8px;
padding: 14px 16px;
text-align: center;
}
.s-label {
font-size: 12px;
color: #909399;
}
.s-value {
font-size: 24px;
font-weight: 700;
margin-top: 4px;
}
.s-value.blue { color: #409EFF; }
.s-value.green { color: #67C23A; }
.s-value.red { color: #F56C6C; }
.s-value.orange { color: #E6A23C; }
.card-header {
display: flex;
align-items: center;
gap: 8px;
}
.empty-hint {
font-size: 12px;
color: #C0C4CC;
}
.sens-val {
font-size: 12px;
color: #606266;
margin-left: 4px;
}
.trend-chart {
height: 520px;
width: 100%;
}
.note-line {
font-size: 12px;
color: #606266;
}
</style>