Files
migrate-ecodms-to-paperless/docs/referenz-ecodms-exportformat.md
2026-09-04 19:51:02 +02:00

379 lines
13 KiB
Markdown

# 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.