CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (pull_request) Canceled after 0s
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
CI / Web (pdf-viewer) (push) Canceled after 0s
CI / Web (shl) (push) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (push) Canceled after 0s
CI / Web (dms-browser) (push) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (pull_request) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (pull_request) Canceled after 0s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (pull_request) Canceled after 0s
Supply-Chain-Scan (npm) / npm-audit (pull_request) Canceled after 0s
Core-Pflichttest-Gate / pflichttest-gate (pull_request) Canceled after 0s
RBAC-Rollenhierarchie-Pflichtgate / rbac-policy-invariant (pull_request) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (push) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (pull_request) Canceled after 0s
CI / Web (dms-browser) (pull_request) Canceled after 0s
CI / Web (pdf-viewer) (pull_request) Canceled after 0s
CI / Web (shl) (pull_request) Canceled after 0s
Core-Schnittstellen-Vertragstests / contract-tests (pull_request) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (pull_request) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (push) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Canceled after 0s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (push) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (pull_request) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Canceled after 0s
Neues Paket dms/internal/migrationfolder migriert archivdms' akten-Tabelle strukturell 1:1 nach DOC-09 (folders, Materialized Path) und verknuepft bereits migrierte Dokumente (DOK-01) mit ihrer Ziel-Akte ueber documents.folder_id. Deterministische Ziel-IDs analog migrationdocument machen erneute Laeufe idempotent, Zyklen in der Quellhierarchie werden erkannt statt Endlosrekursion auszuloesen. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
185 lines
5.7 KiB
Go
185 lines
5.7 KiB
Go
// 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
|
|
}
|