Initial commit
This commit is contained in:
288
docs/01-paperless-vorbereiten.md
Normal file
288
docs/01-paperless-vorbereiten.md
Normal 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).
|
||||
234
docs/02-export-aus-ecodms.md
Normal file
234
docs/02-export-aus-ecodms.md
Normal 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
210
docs/03-extraktion.md
Normal 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
296
docs/04-import.md
Normal 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
250
docs/05-nacharbeit.md
Normal 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.
|
||||
378
docs/referenz-ecodms-exportformat.md
Normal file
378
docs/referenz-ecodms-exportformat.md
Normal 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.
|
||||
Reference in New Issue
Block a user