Skip to content

Dokumente (Technik)

Dokumentenverwaltung Phase 1a: Eingang, Extraktion, Prüfung, Ablage, Volltext, Versionen (ADR-0063, ADR-0065). Anwendersicht: Dokumente. Code: backend/app/documents/.

Setup

./scripts/alembic_upgrade_all.sh

Control 0044_document_permissions, Tenant 0032_documents_core und 0033_documents_hardening (legal_hold nur per Spaltenrecht gesperrt, documents_purge nur für den Migrations-Owner, eine Textextraktion je Version). Neue Mandanten bekommen dieselben Einschränkungen nach den Pauschal-Grants der Provisionierung (app/documents/grants.py). Danach den neuen Worker celery-documents starten (siehe Deployment). Konfigurationsschlüssel: FILE_STORAGE_DIR (Blob-Store), OLLAMA_BASE_URL (für Phase 1b vorbereitet).

Datenmodell

Alle Tabellen liegen in der Mandanten-DB.

Tabelle Zweck
document_categories Kategorien (10_Vertrag … 99_Sonstiges)
document_types 31 geseedete Tiefbau-Typen: Code, Label, Ebene (Projekt/Abschnitt), fields (JSON), Synonyme, Sensitivität (normal/internal/confidential), send_policy, retention_years
documents Status received / review / filed / rejected / trashed (Papierkorb, Tenant-Migration 0035), extraction_state, Titel, document_date, reference_no, valid_from / valid_until, attrs, suggestion / confidence (für P14b), source_channel, filed_folder / filed_name, legal_hold, search_tsv
document_versions unveränderlich: SHA-256, blob_key, Hash-Kette prev_record_hash → record_hash je Dokument
document_extractions Extraktionsergebnisse, nur anhängen
document_links typisierte Fremdschlüssel: Organisation, Person, Genehmigungsverfahren, Auflage, Abschnitts-Auftrag, Tracker-Issue, Geo-Objekt, Entity-Datensatz, Nachricht, verwandtes Dokument; Rollen issuer, recipient, sender, client, contractor, subject, evidence_for, supersedes, reply_to
document_events Audit, nur anhängen
document_settings max_upload_mb (100), allow_unknown_types, min_confidence (0,75), rejected_retention_days (30)

Zusätzlich work_sections.code (nullable, z. B. BA-03), pflegbar über das Feld code der vorhandenen Abschnitts-API.

Typkatalog

Code · Label · Ebene · Sensitivität (N normal, I intern, C vertraulich). Ebene und Sensitivität stehen nur dort, wo sie bekannt und festgelegt sind; die vollständige Zuordnung steht im Seed der Migration 0032.

Kategorie Typen
10 Vertrag VTR-BAU Bauvertrag/Auftrag (Projekt, C); VTR-NU Nachunternehmervertrag (C); VTR-BEST Bestellung/Einzelabruf (Abschnitt, C)
20 LV/Angebot LV-GAEB Leistungsverzeichnis (C); ANG Angebot (C)
30 Planung PLN-LAGE Lage-/Trassenplan (Abschnitt, N)
40 Leitungsauskunft LA Leitungs-/Spartenauskunft (Abschnitt, N)
50 Genehmigungen GEN-VRAO Verkehrsrechtliche Anordnung §45 StVO (Abschnitt, N); VZP Verkehrszeichenplan; GEN-AUFG Aufbruch-/Aufgrabegenehmigung; GEN-TKG Zustimmung §127 TKG (Projekt); GEN-SNU Sondernutzung/Gestattung; GEN-SONST Sonstige Genehmigung
60 Ausführung BTB Bautagebuch (I); LS Lieferschein (I); PRF Prüf-/Messprotokoll (I)
70 Fotos/Beweissicherung FOTO Foto/Beweisfoto (I); BSV Beweissicherung (I)
80 Nachträge BEH Behinderungsanzeige §6 VOB/B; BED Bedenkenanzeige §4 Abs. 3; MKA Mehrkostenanzeige; NTA Nachtragsangebot; NTV Nachtragsvereinbarung (alle C, Projekt)
85 Aufmaß/Abrechnung AUFM Aufmaß (REB, X31) (I); RE Rechnung (C)
90 Abnahme/Doku ABN Abnahmeprotokoll; PLN-REV Revisions-/Bestandsplan; FMA Fertigmeldung
95 Korrespondenz KORR Schriftverkehr; PROT Protokoll
99 Sonstiges SONST

Felddefinitionen (JSON) gibt es u. a. für GEN-VRAO (Aktenzeichen → reference_no, Ausstellungsdatum → document_date, gültig von/bis → valid_from / valid_until, Straße/Ort, Maßnahme, Verkehrsführung als Enum, Regelplan, Arbeitszeiten, Auflagen, Gebühr als Geldbetrag), LA, VTR-BAU, BEH, NTA. send_policy-Standard: N automatisch an Feld-/Manager-Kontakte, I erst nach Prüfung, C nie automatisch. Deutsche Datums- (14.09.2026) und Geldformate (1.234,56 €) werden geparst.

Blob-Store

{FILE_STORAGE_DIR}/tenants/{tenant_id}/blobs/{sha[:2]}/{sha256}
  • Inhaltsadressiert pro Mandant; identische Bytes liegen einmal. Ein doppelter Upload liefert das vorhandene Dokument (duplicate_of), wenn der Aufrufer es sehen darf.
  • Upload gestreamt in 1-MiB-Blöcken mit SHA-256; Limit aus document_settings.max_upload_mb → 413. FastAPI puffert den Multipart-Body vor dem Handler; UploadBodyLimitMiddleware begrenzt deshalb vorab den Request-Body auf /api/v1/documents (MAX_UPLOAD_BODY_MB, Standard 2100; Content-Length und gestreamte Bytes → 413). Ein engeres Limit am Proxy bleibt möglich.
  • MIME aus Magic Bytes. HTML, SVG, Skripte und ausführbare Dateien → 415. Unbekanntes wird als application/octet-stream gespeichert (nur Download).
  • Dateinamen: Unicode NFC, für SharePoint/Windows verbotene Zeichen entfernt. Gilt auch für die Uploads von Rohinput und Feldfotos; der Rohinput-Upload prüft zusätzlich die Projektmitgliedschaft.
  • Download GET /documents/{id}/content: PDFs und Bilder inline mit X-Content-Type-Options: nosniff, alles andere als Anhang; Dateiname nach RFC 5987.
  • Vorschau GET /documents/{id}/pages/{n}: die Prüfseite zeigt PDF-Seiten als serverseitig gerenderte PNGs (pdf-oxide, 110 dpi, max. 20 Seiten), weil der eingebaute PDF-Viewer mancher Browser abgeschaltet ist und ein Iframe dann leer bleibt. Cache unter tenants/{tenant_id}/previews/{sha[:2]}/{sha256}/, nicht im Backup.

Ordner-Policy

Reine Projektion: filed_folder und filed_name werden aus den Metadaten berechnet; Umablegen verschiebt nie lokale Bytes.

{root}/{Kunde}/{Projektcode} {Projektname}/
  00_Eingang/                (nur bei gespiegeltem Eingang, Standard aus)
  10_Vertrag/ 20_LV_Angebot/ 30_Planung/ 40_Leitungsauskunft/ 50_Genehmigungen/ 60_Ausfuehrung/
  70_Fotos_Beweissicherung/ 80_Nachtraege/ 85_Aufmass_Abrechnung/ 90_Abnahme_Doku/
  95_Korrespondenz_Protokolle/ 99_Sonstiges/
  Bauabschnitte/{BA-Code} {BA-Name}/
    30_Planung/ 40_Leitungsauskunft/ 50_Genehmigungen/ 60_Ausfuehrung/
    70_Fotos_Beweissicherung/ 85_Aufmass_Abrechnung/ 90_Abnahme_Doku/
  • Kunde = Organisation des Projekts. Abschnitt ohne Code und Name → BA-<id8>.
  • Ein Abschnitts-Typ ohne Abschnitt landet auf Projektebene, mit Warnung „Bauabschnitt fehlt“.
  • Dateiname: {YYYY-MM-DD}_{TYPCODE}_{Kurztitel≤60}[_AZ-{Aktenzeichen}]_v{NN}.{ext}, Schrägstriche im Aktenzeichen werden Bindestriche, Kollision → Suffix _2, _3; Pfad ≤ 400 Zeichen.
  • Die Kategorie folgt dem Typ (document_types.category_code). Verschieben in eine andere Kategorie heißt daher PATCH mit neuem type_code (und section_id); der Explorer wählt den Typ selbst, wenn genau einer zur Zielkategorie und -ebene passt, sonst fragt er.
  • Kopieren (POST /documents/{id}/copy) legt ein neues documents-Dokument an: gleiche Metadaten, source_channel = system, source_ref.copied_from, eine Version mit demselben sha256/blob_key (received_via = copy, keine zweiten Bytes). Fertige Extraktionen werden mitkopiert, sonst reiht die API die Extraktion ein. Ereignisse copied_from (Kopie) und copied_to (Original). Body optional section_id / type_code für das Ziel.
  • Papierkorb: POST /documents/{id}/trash setzt filed → trashed (Ereignis trashed mit letztem Ordner und Namen; 409 bei legal_hold oder nicht abgelegtem Dokument). filed_folder/filed_name bleiben stehen, der Unique-Index gilt nur für filed. POST /documents/{id}/restore legt neu ab (Placement läuft erneut, belegter Name → Suffix) und schreibt restored. Papierkorb-Dokumente erscheinen weder im Baum noch im Export, zählen nicht als Duplikat, lassen sich nicht ablegen oder ablehnen und werden nicht automatisch gelöscht. POST /documents/trash/empty ({project_id}) löscht den Papierkorb eines Projekts endgültig: documents.delete plus Projekt-Schreibrecht wählen die Dokumente aus, die Löschung läuft über retention.purge_trashed_documents als Migrations-Owner (documents_purge, siehe Löschen). Dort wird status = 'trashed' erneut geprüft; legal_hold und retain_until in der Zukunft überspringen das Dokument. Antwort {purged, skipped}. Der Verlauf verschwindet mit dem Dokument, darum schreibt der Aufruf eine Logzeile trash emptied (Mandant, Projekt, Nutzer, Anzahl).
2026-09-14_GEN-VRAO_Hauptstraße-km-0-2_AZ-32-1234-2026_v01.pdf

Verarbeitung

  1. Upload schreibt Blob, documents- und document_versions-Zeile in einer Transaktion.
  2. Nach dem Commit reiht die API linetra.documents.process_version auf der Queue documents ein.
  3. Worker celery-documents (Concurrency 1) extrahiert: PDF mit pdf-oxide seitenweise; Seiten unter 25 Zeichen werden als Scan (needs_ocr) markiert (Phase 1b); Klartext, CSV, XML (GAEB), JSON und E-Mail werden direkt dekodiert; Bilder erhalten skipped.
  4. Ergebnis in document_extractions, Status received → review, documents_refresh_search baut den Suchvektor.
  5. Daten erkennen (POST /documents/{id}/recognize) füllt das Prüfformular auf Knopfdruck, ohne Modell: Ablage-Dateiname ({Datum}_{TYP}_{Titel}[_AZ-…]_vNN), Katalog-Synonyme, beschriftete Zeilen der Typfelder. Suchgebiet, Sparten und Hinweise werden Freitext (finding_note) mit Status hint, bis die Person Stimmt so setzt (checked). Felder mit party_role (Rechnung: rechnungssteller / issuer) bleiben in attrs. In Kontakt übertragen legt die Organisation an oder verknüpft eine gleichnamige und setzt den document_links-Eintrag; Text nach dem ersten Komma (USt-IdNr.) gehört nicht zum Namen. Die Ablage lädt Ordner mit sort=hints_first (umstellbar) und markiert offene Hinweise im Dateinamen. Der Vorschlag landet in suggestion / confidence; die Metadaten ändern sich erst, wenn die Person speichert oder ablegt. Scans ohne Textlage und die LLM-Klassifikation bleiben P14b.
  6. Beat-Task linetra.documents.sweep läuft alle 120 s und reiht ausstehende Extraktionen, die älter als 120 s sind, erneut ein.

Volltextsuche

documents_refresh_search(document_id) baut search_tsv gewichtet:

Gewicht Quelle Konfiguration
A Titel, Aktenzeichen simple
B Typ-Label und Synonyme, attrs, Namen verknüpfter Beteiligter
C extrahierter Text, bis 400 000 Zeichen
D Dateiname

Anfrage: websearch_to_tsquery('german', q) || websearch_to_tsquery('simple', q), damit Nummern wie 32-1234/2026 treffen. ts_headline erzeugt Ausschnitte nur für die Ergebnisseite. Keine neue Postgres-Extension; pg_trgm in Phase 3, pgvector zurückgestellt.

Rechte und Grants

  • Org-Permissions (Control-Migration 0044): documents.inbox (Standard Org-Admin + Mitarbeiter; nötig für Eingangsdokumente ohne Projekt) und documents.confidential (Standard Org-Admin; Verträge, Nachträge, Rechnungen). Vertrauliche Typen sehen sonst nur Projekt-Owner und -Bearbeiter. Rolle customer hat in den Phasen 1–3 keinen Zugriff.
  • Org-Permission documents.delete (Control-Migration 0049, Standard Org-Admin, Systemadmins immer): Papierkorb ansehen (GET /documents?status=trashed), hineinlegen und wiederherstellen; zusätzlich Schreibzugriff auf das Dokument. Ohne status-Filter liefert die Liste keine Papierkorb-Dokumente.
  • Dokumenttabellen: Grants nur für {slug}_edit, entzogen von {slug}_read (Kundenlogins über QGIS/API sehen nichts). UPDATE/DELETE auf document_versions, document_extractions und document_events sind entzogen.
  • Löschen nur über documents_purge(document_id) (SECURITY DEFINER); verweigert bei legal_hold.
  • Persönliche QGIS-Logins von Mitarbeitern erben _edit (ADR-0055) und können die Dokumenttabellen lesen. Akzeptiert, offener Punkt im Lastenheft.

API

Basis /api/v1. Alle Routen brauchen eine Sitzung; Sichtbarkeit nach den Regeln oben.

Method Path Zweck
GET /documents Liste, paginiert. Filter: project_id, section_id, without_section, type_code, category_code, status[], q, date_from, date_to, valid_until_before, organization_id, person_id, procedure_id, issue_id, source_channel; sort = received_desc | document_date_desc | valid_until_asc | relevance
POST /documents Multipart-Upload; optional project_id, section_id, type_code, title
GET / PATCH /documents/{id} Detail / Metadaten ändern (abgelegte Dokumente wandern in der Projektion)
POST /documents/{id}/recognize Daten erkennen: Vorschlag aus Dateiname und Text ins Formular, ohne abzulegen
POST /documents/{id}/file „Bestätigen & ablegen“
POST /documents/{id}/reject Ablehnen
POST /documents/{id}/copy Abgelegtes Dokument als eigenständiges Dokument kopieren (201); optional section_id, type_code
POST /documents/{id}/trash In den Papierkorb (documents.delete)
POST /documents/{id}/restore Aus dem Papierkorb wiederherstellen (documents.delete)
POST /documents/trash/empty Papierkorb eines Projekts endgültig leeren (documents.delete, Projekt-Schreibrecht)
POST /documents/{id}/versions Neue Fassung
GET /documents/{id}/content Inhalt; ?version_id, ?download
GET /documents/{id}/pages/{n} PNG-Vorschau einer PDF-Seite (1–20, ?version_id)
GET /documents/{id}/events Verlauf
POST /documents/{id}/links Verknüpfung anlegen
DELETE /documents/{id}/links/{link_id} Verknüpfung entfernen
GET /documents/catalog Kategorien und Typen
GET /documents/inbox/summary Zähler für das Menü-Badge
GET / PUT /documents/settings Einstellungen (Schreiben: manage_org_settings)
GET /projects/{id}/documents/tree Ablagebaum mit Zählern
GET /projects/{id}/documents/export?format=csv\|json Export abgelegter Dokumente mit Prüfsummen

Health

GET /health/dependencies enthält die Zeilen pdf_oxide (Paket installiert, zeigt die Version) und document_storage (Verzeichnis beschreibbar und mindestens 1 GiB frei). Sichtbar unter Settings → API.

Backup und Löschen

Das Mandanten-Backup (ZIP) enthält tenants/{tenant_id}/ mit den Dokument-Blobs (ohne previews und *.part). Das ZIP wird in eine temporäre Datei gestreamt und nach dem Download gelöscht, nicht im Speicher gebaut. Das Löschen einer Organisation entfernt das Verzeichnis.

Aufbewahrung abgelehnter Dokumente

Beat-Task linetra.documents.purge_rejected (täglich 03:45) löscht je Mandant abgelehnte Dokumente, deren rejected-Ereignis älter als document_settings.rejected_retention_days ist. Er läuft als Migrations-Owner (POSTGRES_USER) über documents_purge(), weil Versionen und Ereignisse für die App-Rolle unveränderlich sind. Dokumente mit legal_hold werden übersprungen. Blobs werden nur gelöscht, wenn keine andere Version denselben blob_key referenziert.

Schema-CMS-Dateien

entity_files (Bild-/Dokumentfelder) trägt project_id; Zugriff folgt dem Projekt, ohne Projekt nur Uploader und Projektverwalter. Ablage unter tenants/{tenant_id}/entity-files/, MIME per Sniffing, aktive Inhalte werden abgelehnt. Alt-Dateien bleiben an ihrem alten Pfad lesbar; in Phase 3 gehen sie in die Dokumente über.

Datenbankrolle

Die Dokument-Endpunkte nutzen get_documents_db_session (immer Edit-Engine). Die Read-Rollen haben keine Rechte auf den Dokumenttabellen; Gäste (customer) werden in documents.access abgewiesen, alle anderen Rechte prüft require_document_access je Dokument.

Frontend

Header-Menü Dokumente (Badge = offene Eingangszahl), Routen /documents → /documents/inbox → /documents/inbox/upload; Reiter Eingang mit Unterpunkten Upload und Prüfen (/documents/inbox/review), Dokumente (/documents/archive), Suche (/documents/search), Papierkorb (/documents/trash, nur mit documents.delete); Prüfansicht /documents/inbox/review/[id] (Weiter zum nächsten offenen Dokument; /documents/inbox/[id] leitet dorthin um), Detail /documents/[id].

Die Ablage ist der SVAR React File Manager (@svar-ui/react-filemanager, MIT), nur im Browser geladen (next/dynamic, ssr: false):

  • lib/document-explorer.ts bildet den Baum auf Pfad-IDs ab (/50_Genehmigungen, /Bauabschnitte/{Abschnitt}/50_Genehmigungen); / in Labels wird -. Kategorie-Ordner sind lazy, request-data lädt sie über GET /documents?status=filed&category_code=…&section_id|without_section (Seiten à 100).
  • components/document-explorer.tsx fängt move-files, copy-files, delete-files, rename-file, open-file und download-file ab, ruft die API und lädt die betroffenen Ordner per provide-data neu. Bearbeiten (F2) öffnet dieselben Felder wie der Eingang, weil SVARs Namensfeld den berechneten Dateinamen nicht ändern kann. Ändern sich Projekt, Bauabschnitt oder Kategorie, wird einmal nach dem Verschieben gefragt.
  • Kein CDN: Willow fonts={false}, icons="simple", wx-icons liegt unter public/vendor/wx-icons/ (styles/wx-icons.css). Die --wx-*-Variablen sind in styles/document-explorer.css auf die App-Tokens gelegt, damit Hell/Dunkel folgt; Höhe über --document-explorer-height.
  • SVAR bringt keine deutschen Texte für den File Manager mit; lib/i18n/explorer-words.ts liefert sie für en/de (Kernwörter aus @svar-ui/core-locales).

Bekannte Lücken

  • Alt-Dateien in entity-files (vor Migration 0034) liegen weiter im gemeinsamen Verzeichnis außerhalb des Backups, bis sie in Phase 3 in die Dokumente übergehen.
  • Mitarbeiter-QGIS-Logins können Dokumenttabellen lesen (siehe oben).
  • Nicht umgesetzt, nach Phase: KI-Vorschlag (1b); Nextcloud-Spiegel und Connector-Registry (1c); WhatsApp rein/raus und Abruf (2); SharePoint/Drive, Import, Typ-Editor, pg_trgm (3); Beweissicherung/Nachtragsakte (4); GAEB/REB-Auswertung und API-Tokens (5). Siehe Roadmap P14.