Digitale Führerscheinkontrolle: Systeme für DGUV-gestützte Prozesse
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_sealsundlicense_checksimmer mittenant_idfiltern – niemals nur überid. 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?
| Datenkategorie | Rechtsgrundlage | Speicherdauer |
|---|---|---|
| Name, Personal-Nr. | Arbeitsvertrag (Art. 6 Abs. 1b) | Dauer des Arbeitsverhältnisses + 3 Jahre |
| Führerscheinnummer | Berechtigtes Interesse (Art. 6 Abs. 1f) | Wie oben |
| Kontrolldaten | Rechtliche Verpflichtung (Art. 6 Abs. 1c) | Nach interner Löschfrist / Aufbewahrungskonzept |
| GPS-Position | Je nach DSB-Bewertung: Opt-in oder berechtigtes Interesse | Mit 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
| Fehler | Besser |
|---|---|
| Nur manuelle Kontrolle | Siegel für physische Anwesenheit |
| Keine Eskalation | Mehrstufig: Erinnerung → Vorgesetzter → Sperre |
| GPS immer speichern | Opt-in mit klarer Begründung |
| Führerschein-Foto dauerhaft | Hash speichern, Foto löschen |
| Excel als “System” | Echte Datenbank mit Audit-Trail |
| Feste 6-Monats-Intervalle | Konfigurierbar 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:
- Physische Anwesenheit sicherstellen - Siegel auf dem Führerschein statt Selbstauskunft
- Automatisierung - Erinnerungen und Eskalation ohne manuellen Aufwand
- Revisionssicherheit - Lückenlose Dokumentation mit Timestamps
- DSGVO-Konformität - Datensparsamkeit, klare Löschfristen
- 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.
Ü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