- 模型+DB: okr_templates.owner (CMA标准库/行业包/用户自定义) - API: create同名去重(409) + list/get返回owner + apply透传metric_kpi - 前端: 模板卡片展示owner标签 - 文档: kpi-dictionary-gap-list.md(33条缺口+建议补KPI) + okr-template-calibration.md(酣客/博海校准表) - 回归: 模板API验证通过
217 lines
7.2 KiB
Python
217 lines
7.2 KiB
Python
"""
|
||
OKR模板库 API — 按维度筛选 + 用户自定义模板
|
||
"""
|
||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||
from sqlalchemy.orm import Session
|
||
from typing import Optional
|
||
from app.database import get_db
|
||
from app.auth_middleware import require_role
|
||
from app.models import OKRTemplate, StrategicMap
|
||
|
||
router = APIRouter(prefix="/api/cma/okr-templates", tags=["OKR模板库"],
|
||
dependencies=[Depends(require_role("ceo", "finance", "it"))],
|
||
)
|
||
|
||
|
||
DIMENSION_LAYER_NAMES = {
|
||
"finance": "财务层",
|
||
"customer": "客户层",
|
||
"process": "内部流程层",
|
||
"learning": "学习成长层",
|
||
}
|
||
|
||
DIMENSION_LAYER_ICONS = {
|
||
"finance": "💰",
|
||
"customer": "👥",
|
||
"process": "⚙️",
|
||
"learning": "📚",
|
||
}
|
||
|
||
DIMENSION_LAYER_COLORS = {
|
||
"finance": "#F56C6C",
|
||
"customer": "#409EFF",
|
||
"process": "#67C23A",
|
||
"learning": "#E6A23C",
|
||
}
|
||
|
||
@router.get("")
|
||
def list_okr_templates(
|
||
dimension: Optional[str] = Query(None, description="按维度筛选: finance/customer/process/learning"),
|
||
source: Optional[str] = Query(None, description="按来源筛选: system/user/industry_pack"),
|
||
industry_tag: Optional[str] = Query(None, description="按行业标签筛选"),
|
||
search: Optional[str] = Query(None, description="按O名称关键词搜索"),
|
||
active_only: bool = Query(True, description="仅返回启用模板"),
|
||
db: Session = Depends(get_db),
|
||
):
|
||
"""列出 OKR 模板,支持按维度/来源/行业/名称搜索"""
|
||
q = db.query(OKRTemplate)
|
||
if dimension:
|
||
q = q.filter(OKRTemplate.dimension == dimension)
|
||
if source:
|
||
q = q.filter(OKRTemplate.source == source)
|
||
if industry_tag:
|
||
q = q.filter(OKRTemplate.industry_tag == industry_tag)
|
||
if search:
|
||
q = q.filter(OKRTemplate.name.like(f"%{search}%"))
|
||
if active_only:
|
||
q = q.filter(OKRTemplate.is_active == 1)
|
||
templates = q.order_by(OKRTemplate.sort_order, OKRTemplate.id).all()
|
||
return {
|
||
"total": len(templates),
|
||
"items": [
|
||
{
|
||
"id": t.id,
|
||
"name": t.name,
|
||
"description": t.description,
|
||
"dimension": t.dimension,
|
||
"layer": t.layer,
|
||
"industry_tag": t.industry_tag,
|
||
"preset_krs": t.preset_krs,
|
||
"source": t.source,
|
||
"owner": t.owner,
|
||
"use_count": t.use_count,
|
||
"sort_order": t.sort_order,
|
||
"is_active": t.is_active,
|
||
"created_at": t.created_at.isoformat() if t.created_at else None,
|
||
}
|
||
for t in templates
|
||
],
|
||
}
|
||
|
||
|
||
@router.get("/{template_id}")
|
||
def get_okr_template(template_id: int, db: Session = Depends(get_db)):
|
||
"""获取单个 OKR 模板详情"""
|
||
t = db.query(OKRTemplate).filter(OKRTemplate.id == template_id).first()
|
||
if not t:
|
||
raise HTTPException(404, "模板不存在")
|
||
return {
|
||
"id": t.id,
|
||
"name": t.name,
|
||
"description": t.description,
|
||
"dimension": t.dimension,
|
||
"layer": t.layer,
|
||
"industry_tag": t.industry_tag,
|
||
"preset_krs": t.preset_krs,
|
||
"source": t.source,
|
||
"owner": t.owner,
|
||
"use_count": t.use_count,
|
||
"sort_order": t.sort_order,
|
||
"is_active": t.is_active,
|
||
}
|
||
|
||
|
||
@router.post("")
|
||
def create_okr_template(data: dict, db: Session = Depends(get_db)):
|
||
"""用户自定义 OKR 模板(治理: 同名去重 + owner 标记)"""
|
||
name = data.get("name", "").strip()
|
||
if not name:
|
||
raise HTTPException(400, "模板名称不能为空")
|
||
dimension = data.get("dimension", "")
|
||
if dimension not in ("finance", "customer", "process", "learning"):
|
||
raise HTTPException(400, "维度无效,必须是 finance/customer/process/learning")
|
||
preset_krs = data.get("preset_krs", [])
|
||
if not isinstance(preset_krs, list) or len(preset_krs) == 0:
|
||
raise HTTPException(400, "至少需要一个预设KR")
|
||
# P2治理: 同名+同维度去重(防止 id=33 式重复模板,2026-08-21)
|
||
dup = db.query(OKRTemplate).filter(
|
||
OKRTemplate.name == name,
|
||
OKRTemplate.dimension == dimension,
|
||
OKRTemplate.is_active == 1,
|
||
).first()
|
||
if dup:
|
||
raise HTTPException(409, f"已存在同名模板「{name}」(#{dup.id}, source={dup.source}),请改用现有模板或改名")
|
||
|
||
t = OKRTemplate(
|
||
name=name,
|
||
description=data.get("description", ""),
|
||
dimension=dimension,
|
||
layer=data.get("layer", "level3"),
|
||
industry_tag=data.get("industry_tag", "general"),
|
||
preset_krs=preset_krs,
|
||
source="user",
|
||
owner="用户自定义",
|
||
sort_order=data.get("sort_order", 0),
|
||
)
|
||
db.add(t)
|
||
db.commit()
|
||
db.refresh(t)
|
||
return {"ok": True, "id": t.id, "name": t.name}
|
||
|
||
|
||
@router.post("/{template_id}/use")
|
||
def increment_use_count(template_id: int, db: Session = Depends(get_db)):
|
||
"""增加模板使用次数"""
|
||
t = db.query(OKRTemplate).filter(OKRTemplate.id == template_id).first()
|
||
if not t:
|
||
raise HTTPException(404, "模板不存在")
|
||
t.use_count = (t.use_count or 0) + 1
|
||
db.commit()
|
||
return {"ok": True, "use_count": t.use_count}
|
||
|
||
|
||
@router.post("/{template_id}/apply")
|
||
def apply_okr_template(template_id: int, data: dict, db: Session = Depends(get_db)):
|
||
"""应用 OKR 模板 — 创建战略地图并填入 O+KR"""
|
||
t = db.query(OKRTemplate).filter(OKRTemplate.id == template_id).first()
|
||
if not t:
|
||
raise HTTPException(404, "模板不存在")
|
||
|
||
map_title = data.get("title", t.name)
|
||
dim = t.dimension
|
||
preset_krs = t.preset_krs or []
|
||
|
||
# 构建 dimensions: 仅包含模板所在的维度层
|
||
dimensions = []
|
||
for dk in ("finance", "customer", "process", "learning"):
|
||
objectives = []
|
||
if dk == dim:
|
||
objectives.append({
|
||
"name": t.name,
|
||
"description": t.description or "",
|
||
"kpis": [],
|
||
"krs": [
|
||
{
|
||
"name": kr.get("name", ""),
|
||
"target_value": kr.get("target_value", ""),
|
||
"weight": kr.get("weight", 33),
|
||
"metric_kpi_id": kr.get("metric_kpi_id"),
|
||
"metric_kpi_code": kr.get("metric_kpi_code"),
|
||
}
|
||
for kr in preset_krs
|
||
],
|
||
})
|
||
dimensions.append({
|
||
"key": dk,
|
||
"name": DIMENSION_LAYER_NAMES.get(dk, dk),
|
||
"icon": DIMENSION_LAYER_ICONS.get(dk, "📌"),
|
||
"color": DIMENSION_LAYER_COLORS.get(dk, "#909399"),
|
||
"objectives": objectives,
|
||
})
|
||
|
||
m = StrategicMap(
|
||
title=map_title,
|
||
version=data.get("version", "v1.0"),
|
||
status="draft",
|
||
dimensions=dimensions,
|
||
canvas_data={"connections": []},
|
||
)
|
||
db.add(m)
|
||
db.commit()
|
||
db.refresh(m)
|
||
|
||
# 使用 maps API 的 _sync_map_objectives 同步到 map_objectives 表
|
||
from app.api.maps import _sync_map_objectives
|
||
_sync_map_objectives(m, db)
|
||
|
||
# 增加模板使用次数
|
||
t.use_count = (t.use_count or 0) + 1
|
||
db.commit()
|
||
|
||
return {
|
||
"ok": True,
|
||
"map_id": m.id,
|
||
"title": m.title,
|
||
"template_id": template_id,
|
||
}
|