Files
cma-management/backend/app/api/okr_templates.py
T
wecom-fullstack 0c2e57a4cf fix(cma): 工作台跨实体污染治本 — 全局键拆账套 + 缺列收口 + 预警归属兜底
任务 dama-workbench-entity-isolation-fullstack-20260922(DAMA 诊断 / 项目Bot batch5 §B / 任总 2026-09-22「都批」)

B1 现金基线拆账套(零 DDL)
- cash_forecast_engine: 键 cash.current_balance → cash.current_balance.<entity_id>;
  读只读本账套键、不做旧键回退;set_* 签名 entity_id 必传(漏传 TypeError)
- cash.py: POST /balance 挂 get_entity_id;网银流水联动写回带账套
B3(a) alignment_config 拆账套(零 DDL)
- alignment.py: GET/POST /config、GET /tree 只读 alignment_config.<entity_id>;未配置=configured:false
B3(b) 预警 KPI 归属兜底
- bot_bridge_v2: 去掉 kpi_id or 1 兜底;本账套无 KPI 定义 → 跳过建预警 + OperationLog 留痕
  (kpi_alerts.kpi_id NOT NULL ⇒ 方案A 置 NULL 不可行,走方案B 跳过+审计)
B2 缺列五表分表处置
- notification_logs: 经 alert_id → kpi_alerts.entity_id join 归属(无父键行不返回)
- notification_channels / okr_templates: 集团共享口径写入 docstring(有意不过滤)
- system_configs: 由 B1/B3(a) 拆键覆盖;kpi_data_quality_logs 沿用 batch5 的 join 收口
P0 语义收敛
- tenant.py: current_tenant 收敛为项目Bot A2A 状态记录,GET 仅 it/ceo;POST 补 operation_logs 审计
证据:新增/改写用例 22 条;全量 1370 passed(基线 1348)
2026-09-22 11:06:02 +08:00

336 lines
13 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 — 按维度筛选 + 用户自定义模板
⚠️ 集团级共享口径(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,
}