"""预算管理 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, }