diff --git a/backend/app/api/kpis.py b/backend/app/api/kpis.py index 24608357..58303c18 100644 --- a/backend/app/api/kpis.py +++ b/backend/app/api/kpis.py @@ -8,7 +8,7 @@ import json from app.database import get_db from app.auth_middleware import require_auth, require_role, filter_kpis_by_role, kpi_visible_dims -from app.models import StrategicMap, MapObjective, KPIDefinition, KPIValue, KPIAlert, OperationLog, Entity +from app.models import StrategicMap, MapObjective, KPIDefinition, KPIValue, KPIAlert, OperationLog, Entity, KPICausality, KPIHierarchy router = APIRouter(prefix="/api/cma/kpis", tags=["KPI字典"], dependencies=[Depends(require_role("ceo", "finance", "business", "it"))], @@ -97,6 +97,290 @@ def get_kpi_categories(current_user = Depends(require_auth), db: Session = Depen return {"tree": tree, "total": sum(dim_counts.values())} +# ============================================================ +# KPI-5: 五档评分引擎(静态路由必须在动态/{kpi_id}之前) +# ============================================================ + +def _calc_five_tier_score(current_value, target_value): + """五档评分:1-5分""" + if current_value is None or target_value is None or target_value == 0: + return None, "info" + ratio = current_value / target_value + if ratio >= 1.2: + return 5, "success" # 卓越 + elif ratio >= 1.0: + return 4, "success" # 达标 + elif ratio >= 0.8: + return 3, "warning" # 预警 + elif ratio >= 0.5: + return 2, "danger" # 危险 + else: + return 1, "danger" # 失效 + + +@router.get("/score") +def get_kpi_score( + entity_id: int = Query(1, ge=1), + period: Optional[str] = None, + db: Session = Depends(get_db), + current_user = Depends(require_auth), +): + """五档评分引擎 - 返回各KPI评分和BSC四层汇总 + 评分: 5卓越(≥1.2×目标) 4达标(≥目标) 3预警(≥0.8×目标) 2危险(≥0.5×目标) 1失效(<0.5×目标) + """ + # 获取该企业所有活跃KPI + kpis = db.query(KPIDefinition).filter( + KPIDefinition.status == "active", + KPIDefinition.entity_id == entity_id, + ).all() + + if not kpis: + return {"entity_id": entity_id, "kpis": [], "layers": {}, "overall": None} + + # 获取企业信息 + ent = db.query(Entity).filter(Entity.id == entity_id).first() + entity_info = {"id": ent.id, "name": ent.name, "short_name": ent.short_name} if ent else {"id": entity_id} + + # 单个KPI评分 + kpi_scores = [] + for k in kpis: + # 取最新实际值 + val_query = db.query(KPIValue).filter( + KPIValue.kpi_id == k.id, + KPIValue.actual_value.isnot(None), + ) + if period: + val_query = val_query.filter(KPIValue.period == period) + latest_val = val_query.order_by(KPIValue.period.desc()).first() + + current_val = latest_val.actual_value if latest_val else None + score, status = _calc_five_tier_score(current_val, k.target_value) + + kpi_scores.append({ + "kpi_id": k.id, + "kpi_code": k.kpi_code, + "kpi_name": k.kpi_name, + "dimension": k.dimension, + "target_value": k.target_value, + "current_value": current_val, + "score": score, + "status": status, + "unit": k.unit, + "weight": 10, + "period": latest_val.period if latest_val else None, + }) + + # BSC四层汇总 + layer_map = { + "finance": {"label": "财务", "order": 0}, + "customer": {"label": "客户", "order": 1}, + "process": {"label": "流程", "order": 2}, + "learning": {"label": "学习成长", "order": 3}, + } + layers = {} + total_weighted_score = 0 + total_weight = 0 + + for dim_key, dim_info in layer_map.items(): + layer_kpis = [s for s in kpi_scores if s["dimension"] == dim_key and s["score"] is not None] + if not layer_kpis: + layers[dim_key] = {"label": dim_info["label"], "score": None, "status": "info", "kpi_count": 0, "weighted_score": None} + continue + + w = sum(k["weight"] for k in layer_kpis) + ws = sum(k["score"] * k["weight"] for k in layer_kpis) + avg_score = ws / w if w > 0 else None + avg_status = "success" if avg_score and avg_score >= 4 else ("warning" if avg_score and avg_score >= 3 else "danger") if avg_score else "info" + + layers[dim_key] = { + "label": dim_info["label"], + "score": round(avg_score, 2) if avg_score else None, + "status": avg_status, + "kpi_count": len(layer_kpis), + "weighted_score": round(avg_score, 2) if avg_score else None, + } + + if avg_score: + total_weighted_score += avg_score * len(layer_kpis) + total_weight += len(layer_kpis) + + # 综合得分 + overall_score = round(total_weighted_score / total_weight, 2) if total_weight > 0 else None + overall_status = "success" if overall_score and overall_score >= 4 else ("warning" if overall_score and overall_score >= 3 else "danger") if overall_score else "info" + + return { + "entity": entity_info, + "kpis": kpi_scores, + "layers": layers, + "overall": {"score": overall_score, "status": overall_status}, + } + + +# ============================================================ +# KPI-6: KPI三级分解树 +# ============================================================ + +@router.get("/hierarchy") +def get_kpi_hierarchy( + entity_id: int = Query(1, ge=1), + kpi_id: Optional[int] = None, + db: Session = Depends(get_db), + current_user = Depends(require_auth), +): + """KPI三级分解树:公司→部门→个人""" + query = db.query(KPIHierarchy).filter(KPIHierarchy.entity_id == entity_id) + if kpi_id is not None: + query = query.filter( + (KPIHierarchy.parent_kpi_id == kpi_id) | (KPIHierarchy.child_kpi_id == kpi_id) + ) + relations = query.order_by(KPIHierarchy.level).all() + + if not relations: + # 无层级数据,返回公司级KPI作为根节点 + kpis = db.query(KPIDefinition).filter( + KPIDefinition.status == "active", + KPIDefinition.entity_id == entity_id, + ).limit(20).all() + return { + "entity_id": entity_id, + "tree": [{"id": k.id, "kpi_code": k.kpi_code, "kpi_name": k.kpi_name, + "dimension": k.dimension, "level": 1, "children": []} for k in kpis], + "total": len(kpis), + } + + # 构建树 + kpi_ids = set() + for r in relations: + kpi_ids.add(r.parent_kpi_id) + kpi_ids.add(r.child_kpi_id) + + kpi_map = {} + for kid in kpi_ids: + k = db.query(KPIDefinition).filter(KPIDefinition.id == kid).first() + if k: + kpi_map[kid] = {"id": k.id, "kpi_code": k.kpi_code, "kpi_name": k.kpi_name, + "dimension": k.dimension, "level": None, "children": []} + + # 分配层级 + for r in relations: + if r.parent_kpi_id in kpi_map: + kpi_map[r.parent_kpi_id]["level"] = 1 # 公司级 + if r.child_kpi_id in kpi_map: + current_level = kpi_map[r.child_kpi_id].get("level") + new_level = r.level or 2 + if current_level is None or current_level > new_level: + kpi_map[r.child_kpi_id]["level"] = new_level + + # 构造父子关系 + tree = [] + added = set() + for r in relations: + parent = kpi_map.get(r.parent_kpi_id) + child = kpi_map.get(r.child_kpi_id) + if parent and child: + child_node = dict(child) + child_node["weight"] = r.weight + child_node["child_name"] = r.child_name + # 避免重复添加 + child_key = r.child_kpi_id + existing_child = next( + (c for c in parent["children"] if c["id"] == child_key), None + ) + if not existing_child: + parent["children"].append(child_node) + + # 收集顶级节点(有子节点且未被引用的parent) + all_child_ids = {r.child_kpi_id for r in relations} + for r in relations: + pid = r.parent_kpi_id + if pid not in all_child_ids or pid == (kpi_id if kpi_id else -1): + if pid not in added and pid in kpi_map: + tree.append(kpi_map[pid]) + added.add(pid) + + # 如果kpi_id指定,返回该节点为根的子树 + if kpi_id is not None and kpi_id in kpi_map: + root = kpi_map[kpi_id] + return {"entity_id": entity_id, "tree": [root], "total": len(tree)} + + # 否则按level排序 + tree.sort(key=lambda n: (n.get("level") or 99, n["kpi_code"])) + + return {"entity_id": entity_id, "tree": tree, "total": len(tree)} + + +# ============================================================ +# KPI-8: KPI因果链追踪 +# ============================================================ + +@router.get("/{kpi_id}/causality-chain") +def get_kpi_causality_chain( + kpi_id: int, + db: Session = Depends(get_db), + current_user = Depends(require_auth), +): + """KPI因果链追踪 — 返回单个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, + "dimension": src.dimension, + "layer": src.dimension, + "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, + "dimension": tgt.dimension, + "layer": tgt.dimension, + "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, + "layer": kpi.dimension, + }, + "drives": downstream_list, + "driven_by": upstream_list, + "total_upstream": len(upstream_list), + "total_downstream": len(downstream_list), + } + + +# ============================================================ +# 动态路由(必须在静态路由之后) +# ============================================================ + @router.get("/{kpi_id}") def get_kpi(kpi_id: int, db: Session = Depends(get_db)): kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first() diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index e7a00c4d..1953d779 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -293,6 +293,19 @@ class BscLayerConfig(Base): kpi_count_max = Column(Integer, default=5, comment="最多KPI数") +class KPIHierarchy(Base): + """KPI层级关系 — 公司→部门→个人三级分解""" + __tablename__ = "kpi_hierarchy" + id = Column(Integer, primary_key=True, index=True) + entity_id = Column(Integer, ForeignKey("entities.id"), default=1, comment="企业ID") + parent_kpi_id = Column(Integer, ForeignKey("kpi_definitions.id"), nullable=False, comment="上级KPI") + child_kpi_id = Column(Integer, ForeignKey("kpi_definitions.id"), nullable=False, comment="下级KPI") + level = Column(Integer, default=1, comment="1=公司级 2=部门级 3=个人级") + weight = Column(Float, default=1.0, comment="下级对上级的贡献权重(%)") + child_name = Column(String(200), nullable=True, comment="下级节点名称(个人或部门名)") + created_at = Column(DateTime, server_default=func.now()) + + # 兼容性: P2开发新增的模板API需要的模型 # KPIDefinition 已存在,KPITemplate映射到同一定义 KPITemplate = KPIDefinition diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 2ed17258..44e42646 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -36,6 +36,12 @@ export const kpiApi = { delete: (id: number) => api.delete(`/kpis/${id}`), listCategories: () => api.get('/kpis/categories'), associateMap: (id: number, data: any) => api.put(`/kpis/${id}/associate-map`, data), + // KPI-5: 五档评分 + score: (params?: any) => api.get('/kpis/score', { params }), + // KPI-6: 三级分解树 + hierarchy: (params?: any) => api.get('/kpis/hierarchy', { params }), + // KPI-8: 因果链追踪 + causalityChain: (id: number) => api.get(`/kpis/${id}/causality-chain`), } export const mapApi = { diff --git a/frontend/src/views/KPIDetail.vue b/frontend/src/views/KPIDetail.vue index 1ed9b759..ccfdb9e1 100644 --- a/frontend/src/views/KPIDetail.vue +++ b/frontend/src/views/KPIDetail.vue @@ -93,6 +93,29 @@ + +
+ +
暂无层级分解数据
+ + + +
+
+
@@ -237,6 +260,32 @@ const fullNetworkNodes = ref([]) const fullNetworkEdges = ref([]) const fullNetworkLoading = ref(false) +// 层级分解 +const hierarchyTree = ref([]) +const hierarchyLoading = ref(false) +const hierarchyProps = { children: 'children', label: 'kpi_name' } + +function hierarchyLevelTag(level: number) { + return level === 1 ? 'success' : level === 2 ? 'warning' : 'info' +} +function hierarchyDimTag(d: string) { + return ({ finance: '', customer: 'success', process: 'warning', learning: 'info' } as any)[d] || '' +} +function hierarchyDimLabel(d: string) { + return ({ finance: '财务', customer: '客户', process: '流程', learning: '学习' } as any)[d] || d +} + +async function loadHierarchy() { + hierarchyLoading.value = true + try { + const r: any = await kpiApi.hierarchy({ entity_id: 1, kpi_id: kpi.value?.id }) + hierarchyTree.value = r.tree || [] + } catch (e) { + console.error('层级分解加载失败', e) + } + hierarchyLoading.value = false +} + const CAT_MAP: Record = { revenue_growth: '收入增长', profitability: '盈利水平', cost_control: '成本费用', asset_efficiency: '资产效率', cash_risk: '现金流风控', @@ -513,6 +562,8 @@ onMounted(async () => { } catch(e) {} // 加载因果链 await loadCausality(Number(route.params.id)) + // 加载层级分解 + await loadHierarchy() // 加载KPI列表(用于模拟推演选择器) try { const kr: any = await kpiApi.list({ page_size: 100 })