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

@@ -24,29 +24,31 @@ const testSessionCookie = "deklarix_session"
// fakeStore ist eine In-Memory-Implementierung von web.Store, damit die
// Handler-Tests keine echte Postgres-Instanz brauchen.
type fakeStore struct {
mu sync.Mutex
nextID int
accounts map[string]store.Account
users map[string]store.User
usersByEmail map[string]string // email -> user id
sessions map[string]store.Session
auditLog []store.AuditEntry
abteilungen map[string][]store.Abteilung // accountID -> Abteilungen
antraege map[string]store.Antrag
werkzeuge map[string]store.Werkzeug
bewertungen map[string][]store.Bewertung // antragID -> Bewertungen, älteste zuerst
mu sync.Mutex
nextID int
accounts map[string]store.Account
users map[string]store.User
usersByEmail map[string]string // email -> user id
sessions map[string]store.Session
auditLog []store.AuditEntry
abteilungen map[string][]store.Abteilung // accountID -> Abteilungen
antraege map[string]store.Antrag
werkzeuge map[string]store.Werkzeug
bewertungen map[string][]store.Bewertung // antragID -> Bewertungen, älteste zuerst
entscheidungen map[string][]store.Entscheidung // antragID -> Entscheidungen, älteste zuerst
}
func newFakeStore() *fakeStore {
return &fakeStore{
accounts: map[string]store.Account{},
users: map[string]store.User{},
usersByEmail: map[string]string{},
sessions: map[string]store.Session{},
abteilungen: map[string][]store.Abteilung{},
antraege: map[string]store.Antrag{},
werkzeuge: map[string]store.Werkzeug{},
bewertungen: map[string][]store.Bewertung{},
accounts: map[string]store.Account{},
users: map[string]store.User{},
usersByEmail: map[string]string{},
sessions: map[string]store.Session{},
abteilungen: map[string][]store.Abteilung{},
antraege: map[string]store.Antrag{},
werkzeuge: map[string]store.Werkzeug{},
bewertungen: map[string][]store.Bewertung{},
entscheidungen: map[string][]store.Entscheidung{},
}
}
@@ -243,6 +245,18 @@ func (f *fakeStore) ListAntraegeForUser(ctx context.Context, erstellerUserID str
return out, nil
}
func (f *fakeStore) ListAntraegeForAccount(ctx context.Context, accountID string) ([]store.Antrag, error) {
f.mu.Lock()
defer f.mu.Unlock()
var out []store.Antrag
for _, a := range f.antraege {
if a.AccountID == accountID {
out = append(out, a)
}
}
return out, nil
}
func (f *fakeStore) ListWerkzeugeForAccount(ctx context.Context, accountID string) ([]store.Werkzeug, error) {
f.mu.Lock()
defer f.mu.Unlock()
@@ -297,6 +311,28 @@ func (f *fakeStore) GetLatestBewertungForAntrag(ctx context.Context, antragID st
return bs[len(bs)-1], nil
}
func (f *fakeStore) CreateEntscheidung(ctx context.Context, in store.EntscheidungInput) (store.Entscheidung, error) {
f.mu.Lock()
defer f.mu.Unlock()
e := store.Entscheidung{
ID: f.newID(), AntragID: in.AntragID, BewertungID: in.BewertungID, EntscheiderUserID: in.EntscheiderUserID,
Entscheidung: in.Entscheidung, WerkzeugID: in.WerkzeugID, WerkzeugSnapshot: in.WerkzeugSnapshot,
Begruendung: in.Begruendung, GueltigBis: in.GueltigBis, CreatedAt: time.Now(),
}
f.entscheidungen[in.AntragID] = append(f.entscheidungen[in.AntragID], e)
return e, nil
}
func (f *fakeStore) GetLatestEntscheidungForAntrag(ctx context.Context, antragID string) (store.Entscheidung, error) {
f.mu.Lock()
defer f.mu.Unlock()
es := f.entscheidungen[antragID]
if len(es) == 0 {
return store.Entscheidung{}, store.ErrNotFound
}
return es[len(es)-1], nil
}
// ─── Test-Setup ───────────────────────────────────────────────────────
// loadTestRegelwerk lädt die echten rules/*.yaml-Dateien — dieselben,
@@ -358,6 +394,31 @@ func seedAccountWithRole(t *testing.T, fs *fakeStore, accountName, email, role s
return &http.Cookie{Name: testSessionCookie, Value: token}
}
// seedUserInAccount legt einen weiteren Nutzer in einem bereits
// existierenden Account an — für Tests, die mehrere Rollen im selben
// Mandanten brauchen (z. B. mitarbeiter stellt einen Antrag,
// verantwortlicher entscheidet darüber).
func seedUserInAccount(t *testing.T, fs *fakeStore, accountID, email, role string) *http.Cookie {
t.Helper()
ctx := context.Background()
hash, err := auth.HashPassword("test-passwort-123")
if err != nil {
t.Fatalf("HashPassword: %v", err)
}
user, err := fs.CreateUser(ctx, accountID, email, hash, role)
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
token, err := auth.NewSessionToken()
if err != nil {
t.Fatalf("NewSessionToken: %v", err)
}
if _, err := fs.CreateSession(ctx, token, user.ID, time.Now().Add(time.Hour)); err != nil {
t.Fatalf("CreateSession: %v", err)
}
return &http.Cookie{Name: testSessionCookie, Value: token}
}
func seedAccount(t *testing.T, fs *fakeStore, accountName, email string) *http.Cookie {
t.Helper()
return seedAccountWithRole(t, fs, accountName, email, "mitarbeiter")