feat: Posteingang und Entscheiden für die Fachebene (Schritt 5 der Baureihenfolge)

Ebene 3 (Rollen verantwortlicher/pruefer) bekommt GET /faelle
(Posteingang aller offenen Anträge des Mandanten) und GET/POST
/faelle/{id} zum Entscheiden. Eine Entscheidung friert Regelwerk-,
Katalog- und den vollständigen Werkzeugdatensatz ein, verlangt eine
Begründung bei Abweichung vom abgeleiteten Vorschlag, erlaubt bei
Genehmigung nur ein durch die Bewertung zulässiges Werkzeug und
protokolliert die Entscheidung im Audit-Log. entscheidung ist
append-only wie bewertung.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
noroot
2026-08-29 12:26:51 +02:00
parent e968cf9761
commit d1e80dd19f
14 changed files with 1017 additions and 92 deletions

View File

@@ -0,0 +1,111 @@
package store
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
)
// Entscheidung ist die Entscheidung eines/einer Verantwortlichen über
// einen Antrag, auf Basis einer Bewertung (Vorschlag). Append-only —
// siehe Migration. WerkzeugSnapshot ist der eingefrorene, vollständige
// Werkzeug-Datensatz zum Entscheidungszeitpunkt (nil, wenn kein
// Werkzeug gewählt wurde, z. B. bei "abgelehnt") — ein späterer
// Katalog-Wandel darf nicht rückwirkend verändern, worauf die
// Entscheidung beruhte.
type Entscheidung struct {
ID string
AntragID string
BewertungID string
EntscheiderUserID string
Entscheidung string
WerkzeugID *string
WerkzeugSnapshot *Werkzeug
Begruendung string
GueltigBis *time.Time
CreatedAt time.Time
}
const entscheidungColumns = `id, antrag_id, bewertung_id, entscheider_user_id, entscheidung,
werkzeug_id, werkzeug_snapshot, begruendung, gueltig_bis, created_at`
func scanEntscheidung(row interface {
Scan(dest ...any) error
}) (Entscheidung, error) {
var e Entscheidung
var snapshotRaw []byte
err := row.Scan(
&e.ID, &e.AntragID, &e.BewertungID, &e.EntscheiderUserID, &e.Entscheidung,
&e.WerkzeugID, &snapshotRaw, &e.Begruendung, &e.GueltigBis, &e.CreatedAt,
)
if err != nil {
return Entscheidung{}, err
}
if snapshotRaw != nil {
var w Werkzeug
if err := json.Unmarshal(snapshotRaw, &w); err != nil {
return Entscheidung{}, fmt.Errorf("store: werkzeug_snapshot unmarshal: %w", err)
}
e.WerkzeugSnapshot = &w
}
return e, nil
}
// EntscheidungInput bündelt die Felder einer neuen Entscheidung.
type EntscheidungInput struct {
AntragID string
BewertungID string
EntscheiderUserID string
Entscheidung string
WerkzeugID *string
WerkzeugSnapshot *Werkzeug
Begruendung string
GueltigBis *time.Time
}
// CreateEntscheidung speichert eine Entscheidung über einen Antrag.
func (s *Store) CreateEntscheidung(ctx context.Context, in EntscheidungInput) (Entscheidung, error) {
var snapshotJSON []byte
if in.WerkzeugSnapshot != nil {
var err error
snapshotJSON, err = json.Marshal(in.WerkzeugSnapshot)
if err != nil {
return Entscheidung{}, fmt.Errorf("store: werkzeug_snapshot marshal: %w", err)
}
}
row := s.Pool.QueryRow(ctx, `
INSERT INTO entscheidung (
antrag_id, bewertung_id, entscheider_user_id, entscheidung, werkzeug_id, werkzeug_snapshot, begruendung, gueltig_bis
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING `+entscheidungColumns,
in.AntragID, in.BewertungID, in.EntscheiderUserID, in.Entscheidung, in.WerkzeugID, snapshotJSON, in.Begruendung, in.GueltigBis,
)
e, err := scanEntscheidung(row)
if err != nil {
return Entscheidung{}, fmt.Errorf("store: create entscheidung: %w", err)
}
return e, nil
}
// GetLatestEntscheidungForAntrag liefert die zuletzt getroffene
// Entscheidung eines Antrags. Liefert ErrNotFound, wenn noch keine
// Entscheidung existiert.
func (s *Store) GetLatestEntscheidungForAntrag(ctx context.Context, antragID string) (Entscheidung, error) {
row := s.Pool.QueryRow(ctx, `
SELECT `+entscheidungColumns+`
FROM entscheidung WHERE antrag_id = $1 ORDER BY created_at DESC LIMIT 1
`, antragID)
e, err := scanEntscheidung(row)
if errors.Is(err, pgx.ErrNoRows) {
return Entscheidung{}, ErrNotFound
}
if err != nil {
return Entscheidung{}, fmt.Errorf("store: get latest entscheidung: %w", err)
}
return e, nil
}

View File

@@ -0,0 +1,115 @@
package store_test
import (
"context"
"errors"
"testing"
"time"
"github.com/netcell-it/deklarix/internal/store"
)
func testAntragMitBewertung(t *testing.T, s *store.Store, accountID, userID string) (store.Antrag, store.Bewertung) {
t.Helper()
ctx := context.Background()
antrag, err := s.CreateAntrag(ctx, accountID, userID, nil, "Testantrag")
if err != nil {
t.Fatalf("CreateAntrag: %v", err)
}
b, err := s.CreateBewertung(ctx, testBewertungInput(antrag.ID))
if err != nil {
t.Fatalf("CreateBewertung: %v", err)
}
return antrag, b
}
func TestEntscheidungCreateAndGetLatest(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
accID := testAccountID(t, s)
userID := testUserID(t, s, accID)
antrag, b := testAntragMitBewertung(t, s, accID, userID)
w, err := s.CreateWerkzeug(ctx, testWerkzeugInput(nil, "ChatGPT Enterprise"))
if err != nil {
t.Fatalf("CreateWerkzeug: %v", err)
}
gueltigBis := time.Now().Add(365 * 24 * time.Hour).Truncate(time.Millisecond)
e, err := s.CreateEntscheidung(ctx, store.EntscheidungInput{
AntragID: antrag.ID, BewertungID: b.ID, EntscheiderUserID: userID,
Entscheidung: "genehmigt", WerkzeugID: &w.ID, WerkzeugSnapshot: &w,
Begruendung: "", GueltigBis: &gueltigBis,
})
if err != nil {
t.Fatalf("CreateEntscheidung: %v", err)
}
if e.Entscheidung != "genehmigt" || e.WerkzeugSnapshot == nil || e.WerkzeugSnapshot.Name != "ChatGPT Enterprise" {
t.Fatalf("CreateEntscheidung = %+v, unerwartete Werte", e)
}
got, err := s.GetLatestEntscheidungForAntrag(ctx, antrag.ID)
if err != nil {
t.Fatalf("GetLatestEntscheidungForAntrag: %v", err)
}
if got.ID != e.ID {
t.Fatalf("GetLatestEntscheidungForAntrag returned a different row than CreateEntscheidung")
}
}
func TestEntscheidungOhneWerkzeugHatKeinenSnapshot(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
accID := testAccountID(t, s)
userID := testUserID(t, s, accID)
antrag, b := testAntragMitBewertung(t, s, accID, userID)
e, err := s.CreateEntscheidung(ctx, store.EntscheidungInput{
AntragID: antrag.ID, BewertungID: b.ID, EntscheiderUserID: userID,
Entscheidung: "abgelehnt", Begruendung: "Kein geeignetes Werkzeug im Katalog",
})
if err != nil {
t.Fatalf("CreateEntscheidung: %v", err)
}
if e.WerkzeugID != nil || e.WerkzeugSnapshot != nil {
t.Fatalf("expected no werkzeug/snapshot for an abgelehnt Entscheidung, got %+v", e)
}
}
func TestGetLatestEntscheidungForAntragNotFound(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
accID := testAccountID(t, s)
userID := testUserID(t, s, accID)
antrag, err := s.CreateAntrag(ctx, accID, userID, nil, "Ohne Entscheidung")
if err != nil {
t.Fatalf("CreateAntrag: %v", err)
}
_, err = s.GetLatestEntscheidungForAntrag(ctx, antrag.ID)
if !errors.Is(err, store.ErrNotFound) {
t.Fatalf("err = %v, want store.ErrNotFound", err)
}
}
func TestEntscheidungIsAppendOnly(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
accID := testAccountID(t, s)
userID := testUserID(t, s, accID)
antrag, b := testAntragMitBewertung(t, s, accID, userID)
e, err := s.CreateEntscheidung(ctx, store.EntscheidungInput{
AntragID: antrag.ID, BewertungID: b.ID, EntscheiderUserID: userID, Entscheidung: "rueckfrage",
})
if err != nil {
t.Fatalf("CreateEntscheidung: %v", err)
}
_, err = s.Pool.Exec(ctx, `UPDATE entscheidung SET entscheidung = 'genehmigt' WHERE id = $1`, e.ID)
if err == nil {
t.Fatal("expected UPDATE on entscheidung to be rejected by the append-only trigger")
}
_, err = s.Pool.Exec(ctx, `DELETE FROM entscheidung WHERE id = $1`, e.ID)
if err == nil {
t.Fatal("expected DELETE on entscheidung to be rejected by the append-only trigger")
}
}

View File

@@ -0,0 +1 @@
DROP TABLE entscheidung;

View File

@@ -0,0 +1,25 @@
-- Entscheidung: der Mensch entscheidet, das System bereitet nur vor
-- (siehe CLAUDE.md, Grundregel). Eine Entscheidung bezieht sich auf
-- genau eine Bewertung (den Vorschlag, auf dessen Basis entschieden
-- wurde) und friert optional ein gewähltes Werkzeug als vollständigen
-- Datensatz ein (werkzeug_snapshot) — ein späterer Katalog-Wandel darf
-- nicht rückwirkend verändern, worauf eine Entscheidung beruhte.
-- Append-only wie bewertung/audit_log: eine Entscheidung wird nicht
-- korrigiert, sondern durch eine neue Entscheidung auf Basis einer
-- neuen Bewertung ersetzt (Neubewertung + erneute Entscheidung).
CREATE TABLE entscheidung (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
antrag_id UUID NOT NULL REFERENCES antrag (id),
bewertung_id UUID NOT NULL REFERENCES bewertung (id),
entscheider_user_id UUID NOT NULL REFERENCES app_user (id),
entscheidung TEXT NOT NULL CHECK (entscheidung IN ('genehmigt', 'genehmigt_mit_auflagen', 'abgelehnt', 'rueckfrage')),
werkzeug_id UUID REFERENCES werkzeug (id),
werkzeug_snapshot JSONB,
begruendung TEXT NOT NULL DEFAULT '',
gueltig_bis TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TRIGGER entscheidung_append_only
BEFORE UPDATE OR DELETE ON entscheidung
FOR EACH ROW EXECUTE FUNCTION forbid_update_delete();