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:
Hermes CI Fix
2026-10-18 16:31:52 +08:00
parent 2cff58ca6a
commit 75a48f55d2
8 changed files with 1263 additions and 4 deletions
+318
View File
@@ -1,5 +1,6 @@
"""战略地图 API""" """战略地图 API"""
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import text
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from typing import Optional from typing import Optional
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
@@ -1227,3 +1228,320 @@ def get_review_record(
raise HTTPException(404, "复盘记录不存在") raise HTTPException(404, "复盘记录不存在")
return review_to_dict(r) 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,
}
+228
View File
@@ -0,0 +1,228 @@
# -*- coding: utf-8 -*-
"""战略地图「活看板」色阶口径 —— 单一事实源(任务 finance-20261011-strategy-map-frontend-fullstack)
口径依据(逐字,来源见任务件 §三/§四 与 D1 裁定):
| 层 | dimension_key | 层色 |
|---|---|---|
| 财务(滞后) | finance | #F56C6C |
| 客户(过渡) | customer | #409EFF |
| 流程(领先) | process | #67C23A |
| 学习(根本驱动) | learning | #E6A23C |
色阶(§四):
r = 实际值 / 目标值
r >= 1.0 -> success #16A34A
r >= 0.8 -> warning #EAB308
r < 0.8 -> danger #DC2626
无实际值 -> info #94A3B8
特例(**必须**按此三类处理,不要按 direction 字段过滤):
- §4.1 逆指标 ⇒ 取倒数再算:`INVERSE_KPI_IDS` **显式 code/ID 白名单**。
不用 `direction='lower_better'` 过滤 —— 库内 `#230 EXT_139` / `#23 C_CUST_CONCENTRATION`
的 `direction` 为 **NULL**(dama 知会件 `dama-20261011-strategy-map-direction-contract-fyi`),
照 direction 过滤会把这两条逆指标错算成正向。
- §4.2 状态类 ⇒ 不走达成率:判据是 **`unit == '状态'`**(不是 `direction IS NULL`,
逆指标 #230/#23 的 direction 同样是 NULL)。约定 **1 ⇒ success;0 / NULL ⇒ danger**,
并且**绝不**做 `current / target`(避免除零)。
- §4.3 零点保护:目标值 0 / 缺失 ⇒ info(灰),禁止 NaN / Infinity / 除零崩溃。
本模块为纯函数(无 DB / 无框架依赖),供 `api/maps.py` 与单测共用。
"""
from __future__ import annotations
from typing import Any, Iterable, Optional
# ── 层色规范(§三,固定,不可自定义)────────────────────────────
LAYER_META: dict[str, dict[str, str]] = {
"finance": {"key": "finance", "name": "财务(滞后)", "color": "#F56C6C"},
"customer": {"key": "customer", "name": "客户(过渡)", "color": "#409EFF"},
"process": {"key": "process", "name": "流程(领先)", "color": "#67C23A"},
"learning": {"key": "learning", "name": "学习(根本驱动)", "color": "#E6A23C"},
}
#: 展示顺序:自下而上 学习 → 流程 → 客户 → 财务(§五.1)
LAYER_DISPLAY_ORDER: tuple[str, ...] = ("learning", "process", "customer", "finance")
#: 色阶四级色(§四)
LEVEL_COLORS: dict[str, str] = {
"success": "#16A34A",
"warning": "#EAB308",
"danger": "#DC2626",
"info": "#94A3B8",
}
#: 逆指标白名单(§4.1 + D1 裁定 §二:CCC(504) / 库存(511) / 短期借款 EXT_139(230) / 客户集中度(23))
#: 显式写死,不从 direction 推导(理由见模块 docstring)
INVERSE_KPI_IDS: frozenset[int] = frozenset({504, 511, 230, 23})
#: 状态类判据(§4.2:按 unit 判,不按 direction 判)
STATUS_UNIT = "状态"
#: 达成率阈值(§四)
WARNING_RATE = 0.8
SUCCESS_RATE = 1.0
#: 取值状态优先级(同期多行时取更可信的一条)
STATUS_RANK: dict[str, int] = {"verified": 3, "calculated": 2, "pending": 1}
_SUB_PERIOD_MONTHS = {"Q1": 3, "Q2": 6, "Q3": 9, "Q4": 12, "H1": 6, "H2": 12}
class StrategyBoardError(ValueError):
"""口径层显式错误(不静默兜底)"""
def period_rank(period: Optional[str]) -> int:
"""期间可比排序键:统一折成「年内月序」再比,避免字典序把 `2026-H1` 排在 `2026-08` 之后。
- ``YYYY-MM`` → year*12 + MM
- ``YYYY-Qn`` → year*12 + 3n
- ``YYYY-Hn`` → year*12 + 6n
- ``YYYY`` → year*12 + 12
无法解析 → 0(排在所有可解析期间之前,不参与「最新期」竞争)。
"""
if not period:
return 0
raw = str(period).strip()
parts = raw.replace("/", "-").split("-")
try:
year = int(parts[0])
except (ValueError, IndexError):
return 0
if len(parts) < 2:
return year * 12 + 12
sub = parts[1].strip().upper()
if sub.isdigit():
month = int(sub)
if 1 <= month <= 12:
return year * 12 + month
return 0
return year * 12 + _SUB_PERIOD_MONTHS.get(sub, 0)
def status_rank(data_status: Optional[str]) -> int:
"""取值状态优先级(同一期间多行时优先 verified)"""
return STATUS_RANK.get((data_status or "").strip().lower(), 0)
def pick_latest(rows: Iterable[Any], *, period_attr: str = "period",
status_attr: str = "data_status", id_attr: str = "id") -> Optional[Any]:
"""从(已按口径过滤的)实测区行里挑「最新期 + 同期间内最可信」的一条。
排序键 = (期间序, 取值状态序, id)。全部无法解析期间时退回 id,绝不返回随机行。
"""
best = None
best_key = None
for row in rows:
key = (
period_rank(getattr(row, period_attr, None)),
status_rank(getattr(row, status_attr, None)),
getattr(row, id_attr, 0) or 0,
)
if best_key is None or key > best_key:
best, best_key = row, key
return best
def is_status_kpi(unit: Optional[str]) -> bool:
"""状态类判据:unit == '状态'(§4.2)"""
return (unit or "").strip() == STATUS_UNIT
def is_inverse_kpi(kpi_id: Optional[int]) -> bool:
"""逆指标判据:显式白名单(§4.1)"""
return kpi_id is not None and int(kpi_id) in INVERSE_KPI_IDS
def compute_level(
*,
kpi_id: Any = None,
unit: Any = None,
current: Any = None,
target: Any = None,
) -> dict[str, Any]:
"""算单节点色阶。返回 ``{level, color, rate, mode, reason}``。
- ``mode``:``status`` / ``inverse`` / ``normal``
- ``rate``:达成率(状态类与灰灯为 ``None``)
- ``reason``:灰灯/非达成率的原因(供前端 tooltip 与复核用)
任何入参形态(None / 0 / 负数 / 非数值字符串)都不得抛异常、不得返回 NaN/Inf。
"""
mode = "status" if is_status_kpi(unit) else ("inverse" if is_inverse_kpi(kpi_id) else "normal")
out: dict[str, Any] = {"level": "info", "color": LEVEL_COLORS["info"], "rate": None,
"mode": mode, "reason": None}
# ── §4.2 状态类:不走达成率 ──
if mode == "status":
if current is None:
# 件 §4.2 逐字:「0 / NULL = 未达成 ⇒ 红」
out.update(level="danger", color=LEVEL_COLORS["danger"], reason="状态未填报(NULL ⇒ 未达成)")
return out
try:
val = float(current)
except (TypeError, ValueError):
out.update(level="danger", color=LEVEL_COLORS["danger"], reason="状态值非法 ⇒ 未达成")
return out
if val == 1:
out.update(level="success", color=LEVEL_COLORS["success"], reason="状态=1(已达成)")
else:
out.update(level="danger", color=LEVEL_COLORS["danger"], reason=f"状态={val:g}(未达成)")
return out
cur = _to_finite(current)
tgt = _to_finite(target)
# ── §4.3 零点保护 / 无实际值 ──
if cur is None:
out["reason"] = "无实际值"
return out
if tgt is None or tgt == 0:
out["reason"] = "目标值缺失或为 0"
return out
if mode == "inverse":
if cur == 0:
# 逆指标现值为 0 ⇒ 倒数无定义,按零点保护置灰(禁除零)
out["reason"] = "逆指标现值为 0(倒数无定义)"
return out
rate = tgt / cur
else:
rate = cur / tgt
if rate != rate or rate in (float("inf"), float("-inf")): # NaN / Inf 双保险
out["reason"] = "达成率不可计算"
return out
out["rate"] = round(rate, 4)
if rate >= SUCCESS_RATE:
out.update(level="success", color=LEVEL_COLORS["success"])
elif rate >= WARNING_RATE:
out.update(level="warning", color=LEVEL_COLORS["warning"])
else:
out.update(level="danger", color=LEVEL_COLORS["danger"])
return out
def _to_finite(value: Any) -> Optional[float]:
"""容错取数:None / 空串 / 非数值 → None;NaN / Inf → None(禁入算)"""
if value is None or value == "":
return None
try:
num = float(value)
except (TypeError, ValueError):
return None
if num != num or num in (float("inf"), float("-inf")):
return None
return num
def layer_meta(dimension_key: Optional[str]) -> dict[str, str]:
"""层元信息;未知 dimension_key 归入 info 灰的「未分层」占位(不抛错、不丢节点)"""
key = (dimension_key or "").strip()
if key in LAYER_META:
return LAYER_META[key]
return {"key": key or "unknown", "name": key or "未分层", "color": LEVEL_COLORS["info"]}
@@ -0,0 +1,123 @@
# -*- coding: utf-8 -*-
"""`map_objectives.kpis` 读端形态兼容回归(承接 dama 知会件 2026-10-11 19:1x)
背景:map_id=65 的 19 行 `kpis` 现为 NULL;库内唯一非空先例是**编码字符串数组**
(map 53);finance 拟回填的是**对象数组**且未通过形态核验。
因此读端(`_sb_resolve_bindings`)必须对四种形态都不抛错、且两形都能正确绑定:
| 形态 | 样例 | 期望来源 |
|---|---|---|
| 甲 编码字符串数组 | `["F_REVENUE"]` | kpis_code(按 code 查 kpi_definitions) |
| 丙 同上但为 JSON 字符串 | `'["F_REVENUE"]'` | kpis_code |
| 乙 对象数组 | `[{"kpi_id":1,"kpi_code":"F_REVENUE"}]` | kpis(直取 kpi_id) |
| 乙' 对象数组仅 code | `[{"kpi_code":"F_REVENUE"}]` | kpis_code |
| 丁 NULL / [] / 异形 | `None` / `[]` / `"not-json"` | 兜底(name / note / none),不得抛错 |
同时断言:**不按 status='active' 过滤** kpi_definitions
(15 条新建定义 status=NULL,#11 inactive、#230 archived,按 active 过滤会漏)。
纯桩测试(FakeDB),不连库、不触生产。
"""
import json
from types import SimpleNamespace
from typing import Any
import pytest
from app.api.maps import _sb_resolve_bindings
from app.models import KPIDefinition
DEFS = [
SimpleNamespace(id=1, entity_id=1, kpi_code="F_REVENUE", kpi_name="营业收入"),
SimpleNamespace(id=35, entity_id=1, kpi_code="F_NET_PROFIT", kpi_name="净利润"),
# status 刻意置 None:读端不得按 status 过滤
SimpleNamespace(id=504, entity_id=1, kpi_code="F_CCC", kpi_name="现金转换周期", status=None),
]
TARGETS = [(35, "净利润 · 2027")]
def _db() -> Any:
"""桩:仅供单测,不连库(运行时鸭子类型,Session 的极小子集)"""
return _DB()
def _col_val(arg):
try:
return arg.left.key, arg.right.value
except Exception:
return None, None
class _Q:
def __init__(self, model, store):
self.model, self.store, self.eq = model, store, {}
def filter(self, *args):
for a in args:
k, v = _col_val(a)
if k:
self.eq[k] = v
return self
def first(self):
for r in self.store.get(self.model, []):
if all(getattr(r, k, None) == v for k, v in self.eq.items()):
return r
return None
class _DB:
def __init__(self):
self.store = {KPIDefinition: DEFS}
def query(self, model):
return _Q(model, self.store)
def execute(self, stmt, params):
pat = params.get("pat") or ""
class _R:
def first(self):
return next((t for t in TARGETS if pat and t[1].startswith(pat[:-2])), None)
return _R()
def _obj(oid, name, kpis):
return SimpleNamespace(id=oid, name=name, kpis=kpis, dimension_key="finance")
@pytest.mark.parametrize("kpis,expect_id,expect_src", [
(["F_REVENUE"], 1, "kpis_code"), # 甲
(json.dumps(["F_CCC"]), 504, "kpis_code"), # 丙
([{"kpi_id": 35, "kpi_code": "F_NET_PROFIT"}], 35, "kpis"), # 乙
([{"kpi_code": "F_NET_PROFIT"}], 35, "kpis_code"), # 乙'
([{"code": "F_REVENUE"}], 1, "kpis_code"), # 乙''(code 别名)
({"kpi_id": 35}, 35, "kpis"), # 单对象(非数组)
])
def test_kpis_forms_bind_correctly(kpis, expect_id, expect_src):
o = _obj(2001, "任意节点名", kpis)
b, s = _sb_resolve_bindings(_db(), 1, [o])
assert b.get(2001, (None,))[0] == expect_id, f"form={kpis!r} got={b.get(2001)}"
assert s.get(2001) == expect_src, f"form={kpis!r} source={s.get(2001)}"
@pytest.mark.parametrize("kpis", [None, [], "not-json", 12345])
def test_kpis_degenerate_forms_fall_back_and_never_raise(kpis):
"""丁:NULL / 空 / 异形 —— 走 name/note 兜底或 none,绝不抛异常"""
o = _obj(2002, "净利润", kpis) # 名称可回查 + note 兜底均命中 id=35
b, s = _sb_resolve_bindings(_db(), 1, [o])
assert b.get(2002, (None,))[0] == 35
assert s.get(2002) in ("name", "note")
def test_null_kpis_unmatched_node_reports_none_not_crash():
"""19 行全 NULL 时:节点仍须在结果里(source=none),不得丢节点/抛错"""
o = _obj(2003, "图上没有对应 KPI 的节点", None)
b, s = _sb_resolve_bindings(_db(), 1, [o])
assert o.id not in b and s.get(o.id) == "none"
def test_no_status_active_filter_on_kpi_definitions():
"""status=None 的定义(15 条新建 KPI 形态)必须能被读到 —— 读端不得按 status 过滤"""
o = _obj(2004, "现金转换周期", [{"kpi_code": "F_CCC"}])
b, _s = _sb_resolve_bindings(_db(), 1, [o])
assert b.get(2004, (None,))[0] == 504, "status=None 的定义被过滤掉了(应命中)"
@@ -0,0 +1,174 @@
# -*- coding: utf-8 -*-
"""战略地图活看板色阶口径回归(任务 finance-20261011-strategy-map-frontend-fullstack)
覆盖件 §四/§4.1/§4.2/§4.3 与 §七「可直接复用的对照值」(2026-01~08 现取)。
纯函数测试:不连库、不依赖 FastAPI。
"""
import pytest
from app.utils.strategy_board import (
INVERSE_KPI_IDS, LAYER_META, LEVEL_COLORS, STATUS_UNIT,
compute_level, is_inverse_kpi, is_status_kpi, layer_meta, period_rank, pick_latest,
)
# ── §七 对照值(前端自测用,逐条断言期望色)──────────────────────────
@pytest.mark.parametrize("name,kpi_id,unit,current,target,expect", [
("营业收入", 1, "万元", 808.80, 1050, "danger"), # r=0.77
("CM 率", 503, "%", 16.83, 43, "danger"),
("CCC(逆)", 504, "天", 206.4, 150, "danger"), # r=0.727 取倒数
("货币资金", 505, "万元", 5.31, 60, "danger"),
("高贡献线收入占比", 506, "%", 47.81, 55, "warning"), # r=0.87
])
def test_spec_section7_fixtures(name, kpi_id, unit, current, target, expect):
got = compute_level(kpi_id=kpi_id, unit=unit, current=current, target=target)
assert got["level"] == expect, f"{name}: {got}"
def test_inverse_ccc_rate_is_reciprocal():
"""§4.1 CCC 206.4 → 目标 150:r = 150/206.4 = 0.7267(不是 206.4/150=1.376 的绿灯)"""
got = compute_level(kpi_id=504, unit="天", current=206.4, target=150)
assert got["mode"] == "inverse"
assert got["rate"] == pytest.approx(0.7267, abs=1e-4)
assert got["level"] == "danger"
# 若误按正向算会得到 success —— 这正是本回归要拦住的假绿
assert compute_level(kpi_id=9999, unit="天", current=206.4, target=150)["level"] == "success"
def test_inverse_whitelist_is_explicit_not_direction_based():
"""§4.1 逆指标=显式白名单;#230/#23 库内 direction 为 NULL 也不得漏"""
assert INVERSE_KPI_IDS == frozenset({504, 511, 230, 23})
for kid in (504, 511, 230, 23):
assert is_inverse_kpi(kid) is True
for kid in (1, 11, 502, 503, 505, 506, 507, 508, 509, 510, 512, 513, 514, 515, 516):
assert is_inverse_kpi(kid) is False
# 短期借款 #230:unit=元、direction=NULL ⇒ 逆指标(不能因 direction 缺失判成状态类/正向)
got = compute_level(kpi_id=230, unit="元", current=211.5, target=211.5)
assert got["mode"] == "inverse"
assert got["level"] == "success"
# ── §4.2 状态类:按 unit 判,1=绿 / 0=NULL=红,且绝不做除法 ──────────
@pytest.mark.parametrize("kpi_id,current,expect", [
(512, 1, "success"),
(512, 0, "danger"),
(512, None, "danger"), # 件 §4.2 逐字:0 / NULL = 未达成 ⇒ 红
(513, 1, "success"),
(515, 1, "success"),
(516, 0, "danger"),
])
def test_status_class_by_unit(kpi_id, current, expect):
unit = STATUS_UNIT # '状态'
got = compute_level(kpi_id=kpi_id, unit=unit, current=current, target=1)
assert got["mode"] == "status"
assert got["level"] == expect
assert got["rate"] is None, "状态类不得产出达成率"
def test_status_class_never_divides():
"""状态类 + 目标 0/缺失 ⇒ 依然按 1/0 判色,不触发除零"""
for target in (None, 0):
got = compute_level(kpi_id=512, unit="状态", current=0, target=target)
assert got["level"] == "danger" and got["rate"] is None
assert is_status_kpi("状态") is True
assert is_status_kpi("状态数") is False # 精确匹配,不用 startswith
assert is_status_kpi(None) is False
def test_status_not_inferred_from_null_direction():
"""`direction IS NULL` 不等于状态类:#230/#23 同为 NULL 但不走状态分支"""
for kid, unit in ((230, "元"), (23, "%")):
assert compute_level(kpi_id=kid, unit=unit, current=10, target=10)["mode"] == "inverse"
for kid in (512, 513, 515, 516):
assert compute_level(kpi_id=kid, unit=STATUS_UNIT, current=1, target=1)["mode"] == "status"
# ── §4.3 零点保护 / 空值 ────────────────────────────────────────────
@pytest.mark.parametrize("current,target,reason", [
(None, 150, "无实际值"),
(206.4, None, "目标值缺失或为 0"),
(206.4, 0, "目标值缺失或为 0"),
(0, 150, "逆指标现值为 0(倒数无定义)"),
])
def test_zero_guard_returns_info(current, target, reason):
got = compute_level(kpi_id=504, unit="天", current=current, target=target)
assert got["level"] == "info"
assert got["color"] == LEVEL_COLORS["info"]
assert got["rate"] is None
assert got["reason"] == reason
def test_never_nan_or_inf_or_raise():
"""历史教训:`{{ current.cover.toFixed(1) }}` 对 null 无守卫崩卡 —— 后端必须不产出 NaN/Inf"""
bad_inputs = [
(None, None), (None, 0), ("", ""), ("abc", "def"), (float("nan"), 100),
(100, float("nan")), (float("inf"), 100), (100, float("inf")),
("206.4", "150"), (False, 1), ([], {}),
]
for cur, tgt in bad_inputs:
got = compute_level(kpi_id=504, unit="天", current=cur, target=tgt)
assert got["rate"] is None or got["rate"] == got["rate"]
assert got["rate"] not in (float("inf"), float("-inf"))
assert got["level"] in LEVEL_COLORS
# 字符串数字可按数值处理(Excel/JSON 常见)
assert compute_level(kpi_id=1, unit="万元", current="808.80", target="1050")["rate"] == pytest.approx(0.7703, abs=1e-4)
# ── 阈值边界 ────────────────────────────────────────────────────────
@pytest.mark.parametrize("current,target,expect", [
(100, 100, "success"), (120, 100, "success"),
(80, 100, "warning"), (99.9, 100, "warning"),
(79.99, 100, "danger"), (0.01, 100, "danger"),
])
def test_threshold_boundaries(current, target, expect):
assert compute_level(kpi_id=1, unit="万元", current=current, target=target)["level"] == expect
# ── 期间排序 / 取最新(实测区取值口径)─────────────────────────────
@pytest.mark.parametrize("a,b", [
("2026-08", "2026-H1"), ("2026-Q4", "2026-09"), ("2027", "2026-H2"),
("2026-12", "2026-01"), ("2026-Q3", "2026-Q2"),
])
def test_period_rank_order(a, b):
assert period_rank(a) > period_rank(b)
def test_period_rank_unparsable_is_zero():
for bad in (None, "", "未知", "abc", "2026-13"):
assert period_rank(bad) == 0
class _Row:
def __init__(self, period, data_status, actual_value, id):
self.period, self.data_status, self.actual_value, self.id = period, data_status, actual_value, id
def test_pick_latest_prefers_newer_period_then_verified():
rows = [
_Row("2026-01", "verified", 180.87, 1),
_Row("2026-08", "error", 14.45, 9),
_Row("2026-08", "verified", 14.45, 8),
_Row("2026-H1", "verified", 713.27, 10),
]
got = pick_latest(rows)
assert got.period == "2026-08"
assert got.data_status == "verified", "同期间内必须取 verified(不被 error 行顶掉)"
assert pick_latest([]) is None
# ── 层色规范(§三,固定)─────────────────────────────────────────────
def test_layer_colors_fixed():
assert LAYER_META["finance"]["color"] == "#F56C6C"
assert LAYER_META["customer"]["color"] == "#409EFF"
assert LAYER_META["process"]["color"] == "#67C23A"
assert LAYER_META["learning"]["color"] == "#E6A23C"
# 未知/缺失 dimension_key 不抛错、不丢节点(置灰占位)
assert layer_meta(None)["color"] == LEVEL_COLORS["info"]
assert layer_meta("mystery")["color"] == LEVEL_COLORS["info"]
def test_level_colors_fixed():
assert LEVEL_COLORS["success"] == "#16A34A"
assert LEVEL_COLORS["warning"] == "#EAB308"
assert LEVEL_COLORS["danger"] == "#DC2626"
assert LEVEL_COLORS["info"] == "#94A3B8"
+2
View File
@@ -67,6 +67,8 @@ export const mapApi = {
delete: (id: number) => api.delete(`/maps/${id}`), delete: (id: number) => api.delete(`/maps/${id}`),
batchDelete: (ids: number[]) => api.post('/maps/batch-delete', { ids }), batchDelete: (ids: number[]) => api.post('/maps/batch-delete', { ids }),
createWithTemplate: (data: any) => api.post('/maps/create-with-template', data), createWithTemplate: (data: any) => api.post('/maps/create-with-template', data),
// 战略地图活看板(只读,2026-10-11 finance-20261011-strategy-map-frontend-fullstack)
strategyBoard: (id: number, params?: any) => api.get(`/maps/${id}/strategy-board`, { params }),
// 战略地图版本管理(2026-09-08 project-20260908-map-versions-page) // 战略地图版本管理(2026-09-08 project-20260908-map-versions-page)
versions: (mapId: number) => api.get(`/maps/${mapId}/versions`), versions: (mapId: number) => api.get(`/maps/${mapId}/versions`),
versionDetail: (mapId: number, vid: number) => api.get(`/maps/${mapId}/versions/${vid}`), versionDetail: (mapId: number, vid: number) => api.get(`/maps/${mapId}/versions/${vid}`),
+5 -4
View File
@@ -9,10 +9,10 @@ interface MenuItem {
// ── 角色路由映射 ── // ── 角色路由映射 ──
export const ROLE_ROUTES: Record<string, string[]> = { export const ROLE_ROUTES: Record<string, string[]> = {
ceo: ['/my-dashboard', '/dashboard', '/ai-suggestions', '/kpis', '/maps', '/maps-review', '/maps/canvas', '/maps/review', '/alerts', '/notifications', '/org', '/users', '/permissions', '/budget', '/deviations', '/cost', '/cost-intelligence', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/reports', '/alignment', '/dupont-analysis', '/analysis-confidence', '/expenses', '/cash-plan', '/receivables', '/growth-quality', '/product-matrix', '/tax-compliance', '/data-classification', '/cashflow-structure', '/treasury-dashboard', '/treasury-payment'], ceo: ['/my-dashboard', '/dashboard', '/ai-suggestions', '/kpis', '/maps', '/strategy-map', '/maps-review', '/maps/canvas', '/maps/review', '/alerts', '/notifications', '/org', '/users', '/permissions', '/budget', '/deviations', '/cost', '/cost-intelligence', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/reports', '/alignment', '/dupont-analysis', '/analysis-confidence', '/expenses', '/cash-plan', '/receivables', '/growth-quality', '/product-matrix', '/tax-compliance', '/data-classification', '/cashflow-structure', '/treasury-dashboard', '/treasury-payment'],
finance: ['/my-dashboard', '/dashboard', '/ai-suggestions', '/kpis', '/maps', '/maps-review', '/maps/canvas', '/maps/review', '/alerts', '/data', '/budget', '/deviations', '/cost', '/cost-intelligence', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/reports', '/alignment', '/dupont-analysis', '/analysis-confidence', '/expenses', '/cash-plan', '/receivables', '/growth-quality', '/product-matrix', '/tax-compliance', '/data-classification', '/cashflow-structure', '/treasury-dashboard', '/treasury-payment'], finance: ['/my-dashboard', '/dashboard', '/ai-suggestions', '/kpis', '/maps', '/strategy-map', '/maps-review', '/maps/canvas', '/maps/review', '/alerts', '/data', '/budget', '/deviations', '/cost', '/cost-intelligence', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/reports', '/alignment', '/dupont-analysis', '/analysis-confidence', '/expenses', '/cash-plan', '/receivables', '/growth-quality', '/product-matrix', '/tax-compliance', '/data-classification', '/cashflow-structure', '/treasury-dashboard', '/treasury-payment'],
business: ['/my-dashboard', '/dashboard', '/ai-suggestions', '/kpis', '/alerts', '/budget', '/deviations', '/action-plans', '/knowledge', '/guide', '/customer', '/expenses', '/cash-plan', '/growth-quality', '/product-matrix', '/tax-compliance', '/data-classification'], business: ['/my-dashboard', '/dashboard', '/ai-suggestions', '/kpis', '/strategy-map', '/alerts', '/budget', '/deviations', '/action-plans', '/knowledge', '/guide', '/customer', '/expenses', '/cash-plan', '/growth-quality', '/product-matrix', '/tax-compliance', '/data-classification'],
it: ['/my-dashboard', '/dashboard', '/ai-suggestions', '/kpis', '/alerts', '/data', '/org', '/users', '/permissions', '/budget', '/deviations', '/cost', '/cost-intelligence', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/analysis-confidence', '/expenses', '/cash-plan', '/product-matrix', '/tax-compliance', '/data-classification', '/treasury-payment'], it: ['/my-dashboard', '/dashboard', '/ai-suggestions', '/kpis', '/strategy-map', '/alerts', '/data', '/org', '/users', '/permissions', '/budget', '/deviations', '/cost', '/cost-intelligence', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/analysis-confidence', '/expenses', '/cash-plan', '/product-matrix', '/tax-compliance', '/data-classification', '/treasury-payment'],
} }
export const ROLE_ACTIONS: Record<string, string[]> = { export const ROLE_ACTIONS: Record<string, string[]> = {
@@ -26,6 +26,7 @@ export const MENU_ITEMS: MenuItem[] = [
// ── GROUP 1: 规划与目标(Plan)── // ── GROUP 1: 规划与目标(Plan)──
{ path: '/my-dashboard', label: '我的工作台', icon: 'Monitor', roles: ['ceo', 'finance', 'business', 'it'], group: '🔵 P 规划与目标' }, { path: '/my-dashboard', label: '我的工作台', icon: 'Monitor', roles: ['ceo', 'finance', 'business', 'it'], group: '🔵 P 规划与目标' },
{ path: '/maps', label: '战略地图', icon: 'DataBoard', roles: ['ceo', 'finance'], group: '🔵 P 规划与目标' }, { path: '/maps', label: '战略地图', icon: 'DataBoard', roles: ['ceo', 'finance'], group: '🔵 P 规划与目标' },
{ path: '/strategy-map', label: '战略地图看板', icon: 'DataBoard', roles: ['ceo', 'finance', 'business', 'it'], group: '🔵 P 规划与目标' },
{ path: '/budget', label: '预算管理', icon: 'Coin', roles: ['ceo', 'finance', 'business', 'it'], group: '🔵 P 规划与目标' }, { path: '/budget', label: '预算管理', icon: 'Coin', roles: ['ceo', 'finance', 'business', 'it'], group: '🔵 P 规划与目标' },
{ path: '/alignment', label: '战略执行看板', icon: 'Opportunity', roles: ['ceo', 'finance', 'business', 'it'], group: '🔵 P 规划与目标' }, { path: '/alignment', label: '战略执行看板', icon: 'Opportunity', roles: ['ceo', 'finance', 'business', 'it'], group: '🔵 P 规划与目标' },
+2
View File
@@ -17,6 +17,8 @@ const routes = [
{ path: 'learning-dashboard', name: 'LearningDashboard', component: () => import('@/views/LearningDashboard.vue'), meta: { title: '学习成长看板', roles: ['ceo', 'finance', 'it'], zone: 'A' } }, { path: 'learning-dashboard', name: 'LearningDashboard', component: () => import('@/views/LearningDashboard.vue'), meta: { title: '学习成长看板', roles: ['ceo', 'finance', 'it'], zone: 'A' } },
{ path: 'maps-review', name: 'MapReviewList', component: () => import('@/views/MapReview.vue'), meta: { title: '战略回顾会', roles: ['ceo', 'finance'], zone: 'B' } }, { path: 'maps-review', name: 'MapReviewList', component: () => import('@/views/MapReview.vue'), meta: { title: '战略回顾会', roles: ['ceo', 'finance'], zone: 'B' } },
{ path: 'maps/canvas/:id', name: 'MapCanvas', component: () => import('@/views/MapCanvas.vue'), meta: { title: '战略地图画布', roles: ['ceo', 'finance'], zone: 'B', editable: true } }, { path: 'maps/canvas/:id', name: 'MapCanvas', component: () => import('@/views/MapCanvas.vue'), meta: { title: '战略地图画布', roles: ['ceo', 'finance'], zone: 'B', editable: true } },
// /strategy-map: A区(只读活看板 —— 读库渲染,页内零写入口;2026-10-11 finance-20261011-strategy-map-frontend-fullstack)
{ path: 'strategy-map', name: 'StrategyMap', component: () => import('@/views/StrategyMap.vue'), meta: { title: '战略地图看板', roles: ['ceo', 'finance', 'business', 'it'], zone: 'A' } },
{ path: 'maps/review/:id', name: 'MapReview', component: () => import('@/views/MapReview.vue'), meta: { title: '战略回顾会', roles: ['ceo', 'finance'], zone: 'B' } }, { path: 'maps/review/:id', name: 'MapReview', component: () => import('@/views/MapReview.vue'), meta: { title: '战略回顾会', roles: ['ceo', 'finance'], zone: 'B' } },
{ path: 'my-dashboard', name: 'MyDashboard', component: () => import('@/views/MyDashboard.vue'), meta: { title: '我的工作台', roles: ['ceo', 'finance', 'business', 'it'], zone: 'A' } }, { path: 'my-dashboard', name: 'MyDashboard', component: () => import('@/views/MyDashboard.vue'), meta: { title: '我的工作台', roles: ['ceo', 'finance', 'business', 'it'], zone: 'A' } },
{ path: 'knowledge', name: 'CMAKnowledge', component: () => import('@/views/CMAKnowledge.vue'), meta: { title: 'CMA知识库', roles: ['ceo', 'finance', 'business', 'it'], zone: 'B', editable: true } }, { path: 'knowledge', name: 'CMAKnowledge', component: () => import('@/views/CMAKnowledge.vue'), meta: { title: 'CMA知识库', roles: ['ceo', 'finance', 'business', 'it'], zone: 'B', editable: true } },
+411
View File
@@ -0,0 +1,411 @@
<template>
<div class="strategy-map-page" ref="pageRef">
<!-- ── 页头:地图标题 + 版本 + 数据截止期 + 期间切换(只读页,无任何写入口)── -->
<div class="page-header">
<h2 class="page-title">
<el-icon class="title-icon"><DataBoard /></el-icon>
<span>{{ board?.map?.title || '战略地图' }}</span>
<el-tag v-if="board?.map?.version" size="small" effect="plain">{{ board.map.version }}</el-tag>
<el-tag v-if="board?.map?.status" size="small" type="info" effect="plain">{{ board.map.status }}</el-tag>
</h2>
<div class="header-right">
<span class="asof">
<el-icon><Clock /></el-icon>
数据截止期:<b>{{ board?.data_asof || '—' }}</b>
</span>
<el-radio-group v-model="period" size="small" :disabled="!periods.length" @change="load">
<el-radio-button v-for="p in periods" :key="p" :value="p">{{ p }}</el-radio-button>
</el-radio-group>
<el-button size="small" :loading="loading" @click="load">
<el-icon><Refresh /></el-icon>刷新
</el-button>
<el-button size="small" @click="doPrint">
<el-icon><Printer /></el-icon>打印 A3
</el-button>
</div>
</div>
<!-- ── 口径/统计条 ── -->
<div class="meta-bar">
<span class="axis-hint">泳道自下而上:学习 → 流程 → 客户 → 财务</span>
<span class="legend">
<span v-for="lg in legend" :key="lg.level" class="legend-item">
<i class="dot" :style="{ background: lg.color }"></i>{{ lg.label }} {{ lg.count }}
</span>
</span>
<span class="stat">
节点 <b>{{ stats.nodes }}</b> · 已回填实际值 <b>{{ stats.with_actual }}</b>/{{ stats.nodes }} ·
因果链 <b>{{ stats.edges }}</b>
</span>
</div>
<el-alert
v-if="missingTip"
class="caliber-note"
type="info"
:closable="false"
show-icon
:title="missingTip"
/>
<el-alert
v-if="errorMsg"
class="caliber-note"
type="error"
:closable="false"
show-icon
:title="errorMsg"
/>
<!-- ── 看板主体:泳道 + 因果箭头 ── -->
<div v-loading="loading" class="board-wrap" ref="boardRef">
<svg class="edge-layer" :width="boardSize.w" :height="boardSize.h">
<defs>
<marker id="sm-arrow" viewBox="0 0 10 10" refX="9" refY="5"
markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#94A3B8" />
</marker>
<marker id="sm-arrow-neg" viewBox="0 0 10 10" refX="9" refY="5"
markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#F56C6C" />
</marker>
</defs>
<path
v-for="e in edgePaths"
:key="e.id"
:d="e.d"
fill="none"
:stroke="e.negative ? '#F56C6C' : '#94A3B8'"
:stroke-width="e.width"
:stroke-dasharray="e.negative ? '6,4' : '0'"
:opacity="e.opacity"
:marker-end="e.negative ? 'url(#sm-arrow-neg)' : 'url(#sm-arrow)'"
>
<title>{{ e.title }}</title>
</path>
</svg>
<div
v-for="layer in displayLayers"
:key="layer.key"
class="swimlane"
:style="{ borderLeftColor: layer.color }"
>
<div class="lane-head">
<el-icon class="lane-icon" :style="{ color: layer.color }">
<component :is="layerIcon(layer.key)" />
</el-icon>
<span class="lane-name" :style="{ color: layer.color }">{{ layer.name }}</span>
<span class="lane-count">{{ layer.nodes.length }}</span>
</div>
<div class="lane-nodes">
<div
v-for="n in layer.nodes"
:key="n.id"
:ref="(el) => setNodeRef(n.id, el)"
class="node-card"
:class="'lvl-' + n.level"
:style="{ borderTopColor: n.color }"
>
<div class="card-head">
<span class="card-name" :title="n.name">{{ n.name }}</span>
<el-tag size="small" effect="plain" class="unit-tag">{{ n.unit || '—' }}</el-tag>
</div>
<div class="card-value">
<span class="cur" :class="'cur-' + n.level">
{{ n.current === null || n.current === undefined ? '待回填' : fmt(n.current) }}
</span>
<span class="arrow-mark">
<el-icon><Right /></el-icon>
</span>
<span class="tgt">{{ n.target === null || n.target === undefined ? '—' : fmt(n.target) }}</span>
<span class="pct" :style="{ color: n.color }">
{{ n.rate === null || n.rate === undefined ? (n.mode === 'status' ? '状态' : '—') : pct(n.rate) }}
</span>
</div>
<div class="bar">
<div class="bar-in" :style="{ width: barWidth(n), background: n.color }"></div>
</div>
<div class="card-foot">
<span class="foot-left">
<i class="dot" :style="{ background: n.color }"></i>{{ levelLabel(n.level) }}
<span v-if="n.mode === 'inverse'" class="tag-plain">逆</span>
<span v-if="n.mode === 'status'" class="tag-plain">状态</span>
</span>
<span class="foot-right" :title="ownerTip(n)">{{ n.owner || '—' }}</span>
</div>
<div v-if="noteOf(n)" class="card-note" :title="noteOf(n)">{{ noteOf(n) }}</div>
</div>
<div v-if="!layer.nodes.length" class="lane-empty">本层无节点</div>
</div>
</div>
<el-empty v-if="!loading && !stats.nodes" description="该地图暂无节点(map_objectives 为空)" />
</div>
<div class="print-foot">
口径:达成率 = 实际值 / 目标值(逆指标取倒数)· 绿 ≥100% / 黄 ≥80% / 红 &lt;80% / 灰 = 无实际值 ·
实际值取「实测区」(排除预估/预测行)最新期 · 数据来源:CMA 生产库(只读)
</div>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import {
DataBoard, Clock, Refresh, Printer, Right,
Money, User, Setting, Reading, TrendCharts,
} from '@element-plus/icons-vue'
import { mapApi } from '../api/index'
// ── 状态 ──
const boardRef = ref<HTMLElement | null>(null)
const loading = ref(false)
const errorMsg = ref('')
const board = ref<any>(null)
const period = ref<string>('')
const periods = ref<string[]>([])
const boardSize = ref({ w: 0, h: 0 })
const edgePaths = ref<any[]>([])
const MAP_ID = 65 // 陕西酣客三年战略规划(e1);map 列表为空时回退到「最新非归档地图」
const displayLayers = computed<any[]>(() => {
const layers = (board.value?.layers || []).slice()
// 后端已按 学习→流程→客户→财务 返回(自下而上);页面自上而下渲染需反转
const known = ['learning', 'process', 'customer', 'finance']
layers.sort((a, b) => known.indexOf(b.key) - known.indexOf(a.key))
return layers
})
const stats = computed(() => board.value?.stats || {
nodes: 0, with_actual: 0, missing_actual: 0, edges: 0,
by_level: { success: 0, warning: 0, danger: 0, info: 0 },
})
const legend = computed(() => {
const bl = stats.value.by_level || {}
return [
{ level: 'success', label: '达成', color: '#16A34A', count: bl.success || 0 },
{ level: 'warning', label: '接近', color: '#EAB308', count: bl.warning || 0 },
{ level: 'danger', label: '未达成', color: '#DC2626', count: bl.danger || 0 },
{ level: 'info', label: '待回填', color: '#94A3B8', count: bl.info || 0 },
]
})
const missingTip = computed(() => {
const s = stats.value
if (!s.nodes || !s.missing_actual) return ''
return `本图 ${s.nodes} 个节点中 ${s.missing_actual} 个尚无 kpi_values 实际值,按口径置灰(无实际值 ⇒ 灰),`
+ '待数据侧回填后自动上色;另有状态类指标(单位=状态)按 1=达成 / 0 或未填报=未达成 判色,不参与达成率。'
})
// ── 取数(只读 GET)──
async function load() {
loading.value = true
errorMsg.value = ''
try {
const params: any = {}
if (period.value) params.period = period.value
const r: any = await mapApi.strategyBoard(MAP_ID, params)
board.value = r
if (!periods.value.length) periods.value = r.periods || []
if (r.period) period.value = r.period
} catch (e: any) {
const status = e?.response?.status
if (status === 404) {
board.value = null
await loadFallbackMap()
} else {
board.value = null
errorMsg.value = `战略地图数据加载失败${status ? '(HTTP ' + status + ')' : ''}:${e?.message || e}`
}
} finally {
loading.value = false
await nextTick()
drawEdges()
}
}
/** 未命中 map 65 时:取本账套第一张非归档地图(只读,不写库) */
async function loadFallbackMap() {
try {
const maps: any = await mapApi.list()
const list = (maps?.data || maps || []).filter((m: any) => m.status !== 'archived')
const target = list.find((m: any) => m.id === MAP_ID) || list[0]
if (!target) {
errorMsg.value = '本账套暂无战略地图'
return
}
const params: any = period.value ? { period: period.value } : {}
const r: any = await mapApi.strategyBoard(target.id, params)
board.value = r
periods.value = r.periods || []
if (r.period) period.value = r.period
} catch (e: any) {
errorMsg.value = `战略地图数据加载失败:${e?.message || e}`
}
}
// ── 节点 ref / 因果箭头 ──
const nodeEls = new Map<number, HTMLElement>()
function setNodeRef(id: number, el: any) {
if (el) nodeEls.set(id, el as HTMLElement)
else nodeEls.delete(id)
}
function anchor(el: HTMLElement, up: boolean) {
const r = el.getBoundingClientRect()
return { x: r.left + r.width / 2, y: up ? r.top : r.bottom }
}
function drawEdges() {
const wrap = boardRef.value
if (!wrap) return
const wrapRect = wrap.getBoundingClientRect()
const edges = board.value?.edges || []
boardSize.value = { w: wrap.clientWidth, h: wrap.clientHeight }
const paths: any[] = []
for (const e of edges) {
const s = nodeEls.get(e.source_node_id)
const t = nodeEls.get(e.target_node_id)
if (!s || !t) continue
const sr = s.getBoundingClientRect()
const tr = t.getBoundingClientRect()
const start = anchor(s, tr.top < sr.top)
const end = anchor(t, tr.top < sr.top ? false : true)
const x1 = start.x - wrapRect.left
const y1 = start.y - wrapRect.top
const x2 = end.x - wrapRect.left
const y2 = end.y - wrapRect.top
const dy = (y2 - y1) / 2
paths.push({
id: e.id,
d: `M ${x1} ${y1} C ${x1} ${y1 + dy}, ${x2} ${y2 - dy}, ${x2} ${y2}`,
negative: e.direction === 'negative',
width: Math.max(1, Math.round((e.strength || 0.5) * 3)),
opacity: 0.35 + (e.strength || 0.5) * 0.5,
title: `${e.formula || ''}|强度 ${e.strength}|滞后 ${e.lag_months} 月|${e.direction === 'negative' ? '负向' : '正向'}`,
})
}
edgePaths.value = paths
}
let resizeTimer: any = null
function onResize() {
if (resizeTimer) clearTimeout(resizeTimer)
resizeTimer = setTimeout(() => drawEdges(), 150)
}
onMounted(async () => {
await load()
window.addEventListener('resize', onResize)
})
onBeforeUnmount(() => {
window.removeEventListener('resize', onResize)
if (resizeTimer) clearTimeout(resizeTimer)
})
// ── 展示辅助 ──
function fmt(v: any) {
const n = Number(v)
if (!isFinite(n)) return '—'
const abs = Math.abs(n)
if (abs >= 10000) return n.toLocaleString('zh-CN', { maximumFractionDigits: 0 })
if (abs >= 100) return n.toLocaleString('zh-CN', { maximumFractionDigits: 1 })
return n.toLocaleString('zh-CN', { maximumFractionDigits: 2 })
}
function pct(r: any) {
const n = Number(r)
return isFinite(n) ? (n * 100).toFixed(0) + '%' : '—'
}
function barWidth(n: any) {
if (n.mode === 'status') return n.level === 'success' ? '100%' : '0%'
const r = Number(n.rate)
if (!isFinite(r)) return '0%'
return Math.max(0, Math.min(1, r)) * 100 + '%'
}
function levelLabel(lv: string) {
return { success: '达成', warning: '接近', danger: '未达成', info: '待回填' }[lv] || lv
}
function noteOf(n: any) {
if (n.reason) return n.reason
if (n.binding_source === 'none') return '未绑定 KPI'
if (n.current_period) return `实际值期间 ${n.current_period}`
return ''
}
function ownerTip(n: any) {
return `责任人:${n.owner || '—'}|KPI:${n.kpi_code || '—'}|绑定来源:${n.binding_source || '—'}`
}
function layerIcon(key: string) {
return { finance: Money, customer: User, process: Setting, learning: Reading }[key] || TrendCharts
}
function doPrint() {
window.print()
}
</script>
<style scoped>
.strategy-map-page { padding: 16px; background: #f5f7fa; min-height: 100%; }
.page-header { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 10px; margin-bottom: 10px; }
.page-title { display: flex; align-items: center; gap: 8px; font-size: 19px; font-weight: 600; color: #1f2d3d; margin: 0; }
.title-icon { color: #409eff; }
.header-right { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.asof { font-size: 13px; color: #606266; display: inline-flex; align-items: center; gap: 4px; }
.meta-bar { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; font-size: 12.5px; color: #606266; margin-bottom: 8px; }
.axis-hint { color: #909399; }
.legend { display: inline-flex; gap: 12px; }
.legend-item { display: inline-flex; align-items: center; gap: 4px; }
.dot { display: inline-block; width: 9px; height: 9px; border-radius: 50%; }
.caliber-note { margin-bottom: 8px; }
.board-wrap { position: relative; background: #fff; border-radius: 6px; padding: 10px; box-shadow: 0 1px 4px rgba(0,0,0,.06); }
.edge-layer { position: absolute; left: 0; top: 0; pointer-events: none; z-index: 1; }
.swimlane { position: relative; z-index: 2; border-left: 4px solid #dcdfe6; background: #fbfcfe; border-radius: 4px; padding: 8px 10px; margin-bottom: 10px; }
.lane-head { display: flex; align-items: center; gap: 6px; font-weight: 600; font-size: 13.5px; margin-bottom: 8px; }
.lane-icon { font-size: 16px; }
.lane-count { font-weight: 400; color: #909399; font-size: 12px; }
.lane-nodes { display: flex; flex-wrap: wrap; gap: 10px; }
.lane-empty { color: #c0c4cc; font-size: 12px; }
.node-card { width: 232px; background: #fff; border: 1px solid #ebeef5; border-top: 3px solid #94a3b8; border-radius: 5px; padding: 8px 10px 6px; box-shadow: 0 1px 3px rgba(0,0,0,.05); }
.card-head { display: flex; align-items: center; justify-content: space-between; gap: 6px; }
.card-name { font-size: 13.5px; font-weight: 600; color: #1f2d3d; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.unit-tag { flex: none; }
.card-value { display: flex; align-items: baseline; gap: 4px; margin: 6px 0 5px; }
.cur { font-size: 16px; font-weight: 700; }
.cur-success { color: #16A34A; }
.cur-warning { color: #EAB308; }
.cur-danger { color: #DC2626; }
.cur-info { color: #94A3B8; }
.arrow-mark { color: #c0c4cc; display: inline-flex; align-items: center; }
.tgt { font-size: 14px; color: #606266; }
.pct { margin-left: auto; font-size: 12.5px; font-weight: 600; }
.bar { height: 6px; background: #f0f2f5; border-radius: 3px; overflow: hidden; }
.bar-in { height: 100%; border-radius: 3px; transition: width .3s; }
.card-foot { display: flex; align-items: center; justify-content: space-between; margin-top: 6px; font-size: 11.5px; color: #606266; }
.foot-left { display: inline-flex; align-items: center; gap: 4px; }
.foot-right { color: #909399; max-width: 108px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tag-plain { border: 1px solid #dcdfe6; border-radius: 3px; padding: 0 3px; color: #909399; font-size: 10.5px; }
.card-note { margin-top: 4px; font-size: 11px; color: #909399; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.print-foot { margin-top: 10px; font-size: 11.5px; color: #909399; line-height: 1.6; }
@media print {
@page { size: A3 landscape; margin: 8mm; }
.strategy-map-page { padding: 0; background: #fff; }
.header-right .el-button { display: none; }
.board-wrap { box-shadow: none; padding: 0; }
.node-card { break-inside: avoid; }
.swimlane { break-inside: avoid; }
}
</style>