From 13bae7d600e0fbbf94ca6009bb1f49d6803b997b Mon Sep 17 00:00:00 2001 From: Marco Morath Date: Fri, 4 Sep 2026 19:51:02 +0200 Subject: [PATCH] Initial commit --- .env.example | 146 +++++ .gitignore | 16 + Dockerfile | 28 + docker-compose.yml | 121 ++++ docs/01-paperless-vorbereiten.md | 288 +++++++++ docs/02-export-aus-ecodms.md | 234 ++++++++ docs/03-extraktion.md | 210 +++++++ docs/04-import.md | 296 +++++++++ docs/05-nacharbeit.md | 250 ++++++++ docs/referenz-ecodms-exportformat.md | 378 ++++++++++++ ecodms_extract.py | 577 ++++++++++++++++++ ecodms_notizen.py | 258 ++++++++ paperless_import.py | 863 +++++++++++++++++++++++++++ rollen.yml.example | 101 ++++ 14 files changed, 3766 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 docs/01-paperless-vorbereiten.md create mode 100644 docs/02-export-aus-ecodms.md create mode 100644 docs/03-extraktion.md create mode 100644 docs/04-import.md create mode 100644 docs/05-nacharbeit.md create mode 100644 docs/referenz-ecodms-exportformat.md create mode 100644 ecodms_extract.py create mode 100644 ecodms_notizen.py create mode 100644 paperless_import.py create mode 100644 rollen.yml.example diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..292dd96 --- /dev/null +++ b/.env.example @@ -0,0 +1,146 @@ +# Paperless-ngx - Konfigurationsvorlage fuer die ecoDMS-Migration +# Nach .env kopieren und anpassen. +# +# Geheimnisse erzeugen: openssl rand -hex 32 +# Hexadezimal statt base64, dann enthaelt der Wert garantiert keine +# Zeichen, die die Env-Datei durcheinanderbringen. + +# =========================================================================== +# GEHEIMNISSE +# =========================================================================== +POSTGRES_PASSWORD=BITTE_ERSETZEN +# Ausschreiben, nicht ${POSTGRES_PASSWORD}. Docker Compose ersetzt Variablen +# nur in der docker-compose.yml, nicht in einer ueber env_file eingebundenen +# Datei - dort wird der Text woertlich durchgereicht. +PAPERLESS_DBPASS=BITTE_ERSETZEN + +PAPERLESS_SECRET_KEY=BITTE_ERSETZEN +PAPERLESS_ADMIN_USER=admin +PAPERLESS_ADMIN_PASSWORD=BITTE_ERSETZEN + +# =========================================================================== +# BINDUNG +# =========================================================================== +# 127.0.0.1 wenn der Reverse Proxy auf demselben Host laeuft, +# sonst die LAN-Adresse. Paperless nie direkt ins Internet. +BIND_IP=127.0.0.1 + +# =========================================================================== +# ANBINDUNG +# =========================================================================== +PAPERLESS_REDIS=redis://broker:6379 +PAPERLESS_DBHOST=db +PAPERLESS_DBNAME=paperless +PAPERLESS_DBUSER=paperless + +PAPERLESS_TIKA_ENABLED=1 +PAPERLESS_TIKA_ENDPOINT=http://tika:9998 +PAPERLESS_TIKA_GOTENBERG_ENDPOINT=http://gotenberg:3000 + +# =========================================================================== +# ERREICHBARKEIT +# =========================================================================== +# Waehrend der Migration lokal: +PAPERLESS_URL=http://localhost:8000 +PAPERLESS_ALLOWED_HOSTS=localhost + +# Im Produktivbetrieb hinter einem Reverse Proxy stattdessen: +#PAPERLESS_URL=https://dms.example.org +#PAPERLESS_ALLOWED_HOSTS=dms.example.org +#PAPERLESS_CSRF_TRUSTED_ORIGINS=https://dms.example.org +#PAPERLESS_CORS_ALLOWED_HOSTS=https://dms.example.org +#PAPERLESS_USE_X_FORWARD_HOST=true +#PAPERLESS_USE_X_FORWARD_PORT=true +#PAPERLESS_PROXY_SSL_HEADER=["HTTP_X_FORWARDED_PROTO","https"] +#PAPERLESS_COOKIE_PREFIX=pngx_ + +# =========================================================================== +# GRUNDEINSTELLUNGEN +# =========================================================================== +PAPERLESS_TIME_ZONE=Europe/Berlin +PAPERLESS_OCR_LANGUAGE=deu+eng +PAPERLESS_OCR_LANGUAGES=deu eng + +# EINMAL festlegen. Eine spaetere Aenderung benennt den gesamten Bestand um. +# Seit 3.x doppelte geschweifte Klammern (Jinja-Syntax). +PAPERLESS_FILENAME_FORMAT={{ created_year }}/{{ document_type }}/{{ title }} +PAPERLESS_FILENAME_FORMAT_REMOVE_NONE=true + +# =========================================================================== +# NACHVOLLZIEHBARKEIT +# =========================================================================== +PAPERLESS_AUDIT_LOG_ENABLED=true +PAPERLESS_EMPTY_TRASH_DELAY=90 + +# =========================================================================== +# BERECHTIGUNGEN +# =========================================================================== +# WICHTIG: numerische Benutzer-ID, kein Benutzername. Eine falsche ID laesst +# jeden Konsumvorgang mit "Error while queuing document" scheitern. +# ID ermitteln: docker compose exec -T db psql -U paperless -d paperless \ +# -c "SELECT id, username FROM auth_user ORDER BY id;" +# +# Ohne diese Einstellung haetten neu konsumierte Dokumente keinen Eigentuemer +# - und "keine Rechte" heisst in Paperless nicht gesperrt, sondern offen. +PAPERLESS_DEFAULT_PERMISSIONS_OWNER=2 +#PAPERLESS_DEFAULT_PERMISSIONS_VIEW_GROUPS=1 + +# =========================================================================== +# CONSUME-ORDNER +# =========================================================================== +# Waehrend der Migration abgeschaltet. +PAPERLESS_CONSUMER_POLLING=0 + +# Im Produktivbetrieb: Polling statt inotify, wenn der Ordner ueber SMB, +# NFS oder Nextcloud befuellt wird - Dateisystem-Benachrichtigungen sind +# dort unzuverlaessig. +#PAPERLESS_CONSUMER_POLLING=60 +#PAPERLESS_CONSUMER_POLLING_RETRY_COUNT=5 +#PAPERLESS_CONSUMER_POLLING_DELAY=5 +#PAPERLESS_CONSUMER_RECURSIVE=true +#PAPERLESS_CONSUMER_SUBDIRS_AS_TAGS=true + +# PAPERLESS_CONSUMER_IGNORE_PATTERNS NICHT setzen, ausser man kennt die +# Regex-Syntax: In 3.x ist das eine Liste REGULAERER AUSDRUECKE. Ein +# Glob-Muster wie "*.part" laesst den Consumer beim Start abstuerzen +# ("nothing to repeat at position 0"). Die Vorgaben decken .DS_Store, +# Thumbs.db und Aehnliches bereits ab. + +PAPERLESS_CONSUMER_DELETE_DUPLICATES=false + +# =========================================================================== +# OCR UND ARCHIVDATEIEN +# =========================================================================== +# In 3.x sind OCR-Steuerung und Archivdatei-Steuerung entkoppelt. Die alten +# Werte OCR_MODE=skip und OCR_SKIP_ARCHIVE_FILE sind entfallen und werden +# mit W002/W003 angemahnt. +# +# 'auto' laesst OCR aus, wenn bereits ein Textlayer vorhanden ist - bei einem +# Bestand aus einem anderen DMS der Regelfall. Unterschied: Stunden statt Tage. +PAPERLESS_OCR_MODE=auto +PAPERLESS_ARCHIVE_FILE_GENERATION=auto +PAPERLESS_OCR_OUTPUT_TYPE=pdfa +PAPERLESS_OCR_CLEAN=clean +PAPERLESS_OCR_DESKEW=true +PAPERLESS_OCR_ROTATE_PAGES=true +PAPERLESS_OCR_ROTATE_PAGES_THRESHOLD=12 + +# Schuetzt vor einem sehr grossen Scan, der einen Worker lange blockiert. +PAPERLESS_OCR_MAX_IMAGE_PIXELS=256000000 + +# =========================================================================== +# DURCHSATZ +# =========================================================================== +# Waehrend der Migration hoch, im Produktivbetrieb zurueckdrehen. +PAPERLESS_TASK_WORKERS=6 +PAPERLESS_THREADS_PER_WORKER=3 +PAPERLESS_WEBSERVER_WORKERS=2 +PAPERLESS_CONVERT_MEMORY_LIMIT=0 + +# =========================================================================== +# SONSTIGES +# =========================================================================== +PAPERLESS_TRAIN_TASK_CRON=10 3 * * * +PAPERLESS_EMAIL_TASK_CRON=*/15 * * * * +PAPERLESS_ENABLE_UPDATE_CHECK=false +#PAPERLESS_APP_TITLE=Archiv diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d523d27 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +# Konfiguration mit Geheimnissen +.env +rollen.yml + +# Laufzeitdaten +data/ +migration.sqlite +*.json +!*.example.json +*.csv +*.dump +fix_added.py + +# Python +__pycache__/ +*.pyc diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..bd78c61 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,28 @@ +# Paperless-ngx mit E-Rechnungs-Unterstuetzung (XRechnung + ZUGFeRD). +# +# Das Plugin nutzt das Parser-Framework aus 3.x. Es meldet sich unter der +# Entry-Point-Gruppe paperless_ngx.parsers an und wird beim Start automatisch +# gefunden. Passt eine Datei nicht, greifen wieder die eingebauten Parser. +# +# Fuer XRechnung wird Apache FOP gebraucht (XSL-FO -> PDF), das wiederum eine +# Java-Laufzeit benoetigt. ZUGFeRD kommt ohne aus, dort wird das vorhandene +# PDF/A-3 unveraendert durchgereicht. +# +# Bauen: docker compose build webserver +# Pruefen nach dem Start: +# docker compose exec webserver python -c \ +# "from paperless.parsers.registry import get_parser_registry; print(get_parser_registry())" + +FROM ghcr.io/paperless-ngx/paperless-ngx:3.1.0 + +USER root + +RUN apt-get update \ + && apt-get install --no-install-recommends -y default-jre-headless git \ + && rm -rf /var/lib/apt/lists/* + +# Version pinnen, sobald ein getagtes Release vorliegt - das Projekt ist neu. +RUN pip install --no-cache-dir \ + "paperless-ngx-erechnung @ git+https://github.com/bitbetterde/paperless-ngx-erechnung.git" + +USER paperless diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..cb6f651 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,121 @@ +# Paperless-ngx fuer die ecoDMS-Migration +# +# Vorbereitung einmalig: +# mkdir -p data/{data,media,export,consume,pgdata,redis} +# chown -R 1500:1500 data/{data,media,export,consume,redis} +# chown 999:999 data/pgdata +# # auf Btrfs zusaetzlich, VOR dem ersten Start: +# chattr +C data/pgdata data/redis +# +# Auf Systemen mit SELinux im Enforcing-Modus brauchen Bind Mounts eine +# Kennzeichnung. Dauerhaft ueber semanage, siehe docs/01. +# +# Aufruf: +# docker compose up -d --build + +services: + + broker: + image: docker.io/library/redis:7 + restart: unless-stopped + user: "1500:1500" + volumes: + - ./data/redis:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + + db: + image: docker.io/library/postgres:17 + restart: unless-stopped + # + # KEIN "user:" setzen. Das Einstiegsskript korrigiert beim Start Besitzer + # und Rechte des Datenverzeichnisses und schaltet dann selbst auf seinen + # internen Benutzer postgres (UID 999) herunter. Ein erzwungener Benutzer + # laesst das mit "Permission denied" scheitern. Stattdessen gehoert das + # Verzeichnis auf dem Host der 999. + volumes: + - ./data/pgdata:/var/lib/postgresql/data + environment: + POSTGRES_DB: paperless + POSTGRES_USER: paperless + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_INITDB_ARGS: "--data-checksums" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U paperless -d paperless"] + interval: 10s + timeout: 5s + retries: 5 + + gotenberg: + # Wandelt Office-Dokumente in PDF und rendert HTML-Mails. + # Ohne diesen Dienst bleiben Office-Originale ohne Archivdatei. + # + # KEIN "user:" setzen. Chromium legt beim Start ein Crashpad-Verzeichnis + # im Home des internen Benutzers an. Unter fremder UID scheitert das mit + # "chrome_crashpad_handler: --database is required" (HTTP 500 beim Rendern + # von .eml). Unkritisch: keine Bind Mounts, nur internes Netz. + image: docker.io/gotenberg/gotenberg:8 + restart: unless-stopped + command: + - "gotenberg" + - "--chromium-disable-javascript=true" + - "--chromium-allow-list=file:///tmp/.*" + + tika: + # Version GEPINNT und nicht ":latest". + # ":latest" zeigt inzwischen auf Tika 4.0.0, und Paperless 3.1 quittiert + # das mit "HTTP 406 Not Acceptable" bei JEDEM Office-Dokument und JEDER + # .eml - ohne verwertbare Fehlermeldung im Task. Diesen Stand nicht ohne + # Test anheben. + image: docker.io/apache/tika:3.2.3.0 + restart: unless-stopped + user: "1500:1500" + + webserver: + # Ohne E-Rechnungs-Plugin stattdessen: + # image: ghcr.io/paperless-ngx/paperless-ngx:3.1.0 + # und den build-Block entfernen. + build: + context: . + dockerfile: Dockerfile + image: paperless-ngx-local:3.1.0 + restart: unless-stopped + # + # Das Image startet bereits als unprivilegierter Benutzer (1000:1000), + # nicht als root. USERMAP_UID/GID sind damit wirkungslos - sie greifen + # nur, wenn das Einstiegsskript als root beginnt. Die UID wird deshalb + # hier fest gesetzt, passend zum Dateibesitz unter ./data. + user: "1500:1500" + depends_on: + db: + condition: service_healthy + broker: + condition: service_healthy + gotenberg: + condition: service_started + tika: + condition: service_started + ports: + - "${BIND_IP:-127.0.0.1}:8000:8000" + volumes: + - ./data/data:/usr/src/paperless/data + - ./data/media:/usr/src/paperless/media + - ./data/export:/usr/src/paperless/export + - ./data/consume:/usr/src/paperless/consume + # Nur waehrend der Migration: Exportbestand read-only einhaengen. + #- /pfad/zu/export-daten:/ecodms:ro + env_file: .env + healthcheck: + test: ["CMD", "curl", "-fs", "-S", "--max-time", "2", "http://localhost:8000"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 60s + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" diff --git a/docs/01-paperless-vorbereiten.md b/docs/01-paperless-vorbereiten.md new file mode 100644 index 0000000..aa425c1 --- /dev/null +++ b/docs/01-paperless-vorbereiten.md @@ -0,0 +1,288 @@ +# 1. Paperless-Instanz vorbereiten + +Ziel ist eine **leere** Paperless-Instanz, in die importiert wird. Ob sie +später produktiv läuft oder nur der Migration dient, ist zunächst +gleichgültig — die Konfiguration sollte aber von Anfang an die +Produktivfassung sein. + +--- + +## Wo migrieren? + +Der Import ist CPU-gebunden, nicht I/O-gebunden. Wenn die spätere +Produktivumgebung ein schwacher Server ist, lohnt es, die Migration auf +einer stärkeren Maschine zu fahren und den Bestand danach umzuziehen. + +Ein Paperless-Bestand ist portabel: Postgres-Dump plus die Verzeichnisse +`media` und `data`. Voraussetzung ist, dass Paperless- und +Postgres-**Hauptversion** auf beiden Seiten übereinstimmen. + +Zweiter Vorteil: Man kann beim Skriptbau beliebig oft von vorn anfangen, +ohne die produktive Instanz anzufassen. + +--- + +## Versionen festlegen + +**Nie `:latest`.** Alle fünf Images pinnen — Paperless, Postgres, Redis, +Gotenberg und Tika. + +Bei **Tika** ist das keine Stilfrage: `apache/tika:latest` zeigt inzwischen +auf Tika 4.0.0, und Paperless 3.1 quittiert das mit `HTTP 406 Not +Acceptable` bei **jedem** Office-Dokument und **jeder** `.eml`. In einem +Testlauf waren das 315 Fehlschläge in einer einzigen Tranche, ohne +verwertbare Fehlermeldung. + +--- + +## Verzeichnisse + +```bash +mkdir -p data/{data,media,export,consume,pgdata,redis} +``` + +Auf **Btrfs** vor dem ersten Start das Copy-on-Write für die +Datenbankverzeichnisse abschalten, sonst fragmentieren sie stark: + +```bash +chattr +C data/pgdata data/redis +lsattr -d data/pgdata # muss ---------------C zeigen +``` + +Wirkt nur auf leere Verzeichnisse. Nachträglich greift es bei bestehenden +Dateien nicht. + +Auf **ZFS** ist das nicht nötig. Dort lohnt getrenntes Anlegen der +Datenbereiche, damit Snapshots und Sicherungen gezielt greifen. + +--- + +## Benutzer im Container + +Das Paperless-Image startet seit 3.x bereits als unprivilegierter Benutzer +(1000:1000), **nicht als root**. Damit ist `USERMAP_UID`/`USERMAP_GID` +wirkungslos — dieser Mechanismus setzt voraus, dass das Einstiegsskript als +root beginnt und selbst herunterschaltet. Im Log erscheint dann: + +``` +[init-user] WARNING: USERMAP_UID/USERMAP_GID are set but have no effect + when the container is started as a non-root user +``` + +Setz die UID stattdessen fest in der Compose und richte den Dateibesitz +danach aus: + +```yaml + user: "1500:1500" +``` + +### Zwei Container, bei denen das falsch wäre + +**Postgres.** Sein Einstiegsskript korrigiert beim Start Besitzer und +Rechte des Datenverzeichnisses und schaltet dann selbst auf UID 999 +herunter. Ein erzwungener Benutzer lässt das scheitern: + +``` +chmod: changing permissions of '/var/lib/postgresql/data': Permission denied +``` + +Stattdessen gehört das Verzeichnis auf dem Host der 999: + +```bash +chown 999:999 data/pgdata +``` + +**Gotenberg.** Chromium legt beim Start ein Crashpad-Verzeichnis im Home +des internen Benutzers an. Unter fremder UID scheitert das: + +``` +chrome failed to start: chrome_crashpad_handler: --database is required +``` + +Symptom ist ein HTTP 500 beim Rendern von `.eml`. Unkritisch ohne eigene +UID, weil Gotenberg keine Bind Mounts hat und nur im internen Netz +erreichbar ist. + +--- + +## SELinux + +Auf Systemen mit SELinux im Enforcing-Modus — etwa openSUSE Tumbleweed — +brauchen Bind Mounts eine Kennzeichnung, sonst bekommen die Container +`Permission denied`, obwohl Besitzer und Rechte stimmen. + +Erkennbar am Punkt hinter den Rechten und am Kontext `unlabeled_t`: + +```bash +ls -ldZ data/pgdata +# drwxr-xr-x. 999 999 unconfined_u:object_r:unlabeled_t:s0 +``` + +`unlabeled_t` tritt typischerweise bei frisch angelegten +Btrfs-Subvolumes auf. Dauerhafte Lösung: + +```bash +zypper in policycoreutils-python-utils +semanage fcontext -a -t container_file_t "/pfad/zu/data(/.*)?" +restorecon -Rv /pfad/zu/data +``` + +Die `:z`-Option an den Bind Mounts wirkt nur, wenn der Docker-Daemon mit +`"selinux-enabled": true` läuft. Der `semanage`-Eintrag ist unabhängig +davon und überlebt Neustarts. + +**Setz das Label, bevor große Datenmengen hineinkopiert werden** — sonst +läuft `restorecon` später über den gesamten Bestand. + +--- + +## Konfiguration + +`.env.example` nach `.env` kopieren und anpassen. Geheimnisse erzeugen mit +`openssl rand -hex 32` — hexadezimal statt base64, dann enthält der Wert +garantiert keine Zeichen, die die Env-Datei durcheinanderbringen. + +### Was besonders zu beachten ist + +**`PAPERLESS_DBPASS` ausschreiben.** Docker Compose ersetzt Variablen nur +in der `docker-compose.yml`, nicht innerhalb einer über `env_file` +eingebundenen Datei. Ein `${POSTGRES_PASSWORD}` dort wird wörtlich +durchgereicht. + +**`PAPERLESS_FILENAME_FORMAT` einmal festlegen.** Eine spätere Änderung +benennt den gesamten Bestand um. Seit 3.x doppelte geschweifte Klammern: + +``` +PAPERLESS_FILENAME_FORMAT={{ created_year }}/{{ document_type }}/{{ title }} +``` + +**OCR-Einstellungen in 3.x-Syntax.** Die alten Werte `OCR_MODE=skip` und +`OCR_SKIP_ARCHIVE_FILE` sind entfallen: + +``` +PAPERLESS_OCR_MODE=auto +PAPERLESS_ARCHIVE_FILE_GENERATION=auto +``` + +`auto` überspringt OCR, wenn bereits ein Textlayer vorhanden ist — bei +einem Bestand aus einem anderen DMS der Regelfall. Der Unterschied liegt +bei Stunden gegen Tage. + +**`PAPERLESS_CONSUMER_IGNORE_PATTERNS` nicht setzen.** In 3.x ist das eine +Liste von **regulären Ausdrücken**, nicht von Glob-Mustern. Ein Eintrag wie +`*.part` lässt den Consumer beim Start abstürzen: + +``` +re.PatternError: nothing to repeat at position 0 +``` + +Die eingebauten Vorgaben decken `.DS_Store`, `Thumbs.db` und Ähnliches +bereits ab. + +**Consume-Ordner abschalten.** Während der Migration: + +``` +PAPERLESS_CONSUMER_POLLING=0 +``` + +**Worker hochdrehen**, aber mit Bedacht. Die Migration ist seriell je +Dokument, Parallelität nutzt vor allem den Sidecar-Diensten. + +--- + +## Starten + +```bash +docker compose up -d --build +docker compose logs -f webserver +``` + +Das `--build` ist nötig, wenn ein eigenes Image mit Plugins verwendet +wird. Ohne baut Compose nicht neu, sobald der Tag existiert. + +Erwartet wird eine saubere Startmeldung ohne Warnungen. Jede Warnung im +Log ist eine spätere Fehlerquelle — die drei häufigsten betreffen +OCR-Einstellungen, das Dateinamensformat und die Benutzerabbildung. + +--- + +## Benutzer und Gruppen anlegen + +**Vor dem ersten Import**, damit die Rechteübersetzung sie zuordnen kann. + +```bash +docker compose exec webserver python manage.py shell +``` + +```python +from django.contrib.auth.models import User, Group +for n in ["benutzer_a", "benutzer_b"]: + User.objects.get_or_create(username=n) +for g in ["Gruppe_A", "Gruppe_B"]: + Group.objects.get_or_create(name=g) + +U = lambda n: User.objects.get(username=n) +Group.objects.get(name="Gruppe_A").user_set.set([U("benutzer_a"), U("benutzer_b")]) + +for g in Group.objects.all(): + print(g.id, g.name, [u.username for u in g.user_set.all()]) +for u in User.objects.all(): + print(u.id, u.username) +``` + +**Notier die IDs.** Sie werden in `rollen.yml` und in +`PAPERLESS_DEFAULT_PERMISSIONS_OWNER` gebraucht — und dort erwartet +Paperless eine **numerische ID**, keinen Benutzernamen. Eine falsche ID +lässt jeden Konsumvorgang mit `Error while queuing document` scheitern. + +### Löschrechte + +Keiner Gruppe das Recht zum endgültigen Löschen geben. Zusammen mit +Audit-Log, Versionierung und Papierkorb-Frist ist das der vierte Baustein +der Nachvollziehbarkeit. + +--- + +## API-Token + +Im Benutzerprofil erzeugen, nicht in der allgemeinen Verwaltung. + +```bash +export PT=dein_token_40_zeichen +curl -s -H "Authorization: Token $PT" http://localhost:8000/api/documents/ +``` + +Erwartet wird `{"count":0,...}`. Kommt +`{"detail":"Authentication credentials were not provided."}`, fehlt der +Header-Name — die Variable enthält nur den Token, nicht `Authorization:`. + +Alternativ per Shell: + +```bash +docker compose exec webserver python manage.py shell -c \ +"from django.contrib.auth.models import User +from rest_framework.authtoken.models import Token +t,_=Token.objects.get_or_create(user=User.objects.get(username='DEIN_BENUTZER')) +print(t.key)" +``` + +--- + +## Snapshot + +Wenn Instanz, Benutzer, Gruppen und Token stehen: einen Snapshot des +Datenverzeichnisses anlegen. Das ist der Zustand, zu dem man beim Bau des +Migrationsskripts immer wieder zurückwill. + +```bash +docker compose down +btrfs subvolume snapshot -r data ../snapshots/paperless-vorbereitet +docker compose up -d +``` + +Auf ZFS entsprechend `zfs snapshot`. Container vorher stoppen, sonst ist +der Datenbankstand nicht konsistent zu den Dateien. + +--- + +Weiter mit [Export aus ecoDMS](02-export-aus-ecodms.md). diff --git a/docs/02-export-aus-ecodms.md b/docs/02-export-aus-ecodms.md new file mode 100644 index 0000000..ccd0a39 --- /dev/null +++ b/docs/02-export-aus-ecodms.md @@ -0,0 +1,234 @@ +# 2. Export aus ecoDMS + +Der Export läuft über das **Datenexport-Plugin**, das zum normalen +Lizenzumfang gehört. Eine API-Lizenz wird nicht gebraucht. + +--- + +## Warum nicht die Datenbank + +Naheliegend wäre, die ecoDMS-PostgreSQL direkt auszulesen. Das +funktioniert nicht: ecoDMS legt die Dateien in einem eigenen +Container-Speichersystem ab, weder als BLOB in der Datenbank noch als +normale Dateien im Archivverzeichnis. + +Und die Klassifizierungsdaten liegen dort ebenfalls nicht — in der +Live-Datenbank gibt es weder eine `klassifizierung`- noch eine +`docs`-Tabelle. Diese Struktur erzeugt erst der Exporter. + +Der Export ist also der einzige Weg an Inhalte **und** Metadaten. + +--- + +## Vorab: Bestandsaufnahme + +Bevor Zeit in Exporte fließt, sollte man die Größenordnung kennen. Am +einfachsten über einen kleinen Testexport (siehe unten) und die Auswertung +der Tabellen `ecosimsversions` und `docs`. + +Wichtige Zahlen: + +| Frage | Warum | +|---|---| +| Wie viele Dokumente haben mehr als eine Version? | bestimmt den Aufwand des schwierigen Teils | +| Wie viele haben **keine** Version? | brauchen einen Rückfall auf `ecosimsarchive` | +| Wie viele sind mehrfach klassifiziert? | bestimmt die Zusammenführungsregel | +| Welche Dateitypen kommen vor? | nicht alle kann Paperless konsumieren | +| Welche Rollen und Rechtearten? | müssen in der Übersetzung abgebildet sein | + +`ecodms_extract.py` beantwortet all das im Prüfbericht — es lohnt sich +deshalb, früh einen kleinen Export zu machen und ihn durch Stufe 1 zu +schicken. + +--- + +## Testexport zuerst + +**Nicht mit dem Gesamtbestand anfangen.** Ein Export von fünf bis zehn +Dokumenten, der bewusst alle Sonderfälle enthält, ist die beste +Investition: + +- ein Office-Dokument mit mehreren Versionen +- ein PDF mit mehreren Versionen +- ein Dokument ganz **ohne** Versionseinträge +- ein mehrfach klassifiziertes Dokument +- eine E-Mail (`.eml`) +- eine ZUGFeRD-Rechnung +- ein Dokument mit einer Notiz +- ein Dokument mit besonderen Rechten + +Gegen diesen Satz lässt sich die gesamte Migrationslogik entwickeln, ohne +je den Produktivbestand anzufassen. + +--- + +## Fallstrick: unerfüllbare Exportabfrage + +Bei manueller Mehrfachauswahl kann der Exporter eine Abfrage erzeugen, die +niemals zutrifft: + +```sql +docid = '11072' AND docid = '11094' AND docid = '11143' AND … +``` + +Das Ergebnis: ein Export mit vollständigen Stammdaten und **null +Dokumenten**. Ohne Fehlermeldung, ohne Warnung. + +**Nach jedem Export prüfen:** + +```bash +python3 -c " +import sqlite3, base64 +c = sqlite3.connect('file:export.data?mode=ro', uri=True) +print(base64.b64decode(c.execute('SELECT exp_query FROM ecodmsexporter').fetchone()[0]).decode()) +" +``` + +Steht dort `AND` zwischen den IDs statt `OR`, ist der Export leer. + +Vermeiden lässt es sich, indem man Exporte über ein **Suchkriterium** oder +einen **ID-Bereich** definiert statt über einzeln markierte Dokumente. + +--- + +## Tranchen bilden + +Bei größeren Beständen den Export in Blöcke teilen. Bewährt haben sich +etwa 2.000 Dokumente je Tranche. + +Empfohlene Struktur: + +``` +export-daten/ +├── export1/archive/{export.data, export.xml, *.pdf, …} +├── export2/archive/… +└── … +``` + +Nach ID-Bereichen teilen, nicht nach manueller Auswahl. Der Wurzelknoten +der XML trägt `startid` und `endid` — der Exporter denkt ohnehin in +Bereichen. + +**Tranchen dürfen sich überschneiden.** Das Journal überspringt bereits +importierte Dokumente. Das ist nützlich, wenn Teile des Bestands nur unter +einem anderen Benutzerkonto sichtbar sind: einfach eine zusätzliche +Tranche unter diesem Konto exportieren. + +--- + +## Prüfung je Tranche + +### 1. Abfrage plausibel? + +Siehe oben. + +### 2. Dokumentanzahl? + +```bash +python3 -c " +import sqlite3 +c = sqlite3.connect('file:export.data?mode=ro', uri=True) +for t in ('docs','klassifizierung','ecosimsversions','ecosimsarchive', + 'docrollen_hist','econotice'): + print(f'{t:20s}', c.execute(f'SELECT COUNT(*) FROM \"{t}\"').fetchone()[0]) +" +``` + +Sind `docs` und `klassifizierung` auf null, während `documentenart` und +`status` gefüllt sind, hat die Abfrage nicht gegriffen. + +### 3. Referenzen vollständig? + +Die XML verweist auf Dateien im Archivordner. Jede fehlende Referenz ist +ein Dokument, das beim Import scheitert: + +```bash +python3 -c " +import xml.etree.ElementTree as ET, pathlib +root = ET.parse('export.xml').getroot() +ref = {e.get('filePath') for e in root.iter() if e.get('filePath')} +have = {p.name for p in pathlib.Path('.').iterdir() if p.is_file()} +print('fehlt:', sorted(ref - have)[:20]) +print('unreferenziert:', sorted(have - ref - {'export.xml','export.data'})[:20]) +" +``` + +**Erwartete Fehlmeldungen:** Bei Nicht-PDF-Originalen referenziert die XML +eine PDF/A-Fassung je Version, die nicht exportiert wird. Das ist normal +und für Paperless unkritisch. + +### 4. Dateitypen? + +```bash +python3 -c " +import sqlite3, collections +c = sqlite3.connect('file:export.data?mode=ro', uri=True) +n = collections.Counter() +for (f,) in c.execute('SELECT realname FROM ecosimsversions WHERE realname IS NOT NULL'): + n['.' + f.rsplit('.',1)[-1].lower()] += 1 +for k, v in n.most_common(): print(f'{v:6d} {k}') +" +``` + +Vergleich mit dem, was die Zielinstanz kann: + +```bash +docker compose exec webserver python manage.py shell -c \ +"from documents.parsers import get_supported_file_extensions as f; print(sorted(f()))" +``` + +Typischerweise **nicht** unterstützt: `.html`, `.htm`, `.zip`, `.indd`. +`ecodms_extract.py` sortiert solche Dokumente aus und listet sie zur +Nacharbeit. + +### 5. Rollen und Rechtearten? + +```bash +python3 -c " +import sqlite3 +c = sqlite3.connect('file:export.data?mode=ro', uri=True) +for r in c.execute('SELECT DISTINCT role, doc_right FROM docrollen_hist ORDER BY role'): + print(r) +" +``` + +Jede Rolle muss in `rollen.yml` abgebildet oder ausdrücklich verworfen +werden. Stufe 2 bricht sonst vor dem ersten Upload ab — bewusst, denn ein +stilles Verwerfen würde Dokumente **öffnen**, nicht schließen. + +### 6. Mehrfachklassifizierung? + +```bash +python3 -c " +import sqlite3, collections +c = sqlite3.connect('file:export.data?mode=ro', uri=True) +n = collections.Counter(d for (d,) in c.execute('SELECT docid FROM docs')) +h = collections.Counter(n.values()) +for k in sorted(h): print(f'{h[k]:6d} Dokumente mit {k} Klassifizierung(en)') +" +``` + +--- + +## Was der Export nicht enthält + +| Element | Alternative | +|---|---| +| Dokumentverknüpfungen | nicht ermittelbar, Nacharbeit von Hand | +| Klassifizierungshistorie | steht in der XML, aber Paperless kann sie nicht abbilden | +| Anhänge als eigene Dateien | reisen eingebettet im Original mit | + +Der `lucene`-Ordner enthält den Suchindex des Offline-Clients und ist für +die Migration wertlos. + +--- + +## Speicherbedarf + +Der Export enthält je Version Original und teils PDF/A. Rechne mit etwa +dem Umfang der ecoDMS-Sicherung. Auf der Zielseite kommt Ähnliches +hinzu, weil Paperless Original und Archivdatei getrennt ablegt. + +--- + +Weiter mit [Extraktion und Prüfung](03-extraktion.md). diff --git a/docs/03-extraktion.md b/docs/03-extraktion.md new file mode 100644 index 0000000..69383ec --- /dev/null +++ b/docs/03-extraktion.md @@ -0,0 +1,210 @@ +# 3. Extraktion und Prüfung + +Stufe 1 liest eine Exporttranche und erzeugt ein normalisiertes +JSON-Manifest, einen Prüfbericht und eine Ausschlussliste. **Paperless wird +dabei nicht berührt.** + +Die Trennung ist Absicht: Der Lauf ist offline, beliebig wiederholbar, und +das Ergebnis lässt sich ansehen, bevor irgendetwas geschrieben wird. + +--- + +## Aufruf + +```bash +python3 ecodms_extract.py /pfad/export1/archive \ + -t export1 \ + -o export1.json \ + --skipped-csv export1-nacharbeit.csv +``` + +Als Pfad das Verzeichnis angeben, in dem `export.data` und `export.xml` +liegen — also `archive`, nicht das Tranchenverzeichnis darüber. + +--- + +## Der Prüfbericht + +``` +Tranche export1 + Dokumente 1963 + Uebersprungen 34 (.html 20, .zip 11, .indd 2, .dotx 1) + Versionen 2462 + Mehrfachklassifiziert 87 + Notizen 81 + Rollen FFN, r_benutzer_a, r_benutzer_b, ecoSIMSUSER + Rechtearten R, W + + Hinweise (12): + [nicht_unterstuetzt] 34 + [mehrfachklassifizierung] 87 + [ohne_rechte] 1204 + [nur_archiv] 143 + … +``` + +**Diesen Bericht lesen, bevor importiert wird.** Er beantwortet alle +Fragen, die sonst mitten im Import auftauchen. + +### Was die Kategorien bedeuten + +| Kategorie | Bedeutung | Handlung | +|---|---|---| +| `nicht_unterstuetzt` | Dateityp, den Paperless ablehnt | Nacharbeit, Liste in der CSV | +| `mehrfachklassifizierung` | Dokument in mehreren Ordnern | wird zusammengeführt, Konflikte protokolliert | +| `ohne_rechte` | keine Zeile in `docrollen_hist` | Regel in `rollen.yml` festlegen | +| `nur_archiv` | keine Versionszeilen, Rückfall auf `ecosimsarchive` | normal, kein Handlungsbedarf | +| `datei_fehlt` | Version verweist auf nicht vorhandene Datei | Export unvollständig, prüfen | +| `xml_referenz_ohne_datei` | XML verweist auf fehlende Datei | bei Office-Originalen normal | +| `geloeschte_stammdaten` | Dokumentart oder Status ist gelöscht markiert | meist unkritisch | +| `papierkorb` | Dokument ist im ecoDMS-Papierkorb | wird übersprungen | +| `notiz_ohne_bezug` | Notiz zeigt auf unbekannte `docs_id` | in anderer Tranche, oder verwaist | + +### Rollen prüfen + +Die Zeile `Rollen` ist die wichtigste für den nächsten Schritt. Jede +genannte Rolle muss in `rollen.yml` stehen — entweder mit einem Ziel oder +ausdrücklich als `type: null`. + +Stufe 2 bricht sonst ab, bevor die erste Datei hochgeht. + +--- + +## Das Manifest + +Je Dokument entsteht ein Eintrag: + +```json +{ + "ecodms_docid": 10347, + "tranche": "export1", + "title": "Rechnung Beispiel GmbH", + "created": "2025-07-29", + "document_type": "Rechnung", + "status": "Erledigt", + "folders": ["Allgemeines / Haushalt / Kleingeräte", + "Allgemeines / Steuer / ESt / Anlage N"], + "folder_parts": [["Allgemeines","Haushalt","Kleingeräte"], + ["Allgemeines","Steuer","ESt","Anlage N"]], + "custom_fields": { + "Belegnummer": {"value": "902500503118", "type": "String"}, + "Wiedervorlage": {"value": "2026-02-02", "type": "Date"} + }, + "versions": [ + {"version": 1, "file": "ecodms_docid_0010347_revision_0001.pdf", + "saved_at": "2025-07-29 14:12:03.881", "checksum": "…", + "source": "ecosimsversions"} + ], + "permissions": [{"role": "r_benutzer_a", "right": "W"}], + "notes": [{"created": "2014-07-14 00:45:00", "text": "…", "user": ""}], + "docs_ids": [10829, 10834], + "conflicts": {} +} +``` + +**`folder_parts` ist maßgeblich, `folders` nur für die Anzeige.** +Ordnernamen können Schrägstriche enthalten (`Telefon / Internet`) — ein +späteres Zerlegen am Schrägstrich würde solche Namen zerschneiden. + +**`conflicts`** wird bei Mehrfachklassifizierung gefüllt, wenn Titel, +Dokumentart, Status, Datum oder Wiedervorlage zwischen den Instanzen +abweichen. Dann gewinnt der jüngste `ctimestamp`, aber man sieht, dass +etwas verworfen wurde. + +--- + +## Die Ausschlussliste + +`--skipped-csv` schreibt alle Dokumente, deren Dateityp Paperless nicht +konsumieren kann: + +```csv +ecodms_docid,endungen,titel,erstellt,ordner,dateien +1247,.html,Webseite Beispiel,2019-03-12,Allgemeines | EDV,ecodms_docid_0001247_revision_0001.html +1533,.zip,Belege Sammlung,2020-11-04,Steuer,ecodms_docid_0001533_revision_0001.zip +``` + +Diese Dokumente sind **nicht** im Manifest. Sie scheitern deshalb nicht +beim Import, sondern stehen sauber auf einer Liste. + +Die Liste der akzeptierten Endungen steht als `SUPPORTED_EXTENSIONS` oben +im Skript. Sie sollte gegen die Zielinstanz geprüft werden: + +```bash +docker compose exec webserver python manage.py shell -c \ +"from documents.parsers import get_supported_file_extensions as f; print(sorted(f()))" +``` + +--- + +## Regeln, die Stufe 1 anwendet + +Alle stammen aus der Analyse eines produktiven Bestands. Ausführlich in +[referenz-ecodms-exportformat.md](referenz-ecodms-exportformat.md). + +1. **Nach `docid` gruppieren, nicht nach `docs_id`.** Eine `docid` kann + mehrere Klassifizierungsinstanzen haben. +2. **Ordner vereinigen, jüngster `ctimestamp` gewinnt** bei skalaren + Feldern. +3. **Immer `realname` verwenden, nie `pdfrealname`.** Sonst gehen + eingebettete ZUGFeRD-XML und E-Mail-Anhänge verloren. +4. **Ohne Versionszeilen auf `ecosimsarchive` zurückfallen.** Sonst + verschwinden solche Dokumente stillschweigend. +5. **`docrollen_hist.docid` enthält `docs_id`.** Join über `docs.id`. +6. **Nur die höchste in `docrollen_hist` vorhandene Revision** je + `docs_id` gilt. Ältere enthalten noch das globale Importrecht. +7. **Revisionen als Zahlentupel sortieren.** `'1.10'` ist lexikografisch + kleiner als `'1.9'`. +8. **Base64 selektiv dekodieren.** `savedate` und `filename` sind + kodiert, `realname` und `pdfrealname` nicht. +9. **Wiedervorlage aus `klassifizierung.defdate`**, nicht aus + `dynattribute`. +10. **`econotice.nid` enthält `docs_id`**, der Text ist base64-kodiertes + Qt-HTML. + +--- + +## Wiederholbarkeit + +Stufe 1 schreibt nichts außer den Ausgabedateien. Sie kann beliebig oft +laufen — bei jeder Änderung an den Regeln, bei jedem neuen Export. + +Nach einer Änderung am Skript **alle Manifeste neu erzeugen**, damit sie +dasselbe Format haben. + +--- + +## Alle Tranchen auf einmal + +```bash +for n in 1 2 3 4 5 6; do + python3 ecodms_extract.py "export-daten/export$n/archive" \ + -t "export$n" -o "export$n.json" \ + --skipped-csv "export$n-nacharbeit.csv" +done +``` + +Danach eine Gesamtübersicht der Dateitypen: + +```bash +python3 -c " +import json, glob, collections +v = collections.Counter(); d = collections.Counter() +for f in sorted(glob.glob('export*.json')): + for doc in json.load(open(f))['documents']: + exts = set() + for ver in doc['versions']: + e = '.' + ver['file'].rsplit('.',1)[-1].lower() + v[e] += 1; exts.add(e) + for e in exts: d[e] += 1 +print(f\"{'Endung':10s} {'Versionen':>10s} {'Dokumente':>10s}\") +for k, n in v.most_common(): print(f'{k:10s} {n:10d} {d[k]:10d}') +" +``` + +Das ist die Zahl, die vor dem Import bekannt sein sollte — sonst stolpert +man bei Tranche 4 über einen Dateityp, der in Tranche 1 nicht vorkam. + +--- + +Weiter mit [Import](04-import.md). diff --git a/docs/04-import.md b/docs/04-import.md new file mode 100644 index 0000000..7447189 --- /dev/null +++ b/docs/04-import.md @@ -0,0 +1,296 @@ +# 4. Import + +Stufe 2 spielt ein Manifest über die REST-API in Paperless ein und führt +dabei ein eigenes SQLite-Journal. + +--- + +## Das Journal + +`migration.sqlite` ist der Kern. Es hält je Dokument fest: + +| Feld | Bedeutung | +|---|---| +| `ecodms_docid` | Quell-ID | +| `paperless_id` | Ziel-ID | +| `versions_total` / `versions_done` | Fortschritt der Kette | +| `status` | `pending`, `root_created`, `done`, `failed`, `uebersprungen` | +| `last_error` | Fehlertext im Klartext | + +Daraus folgen drei Eigenschaften: + +- **Wiederaufsetzbar.** Ein abgebrochener Lauf setzt fort, ohne Duplikate. +- **Tranchen dürfen sich überschneiden.** Bekannte Dokumente werden + übersprungen. +- **Tranchenübergreifend auswertbar.** Nötig für Zeitstempel und + Verknüpfungen am Ende. + +Das Journal **nicht** löschen, solange die Migration läuft. Und nicht +zusammen mit dem Paperless-Bestand zurücksetzen, ohne beides gemeinsam zu +tun — sonst entstehen Doppelanlagen. + +--- + +## Rollenübersetzung + +`rollen.yml.example` nach `rollen.yml` kopieren und anpassen. + +```yaml +default_owner: benutzer_a + +roles: + r_benutzer_a: + type: user + paperless: benutzer_a + owner: true # wird Eigentümer, wenn beteiligt + FFN: + type: group + paperless: Gruppe_A + ecoSIMSUSER: + type: null # globales Importrecht, entfällt bewusst + +rights: + R: [view] + W: [view, change] + +no_rights_policy: owner_only +``` + +### Warum `ecoSIMSUSER` verworfen wird + +Das ist das globale Importrecht — jeder hat Zugriff. Es wird beim Import +gesetzt und bei der ersten Klassifizierung ersetzt. Wer es übernimmt, +öffnet jedes Dokument für alle. + +### Warum `W` beide Rechte bekommt + +In Paperless implizieren `change` und `view` einander nicht. Ein `change` +ohne `view` ergibt keinen sinnvollen Zustand. + +### Warum unbekannte Rollen zum Abbruch führen + +**In Paperless bedeutet „keine Rechte gesetzt" nicht gesperrt, sondern +unbeschränkt.** Ein Tippfehler im Rollennamen würde Dokumente öffnen, nicht +schließen. Deshalb: entweder abbilden oder ausdrücklich als `type: null` +eintragen. + +--- + +## Zusatzfelder vorab anlegen + +```yaml +custom_fields: + Belegnummer: string + Zeitraum: string + Eingangsdatum: date + Zahlungsdatum: date + Wiedervorlage: date + ecoDMS-ID: string + ecoDMS-Verknuepfung: documentlink +``` + +**Nicht von Hand in der Oberfläche anlegen.** Weicht der Name auch nur +minimal ab, erzeugt das Skript ein zweites gleichnamiges Feld, und die +Werte verteilen sich auf beide. + +--- + +## Ablauf + +### Trockenlauf + +```bash +export PT=dein_api_token +python3 paperless_import.py export1.json --dry-run +``` + +Schreibt nichts, prüft aber die Rollen gegen `rollen.yml` und meldet +Unbekanntes. + +### Stammdaten + +```bash +python3 paperless_import.py export1.json --setup +``` + +Legt Tags, Dokumenttypen und Zusatzfelder an. Vorhandene werden erkannt. + +Danach in der Oberfläche ansehen: Passen die Tags? Bei tiefen +Ordnerbäumen entstehen schnell mehrere hundert. Das ist der Moment, es zu +ändern — später kostet es eine Umstellung des gesamten Bestands. + +### Probelauf + +```bash +python3 paperless_import.py export1.json --limit 2 --stop-on-error +``` + +Zwei Dokumente, Abbruch beim ersten Fehler. Danach in der Oberfläche +prüfen: + +- Tags einzeln statt als Pfad? +- Archiv-Seriennummer gleich der ecoDMS-ID? +- Zusatzfelder gefüllt? +- Eigentümer gesetzt? +- Bei einem mehrversionigen Dokument: Kette vollständig? + +### Volle Tranche + +```bash +python3 paperless_import.py export1.json +``` + +Ohne `--stop-on-error`, damit einzelne Fehlschläge den Lauf nicht +anhalten. Sie landen als `failed` im Journal. + +### Prüfen + +```bash +python3 paperless_import.py --verify +``` + +Vergleicht Journal und Instanz. Meldet abweichende Versionszahlen, +fehlende Eigentümer, abweichende Seriennummern und **Dokumente ohne +Journaleintrag** — das sind mögliche Doppelanlagen. + +--- + +## Tag-Modus + +Standard ist `segments`: Aus `Beruf/Fortbildung/Steuerberater` werden drei +Tags. Das erlaubt Filtern nach einzelnen Ebenen, kostet aber den +Hierarchiekontext — `Steuerberater` allein ist mehrdeutig. + +Alternative: + +```bash +python3 paperless_import.py export1.json --tag-mode path +``` + +Ein Tag je vollständigem Pfad. Übersichtlicher, aber nicht nach Ebenen +filterbar. + +**Die Entscheidung vor dem ersten `--setup` treffen.** Ein Wechsel später +bedeutet, alle Tags neu zu vergeben. + +--- + +## Was Stufe 2 je Dokument tut + +``` +POST /api/documents/post_document/ → Version 1, mit Titel, created, + Dokumenttyp, Tags + Task pollen → paperless_id ins Journal +POST /api/documents/{id}/update_version/ → Version 2…n, mit version_label + Task pollen, streng seriell +PATCH /api/documents/{id}/ → Eigentümer, Rechte, + Zusatzfelder, Seriennummer +``` + +### Warum seriell + +`update_version` hängt an den aktuellen Head an. Innerhalb einer +Versionskette muss jede Version fertig konsumiert sein, bevor die nächste +kommt. Parallelisiert wird über Dokumente hinweg, nicht innerhalb. + +### Rechte nur auf die Wurzel + +Versionen sind eigene Dokumentdatensätze, erben die Rechte aber über +`root_document`. Ein Zugriffstest mit einem nicht berechtigten Benutzer +liefert bei allen Versionen 403. Es genügt also, die Rechte einmal auf das +Wurzeldokument zu setzen. + +### Was die API nicht kann + +`update_version` nimmt ausschließlich `document` und `version_label` +entgegen — **keinen Zeitstempel**. Das `added`-Feld je Version muss +nachträglich gesetzt werden, siehe [Nacharbeit](05-nacharbeit.md). + +--- + +## Fehler behandeln + +```bash +sqlite3 -header -column migration.sqlite \ + "SELECT tranche, status, COUNT(*) FROM mapping GROUP BY tranche, status;" + +sqlite3 -header migration.sqlite \ + "SELECT ecodms_docid, last_error FROM mapping WHERE status='failed' LIMIT 20;" +``` + +Nach einer Korrektur die Fehlschläge erneut anstoßen: + +```bash +sqlite3 migration.sqlite \ + "UPDATE mapping SET status='pending', last_error=NULL WHERE status='failed';" +python3 paperless_import.py export1.json +``` + +Dauerhaft nicht importierbare Dokumente auf einen eigenen Status setzen, +damit sie nicht bei jedem Lauf erneut versucht werden: + +```bash +sqlite3 migration.sqlite \ + "UPDATE mapping SET status='uebersprungen' WHERE ecodms_docid IN (…);" +``` + +### Häufige Fehlerbilder + +| Meldung | Ursache | +|---|---| +| `File type … not supported` | Dateityp, oder Inhalt passt nicht zur Endung | +| `HTTP 406` bei Office/`.eml` | Tika-Version zu neu | +| `Error while queuing document` | oft eine falsche `DEFAULT_PERMISSIONS_OWNER`-ID | +| `Task … nach 600s ohne Ergebnis` | großer Scan mit OCR, `TASK_TIMEOUT` erhöhen | +| `HTTP 400` beim Anlegen eines Tags | Namenskollision nach Normalisierung | + +### Zwischenzustand nach Abbruch + +Wird ein Lauf zwischen Upload und Journaleintrag unterbrochen, existiert +das Dokument in Paperless ohne Journaleintrag. `--verify` findet das. + +Behandlung: Zeigt das Journal `failed` und das Dokument existiert, ist der +Eintrag nachzutragen statt neu hochzuladen: + +```bash +sqlite3 migration.sqlite \ + "UPDATE mapping SET paperless_id=, versions_done=1, + status='root_created', last_error=NULL WHERE ecodms_docid=;" +python3 paperless_import.py exportN.json +``` + +Der Lauf ergänzt dann Rechte, Zusatzfelder und Seriennummer. + +--- + +## Snapshots + +Vor jeder Tranche einen Snapshot des Datenverzeichnisses: + +```bash +docker compose down +btrfs subvolume snapshot -r data ../snapshots/paperless-vor-tranche2 +docker compose up -d +``` + +Container vorher stoppen, sonst ist der Datenbankstand nicht konsistent zu +den Dateien. Beim ersten Durchlauf wird man das brauchen. + +--- + +## Laufzeit + +Bei PDFs mit vorhandenem Textlayer und `OCR_MODE=auto` liegt ein +Konsumvorgang unter einer Sekunde. Für 12.000 Versionen bedeutet das +wenige Stunden. + +Der Engpass ist nicht die CPU, sondern die serielle Arbeitsweise plus das +Abfrageintervall. Mehr Worker beschleunigen nichts, solange immer nur eine +Aufgabe gleichzeitig läuft. + +Deutlich länger dauern Office-Dokumente und E-Mails, weil sie über +Gotenberg und Tika laufen. + +--- + +Weiter mit [Nacharbeit](05-nacharbeit.md). diff --git a/docs/05-nacharbeit.md b/docs/05-nacharbeit.md new file mode 100644 index 0000000..4baa0f6 --- /dev/null +++ b/docs/05-nacharbeit.md @@ -0,0 +1,250 @@ +# 5. Nacharbeit + +Diese Schritte laufen **nach allen Tranchen**, nicht zwischendurch. +Zeitstempel und Verknüpfungen brauchen ein vollständiges Journal. + +Vorher einen Snapshot anlegen — die ersten beiden Schritte greifen direkt +in die Datenbank ein. + +--- + +## 1. Zeitstempel setzen + +Der Endpunkt `update_version` nimmt keinen Zeitstempel entgegen. Alle +Versionen tragen deshalb zunächst das Importdatum statt des +ecoDMS-Zeitstempels. + +```bash +python3 paperless_import.py --emit-timestamps > fix_added.py +wc -l fix_added.py +head -20 fix_added.py +``` + +Kurz hineinsehen: Die Liste sollte plausible Datumswerte enthalten und +ungefähr so viele Einträge haben wie Versionen vorhanden sind. + +```bash +docker compose exec -T webserver python manage.py shell < fix_added.py +``` + +Das erzeugte Skript benutzt `Document.objects.filter(pk=…).update(added=…)` +statt `save()`. Das ist wichtig: `update()` umgeht `auto_now`-Felder und +Signal-Handler, die den Wert sonst wieder überschreiben und nebenbei +Reindexierungen auslösen würden. + +### Prüfen + +```bash +curl -s -H "Authorization: Token $PT" \ + "http://localhost:8000/api/documents/?archive_serial_number=" \ + | python3 -c "import sys,json; print(json.dumps(json.load(sys.stdin)['results'][0]['versions'], indent=2))" +``` + +Die `added`-Werte müssen aus dem Quellzeitraum stammen, nicht vom +Importdatum. + +> Die `versions`-Liste ist **absteigend** sortiert, neueste zuerst. + +--- + +## 2. Notizen übertragen + +```bash +python3 ecodms_notizen.py --exports /pfad/export-daten --tranchen 1-6 \ + --dry-run --csv notizen.csv +``` + +Der Trockenlauf schreibt nichts, erzeugt aber die vollständige Liste. **In +die CSV sehen, bevor übertragen wird:** Bei jeder Zeile stehen die +aufgelöste `ecodms_docid`, die Paperless-ID, der Titel und der Notiztext. +Passen Titel und Notiz inhaltlich zusammen, stimmt die Zuordnung. + +```bash +python3 ecodms_notizen.py --exports /pfad/export-daten --tranchen 1-6 \ + --csv notizen-uebertragen.csv +``` + +Der Lauf ist wiederholbar — textgleiche Notizen am selben Dokument werden +übersprungen. + +### Warum über die Exporte + +`econotice.nid` enthält die `docs_id`. Die Auflösung nach `docid` steht in +der `docs`-Tabelle, und die existiert nur im Export, nicht in der +Live-Datenbank. + +Das Skript liest deshalb **alle** Tranchen ein und baut eine gemeinsame +Zuordnungstabelle, bevor es überträgt — eine Notiz kann zu einem Dokument +aus einer anderen Tranche gehören. + +--- + +## 3. Klassifikator und Suchindex + +```bash +docker compose exec webserver python manage.py document_create_classifier +docker compose exec webserver python manage.py document_index reindex +``` + +Der Reindex über einen großen Bestand dauert. + +Das ist der unterschätzte Nebeneffekt der Migration: Der Klassifikator hat +jetzt einen vollständig klassifizierten Bestand als Trainingsbasis. Neue +Dokumente werden dadurch von Anfang an brauchbar vorgeschlagen. + +--- + +## 4. Verknüpfungen + +Dokumentverknüpfungen sind im Export nicht enthalten und in der +Live-Datenbank nicht auffindbar. Sie müssen von Hand nachgetragen werden. + +Falls die Paare aus einer anderen Quelle vorliegen, geht es automatisch: + +```csv +src_docid,dst_docid +10346,10351 +``` + +```bash +python3 paperless_import.py --links verknuepfungen.csv +``` + +Das Skript setzt **beide Richtungen** ausdrücklich. Die automatische +Gegenrichtung von Paperless greift bei Massenbearbeitung nachweislich +nicht. + +Von Hand geht es über das Zusatzfeld vom Typ Document Link. Die +Archiv-Seriennummer entspricht der ecoDMS-ID, das Auffinden ist also +unkompliziert. + +--- + +## 5. Ausschlusslisten abarbeiten + +Zwei Gruppen sind liegengeblieben: + +**Nicht unterstützte Dateitypen** — aus `exportN-nacharbeit.csv`. +Typischerweise `.html`, `.zip`, `.indd`. Je nach Inhalt: HTML über den +Browser als PDF drucken, ZIP entpacken und einzeln ablegen, oder gar nicht +übernehmen. + +**Fehlschläge aus dem Journal:** + +```bash +sqlite3 -header -csv migration.sqlite \ + "SELECT ecodms_docid, tranche, last_error FROM mapping + WHERE status IN ('failed','uebersprungen');" > nacharbeit-journal.csv +``` + +Ein wiederkehrendes Muster: `.eml`-Dateien, die Paperless als `text/html` +erkennt. Es sind gültige E-Mails mit korrekten Kopfzeilen, aber die +Inhaltserkennung übergewichtet den HTML-Rumpf. Ein ausdrücklich +mitgegebener MIME-Typ hilft nicht — Paperless prüft den Inhalt. + +Behandlung: Mail im Mailprogramm öffnen und als PDF drucken. Das Ergebnis +ist meist besser lesbar als Paperless' eigene Darstellung. + +--- + +## 6. Abschlussprüfung + +```bash +python3 paperless_import.py --verify + +sqlite3 -header -column migration.sqlite \ + "SELECT status, COUNT(*) FROM mapping GROUP BY status;" +``` + +Stichproben in der Oberfläche: + +- Dokument mit mehreren Versionen — Kette und Zeitstempel korrekt? +- Dokument mit Notiz — Text vorhanden und beim richtigen Dokument? +- Mehrfach klassifiziertes Dokument — alle Tags vorhanden? +- ZUGFeRD-Rechnung — eingebettete XML noch da? + ```bash + docker compose exec webserver bash -c \ + "pdfdetach -list /usr/src/paperless/media/documents/originals/*/*.pdf" | head + ``` +- Volltextsuche nach einem bekannten Begriff +- **Anmeldung als eingeschränkter Benutzer** — sieht er nur seine + Dokumente? + +Der letzte Punkt ist der wichtigste. Ein Dokument ohne Eigentümer ist in +Paperless für jeden sichtbar, und in der Oberfläche fällt das als +Superuser nicht auf. + +```bash +export PT2=token_eines_eingeschraenkten_benutzers +curl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: Token $PT2" \ + http://localhost:8000/api/documents// +``` + +403 ist richtig. + +--- + +## 7. Umzug in die Produktivumgebung + +Falls die Migration auf einer anderen Maschine lief: + +```bash +# Quelle +docker compose stop webserver +docker compose exec -T db pg_dump -U paperless -Fc paperless > paperless.dump + +rsync -rlt --no-perms --no-owner --no-group --info=progress2 \ + data/media/ ziel:/pfad/media/ +rsync -rlt --no-perms --no-owner --no-group --info=progress2 \ + data/data/ ziel:/pfad/data/ +``` + +`--no-perms --no-owner --no-group` ist wichtig, wenn das Ziel eigene +Besitzverhältnisse oder ACLs hat — sonst überschreibt rsync sie mit denen +der Quelle. + +Auf dem Ziel **erst nur Datenbank und Broker** starten, damit Paperless +nicht auf ein leeres Schema trifft und ein frisches anlegt: + +```bash +docker compose up -d db broker +docker compose logs -f db # auf "ready to accept connections" warten + +docker compose exec -T db \ + pg_restore -U paperless -d paperless --clean --if-exists < paperless.dump + +docker compose exec -T db psql -U paperless -d paperless -c \ + "SELECT count(*) FROM documents_document;" + +docker compose up -d +``` + +### Was identisch sein muss + +| Wert | Warum | +|---|---| +| `PAPERLESS_SECRET_KEY` | entwertet sonst alle Tokens und Freigabelinks | +| `PAPERLESS_FILENAME_FORMAT` | benennt sonst den gesamten Bestand um | +| Postgres-Hauptversion | ein Dump aus 17 läuft nicht in 16 | +| Paperless-Version | gleich oder neuer, nie älter | + +Die Quellinstanz stehen lassen, bis die Zielinstanz nachweislich läuft. +Ein paar Wochen produktiver Betrieb sind eine bessere Freigabe als jede +Prüfliste. + +--- + +## 8. Betrieb + +Was nach der Migration eingerichtet werden sollte, aber nicht Teil davon +ist: + +- Reverse Proxy mit TLS, Paperless nie direkt exponieren +- Zwei-Faktor für alle Konten +- Sicherung: `pg_dump` plus `media` und `data`. Das Datenverzeichnis von + Postgres **nicht** auf Dateiebene sichern — der Stand wäre inkonsistent +- Consume-Ordner und Mail-Abruf +- Papierkorb-Frist und Audit-Log + +Und der Teil, den man auslässt: **einmal wiederherstellen.** Eine +Sicherung, die nie zurückgespielt wurde, ist eine Vermutung. diff --git a/docs/referenz-ecodms-exportformat.md b/docs/referenz-ecodms-exportformat.md new file mode 100644 index 0000000..d57595d --- /dev/null +++ b/docs/referenz-ecodms-exportformat.md @@ -0,0 +1,378 @@ +# Das ecoDMS-Exportformat + +Nachschlagewerk zu den Eigenheiten des Exports. Der größte Teil davon steht +in keiner Dokumentation und wurde bei der Migration eines gewachsenen +Bestands aufgedeckt. + +**Wer nur ein Dokument aus diesem Repository liest, sollte es dieses sein.** +Fast jeder der hier beschriebenen Punkte kann zu stiller Fehlzuordnung +führen — zu Dokumenten am falschen Platz, zu Rechten an der falschen +Stelle, zu verlorenen Versionen. + +--- + +## Aufbau eines Exports + +Das ecoDMS-Exportwerkzeug erzeugt ein ZIP mit dieser Struktur: + +``` +offline_export/ +├── archive/ +│ ├── export.data SQLite-Datenbank mit Metadaten +│ ├── export.xml dieselben Daten plus Historie +│ ├── lucene/ Suchindex des Offline-Clients +│ ├── ecodms_docid_0010325.pdf Kopfversion als PDF/A +│ ├── ecodms_docid_0010325_revision_0001.odt Original je Version +│ └── … +└── (Programmdateien des Offline-Clients) +``` + +**Die Dateien liegen im Dateisystem, nicht in der Datenbank.** Die +BLOB-Spalten `data`, `pdf` und `ocrtext` in `export.data` sind +durchgehend `NULL`. Das ist gut so — eine SQLite mit dem gesamten +Dateibestand wäre unhandlich. + +--- + +## Fallstrick 1: `docid` gegen `docs_id` + +**Der wichtigste Punkt des ganzen Dokuments.** + +ecoDMS führt zwei Nummern, die leicht verwechselt werden: + +| Nummer | Bedeutung | Wo | +|---|---|---| +| `docid` | das Dokument | `klassifizierung.docid`, `ecosimsversions.docid` | +| `docs_id` | die Klassifizierungsinstanz | `docs.id` | + +Bei niedrigen Nummern stimmen beide zufällig überein. Bei höheren laufen +sie auseinander: + +``` +docs.id 718 → docid 697 +docs.id 2022 → docid 1960 +``` + +Ein Join über die falsche Spalte liefert deshalb bei alten Dokumenten +korrekte Ergebnisse und bei neuen falsche. Das fällt beim Testen mit +wenigen Datensätzen **nicht** auf. + +### Verschärfung: falsch benannte Spalten + +Zwei Tabellen haben eine Spalte namens `docid`, die in Wahrheit die +`docs_id` enthält: + +```sql +CREATE TABLE docrollen_hist(docid bigint, role varchar, doc_right char(1), revision varchar); +-- ^^^^^ enthält docs_id +CREATE TABLE econotice(id bigint, text vcharacter, tdate timestamp, nid vcharacter, username varchar); +-- ^^^ enthält docs_id +``` + +Der korrekte Join ist in beiden Fällen `… = docs.id`. + +### So prüft man es + +Nicht an Datensätzen mit niedrigen Nummern — dort sind beide Werte gleich. +Such gezielt Fälle mit Abweichung und prüf **inhaltlich**, welche Auflösung +zum Datensatz passt: + +```sql +SELECT n.id, n.nid, + (SELECT docid FROM docs WHERE id = CAST(n.nid AS INTEGER)) AS als_docs_id, + (SELECT id FROM docs WHERE docid = CAST(n.nid AS INTEGER)) AS als_docid +FROM econotice n +WHERE als_docs_id IS NOT als_docid; +``` + +Dann die Bemerkung der beiden Kandidaten ansehen. Eine Notiz „an Herrn X +gesendet" gehört zum Dienstplan, nicht zur Zinsmitteilung der Bank. + +--- + +## Fallstrick 2: Mehrfachklassifizierung + +Ein Dokument kann in ecoDMS **mehrfach abgelegt** sein — dieselbe Datei in +zwei Ordnern, mit je eigener Klassifizierung. + +``` +docs.id 10829 (kid 22325) → Haushalt/Kleingeräte, Zeitraum leer +docs.id 10834 (kid 22540) → Steuer/ESt/Anlage N, Zeitraum 2025 + beide: docid 10347 +``` + +Daraus folgt: **Eine `docid` entspricht nicht einer Zeile in `docs`.** +Wer nach `docs_id` gruppiert, legt das Dokument zweimal an. + +Paperless bildet das sogar besser ab, weil Tags many-to-many sind: Aus zwei +Ablageorten wird ein Dokument mit den Tags beider Pfade. Man braucht aber +eine Zusammenführungsregel für die skalaren Felder — bewährt hat sich: der +jüngste `ctimestamp` gewinnt, Ordner werden vereinigt, Abweichungen bei +Titel, Dokumentart, Status und Datum werden protokolliert. + +Häufigkeit im Testbestand: **eines von vier** Dokumenten einer Stichprobe. +Nicht als Randfall behandeln. + +--- + +## Fallstrick 3: Revision ist nicht Version + +Zwei völlig unabhängige Zähler: + +| Begriff | Wo | Zählt | +|---|---|---| +| `klassifizierung.revision` | `1.0`, `1.1`, `1.2` … | Klassifizierungsänderungen | +| `ecosimsversions.version` | `1`, `2`, `3` … | Dateiversionen | + +Beispiele aus einem echten Bestand: + +| docid | Dateiversionen | Revision | +|---|---|---| +| 10325 | 4 | 1.3 (= 4. Revision) | +| 10422 | **3** | **1.1 (= 2. Revision)** | +| 11072 | **0** | 1.5 (= 6. Revision) | +| 11141 | 1 | 1.2 (= 3. Revision) | + +Es gibt keine Umrechnung. Für die Versionskette in Paperless zählt +ausschließlich `ecosimsversions.version`. + +**Und beachte `docid 11072`:** null Dateiversionen. Solche Dokumente haben +keine Zeile in `ecosimsversions` und müssen auf `ecosimsarchive` +zurückfallen, sonst verschwinden sie stillschweigend. + +--- + +## Fallstrick 4: Immer das Original importieren + +`ecosimsversions` führt zwei Dateinamen: + +| Spalte | Inhalt | +|---|---| +| `realname` | Originaldatei | +| `pdfrealname` | PDF/A-Fassung | + +**Immer `realname` verwenden.** Der Unterschied ist nicht kosmetisch: + +- Bei **ZUGFeRD** enthält das Original die eingebettete XML. Eine + neu gerenderte Fassung womöglich nicht. +- Bei **E-Mails** enthält die `.eml` die Anhänge. Das gerenderte PDF nicht. + In einem Testfall: 34,0 KB Original gegen 25,8 KB PDF — die Differenz + war der Rechnungsanhang. + +Bei reinen PDF-Dokumenten sind beide Spalten identisch, die Regel kostet +dort also nichts. + +### Was fehlt + +Bei **Nicht-PDF-Originalen** exportiert ecoDMS pro Version nur das +Original, nicht die PDF/A-Fassung — obwohl `pdfrealname` sie referenziert. +Die XML verweist dann auf Dateien, die im Archiv nicht existieren. + +Für Paperless unkritisch, es erzeugt Archivdateien selbst. Aber ein +Skript, das blind allen Referenzen folgt, läuft ins Leere. + +--- + +## Fallstrick 5: Rechte sind historisiert + +`docrollen_hist` ist eine Historientabelle. Sie enthält alle jemals +gesetzten Rechte, nicht nur die aktuellen: + +``` +docs_id 10829 ecoSIMSUSER W 1.0 +docs_id 10829 r_benutzer_a W 1.1 +docs_id 10829 r_benutzer_b W 1.1 +``` + +`ecoSIMSUSER` ist das **globale Importrecht** — jeder hat Zugriff. Es wird +beim Import gesetzt und bei der ersten Klassifizierung durch die echten +Berechtigten ersetzt. + +**Wer alle Zeilen übernimmt, öffnet jedes Dokument für alle.** + +Maßgeblich sind nur die Zeilen mit der **höchsten in `docrollen_hist` +vorhandenen** Revision je `docs_id`. Nicht der Revisionswert aus +`klassifizierung` — der kann höher sein, wenn zuletzt nur die +Klassifizierung geändert wurde. + +### Revisionen richtig sortieren + +`revision` ist ein String. Lexikografisch ist `'1.10'` kleiner als +`'1.9'`. Bei einem Bestand mit mehr als neun Revisionen führt das zu +falschen Ergebnissen. Als Tupel aus Integern parsen: + +```python +def revtuple(r): + return tuple(int(p) for p in str(r).split(".")) +``` + +### Dokumente ohne Rechteeintrag + +Viele Dokumente haben **gar keine Zeile** in `docrollen_hist` — in einer +Stichprobe sechs von acht. Die Tabelle speichert offenbar nur ausdrücklich +vergebene Berechtigungen. + +Das ist der gefährlichste Punkt der ganzen Migration, denn: + +> **In Paperless bedeutet „keine Rechte gesetzt" nicht gesperrt, sondern +> unbeschränkt.** + +Ein Dokument ohne Eigentümer und ohne Objektrechte ist für jeden +angelegten Benutzer sichtbar. Die Migration muss deshalb jedem Dokument +einen Eigentümer geben, auch wenn aus ecoDMS nichts überliefert ist. + +--- + +## Fallstrick 6: Base64 an unregelmäßigen Stellen + +Manche Felder sind base64-kodiert, benachbarte nicht: + +| Kodiert | Klartext | +|---|---| +| `ecosimsversions.savedate` | `ecosimsversions.realname` | +| `ecosimsversions.filename` | `ecosimsversions.pdfrealname` | +| `ecosimsarchive.version` | `ecosimsarchive.filename` | +| `econotice.text` | `econotice.tdate` | +| XML `` | XML `letzte-änderung` | +| `ecodmsexporter.exp_query` | | + +Es gibt kein erkennbares System. Im Zweifel dekodieren und bei einem +Fehlschlag den Originalwert nehmen. + +### Notiztexte + +`econotice.text` ist base64-kodiertes **Qt-HTML** mit CSS-Block im Kopf. +Zum Auspacken: + +```python +s = base64.b64decode(raw).decode("utf-8", "replace") +s = re.sub(r"<[^>]+>", " ", s) # Tags +s = html.unescape(re.sub(r"\s+", " ", s)) # Entities, Leerraum +s = re.sub(r"^\s*p,\s*li\s*\{[^}]*\}\s*", "", s) # CSS-Rest +``` + +Der letzte Schritt ist nötig, weil der CSS-Block nach dem Entfernen der +Tags als Text übrigbleibt: `p, li { white-space: pre-wrap; }` + +--- + +## Fallstrick 7: Ordnernamen enthalten Schrägstriche + +Ordner heißen in der Praxis auch `Telefon / Internet` oder `TV / Rundfunk`. +Wer den Ordnerpfad zu einem String verkettet und später am Schrägstrich +zerlegt, zerschneidet solche Namen in `Telefon ` und ` Internet`. + +Die Folge sind Tags mit Leerzeichen am Rand — und wenn das Zielsystem die +beim Speichern entfernt, kollidieren sie mit einer echten Ebene gleichen +Namens. + +**Ordnerpfade als Liste von Segmenten führen, nicht als verketteten +String.** + +--- + +## Wo die Wiedervorlage steckt + +Nicht in `dynattribute`, sondern in **`klassifizierung.defdate`**. Es ist +eine eingebaute Funktion, kein dynamisches Attribut. Passend dazu gibt es +in `status` den Wert `Wiedervorlage`. + +`econotice` ist **nicht** die Wiedervorlage, sondern die Notizfunktion. + +--- + +## Die Live-Datenbank ist etwas anderes + +Wer die Klassifizierungsdaten in der laufenden ecoDMS-PostgreSQL sucht, +sucht vergeblich. Dort gibt es **weder `klassifizierung` noch `docs`** — +die inhaltliche Ebene erzeugt erst der Exporter aus dem +Container-Speichersystem. + +In der Live-Datenbank liegt nur die Verwaltungsebene: Archive, Dateien, +Versionen, Benutzer, Rollen, Suchindex. + +Das erklärt auch, warum **Dokumentverknüpfungen** nicht auffindbar sind. +Vier unabhängige Prüfungen an einem produktiven Bestand: + +- keine Tabelle mit zwei Dokumentspalten +- beim Anlegen einer Verknüpfung entsteht **keine neue Zeile** in + irgendeiner Tabelle +- die Partner-ID kommt in keiner Textspalte vor +- ebenso in keiner Zahlenspalte außer den eigenen Datensätzen + +Verknüpfungen sind damit weder im Export noch in der Datenbank greifbar. +Sie müssen von Hand nachgetragen werden. + +--- + +## Der Exporter selbst + +### AND statt OR + +Bei manueller Mehrfachauswahl kann der Exporter eine unerfüllbare Abfrage +erzeugen: + +```sql +docid = '11072' AND docid = '11094' AND docid = '11143' AND … +``` + +Das Ergebnis ist ein Export mit vollständigen Stammdaten und **null +Dokumenten**, ohne Fehlermeldung. Die Abfrage steht base64-kodiert in +`ecodmsexporter.exp_query`: + +```python +base64.b64decode(row["exp_query"]).decode() +``` + +**Nach jedem Export prüfen.** Und Exporte besser über ein Suchkriterium +oder einen ID-Bereich definieren als über eine Liste einzeln markierter +Dokumente. + +### Tabellen im Export + +| Tabelle | Inhalt | +|---|---| +| `docs` | `id` (= docs_id), `docid`, `kid`, `archiv`, `trashed`, `attachments` | +| `klassifizierung` | `kid`, `docid`, `mainfolder`, `folder`, `bemerkung`, `status`, `revision`, `docart`, `ctimestamp`, `cdate`, `defdate`, `docs_id`, `dyn_*` | +| `ecosimsversions` | `docid`, `version`, `savedate`, `realname`, `pdfrealname`, `checksum`, `comment`, `fixiert` | +| `ecosimsarchive` | Kopfdokument je `docid`, Rückfall wenn keine Versionen | +| `docrollen_hist` | `docid` (= docs_id!), `role`, `doc_right`, `revision` | +| `econotice` | `id`, `text` (base64 Qt-HTML), `tdate`, `nid` (= docs_id!), `username` | +| `systemordner` | `oid`, `name`, `parentid` — Hierarchie über `parentid` | +| `documentenart`, `status` | Stammdaten, mit `trashed`-Kennzeichen | +| `dynattribute` | Spaltenname → Anzeigename und Typ, mit `deleted`-Kennzeichen | +| `dup_items`, `duplicatesindexer` | Duplikaterkennung, für die Migration irrelevant | + +### `attachments` bedeutet etwas anderes + +`docs.attachments` zählt **eingebettete** Anhänge, nicht separate Dateien: + +- bei ZUGFeRD die im PDF eingebettete XML +- bei E-Mails die Anhänge innerhalb der `.eml` + +Beide reisen mit dem Original mit. Es fehlt nichts — solange man +`realname` importiert. + +### `lucene` ist wertlos + +Der Ordner enthält den Suchindex des Offline-Clients. Gespeichert sind nur +Klassifizierungsdaten, die ohnehin vorliegen. Der Dokumenttext existiert +lediglich als kleingeschriebene Termliste im invertierten Index und ist +nicht rekonstruierbar. + +Schade — sonst hätte man sich das erneute OCR sparen können. + +--- + +## Prüfliste vor jedem Import + +- [ ] `exp_query` dekodiert und plausibel? +- [ ] Anzahl Dokumente im Export gegen die Erwartung? +- [ ] Alle in der XML referenzierten Dateien im Archiv vorhanden? +- [ ] Rollen im Export vollständig in der Übersetzung abgebildet? +- [ ] Vorkommende `doc_right`-Werte bekannt? +- [ ] Dateitypen bekannt und von Paperless unterstützt? +- [ ] Häufigkeit der Mehrfachklassifizierung geprüft? +- [ ] Dokumente ohne Rechteeintrag — Regel festgelegt? + +`ecodms_extract.py` beantwortet all das im Prüfbericht. diff --git a/ecodms_extract.py b/ecodms_extract.py new file mode 100644 index 0000000..1e33294 --- /dev/null +++ b/ecodms_extract.py @@ -0,0 +1,577 @@ +#!/usr/bin/env python3 +""" +ecodms_extract.py - Stufe 1 der ecoDMS -> Paperless-ngx Migration. + +Liest eine Exporttranche (export.data + export.xml + archive/) und erzeugt +ein normalisiertes JSON-Manifest plus einen Pruefbericht. Beruehrt Paperless +nicht - die Stufe ist offline testbar und beliebig oft wiederholbar. + +Aufruf: + ./ecodms_extract.py /pfad/zu/offline_export/archive -o tranche_01.json + +Die hier kodierten Regeln stammen aus der Analyse dreier Testexporte: + + 1. Gruppierung nach docid, nicht nach docs_id. Eine docid kann mehrere + Klassifizierungsinstanzen haben (Mehrfachablage in mehreren Ordnern). + 2. Beim Zusammenfuehren gewinnt bei Skalaren der juengste ctimestamp, + Ordner werden als Menge vereinigt. + 3. Immer realname (Original) verwenden, nie pdfrealname. Sonst gehen + eingebettete ZUGFeRD-XML und E-Mail-Anhaenge verloren. + 4. Dokumente ohne Zeilen in ecosimsversions fallen auf ecosimsarchive + zurueck und werden als einzige Version behandelt. + 5. docrollen_hist.docid enthaelt docs_id, nicht docid. Join ueber docs.id. + 6. Rechte sind historisiert. Nur die hoechste in docrollen_hist + vorhandene Revision je docs_id gilt - nicht die aus klassifizierung. + 7. Revisionen werden als Zahlentupel sortiert ('1.10' > '1.9'). + 8. base64 an unregelmaessigen Stellen: ecosimsversions.savedate und + .filename sowie ecosimsarchive.version sind kodiert, realname und + pdfrealname nicht. +""" + +from __future__ import annotations + +import argparse +import base64 +import binascii +import html +import json +import re +import sqlite3 +import sys +import xml.etree.ElementTree as ET +from collections import defaultdict +from pathlib import Path + +SCHEMA_VERSION = 2 + +# Dateitypen, die Paperless-ngx konsumieren kann. Massgeblich ist die +# laufende Instanz: +# docker compose exec webserver python manage.py shell -c \ +# "from documents.parsers import get_supported_file_extensions as f; print(sorted(f()))" +# +# Dokumente mit anderen Endungen werden nicht ins Manifest aufgenommen, +# sondern in eine Ausschlussliste geschrieben. Sonst scheitern sie erst +# beim Upload mit HTTP 400 und muellen das Journal zu. +SUPPORTED_EXTENSIONS = { + ".pdf", ".txt", ".text", ".csv", ".rtf", ".xml", ".eml", ".mail", + ".mht", ".mhtml", ".nws", ".brf", ".srt", ".rdf", ".wsdl", ".xsl", ".xpdl", + ".doc", ".docx", ".dot", ".odt", + ".xls", ".xlsx", ".xlb", ".xlc", ".xlm", ".xlt", ".xlw", ".xla", ".ods", + ".ppt", ".pptx", ".pps", ".ppsx", ".pot", ".ppa", ".pwz", ".wiz", ".odp", + ".odg", + ".png", ".jpg", ".jpe", ".jpeg", ".jfif", ".gif", ".bmp", ".webp", + ".tif", ".tiff", ".heic", ".art", + ".bat", ".c", ".h", ".ksh", ".pl", +} + + +# ---------------------------------------------------------------- Hilfsmittel + +def b64(value): + """Dekodiert base64, gibt den Originalwert zurueck wenn das misslingt.""" + if not value: + return value + try: + return base64.b64decode(value, validate=True).decode("utf-8") + except (binascii.Error, UnicodeDecodeError, ValueError): + return value + + +def revtuple(revision): + """'1.10' -> (1, 10). Lexikografische Sortierung waere hier falsch.""" + try: + return tuple(int(p) for p in str(revision).split(".")) + except (TypeError, ValueError): + return (-1,) + + +def clean(value): + """ecoDMS schreibt teils den String 'null' statt eines Leerwerts.""" + if value is None: + return "" + value = str(value).strip() + return "" if value.lower() == "null" else value + + +class Report: + """Sammelt Warnungen nach Kategorie, damit der Bericht lesbar bleibt.""" + + def __init__(self): + self.items = defaultdict(list) + + def warn(self, category, message): + self.items[category].append(message) + + def __len__(self): + return sum(len(v) for v in self.items.values()) + + +# ------------------------------------------------------------------- Stammdaten + +class Lookups: + def __init__(self, con): + self.docart = { + r["daid"]: (r["name"], r["trashed"] == "true") + for r in con.execute("SELECT daid, name, trashed FROM documentenart") + } + self.status = { + r["sid"]: (r["name"], r["trashed"] == "true") + for r in con.execute("SELECT sid, name, trashed FROM status") + } + self.folder = { + r["oid"]: dict(name=r["name"], parent=r["parentid"], deleted=r["deleted"]) + for r in con.execute( + "SELECT oid, name, parentid, deleted FROM systemordner" + ) + } + # Nur nicht geloeschte Attribute werden zu Custom Fields. + self.dyn = { + r["spaltenname"]: dict(name=r["name"], typ=r["datatyp"]) + for r in con.execute( + "SELECT spaltenname, name, datatyp FROM dynattribute " + "WHERE deleted <> 'true'" + ) + } + + def folder_parts(self, oid): + """ + '6.8.1.2' -> ['Allgemeines', 'Steuer', 'ESt', 'Anlage N'] + + Bewusst eine Liste statt eines verketteten Pfads: Ordnernamen in + ecoDMS koennen selbst Schraegstriche enthalten ("Telefon / Internet"), + ein spaeteres Zerlegen am Schraegstrich wuerde solche Namen + zerschneiden. + """ + parts, seen = [], set() + while oid and oid not in ("-1", "") and oid not in seen: + seen.add(oid) + node = self.folder.get(oid) + if node is None: + parts.append(f"?{oid}") + break + parts.append(node["name"].strip()) + oid = node["parent"] + return list(reversed(parts)) + + def folder_path(self, oid): + """Nur fuer Anzeige und Bericht.""" + return " / ".join(self.folder_parts(oid)) + + +# -------------------------------------------------------------------- Rechte + +def effective_rights(con, docs_ids, report): + """ + Wirksame Rechte je docs_id: nur Zeilen der hoechsten dort vorhandenen + Revision. Aeltere Revisionen enthalten z.B. noch ecoSIMSUSER aus dem + Posteingang, das darf nicht mitwandern. + """ + result = {} + for docs_id in docs_ids: + rows = list( + con.execute( + "SELECT role, doc_right, revision FROM docrollen_hist WHERE docid = ?", + (docs_id,), + ) + ) + if not rows: + report.warn("rechte_fehlen", f"docs_id {docs_id} ohne Rechteeintrag") + result[docs_id] = [] + continue + top = max(revtuple(r["revision"]) for r in rows) + result[docs_id] = [ + {"role": r["role"], "right": r["doc_right"], "revision": r["revision"]} + for r in rows + if revtuple(r["revision"]) == top + ] + return result + + +# ------------------------------------------------------------------ Versionen + +def unsupported_ext(filename): + """Gibt die Endung zurueck, wenn Paperless sie nicht konsumieren kann.""" + ext = ("." + filename.rsplit(".", 1)[-1].lower()) if "." in filename else "" + return None if ext in SUPPORTED_EXTENSIONS else (ext or "(ohne Endung)") + + +def versions_for(con, docid, archive_dir, report): + rows = list( + con.execute( + "SELECT version, savedate, filename, realname, pdfrealname, size, " + " comment, fixiert, checksum " + "FROM ecosimsversions WHERE docid = ? ORDER BY version", + (docid,), + ) + ) + + if rows: + out = [] + for r in rows: + fname = r["realname"] + if not fname: + report.warn( + "datei_fehlt", + f"docid {docid} Version {r['version']}: kein realname", + ) + continue + out.append( + { + "version": r["version"], + "file": fname, + "original_name": b64(r["filename"]) or r["realname"], + "saved_at": b64(r["savedate"]), + "comment": clean(r["comment"]), + "fixed": r["fixiert"] == "true", + "checksum": r["checksum"], + "source": "ecosimsversions", + } + ) + return out + + # Regel 4: Rueckfall auf das Archivdokument. + arc = con.execute( + "SELECT filename, realname, size, inserttime, version, fixiert, checksum " + "FROM ecosimsarchive WHERE id = ?", + (docid,), + ).fetchone() + if arc is None or not arc["realname"]: + report.warn("ohne_datei", f"docid {docid}: weder Version noch Archivdatei") + return [] + report.warn("nur_archiv", f"docid {docid}: keine Versionszeilen, nutze Archivdatei") + return [ + { + "version": 1, + "file": arc["realname"], + "original_name": arc["filename"], + "saved_at": arc["inserttime"], + "comment": "", + "fixed": arc["fixiert"] == "true", + "checksum": arc["checksum"], + "source": "ecosimsarchive", + } + ] + + +# --------------------------------------------------------- Klassifizierungen + +def merge_classifications(rows, lookups, report, docid): + """ + Regel 2: Ordner vereinigen, bei Skalaren gewinnt der juengste ctimestamp. + 'rows' sind alle klassifizierung-Zeilen einer docid. + """ + rows = sorted(rows, key=lambda r: r["ctimestamp"] or "") + newest = rows[-1] + + folders, folder_parts, conflicts = [], [], {} + for r in rows: + parts = lookups.folder_parts(r["folder"] or r["mainfolder"]) + if parts and parts not in folder_parts: + folder_parts.append(parts) + folders.append(" / ".join(parts)) + + if len(rows) > 1: + report.warn( + "mehrfachklassifizierung", + f"docid {docid}: {len(rows)} Klassifizierungen -> {', '.join(folders)}", + ) + for field in ("bemerkung", "docart", "status", "cdate", "defdate"): + values = {clean(r[field]) for r in rows} + if len(values) > 1: + conflicts[field] = sorted(values) + + docart_name, docart_trashed = lookups.docart.get( + newest["docart"], (f"?{newest['docart']}", False) + ) + status_name, status_trashed = lookups.status.get( + newest["status"], (f"?{newest['status']}", False) + ) + if docart_trashed: + report.warn("geloeschte_stammdaten", f"docid {docid}: Dokumentart '{docart_name}' ist geloescht") + if status_trashed: + report.warn("geloeschte_stammdaten", f"docid {docid}: Status '{status_name}' ist geloescht") + + custom = {} + for column, meta in lookups.dyn.items(): + value = clean(newest[column] if column in newest.keys() else "") + if value: + custom[meta["name"]] = {"value": value, "type": meta["typ"]} + + # Wiedervorlage steckt in klassifizierung.defdate, nicht in dynattribute - + # sie ist in ecoDMS eine eingebaute Funktion, kein dynamisches Attribut. + # Passt inhaltlich zum Statuswert "Wiedervorlage" (sid 2). + wv = clean(newest["defdate"] if "defdate" in newest.keys() else "") + if wv: + custom["Wiedervorlage"] = {"value": wv, "type": "Date"} + + return { + "title": clean(newest["bemerkung"]) or f"ecoDMS {docid}", + "created": clean(newest["cdate"]), + "changed_at": clean(newest["ctimestamp"]), + "changed_by": clean(newest["changeid"]), + "document_type": docart_name, + "status": status_name, + "folders": folders, # nur Anzeige + "folder_parts": folder_parts, # massgeblich fuer die Tags + "custom_fields": custom, + "revision": clean(newest["revision"]), + "conflicts": conflicts, + } + + +# ------------------------------------------------------------- XML-Historie + +def classification_history(xml_path, report): + """Die Klassifizierungshistorie steht nur in der XML, nicht in der SQLite.""" + history = defaultdict(list) + if not xml_path.exists(): + report.warn("xml_fehlt", f"{xml_path.name} nicht gefunden") + return history, set() + + root = ET.parse(xml_path).getroot() + referenced = set() + for doc in root.findall("document"): + docid = int(doc.get("docid")) + for el in doc.iter(): + if el.get("filePath"): + referenced.add(el.get("filePath")) + for info in doc.findall(".//classifyInfo"): + for ver in info.findall("Version"): + entry = { + child.tag: clean(child.text) for child in ver if clean(child.text) + } + entry["cla_docs_id"] = info.get("cla_docs_id") + history[docid].append(entry) + for docid in history: + history[docid].sort(key=lambda e: revtuple(e.get("revision", "0"))) + return history, referenced + + +def notes_by_docid(con, report): + """ + econotice enthaelt die Notizfunktion, nicht die Wiedervorlage. + + Zwei Fallstricke: + * nid enthaelt die docs_id, NICHT die docid - dasselbe Muster wie bei + docrollen_hist. Inhaltlich verifiziert an drei Faellen mit + auseinanderlaufenden IDs. + * text ist base64-kodiertes Qt-HTML mit CSS-Block im Kopf. + + username ist im gesamten Bestand leer, eine Zuordnung zu Personen + entfaellt also. + """ + notes = defaultdict(list) + docs_id_to_docid = { + r["id"]: r["docid"] for r in con.execute("SELECT id, docid FROM docs") + } + + for row in con.execute("SELECT * FROM econotice ORDER BY id"): + try: + docs_id = int(row["nid"]) + except (TypeError, ValueError): + report.warn("notiz_ohne_bezug", f"econotice {row['id']}: nid={row['nid']!r}") + continue + docid = docs_id_to_docid.get(docs_id) + if docid is None: + report.warn( + "notiz_ohne_bezug", + f"econotice {row['id']}: docs_id {docs_id} nicht im Export", + ) + continue + + raw = row["text"] or "" + try: + text = base64.b64decode(raw).decode("utf-8", "replace") + except (binascii.Error, ValueError): + text = str(raw) + text = re.sub(r"<[^>]+>", " ", text) # HTML-Tags + text = html.unescape(re.sub(r"\s+", " ", text)).strip() + text = re.sub(r"^p,\s*li\s*\{[^}]*\}\s*", "", text) # CSS-Rest + + notes[docid].append( + {"created": clean(row["tdate"]), "text": text, + "user": clean(row["username"])} + ) + return notes + + +# ------------------------------------------------------------------ Hauptlauf + +def extract(archive_dir: Path, tranche: str): + report = Report() + db_path = archive_dir / "export.data" + xml_path = archive_dir / "export.xml" + if not db_path.exists(): + sys.exit(f"export.data nicht gefunden unter {archive_dir}") + + con = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True) + con.row_factory = sqlite3.Row + + lookups = Lookups(con) + history, referenced = classification_history(xml_path, report) + notes = notes_by_docid(con, report) + + # klassifizierung nach docid gruppieren (Regel 1) + by_docid = defaultdict(list) + for row in con.execute("SELECT * FROM klassifizierung"): + by_docid[row["docid"]].append(row) + + # docid -> docs_id (fuer den Rechte-Join, Regel 5) + docs_ids = defaultdict(list) + for row in con.execute("SELECT id, docid, trashed FROM docs"): + if row["trashed"] == "true": + report.warn("papierkorb", f"docs_id {row['id']} (docid {row['docid']}) ist im Papierkorb") + continue + docs_ids[row["docid"]].append(row["id"]) + + on_disk = {p.name for p in archive_dir.iterdir() if p.is_file()} + documents, used_files, skipped = [], set(), [] + + for docid in sorted(by_docid): + meta = merge_classifications(by_docid[docid], lookups, report, docid) + versions = versions_for(con, docid, archive_dir, report) + + for v in versions: + used_files.add(v["file"]) + if on_disk and v["file"] not in on_disk: + report.warn("datei_fehlt", f"docid {docid} v{v['version']}: {v['file']}") + + # Nicht konsumierbare Dateitypen aussortieren. Massgeblich ist die + # letzte Version - sie bestimmt, was Paperless zu sehen bekaeme. + bad = {v["file"]: unsupported_ext(v["file"]) for v in versions} + bad = {f: e for f, e in bad.items() if e} + if bad: + exts = sorted(set(bad.values())) + report.warn("nicht_unterstuetzt", + f"docid {docid}: {', '.join(exts)} ({meta['title'][:50]})") + skipped.append({ + "ecodms_docid": docid, + "title": meta["title"], + "extensions": exts, + "files": sorted(bad), + "folders": meta["folders"], + "created": meta["created"], + }) + continue + + rights = effective_rights(con, docs_ids.get(docid, []), report) + roles = {} + for entries in rights.values(): # Vereinigung ueber alle Instanzen + for e in entries: + prev = roles.get(e["role"]) + # W schlaegt R + if prev is None or (prev == "R" and e["right"] == "W"): + roles[e["role"]] = e["right"] + + if not roles: + report.warn("ohne_rechte", f"docid {docid}: keine wirksamen Rechte") + + documents.append( + { + "ecodms_docid": docid, + "tranche": tranche, + **meta, + "versions": versions, + "permissions": [{"role": r, "right": w} for r, w in sorted(roles.items())], + "classification_history": history.get(docid, []), + "notes": notes.get(docid, []), + "docs_ids": docs_ids.get(docid, []), + } + ) + + # Dateien, die auf der Platte liegen aber nicht verwendet werden. + ignorable = {"export.xml", "export.data"} + orphans = sorted(on_disk - used_files - ignorable) if on_disk else [] + for name in orphans: + # Die gerenderten Kopf-PDFs sind erwartete Waisen (Regel 3). + if "_revision_" not in name: + continue + report.warn("verwaiste_datei", name) + + missing_refs = sorted(referenced - on_disk) if on_disk else [] + for name in missing_refs: + report.warn("xml_referenz_ohne_datei", name) + + manifest = { + "schema_version": SCHEMA_VERSION, + "tranche": tranche, + "source": str(archive_dir), + "counts": { + "documents": len(documents), + "skipped": len(skipped), + "versions": sum(len(d["versions"]) for d in documents), + "multi_classified": sum(1 for d in documents if len(d["docs_ids"]) > 1), + "roles": len({p["role"] for d in documents for p in d["permissions"]}), + "notes": sum(len(d["notes"]) for d in documents), + }, + "roles_seen": sorted({p["role"] for d in documents for p in d["permissions"]}), + "rights_seen": sorted({p["right"] for d in documents for p in d["permissions"]}), + "documents": documents, + "skipped": skipped, + } + return manifest, report + + +def print_report(manifest, report): + c = manifest["counts"] + print(f"Tranche {manifest['tranche']}") + print(f" Dokumente {c['documents']}") + if c.get("skipped"): + exts = {} + for sk in manifest["skipped"]: + for e in sk["extensions"]: + exts[e] = exts.get(e, 0) + 1 + detail = ", ".join(f"{e} {n}" for e, n in sorted(exts.items())) + print(f" Uebersprungen {c['skipped']} ({detail})") + print(f" Versionen {c['versions']}") + print(f" Mehrfachklassifiziert {c['multi_classified']}") + print(f" Notizen {c['notes']}") + print(f" Rollen {', '.join(manifest['roles_seen']) or '-'}") + print(f" Rechtearten {', '.join(manifest['rights_seen']) or '-'}") + if len(report): + print(f"\n Hinweise ({len(report)}):") + for category in sorted(report.items): + entries = report.items[category] + print(f" [{category}] {len(entries)}") + for line in entries[:5]: + print(f" {line}") + if len(entries) > 5: + print(f" ... und {len(entries) - 5} weitere") + else: + print("\n Keine Hinweise.") + + +def main(): + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("archive_dir", type=Path, help="Verzeichnis mit export.data") + ap.add_argument("-o", "--output", type=Path, help="Ziel fuer das JSON-Manifest") + ap.add_argument("-t", "--tranche", default=None, help="Name der Tranche") + ap.add_argument("--skipped-csv", type=Path, + help="Ausschlussliste als CSV fuer die Nacharbeit") + args = ap.parse_args() + + tranche = args.tranche or args.archive_dir.resolve().parent.name + manifest, report = extract(args.archive_dir, tranche) + print_report(manifest, report) + + if args.skipped_csv and manifest["skipped"]: + import csv + with args.skipped_csv.open("w", newline="", encoding="utf-8") as fh: + w = csv.writer(fh) + w.writerow(["ecodms_docid", "endungen", "titel", "erstellt", + "ordner", "dateien"]) + for sk in manifest["skipped"]: + w.writerow([sk["ecodms_docid"], " ".join(sk["extensions"]), + sk["title"], sk["created"], + " | ".join(sk["folders"]), " ".join(sk["files"])]) + print(f" Ausschlussliste: {args.skipped_csv}") + + if args.output: + args.output.write_text( + json.dumps(manifest, indent=2, ensure_ascii=False), encoding="utf-8" + ) + print(f"\n Manifest geschrieben: {args.output}") + + +if __name__ == "__main__": + main() diff --git a/ecodms_notizen.py b/ecodms_notizen.py new file mode 100644 index 0000000..3ff28e9 --- /dev/null +++ b/ecodms_notizen.py @@ -0,0 +1,258 @@ +#!/usr/bin/env python3 +""" +ecodms_notizen.py - Uebertraegt die Notizen aus econotice nach Paperless. + +Liest die export.data-Dateien aller Tranchen. Die Live-Postgres von ecoDMS +enthaelt weder eine Klassifizierungs- noch eine docs-Tabelle - die +inhaltliche Ebene erzeugt erst der Exporter. Die Aufloesung von econotice +auf ein Dokument ist deshalb nur ueber die Exporte moeglich. + + export PT=dein_paperless_token + ./ecodms_notizen.py --dry-run --csv notizen.csv + ./ecodms_notizen.py + +Erkenntnisse, die hier kodiert sind: + + * econotice.nid enthaelt die docs_id, NICHT die docid. Dasselbe Muster + wie bei docrollen_hist. Inhaltlich verifiziert an drei Faellen mit + auseinanderlaufenden IDs (515 -> 514, 633 -> 622, 869 -> 846). + * econotice.text ist base64-kodiertes Qt-HTML mit CSS-Block im Kopf. + * econotice.username ist im gesamten Bestand leer. + * Die Zuordnung nach Paperless laeuft ueber die Archiv-Seriennummer, + die beim Import auf die ecoDMS-docid gesetzt wurde. + +Eine Notiz kann zu einem Dokument aus einer anderen Tranche gehoeren. +Deshalb werden erst ALLE Tranchen eingelesen und eine gemeinsame +Zuordnungstabelle gebaut, bevor uebertragen wird. + +Wiederholbar: textgleiche Notizen am selben Dokument werden uebersprungen. +""" + +from __future__ import annotations + +import argparse +import base64 +import binascii +import csv +import html +import os +import re +import sqlite3 +import sys +from pathlib import Path + +try: + import requests +except ImportError: + sys.exit("Benoetigt python3-requests: sudo zypper in python3-requests") + + +CSS_REST = re.compile(r"^\s*p,\s*li\s*\{[^}]*\}\s*") +TAGS = re.compile(r"<[^>]+>") + + +def qt_html_to_text(raw) -> str: + """base64-kodiertes Qt-HTML -> Klartext.""" + if raw is None: + return "" + try: + s = base64.b64decode(raw).decode("utf-8", "replace") + except (binascii.Error, ValueError, TypeError): + s = str(raw) + s = TAGS.sub(" ", s) + s = html.unescape(re.sub(r"\s+", " ", s)) + return CSS_REST.sub("", s).strip() + + +def read_tranches(paths): + """ + Liest alle Tranchen ein und liefert: + notes {notiz_id: {...}} alle Notizen, dedupliziert + mapping {docs_id: docid} ueber alle Tranchen vereinigt + titles {docid: bemerkung} nur fuer die Anzeige + """ + notes, mapping, titles = {}, {}, {} + for p in paths: + if not p.exists(): + print(f" uebersprungen (nicht vorhanden): {p}") + continue + con = sqlite3.connect(f"file:{p}?mode=ro", uri=True) + con.row_factory = sqlite3.Row + + for r in con.execute("SELECT id, docid FROM docs"): + mapping[r["id"]] = r["docid"] + try: + for r in con.execute( + "SELECT docid, bemerkung FROM klassifizierung" + ): + if r["bemerkung"]: + titles.setdefault(r["docid"], r["bemerkung"]) + except sqlite3.Error: + pass + + n = 0 + for r in con.execute("SELECT * FROM econotice"): + notes[r["id"]] = { + "notiz_id": r["id"], + "nid": r["nid"], + "erstellt": r["tdate"], + "text": qt_html_to_text(r["text"]), + "user": (r["username"] or "").strip(), + "tranche": p.parent.parent.name, + } + n += 1 + con.close() + print(f" {p.parent.parent.name}: {n} Notizen, " + f"{len(mapping)} Zuordnungen kumuliert") + return notes, mapping, titles + + +class Paperless: + def __init__(self, base, token, dry_run=False): + self.base = base.rstrip("/") + self.dry_run = dry_run + self.s = requests.Session() + self.s.headers["Authorization"] = f"Token {token}" + self._asn_cache = {} + + def load_asn_index(self): + """Alle Dokumente einmal holen statt je Notiz einzeln abzufragen.""" + url = f"{self.base}/api/documents/" + params = {"fields": "id,title,archive_serial_number", "page_size": 200} + while url: + r = self.s.get(url, params=params, timeout=120) + r.raise_for_status() + data = r.json() + for d in data.get("results", []): + asn = d.get("archive_serial_number") + if asn is not None: + self._asn_cache[int(asn)] = d + url, params = data.get("next"), None + return len(self._asn_cache) + + def by_asn(self, asn): + return self._asn_cache.get(int(asn)) + + def notes(self, doc_id): + r = self.s.get(f"{self.base}/api/documents/{doc_id}/notes/", timeout=60) + if r.status_code == 404: + return [] + r.raise_for_status() + data = r.json() + return data if isinstance(data, list) else data.get("results", []) + + def add_note(self, doc_id, text): + if self.dry_run: + return {"dry_run": True} + r = self.s.post(f"{self.base}/api/documents/{doc_id}/notes/", + json={"note": text}, timeout=60) + if r.status_code >= 400: + raise RuntimeError(f"HTTP {r.status_code}: {r.text[:300]}") + return r.json() + + +def main(): + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--exports", default="/data/paperless/export-ecodms", + help="Verzeichnis mit exportN/archive/export.data") + ap.add_argument("--tranchen", default="1-7", + help="Bereich oder Liste, z.B. '1-7' oder '1,3,5'") + ap.add_argument("--api", default="http://localhost:8000") + ap.add_argument("--token", help="oder Umgebungsvariable PT") + ap.add_argument("--csv", help="Notizen zusaetzlich als CSV sichern") + ap.add_argument("--dry-run", action="store_true") + ap.add_argument("--prefix", default="[ecoDMS {datum}] ", + help="Vorspann je Notiz, {datum} wird ersetzt") + args = ap.parse_args() + + token = args.token or os.environ.get("PT") + if not token: + sys.exit("Token fehlt: --token oder export PT=...") + + if "-" in args.tranchen: + a, b = args.tranchen.split("-") + nums = range(int(a), int(b) + 1) + else: + nums = [int(x) for x in args.tranchen.split(",")] + paths = [Path(args.exports) / f"export{n}" / "archive" / "export.data" + for n in nums] + + print("Tranchen einlesen:") + notes, mapping, titles = read_tranches(paths) + print(f"\n{len(notes)} Notizen insgesamt, " + f"{len(mapping)} docs_id-Zuordnungen\n") + + api = Paperless(args.api, token, dry_run=args.dry_run) + print("Paperless-Dokumente laden ...") + print(f" {api.load_asn_index()} Dokumente mit Archiv-Seriennummer\n") + + if args.dry_run: + print("TROCKENLAUF - es wird nichts geschrieben\n") + + writer = fh = None + if args.csv: + fh = open(args.csv, "w", newline="", encoding="utf-8") + writer = csv.writer(fh) + writer.writerow(["notiz_id", "docs_id", "ecodms_docid", "paperless_id", + "titel", "erstellt", "notiz", "status"]) + + stats = {} + def count(key): + stats[key] = stats.get(key, 0) + 1 + + for r in sorted(notes.values(), key=lambda x: x["notiz_id"]): + text, status = r["text"], "" + docid = paperless_id = titel = "" + + try: + docs_id = int(r["nid"]) + except (TypeError, ValueError): + docs_id = None + + if not text: + status = "leer"; count("leer") + elif docs_id is None: + status = f"nid unlesbar: {r['nid']!r}"; count("nid_unlesbar") + elif docs_id not in mapping: + status = "docs_id in keiner Tranche"; count("ohne_zuordnung") + else: + docid = mapping[docs_id] + titel = titles.get(docid, "") + doc = api.by_asn(docid) + if doc is None: + status = f"kein Paperless-Dokument mit ASN {docid}" + count("ohne_dokument") + else: + paperless_id = doc["id"] + titel = doc.get("title") or titel + voll = args.prefix.format(datum=str(r["erstellt"])[:10]) + text + try: + if any(str(n.get("note", "")).strip() == voll.strip() + for n in api.notes(paperless_id)): + status = "bereits vorhanden"; count("vorhanden") + else: + api.add_note(paperless_id, voll) + status = "uebertragen"; count("uebertragen") + except Exception as exc: # noqa: BLE001 + status = f"FEHLER: {exc}"; count("fehler") + + if writer: + writer.writerow([r["notiz_id"], r["nid"], docid, paperless_id, + titel, r["erstellt"], text, status]) + if status.startswith("FEHLER") or status.startswith("kein ") \ + or status.startswith("docs_id"): + print(f" notiz {r['notiz_id']} (docs_id {r['nid']}): {status}") + print(f" {text[:90]}") + + if fh: + fh.close() + print(f"\nCSV geschrieben: {args.csv}") + + print() + for k, v in sorted(stats.items()): + print(f" {k:18s} {v}") + + +if __name__ == "__main__": + main() diff --git a/paperless_import.py b/paperless_import.py new file mode 100644 index 0000000..8bcd39b --- /dev/null +++ b/paperless_import.py @@ -0,0 +1,863 @@ +#!/usr/bin/env python3 +""" +paperless_import.py - Stufe 2 der ecoDMS -> Paperless-ngx Migration. + +Liest ein Manifest aus ecodms_extract.py (Stufe 1) und spielt es ueber die +REST-API in Paperless ein. Fuehrt ein eigenes SQLite-Journal, ist damit +wiederaufsetzbar und tranchenuebergreifend auswertbar. + + # 1. Trockenlauf: nichts wird geschrieben + ./paperless_import.py tranche_03.json --dry-run + + # 2. Stammdaten anlegen (Tags, Dokumentarten, Custom Fields) + ./paperless_import.py tranche_03.json --setup + + # 3. Import + ./paperless_import.py tranche_03.json + + # 4. Zeitstempel-Skript erzeugen (API kann sie nicht setzen) + ./paperless_import.py --emit-timestamps > /tmp/fix_added.py + sudo docker compose exec -T webserver python manage.py shell < /tmp/fix_added.py + + # 5. Nach ALLEN Tranchen: Verknuepfungen aufloesen + ./paperless_import.py --links verknuepfungen.csv + + # 6. Pruefbericht + ./paperless_import.py --verify + +Erkenntnisse aus der Testinstanz, die hier kodiert sind: + + * update_version nimmt nur "document" und "version_label" entgegen - + Zeitstempel muessen nachtraeglich per Django-Shell gesetzt werden. + * Rechte werden ueber root_document vererbt. Einmal auf die Wurzel + setzen genuegt, die Versionen erben (403-Test bestaetigt). + * Die versions-Liste am Dokument ist ABSTEIGEND sortiert. + * Innerhalb einer Versionskette muss streng seriell gearbeitet werden, + parallelisiert wird ueber Dokumente hinweg. +""" + +from __future__ import annotations + +import argparse +import csv +import json +import re +import sqlite3 +import sys +import time +from datetime import datetime +from pathlib import Path + +try: + import requests +except ImportError: + sys.exit("Benoetigt python3-requests: sudo zypper in python3-requests") + +try: + import yaml +except ImportError: + sys.exit("Benoetigt python3-PyYAML: sudo zypper in python3-PyYAML") + + +TASK_TIMEOUT = 600 # Sekunden je Konsumvorgang +TASK_POLL_INTERVAL = 2 + + +# ------------------------------------------------------------------- Journal + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS mapping ( + ecodms_docid INTEGER PRIMARY KEY, + tranche TEXT, + paperless_id INTEGER, + versions_total INTEGER, + versions_done INTEGER DEFAULT 0, + status TEXT DEFAULT 'pending', + last_error TEXT, + updated_at TEXT +); +CREATE TABLE IF NOT EXISTS version_times ( + paperless_id INTEGER PRIMARY KEY, + ecodms_docid INTEGER, + version INTEGER, + added TEXT +); +CREATE TABLE IF NOT EXISTS links ( + src_docid INTEGER, + dst_docid INTEGER, + resolved INTEGER DEFAULT 0, + PRIMARY KEY (src_docid, dst_docid) +); +""" + + +class Journal: + def __init__(self, path: Path): + self.con = sqlite3.connect(path) + self.con.row_factory = sqlite3.Row + self.con.executescript(SCHEMA) + self.con.commit() + + def state(self, docid): + r = self.con.execute( + "SELECT * FROM mapping WHERE ecodms_docid = ?", (docid,) + ).fetchone() + return dict(r) if r else None + + def start(self, docid, tranche, total): + self.con.execute( + "INSERT OR IGNORE INTO mapping (ecodms_docid, tranche, versions_total) " + "VALUES (?,?,?)", + (docid, tranche, total), + ) + self.con.commit() + + def update(self, docid, **fields): + fields["updated_at"] = datetime.now().isoformat(timespec="seconds") + sets = ", ".join(f"{k} = ?" for k in fields) + self.con.execute( + f"UPDATE mapping SET {sets} WHERE ecodms_docid = ?", + (*fields.values(), docid), + ) + self.con.commit() + + def remember_time(self, paperless_id, docid, version, added): + self.con.execute( + "INSERT OR REPLACE INTO version_times VALUES (?,?,?,?)", + (paperless_id, docid, version, added), + ) + self.con.commit() + + def add_link(self, src, dst): + self.con.execute( + "INSERT OR IGNORE INTO links (src_docid, dst_docid) VALUES (?,?)", + (src, dst), + ) + self.con.commit() + + +# ----------------------------------------------------------------------- API + +class Paperless: + def __init__(self, base, token, dry_run=False): + self.base = base.rstrip("/") + self.dry_run = dry_run + self.s = requests.Session() + self.s.headers["Authorization"] = f"Token {token}" + + def _url(self, path): + return f"{self.base}/api/{path.lstrip('/')}" + + def get(self, path, **params): + r = self.s.get(self._url(path), params=params, timeout=60) + self._raise(r) + return r.json() + + def get_all(self, path, **params): + """Folgt der Paginierung.""" + out, params = [], {**params, "page_size": 200} + url = self._url(path) + while url: + r = self.s.get(url, params=params, timeout=60) + self._raise(r) + data = r.json() + out.extend(data.get("results", [])) + url, params = data.get("next"), None + return out + + @staticmethod + def _raise(r): + """ + raise_for_status() zeigt nur den Statuscode. Die Begruendung steht + im Antwortkoerper - ohne sie ist ein 400 nicht auswertbar. + """ + if r.status_code < 400: + return + try: + body = json.dumps(r.json(), ensure_ascii=False) + except ValueError: + body = r.text or "" + raise requests.HTTPError( + f"HTTP {r.status_code} bei {r.request.method} {r.url}: " + f"{' '.join(body.split())[:400]}", + response=r, + ) + + def post(self, path, **kwargs): + if self.dry_run: + return {"dry_run": True} + r = self.s.post(self._url(path), timeout=300, **kwargs) + self._raise(r) + return r.json() + + def patch(self, path, payload): + if self.dry_run: + return {"dry_run": True} + r = self.s.patch(self._url(path), json=payload, timeout=60) + self._raise(r) + return r.json() + + # -- Konsumvorgaenge ----------------------------------------------------- + + @staticmethod + def _rows(data): + """/api/tasks/ liefert je nach Stand eine Liste oder ein Seitenobjekt.""" + if isinstance(data, list): + return data + if isinstance(data, dict): + return data.get("results") or [] + return [] + + @staticmethod + def _doc_id(task): + """ + Die Dokument-ID steht je nach Stand an unterschiedlichen Stellen. + In 3.1.0 gleich zweimal: verschachtelt in result_data.document_id + und als Liste in related_document_ids. + """ + def as_int(val): + try: + return int(val) + except (TypeError, ValueError): + return None + + # Listenfelder zuerst - dort steht die ID in 3.1.0 + for key in ("related_document_ids", "document_ids"): + val = task.get(key) + if isinstance(val, (list, tuple)) and val: + got = as_int(val[0]) + if got: + return got + + # Verschachtelt + nested = task.get("result_data") + if isinstance(nested, dict): + for key in ("document_id", "related_document", "id"): + got = as_int(nested.get(key)) + if got: + return got + + # Oberste Ebene + for key in ("related_document", "document_id", "related_document_id"): + got = as_int(task.get(key)) + if got: + return got + + # Notnagel: ID aus einem Ergebnistext ziehen + m = re.search(r"[Dd]ocument\D+(\d+)", str(task.get("result") or "")) + return int(m.group(1)) if m else None + + @staticmethod + def _error_text(task, status): + """ + Die Fehlermeldung steht je nach Stand an verschiedenen Stellen. + Ein blosses "Task FAILURE" im Journal ist wertlos - dann muss man + die Ursache spaeter muehsam aus den Container-Logs rekonstruieren. + """ + rd = task.get("result_data") or {} + for src in (task.get("result"), rd.get("error"), rd.get("exc_message"), + rd.get("exc_type"), task.get("status_display")): + if src: + txt = " ".join(str(src).split()) + if txt and txt.lower() not in ("failure", "failed"): + return txt[:500] + fn = (task.get("input_data") or {}).get("filename", "?") + return f"Task {status} ohne Meldung (Datei: {fn}, task_id: {task.get('task_id')})" + + def find_task(self, task_id): + """ + Sucht einen Task. Greift der Serverfilter nicht, wird die + Gesamtliste durchsucht - der Filtername hat zwischen Versionen + gewechselt, und ein stiller Fehlschlag laesst das Skript sonst + bis zum Timeout warten. + """ + try: + rows = self._rows(self.get("tasks/", task_id=task_id)) + for t in rows: + if str(t.get("task_id")) == str(task_id): + return t + if len(rows) == 1 and not rows[0].get("task_id"): + return rows[0] + except Exception: # noqa: BLE001 + rows = [] + for t in self._rows(self.get("tasks/")): + if str(t.get("task_id")) == str(task_id): + return t + return None + + def wait_for_task(self, task_id, log=None): + """Wartet auf einen Konsumvorgang und liefert die Dokument-ID.""" + if self.dry_run: + return None + if not task_id or not isinstance(task_id, str): + raise RuntimeError(f"Unerwartete Antwort auf den Upload: {task_id!r}") + + deadline = time.time() + TASK_TIMEOUT + waited = 0 + while time.time() < deadline: + task = self.find_task(task_id) + if task: + # Die API liefert den Status kleingeschrieben ("success"), + # aeltere Staende grossgeschrieben. Vergleich deshalb + # unabhaengig von der Schreibweise. + status = str(task.get("status") or "").upper() + if status in ("SUCCESS", "SUCCEEDED"): + doc = self._doc_id(task) + if doc: + return doc + raise RuntimeError(f"Task erfolgreich, aber ohne Dokument-ID: {task}") + if status in ("FAILURE", "FAILED", "REVOKED"): + raise RuntimeError(self._error_text(task, status)) + time.sleep(TASK_POLL_INTERVAL) + waited += TASK_POLL_INTERVAL + if log and waited % 30 == 0: + state = task.get("status") if task else "nicht gefunden" + log(f" ... warte seit {waited}s (Task: {state})") + raise TimeoutError( + f"Task {task_id} nach {TASK_TIMEOUT}s ohne Ergebnis. " + f"Pruefen: curl -H \"Authorization: Token \\$PT\" " + f"'{self.base}/api/tasks/?task_id={task_id}'" + ) + + def post_document(self, path: Path, fields: dict): + with path.open("rb") as fh: + data = {k: v for k, v in fields.items() if v not in (None, "", [])} + files = {"document": (path.name, fh, "application/octet-stream")} + tags = data.pop("tags", []) + payload = [(k, str(v)) for k, v in data.items()] + payload += [("tags", str(t)) for t in tags] + r = self.post("documents/post_document/", files=files, data=payload) + return None if isinstance(r, dict) and r.get("dry_run") else r + + def update_version(self, doc_id: int, path: Path, label: str): + with path.open("rb") as fh: + files = {"document": (path.name, fh, "application/octet-stream")} + r = self.post( + f"documents/{doc_id}/update_version/", + files=files, + data={"version_label": label}, + ) + return None if isinstance(r, dict) and r.get("dry_run") else r + + +# ------------------------------------------------------------------ Auflöser + +class Resolver: + """Legt Stammdaten bei Bedarf an und merkt sich die IDs.""" + + def __init__(self, api: Paperless, rollen: dict, log): + self.api, self.rollen, self.log = api, rollen, log + self.tags = self._index("tags/") + self.types = self._index("document_types/") + self.correspondents = self._index("correspondents/") + self.fields = self._index("custom_fields/") + self.users = self._index("users/", key="username") + self.groups = self._index("groups/") + + def _index(self, path, key="name"): + return {row[key]: row["id"] for row in self.api.get_all(path)} + + def _ensure(self, cache, path, name, extra=None): + if name in cache: + return cache[name] + if self.api.dry_run: + self.log(f" [dry-run] wuerde anlegen: {path} '{name}'") + cache[name] = -1 + return -1 + try: + row = self.api.post(path, json={"name": name, **(extra or {})}) + except Exception as exc: # noqa: BLE001 + # Paperless normalisiert Namen beim Speichern (Leerzeichen). + # Ein 400 heisst deshalb meist: existiert schon unter leicht + # anderem Namen. Liste neu einlesen und erneut nachsehen. + fresh = self._index(path) + hit = fresh.get(name) or fresh.get(name.strip()) + if hit is None: + low = {k.strip().casefold(): v for k, v in fresh.items()} + hit = low.get(name.strip().casefold()) + if hit is None: + raise RuntimeError(f"{path} '{name}' nicht anlegbar: {exc}") from exc + cache.clear(); cache.update(fresh); cache[name] = hit + self.log(f" vorhanden: {path} '{name}' -> {hit}") + return hit + cache[name] = row["id"] + self.log(f" angelegt: {path} '{name}' -> {row['id']}") + return row["id"] + + def tag(self, name): + return self._ensure(self.tags, "tags/", name) + + def doc_type(self, name): + return self._ensure(self.types, "document_types/", name) + + def custom_field_typed(self, name, paperless_type): + """Feld mit ausdruecklich angegebenem Paperless-Datentyp.""" + return self._ensure(self.fields, "custom_fields/", name, + {"data_type": paperless_type}) + + def custom_field(self, name, ecodms_type): + mapping = {"Date": "date", "CheckBox": "boolean", "String": "string"} + return self._ensure( + self.fields, + "custom_fields/", + name, + {"data_type": mapping.get(ecodms_type, "string")}, + ) + + # -- Rechte -------------------------------------------------------------- + + def permissions_for(self, roles): + """ + ecoDMS-Rollen -> Paperless owner + set_permissions. + Nicht abgebildete Rollen fuehren zum Abbruch, nicht zum stillen + Verwerfen: In Paperless heisst "keine Rechte" nicht gesperrt, + sondern unbeschraenkt. + """ + cfg = self.rollen["roles"] + rights = self.rollen["rights"] + owner, view_u, view_g, chg_u, chg_g = None, set(), set(), set(), set() + + for entry in roles: + role, right = entry["role"], entry["right"] + if role not in cfg: + raise KeyError( + f"Rolle '{role}' fehlt in rollen.yml. Ergaenzen oder " + f"ausdruecklich als 'type: null' eintragen." + ) + spec = cfg[role] + if spec.get("type") in (None, "null"): + continue + if right not in rights: + raise KeyError(f"doc_right '{right}' fehlt in rollen.yml") + + perms = rights[right] + if spec["type"] == "user": + uid = self.users.get(spec["paperless"]) + if uid is None: + raise KeyError(f"Benutzer '{spec['paperless']}' existiert nicht") + if spec.get("owner") and owner is None: + owner = uid + if "view" in perms: + view_u.add(uid) + if "change" in perms: + chg_u.add(uid) + else: + gid = self.groups.get(spec["paperless"]) + if gid is None: + raise KeyError(f"Gruppe '{spec['paperless']}' existiert nicht") + if "view" in perms: + view_g.add(gid) + if "change" in perms: + chg_g.add(gid) + + # no_rights_policy: owner_only + if owner is None: + owner = self.users[self.rollen["default_owner"]] + if not (view_u or view_g or chg_u or chg_g): + return owner, None # nur Eigentuemer, sonst niemand + + return owner, { + "view": {"users": sorted(view_u), "groups": sorted(view_g)}, + "change": {"users": sorted(chg_u), "groups": sorted(chg_g)}, + } + + +# ------------------------------------------------------------------- Import + +def iso_added(raw): + """ecoDMS savedate -> ISO-8601 mit Zeitzone.""" + if not raw: + return None + txt = str(raw).strip().replace(" ", "T") + if "." in txt: # Mikrosekunden auf 6 Stellen kuerzen + head, frac = txt.split(".", 1) + txt = f"{head}.{frac[:6]}" + try: + return datetime.fromisoformat(txt).astimezone().isoformat() + except ValueError: + return None + + +def folder_tags(doc_or_folders, mode): + """ + Ordnerpfade -> Tagnamen. + + 'segments' (Vorgabe): 'Beruf/Fortbildung/Steuerberater' wird zu drei + Tags. Erlaubt Filtern nach einzelnen Ebenen, kostet aber den + Hierarchiekontext - 'Steuerberater' allein ist mehrdeutig. Bei + tiefen Baeumen entstehen entsprechend viele Tags. + 'path': ein Tag je Ordnerpfad, Hierarchie bleibt lesbar. + + Bei Mehrfachklassifizierung werden die Segmente vereinigt, Reihenfolge + bleibt stabil. + """ + # Stufe 1 liefert die Ebenen als Liste (folder_parts). Ordnernamen + # koennen Schraegstriche enthalten, ein Zerlegen am Schraegstrich waere + # deshalb falsch. Die Liste "folders" ist nur fuer die Anzeige. + if isinstance(doc_or_folders, dict): + parts_list = doc_or_folders.get("folder_parts") + if parts_list is None: # aeltere Manifeste + parts_list = [f.split(" / ") for f in doc_or_folders.get("folders", [])] + else: + parts_list = [f.split(" / ") for f in doc_or_folders] + + out, seen = [], set() + for parts in parts_list: + names = [" / ".join(parts)] if mode == "path" else parts + for n in names: + n = str(n).strip() + if n and n.casefold() not in seen: + seen.add(n.casefold()) + out.append(n) + return out + + +def import_document(doc, api, res, jr, archive: Path, log, tag_mode="segments"): + docid = doc["ecodms_docid"] + state = jr.state(docid) or {} + + if state.get("status") == "done": + log(f" uebersprungen (bereits importiert als {state['paperless_id']})") + return state["paperless_id"] + + versions = doc["versions"] + if not versions: + raise RuntimeError("keine Version im Manifest") + + jr.start(docid, doc["tranche"], len(versions)) + + # -- Tags: Ordnerebenen + Status + tag_ids = [res.tag(t) for t in folder_tags(doc, tag_mode)] + if doc.get("status"): + tag_ids.append(res.tag(f"Status: {doc['status']}")) + + paperless_id = state.get("paperless_id") + start_at = state.get("versions_done") or 0 + + # -- Version 1 als Wurzeldokument + if not paperless_id: + v1 = versions[0] + f = archive / v1["file"] + if not f.exists(): + raise FileNotFoundError(f) + log(f" v1: {v1['file']}") + task = api.post_document( + f, + { + "title": doc["title"][:127], + "created": doc.get("created") or None, + "document_type": res.doc_type(doc["document_type"]), + "tags": tag_ids, + }, + ) + paperless_id = api.wait_for_task(task, log) if task else None + jr.update(docid, paperless_id=paperless_id, versions_done=1, + status="root_created") + if paperless_id: + jr.remember_time(paperless_id, docid, 1, iso_added(v1["saved_at"])) + start_at = 1 + + # -- Version 2..n strikt seriell anhaengen + for v in versions[start_at:]: + f = archive / v["file"] + if not f.exists(): + raise FileNotFoundError(f) + label = f"v{v['version']}" + if v.get("saved_at"): + label += f" ({str(v['saved_at'])[:10]})" + log(f" v{v['version']}: {v['file']} [{label}]") + task = api.update_version(paperless_id, f, label) + vid = api.wait_for_task(task, log) if task else None + if vid: + jr.remember_time(vid, docid, v["version"], iso_added(v["saved_at"])) + jr.update(docid, versions_done=v["version"]) + + # -- Custom Fields + cf = [] + for name, meta in doc.get("custom_fields", {}).items(): + cf.append({"field": res.custom_field(name, meta["type"]), + "value": meta["value"]}) + cf.append({"field": res.custom_field("ecoDMS-ID", "String"), + "value": str(docid)}) + + # -- Rechte: einmal auf die Wurzel, Versionen erben ueber root_document + owner, perms = res.permissions_for(doc["permissions"]) + payload = { + "owner": owner, + "custom_fields": cf, + "archive_serial_number": docid, # ecoDMS-docid als ASN + } + if perms: + payload["set_permissions"] = perms + + if paperless_id: + try: + api.patch(f"documents/{paperless_id}/", payload) + except Exception as exc: # noqa: BLE001 + # Die ASN ist in Paperless eindeutig. Ein Konflikt darf nicht + # das ganze Dokument kosten - lieber ohne ASN weitermachen und + # den Fall protokollieren. + if "archive_serial_number" in str(exc) or "serial" in str(exc).lower(): + log(f" ASN {docid} abgelehnt ({exc}) - Import ohne ASN") + payload.pop("archive_serial_number") + api.patch(f"documents/{paperless_id}/", payload) + else: + raise + + jr.update(docid, status="done", last_error=None) + return paperless_id + + +def run_import(args, manifest, api, res, jr, log): + archive = Path(manifest["source"]) + docs = manifest["documents"] + if args.limit: + docs = docs[: args.limit] + + ok = failed = 0 + for i, doc in enumerate(docs, 1): + log(f"[{i}/{len(docs)}] docid {doc['ecodms_docid']}: {doc['title'][:60]}") + try: + import_document(doc, api, res, jr, archive, log, args.tag_mode) + ok += 1 + except Exception as exc: # noqa: BLE001 + failed += 1 + jr.update(doc["ecodms_docid"], status="failed", last_error=str(exc)) + log(f" FEHLER: {exc}") + if args.stop_on_error: + raise + log(f"\nFertig: {ok} erfolgreich, {failed} fehlgeschlagen.") + + +# --------------------------------------------------------- Zeitstempel-Skript + +TIMESTAMP_TEMPLATE = '''\ +# Erzeugt von paperless_import.py --emit-timestamps +# +# Ausfuehren mit: +# sudo docker compose exec -T webserver python manage.py shell < fix_added.py +# +# update() statt save(), damit auto_now-Felder und Signal-Handler nicht +# greifen - die wuerden den Wert wieder ueberschreiben und nebenbei +# Reindexierungen ausloesen. +from documents.models import Document +from django.utils.dateparse import parse_datetime + +ROWS = {rows} + +changed = missing = 0 +for pk, added in ROWS: + n = Document.objects.filter(pk=pk).update(added=parse_datetime(added)) + if n: + changed += 1 + else: + missing += 1 +print(f"{{changed}} Zeitstempel gesetzt, {{missing}} Dokumente nicht gefunden") +''' + + +def emit_timestamps(jr): + rows = [ + (r["paperless_id"], r["added"]) + for r in jr.con.execute( + "SELECT paperless_id, added FROM version_times " + "WHERE added IS NOT NULL ORDER BY ecodms_docid, version" + ) + ] + print(TIMESTAMP_TEMPLATE.format(rows=repr(rows))) + + +# ------------------------------------------------------------ Verknuepfungen + +def resolve_links(path: Path, api, res, jr, log): + """ + Phase 2, erst nach ALLEN Tranchen: Erst jetzt sind alle docids im + Journal, also auch die aus spaeteren Tranchen. + + CSV-Format, eine Zeile je Paar: src_docid,dst_docid + """ + with path.open() as fh: + for row in csv.reader(fh): + if len(row) >= 2 and row[0].strip().isdigit(): + jr.add_link(int(row[0]), int(row[1])) + + field_id = res.custom_field("ecoDMS-Verknuepfung", "documentlink") + pending = list(jr.con.execute("SELECT * FROM links WHERE resolved = 0")) + log(f"{len(pending)} Verknuepfungen aufzuloesen") + + # Beide Richtungen sammeln - die Automatik ist beim Bulk-Edit + # nachweislich einseitig (Issue #8960). + partners: dict[int, set[int]] = {} + unresolved = 0 + for row in pending: + a = jr.state(row["src_docid"]) + b = jr.state(row["dst_docid"]) + if not (a and b and a["paperless_id"] and b["paperless_id"]): + unresolved += 1 + continue + partners.setdefault(a["paperless_id"], set()).add(b["paperless_id"]) + partners.setdefault(b["paperless_id"], set()).add(a["paperless_id"]) + + for pid, others in partners.items(): + api.patch( + f"documents/{pid}/", + {"custom_fields": [{"field": field_id, "value": sorted(others)}]}, + ) + jr.con.execute("UPDATE links SET resolved = 1 WHERE resolved = 0") + jr.con.commit() + log(f"{len(partners)} Dokumente verknuepft, {unresolved} ohne Gegenstueck") + + +# ----------------------------------------------------------------- Pruefung + +def verify(api, jr, log): + """ + Prueft das Journal gegen die Instanz. Holt die Dokumente in einem + Stapelabruf statt einzeln - bei ueber tausend Dokumenten waere ein + Aufruf je Dokument unbrauchbar langsam. + """ + rows = list(jr.con.execute("SELECT * FROM mapping ORDER BY ecodms_docid")) + log(f"Journal: {len(rows)} Dokumente") + for status in ("done", "root_created", "pending", "uebersprungen", "failed"): + n = sum(1 for r in rows if r["status"] == status) + if n: + log(f" {status:14s} {n}") + + log("\n lade Dokumente aus Paperless ...") + live = {} + for d in api.get_all("documents/", + fields="id,owner,archive_serial_number,versions"): + live[d["id"]] = d + log(f" {len(live)} Dokumente in der Instanz\n") + + problems = 0 + def flag(msg): + nonlocal problems + problems += 1 + if problems <= 40: + log(f" {msg}") + + seen = set() + for r in rows: + if r["status"] not in ("done", "root_created"): + continue + pid = r["paperless_id"] + if not pid: + flag(f"docid {r['ecodms_docid']}: ohne Paperless-ID") + continue + d = live.get(pid) + if d is None: + flag(f"docid {r['ecodms_docid']}: Dokument {pid} nicht in der Instanz") + continue + seen.add(pid) + n = len(d.get("versions") or []) or 1 + if n != r["versions_total"]: + flag(f"docid {r['ecodms_docid']}: {n} Versionen, " + f"{r['versions_total']} erwartet") + if d.get("owner") is None: + flag(f"docid {r['ecodms_docid']}: KEIN EIGENTUEMER " + f"(waere unbeschraenkt sichtbar)") + asn = d.get("archive_serial_number") + if asn is not None and int(asn) != r["ecodms_docid"]: + flag(f"docid {r['ecodms_docid']}: ASN {asn} weicht ab") + + # Dokumente in der Instanz, die keine Wurzel aus dem Journal sind und + # auch keine Version davon - Kandidaten fuer doppelte Anlage. + versions_of_known = { + v["id"] for pid in seen for v in (live.get(pid, {}).get("versions") or []) + } + orphans = sorted(set(live) - seen - versions_of_known) + if orphans: + log(f"\n {len(orphans)} Dokumente ohne Journaleintrag " + f"(moegliche Doppelanlage): {orphans[:20]}") + problems += len(orphans) + + if problems > 40: + log(f" ... und {problems - 40} weitere") + log(f"\n{problems} Auffaelligkeiten." if problems else "\nKeine Auffaelligkeiten.") + + +# --------------------------------------------------------------------- Main + +def main(): + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("manifest", nargs="?", type=Path, help="JSON aus Stufe 1") + ap.add_argument("--api", default="http://localhost:8000") + ap.add_argument("--token", help="oder Umgebungsvariable PT") + ap.add_argument("--rollen", type=Path, default=Path("rollen.yml")) + ap.add_argument("--journal", type=Path, default=Path("migration.sqlite")) + ap.add_argument("--dry-run", action="store_true") + ap.add_argument("--limit", type=int, help="nur die ersten N Dokumente") + ap.add_argument("--stop-on-error", action="store_true") + ap.add_argument("--tag-mode", choices=("segments", "path"), default="segments", + help="segments: jede Ordnerebene ein eigener Tag (Vorgabe). " + "path: ein Tag je vollstaendigem Ordnerpfad.") + ap.add_argument("--setup", action="store_true", + help="nur Stammdaten anlegen, nichts importieren") + ap.add_argument("--emit-timestamps", action="store_true") + ap.add_argument("--links", type=Path, metavar="CSV") + ap.add_argument("--verify", action="store_true") + args = ap.parse_args() + + log = print + jr = Journal(args.journal) + + if args.emit_timestamps: + emit_timestamps(jr) + return + + import os + token = args.token or os.environ.get("PT") + if not token: + sys.exit("Token fehlt: --token oder export PT=...") + + api = Paperless(args.api, token, dry_run=args.dry_run) + rollen = yaml.safe_load(args.rollen.read_text(encoding="utf-8")) + res = Resolver(api, rollen, log) + + if args.verify: + verify(api, jr, log) + return + + if args.links: + resolve_links(args.links, api, res, jr, log) + return + + if not args.manifest: + sys.exit("Manifest fehlt.") + manifest = json.loads(args.manifest.read_text(encoding="utf-8")) + + log(f"Tranche {manifest['tranche']}: " + f"{manifest['counts']['documents']} Dokumente, " + f"{manifest['counts']['versions']} Versionen") + if args.dry_run: + log("TROCKENLAUF - es wird nichts geschrieben\n") + + # Rollen vorab pruefen, bevor die erste Datei hochgeht + seen = {p["role"] for d in manifest["documents"] for p in d["permissions"]} + unknown = seen - set(rollen["roles"]) + if unknown: + sys.exit(f"Unbekannte Rollen in rollen.yml ergaenzen: {sorted(unknown)}") + + if args.setup: + for d in manifest["documents"]: + for t in folder_tags(d, args.tag_mode): + res.tag(t) + if d.get("status"): + res.tag(f"Status: {d['status']}") + res.doc_type(d["document_type"]) + for name, meta in d.get("custom_fields", {}).items(): + res.custom_field(name, meta["type"]) + # Felder aus rollen.yml unabhaengig vom Tranchen-Inhalt anlegen, + # damit Namen und Typen nicht vom Zufall der ersten Tranche abhaengen. + for name, dtype in (rollen.get("custom_fields") or {}).items(): + res.custom_field_typed(name, dtype) + log("Stammdaten angelegt.") + return + + run_import(args, manifest, api, res, jr, log) + + +if __name__ == "__main__": + main() diff --git a/rollen.yml.example b/rollen.yml.example new file mode 100644 index 0000000..10c59cd --- /dev/null +++ b/rollen.yml.example @@ -0,0 +1,101 @@ +# Uebersetzung der ecoDMS-Rollen nach Paperless-ngx. +# Nach rollen.yml kopieren und anpassen. +# +# GRUNDREGEL: Nicht eingetragene Rollen fuehren zum ABBRUCH, nicht zum +# stillen Verwerfen. Denn in Paperless bedeutet "keine Rechte gesetzt" +# nicht gesperrt, sondern UNBESCHRAENKT - ein Tippfehler wuerde Dokumente +# oeffnen, nicht schliessen. Bewusstes Weglassen wird als "type: null" +# notiert. +# +# Vorkommende Rollen ermitteln: der Pruefbericht von ecodms_extract.py +# gibt sie je Tranche unter "Rollen" aus. Ueber alle Tranchen zusammenfuehren. + +# --------------------------------------------------------------------------- +# Zielstruktur - vor dem ersten Import in Paperless anlegen +# --------------------------------------------------------------------------- +# Nur Dokumentation; angelegt wird ueber die Django-Shell, siehe docs/01. +paperless_users: + - benutzer_a + - benutzer_b + +paperless_groups: + Gruppe_A: + - benutzer_a + - benutzer_b + +# Keiner Gruppe das Recht zum endgueltigen Loeschen geben. Zusammen mit +# Audit-Log, Versionierung und Papierkorb-Frist ist das der vierte Baustein +# der Nachvollziehbarkeit. +group_permissions: + default: [view, change] # ausdruecklich ohne delete + +# --------------------------------------------------------------------------- +# Abbildung der ecoDMS-Rollen +# --------------------------------------------------------------------------- +# Greift, wenn nach der Uebersetzung kein Eigentuemer uebrig bleibt. +default_owner: benutzer_a + +roles: + + # Einzelbenutzer-Rollen tragen in ecoDMS ueblicherweise ein r_-Praefix. + r_benutzer_a: + type: user + paperless: benutzer_a + owner: true # wird Eigentuemer, wenn an einem Dokument beteiligt + + r_benutzer_b: + type: user + paperless: benutzer_b + + # Echte Gruppen ohne Praefix. + GRUPPENNAME: + type: group + paperless: Gruppe_A + + # Globales Importrecht: Jeder hat Zugriff. Wird beim Import gesetzt und + # bei der ersten Klassifizierung durch die echten Berechtigten ersetzt. + # Wer es uebernimmt, oeffnet jedes Dokument fuer alle. + ecoSIMSUSER: + type: null + +# --------------------------------------------------------------------------- +# doc_right -> Paperless-Objektrechte +# --------------------------------------------------------------------------- +# 'change' impliziert in Paperless kein 'view'. W muss deshalb beides setzen. +rights: + R: [view] + W: [view, change] + +# --------------------------------------------------------------------------- +# Dokumente ohne Rechteeintrag +# --------------------------------------------------------------------------- +# In der Praxis haben viele Dokumente GAR KEINE Zeile in docrollen_hist - +# ecoDMS speichert offenbar nur ausdruecklich vergebene Berechtigungen. +# +# owner_only - nur default_owner, sonst niemand (empfohlen) +# unrestricted - kein Eigentuemer, fuer alle sichtbar +no_rights_policy: owner_only + +# Optional: Eigentuemer aus klassifizierung.changeid ableiten statt pauschal +# default_owner. Das Feld enthaelt Klarnamen, keine Rollennamen. +owner_from_changeid: false +users_by_name: + "Vorname Nachname": benutzer_a + +# --------------------------------------------------------------------------- +# Zusatzfelder +# --------------------------------------------------------------------------- +# Werden von "--setup" angelegt, unabhaengig davon, ob in der ersten Tranche +# schon Werte vorkommen. Der Name MUSS exakt dem Namen in der ecoDMS-Tabelle +# dynattribute entsprechen, sonst legt der Import ein zweites, gleichnamiges +# Feld an und die Werte verteilen sich auf beide. +# +# Typen: string, date, boolean, integer, float, monetary, url, documentlink +custom_fields: + Belegnummer: string + Zeitraum: string + Eingangsdatum: date + Zahlungsdatum: date + Wiedervorlage: date # aus klassifizierung.defdate + ecoDMS-ID: string + ecoDMS-Verknuepfung: documentlink