# Das ecoDMS-Exportformat Nachschlagewerk zu den Eigenheiten des Exports. Der größte Teil davon steht in keiner Dokumentation und wurde bei der Migration eines gewachsenen Bestands aufgedeckt. **Wer nur ein Dokument aus diesem Repository liest, sollte es dieses sein.** Fast jeder der hier beschriebenen Punkte kann zu stiller Fehlzuordnung führen — zu Dokumenten am falschen Platz, zu Rechten an der falschen Stelle, zu verlorenen Versionen. --- ## Aufbau eines Exports Das ecoDMS-Exportwerkzeug erzeugt ein ZIP mit dieser Struktur: ``` offline_export/ ├── archive/ │ ├── export.data SQLite-Datenbank mit Metadaten │ ├── export.xml dieselben Daten plus Historie │ ├── lucene/ Suchindex des Offline-Clients │ ├── ecodms_docid_0010325.pdf Kopfversion als PDF/A │ ├── ecodms_docid_0010325_revision_0001.odt Original je Version │ └── … └── (Programmdateien des Offline-Clients) ``` **Die Dateien liegen im Dateisystem, nicht in der Datenbank.** Die BLOB-Spalten `data`, `pdf` und `ocrtext` in `export.data` sind durchgehend `NULL`. Das ist gut so — eine SQLite mit dem gesamten Dateibestand wäre unhandlich. --- ## Fallstrick 1: `docid` gegen `docs_id` **Der wichtigste Punkt des ganzen Dokuments.** ecoDMS führt zwei Nummern, die leicht verwechselt werden: | Nummer | Bedeutung | Wo | |---|---|---| | `docid` | das Dokument | `klassifizierung.docid`, `ecosimsversions.docid` | | `docs_id` | die Klassifizierungsinstanz | `docs.id` | Bei niedrigen Nummern stimmen beide zufällig überein. Bei höheren laufen sie auseinander: ``` docs.id 718 → docid 697 docs.id 2022 → docid 1960 ``` Ein Join über die falsche Spalte liefert deshalb bei alten Dokumenten korrekte Ergebnisse und bei neuen falsche. Das fällt beim Testen mit wenigen Datensätzen **nicht** auf. ### Verschärfung: falsch benannte Spalten Zwei Tabellen haben eine Spalte namens `docid`, die in Wahrheit die `docs_id` enthält: ```sql CREATE TABLE docrollen_hist(docid bigint, role varchar, doc_right char(1), revision varchar); -- ^^^^^ enthält docs_id CREATE TABLE econotice(id bigint, text vcharacter, tdate timestamp, nid vcharacter, username varchar); -- ^^^ enthält docs_id ``` Der korrekte Join ist in beiden Fällen `… = docs.id`. ### So prüft man es Nicht an Datensätzen mit niedrigen Nummern — dort sind beide Werte gleich. Such gezielt Fälle mit Abweichung und prüf **inhaltlich**, welche Auflösung zum Datensatz passt: ```sql SELECT n.id, n.nid, (SELECT docid FROM docs WHERE id = CAST(n.nid AS INTEGER)) AS als_docs_id, (SELECT id FROM docs WHERE docid = CAST(n.nid AS INTEGER)) AS als_docid FROM econotice n WHERE als_docs_id IS NOT als_docid; ``` Dann die Bemerkung der beiden Kandidaten ansehen. Eine Notiz „an Herrn X gesendet" gehört zum Dienstplan, nicht zur Zinsmitteilung der Bank. --- ## Fallstrick 2: Mehrfachklassifizierung Ein Dokument kann in ecoDMS **mehrfach abgelegt** sein — dieselbe Datei in zwei Ordnern, mit je eigener Klassifizierung. ``` docs.id 10829 (kid 22325) → Haushalt/Kleingeräte, Zeitraum leer docs.id 10834 (kid 22540) → Steuer/ESt/Anlage N, Zeitraum 2025 beide: docid 10347 ``` Daraus folgt: **Eine `docid` entspricht nicht einer Zeile in `docs`.** Wer nach `docs_id` gruppiert, legt das Dokument zweimal an. Paperless bildet das sogar besser ab, weil Tags many-to-many sind: Aus zwei Ablageorten wird ein Dokument mit den Tags beider Pfade. Man braucht aber eine Zusammenführungsregel für die skalaren Felder — bewährt hat sich: der jüngste `ctimestamp` gewinnt, Ordner werden vereinigt, Abweichungen bei Titel, Dokumentart, Status und Datum werden protokolliert. Häufigkeit im Testbestand: **eines von vier** Dokumenten einer Stichprobe. Nicht als Randfall behandeln. --- ## Fallstrick 3: Revision ist nicht Version Zwei völlig unabhängige Zähler: | Begriff | Wo | Zählt | |---|---|---| | `klassifizierung.revision` | `1.0`, `1.1`, `1.2` … | Klassifizierungsänderungen | | `ecosimsversions.version` | `1`, `2`, `3` … | Dateiversionen | Beispiele aus einem echten Bestand: | docid | Dateiversionen | Revision | |---|---|---| | 10325 | 4 | 1.3 (= 4. Revision) | | 10422 | **3** | **1.1 (= 2. Revision)** | | 11072 | **0** | 1.5 (= 6. Revision) | | 11141 | 1 | 1.2 (= 3. Revision) | Es gibt keine Umrechnung. Für die Versionskette in Paperless zählt ausschließlich `ecosimsversions.version`. **Und beachte `docid 11072`:** null Dateiversionen. Solche Dokumente haben keine Zeile in `ecosimsversions` und müssen auf `ecosimsarchive` zurückfallen, sonst verschwinden sie stillschweigend. --- ## Fallstrick 4: Immer das Original importieren `ecosimsversions` führt zwei Dateinamen: | Spalte | Inhalt | |---|---| | `realname` | Originaldatei | | `pdfrealname` | PDF/A-Fassung | **Immer `realname` verwenden.** Der Unterschied ist nicht kosmetisch: - Bei **ZUGFeRD** enthält das Original die eingebettete XML. Eine neu gerenderte Fassung womöglich nicht. - Bei **E-Mails** enthält die `.eml` die Anhänge. Das gerenderte PDF nicht. In einem Testfall: 34,0 KB Original gegen 25,8 KB PDF — die Differenz war der Rechnungsanhang. Bei reinen PDF-Dokumenten sind beide Spalten identisch, die Regel kostet dort also nichts. ### Was fehlt Bei **Nicht-PDF-Originalen** exportiert ecoDMS pro Version nur das Original, nicht die PDF/A-Fassung — obwohl `pdfrealname` sie referenziert. Die XML verweist dann auf Dateien, die im Archiv nicht existieren. Für Paperless unkritisch, es erzeugt Archivdateien selbst. Aber ein Skript, das blind allen Referenzen folgt, läuft ins Leere. --- ## Fallstrick 5: Rechte sind historisiert `docrollen_hist` ist eine Historientabelle. Sie enthält alle jemals gesetzten Rechte, nicht nur die aktuellen: ``` docs_id 10829 ecoSIMSUSER W 1.0 docs_id 10829 r_benutzer_a W 1.1 docs_id 10829 r_benutzer_b W 1.1 ``` `ecoSIMSUSER` ist das **globale Importrecht** — jeder hat Zugriff. Es wird beim Import gesetzt und bei der ersten Klassifizierung durch die echten Berechtigten ersetzt. **Wer alle Zeilen übernimmt, öffnet jedes Dokument für alle.** Maßgeblich sind nur die Zeilen mit der **höchsten in `docrollen_hist` vorhandenen** Revision je `docs_id`. Nicht der Revisionswert aus `klassifizierung` — der kann höher sein, wenn zuletzt nur die Klassifizierung geändert wurde. ### Revisionen richtig sortieren `revision` ist ein String. Lexikografisch ist `'1.10'` kleiner als `'1.9'`. Bei einem Bestand mit mehr als neun Revisionen führt das zu falschen Ergebnissen. Als Tupel aus Integern parsen: ```python def revtuple(r): return tuple(int(p) for p in str(r).split(".")) ``` ### Dokumente ohne Rechteeintrag Viele Dokumente haben **gar keine Zeile** in `docrollen_hist` — in einer Stichprobe sechs von acht. Die Tabelle speichert offenbar nur ausdrücklich vergebene Berechtigungen. Das ist der gefährlichste Punkt der ganzen Migration, denn: > **In Paperless bedeutet „keine Rechte gesetzt" nicht gesperrt, sondern > unbeschränkt.** Ein Dokument ohne Eigentümer und ohne Objektrechte ist für jeden angelegten Benutzer sichtbar. Die Migration muss deshalb jedem Dokument einen Eigentümer geben, auch wenn aus ecoDMS nichts überliefert ist. --- ## Fallstrick 6: Base64 an unregelmäßigen Stellen Manche Felder sind base64-kodiert, benachbarte nicht: | Kodiert | Klartext | |---|---| | `ecosimsversions.savedate` | `ecosimsversions.realname` | | `ecosimsversions.filename` | `ecosimsversions.pdfrealname` | | `ecosimsarchive.version` | `ecosimsarchive.filename` | | `econotice.text` | `econotice.tdate` | | XML `` | XML `letzte-änderung` | | `ecodmsexporter.exp_query` | | Es gibt kein erkennbares System. Im Zweifel dekodieren und bei einem Fehlschlag den Originalwert nehmen. ### Notiztexte `econotice.text` ist base64-kodiertes **Qt-HTML** mit CSS-Block im Kopf. Zum Auspacken: ```python s = base64.b64decode(raw).decode("utf-8", "replace") s = re.sub(r"<[^>]+>", " ", s) # Tags s = html.unescape(re.sub(r"\s+", " ", s)) # Entities, Leerraum s = re.sub(r"^\s*p,\s*li\s*\{[^}]*\}\s*", "", s) # CSS-Rest ``` Der letzte Schritt ist nötig, weil der CSS-Block nach dem Entfernen der Tags als Text übrigbleibt: `p, li { white-space: pre-wrap; }` --- ## Fallstrick 7: Ordnernamen enthalten Schrägstriche Ordner heißen in der Praxis auch `Telefon / Internet` oder `TV / Rundfunk`. Wer den Ordnerpfad zu einem String verkettet und später am Schrägstrich zerlegt, zerschneidet solche Namen in `Telefon ` und ` Internet`. Die Folge sind Tags mit Leerzeichen am Rand — und wenn das Zielsystem die beim Speichern entfernt, kollidieren sie mit einer echten Ebene gleichen Namens. **Ordnerpfade als Liste von Segmenten führen, nicht als verketteten String.** --- ## Wo die Wiedervorlage steckt Nicht in `dynattribute`, sondern in **`klassifizierung.defdate`**. Es ist eine eingebaute Funktion, kein dynamisches Attribut. Passend dazu gibt es in `status` den Wert `Wiedervorlage`. `econotice` ist **nicht** die Wiedervorlage, sondern die Notizfunktion. --- ## Die Live-Datenbank ist etwas anderes Wer die Klassifizierungsdaten in der laufenden ecoDMS-PostgreSQL sucht, sucht vergeblich. Dort gibt es **weder `klassifizierung` noch `docs`** — die inhaltliche Ebene erzeugt erst der Exporter aus dem Container-Speichersystem. In der Live-Datenbank liegt nur die Verwaltungsebene: Archive, Dateien, Versionen, Benutzer, Rollen, Suchindex. Das erklärt auch, warum **Dokumentverknüpfungen** nicht auffindbar sind. Vier unabhängige Prüfungen an einem produktiven Bestand: - keine Tabelle mit zwei Dokumentspalten - beim Anlegen einer Verknüpfung entsteht **keine neue Zeile** in irgendeiner Tabelle - die Partner-ID kommt in keiner Textspalte vor - ebenso in keiner Zahlenspalte außer den eigenen Datensätzen Verknüpfungen sind damit weder im Export noch in der Datenbank greifbar. Sie müssen von Hand nachgetragen werden. --- ## Der Exporter selbst ### AND statt OR Bei manueller Mehrfachauswahl kann der Exporter eine unerfüllbare Abfrage erzeugen: ```sql docid = '11072' AND docid = '11094' AND docid = '11143' AND … ``` Das Ergebnis ist ein Export mit vollständigen Stammdaten und **null Dokumenten**, ohne Fehlermeldung. Die Abfrage steht base64-kodiert in `ecodmsexporter.exp_query`: ```python base64.b64decode(row["exp_query"]).decode() ``` **Nach jedem Export prüfen.** Und Exporte besser über ein Suchkriterium oder einen ID-Bereich definieren als über eine Liste einzeln markierter Dokumente. ### Tabellen im Export | Tabelle | Inhalt | |---|---| | `docs` | `id` (= docs_id), `docid`, `kid`, `archiv`, `trashed`, `attachments` | | `klassifizierung` | `kid`, `docid`, `mainfolder`, `folder`, `bemerkung`, `status`, `revision`, `docart`, `ctimestamp`, `cdate`, `defdate`, `docs_id`, `dyn_*` | | `ecosimsversions` | `docid`, `version`, `savedate`, `realname`, `pdfrealname`, `checksum`, `comment`, `fixiert` | | `ecosimsarchive` | Kopfdokument je `docid`, Rückfall wenn keine Versionen | | `docrollen_hist` | `docid` (= docs_id!), `role`, `doc_right`, `revision` | | `econotice` | `id`, `text` (base64 Qt-HTML), `tdate`, `nid` (= docs_id!), `username` | | `systemordner` | `oid`, `name`, `parentid` — Hierarchie über `parentid` | | `documentenart`, `status` | Stammdaten, mit `trashed`-Kennzeichen | | `dynattribute` | Spaltenname → Anzeigename und Typ, mit `deleted`-Kennzeichen | | `dup_items`, `duplicatesindexer` | Duplikaterkennung, für die Migration irrelevant | ### `attachments` bedeutet etwas anderes `docs.attachments` zählt **eingebettete** Anhänge, nicht separate Dateien: - bei ZUGFeRD die im PDF eingebettete XML - bei E-Mails die Anhänge innerhalb der `.eml` Beide reisen mit dem Original mit. Es fehlt nichts — solange man `realname` importiert. ### `lucene` ist wertlos Der Ordner enthält den Suchindex des Offline-Clients. Gespeichert sind nur Klassifizierungsdaten, die ohnehin vorliegen. Der Dokumenttext existiert lediglich als kleingeschriebene Termliste im invertierten Index und ist nicht rekonstruierbar. Schade — sonst hätte man sich das erneute OCR sparen können. --- ## Prüfliste vor jedem Import - [ ] `exp_query` dekodiert und plausibel? - [ ] Anzahl Dokumente im Export gegen die Erwartung? - [ ] Alle in der XML referenzierten Dateien im Archiv vorhanden? - [ ] Rollen im Export vollständig in der Übersetzung abgebildet? - [ ] Vorkommende `doc_right`-Werte bekannt? - [ ] Dateitypen bekannt und von Paperless unterstützt? - [ ] Häufigkeit der Mehrfachklassifizierung geprüft? - [ ] Dokumente ohne Rechteeintrag — Regel festgelegt? `ecodms_extract.py` beantwortet all das im Prüfbericht.