Some checks failed
Deploy Development / deploy (push) Successful in 46s
Test Suite / pytest-backend (push) Failing after 1m30s
Test Suite / k6 /api/health Baseline (push) Has been skipped
Test Suite / playwright-smoke (push) Has been skipped
Test Suite / lint-backend (push) Successful in 2s
Test Suite / compose-smoke (push) Has been skipped
Entfernt operating_phase aus dem Snapshot zugunsten von Lifecycle + signals. Führt methodenneutrale RoadmapItems (Migration 010), Verify-Pfad und Plan-UI ein. Milestone-API bleibt als Compat-Wrapper; Version 0.13.0-ap1.4. Co-authored-by: Cursor <cursoragent@cursor.com>
708 lines
25 KiB
Python
708 lines
25 KiB
Python
"""Default Signal Rule Provider — regelbasiert, tenant-scoped (AP0.8a, AP1.0)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any, Literal, Optional
|
|
|
|
from psycopg2.extras import RealDictCursor
|
|
|
|
from db import get_connection
|
|
from tenant_context import TenantContext
|
|
|
|
AttentionKind = Literal[
|
|
"blocked_action",
|
|
"open_blocker",
|
|
"high_priority_action",
|
|
"unassigned_action",
|
|
"initiative_without_next_action",
|
|
"stale_initiative",
|
|
"milestone_at_risk",
|
|
"overdue_action",
|
|
"review_due",
|
|
"recurring_due",
|
|
"action_review_required",
|
|
]
|
|
|
|
NextActionKind = Literal[
|
|
"assign_action",
|
|
"resolve_blocker",
|
|
"create_action",
|
|
"convert_backlog",
|
|
"review_milestone",
|
|
]
|
|
|
|
Severity = Literal["info", "warning", "critical"]
|
|
|
|
_SEVERITY_ORDER = {"critical": 0, "warning": 1, "info": 2}
|
|
|
|
OPEN_BLOCKER_STATUSES = ("open", "in_progress")
|
|
OPEN_ACTION_STATUSES = ("open", "ready", "in_progress", "blocked", "review_required")
|
|
ACTIVE_INITIATIVE_STATUSES = ("active", "paused")
|
|
|
|
|
|
def _serialize_attention(row: dict[str, Any]) -> dict[str, Any]:
|
|
item = dict(row)
|
|
for key in (
|
|
"scope_id",
|
|
"initiative_id",
|
|
"action_id",
|
|
"blocker_id",
|
|
"milestone_id",
|
|
"review_id",
|
|
"recurring_element_id",
|
|
):
|
|
if item.get(key):
|
|
item[key] = str(item[key])
|
|
return item
|
|
|
|
|
|
def _blocked_actions(cur, ctx: TenantContext) -> list[dict[str, Any]]:
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
'blocked_action' AS kind,
|
|
'critical' AS severity,
|
|
a.title AS title,
|
|
'Maßnahme ist blockiert' AS summary,
|
|
'action' AS scope_type,
|
|
a.id AS scope_id,
|
|
a.initiative_id,
|
|
a.id AS action_id,
|
|
NULL::uuid AS blocker_id,
|
|
NULL::uuid AS milestone_id,
|
|
'action_blocked' AS reason_code,
|
|
'actions' AS data_source
|
|
FROM actions a
|
|
WHERE a.tenant_id = %s AND a.status = 'blocked'
|
|
ORDER BY a.updated_at DESC
|
|
LIMIT 50
|
|
""",
|
|
(ctx.tenant_id,),
|
|
)
|
|
return [_serialize_attention(dict(r)) for r in cur.fetchall()]
|
|
|
|
|
|
def _open_blockers(cur, ctx: TenantContext) -> list[dict[str, Any]]:
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
'open_blocker' AS kind,
|
|
'warning' AS severity,
|
|
b.title AS title,
|
|
'Offener Blocker im Vorhaben' AS summary,
|
|
'blocker' AS scope_type,
|
|
b.id AS scope_id,
|
|
b.initiative_id,
|
|
b.action_id,
|
|
b.id AS blocker_id,
|
|
NULL::uuid AS milestone_id,
|
|
'blocker_open' AS reason_code,
|
|
'blockers' AS data_source
|
|
FROM blockers b
|
|
WHERE b.tenant_id = %s AND b.status IN ('open', 'in_progress')
|
|
ORDER BY b.updated_at DESC
|
|
LIMIT 50
|
|
""",
|
|
(ctx.tenant_id,),
|
|
)
|
|
items = []
|
|
for row in cur.fetchall():
|
|
item = _serialize_attention(dict(row))
|
|
if item.get("action_id"):
|
|
item["action_id"] = str(item["action_id"])
|
|
items.append(item)
|
|
return items
|
|
|
|
|
|
def _high_priority_actions(cur, ctx: TenantContext) -> list[dict[str, Any]]:
|
|
actor_filter = ""
|
|
params: list[Any] = [ctx.tenant_id]
|
|
if ctx.actor_id:
|
|
actor_filter = """
|
|
AND EXISTS (
|
|
SELECT 1 FROM action_assignments aa
|
|
WHERE aa.action_id = a.id
|
|
AND aa.tenant_id = a.tenant_id
|
|
AND aa.actor_id = %s
|
|
)
|
|
"""
|
|
params.append(ctx.actor_id)
|
|
else:
|
|
return []
|
|
|
|
cur.execute(
|
|
f"""
|
|
SELECT
|
|
'high_priority_action' AS kind,
|
|
'warning' AS severity,
|
|
a.title AS title,
|
|
'High-Priority Maßnahme offen' AS summary,
|
|
'action' AS scope_type,
|
|
a.id AS scope_id,
|
|
a.initiative_id,
|
|
a.id AS action_id,
|
|
NULL::uuid AS blocker_id,
|
|
NULL::uuid AS milestone_id,
|
|
'action_high_priority_open' AS reason_code,
|
|
'actions' AS data_source
|
|
FROM actions a
|
|
WHERE a.tenant_id = %s
|
|
AND a.priority = 'high'
|
|
AND a.status IN ('open', 'ready', 'in_progress')
|
|
{actor_filter}
|
|
ORDER BY a.updated_at DESC
|
|
LIMIT 30
|
|
""",
|
|
tuple(params),
|
|
)
|
|
return [_serialize_attention(dict(r)) for r in cur.fetchall()]
|
|
|
|
|
|
def _unassigned_actions(cur, ctx: TenantContext) -> list[dict[str, Any]]:
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
'unassigned_action' AS kind,
|
|
'warning' AS severity,
|
|
a.title AS title,
|
|
'Maßnahme ohne Zuweisung' AS summary,
|
|
'action' AS scope_type,
|
|
a.id AS scope_id,
|
|
a.initiative_id,
|
|
a.id AS action_id,
|
|
NULL::uuid AS blocker_id,
|
|
NULL::uuid AS milestone_id,
|
|
'action_unassigned' AS reason_code,
|
|
'actions' AS data_source
|
|
FROM actions a
|
|
WHERE a.tenant_id = %s
|
|
AND a.status IN ('open', 'ready', 'in_progress')
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM action_assignments aa
|
|
WHERE aa.action_id = a.id AND aa.tenant_id = a.tenant_id
|
|
)
|
|
ORDER BY a.updated_at DESC
|
|
LIMIT 30
|
|
""",
|
|
(ctx.tenant_id,),
|
|
)
|
|
return [_serialize_attention(dict(r)) for r in cur.fetchall()]
|
|
|
|
|
|
def _initiatives_without_next_action(cur, ctx: TenantContext) -> list[dict[str, Any]]:
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
'initiative_without_next_action' AS kind,
|
|
'info' AS severity,
|
|
i.title AS title,
|
|
'Keine offene nächste Maßnahme' AS summary,
|
|
'initiative' AS scope_type,
|
|
i.id AS scope_id,
|
|
i.id AS initiative_id,
|
|
NULL::uuid AS action_id,
|
|
NULL::uuid AS blocker_id,
|
|
NULL::uuid AS milestone_id,
|
|
'initiative_no_open_action' AS reason_code,
|
|
'initiatives' AS data_source
|
|
FROM initiatives i
|
|
WHERE i.tenant_id = %s
|
|
AND i.status IN ('active', 'paused')
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM actions a
|
|
WHERE a.initiative_id = i.id
|
|
AND a.tenant_id = i.tenant_id
|
|
AND a.status IN ('open', 'ready', 'in_progress', 'blocked', 'review_required')
|
|
)
|
|
ORDER BY i.updated_at DESC
|
|
LIMIT 30
|
|
""",
|
|
(ctx.tenant_id,),
|
|
)
|
|
return [_serialize_attention(dict(r)) for r in cur.fetchall()]
|
|
|
|
|
|
def _stale_initiatives(cur, ctx: TenantContext) -> list[dict[str, Any]]:
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
'stale_initiative' AS kind,
|
|
'info' AS severity,
|
|
i.title AS title,
|
|
'Vorhaben seit über 14 Tagen unverändert' AS summary,
|
|
'initiative' AS scope_type,
|
|
i.id AS scope_id,
|
|
i.id AS initiative_id,
|
|
NULL::uuid AS action_id,
|
|
NULL::uuid AS blocker_id,
|
|
NULL::uuid AS milestone_id,
|
|
'initiative_stale' AS reason_code,
|
|
'initiatives' AS data_source
|
|
FROM initiatives i
|
|
WHERE i.tenant_id = %s
|
|
AND i.status = 'active'
|
|
AND i.updated_at < NOW() - INTERVAL '14 days'
|
|
ORDER BY i.updated_at ASC
|
|
LIMIT 20
|
|
""",
|
|
(ctx.tenant_id,),
|
|
)
|
|
return [_serialize_attention(dict(r)) for r in cur.fetchall()]
|
|
|
|
|
|
def _milestones_at_risk(cur, ctx: TenantContext) -> list[dict[str, Any]]:
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
'milestone_at_risk' AS kind,
|
|
'warning' AS severity,
|
|
ri.title AS title,
|
|
'Plan-Element als gefährdet markiert' AS summary,
|
|
'milestone' AS scope_type,
|
|
ri.id AS scope_id,
|
|
r.initiative_id,
|
|
NULL::uuid AS action_id,
|
|
NULL::uuid AS blocker_id,
|
|
ri.id AS milestone_id,
|
|
'milestone_at_risk' AS reason_code,
|
|
'roadmap_items' AS data_source
|
|
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 ri.status = 'at_risk'
|
|
ORDER BY ri.updated_at DESC
|
|
LIMIT 20
|
|
""",
|
|
(ctx.tenant_id,),
|
|
)
|
|
return [_serialize_attention(dict(r)) for r in cur.fetchall()]
|
|
|
|
|
|
def _actions_review_required(cur, ctx: TenantContext) -> list[dict[str, Any]]:
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
'action_review_required' AS kind,
|
|
'warning' AS severity,
|
|
a.title AS title,
|
|
'Ma├ƒnahme wartet auf Review ÔÇö kein geplantes Review verkn├╝pft' AS summary,
|
|
'action' AS scope_type,
|
|
a.id AS scope_id,
|
|
a.initiative_id,
|
|
a.id AS action_id,
|
|
NULL::uuid AS blocker_id,
|
|
NULL::uuid AS milestone_id,
|
|
NULL::uuid AS review_id,
|
|
NULL::uuid AS recurring_element_id,
|
|
'action_review_required_no_review' AS reason_code,
|
|
'actions' AS data_source
|
|
FROM actions a
|
|
WHERE a.tenant_id = %s
|
|
AND a.status = 'review_required'
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM reviews r
|
|
WHERE r.tenant_id = a.tenant_id
|
|
AND r.action_id = a.id
|
|
AND r.status = 'planned'
|
|
)
|
|
ORDER BY a.updated_at DESC
|
|
LIMIT 20
|
|
""",
|
|
(ctx.tenant_id,),
|
|
)
|
|
return [_serialize_attention(dict(r)) for r in cur.fetchall()]
|
|
|
|
|
|
def _overdue_actions(cur, ctx: TenantContext) -> list[dict[str, Any]]:
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
'overdue_action' AS kind,
|
|
'warning' AS severity,
|
|
a.title AS title,
|
|
'Maßnahme überfällig' AS summary,
|
|
'action' AS scope_type,
|
|
a.id AS scope_id,
|
|
a.initiative_id,
|
|
a.id AS action_id,
|
|
NULL::uuid AS blocker_id,
|
|
NULL::uuid AS milestone_id,
|
|
NULL::uuid AS review_id,
|
|
NULL::uuid AS recurring_element_id,
|
|
'action_overdue' AS reason_code,
|
|
'actions' AS data_source
|
|
FROM actions a
|
|
WHERE a.tenant_id = %s
|
|
AND a.due_at IS NOT NULL
|
|
AND a.due_at < NOW()
|
|
AND a.status IN ('open', 'ready', 'in_progress', 'blocked', 'review_required')
|
|
ORDER BY a.due_at ASC
|
|
LIMIT 30
|
|
""",
|
|
(ctx.tenant_id,),
|
|
)
|
|
return [_serialize_attention(dict(r)) for r in cur.fetchall()]
|
|
|
|
|
|
def _reviews_due(cur, ctx: TenantContext) -> list[dict[str, Any]]:
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
'review_due' AS kind,
|
|
'warning' AS severity,
|
|
r.title AS title,
|
|
'Review fällig' AS summary,
|
|
'review' AS scope_type,
|
|
r.id AS scope_id,
|
|
r.initiative_id,
|
|
r.action_id,
|
|
NULL::uuid AS blocker_id,
|
|
r.milestone_id,
|
|
r.id AS review_id,
|
|
NULL::uuid AS recurring_element_id,
|
|
'review_due' AS reason_code,
|
|
'reviews' AS data_source
|
|
FROM reviews r
|
|
WHERE r.tenant_id = %s
|
|
AND r.status = 'planned'
|
|
AND r.due_at IS NOT NULL
|
|
AND r.due_at <= NOW()
|
|
ORDER BY r.due_at ASC
|
|
LIMIT 20
|
|
""",
|
|
(ctx.tenant_id,),
|
|
)
|
|
items = []
|
|
for row in cur.fetchall():
|
|
item = _serialize_attention(dict(row))
|
|
for fk in ("action_id", "milestone_id"):
|
|
if item.get(fk):
|
|
item[fk] = str(item[fk])
|
|
items.append(item)
|
|
return items
|
|
|
|
|
|
def _recurring_due(cur, ctx: TenantContext) -> list[dict[str, Any]]:
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
'recurring_due' AS kind,
|
|
'info' AS severity,
|
|
re.title AS title,
|
|
'Wiederkehrendes Element fällig' AS summary,
|
|
'recurring_element' AS scope_type,
|
|
re.id AS scope_id,
|
|
re.initiative_id,
|
|
NULL::uuid AS action_id,
|
|
NULL::uuid AS blocker_id,
|
|
NULL::uuid AS milestone_id,
|
|
NULL::uuid AS review_id,
|
|
re.id AS recurring_element_id,
|
|
'recurring_due' AS reason_code,
|
|
'recurring_elements' AS data_source
|
|
FROM recurring_elements re
|
|
WHERE re.tenant_id = %s
|
|
AND re.status = 'active'
|
|
AND re.next_due_at IS NOT NULL
|
|
AND re.next_due_at <= NOW()
|
|
ORDER BY re.next_due_at ASC
|
|
LIMIT 20
|
|
""",
|
|
(ctx.tenant_id,),
|
|
)
|
|
return [_serialize_attention(dict(r)) for r in cur.fetchall()]
|
|
|
|
|
|
def get_attention_items(ctx: TenantContext) -> list[dict[str, Any]]:
|
|
"""Regelbasierte Attention Items ÔÇö tenant-scoped, erkl├ñrbar."""
|
|
conn = get_connection()
|
|
try:
|
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
|
items: list[dict[str, Any]] = []
|
|
items.extend(_blocked_actions(cur, ctx))
|
|
items.extend(_open_blockers(cur, ctx))
|
|
items.extend(_high_priority_actions(cur, ctx))
|
|
items.extend(_unassigned_actions(cur, ctx))
|
|
items.extend(_initiatives_without_next_action(cur, ctx))
|
|
items.extend(_stale_initiatives(cur, ctx))
|
|
items.extend(_milestones_at_risk(cur, ctx))
|
|
items.extend(_overdue_actions(cur, ctx))
|
|
items.extend(_actions_review_required(cur, ctx))
|
|
items.extend(_reviews_due(cur, ctx))
|
|
items.extend(_recurring_due(cur, ctx))
|
|
|
|
items.sort(key=lambda x: _SEVERITY_ORDER.get(x["severity"], 99))
|
|
return items
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def get_next_action_candidates(
|
|
ctx: TenantContext, *, limit: int = 10
|
|
) -> list[dict[str, Any]]:
|
|
"""Regelbasierte NextActionCandidates ÔÇö limitiert, tenant-scoped."""
|
|
if limit < 1:
|
|
limit = 1
|
|
if limit > 50:
|
|
limit = 50
|
|
|
|
conn = get_connection()
|
|
candidates: list[dict[str, Any]] = []
|
|
try:
|
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
'resolve_blocker' AS kind,
|
|
b.title AS title,
|
|
'Blocker klären oder Status aktualisieren' AS summary,
|
|
b.initiative_id,
|
|
b.action_id,
|
|
NULL::uuid AS backlog_item_id,
|
|
'blocker_open' AS reason_code,
|
|
'Blocker bearbeiten' AS recommended_action
|
|
FROM blockers b
|
|
WHERE b.tenant_id = %s AND b.status IN ('open', 'in_progress')
|
|
ORDER BY b.updated_at DESC
|
|
LIMIT %s
|
|
""",
|
|
(ctx.tenant_id, limit),
|
|
)
|
|
for row in cur.fetchall():
|
|
item = dict(row)
|
|
item["initiative_id"] = str(item["initiative_id"])
|
|
if item.get("action_id"):
|
|
item["action_id"] = str(item["action_id"])
|
|
candidates.append(item)
|
|
|
|
remaining = limit - len(candidates)
|
|
if remaining > 0:
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
'assign_action' AS kind,
|
|
a.title AS title,
|
|
'Actor zuweisen' AS summary,
|
|
a.initiative_id,
|
|
a.id AS action_id,
|
|
NULL::uuid AS backlog_item_id,
|
|
'action_unassigned' AS reason_code,
|
|
'Maßnahme zuweisen' AS recommended_action
|
|
FROM actions a
|
|
WHERE a.tenant_id = %s
|
|
AND a.status IN ('open', 'ready', 'in_progress')
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM action_assignments aa
|
|
WHERE aa.action_id = a.id AND aa.tenant_id = a.tenant_id
|
|
)
|
|
ORDER BY a.updated_at DESC
|
|
LIMIT %s
|
|
""",
|
|
(ctx.tenant_id, remaining),
|
|
)
|
|
for row in cur.fetchall():
|
|
item = dict(row)
|
|
item["initiative_id"] = str(item["initiative_id"])
|
|
item["action_id"] = str(item["action_id"])
|
|
candidates.append(item)
|
|
|
|
remaining = limit - len(candidates)
|
|
if remaining > 0:
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
'convert_backlog' AS kind,
|
|
bi.title AS title,
|
|
'Freigegebenes Backlog-Item in Maßnahme umwandeln' AS summary,
|
|
bi.initiative_id,
|
|
NULL::uuid AS action_id,
|
|
bi.id AS backlog_item_id,
|
|
'backlog_accepted_not_converted' AS reason_code,
|
|
'In Maßnahme umwandeln' AS recommended_action
|
|
FROM backlog_items bi
|
|
WHERE bi.tenant_id = %s
|
|
AND bi.status = 'accepted'
|
|
AND bi.converted_action_id IS NULL
|
|
ORDER BY bi.updated_at DESC
|
|
LIMIT %s
|
|
""",
|
|
(ctx.tenant_id, remaining),
|
|
)
|
|
for row in cur.fetchall():
|
|
item = dict(row)
|
|
item["initiative_id"] = str(item["initiative_id"])
|
|
item["backlog_item_id"] = str(item["backlog_item_id"])
|
|
candidates.append(item)
|
|
|
|
remaining = limit - len(candidates)
|
|
if remaining > 0:
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
'create_action' AS kind,
|
|
i.title AS title,
|
|
'Nächste Maßnahme für Vorhaben anlegen' AS summary,
|
|
i.id AS initiative_id,
|
|
NULL::uuid AS action_id,
|
|
NULL::uuid AS backlog_item_id,
|
|
'initiative_no_open_action' AS reason_code,
|
|
'Maßnahme anlegen' AS recommended_action
|
|
FROM initiatives i
|
|
WHERE i.tenant_id = %s
|
|
AND i.status IN ('active', 'paused')
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM actions a
|
|
WHERE a.initiative_id = i.id
|
|
AND a.tenant_id = i.tenant_id
|
|
AND a.status IN ('open', 'ready', 'in_progress', 'blocked', 'review_required')
|
|
)
|
|
ORDER BY i.updated_at DESC
|
|
LIMIT %s
|
|
""",
|
|
(ctx.tenant_id, remaining),
|
|
)
|
|
for row in cur.fetchall():
|
|
item = dict(row)
|
|
item["initiative_id"] = str(item["initiative_id"])
|
|
candidates.append(item)
|
|
|
|
return candidates[:limit]
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def get_next_action_candidates_for_initiative(
|
|
ctx: TenantContext, *, initiative_id: str, limit: int = 5
|
|
) -> list[dict[str, Any]]:
|
|
"""NextActionCandidates f├╝r ein Vorhaben ÔÇö tenant-scoped."""
|
|
if limit < 1:
|
|
limit = 1
|
|
if limit > 20:
|
|
limit = 20
|
|
|
|
conn = get_connection()
|
|
candidates: list[dict[str, Any]] = []
|
|
try:
|
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
'resolve_blocker' AS kind,
|
|
b.title AS title,
|
|
'Blocker klären oder Status aktualisieren' AS summary,
|
|
b.initiative_id,
|
|
b.action_id,
|
|
NULL::uuid AS backlog_item_id,
|
|
'blocker_open' AS reason_code,
|
|
'Blocker bearbeiten' AS recommended_action
|
|
FROM blockers b
|
|
WHERE b.tenant_id = %s AND b.initiative_id = %s
|
|
AND b.status IN ('open', 'in_progress')
|
|
ORDER BY b.updated_at DESC
|
|
LIMIT %s
|
|
""",
|
|
(ctx.tenant_id, initiative_id, limit),
|
|
)
|
|
for row in cur.fetchall():
|
|
item = dict(row)
|
|
item["initiative_id"] = str(item["initiative_id"])
|
|
if item.get("action_id"):
|
|
item["action_id"] = str(item["action_id"])
|
|
candidates.append(item)
|
|
|
|
remaining = limit - len(candidates)
|
|
if remaining > 0:
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
'assign_action' AS kind,
|
|
a.title AS title,
|
|
'Actor zuweisen' AS summary,
|
|
a.initiative_id,
|
|
a.id AS action_id,
|
|
NULL::uuid AS backlog_item_id,
|
|
'action_unassigned' AS reason_code,
|
|
'Maßnahme zuweisen' AS recommended_action
|
|
FROM actions a
|
|
WHERE a.tenant_id = %s AND a.initiative_id = %s
|
|
AND a.status IN ('open', 'ready', 'in_progress')
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM action_assignments aa
|
|
WHERE aa.action_id = a.id AND aa.tenant_id = a.tenant_id
|
|
)
|
|
ORDER BY a.updated_at DESC
|
|
LIMIT %s
|
|
""",
|
|
(ctx.tenant_id, initiative_id, remaining),
|
|
)
|
|
for row in cur.fetchall():
|
|
item = dict(row)
|
|
item["initiative_id"] = str(item["initiative_id"])
|
|
item["action_id"] = str(item["action_id"])
|
|
candidates.append(item)
|
|
|
|
remaining = limit - len(candidates)
|
|
if remaining > 0:
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
'convert_backlog' AS kind,
|
|
bi.title AS title,
|
|
'Freigegebenes Backlog-Item in Maßnahme umwandeln' AS summary,
|
|
bi.initiative_id,
|
|
NULL::uuid AS action_id,
|
|
bi.id AS backlog_item_id,
|
|
'backlog_accepted_not_converted' AS reason_code,
|
|
'In Maßnahme umwandeln' AS recommended_action
|
|
FROM backlog_items bi
|
|
WHERE bi.tenant_id = %s AND bi.initiative_id = %s
|
|
AND bi.status = 'accepted'
|
|
AND bi.converted_action_id IS NULL
|
|
ORDER BY bi.updated_at DESC
|
|
LIMIT %s
|
|
""",
|
|
(ctx.tenant_id, initiative_id, remaining),
|
|
)
|
|
for row in cur.fetchall():
|
|
item = dict(row)
|
|
item["initiative_id"] = str(item["initiative_id"])
|
|
item["backlog_item_id"] = str(item["backlog_item_id"])
|
|
candidates.append(item)
|
|
|
|
remaining = limit - len(candidates)
|
|
if remaining > 0:
|
|
cur.execute(
|
|
"""
|
|
SELECT 1 FROM actions a
|
|
WHERE a.initiative_id = %s AND a.tenant_id = %s
|
|
AND a.status IN ('open', 'ready', 'in_progress', 'blocked', 'review_required')
|
|
LIMIT 1
|
|
""",
|
|
(initiative_id, ctx.tenant_id),
|
|
)
|
|
if not cur.fetchone():
|
|
cur.execute(
|
|
"""
|
|
SELECT title FROM initiatives
|
|
WHERE id = %s AND tenant_id = %s
|
|
""",
|
|
(initiative_id, ctx.tenant_id),
|
|
)
|
|
init_row = cur.fetchone()
|
|
if init_row:
|
|
candidates.append(
|
|
{
|
|
"kind": "create_action",
|
|
"title": init_row["title"],
|
|
"summary": "Nächste Maßnahme für Vorhaben anlegen",
|
|
"initiative_id": initiative_id,
|
|
"action_id": None,
|
|
"backlog_item_id": None,
|
|
"reason_code": "initiative_no_open_action",
|
|
"recommended_action": "Maßnahme anlegen",
|
|
}
|
|
)
|
|
|
|
return candidates[:limit]
|
|
finally:
|
|
conn.close()
|