Files
cma-management/backend/app/api/budget.py
T
Hermes CI Fix 3c64dc767e feat: 战略目标vs预算差异可见化 — 目标与预算允许不同但差异可解释
需求: 战略目标(管理团队愿景) vs 预算(财务按实际分析) 允许不同
增强:
- 后端 deviation-report 返回 strategic_target/target_gap_pct/target_gap_level
- 预算执行表加'战略目标'+'目标差异'列(±10%黄/±20%红⚠️提示'需说明原因')
- 战略预算编制KPI树加'战略目标'列(编制时可见对比)
- 实测: F_REVENUE 战略1200 vs 预算173.86 差异-85.5%(high) 红提示

口径说明: 差异基于当前target_monthly/预算值, 目标口径校准另行处理
2026-08-27 17:26:38 +08:00

1391 lines
49 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""预算管理 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.deps import get_entity_id
from app.auth_middleware import require_auth, require_role
from app.models import BudgetPlan, KPIDefinition, OperationLog, KPIValue
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),
keyword: Optional[str] = Query(None, description="搜索KPI名称"),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""查询预算计划列表"""
query = db.query(BudgetPlan).join(
KPIDefinition, BudgetPlan.kpi_id == KPIDefinition.id
).filter(KPIDefinition.entity_id == entity_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)
if keyword:
query = query.filter(KPIDefinition.kpi_name.contains(keyword))
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 "",
"dimension": kpi.dimension if kpi else "",
"unit": kpi.unit 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),
):
"""自动分解年度预算到月度(均分或按历史权重)
支持两种模式:
1. 单KPI:传 kpi_id + annual_budget
2. 批量:不传 kpi_id,分解该年所有已有年度预算的KPI
"""
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")
# ── 批量模式:不传kpi_id → 分解该年所有有年度预算的KPI ──
if not kpi_id:
# 找该年已存在的年度预算(period=YYYY-00 或已按月填的KPI汇总)
# 优先用 budget_plans 中该年的预算作为年度总额
year_budget_rows = db.query(BudgetPlan).filter(
BudgetPlan.entity_id == 1,
BudgetPlan.budget_year == year,
BudgetPlan.status == "active",
).all()
# 按KPI聚合年度预算总额
kpi_annual = {}
for r in year_budget_rows:
kpi_annual[r.kpi_id] = kpi_annual.get(r.kpi_id, 0) + (r.budget_value or 0)
if not kpi_annual:
raise HTTPException(400, "该年度没有可分解的预算,请先在预算执行中录入年度预算")
results = []
created_count = 0
for kid, annual in kpi_annual.items():
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kid).first()
if not kpi:
continue
# 计算各月权重
if method == "weighted":
last_year = year - 1
values = db.query(KPIValue).filter(
KPIValue.kpi_id == kid,
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)
weights = {v.period: v.actual_value / total for v in values} if total > 0 else {}
else:
weights = {}
monthly = []
for m in range(1, 13):
period = f"{year}-{m:02d}"
weight = weights.get(period, 1 / 12) if method == "weighted" and weights else 1 / 12
monthly_value = round(annual * weight, 2)
existing = db.query(BudgetPlan).filter(
BudgetPlan.kpi_id == kid,
BudgetPlan.period == period,
BudgetPlan.version == version,
BudgetPlan.status == "active",
).first()
if existing:
existing.budget_value = monthly_value
existing.updated_at = datetime.now()
else:
db.add(BudgetPlan(
entity_id=1,
kpi_id=kid,
period=period,
budget_value=monthly_value,
budget_year=year,
budget_month=m,
version=version,
status="active",
))
created_count += 1
monthly.append(monthly_value)
results.append({
"kpi_id": kid,
"kpi_code": kpi.kpi_code,
"kpi_name": kpi.kpi_name,
"annual_budget": round(annual, 2),
"method": "equal" if not weights else "weighted",
"monthly": monthly,
"monthly_count": 12,
})
db.commit()
return {
"message": f"批量分解完成:{len(results)}个KPI",
"count": len(results),
"results": results,
"created": created_count,
}
# ── 单KPI模式(原有逻辑)──
if annual_budget is None:
raise HTTPException(400, "缺少必要参数: 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)
# 补充KPI基础信息(修复前端"预算执行KPI无名称"
item["kpi_name"] = kpi.kpi_name
item["dimension"] = kpi.dimension
item["unit"] = kpi.unit or ""
# 战略目标 vs 预算差异(2026-08-27: 战略=愿景/预算=计划, 允许不同但差异可见可解释)
target = kpi.target_monthly or kpi.target_value
item["strategic_target"] = target
if item.get("budget_value") is not None and target:
gap = round((item["budget_value"] - target) / target * 100, 1)
item["target_gap_pct"] = gap
item["target_gap_level"] = "high" if abs(gap) > 20 else ("medium" if abs(gap) > 10 else "ok")
else:
item["target_gap_pct"] = None
item["target_gap_level"] = "none"
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": "零基预算",
}
@router.post("/apply-method")
def apply_budget_method(
data: dict,
db: Session = Depends(get_db),
current_user=Depends(require_auth),
):
"""应用所选预算编制方法到预算计划(2026-08-26:三法并存,按用户场景选择后落地)
接收: { method: 'incremental'|'zero_based'|'flexible', year: 2026, entity: 'hanke', ... }
说明: 方法计算结果 → 写入/更新预算计划(version标注方法名,便于追溯)
"""
method = data.get("method", "zero_based")
year = data.get("year", datetime.now().year)
entity = data.get("entity", "hanke")
entity_id = data.get("entity_id", 1)
# 复用method-comparison计算(获得三法结果)
comp = budget_method_comparison({
"entity": entity,
"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),
"increment_rate": data.get("increment_rate", 0.05),
})
# 找所选方法的结果
selected = None
for m in comp["methods"]:
if m["id"] == method:
selected = m
break
if not selected:
raise HTTPException(400, "未知预算方法: " + method)
# 找到该年的核心KPI(营业收入/净利润/费用率等)
# 取该年已有预算的KPI,或默认核心4个
kpis = db.query(KPIDefinition).filter(
KPIDefinition.entity_id == entity_id,
KPIDefinition.status == "active",
KPIDefinition.kpi_code.in_(["F_REVENUE", "F_NET_PROFIT", "F_COST_RATIO", "F_GROSS_MARGIN"]),
).all()
if not kpis:
raise HTTPException(400, "未找到可应用的KPI")
# 方法结果解释为收入预算(核心KPI应用)
# incremental/flexible/zero_based 的 result_value 均为"预算总额(万)"
# 写入F_REVENUE年度预算(period=YYYY-00 表示年度)
version = f"{method}-{datetime.now().strftime('%Y%m%d')}"
applied = []
for kpi in kpis:
period = f"{year}-00"
# 删除旧版本的同KPI年度预算
db.query(BudgetPlan).filter(
BudgetPlan.kpi_id == kpi.id,
BudgetPlan.period == period,
BudgetPlan.version.like(f"{method}-%"),
).delete()
# 各KPI的应用值(简化:收入用方法结果,其他按比例)
if kpi.kpi_code == "F_REVENUE":
budget_val = selected["result_value"]
elif kpi.kpi_code == "F_NET_PROFIT":
budget_val = round(selected["result_value"] * 0.02, 1) # 净利率约2%
elif kpi.kpi_code == "F_COST_RATIO":
budget_val = round(selected["result_value"] * 0.22, 1) # 费用率约22%
else: # F_GROSS_MARGIN
budget_val = round(selected["result_value"] * 0.18, 1) # 毛利率约18%
bp = BudgetPlan(
entity_id=entity_id,
kpi_id=kpi.id,
period=period,
budget_value=budget_val,
budget_year=year,
budget_month=0,
version=version,
status="active",
remark=f"{selected['name']}应用({selected['result_value']}万) 来源{method}",
)
db.add(bp)
applied.append({"kpi_code": kpi.kpi_code, "budget_value": budget_val})
db.commit()
return {
"message": f"已应用「{selected['name']}」到预算计划",
"method": method,
"method_name": selected["name"],
"version": version,
"total_budget": selected["result_value"],
"detail": selected["detail"],
"applied": applied,
"note": "选择哪种方法取决于场景:增量=稳定业务快速编;零基=成本优化专项;弹性=收入波动大。方法结果写入年度预算(period=YYYY-00),可在版本管理中查看。",
}
# ============ 预算版本管理 API2026-08-25 补充,修复前端"加载版本失败" ============
@router.get("/versions")
def list_budget_versions(
year: Optional[int] = Query(None, description="预算年份"),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""预算版本列表 — 按版本聚合统计"""
query = db.query(BudgetPlan).filter(BudgetPlan.entity_id == entity_id)
if year:
query = query.filter(BudgetPlan.budget_year == year)
rows = query.all()
# 按version+status聚合
version_map = {}
for r in rows:
key = (r.version or "v1.0", r.status or "active")
if key not in version_map:
version_map[key] = {
"version": r.version or "v1.0",
"status": r.status or "active",
"kpi_count": 0,
"total_budget": 0.0,
"periods": set(),
"updated_at": r.updated_at,
}
v = version_map[key]
v["kpi_count"] += 1
v["total_budget"] += r.budget_value or 0
v["periods"].add(r.period)
if r.updated_at and (v["updated_at"] is None or r.updated_at > v["updated_at"]):
v["updated_at"] = r.updated_at
result = []
for key, v in version_map.items():
result.append({
"version": v["version"],
"status": v["status"],
"kpi_count": v["kpi_count"],
"total_budget": round(v["total_budget"], 2),
"period_count": len(v["periods"]),
"year": year,
"updated_at": v["updated_at"].strftime("%Y-%m-%d %H:%M") if v["updated_at"] else "",
})
result.sort(key=lambda x: x["version"], reverse=True)
return result
@router.post("/versions/submit")
def submit_budget_version(
data: dict,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""提交版本审批:active → submitted"""
version = data.get("version", "v1.0")
rows = db.query(BudgetPlan).filter(
BudgetPlan.entity_id == entity_id,
BudgetPlan.version == version,
).all()
if not rows:
raise HTTPException(404, f"版本 {version} 不存在")
for r in rows:
r.status = "submitted"
db.commit()
return {"success": True, "version": version, "status": "submitted", "count": len(rows)}
@router.post("/versions/approve")
def approve_budget_version(
data: dict,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""审批版本:submitted → approved/rejected"""
version = data.get("version", "v1.0")
action = data.get("action", "approved")
rows = db.query(BudgetPlan).filter(
BudgetPlan.entity_id == entity_id,
BudgetPlan.version == version,
).all()
if not rows:
raise HTTPException(404, f"版本 {version} 不存在")
new_status = "approved" if action == "approved" else "rejected"
for r in rows:
r.status = new_status
db.commit()
return {"success": True, "version": version, "status": new_status, "count": len(rows)}
@router.post("/versions/diff")
def diff_budget_versions(
data: dict,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""版本差异对比:v1.0 vs v2.0 逐KPI差异"""
version_a = data.get("version_a", "")
version_b = data.get("version_b", "")
year = data.get("year")
if not version_a or not version_b:
raise HTTPException(400, "需要 version_a 和 version_b")
def load_version(ver: str):
query = db.query(BudgetPlan).filter(
BudgetPlan.entity_id == entity_id,
BudgetPlan.version == ver,
)
if year:
query = query.filter(BudgetPlan.budget_year == year)
return {r.kpi_id: r for r in query.all()}
va = load_version(version_a)
vb = load_version(version_b)
kpi_ids = set(va.keys()) | set(vb.keys())
kpis = db.query(KPIDefinition).filter(KPIDefinition.id.in_(kpi_ids)).all()
kpi_map = {k.id: k for k in kpis}
diffs = []
total_a = total_b = 0.0
changed_count = 0
for kid in sorted(kpi_ids):
ra = va.get(kid)
rb = vb.get(kid)
val_a = ra.budget_value if ra else 0
val_b = rb.budget_value if rb else 0
total_a += val_a
total_b += val_b
k = kpi_map.get(kid)
diff = val_b - val_a
if abs(diff) > 0.001:
changed_count += 1
diffs.append({
"kpi_code": k.kpi_code if k else "",
"kpi_name": k.kpi_name if k else f"KPI-{kid}",
"version_a": round(val_a, 2),
"version_b": round(val_b, 2),
"diff": round(diff, 2),
})
diffs.sort(key=lambda x: -abs(x["diff"]))
return {
"summary": {
"version_a": version_a,
"version_b": version_b,
"kpi_total": len(kpi_ids),
"changed_count": changed_count,
"total_a": round(total_a, 2),
"total_b": round(total_b, 2),
"total_diff": round(total_b - total_a, 2),
},
"diffs": diffs,
}
# ════════════════════════════════════════════════════════════
# 预算↔现金流联动(断点修复#1, 2026-08-27
# ════════════════════════════════════════════════════════════
@router.post("/sync-cash-plans")
def sync_cash_plans(
entity_id: int = Depends(get_entity_id),
db: Session = Depends(get_db),
):
"""预算→现金流计划联动: 按预算KPI生成/更新收付款计划(修复断点#1)
收入类KPI(营收/回款/新客) → receive
成本类KPI(费用/厂补/采购) → pay
upsert: 同KPI+同日期+同类型 更新不重复
"""
from app.models import CashPlan
from datetime import datetime
RECEIVE_KEYS = ("营收", "收入", "销售", "回款", "新客", "收款", "净利润", "毛利")
PAY_KEYS = ("费用", "成本", "厂补", "采购", "返利", "应付", "损耗", "投入")
budgets = db.query(BudgetPlan).filter(
BudgetPlan.entity_id == entity_id, BudgetPlan.status == "active"
).all()
kpi_ids = {b.kpi_id for b in budgets}
kpis = {k.id: k for k in db.query(KPIDefinition).filter(KPIDefinition.id.in_(kpi_ids)).all()} if kpi_ids else {}
created, updated = 0, 0
for b in budgets:
kpi = kpis.get(b.kpi_id)
if not kpi:
continue
name = (kpi.kpi_name or "") + (kpi.kpi_code or "")
if any(k in name for k in RECEIVE_KEYS):
plan_type = "receive"
elif any(k in name for k in PAY_KEYS):
plan_type = "pay"
else:
continue # 无法判类别的KPI跳过
year, month = b.budget_year or 2026, b.budget_month or 1
try:
plan_date = datetime(year, month, 1)
except Exception:
continue
# upsert: 同KPI+同日期+同类型
existing = db.query(CashPlan).filter(
CashPlan.entity_id == entity_id,
CashPlan.related_kpi_id == b.kpi_id,
CashPlan.plan_type == plan_type,
CashPlan.plan_date == plan_date,
).first()
if existing:
existing.amount = b.budget_value
existing.budget_plan_id = b.id
existing.source = "budget_sync"
updated += 1
else:
db.add(CashPlan(
entity_id=entity_id, plan_type=plan_type,
related_kpi_id=b.kpi_id, budget_plan_id=b.id,
amount=b.budget_value, plan_date=plan_date,
description=f"预算联动: {kpi.kpi_name or kpi.kpi_code}",
status="pending", source="budget_sync",
))
created += 1
db.commit()
return {"message": f"现金流联动完成: 新建{created}条, 更新{updated}条", "created": created, "updated": updated}