feat(KR): 行动挂KR完成 — action_plans.kr_id字段+API+前端目标/KR选择+KR中文名自动填+地图krs端点

- action_plans表加kr_id字段(关联krs.id),create/update/list支持kr_id读写
- 创建行动选KR时自动继承objective_id;列表附kr_title
- 前端行动方案库加'所属目标/KR'两级联动选择,选中KR自动带出KPI
- OKR详情页KR卡片下显示达成行动列表(O→KR→行动三层)
- MapCanvasDialogs onKpiChange选中KPI自动填kr.name=kpi.kpi_name(中文)
- 编辑回填英文code KR名转中文显示兜底
- maps.py 新增GET /{map_id}/krs端点(MapReview战略回顾会KR进度)
- 数据修复: krs表9条英文code title→中文名(按kpi_definitions映射)
- 数据迁移: 高置信行动挂KR(费用率→kr10,应收→kr14); 地图32空KR清理
- okr list的kr_summary补metric_kpi_id(前端联动用)
- 新增6个kr_id测试(test_action_plans),全量547通过
This commit is contained in:
Hermes CI Fix
2026-08-27 12:50:30 +08:00
parent 61936302b2
commit 6b479bfe7d
10 changed files with 402 additions and 14 deletions
+41 -1
View File
@@ -9,7 +9,7 @@ from calendar import monthrange
from app.database import get_db
from app.deps import get_entity_id
from app.auth_middleware import require_role, require_auth
from app.models import ActionPlan, KPIAlert, KPIDefinition, User, Objective
from app.models import ActionPlan, KPIAlert, KPIDefinition, User, Objective, KR
logger = logging.getLogger("cma.action_plans")
@@ -64,6 +64,7 @@ def plan_to_dict(p: ActionPlan) -> dict:
"alert_id": p.alert_id,
"kpi_id": p.kpi_id,
"objective_id": p.objective_id,
"kr_id": p.kr_id,
"title": p.title,
"description": p.description,
"assignee": p.assignee,
@@ -87,6 +88,7 @@ def list_plans(
status: Optional[str] = None,
kpi_id: Optional[int] = None,
alert_id: Optional[int] = None,
keyword: Optional[str] = None,
db: Session = Depends(get_db),
current_user: User = Depends(require_auth),
entity_id: int = Depends(get_entity_id),
@@ -100,6 +102,8 @@ def list_plans(
query = query.filter(ActionPlan.kpi_id == kpi_id)
if alert_id:
query = query.filter(ActionPlan.alert_id == alert_id)
if keyword:
query = query.filter(ActionPlan.title.like(f"%{keyword}%"))
# business角色只看自己的
if current_user.role == "business":
@@ -115,6 +119,16 @@ def list_plans(
# 附带KPI名称
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == p.kpi_id).first()
item["kpi_name"] = kpi.kpi_name if kpi else "未知KPI"
item["kpi_code"] = kpi.kpi_code if kpi else None
item["kpi_dimension"] = kpi.dimension if kpi else None
# 附带KR信息(行动挂KR 2026-08-27
if p.kr_id is not None:
kr = db.query(KR).filter(KR.id == p.kr_id).first()
item["kr_title"] = kr.title if kr else None
item["kr_metric_kpi_id"] = kr.metric_kpi_id if kr else None
else:
item["kr_title"] = None
item["kr_metric_kpi_id"] = None
result.append(item)
return {"data": result}
@@ -137,6 +151,16 @@ def create_plan(
if not kpi_ent or kpi_ent.entity_id != entity_id:
raise HTTPException(404, "关联KPI不存在")
# 行动挂KR (2026-08-27): kr_id 需存在且属于当前企业
kr_id = data.get("kr_id")
if kr_id:
kr_ent = db.query(KR).filter(KR.id == kr_id, KR.entity_id == entity_id).first()
if not kr_ent:
raise HTTPException(404, "关联KR不存在")
# 未显式传objective_id时从KR继承目标
if not data.get("objective_id"):
data["objective_id"] = kr_ent.objective_id
due_date = datetime.fromisoformat(data["due_date"]) if data.get("due_date") else None
# 校验截止日期与关联Objective的季度匹配
@@ -150,6 +174,7 @@ def create_plan(
alert_id=data.get("alert_id"),
kpi_id=data["kpi_id"],
objective_id=objective_id,
kr_id=kr_id,
title=data["title"],
description=data.get("description"),
assignee=data.get("assignee"),
@@ -214,6 +239,21 @@ def update_plan(
plan.assignee = data["assignee"]
if "priority" in data:
plan.priority = data["priority"]
if "kr_id" in data:
# 行动挂KR: 支持置空(null)或改挂
if data["kr_id"] is None:
plan.kr_id = None
else:
kr_ent = db.query(KR).filter(KR.id == data["kr_id"]).first()
if not kr_ent:
raise HTTPException(404, "关联KR不存在")
plan.kr_id = kr_ent.id
if data.get("objective_id") is not None:
plan.objective_id = data["objective_id"]
elif plan.objective_id is None:
plan.objective_id = kr_ent.objective_id
if "objective_id" in data:
plan.objective_id = data["objective_id"]
if "due_date" in data:
plan.due_date = datetime.fromisoformat(data["due_date"]) if data["due_date"] else None
if "status" in data:
+65
View File
@@ -533,6 +533,71 @@ def _parse_kr_date(v):
# ── 战略回顾会 聚合接口 ──────────────────────
@router.get("/{map_id}/krs")
def get_map_krs(map_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""战略回顾会 KR进度: 从krs表读取地图各目标的KR (KR修复 2026-08-27)
地图dimensions JSON中每个带krs的目标节点已同步到objectives+krs表,
此处按 节点._objective_id(或 entity+name 回查)取KR。
"""
from app.models import KPIDefinition, KR, Objective
m = db.query(StrategicMap).filter(StrategicMap.id == map_id, StrategicMap.entity_id == entity_id).first()
if not m:
raise HTTPException(404, "战略地图不存在")
dims = m.dimensions
if isinstance(dims, str):
try:
dims = json.loads(dims)
except Exception:
dims = []
dims = dims or []
obj_ids = []
for dim in dims:
for obj in dim.get("objectives", []):
oid = obj.get("_objective_id")
if oid:
obj_ids.append(int(oid))
continue
# 兼容无回写字段的旧节点: 按 entity+name 回查
name = (obj.get("name") or "").strip()
if name:
o = db.query(Objective).filter(
Objective.entity_id == entity_id,
Objective.title == name,
).first()
if o:
obj_ids.append(o.id)
result = []
if obj_ids:
krs = db.query(KR).filter(
KR.entity_id == entity_id,
KR.objective_id.in_(obj_ids),
).order_by(KR.objective_id, KR.sort_order, KR.id).all()
for kr in krs:
mkpi = None
if kr.metric_kpi_id:
mkpi = db.query(KPIDefinition).filter(KPIDefinition.id == kr.metric_kpi_id).first()
result.append({
"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_kr_operator_target(kr.operator)[0] if hasattr(kr, "operator") and kr.operator else ">=",
"weight": float(kr.weight) if kr.weight is not None else None,
"target_value": float(kr.target_value) if kr.target_value is not None else None,
"current_value": float(kr.current_value) if kr.current_value is not None else None,
"progress": kr.progress or 0,
"status": kr.status,
"due_date": kr.due_date.isoformat() if kr.due_date else None,
})
return {"data": result}
@router.get("/{map_id}/review")
def get_map_review(map_id: int, level: Optional[str] = None, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""战略回顾会:返回目标状态、KPI值、改善行动
+2 -1
View File
@@ -192,7 +192,8 @@ def list_objectives(
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,
"operator": _parse_operator(kr.operator), "weight": _num(kr.weight)}
"operator": _parse_operator(kr.operator), "weight": _num(kr.weight),
"metric_kpi_id": kr.metric_kpi_id}
for kr in krs
]
results.append({
+1
View File
@@ -220,6 +220,7 @@ class ActionPlan(Base):
alert_id = Column(Integer, ForeignKey("kpi_alerts.id"), nullable=True, comment="关联预警")
kpi_id = Column(Integer, ForeignKey("kpi_definitions.id"), nullable=False, comment="关联KPI")
objective_id = Column(Integer, ForeignKey("objectives.id"), nullable=True, comment="关联OKR目标")
kr_id = Column(Integer, ForeignKey("krs.id"), nullable=True, comment="关联KR (行动挂KR 2026-08-27)")
title = Column(String(200), nullable=False, comment="计划标题")
description = Column(Text, nullable=True, comment="详细描述")
assignee = Column(String(100), nullable=True, comment="负责人")
+117 -4
View File
@@ -6,7 +6,32 @@ from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
from datetime import datetime, timedelta
from tests.conftest import create_test_user, get_token_for_user, auth_header, create_test_kpi
from app.models import ActionPlan
from app.models import ActionPlan, Objective, KR
def create_test_objective_kr(db: Session, **kwargs):
"""创建测试目标+KR(行动挂KR 2026-08-27"""
obj = Objective(
entity_id=kwargs.get("entity_id", 1),
title=kwargs.get("objective_title", "测试目标"),
quarter="2026Q3",
status="active",
)
db.add(obj)
db.commit()
db.refresh(obj)
kr = KR(
entity_id=kwargs.get("entity_id", 1),
objective_id=obj.id,
title=kwargs.get("kr_title", "测试KR"),
operator=">=",
weight=50,
target_value=100,
)
db.add(kr)
db.commit()
db.refresh(kr)
return obj, kr
def create_test_plan(db: Session, **kwargs) -> ActionPlan:
@@ -109,7 +134,7 @@ class TestActionPlans:
assert data["data"][0]["title"] == "进行中"
def test_filter_by_keyword(self, client: TestClient, db: Session):
"""关键词搜索"""
"""关键词搜索keyword 应过滤 title(期望行为,2026-08-27 TDD示范)"""
user = create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db)
@@ -119,8 +144,9 @@ class TestActionPlans:
resp = client.get("/api/cma/action-plans?keyword=营收", headers=auth_header(token))
assert resp.status_code == 200
data = resp.json()
# API当前未实现keyword过滤,返回全部2
assert len(data["data"]) == 2
# 期望行为:keyword 过滤生效,只返回标题含"营收"的1
assert len(data["data"]) == 1
assert data["data"][0]["title"] == "提升营收计划"
def test_update_plan(self, client: TestClient, db: Session):
"""更新行动计划"""
@@ -226,3 +252,90 @@ class TestActionPlans:
assert data["pending"] == 1
assert data["in_progress"] == 1
assert data["completed"] == 1
# ── 行动挂KR (2026-08-27) ──
def test_create_plan_with_kr_id(self, client: TestClient, db: Session):
"""创建行动时挂KR:返回kr_idobjective_id从KR自动继承"""
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db)
obj, kr = create_test_objective_kr(db)
resp = client.post(
"/api/cma/action-plans",
headers=auth_header(token),
json={
"title": "降低费用率行动",
"kpi_id": kpi.id,
"kr_id": kr.id,
"assignee": "张三",
},
)
assert resp.status_code == 200
data = resp.json()
assert data["kr_id"] == kr.id
# 未显式传objective_id时从KR继承
assert data["objective_id"] == obj.id
def test_create_plan_kr_not_found(self, client: TestClient, db: Session):
"""kr_id不存在 → 404"""
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db)
resp = client.post(
"/api/cma/action-plans",
headers=auth_header(token),
json={"title": "行动", "kpi_id": kpi.id, "kr_id": 99999},
)
assert resp.status_code == 404
def test_list_includes_kr_title(self, client: TestClient, db: Session):
"""列表返回kr_title"""
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db)
obj, kr = create_test_objective_kr(db)
create_test_plan(db, kpi_id=kpi.id, kr_id=kr.id, title="挂KR的行动")
resp = client.get("/api/cma/action-plans", headers=auth_header(token))
assert resp.status_code == 200
data = resp.json()["data"]
item = data[0]
assert item["kr_id"] == kr.id
assert item["kr_title"] == "测试KR"
def test_update_plan_kr_id(self, client: TestClient, db: Session):
"""更新行动可改挂KR"""
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db)
obj1, kr1 = create_test_objective_kr(db, kr_title="KR一")
obj2, kr2 = create_test_objective_kr(db, kr_title="KR二")
plan = create_test_plan(db, kpi_id=kpi.id, kr_id=kr1.id, title="行动")
resp = client.put(
f"/api/cma/action-plans/{plan.id}",
headers=auth_header(token),
json={"kr_id": kr2.id},
)
assert resp.status_code == 200
data = resp.json()
assert data["kr_id"] == kr2.id
def test_update_plan_kr_clear(self, client: TestClient, db: Session):
"""更新行动可置空kr_id"""
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db)
obj, kr = create_test_objective_kr(db)
plan = create_test_plan(db, kpi_id=kpi.id, kr_id=kr.id, title="行动")
resp = client.put(
f"/api/cma/action-plans/{plan.id}",
headers=auth_header(token),
json={"kr_id": None},
)
assert resp.status_code == 200
assert resp.json()["kr_id"] is None