- 后端okr.py: KR读取从ActionPlan改为krs表, 新增KR CRUD API(POST/PUT/DELETE /okr/{objective_id}/krs) + 批量sync
- operator方向符号: krs表加operator/tolerance/weight/sort_order/monthly_milestones列
- progress方向感知计算(>=/>: current/target, <=/<: target/current, =: 容差), 达成→status=achieved
- 关联KPI自动继承方向(threshold_green解析: F_COST_RATIO<=18等)
- maps.py: 保存地图时自动同步objectives+krs表, JSON→krs数据迁移脚本
- 前端: 权重下拉改自由数字输入(可小数33.33) + 方向选择器(≥/≤/>/</=) + 自动平分按钮 + KPI方向继承提示
- pytest: 10个新测试(krs CRUD/方向感知/权重校验/多租户隔离) + 更新旧KR测试
- 迁移: 现有strategic_maps JSON 12条KR已写入krs表
562 lines
21 KiB
Python
562 lines
21 KiB
Python
"""
|
||
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
|
||
]
|
||
}
|