feat: 预测性成本智能MVP — KPI趋势预测引擎(线性回归/移动平均)+API+前端Tab+pytest覆盖

This commit is contained in:
Hermes CI Fix
2026-08-25 00:10:46 +08:00
parent fb9eba38a8
commit 13aa153875
6 changed files with 863 additions and 1 deletions
+3
View File
@@ -220,6 +220,9 @@ export const predictApi = {
forecastAccuracy: (params?: any) => api.get('/predict/accuracy', { params }),
scenarioSuggestions: (params?: any) => api.get('/predict/scenario-suggestions', { params }),
generateSuggestion: (data: any) => api.post('/predict/scenario-suggestion/generate', data),
// KPI趋势预测(预测性成本智能)
kpiForecast: (params?: any) => api.get('/predict/kpi-forecast', { params }),
kpiForecastFinance: (params?: any) => api.get('/predict/kpi-forecast/finance', { params }),
}
export const deviationPushApi = {
+205 -1
View File
@@ -321,12 +321,87 @@
</el-col>
</el-row>
</el-tab-pane>
<!-- Tab 6: KPI趋势预测预测性成本智能 MVP -->
<el-tab-pane label="KPI预测" name="kpiForecast">
<el-card>
<template #header>
<div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px;">
<span>📈 财务KPI趋势预测预测性成本智能 MVP</span>
<div style="display:flex;gap:8px;align-items:center;">
<span style="font-size:12px;color:#909399;">模型</span>
<el-select v-model="kfModel" style="width:120px;" size="small">
<el-option label="线性回归" value="linear" />
<el-option label="移动平均" value="moving_average" />
</el-select>
<span style="font-size:12px;color:#909399;">期数</span>
<el-select v-model="kfPeriods" style="width:80px;" size="small">
<el-option label="3期" :value="3" />
<el-option label="6期" :value="6" />
<el-option label="12期" :value="12" />
</el-select>
<el-button size="small" type="primary" @click="loadKpiForecast" :loading="kfLoading">🔄 重新预测</el-button>
</div>
</div>
</template>
<div style="font-size:12px;color:#909399;margin-bottom:8px;">{{ kfSummary }}</div>
<el-table :data="kfResult" border stripe size="small" style="width:100%;" @expand-change="onKpiExpand">
<el-table-column type="expand">
<template #default="{ row }">
<div style="padding:12px 24px;">
<div style="font-size:12px;color:#606266;margin-bottom:8px;">💡 {{ row.summary }}</div>
<div :ref="(el: any) => setKpiChartEl(el, row)" style="height:300px;width:100%;"></div>
</div>
</template>
</el-table-column>
<el-table-column label="KPI名称" min-width="180">
<template #default="{ row }">
<div>{{ row.kpi.name }}</div>
<div style="font-size:11px;color:#909399;">{{ row.kpi.code }}{{ row.kpi.unit || '无量纲' }}</div>
</template>
</el-table-column>
<el-table-column label="趋势方向" width="100" align="center">
<template #default="{ row }">
<el-tag :type="kfTrendMap[row.trend]?.tag || 'info'" size="small" effect="light">
{{ row.trend === 'up' ? '↑ 上升' : row.trend === 'down' ? '↓ 下降' : '→ 平稳' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="下一期预测" width="140" align="right">
<template #default="{ row }">
<strong :style="{ color: row.trend === 'up' ? '#f56c6c' : row.trend === 'down' ? '#67c23a' : '#303133' }">
{{ fmtKfValue(row.next_target) }}
</strong>
</template>
</el-table-column>
<el-table-column label="置信区间" width="180" align="center">
<template #default="{ row }">
<span v-if="row.forecast?.length" style="font-size:12px;color:#606266;">
{{ fmtKfValue(row.forecast[0].lower) }} ~ {{ fmtKfValue(row.forecast[0].upper) }}
</span>
<span v-else>--</span>
</template>
</el-table-column>
<el-table-column label="置信度" width="90" align="center">
<template #default="{ row }">
<el-tag :type="kfConfMap[row.confidence]?.tag || 'info'" size="small">
{{ kfConfMap[row.confidence]?.label || row.confidence }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="history_count" label="历史期数" width="80" align="center" />
<el-table-column label="模型" width="90" align="center">
<template #default="{ row }">{{ row.model === 'moving_average' ? '移动平均' : '线性回归' }}</template>
</el-table-column>
</el-table>
</el-card>
</el-tab-pane>
</el-tabs>
</div>
</template>
<script setup lang="ts">
import { ref, nextTick } from 'vue'
import { ref, nextTick, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { predictApi } from '../api/index'
@@ -604,6 +679,135 @@ function renderCfChart() {
})
})
}
// ── KPI趋势预测(预测性成本智能 MVP) ──
const kfLoading = ref(false)
const kfModel = ref('linear')
const kfPeriods = ref(3)
const kfResult = ref<any[]>([])
const kfSummary = ref('')
const kfEntityId = ref(Number(localStorage.getItem('cma_entity_id') || 1))
const kfChartEls: Record<string, HTMLElement | null> = {}
const kfCharts: Record<string, any> = {}
const kfTrendMap: Record<string, { label: string; tag: string }> = {
up: { label: '上升', tag: 'danger' },
down: { label: '下降', tag: 'success' },
flat: { label: '平稳', tag: 'info' },
}
const kfConfMap: Record<string, { label: string; tag: string }> = {
high: { label: '高', tag: 'success' },
medium: { label: '中', tag: 'warning' },
low: { label: '低', tag: 'danger' },
}
function fmtKfValue(v: any): string {
if (v === null || v === undefined) return '--'
return Number(v).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
async function loadKpiForecast() {
kfLoading.value = true
try {
const res = await predictApi.kpiForecastFinance({
entity_id: kfEntityId.value,
periods: kfPeriods.value,
model: kfModel.value,
}) as any
kfResult.value = res?.data || []
kfSummary.value = `${res?.total ?? 0} 个财务KPI可预测(历史数据≥3期),按可预测性排序;点击行展开查看历史趋势与预测区间`
} catch (e: any) {
ElMessage.error('KPI预测加载失败: ' + (e?.message || ''))
kfResult.value = []
kfSummary.value = ''
}
kfLoading.value = false
}
// 切换到「KPI预测」Tab 时自动加载
watch(activeTab, (t) => {
if (t === 'kpiForecast') loadKpiForecast()
})
function setKpiChartEl(el: any, row: any) {
if (!el) return
kfChartEls[row.kpi.code] = el
}
function onKpiExpand(row: any, expandedRows: any[]) {
if (!expandedRows.includes(row)) return
nextTick(() => renderKpiChart(row))
}
function renderKpiChart(row: any) {
const el = kfChartEls[row.kpi.code]
if (!el || !row.forecast?.length) return
import('echarts').then(echarts => {
if (kfCharts[row.kpi.code]) kfCharts[row.kpi.code].dispose()
const chart = echarts.init(el)
kfCharts[row.kpi.code] = chart
const hist = row.history || []
const fc = row.forecast || []
const histLen = hist.length
const periods = [...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.predicted)
const lower = fc.map((f: any) => f.lower)
const upper = fc.map((f: any) => f.upper)
// 历史线:仅历史区间;预测线:衔接历史末值后延伸
const histSeries = [...histVals, ...Array(fc.length).fill(null)]
const fcSeries = [...Array(Math.max(0, histLen - 1)).fill(null), histVals[histLen - 1], ...fcVals]
// 置信区间带(stack 双线夹层)
const bandLower = [...Array(histLen).fill(null), ...lower]
const bandWidth = [...Array(histLen).fill(null), ...upper.map((u: number, i: number) => u - lower[i])]
chart.setOption({
grid: { left: 70, right: 30, top: 30, bottom: 40 },
xAxis: { type: 'category', data: periods, axisLabel: { rotate: 45, fontSize: 10 } },
yAxis: { type: 'value', name: row.kpi.unit || '' },
series: [
{
name: '历史值', type: 'line', data: histSeries,
smooth: true, symbol: 'circle', symbolSize: 5,
lineStyle: { width: 2, color: '#409eff' }, itemStyle: { color: '#409eff' },
},
{
name: '预测值', type: 'line', data: fcSeries,
smooth: true, symbol: 'circle', symbolSize: 5,
lineStyle: { width: 2, color: '#e6a23c', type: 'dashed' }, itemStyle: { color: '#e6a23c' },
},
{
name: '区间下界', type: 'line', data: bandLower,
stack: 'ci', lineStyle: { opacity: 0 }, symbol: 'none',
areaStyle: { color: 'rgba(230,162,60,0.15)' },
},
{
name: '区间宽', type: 'line', data: bandWidth,
stack: 'ci', lineStyle: { opacity: 0 }, symbol: 'none',
},
{
name: '预测点', type: 'scatter',
data: fcVals.map((v: number, i: number) => [histLen + i, v]),
symbolSize: 9, itemStyle: { color: '#e6a23c', borderColor: '#fff', borderWidth: 1 },
},
],
tooltip: {
trigger: 'axis',
formatter: function(params: any) {
const idx = params[0]?.dataIndex
if (idx === undefined) return ''
const p = periods[idx]
if (idx < histLen) return `<strong>${p}</strong><br/>实际值: ${fmtKfValue(histVals[idx])}`
const f = fc[idx - histLen]
return `<strong>${p}</strong><br/>预测值: <strong>${fmtKfValue(f.predicted)}</strong><br/>置信区间: ${fmtKfValue(f.lower)} ~ ${fmtKfValue(f.upper)}`
},
},
legend: { bottom: 0, icon: 'circle', itemWidth: 8, itemHeight: 8 },
})
})
}
</script>
<style scoped>