Files
Hermes CI Fix 0d90a8e8f3 test: pytest测试框架 + API测试
- 测试框架:SQLite内存数据库,每个测试自动建表/清理
- auth测试:登录/注册/token验证(6个用例)
- kpis测试:增删改查+重复编码检查(6个用例)
- maps测试:创建/编辑/发布/连线/同维度限制(6个用例)
- 后端修复:KPI创建时检查编码唯一性(原为数据库抛500)
2026-05-28 17:42:15 +08:00

174 lines
6.4 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.auth_middleware import require_auth, require_role, filter_kpis_by_role, kpi_visible_dims
from app.models import StrategicMap, MapObjective, KPIDefinition, KPIValue, KPIAlert, OperationLog
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,
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:
query = query.filter(KPIDefinition.dimension == dimension)
if keyword:
query = query.filter(KPIDefinition.kpi_name.contains(keyword))
if epic:
query = query.filter(KPIDefinition.epic == epic)
if category:
query = query.filter(KPIDefinition.category == category)
total = query.count()
kpis = query.order_by(KPIDefinition.kpi_code).offset((page-1)*page_size).limit(page_size).all()
return {"total": total, "page": page, "page_size": page_size, "data": [kpi_to_dict(k) for k in kpis]}
@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())}
@router.get("/{kpi_id}")
def get_kpi(kpi_id: int, db: Session = Depends(get_db)):
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
if not kpi:
raise HTTPException(404, "KPI不存在")
return kpi_to_dict(kpi)
@router.post("")
def create_kpi(data: dict, db: Session = Depends(get_db), user=WRITE_ROLES):
# 检查编码唯一性
existing = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == data.get("kpi_code", "")).first()
if existing:
raise HTTPException(400, f"KPI编码 {data['kpi_code']} 已存在")
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):
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
if not kpi:
raise HTTPException(404, "KPI不存在")
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):
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
if kpi:
kpi.status = "disabled"
db.commit()
return {"message": "已删除"}
def kpi_to_dict(k):
return {c.name: getattr(k, c.name) for c in k.__table__.columns}
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