403Webshell
Server IP : 202.61.199.114  /  Your IP : 216.73.217.139
Web Server : nginx/1.22.1
System : Linux de.arni-solutions.de 6.1.0-49-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.1.174-1 (2026-05-26) x86_64
User : web20 ( 1018)
PHP Version : 8.4.23
Disable Function : NONE
MySQL : OFF  |  cURL : ON  |  WGET : ON  |  Perl : ON  |  Python : OFF  |  Sudo : ON  |  Pkexec : ON
Directory :  /var/www/seaside2.pacim.de/web/app/services/

Upload File :
current_dir [ Writeable ] document_root [ Writeable ]

 

Command :


[ Back ]     

Current File : /var/www/seaside2.pacim.de/web/app/services/AidaBookingImporter.php
<?php
declare(strict_types=1);

/**
 * Importer für AIDA-Buchungslisten (XLSX).
 *
 * AIDA liefert pro Anlauf zwei Listen:
 *   - "First" ca. 2 Tage vor dem Anlauf (Ersteinlieferung)
 *   - "Update" ca. 1 Tag vor dem Anlauf (Korrektur)
 *
 * Im Update können Zeilen geändert, neu, weg oder umsortiert sein. Die Zeilennummer im
 * Excel taugt deshalb NIE zur Wiedererkennung. Stattdessen:
 *
 *   external_uid  → SHA1 über fachliche Schlüsselfelder (Cruise + Booking-Nr + Kabine/Plate),
 *                   stabil über Updates hinweg. UNIQUE(source, external_uid) verhindert Dubletten.
 *   content_hash  → SHA1 über veränderliche Felder, erkennt Änderungen ohne Spalten-Diff.
 *
 * Workflow je Datei:
 *   1. Header dynamisch lesen, Zeilen in normalisierte Form bringen.
 *   2. Zeilen pro Cruise-ID gruppieren.
 *   3. Für jede Cruise: events.cruise lookup; fehlt das Event → Cruise überspringen + Fehler sammeln.
 *   4. Pro gültige Zeile: external_uid + content_hash bilden, Customer + Booking upserten,
 *      booking_products synchronisieren.
 *   5. Nach allen Inserts/Updates derselben Cruise: alle Buchungen DIESES Events stornieren,
 *      deren external_uid nicht in der aktuellen Liste auftauchte (Storno-Pass).
 *   6. import_runs-Datensatz mit Summary abschließen.
 *
 * Sicherheitsnetze:
 *   - Leere/fehlerhafte Liste → kein Storno-Pass (Schutz vor Massenstornierung).
 *   - Kein passendes Event → die Cruise-Gruppe wird vollständig übersprungen, kein Storno.
 *   - Unbekannter Product Code → Zeile übersprungen + Fehler.
 *   - Fehlende Booking No → Zeile übersprungen + Fehler.
 *   - Datei wird in `files` registriert (MD5-Dedupe), nach Verarbeitung nach data/trash/YYYY-MM-DD/ verschoben.
 *   - Alles in einer DB-Transaktion pro Datei; bei Throwable wird gerollt zurück.
 */
final class AidaBookingImporter
{
    private const SOURCE              = 'AIDA';
    private const DEFAULT_VAT_RATE    = 19.00;
    /** Produkt-Mapping nach den ersten 4 Zeichen des Product Codes. */
    private const PRODUCT_MAP = [
        'PW50' => [1],          // Hallenstellplatz
        'PW85' => [1, 8],       // Halle + Valet
        'PW40' => [2],          // Außenstellplatz
        'PW75' => [2, 8],       // Außen + Valet
    ];

    /**
     * UUID des gerade laufenden File-Imports — wird vor processFile() gesetzt und an
     * upsertBooking/upsertCustomer durchgereicht. Beim INSERT neuer Records landet sie
     * in `import_uuid`, beim UPDATE bleibt der bisherige Wert unverändert (Spur zum
     * ursprünglichen Import bleibt erhalten).
     */
    private ?string $currentImportUuid = null;

    public function __construct(
        private readonly PDO $pdo,
        private readonly string $inboxDir,
        private readonly string $trashDir,
        private readonly string $projectRoot = '',
    ) {}

    /** Erzeugt eine RFC-4122-konforme UUID v4. Wird pro Datei-Import einmal aufgerufen. */
    public static function makeUuidV4(): string
    {
        $b = random_bytes(16);
        $b[6] = chr((ord($b[6]) & 0x0f) | 0x40);
        $b[8] = chr((ord($b[8]) & 0x3f) | 0x80);
        return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($b), 4));
    }

    public static function fromDatabase(Database $db, string $dataDir): self
    {
        return new self(
            $db->getConnection(),
            rtrim($dataDir, '/') . '/aida',
            rtrim($dataDir, '/') . '/trash',
            rtrim(dirname(rtrim($dataDir, '/')), '/'),
        );
    }

    /** Absoluten Pfad in "/data/..."-Form (relativ zum Projektroot) konvertieren. */
    private function toWebPath(string $absPath): string
    {
        if ($this->projectRoot !== '' && str_starts_with($absPath, $this->projectRoot)) {
            return substr($absPath, strlen($this->projectRoot));
        }
        return $absPath;
    }

    /* -------------------------------------------------------------------- */
    /*  Public Entry Points                                                 */
    /* -------------------------------------------------------------------- */

    /**
     * Verarbeitet den Inbox-Ordner. Gruppiert alle XLSX-Dateien zuerst in BATCHES
     * (= ein AIDA-Anlauf besteht aus mehreren Produkt-Dateien). Pro Batch:
     *   - alle Dateien einzeln importieren (Bookings, Customers, Invoices, Payments)
     *   - external_uids ALLER Dateien sammeln
     *   - am ENDE des Batches einmal cancelMissingBookings ausführen (nur bei final)
     *
     * Das verhindert den bisherigen Bug, der nach JEDER Einzeldatei stornierte und damit
     * die Buchungen aus den anderen 4 Produktdateien fälschlich verlor.
     */
    public function processInbox(): array
    {
        $summary = [
            'batches'           => [],
            'skipped_duplicate' => [],
            'created'           => 0,
            'updated'           => 0,
            'unchanged'         => 0,
            'cancelled'         => 0,
            'errors'            => [],
        ];
        if (!is_dir($this->inboxDir)) return $summary;

        // ---- 1) Scan + Filename-Dedup + Gruppierung -----------------------
        $entries = scandir($this->inboxDir) ?: [];
        sort($entries);
        $batches = [];   // batchKey → ['meta' => [...], 'files' => [['path'=>..., 'filename'=>..., 'product_code'=>...], ...]]
        foreach ($entries as $name) {
            if ($name === '' || $name[0] === '.') continue;
            $path = $this->inboxDir . '/' . $name;
            if (!is_file($path)) continue;
            if (strtolower(pathinfo($path, PATHINFO_EXTENSION)) !== 'xlsx') continue;

            // Filename-Dedup: gleiche Datei nie zweimal importieren.
            $stmt = $this->pdo->prepare("SELECT 1 FROM import_files WHERE source = :src AND filename = :n LIMIT 1");
            $stmt->execute(['src' => self::SOURCE, 'n' => $name]);
            if ($stmt->fetchColumn()) {
                $summary['skipped_duplicate'][] = $name;
                $this->moveToTrash($path);
                continue;
            }

            $meta = $this->peekFileMetadata($path);
            if ($meta === null) {
                $summary['errors'][] = "$name: konnte Datei-Metadaten nicht ermitteln — übersprungen";
                continue;
            }
            $batchKey = self::SOURCE . '|' . $meta['cruise'] . '|' . ($meta['file_date'] ?? '0');
            if (!isset($batches[$batchKey])) $batches[$batchKey] = ['meta' => $meta, 'files' => []];
            $batches[$batchKey]['files'][] = [
                'path'         => $path,
                'filename'     => $name,
                'product_code' => $meta['product_code'],
            ];
        }

        // ---- 2) PASS 1: Alle Batches IMPORTIEREN (ohne Storno-Pass) -------
        // Cross-Event-Umbuchungen werden in upsertBooking() schon hier erkannt: wenn AIDA
        // dieselbe Booking-No im neuen Anlauf einer anderen Cruise zuordnet, wird der
        // bestehende DB-Datensatz auf das neue Event/cruise/UID umgezogen — keine doppelten
        // Zeilen, keine fälschliche Stornierung.
        $perBatch = [];   // batchKey → result
        foreach ($batches as $batchKey => $batch) {
            $perBatch[$batchKey] = $this->processBatch($batch);
        }

        // ---- 3) Globales Sub-Key-Wissen aufbauen --------------------------
        // subKey ("legacy_booking_no|parking_count_token") → Liste von Events, in denen
        // die Buchung im AKTUELLEN Importlauf gesehen wurde. Der Cancel-Pass nutzt das,
        // um zu erkennen, ob eine Buchung wirklich weg ist oder nur in eine andere
        // Cruise umgezogen wurde.
        $globalLegacyToUid = [];
        foreach ($perBatch as $r) {
            $evId = $r['event_id'] ?? null;
            foreach (($r['_legacy_to_uid'] ?? []) as $subKey => $uids) {
                foreach ($uids as $u) {
                    $globalLegacyToUid[(string)$subKey][] = [
                        'event_id' => $evId,
                        'uid'      => $u,
                    ];
                }
            }
        }

        // ---- 4) PASS 2: Cancel-Pässe für alle final-Batches ---------------
        foreach ($perBatch as $batchKey => $r) {
            $eventId  = $r['event_id'] ?? null;
            $listType = $r['list_type'] ?? '';
            if ($listType === 'final' && $eventId !== null) {
                $reason = $this->cancelSafetyCheck(
                    (int)$eventId,
                    $r['_seen_uids']        ?? [],
                    $r['_fail_file_count']  ?? 0
                );
                if ($reason === null) {
                    $cancel = $this->cancelMissingBookings(
                        (int)$eventId,
                        $r['_seen_uids']      ?? [],
                        $r['_legacy_to_uid']  ?? [],
                        $globalLegacyToUid,
                    );
                    $r['cancelled']           = $cancel['count'];
                    $r['uid_mismatches']      = $cancel['mismatches']           ?? [];
                    $r['cross_event_skipped'] = $cancel['cross_event_skipped']  ?? [];
                } else {
                    $r['cancel_skipped_reason'] = $reason;
                    $r['errors'][]              = "Storno-Pass übersprungen: $reason";
                }
            }

            // Batch-Record erst jetzt finalisieren — das Storno-Ergebnis soll im JSON-Summary stehen.
            if (!empty($r['batch_id'])) {
                $this->finishBatchRecord((int)$r['batch_id'], $r);
            }

            // Interna nicht in die externe Summary leaken.
            unset($r['_seen_uids'], $r['_legacy_to_uid'], $r['_fail_file_count']);

            $perBatch[$batchKey]   = $r;
            $summary['batches'][]  = $r;
            $summary['created']   += $r['created']   ?? 0;
            $summary['updated']   += $r['updated']   ?? 0;
            $summary['unchanged'] += $r['unchanged'] ?? 0;
            $summary['cancelled'] += $r['cancelled'] ?? 0;
            $summary['errors']     = [...$summary['errors'], ...$r['errors'] ?? []];
        }
        return $summary;
    }

    /**
     * Verarbeitet einen kompletten Batch (alle Produkt-Dateien eines Anlaufs).
     */
    private function processBatch(array $batch): array
    {
        $meta  = $batch['meta'];
        $files = $batch['files'];
        $event = $this->findEvent($meta['cruise']);

        $result = [
            'batch_id'              => null,
            'cruise'                => $meta['cruise'],
            'event_id'              => $event['id'] ?? null,
            'ship'                  => $meta['ship'],
            'file_date'             => $meta['file_date'],
            'departure_date'        => $meta['departure_date'],
            'list_type'             => $meta['list_type'],
            'files'                 => count($files),
            'product_codes'         => array_values(array_unique(array_filter(array_column($files, 'product_code')))),
            'created'               => 0,
            'updated'               => 0,
            'unchanged'             => 0,
            'cancelled'             => 0,
            'errors'                => [],
            'cancel_skipped_reason' => null,
        ];

        if ($event === null) {
            // Ohne Event NICHTS stornieren. Dateien bleiben in der Inbox, damit das
            // Event nachgepflegt + erneut importiert werden kann.
            $result['errors'][] = "Cruise {$meta['cruise']}: kein Event mit cruise='{$meta['cruise']}' gefunden — " . count($files) . " Datei(en) übersprungen";
            return $result;
        }

        $batchId       = $this->createBatchRecord($meta, (int)$event['id']);
        $result['batch_id'] = $batchId;
        $allSeenUids       = [];
        $allLegacyToUid    = [];     // legacy_booking_no → [uid, …] über alle Dateien des Batches
        $okFileCount       = 0;
        $failFileCount     = 0;

        foreach ($files as $f) {
            $fileRes = $this->processFile($f['path'], $meta, $event, $batchId);
            $this->recordImportFile($batchId, $f, $fileRes, $meta);

            if ($fileRes['status'] === 'imported') {
                $okFileCount++;
                $result['created']   += $fileRes['created']   ?? 0;
                $result['updated']   += $fileRes['updated']   ?? 0;
                $result['unchanged'] += $fileRes['unchanged'] ?? 0;
                $allSeenUids = array_merge($allSeenUids, $fileRes['uids'] ?? []);
                foreach (($fileRes['legacy_to_uid'] ?? []) as $lbn => $uids) {
                    foreach ($uids as $u) {
                        $allLegacyToUid[(string)$lbn][] = $u;
                    }
                }
            } else {
                $failFileCount++;
            }
            $result['errors'] = [...$result['errors'], ...$fileRes['errors'] ?? []];

            // Datei verschieben, auch bei parse_failed (außer wir wollen sie zur Reparatur in der Inbox lassen).
            if ($fileRes['status'] !== 'parse_failed') {
                $oldWeb      = $this->toWebPath($f['path']);
                $trashedPath = $this->moveToTrash($f['path']);
                if ($trashedPath !== '' && $trashedPath !== $f['path']) {
                    $upd = $this->pdo->prepare("UPDATE files SET file_path = :new WHERE file_path = :old");
                    $upd->execute(['new' => $this->toWebPath($trashedPath), 'old' => $oldWeb]);
                }
                if (!empty($fileRes['log_rows'])) {
                    $logPath = $this->writeLogFile($trashedPath, $fileRes['log_rows'], $fileRes);
                    if ($logPath !== '') $this->registerLogFile($logPath);
                }
            }
        }

        // Storno-Pass wird NICHT hier ausgeführt — processInbox() macht das erst am Ende
        // aller Batches mit globalem Wissen über alle Sub-Keys, damit Cross-Event-Umbuchungen
        // korrekt erkannt werden. Wir geben die Sammlung als Interna mit zurück.
        $result['_seen_uids']       = $allSeenUids;
        $result['_legacy_to_uid']   = $allLegacyToUid;
        $result['_fail_file_count'] = $failFileCount;
        return $result;
    }

    /**
     * Sicherheitsprüfung vor dem Storno-Pass. Liefert NULL = ok zum Stornieren,
     * sonst eine kurze Begründung warum nicht.
     */
    private function cancelSafetyCheck(int $eventId, array $seenUids, int $failedFileCount): ?string
    {
        if (empty($seenUids)) {
            return 'keine UIDs im Batch (Sicherheitsstopp gegen Massenstorno)';
        }
        if ($failedFileCount > 0) {
            return "$failedFileCount Datei(en) im Batch fehlerhaft — Storno verschoben";
        }
        // Retention-Check: wenn deutlich weniger als 30 % der Bestandsbuchungen in der neuen
        // Liste sind, deutet das auf einen kaputten Import hin → kein automatischer Storno.
        $stmt = $this->pdo->prepare("SELECT COUNT(*) FROM bookings
                                       WHERE source = :s AND event_id = :e AND status <> 'cancelled'");
        $stmt->execute(['s' => self::SOURCE, 'e' => $eventId]);
        $existing = (int)$stmt->fetchColumn();
        if ($existing > 10) {
            $threshold = (int)ceil($existing * 0.3);
            if (count($seenUids) < $threshold) {
                return sprintf(
                    'nur %d von %d Bestandsbuchungen in der neuen Liste (< 30%%) — vermutlich unvollständig',
                    count($seenUids), $existing
                );
            }
        }
        return null;
    }

    /**
     * Verarbeitet EINE Excel-Datei im Kontext eines Batches.
     * Liefert {status, created, updated, unchanged, errors, uids, log_rows}.
     * Status: 'imported' | 'duplicate' | 'not_aida_bookings' | 'parse_failed'.
     *
     * Wichtig: die Storno-Logik ist hier NICHT mehr drin — der Batch-Orchestrator
     * sammelt die UIDs aller Dateien und ruft cancelMissingBookings einmalig am Ende auf.
     */
    public function processFile(string $path, ?array $batchMeta = null, ?array $event = null, ?int $batchId = null): array
    {
        $md5 = md5_file($path);
        if ($md5 === false) {
            return $this->result('parse_failed', ['md5_file fehlgeschlagen']);
        }

        // Datei-MD5-Dedupe gegen `files`.
        $stmt = $this->pdo->prepare("SELECT 1 FROM files WHERE file_md5 = :h LIMIT 1");
        $stmt->execute(['h' => $md5]);
        if ($stmt->fetchColumn()) {
            return $this->result('duplicate', ['Datei bereits in `files` erfasst (MD5) — übersprungen']);
        }
        // Filename-Dedupe: jeder Dateiname darf nur einmal importiert werden. Preview/Final
        // unterscheiden sich AIDA-seitig im Datum-Prefix des Namens.
        $stmt = $this->pdo->prepare("SELECT 1 FROM files WHERE file_name = :n LIMIT 1");
        $stmt->execute(['n' => basename($path)]);
        if ($stmt->fetchColumn()) {
            return $this->result('duplicate', ['Dateiname bereits importiert — übersprungen']);
        }

        $rows = $this->parseExcel($path);
        if ($rows === null) {
            return $this->result('parse_failed', ['XLSX nicht lesbar']);
        }
        if (empty($rows) || !$this->looksLikeAidaBookings($rows[0])) {
            return $this->result('not_aida_bookings', ['Datei enthält keine AIDA-Booking-Spalten (Booking No / Last Name / License Plate)']);
        }

        // BatchMeta kann fehlen, wenn jemand processFile direkt aufruft. Fallback: aus der
        // Datei selbst herleiten (Legacy-Pfad).
        if ($batchMeta === null) {
            $batchMeta = $this->peekFileMetadataFromRows(basename($path), $rows) ?? [
                'cruise' => '', 'file_date' => null, 'departure_date' => null,
                'arrival_date' => null, 'ship' => null,
                'list_type' => 'unknown', 'product_code' => null,
            ];
        }
        $firstBegin = $batchMeta['departure_date'] ?? null;

        // Kategorie aus dem Batch übernehmen (= list_type, einmal pro Anlauf bestimmt).
        $fileCategory = $batchMeta['list_type'] ?: 'aida_booking';
        // Eindeutige UUID für DIESE Datei — gilt für alle Cruises innerhalb. Wird in `files`,
        // `import_files`, `bookings.import_uuid` und `customers.import_uuid` durchgereicht.
        // Pro Cruise eine neue UUID zu vergeben wäre fatal: `import_files` hat UNIQUE(source,filename)
        // und speichert per ON DUPLICATE KEY UPDATE nur die ERSTE UUID — Bookings späterer
        // Cruises wären beim Undo per import_uuid nicht mehr auffindbar.
        $this->currentImportUuid = self::makeUuidV4();
        $this->registerFile($path, $md5, $firstBegin, $fileCategory);

        // Normalisieren + Cruise-Buckets. $perCruise speichert pro Cruise eine Liste mit
        // ['row' => normalisierte Zeile, 'line' => Excel-Zeilennummer, 'raw' => Rohdaten] —
        // damit das Logging die ursprüngliche Zeilennummer kennt.
        $errors    = [];
        $logRows   = [];
        $perCruise = [];
        foreach ($rows as $i => $raw) {
            $row      = $this->normalizeRow($raw);
            $line     = $i + 2;
            $rowLabel = 'Zeile ' . $line;
            $custName = trim(($row['last_name'] ?? '') . ', ' . ($row['first_name'] ?? ''), ', ');

            // Sparse/leere Trailing-Rows leise verwerfen — Excel hängt gerne Geister-Zellen an.
            // Kriterium: weniger als 3 belegte Zellen UND keine cruise_id → kein echter Datensatz.
            $nonEmpty = count(array_filter($raw, static fn ($v) => trim((string)$v) !== ''));
            if ($nonEmpty < 3 && $row['cruise_id'] === '' && $row['booking_no'] === '') {
                continue;
            }

            if ($row['cruise_id'] === '') {
                $msg = "$rowLabel: Cruise ID fehlt — Zeile übersprungen";
                $errors[]  = $msg;
                $logRows[] = ['line' => $line, 'status' => 'skipped', 'cruise' => '', 'booking_no' => $row['booking_no'], 'customer' => $custName, 'note' => 'Cruise ID fehlt'];
                continue;
            }
            if ($row['booking_no'] === '') {
                $msg = "$rowLabel (Cruise {$row['cruise_id']}): Booking No fehlt — Zeile übersprungen";
                $errors[]  = $msg;
                $logRows[] = ['line' => $line, 'status' => 'skipped', 'cruise' => $row['cruise_id'], 'booking_no' => '', 'customer' => $custName, 'note' => 'Booking No fehlt'];
                continue;
            }
            $row['_product_ids'] = self::PRODUCT_MAP[$row['product_code_base']] ?? null;
            if ($row['_product_ids'] === null) {
                $msg = "$rowLabel (Booking {$row['booking_no']}): unbekannter Product Code '{$row['product_code']}' — Zeile übersprungen";
                $errors[]  = $msg;
                $logRows[] = ['line' => $line, 'status' => 'skipped', 'cruise' => $row['cruise_id'], 'booking_no' => $row['booking_no'], 'customer' => $custName, 'note' => "Unbekannter Product Code '{$row['product_code']}'"];
                continue;
            }
            $row['_line'] = $line;
            $perCruise[$row['cruise_id']][] = $row;
        }

        if (empty($perCruise)) {
            return $this->result('imported', $errors, 0, 0, 0, 0, $logRows);
        }

        $totalCreated = $totalUpdated = $totalUnchanged = 0;
        $allUids      = [];   // alle external_uids aus dieser Datei — wandern hoch zum Batch
        $legacyToUid  = [];   // legacy_booking_no → external_uid, für den Mismatch-Schutz im Storno-Pass

        $this->pdo->beginTransaction();
        try {
            foreach ($perCruise as $cruiseId => $cruiseRows) {
                $event = $this->findEvent($cruiseId);
                if ($event === null) {
                    $errors[] = "Cruise {$cruiseId}: kein Event mit cruise='{$cruiseId}' gefunden — " . count($cruiseRows) . " Zeile(n) übersprungen";
                    foreach ($cruiseRows as $row) {
                        $logRows[] = [
                            'line'       => $row['_line'],
                            'status'     => 'skipped',
                            'cruise'     => $cruiseId,
                            'booking_no' => $row['booking_no'],
                            'customer'   => trim(($row['last_name'] ?? '') . ', ' . ($row['first_name'] ?? ''), ', '),
                            'note'       => 'Kein Event mit dieser Cruise gefunden',
                        ];
                    }
                    continue;
                }

                $importRunId = $this->startImportRun($cruiseId, $event, basename($path));
                // currentImportUuid wurde EINMAL pro Datei oben gesetzt — hier NICHT neu erzeugen,
                // sonst geht beim Undo per import_files.import_uuid die Spur zu späteren Cruises verloren.
                $seenUids    = [];

                foreach ($cruiseRows as $row) {
                    $custName = trim(($row['last_name'] ?? '') . ', ' . ($row['first_name'] ?? ''), ', ');
                    $externalUid = $this->buildExternalUid($row, $cruiseId);
                    if ($externalUid === null) {
                        $errors[] = "Cruise {$cruiseId} Booking {$row['booking_no']}: kann external_uid nicht bilden — übersprungen";
                        $logRows[] = ['line' => $row['_line'], 'status' => 'skipped', 'cruise' => $cruiseId, 'booking_no' => $row['booking_no'], 'customer' => $custName, 'note' => 'Keine external_uid bildbar'];
                        continue;
                    }
                    $contentHash = $this->buildContentHash($row);
                    $customerId  = $this->upsertCustomer($row);
                    $booking     = $this->upsertBooking($row, $cruiseId, $customerId, (int)$event['id'], $externalUid, $contentHash, $importRunId);

                    match ($booking['action']) {
                        'created'   => $totalCreated++,
                        'updated'   => $totalUpdated++,
                        'unchanged' => $totalUnchanged++,
                    };
                    if ($booking['action'] !== 'unchanged') {
                        $this->syncProducts($booking['id'], $row);
                    }
                    // AIDA-Buchungen sind beim Import bereits bezahlt → Rechnung + Items + Zahlung
                    // immer synchron halten (Rechnungs-Nr. AIDA-{BookingNo}[-{ParkingCount}],
                    // Zahlung als bank_transfer für den vollen Betrag).
                    $newInvoiceIdFromSync = $this->syncInvoiceAndPayment($booking['id'], $customerId, $row);
                    // search_index — Kunde, Buchung, Rechnung, Event nach jedem Insert/Update
                    // direkt nachpflegen, damit die Live-Suche AIDA-Daten sofort findet.
                    if (class_exists('SearchIndexer')) {
                        try {
                            $this->syncSearchIndexForRecord($customerId, $booking['id'], $newInvoiceIdFromSync, (int)$event['id']);
                        } catch (Throwable $e) {
                            error_log('AidaBookingImporter search-index sync failed: ' . $e->getMessage());
                        }
                    }
                    $this->recordImportFileBooking($externalUid, $booking['id'], (int)$row['_line']);
                    $seenUids[] = $externalUid;
                    // Sub-Key = booking_no + '|' + parking_count_token. Dadurch werden
                    // Sub-Buchungen (gleiche AIDA-Booking-No, unterschiedliche N° of Parking
                    // Bookings) sauber unterschieden. Mismatch-Schutz im Storno-Pass nutzt
                    // diesen Sub-Key, damit nicht versehentlich die falsche Sub-Buchung vor
                    // dem Cancel "gerettet" wird.
                    $pcToken = self::parkingCountToken($row['parking_count'] ?? null);
                    $subKey  = $row['booking_no'] . '|' . $pcToken;
                    $legacyToUid[$subKey][] = $externalUid;
                    $logRows[]  = [
                        'line'       => $row['_line'],
                        'status'     => $booking['action'],   // created / updated / unchanged
                        'cruise'     => $cruiseId,
                        'booking_no' => $row['booking_no'],
                        'customer'   => $custName,
                        'note'       => '',
                    ];
                }

                // ACHTUNG: kein Storno-Pass mehr auf Datei-Ebene — der Batch-Orchestrator
                // sammelt die UIDs aller Dateien dieses Anlaufs und ruft cancelMissingBookings
                // erst nach der letzten Datei auf. Sonst werden Buchungen aus noch nicht
                // verarbeiteten Produkt-Dateien fälschlich storniert.
                $allUids = array_merge($allUids ?? [], $seenUids);

                $this->completeImportRun($importRunId, [
                    'cruise'    => $cruiseId,
                    'event_id'  => (int)$event['id'],
                    'rows'      => count($cruiseRows),
                    'created'   => $totalCreated,
                    'updated'   => $totalUpdated,
                    'unchanged' => $totalUnchanged,
                ]);
            }
            $this->pdo->commit();
        } catch (Throwable $e) {
            $this->pdo->rollBack();
            return $this->result('parse_failed', [...$errors, 'Transaktion zurückgerollt: ' . $e->getMessage()]);
        }

        $res = $this->result('imported', $errors, $totalCreated, $totalUpdated, $totalUnchanged, 0, $logRows);
        $res['uids']          = $allUids;
        $res['legacy_to_uid'] = $legacyToUid;
        return $res;
    }

    /* -------------------------------------------------------------------- */
    /*  Parsing                                                              */
    /* -------------------------------------------------------------------- */

    /**
     * Liest ein XLSX (ohne PhpSpreadsheet) und liefert eine Liste assoziativer Arrays
     * mit slug-isierten Header-Keys (z.B. "cruise_id", "license_plate").
     */
    public function parseExcel(string $path): ?array
    {
        $zip = new ZipArchive();
        if ($zip->open($path) !== true) return null;
        $ssXml    = $zip->getFromName('xl/sharedStrings.xml');
        $sheetXml = $zip->getFromName('xl/worksheets/sheet1.xml');
        $zip->close();
        if ($sheetXml === false) return null;

        $shared = [];
        if ($ssXml !== false && ($ss = @simplexml_load_string($ssXml)) !== false) {
            foreach ($ss->si as $si) {
                $text = '';
                if (isset($si->t)) {
                    $text = (string)$si->t;
                } elseif (isset($si->r)) {
                    foreach ($si->r as $r) $text .= (string)$r->t;
                }
                $shared[] = $text;
            }
        }

        $sheet = @simplexml_load_string($sheetXml);
        if ($sheet === false || !isset($sheet->sheetData)) return null;

        $rows    = [];
        $headers = [];
        foreach ($sheet->sheetData->row as $row) {
            $cells = [];
            foreach ($row->c as $c) {
                $ref  = (string)$c['r'];
                $col  = preg_replace('/\d+/', '', $ref);
                $type = (string)$c['t'];
                $val  = isset($c->v) ? (string)$c->v : '';
                if ($type === 's' && $val !== '') {
                    $val = $shared[(int)$val] ?? '';
                } elseif ($type === 'inlineStr' && isset($c->is->t)) {
                    $val = (string)$c->is->t;
                }
                $cells[$col] = $val;
            }
            $nonEmpty = array_filter($cells, static fn ($v) => $v !== '');
            if (empty($nonEmpty)) continue;

            if (empty($headers)) {
                // Title-Rows (wie "Parking Operator parkenamschiff" oder "Parking Prediction")
                // haben typischerweise nur EINE belegte Zelle. Wir brauchen die echten
                // Spaltennamen — also Title-Rows überspringen, bis wir eine Header-Row mit
                // mindestens 3 Spalten finden.
                if (count($nonEmpty) < 3) continue;
                foreach ($cells as $col => $val) {
                    $headers[$col] = self::slugifyHeader((string)$val);
                }
                continue;
            }
            $assoc = [];
            foreach ($headers as $col => $key) {
                $assoc[$key] = $cells[$col] ?? '';
            }
            if (empty(array_filter($assoc, static fn ($v) => $v !== ''))) continue;
            $rows[] = $assoc;
        }
        return $rows;
    }

    private function looksLikeAidaBookings(array $sampleRow): bool
    {
        $needed = ['booking_no', 'last_name', 'product_code'];
        foreach ($needed as $key) {
            if (!array_key_exists($key, $sampleRow)) return false;
        }
        return true;
    }

    /**
     * Bringt eine geparste Zeile in eine stabile Struktur. Datums-Werte werden aus
     * Excel-Seriennummern in ISO-Strings konvertiert; Kennzeichen wird normalisiert.
     */
    public function normalizeRow(array $raw): array
    {
        $get = static function (array $r, array $keys): string {
            foreach ($keys as $k) {
                if (isset($r[$k]) && $r[$k] !== '') return (string)$r[$k];
            }
            return '';
        };

        $productCode = trim($get($raw, ['product_code']));
        $plate       = trim($get($raw, ['license_plate']));
        $email       = trim(mb_strtolower($get($raw, ['email'])));
        $duration    = $get($raw, ['duration']);
        $fellow      = $get($raw, ['fellow_passenger', 'fellow_passengers']);
        $parking     = $get($raw, ['n_of_parking_bookings', 'no_of_parking_bookings']);

        return [
            'cruise_id'                => trim($get($raw, ['cruise_id', 'cruise'])),
            'ship'                     => trim($get($raw, ['ship', 'ship_name'])),
            'departure_date'           => self::excelDate($get($raw, ['departure_date'])),
            'arrival_date'             => self::excelDate($get($raw, ['arrival_date'])),
            'duration'                 => is_numeric($duration) ? (int)$duration : null,
            'last_name'                => trim($get($raw, ['last_name'])),
            'first_name'               => trim($get($raw, ['first_name'])),
            'booking_no'               => self::normalizeBookingNo($get($raw, ['booking_no', 'booking_number'])),
            // Nullable, damit der Booking-No-Generator zwischen "kein Wert" und "Wert 1"
            // unterscheiden kann (steuert das Format DDMMYY-A-… vs. DDMMYY-A1-…).
            'parking_count'            => ($parking !== '' && is_numeric($parking)) ? (int)$parking : null,
            'cabin_number'             => trim($get($raw, ['cabin_number', 'cabin'])),
            'product_name'             => trim($get($raw, ['product_name'])),
            'product_code'             => $productCode,
            'product_code_base'        => mb_substr($productCode, 0, 4),
            'recipient'                => trim($get($raw, ['recipient'])),
            'sales_price'              => self::parsePrice($get($raw, ['sales_price', 'price'])),
            'license_plate'            => $plate,
            'normalized_license_plate' => self::normalizePlate($plate),
            'guest_count'              => is_numeric($fellow) ? max(1, (int)$fellow + 1) : 1,
            'checkin_time'             => self::excelDateTime($get($raw, ['check_in_time', 'checkin_time'])),
            'phone'                    => trim($get($raw, ['phone'])),
            'email'                    => $email,
        ];
    }

    /* -------------------------------------------------------------------- */
    /*  Lookups / Identity                                                   */
    /* -------------------------------------------------------------------- */

    public function findEvent(string $cruiseId): ?array
    {
        $stmt = $this->pdo->prepare("SELECT * FROM events WHERE cruise = :c LIMIT 1");
        $stmt->execute(['c' => $cruiseId]);
        $row = $stmt->fetch();
        return $row !== false ? $row : null;
    }

    /**
     * Stabile Wiedererkennung NUR über wirklich stabile Felder:
     *   source = AIDA
     *   cruise_id
     *   booking_no (normalisiert, Excel-Float/Whitespace-safe)
     *   product_base_code (PW40 / PW50 / PW75 / PW85)
     *   parking_count (= "N° of Parking Bookings", Sub-Index der Hauptbuchung)
     *
     * Wichtig: AIDA-Booking-No kann mehrfach vorkommen, wenn zu einer Hauptbuchung
     * mehrere Einzelstellplätze gehören. In diesem Fall liefert AIDA pro Sub-Buchung
     * eine fortlaufende N° of Parking Bookings (1, 2, 3 …). Ohne diesen Sub-Index
     * würden zwei verschiedene Sub-Buchungen derselben Booking-No auf dieselbe UID
     * kollabieren — beim Final-Import würde dann eine davon storniert.
     *
     * Kabine, Kennzeichen, Name, E-Mail und Telefon gehören NICHT in die UID — die
     * können sich zwischen Preview und Final ändern (Kabine wird erst spät zugewiesen,
     * Telefon/E-Mail werden nachgepflegt). Diese Felder bleiben im content_hash
     * (= Update-Detektor), aber nicht in der UID (= Identität).
     */
    public function buildExternalUid(array $row, string $cruiseId): ?string
    {
        $bookingNo = self::normalizeBookingNo((string)($row['booking_no'] ?? ''));
        if ($bookingNo === '') return null;
        $base    = (string)($row['product_code_base'] ?? '');
        $pcToken = self::parkingCountToken($row['parking_count'] ?? null);
        return sha1(self::SOURCE . "|{$cruiseId}|{$bookingNo}|{$base}|{$pcToken}");
    }

    /**
     * Normalisiert das Feld "N° of Parking Bookings" zu einem stabilen String-Token.
     * Leere/null/0 werden zu "" — so kollidiert eine echte parking_count=NULL nicht
     * mit parking_count="0" und der Hash bleibt eindeutig.
     */
    public static function parkingCountToken(int|string|null $pc): string
    {
        if ($pc === null) return '';
        if (is_string($pc) && trim($pc) === '') return '';
        if (!is_numeric($pc)) return '';
        $n = (int)$pc;
        return $n > 0 ? (string)$n : '';
    }

    /**
     * Parst die parking_count aus einer intern erzeugten booking_no zurück. Format ist
     * "DDMMYY-A{N}-…" bzw. "DDMMYY-A-…" (siehe generateInternalBookingNo). Liefert
     * "" wenn nicht parsbar — dann gilt die Buchung als Hauptbuchung ohne Sub-Index.
     */
    public static function extractParkingCountFromInternalBookingNo(string $bookingNo): string
    {
        if (preg_match('/^\d{6}-A(\d*)-/', $bookingNo, $m)) {
            return $m[1] ?? '';
        }
        return '';
    }

    /**
     * Findet eine aktive AIDA-Buchung über (legacy_booking_no, parking_count) — ohne
     * Berücksichtigung von event_id oder cruise. Dient als Cross-Event-Fallback im
     * Upsert: AIDA bucht zwischen Preview und Final manchmal auf eine andere Cruise um,
     * dabei bleibt die Booking-No erhalten, die UID ändert sich aber (cruise steckt im Hash).
     *
     * Eindeutigkeit:
     *   - Nur 1 aktiver Treffer auf legacy_booking_no → den nehmen.
     *   - Mehrere (Sub-Buchungen) → über die parking_count aus der internen booking_no
     *     genau eine herauspicken. Andernfalls null.
     */
    private function findActiveBookingByLegacyAndPc(string $legacyBookingNo, int|string|null $parkingCount): ?array
    {
        $lbn = self::normalizeBookingNo($legacyBookingNo);
        if ($lbn === '') return null;
        $stmt = $this->pdo->prepare(
            "SELECT * FROM bookings
              WHERE source = :src
                AND legacy_booking_no = :lbn
                AND status <> 'cancelled'
              ORDER BY id ASC"
        );
        $stmt->execute(['src' => self::SOURCE, 'lbn' => $lbn]);
        $rows = $stmt->fetchAll();
        if (empty($rows)) return null;

        $wantedPc = self::parkingCountToken($parkingCount);
        if (count($rows) === 1 && $wantedPc === '') {
            // Eindeutige Hauptbuchung, kein Sub-Index nötig.
            return $rows[0];
        }
        foreach ($rows as $r) {
            $dbPc = self::extractParkingCountFromInternalBookingNo((string)($r['booking_no'] ?? ''));
            if ($dbPc === $wantedPc) return $r;
        }
        return null;
    }

    /**
     * Booking-No robust normalisieren — Excel macht aus reinen Zahlen gerne Floats
     * oder Scientific Notation. Wir wollen die Original-Zeichenkette so erhalten,
     * dass dieselbe AIDA-Booking-No über Preview und Final immer denselben String liefert.
     *
     *   - trim + interne Whitespaces entfernen
     *   - "12345.0" → "12345"
     *   - "1.23E+5" → "123000"
     *   - führende Nullen bleiben erhalten, falls Excel sie als Text liefert
     */
    public static function normalizeBookingNo(string $bookingNo): string
    {
        $b = trim($bookingNo);
        if ($b === '') return '';
        // Interne Whitespaces raus (Excel hängt gelegentlich U+00A0 / Tabs an).
        $b = preg_replace('/\s+/u', '', $b) ?? '';
        if ($b === '') return '';

        // Scientific notation, z. B. "1.23E+5" → ausschreiben.
        if (preg_match('/^-?\d+(?:\.\d+)?[eE][+-]?\d+$/', $b)) {
            $f = (float)$b;
            // 0 Dezimalstellen, keine Tausender-Trenner.
            $b = number_format($f, 0, '.', '');
        }

        // "12345.0" / "12345.000" → "12345"  (Excel-Float-Artefakt). Nur wenn der
        // Nachkomma-Teil ausschließlich aus Nullen besteht — sonst Original lassen.
        if (preg_match('/^(-?\d+)\.0+$/', $b, $m)) {
            $b = $m[1];
        }

        return $b;
    }

    /**
     * Erkennung von Daten-Änderungen ohne Spalten-Diff.
     * Enthält alle Felder, die sich in einem Update legitim ändern dürfen.
     */
    public function buildContentHash(array $row): string
    {
        return sha1(implode('|', [
            $row['first_name']               ?? '',
            $row['last_name']                ?? '',
            $row['phone']                    ?? '',
            $row['email']                    ?? '',
            $row['license_plate']            ?? '',
            $row['normalized_license_plate'] ?? '',
            (string)($row['guest_count']     ?? ''),
            $row['checkin_time']             ?? '',
            number_format((float)($row['sales_price'] ?? 0), 2, '.', ''),
            $row['product_code']             ?? '',           // full PW400G / PW400M
            $row['product_code_base']        ?? '',           // PW40 / PW50 / PW75 / PW85
            $row['product_name']             ?? '',
            $row['cabin_number']             ?? '',
            $row['departure_date']           ?? '',
            $row['arrival_date']             ?? '',
            (string)($row['duration']        ?? ''),
        ]));
    }

    /* -------------------------------------------------------------------- */
    /*  Upserts                                                              */
    /* -------------------------------------------------------------------- */

    /**
     * Kunden-Upsert mit Fuzzy-Matching gegen Bestand:
     *   1. Match per E-Mail (lower+trim).
     *   2. Match per normalisierter Telefonnummer — "+49 152 / 123-4567" wird zu "01521234567"
     *      und matcht damit auch auf "0152 123 4567" oder "+491521234567".
     *   3. Match per (Vorname + Nachname + Kennzeichen-Ortskennung) — fängt Wiederholer ab,
     *      die in einer Buchung mal ohne E-Mail oder mit Tippfehler reinkommen. "Gleicher Ort"
     *      = identischer Buchstaben-Präfix des Kennzeichens ("HH-AB 123" und "HH-XY 456" zählen
     *      als gleicher Ort = HH; "HH-…" und "B-…" nicht).
     *   4. Sonst neu anlegen.
     *
     * Bei Treffer werden NUR leere Felder ergänzt — bestehende Daten werden nicht überschrieben.
     */
    public function upsertCustomer(array $row): int
    {
        $email     = trim(mb_strtolower((string)$row['email']));
        $phone     = (string)$row['phone'];
        $phoneNorm = self::normalizePhone($phone);
        $firstName = trim((string)($row['first_name'] ?? ''));
        $lastName  = trim((string)($row['last_name']  ?? ''));
        $platePrefix = self::extractPlatePrefix((string)($row['license_plate'] ?? ''));

        $existing = null;
        if ($email !== '' || $phoneNorm !== '') {
            // MySQL 8 REGEXP_REPLACE: '+49' / '0049' Prefixe auf '0' normalisieren,
            // dann alles außer Ziffern entfernen → vergleichbar mit $phoneNorm.
            // Wichtig: PDO im non-emulated Modus erlaubt keine Wiederverwendung benannter
            // Platzhalter — jedes Vorkommen braucht einen eigenen Namen.
            $sql = "SELECT * FROM customers
                     WHERE (
                            (:em_a <> '' AND LOWER(TRIM(COALESCE(email,''))) = :em_b)
                         OR (:ph_a <> '' AND COALESCE(phone,'') <> ''
                             AND REGEXP_REPLACE(
                                   CASE
                                     WHEN phone LIKE '+49%'  THEN CONCAT('0', SUBSTRING(phone, 4))
                                     WHEN phone LIKE '0049%' THEN CONCAT('0', SUBSTRING(phone, 5))
                                     ELSE phone
                                   END,
                                   '[^0-9]', ''
                               ) = :ph_b)
                     )
                     ORDER BY id ASC
                     LIMIT 1";
            $stmt = $this->pdo->prepare($sql);
            $stmt->execute([
                'em_a' => $email, 'em_b' => $email,
                'ph_a' => $phoneNorm, 'ph_b' => $phoneNorm,
            ]);
            $existing = $stmt->fetch() ?: null;
        }

        // Fallback-Match: Vor- + Nachname identisch UND eine bestehende Buchung hat ein
        // Kennzeichen mit demselben Ortspräfix wie das aktuelle. Greift nur, wenn weder
        // E-Mail noch Telefon einen Treffer geliefert haben.
        if ($existing === null && $firstName !== '' && $lastName !== '' && $platePrefix !== '') {
            $sql = "SELECT DISTINCT c.*
                      FROM customers c
                      JOIN bookings  b ON b.customer_id = c.id
                     WHERE LOWER(TRIM(c.first_name)) = LOWER(TRIM(:fn))
                       AND LOWER(TRIM(c.last_name))  = LOWER(TRIM(:ln))
                       AND COALESCE(b.license_plate, '') <> ''
                       AND REGEXP_SUBSTR(UPPER(b.license_plate), '^[A-Z]+') = :pp
                     ORDER BY c.id ASC
                     LIMIT 1";
            $stmt = $this->pdo->prepare($sql);
            $stmt->execute(['fn' => $firstName, 'ln' => $lastName, 'pp' => $platePrefix]);
            $existing = $stmt->fetch() ?: null;
        }

        if ($existing !== null) {
            // Non-destructiv: nur leere Felder füllen. Bestehende Werte bleiben.
            $fillable = [
                'first_name' => $row['first_name'],
                'last_name'  => $row['last_name'],
                'phone'      => $row['phone'],
                'email'      => $email,   // schon normalisiert
                'street'     => $row['street']   ?? '',
                'zip'        => $row['zip']      ?? '',
                'city'       => $row['city']     ?? '',
            ];
            $updates = [];
            $params  = ['id' => (int)$existing['id']];
            foreach ($fillable as $field => $newValue) {
                if ($newValue === '' || $newValue === null) continue;
                $current = $existing[$field] ?? '';
                if ($current !== '' && $current !== null) continue;   // bereits gefüllt → nicht überschreiben
                $updates[] = "`$field` = :$field";
                $params[$field] = $newValue;
            }
            // Source nur setzen, wenn noch leer.
            if (empty($existing['source'])) {
                $updates[] = "source = :src";
                $params['src'] = self::SOURCE;
            }
            if (!empty($updates)) {
                try {
                    $this->pdo->prepare("UPDATE customers SET " . implode(', ', $updates) . " WHERE id = :id")
                              ->execute($params);
                } catch (PDOException) {
                    // E-Mail-UNIQUE-Konflikt → Email aus dem Update entfernen und ohne sie nochmal versuchen.
                    if (isset($params['email'])) {
                        unset($params['email']);
                        $updates = array_values(array_filter($updates, static fn ($u) => !str_contains($u, '`email`')));
                        if (!empty($updates)) {
                            $this->pdo->prepare("UPDATE customers SET " . implode(', ', $updates) . " WHERE id = :id")
                                      ->execute($params);
                        }
                    }
                }
            }
            return (int)$existing['id'];
        }

        // Neu anlegen — import_uuid für robustes Undo mitschreiben.
        try {
            $stmt = $this->pdo->prepare("INSERT INTO customers
                (first_name, last_name, phone, email, source, status, import_uuid)
                VALUES (:fn, :ln, :ph, :em, :src, 'active', :iu)");
            $stmt->execute([
                'fn'  => $row['first_name'], 'ln' => $row['last_name'],
                'ph'  => $row['phone'] !== '' ? $row['phone'] : null,
                'em'  => $email !== '' ? $email : null,
                'src' => self::SOURCE,
                'iu'  => $this->currentImportUuid,
            ]);
            return (int)$this->pdo->lastInsertId();
        } catch (PDOException $e) {
            // UNIQUE-Konflikt auf email → bestehenden Kunden wiederverwenden.
            if ($email !== '') {
                $stmt = $this->pdo->prepare("SELECT id FROM customers WHERE LOWER(TRIM(email)) = :e LIMIT 1");
                $stmt->execute(['e' => $email]);
                $id = $stmt->fetchColumn();
                if ($id) return (int)$id;
            }
            throw $e;
        }
    }

    /**
     * Telefonnummer normalisieren für Fuzzy-Match:
     *   - Whitespace/Bindestriche/Klammern/Slashes entfernen
     *   - "+49" und "0049" auf "0" mappen (deutsches Format)
     *   - alles außer Ziffern raus
     * Beispiele:
     *   "+49152 - 12345 678"  → "015212345678"
     *   "0152 12345678"        → "015212345678"
     *   "00491521234567"       → "01521234567"
     */
    private static function normalizePhone(string $p): string
    {
        $clean = preg_replace('/[\s\-().\/]/u', '', $p) ?? '';
        if (str_starts_with($clean, '+49'))      $clean = '0' . substr($clean, 3);
        elseif (str_starts_with($clean, '0049')) $clean = '0' . substr($clean, 4);
        return preg_replace('/[^0-9]/', '', $clean) ?? '';
    }

    /**
     * Insert oder Update einer Buchung. Identifikation läuft in zwei Stufen:
     *   1) (source, external_uid)             — UID-stabiler Direkt-Match.
     *   2) (source, legacy_booking_no, pc)    — Cross-Event-Fallback: dieselbe AIDA-
     *                                            Buchung in einem anderen Event/Cruise.
     *                                            Tritt auf, wenn AIDA zwischen Preview
     *                                            und Final umbucht.
     *
     * @return array{id:int, action:'created'|'updated'|'unchanged'}
     */
    public function upsertBooking(array $row, string $cruiseId, int $customerId, int $eventId, string $externalUid, string $contentHash, int $importRunId): array
    {
        // 1) Direkt-Match über stabile UID.
        $stmt = $this->pdo->prepare("SELECT * FROM bookings WHERE source = :src AND external_uid = :u LIMIT 1");
        $stmt->execute(['src' => self::SOURCE, 'u' => $externalUid]);
        $existing = $stmt->fetch() ?: null;

        // 2) Cross-Event-Fallback: gleiche legacy_booking_no + parking_count in einem anderen Event.
        //    Findet eine bestehende aktive Zeile, die nur einer anderen Cruise zugeordnet ist —
        //    diese wird umgezogen statt eine zweite Zeile anzulegen.
        if ($existing === null) {
            $existing = $this->findActiveBookingByLegacyAndPc(
                (string)($row['booking_no'] ?? ''),
                $row['parking_count'] ?? null
            );
        }

        $gross = (float)$row['sales_price'];
        $net   = round($gross / (1 + self::DEFAULT_VAT_RATE / 100), 2);
        $vat   = round($gross - $net, 2);

        if ($existing !== null) {
            $existingUid = (string)($existing['external_uid'] ?? '');
            $eventChanged = (int)($existing['event_id'] ?? 0) !== $eventId;
            $uidChanged   = $existingUid !== $externalUid;

            // Unverändert + gleiche UID + gleiches Event + nicht storniert → nur import_run_id refreshen.
            if ($existing['content_hash'] === $contentHash
                && $existing['status'] !== 'cancelled'
                && !$eventChanged
                && !$uidChanged
            ) {
                $stmt = $this->pdo->prepare("UPDATE bookings SET import_run_id = :r WHERE id = :id");
                $stmt->execute(['r' => $importRunId, 'id' => (int)$existing['id']]);
                return ['id' => (int)$existing['id'], 'action' => 'unchanged'];
            }

            // Vor jedem Update einen vollständigen Snapshot (booking + products + invoices +
            // items + payments) in history_log ablegen — die Undo-Route findet ihn per
            // entity_id + import_run_id und stellt damit den Vorzustand wieder her.
            $this->snapshotBookingForUndo((int)$existing['id'], $importRunId);

            // Storno-Wiederbelebung ODER Update ODER Cross-Event-Umbuchung.
            // external_uid wird mit aktualisiert, damit nach einem Cruise-Wechsel die stabile UID stimmt.
            $stmt = $this->pdo->prepare("UPDATE bookings SET
                customer_id = :cid, event_id = :eid,
                travel_from = :tf, travel_to = :tt, arrival_time = :at,
                license_plate = :lp, normalized_license_plate = :nlp,
                guest_count = :gc,
                status = 'confirmed', payment_status = 'paid',
                checkin_status = CASE WHEN checkin_status = 'cancelled' THEN 'pending' ELSE checkin_status END,
                total_net = :n, total_vat = :v, total_gross = :g,
                external_uid = :uid,
                content_hash = :ch, import_run_id = :ri,
                legacy_booking_no = :lbn
                WHERE id = :id");
            $stmt->execute([
                'cid' => $customerId, 'eid' => $eventId,
                'tf' => $row['departure_date'], 'tt' => $row['arrival_date'],
                'at' => $row['checkin_time'] ?: null,
                'lp' => $row['license_plate'] ?: null,
                'nlp'=> $row['normalized_license_plate'] ?: null,
                'gc' => (int)$row['guest_count'],
                'n'  => $net, 'v' => $vat, 'g' => $gross,
                'uid'=> $externalUid,
                'ch' => $contentHash, 'ri' => $importRunId,
                'lbn'=> $row['booking_no'],
                'id' => (int)$existing['id'],
            ]);
            return ['id' => (int)$existing['id'], 'action' => 'updated'];
        }

        // booking_no ist UNIQUE im Schema, kann aber bei doppelten Cabin-Nummern (mit
        // gleichem departure_date und ohne parking_count) kollidieren. Wir generieren
        // den Default und hängen bei Bedarf einen Discriminator an.
        $internalBookingNo = $this->resolveUniqueInternalBookingNo($row);

        $stmt = $this->pdo->prepare("INSERT INTO bookings
            (booking_no, customer_id, event_id, travel_from, travel_to, arrival_time,
             license_plate, normalized_license_plate, guest_count,
             status, payment_status, checkin_status,
             total_net, total_vat, total_gross,
             legacy_booking_no, source, external_uid, content_hash, import_run_id, import_uuid)
            VALUES (:bn, :cid, :eid, :tf, :tt, :at, :lp, :nlp, :gc,
                    'confirmed', 'paid', 'pending',
                    :n, :v, :g,
                    :lbn, :src, :uid, :ch, :ri, :iu)");
        $stmt->execute([
            'bn'  => $internalBookingNo,
            'cid' => $customerId, 'eid' => $eventId,
            'tf'  => $row['departure_date'], 'tt' => $row['arrival_date'],
            'at'  => $row['checkin_time'] ?: null,
            'lp'  => $row['license_plate'] ?: null,
            'nlp' => $row['normalized_license_plate'] ?: null,
            'gc'  => (int)$row['guest_count'],
            'n'   => $net, 'v' => $vat, 'g' => $gross,
            'lbn' => $row['booking_no'],
            'src' => self::SOURCE,
            'uid' => $externalUid, 'ch' => $contentHash, 'ri' => $importRunId,
            'iu'  => $this->currentImportUuid,
        ]);
        return ['id' => (int)$this->pdo->lastInsertId(), 'action' => 'created'];
    }

    /**
     * Liefert eine im bookings.booking_no-Index eindeutige interne Buchungs-Nr.
     * Default kommt aus generateInternalBookingNo(); bei Kollision hängen wir einen
     * laufenden Suffix "-N" an. Tritt nur auf, wenn zwei verschiedene AIDA-Buchungen
     * die gleiche Cabin teilen (und beide ohne N° of Parking Bookings kommen).
     */
    private function resolveUniqueInternalBookingNo(array $row): string
    {
        $base = $this->generateInternalBookingNo($row);
        if ($this->isBookingNoFree($base)) return $base;

        $check = $this->pdo->prepare("SELECT 1 FROM bookings WHERE booking_no = :bn LIMIT 1");
        for ($i = 2; $i <= 99; $i++) {
            $alt = mb_substr($base, 0, 17) . '-' . $i;
            $check->execute(['bn' => $alt]);
            if (!$check->fetchColumn()) return $alt;
        }
        // Reserveplan: zufälliger Hash. Sollte nie nötig sein.
        return mb_substr($base, 0, 12) . '-' . substr(sha1(uniqid('', true)), 0, 6);
    }

    /**
     * Schreibt einen Vollsnapshot der Buchung (inkl. Produkte, Rechnungen, Items, Zahlungen)
     * in history_log, sodass die Undo-Route den Vorzustand exakt wiederherstellen kann.
     * Aufruf direkt VOR dem UPDATE in upsertBooking().
     */
    private function snapshotBookingForUndo(int $bookingId, int $importRunId): void
    {
        // Bookings-Zeile.
        $bStmt = $this->pdo->prepare("SELECT * FROM bookings WHERE id = :id");
        $bStmt->execute(['id' => $bookingId]);
        $booking = $bStmt->fetch();
        if (!$booking) return;

        // booking_products.
        $pStmt = $this->pdo->prepare("SELECT * FROM booking_products WHERE booking_id = :id ORDER BY id ASC");
        $pStmt->execute(['id' => $bookingId]);
        $products = $pStmt->fetchAll();

        // Invoices + invoice_items.
        $iStmt = $this->pdo->prepare("SELECT * FROM invoices WHERE booking_id = :id ORDER BY id ASC");
        $iStmt->execute(['id' => $bookingId]);
        $invoices = $iStmt->fetchAll();
        foreach ($invoices as &$inv) {
            $itStmt = $this->pdo->prepare("SELECT * FROM invoice_items WHERE invoice_id = :iid ORDER BY id ASC");
            $itStmt->execute(['iid' => (int)$inv['id']]);
            $inv['_items'] = $itStmt->fetchAll();
        }
        unset($inv);

        // Payments.
        $payStmt = $this->pdo->prepare("SELECT * FROM payments WHERE booking_id = :id ORDER BY id ASC");
        $payStmt->execute(['id' => $bookingId]);
        $payments = $payStmt->fetchAll();

        $snapshot = [
            'booking'       => $booking,
            'products'      => $products,
            'invoices'      => $invoices,
            'payments'      => $payments,
            'import_run_id' => $importRunId,
            'taken_at'      => date('Y-m-d H:i:s'),
        ];

        // Direkt nach history_log schreiben (AuditLog ist im CLI-Kontext nicht zwingend bound).
        $hStmt = $this->pdo->prepare("INSERT INTO history_log
            (entity_type, entity_id, action, message, old_values, created_by)
            VALUES ('booking', :id, 'import_snapshot', :msg, :ov, 'system')");
        $hStmt->execute([
            'id'  => $bookingId,
            'msg' => 'Pre-import-Snapshot (run ' . $importRunId . ') für mögliches Undo',
            'ov'  => json_encode($snapshot, JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE),
        ]);
    }

    private function isBookingNoFree(string $bn): bool
    {
        $s = $this->pdo->prepare("SELECT 1 FROM bookings WHERE booking_no = :bn LIMIT 1");
        $s->execute(['bn' => $bn]);
        return !$s->fetchColumn();
    }

    /**
     * Replace-Strategie für die Produktzeilen einer Buchung — beim Update werden
     * vorhandene Positionen gelöscht und neu geschrieben. So bleiben die Summen
     * konsistent zur AIDA-Quelle (es gibt nie veraltete Reste).
     *
     * Bei Multi-Produkt-Mappings (PW85/PW75 = Hauptplatz + Valet) verwendet die
     * Add-on-Position (Valet) ihren in der products-Tabelle hinterlegten Festpreis
     * (pricing_mode='fixed'), der Hauptstellplatz erhält den Rest des sales_price.
     * Hat das Add-on keinen Festpreis konfiguriert, fällt das Modul auf den alten
     * Stand zurück (Add-on = 0 €, Hauptplatz = voller Betrag).
     */
    public function syncProducts(int $bookingId, array $row): void
    {
        $productIds = $row['_product_ids'] ?? [];
        if (empty($productIds)) return;

        $gross   = (float)$row['sales_price'];
        $vatRate = self::DEFAULT_VAT_RATE;

        // Konfigurierte Add-on-Preise (alle Produkte außer dem ersten = Hauptstellplatz)
        // einmalig abfragen — nur fixed-price-Produkte liefern eine Position > 0.
        $addonGross = [];
        $addonIds   = array_slice($productIds, 1);
        if (!empty($addonIds)) {
            $ph = implode(',', array_fill(0, count($addonIds), '?'));
            $pStmt = $this->pdo->prepare(
                "SELECT id, price_gross, vat_rate, pricing_mode
                   FROM products
                  WHERE id IN ($ph)"
            );
            $pStmt->execute($addonIds);
            foreach ($pStmt->fetchAll() as $p) {
                $mode = (string)($p['pricing_mode'] ?? 'fixed');
                $pg   = (float)($p['price_gross']  ?? 0);
                if ($mode === 'fixed' && $pg > 0) {
                    $addonGross[(int)$p['id']] = $pg;
                }
            }
        }
        $addonSum = array_sum($addonGross);
        // Defensiv: wenn die Summe der Add-on-Festpreise > AIDA-sales_price ist
        // (Sonderkonditionen, manuelle Rabatte etc.), Add-ons nicht abziehen und
        // wie früher den vollen Betrag auf den Hauptstellplatz buchen.
        if ($addonSum > $gross) {
            $addonGross = [];
            $addonSum   = 0.0;
        }
        $mainGross = max(0.0, round($gross - $addonSum, 2));

        $del = $this->pdo->prepare("DELETE FROM booking_products WHERE booking_id = :b");
        $del->execute(['b' => $bookingId]);

        $ins = $this->pdo->prepare("INSERT INTO booking_products
            (booking_id, product_id, quantity, price_net, vat_rate, price_gross, total_gross)
            VALUES (:b, :p, 1, :pn, :v, :pg, :tg)");

        foreach ($productIds as $idx => $productId) {
            if ($idx === 0) {
                $price = $mainGross;
            } else {
                $price = $addonGross[(int)$productId] ?? 0.0;
            }
            $priceNet = round($price / (1 + $vatRate / 100), 2);
            $ins->execute([
                'b'  => $bookingId, 'p' => $productId,
                'pn' => $priceNet, 'v' => $vatRate,
                'pg' => $price, 'tg' => $price,
            ]);
        }
    }

    /**
     * Stellt sicher, dass jede AIDA-Buchung eine zugehörige Rechnung + Position(en) + Zahlung
     * hat. AIDA-Buchungen sind bereits bezahlt (Banküberweisung von AIDA an den Betreiber).
     *
     * Rechnungs-Nr.: AIDA-{BookingNo} bzw. AIDA-{BookingNo}-{ParkingCount}.
     * Auf Re-Imports werden Beträge + Positionen aktualisiert (delete+insert items),
     * eine bestehende Zahlung wird belassen.
     */
    public function syncInvoiceAndPayment(int $bookingId, int $customerId, array $row): ?int
    {
        $invoiceNo = $this->generateInvoiceNo($row);
        $gross     = (float)$row['sales_price'];
        $vatRate   = self::DEFAULT_VAT_RATE;
        $net       = round($gross / (1 + $vatRate / 100), 2);
        $vat       = round($gross - $net, 2);
        // Rechnungsdatum = Reisedatum (Cruise-Beginn), nicht der Importtag. Damit landet die
        // Rechnung in der Tagesabrechnung des Reisetages und nicht beim Importer-Klick.
        // Fallback: heute, falls departure_date wider Erwarten fehlt.
        $today     = (!empty($row['departure_date']) && preg_match('/^\d{4}-\d{2}-\d{2}$/', (string)$row['departure_date']))
                     ? (string)$row['departure_date']
                     : date('Y-m-d');

        // Existiert bereits eine Rechnung zu dieser Buchung?
        $stmt = $this->pdo->prepare("SELECT id FROM invoices WHERE booking_id = :b ORDER BY id ASC LIMIT 1");
        $stmt->execute(['b' => $bookingId]);
        $invoiceId = (int)($stmt->fetchColumn() ?: 0);

        if ($invoiceId > 0) {
            try {
                // invoice_no aktualisieren — wenn ParkingCount sich geändert hat oder vom Wechsel
                // zwischen "leer" und "gefüllt" kommt, ändert sich die Rechnungsnummer.
                $upd = $this->pdo->prepare("UPDATE invoices SET
                        invoice_no   = :no,
                        customer_id  = :cid,
                        invoice_date = :d,
                        status       = 'paid',
                        total_net    = :n,
                        total_vat    = :v,
                        total_gross  = :g
                      WHERE id = :id");
                $upd->execute([
                    'no' => $invoiceNo, 'cid' => $customerId, 'd' => $today,
                    'n'  => $net, 'v' => $vat, 'g' => $gross, 'id' => $invoiceId,
                ]);
            } catch (PDOException) {
                // UNIQUE-Konflikt auf invoice_no → Nr. nicht ändern, nur Beträge.
                $upd = $this->pdo->prepare("UPDATE invoices SET
                        customer_id  = :cid,
                        invoice_date = :d,
                        status       = 'paid',
                        total_net    = :n,
                        total_vat    = :v,
                        total_gross  = :g
                      WHERE id = :id");
                $upd->execute([
                    'cid' => $customerId, 'd' => $today,
                    'n'   => $net, 'v' => $vat, 'g' => $gross, 'id' => $invoiceId,
                ]);
            }
            $this->pdo->prepare("DELETE FROM invoice_items WHERE invoice_id = :i")
                      ->execute(['i' => $invoiceId]);
        } else {
            try {
                $ins = $this->pdo->prepare("INSERT INTO invoices
                    (invoice_no, booking_id, customer_id, invoice_date, status, total_net, total_vat, total_gross)
                    VALUES (:no, :bid, :cid, :d, 'paid', :n, :v, :g)");
                $ins->execute([
                    'no' => $invoiceNo, 'bid' => $bookingId, 'cid' => $customerId,
                    'd'  => $today, 'n' => $net, 'v' => $vat, 'g' => $gross,
                ]);
                $invoiceId = (int)$this->pdo->lastInsertId();
            } catch (PDOException $e) {
                // UNIQUE auf invoice_no → bestehende Rechnung mit dieser Nr. wiederverwenden.
                $sel = $this->pdo->prepare("SELECT id FROM invoices WHERE invoice_no = :no LIMIT 1");
                $sel->execute(['no' => $invoiceNo]);
                $invoiceId = (int)($sel->fetchColumn() ?: 0);
                if ($invoiceId === 0) throw $e;
            }
        }

        // Positionen aus booking_products neu schreiben — bleibt 1:1 deckungsgleich.
        $bps = $this->pdo->prepare("SELECT bp.*, p.title AS product_title
                                      FROM booking_products bp
                                      LEFT JOIN products p ON p.id = bp.product_id
                                     WHERE bp.booking_id = :b
                                     ORDER BY bp.id ASC");
        $bps->execute(['b' => $bookingId]);
        $itemIns = $this->pdo->prepare("INSERT INTO invoice_items
            (invoice_id, product_id, title, description, quantity,
             price_net, vat_rate, price_gross, total_net, total_vat, total_gross)
            VALUES (:iid, :pid, :title, '', :qty, :pn, :vr, :pg, :tn, :tv, :tg)");
        foreach ($bps->fetchAll() as $bp) {
            $qty       = (int)$bp['quantity'];
            $itemGross = (float)$bp['total_gross'];
            $itemNet   = round($itemGross / (1 + (float)$bp['vat_rate'] / 100), 2);
            $itemVat   = round($itemGross - $itemNet, 2);
            $itemIns->execute([
                'iid'   => $invoiceId,
                'pid'   => (int)$bp['product_id'],
                'title' => (string)($bp['product_title'] ?? ''),
                'qty'   => $qty,
                'pn'    => $bp['price_net'],
                'vr'    => $bp['vat_rate'],
                'pg'    => $bp['price_gross'],
                'tn'    => $itemNet,
                'tv'    => $itemVat,
                'tg'    => $itemGross,
            ]);
        }

        // Zahlung sicherstellen — AIDA zahlt per Banküberweisung in voller Höhe vorab.
        $hasPayment = $this->pdo->prepare("SELECT 1 FROM payments WHERE booking_id = :b LIMIT 1");
        $hasPayment->execute(['b' => $bookingId]);
        if (!$hasPayment->fetchColumn()) {
            $payIns = $this->pdo->prepare("INSERT INTO payments
                (booking_id, invoice_id, amount, payment_method, paid_at, note)
                VALUES (:b, :i, :a, 'bank_transfer', :d, 'AIDA-Import — vorab bezahlt')");
            $payIns->execute([
                'b' => $bookingId,
                'i' => $invoiceId,
                'a' => $gross,
                'd' => $today . ' 00:00:00',
            ]);
        }
        return $invoiceId > 0 ? $invoiceId : null;
    }

    /**
     * Pflegt die search_index-Tabelle nach einem AIDA-Import-Datensatz.
     *  Identische Logik wie LegacyImporter::syncLegacyImportedRecord, nur als Methode
     *  des AidaBookingImporter-Service. Damit landet alles Live-Suchbare sofort im Index.
     */
    private function syncSearchIndexForRecord(int $customerId, int $bookingId, ?int $invoiceId, int $eventId): void
    {
        if (!class_exists('SearchIndexer')) return;

        if ($customerId > 0) {
            $st = $this->pdo->prepare("SELECT * FROM customers WHERE id = :id LIMIT 1");
            $st->execute(['id' => $customerId]);
            $row = $st->fetch();
            if ($row) SearchIndexer::syncCustomer($row);
        }

        if ($bookingId > 0) {
            $st = $this->pdo->prepare(
                "SELECT b.id, b.booking_no, b.license_plate, b.normalized_license_plate,
                        b.travel_from, b.travel_to, b.updated_at, b.customer_id,
                        c.first_name, c.last_name, c.company, c.email, c.phone,
                        e.title AS event_title, e.ship_name
                   FROM bookings b
                   INNER JOIN customers c ON c.id = b.customer_id
                   INNER JOIN events    e ON e.id = b.event_id
                  WHERE b.id = :id LIMIT 1"
            );
            $st->execute(['id' => $bookingId]);
            $row = $st->fetch();
            if ($row) SearchIndexer::syncBooking($row);
        }

        if ($invoiceId !== null && $invoiceId > 0) {
            $st = $this->pdo->prepare(
                "SELECT i.id, i.invoice_no, i.invoice_date, i.updated_at,
                        b.booking_no,
                        c.first_name, c.last_name, c.company
                   FROM invoices i
                   INNER JOIN bookings  b ON b.id = i.booking_id
                   INNER JOIN customers c ON c.id = i.customer_id
                  WHERE i.id = :id LIMIT 1"
            );
            $st->execute(['id' => $invoiceId]);
            $row = $st->fetch();
            if ($row) SearchIndexer::syncInvoice($row);
        }

        if ($eventId > 0) {
            $st = $this->pdo->prepare("SELECT * FROM events WHERE id = :id LIMIT 1");
            $st->execute(['id' => $eventId]);
            $row = $st->fetch();
            if ($row) SearchIndexer::syncEvent($row);
        }
    }

    /**
     * Storniert alle AIDA-Buchungen DIESES Events, deren external_uid nicht in
     * $seenUids enthalten ist — mit zwei Sicherheitsstufen:
     *
     *   1) Leere Final-Liste  → kein Storno (Massenstorno-Schutz, schon vorher abgefangen).
     *   2) legacy_booking_no-Mismatch-Schutz: Wenn die zu stornierende DB-Buchung dieselbe
     *      AIDA-Booking-No im aktuellen Final-Batch hat (aber andere UID, z. B. weil Kabine
     *      oder Kennzeichen in der alten UID-Formel steckten), wird NICHT storniert. Statt
     *      dessen wird die alte external_uid auf die neue (aus dem Final-Batch) migriert,
     *      damit die nächste Aktualisierung sauber funktioniert.
     *
     * Vor jedem Schritt landet eine ausführliche Debug-Ausgabe in STDERR, damit ein fehl-
     * gehender Storno-Pass im Operations-Log nachvollziehbar bleibt.
     *
     * @param int                                              $eventId
     * @param string[]                                         $seenUids
     * @param array<string,string[]>                           $currentLegacyToUid subKey ("booking_no|parking_count") → list of UIDs (event-lokal)
     * @param array<string,array<int,array{event_id:?int,uid:string}>> $globalLegacyToUid subKey → [{event_id, uid}, …] über ALLE Batches dieses Importlaufs
     * @return array{count:int,cancelled:array,mismatches:array,cross_event_skipped:array,migrated:int}
     */
    public function cancelMissingBookings(int $eventId, array $seenUids, array $currentLegacyToUid = [], array $globalLegacyToUid = []): array
    {
        if (empty($seenUids)) {
            return ['count' => 0, 'cancelled' => [], 'mismatches' => [], 'cross_event_skipped' => [], 'migrated' => 0];
        }

        // 1) Alle aktiven AIDA-Bookings dieses Events einlesen — wir brauchen das Bild komplett,
        //    nicht nur den NOT-IN-Filter, weil wir vor dem UPDATE noch Mismatches abfangen müssen.
        $sel = $this->pdo->prepare("SELECT b.id, b.booking_no, b.legacy_booking_no,
                                           b.external_uid, b.license_plate, b.status,
                                           c.first_name, c.last_name
                                     FROM bookings b
                                     INNER JOIN customers c ON c.id = b.customer_id
                                    WHERE b.source = :src
                                      AND b.event_id = :eid
                                      AND b.status <> 'cancelled'
                                    ORDER BY b.id ASC");
        $sel->execute(['src' => self::SOURCE, 'eid' => $eventId]);
        $active = $sel->fetchAll();

        $seenUidsSet     = array_flip(array_values(array_unique($seenUids)));
        // Sub-Key-Lookup im aktuellen Final-Batch.
        // Schlüssel = "legacy_booking_no|parking_count_token".
        // Zusätzlich sammeln wir den breiteren Fallback nach legacy_booking_no allein,
        // falls die DB-Buchung mit alter UID kommt und der Sub-Index nicht parsbar ist.
        $currentSubKeySet   = [];
        $currentLegacyOnly  = [];  // legacy → list of subKeys, für Fuzzy-Fallback
        foreach ($currentLegacyToUid as $subKey => $_uids) {
            $currentSubKeySet[(string)$subKey] = true;
            $lbn = explode('|', (string)$subKey, 2)[0];
            $currentLegacyOnly[$lbn][] = (string)$subKey;
        }

        $debug = $this->shouldDebugCancel();
        if ($debug) {
            $this->dbg("");
            $this->dbg("  ── cancelMissingBookings · event_id={$eventId} ──");
            $this->dbg(sprintf("  Active AIDA bookings in DB: %d", count($active)));
            foreach ($active as $b) {
                $dbPc = self::extractParkingCountFromInternalBookingNo((string)($b['booking_no'] ?? ''));
                $this->dbg(sprintf(
                    "    DB  id=%-6d  booking_no=%-20s  legacy=%-12s  pc=%-2s  uid=%s  plate=%-10s  name=%s %s  status=%s",
                    (int)$b['id'],
                    (string)($b['booking_no'] ?? ''),
                    (string)($b['legacy_booking_no'] ?? ''),
                    $dbPc !== '' ? $dbPc : '–',
                    substr((string)($b['external_uid'] ?? ''), 0, 12),
                    (string)($b['license_plate'] ?? ''),
                    (string)($b['first_name'] ?? ''),
                    (string)($b['last_name']  ?? ''),
                    (string)$b['status']
                ));
            }
            $this->dbg(sprintf("  Current UIDs from Final Batch (%d):", count($seenUidsSet)));
            foreach (array_keys($seenUidsSet) as $u) {
                $this->dbg("    UID " . substr((string)$u, 0, 12) . '…');
            }
            $this->dbg(sprintf("  Current sub-keys (legacy|parking_count) in Final Batch (%d):", count($currentSubKeySet)));
            foreach (array_keys($currentSubKeySet) as $sk) {
                $this->dbg("    sub={$sk}");
            }
        }

        // 2) Kandidaten filtern: in DB aktiv, aber UID NICHT im Final-Batch.
        $candidates = array_values(array_filter(
            $active,
            static fn ($b) => !isset($seenUidsSet[(string)($b['external_uid'] ?? '')])
        ));

        // 3) Mehrere Schutzstufen vor dem Cancel:
        //    a) Sub-Key-Match im aktuellen Event → UID-Mismatch (UID-Migration im selben Event).
        //    b) Sub-Key in einem ANDEREN Event dieses Importlaufs → Cross-Event-Umbuchung.
        //       Wir cancelnnicht — der Datensatz wurde vom upsertBooking() schon zum neuen
        //       Event verschoben (oder hätte verschoben werden sollen). Falls in der DB doch
        //       noch eine alte Zeile mit gleicher LBN/PC in einem anderen Event liegt, wird
        //       sie hier durch das DB-Sicherheitsnetz aufgefangen.
        $toCancel           = [];
        $mismatches         = [];
        $crossEventSkipped  = [];
        foreach ($candidates as $c) {
            $lbn  = (string)($c['legacy_booking_no'] ?? '');
            $dbPc = self::extractParkingCountFromInternalBookingNo((string)($c['booking_no'] ?? ''));
            $dbSubKey = $lbn . '|' . $dbPc;

            // 3a) Exakter Sub-Key-Match im aktuellen Event → UID-Mismatch.
            $matchedSubKey = null;
            if ($lbn !== '' && isset($currentSubKeySet[$dbSubKey])) {
                $matchedSubKey = $dbSubKey;
            }
            // Fallback: alte Buchung ohne sauberen Sub-Index in der booking_no, aber im
            // Final gibt es zur selben legacy_booking_no genau EINE Sub-Buchung → eindeutig.
            elseif ($lbn !== '' && isset($currentLegacyOnly[$lbn]) && count($currentLegacyOnly[$lbn]) === 1) {
                $matchedSubKey = $currentLegacyOnly[$lbn][0];
            }

            if ($matchedSubKey !== null) {
                $c['_matched_sub_key'] = $matchedSubKey;
                $mismatches[] = $c;
                if ($debug) {
                    $newUids = $currentLegacyToUid[$matchedSubKey] ?? [];
                    $this->dbg(sprintf(
                        "  [SKIP CANCEL · UID mismatch] id=%d  legacy=%s  db_pc=%s  matched_sub=%s  old_uid=%s  new_uid(s)=%s",
                        (int)$c['id'], $lbn, $dbPc !== '' ? $dbPc : '–', $matchedSubKey,
                        substr((string)($c['external_uid'] ?? ''), 0, 12),
                        implode(', ', array_map(static fn ($u) => substr((string)$u, 0, 12), $newUids))
                    ));
                }
                continue;
            }

            // 3b) Sub-Key in einem ANDEREN Event dieses Importlaufs → Cross-Event-Umbuchung.
            //     Sollte selten greifen, weil upsertBooking() den Datensatz schon umgezogen hat.
            //     Trotzdem als Sicherheitsnetz, falls die Reihenfolge nicht passt oder die Migration scheiterte.
            $crossEventInfo = null;
            if ($lbn !== '' && isset($globalLegacyToUid[$dbSubKey])) {
                foreach ($globalLegacyToUid[$dbSubKey] as $entry) {
                    if (($entry['event_id'] ?? null) !== $eventId) {
                        $crossEventInfo = $entry;
                        break;
                    }
                }
            }
            // 3c) DB-Sicherheitsnetz: dieselbe (lbn, pc) lebt aktuell in einem anderen aktiven Datensatz.
            //     Schützt auch bei manuellen Eingriffen / unklaren Importzuständen.
            if ($crossEventInfo === null && $lbn !== '') {
                $crossEventInfo = $this->findCrossEventActiveBooking($lbn, $dbPc, (int)$c['id']);
            }

            if ($crossEventInfo !== null) {
                $c['_cross_event_info'] = $crossEventInfo;
                $crossEventSkipped[] = $c;
                if ($debug) {
                    $this->dbg(sprintf(
                        "  [SKIP CANCEL · Cross-Event] id=%d  legacy=%s  db_pc=%s  — Buchung lebt in event_id=%s (UID %s)",
                        (int)$c['id'], $lbn, $dbPc !== '' ? $dbPc : '–',
                        $crossEventInfo['event_id'] ?? '?',
                        substr((string)($crossEventInfo['uid'] ?? ''), 0, 12)
                    ));
                }
                continue;
            }

            $toCancel[] = $c;
            if ($debug) {
                $this->dbg(sprintf(
                    "  [WILL CANCEL] id=%d  booking_no=%s  legacy=%s  db_pc=%s  uid=%s  — Grund: weder UID noch (legacy,parking_count) im Final Batch oder in einem anderen Event aktiv",
                    (int)$c['id'],
                    (string)($c['booking_no'] ?? ''),
                    $lbn,
                    $dbPc !== '' ? $dbPc : '–',
                    substr((string)($c['external_uid'] ?? ''), 0, 12)
                ));
            }
        }

        // 4) Mismatch-Reparatur: alte UID auf neue UID migrieren, sofern eindeutig
        //    und die neue UID nicht schon auf eine andere Buchung zeigt.
        $migrated = 0;
        foreach ($mismatches as $c) {
            $subKey  = (string)($c['_matched_sub_key'] ?? '');
            if ($subKey === '') continue;
            $newUids = array_values(array_unique($currentLegacyToUid[$subKey] ?? []));
            if (count($newUids) !== 1) {
                // Nicht eindeutig (gleiche Sub-Buchung in mehreren Produkt-Dateien
                // mit unterschiedlichen Hashes — sollte nicht passieren) → nicht raten.
                continue;
            }
            $newUid = $newUids[0];
            $oldUid = (string)($c['external_uid'] ?? '');
            if ($newUid === $oldUid || $newUid === '') continue;

            try {
                $upd = $this->pdo->prepare(
                    "UPDATE bookings SET external_uid = :new WHERE id = :id AND source = :src"
                );
                $upd->execute(['new' => $newUid, 'id' => (int)$c['id'], 'src' => self::SOURCE]);
                if ($upd->rowCount() > 0) {
                    $migrated++;
                    if ($debug) {
                        $this->dbg(sprintf(
                            "  [MIGRATED UID] id=%d  sub=%s  %s → %s",
                            (int)$c['id'], $subKey,
                            substr($oldUid, 0, 12), substr($newUid, 0, 12)
                        ));
                    }
                }
            } catch (PDOException $e) {
                // UNIQUE-Konflikt: die Ziel-UID gehört einer anderen Buchung. Wir lassen
                // beide Datensätze in Ruhe und melden den Fall im Debug-Log.
                if ($debug) {
                    $this->dbg(sprintf(
                        "  [MIGRATE FAILED] id=%d  sub=%s  %s → %s  reason=%s",
                        (int)$c['id'], $subKey,
                        substr($oldUid, 0, 12), substr($newUid, 0, 12),
                        $e->getMessage()
                    ));
                }
            }
        }

        // 5) Echte Stornos durchführen — nur die wirklichen Kandidaten, IDs-basiert
        //    (damit hier kein NOT-IN-Filter mehr greift, der die Mismatches doch noch erwischen könnte).
        $count = 0;
        if (!empty($toCancel)) {
            $ids = array_map(static fn ($r) => (int)$r['id'], $toCancel);
            $placeholders = [];
            $params = [];
            foreach ($ids as $i => $id) {
                $key = 'i' . $i;
                $placeholders[] = ':' . $key;
                $params[$key] = $id;
            }
            $upd = $this->pdo->prepare(
                "UPDATE bookings SET
                    status = 'cancelled',
                    checkin_status = 'cancelled',
                    notes = CONCAT(COALESCE(notes,''),
                                   CASE WHEN COALESCE(notes,'') = '' THEN '' ELSE CHAR(10) END,
                                   'Automatisch storniert, da nicht mehr in AIDA Update Liste enthalten.')
                  WHERE id IN (" . implode(',', $placeholders) . ")"
            );
            $upd->execute($params);
            $count = $upd->rowCount();
        }

        if ($debug) {
            $this->dbg(sprintf(
                "  ── Storno-Pass fertig · cancelled=%d  mismatches=%d  cross_event_skipped=%d  migrated=%d",
                $count, count($mismatches), count($crossEventSkipped), $migrated
            ));
        }

        // Interne Annotationen aus den Records entfernen, bevor sie in die Aufrufer-Summary
        // wandern (sonst landen sie in import_runs.summary / batches.summary JSON).
        $cleanup = static function (array $rec): array {
            unset($rec['_matched_sub_key'], $rec['_cross_event_info']);
            return $rec;
        };

        return [
            'count'               => $count,
            'cancelled'           => array_map($cleanup, $toCancel),
            'mismatches'          => array_map($cleanup, $mismatches),
            'cross_event_skipped' => array_map($cleanup, $crossEventSkipped),
            'migrated'            => $migrated,
        ];
    }

    /**
     * Cross-Event-DB-Check: gibt es eine andere aktive Buchung mit derselben legacy_booking_no
     * und derselben parking_count (aus interner booking_no geparst) als die aktuell geprüfte?
     * Wenn ja: die Buchung ist im selben Importlauf in eine andere Cruise umgezogen.
     */
    private function findCrossEventActiveBooking(string $legacyBookingNo, string $parkingCountToken, int $excludeId): ?array
    {
        $stmt = $this->pdo->prepare(
            "SELECT id, event_id, external_uid, booking_no
               FROM bookings
              WHERE source = :src
                AND legacy_booking_no = :lbn
                AND status <> 'cancelled'
                AND id <> :id
              ORDER BY id ASC"
        );
        $stmt->execute(['src' => self::SOURCE, 'lbn' => $legacyBookingNo, 'id' => $excludeId]);
        foreach ($stmt->fetchAll() as $row) {
            $rowPc = self::extractParkingCountFromInternalBookingNo((string)($row['booking_no'] ?? ''));
            if ($rowPc === $parkingCountToken) {
                return [
                    'event_id' => (int)$row['event_id'],
                    'uid'      => (string)$row['external_uid'],
                ];
            }
        }
        return null;
    }

    /** Debug-Ausgabe an STDERR, falls vorhanden. CLI-only — Web-Requests bleiben still. */
    private function dbg(string $line): void
    {
        if (PHP_SAPI !== 'cli') return;
        fwrite(STDERR, $line . PHP_EOL);
    }

    /** Debug-Modus für den Storno-Pass. CLI per Default an, per Env AIDA_IMPORT_DEBUG=0 abschaltbar. */
    private function shouldDebugCancel(): bool
    {
        if (PHP_SAPI !== 'cli') return false;
        $env = getenv('AIDA_IMPORT_DEBUG');
        if ($env === '0' || strcasecmp((string)$env, 'false') === 0) return false;
        return true;
    }

    /* -------------------------------------------------------------------- */
    /*  Import Runs                                                          */
    /* -------------------------------------------------------------------- */

    private function startImportRun(string $cruiseId, array $event, string $filename): int
    {
        $stmt = $this->pdo->prepare("INSERT INTO import_runs
            (source, cruise, event_id, ship_name, travel_from, travel_to, filename, status)
            VALUES (:src, :c, :eid, :sn, :tf, :tt, :fn, 'running')");
        $stmt->execute([
            'src' => self::SOURCE,
            'c'   => $cruiseId,
            'eid' => (int)$event['id'],
            'sn'  => $event['ship_name'] ?? null,
            'tf'  => $event['starts_at'] ? date('Y-m-d', strtotime((string)$event['starts_at'])) : null,
            'tt'  => $event['ends_at']   ? date('Y-m-d', strtotime((string)$event['ends_at']))   : null,
            'fn'  => $filename,
        ]);
        return (int)$this->pdo->lastInsertId();
    }

    private function completeImportRun(int $runId, array $summary): void
    {
        $stmt = $this->pdo->prepare("UPDATE import_runs SET status = 'completed', summary = :s WHERE id = :id");
        $stmt->execute(['s' => json_encode($summary, JSON_UNESCAPED_UNICODE), 'id' => $runId]);
    }

    /* -------------------------------------------------------------------- */
    /*  Datei-Handling                                                       */
    /* -------------------------------------------------------------------- */

    /**
     * Rückwirkender Backfill für `import_file_bookings`. Liest alle AIDA-Excel-Dateien
     * aus `files` (file_category ∈ aida_*, prediction, preview/final/manifest/update)
     * neu ein und schreibt für jede Buchungs-Zeile einen Mapping-Eintrag.
     *
     * Geht über `files.file_path` (Web-Pfad) → Projekt-Root → Disk. Dateien, die nicht
     * mehr auffindbar sind, werden übersprungen.
     *
     * @return array{processed:int, mapped:int, skipped:int, errors:string[]}
     */
    public function backfillImportFileBookings(): array
    {
        $processed = 0;
        $mapped    = 0;
        $skipped   = 0;
        $errors    = [];

        // Nur Dateien mit verwertbarer import_uuid — ohne UUID kein Mapping-Schlüssel.
        $stmt = $this->pdo->query("
            SELECT id, file_name, file_path, file_category, import_uuid
              FROM files
             WHERE import_uuid IS NOT NULL
               AND import_uuid <> ''
               AND file_category IN
                   ('aida_booking','aida_prebooking','prediction','pre','preview','final','manifest','update')
               AND (file_name LIKE '%.xlsx' OR file_name LIKE '%.xls')
        ");
        $files = $stmt->fetchAll() ?: [];

        foreach ($files as $f) {
            $abs = $this->resolveDiskPath((string)$f['file_path']);
            if ($abs === null || !is_file($abs)) {
                $skipped++;
                continue;
            }

            $rows = $this->parseExcel($abs);
            if ($rows === null || empty($rows)) {
                $skipped++;
                continue;
            }
            // Nur AIDA-Booking-Listen haben die nötigen Spalten — Prediction-Listen
            // werden hier korrekt übersprungen (keine Booking No / License Plate).
            if (!$this->looksLikeAidaBookings($rows[0])) {
                $skipped++;
                continue;
            }
            $processed++;

            $importUuid = (string)$f['import_uuid'];
            foreach ($rows as $i => $raw) {
                $row = $this->normalizeRow($raw);
                if ($row['cruise_id'] === '' || $row['booking_no'] === '') continue;
                $row['_product_ids'] = self::PRODUCT_MAP[$row['product_code_base']] ?? null;
                if ($row['_product_ids'] === null) continue;

                $uid = $this->buildExternalUid($row, $row['cruise_id']);
                if ($uid === null) continue;

                // Booking-ID nachschlagen (kann null sein, wenn die Buchung inzwischen
                // hart gelöscht wurde — Mapping bleibt trotzdem nützlich).
                $look = $this->pdo->prepare("SELECT id FROM bookings WHERE source = :s AND external_uid = :u LIMIT 1");
                $look->execute(['s' => self::SOURCE, 'u' => $uid]);
                $bid = $look->fetchColumn();
                $bookingId = $bid !== false ? (int)$bid : null;

                $ins = $this->pdo->prepare("INSERT INTO import_file_bookings
                    (import_uuid, external_uid, booking_id, line_no)
                    VALUES (:iu, :uid, :bid, :ln)
                    ON DUPLICATE KEY UPDATE
                        booking_id = COALESCE(VALUES(booking_id), booking_id),
                        line_no    = COALESCE(VALUES(line_no), line_no)");
                $ins->execute([
                    'iu'  => $importUuid,
                    'uid' => $uid,
                    'bid' => $bookingId,
                    'ln'  => $i + 2,
                ]);
                $mapped++;
            }
        }

        return ['processed' => $processed, 'mapped' => $mapped, 'skipped' => $skipped, 'errors' => $errors];
    }

    /** Web-Pfad ("/data/...") in absoluten Disk-Pfad auflösen. */
    private function resolveDiskPath(string $webPath): ?string
    {
        if ($webPath === '') return null;
        if ($webPath[0] === '/') {
            return $this->projectRoot . $webPath;
        }
        return $webPath;
    }

    /**
     * Hält das N:M-Mapping „diese Datei enthielt diese Buchung" fest. Wird pro
     * verarbeiteter Buchungs-Zeile aufgerufen — damit lässt sich später jede Datei
     * auflisten, in der eine Buchung vorkam (Preview / Update / Final).
     *
     * INSERT … ON DUPLICATE KEY UPDATE ist idempotent: re-imports derselben Datei
     * überschreiben booking_id (falls die Buchung umgehängt wurde) und line_no.
     */
    private function recordImportFileBooking(string $externalUid, int $bookingId, ?int $lineNo): void
    {
        if ($this->currentImportUuid === null || $externalUid === '') return;
        $stmt = $this->pdo->prepare("INSERT INTO import_file_bookings
            (import_uuid, external_uid, booking_id, line_no)
            VALUES (:iu, :uid, :bid, :ln)
            ON DUPLICATE KEY UPDATE
                booking_id = VALUES(booking_id),
                line_no    = VALUES(line_no)");
        $stmt->execute([
            'iu'  => $this->currentImportUuid,
            'uid' => $externalUid,
            'bid' => $bookingId ?: null,
            'ln'  => $lineNo ?: null,
        ]);
    }

    /**
     * Registriert die Datei in `files`. Die Kategorie wird aus Dateinamen + Cruise-Begin
     * abgeleitet:
     *   - ParkingOperatorlist, mtime ≥ 2 Tage vor Begin → "vorab"
     *   - ParkingOperatorlist, weniger → "final"
     *   - sonst → "aida_booking" (Default)
     */
    private function registerFile(string $path, string $md5, ?string $cruiseBeginIso = null, ?string $preComputedCategory = null): void
    {
        $name     = basename($path);
        if ($preComputedCategory !== null) {
            $category = $preComputedCategory;
        } else {
            $category = 'aida_booking';
            if (stripos($name, 'ParkingOperatorlist') !== false) {
                $category = self::classifyOperatorList($path, $cruiseBeginIso);
            }
        }

        $stmt = $this->pdo->prepare("INSERT INTO files
            (file_name, file_size, file_md5, file_category, file_path, file_type, import_uuid)
            VALUES (:n, :s, :m, :c, :p, 'file', :iu)");
        $stmt->execute([
            'n' => $name,
            's' => filesize($path) ?: 0,
            'm' => $md5,
            'c' => $category,
            'p' => $this->toWebPath($path),
            'iu'=> $this->currentImportUuid,
        ]);
    }

    /**
     * "preview" → AIDA-Erstliste (≥ 2 Tage vor Anlauf, also Diff zw. Dateiname-Datum
     *             und Departure Date ≥ 2 Tage).
     * "final"   → AIDA-Update-Liste (1 Tag vor Anlauf).
     *
     * Das Empfangs-Datum wird primär aus dem führenden "DD.MM.YYYY" im DATEINAMEN gezogen
     * (zuverlässiger als filemtime, das beim Kopieren/Verschieben verloren geht). Fehlt
     * der Datum-Prefix, fällt der Klassifizierer auf die Datei-mtime zurück.
     */
    private static function classifyOperatorList(string $path, ?string $cruiseBeginIso): string
    {
        if ($cruiseBeginIso === null || $cruiseBeginIso === '') return 'aida_booking';
        $beginTs = strtotime($cruiseBeginIso);
        if ($beginTs === false) return 'aida_booking';

        $name      = basename($path);
        $nameDate  = self::extractFilenameDate($name);
        if ($nameDate !== null) {
            $refTs = strtotime($nameDate);
        } else {
            $mt    = filemtime($path);
            $refTs = $mt !== false ? $mt : time();
        }
        if ($refTs === false) return 'aida_booking';

        // Differenz in vollen Tagen (Mitternacht-zu-Mitternacht, ignoriert Tageszeit).
        $beginDay = strtotime(date('Y-m-d', $beginTs));
        $refDay   = strtotime(date('Y-m-d', $refTs));
        $diffDays = (int)round(($beginDay - $refDay) / 86400);

        return $diffDays >= 2 ? 'preview' : 'final';
    }

    /**
     * Liest ein führendes "DD.MM.YYYY" aus einem Dateinamen und liefert es als "YYYY-MM-DD".
     */
    /* -------------------------------------------------------------------- */
/*  Batch-Helpers                                                         */
/* -------------------------------------------------------------------- */

    /**
     * Liest die ersten Daten-Zeilen einer Datei und extrahiert daraus die Batch-Identität:
     * cruise, departure_date, arrival_date, ship + file_date / product_code aus dem Namen.
     */
    private function peekFileMetadata(string $path): ?array
    {
        $rows = $this->parseExcel($path);
        if ($rows === null || empty($rows)) return null;
        return $this->peekFileMetadataFromRows(basename($path), $rows);
    }

    private function peekFileMetadataFromRows(string $filename, array $rows): ?array
    {
        if (empty($rows) || !$this->looksLikeAidaBookings($rows[0])) return null;
        $first = $this->normalizeRow($rows[0]);
        if (($first['cruise_id'] ?? '') === '') return null;

        $fileDate    = self::parseFileDateFromFilename($filename);
        $productCode = self::extractProductCodeFromFilename($filename);
        $listType    = self::detectListType($fileDate, $first['departure_date'] ?? null);

        return [
            'cruise'         => $first['cruise_id'],
            'ship'           => $first['ship'] ?? null,
            'departure_date' => $first['departure_date'] ?? null,
            'arrival_date'   => $first['arrival_date']   ?? null,
            'file_date'      => $fileDate,
            'product_code'   => $productCode,
            'list_type'      => $listType,
        ];
    }

    /** "12.05.2026 - …" → "2026-05-12". */
    public static function parseFileDateFromFilename(string $name): ?string
    {
        if (!preg_match('/(\d{2})\.(\d{2})\.(\d{4})/', $name, $m)) return null;
        $d = (int)$m[1]; $mo = (int)$m[2]; $y = (int)$m[3];
        return checkdate($mo, $d, $y) ? sprintf('%04d-%02d-%02d', $y, $mo, $d) : null;
    }

    /** Holt den AIDA-Produktcode (PW400G, PW850M, …) aus dem Dateinamen. */
    public static function extractProductCodeFromFilename(string $name): ?string
    {
        return preg_match('/(PW\d{2,3}[A-Z])/', $name, $m) ? $m[1] : null;
    }

    /** "preview" wenn Datei ≥ 2 Tage vor Departure, "final" bei 1 oder 0 Tagen, sonst "unknown". */
    public static function detectListType(?string $fileDateIso, ?string $departureDateIso): string
    {
        if (!$fileDateIso || !$departureDateIso) return 'unknown';
        $f = strtotime($fileDateIso);
        $d = strtotime($departureDateIso);
        if ($f === false || $d === false) return 'unknown';
        $diff = (int)round(($d - $f) / 86400);
        if ($diff >= 2) return 'preview';
        if ($diff >= 0) return 'final';
        return 'unknown';
    }

    private function createBatchRecord(array $meta, int $eventId): int
    {
        $stmt = $this->pdo->prepare("INSERT INTO import_batches
            (source, cruise, event_id, ship_name, departure_date, arrival_date, file_date, list_type, status)
            VALUES (:src, :c, :eid, :sn, :dd, :ad, :fd, :lt, 'running')");
        $stmt->execute([
            'src' => self::SOURCE,
            'c'   => $meta['cruise'],
            'eid' => $eventId,
            'sn'  => $meta['ship']           ?? null,
            'dd'  => $meta['departure_date'] ?? null,
            'ad'  => $meta['arrival_date']   ?? null,
            'fd'  => $meta['file_date']      ?? null,
            'lt'  => $meta['list_type']      ?? null,
        ]);
        return (int)$this->pdo->lastInsertId();
    }

    private function finishBatchRecord(int $id, array $summary): void
    {
        $stmt = $this->pdo->prepare("UPDATE import_batches
                                       SET status = 'completed', finished_at = NOW(), summary = :s
                                     WHERE id = :id");
        $stmt->execute([
            's'  => json_encode($summary, JSON_UNESCAPED_UNICODE),
            'id' => $id,
        ]);
    }

    /**
     * Pro-Datei-Eintrag in import_files. UNIQUE(source, filename) sorgt für die
     * Dedup-Garantie auf Persistenz-Ebene.
     */
    private function recordImportFile(int $batchId, array $fileInfo, array $fileResult, array $batchMeta): void
    {
        try {
            $hash = is_file($fileInfo['path']) ? (md5_file($fileInfo['path']) ?: null) : null;
            $stmt = $this->pdo->prepare("INSERT INTO import_files
                (import_batch_id, source, filename, file_hash, file_date, list_type,
                 cruise, event_id, product_code, status, summary, import_uuid)
              VALUES (:bid, :src, :fn, :fh, :fd, :lt, :cr, :eid, :pc, :st, :sum, :iu)
              ON DUPLICATE KEY UPDATE
                  import_batch_id = VALUES(import_batch_id),
                  status          = VALUES(status),
                  summary         = VALUES(summary),
                  import_uuid     = COALESCE(import_uuid, VALUES(import_uuid))");
            $stmt->execute([
                'bid' => $batchId,
                'src' => self::SOURCE,
                'fn'  => $fileInfo['filename'],
                'fh'  => $hash,
                'fd'  => $batchMeta['file_date']      ?? null,
                'lt'  => $batchMeta['list_type']      ?? null,
                'cr'  => $batchMeta['cruise']         ?? null,
                'eid' => $batchMeta['event_id']       ?? null,
                'pc'  => $fileInfo['product_code']    ?? null,
                'st'  => $fileResult['status']        ?? 'pending',
                'iu'  => $this->currentImportUuid,
                'sum' => json_encode([
                    'created'   => $fileResult['created']   ?? 0,
                    'updated'   => $fileResult['updated']   ?? 0,
                    'unchanged' => $fileResult['unchanged'] ?? 0,
                    'errors'    => $fileResult['errors']    ?? [],
                    'uid_count' => count($fileResult['uids'] ?? []),
                ], JSON_UNESCAPED_UNICODE),
            ]);
        } catch (PDOException) {
            // Logging darf den Import nicht abreißen lassen.
        }
    }

    private static function extractFilenameDate(string $name): ?string
    {
        if (!preg_match('/(\d{2})\.(\d{2})\.(\d{4})/', $name, $m)) return null;
        $day = (int)$m[1]; $mon = (int)$m[2]; $yr = (int)$m[3];
        if (!checkdate($mon, $day, $yr)) return null;
        return sprintf('%04d-%02d-%02d', $yr, $mon, $day);
    }

    private function moveToTrash(string $path): string
    {
        $day  = date('Y-m-d');
        $dest = $this->trashDir . '/' . $day;
        if (!is_dir($dest)) @mkdir($dest, 0775, true);

        $name   = basename($path);
        $target = $dest . '/' . $name;
        if (file_exists($target)) {
            $info  = pathinfo($name);
            $base  = $info['filename'];
            $ext   = isset($info['extension']) ? '.' . $info['extension'] : '';
            $target = $dest . '/' . $base . '_' . date('His') . $ext;
        }
        @rename($path, $target);
        return $target;
    }

    /* -------------------------------------------------------------------- */
    /*  Helfer                                                               */
    /* -------------------------------------------------------------------- */

    /**
     * Interne Buchungs-Nr: "DDMMYY-A-{Cabin}" bzw. "DDMMYY-A{N}-{Cabin}", wenn
     * `N° of Parking Bookings` (parking_count) gesetzt ist. DDMMYY kommt aus dem
     * Reisebeginn (= travel_from / departure_date). Fällt die Kabinen-Nr. weg, wird
     * stattdessen die externe Booking-No (oder ein Hash) genutzt.
     */
    private function generateInternalBookingNo(array $row): string
    {
        $ts     = !empty($row['departure_date']) ? strtotime((string)$row['departure_date']) : false;
        $ddmmyy = $ts !== false ? date('dmy', $ts) : '000000';
        $tail   = $row['cabin_number'] !== ''
            ? (string)$row['cabin_number']
            : (string)($row['booking_no'] ?: substr(sha1(uniqid('', true)), 0, 8));
        $parking = $row['parking_count'];
        $suffix  = ($parking !== null && $parking !== '') ? 'A' . $parking : 'A';
        return mb_substr($ddmmyy . '-' . $suffix . '-' . $tail, 0, 20);
    }

    /**
     * Rechnungsnummer: "AIDA-{Booking No}" bzw. "AIDA-{Booking No}-{N}" bei gesetztem
     * parking_count. Booking No ist die ORIGINAL-AIDA-Nummer (= legacy_booking_no).
     */
    private function generateInvoiceNo(array $row): string
    {
        $bn = (string)$row['booking_no'];
        $p  = $row['parking_count'];
        return ($p !== null && $p !== '') ? "AIDA-{$bn}-{$p}" : "AIDA-{$bn}";
    }

    /** Excel-Seriennummer → "YYYY-MM-DD". */
    private static function excelDate(string|null|int|float $value): ?string
    {
        if ($value === null || $value === '' || !is_numeric($value)) return null;
        $ts = strtotime('1899-12-30 +' . (int)$value . ' days');
        return $ts ? date('Y-m-d', $ts) : null;
    }

    /** Excel-Seriennummer (mit Bruchteil) → "YYYY-MM-DD HH:MM:SS". */
    private static function excelDateTime(string|null|int|float $value): ?string
    {
        if ($value === null || $value === '' || !is_numeric($value)) return null;
        $f = (float)$value;
        $days    = (int)floor($f);
        $seconds = (int)round(($f - $days) * 86400);
        $base    = strtotime('1899-12-30 +' . $days . ' days');
        return $base ? date('Y-m-d H:i:s', $base + $seconds) : null;
    }

    private static function normalizePlate(string $plate): string
    {
        $up = mb_strtoupper($plate);
        $up = strtr($up, ['Ä' => 'AE', 'Ö' => 'OE', 'Ü' => 'UE', 'ß' => 'SS']);
        return preg_replace('/[^A-Z0-9]/', '', $up) ?? '';
    }

    /**
     * Liefert den Buchstaben-Präfix eines deutschen Kennzeichens — also den Ort.
     *   "HH-AB 123"    → "HH"
     *   "B-XY-9999"    → "B"
     *   "LDS-N 576"    → "LDS"
     *   "MÜ-K 12"      → "MUE"   (Umlaute werden wie in normalizePlate aufgelöst)
     * Leer, wenn das Kennzeichen mit keinem Buchstaben beginnt.
     */
    public static function extractPlatePrefix(string $plate): string
    {
        if (trim($plate) === '') return '';
        $up = mb_strtoupper(trim($plate));
        $up = strtr($up, ['Ä' => 'AE', 'Ö' => 'OE', 'Ü' => 'UE', 'ß' => 'SS']);
        if (preg_match('/^([A-Z]+)/', $up, $m)) return $m[1];
        return '';
    }

    private static function parsePrice(string $value): float
    {
        if ($value === '') return 0.0;
        // "1.234,56" oder "1234.56" — Tausender raus, Komma → Punkt.
        $clean = preg_replace('/[^\d,.\-]/', '', $value) ?? '';
        if (substr_count($clean, ',') > 0 && substr_count($clean, '.') > 0) {
            // beide vorhanden → ',' ist Dezimaltrenner, '.' Tausender
            $clean = str_replace('.', '', $clean);
            $clean = str_replace(',', '.', $clean);
        } else {
            $clean = str_replace(',', '.', $clean);
        }
        return is_numeric($clean) ? (float)$clean : 0.0;
    }

    private static function slugifyHeader(string $h): string
    {
        $h = mb_strtolower(trim($h));
        $h = strtr($h, ['ä' => 'ae', 'ö' => 'oe', 'ü' => 'ue', 'ß' => 'ss', '°' => '']);
        $h = preg_replace('/[^a-z0-9]+/', '_', $h) ?? '';
        return trim($h, '_');
    }

    private function result(string $status, array $errors = [], int $created = 0, int $updated = 0, int $unchanged = 0, int $cancelled = 0, array $logRows = []): array
    {
        return [
            'status'    => $status,
            'created'   => $created,
            'updated'   => $updated,
            'unchanged' => $unchanged,
            'cancelled' => $cancelled,
            'errors'    => $errors,
            'log_rows'  => $logRows,
        ];
    }

    /* -------------------------------------------------------------------- */
    /*  Log-Datei (CSV neben dem Excel im Trash)                            */
    /* -------------------------------------------------------------------- */

    /**
     * Schreibt das Per-Zeilen-Log als semikolongetrennte CSV nach `<datapath>.log`.
     * Header + UTF-8-BOM, damit Excel/LibreOffice die Datei direkt korrekt öffnen.
     */
    private function writeLogFile(string $dataPath, array $logRows, array $resultSummary): string
    {
        $logPath = $dataPath . '.log';
        $fh = @fopen($logPath, 'w');
        if ($fh === false) return '';
        fwrite($fh, "\xEF\xBB\xBF");   // UTF-8 BOM für Excel-Kompatibilität

        // Kopfblock mit Gesamtbilanz
        $stamp = date('Y-m-d H:i:s');
        $head = [
            ['#', "AIDA Buchungs-Import · " . basename($dataPath)],
            ['#', "Verarbeitet: $stamp"],
            ['#', "Erstellt: {$resultSummary['created']} · Aktualisiert: {$resultSummary['updated']} · Unverändert: {$resultSummary['unchanged']} · Storniert: {$resultSummary['cancelled']}"],
            [''],
            ['Zeile', 'Status', 'Cruise', 'Buchungs-Nr.', 'Kunde', 'Hinweis'],
        ];
        foreach ($head as $r) fputcsv($fh, $r, ';', '"', '\\');

        foreach ($logRows as $row) {
            fputcsv($fh, [
                $row['line']       ?? '',
                $row['status']     ?? '',
                $row['cruise']     ?? '',
                $row['booking_no'] ?? '',
                $row['customer']   ?? '',
                $row['note']       ?? '',
            ], ';', '"', '\\');
        }
        fclose($fh);
        return $logPath;
    }

    /**
     * Registriert die Log-Datei in `files` (file_category='import_log'), damit sie
     * im Trash-Browser auftaucht und über den Log-Button aufrufbar ist.
     */
    private function registerLogFile(string $logPath): void
    {
        if (!is_file($logPath)) return;
        $stmt = $this->pdo->prepare("INSERT INTO files
            (file_name, file_size, file_md5, file_category, file_path, file_type)
            VALUES (:n, :s, :m, 'import_log', :p, 'file')");
        $stmt->execute([
            'n' => basename($logPath),
            's' => filesize($logPath) ?: 0,
            'm' => md5_file($logPath) ?: '',
            'p' => $this->toWebPath($logPath),
        ]);
    }
}

Youez - 2016 - github.com/yon3zu
LinuXploit