init: 管理会计OS初始代码
包含前后端完整代码: - 前端:Vue3+Vite+ElementPlus - 后端:FastAPI+SQLAlchemy - 模块:驾驶舱/KPI/战略地图/预警/预算/成本/预测/改善行动 - 当前版本:v1.0.0
This commit is contained in:
@@ -0,0 +1,336 @@
|
||||
"""预算管理 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,
|
||||
}
|
||||
Reference in New Issue
Block a user