"""预算管理 API — 管理会计OS 预算值的CRUD、自动分解、版本管理 """ import re 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 from app.utils.kpi_value_scope import actual_only_clause from app.utils.oplog import log_op from app.utils.budget_status import ( EFFECTIVE_STATUSES, effective_plan_clause, effective_value_map, pick_effective, approval_status_of, ) 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), entity_id: int = Depends(get_entity_id), ): """创建或更新单条预算计划(账套隔离 2026-09-21:KPI 归属校验 + 写会话账套)""" 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是否存在(账套隔离 2026-09-21:仅本账套 KPI,防把预算挂到对端 KPI) kpi = db.query(KPIDefinition).filter( KPIDefinition.id == kpi_id, KPIDefinition.entity_id == entity_id, ).first() if not kpi: raise HTTPException(404, "KPI不存在") # 期间形态校验(年度行同族残余 2026-10-13:YYYY-00 显式接受为年度行,YYYY 提示补全形态) if not isinstance(period, str): raise HTTPException(400, "预算期间格式不正确:请使用 YYYY-MM(月 01–12)或 YYYY-00(年度)") if re.fullmatch(r"\d{4}-(0[1-9]|1[0-2])", period): # 月度形态 year, month = period.split("-")[0], int(period.split("-")[1]) elif re.fullmatch(r"\d{4}-00", period): # 年度形态(系统内部既有年度行,如 apply-method / 年度分解写入):显式接受 year, month = period.split("-")[0], 0 elif re.fullmatch(r"\d{4}", period): raise HTTPException(400, f"预算期间缺少月份:年度预算请使用 {period}-00 形态(例:2027-00),月度预算使用 YYYY-MM") else: raise HTTPException(400, "预算期间格式不正确:请使用 YYYY-MM(月 01–12)或 YYYY-00(年度)") 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( entity_id=entity_id, # 账套隔离 2026-09-21:原漏写→落模型默认 1 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, "entity_id": entity_id}, 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), entity_id: int = Depends(get_entity_id), ): """更新预算计划(账套隔离 2026-09-21:跨账套 id → 404)""" plan = db.query(BudgetPlan).filter( BudgetPlan.id == plan_id, BudgetPlan.entity_id == entity_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), entity_id: int = Depends(get_entity_id), ): """删除预算计划(账套隔离 2026-09-21:跨账套 id → 404)""" plan = db.query(BudgetPlan).filter( BudgetPlan.id == plan_id, BudgetPlan.entity_id == entity_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), entity_id: int = Depends(get_entity_id), ): """自动分解年度预算到月度(均分或按历史权重) 支持两种模式: 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)作为年度总额,避免把月度行也加进来导致滚雪球(非幂等bug修复) year_budget_rows = db.query(BudgetPlan).filter( BudgetPlan.entity_id == entity_id, BudgetPlan.budget_year == year, BudgetPlan.period == f"{year}-00", BudgetPlan.status == "active", ).all() # 按KPI聚合年度预算总额——同一KPI存在多个version年度行时只取一行 # (优先匹配请求version,否则取第一条),避免多版本叠加导致总额虚高(幂等加固) from collections import defaultdict per_kpi = defaultdict(list) for r in year_budget_rows: per_kpi[r.kpi_id].append(r) kpi_annual = {} kpi_version_used = {} for kid, rows in per_kpi.items(): chosen = next((r for r in rows if r.version == version), rows[0]) kpi_annual[kid] = chosen.budget_value or 0 kpi_version_used[kid] = chosen.version if not kpi_annual: # 查询该企业有哪些年份有年度预算行(供提示) from sqlalchemy import distinct avail_years = [r[0] for r in db.query(distinct(BudgetPlan.budget_year)).filter( BudgetPlan.entity_id == entity_id, BudgetPlan.period.like("%-00"), BudgetPlan.status == "active", ).order_by(BudgetPlan.budget_year).all()] hint = f"该年度({year})没有年度预算总额(period=YYYY-00)" if avail_years: hint += f";现有年度数据年份:{', '.join(str(y) for y in avail_years)}" hint += "。请先在预算录入中填写该年度预算总额,或改用战略预算编制按地图录入;如需按已有月度数据反向生成请咨询管理员。" raise HTTPException(400, hint) 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, actual_only_clause(), # 实测区口径:预估行禁作预算派生基数(kpi_value_scope) 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=entity_id, 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), "version_used": kpi_version_used.get(kid), "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分解需传年度预算值)") kpi = db.query(KPIDefinition).filter( KPIDefinition.id == kpi_id, KPIDefinition.entity_id == entity_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, actual_only_clause(), # 实测区口径:预估行禁作预算派生基数(kpi_value_scope) 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.entity_id == entity_id, BudgetPlan.period == period, BudgetPlan.version == version, BudgetPlan.status == "active", ).first() if existing: existing.budget_value = monthly_value else: bp = BudgetPlan( entity_id=entity_id, 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.entity_id == entity_id, BudgetPlan.period == period, BudgetPlan.version == version, BudgetPlan.status == "active", ).first() if existing: existing.budget_value = monthly else: bp = BudgetPlan( entity_id=entity_id, 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), entity_id: int = Depends(get_entity_id), ): """获取差异分析报告(汇总多个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", KPIDefinition.entity_id == entity_id, # 账套隔离 2026-09-21 ) 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), entity_id: int = Depends(get_entity_id), ): """ 滚动预算自动延展: - 删除最早一个月的预测数据 - 新增未来一个月的预测数据(取最近三个月均值) - 返回延展结果 账套隔离:只处理会话账套(join kpi_definitions 按企业过滤),2026-09-18 """ 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 的预算记录(账套隔离: join KPI 按企业过滤, 2026-09-18 — 原为全账套) plans = (db.query(BudgetPlan) .join(KPIDefinition, BudgetPlan.kpi_id == KPIDefinition.id) .filter(BudgetPlan.status == "active", KPIDefinition.entity_id == entity_id) .all()) # 按KPI分组(年度行排除 2026-10-13:budget_month=0 哨兵不参与月度滚动,仅年度行的 KPI 不删不产新行) from collections import defaultdict kpi_plans = defaultdict(list) annual_rows_ignored = 0 for p in plans: if _is_annual_budget_row(p.period, p.budget_month): annual_rows_ignored += 1 continue 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( entity_id=entity_id, 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), "annual_rows_ignored": annual_rows_ignored, } # ────────────────────────────────────────────── # 实际vs预测对比 # ────────────────────────────────────────────── @router.get("/comparison") def get_budget_comparison( kpi_id: Optional[int] = Query(None), year: Optional[int] = Query(None), db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id), ): """ 获取实际值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) # 生效版口径(2026-10-03 状态机归一):approved(已批准) ∪ active(已下达) query = query.filter( BudgetPlan.period.in_(periods), effective_plan_clause(entity_id), # 含账套隔离 ) budget_plans = query.all() # 按KPI+期间索引(同键多版本 → 取生效行 active > approved > updated_at desc) bp_map = effective_value_map(budget_plans) # 查询实际值 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), entity_id: int = Depends(get_entity_id), ): """ 获取单个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, KPIDefinition.entity_id == entity_id, # 账套隔离 2026-09-21 ).first() if not kpi: raise HTTPException(404, "KPI不存在") periods = [f"{y}-{m:02d}" for m in range(1, 13)] # 预算值 # 生效版口径(2026-10-03 状态机归一;保持原「未按账套过滤」行为不变) budgets = db.query(BudgetPlan).filter( BudgetPlan.kpi_id == kpi_id, BudgetPlan.period.in_(periods), BudgetPlan.status.in_(EFFECTIVE_STATUSES), ).all() budget_map = {p: v for (_kid, p), v in effective_value_map(budgets).items()} # 实际值 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), entity_id: int = Depends(get_entity_id), current_user=Depends(require_auth), ): """ 检查实际vs预测偏差,当偏差超过阈值时自动生成预警 (2026-08-28 P1-③/P2-⑤: 统一走 build_deviation_alert,写入归因JSON+场景建议) """ from app.models import KPIValue, BudgetDeviationAlert from sqlalchemy import func from app.utils.deviation_engine import build_deviation_alert threshold = data.get("threshold", 20) # 默认20% period = data.get("period") or datetime.now().strftime("%Y-%m") # 查询该期间有预算的KPI(多租户隔离 entity_id) # 生效版口径(2026-10-03 状态机归一):approved(已批准) ∪ active(已下达) _effective_rows = db.query(BudgetPlan).filter( BudgetPlan.period == period, effective_plan_clause(entity_id), ).all() # 同 KPI 多版本并存 → 取生效行(active > approved > updated_at desc),防重复告警 _by_kpi = {} for _bp in _effective_rows: _by_kpi.setdefault(_bp.kpi_id, []).append(_bp) budget_plans = [r for r in (pick_effective(v) for v in _by_kpi.values()) if r is not None] if not budget_plans: return { "message": f"期间 {period} 无预算数据", "alerts_generated": 0, "alerts": [], } alerts_generated = 0 alerts = [] for bp in budget_plans: kpi_obj = db.query(KPIDefinition).filter( KPIDefinition.id == bp.kpi_id, KPIDefinition.entity_id == entity_id, ).first() if not kpi_obj: continue # 统一告警逻辑(方向性/阈值/归因/场景建议) result = build_deviation_alert(db, kpi_obj, period, entity_id=entity_id, min_rate=threshold) if not result["triggered"]: continue deviation = result["deviation"] budget_val = deviation.get("budget_value") actual_val = deviation.get("actual_value") deviation_rate = deviation.get("deviation_rate") deviation_value = deviation.get("deviation_amount") if deviation_value is None: deviation_value = round((actual_val or 0) - (budget_val or 0), 2) # 检查是否已存在相同的预警 existing_alert = db.query(BudgetDeviationAlert).filter( BudgetDeviationAlert.kpi_id == bp.kpi_id, BudgetDeviationAlert.period == period, BudgetDeviationAlert.status == "open", ).first() if existing_alert: # 已存在open告警: 补齐归因(原open告警可能无归因, 幂等补写) if existing_alert.attribution is None and result["attribution"]: existing_alert.attribution = result["attribution"] existing_alert.alert_type = result["alert_type"] existing_alert.scenario_id = result["scenario_id"] db.flush() 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=result["level"], status="open", suggestion=result["suggestion"], alert_type=result["alert_type"], attribution=result["attribution"], scenario_id=result["scenario_id"], ) db.add(alert) alerts_generated += 1 # 立即 flush:autoflush=False 下,下一次循环的同 KPI 去重查询 # (existing_alert) 需看到本次已 add 的行,否则同期间同 KPI 多预算计划会重复告警 db.flush() 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": result["level"], "suggestion": result["suggestion"], "alert_type": result["alert_type"], "attribution": result["attribution"], }) 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), entity_id: int = Depends(get_entity_id), ): """查询预算偏差预警记录 (2026-08-28: 列表新增 alert_type/attribution/scenario_id,entity_id隔离)""" from app.models import BudgetDeviationAlert query = db.query(BudgetDeviationAlert).filter(BudgetDeviationAlert.entity_id == entity_id) 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, "alert_type": a.alert_type, "attribution": a.attribution, "scenario_id": a.scenario_id, "created_at": a.created_at.isoformat() if a.created_at else None, }) return {"data": result, "total": len(result)} @router.get("/deviation-alerts/{alert_id}/attribution") def get_deviation_alert_attribution( alert_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id), ): """告警归因详情 — 告警 + 归因JSON + 场景建议(联查 scenario_suggestions)(P1-③ 2026-08-28)""" from app.models import BudgetDeviationAlert, ScenarioSuggestion from app.utils.alert_attribution import match_scenario alert = db.query(BudgetDeviationAlert).filter( BudgetDeviationAlert.id == alert_id, BudgetDeviationAlert.entity_id == entity_id, ).first() if not alert: raise HTTPException(404, "预警记录不存在") kpi_obj = db.query(KPIDefinition).filter(KPIDefinition.id == alert.kpi_id).first() # 归因(若旧告警无归因字段,现场组装一次) attribution = alert.attribution if attribution is None: from app.utils.alert_attribution import build_attribution try: attribution, inferred_type = build_attribution(db, alert.kpi_id, alert.period, alert.alert_type) alert.attribution = attribution if alert.alert_type is None: alert.alert_type = inferred_type db.commit() except Exception: attribution = {} scenario = None if alert.scenario_id or alert.alert_type: scenario = match_scenario(db, alert.alert_type) return { "id": alert.id, "kpi_id": alert.kpi_id, "kpi_code": kpi_obj.kpi_code if kpi_obj else "", "kpi_name": kpi_obj.kpi_name if kpi_obj else "", "period": alert.period, "budget_value": alert.budget_value, "actual_value": alert.actual_value, "deviation_rate": alert.deviation_rate, "deviation_value": alert.deviation_value, "alert_level": alert.alert_level, "status": alert.status, "suggestion": alert.suggestion, "alert_type": alert.alert_type, "attribution": attribution or {}, "scenario": scenario, "created_at": alert.created_at.isoformat() if alert.created_at else None, } @router.get("/alert-direction") def get_alert_direction( db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id), ): """越高越好型KPI方向配置(**按会话账套**;2026-09-23 拆账套,零 DDL) 与 B1 `cash.current_balance` 同款:只读本账套键 `kpi_alert_higher_better.`, 本账套未配置 → 走 deviation_engine 内置默认表(兜底,不跨账套借用)。 """ from app.utils.deviation_engine import get_higher_better_codes, higher_better_key from app.models import SystemConfig key = higher_better_key(entity_id) cfg = db.query(SystemConfig).filter( SystemConfig.config_key == key ).first() codes = get_higher_better_codes(db, entity_id) return { "config_key": key, "entity_id": entity_id, "codes": codes, "is_configured": bool(cfg and cfg.config_value), } @router.put("/alert-direction") def update_alert_direction( data: dict, db: Session = Depends(get_db), current_user=Depends(require_auth), entity_id: int = Depends(get_entity_id), ): """维护越高越好型KPI方向配置 (P2-⑤) body: {codes: ["SALES_TOTAL", ...]} 账套隔离(2026-09-23):只写本账套键 `kpi_alert_higher_better.`; body 里的 `entity_id` 一律不信(会话账套为准,同 B1 口径)。 """ import json as _json from app.utils.deviation_engine import higher_better_key from app.models import SystemConfig codes = data.get("codes") if not isinstance(codes, list): raise HTTPException(400, "codes 必须是非空数组") codes = [str(c) for c in codes] key = higher_better_key(entity_id) cfg = db.query(SystemConfig).filter( SystemConfig.config_key == key ).first() if cfg: cfg.config_value = _json.dumps(codes, ensure_ascii=False) else: db.add(SystemConfig( config_key=key, config_value=_json.dumps(codes, ensure_ascii=False), description=f"越高越好型KPI编码列表(实际低于预算才告警)— 账套 {int(entity_id)}", )) db.commit() return {"success": True, "config_key": key, "entity_id": entity_id, "codes": codes} @router.put("/deviation-alerts/{alert_id}") def update_deviation_alert( alert_id: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id), ): """更新偏差预警(如标记已解决)(账套隔离 2026-09-21:跨账套 id → 404)""" from app.models import BudgetDeviationAlert alert = db.query(BudgetDeviationAlert).filter( BudgetDeviationAlert.id == alert_id, BudgetDeviationAlert.entity_id == entity_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, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id), ): """ 预算方法三选一对比计算 接收: { entity: "hanke", last_month_budget: 91, current_revenue: 122, ... } 返回三种方法的计算结果 (2026-08-28 P2-①: zero_based 优先读逐项论证项 budget_zero_based_items, 传入 zero_based_kpi_id+zero_based_period 且有论证项 → 逐项求和 is_demo=false; 无论证项 → fallback 旧公式 is_demo=true) """ 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. 零基预算: 优先逐项论证(P2-① 真零基) zbb_kpi_id = data.get("zero_based_kpi_id") zbb_period = data.get("zero_based_period") zbb_is_demo = True zbb_items = [] if zbb_kpi_id and zbb_period: from app.models import BudgetZeroBasedItem zbb_items = db.query(BudgetZeroBasedItem).filter( BudgetZeroBasedItem.entity_id == entity_id, BudgetZeroBasedItem.kpi_id == zbb_kpi_id, BudgetZeroBasedItem.period == zbb_period, ).all() if zbb_items: # 真零基: 逐项求和(仅 approved+draft 都算,draft为未定稿) zbb_total = round(sum(i.proposed_value for i in zbb_items), 1) zbb_is_demo = False zbb_detail = "零基逐项论证: " + " + ".join( f"{i.item_name}{i.proposed_value}万" for i in zbb_items ) + f" = {zbb_total}万" zbb_savings = round(last_month_budget - zbb_total, 1) else: # fallback 旧演示公式(标注 is_demo) 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, "is_demo": zbb_is_demo, "item_count": len(zbb_items), "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), entity_id: int = Depends(get_entity_id), current_user=Depends(require_auth), ): """应用所选预算编制方法到预算计划(2026-08-26:三法并存,按用户场景选择后落地) 接收: { method: 'incremental'|'zero_based'|'flexible', year: 2026, entity: 'hanke', ... } 说明: 方法计算结果 → 写入/更新预算计划(version标注方法名,便于追溯) (2026-08-28 P2-②: KPI派生规则可配置 budget_derivation_rules, percentage_of → base_kpi实际值×rate; incremental → 上月×(1+rate); 无规则 fallback 默认比例(净利2%/费用率22%/毛利18%), 响应带 rule_source) """ from app.models import BudgetDerivationRule method = data.get("method", "zero_based") year = data.get("year", datetime.now().year) entity = data.get("entity", "hanke") # 复用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), "zero_based_kpi_id": data.get("zero_based_kpi_id"), "zero_based_period": data.get("zero_based_period"), }, db=db, entity_id=entity_id) # 找所选方法的结果 selected = None for m in comp["methods"]: if m["id"] == method: selected = m break if not selected: raise HTTPException(400, "未知预算方法: " + method) # 找到该年的核心KPI(营业收入/净利润/费用率等) 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") # 加载派生规则(P2-②) rules = db.query(BudgetDerivationRule).filter( BudgetDerivationRule.entity_id == entity_id, BudgetDerivationRule.status == "active", ).all() rules_by_kpi = {r.kpi_id: r for r in rules} # 版本 version = f"{method}-{datetime.now().strftime('%Y%m%d')}" applied = [] used_configured = False 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的应用值:收入用方法结果,其他优先派生规则(P2-②) if kpi.kpi_code == "F_REVENUE": budget_val = selected["result_value"] rule_source = "default" formula_note = "方法结果" else: rule = rules_by_kpi.get(kpi.id) if rule and rule.params: rate = float(rule.params.get("rate", 0.02)) if rule.rule_type == "percentage_of" and rule.base_kpi_id: # 来源KPI实际值 × 比例 base_val = None base_actual = db.query(KPIValue).filter( KPIValue.kpi_id == rule.base_kpi_id, KPIValue.actual_value.isnot(None), # 实测区口径:预估行禁作预算基数(kpi_value_scope.actual_only_clause) actual_only_clause(), ).order_by(KPIValue.calculated_at.desc()).first() if base_actual: base_val = base_actual.actual_value if base_val is not None: budget_val = round(base_val * rate, 1) rule_source = "configured" formula_note = f"派生: 来源KPI实际值{base_val} × {rate}" else: budget_val = round(selected["result_value"] * rate, 1) rule_source = "configured_fallback" formula_note = f"派生规则无来源实际值, 按方法结果×{rate}" elif rule.rule_type == "incremental": # 上月预算 × (1+rate) prev_period = f"{year-1}-00" prev_plan = db.query(BudgetPlan).filter( BudgetPlan.entity_id == entity_id, BudgetPlan.kpi_id == kpi.id, BudgetPlan.period == prev_period, BudgetPlan.status == "active", ).order_by(BudgetPlan.updated_at.desc()).first() if prev_plan and prev_plan.budget_value is not None: budget_val = round(prev_plan.budget_value * (1 + rate), 1) rule_source = "configured" formula_note = f"派生: 上年预算{prev_plan.budget_value} × (1+{rate})" else: budget_val = round(selected["result_value"] * rate, 1) rule_source = "configured_fallback" formula_note = f"派生规则无上年预算, 按方法结果×{rate}" else: # formula 类型: 暂按方法结果×rate 兜底 budget_val = round(selected["result_value"] * rate, 1) rule_source = "configured" formula_note = f"派生规则(formula): 方法结果×{rate}" else: # fallback 默认比例 if 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% rule_source = "default" formula_note = "默认比例" if rule_source in ("configured", "configured_fallback"): used_configured = True 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} | {formula_note}", calc_logic=formula_note, ) db.add(bp) applied.append({"kpi_code": kpi.kpi_code, "budget_value": budget_val, "rule_source": rule_source}) 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, "rule_source": "configured" if used_configured else "default", "note": "选择哪种方法取决于场景:增量=稳定业务快速编;零基=成本优化专项;弹性=收入波动大。方法结果写入年度预算(period=YYYY-00),可在版本管理中查看。KPI派生规则可在「派生规则配置」中维护(P2-②)。", } # ============ 预算版本管理 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(): raw_status = v["status"] # 状态机对齐(2026-09-07 UI修复):DB 默认态是 active(当前在用/草稿态), # 前端审批按钮语义用 draft=可提交审批/submitted=待审批/approved=已批准只读/rejected=已驳回 # → approval_status 把 active 映射为 draft,同时保留 status 原字段兼容其他引用(下拉"当前"标记等) approval_status = approval_status_of(raw_status) # 单一实现(预算状态机口径) updated_str = v["updated_at"].strftime("%Y-%m-%d %H:%M") if v["updated_at"] else "" result.append({ "version": v["version"], "status": raw_status, "approval_status": approval_status, "kpi_count": v["kpi_count"], "plan_count": v["kpi_count"], # 前端列 prop 别名(BudgetManagement.vue versions tab) "total_budget": round(v["total_budget"], 2), "period_count": len(v["periods"]), "is_effective": raw_status in EFFECTIVE_STATUSES, # 是否执行基准 "can_release": raw_status == "approved", # 是否可下达 "year": year, "updated_at": updated_str, "last_updated": updated_str, # 前端列 prop 别名 }) 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} 不存在") submittable = [r for r in rows if (r.status or "") in ("draft", "active")] if not submittable: raise HTTPException(400, f"版本 {version} 无可提交行(需 draft/active,当前: {sorted({r.status for r in rows})})") for r in submittable: r.status = "submitted" db.commit() return {"success": True, "version": version, "status": "submitted", "count": len(submittable)} @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" decidable = [r for r in rows if (r.status or "") == "submitted"] if not decidable: raise HTTPException(400, f"版本 {version} 无可审批行(需 submitted,当前: {sorted({r.status for r in rows})})") for r in decidable: r.status = new_status db.commit() return {"success": True, "version": version, "status": new_status, "count": len(decidable)} @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) # ════════════════════════════════════════════════════════════ def _is_annual_budget_row(period: Optional[str], budget_month: Optional[int]) -> bool: """年度预算行判定(形态驱动):budget_month == 0(年度哨兵), 或 period 命中年度形态 YYYY / YYYY-00。""" if budget_month == 0: return True if period is None: return False p = str(period).strip() return re.fullmatch(r"\d{4}(?:-00)?", p) is not None @router.post("/sync-cash-plans") def sync_cash_plans( entity_id: int = Depends(get_entity_id), db: Session = Depends(get_db), ): """预算→现金流计划联动: 按预算KPI生成/更新收付款计划(修复断点#1) (2026-08-28 P2-⑥: 分类规则表优先, 未命中进待分类队列不再静默跳过) 收入类KPI(营收/回款/新客) → receive 成本类KPI(费用/厂补/采购) → pay 分类来源: ①cash_plan_classify_rules规则表(精确KPI→关键词) ②默认关键词兜底 ③待分类队列 upsert: 同KPI+同日期+同类型 更新不重复 """ from app.models import CashPlan, CashPlanClassifyRule, CashPlanUnclassified from datetime import datetime # 默认关键词兜底(兼容存量,规则表优先) RECEIVE_KEYS = ("营收", "收入", "销售", "回款", "新客", "收款", "净利润", "毛利") PAY_KEYS = ("费用", "成本", "厂补", "采购", "返利", "应付", "损耗", "投入") # 加载分类规则表(P2-⑥) rules = db.query(CashPlanClassifyRule).filter( CashPlanClassifyRule.entity_id == entity_id, CashPlanClassifyRule.status == "active", ).order_by(CashPlanClassifyRule.priority.asc()).all() kpi_rules = {r.kpi_id: r for r in rules if r.kpi_id} pattern_rules = [r for r in rules if not r.kpi_id and r.kpi_code_pattern] def classify_plan_type(kpi) -> Optional[str]: """返回 receive/pay/None(未分类)""" # ① 精确KPI匹配(优先) if kpi.id in kpi_rules: return kpi_rules[kpi.id].plan_type # ② 关键词/编码模式匹配(规则表) name = (kpi.kpi_name or "") + (kpi.kpi_code or "") for r in pattern_rules: if r.kpi_code_pattern and r.kpi_code_pattern in name: return r.plan_type # ③ 默认关键词兜底(兼容存量行为) if any(k in name for k in RECEIVE_KEYS): return "receive" if any(k in name for k in PAY_KEYS): return "pay" return None 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, unclassified_count = 0, 0, 0 skipped_annual = 0 for b in budgets: # 年度汇总行(budget_month=0 哨兵 / period=YYYY 或 YYYY-00)不联动现金流: # CashPlan 无年度维度, 跳过以免全年额回落成 YYYY-01 污染按月分桶 if _is_annual_budget_row(b.period, b.budget_month): skipped_annual += 1 continue kpi = kpis.get(b.kpi_id) if not kpi: continue plan_type = classify_plan_type(kpi) if plan_type is None: # 无法判类别 → 写入待分类队列(不静默跳过,P2-⑥) existing_un = db.query(CashPlanUnclassified).filter( CashPlanUnclassified.entity_id == entity_id, CashPlanUnclassified.kpi_id == b.kpi_id, CashPlanUnclassified.period == b.period, CashPlanUnclassified.status == "pending", ).first() if not existing_un: db.add(CashPlanUnclassified( entity_id=entity_id, kpi_id=b.kpi_id, kpi_name=kpi.kpi_name or kpi.kpi_code, period=b.period, budget_value=b.budget_value, reason="未匹配任何分类规则", status="pending", )) unclassified_count += 1 continue 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() message = f"现金流联动完成: 新建{created}条, 更新{updated}条, 待分类{unclassified_count}条" if skipped_annual: message += f", 跳过年度行{skipped_annual}条" return { "message": message, "created": created, "updated": updated, "unclassified_count": unclassified_count, "skipped_annual": skipped_annual, } # ============ 预算闭环补齐(2026-10-03 施工单 CMA-20261003 阶段 3:最小可用骨架) ============ # 设计件 docs/budget-module-governance-design.md §4:控制 / 调整 / 下达 / 考核 # 零 DDL:全部基于既有 budget_plans / kpi_values / operation_logs。 DEFAULT_CONTROL_THRESHOLD_PCT = 20.0 def _budget_control_threshold(db) -> float: """控制阈值(%):system_configs.budget_control_threshold,缺省 20%。""" from app.models import SystemConfig import json as _json cfg = db.query(SystemConfig).filter( SystemConfig.config_key == "budget_control_threshold" ).first() if cfg: try: return float(_json.loads(cfg.config_value)) except (ValueError, TypeError): return DEFAULT_CONTROL_THRESHOLD_PCT return DEFAULT_CONTROL_THRESHOLD_PCT def _effective_rows_for_periods(db, entity_id: int, periods): """期间集合内的生效预算行(同键取生效行,防同 KPI 多版本重复计算)。""" rows = db.query(BudgetPlan).filter( BudgetPlan.period.in_(periods), effective_plan_clause(entity_id), ).all() grouped = {} for r in rows: grouped.setdefault((r.kpi_id, r.period), []).append(r) out = [] for key, rows_of_key in grouped.items(): row = pick_effective(rows_of_key) if row is not None: out.append((key[0], key[1], row)) return out def _direction_of(kpi, higher_better_codes) -> str: return "higher" if (kpi.kpi_code in higher_better_codes or kpi.kpi_name in higher_better_codes) else "lower" @router.get("/control/status") def get_budget_control_status( year: Optional[int] = Query(None), period: Optional[str] = Query(None), db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id), ): """预算控制状态(缺失环「控制」最小骨架,只读):生效预算 vs 实际 → ok/warn/over。""" from app.utils.deviation_engine import get_higher_better_codes y = year or datetime.now().year periods = [period] if period else [f"{y}-{m:02d}" for m in range(1, 13)] threshold = _budget_control_threshold(db) eff = _effective_rows_for_periods(db, entity_id, periods) kpi_ids = {kid for kid, _p, _r in eff} kpis = {k.id: k for k in db.query(KPIDefinition).filter(KPIDefinition.id.in_(kpi_ids)).all()} if kpi_ids else {} actuals = {} if kpi_ids: for v in db.query(KPIValue).filter( KPIValue.kpi_id.in_(kpi_ids), KPIValue.period.in_(periods), KPIValue.actual_value.isnot(None), actual_only_clause(), ).all(): actuals[(v.kpi_id, v.period)] = v.actual_value higher_better = set(get_higher_better_codes(db, entity_id)) items = [] summary = {"total": 0, "ok": 0, "warn": 0, "over": 0, "no_actual": 0} for kid, per, plan in sorted(eff, key=lambda x: (x[0], x[1])): kpi = kpis.get(kid) if kpi is None: continue budget = plan.budget_value or 0 actual = actuals.get((kid, per)) direction = _direction_of(kpi, higher_better) level, rate, deviation_rate = "no_actual", None, None if actual is not None and budget: rate = round(actual / budget * 100, 2) deviation_rate = round((actual - budget) / abs(budget) * 100, 2) if direction == "higher": level = "over" if deviation_rate <= -threshold else ("warn" if deviation_rate <= -threshold * 0.8 else "ok") else: level = "over" if deviation_rate >= threshold else ("warn" if deviation_rate >= threshold * 0.8 else "ok") summary["total"] += 1 summary[level] = summary.get(level, 0) + 1 items.append({ "kpi_id": kid, "kpi_code": kpi.kpi_code, "kpi_name": kpi.kpi_name, "unit": kpi.unit or "", "period": per, "basis_version": plan.version, "basis_status": plan.status, "direction": direction, "budget_value": budget, "actual_value": actual, "execution_rate": rate, "deviation_rate": deviation_rate, "control_level": level, }) return {"year": y, "period": period, "threshold_pct": threshold, "summary": summary, "items": items} @router.get("/assessment") def get_budget_assessment( year: Optional[int] = Query(None), db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id), ): """预算考核(缺失环「考核」最小骨架,只读):生效预算 vs 实际 → 达成率 + 等级 A/B/C/D。""" from app.utils.deviation_engine import get_higher_better_codes y = year or datetime.now().year periods = [f"{y}-{m:02d}" for m in range(1, 13)] eff = _effective_rows_for_periods(db, entity_id, periods) kpi_ids = {kid for kid, _p, _r in eff} kpis = {k.id: k for k in db.query(KPIDefinition).filter(KPIDefinition.id.in_(kpi_ids)).all()} if kpi_ids else {} actuals = {} if kpi_ids: for v in db.query(KPIValue).filter( KPIValue.kpi_id.in_(kpi_ids), KPIValue.period.in_(periods), KPIValue.actual_value.isnot(None), actual_only_clause(), ).all(): actuals[(v.kpi_id, v.period)] = v.actual_value higher_better = set(get_higher_better_codes(db, entity_id)) agg = {} for kid, per, plan in eff: row = agg.setdefault(kid, {"budget_total": 0.0, "actual_total": 0.0, "periods": set(), "basis_version": plan.version, "basis_status": plan.status, "_ts": plan.updated_at}) row["budget_total"] += plan.budget_value or 0 row["periods"].add(per) if plan.updated_at and (row["_ts"] is None or plan.updated_at > row["_ts"]): row["_ts"] = plan.updated_at row["basis_version"] = plan.version row["basis_status"] = plan.status if (kid, per) in actuals: row["actual_total"] += actuals[(kid, per)] or 0 items = [] summary = {"kpi_count": 0, "a": 0, "b": 0, "c": 0, "d": 0, "na": 0, "total_budget": 0.0, "total_actual": 0.0} for kid, row in sorted(agg.items()): kpi = kpis.get(kid) if kpi is None: continue direction = _direction_of(kpi, higher_better) bt, at = round(row["budget_total"], 2), round(row["actual_total"], 2) achieve, grade = None, "NA" if bt and row["periods"]: achieve = round((at / bt * 100) if direction == "higher" else (bt / at * 100 if at else 0.0), 2) grade = "A" if achieve >= 100 else ("B" if achieve >= 90 else ("C" if achieve >= 80 else "D")) summary["kpi_count"] += 1 summary[grade.lower()] = summary.get(grade.lower(), 0) + 1 summary["total_budget"] += bt summary["total_actual"] += at items.append({ "kpi_id": kid, "kpi_code": kpi.kpi_code, "kpi_name": kpi.kpi_name, "unit": kpi.unit or "", "direction": direction, "basis_version": row["basis_version"], "basis_status": row["basis_status"], "period_count": len(row["periods"]), "budget_total": bt, "actual_total": at, "achieve_rate": achieve, "grade": grade, }) summary["total_budget"] = round(summary["total_budget"], 2) summary["total_actual"] = round(summary["total_actual"], 2) summary["achieve_rate"] = round(summary["total_actual"] / summary["total_budget"] * 100, 2) if summary["total_budget"] else None return {"year": y, "summary": summary, "items": items} @router.post("/versions/release") def release_budget_version( data: dict, db: Session = Depends(get_db), current_user=Depends(require_auth), entity_id: int = Depends(get_entity_id), ): """预算下达(缺失环「下达」):已批准版本 → 已下达(active)。fail-closed + 幂等。""" version = (data.get("version") or "").strip() if not version: raise HTTPException(400, "需要 version") rows = db.query(BudgetPlan).filter( BudgetPlan.entity_id == entity_id, BudgetPlan.version == version, ).all() if not rows: raise HTTPException(404, f"版本 {version} 不存在") approved = [r for r in rows if (r.status or "") == "approved"] if not approved: # 幂等判据 = 「本版本确有下达历史」(operation_logs 记录),而不是 active 现状: # 迁移前遗留的 active 行 = 在用/草稿态,不能当成「已下达」(设计件 §1.2/§1.3)。 released_before = any( isinstance(l.detail, dict) and l.detail.get("op") == "release" and l.detail.get("version") == version for l in db.query(OperationLog) .filter(OperationLog.target_type == "budget_version") .order_by(OperationLog.id.desc()) .limit(500) .all() ) if released_before and any((r.status or "") == "active" for r in rows): return {"success": True, "version": version, "status": "active", "count": 0, "already": True} raise HTTPException(400, f"版本 {version} 不可下达(需 approved,当前: {sorted({r.status for r in rows})})") for r in approved: r.status = "active" log_op(db, "update", target_type="budget_version", target_id=None, detail={"op": "release", "version": version, "count": len(approved), "from_status": "approved", "to_status": "active"}, entity_id=entity_id) db.commit() return {"success": True, "version": version, "status": "active", "count": len(approved), "already": False} @router.post("/adjustments") def create_budget_adjustment( data: dict, db: Session = Depends(get_db), current_user=Depends(require_auth), entity_id: int = Depends(get_entity_id), ): """预算调整(缺失环「调整」最小骨架):基于生效基线版本派生新版本(draft),原版本零改动。""" base_version = (data.get("base_version") or "").strip() new_version = (data.get("new_version") or "").strip() reason = (data.get("reason") or "").strip() items = data.get("items") or [] if not base_version or not new_version: raise HTTPException(400, "需要 base_version 与 new_version") if new_version == base_version: raise HTTPException(400, "new_version 不能与 base_version 相同") if not reason: raise HTTPException(400, "调整单必须填写 reason(可追溯)") if db.query(BudgetPlan).filter( BudgetPlan.entity_id == entity_id, BudgetPlan.version == new_version ).first(): raise HTTPException(400, f"版本 {new_version} 已存在(调整单只允许派生新版本,不覆盖)") base_rows = db.query(BudgetPlan).filter( BudgetPlan.entity_id == entity_id, BudgetPlan.version == base_version, BudgetPlan.status.in_(EFFECTIVE_STATUSES), ).all() if not base_rows: raise HTTPException(404, f"基线版本 {base_version} 不存在或非生效版") overrides = {} for it in items: kid, per = it.get("kpi_id"), it.get("period") if kid is None or not per or it.get("budget_value") is None: raise HTTPException(400, "items 每项需 kpi_id / period / budget_value") overrides[(int(kid), per)] = float(it["budget_value"]) operator = current_user.name if hasattr(current_user, "name") else "" applied, unmatched = 0, len(overrides) for r in base_rows: key = (r.kpi_id, r.period) if key in overrides: val = overrides.pop(key) applied += 1 unmatched -= 1 else: val = r.budget_value db.add(BudgetPlan( entity_id=entity_id, map_id=r.map_id, kpi_id=r.kpi_id, period=r.period, budget_value=val, budget_year=r.budget_year, budget_month=r.budget_month, version=new_version, status="draft", source_type=r.source_type or "manual", calc_logic=r.calc_logic, remark=f"调整单:{reason} 基线:{base_version}"[:500], created_by=operator, )) log_op(db, "create", target_type="budget_adjustment", target_id=None, detail={"base_version": base_version, "new_version": new_version, "reason": reason, "copied": len(base_rows), "applied_overrides": applied, "unmatched_overrides": unmatched}, entity_id=entity_id) db.commit() return {"success": True, "new_version": new_version, "base_version": base_version, "reason": reason, "created": len(base_rows), "applied_overrides": applied, "unmatched_overrides": unmatched, "status": "draft", "note": "调整版本已生成为 draft,需提交审批 → 批准 → 下达后方可作为执行基准"}