Files
deklarix/internal/web/antrag_handlers.go
noroot e968cf9761 feat: Ergebnisdarstellung mit Herleitung (Schritt 4 der Baureihenfolge)
POST /antraege berechnet und speichert direkt beim Einreichen eine
append-only bewertung (Datenklasse, KI-VO-Einstufung, Anforderungen,
zulässige/ausgeschlossene Werkzeuge), GET /antraege/{id} zeigt sie
inklusive Herleitung und dem Pflicht-Hinweis, dass das System nicht
entscheidet, sondern nur vorbereitet.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 10:22:24 +02:00

356 lines
13 KiB
Go

package web
import (
"context"
"encoding/json"
"net/http"
"github.com/netcell-it/deklarix/internal/rules"
"github.com/netcell-it/deklarix/internal/store"
)
// antwortenB/C/D-Feldnamen sind bewusst identisch zu den Fakten-
// Schlüsseln in rules/datenklasse.yaml (b1..b7) und
// rules/kivo_einstufung.yaml (c1..c5, c2_folge, c3_art) — Schritt 3
// (Ableitungen) liest dieselben Schlüssel direkt aus antrag.antworten,
// ohne sie erst umbenennen zu müssen.
var datenfragen = []struct {
Key string
Label string
}{
{"b1", "Namen, E-Mail-Adressen oder andere Angaben zu Personen"},
{"b2", "Angaben zu Gesundheit, Religion, Herkunft, Gewerkschaft, Sexualleben"},
{"b3", "Daten von Patienten, Mandanten oder Versicherten"},
{"b4", "Bewerbungs-, Personal- oder Leistungsdaten von Beschäftigten"},
{"b5", "Daten, die uns ein Kunde anvertraut hat"},
{"b6", "Konstruktions-, Kalkulations- oder Vertragsunterlagen"},
{"b7", "Nur allgemein zugängliche oder selbst erfundene Inhalte"},
}
type antragFormData struct {
Title string
Nav navData
Abteilungen []store.Abteilung
Datenfragen []struct {
Key string
Label string
}
Error string
}
// handleAntragNewForm zeigt den Fragebogen (Ebene 2, "Antrag stellen").
// Bewusst eine einzelne Seite statt eines mehrseitigen Assistenten —
// jede zusätzliche Seite kostet Zeit, und "dauert der Antrag länger als
// fünf Minuten, wird er umgangen" (siehe CLAUDE.md). Adaptive
// Folgefragen (C2/C3) werden rein clientseitig per CSS ein-/ausgeblendet,
// ohne zusätzlichen Server-Roundtrip.
func (s *Server) handleAntragNewForm(w http.ResponseWriter, r *http.Request) {
if currentUser(r).Role == "betreiber" {
http.Redirect(w, r, "/betreiber", http.StatusSeeOther)
return
}
abteilungen, err := s.store.ListAbteilungenForAccount(r.Context(), currentUser(r).AccountID)
if err != nil {
http.Error(w, "Abteilungen konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
return
}
data := antragFormData{Title: "Antrag stellen", Nav: navFor(r), Abteilungen: abteilungen, Datenfragen: datenfragen}
if err := s.templates.ExecuteTemplate(w, "antrag-neu", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}
// antworten ist die JSON-Struktur, die in antrag.antworten gespeichert
// wird — ein flaches Objekt aus den Fragebogen-Fakten-Schlüsseln.
// "Unsicher" wird für B unverändert als "unsicher" gespeichert, NICHT
// zu "ja" normalisiert: diese Normalisierung ("Unsicher zählt wie Ja")
// ist eine Auswertungsregel (Schritt 3), keine Speicherregel — was der
// Mitarbeiter tatsächlich geantwortet hat, bleibt im Antrag unverändert
// erhalten.
func antwortenFromForm(r *http.Request) []byte {
m := map[string]any{}
for _, f := range datenfragen {
v := r.FormValue(f.Key)
if v == "" {
v = "nein"
}
m[f.Key] = v
}
m["c1"] = r.FormValue("c1") == "ja"
m["c2"] = r.FormValue("c2") == "ja"
m["c2_folge"] = r.FormValue("c2_folge")
m["c3"] = r.FormValue("c3") == "ja"
m["c3_art"] = r.FormValue("c3_art")
m["c4"] = r.FormValue("c4") == "ja"
m["c5"] = r.FormValue("c5") == "ja"
m["d_werkzeug_freitext"] = r.FormValue("d_werkzeug_freitext")
m["d_zugang"] = r.FormValue("d_zugang")
m["d_geraet"] = r.FormValue("d_geraet")
data, _ := json.Marshal(m) // map[string]any mit nur primitiven Werten kann nicht scheitern
return data
}
// handleAntragCreate legt einen Antrag an und reicht ihn direkt ein
// (entwurf -> eingereicht in einem Schritt) — der Fragebogen ist eine
// einzelne Seite, ein Zwischenspeichern als Entwurf ist für v1 nicht
// vorgesehen.
func (s *Server) handleAntragCreate(w http.ResponseWriter, r *http.Request) {
if currentUser(r).Role == "betreiber" {
http.Redirect(w, r, "/betreiber", http.StatusSeeOther)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
return
}
titel := r.FormValue("titel")
beschreibung := r.FormValue("beschreibung")
ergebnis := r.FormValue("ergebnis")
haeufigkeit := r.FormValue("haeufigkeit")
if titel == "" || beschreibung == "" || ergebnis == "" || haeufigkeit == "" {
http.Error(w, "Titel, Beschreibung, Ergebnis und Häufigkeit sind Pflichtfelder", http.StatusBadRequest)
return
}
var abteilungID *string
if v := r.FormValue("abteilung_id"); v != "" {
abteilungID = &v
}
ctx := r.Context()
user := currentUser(r)
antworten := antwortenFromForm(r)
antrag, err := s.store.CreateAntrag(ctx, user.AccountID, user.ID, abteilungID, titel)
if err != nil {
http.Error(w, "Antrag konnte nicht angelegt werden: "+err.Error(), http.StatusInternalServerError)
return
}
if _, err := s.store.UpdateAntragFelder(ctx, antrag.ID, titel, beschreibung, ergebnis, haeufigkeit, antworten); err != nil {
http.Error(w, "Antrag konnte nicht gespeichert werden: "+err.Error(), http.StatusInternalServerError)
return
}
if err := s.store.SetAntragStatus(ctx, antrag.ID, "eingereicht"); err != nil {
http.Error(w, "Antrag konnte nicht eingereicht werden: "+err.Error(), http.StatusInternalServerError)
return
}
if err := s.bewerteAntrag(ctx, antrag.ID, user.AccountID, antworten); err != nil {
http.Error(w, "Bewertung konnte nicht berechnet werden: "+err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/antraege/"+antrag.ID, http.StatusSeeOther)
}
// bewerteAntrag berechnet die Bewertung für einen eingereichten Antrag
// (Schritt 3: Ableitungen und harte Filter) und speichert sie. Bei
// "verboten" erfolgt die K.-o.-Prüfung — keine Werkzeugsuche, siehe
// CLAUDE.md, Bewertungslogik.
func (s *Server) bewerteAntrag(ctx context.Context, antragID, accountID string, antwortenJSON []byte) error {
antworten, err := rules.ParseAntworten(antwortenJSON)
if err != nil {
return err
}
datenklasse := rules.EvaluateDatenklasse(s.regelwerk.Datenklasse, antworten)
einstufung, err := rules.EvaluateEinstufung(s.regelwerk.Einstufung, antworten)
if err != nil {
return err
}
verboten := rules.IstVerboten(einstufung.ID)
in := store.BewertungInput{
AntragID: antragID, Datenklasse: datenklasse.ID, DatenklasseHerleitung: datenklasse.Herleitung,
Einstufung: einstufung.ID, EinstufungHerleitung: einstufungHerleitungText(einstufung), Verboten: verboten,
KatalogVersion: "n/a (K.-o.-Prüfung, keine Werkzeugsuche)",
}
if !verboten {
anforderungen := rules.DeriveAnforderungen(s.regelwerk.Anforderungen, datenklasse.ID, einstufung.ID)
anforderungIDs := make([]string, len(anforderungen))
for i, a := range anforderungen {
in.Anforderungen = append(in.Anforderungen, store.BewertungAnforderung{ID: a.ID, Beschreibung: a.Beschreibung, Herleitung: a.Herleitung})
anforderungIDs[i] = a.ID
}
werkzeuge, err := s.store.ListWerkzeugeForAccount(ctx, accountID)
if err != nil {
return err
}
kandidaten := make([]rules.WerkzeugKandidat, len(werkzeuge))
for i, w := range werkzeuge {
kandidaten[i] = rules.WerkzeugKandidat{ID: w.ID, Eigenschaften: rules.WerkzeugEigenschaften{
AVVVerfuegbar: w.AVVVerfuegbar, Verarbeitungsort: w.Verarbeitungsort,
TrainingOptOut: w.TrainingOptOut, TrainingStandard: w.TrainingStandard, AufbewahrungTage: w.AufbewahrungTage,
}}
}
zulaessig, ausgeschlossen := rules.FilterWerkzeuge(kandidaten, anforderungIDs)
in.ZulaessigeWerkzeuge = zulaessig
for _, a := range ausgeschlossen {
in.AusgeschlosseneWerkzeuge = append(in.AusgeschlosseneWerkzeuge, store.BewertungAusschluss{WerkzeugID: a.WerkzeugID, NichtErfuellt: a.NichtErfuellt})
}
katalogVersion, err := s.store.CurrentKatalogVersion(ctx)
if err != nil {
return err
}
in.KatalogVersion = katalogVersion
}
in.RegelwerkVersion = rules.RegelwerkVersion(s.regelwerk.Datenklasse, s.regelwerk.Einstufung, s.regelwerk.Anforderungen)
_, err = s.store.CreateBewertung(ctx, in)
return err
}
// einstufungHerleitungText fügt die Quelle (z. B. "Art. 5 KI-VO") mit
// an, falls das Regelwerk eine nennt.
func einstufungHerleitungText(e rules.EinstufungErgebnis) string {
if e.Quelle == "" {
return e.Herleitung
}
return e.Herleitung + " (" + e.Quelle + ")"
}
type antragListItem struct {
ID string
Titel string
Status string
Haeufigkeit string
CreatedAt string
}
type antragListData struct {
Title string
Nav navData
Antraege []antragListItem
}
// handleAntragList zeigt die eigenen Anträge (Ebene 2 — "Eigene
// Anträge einsehen, Status verfolgen"), nicht die des ganzen Mandanten.
func (s *Server) handleAntragList(w http.ResponseWriter, r *http.Request) {
if currentUser(r).Role == "betreiber" {
http.Redirect(w, r, "/betreiber", http.StatusSeeOther)
return
}
list, err := s.store.ListAntraegeForUser(r.Context(), currentUser(r).ID)
if err != nil {
http.Error(w, "Anträge konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
return
}
data := antragListData{Title: "Meine Anträge", Nav: navFor(r)}
for _, a := range list {
data.Antraege = append(data.Antraege, antragListItem{
ID: a.ID, Titel: a.Titel, Status: a.Status, Haeufigkeit: a.Haeufigkeit,
CreatedAt: a.CreatedAt.Format("02.01.2006 15:04"),
})
}
if err := s.templates.ExecuteTemplate(w, "antraege-liste", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}
type anforderungView struct {
ID string
Beschreibung string
Herleitung string
}
type werkzeugAusschlussView struct {
Name string
NichtErfuellt []string
}
type bewertungView struct {
Datenklasse string
DatenklasseHerleitung string
Einstufung string
EinstufungHerleitung string
Verboten bool
Anforderungen []anforderungView
ZulaessigeWerkzeuge []string // Namen, nicht IDs
AusgeschlosseneWerkzeuge []werkzeugAusschlussView
RegelwerkVersion string
KatalogVersion string
CreatedAt string
}
type antragDetailData struct {
Title string
Nav navData
ID string
Titel string
Beschreibung string
Ergebnis string
Haeufigkeit string
Status string
CreatedAt string
Antworten map[string]any
Bewertung *bewertungView
}
// handleAntragDetail zeigt einen einzelnen Antrag mit allen Antworten.
// Ein Antrag eines fremden Nutzers wird wie ein nicht existierender
// behandelt (404), nie mit einer expliziten 403 bestätigt — dasselbe
// Muster wie schon beim alten Produkt für fremde Mandanten.
func (s *Server) handleAntragDetail(w http.ResponseWriter, r *http.Request) {
antrag, err := s.store.GetAntrag(r.Context(), r.PathValue("id"))
if err != nil || antrag.ErstellerUserID != currentUser(r).ID {
http.Error(w, "Antrag nicht gefunden", http.StatusNotFound)
return
}
var antworten map[string]any
if len(antrag.Antworten) > 0 {
_ = json.Unmarshal(antrag.Antworten, &antworten)
}
data := antragDetailData{
Title: "Antrag", 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"), Antworten: antworten,
}
bewertung, err := s.store.GetLatestBewertungForAntrag(r.Context(), antrag.ID)
if err == nil {
data.Bewertung = s.toBewertungView(r.Context(), bewertung)
}
if err := s.templates.ExecuteTemplate(w, "antrag-detail", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}
// toBewertungView löst die in der Bewertung nur als ID gespeicherten
// Werkzeuge zu Anzeigenamen auf. Ein Werkzeug, das seit der Berechnung
// gelöscht wurde, wird mit seiner ID statt eines Namens angezeigt,
// statt den ganzen Antrag unanzeigbar zu machen.
func (s *Server) toBewertungView(ctx context.Context, b store.Bewertung) *bewertungView {
v := &bewertungView{
Datenklasse: b.Datenklasse, DatenklasseHerleitung: b.DatenklasseHerleitung,
Einstufung: b.Einstufung, EinstufungHerleitung: b.EinstufungHerleitung, Verboten: b.Verboten,
RegelwerkVersion: b.RegelwerkVersion, KatalogVersion: b.KatalogVersion,
CreatedAt: b.CreatedAt.Format("02.01.2006 15:04"),
}
for _, a := range b.Anforderungen {
v.Anforderungen = append(v.Anforderungen, anforderungView{ID: a.ID, Beschreibung: a.Beschreibung, Herleitung: a.Herleitung})
}
for _, id := range b.ZulaessigeWerkzeuge {
v.ZulaessigeWerkzeuge = append(v.ZulaessigeWerkzeuge, s.werkzeugName(ctx, id))
}
for _, a := range b.AusgeschlosseneWerkzeuge {
v.AusgeschlosseneWerkzeuge = append(v.AusgeschlosseneWerkzeuge, werkzeugAusschlussView{
Name: s.werkzeugName(ctx, a.WerkzeugID), NichtErfuellt: a.NichtErfuellt,
})
}
return v
}
func (s *Server) werkzeugName(ctx context.Context, id string) string {
w, err := s.store.GetWerkzeug(ctx, id)
if err != nil {
return id
}
return w.Name
}