任务 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)
336 lines
13 KiB
Python
336 lines
13 KiB
Python
"""
|
||
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,
|
||
}
|