"""KPI字典 API""" from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.orm import Session from sqlalchemy import func from typing import Optional, List from datetime import datetime import json from app.database import get_db from app.deps import get_entity_id 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, KPICausality, KPIHierarchy, User from app.api.kpi_governance import validate_kpi_payload, kpi_issues_message router = APIRouter(prefix="/api/cma/kpis", tags=["KPI字典"], dependencies=[Depends(require_role("ceo", "finance", "business", "it"))], ) # 写操作只允许 ceo/finance/it WRITE_ROLES = Depends(require_role("ceo", "finance", "it")) @router.get("") def list_kpis( page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), dimension: Optional[str] = None, keyword: Optional[str] = None, epic: Optional[str] = None, category: Optional[str] = None, entity_id: int = Depends(get_entity_id), kpi_level: Optional[str] = None, db: Session = Depends(get_db), current_user = Depends(require_auth), ): query = db.query(KPIDefinition).filter(KPIDefinition.status == "active") # 角色权限过滤 dims = kpi_visible_dims(current_user.role, db) if dims: query = query.filter(KPIDefinition.dimension.in_(dims)) if dimension: dims_list = [d.strip() for d in dimension.split(',')] if ',' in dimension else [dimension] query = query.filter(KPIDefinition.dimension.in_(dims_list)) if keyword: query = query.filter(KPIDefinition.kpi_name.contains(keyword)) if category: cats_list = [c.strip() for c in category.split(',')] if ',' in category else [category] query = query.filter(KPIDefinition.category.in_(cats_list)) if entity_id is not None: query = query.filter(KPIDefinition.entity_id == entity_id) if kpi_level: query = query.filter(KPIDefinition.kpi_level == kpi_level) total = query.count() kpis = query.order_by(KPIDefinition.kpi_code).offset((page-1)*page_size).limit(page_size).all() result = {"total": total, "page": page, "page_size": page_size, "data": [kpi_to_dict(k) for k in kpis]} if entity_id is not None: ent = db.query(Entity).filter(Entity.id == entity_id).first() if ent: result["entity"] = {"id": ent.id, "name": ent.name, "short_name": ent.short_name} return result @router.get("/categories") def get_kpi_categories(current_user = Depends(require_auth), db: Session = Depends(get_db)): """获取BSC分类结构(带可见性过滤)""" from sqlalchemy import func as sa_func dims = kpi_visible_dims(current_user.role, db) query = db.query( KPIDefinition.dimension, KPIDefinition.category, sa_func.count(KPIDefinition.id) ).filter(KPIDefinition.status == "active") if dims: query = query.filter(KPIDefinition.dimension.in_(dims)) rows = query.group_by(KPIDefinition.dimension, KPIDefinition.category).all() # 构建树形结构 dim_map = {"finance": "财务", "customer": "客户", "process": "内部流程", "learning": "学习成长"} cat_map = { "revenue_growth": "收入增长", "profitability": "盈利水平", "cost_control": "成本费用", "asset_efficiency": "资产效率", "cash_risk": "现金流风控", "customer_scale": "客户规模", "customer_concentration": "客户集中度", "customer_satisfaction": "客户满意", "supply_chain": "供应链效率", "delivery_quality": "交付质量", "talent_pipeline": "人才梯队", "employee_engagement": "员工敬业", "innovation": "创新改善", } tree = [] for dim, cat, cnt in rows: # 找或创建维度节点 dim_node = next((n for n in tree if n["key"] == dim), None) if not dim_node: dim_node = {"key": dim, "label": dim_map.get(dim, dim), "children": []} tree.append(dim_node) dim_node["children"].append({ "key": cat, "label": cat_map.get(cat, cat), "count": cnt, }) dim_counts = {} for d in tree: dim_counts[d["key"]] = sum(c["count"] for c in d["children"]) d["count"] = dim_counts[d["key"]] return {"tree": tree, "total": sum(dim_counts.values())} # ============================================================ # KPI-5: 五档评分引擎(静态路由必须在动态/{kpi_id}之前) # ============================================================ REVERSE_INDICATORS = ['C_REBATE_RATE', 'P_BUG_RATE', 'P_REWORK_PCT', 'F_DEBT_RATIO', 'F_COST_RATIO', 'F_AR_DAYS', 'F_REBATE_RATE', 'F_FACTORY_REBATE_RATE', 'F_COST_CONTROL_RATE', 'F_INV_DAYS'] def _calc_five_tier_score(current_value, target_value, is_reverse=False): """五档评分: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 is_reverse: # 反向指标:实际值越低越好 if ratio <= 0.5: return 5, "success" # 远低于目标→卓越 elif ratio <= 0.8: return 4, "success" # 低于目标→达标 elif ratio <= 1.0: return 3, "warning" # 接近目标→预警 elif ratio <= 1.2: return 2, "danger" # 超过目标→危险 else: return 1, "danger" # 远超目标→失效 else: 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 = Depends(get_entity_id), 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, is_reverse=(k.kpi_code in REVERSE_INDICATORS)) 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-glossary: 知识资产化 — KPI字典实时加载(供ChatBI财务Bot调用) # ============================================================ @router.get("/glossary") def get_kpi_glossary( entity_id: int = Depends(get_entity_id), db: Session = Depends(get_db), current_user = Depends(require_auth), ): """KPI字典实时加载 — 返回所有KPI的定义、当前值、目标值、公式、维度、阈值 供ChatBI财务Bot在分析前调用,确保口径与系统一致。 返回字段: kpi_code, kpi_name, current_value, target_value, formula, dimension, threshold """ kpis = db.query(KPIDefinition).filter( KPIDefinition.status == "active", KPIDefinition.entity_id == entity_id, ).order_by(KPIDefinition.kpi_code).all() result = [] for k in kpis: # 获取最新实际值 latest_val = db.query(KPIValue).filter( KPIValue.kpi_id == k.id, KPIValue.actual_value.isnot(None), ).order_by(KPIValue.period.desc()).first() current_value = latest_val.actual_value if latest_val else None latest_period = latest_val.period if latest_val else None # 组装阈值描述 threshold = None if k.threshold_green or k.threshold_yellow or k.threshold_red: parts = [] if k.threshold_green: parts.append(f"绿灯:{k.threshold_green}") if k.threshold_yellow: parts.append(f"黄灯:{k.threshold_yellow}") if k.threshold_red: parts.append(f"红灯:{k.threshold_red}") threshold = " | ".join(parts) result.append({ "kpi_id": k.id, "kpi_code": k.kpi_code, "kpi_name": k.kpi_name, "dimension": k.dimension, "category": k.category, "formula": k.formula, "formula_desc": k.formula_desc, "unit": k.unit, "target_value": k.target_value, "current_value": current_value, "latest_period": latest_period, "threshold": threshold, "responsible_dept": k.responsible_dept, "responsible_user": k.responsible_user, "data_source": k.data_source, "data_owner": k.data_owner, "frequency": k.frequency, "status": k.status, }) return { "entity_id": entity_id, "total": len(result), "glossary": result, } # ============================================================ # KPI-6: KPI三级分解树 # ============================================================ @router.get("/hierarchy") def get_kpi_hierarchy( entity_id: int = Depends(get_entity_id), 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.post("/{kpi_id}/values") def create_kpi_value( kpi_id: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id), current_user: User = Depends(require_auth), ): """录入KPI单值(人工数据录入,用于客户/流程/学习层台账数据) Body: {period: '2026-08', actual_value: 85} """ 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不存在") period = data.get("period") actual_value = data.get("actual_value") if not period or actual_value is None: raise HTTPException(400, "缺少必要参数: period, actual_value") # 同一期间重复录入 → 更新 existing = db.query(KPIValue).filter( KPIValue.kpi_id == kpi_id, KPIValue.period == period, KPIValue.source_type == "manual", ).first() if existing: existing.actual_value = float(actual_value) existing.data_status = "verified" existing.remark = f"人工录入(更新) by {current_user.username}" db.commit() return {"message": "已更新", "id": existing.id} new_val = KPIValue( kpi_id=kpi_id, entity_id=entity_id, period=period, actual_value=float(actual_value), source_type="manual", source_batch=f"manual-{current_user.username}-{datetime.now().strftime('%Y%m%d')}", data_status="verified", remark=f"人工录入 by {current_user.username}", ) db.add(new_val) db.commit() return {"message": "已录入", "id": new_val.id, "period": period, "actual_value": float(actual_value)} @router.get("/{kpi_id}/values") def list_kpi_values( kpi_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id), current_user: User = Depends(require_auth), ): """查询KPI实际值列表(含source_type标记,供归集标签页展示)""" 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不存在") vals = db.query(KPIValue).filter(KPIValue.kpi_id == kpi_id).order_by(KPIValue.period.desc()).all() return { "data": [{ "id": v.id, "period": v.period, "actual_value": v.actual_value, "source_type": v.source_type or "manual", "source_batch": v.source_batch or "", "data_status": v.data_status, "remark": v.remark or "", } for v in vals] } @router.get("/{kpi_id}") def get_kpi(kpi_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)): kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first() if not kpi: raise HTTPException(404, "KPI不存在") # 账套隔离: 禁止跨企业读取 (2026-08-23 P1a) if kpi.entity_id != entity_id: raise HTTPException(404, "KPI不存在") result = kpi_to_dict(kpi) # 附带历史数据(前端KPI详情"历史数据"Tab依赖) # 2026-08-26修复: 原实现只返回定义信息,values缺失导致历史数据Tab空白 # 2026-08-26增强: values对齐KPI元数据(目标值/偏差/红黄绿判定/单位) vals = db.query(KPIValue).filter( KPIValue.kpi_id == kpi_id, KPIValue.actual_value.isnot(None), ).order_by(KPIValue.period.asc()).all() is_reverse = kpi.kpi_code in REVERSE_INDICATORS target = kpi.target_value result["values"] = [] for v in vals: # 判定红黄绿(对齐工作台语义:正向≥0.9绿/≥0.7黄/否则红;反向≤目标绿/≤1.1倍黄/否则红) level = "info" score = None if target and v.actual_value is not None: if is_reverse: if v.actual_value <= target: level = "green" elif v.actual_value <= target * 1.1: level = "yellow" else: level = "red" else: ratio = v.actual_value / target if ratio >= 0.9: level = "green" elif ratio >= 0.7: level = "yellow" else: level = "red" score, _ = _calc_five_tier_score(v.actual_value, target, is_reverse=is_reverse) # 偏差率(相对目标) deviation = None if target and target != 0 and v.actual_value is not None: deviation = round((v.actual_value - target) / target * 100, 1) result["values"].append({ "id": v.id, "period": v.period, "actual_value": v.actual_value, "target_value": target, "unit": kpi.unit or "", "deviation_pct": deviation, "level": level, "score": score, "source_type": v.source_type, "data_status": v.data_status, "source_batch": v.source_batch, }) return result def _validate_kpi_data(data: dict, db: Session, current_kpi_id: Optional[int] = None, is_update: bool = False): """数据治理4条规则校验(入库必检+元数据+编码规范+战略分级),返回错误信息列表""" issues = validate_kpi_payload(data, db=db, current_kpi_id=current_kpi_id, is_update=is_update) return kpi_issues_message(issues) # ════════════════════════════════════════════════════════════ # KPI多粒度目标:指标类型推断 + 周期目标派生(docs/kpi-design-rule.md 落地) # 规则:累计型 月×3=季、月×12=年(季×4=年);比率型 季/年沿用基准(可手调) # 派生为"虚拟展示值":DB只存用户手填真值,API返回时补派生值+derived标记 # ════════════════════════════════════════════════════════════ RATIO_NAME_HINTS = ['率', '比', '满意度', '周转', '时长', '周期', '天数', '指数', 'NPS', 'LTV', 'CAC', '份额', '集中度', '响应', '完成', '达成', '人均', '单价', '净推荐', '覆盖', '保留', '复购', '转介绍', '投诉', '合规', '认证', '掌握', '胜任', '认知', '采纳', '引用', '复用', '一致性', '准确', '间隙', '时效', '及时'] ACCUM_NAME_HINTS = ['营收', '收入', '利润', '净利', '销售', '客户数', '新客', '新增', '产量', '销量', '金额', '现金流', '回款', '毛利额', '产值', '储备', '数量', '篇数', '报告产出', '提案', '发现数', '知识沉淀', '招待费'] RATIO_UNIT_HINTS = ['%', '倍', '天', '分', '小时', '分钟'] ACCUM_UNIT_HINTS = ['万元', '元', '个', '件', '人', '篇', '份', '万'] def infer_calc_type(kpi_code: str = "", kpi_name: str = "", unit: str = "") -> str: """推断指标类型: accumulate累计(可乘) / ratio比率(不可乘)。名称关键词优先于单位""" n = (kpi_name or "") + " " + (kpi_code or "") u = unit or "" if any(k in n for k in RATIO_NAME_HINTS): return "ratio" if any(k in n for k in ACCUM_NAME_HINTS): return "accumulate" if u in RATIO_UNIT_HINTS or u.startswith("小时"): return "ratio" if u in ACCUM_UNIT_HINTS: return "accumulate" return "ratio" # 兜底比率(率值不能乘,更安全) def derive_cycle_targets(kpi) -> dict: """按指标类型派生月/季/年目标(虚拟值,不落库)。 返回: {"derived": {monthly/quarterly/yearly: 显示值}, "flags": {monthly/quarterly/yearly: 是否派生}} """ calc_type = (getattr(kpi, "target_calc_type", None) or infer_calc_type( kpi.kpi_code or "", kpi.kpi_name or "", kpi.unit or "")).lower() m = kpi.target_monthly q = kpi.target_quarterly y = kpi.target_yearly freq = (kpi.frequency or "monthly").lower() # 基准值(考核周期优先,回退 target_value) base = None if freq == "yearly": base = y elif freq in ("quarterly", "half_year"): base = q elif freq in ("monthly", "weekly"): base = m if base is None: base = kpi.target_value # 无基准值则不派生 if base is None: return {"derived": {"monthly": m, "quarterly": q, "yearly": y}, "flags": {"monthly": False, "quarterly": False, "yearly": False}} dm, dq, dy = m, q, y fm, fq, fy = False, False, False if calc_type == "accumulate": # 锚点月值:手填月目标优先;月基准且手填月空时用 target_value 回退 anchor_m = dm if anchor_m is None and base is not None and freq in ("monthly", "weekly"): anchor_m = base if anchor_m is not None: if dm is None: dm = anchor_m # target_value 回退显示为月基准 if dq is None: dq, fq = anchor_m * 3, True if dy is None: dy, fy = anchor_m * 12, True elif dq is not None: # 季基准(累计型):年=季×4;月不反推(避免小数噪声) if dy is None: dy, fy = dq * 4, True else: # ratio:季/年沿用基准,不乘 if dq is None: dq, fq = base, True if dy is None: dy, fy = base, True return {"derived": {"monthly": dm, "quarterly": dq, "yearly": dy}, "flags": {"monthly": fm, "quarterly": fq, "yearly": fy}} def apply_calc_type_inference(data: dict, infer_missing: bool = True) -> dict: """create/update 前:未显式传 target_calc_type 时按名称/单位推断。 infer_missing=False(update场景):仅当用户显式传了空值时推断,未传则保留DB原值""" if "target_calc_type" in data: if not data.get("target_calc_type"): data["target_calc_type"] = infer_calc_type( data.get("kpi_code", ""), data.get("kpi_name", ""), data.get("unit", "")) elif infer_missing: data["target_calc_type"] = infer_calc_type( data.get("kpi_code", ""), data.get("kpi_name", ""), data.get("unit", "")) return data @router.post("") def create_kpi(data: dict, db: Session = Depends(get_db), user=WRITE_ROLES, entity_id: int = Depends(get_entity_id)): # 检查编码唯一性 existing = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == data.get("kpi_code", ""), KPIDefinition.entity_id == entity_id).first() if existing: raise HTTPException(400, f"KPI编码 {data['kpi_code']} 已存在") # 数据治理校验(规则1强制拦截) errs = _validate_kpi_data(data, db=db, is_update=False) if errs: raise HTTPException(422, detail={"message": "数据校验不通过", "errors": errs}) data["entity_id"] = entity_id # 账套隔离: 强制写入token企业 (2026-08-23 P1a) data = apply_calc_type_inference(data) kpi = KPIDefinition(**data) db.add(kpi) db.commit() db.refresh(kpi) _log(db, 1, "create", "kpi", kpi.id, data) return kpi_to_dict(kpi) @router.put("/{kpi_id}") def update_kpi(kpi_id: int, data: dict, db: Session = Depends(get_db), user=WRITE_ROLES, entity_id: int = Depends(get_entity_id)): kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first() if not kpi: raise HTTPException(404, "KPI不存在") # 账套隔离: 禁止跨企业修改 (2026-08-23 P1a) if kpi.entity_id != entity_id: raise HTTPException(404, "KPI不存在") # 数据治理校验(更新时只检查传了但为空的字段) errs = _validate_kpi_data(data, db=db, current_kpi_id=kpi_id, is_update=True) if errs: raise HTTPException(422, detail={"message": "数据校验不通过", "errors": errs}) data.pop("entity_id", None) # 禁止通过update改企业归属 data = apply_calc_type_inference(data, infer_missing=False) for k, v in data.items(): if hasattr(kpi, k) and v is not None: setattr(kpi, k, v) db.commit() _log(db, 1, "update", "kpi", kpi_id, data) return kpi_to_dict(kpi) @router.delete("/{kpi_id}") def delete_kpi(kpi_id: int, db: Session = Depends(get_db), user=WRITE_ROLES, entity_id: int = Depends(get_entity_id)): kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first() if kpi: # 账套隔离: 禁止跨企业删除 (2026-08-23 P1a) if kpi.entity_id != entity_id: raise HTTPException(404, "KPI不存在") kpi.status = "disabled" db.commit() return {"message": "已删除"} @router.put("/{kpi_id}/restore") def restore_kpi(kpi_id: int, db: Session = Depends(get_db), user=WRITE_ROLES, entity_id: int = Depends(get_entity_id)): kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first() if kpi: # 账套隔离: 禁止跨企业恢复 (2026-08-23 P1a) if kpi.entity_id != entity_id: raise HTTPException(404, "KPI不存在") kpi.status = "active" db.commit() return {"message": "已恢复"} def kpi_to_dict(k): d = {c.name: getattr(k, c.name) for c in k.__table__.columns} # 多粒度目标派生:月/季/年显示值 + derived标记(虚拟,不落库) try: der = derive_cycle_targets(k) d["derived_targets"] = der["derived"] d["derived_flags"] = der["flags"] except Exception: d["derived_targets"] = {"monthly": k.target_monthly, "quarterly": k.target_quarterly, "yearly": k.target_yearly} d["derived_flags"] = {"monthly": False, "quarterly": False, "yearly": False} # 附加战略地图信息 if k.map_id: from app.database import get_session_local try: sess = get_session_local()() m = sess.query(StrategicMap).filter(StrategicMap.id == k.map_id).first() d["map_title"] = m.title if m else None sess.close() except: d["map_title"] = None else: d["map_title"] = None return d @router.put("/{kpi_id}/associate-map") def associate_kpi_map(kpi_id: int, data: dict, db: Session = Depends(get_db), user=WRITE_ROLES): """关联KPI到战略地图""" kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first() if not kpi: raise HTTPException(404, "KPI不存在") map_id = data.get("map_id") if map_id is not None: m = db.query(StrategicMap).filter(StrategicMap.id == map_id).first() if not m: raise HTTPException(404, "战略地图不存在") kpi.map_id = map_id db.commit() _log(db, 1, "update", "kpi", kpi_id, {"action": "associate-map", "map_id": map_id}) return kpi_to_dict(kpi) def _log(db, user_id, action, target_type, target_id, detail): log = OperationLog(user_id=user_id, action=action, target_type=target_type, target_id=target_id, detail=json.dumps(detail, ensure_ascii=False) if detail else None) db.add(log) db.commit() @router.get("/{kpi_id}/objectives") def get_kpi_objectives(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_definitions.objective 字段关联目标 # 也通过 map_id 关联地图 result = { "kpi": {"id": kpi.id, "kpi_code": kpi.kpi_code, "kpi_name": kpi.kpi_name}, "objectives": [], "map": None, } if kpi.map_id: m = db.query(StrategicMap).filter(StrategicMap.id == kpi.map_id).first() if m: result["map"] = {"id": m.id, "title": m.title, "status": m.status} if kpi.objective: objs = db.query(MapObjective).filter( MapObjective.map_id == kpi.map_id, MapObjective.name == kpi.objective, ).all() result["objectives"] = [{"id": o.id, "name": o.name, "dimension_key": o.dimension_key} for o in objs] return result