diff --git a/backend/steering/graph/__init__.py b/backend/steering/graph/__init__.py index 609d75b..bf2eb94 100644 --- a/backend/steering/graph/__init__.py +++ b/backend/steering/graph/__init__.py @@ -1,5 +1,18 @@ -"""Roadmap graph read models (AP1.4d).""" +"""Roadmap graph read models (AP1.4d/4e).""" -from steering.graph.roadmap_engine import compute_initiative_graph_state +from steering.graph.profiles import GraphMethodProfile, get_graph_profile +from steering.graph.roadmap_engine import ( + apply_graph_method_profile, + compute_initiative_graph_state, + compute_initiative_graph_state_with_profile, + load_initiative_graph_state, +) -__all__ = ["compute_initiative_graph_state"] +__all__ = [ + "GraphMethodProfile", + "get_graph_profile", + "apply_graph_method_profile", + "compute_initiative_graph_state", + "compute_initiative_graph_state_with_profile", + "load_initiative_graph_state", +] diff --git a/backend/steering/graph/profiles.py b/backend/steering/graph/profiles.py new file mode 100644 index 0000000..67289f7 --- /dev/null +++ b/backend/steering/graph/profiles.py @@ -0,0 +1,51 @@ +"""Method graph profiles — AP1.4e.""" + +from __future__ import annotations + +from dataclasses import dataclass + +DEFAULT_METHOD_KEY = "generic_operating" + + +@dataclass(frozen=True) +class GraphMethodProfile: + """Steuert Graph-Read-Models pro steering_context.method_key.""" + + enforce_gate_blocking: bool + emphasize_fulfillment: bool + + +_STRICT = GraphMethodProfile(enforce_gate_blocking=True, emphasize_fulfillment=False) +_FULFILLMENT = GraphMethodProfile(enforce_gate_blocking=False, emphasize_fulfillment=True) +_LIGHT = GraphMethodProfile(enforce_gate_blocking=False, emphasize_fulfillment=False) + +METHOD_GRAPH_PROFILES: dict[str, GraphMethodProfile] = { + "generic_operating": _LIGHT, + "queue_pull": _LIGHT, + "dispute_procedure": _LIGHT, + "continuous_product": _FULFILLMENT, + "maturity_progression": _FULFILLMENT, + "chapter_based_progression": _FULFILLMENT, + "product_milestone_driven": _STRICT, + "program_delivery": _STRICT, + "sequential_dependency": _STRICT, + "agile_iteration": _STRICT, + "recurring_control": _FULFILLMENT, +} + + +def get_graph_profile(method_key: str | None) -> GraphMethodProfile: + if not method_key: + return METHOD_GRAPH_PROFILES[DEFAULT_METHOD_KEY] + return METHOD_GRAPH_PROFILES.get(method_key, _STRICT) + + +def enforce_gate_blocking_for_method(method_key: str | None) -> bool: + return get_graph_profile(method_key).enforce_gate_blocking + + +def graph_profile_as_dict(profile: GraphMethodProfile) -> dict[str, bool]: + return { + "enforce_gate_blocking": profile.enforce_gate_blocking, + "emphasize_fulfillment": profile.emphasize_fulfillment, + } diff --git a/backend/steering/graph/roadmap_engine.py b/backend/steering/graph/roadmap_engine.py index 07f1fdd..16cd8ee 100644 --- a/backend/steering/graph/roadmap_engine.py +++ b/backend/steering/graph/roadmap_engine.py @@ -1,9 +1,16 @@ -"""Roadmap gate graph read models — Plan-Zielzustand, kein Workflow (AP1.4d).""" +"""Roadmap graph read models — AP1.4d/4e.""" from __future__ import annotations from typing import Any, Optional +from steering.graph.profiles import ( + DEFAULT_METHOD_KEY, + GraphMethodProfile, + get_graph_profile, + graph_profile_as_dict, +) + TERMINAL_STATUSES = frozenset({"reached", "moved", "discarded"}) ACTIVE_STATUSES = frozenset({"planned", "active", "at_risk"}) BLOCKING_STATUSES = frozenset({"planned", "active", "at_risk"}) @@ -114,6 +121,7 @@ def compute_initiative_graph_state( ratio = _fulfillment_ratio(criteria_progress.get(item_id)) item_states[item_id] = { + "status": status, "blocked": blocked, "ready": ready, "blocked_by": blocked_by, @@ -131,12 +139,72 @@ def compute_initiative_graph_state( } +def _initiative_fulfillment_ratio(item_states: dict[str, dict[str, Any]]) -> Optional[float]: + ratios = [ + st["fulfillment_ratio"] + for st in item_states.values() + if st.get("fulfillment_ratio") is not None + ] + if not ratios: + return None + return round(sum(ratios) / len(ratios), 4) + + +def apply_graph_method_profile( + state: dict[str, Any], profile: GraphMethodProfile +) -> dict[str, Any]: + """Passt blocked/ready an Methodenprofil an (AP1.4e).""" + if profile.enforce_gate_blocking: + state["graph_profile"] = graph_profile_as_dict(profile) + state["initiative_fulfillment_ratio"] = _initiative_fulfillment_ratio(state["items"]) + return state + + item_states = state["items"] + blocked_items: list[str] = [] + ready_items: list[str] = [] + + for item_id, item_state in item_states.items(): + would_block = item_state["blocked"] + item_state["would_block"] = would_block + item_state["blocked"] = False + status = item_state.get("status", "planned") + item_state["ready"] = status in ACTIVE_STATUSES + if item_state["ready"]: + ready_items.append(item_id) + + state["items"] = item_states + state["blocked_items"] = blocked_items + state["ready_items"] = ready_items + state["graph_profile"] = graph_profile_as_dict(profile) + state["initiative_fulfillment_ratio"] = _initiative_fulfillment_ratio(item_states) + return state + + +def compute_initiative_graph_state_with_profile( + *, + items: list[dict[str, Any]], + dependencies: list[dict[str, Any]], + criteria_progress: Optional[dict[str, dict[str, int]]] = None, + method_key: Optional[str] = None, +) -> dict[str, Any]: + base = compute_initiative_graph_state( + items=items, + dependencies=dependencies, + criteria_progress=criteria_progress, + ) + profile = get_graph_profile(method_key) + result = apply_graph_method_profile(base, profile) + result["method_key"] = method_key or DEFAULT_METHOD_KEY + return result + + 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.""" + """Lädt Items, Kanten, Kriterien und Methodenprofil.""" from services import roadmap as roadmap_service from services import roadmap_criteria as criteria_service + from services.steering_context import get_steering_context items = roadmap_service.list_roadmap_items_for_initiative( tenant_id=tenant_id, initiative_id=initiative_id @@ -147,8 +215,12 @@ def load_initiative_graph_state( criteria_progress = criteria_service.criteria_progress_for_initiative( tenant_id=tenant_id, initiative_id=initiative_id ) - return compute_initiative_graph_state( + steering = get_steering_context(tenant_id=tenant_id, initiative_id=initiative_id) + method_key = (steering or {}).get("method_key") or DEFAULT_METHOD_KEY + + return compute_initiative_graph_state_with_profile( items=items, dependencies=dependencies, criteria_progress=criteria_progress, + method_key=method_key, ) diff --git a/backend/steering/signals/default_rules.py b/backend/steering/signals/default_rules.py index 9809f33..4f1ce7e 100644 --- a/backend/steering/signals/default_rules.py +++ b/backend/steering/signals/default_rules.py @@ -17,6 +17,7 @@ AttentionKind = Literal[ "initiative_without_next_action", "stale_initiative", "milestone_at_risk", + "gate_graph_blocked", "overdue_action", "review_due", "recurring_due", @@ -250,6 +251,71 @@ def _stale_initiatives(cur, ctx: TenantContext) -> list[dict[str, Any]]: return [_serialize_attention(dict(r)) for r in cur.fetchall()] +def _graph_blocked_gates(cur, ctx: TenantContext) -> list[dict[str, Any]]: + from steering.graph.profiles import enforce_gate_blocking_for_method + from steering.graph.roadmap_engine import load_initiative_graph_state + + cur.execute( + """ + SELECT sc.initiative_id, sc.method_key + FROM steering_contexts sc + JOIN initiatives i + ON i.id = sc.initiative_id AND i.tenant_id = sc.tenant_id + WHERE sc.tenant_id = %s AND i.status IN ('active', 'paused') + ORDER BY i.updated_at DESC + LIMIT 30 + """, + (ctx.tenant_id,), + ) + + items: list[dict[str, Any]] = [] + for row in cur.fetchall(): + method_key = row.get("method_key") or "generic_operating" + if not enforce_gate_blocking_for_method(method_key): + continue + + initiative_id = str(row["initiative_id"]) + state = load_initiative_graph_state( + tenant_id=ctx.tenant_id, initiative_id=initiative_id + ) + for gate_id in state.get("blocked_items", []): + cur.execute( + """ + SELECT title FROM roadmap_items + WHERE id = %s AND tenant_id = %s + """, + (gate_id, ctx.tenant_id), + ) + gate_row = cur.fetchone() + if not gate_row: + continue + blocked_by = state["items"].get(gate_id, {}).get("blocked_by") or [] + summary = ( + "Graph-Voraussetzung noch nicht erreicht" + if len(blocked_by) != 1 + else "Graph-Voraussetzung noch nicht erreicht" + ) + items.append( + { + "kind": "gate_graph_blocked", + "severity": "warning", + "title": gate_row["title"], + "summary": summary, + "scope_type": "milestone", + "scope_id": gate_id, + "initiative_id": initiative_id, + "action_id": None, + "blocker_id": None, + "milestone_id": gate_id, + "reason_code": "gate_graph_blocked", + "data_source": "roadmap_graph", + } + ) + if len(items) >= 20: + return items + return items + + def _milestones_at_risk(cur, ctx: TenantContext) -> list[dict[str, Any]]: cur.execute( """ @@ -424,6 +490,7 @@ def get_attention_items(ctx: TenantContext) -> list[dict[str, Any]]: items.extend(_unassigned_actions(cur, ctx)) items.extend(_initiatives_without_next_action(cur, ctx)) items.extend(_stale_initiatives(cur, ctx)) + items.extend(_graph_blocked_gates(cur, ctx)) items.extend(_milestones_at_risk(cur, ctx)) items.extend(_overdue_actions(cur, ctx)) items.extend(_actions_review_required(cur, ctx)) diff --git a/backend/tests/test_ap14_roadmap.py b/backend/tests/test_ap14_roadmap.py index 8c42e7a..59d0cd8 100644 --- a/backend/tests/test_ap14_roadmap.py +++ b/backend/tests/test_ap14_roadmap.py @@ -242,3 +242,30 @@ def test_initiative_roadmap_graph_state(client): 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"] + + +def test_graph_blocked_attention_for_gate_enforcing_method(client): + user = provision_user_in_tenant(tenant_role="member") + token = _login(client, user) + initiative_id = _create_initiative(client, token).json()["id"] + + client.patch( + f"/api/steering/initiatives/{initiative_id}/context", + json={"method_key": "product_milestone_driven"}, + headers=_auth(token), + ) + + 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), + ) + + attention = client.get("/api/workspace/attention", headers=_auth(token)) + assert attention.status_code == 200 + assert any( + i["kind"] == "gate_graph_blocked" and i["milestone_id"] == gate_a["id"] + for i in attention.json() + ) diff --git a/backend/tests/test_ap14e_graph_profiles.py b/backend/tests/test_ap14e_graph_profiles.py new file mode 100644 index 0000000..466056f --- /dev/null +++ b/backend/tests/test_ap14e_graph_profiles.py @@ -0,0 +1,70 @@ +"""Unit tests for graph method profiles (AP1.4e).""" + +from steering.graph.profiles import get_graph_profile +from steering.graph.roadmap_engine import ( + apply_graph_method_profile, + compute_initiative_graph_state, + compute_initiative_graph_state_with_profile, +) + + +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"), + } + + +def test_generic_operating_does_not_enforce_blocking(): + items = [ + _item("first", "planned", sort_order=0), + _item("second", "planned", sort_order=1), + ] + state = compute_initiative_graph_state_with_profile( + items=items, + dependencies=[], + method_key="generic_operating", + ) + assert state["graph_profile"]["enforce_gate_blocking"] is False + assert state["items"]["second"]["would_block"] is True + assert state["items"]["second"]["blocked"] is False + assert state["items"]["second"]["ready"] is True + assert state["blocked_items"] == [] + + +def test_product_milestone_enforces_blocking(): + items = [ + _item("first", "planned", sort_order=0), + _item("second", "planned", sort_order=1), + ] + state = compute_initiative_graph_state_with_profile( + items=items, + dependencies=[], + method_key="product_milestone_driven", + ) + assert state["graph_profile"]["enforce_gate_blocking"] is True + assert state["items"]["second"]["blocked"] is True + assert "second" in state["blocked_items"] + + +def test_maturity_progression_emphasizes_fulfillment(): + profile = get_graph_profile("maturity_progression") + assert profile.emphasize_fulfillment is True + assert profile.enforce_gate_blocking is False + + +def test_initiative_fulfillment_ratio_average(): + items = [_item("a", "active"), _item("b", "active")] + base = compute_initiative_graph_state( + items=items, + dependencies=[], + criteria_progress={ + "a": {"total": 2, "closed": 2, "open": 0}, + "b": {"total": 4, "closed": 2, "open": 2}, + }, + ) + profile = get_graph_profile("maturity_progression") + state = apply_graph_method_profile(base, profile) + assert state["initiative_fulfillment_ratio"] == 0.75 diff --git a/frontend/src/components/GateFulfillmentBadge.jsx b/frontend/src/components/GateFulfillmentBadge.jsx new file mode 100644 index 0000000..8fee194 --- /dev/null +++ b/frontend/src/components/GateFulfillmentBadge.jsx @@ -0,0 +1,19 @@ +export function GateFulfillmentBadge({ graphState, itemId }) { + const ratio = graphState?.items?.[itemId]?.fulfillment_ratio + if (ratio == null) return null + + const pct = Math.round(ratio * 100) + const emphasize = graphState?.graph_profile?.emphasize_fulfillment + + return ( + + {pct}% Kriterien + + ) +} diff --git a/frontend/src/components/GateGraphStateBadge.jsx b/frontend/src/components/GateGraphStateBadge.jsx index f57f8df..d030b83 100644 --- a/frontend/src/components/GateGraphStateBadge.jsx +++ b/frontend/src/components/GateGraphStateBadge.jsx @@ -3,6 +3,10 @@ import { gateTitleById } from './GateSelect.jsx' export function GateGraphStateBadge({ graphState, itemId, siblingItems = [] }) { if (!graphState?.items?.[itemId]) return null + if (graphState.graph_profile?.enforce_gate_blocking === false) { + return null + } + const { blocked, ready, blocked_by: blockedBy = [] } = graphState.items[itemId] if (ready) { diff --git a/frontend/src/components/GateMapView.jsx b/frontend/src/components/GateMapView.jsx index 717528f..f50a243 100644 --- a/frontend/src/components/GateMapView.jsx +++ b/frontend/src/components/GateMapView.jsx @@ -18,27 +18,29 @@ function truncateTitle(title, max = 28) { return `${title.slice(0, max - 1)}…` } -export function GateMapView({ initiativeId, items }) { +export function GateMapView({ initiativeId, items, graphState: graphStateProp }) { const navigate = useNavigate() const [dependencies, setDependencies] = useState([]) - const [graphState, setGraphState] = useState(null) + const [graphStateLocal, setGraphStateLocal] = useState(null) + const graphState = graphStateProp ?? graphStateLocal const [loading, setLoading] = useState(true) const [error, setError] = useState(null) useEffect(() => { if (!initiativeId) { setDependencies([]) - setGraphState(null) + if (!graphStateProp) setGraphStateLocal(null) setLoading(false) return undefined } let cancelled = false setLoading(true) setError(null) - Promise.allSettled([ - listInitiativeRoadmapDependencies(initiativeId), - listInitiativeRoadmapGraphState(initiativeId), - ]).then(([depsResult, graphResult]) => { + const loaders = [listInitiativeRoadmapDependencies(initiativeId)] + if (!graphStateProp) { + loaders.push(listInitiativeRoadmapGraphState(initiativeId)) + } + Promise.allSettled(loaders).then(([depsResult, graphResult]) => { if (cancelled) return if (depsResult.status === 'fulfilled') { setDependencies(Array.isArray(depsResult.value) ? depsResult.value : []) @@ -47,17 +49,17 @@ export function GateMapView({ initiativeId, items }) { depsResult.reason?.message || 'Abhängigkeiten konnten nicht geladen werden.', ) } - if (graphResult.status === 'fulfilled') { - setGraphState(graphResult.value || null) - } else { - setGraphState(null) + if (!graphStateProp && graphResult?.status === 'fulfilled') { + setGraphStateLocal(graphResult.value || null) + } else if (!graphStateProp) { + setGraphStateLocal(null) } setLoading(false) }) return () => { cancelled = true } - }, [initiativeId]) + }, [initiativeId, graphStateProp]) const layout = useMemo( () => computeGateGraphLayout(items, dependencies), @@ -152,10 +154,11 @@ export function GateMapView({ initiativeId, items }) { {layout.nodes.map((node) => { const item = node.item const nodeGraph = graphState?.items?.[node.id] + const enforceBlocking = graphState?.graph_profile?.enforce_gate_blocking !== false const nodeClass = 'gate-map-view__node gate-map-view__node--' + (item.status || 'planned') + - (nodeGraph?.blocked ? ' gate-map-view__node--blocked' : '') + + (enforceBlocking && nodeGraph?.blocked ? ' gate-map-view__node--blocked' : '') + (nodeGraph?.ready ? ' gate-map-view__node--ready' : '') return ( resolveGatesViewMode(initiativeId, items.length)) + const [graphState, setGraphState] = useState(null) useEffect(() => { setViewMode(resolveGatesViewMode(initiativeId, items.length)) }, [initiativeId, items.length]) + useEffect(() => { + if (!initiativeId) { + setGraphState(null) + return undefined + } + let cancelled = false + listInitiativeRoadmapGraphState(initiativeId) + .then((data) => { + if (!cancelled) setGraphState(data || null) + }) + .catch(() => { + if (!cancelled) setGraphState(null) + }) + return () => { + cancelled = true + } + }, [initiativeId, items]) + function handleViewChange(mode) { setViewMode(mode) storeGatesViewMode(initiativeId, mode) } + const fulfillmentPct = + graphState?.initiative_fulfillment_ratio != null + ? Math.round(graphState.initiative_fulfillment_ratio * 100) + : null + return (
+ {graphState?.graph_profile?.emphasize_fulfillment && fulfillmentPct != null && ( +

+ Erfüllungsgrad (Plan): {fulfillmentPct}% — Reifegrad zählt, kein + Gate-Zwang. +

+ )} + {graphState?.graph_profile?.enforce_gate_blocking === false && + !graphState?.graph_profile?.emphasize_fulfillment && ( +

+ Gates sind optional — der Graph blockiert nicht hart; Reihenfolge über Kanten oder + Liste. +

+ )} +
) diff --git a/frontend/src/components/RoadmapPlanSection.jsx b/frontend/src/components/RoadmapPlanSection.jsx index ce8af86..b7d22f1 100644 --- a/frontend/src/components/RoadmapPlanSection.jsx +++ b/frontend/src/components/RoadmapPlanSection.jsx @@ -2,11 +2,10 @@ import { useEffect, useMemo, useState } from 'react' import { Link } from 'react-router-dom' import { gatePath } from '../utils/routes.js' import { listInitiativeRoadmapCriteriaProgress, listInitiativeRoadmapGraphState } from '../api/roadmap.js' -import { - ROADMAP_ITEM_TYPE_LABELS, -} from '../constants/status.js' +import { ROADMAP_ITEM_TYPE_LABELS } from '../constants/status.js' import { StatusBadge } from './StatusBadge.jsx' import { GateGraphStateBadge } from './GateGraphStateBadge.jsx' +import { GateFulfillmentBadge } from './GateFulfillmentBadge.jsx' import { EmptyState } from './EmptyState.jsx' import { Modal } from './Modal.jsx' import { RoadmapItemForm } from './RoadmapItemForm.jsx' @@ -33,42 +32,46 @@ export function RoadmapPlanSection({ onReorder, onDelete, busy, + graphState: graphStateProp, }) { const [showCreate, setShowCreate] = useState(false) const [dragItemId, setDragItemId] = useState('') const [dropTargetId, setDropTargetId] = useState('') const [criteriaProgress, setCriteriaProgress] = useState({}) - const [graphState, setGraphState] = useState(null) + const [graphStateLocal, setGraphStateLocal] = useState(null) + const graphState = graphStateProp ?? graphStateLocal const isDesktop = useMinWidth(1024) const canReorder = canManage && typeof onReorder === 'function' useEffect(() => { if (!initiativeId) { setCriteriaProgress({}) - setGraphState(null) + if (!graphStateProp) setGraphStateLocal(null) return undefined } let cancelled = false - Promise.all([ - listInitiativeRoadmapCriteriaProgress(initiativeId), - listInitiativeRoadmapGraphState(initiativeId), - ]) - .then(([progressData, graphData]) => { - if (!cancelled) { - setCriteriaProgress(progressData || {}) - setGraphState(graphData || null) + const loaders = [listInitiativeRoadmapCriteriaProgress(initiativeId)] + if (!graphStateProp) { + loaders.push(listInitiativeRoadmapGraphState(initiativeId)) + } + Promise.all(loaders) + .then((results) => { + if (cancelled) return + setCriteriaProgress(results[0] || {}) + if (!graphStateProp && results[1]) { + setGraphStateLocal(results[1] || null) } }) .catch(() => { if (!cancelled) { setCriteriaProgress({}) - setGraphState(null) + if (!graphStateProp) setGraphStateLocal(null) } }) return () => { cancelled = true } - }, [initiativeId, items]) + }, [initiativeId, items, graphStateProp]) const sortedItems = useMemo(() => sortByOrder(items), [items]) @@ -204,6 +207,7 @@ export function RoadmapPlanSection({ itemId={item.id} siblingItems={sortedItems} /> + {canReorder && !isDesktop && (