Files
cma-management/backend/app/api/alignment.py
T
wecom-fullstack 0c2e57a4cf fix(cma): 工作台跨实体污染治本 — 全局键拆账套 + 缺列收口 + 预警归属兜底
任务 dama-workbench-entity-isolation-fullstack-20260922(DAMA 诊断 / 项目Bot batch5 §B / 任总 2026-09-22「都批」)

B1 现金基线拆账套(零 DDL)
- cash_forecast_engine: 键 cash.current_balance → cash.current_balance.<entity_id>;
  读只读本账套键、不做旧键回退;set_* 签名 entity_id 必传(漏传 TypeError)
- cash.py: POST /balance 挂 get_entity_id;网银流水联动写回带账套
B3(a) alignment_config 拆账套(零 DDL)
- alignment.py: GET/POST /config、GET /tree 只读 alignment_config.<entity_id>;未配置=configured:false
B3(b) 预警 KPI 归属兜底
- bot_bridge_v2: 去掉 kpi_id or 1 兜底;本账套无 KPI 定义 → 跳过建预警 + OperationLog 留痕
  (kpi_alerts.kpi_id NOT NULL ⇒ 方案A 置 NULL 不可行,走方案B 跳过+审计)
B2 缺列五表分表处置
- notification_logs: 经 alert_id → kpi_alerts.entity_id join 归属(无父键行不返回)
- notification_channels / okr_templates: 集团共享口径写入 docstring(有意不过滤)
- system_configs: 由 B1/B3(a) 拆键覆盖;kpi_data_quality_logs 沿用 batch5 的 join 收口
P0 语义收敛
- tenant.py: current_tenant 收敛为项目Bot A2A 状态记录,GET 仅 it/ceo;POST 补 operation_logs 审计
证据:新增/改写用例 22 条;全量 1370 passed(基线 1348)
2026-09-22 11:06:02 +08:00

361 lines
15 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目标对齐管理 API — 管理会计OS
支持三种对齐模式:纵向分解 / 横向支撑 / BSC瀑布链
管理员可初始化选择,后续按模式运作"""
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.auth_middleware import require_auth, require_role, require_auth_401
from app.deps import get_entity_id
from app.models import KPIDefinition, OperationLog, RolePermission
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.<entity_id>`。
# · 读:只读本账套键;未配置 → 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.<entity_id>"""
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.<entity_id>`;未配置 → 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_id>`,不覆盖他账套;
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),
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。
"""
# 获取当前账套的对齐模式(B3(a) 拆账套 2026-09-22:只读 alignment_config.<entity_id>)
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)
kpis = (db.query(KPIDefinition)
.filter(KPIDefinition.entity_id == entity_id, KPIDefinition.status == "active")
.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