diff --git a/backend/main.py b/backend/main.py
index a406bb7..b60b845 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -88,6 +88,7 @@ app.include_router(backlog.router)
app.include_router(milestones.router)
app.include_router(roadmap.initiative_router)
app.include_router(roadmap.items_router)
+app.include_router(roadmap.criteria_router)
app.include_router(evidence.router)
app.include_router(decisions.router)
app.include_router(reviews.router)
diff --git a/backend/migrations/012_roadmap_item_criteria.sql b/backend/migrations/012_roadmap_item_criteria.sql
new file mode 100644
index 0000000..ab3edc0
--- /dev/null
+++ b/backend/migrations/012_roadmap_item_criteria.sql
@@ -0,0 +1,76 @@
+-- AP1.4b: Gate-Checkliste (Prüfplan pro RoadmapItem)
+
+CREATE TABLE roadmap_item_criteria (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
+ roadmap_item_id UUID NOT NULL REFERENCES roadmap_items(id) ON DELETE CASCADE,
+ sort_order INT NOT NULL DEFAULT 0,
+ title TEXT NOT NULL,
+ description TEXT NOT NULL DEFAULT '',
+ criterion_kind VARCHAR(32) NOT NULL DEFAULT 'evidence_required'
+ CHECK (criterion_kind IN ('manual', 'evidence_required', 'review_required', 'metric')),
+ verification_hint TEXT NOT NULL DEFAULT '',
+ status VARCHAR(32) NOT NULL DEFAULT 'open'
+ CHECK (status IN ('open', 'satisfied', 'waived', 'deferred', 'failed')),
+ waived_or_deferred_decision_id UUID NULL REFERENCES decisions(id) ON DELETE SET NULL,
+ satisfied_by_evidence_id UUID NULL REFERENCES evidence(id) ON DELETE SET NULL,
+ satisfied_at TIMESTAMPTZ NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX idx_roadmap_criteria_item ON roadmap_item_criteria(tenant_id, roadmap_item_id);
+CREATE INDEX idx_roadmap_criteria_status ON roadmap_item_criteria(tenant_id, status);
+
+-- Compat: Default-Kriterium pro bestehendem Plan-Element
+INSERT INTO roadmap_item_criteria (
+ tenant_id, roadmap_item_id, sort_order, title, description, criterion_kind, status
+)
+SELECT
+ ri.tenant_id,
+ ri.id,
+ 0,
+ 'Gate allgemein',
+ COALESCE(NULLIF(ri.goal_description, ''), 'Akzeptierter Nachweis oder abgeschlossenes Review am Plan-Element.'),
+ 'evidence_required',
+ 'open'
+FROM roadmap_items ri;
+
+-- Bereits vorhandene accepted Evidence → Default-Kriterium satisfied
+UPDATE roadmap_item_criteria ric
+SET
+ status = 'satisfied',
+ satisfied_at = NOW(),
+ satisfied_by_evidence_id = sub.evidence_id
+FROM (
+ SELECT DISTINCT ON (ric2.id)
+ ric2.id AS criterion_id,
+ e.id AS evidence_id
+ FROM roadmap_item_criteria ric2
+ JOIN evidence e
+ ON e.tenant_id = ric2.tenant_id
+ AND e.roadmap_item_id = ric2.roadmap_item_id
+ AND e.status = 'accepted'
+ WHERE ric2.title = 'Gate allgemein'
+ ORDER BY ric2.id, e.updated_at DESC
+) sub
+WHERE ric.id = sub.criterion_id;
+
+-- Bereits reached Gates ohne Evidence: Review completed
+UPDATE roadmap_item_criteria ric
+SET status = 'satisfied', satisfied_at = NOW()
+FROM roadmap_items ri
+WHERE ric.roadmap_item_id = ri.id
+ AND ric.tenant_id = ri.tenant_id
+ AND ric.title = 'Gate allgemein'
+ AND ric.status = 'open'
+ AND ri.status = 'reached'
+ AND EXISTS (
+ SELECT 1 FROM reviews rev
+ WHERE rev.tenant_id = ri.tenant_id
+ AND rev.initiative_id IN (
+ SELECT r.initiative_id FROM roadmaps r WHERE r.id = ri.roadmap_id
+ )
+ AND rev.status = 'completed'
+ AND (rev.roadmap_item_id = ri.id OR rev.milestone_id = ri.id)
+ );
diff --git a/backend/rights_registrations/operating_model_ops.py b/backend/rights_registrations/operating_model_ops.py
index 0fbcc4d..5320c6a 100644
--- a/backend/rights_registrations/operating_model_ops.py
+++ b/backend/rights_registrations/operating_model_ops.py
@@ -153,3 +153,21 @@ register_capability(
default_grants=_MEMBER_MANAGE,
)
)
+
+register_capability(
+ CapabilityRegistration(
+ key="kairo.roadmap.reopen",
+ module="roadmap",
+ description="Erreichte Plan-Elemente wieder öffnen (Reopen)",
+ default_grants=_MEMBER_MANAGE,
+ )
+)
+
+register_capability(
+ CapabilityRegistration(
+ key="kairo.roadmap.reopen.emergency",
+ module="roadmap",
+ description="Plan-Reopen als Portal-Admin (Notfall)",
+ default_grants=(("portal", "admin"),),
+ )
+)
diff --git a/backend/routers/roadmap.py b/backend/routers/roadmap.py
index 015044c..8065d51 100644
--- a/backend/routers/roadmap.py
+++ b/backend/routers/roadmap.py
@@ -9,6 +9,7 @@ from capabilities import require_capability
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from services import roadmap as roadmap_service
+from services import roadmap_criteria as criteria_service
from tenant_context import TenantContext
router = APIRouter(tags=["roadmap"])
@@ -46,6 +47,38 @@ class DependencyCreateRequest(BaseModel):
dependency_type: Literal["requires", "blocks", "related"] = "requires"
+class CriterionCreateRequest(BaseModel):
+ title: str = Field(min_length=1, max_length=500)
+ description: str = ""
+ criterion_kind: Literal["manual", "evidence_required", "review_required", "metric"] = "manual"
+ verification_hint: str = ""
+ sort_order: int = 0
+
+
+class CriterionUpdateRequest(BaseModel):
+ title: Optional[str] = Field(default=None, min_length=1, max_length=500)
+ description: Optional[str] = None
+ criterion_kind: Optional[
+ Literal["manual", "evidence_required", "review_required", "metric"]
+ ] = None
+ verification_hint: Optional[str] = None
+ sort_order: Optional[int] = None
+ change_reason: str = ""
+
+
+class CriterionWaiveDeferRequest(BaseModel):
+ decision_title: str = Field(min_length=1, max_length=255)
+ decision_description: str = ""
+
+
+class CriterionSatisfyRequest(BaseModel):
+ evidence_id: Optional[str] = None
+
+
+class ReopenRequest(BaseModel):
+ reason: str = ""
+
+
initiative_router = APIRouter(prefix="/api/initiatives", tags=["roadmap"])
items_router = APIRouter(prefix="/api/roadmap-items", tags=["roadmap"])
@@ -169,6 +202,156 @@ def verify_roadmap_item_reached(
raise HTTPException(status_code=400, detail=str(exc)) from exc
+@items_router.post("/{item_id}/reopen")
+def reopen_roadmap_item(
+ item_id: str,
+ body: ReopenRequest,
+ ctx: TenantContext = Depends(require_capability("kairo.roadmap.reopen")),
+):
+ try:
+ return roadmap_service.reopen_roadmap_item(
+ tenant_id=ctx.tenant_id,
+ item_id=item_id,
+ user_id=ctx.user_id,
+ reason=body.reason,
+ )
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+
+
+@items_router.get("/{item_id}/criteria")
+def list_roadmap_item_criteria(
+ item_id: str,
+ ctx: TenantContext = Depends(require_capability("kairo.milestone.read")),
+):
+ try:
+ progress = criteria_service.criteria_progress(
+ tenant_id=ctx.tenant_id, item_id=item_id
+ )
+ items = criteria_service.list_criteria_for_item(
+ tenant_id=ctx.tenant_id, item_id=item_id
+ )
+ return {"items": items, "progress": progress}
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+
+
+@items_router.post("/{item_id}/criteria", status_code=201)
+def create_roadmap_item_criterion(
+ item_id: str,
+ body: CriterionCreateRequest,
+ ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")),
+):
+ try:
+ return criteria_service.create_criterion(
+ tenant_id=ctx.tenant_id,
+ item_id=item_id,
+ user_id=ctx.user_id,
+ title=body.title,
+ description=body.description,
+ criterion_kind=body.criterion_kind,
+ verification_hint=body.verification_hint,
+ sort_order=body.sort_order,
+ )
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+
+
+criteria_router = APIRouter(prefix="/api/roadmap-criteria", tags=["roadmap"])
+
+
+@criteria_router.patch("/{criterion_id}")
+def update_roadmap_criterion(
+ criterion_id: str,
+ body: CriterionUpdateRequest,
+ ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")),
+):
+ try:
+ item = criteria_service.update_criterion(
+ tenant_id=ctx.tenant_id,
+ criterion_id=criterion_id,
+ user_id=ctx.user_id,
+ title=body.title,
+ description=body.description,
+ criterion_kind=body.criterion_kind,
+ verification_hint=body.verification_hint,
+ sort_order=body.sort_order,
+ change_reason=body.change_reason,
+ )
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+ if not item:
+ raise HTTPException(status_code=404, detail="Kriterium nicht gefunden")
+ return item
+
+
+@criteria_router.delete("/{criterion_id}", status_code=204)
+def delete_roadmap_criterion(
+ criterion_id: str,
+ ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")),
+):
+ try:
+ if not criteria_service.delete_criterion(
+ tenant_id=ctx.tenant_id, criterion_id=criterion_id, user_id=ctx.user_id
+ ):
+ raise HTTPException(status_code=404, detail="Kriterium nicht gefunden")
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+
+
+@criteria_router.post("/{criterion_id}/satisfy")
+def satisfy_roadmap_criterion(
+ criterion_id: str,
+ body: CriterionSatisfyRequest,
+ ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")),
+):
+ try:
+ return criteria_service.satisfy_criterion(
+ tenant_id=ctx.tenant_id,
+ criterion_id=criterion_id,
+ user_id=ctx.user_id,
+ evidence_id=body.evidence_id,
+ )
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+
+
+@criteria_router.post("/{criterion_id}/waive")
+def waive_roadmap_criterion(
+ criterion_id: str,
+ body: CriterionWaiveDeferRequest,
+ ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")),
+):
+ try:
+ return criteria_service.waive_criterion(
+ tenant_id=ctx.tenant_id,
+ criterion_id=criterion_id,
+ user_id=ctx.user_id,
+ decision_title=body.decision_title,
+ decision_description=body.decision_description,
+ )
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+
+
+@criteria_router.post("/{criterion_id}/defer")
+def defer_roadmap_criterion(
+ criterion_id: str,
+ body: CriterionWaiveDeferRequest,
+ ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")),
+):
+ try:
+ return criteria_service.defer_criterion(
+ tenant_id=ctx.tenant_id,
+ criterion_id=criterion_id,
+ user_id=ctx.user_id,
+ decision_title=body.decision_title,
+ decision_description=body.decision_description,
+ )
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+
+
@items_router.get("/{item_id}/dependencies")
def list_roadmap_item_dependencies(
item_id: str,
diff --git a/backend/services/roadmap.py b/backend/services/roadmap.py
index 4deb4a1..a74b1d8 100644
--- a/backend/services/roadmap.py
+++ b/backend/services/roadmap.py
@@ -11,6 +11,7 @@ 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[
@@ -275,6 +276,12 @@ def create_roadmap_item(
)
row = _serialize_row(dict(cur.fetchone()))
row["initiative_id"] = initiative_id
+ create_default_criterion(
+ cur,
+ tenant_id=tenant_id,
+ roadmap_item_id=row["id"],
+ description=goal_description or "",
+ )
_sync_milestone_compat_row(
cur,
tenant_id=tenant_id,
@@ -533,7 +540,7 @@ def add_dependency(
def verify_reached(
*, tenant_id: str, item_id: str, user_id: Optional[str] = None
) -> dict[str, Any]:
- """Gate-Verify: reached nur mit accepted Evidence, completed Review oder gate_override Decision."""
+ """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")
@@ -544,51 +551,35 @@ def verify_reached(
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
- cur.execute(
- """
- SELECT 1 FROM evidence
- WHERE tenant_id = %s AND initiative_id = %s
- AND status = 'accepted'
- AND (roadmap_item_id = %s OR milestone_id = %s)
- LIMIT 1
- """,
- (tenant_id, initiative_id, item_id, item_id),
+ criteria_ok, criteria_reason = prepare_criteria_for_verify(
+ cur,
+ tenant_id=tenant_id,
+ initiative_id=initiative_id,
+ item_id=item_id,
)
- if cur.fetchone():
- verify_reason = "evidence_accepted"
- else:
+ verify_reason = "criteria_ready"
+
+ if not criteria_ok:
cur.execute(
"""
- SELECT 1 FROM reviews
+ SELECT 1 FROM decisions
WHERE tenant_id = %s AND initiative_id = %s
- AND status = 'completed'
- AND (roadmap_item_id = %s OR milestone_id = %s)
+ AND status = 'decided'
+ AND title ILIKE %s
LIMIT 1
""",
- (tenant_id, initiative_id, item_id, item_id),
+ (tenant_id, initiative_id, f"gate_override:{item_id}%"),
)
if cur.fetchone():
- verify_reason = "review_completed"
+ verify_reason = "gate_override_decision"
+ criteria_ok = True
else:
- 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}%"),
+ raise ValueError(
+ f"Verify fehlgeschlagen — {criteria_reason}. "
+ "Kriterien in der Checkliste prüfen oder Nachweis am Plan-Element einreichen."
)
- if cur.fetchone():
- verify_reason = "gate_override_decision"
- else:
- raise ValueError(
- "Verify fehlgeschlagen — am Plan-Element fehlt ein akzeptierter "
- "Nachweis (Evidence), ein abgeschlossenes Review oder eine "
- "gate_override-Decision. Nachweis unter Plan einreichen oder auf "
- "der Journey-Seite mit Plan-Element verknüpfen und auf Akzeptiert setzen."
- )
+ elif criteria_reason != "no_criteria":
+ verify_reason = criteria_reason
cur.execute(
"""
@@ -621,3 +612,57 @@ def verify_reached(
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
diff --git a/backend/services/roadmap_criteria.py b/backend/services/roadmap_criteria.py
new file mode 100644
index 0000000..beed977
--- /dev/null
+++ b/backend/services/roadmap_criteria.py
@@ -0,0 +1,652 @@
+"""RoadmapItem criteria / gate checklist service (AP1.4b)."""
+
+from __future__ import annotations
+
+from datetime import datetime, timezone
+from typing import Any, Literal, Optional
+
+from psycopg2.extras import RealDictCursor
+
+from db import get_connection
+from services.audit import log_audit
+from services.decisions import create_decision
+
+CriterionKind = Literal["manual", "evidence_required", "review_required", "metric"]
+CriterionStatus = Literal["open", "satisfied", "waived", "deferred", "failed"]
+
+CRITERION_KINDS = frozenset({"manual", "evidence_required", "review_required", "metric"})
+CRITERION_STATUSES = frozenset({"open", "satisfied", "waived", "deferred", "failed"})
+CLOSED_CRITERION_STATUSES = frozenset({"satisfied", "waived", "deferred"})
+_TERMINAL_ITEM_STATUSES = frozenset({"reached", "moved", "discarded"})
+
+_CRITERION_COLUMNS = """
+ id, tenant_id, roadmap_item_id, sort_order, title, description,
+ criterion_kind, verification_hint, status,
+ waived_or_deferred_decision_id, satisfied_by_evidence_id, satisfied_at,
+ created_at, updated_at
+"""
+
+
+def _serialize_row(row: dict[str, Any]) -> dict[str, Any]:
+ result = dict(row)
+ for key in (
+ "id",
+ "tenant_id",
+ "roadmap_item_id",
+ "waived_or_deferred_decision_id",
+ "satisfied_by_evidence_id",
+ ):
+ if result.get(key):
+ result[key] = str(result[key])
+ for ts in ("satisfied_at", "created_at", "updated_at"):
+ if result.get(ts):
+ result[ts] = result[ts].isoformat()
+ return result
+
+
+def _validate_kind(kind: str) -> None:
+ if kind not in CRITERION_KINDS:
+ raise ValueError(f"Ungültiger criterion_kind: {kind}")
+
+
+def _validate_status(status: str) -> None:
+ if status not in CRITERION_STATUSES:
+ raise ValueError(f"Ungültiger Kriterien-Status: {status}")
+
+
+def _get_item_or_raise(*, tenant_id: str, item_id: str) -> dict[str, Any]:
+ from services.roadmap import get_roadmap_item
+
+ item = get_roadmap_item(tenant_id=tenant_id, item_id=item_id)
+ if not item:
+ raise ValueError("RoadmapItem nicht gefunden")
+ return item
+
+
+def _reopen_item_if_reached(
+ cur,
+ *,
+ tenant_id: str,
+ item: dict[str, Any],
+ user_id: Optional[str],
+ audit_reason: str,
+) -> None:
+ if item["status"] != "reached":
+ return
+ cur.execute(
+ """
+ UPDATE roadmap_items
+ SET status = 'active', updated_at = NOW()
+ WHERE id = %s AND tenant_id = %s
+ """,
+ (item["id"], tenant_id),
+ )
+ log_audit(
+ "roadmap_item.reopened",
+ user_id=user_id,
+ tenant_id=tenant_id,
+ details={
+ "roadmap_item_id": item["id"],
+ "reason": audit_reason,
+ "from_status": "reached",
+ "to_status": "active",
+ },
+ )
+
+
+def create_default_criterion(
+ cur,
+ *,
+ tenant_id: str,
+ roadmap_item_id: str,
+ title: str = "Gate allgemein",
+ description: str = "",
+ sort_order: int = 0,
+) -> dict[str, Any]:
+ cur.execute(
+ f"""
+ INSERT INTO roadmap_item_criteria (
+ tenant_id, roadmap_item_id, sort_order, title, description, criterion_kind
+ )
+ VALUES (%s, %s, %s, %s, %s, 'evidence_required')
+ RETURNING {_CRITERION_COLUMNS}
+ """,
+ (tenant_id, roadmap_item_id, sort_order, title, description or title),
+ )
+ return _serialize_row(dict(cur.fetchone()))
+
+
+def list_criteria_for_item(*, tenant_id: str, item_id: str) -> list[dict[str, Any]]:
+ _get_item_or_raise(tenant_id=tenant_id, item_id=item_id)
+ conn = get_connection()
+ try:
+ with conn.cursor(cursor_factory=RealDictCursor) as cur:
+ cur.execute(
+ f"""
+ SELECT {_CRITERION_COLUMNS}
+ FROM roadmap_item_criteria
+ WHERE tenant_id = %s AND roadmap_item_id = %s
+ ORDER BY sort_order ASC, created_at ASC
+ """,
+ (tenant_id, item_id),
+ )
+ return [_serialize_row(dict(r)) for r in cur.fetchall()]
+ finally:
+ conn.close()
+
+
+def get_criterion(*, tenant_id: str, criterion_id: str) -> Optional[dict[str, Any]]:
+ conn = get_connection()
+ try:
+ with conn.cursor(cursor_factory=RealDictCursor) as cur:
+ cur.execute(
+ f"""
+ SELECT {_CRITERION_COLUMNS}
+ FROM roadmap_item_criteria
+ WHERE id = %s AND tenant_id = %s
+ """,
+ (criterion_id, tenant_id),
+ )
+ row = cur.fetchone()
+ return _serialize_row(dict(row)) if row else None
+ finally:
+ conn.close()
+
+
+def create_criterion(
+ *,
+ tenant_id: str,
+ item_id: str,
+ title: str,
+ description: str = "",
+ criterion_kind: CriterionKind = "manual",
+ verification_hint: str = "",
+ sort_order: int = 0,
+ user_id: Optional[str] = None,
+) -> dict[str, Any]:
+ title = title.strip()
+ if not title:
+ raise ValueError("Titel ist erforderlich")
+ _validate_kind(criterion_kind)
+ item = _get_item_or_raise(tenant_id=tenant_id, item_id=item_id)
+
+ conn = get_connection()
+ try:
+ with conn.cursor(cursor_factory=RealDictCursor) as cur:
+ cur.execute(
+ f"""
+ INSERT INTO roadmap_item_criteria (
+ tenant_id, roadmap_item_id, sort_order, title, description,
+ criterion_kind, verification_hint
+ )
+ VALUES (%s, %s, %s, %s, %s, %s, %s)
+ RETURNING {_CRITERION_COLUMNS}
+ """,
+ (
+ tenant_id,
+ item_id,
+ sort_order,
+ title,
+ description,
+ criterion_kind,
+ verification_hint,
+ ),
+ )
+ row = _serialize_row(dict(cur.fetchone()))
+ if item["status"] == "reached":
+ _reopen_item_if_reached(
+ cur,
+ tenant_id=tenant_id,
+ item=item,
+ user_id=user_id,
+ audit_reason="criterion_created_on_reached_gate",
+ )
+ conn.commit()
+ finally:
+ conn.close()
+
+ log_audit(
+ "roadmap_item_criterion.created",
+ user_id=user_id,
+ tenant_id=tenant_id,
+ details={"criterion_id": row["id"], "roadmap_item_id": item_id, "title": title},
+ )
+ return row
+
+
+def update_criterion(
+ *,
+ tenant_id: str,
+ criterion_id: str,
+ user_id: Optional[str] = None,
+ title: Optional[str] = None,
+ description: Optional[str] = None,
+ criterion_kind: Optional[CriterionKind] = None,
+ verification_hint: Optional[str] = None,
+ sort_order: Optional[int] = None,
+ change_reason: str = "",
+) -> Optional[dict[str, Any]]:
+ existing = get_criterion(tenant_id=tenant_id, criterion_id=criterion_id)
+ if not existing:
+ return None
+ item = _get_item_or_raise(tenant_id=tenant_id, item_id=existing["roadmap_item_id"])
+
+ 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 description is not None:
+ updates.append("description = %s")
+ params.append(description)
+ if criterion_kind is not None:
+ _validate_kind(criterion_kind)
+ updates.append("criterion_kind = %s")
+ params.append(criterion_kind)
+ if verification_hint is not None:
+ updates.append("verification_hint = %s")
+ params.append(verification_hint)
+ if sort_order is not None:
+ updates.append("sort_order = %s")
+ params.append(sort_order)
+ if not updates:
+ return existing
+
+ if item["status"] == "reached" and not change_reason.strip():
+ raise ValueError(
+ "Änderung an Kriterien bei erreichtem Gate erfordert Begründung (change_reason)"
+ )
+
+ updates.append("updated_at = NOW()")
+ params.extend([criterion_id, tenant_id])
+
+ conn = get_connection()
+ try:
+ with conn.cursor(cursor_factory=RealDictCursor) as cur:
+ cur.execute(
+ f"""
+ UPDATE roadmap_item_criteria
+ SET {", ".join(updates)}
+ WHERE id = %s AND tenant_id = %s
+ RETURNING {_CRITERION_COLUMNS}
+ """,
+ params,
+ )
+ row = cur.fetchone()
+ if not row:
+ return None
+ result = _serialize_row(dict(row))
+ if item["status"] == "reached":
+ _reopen_item_if_reached(
+ cur,
+ tenant_id=tenant_id,
+ item=item,
+ user_id=user_id,
+ audit_reason=change_reason.strip() or "criterion_updated_on_reached_gate",
+ )
+ conn.commit()
+ finally:
+ conn.close()
+
+ log_audit(
+ "roadmap_item_criterion.updated",
+ user_id=user_id,
+ tenant_id=tenant_id,
+ details={
+ "criterion_id": criterion_id,
+ "roadmap_item_id": existing["roadmap_item_id"],
+ "change_reason": change_reason.strip() or None,
+ },
+ )
+ return result
+
+
+def delete_criterion(
+ *, tenant_id: str, criterion_id: str, user_id: Optional[str] = None
+) -> bool:
+ existing = get_criterion(tenant_id=tenant_id, criterion_id=criterion_id)
+ if not existing:
+ return False
+
+ conn = get_connection()
+ try:
+ with conn.cursor() as cur:
+ cur.execute(
+ """
+ SELECT COUNT(*) FROM roadmap_item_criteria
+ WHERE tenant_id = %s AND roadmap_item_id = %s
+ """,
+ (tenant_id, existing["roadmap_item_id"]),
+ )
+ if cur.fetchone()[0] <= 1:
+ raise ValueError("Letztes Kriterium eines Gates kann nicht gelöscht werden")
+
+ cur.execute(
+ """
+ DELETE FROM roadmap_item_criteria
+ WHERE id = %s AND tenant_id = %s
+ RETURNING id
+ """,
+ (criterion_id, tenant_id),
+ )
+ deleted = cur.fetchone() is not None
+ conn.commit()
+ finally:
+ conn.close()
+
+ if deleted:
+ log_audit(
+ "roadmap_item_criterion.deleted",
+ user_id=user_id,
+ tenant_id=tenant_id,
+ details={
+ "criterion_id": criterion_id,
+ "roadmap_item_id": existing["roadmap_item_id"],
+ },
+ )
+ return deleted
+
+
+def _accepted_evidence_for_item(cur, *, tenant_id: str, initiative_id: str, item_id: str):
+ cur.execute(
+ """
+ SELECT id FROM evidence
+ WHERE tenant_id = %s AND initiative_id = %s AND status = 'accepted'
+ AND (roadmap_item_id = %s OR milestone_id = %s)
+ ORDER BY updated_at DESC
+ LIMIT 1
+ """,
+ (tenant_id, initiative_id, item_id, item_id),
+ )
+ row = cur.fetchone()
+ return str(row["id"]) if row else None
+
+
+def _completed_review_for_item(cur, *, tenant_id: str, initiative_id: str, item_id: str) -> bool:
+ cur.execute(
+ """
+ SELECT 1 FROM reviews
+ WHERE tenant_id = %s AND initiative_id = %s AND status = 'completed'
+ AND (roadmap_item_id = %s OR milestone_id = %s)
+ LIMIT 1
+ """,
+ (tenant_id, initiative_id, item_id, item_id),
+ )
+ return cur.fetchone() is not None
+
+
+def satisfy_criterion(
+ *,
+ tenant_id: str,
+ criterion_id: str,
+ user_id: Optional[str] = None,
+ evidence_id: Optional[str] = None,
+) -> dict[str, Any]:
+ existing = get_criterion(tenant_id=tenant_id, criterion_id=criterion_id)
+ if not existing:
+ raise ValueError("Kriterium nicht gefunden")
+ item = _get_item_or_raise(tenant_id=tenant_id, item_id=existing["roadmap_item_id"])
+ if item["status"] in _TERMINAL_ITEM_STATUSES and item["status"] != "reached":
+ raise ValueError(f"Gate ist terminal: {item['status']}")
+
+ conn = get_connection()
+ try:
+ with conn.cursor(cursor_factory=RealDictCursor) as cur:
+ linked_evidence = evidence_id
+ kind = existing["criterion_kind"]
+ if kind == "evidence_required":
+ if linked_evidence:
+ cur.execute(
+ """
+ SELECT id FROM evidence
+ WHERE id = %s AND tenant_id = %s AND status = 'accepted'
+ AND initiative_id = %s
+ """,
+ (linked_evidence, tenant_id, item["initiative_id"]),
+ )
+ if not cur.fetchone():
+ raise ValueError("Evidence nicht gefunden oder nicht akzeptiert")
+ else:
+ linked_evidence = _accepted_evidence_for_item(
+ cur,
+ tenant_id=tenant_id,
+ initiative_id=item["initiative_id"],
+ item_id=item["id"],
+ )
+ if not linked_evidence:
+ raise ValueError(
+ "Evidence-Kriterium: akzeptierter Nachweis am Plan-Element erforderlich"
+ )
+ elif kind == "review_required":
+ if not _completed_review_for_item(
+ cur,
+ tenant_id=tenant_id,
+ initiative_id=item["initiative_id"],
+ item_id=item["id"],
+ ):
+ raise ValueError(
+ "Review-Kriterium: abgeschlossenes Review am Plan-Element erforderlich"
+ )
+
+ cur.execute(
+ f"""
+ UPDATE roadmap_item_criteria
+ SET status = 'satisfied',
+ satisfied_by_evidence_id = %s,
+ satisfied_at = NOW(),
+ waived_or_deferred_decision_id = NULL,
+ updated_at = NOW()
+ WHERE id = %s AND tenant_id = %s
+ RETURNING {_CRITERION_COLUMNS}
+ """,
+ (linked_evidence, criterion_id, tenant_id),
+ )
+ result = _serialize_row(dict(cur.fetchone()))
+ conn.commit()
+ finally:
+ conn.close()
+
+ log_audit(
+ "roadmap_item_criterion.satisfied",
+ user_id=user_id,
+ tenant_id=tenant_id,
+ details={
+ "criterion_id": criterion_id,
+ "roadmap_item_id": existing["roadmap_item_id"],
+ "evidence_id": linked_evidence,
+ },
+ )
+ return result
+
+
+def _waive_or_defer(
+ *,
+ tenant_id: str,
+ criterion_id: str,
+ action: Literal["waived", "deferred"],
+ user_id: Optional[str],
+ decision_title: str,
+ decision_description: str,
+) -> dict[str, Any]:
+ existing = get_criterion(tenant_id=tenant_id, criterion_id=criterion_id)
+ if not existing:
+ raise ValueError("Kriterium nicht gefunden")
+ item = _get_item_or_raise(tenant_id=tenant_id, item_id=existing["roadmap_item_id"])
+ if item["status"] in _TERMINAL_ITEM_STATUSES and item["status"] != "reached":
+ raise ValueError(f"Gate ist terminal: {item['status']}")
+
+ decision_title = decision_title.strip()
+ if not decision_title:
+ raise ValueError("Decision-Titel (Begründung) ist erforderlich")
+
+ prefix = f"criterion_{action}:"
+ decision = create_decision(
+ tenant_id=tenant_id,
+ initiative_id=item["initiative_id"],
+ title=f"{prefix}{criterion_id} — {decision_title}",
+ description=decision_description,
+ status="decided",
+ user_id=user_id,
+ )
+
+ conn = get_connection()
+ try:
+ with conn.cursor(cursor_factory=RealDictCursor) as cur:
+ cur.execute(
+ f"""
+ UPDATE roadmap_item_criteria
+ SET status = %s,
+ waived_or_deferred_decision_id = %s,
+ satisfied_by_evidence_id = NULL,
+ satisfied_at = NULL,
+ updated_at = NOW()
+ WHERE id = %s AND tenant_id = %s
+ RETURNING {_CRITERION_COLUMNS}
+ """,
+ (action, decision["id"], criterion_id, tenant_id),
+ )
+ result = _serialize_row(dict(cur.fetchone()))
+ conn.commit()
+ finally:
+ conn.close()
+
+ log_audit(
+ f"roadmap_item_criterion.{action}",
+ user_id=user_id,
+ tenant_id=tenant_id,
+ details={
+ "criterion_id": criterion_id,
+ "roadmap_item_id": existing["roadmap_item_id"],
+ "decision_id": decision["id"],
+ },
+ )
+ return result
+
+
+def waive_criterion(
+ *,
+ tenant_id: str,
+ criterion_id: str,
+ user_id: Optional[str] = None,
+ decision_title: str,
+ decision_description: str = "",
+) -> dict[str, Any]:
+ return _waive_or_defer(
+ tenant_id=tenant_id,
+ criterion_id=criterion_id,
+ action="waived",
+ user_id=user_id,
+ decision_title=decision_title,
+ decision_description=decision_description,
+ )
+
+
+def defer_criterion(
+ *,
+ tenant_id: str,
+ criterion_id: str,
+ user_id: Optional[str] = None,
+ decision_title: str,
+ decision_description: str = "",
+) -> dict[str, Any]:
+ return _waive_or_defer(
+ tenant_id=tenant_id,
+ criterion_id=criterion_id,
+ action="deferred",
+ user_id=user_id,
+ decision_title=decision_title,
+ decision_description=decision_description,
+ )
+
+
+def criteria_progress(*, tenant_id: str, item_id: str) -> dict[str, int]:
+ conn = get_connection()
+ try:
+ with conn.cursor() as cur:
+ cur.execute(
+ """
+ SELECT
+ COUNT(*) AS total,
+ COUNT(*) FILTER (
+ WHERE status IN ('satisfied', 'waived', 'deferred')
+ ) AS closed
+ FROM roadmap_item_criteria
+ WHERE tenant_id = %s AND roadmap_item_id = %s
+ """,
+ (tenant_id, item_id),
+ )
+ total, closed = cur.fetchone()
+ return {"total": total, "closed": closed, "open": total - closed}
+ finally:
+ conn.close()
+
+
+def prepare_criteria_for_verify(
+ cur,
+ *,
+ tenant_id: str,
+ initiative_id: str,
+ item_id: str,
+) -> tuple[bool, str]:
+ """Auto-satisfy open evidence/review criteria wenn Nachweis da; prüft Gesamtstatus."""
+ cur.execute(
+ f"""
+ SELECT {_CRITERION_COLUMNS}
+ FROM roadmap_item_criteria
+ WHERE tenant_id = %s AND roadmap_item_id = %s
+ ORDER BY sort_order ASC, created_at ASC
+ """,
+ (tenant_id, item_id),
+ )
+ criteria = [dict(r) for r in cur.fetchall()]
+ if not criteria:
+ return True, "no_criteria"
+
+ evidence_id = _accepted_evidence_for_item(
+ cur, tenant_id=tenant_id, initiative_id=initiative_id, item_id=item_id
+ )
+ has_review = _completed_review_for_item(
+ cur, tenant_id=tenant_id, initiative_id=initiative_id, item_id=item_id
+ )
+
+ for crit in criteria:
+ status = crit["status"]
+ if status == "failed":
+ return False, f"Kriterium fehlgeschlagen: {crit['title']}"
+ if status in CLOSED_CRITERION_STATUSES:
+ if status in ("waived", "deferred") and not crit["waived_or_deferred_decision_id"]:
+ return False, f"Kriterium ohne Decision: {crit['title']}"
+ continue
+ if status == "open":
+ kind = crit["criterion_kind"]
+ if kind == "evidence_required" and evidence_id:
+ cur.execute(
+ """
+ UPDATE roadmap_item_criteria
+ SET status = 'satisfied',
+ satisfied_by_evidence_id = %s,
+ satisfied_at = NOW(),
+ updated_at = NOW()
+ WHERE id = %s AND tenant_id = %s
+ """,
+ (evidence_id, crit["id"], tenant_id),
+ )
+ continue
+ if kind == "review_required" and has_review:
+ cur.execute(
+ """
+ UPDATE roadmap_item_criteria
+ SET status = 'satisfied', satisfied_at = NOW(), updated_at = NOW()
+ WHERE id = %s AND tenant_id = %s
+ """,
+ (crit["id"], tenant_id),
+ )
+ continue
+ if kind == "manual":
+ return False, f"Manuelles Kriterium noch offen: {crit['title']}"
+ return False, f"Kriterium noch offen: {crit['title']}"
+
+ return True, "criteria_ready"
diff --git a/backend/tests/test_ap14_roadmap.py b/backend/tests/test_ap14_roadmap.py
index 0a5b2c4..8f17d92 100644
--- a/backend/tests/test_ap14_roadmap.py
+++ b/backend/tests/test_ap14_roadmap.py
@@ -84,7 +84,7 @@ def test_verify_reached_requires_evidence(client):
)
assert ok.status_code == 200
assert ok.json()["status"] == "reached"
- assert ok.json()["verify_reason"] == "evidence_accepted"
+ assert ok.json()["verify_reason"] in ("criteria_ready", "evidence_accepted")
def test_milestone_compat_api_uses_roadmap(client):
diff --git a/backend/tests/test_ap14b_gate_checklist.py b/backend/tests/test_ap14b_gate_checklist.py
new file mode 100644
index 0000000..9490d5e
--- /dev/null
+++ b/backend/tests/test_ap14b_gate_checklist.py
@@ -0,0 +1,150 @@
+"""AP1.4b — Gate checklist, waive/defer, reopen tests."""
+
+from __future__ import annotations
+
+from tests.factories import provision_user_in_tenant
+from tests.test_initiatives_actions import (
+ _auth,
+ _create_initiative,
+ _login,
+)
+from tests.test_ap14_roadmap import _create_roadmap_item
+
+
+def _list_criteria(client, token, item_id):
+ return client.get(f"/api/roadmap-items/{item_id}/criteria", headers=_auth(token))
+
+
+def test_default_criterion_on_create(client):
+ user = provision_user_in_tenant(tenant_role="member")
+ token = _login(client, user)
+ initiative_id = _create_initiative(client, token).json()["id"]
+ item = _create_roadmap_item(client, token, initiative_id).json()
+
+ resp = _list_criteria(client, token, item["id"])
+ assert resp.status_code == 200
+ body = resp.json()
+ assert body["progress"]["total"] == 1
+ assert body["items"][0]["title"] == "Gate allgemein"
+ assert body["items"][0]["status"] == "open"
+
+
+def test_verify_with_waived_criterion(client):
+ user = provision_user_in_tenant(tenant_role="member")
+ token = _login(client, user)
+ initiative_id = _create_initiative(client, token).json()["id"]
+ item = _create_roadmap_item(client, token, initiative_id, status="active").json()
+
+ criteria = _list_criteria(client, token, item["id"]).json()["items"]
+ crit_a = criteria[0]["id"]
+
+ added = client.post(
+ f"/api/roadmap-items/{item['id']}/criteria",
+ json={"title": "Review-Protokoll", "criterion_kind": "manual"},
+ headers=_auth(token),
+ )
+ assert added.status_code == 201
+ crit_b = added.json()["id"]
+
+ waived = client.post(
+ f"/api/roadmap-criteria/{crit_b}/waive",
+ json={"decision_title": "Review entfällt", "decision_description": "PO-Freigabe"},
+ headers=_auth(token),
+ )
+ assert waived.status_code == 200
+ assert waived.json()["status"] == "waived"
+
+ evidence = client.post(
+ f"/api/initiatives/{initiative_id}/evidence",
+ json={
+ "title": "Nachweis",
+ "roadmap_item_id": item["id"],
+ "status": "accepted",
+ },
+ headers=_auth(token),
+ )
+ assert evidence.status_code == 201
+
+ ok = client.post(
+ f"/api/roadmap-items/{item['id']}/verify-reached",
+ headers=_auth(token),
+ )
+ assert ok.status_code == 200
+ assert ok.json()["status"] == "reached"
+
+ progress = _list_criteria(client, token, item["id"]).json()["progress"]
+ assert progress["closed"] == 2
+
+
+def test_reopen_keeps_criterion_status(client):
+ user = provision_user_in_tenant(tenant_role="member")
+ token = _login(client, user)
+ initiative_id = _create_initiative(client, token).json()["id"]
+ item = _create_roadmap_item(client, token, initiative_id, status="active").json()
+
+ client.post(
+ f"/api/initiatives/{initiative_id}/evidence",
+ json={
+ "title": "Nachweis",
+ "roadmap_item_id": item["id"],
+ "status": "accepted",
+ },
+ headers=_auth(token),
+ )
+ client.post(
+ f"/api/roadmap-items/{item['id']}/verify-reached",
+ headers=_auth(token),
+ )
+
+ criteria_before = _list_criteria(client, token, item["id"]).json()["items"]
+ assert criteria_before[0]["status"] == "satisfied"
+
+ reopened = client.post(
+ f"/api/roadmap-items/{item['id']}/reopen",
+ json={"reason": "Versehentlich geschlossen"},
+ headers=_auth(token),
+ )
+ assert reopened.status_code == 200
+ assert reopened.json()["status"] == "active"
+
+ criteria_after = _list_criteria(client, token, item["id"]).json()["items"]
+ assert criteria_after[0]["status"] == "satisfied"
+
+
+def test_criterion_change_on_reached_reopens_gate(client):
+ user = provision_user_in_tenant(tenant_role="member")
+ token = _login(client, user)
+ initiative_id = _create_initiative(client, token).json()["id"]
+ item = _create_roadmap_item(client, token, initiative_id, status="active").json()
+
+ client.post(
+ f"/api/initiatives/{initiative_id}/evidence",
+ json={
+ "title": "Nachweis",
+ "roadmap_item_id": item["id"],
+ "status": "accepted",
+ },
+ headers=_auth(token),
+ )
+ client.post(
+ f"/api/roadmap-items/{item['id']}/verify-reached",
+ headers=_auth(token),
+ )
+
+ crit_id = _list_criteria(client, token, item["id"]).json()["items"][0]["id"]
+ patched = client.patch(
+ f"/api/roadmap-criteria/{crit_id}",
+ json={
+ "title": "Gate allgemein (präzisiert)",
+ "change_reason": "DoD nachgeschärft",
+ },
+ headers=_auth(token),
+ )
+ assert patched.status_code == 200
+
+ item_after = client.get(
+ f"/api/roadmap-items/{item['id']}",
+ headers=_auth(token),
+ ).json()
+ assert item_after["status"] == "active"
+ assert patched.json()["title"] == "Gate allgemein (präzisiert)"
diff --git a/backend/tests/test_rights_registry.py b/backend/tests/test_rights_registry.py
index b5a8ca7..706929f 100644
--- a/backend/tests/test_rights_registry.py
+++ b/backend/tests/test_rights_registry.py
@@ -43,6 +43,8 @@ def test_registry_contains_initial_capabilities():
"kairo.review.manage",
"kairo.recurring.read",
"kairo.recurring.manage",
+ "kairo.roadmap.reopen",
+ "kairo.roadmap.reopen.emergency",
}
@@ -54,7 +56,7 @@ def test_sync_is_idempotent():
try:
with conn.cursor() as cur:
cur.execute("SELECT COUNT(*) FROM capabilities")
- assert cur.fetchone()[0] == 33
+ assert cur.fetchone()[0] == 35
cur.execute("SELECT COUNT(*) FROM role_capability_grants")
assert cur.fetchone()[0] >= 5
finally:
diff --git a/backend/version.py b/backend/version.py
index 0c95b54..0f8fd6e 100644
--- a/backend/version.py
+++ b/backend/version.py
@@ -1,3 +1,3 @@
-APP_VERSION = "0.13.0-ap1.4"
-DB_SCHEMA_VERSION = "011"
+APP_VERSION = "0.13.1-ap1.4b"
+DB_SCHEMA_VERSION = "012"
APP_NAME = "jinkendo-kairo"
diff --git a/docs/product/Kairo_Implementation_Truth_Table_v0.1.md b/docs/product/Kairo_Implementation_Truth_Table_v0.1.md
index 42db788..a63eb3f 100644
--- a/docs/product/Kairo_Implementation_Truth_Table_v0.1.md
+++ b/docs/product/Kairo_Implementation_Truth_Table_v0.1.md
@@ -1,7 +1,7 @@
# Kairo — Implementation Truth Table v0.1
**Status:** living document — bei jedem AP aktualisieren
-**Stand:** 2026-07-05 (nach AP1.1b)
+**Stand:** 2026-07-06 (nach AP1.4b)
**Zweck:** Ehrliche Trennung von **implementiert**, **teilweise**, **nur API/Schema**, **nur Dokumentiert**
Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden.
@@ -30,7 +30,7 @@ Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden.
| Capabilities | ✓ | Keine Objekt-Sichtbarkeit |
| Auth / Session | ✓ | |
| Audit (Auth/Admin) | ◐ | Nicht für alle OM-Events |
-| Migrationen nummeriert | ✓ | Schema 009 |
+| Migrationen nummeriert | ✓ | Schema 012 |
---
@@ -51,9 +51,10 @@ Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden.
| RecurringElement | ✓ | |
| Task (unter Action) | ✗ | |
| Roadmap | ◐ | 1 pro Initiative; Migration 010 |
-| RoadmapItem | ◐ | milestone/review_gate/maturity_stage; Milestone-API Compat |
+| RoadmapItem | ◐ | Verify über Checkliste AP1.4b |
| RoadmapItem Dependencies | ◐ | requires/blocks/related |
-| Plan-Ist-Verknüpfung | ◐ | backlog/actions.roadmap_item_id; Gate-Verify AP1.4 |
+| RoadmapItem Criteria | ◐ | Checkliste, waive/defer, Reopen AP1.4b |
+| Plan-Ist-Verknüpfung | ◐ | Evidence/Reviews + Kriterien |
---
@@ -88,7 +89,7 @@ Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden.
| Next-Action-Widget konfigurierbar (Workspace + Initiative) | ✓ | scope portfolio/initiative |
| Action Detail-Route | ✓ | `/initiatives/:id/actions/:actionId` |
| Action Detail-Seite | ✗ | |
-| Gate Detail-Seite | ✗ | |
+| Gate Detail-Seite | ◐ | `/initiatives/:id/plan/items/:itemId` AP1.4b |
| Modal-Bearbeitung | ✗ | Inline-Formulare überall |
| Admin-UI | ✗ | |
@@ -98,13 +99,14 @@ Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden.
| Gate-Anforderung (Vision) | Ist |
|---------------------------|-----|
-| DoD / prüfbare Kriterien | ◐ `goal_description` Freitext, kein Kriterienmodell |
-| Zieltermin | ◐ `target_date` |
-| Abhängigkeiten | ✗ |
-| parallel vs. sequenziell | ✗ |
-| Verify vor `reached` | ✗ Status-Dropdown |
-| Decision bei `moved` | ✗ |
-| Quality-Gate-Semantik | ✗ |
+| DoD / prüfbare Kriterien | ◐ | `roadmap_item_criteria`; Default „Gate allgemein“ |
+| Zieltermin | ◐ | `target_date` |
+| Abhängigkeiten | ◐ | API; Graph Engine AP1.4d |
+| parallel vs. sequenziell | ◐ | `sequencing_mode` (deprecated); Graph AP1.4d |
+| Verify vor `reached` | ◐ | Kriterienplan + gate_override |
+| Reopen nach `reached` | ◐ | `active`, Kriterien bleiben AP1.4b |
+| Decision bei `moved` | ✗ | |
+| Quality-Gate-Semantik | ◐ | Checkliste + Audit |
---
@@ -115,7 +117,7 @@ Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden.
| Arbeitspaket | Maßnahme |
| Quality Gate | Meilenstein (CRUD) |
| Programm | Vorhaben |
-| Plan | (nicht sichtbar) |
+| Plan | ◐ | Plan-Liste + Gate-Detail AP1.4b |
---
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 9609575..7eb2bb6 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -9,6 +9,7 @@ import { InitiativePlanPage } from './pages/initiative/InitiativePlanPage.jsx'
import { InitiativeExecutionPage } from './pages/initiative/InitiativeExecutionPage.jsx'
import { InitiativeInboxPage } from './pages/initiative/InitiativeInboxPage.jsx'
import { InitiativeJourneyPage } from './pages/initiative/InitiativeJourneyPage.jsx'
+import { RoadmapItemDetailPage } from './pages/initiative/RoadmapItemDetailPage.jsx'
import { VIEWS } from './registry/viewRegistry.js'
import './app.css'
@@ -39,6 +40,7 @@ function AppRoutes() {
{ROADMAP_ITEM_TYPE_LABELS[item.item_type] || item.item_type} {' · '} diff --git a/frontend/src/constants/status.js b/frontend/src/constants/status.js index 98e7d44..a4acbf9 100644 --- a/frontend/src/constants/status.js +++ b/frontend/src/constants/status.js @@ -72,6 +72,24 @@ export const SEQUENCING_MODE_LABELS = { optional: 'Optional', } +export const CRITERION_STATUSES = ['open', 'satisfied', 'waived', 'deferred', 'failed'] +export const CRITERION_KINDS = ['manual', 'evidence_required', 'review_required', 'metric'] + +export const CRITERION_STATUS_LABELS = { + open: 'Offen', + satisfied: 'Erfüllt', + waived: 'Ausgelassen', + deferred: 'Verschoben', + failed: 'Fehlgeschlagen', +} + +export const CRITERION_KIND_LABELS = { + manual: 'Manuell', + evidence_required: 'Nachweis', + review_required: 'Review', + metric: 'Metrik', +} + export const EVIDENCE_STATUS_LABELS = { submitted: 'Eingereicht', accepted: 'Akzeptiert', diff --git a/frontend/src/pages/initiative/InitiativePlanPage.jsx b/frontend/src/pages/initiative/InitiativePlanPage.jsx index bc58308..d0a177b 100644 --- a/frontend/src/pages/initiative/InitiativePlanPage.jsx +++ b/frontend/src/pages/initiative/InitiativePlanPage.jsx @@ -1,7 +1,9 @@ +import { useParams } from 'react-router-dom' import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx' import { RoadmapPlanSection } from '../../components/RoadmapPlanSection.jsx' export function InitiativePlanPage() { + const { id } = useParams() const { roadmapItems, capabilities, @@ -22,6 +24,7 @@ export function InitiativePlanPage() { <> {error &&
{error}
}+ Plan + {' · '} + {item.title} +
+ + {error &&{error}
} + ++ {ROADMAP_ITEM_TYPE_LABELS[item.item_type] || item.item_type} + {' · '} + {MILESTONE_STATUS_LABELS[item.status] || item.status} +
+ {item.goal_description &&{item.goal_description}
} ++ Checkliste: {closedCount}/{totalCount} Kriterien abgeschlossen +
+ ++ {CRITERION_KIND_LABELS[crit.criterion_kind] || crit.criterion_kind} +
+ {crit.description &&{crit.description}
} +