Files
cma-management/backend/app/api/maps.py
T
Hermes CI Fix 61936302b2 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表
2026-08-27 10:59:56 +08:00

723 lines
27 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""战略地图 API"""
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, Objective, KR, KPIDefinition
import json
router = APIRouter(prefix="/api/cma/maps", tags=["战略地图"],
dependencies=[Depends(require_role("ceo", "finance"))],
)
# ── 四维度模板 ──────────────────────────────
STRATEGIC_MAP_TEMPLATE = [
{
"key": "finance",
"name": "财务层",
"icon": "💰",
"color": "#F56C6C",
"objectives": [
{"name": "营收目标", "kpis": ["F_REVENUE"]},
{"name": "净利润率", "kpis": ["F_NET_PROFIT"]},
{"name": "现金流", "kpis": ["F_OP_CFLOW"]},
],
},
{
"key": "customer",
"name": "客户层",
"icon": "👥",
"color": "#409EFF",
"objectives": [
{"name": "客户满意度", "kpis": ["C_SATISFACTION"]},
{"name": "市场份额", "kpis": ["C_MARKET_SHARE"]},
{"name": "客户保留率", "kpis": ["C_RETENTION_RATE"]},
],
},
{
"key": "process",
"name": "内部流程层",
"icon": "⚙️",
"color": "#67C23A",
"objectives": [
{"name": "运营效率", "kpis": ["P_DELIVERY"]},
{"name": "质量合格率", "kpis": ["P_PASS_RATE"]},
],
},
{
"key": "learning",
"name": "学习成长层",
"icon": "📚",
"color": "#E6A23C",
"objectives": [
{"name": "关键岗位胜任度", "kpis": ["L_COMPETENCY"]},
{"name": "培训完成率", "kpis": ["L_TRAINING"]},
],
},
]
# ── CRUD ────────────────────────────────────
@router.get("")
def list_maps(db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
# 账套隔离: 仅当前企业地图 (2026-08-25)
maps = db.query(StrategicMap).filter(StrategicMap.entity_id == entity_id).order_by(StrategicMap.updated_at.desc()).all()
return {"data": [m_to_dict(m, db) for m in maps]}
@router.post("")
def create_map(data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
# 空白地图默认四层空结构(避免dimensions=NULL导致加载报错)
if "dimensions" not in data or data.get("dimensions") is None:
data = {**data, "dimensions": [
{"key": "finance", "name": "财务层", "icon": "💰", "color": "#F56C6C", "objectives": []},
{"key": "customer", "name": "客户层", "icon": "👥", "color": "#409EFF", "objectives": []},
{"key": "process", "name": "流程层", "icon": "⚙️", "color": "#67C23A", "objectives": []},
{"key": "learning", "name": "学习层", "icon": "📚", "color": "#E6A23C", "objectives": []},
]}
if "canvas_data" not in data or data.get("canvas_data") is None:
data = {**data, "canvas_data": {"connections": []}}
data["entity_id"] = entity_id # 账套隔离: 强制token企业
m = StrategicMap(**data)
db.add(m)
db.commit()
db.refresh(m)
_sync_map_objectives(m, db)
return m_to_dict(m, db)
@router.post("/create-with-template")
def create_map_with_template(data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""一键创建带四维度模板的战略地图(账套隔离: 强制token企业)"""
m = StrategicMap(
title=data.get("title", "新建战略地图"),
version=data.get("version", "v1.0"),
status="draft",
entity_id=entity_id,
dimensions=STRATEGIC_MAP_TEMPLATE,
canvas_data={"connections": []},
)
db.add(m)
db.commit()
db.refresh(m)
_sync_map_objectives(m, db)
return m_to_dict(m, db)
@router.put("/{map_id}")
def update_map(map_id: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
m = db.query(StrategicMap).filter(StrategicMap.id == map_id).first()
if not m:
raise HTTPException(404, "战略地图不存在")
# 账套隔离: 禁止跨企业修改
if m.entity_id != entity_id:
raise HTTPException(404, "战略地图不存在")
old_status = m.status
# 安全白名单: 只允许编辑业务字段, 禁止 entity_id 等敏感字段被请求体篡改 (OpenCode审查#7)
# status 允许但仅限合法值域(draft/published), 防止任意发布
ALLOWED_MAP_FIELDS = {"title", "version", "dimensions", "canvas_data", "description", "status"}
for k, v in data.items():
if k in ALLOWED_MAP_FIELDS and hasattr(m, k) and v is not None:
if k == "status" and v not in ("draft", "published"):
continue
setattr(m, k, v)
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":
_auto_snapshot(m, db)
return m_to_dict(m, db)
# ── 删除地图 ─────────────────────────────────
@router.delete("/{map_id}")
def delete_map(map_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""删除战略地图(账套隔离: 禁止跨企业删除)"""
m = db.query(StrategicMap).filter(StrategicMap.id == map_id).first()
if not m:
raise HTTPException(404, "战略地图不存在")
if m.entity_id != entity_id:
raise HTTPException(404, "战略地图不存在")
db.delete(m)
db.commit()
return {"message": "已删除"}
@router.post("/batch-delete")
def batch_delete_maps(data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""批量删除战略地图(账套隔离: 仅本企业地图)"""
ids = data.get("ids", [])
if not ids:
raise HTTPException(400, "请选择要删除的地图")
deleted = 0
for mid in ids:
m = db.query(StrategicMap).filter(StrategicMap.id == mid, StrategicMap.entity_id == entity_id).first()
if m:
db.delete(m)
deleted += 1
db.commit()
return {"message": f"已删除 {deleted} 个地图", "deleted": deleted}
# ── 连线管理 ─────────────────────────────────
def _get_connections(m: StrategicMap) -> list:
if not m.canvas_data:
m.canvas_data = {"connections": []}
if isinstance(m.canvas_data, str):
try:
m.canvas_data = json.loads(m.canvas_data)
except:
m.canvas_data = {"connections": []}
if "connections" not in m.canvas_data:
m.canvas_data["connections"] = []
return m.canvas_data["connections"]
@router.post("/{map_id}/connections")
def add_connection(map_id: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""新增因果连线: {"from": "learning-0", "to": "process-0"}"""
m = db.query(StrategicMap).filter(StrategicMap.id == map_id, StrategicMap.entity_id == entity_id).first()
if not m:
raise HTTPException(404, "战略地图不存在")
from_id = data.get("from", "")
to_id = data.get("to", "")
if not from_id or not to_id:
raise HTTPException(400, "请提供 from 和 to")
# 校验: 不能自连
if from_id == to_id:
raise HTTPException(400, "不能自身连线")
# 校验: 维度不能相同 (但放开允许同层连线, 仅禁止自连)
from_dim = from_id.rsplit("-", 1)[0]
to_dim = to_id.rsplit("-", 1)[0]
conns = _get_connections(m)
# 校验: 不能重复
for c in conns:
if c.get("from") == from_id and c.get("to") == to_id:
raise HTTPException(400, "已存在相同的连线")
conns.append({
"from": from_id,
"to": to_id,
"style": "solid",
"effect": data.get("effect", "positive"),
"label": data.get("label", ""),
})
m.canvas_data["connections"] = conns
db.commit()
return {"connections": conns}
@router.delete("/{map_id}/connections")
def delete_connection_by_key(map_id: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""根据 from/to 删除连线"""
m = db.query(StrategicMap).filter(StrategicMap.id == map_id, StrategicMap.entity_id == entity_id).first()
if not m:
raise HTTPException(404, "战略地图不存在")
from_id = data.get("from", "")
to_id = data.get("to", "")
conns = _get_connections(m)
new_conns = [c for c in conns if not (c.get("from") == from_id and c.get("to") == to_id)]
if len(new_conns) == len(conns):
raise HTTPException(404, "连线不存在")
m.canvas_data["connections"] = new_conns
db.commit()
return {"connections": new_conns, "removed": {"from": from_id, "to": to_id}}
# ── 版本管理 ─────────────────────────────────
def _auto_snapshot(m: StrategicMap, db: Session):
"""发布时自动创建版本快照"""
from app.models import StrategicMapVersion
import re
# 自动递增版本号: 找到最大次版本号
existing = db.query(StrategicMapVersion).filter(
StrategicMapVersion.map_id == m.id
).order_by(StrategicMapVersion.id.desc()).first()
if existing:
match = re.search(r"v(\d+)\.(\d+)", existing.version)
if match:
major = int(match.group(1))
minor = int(match.group(2)) + 1
new_ver = f"v{major}.{minor}"
else:
new_ver = "v1.0"
else:
new_ver = "v1.0"
# 确保 JSON 序列化
dims = m.dimensions
canvas = m.canvas_data
if isinstance(dims, str):
try:
dims = json.loads(dims)
except:
dims = []
if isinstance(canvas, str):
try:
canvas = json.loads(canvas)
except:
canvas = {"connections": []}
snapshot = StrategicMapVersion(
map_id=m.id,
version=new_ver,
dimensions=dims,
canvas_data=canvas,
comment=f"发布 {new_ver}",
)
db.add(snapshot)
db.commit()
# ── 工具函数 ─────────────────────────────────
def m_to_dict(m, db: Session = None):
d = {c.name: getattr(m, c.name) for c in m.__table__.columns}
if db:
_merge_map_objectives(m, db)
d["dimensions"] = m.dimensions
return d
def _sync_map_objectives(m, db):
"""保存时:将dimensions JSON中的目标同步到map_objectives表"""
db.query(MapObjective).filter(MapObjective.map_id == m.id).delete()
dims = m.dimensions
if isinstance(dims, str):
dims = json.loads(dims)
dims = dims or []
for dim in dims:
for i, obj in enumerate(dim.get("objectives", [])):
mo = MapObjective(
map_id=m.id,
entity_id=m.entity_id if m.entity_id is not None else 1, # 强制继承地图企业(OpenCode审查#11)
dimension_key=dim.get("key", ""),
name=obj.get("name", ""),
description=obj.get("description", ""),
icon=obj.get("icon", "target"),
kpis=obj.get("kpis", []),
sort_order=i,
)
db.add(mo)
db.commit()
def _merge_map_objectives(m, db):
"""读取时:将map_objectives表的数据合并进dimensions JSON"""
objs = db.query(MapObjective).filter(MapObjective.map_id == m.id).order_by(MapObjective.sort_order).all()
if not objs:
return
dims = m.dimensions
if isinstance(dims, str):
dims = json.loads(dims)
dims = dims or []
# 按dimension_key分组
from collections import defaultdict
grouped = defaultdict(list)
for o in objs:
grouped[o.dimension_key].append(o)
for dim in dims:
key = dim.get("key", "")
if key in grouped:
merged = []
for i, o in enumerate(grouped[key]):
# 保留原有JSON中的额外字段(targetValue/krs等)
existing = dim["objectives"][i] if i < len(dim["objectives"]) else {}
merged.append({
"name": o.name,
"description": o.description or existing.get("description", ""),
"icon": o.icon or existing.get("icon", "target"),
"kpis": o.kpis or existing.get("kpis", []),
**{k: existing[k] for k in existing if k not in ("name", "description", "icon", "kpis")},
})
dim["objectives"] = merged
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
# ── 战略回顾会 聚合接口 ──────────────────────
@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值、改善行动
level: 可选 strategic/operational — 战略回顾(默认strategic)只显示战略级KPI
不传则返回全部KPI(向后兼容)。
"""
from app.models import KPIDefinition, KPIValue, ActionPlan
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):
dims = json.loads(dims)
dims = dims or [] # NULL容错
# 收集所有KPI code
all_kpi_codes = set()
for dim in dims:
for obj in dim.get("objectives", []):
for code in obj.get("kpis", []):
all_kpi_codes.add(code)
# 查询KPI定义(账套隔离: 同编码跨企业会混入, OpenCode审查#10
kpi_defs = db.query(KPIDefinition).filter(
KPIDefinition.kpi_code.in_(all_kpi_codes) if all_kpi_codes else False,
KPIDefinition.entity_id == entity_id,
).all() if all_kpi_codes else []
kpi_map = {k.kpi_code: k for k in kpi_defs}
# 查询最新KPI实际值
kpi_ids = [k.id for k in kpi_defs]
latest_values = {}
if kpi_ids:
# 取每个KPI的最新一条
for kid in kpi_ids:
v = db.query(KPIValue).filter(
KPIValue.kpi_id == kid
).order_by(KPIValue.calculated_at.desc()).first()
if v:
latest_values[kid] = {
"actual_value": v.actual_value,
"period": v.period,
"source_type": v.source_type,
}
# 查询改善行动(按KPI_id关联)
action_plans_data = []
if kpi_ids:
plans = db.query(ActionPlan).filter(
ActionPlan.kpi_id.in_(kpi_ids)
).order_by(ActionPlan.created_at.desc()).all()
for p in plans:
action_plans_data.append({
"id": p.id,
"kpi_id": p.kpi_id,
"title": p.title,
"assignee": p.assignee,
"priority": p.priority,
"due_date": p.due_date.isoformat() if p.due_date else None,
"status": p.status,
"progress": p.progress or 0,
"created_at": p.created_at.isoformat() if p.created_at else None,
})
# 构建维度目标状态
dim_results = []
total_ok = 0
total_warn = 0
total_err = 0
total_obj_count = 0
focus_items = []
for dim in dims:
dim_key = dim.get("key", "")
dim_name = dim.get("name", "")
dim_icon = dim.get("icon", "")
dim_color = dim.get("color", "")
objectives = []
for obj in dim.get("objectives", []):
total_obj_count += 1
obj_kpis = []
worst_level = "green"
for code in obj.get("kpis", []):
kpi_def = kpi_map.get(code)
if not kpi_def:
continue
# 规则4: 战略地图默认只显示strategic级KPIlevel过滤)
if level and kpi_def.kpi_level != level:
continue
lv = latest_values.get(kpi_def.id, {})
actual = lv.get("actual_value")
# 多粒度目标优先: 月目标(多数KPI月度) → 兼容legacy target_value (OpenCode审查#5)
target = kpi_def.target_monthly or kpi_def.target_value
# 判断红黄绿灯(绿≥90% / 黄60-90% / 红<60%
# 方向修正: 越低越好型(费用率/成本率/损耗/逾期/投诉/流失) 与 负值目标 (OpenCode审查#5)
level = "gray"
if actual is not None and target:
name = (kpi_def.kpi_name or "") + " " + (kpi_def.kpi_code or "")
lower_better = any(k in name for k in
("费用率", "成本率", "损耗", "逾期", "投诉", "流失", "负债率", "返修", "缺勤"))
if lower_better:
ratio = target / actual if actual else 0 # 实际越低越好: 目标/实际
else:
ratio = actual / target
if ratio < 0:
ratio = 0 # 负值目标(如亏损控制) 完成度不为负
if ratio >= 0.9:
level = "green"
elif ratio >= 0.6:
level = "yellow"
else:
level = "red"
else:
level = "gray"
if level == "red":
worst_level = "red"
elif level == "yellow" and worst_level != "red":
worst_level = "yellow"
obj_kpis.append({
"kpi_id": kpi_def.id,
"kpi_code": code,
"kpi_name": kpi_def.kpi_name,
"target_value": target,
"actual_value": actual,
"unit": kpi_def.unit,
"level": level,
})
obj_item = {
"name": obj.get("name", ""),
"icon": obj.get("icon", ""),
"kpis": obj_kpis,
"level": worst_level,
"has_data": len(obj_kpis) > 0,
# 行动方案汇总(OpenCode审查#6): 按本目标KPI统计
"action_plan_summary": {
"total": len([p for p in action_plans_data if p["kpi_id"] in [k["kpi_id"] for k in obj_kpis]]),
"pending": len([p for p in action_plans_data
if p["kpi_id"] in [k["kpi_id"] for k in obj_kpis]
and p["status"] not in ("done", "completed")]),
} if obj_kpis else {"total": 0, "pending": 0},
}
objectives.append(obj_item)
if worst_level == "green":
total_ok += 1
elif worst_level == "yellow":
total_warn += 1
elif worst_level == "red":
total_err += 1
# 红色和黄色归入需重点关注
if worst_level in ("red", "yellow"):
focus_items.append(obj_item)
dim_results.append({
"key": dim_key,
"name": dim_name,
"icon": dim_icon,
"color": dim_color,
"objectives": objectives,
})
# 排序:红色在前,黄色在后
focus_items.sort(key=lambda x: (0 if x["level"] == "red" else 1, x["name"]))
return {
"map_id": m.id,
"title": m.title,
"version": m.version,
"status": m.status,
"dimensions": dim_results,
"summary": {
"total": total_obj_count,
"green": total_ok,
"yellow": total_warn,
"red": total_err,
"health_score": round(total_ok / total_obj_count * 100, 1) if total_obj_count > 0 else 0,
},
"focus_items": focus_items,
"action_plans": action_plans_data,
}