Initial commit
This commit is contained in:
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