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>
237 lines
7.9 KiB
Go
237 lines
7.9 KiB
Go
// Ebene 3 (Fachebene) — Posteingang offener Anträge und Entscheiden.
|
|
// Rollen "verantwortlicher" (Entscheidungsrecht) und "pruefer"
|
|
// (identische Sicht, kein Entscheidungsrecht) sehen dieselben Seiten;
|
|
// siehe requireFachebene in middleware.go und CLAUDE.md, Fünf Ebenen.
|
|
package web
|
|
|
|
import (
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/netcell-it/deklarix/internal/store"
|
|
)
|
|
|
|
type fallListItem struct {
|
|
ID string
|
|
Titel string
|
|
Haeufigkeit string
|
|
CreatedAt string
|
|
}
|
|
|
|
type fallListData struct {
|
|
Title string
|
|
Nav navData
|
|
Faelle []fallListItem
|
|
}
|
|
|
|
// handleFaelleListe zeigt den Posteingang: alle noch offenen (Status
|
|
// "eingereicht") Anträge des ganzen Mandanten — im Unterschied zu
|
|
// GET /antraege, das nur die eigenen Anträge eines Nutzers zeigt.
|
|
func (s *Server) handleFaelleListe(w http.ResponseWriter, r *http.Request) {
|
|
antraege, err := s.store.ListAntraegeForAccount(r.Context(), currentUser(r).AccountID)
|
|
if err != nil {
|
|
http.Error(w, "Anträge konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
data := fallListData{Title: "Posteingang", Nav: navFor(r)}
|
|
for _, a := range antraege {
|
|
if a.Status != "eingereicht" {
|
|
continue
|
|
}
|
|
data.Faelle = append(data.Faelle, fallListItem{
|
|
ID: a.ID, Titel: a.Titel, Haeufigkeit: a.Haeufigkeit,
|
|
CreatedAt: a.CreatedAt.Format("02.01.2006 15:04"),
|
|
})
|
|
}
|
|
if err := s.templates.ExecuteTemplate(w, "faelle-liste", data); err != nil {
|
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
|
|
type entscheidungView struct {
|
|
Entscheidung string
|
|
Begruendung string
|
|
WerkzeugName string
|
|
GueltigBis string
|
|
CreatedAt string
|
|
}
|
|
|
|
type werkzeugOption struct {
|
|
ID string
|
|
Name string
|
|
}
|
|
|
|
type fallDetailData struct {
|
|
Title string
|
|
Nav navData
|
|
ID string
|
|
Titel string
|
|
Beschreibung string
|
|
Ergebnis string
|
|
Haeufigkeit string
|
|
Status string
|
|
CreatedAt string
|
|
Bewertung *bewertungView
|
|
Entscheidung *entscheidungView
|
|
CanEntscheiden bool
|
|
WerkzeugOptionen []werkzeugOption
|
|
Error string
|
|
}
|
|
|
|
// vorschlagFuer leitet aus einer Bewertung ab, welche Entscheidung das
|
|
// System vorschlagen würde — nicht gespeichert, nur zur Bestimmung, ob
|
|
// eine tatsächliche Entscheidung davon abweicht (dann ist die
|
|
// Begründung Pflicht, siehe CLAUDE.md, Grundregel).
|
|
func vorschlagFuer(b store.Bewertung) string {
|
|
if b.Verboten {
|
|
return "abgelehnt"
|
|
}
|
|
if len(b.ZulaessigeWerkzeuge) > 0 {
|
|
return "genehmigt"
|
|
}
|
|
return "rueckfrage"
|
|
}
|
|
|
|
// handleFallDetail zeigt einen Antrag mit Bewertung für die Fachebene
|
|
// — anders als GET /antraege/{id} (nur der eigene Antrag) ist hier
|
|
// jeder Antrag des Mandanten sichtbar, unabhängig vom Ersteller.
|
|
func (s *Server) handleFallDetail(w http.ResponseWriter, r *http.Request) {
|
|
antrag, err := s.store.GetAntrag(r.Context(), r.PathValue("id"))
|
|
if err != nil || antrag.AccountID != currentUser(r).AccountID {
|
|
http.Error(w, "Antrag nicht gefunden", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
data := fallDetailData{
|
|
Title: "Fall", Nav: navFor(r), ID: antrag.ID, Titel: antrag.Titel,
|
|
Beschreibung: antrag.Beschreibung, Ergebnis: antrag.Ergebnis, Haeufigkeit: antrag.Haeufigkeit,
|
|
Status: antrag.Status, CreatedAt: antrag.CreatedAt.Format("02.01.2006 15:04"),
|
|
}
|
|
|
|
bewertung, err := s.store.GetLatestBewertungForAntrag(r.Context(), antrag.ID)
|
|
if err == nil {
|
|
data.Bewertung = s.toBewertungView(r.Context(), bewertung)
|
|
for _, id := range bewertung.ZulaessigeWerkzeuge {
|
|
data.WerkzeugOptionen = append(data.WerkzeugOptionen, werkzeugOption{ID: id, Name: s.werkzeugName(r.Context(), id)})
|
|
}
|
|
}
|
|
data.CanEntscheiden = currentUser(r).Role == "verantwortlicher" && antrag.Status == "eingereicht" && data.Bewertung != nil
|
|
|
|
entscheidung, err := s.store.GetLatestEntscheidungForAntrag(r.Context(), antrag.ID)
|
|
if err == nil {
|
|
v := &entscheidungView{
|
|
Entscheidung: entscheidung.Entscheidung, Begruendung: entscheidung.Begruendung,
|
|
CreatedAt: entscheidung.CreatedAt.Format("02.01.2006 15:04"),
|
|
}
|
|
if entscheidung.WerkzeugSnapshot != nil {
|
|
v.WerkzeugName = entscheidung.WerkzeugSnapshot.Name
|
|
}
|
|
if entscheidung.GueltigBis != nil {
|
|
v.GueltigBis = entscheidung.GueltigBis.Format("02.01.2006")
|
|
}
|
|
data.Entscheidung = v
|
|
}
|
|
|
|
if err := s.templates.ExecuteTemplate(w, "fall-detail", data); err != nil {
|
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
|
|
// gueltigkeitFuer liefert das Ablaufdatum einer Genehmigung — 12 Monate,
|
|
// bei hochrisiko 6 (siehe CLAUDE.md, Entscheidung/Register/Wiedervorlage).
|
|
func gueltigkeitFuer(einstufung string) time.Time {
|
|
if einstufung == "hochrisiko" {
|
|
return time.Now().AddDate(0, 6, 0)
|
|
}
|
|
return time.Now().AddDate(1, 0, 0)
|
|
}
|
|
|
|
// handleFallEntscheiden speichert die Entscheidung eines/einer
|
|
// Verantwortlichen. Prüfer haben dieselbe Sicht, aber kein
|
|
// Entscheidungsrecht — anders als bei requireFachebene (404, weil die
|
|
// Seite für sie gar nicht existieren soll) ist das hier ein 403: ein
|
|
// Prüfer darf wissen, dass der Fall existiert, nur nicht entscheiden.
|
|
func (s *Server) handleFallEntscheiden(w http.ResponseWriter, r *http.Request) {
|
|
user := currentUser(r)
|
|
if user.Role != "verantwortlicher" {
|
|
http.Error(w, "kein Entscheidungsrecht", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
antrag, err := s.store.GetAntrag(r.Context(), r.PathValue("id"))
|
|
if err != nil || antrag.AccountID != user.AccountID {
|
|
http.Error(w, "Antrag nicht gefunden", http.StatusNotFound)
|
|
return
|
|
}
|
|
if antrag.Status != "eingereicht" {
|
|
http.Error(w, "Antrag ist bereits entschieden", http.StatusBadRequest)
|
|
return
|
|
}
|
|
bewertung, err := s.store.GetLatestBewertungForAntrag(r.Context(), antrag.ID)
|
|
if err != nil {
|
|
http.Error(w, "Bewertung nicht gefunden", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
if err := r.ParseForm(); err != nil {
|
|
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
|
return
|
|
}
|
|
entscheidungTyp := r.FormValue("entscheidung")
|
|
begruendung := r.FormValue("begruendung")
|
|
werkzeugID := r.FormValue("werkzeug_id")
|
|
|
|
switch entscheidungTyp {
|
|
case "genehmigt", "genehmigt_mit_auflagen", "abgelehnt", "rueckfrage":
|
|
default:
|
|
http.Error(w, "ungültige Entscheidung", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if vorschlagFuer(bewertung) != entscheidungTyp && begruendung == "" {
|
|
http.Error(w, "Begründung erforderlich, wenn die Entscheidung vom Vorschlag abweicht", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
in := store.EntscheidungInput{
|
|
AntragID: antrag.ID, BewertungID: bewertung.ID, EntscheiderUserID: user.ID,
|
|
Entscheidung: entscheidungTyp, Begruendung: begruendung,
|
|
}
|
|
if entscheidungTyp == "genehmigt" || entscheidungTyp == "genehmigt_mit_auflagen" {
|
|
zulaessig := false
|
|
for _, id := range bewertung.ZulaessigeWerkzeuge {
|
|
if id == werkzeugID {
|
|
zulaessig = true
|
|
break
|
|
}
|
|
}
|
|
if werkzeugID == "" || !zulaessig {
|
|
http.Error(w, "bei Genehmigung muss ein zulässiges Werkzeug gewählt werden", http.StatusBadRequest)
|
|
return
|
|
}
|
|
werkzeug, err := s.store.GetWerkzeug(r.Context(), werkzeugID)
|
|
if err != nil {
|
|
http.Error(w, "Werkzeug nicht gefunden", http.StatusNotFound)
|
|
return
|
|
}
|
|
in.WerkzeugID = &werkzeugID
|
|
in.WerkzeugSnapshot = &werkzeug
|
|
gueltigBis := gueltigkeitFuer(bewertung.Einstufung)
|
|
in.GueltigBis = &gueltigBis
|
|
}
|
|
|
|
if _, err := s.store.CreateEntscheidung(r.Context(), in); err != nil {
|
|
http.Error(w, "Entscheidung konnte nicht gespeichert werden: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if err := s.store.SetAntragStatus(r.Context(), antrag.ID, "entschieden"); err != nil {
|
|
http.Error(w, "Antrag konnte nicht aktualisiert werden: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if _, err := s.store.CreateAuditEntry(r.Context(), user.ID, "antrag_entschieden", "antrag", antrag.ID, entscheidungTyp); err != nil {
|
|
http.Error(w, "Audit-Log konnte nicht geschrieben werden: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
http.Redirect(w, r, "/faelle/"+antrag.ID, http.StatusSeeOther)
|
|
}
|