Digitale Führerscheinkontrolle: Systeme für DGUV-gestützte Prozesse
Compliance-Tools

Digitale Führerscheinkontrolle: Systeme für DGUV-gestützte Prozesse

Carola Schulte
28. April 2025
14 min

Digitale Führerscheinkontrolle: Systeme für DGUV-gestützte Prozesse

“Können Sie nachweisen, dass Herr Müller am 15. März einen gültigen Führerschein hatte?” Diese Frage nach einem Unfall kann teuer werden. Ohne lückenlose Dokumentation haftet der Fuhrparkverantwortliche persönlich - und die Versicherung verweigert möglicherweise die Leistung.

Die gute Nachricht: Digitale Führerscheinkontrolle löst dieses Problem elegant. In diesem Artikel zeige ich Ihnen, wie solche Systeme technisch funktionieren - basierend auf Erfahrungen aus der Entwicklung eines Systems mit holografischen Sicherheitssiegeln.


Rechtlicher Hintergrund: Warum Führerscheinkontrolle Pflicht ist

Straf- und haftungsrechtliche Risiken

Unternehmen tragen bei Dienstfahrzeugen eine Organisationsverantwortung. Wird ein Mitarbeiter ohne gültige Fahrerlaubnis am Steuer erwischt oder verursacht einen Unfall, drohen:

  • Strafrechtliche Konsequenzen für Verantwortliche (u.a. bei Fahren ohne Fahrerlaubnis, Organisationsverschulden)
  • Versicherungsrechtliche Probleme - Leistungskürzung oder -verweigerung bei mangelhafter Dokumentation
  • Persönliche Haftung des Fuhrparkverantwortlichen bei nachgewiesener Pflichtverletzung

Hinweis: Die genauen rechtlichen Anforderungen hängen vom Einzelfall ab. Dieser Artikel ersetzt keine Rechtsberatung.

DGUV Vorschrift 70

Die Deutsche Gesetzliche Unfallversicherung schreibt vor:

“Der Unternehmer darf mit dem selbständigen Führen von maschinell angetriebenen Fahrzeugen nur Versicherte beauftragen, die […] im Besitz eines erforderlichen Führerscheins sind.”

Konkret bedeutet das:

  • Führerscheinkontrolle vor Erstüberlassung: Pflicht
  • Regelmäßige Wiederholungskontrolle: mindestens halbjährlich empfohlen
  • Dokumentation: revisionssicher aufbewahren

Manuelle vs. Digitale Kontrolle

Das Problem mit Excel und Papier

┌─────────────────────────────────────────────────────────────────────┐
│                    MANUELLE FÜHRERSCHEINKONTROLLE                   │
├─────────────────────────────────────────────────────────────────────┤
│                                                                     │
│  1. Fuhrparkleiter erstellt Excel-Liste mit Terminen               │
│  2. Fahrer kommt (hoffentlich) zum vereinbarten Termin             │
│  3. Führerschein wird angeschaut und Datum notiert                 │
│  4. Bei Außendienst: Termin verschieben, vergessen, ...            │
│  5. Nach 2 Jahren: "Wo ist die Liste von 2023?"                    │
│                                                                     │
│  ❌ Fehleranfällig  ❌ Nicht skalierbar  ❌ Kein Audit-Trail       │
└─────────────────────────────────────────────────────────────────────┘

Die digitale Lösung

┌─────────────────────────────────────────────────────────────────────┐
│                   DIGITALE FÜHRERSCHEINKONTROLLE                    │
├─────────────────────────────────────────────────────────────────────┤
│                                                                     │
│  1. System sendet automatisch Erinnerung an Fahrer                 │
│  2. Fahrer scannt Sicherheitssiegel mit Smartphone                 │
│  3. System erfasst: Wer, Wann, Wo (GPS), Foto optional             │
│  4. Eskalation bei Nicht-Kontrolle an Vorgesetzten                 │
│  5. Revisionssicheres Archiv mit Suchfunktion                      │
│                                                                     │
│  ✅ Automatisiert  ✅ Skalierbar  ✅ Revisionssicher dokumentiert  │
└─────────────────────────────────────────────────────────────────────┘

Architektur eines Führerscheinkontroll-Systems

Komponenten-Übersicht

┌─────────────────────────────────────────────────────────────────────────┐
│                         FÜHRERSCHEINKONTROLL-SYSTEM                     │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                         │
│  ┌───────────────┐    ┌───────────────┐    ┌───────────────┐           │
│  │  FAHRER-APP   │    │    BACKEND    │    │   DASHBOARD   │           │
│  │               │    │               │    │               │           │
│  │  QR/NFC-Scan  │───▶│  Validierung  │◀───│  Übersicht    │           │
│  │  GPS-Position │    │  Intervalle   │    │  Eskalation   │           │
│  │  Foto-Upload  │    │  Erinnerungen │    │  Reports      │           │
│  └───────────────┘    └───────────────┘    └───────────────┘           │
│                              │                                          │
│                              ▼                                          │
│                    ┌───────────────────┐                               │
│                    │  SICHERHEITSSIEGEL │                               │
│                    │                   │                               │
│                    │  Hologramm + QR   │                               │
│                    │  Auf Führerschein │                               │
│                    │  Manipulations-   │                               │
│                    │  schutz           │                               │
│                    └───────────────────┘                               │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘

Das Sicherheitssiegel-Konzept

Der Clou: Ein holografisches Siegel auf dem Führerschein, das nur vor Ort gescannt werden kann.

Warum das wichtig ist:

  • Physische Anwesenheit beweisen: Der Fahrer muss den echten Führerschein in der Hand haben
  • Manipulationsschutz: Hologramme sind schwer zu fälschen
  • Eindeutige Zuordnung: Jedes Siegel hat eine einmalige ID
<?php

declare(strict_types=1);

class SealService
{
    public function __construct(
        private readonly PDO $db
    ) {}

    /**
     * Siegel validieren - simpel: Token hashen, DB lookup, fertig.
     */
    public function verify(string $scannedToken, string $tenantId): SealVerificationResult
    {
        // Token hashen (auf Siegel steht Klartext, in DB liegt Hash)
        $tokenHash = hash('sha256', $scannedToken);

        // DB lookup mit Tenant-Prüfung
        $stmt = $this->db->prepare(
            'SELECT s.id, s.driver_id, s.revoked_at
             FROM security_seals s
             WHERE s.tenant_id = ?
             AND s.token_hash = ?'
        );
        $stmt->execute([$tenantId, $tokenHash]);
        $seal = $stmt->fetch();

        if ($seal === false) {
            return SealVerificationResult::invalid('Siegel nicht gefunden');
        }

        if ($seal['revoked_at'] !== null) {
            return SealVerificationResult::invalid('Siegel wurde gesperrt');
        }

        return SealVerificationResult::valid($seal['driver_id']);
    }

    /**
     * Neues Siegel erstellen
     */
    public function createSeal(string $tenantId, string $driverId): string
    {
        // Zufälliges Token generieren (16 Zeichen Base32, gut lesbar)
        $token = $this->generateToken();
        $tokenHash = hash('sha256', $token);

        // Altes Siegel des Fahrers revoken
        $this->db->prepare(
            'UPDATE security_seals
             SET revoked_at = NOW(), revoked_reason = ?
             WHERE tenant_id = ? AND driver_id = ? AND revoked_at IS NULL'
        )->execute(['Neues Siegel erstellt', $tenantId, $driverId]);

        // Neues Siegel anlegen
        $this->db->prepare(
            'INSERT INTO security_seals (tenant_id, token_hash, driver_id)
             VALUES (?, ?, ?)'
        )->execute([$tenantId, $tokenHash, $driverId]);

        // Token zurückgeben (wird auf Siegel gedruckt)
        return $token;
    }

    private function generateToken(): string
    {
        // Base32-Alphabet (ohne 0/O/1/I für Lesbarkeit)
        $chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
        $token = '';
        for ($i = 0; $i < 16; $i++) {
            $token .= $chars[random_int(0, strlen($chars) - 1)];
        }
        return $token;
    }
}

Warum so simpel? Auf dem Aufkleber steht ein zufälliges Token (z.B. ABCD-EFGH-JKLM-NPQR). In der Datenbank liegt nur der Hash davon. Bei Scan: Token hashen, DB lookup, fertig. Kein Krypto-Overhead, leicht verständlich, reicht für den Use Case.

Multi-Tenant-Regel: Jede Query auf security_seals und license_checks immer mit tenant_id filtern – niemals nur über id. Sonst kann bei Bugs “falscher Tenant” sichtbar werden.


Datenmodell

PostgreSQL-Schema

-- Mandanten (Unternehmen)
CREATE TABLE tenants (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name VARCHAR(255) NOT NULL,
    settings JSONB DEFAULT '{}',
    created_at TIMESTAMPTZ DEFAULT NOW()
);

-- Fahrer
CREATE TABLE drivers (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id UUID NOT NULL REFERENCES tenants(id),
    employee_number VARCHAR(50),
    first_name VARCHAR(100) NOT NULL,
    last_name VARCHAR(100) NOT NULL,
    email VARCHAR(255),
    phone VARCHAR(50),

    -- Führerscheindaten
    license_number VARCHAR(50) NOT NULL,
    license_classes VARCHAR(50)[], -- {'B', 'BE', 'C', 'CE'}
    license_valid_until DATE,

    -- Kontrollintervall (überschreibt Tenant-Default)
    check_interval_days INTEGER DEFAULT 180, -- 6 Monate

    -- Materialisiert für Scheduler-Performance (Trigger pflegt das)
    next_check_due DATE,

    is_active BOOLEAN DEFAULT true,
    created_at TIMESTAMPTZ DEFAULT NOW(),

    UNIQUE(tenant_id, license_number)
);

CREATE INDEX idx_drivers_tenant ON drivers(tenant_id) WHERE is_active = true;
CREATE INDEX idx_drivers_next_check ON drivers(tenant_id, next_check_due)
    WHERE is_active = true;

-- Sicherheitssiegel
CREATE TABLE security_seals (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id UUID NOT NULL REFERENCES tenants(id),
    token_hash VARCHAR(64) NOT NULL, -- SHA256 des Tokens (Token selbst nicht speichern!)
    driver_id UUID NOT NULL REFERENCES drivers(id),

    issued_at TIMESTAMPTZ DEFAULT NOW(),
    issued_by UUID, -- User der das Siegel ausgegeben hat

    revoked_at TIMESTAMPTZ,
    revoked_reason VARCHAR(255),

    UNIQUE(tenant_id, token_hash)
);

-- Nur ein aktives Siegel pro Fahrer (mit tenant_id für Multi-Tenant-Sicherheit)
CREATE UNIQUE INDEX idx_seals_active_driver
    ON security_seals(tenant_id, driver_id)
    WHERE revoked_at IS NULL;

-- Kontrollen (die wichtigste Tabelle)
CREATE TABLE license_checks (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id UUID NOT NULL REFERENCES tenants(id),
    driver_id UUID NOT NULL REFERENCES drivers(id),
    seal_id UUID REFERENCES security_seals(id),

    checked_at TIMESTAMPTZ NOT NULL,
    checked_by_user UUID, -- Bei manueller Kontrolle

    -- Scan-Details
    scan_method VARCHAR(20) NOT NULL, -- 'qr', 'nfc', 'manual'
    device_id VARCHAR(100),
    app_version VARCHAR(20),

    -- GPS (optional, aber empfohlen)
    latitude DECIMAL(10, 8),
    longitude DECIMAL(11, 8),
    location_accuracy_meters INTEGER,

    -- Ergebnis
    result VARCHAR(20) NOT NULL, -- 'valid', 'expired', 'invalid', 'manual_ok'
    result_details JSONB,

    -- Foto des Führerscheins (optional)
    photo_stored BOOLEAN DEFAULT false,
    photo_hash VARCHAR(64), -- SHA256 für Integrität

    created_at TIMESTAMPTZ DEFAULT NOW()
) PARTITION BY RANGE (checked_at);

-- Partitionen pro Jahr
CREATE TABLE license_checks_2024 PARTITION OF license_checks
    FOR VALUES FROM ('2024-01-01') TO ('2025-01-01');
CREATE TABLE license_checks_2025 PARTITION OF license_checks
    FOR VALUES FROM ('2025-01-01') TO ('2026-01-01');

-- Index für schnelle Abfragen (auf Parent = automatisch auf Partitionen, ab PG11)
-- Bei älteren Versionen oder manueller Partitionierung: pro Partition anlegen
CREATE INDEX idx_checks_driver_time ON license_checks(driver_id, checked_at DESC);
CREATE INDEX idx_checks_tenant_time ON license_checks(tenant_id, checked_at DESC);

-- Trigger: next_check_due automatisch aktualisieren
CREATE OR REPLACE FUNCTION update_next_check_due() RETURNS trigger AS $$
BEGIN
    UPDATE drivers
    SET next_check_due = NEW.checked_at::date + check_interval_days
    WHERE id = NEW.driver_id;
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_next_check_due
    AFTER INSERT ON license_checks
    FOR EACH ROW
    EXECUTE FUNCTION update_next_check_due();

-- Bei Änderung des Intervalls: next_check_due neu berechnen
CREATE OR REPLACE FUNCTION recalc_next_check_due() RETURNS trigger AS $$
BEGIN
    IF NEW.check_interval_days <> OLD.check_interval_days THEN
        -- Aus letztem Check neu berechnen (mit tenant_id für Partition-Pruning)
        SELECT lc.checked_at::date + NEW.check_interval_days
        INTO NEW.next_check_due
        FROM license_checks lc
        WHERE lc.tenant_id = NEW.tenant_id
          AND lc.driver_id = NEW.id
        ORDER BY lc.checked_at DESC
        LIMIT 1;

        -- Fallback: kein Check vorhanden = sofort fällig
        IF NEW.next_check_due IS NULL THEN
            NEW.next_check_due := CURRENT_DATE;
        END IF;
    END IF;
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_recalc_next_check_due
    BEFORE UPDATE OF check_interval_days ON drivers
    FOR EACH ROW
    EXECUTE FUNCTION recalc_next_check_due();

Intervall-Management und Erinnerungen

Der Scheduler

<?php

declare(strict_types=1);

class LicenseCheckScheduler
{
    public function __construct(
        private readonly DriverRepository $drivers,
        private readonly NotificationService $notifications,
        private readonly LoggerInterface $logger
    ) {}

    /**
     * Täglich per Cron ausführen
     */
    public function processUpcomingChecks(string $tenantId): SchedulerResult
    {
        $stats = ['reminded' => 0, 'escalated' => 0, 'blocked' => 0];

        // Fahrer mit next_check_due in den nächsten 14 Tagen
        // Nutzt Index: idx_drivers_next_check(tenant_id, next_check_due)
        $upcomingDrivers = $this->drivers->findWithUpcomingCheck(
            tenantId: $tenantId,
            daysAhead: 14
        );

        foreach ($upcomingDrivers as $driver) {
            $daysUntilDue = $this->calculateDaysUntilDue($driver);

            if ($daysUntilDue <= 0) {
                // Überfällig!
                $this->handleOverdue($driver);
                $stats['escalated']++;
            } elseif ($daysUntilDue <= 3) {
                // Dringend
                $this->sendUrgentReminder($driver);
                $stats['reminded']++;
            } elseif ($daysUntilDue <= 7) {
                // Normal
                $this->sendReminder($driver);
                $stats['reminded']++;
            } elseif ($daysUntilDue <= 14) {
                // Erste Erinnerung
                $this->sendFirstReminder($driver);
                $stats['reminded']++;
            }
        }

        return new SchedulerResult($stats);
    }

    private function calculateDaysUntilDue(Driver $driver): int
    {
        // next_check_due ist materialisiert (Trigger pflegt es)
        if ($driver->nextCheckDue === null) {
            // Noch nie kontrolliert = sofort fällig
            return 0;
        }

        $now = new DateTimeImmutable('today');
        return (int) $now->diff($driver->nextCheckDue)->format('%r%a');
    }

    private function handleOverdue(Driver $driver): void
    {
        $daysPastDue = abs($this->calculateDaysUntilDue($driver));

        // Eskalationsstufen
        if ($daysPastDue >= 14) {
            // Stufe 3: Fahrzeugnutzung sperren
            $this->blockVehicleUsage($driver);
            $this->notifyManagement($driver, EscalationLevel::Critical);

            $this->logger->critical('Führerscheinkontrolle kritisch überfällig', [
                'driver_id' => $driver->id,
                'days_overdue' => $daysPastDue
            ]);

        } elseif ($daysPastDue >= 7) {
            // Stufe 2: Vorgesetzten informieren
            $this->notifications->notifySupervisor($driver);
            $this->notifications->sendUrgent($driver,
                'Führerscheinkontrolle überfällig - bitte umgehend durchführen!'
            );

        } else {
            // Stufe 1: Tägliche Erinnerung
            $this->notifications->sendDaily($driver,
                'Ihre Führerscheinkontrolle ist überfällig.'
            );
        }
    }

    private function blockVehicleUsage(Driver $driver): void
    {
        // Integration mit Fuhrpark-System
        $this->drivers->setVehicleAccessBlocked($driver->id, true);

        // Wenn Fahrzeugschlüssel-System vorhanden
        // $this->keySystem->revokeAccess($driver->employeeNumber);
    }
}

Erinnerungs-Templates

<?php

class NotificationTemplates
{
    public static function firstReminder(Driver $driver, int $daysRemaining): string
    {
        return <<<TEXT
        Hallo {$driver->firstName},

        Ihre Führerscheinkontrolle ist in {$daysRemaining} Tagen fällig.

        So führen Sie die Kontrolle durch:
        1. Öffnen Sie die Führerscheinkontroll-App
        2. Scannen Sie das Siegel auf Ihrem Führerschein
        3. Fertig!

        Bei Fragen wenden Sie sich an Ihren Fuhrparkverantwortlichen.

        Viele Grüße
        Ihr Fuhrpark-Team
        TEXT;
    }

    public static function urgentReminder(Driver $driver): string
    {
        return <<<TEXT
        ⚠️ DRINGEND: Führerscheinkontrolle erforderlich

        Hallo {$driver->firstName},

        Ihre Führerscheinkontrolle ist in weniger als 3 Tagen fällig!

        Bitte führen Sie die Kontrolle HEUTE durch, um eine
        Sperrung Ihrer Fahrzeugnutzung zu vermeiden.

        TEXT;
    }

    public static function supervisorEscalation(
        Driver $driver,
        int $daysOverdue
    ): string {
        return <<<TEXT
        🚨 Eskalation: Überfällige Führerscheinkontrolle

        Mitarbeiter: {$driver->firstName} {$driver->lastName}
        Personal-Nr.: {$driver->employeeNumber}
        Überfällig seit: {$daysOverdue} Tagen

        Der Mitarbeiter hat trotz mehrfacher Erinnerung keine
        Führerscheinkontrolle durchgeführt.

        Bitte ergreifen Sie geeignete Maßnahmen.

        TEXT;
    }
}

Der Scan-Prozess

App-seitiger Flow (vereinfacht)

// React Native / Flutter - vereinfachter Code

interface ScanResult {
  sealToken: string;
  timestamp: string;
  location?: {
    latitude: number;
    longitude: number;
    accuracy: number;
  };
  deviceId: string;
}

async function performLicenseCheck(): Promise<CheckResult> {
  // 1. GPS-Position erfassen (falls erlaubt)
  const location = await getCurrentLocation();

  // 2. QR-Code scannen
  const scanResult = await scanQRCode();

  if (!scanResult.success) {
    return { success: false, error: 'Scan fehlgeschlagen' };
  }

  // 3. An Backend senden
  const response = await api.post('/license-checks', {
    sealToken: scanResult.data,
    scannedAt: new Date().toISOString(),
    location: location,
    deviceId: getDeviceId(),
    appVersion: APP_VERSION,
  });

  // 4. Ergebnis anzeigen
  if (response.result === 'valid') {
    showSuccess('Führerscheinkontrolle erfolgreich!');
    showNextCheckDate(response.nextCheckDue);
  } else {
    showError(response.message);
    // Bei Fehler: Möglichkeit zur manuellen Kontrolle anbieten
  }

  return response;
}

Backend-Validierung

<?php

declare(strict_types=1);

class LicenseCheckController
{
    public function __construct(
        private readonly SealRepository $seals,
        private readonly DriverRepository $drivers,
        private readonly CheckRepository $checks,
        private readonly AuditLogger $audit
    ) {}

    public function performCheck(Request $request): JsonResponse
    {
        $tenantId = $request->user()->tenantId; // Aus Auth-Context

        $data = $request->validate([
            'sealToken' => 'required|string|max:100',
            'scannedAt' => 'required|date',
            'location' => 'nullable|array',
            'location.latitude' => 'nullable|numeric|between:-90,90',
            'location.longitude' => 'nullable|numeric|between:-180,180',
            'deviceId' => 'required|string|max:100',
            'appVersion' => 'required|string|max:20',
        ]);

        // 1. Siegel finden und validieren (hasht Token intern, sucht mit tenant_id)
        $seal = $this->seals->findByToken($tenantId, $data['sealToken']);

        if ($seal === null) {
            $this->audit->log('unknown_seal_scanned', $data);
            return $this->errorResponse('Unbekanntes Siegel', 'SEAL_NOT_FOUND');
        }

        if ($seal->isRevoked()) {
            $this->audit->log('revoked_seal_scanned', [
                'seal_id' => $seal->id,
                'revoked_at' => $seal->revokedAt,
            ]);
            return $this->errorResponse(
                'Siegel wurde gesperrt. Bitte wenden Sie sich an den Fuhrpark.',
                'SEAL_REVOKED'
            );
        }

        // 2. Fahrer ermitteln
        $driver = $this->drivers->find($seal->driverId);

        if (!$driver->isActive) {
            return $this->errorResponse(
                'Fahrer ist nicht mehr aktiv',
                'DRIVER_INACTIVE'
            );
        }

        // 3. Führerschein-Gültigkeit prüfen
        $licenseStatus = $this->checkLicenseValidity($driver);

        // 4. Scan-Zeit validieren (Anti-Manipulation)
        // Client kann max. 24h in die Vergangenheit scannen (Offline-Toleranz)
        $scannedAt = new DateTimeImmutable($data['scannedAt']);
        $now = new DateTimeImmutable();
        $maxBackdate = $now->modify('-24 hours');

        if ($scannedAt > $now) {
            $scannedAt = $now; // Zukunft → Server-Zeit
        } elseif ($scannedAt < $maxBackdate) {
            $scannedAt = $maxBackdate; // Zu weit zurück → Cap
        }

        // 5. Kontrolle speichern
        $check = new LicenseCheck(
            tenantId: $driver->tenantId,
            driverId: $driver->id,
            sealId: $seal->id,
            checkedAt: $scannedAt,
            scanMethod: 'qr',
            deviceId: $data['deviceId'],
            appVersion: $data['appVersion'],
            latitude: $data['location']['latitude'] ?? null,
            longitude: $data['location']['longitude'] ?? null,
            result: $licenseStatus->result,
            resultDetails: $licenseStatus->details,
        );

        $this->checks->save($check);

        // 6. Audit-Log
        $this->audit->log('license_check_performed', [
            'check_id' => $check->id,
            'driver_id' => $driver->id,
            'result' => $licenseStatus->result,
        ]);

        // 7. Nächsten Termin (Trigger hat ihn schon berechnet, hier nur Response)
        // Wichtig bei Offline-Scan/Backdating: Intervall startet ab Kontrolle
        $nextCheckDue = $check->checkedAt
            ->modify("+{$driver->checkIntervalDays} days");

        return response()->json([
            'success' => true,
            'result' => $licenseStatus->result,
            'message' => $licenseStatus->message,
            'nextCheckDue' => $nextCheckDue->format('Y-m-d'),
            'driverName' => $driver->firstName . ' ' . $driver->lastName,
        ]);
    }

    private function checkLicenseValidity(Driver $driver): LicenseStatus
    {
        // Führerschein abgelaufen?
        if ($driver->licenseValidUntil !== null) {
            $today = new DateTimeImmutable('today');

            if ($driver->licenseValidUntil < $today) {
                return new LicenseStatus(
                    result: 'expired',
                    message: 'Führerschein abgelaufen am ' .
                             $driver->licenseValidUntil->format('d.m.Y'),
                    details: ['expired_at' => $driver->licenseValidUntil]
                );
            }

            // Läuft bald ab? (Warnung)
            $warningDate = $today->modify('+30 days');
            if ($driver->licenseValidUntil < $warningDate) {
                return new LicenseStatus(
                    result: 'valid',
                    message: 'Kontrolle erfolgreich. Hinweis: Führerschein läuft am ' .
                             $driver->licenseValidUntil->format('d.m.Y') . ' ab.',
                    details: ['expires_soon' => true]
                );
            }
        }

        return new LicenseStatus(
            result: 'valid',
            message: 'Führerscheinkontrolle erfolgreich durchgeführt.',
            details: []
        );
    }
}

DSGVO-Aspekte

Welche Daten werden verarbeitet?

DatenkategorieRechtsgrundlageSpeicherdauer
Name, Personal-Nr.Arbeitsvertrag (Art. 6 Abs. 1b)Dauer des Arbeitsverhältnisses + 3 Jahre
FührerscheinnummerBerechtigtes Interesse (Art. 6 Abs. 1f)Wie oben
KontrolldatenRechtliche Verpflichtung (Art. 6 Abs. 1c)Nach interner Löschfrist / Aufbewahrungskonzept
GPS-PositionJe nach DSB-Bewertung: Opt-in oder berechtigtes InteresseMit Kontrolldaten

GPS-Erfassung: Standard ist: optional und minimiert. Ob Einwilligung oder berechtigtes Interesse greift, hängt von Betriebsvereinbarung und DSB-Bewertung ab. Im Zweifel: Opt-in mit klarer Information.

Foto des Führerscheins: In vielen Unternehmen tabu - zu viele DSGVO-Fallstricke. Standard ist: kein Foto, nur Scan-Event + Siegel-Validierung + Audit-Trail. Das reicht für den Nachweis.

Datensparsamkeit umsetzen

<?php

class GdprCompliantStorage
{
    /**
     * GPS nur speichern wenn wirklich nötig
     */
    public function shouldStoreLocation(Tenant $tenant, Driver $driver): bool
    {
        // Tenant-Setting prüfen
        if (!$tenant->getSetting('store_check_location', false)) {
            return false;
        }

        // Fahrer hat Widerspruch eingelegt?
        if ($driver->hasOptedOutOfLocationTracking()) {
            return false;
        }

        return true;
    }

    /**
     * Foto-Hash statt Foto speichern
     */
    public function processLicensePhoto(
        string $photoData,
        LicenseCheck $check
    ): void {
        // Foto nur temporär zur Validierung nutzen
        $hash = hash('sha256', $photoData);

        // Optional: KI-Validierung (Führerschein erkannt?)
        $isValidLicense = $this->validateLicenseImage($photoData);

        // Nur Hash speichern, nicht das Foto selbst
        $check->photoHash = $hash;
        $check->photoValidated = $isValidLicense;

        // Foto NICHT speichern (Datensparsamkeit)
        // Wenn doch nötig: verschlüsselt und zeitlich begrenzt
    }

    /**
     * Automatische Anonymisierung nach Aufbewahrungsfrist
     * driver_id bleibt (FK NOT NULL), nur Metadaten werden gelöscht
     */
    public function cleanupExpiredData(Tenant $tenant): int
    {
        $retentionYears = $tenant->getSetting('retention_years', 10);
        $cutoffDate = (new DateTimeImmutable())
            ->modify("-{$retentionYears} years");

        // Metadaten anonymisieren, FK bleibt intakt
        $count = $this->db->execute(
            "UPDATE license_checks
             SET latitude = NULL,
                 longitude = NULL,
                 device_id = NULL,
                 photo_hash = NULL
             WHERE tenant_id = ?
             AND checked_at < ?
             AND (latitude IS NOT NULL
                  OR longitude IS NOT NULL
                  OR device_id IS NOT NULL
                  OR photo_hash IS NOT NULL)",
            [$tenant->id, $cutoffDate]
        );

        return $count;
    }
}

Dashboard für Fuhrparkleiter

Statusübersicht

<?php

class DashboardService
{
    public function getComplianceOverview(string $tenantId): ComplianceOverview
    {
        // Nutzt materialisierte next_check_due – kein LATERAL nötig
        $stats = $this->db->query(
            "SELECT
                COUNT(*) FILTER (WHERE status = 'ok') as compliant,
                COUNT(*) FILTER (WHERE status = 'due_soon') as due_soon,
                COUNT(*) FILTER (WHERE status = 'overdue') as overdue,
                COUNT(*) FILTER (WHERE status = 'blocked') as blocked,
                COUNT(*) as total
             FROM (
                SELECT
                    d.id,
                    CASE
                        WHEN d.vehicle_access_blocked THEN 'blocked'
                        WHEN d.next_check_due IS NULL THEN 'overdue'
                        WHEN d.next_check_due < CURRENT_DATE THEN 'overdue'
                        WHEN d.next_check_due < CURRENT_DATE + 14 THEN 'due_soon'
                        ELSE 'ok'
                    END as status
                FROM drivers d
                WHERE d.tenant_id = ?
                AND d.is_active = true
             ) sub",
            [$tenantId]
        );

        return new ComplianceOverview(
            compliant: $stats['compliant'],
            dueSoon: $stats['due_soon'],
            overdue: $stats['overdue'],
            blocked: $stats['blocked'],
            total: $stats['total'],
            complianceRate: $stats['total'] > 0
                ? round($stats['compliant'] / $stats['total'] * 100, 1)
                : 100.0
        );
    }
}

Export für Audits

<?php

class AuditExportService
{
    /**
     * Export für Versicherung/Behörden
     */
    public function generateAuditReport(
        string $tenantId,
        DateTimeImmutable $from,
        DateTimeImmutable $to
    ): AuditReport {
        $checks = $this->db->query(
            "SELECT
                d.employee_number,
                d.first_name,
                d.last_name,
                d.license_number,
                d.license_classes,
                lc.checked_at,
                lc.scan_method,
                lc.result,
                lc.latitude,
                lc.longitude
             FROM license_checks lc
             JOIN drivers d ON d.id = lc.driver_id
             WHERE lc.tenant_id = ?
             AND lc.checked_at BETWEEN ? AND ?
             ORDER BY lc.checked_at DESC",
            [$tenantId, $from, $to]
        );

        return new AuditReport(
            generatedAt: new DateTimeImmutable(),
            periodFrom: $from,
            periodTo: $to,
            checks: $checks,
            summary: $this->generateSummary($checks),
            // Digitale Signatur für Integrität
            signature: $this->signReport($checks)
        );
    }

    private function signReport(array $checks): string
    {
        $data = json_encode($checks);
        return hash_hmac('sha256', $data, $this->reportSigningKey);
    }
}

Checkliste: Führerscheinkontroll-System entwickeln

MVP (Phase 1):

  • Fahrer- und Siegel-Verwaltung
  • QR-Code-Scan in App
  • Basis-Validierung (Siegel gültig?)
  • Kontrolle speichern mit Timestamp
  • Einfache Übersicht für Fuhrparkleiter

Erweiterung (Phase 2):

  • Automatische Erinnerungen (Email/Push)
  • Eskalationsprozess
  • GPS-Erfassung (optional)
  • Dashboard mit Compliance-Rate
  • PDF-Export für Audits

Enterprise (Phase 3):

  • NFC-Siegel-Support
  • Integration Fuhrpark-Software
  • Fahrzeugsperre bei Nicht-Kontrolle
  • Multi-Tenant / White-Label
  • API für Drittsysteme

Typische Fehler vermeiden

FehlerBesser
Nur manuelle KontrolleSiegel für physische Anwesenheit
Keine EskalationMehrstufig: Erinnerung → Vorgesetzter → Sperre
GPS immer speichernOpt-in mit klarer Begründung
Führerschein-Foto dauerhaftHash speichern, Foto löschen
Excel als “System”Echte Datenbank mit Audit-Trail
Feste 6-Monats-IntervalleKonfigurierbar pro Fahrer/Fahrzeugklasse

Fazit

Digitale Führerscheinkontrolle ist kein Luxus, sondern notwendige Absicherung. Die Haftungsrisiken bei mangelhafter Dokumentation sind erheblich - im schlimmsten Fall persönliche Strafe für den Verantwortlichen.

Die wichtigsten Punkte:

  1. Physische Anwesenheit sicherstellen - Siegel auf dem Führerschein statt Selbstauskunft
  2. Automatisierung - Erinnerungen und Eskalation ohne manuellen Aufwand
  3. Revisionssicherheit - Lückenlose Dokumentation mit Timestamps
  4. DSGVO-Konformität - Datensparsamkeit, klare Löschfristen
  5. Skalierbarkeit - Von 10 bis 10.000 Fahrer ohne Mehraufwand

Die Technik ist überschaubar. Die Herausforderung liegt in der Akzeptanz: Fahrer müssen das System nutzen. Deshalb: So einfach wie möglich machen. Ein Scan, fertig. Keine komplizierten Formulare, keine Desktop-Pflicht.

Ein gut implementiertes System spart nicht nur Verwaltungsaufwand - es kann im Ernstfall vor persönlicher Haftung schützen.

Carola Schulte

Über Carola Schulte

Software-Architektin mit 25+ Jahren Erfahrung. Spezialisiert auf robuste Business-Apps mit PHP/PostgreSQL, Security-by-Design und DSGVO-konforme Systeme. 1,8M+ Lines of Code in Produktion.

Projekt im Kopf?

Lassen Sie uns besprechen, wie ich Ihre Anforderungen umsetzen kann – kostenlos und unverbindlich.

Kostenloses Erstgespräch