379 lines
13 KiB
Markdown
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.
|