Files
cma-management/backend/app/api/kpis.py
T
Hermes CI Fix ac4bbc6891 fix(h2-forecast): 对外 KPIValue 残余读路径统一实测区口径(7 处)
任总 2026-10-02 11:35 直令「对外 /api/cma/** 读 KPIValue 默认只取实测区;
预估禁进趋势序列 / 禁作预警与预算依据」的同族残余读路径收口。

7 处逐处改(均引 app/utils/kpi_value_scope.actual_only_clause,不新写字面量口径):
- kpis.py:604            GET /kpis/{id} 详情历史值(预估禁进趋势序列)
- ai_analysis.py:363     GET /ai/kpi-analysis/{id} 趋势序列/LLM 上下文
- thresholds.py:132      GET /thresholds/suggest/{id} 阈值推荐依据
- budget_generate.py:94  GET /budget/kpi-budget-candidates 预算候选最新值
- budget.py:1380         POST /budget/apply-method 派生基数(预算依据)
- maps.py:657            GET /maps/{id}/review 地图节点最新值
- data_quality.py:363    GET /data-quality/stats 缺失判定(仅预估行不算有实际值)

证据:同脚本双树 A/B 11/11(改前 3/11 FAIL)+ pytest 3 failed/1529 passed 与基线逐条同 ID。
不 push / 不部署 / 不重启 / 不写库。
2026-10-02 21:01:25 +08:00

969 lines
40 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"""
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.utils.oplog import log_op
from app.utils.alert_entity_scope import month_period_clause
from app.utils.kpi_value_scope import actual_only_clause
from app.api.kpi_governance import validate_kpi_payload, kpi_issues_message
from app.utils.threshold_parser import (
calc_level as _tp_calc_level,
is_valid_threshold,
THRESHOLD_HELP,
)
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}之前)
# ============================================================
# ── 反向指标(值越低越好)· 单一事实源(single source of truth) ──
# 本表是「指标方向」的唯一权威定义(2026-09-15 M2 准入2 收口):凡按方向判级别/评分的代码
# 一律引用本表,禁止再复制字面量副本。
# - app/api/dashboard.py「工作台三色」原自带一份副本 → 已改为 from app.api.kpis import
# - app/api/bot_kpis.py 原 import 本表却未使用 → 已改为派生自本表
# 判定语义:在内 = 越低越好(实际/目标 ≤0.5 → 5 分);不在内 = 越高越好(比值 ≥1.2 → 5 分)
# 方向口径注意(两处「返补/返利」现金流方向相反,禁止误合并):
# - C_REBATE_RATE / F_REBATE_RATE = 渠道返补(对外付出,越低越好 → 反向,留在本表)
# - F_FACTORY_REBATE_RATE = 上游厂补率(厂补(捐赠收益)/主营成本,收到越多越好 → 正向,不属本表)
# 2026-09-15 修正:原被误列入本表 → 现网 278.7%(target_value=80)被判 1 分"失效";
# 与其三色阈值文本(green>=100=1.25×target / yellow>=80=target / red<80,越高越好)方向相反。
# P-6 定稿 = 改方向(阈值文本保持不动),修正后同一实测值判 5 分。
REVERSE_INDICATORS = ['C_REBATE_RATE', 'P_BUG_RATE', 'P_REWORK_PCT', 'F_DEBT_RATIO',
'F_COST_RATIO', 'F_AR_DAYS', 'F_REBATE_RATE',
'F_COST_CONTROL_RATE', 'F_INV_DAYS']
def _static_threshold_level(value, kpi) -> str:
"""按 KPI 静态阈值文本判定级别(统一解析器,判定顺序 red→green→yellow),无阈值返回 info。
供保本派生KPI(F_BEP_*)在详情页使用,与 alert_generator 语义一致:
解析与判定逻辑统一收敛到 app.utils.threshold_parser(单一事实源,2026-09-11 修复双实现+红灯遮蔽)。"""
return _tp_calc_level(value, kpi)
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)
val_query = val_query.filter(month_period_clause(KPIValue.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),
month_period_clause(KPIValue.period),
).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),
entity_id: int = Depends(get_entity_id),
):
"""KPI因果链追踪 — 返回单个KPI的上下游因果链(账套隔离 2026-09-21:跨账套 id → 404)"""
kpi = db.query(KPIDefinition).filter(
KPIDefinition.id == kpi_id,
KPIDefinition.entity_id == entity_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, month_period_clause(KPIValue.period)
).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 "",
# P0-① 三区标注: C区Bot回填时间
"updated_at": v.calculated_at.strftime("%Y-%m-%d %H:%M") if v.calculated_at else None,
} 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),
# 实测区口径:预估行禁入趋势序列(kpi_value_scope.actual_only_clause)
actual_only_clause(),
).order_by(KPIValue.period.asc()).all()
is_reverse = kpi.kpi_code in REVERSE_INDICATORS
target = kpi.target_value
# 保本派生KPI(F_BEP_*)使用静态阈值文本判定红黄绿(对齐 KPIList 预警tag语义),
# 不走 target 比率——保本线是参考值不是完成率目标(cma-bep-kpi 2026-09-08)
use_static_threshold = kpi.kpi_code.startswith("F_BEP_") and (
kpi.threshold_green or kpi.threshold_yellow or kpi.threshold_red)
result["values"] = []
for v in vals:
# 判定红黄绿(对齐工作台语义:正向≥0.9绿/≥0.7黄/否则红;反向≤目标绿/≤1.1倍黄/否则红)
level = "info"
score = None
if v.actual_value is not None:
if use_static_threshold:
level = _static_threshold_level(v.actual_value, kpi)
elif target:
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,
# P0-① 三区标注: C区Bot回填时间(UI BotSourceTag 消费)
"updated_at": v.calculated_at.strftime("%Y-%m-%d %H:%M") if v.calculated_at else None,
})
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
THRESHOLD_FIELDS = ("threshold_green", "threshold_yellow", "threshold_red")
def validate_threshold_format(data: dict) -> List[str]:
"""阈值表达式格式校验(2026-09-11):非法格式返回错误文本,让保存被拒而不是静默失效。
与 app.utils.threshold_parser 共用同一套语法(支持负数/区间 A~B/多条件 |)。
"""
errs: List[str] = []
for f in THRESHOLD_FIELDS:
if f in data and not is_valid_threshold(data.get(f)):
errs.append(f"阈值格式非法 [{f}={data.get(f)}]:{THRESHOLD_HELP}")
return errs
@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)
errs = errs + validate_threshold_format(data)
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)
# create 侧字段白名单(OpenCode 审查残留项修复 2026-09-07):
# 仿 update 白名单 + kpi_code(新建必填),拦截 status/important_flag/主键等敏感字段注入
# (update 2026-08-31 已有白名单;create 此前直接 KPIDefinition(**data) 展开,可被注入)
_create_allow = UPDATE_KPI_WHITELIST | {"kpi_code"}
for k in list(data.keys()):
if k != "entity_id" and k not in _create_allow:
data.pop(k, None)
kpi = KPIDefinition(**data)
db.add(kpi)
db.commit()
db.refresh(kpi)
_log(db, 1, "create", "kpi", kpi.id, data, entity_id=entity_id)
return kpi_to_dict(kpi)
# 可更新字段白名单(安全修复 2026-08-31):update_kpi 只允许更新业务属性。
# status / important_flag 等敏感字段及主键/归属字段(id/kpi_code/entity_id/map_id/created_*)一律忽略,
# 防越权修改(如借 update 篡改重要标记/上下架状态)。
UPDATE_KPI_WHITELIST = {
"kpi_name", "dimension", "objective", "formula", "formula_desc",
"data_source_type", "data_source_config", "data_source", "data_owner",
"frequency", "unit", "target_value", "target_monthly", "target_quarterly",
"target_yearly", "target_calc_type", "threshold_green", "threshold_yellow",
"threshold_red", "category", "data_level", "data_category",
"responsible_dept", "responsible_user", "kpi_level", "bot_source", "epic",
}
@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)
errs = errs + validate_threshold_format(data)
if errs:
raise HTTPException(422, detail={"message": "数据校验不通过", "errors": errs})
data.pop("entity_id", None) # 禁止通过update改企业归属
data = apply_calc_type_inference(data, infer_missing=False)
# 白名单过滤(安全修复 2026-08-31):白名单外字段(status/important_flag/主键等)忽略不修改
for k in list(data.keys()):
if k not in UPDATE_KPI_WHITELIST:
data.pop(k, None)
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, entity_id=entity_id)
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,
entity_id: int = Depends(get_entity_id)):
"""关联KPI到战略地图(账套隔离 2026-09-21:KPI 与地图均须属本账套,跨账套 → 404)"""
kpi = db.query(KPIDefinition).filter(
KPIDefinition.id == kpi_id,
KPIDefinition.entity_id == entity_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,
StrategicMap.entity_id == entity_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},
entity_id=entity_id)
return kpi_to_dict(kpi)
def _log(db, user_id, action, target_type, target_id, detail, entity_id=None):
"""统一走 log_op(2026-09-23 ④:审计写入统一带 `detail.entity_id`)"""
return log_op(db, action, target_type=target_type, target_id=target_id,
detail=detail, user_id=user_id, entity_id=entity_id, commit=True)
@router.get("/{kpi_id}/objectives")
def get_kpi_objectives(kpi_id: int, db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id)):
"""查看KPI所属的目标和战略地图(账套隔离 2026-09-21:跨账套 id → 404)
鉴权口径:本 router 级 `dependencies=[Depends(require_role(...))]` 已覆盖本端点
(无 token → 401),此处不再重复挂 require_auth,只补缺失的账套收口。
"""
kpi = db.query(KPIDefinition).filter(
KPIDefinition.id == kpi_id, KPIDefinition.entity_id == entity_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, StrategicMap.entity_id == entity_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.entity_id == entity_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