feat(okr): OKR模板库 tags 多值标签 — 表结构+回填36模板+API过滤 (fullstack-okr-tags-20260911)
- okr_templates 新增 tags JSON 字段(function/cycle/goal_type 固定枚举) - 回填脚本幂等:36/36 模板 tags 补齐,二次运行跳过 - API: GET /okr-templates 支持 function/cycle/goal_type/tag 过滤,新增 GET /tags/schema - API: POST 创建 + 新增 PUT 更新接受 tags(非法值 400) - dimension 四泳道主分类路径不变(回归 11/8/9/8 一致)
This commit is contained in:
@@ -34,16 +34,103 @@ DIMENSION_LAYER_COLORS = {
|
||||
"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 模板,支持按维度/来源/行业/名称搜索"""
|
||||
"""列出 OKR 模板,支持按维度/来源/行业/名称搜索 + tags 多值标签过滤"""
|
||||
q = db.query(OKRTemplate)
|
||||
if dimension:
|
||||
q = q.filter(OKRTemplate.dimension == dimension)
|
||||
@@ -56,26 +143,12 @@ def list_okr_templates(
|
||||
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": [
|
||||
{
|
||||
"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
|
||||
],
|
||||
"items": [_tmpl_dict(t, with_created=True) for t in templates],
|
||||
}
|
||||
|
||||
|
||||
@@ -85,20 +158,7 @@ def get_okr_template(template_id: int, db: Session = Depends(get_db)):
|
||||
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,
|
||||
}
|
||||
return _tmpl_dict(t, with_created=True)
|
||||
|
||||
|
||||
@router.post("")
|
||||
@@ -128,6 +188,7 @@ def create_okr_template(data: dict, db: Session = Depends(get_db)):
|
||||
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="用户自定义",
|
||||
@@ -136,7 +197,47 @@ def create_okr_template(data: dict, db: Session = Depends(get_db)):
|
||||
db.add(t)
|
||||
db.commit()
|
||||
db.refresh(t)
|
||||
return {"ok": True, "id": t.id, "name": t.name}
|
||||
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")
|
||||
|
||||
@@ -460,6 +460,7 @@ class OKRTemplate(Base):
|
||||
dimension = Column(String(20), nullable=False, comment="finance/customer/process/learning")
|
||||
layer = Column(String(20), default="level1", comment="level1/level2/level3")
|
||||
industry_tag = Column(String(50), default="general", comment="行业标签")
|
||||
tags = Column(JSON, nullable=True, comment="多值标签 function/cycle/goal_type(辅助检索,主分类仍为 dimension)")
|
||||
preset_krs = Column(JSON, nullable=False, comment="预设关键结果列表")
|
||||
source = Column(String(20), default="system", comment="system/user/industry_pack")
|
||||
owner = Column(String(50), default="CMA标准库", comment="模板负责人(治理P2: 可追踪)")
|
||||
|
||||
Reference in New Issue
Block a user