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:
committed by
Hermes CI Fix
parent
0a6525edab
commit
adcd118498
+284
-10
@@ -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}
|
||||
|
||||
|
||||
# ── 版本管理 ─────────────────────────────────
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user