From 38968750a3b58c664ec42f4f81c404a7d8530faa Mon Sep 17 00:00:00 2001 From: Hermes CI Fix Date: Fri, 11 Sep 2026 19:33:52 +0800 Subject: [PATCH] =?UTF-8?q?feat(okr):=20OKR=E6=A8=A1=E6=9D=BF=E5=BA=93=20t?= =?UTF-8?q?ags=20=E5=A4=9A=E5=80=BC=E6=A0=87=E7=AD=BE=20=E2=80=94=20?= =?UTF-8?q?=E8=A1=A8=E7=BB=93=E6=9E=84+=E5=9B=9E=E5=A1=AB36=E6=A8=A1?= =?UTF-8?q?=E6=9D=BF+API=E8=BF=87=E6=BB=A4=20(fullstack-okr-tags-20260911)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 一致) --- backend/app/api/okr_templates.py | 169 ++++++++++++---- backend/app/models/__init__.py | 1 + backend/scripts/backfill_okr_template_tags.py | 186 ++++++++++++++++++ backend/scripts/migrate_okr_templates_tags.py | 70 +++++++ 4 files changed, 392 insertions(+), 34 deletions(-) create mode 100644 backend/scripts/backfill_okr_template_tags.py create mode 100644 backend/scripts/migrate_okr_templates_tags.py diff --git a/backend/app/api/okr_templates.py b/backend/app/api/okr_templates.py index 21637a9e..05c3c479 100644 --- a/backend/app/api/okr_templates.py +++ b/backend/app/api/okr_templates.py @@ -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") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 5cd236c1..2ae82a9e 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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: 可追踪)") diff --git a/backend/scripts/backfill_okr_template_tags.py b/backend/scripts/backfill_okr_template_tags.py new file mode 100644 index 00000000..a94d2981 --- /dev/null +++ b/backend/scripts/backfill_okr_template_tags.py @@ -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()) diff --git a/backend/scripts/migrate_okr_templates_tags.py b/backend/scripts/migrate_okr_templates_tags.py new file mode 100644 index 00000000..c1ff8a7a --- /dev/null +++ b/backend/scripts/migrate_okr_templates_tags.py @@ -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()