Files
cma-management/backend/app/api/maps.py
T

1607 lines
65 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.
"""战略地图 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
from app.database import get_db
from app.deps import get_entity_id
from app.auth_middleware import require_auth, require_role
from app.models import StrategicMap, OperationLog, MapObjective, Objective, KR, KPIDefinition, ReviewRecord, User
from app.utils.kr_name import sanitize_kr_name
from app.utils.kpi_value_scope import actual_only_clause
import copy
# CMA-KPI-20261003 ③ 落码:考核型目标读路径分流(kpi_targets 优先;未命中回退哨兵在 utils 内)
from app.utils.kpi_target_reader import fetch_target_map, pick_cycle_target, target_type_for_frequency
import json
import logging
logger = logging.getLogger(__name__)
# ── C1 数据前置(改造单 v2 §3,硬约束)──────────────────────────────
# C_CUST_CONCENTRATION 的 69 个实测点含 2026-10 未来期与重复值;清理完成前:
# ① 相关目标节点显示「数据待核」、**不着色**(level=pending);
# ② 该 KPI 的实测值/达成率不得进入摘要条、节点详情、趋势图(96.3% 零出口)。
# 清理单据落地后删除本表即可恢复;不要在别处另写白名单。
DATA_HOLD_KPIS = {
"C_CUST_CONCENTRATION": "数据待核:C1 实测点含未来期与重复值,清理未完成",
}
router = APIRouter(prefix="/api/cma/maps", tags=["战略地图"],
dependencies=[Depends(require_role("ceo", "finance"))],
)
# ── 四维度模板 ──────────────────────────────
STRATEGIC_MAP_TEMPLATE = [
{
"key": "finance",
"name": "财务层",
"icon": "💰",
"color": "#F56C6C",
"objectives": [
{"name": "营收目标", "kpis": ["F_REVENUE"]},
{"name": "净利润率", "kpis": ["F_NET_PROFIT"]},
{"name": "现金流", "kpis": ["F_OP_CFLOW"]},
],
},
{
"key": "customer",
"name": "客户层",
"icon": "👥",
"color": "#409EFF",
"objectives": [
{"name": "客户满意度", "kpis": ["C_SATISFACTION"]},
{"name": "市场份额", "kpis": ["C_MARKET_SHARE"]},
{"name": "客户保留率", "kpis": ["C_RETENTION_RATE"]},
],
},
{
"key": "process",
"name": "内部流程层",
"icon": "⚙️",
"color": "#67C23A",
"objectives": [
{"name": "运营效率", "kpis": ["P_DELIVERY"]},
{"name": "质量合格率", "kpis": ["P_PASS_RATE"]},
],
},
{
"key": "learning",
"name": "学习成长层",
"icon": "📚",
"color": "#E6A23C",
"objectives": [
{"name": "关键岗位胜任度", "kpis": ["L_COMPETENCY"]},
{"name": "培训完成率", "kpis": ["L_TRAINING"]},
],
},
]
# ── CRUD ────────────────────────────────────
@router.get("")
def list_maps(db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
# 账套隔离: 仅当前企业地图 (2026-08-25)
maps = db.query(StrategicMap).filter(StrategicMap.entity_id == entity_id).order_by(StrategicMap.updated_at.desc()).all()
return {"data": [m_to_dict(m, db) for m in maps]}
@router.get("/strategy/revenue-caliber")
def strategy_revenue_caliber(entity_id: int = Depends(get_entity_id)):
"""战略地图摘要条 · 收入口径状态(口径已闭合,金额取 /maps/summary)。
2026-10-19 A1 终稿:两家均总额法;增值税销售货物 13%。
本端点只出口径与业务线维度(不含金额);金额一律由 /maps/summary 现算。
"""
from app.data import revenue_caliber
return revenue_caliber.snapshot(entity_id)
@router.post("")
def create_map(data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
# 空白地图默认四层空结构(避免dimensions=NULL导致加载报错)
if "dimensions" not in data or data.get("dimensions") is None:
data = {**data, "dimensions": [
{"key": "finance", "name": "财务层", "icon": "💰", "color": "#F56C6C", "objectives": []},
{"key": "customer", "name": "客户层", "icon": "👥", "color": "#409EFF", "objectives": []},
{"key": "process", "name": "流程层", "icon": "⚙️", "color": "#67C23A", "objectives": []},
{"key": "learning", "name": "学习层", "icon": "📚", "color": "#E6A23C", "objectives": []},
]}
if "canvas_data" not in data or data.get("canvas_data") is None:
data = {**data, "canvas_data": {"connections": []}}
data["entity_id"] = entity_id # 账套隔离: 强制token企业
m = StrategicMap(**data)
db.add(m)
db.commit()
db.refresh(m)
_sync_map_objectives(m, db)
return m_to_dict(m, db)
@router.post("/create-with-template")
def create_map_with_template(data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""一键创建带四维度模板的战略地图(账套隔离: 强制token企业)"""
m = StrategicMap(
title=data.get("title", "新建战略地图"),
version=data.get("version", "v1.0"),
status="draft",
entity_id=entity_id,
dimensions=STRATEGIC_MAP_TEMPLATE,
canvas_data={"connections": []},
)
db.add(m)
db.commit()
db.refresh(m)
_sync_map_objectives(m, db)
return m_to_dict(m, db)
# ── 乐观并发保护(缺陷修复 cma-strategy-map-autosave-stale-overwrite, 2026-10-12)──
# 背景(生产实测,非推测):在线画布页把「浏览器本地快照」每 30s 整表 PUT 一次
# (dimensions + canvas_data)。若期间服务端已被他方写入(批处理/脚本/他方 API/他人编辑),
# 过期快照会静默回置服务端数据,并驱动 _sync_map_objectives 依快照重建节点行
# —— 实测把 19 行 map_objectives 删掉且未插回。
# 规则:凡请求体要覆写 dimensions/canvas_data 的写,必须携带客户端基准
# base_updated_at(或 updated_at;取库内既有列,不新增表/列);与库内现值不符 → 409。
# 校验在**任何写入之前**完成 ⇒ 校验不过时一行都不删/不改。
CONCURRENCY_GUARDED_FIELDS = ("dimensions", "canvas_data")
CONCURRENCY_BASE_KEYS = ("base_updated_at", "updated_at")
def _stamp(dt) -> Optional[str]:
"""库内时间戳 → 秒级 ISO 字符串(响应体/409 头统一口径)"""
if isinstance(dt, datetime):
return dt.replace(microsecond=0).isoformat()
return str(dt) if dt is not None else None
def _norm_stamp(raw) -> Optional[datetime]:
"""客户端基准 → 秒级 datetime(带时区先转服务器本地时区);不可解析返回 None"""
if raw is None:
return None
if isinstance(raw, datetime):
dt = raw
else:
s = str(raw).strip()
if not s:
return None
if s.endswith("Z"):
s = s[:-1] + "+00:00"
try:
dt = datetime.fromisoformat(s)
except ValueError:
return None
if dt.tzinfo is not None:
dt = dt.astimezone().replace(tzinfo=None)
return dt.replace(microsecond=0)
def _require_base_stamp(m: StrategicMap, data: dict) -> datetime:
"""并发基准唯一取值口:缺失 / 不可解析 → 409 fail-closed(调用方据此保证「一行都不改」)"""
base_raw = None
for k in CONCURRENCY_BASE_KEYS:
if data.get(k) is not None:
base_raw = data[k]
break
current = _stamp(m.updated_at)
if base_raw is None:
code = "concurrency_base_required"
detail = "缺少并发基准(base_updated_at):覆写战略地图内容须携带载入时的 updated_at"
else:
base = _norm_stamp(base_raw)
if base is not None:
return base
code = "concurrency_base_invalid"
detail = "并发基准(base_updated_at)格式无法解析"
raise HTTPException(409, detail, headers={
"X-CMA-Concurrency-Code": code,
"X-CMA-Map-Updated-At": current or "",
})
def assert_map_not_stale(m: StrategicMap, data: dict, fields=CONCURRENCY_GUARDED_FIELDS):
"""覆写 <fields> 前的前置校验(A 兜底 —— 本单不得移除)
- 不涉及护栏字段(或值为 None ⇒ 不会被赋值)→ 直接放行(如 MapList 只改 title/version);
- 缺基准 / 基准不可解析 / 基准与服务端现值不符 → 409(fail-closed:宁拒不覆写);
- 409 响应体带 detail 文案,响应头带 X-CMA-Concurrency-Code 与 X-CMA-Map-Updated-At。
- fields 参数供 P2 的按 objective 粒度通道复用(该通道只对 canvas_data 沿用本粒度)。
"""
overwriting = [k for k in fields if k in data and data.get(k) is not None]
if not overwriting:
return
base = _require_base_stamp(m, data)
if base != _norm_stamp(m.updated_at):
raise HTTPException(409, "地图已被修改,刷新后重试", headers={
"X-CMA-Concurrency-Code": "stale_map",
"X-CMA-Map-Updated-At": _stamp(m.updated_at) or "",
})
# ── 按 objective 粒度合并(P2 cma-strategy-map-objective-merge, 2026-10-12)──────
# 残留面(A 落地后):dimensions 仍是整表提交与覆写 ⇒ 两个会话编辑同一地图的**不同** objective 时,
# 后到者带过期基准被 409 拒绝:安全,但用户被迫整页刷新重做,且 409 语义被稀释
# (真冲突与「互不相干的并发」同码)。
#
# 本层把写通道从「整表覆写」收敛为「按 objective 粒度合并」:服务端保留未被本地改动的节点
# (含他方新增/删除的节点),仅对本地脏项做增/改/删;校验仍在任何写之前完成。
#
# 请求契约(全部沿用既有列,不新增表/列):
# dimensions 本地快照(结构/顺序;未标脏的节点内容服务端一律不采纳)
# dirty_objectives [{"dim_key": "finance", "objective_id": 12|null, "name": "目标A",
# "base": {...}(客户端载入时该目标的服务端原文)|null(本地新增),
# "value": {...}(新值)|null(本地删除)}]
# canvas_data 与库内现值一致 ⇒ 视为未改动(不覆写);有实质差异 ⇒ 退回 A 的 map 级校验
# base_updated_at 仍必需(缺失/不可解析 → 409,与 A 同)
#
# 冲突判定:脏项 base 与服务端现值不一致 ⇒ **同一 objective** 被并发改 ⇒ 409
# X-CMA-Concurrency-Code: stale_objective(内容冲突)/ stale_objective_missing(已被他人删除)
DIRTY_OBJECTIVES_KEY = "dirty_objectives"
def _is_granular_payload(data: dict) -> bool:
"""按 objective 粒度通道的开关:请求携带 dirty_objectives 列表(旧调用方不带 ⇒ 旧整表语义)"""
return isinstance(data.get(DIRTY_OBJECTIVES_KEY), list)
def _dims_list(raw) -> list:
"""dimensions 容错读:str / None / 非 list → [];只保留 dict 元素"""
if isinstance(raw, str):
try:
raw = json.loads(raw)
except ValueError:
raw = []
if not isinstance(raw, list):
return []
return [d for d in raw if isinstance(d, dict)]
def _obj_tag(o) -> Optional[int]:
"""objective 的服务端行 id(_objective_id / objective_id),无则 None"""
if not isinstance(o, dict):
return None
raw = o.get("_objective_id")
if raw is None:
raw = o.get("objective_id")
if raw is None:
return None
try:
return int(raw)
except (TypeError, ValueError):
return None
def _obj_key(dim_key: str, o) -> tuple:
"""objective 稳定标识:优先行 id,退化到 (维度, 名称)"""
tag = _obj_tag(o)
if tag is not None:
return (dim_key, "id", tag)
return (dim_key, "name", str((o or {}).get("name") or "").strip())
def _obj_name_key(dim_key: str, o) -> tuple:
return (dim_key, "name", str((o or {}).get("name") or "").strip())
def _canon_obj(o) -> str:
"""objective 内容归一化(并发冲突比对):丢 _ 前缀簿记键 / None / 空串,稳定序列化"""
def clean(v):
if isinstance(v, dict):
return {k: clean(x) for k, x in v.items()
if not str(k).startswith("_") and x is not None and x != ""}
if isinstance(v, list):
return [clean(x) for x in v]
return v
return json.dumps(clean(o or {}), ensure_ascii=False, sort_keys=True)
def _canon_json(v) -> str:
return json.dumps(v, ensure_ascii=False, sort_keys=True, default=str)
def _canon_canvas(v) -> str:
"""canvas_data 归一化:None / {} / {'connections': []} 等价 ⇒ 视为「无改动」"""
if not isinstance(v, dict):
return _canon_json({"connections": []})
conns = v.get("connections")
return _canon_json({"connections": conns if isinstance(conns, list) else []})
def _find_objective(dim: dict, probe, dim_key: str) -> Optional[int]:
"""在 dim.objectives 中定位(先按行 id,退化按名称);返回下标,未命中 None"""
objs = dim.get("objectives") or []
key = _obj_key(dim_key, probe)
name_key = _obj_name_key(dim_key, probe)
for i, o in enumerate(objs):
if _obj_key(dim_key, o) == key:
return i
for i, o in enumerate(objs):
if _obj_key(dim_key, o) == name_key:
return i
return None
def _raise_stale_objective(m: StrategicMap, name: str, why: str):
raise HTTPException(409, f"目标「{name}」{why},刷新后重试", headers={
"X-CMA-Concurrency-Code": "stale_objective",
"X-CMA-Map-Updated-At": _stamp(m.updated_at) or "",
})
def _merge_dimensions_by_objective(m: StrategicMap, data: dict):
"""按 objective 粒度合并 dimensions;与库内现值等价时返回 None(调用方不写该字段)
顺序:
① 冲突校验(全部脏项,任何写之前):脏项 base(客户端载入时原文)≠ 服务端现值 ⇒ 409;
② 合并:仅对脏项增/改/删,服务端其余节点(含他方新增)原样保留;
③ 顺序:本地脏过的维度按提交快照顺序重排(服务端独有节点稳定排末尾);
④ 维度层元数据不在本粒度内(无编辑入口)⇒ 服务端保留现值,新维度取提交元数据。
"""
dims_in = _dims_list(data.get("dimensions"))
dirty = [e for e in (data.get(DIRTY_OBJECTIVES_KEY) or []) if isinstance(e, dict)]
current = copy.deepcopy(_dims_list(m.dimensions))
submitted_by_key = {d.get("key") or "": d for d in dims_in}
server_by_key = {d.get("key") or "": d for d in current}
# ── ① 冲突校验(fail-closed:任一脏项冲突即整体 409,此处尚未动任何数据)──
for e in dirty:
base = e.get("base")
if base is None:
continue # 本地新增:无基准可比
dk = e.get("dim_key") or ""
srv_dim = server_by_key.get(dk)
srv_obj = None
if srv_dim is not None:
pos = _find_objective(srv_dim, e, dk)
if pos is not None:
srv_obj = (srv_dim.get("objectives") or [])[pos]
name = str(e.get("name") or (base or {}).get("name") or "")
if srv_obj is None:
_raise_stale_objective(m, name, "已被他人删除")
if _canon_obj(srv_obj) != _canon_obj(base):
_raise_stale_objective(m, name, "已被他人修改")
# ── ② 合并 ──
merged = copy.deepcopy(current)
merged_by_key = {d.get("key") or "": d for d in merged}
touched = []
for e in dirty:
dk = e.get("dim_key") or ""
dim = merged_by_key.get(dk)
if dim is None:
sub = submitted_by_key.get(dk) or {"key": dk}
dim = {"key": dk, "name": sub.get("name") or sub.get("label") or dk,
"icon": sub.get("icon"), "color": sub.get("color"), "objectives": []}
merged.append(dim)
merged_by_key[dk] = dim
if dk not in touched:
touched.append(dk)
value = e.get("value")
if value is None: # 本地删除:定位不到 ⇒ 已被他人删除(幂等)
pos = _find_objective(dim, e, dk)
if pos is not None:
dim.setdefault("objectives", []).pop(pos)
continue
if e.get("base") is None: # 本地新增:追加(绝不覆盖服务端既有同名节点)
dim.setdefault("objectives", []).append(copy.deepcopy(value))
continue
pos = _find_objective(dim, e, dk)
if pos is None:
dim.setdefault("objectives", []).append(copy.deepcopy(value))
else:
dim["objectives"][pos] = copy.deepcopy(value)
# ── ③ 顺序:本地脏过的维度按提交快照重排 ──
for dk in touched:
dim = merged_by_key[dk]
sub_objs = (submitted_by_key.get(dk) or {}).get("objectives") or []
ranks = []
for o in sub_objs:
ranks.append(_obj_key(dk, o))
ranks.append(_obj_name_key(dk, o))
def _rank(o, ranks=ranks, dk=dk):
for cand in (_obj_key(dk, o), _obj_name_key(dk, o)):
if cand in ranks:
return ranks.index(cand)
return len(ranks)
dim["objectives"] = sorted(dim.get("objectives") or [], key=_rank)
if _canon_json(merged) == _canon_json(current):
return None
return merged
def _guard_canvas_data_granular(m: StrategicMap, data: dict):
"""granular 通道下 canvas_data 的护栏:
与库内现值一致(含 None / {} / {'connections': []} 等价)⇒ 视为未改动,剔除该字段不覆写;
有实质差异 ⇒ 退回 A 的 map 级基准校验(过期 ⇒ 409 stale_map)。
"""
if data.get("canvas_data") is None:
data.pop("canvas_data", None)
return
if _canon_canvas(data["canvas_data"]) == _canon_canvas(m.canvas_data):
data.pop("canvas_data", None)
return
assert_map_not_stale(m, data, fields=("canvas_data",))
@router.put("/{map_id}")
def update_map(map_id: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
m = db.query(StrategicMap).filter(StrategicMap.id == map_id).first()
if not m:
raise HTTPException(404, "战略地图不存在")
# 账套隔离: 禁止跨企业修改
if m.entity_id != entity_id:
raise HTTPException(404, "战略地图不存在")
# 并发保护(A 兜底 2026-10-12 + 按 objective 粒度合并 P2 2026-10-12):
# - 旧调用方(不带 dirty_objectives)⇒ 整表语义不变:覆写 dimensions/canvas_data 前校验基准;
# - 带 dirty_objectives ⇒ 按 objective 粒度合并:基准仍必需(fail-closed),但只有「同一 objective」
# 被并发改才 409(stale_objective);他方在其它 objective 上的改动被保留而非拒绝。
# 校验全部在任何 setattr/删行/同步之前完成 ⇒ 校验不过时一行都不改。
if _is_granular_payload(data):
_require_base_stamp(m, data)
_guard_canvas_data_granular(m, data)
merged_dims = _merge_dimensions_by_objective(m, data)
if merged_dims is None:
data.pop("dimensions", None) # 合并结果与库内现值等价 ⇒ 不写该字段
else:
data["dimensions"] = merged_dims
else:
assert_map_not_stale(m, data)
# 基准字段/脏项明细不是业务字段,剔除后不参与赋值
data = {k: v for k, v in data.items()
if k not in CONCURRENCY_BASE_KEYS and k != DIRTY_OBJECTIVES_KEY}
old_status = m.status
# 安全白名单: 只允许编辑业务字段, 禁止 entity_id 等敏感字段被请求体篡改 (OpenCode审查#7)
# status 允许但仅限合法值域(draft/published), 防止任意发布
ALLOWED_MAP_FIELDS = {"title", "version", "dimensions", "canvas_data", "description", "status"}
for k, v in data.items():
if k in ALLOWED_MAP_FIELDS and hasattr(m, k) and v is not None:
if k == "status" and v not in ("draft", "published"):
continue
setattr(m, k, v)
db.commit()
# 同步目标到map_objectives表
_sync_map_objectives(m, db)
# KR完整修复(2026-08-27): 同步 objectives+krs 表
_sync_objectives_krs(m, db)
# ├─ 版本管理: draft → published 时自动创建快照
if old_status == "draft" and m.status == "published":
_auto_snapshot(m, db)
return m_to_dict(m, db)
# ── 删除地图 ─────────────────────────────────
@router.delete("/{map_id}")
def delete_map(map_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""删除战略地图(账套隔离: 禁止跨企业删除)"""
m = db.query(StrategicMap).filter(StrategicMap.id == map_id).first()
if not m:
raise HTTPException(404, "战略地图不存在")
if m.entity_id != entity_id:
raise HTTPException(404, "战略地图不存在")
db.delete(m)
db.commit()
return {"message": "已删除"}
@router.post("/batch-delete")
def batch_delete_maps(data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""批量删除战略地图(账套隔离: 仅本企业地图)"""
ids = data.get("ids", [])
if not ids:
raise HTTPException(400, "请选择要删除的地图")
deleted = 0
for mid in ids:
m = db.query(StrategicMap).filter(StrategicMap.id == mid, StrategicMap.entity_id == entity_id).first()
if m:
db.delete(m)
deleted += 1
db.commit()
return {"message": f"已删除 {deleted} 个地图", "deleted": deleted}
# ── 连线管理 ─────────────────────────────────
def _get_connections(m: StrategicMap) -> list:
if not m.canvas_data:
m.canvas_data = {"connections": []}
if isinstance(m.canvas_data, str):
try:
m.canvas_data = json.loads(m.canvas_data)
except:
m.canvas_data = {"connections": []}
if "connections" not in m.canvas_data:
m.canvas_data["connections"] = []
return m.canvas_data["connections"]
@router.post("/{map_id}/connections")
def add_connection(map_id: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""新增因果连线: {"from": "learning-0", "to": "process-0"}"""
m = db.query(StrategicMap).filter(StrategicMap.id == map_id, StrategicMap.entity_id == entity_id).first()
if not m:
raise HTTPException(404, "战略地图不存在")
from_id = data.get("from", "")
to_id = data.get("to", "")
if not from_id or not to_id:
raise HTTPException(400, "请提供 from 和 to")
# 校验: 不能自连
if from_id == to_id:
raise HTTPException(400, "不能自身连线")
# 校验: 维度不能相同 (但放开允许同层连线, 仅禁止自连)
from_dim = from_id.rsplit("-", 1)[0]
to_dim = to_id.rsplit("-", 1)[0]
conns = _get_connections(m)
# 校验: 不能重复
for c in conns:
if c.get("from") == from_id and c.get("to") == to_id:
raise HTTPException(400, "已存在相同的连线")
conns.append({
"from": from_id,
"to": to_id,
"style": "solid",
"effect": data.get("effect", "positive"),
"label": data.get("label", ""),
})
m.canvas_data["connections"] = conns
db.commit()
# updated_at: 供前端采纳为新基准(并发保护 base_updated_at),避免自伤 409
return {"connections": conns, "updated_at": _stamp(m.updated_at)}
@router.delete("/{map_id}/connections")
def delete_connection_by_key(map_id: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""根据 from/to 删除连线"""
m = db.query(StrategicMap).filter(StrategicMap.id == map_id, StrategicMap.entity_id == entity_id).first()
if not m:
raise HTTPException(404, "战略地图不存在")
from_id = data.get("from", "")
to_id = data.get("to", "")
conns = _get_connections(m)
new_conns = [c for c in conns if not (c.get("from") == from_id and c.get("to") == to_id)]
if len(new_conns) == len(conns):
raise HTTPException(404, "连线不存在")
m.canvas_data["connections"] = new_conns
db.commit()
return {"connections": new_conns, "removed": {"from": from_id, "to": to_id},
"updated_at": _stamp(m.updated_at)}
# ── 版本管理 ─────────────────────────────────
def _auto_snapshot(m: StrategicMap, db: Session):
"""发布时自动创建版本快照"""
from app.models import StrategicMapVersion
import re
# 自动递增版本号: 找到最大次版本号
existing = db.query(StrategicMapVersion).filter(
StrategicMapVersion.map_id == m.id
).order_by(StrategicMapVersion.id.desc()).first()
if existing:
match = re.search(r"v(\d+)\.(\d+)", existing.version)
if match:
major = int(match.group(1))
minor = int(match.group(2)) + 1
new_ver = f"v{major}.{minor}"
else:
new_ver = "v1.0"
else:
new_ver = "v1.0"
# 确保 JSON 序列化
dims = m.dimensions
canvas = m.canvas_data
if isinstance(dims, str):
try:
dims = json.loads(dims)
except:
dims = []
if isinstance(canvas, str):
try:
canvas = json.loads(canvas)
except:
canvas = {"connections": []}
snapshot = StrategicMapVersion(
map_id=m.id,
entity_id=m.entity_id if m.entity_id is not None else 1, # 继承地图企业(2026-09-08 修复: 原缺失致entity2版本行落entity1)
version=new_ver,
dimensions=dims,
canvas_data=canvas,
comment=f"发布 {new_ver}",
)
db.add(snapshot)
db.commit()
# ── 工具函数 ─────────────────────────────────
def m_to_dict(m, db: Session = None):
d = {c.name: getattr(m, c.name) for c in m.__table__.columns}
if db:
_merge_map_objectives(m, db)
d["dimensions"] = m.dimensions
return d
def _sync_map_objectives(m, db):
"""保存时:将dimensions JSON中的目标同步到map_objectives表"""
db.query(MapObjective).filter(MapObjective.map_id == m.id).delete()
dims = m.dimensions
if isinstance(dims, str):
dims = json.loads(dims)
dims = dims or []
for dim in dims:
for i, obj in enumerate(dim.get("objectives", [])):
mo = MapObjective(
map_id=m.id,
entity_id=m.entity_id if m.entity_id is not None else 1, # 强制继承地图企业(OpenCode审查#11)
dimension_key=dim.get("key", ""),
name=obj.get("name", ""),
description=obj.get("description", ""),
icon=obj.get("icon", "target"),
kpis=obj.get("kpis", []),
sort_order=i,
)
db.add(mo)
db.commit()
def _merge_map_objectives(m, db):
"""读取时:将map_objectives表的数据合并进dimensions JSON
注意(安全审查 2026-08-31):本函数是【只读合并】——只读 map_objectives 并合并到
dimensions JSON,不写库、无事务提交需求,缺 commit 不影响。勿误判为写操作。
"""
objs = db.query(MapObjective).filter(MapObjective.map_id == m.id).order_by(MapObjective.sort_order).all()
if not objs:
return
dims = m.dimensions
if isinstance(dims, str):
dims = json.loads(dims)
dims = dims or []
# 按dimension_key分组
from collections import defaultdict
grouped = defaultdict(list)
for o in objs:
grouped[o.dimension_key].append(o)
for dim in dims:
key = dim.get("key", "")
if key in grouped:
merged = []
for i, o in enumerate(grouped[key]):
# 保留原有JSON中的额外字段(targetValue/krs等)
existing = dim["objectives"][i] if i < len(dim["objectives"]) else {}
merged.append({
"name": o.name,
"description": o.description or existing.get("description", ""),
"icon": o.icon or existing.get("icon", "target"),
"kpis": o.kpis or existing.get("kpis", []),
**{k: existing[k] for k in existing if k not in ("name", "description", "icon", "kpis")},
})
dim["objectives"] = merged
m.dimensions = dims
# ── KR完整修复(2026-08-27): 地图节点 → objectives + krs 表同步 ──
KR_OPERATORS = (">=", "<=", ">", "<", "=")
def _parse_kr_operator_target(raw_target, operator=None):
"""解析KR目标值字符串:
旧格式 '≥20%'/'<=18' → ('>=', 20.0)
新格式 operator='>=' + target=20 → ('>=', 20.0)
"""
if operator:
op = str(operator).strip()
if op == "≥":
op = ">="
elif op == "≤":
op = "<="
if op not in KR_OPERATORS:
op = ">="
else:
op = ">="
if raw_target is None:
return op, None
if isinstance(raw_target, (int, float)):
return op, float(raw_target)
s = str(raw_target).strip()
if s == "":
return op, None
# 前缀符号
for cand in KR_OPERATORS:
if s.startswith(cand):
op = cand
s = s[len(cand):]
break
if s.startswith("≥"):
op = ">="
s = s[1:]
elif s.startswith("≤"):
op = "<="
s = s[1:]
# 去掉单位(%、天、小时等非数字后缀)
num = s
while num and not (num[0].isdigit() or num[0] in ".+-"):
num = num[1:]
import re
m = re.match(r"[-+]?\d*\.?\d+", num)
if not m:
return op, None
return op, float(m.group(0))
def _sync_objectives_krs(m, db):
"""保存地图时:将dimensions JSON中带krs的目标节点同步到objectives表+krs表。
- 每个带krs的目标节点 → 确保objectives表有记录(按entity_id+name匹配,无则创建)
- krs数组 → krs表 upsert(按title匹配;删除本目标下JSON中不存在的krs)
- 回写 objective_id / kr_id 到节点JSON,方便前端下次直接使用
"""
dims = m.dimensions
if isinstance(dims, str):
try:
dims = json.loads(dims)
except Exception:
return
dims = dims or []
entity_id = m.entity_id if m.entity_id is not None else 1
for dim in dims:
dim_key = dim.get("key", "")
for obj in dim.get("objectives", []):
krs = obj.get("krs") or []
if not krs:
continue
name = (obj.get("name") or "").strip()
if not name:
continue
# 1. 确保 Objective 记录存在
objective = db.query(Objective).filter(
Objective.entity_id == entity_id,
Objective.title == name,
).first()
if not objective:
objective = Objective(
entity_id=entity_id,
title=name,
description=obj.get("description") or "",
dimension=dim_key,
quarter=_map_quarter(m),
owner=obj.get("owner") or "",
status="active",
progress=0,
)
db.add(objective)
db.flush() # 拿到 id
obj["_objective_id"] = objective.id
# 2. upsert krs 表
existing_krs = {kr.title: kr for kr in db.query(KR).filter(KR.objective_id == objective.id).all()}
keep_ids = set()
for i, kr_item in enumerate(krs):
if not isinstance(kr_item, dict):
continue
title = (kr_item.get("title") or kr_item.get("name") or "").strip()
if not title:
# 跳过空KR(前端允许空白行,不入库)
continue
op, tgt = _parse_kr_operator_target(
kr_item.get("target_value"), kr_item.get("operator"))
kpi_id = kr_item.get("metric_kpi_id")
if not kpi_id and kr_item.get("kpi_code"):
kpi = db.query(KPIDefinition).filter(
KPIDefinition.entity_id == entity_id,
KPIDefinition.kpi_code == kr_item["kpi_code"],
).first()
kpi_id = kpi.id if kpi else None
# 关联KPI时自动继承方向(KPI阈值如 '>=800')
if kpi_id and not kr_item.get("operator"):
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
if kpi and kpi.threshold_green:
op2, tgt2 = _parse_kr_operator_target(kpi.threshold_green)
op, tgt = op2, (tgt2 if tgt is None else tgt)
# 护栏(2026-09-16):名称尾部「≥/≤+数字」与目标值不一致 = 历史污染截断值,
# 在此透传点(JSON/模板名 → krs.title)规整,防止污染继续传染(判据见 app/utils/kr_name.py)
clean_title, _polluted = sanitize_kr_name(title, kr_item.get("target_value"), tgt)
if _polluted:
logger.warning(
"KR名称污染已规整: map=%s objective=%s %r -> %r (target=%s)",
m.id, objective.id, title, clean_title, tgt,
)
title = clean_title
kr_item["name"] = clean_title # 同步回写 JSON,保持展示口径=落库口径
kr = existing_krs.get(title)
if kr is None:
kr = KR(
entity_id=entity_id,
objective_id=objective.id,
title=title,
)
db.add(kr)
kr.metric_kpi_id = kpi_id
kr.operator = op
if kr_item.get("tolerance") is not None:
kr.tolerance = kr_item.get("tolerance")
if kr_item.get("weight") is not None:
kr.weight = kr_item.get("weight")
if tgt is not None:
kr.target_value = tgt
if kr_item.get("current_value") is not None:
kr.current_value = kr_item.get("current_value")
if kr_item.get("due_date"):
kr.due_date = _parse_kr_date(kr_item.get("due_date"))
kr.sort_order = i + 1
keep_ids.add(kr.id)
kr_item["_kr_id"] = kr.id
# 3. 删除JSON中不存在的krs(空标题的除外——它从未入库)
for title, kr in existing_krs.items():
if kr.id not in keep_ids:
db.delete(kr)
db.commit()
# 回写dimensions(含_objective_id/_kr_id)
m.dimensions = dims
db.commit()
def _map_quarter(m) -> str:
"""从地图标题提取季度标识(如 2026Q3),无则空字符串"""
title = m.title or ""
import re
m_q = re.search(r"(20\d{2})\s*[Qq](\d)", title)
if m_q:
return f"{m_q.group(1)}Q{m_q.group(2)}"
return ""
def _parse_kr_date(v):
if v is None or v == "":
return None
if isinstance(v, datetime):
return v.date()
try:
return datetime.strptime(str(v)[:10], "%Y-%m-%d").date()
except (ValueError, TypeError):
return None
# ── 战略回顾会 聚合接口 ──────────────────────
@router.get("/{map_id}/krs")
def get_map_krs(map_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""战略回顾会 KR进度: 从krs表读取地图各目标的KR (KR修复 2026-08-27)
地图dimensions JSON中每个带krs的目标节点已同步到objectives+krs表,
此处按 节点._objective_id(或 entity+name 回查)取KR。
"""
from app.models import KPIDefinition, KR, Objective
m = db.query(StrategicMap).filter(StrategicMap.id == map_id, StrategicMap.entity_id == entity_id).first()
if not m:
raise HTTPException(404, "战略地图不存在")
dims = m.dimensions
if isinstance(dims, str):
try:
dims = json.loads(dims)
except Exception:
dims = []
dims = dims or []
obj_ids = []
for dim in dims:
for obj in dim.get("objectives", []):
oid = obj.get("_objective_id")
if oid:
obj_ids.append(int(oid))
continue
# 兼容无回写字段的旧节点: 按 entity+name 回查
name = (obj.get("name") or "").strip()
if name:
o = db.query(Objective).filter(
Objective.entity_id == entity_id,
Objective.title == name,
).first()
if o:
obj_ids.append(o.id)
result = []
if obj_ids:
krs = db.query(KR).filter(
KR.entity_id == entity_id,
KR.objective_id.in_(obj_ids),
).order_by(KR.objective_id, KR.sort_order, KR.id).all()
for kr in krs:
mkpi = None
if kr.metric_kpi_id:
mkpi = db.query(KPIDefinition).filter(KPIDefinition.id == kr.metric_kpi_id).first()
result.append({
"id": kr.id,
"objective_id": kr.objective_id,
"title": kr.title,
"metric_kpi_id": kr.metric_kpi_id,
"metric_kpi_code": mkpi.kpi_code if mkpi else None,
"metric_kpi_name": mkpi.kpi_name if mkpi else None,
"operator": _parse_kr_operator_target(kr.operator)[0] if hasattr(kr, "operator") and kr.operator else ">=",
"weight": float(kr.weight) if kr.weight is not None else None,
"target_value": float(kr.target_value) if kr.target_value is not None else None,
"current_value": float(kr.current_value) if kr.current_value is not None else None,
"progress": kr.progress or 0,
"status": kr.status,
"due_date": kr.due_date.isoformat() if kr.due_date else None,
})
return {"data": result}
@router.get("/{map_id}/review")
def get_map_review(map_id: int, level: Optional[str] = None, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""战略回顾会:返回目标状态、KPI值、改善行动
level: 可选 strategic/operational — 战略回顾(默认strategic)只显示战略级KPI;
不传则返回全部KPI(向后兼容)。
"""
from app.models import KPIDefinition, KPIValue, ActionPlan
m = db.query(StrategicMap).filter(StrategicMap.id == map_id, StrategicMap.entity_id == entity_id).first()
if not m:
raise HTTPException(404, "战略地图不存在")
dims = m.dimensions
if isinstance(dims, str):
dims = json.loads(dims)
dims = dims or [] # NULL容错
# 收集所有KPI code
all_kpi_codes = set()
for dim in dims:
for obj in dim.get("objectives", []):
for code in obj.get("kpis", []):
all_kpi_codes.add(code)
# 查询KPI定义(账套隔离: 同编码跨企业会混入, OpenCode审查#10)
kpi_defs = db.query(KPIDefinition).filter(
KPIDefinition.kpi_code.in_(all_kpi_codes) if all_kpi_codes else False,
KPIDefinition.entity_id == entity_id,
).all() if all_kpi_codes else []
kpi_map = {k.kpi_code: k for k in kpi_defs}
# ③ 落码:考核型目标一次性取新路径(战略回顾当期)
_tmap = fetch_target_map(db, entity_id, [k.id for k in kpi_defs])
# 查询最新KPI实际值
kpi_ids = [k.id for k in kpi_defs]
latest_values = {}
if kpi_ids:
# 取每个KPI的最新一条
for kid in kpi_ids:
v = db.query(KPIValue).filter(
KPIValue.kpi_id == kid,
# 实测区口径:预估行禁作地图节点实测值(kpi_value_scope.actual_only_clause)
actual_only_clause(),
).order_by(KPIValue.calculated_at.desc()).first()
if v:
latest_values[kid] = {
"actual_value": v.actual_value,
"period": v.period,
"source_type": v.source_type,
}
# 查询改善行动(按KPI_id关联)
action_plans_data = []
if kpi_ids:
plans = db.query(ActionPlan).filter(
ActionPlan.kpi_id.in_(kpi_ids)
).order_by(ActionPlan.created_at.desc()).all()
for p in plans:
action_plans_data.append({
"id": p.id,
"kpi_id": p.kpi_id,
"title": p.title,
"assignee": p.assignee,
"priority": p.priority,
"due_date": p.due_date.isoformat() if p.due_date else None,
"status": p.status,
"progress": p.progress or 0,
"created_at": p.created_at.isoformat() if p.created_at else None,
})
# 构建维度目标状态
dim_results = []
total_ok = 0
total_warn = 0
total_err = 0
total_obj_count = 0
focus_items = []
for dim in dims:
dim_key = dim.get("key", "")
dim_name = dim.get("name", "")
dim_icon = dim.get("icon", "")
dim_color = dim.get("color", "")
objectives = []
for obj in dim.get("objectives", []):
total_obj_count += 1
obj_kpis = []
worst_level = "green"
for code in obj.get("kpis", []):
kpi_def = kpi_map.get(code)
if not kpi_def:
continue
# 规则4: 战略地图默认只显示strategic级KPI(level过滤)
if level and kpi_def.kpi_level != level:
continue
lv = latest_values.get(kpi_def.id, {})
# C1 数据前置:held KPI 的实测值一律不外泄(96.3% 零出口)
held = code in DATA_HOLD_KPIS
actual = None if held else lv.get("actual_value")
# 多粒度目标优先: 月目标(多数KPI月度) → 兼容legacy target_value (OpenCode审查#5)
# ③ 落码:新路径命中即用;未命中回退兼容列(哨兵在 utils,不 or 0)
# COMPAT-FALLBACK 到期 2026-11-02:新表未命中 → 回退兼容列(顺序回退,不 or 0)
target = pick_cycle_target(_tmap, kpi_def.id, "monthly", kpi_def.target_monthly)
if target is None:
target = pick_cycle_target(_tmap, kpi_def.id,
target_type_for_frequency(kpi_def.frequency),
kpi_def.target_value)
# 判断红黄绿灯(绿≥90% / 黄60-90% / 红<60%)
# 方向修正: 越低越好型(费用率/成本率/损耗/逾期/投诉/流失) 与 负值目标 (OpenCode审查#5)
level = "gray"
ratio_val = None
if actual is not None and target:
name = (kpi_def.kpi_name or "") + " " + (kpi_def.kpi_code or "")
lower_better = any(k in name for k in
("费用率", "成本率", "损耗", "逾期", "投诉", "流失", "负债率", "返修", "缺勤"))
if lower_better:
ratio = target / actual if actual else 0 # 实际越低越好: 目标/实际
else:
ratio = actual / target
if ratio < 0:
ratio = 0 # 负值目标(如亏损控制) 完成度不为负
ratio_val = ratio
if ratio >= 0.9:
level = "green"
elif ratio >= 0.6:
level = "yellow"
else:
level = "red"
else:
level = "gray"
# C1 数据前置:held KPI 不着色、不透出达成率(level=pending 由前端灰虚线呈现)
if held:
level = "pending"
ratio_val = None
if level == "red":
worst_level = "red"
elif level == "yellow" and worst_level != "red":
worst_level = "yellow"
obj_kpis.append({
"kpi_id": kpi_def.id,
"kpi_code": code,
"kpi_name": kpi_def.kpi_name,
"target_value": target,
"actual_value": actual,
"unit": kpi_def.unit,
"level": level,
# 达成率(按指标方向归一,None=无目标/无实测,调用方须显式显示"无数据")
"ratio": round(ratio_val, 4) if ratio_val is not None else None,
})
kpi_ratios = [k["ratio"] for k in obj_kpis if k.get("ratio") is not None]
obj_ratio = round(sum(kpi_ratios) / len(kpi_ratios), 4) if kpi_ratios else None
# C1 数据前置:目标含 held KPI ⇒ 整节点「数据待核」,不计入任何达成率汇总
hold_reason = next((DATA_HOLD_KPIS[c] for c in obj.get("kpis", []) if c in DATA_HOLD_KPIS), None)
obj_item = {
"name": obj.get("name", ""),
"icon": obj.get("icon", ""),
"kpis": obj_kpis,
"level": "pending" if hold_reason else worst_level,
"has_data": len(obj_kpis) > 0,
# 目标达成率 = 有实测值 KPI 的方向归一达成率均值;None ⇒ 前端显示"无数据"
"ratio": None if hold_reason else obj_ratio,
"kpi_total": len(obj_kpis),
"kpi_with_data": 0 if hold_reason else len(kpi_ratios),
"has_actual": False if hold_reason else bool(kpi_ratios),
"data_hold": bool(hold_reason),
"hold_reason": hold_reason,
# 行动方案汇总(OpenCode审查#6): 按本目标KPI统计
"action_plan_summary": {
"total": len([p for p in action_plans_data if p["kpi_id"] in [k["kpi_id"] for k in obj_kpis]]),
"pending": len([p for p in action_plans_data
if p["kpi_id"] in [k["kpi_id"] for k in obj_kpis]
and p["status"] not in ("done", "completed")]),
} if obj_kpis else {"total": 0, "pending": 0},
}
objectives.append(obj_item)
# C1 数据前置:数据待核目标单列,不进达标/关注/风险任何一档
if hold_reason:
total_hold += 1
continue
if worst_level == "green":
total_ok += 1
elif worst_level == "yellow":
total_warn += 1
elif worst_level == "red":
total_err += 1
# 红色和黄色归入需重点关注
if worst_level in ("red", "yellow"):
focus_items.append(obj_item)
dim_results.append({
"key": dim_key,
"name": dim_name,
"icon": dim_icon,
"color": dim_color,
"objectives": objectives,
})
# 排序:红色在前,黄色在后
focus_items.sort(key=lambda x: (0 if x["level"] == "red" else 1, x["name"]))
return {
"map_id": m.id,
"title": m.title,
"version": m.version,
"status": m.status,
"dimensions": dim_results,
"summary": {
"total": total_obj_count,
"green": total_ok,
"yellow": total_warn,
"red": total_err,
"health_score": round(total_ok / total_obj_count * 100, 1) if total_obj_count > 0 else 0,
},
"focus_items": focus_items,
"action_plans": action_plans_data,
}
# ── 复盘记录持久化(大PDCA Act闭环 2026-08-27)────────────────
def review_to_dict(r: ReviewRecord) -> dict:
return {
"id": r.id,
"entity_id": r.entity_id,
"map_id": r.map_id,
"review_type": r.review_type,
"review_date": r.review_date.isoformat() if r.review_date else None,
"summary": r.summary,
"adjustments": r.adjustments,
"next_priorities": r.next_priorities,
"created_by": r.created_by,
"created_at": r.created_at.isoformat() if r.created_at else None,
}
def _parse_review_date(raw) -> Optional[date]:
"""兼容 'YYYY-MM-DD' / datetime / date 输入"""
if raw is None:
return datetime.now().date()
if isinstance(raw, datetime):
return raw.date()
if isinstance(raw, date):
return raw
try:
return datetime.strptime(str(raw)[:10], "%Y-%m-%d").date()
except ValueError:
return datetime.now().date()
def _get_map_or_404(db: Session, map_id: int, entity_id: int) -> StrategicMap:
m = db.query(StrategicMap).filter(
StrategicMap.id == map_id, StrategicMap.entity_id == entity_id
).first()
if not m:
raise HTTPException(404, "战略地图不存在")
return m
@router.post("/{map_id}/review-records")
def create_review_record(
map_id: int,
data: dict,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
current_user: User = Depends(require_auth),
):
"""保存复盘结论(战略回顾会/运营复盘会结束时落库)"""
_get_map_or_404(db, map_id, entity_id)
summary = (data.get("summary") or "").strip()
if not summary:
raise HTTPException(400, "复盘结论(summary)不能为空")
r = ReviewRecord(
entity_id=entity_id, # 多租户隔离: 强制token企业
map_id=map_id,
review_type=data.get("review_type", "quarterly"),
review_date=_parse_review_date(data.get("review_date")),
summary=summary,
adjustments=data.get("adjustments") or [],
next_priorities=data.get("next_priorities") or [],
created_by=current_user.id if current_user else None,
)
db.add(r)
db.commit()
db.refresh(r)
return review_to_dict(r)
@router.get("/{map_id}/review-records")
def list_review_records(
map_id: int,
review_type: Optional[str] = None,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""历史复盘记录列表(按复盘日期倒序)"""
_get_map_or_404(db, map_id, entity_id)
q = db.query(ReviewRecord).filter(
ReviewRecord.map_id == map_id,
ReviewRecord.entity_id == entity_id,
)
if review_type:
q = q.filter(ReviewRecord.review_type == review_type)
records = q.order_by(ReviewRecord.review_date.desc(), ReviewRecord.id.desc()).all()
return {"data": [review_to_dict(r) for r in records]}
@router.get("/{map_id}/review-records/{record_id}")
def get_review_record(
map_id: int,
record_id: int,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""单条复盘记录详情"""
_get_map_or_404(db, map_id, entity_id)
r = db.query(ReviewRecord).filter(
ReviewRecord.id == record_id,
ReviewRecord.map_id == map_id,
ReviewRecord.entity_id == entity_id,
).first()
if not r:
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,
}