Pfad in travel_booking.selected_travel (Array notation). * NULL-Pfade werden durch spezielle Closure-Handler behandelt. */ private const FIELD_MAP = [ 'title' => ['travel_title'], 'travel_number' => ['travel_number'], 'travel_country_id' => ['travel_country_id', 0], 'travel_category_id' => ['travel_category_id'], 'travelagenda_id' => ['travelagenda_id'], 'travel_company_id' => ['travel_company_id'], ]; public function handle(): int { $apply = (bool) $this->option('apply'); $force = (bool) $this->option('force'); $days = max(1, (int) $this->option('days')); $tbIds = (array) $this->option('id'); $bookingIds = (array) $this->option('booking-id'); $query = TravelBooking::query() ->whereNotNull('crm_booking_id') ->orderBy('id', 'desc'); if (!empty($tbIds)) { $query->whereIn('id', $tbIds); $scopeLabel = 'per --id=' . implode(',', $tbIds); } elseif (!empty($bookingIds)) { $query->whereIn('crm_booking_id', $bookingIds); $scopeLabel = 'per --booking-id=' . implode(',', $bookingIds); } else { $query->where('created', '>=', Carbon::now()->subDays($days)); $scopeLabel = "letzte {$days} Tage"; } $total = (clone $query)->count(); $this->line(sprintf('Prüfe %d verknüpfte(n) travel_booking-Eintrag/e (%s)…', $total, $scopeLabel)); if ($total === 0) { $this->info('Keine passenden Einträge gefunden.'); return self::SUCCESS; } $plan = []; $missingSource = []; $missingTarget = []; $query->chunkById(200, function ($chunk) use (&$plan, &$missingSource, &$missingTarget, $force) { $crmIds = $chunk->pluck('crm_booking_id')->filter()->all(); if (empty($crmIds)) { return; } $bookings = Booking::whereIn('id', $crmIds)->get()->keyBy('id'); foreach ($chunk as $tb) { $booking = $bookings->get($tb->crm_booking_id); if (!$booking) { $missingTarget[] = $tb; continue; } $source = $tb->selected_travel; if (!is_array($source) || empty($source)) { $missingSource[] = $tb; continue; } $diff = $this->computeDiff($booking, $source, $force); if (!empty($diff)) { $plan[] = [ 'tb' => $tb, 'booking' => $booking, 'diff' => $diff, ]; } } }); $this->newLine(); $this->info('Analyse:'); $this->line(sprintf(' • Booking-Einträge mit reparierbaren Lücken: %d', count($plan))); $this->line(sprintf(' • Ohne selected_travel-JSON (nicht reparierbar): %d', count($missingSource))); $this->line(sprintf(' • CRM-Booking existiert nicht mehr (Orphan): %d', count($missingTarget))); if (!empty($missingSource)) { $this->newLine(); $this->warn('Folgende travel_bookings haben kein/leeres selected_travel — hier lässt sich nichts nachziehen:'); foreach ($missingSource as $tb) { $this->line(sprintf(' SKIP tb#%d → booking #%d', $tb->id, $tb->crm_booking_id)); } } if (!empty($missingTarget)) { $this->newLine(); $this->warn('Folgende travel_bookings verweisen auf ein nicht existierendes CRM-Booking:'); foreach ($missingTarget as $tb) { $this->line(sprintf(' ORPHAN tb#%d → booking #%d fehlt', $tb->id, $tb->crm_booking_id)); } } if (empty($plan)) { $this->newLine(); $this->info('Keine Reparaturen nötig — alle Programm-Felder sind synchron.'); return self::SUCCESS; } $this->newLine(); $this->line('Geplante Änderungen:'); foreach ($plan as $item) { $tb = $item['tb']; $booking = $item['booking']; $this->line(sprintf(' booking #%d (tb#%d, %s):', $booking->id, $tb->id, $booking->title ?: '—')); foreach ($item['diff'] as $field => $change) { $this->line(sprintf( ' %-20s %s → %s', $field, $this->fmt($change['old']), $this->fmt($change['new']) )); } } if (!$apply) { $this->newLine(); $this->warn('Dry-Run — keine Änderungen. Mit --apply ausführen, um die Werte zu schreiben.'); if (!$force) { $this->line('Hinweis: Nur leere Felder werden gefüllt. Mit --force auch bestehende Werte überschreiben.'); } return self::SUCCESS; } $this->newLine(); $this->info('Schreibe Änderungen…'); $ok = 0; $fail = 0; foreach ($plan as $item) { $booking = $item['booking']; try { DB::connection('mysql')->beginTransaction(); foreach ($item['diff'] as $field => $change) { $booking->{$field} = $change['new']; } $booking->save(); DB::connection('mysql')->commit(); $ok++; $this->info(sprintf(' ✓ booking #%d aktualisiert (%d Feld/er)', $booking->id, count($item['diff']))); } catch (Throwable $e) { if (DB::connection('mysql')->transactionLevel() > 0) { DB::connection('mysql')->rollBack(); } $fail++; $this->error(sprintf(' ✗ booking #%d FAILED: %s', $booking->id, $e->getMessage())); } } $this->newLine(); $this->info(sprintf('Fertig. Aktualisiert: %d Fehlgeschlagen: %d', $ok, $fail)); return $fail > 0 ? self::FAILURE : self::SUCCESS; } /** * Ermittelt, welche Felder sich ändern würden. * * @return array */ private function computeDiff(Booking $booking, array $source, bool $force): array { $diff = []; foreach (self::FIELD_MAP as $column => $path) { $newValue = $this->resolvePath($source, $path); if ($newValue === null || $newValue === '') { continue; } $currentValue = $booking->{$column} ?? null; if (!$force && $this->isFilled($currentValue)) { continue; } if ((string) $currentValue === (string) $newValue) { continue; } $diff[$column] = [ 'old' => $currentValue, 'new' => $newValue, ]; } return $diff; } /** * Folgt einem Array-Pfad (z. B. ['travel_country_id', 0]) im Source-Array. */ private function resolvePath(array $source, array $path) { $cursor = $source; foreach ($path as $key) { if (!is_array($cursor) || !array_key_exists($key, $cursor)) { return null; } $cursor = $cursor[$key]; } return $cursor; } private function isFilled($value): bool { return $value !== null && $value !== '' && $value !== 0 && $value !== '0'; } private function fmt($value): string { if ($value === null) return ''; if ($value === '') return '""'; return (string) $value; } }