// 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" }