Zum Inhalt springen

GAEB-Austausch, LV-Verwaltung, Kalkulation und Preisspiegel

Beta — in Erprobung

Feinkonzept — GAEB / Leistungsverzeichnis / Angebotswesen

Abschnitt betitelt „Feinkonzept — GAEB / Leistungsverzeichnis / Angebotswesen“

Modul: Handwerk · Referenz: FUNKTIONSUMFANG.md §4.3 Cross-Links: 04-mobile-aufmass.md (Aufmaß referenziert OZ aus LV) · 05-bau-abrechnung.md (Schlussrechnung aus LV + Aufmaß) · FUNKTIONSUMFANG.md §3.5 (gemeinsame Rechnungs-Engine) Status: Feinkonzept v0.1 · 2026-04-19


feature_id: handwerk/03-gaeb-lv
title: GAEB-Austausch, LV-Verwaltung, Kalkulation und Preisspiegel
funktionsumfang_ref: §4.3
roadmap_horizont: V1
plattformen:
mobile: nur-Ansicht # Kalkulator arbeitet am Bildschirm, nicht in Handschuhen
web: vollständig
desktop: ab V1.5 bei Bedarf # Bau-Kalkulator mit Zweit-Monitor
owner_rolle: Kalkulator
modul_gate_flag: module.handwerk.gaeb
compliance_flags:
gobd: true # LV ist Grundlage für Abrechnung, Unveränderbarkeit Pflicht
arbzg: false
vob: true # VOB/A §7 (eindeutige Leistungsbeschreibung), VOB/B §2, §14
dsgvo: true # Kontaktdaten Bieter / Auftraggeber
bfsg: false # reiner Admin-Flow, kein ESS
betrvg: false
stvg: false
weitere: [GAEB DA XML 3.3, StLB-Bau, DIN 276, KoSIT-Schematron (nur indirekt über X89→Rechnung)]
referenzkunde:
status: TBD
name: "zu klären mit Sales — Kandidat: shk-gebruder-schmidt (80-MA-SHK, NRW, öffentliche AG)"
quelle: Sales-Call 2026-03-12, Nachfrage nach RIB-iTWO-Import
estimate_eng_tage: 68 # Parser 14 · LV-UI 14 · Kalkulation 12 · Preisspiegel 8 · Validator 8 · Tests/Migrationen 12
abhängigkeiten:
- kern/11-rollen-rls # Kalkulator-Rolle + RLS-Policies
- kern/12-audit-log # Hash-Chain für LV-Versionen
- handwerk/04-mobile-aufmass # DA11/DA12 → OZ-Rückkopplung
- handwerk/05-bau-abrechnung # X84/X89 → XRechnung

GAEB (Gemeinsamer Ausschuss Elektronik im Bauwesen) ist seit 1985 der de-facto-Standard für die elektronische Leistungsbeschreibung im deutschen Bauwesen. Die aktuelle Austausch-Norm ist GAEB DA XML Version 3.3 (ratifiziert 2019, Errata 2022). Ältere ASCII-Phasen (DA81, DA82 …, fixed-width EBCDIC-Heritage) werden parallel noch erzeugt, weil viele Ausschreibungs-Plattformen öffentlicher AG (subreport ELViS, Deutsche eVergabe, Vergabemarktplatz Hessen, TED-EU) sie verlangen. Werkszeit muss beide Welten lesen und schreiben — sonst kann ein 40-MA-Bauunternehmen in NRW einfach nicht an öffentlichen Ausschreibungen teilnehmen.

Ohne GAEB-Fähigkeit passiert heute in Handwerksbetrieben: Der Kalkulator bekommt per E-Mail eine .d83 oder .X83-Datei, öffnet sie in RIB iTWO / Nevaris / California / STREIT, kalkuliert dort, exportiert DA84 zurück. Für jedes dieser Tools ist eine eigene Lizenz (4-stellig jährlich) plus Einführung (5-stellig) fällig. Ein 25-MA-SHK-Betrieb kann sich das nicht leisten — und kann daher nicht an Ausschreibungen mit LV-Pflicht teilnehmen. Werkszeit schließt diese Lücke ohne den Anspruch, RIB iTWO in vollem Umfang zu ersetzen: Wir lesen, kalkulieren, schreiben zurück. Die 20-%-Spezialfälle (ORCA AVA, DBD-BIM-Verknüpfung, IFC-Round-Trip) bleiben bewusst außen vor.

  • VOB/A § 7 verlangt eindeutige und erschöpfende Leistungsbeschreibung — praktisch umgesetzt über StLB-Bau (Standardleistungsbuch Bau, gepflegt vom Gemeinsamen Ausschuss Elektronik im Bauwesen). Abweichungen davon sind begründungspflichtig.
  • VOB/B § 2 regelt Änderungen und Nachträge; jeder Nachtrag muss zwingend auf eine OZ (Ordnungszahl) des Ur-LV zeigen.
  • VOB/B § 14 Abs. 1 verlangt eine prüfbare Schlussrechnung — sie ist nur prüfbar, wenn Aufmaß und Abrechnung strikt der OZ-Struktur des beauftragten LV folgen.
  • GoBD: Die LV-Version, auf deren Basis Auftrag erteilt wurde, ist unveränderlich zu archivieren (10 Jahre nach §147 AO). Jede Nachtrags-Version ist eine eigene unveränderliche Version, nicht ein Edit der Vorversion.

In zeitapp existierte routes/quotes.js — ein freier Angebots-Editor ohne jegliche OZ-/Titel-Hierarchie und ohne GAEB-Bindung. Die LESSONS-LEARNED §5 / §11 hält das explizit fest: „Angebote werden nicht neben dem LV gebaut, sondern direkt auf die GAEB-LV-Struktur aufgesetzt.“ Die Alt-App produzierte damit doppelte Stammdaten (Angebot-Position ≠ LV-Position ≠ Rechnungs-Position), ließ Interop-Tests gegen RIB iTWO scheitern, und jeder Nachtrag musste händisch gegen das LV verglichen werden.

Werkszeit macht das explizit anders:

  1. LV ist die einzige Quelle der Wahrheit für Angebot, Auftrag, Nachtrag, Aufmaß und Rechnung.
  2. Angebots-Varianten (Haupt-/Neben-/Alternativ-Angebot) sind Varianten derselben LV-Struktur, kein paralleler Zweig.
  3. Jede OZ ist gegen StLB-Bau validiert; wenn nicht StLB-konform, erzwingt der Editor eine Freitext-Begründung.
  4. LV-Änderungen nach Auftragserteilung sind append-only — neue Nachtrags-Version mit eigener OZ-Erweiterung (z.B. 01.02.030.N1).
Metrik Ist (ohne Werkszeit) Ziel mit Werkszeit
Zeit bis abgegebenes Angebot ab DA83-Eingang 3–5 Arbeitstage (Excel-Kalkulation, manuelles DA84-Schreiben) ≤ 4 h für 300-Positionen-LV
Interop-Fehler beim Re-Import durch AG ≈ 20 % (Schema-Verstöße, OZ-Lücken, fehlende KatalogNr) < 2 % (strikter Export + Schematron vorab)
Doppelte Stammdaten Angebot ↔ LV ↔ Rechnung ~100 % (zwei Quellen) 0 % (eine Quelle)
Nachtrag mit korrektem OZ-Bezug „meistens“ 100 % (Editor erzwingt)

Rolle Aktion Scope Plattform
Kalkulator Import DA83, Kalkulation, Export DA84, Angebots-Varianten team 🌐 (Desktop ab V1.5)
Bauleitung LV einsehen, OZ-Bezug für Nachtrag wählen, Aufmaß-Freigabe gegen OZ team 📱🌐
Admin StLB-Bau-Aktualisierung, Import-Validator-Regeln konfigurieren, Preisspiegel-Zugriff all 🌐
Buchhaltung LV als Grundlage für X86/X89-Rechnung, Kumulations-Prüfung all 🌐
Monteur Positionsmenge vor Ort sehen (read-only), Aufmaß erfassen (siehe §4.4) own/team 📱
Subunternehmer (extern) Preisspiegel nicht einsehbar (anonymisiert); sieht nur die eigene Angebots-Variante via Link own 🌐

Persona-Skizzen:

  • Thomas K. (Kalkulator, 52, Bauunternehmen 80 MA, NRW) — arbeitet 80 % am Zweit-Monitor, kennt RIB iTWO, hasst „web-Apps, die bei 2.000 Positionen lahmen“. Will Keyboard-Shortcuts für Positions-Kopie, Spalten-Fix, Massen-Preis-Update.
  • Sabine M. (Bauleiterin, 45, Hochbau) — muss auf der Baustelle Nachtrag gegen LV gegenhalten. Tablet im Auto, Kontext wechselt zwischen 3 Baustellen.
  • Frau Keller (Einkauf, 48, SHK) — vergleicht 4 Sub-Angebote manuell in Excel, braucht Preisspiegel mit Bieter-Anonymisierung für das interne Vergabe-Gremium.

US-01 [V1] Als Kalkulator möchte ich eine per E-Mail empfangene GAEB-X83-Datei
importieren, damit ich nicht alle 320 Positionen aus einer PDF-
Ausschreibung abtippen muss.
US-02 [V1] Als Kalkulator möchte ich Materialkosten aus meiner Artikel-Stammdaten-
Tabelle auf eine Position anwenden, mit Aufschlag auf BGK/AGK/W&G,
damit der EP rechnerisch nachvollziehbar und als DA84 exportierbar ist.
US-03 [V1] Als Kalkulator möchte ich drei Angebots-Varianten (Haupt/Neben/Alternativ)
derselben LV-Struktur kalkulieren und als drei X83 exportieren, damit
der AG zwischen Varianten wählen kann.
US-04 [V1] Als Einkauf möchte ich einen Preisspiegel über 4 Bieter sehen, sortiert
nach Gesamtsumme und je OZ-Titel, damit das Vergabe-Gremium eine
begründete Entscheidung treffen kann.
US-05 [V1] Als Bauleitung möchte ich in der Mobile-App die Positions-Details einer
OZ einsehen (Kurztext, Langtext, Menge, EP), damit ich auf der Baustelle
mit dem Bauherrn klären kann, ob der Streitpunkt im LV enthalten ist.
US-06 [V1] Als Admin möchte ich den GAEB-Import-Validator konfigurieren (Schema-
Strenge, OZ-Lücken-Toleranz, StLB-Bau-Pflichtcheck), damit wir für
verschiedene AG unterschiedliche Import-Strengen fahren können.
US-07 [V1.5] Als Kalkulator möchte ich die Kalkulations-Bestandteile (ME/LE/GK/SO)
als Vorlage auf ähnliche OZ-Positionen massenweise übertragen können,
damit ich in einem 800-Positionen-LV nicht 800-mal dieselbe Struktur
eintippen muss.
US-08 [V1.5] Als Buchhaltung möchte ich aus dem beauftragten LV automatisch eine
X86-Rechnungs-Skizze erzeugen, mit Aufmaß-Mengen aus §4.4, damit die
Schlussrechnung die Prüffähigkeit nach VOB/B §14 Abs. 1 erfüllt.

  • F-M-01 — LV-Ansicht read-only. Baum Los → Titel → Position → Unterposition mit virtualisiertem Scroll (Lazy-Load pro Titel, Positionen erst beim Expandieren). Limit 10.000 Positionen.
  • F-M-02 — OZ-Suche. Suche nach OZ-Präfix (01.02.*) oder Freitext im Kurztext. Offline-fähig, wenn LV zuvor im Cache.
  • F-M-03 — Positions-Detail. OZ, Kurztext, Langtext (HTML-gerendert), Menge, ME, EP, GP, Status (beauftragt/frei), StLB-Katalog-Nummer. Optional: Foto-Referenz aus Bautagebuch zur selben OZ.
  • F-M-04 — Nachtrags-OZ wählen. Aus LV-Baum eine OZ als Parent für einen Nachtrag wählen — erzeugt nicht selbst den Nachtrag (siehe §4.2 Nachtrag), nur den OZ-Bezug.
  • F-M-05 — Offline-Cache. Zuletzt geöffnetes LV wird vollständig in SQLite vorgehalten (komprimiert, Bulk-Insert-Check-summed). Kein Mobile-Edit des LV — Schreiboperationen sind Web-exklusiv.
  • F-W-01 — GAEB-Import. Datei-Upload (.d81–.d89, .x81–.x89, .xml, .zip mit mehreren Dateien). Max. Upload 50 MB. MIME-Check + Magic-Byte + Schema-Validierung.
  • F-W-02 — LV-Editor. Split-Pane: links Baum mit Drag-&-Drop innerhalb einer Ebene; rechts Detail-Form (Kurztext, Langtext WYSIWYG, Menge, ME, Positions-Art: Standard / Eventual / Zuschlag / Grundposition / Alternativ). Keyboard-Shortcuts (Alt+↑/↓ Position verschieben, Ctrl+D duplizieren, Ctrl+K Kalkulation öffnen).
  • F-W-03 — OZ-Auto-Vergabe. Beim Anlegen einer Position erhält sie automatisch die nächste OZ innerhalb des Parent-Titels, gem. konfigurierter Gliederungsregel (NN.NN.NN default / 4-stufig optional).
  • F-W-04 — StLB-Bau-Lookup. Bei Freitext-Kurztext zeigt Auto-Complete StLB-Katalog-Treffer (Version 2024.1, jährliche Updates über Admin-Upload); Übernahme-Klick füllt StLB-Nr, Standardkurztext, Standardlangtext.
  • F-W-05 — Kalkulations-Overlay. Pro Position: Zeilen Materialeinzelkosten (ME), Lohneinzelkosten (LE) (Zeit × Stundensatz), Gerätekosten (GK), Sonstige (SO). Zuschläge als %-Aufschlag: BGK (Baustellengemeinkosten), AGK (Allgemeine Geschäftskosten), W&G (Wagnis & Gewinn). Resultat: EP = ∑ME + ∑LE + ∑GK + ∑SO, dann × (1+BGK) × (1+AGK) × (1+W&G). Rundung konfigurierbar (ROUND_HALF_EVEN Pflicht für DATEV-Kompatibilität).
  • F-W-06 — Angebots-Varianten. Haupt-, Neben-, Alternativ-Angebot als Fork auf Positionsebene (Branch-Pointer), ohne Datenduplizierung für unveränderte Positionen (Copy-on-Write).
  • F-W-07 — Export. Auswahl DA81 | X81 | DA82 | X82 | DA83 | X83 | DA84 | X84 | DA85 | X85 | DA86 | X86 | DA89 | X89. Vor Export läuft der interne Schema-Check; rot → Export gesperrt, Liste der Fehler mit OZ-Tiefenbezug.
  • F-W-08 — Preisspiegel. Tabelle: Zeilen = OZ/Titel, Spalten = Bieter (anonymisierbar: „Bieter A/B/C“). Zeilen-Summen, Spalten-Summen, Ranking je OZ, farbige Delta-Markierung (>10 % vom Median = orange, >25 % = rot). Export CSV + PDF.
  • F-W-09 — Versions-Migration. Import einer GAEB-DA-ASCII-Datei erzeugt intern eine DA-XML-3.3-Repräsentation; Export kann beides. Migrations-Protokoll je Datei zeigt Regel-Differenzen (Vergabeart 1-stellig → 2-stellig).
  • F-X-01 — LV-Versions-Badge. Jede Positions-Anzeige (Mobile + Web + PDF) zeigt v3 · beauftragt 2026-03-12 aus der Version-Historie.
  • F-X-02 — OZ-Referenz-Resolver. Gemeinsame Logik oz.parse('01.02.030.a') / oz.format({los:1,titel:2,pos:30,unterpos:'a'}) — in Dart und TypeScript bit-identisch (shared packages/oz-core/, Port pro Sprache, Property-Tests auf beiden Seiten).
  • F-A-01 — StLB-Bau-Katalog hochladen. Admin lädt StLB-Bau-Katalog-ZIP (offizielle GAEB-Distribution) hoch; Parser indexiert Positionen, macht sie im Lookup-Search verfügbar.
  • F-A-02 — Gliederungsregel. Default OZ-Format (NN.NN.NN / NN.NN.NN.N / NN.NN.NNN.N) pro Tenant. Nach Anlegen eines LV nicht mehr änderbar (OZ-Migration zu schmerzhaft).
  • F-A-03 — Zuschlagssätze Default. BGK/AGK/W&G-Prozentsätze pro Tenant als Default, in Kalkulation editierbar. Historisiert (Stichtag).
  • F-A-04 — Import-Validator-Strenge. Pro Tenant: strict (Schema 1:1, OZ-Lückenfreiheit, StLB-Pflicht) / balanced (Schema-Validation, OZ-Lücken Warnung, StLB optional) / loose (nur Parse-Erfolg). Default balanced.
  • F-A-05 — Export-Profile. Vorlage pro AG (z.B. „Stadt München“, „DB Netz“) — legt Export-Format, Dateiname-Schema, Zeichensatz (UTF-8 / ISO-8859-15 / Windows-1252 für Altsysteme), Gewerke-Codierung fest.
ID MVP V1 V1.5 V2
F-M-01 … F-M-05
F-W-01 bis F-W-07
F-W-08 Preisspiegel
F-W-09 Migration DA↔X
F-X-01 LV-Versions-Badge
F-X-02 OZ-Resolver
F-A-01 StLB-Upload
F-A-02 Gliederungsregel
F-A-03 Zuschlagssätze
F-A-04 Validator-Strenge
F-A-05 Export-Profile
Massenkopie von Kalk. (US-07)
IFC-Verknüpfung BIM ❌ (explizit Nicht-Ziel, §14)

HTML-Hero-Mockup: 03-gaeb-lv.html — Web dominant, Mobile als schmaler Nebendarsteller (Kalkulator-Workbench ist Web-Arbeit).

┌────────────────────────────────────────────────────────────────────────────────┐
│ Werkszeit · shk-gebruder-schmidt Thomas K. · Kalkulator [Profil ▾] │
├────────────────────────────────────────────────────────────────────────────────┤
│ Sidebar │ LV · Sanierung Schule A · 320 Positionen · X83 v3 🟡 kalkuliert │
│ ───────── │ [⤒ Import DA83] [⤓ Export DA84] [🔍 Schema-Check] [+ Variante] │
│ Zeit │ ┌──────────────────────────────┬─────────────────────────────────┐ │
│ Plan │ │ OZ-Baum 🔍 Suche │ Position 01.02.030 │ │
│ Doku │ │ ▾ 01 Rohbau │ ─────────────────────────────── │ │
│ ▶ LV │ │ ▾ 02 Putzarbeiten │ Kurztext: │ │
│ Aufmaß │ │ ○ 01.02.010 Putz aufbr. │ Putz-Innenwand abreißen │ │
│ Nachtr. │ │ ● 01.02.030 Putz innen │ 🔴 ME: m² Menge: 128,50 │ │
│ Rechn. │ │ ○ 01.02.040 Putz Decke │ ────────────────────────────── │ │
│ Stamm │ │ ▸ 03 Maurerarbeiten │ EP 12,40 € GP 1.593,40 € │ │
│ │ │ ▸ 02 Ausbau │ ────────────────────────────── │ │
│ │ │ ▸ 03 Technische Anlagen │ Kalkulation [Ctrl+K] │ │
│ │ │ ┌──────────────────────────┐ │ ME Material 1,80 € │ │
│ │ │ │ Lücken: 01.02.020 fehlt │ │ LE Lohn 7,20 € (0,3h×24) │ │
│ │ │ │ StLB: 3 OZ ohne Kat-Nr │ │ GK Gerät 0,40 € │ │
│ │ │ └──────────────────────────┘ │ SO Sonstiges 0,00 € │ │
│ │ │ │ ──────────────────── │ │
│ │ │ │ Σ 9,40 € │ │
│ │ │ │ +BGK 8% → 10,15 € │ │
│ │ │ │ +AGK 12% → 11,37 € │ │
│ │ │ │ +W&G 9% → 12,39 € ≈ 12,40 €│ │
│ │ └──────────────────────────────┴─────────────────────────────────┘ │
│ │ [🟢 Version 3 gespeichert · 10:42 · Thomas K.] │
└────────────────────────────────────────────────────────────────────────────────┘

6.2 Import-Wizard Web (Schema-Check fehlgeschlagen)

Abschnitt betitelt „6.2 Import-Wizard Web (Schema-Check fehlgeschlagen)“
┌────────────────────────────────────────────────────────────────────────────────┐
│ GAEB-Import · schule-a-sanierung-X83.x83 [Abbrechen]│
├────────────────────────────────────────────────────────────────────────────────┤
│ ① Upload ─────── ② Format erkannt ─────── ③ Validierung ────── ④ Übernehmen │
│ ▲ │
│ │ │
│ Format: GAEB DA XML 3.3 · Phase 83 (Angebot) · 320 Positionen · 127 KB │
│ │
│ 🔴 5 Fehler · 🟡 12 Warnungen · 🟢 303 Positionen OK │
│ ┌──────────────────────────────────────────────────────────────────────────┐ │
│ │ 🔴 ERR-001 01.02.020 OZ-Lücke: zwischen 010 und 030 │ │
│ │ 🔴 ERR-002 01.02.030 Kurztext > 70 Zeichen (GAEB-Regel) │ │
│ │ 🔴 ERR-003 01.03 Titel ohne Positionen │ │
│ │ 🔴 ERR-004 02.01.010 ME "qm" ungültig (→ erwartet "m2"/"m²") │ │
│ │ 🔴 ERR-005 03.02.040.a Unterposition vor Hauptposition 040 │ │
│ │ 🟡 WARN 15 Positionen StLB-Kat-Nr fehlt (Profil: balanced) │ │
│ └──────────────────────────────────────────────────────────────────────────┘ │
│ │
│ [⤒ Datei korrigieren] [⚙ Toleranter Import (Profil: loose)] [▶ Weiter »] │
└────────────────────────────────────────────────────────────────────────────────┘
┌────────────────────────────────────────────────────────────────────────────────┐
│ Preisspiegel · Schule A · 4 Bieter · Anonymisierung 🟢 an │
├────────────────────────────────────────────────────────────────────────────────┤
│ OZ / Titel │ Bieter A │ Bieter B │ Bieter C │ Bieter D │ Δ │
│──────────────────────────┼───────────┼───────────┼───────────┼───────────┼───│
│ 01 Rohbau │ 48.210,- │ 45.980,-🏆│ 51.300,- │ 47.200,- │11%│
│ 01.01 Maurerarbeiten │ 12.450,- │ 11.800,-🏆│ 13.200,- │ 12.100,- │12%│
│ 01.02 Putzarbeiten │ 21.300,- │ 20.500,-🏆│ 23.100,- │ 21.900,- │13%│
│ 01.02.030 Putz innen │ 1.593,40 │ 1.501,70🏆│ 1.720,00 │ 1.620,00 │15%│
│ 02 Ausbau │ 82.100,-🏆│ 84.300,- │ 88.400,- │ 85.500,- │ 8%│
│ 03 Tech. Anlagen │142.800,- │138.500,-🏆│145.200,- │140.100,- │ 5%│
│──────────────────────────┼───────────┼───────────┼───────────┼───────────┼───│
│ SUMME NETTO │273.110,-🥈│268.780,-🏆│284.900,- │272.800,-🥉│ 6%│
│ │
│ [⤓ CSV] [⤓ PDF-Vergabeempfehlung] [Namen anzeigen (Audit-Log!)] │
└────────────────────────────────────────────────────────────────────────────────┘
┌────────────────────────────────┐
│ ← Schule A · LV v3 🔍 ⋯ │
├────────────────────────────────┤
│ 01.02.030 │
│ Putz-Innenwand abreißen │
│ ────────────────────────────── │
│ Menge: 128,50 m² │
│ EP: 12,40 € │
│ GP: 1.593,40 € │
│ Status: 🟢 beauftragt │
│ StLB: 015·12·030 │
│ ────────────────────────────── │
│ Langtext: │
│ Bestehenden Kalk-Zement-Putz, │
│ Dicke bis 20 mm, von massivem │
│ Mauerwerk sorgfältig abschla- │
│ gen. Untergrund besenrein. │
│ Schutt auf Mulde. │
│ ────────────────────────────── │
│ [📸 Foto-Ref (Bautagebuch)] │
│ [✚ Nachtrag zu dieser OZ] │
│ [📐 Aufmaß zu dieser OZ] │
├────────────────────────────────┤
│ [Zeit] [Plan] [Doku] [Mehr] │
└────────────────────────────────┘
┌────────────────────────────────────────────────────────────────┐
│ ⚠ Export DA84 gesperrt │
├────────────────────────────────────────────────────────────────┤
│ Der Schema-Check des Export-Profils "Strict (VOB/A §7)" fand │
│ 2 blockierende Fehler: │
│ │
│ 🔴 01.02.030 Kalkulation unvollständig (LE leer) │
│ 🔴 03.04.010.a Unterposition referenziert fehlende Hauptpos. │
│ │
│ Warnungen (nicht-blockierend): │
│ 🟡 12 Positionen: StLB-Kat-Nr fehlt │
│ │
│ [Zur Fehlerliste] [Profil wechseln] [Abbrechen] │
└────────────────────────────────────────────────────────────────┘
┌────────────────────────────────────────────────────────────────┐
│ LV-Versionen · Schule A │
├────────────────────────────────────────────────────────────────┤
│ v4 🟡 Entwurf 2026-04-19 10:42 Thomas K. │
│ Preiskalkulation Pos. 01.02.030 angepasst (+2,40 €) │
│ Hash: 4a8b2d…e9f1c3 Prev: 2c9e1a…8f3d │
│ ──────────────────────────────────────────────────────────── │
│ v3 🟢 Beauftragt 2026-03-12 14:18 Sabine M. │
│ Auftrag X84 erhalten von Bauherr Gebr. Schmidt │
│ Hash: 2c9e1a…8f3d Prev: a1b2c3…4d5e 🔒 unveränderlich │
│ ──────────────────────────────────────────────────────────── │
│ v2 🟢 Angebot X83 2026-02-28 09:04 Thomas K. │
│ Neben-Angebot mit 8% Nachlass │
│ Hash: a1b2c3…4d5e 🔒 │
│ ──────────────────────────────────────────────────────────── │
│ v1 🟢 Import X83 2026-02-15 11:00 (System) │
│ Importiert aus schule-a-X83.x83 (127 KB) · KoSIT-grün │
└────────────────────────────────────────────────────────────────┘

Symbol-Konvention gem. TEMPLATE §6: 🔴 Pflichtfeld/Error, 🟡 Warnung, 🟢 OK, 🏆 günstigster Bieter, 🥈/🥉 Rang, 🔒 unveränderlich (GoBD).


Pseudo-Drizzle-Schema (Postgres 17, alle Tabellen mit tenant_id + RLS).

apps/api/src/db/schema/gaeb.ts
import { pgTable, uuid, text, integer, numeric, timestamp, bytea, jsonb, index, uniqueIndex } from 'drizzle-orm/pg-core';
export const lvTable = pgTable('lv', {
id: uuid('id').primaryKey().defaultRandom(), // UUID v7 für Idempotenz
tenantId: uuid('tenant_id').notNull().references(() => tenantsTable.id),
projectId: uuid('project_id').notNull().references(() => projectsTable.id),
bezeichnung: text('bezeichnung').notNull(), // "Schule A Sanierung"
phase: text('phase').notNull(), // 'anfrage'|'angebot'|'auftrag'|'abrechnung'
waehrung: text('waehrung').notNull().default('EUR'),
ustSatz: numeric('ust_satz', { precision: 4, scale: 2 }), // 19.00 / 7.00
gliederung: text('gliederung').notNull().default('NN.NN.NN'), // OZ-Format, immutable
currentVersion: integer('current_version').notNull().default(1),
status: text('status').notNull(), // 'entwurf'|'angebot'|'beauftragt'|'abgerechnet'|'archiviert'
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
createdBy: uuid('created_by').notNull().references(() => usersTable.id),
}, (t) => ({
tenantProjectIdx: index('lv_tenant_project_idx').on(t.tenantId, t.projectId),
}));
// Versionierung: jede Mutation erzeugt eine neue Zeile, Vorgänger bleibt unverändert.
// Hash-Chain sichert GoBD-Unveränderlichkeit ab.
export const lvVersionTable = pgTable('lv_version', {
id: uuid('id').primaryKey().defaultRandom(),
tenantId: uuid('tenant_id').notNull(),
lvId: uuid('lv_id').notNull().references(() => lvTable.id),
version: integer('version').notNull(), // monoton steigend pro lv_id
phase: text('phase').notNull(), // 'anfrage'|...
quellDatei: text('quell_datei'), // z.B. "X83"
quellHash: bytea('quell_hash'), // SHA-256 der Ursprungsdatei
summeNetto: numeric('summe_netto', { precision: 14, scale: 2 }),
notiz: text('notiz'), // Begründung Pflicht bei v>1
hashPrev: bytea('hash_prev'), // Hash-Chain-Vorgänger
hashSelf: bytea('hash_self').notNull(), // SHA-256 (canonicalJSON)
immutableAt: timestamp('immutable_at', { withTimezone: true }), // gesetzt bei 'beauftragt'
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
createdBy: uuid('created_by').notNull(),
}, (t) => ({
uniqueVersion: uniqueIndex('lv_version_unique').on(t.lvId, t.version),
tenantIdx: index('lv_version_tenant_idx').on(t.tenantId, t.createdAt.desc()),
}));
// OZ-Eintrag — Titel, Position, Unterposition als einheitlicher Knoten mit `knotenTyp`.
export const lvKnotenTable = pgTable('lv_knoten', {
id: uuid('id').primaryKey().defaultRandom(),
tenantId: uuid('tenant_id').notNull(),
lvVersionId: uuid('lv_version_id').notNull().references(() => lvVersionTable.id),
parentKnotenId: uuid('parent_knoten_id').references((): any => lvKnotenTable.id),
oz: text('oz').notNull(), // '01' | '01.02' | '01.02.030' | '01.02.030.a'
ozSort: text('oz_sort').notNull(), // Null-gepolstert für Ordering: '01.02.030.0000a'
knotenTyp: text('knoten_typ').notNull(), // 'los'|'titel'|'position'|'unterposition'
positionsArt: text('positionsart'), // 'standard'|'eventual'|'zuschlag'|'grund'|'alternativ'
kurztext: text('kurztext'), // max 70 Zeichen (GAEB-Limit)
langtext: text('langtext'), // HTML erlaubt (sanitized)
stlbKatalog: text('stlb_katalog'), // '2024.1'
stlbNr: text('stlb_nr'), // '015.12.030'
mengeneinheit: text('mengeneinheit'), // 'm2', 'm', 'Stk', 'h', 'psch'
menge: numeric('menge', { precision: 14, scale: 4 }),
ep: numeric('ep', { precision: 12, scale: 4 }), // Einheitspreis (brutto ohne USt)
gp: numeric('gp', { precision: 14, scale: 2 }), // Generated column: ROUND(menge*ep, 2, HALF_EVEN)
kalkulation: jsonb('kalkulation'), // { me, le, gk, so, bgk, agk, wug, rundung }
gaebRaw: jsonb('gaeb_raw'), // Original-XML-Fragment für Round-Trip-Treue
}, (t) => ({
lvSortIdx: index('lv_knoten_sort_idx').on(t.lvVersionId, t.ozSort),
parentIdx: index('lv_knoten_parent_idx').on(t.parentKnotenId),
uniqueOzProVer: uniqueIndex('lv_knoten_oz_unique').on(t.lvVersionId, t.oz),
}));
// Angebots-Varianten — Branch-Pointer auf lv_version, mit Overrides pro Knoten.
export const lvVarianteTable = pgTable('lv_variante', {
id: uuid('id').primaryKey().defaultRandom(),
tenantId: uuid('tenant_id').notNull(),
lvVersionId: uuid('lv_version_id').notNull(),
name: text('name').notNull(), // 'Haupt' | 'Neben-1' | 'Alt-Zemenputz'
varianteTyp: text('variante_typ').notNull(), // 'haupt'|'neben'|'alternativ'
nachlassProz: numeric('nachlass_proz', { precision: 5, scale: 2 }),
overrides: jsonb('overrides'), // [{knotenId, ep, menge, kurztext}]
});
// Bieter-Angebot (für Preisspiegel)
export const bieterAngebotTable = pgTable('bieter_angebot', {
id: uuid('id').primaryKey().defaultRandom(),
tenantId: uuid('tenant_id').notNull(),
lvVersionId: uuid('lv_version_id').notNull(),
bieterName: text('bieter_name').notNull(), // intern
anonymisiertAls: text('anonymisiert_als'), // 'Bieter A'
quellDatei: text('quell_datei'), // 'X83'
positionen: jsonb('positionen'), // [{oz, ep, gp}]
summeNetto: numeric('summe_netto', { precision: 14, scale: 2 }),
empfangenAm: timestamp('empfangen_am', { withTimezone: true }),
});
// StLB-Bau-Katalog als Admin-Upload (pro Tenant opt-in, offizieller Katalog von GAEB)
export const stlbKatalogTable = pgTable('stlb_katalog', {
id: uuid('id').primaryKey().defaultRandom(),
tenantId: uuid('tenant_id').notNull(),
version: text('version').notNull(), // '2024.1'
aktiv: boolean('aktiv').notNull().default(true),
katalogData: jsonb('katalog_data'), // parsed index
});
ALTER TABLE lv ENABLE ROW LEVEL SECURITY;
ALTER TABLE lv_version ENABLE ROW LEVEL SECURITY;
ALTER TABLE lv_knoten ENABLE ROW LEVEL SECURITY;
ALTER TABLE lv_variante ENABLE ROW LEVEL SECURITY;
ALTER TABLE bieter_angebot ENABLE ROW LEVEL SECURITY;
CREATE POLICY lv_tenant_isolation ON lv
USING (tenant_id = current_setting('app.tenant_id')::uuid);
CREATE POLICY lv_scope ON lv
FOR SELECT
USING (
tenant_id = current_setting('app.tenant_id')::uuid
AND (
current_setting('app.scope') = 'all'
OR (current_setting('app.scope') IN ('team', 'own')
AND project_id IN (SELECT project_id FROM project_member WHERE user_id = current_setting('app.user_id')::uuid))
)
);
-- Schreibsperre für bereits-beauftragte LV-Versionen (GoBD-Append-Only)
CREATE POLICY lv_version_no_update_after_immutable ON lv_version
FOR UPDATE
USING (immutable_at IS NULL);
-- Preisspiegel-Sicht: Einkauf-Rolle darf Bieter-Namen lesen; Bauleitung nur anonymisiert.
CREATE POLICY bieter_namen_restricted ON bieter_angebot
FOR SELECT
USING (
tenant_id = current_setting('app.tenant_id')::uuid
AND (
current_setting('app.role') = 'einkauf'
OR bieter_name IS NULL -- anonymisierte Kopie
)
);
  • lv.project_idprojects.id (Baustelle)
  • lv_knoten wird von aufmass_zeile (§4.4) über oz + lv_version_id referenziert — Fremdschlüssel fachlich, nicht physisch (Versions-Pivot).
  • lv_version wird von rechnung.lv_version_id referenziert (§4.5) — Schlussrechnung zeigt hart auf die beauftragte Version.
  • lv_version.hashSelf Hash-Chain ist Teil der tenant-globalen Audit-Hash-Chain (gem. kern/12-audit-log).

Pfad-Konvention: /v1/handwerk/gaeb/…, Gate module.handwerk.gaeb.

Methode Pfad Auth-Scope Rate-Limit Idempotenz Beschreibung
POST /v1/handwerk/gaeb/lv gaeb:write Standard Idem-Key Pflicht LV anlegen (leer oder aus Template)
GET /v1/handwerk/gaeb/lv gaeb:read:<scope> Standard Liste (Filter Projekt/Phase/Status)
GET /v1/handwerk/gaeb/lv/{id} gaeb:read:<scope> Standard Detail inkl. aktuelle Version
POST /v1/handwerk/gaeb/lv/{id}/versions gaeb:write Standard Idem-Key Neue Version (Entwurf, basierend auf Vorgänger)
POST /v1/handwerk/gaeb/lv/{id}/versions/{v}/commit gaeb:write Privileged Idem-Key Version einfrieren (immutable_at=NOW())
GET /v1/handwerk/gaeb/lv/{id}/versions/{v}/knoten gaeb:read:<scope> Standard Baum (paginated pro Titel)
PATCH /v1/handwerk/gaeb/lv/{id}/versions/{v}/knoten/{knotenId} gaeb:write Standard Idem-Key OZ-Knoten ändern (nur wenn nicht committed)
POST /v1/handwerk/gaeb/import gaeb:import Privileged Idem-Key Datei-Upload DA/X81–89
POST /v1/handwerk/gaeb/export gaeb:export Privileged Idem-Key Export-Job (Phase, Profil)
GET /v1/handwerk/gaeb/export/jobs/{jobId} gaeb:export Standard Job-Status / Download-Link
POST /v1/handwerk/gaeb/lv/{id}/preisspiegel gaeb:preisspiegel Standard Idem-Key Bieter-Angebot zufügen
GET /v1/handwerk/gaeb/lv/{id}/preisspiegel gaeb:preisspiegel Standard Preisspiegel-Aggregation
POST /v1/handwerk/gaeb/validate gaeb:read Standard Trocken-Validierung (kein Speichern)
POST /v1/handwerk/admin/stlb-katalog admin:write Admin Idem-Key StLB-Bau-Katalog-Upload
paths:
/v1/handwerk/gaeb/import:
post:
operationId: importGaebFile
x-werkszeit-scope: gaeb:import
x-werkszeit-rate-limit: privileged
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
properties:
file: { type: string, format: binary }
projectId: { type: string, format: uuid }
strictMode: { type: string, enum: [strict, balanced, loose] }
responses:
'202':
description: Import in Arbeit (Parser-Job asynchron)
content: { application/json: { schema: { $ref: '#/components/schemas/ImportJob' } } }
'400': { description: 'Format nicht erkannt / Schema-Fehler' }
'413': { description: 'Datei > 50 MB' }
components:
schemas:
ImportJob:
type: object
required: [jobId, status, phase, parsedPositionen, fehler, warnungen]
properties:
jobId: { type: string, format: uuid }
status: { type: string, enum: [queued, parsing, validating, done, failed] }
phase: { type: string, enum: [81, 82, 83, 84, 85, 86, 89] }
xmlVersion: { type: string, example: "3.3" }
parsedPositionen: { type: integer }
fehler:
type: array
items:
type: object
properties:
code: { type: string, example: "ERR-OZ-LUECKE" }
oz: { type: string, example: "01.02.020" }
meldung: { type: string }
warnungen: { type: array, items: { type: object } }
LVKnoten:
type: object
required: [id, oz, knotenTyp]
properties:
id: { type: string, format: uuid }
oz: { type: string, pattern: "^[0-9]{2}(\\.[0-9]{2})*(\\.[a-z])?$" }
knotenTyp: { type: string, enum: [los, titel, position, unterposition] }
kurztext: { type: string, maxLength: 70 }
langtext: { type: string }
mengeneinheit: { type: string, enum: [m, m2, m3, Stk, h, kg, t, psch, l] }
menge: { type: number, format: double }
ep: { type: number, format: double }
gp: { type: number, format: double, readOnly: true }
stlbNr: { type: string, nullable: true }
kalkulation: { $ref: '#/components/schemas/Kalkulation' }
  • gaeb.lv.created — neues LV angelegt
  • gaeb.lv.version.commited — Version eingefroren (Auftrag erteilt)
  • gaeb.import.done — Import fertig (Payload: jobId, fehler, warnungen)
  • gaeb.import.failed — Parser-Fehler
  • gaeb.export.done — Export-Artefakt bereit
  • gaeb.preisspiegel.updated — Bieter zugefügt/entfernt

Profil Begründung Mechanik
Best-effort-offline (Mobile Read) Bauleitung will LV auf Baustelle einsehen, Netz nicht garantiert LV bei letztem Öffnen vollständig in SQLite gecached, Positions-Detail offline sichtbar
Online-only (Web Editor + Import/Export) Kalkulation erfolgt am Desktop; Import-Parser läuft serverseitig (Mustang-ähnlich), keine Parser-Binary im Browser UI blockiert Import-Button bei connectivity=none, Toast „Import nur online“
Online-only (Preisspiegel) Cross-Tenant-Aggregation (nicht zutreffend hier) / schreibgeschützt bei Anonymisierung

Import-Upload: S3-Multipart über presigned URL; SQLite hält nur Job-Referenz + Status-Cache. GAEB-Quell-Datei wird nach erfolgreichem Parse in S3 mit Object Lock (Compliance, 10 Jahre) abgelegt — die Datei ist Teil der GoBD-relevanten Unterlagen.

LV-Mutation ist server-authoritativ; Parallel-Edits durch zwei Kalkulatoren werden über optimistisches Locking abgefangen (If-Match: <lv_version.id>). Bei Konflikt: 409 mit Diff-Vorschau, User muss entscheiden (Overwrite / Abbrechen / Merge-Dialog — letzteres in V1.5). Kein Last-Write-Wins (LESSONS-LEARNED §4).


  • Append-only LV-Versionen: Sobald lv_version.immutable_at IS NOT NULL, verweigert RLS jeden UPDATE. Mutationen erzeugen eine neue Version mit Verweis auf Vorgänger.
  • Hash-Chain: hash_self = SHA-256(canonicalJSON(lv_version + knoten-Baum + kalkulation)). hash_prev zeigt auf Vorgänger-Version, bildet eine LV-lokale Kette. Diese Kette wird an das globale Audit-Log-Hash-Merkle angehängt (Tages-Anker in S3).
  • Aufbewahrung: Quell-Dateien (DA83, X83) in S3 mit Object Lock (Compliance-Mode), 10 Jahre ab immutable_at, gem. §147 AO. Schlüssel-Schema: s3://werkszeit-gobd/{tenantId}/gaeb/{lvId}/{version}/quelle.xml.
  • Nachvollziehbarkeit: Jede Mutation (Import, Knoten-Änderung, Kalkulation, Commit, Export) erzeugt einen Audit-Eintrag (gaeb.lv.*) mit User, Zeit, Diff-Payload-Hash.
  • Verfahrensdokumentation: Textbaustein in gobd-verfahrensdoku.md, Abschnitt „LV-Verwaltung“.

Nicht zutreffend, weil Feature reine Kaufmännische Arbeit ist und keine Arbeitszeit-Erfassung umfasst.

  • §2 Abs. 5/6 (Änderungen, Nachträge): Jeder Nachtrag muss über Editor auf eine OZ des beauftragten LV (lv_version.immutable_at IS NOT NULL) zeigen. Nachtrags-OZ erhält Präfix N<num> (z.B. 01.02.030.N1), bleibt im Baum des Ur-LV erkennbar.
  • §14 Abs. 1 (prüfbare Schlussrechnung): Rechnung (§4.5) generiert sich zwingend aus lv_version.id + Aufmaß-Aggregation pro OZ; die Rechnung kann nicht mit einer anderen als der beauftragten Version verknüpft sein (FK-Check im Drizzle-Insert).
  • VOB/A §7 (eindeutige Leistungsbeschreibung): Export-Profil strict erzwingt StLB-Bau-Katalog-Nr für jede Position; ohne Kat-Nr bleibt der Export gesperrt.
  • Art. 5 Datenminimierung: Bieter-Angebote speichern keine Personen-E-Mails im Klartext — nur Firmenname + anonymisierbares Label (Bieter A).
  • Art. 6 Rechtsgrundlage: Art. 6 Abs. 1 lit. b (Vertragserfüllung — Angebot/Auftrag) + Abs. 1 lit. f (berechtigtes Interesse — Preisspiegel für Vergabe-Gremium).
  • Art. 17 Löschung: Bieter-Stammdaten-Löschung über Maskierung (bieter_name='[gelöscht]') wegen GoBD-Konflikt; Anonymisierungs-Label bleibt erhalten.
  • Art. 20 Datenexport: Bieter können über Portal-Link ihre eingereichte Version als X83-Download ziehen.
  • DSFA: Nicht Pflicht (kein Profiling, keine systematische Bewertung natürlicher Personen).

Nicht zutreffend für LV-Editor (kein ESS-Flow, kein öffentlicher Zugang). Mobile Read-View ist aber Teil der allgemeinen App → übernimmt die App-weiten A11y-Regeln (VoiceOver, Tab-Navigation, Kontrast ≥ 4.5:1).

Nicht zutreffend: Feature ermöglicht weder Leistungs- noch Verhaltenskontrolle von Mitarbeitern.

  • GAEB DA XML 3.3 Schema: XSD-Validierung (offizielles Schema von GAEB-Dienststelle) als Pflicht im Import/Export-Pfad. Schematron-Regeln aus StLB-Dynamischer-Baudatenbank werden im strict-Profil zusätzlich ausgewertet.
  • StLB-Bau (Standardleistungsbuch Bau): Katalog-Version pro Tenant aktivierbar, Versions-Migration (z.B. 2023.2 → 2024.1) über Admin-Dialog mit Differenz-Report.
  • DIN 276 (Kostengruppierung): Optionales Tagging pro Position; nicht Pflicht für V1. In V2 als Reporting-Dimension.
  • KoSIT-Schematron: Indirekt über §4.5 relevant, wenn LV-Position zur XRechnung-Position wird.

# Szenario Erwartetes Verhalten
EC-01 OZ-Lücke im Import (01.02.010 und 01.02.030, kein 020) balanced-Profil: Warnung mit OZ-Liste im UI, Import läuft durch. strict-Profil: Import abgebrochen, Fix-Vorschlag „OZ 020 als Leerposition einfügen?“
EC-02 Zeichensatz-Bruch (ISO-8859-15-Datei mit Umlauten als Bytes \xFC, Parser stolpert) Magic-Byte-Erkennung erzwingt Charset-Dialog, User wählt manuell; intern wird nach UTF-8 konvertiert und als gaeb_raw.originalCharset gespeichert
EC-03 Mitten im Import: LV-Version wird parallel committed 409-Konflikt auf POST-Commit, User sieht Warnung „Version v3 wurde bereits beauftragt; neuer Import erzeugt v4 als Entwurf“
EC-04 Multi-Tenant-Bleed: User wechselt Tenant und versucht LV-ID von Tenant A in Tenant B zu lesen API antwortet 404 (nicht 403), kein Audit-Log-Eintrag in Tenant A (RLS unsichtbar)
EC-05 Großmenge: 15.000-Positionen-LV aus Infrastrukturprojekt Parser-Lambda-Timeout 15 min, Chunked-Parse (Stream-XSD-Validator), UI zeigt Progressbar, Mobile-Cache auf letzte 5 MB Kompression begrenzt
EC-06 OZ-Überlauf: 3-stufige Gliederung, Tenant braucht 4 Stufen Gliederungsregel ist immutable pro LV; Warnung im UI, Workaround: Unterpositionen mit Buchstaben-Suffix (01.02.030.a) — gem. GAEB 3.3 zulässig
EC-07 DA83-ASCII-Datei (fixed-width) mit Zeilenumbruch CRLF statt CR Parser toleriert beide, korrigiert intern; gaeb_raw.normalizationApplied=['lineEndings:CRLF→CR']
EC-08 Export DA84, AG-System lehnt ab wegen Dateiname Export-Profil definiert Dateinamen-Template ({ausschreibungsNr}-DA84.d84); Vorschau vor Download
EC-09 Plan-Limit: Starter-Plan, aber Kunde versucht GAEB zu nutzen Modul-Gate module.handwerk.gaeb nicht aktiv → Hono-Route antwortet 403 (nicht 404, weil Feature existiert), UI zeigt Upgrade-Modal
EC-10 Preisspiegel-Bleed: Bauleitung versucht Bieter-Namen per API zu lesen RLS-Policy bieter_namen_restricted filtert für Rolle ≠ einkauf die bieter_name-Spalte auf null; UI zeigt „Bieter A“
EC-11 Hash-Chain-Bruch (manueller DB-Edit im worst case) compliance-test C-01 detektiert beim nächsten Commit-Versuch; LV-Version wird als „verdächtig“ markiert, Tech-Lead-Eskalation (Stop-the-bus-Trigger §18)
EC-12 Rundungs-Drift zwischen GP-Berechnung (Dart Mobile) und Backend (TS) Property-based Tests gegen 10.000 Zufalls-Mengen×EP, ROUND_HALF_EVEN Pflicht; Delta > 0,01 €: CI-Failure

Funktionalität: GAEB-LV-Import und -Export
Hintergrund:
Angenommen ein Tenant "shk-gebruder-schmidt" mit aktivem Modul-Gate "module.handwerk.gaeb"
Und ein Kalkulator "Thomas" mit Rolle "Kalkulator" und Scope "team"
Und ein Projekt "Sanierung Schule A" ist angelegt
Szenario: Happy Path — DA-X83-Import, Kalkulation, DA-X84-Export
Wenn Thomas die Datei "schule-a-X83.x83" über den Import-Wizard hochlädt
Dann erkennt das System das Format "GAEB DA XML 3.3, Phase 83 (Angebot)"
Und es werden 320 Positionen importiert
Und 0 Fehler und maximal 15 Warnungen werden gemeldet
Und ein LV der Version 1 mit Status "entwurf" ist verknüpft zum Projekt "Sanierung Schule A"
Wenn Thomas Position "01.02.030" öffnet
Und die Kalkulation setzt auf ME=1,80 € · LE=7,20 € · GK=0,40 € · BGK=8% · AGK=12% · W&G=9%
Dann wird der EP als 12,40 € berechnet (ROUND_HALF_EVEN)
Und GP = 128,50 × 12,40 = 1.593,40 €
Wenn Thomas das LV als "X84 · Hauptangebot" exportiert
Dann liefert das System eine Datei "schule-a-X84.x84" (UTF-8)
Und der integrierte Schema-Check meldet 0 Fehler
Und ein Audit-Log-Eintrag der Art "gaeb.lv.export" wird erzeugt (User=Thomas, Version=1)
Szenario: Grenzfall — Import-Validator-Fehler bei OZ-Lücke (strict)
Angenommen der Tenant ist auf Import-Profil "strict" konfiguriert
Und die Datei "schule-a-X83.x83" enthält OZ-Lücke zwischen "01.02.010" und "01.02.030"
Wenn Thomas die Datei importiert
Dann antwortet die API mit 422 und einer Fehlerliste inkl. Code "ERR-OZ-LUECKE"
Und es wird KEIN LV angelegt
Und Thomas bekommt den Vorschlag "Leerposition 01.02.020 einfügen" oder "Profil auf balanced wechseln"
Szenario: Grenzfall — Versions-Migration DA-ASCII → DA-XML 3.3
Angenommen Thomas lädt eine "schule-a.d83" (ASCII-GAEB, Version 2.0) hoch
Wenn das System das Format als "GAEB DA 2.0, ASCII, Phase 83" erkennt
Dann wird die Datei intern in DA-XML-3.3-Repräsentation überführt
Und ein Migrations-Protokoll wird angezeigt mit den Regel-Differenzen
Und beim Export kann Thomas explizit zwischen "DA84 (ASCII)" und "X84 (XML 3.3)" wählen
Und in beiden Exporten ist der Round-Trip der importierten Felder verlustfrei (keine Feld-Drop-Warnung)
Szenario: Grenzfall — Konflikt bei Parallel-Edit durch zwei Kalkulatoren
Angenommen Thomas und Lisa (beide Kalkulator im Team) bearbeiten LV v1 parallel
Und Lisa hat bereits Pos. "01.02.030" auf EP=13,00 € geändert und gespeichert
Wenn Thomas seine Änderung auf EP=12,40 € mit If-Match auf den ursprünglichen Etag PATCHt
Dann antwortet die API mit 409 und einem Diff-Payload (Lisa: 13,00 · Thomas: 12,40 · Basis: 12,00)
Und Thomas sieht die Auflösungs-Optionen "Lisas Wert übernehmen" / "Meinen Wert überschreiben (Begründung Pflicht)" / "Abbrechen"
Szenario: Grenzfall — Multi-Tenant-Isolation beim Import
Angenommen ein zweiter Tenant "musterbetrieb-maler"
Wenn Thomas (Tenant "shk-gebruder-schmidt") versucht, über die API ein LV aus Tenant "musterbetrieb-maler" per ID zu lesen
Dann antwortet die API mit 404
Und es entsteht KEIN Audit-Log-Eintrag in Tenant "musterbetrieb-maler"
Szenario: Grenzfall — GoBD-Sperre nach Commit
Angenommen LV v3 ist auf "beauftragt" gesetzt (immutable_at ist NOT NULL)
Wenn Thomas versucht, in v3 einen Knoten zu ändern
Dann antwortet die API mit 409 und Code "GOBD_VERSION_LOCKED"
Und der UI zeigt den Hinweis "Version 3 ist beauftragt und unveränderlich. Soll ich Version 4 als Entwurf anlegen?"
Wenn Thomas "Ja" klickt, dann wird v4 als Copy-on-Write erzeugt, hash_prev zeigt auf v3

  • U-01oz.parse() / oz.format() — Property-based (10.000 Zufalls-OZ): format(parse(s)) === s für 4-stufige Gliederung
  • U-02 — EP-Kalkulation: calculate(me, le, gk, so, bgk, agk, wug) vs. Referenz-Excel-Tabelle aus StLB, Abweichung < 0,005 €
  • U-03ozSort-Generator: Liste von 1.000 OZ wird nach ozSort sortiert = natürliche OZ-Reihenfolge
  • U-04 — Round-Trip: parseGaebXml(file)serializeGaebXml(model) = byte-identisch (abgesehen von Canonical-Whitespace) für 12 Muster-Dateien
  • W-01 — LV-Baum mit 10.000 Positionen — Render-Zeit < 200 ms Initial, Lazy-Load < 50 ms pro Titel
  • W-02 — Golden-Test für LV-Mobile-Detail (de-DE, Light/Dark)
  • W-03 — OZ-Search-Input: Eingabe „01.02“ filtert Baum in ≤ 80 ms
  • I-01 — Multi-Tenant-Isolation: Test legt 2 Tenants an; Tenant A darf LV von Tenant B nicht sehen (GET, PATCH, POST/export); 404 + kein Audit-Log in B
  • I-02 — RLS-Policy lv_scope: Kalkulator in Team X sieht keine LVs aus Team Y desselben Tenants
  • I-03 — Outbox-Idempotenz: POST /v1/handwerk/gaeb/import mit identischem Idem-Key → 200 (zweite Antwort), nur 1 Job angelegt
  • I-04 — GoBD-Sperre: PATCH auf Knoten einer committed-Version → 409 GOBD_VERSION_LOCKED, kein DB-Write
  • E-01 — Patrol/iOS + Android + Playwright/Chromium/Firefox/WebKit: Happy-Path (Import X83 · Kalkulieren Pos 030 · Export X84) — Gherkin Szenario 1:1
  • E-02 — Offline LV-Ansicht Mobile: Device offline, zuletzt geladenes LV bleibt sichtbar, OZ-Suche funktioniert
  • E-03 — Visuelle Regression: Golden-Image LV-Editor Web (Light/Dark/de-DE) — Abweichung > 0,1 % blockt Merge
  • E-04 — A11y: axe-core auf LV-Editor — keine Critical/Serious; Flutter-Semantics für Mobile-Detail grün
  • C-01 — Hash-Chain-Integrität: manueller Eingriff in lv_version.summeNetto lässt nachfolgende hash_self brechen; Detection-Test schlägt Alarm
  • C-02 — GAEB-Interop: Round-Trip mit 12 Referenz-Dateien aus RIB-iTWO-Export-Samples, strict-Profil; Diff muss leer sein (modulo Whitespace und Timestamp-Feldern)
  • C-03 — StLB-Bau-Katalog-Validator: 100 Stichproben-Positionen, Katalog-Nr muss auflösbar sein
  • C-04 — Schema-Check XSD: offizielles GAEB-DA-XML-3.3-XSD gegen jeden Export; 0 Validation-Errors
  • C-05 — Rundungs-Property-Test: 100.000 Zufalls-(menge, ep)gp identisch zwischen Dart-Client und TS-Backend, ROUND_HALF_EVEN
  • Lokaler 20×-Re-Run der neuen E2E-Tests grün (DOD §2.2 letzter Punkt)

Nicht-Ziel Begründung
Vollständiger RIB-iTWO-Ersatz Nicht in Phase 1. Werkszeit deckt 80 % der AVA-Aufgaben ab (Import/Kalk/Export). Komplexe BIM-Integration, CAD-Mengensplit, Bauzeiten-Simulation bleiben bei Spezial-Tools.
IFC-/BIM-Round-Trip Kein Referenzkunde in Phase 1. Verschoben auf V3, dann separates Feinkonzept handwerk/12-bim-ifc.md.
Ausschreibungs-Plattform-Direktanbindung (subreport ELViS, Deutsche eVergabe) API-Anbindung an öffentliche Plattformen ist pro Plattform eigener Vertrag + Zertifizierung. Werkszeit bleibt bei Datei-Up/Download; Direktanbindung frühestens V2 bei zahlendem Design-Partner.
ORCA AVA-Import ORCA nutzt GAEB plus proprietäre Extensions. Kein aktueller Kunde. V2 falls nachgefragt.
Bauzeitenplan (Gantt) aus LV Separates Feature (handwerk/13-bauzeitenplan.md, nicht in V1).
Dynamische Preisanpassung (Stoffpreisgleitklausel nach BGB §313) Wird im Kalkulations-Editor als Metadaten-Feld geführt, aber nicht automatisch berechnet — in V2.
Elektronische Signatur für Angebots-Abgabe QES/FES ist öffentliche-AG-Thema, liegt bei ELViS / Vergabeplattform. Werkszeit erzeugt nur die X83-Datei.
Multi-Language LV (Deutsch/Englisch parallel) Nicht Zielmarkt Phase 1.

Risiko / Annahme Impact Wahrscheinlichkeit Gegenmaßnahme
GAEB-XSD-Interop mit RIB iTWO 2024.x ist strikter als unser Parser — Exports werden abgelehnt hoch mittel Early-Prototype Monat 2: Export gegen iTWO-Import testen mit Muster-LV (3 Größen); evtl. Schematron-Profil „iTWO-kompatibel“ einbauen
StLB-Bau-Katalog-Lizenz: Verteilung an Kunden könnte Lizenz-Zahlung an GAEB-Dienststelle erfordern mittel mittel Rechtsklärung vor V1-Release; Fallback: Kunde lädt eigenen Katalog hoch (Admin-Upload), Werkszeit liefert ihn nicht mit aus
Parser-Performance bei 15.000-Positionen-LV: Lambda-Timeout, Browser-Mobile-Cache-Overflow mittel niedrig Streaming-Parser (SAX-basiert) in Node statt DOM; Lazy-Load im Client; Mobile-Cache per Titel (nicht pro LV)
OZ-Format-Änderung mid-lifecycle — Kunde will nachträglich 4-stufig niedrig hoch Dokumentiertes Nicht-Ziel; Migration nur via LV-Neuanlage + manuellem Copy
Hash-Chain-Bruch durch DB-Restore hoch niedrig Pre-Commit-Hook im Backup: Hash-Chain-Prüfung nach Restore Pflicht; dokumentierter Runbook
Mustang-Lambda-Ökosystem-Reife für X86/X89-Bezug zur Rechnung (siehe §4.5) mittel niedrig Prototyp vor V1-Freeze (siehe ADR-0002)

  • Vorbedingung: kern/11-rollen-rls (Kalkulator-Rolle, RLS-Baseline), kern/12-audit-log (Hash-Chain-Infrastruktur)
  • Schnittstelle zu:
    • handwerk/04-mobile-aufmass.md — Aufmaß greift auf lv_knoten.oz einer lv_version_id (beauftragt) zu
    • handwerk/05-bau-abrechnung.md — X86/X89-Export-Routinen übernehmen die gleiche Mustang-Lambda-Pipeline
    • Projekt/Kunden-Stammdaten (Kern) — LV.projectId → projects
  • Wird konsumiert von:
    • Nachtrag-Management (§4.2)
    • Schlussrechnung (§4.5)
    • Preisspiegel-Dashboard (ist selbst Teil dieses Feinkonzepts)

Status: TBD — zu klären mit Sales / PO.

DOR §1.1.1 verlangt: „Mindestens ein zahlender Design-Partner ist namentlich dokumentiert, der das Feature konkret fordert.“

Kandidaten-Profile:

  • 30–80-MA-Bauunternehmen (Hochbau / SHK / Elektro), das regelmäßig an öffentlichen AG teilnimmt (Stadt, Land, DB, Bundeswehr)
  • Betrieb mit ≥ 1 Vollzeit-Kalkulator/-in, der heute RIB iTWO oder Nevaris nutzt und die Kosten (4–10 k€/Jahr/Arbeitsplatz) drücken will
  • Betrieb mit ausreichender IT-Reife (eigene Mail-Domain, DATEV-Steuerberater, existierende digitale Zeiterfassung)

Validierungs-Fragen für das Erst-Gespräch:

  1. Wie viele GAEB-Dateien bekommen Sie pro Monat, wie groß sind die (Positionen)?
  2. Welches Tool nutzen Sie heute — und was fehlt Ihnen daran am meisten?
  3. Haben Sie Probleme bei der X84-Abgabe, die der AG ablehnt? Welche Fehler kamen zurück?
  4. Wäre ein vereinfachtes Werkszeug (80 % der Funktionen, aber aus einem Guss mit Ihrer Zeiterfassung/Aufmaß/Rechnung) ein Kaufanreiz — bei welcher Preismarge pro Arbeitsplatz?
  5. Bereit für 30-Tage-Beta mit 2 echten LVs zum Import und einem Probe-Export an einen internen Test-AG?

  1. Datenmodell + RLS + Multi-Tenant-Test (immer zuerst — alles andere ist auf Sand gebaut). Inkl. oz.parse/format in packages/oz-core (Dart + TS).
  2. GAEB-Parser-Lambda (Node, Streaming-XSD-Validator, DA-ASCII → DA-XML-3.3-Adapter). Start mit X83/X84 (80 % der Praxisfälle), X81/X82/X85/X86/X89 folgen innerhalb V1.
  3. Backend-API + OpenAPI-Spec → Dart-Client-Generierung. POST /import async (Queue + Webhook), GET /jobs für Status.
  4. LV-Editor Web — virtualisierter Baum (ag-grid oder eigener mit react-virtual-Äquivalent in Flutter-Web), Split-Pane, Keyboard-Shortcuts.
  5. Kalkulations-Overlay mit ROUND_HALF_EVEN-Pflicht auf beiden Seiten.
  6. Mobile Read-View mit SQLite-Cache.
  7. Preisspiegel (Bieter-Upload, Aggregation, Anonymisierung, PDF/CSV).
  8. Import-Validator-Strenge (strict/balanced/loose).
  9. Compliance-Tests vollständig (C-01 bis C-05) — gate vor V1-Freeze.
  10. E2E-Suite + Doku-PR mit auto-generierten Screenshots.
  • Multi-Tenant-Isolation, Hash-Chain, Schema-Validator = nicht verhandelbar.
  • Zuerst einsparbar: Preisspiegel-Anonymisierungs-Feinheiten (V1.5), ASCII-Import DA85/86/89 (X-Pendants zuerst), Keyboard-Shortcuts über Basis hinaus.
  • Multi-Tenant-Bleed in irgendeinem Test.
  • Hash-Chain-Bruch in C-01.
  • GAEB-Round-Trip-Verlust von Feldern in C-02 (schlägt für 1 Muster-Datei fehl).
  • RIB-iTWO-Interop: unsere X84-Datei wird vom echten iTWO-Import abgelehnt mit Schema-Fehler, den wir nicht reproduzieren können.
  • OZ-Core als eigenes Paket (Dart + TS Port): diese winzige Investition zahlt sich bei Aufmaß (§4.4), Rechnung (§4.5), Nachtrag (§4.2) und später Bauzeitenplan als Plattform-Vorteil zurück — und hält uns von 01.02.030 != 01.02.30 != 1.2.30-Bugs.
  • Copy-on-Write-Varianten statt Duplikat-Tabelle: Storage-schonend, Diffs sind Gratis.
  • Streaming-Parser: 15.000-Positionen-LV ist damit technisch ein Non-Issue — Tier-2-Infrastruktur-Kunden kommen ohne Refactor.
  • Append-only Versionen mit Hash-Chain: sauberer GoBD-Nachweis vom ersten Tag; spart uns die Audit-Panik, wenn Finanzamt 2029 für die Projekte von 2026 Prüfung anberaumt.

Letzte Aktualisierung: 2026-04-19. Änderungen erfordern PR-Review durch Tech-Lead + PO.

Für Entwickler — API-Endpoints19
MethodePfadAuthZweck
GET/v1/handwerk/gaeb-lv/stlb-katalog/lookupbearerAuthSTLB-Katalog-Lookup
GET/v1/handwerk/lvbearerAuthLeistungsverzeichnisse auflisten.
POST/v1/handwerk/lvbearerAuthLeistungsverzeichnis anlegen.
DELETE/v1/handwerk/lv/{id}bearerAuthLeistungsverzeichnis löschen (nur Status `entwurf`).
GET/v1/handwerk/lv/{id}bearerAuthLeistungsverzeichnis (Detail inkl. Status-Historie).
PUT/v1/handwerk/lv/{id}bearerAuthLeistungsverzeichnis aktualisieren.
POST/v1/handwerk/lv/{id}/export-reb23003bearerAuthLV als REB-23.003-Datei exportieren (windows-1252).
POST/v1/handwerk/lv/{id}/export-xmlbearerAuthLV als GAEB-XML exportieren.
GET/v1/handwerk/lv/{id}/gliederungenbearerAuthLV-Gliederungsebenen auflisten.
POST/v1/handwerk/lv/{id}/gliederungenbearerAuthLV-Gliederungsebene anlegen.
DELETE/v1/handwerk/lv/{id}/gliederungen/{gId}bearerAuthLV-Gliederungsebene löschen.
PUT/v1/handwerk/lv/{id}/gliederungen/{gId}bearerAuthLV-Gliederungsebene aktualisieren.
POST/v1/handwerk/lv/{id}/import-xmlbearerAuthGAEB-XML in LV importieren.
GET/v1/handwerk/lv/{id}/positionenbearerAuthLV-Positionen auflisten.
POST/v1/handwerk/lv/{id}/positionenbearerAuthLV-Position anlegen.
DELETE/v1/handwerk/lv/{id}/positionen/{pId}bearerAuthLV-Position löschen.
PUT/v1/handwerk/lv/{id}/positionen/{pId}bearerAuthLV-Position aktualisieren.
GET/v1/handwerk/lv/{id}/summenbearerAuthLV-Summen (Netto/Brutto, Anzahl Positionen/Ebenen).
POST/v1/handwerk/lv/{id}/transitionbearerAuthLV-Status wechseln.