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
+253
View File
@@ -0,0 +1,253 @@
"""现金流分类规则 API — 管理会计OS (P2-⑥ 2026-08-28)
分类规则管理(cash_plan_classify_rules) + 待分类队列(cash_plan_unclassified) + 一键归类。
sync-cash-plans 未命中的KPI进入待分类队列,人工一键归类 → 自动补建规则+生成CashPlan。
"""
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 (
CashPlanClassifyRule, CashPlanUnclassified, CashPlan,
KPIDefinition, BudgetPlan,
)
router = APIRouter(
prefix="/api/cma/budget",
tags=["现金流分类"],
dependencies=[Depends(require_role("ceo", "finance", "it"))],
)
# ── 分类规则 CRUD ──────────────────────────────
@router.get("/cash-classify-rules")
def list_classify_rules(
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""分类规则列表(按 entity_id 隔离)"""
rows = db.query(CashPlanClassifyRule).filter(
CashPlanClassifyRule.entity_id == entity_id
).order_by(CashPlanClassifyRule.priority.asc(), CashPlanClassifyRule.id.desc()).all()
kpi_ids = {r.kpi_id for r in rows if r.kpi_id}
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) if r.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 "",
"kpi_code_pattern": r.kpi_code_pattern,
"plan_type": r.plan_type,
"priority": r.priority,
"status": r.status,
"created_at": r.created_at.isoformat() if r.created_at else None,
})
return {"data": result, "total": len(result)}
@router.post("/cash-classify-rules")
def create_classify_rule(
data: dict,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
current_user=Depends(require_auth),
):
"""新建分类规则(kpi_id 精确 或 kpi_code_pattern 关键词 二选一)"""
plan_type = data.get("plan_type")
if plan_type not in ("receive", "pay"):
raise HTTPException(400, "plan_type 必须是 receive/pay")
kpi_id = data.get("kpi_id")
pattern = data.get("kpi_code_pattern")
if not kpi_id and not pattern:
raise HTTPException(400, "需要 kpi_id 或 kpi_code_pattern 至少一个")
row = CashPlanClassifyRule(
entity_id=entity_id,
kpi_id=kpi_id,
kpi_code_pattern=pattern,
plan_type=plan_type,
priority=data.get("priority", 10),
status=data.get("status", "active"),
)
db.add(row)
db.commit()
db.refresh(row)
return {"message": "分类规则已创建", "id": row.id}
@router.put("/cash-classify-rules/{rule_id}")
def update_classify_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(CashPlanClassifyRule).filter(
CashPlanClassifyRule.id == rule_id,
CashPlanClassifyRule.entity_id == entity_id,
).first()
if not row:
raise HTTPException(404, "规则不存在")
for field in ("kpi_id", "kpi_code_pattern", "plan_type", "priority", "status"):
if field in data:
setattr(row, field, data[field])
db.commit()
return {"message": "分类规则已更新", "id": row.id}
@router.delete("/cash-classify-rules/{rule_id}")
def delete_classify_rule(
rule_id: int,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
current_user=Depends(require_auth),
):
"""删除分类规则"""
row = db.query(CashPlanClassifyRule).filter(
CashPlanClassifyRule.id == rule_id,
CashPlanClassifyRule.entity_id == entity_id,
).first()
if not row:
raise HTTPException(404, "规则不存在")
db.delete(row)
db.commit()
return {"message": "分类规则已删除"}
# ── 待分类队列 ──────────────────────────────
@router.get("/cash-unclassified")
def list_unclassified(
status: Optional[str] = Query(None, description="pending/classified/ignored"),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""待分类KPI队列"""
query = db.query(CashPlanUnclassified).filter(CashPlanUnclassified.entity_id == entity_id)
if status:
query = query.filter(CashPlanUnclassified.status == status)
rows = query.order_by(CashPlanUnclassified.created_at.desc()).all()
result = []
for r in rows:
result.append({
"id": r.id,
"kpi_id": r.kpi_id,
"kpi_name": r.kpi_name,
"period": r.period,
"budget_value": r.budget_value,
"reason": r.reason,
"status": r.status,
"created_at": r.created_at.isoformat() if r.created_at else None,
"resolved_at": r.resolved_at.isoformat() if r.resolved_at else None,
})
return {"data": result, "total": len(result)}
@router.post("/cash-unclassified/{item_id}/classify")
def classify_unclassified(
item_id: int,
data: dict,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
current_user=Depends(require_auth),
):
"""一键归类: body {plan_type: receive/pay}
① 自动补建分类规则 ② 标记队列 classified ③ 联动生成对应 CashPlan
"""
plan_type = data.get("plan_type")
if plan_type not in ("receive", "pay"):
raise HTTPException(400, "plan_type 必须是 receive/pay")
item = db.query(CashPlanUnclassified).filter(
CashPlanUnclassified.id == item_id,
CashPlanUnclassified.entity_id == entity_id,
CashPlanUnclassified.status == "pending",
).first()
if not item:
raise HTTPException(404, "待分类记录不存在或已处理")
# ① 自动补建规则(无精确KPI规则时)
existing_rule = db.query(CashPlanClassifyRule).filter(
CashPlanClassifyRule.entity_id == entity_id,
CashPlanClassifyRule.kpi_id == item.kpi_id,
).first()
if not existing_rule:
db.add(CashPlanClassifyRule(
entity_id=entity_id,
kpi_id=item.kpi_id,
kpi_code_pattern=None,
plan_type=plan_type,
priority=10,
status="active",
))
# ② 标记队列
item.status = "classified"
item.resolved_at = datetime.now()
# ③ 联动生成 CashPlan(有期间和预算值时)
plan_created = False
if item.period and item.budget_value is not None:
try:
year, month = int(item.period.split("-")[0]), int(item.period.split("-")[1])
plan_date = datetime(year, month, 1)
except Exception:
plan_date = None
if plan_date:
existing_plan = db.query(CashPlan).filter(
CashPlan.entity_id == entity_id,
CashPlan.related_kpi_id == item.kpi_id,
CashPlan.plan_type == plan_type,
CashPlan.plan_date == plan_date,
).first()
if not existing_plan:
db.add(CashPlan(
entity_id=entity_id,
plan_type=plan_type,
related_kpi_id=item.kpi_id,
amount=item.budget_value,
plan_date=plan_date,
description=f"待分类队列归类: {item.kpi_name or ''}",
status="pending",
source="budget_sync",
))
plan_created = True
db.commit()
return {
"message": f"已归类为 {plan_type}" + (" 并生成现金流计划" if plan_created else ""),
"plan_type": plan_type,
"rule_created": not existing_rule,
"plan_created": plan_created,
}
@router.post("/cash-unclassified/{item_id}/ignore")
def ignore_unclassified(
item_id: int,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
current_user=Depends(require_auth),
):
"""忽略该KPI(不生成规则)"""
item = db.query(CashPlanUnclassified).filter(
CashPlanUnclassified.id == item_id,
CashPlanUnclassified.entity_id == entity_id,
).first()
if not item:
raise HTTPException(404, "待分类记录不存在")
item.status = "ignored"
item.resolved_at = datetime.now()
db.commit()
return {"message": "已忽略"}