Kairo-Jinkendo/backend/services/plan_ist.py
Lars 7e4936a2e4
Some checks failed
Deploy Development / deploy (push) Failing after 37s
Test Suite / pytest-backend (push) Successful in 1m41s
Test Suite / lint-backend (push) Successful in 1s
Test Suite / compose-smoke (push) Has been skipped
Test Suite / k6 /api/health Baseline (push) Successful in 18s
Test Suite / playwright-smoke (push) Successful in 13s
AP1.6: Plan/Ist-Verknuepfung und Journey-Timeline.
Backlog, Actions, Projects und Tasks koennen Gates zugeordnet werden; Gate-Beitragsliste und chronologische Journey machen Plan/Ist nachvollziehbar.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 08:43:11 +02:00

323 lines
12 KiB
Python

"""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