Files
patrickandClaude Sonnet 5.5 eaffcaf885
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (pull_request) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (push) Failing after 1m6s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Successful in 3s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (push) Successful in 4s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (push) Failing after 23s
CI / Web (dms-browser) (push) Failing after 1m31s
CI / Web (pdf-viewer) (push) Failing after 1m3s
CI / Web (shl) (push) Failing after 14s
Supply-Chain-Scan (Go) / govulncheck (push) Failing after 4m11s
Workflow-Schnittstellen-Vertragstests / contract-tests (push) Failing after 41s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (pull_request) Successful in 7s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (pull_request) Successful in 8s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (pull_request) Failing after 30s
CI / Web (dms-browser) (pull_request) Failing after 1m24s
CI / Web (pdf-viewer) (pull_request) Failing after 57s
CI / Web (shl) (pull_request) Failing after 11s
CI / Core-Modul (Build/Vet/Test) (pull_request) Failing after 11m29s
Core-Schnittstellen-Vertragstests / contract-tests (pull_request) Successful in 10m30s
Supply-Chain-Scan (Go) / govulncheck (pull_request) Failing after 3m26s
Supply-Chain-Scan (npm) / npm-audit (pull_request) Failing after 1m57s
Core-Pflichttest-Gate / pflichttest-gate (pull_request) Successful in 10m25s
RBAC-Rollenhierarchie-Pflichtgate / rbac-policy-invariant (pull_request) Successful in 9m47s
Workflow-Schnittstellen-Vertragstests / contract-tests (pull_request) Failing after 57s
Workflow-Pflichttest-Gate / pflichttest-gate (pull_request) Failing after 1m13s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Failing after 3s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (pull_request) Failing after 3s
DB-Integrationstests / db-tests (pull_request) Canceled after 0s
workflow: svcauth, tenantregistryclient aus common v0.1.0
Das Pflichttest-Gate von workflow verweist nicht mehr auf die geloeschten
Pakete (common hat ein eigenes Gate).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 23:33:55 +01:00

155 lines
4.8 KiB
Go

// Package routingengine implementiert RTE-02: fuehrt das Regelwerk aus
// RTE-01 gegen eingehende Objekte aus. Erste zutreffende Regel nach
// Prioritaet gewinnt, sonst landet das Objekt im Klaerungskorb statt
// verloren zu gehen.
package routingengine
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/workflow/internal/routingrule"
)
// Decision ist das Ergebnis einer Ausfuehrung: entweder eine passende Regel
// mit ihrer Aktion, oder keine Regel (Klaerungskorb).
type Decision struct {
Matched bool
RuleID string
RuleName string
Action routingrule.Action
InBasket bool
BasketID string
}
type Engine struct {
rules *routingrule.Store
pool *pgxpool.Pool
}
func NewEngine(rules *routingrule.Store, pool *pgxpool.Pool) *Engine {
return &Engine{rules: rules, pool: pool}
}
// Execute wertet das Regelwerk live gegen object aus (Pruefung 3: eine
// Regeländerung wirkt beim naechsten Execute-Aufruf, da ListEnabled bei
// jedem Aufruf frisch aus der Datenbank liest — kein In-Memory-Cache, also
// kein Neustart noetig). objectType/objectID identifizieren das Objekt fuer
// Protokoll und Klaerungskorb, unabhaengig vom aufrufenden Modul.
func (e *Engine) Execute(ctx context.Context, objectType, objectID string, object map[string]any) (Decision, error) {
rules, err := e.rules.ListEnabled(ctx)
if err != nil {
return Decision{}, fmt.Errorf("routingengine: regeln laden: %w", err)
}
for _, r := range rules {
if !r.Matches(object) {
continue
}
if len(r.Actions) == 0 {
continue
}
action := r.Actions[0]
if err := e.recordDecision(ctx, objectType, objectID, &r.ID, action); err != nil {
return Decision{}, err
}
return Decision{Matched: true, RuleID: r.ID, RuleName: r.Name, Action: action}, nil
}
// Akzeptanzkriterium 1: keine zutreffende Regel -> Klaerungskorb statt
// Verlust des Objekts.
basketID, err := e.addToBasket(ctx, objectType, objectID, "keine zutreffende regel")
if err != nil {
return Decision{}, err
}
if err := e.recordDecision(ctx, objectType, objectID, nil, routingrule.Action{}); err != nil {
return Decision{}, err
}
return Decision{Matched: false, InBasket: true, BasketID: basketID}, nil
}
// recordDecision protokolliert JEDE Weiterleitung nachvollziehbar
// (Akzeptanzkriterium 2), auch die ohne Treffer.
func (e *Engine) recordDecision(ctx context.Context, objectType, objectID string, ruleID *string, action routingrule.Action) error {
var actionJSON []byte
if ruleID != nil {
b, err := json.Marshal(action)
if err != nil {
return fmt.Errorf("routingengine: aktion kodieren: %w", err)
}
actionJSON = b
}
_, err := e.pool.Exec(ctx, `
INSERT INTO routing_decisions (object_type, object_id, matched_rule_id, matched_action)
VALUES ($1, $2, $3, $4)
`, objectType, objectID, ruleID, actionJSON)
if err != nil {
return fmt.Errorf("routingengine: entscheidung protokollieren: %w", err)
}
return nil
}
func (e *Engine) addToBasket(ctx context.Context, objectType, objectID, reason string) (string, error) {
var id string
err := e.pool.QueryRow(ctx, `
INSERT INTO routing_clarification_basket (object_type, object_id, reason)
VALUES ($1, $2, $3) RETURNING id
`, objectType, objectID, reason).Scan(&id)
if err != nil {
return "", fmt.Errorf("routingengine: klaerungskorb eintragen: %w", err)
}
return id, nil
}
// UnresolvedBasketEntries liefert alle offenen Klaerungskorb-Eintraege
// (Pruefung 2: kein Testobjekt geht verloren -- nachweisbar ueber diese
// Liste).
func (e *Engine) UnresolvedBasketEntries(ctx context.Context) ([]BasketEntry, error) {
rows, err := e.pool.Query(ctx, `
SELECT id, object_type, object_id, reason, created_at
FROM routing_clarification_basket
WHERE resolved_at IS NULL
ORDER BY created_at ASC
`)
if err != nil {
return nil, fmt.Errorf("routingengine: klaerungskorb lesen: %w", err)
}
defer rows.Close()
var out []BasketEntry
for rows.Next() {
var b BasketEntry
if err := rows.Scan(&b.ID, &b.ObjectType, &b.ObjectID, &b.Reason, &b.CreatedAt); err != nil {
return nil, fmt.Errorf("routingengine: zeile lesen: %w", err)
}
out = append(out, b)
}
return out, rows.Err()
}
// ResolveBasketEntry markiert einen Klaerungskorb-Eintrag als aufgeloest
// (z. B. nachdem manuell weitergeleitet wurde).
func (e *Engine) ResolveBasketEntry(ctx context.Context, id string) error {
res, err := e.pool.Exec(ctx, `UPDATE routing_clarification_basket SET resolved_at = now() WHERE id = $1 AND resolved_at IS NULL`, id)
if err != nil {
return fmt.Errorf("routingengine: klaerungskorb aufloesen: %w", err)
}
if res.RowsAffected() == 0 {
return pgx.ErrNoRows
}
return nil
}
type BasketEntry struct {
ID string
ObjectType string
ObjectID string
Reason string
CreatedAt time.Time
}