Files
archivdms/internal/api/compliance_handlers.go
patrick 9a24ea29e1 FDN-01: repository & projektgerüst
Git-Repository für bestehenden archivdms-Code initialisiert, Branch-/Commit-Konvention (feature/<ticket>-<slug>-Branches, Ticket-Prefix in Commit-Nachricht) etabliert.
2026-08-11 21:27:53 +02:00

536 lines
24 KiB
Go

// GoBD-Verfahrensdokumentation: Entwurfs-Generator.
//
// GET /api/compliance/procedure-documentation[?tenant_id=N]
//
// Erzeugt live aus dem aktuellen DB-Stand einen Markdown-Baustein einer
// GoBD-Verfahrensdokumentation für GENAU EINEN Mandanten (kein Caching, kein
// Vermischen mehrerer Mandanten). Konzept/Gliederung siehe
// .claude/agent-memory/retention-compliance/project_gobd_verfahrensdokumentation.md
//
// Auth (Muster wie retention_rule_handlers.go): domain_admin+ (s.authAdmin) für
// den EIGENEN Mandanten. Ein superadmin darf zusätzlich per ?tenant_id=N einen
// fremden Mandanten exportieren; für alle anderen Rollen ist ein abweichender
// tenant_id-Parameter ein 403. Sämtliche Queries sind strikt auf die eine
// aufgelöste tenant_id gefiltert (applikationsseitige Mandantentrennung, kein
// Postgres-RLS).
//
// Das Ergebnis ist ausdrücklich ein ENTWURF und kein rechtsverbindliches
// Fertigdokument — der Hinweis steht als erste Zeile im Dokument.
package api
import (
"context"
"fmt"
"net/http"
"sort"
"strconv"
"strings"
"time"
"archivdms/internal/audit"
"archivdms/internal/storage"
"archivdms/internal/userstore"
)
// handleProcedureDocumentation handles GET /api/compliance/procedure-documentation.
func (s *Server) handleProcedureDocumentation(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil && sess.Role != userstore.RoleSuperAdmin {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
// Ziel-Mandant auflösen: Default ist der eigene Mandant. Ein expliziter
// ?tenant_id= ist nur für superadmin erlaubt (Cross-Tenant), für alle
// anderen nur, wenn er dem eigenen Mandanten entspricht.
tenantID := int64(0)
if sess.TenantID != nil {
tenantID = *sess.TenantID
}
if raw := strings.TrimSpace(r.URL.Query().Get("tenant_id")); raw != "" {
requested, err := strconv.ParseInt(raw, 10, 64)
if err != nil || requested <= 0 {
writeError(w, http.StatusBadRequest, "invalid tenant_id")
return
}
if sess.Role != userstore.RoleSuperAdmin && requested != tenantID {
s.audlog.Log(audit.Entry{
EventType: audit.EventComplianceExport, Username: sess.Username, TenantID: sess.TenantID,
Success: false, Detail: "procedure_doc cross-tenant denied requested:" + raw,
})
writeError(w, http.StatusForbidden, "cross-tenant export requires superadmin")
return
}
tenantID = requested
}
if tenantID <= 0 {
writeError(w, http.StatusBadRequest, "tenant_id required")
return
}
tenantName := "Mandant " + strconv.FormatInt(tenantID, 10)
tenantSlug := strconv.FormatInt(tenantID, 10)
if s.tenantStore != nil {
t, err := s.tenantStore.GetByID(r.Context(), tenantID)
if err != nil || t == nil {
s.audlog.Log(audit.Entry{
EventType: audit.EventComplianceExport, Username: sess.Username, TenantID: sess.TenantID,
Success: false, Detail: "procedure_doc tenant lookup failed tenant:" + strconv.FormatInt(tenantID, 10),
})
writeError(w, http.StatusNotFound, "tenant not found")
return
}
tenantName = t.Name
if t.Slug != "" {
tenantSlug = t.Slug
}
}
md, err := s.buildProcedureDocumentation(r.Context(), tenantID, tenantName)
if err != nil {
s.audlog.Log(audit.Entry{
EventType: audit.EventComplianceExport, Username: sess.Username, TenantID: sess.TenantID,
Success: false, Detail: "procedure_doc tenant:" + strconv.FormatInt(tenantID, 10) + " err:" + err.Error(),
})
writeError(w, http.StatusInternalServerError, "generate procedure documentation failed")
return
}
s.audlog.Log(audit.Entry{
EventType: audit.EventComplianceExport, Username: sess.Username, TenantID: sess.TenantID,
Success: true, Detail: "procedure_doc generated tenant:" + strconv.FormatInt(tenantID, 10),
})
filename := fmt.Sprintf("verfahrensdokumentation-entwurf-%s-%s.md",
safeFilenamePart(tenantSlug), time.Now().Format("2006-01-02"))
w.Header().Set("Content-Type", "text/markdown; charset=utf-8")
w.Header().Set("Content-Disposition", `attachment; filename="`+filename+`"`)
w.Header().Set("X-Content-Type-Options", "nosniff")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(md))
}
// safeFilenamePart reduces a tenant slug to [a-z0-9-] so it can never break out
// of the Content-Disposition filename.
func safeFilenamePart(in string) string {
var b strings.Builder
for _, r := range strings.ToLower(in) {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
b.WriteRune(r)
case r == '-' || r == '_':
b.WriteRune('-')
}
}
out := b.String()
if out == "" {
return "mandant"
}
if len(out) > 60 {
out = out[:60]
}
return out
}
// buildProcedureDocumentation assembles the Markdown draft for one tenant.
// Every store call below is tenant-scoped; nothing is queried tenant-wide.
func (s *Server) buildProcedureDocumentation(ctx context.Context, tenantID int64, tenantName string) (string, error) {
now := time.Now()
rules, err := s.store.ListRetentionRules(ctx, tenantID)
if err != nil {
return "", fmt.Errorf("list retention rules: %w", err)
}
docTypes, err := s.store.ListTaxonomyEntities(ctx, "document_types", tenantID)
if err != nil {
return "", fmt.Errorf("list document types: %w", err)
}
tags, err := s.store.ListTaxonomyEntities(ctx, "tags", tenantID)
if err != nil {
return "", fmt.Errorf("list tags: %w", err)
}
groups, err := s.store.ListPermissionGroups(ctx, tenantID)
if err != nil {
return "", fmt.Errorf("list permission groups: %w", err)
}
workflows, err := s.store.ListWorkflows(ctx, tenantID)
if err != nil {
return "", fmt.Errorf("list workflows: %w", err)
}
templates, err := s.store.ListTemplates(ctx, tenantID, nil)
if err != nil {
return "", fmt.Errorf("list classification templates: %w", err)
}
stats, err := s.store.ComplianceStatsForTenant(ctx, tenantID)
if err != nil {
return "", fmt.Errorf("compliance stats: %w", err)
}
docTypeName := map[int64]string{}
for _, dt := range docTypes {
docTypeName[dt.ID] = dt.Name
}
var b strings.Builder
// --- Kopf / Entwurfskennzeichnung -----------------------------------
b.WriteString("> **ENTWURF** — automatisch generierter Baustein einer GoBD-Verfahrensdokumentation, Stand " +
now.Format("02.01.2006 15:04:05 MST") + ".\n" +
"> Ersetzt keine rechtliche Prüfung, muss um Organisationsbeschreibung/Verantwortlichkeiten/Backup-Notfallkonzept " +
"ergänzt und von fachkundiger Stelle geprüft werden.\n\n")
b.WriteString("# Verfahrensdokumentation (Entwurf) — " + tenantName + "\n\n")
b.WriteString("| | |\n|---|---|\n")
b.WriteString("| Mandant | " + mdCell(tenantName) + " |\n")
b.WriteString("| Mandanten-ID | " + strconv.FormatInt(tenantID, 10) + " |\n")
b.WriteString("| Stand der Generierung | " + now.Format("02.01.2006 15:04:05 MST") + " |\n")
b.WriteString("| System | archivdms (Dokumentenmanagementsystem) |\n")
b.WriteString("| Aktive Dokumente | " + strconv.FormatInt(stats.Documents, 10) + " |\n")
b.WriteString("| Davon mit Aufbewahrungsfrist (retain_until) | " + strconv.FormatInt(stats.DocumentsWithRetain, 10) + " |\n")
b.WriteString("| Dokumente im Papierkorb | " + strconv.FormatInt(stats.DocumentsInTrash, 10) + " |\n\n")
b.WriteString("Dieses Dokument beschreibt ausschließlich die im System hinterlegte Konfiguration des oben " +
"genannten Mandanten. Daten anderer Mandanten sind nicht enthalten (mandantengetrennte Auswertung).\n\n")
// --- 1. Aufbewahrungsfristen ----------------------------------------
b.WriteString("## 1. Aufbewahrungsfristen\n\n")
b.WriteString("Aufbewahrungsfristen werden als Regeln je Dokumenttyp gepflegt. Eine Regel ohne Dokumenttyp " +
"gilt als Mandanten-Default mit niedrigster Präzedenz; eine dokumenttyp-spezifische Regel hat Vorrang. " +
"Aus Fristbeginn (Trigger) und Frist berechnet das System je Dokument ein Datum `retain_until`; " +
"bis zu diesem Datum ist eine endgültige Löschung technisch blockiert.\n\n")
b.WriteString("Fristbeginn (Trigger-Typen): `document_date` = Belegdatum (ersatzweise Uploaddatum), " +
"`upload_date` = Uploaddatum, `fixed_date` = fixes Stichtagsdatum, " +
"`event` = ereignisgesteuert (wird nicht automatisch berechnet, erfordert manuelle Fristsetzung).\n\n")
if len(rules) == 0 {
b.WriteString("**Es sind derzeit keine Aufbewahrungsregeln konfiguriert.** [MANUELL ZU ERGÄNZEN: " +
"gesetzliche Fristen (z. B. § 147 AO, § 257 HGB) je Dokumentart benennen und im System hinterlegen.]\n\n")
} else {
b.WriteString("| Regel | Geltungsbereich | Fristbeginn | Frist | Rechtsgrundlage | Löschfreigabe nötig | DSGVO-Konflikt | Aktiv |\n")
b.WriteString("|---|---|---|---|---|---|---|---|\n")
for _, ru := range rules {
scope := "Mandanten-Default (alle Dokumenttypen ohne eigene Regel)"
if ru.DocTypeID != nil {
if n, ok := docTypeName[*ru.DocTypeID]; ok {
scope = "Dokumenttyp: " + n
} else {
scope = "Dokumenttyp-ID " + strconv.FormatInt(*ru.DocTypeID, 10)
}
}
trigger := ru.TriggerType
if ru.TriggerReference != "" {
trigger += " (" + ru.TriggerReference + ")"
}
b.WriteString("| " + mdCell(ru.Name) + " | " + mdCell(scope) + " | " + mdCell(trigger) + " | " +
mdCell(retentionPeriodText(ru)) + " | " + mdCell(orDash(ru.LegalBasis)) + " | " +
jaNein(ru.RequiresApprovalForDestroy) + " | " + jaNein(ru.DSGVOConflict) + " | " +
jaNein(ru.Active) + " |\n")
}
b.WriteString("\n")
}
b.WriteString("[MANUELL ZU ERGÄNZEN: Prüfung, ob die hinterlegten Fristen den für dieses Unternehmen " +
"einschlägigen handels- und steuerrechtlichen Vorgaben entsprechen.]\n\n")
// --- 2. Zugriffsschutz ----------------------------------------------
b.WriteString("## 2. Zugriffsschutz und Berechtigungen\n\n")
b.WriteString("### 2.1 Rollenmodell\n\n")
b.WriteString("- `superadmin` — mandantenübergreifende Systemverwaltung (Anlage von Mandanten).\n")
b.WriteString("- `domain_admin` — Administration innerhalb des eigenen Mandanten: Benutzer, Berechtigungsgruppen, " +
"Aufbewahrungsregeln, Workflows, Klassifizierungsvorlagen, Bestätigung endgültiger Löschungen.\n")
b.WriteString("- `user` — Erfassen, Suchen und Bearbeiten von Dokumenten im Rahmen der erteilten Berechtigungen.\n\n")
b.WriteString("Die Anmeldung erfolgt passwortbasiert (bcrypt-Hash, Kostenfaktor 12) bzw. optional gegen ein " +
"Verzeichnis (LDAP); die Sitzung wird über ein signiertes, nicht per JavaScript auslesbares Sitzungs-Cookie " +
"geführt. Mandantentrennung erfolgt applikationsseitig: jede Datenbankabfrage ist auf den Mandanten des " +
"angemeldeten Benutzers eingeschränkt.\n\n")
if s.users != nil {
users, err := s.users.ListByTenant(ctx, tenantID)
if err != nil {
return "", fmt.Errorf("list users: %w", err)
}
roleCount := map[string]int{}
for _, u := range users {
if !u.Active {
roleCount["(inaktiv)"]++
continue
}
roleCount[u.Role]++
}
keys := make([]string, 0, len(roleCount))
for k := range roleCount {
keys = append(keys, k)
}
sort.Strings(keys)
b.WriteString("Benutzerbestand dieses Mandanten: ")
if len(keys) == 0 {
b.WriteString("keine Benutzer erfasst.\n\n")
} else {
parts := make([]string, 0, len(keys))
for _, k := range keys {
parts = append(parts, fmt.Sprintf("%s: %d", k, roleCount[k]))
}
b.WriteString(strings.Join(parts, ", ") + ".\n\n")
}
}
b.WriteString("### 2.2 Berechtigungsgruppen\n\n")
if len(groups) == 0 {
b.WriteString("Es sind keine Berechtigungsgruppen angelegt; der Zugriff wird ausschließlich über das " +
"Rollenmodell gesteuert. [MANUELL ZU ERGÄNZEN: Begründung, falls keine feinere Zugriffssteuerung " +
"erforderlich ist.]\n\n")
} else {
b.WriteString("| Gruppe | Mitglieder |\n|---|---|\n")
for _, g := range groups {
members, err := s.store.ListGroupMembersDetailed(ctx, g.ID, tenantID)
if err != nil {
return "", fmt.Errorf("list group members: %w", err)
}
names := make([]string, 0, len(members))
for _, m := range members {
names = append(names, m.Username)
}
if len(names) == 0 {
names = append(names, "—")
}
b.WriteString("| " + mdCell(g.Name) + " | " + mdCell(strings.Join(names, ", ")) + " |\n")
}
b.WriteString("\n")
}
b.WriteString("### 2.3 Berechtigungsebenen\n\n")
b.WriteString("Der Zugriff auf ein einzelnes Dokument ergibt sich aus drei Ebenen; die speziellere Ebene " +
"überschreibt die allgemeinere:\n\n")
b.WriteString("1. **Dokumenttyp-Berechtigung** (Grundeinstellung je Dokumenttyp)\n")
b.WriteString("2. **Schlagwort-Berechtigung** (Grants über die Tags eines Dokuments)\n")
b.WriteString("3. **Einzeldokument-Berechtigung** (Übersteuerung für ein konkretes Dokument, inkl. Entzug/`deny`)\n\n")
b.WriteString(fmt.Sprintf("Aktuell vergeben: %d Dokumenttyp-Berechtigungen, %d Schlagwort-Berechtigungen, "+
"%d Einzeldokument-Berechtigungen bei %d Berechtigungsgruppen.\n\n",
stats.DocTypeGrants, stats.TagGrants, stats.DocumentGrants, stats.PermissionGroups))
// Dokumenttyp-Grants im Detail.
dtRows := 0
var dtBuf strings.Builder
for _, dt := range docTypes {
grants, err := s.store.ListDocumentTypeGrants(ctx, tenantID, dt.ID)
if err != nil {
return "", fmt.Errorf("list document type grants: %w", err)
}
for _, g := range grants {
dtBuf.WriteString("| " + mdCell(dt.Name) + " | " + mdCell(g.GroupName) + " | " + mdCell(g.Access) + " |\n")
dtRows++
}
}
if dtRows > 0 {
b.WriteString("**Dokumenttyp-Berechtigungen**\n\n| Dokumenttyp | Gruppe | Zugriff |\n|---|---|---|\n")
b.WriteString(dtBuf.String() + "\n")
}
tagRows := 0
var tagBuf strings.Builder
for _, tg := range tags {
grants, err := s.store.ListTagGrants(ctx, tenantID, tg.ID)
if err != nil {
return "", fmt.Errorf("list tag grants: %w", err)
}
for _, g := range grants {
tagBuf.WriteString("| " + mdCell(tg.Name) + " | " + mdCell(g.GroupName) + " | " + mdCell(g.Access) + " |\n")
tagRows++
}
}
if tagRows > 0 {
b.WriteString("**Schlagwort-Berechtigungen**\n\n| Schlagwort | Gruppe | Zugriff |\n|---|---|---|\n")
b.WriteString(tagBuf.String() + "\n")
}
if stats.DocumentGrants > 0 {
b.WriteString(fmt.Sprintf("Zusätzlich bestehen %d dokumentbezogene Einzelberechtigungen. "+
"Jede Änderung daran ist im Änderungsprotokoll nachvollziehbar.\n\n", stats.DocumentGrants))
}
// --- 3. Löschkonzept -------------------------------------------------
b.WriteString("## 3. Löschkonzept und Unveränderbarkeit\n\n")
b.WriteString("### 3.1 Unveränderbarkeit der Ablage (WORM)\n\n")
b.WriteString("Archivierte Dokumente werden im Dateisystem nach dem WORM-Prinzip abgelegt " +
"(*write once, read many*): die Datei wird einmalig geschrieben und anschließend schreibgeschützt gesetzt " +
"(Dateirechte 0440, nur lesend). Der Ablagepfad wird nach Mandant, Jahr und Monat gegliedert; der Dateiname " +
"ist der SHA-256-Hash des Inhalts. Dieser Prüfwert wird zusätzlich in der Datenbank geführt und dient als " +
"fälschungssensibler Fingerabdruck: eine nachträgliche inhaltliche Veränderung würde den Hash verändern und " +
"wäre damit erkennbar. Ein erneuter Upload desselben Inhalts wird über den Hash als Dublette erkannt.\n\n")
b.WriteString("### 3.2 Zweistufiges Löschverfahren\n\n")
b.WriteString("Ein Dokument kann nicht unmittelbar aus dem Archiv entfernt werden. Der Ablauf ist zweistufig " +
"und folgt dem Vier-Augen-Prinzip:\n\n")
b.WriteString("1. **Papierkorb (Soft-Delete):** Das Dokument wird als gelöscht markiert (`deleted_at`, " +
"`deleted_by`), bleibt aber gespeichert und wiederherstellbar.\n")
b.WriteString("2. **Löschantrag:** Ein Benutzer beantragt die endgültige Löschung (Status `pending`).\n")
b.WriteString("3. **Bestätigung durch eine zweite Person:** Ein Administrator (`domain_admin`) bestätigt den " +
"Antrag. Eine Bestätigung durch dieselbe Person, die den Antrag gestellt hat, wird technisch " +
"zurückgewiesen (Vier-Augen-Prinzip).\n")
b.WriteString("4. **Fristprüfung:** Besteht noch eine laufende Aufbewahrungsfrist (`retain_until` in der " +
"Zukunft), wird die Löschung blockiert (Status `blocked_retention`).\n")
b.WriteString("5. **Ausführung:** Erst danach wird die WORM-Datei entfernt (Status `executed`). Der " +
"Löschvorgang wird protokolliert (Antragsteller, Bestätigender, Zeitpunkte, Titel, Inhalts-Hash, " +
"Fristzustand) — es verbleibt ein Nachweis über die erfolgte Löschung.\n\n")
b.WriteString("Ein Antrag kann bis zur Bestätigung zurückgezogen werden (Status `cancelled`).\n\n")
if len(stats.DeleteRequestsByStat) > 0 {
statuses := make([]string, 0, len(stats.DeleteRequestsByStat))
for k := range stats.DeleteRequestsByStat {
statuses = append(statuses, k)
}
sort.Strings(statuses)
b.WriteString("Bisherige Löschanträge dieses Mandanten:\n\n| Status | Anzahl |\n|---|---|\n")
for _, st := range statuses {
b.WriteString("| " + mdCell(st) + " | " + strconv.FormatInt(stats.DeleteRequestsByStat[st], 10) + " |\n")
}
b.WriteString("\n")
} else {
b.WriteString("Für diesen Mandanten wurden bislang keine endgültigen Löschungen beantragt.\n\n")
}
// --- 4. Erfassungsautomatisierung ------------------------------------
b.WriteString("## 4. Erfassung und automatisierte Verarbeitung\n\n")
b.WriteString("Dokumente gelangen über den Weg des Uploads über die Weboberfläche oder über eine " +
"mandantenbezogene SFTP-Ablage (Posteingangsverzeichnis) in das System. Nach der Übernahme wird der " +
"Dokumenteninhalt maschinell ausgelesen (Texterkennung/OCR), ein Prüfwert gebildet und das Dokument in " +
"die revisionssichere Ablage überführt. Die Verarbeitung erfolgt über eine Warteschlange; der " +
"Verarbeitungsstatus je Dokument ist im System einsehbar.\n\n")
b.WriteString("### 4.1 Regeln zur automatischen Zuordnung (Workflows)\n\n")
if len(workflows) == 0 {
b.WriteString("Es sind keine Workflow-Regeln konfiguriert; die Verschlagwortung erfolgt manuell " +
"bzw. über die Mustererkennung der Stammdaten.\n\n")
} else {
b.WriteString("| Regel | Auslöser | Aktiv | Priorität |\n|---|---|---|---|\n")
for _, wf := range workflows {
b.WriteString("| " + mdCell(wf.Name) + " | " + mdCell(wf.TriggerType) + " | " +
jaNein(wf.Enabled) + " | " + strconv.Itoa(wf.Priority) + " |\n")
}
b.WriteString("\nJede Ausführung einer Workflow-Regel wird protokolliert (Regel, Dokument, Treffer, " +
"angewandte Aktionen), sodass die maschinelle Zuordnung nachvollziehbar bleibt.\n\n")
}
b.WriteString("### 4.2 Klassifizierungsvorlagen\n\n")
if len(templates) == 0 {
b.WriteString("Es sind keine Klassifizierungsvorlagen hinterlegt.\n\n")
} else {
b.WriteString("| Vorlage | Dokumenttyp | Aufbewahrung (Jahre) | Aktiv | Beschreibung |\n|---|---|---|---|---|\n")
for _, t := range templates {
dt := "—"
if t.DocTypeID != nil {
if n, ok := docTypeName[*t.DocTypeID]; ok {
dt = n
} else {
dt = "Dokumenttyp-ID " + strconv.FormatInt(*t.DocTypeID, 10)
}
}
ry := "—"
if t.RetainYears != nil {
ry = strconv.Itoa(*t.RetainYears)
}
b.WriteString("| " + mdCell(t.Name) + " | " + mdCell(dt) + " | " + ry + " | " +
jaNein(t.Active) + " | " + mdCell(orDash(t.Description)) + " |\n")
}
b.WriteString("\nDie Anwendung einer Vorlage auf ein Dokument wird protokolliert. Eine spätere Änderung " +
"einer Vorlage wirkt nicht rückwirkend auf bereits klassifizierte Dokumente.\n\n")
}
b.WriteString("### 4.3 Stammdaten der Indizierung\n\n")
b.WriteString(fmt.Sprintf("Für diesen Mandanten sind %d Dokumenttypen und %d Schlagworte gepflegt. "+
"Dokumenttypen und Schlagworte können mit Erkennungsmustern versehen werden, über die eine Zuordnung "+
"beim Einlesen automatisch vorgeschlagen bzw. gesetzt wird.\n\n", len(docTypes), len(tags)))
if len(docTypes) > 0 {
names := make([]string, 0, len(docTypes))
for _, dt := range docTypes {
names = append(names, dt.Name)
}
b.WriteString("Dokumenttypen: " + strings.Join(names, ", ") + "\n\n")
}
// --- 5. Nachvollziehbarkeit ------------------------------------------
b.WriteString("## 5. Nachvollziehbarkeit (Änderungsprotokoll)\n\n")
b.WriteString("Das System führt ein fortschreibendes, nur ergänzbares Änderungsprotokoll (Audit-Log). " +
"Bestehende Protokolleinträge können weder verändert noch gelöscht werden; entsprechende " +
"Datenbankoperationen werden auf Datenbankebene unterbunden. Jeder Eintrag enthält Zeitstempel, " +
"Ereignisart, Benutzername, IP-Adresse, betroffenes Dokument, Erfolg/Misserfolg sowie eine " +
"Detailangabe. Auch fehlgeschlagene Versuche werden protokolliert.\n\n")
b.WriteString("Protokollierte Ereignisarten (Auszug):\n\n")
b.WriteString("- **Anmeldung/Sitzung:** Anmeldung, Abmeldung, fehlgeschlagene Anmeldung, Verzeichnisanmeldung (LDAP)\n")
b.WriteString("- **Dokumente:** Anlage, Änderung, erneute Verarbeitung, Seitentrennung, Zuordnung von " +
"Dokumenttyp/Korrespondent/Belegdatum, Notizen\n")
b.WriteString("- **Löschung:** Verschieben in den Papierkorb, Wiederherstellung, Löschantrag, Bestätigung, " +
"Ausführung, Ablehnung wegen laufender Aufbewahrungsfrist\n")
b.WriteString("- **Aufbewahrung:** Anlage/Änderung/Löschung von Aufbewahrungsregeln, Anwendung der Fristenläufe\n")
b.WriteString("- **Berechtigungen:** Änderungen an Berechtigungen und Gruppen, Benutzerverwaltung\n")
b.WriteString("- **Automatisierung:** Workflow-Ausführungen, Anwendung von Klassifizierungsvorlagen, " +
"maschinelle Metadatenvorschläge\n")
b.WriteString("- **Weitergabe:** Erstellung, Widerruf und Abruf von Freigabelinks\n")
b.WriteString("- **Schnittstellen:** SFTP-Zugangsdaten und SFTP-Anmeldungen, Konfigurationsänderungen\n")
b.WriteString("- **Compliance:** Erzeugung dieser Verfahrensdokumentation\n\n")
b.WriteString("Das Protokoll ist für Administratoren einsehbar und je Dokument filterbar.\n\n")
// --- 6. Manuell zu ergänzende Kapitel --------------------------------
b.WriteString("## 6. Allgemeine Beschreibung des Unternehmens und der Organisation\n\n")
b.WriteString("[MANUELL ZU ERGÄNZEN: Unternehmensgegenstand, Aufbau- und Ablauforganisation, welche " +
"Belegarten anfallen, welche Vorsysteme (z. B. Kasse, Warenwirtschaft, Buchhaltung) bestehen und wie " +
"diese mit dem Archiv zusammenwirken.]\n\n")
b.WriteString("## 7. Verantwortliche Personen und Vertretungsregelung\n\n")
b.WriteString("[MANUELL ZU ERGÄNZEN: Namentlich Verantwortliche für Archivierung, Berechtigungsvergabe, " +
"Löschfreigabe und Systembetrieb; Vertretungsregelung; Arbeitsanweisungen und deren Bekanntgabe an die " +
"Mitarbeitenden.]\n\n")
b.WriteString("## 8. Technische Systemdokumentation, Server-, Backup- und Notfallkonzept\n\n")
b.WriteString("[MANUELL ZU ERGÄNZEN: eingesetzte Hardware/Server, Standort und Betreiber, Betriebssystem- " +
"und Datenbankstand, Datensicherungsverfahren (Umfang, Häufigkeit, Aufbewahrung der Sicherungen, " +
"Auslagerung), Rücksicherungstests, Notfall- und Wiederanlaufplan, Verfahren bei Systemwechsel/Migration " +
"und Sicherstellung der Lesbarkeit über die gesamte Aufbewahrungsdauer.]\n\n")
b.WriteString("## 9. Änderungshistorie dieser Verfahrensdokumentation\n\n")
b.WriteString("[MANUELL ZU ERGÄNZEN: Versionsstände, Datum, Bearbeiter und Anlass der Änderung. Der " +
"vorliegende Entwurf gibt den Systemstand vom " + now.Format("02.01.2006 15:04:05 MST") +
" wieder und wird bei jedem Export neu erzeugt.]\n\n")
b.WriteString("---\n\n")
b.WriteString("*Automatisch erzeugter Entwurf aus dem archivdms-Systemstand. Kein rechtsverbindliches " +
"Fertigdokument — vor Verwendung gegenüber Dritten (z. B. im Rahmen einer Betriebsprüfung) durch eine " +
"fachkundige Stelle prüfen und um die als [MANUELL ZU ERGÄNZEN] gekennzeichneten Abschnitte vervollständigen.*\n")
return b.String(), nil
}
// retentionPeriodText renders the retention period of a rule in German prose.
func retentionPeriodText(ru storage.RetentionRule) string {
parts := []string{}
if ru.RetentionYears != nil && *ru.RetentionYears > 0 {
parts = append(parts, strconv.Itoa(*ru.RetentionYears)+" Jahre")
}
if ru.RetentionDays != nil && *ru.RetentionDays > 0 {
parts = append(parts, strconv.Itoa(*ru.RetentionDays)+" Tage")
}
if len(parts) == 0 {
return "nicht gesetzt"
}
return strings.Join(parts, " + ")
}
// mdCell escapes the characters that would break a Markdown table cell.
func mdCell(in string) string {
out := strings.ReplaceAll(in, "|", "\\|")
out = strings.ReplaceAll(out, "\r", " ")
out = strings.ReplaceAll(out, "\n", " ")
return strings.TrimSpace(out)
}
// orDash returns "—" for an empty string.
func orDash(in string) string {
if strings.TrimSpace(in) == "" {
return "—"
}
return in
}
// jaNein renders a bool in German.
func jaNein(b bool) string {
if b {
return "ja"
}
return "nein"
}