"""预算管理 API — 管理会计OS 预算值的CRUD、自动分解、版本管理 """ from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.orm import Session from sqlalchemy import func, or_ 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), map_id: Optional[int] = Query(None, description="按战略地图过滤预算"), 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 map_id: # 按地图隔离: 显示该地图的预算 + 未绑定地图的历史预算(NULL, 兼容迁移) query = query.filter(or_(BudgetPlan.map_id == map_id, BudgetPlan.map_id.is_(None))) 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, "map_id": p.map_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") map_id = data.get("map_id") # 预算归属地图(2026-08-27: 按地图隔离) # 检查是否已有记录(去重, 含map_id) existing = db.query(BudgetPlan).filter( BudgetPlan.kpi_id == kpi_id, BudgetPlan.period == period, BudgetPlan.version == version, BudgetPlan.map_id == map_id, 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, map_id=map_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),可在版本管理中查看。", } # ============ 预算版本管理 API(2026-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}