"""KPI目标对齐管理 API — 管理会计OS 支持三种对齐模式:纵向分解 / 横向支撑 / BSC瀑布链 管理员可初始化选择,后续按模式运作""" from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.orm import Session from sqlalchemy import func, or_ from typing import Optional, List from datetime import datetime import json from app.database import get_db from app.auth_middleware import require_auth, require_role, require_auth_401 from app.deps import get_entity_id from app.models import KPIDefinition, MapObjective, OperationLog, RolePermission, StrategicMap router = APIRouter(prefix="/api/cma/alignment", tags=["KPI目标对齐"], # 用户端点:router 级要求会话 Bearer 鉴权(cma-api-auth-surface-audit-20260914); # 原注释「不设全局权限,每个接口单独控制」保留语义——写接口仍各自叠加 require_role, # router 级只是「至少已认证」底线(补齐 /modes、GET /config 原无鉴权的缺口)。 dependencies=[Depends(require_auth_401)], ) # ── B3(a) 对齐模式拆账套(2026-09-22 dama-workbench-entity-isolation-fullstack-20260922)── # 背景:`role_permissions.key` 为 unique 单行键 → `alignment_config` 全库一行, # 任一账套的 ceo/it 改对齐模式 → **全账套生效**(跨账套配置污染)。 # 治本(方案 A,零 DDL):键名带账套后缀 → `alignment_config.`。 # · 读:只读本账套键;未配置 → configured=false(/tree 提示先初始化), # **不回退旧全局键**(回退即恢复跨账套生效)。 # · 写:只写本账套键(entity 来自会话 token)。 # · 旧值搬运:脚本 scripts/migrate_cash_balance_key_entity_20260922.py --check/--apply # (同一 500 口径:原全局值落到「当前生产账套」名下;--down 删新键并写回旧键)。 ALIGNMENT_KEY_PREFIX = "alignment_config" def alignment_key(entity_id: int) -> str: """账套维度的对齐模式键名:alignment_config.""" return f"{ALIGNMENT_KEY_PREFIX}.{int(entity_id)}" def _get_alignment_perm(db: Session, entity_id: int): """读本账套的对齐模式配置行(不存在 → None)""" return db.query(RolePermission).filter( RolePermission.key == alignment_key(entity_id) ).first() ALIGNMENT_MODES = [ { "key": "vertical_decomposition", "name": "纵向分解", "description": "上级KPI直接拆分为多个下级KPI,目标值汇总等于上级目标。适用于营收、成本等可量化指标。", "example": "公司销售总额2000万 → 区域A 800万 + 区域B 700万 + 区域C 500万", }, { "key": "horizontal_support", "name": "横向支撑", "description": "下级KPI是上级KPI的驱动因子,下级目标达成支撑上级结果。适用于复合型指标。", "example": "销售毛利率30% ← 销售总额↑ + 成本控制↓ + 高毛利产品占比↑", }, { "key": "bsc_chain", "name": "BSC瀑布链", "description": "按平衡计分卡因果链层层传导:学习成长→内部流程→客户→财务。", "example": "培训完成率↑ → 订单交付及时率↑ → 客户满意度↑ → 销售总额↑", }, ] @router.get("/modes") def list_modes(): """返回三种对齐模式的定义(公开接口)""" return {"modes": ALIGNMENT_MODES} @router.get("/config") def get_alignment_config(entity_id: int = Depends(get_entity_id), db: Session = Depends(get_db)): """获取**当前账套**的对齐模式配置(B3(a) 拆账套,2026-09-22) 修复前:读全局唯一键 `alignment_config` → 任一账套改模式全平台生效。 修复后:只读 `alignment_config.`;未配置 → configured=false。 """ perm = _get_alignment_perm(db, entity_id) if not perm: return { "mode": None, "configured": False, "entity_id": entity_id, "modes": ALIGNMENT_MODES, } return { "mode": perm.value, "configured": True, "entity_id": entity_id, "modes": ALIGNMENT_MODES, } @router.post("/config") def set_alignment_config( data: dict, entity_id: int = Depends(get_entity_id), db: Session = Depends(get_db), user = Depends(require_role("ceo", "it")), ): """初始化/修改**当前账套**的对齐模式(CEO/IT权限) B3(a)(2026-09-22):只写 `alignment_config.`,不覆盖他账套; entity 来自会话 token,显式传他账套 entity_id → get_entity_id 403。 """ mode_key = data.get("mode") if mode_key not in [m["key"] for m in ALIGNMENT_MODES]: raise HTTPException(400, f"无效的对齐模式: {mode_key}") perm = _get_alignment_perm(db, entity_id) if perm: perm.value = {"mode": mode_key, "set_at": datetime.now().isoformat(), "entity_id": entity_id} else: perm = RolePermission(key=alignment_key(entity_id), value={"mode": mode_key, "set_at": datetime.now().isoformat(), "entity_id": entity_id}) db.add(perm) db.commit() return {"message": f"对齐模式已设置为: {mode_key}", "mode": mode_key, "entity_id": entity_id} @router.get("/tree") def get_alignment_tree( kpi_id: Optional[int] = Query(None), map_id: Optional[int] = Query(None), entity_id: int = Depends(get_entity_id), db: Session = Depends(get_db), user = Depends(require_auth), ): """获取KPI对齐关系树(按当前账套隔离) 根据当前系统配置的对齐模式,返回KPI的父子层级关系。 如果指定kpi_id,返回该KPI及其下级树; 如果不指定,返回整个对齐树。 账套隔离(2026-09-18 project-20260918-alignment-entity-leak): 只取 entity_id 归属本账套的 KPI(entity_id 来自会话 token 绑定,写法对齐 growth-quality / predict.accuracy)。三种模式共用本列表 → 三模式同时隔离。 跨实体显式传参(?entity_id=<他人账套>)由 get_entity_id 返回 403。 地图筛选(2026-10-01 project-20261001-align-tree-map-filter-fullstack): `map_id` 参与**KPI 选择集**过滤,且只作用于 KPI 选择集——三种模式的构建函数不变。 过滤取「本账套该地图已关联的 KPI」,关联源按**权威程度**合并两条通道: ① `map_objectives.kpis`(**战略地图编辑器**写入的 KPI 编码数组,真源 `maps.py::_sync_map_objectives`;现网唯一有内容的通道); ② `KPIDefinition.map_id`(KPI 详情页「关联地图」写入;现网 329 行全为 NULL)。 两者取并集后与「本账套 `status='active'`」求交。跨账套/不存在的地图 id:先核该地图 归属本账套,不属本账套即返回**空树**(不泄漏他账套 KPI)。 不传 `map_id`:逐字段维持改前行为(本账套全部 active KPI)。 """ # 获取当前账套的对齐模式(B3(a) 拆账套 2026-09-22:只读 alignment_config.) config_perm = _get_alignment_perm(db, entity_id) mode = config_perm.value.get("mode") if config_perm else None if not mode: raise HTTPException(400, "系统未配置对齐模式,请先在系统设置中初始化") # 获取本账套所有KPI(账套隔离:仅当前 entity_id,不再返回全实体 KPI) kpi_query = (db.query(KPIDefinition) .filter(KPIDefinition.entity_id == entity_id, KPIDefinition.status == "active")) # 地图筛选:只缩小 KPI 选择集,不改动任何构建逻辑 empty_by_map = False if map_id is not None: owned = (db.query(StrategicMap.id) .filter(StrategicMap.id == map_id, StrategicMap.entity_id == entity_id) .first()) if not owned: empty_by_map = True # 跨账套 / 不存在 ⇒ 空树(不泄漏他账套 KPI) else: codes = set() for (arr,) in (db.query(MapObjective.kpis) .filter(MapObjective.map_id == map_id).all()): if isinstance(arr, list): codes.update(str(c) for c in arr if c) if codes: kpi_query = kpi_query.filter(or_( KPIDefinition.map_id == map_id, KPIDefinition.kpi_code.in_(sorted(codes)), )) else: kpi_query = kpi_query.filter(KPIDefinition.map_id == map_id) kpis = [] if empty_by_map else kpi_query.order_by(KPIDefinition.kpi_code).all() kpi_map = {k.id: k for k in kpis} # 构建父子关系 if mode == "vertical_decomposition": # 纵向分解:BSC编码前缀相同=同一系列 return _build_vertical_tree(kpis, kpi_id) elif mode == "horizontal_support": # 横向支撑:按BSC维度+类别的因果关系 return _build_horizontal_tree(kpis, kpi_id) elif mode == "bsc_chain": # BSC瀑布链:按维度层级传导 return _build_bsc_chain(kpis, kpi_id) else: raise HTTPException(400, f"未知的对齐模式: {mode}") def _build_vertical_tree(kpis, kpi_id=None): """纵向分解树:按编码前缀分组,同一前缀=同一系列""" from collections import defaultdict # 提取前缀(如 F_REVENUE_001 → F_REVENUE) groups = defaultdict(list) for k in kpis: parts = k.kpi_code.rsplit("_", 1) prefix = parts[0] if len(parts) > 1 else k.kpi_code groups[prefix].append(k) def make_node(kpi): return { "id": kpi.id, "kpi_code": kpi.kpi_code, "kpi_name": kpi.kpi_name, "dimension": kpi.dimension, "category": kpi.category, "target_value": kpi.target_value, "unit": kpi.unit, "children": [], } trees = [] # 每个前缀组中,按序号升序,第一个为父级 for prefix, group in sorted(groups.items()): sorted_group = sorted(group, key=lambda k: k.kpi_code) if len(sorted_group) > 1: parent = make_node(sorted_group[0]) parent["children"] = [make_node(c) for c in sorted_group[1:]] for c in parent["children"]: c["alignment_type"] = "vertical_split" c["parent_code"] = parent["kpi_code"] parent["child_count"] = len(parent["children"]) trees.append(parent) else: trees.append(make_node(sorted_group[0])) if kpi_id: # 只返回指定KPI的子树 return _filter_tree(trees, kpi_id) return {"mode": "vertical_decomposition", "mode_name": "纵向分解", "tree": trees, "total": len(kpis)} def _build_horizontal_tree(kpis, kpi_id=None): """横向支撑树:按BSC维度因果关联""" # 因果顺序:learning → process → customer → finance dim_order = {"learning": 0, "process": 1, "customer": 2, "finance": 3} dim_name = {"finance": "财务", "customer": "客户", "process": "内部流程", "learning": "学习成长"} # 按维度分组 groups = {"finance": [], "customer": [], "process": [], "learning": []} for k in kpis: if k.dimension in groups: groups[k.dimension].append(k) def make_node(kpi): return { "id": kpi.id, "kpi_code": kpi.kpi_code, "kpi_name": kpi.kpi_name, "dimension": kpi.dimension, "category": kpi.category, "target_value": kpi.target_value, "unit": kpi.unit, "children": [], } # 构建层级:一个维度节点包含该维度所有KPI trees = [] for dim, ks in sorted(groups.items(), key=lambda x: dim_order.get(x[0], 9)): if not ks: continue dim_node = { "id": None, "dimension": dim, "kpi_name": dim_name.get(dim, dim), "is_dimension_group": True, "children": [make_node(k) for k in sorted(ks, key=lambda x: x.kpi_code)], "child_count": len(ks), } # 建立因果关联说明 if dim == "learning": dim_node["description"] = "驱动因素:人才培养与创新" for c in dim_node["children"]: c["drives"] = "internal_process" elif dim == "process": dim_node["description"] = "过程保障:效率与质量提升" for c in dim_node["children"]: c["drives"] = "customer" elif dim == "customer": dim_node["description"] = "市场反馈:客户规模与满意度" for c in dim_node["children"]: c["drives"] = "finance" elif dim == "finance": dim_node["description"] = "结果指标:收入与盈利" for c in dim_node["children"]: c["drives"] = None trees.append(dim_node) if kpi_id: return _filter_tree(trees, kpi_id) return { "mode": "horizontal_support", "mode_name": "横向支撑", "tree": trees, "total": len(kpis), "causal_chain": [ {"from": "学习成长", "to": "内部流程", "logic": "培训与创新→流程效率提升"}, {"from": "内部流程", "to": "客户", "logic": "流程效率→客户满意度提升"}, {"from": "客户", "to": "财务", "logic": "客户规模→财务结果达成"}, ], } def _build_bsc_chain(kpis, kpi_id=None): """BSC瀑布链:按category类别间的因果传导""" from collections import defaultdict # 每个维度的KPI按category分组 cat_kpis = defaultdict(list) for k in kpis: if k.category: cat_kpis[k.category].append(k) # BSC瀑布链的传导关系 chain = [ {"cat": "talent_pipeline", "label": "人才梯队", "dim": "learning", "feeds": ["supply_chain", "delivery_quality"]}, {"cat": "employee_engagement", "label": "员工敬业", "dim": "learning", "feeds": ["supply_chain"]}, {"cat": "innovation", "label": "创新改善", "dim": "learning", "feeds": ["delivery_quality"]}, {"cat": "supply_chain", "label": "供应链效率", "dim": "process", "feeds": ["delivery_quality"]}, {"cat": "delivery_quality", "label": "交付质量", "dim": "process", "feeds": ["customer_scale", "customer_satisfaction"]}, {"cat": "customer_scale", "label": "客户规模", "dim": "customer", "feeds": ["revenue_growth"]}, {"cat": "customer_concentration", "label": "客户集中度", "dim": "customer", "feeds": ["profitability"]}, {"cat": "customer_satisfaction", "label": "客户满意", "dim": "customer", "feeds": ["revenue_growth", "profitability"]}, {"cat": "revenue_growth", "label": "收入增长", "dim": "finance", "feeds": ["profitability"]}, {"cat": "profitability", "label": "盈利水平", "dim": "finance", "feeds": None}, {"cat": "cost_control", "label": "成本费用", "dim": "finance", "feeds": ["profitability"]}, {"cat": "asset_efficiency", "label": "资产效率", "dim": "finance", "feeds": ["profitability"]}, {"cat": "cash_risk", "label": "现金流风控", "dim": "finance", "feeds": ["profitability"]}, ] def make_node(kpi): return { "id": kpi.id, "kpi_code": kpi.kpi_code, "kpi_name": kpi.kpi_name, "dimension": kpi.dimension, "category": kpi.category, "target_value": kpi.target_value, "unit": kpi.unit, } # 构建瀑布链 trees = [] for link in chain: cat = link["cat"] if cat not in cat_kpis: continue cat_node = { "id": None, "category": cat, "category_label": link["label"], "dimension": link["dim"], "is_category_group": True, "feeds": link["feeds"], "children": [make_node(k) for k in sorted(cat_kpis[cat], key=lambda x: x.kpi_code)], "child_count": len(cat_kpis[cat]), } trees.append(cat_node) if kpi_id: return _filter_tree(trees, kpi_id) return { "mode": "bsc_chain", "mode_name": "BSC瀑布链", "tree": trees, "total": len(kpis), "chain": chain, } def _filter_tree(nodes, target_id): """在树中查找包含指定KPI的子树""" for node in nodes: if node.get("id") == target_id: return node if node.get("children"): for child in node["children"]: if child.get("id") == target_id: return child # 递归查找 found = _filter_tree(node["children"], target_id) if found: return found return None