"""KPI因果链建模 — 任务2 KPI间因果关系网络 + 模拟推演 + 三层验证机制(数据/AI/人工) (2026-08-27 P2) """ from datetime import datetime from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.orm import Session from sqlalchemy import text from typing import Optional import logging 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 KPIDefinition, KPICausality, KPIValue, OperationLog from app.services.causality_verification import ( ALL_STATUSES, ALL_SOURCE_TYPES, STATUS_PENDING, ) logger = logging.getLogger("kpi-causality") router = APIRouter(prefix="/api/cma/kpi-causality", tags=["KPI因果链"], dependencies=[Depends(require_role("ceo", "finance", "business", "it"))], ) WRITE_ROLES = Depends(require_role("ceo", "finance", "it")) def _to_dict(obj): return {c.name: getattr(obj, c.name) for c in obj.__table__.columns} # ============================================================ # 注意: 静态路径必须放在动态路径之前(/{id}之前) # ============================================================ @router.get("/full-network") def get_full_network(db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)): """获取全局因果网络数据(用于力导向图)— 账套隔离: 仅当前企业KPI的因果链 (2026-08-23 P1b, 2026-08-27 用entity_id列)""" edges = db.query(KPICausality).filter(KPICausality.entity_id == entity_id).all() node_ids = set() edge_list = [] for e in edges: node_ids.add(e.source_kpi_id) node_ids.add(e.target_kpi_id) edge_list.append({ "source": e.source_kpi_id, "target": e.target_kpi_id, "strength": e.strength, "direction": e.direction, "lag_months": e.lag_months, }) # 获取所有节点信息 kpis = db.query(KPIDefinition).filter(KPIDefinition.id.in_(node_ids)).all() if node_ids else [] node_map = {k.id: { "id": k.id, "kpi_code": k.kpi_code, "kpi_name": k.kpi_name, "dimension": k.dimension, "category": k.category, } for k in kpis} nodes = [] for nid in node_ids: info = node_map.get(nid, {"id": nid, "kpi_code": f"KPI#{nid}", "kpi_name": f"KPI#{nid}"}) nodes.append(info) return {"nodes": nodes, "edges": edge_list, "total_edges": len(edge_list)} @router.get("/kpi/{kpi_id}/network") def get_kpi_network(kpi_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)): """获取KPI的因果网络(上游驱动 + 下游影响)— 账套隔离: 校验KPI属于当前企业""" kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first() if not kpi: raise HTTPException(404, "KPI不存在") if kpi.entity_id != entity_id: raise HTTPException(404, "KPI不存在") # 跨企业不暴露存在性 # 上游(指向当前KPI的因果) upstream = db.query(KPICausality).filter( KPICausality.target_kpi_id == kpi_id, KPICausality.entity_id == entity_id, ).all() upstream_list = [] for c in upstream: src = db.query(KPIDefinition).filter(KPIDefinition.id == c.source_kpi_id).first() if src: upstream_list.append({ "causality_id": c.id, "kpi_id": src.id, "kpi_code": src.kpi_code, "kpi_name": src.kpi_name, "strength": c.strength, "lag_months": c.lag_months, "direction": c.direction, "formula": c.formula, "source_type": c.source_type, "verify_status": c.verify_status, "verified_at": c.verified_at.isoformat() if c.verified_at else None, "verified_by": c.verified_by, }) # 下游(当前KPI指向的因果) downstream = db.query(KPICausality).filter( KPICausality.source_kpi_id == kpi_id, KPICausality.entity_id == entity_id, ).all() downstream_list = [] for c in downstream: tgt = db.query(KPIDefinition).filter(KPIDefinition.id == c.target_kpi_id).first() if tgt: downstream_list.append({ "causality_id": c.id, "kpi_id": tgt.id, "kpi_code": tgt.kpi_code, "kpi_name": tgt.kpi_name, "strength": c.strength, "lag_months": c.lag_months, "direction": c.direction, "formula": c.formula, "source_type": c.source_type, "verify_status": c.verify_status, "verified_at": c.verified_at.isoformat() if c.verified_at else None, "verified_by": c.verified_by, }) return { "kpi": {"id": kpi.id, "kpi_code": kpi.kpi_code, "kpi_name": kpi.kpi_name, "dimension": kpi.dimension}, "upstream": upstream_list, "downstream": downstream_list, } @router.post("/simulate") def simulate_causality(data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)): """模拟推演: 修改一个KPI的值,预测对其他KPI的影响 Body: { kpi_id: int, new_value: float, period: str } """ kpi_id = data.get("kpi_id") new_value = data.get("new_value") period = data.get("period") if not kpi_id or new_value is None: raise HTTPException(400, "必须指定kpi_id和new_value") source_kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first() if not source_kpi: raise HTTPException(404, "KPI不存在") if source_kpi.entity_id != entity_id: raise HTTPException(404, "KPI不存在") # 获取当前值 current_value = None query_values = db.query(KPIValue).filter( KPIValue.kpi_id == kpi_id, KPIValue.actual_value.isnot(None), ) if period: query_values = query_values.filter(KPIValue.period == period) latest = query_values.order_by(KPIValue.period.desc()).first() if latest: current_value = latest.actual_value previous_value = current_value or new_value change_pct = ((new_value - previous_value) / previous_value * 100) if previous_value and previous_value != 0 else 0 # BFS遍历下游因果链 visited = set() impacts = [] queue = [(kpi_id, change_pct, 0, 1.0)] # (kpi_id, change_pct, depth, cumulative_strength) while queue: current_kpi_id, current_change, depth, cum_strength = queue.pop(0) if current_kpi_id in visited: continue visited.add(current_kpi_id) # 查找从current_kpi_id出发的下游因果链(账套隔离: 仅本企业链) downstream = db.query(KPICausality).filter( KPICausality.source_kpi_id == current_kpi_id, KPICausality.entity_id == entity_id, ).all() for edge in downstream: target_id = edge.target_kpi_id if target_id in visited: continue target_kpi = db.query(KPIDefinition).filter(KPIDefinition.id == target_id).first() if not target_kpi: continue # 计算影响: 变化率 × 强度 × 方向 edge_strength = edge.strength or 0.5 direction_factor = 1.0 if edge.direction == "positive" else -1.0 propagated_change = current_change * edge_strength * direction_factor # 获取当前值 tgt_val = db.query(KPIValue).filter( KPIValue.kpi_id == target_id, KPIValue.actual_value.isnot(None), ).order_by(KPIValue.period.desc()).first() predicted_value = None if tgt_val and tgt_val.actual_value: predicted_value = round(tgt_val.actual_value * (1 + propagated_change / 100), 2) impacts.append({ "kpi_id": target_id, "kpi_code": target_kpi.kpi_code, "kpi_name": target_kpi.kpi_name, "dimension": target_kpi.dimension, "current_value": tgt_val.actual_value if tgt_val else None, "predicted_value": predicted_value, "change_pct": round(propagated_change, 2), "strength": edge_strength, "direction": edge.direction, "lag_months": edge.lag_months, "depth": depth + 1, "path_strength": round(cum_strength * edge_strength, 3), }) # 继续遍历下游 new_cum = cum_strength * edge_strength if new_cum > 0.05 and depth < 5: queue.append((target_id, propagated_change, depth + 1, new_cum)) return { "source": { "kpi_id": source_kpi.id, "kpi_code": source_kpi.kpi_code, "kpi_name": source_kpi.kpi_name, "current_value": current_value, "new_value": new_value, "change_pct": round(change_pct, 2), }, "impacts": impacts, "total_impacted": len(impacts), } # ============================================================ # CRUD (动态路径) # ============================================================ @router.get("") def list_causalities( source_kpi_id: Optional[int] = None, target_kpi_id: Optional[int] = None, verify_status: Optional[str] = None, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id), ): """获取因果链列表(账套隔离: 仅当前企业KPI, 2026-08-23 P1b, 2026-08-27 支持verify_status筛选)""" query = db.query(KPICausality).filter(KPICausality.entity_id == entity_id) if source_kpi_id: query = query.filter(KPICausality.source_kpi_id == source_kpi_id) if target_kpi_id: query = query.filter(KPICausality.target_kpi_id == target_kpi_id) if verify_status: query = query.filter(KPICausality.verify_status == verify_status) items = query.order_by(KPICausality.id).all() result = [] for c in items: d = _to_dict(c) src = db.query(KPIDefinition).filter(KPIDefinition.id == c.source_kpi_id).first() tgt = db.query(KPIDefinition).filter(KPIDefinition.id == c.target_kpi_id).first() d["source_kpi_code"] = src.kpi_code if src else None d["source_kpi_name"] = src.kpi_name if src else None d["target_kpi_code"] = tgt.kpi_code if tgt else None d["target_kpi_name"] = tgt.kpi_name if tgt else None result.append(d) return {"data": result, "total": len(result)} @router.get("/verify-status") def get_verify_status( verify_status: Optional[str] = None, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id), ): """验证状态总览 — 按状态统计 + 链列表(2026-08-27 三层验证机制) 可选 ?verify_status=pending/data_verified/human_verified/disputed 筛选 """ if verify_status and verify_status not in ALL_STATUSES: raise HTTPException(400, f"verify_status 必须为 {'/'.join(ALL_STATUSES)}") query = db.query(KPICausality).filter(KPICausality.entity_id == entity_id) if verify_status: query = query.filter(KPICausality.verify_status == verify_status) items = query.order_by(KPICausality.id).all() by_status = {s: 0 for s in ALL_STATUSES} data = [] for c in items: by_status[c.verify_status] = by_status.get(c.verify_status, 0) + 1 d = _to_dict(c) src = db.query(KPIDefinition).filter(KPIDefinition.id == c.source_kpi_id).first() tgt = db.query(KPIDefinition).filter(KPIDefinition.id == c.target_kpi_id).first() d["source_kpi_code"] = src.kpi_code if src else None d["source_kpi_name"] = src.kpi_name if src else None d["target_kpi_code"] = tgt.kpi_code if tgt else None d["target_kpi_name"] = tgt.kpi_name if tgt else None d["verified_at"] = c.verified_at.isoformat() if c.verified_at else None data.append(d) return { "summary": {"total": len(items), "by_status": by_status}, "data": data, } @router.get("/{causality_id}") def get_causality(causality_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)): c = db.query(KPICausality).filter(KPICausality.id == causality_id).first() if not c: raise HTTPException(404, "因果链不存在") if c.entity_id != entity_id: raise HTTPException(404, "因果链不存在") # 账套隔离 d = _to_dict(c) src = db.query(KPIDefinition).filter(KPIDefinition.id == c.source_kpi_id).first() tgt = db.query(KPIDefinition).filter(KPIDefinition.id == c.target_kpi_id).first() d["source"] = {"id": src.id, "kpi_code": src.kpi_code, "kpi_name": src.kpi_name} if src else None d["target"] = {"id": tgt.id, "kpi_code": tgt.kpi_code, "kpi_name": tgt.kpi_name} if tgt else None return d @router.post("") def create_causality(data: dict, db: Session = Depends(get_db), user=WRITE_ROLES): """创建因果链(source_type标记来源, 2026-08-27)""" source_id = data.get("source_kpi_id") target_id = data.get("target_kpi_id") if not source_id or not target_id: raise HTTPException(400, "必须指定源KPI和目标KPI") if source_id == target_id: raise HTTPException(400, "源和目标不能相同") src = db.query(KPIDefinition).filter(KPIDefinition.id == source_id).first() tgt = db.query(KPIDefinition).filter(KPIDefinition.id == target_id).first() if not src or not tgt: raise HTTPException(404, "KPI不存在") if src.entity_id != tgt.entity_id: raise HTTPException(400, "源KPI和目标KPI必须属于同一企业") existing = db.query(KPICausality).filter( KPICausality.source_kpi_id == source_id, KPICausality.target_kpi_id == target_id, ).first() if existing: raise HTTPException(400, f"因果链已存在: {src.kpi_code}→{tgt.kpi_code}") source_type = data.get("source_type", "manual") if source_type not in ALL_SOURCE_TYPES: raise HTTPException(400, f"source_type 必须为 {'/'.join(ALL_SOURCE_TYPES)}") c = KPICausality( entity_id=src.entity_id, source_kpi_id=source_id, target_kpi_id=target_id, strength=data.get("strength", 0.5), lag_months=data.get("lag_months", 1), formula=data.get("formula"), direction=data.get("direction", "positive"), source_type=source_type, verify_status=STATUS_PENDING, ) db.add(c) db.commit() db.refresh(c) db.add(OperationLog(action="create", target_type="kpi_causality", detail=f"创建因果链: {src.kpi_code}→{tgt.kpi_code} (source={source_type})")) db.commit() return _to_dict(c) @router.put("/{causality_id}") def update_causality(causality_id: int, data: dict, db: Session = Depends(get_db), user=WRITE_ROLES, entity_id: int = Depends(get_entity_id)): c = db.query(KPICausality).filter(KPICausality.id == causality_id).first() if not c: raise HTTPException(404, "因果链不存在") if c.entity_id != entity_id: raise HTTPException(404, "因果链不存在") # 账套隔离 for field in ("strength", "lag_months", "formula", "direction", "source_type"): if field in data: if field == "source_type" and data[field] not in ALL_SOURCE_TYPES: raise HTTPException(400, f"source_type 必须为 {'/'.join(ALL_SOURCE_TYPES)}") setattr(c, field, data[field]) # 修改链定义后,验证状态回到待检(定义变了旧结论失效) if any(f in data for f in ("strength", "lag_months", "formula", "direction")): c.verify_status = STATUS_PENDING c.verified_at = None c.verified_by = None db.commit() db.refresh(c) return _to_dict(c) @router.put("/{causality_id}/verify") def verify_causality(causality_id: int, data: dict, db: Session = Depends(get_db), user=WRITE_ROLES, entity_id: int = Depends(get_entity_id)): """人工确认(战略回顾会核对打标)— 2026-08-27 三层验证机制 Body: { verify_status: "human_verified"|"disputed", verified_by?: "任富海" } 默认打标 human_verified(人工最终确认)。 """ c = db.query(KPICausality).filter(KPICausality.id == causality_id).first() if not c: raise HTTPException(404, "因果链不存在") if c.entity_id != entity_id: raise HTTPException(404, "因果链不存在") # 账套隔离 target_status = data.get("verify_status", "human_verified") if target_status not in ("human_verified", "disputed"): raise HTTPException(400, "verify_status 必须为 human_verified 或 disputed") verified_by = data.get("verified_by") or user.name or user.username c.verify_status = target_status c.verified_at = datetime.now() c.verified_by = str(verified_by)[:50] db.add(OperationLog(action="verify", target_type="kpi_causality", detail=f"因果链 #{causality_id} 人工确认: {target_status} (by {verified_by})")) db.commit() db.refresh(c) d = _to_dict(c) d["verified_at"] = c.verified_at.isoformat() if c.verified_at else None return d @router.delete("/{causality_id}") def delete_causality(causality_id: int, db: Session = Depends(get_db), user=WRITE_ROLES, entity_id: int = Depends(get_entity_id)): c = db.query(KPICausality).filter(KPICausality.id == causality_id).first() if c: if c.entity_id != entity_id: raise HTTPException(404, "因果链不存在") # 账套隔离 db.delete(c) db.commit() return {"message": "已删除"}