fix: Gate-Verify — Evidence an Plan-Element verknüpfen
Some checks failed
Deploy Development / deploy (push) Successful in 47s
Test Suite / pytest-backend (push) Failing after 1m31s
Test Suite / k6 /api/health Baseline (push) Has been skipped
Test Suite / playwright-smoke (push) Has been skipped
Test Suite / lint-backend (push) Successful in 1s
Test Suite / compose-smoke (push) Has been skipped

Verify prüft akzeptiertes Evidence am RoadmapItem. Plan-UI schließt Gates mit Nachweis;
Journey erlaubt Plan-Zuordnung. Migration 011 roadmap_item_id auf evidence/reviews.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Lars 2026-07-05 19:24:18 +02:00
parent 09433b5d28
commit fdda8a9fae
13 changed files with 262 additions and 38 deletions

View File

@ -0,0 +1,29 @@
-- AP1.4b: Plan-Ist-Verknüpfung für Evidence und Reviews
ALTER TABLE evidence
ADD COLUMN roadmap_item_id UUID NULL REFERENCES roadmap_items(id) ON DELETE SET NULL;
ALTER TABLE reviews
ADD COLUMN roadmap_item_id UUID NULL REFERENCES roadmap_items(id) ON DELETE SET NULL;
CREATE INDEX idx_evidence_roadmap_item ON evidence(tenant_id, roadmap_item_id)
WHERE roadmap_item_id IS NOT NULL;
CREATE INDEX idx_reviews_roadmap_item ON reviews(tenant_id, roadmap_item_id)
WHERE roadmap_item_id IS NOT NULL;
-- Bestehende milestone_id-Verknüpfungen spiegeln (RoadmapItem behält oft dieselbe UUID)
UPDATE evidence e
SET roadmap_item_id = e.milestone_id
WHERE e.milestone_id IS NOT NULL
AND EXISTS (
SELECT 1 FROM roadmap_items ri
WHERE ri.id = e.milestone_id AND ri.tenant_id = e.tenant_id
);
UPDATE reviews r
SET roadmap_item_id = r.milestone_id
WHERE r.milestone_id IS NOT NULL
AND EXISTS (
SELECT 1 FROM roadmap_items ri
WHERE ri.id = r.milestone_id AND ri.tenant_id = r.tenant_id
);

View File

@ -19,8 +19,10 @@ class EvidenceUpdateRequest(BaseModel):
status: Optional[Literal["submitted", "accepted", "rejected"]] = None
action_id: Optional[str] = None
milestone_id: Optional[str] = None
roadmap_item_id: Optional[str] = None
clear_action_id: bool = False
clear_milestone_id: bool = False
clear_roadmap_item_id: bool = False
@router.get("/{evidence_id}")
@ -50,8 +52,10 @@ def update_evidence(
status=body.status,
action_id=body.action_id,
milestone_id=body.milestone_id,
roadmap_item_id=body.roadmap_item_id,
clear_action_id=body.clear_action_id,
clear_milestone_id=body.clear_milestone_id,
clear_roadmap_item_id=body.clear_roadmap_item_id,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc

View File

@ -79,6 +79,7 @@ class EvidenceCreateRequest(BaseModel):
status: Literal["submitted", "accepted", "rejected"] = "submitted"
action_id: Optional[str] = None
milestone_id: Optional[str] = None
roadmap_item_id: Optional[str] = None
class DecisionCreateRequest(BaseModel):
@ -412,6 +413,7 @@ def create_initiative_evidence(
status=body.status,
action_id=body.action_id,
milestone_id=body.milestone_id,
roadmap_item_id=body.roadmap_item_id,
submitted_by_actor_id=ctx.actor_id,
user_id=ctx.user_id,
)

View File

@ -15,8 +15,8 @@ EvidenceStatus = Literal["submitted", "accepted", "rejected"]
EVIDENCE_STATUSES = frozenset({"submitted", "accepted", "rejected"})
_EVIDENCE_COLUMNS = """
id, tenant_id, initiative_id, action_id, milestone_id, title, description,
status, submitted_by_actor_id, created_at, updated_at
id, tenant_id, initiative_id, action_id, milestone_id, roadmap_item_id,
title, description, status, submitted_by_actor_id, created_at, updated_at
"""
@ -28,6 +28,7 @@ def _serialize_row(row: dict[str, Any]) -> dict[str, Any]:
"initiative_id",
"action_id",
"milestone_id",
"roadmap_item_id",
"submitted_by_actor_id",
):
if result.get(key):
@ -72,6 +73,29 @@ def _action_in_initiative(*, tenant_id: str, initiative_id: str, action_id: str)
conn.close()
def _roadmap_item_in_initiative(
*, tenant_id: str, initiative_id: str, roadmap_item_id: str
) -> Optional[dict[str, str]]:
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
SELECT ri.id, ri.item_type
FROM roadmap_items ri
JOIN roadmaps r ON r.id = ri.roadmap_id AND r.tenant_id = ri.tenant_id
WHERE ri.id = %s AND ri.tenant_id = %s AND r.initiative_id = %s
""",
(roadmap_item_id, tenant_id, initiative_id),
)
row = cur.fetchone()
if not row:
return None
return {"id": str(row["id"]), "item_type": row["item_type"]}
finally:
conn.close()
def _milestone_in_initiative(
*, tenant_id: str, initiative_id: str, milestone_id: str
) -> bool:
@ -93,7 +117,6 @@ def _milestone_in_initiative(
FROM roadmap_items ri
JOIN roadmaps r ON r.id = ri.roadmap_id AND r.tenant_id = ri.tenant_id
WHERE ri.id = %s AND ri.tenant_id = %s AND r.initiative_id = %s
AND ri.item_type = 'milestone'
""",
(milestone_id, tenant_id, initiative_id),
)
@ -102,6 +125,40 @@ def _milestone_in_initiative(
conn.close()
def _resolve_gate_links(
*,
tenant_id: str,
initiative_id: str,
milestone_id: Optional[str] = None,
roadmap_item_id: Optional[str] = None,
) -> tuple[Optional[str], Optional[str]]:
"""Gibt (milestone_id, roadmap_item_id) für Evidence-Insert zurück."""
resolved_milestone = milestone_id
resolved_roadmap = roadmap_item_id
if roadmap_item_id:
item = _roadmap_item_in_initiative(
tenant_id=tenant_id,
initiative_id=initiative_id,
roadmap_item_id=roadmap_item_id,
)
if not item:
raise ValueError("Plan-Element gehört nicht zum Vorhaben")
resolved_roadmap = item["id"]
if item["item_type"] == "milestone":
resolved_milestone = item["id"]
elif milestone_id:
if not _milestone_in_initiative(
tenant_id=tenant_id,
initiative_id=initiative_id,
milestone_id=milestone_id,
):
raise ValueError("Meilenstein gehört nicht zum Vorhaben")
resolved_roadmap = milestone_id
return resolved_milestone, resolved_roadmap
def create_evidence(
*,
tenant_id: str,
@ -111,6 +168,7 @@ def create_evidence(
status: EvidenceStatus = "submitted",
action_id: Optional[str] = None,
milestone_id: Optional[str] = None,
roadmap_item_id: Optional[str] = None,
submitted_by_actor_id: Optional[str] = None,
user_id: Optional[str] = None,
) -> dict[str, Any]:
@ -124,10 +182,12 @@ def create_evidence(
tenant_id=tenant_id, initiative_id=initiative_id, action_id=action_id
):
raise ValueError("Maßnahme gehört nicht zum Vorhaben")
if milestone_id and not _milestone_in_initiative(
tenant_id=tenant_id, initiative_id=initiative_id, milestone_id=milestone_id
):
raise ValueError("Meilenstein gehört nicht zum Vorhaben")
milestone_id, roadmap_item_id = _resolve_gate_links(
tenant_id=tenant_id,
initiative_id=initiative_id,
milestone_id=milestone_id,
roadmap_item_id=roadmap_item_id,
)
if submitted_by_actor_id and not _actor_in_tenant(
tenant_id=tenant_id, actor_id=submitted_by_actor_id
):
@ -139,10 +199,10 @@ def create_evidence(
cur.execute(
f"""
INSERT INTO evidence (
tenant_id, initiative_id, action_id, milestone_id,
tenant_id, initiative_id, action_id, milestone_id, roadmap_item_id,
title, description, status, submitted_by_actor_id
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING {_EVIDENCE_COLUMNS}
""",
(
@ -150,6 +210,7 @@ def create_evidence(
initiative_id,
action_id,
milestone_id,
roadmap_item_id,
title,
description,
status,
@ -221,8 +282,10 @@ def update_evidence(
status: Optional[EvidenceStatus] = None,
action_id: Optional[str] = None,
milestone_id: Optional[str] = None,
roadmap_item_id: Optional[str] = None,
clear_action_id: bool = False,
clear_milestone_id: bool = False,
clear_roadmap_item_id: bool = False,
) -> Optional[dict[str, Any]]:
existing = get_evidence(tenant_id=tenant_id, evidence_id=evidence_id)
if not existing:
@ -255,17 +318,20 @@ def update_evidence(
raise ValueError("Maßnahme gehört nicht zum Vorhaben")
updates.append("action_id = %s")
params.append(action_id)
if clear_milestone_id:
if clear_milestone_id or clear_roadmap_item_id:
updates.append("milestone_id = NULL")
elif milestone_id is not None:
if not _milestone_in_initiative(
updates.append("roadmap_item_id = NULL")
elif roadmap_item_id is not None or milestone_id is not None:
link_milestone, link_roadmap = _resolve_gate_links(
tenant_id=tenant_id,
initiative_id=existing["initiative_id"],
milestone_id=milestone_id,
):
raise ValueError("Meilenstein gehört nicht zum Vorhaben")
roadmap_item_id=roadmap_item_id,
)
updates.append("milestone_id = %s")
params.append(milestone_id)
params.append(link_milestone)
updates.append("roadmap_item_id = %s")
params.append(link_roadmap)
if not updates:
return existing

View File

@ -549,10 +549,10 @@ def verify_reached(
SELECT 1 FROM evidence
WHERE tenant_id = %s AND initiative_id = %s
AND status = 'accepted'
AND milestone_id = %s
AND (roadmap_item_id = %s OR milestone_id = %s)
LIMIT 1
""",
(tenant_id, initiative_id, item_id),
(tenant_id, initiative_id, item_id, item_id),
)
if cur.fetchone():
verify_reason = "evidence_accepted"
@ -562,10 +562,10 @@ def verify_reached(
SELECT 1 FROM reviews
WHERE tenant_id = %s AND initiative_id = %s
AND status = 'completed'
AND (milestone_id = %s)
AND (roadmap_item_id = %s OR milestone_id = %s)
LIMIT 1
""",
(tenant_id, initiative_id, item_id),
(tenant_id, initiative_id, item_id, item_id),
)
if cur.fetchone():
verify_reason = "review_completed"
@ -584,8 +584,10 @@ def verify_reached(
verify_reason = "gate_override_decision"
else:
raise ValueError(
"Verify fehlgeschlagen — accepted Evidence, completed Review "
"oder gate_override Decision erforderlich"
"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."
)
cur.execute(

View File

@ -71,7 +71,7 @@ def test_verify_reached_requires_evidence(client):
f"/api/initiatives/{initiative_id}/evidence",
json={
"title": "Nachweis",
"milestone_id": item["id"],
"roadmap_item_id": item["id"],
"status": "accepted",
},
headers=_auth(token),

View File

@ -1,3 +1,3 @@
APP_VERSION = "0.13.0-ap1.4"
DB_SCHEMA_VERSION = "010"
DB_SCHEMA_VERSION = "011"
APP_NAME = "jinkendo-kairo"

View File

@ -1,24 +1,50 @@
import { useState } from 'react'
import { EVIDENCE_STATUSES, EVIDENCE_STATUS_LABELS } from '../constants/status.js'
import { EVIDENCE_STATUSES, EVIDENCE_STATUS_LABELS, ROADMAP_ITEM_TYPE_LABELS } from '../constants/status.js'
import { StatusBadge } from './StatusBadge.jsx'
import { EmptyState } from './EmptyState.jsx'
export function EvidenceSection({ items, canManage, onCreate, onUpdateStatus, onDelete, busy }) {
function planElementLabel(items, roadmapItemId) {
if (!roadmapItemId) return null
const item = items.find((i) => i.id === roadmapItemId)
if (!item) return 'Plan-Element'
const type = ROADMAP_ITEM_TYPE_LABELS[item.item_type] || item.item_type
return `${type}: ${item.title}`
}
export function EvidenceSection({
items,
roadmapItems = [],
canManage,
onCreate,
onUpdateStatus,
onDelete,
busy,
}) {
const [title, setTitle] = useState('')
const [roadmapItemId, setRoadmapItemId] = useState('')
const [showForm, setShowForm] = useState(false)
async function handleSubmit(e) {
e.preventDefault()
if (!title.trim()) return
await onCreate({ title: title.trim() })
await onCreate({
title: title.trim(),
roadmap_item_id: roadmapItemId || undefined,
})
setTitle('')
setRoadmapItemId('')
setShowForm(false)
}
return (
<section className="card">
<div className="section-header">
<h2>Evidence</h2>
<div>
<h2>Evidence</h2>
<p className="section-lead muted">
Für Gate-Verify am Plan-Element verknüpfen und Status Akzeptiert setzen.
</p>
</div>
{canManage && (
<button
type="button"
@ -41,6 +67,22 @@ export function EvidenceSection({ items, canManage, onCreate, onUpdateStatus, on
required
/>
</label>
{roadmapItems.length > 0 && (
<label>
Plan-Element (optional)
<select
value={roadmapItemId}
onChange={(e) => setRoadmapItemId(e.target.value)}
>
<option value=""> nicht zugeordnet </option>
{roadmapItems.map((item) => (
<option key={item.id} value={item.id}>
{ROADMAP_ITEM_TYPE_LABELS[item.item_type] || item.item_type}: {item.title}
</option>
))}
</select>
</label>
)}
<button type="submit" className="btn btn-primary" disabled={busy}>
Anlegen
</button>
@ -55,6 +97,15 @@ export function EvidenceSection({ items, canManage, onCreate, onUpdateStatus, on
<div className="list-item-main">
<strong>{item.title}</strong>
{item.description && <p className="list-item-desc">{item.description}</p>}
{(item.roadmap_item_id || item.milestone_id) && (
<p className="muted list-item-sub">
Plan:{' '}
{planElementLabel(
roadmapItems,
item.roadmap_item_id || item.milestone_id
)}
</p>
)}
</div>
<div className="list-item-meta action-controls">
<StatusBadge kind="evidence" status={item.status} />

View File

@ -31,7 +31,7 @@ export function RoadmapPlanSection({
onCreate,
onUpdateStatus,
onUpdateItem,
onVerifyReached,
onVerifyWithEvidence,
onDelete,
busy,
}) {
@ -41,6 +41,7 @@ export function RoadmapPlanSection({
const [itemType, setItemType] = useState('milestone')
const [sequencingMode, setSequencingMode] = useState('sequential')
const [showForm, setShowForm] = useState(false)
const [verifyTitles, setVerifyTitles] = useState({})
async function handleSubmit(e) {
e.preventDefault()
@ -193,14 +194,41 @@ export function RoadmapPlanSection({
))}
</select>
{['planned', 'active', 'at_risk'].includes(item.status) && (
<button
type="button"
className="btn btn-primary"
disabled={busy}
onClick={() => onVerifyReached(item.id)}
>
Verify erreicht
</button>
<div className="gate-verify-block">
<label className="gate-verify-label">
Nachweis für Verify
<input
type="text"
value={verifyTitles[item.id] ?? ''}
placeholder={`Nachweis: ${item.title}`}
onChange={(e) =>
setVerifyTitles((prev) => ({
...prev,
[item.id]: e.target.value,
}))
}
maxLength={255}
/>
</label>
<button
type="button"
className="btn btn-primary"
disabled={busy}
onClick={() =>
onVerifyWithEvidence(
item.id,
verifyTitles[item.id]?.trim() || `Nachweis: ${item.title}`
)
}
>
Gate schließen
</button>
<p className="muted gate-verify-hint">
Legt akzeptiertes Evidence am Plan-Element an und setzt Status auf erreicht.
Bereits vorhandener Nachweis auf Journey: Plan-Element zuweisen und auf
Akzeptiert setzen, dann erneut Verify.
</p>
</div>
)}
<button
type="button"

View File

@ -347,6 +347,24 @@ export function InitiativeOperationsProvider({ children }) {
}
}
async function handleVerifyWithEvidence(itemId, evidenceTitle) {
setFormBusy(true)
setError(null)
try {
await createInitiativeEvidence(id, {
title: evidenceTitle,
roadmap_item_id: itemId,
status: 'accepted',
})
await verifyRoadmapItemReached(itemId)
await load()
} catch (err) {
setError(err.message)
} finally {
setFormBusy(false)
}
}
async function handleVerifyRoadmapItem(itemId) {
setFormBusy(true)
try {
@ -545,6 +563,7 @@ export function InitiativeOperationsProvider({ children }) {
handleRoadmapItemStatus,
handleUpdateRoadmapItem,
handleVerifyRoadmapItem,
handleVerifyWithEvidence,
handleDeleteRoadmapItem,
handleCreateMilestone: handleCreateRoadmapItem,
handleMilestoneStatus: handleRoadmapItemStatus,

View File

@ -45,6 +45,7 @@ export function InitiativeJourneyPage() {
/>
<EvidenceSection
items={ops.evidenceItems}
roadmapItems={ops.roadmapItems}
canManage={capabilities.has('kairo.evidence.manage')}
onCreate={ops.handleCreateEvidence}
onUpdateStatus={ops.handleEvidenceStatus}

View File

@ -9,7 +9,7 @@ export function InitiativePlanPage() {
handleCreateRoadmapItem,
handleRoadmapItemStatus,
handleUpdateRoadmapItem,
handleVerifyRoadmapItem,
handleVerifyWithEvidence,
handleDeleteRoadmapItem,
error,
} = useInitiativeOperations()
@ -27,7 +27,7 @@ export function InitiativePlanPage() {
onCreate={handleCreateRoadmapItem}
onUpdateStatus={handleRoadmapItemStatus}
onUpdateItem={handleUpdateRoadmapItem}
onVerifyReached={handleVerifyRoadmapItem}
onVerifyWithEvidence={handleVerifyWithEvidence}
onDelete={handleDeleteRoadmapItem}
busy={formBusy}
/>

View File

@ -1123,4 +1123,26 @@
margin-top: 0.5rem;
}
.gate-verify-block {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid var(--border-subtle, #e5e7eb);
min-width: 12rem;
}
.gate-verify-label {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.85rem;
}
.gate-verify-hint {
font-size: 0.8rem;
line-height: 1.35;
margin: 0;
}