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
218 lines
7.2 KiB
Python
218 lines
7.2 KiB
Python
"""零基预算逐项论证 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,
|
||
}
|