mein-sterntours/app/Console/Commands/BookingsAuditStornoPriceTotal.php
2026-04-22 16:01:27 +02:00

156 lines
5.4 KiB
PHP

<?php
namespace App\Console\Commands;
use App\Models\Booking;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
/**
* Findet Buchungen, bei denen nach Storno price_total nicht mehr zu price_canceled passt
* (typisch nach Speichern / calculate_price_total ohne Storno-Schutz).
*
* php artisan bookings:audit-storno-price-total
* php artisan bookings:audit-storno-price-total --export=/tmp/mismatch.csv
* php artisan bookings:audit-storno-price-total --fix (setzt price_total = price_canceled)
*/
class BookingsAuditStornoPriceTotal extends Command
{
protected $signature = 'bookings:audit-storno-price-total
{--fix : Setzt price_total auf price_canceled für betroffene Zeilen}
{--export= : CSV-Datei (relativ zum Projektroot oder absolut)}
{--id=* : Nur diese Buchungs-IDs (booking.id)}';
protected $description = 'Abgleich Storno: price_total vs. price_canceled (inkl. optional --fix / --export)';
private const EPS = 0.009;
public function handle(): int
{
$query = Booking::query()
->whereNotNull('canceled')
->whereNotNull('price_canceled')
->orderBy('id');
$ids = $this->option('id');
if ($ids !== []) {
$query->whereIn('id', $ids);
}
$mismatch = [];
$fixable = [];
foreach ($query->cursor() as $booking) {
$pt = (float) $booking->getPriceTotalRaw();
$pc = (float) $booking->getPriceCanceledRaw();
if (abs($pt - $pc) <= self::EPS) {
continue;
}
$hasStornoRow = $booking->booking_strono()->exists();
$hasStornoDoc = $booking->hasDocument('storno');
$mismatch[] = [
'id' => $booking->id,
'merlin_order_number' => $booking->merlin_order_number,
'price' => $booking->getPriceRaw(),
'price_canceled' => $pc,
'price_total_db' => $pt,
'price_balance' => $booking->getPriceBalanceRaw(),
'canceled_pct' => $booking->getCanceledRaw(),
'booking_storno_row' => $hasStornoRow ? 'ja' : 'nein',
'storno_pdf' => $hasStornoDoc ? 'ja' : 'nein',
'diff' => round($pt - $pc, 2),
];
$fixable[] = $booking->id;
}
$this->warn('Storno mit gesetztem price_canceled, aber price_total weicht ab:');
$this->newLine();
if ($mismatch === []) {
$this->info('Keine Abweichungen gefunden.');
} else {
$this->table(
['id', 'MyJack', 'price', 'price_canceled', 'price_total', 'diff', 'storno_row', 'storno_pdf'],
collect($mismatch)->map(function ($r) {
return [
$r['id'],
$r['merlin_order_number'],
$r['price'],
$r['price_canceled'],
$r['price_total_db'],
$r['diff'],
$r['booking_storno_row'],
$r['storno_pdf'],
];
})->all()
);
$this->newLine();
$this->line('Anzahl: '.count($mismatch));
}
$exportPath = $this->option('export');
if ($exportPath && $mismatch !== []) {
$path = $this->resolveExportPath($exportPath);
$fp = fopen($path, 'w');
if ($fp === false) {
$this->error('Export nicht schreibbar: '.$path);
return self::FAILURE;
}
fputcsv($fp, array_keys($mismatch[0]), ';');
foreach ($mismatch as $row) {
fputcsv($fp, $row, ';');
}
fclose($fp);
$this->info('CSV geschrieben: '.$path);
}
if ($this->option('fix')) {
if ($fixable === []) {
$this->info('Nichts zu korrigieren.');
return self::SUCCESS;
}
if (! $this->confirm('price_total für '.count($fixable).' Buchung(en) auf price_canceled setzen?', true)) {
$this->warn('Abgebrochen.');
return self::SUCCESS;
}
$updated = 0;
DB::transaction(function () use ($fixable, &$updated) {
foreach ($fixable as $id) {
$b = Booking::query()->lockForUpdate()->find($id);
if (! $b) {
continue;
}
$pc = (float) $b->getPriceCanceledRaw();
$pt = (float) $b->getPriceTotalRaw();
if (abs($pt - $pc) <= self::EPS) {
continue;
}
$b->price_total = round($pc, 2);
$b->save();
$updated++;
}
});
$this->info("Korrigiert: {$updated} Buchung(en).");
}
$this->newLine();
$this->line('Hinweis: Buchungen mit canceled gesetzt, aber price_canceled NULL, werden hier nicht geprüft.');
return self::SUCCESS;
}
private function resolveExportPath(string $exportPath): string
{
if ($exportPath[0] === '/' || preg_match('#^[A-Za-z]:\\\\#', $exportPath)) {
return $exportPath;
}
return base_path(trim($exportPath, '/'));
}
}