diff --git a/backend/app/api/maps.py b/backend/app/api/maps.py index 35aa0ffb..3e95e27c 100644 --- a/backend/app/api/maps.py +++ b/backend/app/api/maps.py @@ -681,9 +681,102 @@ def _get_connections(m: StrategicMap) -> list: 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(兼容期)。""" + """读连线:表优先,表空回落 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, "战略地图不存在") @@ -691,25 +784,33 @@ def list_connections(map_id: int, db: Session = Depends(get_db), entity_id: int 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(rows), - "connections": [{ - "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], + "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(conns), - "connections": [{ - "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 "", - } for c in conns if isinstance(c, dict)], + "source": "canvas_data", "total": len(items), + "unresolved": len(unresolved), "unresolved_items": unresolved, + "connections": items, } @@ -751,6 +852,7 @@ def analyze_connections(map_id: int, db: Session = Depends(get_db), entity_id: i 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: @@ -768,12 +870,14 @@ def analyze_connections(map_id: int, db: Session = Depends(get_db), entity_id: i 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_objective_id"): + if not it.get("from_key"): broken.append({"from": it["from"], "to": it["to"], "side": "from", - "node": it["from"], "reason": parse_node_ref(it["from"]).get("error") or "objective_unresolved"}) - if not it.get("to_objective_id"): + "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": parse_node_ref(it["to"]).get("error") or "objective_unresolved"}) + "node": it["to"], + "reason": it.get("to_key_reason") or "objective_unresolved"}) warnings = [] if reversed_edges: @@ -791,6 +895,8 @@ def analyze_connections(map_id: int, db: Session = Depends(get_db), entity_id: i "same_layer_count": same_layer, "reversed": reversed_edges, "broken": broken, "unknown_layer": unknown, "warnings": warnings, + "unresolved": len(unresolved), "unresolved_items": unresolved, + "items": items, } @@ -925,26 +1031,98 @@ def m_to_dict(m, db: Session = None): 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 [] + """保存时:将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: - 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) + 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): @@ -978,6 +1156,9 @@ def _merge_map_objectives(m, db): "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 diff --git a/backend/tests/test_conn_key_rename_stability.py b/backend/tests/test_conn_key_rename_stability.py new file mode 100644 index 00000000..830f21dd --- /dev/null +++ b/backend/tests/test_conn_key_rename_stability.py @@ -0,0 +1,415 @@ +"""P3 阶段A · 连线键解耦(读侧解析键)验收测试 —— 沙箱,不连生产。 + +判据(对应 P3 单验收标准 2/3): + 1. 目标改名后 `from_key/to_key` 跟随**同一节点**(key 由 objective_id 反查,不随中文名变); + 2. 索引位移(删掉前一个目标)后 key 跟随节点到新位置; + 3. 负测:拿掉 `objective_id` 锚点(等价于"未实现解析键")且名字已改 ⇒ 必须解析失败 + 并被计入 `unresolved`,证明判据有区分度、不是空过。 +""" +import copy + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy.orm import Session + +from app.models import StrategicMap, MapObjective, MapConnection +from tests.conftest import create_test_user, get_token_for_user, auth_header + +DIMS = [ + {"key": "finance", "name": "财务层", "icon": "F", "color": "#F56C6C", + "objectives": [{"name": "营业收入"}, {"name": "营业利润"}]}, + {"key": "customer", "name": "客户层", "icon": "C", "color": "#409EFF", + "objectives": [{"name": "客户留存"}]}, + {"key": "process", "name": "流程层", "icon": "P", "color": "#67C23A", + "objectives": [{"name": "交付效率"}, {"name": "质量闭环"}]}, + {"key": "learning", "name": "学习层", "icon": "L", "color": "#E6A23C", + "objectives": [{"name": "全员培训"}, {"name": "能力认证"}]}, +] + + +def _seed(db: Session) -> StrategicMap: + m = StrategicMap(entity_id=1, title="p3-conn-key", status="draft", + dimensions=copy.deepcopy(DIMS), canvas_data={"connections": []}) + db.add(m) + db.commit() + db.refresh(m) + for d in DIMS: + for i, o in enumerate(d["objectives"]): + db.add(MapObjective(entity_id=1, map_id=m.id, dimension_key=d["key"], + name=o["name"], sort_order=i)) + db.commit() + return m + + +def _save_dims(client, headers, db: Session, m: StrategicMap, dims) -> None: + """整表 PUT(legacy 通道,批处理/脚本真实会走这条)""" + db.refresh(m) + body = {"dimensions": dims, + "canvas_data": copy.deepcopy(m.canvas_data), + "base_updated_at": m.updated_at.isoformat() if m.updated_at else None} + r = client.put(f"/api/cma/maps/{m.id}", json=body, headers=headers) + assert r.status_code == 200, f"整表 PUT 失败: {r.status_code} {r.text[:300]}" + + +def _conn(client, headers, map_id: int) -> dict: + r = client.get(f"/api/cma/maps/{map_id}/connections", headers=headers) + assert r.status_code == 200, r.text[:300] + return r.json() + + +def _mk_conn(client, headers, map_id: int, frm: str, to: str) -> None: + r = client.post(f"/api/cma/maps/{map_id}/connections", headers=headers, + json={"from": frm, "to": to}) + assert r.status_code == 200, f"建连线失败: {r.status_code} {r.text[:300]}" + + +def test_rename_keeps_render_key(client: TestClient, db: Session): + """UI 通道(带回 `_mo_id`)改名 → 连线仍挂同一节点(key 不变、unresolved=0) + + 口径 B(ASK-20261023-04,任总 2026-10-23 15:13「放行 4」):只有带回 `_mo_id` 的 + 通道能精确保住锚点;不带 `_mo_id` 的老通道改名会断线但可见, + 见 test_rename_without_moid_breaks_visibly。 + """ + create_test_user(db) + h = auth_header(get_token_for_user(client)) + m = _seed(db) + _mk_conn(client, h, m.id, "learning:全员培训", "process:交付效率") + + base = _conn(client, h, m.id) + assert base["total"] == 1 and base["unresolved"] == 0, base + c0 = base["connections"][0] + assert c0["from_key"] == "learning-0" and c0["to_key"] == "process-0", c0 + assert c0["from_key_source"] == "objective_id" + + # 真实 UI 通道:先读(读侧 m_to_dict→_merge_map_objectives 回写 `_mo_id`),改名后整表回传 + r = client.get("/api/cma/maps", headers=h) + assert r.status_code == 200, r.text[:300] + got_map = next(x for x in r.json()["data"] if x["id"] == m.id) + dims = got_map["dimensions"] + assert dims[3]["objectives"][0].get("_mo_id"), "读侧未回写 _mo_id,UI 通道无法保锚点" + dims[3]["objectives"][0]["name"] = "全员培训A" + _save_dims(client, h, db, m, dims) + + after = _conn(client, h, m.id) + assert after["total"] == 1, f"连线数减少: {after}" + assert after["unresolved"] == 0, after["unresolved_items"] + a0 = after["connections"][0] + # 原始串仍是旧名(禁回填),但渲染键已由锚点解析到新节点 + assert a0["from"] == "learning:全员培训", a0 + assert a0["from_key"] == "learning-0", a0 + assert a0["from_key_source"] == "objective_id" and a0["from_key_reason"] is None, a0 + + +def test_index_shift_follows_node(client: TestClient, db: Session): + """删除同层前一个目标 ⇒ 节点索引位移,key 跟随节点而非冻结在旧序号""" + create_test_user(db) + h = auth_header(get_token_for_user(client)) + m = _seed(db) + _mk_conn(client, h, m.id, "learning:能力认证", "process:交付效率") + + base = _conn(client, h, m.id)["connections"][0] + assert base["from_key"] == "learning-1", base + + dims = copy.deepcopy(m.dimensions) + dims[3]["objectives"] = dims[3]["objectives"][1:] # 删掉「全员培训」 + _save_dims(client, h, db, m, dims) + + after = _conn(client, h, m.id) + assert after["unresolved"] == 0, after["unresolved_items"] + a0 = after["connections"][0] + assert a0["from_key"] == "learning-0", f"key 未跟随节点位移: {a0}" + assert a0["from_key_source"] == "objective_id", a0 + + +def test_negative_without_anchor_must_fail(client: TestClient, db: Session): + """负测:拿掉 objective_id 锚点 + 名字已改 ⇒ 必须解析失败并计入 unresolved + + 证明判据有区分度:若解析键能力缺失(=改造前行为),这条必然断线且被计数, + 不会静默画到错节点上。 + """ + create_test_user(db) + h = auth_header(get_token_for_user(client)) + m = _seed(db) + _mk_conn(client, h, m.id, "learning:全员培训", "process:交付效率") + + dims = copy.deepcopy(m.dimensions) + dims[3]["objectives"][0]["name"] = "全员培训A" + _save_dims(client, h, db, m, dims) + + # 模拟"无锚点"(历史脏行 / 未回填):抹掉 from_objective_id + row = db.query(MapConnection).filter(MapConnection.map_id == m.id).first() + row.from_objective_id = None + db.commit() + + broken = _conn(client, h, m.id) + b0 = broken["connections"][0] + assert b0["from_key"] is None, f"无锚点 + 旧名不应解析成功: {b0}" + assert b0["from_key_reason"] == "objective_unresolved", b0 + assert broken["unresolved"] == 1, broken["unresolved_items"] + assert broken["unresolved_items"][0]["side"] == "from" + assert broken["unresolved_items"][0]["reason"] == "objective_unresolved" + # 另一端不受影响 + assert b0["to_key"] == "process-0" and b0["to_key_source"] == "objective_id", b0 + + +def test_fallback_name_source_still_works(client: TestClient, db: Session): + """兼容期:历史行无 objective_id 但名字未改 ⇒ 仍按 `层:名字` 解析成功(不误报未解析)""" + create_test_user(db) + h = auth_header(get_token_for_user(client)) + m = _seed(db) + _mk_conn(client, h, m.id, "learning:全员培训", "process:交付效率") + + row = db.query(MapConnection).filter(MapConnection.map_id == m.id).first() + row.from_objective_id = None + db.commit() + + got = _conn(client, h, m.id) + assert got["unresolved"] == 0, got["unresolved_items"] + c0 = got["connections"][0] + assert c0["from_key"] == "learning-0" and c0["from_key_source"] == "name", c0 + + +# ── project 2026-10-22 23:25 裁定的 3 项准入条件(阶段B 开跑前补测)───────────── +# 条件2-a:连续两次保存(不改名,不带 _mo_id)⇒ map_objectives 行 id 集合不变(幂等); +# 条件2-b:同一次保存内「改名 + 同层调序」且不带 _mo_id ⇒ 两个目标的 id 不得串位; +# 条件3 :删除面 —— dims 少传整层 ⇒ 该层行才删;被删目标的连线端计入 unresolved,不得静默消失。 +# 另:hold 口径断言 total = green + yellow + red + hold(hold 不得二次计入 miss)。 + +def _row_ids(db: Session, map_id: int) -> dict: + """{(层, 名字): 行 id}""" + rows = db.query(MapObjective).filter(MapObjective.map_id == map_id).all() + return {(r.dimension_key, r.name): r.id for r in rows} + + +def test_save_twice_is_idempotent(client: TestClient, db: Session): + """连续两次保存不改名 ⇒ 行 id 集合逐字不变(否则锚点必成野指针)""" + create_test_user(db) + h = auth_header(get_token_for_user(client)) + m = _seed(db) + _mk_conn(client, h, m.id, "learning:全员培训", "process:交付效率") + + first = _row_ids(db, m.id) + dims = copy.deepcopy(m.dimensions) + _save_dims(client, h, db, m, dims) # 第 1 次保存(结构未变) + second = _row_ids(db, m.id) + _save_dims(client, h, db, m, dims) # 第 2 次保存 + third = _row_ids(db, m.id) + + assert first == second, f"首次保存换了行 id: {first} -> {second}" + assert second == third, f"二次保存换了行 id: {second} -> {third}" + got = _conn(client, h, m.id) + assert got["unresolved"] == 0, got["unresolved_items"] + assert got["connections"][0]["from_key"] == "learning-0" + + +def test_rename_plus_reorder_without_moid_no_crossbind(client: TestClient, db: Session): + """口径 B:同一次保存内「改名 + 同层调序」且不带 _mo_id ⇒ 不得串位 + + 名字层先把未改名项钉住原行;改名项不再按位置兜底 ⇒ 当新行(旧行删除), + 连线锚点指向旧行 ⇒ unresolved + stale_objective_id(断线可见,绝不静默错绑)。 + """ + create_test_user(db) + h = auth_header(get_token_for_user(client)) + m = _seed(db) + _mk_conn(client, h, m.id, "learning:能力认证", "process:交付效率") + before = _row_ids(db, m.id) + assert _conn(client, h, m.id)["connections"][0]["from_key"] == "learning-1" + + # 调序(能力认证升到第 0 位)+ 改名(能力认证→能力认证A),整表 PUT,不带 _mo_id + dims = copy.deepcopy(m.dimensions) + dims[3]["objectives"] = [{"name": "能力认证A"}, {"name": "全员培训"}] + _save_dims(client, h, db, m, dims) + + after = _row_ids(db, m.id) + assert after[("learning", "全员培训")] == before[("learning", "全员培训")], \ + f"未改名的「全员培训」行 id 变了(串位): {before} -> {after}" + assert after[("learning", "能力认证A")] != before[("learning", "能力认证")], \ + f"口径 B 下老通道改名应新建行(不做位置兜底): {before} -> {after}" + + got = _conn(client, h, m.id) + assert got["total"] == 1, got + c0 = got["connections"][0] + assert got["unresolved"] == 1, got["unresolved_items"] + assert c0["from"] == "learning:能力认证", "原始串被改写(禁回填)" + assert c0["from_key"] is None and c0["from_key_reason"] == "stale_objective_id", c0 + # 关键:锚点不得被改指到别的节点(旧行为会把线静默挂到「全员培训」上) + row = db.query(MapConnection).filter(MapConnection.map_id == m.id).first() + assert row.from_objective_id == before[("learning", "能力认证")], "连线锚点被静默改指(错绑)" + + +def test_rename_without_moid_breaks_visibly(client: TestClient, db: Session): + """口径 B 单点验证:不带 `_mo_id` 的纯改名 ⇒ 新行 + 连线进 unresolved(断线可见) + + 同通道下「删A加B」与「A改名为B」数据上不可区分,位置兜底会让前者静默错绑; + 放弃兜底的代价就是这里:改名会断线,但断线在接口里可见、可计数。 + """ + create_test_user(db) + h = auth_header(get_token_for_user(client)) + m = _seed(db) + _mk_conn(client, h, m.id, "learning:全员培训", "process:交付效率") + before = _row_ids(db, m.id) + + dims = copy.deepcopy(m.dimensions) + assert not any("_mo_id" in o for o in dims[3]["objectives"]), "fixture 不应带 _mo_id(老通道)" + dims[3]["objectives"][0]["name"] = "全员培训A" + _save_dims(client, h, db, m, dims) + + after = _row_ids(db, m.id) + assert after[("learning", "全员培训A")] != before[("learning", "全员培训")], \ + f"老通道改名竟保住锚点(口径 B 下应为新行): {before} -> {after}" + assert ("learning", "全员培训") not in after, f"旧行未删: {after}" + + got = _conn(client, h, m.id) + assert got["total"] == 1, f"连线行被静默丢弃: {got}" + c0 = got["connections"][0] + assert c0["from"] == "learning:全员培训", "原始串被改写(禁回填)" + assert c0["from_key"] is None and c0["from_key_reason"] == "stale_objective_id", c0 + assert got["unresolved"] == 1 and got["unresolved_items"][0]["reason"] == "stale_objective_id" + assert c0["to_key"] == "process-0", "另一端不应受影响" + row = db.query(MapConnection).filter(MapConnection.map_id == m.id).first() + assert row.from_objective_id == before[("learning", "全员培训")], "连线锚点被静默改指(错绑)" + + +def test_delete_face_unresolved_not_silent(client: TestClient, db: Session): + """删除面:dims 少传整层 ⇒ 该层行删;被删目标的连线端计入 unresolved 且原始串保留""" + create_test_user(db) + h = auth_header(get_token_for_user(client)) + m = _seed(db) + _mk_conn(client, h, m.id, "learning:全员培训", "process:交付效率") + + dims = copy.deepcopy(m.dimensions) + dims = dims[:3] # 整层去掉 learning + _save_dims(client, h, db, m, dims) + + left = db.query(MapObjective).filter(MapObjective.map_id == m.id, + MapObjective.dimension_key == "learning").all() + assert left == [], f"该层已不在 dims,行未删: {[r.id for r in left]}" + + got = _conn(client, h, m.id) + assert got["total"] == 1, f"连线被静默丢弃: {got}" # 行仍在,不消失 + c0 = got["connections"][0] + assert c0["from"] == "learning:全员培训", "原始串被改写(禁回填)" + # 锚点指向已删行 ⇒ reason 是更精确的 `stale_objective_id`(区别于「无锚点」的 objective_unresolved) + assert c0["from_key"] is None and c0["from_key_reason"] == "stale_objective_id", c0 + assert got["unresolved"] == 1, got["unresolved_items"] + assert got["unresolved_items"][0]["side"] == "from" + assert c0["to_key"] == "process-0", "另一端不应受影响" + + +def test_review_hold_total_invariant(client: TestClient, db: Session): + """hold 口径:total = green + yellow + red + hold;hold 不得二次计入 miss(红/黄)""" + from app.models import KPIDefinition, KPIValue + from app.api.maps import DATA_HOLD_KPIS + from tests.conftest import create_test_kpi + + create_test_user(db) + h = auth_header(get_token_for_user(client)) + hold_code = next(iter(DATA_HOLD_KPIS)) + red_code = "P3_TOTAL_INVARIANT_RED" + db.query(KPIDefinition).filter(KPIDefinition.kpi_code == red_code).delete(synchronize_session=False) + db.commit() + red_kpi = create_test_kpi(db, kpi_code=red_code, kpi_name="P3 红项", dimension="customer", + target_value=100.0, kpi_level="strategic", unit="%") + db.add(KPIValue(kpi_id=red_kpi.id, period="2026-09", actual_value=10.0, source_type="actual")) + db.commit() + + dims = [{"key": "customer", "name": "客户层", "icon": "", "color": "#409EFF", "objectives": [ + {"name": "待核项", "kpis": [hold_code]}, + {"name": "红项", "kpis": [red_code]}, + {"name": "无指标项"}, + ]}] + m = StrategicMap(entity_id=1, title="p3-hold-invariant", status="active", + dimensions=dims, canvas_data={"connections": []}) + db.add(m) + db.commit() + db.refresh(m) + + r = client.get(f"/api/cma/maps/{m.id}/review", params={"level": "strategic"}, headers=h) + assert r.status_code == 200, r.text + s = r.json()["summary"] + assert s["total"] == 3, s + assert s["hold"] == 1, s + assert s["red"] == 1, s + assert s["green"] == 1, s + assert s["total"] == s["green"] + s["yellow"] + s["red"] + s["hold"], \ + f"分母不守恒(hold 被二次计入?): {s}" + + db.query(KPIValue).filter(KPIValue.kpi_id == red_kpi.id).delete(synchronize_session=False) + db.query(KPIDefinition).filter(KPIDefinition.kpi_code == red_code).delete(synchronize_session=False) + db.commit() + + + + +def test_negative_greedy_matching_would_crossbind(client: TestClient, db: Session, monkeypatch): + """负测(判据有效性的反证):把定位算法换回历史「逐项贪心(名→序位→空位)」, + 则 rename+reorder 场景下两个目标的 id 必然交换 —— 证明 + test_rename_plus_reorder_without_moid_no_crossbind 有区分度,不是空过。""" + from app.api import maps as maps_mod + + def legacy_greedy(m, db_session=None): + sess = db_session or db + dims = maps_mod._dims_list(m.dimensions) + ent = m.entity_id if m.entity_id is not None else 1 + rows = sess.query(MapObjective).filter(MapObjective.map_id == m.id).order_by( + MapObjective.sort_order, MapObjective.id).all() + used, keep = set(), [] + for dim in dims: + dk = dim.get("key", "") + for i, obj in enumerate(dim.get("objectives") or []): + if not isinstance(obj, dict): + continue + target = None + want = maps_mod._norm_obj_name(obj.get("name")) + for r in rows: # ① 名字 + if r.id in used or r.dimension_key != dk: + continue + if maps_mod._norm_obj_name(r.name) == want: + target = r + break + if target is None: + for r in rows: # ② 同层同序位 + if r.id in used or r.dimension_key != dk: + continue + if (r.sort_order or 0) == i: + target = r + break + if target is None: + for r in rows: # ③ 同层空位 + if r.id in used or r.dimension_key != dk: + continue + target = r + break + if target is None: + target = MapObjective(map_id=m.id, entity_id=ent, dimension_key=dk or "finance") + sess.add(target) + else: + used.add(target.id) + target.entity_id = ent + target.dimension_key = dk + target.name = (obj.get("name") or "")[:200] + target.icon = obj.get("icon") or "target" + target.kpis = obj.get("kpis") or [] + target.sort_order = i + keep.append(target) + sess.flush() + for r in rows: + if r.id not in used: + sess.delete(r) + sess.commit() + + create_test_user(db) + h = auth_header(get_token_for_user(client)) + m = _seed(db) + before = _row_ids(db, m.id) + + monkeypatch.setattr(maps_mod, "_sync_map_objectives", legacy_greedy) + dims = copy.deepcopy(m.dimensions) + dims[3]["objectives"] = [{"name": "能力认证A"}, {"name": "全员培训"}] + _save_dims(client, h, db, m, dims) + after = _row_ids(db, m.id) + + assert after[("learning", "全员培训")] != before[("learning", "全员培训")], \ + f"旧算法竟未串位,则该负测无法证明新判据有效: {before} -> {after}" + assert after[("learning", "能力认证A")] == before[("learning", "全员培训")], \ + f"预期旧算法把「能力认证A」绑到「全员培训」的旧行 id(串位): {before} -> {after}" diff --git a/frontend/src/components/strategy/ConnectionLines.vue b/frontend/src/components/strategy/ConnectionLines.vue index 4b1f70bd..1d85242e 100644 --- a/frontend/src/components/strategy/ConnectionLines.vue +++ b/frontend/src/components/strategy/ConnectionLines.vue @@ -136,12 +136,18 @@ import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue' import { ElMessageBox } from 'element-plus' import { getChainForConnection, getChainForNode } from '../../utils/chainGraph' +import { resolveEndpoints, relRectOf } from '../../utils/connKeys' const props = defineProps({ /** 各层DOM元素引用,格式:{ financial: HTMLElement, customer: HTMLElement, ... } */ layerRefs: { type: Object, required: true }, /** 节点DOM元素引用,格式:{ 'financial-0': HTMLElement, ... } */ nodeRefs: { type: Object, default: () => ({}) }, + /** + * 服务端解析键(P3):与 connections 逐下标对齐的 [{from_key,to_key}]。 + * key = `${层}-${序号}`,由 objective_id 反查而来 ⇒ 目标改名后线仍指向同一节点。 + */ + connKeys: { type: Array, default: () => [] }, /** 层顺序列表 */ layerKeys: { type: Array, default: () => ['financial', 'customer', 'process', 'learning'] }, /** 同层连线数据 [{from: 'financial-0', to: 'financial-1', effect, label}] */ @@ -328,26 +334,22 @@ function calcLayerConnections() { // 节点坐标缓存(相对 svg):同一节点被多条连线引用时只 getBoundingClientRect 一次 const rectCache = {} + const origin = { left: svgRect.left, top: svgRect.top } const getRect = (key) => { - if (rectCache[key]) return rectCache[key] - const el = props.nodeRefs[key] - if (!el) return null - const r = el.getBoundingClientRect() - rectCache[key] = { - left: r.left - svgRect.left, - top: r.top - svgRect.top, - right: r.right - svgRect.left, - bottom: r.bottom - svgRect.top, - width: r.width, - height: r.height, - } + if (!key) return null + if (key in rectCache) return rectCache[key] + // relRectOf:缺 ref / 已卸载 / 0×0(隐藏或未布局)一律 null —— 宁可不画,不画到 (0,0) + rectCache[key] = relRectOf(props.nodeRefs, key, origin) || null return rectCache[key] } - for (const conn of props.connections) { - const fr = getRect(conn.from) - const tr = getRect(conn.to) - if (!fr || !tr) continue + let skipped = 0 + props.connections.forEach((conn, idx) => { + // P3:优先服务端解析键(objective_id 反查,改名不断线),缺失回落原标识串 + const ep = resolveEndpoints(conn, idx, props.connKeys) + const fr = getRect(ep.from) + const tr = getRect(ep.to) + if (!fr || !tr) { skipped += 1; return } // 源节点右侧中点 → 目标节点左侧中点 const x1 = fr.right @@ -373,7 +375,11 @@ function calcLayerConnections() { effect: conn.effect || 'positive', label: conn.label || '', }) - } + }) + + // 悬停/拖拽期节点可能尚未注册(预取未回、改名后 DOM 重建)⇒ 有跳过就做有界重算, + // 而不是留一条静默消失的线(P2 暴露的「零绘制」同类问题)。 + if (skipped > 0 && depth < 6) scheduleRetry(depth + 1) layerConnections.value = result } diff --git a/frontend/src/utils/connKeys.ts b/frontend/src/utils/connKeys.ts new file mode 100644 index 00000000..2409d768 --- /dev/null +++ b/frontend/src/utils/connKeys.ts @@ -0,0 +1,101 @@ +/** + * 连线端点解析(P3 cma-map-canvas-conn-key-rename-stable) + * + * 为什么需要它: + * `canvas_data.connections[].from/to` 存的是**写入时刻**的节点标识串 + * (`层:中文名` 或 `层-序号`)。目标一改名,名字串指向不到任何节点 ⇒ 该线静默跳过。 + * 服务端 `/api/cma/maps/{id}/connections` 现在为每条连接附 `from_key/to_key` + * (= `${dim}-${idx}`,由 `from/to_objective_id` 在当前 dimensions 树反查得出), + * 渲染按 key 取节点 ⇒ 与中文名解耦;key 缺失(目标已删/历史脏 id)才回落到原值。 + * + * 同时收口两类"悬停/拖拽期取不到节点"的兜底: + * · 节点 ref 未注册(预取未返回/刚改名重建)⇒ 返回 null,交给调用方重试,绝不画到 (0,0); + * · 元素已卸载或隐藏(0×0 矩形)⇒ 视同缺失,同样不画。 + */ + +export type ConnectionKeyEntry = { from_key?: string | null; to_key?: string | null } + +export type ConnEndpoints = { + from: string | null + to: string | null + fromSource: 'key' | 'raw' | null + toSource: 'key' | 'raw' | null +} + +/** 画布渲染键(`层-序号`):只有这种键可以参与命中/吸附/合法目标判定 */ +export function isCanonicalNodeKey(key: unknown): boolean { + return typeof key === 'string' && /^[a-z_]+-\d+$/.test(key) +} + +/** + * 单条连线两端取什么键去查 DOM。 + * 优先服务端解析键(改名不断线),缺失回落数据里的原值(历史/降级路径)。 + */ +export function resolveEndpoints( + conn: any, + idx: number, + resolved?: ConnectionKeyEntry[] | null, +): ConnEndpoints { + const hit: ConnectionKeyEntry = (resolved && resolved[idx]) || {} + const pick = (side: 'from' | 'to') => { + const key = side === 'from' ? hit.from_key : hit.to_key + if (typeof key === 'string' && key) return { key, source: 'key' as const } + const raw = conn?.[side] + if (typeof raw === 'string' && raw) return { key: raw, source: 'raw' as const } + return { key: null, source: null } + } + const f = pick('from') + const t = pick('to') + return { from: f.key, to: t.key, fromSource: f.source, toSource: t.source } +} + +export type RelRect = { left: number; top: number; right: number; bottom: number; width: number; height: number } + +/** + * 取节点矩形(相对容器原点)。 + * 缺 ref / 已卸载 / 0×0(隐藏、未布局)一律 null —— 上层据此跳过该线并安排一次重算, + * 这条判据是「线不画到左上角 (0,0)」的唯一关卡。 + */ +export function relRectOf( + nodeRefs: Record | null | undefined, + key: string | null, + origin: { left: number; top: number }, +): RelRect | null { + if (!key || !nodeRefs) return null + const el: any = nodeRefs[key] + if (!el) return null + if (el.isConnected === false) return null + if (typeof el.getBoundingClientRect !== 'function') return null + const r = el.getBoundingClientRect() + if (!r || (!r.width && !r.height)) return null + return { + left: r.left - origin.left, + top: r.top - origin.top, + right: r.right - origin.left, + bottom: r.bottom - origin.top, + width: r.width, + height: r.height, + } +} + +/** + * 解析键索引(服务端 /connections → 与本地 connections 数组逐下标对齐)。 + * 按 `from→to` 配对消费(同名同向重复连线按出现顺序配对),配不上的留空 ⇒ 回落原值。 + */ +export function buildKeyIndex( + localConnections: any[], + serverConnections: any[], +): ConnectionKeyEntry[] { + const pool = new Map() + for (const it of serverConnections || []) { + const pk = `${it?.from}\u0000${it?.to}` + const arr = pool.get(pk) || [] + arr.push(it) + pool.set(pk, arr) + } + return (localConnections || []).map((c: any) => { + const arr = pool.get(`${c?.from}\u0000${c?.to}`) || [] + const hit = arr.shift() + return { from_key: hit?.from_key ?? null, to_key: hit?.to_key ?? null } + }) +} diff --git a/frontend/src/views/MapCanvas.vue b/frontend/src/views/MapCanvas.vue index 7aa36419..ff83ad05 100644 --- a/frontend/src/views/MapCanvas.vue +++ b/frontend/src/views/MapCanvas.vue @@ -210,6 +210,7 @@ :layer-refs="layerRefs" :node-refs="compiledNodeRefs" :layer-keys="layerKeysOrdered" + :conn-keys="connKeys" :connections="connections" :temp-line="tempLine" :container-key="recalcTrigger" @@ -319,6 +320,8 @@ import MapTargetTree from "../components/strategy-map/MapTargetTree.vue" import MapNodeDetail from "../components/strategy-map/MapNodeDetail.vue" import MapCompleteness from "../components/strategy-map/MapCompleteness.vue" import ConnectionLines from "../components/strategy/ConnectionLines.vue" +// P3:连线端点解析键(`层-序号`)—— 目标改名后线仍指向同一节点 +import { buildKeyIndex, type ConnectionKeyEntry } from "../utils/connKeys" import MapCanvasToolbar from "../components/map-canvas/MapCanvasToolbar.vue" import MapDimensionFilter from "../components/map-canvas/MapDimensionFilter.vue" import CaliberBadge from "../components/CaliberBadge.vue" @@ -403,6 +406,8 @@ const undoStack = ref<{ dimensions: any; connections: any[] }[]>([]) // 连线 const connections = ref([]) +/** P3:服务端解析键(与 connections 逐下标对齐);空 ⇒ 回落原标识串(历史/降级) */ +const connKeys = ref([]) const linkingFrom = ref<{ key: string; obj: any } | null>(null) const tempLine = ref<{ x1: number; y1: number; x2: number; y2: number } | null>(null) const dragConnectTarget = ref(null) @@ -797,6 +802,22 @@ watch(zoomLevel, () => { nextTick(() => connectionLinesRef.value?.recalcAll()) }) +// ── P3:服务端解析键 ── +// 为什么必须取服务端:canvas_data 里存的是写入时刻的标识串(`层:中文名`), +// 目标改名后名字串失效;服务端能由 from/to_objective_id 反查当前 `${层}-${序号}`。 +// 取不到(接口异常/离线)⇒ 置空,画布回落原标识串,行为与改造前一致(不更坏)。 +async function refreshConnKeys() { + const id = selectedMap.value + if (!id) { connKeys.value = []; return } + try { + const r: any = await api.get(`/maps/${id}/connections`) + const serverConns = r?.data?.connections || [] + connKeys.value = buildKeyIndex(loadedCanvas || connections.value || [], serverConns) + } catch (e) { + connKeys.value = [] + } +} + // ── 画布加载 ── function loadCanvas() { caliberFacts.value = caliberGapFor(currentCaliberEntityId()) @@ -842,6 +863,7 @@ function loadCanvas() { loadedDims = deepClone(dimensions) connections.value = map?.canvas_data?.connections || [] loading.value = false + refreshConnKeys() nextTick(() => { triggerRecalc() updateGridMode() @@ -982,6 +1004,7 @@ function undoLast() { connections.value = JSON.parse(JSON.stringify(snap.connections)) selectedNode.value = null markDirty() + refreshConnKeys() // P3:撤销可能改动目标顺序/名字,解析键随之刷新 nextTick(() => { triggerRecalc(); updateZoomWrapHeight(); loadObjectiveLevels() }) ElMessage.success('已撤销上一步') } @@ -1716,6 +1739,9 @@ async function saveCanvas(silent?: boolean) { if (res?.updated_at) baseUpdatedAt.value = res.updated_at // 采纳服务端新基准 refreshBaselines(res?.dimensions) // 基线回到服务端现值(响应体为权威) markClean() + // P3:保存后刷新解析键(改名/调序都会让 `${层}-${序号}` 变,线要跟着节点走) + if (payload.canvas_data) loadedCanvas = deepClone(connections.value) + refreshConnKeys() if (!silent) ElMessage.success("已保存") } catch (e: any) { if (e?.response?.status === 409) { @@ -1742,6 +1768,8 @@ async function saveConnections() { base_updated_at: currentBaseStamp(), }) if (res?.updated_at) baseUpdatedAt.value = res.updated_at + loadedCanvas = deepClone(connections.value) + refreshConnKeys() // P3:连线新增/删除后服务端重算解析键 } catch (e: any) { if (e?.response?.status === 409) { autoSaveBlocked.value = true