""" OKR模板库 API — 按维度筛选 + 用户自定义模板 ⚠️ 集团级共享口径(2026-09-22 dama-workbench-entity-isolation-fullstack-20260922,§四 B2) ------------------------------------------------------------------------------------ `okr_templates` 表**无 entity_id 列**,且属**模板/配置类**(无父键可 join 桥接)。 任总 2026-09-22「都批」已按 **方案 1(零 DDL)** 拍板: **保持全局 = 集团级共享配置** —— 模板库是集团知识资产,一套模板(维度/行业标签/ 目标+KR 骨架)供全账套复用;账套差异体现在**使用模板生成的具体目标/KR 行** (`objectives` / `map_objectives` 均带 entity_id,已隔离),而不是模板本身。 因此本模块的列表/详情/新增/改/删**有意不按账套过滤**——这是口径,不是漏隔离。 若后续确需「账套私有模板」,须单独立单加 `entity_id` 列(DDL + 回填 + 唯一键 `name+dimension` 改造),本单不预设。 """ 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, }