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
}