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",
|
"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("")
|
@router.get("")
|
||||||
def list_okr_templates(
|
def list_okr_templates(
|
||||||
dimension: Optional[str] = Query(None, description="按维度筛选: finance/customer/process/learning"),
|
dimension: Optional[str] = Query(None, description="按维度筛选: finance/customer/process/learning"),
|
||||||
source: Optional[str] = Query(None, description="按来源筛选: system/user/industry_pack"),
|
source: Optional[str] = Query(None, description="按来源筛选: system/user/industry_pack"),
|
||||||
industry_tag: Optional[str] = Query(None, description="按行业标签筛选"),
|
industry_tag: Optional[str] = Query(None, description="按行业标签筛选"),
|
||||||
search: Optional[str] = Query(None, description="按O名称关键词搜索"),
|
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="仅返回启用模板"),
|
active_only: bool = Query(True, description="仅返回启用模板"),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""列出 OKR 模板,支持按维度/来源/行业/名称搜索"""
|
"""列出 OKR 模板,支持按维度/来源/行业/名称搜索 + tags 多值标签过滤"""
|
||||||
q = db.query(OKRTemplate)
|
q = db.query(OKRTemplate)
|
||||||
if dimension:
|
if dimension:
|
||||||
q = q.filter(OKRTemplate.dimension == dimension)
|
q = q.filter(OKRTemplate.dimension == dimension)
|
||||||
@@ -56,26 +143,12 @@ def list_okr_templates(
|
|||||||
if active_only:
|
if active_only:
|
||||||
q = q.filter(OKRTemplate.is_active == 1)
|
q = q.filter(OKRTemplate.is_active == 1)
|
||||||
templates = q.order_by(OKRTemplate.sort_order, OKRTemplate.id).all()
|
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 {
|
return {
|
||||||
"total": len(templates),
|
"total": len(templates),
|
||||||
"items": [
|
"items": [_tmpl_dict(t, with_created=True) for t in templates],
|
||||||
{
|
|
||||||
"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
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -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()
|
t = db.query(OKRTemplate).filter(OKRTemplate.id == template_id).first()
|
||||||
if not t:
|
if not t:
|
||||||
raise HTTPException(404, "模板不存在")
|
raise HTTPException(404, "模板不存在")
|
||||||
return {
|
return _tmpl_dict(t, with_created=True)
|
||||||
"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,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("")
|
@router.post("")
|
||||||
@@ -128,6 +188,7 @@ def create_okr_template(data: dict, db: Session = Depends(get_db)):
|
|||||||
dimension=dimension,
|
dimension=dimension,
|
||||||
layer=data.get("layer", "level3"),
|
layer=data.get("layer", "level3"),
|
||||||
industry_tag=data.get("industry_tag", "general"),
|
industry_tag=data.get("industry_tag", "general"),
|
||||||
|
tags=normalize_tags(data.get("tags")),
|
||||||
preset_krs=preset_krs,
|
preset_krs=preset_krs,
|
||||||
source="user",
|
source="user",
|
||||||
owner="用户自定义",
|
owner="用户自定义",
|
||||||
@@ -136,7 +197,47 @@ def create_okr_template(data: dict, db: Session = Depends(get_db)):
|
|||||||
db.add(t)
|
db.add(t)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(t)
|
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")
|
@router.post("/{template_id}/use")
|
||||||
|
|||||||
@@ -460,6 +460,7 @@ class OKRTemplate(Base):
|
|||||||
dimension = Column(String(20), nullable=False, comment="finance/customer/process/learning")
|
dimension = Column(String(20), nullable=False, comment="finance/customer/process/learning")
|
||||||
layer = Column(String(20), default="level1", comment="level1/level2/level3")
|
layer = Column(String(20), default="level1", comment="level1/level2/level3")
|
||||||
industry_tag = Column(String(50), default="general", comment="行业标签")
|
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="预设关键结果列表")
|
preset_krs = Column(JSON, nullable=False, comment="预设关键结果列表")
|
||||||
source = Column(String(20), default="system", comment="system/user/industry_pack")
|
source = Column(String(20), default="system", comment="system/user/industry_pack")
|
||||||
owner = Column(String(50), default="CMA标准库", comment="模板负责人(治理P2: 可追踪)")
|
owner = Column(String(50), default="CMA标准库", comment="模板负责人(治理P2: 可追踪)")
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
OKR 模板库 — tags 多值标签回填(幂等)
|
||||||
|
|
||||||
|
任务: fullstack-okr-tags-20260911
|
||||||
|
标签体系(固定枚举,防随意值):
|
||||||
|
function : 销售/财务/生产/HR/研发/客服/供应链
|
||||||
|
cycle : 年度/季度/月度
|
||||||
|
goal_type: 增长/效率/合规/创新/转型
|
||||||
|
|
||||||
|
策略:
|
||||||
|
- 按模板 name 显式映射(36 个现役模板,语义推断)
|
||||||
|
- 未命中映射的新模板 → 关键词规则 + dimension 兜底(保守值,不留空)
|
||||||
|
- 幂等: 已有非空 tags 的行默认跳过(--force 可覆盖重算)
|
||||||
|
|
||||||
|
用法:
|
||||||
|
python3 backend/scripts/backfill_okr_template_tags.py # 回填(跳过已填)
|
||||||
|
python3 backend/scripts/backfill_okr_template_tags.py --dry-run # 只看计划
|
||||||
|
python3 backend/scripts/backfill_okr_template_tags.py --force # 全量重算覆盖
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."))
|
||||||
|
|
||||||
|
from app.database import get_session_local # noqa: E402
|
||||||
|
from app.models import OKRTemplate # noqa: E402
|
||||||
|
|
||||||
|
FUNCTION_ENUM = ["销售", "财务", "生产", "HR", "研发", "客服", "供应链"]
|
||||||
|
CYCLE_ENUM = ["年度", "季度", "月度"]
|
||||||
|
GOAL_TYPE_ENUM = ["增长", "效率", "合规", "创新", "转型"]
|
||||||
|
|
||||||
|
# ── 显式映射(按模板名,语义推断;保守:不确定的取最宽值) ──
|
||||||
|
NAME_TAGS = {
|
||||||
|
# 财务层 level1
|
||||||
|
"扩大收入规模": {"function": ["销售", "财务"], "cycle": ["年度"], "goal_type": ["增长"]},
|
||||||
|
"优化成本结构": {"function": ["财务", "生产"], "cycle": ["年度"], "goal_type": ["效率"]},
|
||||||
|
"提升资产效率": {"function": ["财务"], "cycle": ["年度"], "goal_type": ["效率"]},
|
||||||
|
"保障现金流安全": {"function": ["财务"], "cycle": ["年度"], "goal_type": ["合规"]},
|
||||||
|
# 客户层 level1
|
||||||
|
"产品领先": {"function": ["研发"], "cycle": ["年度"], "goal_type": ["创新"]},
|
||||||
|
"客户亲密": {"function": ["销售", "客服"], "cycle": ["年度"], "goal_type": ["增长"]},
|
||||||
|
"卓越运营": {"function": ["销售", "客服"], "cycle": ["年度"], "goal_type": ["效率"]},
|
||||||
|
# 流程层 level1
|
||||||
|
"运营高效顺畅": {"function": ["供应链", "生产"], "cycle": ["年度"], "goal_type": ["效率"]},
|
||||||
|
"客户管理有序": {"function": ["客服", "销售"], "cycle": ["年度"], "goal_type": ["效率"]},
|
||||||
|
"创新驱动增长": {"function": ["研发"], "cycle": ["年度"], "goal_type": ["创新", "增长"]},
|
||||||
|
"合规稳健运营": {"function": ["财务"], "cycle": ["年度"], "goal_type": ["合规"]},
|
||||||
|
# 学习成长层 level1
|
||||||
|
"团队能力升级": {"function": ["HR"], "cycle": ["年度"], "goal_type": ["效率"]},
|
||||||
|
"数字系统赋能": {"function": ["研发"], "cycle": ["年度"], "goal_type": ["转型"]},
|
||||||
|
"组织协同对齐": {"function": ["HR"], "cycle": ["年度"], "goal_type": ["效率"]},
|
||||||
|
# 行业包 trading
|
||||||
|
"优化渠补结构": {"function": ["销售", "财务"], "cycle": ["季度"], "goal_type": ["效率"]},
|
||||||
|
"加速应收周转": {"function": ["财务", "销售"], "cycle": ["季度"], "goal_type": ["效率"]},
|
||||||
|
"建立渠道分级体系": {"function": ["销售"], "cycle": ["季度"], "goal_type": ["效率"]},
|
||||||
|
"提升核心渠道忠诚度": {"function": ["销售"], "cycle": ["季度"], "goal_type": ["增长"]},
|
||||||
|
"建立渠补谈判SOP": {"function": ["销售"], "cycle": ["季度"], "goal_type": ["合规"]},
|
||||||
|
"进销存系统全覆盖": {"function": ["供应链"], "cycle": ["季度"], "goal_type": ["转型"]},
|
||||||
|
"渠道赋能培训": {"function": ["销售", "HR"], "cycle": ["季度"], "goal_type": ["效率"]},
|
||||||
|
# 行业包 it_service
|
||||||
|
"提升人均产值": {"function": ["研发", "财务"], "cycle": ["年度"], "goal_type": ["效率"]},
|
||||||
|
"提高NPS净推荐值": {"function": ["客服"], "cycle": ["年度"], "goal_type": ["增长"]},
|
||||||
|
"交付标准化": {"function": ["研发"], "cycle": ["年度"], "goal_type": ["效率"]},
|
||||||
|
"知识管理体系化": {"function": ["研发", "HR"], "cycle": ["年度"], "goal_type": ["效率"]},
|
||||||
|
"工程师认证体系": {"function": ["HR", "研发"], "cycle": ["年度"], "goal_type": ["效率"]},
|
||||||
|
# 行业包 manufacturing
|
||||||
|
"提升产能利用率": {"function": ["生产"], "cycle": ["季度"], "goal_type": ["效率"]},
|
||||||
|
"降低生产成本": {"function": ["生产", "财务"], "cycle": ["年度"], "goal_type": ["效率"]},
|
||||||
|
"提升产品一次合格率": {"function": ["生产"], "cycle": ["季度"], "goal_type": ["效率"]},
|
||||||
|
"缩短交付周期": {"function": ["生产", "供应链"], "cycle": ["季度"], "goal_type": ["效率"]},
|
||||||
|
"设备智能化升级": {"function": ["生产", "研发"], "cycle": ["年度"], "goal_type": ["转型"]},
|
||||||
|
"安全生产零事故": {"function": ["生产"], "cycle": ["年度"], "goal_type": ["合规"]},
|
||||||
|
# 用户自建模板
|
||||||
|
"营收目标": {"function": ["销售", "财务"], "cycle": ["年度"], "goal_type": ["增长"]},
|
||||||
|
"营收增长": {"function": ["销售", "财务"], "cycle": ["年度"], "goal_type": ["增长"]},
|
||||||
|
"成本与现金流": {"function": ["财务"], "cycle": ["年度"], "goal_type": ["效率"]},
|
||||||
|
"客户拓展": {"function": ["销售", "客服"], "cycle": ["年度"], "goal_type": ["增长"]},
|
||||||
|
}
|
||||||
|
|
||||||
|
# 关键词规则(未显式命中的模板兜底)
|
||||||
|
KEYWORD_RULES = [
|
||||||
|
(["销售", "营收", "收入", "客户", "渠道", "回款", "应收"], "function", "销售"),
|
||||||
|
(["成本", "费用", "现金流", "资金", "税", "财务", "利润", "产值"], "function", "财务"),
|
||||||
|
(["生产", "产能", "合格率", "设备", "制造", "安全"], "function", "生产"),
|
||||||
|
(["人才", "培训", "认证", "组织", "文化", "能力"], "function", "HR"),
|
||||||
|
(["研发", "产品", "技术", "系统", "数字", "创新", "知识"], "function", "研发"),
|
||||||
|
(["服务", "客服", "NPS", "满意度", "投诉"], "function", "客服"),
|
||||||
|
(["供应链", "交付", "库存", "进销存", "采购", "物流"], "function", "供应链"),
|
||||||
|
(["合规", "风险", "安全", "稳健", "零事故", "审计"], "goal_type", "合规"),
|
||||||
|
(["创新", "领先", "新品", "研发"], "goal_type", "创新"),
|
||||||
|
(["转型", "数字化", "智能化", "升级", "覆盖"], "goal_type", "转型"),
|
||||||
|
(["降低成本", "效率", "优化", "标准化", "周转", "管控"], "goal_type", "效率"),
|
||||||
|
(["增长", "提升", "扩大", "拓展", "规模"], "goal_type", "增长"),
|
||||||
|
]
|
||||||
|
|
||||||
|
# dimension → 默认 function(最保守兜底)
|
||||||
|
DIM_DEFAULT_FUNCTION = {
|
||||||
|
"finance": "财务",
|
||||||
|
"customer": "销售",
|
||||||
|
"process": "生产",
|
||||||
|
"learning": "HR",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def infer_tags(t) -> dict:
|
||||||
|
"""按 name 显式映射 → 关键词规则 → 兜底;保证三键非空且值合法"""
|
||||||
|
mapped = NAME_TAGS.get(t.name or "")
|
||||||
|
if mapped:
|
||||||
|
return {
|
||||||
|
"function": list(mapped["function"]),
|
||||||
|
"cycle": list(mapped["cycle"]),
|
||||||
|
"goal_type": list(mapped["goal_type"]),
|
||||||
|
}
|
||||||
|
|
||||||
|
# 关键词规则兜底
|
||||||
|
text_blob = f"{t.name or ''} {t.description or ''}"
|
||||||
|
funcs, gtypes = [], []
|
||||||
|
for kws, field, value in KEYWORD_RULES:
|
||||||
|
if any(k in text_blob for k in kws):
|
||||||
|
if field == "function" and value not in funcs:
|
||||||
|
funcs.append(value)
|
||||||
|
elif field == "goal_type" and value not in gtypes:
|
||||||
|
gtypes.append(value)
|
||||||
|
if not funcs:
|
||||||
|
funcs = [DIM_DEFAULT_FUNCTION.get(t.dimension or "", "财务")]
|
||||||
|
if not gtypes:
|
||||||
|
gtypes = ["效率"]
|
||||||
|
return {"function": funcs, "cycle": ["年度"], "goal_type": gtypes}
|
||||||
|
|
||||||
|
|
||||||
|
def validate(tags: dict) -> dict:
|
||||||
|
assert isinstance(tags, dict), "tags 必须是 dict"
|
||||||
|
for key, enum in (("function", FUNCTION_ENUM), ("cycle", CYCLE_ENUM), ("goal_type", GOAL_TYPE_ENUM)):
|
||||||
|
vals = tags.get(key)
|
||||||
|
assert isinstance(vals, list) and vals, f"tags.{key} 必须为非空数组"
|
||||||
|
bad = [v for v in vals if v not in enum]
|
||||||
|
assert not bad, f"tags.{key} 含非法值 {bad},允许: {enum}"
|
||||||
|
return tags
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--dry-run", action="store_true", help="只打印计划不写库")
|
||||||
|
ap.add_argument("--force", action="store_true", help="已填行也重算覆盖")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
SessionLocal = get_session_local()
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
rows = db.query(OKRTemplate).order_by(OKRTemplate.id).all()
|
||||||
|
total = len(rows)
|
||||||
|
filled_before = sum(1 for r in rows if r.tags)
|
||||||
|
updated, skipped = 0, 0
|
||||||
|
|
||||||
|
for t in rows:
|
||||||
|
if t.tags and not args.force:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
tags = validate(infer_tags(t))
|
||||||
|
print(f" #{t.id:<3} {t.name:<12} dim={t.dimension:<9} -> {json.dumps(tags, ensure_ascii=False)}")
|
||||||
|
if not args.dry_run:
|
||||||
|
t.tags = tags
|
||||||
|
db.add(t)
|
||||||
|
db.commit()
|
||||||
|
updated += 1
|
||||||
|
|
||||||
|
# 回查(写后验证)
|
||||||
|
db.expire_all()
|
||||||
|
rows2 = db.query(OKRTemplate).all()
|
||||||
|
filled_after = sum(1 for r in rows2 if r.tags)
|
||||||
|
empty_ids = [r.id for r in rows2 if not r.tags]
|
||||||
|
print("-" * 60)
|
||||||
|
print(f"总数={total} 回填前已填={filled_before} 本次更新={updated} 跳过(已有tags)={skipped}")
|
||||||
|
print(f"回填后已填={filled_after}/{len(rows2)} 空值={len(empty_ids)} {('ids=' + str(empty_ids)) if empty_ids else ''}")
|
||||||
|
if args.dry_run:
|
||||||
|
print("[DRY-RUN] 未写库")
|
||||||
|
return 0 if not empty_ids else 1
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
OKR 模板库 — tags 多值标签字段迁移(幂等 DDL)
|
||||||
|
|
||||||
|
任务: fullstack-okr-tags-20260911
|
||||||
|
- 目的: okr_templates 新增 tags JSON 字段(辅助检索标签)
|
||||||
|
- 约束: 不改 dimension 四泳道主分类;只加字段
|
||||||
|
- 幂等: 字段已存在则跳过(可重复执行)
|
||||||
|
|
||||||
|
用法: python3 backend/scripts/migrate_okr_templates_tags.py [--dry-run]
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."))
|
||||||
|
|
||||||
|
from sqlalchemy import text # noqa: E402
|
||||||
|
from app.database import get_engine # noqa: E402
|
||||||
|
|
||||||
|
TABLE = "okr_templates"
|
||||||
|
COLUMN = "tags"
|
||||||
|
DDL = (
|
||||||
|
"ALTER TABLE okr_templates "
|
||||||
|
"ADD COLUMN tags JSON NULL COMMENT '多值标签 function/cycle/goal_type'"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def column_exists(conn) -> bool:
|
||||||
|
row = conn.execute(
|
||||||
|
text(
|
||||||
|
"SELECT COUNT(*) FROM information_schema.COLUMNS "
|
||||||
|
"WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :t AND COLUMN_NAME = :c"
|
||||||
|
),
|
||||||
|
{"t": TABLE, "c": COLUMN},
|
||||||
|
).scalar()
|
||||||
|
return bool(row)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--dry-run", action="store_true")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
engine = get_engine()
|
||||||
|
with engine.begin() as conn:
|
||||||
|
if column_exists(conn):
|
||||||
|
print(f"[SKIP] {TABLE}.{COLUMN} 已存在,无需迁移")
|
||||||
|
else:
|
||||||
|
if args.dry_run:
|
||||||
|
print(f"[DRY-RUN] 将执行: {DDL}")
|
||||||
|
else:
|
||||||
|
conn.execute(text(DDL))
|
||||||
|
print(f"[OK] 已执行: {DDL}")
|
||||||
|
|
||||||
|
cols = conn.execute(
|
||||||
|
text(
|
||||||
|
"SELECT COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE FROM information_schema.COLUMNS "
|
||||||
|
"WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :t ORDER BY ORDINAL_POSITION"
|
||||||
|
),
|
||||||
|
{"t": TABLE},
|
||||||
|
).fetchall()
|
||||||
|
print("[VERIFY] 当前表结构:")
|
||||||
|
for c in cols:
|
||||||
|
mark = " <== tags" if c[0] == COLUMN else ""
|
||||||
|
print(f" - {c[0]}: {c[1]} null={c[2]}{mark}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user