fix(maps): 画布因果连线零绘制(节点双键注册) + 战略回顾 total_hold 未初始化 500
ci/woodpecker/push/main Pipeline was successful
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:
committed by
Hermes CI Fix
parent
4cad2b73c4
commit
986dec516e
@@ -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
|
||||
@@ -42,7 +42,7 @@
|
||||
<div class="layer-node-wrapper">
|
||||
<MapNode
|
||||
:key="`${layerKey}-${index}`"
|
||||
:ref="el => setNodeRef(`${layerKey}-${index}`, el)"
|
||||
:ref="el => setNodeRef(`${layerKey}-${index}`, el, element)"
|
||||
:objective="element"
|
||||
:node-key="`${layerKey}-${index}`"
|
||||
:index="index"
|
||||
@@ -144,8 +144,41 @@ const bodyRef = ref<HTMLElement | null>(null)
|
||||
|
||||
// Node DOM refs for line calculation (reactive so parent can aggregate)
|
||||
const nodeRefs = reactive<Record<string, any>>({})
|
||||
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<string, string> = {} // dataKey → 渲染键(谁注册的)
|
||||
const ownerDataKey: Record<string, string> = {} // 渲染键 → 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
|
||||
|
||||
Reference in New Issue
Block a user