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
+518
View File
@@ -336,6 +336,524 @@ def get_deviation_report(
} }
# ──────────────────────────────────────────────
# 滚动/固定预算切换
# ──────────────────────────────────────────────
@router.get("/config")
def get_budget_config(db: Session = Depends(get_db)):
"""获取预算模式配置"""
from app.models import SystemConfig
cfg = db.query(SystemConfig).filter(SystemConfig.config_key == "budget_mode").first()
if not cfg:
return {"budget_mode": "fixed", "rolling_months": 12, "description": "固定预算(年度)"}
import json
try:
val = json.loads(cfg.config_value)
except (json.JSONDecodeError, TypeError):
val = {"mode": "fixed", "rolling_months": 12}
return val
@router.post("/config")
def set_budget_config(
data: dict,
db: Session = Depends(get_db),
current_user=Depends(require_auth),
):
"""设置预算模式"""
from app.models import SystemConfig
import json
mode = data.get("mode", "fixed")
rolling_months = data.get("rolling_months", 12)
if mode not in ("fixed", "rolling"):
raise HTTPException(400, "预算模式必须是 fixed 或 rolling")
cfg = db.query(SystemConfig).filter(SystemConfig.config_key == "budget_mode").first()
val = json.dumps({"mode": mode, "rolling_months": rolling_months}, ensure_ascii=False)
if cfg:
cfg.config_value = val
else:
cfg = SystemConfig(
config_key="budget_mode",
config_value=val,
description="预算模式: fixed=固定预算, rolling=滚动预算",
)
db.add(cfg)
db.commit()
return {"message": f"预算模式已切换为{'滚动预算' if mode == 'rolling' else '固定预算'}", "budget_mode": mode, "rolling_months": rolling_months}
# ──────────────────────────────────────────────
# 滚动预算自动延展
# ──────────────────────────────────────────────
@router.post("/roll-forward")
def budget_roll_forward(
db: Session = Depends(get_db),
current_user=Depends(require_auth),
):
"""
滚动预算自动延展:
- 删除最早一个月的预测数据
- 新增未来一个月的预测数据(取最近三个月均值)
- 返回延展结果
"""
from app.models import SystemConfig
cfg = db.query(SystemConfig).filter(SystemConfig.config_key == "budget_mode").first()
import json
if not cfg:
raise HTTPException(400, "未配置预算模式,请先设置")
try:
val = json.loads(cfg.config_value)
except (json.JSONDecodeError, TypeError):
raise HTTPException(400, "预算模式配置异常")
if val.get("mode") != "rolling":
raise HTTPException(400, "当前为固定预算模式,无需延展")
now = datetime.now()
current_year, current_month = now.year, now.month
# 获取所有active的预算记录
plans = db.query(BudgetPlan).filter(BudgetPlan.status == "active").all()
# 按KPI分组
from collections import defaultdict
kpi_plans = defaultdict(list)
for p in plans:
kpi_plans[p.kpi_id].append(p)
rolled_kpis = []
for kpi_id, p_list in kpi_plans.items():
# 按期间排序
p_list.sort(key=lambda x: (x.budget_year, x.budget_month))
# 找出最早的一个月并删除
if p_list:
oldest = p_list[0]
db.query(BudgetPlan).filter(BudgetPlan.id == oldest.id).delete()
# 计算新增月份的预算值(取最近三个月均值)
recent_values = [p.budget_value for p in p_list[-3:]] if len(p_list) >= 3 else [p.budget_value for p in p_list]
avg_value = round(sum(recent_values) / len(recent_values), 2) if recent_values else 0
# 计算新的月份(当前月 + 12个月后)
new_year = current_year
new_month = current_month + val.get("rolling_months", 12)
while new_month > 12:
new_month -= 12
new_year += 1
new_period = f"{new_year}-{new_month:02d}"
# 检查是否已存在
existing = db.query(BudgetPlan).filter(
BudgetPlan.kpi_id == kpi_id,
BudgetPlan.period == new_period,
BudgetPlan.status == "active",
).first()
if not existing:
bp = BudgetPlan(
kpi_id=kpi_id,
period=new_period,
budget_value=avg_value,
budget_year=new_year,
budget_month=new_month,
version="rolling",
status="active",
remark=f"滚动延展自{current_year}-{current_month:02d}",
created_by=current_user.name if hasattr(current_user, "name") else "",
)
db.add(bp)
rolled_kpis.append({
"kpi_id": kpi_id,
"removed_period": f"{p_list[0].budget_year}-{p_list[0].budget_month:02d}" if p_list else None,
"added_period": new_period,
"predicted_value": avg_value,
})
db.commit()
return {
"message": f"滚动预算已延展,处理了 {len(rolled_kpis)} 个KPI",
"rolled_kpis": rolled_kpis,
"current_month": f"{current_year}-{current_month:02d}",
"rolling_months": val.get("rolling_months", 12),
}
# ──────────────────────────────────────────────
# 实际vs预测对比
# ──────────────────────────────────────────────
@router.get("/comparison")
def get_budget_comparison(
kpi_id: Optional[int] = Query(None),
year: Optional[int] = Query(None),
db: Session = Depends(get_db),
):
"""
获取实际值vs预测值对比数据
返回:各月预算值、实际值、偏差率,以及分界点标记
"""
from app.models import KPIValue, SystemConfig
import json
now = datetime.now()
y = year or now.year
# 判断预算模式
cfg = db.query(SystemConfig).filter(SystemConfig.config_key == "budget_mode").first()
budget_mode = "fixed"
rolling_months = 12
if cfg:
try:
val = json.loads(cfg.config_value)
budget_mode = val.get("mode", "fixed")
rolling_months = val.get("rolling_months", 12)
except (json.JSONDecodeError, TypeError):
pass
# 确定查询的月份范围
if budget_mode == "rolling":
# 滚动预算:从当月起的 rolling_months 个月
start_year, start_month = now.year, now.month
periods = []
for i in range(rolling_months):
m = start_month + i
yy = start_year
while m > 12:
m -= 12
yy += 1
periods.append(f"{yy}-{m:02d}")
else:
# 固定预算:全年1-12月
periods = [f"{y}-{m:02d}" for m in range(1, 13)]
# 查询预算数据
query = db.query(BudgetPlan).join(
KPIDefinition, BudgetPlan.kpi_id == KPIDefinition.id
)
if kpi_id:
query = query.filter(BudgetPlan.kpi_id == kpi_id)
query = query.filter(BudgetPlan.period.in_(periods), BudgetPlan.status == "active")
budget_plans = query.all()
# 按KPI+期间索引
bp_map = {}
for bp in budget_plans:
key = (bp.kpi_id, bp.period)
bp_map[key] = bp.budget_value
# 查询实际值
kpi_ids = set(bp.kpi_id for bp in budget_plans)
actual_values = {}
if kpi_ids:
values = db.query(KPIValue).filter(
KPIValue.kpi_id.in_(kpi_ids),
KPIValue.period.in_(periods),
KPIValue.actual_value.isnot(None),
).all()
for v in values:
key = (v.kpi_id, v.period)
actual_values[key] = v.actual_value
# 构建对比数据
now_period = now.strftime("%Y-%m")
months_data = []
for period in periods:
monthly = {"period": period, "is_current_period": period == now_period}
total_budget = 0
total_actual = 0
count_budget = 0
count_actual = 0
for kpi_id_item in kpi_ids:
bp_key = (kpi_id_item, period)
if bp_key in bp_map:
total_budget += bp_map[bp_key] or 0
count_budget += 1
if bp_key in actual_values:
total_actual += actual_values[bp_key] or 0
count_actual += 1
monthly["budget_total"] = round(total_budget, 2)
monthly["actual_total"] = round(total_actual, 2)
monthly["kpi_count"] = len(kpi_ids)
# 分界点标记
if budget_mode == "rolling":
# 滚动预算下,当前月之后为预测值
monthly["is_prediction"] = period > now_period
else:
monthly["is_prediction"] = period > now_period
# 偏差率
if monthly["budget_total"] and monthly["budget_total"] > 0:
monthly["deviation_rate"] = round(
(monthly["actual_total"] - monthly["budget_total"]) / monthly["budget_total"] * 100, 2
) if monthly["actual_total"] is not None else None
else:
monthly["deviation_rate"] = None
months_data.append(monthly)
return {
"periods": periods,
"budget_mode": budget_mode,
"year": y,
"current_period": now_period,
"months_data": months_data,
"total_kpis": len(kpi_ids),
}
@router.get("/comparison/kpi/{kpi_id}")
def get_kpi_comparison(
kpi_id: int,
year: Optional[int] = Query(None),
db: Session = Depends(get_db),
):
"""
获取单个KPI的实际vs预测对比数据(用于图表展示)
"""
from app.models import KPIValue, SystemConfig
import json
now = datetime.now()
y = year or now.year
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
if not kpi:
raise HTTPException(404, "KPI不存在")
periods = [f"{y}-{m:02d}" for m in range(1, 13)]
# 预算值
budgets = db.query(BudgetPlan).filter(
BudgetPlan.kpi_id == kpi_id,
BudgetPlan.period.in_(periods),
BudgetPlan.status == "active",
).all()
budget_map = {bp.period: bp.budget_value for bp in budgets}
# 实际值
actuals = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi_id,
KPIValue.period.in_(periods),
KPIValue.actual_value.isnot(None),
).all()
actual_map = {av.period: av.actual_value for av in actuals}
now_period = now.strftime("%Y-%m")
data_points = []
for period in periods:
bv = budget_map.get(period)
av = actual_map.get(period)
dr = None
if bv and bv > 0 and av is not None:
dr = round((av - bv) / bv * 100, 2)
data_points.append({
"period": period,
"budget_value": bv,
"actual_value": av,
"deviation_rate": dr,
"is_prediction": period > now_period,
"is_current_period": period == now_period,
})
return {
"kpi_id": kpi.id,
"kpi_code": kpi.kpi_code,
"kpi_name": kpi.kpi_name,
"unit": kpi.unit or "",
"year": y,
"current_period": now_period,
"data_points": data_points,
}
# ──────────────────────────────────────────────
# 预测偏差告警
# ──────────────────────────────────────────────
@router.post("/deviation-check")
def check_budget_deviation(
data: dict,
db: Session = Depends(get_db),
current_user=Depends(require_auth),
):
"""
检查实际vs预测偏差,当偏差超过20%时自动生成预警
"""
from app.models import KPIValue, BudgetDeviationAlert
from sqlalchemy import func
threshold = data.get("threshold", 20) # 默认20%
period = data.get("period") or datetime.now().strftime("%Y-%m")
auto_resolve = data.get("auto_resolve", True) # 是否自动关闭已解决的预警
# 查询该期间的有预算的KPI
budget_plans = db.query(BudgetPlan).filter(
BudgetPlan.period == period,
BudgetPlan.status == "active",
).all()
if not budget_plans:
return {
"message": f"期间 {period} 无预算数据",
"alerts_generated": 0,
"alerts": [],
}
alerts_generated = 0
alerts = []
for bp in budget_plans:
# 查询实际值
actual = db.query(KPIValue).filter(
KPIValue.kpi_id == bp.kpi_id,
KPIValue.period == period,
KPIValue.actual_value.isnot(None),
).first()
if not actual or actual.actual_value is None:
continue
budget_val = bp.budget_value
actual_val = actual.actual_value
if budget_val == 0:
continue
# 计算偏差率
deviation_rate = round((actual_val - budget_val) / budget_val * 100, 2)
# 只有偏差超过阈值才生成预警
if abs(deviation_rate) <= threshold:
continue
deviation_value = round(actual_val - budget_val, 2)
# 判断预警等级
alert_level = "critical" if abs(deviation_rate) > 50 else "warning"
# 生成建议
if deviation_rate > 0:
suggestion = f"实际值超出预算 {deviation_rate}%,建议核查超支原因并采取控制措施"
else:
suggestion = f"实际值低于预算 {abs(deviation_rate)}%,建议分析是否预算过高或业务量未达预期"
# 检查是否已存在相同的预警
existing_alert = db.query(BudgetDeviationAlert).filter(
BudgetDeviationAlert.kpi_id == bp.kpi_id,
BudgetDeviationAlert.period == period,
BudgetDeviationAlert.status == "open",
).first()
if existing_alert:
continue
alert = BudgetDeviationAlert(
kpi_id=bp.kpi_id,
period=period,
budget_value=budget_val,
actual_value=actual_val,
deviation_rate=deviation_rate,
deviation_value=deviation_value,
alert_level=alert_level,
status="open",
suggestion=suggestion,
)
db.add(alert)
alerts_generated += 1
kpi_obj = db.query(KPIDefinition).filter(KPIDefinition.id == bp.kpi_id).first()
alerts.append({
"kpi_id": bp.kpi_id,
"kpi_code": kpi_obj.kpi_code if kpi_obj else "",
"kpi_name": kpi_obj.kpi_name if kpi_obj else "",
"period": period,
"budget_value": budget_val,
"actual_value": actual_val,
"deviation_rate": deviation_rate,
"deviation_value": deviation_value,
"alert_level": alert_level,
"suggestion": suggestion,
})
db.commit()
return {
"message": f"检查完成,生成了 {alerts_generated} 条预警",
"period": period,
"threshold": threshold,
"alerts_generated": alerts_generated,
"alerts": alerts,
}
@router.get("/deviation-alerts")
def list_deviation_alerts(
kpi_id: Optional[int] = Query(None),
period: Optional[str] = Query(None),
alert_level: Optional[str] = Query(None),
status: Optional[str] = Query(None),
db: Session = Depends(get_db),
):
"""查询预算偏差预警记录"""
from app.models import BudgetDeviationAlert
query = db.query(BudgetDeviationAlert)
if kpi_id:
query = query.filter(BudgetDeviationAlert.kpi_id == kpi_id)
if period:
query = query.filter(BudgetDeviationAlert.period == period)
if alert_level:
query = query.filter(BudgetDeviationAlert.alert_level == alert_level)
if status:
query = query.filter(BudgetDeviationAlert.status == status)
alerts = query.order_by(BudgetDeviationAlert.created_at.desc()).all()
result = []
for a in alerts:
kpi_obj = db.query(KPIDefinition).filter(KPIDefinition.id == a.kpi_id).first()
result.append({
"id": a.id,
"kpi_id": a.kpi_id,
"kpi_code": kpi_obj.kpi_code if kpi_obj else "",
"kpi_name": kpi_obj.kpi_name if kpi_obj else "",
"period": a.period,
"budget_value": a.budget_value,
"actual_value": a.actual_value,
"deviation_rate": a.deviation_rate,
"deviation_value": a.deviation_value,
"alert_level": a.alert_level,
"status": a.status,
"suggestion": a.suggestion,
"created_at": a.created_at.isoformat() if a.created_at else None,
})
return {"data": result, "total": len(result)}
@router.put("/deviation-alerts/{alert_id}")
def update_deviation_alert(
alert_id: int,
data: dict,
db: Session = Depends(get_db),
):
"""更新偏差预警(如标记已解决)"""
from app.models import BudgetDeviationAlert
alert = db.query(BudgetDeviationAlert).filter(BudgetDeviationAlert.id == alert_id).first()
if not alert:
raise HTTPException(404, "预警记录不存在")
if "status" in data:
alert.status = data["status"]
db.commit()
return {"message": "预警已更新"}
# ────────────────────────────────────────────── # ──────────────────────────────────────────────
# 功能6: 预算方法三选一向导 (CMA P1 - 增量/零基/弹性) # 功能6: 预算方法三选一向导 (CMA P1 - 增量/零基/弹性)
# ────────────────────────────────────────────── # ──────────────────────────────────────────────
+26
View File
@@ -382,6 +382,32 @@ class CashForecast(Base):
created_at = Column(DateTime, server_default=func.now()) created_at = Column(DateTime, server_default=func.now())
class SystemConfig(Base):
"""系统配置 — key-value存储"""
__tablename__ = "system_configs"
id = Column(Integer, primary_key=True, index=True)
config_key = Column(String(100), unique=True, nullable=False, comment="配置键")
config_value = Column(String(500), nullable=True, comment="配置值")
description = Column(String(500), nullable=True, comment="配置说明")
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
class BudgetDeviationAlert(Base):
"""预算偏差预警记录"""
__tablename__ = "budget_deviation_alerts"
id = Column(Integer, primary_key=True, index=True)
kpi_id = Column(Integer, ForeignKey("kpi_definitions.id"), nullable=False, comment="关联KPI")
period = Column(String(20), nullable=False, comment="期间 YYYY-MM")
budget_value = Column(Float, nullable=True, comment="预算值")
actual_value = Column(Float, nullable=True, comment="实际值")
deviation_rate = Column(Float, nullable=True, comment="偏差率 %")
deviation_value = Column(Float, nullable=True, comment="偏差绝对值")
alert_level = Column(String(20), default="warning", comment="warning/critical")
status = Column(String(20), default="open", comment="open/resolved/ignored")
suggestion = Column(String(500), nullable=True, comment="处理建议")
created_at = Column(DateTime, server_default=func.now())
class ForecastAccuracy(Base): class ForecastAccuracy(Base):
"""预测准确率 — 上期预测 vs 本期实际""" """预测准确率 — 上期预测 vs 本期实际"""
__tablename__ = "forecast_accuracy" __tablename__ = "forecast_accuracy"
+9
View File
@@ -150,6 +150,15 @@ export const budgetApi = {
autoDecompose: (data: any) => api.post('/budget/auto-decompose', data), autoDecompose: (data: any) => api.post('/budget/auto-decompose', data),
deviationReport: (params?: any) => api.get('/budget/deviation-report', { params }), deviationReport: (params?: any) => api.get('/budget/deviation-report', { params }),
methodComparison: (data: any) => api.post('/budget/method-comparison', data), 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 = { export const costApi = {
+374 -1
View File
@@ -29,6 +29,21 @@
<el-switch v-model="batchEditMode" active-text="批量编辑" inactive-text="逐行编辑" @change="onBatchEditToggle" /> <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" type="primary" @click="batchSaveAll" :loading="batchSavingAll" :disabled="changedRows.length === 0">批量保存 ({{ changedRows.length }})</el-button>
<el-button v-if="batchEditMode" @click="batchCancelAll">取消</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> </div>
<template v-if="budgetViewMode === 'list'"> <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 ? '暂无数据,在行内编辑填值后保存即可创建' : '请先选择年份'"> <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>
</div> </div>
</el-tab-pane> </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> </el-tabs>
<MyDialog v-model="showAddBudget" title="新增预算" :width="500"> <MyDialog v-model="showAddBudget" title="新增预算" :width="500">
@@ -283,13 +424,14 @@
</div> </div>
</template> </template>
<script setup lang="ts"> <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 { ElMessage, ElMessageBox } from 'element-plus'
import { budgetApi, kpiApi, mapApi, actionPlanApi } from '../api/index' import { budgetApi, kpiApi, mapApi, actionPlanApi } from '../api/index'
import MyDialog from '../components/MyDialog.vue' import MyDialog from '../components/MyDialog.vue'
const activeTab = ref('input') const activeTab = ref('input')
const currentYear = new Date().getFullYear() 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 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 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) 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) => { watch(activeTab, (tab) => {
if (tab === 'decompose') loadBudget() if (tab === 'decompose') loadBudget()
else if (tab === 'strategy') loadStrategyBudget() else if (tab === 'strategy') loadStrategyBudget()
else if (tab === 'versions') loadVersions() else if (tab === 'versions') loadVersions()
else if (tab === 'execution') loadExecutionReport() else if (tab === 'execution') loadExecutionReport()
else if (tab === 'method') refreshMethodComparison() else if (tab === 'method') refreshMethodComparison()
else if (tab === 'rolling') {
loadComparison()
loadDeviationAlerts()
}
}) })
onMounted(async () => { onMounted(async () => {
await loadPublishedMaps() await loadPublishedMaps()
loadBudget() loadBudget()
loadBudgetConfig()
loadComparisonKpiOptions()
}) })
</script> </script>