feat(maps): P1 四层因果结构化 map_connections 表 + 迁移脚本 + analysis 只读端点

- 新表 map_connections(entity_id/map_id/from_node/to_node/from_layer/to_layer/
  from_objective_id/to_objective_id/effect/label/sort_order),唯一键 (map_id,from,to) 保幂等
- 节点双格式兼容:层:中文目标名(生产存量 31 条)与 层-序号(前端现用)
- canvas_data 保留不动(双写期);POST/DELETE 写表 + 同步 JSON,失败仅告警不阻断
- GET /connections 表优先、表空回落 canvas_data;新增 GET /connections/analysis
  (层统计/跨层分布/断链/反向告警不拦截)
- scripts/migrate_map_connections.py 四态(check/apply/idem/rollback)+ canvas 快照回滚点
- tests: 9 条(双格式解析、双写、回落、删除同步、分析统计与告警)
This commit is contained in:
wecom-fullstack
2026-10-22 05:19:45 +08:00
committed by Hermes CI Fix
parent 0a6525edab
commit adcd118498
4 changed files with 1005 additions and 10 deletions
+284 -10
View File
@@ -7,12 +7,13 @@ 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.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__)
@@ -507,7 +508,165 @@ def batch_delete_maps(data: dict, db: Session = Depends(get_db), entity_id: int
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:
@@ -522,9 +681,122 @@ def _get_connections(m: StrategicMap) -> list:
return m.canvas_data["connections"]
@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(兼容期)。"""
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:
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],
}
conns = _get_connections(m)
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)],
}
@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
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_objective_id"):
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"):
broken.append({"from": it["from"], "to": it["to"], "side": "to",
"node": it["to"], "reason": parse_node_ref(it["to"]).get("error") 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,
}
@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"}"""
"""新增因果连线: {"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, "战略地图不存在")
@@ -539,10 +811,6 @@ def add_connection(map_id: int, data: dict, db: Session = Depends(get_db), entit
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)
# 校验: 不能重复
@@ -560,14 +828,19 @@ def add_connection(map_id: int, data: dict, db: Session = Depends(get_db), entit
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)}
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 删除连线"""
"""根据 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, "战略地图不存在")
@@ -586,9 +859,10 @@ def delete_connection_by_key(map_id: int, data: dict, db: Session = Depends(get_
# 否则接口返回 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)}
"updated_at": _stamp(m.updated_at), "db_synced": db_synced}
# ── 版本管理 ─────────────────────────────────
+31
View File
@@ -364,6 +364,37 @@ class MapObjective(Base):
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
class MapConnection(Base):
"""战略地图四层因果连线(2026-10-21 P1:canvas_data.connections → 独立表)
迁移来源 = `strategic_maps.canvas_data->connections`,双写期表与 JSON 并存
(JSON 保留供前端渲染;表供归因/校验/分析)。
节点标识双兼容(生产实况两种格式并存):
- `dim:中文目标名`(生产存量实际格式,如 `learning:贡献率思维全员培训`)
- `dim-序号`(当前前端 MapCanvas 生成的格式,如 `learning-0`)
原始串原样保存在 from_node/to_node,解析结果落 from_layer/to_layer/from_objective_id。
"""
__tablename__ = "map_connections"
__table_args__ = (
UniqueConstraint("map_id", "from_node", "to_node", name="uk_map_conn_edge"),
)
id = Column(Integer, primary_key=True, index=True)
entity_id = Column(Integer, default=1, index=True, comment="企业ID(冗余,便于按账套直查)")
map_id = Column(Integer, ForeignKey("strategic_maps.id", ondelete="CASCADE"), nullable=False, index=True, comment="关联战略地图")
from_node = Column(String(255), nullable=False, comment="源节点原始标识(原样保留)")
to_node = Column(String(255), nullable=False, comment="目标节点原始标识(原样保留)")
from_layer = Column(String(20), nullable=True, comment="解析层: learning/process/customer/finance")
to_layer = Column(String(20), nullable=True, comment="解析层: learning/process/customer/finance")
from_objective_id = Column(Integer, ForeignKey("map_objectives.id", ondelete="SET NULL"), nullable=True, comment="源目标ID(可解析时回填)")
to_objective_id = Column(Integer, ForeignKey("map_objectives.id", ondelete="SET NULL"), nullable=True, comment="目标目标ID(可解析时回填)")
effect = Column(String(20), default="positive", comment="positive/negative(存量缺省 positive)")
label = Column(String(255), nullable=True, comment="连线标签")
sort_order = Column(Integer, default=0, comment="画布内原始顺序(保持渲染一致)")
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
class KPICausality(Base):
"""KPI因果链 — 记录KPI间的因果关系"""
__tablename__ = "kpi_causality"
+524
View File
@@ -0,0 +1,524 @@
#!/usr/bin/env python3
"""canvas_data.connections → map_connections 迁移(P1 四层因果结构化)
任务:cma-map-connections-table-p1 / project-20261021-cma-map-connections-table-p1-fullstack
设计要点
--------
1. **只搬不删**:canvas_data 原样保留(双写期,前端渲染仍读 JSON)。本脚本对
strategic_maps 只做 SELECT,任何情况下不 UPDATE。
2. **幂等**:目标表唯一键 (map_id, from_node, to_node)。重跑 `--apply` 只补缺,
已存在的行 SKIP;`--idem` 为只读等价复跑(断言全部已在位),零写。
3. **双格式兼容**(生产实况):
`dim:中文目标名` → 按 (dimension_key, 目标名) 解析 map_objectives.id
`dim-序号` → 按 dimensions[dim].objectives 顺序解析(缺 dimensions 时退 sort_order)
原始串原样落 from_node/to_node,解析结果落 *_layer / *_objective_id。
4. **四态**:--check(默认拒绝无标志调用)/ --apply / --idem / --rollback
写库通道:生产 apply/rollback = root over unix socket(cma_api 无新表 DML 授权);
--check 走环境变量只读账号(cma_reader/cma_api 的 SELECT)。
用法
----
python3 migrate_map_connections.py --check [--json-out PATH]
python3 migrate_map_connections.py --show-create
python3 migrate_map_connections.py --apply [--backup-dir DIR] [--evidence PATH]
python3 migrate_map_connections.py --idem
python3 migrate_map_connections.py --rollback <apply-evidence.json>
退出码:0 成功 | 2 用法/缺标志 | 3 表不存在(先走 db_migrate.py --apply)| 4 画布 JSON 不可解析(fail-closed)| 5 自检不一致
"""
import argparse
import hashlib
import json
import os
import re
import sys
from datetime import datetime
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.dirname(HERE))
LAYERS = ("learning", "process", "customer", "finance")
LAYER_ORDER = {k: i for i, k in enumerate(LAYERS)}
NODE_COLON = re.compile(r"^([a-z_]+):(.+)$")
NODE_INDEX = re.compile(r"^([a-z_]+)-(\d+)$")
# ── 连接(凭据只从 env 读,不回显)────────────────────────────────
def _mask(url: str) -> str:
return re.sub(r"://([^:/@]+):[^@]*@", r"://\1:***@", url)
def build_engine(db_url: str | None, mode: str):
from sqlalchemy import create_engine
if db_url:
url = db_url
elif mode == "check" or mode == "show-create":
user = os.getenv("CMA_DB_USER", "cma_reader")
pwd = os.getenv("CMA_DB_PASS", "")
host = os.getenv("CMA_DB_HOST", "127.0.0.1")
port = os.getenv("CMA_DB_PORT", "3306")
name = os.getenv("CMA_DB_NAME", "cma")
if not pwd:
print("[fail] 缺少 CMA_DB_PASS(只读预检需要 SELECT 账号凭据)", file=sys.stderr)
sys.exit(2)
url = f"mysql+pymysql://{user}:{pwd}@{host}:{port}/{name}?charset=utf8mb4"
else:
name = os.getenv("CMA_DB_NAME", "cma")
url = f"mysql+pymysql://root@localhost/{name}?unix_socket=/var/run/mysqld/mysqld.sock&charset=utf8mb4"
return create_engine(url, future=True), url
def load_dotenv():
try:
from dotenv import load_dotenv as _ld
_ld(os.path.join(os.path.dirname(HERE), ".env"))
except Exception:
pass
# ── JSON 规范化 ────────────────────────────────────────────────
def as_json(v):
"""DB JSON 列可能是 dict/list 或 str(SQLite/部分驱动)。"""
if isinstance(v, (bytes, bytearray)):
v = v.decode("utf-8")
if isinstance(v, str):
return json.loads(v)
return v
def canvas_md5(canvas) -> str:
raw = json.dumps(canvas, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
return hashlib.md5(raw.encode("utf-8")).hexdigest()
def norm_name(s: str) -> str:
return re.sub(r"\s+", " ", (s or "").strip())
# ── 节点解析 ───────────────────────────────────────────────────
def parse_node(node):
"""→ (layer|None, ref|None, err|None)
ref = {"kind": "name", "name": ...} | {"kind": "index", "index": n}
"""
if not isinstance(node, str) or not node.strip():
return None, None, "node_empty"
s = node.strip()
m = NODE_COLON.match(s)
if m:
layer = m.group(1)
name = m.group(2).strip()
if layer not in LAYERS:
return None, None, f"unknown_layer:{layer}"
if not name:
return None, None, "node_name_empty"
return layer, {"kind": "name", "name": name}, None
m = NODE_INDEX.match(s)
if m:
layer = m.group(1)
if layer not in LAYERS:
return None, None, f"unknown_layer:{layer}"
return layer, {"kind": "index", "index": int(m.group(2))}, None
return None, None, "node_format_unrecognized"
class ObjectiveIndex:
"""按 map 建索引:名字 → objective_id;层 + 序号 → objective_id。"""
def __init__(self, dims_by_map, objs_by_map):
self.by_name = {}
self.by_order = {}
self.dims = dims_by_map
for map_id, objs in objs_by_map.items():
name_idx, order_idx = {}, {}
for o in objs:
name_idx[(o["dimension_key"], norm_name(o["name"]))] = o["id"]
order_idx.setdefault(o["dimension_key"], []).append(o)
for k in order_idx:
order_idx[k].sort(key=lambda o: (o.get("sort_order") or 0, o["id"]))
self.by_name[map_id] = name_idx
self.by_order[map_id] = order_idx
def resolve(self, map_id, layer, ref):
"""→ (objective_id|None, note|None)"""
if ref["kind"] == "name":
hit = self.by_name.get(map_id, {}).get((layer, norm_name(ref["name"])))
if hit:
return hit, None
# 仅大小写/空白差异的近似命中:报告但不静默
want = norm_name(ref["name"]).replace(" ", "")
for (lk, ln), oid in self.by_name.get(map_id, {}).items():
if lk == layer and ln.replace(" ", "") == want:
return oid, "normalized_match"
return None, "objective_unresolved"
# index:优先按 dimensions[dim].objectives 顺序(前端 key 就是该数组下标)
idx = ref["index"]
dims = self.dims.get(map_id) or []
for d in dims if isinstance(dims, list) else []:
if d.get("key") == layer:
objs = d.get("objectives") or []
if 0 <= idx < len(objs):
name = norm_name(objs[idx].get("name"))
hit = self.by_name.get(map_id, {}).get((layer, name))
if hit:
return hit, None
return None, "objective_unresolved"
return None, "index_out_of_range"
# dimensions 缺失 → 退 sort_order
cand = self.by_order.get(map_id, {}).get(layer) or []
for o in cand:
if (o.get("sort_order") or 0) == idx:
return o["id"], "resolved_by_sort_order"
if 0 <= idx < len(cand):
return cand[idx]["id"], "resolved_by_position"
return None, "index_out_of_range"
# ── 计划构建(纯读)────────────────────────────────────────────
def load_state(session):
from sqlalchemy import inspect
from app.models import StrategicMap, MapObjective, MapConnection
insp = inspect(session.get_bind())
has_table = insp.has_table(MapConnection.__tablename__)
maps = []
for m in session.query(StrategicMap).order_by(StrategicMap.id).all():
maps.append({
"id": m.id, "entity_id": m.entity_id, "title": m.title, "status": m.status,
"canvas": as_json(m.canvas_data), "dimensions": as_json(m.dimensions),
})
objs = {}
for o in session.query(MapObjective).order_by(MapObjective.map_id, MapObjective.sort_order, MapObjective.id).all():
objs.setdefault(o.map_id, []).append({
"id": o.id, "dimension_key": o.dimension_key, "name": o.name, "sort_order": o.sort_order,
})
existing = {}
if has_table:
for c in session.query(MapConnection).all():
existing[(c.map_id, c.from_node, c.to_node)] = c.id
return maps, objs, existing, has_table
def build_plan(maps, objs):
oi = ObjectiveIndex({m["id"]: m["dimensions"] for m in maps}, objs)
plan, per_map, anomalies = [], [], []
def note(map_id, idx, kind, node, detail=""):
anomalies.append({"map_id": map_id, "canvas_index": idx, "kind": kind,
"node": node, "detail": detail})
for m in maps:
canvas = m["canvas"]
conns = []
if canvas is None:
canvas = {}
if not isinstance(canvas, dict):
note(m["id"], None, "canvas_not_object", None, f"type={type(canvas).__name__}")
per_map.append({"map_id": m["id"], "entity_id": m["entity_id"], "title": m["title"],
"canvas_connections": None, "plan_rows": 0, "anomalies": 1})
continue
conns = canvas.get("connections") or []
if not isinstance(conns, list):
note(m["id"], None, "connections_not_list", None, f"type={type(conns).__name__}")
conns = []
rows, seen = [], {}
for i, c in enumerate(conns):
if not isinstance(c, dict):
note(m["id"], i, "connection_not_object", None, f"type={type(c).__name__}")
continue
frm, to = c.get("from"), c.get("to")
if not isinstance(frm, str) or not isinstance(to, str) or not frm or not to:
note(m["id"], i, "endpoint_missing", f"{frm!r}->{to!r}", "from/to 缺失或非字符串")
continue
key = (frm, to)
if key in seen:
note(m["id"], i, "duplicate_edge", f"{frm} -> {to}", f"与 index={seen[key]} 重复(唯一键会合并)")
continue
seen[key] = i
fl, fref, ferr = parse_node(frm)
tl, tref, terr = parse_node(to)
if ferr:
note(m["id"], i, ferr, frm, "from 解析失败")
if terr:
note(m["id"], i, terr, to, "to 解析失败")
if frm == to:
note(m["id"], i, "self_loop", frm, "自连")
if fl and tl and LAYER_ORDER[tl] < LAYER_ORDER[fl]:
note(m["id"], i, "reverse_direction", f"{frm} -> {to}",
f"{fl}→{tl} 与 learning→process→customer→finance 相反(告警不拦截)")
f_obj = f_note = t_obj = t_note = None
if fref:
f_obj, f_note = oi.resolve(m["id"], fl, fref)
if f_note and f_note != "normalized_match":
note(m["id"], i, f_note, frm, "from 目标未解析")
if tref:
t_obj, t_note = oi.resolve(m["id"], tl, tref)
if t_note and t_note != "normalized_match":
note(m["id"], i, t_note, to, "to 目标未解析")
rows.append({
"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": i,
})
plan.extend(rows)
per_map.append({
"map_id": m["id"], "entity_id": m["entity_id"], "title": m["title"],
"status": m["status"], "canvas_connections": len(conns), "plan_rows": len(rows),
"anomalies": sum(1 for a in anomalies if a["map_id"] == m["id"]),
})
return plan, per_map, anomalies
def summarize(plan, per_map, anomalies, existing=None):
by_pair, by_layer = {}, {}
for r in plan:
pair = f"{r['from_layer'] or '?'}->{r['to_layer'] or '?'}"
by_pair[pair] = by_pair.get(pair, 0) + 1
for lay in (r["from_layer"], r["to_layer"]):
if lay:
by_layer[lay] = by_layer.get(lay, 0) + 1
existing = existing or {}
to_insert = sum(1 for r in plan if (r["map_id"], r["from_node"], r["to_node"]) not in existing)
return {
"canvas_connections": sum(p["canvas_connections"] or 0 for p in per_map),
"plan_rows": len(plan),
"already_in_table": len([r for r in plan if (r["map_id"], r["from_node"], r["to_node"]) in existing]),
"to_insert": to_insert,
"unresolved_layer": sum(1 for r in plan if not r["from_layer"] or not r["to_layer"]),
"unresolved_objective": sum(1 for r in plan if not r["from_objective_id"] or not r["to_objective_id"]),
"anomalies": len(anomalies),
"layer_pair_distribution": by_pair,
"layer_node_refs": by_layer,
}
# ── 三种模式的实现 ─────────────────────────────────────────────
def cmd_check(engine, args):
from sqlalchemy.orm import Session
with Session(engine) as s:
maps, objs, existing, has_table = load_state(s)
plan, per_map, anomalies = build_plan(maps, objs)
report = {
"generated_at": datetime.now().isoformat(timespec="seconds"),
"mode": "check", "read_only": True,
"db": _mask(engine.url.render_as_string(hide_password=True).replace("***", "***")),
"engine_dialect": engine.dialect.name,
"table_exists": has_table,
"totals": summarize(plan, per_map, anomalies, existing),
"maps": per_map,
"anomalies": anomalies,
"sample_rows": plan[:5],
}
if args.json_out:
os.makedirs(os.path.dirname(os.path.abspath(args.json_out)), exist_ok=True)
with open(args.json_out, "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
print(f"[check] 库={report['db']} 方言={report['engine_dialect']} 目标表存在={has_table}")
t = report["totals"]
print(f"[check] 画布连线={t['canvas_connections']} 计划行={t['plan_rows']} "
f"已在表={t['already_in_table']} 待插入={t['to_insert']}")
print(f"[check] 层未解析={t['unresolved_layer']} 目标未解析={t['unresolved_objective']} 异常条目={t['anomalies']}")
print(f"[check] 层对分布={t['layer_pair_distribution']}")
for p in per_map:
print(f"[check] map {p['map_id']} (entity={p['entity_id']}, {p['status']}): "
f"canvas={p['canvas_connections']} → plan={p['plan_rows']} 异常={p['anomalies']}")
for a in anomalies[:50]:
print(f"[check] ✗ map={a['map_id']} idx={a['canvas_index']} {a['kind']} {a['node']} {a['detail']}")
if not has_table:
print("[check] ⚠ 目标表不存在:建表须先走 db_migrate.py --apply(受批面)")
return 0
def cmd_show_create():
from sqlalchemy.schema import CreateTable
from sqlalchemy.dialects import mysql
from app.models import MapConnection
print(CreateTable(MapConnection.__table__).compile(dialect=mysql.dialect()))
return 0
def cmd_apply(engine, args):
from sqlalchemy.orm import Session
from sqlalchemy.exc import IntegrityError
from app.models import MapConnection
with Session(engine) as s:
maps, objs, existing, has_table = load_state(s)
if not has_table:
print("[fail] map_connections 表不存在 → 先执行(受批)db_migrate.py --apply", file=sys.stderr)
return 3
plan, per_map, anomalies = build_plan(maps, objs)
hard = [a for a in anomalies if a["kind"] in ("canvas_not_object", "connections_not_list", "connection_not_object")]
if hard:
print(f"[fail] 画布结构不可解析 {len(hard)} 处 → fail-closed,未写任何行:"
f"{hard[:3]}", file=sys.stderr)
return 4
# 回滚点:canvas_data 全量快照 + md5(只读 SELECT 得到)
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
backup_dir = os.path.abspath(args.backup_dir or f"/srv/hermes/backups/map_connections_p1/{ts}")
os.makedirs(backup_dir, exist_ok=True)
backup_path = os.path.join(backup_dir, "canvas_data_snapshot.json")
with open(backup_path, "w", encoding="utf-8") as f:
json.dump([{"map_id": m["id"], "entity_id": m["entity_id"], "title": m["title"],
"canvas_data": m["canvas"], "md5": canvas_md5(m["canvas"])} for m in maps],
f, ensure_ascii=False, indent=2)
pre_md5 = {m["id"]: canvas_md5(m["canvas"]) for m in maps}
inserted, skipped, errors = [], 0, []
with Session(engine) as s:
try:
for r in plan:
key = (r["map_id"], r["from_node"], r["to_node"])
if key in existing:
skipped += 1
continue
obj = MapConnection(**r)
s.add(obj)
try:
s.flush()
except IntegrityError:
s.rollback()
skipped += 1
continue
inserted.append({"id": obj.id, "map_id": r["map_id"],
"from_node": r["from_node"], "to_node": r["to_node"]})
s.commit()
except Exception as exc:
s.rollback()
print(f"[fail] 写入失败已回滚:{type(exc).__name__}: {exc}", file=sys.stderr)
return 1
# 写后自检:行数 + canvas 未变
with Session(engine) as s:
maps2, _, _, _ = load_state(s)
table_rows = s.query(MapConnection).filter(
MapConnection.map_id.in_([m["id"] for m in maps])).count() if maps else 0
post_md5 = {m["id"]: canvas_md5(m["canvas"]) for m in maps2}
canvas_unchanged = pre_md5 == post_md5
expected = len({(r["map_id"], r["from_node"], r["to_node"]) for r in plan})
evidence = {
"action": "apply", "status": "ok",
"started_at": ts, "finished_at": datetime.now().isoformat(timespec="seconds"),
"db_engine": engine.dialect.name,
"backup_path": backup_path, "backup_pre_md5": pre_md5, "post_md5": post_md5,
"canvas_unchanged": canvas_unchanged,
"plan_rows": len(plan), "inserted": len(inserted), "skipped_existing": skipped,
"table_rows_for_scope": table_rows, "expected_distinct_edges": expected,
"anomalies": anomalies, "maps": per_map, "inserted_ids": inserted,
}
ok = canvas_unchanged and table_rows == expected and not errors
evidence["verdict"] = "PASS" if ok else "FAIL"
ev_path = os.path.abspath(args.evidence or os.path.join(backup_dir, "apply_evidence.json"))
os.makedirs(os.path.dirname(ev_path), exist_ok=True)
with open(ev_path, "w", encoding="utf-8") as f:
json.dump(evidence, f, ensure_ascii=False, indent=2)
print(f"[apply] 计划 {len(plan)} 行;新插入 {len(inserted)},已存在跳过 {skipped}")
print(f"[apply] 表内本范围行数 {table_rows} / 期望 {expected};canvas 未改={canvas_unchanged}")
print(f"[apply] 备份 {backup_path}")
print(f"[apply] 证据 {ev_path}")
print(f"[apply] verdict={evidence['verdict']}")
return 0 if ok else 5
def cmd_idem(engine, args):
from sqlalchemy.orm import Session
with Session(engine) as s:
maps, objs, existing, has_table = load_state(s)
if not has_table:
print("[fail] 表不存在", file=sys.stderr)
return 3
plan, per_map, anomalies = build_plan(maps, objs)
mismatches = []
for r in plan:
key = (r["map_id"], r["from_node"], r["to_node"])
cid = existing.get(key)
if not cid:
mismatches.append({"kind": "missing", "key": list(key)})
continue
row = s.get(__import__("app.models", fromlist=["MapConnection"]).MapConnection, cid)
for f in ("from_layer", "to_layer", "from_objective_id", "to_objective_id"):
if (getattr(row, f) or None) != (r[f] or None):
mismatches.append({"kind": "value_diff", "key": list(key), "field": f,
"db": getattr(row, f), "plan": r[f]})
print(f"[idem] 计划 {len(plan)} 行,已在位 {len(plan) - len([m for m in mismatches if m['kind'] == 'missing'])};"
f"差异 {len(mismatches)}")
for m in mismatches[:20]:
print(f"[idem] ✗ {m}")
print(f"[idem] verdict={'PASS' if not mismatches else 'FAIL'}")
return 0 if not mismatches else 5
def cmd_rollback(engine, args):
from sqlalchemy.orm import Session
from app.models import MapConnection
path = args.rollback
if not path or not os.path.exists(path):
print(f"[fail] 回滚证据不存在:{path}", file=sys.stderr)
return 2
with open(path, encoding="utf-8") as f:
ev = json.load(f)
ids = [x["id"] for x in ev.get("inserted_ids", [])]
if not ids:
print("[rollback] 证据里没有 inserted_ids,无需回滚")
return 0
with Session(engine) as s:
n = s.query(MapConnection).filter(MapConnection.id.in_(ids)).delete(synchronize_session=False)
s.commit()
left = s.query(MapConnection).filter(MapConnection.id.in_(ids)).count()
print(f"[rollback] 删除 {n} 行,残留 {left}(证据 {path});canvas_data 本脚本从未写入,无需还原")
return 0 if left == 0 else 5
def main():
ap = argparse.ArgumentParser(description="canvas_data.connections → map_connections(四态幂等迁移)")
g = ap.add_mutually_exclusive_group()
g.add_argument("--check", action="store_true", help="只读预检(dry-run):行数/异常/待插入")
g.add_argument("--apply", action="store_true", help="唯一写:幂等插入(需表已存在)")
g.add_argument("--idem", action="store_true", help="只读幂等复跑:断言全部已在位")
g.add_argument("--rollback", metavar="EVIDENCE_JSON", help="按 apply 证据删除本批插入行")
g.add_argument("--show-create", action="store_true", help="打印模型编译出的 CREATE TABLE")
ap.add_argument("--db-url", help="覆盖连接串(演练副本/SQLite 用)")
ap.add_argument("--json-out", help="--check 的机器可读报告路径")
ap.add_argument("--backup-dir", help="--apply 回滚点目录")
ap.add_argument("--evidence", help="--apply 证据 JSON 路径")
args = ap.parse_args()
load_dotenv()
if args.show_create:
return cmd_show_create()
if not any([args.check, args.apply, args.idem, args.rollback]):
print("拒绝无标志调用:请显式给 --check / --apply / --idem / --rollback", file=sys.stderr)
return 2
mode = "check" if args.check else "apply" if args.apply else "idem" if args.idem else "rollback"
engine, url = build_engine(args.db_url, mode)
print(f"[{mode}] 通道 {_mask(url)}")
if args.check:
return cmd_check(engine, args)
if args.apply:
return cmd_apply(engine, args)
if args.idem:
return cmd_idem(engine, args)
return cmd_rollback(engine, args)
if __name__ == "__main__":
sys.exit(main())
+166
View File
@@ -0,0 +1,166 @@
"""P1 四层因果结构化:map_connections 表 + /connections 读/写/analysis 回归
任务 project-20261021-cma-map-connections-table-p1-fullstack
覆盖:双格式解析(层:中文名 / 层-序号)、双写、读表回落 canvas、分析与告警不拦截。
"""
import pytest
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
from app.models import StrategicMap, MapObjective, MapConnection
from app.api.maps import parse_node_ref, resolve_node
from tests.conftest import create_test_user, get_token_for_user, auth_header
DIMS = [
{"key": "finance", "name": "财务层", "icon": "💰", "color": "#F56C6C",
"objectives": [{"name": "营业收入"}, {"name": "营业利润"}]},
{"key": "customer", "name": "客户层", "icon": "👥", "color": "#409EFF",
"objectives": [{"name": "客户留存"}]},
{"key": "process", "name": "流程层", "icon": "⚙️", "color": "#67C23A",
"objectives": [{"name": "交付效率"}, {"name": "质量闭环"}]},
{"key": "learning", "name": "学习层", "icon": "📚", "color": "#E6A23C",
"objectives": [{"name": "全员培训"}, {"name": "能力认证"}]},
]
def _seed_map(db: Session, title="P1连线测试", canvas=None) -> StrategicMap:
m = StrategicMap(entity_id=1, title=title, status="draft", dimensions=DIMS,
canvas_data=canvas or {"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
@pytest.fixture(autouse=True)
def _clean(db: Session):
db.query(MapConnection).delete()
db.query(MapObjective).filter(MapObjective.map_id >= 900000).delete()
db.query(StrategicMap).filter(StrategicMap.id >= 900000).delete()
db.commit()
yield
class TestNodeParse:
def test_colon_format(self):
r = parse_node_ref("learning:全员培训")
assert (r["layer"], r["kind"], r["value"], r["error"]) == ("learning", "name", "全员培训", None)
def test_index_format(self):
r = parse_node_ref("process-1")
assert (r["layer"], r["kind"], r["value"], r["error"]) == ("process", "index", 1, None)
def test_unknown_formats(self):
assert parse_node_ref("nonsense")["error"] == "node_format_unrecognized"
assert parse_node_ref("hr:某目标")["error"] == "unknown_layer:hr"
assert parse_node_ref("")["error"] == "node_empty"
def test_resolve_both_formats(self, db: Session):
m = _seed_map(db)
# 冒号中文名
layer, oid, note = resolve_node(db, m, "learning:能力认证")
assert (layer, note) == ("learning", None) and oid is not None
# 序号(dimensions 数组下标)
layer, oid2, note = resolve_node(db, m, "process-1")
assert (layer, note) == ("process", None)
obj = db.query(MapObjective).filter(MapObjective.id == oid2).first()
assert obj.name == "质量闭环"
class TestConnectionApi:
def test_post_dual_write_and_get_table(self, client: TestClient, db: Session):
create_test_user(db)
token = get_token_for_user(client)
m = _seed_map(db, title="P1双写")
mid = m.id
resp = client.post(f"/api/cma/maps/{mid}/connections", headers=auth_header(token),
json={"from": "learning:全员培训", "to": "process:交付效率"})
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["db_synced"] is True
assert body["connections"][0]["from"] == "learning:全员培训"
# canvas_data 保留(前端渲染主路径)
db.expire_all()
m2 = db.query(StrategicMap).filter(StrategicMap.id == mid).first()
assert m2.canvas_data["connections"][0]["to"] == "process:交付效率"
# 表内落行且层/目标解析正确
row = db.query(MapConnection).filter(MapConnection.map_id == mid).one()
assert (row.from_layer, row.to_layer) == ("learning", "process")
assert row.from_objective_id and row.to_objective_id
got = client.get(f"/api/cma/maps/{mid}/connections", headers=auth_header(token))
assert got.status_code == 200
assert got.json()["source"] == "table"
assert got.json()["total"] == 1
def test_index_format_persisted(self, client: TestClient, db: Session):
create_test_user(db)
token = get_token_for_user(client)
m = _seed_map(db, title="P1序号格式")
resp = client.post(f"/api/cma/maps/{m.id}/connections", headers=auth_header(token),
json={"from": "learning-0", "to": "process-1"})
assert resp.status_code == 200, resp.text
row = db.query(MapConnection).filter(MapConnection.map_id == m.id).one()
assert (row.from_node, row.from_layer) == ("learning-0", "learning")
assert row.from_objective_id and row.to_objective_id
def test_get_falls_back_to_canvas(self, client: TestClient, db: Session):
create_test_user(db)
token = get_token_for_user(client)
m = _seed_map(db, title="P1回落",
canvas={"connections": [{"from": "learning:全员培训", "to": "process:质量闭环"}]})
got = client.get(f"/api/cma/maps/{m.id}/connections", headers=auth_header(token))
assert got.status_code == 200
assert got.json()["source"] == "canvas_data"
assert got.json()["total"] == 1
def test_delete_removes_both(self, client: TestClient, db: Session):
create_test_user(db)
token = get_token_for_user(client)
m = _seed_map(db, title="P1删除")
client.post(f"/api/cma/maps/{m.id}/connections", headers=auth_header(token),
json={"from": "learning:全员培训", "to": "process:交付效率"})
resp = client.request("DELETE", f"/api/cma/maps/{m.id}/connections", headers=auth_header(token),
json={"from": "learning:全员培训", "to": "process:交付效率"})
assert resp.status_code == 200, resp.text
assert resp.json()["db_synced"] is True
assert db.query(MapConnection).filter(MapConnection.map_id == m.id).count() == 0
db.expire_all()
m2 = db.query(StrategicMap).filter(StrategicMap.id == m.id).first()
assert m2.canvas_data["connections"] == []
def test_analysis_stats_reverse_and_broken(self, client: TestClient, db: Session):
create_test_user(db)
token = get_token_for_user(client)
m = _seed_map(db, title="P1分析")
pairs = [
("learning:全员培训", "process:交付效率"), # 正常
("process:质量闭环", "customer:客户留存"), # 正常
("customer:客户留存", "finance:营业收入"), # 正常
("finance:营业利润", "customer:客户留存"), # 反向(告警不拦截)
("finance:营业收入", "finance:营业利润"), # 同层
("learning:不存在目标", "process:交付效率"), # 断链(照样可写)
]
for f, t in pairs:
r = client.post(f"/api/cma/maps/{m.id}/connections", headers=auth_header(token),
json={"from": f, "to": t})
assert r.status_code == 200, r.text
a = client.get(f"/api/cma/maps/{m.id}/connections/analysis", headers=auth_header(token))
assert a.status_code == 200, a.text
d = a.json()
assert d["source"] == "table" and d["total"] == 6
assert d["layer_stats"]["learning"]["as_from"] == 2
assert d["layer_stats"]["finance"]["as_to"] == 2 # customer→finance 1 + finance→finance 1
assert d["cross_layer"]["learning->process"] == 2 # 正常 1 + 断链 1(层可解析、目标不可解析)
assert d["same_layer_count"] == 1
assert len(d["reversed"]) == 1 and d["reversed"][0]["from_layer"] == "finance"
assert any(b["side"] == "from" for b in d["broken"])
assert any("反向连线" in w for w in d["warnings"])