"""战略地图 API""" from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy import text from sqlalchemy.orm import Session from sqlalchemy.orm.attributes import flag_modified 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 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): """覆写 前的前置校验(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 # D2(2026-10-20):裸 JSON 列不做就地变更跟踪;显式标脏,与模型层 MutableDict 双保险 flag_modified(m, "canvas_data") 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 # D2(2026-10-20):换新列表赋值同样不被裸 JSON 列跟踪 ⇒ 显式标脏(根因修复点) flag_modified(m, "canvas_data") 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} # 查询最新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) 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, "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, }