init: 管理会计OS初始代码

包含前后端完整代码:
- 前端:Vue3+Vite+ElementPlus
- 后端:FastAPI+SQLAlchemy
- 模块:驾驶舱/KPI/战略地图/预警/预算/成本/预测/改善行动
- 当前版本:v1.0.0
This commit is contained in:
Hermes CI Fix
2026-05-28 17:32:22 +08:00
commit 3dddd36866
142 changed files with 18533 additions and 0 deletions
+336
View File
@@ -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,
}