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 @@ 批量保存 ({{ changedRows.length }}) 取消 + +
+ 预算模式: + + 滚动12月 + 固定年度 +
+ 延展