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"