Files
cma-management/backend/app/api/zero_based.py
T
Hermes CI Fix 94aeb14e95 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
2026-08-28 18:03:47 +08:00

218 lines
7.2 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.
"""零基预算逐项论证 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_plansversion='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,
}