feat: Wiedervorlage und Katalog-Benachrichtigung (Schritt 7 der Baureihenfolge)
GET /wiedervorlage (Ebene 3) zeigt aktive Genehmigungen, die erneut geprüft werden sollten: Ablaufdatum erreicht oder innerhalb von 30 Tagen, das zugesagte Werkzeug wurde aus dem Katalog entfernt, oder der aktuelle Katalogeintrag weicht bei AVV/Training-Standard/ Verarbeitungsort vom zum Entscheidungszeitpunkt eingefrorenen Snapshot ab. Keine E-Mail-Infrastruktur vorhanden — Benachrichtigung ist bewusst eine In-App-Liste. Damit ist die ursprüngliche Baureihenfolge (Schritt 1-7) vollständig umgesetzt. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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))
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
{{if not .IsBetreiber}}<a href="/antraege">Meine Anträge</a>{{end}}
|
||||
{{if .IsFachebene}}<a href="/faelle">Posteingang</a>{{end}}
|
||||
{{if .IsFachebene}}<a href="/registereintraege">Register</a>{{end}}
|
||||
{{if .IsFachebene}}<a href="/wiedervorlage">Wiedervorlage</a>{{end}}
|
||||
{{if .IsAdmin}}<a href="/verwaltung/nutzer">Nutzerverwaltung</a>{{end}}
|
||||
{{if .IsAdmin}}<a href="/verwaltung/abteilungen">Abteilungen</a>{{end}}
|
||||
{{if .IsAdmin}}<a href="/verwaltung/werkzeuge">Werkzeugkatalog</a>{{end}}
|
||||
|
||||
29
internal/web/templates/wiedervorlage_liste.html
Normal file
29
internal/web/templates/wiedervorlage_liste.html
Normal file
@@ -0,0 +1,29 @@
|
||||
{{define "wiedervorlage-liste"}}<!doctype html>
|
||||
<html lang="de">
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<h1>Wiedervorlage</h1>
|
||||
<p class="hinweis">
|
||||
Genehmigungen, die abgelaufen sind, bald ablaufen, oder deren
|
||||
Werkzeug sich seit der Entscheidung im Katalog geändert hat.
|
||||
</p>
|
||||
{{if not .Eintraege}}
|
||||
<p class="keine-findings">Nichts zur Wiedervorlage.</p>
|
||||
{{else}}
|
||||
<ul class="findings">
|
||||
{{range .Eintraege}}
|
||||
<li class="finding finding-mittel">
|
||||
<strong><a href="/faelle/{{.AntragID}}">{{.Titel}}</a></strong>{{if .Werkzeug}} — {{.Werkzeug}}{{end}}
|
||||
<ul>
|
||||
{{range .Gruende}}<li>{{.}}</li>{{end}}
|
||||
</ul>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{end}}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
104
internal/web/wiedervorlage_handlers.go
Normal file
104
internal/web/wiedervorlage_handlers.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
95
internal/web/wiedervorlage_handlers_test.go
Normal file
95
internal/web/wiedervorlage_handlers_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user