feat: Posteingang und Entscheiden für die Fachebene (Schritt 5 der Baureihenfolge)
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>
This commit is contained in:
70
CLAUDE.md
70
CLAUDE.md
@@ -205,23 +205,45 @@ mehrfacher Neubewertung eines Antrags erhalten bleibt.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Entscheidung, Register, Wiedervorlage (geplant, Schritt 5-7)
|
## Entscheidung, Register, Wiedervorlage (Schritt 5 erledigt, Schritt 6-7 geplant)
|
||||||
|
|
||||||
|
**Schritt 5 (Entscheidung, Snapshot, Audit-Log) ist umgesetzt.** Ebene 3
|
||||||
|
(Fachebene) hat einen Posteingang (`GET /faelle`, alle offenen —
|
||||||
|
Status "eingereicht" — Anträge des Mandanten, nicht nur die eigenen)
|
||||||
|
und eine Entscheiden-Seite (`GET /faelle/{id}`, `POST
|
||||||
|
/faelle/{id}/entscheiden`, siehe `internal/web/fachebene_handlers.go`).
|
||||||
|
Rollen `verantwortlicher` und `pruefer` sehen dieselbe Sicht
|
||||||
|
(`requireFachebene`), nur `verantwortlicher` hat Entscheidungsrecht —
|
||||||
|
ein `pruefer`-POST auf `/entscheiden` bekommt 403 (er darf wissen, dass
|
||||||
|
der Fall existiert, nur nicht entscheiden; anders als bei
|
||||||
|
`requireFachebene`/`requireBetreiber`, die bei falscher Rolle 404
|
||||||
|
liefern, um die Existenz der Seite selbst zu verbergen).
|
||||||
|
|
||||||
Der/die Verantwortliche wählt: genehmigt / genehmigt mit Auflagen /
|
Der/die Verantwortliche wählt: genehmigt / genehmigt mit Auflagen /
|
||||||
abgelehnt / Rückfrage. Begründungsfeld ist bei Abweichung vom Vorschlag
|
abgelehnt / Rückfrage. **Vorschlag-Vergleich:** `vorschlagFuer` leitet
|
||||||
Pflicht. **Snapshot bei Entscheidung:** Regelwerk-Version, Katalog-
|
aus der Bewertung ab, was das System vorschlagen würde (`abgelehnt` bei
|
||||||
Version und der vollständige Werkzeugdatensatz werden eingefroren — ein
|
`verboten`, sonst `genehmigt` wenn mindestens ein zulässiges Werkzeug
|
||||||
späterer Katalog-Wandel darf nicht rückwirkend verändern, worauf eine
|
existiert, sonst `rueckfrage`) — weicht die tatsächliche Entscheidung
|
||||||
Entscheidung beruhte.
|
davon ab, ist das Begründungsfeld Pflicht (sonst 400). **Snapshot bei
|
||||||
|
Entscheidung:** `entscheidung.werkzeug_snapshot` (JSONB) friert den
|
||||||
|
vollständigen `store.Werkzeug`-Datensatz zum Entscheidungszeitpunkt ein
|
||||||
|
— ein späterer Katalog-Wandel darf nicht rückwirkend verändern, worauf
|
||||||
|
eine Entscheidung beruhte. Bei "genehmigt"/"genehmigt mit Auflagen" ist
|
||||||
|
ein Werkzeug aus der zulässigen Liste der Bewertung Pflicht (hart
|
||||||
|
geprüft, kein Override eines ausgeschlossenen Werkzeugs). `entscheidung`
|
||||||
|
ist append-only (Migration 0010) wie `bewertung`; jede Entscheidung
|
||||||
|
erzeugt zusätzlich einen `audit_log`-Eintrag (Action
|
||||||
|
`antrag_entschieden`).
|
||||||
|
|
||||||
Genehmigungen erhalten ein Ablaufdatum (Vorschlag: 12 Monate, bei
|
Genehmigungen erhalten ein Ablaufdatum (`gueltig_bis`: 12 Monate, bei
|
||||||
`hochrisiko` 6). Ändert sich im Katalog eine Eigenschaft, auf der eine
|
`hochrisiko` 6 — `gueltigkeitFuer`). **Noch nicht gebaut (Schritt 7):**
|
||||||
aktive Genehmigung beruht, wird der/die Verantwortliche benachrichtigt
|
die Benachrichtigung, wenn sich im Katalog eine Eigenschaft ändert, auf
|
||||||
(Wiedervorlage).
|
der eine aktive Genehmigung beruht (Wiedervorlage).
|
||||||
|
|
||||||
Jede Genehmigung erzeugt automatisch einen Registereintrag: Zweck,
|
**Noch nicht gebaut (Schritt 6):** ein `registereintrag` wird bislang
|
||||||
Abteilung, Werkzeug, Datenklasse, Einstufung, Auflagen, Verantwortliche/
|
NICHT automatisch aus einer Genehmigung erzeugt, und es gibt keinen
|
||||||
r, Datum, Gültigkeit. Export als PDF und CSV.
|
PDF-/CSV-Export. Geplant: Zweck, Abteilung, Werkzeug, Datenklasse,
|
||||||
|
Einstufung, Auflagen, Verantwortliche/r, Datum, Gültigkeit.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -305,12 +327,16 @@ nicht anzulegen. Noch nicht gebaut (kein Abo-System).
|
|||||||
NICHT append-only (normale Zustandsänderung `entwurf` →
|
NICHT append-only (normale Zustandsänderung `entwurf` →
|
||||||
`eingereicht` → `entschieden`, wie `submission` es im alten Produkt
|
`eingereicht` → `entschieden`, wie `submission` es im alten Produkt
|
||||||
war).
|
war).
|
||||||
- `bewertung`, `entscheidung`, `registereintrag` — **noch nicht
|
- `bewertung` — der berechnete Vorschlag (Schritt 4).
|
||||||
gebaut**, geplant für Schritt 5/6 der Baureihenfolge.
|
- `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.
|
||||||
|
|
||||||
**Append-only:** kein UPDATE auf `audit_log` (Trigger `forbid_update_delete`,
|
**Append-only:** kein UPDATE auf `audit_log` (Trigger `forbid_update_delete`,
|
||||||
wiederverwendet aus dem alten Produkt). `bewertung`/`entscheidung`/
|
wiederverwendet aus dem alten Produkt), ebenso `bewertung` und
|
||||||
`registereintrag` werden bei ihrer Einführung ebenfalls append-only.
|
`entscheidung`. `registereintrag` wird bei seiner Einführung ebenfalls
|
||||||
|
append-only.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -341,7 +367,15 @@ wiederverwendet aus dem alten Produkt). `bewertung`/`entscheidung`/
|
|||||||
`bewertung` ist append-only. Getestet inkl. K.-o.-Prüfung
|
`bewertung` ist append-only. Getestet inkl. K.-o.-Prüfung
|
||||||
(`verboten` überspringt die Werkzeugsuche) und hartem Filter
|
(`verboten` überspringt die Werkzeugsuche) und hartem Filter
|
||||||
gegen den Katalog.)
|
gegen den Katalog.)
|
||||||
5. Entscheidung, Snapshot, Audit-Log
|
5. ~~Entscheidung, Snapshot, Audit-Log~~ — **erledigt** (Ebene 3:
|
||||||
|
`GET /faelle` Posteingang, `GET /faelle/{id}` + `POST
|
||||||
|
/faelle/{id}/entscheiden`. `verantwortlicher` entscheidet,
|
||||||
|
`pruefer` sieht dieselbe Seite ohne Entscheidungsrecht [403 bei
|
||||||
|
Entscheidungsversuch]. Begründung ist Pflicht bei Abweichung vom
|
||||||
|
abgeleiteten Vorschlag (`vorschlagFuer`), Genehmigung friert den
|
||||||
|
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
|
||||||
7. Wiedervorlage und Katalog-Benachrichtigung
|
7. Wiedervorlage und Katalog-Benachrichtigung
|
||||||
|
|
||||||
|
|||||||
111
internal/store/entscheidung.go
Normal file
111
internal/store/entscheidung.go
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Entscheidung ist die Entscheidung eines/einer Verantwortlichen über
|
||||||
|
// einen Antrag, auf Basis einer Bewertung (Vorschlag). Append-only —
|
||||||
|
// siehe Migration. WerkzeugSnapshot ist der eingefrorene, vollständige
|
||||||
|
// Werkzeug-Datensatz zum Entscheidungszeitpunkt (nil, wenn kein
|
||||||
|
// Werkzeug gewählt wurde, z. B. bei "abgelehnt") — ein späterer
|
||||||
|
// Katalog-Wandel darf nicht rückwirkend verändern, worauf die
|
||||||
|
// Entscheidung beruhte.
|
||||||
|
type Entscheidung struct {
|
||||||
|
ID string
|
||||||
|
AntragID string
|
||||||
|
BewertungID string
|
||||||
|
EntscheiderUserID string
|
||||||
|
Entscheidung string
|
||||||
|
WerkzeugID *string
|
||||||
|
WerkzeugSnapshot *Werkzeug
|
||||||
|
Begruendung string
|
||||||
|
GueltigBis *time.Time
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
const entscheidungColumns = `id, antrag_id, bewertung_id, entscheider_user_id, entscheidung,
|
||||||
|
werkzeug_id, werkzeug_snapshot, begruendung, gueltig_bis, created_at`
|
||||||
|
|
||||||
|
func scanEntscheidung(row interface {
|
||||||
|
Scan(dest ...any) error
|
||||||
|
}) (Entscheidung, error) {
|
||||||
|
var e Entscheidung
|
||||||
|
var snapshotRaw []byte
|
||||||
|
err := row.Scan(
|
||||||
|
&e.ID, &e.AntragID, &e.BewertungID, &e.EntscheiderUserID, &e.Entscheidung,
|
||||||
|
&e.WerkzeugID, &snapshotRaw, &e.Begruendung, &e.GueltigBis, &e.CreatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return Entscheidung{}, err
|
||||||
|
}
|
||||||
|
if snapshotRaw != nil {
|
||||||
|
var w Werkzeug
|
||||||
|
if err := json.Unmarshal(snapshotRaw, &w); err != nil {
|
||||||
|
return Entscheidung{}, fmt.Errorf("store: werkzeug_snapshot unmarshal: %w", err)
|
||||||
|
}
|
||||||
|
e.WerkzeugSnapshot = &w
|
||||||
|
}
|
||||||
|
return e, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EntscheidungInput bündelt die Felder einer neuen Entscheidung.
|
||||||
|
type EntscheidungInput struct {
|
||||||
|
AntragID string
|
||||||
|
BewertungID string
|
||||||
|
EntscheiderUserID string
|
||||||
|
Entscheidung string
|
||||||
|
WerkzeugID *string
|
||||||
|
WerkzeugSnapshot *Werkzeug
|
||||||
|
Begruendung string
|
||||||
|
GueltigBis *time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateEntscheidung speichert eine Entscheidung über einen Antrag.
|
||||||
|
func (s *Store) CreateEntscheidung(ctx context.Context, in EntscheidungInput) (Entscheidung, error) {
|
||||||
|
var snapshotJSON []byte
|
||||||
|
if in.WerkzeugSnapshot != nil {
|
||||||
|
var err error
|
||||||
|
snapshotJSON, err = json.Marshal(in.WerkzeugSnapshot)
|
||||||
|
if err != nil {
|
||||||
|
return Entscheidung{}, fmt.Errorf("store: werkzeug_snapshot marshal: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
row := s.Pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO entscheidung (
|
||||||
|
antrag_id, bewertung_id, entscheider_user_id, entscheidung, werkzeug_id, werkzeug_snapshot, begruendung, gueltig_bis
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
|
RETURNING `+entscheidungColumns,
|
||||||
|
in.AntragID, in.BewertungID, in.EntscheiderUserID, in.Entscheidung, in.WerkzeugID, snapshotJSON, in.Begruendung, in.GueltigBis,
|
||||||
|
)
|
||||||
|
e, err := scanEntscheidung(row)
|
||||||
|
if err != nil {
|
||||||
|
return Entscheidung{}, fmt.Errorf("store: create entscheidung: %w", err)
|
||||||
|
}
|
||||||
|
return e, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLatestEntscheidungForAntrag liefert die zuletzt getroffene
|
||||||
|
// Entscheidung eines Antrags. Liefert ErrNotFound, wenn noch keine
|
||||||
|
// Entscheidung existiert.
|
||||||
|
func (s *Store) GetLatestEntscheidungForAntrag(ctx context.Context, antragID string) (Entscheidung, error) {
|
||||||
|
row := s.Pool.QueryRow(ctx, `
|
||||||
|
SELECT `+entscheidungColumns+`
|
||||||
|
FROM entscheidung WHERE antrag_id = $1 ORDER BY created_at DESC LIMIT 1
|
||||||
|
`, antragID)
|
||||||
|
e, err := scanEntscheidung(row)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return Entscheidung{}, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return Entscheidung{}, fmt.Errorf("store: get latest entscheidung: %w", err)
|
||||||
|
}
|
||||||
|
return e, nil
|
||||||
|
}
|
||||||
115
internal/store/entscheidung_test.go
Normal file
115
internal/store/entscheidung_test.go
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
package store_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testAntragMitBewertung(t *testing.T, s *store.Store, accountID, userID string) (store.Antrag, store.Bewertung) {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
antrag, err := s.CreateAntrag(ctx, accountID, userID, nil, "Testantrag")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateAntrag: %v", err)
|
||||||
|
}
|
||||||
|
b, err := s.CreateBewertung(ctx, testBewertungInput(antrag.ID))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateBewertung: %v", err)
|
||||||
|
}
|
||||||
|
return antrag, b
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEntscheidungCreateAndGetLatest(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
accID := testAccountID(t, s)
|
||||||
|
userID := testUserID(t, s, accID)
|
||||||
|
antrag, b := testAntragMitBewertung(t, s, accID, userID)
|
||||||
|
w, err := s.CreateWerkzeug(ctx, testWerkzeugInput(nil, "ChatGPT Enterprise"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateWerkzeug: %v", err)
|
||||||
|
}
|
||||||
|
gueltigBis := time.Now().Add(365 * 24 * time.Hour).Truncate(time.Millisecond)
|
||||||
|
|
||||||
|
e, err := s.CreateEntscheidung(ctx, store.EntscheidungInput{
|
||||||
|
AntragID: antrag.ID, BewertungID: b.ID, EntscheiderUserID: userID,
|
||||||
|
Entscheidung: "genehmigt", WerkzeugID: &w.ID, WerkzeugSnapshot: &w,
|
||||||
|
Begruendung: "", GueltigBis: &gueltigBis,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateEntscheidung: %v", err)
|
||||||
|
}
|
||||||
|
if e.Entscheidung != "genehmigt" || e.WerkzeugSnapshot == nil || e.WerkzeugSnapshot.Name != "ChatGPT Enterprise" {
|
||||||
|
t.Fatalf("CreateEntscheidung = %+v, unerwartete Werte", e)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := s.GetLatestEntscheidungForAntrag(ctx, antrag.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetLatestEntscheidungForAntrag: %v", err)
|
||||||
|
}
|
||||||
|
if got.ID != e.ID {
|
||||||
|
t.Fatalf("GetLatestEntscheidungForAntrag returned a different row than CreateEntscheidung")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEntscheidungOhneWerkzeugHatKeinenSnapshot(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
accID := testAccountID(t, s)
|
||||||
|
userID := testUserID(t, s, accID)
|
||||||
|
antrag, b := testAntragMitBewertung(t, s, accID, userID)
|
||||||
|
|
||||||
|
e, err := s.CreateEntscheidung(ctx, store.EntscheidungInput{
|
||||||
|
AntragID: antrag.ID, BewertungID: b.ID, EntscheiderUserID: userID,
|
||||||
|
Entscheidung: "abgelehnt", Begruendung: "Kein geeignetes Werkzeug im Katalog",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateEntscheidung: %v", err)
|
||||||
|
}
|
||||||
|
if e.WerkzeugID != nil || e.WerkzeugSnapshot != nil {
|
||||||
|
t.Fatalf("expected no werkzeug/snapshot for an abgelehnt Entscheidung, got %+v", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetLatestEntscheidungForAntragNotFound(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
accID := testAccountID(t, s)
|
||||||
|
userID := testUserID(t, s, accID)
|
||||||
|
antrag, err := s.CreateAntrag(ctx, accID, userID, nil, "Ohne Entscheidung")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateAntrag: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = s.GetLatestEntscheidungForAntrag(ctx, antrag.ID)
|
||||||
|
if !errors.Is(err, store.ErrNotFound) {
|
||||||
|
t.Fatalf("err = %v, want store.ErrNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEntscheidungIsAppendOnly(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
accID := testAccountID(t, s)
|
||||||
|
userID := testUserID(t, s, accID)
|
||||||
|
antrag, b := testAntragMitBewertung(t, s, accID, userID)
|
||||||
|
e, err := s.CreateEntscheidung(ctx, store.EntscheidungInput{
|
||||||
|
AntragID: antrag.ID, BewertungID: b.ID, EntscheiderUserID: userID, Entscheidung: "rueckfrage",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateEntscheidung: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = s.Pool.Exec(ctx, `UPDATE entscheidung SET entscheidung = 'genehmigt' WHERE id = $1`, e.ID)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected UPDATE on entscheidung to be rejected by the append-only trigger")
|
||||||
|
}
|
||||||
|
_, err = s.Pool.Exec(ctx, `DELETE FROM entscheidung WHERE id = $1`, e.ID)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected DELETE on entscheidung to be rejected by the append-only trigger")
|
||||||
|
}
|
||||||
|
}
|
||||||
1
internal/store/migrations/0010_entscheidung.down.sql
Normal file
1
internal/store/migrations/0010_entscheidung.down.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE entscheidung;
|
||||||
25
internal/store/migrations/0010_entscheidung.up.sql
Normal file
25
internal/store/migrations/0010_entscheidung.up.sql
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
-- Entscheidung: der Mensch entscheidet, das System bereitet nur vor
|
||||||
|
-- (siehe CLAUDE.md, Grundregel). Eine Entscheidung bezieht sich auf
|
||||||
|
-- genau eine Bewertung (den Vorschlag, auf dessen Basis entschieden
|
||||||
|
-- wurde) und friert optional ein gewähltes Werkzeug als vollständigen
|
||||||
|
-- Datensatz ein (werkzeug_snapshot) — ein späterer Katalog-Wandel darf
|
||||||
|
-- nicht rückwirkend verändern, worauf eine Entscheidung beruhte.
|
||||||
|
-- Append-only wie bewertung/audit_log: eine Entscheidung wird nicht
|
||||||
|
-- korrigiert, sondern durch eine neue Entscheidung auf Basis einer
|
||||||
|
-- neuen Bewertung ersetzt (Neubewertung + erneute Entscheidung).
|
||||||
|
CREATE TABLE entscheidung (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
antrag_id UUID NOT NULL REFERENCES antrag (id),
|
||||||
|
bewertung_id UUID NOT NULL REFERENCES bewertung (id),
|
||||||
|
entscheider_user_id UUID NOT NULL REFERENCES app_user (id),
|
||||||
|
entscheidung TEXT NOT NULL CHECK (entscheidung IN ('genehmigt', 'genehmigt_mit_auflagen', 'abgelehnt', 'rueckfrage')),
|
||||||
|
werkzeug_id UUID REFERENCES werkzeug (id),
|
||||||
|
werkzeug_snapshot JSONB,
|
||||||
|
begruendung TEXT NOT NULL DEFAULT '',
|
||||||
|
gueltig_bis TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TRIGGER entscheidung_append_only
|
||||||
|
BEFORE UPDATE OR DELETE ON entscheidung
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION forbid_update_delete();
|
||||||
236
internal/web/fachebene_handlers.go
Normal file
236
internal/web/fachebene_handlers.go
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
// 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)
|
||||||
|
}
|
||||||
216
internal/web/fachebene_handlers_test.go
Normal file
216
internal/web/fachebene_handlers_test.go
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// seedFallImAccount legt einen Mandanten mit einem Mitarbeiter an, der
|
||||||
|
// einen Antrag einreicht (mit personenbezogener Datenklasse, Einstufung
|
||||||
|
// minimal — siehe fullAntragForm), und liefert dessen ID sowie eine
|
||||||
|
// zweite Cookie für einen weiteren Nutzer der Rolle role im selben
|
||||||
|
// Mandanten (verantwortlicher/pruefer).
|
||||||
|
func seedFallImAccount(t *testing.T, fs *fakeStore, s *web.Server, role string) (antragID string, fachebeneCookie *http.Cookie) {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
acc, err := fs.CreateAccount(ctx, "Fachebene-Mandant")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateAccount: %v", err)
|
||||||
|
}
|
||||||
|
mitarbeiterCookie := seedUserInAccount(t, fs, acc.ID, "mitarbeiter@example.com", "mitarbeiter")
|
||||||
|
resp := postForm(t, s, mitarbeiterCookie, "/antraege", fullAntragForm())
|
||||||
|
if resp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("Antrag anlegen: status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
for id := range fs.antraege {
|
||||||
|
if fs.antraege[id].AccountID == acc.ID {
|
||||||
|
antragID = id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fachebeneCookie = seedUserInAccount(t, fs, acc.ID, role+"@example.com", role)
|
||||||
|
return antragID, fachebeneCookie
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMitarbeiterCannotAccessFaelle(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
cookie := seedAccount(t, fs, "Test-Mandant", "mitarbeiter@example.com")
|
||||||
|
|
||||||
|
resp := getWithCookie(t, s, cookie, "/faelle")
|
||||||
|
if resp.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("status = %d, want 404 for role mitarbeiter on /faelle", resp.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFaelleListeShowsOpenAntraegeForAccount(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
_, verantwortlicherCookie := seedFallImAccount(t, fs, s, "verantwortlicher")
|
||||||
|
|
||||||
|
resp := getWithCookie(t, s, verantwortlicherCookie, "/faelle")
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(resp.Body.String(), "Angebotstexte generieren") {
|
||||||
|
t.Errorf("expected the open antrag in the Posteingang, got: %s", resp.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ein Verantwortlicher eines anderen Mandanten darf ihn nicht sehen.
|
||||||
|
otherAcc, err := fs.CreateAccount(context.Background(), "Anderer Mandant")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateAccount: %v", err)
|
||||||
|
}
|
||||||
|
otherCookie := seedUserInAccount(t, fs, otherAcc.ID, "other-verantwortlicher@example.com", "verantwortlicher")
|
||||||
|
otherResp := getWithCookie(t, s, otherCookie, "/faelle")
|
||||||
|
if strings.Contains(otherResp.Body.String(), "Angebotstexte generieren") {
|
||||||
|
t.Errorf("expected no cross-tenant antrag in the Posteingang, got: %s", otherResp.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFallDetailShowsDecideFormOnlyForVerantwortlicher(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
antragID, verantwortlicherCookie := seedFallImAccount(t, fs, s, "verantwortlicher")
|
||||||
|
|
||||||
|
resp := getWithCookie(t, s, verantwortlicherCookie, "/faelle/"+antragID)
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(resp.Body.String(), `name="entscheidung"`) {
|
||||||
|
t.Errorf("expected the Entscheiden form for verantwortlicher, got: %s", resp.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFallDetailHidesDecideFormForPruefer(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
antragID, prueferCookie := seedFallImAccount(t, fs, s, "pruefer")
|
||||||
|
|
||||||
|
resp := getWithCookie(t, s, prueferCookie, "/faelle/"+antragID)
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
if strings.Contains(resp.Body.String(), `name="entscheidung"`) {
|
||||||
|
t.Errorf("expected no Entscheiden form for pruefer, got: %s", resp.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFallEntscheidenRejectsPruefer(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
antragID, prueferCookie := seedFallImAccount(t, fs, s, "pruefer")
|
||||||
|
|
||||||
|
resp := postForm(t, s, prueferCookie, "/faelle/"+antragID+"/entscheiden", url.Values{"entscheidung": {"rueckfrage"}})
|
||||||
|
if resp.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("status = %d, want 403 for pruefer", resp.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFallEntscheidenRequiresBegruendungOnAbweichung: fullAntragForm
|
||||||
|
// liefert keine passenden Werkzeuge im (leeren) Katalog, der Vorschlag
|
||||||
|
// ist also "rueckfrage" (siehe vorschlagFuer). "abgelehnt" weicht davon
|
||||||
|
// ab und braucht eine Begründung.
|
||||||
|
func TestFallEntscheidenRequiresBegruendungOnAbweichung(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"}})
|
||||||
|
if resp.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d, want 400 ohne Begründung bei Abweichung, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = postForm(t, s, verantwortlicherCookie, "/faelle/"+antragID+"/entscheiden", url.Values{
|
||||||
|
"entscheidung": {"abgelehnt"}, "begruendung": {"Kein Werkzeug im Katalog verfügbar, trotzdem vertretbar"},
|
||||||
|
})
|
||||||
|
if resp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("status = %d, want 303 mit Begründung, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFallEntscheidenRueckfrageOhneAbweichungBrauchtKeineBegruendung(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": {"rueckfrage"}})
|
||||||
|
if resp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("status = %d, want 303 (rueckfrage entspricht dem Vorschlag bei leerem Katalog), body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
detail := getWithCookie(t, s, verantwortlicherCookie, "/faelle/"+antragID)
|
||||||
|
if !strings.Contains(detail.Body.String(), "rueckfrage") {
|
||||||
|
t.Errorf("expected the Entscheidung on the fall-detail page, got: %s", detail.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFallEntscheidenGenehmigtBrauchtZulaessigesWerkzeug(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
acc, err := fs.CreateAccount(context.Background(), "Mit Katalog")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateAccount: %v", err)
|
||||||
|
}
|
||||||
|
mitarbeiterCookie := seedUserInAccount(t, fs, acc.ID, "mitarbeiter@example.com", "mitarbeiter")
|
||||||
|
fs.werkzeuge["werkzeug-ok"] = store.Werkzeug{ID: "werkzeug-ok", Name: "ChatGPT Enterprise", AVVVerfuegbar: true, TrainingStandard: true}
|
||||||
|
s := newServer(t, fs)
|
||||||
|
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")
|
||||||
|
|
||||||
|
// Ohne werkzeug_id: abgelehnt.
|
||||||
|
resp := postForm(t, s, verantwortlicherCookie, "/faelle/"+antragID+"/entscheiden", url.Values{"entscheidung": {"genehmigt"}})
|
||||||
|
if resp.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d, want 400 ohne Werkzeug bei Genehmigung", resp.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mit zulässigem Werkzeug: erfolgreich, Antrag wird "entschieden".
|
||||||
|
resp = postForm(t, s, verantwortlicherCookie, "/faelle/"+antragID+"/entscheiden", url.Values{
|
||||||
|
"entscheidung": {"genehmigt"}, "werkzeug_id": {"werkzeug-ok"},
|
||||||
|
})
|
||||||
|
if resp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
antrag, err := fs.GetAntrag(context.Background(), antragID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetAntrag: %v", err)
|
||||||
|
}
|
||||||
|
if antrag.Status != "entschieden" {
|
||||||
|
t.Errorf("Status = %q, want entschieden", antrag.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
detail := getWithCookie(t, s, verantwortlicherCookie, "/faelle/"+antragID)
|
||||||
|
if !strings.Contains(detail.Body.String(), "ChatGPT Enterprise") {
|
||||||
|
t.Errorf("expected the chosen werkzeug name on the fall-detail page, got: %s", detail.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFallDetailRejectsForeignAccount(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
antragID, _ := seedFallImAccount(t, fs, s, "verantwortlicher")
|
||||||
|
|
||||||
|
otherAcc, err := fs.CreateAccount(context.Background(), "Fremder Mandant")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateAccount: %v", err)
|
||||||
|
}
|
||||||
|
otherCookie := seedUserInAccount(t, fs, otherAcc.ID, "fremd@example.com", "verantwortlicher")
|
||||||
|
|
||||||
|
resp := getWithCookie(t, s, otherCookie, "/faelle/"+antragID)
|
||||||
|
if resp.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("status = %d, want 404 for a foreign account's fall", resp.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -89,19 +89,44 @@ func (s *Server) requireBetreiber(next http.HandlerFunc) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// requireFachebene schützt die Fachebene (Ebene 3 — Posteingang und
|
||||||
|
// Entscheiden, Rollen "verantwortlicher" und "pruefer", siehe
|
||||||
|
// CLAUDE.md). Beide Rollen sehen dieselbe Sicht; ob innerhalb der
|
||||||
|
// Seite tatsächlich entschieden werden darf, prüft der einzelne
|
||||||
|
// Handler (nur "verantwortlicher" hat Entscheidungsrecht). Wie bei
|
||||||
|
// requireBetreiber: 404 statt 403 bei falscher Rolle, damit ein
|
||||||
|
// angemeldeter, aber unprivilegierter Nutzer (z. B. "mitarbeiter")
|
||||||
|
// nicht erfährt, dass es die Seite überhaupt gibt.
|
||||||
|
func (s *Server) requireFachebene(next http.HandlerFunc) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, ok := s.authenticate(r)
|
||||||
|
if !ok {
|
||||||
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if user.Role != "verantwortlicher" && user.Role != "pruefer" {
|
||||||
|
http.Error(w, "nicht gefunden", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next(w, r.WithContext(context.WithValue(r.Context(), userContextKey, user)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// navData steuert die gemeinsame Navigation (layout.html, "nav"-Block).
|
// navData steuert die gemeinsame Navigation (layout.html, "nav"-Block).
|
||||||
// Eigenes, kleines Struct statt jeder Seite Zugriff auf den vollen
|
// Eigenes, kleines Struct statt jeder Seite Zugriff auf den vollen
|
||||||
// currentUser zu geben — die Navigation braucht nur, ob ein
|
// currentUser zu geben — die Navigation braucht nur, ob ein
|
||||||
// Plattform-Link gezeigt werden soll.
|
// Plattform-Link gezeigt werden soll.
|
||||||
type navData struct {
|
type navData struct {
|
||||||
IsBetreiber bool
|
IsBetreiber bool
|
||||||
|
IsFachebene bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// navFor liefert die Nav-Daten für den angemeldeten Nutzer der Anfrage.
|
// navFor liefert die Nav-Daten für den angemeldeten Nutzer der Anfrage.
|
||||||
// Nur für Seiten hinter requirePage/requireBetreiber aufrufbar (braucht
|
// Nur für Seiten hinter requirePage/requireBetreiber aufrufbar (braucht
|
||||||
// currentUser).
|
// currentUser).
|
||||||
func navFor(r *http.Request) navData {
|
func navFor(r *http.Request) navData {
|
||||||
return navData{IsBetreiber: currentUser(r).Role == "betreiber"}
|
role := currentUser(r).Role
|
||||||
|
return navData{IsBetreiber: role == "betreiber", IsFachebene: role == "verantwortlicher" || role == "pruefer"}
|
||||||
}
|
}
|
||||||
|
|
||||||
// currentUser liest den Nutzer, den requirePage/requireAPI in den
|
// currentUser liest den Nutzer, den requirePage/requireAPI in den
|
||||||
|
|||||||
@@ -47,12 +47,16 @@ type Store interface {
|
|||||||
UpdateAntragFelder(ctx context.Context, id, titel, beschreibung, ergebnis, haeufigkeit string, antworten []byte) (store.Antrag, error)
|
UpdateAntragFelder(ctx context.Context, id, titel, beschreibung, ergebnis, haeufigkeit string, antworten []byte) (store.Antrag, error)
|
||||||
SetAntragStatus(ctx context.Context, id, status string) error
|
SetAntragStatus(ctx context.Context, id, status string) error
|
||||||
ListAntraegeForUser(ctx context.Context, erstellerUserID string) ([]store.Antrag, error)
|
ListAntraegeForUser(ctx context.Context, erstellerUserID string) ([]store.Antrag, error)
|
||||||
|
ListAntraegeForAccount(ctx context.Context, accountID string) ([]store.Antrag, error)
|
||||||
|
|
||||||
ListWerkzeugeForAccount(ctx context.Context, accountID string) ([]store.Werkzeug, error)
|
ListWerkzeugeForAccount(ctx context.Context, accountID string) ([]store.Werkzeug, error)
|
||||||
GetWerkzeug(ctx context.Context, id string) (store.Werkzeug, error)
|
GetWerkzeug(ctx context.Context, id string) (store.Werkzeug, error)
|
||||||
CurrentKatalogVersion(ctx context.Context) (string, error)
|
CurrentKatalogVersion(ctx context.Context) (string, error)
|
||||||
CreateBewertung(ctx context.Context, in store.BewertungInput) (store.Bewertung, error)
|
CreateBewertung(ctx context.Context, in store.BewertungInput) (store.Bewertung, error)
|
||||||
GetLatestBewertungForAntrag(ctx context.Context, antragID string) (store.Bewertung, error)
|
GetLatestBewertungForAntrag(ctx context.Context, antragID string) (store.Bewertung, error)
|
||||||
|
|
||||||
|
CreateEntscheidung(ctx context.Context, in store.EntscheidungInput) (store.Entscheidung, error)
|
||||||
|
GetLatestEntscheidungForAntrag(ctx context.Context, antragID string) (store.Entscheidung, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Regelwerk bündelt die drei geladenen Regelwerk-Dateien, die die
|
// Regelwerk bündelt die drei geladenen Regelwerk-Dateien, die die
|
||||||
@@ -99,6 +103,9 @@ func NewServer(st Store, regelwerk Regelwerk) (*Server, error) {
|
|||||||
mux.HandleFunc("GET /antraege/neu", s.requirePage(s.handleAntragNewForm))
|
mux.HandleFunc("GET /antraege/neu", s.requirePage(s.handleAntragNewForm))
|
||||||
mux.HandleFunc("POST /antraege", s.requirePage(s.handleAntragCreate))
|
mux.HandleFunc("POST /antraege", s.requirePage(s.handleAntragCreate))
|
||||||
mux.HandleFunc("GET /antraege/{id}", s.requirePage(s.handleAntragDetail))
|
mux.HandleFunc("GET /antraege/{id}", s.requirePage(s.handleAntragDetail))
|
||||||
|
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 /betreiber", s.requireBetreiber(s.handleBetreiberDashboard))
|
mux.HandleFunc("GET /betreiber", s.requireBetreiber(s.handleBetreiberDashboard))
|
||||||
mux.HandleFunc("GET /betreiber/accounts", s.requireBetreiber(s.handleBetreiberAccountList))
|
mux.HandleFunc("GET /betreiber/accounts", s.requireBetreiber(s.handleBetreiberAccountList))
|
||||||
mux.HandleFunc("GET /betreiber/accounts/{id}", s.requireBetreiber(s.handleBetreiberAccountDetail))
|
mux.HandleFunc("GET /betreiber/accounts/{id}", s.requireBetreiber(s.handleBetreiberAccountDetail))
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ type fakeStore struct {
|
|||||||
antraege map[string]store.Antrag
|
antraege map[string]store.Antrag
|
||||||
werkzeuge map[string]store.Werkzeug
|
werkzeuge map[string]store.Werkzeug
|
||||||
bewertungen map[string][]store.Bewertung // antragID -> Bewertungen, älteste zuerst
|
bewertungen map[string][]store.Bewertung // antragID -> Bewertungen, älteste zuerst
|
||||||
|
entscheidungen map[string][]store.Entscheidung // antragID -> Entscheidungen, älteste zuerst
|
||||||
}
|
}
|
||||||
|
|
||||||
func newFakeStore() *fakeStore {
|
func newFakeStore() *fakeStore {
|
||||||
@@ -47,6 +48,7 @@ func newFakeStore() *fakeStore {
|
|||||||
antraege: map[string]store.Antrag{},
|
antraege: map[string]store.Antrag{},
|
||||||
werkzeuge: map[string]store.Werkzeug{},
|
werkzeuge: map[string]store.Werkzeug{},
|
||||||
bewertungen: map[string][]store.Bewertung{},
|
bewertungen: map[string][]store.Bewertung{},
|
||||||
|
entscheidungen: map[string][]store.Entscheidung{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,6 +245,18 @@ func (f *fakeStore) ListAntraegeForUser(ctx context.Context, erstellerUserID str
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) ListAntraegeForAccount(ctx context.Context, accountID string) ([]store.Antrag, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
var out []store.Antrag
|
||||||
|
for _, a := range f.antraege {
|
||||||
|
if a.AccountID == accountID {
|
||||||
|
out = append(out, a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (f *fakeStore) ListWerkzeugeForAccount(ctx context.Context, accountID string) ([]store.Werkzeug, error) {
|
func (f *fakeStore) ListWerkzeugeForAccount(ctx context.Context, accountID string) ([]store.Werkzeug, error) {
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
defer f.mu.Unlock()
|
defer f.mu.Unlock()
|
||||||
@@ -297,6 +311,28 @@ func (f *fakeStore) GetLatestBewertungForAntrag(ctx context.Context, antragID st
|
|||||||
return bs[len(bs)-1], nil
|
return bs[len(bs)-1], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) CreateEntscheidung(ctx context.Context, in store.EntscheidungInput) (store.Entscheidung, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
e := store.Entscheidung{
|
||||||
|
ID: f.newID(), AntragID: in.AntragID, BewertungID: in.BewertungID, EntscheiderUserID: in.EntscheiderUserID,
|
||||||
|
Entscheidung: in.Entscheidung, WerkzeugID: in.WerkzeugID, WerkzeugSnapshot: in.WerkzeugSnapshot,
|
||||||
|
Begruendung: in.Begruendung, GueltigBis: in.GueltigBis, CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
f.entscheidungen[in.AntragID] = append(f.entscheidungen[in.AntragID], e)
|
||||||
|
return e, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) GetLatestEntscheidungForAntrag(ctx context.Context, antragID string) (store.Entscheidung, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
es := f.entscheidungen[antragID]
|
||||||
|
if len(es) == 0 {
|
||||||
|
return store.Entscheidung{}, store.ErrNotFound
|
||||||
|
}
|
||||||
|
return es[len(es)-1], nil
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Test-Setup ───────────────────────────────────────────────────────
|
// ─── Test-Setup ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
// loadTestRegelwerk lädt die echten rules/*.yaml-Dateien — dieselben,
|
// loadTestRegelwerk lädt die echten rules/*.yaml-Dateien — dieselben,
|
||||||
@@ -358,6 +394,31 @@ func seedAccountWithRole(t *testing.T, fs *fakeStore, accountName, email, role s
|
|||||||
return &http.Cookie{Name: testSessionCookie, Value: token}
|
return &http.Cookie{Name: testSessionCookie, Value: token}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// seedUserInAccount legt einen weiteren Nutzer in einem bereits
|
||||||
|
// existierenden Account an — für Tests, die mehrere Rollen im selben
|
||||||
|
// Mandanten brauchen (z. B. mitarbeiter stellt einen Antrag,
|
||||||
|
// verantwortlicher entscheidet darüber).
|
||||||
|
func seedUserInAccount(t *testing.T, fs *fakeStore, accountID, email, role string) *http.Cookie {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
hash, err := auth.HashPassword("test-passwort-123")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("HashPassword: %v", err)
|
||||||
|
}
|
||||||
|
user, err := fs.CreateUser(ctx, accountID, email, hash, role)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateUser: %v", err)
|
||||||
|
}
|
||||||
|
token, err := auth.NewSessionToken()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSessionToken: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := fs.CreateSession(ctx, token, user.ID, time.Now().Add(time.Hour)); err != nil {
|
||||||
|
t.Fatalf("CreateSession: %v", err)
|
||||||
|
}
|
||||||
|
return &http.Cookie{Name: testSessionCookie, Value: token}
|
||||||
|
}
|
||||||
|
|
||||||
func seedAccount(t *testing.T, fs *fakeStore, accountName, email string) *http.Cookie {
|
func seedAccount(t *testing.T, fs *fakeStore, accountName, email string) *http.Cookie {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
return seedAccountWithRole(t, fs, accountName, email, "mitarbeiter")
|
return seedAccountWithRole(t, fs, accountName, email, "mitarbeiter")
|
||||||
|
|||||||
@@ -14,60 +14,7 @@
|
|||||||
<p><strong>Häufigkeit:</strong> {{.Haeufigkeit}}</p>
|
<p><strong>Häufigkeit:</strong> {{.Haeufigkeit}}</p>
|
||||||
|
|
||||||
{{if .Bewertung}}
|
{{if .Bewertung}}
|
||||||
<h2>Bewertung</h2>
|
{{template "bewertung-block" .Bewertung}}
|
||||||
|
|
||||||
{{if .Bewertung.Verboten}}
|
|
||||||
<p class="fehler"><strong>Verboten.</strong> Diese Nutzung fällt laut Einstufung unter ein verbotenes KI-Praktiken-Muster der KI-VO ({{.Bewertung.Einstufung}}). {{.Bewertung.EinstufungHerleitung}}</p>
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
<p><strong>Datenklasse:</strong> {{.Bewertung.Datenklasse}}<br>
|
|
||||||
<span class="hinweis">{{.Bewertung.DatenklasseHerleitung}}</span></p>
|
|
||||||
|
|
||||||
<p><strong>KI-VO-Einstufung:</strong> {{.Bewertung.Einstufung}}<br>
|
|
||||||
<span class="hinweis">{{.Bewertung.EinstufungHerleitung}}</span></p>
|
|
||||||
|
|
||||||
{{if .Bewertung.Anforderungen}}
|
|
||||||
<h3>Anforderungen</h3>
|
|
||||||
<ul class="findings">
|
|
||||||
{{range .Bewertung.Anforderungen}}
|
|
||||||
<li class="finding finding-mittel">
|
|
||||||
<strong>{{.Beschreibung}}</strong>
|
|
||||||
<p>{{.Herleitung}}</p>
|
|
||||||
</li>
|
|
||||||
{{end}}
|
|
||||||
</ul>
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
{{if not .Bewertung.Verboten}}
|
|
||||||
<h3>Zulässige Werkzeuge</h3>
|
|
||||||
{{if .Bewertung.ZulaessigeWerkzeuge}}
|
|
||||||
<ul>
|
|
||||||
{{range .Bewertung.ZulaessigeWerkzeuge}}<li>{{.}}</li>{{end}}
|
|
||||||
</ul>
|
|
||||||
{{else}}
|
|
||||||
<p class="rueckfrage">Kein Werkzeug im Katalog erfüllt aktuell alle Anforderungen dieses Antrags.</p>
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
{{if .Bewertung.AusgeschlosseneWerkzeuge}}
|
|
||||||
<h3>Ausgeschlossene Werkzeuge</h3>
|
|
||||||
<ul class="findings">
|
|
||||||
{{range .Bewertung.AusgeschlosseneWerkzeuge}}
|
|
||||||
<li class="finding finding-niedrig">
|
|
||||||
<strong>{{.Name}}</strong>
|
|
||||||
<p>Nicht erfüllt: {{range $i, $a := .NichtErfuellt}}{{if $i}}, {{end}}{{$a}}{{end}}</p>
|
|
||||||
</li>
|
|
||||||
{{end}}
|
|
||||||
</ul>
|
|
||||||
{{end}}
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
<p class="disclaimer">
|
|
||||||
Das System entscheidet nicht. Es bereitet vor. Diese Einstufung ist ein
|
|
||||||
Vorschlag zur Herleitung von Datenklasse, KI-VO-Einstufung und
|
|
||||||
Anforderungen (Regelwerk {{.Bewertung.RegelwerkVersion}}, Katalog
|
|
||||||
{{.Bewertung.KatalogVersion}}, berechnet am {{.Bewertung.CreatedAt}}) —
|
|
||||||
die Entscheidung trifft ein Mensch.
|
|
||||||
</p>
|
|
||||||
{{else}}
|
{{else}}
|
||||||
<p class="hinweis">Die Bewertung wird berechnet, sobald der Antrag eingereicht ist.</p>
|
<p class="hinweis">Die Bewertung wird berechnet, sobald der Antrag eingereicht ist.</p>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
24
internal/web/templates/faelle_liste.html
Normal file
24
internal/web/templates/faelle_liste.html
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{{define "faelle-liste"}}<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>{{template "head" .}}</head>
|
||||||
|
<body>
|
||||||
|
{{template "nav" .Nav}}
|
||||||
|
<div class="page">
|
||||||
|
<h1>Posteingang</h1>
|
||||||
|
{{if not .Faelle}}
|
||||||
|
<p class="keine-findings">Keine offenen Anträge — alles bearbeitet.</p>
|
||||||
|
{{else}}
|
||||||
|
<ul class="beitraege-liste">
|
||||||
|
{{range .Faelle}}
|
||||||
|
<li>
|
||||||
|
<a href="/faelle/{{.ID}}">
|
||||||
|
<strong>{{.Titel}}</strong> · {{.Haeufigkeit}} · eingereicht am {{.CreatedAt}}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
60
internal/web/templates/fall_detail.html
Normal file
60
internal/web/templates/fall_detail.html
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
{{define "fall-detail"}}<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>{{template "head" .}}</head>
|
||||||
|
<body>
|
||||||
|
{{template "nav" .Nav}}
|
||||||
|
<div class="page">
|
||||||
|
<p><a href="/faelle">← Posteingang</a></p>
|
||||||
|
<h1>{{.Titel}}</h1>
|
||||||
|
<p class="hinweis">Eingereicht am {{.CreatedAt}} — Status: <span class="status status-{{.Status}}">{{.Status}}</span></p>
|
||||||
|
|
||||||
|
<h2>Vorhaben</h2>
|
||||||
|
<p><strong>Was soll die KI tun?</strong><br>{{.Beschreibung}}</p>
|
||||||
|
<p><strong>Was soll herauskommen?</strong><br>{{.Ergebnis}}</p>
|
||||||
|
<p><strong>Häufigkeit:</strong> {{.Haeufigkeit}}</p>
|
||||||
|
|
||||||
|
{{if .Bewertung}}
|
||||||
|
{{template "bewertung-block" .Bewertung}}
|
||||||
|
{{else}}
|
||||||
|
<p class="hinweis">Für diesen Antrag liegt noch keine Bewertung vor.</p>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .Entscheidung}}
|
||||||
|
<h2>Entscheidung</h2>
|
||||||
|
<p><strong>{{.Entscheidung.Entscheidung}}</strong>{{if .Entscheidung.WerkzeugName}} — {{.Entscheidung.WerkzeugName}}{{end}}<br>
|
||||||
|
<span class="hinweis">am {{.Entscheidung.CreatedAt}}{{if .Entscheidung.GueltigBis}}, gültig bis {{.Entscheidung.GueltigBis}}{{end}}</span></p>
|
||||||
|
{{if .Entscheidung.Begruendung}}<p>{{.Entscheidung.Begruendung}}</p>{{end}}
|
||||||
|
{{else if .CanEntscheiden}}
|
||||||
|
<h2>Entscheiden</h2>
|
||||||
|
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
|
||||||
|
<form method="post" action="/faelle/{{.ID}}/entscheiden">
|
||||||
|
<fieldset>
|
||||||
|
<legend>Entscheidung</legend>
|
||||||
|
<label><input type="radio" name="entscheidung" value="genehmigt" required> Genehmigt</label>
|
||||||
|
<label><input type="radio" name="entscheidung" value="genehmigt_mit_auflagen"> Genehmigt mit Auflagen</label>
|
||||||
|
<label><input type="radio" name="entscheidung" value="abgelehnt"> Abgelehnt</label>
|
||||||
|
<label><input type="radio" name="entscheidung" value="rueckfrage"> Rückfrage</label>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
{{if .WerkzeugOptionen}}
|
||||||
|
<label>Werkzeug (bei Genehmigung erforderlich)
|
||||||
|
<select name="werkzeug_id">
|
||||||
|
<option value="">— auswählen —</option>
|
||||||
|
{{range .WerkzeugOptionen}}<option value="{{.ID}}">{{.Name}}</option>{{end}}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<label>Begründung (Pflicht bei Abweichung vom Vorschlag)
|
||||||
|
<textarea name="begruendung" rows="3"></textarea>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button type="submit">Entscheidung speichern</button>
|
||||||
|
</form>
|
||||||
|
{{else}}
|
||||||
|
<p class="hinweis">Noch keine Entscheidung getroffen. Nur die Rolle "Verantwortlicher" kann entscheiden.</p>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
@@ -10,9 +10,72 @@
|
|||||||
<nav>
|
<nav>
|
||||||
<a href="/">Start</a>
|
<a href="/">Start</a>
|
||||||
{{if not .IsBetreiber}}<a href="/antraege">Meine Anträge</a>{{end}}
|
{{if not .IsBetreiber}}<a href="/antraege">Meine Anträge</a>{{end}}
|
||||||
|
{{if .IsFachebene}}<a href="/faelle">Posteingang</a>{{end}}
|
||||||
{{if .IsBetreiber}}<a href="/betreiber">Plattform</a>{{end}}
|
{{if .IsBetreiber}}<a href="/betreiber">Plattform</a>{{end}}
|
||||||
<form method="post" action="/logout" style="display:inline">
|
<form method="post" action="/logout" style="display:inline">
|
||||||
<button type="submit">Abmelden</button>
|
<button type="submit">Abmelden</button>
|
||||||
</form>
|
</form>
|
||||||
</nav>
|
</nav>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
{{/* bewertung-block rendert eine Bewertung (Datenklasse, KI-VO-
|
||||||
|
Einstufung, Anforderungen, Werkzeuge, Pflicht-Disclaimer) — gemeinsam
|
||||||
|
genutzt von antrag_detail.html (Ebene 2, nur Ansicht) und
|
||||||
|
fall_detail.html (Ebene 3, mit Entscheiden-Formular). Erwartet eine
|
||||||
|
*bewertungView als . */}}
|
||||||
|
{{define "bewertung-block"}}
|
||||||
|
<h2>Bewertung</h2>
|
||||||
|
|
||||||
|
{{if .Verboten}}
|
||||||
|
<p class="fehler"><strong>Verboten.</strong> Diese Nutzung fällt laut Einstufung unter ein verbotenes KI-Praktiken-Muster der KI-VO ({{.Einstufung}}). {{.EinstufungHerleitung}}</p>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<p><strong>Datenklasse:</strong> {{.Datenklasse}}<br>
|
||||||
|
<span class="hinweis">{{.DatenklasseHerleitung}}</span></p>
|
||||||
|
|
||||||
|
<p><strong>KI-VO-Einstufung:</strong> {{.Einstufung}}<br>
|
||||||
|
<span class="hinweis">{{.EinstufungHerleitung}}</span></p>
|
||||||
|
|
||||||
|
{{if .Anforderungen}}
|
||||||
|
<h3>Anforderungen</h3>
|
||||||
|
<ul class="findings">
|
||||||
|
{{range .Anforderungen}}
|
||||||
|
<li class="finding finding-mittel">
|
||||||
|
<strong>{{.Beschreibung}}</strong>
|
||||||
|
<p>{{.Herleitung}}</p>
|
||||||
|
</li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if not .Verboten}}
|
||||||
|
<h3>Zulässige Werkzeuge</h3>
|
||||||
|
{{if .ZulaessigeWerkzeuge}}
|
||||||
|
<ul>
|
||||||
|
{{range .ZulaessigeWerkzeuge}}<li>{{.}}</li>{{end}}
|
||||||
|
</ul>
|
||||||
|
{{else}}
|
||||||
|
<p class="rueckfrage">Kein Werkzeug im Katalog erfüllt aktuell alle Anforderungen dieses Antrags.</p>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .AusgeschlosseneWerkzeuge}}
|
||||||
|
<h3>Ausgeschlossene Werkzeuge</h3>
|
||||||
|
<ul class="findings">
|
||||||
|
{{range .AusgeschlosseneWerkzeuge}}
|
||||||
|
<li class="finding finding-niedrig">
|
||||||
|
<strong>{{.Name}}</strong>
|
||||||
|
<p>Nicht erfüllt: {{range $i, $a := .NichtErfuellt}}{{if $i}}, {{end}}{{$a}}{{end}}</p>
|
||||||
|
</li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<p class="disclaimer">
|
||||||
|
Das System entscheidet nicht. Es bereitet vor. Diese Einstufung ist ein
|
||||||
|
Vorschlag zur Herleitung von Datenklasse, KI-VO-Einstufung und
|
||||||
|
Anforderungen (Regelwerk {{.RegelwerkVersion}}, Katalog
|
||||||
|
{{.KatalogVersion}}, berechnet am {{.CreatedAt}}) —
|
||||||
|
die Entscheidung trifft ein Mensch.
|
||||||
|
</p>
|
||||||
|
{{end}}
|
||||||
|
|||||||
Reference in New Issue
Block a user