feat(KR): KR完整修复 — krs表打通+方向符号operator+权重自由输入

- 后端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表
This commit is contained in:
Hermes CI Fix
2026-08-27 10:59:56 +08:00
parent 87cb9e7db2
commit 61936302b2
11 changed files with 1030 additions and 123 deletions
+5 -3
View File
@@ -13,7 +13,7 @@ from app.models import (
User, StrategicMap, KPIDefinition, KPITemplate, KPIValue,
DataSourceConfig, KPIAlert, OperationLog, NotificationChannel,
NotificationLog, RolePermission, ActionPlan, OrgNode,
StrategicMapVersion, MapObjective, Objective,
StrategicMapVersion, MapObjective, Objective, KR,
)
from app.models.budget_plan import BudgetPlan
from app.models.cost_model import StandardCost, ActualCost, AbcActivity, AbcAllocation
@@ -457,13 +457,15 @@ def bot_query(
objs = db.query(Objective).filter(Objective.status == "active").all()
result["okr"] = []
for o in objs:
krs = db.query(ActionPlan).filter(ActionPlan.objective_id == o.id).all()
# KR完整修复(2026-08-27): 从krs表读取
krs = db.query(KR).filter(KR.objective_id == o.id).all()
result["okr"].append({
"id": o.id, "title": o.title, "quarter": o.quarter,
"dimension": o.dimension, "progress": o.progress,
"confidence": o.confidence,
"key_results": [
{"title": kr.title, "status": kr.status, "progress": kr.progress}
{"title": kr.title, "status": kr.status, "progress": kr.progress,
"operator": kr.operator, "target_value": _float(kr.target_value)}
for kr in krs
]
})
+175 -1
View File
@@ -2,10 +2,11 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from typing import Optional
from datetime import datetime, timedelta
from app.database import get_db
from app.deps import get_entity_id
from app.auth_middleware import require_auth, require_role
from app.models import StrategicMap, OperationLog, MapObjective
from app.models import StrategicMap, OperationLog, MapObjective, Objective, KR, KPIDefinition
import json
router = APIRouter(prefix="/api/cma/maps", tags=["战略地图"],
@@ -127,6 +128,8 @@ def update_map(map_id: int, data: dict, db: Session = Depends(get_db), entity_id
db.commit()
# 同步目标到map_objectives表
_sync_map_objectives(m, db)
# KR完整修复(2026-08-27): 同步 objectives+krs 表
_sync_objectives_krs(m, db)
# ├─ 版本管理: draft → published 时自动创建快照
if old_status == "draft" and m.status == "published":
@@ -356,6 +359,177 @@ def _merge_map_objectives(m, db):
m.dimensions = dims
# ── KR完整修复(2026-08-27): 地图节点 → objectives + krs 表同步 ──
KR_OPERATORS = (">=", "<=", ">", "<", "=")
def _parse_kr_operator_target(raw_target, operator=None):
"""解析KR目标值字符串:
旧格式 '≥20%'/'<=18' → ('>=', 20.0)
新格式 operator='>=' + target=20 → ('>=', 20.0)
"""
if operator:
op = str(operator).strip()
if op == "":
op = ">="
elif op == "":
op = "<="
if op not in KR_OPERATORS:
op = ">="
else:
op = ">="
if raw_target is None:
return op, None
if isinstance(raw_target, (int, float)):
return op, float(raw_target)
s = str(raw_target).strip()
if s == "":
return op, None
# 前缀符号
for cand in KR_OPERATORS:
if s.startswith(cand):
op = cand
s = s[len(cand):]
break
if s.startswith(""):
op = ">="
s = s[1:]
elif s.startswith(""):
op = "<="
s = s[1:]
# 去掉单位(%、天、小时等非数字后缀)
num = s
while num and not (num[0].isdigit() or num[0] in ".+-"):
num = num[1:]
import re
m = re.match(r"[-+]?\d*\.?\d+", num)
if not m:
return op, None
return op, float(m.group(0))
def _sync_objectives_krs(m, db):
"""保存地图时:将dimensions JSON中带krs的目标节点同步到objectives表+krs表。
- 每个带krs的目标节点 → 确保objectives表有记录(按entity_id+name匹配,无则创建)
- krs数组 → krs表 upsert(按title匹配;删除本目标下JSON中不存在的krs)
- 回写 objective_id / kr_id 到节点JSON,方便前端下次直接使用
"""
dims = m.dimensions
if isinstance(dims, str):
try:
dims = json.loads(dims)
except Exception:
return
dims = dims or []
entity_id = m.entity_id if m.entity_id is not None else 1
for dim in dims:
dim_key = dim.get("key", "")
for obj in dim.get("objectives", []):
krs = obj.get("krs") or []
if not krs:
continue
name = (obj.get("name") or "").strip()
if not name:
continue
# 1. 确保 Objective 记录存在
objective = db.query(Objective).filter(
Objective.entity_id == entity_id,
Objective.title == name,
).first()
if not objective:
objective = Objective(
entity_id=entity_id,
title=name,
description=obj.get("description") or "",
dimension=dim_key,
quarter=_map_quarter(m),
owner=obj.get("owner") or "",
status="active",
progress=0,
)
db.add(objective)
db.flush() # 拿到 id
obj["_objective_id"] = objective.id
# 2. upsert krs 表
existing_krs = {kr.title: kr for kr in db.query(KR).filter(KR.objective_id == objective.id).all()}
keep_ids = set()
for i, kr_item in enumerate(krs):
if not isinstance(kr_item, dict):
continue
title = (kr_item.get("title") or kr_item.get("name") or "").strip()
if not title:
# 跳过空KR(前端允许空白行,不入库)
continue
op, tgt = _parse_kr_operator_target(
kr_item.get("target_value"), kr_item.get("operator"))
kpi_id = kr_item.get("metric_kpi_id")
if not kpi_id and kr_item.get("kpi_code"):
kpi = db.query(KPIDefinition).filter(
KPIDefinition.entity_id == entity_id,
KPIDefinition.kpi_code == kr_item["kpi_code"],
).first()
kpi_id = kpi.id if kpi else None
# 关联KPI时自动继承方向(KPI阈值如 '>=800'
if kpi_id and not kr_item.get("operator"):
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
if kpi and kpi.threshold_green:
op2, tgt2 = _parse_kr_operator_target(kpi.threshold_green)
op, tgt = op2, (tgt2 if tgt is None else tgt)
kr = existing_krs.get(title)
if kr is None:
kr = KR(
entity_id=entity_id,
objective_id=objective.id,
title=title,
)
db.add(kr)
kr.metric_kpi_id = kpi_id
kr.operator = op
if kr_item.get("tolerance") is not None:
kr.tolerance = kr_item.get("tolerance")
if kr_item.get("weight") is not None:
kr.weight = kr_item.get("weight")
if tgt is not None:
kr.target_value = tgt
if kr_item.get("current_value") is not None:
kr.current_value = kr_item.get("current_value")
if kr_item.get("due_date"):
kr.due_date = _parse_kr_date(kr_item.get("due_date"))
kr.sort_order = i + 1
keep_ids.add(kr.id)
kr_item["_kr_id"] = kr.id
# 3. 删除JSON中不存在的krs(空标题的除外——它从未入库)
for title, kr in existing_krs.items():
if kr.id not in keep_ids:
db.delete(kr)
db.commit()
# 回写dimensions(含_objective_id/_kr_id
m.dimensions = dims
db.commit()
def _map_quarter(m) -> str:
"""从地图标题提取季度标识(如 2026Q3),无则空字符串"""
title = m.title or ""
import re
m_q = re.search(r"(20\d{2})\s*[Qq](\d)", title)
if m_q:
return f"{m_q.group(1)}Q{m_q.group(2)}"
return ""
def _parse_kr_date(v):
if v is None or v == "":
return None
if isinstance(v, datetime):
return v.date()
try:
return datetime.strptime(str(v)[:10], "%Y-%m-%d").date()
except (ValueError, TypeError):
return None
# ── 战略回顾会 聚合接口 ──────────────────────
+378 -60
View File
@@ -1,10 +1,11 @@
"""
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
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
@@ -16,6 +17,159 @@ router = APIRouter(prefix="/api/cma/okr", tags=["OKR目标管理"],
)
# ── 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"),
@@ -24,7 +178,7 @@ def list_objectives(
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""列出OKR目标(账套隔离: 按token企业, 2026-08-23 P2"""
"""列出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)
@@ -35,9 +189,10 @@ def list_objectives(
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()
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}
{"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({
@@ -47,7 +202,7 @@ def list_objectives(
"confidence": o.confidence,
"key_results": kr_summary,
"kr_count": len(krs),
"kr_completed": sum(1 for kr in krs if kr.status == "completed"),
"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}
@@ -60,7 +215,6 @@ def create_objective(
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")
@@ -78,11 +232,9 @@ def create_objective(
@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()
"""获取单个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,
@@ -90,31 +242,195 @@ def get_objective(obj_id: int, db: Session = Depends(get_db), entity_id: int = D
"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
]
"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)):
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进度"""
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()
_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 必须是数组")
# 规范化:只保留 month/label/status/target 字段
normalized = []
for ms in milestones:
normalized.append({
@@ -124,44 +440,45 @@ def update_kr_milestones(okr_id: int, kr_id: int, data: dict, db: Session = Depe
"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 = "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)):
def auto_generate_milestones(okr_id: int, data: dict, db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id)):
"""为KR自动生成月度里程碑(按季度剩余月份均分)"""
obj = db.query(Objective).filter(Objective.id == okr_id).first()
if not obj:
raise HTTPException(404, "OKR不存在")
_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(ActionPlan).filter(
ActionPlan.id == kr_id, ActionPlan.objective_id == okr_id
).first()
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 = _build_auto_milestones(kr)
kr.monthly_milestones = milestones
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: ActionPlan) -> list:
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)
# 计算前3个月份(从最早的月份开始)
month_keys = []
for i in range(3, 0, -1):
y, m = base.year, base.month - i
@@ -180,34 +497,35 @@ def _build_auto_milestones(kr: ActionPlan) -> list:
]
@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}
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)):
"""获取OKR的时间分解视图数据"""
okr = db.query(Objective).filter(Objective.id == okr_id).first()
if not okr:
raise HTTPException(404, "OKR不存在")
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.quarter.is_(None),
Objective.entity_id == entity_id,
).first()
# 2. 本OKR的所有KR关联到该Objective的ActionPlan
krs = db.query(ActionPlan).filter(ActionPlan.objective_id == okr_id).all()
# 2. 本OKR的所有KRkrs表
krs = db.query(KR).filter(KR.objective_id == okr_id).order_by(KR.id).all()
# 3. 当前周的ActionPlan(本周行动计划)
now = datetime.now()
+2
View File
@@ -89,6 +89,8 @@ def trace_ontology(
"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": kr.operator or ">=",
"weight": _num(kr.weight),
"target_value": _num(kr.target_value),
"current_value": _num(kr.current_value),
"progress": kr.progress,