feat: 持续规划 — 滚动预算+自动延展+对比线+偏差告警
This commit is contained in:
@@ -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 - 增量/零基/弹性)
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user