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

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.