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

- 后端okr.py: KR读取从ActionPlan改为krs表, 新增KR CRUD API(POST/PUT/DELETE /okr/{objective_id}/krs) + 批量sync
- operator方向符号: krs表加operator/tolerance/weight/sort_order/monthly_milestones列
- progress方向感知计算(>=/>: current/target, <=/<: target/current, =: 容差), 达成→status=achieved
- 关联KPI自动继承方向(threshold_green解析: F_COST_RATIO<=18等)
- maps.py: 保存地图时自动同步objectives+krs表, JSON→krs数据迁移脚本
- 前端: 权重下拉改自由数字输入(可小数33.33) + 方向选择器(≥/≤/>/</=) + 自动平分按钮 + KPI方向继承提示
- pytest: 10个新测试(krs CRUD/方向感知/权重校验/多租户隔离) + 更新旧KR测试
- 迁移: 现有strategic_maps JSON 12条KR已写入krs表
This commit is contained in:
Hermes CI Fix
2026-08-27 10:59:56 +08:00
parent 87cb9e7db2
commit 61936302b2
11 changed files with 1030 additions and 123 deletions
+5 -3
View File
@@ -13,7 +13,7 @@ from app.models import (
User, StrategicMap, KPIDefinition, KPITemplate, KPIValue,
DataSourceConfig, KPIAlert, OperationLog, NotificationChannel,
NotificationLog, RolePermission, ActionPlan, OrgNode,
StrategicMapVersion, MapObjective, Objective,
StrategicMapVersion, MapObjective, Objective, KR,
)
from app.models.budget_plan import BudgetPlan
from app.models.cost_model import StandardCost, ActualCost, AbcActivity, AbcAllocation
@@ -457,13 +457,15 @@ def bot_query(
objs = db.query(Objective).filter(Objective.status == "active").all()
result["okr"] = []
for o in objs:
krs = db.query(ActionPlan).filter(ActionPlan.objective_id == o.id).all()
# KR完整修复(2026-08-27): 从krs表读取
krs = db.query(KR).filter(KR.objective_id == o.id).all()
result["okr"].append({
"id": o.id, "title": o.title, "quarter": o.quarter,
"dimension": o.dimension, "progress": o.progress,
"confidence": o.confidence,
"key_results": [
{"title": kr.title, "status": kr.status, "progress": kr.progress}
{"title": kr.title, "status": kr.status, "progress": kr.progress,
"operator": kr.operator, "target_value": _float(kr.target_value)}
for kr in krs
]
})
+175 -1
View File
@@ -2,10 +2,11 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from typing import Optional
from datetime import datetime, timedelta
from app.database import get_db
from app.deps import get_entity_id
from app.auth_middleware import require_auth, require_role
from app.models import StrategicMap, OperationLog, MapObjective
from app.models import StrategicMap, OperationLog, MapObjective, Objective, KR, KPIDefinition
import json
router = APIRouter(prefix="/api/cma/maps", tags=["战略地图"],
@@ -127,6 +128,8 @@ def update_map(map_id: int, data: dict, db: Session = Depends(get_db), entity_id
db.commit()
# 同步目标到map_objectives表
_sync_map_objectives(m, db)
# KR完整修复(2026-08-27): 同步 objectives+krs 表
_sync_objectives_krs(m, db)
# ├─ 版本管理: draft → published 时自动创建快照
if old_status == "draft" and m.status == "published":
@@ -356,6 +359,177 @@ def _merge_map_objectives(m, db):
m.dimensions = dims
# ── KR完整修复(2026-08-27): 地图节点 → objectives + krs 表同步 ──
KR_OPERATORS = (">=", "<=", ">", "<", "=")
def _parse_kr_operator_target(raw_target, operator=None):
"""解析KR目标值字符串:
旧格式 '≥20%'/'<=18' → ('>=', 20.0)
新格式 operator='>=' + target=20 → ('>=', 20.0)
"""
if operator:
op = str(operator).strip()
if op == "":
op = ">="
elif op == "":
op = "<="
if op not in KR_OPERATORS:
op = ">="
else:
op = ">="
if raw_target is None:
return op, None
if isinstance(raw_target, (int, float)):
return op, float(raw_target)
s = str(raw_target).strip()
if s == "":
return op, None
# 前缀符号
for cand in KR_OPERATORS:
if s.startswith(cand):
op = cand
s = s[len(cand):]
break
if s.startswith(""):
op = ">="
s = s[1:]
elif s.startswith(""):
op = "<="
s = s[1:]
# 去掉单位(%、天、小时等非数字后缀)
num = s
while num and not (num[0].isdigit() or num[0] in ".+-"):
num = num[1:]
import re
m = re.match(r"[-+]?\d*\.?\d+", num)
if not m:
return op, None
return op, float(m.group(0))
def _sync_objectives_krs(m, db):
"""保存地图时:将dimensions JSON中带krs的目标节点同步到objectives表+krs表。
- 每个带krs的目标节点 → 确保objectives表有记录(按entity_id+name匹配,无则创建)
- krs数组 → krs表 upsert(按title匹配;删除本目标下JSON中不存在的krs)
- 回写 objective_id / kr_id 到节点JSON,方便前端下次直接使用
"""
dims = m.dimensions
if isinstance(dims, str):
try:
dims = json.loads(dims)
except Exception:
return
dims = dims or []
entity_id = m.entity_id if m.entity_id is not None else 1
for dim in dims:
dim_key = dim.get("key", "")
for obj in dim.get("objectives", []):
krs = obj.get("krs") or []
if not krs:
continue
name = (obj.get("name") or "").strip()
if not name:
continue
# 1. 确保 Objective 记录存在
objective = db.query(Objective).filter(
Objective.entity_id == entity_id,
Objective.title == name,
).first()
if not objective:
objective = Objective(
entity_id=entity_id,
title=name,
description=obj.get("description") or "",
dimension=dim_key,
quarter=_map_quarter(m),
owner=obj.get("owner") or "",
status="active",
progress=0,
)
db.add(objective)
db.flush() # 拿到 id
obj["_objective_id"] = objective.id
# 2. upsert krs 表
existing_krs = {kr.title: kr for kr in db.query(KR).filter(KR.objective_id == objective.id).all()}
keep_ids = set()
for i, kr_item in enumerate(krs):
if not isinstance(kr_item, dict):
continue
title = (kr_item.get("title") or kr_item.get("name") or "").strip()
if not title:
# 跳过空KR(前端允许空白行,不入库)
continue
op, tgt = _parse_kr_operator_target(
kr_item.get("target_value"), kr_item.get("operator"))
kpi_id = kr_item.get("metric_kpi_id")
if not kpi_id and kr_item.get("kpi_code"):
kpi = db.query(KPIDefinition).filter(
KPIDefinition.entity_id == entity_id,
KPIDefinition.kpi_code == kr_item["kpi_code"],
).first()
kpi_id = kpi.id if kpi else None
# 关联KPI时自动继承方向(KPI阈值如 '>=800'
if kpi_id and not kr_item.get("operator"):
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
if kpi and kpi.threshold_green:
op2, tgt2 = _parse_kr_operator_target(kpi.threshold_green)
op, tgt = op2, (tgt2 if tgt is None else tgt)
kr = existing_krs.get(title)
if kr is None:
kr = KR(
entity_id=entity_id,
objective_id=objective.id,
title=title,
)
db.add(kr)
kr.metric_kpi_id = kpi_id
kr.operator = op
if kr_item.get("tolerance") is not None:
kr.tolerance = kr_item.get("tolerance")
if kr_item.get("weight") is not None:
kr.weight = kr_item.get("weight")
if tgt is not None:
kr.target_value = tgt
if kr_item.get("current_value") is not None:
kr.current_value = kr_item.get("current_value")
if kr_item.get("due_date"):
kr.due_date = _parse_kr_date(kr_item.get("due_date"))
kr.sort_order = i + 1
keep_ids.add(kr.id)
kr_item["_kr_id"] = kr.id
# 3. 删除JSON中不存在的krs(空标题的除外——它从未入库)
for title, kr in existing_krs.items():
if kr.id not in keep_ids:
db.delete(kr)
db.commit()
# 回写dimensions(含_objective_id/_kr_id
m.dimensions = dims
db.commit()
def _map_quarter(m) -> str:
"""从地图标题提取季度标识(如 2026Q3),无则空字符串"""
title = m.title or ""
import re
m_q = re.search(r"(20\d{2})\s*[Qq](\d)", title)
if m_q:
return f"{m_q.group(1)}Q{m_q.group(2)}"
return ""
def _parse_kr_date(v):
if v is None or v == "":
return None
if isinstance(v, datetime):
return v.date()
try:
return datetime.strptime(str(v)[:10], "%Y-%m-%d").date()
except (ValueError, TypeError):
return None
# ── 战略回顾会 聚合接口 ──────────────────────
+378 -60
View File
@@ -1,10 +1,11 @@
"""
OKR目标管理 API — 季度目标 + 关键结果 + KPI联动
KR完整修复(2026-08-27): KR读取从ActionPlan改为krs表 + 方向符号operator + 权重
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import func
from datetime import datetime, timedelta
from datetime import datetime, timedelta, date as date_type
from typing import Optional
from app.database import get_db
from app.deps import get_entity_id
@@ -16,6 +17,159 @@ router = APIRouter(prefix="/api/cma/okr", tags=["OKR目标管理"],
)
# ── KR 工具函数 ─────────────────────────────────────────
VALID_OPERATORS = (">=", "<=", ">", "<", "=")
def _num(v):
"""Decimal → float 便于 JSON 序列化"""
return float(v) if v is not None else None
def _parse_operator(raw):
"""解析方向符号: 支持 >=/<=/>/</=,非法默认 >="""
if not raw:
return ">="
op = str(raw).strip()
if op in VALID_OPERATORS:
return op
# 兼容中文符号 ≥ ≤
if op in ("",):
return ">="
if op in ("",):
return "<="
return ">="
def _eval_kr(kr: KR) -> bool:
"""达成判断: 实际值满足 operator → True"""
if kr.current_value is None or kr.target_value is None:
return False
cur = float(kr.current_value)
tgt = float(kr.target_value)
op = _parse_operator(kr.operator)
if op == ">=":
ok = cur >= tgt
elif op == "<=":
ok = cur <= tgt
elif op == ">":
ok = cur > tgt
elif op == "<":
ok = cur < tgt
elif op == "=":
tol = float(kr.tolerance) if kr.tolerance is not None else 0.0
ok = abs(cur - tgt) <= tol
else:
ok = False
return ok
def _calc_progress(kr: KR) -> int:
"""方向感知进度计算:
>=/>: current/target*100
<=/<: target/current*100
= : 容差内100
"""
if kr.current_value is None or kr.target_value is None:
return kr.progress or 0
cur = float(kr.current_value)
tgt = float(kr.target_value)
if tgt == 0:
return 100 if cur == 0 else (100 if cur > 0 else 0)
op = _parse_operator(kr.operator)
if op in (">=", ">"):
pct = cur / tgt * 100
elif op in ("<=", "<"):
# 值越小进度越高(如费用率<=18%)
if cur <= tgt:
pct = 100.0
else:
pct = max(0.0, tgt / cur * 100)
elif op == "=":
tol = float(kr.tolerance) if kr.tolerance is not None else 0.0
if abs(cur - tgt) <= tol:
pct = 100.0
else:
pct = max(0.0, 100.0 - abs(cur - tgt) / (abs(tgt) or 1) * 100)
else:
pct = cur / tgt * 100
return max(0, min(100, round(pct)))
def _refresh_kr_state(kr: KR):
"""按当前值刷新 progress + status(方向感知)"""
if kr.current_value is not None and kr.target_value is not None:
kr.progress = _calc_progress(kr)
if _eval_kr(kr):
kr.status = "achieved"
elif kr.progress > 0:
kr.status = "in_progress"
return kr
def _inherit_kpi_direction(db: Session, kpi_id, operator, target_value):
"""关联KPI时自动继承方向: 从KPI的threshold_green解析(如 '>=800' / '<=18')"""
if not kpi_id:
return operator, target_value
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
if not kpi:
return operator, target_value
threshold = (kpi.threshold_green or "").strip()
if not threshold:
return operator, target_value
op = operator
tgt = target_value
for cand in VALID_OPERATORS:
if threshold.startswith(cand):
op = cand
try:
tgt = float(threshold[len(cand):].strip())
except (ValueError, TypeError):
pass
break
else:
# 无符号时尝试解析纯数字
try:
tgt = float(threshold)
except (ValueError, TypeError):
pass
return op, tgt
def _kr_serialize(kr: KR, db: Session = None) -> dict:
"""KR → dict(含operator/weight/tolerance"""
mkpi = None
if db and kr.metric_kpi_id:
mkpi = db.query(KPIDefinition).filter(KPIDefinition.id == kr.metric_kpi_id).first()
return {
"id": kr.id,
"objective_id": kr.objective_id,
"title": kr.title,
"metric_kpi_id": kr.metric_kpi_id,
"metric_kpi_code": mkpi.kpi_code if mkpi else None,
"metric_kpi_name": mkpi.kpi_name if mkpi else None,
"operator": _parse_operator(kr.operator),
"tolerance": _num(kr.tolerance),
"weight": _num(kr.weight),
"target_value": _num(kr.target_value),
"current_value": _num(kr.current_value),
"progress": kr.progress,
"status": kr.status,
"due_date": kr.due_date.isoformat() if kr.due_date else None,
"monthly_milestones": kr.monthly_milestones or [],
}
def _get_objective_or_404(db: Session, obj_id: int, entity_id: int) -> Objective:
obj = db.query(Objective).filter(Objective.id == obj_id, Objective.entity_id == entity_id).first()
if not obj:
raise HTTPException(404, "目标不存在")
return obj
# ── 目标列表/详情 ─────────────────────────────────────
@router.get("")
def list_objectives(
quarter: Optional[str] = Query(None, description="筛选季度: 2026Q3"),
@@ -24,7 +178,7 @@ def list_objectives(
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""列出OKR目标(账套隔离: 按token企业, 2026-08-23 P2"""
"""列出OKR目标(账套隔离: 按token企业 — KR读取自krs表(2026-08-27)"""
q = db.query(Objective).filter(Objective.entity_id == entity_id)
if quarter:
q = q.filter(Objective.quarter == quarter)
@@ -35,9 +189,10 @@ def list_objectives(
objs = q.order_by(Objective.quarter.desc(), Objective.id).all()
results = []
for o in objs:
krs = db.query(ActionPlan).filter(ActionPlan.objective_id == o.id).all()
krs = db.query(KR).filter(KR.objective_id == o.id).order_by(KR.id).all()
kr_summary = [
{"id": kr.id, "title": kr.title, "status": kr.status, "progress": kr.progress}
{"id": kr.id, "title": kr.title, "status": kr.status, "progress": kr.progress,
"operator": _parse_operator(kr.operator), "weight": _num(kr.weight)}
for kr in krs
]
results.append({
@@ -47,7 +202,7 @@ def list_objectives(
"confidence": o.confidence,
"key_results": kr_summary,
"kr_count": len(krs),
"kr_completed": sum(1 for kr in krs if kr.status == "completed"),
"kr_completed": sum(1 for kr in krs if kr.status in ("completed", "achieved")),
"created_at": o.created_at.isoformat() if o.created_at else None,
})
return {"total": len(results), "items": results}
@@ -60,7 +215,6 @@ def create_objective(
entity_id: int = Depends(get_entity_id),
):
"""创建OKR目标(支持JSON Body和Query参数两种方式)— 账套隔离: 强制token企业"""
# 兼容旧版Query参数
title = data.get("title") or ""
quarter = data.get("quarter") or ""
description = data.get("description")
@@ -78,11 +232,9 @@ def create_objective(
@router.get("/{obj_id}")
def get_objective(obj_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""获取单个OKR详情(账套隔离: 跨企业404)"""
obj = db.query(Objective).filter(Objective.id == obj_id, Objective.entity_id == entity_id).first()
if not obj:
raise HTTPException(404, "目标不存在")
krs = db.query(ActionPlan).filter(ActionPlan.objective_id == obj_id).all()
"""获取单个OKR详情(账套隔离: 跨企业404) — KR读取自krs表(2026-08-27)"""
obj = _get_objective_or_404(db, obj_id, entity_id)
krs = db.query(KR).filter(KR.objective_id == obj_id).order_by(KR.id).all()
return {
"objective": {
"id": obj.id, "title": obj.title, "description": obj.description,
@@ -90,31 +242,195 @@ def get_objective(obj_id: int, db: Session = Depends(get_db), entity_id: int = D
"owner": obj.owner, "status": obj.status, "progress": obj.progress,
"confidence": obj.confidence,
},
"key_results": [
{"id": kr.id, "title": kr.title, "kpi_id": kr.kpi_id,
"status": kr.status, "progress": kr.progress,
"due_date": kr.due_date.isoformat() if kr.due_date else None,
"assignee": kr.assignee}
for kr in krs
]
"key_results": [_kr_serialize(kr, db) for kr in krs],
}
@router.patch("/{obj_id}")
def update_objective(obj_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""更新OKR进度(通过krs表KR自动计算加权进度)"""
obj = _get_objective_or_404(db, obj_id, entity_id)
krs = db.query(KR).filter(KR.objective_id == obj_id).all()
if krs:
weights = [float(kr.weight) if kr.weight else 1.0 for kr in krs]
total_w = sum(weights) or len(krs)
obj.progress = round(sum(kr.progress * w for kr, w in zip(krs, weights)) / total_w)
db.commit()
return {"ok": True, "id": obj_id, "progress": obj.progress}
# ── KR CRUD ───────────────────────────────────────────
@router.post("/{objective_id}/krs")
def create_kr(objective_id: int, data: dict, db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id)):
"""新增KR(落krs表)— title/metric_kpi_id/operator/target_value/weight/progress/due_date
关联KPI时自动继承方向(threshold_green)
"""
obj = _get_objective_or_404(db, objective_id, entity_id)
title = data.get("title") or ""
if not title:
raise HTTPException(422, "缺少必填字段: title")
operator, target_value = _inherit_kpi_direction(
db, data.get("metric_kpi_id"), data.get("operator"), data.get("target_value"))
if target_value is None and operator != "=":
# 没有目标值也能建(后续补),但明确提示
pass
kr = KR(
entity_id=entity_id,
objective_id=objective_id,
title=title,
metric_kpi_id=data.get("metric_kpi_id") or None,
operator=_parse_operator(operator),
tolerance=data.get("tolerance"),
weight=data.get("weight"),
target_value=target_value,
current_value=data.get("current_value"),
progress=data.get("progress", 0),
status=data.get("status", "pending"),
due_date=_parse_date(data.get("due_date")),
monthly_milestones=data.get("monthly_milestones") or [],
)
_refresh_kr_state(kr)
db.add(kr)
db.commit()
db.refresh(kr)
update_objective(objective_id, db, entity_id)
return {"ok": True, "id": kr.id, "kr": _kr_serialize(kr, db)}
@router.put("/{objective_id}/krs/sync")
def sync_krs(objective_id: int, data: dict, db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id)):
"""批量同步KR数组(前端保存整个KR列表时用):
body: {"krs": [{id?, title, operator, target_value, weight, kpi_code?, ...}]}
已存在的按id更新;无id且同名则更新;否则新增;不在列表中的删除
注意: 必须声明在 /krs/{kr_id} 之前,避免 'sync' 被解析为 kr_id
"""
obj = _get_objective_or_404(db, objective_id, entity_id)
incoming = data.get("krs", [])
if not isinstance(incoming, list):
raise HTTPException(400, "krs 必须是数组")
existing = {kr.id: kr for kr in db.query(KR).filter(KR.objective_id == objective_id).all()}
keep_ids = set()
now_name = {}
for i, item in enumerate(incoming):
title = (item.get("title") or item.get("name") or "").strip()
if not title:
continue
kpi_id = item.get("metric_kpi_id") or item.get("kpi_id")
# kpi_code → kpi_id 转换
if not kpi_id and item.get("kpi_code"):
kpi = db.query(KPIDefinition).filter(
KPIDefinition.kpi_code == item["kpi_code"],
KPIDefinition.entity_id == entity_id).first()
kpi_id = kpi.id if kpi else None
operator, target_value = _inherit_kpi_direction(db, kpi_id, item.get("operator"), item.get("target_value"))
kr = None
if item.get("id") and int(item["id"]) in existing:
kr = existing[int(item["id"])]
elif title in now_name:
kr = now_name[title]
else:
# 按同名找已有
for k in existing.values():
if k.title == title:
kr = k
break
if kr is None:
kr = KR(entity_id=entity_id, objective_id=objective_id, title=title)
db.add(kr)
kr.title = title
kr.metric_kpi_id = kpi_id
kr.operator = _parse_operator(operator)
if item.get("tolerance") is not None:
kr.tolerance = item.get("tolerance")
if item.get("weight") is not None:
kr.weight = item.get("weight")
if item.get("target_value") is not None:
kr.target_value = item.get("target_value")
elif target_value is not None:
kr.target_value = target_value
if item.get("current_value") is not None:
kr.current_value = item.get("current_value")
if item.get("due_date"):
kr.due_date = _parse_date(item.get("due_date"))
if item.get("monthly_milestones") is not None:
kr.monthly_milestones = item.get("monthly_milestones")
_refresh_kr_state(kr)
keep_ids.add(kr.id)
now_name[title] = kr
# 删除不在列表中的
for kid, kr in existing.items():
if kid not in keep_ids:
db.delete(kr)
db.commit()
update_objective(objective_id, db, entity_id)
krs = db.query(KR).filter(KR.objective_id == objective_id).order_by(KR.id).all()
return {"ok": True, "objective_id": objective_id, "krs": [_kr_serialize(kr, db) for kr in krs]}
@router.put("/{objective_id}/krs/{kr_id}")
def update_kr(objective_id: int, kr_id: int, data: dict, db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id)):
"""更新KR(含方向/权重/当前值,自动重算progress+status"""
obj = _get_objective_or_404(db, objective_id, entity_id)
kr = db.query(KR).filter(KR.id == kr_id, KR.objective_id == objective_id,
KR.entity_id == entity_id).first()
if not kr:
raise HTTPException(404, "KR不存在或不属于该目标")
updatable = {"title", "metric_kpi_id", "operator", "tolerance", "weight",
"target_value", "current_value", "progress", "status", "due_date",
"monthly_milestones"}
for k, v in data.items():
if k in updatable and v is not None:
setattr(kr, k, v)
# 关联KPI变化 → 自动继承方向
if "metric_kpi_id" in data or "operator" not in data or "target_value" not in data:
if data.get("metric_kpi_id") and ("operator" not in data or "target_value" not in data):
kr.operator, kr.target_value = _inherit_kpi_direction(
db, data.get("metric_kpi_id", kr.metric_kpi_id),
data.get("operator", kr.operator), data.get("target_value", kr.target_value))
if data.get("due_date"):
kr.due_date = _parse_date(data["due_date"])
_refresh_kr_state(kr)
db.commit()
db.refresh(kr)
update_objective(objective_id, db, entity_id)
return {"ok": True, "id": kr.id, "kr": _kr_serialize(kr, db)}
@router.delete("/{objective_id}/krs/{kr_id}")
def delete_kr(objective_id: int, kr_id: int, db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id)):
"""删除KR"""
_get_objective_or_404(db, objective_id, entity_id)
kr = db.query(KR).filter(KR.id == kr_id, KR.objective_id == objective_id,
KR.entity_id == entity_id).first()
if not kr:
raise HTTPException(404, "KR不存在或不属于该目标")
db.delete(kr)
db.commit()
update_objective(objective_id, db, entity_id)
return {"ok": True, "deleted": kr_id}
# ── KR里程碑(时间分解) ─────────────────────────────
@router.put("/{okr_id}/decomposition/milestones/{kr_id}")
def update_kr_milestones(okr_id: int, kr_id: int, data: dict, db: Session = Depends(get_db)):
def update_kr_milestones(okr_id: int, kr_id: int, data: dict, db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id)):
"""保存KR的月度里程碑(时间分解视图编辑) — 同步更新KR进度"""
obj = db.query(Objective).filter(Objective.id == okr_id).first()
if not obj:
raise HTTPException(404, "OKR不存在")
kr = db.query(ActionPlan).filter(
ActionPlan.id == kr_id, ActionPlan.objective_id == okr_id
).first()
_get_objective_or_404(db, okr_id, entity_id)
kr = db.query(KR).filter(KR.id == kr_id, KR.objective_id == okr_id,
KR.entity_id == entity_id).first()
if not kr:
raise HTTPException(404, "KR不存在或不属于该OKR")
milestones = data.get("milestones")
if not isinstance(milestones, list):
raise HTTPException(400, "milestones 必须是数组")
# 规范化:只保留 month/label/status/target 字段
normalized = []
for ms in milestones:
normalized.append({
@@ -124,44 +440,45 @@ def update_kr_milestones(okr_id: int, kr_id: int, data: dict, db: Session = Depe
"target": ms.get("target"),
})
kr.monthly_milestones = normalized
# 里程碑完成 → KR进度同步更新(完成数/总数)
if normalized:
done = sum(1 for ms in normalized if ms.get("status") == "completed")
kr.progress = round(done / len(normalized) * 100)
if kr.progress >= 100:
kr.status = "completed"
elif kr.status == "completed" and kr.progress < 100:
kr.status = "achieved"
elif kr.status == "achieved" and kr.progress < 100:
kr.status = "in_progress"
db.commit()
return {"ok": True, "kr_id": kr.id, "progress": kr.progress, "milestones": kr.monthly_milestones}
@router.post("/{okr_id}/decomposition/milestones/generate")
def auto_generate_milestones(okr_id: int, data: dict, db: Session = Depends(get_db)):
def auto_generate_milestones(okr_id: int, data: dict, db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id)):
"""为KR自动生成月度里程碑(按季度剩余月份均分)"""
obj = db.query(Objective).filter(Objective.id == okr_id).first()
if not obj:
raise HTTPException(404, "OKR不存在")
_get_objective_or_404(db, okr_id, entity_id)
kr_id = data.get("kr_id")
if not kr_id:
raise HTTPException(400, "缺少 kr_id")
kr = db.query(ActionPlan).filter(
ActionPlan.id == kr_id, ActionPlan.objective_id == okr_id
).first()
kr = db.query(KR).filter(KR.id == kr_id, KR.objective_id == okr_id,
KR.entity_id == entity_id).first()
if not kr:
raise HTTPException(404, "KR不存在或不属于该OKR")
# 重新生成:覆盖已有里程碑(幂等)
milestones = _build_auto_milestones(kr)
kr.monthly_milestones = milestones
kr.monthly_milestones = _build_auto_milestones(kr)
db.commit()
return {"ok": True, "kr_id": kr.id, "milestones": kr.monthly_milestones}
def _build_auto_milestones(kr: ActionPlan) -> list:
def _build_auto_milestones(kr: KR) -> list:
"""按截止日期向前均分3个月度里程碑"""
base = kr.due_date or datetime.now() + timedelta(days=90)
if isinstance(base, str):
try:
base = datetime.strptime(base[:10], "%Y-%m-%d").date()
except ValueError:
base = datetime.now().date() + timedelta(days=90)
if isinstance(base, datetime):
base = base.date()
base = base.replace(day=1)
# 计算前3个月份(从最早的月份开始)
month_keys = []
for i in range(3, 0, -1):
y, m = base.year, base.month - i
@@ -180,34 +497,35 @@ def _build_auto_milestones(kr: ActionPlan) -> list:
]
@router.patch("/{obj_id}")
def update_objective(obj_id: int, db: Session = Depends(get_db)):
"""更新OKR进度(通过查询ActionPlan自动计算)"""
obj = db.query(Objective).filter(Objective.id == obj_id).first()
if not obj:
raise HTTPException(404, "目标不存在")
krs = db.query(ActionPlan).filter(ActionPlan.objective_id == obj_id).all()
if krs:
obj.progress = sum(kr.progress for kr in krs) // len(krs)
db.commit()
return {"ok": True, "id": obj_id, "progress": obj.progress}
def _parse_date(v):
"""兼容 str/date/datetime → date"""
if v is None or v == "":
return None
if isinstance(v, date_type):
return v
if isinstance(v, datetime):
return v.date()
try:
return datetime.strptime(str(v)[:10], "%Y-%m-%d").date()
except (ValueError, TypeError):
return None
@router.get("/{okr_id}/decomposition")
def get_okr_decomposition(okr_id: int, db: Session = Depends(get_db)):
"""获取OKR的时间分解视图数据"""
okr = db.query(Objective).filter(Objective.id == okr_id).first()
if not okr:
raise HTTPException(404, "OKR不存在")
def get_okr_decomposition(okr_id: int, db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id)):
"""获取OKR的时间分解视图数据 — KR读取自krs表(2026-08-27)"""
okr = _get_objective_or_404(db, okr_id, entity_id)
# 1. 关联的BSC战略O(年度 — 相同维度且没有季度标识)
bsc_o = db.query(Objective).filter(
Objective.dimension == okr.dimension,
Objective.quarter.is_(None)
Objective.quarter.is_(None),
Objective.entity_id == entity_id,
).first()
# 2. 本OKR的所有KR关联到该Objective的ActionPlan
krs = db.query(ActionPlan).filter(ActionPlan.objective_id == okr_id).all()
# 2. 本OKR的所有KRkrs表
krs = db.query(KR).filter(KR.objective_id == okr_id).order_by(KR.id).all()
# 3. 当前周的ActionPlan(本周行动计划)
now = datetime.now()
+2
View File
@@ -89,6 +89,8 @@ def trace_ontology(
"metric_kpi_id": kr.metric_kpi_id,
"metric_kpi_code": mkpi.kpi_code if mkpi else None,
"metric_kpi_name": mkpi.kpi_name if mkpi else None,
"operator": kr.operator or ">=",
"weight": _num(kr.weight),
"target_value": _num(kr.target_value),
"current_value": _num(kr.current_value),
"progress": kr.progress,
+6 -1
View File
@@ -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())
+37
View File
@@ -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()
+20 -9
View File
@@ -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详情包含关联KRKR完整修复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(
+262
View File
@@ -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表KRoperator/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"
+8
View File
@@ -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>
<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=">">&gt; 大于</option>
<option value="<">&lt; 小于</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,13 +549,33 @@ function nextStep() {
form.value = {
o_name: tmpl.name,
o_description: tmpl.description,
krs: tmpl.preset_krs.map((kr: any) => ({
krs: tmpl.preset_krs.map((kr: any) => {
const parsed = parseKrTarget(kr.target_value)
return {
name: kr.name,
target_value: kr.target_value,
weight: String(kr.weight),
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() {
@@ -563,7 +584,7 @@ function prevStep() {
}
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)
}
// NKR
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.target_value == null || kr.target_value === "") return
// KR+
if (kr.name && !/\d/.test(kr.name)) {
kr.name = kr.name + '≥' + pct
}
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) => ({
const existingKrs = (obj.krs || []).map((kr: any) => {
const parsed = parseKrTarget(kr.target_value)
return {
name: kr.name || "",
target_value: kr.target_value || "",
weight: String(kr.weight || 33),
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()
+37 -1
View File
@@ -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_idKR
if (dim.objectives[idx]._objective_id) {
node._objective_id = dim.objectives[idx]._objective_id
}
dim.objectives[idx] = { ...node, ...extra }
ElMessage.success("节点已更新")
// KR: krsobjective_idAPI
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): KRkrs
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) {
// JSONkrs
console.warn("[KR同步] 节点KR同步失败:", e?.response?.data?.detail || e?.message)
}
}
// OOKR3
let userTemplateCooldown: Record<string, number> = {}
async function saveUserTemplate(data: any, layer: string) {