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
+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
# ── 战略回顾会 聚合接口 ──────────────────────