diff --git a/CLAUDE.md b/CLAUDE.md index af1adad..39bd47f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -246,7 +246,7 @@ mehrfacher Neubewertung eines Antrags erhalten bleibt. --- -## Entscheidung, Register, Wiedervorlage (Schritt 5+6 erledigt, Schritt 7 geplant) +## Entscheidung, Register, Wiedervorlage (Schritt 5-7 erledigt) **Schritt 5 (Entscheidung, Snapshot, Audit-Log) ist umgesetzt.** Ebene 3 (Fachebene) hat einen Posteingang (`GET /faelle`, alle offenen — @@ -277,9 +277,22 @@ erzeugt zusätzlich einen `audit_log`-Eintrag (Action `antrag_entschieden`). Genehmigungen erhalten ein Ablaufdatum (`gueltig_bis`: 12 Monate, bei -`hochrisiko` 6 — `gueltigkeitFuer`). **Noch nicht gebaut (Schritt 7):** -die Benachrichtigung, wenn sich im Katalog eine Eigenschaft ändert, auf -der eine aktive Genehmigung beruht (Wiedervorlage). +`hochrisiko` 6 — `gueltigkeitFuer`). + +**Schritt 7 (Wiedervorlage, Katalog-Benachrichtigung) ist umgesetzt** +(`internal/web/wiedervorlage_handlers.go`, `GET /wiedervorlage`, Ebene +3). Es gibt keine E-Mail-Infrastruktur — "Benachrichtigung" ist bewusst +eine In-App-Liste, kein Versand. Eine aktive Genehmigung erscheint dort, +wenn mindestens einer zutrifft: (1) `gueltig_bis` ist erreicht oder +liegt innerhalb von 30 Tagen, (2) das zugesagte Werkzeug wurde aus dem +Katalog entfernt, (3) `werkzeugDiff` erkennt eine Abweichung zwischen +dem eingefrorenen `werkzeug_snapshot` und dem aktuellen Katalogeintrag +bei AVV-Verfügbarkeit, Training-Standard oder Verarbeitungsort. Keine +gefundene Abweichung → die Genehmigung erscheint nicht (kein stiller +Blanko-Eintrag für jede Genehmigung). `store.ListAktiveGenehmigungenForAccount` +liefert dafür alle `genehmigt`/`genehmigt_mit_auflagen`-Entscheidungen +eines Mandanten (Join über `antrag.account_id`, da `entscheidung` +selbst kein `account_id` trägt). **Schritt 6 (Registereintrag, Export) ist umgesetzt.** Jede Genehmigung erzeugt automatisch einen `registereintrag` (append-only, Migration @@ -438,7 +451,10 @@ wiederverwendet aus dem alten Produkt), ebenso `bewertung`, Der PDF-Export nutzt das bereits vorhandene, bis dahin ungenutzte `go-pdf/fpdf` [keine neue Abhängigkeit] mit `UnicodeTranslatorFromDescriptor` für deutsche Umlaute.) -7. Wiedervorlage und Katalog-Benachrichtigung +7. ~~Wiedervorlage und Katalog-Benachrichtigung~~ — **erledigt** + (`GET /wiedervorlage`: abgelaufene/bald ablaufende Genehmigungen und + Genehmigungen, deren Werkzeug sich seither im Katalog geändert hat + oder entfernt wurde. In-App-Liste, kein E-Mail-Versand.) Nicht bauen (v1): automatische Genehmigung ohne Mensch, Erkennung tatsächlicher Werkzeug-Nutzung, Mitarbeiterüberwachung (nichts, was diff --git a/internal/web/server.go b/internal/web/server.go index eb86b63..bc673b8 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -124,6 +124,7 @@ func NewServer(st Store, regelwerk Regelwerk) (*Server, error) { mux.HandleFunc("GET /registereintraege", s.requireFachebene(s.handleRegisterListe)) mux.HandleFunc("GET /registereintraege/export.csv", s.requireFachebene(s.handleRegisterExportCSV)) mux.HandleFunc("GET /registereintraege/export.pdf", s.requireFachebene(s.handleRegisterExportPDF)) + mux.HandleFunc("GET /wiedervorlage", s.requireFachebene(s.handleWiedervorlageListe)) mux.HandleFunc("GET /verwaltung/nutzer", s.requireAdmin(s.handleNutzerListe)) mux.HandleFunc("GET /verwaltung/nutzer/neu", s.requireAdmin(s.handleNutzerNeuForm)) mux.HandleFunc("POST /verwaltung/nutzer", s.requireAdmin(s.handleNutzerCreate)) diff --git a/internal/web/templates/layout.html b/internal/web/templates/layout.html index 6e33805..625cb89 100644 --- a/internal/web/templates/layout.html +++ b/internal/web/templates/layout.html @@ -12,6 +12,7 @@ {{if not .IsBetreiber}}Meine Anträge{{end}} {{if .IsFachebene}}Posteingang{{end}} {{if .IsFachebene}}Register{{end}} + {{if .IsFachebene}}Wiedervorlage{{end}} {{if .IsAdmin}}Nutzerverwaltung{{end}} {{if .IsAdmin}}Abteilungen{{end}} {{if .IsAdmin}}Werkzeugkatalog{{end}} diff --git a/internal/web/templates/wiedervorlage_liste.html b/internal/web/templates/wiedervorlage_liste.html new file mode 100644 index 0000000..6fc2c30 --- /dev/null +++ b/internal/web/templates/wiedervorlage_liste.html @@ -0,0 +1,29 @@ +{{define "wiedervorlage-liste"}} + +{{template "head" .}} + +{{template "nav" .Nav}} +
+

Wiedervorlage

+

+ Genehmigungen, die abgelaufen sind, bald ablaufen, oder deren + Werkzeug sich seit der Entscheidung im Katalog geändert hat. +

+{{if not .Eintraege}} +

Nichts zur Wiedervorlage.

+{{else}} + +{{end}} +
+ + +{{end}} diff --git a/internal/web/wiedervorlage_handlers.go b/internal/web/wiedervorlage_handlers.go new file mode 100644 index 0000000..6ff6dbd --- /dev/null +++ b/internal/web/wiedervorlage_handlers.go @@ -0,0 +1,104 @@ +// Ebene 3 — Wiedervorlage (Schritt 7 der Baureihenfolge, siehe +// CLAUDE.md). Eine aktive Genehmigung muss erneut geprüft werden, wenn +// entweder ihr Ablaufdatum erreicht/nah ist, oder sich im Katalog eine +// Eigenschaft geändert hat, auf der sie beruhte. Es gibt keine E-Mail- +// Infrastruktur — "Benachrichtigung" ist v1 eine In-App-Liste, kein +// Versand. +package web + +import ( + "fmt" + "net/http" + "time" + + "github.com/netcell-it/deklarix/internal/store" +) + +// wiedervorlageSchwelle: eine Genehmigung erscheint schon vor ihrem +// Ablauf in der Wiedervorlage, damit der/die Verantwortliche Zeit zum +// Reagieren hat. +const wiedervorlageSchwelle = 30 * 24 * time.Hour + +// werkzeugDiff vergleicht den zum Entscheidungszeitpunkt eingefrorenen +// Werkzeug-Snapshot mit dem aktuellen Katalogstand und benennt jede für +// die Bewertung relevante Eigenschaft, die sich seitdem geändert hat. +func werkzeugDiff(snapshot, current store.Werkzeug) []string { + var diffs []string + if snapshot.AVVVerfuegbar != current.AVVVerfuegbar { + diffs = append(diffs, fmt.Sprintf("AVV verfügbar: %v → %v", snapshot.AVVVerfuegbar, current.AVVVerfuegbar)) + } + if snapshot.TrainingStandard != current.TrainingStandard { + diffs = append(diffs, fmt.Sprintf("Training standardmäßig aus: %v → %v", snapshot.TrainingStandard, current.TrainingStandard)) + } + if snapshot.Verarbeitungsort != current.Verarbeitungsort { + diffs = append(diffs, fmt.Sprintf("Verarbeitungsort: %s → %s", snapshot.Verarbeitungsort, current.Verarbeitungsort)) + } + return diffs +} + +type wiedervorlageItem struct { + AntragID string + Titel string + Werkzeug string + Gruende []string +} + +type wiedervorlageListeData struct { + Title string + Nav navData + Eintraege []wiedervorlageItem +} + +// handleWiedervorlageListe zeigt alle aktiven Genehmigungen des +// Mandanten, die erneut geprüft werden sollten — abgelaufen/bald +// ablaufend oder mit einem seither im Katalog geänderten Werkzeug. +// Genehmigungen ohne Grund zur Wiedervorlage werden nicht gezeigt. +func (s *Server) handleWiedervorlageListe(w http.ResponseWriter, r *http.Request) { + entscheidungen, err := s.store.ListAktiveGenehmigungenForAccount(r.Context(), currentUser(r).AccountID) + if err != nil { + http.Error(w, "Genehmigungen konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError) + return + } + + var data wiedervorlageListeData + data.Title, data.Nav = "Wiedervorlage", navFor(r) + for _, e := range entscheidungen { + var gruende []string + + if e.GueltigBis != nil { + if time.Now().After(*e.GueltigBis) { + gruende = append(gruende, "Genehmigung ist abgelaufen (seit "+e.GueltigBis.Format("02.01.2006")+")") + } else if time.Until(*e.GueltigBis) < wiedervorlageSchwelle { + gruende = append(gruende, "Genehmigung läuft am "+e.GueltigBis.Format("02.01.2006")+" ab") + } + } + + werkzeugName := "" + if e.WerkzeugID != nil { + current, err := s.store.GetWerkzeug(r.Context(), *e.WerkzeugID) + if err != nil { + gruende = append(gruende, "Werkzeug wurde aus dem Katalog entfernt") + } else { + werkzeugName = current.Name + if e.WerkzeugSnapshot != nil { + gruende = append(gruende, werkzeugDiff(*e.WerkzeugSnapshot, current)...) + } + } + } + + if len(gruende) == 0 { + continue + } + antrag, err := s.store.GetAntrag(r.Context(), e.AntragID) + if err != nil { + continue + } + data.Eintraege = append(data.Eintraege, wiedervorlageItem{ + AntragID: antrag.ID, Titel: antrag.Titel, Werkzeug: werkzeugName, Gruende: gruende, + }) + } + + if err := s.templates.ExecuteTemplate(w, "wiedervorlage-liste", data); err != nil { + http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError) + } +} diff --git a/internal/web/wiedervorlage_handlers_test.go b/internal/web/wiedervorlage_handlers_test.go new file mode 100644 index 0000000..022120a --- /dev/null +++ b/internal/web/wiedervorlage_handlers_test.go @@ -0,0 +1,95 @@ +package web_test + +import ( + "net/http" + "strings" + "testing" + "time" +) + +func TestWiedervorlageZeigtAbgelaufeneGenehmigung(t *testing.T) { + fs := newFakeStore() + s := newServer(t, fs) + accountID := genehmigeFall(t, fs, s) + + var antragID string + for id, a := range fs.antraege { + if a.AccountID == accountID { + antragID = id + } + } + es := fs.entscheidungen[antragID] + abgelaufen := time.Now().Add(-24 * time.Hour) + es[len(es)-1].GueltigBis = &abgelaufen + fs.entscheidungen[antragID] = es + + prueferCookie := seedUserInAccount(t, fs, accountID, "reviewer@example.com", "pruefer") + resp := getWithCookie(t, s, prueferCookie, "/wiedervorlage") + if resp.Code != http.StatusOK { + t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String()) + } + if !strings.Contains(resp.Body.String(), "abgelaufen") { + t.Errorf("expected the abgelaufene Genehmigung to appear, got: %s", resp.Body.String()) + } +} + +func TestWiedervorlageZeigtGeaenderteWerkzeugeigenschaft(t *testing.T) { + fs := newFakeStore() + s := newServer(t, fs) + accountID := genehmigeFall(t, fs, s) + + wz := fs.werkzeuge["werkzeug-ok"] + wz.AVVVerfuegbar = false // Katalog hat sich seit der Genehmigung geändert + fs.werkzeuge["werkzeug-ok"] = wz + + prueferCookie := seedUserInAccount(t, fs, accountID, "reviewer@example.com", "pruefer") + resp := getWithCookie(t, s, prueferCookie, "/wiedervorlage") + if resp.Code != http.StatusOK { + t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String()) + } + if !strings.Contains(resp.Body.String(), "AVV verfügbar") { + t.Errorf("expected the changed AVV property to be flagged, got: %s", resp.Body.String()) + } +} + +func TestWiedervorlageZeigtEntferntesWerkzeug(t *testing.T) { + fs := newFakeStore() + s := newServer(t, fs) + accountID := genehmigeFall(t, fs, s) + delete(fs.werkzeuge, "werkzeug-ok") + + prueferCookie := seedUserInAccount(t, fs, accountID, "reviewer@example.com", "pruefer") + resp := getWithCookie(t, s, prueferCookie, "/wiedervorlage") + if resp.Code != http.StatusOK { + t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String()) + } + if !strings.Contains(resp.Body.String(), "aus dem Katalog entfernt") { + t.Errorf("expected the removed werkzeug to be flagged, got: %s", resp.Body.String()) + } +} + +func TestWiedervorlageIstLeerOhneAenderung(t *testing.T) { + fs := newFakeStore() + s := newServer(t, fs) + accountID := genehmigeFall(t, fs, s) + + prueferCookie := seedUserInAccount(t, fs, accountID, "reviewer@example.com", "pruefer") + resp := getWithCookie(t, s, prueferCookie, "/wiedervorlage") + if resp.Code != http.StatusOK { + t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String()) + } + if !strings.Contains(resp.Body.String(), "Nichts zur Wiedervorlage") { + t.Errorf("expected no entries when nothing changed, got: %s", resp.Body.String()) + } +} + +func TestMitarbeiterCannotAccessWiedervorlage(t *testing.T) { + fs := newFakeStore() + s := newServer(t, fs) + cookie := seedAccountWithRole(t, fs, "Test-Mandant", "mitarbeiter@example.com", "mitarbeiter") + + resp := getWithCookie(t, s, cookie, "/wiedervorlage") + if resp.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404 for role mitarbeiter", resp.Code) + } +}