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;UploadBodyLimitMiddlewarebegrenzt deshalb vorab den Request-Body auf/api/v1/documents(MAX_UPLOAD_BODY_MB, Standard 2100;Content-Lengthund 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 alsapplication/octet-streamgespeichert (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 mitX-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 untertenants/{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 daherPATCHmit neuemtype_code(undsection_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 neuesdocuments-Dokument an: gleiche Metadaten,source_channel = system,source_ref.copied_from, eine Version mit demselbensha256/blob_key(received_via = copy, keine zweiten Bytes). Fertige Extraktionen werden mitkopiert, sonst reiht die API die Extraktion ein. Ereignissecopied_from(Kopie) undcopied_to(Original). Body optionalsection_id/type_codefür das Ziel. - Papierkorb:
POST /documents/{id}/trashsetztfiled→trashed(Ereignistrashedmit letztem Ordner und Namen;409beilegal_holdoder nicht abgelegtem Dokument).filed_folder/filed_namebleiben stehen, der Unique-Index gilt nur fürfiled.POST /documents/{id}/restorelegt neu ab (Placement läuft erneut, belegter Name → Suffix) und schreibtrestored. 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.deleteplus Projekt-Schreibrecht wählen die Dokumente aus, die Löschung läuft überretention.purge_trashed_documentsals Migrations-Owner (documents_purge, siehe Löschen). Dort wirdstatus = 'trashed'erneut geprüft;legal_holdundretain_untilin der Zukunft überspringen das Dokument. Antwort{purged, skipped}. Der Verlauf verschwindet mit dem Dokument, darum schreibt der Aufruf eine Logzeiletrash emptied(Mandant, Projekt, Nutzer, Anzahl).
2026-09-14_GEN-VRAO_Hauptstraße-km-0-2_AZ-32-1234-2026_v01.pdf
Verarbeitung
- Upload schreibt Blob,
documents- unddocument_versions-Zeile in einer Transaktion. - Nach dem Commit reiht die API
linetra.documents.process_versionauf der Queuedocumentsein. - 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 erhaltenskipped. - Ergebnis in
document_extractions, Statusreceived→review,documents_refresh_searchbaut den Suchvektor. - 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 Statushint, bis die Person Stimmt so setzt (checked). Felder mitparty_role(Rechnung:rechnungssteller/issuer) bleiben inattrs. In Kontakt übertragen legt die Organisation an oder verknüpft eine gleichnamige und setzt dendocument_links-Eintrag; Text nach dem ersten Komma (USt-IdNr.) gehört nicht zum Namen. Die Ablage lädt Ordner mitsort=hints_first(umstellbar) und markiert offene Hinweise im Dateinamen. Der Vorschlag landet insuggestion/confidence; die Metadaten ändern sich erst, wenn die Person speichert oder ablegt. Scans ohne Textlage und die LLM-Klassifikation bleiben P14b. - Beat-Task
linetra.documents.sweeplä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) unddocuments.confidential(Standard Org-Admin; Verträge, Nachträge, Rechnungen). Vertrauliche Typen sehen sonst nur Projekt-Owner und -Bearbeiter. Rollecustomerhat in den Phasen 1–3 keinen Zugriff. - Org-Permission
documents.delete(Control-Migration0049, Standard Org-Admin, Systemadmins immer): Papierkorb ansehen (GET /documents?status=trashed), hineinlegen und wiederherstellen; zusätzlich Schreibzugriff auf das Dokument. Ohnestatus-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/DELETEaufdocument_versions,document_extractionsunddocument_eventssind entzogen. - Löschen nur über
documents_purge(document_id)(SECURITY DEFINER); verweigert beilegal_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.tsbildet den Baum auf Pfad-IDs ab (/50_Genehmigungen,/Bauabschnitte/{Abschnitt}/50_Genehmigungen);/in Labels wird-. Kategorie-Ordner sindlazy,request-datalädt sie überGET /documents?status=filed&category_code=…§ion_id|without_section(Seiten à 100).components/document-explorer.tsxfängtmove-files,copy-files,delete-files,rename-file,open-fileunddownload-fileab, ruft die API und lädt die betroffenen Ordner perprovide-dataneu. 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-iconsliegt unterpublic/vendor/wx-icons/(styles/wx-icons.css). Die--wx-*-Variablen sind instyles/document-explorer.cssauf 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.tsliefert 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.