"""Roadmap service — tenant-scoped Plan/Gates (AP1.4).""" from __future__ import annotations import json from datetime import date from typing import Any, Literal, Optional from psycopg2.extras import RealDictCursor from db import get_connection from services.audit import log_audit from services.initiatives import get_initiative from services.roadmap_criteria import create_default_criterion, prepare_criteria_for_verify RoadmapItemType = Literal["milestone", "review_gate", "maturity_stage", "work_cycle", "join_gate"] RoadmapItemStatus = Literal[ "planned", "active", "at_risk", "reached", "moved", "discarded" ] SequencingMode = Literal["sequential", "parallel", "optional"] DependencyType = Literal["requires", "blocks", "related", "parallel_group", "optional_branch"] ROADMAP_ITEM_TYPES = frozenset({"milestone", "review_gate", "maturity_stage", "work_cycle", "join_gate"}) ROADMAP_ITEM_STATUSES = frozenset( {"planned", "active", "at_risk", "reached", "moved", "discarded"} ) SEQUENCING_MODES = frozenset({"sequential", "parallel", "optional"}) DEPENDENCY_TYPES = frozenset({"requires", "blocks", "related", "parallel_group", "optional_branch"}) TERMINAL_STATUSES = frozenset({"reached", "moved", "discarded"}) def _serialize_row(row: dict[str, Any]) -> dict[str, Any]: result = dict(row) for key in ("id", "tenant_id", "roadmap_id", "initiative_id", "from_item_id", "to_item_id", "strand_project_id"): if result.get(key): result[key] = str(result[key]) if result.get("target_date"): result["target_date"] = ( result["target_date"].isoformat() if hasattr(result["target_date"], "isoformat") else str(result["target_date"]) ) for ts in ("created_at", "updated_at"): if result.get(ts): result[ts] = result[ts].isoformat() dod = result.get("definition_of_done") if isinstance(dod, str): result["definition_of_done"] = json.loads(dod) return result def _validate_item_type(item_type: str) -> None: if item_type not in ROADMAP_ITEM_TYPES: raise ValueError(f"Ungültiger RoadmapItem-Typ: {item_type}") def _validate_status(status: str) -> None: if status not in ROADMAP_ITEM_STATUSES: raise ValueError(f"Ungültiger RoadmapItem-Status: {status}") def _validate_sequencing_mode(mode: str) -> None: if mode not in SEQUENCING_MODES: raise ValueError(f"Ungültiger sequencing_mode: {mode}") def _validate_dependency_type(dep_type: str) -> None: if dep_type not in DEPENDENCY_TYPES: raise ValueError(f"Ungültiger dependency_type: {dep_type}") def _sync_milestone_compat_row( cur, *, tenant_id: str, initiative_id: str, item: dict[str, Any], ) -> None: """Hält milestones-Tabelle für Evidence/Review-FKs synchron (Compat bis Drop).""" if item["item_type"] != "milestone": cur.execute( "DELETE FROM milestones WHERE id = %s AND tenant_id = %s", (item["id"], tenant_id), ) return target_date = item.get("target_date") if target_date and isinstance(target_date, str): target_date = date.fromisoformat(target_date) cur.execute( """ INSERT INTO milestones ( id, tenant_id, initiative_id, title, goal_description, status, target_date ) VALUES (%s, %s, %s, %s, %s, %s, %s) ON CONFLICT (id) DO UPDATE SET title = EXCLUDED.title, goal_description = EXCLUDED.goal_description, status = EXCLUDED.status, target_date = EXCLUDED.target_date, updated_at = NOW() """, ( item["id"], tenant_id, initiative_id, item["title"], item.get("goal_description", ""), item["status"], target_date, ), ) def ensure_roadmap(*, tenant_id: str, initiative_id: str) -> dict[str, Any]: if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id): raise ValueError("Initiative nicht gefunden") conn = get_connection() try: with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute( """ SELECT id, tenant_id, initiative_id, title, created_at, updated_at FROM roadmaps WHERE tenant_id = %s AND initiative_id = %s """, (tenant_id, initiative_id), ) row = cur.fetchone() if row: return _serialize_row(dict(row)) cur.execute( """ INSERT INTO roadmaps (tenant_id, initiative_id, title) VALUES (%s, %s, 'Plan') RETURNING id, tenant_id, initiative_id, title, created_at, updated_at """, (tenant_id, initiative_id), ) result = _serialize_row(dict(cur.fetchone())) conn.commit() return result finally: conn.close() def get_roadmap_for_initiative(*, tenant_id: str, initiative_id: str) -> Optional[dict[str, Any]]: if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id): raise ValueError("Initiative nicht gefunden") conn = get_connection() try: with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute( """ SELECT id, tenant_id, initiative_id, title, created_at, updated_at FROM roadmaps WHERE tenant_id = %s AND initiative_id = %s """, (tenant_id, initiative_id), ) row = cur.fetchone() return _serialize_row(dict(row)) if row else None finally: conn.close() def list_roadmap_items_for_initiative( *, tenant_id: str, initiative_id: str ) -> list[dict[str, Any]]: roadmap = get_roadmap_for_initiative(tenant_id=tenant_id, initiative_id=initiative_id) if not roadmap: return [] conn = get_connection() try: with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute( """ SELECT ri.id, ri.tenant_id, ri.roadmap_id, r.initiative_id, ri.item_type, ri.title, ri.goal_description, ri.definition_of_done, ri.status, ri.sequencing_mode, ri.target_date, ri.sort_order, ri.strand_project_id, ri.created_at, ri.updated_at FROM roadmap_items ri JOIN roadmaps r ON r.id = ri.roadmap_id AND r.tenant_id = ri.tenant_id WHERE ri.tenant_id = %s AND r.initiative_id = %s ORDER BY ri.sort_order ASC, ri.target_date NULLS LAST, ri.title """, (tenant_id, initiative_id), ) return [_serialize_row(dict(r)) for r in cur.fetchall()] finally: conn.close() def get_roadmap_item(*, tenant_id: str, item_id: str) -> Optional[dict[str, Any]]: conn = get_connection() try: with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute( """ SELECT ri.id, ri.tenant_id, ri.roadmap_id, r.initiative_id, ri.item_type, ri.title, ri.goal_description, ri.definition_of_done, ri.status, ri.sequencing_mode, ri.target_date, ri.sort_order, ri.strand_project_id, ri.created_at, ri.updated_at FROM roadmap_items ri JOIN roadmaps r ON r.id = ri.roadmap_id AND r.tenant_id = ri.tenant_id WHERE ri.id = %s AND ri.tenant_id = %s """, (item_id, tenant_id), ) row = cur.fetchone() return _serialize_row(dict(row)) if row else None finally: conn.close() def create_roadmap_item( *, tenant_id: str, initiative_id: str, title: str, item_type: RoadmapItemType = "milestone", goal_description: str = "", definition_of_done: Optional[list[Any]] = None, status: RoadmapItemStatus = "planned", sequencing_mode: SequencingMode = "sequential", target_date: Optional[date] = None, sort_order: int = 0, strand_project_id: Optional[str] = None, initial_criterion_title: Optional[str] = None, user_id: Optional[str] = None, ) -> dict[str, Any]: title = title.strip() if not title: raise ValueError("Titel ist erforderlich") _validate_item_type(item_type) _validate_status(status) if status == "reached": raise ValueError("Status 'reached' nur über verify-reached") _validate_sequencing_mode(sequencing_mode) roadmap = ensure_roadmap(tenant_id=tenant_id, initiative_id=initiative_id) dod = definition_of_done if definition_of_done is not None else [] conn = get_connection() try: with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute( """ INSERT INTO roadmap_items ( tenant_id, roadmap_id, item_type, title, goal_description, definition_of_done, status, sequencing_mode, target_date, sort_order, strand_project_id ) VALUES (%s, %s, %s, %s, %s, %s::jsonb, %s, %s, %s, %s, %s) RETURNING id, tenant_id, roadmap_id, item_type, title, goal_description, definition_of_done, status, sequencing_mode, target_date, sort_order, strand_project_id, created_at, updated_at """, ( tenant_id, roadmap["id"], item_type, title, goal_description, json.dumps(dod), status, sequencing_mode, target_date, sort_order, strand_project_id, ), ) row = _serialize_row(dict(cur.fetchone())) row["initiative_id"] = initiative_id if item_type not in ("work_cycle", "join_gate"): create_default_criterion( cur, tenant_id=tenant_id, roadmap_item_id=row["id"], title=(initial_criterion_title or "Gate allgemein").strip() or "Gate allgemein", description=goal_description or "", ) _sync_milestone_compat_row( cur, tenant_id=tenant_id, initiative_id=initiative_id, item=row, ) conn.commit() finally: conn.close() log_audit( "roadmap_item.created", user_id=user_id, tenant_id=tenant_id, details={ "roadmap_item_id": row["id"], "initiative_id": initiative_id, "item_type": item_type, "title": title, }, ) return row def update_roadmap_item( *, tenant_id: str, item_id: str, user_id: Optional[str] = None, title: Optional[str] = None, goal_description: Optional[str] = None, definition_of_done: Optional[list[Any]] = None, item_type: Optional[RoadmapItemType] = None, status: Optional[RoadmapItemStatus] = None, sequencing_mode: Optional[SequencingMode] = None, target_date: Optional[date] = None, clear_target_date: bool = False, sort_order: Optional[int] = None, strand_project_id: Optional[str] = None, clear_strand_project: bool = False, ) -> Optional[dict[str, Any]]: existing = get_roadmap_item(tenant_id=tenant_id, item_id=item_id) if not existing: return None old_status = existing["status"] updates: list[str] = [] params: list[Any] = [] if title is not None: title = title.strip() if not title: raise ValueError("Titel ist erforderlich") updates.append("title = %s") params.append(title) if goal_description is not None: updates.append("goal_description = %s") params.append(goal_description) if definition_of_done is not None: updates.append("definition_of_done = %s::jsonb") params.append(json.dumps(definition_of_done)) if item_type is not None: _validate_item_type(item_type) updates.append("item_type = %s") params.append(item_type) if status is not None: _validate_status(status) if status in TERMINAL_STATUSES and status == "reached": raise ValueError( "Status 'reached' nur über verify-reached — nicht direkt setzen" ) if status in ("moved", "discarded"): raise ValueError( "Status moved/discarded erfordert Decision — noch nicht implementiert" ) updates.append("status = %s") params.append(status) if sequencing_mode is not None: _validate_sequencing_mode(sequencing_mode) updates.append("sequencing_mode = %s") params.append(sequencing_mode) if clear_target_date: updates.append("target_date = NULL") elif target_date is not None: updates.append("target_date = %s") params.append(target_date) if sort_order is not None: updates.append("sort_order = %s") params.append(sort_order) if clear_strand_project: updates.append("strand_project_id = NULL") elif strand_project_id is not None: updates.append("strand_project_id = %s") params.append(strand_project_id) if not updates: return existing updates.append("updated_at = NOW()") params.extend([item_id, tenant_id]) conn = get_connection() try: with conn.cursor(cursor_factory=RealDictCursor) as cur: if ( status == "active" and existing.get("item_type") == "maturity_stage" ): from services.progression_graph import ( enforce_single_active_work_gate_per_strand, ) enforce_single_active_work_gate_per_strand( cur, tenant_id=tenant_id, initiative_id=str(existing["initiative_id"]), keep_active_id=item_id, strand_project_id=strand_project_id if strand_project_id is not None else existing.get("strand_project_id"), ) cur.execute( f""" UPDATE roadmap_items SET {", ".join(updates)} WHERE id = %s AND tenant_id = %s RETURNING id, tenant_id, roadmap_id, item_type, title, goal_description, definition_of_done, status, sequencing_mode, target_date, sort_order, strand_project_id, created_at, updated_at """, params, ) row = cur.fetchone() if not row: return None result = _serialize_row(dict(row)) result["initiative_id"] = existing["initiative_id"] _sync_milestone_compat_row( cur, tenant_id=tenant_id, initiative_id=existing["initiative_id"], item=result, ) conn.commit() finally: conn.close() log_audit( "roadmap_item.updated", user_id=user_id, tenant_id=tenant_id, details={"roadmap_item_id": item_id}, ) if status is not None and status != old_status: log_audit( "roadmap_item.status_changed", user_id=user_id, tenant_id=tenant_id, details={ "roadmap_item_id": item_id, "from_status": old_status, "to_status": status, }, ) return result def delete_roadmap_item( *, tenant_id: str, item_id: str, user_id: Optional[str] = None ) -> bool: conn = get_connection() try: with conn.cursor() as cur: cur.execute( "DELETE FROM milestones WHERE id = %s AND tenant_id = %s", (item_id, tenant_id), ) cur.execute( "DELETE FROM roadmap_items WHERE id = %s AND tenant_id = %s RETURNING id", (item_id, tenant_id), ) deleted = cur.fetchone() is not None conn.commit() finally: conn.close() if deleted: log_audit( "roadmap_item.deleted", user_id=user_id, tenant_id=tenant_id, details={"roadmap_item_id": item_id}, ) 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: raise ValueError("RoadmapItem nicht gefunden") conn = get_connection() try: with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute( """ 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), ) return [_serialize_dependency(dict(row)) for row in cur.fetchall()] finally: conn.close() def list_dependencies_for_initiative( *, tenant_id: str, initiative_id: str ) -> list[dict[str, Any]]: if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id): raise ValueError("Initiative nicht gefunden") conn = get_connection() try: with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute( """ SELECT d.id, d.tenant_id, d.from_item_id, d.to_item_id, 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 INNER JOIN roadmaps r ON r.id = fi.roadmap_id AND r.tenant_id = fi.tenant_id WHERE d.tenant_id = %s AND r.initiative_id = %s ORDER BY d.created_at ASC """, (tenant_id, initiative_id), ) return [_serialize_dependency(dict(row)) for row in cur.fetchall()] finally: conn.close() def add_dependency( *, tenant_id: str, 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) if from_item_id == to_item_id: raise ValueError("Abhängigkeit auf sich selbst nicht erlaubt") from_item = get_roadmap_item(tenant_id=tenant_id, item_id=from_item_id) to_item = get_roadmap_item(tenant_id=tenant_id, item_id=to_item_id) if not from_item or not to_item: raise ValueError("RoadmapItem nicht gefunden") 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: cur.execute( """ SELECT 1 FROM roadmap_item_dependencies WHERE tenant_id = %s AND from_item_id = %s AND to_item_id = %s AND dependency_type = %s """, (tenant_id, from_item_id, to_item_id, dependency_type), ) if cur.fetchone(): raise ValueError("Abhängigkeit existiert bereits") cur.execute( """ INSERT INTO roadmap_item_dependencies ( tenant_id, from_item_id, to_item_id, dependency_type, group_key ) 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, normalized_group_key), ) row = _serialize_dependency(dict(cur.fetchone())) conn.commit() finally: conn.close() log_audit( "roadmap_item.dependency_added", user_id=user_id, tenant_id=tenant_id, details={ "from_item_id": from_item_id, "to_item_id": to_item_id, "dependency_type": dependency_type, "group_key": normalized_group_key, }, ) return row def delete_dependency( *, tenant_id: str, dependency_id: str, user_id: Optional[str] = None ) -> bool: conn = get_connection() try: with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute( """ SELECT id, from_item_id, to_item_id, dependency_type FROM roadmap_item_dependencies WHERE id = %s AND tenant_id = %s """, (dependency_id, tenant_id), ) row = cur.fetchone() if not row: raise ValueError("Abhängigkeit nicht gefunden") cur.execute( """ DELETE FROM roadmap_item_dependencies WHERE id = %s AND tenant_id = %s """, (dependency_id, tenant_id), ) conn.commit() finally: conn.close() log_audit( "roadmap_item.dependency_removed", user_id=user_id, tenant_id=tenant_id, details={ "dependency_id": dependency_id, "from_item_id": str(row["from_item_id"]), "to_item_id": str(row["to_item_id"]), "dependency_type": row["dependency_type"], }, ) return True def verify_reached( *, tenant_id: str, item_id: str, user_id: Optional[str] = None ) -> dict[str, Any]: """Gate-Verify: reached wenn alle Kriterien erfüllt (satisfied/waived/deferred).""" item = get_roadmap_item(tenant_id=tenant_id, item_id=item_id) if not item: raise ValueError("RoadmapItem nicht gefunden") if item["status"] in TERMINAL_STATUSES: raise ValueError(f"RoadmapItem bereits terminal: {item['status']}") if item.get("item_type") == "join_gate": raise ValueError( "Join-Knoten schaltet automatisch, wenn alle Eingänge erreicht sind — kein manuelles Verify" ) initiative_id = item["initiative_id"] conn = get_connection() try: with conn.cursor(cursor_factory=RealDictCursor) as cur: criteria_ok, criteria_reason = prepare_criteria_for_verify( cur, tenant_id=tenant_id, initiative_id=initiative_id, item_id=item_id, ) verify_reason = "criteria_ready" if not criteria_ok: cur.execute( """ SELECT 1 FROM decisions WHERE tenant_id = %s AND initiative_id = %s AND status = 'decided' AND title ILIKE %s LIMIT 1 """, (tenant_id, initiative_id, f"gate_override:{item_id}%"), ) if cur.fetchone(): verify_reason = "gate_override_decision" criteria_ok = True else: raise ValueError( f"Verify fehlgeschlagen — {criteria_reason}. " "Kriterien in der Checkliste prüfen oder Nachweis am Plan-Element einreichen." ) elif criteria_reason != "no_criteria": verify_reason = criteria_reason cur.execute( """ UPDATE roadmap_items SET status = 'reached', updated_at = NOW() WHERE id = %s AND tenant_id = %s RETURNING id, tenant_id, roadmap_id, item_type, title, goal_description, definition_of_done, status, sequencing_mode, target_date, sort_order, strand_project_id, created_at, updated_at """, (item_id, tenant_id), ) row = _serialize_row(dict(cur.fetchone())) row["initiative_id"] = initiative_id row["verify_reason"] = verify_reason _sync_milestone_compat_row( cur, tenant_id=tenant_id, initiative_id=initiative_id, item=row, ) conn.commit() finally: conn.close() log_audit( "roadmap_item.reached", user_id=user_id, tenant_id=tenant_id, details={"roadmap_item_id": item_id, "verify_reason": verify_reason}, ) if row.get("item_type") == "maturity_stage": from services.progression_graph import on_work_gate_reached transition = on_work_gate_reached( tenant_id=tenant_id, initiative_id=initiative_id, reached_item_id=item_id, user_id=user_id, ) if transition: row["maturity_transition"] = transition elif row.get("item_type") != "work_cycle": from services.steering_events import emit_steering_event steering = emit_steering_event( tenant_id=tenant_id, initiative_id=initiative_id, event_kind="gate_verified", payload={ "roadmap_item_id": item_id, "item_type": row.get("item_type"), "verify_reason": verify_reason, }, user_id=user_id, ) row["steering_event"] = { "handled": steering.get("handled"), "lifecycle_chain": steering.get("lifecycle_chain"), "lifecycle_state": steering.get("evaluation", {}) .get("lifecycle", {}) .get("current_state"), } return row def reopen_roadmap_item( *, tenant_id: str, item_id: str, user_id: Optional[str] = None, reason: str = "", ) -> dict[str, Any]: """Reopen reached → active; Kriterien-Status bleibt unverändert.""" item = get_roadmap_item(tenant_id=tenant_id, item_id=item_id) if not item: raise ValueError("RoadmapItem nicht gefunden") if item["status"] != "reached": raise ValueError("Reopen nur für erreichte Plan-Elemente (status=reached)") conn = get_connection() try: with conn.cursor(cursor_factory=RealDictCursor) as cur: if item.get("item_type") == "maturity_stage": from services.progression_graph import ( enforce_single_active_work_gate_per_strand, ) enforce_single_active_work_gate_per_strand( cur, tenant_id=tenant_id, initiative_id=str(item["initiative_id"]), keep_active_id=item_id, strand_project_id=item.get("strand_project_id"), ) cur.execute( """ UPDATE roadmap_items SET status = 'active', updated_at = NOW() WHERE id = %s AND tenant_id = %s RETURNING id, tenant_id, roadmap_id, item_type, title, goal_description, definition_of_done, status, sequencing_mode, target_date, sort_order, strand_project_id, created_at, updated_at """, (item_id, tenant_id), ) row = _serialize_row(dict(cur.fetchone())) row["initiative_id"] = item["initiative_id"] _sync_milestone_compat_row( cur, tenant_id=tenant_id, initiative_id=item["initiative_id"], item=row, ) conn.commit() finally: conn.close() if row.get("item_type") == "maturity_stage": from services.maturity_stage_transition import on_maturity_stage_reopened row["maturity_practice"] = on_maturity_stage_reopened( tenant_id=tenant_id, initiative_id=str(item["initiative_id"]), stage_id=item_id, user_id=user_id, ) log_audit( "roadmap_item.reopened", user_id=user_id, tenant_id=tenant_id, details={ "roadmap_item_id": item_id, "reason": reason.strip() or None, "from_status": "reached", "to_status": "active", }, ) if row.get("item_type") != "work_cycle": from services.steering_events import emit_steering_event steering = emit_steering_event( tenant_id=tenant_id, initiative_id=str(row["initiative_id"]), event_kind="gate_reopened", payload={ "roadmap_item_id": item_id, "item_type": row.get("item_type"), "reason": reason.strip() or None, }, user_id=user_id, ) row["steering_event"] = { "handled": steering.get("handled"), "lifecycle_chain": steering.get("lifecycle_chain"), } return row