Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b736e0727a |
@@ -2,6 +2,7 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy.orm.attributes import flag_modified
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from datetime import date, datetime, timedelta
|
from datetime import date, datetime, timedelta
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
@@ -558,6 +559,8 @@ def add_connection(map_id: int, data: dict, db: Session = Depends(get_db), entit
|
|||||||
"label": data.get("label", ""),
|
"label": data.get("label", ""),
|
||||||
})
|
})
|
||||||
m.canvas_data["connections"] = conns
|
m.canvas_data["connections"] = conns
|
||||||
|
# D2(2026-10-20):裸 JSON 列不做就地变更跟踪;显式标脏,与模型层 MutableDict 双保险
|
||||||
|
flag_modified(m, "canvas_data")
|
||||||
db.commit()
|
db.commit()
|
||||||
# updated_at: 供前端采纳为新基准(并发保护 base_updated_at),避免自伤 409
|
# updated_at: 供前端采纳为新基准(并发保护 base_updated_at),避免自伤 409
|
||||||
return {"connections": conns, "updated_at": _stamp(m.updated_at)}
|
return {"connections": conns, "updated_at": _stamp(m.updated_at)}
|
||||||
@@ -580,6 +583,8 @@ def delete_connection_by_key(map_id: int, data: dict, db: Session = Depends(get_
|
|||||||
raise HTTPException(404, "连线不存在")
|
raise HTTPException(404, "连线不存在")
|
||||||
|
|
||||||
m.canvas_data["connections"] = new_conns
|
m.canvas_data["connections"] = new_conns
|
||||||
|
# D2(2026-10-20):换新列表赋值同样不被裸 JSON 列跟踪 ⇒ 显式标脏(根因修复点)
|
||||||
|
flag_modified(m, "canvas_data")
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"connections": new_conns, "removed": {"from": from_id, "to": to_id},
|
return {"connections": new_conns, "removed": {"from": from_id, "to": to_id},
|
||||||
"updated_at": _stamp(m.updated_at)}
|
"updated_at": _stamp(m.updated_at)}
|
||||||
|
|||||||
+5
-2
@@ -41,6 +41,11 @@ app.include_router(kpis.router)
|
|||||||
app.include_router(kpi_governance.router)
|
app.include_router(kpi_governance.router)
|
||||||
app.include_router(templates.router)
|
app.include_router(templates.router)
|
||||||
app.include_router(maps.router)
|
app.include_router(maps.router)
|
||||||
|
# 顺序铁律(2026-10-20 D1 修复):同前缀族的路由必须把**字面路径段**路由器 include 在
|
||||||
|
# 任何 `/{map_id}/<literal>` 参数化路由(objectives.router 的 GET /{map_id}/objectives)之前,
|
||||||
|
# 否则 /api/cma/maps/completeness/objectives 会被判 map_id="completeness" → 恒 422。
|
||||||
|
app.include_router(map_completeness.router) # 战略地图数据完备度(口径 CMA-CALIBER-MAP-COMPLETENESS-20261018 v1.0)
|
||||||
|
app.include_router(map_summary.router) # 战略地图摘要条(收入分线/新式毛利率/管理指标 · 口径 CMA-A1 终稿)
|
||||||
app.include_router(dashboard.router)
|
app.include_router(dashboard.router)
|
||||||
app.include_router(data.router)
|
app.include_router(data.router)
|
||||||
app.include_router(alerts.router)
|
app.include_router(alerts.router)
|
||||||
@@ -56,8 +61,6 @@ app.include_router(action_plans.router)
|
|||||||
app.include_router(alignment.router)
|
app.include_router(alignment.router)
|
||||||
app.include_router(org.router)
|
app.include_router(org.router)
|
||||||
app.include_router(objectives.router)
|
app.include_router(objectives.router)
|
||||||
app.include_router(map_completeness.router) # 战略地图数据完备度(口径 CMA-CALIBER-MAP-COMPLETENESS-20261018 v1.0)
|
|
||||||
app.include_router(map_summary.router) # 战略地图摘要条(收入分线/新式毛利率/管理指标 · 口径 CMA-A1 终稿)
|
|
||||||
app.include_router(versions.router)
|
app.include_router(versions.router)
|
||||||
app.include_router(budget.router)
|
app.include_router(budget.router)
|
||||||
app.include_router(value_sources.router)
|
app.include_router(value_sources.router)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""管理会计OS 数据模型"""
|
"""管理会计OS 数据模型"""
|
||||||
from sqlalchemy import Column, Integer, String, Text, Float, DateTime, ForeignKey, Boolean, JSON, func, text, UniqueConstraint, Index, Numeric, Date
|
from sqlalchemy import Column, Integer, String, Text, Float, DateTime, ForeignKey, Boolean, JSON, func, text, UniqueConstraint, Index, Numeric, Date
|
||||||
|
from sqlalchemy.ext.mutable import MutableDict
|
||||||
from app.database import Base
|
from app.database import Base
|
||||||
|
|
||||||
from app.models.budget_plan import BudgetPlan
|
from app.models.budget_plan import BudgetPlan
|
||||||
@@ -59,7 +60,7 @@ class StrategicMap(Base):
|
|||||||
version = Column(String(20), default="v1.0", comment="版本号")
|
version = Column(String(20), default="v1.0", comment="版本号")
|
||||||
status = Column(String(20), default="draft", comment="draft/published")
|
status = Column(String(20), default="draft", comment="draft/published")
|
||||||
dimensions = Column(JSON, nullable=True, comment="四维度和目标列表")
|
dimensions = Column(JSON, nullable=True, comment="四维度和目标列表")
|
||||||
canvas_data = Column(JSON, nullable=True, comment="画布连线数据")
|
canvas_data = Column(MutableDict.as_mutable(JSON), nullable=True, comment="画布连线数据")
|
||||||
created_by = Column(Integer, nullable=True)
|
created_by = Column(Integer, nullable=True)
|
||||||
created_at = Column(DateTime, server_default=func.now())
|
created_at = Column(DateTime, server_default=func.now())
|
||||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
"""D2(2026-10-20 fullstack-20261020-strategy-map-3defects-fix)
|
||||||
|
|
||||||
|
缺陷:`DELETE /api/cma/maps/{id}/connections` 返回 200 但库里没变(POST 同样不可靠)。
|
||||||
|
根因:`StrategicMap.canvas_data` 是裸 `Column(JSON)`,无变更跟踪;接口用
|
||||||
|
「`m.canvas_data["connections"] = new_conns`」就地改,flush 时该列未被判脏 ⇒ 不写库。
|
||||||
|
|
||||||
|
判据铁律(任务 §1.2):**不能**用「响应体里 connections 变短了」当落库证据 ——
|
||||||
|
必须新开 session 用原生 SQL 复读 `canvas_data`。
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from app.models import StrategicMap
|
||||||
|
|
||||||
|
|
||||||
|
def _raw_canvas_data(map_id: int) -> dict:
|
||||||
|
"""**新 session + 原生 SQL** 复读(不复用请求内 session)。"""
|
||||||
|
from tests.conftest import TEST_SESSION_LOCAL
|
||||||
|
|
||||||
|
session = TEST_SESSION_LOCAL()
|
||||||
|
try:
|
||||||
|
row = session.execute(
|
||||||
|
text("SELECT canvas_data FROM strategic_maps WHERE id = :mid"), {"mid": map_id}
|
||||||
|
).first()
|
||||||
|
assert row is not None, f"strategic_maps.id={map_id} 不存在"
|
||||||
|
raw = row[0]
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
if isinstance(raw, (str, bytes)):
|
||||||
|
return json.loads(raw)
|
||||||
|
return raw or {}
|
||||||
|
|
||||||
|
|
||||||
|
def _pairs(canvas: dict):
|
||||||
|
return [(c.get("from"), c.get("to")) for c in (canvas.get("connections") or [])]
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_connection_persists_across_sessions(client, db):
|
||||||
|
from tests.conftest import auth_header, create_test_map, create_test_user, get_token_for_user
|
||||||
|
|
||||||
|
create_test_user(db)
|
||||||
|
m = create_test_map(db, entity_id=1) # canvas_data={"connections": []}
|
||||||
|
db.commit()
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
h = auth_header(token)
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
f"/api/cma/maps/{m.id}/connections",
|
||||||
|
json={"from": "learning-0", "to": "process-0"},
|
||||||
|
headers=h,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert ("learning-0", "process-0") in _pairs(_raw_canvas_data(m.id)), (
|
||||||
|
"POST /connections 返回 200 但新 session 原生 SQL 读不到该连线(未落库)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_connection_persists_across_sessions(client, db):
|
||||||
|
from tests.conftest import auth_header, create_test_map, create_test_user, get_token_for_user
|
||||||
|
|
||||||
|
create_test_user(db)
|
||||||
|
m = create_test_map(
|
||||||
|
db,
|
||||||
|
entity_id=1,
|
||||||
|
canvas_data={"connections": [
|
||||||
|
{"from": "learning-0", "to": "process-0", "style": "solid", "effect": "positive", "label": ""},
|
||||||
|
{"from": "process-0", "to": "customer-0", "style": "solid", "effect": "positive", "label": ""},
|
||||||
|
]},
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
h = auth_header(token)
|
||||||
|
|
||||||
|
resp = client.request(
|
||||||
|
"DELETE",
|
||||||
|
f"/api/cma/maps/{m.id}/connections",
|
||||||
|
json={"from": "learning-0", "to": "process-0"},
|
||||||
|
headers=h,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
|
||||||
|
persisted = _pairs(_raw_canvas_data(m.id))
|
||||||
|
assert ("learning-0", "process-0") not in persisted, (
|
||||||
|
f"DELETE 返回 200 但新 session 原生 SQL 仍读到已删连线:{persisted}"
|
||||||
|
)
|
||||||
|
assert ("process-0", "customer-0") in persisted, "无关连线被误删"
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_missing_connection_still_404(client, db):
|
||||||
|
"""负测:删除不存在的 from/to 仍 404(不得因标脏改动变成 200)。"""
|
||||||
|
from tests.conftest import auth_header, create_test_map, create_test_user, get_token_for_user
|
||||||
|
|
||||||
|
create_test_user(db)
|
||||||
|
m = create_test_map(db, entity_id=1)
|
||||||
|
db.commit()
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
h = auth_header(token)
|
||||||
|
|
||||||
|
resp = client.request(
|
||||||
|
"DELETE",
|
||||||
|
f"/api/cma/maps/{m.id}/connections",
|
||||||
|
json={"from": "learning-9", "to": "process-9"},
|
||||||
|
headers=h,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404, resp.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_canvas_data_mutation_tracked_by_mutable_dict(db):
|
||||||
|
"""模型层双保险:MutableDict 包装后,**不经 flag_modified** 的 dict 赋值也应落库。
|
||||||
|
|
||||||
|
说明:`MutableDict.as_mutable(JSON)` 返回的仍是 JSON 类型实例(跟踪靠 mapper 级
|
||||||
|
attribute 事件 + coerce),因此这里用**行为断言**而不是断言列类型类名。
|
||||||
|
"""
|
||||||
|
m = StrategicMap(title="MutableDict 行为验证", entity_id=1, canvas_data={"connections": []})
|
||||||
|
db.add(m)
|
||||||
|
db.commit()
|
||||||
|
mid = m.id
|
||||||
|
db.expire_all()
|
||||||
|
|
||||||
|
loaded = db.query(StrategicMap).filter(StrategicMap.id == mid).first()
|
||||||
|
# 就地改 dict(等价于接口里的 `m.canvas_data["connections"] = new_conns`)
|
||||||
|
loaded.canvas_data["connections"] = [{"from": "learning-0", "to": "process-0"}]
|
||||||
|
db.commit() # 不调用 flag_modified
|
||||||
|
|
||||||
|
assert ("learning-0", "process-0") in _pairs(_raw_canvas_data(mid)), (
|
||||||
|
"canvas_data 未被 MutableDict 跟踪:dict 赋值后 commit 未落库"
|
||||||
|
)
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
"""D1(2026-10-20 fullstack-20261020-strategy-map-3defects-fix)
|
||||||
|
|
||||||
|
缺陷:`GET /api/cma/maps/completeness/objectives` 恒 422。
|
||||||
|
根因(项目Bot 复核修正):`main.py` 先 include 的 `objectives.router`
|
||||||
|
(prefix `/api/cma/maps`,`GET /{map_id}/objectives`)把 `map_id="completeness"`
|
||||||
|
当 int 解析 → 422;真抢占者不是 `maps.router`。
|
||||||
|
|
||||||
|
修复:`map_completeness.router` / `map_summary.router` 前移到 `objectives.router` 之前。
|
||||||
|
本文件钉住三件事:
|
||||||
|
1) 该 path 的首个 FULL match 落在 `app.api.map_completeness`(比 200/401 更硬,不依赖 DB);
|
||||||
|
2) `/completeness/summary` 不回归;
|
||||||
|
3) `/maps/{map_id}/objectives` 仍命中 `app.api.objectives`;
|
||||||
|
4) 同前缀族全量巡检:任何字面路径路由都不得被先注册的参数化路由吃掉。
|
||||||
|
"""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi.routing import APIRoute
|
||||||
|
from starlette.routing import Match
|
||||||
|
|
||||||
|
from app.main import app
|
||||||
|
|
||||||
|
COMPLETENESS_OBJECTIVES = "/api/cma/maps/completeness/objectives"
|
||||||
|
|
||||||
|
|
||||||
|
def _first_full_match(path: str, method: str = "GET"):
|
||||||
|
"""按注册顺序返回首个 FULL match 的 route(= Starlette 实际分发结果)。"""
|
||||||
|
scope = {"type": "http", "method": method, "path": path, "headers": []}
|
||||||
|
for route in app.routes:
|
||||||
|
if not hasattr(route, "matches"):
|
||||||
|
continue
|
||||||
|
match, _child = route.matches(scope)
|
||||||
|
if match == Match.FULL:
|
||||||
|
return route
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _ep(route) -> str:
|
||||||
|
endpoint = getattr(route, "endpoint", None)
|
||||||
|
return f"{getattr(endpoint, '__module__', '?')}.{getattr(endpoint, '__name__', '?')}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_imported_from_this_worktree():
|
||||||
|
"""防呆:pytest.ini 的 pythonpath 指向部署树,必须断言用的是当前工作区代码。"""
|
||||||
|
from app import main as app_main
|
||||||
|
|
||||||
|
repo_backend = Path(__file__).resolve().parents[1]
|
||||||
|
assert Path(app_main.__file__).resolve().is_relative_to(repo_backend), (
|
||||||
|
f"导入了非本工作区的 app.main:{app_main.__file__}(期望在 {repo_backend} 下)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_completeness_objectives_route_resolves_to_map_completeness():
|
||||||
|
route = _first_full_match(COMPLETENESS_OBJECTIVES)
|
||||||
|
assert route is not None, f"没有路由匹配 {COMPLETENESS_OBJECTIVES}"
|
||||||
|
assert _ep(route) == "app.api.map_completeness.completeness_objectives", (
|
||||||
|
f"{COMPLETENESS_OBJECTIVES} 被抢先解析为 {_ep(route)}"
|
||||||
|
)
|
||||||
|
assert getattr(route, "path", None) == COMPLETENESS_OBJECTIVES
|
||||||
|
|
||||||
|
|
||||||
|
def test_completeness_summary_not_regressed():
|
||||||
|
route = _first_full_match("/api/cma/maps/completeness/summary")
|
||||||
|
assert route is not None
|
||||||
|
assert _ep(route) == "app.api.map_completeness.completeness_summary", _ep(route)
|
||||||
|
|
||||||
|
|
||||||
|
def test_map_summary_not_shadowed():
|
||||||
|
route = _first_full_match("/api/cma/maps/summary")
|
||||||
|
assert route is not None
|
||||||
|
assert _ep(route) == "app.api.map_summary.map_summary", _ep(route)
|
||||||
|
|
||||||
|
|
||||||
|
def test_map_objectives_endpoint_not_regressed():
|
||||||
|
route = _first_full_match("/api/cma/maps/65/objectives")
|
||||||
|
assert route is not None
|
||||||
|
assert _ep(route) == "app.api.objectives.list_objectives", _ep(route)
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_literal_path_shadowed_in_maps_prefix_family():
|
||||||
|
"""同前缀族全量巡检:/api/cma/maps* 下所有**字面**路径的首个匹配必须是自己。"""
|
||||||
|
family = [
|
||||||
|
r for r in app.routes
|
||||||
|
if isinstance(r, APIRoute)
|
||||||
|
and r.path.startswith("/api/cma/maps")
|
||||||
|
and "{" not in r.path
|
||||||
|
]
|
||||||
|
assert family, "未发现 /api/cma/maps* 字面路径路由,巡检面为空(异常)"
|
||||||
|
|
||||||
|
problems = []
|
||||||
|
for r in family:
|
||||||
|
for method in sorted((r.methods or set()) - {"HEAD", "OPTIONS"}):
|
||||||
|
first = _first_full_match(r.path, method)
|
||||||
|
if first is not r:
|
||||||
|
problems.append(f"{method} {r.path} -> {_ep(first)}(应为 {_ep(r)})")
|
||||||
|
assert not problems, "同前缀族路由被抢占:\n" + "\n".join(problems)
|
||||||
|
|
||||||
|
|
||||||
|
def test_completeness_objectives_http_not_422(client, db):
|
||||||
|
"""端到端:带凭据请求不得出现 loc=["path","map_id"] / input="completeness" 的 422。"""
|
||||||
|
from tests.conftest import auth_header, create_test_user, get_token_for_user
|
||||||
|
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
resp = client.get(COMPLETENESS_OBJECTIVES, headers=auth_header(token))
|
||||||
|
assert resp.status_code != 422, resp.text
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
body = resp.json()
|
||||||
|
assert "objectives" in body or isinstance(body, dict)
|
||||||
@@ -994,10 +994,35 @@ function onTreeDragStart(payload: { dimKey: string; index: number; obj: any }) {
|
|||||||
}
|
}
|
||||||
function onTreeDragOver(e: DragEvent) {
|
function onTreeDragOver(e: DragEvent) {
|
||||||
if (!treeDragSource.value) return
|
if (!treeDragSource.value) return
|
||||||
const el = document.elementFromPoint(e.clientX, e.clientY)?.closest('[data-node-key]') as HTMLElement | null
|
const key = hitTestTreeNodeAt(e.clientX, e.clientY)
|
||||||
const key = el?.getAttribute('data-node-key') || null
|
|
||||||
dragConnectTarget.value = key && key !== treeDragSource.value.key ? key : null
|
dragConnectTarget.value = key && key !== treeDragSource.value.key ? key : null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* D3(2026-10-20):窄视口下 .kp-panel 是 fixed + z-index:1000 的浮层,会盖住画布主体;
|
||||||
|
* 旧实现用 document.elementFromPoint() 取节点 —— 命中面板时 .closest('[data-node-key]') 恒 null,
|
||||||
|
* 拖拽建连线必走 cancelLink()。改为**几何命中**:直接遍历画布内全部 [data-node-key],
|
||||||
|
* 用 getBoundingClientRect() 做包含判断(rect 已含 transform: scale,与 clientX/Y 同坐标系);
|
||||||
|
* 多点重叠时取中心距拖拽点最近者。**不再回退 elementFromPoint**(否则窄屏复发)。
|
||||||
|
* 性能:19 个节点的 rect 遍历一帧一次 ≈ 微秒级,无需 rAF/节流,避免节流造成 drop 时刻判据滞后。
|
||||||
|
*/
|
||||||
|
function hitTestTreeNodeAt(x: number, y: number): string | null {
|
||||||
|
const box = scrollWrapRef.value
|
||||||
|
if (!box) return null
|
||||||
|
const nodes = box.querySelectorAll<HTMLElement>('[data-node-key]')
|
||||||
|
let bestKey: string | null = null
|
||||||
|
let bestDist = Number.POSITIVE_INFINITY
|
||||||
|
for (let i = 0; i < nodes.length; i++) {
|
||||||
|
const r = nodes[i].getBoundingClientRect()
|
||||||
|
if (r.width === 0 && r.height === 0) continue // 隐藏层/折叠层
|
||||||
|
if (x < r.left || x > r.right || y < r.top || y > r.bottom) continue
|
||||||
|
const dx = x - (r.left + r.width / 2)
|
||||||
|
const dy = y - (r.top + r.height / 2)
|
||||||
|
const d = dx * dx + dy * dy
|
||||||
|
if (d < bestDist) { bestDist = d; bestKey = nodes[i].getAttribute('data-node-key') }
|
||||||
|
}
|
||||||
|
return bestKey
|
||||||
|
}
|
||||||
function onTreeDragLeave(e: DragEvent) {
|
function onTreeDragLeave(e: DragEvent) {
|
||||||
const related = (e as any).relatedTarget as Node | null
|
const related = (e as any).relatedTarget as Node | null
|
||||||
const box = scrollWrapRef.value
|
const box = scrollWrapRef.value
|
||||||
|
|||||||
Reference in New Issue
Block a user