feat: Registereintrag und Export (Schritt 6 der Baureihenfolge)
Jede Genehmigung (genehmigt/genehmigt_mit_auflagen) erzeugt jetzt automatisch einen append-only registereintrag mit Zweck, Abteilung, Werkzeug, Datenklasse, Einstufung, Auflagen, Verantwortliche/r und Gültigkeit. Ebene 3 (verantwortlicher/pruefer) kann das Register unter GET /registereintraege einsehen und als CSV oder PDF exportieren. Der PDF-Export nutzt das bereits vorhandene, bisher ungenutzte go-pdf/fpdf (keine neue Abhängigkeit) mit UnicodeTranslatorFromDescriptor für deutsche Umlaute. Abgelehnte Entscheidungen und Rückfragen erzeugen bewusst keinen Registereintrag. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
40
CLAUDE.md
40
CLAUDE.md
@@ -220,7 +220,7 @@ mehrfacher Neubewertung eines Antrags erhalten bleibt.
|
||||
|
||||
---
|
||||
|
||||
## Entscheidung, Register, Wiedervorlage (Schritt 5 erledigt, Schritt 6-7 geplant)
|
||||
## Entscheidung, Register, Wiedervorlage (Schritt 5+6 erledigt, Schritt 7 geplant)
|
||||
|
||||
**Schritt 5 (Entscheidung, Snapshot, Audit-Log) ist umgesetzt.** Ebene 3
|
||||
(Fachebene) hat einen Posteingang (`GET /faelle`, alle offenen —
|
||||
@@ -255,10 +255,16 @@ Genehmigungen erhalten ein Ablaufdatum (`gueltig_bis`: 12 Monate, bei
|
||||
die Benachrichtigung, wenn sich im Katalog eine Eigenschaft ändert, auf
|
||||
der eine aktive Genehmigung beruht (Wiedervorlage).
|
||||
|
||||
**Noch nicht gebaut (Schritt 6):** ein `registereintrag` wird bislang
|
||||
NICHT automatisch aus einer Genehmigung erzeugt, und es gibt keinen
|
||||
PDF-/CSV-Export. Geplant: Zweck, Abteilung, Werkzeug, Datenklasse,
|
||||
Einstufung, Auflagen, Verantwortliche/r, Datum, Gültigkeit.
|
||||
**Schritt 6 (Registereintrag, Export) ist umgesetzt.** Jede Genehmigung
|
||||
erzeugt automatisch einen `registereintrag` (append-only, Migration
|
||||
0011): Zweck (`antrag.titel`), Abteilung, Werkzeug, Datenklasse,
|
||||
Einstufung, Auflagen (die Anforderungen der Bewertung), Verantwortliche/r,
|
||||
Entscheidungsdatum, Gültigkeit — siehe `internal/web/register_handlers.go`.
|
||||
`GET /registereintraege` (Ebene 3, `verantwortlicher`/`pruefer`) zeigt
|
||||
sie, `GET /registereintraege/export.{csv,pdf}` exportiert. Abgelehnte
|
||||
Entscheidungen und Rückfragen erzeugen bewusst KEINEN Registereintrag —
|
||||
das Register ist eine Genehmigungsliste, kein vollständiges Antragslog
|
||||
(das leistet bereits `GET /faelle`).
|
||||
|
||||
---
|
||||
|
||||
@@ -346,12 +352,13 @@ nicht anzulegen. Noch nicht gebaut (kein Abo-System).
|
||||
- `entscheidung` — die Entscheidung eines/einer Verantwortlichen über
|
||||
einen Antrag, mit Snapshot des gewählten Werkzeugs (`werkzeug_snapshot`,
|
||||
JSONB) und Ablaufdatum (`gueltig_bis`) bei Genehmigung (Schritt 5).
|
||||
- `registereintrag` — **noch nicht gebaut**, geplant für Schritt 6.
|
||||
- `registereintrag` — automatisch bei jeder Genehmigung erzeugt
|
||||
(Schritt 6), denormalisierter Nachweis (eigene Textspalten statt
|
||||
Joins über bewertung/entscheidung/werkzeug).
|
||||
|
||||
**Append-only:** kein UPDATE auf `audit_log` (Trigger `forbid_update_delete`,
|
||||
wiederverwendet aus dem alten Produkt), ebenso `bewertung` und
|
||||
`entscheidung`. `registereintrag` wird bei seiner Einführung ebenfalls
|
||||
append-only.
|
||||
wiederverwendet aus dem alten Produkt), ebenso `bewertung`,
|
||||
`entscheidung` und `registereintrag`.
|
||||
|
||||
---
|
||||
|
||||
@@ -391,7 +398,20 @@ append-only.
|
||||
vollständigen Werkzeugdatensatz ein und braucht ein zulässiges
|
||||
Werkzeug aus der Bewertung, `entscheidung` ist append-only, jede
|
||||
Entscheidung erzeugt einen `audit_log`-Eintrag.)
|
||||
6. Registereintrag und Export
|
||||
6. ~~Registereintrag und Export~~ — **erledigt** (`internal/web/register_handlers.go`:
|
||||
`GET /registereintraege` zeigt alle Registereinträge des Mandanten,
|
||||
`GET /registereintraege/export.{csv,pdf}` exportiert sie. Jede
|
||||
Genehmigung — `genehmigt` oder `genehmigt_mit_auflagen` — erzeugt in
|
||||
`handleFallEntscheiden` automatisch einen `registereintrag`
|
||||
[append-only, Migration 0011]: Zweck [`antrag.titel`], Abteilung,
|
||||
Werkzeug, Datenklasse, Einstufung, Auflagen [die Anforderungen der
|
||||
Bewertung], Verantwortliche/r, Entscheidungsdatum, Gültigkeit —
|
||||
bewusst denormalisiert [eigene Textspalten statt Joins], damit ein
|
||||
Eintrag nicht von einem späteren Katalog-/Abteilungs-Wandel abhängt.
|
||||
Abgelehnte Entscheidungen und Rückfragen erzeugen keinen Eintrag.
|
||||
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
|
||||
|
||||
Nicht bauen (v1): automatische Genehmigung ohne Mensch, Erkennung
|
||||
|
||||
1
internal/store/migrations/0011_registereintrag.down.sql
Normal file
1
internal/store/migrations/0011_registereintrag.down.sql
Normal file
@@ -0,0 +1 @@
|
||||
DROP TABLE registereintrag;
|
||||
27
internal/store/migrations/0011_registereintrag.up.sql
Normal file
27
internal/store/migrations/0011_registereintrag.up.sql
Normal file
@@ -0,0 +1,27 @@
|
||||
-- Registereintrag: wird automatisch bei jeder Genehmigung erzeugt
|
||||
-- (genehmigt / genehmigt_mit_auflagen), siehe CLAUDE.md, Entscheidung/
|
||||
-- Register/Wiedervorlage. Bewusst denormalisiert (eigene Textspalten
|
||||
-- statt Joins über bewertung/entscheidung/werkzeug) — ein Registereintrag
|
||||
-- ist ein für sich lesbarer, exportierbarer Nachweis, der nicht von
|
||||
-- einem späteren Katalog- oder Abteilungs-Wandel abhängen darf.
|
||||
-- Append-only wie bewertung/entscheidung.
|
||||
CREATE TABLE registereintrag (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
account_id UUID NOT NULL REFERENCES account (id),
|
||||
antrag_id UUID NOT NULL REFERENCES antrag (id),
|
||||
entscheidung_id UUID NOT NULL REFERENCES entscheidung (id),
|
||||
zweck TEXT NOT NULL,
|
||||
abteilung TEXT NOT NULL DEFAULT '',
|
||||
werkzeug TEXT NOT NULL,
|
||||
datenklasse TEXT NOT NULL,
|
||||
einstufung TEXT NOT NULL,
|
||||
auflagen TEXT[] NOT NULL DEFAULT '{}',
|
||||
verantwortlicher TEXT NOT NULL,
|
||||
entschieden_am TIMESTAMPTZ NOT NULL,
|
||||
gueltig_bis TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TRIGGER registereintrag_append_only
|
||||
BEFORE UPDATE OR DELETE ON registereintrag
|
||||
FOR EACH ROW EXECUTE FUNCTION forbid_update_delete();
|
||||
106
internal/store/registereintrag.go
Normal file
106
internal/store/registereintrag.go
Normal file
@@ -0,0 +1,106 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Registereintrag wird automatisch bei jeder Genehmigung erzeugt
|
||||
// (siehe CLAUDE.md, Entscheidung/Register/Wiedervorlage). Append-only
|
||||
// — siehe Migration. Bewusst denormalisiert: eigene Textspalten statt
|
||||
// Joins über bewertung/entscheidung/werkzeug, damit ein einmal
|
||||
// erzeugter Eintrag nicht von einem späteren Katalog- oder Abteilungs-
|
||||
// Wandel abhängt.
|
||||
type Registereintrag struct {
|
||||
ID string
|
||||
AccountID string
|
||||
AntragID string
|
||||
EntscheidungID string
|
||||
Zweck string
|
||||
Abteilung string
|
||||
Werkzeug string
|
||||
Datenklasse string
|
||||
Einstufung string
|
||||
Auflagen []string
|
||||
Verantwortlicher string
|
||||
EntschiedenAm time.Time
|
||||
GueltigBis *time.Time
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
const registereintragColumns = `id, account_id, antrag_id, entscheidung_id, zweck, abteilung, werkzeug,
|
||||
datenklasse, einstufung, auflagen, verantwortlicher, entschieden_am, gueltig_bis, created_at`
|
||||
|
||||
func scanRegistereintrag(row interface {
|
||||
Scan(dest ...any) error
|
||||
}) (Registereintrag, error) {
|
||||
var e Registereintrag
|
||||
err := row.Scan(
|
||||
&e.ID, &e.AccountID, &e.AntragID, &e.EntscheidungID, &e.Zweck, &e.Abteilung, &e.Werkzeug,
|
||||
&e.Datenklasse, &e.Einstufung, &e.Auflagen, &e.Verantwortlicher, &e.EntschiedenAm, &e.GueltigBis, &e.CreatedAt,
|
||||
)
|
||||
return e, err
|
||||
}
|
||||
|
||||
// RegistereintragInput bündelt die Felder eines neuen Registereintrags.
|
||||
type RegistereintragInput struct {
|
||||
AccountID string
|
||||
AntragID string
|
||||
EntscheidungID string
|
||||
Zweck string
|
||||
Abteilung string
|
||||
Werkzeug string
|
||||
Datenklasse string
|
||||
Einstufung string
|
||||
Auflagen []string
|
||||
Verantwortlicher string
|
||||
EntschiedenAm time.Time
|
||||
GueltigBis *time.Time
|
||||
}
|
||||
|
||||
// CreateRegistereintrag speichert einen Registereintrag.
|
||||
func (s *Store) CreateRegistereintrag(ctx context.Context, in RegistereintragInput) (Registereintrag, error) {
|
||||
if in.Auflagen == nil {
|
||||
in.Auflagen = []string{}
|
||||
}
|
||||
row := s.Pool.QueryRow(ctx, `
|
||||
INSERT INTO registereintrag (
|
||||
account_id, antrag_id, entscheidung_id, zweck, abteilung, werkzeug,
|
||||
datenklasse, einstufung, auflagen, verantwortlicher, entschieden_am, gueltig_bis
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
RETURNING `+registereintragColumns,
|
||||
in.AccountID, in.AntragID, in.EntscheidungID, in.Zweck, in.Abteilung, in.Werkzeug,
|
||||
in.Datenklasse, in.Einstufung, in.Auflagen, in.Verantwortlicher, in.EntschiedenAm, in.GueltigBis,
|
||||
)
|
||||
e, err := scanRegistereintrag(row)
|
||||
if err != nil {
|
||||
return Registereintrag{}, fmt.Errorf("store: create registereintrag: %w", err)
|
||||
}
|
||||
return e, nil
|
||||
}
|
||||
|
||||
// ListRegistereintraegeForAccount liefert alle Registereinträge eines
|
||||
// Mandanten, neueste zuerst.
|
||||
func (s *Store) ListRegistereintraegeForAccount(ctx context.Context, accountID string) ([]Registereintrag, error) {
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT `+registereintragColumns+` FROM registereintrag WHERE account_id = $1 ORDER BY entschieden_am DESC
|
||||
`, accountID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list registereintraege for account: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []Registereintrag
|
||||
for rows.Next() {
|
||||
e, err := scanRegistereintrag(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: scan registereintrag: %w", err)
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("store: list registereintraege for account: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
100
internal/store/registereintrag_test.go
Normal file
100
internal/store/registereintrag_test.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package store_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/store"
|
||||
)
|
||||
|
||||
func testRegistereintragInput(accountID, antragID, entscheidungID string) store.RegistereintragInput {
|
||||
gueltigBis := time.Now().Add(365 * 24 * time.Hour).Truncate(time.Millisecond)
|
||||
return store.RegistereintragInput{
|
||||
AccountID: accountID, AntragID: antragID, EntscheidungID: entscheidungID,
|
||||
Zweck: "Angebotstexte generieren", Abteilung: "Vertrieb", Werkzeug: "ChatGPT Enterprise",
|
||||
Datenklasse: "personenbezogen", Einstufung: "minimal",
|
||||
Auflagen: []string{"AVV erforderlich", "kein Training auf Eingaben"},
|
||||
Verantwortlicher: "verantwortlicher@example.com", EntschiedenAm: time.Now().Truncate(time.Millisecond),
|
||||
GueltigBis: &gueltigBis,
|
||||
}
|
||||
}
|
||||
|
||||
func testEntscheidungID(t *testing.T, s *store.Store, antragID, bewertungID, userID string) string {
|
||||
t.Helper()
|
||||
e, err := s.CreateEntscheidung(context.Background(), store.EntscheidungInput{
|
||||
AntragID: antragID, BewertungID: bewertungID, EntscheiderUserID: userID, Entscheidung: "genehmigt",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateEntscheidung: %v", err)
|
||||
}
|
||||
return e.ID
|
||||
}
|
||||
|
||||
func TestRegistereintragCreateAndList(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
userID := testUserID(t, s, accID)
|
||||
antrag, bewertung := testAntragMitBewertung(t, s, accID, userID)
|
||||
entscheidungID := testEntscheidungID(t, s, antrag.ID, bewertung.ID, userID)
|
||||
|
||||
e, err := s.CreateRegistereintrag(ctx, testRegistereintragInput(accID, antrag.ID, entscheidungID))
|
||||
if err != nil {
|
||||
t.Fatalf("CreateRegistereintrag: %v", err)
|
||||
}
|
||||
if e.Werkzeug != "ChatGPT Enterprise" || len(e.Auflagen) != 2 {
|
||||
t.Fatalf("CreateRegistereintrag = %+v, unerwartete Werte", e)
|
||||
}
|
||||
|
||||
list, err := s.ListRegistereintraegeForAccount(ctx, accID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListRegistereintraegeForAccount: %v", err)
|
||||
}
|
||||
if len(list) != 1 || list[0].ID != e.ID {
|
||||
t.Fatalf("ListRegistereintraegeForAccount = %+v, want exactly one entry", list)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistereintragIsolatesTenants(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accA := testAccountID(t, s)
|
||||
userA := testUserID(t, s, accA)
|
||||
antragA, bewertungA := testAntragMitBewertung(t, s, accA, userA)
|
||||
entscheidungA := testEntscheidungID(t, s, antragA.ID, bewertungA.ID, userA)
|
||||
if _, err := s.CreateRegistereintrag(ctx, testRegistereintragInput(accA, antragA.ID, entscheidungA)); err != nil {
|
||||
t.Fatalf("CreateRegistereintrag (A): %v", err)
|
||||
}
|
||||
|
||||
accB := testAccountID(t, s)
|
||||
list, err := s.ListRegistereintraegeForAccount(ctx, accB)
|
||||
if err != nil {
|
||||
t.Fatalf("ListRegistereintraegeForAccount (B): %v", err)
|
||||
}
|
||||
if len(list) != 0 {
|
||||
t.Fatalf("expected no registereintraege for a different tenant, got %+v", list)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistereintragIsAppendOnly(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
userID := testUserID(t, s, accID)
|
||||
antrag, bewertung := testAntragMitBewertung(t, s, accID, userID)
|
||||
entscheidungID := testEntscheidungID(t, s, antrag.ID, bewertung.ID, userID)
|
||||
e, err := s.CreateRegistereintrag(ctx, testRegistereintragInput(accID, antrag.ID, entscheidungID))
|
||||
if err != nil {
|
||||
t.Fatalf("CreateRegistereintrag: %v", err)
|
||||
}
|
||||
|
||||
_, err = s.Pool.Exec(ctx, `UPDATE registereintrag SET werkzeug = 'geaendert' WHERE id = $1`, e.ID)
|
||||
if err == nil {
|
||||
t.Fatal("expected UPDATE on registereintrag to be rejected by the append-only trigger")
|
||||
}
|
||||
_, err = s.Pool.Exec(ctx, `DELETE FROM registereintrag WHERE id = $1`, e.ID)
|
||||
if err == nil {
|
||||
t.Fatal("expected DELETE on registereintrag to be rejected by the append-only trigger")
|
||||
}
|
||||
}
|
||||
@@ -192,11 +192,13 @@ func (s *Server) handleFallEntscheiden(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
istGenehmigung := entscheidungTyp == "genehmigt" || entscheidungTyp == "genehmigt_mit_auflagen"
|
||||
in := store.EntscheidungInput{
|
||||
AntragID: antrag.ID, BewertungID: bewertung.ID, EntscheiderUserID: user.ID,
|
||||
Entscheidung: entscheidungTyp, Begruendung: begruendung,
|
||||
}
|
||||
if entscheidungTyp == "genehmigt" || entscheidungTyp == "genehmigt_mit_auflagen" {
|
||||
var werkzeugName string
|
||||
if istGenehmigung {
|
||||
zulaessig := false
|
||||
for _, id := range bewertung.ZulaessigeWerkzeuge {
|
||||
if id == werkzeugID {
|
||||
@@ -213,13 +215,15 @@ func (s *Server) handleFallEntscheiden(w http.ResponseWriter, r *http.Request) {
|
||||
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
|
||||
}
|
||||
|
||||
if _, err := s.store.CreateEntscheidung(r.Context(), in); err != nil {
|
||||
entscheidung, err := s.store.CreateEntscheidung(r.Context(), in)
|
||||
if err != nil {
|
||||
http.Error(w, "Entscheidung konnte nicht gespeichert werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -232,5 +236,30 @@ func (s *Server) handleFallEntscheiden(w http.ResponseWriter, r *http.Request) {
|
||||
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)
|
||||
}
|
||||
|
||||
133
internal/web/register_handlers.go
Normal file
133
internal/web/register_handlers.go
Normal file
@@ -0,0 +1,133 @@
|
||||
// Ebene 3 — Register und Export (Schritt 6 der Baureihenfolge, siehe
|
||||
// CLAUDE.md). Jede Genehmigung erzeugt automatisch einen Registereintrag
|
||||
// (siehe handleFallEntscheiden); diese Seiten zeigen und exportieren
|
||||
// sie. Reuse des bereits vorhandenen (bisher ungenutzten) go-pdf/fpdf-
|
||||
// Dependency statt einer neuen Bibliothek für den PDF-Export.
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/go-pdf/fpdf"
|
||||
)
|
||||
|
||||
type registerEintragView struct {
|
||||
Zweck string
|
||||
Abteilung string
|
||||
Werkzeug string
|
||||
Datenklasse string
|
||||
Einstufung string
|
||||
Auflagen string
|
||||
Verantwortlicher string
|
||||
EntschiedenAm string
|
||||
GueltigBis string
|
||||
}
|
||||
|
||||
type registerListeData struct {
|
||||
Title string
|
||||
Nav navData
|
||||
Eintraege []registerEintragView
|
||||
}
|
||||
|
||||
func (s *Server) registerEintraegeFuerAccount(r *http.Request) ([]registerEintragView, error) {
|
||||
eintraege, err := s.store.ListRegistereintraegeForAccount(r.Context(), currentUser(r).AccountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
views := make([]registerEintragView, len(eintraege))
|
||||
for i, e := range eintraege {
|
||||
gueltigBis := ""
|
||||
if e.GueltigBis != nil {
|
||||
gueltigBis = e.GueltigBis.Format("02.01.2006")
|
||||
}
|
||||
views[i] = registerEintragView{
|
||||
Zweck: e.Zweck, Abteilung: e.Abteilung, Werkzeug: e.Werkzeug,
|
||||
Datenklasse: e.Datenklasse, Einstufung: e.Einstufung, Auflagen: strings.Join(e.Auflagen, "; "),
|
||||
Verantwortlicher: e.Verantwortlicher, EntschiedenAm: e.EntschiedenAm.Format("02.01.2006 15:04"), GueltigBis: gueltigBis,
|
||||
}
|
||||
}
|
||||
return views, nil
|
||||
}
|
||||
|
||||
// handleRegisterListe zeigt das Register (alle Genehmigungen des
|
||||
// Mandanten) — Ebene 3, für verantwortlicher und pruefer gleichermaßen
|
||||
// lesbar.
|
||||
func (s *Server) handleRegisterListe(w http.ResponseWriter, r *http.Request) {
|
||||
views, err := s.registerEintraegeFuerAccount(r)
|
||||
if err != nil {
|
||||
http.Error(w, "Register konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
data := registerListeData{Title: "Register", Nav: navFor(r), Eintraege: views}
|
||||
if err := s.templates.ExecuteTemplate(w, "register-liste", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
var registerCSVHeader = []string{"Zweck", "Abteilung", "Werkzeug", "Datenklasse", "Einstufung", "Auflagen", "Verantwortliche/r", "Entschieden am", "Gültig bis"}
|
||||
|
||||
func registerCSVRow(e registerEintragView) []string {
|
||||
return []string{e.Zweck, e.Abteilung, e.Werkzeug, e.Datenklasse, e.Einstufung, e.Auflagen, e.Verantwortlicher, e.EntschiedenAm, e.GueltigBis}
|
||||
}
|
||||
|
||||
// handleRegisterExportCSV exportiert das Register als CSV.
|
||||
func (s *Server) handleRegisterExportCSV(w http.ResponseWriter, r *http.Request) {
|
||||
views, err := s.registerEintraegeFuerAccount(r)
|
||||
if err != nil {
|
||||
http.Error(w, "Register konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="register.csv"`)
|
||||
cw := csv.NewWriter(w)
|
||||
_ = cw.Write(registerCSVHeader)
|
||||
for _, e := range views {
|
||||
_ = cw.Write(registerCSVRow(e))
|
||||
}
|
||||
cw.Flush()
|
||||
}
|
||||
|
||||
// handleRegisterExportPDF exportiert das Register als PDF — eine
|
||||
// einfache Tabelle, keine Gestaltung über den Design-System-Anspruch
|
||||
// des restlichen Produkts hinaus.
|
||||
func (s *Server) handleRegisterExportPDF(w http.ResponseWriter, r *http.Request) {
|
||||
views, err := s.registerEintraegeFuerAccount(r)
|
||||
if err != nil {
|
||||
http.Error(w, "Register konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
pdf := fpdf.New("L", "mm", "A4", "")
|
||||
// cp1252 deckt deutsche Umlaute/ß ab — die eingebauten Helvetica-
|
||||
// Fonts von fpdf können sonst kein UTF-8 darstellen.
|
||||
tr := pdf.UnicodeTranslatorFromDescriptor("")
|
||||
pdf.AddPage()
|
||||
pdf.SetFont("Helvetica", "B", 14)
|
||||
pdf.CellFormat(0, 10, tr("Register"), "", 1, "L", false, 0, "")
|
||||
pdf.Ln(2)
|
||||
|
||||
colWidths := []float64{45, 25, 35, 30, 30, 55, 40, 30, 25}
|
||||
pdf.SetFont("Helvetica", "B", 8)
|
||||
for i, h := range registerCSVHeader {
|
||||
pdf.CellFormat(colWidths[i], 8, tr(h), "1", 0, "L", false, 0, "")
|
||||
}
|
||||
pdf.Ln(-1)
|
||||
|
||||
pdf.SetFont("Helvetica", "", 8)
|
||||
for _, e := range views {
|
||||
row := registerCSVRow(e)
|
||||
for i, cell := range row {
|
||||
pdf.CellFormat(colWidths[i], 8, tr(cell), "1", 0, "L", false, 0, "")
|
||||
}
|
||||
pdf.Ln(-1)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/pdf")
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="register.pdf"`)
|
||||
if err := pdf.Output(w); err != nil {
|
||||
http.Error(w, fmt.Sprintf("PDF konnte nicht erzeugt werden: %v", err), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
142
internal/web/register_handlers_test.go
Normal file
142
internal/web/register_handlers_test.go
Normal file
@@ -0,0 +1,142 @@
|
||||
package web_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/store"
|
||||
"github.com/netcell-it/deklarix/internal/web"
|
||||
)
|
||||
|
||||
// genehmigeFall reicht einen Antrag ein und genehmigt ihn mit einem
|
||||
// echten, zulässigen Werkzeug — Vorbereitung für Register-Tests, die
|
||||
// einen tatsächlichen Registereintrag brauchen.
|
||||
func genehmigeFall(t *testing.T, fs *fakeStore, s *web.Server) (accountID string) {
|
||||
t.Helper()
|
||||
acc, err := fs.CreateAccount(context.Background(), "Register-Mandant")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAccount: %v", err)
|
||||
}
|
||||
fs.werkzeuge["werkzeug-ok"] = store.Werkzeug{ID: "werkzeug-ok", Name: "ChatGPT Enterprise", AVVVerfuegbar: true, TrainingStandard: true}
|
||||
mitarbeiterCookie := seedUserInAccount(t, fs, acc.ID, "mitarbeiter@example.com", "mitarbeiter")
|
||||
createResp := postForm(t, s, mitarbeiterCookie, "/antraege", fullAntragForm())
|
||||
if createResp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("Antrag anlegen: status = %d, body: %s", createResp.Code, createResp.Body.String())
|
||||
}
|
||||
var antragID string
|
||||
for id := range fs.antraege {
|
||||
if fs.antraege[id].AccountID == acc.ID {
|
||||
antragID = id
|
||||
}
|
||||
}
|
||||
verantwortlicherCookie := seedUserInAccount(t, fs, acc.ID, "verantwortlicher@example.com", "verantwortlicher")
|
||||
entscheidenResp := postForm(t, s, verantwortlicherCookie, "/faelle/"+antragID+"/entscheiden", url.Values{
|
||||
"entscheidung": {"genehmigt"}, "werkzeug_id": {"werkzeug-ok"},
|
||||
})
|
||||
if entscheidenResp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("Entscheiden: status = %d, body: %s", entscheidenResp.Code, entscheidenResp.Body.String())
|
||||
}
|
||||
return acc.ID
|
||||
}
|
||||
|
||||
func TestGenehmigungErzeugtRegistereintrag(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
accountID := genehmigeFall(t, fs, s)
|
||||
|
||||
list := fs.registereintraege[accountID]
|
||||
if len(list) != 1 {
|
||||
t.Fatalf("registereintraege = %+v, want exactly one entry", list)
|
||||
}
|
||||
e := list[0]
|
||||
if e.Werkzeug != "ChatGPT Enterprise" || e.Zweck != "Angebotstexte generieren" || e.Datenklasse != "personenbezogen" {
|
||||
t.Errorf("Registereintrag = %+v, unerwartete Werte", e)
|
||||
}
|
||||
if e.Verantwortlicher != "verantwortlicher@example.com" {
|
||||
t.Errorf("Verantwortlicher = %q, want verantwortlicher@example.com", e.Verantwortlicher)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAbgelehnteEntscheidungErzeugtKeinenRegistereintrag(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
antragID, verantwortlicherCookie := seedFallImAccount(t, fs, s, "verantwortlicher")
|
||||
|
||||
resp := postForm(t, s, verantwortlicherCookie, "/faelle/"+antragID+"/entscheiden", url.Values{
|
||||
"entscheidung": {"abgelehnt"}, "begruendung": {"Kein Werkzeug verfügbar"},
|
||||
})
|
||||
if resp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
|
||||
for _, list := range fs.registereintraege {
|
||||
if len(list) != 0 {
|
||||
t.Fatalf("expected no registereintrag for an abgelehnt Entscheidung, got %+v", list)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterListeZeigtGenehmigung(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
accountID := genehmigeFall(t, fs, s)
|
||||
verantwortlicherCookie := seedUserInAccount(t, fs, accountID, "reviewer@example.com", "pruefer")
|
||||
|
||||
resp := getWithCookie(t, s, verantwortlicherCookie, "/registereintraege")
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
if !strings.Contains(resp.Body.String(), "ChatGPT Enterprise") {
|
||||
t.Errorf("expected the genehmigtes werkzeug in the register, got: %s", resp.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterExportCSVEnthaeltEintrag(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
accountID := genehmigeFall(t, fs, s)
|
||||
verantwortlicherCookie := seedUserInAccount(t, fs, accountID, "reviewer@example.com", "pruefer")
|
||||
|
||||
resp := getWithCookie(t, s, verantwortlicherCookie, "/registereintraege/export.csv")
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
if ct := resp.Header().Get("Content-Type"); !strings.Contains(ct, "text/csv") {
|
||||
t.Errorf("Content-Type = %q, want text/csv", ct)
|
||||
}
|
||||
if !strings.Contains(resp.Body.String(), "ChatGPT Enterprise") {
|
||||
t.Errorf("expected the werkzeug in the CSV export, got: %s", resp.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterExportPDFReturnsPDF(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
accountID := genehmigeFall(t, fs, s)
|
||||
verantwortlicherCookie := seedUserInAccount(t, fs, accountID, "reviewer@example.com", "pruefer")
|
||||
|
||||
resp := getWithCookie(t, s, verantwortlicherCookie, "/registereintraege/export.pdf")
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
if ct := resp.Header().Get("Content-Type"); ct != "application/pdf" {
|
||||
t.Errorf("Content-Type = %q, want application/pdf", ct)
|
||||
}
|
||||
if !strings.HasPrefix(resp.Body.String(), "%PDF") {
|
||||
t.Error("expected the response body to start with the PDF magic bytes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMitarbeiterCannotAccessRegister(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
cookie := seedAccountWithRole(t, fs, "Test-Mandant", "mitarbeiter@example.com", "mitarbeiter")
|
||||
|
||||
resp := getWithCookie(t, s, cookie, "/registereintraege")
|
||||
if resp.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404 for role mitarbeiter on /registereintraege", resp.Code)
|
||||
}
|
||||
}
|
||||
@@ -61,6 +61,10 @@ type Store interface {
|
||||
|
||||
CreateEntscheidung(ctx context.Context, in store.EntscheidungInput) (store.Entscheidung, error)
|
||||
GetLatestEntscheidungForAntrag(ctx context.Context, antragID string) (store.Entscheidung, error)
|
||||
|
||||
GetAbteilung(ctx context.Context, id string) (store.Abteilung, error)
|
||||
CreateRegistereintrag(ctx context.Context, in store.RegistereintragInput) (store.Registereintrag, error)
|
||||
ListRegistereintraegeForAccount(ctx context.Context, accountID string) ([]store.Registereintrag, error)
|
||||
}
|
||||
|
||||
// Regelwerk bündelt die drei geladenen Regelwerk-Dateien, die die
|
||||
@@ -110,6 +114,9 @@ func NewServer(st Store, regelwerk Regelwerk) (*Server, error) {
|
||||
mux.HandleFunc("GET /faelle", s.requireFachebene(s.handleFaelleListe))
|
||||
mux.HandleFunc("GET /faelle/{id}", s.requireFachebene(s.handleFallDetail))
|
||||
mux.HandleFunc("POST /faelle/{id}/entscheiden", s.requireFachebene(s.handleFallEntscheiden))
|
||||
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 /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))
|
||||
|
||||
@@ -24,31 +24,33 @@ const testSessionCookie = "deklarix_session"
|
||||
// fakeStore ist eine In-Memory-Implementierung von web.Store, damit die
|
||||
// Handler-Tests keine echte Postgres-Instanz brauchen.
|
||||
type fakeStore struct {
|
||||
mu sync.Mutex
|
||||
nextID int
|
||||
accounts map[string]store.Account
|
||||
users map[string]store.User
|
||||
usersByEmail map[string]string // email -> user id
|
||||
sessions map[string]store.Session
|
||||
auditLog []store.AuditEntry
|
||||
abteilungen map[string][]store.Abteilung // accountID -> Abteilungen
|
||||
antraege map[string]store.Antrag
|
||||
werkzeuge map[string]store.Werkzeug
|
||||
bewertungen map[string][]store.Bewertung // antragID -> Bewertungen, älteste zuerst
|
||||
entscheidungen map[string][]store.Entscheidung // antragID -> Entscheidungen, älteste zuerst
|
||||
mu sync.Mutex
|
||||
nextID int
|
||||
accounts map[string]store.Account
|
||||
users map[string]store.User
|
||||
usersByEmail map[string]string // email -> user id
|
||||
sessions map[string]store.Session
|
||||
auditLog []store.AuditEntry
|
||||
abteilungen map[string][]store.Abteilung // accountID -> Abteilungen
|
||||
antraege map[string]store.Antrag
|
||||
werkzeuge map[string]store.Werkzeug
|
||||
bewertungen map[string][]store.Bewertung // antragID -> Bewertungen, älteste zuerst
|
||||
entscheidungen map[string][]store.Entscheidung // antragID -> Entscheidungen, älteste zuerst
|
||||
registereintraege map[string][]store.Registereintrag // accountID -> Registereintraege
|
||||
}
|
||||
|
||||
func newFakeStore() *fakeStore {
|
||||
return &fakeStore{
|
||||
accounts: map[string]store.Account{},
|
||||
users: map[string]store.User{},
|
||||
usersByEmail: map[string]string{},
|
||||
sessions: map[string]store.Session{},
|
||||
abteilungen: map[string][]store.Abteilung{},
|
||||
antraege: map[string]store.Antrag{},
|
||||
werkzeuge: map[string]store.Werkzeug{},
|
||||
bewertungen: map[string][]store.Bewertung{},
|
||||
entscheidungen: map[string][]store.Entscheidung{},
|
||||
accounts: map[string]store.Account{},
|
||||
users: map[string]store.User{},
|
||||
usersByEmail: map[string]string{},
|
||||
sessions: map[string]store.Session{},
|
||||
abteilungen: map[string][]store.Abteilung{},
|
||||
antraege: map[string]store.Antrag{},
|
||||
werkzeuge: map[string]store.Werkzeug{},
|
||||
bewertungen: map[string][]store.Bewertung{},
|
||||
entscheidungen: map[string][]store.Entscheidung{},
|
||||
registereintraege: map[string][]store.Registereintrag{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,6 +189,19 @@ func (f *fakeStore) ListAbteilungenForAccount(ctx context.Context, accountID str
|
||||
return f.abteilungen[accountID], nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) GetAbteilung(ctx context.Context, id string) (store.Abteilung, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
for _, list := range f.abteilungen {
|
||||
for _, a := range list {
|
||||
if a.ID == id {
|
||||
return a, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return store.Abteilung{}, store.ErrNotFound
|
||||
}
|
||||
|
||||
func (f *fakeStore) CreateAntrag(ctx context.Context, accountID, erstellerUserID string, abteilungID *string, titel string) (store.Antrag, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
@@ -385,6 +400,26 @@ func (f *fakeStore) GetLatestEntscheidungForAntrag(ctx context.Context, antragID
|
||||
return es[len(es)-1], nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) CreateRegistereintrag(ctx context.Context, in store.RegistereintragInput) (store.Registereintrag, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
e := store.Registereintrag{
|
||||
ID: f.newID(), AccountID: in.AccountID, AntragID: in.AntragID, EntscheidungID: in.EntscheidungID,
|
||||
Zweck: in.Zweck, Abteilung: in.Abteilung, Werkzeug: in.Werkzeug,
|
||||
Datenklasse: in.Datenklasse, Einstufung: in.Einstufung, Auflagen: in.Auflagen,
|
||||
Verantwortlicher: in.Verantwortlicher, EntschiedenAm: in.EntschiedenAm, GueltigBis: in.GueltigBis,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
f.registereintraege[in.AccountID] = append(f.registereintraege[in.AccountID], e)
|
||||
return e, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) ListRegistereintraegeForAccount(ctx context.Context, accountID string) ([]store.Registereintrag, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.registereintraege[accountID], nil
|
||||
}
|
||||
|
||||
// ─── Test-Setup ───────────────────────────────────────────────────────
|
||||
|
||||
// loadTestRegelwerk lädt die echten rules/*.yaml-Dateien — dieselben,
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
<a href="/">Start</a>
|
||||
{{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 .IsAdmin}}<a href="/verwaltung/nutzer">Nutzerverwaltung</a>{{end}}
|
||||
{{if .IsBetreiber}}<a href="/betreiber">Plattform</a>{{end}}
|
||||
<form method="post" action="/logout" style="display:inline">
|
||||
|
||||
38
internal/web/templates/register_liste.html
Normal file
38
internal/web/templates/register_liste.html
Normal file
@@ -0,0 +1,38 @@
|
||||
{{define "register-liste"}}<!doctype html>
|
||||
<html lang="de">
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<h1>Register</h1>
|
||||
<p class="hinweis">
|
||||
Jede Genehmigung erzeugt automatisch einen Registereintrag —
|
||||
abgelehnte Anträge und Rückfragen stehen nicht im Register.
|
||||
</p>
|
||||
<p><a href="/registereintraege/export.csv">CSV-Export</a> · <a href="/registereintraege/export.pdf">PDF-Export</a></p>
|
||||
{{if not .Eintraege}}
|
||||
<p class="hinweis">Noch keine Genehmigungen.</p>
|
||||
{{else}}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Zweck</th><th>Abteilung</th><th>Werkzeug</th><th>Datenklasse</th>
|
||||
<th>Einstufung</th><th>Auflagen</th><th>Verantwortliche/r</th>
|
||||
<th>Entschieden am</th><th>Gültig bis</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Eintraege}}
|
||||
<tr>
|
||||
<td>{{.Zweck}}</td><td>{{.Abteilung}}</td><td>{{.Werkzeug}}</td><td>{{.Datenklasse}}</td>
|
||||
<td>{{.Einstufung}}</td><td>{{.Auflagen}}</td><td>{{.Verantwortlicher}}</td>
|
||||
<td>{{.EntschiedenAm}}</td><td>{{.GueltigBis}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{end}}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user