根因: GET /kpis/{kpi_id} 只返回KPI定义信息, 未附带历史值
前端期望 kv.values(图表+表格数据) → 拿到undefined → Tab空白
修复: get_kpi 查询该KPI的kpi_values(按期间升序), 附带
values[{period, actual_value, source_type, data_status, source_batch}]
验证: F_REVENUE 10条(2026-01~08+), 渠补率9条
751 lines
30 KiB
Python
751 lines
30 KiB
Python
"""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
|
||
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',
|
||
'P_QUALITY_RATE', 'F_COST_RATIO', 'F_INTEREST_COVER', 'F_QUICK_RATIO']
|
||
|
||
|
||
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.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空白
|
||
vals = db.query(KPIValue).filter(
|
||
KPIValue.kpi_id == kpi_id,
|
||
KPIValue.actual_value.isnot(None),
|
||
).order_by(KPIValue.period.asc()).all()
|
||
result["values"] = [
|
||
{
|
||
"id": v.id,
|
||
"period": v.period,
|
||
"actual_value": v.actual_value,
|
||
"source_type": v.source_type,
|
||
"data_status": v.data_status,
|
||
"source_batch": v.source_batch,
|
||
}
|
||
for v in vals
|
||
]
|
||
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
|