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) 切换隔离正确
310 lines
12 KiB
Python
310 lines
12 KiB
Python
"""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": "已删除"}
|