feat: 预测性成本智能MVP — KPI趋势预测引擎(线性回归/移动平均)+API+前端Tab+pytest覆盖
This commit is contained in:
@@ -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 = {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user