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:
+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
|
||||
|
||||
|
||||
# ── 战略回顾会 聚合接口 ──────────────────────
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user