155 lines
4.8 KiB
Go
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
|
|
}
|