From fdda8a9fae1da4b9415b88f20e9e8222b62fe9a2 Mon Sep 17 00:00:00 2001 From: Lars Date: Sun, 5 Jul 2026 19:24:18 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20Gate-Verify=20=E2=80=94=20Evidence=20an?= =?UTF-8?q?=20Plan-Element=20verkn=C3=BCpfen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/migrations/011_roadmap_item_links.sql | 29 ++++++ backend/routers/evidence.py | 4 + backend/routers/initiatives.py | 2 + backend/services/evidence.py | 96 ++++++++++++++++--- backend/services/roadmap.py | 14 +-- backend/tests/test_ap14_roadmap.py | 2 +- backend/version.py | 2 +- frontend/src/components/EvidenceSection.jsx | 59 +++++++++++- .../src/components/RoadmapPlanSection.jsx | 46 +++++++-- .../context/InitiativeOperationsContext.jsx | 19 ++++ .../initiative/InitiativeJourneyPage.jsx | 1 + .../pages/initiative/InitiativePlanPage.jsx | 4 +- frontend/src/styles/components.css | 22 +++++ 13 files changed, 262 insertions(+), 38 deletions(-) create mode 100644 backend/migrations/011_roadmap_item_links.sql diff --git a/backend/migrations/011_roadmap_item_links.sql b/backend/migrations/011_roadmap_item_links.sql new file mode 100644 index 0000000..639ddce --- /dev/null +++ b/backend/migrations/011_roadmap_item_links.sql @@ -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 + ); diff --git a/backend/routers/evidence.py b/backend/routers/evidence.py index 78403e4..3372dcc 100644 --- a/backend/routers/evidence.py +++ b/backend/routers/evidence.py @@ -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 diff --git a/backend/routers/initiatives.py b/backend/routers/initiatives.py index cfd939d..5721df2 100644 --- a/backend/routers/initiatives.py +++ b/backend/routers/initiatives.py @@ -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, ) diff --git a/backend/services/evidence.py b/backend/services/evidence.py index e4c23e2..90fcdcd 100644 --- a/backend/services/evidence.py +++ b/backend/services/evidence.py @@ -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 diff --git a/backend/services/roadmap.py b/backend/services/roadmap.py index 824f08a..4deb4a1 100644 --- a/backend/services/roadmap.py +++ b/backend/services/roadmap.py @@ -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( diff --git a/backend/tests/test_ap14_roadmap.py b/backend/tests/test_ap14_roadmap.py index 48cb814..0a5b2c4 100644 --- a/backend/tests/test_ap14_roadmap.py +++ b/backend/tests/test_ap14_roadmap.py @@ -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), diff --git a/backend/version.py b/backend/version.py index 93e66b0..0c95b54 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,3 +1,3 @@ APP_VERSION = "0.13.0-ap1.4" -DB_SCHEMA_VERSION = "010" +DB_SCHEMA_VERSION = "011" APP_NAME = "jinkendo-kairo" diff --git a/frontend/src/components/EvidenceSection.jsx b/frontend/src/components/EvidenceSection.jsx index 4e749c4..89eb320 100644 --- a/frontend/src/components/EvidenceSection.jsx +++ b/frontend/src/components/EvidenceSection.jsx @@ -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 (
-

Evidence

+
+

Evidence

+

+ Für Gate-Verify am Plan-Element verknüpfen und Status Akzeptiert setzen. +

+
{canManage && ( @@ -55,6 +97,15 @@ export function EvidenceSection({ items, canManage, onCreate, onUpdateStatus, on
{item.title} {item.description &&

{item.description}

} + {(item.roadmap_item_id || item.milestone_id) && ( +

+ Plan:{' '} + {planElementLabel( + roadmapItems, + item.roadmap_item_id || item.milestone_id + )} +

+ )}
diff --git a/frontend/src/components/RoadmapPlanSection.jsx b/frontend/src/components/RoadmapPlanSection.jsx index 93cd89d..2fe1f7f 100644 --- a/frontend/src/components/RoadmapPlanSection.jsx +++ b/frontend/src/components/RoadmapPlanSection.jsx @@ -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({ ))} {['planned', 'active', 'at_risk'].includes(item.status) && ( - +
+ + +

+ 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. +

+
)}