Files
cma-management/backend/app/utils/strategy_board.py
T
Hermes CI Fix 75a48f55d2 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)
2026-10-18 16:31:52 +08:00

229 lines
8.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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"]}