Zum Inhalt springen

Mobiles Lager & Material-Management — Fahrzeug-Tag, Scan, OCI-Punchout

Beta — in Erprobung

Feinkonzept — Mobiles Lager & Material-Management (§4.6)

Abschnitt betitelt „Feinkonzept — Mobiles Lager & Material-Management (§4.6)“

Senior-Consultant-Einordnung. Dieses Feinkonzept macht das Service-Fahrzeug zum buchhalterisch korrekten Lagerort. Wir bewegen uns damit direkt im GoBD-Pfad (jede Entnahme ist ein Warenbewegungs-Beleg) und im §13b-UStG-Pfad (Material-Beistellungen an Bauleistungs-Subunternehmer). Wer hier „Last-Write-Wins“ macht oder einen UUID-v4-Key verwendet, baut sich einen Audit-Befund. Wir wählen ausdrücklich CRDT-artige Mengen-Semantik + Server-autoritative Resolution, weil Offline-Doppelentnahme in diesem Modul nicht die Ausnahme, sondern der Normalfall ist.


feature_id: handwerk/06-mobiles-lager-material
title: Mobiles Lager & Material-Management — Fahrzeug-Tag, Scan, OCI-Punchout
funktionsumfang_ref: §4.6
roadmap_horizont: V2
plattformen:
mobile: vollständig
web: vollständig mit-Bulk
desktop: aus
owner_rolle: Mitarbeiter # Entnahme | Manager (Bestellung, Freigabe) | Admin (Stammdaten, Händler-Integration) | Einkauf (Rahmenverträge)
modul_gate_flag: module.material
compliance_flags:
gobd: true
arbzg: false
vob: false
dsgvo: true
bfsg: false # kein ESS-Flow — reine Monteur-/Manager-Funktion
betrvg: false # keine Leistungs-/Verhaltenskontrolle, Geofencing ist off
stvg: false
weitere: [§13b UStG, §147 AO, KI-VO Art. 50, ElektroG, ProdHaftG]
referenzkunde:
status: TBD
name: "zu klären mit Sales — siehe DOR §1.1.1 (Kandidat: SHK-Gebrüder-Schmidt / ELKO-Service Köln)"
quelle: Sales-Hypothese (noch kein Discovery-Call)
estimate_eng_tage: 48 # MVP-Slice: Tag + Scan + manuelle Entnahme + Server-Sync; OCI und Auto-Replenishment je +12
abhängigkeiten:
- handwerk/05-bau-abrechnung # Materialentnahme schreibt auf Kostenstelle Projekt (Cross-Link §4.5)
- kern/02-auth-scopes # Scope `own` auf eigene Entnahmen, `team` auf Kolonne, `all` auf Tenant
- kern/08-audit-log # jede Bestandsmutation ist ein Audit-Event

Marktrealität (DE-Handwerk). Das Service-Fahrzeug eines Heizungsbauers, Elektrikers oder SHK-Technikers ist operativ das tatsächliche Lager. Heute läuft das in 80 % der Betriebe so: Morgens wird ein Zettel ausgedruckt oder gar nichts, mittags wird am Großhändler nachgekauft (Würth, Hagebau, Sonepar) und der Lieferschein landet bestenfalls in der Werkstatt. Der Monteur weiß selbst nicht sicher, ob er noch 3 oder 30 Winkel DN 15 im Wagen hat. Rechnungsseitig ist das eine Katastrophe: Material wird am Ende des Projekts „nachgetragen“, also aus Erinnerung geschätzt — mit entsprechender Marge-Erosion.

Rechtlicher Rahmen:

  • GoBD verlangt seit 2015, dass Warenbewegungen als Belege zeitnah und unveränderbar dokumentiert werden (§145 AO, §147 AO). Eine händisch geführte Excel-Datei erfüllt das explizit nicht — der BMF-Erlass vom 28.11.2019 nennt „Bestandsveränderungen“ als Pflicht-Nachweis.
  • §13b UStG greift bei Bauleistungen: Stellt der Betrieb einem Subunternehmer Material bei, ist die Abgrenzung zwischen „Materialgestellung“ und „Einkaufsnebenleistung“ umsatzsteuerlich relevant — ohne Ausgabe-Beleg ist das nicht prüfbar.
  • §147 AO verlangt 10 Jahre Aufbewahrung der Belege — unveränderbar.
  • ElektroG und ProdHaftG machen eine Chargen-Rückverfolgung bei Elektro- und Sanitär-Bauteilen teilweise verbindlich (WEEE-Rücknahme, Produkthaftungs-Ausforschung bei Mangelschäden).

Schmerzpunkt der Alt-App. FahrzeugLagerScreen und MaterialVerbrauchScreen aus der zeitapp sind Teil der Route-Wildwuchs-Diagnose in LESSONS-LEARNED §2 — beide Screens existierten, aber ohne Barcode-Scan, ohne OCI, ohne Offline-Konflikt-Strategie und ohne tenant-seitige Bestandsführung im Postgres-Sinn. Ein gleichzeitiger Entnahme-Konflikt zweier Monteure führte im schlimmsten Fall zu Negativ-Beständen, die niemand reparierte. Genau den Punkt adressieren wir hier (siehe LESSONS-LEARNED §4 — „Offline-First war nur halb offline“).

Erwarteter Outcome.

  • Material-Erfassung 80 % automatisiert: Scan statt Suche, 3 Sekunden pro Entnahme statt 3 Minuten pro Tag in der Werkstatt.
  • Bestell-Durchlaufzeit −60 %: OCI-Punchout direkt aus der App statt abendliches Fax an den Großhandel.
  • Material-Marge +3 bis +5 Prozentpunkte: weil Material direkt auf das Projekt kontiert wird und nicht pauschal in „Regie“ verschwindet (siehe §4.5 Bau-Abrechnung).
  • Inventur-Aufwand von 2 Personentagen auf 2 Stunden: dank Stichtags-Snapshot + mobiler Scan-Inventur.

Rolle Aktion Scope Plattform
Mitarbeiter (Monteur) Fahrzeug morgens per NFC anmelden; Material per Scan entnehmen / rückbuchen; Mindestbestand-Warnung erhalten; Punchout starten; Inventur-Zählung ausführen own auf eigene Entnahmen, team auf das aktuelle Fahrzeug 📱 (Web nur Nachkontrolle)
Manager Bestellvorschläge freigeben; Budget-Freigabe bei OCI-Cart über Limit; Fehler-Tickets aus Konflikten bearbeiten; Material-Ist-Kosten pro Projekt prüfen team 🌐 primär, 📱 Approve-Push
Admin Händler-Integration OCI einrichten; Artikel-Stammdaten pflegen; Soll-Bestände pro Fahrzeug-Profil; NFC-Tags onboarden; Auto-Replenishment-Regeln all 🌐
Einkauf Rahmenverträge pflegen; Preislisten-Import (BMEcat 2005 / GAEB XML); Lieferanten-Matching all 🌐
Buchhaltung GoBD-Export der Bestandsmutationen; Inventur-Differenz-Bericht; §13b-Material-Beistellungs-Liste all 🌐

Persona-Skizzen.

  • Mehmet Yılmaz (Monteur SHK, 34, musterbetrieb-maler / shk-gebruder-schmidt) — startet 06:55 Uhr, tippt NFC-Fahrzeug-Tag am Armaturenbrett an, die App springt auf „Fahrzeug M-SG 247 · Bestand 412 Positionen“. Im Heizungskeller hat er keinen Empfang, aber Handschuhe und eine Stirnlampe. Er scannt die EAN auf dem Schraubenkarton → „Schraube DIN 7991 M8x40 Edelstahl A2, 47 Stk vorhanden → 10 entnommen“. Wenn die 20er-Schwelle unterschritten wird, erscheint morgen früh automatisch eine Nachbestell-Karte.
  • Thomas Schmidt (Bauleiter, 48, shk-gebruder-schmidt) — sieht im Web-Dashboard, dass Fahrzeug M-SG 247 heute 184 € Material für Projekt „BV Lehrer Allee 7“ verbucht hat, genehmigt per Klick einen Würth-Cart über 620 € und bekommt eine Konflikt-Warnung „Negativ-Bestand DN-15-Winkel, 2 parallele Entnahmen, Auto-Nachbestellung ausgelöst“.
  • Sabine Maier (Admin, 45, musterbetrieb-maler) — konfiguriert OCI-Punchout-URL zum Hagebau-Portal inkl. HMAC-Secret und testet den Round-Trip im Sandbox-Mandanten.
  • Petra Albrecht (Buchhaltung, 58, externer Steuerberater) — zieht am Monatsende den GoBD-Export der Bestandsbewegungen und die Inventur-Differenz-Liste.

US-01 [V2 MVP] Als Monteur möchte ich mein Fahrzeug per NFC-Tag anmelden,
um morgens in unter 2 Sekunden ohne Tippen im richtigen Lager-Kontext zu sein.
US-02 [V2 MVP] Als Monteur möchte ich Material per GS1-128-Scan entnehmen,
damit ich auch mit Handschuhen und ohne Empfang eine korrekte Buchung erzeuge.
US-03 [V2 MVP] Als Monteur möchte ich bei Unterschreitung des Mindestbestands
eine Bestellvorschlag-Karte mit einem Tap zur Freigabe erhalten.
US-04 [V2 MVP] Als Manager möchte ich Bestellungen an den Großhändler per OCI-Punchout auslösen,
damit der Warenkorb aus dem Händler-Portal 1:1 als Bestellung in Werkszeit liegt.
US-05 [V2 MVP] Als Manager möchte ich beim Offline-Doppel-Entnahme-Konflikt automatisch
ein Lieferverzug-Ticket inkl. Alternativ-Fahrzeug-Vorschlag sehen.
US-06 [V2] Als Admin möchte ich eine Stichtags-Inventur mit Scan-Zählung
und Differenz-Bericht auslösen, um GoBD-konforme Korrekturbuchungen vorzunehmen.
US-07 [V2] Als Buchhaltung möchte ich eine §13b-Material-Beistellungs-Liste pro Projekt
exportieren, um die Reverse-Charge-Abgrenzung sauber zu dokumentieren.
US-08 [V2.5] Als Einkauf möchte ich Rahmenverträge mit Staffelpreisen so hinterlegen,
dass der Punchout automatisch die richtige Konditionen-ID sendet.
US-09 [V2.5] Als Admin möchte ich einen Auto-Replenishment-Modus aktivieren,
der wöchentlich aus Soll-Bestand minus Ist-Bestand automatisch einen Cart befüllt.

  • F-M-01 — NFC-Fahrzeug-Anmeldung. NTAG213 (180 Byte) oder NTAG215 (504 Byte) mit einem NDEF-Record application/vnd.werkszeit.vehicle und Payload { "vehicleId": "...", "tenantId": "...", "nonce": "..." }. Lese-Distanz verpflichtend <4 cm (Default bei NTAG-Reichweite ~3 cm), damit unbeabsichtigtes Auslesen ausgeschlossen ist. Falls der Tag unbekannt ist → Onboarding-Flow (EC-01).
  • F-M-02 — Fahrzeug-Kontext-Banner. Nach erfolgreicher NFC-Anmeldung zeigt eine persistente Banner-Zeile Fahrzeug M-SG 247 · 412 Positionen · zuletzt gesynct 07:02 Uhr. Der Kontext gilt bis 18:00 Uhr oder bis erneutes NFC-Tap.
  • F-M-03 — GS1-128-Scan. Code-128 mit Application Identifiers: (01) GTIN-14, (10) Charge, (17) Verfallsdatum YYMMDD, (21) Seriennummer, (30) Menge. Parser laut GS1 General Specifications v24 (Release 2024, 2026 gültig). Fallback: EAN-13, QR, GS1 DataMatrix. Hardware: Kamera (mobile_scanner) + Bluetooth-HID (Zebra RS5100, Honeywell Voyager 1452g).
  • F-M-04 — Stapel-Scan-Modus. Kamera bleibt offen, Vibrations-Feedback pro Scan, Anzeige der letzten 5 Scans als Liste. Abschluss per großem Orange-Button „Fertig · 14 Positionen“.
  • F-M-05 — Manuelle Entnahme. Wenn kein Code vorhanden → Stichwort-Suche in Tenant-Artikel-Stammdaten, Menge, optional Foto. Foto wird als S3-Multipart-Upload über presigned URL hochgeladen (Drift speichert nur Thumbnail + Referenz, DOD §2.5).
  • F-M-06 — Rückbuchung. Gleicher Flow, aber mit Toggle „Rückbuchung“; Grund-Feld (Pflicht bei GoBD: „Bauherr hat nicht eingebaut“, „Beschädigt“, „Falsch gegriffen“).
  • F-M-07 — Mindestbestand-Warnung + Bestellvorschlag-Karte. Bei Unterschreitung der reorderThreshold im Stammdaten-Profil: Push-Notification + Card mit Artikel, Vorschlagsmenge (Differenz zu targetStock), bevorzugtem Händler (aus Rahmenvertrag). Ein-Tap-Bestätigung legt Position in den orderCart des Fahrzeugs.
  • F-M-08 — OCI-Punchout auslösen. Button „Zu Hagebau / Würth / Sonepar bestellen“ öffnet ein flutter_inappwebview auf die OCI-URL des konfigurierten Händlers; der Händler übernimmt Authentifizierung mit vorab generiertem HMAC-Session-Token.
  • F-M-09 — OCI-Hook-Empfang. Rückkehr vom Händler-Portal per HOOK_URL-POST → Deep-Link zurück in die App → Warenkorb-Darstellung aus NEW_ITEM-*-Feldern → Freigabe-Workflow.
  • F-M-10 — Lieferung an Baustelle. Check-Option „Lieferadresse = aktuelle Baustelle (GPS-Match)“ statt Firmenzentrale.
  • F-M-11 — Inventur-Zählung mobil. Stichtag vom Admin eröffnet, App zeigt alle im Fahrzeug erwarteten Artikel als Zählliste, Scan springt zur Position, Ist-Menge eintragen, Foto optional, Speichern → Differenz-Flag.
  • F-M-12 — Offline-Verhalten. Voll-offline (DOR §1.2.9): Stapel-Scans und Entnahmen gehen in die Outbox mit UUID-v7-Idempotency-Key. OCI-Punchout ist zwangsläufig online-only (Browser gegen Händler), die UI zeigt „Offline — Bestellvorschlag wird gespeichert, Punchout bei nächster Verbindung“.
  • F-W-01 — Bestandsübersicht pro Fahrzeug mit Soll/Ist/Delta-Heatmap.
  • F-W-02 — Bestellvorschlags-Kanban (Vorschlag → Freigabe durch Manager → Punchout → Eingegangen → Gebucht).
  • F-W-03 — Konflikt-Ticket-Liste (aus F-X-01, siehe unten).
  • F-W-04 — OCI-Händler-Konfiguration (URL, Hook-Secret, Credentials, Sandbox-Umschalter, Test-Punchout).
  • F-W-05 — Stammdaten-Import BMEcat 2005 und GAEB XML v3.3 für Artikel-Stamm.
  • F-W-06 — Inventur-Lauf-Verwaltung: Stichtag setzen, Snapshot-Freeze, Fortschritts-Matrix (Fahrzeug × Artikel), Differenz-Bericht mit einem Klick „Korrekturbuchung erzeugen“.
  • F-W-07 — GoBD-Export: CSV oder IDEA-Schnittstelle (ZIP mit index.xml, Bestandsbewegungen.csv), §13b-UStG-Liste (Material-Beistellungen an Sub-Unternehmer).
  • F-W-08 — Bulk-Aktionen (Korrekturbuchung, Umlagerung Fahrzeug→Fahrzeug, Ausbuchung Schwund mit Grund-Pflichtfeld).
  • F-X-01 — Konflikt-Auflösung bei Negativ-Bestand. Algorithmus: Semantischer Merge, keine Last-Write-Wins.
    • Jede Entnahme ist eine Delta-Operation (-5 Stk Artikel X), nicht ein Set-Operation. Deltas sind additiv-kommutativ (PN-Counter-CRDT-artig).
    • Server wendet Deltas in Serialisierungs-Reihenfolge des ersten Eintreffens an.
    • Wenn bestandNachDelta < 0: Das später eintreffende Delta wird auf bestand_verfügbar gedeckelt (z. B. 3 statt 8 Stk), der Rest (5 Stk) wird als LieferverzugTicket erzeugt. Auto-Trigger restbestand == 0 → Sofort-Lieferauftrag greift auf den bevorzugten Händler-Rahmenvertrag zu.
    • Der zu kurz gekommene Monteur bekommt einen Push mit Alternativen: (a) anderes Fahrzeug im 5-km-Radius (Geo-Match aus Dienstplan), (b) Schnell-Nachbestellung mit Liefer-ETA aus OCI-Scraping.
  • F-X-02 — Audit-Log-Eintrag pro Bestandsmutation: material.stock.mutated, material.order.submitted, material.inventory.corrected.
  • F-A-01 — NFC-Tag-Onboarding: Admin scannt neuen leeren Tag, weist Fahrzeug zu, App schreibt NDEF-Record (schreibgeschützt setzen per CC-Lock-Byte-0x0F). Tag-UID in vehicle_nfc_tag-Tabelle.
  • F-A-02 — Soll-Bestands-Profile pro Fahrzeug-Klasse (SHK-Vollausstattung, Elektro-Kleinbus, Notdienst-Wagen).
  • F-A-03 — Auto-Replenishment-Regeln (z. B. Montagmorgens 04:00 Uhr automatisch Cart befüllen, Freigabe-Schwelle 500 €).
  • F-A-04 — OCI-Händler-Katalog: vordefinierte Konfigurationen für GC-Gruppe, Würth, Sonepar, Hagebau, Conrad Business.
Anforderung-ID V2-MVP (Monat 15–18) V2 (Monat 19–22) V2.5 (Monat 23+)
F-M-01, -02, -03, -04, -05, -06
F-M-07, -08, -09
F-M-10, -11
F-M-12
F-W-01, -02, -04
F-W-03, -05, -06
F-W-07, -08
F-X-01, -02
F-A-01, -02, -04
F-A-03

HTML-Hero-Mockup: 06-mobiles-lager-material.html — Mobile + Web Side-by-Side mit Persona Mehmet Yılmaz (Monteur) und Thomas Schmidt (Bauleiter), Beispiel-Artikel „Schraube DIN 7991 M8x40 Edelstahl A2“, Bestand 47, Mindestbestand 20.

┌────────────────────────────────┐
│ 🛡 GoBD · §13b · DSGVO │ ← Compliance-Banner
├────────────────────────────────┤
│ ← Lager ⚙ │
├────────────────────────────────┤
│ 📡 Tag erkannt │
│ Fahrzeug M-SG 247 │
│ Kastenwagen SHK · Mehmet Y. │
│ zuletzt gesynct 07:02 │
│ │
│ Bestand: 412 Positionen │
│ ┌────────────────────────────┐ │
│ │ 🔴 3 unter Mindestbestand │ │
│ │ → Bestellvorschlag (3) │ │
│ └────────────────────────────┘ │
│ │
│ [📷 SCAN] [+ Manuell] [Inv.] │
│ │
│ Zuletzt entnommen: │
│ 06:58 Winkel DN 15 −2 │
│ 17.04. Kupferrohr 18mm −3 m │
│ 17.04. Dichtung DN 20 −5 │
├────────────────────────────────┤
│ [Zeit] [Plan] [Doku] [Lager] │
└────────────────────────────────┘
┌────────────────────────────────┐
│ ← Scan · Fahrzeug M-SG 247 │
├────────────────────────────────┤
│ │
│ ┌──────────────────────┐ │
│ │ ║║ ║║║║ ║║ ║║║ ║║║ │ │ ← Live-Kamera
│ │ (GS1-128 Bereich) │ │
│ └──────────────────────┘ │
│ │
│ Letzte Scans: │
│ ✓ Schraube DIN 7991 M8x40 −10 │
│ (01)04046962340127 │
│ (10)CHG-2026-04-A │
│ ✓ Dichtung DN 20 −4 │
│ ✓ Kupfer-T-Stück 22mm −2 │
│ │
│ 🔴 Pflicht-Feld: Kostenstelle │
│ Projekt Lehrer Allee 7 ▼ │
│ │
│ [ FERTIG · 3 Positionen ] │
└────────────────────────────────┘

6.3 Mobile — Mindestbestand-Warnung + Bestellvorschlag

Abschnitt betitelt „6.3 Mobile — Mindestbestand-Warnung + Bestellvorschlag“
┌────────────────────────────────┐
│ ← Bestellvorschlag │
├────────────────────────────────┤
│ ⚠ Mindestbestand unterschritten│
│ │
│ Schraube DIN 7991 M8x40 │
│ Edelstahl A2 │
│ ┌────────────────────────────┐ │
│ │ Ist: 37 Stk │ │
│ │ Min: 20 Stk │ │
│ │ Soll: 100 Stk │ │
│ │ Nachbestellen: 63 Stk │ │
│ └────────────────────────────┘ │
│ │
│ Händler: Würth (Rahmenvertrag) │
│ Preis: ≈ 14,70 € │
│ ETA: morgen 10:00 │
│ │
│ Lieferadr.: ⦿ Baustelle Lehrer │
│ ○ Werkstatt M-10 │
│ │
│ [ In Cart ] [ Jetzt bestellen]│
└────────────────────────────────┘
┌────────────────────────────────┐
│ ← Hagebau-Portal (OCI 4.0) │
├────────────────────────────────┤
│ 🔒 hagebau.de/punchout/... │
│ ┌────────────────────────────┐ │
│ │ │ │
│ │ [ Hagebau-Katalog ] │ │
│ │ │ │
│ │ Sie wurden von │ │
│ │ Werkszeit (mustermann) │ │
│ │ eingebucht — Cart wird │ │
│ │ automatisch übergeben │ │
│ │ │ │
│ │ [ ▶ Cart übertragen ] │ │
│ └────────────────────────────┘ │
│ │
│ Session-Timeout in 09:42 │
└────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────────────────┐
│ Werkszeit · shk-gebruder-schmidt Thomas S. · Bauleitung [Profil] │
├──────────────────────────────────────────────────────────────────────────────┤
│ Sidebar │ Lager · Fahrzeug M-SG 247 Mo 19.04.2026 · gesynct 09:42 │
│ │ ┌────────────────────────────────────────────────────────────┐ │
│ ▶ Lager │ │ KPI Positionen 412 · Unter Min. 3 · Wert 18.472 € │ │
│ Bestell.│ │ Konflikte heute 1 · Offene Tickets 1 · Ø Alter 2 h │ │
│ Inventur│ └────────────────────────────────────────────────────────────┘ │
│ Händler │ │
│ │ Artikel │ Soll │ Ist │ Δ │ Status │
│ │ Schraube DIN 7991 M8x40 A2 │ 100 │ 47 │-53 │ 🟢 OK │
│ │ Winkel DN 15 │ 40 │ 0 │-40 │ 🔴 Negativ! │
│ │ Kupferrohr 18 mm (m) │ 30 │ 24 │ -6 │ 🟡 Knapp │
│ │ Dichtung DN 20 │ 60 │ 55 │ -5 │ 🟢 OK │
│ │ … │
│ │ │
│ │ ┌ Konflikt-Ticket #LGR-2026-0412 ──────────────────────────┐ │
│ │ │ Artikel: Winkel DN 15 │ │
│ │ │ Monteur A: Mehmet Y. hat 5 Stk entnommen (offline 06:58) │ │
│ │ │ Monteur B: Andreas W. hat 8 Stk entnommen (offline 07:11)│ │
│ │ │ Bestand vor Konflikt: 10 Stk │ │
│ │ │ Auto-Resolution: A bekommt 5, B bekommt 5, B fehlen 3 │ │
│ │ │ Auto-Aktion: Lieferauftrag Würth 20 Stk ausgelöst │ │
│ │ │ │ │
│ │ │ [ Genehmigen ] [ Manuell anpassen ] [ Alternativen 📍 ]│ │
│ │ └────────────────────────────────────────────────────────── ┘ │
└──────────────────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────────────────┐
│ Inventur · Stichtag 31.03.2026 · Schluss: 02.04.2026 12:00 · 87% gezählt │
├──────────────────────────────────────────────────────────────────────────────┤
│ Fahrzeug │ Pos.│ Gezählt │ Differenz │ Wert-Diff │ Status │
│ M-SG 247 │ 412 │ 412 │ −3 Stk │ −47,20 € │ ✓ Abgeschlossen │
│ M-SG 312 │ 389 │ 389 │ +12 Stk │ +132,50 € │ ✓ Abgeschlossen │
│ M-SG 445 │ 401 │ 312 │ — │ — │ ⚠ 78% · offen │
│ │
│ Differenzen (−47,20 €): Haupttreiber: Kupferrohr 18 mm −4 m (37,60 €) │
│ Schraube DIN 7991 −8 Stk (2,40 €) │
│ │
│ [ Korrekturbuchungen erzeugen (GoBD-konform) ] [ Detail-PDF ] │
└──────────────────────────────────────────────────────────────────────────────┘

Symbol-Konvention nach TEMPLATE.md §6.


apps/api/src/db/schema/material.ts
import { pgTable, uuid, text, numeric, integer, timestamp, boolean, bytea, jsonb, index, unique } from 'drizzle-orm/pg-core';
export const vehiclesTable = pgTable('vehicles', {
id: uuid('id').primaryKey().defaultRandom(), // UUID v7
tenantId: uuid('tenant_id').notNull(),
plate: text('plate').notNull(), // "M-SG 247"
profileId: uuid('profile_id').notNull(), // Fahrzeug-Soll-Profil
active: boolean('active').notNull().default(true),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
createdBy: uuid('created_by').notNull(),
}, (t) => ({
tenantIdx: index('vehicles_tenant_idx').on(t.tenantId),
tenantPlate: unique('vehicles_tenant_plate_unique').on(t.tenantId, t.plate),
}));
export const vehicleNfcTagsTable = pgTable('vehicle_nfc_tags', {
id: uuid('id').primaryKey().defaultRandom(),
tenantId: uuid('tenant_id').notNull(),
vehicleId: uuid('vehicle_id').notNull().references(() => vehiclesTable.id),
nfcUid: text('nfc_uid').notNull(), // ISO14443-A UID
chipType: text('chip_type').notNull(), // 'NTAG213' | 'NTAG215'
nonce: bytea('nonce').notNull(), // für HMAC-Anti-Replay
locked: boolean('locked').notNull().default(false), // CC-Lock-Byte gesetzt
lastSeenAt: timestamp('last_seen_at', { withTimezone: true }),
}, (t) => ({
tenantUid: unique('nfc_tenant_uid_unique').on(t.tenantId, t.nfcUid),
}));
export const articlesTable = pgTable('articles', {
id: uuid('id').primaryKey().defaultRandom(),
tenantId: uuid('tenant_id').notNull(),
gtin14: text('gtin14'), // AI(01), null falls kein Barcode
sku: text('sku').notNull(), // interner Schlüssel
name: text('name').notNull(), // "Schraube DIN 7991 M8x40 Edelstahl A2"
uom: text('uom').notNull(), // 'PCE' | 'M' | 'KG'
etimClass: text('etim_class'), // ETIM-9.0
vatRate: numeric('vat_rate', { precision: 5, scale: 2 }).notNull(),
listPrice: numeric('list_price', { precision: 12, scale: 4 }).notNull(),
hazardClass: text('hazard_class'), // ADR-Gefahrgut, optional
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
}, (t) => ({
tenantGtin: index('articles_tenant_gtin_idx').on(t.tenantId, t.gtin14),
tenantSku: unique('articles_tenant_sku_unique').on(t.tenantId, t.sku),
}));
export const stockLevelsTable = pgTable('stock_levels', {
id: uuid('id').primaryKey().defaultRandom(),
tenantId: uuid('tenant_id').notNull(),
vehicleId: uuid('vehicle_id').notNull().references(() => vehiclesTable.id),
articleId: uuid('article_id').notNull().references(() => articlesTable.id),
quantity: numeric('quantity', { precision: 14, scale: 4 }).notNull().default('0'),
minStock: numeric('min_stock', { precision: 14, scale: 4 }).notNull(),
targetStock: numeric('target_stock', { precision: 14, scale: 4 }).notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
}, (t) => ({
tenantVehArt: unique('stock_tenant_veh_art_unique').on(t.tenantId, t.vehicleId, t.articleId),
}));
// Append-only Bestandsbewegungen — GoBD-relevant, Hash-Chain
export const stockMovementsTable = pgTable('stock_movements', {
id: uuid('id').primaryKey().defaultRandom(), // UUID v7
tenantId: uuid('tenant_id').notNull(),
vehicleId: uuid('vehicle_id').notNull(),
articleId: uuid('article_id').notNull(),
movementType: text('movement_type').notNull(), // 'ISSUE' | 'RETURN' | 'RECEIPT' | 'CORRECTION' | 'INVENTORY_SET' | 'TRANSFER_IN' | 'TRANSFER_OUT'
deltaQty: numeric('delta_qty', { precision: 14, scale: 4 }).notNull(), // Vorzeichen-behaftet
reason: text('reason'), // Pflicht bei CORRECTION / RETURN
batchNo: text('batch_no'), // GS1 AI(10)
expiryDate: text('expiry_date'), // GS1 AI(17) YYMMDD
serialNo: text('serial_no'), // GS1 AI(21)
projectId: uuid('project_id'), // Kostenstelle (Cross-Link §4.5)
sourceOp: text('source_op').notNull(), // 'SCAN' | 'MANUAL' | 'OCI_RECEIPT' | 'INVENTORY' | 'CONFLICT_RESOLVE'
idempotencyKey: uuid('idempotency_key').notNull(), // UUID v7 aus Client-Outbox
clientTs: timestamp('client_ts', { withTimezone: true }).notNull(), // Zeitstempel am Gerät
serverTs: timestamp('server_ts', { withTimezone: true }).notNull().defaultNow(),
createdBy: uuid('created_by').notNull(),
// GoBD Hash-Chain
hashPrev: bytea('hash_prev'),
hashSelf: bytea('hash_self').notNull(),
}, (t) => ({
tenantVehIdx: index('mov_tenant_veh_idx').on(t.tenantId, t.vehicleId, t.serverTs.desc()),
tenantArtIdx: index('mov_tenant_art_idx').on(t.tenantId, t.articleId, t.serverTs.desc()),
tenantIdemp: unique('mov_tenant_idemp_unique').on(t.tenantId, t.idempotencyKey),
tenantProj: index('mov_tenant_proj_idx').on(t.tenantId, t.projectId, t.serverTs.desc()),
}));
export const ociConfigsTable = pgTable('oci_configs', {
id: uuid('id').primaryKey().defaultRandom(),
tenantId: uuid('tenant_id').notNull(),
vendor: text('vendor').notNull(), // 'HAGEBAU' | 'WUERTH' | 'SONEPAR' | 'GC_GRUPPE' | 'CONRAD'
punchoutUrl: text('punchout_url').notNull(), // HTTPS
hookSecret: bytea('hook_secret').notNull(), // HMAC-Secret für Hook-Signatur
username: text('username').notNull(),
passwordEnc: bytea('password_enc').notNull(), // KMS-verschlüsselt
sandbox: boolean('sandbox').notNull().default(false),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
});
export const orderCartsTable = pgTable('order_carts', {
id: uuid('id').primaryKey().defaultRandom(),
tenantId: uuid('tenant_id').notNull(),
vehicleId: uuid('vehicle_id').notNull(),
projectId: uuid('project_id'),
status: text('status').notNull(), // 'DRAFT' | 'PUNCHOUT_OPEN' | 'SUBMITTED' | 'CONFIRMED' | 'DELIVERED' | 'BOOKED' | 'CANCELLED'
vendor: text('vendor'),
totalNet: numeric('total_net', { precision: 14, scale: 2 }),
hookPayload: jsonb('hook_payload'), // OCI-Rückgabe-Daten
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
createdBy: uuid('created_by').notNull(),
}, (t) => ({
tenantVehIdx: index('carts_tenant_veh_idx').on(t.tenantId, t.vehicleId, t.createdAt.desc()),
}));
export const orderCartLinesTable = pgTable('order_cart_lines', {
id: uuid('id').primaryKey().defaultRandom(),
tenantId: uuid('tenant_id').notNull(),
cartId: uuid('cart_id').notNull().references(() => orderCartsTable.id),
articleId: uuid('article_id'), // null bei neuen Händler-Artikeln
vendorSku: text('vendor_sku'),
description: text('description').notNull(),
quantity: numeric('quantity', { precision: 14, scale: 4 }).notNull(),
unitPrice: numeric('unit_price', { precision: 14, scale: 4 }).notNull(),
});
export const inventoryRunsTable = pgTable('inventory_runs', {
id: uuid('id').primaryKey().defaultRandom(),
tenantId: uuid('tenant_id').notNull(),
cutoffDate: text('cutoff_date').notNull(), // ISO-Datum
snapshotAt: timestamp('snapshot_at', { withTimezone: true }).notNull(),
status: text('status').notNull(), // 'OPEN' | 'IN_PROGRESS' | 'CLOSED'
snapshotJson:jsonb('snapshot_json').notNull(), // Bestände aller Fahrzeuge zum Stichtag
createdBy: uuid('created_by').notNull(),
});
export const conflictTicketsTable = pgTable('conflict_tickets', {
id: uuid('id').primaryKey().defaultRandom(),
tenantId: uuid('tenant_id').notNull(),
vehicleId: uuid('vehicle_id').notNull(),
articleId: uuid('article_id').notNull(),
movementAId: uuid('movement_a_id').notNull(),
movementBId: uuid('movement_b_id').notNull(),
deficitQty: numeric('deficit_qty', { precision: 14, scale: 4 }).notNull(),
autoActionTaken: text('auto_action_taken').notNull(), // 'ORDER_TRIGGERED' | 'ALTERNATIVE_SUGGESTED' | 'NONE'
status: text('status').notNull(), // 'OPEN' | 'RESOLVED' | 'REJECTED'
resolvedAt: timestamp('resolved_at', { withTimezone: true }),
});

RLS-Policy-Sketch (exemplarisch für stock_movements, analog für alle anderen):

ALTER TABLE stock_movements ENABLE ROW LEVEL SECURITY;
CREATE POLICY stock_movements_tenant_isolation ON stock_movements
USING (tenant_id = current_setting('app.tenant_id')::uuid);
CREATE POLICY stock_movements_scope ON stock_movements
FOR SELECT USING (
tenant_id = current_setting('app.tenant_id')::uuid AND (
current_setting('app.scope') = 'all'
OR (current_setting('app.scope') = 'team'
AND vehicle_id IN (SELECT vehicle_id FROM team_vehicle_assignments
WHERE team_id = current_setting('app.team_id')::uuid))
OR (current_setting('app.scope') = 'own'
AND created_by = current_setting('app.user_id')::uuid)
)
);
-- Append-only erzwingen:
CREATE POLICY stock_movements_no_update ON stock_movements FOR UPDATE USING (false);
CREATE POLICY stock_movements_no_delete ON stock_movements FOR DELETE USING (false);

ER-Bezug. users, tenants, projects, teams aus Kern-Schema; audit_log aus §11-Feinkonzept wird pro Bestandsmutation beschrieben.


Methode Pfad Auth-Scope Rate-Limit Idempotenz Beschreibung
POST /v1/material/vehicles/{id}/checkin material:write Standard Idempotency-Key Pflicht NFC-Anmeldung (Nonce-Prüfung, Last-Seen-Update)
GET /v1/material/vehicles/{id}/stock material:read:team Standard Bestandsübersicht
POST /v1/material/movements material:write Burst Idempotency-Key Pflicht Entnahme/Rückbuchung (Client-Outbox-Push)
POST /v1/material/movements:bulk material:write Burst Idempotency-Key Pflicht Stapel-Scan
GET /v1/material/articles material:read:team Standard Artikel-Suche (FTS)
POST /v1/material/carts material:write Standard Idempotency-Key Pflicht Cart aus Bestellvorschlag
POST /v1/material/carts/{id}/punchout material:write Privileged Idempotency-Key Pflicht OCI-Session eröffnen → HTML-Form mit HOOK_URL zurück
POST /v1/material/oci/hook material:integration Burst HMAC-Signatur OCI-Cart-Return (Public-Endpoint mit HMAC)
POST /v1/material/carts/{id}/submit material:approve Standard Idempotency-Key Pflicht Bestellung final rausschicken
POST /v1/material/inventory/runs material:approve Standard Idempotency-Key Pflicht Inventur eröffnen
POST /v1/material/inventory/runs/{id}/count material:write Burst Idempotency-Key Pflicht Zählung pro Artikel
POST /v1/material/inventory/runs/{id}/close material:approve Privileged Idempotency-Key Pflicht Differenzbuchungen auslösen
GET /v1/material/conflicts material:read:team Standard Konflikt-Tickets
POST /v1/material/conflicts/{id}/resolve material:approve Standard Idempotency-Key Pflicht Manuelle Resolution
GET /v1/material/export/gobd material:export Privileged IDEA-ZIP mit index.xml

OpenAPI-Schema-Skizze:

paths:
/v1/material/movements:
post:
operationId: createMovement
x-werkszeit-scope: material:write
x-werkszeit-rate-limit: burst
parameters:
- in: header
name: Idempotency-Key
required: true
schema: { type: string, format: uuid }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [vehicleId, articleId, deltaQty, movementType, clientTs, sourceOp]
properties:
vehicleId: { type: string, format: uuid }
articleId: { type: string, format: uuid }
deltaQty: { type: string, pattern: '^-?\\d+(\\.\\d{1,4})?$' }
movementType: { type: string, enum: [ISSUE, RETURN, CORRECTION, INVENTORY_SET] }
reason: { type: string, maxLength: 500 }
batchNo: { type: string, maxLength: 20 }
expiryDate: { type: string, pattern: '^[0-9]{6}$' }
projectId: { type: string, format: uuid, nullable: true }
clientTs: { type: string, format: date-time }
sourceOp: { type: string, enum: [SCAN, MANUAL, INVENTORY] }
responses:
'201': { description: Buchung erzeugt }
'409': { description: Idempotency-Konflikt }
'422': { description: Fachlicher Konflikt (Bestand würde <0) — enthält conflictTicketId }

Webhook-Events: material.stock.mutated, material.stock.low, material.order.cart_created, material.order.punchout_returned, material.order.submitted, material.inventory.opened, material.inventory.closed, material.conflict.opened, material.conflict.resolved.


Profil Begründung Mechanik
Voll-offline (Entnahme, Rückbuchung, Stapel-Scan, Inventur-Zählung) Monteur im Heizungskeller ohne LTE ist der Normalfall — DOR §1.2.9 Outbox in Drift mit UUID-v7-Idempotency-Key, Delta-Semantik (nicht Set-Semantik). Retry bei connectivity == online. Lokal wird optimistisch gebucht, aber mit Flag syncState=PENDING.
Best-effort-offline (Bestellvorschlag erzeugen, Cart-Zeile hinzufügen) Vorschlag kann lokal erzeugt werden, Vendor-Match braucht Server Lokale Speicherung, Vendor-Auflösung beim Sync
Online-only (OCI-Punchout, Cart-Submit, Inventur-Close, GoBD-Export) Punchout braucht aktive Händler-Session; Submit und Close sind GoBD-final UI-Block mit klarem Hinweis, Retry when online

Konflikt-Strategie. Siehe F-X-01: Delta-Semantik auf Server, automatischer Lieferauftrag bei Negativ-Resolution, niemals Last-Write-Wins. Append-only mit versionierter Korrektur ist der einzige Weg, der GoBD standhält. Hintergrund in LESSONS-LEARNED §4.

Foto-/Datei-Sync. Fotos (Entnahme-Beleg, Inventur-Beleg) gehen als S3-Multipart über presigned URL; Drift speichert thumbnailBase64 (max 60 KB) + S3-Key.


  • Append-only-Tabelle: stock_movements mit hash_prev + hash_self (SHA-256 über id || tenantId || vehicleId || articleId || movementType || deltaQty || reason || projectId || idempotencyKey || clientTs || serverTs || createdBy || hash_prev). Update/Delete-Policies auf RLS-Ebene verboten.
  • Aufbewahrung: Foto-Belege der Entnahme in S3 mit Object Lock (Compliance-Mode, 10 Jahre — §147 AO).
  • Zeitnah: Buchung entsteht spätestens beim nächsten Sync; serverTs − clientTs > 30 Tage wird in einen Audit-Warn-Report gehoben.
  • Nachvollziehbar: Jede Mutation erzeugt Audit-Log-Eintrag material.stock.mutated mit Ziel-ID, Akteur, Delta, Quelle (Scan/Manual/OCI/Inventur).
  • Unveränderbar: Korrekturen laufen immer als neue CORRECTION-Buchung mit Pflicht-reason, nie als Update der Original-Zeile.
  • Prüfbar: GoBD-Export als ZIP mit index.xml (GDPdU-Format, IDEA-kompatibel).
  • Verfahrensdokumentation: Textbaustein-Referenz gobd-material-verfahrensdoku.md (Stichworte: „Warenbewegung als Beleg“, „Fahrzeug als Lagerort“, „Chargen-Rückverfolgung“).

— nicht zutreffend, weil dieses Modul keine Arbeitszeit-Events erzeugt. Cross-Modul: Entnahmen liegen zeitlich innerhalb einer Stempelung, beeinflussen aber ArbZG-Berechnung nicht.

— nicht zutreffend als Haupt-Compliance-Pfad. Material-Zuordnung zu Projekten fließt in die VOB-Abrechnung §4.5, dort greifen §14 VOB/B-Anforderungen.

  • Art. 5 Datenminimierung: Es werden keine personenbezogenen Standortdaten dauerhaft gespeichert. Nur die Zuordnung createdBy = user_id pro Buchung ist personenbezogen (zulässig nach Art. 6 Abs. 1 b — Vertragserfüllung).
  • Art. 6 Rechtsgrundlage: Vertrag (Arbeitsvertrag, Betriebsmittel-Nutzung).
  • Art. 17 Löschung: createdBy wird bei Lösch-Antrag eines Ex-Mitarbeiters auf anonymized-user-<tenant> maskiert (GoBD-Konflikt-Vermeidung: Beleg bleibt, Person wird maskiert). Fotos werden geprüft, ob sie Personen enthalten — falls ja, werden sie bei Löschantrag ausgewertet und ggf. unkenntlich gemacht oder gelöscht (Pflichtfeld-Prozess).
  • Art. 20 Datenexport: Der Ex-Mitarbeiter erhält in seinem DSGVO-JSON-Export die Liste aller von ihm getätigten Buchungen (nur seine createdBy-Zeilen).
  • Art. 30 Verarbeitungsverzeichnis: Eintrag „Materialverwaltung Service-Fahrzeuge“ — Zweck: Warenwirtschaft; Empfänger: Handelspartner via OCI; Löschfrist: 10 Jahre (§147 AO).
  • DSFA: Nicht Pflicht — keine systematische Überwachung, keine Hochrisiko-Verarbeitung.

— nicht zutreffend, weil dieses Modul kein ESS-Flow ist (Mitarbeiter-Selbstbedienung im Sinne des BFSG adressiert Lohnzettel, Stammdaten, Urlaubsantrag). Die App-Screens erfüllen trotzdem WCAG 2.2 AA aus Konsistenz-Gründen (Fokus, Kontrast, Labels) und werden über axe-core (Web-Pendant) getestet.

— nicht zutreffend. Das Modul erzeugt keine Leistungs- oder Verhaltenskontrolle. Wir tracken keine Entnahmequoten pro Mitarbeiter, kein Geofencing, keine Bewegungsprofile. Falls ein Kunde „Top-Entnahmen pro Mitarbeiter“-Reports fordert, wandert das hinter einen Betriebsrat-Freischaltungs-Workflow.

  • §13b UStG — Material-Beistellung: Export-Liste pro Projekt und Sub-Unternehmer mit Flag „Material auf Projekt“, damit der Steuerberater prüfen kann, ob die §13b-Reverse-Charge-Abgrenzung greift.
  • §147 AO — Aufbewahrung: siehe §10.1.
  • KI-VO Art. 50 — Transparenz: Der Auto-Bestellvorschlag (F-M-07) ist eine Heuristik (Soll-Ist-Differenz × Sicherheitsfaktor), keine KI im Sinne der AI-Act-Definition. Falls V2.5 ein ML-Forecast-Modul hinzukommt, wird dort der Transparenz-Hinweis „Vorschlag — bitte prüfen“ zur Pflicht.
  • ElektroG / ProdHaftG: Chargen- und Seriennummer-Erfassung via GS1 AI(10) und AI(21) ist optional, aber empfohlen — für Elektro- und Sanitär-Bauteile kundenseitig aktivierbar.
  • ADR-Gefahrgut: articles.hazardClass wird in der Fahrzeug-Gesamtansicht aggregiert; ab gewisser Schwelle (250 Punkte) muss der Fahrer die ADR-Kleinmengenregel-Bescheinigung dabei haben. V2.5-Feature.

# Szenario Erwartetes Verhalten
EC-01 NFC-Tag unbekannt / neu Onboarding-Flow: App zeigt „Tag nicht registriert — Fahrzeug auswählen“, Mitarbeiter wählt Fahrzeug aus Dropdown, Admin bekommt Approval-Push (bei Scope own Mitarbeiter nicht Self-Service), nach Admin-OK wird vehicle_nfc_tag-Zeile angelegt.
EC-02 OCI-Punchout-Timeout (Händler antwortet nicht innerhalb 60 s) Frontend zeigt Retry-Dialog, Cart bleibt im Status PUNCHOUT_OPEN, nach 3 Retries Fallback auf manuelle Bestellung mit Copy-Paste-Cart-Text. Audit-Eintrag material.oci.timeout.
EC-03 Inventur-Differenz negativ (gezählt < erwartet) Differenz wird als CORRECTION-Buchung mit reason="Inventurdifferenz Stichtag X" erfasst, Manager-Freigabe nötig, optional Foto-Beleg.
EC-04 Multi-Tenant-Bleed-Versuch (User mit 2 Tenants wechselt Kontext, scannt Tag von Tenant B) Server weist Check-in ab mit 404 (nicht 403 — Existenz nicht offenbaren). Kein Audit-Eintrag in Tenant B.
EC-05 Sync-Konflikt: Offline-Doppelentnahme Siehe F-X-01. A bekommt Soll, B bekommt Rest, Delta als conflict_ticket, Auto-Nachbestellung.
EC-06 GS1-128 mit Pflicht-Verfallsdatum überschritten App zeigt Warnung „Verfallsdatum 26.03.2026 überschritten — Entnahme für Kundenprojekt blockiert“, Admin kann Override-Scope mit Grund setzen.
EC-07 Hardware-Scanner koppelt, aber keine Kamera-Permission App schlägt Fallback auf Bluetooth-HID vor, Kamera-Einstellung im Settings-Screen verlinkt.
EC-08 Großmenge-Export (Manager zieht 120.000 Bewegungen als CSV) Asynchroner Job, E-Mail mit Download-Link, max. Gültigkeit 24 h, S3-pre-signed.
EC-09 NFC-Tag wurde manipuliert (Nonce stimmt nicht) HMAC-Check scheitert, Server gibt 409, Audit-Warn-Eintrag material.nfc.tampered, Admin-Eskalation.
EC-10 Plan-Limit Starter erreicht (Material nicht inkludiert) Modul-Gate module.material ist off, Backend-Routes antworten mit 404. Frontend zeigt Upgrade-Screen.
EC-11 Rollen-Demotion während laufender Bulk-Inventur Serverseitig wird pro Zähl-Request die aktuelle Rolle geprüft, abgebrochen mit 403 sobald Rechte verloren.
EC-12 OCI-Hook mit falschem HMAC 401, Audit-Eintrag material.oci.hook.unauthorized, Admin-Warnung.

Funktionalität: Mobiles Lager & Material-Management
Hintergrund:
Angenommen ein Tenant "shk-gebruder-schmidt" mit aktivem Modul-Gate "module.material"
Und ein Monteur "Mehmet" mit Rolle "Mitarbeiter" und Scope "own"
Und ein Fahrzeug "M-SG 247" mit NFC-Tag "04A1F2..." und Soll-Profil "SHK-Vollausstattung"
Und ein Artikel "Schraube DIN 7991 M8x40 Edelstahl A2" mit GTIN "04046962340127", Bestand 47, Mindestbestand 20
Szenario: Happy Path — NFC-Anmeldung, GS1-Scan, Entnahme
Wenn Mehmet sein Gerät an das NFC-Siegel "M-SG 247" hält
Und die App den NDEF-Record vollständig liest
Dann wird die Fahrzeug-Anmeldung mit Nonce-Prüfung bestätigt
Und die App zeigt "Fahrzeug M-SG 247 · 412 Positionen"
Wenn Mehmet den Stapel-Scan öffnet und den GS1-128-Code "(01)04046962340127(10)CHG-2026-04-A(30)10" scannt
Dann erscheint die Position "Schraube DIN 7991 M8x40 Edelstahl A2 · −10" mit Charge "CHG-2026-04-A"
Wenn Mehmet das Projekt "BV Lehrer Allee 7" als Kostenstelle wählt und auf "Fertig" tippt
Dann wird genau eine Bewegung vom Typ "ISSUE" mit delta_qty=-10 gespeichert
Und der Bestand steht auf 37
Und ein Audit-Log-Eintrag "material.stock.mutated" entsteht
Und die Bewegung ist hash-verkettet mit der vorigen Bewegung dieses Tenants
Szenario: Grenzfall — NFC-Tag unbekannt (Onboarding-Flow, EC-01)
Angenommen ein NFC-Tag mit UID "04B2E3..." ist in keiner "vehicle_nfc_tags"-Zeile registriert
Wenn Mehmet diesen Tag liest
Dann zeigt die App "Tag nicht registriert — Fahrzeug auswählen"
Und der Fahrzeug-Dropdown listet nur Fahrzeuge mit Scope "team" für Mehmet
Wenn Mehmet "M-SG 312" wählt und auf "Registrieren" tippt
Dann wird ein Approval-Push an einen Admin gesendet
Und der Tag ist bis zur Admin-Freigabe im Status "PENDING"
Und Mehmet erhält die Anmeldung erst nach Admin-Approve
Szenario: Grenzfall — OCI-Punchout-Timeout (EC-02)
Angenommen eine OCI-Konfiguration für Vendor "HAGEBAU" mit punchout_url "https://punchout.hagebau.de/..."
Und Mehmet hat einen Cart mit 3 Positionen erstellt
Wenn Mehmet auf "Jetzt bestellen" tippt
Und der Hagebau-Server innerhalb 60 Sekunden nicht antwortet
Dann zeigt die App einen Retry-Dialog
Und der Cart-Status bleibt "PUNCHOUT_OPEN"
Wenn Mehmet 3 Retries erfolglos durchführt
Dann bietet die App einen manuellen Fallback mit exportierbarem Cart-Text
Und ein Audit-Log-Eintrag "material.oci.timeout" entsteht
Szenario: Grenzfall — Inventur-Differenz negativ (EC-03)
Angenommen ein Inventur-Lauf zum Stichtag "31.03.2026"
Und der Snapshot erwartet 47 Stk "Schraube DIN 7991 M8x40"
Wenn Mehmet bei der Zählung 39 Stk erfasst
Dann wird eine "CORRECTION"-Bewegung mit delta_qty=-8 und reason="Inventurdifferenz 2026-03-31" als Entwurf angelegt
Und die Differenz erscheint rot im Differenz-Bericht
Wenn der Manager auf "Korrekturbuchungen erzeugen" klickt
Dann werden die Korrekturen mit Hash-Chain final
Und die Audit-Log-Einträge "material.inventory.corrected" werden erzeugt
Szenario: Grenzfall — Offline-Doppelentnahme (F-X-01)
Angenommen Artikel "Winkel DN 15", Bestand 10 Stk im Fahrzeug M-SG 247
Und Mehmet ist offline seit 06:30 Uhr
Und Andreas ist offline seit 06:45 Uhr (beide gleiches Fahrzeug via Team-Scope)
Wenn Mehmet lokal 5 Stk entnimmt (Idempotency-Key A, clientTs 06:58)
Und Andreas lokal 8 Stk entnimmt (Idempotency-Key B, clientTs 07:11)
Und beide um 08:00 Uhr syncen (Mehmet zuerst)
Dann wird Mehmet's Delta "-5" sauber angewendet (Bestand 5)
Und Andreas' Delta wird auf "-5" gedeckelt (Bestand 0)
Und ein Conflict-Ticket entsteht mit deficit_qty=3
Und ein Auto-Lieferauftrag an den Rahmenvertrags-Händler wird ausgelöst
Und Andreas erhält einen Push mit Alternativen (anderes Fahrzeug im 5-km-Radius + ETA)
Und beide Bewegungen sind in stock_movements persistent (keine wird gelöscht)

  • U-01 — GS1-128-Parser: Happy, FNC1-Trennung, AI mit fester Länge, AI mit variabler Länge (01, 10, 17, 21, 30), Grenzfall „Mehrfach-AI im Code“.
  • U-02 — Delta-Semantik: Property-based, 1000 zufällige Delta-Folgen über identischen Bestand müssen zum gleichen Endergebnis wie sum(deltas) führen.
  • U-03 — Hash-Chain: Manipulation einer Zeile invalidiert alle nachfolgenden Hashes.
  • U-04 — OCI-XML-Roundtrip: Mustang-Cart in, Werkszeit-Cart-Lines out, Feld-Vollständigkeit.
  • U-05 — Rundung und Mengeneinheiten: PCE, M (bis 3 Nachkommastellen), KG.
  • W-01 — Stempel-Button (hier: „FERTIG · N Positionen“) disabled bei len(scans) == 0.
  • W-02 — Golden-Test Mobile-Frame mit Compliance-Banner, de-DE-Locale, Dark-Mode.
  • W-03 — Foto-Thumbnail-Upload zeigt Progress-Bar und fällt bei Netzverlust sauber in Queue.
  • I-01 — Multi-Tenant-Isolation (Pflicht): Tenant A darf keinen Cart, keine Movement, keinen Tag von Tenant B lesen/schreiben.
  • I-02 — RLS own: Mitarbeiter sieht nur eigene Entnahmen; Manager sieht team-weit.
  • I-03 — Outbox-Idempotenz: doppelter Request mit gleicher Idempotency-Key → 409 + keine Doppelbuchung.
  • I-04 — OCI-Hook-HMAC-Verification: korrekter HMAC → 200; gefälschter HMAC → 401 + Audit-Eintrag.
  • E-01 — Happy Path aus §12 auf iOS + Android + Chromium/Firefox/WebKit.
  • E-02 — Offline-Scan → Sync mit simuliertem Netzausfall (Patrol network off → on + 3 Buchungen in Outbox).
  • E-03 — OCI-Punchout gegen Sandbox-Mock-Server.
  • E-04 — Inventur-Lauf End-to-End: Eröffnung → Zählung → Schluss → Korrektur-Hash-Chain.
  • E-05 — Konflikt-Szenario: zwei parallele Offline-Geräte via Patrol-Multi-Device.
  • E-06 — Accessibility: axe-core für Web-Dashboard ohne Critical/Serious, Flutter-Semantics-Test für Scan-Screen.
  • C-01 — Hash-Chain-Integrität: Mutation erkannt, Break-Index korrekt.
  • C-02 — GoBD-IDEA-Export: ZIP entspricht GDPdU-Schema, von idea-validate akzeptiert.
  • C-03 — GS1-Muster-Barcodes aus GS1 Germany Referenz-Set — 100 % korrekt geparst.
  • C-04 — OCI 4.0 BMEcat-Return aus Hagebau-Sandbox wird als Cart angelegt.

Lokaler 20×-Re-Run der neuen E2E-Tests grün (DOD §2.2 letzter Punkt). Bluetooth-Scanner-Kopplung wird in Patrol gemockt, nicht real.


Nicht-Ziel Begründung
WMS-Vollfunktion (Zonen, Bin-Locations, Pick-Paths im Zentrallager) Werkszeit verwaltet Fahrzeug-Lager + Werkstatt, kein Hochregal-Lager. Kunden mit echtem WMS bleiben bei pfl, prenode oder SAP EWM.
RFID UHF (EPC Gen2) Gate-Scan Hardware-Investment rechtfertigt sich nur ab 500+ MA mit stationären Gates. Nicht im Zielmarkt.
Freie Lieferanten-Ausschreibung / Preisspiegel Liegt bei simplesystem, Mercateo, onventis. §4.6 konzentriert sich auf OCI-Punchout zum bestehenden Händler.
EDI-Auftragsbestätigung (ORDRSP, DESADV, INVOIC) V2 Nicht-Scope, Nachfrage kommt erst bei Tier-1-Industrie-Kunden.
Chargenrückverfolgung über mehrere Jahre (Serialisierungs-Ledger) §10.7 ElektroG-Basis reicht für Handwerk. Vollständige Serialisierung ist Pharma-/Tobacco-Thema.
Maschinenanbindung (OPC UA) Liegt außerhalb des SaaS-Scope.
Automatische Rücknahme von Elektroschrott (WEEE-Konto) Liegt bei den Erstinverkehrbringern (Hersteller). Werkszeit dokumentiert nur die Rücknahme als Bewegung.

Risiko / Annahme Impact Wahrscheinlichkeit Gegenmaßnahme
OCI-4.0-Varianten je Händler (Hagebau vs. Würth vs. Sonepar) sind nicht 100 % kompatibel hoch hoch Eigener Adapter pro Händler, Interop-Test gegen Sandbox-Systeme vor jedem Release. Design-Partner pro Händler.
NFC-Tags werden beschädigt / ausgetauscht mittel mittel Fallback manuelle Fahrzeug-Auswahl; monatlicher Tag-Reader-Health-Check.
§13b-UStG-Auslegung bei Material-Beistellung wechselt durch BFH-Urteil hoch niedrig Export-Liste ist formatier-agnostisch, Steuerberater-Konsultation vor Go-Live mit Design-Partner.
Bluetooth-Scanner-Kopplung flaky auf Android 14 mittel mittel Fallback Kamera-Scan, Kompatibilitäts-Matrix mit Zebra/Honeywell dokumentieren.
CRDT-Delta-Semantik verwirrt den Nutzer bei manueller Korrektur mittel mittel UX-Interviews + Konflikt-Ticket zeigt klar „A bekam 5, Du bekamst 5, 3 offen“, nicht abstrakte Set-Differenz.
GoBD-Prüfer akzeptiert Hash-Chain nicht als „unveränderbar“ hoch niedrig Zusätzlich S3 Object Lock Compliance-Mode für Foto-Belege + quartalsweiser WORM-Dump der stock_movements-Partition.
Auto-Lieferauftrag triggert Doppelbestellung bei Händler-Fehler hoch niedrig Idempotency-Key auch zum Händler geschickt (X-Werkszeit-Order-Key); Manager-Freigabe über Schwellenwert.

Offene Annahmen.

  • NTAG213 reicht (180 Byte) vs. NTAG215 (504 Byte): hängt vom Umfang des NDEF-Records (Payload ohne Signatur ≈ 90 Byte, mit HMAC ≈ 140 Byte) ab — Entscheidung vor Bulk-Beschaffung.
  • Der prozentuale Anteil offline-Entnahmen: angenommen 40 %, zu validieren mit erstem Design-Partner.
  • Preisgenauigkeit bei OCI-Hook: Manche Händler liefern Brutto ohne MwSt-Breakdown — im Cart-Reconciliation-Layer abfangen.

  • Vorbedingung:
    • kern/02-auth-scopes (Scopes own/team/all als RLS-Policy-Fabrik).
    • kern/08-audit-log (Hash-verkettete Append-only-Log-Infrastruktur).
    • kern/05-s3-object-lock (presigned-URL-Multipart + Compliance-Mode-Config).
    • handwerk/05-bau-abrechnung (Projekt-IDs als Kostenstellen — Bewegung schreibt projectId).
  • Schnittstelle zu:
    • Bau-ERP-Integrationen (RIB iTWO, Nevaris) via BMEcat-Import.
    • OCI-Händler (Hagebau, Würth, Sonepar, GC-Gruppe, Conrad Business).
    • Stammdaten-Feeds ETIM, GS1 Germany.
  • Wird konsumiert von:
    • handwerk/05-bau-abrechnung (Material-Ist-Kosten pro Projekt).
    • handwerk/07-maengel-tickets (Material-Bezug bei Mangel-Behebung als Entnahme auf projectId=<mängelprojekt>).
    • kern/10-reporting (KPI-Karten Lager-Umschlag, ABC-Analyse).

Status: TBD — zu klären mit Sales / Product-Owner.

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

Kandidaten-Profile:

  • SHK-Betrieb NRW, 25–80 MA, 8–15 Service-Fahrzeuge, Würth- oder Hagebau-Stammkunde, Bestandsprobleme im wöchentlichen Betrieb.
  • Elektro-Handwerk Bayern, 15–40 MA, Sonepar-Anschluss, hoher OCI-Reifegrad, existierender ETIM-Artikelstamm.
  • Facility-Service NDS, 80–150 MA, mehrere Depots, Auto-Replenishment-Bedarf.

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

  1. Wie viele Material-Entnahmen pro Fahrzeug und Tag (Median, Range)?
  2. Welcher Händler ist der wichtigste, mit welcher OCI-Version?
  3. Gab es im letzten Jahr Bestandsabweichungen, die Sie beim Steuerberater erklären mussten?
  4. Wie lange dauert Ihre aktuelle Inventur?
  5. Haben Sie schon NFC-Lösungen im Fahrzeug (z. B. LapID-Siegel)?
  6. Bereit, das Feature im Beta 90 Tage zu testen gegen 20 % Rabatt auf den Pro-Plan?

Build-Sequenz.

  1. Datenmodell + RLS + Multi-Tenant-Test (stock_movements, stock_levels, vehicles, vehicle_nfc_tags) — immer zuerst.
  2. Backend-API /movements + OpenAPI-Spec → Dart-Client.
  3. Mobile Scan-Flow (GS1-128 Parser + Kamera + BT-HID + Offline-Outbox).
  4. NFC-Check-in (Tag-Registration, Nonce-Protokoll).
  5. Web Bestandsübersicht + Konflikt-Panel.
  6. OCI-Integration Händler #1 (Empfehlung Würth, weil reifste OCI-4.0-Doku).
  7. Bestellvorschlag + Auto-Replenishment-Heuristik (noch keine ML).
  8. Inventur-Lauf + GoBD-Export.
  9. Compliance-Test-Suite: GS1-Muster, IDEA-ZIP, HMAC-Fail-Cases.
  10. E2E-Suite (Patrol-Multi-Device für Konflikt-Szenario).
  11. Doku-PR mit auto-generierten Screenshots.

Risiko-Reihenfolge (falls Zeitnot).

  • Multi-Tenant-Isolation, Hash-Chain, Delta-Semantik, Idempotency sind nicht verhandelbar.
  • Streich-Kandidaten: ML-Bestellvorschlag, Auto-Replenishment-Regeln (V2.5), Bluetooth-HID (Kamera reicht im MVP), BMEcat-Import (CSV-Import-First).

Stop-the-Bus-Triggers.

  • Multi-Tenant-Bleed in einem Test.
  • Hash-Chain-Bruch bei Konflikt-Auflösung.
  • Idempotency-Key-Kollision führt zu Doppelbuchung.
  • OCI-Hook akzeptiert gefälschte HMAC.
  • GoBD-Prüfer in Design-Partner-Audit weist den Export zurück.
  • Performance: Fahrzeug-Bestandsübersicht mit 500 Artikeln > 1,5 s p95 auf Seed-Tenant B.

Was uns 2027 dankbar macht.

  • Delta-Semantik statt Set-Semantik. Jede spätere Erweiterung (Multi-Lager, Zonen, Chargen-Vererbung) ist ein weiteres Delta — nicht ein neues Update-Pattern. Migration zu einem echten CRDT-Typ bleibt möglich, weil die Oper-Struktur schon vorhanden ist.
  • Idempotency-Key auf jeder Mutation. Retry-Flakiness (Android-Netz-Wechsel, iOS-Background-Kill) ist damit folgenlos. Alt-App hatte das nicht — siehe LESSONS-LEARNED §4.
  • Hash-Chain ab Tag 1. GoBD-Prüfungen aus 2028 greifen auf 2026er Bewegungen zu — wir müssen das nachträglich nicht einbauen.
  • OCI 4.0 von Anfang an. Wenn der Markt Peppol Catalog übernimmt, ist die Abstraktion „externer Warenkorb“ bereits vorhanden und der Adapter wird ausgetauscht, nicht das Modell.

Letzte Aktualisierung: 2026-04-19.

Für Entwickler — API-Endpoints4
MethodePfadAuthZweck
GET/v1/material/articlesbearerAuthArtikel suchen (Volltext + GTIN-14-Lookup)
POST/v1/material/movementsbearerAuthBestandsbewegung buchen (ISSUE / RETURN / CORRECTION / INVENTORY_SET)
POST/v1/material/vehicles/{vehicleId}/checkinbearerAuthFahrzeug per NFC anmelden (Nonce-Prüfung)
GET/v1/material/vehicles/{vehicleId}/stockbearerAuthBestandsübersicht eines Fahrzeugs