"""实际值自动归集 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")