Files
cma-management/backend/app/api/okr_templates.py
T
Hermes CI Fix a0762b367f fix(cma): entity isolation batch4 (cash plan writes, budget ownership, read leaks, okr template)
P0-A cash.py: scope api_update_plan / api_delete_plan / api_complete_plan /
api_register_payment to session entity (cross-entity id -> 404, aligning with
the file's existing get_entity_id endpoints).
P0-B budget.py: write entity_id on BudgetPlan creation + require the KPI to
belong to the session entity; scope /comparison, /comparison/kpi/{id} and
/deviation-report reads to the session entity.
P1 notifications.py: explicit field whitelist (name/config/enabled) on
update_channel to close mass-assignment.
P1 read leaks scoped to session entity: bot_kpis list, data_quality check/stats
(KPI calibre only; quality-log table has no entity_id column), kpis causality
chain + associate-map (KPI and map ownership), dashboard my-kpis,
customer_dashboard list, kpi_governance audit, budget_generate candidates,
driver_budget history (+ entity_id on its write path), expenses stats
(+ entity_id on its write path).
P2 okr_templates.py: write entity_id on the StrategicMap created by apply.

tests: tests/test_cross_entity_isolation_batch4_20260921.py (30 cases;
RED before fix = 22 failed, GREEN after = 30 passed; full suite 1302 passed /
1 xfailed / 0 failed).
2026-09-21 09:37:38 +08:00

323 lines
12 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.
"""
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.deps import get_entity_id
from app.auth_middleware import require_role
from app.models import OKRTemplate, StrategicMap
from app.utils.kr_name import sanitize_kr_name
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",
}
# ── tags 多值标签(辅助检索;主分类仍为 dimension 四泳道)2026-09-11 ──
TAGS_SCHEMA = {
"function": ["销售", "财务", "生产", "HR", "研发", "客服", "供应链"],
"cycle": ["年度", "季度", "月度"],
"goal_type": ["增长", "效率", "合规", "创新", "转型"],
}
def normalize_tags(raw) -> Optional[dict]:
"""校验 + 规范化 tags(固定枚举,防随意值);空值返回 None"""
if raw is None:
return None
if not isinstance(raw, dict):
raise HTTPException(400, "tags 必须是对象,如 {\"function\":[\"销售\"],\"cycle\":[\"季度\"],\"goal_type\":[\"增长\"]}")
out = {}
for key, enum in TAGS_SCHEMA.items():
vals = raw.get(key)
if vals is None:
continue
if isinstance(vals, str):
vals = [v.strip() for v in vals.split(",") if v.strip()]
if not isinstance(vals, list):
raise HTTPException(400, f"tags.{key} 必须是数组")
bad = [v for v in vals if v not in enum]
if bad:
raise HTTPException(400, f"tags.{key} 含非法值 {bad},允许值: {enum}")
if vals:
out[key] = vals
return out or None
def _split_filter(val: Optional[str]):
if not val:
return []
return [v.strip() for v in val.split(",") if v.strip()]
def _match_tags(t, function=None, cycle=None, goal_type=None, tag=None) -> bool:
"""标签过滤(多值 OR 语义:任一命中即算匹配;跨字段为 AND)"""
tags = t.tags or {}
for key, val in (("function", function), ("cycle", cycle), ("goal_type", goal_type)):
wanted = _split_filter(val)
if not wanted:
continue
have = tags.get(key) or []
if not any(w in have for w in wanted):
return False
if tag:
all_vals = set()
for k in TAGS_SCHEMA:
all_vals.update(tags.get(k) or [])
if tag not in all_vals:
return False
return True
def _tmpl_dict(t, with_created=False):
d = {
"id": t.id,
"name": t.name,
"description": t.description,
"dimension": t.dimension,
"layer": t.layer,
"industry_tag": t.industry_tag,
"tags": t.tags,
"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,
}
if with_created:
d["created_at"] = t.created_at.isoformat() if t.created_at else None
return d
@router.get("/tags/schema")
def get_tags_schema():
"""tags 标签体系(固定枚举)— 供前端下拉动态加载"""
return {"schema": TAGS_SCHEMA}
@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名称关键词搜索"),
function: Optional[str] = Query(None, description="按职能标签筛选: 销售/财务/生产/HR/研发/客服/供应链(逗号分隔多值)"),
cycle: Optional[str] = Query(None, description="按周期标签筛选: 年度/季度/月度"),
goal_type: Optional[str] = Query(None, description="按目标类型筛选: 增长/效率/合规/创新/转型"),
tag: Optional[str] = Query(None, description="任意标签值筛选(跨 function/cycle/goal_type)"),
active_only: bool = Query(True, description="仅返回启用模板"),
db: Session = Depends(get_db),
):
"""列出 OKR 模板,支持按维度/来源/行业/名称搜索 + tags 多值标签过滤"""
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()
# tags 过滤在 Python 层完成(JSON 多值 OR 语义 + 兼容 SQLite 测试库)
if function or cycle or goal_type or tag:
templates = [t for t in templates if _match_tags(t, function, cycle, goal_type, tag)]
return {
"total": len(templates),
"items": [_tmpl_dict(t, with_created=True) 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 _tmpl_dict(t, with_created=True)
@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"),
tags=normalize_tags(data.get("tags")),
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, "tags": t.tags}
@router.put("/{template_id}")
def update_okr_template(template_id: int, data: dict, db: Session = Depends(get_db)):
"""更新 OKR 模板(含 tags 多值标签;仅传需要变更的字段)"""
t = db.query(OKRTemplate).filter(OKRTemplate.id == template_id).first()
if not t:
raise HTTPException(404, "模板不存在")
if "name" in data:
name = (data.get("name") or "").strip()
if not name:
raise HTTPException(400, "模板名称不能为空")
t.name = name
if "description" in data:
t.description = data.get("description") or ""
if "dimension" in data:
dimension = data.get("dimension")
if dimension not in ("finance", "customer", "process", "learning"):
raise HTTPException(400, "维度无效,必须是 finance/customer/process/learning")
t.dimension = dimension
if "layer" in data:
t.layer = data.get("layer")
if "industry_tag" in data:
t.industry_tag = data.get("industry_tag")
if "preset_krs" in data:
preset_krs = data.get("preset_krs")
if not isinstance(preset_krs, list) or len(preset_krs) == 0:
raise HTTPException(400, "至少需要一个预设KR")
t.preset_krs = preset_krs
if "sort_order" in data:
t.sort_order = data.get("sort_order")
if "is_active" in data:
t.is_active = data.get("is_active")
if "tags" in data:
t.tags = normalize_tags(data.get("tags"))
db.commit()
db.refresh(t)
return {"ok": True, "id": t.id, "name": t.name, "tags": t.tags}
@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),
entity_id: int = Depends(get_entity_id)):
"""应用 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": [
{
# 护栏(2026-09-16):模板名 → 新地图 KR 名 透传点,污染名(尾部符号+截断数字)在此规整
"name": sanitize_kr_name(kr.get("name", ""), kr.get("target_value"))[0],
"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(
entity_id=entity_id, # 账套隔离 2026-09-21:原漏写→落模型默认 1(对齐 maps.py)
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,
}