Löst einen Blocker im Kernablauf: eine frisch registrierte Firma hat nur einen admin-Login und konnte damit bislang keinen einzigen eingereichten Antrag sehen oder entscheiden, weil requireFachebene nur verantwortlicher/pruefer durchließ. Die Spezifikation will admin+ KI-Verantwortlicher ohnehin auf derselben Person (siehe CLAUDE.md, Offene Punkte) — statt eines Datenmodell-Umbaus (roles-Array oder zwei app_user-Zeilen pro Person) bekommt admin jetzt pragmatisch dieselben Fachebene-Rechte wie verantwortlicher (hatEntscheidungsrecht in fachebene_handlers.go). pruefer bleibt unverändert nur lesend. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
274 lines
9.4 KiB
Go
274 lines
9.4 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
|
|
}
|
|
|
|
// hatEntscheidungsrecht — "admin" ist bewusst mit eingeschlossen, siehe
|
|
// requireFachebene: die Spezifikation will admin+verantwortlicher auf
|
|
// derselben Person, ohne dass app_user.role zwei Werte tragen kann.
|
|
func hatEntscheidungsrecht(role string) bool {
|
|
return role == "verantwortlicher" || role == "admin"
|
|
}
|
|
|
|
// 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 = hatEntscheidungsrecht(currentUser(r).Role) && 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 (oder admin, siehe hatEntscheidungsrecht). 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 !hatEntscheidungsrecht(user.Role) {
|
|
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
|
|
}
|
|
|
|
istGenehmigung := entscheidungTyp == "genehmigt" || entscheidungTyp == "genehmigt_mit_auflagen"
|
|
in := store.EntscheidungInput{
|
|
AntragID: antrag.ID, BewertungID: bewertung.ID, EntscheiderUserID: user.ID,
|
|
Entscheidung: entscheidungTyp, Begruendung: begruendung,
|
|
}
|
|
var werkzeugName string
|
|
if istGenehmigung {
|
|
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
|
|
}
|
|
werkzeugName = werkzeug.Name
|
|
in.WerkzeugID = &werkzeugID
|
|
in.WerkzeugSnapshot = &werkzeug
|
|
gueltigBis := gueltigkeitFuer(bewertung.Einstufung)
|
|
in.GueltigBis = &gueltigBis
|
|
}
|
|
|
|
entscheidung, err := s.store.CreateEntscheidung(r.Context(), in)
|
|
if 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
|
|
}
|
|
|
|
// Jede Genehmigung erzeugt automatisch einen Registereintrag (siehe
|
|
// CLAUDE.md) — bei "abgelehnt"/"rueckfrage" gibt es nichts
|
|
// Genehmigtes, das im Register stehen müsste.
|
|
if istGenehmigung {
|
|
abteilungName := ""
|
|
if antrag.AbteilungID != nil {
|
|
if abt, err := s.store.GetAbteilung(r.Context(), *antrag.AbteilungID); err == nil {
|
|
abteilungName = abt.Name
|
|
}
|
|
}
|
|
var auflagen []string
|
|
for _, a := range bewertung.Anforderungen {
|
|
auflagen = append(auflagen, a.Beschreibung)
|
|
}
|
|
if _, err := s.store.CreateRegistereintrag(r.Context(), store.RegistereintragInput{
|
|
AccountID: user.AccountID, AntragID: antrag.ID, EntscheidungID: entscheidung.ID,
|
|
Zweck: antrag.Titel, Abteilung: abteilungName, Werkzeug: werkzeugName,
|
|
Datenklasse: bewertung.Datenklasse, Einstufung: bewertung.Einstufung, Auflagen: auflagen,
|
|
Verantwortlicher: user.Email, EntschiedenAm: entscheidung.CreatedAt, GueltigBis: entscheidung.GueltigBis,
|
|
}); err != nil {
|
|
http.Error(w, "Registereintrag konnte nicht angelegt werden: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
|
|
http.Redirect(w, r, "/faelle/"+antrag.ID, http.StatusSeeOther)
|
|
}
|