Files
nexarch/workflow/internal/routingengine/routingengine.go
2026-09-26 09:15:49 +00: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
}