- 模型: Objective/KR/ObjectiveKPI/KPIValue 加 entity_id - DB: 4表加列; kpi_values 1610条按kpi_id回填(1269酣客/341博海) - OKR域: 测试O删除重建; okr list/get/create + ontology trace/objectives 按token企业隔离(跨企业404) - KPIValue写入: data/bot_bridge/bot_kpis 创建时带entity_id - 验证: 酣客创建OKR博海不可见; 跨企业读404; pytest 451通过(expenses单跑37通过为既有排序flaky)
244 lines
9.2 KiB
Python
244 lines
9.2 KiB
Python
"""
|
||
OKR目标管理 API — 季度目标 + 关键结果 + KPI联动
|
||
"""
|
||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||
from sqlalchemy.orm import Session
|
||
from sqlalchemy import func
|
||
from datetime import datetime, timedelta
|
||
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"))],
|
||
)
|
||
|
||
|
||
@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企业, 2026-08-23 P2)"""
|
||
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(ActionPlan).filter(ActionPlan.objective_id == o.id).all()
|
||
kr_summary = [
|
||
{"id": kr.id, "title": kr.title, "status": kr.status, "progress": kr.progress}
|
||
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 == "completed"),
|
||
"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企业"""
|
||
# 兼容旧版Query参数
|
||
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)"""
|
||
obj = db.query(Objective).filter(Objective.id == obj_id, Objective.entity_id == entity_id).first()
|
||
if not obj:
|
||
raise HTTPException(404, "目标不存在")
|
||
krs = db.query(ActionPlan).filter(ActionPlan.objective_id == obj_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": [
|
||
{"id": kr.id, "title": kr.title, "kpi_id": kr.kpi_id,
|
||
"status": kr.status, "progress": kr.progress,
|
||
"due_date": kr.due_date.isoformat() if kr.due_date else None,
|
||
"assignee": kr.assignee}
|
||
for kr in krs
|
||
]
|
||
}
|
||
|
||
|
||
@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)):
|
||
"""保存KR的月度里程碑(时间分解视图编辑) — 同步更新KR进度"""
|
||
obj = db.query(Objective).filter(Objective.id == okr_id).first()
|
||
if not obj:
|
||
raise HTTPException(404, "OKR不存在")
|
||
kr = db.query(ActionPlan).filter(
|
||
ActionPlan.id == kr_id, ActionPlan.objective_id == okr_id
|
||
).first()
|
||
if not kr:
|
||
raise HTTPException(404, "KR不存在或不属于该OKR")
|
||
milestones = data.get("milestones")
|
||
if not isinstance(milestones, list):
|
||
raise HTTPException(400, "milestones 必须是数组")
|
||
# 规范化:只保留 month/label/status/target 字段
|
||
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
|
||
# 里程碑完成 → KR进度同步更新(完成数/总数)
|
||
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 = "completed"
|
||
elif kr.status == "completed" 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)):
|
||
"""为KR自动生成月度里程碑(按季度剩余月份均分)"""
|
||
obj = db.query(Objective).filter(Objective.id == okr_id).first()
|
||
if not obj:
|
||
raise HTTPException(404, "OKR不存在")
|
||
kr_id = data.get("kr_id")
|
||
if not kr_id:
|
||
raise HTTPException(400, "缺少 kr_id")
|
||
kr = db.query(ActionPlan).filter(
|
||
ActionPlan.id == kr_id, ActionPlan.objective_id == okr_id
|
||
).first()
|
||
if not kr:
|
||
raise HTTPException(404, "KR不存在或不属于该OKR")
|
||
# 重新生成:覆盖已有里程碑(幂等)
|
||
milestones = _build_auto_milestones(kr)
|
||
kr.monthly_milestones = milestones
|
||
db.commit()
|
||
return {"ok": True, "kr_id": kr.id, "milestones": kr.monthly_milestones}
|
||
|
||
|
||
def _build_auto_milestones(kr: ActionPlan) -> list:
|
||
"""按截止日期向前均分3个月度里程碑"""
|
||
base = kr.due_date or datetime.now() + timedelta(days=90)
|
||
base = base.replace(day=1)
|
||
# 计算前3个月份(从最早的月份开始)
|
||
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)
|
||
]
|
||
|
||
|
||
@router.patch("/{obj_id}")
|
||
def update_objective(obj_id: int, db: Session = Depends(get_db)):
|
||
"""更新OKR进度(通过查询ActionPlan自动计算)"""
|
||
obj = db.query(Objective).filter(Objective.id == obj_id).first()
|
||
if not obj:
|
||
raise HTTPException(404, "目标不存在")
|
||
krs = db.query(ActionPlan).filter(ActionPlan.objective_id == obj_id).all()
|
||
if krs:
|
||
obj.progress = sum(kr.progress for kr in krs) // len(krs)
|
||
db.commit()
|
||
return {"ok": True, "id": obj_id, "progress": obj.progress}
|
||
|
||
|
||
@router.get("/{okr_id}/decomposition")
|
||
def get_okr_decomposition(okr_id: int, db: Session = Depends(get_db)):
|
||
"""获取OKR的时间分解视图数据"""
|
||
okr = db.query(Objective).filter(Objective.id == okr_id).first()
|
||
if not okr:
|
||
raise HTTPException(404, "OKR不存在")
|
||
|
||
# 1. 关联的BSC战略O(年度 — 相同维度且没有季度标识)
|
||
bsc_o = db.query(Objective).filter(
|
||
Objective.dimension == okr.dimension,
|
||
Objective.quarter.is_(None)
|
||
).first()
|
||
|
||
# 2. 本OKR的所有KR(关联到该Objective的ActionPlan)
|
||
krs = db.query(ActionPlan).filter(ActionPlan.objective_id == okr_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
|
||
]
|
||
}
|