From 986dec516eed58c36d74f1e4a496c2a5f3ae7fec Mon Sep 17 00:00:00 2001 From: wecom-fullstack Date: Thu, 22 Oct 2026 15:20:04 +0800 Subject: [PATCH] =?UTF-8?q?fix(maps):=20=E7=94=BB=E5=B8=83=E5=9B=A0?= =?UTF-8?q?=E6=9E=9C=E8=BF=9E=E7=BA=BF=E9=9B=B6=E7=BB=98=E5=88=B6(?= =?UTF-8?q?=E8=8A=82=E7=82=B9=E5=8F=8C=E9=94=AE=E6=B3=A8=E5=86=8C)=20+=20?= =?UTF-8?q?=E6=88=98=E7=95=A5=E5=9B=9E=E9=A1=BE=20total=5Fhold=20=E6=9C=AA?= =?UTF-8?q?=E5=88=9D=E5=A7=8B=E5=8C=96=20500?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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放行) --- backend/app/api/maps.py | 2 + .../tests/test_maps_review_hold_20261022.py | 137 ++++++++++++++++++ .../components/strategy-map/StrategyLayer.vue | 39 ++++- 3 files changed, 175 insertions(+), 3 deletions(-) create mode 100644 backend/tests/test_maps_review_hold_20261022.py diff --git a/backend/app/api/maps.py b/backend/app/api/maps.py index ee426c9e..35aa0ffb 100644 --- a/backend/app/api/maps.py +++ b/backend/app/api/maps.py @@ -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, diff --git a/backend/tests/test_maps_review_hold_20261022.py b/backend/tests/test_maps_review_hold_20261022.py new file mode 100644 index 00000000..45efcb46 --- /dev/null +++ b/backend/tests/test_maps_review_hold_20261022.py @@ -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 diff --git a/frontend/src/components/strategy-map/StrategyLayer.vue b/frontend/src/components/strategy-map/StrategyLayer.vue index b95c9220..3eaafb82 100644 --- a/frontend/src/components/strategy-map/StrategyLayer.vue +++ b/frontend/src/components/strategy-map/StrategyLayer.vue @@ -42,7 +42,7 @@
(null) // Node DOM refs for line calculation (reactive so parent can aggregate) const nodeRefs = reactive>({}) -function setNodeRef(key: string, el: any) { - if (el) nodeRefs[key] = el +/** + * 双键注册:`${layerKey}-${index}`(渲染键,历史契约)+ `${layerKey}:${目标名}`(数据键)。 + * canvas_data.connections / map_connections 里的节点标识形如 `learning:贡献率思维全员培训`, + * 只注册渲染键会导致 ConnectionLines 全部取不到 DOM → 一条线都不画(P2 缺陷)。 + * 同层同名目标:先到先得(与数据侧 `dim:name` 本身的歧义语义保持一致)。 + * 卸载(el=null)时按 key 反查清理自己注册的数据键,避免残留指向已卸载 DOM(线画到 0,0)。 + */ +const dataKeyOwner: Record = {} // dataKey → 渲染键(谁注册的) +const ownerDataKey: Record = {} // 渲染键 → dataKey +function setNodeRef(key: string, el: any, element?: any) { + // 组件 ref 给的是实例(不是 DOM),ConnectionLines 直接调 getBoundingClientRect + // ⇒ 统一归一化为 DOM 元素($el);取不到 DOM 视同卸载,走清理分支。 + const dom: HTMLElement | null = el + ? (el.nodeType === 1 ? el : (el.$el && el.$el.nodeType === 1 ? el.$el : null)) + : null + if (!dom) { + const dk = ownerDataKey[key] + if (dk && dataKeyOwner[dk] === key) { + delete nodeRefs[dk] + delete dataKeyOwner[dk] + } + delete ownerDataKey[key] + delete nodeRefs[key] + return + } + nodeRefs[key] = dom + const nm = typeof element?.name === 'string' ? element.name : '' + if (nm) { + const dataKey = `${props.layerKey}:${nm}` + if (!nodeRefs[dataKey]) { + nodeRefs[dataKey] = dom + dataKeyOwner[dataKey] = key + ownerDataKey[key] = dataKey + } + } } // Expose nodeRefs to parent for connection line calculation