Some checks failed
Deploy Development / deploy (push) Successful in 52s
Test Suite / pytest-backend (push) Failing after 5m3s
Test Suite / k6 /api/health Baseline (push) Has been skipped
Test Suite / playwright-smoke (push) Has been skipped
Test Suite / lint-backend (push) Successful in 3s
Test Suite / compose-smoke (push) Has been skipped
join_gate, strand_project_id, Join-Auto-Schalten, multi-active Gates, Progression-Demo und PO-Lock-Spec. FE: Today strang-gruppiert, Join-Warteliste in Kontrolle. Co-authored-by: Cursor <cursoragent@cursor.com>
871 lines
30 KiB
Python
871 lines
30 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", "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
|