feat(strategy-map): live board reading map_objectives + kpi_targets (read-only)
Task: finance-20261011-strategy-map-frontend-fullstack (P2, read-only integration)
- backend/app/utils/strategy_board.py: single source of truth for the level
caliber (layer colors, inverse-kpi explicit whitelist, status class by
unit, zero guard, period ranking, latest-actual picking). Pure functions.
- backend/app/api/maps.py: GET /api/cma/maps/{map_id}/strategy-board
- nodes straight from map_objectives (sorted by sort_order, id)
- node<->kpi binding with 4-step fallback, binding_source echoed back
- targets from kpi_targets (target_type compatible: yearly + annual),
deduped per (kpi_id, period) with yearly preferred
- actuals from kpi_values actual-only scope, latest period
- causality edges from kpi_causality, out-of-map endpoints dropped+counted
- entity_id taken from the token only (no cross-tenant reads)
- zero writes; nothing is persisted by this endpoint
- frontend/src/views/StrategyMap.vue: /strategy-map live board (4 swim lanes
bottom-up, node cards, 20 causality arrows in SVG, period switch, A3 print).
No emoji, null guards everywhere (no toFixed on null).
- api/index.ts + router/index.ts + permission.ts: route + menu registration
(/strategy-map, zone A read-only, roles ceo/finance/business/it).
- backend/tests/test_strategy_board_20261011.py: 33 cases (spec section 4 /
4.1 / 4.2 / 4.3 + section 7 fixtures + threshold boundaries + period rank).
- backend/tests/test_map_objectives_kpis_form_20261011.py: 14 cases for the
four kpis column shapes (list, json string, object list, NULL/[]/weird).
Not included: push + backend restart (production write, separate gate).
(cherry picked from commit c675bde6b4beaf1d8a9d02a80f5434203a53f348)
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
"""战略地图 API"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional
|
||||
from datetime import date, datetime, timedelta
|
||||
@@ -1227,3 +1228,320 @@ def get_review_record(
|
||||
raise HTTPException(404, "复盘记录不存在")
|
||||
return review_to_dict(r)
|
||||
|
||||
|
||||
# ── 战略地图「活看板」(只读)────────────────────────────────────────
|
||||
# 任务:finance-20261011-strategy-map-frontend-fullstack(任总 2026-10-11 19:07「收口」)
|
||||
# 口径单一源:app/utils/strategy_board.py(色阶/逆指标白名单/状态类/零点保护)
|
||||
# 只读:本端点不写任何表;entity_id 严格取 token → 博海 e2 天然隔离。
|
||||
|
||||
# kpi_targets.target_type 取值:兼容期读两形(不阻塞渲染)
|
||||
# - 现状(2026-10-11 只读实测):全库仅 `annual: 57`(e1 战略框架同步写入,值域外)
|
||||
# - P0 修正单 finance-20261011-kpi-targets-targettype-fix-dama 会把它们改成 `yearly`
|
||||
# - 两形都读 ⇒ 修正前后同一份代码都取得到数;同 (kpi_id, period) 重复时偏好 `yearly`
|
||||
_PERIOD_TYPES = ("yearly", "annual")
|
||||
|
||||
|
||||
def _sb_resolve_bindings(db: Session, entity_id: int, objs: list) -> tuple[dict, dict]:
|
||||
"""节点 ↔ KPI 绑定:优先 `map_objectives.kpis`(收口单已回填),缺失时按名称回查。
|
||||
|
||||
返回 (bindings, sources):``bindings[obj.id] = (kpi_id, kpi_code)``;
|
||||
``sources[obj.id]`` ∈ {kpis, kpis_code, name, note, none} —— 供复核方判断
|
||||
是否已用上 19 条回填绑定(回填前 name/note 兜底)。
|
||||
"""
|
||||
from app.models import KPIDefinition
|
||||
|
||||
bindings: dict = {}
|
||||
sources: dict = {}
|
||||
declared_codes: dict = {}
|
||||
|
||||
for obj in objs:
|
||||
raw = obj.kpis
|
||||
if isinstance(raw, str):
|
||||
try:
|
||||
raw = json.loads(raw)
|
||||
except Exception:
|
||||
raw = None
|
||||
if isinstance(raw, dict):
|
||||
raw = [raw]
|
||||
if not isinstance(raw, list):
|
||||
continue
|
||||
for item in raw:
|
||||
if isinstance(item, dict):
|
||||
kid, code = item.get("kpi_id"), item.get("kpi_code") or item.get("code")
|
||||
if kid:
|
||||
bindings[obj.id] = (int(kid), code)
|
||||
sources[obj.id] = "kpis"
|
||||
break
|
||||
if code:
|
||||
declared_codes[obj.id] = code
|
||||
break
|
||||
elif isinstance(item, str):
|
||||
declared_codes[obj.id] = item
|
||||
break
|
||||
|
||||
def _def_by_code(code: str, cache: dict):
|
||||
if code in cache:
|
||||
return cache[code]
|
||||
row = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.entity_id == entity_id,
|
||||
KPIDefinition.kpi_code == code,
|
||||
).first()
|
||||
cache[code] = row
|
||||
return row
|
||||
|
||||
code_cache: dict = {}
|
||||
for oid, code in declared_codes.items():
|
||||
row = _def_by_code(code, code_cache)
|
||||
if row:
|
||||
bindings[oid] = (row.id, row.kpi_code)
|
||||
sources[oid] = "kpis_code"
|
||||
|
||||
# 兜底:按节点名回查 KPI 名 / kpi_targets.note("名称 · 期间")
|
||||
for obj in objs:
|
||||
if obj.id in bindings:
|
||||
continue
|
||||
row = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.entity_id == entity_id,
|
||||
KPIDefinition.kpi_name == obj.name,
|
||||
).first()
|
||||
if row:
|
||||
bindings[obj.id] = (row.id, row.kpi_code)
|
||||
sources[obj.id] = "name"
|
||||
continue
|
||||
hit = db.execute(text(
|
||||
"SELECT kpi_id FROM kpi_targets "
|
||||
"WHERE entity_id = :e AND target_type IN ('yearly', 'annual') AND note LIKE :pat "
|
||||
"ORDER BY period LIMIT 1"
|
||||
), {"e": entity_id, "pat": f"{obj.name} · %"}).first()
|
||||
if hit:
|
||||
bindings[obj.id] = (int(hit[0]), None)
|
||||
sources[obj.id] = "note"
|
||||
else:
|
||||
sources[obj.id] = "none"
|
||||
return bindings, sources
|
||||
|
||||
|
||||
@router.get("/{map_id}/strategy-board")
|
||||
def get_strategy_board(
|
||||
map_id: int,
|
||||
period: Optional[str] = Query(None, description="期间 2027/2028/2029,默认取最小可用期间"),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""战略地图活看板(只读):19 节点分层 + 达成率色阶 + 因果箭头。
|
||||
|
||||
- 目标值:`kpi_targets`(`target_type` 兼容 `yearly`/`annual` 两形,按 period)
|
||||
- 实际值:`kpi_values` **实测区**最新期(`kpi_value_scope.actual_only_clause`)
|
||||
- 色阶:r = 实际/目标;≥1.0 success / ≥0.8 warning / <0.8 danger / 无实际值 info
|
||||
- 逆指标(显式白名单 504/511/230/23)取倒数;状态类(unit='状态')按 1/0 判色
|
||||
"""
|
||||
from app.models import KPIValue, KPICausality, KPIDefinition
|
||||
from app.utils.strategy_board import (
|
||||
INVERSE_KPI_IDS, LAYER_DISPLAY_ORDER, LAYER_META, LEVEL_COLORS, STATUS_UNIT,
|
||||
compute_level, layer_meta, period_rank, pick_latest,
|
||||
)
|
||||
|
||||
warnings: list = []
|
||||
m = db.query(StrategicMap).filter(
|
||||
StrategicMap.id == map_id, StrategicMap.entity_id == entity_id,
|
||||
).first()
|
||||
if not m:
|
||||
raise HTTPException(404, "战略地图不存在")
|
||||
|
||||
objs = db.query(MapObjective).filter(
|
||||
MapObjective.map_id == map_id, MapObjective.entity_id == entity_id,
|
||||
).order_by(MapObjective.sort_order, MapObjective.id).all()
|
||||
if not objs:
|
||||
warnings.append("该地图下无 map_objectives 节点(map_objectives 为空)")
|
||||
|
||||
bindings, sources = _sb_resolve_bindings(db, entity_id, objs)
|
||||
kpi_ids = sorted({v[0] for v in bindings.values() if v and v[0]})
|
||||
kpi_defs = {}
|
||||
if kpi_ids:
|
||||
for k in db.query(KPIDefinition).filter(
|
||||
KPIDefinition.entity_id == entity_id, KPIDefinition.id.in_(kpi_ids),
|
||||
).all():
|
||||
kpi_defs[k.id] = k
|
||||
|
||||
# ── 目标值(kpi_targets 无 ORM 模型,用只读 text() 取)──
|
||||
targets: dict = {}
|
||||
periods: list = []
|
||||
if kpi_ids:
|
||||
id_list = ",".join(str(int(i)) for i in kpi_ids) # 已 int 化,无注入面
|
||||
ttype = ",".join("'%s'" % t for t in _PERIOD_TYPES)
|
||||
rows = db.execute(text(
|
||||
"SELECT kpi_id, period, target_type, target_value FROM kpi_targets "
|
||||
f"WHERE entity_id = :e AND target_type IN ({ttype}) AND kpi_id IN ({id_list})"
|
||||
), {"e": entity_id}).fetchall()
|
||||
# 同 (kpi_id, period) 两形并存(兼容期)⇒ 取 `_PERIOD_TYPES` 中序更靠前者(yearly 优先)
|
||||
_type_rank = {t: i for i, t in enumerate(_PERIOD_TYPES)}
|
||||
best: dict = {}
|
||||
for r in rows:
|
||||
kid, per, ttype_row, tv = int(r[0]), str(r[1]), str(r[2]), r[3]
|
||||
rank = _type_rank.get(ttype_row, len(_PERIOD_TYPES))
|
||||
if (kid, per) in best and best[(kid, per)][0] <= rank:
|
||||
continue
|
||||
best[(kid, per)] = (rank, float(tv) if tv is not None else None)
|
||||
for (kid, per) in best:
|
||||
targets[(kid, per)] = best[(kid, per)][1]
|
||||
if per not in periods:
|
||||
periods.append(per)
|
||||
periods.sort(key=period_rank)
|
||||
|
||||
# `direction` 列在 ORM 模型里未映射(库内有、模型无);判色**不依赖**它
|
||||
# (逆指标用显式白名单,见 strategy_board.INVERSE_KPI_IDS),此处仅为回执留证
|
||||
directions: dict = {}
|
||||
if kpi_ids:
|
||||
id_list_d = ",".join(str(int(i)) for i in kpi_ids)
|
||||
for r in db.execute(text(
|
||||
f"SELECT id, direction FROM kpi_definitions WHERE id IN ({id_list_d})"
|
||||
)).fetchall():
|
||||
directions[int(r[0])] = r[1]
|
||||
|
||||
# ── 实际值:实测区最新期(每 KPI 一条)──
|
||||
currents: dict = {}
|
||||
if kpi_ids:
|
||||
vrows = db.query(KPIValue).filter(
|
||||
KPIValue.entity_id == entity_id,
|
||||
KPIValue.kpi_id.in_(kpi_ids),
|
||||
actual_only_clause(),
|
||||
).all()
|
||||
by_kpi: dict = {}
|
||||
for v in vrows:
|
||||
by_kpi.setdefault(v.kpi_id, []).append(v)
|
||||
for kid, rows_k in by_kpi.items():
|
||||
currents[kid] = pick_latest(rows_k)
|
||||
|
||||
# ── 因果箭头(先按本单 source_type,为空则退回该实体全部)──
|
||||
edges_raw = db.query(KPICausality).filter(
|
||||
KPICausality.entity_id == entity_id,
|
||||
KPICausality.source_type == "strategy-2027",
|
||||
).all()
|
||||
if not edges_raw:
|
||||
edges_raw = db.query(KPICausality).filter(KPICausality.entity_id == entity_id).all()
|
||||
if edges_raw:
|
||||
warnings.append("未命中 source_type='strategy-2027' 的因果链,已退回该实体全部因果链")
|
||||
|
||||
chosen_period = period if period else (periods[0] if periods else None)
|
||||
obj_by_kpi = {v[0]: k for k, v in bindings.items() if v and v[0]}
|
||||
|
||||
nodes: list = []
|
||||
for obj in objs:
|
||||
kid, code = bindings.get(obj.id, (None, None))
|
||||
kdef = kpi_defs.get(kid) if kid else None
|
||||
unit = (kdef.unit if kdef else None) or None
|
||||
target = targets.get((kid, chosen_period)) if kid else None
|
||||
vrow = currents.get(kid) if kid else None
|
||||
cur = float(vrow.actual_value) if (vrow and vrow.actual_value is not None) else None
|
||||
calc = compute_level(kpi_id=kid, unit=unit, current=cur, target=target)
|
||||
lmeta = layer_meta(obj.dimension_key)
|
||||
owner = None
|
||||
if kdef:
|
||||
owner = getattr(kdef, "responsible_user", None) or getattr(kdef, "data_owner", None)
|
||||
dept = getattr(kdef, "responsible_dept", None)
|
||||
if dept and owner:
|
||||
owner = f"{dept}·{owner}"
|
||||
elif dept:
|
||||
owner = dept
|
||||
nodes.append({
|
||||
"id": obj.id,
|
||||
"name": obj.name,
|
||||
"dimension_key": obj.dimension_key,
|
||||
"layer_name": lmeta["name"],
|
||||
"layer_color": lmeta["color"],
|
||||
"icon": obj.icon,
|
||||
"sort_order": obj.sort_order,
|
||||
"description": obj.description,
|
||||
"kpi_id": kid,
|
||||
"kpi_code": code or (kdef.kpi_code if kdef else None),
|
||||
"kpi_name": kdef.kpi_name if kdef else None,
|
||||
"unit": unit,
|
||||
"direction": directions.get(kid),
|
||||
"owner": owner,
|
||||
"binding_source": sources.get(obj.id, "none"),
|
||||
"current": cur,
|
||||
"current_period": (vrow.period if vrow else None),
|
||||
"current_data_status": (vrow.data_status if vrow else None),
|
||||
"current_source_type": (vrow.source_type if vrow else None),
|
||||
"target": target,
|
||||
"rate": calc["rate"],
|
||||
"level": calc["level"],
|
||||
"color": calc["color"],
|
||||
"mode": calc["mode"],
|
||||
"reason": calc["reason"],
|
||||
})
|
||||
if obj.id not in bindings or not kid:
|
||||
warnings.append(f"节点「{obj.name}」未绑定 KPI(map_objectives.kpis 为空且回查未命中)")
|
||||
elif code is None and kdef:
|
||||
code = kdef.kpi_code
|
||||
|
||||
layer_buckets: dict = {k: [] for k in LAYER_DISPLAY_ORDER}
|
||||
for n in nodes:
|
||||
layer_buckets.setdefault(n["dimension_key"] if n["dimension_key"] in LAYER_META else "unknown", []).append(n)
|
||||
|
||||
layers: list = []
|
||||
for key in LAYER_DISPLAY_ORDER:
|
||||
lmeta = LAYER_META[key]
|
||||
layers.append({"key": key, "name": lmeta["name"], "color": lmeta["color"],
|
||||
"nodes": layer_buckets.get(key, [])})
|
||||
for key, bucket in layer_buckets.items():
|
||||
if key not in LAYER_META and bucket:
|
||||
layers.append({"key": key, "name": key, "color": LEVEL_COLORS["info"], "nodes": bucket})
|
||||
warnings.append(f"存在规范外 dimension_key='{key}'({len(bucket)} 个节点)")
|
||||
|
||||
edges: list = []
|
||||
dropped = 0
|
||||
for e in edges_raw:
|
||||
sid, tid = obj_by_kpi.get(e.source_kpi_id), obj_by_kpi.get(e.target_kpi_id)
|
||||
if sid is None or tid is None:
|
||||
dropped += 1
|
||||
continue
|
||||
edges.append({
|
||||
"id": e.id,
|
||||
"source_node_id": sid, "target_node_id": tid,
|
||||
"source_kpi_id": e.source_kpi_id, "target_kpi_id": e.target_kpi_id,
|
||||
"strength": float(e.strength) if e.strength is not None else None,
|
||||
"lag_months": e.lag_months,
|
||||
"direction": e.direction,
|
||||
"formula": e.formula,
|
||||
})
|
||||
if dropped:
|
||||
warnings.append(f"{dropped} 条因果链端点不在本图 19 节点内,已剔除")
|
||||
|
||||
asof = None
|
||||
asof_rank = -1
|
||||
for v in currents.values():
|
||||
if period_rank(v.period) > asof_rank:
|
||||
asof, asof_rank = v.period, period_rank(v.period)
|
||||
|
||||
with_actual = sum(1 for n in nodes if n["current"] is not None)
|
||||
return {
|
||||
"map": {
|
||||
"id": m.id, "entity_id": m.entity_id, "title": m.title,
|
||||
"version": m.version, "status": m.status,
|
||||
"updated_at": m.updated_at.isoformat() if getattr(m, "updated_at", None) else None,
|
||||
},
|
||||
"period": chosen_period,
|
||||
"periods": periods,
|
||||
"data_asof": asof,
|
||||
"layers": layers,
|
||||
"edges": edges,
|
||||
"stats": {
|
||||
"nodes": len(nodes),
|
||||
"with_actual": with_actual,
|
||||
"missing_actual": len(nodes) - with_actual,
|
||||
"edges": len(edges),
|
||||
"by_level": {lv: sum(1 for n in nodes if n["level"] == lv) for lv in LEVEL_COLORS},
|
||||
},
|
||||
"caliber": {
|
||||
"source": "app/utils/strategy_board.py",
|
||||
"level_rule": "r=actual/target;>=1.0 success;>=0.8 warning;<0.8 danger;no actual info",
|
||||
"inverse_whitelist": sorted(INVERSE_KPI_IDS),
|
||||
"status_unit": STATUS_UNIT,
|
||||
"actual_scope": "kpi_values 实测区(排除 estimated/forecast)最新期",
|
||||
"zero_guard": "target 0/缺失 ⇒ info,禁 NaN/Inf",
|
||||
},
|
||||
"warnings": warnings,
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user