feat: P0/P1/P2全部功能 — 四层泳道/视角切换/KPI看板/预警/差异反打/预算/知识面板/回顾会/情景预测/Excel导入/角色权限

This commit is contained in:
Hermes CI Fix
2026-07-12 17:46:08 +08:00
parent cdf00efd69
commit ee25d5fa1d
8871 changed files with 1778433 additions and 0 deletions
+301
View File
@@ -0,0 +1,301 @@
"""预算自动从KPI推算 API — P1-2
根据KPI的目标值自动生成预算建议。
"""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.database import get_db
from app.auth_middleware import require_auth, require_role
from app.models import KPIDefinition, BudgetPlan, KPIValue, OperationLog
import json
import logging
from datetime import datetime
logger = logging.getLogger("cma.budget_gen")
router = APIRouter(prefix="/api/cma/budget", tags=["KPI→预算"],
dependencies=[Depends(require_role("ceo", "finance"))],
)
def _calc_budget(kpi: KPIDefinition) -> dict:
"""根据KPI类型推算预算
算法:
- 降本类: (当前值-目标值)×0.3
- 增收类: 目标增收额×0.2
- 能力类: 人均培训成本×人数
- 系统类: 按模块开发费估算
"""
category = kpi.category or ""
target = kpi.target_value or 0
result = {
"suggested_budget": 0,
"calc_logic": "",
"calc_type": "未知",
}
# 降本类: cost_control, cash_risk
if category in ("cost_control", "cash_risk", "asset_efficiency"):
result["calc_type"] = "降本类"
# 当前值需要从最新的KPIValue获取
# 这里返回算法描述,前端传入当前值
result["calc_type_desc"] = "(当前值-目标值)×0.3"
result["suggested_budget"] = 0 # 需要前端传当前值
# 增收类: revenue_growth, profitability
elif category in ("revenue_growth", "profitability", "customer_scale"):
result["calc_type"] = "增收类"
result["calc_type_desc"] = "目标增收额×0.2"
result["suggested_budget"] = round(target * 0.2, 2)
# 能力类: talent_pipeline, employee_engagement, innovation
elif category in ("talent_pipeline", "employee_engagement", "innovation"):
result["calc_type"] = "能力类"
result["calc_type_desc"] = "人均培训成本×人数"
result["suggested_budget"] = 0 # 需要外部参数
# 系统类: 默认为系统类
elif category in ("supply_chain", "delivery_quality", "customer_concentration", "customer_satisfaction"):
result["calc_type"] = "系统类"
result["calc_type_desc"] = "按功能模块开发费估算"
result["suggested_budget"] = round(target * 0.15, 2)
# 其他未分类
else:
result["calc_type"] = "系统类"
result["calc_type_desc"] = "按功能模块开发费估算"
result["suggested_budget"] = round(target * 0.15, 2)
return result
@router.get("/kpi-budget-candidates")
def get_kpi_budget_candidates(
year: int = None,
db: Session = Depends(get_db),
):
"""获取可用于生成预算的KPI列表,按类型分类"""
if not year:
year = datetime.now().year
kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").all()
# 获取每个KPI的最新实际值
latest_values = {}
for kpi in kpis:
v = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi.id
).order_by(KPIValue.calculated_at.desc()).first()
if v:
latest_values[kpi.id] = v.actual_value
# 分类
categorized = {
"cost_reduction": [], # 降本类
"revenue_growth": [], # 增收类
"capability": [], # 能力类
"system": [], # 系统类
}
for kpi in kpis:
calc_info = _calc_budget(kpi)
current_val = latest_values.get(kpi.id)
# 降本类: 需要当前值
if calc_info["calc_type"] == "降本类":
if current_val is not None and kpi.target_value:
diff = current_val - kpi.target_value
suggested = round(max(diff, 0) * 0.3, 2)
calc_logic = f"当前值{current_val}-目标值{kpi.target_value}={diff:.2f},×0.3={suggested:.2f}"
else:
suggested = 0
calc_logic = "缺少当前值或目标值,无法计算"
item = {
"id": kpi.id,
"kpi_code": kpi.kpi_code,
"kpi_name": kpi.kpi_name,
"dimension": kpi.dimension,
"category": kpi.category,
"calc_type": "降本类",
"target_value": kpi.target_value,
"current_value": current_val,
"suggested_budget": suggested,
"calc_logic": calc_logic,
}
categorized["cost_reduction"].append(item)
elif calc_info["calc_type"] == "增收类":
suggested = round((kpi.target_value or 0) * 0.2, 2)
calc_logic = f"目标增收额{kpi.target_value}×0.2={suggested:.2f}"
item = {
"id": kpi.id,
"kpi_code": kpi.kpi_code,
"kpi_name": kpi.kpi_name,
"dimension": kpi.dimension,
"category": kpi.category,
"calc_type": "增收类",
"target_value": kpi.target_value,
"current_value": current_val,
"suggested_budget": suggested,
"calc_logic": calc_logic,
}
categorized["revenue_growth"].append(item)
elif calc_info["calc_type"] == "能力类":
# 假设人均培训成本2000元, 默认10人
suggested = round(2000 * 10, 2)
calc_logic = f"人均培训成本2000元×10人={suggested:.2f}(可调整人数和单价)"
item = {
"id": kpi.id,
"kpi_code": kpi.kpi_code,
"kpi_name": kpi.kpi_name,
"dimension": kpi.dimension,
"category": kpi.category,
"calc_type": "能力类",
"target_value": kpi.target_value,
"current_value": current_val,
"suggested_budget": suggested,
"calc_logic": calc_logic,
"per_head_cost": 2000,
"head_count": 10,
}
categorized["capability"].append(item)
else: # 系统类
suggested = round((kpi.target_value or 0) * 0.15, 2)
if suggested <= 0:
suggested = 30000 # 默认3万
calc_logic = "按模块开发费估算: 默认30000元(可调整)"
else:
calc_logic = f"目标值{kpi.target_value}×0.15={suggested:.2f}"
item = {
"id": kpi.id,
"kpi_code": kpi.kpi_code,
"kpi_name": kpi.kpi_name,
"dimension": kpi.dimension,
"category": kpi.category,
"calc_type": "系统类",
"target_value": kpi.target_value,
"current_value": current_val,
"suggested_budget": suggested,
"calc_logic": calc_logic,
}
categorized["system"].append(item)
return {"data": categorized}
@router.post("/generate-from-kpis")
def generate_budget_from_kpis(
data: dict,
db: Session = Depends(get_db),
current_user=Depends(require_auth),
):
"""从选中的KPI生成预算科目
Body: {
year: int,
month: int,
version: string,
items: [
{
kpi_id: int,
budget_amount: float, // 用户可编辑
calc_logic: string,
calc_type: string,
}
]
}
"""
year = data.get("year", datetime.now().year)
month = data.get("month", datetime.now().month + 1)
version = data.get("version", "v1.0")
items = data.get("items", [])
if not items:
raise HTTPException(400, "请至少选择一个KPI")
period = f"{year}-{month:02d}"
results = []
total_amount = 0
for item in items:
kpi_id = item.get("kpi_id")
budget_amount = item.get("budget_amount")
calc_logic = item.get("calc_logic", "")
calc_type = item.get("calc_type", "")
if not kpi_id or budget_amount is None:
continue
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
if not kpi:
continue
# 检查是否已有记录
existing = db.query(BudgetPlan).filter(
BudgetPlan.kpi_id == kpi_id,
BudgetPlan.period == period,
BudgetPlan.version == version,
BudgetPlan.status == "active",
).first()
if existing:
existing.budget_value = budget_amount
existing.source_type = "kpi_generated"
existing.source_kpi_id = kpi_id
existing.calc_logic = calc_logic
existing.remark = f"KPI推算({calc_type}): {calc_logic}"
plan_id = existing.id
else:
plan = BudgetPlan(
kpi_id=kpi_id,
period=period,
budget_value=budget_amount,
budget_year=year,
budget_month=month,
version=version,
status="active",
source_type="kpi_generated",
source_kpi_id=kpi_id,
calc_logic=calc_logic,
remark=f"KPI推算({calc_type}): {calc_logic}",
created_by=current_user.name if hasattr(current_user, "name") else "",
)
db.add(plan)
db.flush()
plan_id = plan.id
total_amount += budget_amount
results.append({
"kpi_id": kpi_id,
"kpi_code": kpi.kpi_code,
"kpi_name": kpi.kpi_name,
"budget_amount": budget_amount,
"calc_logic": calc_logic,
"plan_id": plan_id,
})
# 操作日志
log = OperationLog(
user_id=getattr(current_user, "id", None),
action="kpi_generate_budget",
target_type="budget",
detail=json.dumps({
"year": year,
"month": month,
"version": version,
"item_count": len(results),
"total_amount": total_amount,
}, ensure_ascii=False),
)
db.add(log)
db.commit()
return {
"message": f"已从{len(results)}个KPI生成预算,合计¥{total_amount:,.2f}",
"total_amount": total_amount,
"items": results,
}