Zum Inhalt springen

Projekte, Kunden & Baustellen — Stammdaten, Quick-Create, OCR, Stundennachweis

Live in Produktion

Feinkonzept · §3.4 Projekte, Kunden & Baustellen — Stammdaten, Quick-Create, Visitenkarten-OCR, Stundennachweis

Abschnitt betitelt „Feinkonzept · §3.4 Projekte, Kunden & Baustellen — Stammdaten, Quick-Create, Visitenkarten-OCR, Stundennachweis“

Einordnung. Dieses Feinkonzept setzt FUNKTIONSUMFANG §3.4 um. Es ist das Rückgrat der operativen Stammdatenwelt: ohne saubere Kunden-, Projekt- und Baustellen-Entitäten landet kein Stempel auf einem Kostenträger, kein Stundennachweis trägt eine Adresse, kein DATEV-Export hat eine Debitorennummer. Die Disziplin aus LESSONS-LEARNED §9 („keine 70 Routes zur Sicherheit“) gilt hier besonders: wir bauen die drei Entitäten als eine fachliche Einheit mit klaren Feld-Pflichtstufen — nicht drei halbherzige CRUD-Module.


feature_id: kern/04-projekte-kunden-baustellen
title: Projekte, Kunden & Baustellen — Stammdaten, Quick-Create, OCR, Stundennachweis
funktionsumfang_ref: §3.4
roadmap_horizont: MVP
plattformen:
mobile: vollständig # Quick-Create, Visitenkarten-OCR, Favoriten, Signatur
web: mit-Bulk # Vollformular, Budgets, Stundensätze, Massen-PDF-Versand
desktop: ab V1.5 bei Bedarf
owner_rolle: Manager # Stammdaten-Pflege primär Manager/Admin, Anlage auch Mitarbeiter
modul_gate_flag: module.kern.stammdaten
compliance_flags:
gobd: true # Stundennachweis + Stundensätze sind abrechnungsrelevant (§147 AO)
arbzg: false # Keine Zeit-Erfassungslogik hier; nur Zuordnung
vob: false # VOB-Nachtrag/LV liegen in eigenem Feinkonzept
dsgvo: true # Kunden-Ansprechpartner = personenbezogene Daten
bfsg: false # Stammdaten-Pflege ist Admin-Pfad (Web-exklusiv)
betrvg: false # Keine Mitarbeiter-Tracking-Funktion
stvg: false
weitere: ["§14 UStG Rechnungs-Pflichtfelder", "§48b EStG Freistellungs-Nachweis (Bau)"]
referenzkunde:
status: TBD
name: "zu klären mit Sales — Zielprofile: Malerbetrieb 15–30 MA, SHK-Betrieb 60–100 MA"
quelle: Interview-Protokoll (musterbetrieb-maler Scoping-Gespräch 2026-03-28)
estimate_eng_tage: 22 # MVP-Slice: Entitäten + Quick-Create + Visitenkarten-OCR + Stundennachweis-PDF
abhängigkeiten:
- kern/09-auth-self-service # Scope-Model + Tenant-Setting für RLS
- kern/11-compliance-audit # Audit-Log für jede Stammdaten-Mutation
- kern/03-zeiterfassung # Projekt-/Baustellen-Zuordnung wird beim Stempeln konsumiert

Marktrealität (DE-Handwerk). Der 18-Mann-Malerbetrieb in Bayern führt heute Kunden in Excel, Baustellen in einer Klarsichthülle und Stundennachweise auf einem DIN-A5-Block, den der Bauherr vor Ort abzeichnet. Der 80-MA-SHK-Betrieb in NRW hat eine ERP-Schattenlösung (Sander & Doll oder pds), die exportieren kann, aber niemand im Feld benutzt — der Monteur ruft die Stammdaten nicht ab, er kritzelt auf Papier. Beide Welten leiden an demselben Bruch: Stammdaten leben im Büro, der Auftrag entsteht am Tresen des Bauherren. Wer dort keine Kunde-anlegen-in-15-Sekunden-Funktion hat, verliert die Zuordnung und damit §14-UStG-Pflichtfelder, Debitorennummer, Ansprechpartner, Zahlungsziel.

Rechtlich flankiert wird das durch §14 UStG (Pflichtangaben auf Rechnungen — ohne Kundensteuernummer/USt-ID bei B2B droht Vorsteuer-Ausschluss) und §48b EStG (bei Bau-Subunternehmern braucht der Auftraggeber eine Freistellungsbescheinigung, sonst 15 % Bauabzugssteuer). Beides landet in den Kunden-Stammdaten — nicht in einem separaten „Tax-Modul“.

Schmerzpunkt der Alt-App. Die Alt-App hatte Kunden, Projekte und Baustellen als drei lose Tabellen ohne gemeinsames Anlage-Fenster und ohne Quick-Create im Feld (LESSONS-LEARNED §2 — Feature-Wildwuchs mit 97 Tabellen). Der Monteur hat im Keller keinen Kunden gefunden, weil keiner angelegt war, und hat den Stempel auf „Sonstiges/Intern“ gebucht — die Stunde war für die Abrechnung verloren. Zusätzlich lag die gesamte Tenant-Trennung in WHERE tenant_id = ?-Klauseln (LESSONS-LEARNED §3); eine vergessene Klausel hätte Kundendaten von Tenant A in der Such-Autocomplete von Tenant B sichtbar gemacht.

Erwarteter Outcome.

  1. Quick-Create in ≤ 15 Sekunden auf Mobile ohne Blocker für den Workflow („Stempel jetzt, Feld-Pflege später“).
  2. Visitenkarten-OCR: Neukunde via Foto → 5 Felder vorausgefüllt → Bestätigung → angelegt. Zielzeit 30 s inkl. Foto.
  3. Stundennachweis-PDF aus Zeit-Buchungen pro Woche/Monat, Touch-Signatur durch Bauherr vor Ort (Mobile), Bulk-Versand per E-Mail (Web).
  4. Keine stillen Datenlecks. Postgres-RLS + Multi-Tenant-Test-Invariante (DOD §2.2) verhindern, dass ein vergessener WHERE Tenant-Daten bloßlegt.

Rolle Aktion Scope Plattform
Mitarbeiter (Monteur) Projekt/Baustelle wählen, Quick-Create Kunde/Baustelle, Stundennachweis signieren lassen own (eigene Stempelung, keine fremden Kundenlisten) 📱🌐
Bauleiter Baustellen-Details lesen, Fortschritt kommentieren, Bauherr-Signatur einholen team (alle Baustellen seines Teams) 📱🌐
Manager Projekte anlegen, Budgets pflegen, Stundennachweise bulk-generieren und versenden team / all per Admin-Freigabe 📱🌐
Admin Kunden-Vollformular (UStID, Debitorennr., §48b EStG-Freistellung), Stundensätze, Lohnarten-Mapping all 🌐
Buchhaltung Kunden-Bank-/UStID-Felder pflegen, DATEV-Debitorennr. abgleichen all (Schreibschutz auf Personalfeldern durch HR) 🌐

Persona-Skizzen.

  • Hannes Krüger (Monteur, 38, SHK, Tenant shk-gebruder-schmidt): arbeitet mit Handschuhen, Empfang oft Edge/kein LTE, will drei Bauplätze dieser Woche ohne Tippen auswählen.
  • Sabine Maier (Manager, 45, Malerbetrieb musterbetrieb-maler): verwaltet 25 MA, pflegt Stammdaten im Büro, erstellt freitags Stundennachweise für 3–5 Baustellen.
  • Thomas Schmidt (Bauleiter SHK, 52): Baustellen-Tournee mit Tablet, holt Bauherr-Unterschriften ein, übergibt Stundennachweis-PDF direkt per E-Mail.
  • Frau Müller (Buchhaltung, 56, shk-gebruder-schmidt): kennt DATEV-Debitoren, will UStID-Prüfung per BZSt-Abgleich und einmal-im-Monat-Export ohne Klickstrecke.

US-01 [MVP] Als Monteur möchte ich offline eine Baustelle aus meiner Favoritenliste
wählen, um ohne LTE-Empfang im Keller stempeln zu können.
US-02 [MVP] Als Monteur möchte ich im Feld in ≤15 Sekunden einen neuen Kunden
anlegen (nur Pflichtfelder), damit ich nicht auf „Intern" stempeln muss.
US-03 [MVP] Als Monteur möchte ich eine Visitenkarte abfotografieren und die
Felder vorgefüllt bestätigen, um beim Bauherr-Termin keine Tipparbeit
zu haben.
US-04 [MVP] Als Manager möchte ich einen Stundennachweis für einen Mitarbeiter
wochenweise als PDF erzeugen und an den Bauherrn senden, damit die
Abnahme dokumentiert ist (§14 UStG-konform).
US-05 [MVP] Als Manager möchte ich in der App einen Bauherr-Stundennachweis
per Touch-Signatur direkt vor Ort einholen, um den Abrechnungs-
streit im Nachgang zu vermeiden.
US-06 [MVP] Als Admin möchte ich Stundensätze pro Rolle/Mitarbeiter/Datum
pflegen, damit Projekt-Budgets realistisch gerechnet werden
(ein Satz 2025 ≠ ein Satz 2026).
US-07 [MVP] Als Buchhaltung möchte ich die UStID eines Neukunden per BZSt
validieren lassen, damit Rechnungen nicht an ungültige IDs gehen.
US-08 [V1] Als Admin möchte ich Projekt-Budgets (Zeit + Geld) anlegen und
Warnschwellen definieren, damit ich vor Ausuferung gewarnt werde.
US-09 [V1] Als Manager möchte ich Stundennachweise bulk für alle Mitarbeiter
eines Projekts generieren und an eine Bauherr-E-Mail versenden.
US-10 [V1.5] Als Admin möchte ich §48b-EStG-Freistellungsbescheinigungen als
Foto hinterlegen und Ablaufdatum+Erinnerung bekommen, damit ich
nicht 15 % Bauabzugssteuer einbehalten muss.

  • F-M-01 (Stammdaten-Browser) — Listen-Screen mit drei Tabs „Kunden / Projekte / Baustellen“. Suchfeld oben (Fulltext auf Postgres tsvector-Index, offline über Drift-FTS5). Favoriten-Stern pro Eintrag. Default-Sortierung: zuletzt-verwendet durch mich.
  • F-M-02 (Favoriten & eigene Beteiligungen) — Pro User eine Favoriten-Liste (user_favorites-Tabelle). Eigene Beteiligungen werden implizit durch Stempelungen der letzten 14 Tage gezeichnet (kein Nutzer muss Favoriten aktiv pflegen, aber darf).
  • F-M-03 (Detail-Screen Kunde/Baustelle) — Adresse, Ansprechpartner (Name, Telefon-Link, E-Mail-Link), Stundenstand (aggregiert aus Zeit-Buchungen der letzten 30 Tage), „Stundennachweis erstellen“-CTA.
  • F-M-04 (Quick-Create Kunde — Minimal-Form) — Pflicht: Firma ODER Name (eins von beiden), Straße, PLZ, Ort. Optional: Telefon, E-Mail. Keine UStID, keine Debitorennummer, keine Zahlungsbedingung — das sind Web-Admin-Felder, die die Buchhaltung nachpflegt. Der Datensatz wird als draft_by_field = true markiert, sichtbar für Admin im Web-Dashboard „Felder pflegen“.
  • F-M-05 (Quick-Create Baustelle) — Pflicht: Bezeichnung, Adresse, zugeordneter Kunde. Optional: Geo-Pin (auto via GPS), Bauherr-Ansprechpartner. Erzeugt fehlt-Kunde-Dialog, falls kein Kunde verknüpft.
  • F-M-06 (Visitenkarten-OCR) — Kamera öffnet, Live-Detection-Overlay (Rechteck um erkannte Karte), Foto-Erfassung. Google ML Kit / Apple Vision extrahieren Felder on-device (Firma, Name, Telefon, E-Mail, Adresse). Dedup-Prüfung: Fuzzy-Match auf Firma+PLZ+Straße-Präfix im lokalen Drift-Cache → bei Treffer Dialog „Kunde Y.X.Z existiert — zusammenführen oder neu?“. Kein Server-Roundtrip für OCR (on-device, LESSONS-LEARNED §4 — offline tauglich).
  • F-M-07 (Stundennachweis erstellen + Bauherr-Signatur) — Wähle Mitarbeiter (oder „ich“), Baustelle, Zeitraum (Default: aktuelle Woche). App zeigt Zeit-Buchungen gruppiert nach Tag, aggregiert in Stunden+Tätigkeit. Touch-Signatur-Pad (signature-Package, .wz-signature). PDF wird client-seitig via printing-Package gerendert — oder serverseitig via Typst, wenn online. Optional E-Mail-Versand nach Signatur.
  • F-M-08 (Stundennachweis-Versand per E-Mail) — App öffnet Share-Sheet mit vorgefülltem Betreff „Stundennachweis KW 16 – Baustelle Lehrer Allee 7“, Body-Template, PDF als Anhang. Kopie landet im Backend-Audit-Log mit stundennachweis.sent.email.
  • F-W-01 (Kunden-Vollformular) — Alle Pflichtfelder nach §14 UStG für B2B: Firmenname, vollständige Adresse, UStID (optional für B2C), Steuernummer (optional), Debitorennummer (für DATEV), Zahlungsziel (Default 14 Tage), Skonto-Regeln, Bankverbindung (IBAN — pgcrypto-verschlüsselt at-rest gem. TECH-STACK §11), Kontakt-Liste (Ansprechpartner mit Funktion/Telefon/E-Mail), §48b-Freistellungs-Foto + Gültigkeit.
  • F-W-02 (UStID-Validierung) — Button „UStID prüfen“ → Backend-Endpoint /v1/customers/validate-vat ruft BZSt-Abgleichs-SOAP-Schnittstelle auf (qualifizierte Abfrage mit Firma+PLZ+Straße). Ergebnis als 24h-Cache persistiert mit vat_validated_at. Rate-Limit 10/min pro Tenant (BZSt-Fair-Use).
  • F-W-03 (Projekt-Verwaltung) — Projekt = Klammer über mehrere Baustellen (z. B. „Wohnanlage Müller-Schule“ mit 3 Bauabschnitten). Felder: Name, Beschreibung, Start/Ende, Verantwortliche Bauleitung, Status (Angebot / Angenommen / In Arbeit / Abgeschlossen / Archiviert).
  • F-W-04 (Projekt-Budgets) — Budget in Stunden + Budget in Euro + Warnschwelle (% von Budget). Live-Forecast basierend auf bisherigem Verbrauch. Wenn Warnschwelle überschritten → Badge am Projekt + Benachrichtigung an Bauleitung. (V1-Scope — MVP hat nur das Feld, Forecast-Berechnung folgt in V1.)
  • F-W-05 (Baustellen-Verwaltung) — Adresse, Geo-Koordinaten, SOKA-Bau-Nr. (für Bau-Kunden), Kolonnen-Zuordnung, DGUV-Plakette-Ablauf. Listen-Filter: aktive / abgeschlossene / archivierte.
  • F-W-06 (Stundensätze pro Rolle/Mitarbeiter/Datum) — Tabelle hourly_rates mit valid_from/valid_to (SCD Type 2 — kein Update, neuer Datensatz). Zuweisbar pro Mitarbeiter, pro Rolle, pro Kunde-Override. Anzeige zeigt „effektiver Satz heute“ mit Vererbungs-Breadcrumb (Override > Mitarbeiter-Satz > Rollen-Default).
  • F-W-07 (Stundennachweis Bulk-Generierung) — Filter (Zeitraum, Projekt, Mitarbeiter, Status), Mehrfachauswahl, Aktion „Generieren & Versenden“. PDF-Generierung via Typst-Server (TECH-STACK §5.1), Versand via SES. Status-Tracking: Entwurf → Wartet-auf-Bauherr → Signiert → Versendet → In-Rechnung.
  • F-W-08 (Stundennachweis-PDF — Layout) — Kopf: Tenant-Logo, Tenant-Adresse, Bauherr-Adresse, Zeitraum, Projekt/Baustelle, Mitarbeiter. Tabelle: Tag, Start, Ende, Pause, Netto-Stunden, Tätigkeit. Fußbereich: Signatur-Platzhalter, Hash-Chain-Footprint („Dokument-Integrität: sha256: · Audit-Ref: “). Generator: Typst-Template stundennachweis.typ.
  • F-X-01 (Referenz-Integrität) — Kunde löschen → prüfe offene Baustellen+Zeit-Buchungen. Kaskaden-Löschung ist nicht erlaubt; stattdessen Soft-Delete via archived_at-Feld. GoBD verhindert harte Löschung von Abrechnungs-Bezügen.
  • F-X-02 (Dedup-Assistent) — Bei jeder Anlage prüfe Fuzzy-Match (Trigram-Index auf customers.name) und zeige ggf. „Ähnlicher Kunde vorhanden: Maler Schmidt GmbH — zusammenführen?“.
  • F-X-03 (Tenant-Hopping-Schutz) — Alle Lookups laufen über current_setting('app.tenant_id')-basierte RLS-Policies. API-Endpunkte antworten auf fremde IDs mit 404 (nicht 403 — Existenz nicht offenbaren; konsistent mit §12 Akzeptanzkriterium).
  • F-A-01 (Draft-Queue) — Web-Dashboard „Stammdaten-Nachpflege“: alle Quick-Create-Einträge mit draft_by_field = true. Buchhaltung arbeitet diese ab und setzt Flag auf false, sobald UStID + Debitorennr. + Zahlungsziel gepflegt sind.
  • F-A-02 (Import/Migration) — CSV-Import aus Excel, Clockodo, ZEP, Personio. Mapping-Oberfläche, Preview, Dedup-Check, Rollback-Transaktion. Teil des Onboarding-Wizards.
  • F-A-03 (§48b EStG-Erinnerung) — Admin-Einstellung „Erinnere mich an ablaufende Freistellungen“: 90/30/7 Tage vor Ablauf → Push + E-Mail. Ohne gültige Bescheinigung blockiert die Rechnungsstellung automatisch den Subunternehmer (konsistent mit FUNKTIONSUMFANG §4.8).
Anforderung-ID MVP V1 V1.5 V2
F-M-01 Listen-Browser
F-M-02 Favoriten
F-M-03 Detail-Screen
F-M-04 Quick-Create Kunde
F-M-05 Quick-Create Baustelle
F-M-06 Visitenkarten-OCR
F-M-07 Stundennachweis + Signatur
F-M-08 Stundennachweis-Versand
F-W-01 Kunden-Vollformular
F-W-02 UStID-Validierung
F-W-03 Projekt-Verwaltung
F-W-04 Projekt-Budgets
F-W-05 Baustellen-Verwaltung
F-W-06 Stundensätze (SCD2)
F-W-07 Bulk-Generierung
F-W-08 PDF-Layout (Typst)
F-A-01 Draft-Queue
F-A-02 Import-Wizard
F-A-03 §48b-Erinnerung

HTML-Hero-Mockup: 04-projekte-kunden-baustellen.html — Mobile (Hannes Krüger, Baustellen-Wechsel mit Visitenkarten-OCR) + Web (Sabine Maier, Stundennachweis-Bulk-Generierung).

┌────────────────────────────────┐
│ ← Baustellen [+] │
├────────────────────────────────┤
│ 🛡 GoBD · DSGVO │
│ │
│ 🔍 Suche Baustelle… │
│ │
│ ⭐ Favoriten │
│ ┌────────────────────────────┐ │
│ │📍 Lehrer Allee 7, München │ │
│ │ Kunde: Bgm. Müller-Schule │ │
│ │ Zuletzt: heute 07:02 │ │
│ └────────────────────────────┘ │
│ ┌────────────────────────────┐ │
│ │📍 Schulstraße 12, Köln │ │
│ │ Kunde: Maier GmbH │ │
│ │ Zuletzt: Fr 16.04. │ │
│ └────────────────────────────┘ │
│ │
│ Letzte 14 Tage │
│ ┌────────────────────────────┐ │
│ │📍 Werkstatt Augsburg │ │
│ │ Zuletzt: Mi 14.04. │ │
│ └────────────────────────────┘ │
│ │
│ [+ Neue Baustelle anlegen] │
├────────────────────────────────┤
│ [Zeit] [Plan] [Doku] [Mehr] │
└────────────────────────────────┘
┌────────────────────────────────┐
│ ← Neuer Kunde [✕] │
├────────────────────────────────┤
│ 📷 Karte in Rahmen ablegen │
│ ┌────────────────────────────┐ │
│ │ ┌──────────────┐ │ │
│ │ │ Müller GmbH │ │ │
│ │ │ H. Müller │ │ │
│ │ │ Tel 089/… │ │ │
│ │ └──────────────┘ │ │
│ │ (Live-Kamera-Vorschau) │ │
│ └────────────────────────────┘ │
│ │
│ [📷 Auslösen] [Manuell …] │
└────────────────────────────────┘
Nach Auslösen (on-device OCR ~0.4 s):
┌────────────────────────────────┐
│ ← Felder prüfen [✓] │
├────────────────────────────────┤
│ FIRMA │
│ [Müller Bauträger GmbH ]🔴│
│ ANSPRECHPARTNER │
│ [Heinrich Müller ] │
│ TELEFON │
│ [+49 89 4711-23 ] │
│ E-MAIL │
│ STRASSE │
│ [Lehrer Allee 7 ]🔴│
│ PLZ / ORT │
│ [80331] [München ]🔴│
│ │
│ ⚠ Ähnlich: „Müller & Sohn │
│ Bau KG, München" vorhanden │
│ [Nur neu] [Zusammenführen] │
│ │
│ [Speichern als Entwurf] │
└────────────────────────────────┘
┌────────────────────────────────┐
│ ← Stundennachweis [⋯] │
├────────────────────────────────┤
│ 🛡 GoBD · §14 UStG · DSGVO │
│ │
│ KW 16 · 12.–18.04.2026 │
│ Hannes Krüger │
│ Baustelle Lehrer Allee 7 │
│ │
│ ┌────────────────────────────┐ │
│ │ Mo 12.04. 8:00 h │ │
│ │ Di 13.04. 8:30 h ⚠ 9,5h │ │
│ │ Mi 14.04. Urlaub × │ │
│ │ Do 15.04. 8:00 h │ │
│ │ Fr 16.04. 6:15 h │ │
│ │ ───────────────────────── │ │
│ │ Gesamt: 30:45 h │ │
│ └────────────────────────────┘ │
│ │
│ ┌────────────────────────────┐ │
│ │ Hier unterschreiben │ │
│ │ (Bauherr / Auftraggeber) │ │
│ │ │ │
│ │ ───── ✍️ ───── │ │
│ └────────────────────────────┘ │
│ [✕ Löschen] │
│ │
│ [✓ Signiert bestätigen] │
└────────────────────────────────┘
┌────────────────────────────────────────────────────────────────────────────────┐
│ Werkszeit · musterbetrieb-maler Sabine Maier · Manager [Profil ▾]│
├────────────────────────────────────────────────────────────────────────────────┤
│ Sidebar │ Stundennachweise · KW 16 · 12.–18.04.2026 │
│ ───── │ 🛡 GoBD · §14 UStG · DSGVO │
│ Zeit │ ┌──────────────────────────────────────────────────────────────┐ │
│ Plan │ │ Filter: [Projekt ▼ Lehrer Allee 7] [Status ▼ alle] │ │
│ ▶ Stamm │ │ [Zeitraum ▼ KW 16] [Aktualisieren] │ │
│ ├ Kund. │ └──────────────────────────────────────────────────────────────┘ │
│ ├ Proj. │ │
│ └ Bau. │ 2 Nachweise ausgewählt · 87:30 h · Bauherr: Bgm.-Müller-Schule │
│ Doku │ [An Bauherr senden] [Freigeben] [⤓ PDF herunterladen] │
│ DATEV │ ┌──────────────────────────────────────────────────────────────┐ │
│ │ │☑ Mitarbeiter │Zeitraum │Status │Stunden │ PDF │ │
│ │ │☑ Hannes K. │KW 16 │Wartet Bauherr │42:15 │ ⤓ │ │
│ │ │☑ Mehmet Y. │KW 16 │Wartet Bauherr │45:15 │ ⤓ │ │
│ │ │☐ Frank D. │KW 16 │eAU │ 0:00 │ — │ │
│ │ │☐ Sabine M. │KW 16 │Entwurf │40:00 │ Entwurf │ │
│ │ └──────────────────────────────────────────────────────────────┘ │
│ │ │
└────────────────────────────────────────────────────────────────────────────────┘

6.5 Web — Kunden-Vollformular mit UStID-Prüfung

Abschnitt betitelt „6.5 Web — Kunden-Vollformular mit UStID-Prüfung“
┌────────────────────────────────────────────────────────────────────────────────┐
│ Kunden · Müller Bauträger GmbH [Abbrechen] │
├────────────────────────────────────────────────────────────────────────────────┤
│ Stammdaten §14 UStG DATEV §48b EStG Kontakte │
│ ═════════════════════════════════════════════════════════════════════════════ │
│ FIRMA USTID │
│ [Müller Bauträger GmbH ]🔴 [DE123456789 ] [Prüfen] │
│ ✓ BZSt-geprüft 19.04.26 · gültig │
│ STRASSE STEUERNUMMER │
│ [Lehrer Allee 7 ]🔴 [143/456/78901 ] │
│ PLZ ORT │
│ [80331]🔴 [München ]🔴 │
│ │
│ DEBITORENNUMMER (DATEV) ZAHLUNGSZIEL │
│ [10023 ] [14 Tage, 2 % Skonto bei 7 Tagen ▼] │
│ │
│ ⚠ Dieser Datensatz wurde mobil mit Minimal-Daten angelegt. Bitte ergänzen: │
│ ○ UStID fehlte → ergänzt ✓ │
│ ○ Debitorennummer fehlte → ergänzt ✓ │
│ ○ §48b EStG-Freistellung fehlt (nur Bau-Subunternehmer) → [Foto hochladen] │
│ │
│ [Speichern & zurück] │
└────────────────────────────────────────────────────────────────────────────────┘
┌────────────────────────────────────────────────────────────────────────────────┐
│ Stundensätze · Hannes Krüger [Neuer Satz] │
├────────────────────────────────────────────────────────────────────────────────┤
│ EFFEKTIVER SATZ HEUTE: 52,00 €/h (Override → Mitarbeiter → Rolle „Monteur") │
│ │
│ Gültig ab Gültig bis Satz Scope Quelle │
│ 01.01.2026 — 52,00 € Mitarbeiter-Override Admin SM │
│ 01.01.2025 31.12.2025 48,00 € Mitarbeiter-Override Admin SM │
│ 01.07.2024 31.12.2024 46,50 € Rolle „Monteur" System-Default│
│ │
│ ⚠ Historische Sätze werden nicht überschrieben — jeder Satz erzeugt eine │
│ neue versionierte Zeile. Abrechnungen werden zum gültigen Satz am │
│ Leistungsdatum rückwirkend konsistent bleiben (GoBD-Nachvollziehbarkeit). │
└────────────────────────────────────────────────────────────────────────────────┘

apps/api/src/db/schema/stammdaten.ts
export const customersTable = pgTable('customers', {
id: uuid('id').primaryKey().defaultRandom(), // UUID v7 (Idempotenz)
tenantId: uuid('tenant_id').notNull().references(() => tenantsTable.id),
name: text('name').notNull(),
legalForm: text('legal_form'), // GmbH, KG, GbR, e.K., Privat
street: text('street').notNull(),
zip: text('zip').notNull(),
city: text('city').notNull(),
country: text('country').notNull().default('DE'),
vatId: text('vat_id'), // EU-USt-IdNr
vatValidatedAt: timestamp('vat_validated_at', { withTimezone: true }),
vatValidationResult: jsonb('vat_validation_result'), // BZSt-Antwort (Cache 24h)
taxNumber: text('tax_number'), // Steuernummer (DE-Format)
datevDebitorNr: text('datev_debitor_nr'), // DATEV-Debitorennummer
paymentTermsDays: integer('payment_terms_days').default(14),
skontoPercent: numeric('skonto_percent', { precision: 4, scale: 2 }),
skontoDays: integer('skonto_days'),
ibanEnc: text('iban_enc'), // pgcrypto AES-256
freistellung48bPhotoS3Key: text('freistellung_48b_photo_s3_key'),
freistellung48bValidUntil: date('freistellung_48b_valid_until'),
draftByField: boolean('draft_by_field').notNull().default(false),
archivedAt: timestamp('archived_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
createdBy: uuid('created_by').notNull().references(() => usersTable.id),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
// Audit-Hash-Chain (siehe Feinkonzept §3.11)
hashPrev: bytea('hash_prev'),
hashSelf: bytea('hash_self'),
}, (t) => ({
tenantIdx: index('customers_tenant_idx').on(t.tenantId, t.archivedAt, t.name),
nameTrgm: index('customers_name_trgm_idx').using('gin', sql`${t.name} gin_trgm_ops`),
draftIdx: index('customers_draft_idx').on(t.tenantId).where(sql`draft_by_field = true`),
}));
export const projectsTable = pgTable('projects', {
id: uuid('id').primaryKey().defaultRandom(),
tenantId: uuid('tenant_id').notNull().references(() => tenantsTable.id),
customerId: uuid('customer_id').notNull().references(() => customersTable.id),
name: text('name').notNull(),
description: text('description'),
status: text('status').notNull().default('angebot'), // angebot|angenommen|in_arbeit|abgeschlossen|archiviert
startDate: date('start_date'),
endDate: date('end_date'),
budgetHours: numeric('budget_hours', { precision: 10, scale: 2 }),
budgetEuro: numeric('budget_euro', { precision: 12, scale: 2 }),
warnThresholdPercent: integer('warn_threshold_percent').default(80),
bauleitungUserId: uuid('bauleitung_user_id').references(() => usersTable.id),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
createdBy: uuid('created_by').notNull().references(() => usersTable.id),
hashPrev: bytea('hash_prev'),
hashSelf: bytea('hash_self'),
}, (t) => ({
tenantIdx: index('projects_tenant_customer_idx').on(t.tenantId, t.customerId),
}));
export const constructionSitesTable = pgTable('construction_sites', {
id: uuid('id').primaryKey().defaultRandom(),
tenantId: uuid('tenant_id').notNull().references(() => tenantsTable.id),
projectId: uuid('project_id').references(() => projectsTable.id), // nullable: Direkt-Zuordnung Kunde ohne Projekt
customerId: uuid('customer_id').notNull().references(() => customersTable.id),
label: text('label').notNull(),
street: text('street').notNull(),
zip: text('zip').notNull(),
city: text('city').notNull(),
geoLat: numeric('geo_lat', { precision: 9, scale: 6 }),
geoLng: numeric('geo_lng', { precision: 9, scale: 6 }),
sokaBauNr: text('soka_bau_nr'), // nur Bau-Tenants
active: boolean('active').notNull().default(true),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
createdBy: uuid('created_by').notNull().references(() => usersTable.id),
}, (t) => ({
tenantIdx: index('sites_tenant_customer_idx').on(t.tenantId, t.customerId, t.active),
geoIdx: index('sites_geo_idx').using('gist', sql`ll_to_earth(${t.geoLat}, ${t.geoLng})`),
}));
export const hourlyRatesTable = pgTable('hourly_rates', {
id: uuid('id').primaryKey().defaultRandom(),
tenantId: uuid('tenant_id').notNull().references(() => tenantsTable.id),
scope: text('scope').notNull(), // 'role' | 'user' | 'customer_override'
roleId: text('role_id'),
userId: uuid('user_id').references(() => usersTable.id),
customerId: uuid('customer_id').references(() => customersTable.id),
rateEuro: numeric('rate_euro', { precision: 8, scale: 2 }).notNull(),
validFrom: date('valid_from').notNull(),
validUntil: date('valid_until'), // nullable = open-ended, SCD Type 2
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
createdBy: uuid('created_by').notNull().references(() => usersTable.id),
hashPrev: bytea('hash_prev'),
hashSelf: bytea('hash_self'),
}, (t) => ({
effectiveIdx: index('rates_effective_idx').on(t.tenantId, t.userId, t.validFrom),
noOverlap: check('rates_no_overlap', sql`valid_until IS NULL OR valid_until > valid_from`),
}));
export const stundennachweisTable = pgTable('stundennachweis', {
id: uuid('id').primaryKey().defaultRandom(),
tenantId: uuid('tenant_id').notNull().references(() => tenantsTable.id),
userId: uuid('user_id').notNull().references(() => usersTable.id),
constructionSiteId: uuid('construction_site_id').notNull().references(() => constructionSitesTable.id),
periodStart: date('period_start').notNull(),
periodEnd: date('period_end').notNull(),
totalHours: numeric('total_hours', { precision: 8, scale: 2 }).notNull(),
status: text('status').notNull().default('entwurf'),
// entwurf | wartet_bauherr | signiert | versendet | abgerechnet | storniert
signatureSvg: text('signature_svg'), // SVG der Touch-Unterschrift (Bauherr)
signerName: text('signer_name'),
signedAt: timestamp('signed_at', { withTimezone: true }),
pdfS3Key: text('pdf_s3_key'), // S3 mit Object Lock (10 Jahre, §147 AO)
pdfSha256: bytea('pdf_sha256'),
emailedAt: timestamp('emailed_at', { withTimezone: true }),
emailedTo: text('emailed_to'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
createdBy: uuid('created_by').notNull().references(() => usersTable.id),
hashPrev: bytea('hash_prev'),
hashSelf: bytea('hash_self'),
});

RLS-Policy-Sketch (Pflicht für jede Tabelle — DOD §2.4).

ALTER TABLE customers ENABLE ROW LEVEL SECURITY;
-- Strikte Tenant-Isolation — gilt für ALLE Operations, keine Ausnahme.
CREATE POLICY customers_tenant_isolation ON customers
USING (tenant_id = current_setting('app.tenant_id')::uuid)
WITH CHECK (tenant_id = current_setting('app.tenant_id')::uuid);
-- Scope-Feingranularität (Lesen): Mitarbeiter mit Scope 'own' sehen nur Kunden,
-- bei denen sie als Team-Mitglied in mind. einer Baustelle beteiligt sind.
CREATE POLICY customers_scope_read ON customers FOR SELECT
USING (
tenant_id = current_setting('app.tenant_id')::uuid
AND (
current_setting('app.scope') = 'all'
OR (current_setting('app.scope') = 'team'
AND EXISTS (SELECT 1 FROM construction_sites cs
JOIN site_team_members stm ON stm.site_id = cs.id
WHERE cs.customer_id = customers.id
AND stm.user_id = current_setting('app.user_id')::uuid))
OR (current_setting('app.scope') = 'own'
AND EXISTS (SELECT 1 FROM time_entries te
JOIN construction_sites cs ON cs.id = te.construction_site_id
WHERE cs.customer_id = customers.id
AND te.user_id = current_setting('app.user_id')::uuid
AND te.created_at > now() - interval '90 days'))
)
);
-- Schreibschutz: Quick-Create (draft) darf jeder, Vollpflege nur Admin/Buchhaltung.
CREATE POLICY customers_draft_create ON customers FOR INSERT
WITH CHECK (
tenant_id = current_setting('app.tenant_id')::uuid
AND draft_by_field = true
);
CREATE POLICY customers_admin_update ON customers FOR UPDATE
USING (
tenant_id = current_setting('app.tenant_id')::uuid
AND current_setting('app.role') IN ('admin', 'buchhaltung')
);

Analoge Policies gelten für projects, construction_sites, hourly_rates, stundennachweis.

ER-Bezug. Referenziert tenants, users, site_team_members (aus §3.9-Feinkonzept), time_entries (aus §3.1-Feinkonzept). Wird referenziert von invoices, nachtraege, aufmass (aus Handwerks-Feinkonzepten V1).


Methode Pfad Auth-Scope Rate-Limit Idempotenz Beschreibung
POST /v1/customers customers:write Standard Idempotency-Key Pflicht Anlage (Quick-Create oder Vollform)
GET /v1/customers customers:read:<scope> Standard Liste mit Filter (?q=, ?draft=true)
GET /v1/customers/{id} customers:read:<scope> Standard Detail
PATCH /v1/customers/{id} customers:write Standard Idempotency-Key Partielle Mutation (Audit-Log Pflicht)
POST /v1/customers/validate-vat customers:write Privileged (10/min/Tenant) BZSt-UStID-Abgleich
POST /v1/customers/{id}/merge customers:admin Privileged Idempotency-Key Dedup-Merge (append-only, Ziel-ID bleibt)
POST /v1/projects projects:write Standard Idempotency-Key Anlage
GET /v1/projects projects:read:<scope> Standard Liste
GET /v1/projects/{id}/budget projects:read:<scope> Standard Budget-Stand (Ist vs. Soll, Forecast)
POST /v1/construction-sites sites:write Standard Idempotency-Key Anlage (Quick-Create auch offline)
GET /v1/construction-sites/nearby sites:read:<scope> Standard ?lat=,?lng=,?radius_km= — für GPS-Vorschlag Mobile
POST /v1/hourly-rates rates:admin Standard Idempotency-Key Neuer Satz (versioniert, SCD Type 2)
GET /v1/hourly-rates/effective rates:read Standard ?user_id=,?date= → effektiver Satz
POST /v1/stundennachweis stundennachweis:write Standard Idempotency-Key Generieren (erzeugt Typst-PDF, S3-Object-Lock)
POST /v1/stundennachweis/{id}/sign stundennachweis:sign Privileged Idempotency-Key Touch-Signatur setzen (Hash-Chain-Entry)
POST /v1/stundennachweis/{id}/send-email stundennachweis:send Privileged (20/h/User) Idempotency-Key SES-Versand
POST /v1/stundennachweis/bulk-generate stundennachweis:bulk Privileged (5/min/Tenant) Idempotency-Key Web-Bulk-PDF, Queue via BullMQ

OpenAPI-Schema-Skizze (gekürzt).

paths:
/v1/customers:
post:
operationId: createCustomer
x-werkszeit-scope: customers:write
x-werkszeit-rate-limit: standard
parameters:
- name: Idempotency-Key
in: header
required: true
schema: { type: string, format: uuid }
requestBody:
content:
application/json:
schema:
oneOf:
- $ref: '#/components/schemas/CustomerQuickCreate' # draftByField=true, nur 5 Felder
- $ref: '#/components/schemas/CustomerFull' # alle §14-UStG-Felder
responses:
'201': { content: { application/json: { schema: { $ref: '#/components/schemas/Customer' } } } }
'409': { description: 'Idempotency conflict' }
'422': { description: 'Validation (e.g. duplicate detected without merge-token)' }
/v1/customers/validate-vat:
post:
operationId: validateVat
x-werkszeit-scope: customers:write
x-werkszeit-rate-limit: privileged # 10/min/Tenant (BZSt-Fair-Use)
requestBody:
content:
application/json:
schema:
type: object
required: [vatId, name, street, zip, city]
properties:
vatId: { type: string, pattern: '^[A-Z]{2}[0-9A-Z]+$' }
name: { type: string }
street: { type: string }
zip: { type: string }
city: { type: string }
responses:
'200':
content:
application/json:
schema:
type: object
properties:
valid: { type: boolean }
validatedAt: { type: string, format: date-time }
bzstRequestId: { type: string }
mismatches: { type: array, items: { type: string } }

Webhook-Events.

  • customer.created (payload enthält draftByField-Flag)
  • customer.updated
  • customer.merged (alt-id, neu-id)
  • project.created, project.budget.threshold_exceeded
  • construction_site.created, construction_site.archived
  • stundennachweis.created, stundennachweis.signed, stundennachweis.sent
  • hourly_rate.created

Profil Begründung Mechanik
Voll-offline — Quick-Create Kunde/Baustelle, Favoriten, Detail-Browser, Visitenkarten-OCR, Stundennachweis erzeugen + Signatur Der Monteur darf nie in der Keller-Baustelle an „kein Empfang“ scheitern. Drift-SQLite hält customers, projects, construction_sites, hourly_rates, stundennachweis vollständig für die letzten 90 Tage + Favoriten. Mutationen landen in outbox_entries mit UUID-v7-Idempotency-Key. On-device OCR via ML Kit / Apple Vision (TECH-STACK §3.2). PDF wird offline via printing-Package aus Flutter client-seitig gerendert, serverseitig neu-gerendert bei Sync (Typst als Ground-Truth).
Best-effort-offline — UStID-Validierung BZSt-Abgleich braucht Online-Verbindung. UI zeigt „BZSt-Abgleich ausstehend — wird bei Netz nachgeholt“. Bis zur Validierung gilt vat_validated_at = NULL.
Online-only — Bulk-Stundennachweis-Generierung, SES-Versand, §48b-Foto-Upload Queue-Worker (BullMQ) und S3-Multipart brauchen Netz; kein sinnvoller Offline-UX. UI blockt Button bei connectivity == none mit Erklärung.

Konflikt-Strategie. Append-only: Mutation = neuer versionierter Eintrag mit Begründungs-Feld. Konkret: wird ein Kundensatz offline auf Gerät A und online auf Gerät B geändert, syncht A später nach. Der Server vergleicht hash_prev — stimmt die Kette nicht, wird der A-Eintrag als Konflikt-Branch gespeichert, Admin bekommt ein Merge-Ticket im Web. Kein Last-Write-Wins (LESSONS-LEARNED §4).

Foto-Sync. Visitenkarten-Foto und §48b-Bescheinigung gehen per S3-Multipart über presigned URL. Drift hält nur Thumbnail + s3_key-Referenz. Limit pro Gerät: 500 MB Foto-Cache vor Sync-Erzwingung (TECH-STACK §6).


  • Append-only für abrechnungsrelevante Felder: hourly_rates und stundennachweis sind nicht mutierbar. Korrekturen erzeugen neuen Satz mit valid_until auf dem alten und neuem valid_from — oder einen Storno-Nachweis mit Verweis.
  • Hash-Chain: hash_prev/hash_self auf customers, projects, hourly_rates, stundennachweis. Details in Feinkonzept §3.11.
  • Aufbewahrung: Stundennachweis-PDFs in S3 mit Object Lock (Compliance-Mode, 10 Jahre — §147 AO Abs. 3). Customer-Kernfelder (Firma, Adresse, UStID) sind Zuordnungs-Bezug zur Rechnung und bleiben min. 10 Jahre nach letzter Rechnung referenzierbar.
  • Verfahrensdokumentation: Textbaustein „Stammdaten-Pflege und Stundennachweis“ in gobd-verfahrensdoku.md (verwaltet im Compliance-Feinkonzept §3.11).

— nicht zutreffend, weil dieses Feature keine Arbeitszeiten erfasst. ArbZG-Prüfung liegt im Zeiterfassungs-Feinkonzept §3.1. Stundennachweis übernimmt die bereits geprüften time_entries — werden dort ArbZG-Warnungen markiert, erscheinen sie im PDF als Zusatzzeile („Hinweis: 15.04. 10:30 h — ArbZG-Markierung gesetzt“).

— teilweise. Der Stundennachweis ist kein VOB-Nachtrag und ersetzt keinen VOB-Abschlags-/Schlussrechnungsprozess. Er ist Stundenzettel-Äquivalent (§631 ff. BGB). VOB-Nachtrag liegt in Handwerks-Feinkonzept §4.2 (separat).

  • Art. 5 Datenminimierung: Ansprechpartner-Feld „Funktion“ ist optional. Keine Geburtstage, keine Privat-Telefonnummer — wir brauchen nur dienstliche Kontaktdaten.
  • Art. 6 Rechtsgrundlage: Vertrag (Art. 6 Abs. 1 lit. b — Kunden-Daten zur Auftragsabwicklung) + berechtigtes Interesse (lit. f — Dedup-Prüfung, Betrugsvermeidung).
  • Art. 17 Löschung: Kunden-Löschung ist Soft-Delete (archived_at). Konflikt mit GoBD: Solange abrechnungsrelevante Bezüge (Rechnungen, Stundennachweise) bestehen, gilt §147 AO (10 Jahre) vor Art. 17. Gelöst wird der Konflikt durch Maskierung statt Löschung: Ansprechpartner-Name/Telefon/E-Mail werden auf Anfrage überschrieben (redacted_at + redacted_reason), Firma+Adresse+UStID bleiben für die Rechnungs-Buchhaltung erhalten. Detaillierte Mechanik siehe Feinkonzept §3.11 §10.4.
  • Art. 20 Datenexport: Kunden-Ansprechpartner kann eigenen Datensatz exportieren → JSON mit Feldern Name, Funktion, Telefon, E-Mail, zugeordnete Projekte (Titel, Datum, aber nicht andere Beteiligte). Implementiert im §3.11-Feinkonzept.
  • Art. 30 Verarbeitungsverzeichnis: Verarbeitung „Stammdaten Kunden/Projekte/Baustellen“ mit Zweck „Auftragsabwicklung und Rechnungsstellung“, Empfänger-Kategorien „Steuerberater via DATEV-Export, Bauherr via Stundennachweis-E-Mail“.
  • DSFA: Nicht Pflicht — Verarbeitung geht nicht über normale Geschäftskontakt-Pflege hinaus, keine Scoring-, keine besondere Datenkategorie gem. Art. 9.

— nicht zutreffend, weil Stammdaten-Pflege kein öffentlich zugänglicher ESS-Flow ist (Web-Admin-Pfad hinter Auth). Die Mobile-Flows (Quick-Create, Signatur) sind durch das Zeiterfassungs-Feinkonzept flächig im A11y-Test-Harness abgedeckt — Focus-Order, Screen-Reader-Labels (Semantics-Widgets), Kontrast ≥ 4.5:1 (Design-System-Tokens --wz-text auf --wz-paper = 15.3:1).

— nicht zutreffend. Stammdaten enthalten keine Mitarbeiter-Verhaltens- oder Leistungsüberwachung. Die Baustelle ist ein Kostenträger, nicht eine Tracking-Einheit.

  • Vollformular erzwingt vor Freigabe: Firma/Name des Leistungsempfängers, vollständige Anschrift, UStID (B2B) ODER Steuernummer, Zahlungsbedingungen. Fehlen diese, kann aus dem Kunden keine Rechnung erzeugt werden (Backend-Validator blockiert POST /v1/invoices).
  • Optionales Feld freistellung_48b_photo_s3_key + freistellung_48b_valid_until. Wenn der Kunde ein Auftraggeber ist, bei dem Werkszeit-Tenant als Subunternehmer Bauleistungen erbringt, und keine gültige Freistellung existiert → Rechnung automatisch 15 % Bauabzugssteuer. Diese Logik greift im Rechnungs-Feinkonzept, hier liegt nur die Datenhaltung.

# Szenario Erwartetes Verhalten
EC-01 Zeitzonenwechsel (Kunde mit Adresse in Österreich) Tenant-Zeitzone bleibt relevant (Rechnungsdatum). Customer-Adresse hat country-Feld. Für Ö = 20 % USt + MwSt-Einstellung (wird in Rechnungs-Modul gelöst). Hier nur Speicherung.
EC-02 Multi-Tenant-Bleed-Versuch (User in Tenant A sucht per ID Kunde aus Tenant B) API antwortet 404, nicht 403 (Existenz nicht offenbaren). Kein Audit-Log-Eintrag in Tenant B. Integrationstest Pflicht (DOD §2.2).
EC-03 Sync-Konflikt (Kunde offline-modifiziert auf Gerät A + online auf Web) Server detektiert Hash-Chain-Bruch, erzeugt Konflikt-Eintrag. Admin bekommt Merge-Ticket mit Diff-View. Nutzer sehen Version des Servers, bis Admin entscheidet.
EC-04 Großmenge (Manager fordert Stundennachweis-Bulk für 200 Mitarbeiter × 4 Wochen = 800 PDFs) Job landet in BullMQ-Queue, Fortschritts-Indikator im UI, fertige PDFs als ZIP per S3 presigned URL. Serverseitiges Rate-Limit 5/min/Tenant (DDoS-Schutz).
EC-05 Abgelaufenes §48b-Zertifikat (Kunde-Bauleister mit abgelaufener Freistellung) Kunde bleibt anlegbar, aber Rechnungserstellung (im Invoice-Feinkonzept) markiert automatisch +15 % Bauabzugssteuer und zeigt Compliance-Warnung „§48 EStG Abs. 1 greift — Bauabzugssteuer einbehalten“.
EC-06 Plan-Limit erreicht (Starter-Plan erlaubt 50 aktive Kunden; 51. wird angelegt) Backend-Route blockiert mit HTTP 402 + Redirect zu Upgrade-Flow. Kein Daten-Verlust (Mobile-Outbox hält Mutation, bis Tenant upgegradet — oder Admin löscht alten).
EC-07 Rollen-Demotion mid-action (Admin startet Bulk-Generierung, verliert Rolle mitten drin) BullMQ-Worker prüft Rolle bei Job-Pickup UND vor jedem einzelnen PDF-Typst-Render. Nach Demotion: Rest-Batch pausiert, Ticket an Tenant-Admin.
EC-08 Visitenkarten-OCR-Fehlerkennung (Firma = “GmbH”, Name in Firmen-Feld) User kann alle Felder vor Speichern editieren. Wenn Felder nach OCR leer, Hinweis „Manuell ergänzen“. OCR-Confidence-Score < 0.6 → Feld rot, Pflicht zur Prüfung.
EC-09 Dedup-Treffer mit nur Trigram-Similarity (Firma „Maler Schmidt GmbH“ + „Maler-Schmidt GmbH“) Server-Side Fuzzy-Query (pg_trgm % mit Threshold 0.7). Bei Treffer → UI-Dialog mit Seitenvergleich, User wählt „Zusammenführen“ (alter ID behalten, neue Felder übernommen) oder „Beide behalten“ (Flag gesetzt, Admin-Review).
EC-10 UStID-Validierung BZSt-Ausfall BZSt nicht erreichbar → Status pending, Retry-Plan (exponential backoff 5 min / 15 min / 1 h / 6 h). Admin bekommt Dashboard-Benachrichtigung nach 4 fehlgeschlagenen Retries. Rechnung darf trotzdem gestellt werden, aber mit Warnung „UStID nicht validiert“.

Funktionalität: Projekte, Kunden & Baustellen — Quick-Create + Stundennachweis
Hintergrund:
Angenommen ein Tenant "shk-gebruder-schmidt" mit aktivem Modul-Gate "module.kern.stammdaten"
Und ein Bauleiter "Thomas Schmidt" mit Rolle "Bauleiter" und Scope "team"
Und eine Baustelle "Schulstraße 12, 50667 Köln" im Tenant existiert
Szenario: Happy Path — Quick-Create Baustelle im Feld, offline, dann Sync
Angenommen Thomas ist mit dem Phone offline (Flight-Mode an)
Wenn Thomas auf "+ Neue Baustelle" tippt
Und das Formular mit "Hauptstr. 5, 50667 Köln, Kunde: Maier GmbH" füllt
Und "Speichern" drückt
Dann zeigt die App "Gespeichert — wird synchronisiert" an
Und ein Eintrag in Drift-Outbox mit Idempotency-Key <UUID-v7> existiert
Wenn Thomas' Gerät 10 Minuten später wieder online ist
Dann wird die Baustelle zum Server synchronisiert
Und ein Audit-Log-Eintrag "construction_site.created" mit actor="Thomas Schmidt" wird erzeugt
Und die Hash-Chain der construction_sites-Tabelle ist intakt
Szenario: Grenzfall — Tenant-Hopping-Versuch
Angenommen ein zweiter Tenant "musterbetrieb-maler" mit Kunde-UUID <C-X>
Wenn Thomas (Tenant shk-gebruder-schmidt) versucht, GET /v1/customers/<C-X> aufzurufen
Dann antwortet die API mit HTTP 404 (NICHT 403)
Und es entsteht KEIN Audit-Log-Eintrag in Tenant musterbetrieb-maler
Und der Multi-Tenant-Integrationstest (DOD §2.2) failt den CI-Lauf, wenn diese Assertion fehlt
Szenario: Grenzfall — DSGVO-Löschung vs. GoBD-Aufbewahrung
Angenommen ein Kunde "Müller GmbH" mit 3 Stundennachweisen und 1 Rechnung aus 2024
Und der Ansprechpartner "H. Müller" fordert DSGVO Art. 17-Löschung seiner Daten
Wenn die Admin-UI "Daten maskieren" drückt
Dann werden die Felder name, phone, email des Ansprechpartners überschrieben (redacted_at gesetzt)
Aber die Kunden-Firma, -Adresse und -UStID bleiben unverändert (GoBD §147 AO, 10 Jahre)
Und ein Audit-Log-Eintrag "customer_contact.redacted" mit Begründung erscheint
Und der DSGVO-Art.-20-Export für diesen Ansprechpartner ist ab sofort leer
Szenario: Grenzfall — Stundennachweis vor Ort ohne Netz, Bauherr unterschreibt, App speichert offline
Angenommen Thomas ist auf Baustelle Schulstraße 12 und offline
Und eine Woche mit 5 Zeit-Einträgen (total 42:15 h) im Drift-Cache liegt
Wenn Thomas auf "Stundennachweis erstellen" tippt
Und der Bauherr "Hr. Müller-Bgm." auf dem Touch-Pad unterschreibt
Dann erzeugt die App lokal ein PDF via printing-Package
Und speichert signature_svg, signer_name, signed_at in Drift
Und ein Outbox-Entry "stundennachweis.sign" mit Idempotency-Key wird angelegt
Wenn das Gerät wieder online ist
Dann überträgt die Outbox den Eintrag
Und der Server rendert das Ground-Truth-PDF via Typst
Und legt es in S3 mit Object Lock (10 Jahre) ab
Und setzt pdf_sha256 + pdf_s3_key
Und ein Audit-Log-Eintrag "stundennachweis.signed" mit SHA-256-Footprint erscheint
Szenario: Grenzfall — Dedup-Treffer bei Visitenkarten-OCR
Angenommen Sabine scannt eine Visitenkarte "Maler Schmidt GmbH, Lehrer Allee 7, München"
Und in der Kunden-Tabelle existiert bereits "Maler-Schmidt GmbH, Lehrer Allee 7, München"
Wenn die OCR-Felder extrahiert sind
Dann prüft das Backend mit pg_trgm (Similarity > 0.7)
Und zeigt einen Dialog "Ähnlicher Kunde — zusammenführen oder neu?"
Und Sabine wählt "Zusammenführen"
Dann bleibt die alte Kunde-ID, fehlende Felder (z.B. Telefon) werden ergänzt
Und ein Audit-Log "customer.merged" mit Verweis auf OCR-Quelle erscheint
Und der neue Datensatz wird NICHT erzeugt (keine Dublette)
Szenario: Grenzfall — Plan-Limit (Starter-Plan, 51. Kunde)
Angenommen der Tenant "musterbetrieb-maler" hat Plan "Starter" mit Limit 50 aktive Kunden
Und bereits 50 Kunden existieren mit archived_at IS NULL
Wenn Sabine einen 51. Kunden anlegt
Dann antwortet die API mit HTTP 402 Payment-Required
Und UI zeigt Modal "Plan-Limit erreicht → Upgrade auf Business"
Und die Offline-Outbox behält die Mutation für bis zu 7 Tage (nach Upgrade Auto-Submit)

  • U-01 Validierungsregel: UStID-Prefix passt zu country (DE123… nur wenn country=DE, ATU… nur AT).
  • U-02 Dedup-Similarity: Trigram-Score zwischen zwei Namen in vordefinierter Test-Matrix.
  • U-03 SCD-Type-2-Logik für hourly_rates: neuer Satz setzt valid_until auf vorigen Datensatz automatisch.
  • U-04 Property-based: 500 zufällig generierte Visitenkarten-Texte → OCR-Parser extrahiert nie leere Pflichtfelder ohne Confidence < 0.6-Markierung.
  • U-05 Hash-Chain-Integrität: nach Patch wird hash_self korrekt aus hash_prev + canonical_row berechnet.
  • W-01 Quick-Create-Form Disabled-State wenn Pflichtfelder leer.
  • W-02 Golden-Test: Stundennachweis-Detail-Screen in de-DE + Light/Dark.
  • W-03 Signature-Pad Clear-Button leert Canvas und setzt State signed=false.
  • W-04 Dedup-Dialog zeigt beide Datensätze in Diff-View.

13.3 Integrations-Tests (Drizzle + Testcontainers)

Abschnitt betitelt „13.3 Integrations-Tests (Drizzle + Testcontainers)“
  • I-01 Multi-Tenant-Isolation: Tenant A legt Kunde K an, Tenant B kann K weder per GET, PATCH, DELETE noch über Projekt-Referenz ansprechen (DOD §2.2).
  • I-02 RLS Scope-own: Monteur sieht nur Kunden, mit deren Baustellen er in den letzten 90 Tagen eine Zeit-Buchung hatte.
  • I-03 Outbox-Idempotenz: doppelter Idempotency-Key → 409, kein zweiter DB-Eintrag.
  • I-04 Dedup-Merge: zwei Kunden mergen → alt-ID bleibt, neue Felder übernommen, customer.merged-Audit-Eintrag mit alt_id+neu_id.
  • I-05 Hash-Chain zerbricht nicht bei parallelen INSERTs (Advisory Lock pro Tabelle+Tenant).
  • I-06 UStID-Validierung: BZSt-Mock liefert Mismatches → vat_validation_result.mismatches enthält die Felder.
  • E-01 Happy Path Mobile: Hannes legt Baustelle offline an, geht online, Sync erfolgreich, Baustelle im Web sichtbar.
  • E-02 Visitenkarten-OCR auf iOS Simulator mit Test-Foto → 5 Felder befüllt + Dedup-Dialog erscheint.
  • E-03 Stundennachweis-Signatur Mobile + Web-Review-Freigabe.
  • E-04 Bulk-Stundennachweis: Web generiert 5 PDFs, versendet per SES (Mailhog-Test), Status-Updates live via SSE.
  • E-05 Visuelle Regression: Kunden-Vollformular in de-DE light/dark.
  • E-06 Accessibility: Web-Kunden-Formular axe-core ohne Critical-Findings; Mobile-Quick-Create VoiceOver-Durchlauf.
  • C-01 Hash-Chain-Integrität: Mutation eines Kundensatzes in Rohform (per Drizzle ohne API-Service) bricht hash_self-Prüfung im Verifier.
  • C-02 GoBD-Append-only: DELETE auf stundennachweis scheitert mit DB-Trigger-Exception.
  • C-03 DSGVO-Art.-17-Maskierung: nach Redact sind Ansprechpartner-Felder leer, aber Stundennachweise der letzten 10 Jahre weiter verknüpfbar.
  • C-04 DSGVO-Art.-20-Export: JSON enthält alle Felder des Ansprechpartners, keine fremden Daten.
  • C-05 S3-Object-Lock-Nachweis: Versuch, ein Stundennachweis-PDF via S3-API zu löschen → 403 vor Ablauf der Retention.
  • Lokaler 20×-Re-Run der neuen Patrol-E2E grün (DOD §2.2).

Nicht-Ziel Begründung
CRM mit Pipeline / Opportunities Werkszeit ist kein HubSpot — Stammdaten sind Abwicklungs-Register, nicht Vertriebspipeline.
Bauherr-Portal (Self-Service-Login für Auftraggeber) Hinzufügbar, aber nur mit zahlendem Referenzkunden (DOR §1.1.1). Bis dahin E-Mail-Versand ausreichend.
Automatische Adress-Vervollständigung via Google Places US-Provider-Abhängigkeit, DSGVO-Eigentor. Alternative: Deutsche Post Datafactory über Partner-API — Prüfung in V2.
Bonitäts-Prüfung / Creditreform-Integration Über Scope; Buchhaltung macht das extern. Optional in V2 als Add-On.
Fuhrpark-Management (Fahrzeug = Baustelle?) Basis-Fahrtenbuch im Handwerks-Modul, kein eigenes Fuhrpark-Modul.
Multi-Currency Phase 1 DE-Markt = EUR. Ö/CH-Tenants: offen für V2 mit FX-Modul.
Erweiterte GAEB-LV-Integration bei Projekt GAEB liegt in Handwerks-Feinkonzept §4.3. Hier nur Referenz-Feld gaeb_lv_id vorbereitet.

Risiko / Annahme Impact Wahrscheinlichkeit Gegenmaßnahme
BZSt-UStID-SOAP-API hat Fair-Use-Rate-Limit, das wir nicht kennen mittel mittel Prototyp gegen BZSt-Sandbox, eigenes Rate-Limit 10/min/Tenant + Queue bei Überschreitung
Visitenkarten-OCR-Qualität bei schlechtem Licht auf Baustelle mittel hoch Fallback-Button „Manuell“ prominent + Confidence-Score rot markieren bei <0.6
DSGVO vs. GoBD: Maskierung vs. Löschung juristisch angreifbar hoch niedrig Rechtsanwalts-Konsultation eingeplant; Verfahrensdoku-Text mit Rechtsverweis §147 AO + Art. 17 Abs. 3 DSGVO (Aufbewahrungspflichten-Ausnahme)
Typst-PDF-Rendering-Latenz bei Bulk 200+ PDFs mittel mittel Queue via BullMQ, max 5 parallele Render-Worker, Progress-SSE im UI; Last-Test vor V1
Dedup-False-Positives bei häufigen Firmennamen („Schmidt GmbH“) niedrig hoch Similarity-Threshold default 0.7 + Adress-Bestandteile als Tiebreaker + User-Override „Beide behalten“
SCD-Type-2-Historie macht Stundensatz-UI verwirrend mittel mittel UI zeigt immer nur den effektiven Satz + Verlaufs-Drawer auf Klick; Admin kann Zukunfts-Sätze planen

  • Vorbedingung: kern/09-auth-self-service (Tenant-Setup, RLS-Context-Variables, Rollen); kern/03-zeiterfassung (nur für Stundennachweis-Konsumption — die Zeit-Buchungen selbst liegen dort).
  • Schnittstelle zu: kern/11-compliance-audit (Audit-Log + Hash-Chain), kern/05-rechnungen (Rechnungsstellung konsumiert Kunden + Stundennachweise), handwerk/04-bautagebuch (Baustellen-Referenz), handwerk/10-sub-vergabe (§48b-Freistellung aus Kunde).
  • Wird konsumiert von: praktisch allen operativen Feinkonzepten, die einen Kostenträger brauchen. Kunden/Projekte/Baustellen sind die „Foreign-Key-Hubs“ des Systems.

Status: TBD

Kandidaten-Profile:

  • Malerbetrieb 15–30 MA in Bayern/BW, heute Excel+DATEV-Direkt. Pain: Stundennachweis freitags 45 min pro MA, Bauherr unterschreibt erst beim nächsten Termin → Streitpotenzial.
  • SHK-Betrieb 60–100 MA in NRW, hat ERP (pds/Sander & Doll), aber nur im Büro. Pain: Monteure haben keine Stammdaten im Feld, Zuordnung passiert abends am PC → fehlerhaft.

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

  1. Wie viele neue Kunden/Baustellen legen Sie pro Woche an? Wie lange dauert jeweils die Anlage?
  2. Wie oft passiert es, dass ein Monteur auf „Sonstiges/Intern“ stempelt statt auf den richtigen Kunden?
  3. Wie lange dauert die Erstellung+Versand der wöchentlichen Stundennachweise heute?
  4. Welche Pflichtfelder in Ihrem DATEV-Debitorenstamm sind nicht-verhandelbar?
  5. Haben Sie Erfahrung mit BZSt-UStID-Abgleich? Wäre eine integrierte Prüfung ein Kauf-Argument?
  6. Wären Sie bereit, die Visitenkarten-OCR im Beta-Status für 30 Tage gegen 50 % Rabatt auf Business-Plan zu testen?

Build-Sequenz (innerhalb dieses Features).

  1. Datenmodell + RLS-Policies + Multi-Tenant-Integrationstest (Woche 1). Ohne dieses Fundament ist jede weitere Zeile Code ein Hebel für Datenlecks.
  2. Backend-API für customers, projects, construction_sites inkl. Idempotency + OpenAPI-Spec (Woche 1–2). Dart-Client muss grün bauen.
  3. Mobile Quick-Create-Flow mit Drift-Outbox (Woche 2–3). Diese eine User-Journey ist das kommerzielle Verkaufsargument — hier muss die Bedienung sitzen.
  4. Stundennachweis-Generator (Typst-Template + PDF-Endpoint + S3-Object-Lock) (Woche 3–4).
  5. Mobile-Signatur-Flow mit offline-fähigem PDF (Woche 4).
  6. Web-Kunden-Vollformular mit §14-UStG-Validierung + BZSt-Prüfung (Woche 5).
  7. Bulk-Stundennachweis-Pipeline (Queue + SES) (Woche 6 — V1, nicht MVP).
  8. Visitenkarten-OCR mit ML Kit / Apple Vision + Dedup-UI (Woche 6–7 — priorisieren nach Referenzkunden-Nachfrage).
  9. Compliance-Tests gegen Mock-BZSt + Hash-Chain-Verifier (durchgängig, nicht am Ende).

Risiko-Reihenfolge (was zuerst absichern).

  • RLS + Tenant-Isolation — nicht verhandelbar. Wenn die Multi-Tenant-Integration-Tests nicht ab Tag 1 scharf sind, kann dieses Feature nie produktiv gehen.
  • Append-only + Hash-Chain — Stundennachweise und Stundensätze sind GoBD-pflichtig; ein Rollback auf „Update-in-place“ wäre später ein Audit-Finding.
  • Visitenkarten-OCR ist das erste Streich-Kandidat, falls Zeit knapp wird — Quick-Create manuell reicht für MVP.

Stop-the-Bus-Triggers.

  • Multi-Tenant-Bleed in einem Test.
  • Hash-Chain-Bruch beim Kunden-Merge.
  • DSGVO-Art.-17-Anfrage scheitert an fehlender Maskierungs-Strategie (GoBD-Aufbewahrungspflicht blockiert Löschung) — muss durch die in §10.4 skizzierte Mechanik abgefangen werden, bevor das Feature live geht.
  • Stundennachweis-PDF lässt sich aus S3 löschen (Object-Lock-Config fehlerhaft).

Was uns 2027 dankbar macht.

  • Idempotency-Keys auf jeder Mutation ab Tag 1 — wenn Mobile-Sync irgendwann komplex wird (Konflikte, Retries, Multi-Device), ist das Fundament schon da.
  • SCD-Type-2-Stundensätze ab MVP — das spart uns die „wieso ist der alte Stundennachweis jetzt mit neuem Satz gerechnet?“-Diskussion mit dem Steuerberater.
  • BZSt-Validierung als eigene Queue — wenn V2 einen zweiten amtlichen Abgleich braucht (z. B. BG-Bau-Mitgliedsnummer), ist das Pattern etabliert.

Letzte Aktualisierung: 2026-04-19. Änderungen erfordern PR-Review durch Tech-Lead + Product-Owner sowie Koordination mit den Feinkonzepten §3.9 (Auth-Scopes) und §3.11 (Audit-Log-Events).

Für Entwickler — API-Endpoints20
MethodePfadAuthZweck
GET/v1/kern/construction-sitesbearerAuthBaustellen auflisten
POST/v1/kern/construction-sitesbearerAuthBaustelle anlegen
GET/v1/kern/construction-sites/{id}bearerAuthBaustelle (Detail)
PATCH/v1/kern/construction-sites/{id}bearerAuthBaustelle ändern
GET/v1/kern/customersbearerAuthKunden auflisten
POST/v1/kern/customersbearerAuthKunden anlegen (Quick-Create oder Vollform)
GET/v1/kern/customers/{id}bearerAuthKunden-Detail
PATCH/v1/kern/customers/{id}bearerAuthKunden-Felder pflegen
POST/v1/kern/customers/{id}/redact-contactbearerAuthDSGVO Art. 17 — Kontakt maskieren
GET/v1/kern/hourly-ratesbearerAuthStundensätze auflisten (Historie)
POST/v1/kern/hourly-ratesbearerAuthNeuen Stundensatz anlegen (SCD Type 2)
GET/v1/kern/hourly-rates/effectivebearerAuthEffektiver Stundensatz zu Datum
GET/v1/kern/projectsbearerAuthProjekte auflisten
POST/v1/kern/projectsbearerAuthProjekt anlegen
GET/v1/kern/projects/{id}bearerAuthProjekt (Detail)
PATCH/v1/kern/projects/{id}bearerAuthProjekt ändern
GET/v1/kern/stundennachweisbearerAuthStundennachweise auflisten
POST/v1/kern/stundennachweisbearerAuthStundennachweis erzeugen
GET/v1/kern/stundennachweis/{id}bearerAuthStundennachweis (Detail)
POST/v1/kern/stundennachweis/{id}/signbearerAuthTouch-Signatur setzen