AP1.4b: Gate-Checkliste, Verify, Reopen und Plan-Detail-UI
All checks were successful
Deploy Development / deploy (push) Successful in 48s
Test Suite / pytest-backend (push) Successful in 1m36s
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
All checks were successful
Deploy Development / deploy (push) Successful in 48s
Test Suite / pytest-backend (push) Successful in 1m36s
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 mit waive/defer per Decision; Verify gegen Checkliste; Reopen active mit Audit; Schema 012. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
e70e3196ec
commit
9bf6ccee36
|
|
@ -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)
|
||||
|
|
|
|||
76
backend/migrations/012_roadmap_item_criteria.sql
Normal file
76
backend/migrations/012_roadmap_item_criteria.sql
Normal file
|
|
@ -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)
|
||||
);
|
||||
|
|
@ -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"),),
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
652
backend/services/roadmap_criteria.py
Normal file
652
backend/services/roadmap_criteria.py
Normal file
|
|
@ -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"
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
150
backend/tests/test_ap14b_gate_checklist.py
Normal file
150
backend/tests/test_ap14b_gate_checklist.py
Normal file
|
|
@ -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)"
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<Route path="/initiatives/:id" element={<InitiativeLayout />}>
|
||||
<Route index element={<InitiativeOverviewPage />} />
|
||||
<Route path="plan" element={<InitiativePlanPage />} />
|
||||
<Route path="plan/items/:itemId" element={<RoadmapItemDetailPage />} />
|
||||
<Route path="execution" element={<InitiativeExecutionPage />} />
|
||||
<Route path="inbox" element={<InitiativeInboxPage />} />
|
||||
<Route path="journey" element={<InitiativeJourneyPage />} />
|
||||
|
|
|
|||
|
|
@ -28,6 +28,60 @@ export function verifyRoadmapItemReached(itemId) {
|
|||
})
|
||||
}
|
||||
|
||||
export function reopenRoadmapItem(itemId, body = {}) {
|
||||
return apiFetch(`/api/roadmap-items/${itemId}/reopen`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
export function getRoadmapItem(itemId) {
|
||||
return apiFetch(`/api/roadmap-items/${itemId}`)
|
||||
}
|
||||
|
||||
export function listRoadmapItemCriteria(itemId) {
|
||||
return apiFetch(`/api/roadmap-items/${itemId}/criteria`)
|
||||
}
|
||||
|
||||
export function createRoadmapItemCriterion(itemId, body) {
|
||||
return apiFetch(`/api/roadmap-items/${itemId}/criteria`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
export function updateRoadmapCriterion(criterionId, body) {
|
||||
return apiFetch(`/api/roadmap-criteria/${criterionId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteRoadmapCriterion(criterionId) {
|
||||
return apiFetch(`/api/roadmap-criteria/${criterionId}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export function satisfyRoadmapCriterion(criterionId, body = {}) {
|
||||
return apiFetch(`/api/roadmap-criteria/${criterionId}/satisfy`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
export function waiveRoadmapCriterion(criterionId, body) {
|
||||
return apiFetch(`/api/roadmap-criteria/${criterionId}/waive`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
export function deferRoadmapCriterion(criterionId, body) {
|
||||
return apiFetch(`/api/roadmap-criteria/${criterionId}/defer`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
export function listRoadmapItemDependencies(itemId) {
|
||||
return apiFetch(`/api/roadmap-items/${itemId}/dependencies`)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import {
|
||||
MILESTONE_STATUSES,
|
||||
MILESTONE_STATUS_LABELS,
|
||||
|
|
@ -26,6 +27,7 @@ function formatDate(value) {
|
|||
}
|
||||
|
||||
export function RoadmapPlanSection({
|
||||
initiativeId,
|
||||
items,
|
||||
canManage,
|
||||
onCreate,
|
||||
|
|
@ -148,7 +150,15 @@ export function RoadmapPlanSection({
|
|||
{items.map((item) => (
|
||||
<li key={item.id} className="list-item card-list-item milestone-list-item">
|
||||
<div className="list-item-main">
|
||||
<strong>{item.title}</strong>
|
||||
<strong>
|
||||
{initiativeId ? (
|
||||
<Link to={`/initiatives/${initiativeId}/plan/items/${item.id}`}>
|
||||
{item.title}
|
||||
</Link>
|
||||
) : (
|
||||
item.title
|
||||
)}
|
||||
</strong>
|
||||
<p className="muted list-item-sub">
|
||||
{ROADMAP_ITEM_TYPE_LABELS[item.item_type] || item.item_type}
|
||||
{' · '}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 && <p className="error">{error}</p>}
|
||||
<RoadmapPlanSection
|
||||
initiativeId={id}
|
||||
items={roadmapItems}
|
||||
canManage={capabilities.has('kairo.milestone.manage')}
|
||||
onCreate={handleCreateRoadmapItem}
|
||||
|
|
|
|||
337
frontend/src/pages/initiative/RoadmapItemDetailPage.jsx
Normal file
337
frontend/src/pages/initiative/RoadmapItemDetailPage.jsx
Normal file
|
|
@ -0,0 +1,337 @@
|
|||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import {
|
||||
createInitiativeEvidence,
|
||||
} from '../../api/evidence.js'
|
||||
import {
|
||||
getRoadmapItem,
|
||||
listRoadmapItemCriteria,
|
||||
createRoadmapItemCriterion,
|
||||
deleteRoadmapCriterion,
|
||||
satisfyRoadmapCriterion,
|
||||
waiveRoadmapCriterion,
|
||||
deferRoadmapCriterion,
|
||||
verifyRoadmapItemReached,
|
||||
reopenRoadmapItem,
|
||||
} from '../../api/roadmap.js'
|
||||
import { ErrorState } from '../../components/ErrorState.jsx'
|
||||
import { LoadingState } from '../../components/LoadingState.jsx'
|
||||
import { StatusBadge } from '../../components/StatusBadge.jsx'
|
||||
import {
|
||||
CRITERION_KIND_LABELS,
|
||||
CRITERION_KINDS,
|
||||
CRITERION_STATUS_LABELS,
|
||||
MILESTONE_STATUS_LABELS,
|
||||
ROADMAP_ITEM_TYPE_LABELS,
|
||||
} from '../../constants/status.js'
|
||||
import { useCapabilities } from '../../hooks/useCapabilities.js'
|
||||
|
||||
function CriterionDecisionForm({ label, onSubmit, busy }) {
|
||||
const [title, setTitle] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
|
||||
return (
|
||||
<form
|
||||
className="inline-form-block criterion-decision-form"
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault()
|
||||
if (!title.trim()) return
|
||||
await onSubmit({
|
||||
decision_title: title.trim(),
|
||||
decision_description: description.trim(),
|
||||
})
|
||||
setTitle('')
|
||||
setDescription('')
|
||||
}}
|
||||
>
|
||||
<label>
|
||||
{label} — Begründung (Decision)
|
||||
<input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
required
|
||||
maxLength={255}
|
||||
placeholder="Kurzbegründung"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Details
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={2}
|
||||
/>
|
||||
</label>
|
||||
<button type="submit" className="btn btn-secondary" disabled={busy}>
|
||||
Speichern
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
export function RoadmapItemDetailPage() {
|
||||
const { id: initiativeId, itemId } = useParams()
|
||||
const { capabilities } = useCapabilities()
|
||||
const canManage = capabilities.has('kairo.milestone.manage')
|
||||
const canReopen = capabilities.has('kairo.roadmap.reopen')
|
||||
|
||||
const [item, setItem] = useState(null)
|
||||
const [criteria, setCriteria] = useState([])
|
||||
const [progress, setProgress] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [newTitle, setNewTitle] = useState('')
|
||||
const [newKind, setNewKind] = useState('manual')
|
||||
const [expandedDecision, setExpandedDecision] = useState(null)
|
||||
const [reopenReason, setReopenReason] = useState('')
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const [itemData, criteriaData] = await Promise.all([
|
||||
getRoadmapItem(itemId),
|
||||
listRoadmapItemCriteria(itemId),
|
||||
])
|
||||
setItem(itemData)
|
||||
setCriteria(criteriaData.items || [])
|
||||
setProgress(criteriaData.progress || null)
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [itemId])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
async function runAction(fn) {
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
await fn()
|
||||
await load()
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <LoadingState message="Plan-Element wird geladen…" />
|
||||
if (error && !item) return <ErrorState message={error} onRetry={load} />
|
||||
if (!item) return <ErrorState message="Plan-Element nicht gefunden." onRetry={load} />
|
||||
|
||||
const closedCount = progress?.closed ?? 0
|
||||
const totalCount = progress?.total ?? criteria.length
|
||||
|
||||
return (
|
||||
<section className="card roadmap-item-detail">
|
||||
<p className="breadcrumb muted">
|
||||
<Link to={`/initiatives/${initiativeId}/plan`}>Plan</Link>
|
||||
{' · '}
|
||||
{item.title}
|
||||
</p>
|
||||
|
||||
{error && <p className="error">{error}</p>}
|
||||
|
||||
<div className="section-header">
|
||||
<div>
|
||||
<h2>{item.title}</h2>
|
||||
<p className="section-lead muted">
|
||||
{ROADMAP_ITEM_TYPE_LABELS[item.item_type] || item.item_type}
|
||||
{' · '}
|
||||
{MILESTONE_STATUS_LABELS[item.status] || item.status}
|
||||
</p>
|
||||
{item.goal_description && <p>{item.goal_description}</p>}
|
||||
</div>
|
||||
<StatusBadge kind="milestone" status={item.status} />
|
||||
</div>
|
||||
|
||||
<p className="muted">
|
||||
Checkliste: {closedCount}/{totalCount} Kriterien abgeschlossen
|
||||
</p>
|
||||
|
||||
<ul className="item-list criterion-list">
|
||||
{criteria.map((crit) => (
|
||||
<li key={crit.id} className="list-item card-list-item">
|
||||
<div className="list-item-main">
|
||||
<strong>{crit.title}</strong>
|
||||
<p className="muted list-item-sub">
|
||||
{CRITERION_KIND_LABELS[crit.criterion_kind] || crit.criterion_kind}
|
||||
</p>
|
||||
{crit.description && <p className="list-item-desc">{crit.description}</p>}
|
||||
</div>
|
||||
<div className="list-item-meta action-controls">
|
||||
<span className="status-pill">
|
||||
{CRITERION_STATUS_LABELS[crit.status] || crit.status}
|
||||
</span>
|
||||
{canManage && crit.status === 'open' && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={busy}
|
||||
onClick={() => runAction(() => satisfyRoadmapCriterion(crit.id))}
|
||||
>
|
||||
Erfüllt
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
setExpandedDecision(
|
||||
expandedDecision === `${crit.id}:waive` ? null : `${crit.id}:waive`
|
||||
)
|
||||
}
|
||||
>
|
||||
Auslassen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
setExpandedDecision(
|
||||
expandedDecision === `${crit.id}:defer` ? null : `${crit.id}:defer`
|
||||
)
|
||||
}
|
||||
>
|
||||
Verschieben
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{canManage && criteria.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
disabled={busy}
|
||||
onClick={() => runAction(() => deleteRoadmapCriterion(crit.id))}
|
||||
>
|
||||
Entfernen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{expandedDecision === `${crit.id}:waive` && (
|
||||
<CriterionDecisionForm
|
||||
label="Auslassen"
|
||||
busy={busy}
|
||||
onSubmit={(body) =>
|
||||
runAction(async () => {
|
||||
await waiveRoadmapCriterion(crit.id, body)
|
||||
setExpandedDecision(null)
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{expandedDecision === `${crit.id}:defer` && (
|
||||
<CriterionDecisionForm
|
||||
label="Verschieben"
|
||||
busy={busy}
|
||||
onSubmit={(body) =>
|
||||
runAction(async () => {
|
||||
await deferRoadmapCriterion(crit.id, body)
|
||||
setExpandedDecision(null)
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{canManage && (
|
||||
<form
|
||||
className="inline-form-block"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
if (!newTitle.trim()) return
|
||||
runAction(async () => {
|
||||
await createRoadmapItemCriterion(itemId, {
|
||||
title: newTitle.trim(),
|
||||
criterion_kind: newKind,
|
||||
})
|
||||
setNewTitle('')
|
||||
setNewKind('manual')
|
||||
})
|
||||
}}
|
||||
>
|
||||
<h3>Kriterium hinzufügen</h3>
|
||||
<label>
|
||||
Titel
|
||||
<input
|
||||
value={newTitle}
|
||||
onChange={(e) => setNewTitle(e.target.value)}
|
||||
maxLength={500}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Art
|
||||
<select value={newKind} onChange={(e) => setNewKind(e.target.value)}>
|
||||
{CRITERION_KINDS.map((k) => (
|
||||
<option key={k} value={k}>
|
||||
{CRITERION_KIND_LABELS[k]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<button type="submit" className="btn btn-primary" disabled={busy}>
|
||||
Hinzufügen
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="gate-detail-actions">
|
||||
{canManage && ['planned', 'active', 'at_risk'].includes(item.status) && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
runAction(async () => {
|
||||
await createInitiativeEvidence(initiativeId, {
|
||||
title: `Nachweis: ${item.title}`,
|
||||
roadmap_item_id: itemId,
|
||||
status: 'accepted',
|
||||
})
|
||||
await verifyRoadmapItemReached(itemId)
|
||||
})
|
||||
}
|
||||
>
|
||||
Gate schließen (Verify)
|
||||
</button>
|
||||
)}
|
||||
{canReopen && item.status === 'reached' && (
|
||||
<div className="reopen-block">
|
||||
<label>
|
||||
Reopen — Begründung (optional)
|
||||
<input
|
||||
value={reopenReason}
|
||||
onChange={(e) => setReopenReason(e.target.value)}
|
||||
placeholder="Versehentlich geschlossen"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
runAction(async () => {
|
||||
await reopenRoadmapItem(itemId, { reason: reopenReason.trim() })
|
||||
setReopenReason('')
|
||||
})
|
||||
}
|
||||
>
|
||||
Gate wieder öffnen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user