feat: 持续规划 — 滚动预算+自动延展+对比线+偏差告警
This commit is contained in:
@@ -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 = {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user