Files
cma-management/backend/app/api/budget.py
T

1118 lines
38 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""预算管理 API — 管理会计OS
预算值的CRUD、自动分解、版本管理
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import func
from typing import Optional
from datetime import datetime
from app.database import get_db
from app.deps import get_entity_id
from app.auth_middleware import require_auth, require_role
from app.models import BudgetPlan, KPIDefinition, OperationLog
router = APIRouter(prefix="/api/cma/budget", tags=["预算管理"],
dependencies=[Depends(require_role("ceo", "finance", "it"))],
)
@router.get("/plans")
def list_budget_plans(
kpi_id: Optional[int] = Query(None),
period: Optional[str] = Query(None),
year: Optional[int] = Query(None),
version: Optional[str] = Query(None),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""查询预算计划列表"""
query = db.query(BudgetPlan).join(
KPIDefinition, BudgetPlan.kpi_id == KPIDefinition.id
).filter(KPIDefinition.entity_id == entity_id)
if kpi_id:
query = query.filter(BudgetPlan.kpi_id == kpi_id)
if period:
query = query.filter(BudgetPlan.period == period)
if year:
query = query.filter(BudgetPlan.budget_year == year)
if version:
query = query.filter(BudgetPlan.version == version)
plans = query.order_by(BudgetPlan.budget_year.desc(), BudgetPlan.budget_month.asc()).all()
result = []
for p in plans:
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == p.kpi_id).first()
result.append({
"id": p.id,
"kpi_id": p.kpi_id,
"kpi_code": kpi.kpi_code if kpi else "",
"kpi_name": kpi.kpi_name if kpi else "",
"period": p.period,
"budget_value": p.budget_value,
"budget_year": p.budget_year,
"budget_month": p.budget_month,
"version": p.version,
"status": p.status,
"remark": p.remark,
"created_at": p.created_at.isoformat() if p.created_at else None,
})
return {"data": result, "total": len(result)}
@router.post("/plans")
def create_budget_plan(
data: dict,
db: Session = Depends(get_db),
current_user=Depends(require_auth),
):
"""创建或更新单条预算计划"""
kpi_id = data.get("kpi_id")
period = data.get("period")
budget_value = data.get("budget_value")
if not all([kpi_id, period, budget_value is not None]):
raise HTTPException(400, "缺少必要参数: kpi_id, period, budget_value")
# 检查KPI是否存在
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
if not kpi:
raise HTTPException(404, "KPI不存在")
year, month = period.split("-")
version = data.get("version", "v1.0")
# 检查是否已有记录(去重)
existing = db.query(BudgetPlan).filter(
BudgetPlan.kpi_id == kpi_id,
BudgetPlan.period == period,
BudgetPlan.version == version,
BudgetPlan.status == "active",
).first()
if existing:
existing.budget_value = budget_value
existing.remark = data.get("remark", existing.remark)
db.commit()
db.refresh(existing)
return {"message": "预算已更新", "id": existing.id}
else:
plan = BudgetPlan(
kpi_id=kpi_id,
period=period,
budget_value=budget_value,
budget_year=int(year),
budget_month=int(month),
version=version,
status="active",
remark=data.get("remark", ""),
created_by=current_user.name if hasattr(current_user, "name") else "",
)
db.add(plan)
db.commit()
db.refresh(plan)
# 记录操作日志
log = OperationLog(
action="create",
target_type="budget",
target_id=plan.id,
detail=__import__("json").dumps({"kpi_id": kpi_id, "period": period, "value": budget_value}, ensure_ascii=False),
)
db.add(log)
db.commit()
return {"message": "预算已创建", "id": plan.id}
@router.put("/plans/{plan_id}")
def update_budget_plan(
plan_id: int,
data: dict,
db: Session = Depends(get_db),
):
"""更新预算计划"""
plan = db.query(BudgetPlan).filter(BudgetPlan.id == plan_id).first()
if not plan:
raise HTTPException(404, "预算计划不存在")
if "budget_value" in data:
plan.budget_value = data["budget_value"]
if "remark" in data:
plan.remark = data["remark"]
if "version" in data:
plan.version = data["version"]
if "status" in data:
plan.status = data["status"]
db.commit()
return {"message": "预算已更新"}
@router.delete("/plans/{plan_id}")
def delete_budget_plan(
plan_id: int,
db: Session = Depends(get_db),
):
"""删除预算计划"""
plan = db.query(BudgetPlan).filter(BudgetPlan.id == plan_id).first()
if not plan:
raise HTTPException(404, "预算计划不存在")
db.delete(plan)
db.commit()
return {"message": "预算已删除"}
@router.post("/auto-decompose")
def auto_decompose_budget(
data: dict,
db: Session = Depends(get_db),
current_user=Depends(require_auth),
):
"""自动分解年度预算到月度(均分或按历史权重)"""
kpi_id = data.get("kpi_id")
year = data.get("year", datetime.now().year)
annual_budget = data.get("annual_budget")
method = data.get("method", "equal") # equal / weighted
version = data.get("version", "v1.0")
if not kpi_id or annual_budget is None:
raise HTTPException(400, "缺少必要参数: kpi_id, annual_budget")
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
if not kpi:
raise HTTPException(404, "KPI不存在")
# 计算各月权重
if method == "weighted":
# 按去年各月实际值的比例分配
last_year = year - 1
values = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi_id,
KPIValue.period.like(f"{last_year}-%"),
KPIValue.actual_value.isnot(None),
).order_by(KPIValue.period.asc()).all()
total = sum(v.actual_value for v in values)
if total > 0:
weights = {v.period: v.actual_value / total for v in values}
else:
method = "equal"
created = []
for m in range(1, 13):
period = f"{year}-{m:02d}"
weight = weights.get(period, 1 / 12) if method == "weighted" else 1 / 12
monthly_value = round(annual_budget * weight, 2)
existing = db.query(BudgetPlan).filter(
BudgetPlan.kpi_id == kpi_id,
BudgetPlan.period == period,
BudgetPlan.version == version,
BudgetPlan.status == "active",
).first()
if existing:
existing.budget_value = monthly_value
else:
bp = BudgetPlan(
kpi_id=kpi_id, period=period,
budget_value=monthly_value, budget_year=year,
budget_month=m, version=version, status="active",
created_by=current_user.name if hasattr(current_user, "name") else "",
)
db.add(bp)
created.append({"period": period, "value": monthly_value})
else:
# 均分
monthly = round(annual_budget / 12, 2)
created = []
for m in range(1, 13):
period = f"{year}-{m:02d}"
existing = db.query(BudgetPlan).filter(
BudgetPlan.kpi_id == kpi_id,
BudgetPlan.period == period,
BudgetPlan.version == version,
BudgetPlan.status == "active",
).first()
if existing:
existing.budget_value = monthly
else:
bp = BudgetPlan(
kpi_id=kpi_id, period=period,
budget_value=monthly, budget_year=year,
budget_month=m, version=version, status="active",
created_by=current_user.name if hasattr(current_user, "name") else "",
)
db.add(bp)
created.append({"period": period, "value": monthly})
db.commit()
return {
"message": f"年度预算已分解为{len(created)}个月度预算",
"kpi_id": kpi_id,
"kpi_name": kpi.kpi_name,
"year": year,
"annual_budget": annual_budget,
"method": method,
"monthly_budgets": created,
}
@router.get("/deviation-report")
def get_deviation_report(
kpi_id: Optional[int] = Query(None),
period: Optional[str] = Query(None),
year: Optional[int] = Query(None),
month: Optional[int] = Query(None),
dimension: Optional[str] = Query(None),
alert_level: Optional[str] = Query(None),
db: Session = Depends(get_db),
):
"""获取差异分析报告(汇总多个KPI的实际vs预算差异)"""
if period is None:
if year and month:
period = f"{year}-{month:02d}"
elif year:
period = f"{year}-{datetime.now().month:02d}"
else:
period = datetime.now().strftime("%Y-%m")
query = db.query(KPIDefinition).filter(KPIDefinition.status == "active")
if kpi_id:
query = query.filter(KPIDefinition.id == kpi_id)
if dimension:
query = query.filter(KPIDefinition.dimension == dimension)
kpis = query.all()
from app.utils.deviation_engine import calc_period_deviation, calc_period_diff
items = []
summary = {
"total_kpis": 0,
"has_budget": 0,
"over_budget": 0,
"under_budget": 0,
"avg_deviation_rate": 0,
}
rates = []
for kpi in kpis:
item = calc_period_deviation(db, kpi.id, period)
# 补充KPI基础信息(修复前端"预算执行KPI无名称"
item["kpi_name"] = kpi.kpi_name
item["dimension"] = kpi.dimension
item["unit"] = kpi.unit or ""
items.append(item)
summary["total_kpis"] += 1
if item.get("budget_value") is not None:
summary["has_budget"] += 1
if item.get("is_over_budget"):
summary["over_budget"] += 1
elif item.get("deviation_rate") is not None and item["deviation_rate"] < 0:
summary["under_budget"] += 1
if item.get("deviation_rate") is not None:
rates.append(abs(item["deviation_rate"]))
# 补充同比/环比
if item.get("actual_value") is not None:
item["yoy"] = calc_period_diff(db, kpi.id, period, "yoy")
item["mom"] = calc_period_diff(db, kpi.id, period, "mom")
summary["avg_deviation_rate"] = round(sum(rates) / len(rates), 2) if rates else 0
# 前端 alert_level 过滤
if alert_level:
def get_level(rate):
if rate is None:
return None
if rate > 20:
return "red"
if rate > 10:
return "yellow"
return "normal"
items = [i for i in items if get_level(i.get("deviation_rate")) == alert_level]
return {
"period": period,
"summary": summary,
"items": items,
}
# ──────────────────────────────────────────────
# 滚动/固定预算切换
# ──────────────────────────────────────────────
@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 - 增量/零基/弹性)
# ──────────────────────────────────────────────
@router.post("/method-comparison")
def budget_method_comparison(data: dict):
"""
预算方法三选一对比计算
接收: { entity: "hanke", last_month_budget: 91, current_revenue: 122, ... }
返回三种方法的计算结果
"""
entity = data.get("entity", "hanke")
last_month_budget = data.get("last_month_budget", 91) # 上月预算(万)
current_revenue = data.get("current_revenue", 122) # 当前收入(万)
fixed_costs = data.get("fixed_costs", {
"rent": 15, # 房租(万)
"labor": 40, # 人工(万)
"entertainment": 16, # 招待费(万)
"misc": 12, # 杂项(万)
})
variable_cost_rate = data.get("variable_cost_rate", 0.4862) # 变动成本率
# 1. 增量预算: 基于上月统一调整
increment_rate = data.get("increment_rate", 0.05) # 5%增幅
incremental_result = round(last_month_budget * (1 + increment_rate), 1)
incremental_detail = f"上月{last_month_budget}× (1+{increment_rate*100:.0f}%) = {incremental_result}万"
# 2. 零基预算: 每项从零论证
zbb_entertainment = round(fixed_costs.get("entertainment", 16) / 2, 1) # 砍半
zbb_misc = round(fixed_costs.get("misc", 12) * 0.7, 1) # 压缩30%
zbb_total = round(
fixed_costs.get("rent", 15)
+ fixed_costs.get("labor", 40)
+ zbb_entertainment
+ zbb_misc,
1,
)
zbb_savings = round(last_month_budget - zbb_total, 1)
zbb_detail = (
f"房租{fixed_costs.get('rent', 15)}万(固定)+人工{fixed_costs.get('labor', 40)}万(砍不掉)"
f"+招待{zbb_entertainment}万(砍半)+杂项{zbb_misc}万(压缩)"
f"={zbb_total}万 ← 省{zbb_savings}万"
)
# 3. 弹性预算: 根据收入水平动态调整
flexible_fixed = round(fixed_costs.get("rent", 15) + fixed_costs.get("labor", 40) * 0.5, 1)
flexible_variable = round(current_revenue * variable_cost_rate * 0.4, 1)
flexible_total = round(flexible_fixed + flexible_variable, 1)
flexible_variance = round(last_month_budget - flexible_total, 1)
flex_detail = (
f"收入{current_revenue}万 → 对应费用预算 = {flexible_total}万"
f"(固定部分{flexible_fixed}万+变动部分{flexible_variable}万)"
f",实际{last_month_budget}万 → 差异{flexible_variance}万 → {'效率问题' if flexible_variance > 0 else '节省'}"
)
# 推荐方法
recommended = "zero_based"
return {
"entity": entity,
"entity_name": "陕西酣客(白酒经销)" if entity == "hanke" else "陕西博海科技(IT服务)",
"methods": [
{
"id": "incremental",
"name": "增量预算",
"name_en": "Incremental Budgeting",
"result_value": incremental_result,
"detail": incremental_detail,
"pros": "简单快速",
"cons": "浪费持续",
"is_recommended": False,
},
{
"id": "zero_based",
"name": "零基预算",
"name_en": "Zero-Based Budgeting (ZBB)",
"result_value": zbb_total,
"savings": zbb_savings,
"detail": zbb_detail,
"pros": "最合理",
"cons": "耗时",
"is_recommended": True,
},
{
"id": "flexible",
"name": "弹性预算",
"name_en": "Flexible Budgeting",
"result_value": flexible_total,
"variance": flexible_variance,
"detail": flex_detail,
"pros": "动态响应",
"cons": "需要详细分类",
"is_recommended": False,
},
],
"recommended": recommended,
"recommended_name": "零基预算",
}
# ============ 预算版本管理 API2026-08-25 补充,修复前端"加载版本失败" ============
@router.get("/versions")
def list_budget_versions(
year: Optional[int] = Query(None, description="预算年份"),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""预算版本列表 — 按版本聚合统计"""
query = db.query(BudgetPlan).filter(BudgetPlan.entity_id == entity_id)
if year:
query = query.filter(BudgetPlan.budget_year == year)
rows = query.all()
# 按version+status聚合
version_map = {}
for r in rows:
key = (r.version or "v1.0", r.status or "active")
if key not in version_map:
version_map[key] = {
"version": r.version or "v1.0",
"status": r.status or "active",
"kpi_count": 0,
"total_budget": 0.0,
"periods": set(),
"updated_at": r.updated_at,
}
v = version_map[key]
v["kpi_count"] += 1
v["total_budget"] += r.budget_value or 0
v["periods"].add(r.period)
if r.updated_at and (v["updated_at"] is None or r.updated_at > v["updated_at"]):
v["updated_at"] = r.updated_at
result = []
for key, v in version_map.items():
result.append({
"version": v["version"],
"status": v["status"],
"kpi_count": v["kpi_count"],
"total_budget": round(v["total_budget"], 2),
"period_count": len(v["periods"]),
"year": year,
"updated_at": v["updated_at"].strftime("%Y-%m-%d %H:%M") if v["updated_at"] else "",
})
result.sort(key=lambda x: x["version"], reverse=True)
return result
@router.post("/versions/submit")
def submit_budget_version(
data: dict,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""提交版本审批:active → submitted"""
version = data.get("version", "v1.0")
rows = db.query(BudgetPlan).filter(
BudgetPlan.entity_id == entity_id,
BudgetPlan.version == version,
).all()
if not rows:
raise HTTPException(404, f"版本 {version} 不存在")
for r in rows:
r.status = "submitted"
db.commit()
return {"success": True, "version": version, "status": "submitted", "count": len(rows)}
@router.post("/versions/approve")
def approve_budget_version(
data: dict,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""审批版本:submitted → approved/rejected"""
version = data.get("version", "v1.0")
action = data.get("action", "approved")
rows = db.query(BudgetPlan).filter(
BudgetPlan.entity_id == entity_id,
BudgetPlan.version == version,
).all()
if not rows:
raise HTTPException(404, f"版本 {version} 不存在")
new_status = "approved" if action == "approved" else "rejected"
for r in rows:
r.status = new_status
db.commit()
return {"success": True, "version": version, "status": new_status, "count": len(rows)}
@router.post("/versions/diff")
def diff_budget_versions(
data: dict,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""版本差异对比:v1.0 vs v2.0 逐KPI差异"""
version_a = data.get("version_a", "")
version_b = data.get("version_b", "")
year = data.get("year")
if not version_a or not version_b:
raise HTTPException(400, "需要 version_a 和 version_b")
def load_version(ver: str):
query = db.query(BudgetPlan).filter(
BudgetPlan.entity_id == entity_id,
BudgetPlan.version == ver,
)
if year:
query = query.filter(BudgetPlan.budget_year == year)
return {r.kpi_id: r for r in query.all()}
va = load_version(version_a)
vb = load_version(version_b)
kpi_ids = set(va.keys()) | set(vb.keys())
kpis = db.query(KPIDefinition).filter(KPIDefinition.id.in_(kpi_ids)).all()
kpi_map = {k.id: k for k in kpis}
diffs = []
total_a = total_b = 0.0
changed_count = 0
for kid in sorted(kpi_ids):
ra = va.get(kid)
rb = vb.get(kid)
val_a = ra.budget_value if ra else 0
val_b = rb.budget_value if rb else 0
total_a += val_a
total_b += val_b
k = kpi_map.get(kid)
diff = val_b - val_a
if abs(diff) > 0.001:
changed_count += 1
diffs.append({
"kpi_code": k.kpi_code if k else "",
"kpi_name": k.kpi_name if k else f"KPI-{kid}",
"version_a": round(val_a, 2),
"version_b": round(val_b, 2),
"diff": round(diff, 2),
})
diffs.sort(key=lambda x: -abs(x["diff"]))
return {
"summary": {
"version_a": version_a,
"version_b": version_b,
"kpi_total": len(kpi_ids),
"changed_count": changed_count,
"total_a": round(total_a, 2),
"total_b": round(total_b, 2),
"total_diff": round(total_b - total_a, 2),
},
"diffs": diffs,
}