Files
cma-management/backend/app/api/budget.py
T
Hermes CI Fix 1da0a74568 fix(h2-forecast): 预估区残余读路径 P2 收口(3 类 6 处)
任总 2026-10-02 11:35 直令「对外 /api/cma/** 读 KPIValue 默认只取实测区」同族残余
(ac4bbc6 范围外的 3 类 6 处)。6 处一律引 app.utils.kpi_value_scope.actual_only_clause(),
零新写字面量口径:
- customer_dashboard.py:76    年视图汇总(预估禁与实测相加)
- budget.py:260               按去年实绩加权派生基数(批量 auto-decompose)
- budget.py:333               同上(单 KPI auto-decompose)
- cash_forecast_engine.py:204 get_entity_kpi_history 历史/趋势序列
- cash_forecast_engine.py:259 get_current_cash_balance 最新值
- cash_forecast_engine.py:471 calculate_accuracy 实际值序列(预测输入)

证据:同脚本双树 A/B 15/15(改前 5/15,真库 kpi-forecast history 去掉 2026-H2、
next_target 336.32→250.99);分类计数 33/41/9/3 → 39 covered/38 period_guarded/9 write/0 unguarded;
pytest 3 failed/1529 passed,失败集与基线逐条同 ID。不 push / 不部署 / 不重启 / 不写库。
Task: project-20261002-h2-forecast-residual-reads-p2-fullstack
2026-10-02 23:41:17 +08:00

1744 lines
66 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""预算管理 API — 管理会计OS
预算值的CRUD、自动分解、版本管理
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import func, 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
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不存在")
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(
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分组
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(
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),
}
# ──────────────────────────────────────────────
# 实际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)
query = query.filter(
BudgetPlan.period.in_(periods),
BudgetPlan.status == "active",
BudgetPlan.entity_id == entity_id, # 账套隔离 2026-09-21
)
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),
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)]
# 预算值
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),
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)
budget_plans = db.query(BudgetPlan).filter(
BudgetPlan.entity_id == entity_id,
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:
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.<entity_id>`,
本账套未配置 → 走 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.<entity_id>`;
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 = "draft" if raw_status == "active" else 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"]),
"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} 不存在")
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)
(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
for b in budgets:
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()
return {
"message": f"现金流联动完成: 新建{created}条, 更新{updated}条, 待分类{unclassified_count}条",
"created": created, "updated": updated,
"unclassified_count": unclassified_count,
}