All checks were successful
Deploy Development / deploy (push) Successful in 47s
Test Suite / pytest-backend (push) Successful in 2m13s
Test Suite / lint-backend (push) Successful in 2s
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 12s
Kriterien-Fortschritt in der Gate-Liste, Modal-UX fuer Checklisten und optionales Erst-Kriterium beim Anlegen. Co-authored-by: Cursor <cursoragent@cursor.com>
762 lines
26 KiB
Python
762 lines
26 KiB
Python
"""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"]
|
|
RoadmapItemStatus = Literal[
|
|
"planned", "active", "at_risk", "reached", "moved", "discarded"
|
|
]
|
|
SequencingMode = Literal["sequential", "parallel", "optional"]
|
|
DependencyType = Literal["requires", "blocks", "related"]
|
|
|
|
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"})
|
|
|
|
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"):
|
|
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.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.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,
|
|
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
|
|
)
|
|
VALUES (%s, %s, %s, %s, %s, %s::jsonb, %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, created_at, updated_at
|
|
""",
|
|
(
|
|
tenant_id,
|
|
roadmap["id"],
|
|
item_type,
|
|
title,
|
|
goal_description,
|
|
json.dumps(dod),
|
|
status,
|
|
sequencing_mode,
|
|
target_date,
|
|
sort_order,
|
|
),
|
|
)
|
|
row = _serialize_row(dict(cur.fetchone()))
|
|
row["initiative_id"] = initiative_id
|
|
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,
|
|
) -> 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 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:
|
|
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, 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 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, 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
|
|
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.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),
|
|
)
|
|
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
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def add_dependency(
|
|
*,
|
|
tenant_id: str,
|
|
from_item_id: str,
|
|
to_item_id: str,
|
|
dependency_type: DependencyType = "requires",
|
|
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")
|
|
|
|
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
|
|
)
|
|
VALUES (%s, %s, %s, %s)
|
|
RETURNING id, tenant_id, from_item_id, to_item_id, dependency_type, created_at
|
|
""",
|
|
(tenant_id, from_item_id, to_item_id, dependency_type),
|
|
)
|
|
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()
|
|
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,
|
|
},
|
|
)
|
|
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']}")
|
|
|
|
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, 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},
|
|
)
|
|
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:
|
|
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, 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()
|
|
|
|
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",
|
|
},
|
|
)
|
|
return row
|