""" OKR目标管理 API — 季度目标 + 关键结果 + KPI联动 KR完整修复(2026-08-27): KR读取从ActionPlan改为krs表 + 方向符号operator + 权重 """ from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.orm import Session from sqlalchemy import func from datetime import datetime, timedelta, date as date_type 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 Objective, ActionPlan, KR, ObjectiveKPI, KPIDefinition router = APIRouter(prefix="/api/cma/okr", tags=["OKR目标管理"], dependencies=[Depends(require_role("ceo", "finance", "it"))], ) # ── KR 工具函数 ───────────────────────────────────────── VALID_OPERATORS = (">=", "<=", ">", "<", "=") def _num(v): """Decimal → float 便于 JSON 序列化""" return float(v) if v is not None else None def _parse_operator(raw): """解析方向符号: 支持 >=/<=/>/=""" if not raw: return ">=" op = str(raw).strip() if op in VALID_OPERATORS: return op # 兼容中文符号 ≥ ≤ if op in ("≥",): return ">=" if op in ("≤",): return "<=" return ">=" def _eval_kr(kr: KR) -> bool: """达成判断: 实际值满足 operator → True""" if kr.current_value is None or kr.target_value is None: return False cur = float(kr.current_value) tgt = float(kr.target_value) op = _parse_operator(kr.operator) if op == ">=": ok = cur >= tgt elif op == "<=": ok = cur <= tgt elif op == ">": ok = cur > tgt elif op == "<": ok = cur < tgt elif op == "=": tol = float(kr.tolerance) if kr.tolerance is not None else 0.0 ok = abs(cur - tgt) <= tol else: ok = False return ok def _calc_progress(kr: KR) -> int: """方向感知进度计算: >=/>: current/target*100 <=/<: target/current*100 = : 容差内100 """ if kr.current_value is None or kr.target_value is None: return kr.progress or 0 cur = float(kr.current_value) tgt = float(kr.target_value) if tgt == 0: return 100 if cur == 0 else (100 if cur > 0 else 0) op = _parse_operator(kr.operator) if op in (">=", ">"): pct = cur / tgt * 100 elif op in ("<=", "<"): # 值越小进度越高(如费用率<=18%) if cur <= tgt: pct = 100.0 else: pct = max(0.0, tgt / cur * 100) elif op == "=": tol = float(kr.tolerance) if kr.tolerance is not None else 0.0 if abs(cur - tgt) <= tol: pct = 100.0 else: pct = max(0.0, 100.0 - abs(cur - tgt) / (abs(tgt) or 1) * 100) else: pct = cur / tgt * 100 return max(0, min(100, round(pct))) def _refresh_kr_state(kr: KR): """按当前值刷新 progress + status(方向感知)""" if kr.current_value is not None and kr.target_value is not None: kr.progress = _calc_progress(kr) if _eval_kr(kr): kr.status = "achieved" elif kr.progress > 0: kr.status = "in_progress" return kr def _inherit_kpi_direction(db: Session, kpi_id, operator, target_value): """关联KPI时自动继承方向: 从KPI的threshold_green解析(如 '>=800' / '<=18')""" if not kpi_id: return operator, target_value kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first() if not kpi: return operator, target_value threshold = (kpi.threshold_green or "").strip() if not threshold: return operator, target_value op = operator tgt = target_value for cand in VALID_OPERATORS: if threshold.startswith(cand): op = cand try: tgt = float(threshold[len(cand):].strip()) except (ValueError, TypeError): pass break else: # 无符号时尝试解析纯数字 try: tgt = float(threshold) except (ValueError, TypeError): pass return op, tgt def _kr_serialize(kr: KR, db: Session = None) -> dict: """KR → dict(含operator/weight/tolerance)""" mkpi = None if db and kr.metric_kpi_id: mkpi = db.query(KPIDefinition).filter(KPIDefinition.id == kr.metric_kpi_id).first() return { "id": kr.id, "objective_id": kr.objective_id, "title": kr.title, "metric_kpi_id": kr.metric_kpi_id, "metric_kpi_code": mkpi.kpi_code if mkpi else None, "metric_kpi_name": mkpi.kpi_name if mkpi else None, "operator": _parse_operator(kr.operator), "tolerance": _num(kr.tolerance), "weight": _num(kr.weight), "target_value": _num(kr.target_value), "current_value": _num(kr.current_value), "progress": kr.progress, "status": kr.status, "due_date": kr.due_date.isoformat() if kr.due_date else None, "monthly_milestones": kr.monthly_milestones or [], } def _get_objective_or_404(db: Session, obj_id: int, entity_id: int) -> Objective: obj = db.query(Objective).filter(Objective.id == obj_id, Objective.entity_id == entity_id).first() if not obj: raise HTTPException(404, "目标不存在") return obj # ── 目标列表/详情 ───────────────────────────────────── @router.get("") def list_objectives( quarter: Optional[str] = Query(None, description="筛选季度: 2026Q3"), dimension: Optional[str] = Query(None), status: Optional[str] = Query(None), db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id), ): """列出OKR目标(账套隔离: 按token企业) — KR读取自krs表(2026-08-27)""" q = db.query(Objective).filter(Objective.entity_id == entity_id) if quarter: q = q.filter(Objective.quarter == quarter) if dimension: q = q.filter(Objective.dimension == dimension) if status: q = q.filter(Objective.status == status) objs = q.order_by(Objective.quarter.desc(), Objective.id).all() results = [] for o in objs: krs = db.query(KR).filter(KR.objective_id == o.id).order_by(KR.id).all() kr_summary = [ {"id": kr.id, "title": kr.title, "status": kr.status, "progress": kr.progress, "operator": _parse_operator(kr.operator), "weight": _num(kr.weight)} for kr in krs ] results.append({ "id": o.id, "title": o.title, "description": o.description, "dimension": o.dimension, "quarter": o.quarter, "owner": o.owner, "status": o.status, "progress": o.progress, "confidence": o.confidence, "key_results": kr_summary, "kr_count": len(krs), "kr_completed": sum(1 for kr in krs if kr.status in ("completed", "achieved")), "created_at": o.created_at.isoformat() if o.created_at else None, }) return {"total": len(results), "items": results} @router.post("") def create_objective( data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id), ): """创建OKR目标(支持JSON Body和Query参数两种方式)— 账套隔离: 强制token企业""" title = data.get("title") or "" quarter = data.get("quarter") or "" description = data.get("description") dimension = data.get("dimension") owner = data.get("owner") if not title or not quarter: raise HTTPException(422, "缺少必填字段: title, quarter") obj = Objective(title=title, quarter=quarter, description=description, dimension=dimension, owner=owner, entity_id=entity_id) db.add(obj) db.commit() db.refresh(obj) return {"ok": True, "id": obj.id, "title": obj.title} @router.get("/{obj_id}") def get_objective(obj_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)): """获取单个OKR详情(账套隔离: 跨企业404) — KR读取自krs表(2026-08-27)""" obj = _get_objective_or_404(db, obj_id, entity_id) krs = db.query(KR).filter(KR.objective_id == obj_id).order_by(KR.id).all() return { "objective": { "id": obj.id, "title": obj.title, "description": obj.description, "dimension": obj.dimension, "quarter": obj.quarter, "owner": obj.owner, "status": obj.status, "progress": obj.progress, "confidence": obj.confidence, }, "key_results": [_kr_serialize(kr, db) for kr in krs], } @router.patch("/{obj_id}") def update_objective(obj_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)): """更新OKR进度(通过krs表KR自动计算加权进度)""" obj = _get_objective_or_404(db, obj_id, entity_id) krs = db.query(KR).filter(KR.objective_id == obj_id).all() if krs: weights = [float(kr.weight) if kr.weight else 1.0 for kr in krs] total_w = sum(weights) or len(krs) obj.progress = round(sum(kr.progress * w for kr, w in zip(krs, weights)) / total_w) db.commit() return {"ok": True, "id": obj_id, "progress": obj.progress} # ── KR CRUD ─────────────────────────────────────────── @router.post("/{objective_id}/krs") def create_kr(objective_id: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)): """新增KR(落krs表)— title/metric_kpi_id/operator/target_value/weight/progress/due_date 关联KPI时自动继承方向(threshold_green) """ obj = _get_objective_or_404(db, objective_id, entity_id) title = data.get("title") or "" if not title: raise HTTPException(422, "缺少必填字段: title") operator, target_value = _inherit_kpi_direction( db, data.get("metric_kpi_id"), data.get("operator"), data.get("target_value")) if target_value is None and operator != "=": # 没有目标值也能建(后续补),但明确提示 pass kr = KR( entity_id=entity_id, objective_id=objective_id, title=title, metric_kpi_id=data.get("metric_kpi_id") or None, operator=_parse_operator(operator), tolerance=data.get("tolerance"), weight=data.get("weight"), target_value=target_value, current_value=data.get("current_value"), progress=data.get("progress", 0), status=data.get("status", "pending"), due_date=_parse_date(data.get("due_date")), monthly_milestones=data.get("monthly_milestones") or [], ) _refresh_kr_state(kr) db.add(kr) db.commit() db.refresh(kr) update_objective(objective_id, db, entity_id) return {"ok": True, "id": kr.id, "kr": _kr_serialize(kr, db)} @router.put("/{objective_id}/krs/sync") def sync_krs(objective_id: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)): """批量同步KR数组(前端保存整个KR列表时用): body: {"krs": [{id?, title, operator, target_value, weight, kpi_code?, ...}]} 已存在的按id更新;无id且同名则更新;否则新增;不在列表中的删除 注意: 必须声明在 /krs/{kr_id} 之前,避免 'sync' 被解析为 kr_id """ obj = _get_objective_or_404(db, objective_id, entity_id) incoming = data.get("krs", []) if not isinstance(incoming, list): raise HTTPException(400, "krs 必须是数组") existing = {kr.id: kr for kr in db.query(KR).filter(KR.objective_id == objective_id).all()} keep_ids = set() now_name = {} for i, item in enumerate(incoming): title = (item.get("title") or item.get("name") or "").strip() if not title: continue kpi_id = item.get("metric_kpi_id") or item.get("kpi_id") # kpi_code → kpi_id 转换 if not kpi_id and item.get("kpi_code"): kpi = db.query(KPIDefinition).filter( KPIDefinition.kpi_code == item["kpi_code"], KPIDefinition.entity_id == entity_id).first() kpi_id = kpi.id if kpi else None operator, target_value = _inherit_kpi_direction(db, kpi_id, item.get("operator"), item.get("target_value")) kr = None if item.get("id") and int(item["id"]) in existing: kr = existing[int(item["id"])] elif title in now_name: kr = now_name[title] else: # 按同名找已有 for k in existing.values(): if k.title == title: kr = k break if kr is None: kr = KR(entity_id=entity_id, objective_id=objective_id, title=title) db.add(kr) kr.title = title kr.metric_kpi_id = kpi_id kr.operator = _parse_operator(operator) if item.get("tolerance") is not None: kr.tolerance = item.get("tolerance") if item.get("weight") is not None: kr.weight = item.get("weight") if item.get("target_value") is not None: kr.target_value = item.get("target_value") elif target_value is not None: kr.target_value = target_value if item.get("current_value") is not None: kr.current_value = item.get("current_value") if item.get("due_date"): kr.due_date = _parse_date(item.get("due_date")) if item.get("monthly_milestones") is not None: kr.monthly_milestones = item.get("monthly_milestones") _refresh_kr_state(kr) keep_ids.add(kr.id) now_name[title] = kr # 删除不在列表中的 for kid, kr in existing.items(): if kid not in keep_ids: db.delete(kr) db.commit() update_objective(objective_id, db, entity_id) krs = db.query(KR).filter(KR.objective_id == objective_id).order_by(KR.id).all() return {"ok": True, "objective_id": objective_id, "krs": [_kr_serialize(kr, db) for kr in krs]} @router.put("/{objective_id}/krs/{kr_id}") def update_kr(objective_id: int, kr_id: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)): """更新KR(含方向/权重/当前值,自动重算progress+status)""" obj = _get_objective_or_404(db, objective_id, entity_id) kr = db.query(KR).filter(KR.id == kr_id, KR.objective_id == objective_id, KR.entity_id == entity_id).first() if not kr: raise HTTPException(404, "KR不存在或不属于该目标") updatable = {"title", "metric_kpi_id", "operator", "tolerance", "weight", "target_value", "current_value", "progress", "status", "due_date", "monthly_milestones"} for k, v in data.items(): if k in updatable and v is not None: setattr(kr, k, v) # 关联KPI变化 → 自动继承方向 if "metric_kpi_id" in data or "operator" not in data or "target_value" not in data: if data.get("metric_kpi_id") and ("operator" not in data or "target_value" not in data): kr.operator, kr.target_value = _inherit_kpi_direction( db, data.get("metric_kpi_id", kr.metric_kpi_id), data.get("operator", kr.operator), data.get("target_value", kr.target_value)) if data.get("due_date"): kr.due_date = _parse_date(data["due_date"]) _refresh_kr_state(kr) db.commit() db.refresh(kr) update_objective(objective_id, db, entity_id) return {"ok": True, "id": kr.id, "kr": _kr_serialize(kr, db)} @router.delete("/{objective_id}/krs/{kr_id}") def delete_kr(objective_id: int, kr_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)): """删除KR""" _get_objective_or_404(db, objective_id, entity_id) kr = db.query(KR).filter(KR.id == kr_id, KR.objective_id == objective_id, KR.entity_id == entity_id).first() if not kr: raise HTTPException(404, "KR不存在或不属于该目标") db.delete(kr) db.commit() update_objective(objective_id, db, entity_id) return {"ok": True, "deleted": kr_id} # ── KR里程碑(时间分解) ───────────────────────────── @router.put("/{okr_id}/decomposition/milestones/{kr_id}") def update_kr_milestones(okr_id: int, kr_id: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)): """保存KR的月度里程碑(时间分解视图编辑) — 同步更新KR进度""" _get_objective_or_404(db, okr_id, entity_id) kr = db.query(KR).filter(KR.id == kr_id, KR.objective_id == okr_id, KR.entity_id == entity_id).first() if not kr: raise HTTPException(404, "KR不存在或不属于该OKR") milestones = data.get("milestones") if not isinstance(milestones, list): raise HTTPException(400, "milestones 必须是数组") normalized = [] for ms in milestones: normalized.append({ "month": ms.get("month", ""), "label": ms.get("label", ""), "status": ms.get("status", "pending"), "target": ms.get("target"), }) kr.monthly_milestones = normalized if normalized: done = sum(1 for ms in normalized if ms.get("status") == "completed") kr.progress = round(done / len(normalized) * 100) if kr.progress >= 100: kr.status = "achieved" elif kr.status == "achieved" and kr.progress < 100: kr.status = "in_progress" db.commit() return {"ok": True, "kr_id": kr.id, "progress": kr.progress, "milestones": kr.monthly_milestones} @router.post("/{okr_id}/decomposition/milestones/generate") def auto_generate_milestones(okr_id: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)): """为KR自动生成月度里程碑(按季度剩余月份均分)""" _get_objective_or_404(db, okr_id, entity_id) kr_id = data.get("kr_id") if not kr_id: raise HTTPException(400, "缺少 kr_id") kr = db.query(KR).filter(KR.id == kr_id, KR.objective_id == okr_id, KR.entity_id == entity_id).first() if not kr: raise HTTPException(404, "KR不存在或不属于该OKR") kr.monthly_milestones = _build_auto_milestones(kr) db.commit() return {"ok": True, "kr_id": kr.id, "milestones": kr.monthly_milestones} def _build_auto_milestones(kr: KR) -> list: """按截止日期向前均分3个月度里程碑""" base = kr.due_date or datetime.now() + timedelta(days=90) if isinstance(base, str): try: base = datetime.strptime(base[:10], "%Y-%m-%d").date() except ValueError: base = datetime.now().date() + timedelta(days=90) if isinstance(base, datetime): base = base.date() base = base.replace(day=1) month_keys = [] for i in range(3, 0, -1): y, m = base.year, base.month - i while m <= 0: m += 12 y -= 1 month_keys.append(f"{y:04d}-{m:02d}") return [ { "month": mk, "label": f"里程碑{i + 1}", "status": "pending", "target": None, } for i, mk in enumerate(month_keys) ] def _parse_date(v): """兼容 str/date/datetime → date""" if v is None or v == "": return None if isinstance(v, date_type): return v if isinstance(v, datetime): return v.date() try: return datetime.strptime(str(v)[:10], "%Y-%m-%d").date() except (ValueError, TypeError): return None @router.get("/{okr_id}/decomposition") def get_okr_decomposition(okr_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)): """获取OKR的时间分解视图数据 — KR读取自krs表(2026-08-27)""" okr = _get_objective_or_404(db, okr_id, entity_id) # 1. 关联的BSC战略O(年度 — 相同维度且没有季度标识) bsc_o = db.query(Objective).filter( Objective.dimension == okr.dimension, Objective.quarter.is_(None), Objective.entity_id == entity_id, ).first() # 2. 本OKR的所有KR(krs表) krs = db.query(KR).filter(KR.objective_id == okr_id).order_by(KR.id).all() # 3. 当前周的ActionPlan(本周行动计划) now = datetime.now() week_start = now - timedelta(days=now.weekday()) week_start = week_start.replace(hour=0, minute=0, second=0, microsecond=0) week_end = week_start + timedelta(days=7) action_plans = db.query(ActionPlan).filter( ActionPlan.objective_id == okr_id, ActionPlan.due_date.between(week_start, week_end) ).all() return { "annual_o": bsc_o.title if bsc_o else None, "quarterly_o": okr.title, "krs": [ { "kr_id": kr.id, "title": kr.title, "progress": kr.progress, "milestones": kr.monthly_milestones or [] } for kr in krs ], "weekly_actions": [ { "id": ap.id, "title": ap.title, "status": ap.status, "deadline": ap.due_date.isoformat() if ap.due_date else None } for ap in action_plans ] }