包含前后端完整代码: - 前端:Vue3+Vite+ElementPlus - 后端:FastAPI+SQLAlchemy - 模块:驾驶舱/KPI/战略地图/预警/预算/成本/预测/改善行动 - 当前版本:v1.0.0
337 lines
11 KiB
Python
337 lines
11 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,
|
|
}
|