| 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 : |
<?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),
]);
}
}