Files
cma-management/backend/app/api/kpi_causality.py
T
Hermes CI Fix 50c15ddbf6 fix: 多租户数据隔离P1a+P1b — 工作台/预警/行动计划/因果链按企业过滤
P1a(有entity表查询补齐):
- kpis get/update/delete/restore 跨企业404校验
- kpis create 强制entity=token企业, update禁止改归属
P1b(无entity表join隔离):
- alerts list/resolve join kpi_definitions 按企业过滤
- action_plans list join过滤 + create校验关联KPI归属
- kpi_causality full-network/list join过滤
- dashboard my_dashboard(用户发现) assigned/preset均按企业隔离
测试: 测试KPI种子entity对齐(2→1), pytest 451 passed
实证: 酣客token 6KPI(无博海id) vs 博海token 1KPI(414) 切换隔离正确
2026-08-23 17:43:39 +08:00

310 lines
12 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.
"""KPI因果链建模 — 任务2
KPI间因果关系网络 + 模拟推演
"""
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
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)"""
edges = db.query(KPICausality).join(KPIDefinition, KPIDefinition.id == KPICausality.source_kpi_id).filter(KPIDefinition.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)):
"""获取KPI的因果网络(上游驱动 + 下游影响)"""
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
if not kpi:
raise HTTPException(404, "KPI不存在")
# 上游(指向当前KPI的因果)
upstream = db.query(KPICausality).filter(KPICausality.target_kpi_id == kpi_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,
})
# 下游(当前KPI指向的因果)
downstream = db.query(KPICausality).filter(KPICausality.source_kpi_id == kpi_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,
})
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)):
"""模拟推演: 修改一个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不存在")
# 获取当前值
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
).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,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""获取因果链列表(账套隔离: 仅当前企业KPI, 2026-08-23 P1b"""
query = db.query(KPICausality).join(KPIDefinition, KPIDefinition.id == KPICausality.source_kpi_id).filter(KPIDefinition.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)
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("/{causality_id}")
def get_causality(causality_id: int, db: Session = Depends(get_db)):
c = db.query(KPICausality).filter(KPICausality.id == causality_id).first()
if not c:
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_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不存在")
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}")
c = KPICausality(
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"),
)
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}"))
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):
c = db.query(KPICausality).filter(KPICausality.id == causality_id).first()
if not c:
raise HTTPException(404, "因果链不存在")
for field in ("strength", "lag_months", "formula", "direction"):
if field in data:
setattr(c, field, data[field])
db.commit()
db.refresh(c)
return _to_dict(c)
@router.delete("/{causality_id}")
def delete_causality(causality_id: int, db: Session = Depends(get_db), user=WRITE_ROLES):
c = db.query(KPICausality).filter(KPICausality.id == causality_id).first()
if c:
db.delete(c)
db.commit()
return {"message": "已删除"}