feat: 预算系统6项技术改进(告警归因/实际值自动归集/真零基/派生规则/告警路径统一/现金流分类)

P1-③ 告警归因: budget_deviation_alerts+alert_type/attribution/scenario_id, 归因引擎alert_attribution.py(子KPI/科目/量价差/趋势), deviation-check统一写归因+场景, GET /deviation-alerts/{id}/attribution详情(旧告警现场组装)
P1-④ 实际值自动归集: kpi_value_sources/kpi_value_collect_logs表+CRUD+试跑+覆盖率, 采集器kpi_value_collector.py(voucher_details/进销存/cash_plans按entity+period汇总, 幂等upsert不覆盖人工), crontab每日06:30
P2-① 真零基: budget_zero_based_items逐项论证表+generate, method-comparison有论证项逐项求和is_demo=false否则fallback
P2-② 派生规则: budget_derivation_rules配置表, apply-method优先读规则rule_source=configured
P2-⑤ 告警双路径合并: deviation_engine.build_deviation_alert统一函数, 方向列表配置化kpi_alert_higher_better+alert-direction接口
P2-⑥ 现金流分类: cash_plan_classify_rules规则表+cash_plan_unclassified待分类队列, sync-cash-plans未命中进队列不静默跳过
新增: GET /kpis/{kpi_id}/values + 前端kpiApi.values(归集标签页数据源), scenario_suggestions幂等seed(init_db)
测试: test_budget_tech_improve.py 15用例, 预算相关96 passed, 全量646 passed
This commit is contained in:
Hermes CI Fix
2026-08-28 18:03:47 +08:00
parent 3bc68fa1c6
commit 94aeb14e95
16 changed files with 3164 additions and 118 deletions
+141
View File
@@ -0,0 +1,141 @@
"""KPI派生规则 API — 管理会计OS (P2-② 2026-08-28)
派生规则配置(budget_derivation_rules)apply-method 派生KPI时优先读规则,
percentage_of → base_kpi实际值×rateincremental → 上月×(1+rate);无规则fallback默认比例。
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from typing import Optional
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 BudgetDerivationRule, KPIDefinition
router = APIRouter(
prefix="/api/cma/budget",
tags=["派生规则"],
dependencies=[Depends(require_role("ceo", "finance", "it"))],
)
@router.get("/derivation-rules")
def list_derivation_rules(
kpi_id: Optional[int] = Query(None),
status: Optional[str] = Query(None),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""派生规则列表(按 entity_id 隔离)"""
query = db.query(BudgetDerivationRule).filter(BudgetDerivationRule.entity_id == entity_id)
if kpi_id:
query = query.filter(BudgetDerivationRule.kpi_id == kpi_id)
if status:
query = query.filter(BudgetDerivationRule.status == status)
rows = query.order_by(BudgetDerivationRule.id.desc()).all()
all_kpi_ids = {r.kpi_id for r in rows} | {r.base_kpi_id for r in rows if r.base_kpi_id}
kpis = {k.id: k for k in db.query(KPIDefinition).filter(KPIDefinition.id.in_(all_kpi_ids)).all()} if all_kpi_ids else {}
result = []
for r in rows:
kpi = kpis.get(r.kpi_id)
base = kpis.get(r.base_kpi_id) if r.base_kpi_id else None
result.append({
"id": r.id,
"kpi_id": r.kpi_id,
"kpi_code": kpi.kpi_code if kpi else "",
"kpi_name": kpi.kpi_name if kpi else "",
"rule_type": r.rule_type,
"base_kpi_id": r.base_kpi_id,
"base_kpi_code": base.kpi_code if base else "",
"base_kpi_name": base.kpi_name if base else "",
"params": r.params,
"formula_text": r.formula_text,
"status": r.status,
"created_at": r.created_at.isoformat() if r.created_at else None,
})
return {"data": result, "total": len(result)}
@router.post("/derivation-rules")
def create_derivation_rule(
data: dict,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
current_user=Depends(require_auth),
):
"""新建派生规则(同KPI同类型唯一)"""
kpi_id = data.get("kpi_id")
rule_type = data.get("rule_type")
if not kpi_id or rule_type not in ("incremental", "percentage_of", "formula"):
raise HTTPException(400, "需要 kpi_id 且 rule_type ∈ incremental/percentage_of/formula")
kpi = db.query(KPIDefinition).filter(
KPIDefinition.id == kpi_id,
KPIDefinition.entity_id == entity_id,
).first()
if not kpi:
raise HTTPException(404, "KPI不存在")
existing = db.query(BudgetDerivationRule).filter(
BudgetDerivationRule.entity_id == entity_id,
BudgetDerivationRule.kpi_id == kpi_id,
BudgetDerivationRule.rule_type == rule_type,
).first()
if existing:
raise HTTPException(400, f"KPI({kpi_id})已存在 {rule_type} 规则")
row = BudgetDerivationRule(
entity_id=entity_id,
kpi_id=kpi_id,
rule_type=rule_type,
base_kpi_id=data.get("base_kpi_id"),
params=data.get("params"),
formula_text=data.get("formula_text"),
status=data.get("status", "active"),
)
db.add(row)
db.commit()
db.refresh(row)
return {"message": "派生规则已创建", "id": row.id}
@router.put("/derivation-rules/{rule_id}")
def update_derivation_rule(
rule_id: int,
data: dict,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
current_user=Depends(require_auth),
):
"""更新派生规则"""
row = db.query(BudgetDerivationRule).filter(
BudgetDerivationRule.id == rule_id,
BudgetDerivationRule.entity_id == entity_id,
).first()
if not row:
raise HTTPException(404, "规则不存在")
for field in ("rule_type", "base_kpi_id", "params", "formula_text", "status"):
if field in data:
setattr(row, field, data[field])
db.commit()
return {"message": "派生规则已更新", "id": row.id}
@router.delete("/derivation-rules/{rule_id}")
def delete_derivation_rule(
rule_id: int,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
current_user=Depends(require_auth),
):
"""删除派生规则"""
row = db.query(BudgetDerivationRule).filter(
BudgetDerivationRule.id == rule_id,
BudgetDerivationRule.entity_id == entity_id,
).first()
if not row:
raise HTTPException(404, "规则不存在")
db.delete(row)
db.commit()
return {"message": "派生规则已删除"}