feat: 战略目标vs预算差异可见化 — 目标与预算允许不同但差异可解释

需求: 战略目标(管理团队愿景) vs 预算(财务按实际分析) 允许不同
增强:
- 后端 deviation-report 返回 strategic_target/target_gap_pct/target_gap_level
- 预算执行表加'战略目标'+'目标差异'列(±10%黄/±20%红⚠️提示'需说明原因')
- 战略预算编制KPI树加'战略目标'列(编制时可见对比)
- 实测: F_REVENUE 战略1200 vs 预算173.86 差异-85.5%(high) 红提示

口径说明: 差异基于当前target_monthly/预算值, 目标口径校准另行处理
This commit is contained in:
Hermes CI Fix
2026-08-27 17:26:38 +08:00
parent e82a6af59c
commit 3c64dc767e
2 changed files with 19 additions and 1 deletions
+10
View File
@@ -400,6 +400,16 @@ def get_deviation_report(
item["kpi_name"] = kpi.kpi_name
item["dimension"] = kpi.dimension
item["unit"] = kpi.unit or ""
# 战略目标 vs 预算差异(2026-08-27: 战略=愿景/预算=计划, 允许不同但差异可见可解释)
target = kpi.target_monthly or kpi.target_value
item["strategic_target"] = target
if item.get("budget_value") is not None and target:
gap = round((item["budget_value"] - target) / target * 100, 1)
item["target_gap_pct"] = gap
item["target_gap_level"] = "high" if abs(gap) > 20 else ("medium" if abs(gap) > 10 else "ok")
else:
item["target_gap_pct"] = None
item["target_gap_level"] = "none"
items.append(item)
summary["total_kpis"] += 1
+9 -1
View File
@@ -133,6 +133,7 @@
<el-table :data="obj.kpis" border stripe size="small" style="width:100%;" :show-header="false" @selection-change="(sel: any[]) => onStrategyKpiSelect(sel, dim.key)">
<el-table-column type="selection" width="36" />
<el-table-column prop="kpi_code" label="编码" width="110" /><el-table-column prop="kpi_name" label="KPI名称" min-width="160" />
<el-table-column label="战略目标" width="110"><template #default="{ row }"><span style="color:#e6a23c;font-size:12px;">{{ row.target != null ? formatNumber(row.target, row.unit) : '-' }}</span></template></el-table-column>
<el-table-column label="预算值" width="190">
<template #default="{ row }">
<el-input-number v-model="row._editValue" :min="0" :precision="row.precision || 0" :step="row.step || 1" controls-position="right" style="width:150px;" @change="row._changed = (row._editValue !== (row.budget_value ?? 0))" />
@@ -187,6 +188,13 @@
<el-table-column prop="kpi_code" label="编码" width="110" /><el-table-column prop="kpi_name" label="KPI名称" min-width="140" />
<el-table-column prop="dimension" label="维度" width="80"><template #default="{ row }"><el-tag size="small" :type="dimTag(row.dimension)">{{ dimLabel(row.dimension) }}</el-tag></template></el-table-column>
<el-table-column prop="budget_value" label="预算值" width="130"><template #default="{ row }">{{ formatNumber(row.budget_value, row.unit) }}</template></el-table-column>
<!-- 战略目标 vs 预算(2026-08-27: 允许不同, 差异可见可解释) -->
<el-table-column prop="strategic_target" label="战略目标" width="120"><template #default="{ row }"><span :style="{color: row.target_gap_level === 'high' ? '#f56c6c' : row.target_gap_level === 'medium' ? '#e6a23c' : '#606266'}">{{ formatNumber(row.strategic_target, row.unit) }}</span></template></el-table-column>
<el-table-column label="目标差异" width="110"><template #default="{ row }">
<span v-if="row.target_gap_pct != null" :style="{color: row.target_gap_level === 'high' ? '#f56c6c' : row.target_gap_level === 'medium' ? '#e6a23c' : '#67c23a', fontWeight:600}">{{ row.target_gap_pct > 0 ? '+' : '' }}{{ row.target_gap_pct }}%</span>
<el-tooltip v-if="row.target_gap_level === 'high'" content="预算偏离战略目标超20%需说明原因" placement="top"><span style="margin-left:4px;cursor:help;">⚠️</span></el-tooltip>
<span v-else style="color:#c0c4cc;">-</span>
</template></el-table-column>
<el-table-column prop="actual_value" label="实际值" width="130"><template #default="{ row }">{{ formatNumber(row.actual_value, row.unit) }}</template></el-table-column>
<el-table-column prop="deviation_value" label="偏差" width="130"><template #default="{ row }"><span :style="{color: row.deviation_value > 0 ? '#f56c6c' : row.deviation_value < 0 ? '#67c23a' : '#999'}">{{ row.deviation_value > 0 ? '+' : '' }}{{ formatNumber(row.deviation_value, row.unit) }}</span></template></el-table-column>
<el-table-column label="执行率" width="100"><template #default="{ row }"><el-progress :percentage="row.execution_rate || 0" :status="row.execution_rate > 100 ? 'exception' : row.execution_rate > 80 ? 'warning' : 'success'" :stroke-width="16" :text-inside="true" /></template></el-table-column>
@@ -621,7 +629,7 @@ async function loadStrategyBudget() {
let dims = selectedMap.value.dimensions; if (typeof dims === 'string') { try { dims = JSON.parse(dims) } catch { dims = [] } }; if (!Array.isArray(dims) || dims.length === 0) { strategyTree.value = []; return }
const kr: any = await kpiApi.list({ page_size: 100, entity_id: getEntityId() }); const kd = kr.data || kr || []; const km = new Map<string, any>(); (Array.isArray(kd) ? kd : (kd.items || [])).forEach((k: any) => km.set(k.kpi_code, k))
const tree: any[] = []
for (const dim of dims) { const dt = { budget: 0, kpiCount: 0 }; const objs = (dim.objectives || []).map((obj: any) => { const kpis = (obj.kpis || []).map((code: string) => { const kpi = km.get(code); const plan = plans.find((p: any) => p.kpi_code === code); const bv = plan ? plan.budget_value : null; dt.kpiCount++; if (bv) dt.budget += bv; return { kpi_code: code, kpi_id: kpi?.id || null, kpi_name: kpi?.kpi_name || code, unit: kpi?.unit || '', precision: kpi?.precision || 0, step: kpi?.step || 1, dimension: dim.key, period: plan?.period || `${strategyFilterYear.value}-${String(strategyFilterMonth.value || 1).padStart(2, '0')}`, version: plan?.version || 'v1.0', budget_value: bv, plan_id: plan?.id || null, _editValue: bv ?? 0, _changed: false, _saving: false, _dimKey: dim.key } }).filter(Boolean); return { name: obj.name, kpis } }); tree.push({ key: dim.key, name: dim.name, icon: dim.icon, color: dim.color, kpiCount: dt.kpiCount, totalBudget: dt.budget, objectives: objs }) }
for (const dim of dims) { const dt = { budget: 0, kpiCount: 0 }; const objs = (dim.objectives || []).map((obj: any) => { const kpis = (obj.kpis || []).map((code: string) => { const kpi = km.get(code); const plan = plans.find((p: any) => p.kpi_code === code); const bv = plan ? plan.budget_value : null; dt.kpiCount++; if (bv) dt.budget += bv; return { kpi_code: code, kpi_id: kpi?.id || null, kpi_name: kpi?.kpi_name || code, unit: kpi?.unit || '', precision: kpi?.precision || 0, step: kpi?.step || 1, dimension: dim.key, period: plan?.period || `${strategyFilterYear.value}-${String(strategyFilterMonth.value || 1).padStart(2, '0')}`, version: plan?.version || 'v1.0', budget_value: bv, plan_id: plan?.id || null, target: kpi?.target_monthly || kpi?.target_value || null, _editValue: bv ?? 0, _changed: false, _saving: false, _dimKey: dim.key } }).filter(Boolean); return { name: obj.name, kpis } }); tree.push({ key: dim.key, name: dim.name, icon: dim.icon, color: dim.color, kpiCount: dt.kpiCount, totalBudget: dt.budget, objectives: objs }) }
strategyTree.value = tree
} catch (e: any) { ElMessage.error('加载战略预算数据失败: ' + (e?.response?.data?.detail || e?.message || '未知错误')) }
}