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:
@@ -0,0 +1,141 @@
|
||||
"""KPI派生规则 API — 管理会计OS (P2-② 2026-08-28)
|
||||
|
||||
派生规则配置(budget_derivation_rules):apply-method 派生KPI时优先读规则,
|
||||
percentage_of → base_kpi实际值×rate;incremental → 上月×(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": "派生规则已删除"}
|
||||
Reference in New Issue
Block a user