"""Plan/Ist-Verknüpfung — Gates, Journey, Beiträge (AP1.6).""" from __future__ import annotations from typing import Any, Literal, Optional from psycopg2.extras import RealDictCursor from db import get_connection from services.initiatives import get_initiative JourneyEventKind = Literal[ "decision", "review", "evidence", "action", "backlog", "gate", ] def validate_roadmap_item_in_initiative( cur, *, tenant_id: str, initiative_id: str, roadmap_item_id: Optional[str], ) -> None: if not roadmap_item_id: return cur.execute( """ SELECT 1 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 AND r.initiative_id = %s """, (roadmap_item_id, tenant_id, initiative_id), ) if not cur.fetchone(): raise ValueError("Gate gehört nicht zu diesem Vorhaben") def _iso(value: Any) -> Optional[str]: if not value: return None return value.isoformat() if hasattr(value, "isoformat") else str(value) def list_gate_contributions( *, tenant_id: str, roadmap_item_id: str ) -> dict[str, Any]: conn = get_connection() try: with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute( """ SELECT ri.id, ri.title, ri.status, r.initiative_id 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 """, (roadmap_item_id, tenant_id), ) gate = cur.fetchone() if not gate: raise ValueError("Gate nicht gefunden") initiative_id = str(gate["initiative_id"]) cur.execute( """ SELECT id, title, status, priority, converted_action_id FROM backlog_items WHERE tenant_id = %s AND initiative_id = %s AND roadmap_item_id = %s ORDER BY updated_at DESC """, (tenant_id, initiative_id, roadmap_item_id), ) backlog = [dict(r) for r in cur.fetchall()] cur.execute( """ SELECT id, title, status, priority, project_id FROM actions WHERE tenant_id = %s AND initiative_id = %s AND roadmap_item_id = %s ORDER BY updated_at DESC """, (tenant_id, initiative_id, roadmap_item_id), ) actions = [dict(r) for r in cur.fetchall()] cur.execute( """ SELECT id, title, status, parent_project_id FROM projects WHERE tenant_id = %s AND initiative_id = %s AND roadmap_item_id = %s ORDER BY updated_at DESC """, (tenant_id, initiative_id, roadmap_item_id), ) projects = [dict(r) for r in cur.fetchall()] cur.execute( """ SELECT t.id, t.title, t.status, t.action_id, a.title AS action_title FROM tasks t JOIN actions a ON a.id = t.action_id AND a.tenant_id = t.tenant_id WHERE t.tenant_id = %s AND a.initiative_id = %s AND t.roadmap_item_id = %s ORDER BY t.updated_at DESC """, (tenant_id, initiative_id, roadmap_item_id), ) tasks = [dict(r) for r in cur.fetchall()] finally: conn.close() def _ids(rows: list[dict]) -> list[dict[str, Any]]: out = [] for row in rows: item = dict(row) for key in item: if item[key] is not None and key.endswith("_id"): item[key] = str(item[key]) out.append(item) return out return { "roadmap_item_id": roadmap_item_id, "initiative_id": initiative_id, "gate_title": gate["title"], "gate_status": gate["status"], "backlog_items": _ids(backlog), "actions": _ids(actions), "projects": _ids(projects), "tasks": _ids(tasks), "counts": { "backlog": len(backlog), "actions": len(actions), "projects": len(projects), "tasks": len(tasks), "total": len(backlog) + len(actions) + len(projects) + len(tasks), }, } def list_journey_events(*, 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") events: list[dict[str, Any]] = [] conn = get_connection() try: with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute( """ SELECT id, title, status, outcome, created_at, updated_at FROM decisions WHERE tenant_id = %s AND initiative_id = %s """, (tenant_id, initiative_id), ) for row in cur.fetchall(): events.append( { "kind": "decision", "entity_id": str(row["id"]), "title": row["title"], "status": row["status"], "summary": row["outcome"] or row["status"], "occurred_at": _iso(row["updated_at"] or row["created_at"]), "roadmap_item_id": None, } ) cur.execute( """ SELECT id, title, status, summary, created_at, updated_at, milestone_id, roadmap_item_id FROM reviews WHERE tenant_id = %s AND initiative_id = %s """, (tenant_id, initiative_id), ) for row in cur.fetchall(): rid = row.get("roadmap_item_id") or row.get("milestone_id") events.append( { "kind": "review", "entity_id": str(row["id"]), "title": row["title"], "status": row["status"], "summary": row["summary"] or row["status"], "occurred_at": _iso(row["updated_at"] or row["created_at"]), "roadmap_item_id": str(rid) if rid else None, } ) cur.execute( """ SELECT id, title, status, description, created_at, updated_at, roadmap_item_id, action_id FROM evidence WHERE tenant_id = %s AND initiative_id = %s """, (tenant_id, initiative_id), ) for row in cur.fetchall(): events.append( { "kind": "evidence", "entity_id": str(row["id"]), "title": row["title"], "status": row["status"], "summary": row["description"] or row["status"], "occurred_at": _iso(row["updated_at"] or row["created_at"]), "roadmap_item_id": ( str(row["roadmap_item_id"]) if row.get("roadmap_item_id") else None ), "action_id": str(row["action_id"]) if row.get("action_id") else None, } ) cur.execute( """ SELECT id, title, status, created_at, updated_at, roadmap_item_id, project_id FROM actions WHERE tenant_id = %s AND initiative_id = %s """, (tenant_id, initiative_id), ) for row in cur.fetchall(): events.append( { "kind": "action", "entity_id": str(row["id"]), "title": row["title"], "status": row["status"], "summary": f"Arbeitspaket — {row['status']}", "occurred_at": _iso(row["created_at"]), "roadmap_item_id": ( str(row["roadmap_item_id"]) if row.get("roadmap_item_id") else None ), "project_id": str(row["project_id"]) if row.get("project_id") else None, } ) if row["status"] in ("done", "discarded") and row["updated_at"] != row["created_at"]: events.append( { "kind": "action", "entity_id": str(row["id"]), "title": row["title"], "status": row["status"], "summary": f"Arbeitspaket abgeschlossen — {row['status']}", "occurred_at": _iso(row["updated_at"]), "roadmap_item_id": ( str(row["roadmap_item_id"]) if row.get("roadmap_item_id") else None ), "project_id": ( str(row["project_id"]) if row.get("project_id") else None ), } ) cur.execute( """ SELECT id, title, status, created_at, updated_at, roadmap_item_id, converted_action_id FROM backlog_items WHERE tenant_id = %s AND initiative_id = %s """, (tenant_id, initiative_id), ) for row in cur.fetchall(): if row["status"] == "converted": events.append( { "kind": "backlog", "entity_id": str(row["id"]), "title": row["title"], "status": row["status"], "summary": "Backlog → Arbeitspaket committet", "occurred_at": _iso(row["updated_at"]), "roadmap_item_id": ( str(row["roadmap_item_id"]) if row.get("roadmap_item_id") else None ), "action_id": ( str(row["converted_action_id"]) if row.get("converted_action_id") else None ), } ) cur.execute( """ SELECT ri.id, ri.title, ri.status, ri.updated_at, ri.created_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 AND ri.status IN ('reached', 'moved', 'discarded', 'at_risk') """, (tenant_id, initiative_id), ) for row in cur.fetchall(): events.append( { "kind": "gate", "entity_id": str(row["id"]), "title": row["title"], "status": row["status"], "summary": f"Zielzustand — {row['status']}", "occurred_at": _iso(row["updated_at"] or row["created_at"]), "roadmap_item_id": str(row["id"]), } ) finally: conn.close() events = [e for e in events if e.get("occurred_at")] events.sort(key=lambda e: e["occurred_at"], reverse=True) return events