From 75a48f55d2de16e3c75ae43a439db8af9a021fd7 Mon Sep 17 00:00:00 2001 From: Hermes CI Fix Date: Sun, 11 Oct 2026 21:18:45 +0800 Subject: [PATCH] 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) --- backend/app/api/maps.py | 318 ++++++++++++++ backend/app/utils/strategy_board.py | 228 ++++++++++ .../test_map_objectives_kpis_form_20261011.py | 123 ++++++ backend/tests/test_strategy_board_20261011.py | 174 ++++++++ frontend/src/api/index.ts | 2 + frontend/src/permission.ts | 9 +- frontend/src/router/index.ts | 2 + frontend/src/views/StrategyMap.vue | 411 ++++++++++++++++++ 8 files changed, 1263 insertions(+), 4 deletions(-) create mode 100644 backend/app/utils/strategy_board.py create mode 100644 backend/tests/test_map_objectives_kpis_form_20261011.py create mode 100644 backend/tests/test_strategy_board_20261011.py create mode 100644 frontend/src/views/StrategyMap.vue diff --git a/backend/app/api/maps.py b/backend/app/api/maps.py index 2f390a3a..6cb93cff 100644 --- a/backend/app/api/maps.py +++ b/backend/app/api/maps.py @@ -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, + } + + diff --git a/backend/app/utils/strategy_board.py b/backend/app/utils/strategy_board.py new file mode 100644 index 00000000..1a6786cf --- /dev/null +++ b/backend/app/utils/strategy_board.py @@ -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"]} diff --git a/backend/tests/test_map_objectives_kpis_form_20261011.py b/backend/tests/test_map_objectives_kpis_form_20261011.py new file mode 100644 index 00000000..d95c55e4 --- /dev/null +++ b/backend/tests/test_map_objectives_kpis_form_20261011.py @@ -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 的定义被过滤掉了(应命中)" diff --git a/backend/tests/test_strategy_board_20261011.py b/backend/tests/test_strategy_board_20261011.py new file mode 100644 index 00000000..a6c2d5bb --- /dev/null +++ b/backend/tests/test_strategy_board_20261011.py @@ -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" diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 60b6a1dc..e32ee205 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -67,6 +67,8 @@ export const mapApi = { delete: (id: number) => api.delete(`/maps/${id}`), batchDelete: (ids: number[]) => api.post('/maps/batch-delete', { ids }), 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) versions: (mapId: number) => api.get(`/maps/${mapId}/versions`), versionDetail: (mapId: number, vid: number) => api.get(`/maps/${mapId}/versions/${vid}`), diff --git a/frontend/src/permission.ts b/frontend/src/permission.ts index ab235cb7..f68ff91a 100644 --- a/frontend/src/permission.ts +++ b/frontend/src/permission.ts @@ -9,10 +9,10 @@ interface MenuItem { // ── 角色路由映射 ── export const ROLE_ROUTES: Record = { - 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'], - 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'], - 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'], - 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'], + 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', '/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', '/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', '/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 = { @@ -26,6 +26,7 @@ export const MENU_ITEMS: MenuItem[] = [ // ── GROUP 1: 规划与目标(Plan)── { path: '/my-dashboard', label: '我的工作台', icon: 'Monitor', roles: ['ceo', 'finance', 'business', 'it'], 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: '/alignment', label: '战略执行看板', icon: 'Opportunity', roles: ['ceo', 'finance', 'business', 'it'], group: '🔵 P 规划与目标' }, diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 43c72675..284fcd66 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -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: '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 } }, + // /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: '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 } }, diff --git a/frontend/src/views/StrategyMap.vue b/frontend/src/views/StrategyMap.vue new file mode 100644 index 00000000..b9583a0b --- /dev/null +++ b/frontend/src/views/StrategyMap.vue @@ -0,0 +1,411 @@ + + + + +