955 lines
32 KiB
Python
955 lines
32 KiB
Python
"""预算管理 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.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),
|
||
):
|
||
"""查询预算计划列表"""
|
||
query = db.query(BudgetPlan).join(
|
||
KPIDefinition, BudgetPlan.kpi_id == KPIDefinition.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)
|
||
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": "零基预算",
|
||
}
|