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,217 @@
|
||||
"""零基预算逐项论证 API — 管理会计OS (P2-① 2026-08-28)
|
||||
|
||||
CRUD 逐项论证项(budget_zero_based_items) + generate 生成零基预算写 budget_plans。
|
||||
method-comparison 的 zero_based 分支优先读论证项(有数据→逐项求和 is_demo=false)。
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
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 BudgetZeroBasedItem, KPIDefinition, BudgetPlan
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/cma/budget",
|
||||
tags=["零基预算"],
|
||||
dependencies=[Depends(require_role("ceo", "finance", "it"))],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/zero-based/items")
|
||||
def list_zero_based_items(
|
||||
kpi_id: Optional[int] = Query(None),
|
||||
period: Optional[str] = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""零基逐项论证列表(kpi_id+period 过滤)"""
|
||||
query = db.query(BudgetZeroBasedItem).filter(BudgetZeroBasedItem.entity_id == entity_id)
|
||||
if kpi_id:
|
||||
query = query.filter(BudgetZeroBasedItem.kpi_id == kpi_id)
|
||||
if period:
|
||||
query = query.filter(BudgetZeroBasedItem.period == period)
|
||||
rows = query.order_by(BudgetZeroBasedItem.id.desc()).all()
|
||||
|
||||
kpi_ids = {r.kpi_id for r in rows}
|
||||
kpis = {k.id: k for k in db.query(KPIDefinition).filter(KPIDefinition.id.in_(kpi_ids)).all()} if kpi_ids else {}
|
||||
result = []
|
||||
for r in rows:
|
||||
kpi = kpis.get(r.kpi_id)
|
||||
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 "",
|
||||
"period": r.period,
|
||||
"item_name": r.item_name,
|
||||
"item_category": r.item_category,
|
||||
"base_value": r.base_value,
|
||||
"justification": r.justification,
|
||||
"proposed_value": r.proposed_value,
|
||||
"status": r.status,
|
||||
"created_by": r.created_by,
|
||||
"created_at": r.created_at.isoformat() if r.created_at else None,
|
||||
})
|
||||
total_proposed = round(sum(r.proposed_value for r in rows), 2)
|
||||
return {"data": result, "total": len(result), "total_proposed": total_proposed}
|
||||
|
||||
|
||||
@router.post("/zero-based/items")
|
||||
def create_zero_based_item(
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""新建逐项论证项"""
|
||||
kpi_id = data.get("kpi_id")
|
||||
period = data.get("period")
|
||||
item_name = data.get("item_name")
|
||||
base_value = data.get("base_value")
|
||||
proposed_value = data.get("proposed_value")
|
||||
if not kpi_id or not period or not item_name:
|
||||
raise HTTPException(400, "缺少必要参数: kpi_id, period, item_name")
|
||||
if base_value is None:
|
||||
base_value = 0
|
||||
if proposed_value is None:
|
||||
proposed_value = 0
|
||||
|
||||
kpi = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.id == kpi_id,
|
||||
KPIDefinition.entity_id == entity_id,
|
||||
).first()
|
||||
if not kpi:
|
||||
raise HTTPException(404, "KPI不存在")
|
||||
|
||||
row = BudgetZeroBasedItem(
|
||||
entity_id=entity_id,
|
||||
kpi_id=kpi_id,
|
||||
period=period,
|
||||
item_name=item_name,
|
||||
item_category=data.get("item_category", "discretionary"),
|
||||
base_value=base_value,
|
||||
justification=data.get("justification"),
|
||||
proposed_value=proposed_value,
|
||||
status=data.get("status", "draft"),
|
||||
created_by=data.get("created_by") or (current_user.name if hasattr(current_user, "name") else None),
|
||||
)
|
||||
db.add(row)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return {"message": "论证项已创建", "id": row.id}
|
||||
|
||||
|
||||
@router.put("/zero-based/items/{item_id}")
|
||||
def update_zero_based_item(
|
||||
item_id: int,
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""更新逐项论证项"""
|
||||
row = db.query(BudgetZeroBasedItem).filter(
|
||||
BudgetZeroBasedItem.id == item_id,
|
||||
BudgetZeroBasedItem.entity_id == entity_id,
|
||||
).first()
|
||||
if not row:
|
||||
raise HTTPException(404, "论证项不存在")
|
||||
for field in ("item_name", "item_category", "base_value", "justification",
|
||||
"proposed_value", "status"):
|
||||
if field in data:
|
||||
setattr(row, field, data[field])
|
||||
db.commit()
|
||||
return {"message": "论证项已更新", "id": row.id}
|
||||
|
||||
|
||||
@router.delete("/zero-based/items/{item_id}")
|
||||
def delete_zero_based_item(
|
||||
item_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""删除逐项论证项"""
|
||||
row = db.query(BudgetZeroBasedItem).filter(
|
||||
BudgetZeroBasedItem.id == item_id,
|
||||
BudgetZeroBasedItem.entity_id == entity_id,
|
||||
).first()
|
||||
if not row:
|
||||
raise HTTPException(404, "论证项不存在")
|
||||
db.delete(row)
|
||||
db.commit()
|
||||
return {"message": "论证项已删除"}
|
||||
|
||||
|
||||
@router.post("/zero-based/generate")
|
||||
def generate_zero_based_budget(
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""生成零基预算 = Σ(proposed_value),写 budget_plans(version='zbb-YYYYMMDD',calc_logic='zero_based_itemized')"""
|
||||
kpi_id = data.get("kpi_id")
|
||||
period = data.get("period")
|
||||
year = data.get("year")
|
||||
if not kpi_id or not period:
|
||||
raise HTTPException(400, "缺少必要参数: kpi_id, period")
|
||||
|
||||
items = db.query(BudgetZeroBasedItem).filter(
|
||||
BudgetZeroBasedItem.entity_id == entity_id,
|
||||
BudgetZeroBasedItem.kpi_id == kpi_id,
|
||||
BudgetZeroBasedItem.period == period,
|
||||
).all()
|
||||
if not items:
|
||||
raise HTTPException(400, f"期间 {period} 无逐项论证项,请先录入")
|
||||
|
||||
total = round(sum(i.proposed_value for i in items), 2)
|
||||
# 解析年份
|
||||
if not year:
|
||||
try:
|
||||
year = int(period.split("-")[0])
|
||||
except Exception:
|
||||
year = datetime.now().year
|
||||
month = 0
|
||||
try:
|
||||
month = int(period.split("-")[1]) if "-" in period else 0
|
||||
except Exception:
|
||||
month = 0
|
||||
|
||||
version = f"zbb-{datetime.now().strftime('%Y%m%d')}"
|
||||
# 删除同KPI同期间同版本旧预算,防重复
|
||||
db.query(BudgetPlan).filter(
|
||||
BudgetPlan.entity_id == entity_id,
|
||||
BudgetPlan.kpi_id == kpi_id,
|
||||
BudgetPlan.period == period,
|
||||
BudgetPlan.version == version,
|
||||
).delete()
|
||||
|
||||
bp = BudgetPlan(
|
||||
entity_id=entity_id,
|
||||
kpi_id=kpi_id,
|
||||
period=period,
|
||||
budget_value=total,
|
||||
budget_year=year,
|
||||
budget_month=month,
|
||||
version=version,
|
||||
status="active",
|
||||
source_type="zero_based",
|
||||
calc_logic="zero_based_itemized",
|
||||
remark=f"零基逐项论证生成: {len(items)}项 Σ(proposed_value)={total}",
|
||||
)
|
||||
db.add(bp)
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"message": f"零基预算已生成: {total}({len(items)}项论证)",
|
||||
"kpi_id": kpi_id,
|
||||
"period": period,
|
||||
"total": total,
|
||||
"item_count": len(items),
|
||||
"version": version,
|
||||
"plan_id": bp.id,
|
||||
}
|
||||
Reference in New Issue
Block a user