diff --git a/backend/app/api/budget.py b/backend/app/api/budget.py
index ab46345d..5f7de7eb 100644
--- a/backend/app/api/budget.py
+++ b/backend/app/api/budget.py
@@ -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 - 增量/零基/弹性)
# ──────────────────────────────────────────────
diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py
index ba0deff9..a5a65980 100644
--- a/backend/app/models/__init__.py
+++ b/backend/app/models/__init__.py
@@ -382,6 +382,32 @@ class CashForecast(Base):
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):
"""预测准确率 — 上期预测 vs 本期实际"""
__tablename__ = "forecast_accuracy"
diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts
index 1b997374..a9a354cd 100644
--- a/frontend/src/api/index.ts
+++ b/frontend/src/api/index.ts
@@ -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 = {
diff --git a/frontend/src/views/BudgetManagement.vue b/frontend/src/views/BudgetManagement.vue
index 168830b0..5f884220 100644
--- a/frontend/src/views/BudgetManagement.vue
+++ b/frontend/src/views/BudgetManagement.vue
@@ -29,6 +29,21 @@
暂无对比数据,请先录入预算和实际值
+暂无偏差预警,点击「执行偏差检查」扫描当前期间
+