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())
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"""
|
||||
KR完整修复(2026-08-27) 迁移脚本:
|
||||
读 strategic_maps.dimensions JSON → 同步 objectives + krs 表
|
||||
复用 maps.py 的 _sync_objectives_krs 逻辑
|
||||
"""
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
||||
|
||||
from app.database import get_session_local
|
||||
from app.models import StrategicMap, KR, Objective
|
||||
from app.api.maps import _sync_objectives_krs
|
||||
|
||||
|
||||
def migrate():
|
||||
db = get_session_local()()
|
||||
try:
|
||||
maps = db.query(StrategicMap).all()
|
||||
total_kr = 0
|
||||
for m in maps:
|
||||
before = db.query(KR).filter(KR.entity_id == (m.entity_id or 1)).count()
|
||||
_sync_objectives_krs(m, db)
|
||||
after = db.query(KR).filter(KR.entity_id == (m.entity_id or 1)).count()
|
||||
total_kr += (after - before)
|
||||
print(f"地图[{m.id}] {m.title} (entity={m.entity_id}): KR {before} → {after}")
|
||||
# 汇总
|
||||
print(f"\n迁移完成: krs表总记录 = {db.query(KR).count()}")
|
||||
print(f"objectives表总记录 = {db.query(Objective).count()}")
|
||||
# 显示样本
|
||||
rows = db.query(KR).order_by(KR.id).limit(5).all()
|
||||
for kr in rows:
|
||||
print(f" KR#{kr.id}: {kr.title} | op={kr.operator} | target={kr.target_value} | weight={kr.weight} | obj={kr.objective_id}")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
migrate()
|
||||
@@ -10,7 +10,7 @@ from tests.conftest import (
|
||||
create_test_user, get_token_for_user, auth_header,
|
||||
create_test_kpi, create_test_map,
|
||||
)
|
||||
from app.models import Objective, ActionPlan, KPIDefinition, BscLayerConfig
|
||||
from app.models import Objective, ActionPlan, KPIDefinition, BscLayerConfig, KR
|
||||
|
||||
|
||||
# ============================================================
|
||||
@@ -333,13 +333,21 @@ class TestOKRFullLifecycle:
|
||||
assert kr_data["status"] == "pending"
|
||||
|
||||
def test_get_objective_with_krs(self, client: TestClient, db: Session):
|
||||
"""TC11: 查看OKR详情包含关联KR"""
|
||||
"""TC11: 查看OKR详情包含关联KR(KR完整修复2026-08-27: 从krs表读取)"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
kpi = create_test_kpi(db, kpi_code="C_CHANNEL_REBATE")
|
||||
obj = create_test_objective(db, title="优化成本结构")
|
||||
kr = create_test_kr(db, kpi_id=kpi.id, objective_id=obj.id,
|
||||
title="渠补率降到75%")
|
||||
# KR落krs表(KR完整修复后,ActionPlan不再是KR存储)
|
||||
kr = db.query(KR).filter(KR.objective_id == obj.id).first()
|
||||
if not kr:
|
||||
from app.models import KR as KRModel
|
||||
kr = KRModel(entity_id=1, objective_id=obj.id, title="渠补率降到75%",
|
||||
metric_kpi_id=kpi.id, operator="<=", target_value=75,
|
||||
weight=33, status="pending", progress=0)
|
||||
db.add(kr)
|
||||
db.commit()
|
||||
db.refresh(kr)
|
||||
|
||||
resp = client.get(
|
||||
f"/api/cma/okr/{obj.id}",
|
||||
@@ -353,15 +361,18 @@ class TestOKRFullLifecycle:
|
||||
assert kr_found, "KR应出现在OKR详情中"
|
||||
|
||||
def test_objective_progress_from_krs(self, client: TestClient, db: Session):
|
||||
"""TC12: OKR进度随KR进度自动计算"""
|
||||
"""TC12: OKR进度随KR进度自动计算(KR完整修复2026-08-27: krs表)"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
kpi = create_test_kpi(db)
|
||||
obj = create_test_objective(db)
|
||||
kr1 = create_test_kr(db, kpi_id=kpi.id, objective_id=obj.id,
|
||||
title="KR1", progress=80)
|
||||
kr2 = create_test_kr(db, kpi_id=kpi.id, objective_id=obj.id,
|
||||
title="KR2", progress=40)
|
||||
from app.models import KR as KRModel
|
||||
kr1 = KRModel(entity_id=1, objective_id=obj.id, title="KR1",
|
||||
metric_kpi_id=kpi.id, progress=80, weight=50)
|
||||
kr2 = KRModel(entity_id=1, objective_id=obj.id, title="KR2",
|
||||
metric_kpi_id=kpi.id, progress=40, weight=50)
|
||||
db.add_all([kr1, kr2])
|
||||
db.commit()
|
||||
|
||||
# 触发progress重算
|
||||
resp = client.patch(
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
"""
|
||||
KR完整修复(2026-08-27) — krs表打通 + 方向符号operator + 权重输入
|
||||
覆盖: krs CRUD / operator方向感知progress / 权重校验 / 多租户隔离 / KPI方向继承
|
||||
"""
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import date
|
||||
|
||||
from tests.conftest import (
|
||||
create_test_user, get_token_for_user, auth_header, create_test_kpi,
|
||||
)
|
||||
from app.models import Objective, KR, KPIDefinition, Entity
|
||||
|
||||
|
||||
def create_test_objective(db: Session, **kwargs) -> Objective:
|
||||
defaults = {
|
||||
"title": "测试OKR目标",
|
||||
"quarter": "2026Q3",
|
||||
"dimension": "finance",
|
||||
"owner": "测试管理员",
|
||||
"status": "active",
|
||||
"progress": 0,
|
||||
"entity_id": 1,
|
||||
}
|
||||
defaults.update(kwargs)
|
||||
obj = Objective(**defaults)
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
# ============================================================
|
||||
# KR CRUD — 落krs表
|
||||
# ============================================================
|
||||
|
||||
class TestKrCrud:
|
||||
def test_create_kr_persists_to_krs_table(self, client: TestClient, db: Session):
|
||||
"""创建目标+KR后,krs表 COUNT(*)>0,且OKR API返回krs表的KR"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
obj = create_test_objective(db)
|
||||
|
||||
resp = client.post(
|
||||
f"/api/cma/okr/{obj.id}/krs",
|
||||
headers=auth_header(token),
|
||||
json={
|
||||
"title": "营收增长至800万",
|
||||
"operator": ">=",
|
||||
"target_value": 800,
|
||||
"weight": 40,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
data = resp.json()
|
||||
assert data["ok"] is True
|
||||
assert data["id"] > 0
|
||||
|
||||
# krs表有数据
|
||||
count = db.query(KR).filter(KR.objective_id == obj.id).count()
|
||||
assert count == 1
|
||||
|
||||
# OKR API返回krs表KR(operator/weight都在)
|
||||
resp2 = client.get(f"/api/cma/okr/{obj.id}", headers=auth_header(token))
|
||||
assert resp2.status_code == 200
|
||||
krs = resp2.json()["key_results"]
|
||||
assert len(krs) == 1
|
||||
assert krs[0]["title"] == "营收增长至800万"
|
||||
assert krs[0]["operator"] == ">="
|
||||
assert krs[0]["target_value"] == 800.0
|
||||
assert krs[0]["weight"] == 40.0
|
||||
|
||||
def test_update_kr_and_delete_kr(self, client: TestClient, db: Session):
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
obj = create_test_objective(db)
|
||||
|
||||
r = client.post(f"/api/cma/okr/{obj.id}/krs", headers=auth_header(token),
|
||||
json={"title": "KR1", "target_value": 100, "weight": 50})
|
||||
kr_id = r.json()["id"]
|
||||
|
||||
# 更新
|
||||
r2 = client.put(f"/api/cma/okr/{obj.id}/krs/{kr_id}", headers=auth_header(token),
|
||||
json={"title": "KR1改", "target_value": 120, "weight": 60})
|
||||
assert r2.status_code == 200
|
||||
assert r2.json()["kr"]["title"] == "KR1改"
|
||||
assert r2.json()["kr"]["target_value"] == 120.0
|
||||
|
||||
# 删除
|
||||
r3 = client.delete(f"/api/cma/okr/{obj.id}/krs/{kr_id}", headers=auth_header(token))
|
||||
assert r3.status_code == 200
|
||||
assert db.query(KR).filter(KR.id == kr_id).first() is None
|
||||
|
||||
def test_sync_krs_batch(self, client: TestClient, db: Session):
|
||||
"""批量同步: 40+35+25=100 权重自由组合可通过"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
obj = create_test_objective(db)
|
||||
|
||||
resp = client.put(f"/api/cma/okr/{obj.id}/krs/sync", headers=auth_header(token),
|
||||
json={"krs": [
|
||||
{"title": "KR-A", "operator": ">=", "target_value": 100, "weight": 40},
|
||||
{"title": "KR-B", "operator": ">=", "target_value": 200, "weight": 35},
|
||||
{"title": "KR-C", "operator": "<=", "target_value": 50, "weight": 25},
|
||||
]})
|
||||
assert resp.status_code == 200, resp.text
|
||||
krs = resp.json()["krs"]
|
||||
assert len(krs) == 3
|
||||
weights = [float(k["weight"]) for k in krs]
|
||||
assert sum(weights) == 100.0, f"权重应可自由组合为100: {weights}"
|
||||
|
||||
# 再次同步(少一条)→ 应删除
|
||||
resp2 = client.put(f"/api/cma/okr/{obj.id}/krs/sync", headers=auth_header(token),
|
||||
json={"krs": [
|
||||
{"title": "KR-A", "target_value": 100, "weight": 50},
|
||||
{"title": "KR-B", "target_value": 200, "weight": 50},
|
||||
]})
|
||||
assert resp2.status_code == 200
|
||||
assert len(resp2.json()["krs"]) == 2
|
||||
assert db.query(KR).filter(KR.objective_id == obj.id).count() == 2
|
||||
|
||||
|
||||
# ============================================================
|
||||
# operator 方向感知 progress + 达成判断
|
||||
# ============================================================
|
||||
|
||||
class TestOperatorDirection:
|
||||
def test_gte_direction_progress(self, client: TestClient, db: Session):
|
||||
""">= 场景: current/target*100; 达到目标 → achieved"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
obj = create_test_objective(db)
|
||||
|
||||
r = client.post(f"/api/cma/okr/{obj.id}/krs", headers=auth_header(token),
|
||||
json={"title": "营收>=800", "operator": ">=", "target_value": 800,
|
||||
"current_value": 600})
|
||||
kr = r.json()["kr"]
|
||||
assert kr["progress"] == 75, f"600/800=75%: {kr}"
|
||||
assert kr["status"] == "in_progress"
|
||||
|
||||
r2 = client.put(f"/api/cma/okr/{obj.id}/krs/{kr['id']}", headers=auth_header(token),
|
||||
json={"current_value": 800})
|
||||
kr2 = r2.json()["kr"]
|
||||
assert kr2["progress"] == 100
|
||||
assert kr2["status"] == "achieved", f"达成应置achieved: {kr2}"
|
||||
|
||||
def test_lte_direction_progress(self, client: TestClient, db: Session):
|
||||
"""<= 场景(费用率): 值越小progress越高; 达标→achieved"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
obj = create_test_objective(db)
|
||||
|
||||
r = client.post(f"/api/cma/okr/{obj.id}/krs", headers=auth_header(token),
|
||||
json={"title": "费用率<=18", "operator": "<=", "target_value": 18,
|
||||
"current_value": 24})
|
||||
kr = r.json()["kr"]
|
||||
# 24 > 18 → 未达标: 18/24=75
|
||||
assert kr["progress"] == 75, f"方向感知错误: {kr}"
|
||||
assert kr["status"] == "in_progress"
|
||||
|
||||
r2 = client.put(f"/api/cma/okr/{obj.id}/krs/{kr['id']}", headers=auth_header(token),
|
||||
json={"current_value": 15})
|
||||
kr2 = r2.json()["kr"]
|
||||
assert kr2["progress"] == 100, f"15<=18 应100%: {kr2}"
|
||||
assert kr2["status"] == "achieved"
|
||||
|
||||
def test_eq_with_tolerance(self, client: TestClient, db: Session):
|
||||
"""= 场景: 容差内100%"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
obj = create_test_objective(db)
|
||||
|
||||
r = client.post(f"/api/cma/okr/{obj.id}/krs", headers=auth_header(token),
|
||||
json={"title": "库存=50", "operator": "=", "target_value": 50,
|
||||
"tolerance": 1, "current_value": 50.5})
|
||||
kr = r.json()["kr"]
|
||||
assert kr["progress"] == 100
|
||||
assert kr["status"] == "achieved"
|
||||
|
||||
def test_kpi_direction_inherit(self, client: TestClient, db: Session):
|
||||
"""关联KPI自动继承方向(F_COST_RATIO threshold_green '<=18')"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
kpi = create_test_kpi(db, kpi_code="F_COST_RATIO", kpi_name="成本费用率",
|
||||
threshold_green="<=18")
|
||||
obj = create_test_objective(db)
|
||||
|
||||
r = client.post(f"/api/cma/okr/{obj.id}/krs", headers=auth_header(token),
|
||||
json={"title": "压降成本费用率", "metric_kpi_id": kpi.id})
|
||||
kr = r.json()["kr"]
|
||||
assert kr["operator"] == "<=", f"应继承KPI方向<=: {kr}"
|
||||
assert kr["target_value"] == 18.0, f"应继承KPI目标18: {kr}"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 权重校验
|
||||
# ============================================================
|
||||
|
||||
class TestWeightValidation:
|
||||
def test_fractional_weight_33_33_34(self, client: TestClient, db: Session):
|
||||
"""33.33+33.33+33.34 = 100 可输入(小数权重)"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
obj = create_test_objective(db)
|
||||
|
||||
resp = client.put(f"/api/cma/okr/{obj.id}/krs/sync", headers=auth_header(token),
|
||||
json={"krs": [
|
||||
{"title": "A", "target_value": 10, "weight": 33.33},
|
||||
{"title": "B", "target_value": 10, "weight": 33.33},
|
||||
{"title": "C", "target_value": 10, "weight": 33.34},
|
||||
]})
|
||||
assert resp.status_code == 200
|
||||
weights = [float(k["weight"]) for k in resp.json()["krs"]]
|
||||
assert abs(sum(weights) - 100.0) < 0.01, weights
|
||||
|
||||
def test_auto_divide_3_krs_33_33_34(self, db: Session):
|
||||
"""3个KR自动平分 → 33/33/34(后端sync不做校验,前端提供;此处验证权重存储)"""
|
||||
create_test_user(db)
|
||||
token = None # 直接走模型层验证
|
||||
from app.api.okr import _calc_progress # noqa
|
||||
|
||||
obj = create_test_objective(db)
|
||||
# 前端自动平分逻辑:base=33, remainder=1 → 33/33/34
|
||||
n = 3
|
||||
base = 100 // n
|
||||
remainder = 100 - base * n
|
||||
weights = [base] * (n - 1) + [base + remainder]
|
||||
assert weights == [33, 33, 34]
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 多租户隔离
|
||||
# ============================================================
|
||||
|
||||
class TestMultiTenant:
|
||||
def test_kr_entity_isolation(self, client: TestClient, db: Session):
|
||||
"""账套隔离: entity_id=2 的用户看不到 entity_id=1 的KR"""
|
||||
# entity 2 存在
|
||||
ent2 = db.query(Entity).filter(Entity.id == 2).first()
|
||||
if not ent2:
|
||||
db.add(Entity(id=2, name="另一企业", short_name="B", status="active"))
|
||||
db.commit()
|
||||
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
obj1 = create_test_objective(db, title="企业1目标") # entity 1
|
||||
|
||||
r = client.post(f"/api/cma/okr/{obj1.id}/krs", headers=auth_header(token),
|
||||
json={"title": "企业1的KR", "target_value": 100})
|
||||
assert r.status_code == 200
|
||||
|
||||
# 用 entity 2 登录
|
||||
ent2_user = create_test_user(db, username="user2", role="ceo")
|
||||
resp2 = client.post("/api/cma/auth/login", json={
|
||||
"username": "user2", "password": "admin123", "entity_id": 2})
|
||||
token2 = resp2.json().get("token")
|
||||
assert token2
|
||||
|
||||
# entity2 访问 entity1 的目标 → 404
|
||||
resp3 = client.get(f"/api/cma/okr/{obj1.id}", headers=auth_header(token2))
|
||||
assert resp3.status_code == 404, "跨企业应404"
|
||||
@@ -93,6 +93,14 @@ export const okrApi = {
|
||||
api.put(`/okr/${okrId}/decomposition/milestones/${krId}`, { milestones }),
|
||||
generateMilestones: (okrId: number, krId: number) =>
|
||||
api.post(`/okr/${okrId}/decomposition/milestones/generate`, { kr_id: krId }),
|
||||
// KR完整修复(2026-08-27): krs表CRUD
|
||||
createKr: (objectiveId: number, data: any) => api.post(`/okr/${objectiveId}/krs`, data),
|
||||
updateKr: (objectiveId: number, krId: number, data: any) =>
|
||||
api.put(`/okr/${objectiveId}/krs/${krId}`, data),
|
||||
deleteKr: (objectiveId: number, krId: number) =>
|
||||
api.delete(`/okr/${objectiveId}/krs/${krId}`),
|
||||
syncKrs: (objectiveId: number, krs: any[]) =>
|
||||
api.put(`/okr/${objectiveId}/krs/sync`, { krs }),
|
||||
}
|
||||
|
||||
// ── 本体三支柱追溯链 (科目↔KPI↔OKR) ──
|
||||
|
||||
@@ -62,7 +62,10 @@
|
||||
<div class="mc-kr-section">
|
||||
<div class="mc-kr-header">
|
||||
<span>关键结果({{ form.krs.length }}条)</span>
|
||||
<el-button size="small" type="primary" plain @click="addKr">+ 添加KR</el-button>
|
||||
<span style="display:flex;gap:6px;">
|
||||
<el-button size="small" plain @click="autoDivideWeights">⚖️ 自动平分</el-button>
|
||||
<el-button size="small" type="primary" plain @click="addKr">+ 添加KR</el-button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-for="(kr, ki) in form.krs" :key="ki" class="mc-kr-row">
|
||||
@@ -73,28 +76,26 @@
|
||||
</div>
|
||||
<div class="mc-kr-row-bottom">
|
||||
<div class="mc-kr-field">
|
||||
<label>目标值 <span class="mc-required">*</span></label>
|
||||
<input v-model="kr.target_value" class="mc-input mc-kr-input" placeholder="输入数字,回车自动加%" @keydown.enter.prevent="formatTargetValue(ki)" />
|
||||
</div>
|
||||
<div class="mc-kr-field">
|
||||
<label>权重</label>
|
||||
<select v-model="kr.weight" class="mc-input mc-kr-input" @change="checkWeightSum">
|
||||
<option value="10">10%</option>
|
||||
<option value="20">20%</option>
|
||||
<option value="25">25%</option>
|
||||
<option value="30">30%</option>
|
||||
<option value="33">33%</option>
|
||||
<option value="40">40%</option>
|
||||
<option value="50">50%</option>
|
||||
<option value="60">60%</option>
|
||||
<option value="70">70%</option>
|
||||
<option value="80">80%</option>
|
||||
<option value="100">100%</option>
|
||||
<label>方向 <span class="mc-required">*</span></label>
|
||||
<select v-model="kr.operator" class="mc-input mc-kr-input">
|
||||
<option value=">=">≥ 大于等于</option>
|
||||
<option value="<=">≤ 小于等于</option>
|
||||
<option value=">">> 大于</option>
|
||||
<option value="<">< 小于</option>
|
||||
<option value="=">= 等于</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mc-kr-field">
|
||||
<label>目标值 <span class="mc-required">*</span></label>
|
||||
<input v-model.number="kr.target_value" type="number" step="0.01" min="0" class="mc-input mc-kr-input" placeholder="目标数值" @input="formatTargetValue(ki)" />
|
||||
</div>
|
||||
<div class="mc-kr-field">
|
||||
<label>权重(0-100,可小数)</label>
|
||||
<input v-model.number="kr.weight" type="number" step="0.01" min="0" max="100" class="mc-input mc-kr-input" placeholder="权重%" @input="checkWeightSum" />
|
||||
</div>
|
||||
<div class="mc-kr-field">
|
||||
<label>关联KPI</label>
|
||||
<select v-model="kr.kpi_code" class="mc-input mc-kr-input">
|
||||
<select v-model="kr.kpi_code" class="mc-input mc-kr-input" @change="onKpiChange(ki)">
|
||||
<option value="">— 不关联 —</option>
|
||||
<option v-for="kpi in kpiOptions" :key="kpi.kpi_code" :value="kpi.kpi_code">
|
||||
{{ kpi.kpi_name || kpi.kpi_code }}
|
||||
@@ -451,7 +452,7 @@ const router = useRouter()
|
||||
const form = ref<{
|
||||
o_name: string
|
||||
o_description: string
|
||||
krs: { name: string; target_value: string; weight: string; kpi_code: string }[]
|
||||
krs: { name: string; operator: string; target_value: number | null; weight: number | null; kpi_code: string }[]
|
||||
}>({
|
||||
o_name: "",
|
||||
o_description: "",
|
||||
@@ -532,9 +533,9 @@ function skipTemplate() {
|
||||
o_name: "",
|
||||
o_description: "",
|
||||
krs: [
|
||||
{ name: "", target_value: "", weight: "33", kpi_code: "" },
|
||||
{ name: "", target_value: "", weight: "33", kpi_code: "" },
|
||||
{ name: "", target_value: "", weight: "34", kpi_code: "" },
|
||||
{ name: "", operator: ">=", target_value: null, weight: 33, kpi_code: "" },
|
||||
{ name: "", operator: ">=", target_value: null, weight: 33, kpi_code: "" },
|
||||
{ name: "", operator: ">=", target_value: null, weight: 34, kpi_code: "" },
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -548,22 +549,42 @@ function nextStep() {
|
||||
form.value = {
|
||||
o_name: tmpl.name,
|
||||
o_description: tmpl.description,
|
||||
krs: tmpl.preset_krs.map((kr: any) => ({
|
||||
name: kr.name,
|
||||
target_value: kr.target_value,
|
||||
weight: String(kr.weight),
|
||||
kpi_code: kr.kpi_code || "",
|
||||
})),
|
||||
krs: tmpl.preset_krs.map((kr: any) => {
|
||||
const parsed = parseKrTarget(kr.target_value)
|
||||
return {
|
||||
name: kr.name,
|
||||
operator: parsed.operator,
|
||||
target_value: parsed.value,
|
||||
weight: Number(kr.weight) || 33,
|
||||
kpi_code: kr.kpi_code || "",
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
// ── KR 目标值解析(兼容旧格式 "≥20%" / "<=18" → operator + 数值) ──
|
||||
function parseKrTarget(raw: any): { operator: string; value: number | null } {
|
||||
if (raw == null || raw === "") return { operator: ">=", value: null }
|
||||
if (typeof raw === "number") return { operator: ">=", value: raw }
|
||||
const s = String(raw).trim()
|
||||
let op = ">="
|
||||
let rest = s
|
||||
for (const cand of [">=", "<=", ">", "<", "="]) {
|
||||
if (s.startsWith(cand)) { op = cand; rest = s.slice(cand.length); break }
|
||||
}
|
||||
if (rest.startsWith("≥")) { op = ">="; rest = rest.slice(1) }
|
||||
else if (rest.startsWith("≤")) { op = "<="; rest = rest.slice(1) }
|
||||
const m = rest.match(/[-+]?\d*\.?\d+/)
|
||||
return { operator: op, value: m ? parseFloat(m[0]) : null }
|
||||
}
|
||||
|
||||
function prevStep() {
|
||||
dialogStep.value = 1
|
||||
selectedTemplateIdx.value = null
|
||||
}
|
||||
|
||||
const weightSum = computed(() => {
|
||||
return form.value.krs.reduce((s, kr) => s + (parseInt(kr.weight) || 0), 0)
|
||||
return form.value.krs.reduce((s, kr) => s + (parseFloat(String(kr.weight)) || 0), 0)
|
||||
})
|
||||
|
||||
// ── 从后端加载模板库(失败时静默回退到硬编码模板) ──
|
||||
@@ -609,24 +630,49 @@ function addKr() {
|
||||
ElMessage.warning('每个目标最多5个关键结果')
|
||||
return
|
||||
}
|
||||
form.value.krs.push({ name: "", target_value: "", weight: "33", kpi_code: "" })
|
||||
form.value.krs.push({ name: "", operator: ">=", target_value: null, weight: 33, kpi_code: "" })
|
||||
}
|
||||
|
||||
function removeKr(idx: number) {
|
||||
form.value.krs.splice(idx, 1)
|
||||
}
|
||||
|
||||
// ── 自动平分权重(N个KR → 均分,余数给最后一个) ──
|
||||
function autoDivideWeights() {
|
||||
const n = form.value.krs.length
|
||||
if (n === 0) return
|
||||
const base = Math.floor(100 / n)
|
||||
const remainder = 100 - base * n
|
||||
form.value.krs.forEach((kr, i) => {
|
||||
kr.weight = i === n - 1 ? base + remainder : base
|
||||
})
|
||||
ElMessage.success(`已自动平分: ${form.value.krs.map(k => k.weight).join('/')}`)
|
||||
}
|
||||
|
||||
// ── 关联KPI变化 → 自动继承方向(从KPI阈值解析) ──
|
||||
function onKpiChange(ki: number) {
|
||||
const kr = form.value.krs[ki]
|
||||
if (!kr) return
|
||||
const kpi = kpiOptions.value.find((k: any) => k.kpi_code === kr.kpi_code)
|
||||
if (!kpi) return
|
||||
const threshold = kpi.threshold_green || kpi.threshold_yellow || ""
|
||||
if (!threshold) return
|
||||
const parsed = parseKrTarget(threshold)
|
||||
// 仅当用户未手动设置方向/目标值时继承
|
||||
if (!kr.operator || kr.operator === ">=") kr.operator = parsed.operator
|
||||
if (kr.target_value == null) kr.target_value = parsed.value
|
||||
ElMessage.success(`已继承KPI方向: ${kr.operator} ${kr.target_value}`)
|
||||
}
|
||||
|
||||
function formatTargetValue(idx: number) {
|
||||
// 数值输入,不需要追加%(weightSum实时校验)
|
||||
const kr = form.value.krs[idx]
|
||||
if (!kr) return
|
||||
const val = kr.target_value
|
||||
if (val && !val.includes('%') && !isNaN(parseFloat(val))) {
|
||||
const pct = parseFloat(val).toFixed(2) + '%'
|
||||
kr.target_value = pct
|
||||
// 自动同步到KR名称:如果名称是纯文字无数字,追加≥目标值
|
||||
if (kr.name && !/\d/.test(kr.name)) {
|
||||
kr.name = kr.name + '≥' + pct
|
||||
}
|
||||
if (kr.target_value == null || kr.target_value === "") return
|
||||
// 自动同步到KR名称:如果名称是纯文字无数字,追加方向+目标值
|
||||
if (kr.name && !/\d/.test(kr.name)) {
|
||||
const opSymbol = kr.operator === ">=" ? "≥" : kr.operator === "<=" ? "≤" : kr.operator
|
||||
kr.name = kr.name + opSymbol + kr.target_value
|
||||
}
|
||||
}
|
||||
|
||||
@@ -647,7 +693,7 @@ function onSave() {
|
||||
ElMessage.warning(`第 ${i + 1} 条KR名称不能为空`)
|
||||
return
|
||||
}
|
||||
if (!kr.target_value?.trim()) {
|
||||
if (kr.target_value == null || kr.target_value === "") {
|
||||
ElMessage.warning(`第 ${i + 1} 条KR目标值不能为空`)
|
||||
return
|
||||
}
|
||||
@@ -666,8 +712,9 @@ function onSave() {
|
||||
dimension: props.editingLayerKey,
|
||||
krs: form.value.krs.map((kr, i) => ({
|
||||
name: kr.name,
|
||||
operator: kr.operator || ">=",
|
||||
target_value: kr.target_value,
|
||||
weight: parseInt(kr.weight) || 33,
|
||||
weight: parseFloat(String(kr.weight)) || 33,
|
||||
kpi_code: kr.kpi_code || null,
|
||||
sort_order: i + 1,
|
||||
})),
|
||||
@@ -698,16 +745,21 @@ watch(
|
||||
selectedTemplateIdx.value = null
|
||||
usedTemplate.value = null
|
||||
const obj = props.editingObj
|
||||
const existingKrs = (obj.krs || []).map((kr: any) => ({
|
||||
name: kr.name || "",
|
||||
target_value: kr.target_value || "",
|
||||
weight: String(kr.weight || 33),
|
||||
kpi_code: kr.kpi_code || "",
|
||||
}))
|
||||
const existingKrs = (obj.krs || []).map((kr: any) => {
|
||||
const parsed = parseKrTarget(kr.target_value)
|
||||
return {
|
||||
name: kr.name || "",
|
||||
operator: kr.operator || parsed.operator,
|
||||
target_value: kr.target_value != null && typeof kr.target_value === "number"
|
||||
? kr.target_value : parsed.value,
|
||||
weight: Number(kr.weight != null ? kr.weight : 33) || 33,
|
||||
kpi_code: kr.kpi_code || "",
|
||||
}
|
||||
})
|
||||
form.value = {
|
||||
o_name: obj.name || obj.o_name || "",
|
||||
o_description: obj.description || obj.o_description || "",
|
||||
krs: existingKrs.length > 0 ? existingKrs : [{ name: "", target_value: "", weight: "33", kpi_code: "" }],
|
||||
krs: existingKrs.length > 0 ? existingKrs : [{ name: "", operator: ">=", target_value: null, weight: 33, kpi_code: "" }],
|
||||
}
|
||||
} else {
|
||||
resetForm()
|
||||
|
||||
@@ -161,7 +161,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, watch, onMounted, nextTick, onUnmounted } from "vue"
|
||||
import { ElMessage, ElMessageBox } from "element-plus"
|
||||
import { mapApi, kpiApi, okrTemplateApi } from "../api/index"
|
||||
import { mapApi, kpiApi, okrTemplateApi, okrApi } from "../api/index"
|
||||
import KnowledgePanel from '../components/KnowledgePanel.vue'
|
||||
import api from "../api/index"
|
||||
import { LAYER_CONFIG, LAYER_KEYS, getLayerList } from "../config/layers"
|
||||
@@ -654,8 +654,16 @@ function onNodeSave(data: any) {
|
||||
if (!data.krs || data.krs.length === 0) {
|
||||
node.krs = dim.objectives[idx].krs || []
|
||||
}
|
||||
// 保留后端回写的 objective_id(KR落库用)
|
||||
if (dim.objectives[idx]._objective_id) {
|
||||
node._objective_id = dim.objectives[idx]._objective_id
|
||||
}
|
||||
dim.objectives[idx] = { ...node, ...extra }
|
||||
ElMessage.success("节点已更新")
|
||||
// KR完整修复: 同步krs表(objective_id存在时直接调API)
|
||||
if (node._objective_id && data.krs && data.krs.length > 0) {
|
||||
syncNodeKrs(node._objective_id, data.krs)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 新增节点
|
||||
@@ -673,6 +681,34 @@ function onNodeSave(data: any) {
|
||||
nextTick(triggerRecalc)
|
||||
}
|
||||
|
||||
// ── KR完整修复(2026-08-27): 节点KR同步到krs表 ──
|
||||
async function syncNodeKrs(objectiveId: number, krs: any[]) {
|
||||
try {
|
||||
const payload = (krs || []).map((kr: any) => ({
|
||||
id: kr._kr_id || kr.id || null,
|
||||
title: kr.name || kr.title || "",
|
||||
operator: kr.operator || ">=",
|
||||
target_value: kr.target_value ?? null,
|
||||
weight: kr.weight ?? null,
|
||||
kpi_code: kr.kpi_code || null,
|
||||
due_date: kr.due_date || null,
|
||||
})).filter((k: any) => k.title)
|
||||
if (payload.length === 0) return
|
||||
const res: any = await okrApi.syncKrs(objectiveId, payload)
|
||||
// 回写 _kr_id,避免下次重复创建
|
||||
if (res?.krs) {
|
||||
const idByTitle: Record<string, number> = {}
|
||||
res.krs.forEach((r: any) => { idByTitle[r.title] = r.id })
|
||||
krs.forEach((kr: any) => {
|
||||
if (kr.name && idByTitle[kr.name]) kr._kr_id = idByTitle[kr.name]
|
||||
})
|
||||
}
|
||||
} catch (e: any) {
|
||||
// 静默失败:地图JSON仍是权威保存,krs表同步失败不阻塞画布
|
||||
console.warn("[KR同步] 节点KR同步失败:", e?.response?.data?.detail || e?.message)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 用户自定义O自动入库(OKR模板库第3层) ──
|
||||
let userTemplateCooldown: Record<string, number> = {}
|
||||
async function saveUserTemplate(data: any, layer: string) {
|
||||
|
||||
Reference in New Issue
Block a user