Files
cma-management/backend/app/api/value_sources.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

277 lines
9.6 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 (P1-④ 2026-08-28)
取数映射管理(kpi_value_sources) + 手动触发采集 + 采集日志 + 覆盖率统计
采集器本体: scripts/kpi_value_collector.py(系统 crontab 每日 06:30
"""
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 KPIValueSource, KPIValueCollectLog, KPIDefinition, KPIValue
router = APIRouter(
prefix="/api/cma/budget",
tags=["实际值归集"],
dependencies=[Depends(require_role("ceo", "finance", "it"))],
)
# ── 取数映射 CRUD ──────────────────────────────
@router.get("/value-sources")
def list_value_sources(
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(KPIValueSource).filter(KPIValueSource.entity_id == entity_id)
if kpi_id:
query = query.filter(KPIValueSource.kpi_id == kpi_id)
if status:
query = query.filter(KPIValueSource.status == status)
rows = query.order_by(KPIValueSource.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,
"entity_id": r.entity_id,
"kpi_id": r.kpi_id,
"kpi_code": kpi.kpi_code if kpi else "",
"kpi_name": kpi.kpi_name if kpi else "",
"source_table": r.source_table,
"source_field": r.source_field,
"aggregate": r.aggregate,
"filter_rule": r.filter_rule,
"period_field": r.period_field,
"unit_conversion": r.unit_conversion,
"status": r.status,
"created_at": r.created_at.isoformat() if r.created_at else None,
})
return {"data": result, "total": len(result)}
@router.post("/value-sources")
def create_value_source(
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")
source_table = data.get("source_table")
source_field = data.get("source_field")
if not kpi_id or not source_table or not source_field:
raise HTTPException(400, "缺少必要参数: kpi_id, source_table, source_field")
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(KPIValueSource).filter(
KPIValueSource.entity_id == entity_id,
KPIValueSource.kpi_id == kpi_id,
KPIValueSource.source_table == source_table,
).first()
if existing:
raise HTTPException(400, f"该KPI({kpi_id})已存在 {source_table} 取数映射")
row = KPIValueSource(
entity_id=entity_id,
kpi_id=kpi_id,
source_table=source_table,
source_field=source_field,
aggregate=data.get("aggregate", "sum"),
filter_rule=data.get("filter_rule"),
period_field=data.get("period_field", "period"),
unit_conversion=data.get("unit_conversion", 1),
status=data.get("status", "active"),
)
db.add(row)
db.commit()
db.refresh(row)
return {"message": "取数映射已创建", "id": row.id}
@router.put("/value-sources/{source_id}")
def update_value_source(
source_id: int,
data: dict,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
current_user=Depends(require_auth),
):
"""更新取数映射"""
row = db.query(KPIValueSource).filter(
KPIValueSource.id == source_id,
KPIValueSource.entity_id == entity_id,
).first()
if not row:
raise HTTPException(404, "映射不存在")
for field in ("source_table", "source_field", "aggregate", "filter_rule",
"period_field", "unit_conversion", "status"):
if field in data:
setattr(row, field, data[field])
db.commit()
return {"message": "映射已更新", "id": row.id}
@router.delete("/value-sources/{source_id}")
def delete_value_source(
source_id: int,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
current_user=Depends(require_auth),
):
"""删除取数映射"""
row = db.query(KPIValueSource).filter(
KPIValueSource.id == source_id,
KPIValueSource.entity_id == entity_id,
).first()
if not row:
raise HTTPException(404, "映射不存在")
db.delete(row)
db.commit()
return {"message": "映射已删除"}
@router.post("/value-sources/test")
def test_value_source(
data: dict,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""试跑单条映射返回预览值(不写库)"""
from scripts.kpi_value_collector import collect_for_mapping
mapping = KPIValueSource(
entity_id=entity_id,
kpi_id=data.get("kpi_id"),
source_table=data.get("source_table"),
source_field=data.get("source_field"),
aggregate=data.get("aggregate", "sum"),
filter_rule=data.get("filter_rule"),
period_field=data.get("period_field", "period"),
unit_conversion=data.get("unit_conversion", 1),
status="active",
)
period = data.get("period") or _default_period()
try:
value, message = collect_for_mapping(db, mapping, period, write_kpi=False)
return {"success": True, "period": period, "value": value, "message": message}
except Exception as e:
return {"success": False, "period": period, "value": None, "message": str(e)}
# ── 采集器触发 ──────────────────────────────
@router.post("/value-collect/run")
def run_value_collect(
data: dict,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
current_user=Depends(require_auth),
):
"""手动触发采集器(可选 period 参数,默认当月)"""
from scripts.kpi_value_collector import run_collector
period = data.get("period") or _default_period()
kpi_id = data.get("kpi_id") # 可选: 只采集单个KPI
result = run_collector(db, entity_id=entity_id, period=period, kpi_id=kpi_id)
result["period"] = period
return result
# ── 采集日志 ──────────────────────────────
@router.get("/value-collect/logs")
def list_collect_logs(
status: Optional[str] = Query(None),
period: Optional[str] = Query(None),
limit: int = Query(50, ge=1, le=200),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""采集日志(status过滤)"""
query = db.query(KPIValueCollectLog).filter(KPIValueCollectLog.entity_id == entity_id)
if status:
query = query.filter(KPIValueCollectLog.status == status)
if period:
query = query.filter(KPIValueCollectLog.period == period)
rows = query.order_by(KPIValueCollectLog.collected_at.desc()).limit(limit).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,
"source_table": r.source_table,
"collected_value": r.collected_value,
"status": r.status,
"message": r.message,
"collected_at": r.collected_at.isoformat() if r.collected_at else None,
})
return {"data": result, "total": len(result)}
# ── 覆盖率统计 ──────────────────────────────
@router.get("/value-sources/coverage")
def value_source_coverage(
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""覆盖率统计:已配映射KPI数 / 总活跃KPI数 / 未配置清单"""
total_kpis = db.query(KPIDefinition).filter(
KPIDefinition.entity_id == entity_id,
KPIDefinition.status == "active",
).count()
mapped_rows = db.query(KPIValueSource).filter(
KPIValueSource.entity_id == entity_id,
KPIValueSource.status == "active",
).all()
mapped_kpi_ids = {r.kpi_id for r in mapped_rows}
all_kpis = db.query(KPIDefinition).filter(
KPIDefinition.entity_id == entity_id,
KPIDefinition.status == "active",
).all()
unmapped = [{
"kpi_id": k.id,
"kpi_code": k.kpi_code,
"kpi_name": k.kpi_name,
} for k in all_kpis if k.id not in mapped_kpi_ids]
coverage = round(len(mapped_kpi_ids) / total_kpis * 100, 1) if total_kpis else 0
return {
"mapped_count": len(mapped_kpi_ids),
"total_kpis": total_kpis,
"coverage_pct": coverage,
"unmapped_count": len(unmapped),
"unmapped": unmapped,
}
def _default_period() -> str:
from datetime import datetime
return datetime.now().strftime("%Y-%m")