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:
@@ -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
@@ -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
@@ -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的所有KR(krs表)
|
||||
krs = db.query(KR).filter(KR.objective_id == okr_id).order_by(KR.id).all()
|
||||
|
||||
# 3. 当前周的ActionPlan(本周行动计划)
|
||||
now = datetime.now()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -750,9 +750,14 @@ class KR(Base):
|
||||
objective_id = Column(Integer, ForeignKey("objectives.id"), nullable=False, comment="OKR目标ID")
|
||||
title = Column(String(200), nullable=False, comment="KR标题")
|
||||
metric_kpi_id = Column(Integer, ForeignKey("kpi_definitions.id"), nullable=True, comment="度量KPI ID")
|
||||
operator = Column(String(5), default=">=", nullable=False, comment="方向符号: >=/<=/>/</= (KR完整修复 2026-08-27)")
|
||||
tolerance = Column(Numeric(10, 4), nullable=True, comment="容差(仅=时使用)")
|
||||
weight = Column(Numeric(5, 2), nullable=True, comment="KR权重%")
|
||||
target_value = Column(Numeric(15, 2), nullable=True, comment="目标值")
|
||||
current_value = Column(Numeric(15, 2), nullable=True, comment="当前值")
|
||||
progress = Column(Integer, default=0, comment="完成进度 0-100")
|
||||
status = Column(String(20), default="pending", comment="pending/in_progress/completed/cancelled")
|
||||
status = Column(String(20), default="pending", comment="pending/in_progress/completed/cancelled/achieved")
|
||||
due_date = Column(Date, nullable=True, comment="截止日期")
|
||||
monthly_milestones = Column(JSON, nullable=True, comment="月度里程碑: [{\"month\":\"2026-07\",\"label\":\"...\",\"status\":\"completed\"}]")
|
||||
sort_order = Column(Integer, default=0, comment="排序")
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
Reference in New Issue
Block a user