phase 2 dev

This commit is contained in:
Kevin Adametz 2026-04-22 16:01:27 +02:00
parent 5a7478907e
commit ba48745809
59 changed files with 2692 additions and 1994 deletions

View file

@ -0,0 +1,156 @@
<?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, '/'));
}
}

View file

@ -37,7 +37,7 @@ class ContactsFindDuplicates extends Command
// ── HIGH: gleiche E-Mail ──────────────────────────────────────────
if ($this->shouldCheck('HIGH')) {
$emailDupes = DB::table('customer')
$emailDupes = DB::table('contacts')
->select('email', DB::raw('COUNT(*) as cnt'), DB::raw('GROUP_CONCAT(id ORDER BY updated_at DESC) as ids'))
->whereNotNull('email')
->where('email', '!=', '')
@ -60,7 +60,7 @@ class ContactsFindDuplicates extends Command
// ── MEDIUM: Name + Vorname + Geburtsdatum ────────────────────────
if ($this->shouldCheck('MEDIUM')) {
$nameBdDupes = DB::table('customer')
$nameBdDupes = DB::table('contacts')
->select(
'name', 'firstname', 'birthdate',
DB::raw('COUNT(*) as cnt'),
@ -88,7 +88,7 @@ class ContactsFindDuplicates extends Command
// ── LOW: Name + Vorname + PLZ ─────────────────────────────────────
if ($this->shouldCheck('LOW')) {
$nameZipDupes = DB::table('customer')
$nameZipDupes = DB::table('contacts')
->select(
'name', 'firstname', 'zip',
DB::raw('COUNT(*) as cnt'),

View file

@ -90,7 +90,7 @@ class ContactsMergeDuplicates extends Command
private function findByEmail(): array
{
return DB::table('customer')
return DB::table('contacts')
->select('email', DB::raw('GROUP_CONCAT(id ORDER BY updated_at DESC, id DESC) as ids'))
->whereNotNull('email')
->where('email', '!=', '')
@ -104,7 +104,7 @@ class ContactsMergeDuplicates extends Command
private function findByNameBirthdate(): array
{
return DB::table('customer')
return DB::table('contacts')
->select(DB::raw('GROUP_CONCAT(id ORDER BY updated_at DESC, id DESC) as ids'))
->whereNotNull('name')
->whereNotNull('firstname')
@ -119,7 +119,7 @@ class ContactsMergeDuplicates extends Command
private function findByNameZip(): array
{
return DB::table('customer')
return DB::table('contacts')
->select(DB::raw('GROUP_CONCAT(id ORDER BY updated_at DESC, id DESC) as ids'))
->whereNotNull('name')
->whereNotNull('firstname')
@ -173,11 +173,11 @@ class ContactsMergeDuplicates extends Command
private function mergeInto(int $masterId, int $dupeId): void
{
// 1. Leads umhängen
$leadCount = DB::table('lead')->where('customer_id', $dupeId)->count();
$leadCount = DB::table('inquiries')->where('customer_id', $dupeId)->count();
if ($leadCount > 0) {
$this->line(" lead.customer_id: {$leadCount} Zeile(n) → #{$masterId}");
if (!$this->dryRun) {
DB::table('lead')
DB::table('inquiries')
->where('customer_id', $dupeId)
->update(['customer_id' => $masterId]);
}
@ -220,7 +220,7 @@ class ContactsMergeDuplicates extends Command
// 5. Duplikat als zusammengeführt markieren
if (!$this->dryRun) {
DB::table('customer')
DB::table('contacts')
->where('id', $dupeId)
->update([
'merged_into_id' => $masterId,

View file

@ -193,7 +193,8 @@ class SyncNewsletterKulturreisen extends Command
'description' => 'Kontakt durch Kulturreisen-Buchung erstellt',
'metadata' => [
'booking_id' => $booking->id,
'lead_id' => $booking->lead_id,
// Metadaten-Key bleibt `lead_id`; Wert kommt aus booking.inquiry_id.
'lead_id' => $booking->inquiry_id,
],
]);
}