Files
cma-management/backend/app/api/budget_generate.py
T
wecom-fullstack 467472311d feat(cma): audit write-side entity closure + entity-dimension data dictionary
task dama-entity-isolation-sweep-20260923 (sections 2-2 4/5, merged by projectBot 2026-09-23 18:2x)

4) operation_logs write side: every audit write carries the account (no DDL, no column added)

   - new app/utils/oplog.py::log_op() single wrapper: sets detail.entity_id; scope=global for

     by-design non-entity writes (platform dict / role-level); scope=batch + OPLOG_BATCH_NO_ENTITY

     sentinel for cross-account batches; no context -> OPLOG_NO_ENTITY sentinel (never guess)

   - 16 remaining construction points converted (data/alert_rules/bi_reports/kpi_causality/

     bot_bridge x2/budget_generate/driver_budget/kpis _log x3/templates _log x2/erp_sync);

     pre-4 state had 20 sites without entity_id, now 0 outside the wrapper

   - read side untouched (knowledge_service still user_id fallback); history not backfilled

5) docs/entity-dimension-data-dictionary.md: 8-table three-state registry

   (kpi_templates/driver_factor_templates/scenario_suggestions/bot_bridge_config = non-entity;

    kpi_subject_map = inherited via kpi_id; analysis_results/subjects = account dimension,

    legacy rows still NULL -> other task; operation_logs = non-entity column + detail.entity_id)

Evidence: RED 2 failed/7 passed (reverted call sites) -> GREEN full 1428 passed; 9 new tests

including two read-back rows; app md5 restore verified identical; no cross-account writes

LOCAL commit only - push is gated (ahead contains the P1 entity-dim batch commit fb4609b)
2026-09-23 23:08:26 +08:00

310 lines
11 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.
"""预算自动从KPI推算 API — P1-2
根据KPI的目标值自动生成预算建议。
"""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
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 KPIDefinition, BudgetPlan, KPIValue, OperationLog
from app.utils.oplog import log_op, SCOPE_BATCH
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),
entity_id: int = Depends(get_entity_id),
):
"""获取本账套可用于生成预算的KPI列表,按类型分类(账套隔离 2026-09-21)"""
if not year:
year = datetime.now().year
kpis = db.query(KPIDefinition).filter(
KPIDefinition.status == "active",
KPIDefinition.entity_id == entity_id,
).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
_kpi_entities = set() # 2026-09-23 ④:审计带账套(取被处理 KPI 自身的账套,不猜会话账套)
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
_kpi_entities.add(getattr(kpi, "entity_id", None))
# 检查是否已有记录
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,
})
# 操作日志(2026-09-23 ④:detail 带账套;跨账套批量 ⇒ 留空 + 哨兵,不猜账套)
_entities = sorted(x for x in _kpi_entities if x is not None)
log_op(db, "kpi_generate_budget", target_type="budget",
user_id=getattr(current_user, "id", None),
detail={
"year": year,
"month": month,
"version": version,
"item_count": len(results),
"total_amount": total_amount,
"kpi_entity_ids": _entities,
},
entity_id=(_entities[0] if len(_entities) == 1 else None),
scope=(SCOPE_BATCH if len(_entities) > 1 else None))
db.commit()
return {
"message": f"已从{len(results)}个KPI生成预算,合计¥{total_amount:,.2f}",
"total_amount": total_amount,
"items": results,
}