"""战略地图 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, MapConnection from app.utils.kr_name import sanitize_kr_name from app.utils.kpi_value_scope import actual_only_clause import copy import json import logging import re 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): """覆写 前的前置校验(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} # ── 连线管理(P1 四层因果结构化:map_connections 表 ⇄ canvas_data 双写)── # # 2026-10-21 cma-map-connections-table-p1: # · 生产存量节点标识是 `层:中文目标名`(如 `learning:贡献率思维全员培训`), # 当前前端 MapCanvas 生成的是 `层-序号`(如 `learning-0`)⇒ 两种格式都必须能解析; # · 表是新增读模型(归因/校验用),canvas_data 保持原样供前端渲染 ⇒ 双写期; # · 表缺失时(迁移前/回滚后)所有写路径降级为只写 canvas_data,不阻断前端。 LAYER_ORDER = ("learning", "process", "customer", "finance") _LAYER_INDEX = {k: i for i, k in enumerate(LAYER_ORDER)} _NODE_COLON = re.compile(r"^([a-z_]+):(.+)$") _NODE_INDEX = re.compile(r"^([a-z_]+)-(\d+)$") def _norm_obj_name(s) -> str: return re.sub(r"\s+", " ", str(s or "").strip()) def parse_node_ref(node): """节点标识解析(双格式兼容)→ dict(layer, kind, value, error) `learning:贡献率思维全员培训` → layer=learning, kind=name, value=中文名 `learning-0` → layer=learning, kind=index, value=0 """ if not isinstance(node, str) or not node.strip(): return {"layer": None, "kind": None, "value": None, "error": "node_empty"} s = node.strip() m = _NODE_COLON.match(s) if m: layer, name = m.group(1), m.group(2).strip() if layer not in _LAYER_INDEX: return {"layer": None, "kind": None, "value": None, "error": f"unknown_layer:{layer}"} if not name: return {"layer": layer, "kind": None, "value": None, "error": "node_name_empty"} return {"layer": layer, "kind": "name", "value": name, "error": None} m = _NODE_INDEX.match(s) if m: layer, idx = m.group(1), int(m.group(2)) if layer not in _LAYER_INDEX: return {"layer": None, "kind": None, "value": None, "error": f"unknown_layer:{layer}"} return {"layer": layer, "kind": "index", "value": idx, "error": None} return {"layer": None, "kind": None, "value": None, "error": "node_format_unrecognized"} def _objective_index(db: Session, m: StrategicMap): """(名字索引, 层内顺序索引) —— 名字索引键 = (dimension_key, 归一化名称)。""" rows = db.query(MapObjective).filter(MapObjective.map_id == m.id).order_by( MapObjective.sort_order, MapObjective.id).all() by_name, by_layer = {}, {} for o in rows: by_name[(o.dimension_key, _norm_obj_name(o.name))] = o.id by_layer.setdefault(o.dimension_key, []).append(o) return by_name, by_layer def resolve_node(db: Session, m: StrategicMap, node): """→ (layer, objective_id|None, note|None) `层-序号` 的序号语义 = 前端 MapCanvas 使用的 `dimensions[层].objectives` 数组下标; dimensions 缺失时退化到 map_objectives.sort_order / 层内顺序。 """ ref = parse_node_ref(node) layer = ref["layer"] if ref["error"]: return layer, None, ref["error"] by_name, by_layer = _objective_index(db, m) if ref["kind"] == "name": want = _norm_obj_name(ref["value"]) hit = by_name.get((layer, want)) if hit: return layer, hit, None return layer, None, "objective_unresolved" idx = ref["value"] dims = _dims_list(m.dimensions) for d in dims: if d.get("key") == layer: objs = d.get("objectives") or [] if 0 <= idx < len(objs): name = _norm_obj_name((objs[idx] or {}).get("name")) hit = by_name.get((layer, name)) return layer, hit, (None if hit else "objective_unresolved") return layer, None, "index_out_of_range" cand = by_layer.get(layer) or [] for o in cand: if (o.sort_order or 0) == idx: return layer, o.id, "resolved_by_sort_order" if 0 <= idx < len(cand): return layer, cand[idx].id, "resolved_by_position" return layer, None, "index_out_of_range" def _row_payload_from_canvas(db: Session, m: StrategicMap, c: dict, sort_order: int) -> dict: frm, to = c.get("from", ""), c.get("to", "") fl, f_obj, f_note = resolve_node(db, m, frm) tl, t_obj, t_note = resolve_node(db, m, to) return { "entity_id": m.entity_id if m.entity_id is not None else 1, "map_id": m.id, "from_node": frm, "to_node": to, "from_layer": fl, "to_layer": tl, "from_objective_id": f_obj, "to_objective_id": t_obj, "effect": c.get("effect") or "positive", "label": (c.get("label") or None), "sort_order": sort_order, } def _table_ready(db: Session) -> bool: """新表是否可用(迁移前/回滚后为 False)——用 SAVEPOINT 隔离探测,避免污染事务。""" try: with db.begin_nested(): db.query(MapConnection).limit(1).all() return True except Exception: return False def _table_rows(db: Session, m: StrategicMap): # 账套隔离已在 map 查询处完成;这里只按 map_id 取行,避免历史 map.entity_id 为空时 # 冗余列与 map 不一致导致「表里有行却读不到」的假回落。 rows = db.query(MapConnection).filter( MapConnection.map_id == m.id, ).order_by(MapConnection.sort_order, MapConnection.id).all() return rows def _table_upsert(db: Session, m: StrategicMap, payload: dict) -> bool: """双写:失败只告警、不阻断 canvas_data 主路径(兼容期容错)。""" try: with db.begin_nested(): existing = db.query(MapConnection).filter( MapConnection.map_id == payload["map_id"], MapConnection.from_node == payload["from_node"], MapConnection.to_node == payload["to_node"], ).first() if existing: for k, v in payload.items(): setattr(existing, k, v) else: db.add(MapConnection(**payload)) db.flush() return True except Exception as exc: # 表缺失/授权不足/约束冲突 → 主路径继续 logger.warning("map_connections 双写失败(canvas_data 已保留): %s: %s", type(exc).__name__, exc) return False def _table_delete(db: Session, m: StrategicMap, from_node: str, to_node: str) -> bool: try: with db.begin_nested(): db.query(MapConnection).filter( MapConnection.map_id == m.id, MapConnection.from_node == from_node, MapConnection.to_node == to_node, ).delete(synchronize_session=False) return True except Exception as exc: logger.warning("map_connections 删除同步失败: %s: %s", type(exc).__name__, exc) return False 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"] def _objective_key_index(db: Session, m: StrategicMap): """连线解析锚点:MapObjective.id → 画布渲染键 `${dim}-${idx}`(以当前 dimensions 顺序为准) 返回 (id2key, name2key, dim_len): · id2key : 本表行 id → `${dim}-${idx}`(map_connections.from/to_objective_id 的锚点) · name2key: (dim, 归一化名称) → `${dim}-${idx}`(历史 `层:中文名` 的兜底,改名即失效) · dim_len : {dim: len(objectives)}(`层-序号` 越界判定) 只按名称把 dimensions 节点对回 map_objectives 行,**不读** dims JSON 里的 `_objective_id` —— 那是 objectives 表(实体级目标)的 id 空间,与 map_objectives 行 id 不是一回事。 """ dims = _dims_list(m.dimensions) rows = db.query(MapObjective).filter(MapObjective.map_id == m.id).order_by( MapObjective.sort_order, MapObjective.id).all() by_name = {} for o in rows: by_name[(o.dimension_key, _norm_obj_name(o.name))] = o.id id2key, name2key, dim_len = {}, {}, {} for d in dims: dk = d.get("key") or "" if not dk: continue objs = d.get("objectives") or [] dim_len[dk] = len(objs) for idx, o in enumerate(objs): if not isinstance(o, dict): continue key = f"{dk}-{idx}" nm = _norm_obj_name(o.get("name")) if nm: name2key[(dk, nm)] = key oid = by_name.get((dk, nm)) if oid is not None: id2key[oid] = key return id2key, name2key, dim_len def _key_for_node(node, id2key, name2key, dim_len, objective_id=None): """单端节点解析 → (key, source, reason) 顺序:objective_id 锚点 → `层:中文名` → `层-序号`。 改名后 `objective_id` 仍指向同一节点 ⇒ key 不变、线不断;锚点失效(目标已删/历史脏 id/ 跨 map)才回退原名字串,仍解析不到才返回 None(调用方计入 unresolved)。 """ if objective_id: hit = id2key.get(objective_id) if hit: return hit, "objective_id", None ref = parse_node_ref(node) if ref["error"]: return None, None, ref["error"] layer = ref["layer"] stale = "stale_objective_id" if objective_id else None if ref["kind"] == "name": want = _norm_obj_name(ref["value"]) hit = name2key.get((layer, want)) if hit: return hit, "name", stale return None, None, (stale or "objective_unresolved") idx = ref["value"] if 0 <= idx < dim_len.get(layer, 0): return f"{layer}-{idx}", "index", stale return None, None, "index_out_of_range" def _attach_keys(db: Session, m: StrategicMap, items: list): """给连线条目附 `from_key/to_key`(渲染键)+ 未解析清单。 条目原地补字段:`{side}_key` / `{side}_key_source` / `{side}_key_reason`, 键与中文名解耦的唯一出口(前端按 key 画线,缺失回落原值)。 """ id2key, name2key, dim_len = _objective_key_index(db, m) unresolved = [] for it in items: for side in ("from", "to"): key, source, reason = _key_for_node( it.get(side), id2key, name2key, dim_len, it.get(f"{side}_objective_id")) it[f"{side}_key"] = key it[f"{side}_key_source"] = source it[f"{side}_key_reason"] = reason if key is None: unresolved.append({ "from": it.get("from"), "to": it.get("to"), "side": side, "node": it.get(side), "reason": reason, "objective_id": it.get(f"{side}_objective_id"), }) return items, unresolved @router.get("/{map_id}/connections") def list_connections(map_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)): """读连线:表优先,表空回落 canvas_data(兼容期)。 P3:每条连线附 `from_key/to_key` = `${dim}-${idx}`(由 objective_id 在当前 dimensions 树反查)+ 顶层 `unresolved` 计数与清单。前端优先按 key 画线 ⇒ 目标改名不断线。 """ m = db.query(StrategicMap).filter(StrategicMap.id == map_id, StrategicMap.entity_id == entity_id).first() if not m: raise HTTPException(404, "战略地图不存在") if _table_ready(db): rows = _table_rows(db, m) if rows: items = [{ "from": r.from_node, "to": r.to_node, "style": "solid", "effect": r.effect or "positive", "label": r.label or "", "from_layer": r.from_layer, "to_layer": r.to_layer, "from_objective_id": r.from_objective_id, "to_objective_id": r.to_objective_id, } for r in rows] items, unresolved = _attach_keys(db, m, items) return { "source": "table", "total": len(items), "unresolved": len(unresolved), "unresolved_items": unresolved, "connections": items, } conns = _get_connections(m) items = [{ "from": c.get("from"), "to": c.get("to"), "style": c.get("style") or "solid", "effect": c.get("effect") or "positive", "label": c.get("label") or "", "from_objective_id": c.get("from_objective_id"), "to_objective_id": c.get("to_objective_id"), } for c in conns if isinstance(c, dict)] items, unresolved = _attach_keys(db, m, items) return { "source": "canvas_data", "total": len(items), "unresolved": len(unresolved), "unresolved_items": unresolved, "connections": items, } @router.get("/{map_id}/connections/analysis") def analyze_connections(map_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)): """连线只读分析(归因/复盘用):层统计 + 跨层分布 + 断链 + 反向告警(不拦截)。 读路径:表内有行 → 读表;否则回落 canvas_data(并对回落数据现算解析结果)。 """ m = db.query(StrategicMap).filter(StrategicMap.id == map_id, StrategicMap.entity_id == entity_id).first() if not m: raise HTTPException(404, "战略地图不存在") source = "canvas_data" items = [] table_rows = _table_rows(db, m) if _table_ready(db) else [] if table_rows: source = "table" for r in table_rows: items.append({ "from": r.from_node, "to": r.to_node, "from_layer": r.from_layer, "to_layer": r.to_layer, "from_objective_id": r.from_objective_id, "to_objective_id": r.to_objective_id, "effect": r.effect or "positive", "label": r.label, }) else: for i, c in enumerate(_get_connections(m)): if not isinstance(c, dict): continue row = _row_payload_from_canvas(db, m, c, i) items.append({ "from": row["from_node"], "to": row["to_node"], "from_layer": row["from_layer"], "to_layer": row["to_layer"], "from_objective_id": row["from_objective_id"], "to_objective_id": row["to_objective_id"], "effect": row["effect"], "label": row["label"], }) layer_stats = {k: {"as_from": 0, "as_to": 0} for k in LAYER_ORDER} cross_layer, reversed_edges, broken, unknown = {}, [], [], [] same_layer = 0 items, unresolved = _attach_keys(db, m, items) for it in items: fl, tl = it.get("from_layer"), it.get("to_layer") if fl in layer_stats: layer_stats[fl]["as_from"] += 1 if tl in layer_stats: layer_stats[tl]["as_to"] += 1 if fl not in _LAYER_INDEX or tl not in _LAYER_INDEX: unknown.append({"from": it["from"], "to": it["to"], "from_layer": fl, "to_layer": tl}) else: pair = f"{fl}->{tl}" cross_layer[pair] = cross_layer.get(pair, 0) + 1 if fl == tl: same_layer += 1 elif _LAYER_INDEX[tl] < _LAYER_INDEX[fl]: reversed_edges.append({"from": it["from"], "to": it["to"], "from_layer": fl, "to_layer": tl}) if not it.get("from_key"): broken.append({"from": it["from"], "to": it["to"], "side": "from", "node": it["from"], "reason": it.get("from_key_reason") or "objective_unresolved"}) if not it.get("to_key"): broken.append({"from": it["from"], "to": it["to"], "side": "to", "node": it["to"], "reason": it.get("to_key_reason") or "objective_unresolved"}) warnings = [] if reversed_edges: warnings.append(f"反向连线 {len(reversed_edges)} 条(与 learning→process→customer→finance 相反," f"仅告警不拦截)") if broken: warnings.append(f"断链 {len(broken)} 处(节点未能解析到 map_objectives)") if unknown: warnings.append(f"层前缀无法识别 {len(unknown)} 条") return { "map_id": m.id, "entity_id": m.entity_id, "source": source, "layer_order": list(LAYER_ORDER), "total": len(items), "layer_stats": layer_stats, "cross_layer": cross_layer, "same_layer_count": same_layer, "reversed": reversed_edges, "broken": broken, "unknown_layer": unknown, "warnings": warnings, "unresolved": len(unresolved), "unresolved_items": unresolved, "items": items, } @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"}(表 + canvas_data 双写)""" 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, "不能自身连线") 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 from sqlalchemy.orm.attributes import flag_modified flag_modified(m, "canvas_data") # 同上:JSON 列需显式标脏才能落库 # 双写(表缺失/失败不阻断前端主路径) payload = _row_payload_from_canvas(db, m, conns[-1], len(conns) - 1) db_synced = _table_upsert(db, m, payload) db.commit() # updated_at: 供前端采纳为新基准(并发保护 base_updated_at),避免自伤 409 return {"connections": conns, "updated_at": _stamp(m.updated_at), "db_synced": db_synced} @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 删除连线(表 + canvas_data 同步删)""" 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 # JSON 列就地/换列表赋值不会被 ORM 自动标记为 dirty ⇒ 必须显式 flag_modified, # 否则接口返回 200 且响应列表已减 1,但 commit 不 flush,重读仍是旧值(删连线变哑弹)。 from sqlalchemy.orm.attributes import flag_modified flag_modified(m, "canvas_data") db_synced = _table_delete(db, m, from_id, to_id) db.commit() return {"connections": new_conns, "removed": {"from": from_id, "to": to_id}, "updated_at": _stamp(m.updated_at), "db_synced": db_synced} # ── 版本管理 ───────────────────────────────── 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表(就地 upsert) 2026-10-22 cma-map-canvas-conn-key-rename-stable(P3): 原实现是 delete+insert —— MySQL 的 AUTO_INCREMENT 计数器不随 DELETE 归零,每次保存 都会给同一节点换一批新行 id,`map_connections.from/to_objective_id` 随即变成野指针 (读侧再按 id 反查就查不到),「用 objective_id 与中文名解耦」的前提被静默破坏。 改为就地 upsert:行 id 全程稳定,连线锚点才真的能扛住改名。 定位顺序:dims 的 `_mo_id`(读侧由 _merge_map_objectives 回写)→ (dimension_key, 归一化名称)。 **不做位置兜底**(口径 B,ASK-20261023-04 任总 2026-10-23 15:13「放行 4」): 未命中名字的项一律视为新目标,未被占用的存量行一律视为已删除。理由见下方 ③。 """ dims = _dims_list(m.dimensions) entity_id = m.entity_id if m.entity_id is not None else 1 rows = db.query(MapObjective).filter(MapObjective.map_id == m.id).order_by( MapObjective.sort_order, MapObjective.id).all() by_id = {r.id: r for r in rows} used = set() # 计划项:按 dims 顺序展开 planned = [] for dim in dims: dk = dim.get("key", "") for i, obj in enumerate(dim.get("objectives") or []): if isinstance(obj, dict): planned.append((dk, i, obj)) assigned = {} # ① 显式锚点:客户端回带的 `_mo_id`(读侧 _merge_map_objectives 写入)优先级最高 for idx, (dk, i, obj) in enumerate(planned): try: mo_id = int(obj.get("_mo_id")) if obj.get("_mo_id") is not None else None except (TypeError, ValueError): mo_id = None if mo_id is not None and mo_id in by_id and mo_id not in used: assigned[idx] = by_id[mo_id] used.add(mo_id) # ② 名称批:**先全表跑完名字匹配,再做位置兜底**。 # 逐项贪心(名→序位→空位)时,改名项在名字层落空后会立刻抢占同序位行,把 # 「同一次保存里改名 + 同层调序」的两个目标 id 串位 —— 线静默画到别的节点上, # 比断线更坏(看不见)。批处理顺序把名字仍有效的项先钉住,未命中的项再按顺序配对。 for idx, (dk, i, obj) in enumerate(planned): if idx in assigned: continue want = _norm_obj_name(obj.get("name")) cand = [r for r in rows if r.id not in used and r.dimension_key == dk and _norm_obj_name(r.name) == want] if cand: cand.sort(key=lambda r: (r.sort_order or 0, r.id)) # 同名重复取最靠前,结果确定 assigned[idx] = cand[0] used.add(cand[0].id) # ③ (口径 B · ASK-20261023-04 任总 2026-10-23 15:13「放行 4」)**不做位置兜底**。 # 老通道(不带 `_mo_id`)下「删一个 + 加一个」与「改名」数据上不可区分:按位置配对 # 在其中一种情况下必然静默错绑(新项继承被删项的锚点,线正常画着却是错的)。 # 错绑比断线更坏 —— 断线在读侧 `unresolved` 里可见、可计数。故: # · 名字未命中的项 → 一律当新目标插入(新行 id); # · 未被占用的存量行 → 一律当已删除删除; # · 连线锚点由此指向被删行 ⇒ 读侧记 `stale_objective_id` 并计入 unresolved。 # UI 通道(客户端回带 `_mo_id`)改名仍走 ① 精确保住锚点,本口径不影响它。 # 逐项贪心(名→序位→空位)的串位反证见 tests/test_conn_key_rename_stability.py # 的 test_negative_greedy_matching_would_crossbind。 created = 0 keep = [] for idx, (dk, i, obj) in enumerate(planned): target = assigned.get(idx) if target is None: target = MapObjective(map_id=m.id, entity_id=entity_id, dimension_key=dk or "finance") db.add(target) created += 1 target.entity_id = entity_id target.dimension_key = dk target.name = (obj.get("name") or "")[:200] target.description = obj.get("description") or "" target.icon = obj.get("icon") or "target" target.kpis = obj.get("kpis") or [] target.sort_order = i keep.append(target) db.flush() # 新增行拿到 id 后才有 id 可比 deleted = 0 for r in rows: if r.id not in used: db.delete(r) deleted += 1 db.commit() return {"planned": len(planned), "created": created, "deleted": deleted, "kept": len(used)} 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")}, # P3:回写本表行 id(连线锚点的落点)。只在读响应里出现,不落 dimensions JSON; # 客户端原样带回保存时,_sync_map_objectives 据此就地更新、保住行 id。 "_mo_id": o.id, }) 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} # 查询最新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_hold = 0 # C1 数据待核计数(此前漏初始化 → 命中 hold_reason 即 UnboundLocalError → review 500) 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) target = kpi_def.target_monthly or 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, "hold": total_hold, "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, }