Compare commits
No commits in common. "09433b5d287a72700527671cb2b51b98337f31fc" and "687aad6cbd9f0fa687e99571c4064b531023477b" have entirely different histories.
09433b5d28
...
687aad6cbd
|
|
@ -5,17 +5,28 @@ Baut aus flachen OM-Tabellen einen erklärbaren Graph für UI und spätere Steer
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any, Optional
|
from typing import Any, Literal, Optional
|
||||||
|
|
||||||
from psycopg2.extras import RealDictCursor
|
from psycopg2.extras import RealDictCursor
|
||||||
|
|
||||||
from db import get_connection
|
from db import get_connection
|
||||||
from data_layer.attention import get_next_action_candidates_for_initiative
|
from data_layer.attention import get_next_action_candidates_for_initiative
|
||||||
from steering.context import get_steering_context_dto
|
from steering.context import get_steering_context_dto
|
||||||
from steering.signals.snapshot_signals import derive_initiative_signals
|
|
||||||
from services.initiatives import get_initiative
|
from services.initiatives import get_initiative
|
||||||
from tenant_context import TenantContext
|
from tenant_context import TenantContext
|
||||||
|
|
||||||
|
OperatingPhase = Literal[
|
||||||
|
"capture",
|
||||||
|
"triage",
|
||||||
|
"structure",
|
||||||
|
"commit",
|
||||||
|
"execute",
|
||||||
|
"verify",
|
||||||
|
"review",
|
||||||
|
"adapt",
|
||||||
|
"closure",
|
||||||
|
]
|
||||||
|
|
||||||
OPEN_BLOCKER = ("open", "in_progress")
|
OPEN_BLOCKER = ("open", "in_progress")
|
||||||
OPEN_ACTION = ("open", "ready", "in_progress", "blocked", "review_required")
|
OPEN_ACTION = ("open", "ready", "in_progress", "blocked", "review_required")
|
||||||
ACTIVE_INITIATIVE = ("active", "paused")
|
ACTIVE_INITIATIVE = ("active", "paused")
|
||||||
|
|
@ -29,6 +40,51 @@ def _iso(value: Any) -> Optional[str]:
|
||||||
return value.isoformat() if value else None
|
return value.isoformat() if value else None
|
||||||
|
|
||||||
|
|
||||||
|
def _derive_operating_phase(
|
||||||
|
*,
|
||||||
|
initiative_status: str,
|
||||||
|
open_actions: int,
|
||||||
|
blocked_actions: int,
|
||||||
|
review_required_actions: int,
|
||||||
|
open_blockers: int,
|
||||||
|
backlog_new: int,
|
||||||
|
planned_reviews_due: int,
|
||||||
|
) -> tuple[OperatingPhase, list[str]]:
|
||||||
|
"""Heuristische, erklärbare Phase — kein Lifecycle-State-Machine-Ersatz."""
|
||||||
|
signals: list[str] = []
|
||||||
|
|
||||||
|
if initiative_status in ("completed", "archived"):
|
||||||
|
return "closure", ["initiative_terminal"]
|
||||||
|
|
||||||
|
if open_actions == 0 and backlog_new > 0:
|
||||||
|
signals.append("backlog_awaiting_commit")
|
||||||
|
return "triage", signals
|
||||||
|
|
||||||
|
if open_actions == 0 and backlog_new == 0:
|
||||||
|
signals.append("no_open_work")
|
||||||
|
return "structure", signals
|
||||||
|
|
||||||
|
if blocked_actions > 0 or open_blockers > 0:
|
||||||
|
if blocked_actions:
|
||||||
|
signals.append("actions_blocked")
|
||||||
|
if open_blockers:
|
||||||
|
signals.append("open_blockers")
|
||||||
|
return "execute", signals
|
||||||
|
|
||||||
|
if review_required_actions > 0 or planned_reviews_due > 0:
|
||||||
|
if review_required_actions:
|
||||||
|
signals.append("actions_need_review")
|
||||||
|
if planned_reviews_due:
|
||||||
|
signals.append("reviews_due")
|
||||||
|
return "review", signals
|
||||||
|
|
||||||
|
if open_actions > 0:
|
||||||
|
signals.append("work_in_progress")
|
||||||
|
return "execute", signals
|
||||||
|
|
||||||
|
return "adapt", signals
|
||||||
|
|
||||||
|
|
||||||
def get_initiative_steering_snapshot(
|
def get_initiative_steering_snapshot(
|
||||||
ctx: TenantContext, *, initiative_id: str
|
ctx: TenantContext, *, initiative_id: str
|
||||||
) -> Optional[dict[str, Any]]:
|
) -> Optional[dict[str, Any]]:
|
||||||
|
|
@ -99,16 +155,14 @@ def get_initiative_steering_snapshot(
|
||||||
|
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
SELECT id, title, status, target_date, item_type, sequencing_mode,
|
SELECT id, title, status, target_date, created_at, updated_at
|
||||||
sort_order, created_at, updated_at
|
FROM milestones
|
||||||
FROM roadmap_items ri
|
WHERE tenant_id = %s AND initiative_id = %s
|
||||||
JOIN roadmaps r ON r.id = ri.roadmap_id AND r.tenant_id = ri.tenant_id
|
ORDER BY updated_at DESC
|
||||||
WHERE ri.tenant_id = %s AND r.initiative_id = %s
|
|
||||||
ORDER BY ri.sort_order ASC, ri.updated_at DESC
|
|
||||||
""",
|
""",
|
||||||
(ctx.tenant_id, initiative_id),
|
(ctx.tenant_id, initiative_id),
|
||||||
)
|
)
|
||||||
roadmap_items_raw = [dict(r) for r in cur.fetchall()]
|
milestones_raw = [dict(r) for r in cur.fetchall()]
|
||||||
|
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
|
|
@ -216,17 +270,14 @@ def get_initiative_steering_snapshot(
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
roadmap_items: list[dict[str, Any]] = []
|
milestones = []
|
||||||
for m in roadmap_items_raw:
|
for m in milestones_raw:
|
||||||
mid = _sid(m["id"])
|
mid = _sid(m["id"])
|
||||||
roadmap_items.append(
|
milestones.append(
|
||||||
{
|
{
|
||||||
"id": mid,
|
"id": mid,
|
||||||
"title": m["title"],
|
"title": m["title"],
|
||||||
"status": m["status"],
|
"status": m["status"],
|
||||||
"item_type": m["item_type"],
|
|
||||||
"sequencing_mode": m["sequencing_mode"],
|
|
||||||
"sort_order": m.get("sort_order", 0),
|
|
||||||
"target_date": m["target_date"].isoformat() if m.get("target_date") else None,
|
"target_date": m["target_date"].isoformat() if m.get("target_date") else None,
|
||||||
"reviews": reviews_by_milestone.get(mid, []),
|
"reviews": reviews_by_milestone.get(mid, []),
|
||||||
}
|
}
|
||||||
|
|
@ -237,7 +288,7 @@ def get_initiative_steering_snapshot(
|
||||||
)
|
)
|
||||||
backlog_new = sum(1 for bi in backlog if bi["status"] in ("new", "triaged", "accepted"))
|
backlog_new = sum(1 for bi in backlog if bi["status"] in ("new", "triaged", "accepted"))
|
||||||
|
|
||||||
signals = derive_initiative_signals(
|
phase, phase_signals = _derive_operating_phase(
|
||||||
initiative_status=initiative["status"],
|
initiative_status=initiative["status"],
|
||||||
open_actions=open_actions,
|
open_actions=open_actions,
|
||||||
blocked_actions=blocked_actions,
|
blocked_actions=blocked_actions,
|
||||||
|
|
@ -250,7 +301,7 @@ def get_initiative_steering_snapshot(
|
||||||
upcoming_milestones = sorted(
|
upcoming_milestones = sorted(
|
||||||
[
|
[
|
||||||
m
|
m
|
||||||
for m in roadmap_items
|
for m in milestones
|
||||||
if m["status"] in ("planned", "active", "at_risk")
|
if m["status"] in ("planned", "active", "at_risk")
|
||||||
],
|
],
|
||||||
key=lambda m: (m["target_date"] is None, m["target_date"] or ""),
|
key=lambda m: (m["target_date"] is None, m["target_date"] or ""),
|
||||||
|
|
@ -271,9 +322,10 @@ def get_initiative_steering_snapshot(
|
||||||
"method_key": steering["method_key"],
|
"method_key": steering["method_key"],
|
||||||
"method_label": steering.get("method_label"),
|
"method_label": steering.get("method_label"),
|
||||||
"method_version": steering["method_version"],
|
"method_version": steering["method_version"],
|
||||||
"signals": signals,
|
"operating_phase": phase,
|
||||||
|
"operating_phase_deprecated": True,
|
||||||
|
"phase_signals": phase_signals,
|
||||||
"upcoming_milestones": upcoming_milestones,
|
"upcoming_milestones": upcoming_milestones,
|
||||||
"upcoming_roadmap_items": upcoming_milestones,
|
|
||||||
"next_actions": next_actions,
|
"next_actions": next_actions,
|
||||||
"counts": {
|
"counts": {
|
||||||
"actions_open": open_actions,
|
"actions_open": open_actions,
|
||||||
|
|
@ -289,8 +341,7 @@ def get_initiative_steering_snapshot(
|
||||||
"unlinked_reviews": len(unlinked_reviews),
|
"unlinked_reviews": len(unlinked_reviews),
|
||||||
},
|
},
|
||||||
"actions": actions,
|
"actions": actions,
|
||||||
"roadmap_items": roadmap_items,
|
"milestones": milestones,
|
||||||
"milestones": roadmap_items,
|
|
||||||
"initiative_level": {
|
"initiative_level": {
|
||||||
"blockers": unlinked_blockers,
|
"blockers": unlinked_blockers,
|
||||||
"evidence": unlinked_evidence,
|
"evidence": unlinked_evidence,
|
||||||
|
|
|
||||||
|
|
@ -68,7 +68,6 @@ from routers import ( # noqa: E402
|
||||||
initiatives,
|
initiatives,
|
||||||
me,
|
me,
|
||||||
milestones,
|
milestones,
|
||||||
roadmap,
|
|
||||||
prompts,
|
prompts,
|
||||||
recurring,
|
recurring,
|
||||||
reviews,
|
reviews,
|
||||||
|
|
@ -86,8 +85,6 @@ app.include_router(actions.router)
|
||||||
app.include_router(blockers.router)
|
app.include_router(blockers.router)
|
||||||
app.include_router(backlog.router)
|
app.include_router(backlog.router)
|
||||||
app.include_router(milestones.router)
|
app.include_router(milestones.router)
|
||||||
app.include_router(roadmap.initiative_router)
|
|
||||||
app.include_router(roadmap.items_router)
|
|
||||||
app.include_router(evidence.router)
|
app.include_router(evidence.router)
|
||||||
app.include_router(decisions.router)
|
app.include_router(decisions.router)
|
||||||
app.include_router(reviews.router)
|
app.include_router(reviews.router)
|
||||||
|
|
|
||||||
|
|
@ -1,86 +0,0 @@
|
||||||
-- AP1.4: Roadmap + RoadmapItem + Dependencies (methodenneutral)
|
|
||||||
|
|
||||||
CREATE TABLE roadmaps (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
|
||||||
initiative_id UUID NOT NULL REFERENCES initiatives(id) ON DELETE CASCADE,
|
|
||||||
title VARCHAR(255) NOT NULL DEFAULT 'Plan',
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
UNIQUE (tenant_id, initiative_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX idx_roadmaps_tenant_initiative ON roadmaps(tenant_id, initiative_id);
|
|
||||||
|
|
||||||
CREATE TABLE roadmap_items (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
|
||||||
roadmap_id UUID NOT NULL REFERENCES roadmaps(id) ON DELETE CASCADE,
|
|
||||||
item_type VARCHAR(32) NOT NULL DEFAULT 'milestone'
|
|
||||||
CHECK (item_type IN ('milestone', 'review_gate', 'maturity_stage')),
|
|
||||||
title VARCHAR(255) NOT NULL,
|
|
||||||
goal_description TEXT NOT NULL DEFAULT '',
|
|
||||||
definition_of_done JSONB NOT NULL DEFAULT '[]'::jsonb,
|
|
||||||
status VARCHAR(32) NOT NULL DEFAULT 'planned'
|
|
||||||
CHECK (status IN ('planned', 'active', 'at_risk', 'reached', 'moved', 'discarded')),
|
|
||||||
sequencing_mode VARCHAR(32) NOT NULL DEFAULT 'sequential'
|
|
||||||
CHECK (sequencing_mode IN ('sequential', 'parallel', 'optional')),
|
|
||||||
target_date DATE NULL,
|
|
||||||
sort_order INT NOT NULL DEFAULT 0,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX idx_roadmap_items_tenant_roadmap ON roadmap_items(tenant_id, roadmap_id);
|
|
||||||
CREATE INDEX idx_roadmap_items_tenant_status ON roadmap_items(tenant_id, status);
|
|
||||||
CREATE INDEX idx_roadmap_items_tenant_type ON roadmap_items(tenant_id, item_type);
|
|
||||||
|
|
||||||
CREATE TABLE roadmap_item_dependencies (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
|
||||||
from_item_id UUID NOT NULL REFERENCES roadmap_items(id) ON DELETE CASCADE,
|
|
||||||
to_item_id UUID NOT NULL REFERENCES roadmap_items(id) ON DELETE CASCADE,
|
|
||||||
dependency_type VARCHAR(32) NOT NULL DEFAULT 'requires'
|
|
||||||
CHECK (dependency_type IN ('requires', 'blocks', 'related')),
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
UNIQUE (tenant_id, from_item_id, to_item_id, dependency_type)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX idx_roadmap_deps_from ON roadmap_item_dependencies(tenant_id, from_item_id);
|
|
||||||
CREATE INDEX idx_roadmap_deps_to ON roadmap_item_dependencies(tenant_id, to_item_id);
|
|
||||||
|
|
||||||
ALTER TABLE backlog_items
|
|
||||||
ADD COLUMN roadmap_item_id UUID NULL REFERENCES roadmap_items(id) ON DELETE SET NULL;
|
|
||||||
|
|
||||||
ALTER TABLE actions
|
|
||||||
ADD COLUMN roadmap_item_id UUID NULL REFERENCES roadmap_items(id) ON DELETE SET NULL;
|
|
||||||
|
|
||||||
CREATE INDEX idx_backlog_roadmap_item ON backlog_items(tenant_id, roadmap_item_id)
|
|
||||||
WHERE roadmap_item_id IS NOT NULL;
|
|
||||||
CREATE INDEX idx_actions_roadmap_item ON actions(tenant_id, roadmap_item_id)
|
|
||||||
WHERE roadmap_item_id IS NOT NULL;
|
|
||||||
|
|
||||||
-- Ein Plan pro Initiative
|
|
||||||
INSERT INTO roadmaps (tenant_id, initiative_id, title)
|
|
||||||
SELECT tenant_id, id, 'Plan' FROM initiatives;
|
|
||||||
|
|
||||||
-- Bestehende Meilensteine → RoadmapItems (IDs beibehalten für Evidence/Review-FKs)
|
|
||||||
INSERT INTO roadmap_items (
|
|
||||||
id, tenant_id, roadmap_id, item_type, title, goal_description, status,
|
|
||||||
sequencing_mode, target_date, sort_order, created_at, updated_at
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
m.id,
|
|
||||||
m.tenant_id,
|
|
||||||
r.id,
|
|
||||||
'milestone',
|
|
||||||
m.title,
|
|
||||||
m.goal_description,
|
|
||||||
m.status,
|
|
||||||
'sequential',
|
|
||||||
m.target_date,
|
|
||||||
0,
|
|
||||||
m.created_at,
|
|
||||||
m.updated_at
|
|
||||||
FROM milestones m
|
|
||||||
JOIN roadmaps r ON r.initiative_id = m.initiative_id AND r.tenant_id = m.tenant_id;
|
|
||||||
|
|
@ -1,198 +0,0 @@
|
||||||
"""Roadmap API — AP1.4."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import date
|
|
||||||
from typing import Any, Literal, Optional
|
|
||||||
|
|
||||||
from capabilities import require_capability
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
from services import roadmap as roadmap_service
|
|
||||||
from tenant_context import TenantContext
|
|
||||||
|
|
||||||
router = APIRouter(tags=["roadmap"])
|
|
||||||
|
|
||||||
|
|
||||||
class RoadmapItemCreateRequest(BaseModel):
|
|
||||||
title: str = Field(min_length=1, max_length=255)
|
|
||||||
item_type: Literal["milestone", "review_gate", "maturity_stage"] = "milestone"
|
|
||||||
goal_description: str = ""
|
|
||||||
definition_of_done: list[Any] = Field(default_factory=list)
|
|
||||||
status: Literal[
|
|
||||||
"planned", "active", "at_risk", "reached", "moved", "discarded"
|
|
||||||
] = "planned"
|
|
||||||
sequencing_mode: Literal["sequential", "parallel", "optional"] = "sequential"
|
|
||||||
target_date: Optional[date] = None
|
|
||||||
sort_order: int = 0
|
|
||||||
|
|
||||||
|
|
||||||
class RoadmapItemUpdateRequest(BaseModel):
|
|
||||||
title: Optional[str] = Field(default=None, min_length=1, max_length=255)
|
|
||||||
item_type: Optional[Literal["milestone", "review_gate", "maturity_stage"]] = None
|
|
||||||
goal_description: Optional[str] = None
|
|
||||||
definition_of_done: Optional[list[Any]] = None
|
|
||||||
status: Optional[
|
|
||||||
Literal["planned", "active", "at_risk", "reached", "moved", "discarded"]
|
|
||||||
] = None
|
|
||||||
sequencing_mode: Optional[Literal["sequential", "parallel", "optional"]] = None
|
|
||||||
target_date: Optional[date] = None
|
|
||||||
clear_target_date: bool = False
|
|
||||||
sort_order: Optional[int] = None
|
|
||||||
|
|
||||||
|
|
||||||
class DependencyCreateRequest(BaseModel):
|
|
||||||
to_item_id: str
|
|
||||||
dependency_type: Literal["requires", "blocks", "related"] = "requires"
|
|
||||||
|
|
||||||
|
|
||||||
initiative_router = APIRouter(prefix="/api/initiatives", tags=["roadmap"])
|
|
||||||
items_router = APIRouter(prefix="/api/roadmap-items", tags=["roadmap"])
|
|
||||||
|
|
||||||
|
|
||||||
@initiative_router.get("/{initiative_id}/roadmap")
|
|
||||||
def get_initiative_roadmap(
|
|
||||||
initiative_id: str,
|
|
||||||
ctx: TenantContext = Depends(require_capability("kairo.milestone.read")),
|
|
||||||
):
|
|
||||||
try:
|
|
||||||
roadmap = roadmap_service.get_roadmap_for_initiative(
|
|
||||||
tenant_id=ctx.tenant_id, initiative_id=initiative_id
|
|
||||||
)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
||||||
if not roadmap:
|
|
||||||
return {"initiative_id": initiative_id, "items": []}
|
|
||||||
items = roadmap_service.list_roadmap_items_for_initiative(
|
|
||||||
tenant_id=ctx.tenant_id, initiative_id=initiative_id
|
|
||||||
)
|
|
||||||
return {**roadmap, "items": items}
|
|
||||||
|
|
||||||
|
|
||||||
@initiative_router.get("/{initiative_id}/roadmap/items")
|
|
||||||
def list_initiative_roadmap_items(
|
|
||||||
initiative_id: str,
|
|
||||||
ctx: TenantContext = Depends(require_capability("kairo.milestone.read")),
|
|
||||||
):
|
|
||||||
try:
|
|
||||||
return roadmap_service.list_roadmap_items_for_initiative(
|
|
||||||
tenant_id=ctx.tenant_id, initiative_id=initiative_id
|
|
||||||
)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
||||||
|
|
||||||
|
|
||||||
@initiative_router.post("/{initiative_id}/roadmap/items", status_code=201)
|
|
||||||
def create_initiative_roadmap_item(
|
|
||||||
initiative_id: str,
|
|
||||||
body: RoadmapItemCreateRequest,
|
|
||||||
ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")),
|
|
||||||
):
|
|
||||||
try:
|
|
||||||
return roadmap_service.create_roadmap_item(
|
|
||||||
tenant_id=ctx.tenant_id,
|
|
||||||
initiative_id=initiative_id,
|
|
||||||
user_id=ctx.user_id,
|
|
||||||
title=body.title,
|
|
||||||
item_type=body.item_type,
|
|
||||||
goal_description=body.goal_description,
|
|
||||||
definition_of_done=body.definition_of_done,
|
|
||||||
status=body.status,
|
|
||||||
sequencing_mode=body.sequencing_mode,
|
|
||||||
target_date=body.target_date,
|
|
||||||
sort_order=body.sort_order,
|
|
||||||
)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
||||||
|
|
||||||
|
|
||||||
@items_router.get("/{item_id}")
|
|
||||||
def get_roadmap_item(
|
|
||||||
item_id: str,
|
|
||||||
ctx: TenantContext = Depends(require_capability("kairo.milestone.read")),
|
|
||||||
):
|
|
||||||
item = roadmap_service.get_roadmap_item(tenant_id=ctx.tenant_id, item_id=item_id)
|
|
||||||
if not item:
|
|
||||||
raise HTTPException(status_code=404, detail="RoadmapItem nicht gefunden")
|
|
||||||
return item
|
|
||||||
|
|
||||||
|
|
||||||
@items_router.patch("/{item_id}")
|
|
||||||
def update_roadmap_item(
|
|
||||||
item_id: str,
|
|
||||||
body: RoadmapItemUpdateRequest,
|
|
||||||
ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")),
|
|
||||||
):
|
|
||||||
try:
|
|
||||||
item = roadmap_service.update_roadmap_item(
|
|
||||||
tenant_id=ctx.tenant_id,
|
|
||||||
item_id=item_id,
|
|
||||||
user_id=ctx.user_id,
|
|
||||||
title=body.title,
|
|
||||||
item_type=body.item_type,
|
|
||||||
goal_description=body.goal_description,
|
|
||||||
definition_of_done=body.definition_of_done,
|
|
||||||
status=body.status,
|
|
||||||
sequencing_mode=body.sequencing_mode,
|
|
||||||
target_date=body.target_date,
|
|
||||||
clear_target_date=body.clear_target_date,
|
|
||||||
sort_order=body.sort_order,
|
|
||||||
)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
||||||
if not item:
|
|
||||||
raise HTTPException(status_code=404, detail="RoadmapItem nicht gefunden")
|
|
||||||
return item
|
|
||||||
|
|
||||||
|
|
||||||
@items_router.delete("/{item_id}", status_code=204)
|
|
||||||
def delete_roadmap_item(
|
|
||||||
item_id: str,
|
|
||||||
ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")),
|
|
||||||
):
|
|
||||||
if not roadmap_service.delete_roadmap_item(
|
|
||||||
tenant_id=ctx.tenant_id, item_id=item_id, user_id=ctx.user_id
|
|
||||||
):
|
|
||||||
raise HTTPException(status_code=404, detail="RoadmapItem nicht gefunden")
|
|
||||||
|
|
||||||
|
|
||||||
@items_router.post("/{item_id}/verify-reached")
|
|
||||||
def verify_roadmap_item_reached(
|
|
||||||
item_id: str,
|
|
||||||
ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")),
|
|
||||||
):
|
|
||||||
try:
|
|
||||||
return roadmap_service.verify_reached(
|
|
||||||
tenant_id=ctx.tenant_id, item_id=item_id, user_id=ctx.user_id
|
|
||||||
)
|
|
||||||
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,
|
|
||||||
ctx: TenantContext = Depends(require_capability("kairo.milestone.read")),
|
|
||||||
):
|
|
||||||
try:
|
|
||||||
return roadmap_service.list_dependencies(tenant_id=ctx.tenant_id, item_id=item_id)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
||||||
|
|
||||||
|
|
||||||
@items_router.post("/{item_id}/dependencies", status_code=201)
|
|
||||||
def add_roadmap_item_dependency(
|
|
||||||
item_id: str,
|
|
||||||
body: DependencyCreateRequest,
|
|
||||||
ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")),
|
|
||||||
):
|
|
||||||
try:
|
|
||||||
return roadmap_service.add_dependency(
|
|
||||||
tenant_id=ctx.tenant_id,
|
|
||||||
from_item_id=item_id,
|
|
||||||
to_item_id=body.to_item_id,
|
|
||||||
dependency_type=body.dependency_type,
|
|
||||||
user_id=ctx.user_id,
|
|
||||||
)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
||||||
|
|
@ -85,18 +85,6 @@ def _milestone_in_initiative(
|
||||||
""",
|
""",
|
||||||
(milestone_id, tenant_id, initiative_id),
|
(milestone_id, tenant_id, initiative_id),
|
||||||
)
|
)
|
||||||
if cur.fetchone():
|
|
||||||
return True
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT 1
|
|
||||||
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),
|
|
||||||
)
|
|
||||||
return cur.fetchone() is not None
|
return cur.fetchone() is not None
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
|
||||||
|
|
@ -1,31 +1,46 @@
|
||||||
"""Milestone service — Kompatibilitäts-Wrapper auf RoadmapItems (AP0.8d → AP1.4)."""
|
"""Milestone service — tenant-scoped CRUD (AP0.8d)."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from typing import Any, Literal, Optional
|
from typing import Any, Literal, Optional
|
||||||
|
|
||||||
from services import roadmap as roadmap_service
|
from psycopg2.extras import RealDictCursor
|
||||||
|
|
||||||
|
from db import get_connection
|
||||||
|
from services.audit import log_audit
|
||||||
|
from services.initiatives import get_initiative
|
||||||
|
|
||||||
MilestoneStatus = Literal[
|
MilestoneStatus = Literal[
|
||||||
"planned", "active", "at_risk", "reached", "moved", "discarded"
|
"planned", "active", "at_risk", "reached", "moved", "discarded"
|
||||||
]
|
]
|
||||||
|
|
||||||
MILESTONE_STATUSES = roadmap_service.ROADMAP_ITEM_STATUSES
|
MILESTONE_STATUSES = frozenset(
|
||||||
|
{"planned", "active", "at_risk", "reached", "moved", "discarded"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _as_milestone(item: dict[str, Any]) -> dict[str, Any]:
|
def _serialize_row(row: dict[str, Any]) -> dict[str, Any]:
|
||||||
return {
|
result = dict(row)
|
||||||
"id": item["id"],
|
for key in ("id", "tenant_id", "initiative_id"):
|
||||||
"tenant_id": item["tenant_id"],
|
if result.get(key):
|
||||||
"initiative_id": item["initiative_id"],
|
result[key] = str(result[key])
|
||||||
"title": item["title"],
|
if result.get("target_date"):
|
||||||
"goal_description": item.get("goal_description", ""),
|
result["target_date"] = (
|
||||||
"status": item["status"],
|
result["target_date"].isoformat()
|
||||||
"target_date": item.get("target_date"),
|
if hasattr(result["target_date"], "isoformat")
|
||||||
"created_at": item["created_at"],
|
else str(result["target_date"])
|
||||||
"updated_at": item["updated_at"],
|
)
|
||||||
}
|
if result.get("created_at"):
|
||||||
|
result["created_at"] = result["created_at"].isoformat()
|
||||||
|
if result.get("updated_at"):
|
||||||
|
result["updated_at"] = result["updated_at"].isoformat()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_status(status: str) -> None:
|
||||||
|
if status not in MILESTONE_STATUSES:
|
||||||
|
raise ValueError(f"Ungültiger Meilenstein-Status: {status}")
|
||||||
|
|
||||||
|
|
||||||
def create_milestone(
|
def create_milestone(
|
||||||
|
|
@ -38,31 +53,80 @@ def create_milestone(
|
||||||
target_date: Optional[date] = None,
|
target_date: Optional[date] = None,
|
||||||
user_id: Optional[str] = None,
|
user_id: Optional[str] = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
item = roadmap_service.create_roadmap_item(
|
title = title.strip()
|
||||||
tenant_id=tenant_id,
|
if not title:
|
||||||
initiative_id=initiative_id,
|
raise ValueError("Titel ist erforderlich")
|
||||||
title=title,
|
_validate_status(status)
|
||||||
item_type="milestone",
|
if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id):
|
||||||
goal_description=goal_description,
|
raise ValueError("Initiative nicht gefunden")
|
||||||
status=status,
|
|
||||||
target_date=target_date,
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO milestones (
|
||||||
|
tenant_id, initiative_id, title, goal_description, status, target_date
|
||||||
|
)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s)
|
||||||
|
RETURNING id, tenant_id, initiative_id, title, goal_description, status,
|
||||||
|
target_date, created_at, updated_at
|
||||||
|
""",
|
||||||
|
(tenant_id, initiative_id, title, goal_description, status, target_date),
|
||||||
|
)
|
||||||
|
row = _serialize_row(dict(cur.fetchone()))
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
log_audit(
|
||||||
|
"milestone.created",
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
details={"milestone_id": row["id"], "initiative_id": initiative_id, "title": title},
|
||||||
)
|
)
|
||||||
return _as_milestone(item)
|
return row
|
||||||
|
|
||||||
|
|
||||||
def list_milestones_for_initiative(*, tenant_id: str, initiative_id: str) -> list[dict[str, Any]]:
|
def list_milestones_for_initiative(*, tenant_id: str, initiative_id: str) -> list[dict[str, Any]]:
|
||||||
items = roadmap_service.list_roadmap_items_for_initiative(
|
if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id):
|
||||||
tenant_id=tenant_id, initiative_id=initiative_id
|
raise ValueError("Initiative nicht gefunden")
|
||||||
)
|
|
||||||
return [_as_milestone(i) for i in items if i["item_type"] == "milestone"]
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, tenant_id, initiative_id, title, goal_description, status,
|
||||||
|
target_date, created_at, updated_at
|
||||||
|
FROM milestones
|
||||||
|
WHERE tenant_id = %s AND initiative_id = %s
|
||||||
|
ORDER BY target_date NULLS LAST, updated_at DESC, title
|
||||||
|
""",
|
||||||
|
(tenant_id, initiative_id),
|
||||||
|
)
|
||||||
|
return [_serialize_row(dict(r)) for r in cur.fetchall()]
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
def get_milestone(*, tenant_id: str, milestone_id: str) -> Optional[dict[str, Any]]:
|
def get_milestone(*, tenant_id: str, milestone_id: str) -> Optional[dict[str, Any]]:
|
||||||
item = roadmap_service.get_roadmap_item(tenant_id=tenant_id, item_id=milestone_id)
|
conn = get_connection()
|
||||||
if not item or item["item_type"] != "milestone":
|
try:
|
||||||
return None
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
return _as_milestone(item)
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, tenant_id, initiative_id, title, goal_description, status,
|
||||||
|
target_date, created_at, updated_at
|
||||||
|
FROM milestones
|
||||||
|
WHERE id = %s AND tenant_id = %s
|
||||||
|
""",
|
||||||
|
(milestone_id, tenant_id),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
return _serialize_row(dict(row)) if row else None
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
def update_milestone(
|
def update_milestone(
|
||||||
|
|
@ -76,17 +140,78 @@ def update_milestone(
|
||||||
target_date: Optional[date] = None,
|
target_date: Optional[date] = None,
|
||||||
clear_target_date: bool = False,
|
clear_target_date: bool = False,
|
||||||
) -> Optional[dict[str, Any]]:
|
) -> Optional[dict[str, Any]]:
|
||||||
item = roadmap_service.update_roadmap_item(
|
existing = get_milestone(tenant_id=tenant_id, milestone_id=milestone_id)
|
||||||
tenant_id=tenant_id,
|
if not existing:
|
||||||
item_id=milestone_id,
|
return None
|
||||||
|
|
||||||
|
old_status = existing["status"]
|
||||||
|
updates: list[str] = []
|
||||||
|
params: list[Any] = []
|
||||||
|
|
||||||
|
if title is not None:
|
||||||
|
title = title.strip()
|
||||||
|
if not title:
|
||||||
|
raise ValueError("Titel ist erforderlich")
|
||||||
|
updates.append("title = %s")
|
||||||
|
params.append(title)
|
||||||
|
if goal_description is not None:
|
||||||
|
updates.append("goal_description = %s")
|
||||||
|
params.append(goal_description)
|
||||||
|
if status is not None:
|
||||||
|
_validate_status(status)
|
||||||
|
updates.append("status = %s")
|
||||||
|
params.append(status)
|
||||||
|
if clear_target_date:
|
||||||
|
updates.append("target_date = NULL")
|
||||||
|
elif target_date is not None:
|
||||||
|
updates.append("target_date = %s")
|
||||||
|
params.append(target_date)
|
||||||
|
|
||||||
|
if not updates:
|
||||||
|
return existing
|
||||||
|
|
||||||
|
updates.append("updated_at = NOW()")
|
||||||
|
params.extend([milestone_id, tenant_id])
|
||||||
|
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute(
|
||||||
|
f"""
|
||||||
|
UPDATE milestones
|
||||||
|
SET {", ".join(updates)}
|
||||||
|
WHERE id = %s AND tenant_id = %s
|
||||||
|
RETURNING id, tenant_id, initiative_id, title, goal_description, status,
|
||||||
|
target_date, created_at, updated_at
|
||||||
|
""",
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
result = _serialize_row(dict(row))
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
log_audit(
|
||||||
|
"milestone.updated",
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
title=title,
|
tenant_id=tenant_id,
|
||||||
goal_description=goal_description,
|
details={"milestone_id": milestone_id},
|
||||||
status=status,
|
|
||||||
target_date=target_date,
|
|
||||||
clear_target_date=clear_target_date,
|
|
||||||
)
|
)
|
||||||
return _as_milestone(item) if item else None
|
if status is not None and status != old_status:
|
||||||
|
log_audit(
|
||||||
|
"milestone.status_changed",
|
||||||
|
user_id=user_id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
details={
|
||||||
|
"milestone_id": milestone_id,
|
||||||
|
"from_status": old_status,
|
||||||
|
"to_status": status,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def delete_milestone(
|
def delete_milestone(
|
||||||
|
|
@ -95,9 +220,23 @@ def delete_milestone(
|
||||||
milestone_id: str,
|
milestone_id: str,
|
||||||
user_id: Optional[str] = None,
|
user_id: Optional[str] = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
item = roadmap_service.get_roadmap_item(tenant_id=tenant_id, item_id=milestone_id)
|
conn = get_connection()
|
||||||
if not item or item["item_type"] != "milestone":
|
try:
|
||||||
return False
|
with conn.cursor() as cur:
|
||||||
return roadmap_service.delete_roadmap_item(
|
cur.execute(
|
||||||
tenant_id=tenant_id, item_id=milestone_id, user_id=user_id
|
"DELETE FROM milestones WHERE id = %s AND tenant_id = %s RETURNING id",
|
||||||
)
|
(milestone_id, tenant_id),
|
||||||
|
)
|
||||||
|
deleted = cur.fetchone() is not None
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if deleted:
|
||||||
|
log_audit(
|
||||||
|
"milestone.deleted",
|
||||||
|
user_id=user_id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
details={"milestone_id": milestone_id},
|
||||||
|
)
|
||||||
|
return deleted
|
||||||
|
|
|
||||||
|
|
@ -1,621 +0,0 @@
|
||||||
"""Roadmap service — tenant-scoped Plan/Gates (AP1.4)."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
from datetime import date
|
|
||||||
from typing import Any, Literal, Optional
|
|
||||||
|
|
||||||
from psycopg2.extras import RealDictCursor
|
|
||||||
|
|
||||||
from db import get_connection
|
|
||||||
from services.audit import log_audit
|
|
||||||
from services.initiatives import get_initiative
|
|
||||||
|
|
||||||
RoadmapItemType = Literal["milestone", "review_gate", "maturity_stage"]
|
|
||||||
RoadmapItemStatus = Literal[
|
|
||||||
"planned", "active", "at_risk", "reached", "moved", "discarded"
|
|
||||||
]
|
|
||||||
SequencingMode = Literal["sequential", "parallel", "optional"]
|
|
||||||
DependencyType = Literal["requires", "blocks", "related"]
|
|
||||||
|
|
||||||
ROADMAP_ITEM_TYPES = frozenset({"milestone", "review_gate", "maturity_stage"})
|
|
||||||
ROADMAP_ITEM_STATUSES = frozenset(
|
|
||||||
{"planned", "active", "at_risk", "reached", "moved", "discarded"}
|
|
||||||
)
|
|
||||||
SEQUENCING_MODES = frozenset({"sequential", "parallel", "optional"})
|
|
||||||
DEPENDENCY_TYPES = frozenset({"requires", "blocks", "related"})
|
|
||||||
|
|
||||||
TERMINAL_STATUSES = frozenset({"reached", "moved", "discarded"})
|
|
||||||
|
|
||||||
|
|
||||||
def _serialize_row(row: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
result = dict(row)
|
|
||||||
for key in ("id", "tenant_id", "roadmap_id", "initiative_id", "from_item_id", "to_item_id"):
|
|
||||||
if result.get(key):
|
|
||||||
result[key] = str(result[key])
|
|
||||||
if result.get("target_date"):
|
|
||||||
result["target_date"] = (
|
|
||||||
result["target_date"].isoformat()
|
|
||||||
if hasattr(result["target_date"], "isoformat")
|
|
||||||
else str(result["target_date"])
|
|
||||||
)
|
|
||||||
for ts in ("created_at", "updated_at"):
|
|
||||||
if result.get(ts):
|
|
||||||
result[ts] = result[ts].isoformat()
|
|
||||||
dod = result.get("definition_of_done")
|
|
||||||
if isinstance(dod, str):
|
|
||||||
result["definition_of_done"] = json.loads(dod)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_item_type(item_type: str) -> None:
|
|
||||||
if item_type not in ROADMAP_ITEM_TYPES:
|
|
||||||
raise ValueError(f"Ungültiger RoadmapItem-Typ: {item_type}")
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_status(status: str) -> None:
|
|
||||||
if status not in ROADMAP_ITEM_STATUSES:
|
|
||||||
raise ValueError(f"Ungültiger RoadmapItem-Status: {status}")
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_sequencing_mode(mode: str) -> None:
|
|
||||||
if mode not in SEQUENCING_MODES:
|
|
||||||
raise ValueError(f"Ungültiger sequencing_mode: {mode}")
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_dependency_type(dep_type: str) -> None:
|
|
||||||
if dep_type not in DEPENDENCY_TYPES:
|
|
||||||
raise ValueError(f"Ungültiger dependency_type: {dep_type}")
|
|
||||||
|
|
||||||
|
|
||||||
def _sync_milestone_compat_row(
|
|
||||||
cur,
|
|
||||||
*,
|
|
||||||
tenant_id: str,
|
|
||||||
initiative_id: str,
|
|
||||||
item: dict[str, Any],
|
|
||||||
) -> None:
|
|
||||||
"""Hält milestones-Tabelle für Evidence/Review-FKs synchron (Compat bis Drop)."""
|
|
||||||
if item["item_type"] != "milestone":
|
|
||||||
cur.execute(
|
|
||||||
"DELETE FROM milestones WHERE id = %s AND tenant_id = %s",
|
|
||||||
(item["id"], tenant_id),
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
target_date = item.get("target_date")
|
|
||||||
if target_date and isinstance(target_date, str):
|
|
||||||
target_date = date.fromisoformat(target_date)
|
|
||||||
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO milestones (
|
|
||||||
id, tenant_id, initiative_id, title, goal_description, status, target_date
|
|
||||||
)
|
|
||||||
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
|
||||||
ON CONFLICT (id) DO UPDATE SET
|
|
||||||
title = EXCLUDED.title,
|
|
||||||
goal_description = EXCLUDED.goal_description,
|
|
||||||
status = EXCLUDED.status,
|
|
||||||
target_date = EXCLUDED.target_date,
|
|
||||||
updated_at = NOW()
|
|
||||||
""",
|
|
||||||
(
|
|
||||||
item["id"],
|
|
||||||
tenant_id,
|
|
||||||
initiative_id,
|
|
||||||
item["title"],
|
|
||||||
item.get("goal_description", ""),
|
|
||||||
item["status"],
|
|
||||||
target_date,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def ensure_roadmap(*, tenant_id: str, initiative_id: str) -> dict[str, Any]:
|
|
||||||
if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id):
|
|
||||||
raise ValueError("Initiative nicht gefunden")
|
|
||||||
|
|
||||||
conn = get_connection()
|
|
||||||
try:
|
|
||||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT id, tenant_id, initiative_id, title, created_at, updated_at
|
|
||||||
FROM roadmaps
|
|
||||||
WHERE tenant_id = %s AND initiative_id = %s
|
|
||||||
""",
|
|
||||||
(tenant_id, initiative_id),
|
|
||||||
)
|
|
||||||
row = cur.fetchone()
|
|
||||||
if row:
|
|
||||||
return _serialize_row(dict(row))
|
|
||||||
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO roadmaps (tenant_id, initiative_id, title)
|
|
||||||
VALUES (%s, %s, 'Plan')
|
|
||||||
RETURNING id, tenant_id, initiative_id, title, created_at, updated_at
|
|
||||||
""",
|
|
||||||
(tenant_id, initiative_id),
|
|
||||||
)
|
|
||||||
result = _serialize_row(dict(cur.fetchone()))
|
|
||||||
conn.commit()
|
|
||||||
return result
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
|
|
||||||
def get_roadmap_for_initiative(*, tenant_id: str, initiative_id: str) -> Optional[dict[str, Any]]:
|
|
||||||
if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id):
|
|
||||||
raise ValueError("Initiative nicht gefunden")
|
|
||||||
|
|
||||||
conn = get_connection()
|
|
||||||
try:
|
|
||||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT id, tenant_id, initiative_id, title, created_at, updated_at
|
|
||||||
FROM roadmaps
|
|
||||||
WHERE tenant_id = %s AND initiative_id = %s
|
|
||||||
""",
|
|
||||||
(tenant_id, initiative_id),
|
|
||||||
)
|
|
||||||
row = cur.fetchone()
|
|
||||||
return _serialize_row(dict(row)) if row else None
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
|
|
||||||
def list_roadmap_items_for_initiative(
|
|
||||||
*, tenant_id: str, initiative_id: str
|
|
||||||
) -> list[dict[str, Any]]:
|
|
||||||
roadmap = get_roadmap_for_initiative(tenant_id=tenant_id, initiative_id=initiative_id)
|
|
||||||
if not roadmap:
|
|
||||||
return []
|
|
||||||
|
|
||||||
conn = get_connection()
|
|
||||||
try:
|
|
||||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT
|
|
||||||
ri.id, ri.tenant_id, ri.roadmap_id, r.initiative_id,
|
|
||||||
ri.item_type, ri.title, ri.goal_description, ri.definition_of_done,
|
|
||||||
ri.status, ri.sequencing_mode, ri.target_date, ri.sort_order,
|
|
||||||
ri.created_at, ri.updated_at
|
|
||||||
FROM roadmap_items ri
|
|
||||||
JOIN roadmaps r ON r.id = ri.roadmap_id AND r.tenant_id = ri.tenant_id
|
|
||||||
WHERE ri.tenant_id = %s AND r.initiative_id = %s
|
|
||||||
ORDER BY ri.sort_order ASC, ri.target_date NULLS LAST, ri.title
|
|
||||||
""",
|
|
||||||
(tenant_id, initiative_id),
|
|
||||||
)
|
|
||||||
return [_serialize_row(dict(r)) for r in cur.fetchall()]
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
|
|
||||||
def get_roadmap_item(*, tenant_id: str, item_id: str) -> Optional[dict[str, Any]]:
|
|
||||||
conn = get_connection()
|
|
||||||
try:
|
|
||||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT
|
|
||||||
ri.id, ri.tenant_id, ri.roadmap_id, r.initiative_id,
|
|
||||||
ri.item_type, ri.title, ri.goal_description, ri.definition_of_done,
|
|
||||||
ri.status, ri.sequencing_mode, ri.target_date, ri.sort_order,
|
|
||||||
ri.created_at, ri.updated_at
|
|
||||||
FROM roadmap_items ri
|
|
||||||
JOIN roadmaps r ON r.id = ri.roadmap_id AND r.tenant_id = ri.tenant_id
|
|
||||||
WHERE ri.id = %s AND ri.tenant_id = %s
|
|
||||||
""",
|
|
||||||
(item_id, tenant_id),
|
|
||||||
)
|
|
||||||
row = cur.fetchone()
|
|
||||||
return _serialize_row(dict(row)) if row else None
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
|
|
||||||
def create_roadmap_item(
|
|
||||||
*,
|
|
||||||
tenant_id: str,
|
|
||||||
initiative_id: str,
|
|
||||||
title: str,
|
|
||||||
item_type: RoadmapItemType = "milestone",
|
|
||||||
goal_description: str = "",
|
|
||||||
definition_of_done: Optional[list[Any]] = None,
|
|
||||||
status: RoadmapItemStatus = "planned",
|
|
||||||
sequencing_mode: SequencingMode = "sequential",
|
|
||||||
target_date: Optional[date] = None,
|
|
||||||
sort_order: int = 0,
|
|
||||||
user_id: Optional[str] = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
title = title.strip()
|
|
||||||
if not title:
|
|
||||||
raise ValueError("Titel ist erforderlich")
|
|
||||||
_validate_item_type(item_type)
|
|
||||||
_validate_status(status)
|
|
||||||
if status == "reached":
|
|
||||||
raise ValueError("Status 'reached' nur über verify-reached")
|
|
||||||
_validate_sequencing_mode(sequencing_mode)
|
|
||||||
|
|
||||||
roadmap = ensure_roadmap(tenant_id=tenant_id, initiative_id=initiative_id)
|
|
||||||
dod = definition_of_done if definition_of_done is not None else []
|
|
||||||
|
|
||||||
conn = get_connection()
|
|
||||||
try:
|
|
||||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO roadmap_items (
|
|
||||||
tenant_id, roadmap_id, item_type, title, goal_description,
|
|
||||||
definition_of_done, status, sequencing_mode, target_date, sort_order
|
|
||||||
)
|
|
||||||
VALUES (%s, %s, %s, %s, %s, %s::jsonb, %s, %s, %s, %s)
|
|
||||||
RETURNING id, tenant_id, roadmap_id, item_type, title, goal_description,
|
|
||||||
definition_of_done, status, sequencing_mode, target_date,
|
|
||||||
sort_order, created_at, updated_at
|
|
||||||
""",
|
|
||||||
(
|
|
||||||
tenant_id,
|
|
||||||
roadmap["id"],
|
|
||||||
item_type,
|
|
||||||
title,
|
|
||||||
goal_description,
|
|
||||||
json.dumps(dod),
|
|
||||||
status,
|
|
||||||
sequencing_mode,
|
|
||||||
target_date,
|
|
||||||
sort_order,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
row = _serialize_row(dict(cur.fetchone()))
|
|
||||||
row["initiative_id"] = initiative_id
|
|
||||||
_sync_milestone_compat_row(
|
|
||||||
cur,
|
|
||||||
tenant_id=tenant_id,
|
|
||||||
initiative_id=initiative_id,
|
|
||||||
item=row,
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
log_audit(
|
|
||||||
"roadmap_item.created",
|
|
||||||
user_id=user_id,
|
|
||||||
tenant_id=tenant_id,
|
|
||||||
details={
|
|
||||||
"roadmap_item_id": row["id"],
|
|
||||||
"initiative_id": initiative_id,
|
|
||||||
"item_type": item_type,
|
|
||||||
"title": title,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return row
|
|
||||||
|
|
||||||
|
|
||||||
def update_roadmap_item(
|
|
||||||
*,
|
|
||||||
tenant_id: str,
|
|
||||||
item_id: str,
|
|
||||||
user_id: Optional[str] = None,
|
|
||||||
title: Optional[str] = None,
|
|
||||||
goal_description: Optional[str] = None,
|
|
||||||
definition_of_done: Optional[list[Any]] = None,
|
|
||||||
item_type: Optional[RoadmapItemType] = None,
|
|
||||||
status: Optional[RoadmapItemStatus] = None,
|
|
||||||
sequencing_mode: Optional[SequencingMode] = None,
|
|
||||||
target_date: Optional[date] = None,
|
|
||||||
clear_target_date: bool = False,
|
|
||||||
sort_order: Optional[int] = None,
|
|
||||||
) -> Optional[dict[str, Any]]:
|
|
||||||
existing = get_roadmap_item(tenant_id=tenant_id, item_id=item_id)
|
|
||||||
if not existing:
|
|
||||||
return None
|
|
||||||
|
|
||||||
old_status = existing["status"]
|
|
||||||
updates: list[str] = []
|
|
||||||
params: list[Any] = []
|
|
||||||
|
|
||||||
if title is not None:
|
|
||||||
title = title.strip()
|
|
||||||
if not title:
|
|
||||||
raise ValueError("Titel ist erforderlich")
|
|
||||||
updates.append("title = %s")
|
|
||||||
params.append(title)
|
|
||||||
if goal_description is not None:
|
|
||||||
updates.append("goal_description = %s")
|
|
||||||
params.append(goal_description)
|
|
||||||
if definition_of_done is not None:
|
|
||||||
updates.append("definition_of_done = %s::jsonb")
|
|
||||||
params.append(json.dumps(definition_of_done))
|
|
||||||
if item_type is not None:
|
|
||||||
_validate_item_type(item_type)
|
|
||||||
updates.append("item_type = %s")
|
|
||||||
params.append(item_type)
|
|
||||||
if status is not None:
|
|
||||||
_validate_status(status)
|
|
||||||
if status in TERMINAL_STATUSES and status == "reached":
|
|
||||||
raise ValueError(
|
|
||||||
"Status 'reached' nur über verify-reached — nicht direkt setzen"
|
|
||||||
)
|
|
||||||
if status in ("moved", "discarded"):
|
|
||||||
raise ValueError(
|
|
||||||
"Status moved/discarded erfordert Decision — noch nicht implementiert"
|
|
||||||
)
|
|
||||||
updates.append("status = %s")
|
|
||||||
params.append(status)
|
|
||||||
if sequencing_mode is not None:
|
|
||||||
_validate_sequencing_mode(sequencing_mode)
|
|
||||||
updates.append("sequencing_mode = %s")
|
|
||||||
params.append(sequencing_mode)
|
|
||||||
if clear_target_date:
|
|
||||||
updates.append("target_date = NULL")
|
|
||||||
elif target_date is not None:
|
|
||||||
updates.append("target_date = %s")
|
|
||||||
params.append(target_date)
|
|
||||||
if sort_order is not None:
|
|
||||||
updates.append("sort_order = %s")
|
|
||||||
params.append(sort_order)
|
|
||||||
|
|
||||||
if not updates:
|
|
||||||
return existing
|
|
||||||
|
|
||||||
updates.append("updated_at = NOW()")
|
|
||||||
params.extend([item_id, tenant_id])
|
|
||||||
|
|
||||||
conn = get_connection()
|
|
||||||
try:
|
|
||||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
|
||||||
cur.execute(
|
|
||||||
f"""
|
|
||||||
UPDATE roadmap_items
|
|
||||||
SET {", ".join(updates)}
|
|
||||||
WHERE id = %s AND tenant_id = %s
|
|
||||||
RETURNING id, tenant_id, roadmap_id, item_type, title, goal_description,
|
|
||||||
definition_of_done, status, sequencing_mode, target_date,
|
|
||||||
sort_order, created_at, updated_at
|
|
||||||
""",
|
|
||||||
params,
|
|
||||||
)
|
|
||||||
row = cur.fetchone()
|
|
||||||
if not row:
|
|
||||||
return None
|
|
||||||
result = _serialize_row(dict(row))
|
|
||||||
result["initiative_id"] = existing["initiative_id"]
|
|
||||||
_sync_milestone_compat_row(
|
|
||||||
cur,
|
|
||||||
tenant_id=tenant_id,
|
|
||||||
initiative_id=existing["initiative_id"],
|
|
||||||
item=result,
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
log_audit(
|
|
||||||
"roadmap_item.updated",
|
|
||||||
user_id=user_id,
|
|
||||||
tenant_id=tenant_id,
|
|
||||||
details={"roadmap_item_id": item_id},
|
|
||||||
)
|
|
||||||
if status is not None and status != old_status:
|
|
||||||
log_audit(
|
|
||||||
"roadmap_item.status_changed",
|
|
||||||
user_id=user_id,
|
|
||||||
tenant_id=tenant_id,
|
|
||||||
details={
|
|
||||||
"roadmap_item_id": item_id,
|
|
||||||
"from_status": old_status,
|
|
||||||
"to_status": status,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def delete_roadmap_item(
|
|
||||||
*, tenant_id: str, item_id: str, user_id: Optional[str] = None
|
|
||||||
) -> bool:
|
|
||||||
conn = get_connection()
|
|
||||||
try:
|
|
||||||
with conn.cursor() as cur:
|
|
||||||
cur.execute(
|
|
||||||
"DELETE FROM milestones WHERE id = %s AND tenant_id = %s",
|
|
||||||
(item_id, tenant_id),
|
|
||||||
)
|
|
||||||
cur.execute(
|
|
||||||
"DELETE FROM roadmap_items WHERE id = %s AND tenant_id = %s RETURNING id",
|
|
||||||
(item_id, tenant_id),
|
|
||||||
)
|
|
||||||
deleted = cur.fetchone() is not None
|
|
||||||
conn.commit()
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
if deleted:
|
|
||||||
log_audit(
|
|
||||||
"roadmap_item.deleted",
|
|
||||||
user_id=user_id,
|
|
||||||
tenant_id=tenant_id,
|
|
||||||
details={"roadmap_item_id": item_id},
|
|
||||||
)
|
|
||||||
return deleted
|
|
||||||
|
|
||||||
|
|
||||||
def list_dependencies(*, tenant_id: str, item_id: str) -> list[dict[str, Any]]:
|
|
||||||
item = get_roadmap_item(tenant_id=tenant_id, item_id=item_id)
|
|
||||||
if not item:
|
|
||||||
raise ValueError("RoadmapItem nicht gefunden")
|
|
||||||
|
|
||||||
conn = get_connection()
|
|
||||||
try:
|
|
||||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT id, tenant_id, from_item_id, to_item_id, dependency_type, created_at
|
|
||||||
FROM roadmap_item_dependencies
|
|
||||||
WHERE tenant_id = %s AND (from_item_id = %s OR to_item_id = %s)
|
|
||||||
ORDER BY created_at ASC
|
|
||||||
""",
|
|
||||||
(tenant_id, item_id, item_id),
|
|
||||||
)
|
|
||||||
items = []
|
|
||||||
for row in cur.fetchall():
|
|
||||||
dep = dict(row)
|
|
||||||
for key in ("id", "tenant_id", "from_item_id", "to_item_id"):
|
|
||||||
dep[key] = str(dep[key])
|
|
||||||
if dep.get("created_at"):
|
|
||||||
dep["created_at"] = dep["created_at"].isoformat()
|
|
||||||
items.append(dep)
|
|
||||||
return items
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
|
|
||||||
def add_dependency(
|
|
||||||
*,
|
|
||||||
tenant_id: str,
|
|
||||||
from_item_id: str,
|
|
||||||
to_item_id: str,
|
|
||||||
dependency_type: DependencyType = "requires",
|
|
||||||
user_id: Optional[str] = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
_validate_dependency_type(dependency_type)
|
|
||||||
if from_item_id == to_item_id:
|
|
||||||
raise ValueError("Abhängigkeit auf sich selbst nicht erlaubt")
|
|
||||||
|
|
||||||
from_item = get_roadmap_item(tenant_id=tenant_id, item_id=from_item_id)
|
|
||||||
to_item = get_roadmap_item(tenant_id=tenant_id, item_id=to_item_id)
|
|
||||||
if not from_item or not to_item:
|
|
||||||
raise ValueError("RoadmapItem nicht gefunden")
|
|
||||||
if from_item["initiative_id"] != to_item["initiative_id"]:
|
|
||||||
raise ValueError("Abhängigkeiten nur innerhalb eines Vorhabens")
|
|
||||||
|
|
||||||
conn = get_connection()
|
|
||||||
try:
|
|
||||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO roadmap_item_dependencies (
|
|
||||||
tenant_id, from_item_id, to_item_id, dependency_type
|
|
||||||
)
|
|
||||||
VALUES (%s, %s, %s, %s)
|
|
||||||
RETURNING id, tenant_id, from_item_id, to_item_id, dependency_type, created_at
|
|
||||||
""",
|
|
||||||
(tenant_id, from_item_id, to_item_id, dependency_type),
|
|
||||||
)
|
|
||||||
row = dict(cur.fetchone())
|
|
||||||
for key in ("id", "tenant_id", "from_item_id", "to_item_id"):
|
|
||||||
row[key] = str(row[key])
|
|
||||||
row["created_at"] = row["created_at"].isoformat()
|
|
||||||
conn.commit()
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
log_audit(
|
|
||||||
"roadmap_item.dependency_added",
|
|
||||||
user_id=user_id,
|
|
||||||
tenant_id=tenant_id,
|
|
||||||
details={
|
|
||||||
"from_item_id": from_item_id,
|
|
||||||
"to_item_id": to_item_id,
|
|
||||||
"dependency_type": dependency_type,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return row
|
|
||||||
|
|
||||||
|
|
||||||
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."""
|
|
||||||
item = get_roadmap_item(tenant_id=tenant_id, item_id=item_id)
|
|
||||||
if not item:
|
|
||||||
raise ValueError("RoadmapItem nicht gefunden")
|
|
||||||
if item["status"] in TERMINAL_STATUSES:
|
|
||||||
raise ValueError(f"RoadmapItem bereits terminal: {item['status']}")
|
|
||||||
|
|
||||||
initiative_id = item["initiative_id"]
|
|
||||||
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 milestone_id = %s
|
|
||||||
LIMIT 1
|
|
||||||
""",
|
|
||||||
(tenant_id, initiative_id, item_id),
|
|
||||||
)
|
|
||||||
if cur.fetchone():
|
|
||||||
verify_reason = "evidence_accepted"
|
|
||||||
else:
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT 1 FROM reviews
|
|
||||||
WHERE tenant_id = %s AND initiative_id = %s
|
|
||||||
AND status = 'completed'
|
|
||||||
AND (milestone_id = %s)
|
|
||||||
LIMIT 1
|
|
||||||
""",
|
|
||||||
(tenant_id, initiative_id, item_id),
|
|
||||||
)
|
|
||||||
if cur.fetchone():
|
|
||||||
verify_reason = "review_completed"
|
|
||||||
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}%"),
|
|
||||||
)
|
|
||||||
if cur.fetchone():
|
|
||||||
verify_reason = "gate_override_decision"
|
|
||||||
else:
|
|
||||||
raise ValueError(
|
|
||||||
"Verify fehlgeschlagen — accepted Evidence, completed Review "
|
|
||||||
"oder gate_override Decision erforderlich"
|
|
||||||
)
|
|
||||||
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
UPDATE roadmap_items
|
|
||||||
SET status = 'reached', updated_at = NOW()
|
|
||||||
WHERE id = %s AND tenant_id = %s
|
|
||||||
RETURNING id, tenant_id, roadmap_id, item_type, title, goal_description,
|
|
||||||
definition_of_done, status, sequencing_mode, target_date,
|
|
||||||
sort_order, created_at, updated_at
|
|
||||||
""",
|
|
||||||
(item_id, tenant_id),
|
|
||||||
)
|
|
||||||
row = _serialize_row(dict(cur.fetchone()))
|
|
||||||
row["initiative_id"] = initiative_id
|
|
||||||
row["verify_reason"] = verify_reason
|
|
||||||
_sync_milestone_compat_row(
|
|
||||||
cur,
|
|
||||||
tenant_id=tenant_id,
|
|
||||||
initiative_id=initiative_id,
|
|
||||||
item=row,
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
log_audit(
|
|
||||||
"roadmap_item.reached",
|
|
||||||
user_id=user_id,
|
|
||||||
tenant_id=tenant_id,
|
|
||||||
details={"roadmap_item_id": item_id, "verify_reason": verify_reason},
|
|
||||||
)
|
|
||||||
return row
|
|
||||||
|
|
@ -256,20 +256,19 @@ def _milestones_at_risk(cur, ctx: TenantContext) -> list[dict[str, Any]]:
|
||||||
SELECT
|
SELECT
|
||||||
'milestone_at_risk' AS kind,
|
'milestone_at_risk' AS kind,
|
||||||
'warning' AS severity,
|
'warning' AS severity,
|
||||||
ri.title AS title,
|
m.title AS title,
|
||||||
'Plan-Element als gefährdet markiert' AS summary,
|
'Meilenstein als gefährdet markiert' AS summary,
|
||||||
'milestone' AS scope_type,
|
'milestone' AS scope_type,
|
||||||
ri.id AS scope_id,
|
m.id AS scope_id,
|
||||||
r.initiative_id,
|
m.initiative_id,
|
||||||
NULL::uuid AS action_id,
|
NULL::uuid AS action_id,
|
||||||
NULL::uuid AS blocker_id,
|
NULL::uuid AS blocker_id,
|
||||||
ri.id AS milestone_id,
|
m.id AS milestone_id,
|
||||||
'milestone_at_risk' AS reason_code,
|
'milestone_at_risk' AS reason_code,
|
||||||
'roadmap_items' AS data_source
|
'milestones' AS data_source
|
||||||
FROM roadmap_items ri
|
FROM milestones m
|
||||||
JOIN roadmaps r ON r.id = ri.roadmap_id AND r.tenant_id = ri.tenant_id
|
WHERE m.tenant_id = %s AND m.status = 'at_risk'
|
||||||
WHERE ri.tenant_id = %s AND ri.status = 'at_risk'
|
ORDER BY m.updated_at DESC
|
||||||
ORDER BY ri.updated_at DESC
|
|
||||||
LIMIT 20
|
LIMIT 20
|
||||||
""",
|
""",
|
||||||
(ctx.tenant_id,),
|
(ctx.tenant_id,),
|
||||||
|
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
"""Initiative snapshot signals — erklärbare Heuristiken ohne operating_phase (AP1.2)."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
|
|
||||||
def derive_initiative_signals(
|
|
||||||
*,
|
|
||||||
initiative_status: str,
|
|
||||||
open_actions: int,
|
|
||||||
blocked_actions: int,
|
|
||||||
review_required_actions: int,
|
|
||||||
open_blockers: int,
|
|
||||||
backlog_new: int,
|
|
||||||
planned_reviews_due: int,
|
|
||||||
) -> list[str]:
|
|
||||||
"""Regelbasierte Signale für Steuerungs-Snapshot — kein Lifecycle-Ersatz."""
|
|
||||||
if initiative_status in ("completed", "archived"):
|
|
||||||
return ["initiative_terminal"]
|
|
||||||
|
|
||||||
signals: list[str] = []
|
|
||||||
|
|
||||||
if open_actions == 0 and backlog_new > 0:
|
|
||||||
signals.append("backlog_awaiting_commit")
|
|
||||||
return signals
|
|
||||||
|
|
||||||
if open_actions == 0 and backlog_new == 0:
|
|
||||||
signals.append("no_open_work")
|
|
||||||
return signals
|
|
||||||
|
|
||||||
if blocked_actions > 0:
|
|
||||||
signals.append("actions_blocked")
|
|
||||||
if open_blockers > 0:
|
|
||||||
signals.append("open_blockers")
|
|
||||||
if blocked_actions > 0 or open_blockers > 0:
|
|
||||||
return signals
|
|
||||||
|
|
||||||
if review_required_actions > 0:
|
|
||||||
signals.append("actions_need_review")
|
|
||||||
if planned_reviews_due > 0:
|
|
||||||
signals.append("reviews_due")
|
|
||||||
if review_required_actions > 0 or planned_reviews_due > 0:
|
|
||||||
return signals
|
|
||||||
|
|
||||||
if open_actions > 0:
|
|
||||||
signals.append("work_in_progress")
|
|
||||||
|
|
||||||
return signals
|
|
||||||
|
|
@ -36,22 +36,20 @@ class ProductMilestoneDrivenStrategy:
|
||||||
"""
|
"""
|
||||||
SELECT
|
SELECT
|
||||||
'review_milestone' AS kind,
|
'review_milestone' AS kind,
|
||||||
ri.title AS title,
|
m.title AS title,
|
||||||
'Plan-Element prüfen oder Status aktualisieren' AS summary,
|
'Meilenstein prüfen oder Status aktualisieren' AS summary,
|
||||||
r.initiative_id,
|
m.initiative_id,
|
||||||
NULL::uuid AS action_id,
|
NULL::uuid AS action_id,
|
||||||
NULL::uuid AS backlog_item_id,
|
NULL::uuid AS backlog_item_id,
|
||||||
'milestone_at_risk_or_active' AS reason_code,
|
'milestone_at_risk_or_active' AS reason_code,
|
||||||
'Plan-Element prüfen' AS recommended_action
|
'Meilenstein prüfen' AS recommended_action
|
||||||
FROM roadmap_items ri
|
FROM milestones m
|
||||||
JOIN roadmaps r ON r.id = ri.roadmap_id AND r.tenant_id = ri.tenant_id
|
WHERE m.tenant_id = %s AND m.initiative_id = %s
|
||||||
WHERE ri.tenant_id = %s AND r.initiative_id = %s
|
AND m.status IN ('at_risk', 'active')
|
||||||
AND ri.item_type = 'milestone'
|
|
||||||
AND ri.status IN ('at_risk', 'active')
|
|
||||||
ORDER BY
|
ORDER BY
|
||||||
CASE ri.status WHEN 'at_risk' THEN 0 ELSE 1 END,
|
CASE m.status WHEN 'at_risk' THEN 0 ELSE 1 END,
|
||||||
ri.target_date ASC NULLS LAST,
|
m.target_date ASC NULLS LAST,
|
||||||
ri.updated_at DESC
|
m.updated_at DESC
|
||||||
LIMIT %s
|
LIMIT %s
|
||||||
""",
|
""",
|
||||||
(ctx.tenant_id, initiative_id, limit),
|
(ctx.tenant_id, initiative_id, limit),
|
||||||
|
|
|
||||||
|
|
@ -30,8 +30,17 @@ def test_steering_snapshot_graph(client):
|
||||||
)
|
)
|
||||||
assert snap.status_code == 200
|
assert snap.status_code == 200
|
||||||
data = snap.json()
|
data = snap.json()
|
||||||
assert isinstance(data.get("signals"), list)
|
assert data["operating_phase"] in (
|
||||||
assert "operating_phase" not in data
|
"execute",
|
||||||
|
"triage",
|
||||||
|
"structure",
|
||||||
|
"review",
|
||||||
|
"adapt",
|
||||||
|
"commit",
|
||||||
|
"capture",
|
||||||
|
"verify",
|
||||||
|
"closure",
|
||||||
|
)
|
||||||
linked = next(a for a in data["actions"] if a["id"] == action["id"])
|
linked = next(a for a in data["actions"] if a["id"] == action["id"])
|
||||||
assert len(linked["blockers"]) == 1
|
assert len(linked["blockers"]) == 1
|
||||||
assert linked["open_blocker_count"] == 1
|
assert linked["open_blocker_count"] == 1
|
||||||
|
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
"""AP1.2 — Snapshot signals without operating_phase."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from steering.signals.snapshot_signals import derive_initiative_signals
|
|
||||||
|
|
||||||
|
|
||||||
def test_derive_signals_backlog_awaiting_commit():
|
|
||||||
signals = derive_initiative_signals(
|
|
||||||
initiative_status="active",
|
|
||||||
open_actions=0,
|
|
||||||
blocked_actions=0,
|
|
||||||
review_required_actions=0,
|
|
||||||
open_blockers=0,
|
|
||||||
backlog_new=3,
|
|
||||||
planned_reviews_due=0,
|
|
||||||
)
|
|
||||||
assert signals == ["backlog_awaiting_commit"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_derive_signals_work_in_progress():
|
|
||||||
signals = derive_initiative_signals(
|
|
||||||
initiative_status="active",
|
|
||||||
open_actions=2,
|
|
||||||
blocked_actions=0,
|
|
||||||
review_required_actions=0,
|
|
||||||
open_blockers=0,
|
|
||||||
backlog_new=0,
|
|
||||||
planned_reviews_due=0,
|
|
||||||
)
|
|
||||||
assert signals == ["work_in_progress"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_derive_signals_terminal():
|
|
||||||
signals = derive_initiative_signals(
|
|
||||||
initiative_status="completed",
|
|
||||||
open_actions=0,
|
|
||||||
blocked_actions=0,
|
|
||||||
review_required_actions=0,
|
|
||||||
open_blockers=0,
|
|
||||||
backlog_new=0,
|
|
||||||
planned_reviews_due=0,
|
|
||||||
)
|
|
||||||
assert signals == ["initiative_terminal"]
|
|
||||||
|
|
@ -1,122 +0,0 @@
|
||||||
"""AP1.4 — RoadmapItem & Quality Gate tests."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from tests.factories import provision_user_in_tenant
|
|
||||||
from tests.test_initiatives_actions import (
|
|
||||||
_auth,
|
|
||||||
_create_initiative,
|
|
||||||
_login,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _create_roadmap_item(client, token, initiative_id, **kwargs):
|
|
||||||
body = {"title": "Gate A", "item_type": "milestone", **kwargs}
|
|
||||||
return client.post(
|
|
||||||
f"/api/initiatives/{initiative_id}/roadmap/items",
|
|
||||||
json=body,
|
|
||||||
headers=_auth(token),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_roadmap_item_crud(client):
|
|
||||||
user = provision_user_in_tenant(tenant_role="member")
|
|
||||||
token = _login(client, user)
|
|
||||||
initiative_id = _create_initiative(client, token).json()["id"]
|
|
||||||
|
|
||||||
created = _create_roadmap_item(
|
|
||||||
client,
|
|
||||||
token,
|
|
||||||
initiative_id,
|
|
||||||
sequencing_mode="parallel",
|
|
||||||
item_type="maturity_stage",
|
|
||||||
)
|
|
||||||
assert created.status_code == 201
|
|
||||||
item = created.json()
|
|
||||||
assert item["item_type"] == "maturity_stage"
|
|
||||||
assert item["sequencing_mode"] == "parallel"
|
|
||||||
|
|
||||||
listed = client.get(
|
|
||||||
f"/api/initiatives/{initiative_id}/roadmap/items",
|
|
||||||
headers=_auth(token),
|
|
||||||
)
|
|
||||||
assert listed.status_code == 200
|
|
||||||
assert len(listed.json()) >= 1
|
|
||||||
|
|
||||||
patched = client.patch(
|
|
||||||
f"/api/roadmap-items/{item['id']}",
|
|
||||||
json={"status": "at_risk"},
|
|
||||||
headers=_auth(token),
|
|
||||||
)
|
|
||||||
assert patched.status_code == 200
|
|
||||||
assert patched.json()["status"] == "at_risk"
|
|
||||||
|
|
||||||
deleted = client.delete(f"/api/roadmap-items/{item['id']}", headers=_auth(token))
|
|
||||||
assert deleted.status_code == 204
|
|
||||||
|
|
||||||
|
|
||||||
def test_verify_reached_requires_evidence(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()
|
|
||||||
|
|
||||||
fail = client.post(
|
|
||||||
f"/api/roadmap-items/{item['id']}/verify-reached",
|
|
||||||
headers=_auth(token),
|
|
||||||
)
|
|
||||||
assert fail.status_code == 400
|
|
||||||
|
|
||||||
evidence = client.post(
|
|
||||||
f"/api/initiatives/{initiative_id}/evidence",
|
|
||||||
json={
|
|
||||||
"title": "Nachweis",
|
|
||||||
"milestone_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"
|
|
||||||
assert ok.json()["verify_reason"] == "evidence_accepted"
|
|
||||||
|
|
||||||
|
|
||||||
def test_milestone_compat_api_uses_roadmap(client):
|
|
||||||
user = provision_user_in_tenant(tenant_role="member")
|
|
||||||
token = _login(client, user)
|
|
||||||
initiative_id = _create_initiative(client, token).json()["id"]
|
|
||||||
|
|
||||||
created = client.post(
|
|
||||||
f"/api/initiatives/{initiative_id}/milestones",
|
|
||||||
json={"title": "Legacy MS", "status": "planned"},
|
|
||||||
headers=_auth(token),
|
|
||||||
)
|
|
||||||
assert created.status_code == 201
|
|
||||||
ms_id = created.json()["id"]
|
|
||||||
|
|
||||||
roadmap = client.get(
|
|
||||||
f"/api/initiatives/{initiative_id}/roadmap/items",
|
|
||||||
headers=_auth(token),
|
|
||||||
).json()
|
|
||||||
assert any(i["id"] == ms_id and i["item_type"] == "milestone" for i in roadmap)
|
|
||||||
|
|
||||||
|
|
||||||
def test_steering_snapshot_reads_roadmap_items(client):
|
|
||||||
user = provision_user_in_tenant(tenant_role="member")
|
|
||||||
token = _login(client, user)
|
|
||||||
initiative_id = _create_initiative(client, token).json()["id"]
|
|
||||||
_create_roadmap_item(client, token, initiative_id, status="active", target_date="2026-12-31")
|
|
||||||
|
|
||||||
snap = client.get(
|
|
||||||
f"/api/initiatives/{initiative_id}/steering-snapshot",
|
|
||||||
headers=_auth(token),
|
|
||||||
).json()
|
|
||||||
assert snap.get("roadmap_items")
|
|
||||||
assert snap.get("upcoming_milestones")
|
|
||||||
assert len(snap["upcoming_roadmap_items"]) >= 1
|
|
||||||
|
|
@ -41,8 +41,7 @@ def test_steering_snapshot_has_lifecycle(client):
|
||||||
assert snap.status_code == 200
|
assert snap.status_code == 200
|
||||||
data = snap.json()
|
data = snap.json()
|
||||||
assert "lifecycle_state" in data
|
assert "lifecycle_state" in data
|
||||||
assert "signals" in data
|
assert data.get("operating_phase_deprecated") is True
|
||||||
assert "operating_phase" not in data
|
|
||||||
assert data["lifecycle_state"] in (
|
assert data["lifecycle_state"] in (
|
||||||
"intake",
|
"intake",
|
||||||
"planning",
|
"planning",
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
APP_VERSION = "0.13.0-ap1.4"
|
APP_VERSION = "0.12.0-ap1.2c"
|
||||||
DB_SCHEMA_VERSION = "010"
|
DB_SCHEMA_VERSION = "009"
|
||||||
APP_NAME = "jinkendo-kairo"
|
APP_NAME = "jinkendo-kairo"
|
||||||
|
|
|
||||||
|
|
@ -159,19 +159,6 @@ Steering Snapshot: `upcoming_milestones` liest aus `roadmap_items` statt `milest
|
||||||
- [ ] item_type initial: milestone, review_gate, maturity_stage
|
- [ ] item_type initial: milestone, review_gate, maturity_stage
|
||||||
- [ ] milestones-Tabelle darf deprecated werden
|
- [ ] milestones-Tabelle darf deprecated werden
|
||||||
- [ ] AP1.4 nach AP1.2c
|
- [ ] AP1.4 nach AP1.2c
|
||||||
- [ ] **Methodenneutralität:** Schema/UI nicht nur für sequenzielle Software-Gates
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Methodische Neutralität (AP1.4)
|
|
||||||
|
|
||||||
AP1.4 liefert das **generische Plan-Gerüst**. Konkrete Methoden (z. B. `product_milestone_driven`, Reifegrad-Parallelpfade, Content-Kapitel) **registrieren sich** über:
|
|
||||||
|
|
||||||
- `item_type` + `sequencing_mode` auf RoadmapItem
|
|
||||||
- `method_key` auf `steering_context` (bereits vorhanden)
|
|
||||||
- später: Method-spezifische Verify-Policies, Structure Builder, Next-Action-Strategien
|
|
||||||
|
|
||||||
**Nicht:** fest codierte „Meilenstein = lineare Software-Roadmap“-UI oder Dependency-Modell nur für WBS.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -298,18 +298,4 @@ UI-Unterseiten und Agent-API müssen **fachlich äquivalent** bleiben.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 15. Methodische Flexibilität (verbindlich)
|
|
||||||
|
|
||||||
Kairo unterstützt **unterschiedliche Entwicklungs- und Steuerungsmethoden** — auf Portfolio-, Initiativ-, Plan- und Ausführungsebene.
|
|
||||||
|
|
||||||
- **Kernmodell generisch:** RoadmapItem-Typen, Lifecycle, Actions — nicht „nur Software-PM“
|
|
||||||
- **Methoden in Registry:** `steering_context.method_key` + Strategy/Hook-Pakete — keine Sonder-Tabellen pro Methode
|
|
||||||
- **Plan flexibel:** `sequencing_mode` sequenziell | parallel | optional; `item_type` (milestone, maturity_stage, chapter, …)
|
|
||||||
- **Verify flexibel:** Gate-`reached` über Evidence/Review/Decision — Policies methodenabhängig konfigurierbar (später)
|
|
||||||
- **UI methodenbewusst, nicht methodenfix:** Plan-Tab rendert RoadmapItems; Labels/Filter aus Method-Profil, nicht hardcodiert
|
|
||||||
|
|
||||||
Siehe Vision v0.2 §9.1.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
*v0.1 bleibt als historische Referenz erhalten.*
|
*v0.1 bleibt als historische Referenz erhalten.*
|
||||||
|
|
|
||||||
|
|
@ -50,10 +50,10 @@ Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden.
|
||||||
| Review | ✓ | |
|
| Review | ✓ | |
|
||||||
| RecurringElement | ✓ | |
|
| RecurringElement | ✓ | |
|
||||||
| Task (unter Action) | ✗ | |
|
| Task (unter Action) | ✗ | |
|
||||||
| Roadmap | ◐ | 1 pro Initiative; Migration 010 |
|
| Roadmap | ✗ | 📄 Target State |
|
||||||
| RoadmapItem | ◐ | milestone/review_gate/maturity_stage; Milestone-API Compat |
|
| RoadmapItem | ✗ | 📄; Milestone soll hierhin |
|
||||||
| RoadmapItem Dependencies | ◐ | requires/blocks/related |
|
| RoadmapItem Dependencies | ✗ | |
|
||||||
| Plan-Ist-Verknüpfung | ◐ | backlog/actions.roadmap_item_id; Gate-Verify AP1.4 |
|
| Plan-Ist-Verknüpfung | ✗ | Backlog/Action → Gate |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -70,8 +70,7 @@ Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden.
|
||||||
| Situativer Steuerungskontext (Next Action) | ✗ | 📄 Vision §7.6 |
|
| Situativer Steuerungskontext (Next Action) | ✗ | 📄 Vision §7.6 |
|
||||||
| AttentionItem | ◐ | |
|
| AttentionItem | ◐ | |
|
||||||
| Initiative Steering Snapshot | ✓ | Graph für Actions + linked |
|
| Initiative Steering Snapshot | ✓ | Graph für Actions + linked |
|
||||||
| operating_phase | ✗ | entfernt AP1.2; Snapshot nutzt `signals` + Lifecycle |
|
| operating_phase | ◐ | deprecated flag; noch im Snapshot |
|
||||||
| signals (Snapshot) | ✓ | `backend/steering/signals/snapshot_signals.py` |
|
|
||||||
| Hook Orchestrator | ✗ | AP1.3 geplant |
|
| Hook Orchestrator | ✗ | AP1.3 geplant |
|
||||||
| Structure Builder | ✗ | 📄 |
|
| Structure Builder | ✗ | 📄 |
|
||||||
| DoD Engine | ✗ | 📄 |
|
| DoD Engine | ✗ | 📄 |
|
||||||
|
|
@ -134,8 +133,7 @@ Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden.
|
||||||
| AP | Erwartete Truth-Table-Änderung |
|
| AP | Erwartete Truth-Table-Änderung |
|
||||||
|----|--------------------------------|
|
|----|--------------------------------|
|
||||||
| AP1.2c | IA-Skeleton ✓ |
|
| AP1.2c | IA-Skeleton ✓ |
|
||||||
| AP1.2 | operating_phase ✗, signals ✓ |
|
| AP1.4 | RoadmapItem ○→◐, Gate Verify ◐ |
|
||||||
| AP1.4 | RoadmapItem ◐, Gate Verify ◐ |
|
|
||||||
| AP1.5 | Task ○, Project GUI ◐ |
|
| AP1.5 | Task ○, Project GUI ◐ |
|
||||||
| AP1.8 | Portfolio-Priorität ○, situativer Kontext ○ |
|
| AP1.8 | Portfolio-Priorität ○, situativer Kontext ○ |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -359,41 +359,6 @@ Prompt/KI/MCP bleiben **eingefroren**, bis Plan/Ist und Gates tragfähig modelli
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 9.1 Methodische Flexibilität auf allen Ebenen (verbindlich)
|
|
||||||
|
|
||||||
Kairo ist **kein** einzelnes PM- oder Software-Framework. Auf **allen Ebenen** können unterschiedliche **Entwicklungs- und Steuerungsmethoden** gelten — parallel im Portfolio, unterschiedlich pro Vorhaben, später auch pro Project/Stream.
|
|
||||||
|
|
||||||
**Prinzip:** Das **Kernmodell bleibt generisch**; konkrete Methoden stecken in der **Method Registry** (`backend/steering/methods/`), nicht in hardcodierten UI-Flows oder Tabellen pro Methode.
|
|
||||||
|
|
||||||
| Ebene | Was variiert methodisch | Mechanismus (Ziel) |
|
|
||||||
|-------|-------------------------|---------------------|
|
|
||||||
| **Portfolio** | Priorisierung, Attention | Strategien in `steering/` (AP1.8) |
|
|
||||||
| **Initiative / Programm** | Lifecycle, Signals, Next Action | `steering_context.method_key` |
|
|
||||||
| **Plan** | Gate-Typen, Abfolge, DoD | `RoadmapItem.item_type`, `sequencing_mode`, Method-Hooks |
|
|
||||||
| **Ist / Ausführung** | Hierarchie (WBS vs. flach), Commit-Regeln | Method-Profil + Structure Builder (später) |
|
|
||||||
| **Verify / Review** | Was zählt als „erreicht“ | Evidence/Review-Policies pro Methode |
|
|
||||||
| **Agenten / Vibe-Coder** | Empfohlene nächste Schritte | Operational API + gleiche Steering-Strategien |
|
|
||||||
|
|
||||||
**Beispiele gleichzeitig im Tenant:**
|
|
||||||
|
|
||||||
- Vorhaben A: `product_milestone_driven` — sequenzielle Gates, Meilenstein-Horizont
|
|
||||||
- Vorhaben B: Reifegrad-Modell — **parallele** `maturity_stage`-RoadmapItems
|
|
||||||
- Vorhaben C: Content/Buch — Kapitel-Gates, andere DoD-Kriterien
|
|
||||||
- Vorhaben D: `generic_operating` — leichte Struktur, wenig Plan-Zwang
|
|
||||||
|
|
||||||
**Was Implementierung nicht tun darf:**
|
|
||||||
|
|
||||||
- Ein einziges Gate-Modell (nur sequenzielle Software-Meilensteine)
|
|
||||||
- UI, die nur eine Methoden-Metapher zeigt
|
|
||||||
- Abhängigkeiten nur als Gantt-Denke
|
|
||||||
- Next Action fest auf Priorität+Fälligkeit — ohne methoden-/kontextabhängige Strategie
|
|
||||||
|
|
||||||
**AP1.4-Konsequenz:** RoadmapItem-Schema und Plan-UI **methodenneutral** bauen; `product_milestone_driven` ist **eine** registrierte Methode, nicht das Produkt.
|
|
||||||
|
|
||||||
**Später:** Method Packages (Structure Builder, Hook-Sets, Next-Action-Strategien) erweiterbar — ohne OM-Tabellen pro Methode (Target State §7–8, §14).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Ehrlicher Implementierungsstand (Kurz)
|
## 10. Ehrlicher Implementierungsstand (Kurz)
|
||||||
|
|
||||||
| Visionselement | Stand |
|
| Visionselement | Stand |
|
||||||
|
|
@ -445,25 +410,9 @@ Kein weiteres Layout-Patching auf der Omnibus-Seite, bevor:
|
||||||
| **AP1.5** | Hierarchie: Project, Task unter Action |
|
| **AP1.5** | Hierarchie: Project, Task unter Action |
|
||||||
| **AP1.6** | Plan/Ist-Views, Journey |
|
| **AP1.6** | Plan/Ist-Views, Journey |
|
||||||
|
|
||||||
*Später:* Method Packages (Structure Builder, Hook-Sets, Next-Action-Strategien) erweiterbar — ohne OM-Tabellen pro Methode (Target State §7–8, §14).
|
|
||||||
|
|
||||||
## 13. Product Owner Leitplanken
|
|
||||||
|
|
||||||
Entscheidungen an diesem Dokument prüfen:
|
|
||||||
|
|
||||||
1. Beantwortet die Änderung die **Leitfrage** auf der richtigen **Ebene** (Ausführung vs. Plan)?
|
|
||||||
2. Trennt sie **Plan** und **Ist**?
|
|
||||||
3. Ist ein Meilenstein **prüfbar** — oder nur ein Statusfeld?
|
|
||||||
4. Passiert Bearbeitung in **Modal/Detail** — oder wächst wieder eine CRUD-Wand?
|
|
||||||
5. Wird die **Reise** nachvollziehbar — oder nur der aktuelle Zustand?
|
|
||||||
6. Verhindert sie Scope-Creep in Richtung To-do-Tool?
|
|
||||||
7. Wird die Änderung **nur für eine Methode** hardcodiert — oder über Registry/Profil erweiterbar?
|
|
||||||
8. Unterstützt das Schema **sequenziell und parallel** (Gates vs. Reifegrade)?
|
|
||||||
9. Bleibt **Plan/Ist** getrennt, unabhängig von der gewählten Methode?
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 14. Verbindliche Dokumenten-Hierarchie (neu)
|
## 12. Verbindliche Dokumenten-Hierarchie (neu)
|
||||||
|
|
||||||
Bei Konflikten ab 2026-07-05:
|
Bei Konflikten ab 2026-07-05:
|
||||||
|
|
||||||
|
|
@ -478,4 +427,17 @@ Bei Konflikten ab 2026-07-05:
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 13. Product Owner Leitplanken
|
||||||
|
|
||||||
|
Entscheidungen an diesem Dokument prüfen:
|
||||||
|
|
||||||
|
1. Beantwortet die Änderung die **Leitfrage** auf der richtigen **Ebene** (Ausführung vs. Plan)?
|
||||||
|
2. Trennt sie **Plan** und **Ist**?
|
||||||
|
3. Ist ein Meilenstein **prüfbar** — oder nur ein Statusfeld?
|
||||||
|
4. Passiert Bearbeitung in **Modal/Detail** — oder wächst wieder eine CRUD-Wand?
|
||||||
|
5. Wird die **Reise** nachvollziehbar — oder nur der aktuelle Zustand?
|
||||||
|
6. Verhindert sie Scope-Creep in Richtung To-do-Tool?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
*Verwandt: `DOCUMENTATION_REVISION_PROGRAM_v0.2.md`, `Kairo_Canonical_Operating_Model_v0.2.md`, `Kairo_Implementation_Truth_Table_v0.1.md`*
|
*Verwandt: `DOCUMENTATION_REVISION_PROGRAM_v0.2.md`, `Kairo_Canonical_Operating_Model_v0.2.md`, `Kairo_Implementation_Truth_Table_v0.1.md`*
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "kairo-jinkendo-frontend",
|
"name": "kairo-jinkendo-frontend",
|
||||||
"version": "0.13.0-ap1.4",
|
"version": "0.12.0-ap1.2c",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
import { apiFetch } from './client.js'
|
|
||||||
|
|
||||||
export function listInitiativeRoadmapItems(initiativeId) {
|
|
||||||
return apiFetch(`/api/initiatives/${initiativeId}/roadmap/items`)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createInitiativeRoadmapItem(initiativeId, body) {
|
|
||||||
return apiFetch(`/api/initiatives/${initiativeId}/roadmap/items`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updateRoadmapItem(itemId, body) {
|
|
||||||
return apiFetch(`/api/roadmap-items/${itemId}`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function deleteRoadmapItem(itemId) {
|
|
||||||
return apiFetch(`/api/roadmap-items/${itemId}`, { method: 'DELETE' })
|
|
||||||
}
|
|
||||||
|
|
||||||
export function verifyRoadmapItemReached(itemId) {
|
|
||||||
return apiFetch(`/api/roadmap-items/${itemId}/verify-reached`, {
|
|
||||||
method: 'POST',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function listRoadmapItemDependencies(itemId) {
|
|
||||||
return apiFetch(`/api/roadmap-items/${itemId}/dependencies`)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function addRoadmapItemDependency(itemId, body) {
|
|
||||||
return apiFetch(`/api/roadmap-items/${itemId}/dependencies`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
@ -4,7 +4,7 @@ const AREAS = [
|
||||||
{
|
{
|
||||||
to: 'plan',
|
to: 'plan',
|
||||||
title: 'Plan',
|
title: 'Plan',
|
||||||
description: 'Roadmap, Gates und Plan-Ist — methodenneutral pflegen.',
|
description: 'Meilensteine und Roadmap — überprüfbare Zielpunkte pflegen.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
to: 'execution',
|
to: 'execution',
|
||||||
|
|
@ -34,8 +34,8 @@ export function InitiativeNavCards({ initiativeId, counts = {} }) {
|
||||||
? `${counts.backlog} Backlog`
|
? `${counts.backlog} Backlog`
|
||||||
: area.to === 'execution' && counts.actionsOpen
|
: area.to === 'execution' && counts.actionsOpen
|
||||||
? `${counts.actionsOpen} offen`
|
? `${counts.actionsOpen} offen`
|
||||||
: area.to === 'plan' && counts.roadmapItems
|
: area.to === 'plan' && counts.milestones
|
||||||
? `${counts.roadmapItems} Plan-Elemente`
|
? `${counts.milestones} Meilensteine`
|
||||||
: null
|
: null
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
|
|
|
||||||
|
|
@ -1,220 +0,0 @@
|
||||||
import { useState } from 'react'
|
|
||||||
import {
|
|
||||||
MILESTONE_STATUSES,
|
|
||||||
MILESTONE_STATUS_LABELS,
|
|
||||||
ROADMAP_ITEM_TYPE_LABELS,
|
|
||||||
SEQUENCING_MODE_LABELS,
|
|
||||||
} from '../constants/status.js'
|
|
||||||
import { StatusBadge } from './StatusBadge.jsx'
|
|
||||||
import { EmptyState } from './EmptyState.jsx'
|
|
||||||
|
|
||||||
const ITEM_TYPES = ['milestone', 'review_gate', 'maturity_stage']
|
|
||||||
const SEQUENCING_MODES = ['sequential', 'parallel', 'optional']
|
|
||||||
const EDITABLE_STATUSES = MILESTONE_STATUSES.filter(
|
|
||||||
(s) => !['reached', 'moved', 'discarded'].includes(s)
|
|
||||||
)
|
|
||||||
|
|
||||||
function formatDate(value) {
|
|
||||||
if (!value) return null
|
|
||||||
try {
|
|
||||||
return new Date(value.includes('T') ? value : `${value}T12:00:00`).toLocaleDateString(
|
|
||||||
'de-DE'
|
|
||||||
)
|
|
||||||
} catch {
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function RoadmapPlanSection({
|
|
||||||
items,
|
|
||||||
canManage,
|
|
||||||
onCreate,
|
|
||||||
onUpdateStatus,
|
|
||||||
onUpdateItem,
|
|
||||||
onVerifyReached,
|
|
||||||
onDelete,
|
|
||||||
busy,
|
|
||||||
}) {
|
|
||||||
const [title, setTitle] = useState('')
|
|
||||||
const [goalDescription, setGoalDescription] = useState('')
|
|
||||||
const [targetDate, setTargetDate] = useState('')
|
|
||||||
const [itemType, setItemType] = useState('milestone')
|
|
||||||
const [sequencingMode, setSequencingMode] = useState('sequential')
|
|
||||||
const [showForm, setShowForm] = useState(false)
|
|
||||||
|
|
||||||
async function handleSubmit(e) {
|
|
||||||
e.preventDefault()
|
|
||||||
if (!title.trim()) return
|
|
||||||
await onCreate({
|
|
||||||
title: title.trim(),
|
|
||||||
goal_description: goalDescription.trim(),
|
|
||||||
target_date: targetDate || undefined,
|
|
||||||
item_type: itemType,
|
|
||||||
sequencing_mode: sequencingMode,
|
|
||||||
})
|
|
||||||
setTitle('')
|
|
||||||
setGoalDescription('')
|
|
||||||
setTargetDate('')
|
|
||||||
setItemType('milestone')
|
|
||||||
setSequencingMode('sequential')
|
|
||||||
setShowForm(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="card">
|
|
||||||
<div className="section-header">
|
|
||||||
<div>
|
|
||||||
<h2>Plan</h2>
|
|
||||||
<p className="section-lead muted">
|
|
||||||
Roadmap-Elemente methodenneutral — Gates, Reifegrade oder Review-Punkte.
|
|
||||||
Erreicht nur über Verify (Evidence, Review oder Decision).
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{canManage && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-primary btn-block-mobile"
|
|
||||||
onClick={() => setShowForm((v) => !v)}
|
|
||||||
>
|
|
||||||
{showForm ? 'Abbrechen' : 'Plan-Element'}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showForm && canManage && (
|
|
||||||
<form className="inline-form-block milestone-form" onSubmit={handleSubmit}>
|
|
||||||
<label>
|
|
||||||
Typ
|
|
||||||
<select value={itemType} onChange={(e) => setItemType(e.target.value)}>
|
|
||||||
{ITEM_TYPES.map((t) => (
|
|
||||||
<option key={t} value={t}>
|
|
||||||
{ROADMAP_ITEM_TYPE_LABELS[t] || t}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Titel
|
|
||||||
<input
|
|
||||||
value={title}
|
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
|
||||||
maxLength={255}
|
|
||||||
required
|
|
||||||
placeholder="z. B. MVP nutzbar im Alltag"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Ziel / Definition of Done
|
|
||||||
<textarea
|
|
||||||
value={goalDescription}
|
|
||||||
onChange={(e) => setGoalDescription(e.target.value)}
|
|
||||||
rows={3}
|
|
||||||
placeholder="Woran erkennst du, dass dieses Element erreicht ist?"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Abfolge
|
|
||||||
<select
|
|
||||||
value={sequencingMode}
|
|
||||||
onChange={(e) => setSequencingMode(e.target.value)}
|
|
||||||
>
|
|
||||||
{SEQUENCING_MODES.map((m) => (
|
|
||||||
<option key={m} value={m}>
|
|
||||||
{SEQUENCING_MODE_LABELS[m] || m}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Zieltermin
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={targetDate}
|
|
||||||
onChange={(e) => setTargetDate(e.target.value)}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<button type="submit" className="btn btn-primary" disabled={busy}>
|
|
||||||
Anlegen
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{items.length === 0 && (
|
|
||||||
<EmptyState message="Noch kein Plan — strukturiere das Vorhaben in überprüfbare Roadmap-Elemente." />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ul className="item-list milestone-list">
|
|
||||||
{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>
|
|
||||||
<p className="muted list-item-sub">
|
|
||||||
{ROADMAP_ITEM_TYPE_LABELS[item.item_type] || item.item_type}
|
|
||||||
{' · '}
|
|
||||||
{SEQUENCING_MODE_LABELS[item.sequencing_mode] || item.sequencing_mode}
|
|
||||||
</p>
|
|
||||||
{item.goal_description && (
|
|
||||||
<p className="list-item-desc">{item.goal_description}</p>
|
|
||||||
)}
|
|
||||||
{item.target_date && (
|
|
||||||
<p className="muted list-item-sub">
|
|
||||||
Zieltermin: {formatDate(item.target_date)}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="list-item-meta action-controls">
|
|
||||||
<StatusBadge kind="milestone" status={item.status} />
|
|
||||||
{canManage && item.status !== 'reached' && (
|
|
||||||
<>
|
|
||||||
<select
|
|
||||||
className="inline-select"
|
|
||||||
value={item.status}
|
|
||||||
onChange={(e) => onUpdateStatus(item.id, e.target.value)}
|
|
||||||
aria-label="Plan-Status"
|
|
||||||
>
|
|
||||||
{EDITABLE_STATUSES.map((s) => (
|
|
||||||
<option key={s} value={s}>
|
|
||||||
{MILESTONE_STATUS_LABELS[s]}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<select
|
|
||||||
className="inline-select"
|
|
||||||
value={item.sequencing_mode}
|
|
||||||
onChange={(e) =>
|
|
||||||
onUpdateItem(item.id, { sequencing_mode: e.target.value })
|
|
||||||
}
|
|
||||||
aria-label="Abfolge"
|
|
||||||
>
|
|
||||||
{SEQUENCING_MODES.map((m) => (
|
|
||||||
<option key={m} value={m}>
|
|
||||||
{SEQUENCING_MODE_LABELS[m]}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
{['planned', 'active', 'at_risk'].includes(item.status) && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-primary"
|
|
||||||
disabled={busy}
|
|
||||||
onClick={() => onVerifyReached(item.id)}
|
|
||||||
>
|
|
||||||
Verify → erreicht
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-secondary"
|
|
||||||
onClick={() => onDelete(item.id)}
|
|
||||||
>
|
|
||||||
Löschen
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import {
|
import {
|
||||||
LIFECYCLE_LABELS,
|
LIFECYCLE_LABELS,
|
||||||
|
PHASE_LABELS,
|
||||||
|
PHASE_DESCRIPTIONS,
|
||||||
SIGNAL_LABELS,
|
SIGNAL_LABELS,
|
||||||
NEXT_ACTION_KIND_LABELS,
|
NEXT_ACTION_KIND_LABELS,
|
||||||
} from '../constants/operating.js'
|
} from '../constants/operating.js'
|
||||||
|
|
@ -49,7 +51,9 @@ export function SteeringSnapshotPanel({
|
||||||
lifecycle_label,
|
lifecycle_label,
|
||||||
method_key,
|
method_key,
|
||||||
method_label,
|
method_label,
|
||||||
signals = [],
|
operating_phase,
|
||||||
|
operating_phase_deprecated,
|
||||||
|
phase_signals,
|
||||||
counts,
|
counts,
|
||||||
upcoming_milestones = [],
|
upcoming_milestones = [],
|
||||||
next_actions = [],
|
next_actions = [],
|
||||||
|
|
@ -57,7 +61,12 @@ export function SteeringSnapshotPanel({
|
||||||
const displayLabel =
|
const displayLabel =
|
||||||
lifecycle_label ||
|
lifecycle_label ||
|
||||||
LIFECYCLE_LABELS[lifecycle_state] ||
|
LIFECYCLE_LABELS[lifecycle_state] ||
|
||||||
lifecycle_state
|
PHASE_LABELS[operating_phase] ||
|
||||||
|
lifecycle_state ||
|
||||||
|
operating_phase
|
||||||
|
const phaseDesc = lifecycle_state
|
||||||
|
? null
|
||||||
|
: PHASE_DESCRIPTIONS[operating_phase] || ''
|
||||||
|
|
||||||
const statPills = [
|
const statPills = [
|
||||||
{ label: 'Offen', value: counts.actions_open, warn: false },
|
{ label: 'Offen', value: counts.actions_open, warn: false },
|
||||||
|
|
@ -117,9 +126,11 @@ export function SteeringSnapshotPanel({
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{signals?.length > 0 && (
|
{phaseDesc && <p className="steering-phase-desc">{phaseDesc}</p>}
|
||||||
|
|
||||||
|
{phase_signals?.length > 0 && (
|
||||||
<p className="muted snapshot-signals">
|
<p className="muted snapshot-signals">
|
||||||
{signals.map((s) => SIGNAL_LABELS[s] || s).join(' · ')}
|
{phase_signals.map((s) => SIGNAL_LABELS[s] || s).join(' · ')}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -60,18 +60,6 @@ export const MILESTONE_STATUS_LABELS = {
|
||||||
discarded: 'Verworfen',
|
discarded: 'Verworfen',
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ROADMAP_ITEM_TYPE_LABELS = {
|
|
||||||
milestone: 'Meilenstein / Gate',
|
|
||||||
review_gate: 'Review-Gate',
|
|
||||||
maturity_stage: 'Reifegrad-Stufe',
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SEQUENCING_MODE_LABELS = {
|
|
||||||
sequential: 'Sequenziell',
|
|
||||||
parallel: 'Parallel',
|
|
||||||
optional: 'Optional',
|
|
||||||
}
|
|
||||||
|
|
||||||
export const EVIDENCE_STATUS_LABELS = {
|
export const EVIDENCE_STATUS_LABELS = {
|
||||||
submitted: 'Eingereicht',
|
submitted: 'Eingereicht',
|
||||||
accepted: 'Akzeptiert',
|
accepted: 'Akzeptiert',
|
||||||
|
|
|
||||||
|
|
@ -28,12 +28,11 @@ import {
|
||||||
convertBacklogToAction,
|
convertBacklogToAction,
|
||||||
} from '../api/backlog.js'
|
} from '../api/backlog.js'
|
||||||
import {
|
import {
|
||||||
listInitiativeRoadmapItems,
|
listInitiativeMilestones,
|
||||||
createInitiativeRoadmapItem,
|
createInitiativeMilestone,
|
||||||
updateRoadmapItem,
|
updateMilestone,
|
||||||
deleteRoadmapItem,
|
deleteMilestone,
|
||||||
verifyRoadmapItemReached,
|
} from '../api/milestones.js'
|
||||||
} from '../api/roadmap.js'
|
|
||||||
import {
|
import {
|
||||||
listInitiativeEvidence,
|
listInitiativeEvidence,
|
||||||
createInitiativeEvidence,
|
createInitiativeEvidence,
|
||||||
|
|
@ -75,7 +74,7 @@ export function InitiativeOperationsProvider({ children }) {
|
||||||
const [actions, setActions] = useState([])
|
const [actions, setActions] = useState([])
|
||||||
const [blockers, setBlockers] = useState([])
|
const [blockers, setBlockers] = useState([])
|
||||||
const [backlogItems, setBacklogItems] = useState([])
|
const [backlogItems, setBacklogItems] = useState([])
|
||||||
const [roadmapItems, setRoadmapItems] = useState([])
|
const [milestones, setMilestones] = useState([])
|
||||||
const [evidenceItems, setEvidenceItems] = useState([])
|
const [evidenceItems, setEvidenceItems] = useState([])
|
||||||
const [decisions, setDecisions] = useState([])
|
const [decisions, setDecisions] = useState([])
|
||||||
const [reviews, setReviews] = useState([])
|
const [reviews, setReviews] = useState([])
|
||||||
|
|
@ -111,7 +110,7 @@ export function InitiativeOperationsProvider({ children }) {
|
||||||
if (canReadOm) {
|
if (canReadOm) {
|
||||||
loads.push(listInitiativeBlockers(id).then(setBlockers).catch(() => setBlockers([])))
|
loads.push(listInitiativeBlockers(id).then(setBlockers).catch(() => setBlockers([])))
|
||||||
loads.push(listInitiativeBacklog(id).then(setBacklogItems).catch(() => setBacklogItems([])))
|
loads.push(listInitiativeBacklog(id).then(setBacklogItems).catch(() => setBacklogItems([])))
|
||||||
loads.push(listInitiativeRoadmapItems(id).then(setRoadmapItems).catch(() => setRoadmapItems([])))
|
loads.push(listInitiativeMilestones(id).then(setMilestones).catch(() => setMilestones([])))
|
||||||
loads.push(listInitiativeEvidence(id).then(setEvidenceItems).catch(() => setEvidenceItems([])))
|
loads.push(listInitiativeEvidence(id).then(setEvidenceItems).catch(() => setEvidenceItems([])))
|
||||||
loads.push(listInitiativeDecisions(id).then(setDecisions).catch(() => setDecisions([])))
|
loads.push(listInitiativeDecisions(id).then(setDecisions).catch(() => setDecisions([])))
|
||||||
loads.push(listInitiativeReviews(id).then(setReviews).catch(() => setReviews([])))
|
loads.push(listInitiativeReviews(id).then(setReviews).catch(() => setReviews([])))
|
||||||
|
|
@ -317,10 +316,10 @@ export function InitiativeOperationsProvider({ children }) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleCreateRoadmapItem(body) {
|
async function handleCreateMilestone(body) {
|
||||||
setFormBusy(true)
|
setFormBusy(true)
|
||||||
try {
|
try {
|
||||||
await createInitiativeRoadmapItem(id, body)
|
await createInitiativeMilestone(id, body)
|
||||||
await load()
|
await load()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
|
|
@ -329,50 +328,24 @@ export function InitiativeOperationsProvider({ children }) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleRoadmapItemStatus(itemId, status) {
|
async function handleMilestoneStatus(milestoneId, status) {
|
||||||
try {
|
try {
|
||||||
await updateRoadmapItem(itemId, { status })
|
await updateMilestone(milestoneId, { status })
|
||||||
await load()
|
await load()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleUpdateRoadmapItem(itemId, body) {
|
async function handleDeleteMilestone(milestoneId) {
|
||||||
try {
|
try {
|
||||||
await updateRoadmapItem(itemId, body)
|
await deleteMilestone(milestoneId)
|
||||||
await load()
|
await load()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleVerifyRoadmapItem(itemId) {
|
|
||||||
setFormBusy(true)
|
|
||||||
try {
|
|
||||||
await verifyRoadmapItemReached(itemId)
|
|
||||||
await load()
|
|
||||||
} catch (err) {
|
|
||||||
setError(err.message)
|
|
||||||
} finally {
|
|
||||||
setFormBusy(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDeleteRoadmapItem(itemId) {
|
|
||||||
try {
|
|
||||||
await deleteRoadmapItem(itemId)
|
|
||||||
await load()
|
|
||||||
} catch (err) {
|
|
||||||
setError(err.message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const milestones = useMemo(
|
|
||||||
() => roadmapItems.filter((item) => item.item_type === 'milestone'),
|
|
||||||
[roadmapItems]
|
|
||||||
)
|
|
||||||
|
|
||||||
async function handleCreateEvidence(body) {
|
async function handleCreateEvidence(body) {
|
||||||
setFormBusy(true)
|
setFormBusy(true)
|
||||||
try {
|
try {
|
||||||
|
|
@ -501,7 +474,6 @@ export function InitiativeOperationsProvider({ children }) {
|
||||||
blockers,
|
blockers,
|
||||||
unlinkedBlockers,
|
unlinkedBlockers,
|
||||||
backlogItems,
|
backlogItems,
|
||||||
roadmapItems,
|
|
||||||
milestones,
|
milestones,
|
||||||
evidenceItems,
|
evidenceItems,
|
||||||
decisions,
|
decisions,
|
||||||
|
|
@ -541,14 +513,9 @@ export function InitiativeOperationsProvider({ children }) {
|
||||||
handleBacklogStatus,
|
handleBacklogStatus,
|
||||||
handleConvertBacklog,
|
handleConvertBacklog,
|
||||||
handleDeleteBacklog,
|
handleDeleteBacklog,
|
||||||
handleCreateRoadmapItem,
|
handleCreateMilestone,
|
||||||
handleRoadmapItemStatus,
|
handleMilestoneStatus,
|
||||||
handleUpdateRoadmapItem,
|
handleDeleteMilestone,
|
||||||
handleVerifyRoadmapItem,
|
|
||||||
handleDeleteRoadmapItem,
|
|
||||||
handleCreateMilestone: handleCreateRoadmapItem,
|
|
||||||
handleMilestoneStatus: handleRoadmapItemStatus,
|
|
||||||
handleDeleteMilestone: handleDeleteRoadmapItem,
|
|
||||||
handleCreateEvidence,
|
handleCreateEvidence,
|
||||||
handleEvidenceStatus,
|
handleEvidenceStatus,
|
||||||
handleDeleteEvidence,
|
handleDeleteEvidence,
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,6 @@ export function InitiativeOverviewPage() {
|
||||||
capabilities,
|
capabilities,
|
||||||
handleMethodChange,
|
handleMethodChange,
|
||||||
backlogItems,
|
backlogItems,
|
||||||
roadmapItems,
|
|
||||||
milestones,
|
milestones,
|
||||||
} = useInitiativeOperations()
|
} = useInitiativeOperations()
|
||||||
|
|
||||||
|
|
@ -70,7 +69,7 @@ export function InitiativeOverviewPage() {
|
||||||
initiativeId={initiativeId}
|
initiativeId={initiativeId}
|
||||||
counts={{
|
counts={{
|
||||||
backlog: backlogItems.length,
|
backlog: backlogItems.length,
|
||||||
roadmapItems: roadmapItems.length,
|
milestones: milestones.length,
|
||||||
actionsOpen: counts.actions_open,
|
actionsOpen: counts.actions_open,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,14 @@
|
||||||
import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx'
|
import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx'
|
||||||
import { RoadmapPlanSection } from '../../components/RoadmapPlanSection.jsx'
|
import { MilestonesSection } from '../../components/MilestonesSection.jsx'
|
||||||
|
|
||||||
export function InitiativePlanPage() {
|
export function InitiativePlanPage() {
|
||||||
const {
|
const {
|
||||||
roadmapItems,
|
milestones,
|
||||||
capabilities,
|
capabilities,
|
||||||
formBusy,
|
formBusy,
|
||||||
handleCreateRoadmapItem,
|
handleCreateMilestone,
|
||||||
handleRoadmapItemStatus,
|
handleMilestoneStatus,
|
||||||
handleUpdateRoadmapItem,
|
handleDeleteMilestone,
|
||||||
handleVerifyRoadmapItem,
|
|
||||||
handleDeleteRoadmapItem,
|
|
||||||
error,
|
error,
|
||||||
} = useInitiativeOperations()
|
} = useInitiativeOperations()
|
||||||
|
|
||||||
|
|
@ -21,14 +19,12 @@ export function InitiativePlanPage() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{error && <p className="error">{error}</p>}
|
{error && <p className="error">{error}</p>}
|
||||||
<RoadmapPlanSection
|
<MilestonesSection
|
||||||
items={roadmapItems}
|
milestones={milestones}
|
||||||
canManage={capabilities.has('kairo.milestone.manage')}
|
canManage={capabilities.has('kairo.milestone.manage')}
|
||||||
onCreate={handleCreateRoadmapItem}
|
onCreate={handleCreateMilestone}
|
||||||
onUpdateStatus={handleRoadmapItemStatus}
|
onUpdateStatus={handleMilestoneStatus}
|
||||||
onUpdateItem={handleUpdateRoadmapItem}
|
onDelete={handleDeleteMilestone}
|
||||||
onVerifyReached={handleVerifyRoadmapItem}
|
|
||||||
onDelete={handleDeleteRoadmapItem}
|
|
||||||
busy={formBusy}
|
busy={formBusy}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user