mein-sterntours/app/Console/Commands/BookingsRepairFromTravelBookings.php

280 lines
10 KiB
PHP

<?php
namespace App\Console\Commands;
use Throwable;
use Carbon\Carbon;
use App\Models\Booking;
use App\Models\TravelBooking;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
/**
* Repair: Programm-Felder im CRM-Booking aus travel_booking.selected_travel nachziehen.
*
* Hintergrund:
* BookingImport::importFrom() liest beim API-Import die Programm-Informationen
* aus travel_booking.selected_travel (JSON) und schreibt sie in booking. War
* dieses JSON zum Import-Zeitpunkt unvollständig (oder leer), fehlen im CRM
* einzelne Programm-Felder — sichtbar z. B. als "Reiseprogramm fehlt" in der
* Buchungsanzeige. Das travel_booking-Backup enthält die Daten in der Regel
* trotzdem (bzw. wurde später vervollständigt), also können wir sie sauber
* nachziehen.
*
* Geprüfte / reparierbare Felder:
* booking.title ← selected_travel['travel_title']
* booking.travel_number ← selected_travel['travel_number']
* booking.travel_country_id ← selected_travel['travel_country_id'][0]
* booking.travel_category_id ← selected_travel['travel_category_id']
* booking.travelagenda_id ← selected_travel['travelagenda_id']
* booking.travel_company_id ← selected_travel['travel_company_id'] (Default 4)
*
* Strategie:
* • Default: nur Felder ÜBERSCHREIBEN, die im Booking aktuell leer/NULL sind
* (→ keine manuell gesetzten Werte werden zerstört).
* • --force: setzt auch bereits gefüllte Felder auf den Source-Wert um.
* • Default: Dry-Run. Nur mit --apply wird wirklich geschrieben.
*
* Beispiele:
* php artisan bookings:repair-from-travel-bookings
* php artisan bookings:repair-from-travel-bookings --days=30
* php artisan bookings:repair-from-travel-bookings --booking-id=9876 --apply
* php artisan bookings:repair-from-travel-bookings --id=12345 --apply --force
*/
class BookingsRepairFromTravelBookings extends Command
{
protected $signature = 'bookings:repair-from-travel-bookings
{--days=14 : Zeitfenster in Tagen (auf travel_booking.created); ignoriert bei --id/--booking-id}
{--id=* : Konkrete travel_booking.id(s)}
{--booking-id=* : Konkrete booking.id(s) (im CRM) — mappt rückwärts auf travel_booking}
{--apply : Änderungen wirklich schreiben (ohne Flag: nur Dry-Run)}
{--force : Auch bereits gesetzte Booking-Felder überschreiben (Default: nur leere/NULL füllen)}';
protected $description = 'Gleicht programmbezogene Felder im CRM-Booking mit travel_booking.selected_travel ab und füllt fehlende Werte nach.';
/**
* Mapping: booking-Spalte => 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<string, array{old: mixed, new: mixed}>
*/
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 '<null>';
if ($value === '') return '""';
return (string) $value;
}
}