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 - 增量/零基/弹性)
# ──────────────────────────────────────────────