Initial commit

This commit is contained in:
2026-09-04 19:51:02 +02:00
parent 7840783dc6
commit 13bae7d600
14 changed files with 3766 additions and 0 deletions

146
.env.example Normal file
View File

@@ -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

16
.gitignore vendored Normal file
View File

@@ -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

28
Dockerfile Normal file
View File

@@ -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

121
docker-compose.yml Normal file
View File

@@ -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"

View File

@@ -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).

View File

@@ -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).

210
docs/03-extraktion.md Normal file
View File

@@ -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).

296
docs/04-import.md Normal file
View File

@@ -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=<id>, versions_done=1,
status='root_created', last_error=NULL WHERE ecodms_docid=<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).

250
docs/05-nacharbeit.md Normal file
View File

@@ -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=<docid>" \
| 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/<fremde-id>/
```
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.

View File

@@ -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 `<date>` | 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.

577
ecodms_extract.py Normal file
View File

@@ -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()

258
ecodms_notizen.py Normal file
View File

@@ -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()

863
paperless_import.py Normal file
View File

@@ -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()

101
rollen.yml.example Normal file
View File

@@ -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