diff --git a/backend/migrations/020_roadmap_graph_edges.sql b/backend/migrations/020_roadmap_graph_edges.sql new file mode 100644 index 0000000..81bbdd3 --- /dev/null +++ b/backend/migrations/020_roadmap_graph_edges.sql @@ -0,0 +1,17 @@ +-- AP1.4d: Graph Engine — erweiterte Kantenarten + Parallelgruppe + +ALTER TABLE roadmap_item_dependencies + DROP CONSTRAINT IF EXISTS roadmap_item_dependencies_dependency_type_check; + +ALTER TABLE roadmap_item_dependencies + ADD CONSTRAINT roadmap_item_dependencies_dependency_type_check + CHECK (dependency_type IN ( + 'requires', 'blocks', 'related', 'parallel_group', 'optional_branch' + )); + +ALTER TABLE roadmap_item_dependencies + ADD COLUMN IF NOT EXISTS group_key VARCHAR(128) NULL; + +CREATE INDEX idx_roadmap_deps_group_key + ON roadmap_item_dependencies(tenant_id, group_key) + WHERE group_key IS NOT NULL; diff --git a/backend/routers/roadmap.py b/backend/routers/roadmap.py index 1e0ce7b..d9d249f 100644 --- a/backend/routers/roadmap.py +++ b/backend/routers/roadmap.py @@ -45,7 +45,10 @@ class RoadmapItemUpdateRequest(BaseModel): class DependencyCreateRequest(BaseModel): to_item_id: str - dependency_type: Literal["requires", "blocks", "related"] = "requires" + dependency_type: Literal[ + "requires", "blocks", "related", "parallel_group", "optional_branch" + ] = "requires" + group_key: Optional[str] = Field(default=None, max_length=128) class CriterionCreateRequest(BaseModel): @@ -143,6 +146,21 @@ def list_initiative_roadmap_criteria_progress( raise HTTPException(status_code=400, detail=str(exc)) from exc +@initiative_router.get("/{initiative_id}/roadmap/graph-state") +def get_initiative_roadmap_graph_state( + initiative_id: str, + ctx: TenantContext = Depends(require_capability("kairo.milestone.read")), +): + try: + from steering.graph.roadmap_engine import load_initiative_graph_state + + return load_initiative_graph_state( + tenant_id=ctx.tenant_id, initiative_id=initiative_id + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + @initiative_router.post("/{initiative_id}/roadmap/items", status_code=201) def create_initiative_roadmap_item( initiative_id: str, @@ -419,6 +437,7 @@ def add_roadmap_item_dependency( from_item_id=item_id, to_item_id=body.to_item_id, dependency_type=body.dependency_type, + group_key=body.group_key, user_id=ctx.user_id, ) except ValueError as exc: diff --git a/backend/services/roadmap.py b/backend/services/roadmap.py index 7d51f20..9f576f1 100644 --- a/backend/services/roadmap.py +++ b/backend/services/roadmap.py @@ -18,14 +18,14 @@ RoadmapItemStatus = Literal[ "planned", "active", "at_risk", "reached", "moved", "discarded" ] SequencingMode = Literal["sequential", "parallel", "optional"] -DependencyType = Literal["requires", "blocks", "related"] +DependencyType = Literal["requires", "blocks", "related", "parallel_group", "optional_branch"] ROADMAP_ITEM_TYPES = frozenset({"milestone", "review_gate", "maturity_stage"}) ROADMAP_ITEM_STATUSES = frozenset( {"planned", "active", "at_risk", "reached", "moved", "discarded"} ) SEQUENCING_MODES = frozenset({"sequential", "parallel", "optional"}) -DEPENDENCY_TYPES = frozenset({"requires", "blocks", "related"}) +DEPENDENCY_TYPES = frozenset({"requires", "blocks", "related", "parallel_group", "optional_branch"}) TERMINAL_STATUSES = frozenset({"reached", "moved", "discarded"}) @@ -457,6 +457,18 @@ def delete_roadmap_item( return deleted +def _serialize_dependency(row: dict[str, Any]) -> dict[str, Any]: + dep = dict(row) + for key in ("id", "tenant_id", "from_item_id", "to_item_id"): + if dep.get(key): + dep[key] = str(dep[key]) + if dep.get("created_at"): + dep["created_at"] = dep["created_at"].isoformat() + if dep.get("group_key") is None: + dep.pop("group_key", None) + return dep + + def list_dependencies(*, tenant_id: str, item_id: str) -> list[dict[str, Any]]: item = get_roadmap_item(tenant_id=tenant_id, item_id=item_id) if not item: @@ -467,22 +479,15 @@ def list_dependencies(*, tenant_id: str, item_id: str) -> list[dict[str, Any]]: with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute( """ - SELECT id, tenant_id, from_item_id, to_item_id, dependency_type, created_at + SELECT id, tenant_id, from_item_id, to_item_id, dependency_type, + group_key, created_at FROM roadmap_item_dependencies WHERE tenant_id = %s AND (from_item_id = %s OR to_item_id = %s) ORDER BY created_at ASC """, (tenant_id, item_id, item_id), ) - items = [] - for row in cur.fetchall(): - dep = dict(row) - for key in ("id", "tenant_id", "from_item_id", "to_item_id"): - dep[key] = str(dep[key]) - if dep.get("created_at"): - dep["created_at"] = dep["created_at"].isoformat() - items.append(dep) - return items + return [_serialize_dependency(dict(row)) for row in cur.fetchall()] finally: conn.close() @@ -499,7 +504,7 @@ def list_dependencies_for_initiative( cur.execute( """ SELECT d.id, d.tenant_id, d.from_item_id, d.to_item_id, - d.dependency_type, d.created_at + d.dependency_type, d.group_key, d.created_at FROM roadmap_item_dependencies d INNER JOIN roadmap_items fi ON fi.id = d.from_item_id AND fi.tenant_id = d.tenant_id @@ -510,15 +515,7 @@ def list_dependencies_for_initiative( """, (tenant_id, initiative_id), ) - items = [] - for row in cur.fetchall(): - dep = dict(row) - for key in ("id", "tenant_id", "from_item_id", "to_item_id"): - dep[key] = str(dep[key]) - if dep.get("created_at"): - dep["created_at"] = dep["created_at"].isoformat() - items.append(dep) - return items + return [_serialize_dependency(dict(row)) for row in cur.fetchall()] finally: conn.close() @@ -529,6 +526,7 @@ def add_dependency( from_item_id: str, to_item_id: str, dependency_type: DependencyType = "requires", + group_key: Optional[str] = None, user_id: Optional[str] = None, ) -> dict[str, Any]: _validate_dependency_type(dependency_type) @@ -542,6 +540,12 @@ def add_dependency( if from_item["initiative_id"] != to_item["initiative_id"]: raise ValueError("Abhängigkeiten nur innerhalb eines Vorhabens") + normalized_group_key: Optional[str] = None + if group_key is not None: + normalized_group_key = group_key.strip() or None + if dependency_type == "parallel_group" and not normalized_group_key: + raise ValueError("group_key ist für parallel_group erforderlich") + conn = get_connection() try: with conn.cursor(cursor_factory=RealDictCursor) as cur: @@ -559,17 +563,15 @@ def add_dependency( cur.execute( """ INSERT INTO roadmap_item_dependencies ( - tenant_id, from_item_id, to_item_id, dependency_type + tenant_id, from_item_id, to_item_id, dependency_type, group_key ) - VALUES (%s, %s, %s, %s) - RETURNING id, tenant_id, from_item_id, to_item_id, dependency_type, created_at + VALUES (%s, %s, %s, %s, %s) + RETURNING id, tenant_id, from_item_id, to_item_id, dependency_type, + group_key, created_at """, - (tenant_id, from_item_id, to_item_id, dependency_type), + (tenant_id, from_item_id, to_item_id, dependency_type, normalized_group_key), ) - row = dict(cur.fetchone()) - for key in ("id", "tenant_id", "from_item_id", "to_item_id"): - row[key] = str(row[key]) - row["created_at"] = row["created_at"].isoformat() + row = _serialize_dependency(dict(cur.fetchone())) conn.commit() finally: conn.close() @@ -582,6 +584,7 @@ def add_dependency( "from_item_id": from_item_id, "to_item_id": to_item_id, "dependency_type": dependency_type, + "group_key": normalized_group_key, }, ) return row diff --git a/backend/steering/graph/__init__.py b/backend/steering/graph/__init__.py new file mode 100644 index 0000000..609d75b --- /dev/null +++ b/backend/steering/graph/__init__.py @@ -0,0 +1,5 @@ +"""Roadmap graph read models (AP1.4d).""" + +from steering.graph.roadmap_engine import compute_initiative_graph_state + +__all__ = ["compute_initiative_graph_state"] diff --git a/backend/steering/graph/roadmap_engine.py b/backend/steering/graph/roadmap_engine.py new file mode 100644 index 0000000..07f1fdd --- /dev/null +++ b/backend/steering/graph/roadmap_engine.py @@ -0,0 +1,154 @@ +"""Roadmap gate graph read models — Plan-Zielzustand, kein Workflow (AP1.4d).""" + +from __future__ import annotations + +from typing import Any, Optional + +TERMINAL_STATUSES = frozenset({"reached", "moved", "discarded"}) +ACTIVE_STATUSES = frozenset({"planned", "active", "at_risk"}) +BLOCKING_STATUSES = frozenset({"planned", "active", "at_risk"}) + + +def _is_prerequisite_satisfied(status: str) -> bool: + return status == "reached" + + +def _fulfillment_ratio(progress: Optional[dict[str, int]]) -> Optional[float]: + if not progress: + return None + total = progress.get("total", 0) + if total <= 0: + return None + closed = progress.get("closed", 0) + return round(closed / total, 4) + + +def _sort_items(items: list[dict[str, Any]]) -> list[dict[str, Any]]: + return sorted( + items, + key=lambda item: ( + item.get("sort_order", 0), + item.get("title") or "", + str(item.get("id", "")), + ), + ) + + +def _sequential_order_blocked_by( + item: dict[str, Any], + sorted_items: list[dict[str, Any]], +) -> list[str]: + """Fallback wenn keine Kanten: sequenzielle Items nach sort_order.""" + blocked_by: list[str] = [] + item_id = str(item["id"]) + for prev in sorted_items: + if str(prev["id"]) == item_id: + break + if prev.get("sequencing_mode", "sequential") != "sequential": + continue + if not _is_prerequisite_satisfied(prev.get("status", "planned")): + blocked_by.append(str(prev["id"])) + return blocked_by + + +def compute_initiative_graph_state( + *, + items: list[dict[str, Any]], + dependencies: list[dict[str, Any]], + criteria_progress: Optional[dict[str, dict[str, int]]] = None, +) -> dict[str, Any]: + """ + Read Model pro Initiative: blocked / ready / fulfillment_ratio. + + Kanten-Semantik (from → to): + - requires: from benötigt to (Voraussetzung) als reached + - optional_branch: wie requires, blockiert aber nicht (nur Metadaten) + - blocks: from blockiert to solange from noch aktiv ist + - parallel_group / related: keine Blockade + """ + criteria_progress = criteria_progress or {} + sorted_items = _sort_items(items) + item_by_id = {str(item["id"]): item for item in items} + has_any_deps = len(dependencies) > 0 + + blocked_by_map: dict[str, list[str]] = {item_id: [] for item_id in item_by_id} + + for dep in dependencies: + dep_type = dep.get("dependency_type") or dep.get("edge_kind") or "requires" + from_id = str(dep["from_item_id"]) + to_id = str(dep["to_item_id"]) + if from_id not in item_by_id or to_id not in item_by_id: + continue + + if dep_type == "requires": + prereq = item_by_id[to_id] + if not _is_prerequisite_satisfied(prereq.get("status", "planned")): + if to_id not in blocked_by_map[from_id]: + blocked_by_map[from_id].append(to_id) + elif dep_type == "blocks": + blocker = item_by_id[from_id] + if blocker.get("status", "planned") in BLOCKING_STATUSES: + if from_id not in blocked_by_map[to_id]: + blocked_by_map[to_id].append(from_id) + + if not has_any_deps: + for item in sorted_items: + item_id = str(item["id"]) + if item.get("sequencing_mode", "sequential") == "sequential": + for prereq_id in _sequential_order_blocked_by(item, sorted_items): + if prereq_id not in blocked_by_map[item_id]: + blocked_by_map[item_id].append(prereq_id) + + item_states: dict[str, dict[str, Any]] = {} + blocked_items: list[str] = [] + ready_items: list[str] = [] + + for item_id, item in item_by_id.items(): + status = item.get("status", "planned") + blocked_by = blocked_by_map.get(item_id, []) + blocked = len(blocked_by) > 0 + ready = ( + status in ACTIVE_STATUSES + and not blocked + ) + + ratio = _fulfillment_ratio(criteria_progress.get(item_id)) + item_states[item_id] = { + "blocked": blocked, + "ready": ready, + "blocked_by": blocked_by, + "fulfillment_ratio": ratio, + } + if blocked and status in ACTIVE_STATUSES: + blocked_items.append(item_id) + if ready: + ready_items.append(item_id) + + return { + "items": item_states, + "blocked_items": blocked_items, + "ready_items": ready_items, + } + + +def load_initiative_graph_state( + *, tenant_id: str, initiative_id: str +) -> dict[str, Any]: + """Lädt Items, Kanten und Kriterien-Fortschritt und berechnet Graph-State.""" + from services import roadmap as roadmap_service + from services import roadmap_criteria as criteria_service + + items = roadmap_service.list_roadmap_items_for_initiative( + tenant_id=tenant_id, initiative_id=initiative_id + ) + dependencies = roadmap_service.list_dependencies_for_initiative( + tenant_id=tenant_id, initiative_id=initiative_id + ) + criteria_progress = criteria_service.criteria_progress_for_initiative( + tenant_id=tenant_id, initiative_id=initiative_id + ) + return compute_initiative_graph_state( + items=items, + dependencies=dependencies, + criteria_progress=criteria_progress, + ) diff --git a/backend/tests/test_ap14_roadmap.py b/backend/tests/test_ap14_roadmap.py index 3014ff5..8c42e7a 100644 --- a/backend/tests/test_ap14_roadmap.py +++ b/backend/tests/test_ap14_roadmap.py @@ -218,3 +218,27 @@ def test_initiative_roadmap_criteria_progress(client): body = progress.json() assert body[item["id"]]["total"] >= 1 assert body[item["id"]]["closed"] == 0 + + +def test_initiative_roadmap_graph_state(client): + user = provision_user_in_tenant(tenant_role="member") + token = _login(client, user) + initiative_id = _create_initiative(client, token).json()["id"] + gate_a = _create_roadmap_item(client, token, initiative_id, title="Gate A", sort_order=0).json() + gate_b = _create_roadmap_item(client, token, initiative_id, title="Gate B", sort_order=1).json() + + client.post( + f"/api/roadmap-items/{gate_a['id']}/dependencies", + json={"to_item_id": gate_b["id"], "dependency_type": "requires"}, + headers=_auth(token), + ) + + state = client.get( + f"/api/initiatives/{initiative_id}/roadmap/graph-state", + headers=_auth(token), + ) + assert state.status_code == 200 + body = state.json() + assert body["items"][gate_a["id"]]["blocked"] is True + assert gate_b["id"] in body["items"][gate_a["id"]]["blocked_by"] + assert gate_a["id"] not in body["ready_items"] diff --git a/backend/tests/test_ap14d_roadmap_graph.py b/backend/tests/test_ap14d_roadmap_graph.py new file mode 100644 index 0000000..e7f05b6 --- /dev/null +++ b/backend/tests/test_ap14d_roadmap_graph.py @@ -0,0 +1,85 @@ +"""Unit tests for roadmap graph engine (AP1.4d).""" + +from steering.graph.roadmap_engine import compute_initiative_graph_state + + +def _item(item_id: str, status: str = "planned", **kwargs): + return { + "id": item_id, + "status": status, + "sort_order": kwargs.get("sort_order", 0), + "sequencing_mode": kwargs.get("sequencing_mode", "sequential"), + "title": kwargs.get("title", item_id), + } + + +def test_requires_blocks_until_prerequisite_reached(): + items = [_item("a", "planned"), _item("b", "reached")] + deps = [ + { + "from_item_id": "a", + "to_item_id": "b", + "dependency_type": "requires", + } + ] + state = compute_initiative_graph_state(items=items, dependencies=deps) + assert state["items"]["a"]["blocked"] is False + assert state["items"]["a"]["ready"] is True + assert "a" in state["ready_items"] + + items[1]["status"] = "planned" + state = compute_initiative_graph_state(items=items, dependencies=deps) + assert state["items"]["a"]["blocked"] is True + assert state["items"]["a"]["blocked_by"] == ["b"] + assert "a" in state["blocked_items"] + + +def test_sequential_fallback_without_edges(): + items = [ + _item("first", "planned", sort_order=0), + _item("second", "planned", sort_order=1), + ] + state = compute_initiative_graph_state(items=items, dependencies=[]) + assert state["items"]["first"]["ready"] is True + assert state["items"]["second"]["blocked"] is True + assert state["items"]["second"]["blocked_by"] == ["first"] + + items[0]["status"] = "reached" + state = compute_initiative_graph_state(items=items, dependencies=[]) + assert state["items"]["second"]["ready"] is True + + +def test_parallel_sequencing_mode_skips_order_block(): + items = [ + _item("first", "planned", sort_order=0), + _item("second", "planned", sort_order=1, sequencing_mode="parallel"), + ] + state = compute_initiative_graph_state(items=items, dependencies=[]) + assert state["items"]["second"]["ready"] is True + + +def test_blocks_edge(): + items = [_item("blocker", "active"), _item("blocked", "planned")] + deps = [ + { + "from_item_id": "blocker", + "to_item_id": "blocked", + "dependency_type": "blocks", + } + ] + state = compute_initiative_graph_state(items=items, dependencies=deps) + assert state["items"]["blocked"]["blocked"] is True + assert state["items"]["blocked"]["blocked_by"] == ["blocker"] + + items[0]["status"] = "reached" + state = compute_initiative_graph_state(items=items, dependencies=deps) + assert state["items"]["blocked"]["ready"] is True + + +def test_fulfillment_ratio_from_criteria_progress(): + items = [_item("g", "active")] + progress = {"g": {"total": 4, "closed": 2, "open": 2}} + state = compute_initiative_graph_state( + items=items, dependencies=[], criteria_progress=progress + ) + assert state["items"]["g"]["fulfillment_ratio"] == 0.5 diff --git a/frontend/src/api/roadmap.js b/frontend/src/api/roadmap.js index 48d9f91..31d7d09 100644 --- a/frontend/src/api/roadmap.js +++ b/frontend/src/api/roadmap.js @@ -12,6 +12,10 @@ export function listInitiativeRoadmapCriteriaProgress(initiativeId) { return apiFetch(`/api/initiatives/${initiativeId}/roadmap/criteria-progress`) } +export function listInitiativeRoadmapGraphState(initiativeId) { + return apiFetch(`/api/initiatives/${initiativeId}/roadmap/graph-state`) +} + export function createInitiativeRoadmapItem(initiativeId, body) { return apiFetch(`/api/initiatives/${initiativeId}/roadmap/items`, { method: 'POST', diff --git a/frontend/src/components/GateDependenciesSection.jsx b/frontend/src/components/GateDependenciesSection.jsx index 2eb679f..98c1f6b 100644 --- a/frontend/src/components/GateDependenciesSection.jsx +++ b/frontend/src/components/GateDependenciesSection.jsx @@ -6,6 +6,8 @@ const DEPENDENCY_TYPE_LABELS = { requires: 'Voraussetzung', blocks: 'Blockiert', related: 'Bezug', + parallel_group: 'Parallelgruppe', + optional_branch: 'Optional', } const OUTGOING_PHRASES = { @@ -56,6 +58,7 @@ function DependencyRow({ dep, itemId, siblingItems, direction, canManage, busy,
{DEPENDENCY_TYPE_LABELS[dep.dependency_type] || dep.dependency_type} + {dep.group_key ? ` · Gruppe ${dep.group_key}` : ''}
{canManage && ( @@ -92,11 +95,17 @@ export function GateDependenciesSection({ const form = event.target const toItemId = form.to_item_id.value const dependencyType = form.dependency_type.value + const groupKey = form.group_key?.value?.trim() if (!toItemId) return - await onAdd({ + const body = { to_item_id: toItemId, dependency_type: dependencyType, - }) + } + if (dependencyType === 'parallel_group') { + if (!groupKey) return + body.group_key = groupKey + } + await onAdd(body) form.reset() } @@ -169,9 +178,20 @@ export function GateDependenciesSection({ + )} -+ Reihenfolge und Parallelität steuerst du über Kanten auf der Gate-Detailseite — nicht mehr + über ein Abfolge-Feld. +
{mode === 'edit' && (