fix(maps): 画布因果连线零绘制(节点双键注册) + 战略回顾 total_hold 未初始化 500
ci/woodpecker/push/main Pipeline was successful

- StrategyLayer.vue: setNodeRef 双键注册(渲染键 layer-index 与数据键 layer:目标名),$el 归一化为 DOM,卸载时清理数据键
- maps.py: total_hold 初始化 0 + summary.hold 透出(修复 review?level=strategic 500 UnboundLocalError)
- tests: 新增 test_maps_review_hold_20261022.py(待核/普通/空/NULL/无level 五例,负对照已验证会失败)

任务: project-20261022-cma-map-canvas-connections-not-drawn-fullstack
阶段A候选件;未 push、未部署(等阶段B放行)
This commit is contained in:
wecom-fullstack
2026-10-22 16:50:10 +08:00
committed by Hermes CI Fix
parent 4cad2b73c4
commit 986dec516e
3 changed files with 175 additions and 3 deletions
+2
View File
@@ -1305,6 +1305,7 @@ def get_map_review(map_id: int, level: Optional[str] = None, db: Session = Depen
total_ok = 0
total_warn = 0
total_err = 0
total_hold = 0 # C1 数据待核计数(此前漏初始化 → 命中 hold_reason 即 UnboundLocalError → review 500)
total_obj_count = 0
focus_items = []
@@ -1444,6 +1445,7 @@ def get_map_review(map_id: int, level: Optional[str] = None, db: Session = Depen
"green": total_ok,
"yellow": total_warn,
"red": total_err,
"hold": total_hold,
"health_score": round(total_ok / total_obj_count * 100, 1) if total_obj_count > 0 else 0,
},
"focus_items": focus_items,
@@ -0,0 +1,137 @@
"""P2 回归:GET /api/cma/maps/{id}/review?level=strategic 不再 500
任务 project-20261022-cma-map-canvas-connections-not-drawn-fullstack(缺陷2)
根因:maps.py 的 total_hold 只在「目标含 DATA_HOLD_KPIS」分支被 += 累加、从未初始化,
首个待核目标即 UnboundLocalError → 500(线上 map65 栈顶实测)。
修复:初始化 total_hold = 0,并在 summary.hold 透出计数(纯增量,消费方 green/yellow/red/health_score 不受影响)。
覆盖:
① 含待核 KPI 的地图 → 200,summary.hold=1,该目标 data_hold=True 且 actual 不外泄
② 普通地图 → 200,summary.hold=0
③ dimensions 缺失(None)/ 空数组 → 200,不抛(total=0,health_score=0)
"""
import pytest
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
from app.models import StrategicMap, MapObjective, KPIDefinition, KPIValue
from app.api.maps import DATA_HOLD_KPIS
from tests.conftest import create_test_user, get_token_for_user, auth_header, create_test_kpi
HOLD_CODE = "C_CUST_CONCENTRATION"
PLAIN_CODE = "TEST_REVIEW_PLAIN"
def _seed_review_map(db: Session, dims, title="P2 review 回归", map_id=None) -> StrategicMap:
kwargs = dict(entity_id=1, title=title, status="active", dimensions=dims,
canvas_data={"connections": []})
if map_id is not None:
kwargs["id"] = map_id
m = StrategicMap(**kwargs)
db.add(m)
db.commit()
db.refresh(m)
return m
@pytest.fixture(autouse=True)
def _clean(db: Session):
yield
db.rollback()
db.query(KPIValue).filter(KPIValue.kpi_id.in_(
[k.id for k in db.query(KPIDefinition).filter(
KPIDefinition.kpi_code.in_([HOLD_CODE, PLAIN_CODE])).all()]
)).delete(synchronize_session=False)
db.query(KPIDefinition).filter(KPIDefinition.kpi_code.in_([HOLD_CODE, PLAIN_CODE])).delete(
synchronize_session=False)
db.query(MapObjective).filter(MapObjective.map_id >= 900000).delete(synchronize_session=False)
db.query(StrategicMap).filter(StrategicMap.id >= 900000).delete(synchronize_session=False)
db.commit()
def _hold_dims():
# 一个待核目标(含 C_CUST_CONCENTRATION)+ 一个普通目标
return [{
"key": "customer", "name": "客户层", "icon": "👥", "color": "#409EFF",
"objectives": [
{"name": "客户集中度", "kpis": [HOLD_CODE]},
{"name": "客户满意", "kpis": [PLAIN_CODE]},
],
}]
def _seed_kpis(db: Session):
hold = create_test_kpi(db, kpi_code=HOLD_CODE, kpi_name="客户集中度", dimension="customer",
target_value=60.0, kpi_level="strategic", unit="%")
plain = create_test_kpi(db, kpi_code=PLAIN_CODE, kpi_name="客户满意", dimension="customer",
target_value=90.0, kpi_level="strategic", unit="分")
return hold, plain
class TestReviewNo500:
def test_data_hold_kpi_returns_200_and_counts_hold(self, client: TestClient, db: Session):
"""含待核 KPI:修复前此处 UnboundLocalError → 500;修复后 200 且 summary.hold=1。"""
create_test_user(db)
token = get_token_for_user(client)
hold_kpi, plain_kpi = _seed_kpis(db)
db.add(KPIValue(kpi_id=hold_kpi.id, period="2026-09", actual_value=72.0, source_type="actual"))
db.add(KPIValue(kpi_id=plain_kpi.id, period="2026-09", actual_value=93.0, source_type="actual"))
db.commit()
m = _seed_review_map(db, _hold_dims(), map_id=900101)
resp = client.get(f"/api/cma/maps/{m.id}/review", params={"level": "strategic"},
headers=auth_header(token))
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["map_id"] == m.id
s = body["summary"]
assert s["hold"] == 1, s
assert s["total"] == 2, s
assert s["green"] == 1, s # 待核目标不进任何档
assert isinstance(s["health_score"], (int, float))
objs = {o["name"]: o for o in body["dimensions"][0]["objectives"]}
held = objs["客户集中度"]
assert held["data_hold"] is True and held["hold_reason"]
assert held["level"] == "pending"
# 数据前置:待核目标的实测值不得外泄
assert all(k["actual_value"] is None for k in held["kpis"])
assert objs["客户满意"]["data_hold"] is False
def test_plain_map_returns_200_hold_zero(self, client: TestClient, db: Session):
create_test_user(db)
token = get_token_for_user(client)
_seed_kpis(db)
m = _seed_review_map(db, _hold_dims(), title="P2 普通地图", map_id=900102)
# 去掉待核目标,只留普通目标
m.dimensions = [{"key": "customer", "name": "客户层", "icon": "", "color": "#409EFF",
"objectives": [{"name": "客户满意", "kpis": [PLAIN_CODE]}]}]
db.commit()
resp = client.get(f"/api/cma/maps/{m.id}/review", params={"level": "strategic"},
headers=auth_header(token))
assert resp.status_code == 200, resp.text
assert resp.json()["summary"]["hold"] == 0
@pytest.mark.parametrize("dims", [None, []])
def test_empty_dimensions_returns_200(self, client: TestClient, db: Session, dims):
create_test_user(db)
token = get_token_for_user(client)
m = _seed_review_map(db, dims, title="P2 空地图", map_id=900103)
resp = client.get(f"/api/cma/maps/{m.id}/review", params={"level": "strategic"},
headers=auth_header(token))
assert resp.status_code == 200, resp.text
s = resp.json()["summary"]
assert s["total"] == 0 and s["hold"] == 0 and s["health_score"] == 0
def test_level_none_still_200(self, client: TestClient, db: Session):
"""不传 level(向后兼容路径)同样不得 500。"""
create_test_user(db)
token = get_token_for_user(client)
hold_kpi, _ = _seed_kpis(db)
db.add(KPIValue(kpi_id=hold_kpi.id, period="2026-09", actual_value=72.0, source_type="actual"))
db.commit()
m = _seed_review_map(db, _hold_dims(), title="P2 无 level", map_id=900104)
resp = client.get(f"/api/cma/maps/{m.id}/review", headers=auth_header(token))
assert resp.status_code == 200, resp.text
assert resp.json()["summary"]["hold"] == 1