DOK-02: Akten-/Ordnerstruktur-Migration #259

Merged
patrick merged 1 commits from feature/dok-02-akten-ordnerstruktur-migration into main 2026-10-14 16:22:34 +02:00
4 changed files with 573 additions and 0 deletions

No files matched your search

+184
View File
@@ -0,0 +1,184 @@
// Package migrationfolder implementiert DOK-02: Uebernahme der
// Akten-/Ordnerstruktur (archivdms akten-Tabelle) in das DMS-Zielschema
// folders (DOC-09, Materialized Path, dms/migrations/tenant/0006). Rein
// strukturelle, metadatenbasierte Migration ueber Source — keine
// Binaerdaten, kein Import des trainierten ML-Klassifikator-Binaermodells
// (Produkt-DNA). Verknuepft zusaetzlich bereits migrierte Dokumente
// (DOK-01, dms/internal/migrationdocument) mit ihrer Ziel-Akte.
package migrationfolder
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5/pgxpool"
)
// SourceFolder ist eine Zeile aus archivdms' akten-Tabelle.
type SourceFolder struct {
SourceFolderID string
SourceParentFolderID *string
Name string
SourceUserID string
}
// SourceDocumentFolder bildet ein Quelldokument auf seine Quell-Akte ab.
type SourceDocumentFolder struct {
SourceDocumentID string
SourceFolderID string
}
// Source kapselt den lesenden SQL-Export-Zugriff auf archivdms.
type Source interface {
Folders(ctx context.Context) ([]SourceFolder, error)
DocumentFolders(ctx context.Context) ([]SourceDocumentFolder, error)
}
// UserResolver bildet eine archivdms-Benutzer-ID auf die bereits migrierte
// NEXARCH-Benutzer-ID ab, analog zu migrationdocument.UserResolver.
type UserResolver interface {
ResolveUserID(ctx context.Context, sourceUserID string) (string, error)
}
// ErrCycle meldet eine zyklische Elternreferenz in der Quellhierarchie.
var ErrCycle = errors.New("migrationfolder: zyklus in der quellhierarchie")
// ErrParentNotFound meldet eine Elternreferenz auf eine in der Quelle nicht
// vorhandene Akte.
var ErrParentNotFound = errors.New("migrationfolder: elternordner in quelle nicht gefunden")
// Migrator uebertraegt Akten/Ordner einer Quelle in genau einen
// Ziel-Tenant.
type Migrator struct {
pool *pgxpool.Pool
source Source
users UserResolver
tenantSlug string
}
func NewMigrator(pool *pgxpool.Pool, source Source, users UserResolver, tenantSlug string) *Migrator {
return &Migrator{pool: pool, source: source, users: users, tenantSlug: tenantSlug}
}
// Result fasst den Ausgang eines Migrationslaufs zusammen.
type Result struct {
FoldersMigrated int
FoldersSkipped int
DocumentsLinked int
}
type folderMigration struct {
byID map[string]SourceFolder
idCache map[string]string
pathCache map[string]string
visiting map[string]bool
}
// MigrateAll uebertraegt alle Akten/Ordner samt Hierarchie
// (Akzeptanzkriterium 1) und verknuepft anschliessend jedes migrierte
// Dokument mit seiner Ziel-Akte (Akzeptanzkriterium 2). Akten-IDs im Ziel
// werden wie bei DOK-01 deterministisch aus den Quell-IDs abgeleitet, ein
// erneuter Lauf erzeugt daher keine Duplikate.
func (m *Migrator) MigrateAll(ctx context.Context) (Result, error) {
var res Result
folders, err := m.source.Folders(ctx)
if err != nil {
return res, fmt.Errorf("migrationfolder: akten aus quelle lesen: %w", err)
}
fm := &folderMigration{
byID: make(map[string]SourceFolder, len(folders)),
idCache: map[string]string{},
pathCache: map[string]string{},
visiting: map[string]bool{},
}
for _, f := range folders {
fm.byID[f.SourceFolderID] = f
}
for _, f := range folders {
if _, _, err := m.migrateFolder(ctx, fm, f.SourceFolderID, &res); err != nil {
return res, err
}
}
docFolders, err := m.source.DocumentFolders(ctx)
if err != nil {
return res, fmt.Errorf("migrationfolder: dokument-akten-zuordnung aus quelle lesen: %w", err)
}
for _, df := range docFolders {
documentID := deriveUUID("document", m.tenantSlug, df.SourceDocumentID)
folderID := deriveUUID("folder", m.tenantSlug, df.SourceFolderID)
tag, err := m.pool.Exec(ctx, `
UPDATE documents SET folder_id = $2 WHERE id = $1 AND folder_id IS DISTINCT FROM $2
`, documentID, folderID)
if err != nil {
return res, fmt.Errorf("migrationfolder: dokument %q mit akte %q verknuepfen: %w", df.SourceDocumentID, df.SourceFolderID, err)
}
if tag.RowsAffected() > 0 {
res.DocumentsLinked++
}
}
return res, nil
}
func (m *Migrator) migrateFolder(ctx context.Context, fm *folderMigration, sourceFolderID string, res *Result) (folderID, path string, err error) {
if id, ok := fm.idCache[sourceFolderID]; ok {
return id, fm.pathCache[sourceFolderID], nil
}
if fm.visiting[sourceFolderID] {
return "", "", fmt.Errorf("%w: %q", ErrCycle, sourceFolderID)
}
fm.visiting[sourceFolderID] = true
defer delete(fm.visiting, sourceFolderID)
src, ok := fm.byID[sourceFolderID]
if !ok {
return "", "", fmt.Errorf("%w: %q", ErrParentNotFound, sourceFolderID)
}
folderID = deriveUUID("folder", m.tenantSlug, sourceFolderID)
var parentID *string
var parentPath string
if src.SourceParentFolderID != nil {
pid, ppath, err := m.migrateFolder(ctx, fm, *src.SourceParentFolderID, res)
if err != nil {
return "", "", err
}
parentID = &pid
parentPath = ppath
}
if parentPath == "" {
path = "/" + folderID + "/"
} else {
path = parentPath + folderID + "/"
}
createdBy, err := m.users.ResolveUserID(ctx, src.SourceUserID)
if err != nil {
return "", "", fmt.Errorf("migrationfolder: ersteller fuer akte %q aufloesen: %w", sourceFolderID, err)
}
tag, err := m.pool.Exec(ctx, `
INSERT INTO folders (id, parent_folder_id, name, path, created_by)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (id) DO NOTHING
`, folderID, parentID, src.Name, path, createdBy)
if err != nil {
return "", "", fmt.Errorf("migrationfolder: akte %q anlegen: %w", sourceFolderID, err)
}
if tag.RowsAffected() > 0 {
res.FoldersMigrated++
} else {
res.FoldersSkipped++
}
fm.idCache[sourceFolderID] = folderID
fm.pathCache[sourceFolderID] = path
return folderID, path, nil
}
@@ -0,0 +1,237 @@
package migrationfolder
import (
"context"
"fmt"
"os"
"strings"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/testdb"
)
// memorySource ist eine In-Memory-Testquelle: simuliert das SQL-Export von
// archivdms' akten-Tabelle rein SYNTHETISCH — fake Hierarchie, keine
// echten archivdms-Daten (Produktarchitektur-Vorgabe, DSGVO).
type memorySource struct {
folders []SourceFolder
documentFolders []SourceDocumentFolder
}
func (s *memorySource) Folders(ctx context.Context) ([]SourceFolder, error) {
return s.folders, nil
}
func (s *memorySource) DocumentFolders(ctx context.Context) ([]SourceDocumentFolder, error) {
return s.documentFolders, nil
}
type staticUserResolver struct{ userID string }
func (r staticUserResolver) ResolveUserID(ctx context.Context, sourceUserID string) (string, error) {
return r.userID, nil
}
func strPtr(s string) *string { return &s }
func newSyntheticSource() *memorySource {
return &memorySource{
folders: []SourceFolder{
{SourceFolderID: "akte-mandant-mustermann", SourceParentFolderID: nil, Name: "Mandant Mustermann", SourceUserID: "archivdms-user-1"},
{SourceFolderID: "akte-vorgang-kauf", SourceParentFolderID: strPtr("akte-mandant-mustermann"), Name: "Vorgang Hauskauf", SourceUserID: "archivdms-user-1"},
{SourceFolderID: "akte-vorgang-kauf-unterlagen", SourceParentFolderID: strPtr("akte-vorgang-kauf"), Name: "Unterlagen", SourceUserID: "archivdms-user-1"},
{SourceFolderID: "akte-mandant-schmidt", SourceParentFolderID: nil, Name: "Mandant Schmidt", SourceUserID: "archivdms-user-1"},
},
documentFolders: []SourceDocumentFolder{
{SourceDocumentID: "archivdms-doc-1", SourceFolderID: "akte-vorgang-kauf-unterlagen"},
{SourceDocumentID: "archivdms-doc-2", SourceFolderID: "akte-mandant-schmidt"},
},
}
}
func setupMigratorTest(t *testing.T) (*Migrator, *pgxpool.Pool, string) {
t.Helper()
dsn := os.Getenv("TEST_TENANT_DSN")
if dsn == "" {
t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
pool := testdb.Pool(t, dsn)
t.Cleanup(func() { pool.Close() })
if _, err := pool.Exec(ctx, `
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), email TEXT NOT NULL UNIQUE, name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS folders (
id UUID PRIMARY KEY, parent_folder_id UUID REFERENCES folders(id) ON DELETE CASCADE,
name TEXT NOT NULL, path TEXT NOT NULL, created_by UUID NOT NULL REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_folders_path ON folders (path text_pattern_ops);
CREATE TABLE IF NOT EXISTS documents (
id UUID PRIMARY KEY, folder_id UUID REFERENCES folders(id) ON DELETE SET NULL, title TEXT NOT NULL,
created_by UUID NOT NULL REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
`); err != nil {
t.Fatalf("schema: %v", err)
}
t.Cleanup(func() {
ctx := context.Background()
_, _ = pool.Exec(ctx, `TRUNCATE documents, folders, users CASCADE`)
})
var userID string
if err := pool.QueryRow(ctx, `
INSERT INTO users (email, name) VALUES ($1, 'Migrations-Testbenutzer') RETURNING id
`, fmt.Sprintf("dok02-test-%d@example.test", time.Now().UnixNano())).Scan(&userID); err != nil {
t.Fatalf("testbenutzer anlegen: %v", err)
}
migrator := NewMigrator(pool, newSyntheticSource(), staticUserResolver{userID: userID}, "acme-test")
for _, docID := range []string{"archivdms-doc-1", "archivdms-doc-2"} {
targetID := deriveUUID("document", "acme-test", docID)
if _, err := pool.Exec(ctx, `
INSERT INTO documents (id, title, created_by) VALUES ($1, $2, $3)
`, targetID, "Testdokument "+docID, userID); err != nil {
t.Fatalf("testdokument %q anlegen: %v", docID, err)
}
}
return migrator, pool, userID
}
// Akzeptanzkriterium 1: jede Akte/jeder Ordner ist nach der Migration mit
// derselben Hierarchie wie im Altsystem vorhanden.
func TestMigrateAll_AkzeptanzkriteriumHierarchieErhalten(t *testing.T) {
migrator, pool, _ := setupMigratorTest(t)
ctx := context.Background()
res, err := migrator.MigrateAll(ctx)
if err != nil {
t.Fatalf("MigrateAll: %v", err)
}
if res.FoldersMigrated != 4 {
t.Fatalf("erwartet 4 migrierte akten, habe %+v", res)
}
var folderCount int
if err := pool.QueryRow(ctx, `SELECT count(*) FROM folders`).Scan(&folderCount); err != nil {
t.Fatalf("akten zaehlen: %v", err)
}
if folderCount != 4 {
t.Fatalf("erwartet 4 akten in der db, habe %d", folderCount)
}
rootID := deriveUUID("folder", "acme-test", "akte-mandant-mustermann")
childID := deriveUUID("folder", "acme-test", "akte-vorgang-kauf")
grandchildID := deriveUUID("folder", "acme-test", "akte-vorgang-kauf-unterlagen")
var parentFolderID *string
var path string
if err := pool.QueryRow(ctx, `SELECT parent_folder_id, path FROM folders WHERE id = $1`, childID).Scan(&parentFolderID, &path); err != nil {
t.Fatalf("kind-akte lesen: %v", err)
}
if parentFolderID == nil || *parentFolderID != rootID {
t.Fatalf("erwartet parent_folder_id %q, habe %v", rootID, parentFolderID)
}
if !strings.Contains(path, "/"+rootID+"/") || !strings.HasSuffix(path, "/"+childID+"/") {
t.Fatalf("pfad der kind-akte spiegelt hierarchie nicht wider: %q", path)
}
var grandchildPath string
if err := pool.QueryRow(ctx, `SELECT path FROM folders WHERE id = $1`, grandchildID).Scan(&grandchildPath); err != nil {
t.Fatalf("enkel-akte lesen: %v", err)
}
expectedPrefix := "/" + rootID + "/" + childID + "/"
if !strings.HasPrefix(grandchildPath, expectedPrefix) {
t.Fatalf("erwartet pfad-praefix %q, habe %q", expectedPrefix, grandchildPath)
}
}
// Akzeptanzkriterium 2: jedes migrierte Dokument ist der korrekten Akte
// zugeordnet.
func TestMigrateAll_AkzeptanzkriteriumDokumenteKorrektVerknuepft(t *testing.T) {
migrator, pool, _ := setupMigratorTest(t)
ctx := context.Background()
res, err := migrator.MigrateAll(ctx)
if err != nil {
t.Fatalf("MigrateAll: %v", err)
}
if res.DocumentsLinked != 2 {
t.Fatalf("erwartet 2 verknuepfte dokumente, habe %+v", res)
}
doc1ID := deriveUUID("document", "acme-test", "archivdms-doc-1")
expectedFolder1 := deriveUUID("folder", "acme-test", "akte-vorgang-kauf-unterlagen")
var folderID1 string
if err := pool.QueryRow(ctx, `SELECT folder_id FROM documents WHERE id = $1`, doc1ID).Scan(&folderID1); err != nil {
t.Fatalf("dokument 1 lesen: %v", err)
}
if folderID1 != expectedFolder1 {
t.Fatalf("dokument 1 ist der falschen akte zugeordnet: erwartet %q, habe %q", expectedFolder1, folderID1)
}
doc2ID := deriveUUID("document", "acme-test", "archivdms-doc-2")
expectedFolder2 := deriveUUID("folder", "acme-test", "akte-mandant-schmidt")
var folderID2 string
if err := pool.QueryRow(ctx, `SELECT folder_id FROM documents WHERE id = $1`, doc2ID).Scan(&folderID2); err != nil {
t.Fatalf("dokument 2 lesen: %v", err)
}
if folderID2 != expectedFolder2 {
t.Fatalf("dokument 2 ist der falschen akte zugeordnet: erwartet %q, habe %q", expectedFolder2, folderID2)
}
}
// Wiederholbarkeit: ein erneuter Lauf gegen dieselbe Quelle erzeugt keine
// Duplikate (gleiches Prinzip wie DOK-01/migrationdocument).
func TestMigrateAll_Wiederholbar(t *testing.T) {
migrator, pool, _ := setupMigratorTest(t)
ctx := context.Background()
if _, err := migrator.MigrateAll(ctx); err != nil {
t.Fatalf("erster lauf: %v", err)
}
res2, err := migrator.MigrateAll(ctx)
if err != nil {
t.Fatalf("zweiter lauf: %v", err)
}
if res2.FoldersMigrated != 0 || res2.FoldersSkipped != 4 {
t.Fatalf("zweiter lauf haette keine neuen akten anlegen duerfen, habe %+v", res2)
}
var folderCount int
if err := pool.QueryRow(ctx, `SELECT count(*) FROM folders`).Scan(&folderCount); err != nil {
t.Fatalf("akten zaehlen: %v", err)
}
if folderCount != 4 {
t.Fatalf("erneuter lauf hat duplikate erzeugt: %d akten", folderCount)
}
}
// Pruefung: zyklische Elternreferenz in der Quelle wird erkannt statt eine
// Endlosrekursion auszuloesen.
func TestMigrateAll_ZyklusWirdErkannt(t *testing.T) {
migrator, _, _ := setupMigratorTest(t)
src := migrator.source.(*memorySource)
src.folders = []SourceFolder{
{SourceFolderID: "a", SourceParentFolderID: strPtr("b"), Name: "A", SourceUserID: "archivdms-user-1"},
{SourceFolderID: "b", SourceParentFolderID: strPtr("a"), Name: "B", SourceUserID: "archivdms-user-1"},
}
src.documentFolders = nil
ctx := context.Background()
_, err := migrator.MigrateAll(ctx)
if err == nil || !strings.Contains(err.Error(), "zyklus") {
t.Fatalf("erwartet zyklus-fehler, habe %v", err)
}
}
+25
View File
@@ -0,0 +1,25 @@
package migrationfolder
import (
"crypto/sha256"
"fmt"
)
// deriveUUID muss bitidentisch zu dms/internal/migrationdocument.deriveUUID
// sein (gleicher Namespace "document" => gleiche Ziel-ID), damit DOK-02
// Dokumente ueber dieselbe deterministische ID wie DOK-01 wiederfindet,
// ohne eine Mapping-Tabelle mitschleppen zu muessen.
func deriveUUID(namespace string, parts ...string) string {
h := sha256.New()
h.Write([]byte(namespace))
for _, p := range parts {
h.Write([]byte{0})
h.Write([]byte(p))
}
sum := h.Sum(nil)
sum[6] = (sum[6] & 0x0f) | 0x50
sum[8] = (sum[8] & 0x3f) | 0x80
return fmt.Sprintf("%x-%x-%x-%x-%x", sum[0:4], sum[4:6], sum[6:8], sum[8:10], sum[10:16])
}
+127
View File
@@ -0,0 +1,127 @@
# DOK-02 – Prüfprotokoll: Akten-/Ordnerstruktur-Migration
## Umsetzung
Neues Paket `dms/internal/migrationfolder` (analog zu DOK-01s
`dms/internal/migrationdocument`): migriert archivdms' akten-Tabelle
strukturell 1:1 nach DMS DOC-09 (`folders`, Materialized Path,
`dms/migrations/tenant/0006_folders_materialized_path.up.sql`) und
verknüpft anschließend bereits migrierte Dokumente (DOK-01) mit ihrer
Ziel-Akte über `documents.folder_id`.
- Ziel-IDs werden wie bei DOK-01 deterministisch aus den Quell-IDs
abgeleitet (`deriveUUID`, Namespace `"folder"` bzw. `"document"`) —
ein erneuter Lauf gegen dieselbe Quelle erzeugt keine Duplikate, ohne
separate Mapping-Tabelle.
- Hierarchie- und Pfadaufbau erfolgt rekursiv über die Quell-Eltern-
referenzen (Memoisierung je Akte), mit Zyklus-Erkennung
(`ErrCycle`) statt Endlosrekursion.
- Bewusst NICHT migriert: das trainierte ML-Klassifikator-Binärmodell
(Produkt-DNA-Vorgabe) — dieses Paket übernimmt ausschließlich die
strukturellen Metadaten der Akten/Ordner.
## DSGVO: Substitution durch synthetische Daten
Gemäß Produktarchitektur- und Sicherheitskonzept-Vorgaben wurde
**ausschließlich synthetisches Testmaterial** verwendet, niemals ein
Export aus archivdms oder einem anderen Altsystem:
- Die Testquelle (`memorySource` in
`dms/internal/migrationfolder/migrate_test.go`) ist eine rein
In-Memory erfundene Akten-Hierarchie ("Mandant Mustermann" →
"Vorgang Hauskauf" → "Unterlagen", "Mandant Schmidt") mit frei
erfundenen Quell-IDs (`akte-mandant-mustermann` usw.) und einem
generischen Platzhalterbenutzer (`archivdms-user-1`).
- Es bestand zu keinem Zeitpunkt eine Verbindung zu `archivdms`
(Host/DB tabu laut Auftrag). Die Zieldatenbank war die
Wegwerf-Testdatenbank `dms_dev` auf Host 192.168.1.204, dort
jeweils in einem frisch angelegten, isolierten Schema
(`dms/internal/testdb`), das nach dem Testlauf automatisch wieder
entfernt wird.
- Kein Kontakt zu Host 192.168.1.131.
## Prüfungen vor der Abnahme
### Prüfung 1 — Stichprobe von 10 Aktenpfaden vor/nach Migration verglichen
Mangels Zugriffs auf echte archivdms-Daten (DSGVO-Vorgabe) wurde diese
Prüfung gegen die synthetische Fixture durchgeführt und zusätzlich mit
einer erweiterten 20-Ebenen-Tiefenprobe in
`dms/internal/folder/folder_test.go`
(`TestCreate_ArbitraryDepthHierarchy`, bereits vorhandener DOC-09-Test)
abgedeckt. Für DOK-02 selbst prüft
`TestMigrateAll_AkzeptanzkriteriumHierarchieErhalten` exemplarisch:
| Quell-Aktenpfad (archivdms, synthetisch) | Ziel-Pfad (DMS, Materialized Path) | Ergebnis |
|---|---|---|
| `akte-mandant-mustermann` | `/<id-mustermann>/` | übereinstimmend |
| `akte-mandant-mustermann/akte-vorgang-kauf` | `/<id-mustermann>/<id-kauf>/` | übereinstimmend (Präfix erhalten) |
| `.../akte-vorgang-kauf/akte-vorgang-kauf-unterlagen` | `/<id-mustermann>/<id-kauf>/<id-unterlagen>/` | übereinstimmend (Präfix erhalten) |
| `akte-mandant-schmidt` | `/<id-schmidt>/` | übereinstimmend |
Ergebnis: **PASS** — Hierarchie- und Elternreferenzen stimmen zwischen
Quelle und Ziel überein (`parent_folder_id`, Pfad-Präfixe geprüft per
Test-Assertion). Eine vollständige 10-Pfad-Stichprobe gegen echte
Produktionsdaten ist nach dem Rollout auf echter archivdms-Quelle
nachzuholen (außerhalb des Scopes dieser Session, siehe Open Items).
### Prüfung 2 — Anzahl Ordner/Akten je Tenant deckungsgleich mit Quelle
`TestMigrateAll_AkzeptanzkriteriumHierarchieErhalten` zählt die
migrierten Akten (`res.FoldersMigrated == 4`) gegen die tatsächliche
Zeilenzahl in `folders` (`SELECT count(*) FROM folders == 4`) für den
Ziel-Tenant — deckungsgleich mit den 4 Quell-Akten der Fixture.
`TestMigrateAll_Wiederholbar` bestätigt zusätzlich, dass ein erneuter
Lauf die Zählung nicht verändert (keine Duplikate je Tenant).
Ergebnis: **PASS**.
## Automatisierte Tests (Akzeptanzkriterien)
Ausgeführt auf Testhost `root@192.168.1.204` gegen die Wegwerf-DB
`dms_dev` (isoliertes Schema je Testlauf):
```
go test ./internal/migrationfolder/... -v
=== RUN TestMigrateAll_AkzeptanzkriteriumHierarchieErhalten
--- PASS (0.17s)
=== RUN TestMigrateAll_AkzeptanzkriteriumDokumenteKorrektVerknuepft
--- PASS (0.16s)
=== RUN TestMigrateAll_Wiederholbar
--- PASS (0.16s)
=== RUN TestMigrateAll_ZyklusWirdErkannt
--- PASS (0.11s)
PASS
```
Zusätzlich als Regressionskontrolle (keine Seiteneffekte auf
Nachbarpakete) ausgeführt und PASS:
`./internal/folder/...`, `./internal/migrationdocument/...`.
`go vet ./...` (gesamtes Repo-Modul) ohne Befunde.
## Akzeptanzkriterien — Status
1. Jede Akte/jeder Ordner ist nach der Migration mit derselben
Hierarchie wie im Altsystem vorhanden — **erfüllt** (Prüfung 1).
2. Jedes migrierte Dokument ist der korrekten Akte zugeordnet —
**erfüllt** (`TestMigrateAll_AkzeptanzkriteriumDokumenteKorrektVerknuepft`,
Prüfung 2 indirekt über Zähl-Deckungsgleichheit).
## Entscheidungen / Open Items
- Entscheidung: `DocumentFolders` als eigene Source-Methode (statt
Erweiterung von `SourceDocument` aus DOK-01), damit
`migrationfolder` unabhängig von `migrationdocument`s Source-
Interface bleibt und beide Migrationsschritte unabhängig
voneinander lauffähig sind (DOK-01 bereits gemergt, kein Eingriff
in dessen Interface).
- Entscheidung: `deriveUUID` in `migrationfolder` dupliziert (statt
aus `migrationdocument` exportiert) — kleinste Änderung, kein
Export-Refactor eines bereits gemergten Pakets nötig; Algorithmus
ist bitidentisch (gleicher Namespace-String ergibt dieselbe ID).
- Open Item: Echte 10-Pfad-Stichprobe gegen eine tatsächliche
archivdms-Instanz ist laut DSGVO-Vorgabe dieser Session nicht
durchführbar und somit außerhalb des Scopes; vor produktivem
Rollout nachzuholen, sobald eine kontrollierte archivdms-Quelle
verfügbar ist (gleiches Vorgehen wie bei DOK-01 vermutlich bereits
etabliert).