feat: 持续规划 — 滚动预算+自动延展+对比线+偏差告警

This commit is contained in:
Hermes CI Fix
2026-07-22 12:31:25 +08:00
parent cbcc0d0a28
commit 66554fb7fa
4 changed files with 927 additions and 1 deletions
+9
View File
@@ -150,6 +150,15 @@ export const budgetApi = {
autoDecompose: (data: any) => api.post('/budget/auto-decompose', data),
deviationReport: (params?: any) => api.get('/budget/deviation-report', { params }),
methodComparison: (data: any) => api.post('/budget/method-comparison', data),
// 持续规划
getConfig: () => api.get('/budget/config'),
setConfig: (data: any) => api.post('/budget/config', data),
rollForward: () => api.post('/budget/roll-forward'),
getComparison: (params?: any) => api.get('/budget/comparison', { params }),
getKpiComparison: (kpiId: number, params?: any) => api.get(`/budget/comparison/kpi/${kpiId}`, { params }),
deviationCheck: (data: any) => api.post('/budget/deviation-check', data),
listDeviationAlerts: (params?: any) => api.get('/budget/deviation-alerts', { params }),
updateDeviationAlert: (id: number, data: any) => api.put(`/budget/deviation-alerts/${id}`, data),
}
export const costApi = {
+374 -1
View File
@@ -29,6 +29,21 @@
<el-switch v-model="batchEditMode" active-text="批量编辑" inactive-text="逐行编辑" @change="onBatchEditToggle" />
<el-button v-if="batchEditMode" type="primary" @click="batchSaveAll" :loading="batchSavingAll" :disabled="changedRows.length === 0">批量保存 ({{ changedRows.length }})</el-button>
<el-button v-if="batchEditMode" @click="batchCancelAll">取消</el-button>
<el-divider direction="vertical" />
<div style="display:flex;align-items:center;gap:6px;font-size:12px;">
<span style="color:#909399;">预算模式</span>
<el-switch
v-model="budgetMode"
active-value="rolling"
inactive-value="fixed"
active-text="滚动预算"
inactive-text="固定预算"
@change="onBudgetModeChange"
/>
<el-tag v-if="budgetMode === 'rolling'" size="small" type="warning" effect="plain">滚动12月</el-tag>
<el-tag v-else size="small" type="info" effect="plain">固定年度</el-tag>
</div>
<el-button v-if="budgetMode === 'rolling'" size="small" type="warning" @click="doRollForward" :loading="rollingForward">延展</el-button>
</div>
<template v-if="budgetViewMode === 'list'">
<el-table :data="budgetList" v-loading="loading" border stripe size="small" style="width:100%;" :row-class-name="rowClass" :empty-text="filterYear ? '暂无数据,在行内编辑填值后保存即可创建' : '请先选择年份'">
@@ -222,6 +237,132 @@
</div>
</div>
</el-tab-pane>
<el-tab-pane label="持续规划" name="rolling">
<el-tabs v-model="rollingTab" type="border-card" style="margin-top:4px;">
<el-tab-pane label="实际vs预测对比" name="comparison">
<div style="display:flex;gap:12px;margin-bottom:16px;flex-wrap:wrap;align-items:center;">
<el-select v-model="comparisonYear" placeholder="年份" style="width:100px;"><el-option v-for="y in yearOptions" :key="y" :label="y" :value="y" /></el-select>
<el-select v-model="comparisonKpiId" placeholder="选择KPI(可选)" filterable clearable style="width:220px;" @change="loadComparison">
<el-option v-for="k in comparisonKpiOptions" :key="k.id" :label="`${k.kpi_code} - ${k.kpi_name}`" :value="k.id" />
</el-select>
<el-button type="primary" @click="loadComparison">刷新</el-button>
<el-tag v-if="budgetMode === 'rolling'" size="small" type="warning">滚动预算 - 当前月之后为预测值</el-tag>
<el-tag v-else size="small" type="info">固定预算</el-tag>
</div>
<!-- 对比图表 -->
<el-card v-if="comparisonData.length > 0" shadow="hover" style="margin-bottom:16px;">
<div ref="comparisonChartRef" style="width:100%;height:360px;"></div>
</el-card>
<!-- 分界点指示 & 摘要 -->
<el-row :gutter="16" style="margin-bottom:16px;" v-if="comparisonSummary">
<el-col :span="6" v-for="s in comparisonSummary" :key="s.label">
<el-card shadow="hover">
<div style="text-align:center;">
<div style="font-size:12px;color:#999;">{{ s.label }}</div>
<div style="font-size:20px;font-weight:600;margin-top:4px;" :style="{color: s.color}">{{ s.value }}</div>
</div>
</el-card>
</el-col>
</el-row>
<!-- 对比详情表格 -->
<el-table :data="comparisonData" v-loading="comparisonLoading" border stripe size="small" style="width:100%;">
<el-table-column prop="period" label="期间" width="100" />
<el-table-column prop="budget_total" label="预算值" width="150"><template #default="{ row }">{{ formatNumber(row.budget_total) }}</template></el-table-column>
<el-table-column prop="actual_total" label="实际值" width="150"><template #default="{ row }">{{ row.actual_total != null ? formatNumber(row.actual_total) : '--' }}</template></el-table-column>
<el-table-column label="偏差率" width="130">
<template #default="{ row }">
<el-tag v-if="row.deviation_rate != null" :type="Math.abs(row.deviation_rate) > 20 ? 'danger' : Math.abs(row.deviation_rate) > 10 ? 'warning' : 'success'" size="small">
{{ row.deviation_rate > 0 ? '+' : '' }}{{ row.deviation_rate }}%
</el-tag>
<span v-else style="color:#ccc;">--</span>
</template>
</el-table-column>
<el-table-column label="类型标记" width="120">
<template #default="{ row }">
<el-tag v-if="row.is_prediction" size="small" type="warning">预测值</el-tag>
<el-tag v-else-if="row.is_current_period" size="small" type="danger">当前期</el-tag>
<el-tag v-else size="small" type="success">已发生</el-tag>
</template>
</el-table-column>
</el-table>
<div v-if="comparisonData.length === 0 && !comparisonLoading" style="padding:40px 0;text-align:center;color:#999;">
<p>暂无对比数据,请先录入预算和实际值</p>
</div>
</el-tab-pane>
<el-tab-pane label="偏差告警" name="alerts">
<div style="display:flex;gap:12px;margin-bottom:16px;flex-wrap:wrap;align-items:center;">
<el-select v-model="alertFilterPeriod" placeholder="期间" style="width:120px;">
<el-option v-for="p in alertPeriodOptions" :key="p" :label="p" :value="p" />
</el-select>
<el-select v-model="alertFilterLevel" placeholder="级别" clearable style="width:100px;">
<el-option label="严重" value="critical" />
<el-option label="警告" value="warning" />
</el-select>
<el-select v-model="alertFilterStatus" placeholder="状态" clearable style="width:100px;">
<el-option label="未处理" value="open" />
<el-option label="已解决" value="resolved" />
<el-option label="已忽略" value="ignored" />
</el-select>
<el-button type="primary" @click="loadDeviationAlerts">查询</el-button>
<el-button type="danger" @click="doDeviationCheck" :loading="deviationChecking">执行偏差检查 (超20%告警)</el-button>
</div>
<el-alert
v-if="deviationAlertMessage"
:title="deviationAlertMessage"
type="info"
show-icon
:closable="true"
style="margin-bottom:12px;"
@close="deviationAlertMessage = ''"
/>
<el-table :data="deviationAlerts" v-loading="alertLoading" border stripe size="small" style="width:100%;">
<el-table-column prop="period" label="期间" width="90" />
<el-table-column prop="kpi_code" label="KPI编码" width="100" />
<el-table-column prop="kpi_name" label="KPI名称" min-width="140" />
<el-table-column prop="budget_value" label="预算值" width="120"><template #default="{ row }">{{ formatNumber(row.budget_value) }}</template></el-table-column>
<el-table-column prop="actual_value" label="实际值" width="120"><template #default="{ row }">{{ formatNumber(row.actual_value) }}</template></el-table-column>
<el-table-column prop="deviation_rate" label="偏差率" width="110">
<template #default="{ row }">
<el-tag :type="Math.abs(row.deviation_rate) > 50 ? 'danger' : 'warning'" size="small">
{{ row.deviation_rate > 0 ? '+' : '' }}{{ row.deviation_rate }}%
</el-tag>
</template>
</el-table-column>
<el-table-column label="级别" width="80">
<template #default="{ row }">
<el-tag :type="row.alert_level === 'critical' ? 'danger' : 'warning'" size="small">
{{ row.alert_level === 'critical' ? '严重' : '警告' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="suggestion" label="建议" min-width="180" />
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag v-if="row.status === 'open'" type="danger" size="small">未处理</el-tag>
<el-tag v-else-if="row.status === 'resolved'" type="success" size="small">已解决</el-tag>
<el-tag v-else type="info" size="small">已忽略</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
<el-button v-if="row.status === 'open'" size="small" type="success" @click="resolveAlert(row)">标记解决</el-button>
<el-button v-else-if="row.status === 'resolved'" size="small" @click="reopenAlert(row)">重新打开</el-button>
</template>
</el-table-column>
</el-table>
<div v-if="deviationAlerts.length === 0 && !alertLoading" style="padding:40px 0;text-align:center;color:#999;">
<p>暂无偏差预警,点击「执行偏差检查」扫描当前期间</p>
</div>
</el-tab-pane>
</el-tabs>
</el-tab-pane>
</el-tabs>
<MyDialog v-model="showAddBudget" title="新增预算" :width="500">
@@ -283,13 +424,14 @@
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed, watch } from 'vue'
import { ref, onMounted, computed, watch, nextTick } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { budgetApi, kpiApi, mapApi, actionPlanApi } from '../api/index'
import MyDialog from '../components/MyDialog.vue'
const activeTab = ref('input')
const currentYear = new Date().getFullYear()
const currentMonth = new Date().getMonth() + 1
const yearOptions = computed(() => { const y: number[] = []; for (let i = currentYear - 2; i <= currentYear + 2; i++) y.push(i); return y })
const filterYear = ref(currentYear); const filterMonth = ref(0); const searchKpi = ref('')
const loading = ref(false); const budgetList = ref<any[]>([]); const page = ref(1); const pageSize = ref(20); const total = ref(0); const noMap = ref(false)
@@ -524,16 +666,247 @@ function confirmMethod() {
}
}
// ── 滚动/固定预算切换 ──
const budgetMode = ref('fixed')
const rollingForward = ref(false)
async function loadBudgetConfig() {
try {
const r: any = await budgetApi.getConfig()
budgetMode.value = r.budget_mode || r.mode || 'fixed'
} catch { budgetMode.value = 'fixed' }
}
async function onBudgetModeChange(mode: string) {
try {
await budgetApi.setConfig({ mode, rolling_months: 12 })
ElMessage.success(`预算模式已切换为${mode === 'rolling' ? '滚动预算' : '固定预算'}`)
loadBudget()
} catch (e: any) {
budgetMode.value = mode === 'rolling' ? 'fixed' : 'rolling'
ElMessage.error(e?.response?.data?.detail || '切换失败')
}
}
async function doRollForward() {
rollingForward.value = true
try {
const r: any = await budgetApi.rollForward()
ElMessage.success(r.message || '滚动预算已延展')
if (r.rolled_kpis?.length > 0) {
ElMessage.info(`新增了 ${r.rolled_kpis.length} 个KPI的未来一个月预测`)
}
loadBudget()
} catch (e: any) {
ElMessage.error(e?.response?.data?.detail || '延展失败')
}
rollingForward.value = false
}
// ── 实际vs预测对比 ──
const rollingTab = ref('comparison')
const comparisonYear = ref(currentYear)
const comparisonKpiId = ref<number | null>(null)
const comparisonLoading = ref(false)
const comparisonData = ref<any[]>([])
const comparisonSummary = ref<any[]>([])
const comparisonKpiOptions = ref<any[]>([])
const comparisonChartRef = ref<HTMLElement | null>(null)
let comparisonChart: any = null
async function loadComparisonKpiOptions() {
try {
const r: any = await kpiApi.list({ page_size: 200 })
const d = r.data || r || []
comparisonKpiOptions.value = Array.isArray(d) ? d : (d.items || [])
} catch { comparisonKpiOptions.value = [] }
}
async function loadComparison() {
comparisonLoading.value = true
try {
const params: any = { year: comparisonYear.value }
if (comparisonKpiId.value) params.kpi_id = comparisonKpiId.value
const r: any = await budgetApi.getComparison(params)
comparisonData.value = r.months_data || []
// 计算摘要
const md = comparisonData.value
const totalBudget = md.reduce((s: number, m: any) => s + (m.budget_total || 0), 0)
const totalActual = md.reduce((s: number, m: any) => s + (m.actual_total || 0), 0)
const maxDevRate = Math.max(...md.filter((m: any) => m.deviation_rate != null).map((m: any) => Math.abs(m.deviation_rate)), 0)
const alertCount = md.filter((m: any) => m.deviation_rate != null && Math.abs(m.deviation_rate) > 20).length
comparisonSummary.value = [
{ label: '预算总额', value: formatNumber(totalBudget), color: '#409eff' },
{ label: '实际总额', value: totalActual > 0 ? formatNumber(totalActual) : '--', color: totalActual > totalBudget ? '#f56c6c' : '#67c23a' },
{ label: '最大偏差率', value: maxDevRate > 0 ? `${maxDevRate}%` : '--', color: maxDevRate > 20 ? '#f56c6c' : '#67c23a' },
{ label: '超20%偏差月数', value: `${alertCount}个月`, color: alertCount > 0 ? '#f56c6c' : '#67c23a' },
]
// 渲染echarts图表
await nextTick()
renderComparisonChart()
} catch (e: any) {
ElMessage.error('加载对比数据失败')
comparisonData.value = []
}
comparisonLoading.value = false
}
function renderComparisonChart() {
if (!comparisonChartRef.value) return
// 使用ECharts
const echarts = (window as any).echarts
if (!echarts) {
// 如果没有全局echarts,尝试从已挂载的组件获取
import('echarts').then(echarts => {
doRenderChart(echarts)
}).catch(() => {
// ECharts可能已经在vendor bundle中
if ((window as any).echarts) doRenderChart((window as any).echarts)
})
return
}
doRenderChart(echarts)
}
function doRenderChart(echarts: any) {
if (comparisonChart) comparisonChart.dispose()
comparisonChart = echarts.init(comparisonChartRef.value!)
const data = comparisonData.value
const periods = data.map((d: any) => d.period)
const budgetValues = data.map((d: any) => d.budget_total != null ? d.budget_total : null)
const actualValues = data.map((d: any) => d.actual_total != null ? d.actual_total : null)
// 找到分界点(当前期之后为预测)
const nowPeriod = `${currentYear}-${String(currentMonth).padStart(2, '0')}`
const splitIndex = periods.findIndex((p: string) => p > nowPeriod)
const option = {
tooltip: { trigger: 'axis' },
legend: { data: ['预算值', '实际值'] },
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
xAxis: { type: 'category', data: periods, axisLabel: { rotate: 45 } },
yAxis: { type: 'value' },
series: [
{
name: '预算值',
type: 'line',
data: budgetValues,
smooth: true,
lineStyle: { width: 2, color: '#409eff' },
itemStyle: { color: '#409eff' },
markLine: splitIndex >= 0 ? {
data: [{ xAxis: periods[splitIndex] || periods[splitIndex - 1] }],
label: { formatter: '▼ 预测开始', color: '#e6a23c' },
lineStyle: { color: '#e6a23c', type: 'dashed', width: 2 },
} : undefined,
},
{
name: '实际值',
type: 'bar',
data: actualValues,
barWidth: '20%',
itemStyle: {
color: (params: any) => {
const item = data[params.dataIndex]
if (!item) return '#67c23a'
if (item.actual_total == null) return '#d9d9d9'
if (item.deviation_rate != null && Math.abs(item.deviation_rate) > 20) return '#f56c6c'
if (item.deviation_rate != null && Math.abs(item.deviation_rate) > 10) return '#e6a23c'
return '#67c23a'
},
},
},
],
}
comparisonChart.setOption(option)
}
// ── 偏差告警 ──
const alertFilterPeriod = ref(`${currentYear}-${String(currentMonth).padStart(2, '0')}`)
const alertFilterLevel = ref('')
const alertFilterStatus = ref('')
const alertLoading = ref(false)
const deviationAlerts = ref<any[]>([])
const deviationChecking = ref(false)
const deviationAlertMessage = ref('')
const alertPeriodOptions = computed(() => {
const opts: string[] = []
for (let m = 1; m <= 12; m++) {
opts.push(`${currentYear}-${String(m).padStart(2, '0')}`)
}
return opts
})
async function loadDeviationAlerts() {
alertLoading.value = true
try {
const params: any = {}
if (alertFilterPeriod.value) params.period = alertFilterPeriod.value
if (alertFilterLevel.value) params.alert_level = alertFilterLevel.value
if (alertFilterStatus.value) params.status = alertFilterStatus.value
const r: any = await budgetApi.listDeviationAlerts(params)
deviationAlerts.value = r.data || []
} catch { deviationAlerts.value = [] }
alertLoading.value = false
}
async function doDeviationCheck() {
deviationChecking.value = true
deviationAlertMessage.value = ''
try {
const r: any = await budgetApi.deviationCheck({
period: alertFilterPeriod.value,
threshold: 20,
})
if (r.alerts_generated > 0) {
deviationAlertMessage.value = `偏差检查完成:生成了 ${r.alerts_generated} 条预警`
ElMessage.warning(`发现 ${r.alerts_generated} 条偏差预警`)
} else {
deviationAlertMessage.value = '偏差检查完成:未发现超过20%的偏差'
ElMessage.success('未发现偏差预警')
}
loadDeviationAlerts()
} catch (e: any) {
ElMessage.error(e?.response?.data?.detail || '偏差检查失败')
}
deviationChecking.value = false
}
async function resolveAlert(row: any) {
try {
await budgetApi.updateDeviationAlert(row.id, { status: 'resolved' })
ElMessage.success('已标记为已解决')
row.status = 'resolved'
loadDeviationAlerts()
} catch { ElMessage.error('操作失败') }
}
async function reopenAlert(row: any) {
try {
await budgetApi.updateDeviationAlert(row.id, { status: 'open' })
ElMessage.success('已重新打开')
row.status = 'open'
loadDeviationAlerts()
} catch { ElMessage.error('操作失败') }
}
watch(activeTab, (tab) => {
if (tab === 'decompose') loadBudget()
else if (tab === 'strategy') loadStrategyBudget()
else if (tab === 'versions') loadVersions()
else if (tab === 'execution') loadExecutionReport()
else if (tab === 'method') refreshMethodComparison()
else if (tab === 'rolling') {
loadComparison()
loadDeviationAlerts()
}
})
onMounted(async () => {
await loadPublishedMaps()
loadBudget()
loadBudgetConfig()
loadComparisonKpiOptions()
})
</script>