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)
229 lines
8.6 KiB
Python
229 lines
8.6 KiB
Python
# -*- 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"]}
|