WIP: Sicherheitsnetz vor Phase-1-R\u00fcckbau

Enth\u00e4lt gemischt: Laravel-10-Upgrade + Phase 1 (Contacts-Modul, Duplicats-Commands,
Soft-Delete+Merge-Fields) + Phase 2 Code-Umstellungen (inquiry_id, $table='contacts'/'inquiries')
+ Offers-Modul (Migrationen, Models, offer_id in Booking, offer-Disk in filesystems.php).

Phase 2 + Offers werden im folgenden Commit nach dev/backups/phase2-offers-2026-04-17/
verschoben, damit der Workspace auf Phase-1-only (= Test-System-Stand) reduziert ist
und direkt auf Live deploybar wird.

Tarball-Backup zus\u00e4tzlich unter: ../backups-safety/workspace-pre-phase1-rollback-2026-04-17.tar.gz

Made-with: Cursor
This commit is contained in:
Phase-1-Rollback-Agent 2026-04-17 13:40:31 +00:00
parent 389d5d1820
commit e3dc1afd8e
165 changed files with 21914 additions and 3516 deletions

View file

@ -0,0 +1,160 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
/**
* Phase 1 Schritt 1: Duplikate identifizieren
*
* Sucht Kunden-Datensätze, die vermutlich dieselbe Person repräsentieren.
* Drei Erkennungs-Stufen (absteigend nach Konfidenz):
*
* HIGH gleiche E-Mail (nicht leer)
* MEDIUM gleicher Name + Vorname + Geburtsdatum
* LOW gleicher Name + Vorname + PLZ
*
* Verwendung:
* php artisan contacts:find-duplicates
* php artisan contacts:find-duplicates --export=duplicates.csv
* php artisan contacts:find-duplicates --confidence=HIGH
*/
class ContactsFindDuplicates extends Command
{
protected $signature = 'contacts:find-duplicates
{--export= : Pfad zur CSV-Ausgabedatei}
{--confidence= : Nur diese Konfidenz-Stufe ausgeben (HIGH|MEDIUM|LOW)}';
protected $description = 'Identifiziert doppelte Customer-Datensätze anhand von E-Mail, Name/Geburtsdatum oder Name/PLZ';
public function handle(): int
{
$this->info('Suche nach Duplikaten in der customer-Tabelle...');
$this->newLine();
$groups = collect();
// ── HIGH: gleiche E-Mail ──────────────────────────────────────────
if ($this->shouldCheck('HIGH')) {
$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', '!=', '')
->whereNull('merged_into_id')
->groupBy('email')
->having('cnt', '>', 1)
->get();
foreach ($emailDupes as $row) {
$groups->push([
'confidence' => 'HIGH',
'reason' => 'E-Mail: ' . $row->email,
'ids' => $row->ids,
'count' => $row->cnt,
]);
}
$this->line(sprintf('<fg=green>HIGH</> (gleiche E-Mail): %d Gruppen', $emailDupes->count()));
}
// ── MEDIUM: Name + Vorname + Geburtsdatum ────────────────────────
if ($this->shouldCheck('MEDIUM')) {
$nameBdDupes = DB::table('contacts')
->select(
'name', 'firstname', 'birthdate',
DB::raw('COUNT(*) as cnt'),
DB::raw('GROUP_CONCAT(id ORDER BY updated_at DESC) as ids')
)
->whereNotNull('name')
->whereNotNull('firstname')
->whereNotNull('birthdate')
->whereNull('merged_into_id')
->groupBy('name', 'firstname', 'birthdate')
->having('cnt', '>', 1)
->get();
foreach ($nameBdDupes as $row) {
$groups->push([
'confidence' => 'MEDIUM',
'reason' => "Name: {$row->firstname} {$row->name}, GD: {$row->birthdate}",
'ids' => $row->ids,
'count' => $row->cnt,
]);
}
$this->line(sprintf('<fg=yellow>MEDIUM</> (Name+GD): %d Gruppen', $nameBdDupes->count()));
}
// ── LOW: Name + Vorname + PLZ ─────────────────────────────────────
if ($this->shouldCheck('LOW')) {
$nameZipDupes = DB::table('contacts')
->select(
'name', 'firstname', 'zip',
DB::raw('COUNT(*) as cnt'),
DB::raw('GROUP_CONCAT(id ORDER BY updated_at DESC) as ids')
)
->whereNotNull('name')
->whereNotNull('firstname')
->whereNotNull('zip')
->where('zip', '!=', '')
->whereNull('merged_into_id')
->groupBy('name', 'firstname', 'zip')
->having('cnt', '>', 1)
->get();
foreach ($nameZipDupes as $row) {
$groups->push([
'confidence' => 'LOW',
'reason' => "Name: {$row->firstname} {$row->name}, PLZ: {$row->zip}",
'ids' => $row->ids,
'count' => $row->cnt,
]);
}
$this->line(sprintf('<fg=red>LOW</> (Name+PLZ): %d Gruppen', $nameZipDupes->count()));
}
$this->newLine();
$this->info(sprintf('Gesamt: %d Duplikat-Gruppen gefunden', $groups->count()));
if ($groups->isEmpty()) {
$this->info('Keine Duplikate — nichts zu tun.');
return self::SUCCESS;
}
// Tabellen-Ausgabe
$this->table(
['Konfidenz', 'Grund', 'IDs (neueste zuerst)', 'Anzahl'],
$groups->map(fn ($g) => [$g['confidence'], $g['reason'], $g['ids'], $g['count']])->all()
);
// CSV-Export
if ($export = $this->option('export')) {
$this->exportCsv($groups->all(), $export);
$this->info("CSV gespeichert: {$export}");
} else {
$this->newLine();
$this->line('Tipp: --export=duplicates.csv für CSV-Export');
$this->line('Tipp: php artisan contacts:merge-duplicates --dry-run zum Prüfen');
}
return self::SUCCESS;
}
private function shouldCheck(string $level): bool
{
$filter = strtoupper((string) $this->option('confidence'));
return $filter === '' || $filter === $level;
}
private function exportCsv(array $groups, string $path): void
{
$handle = fopen($path, 'w');
fputcsv($handle, ['Konfidenz', 'Grund', 'IDs (neueste zuerst)', 'Anzahl']);
foreach ($groups as $group) {
fputcsv($handle, [$group['confidence'], $group['reason'], $group['ids'], $group['count']]);
}
fclose($handle);
}
}

View file

@ -0,0 +1,233 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
/**
* Phase 1 Schritt 2: Duplikate zusammenführen
*
* Strategie: Der neueste Datensatz (höchstes updated_at, dann höchste id)
* wird Master. Alle anderen Datensätze derselben Gruppe erhalten
* merged_into_id = master_id und werden nicht mehr zurückgegeben.
*
* Alle FK-Referenzen in lead, booking, customer_mails, lead_mails
* werden auf den Master umgestellt.
*
* Verwendung:
* php artisan contacts:merge-duplicates --dry-run # Vorschau, keine Änderung
* php artisan contacts:merge-duplicates --confidence=HIGH # Nur sichere Duplikate
* php artisan contacts:merge-duplicates # Ausführen
*/
class ContactsMergeDuplicates extends Command
{
protected $signature = 'contacts:merge-duplicates
{--dry-run : Zeigt was passieren würde, ohne Daten zu ändern}
{--confidence= : Nur diese Konfidenz-Stufe verarbeiten (HIGH|MEDIUM|LOW)}
{--force : Überspringt Sicherheitsabfrage}';
protected $description = 'Führt doppelte Customer-Datensätze zusammen (neuester wird Master)';
private bool $dryRun = false;
private int $mergedCount = 0;
private int $updatedLeads = 0;
private int $updatedBookings = 0;
public function handle(): int
{
$this->dryRun = (bool) $this->option('dry-run');
if ($this->dryRun) {
$this->warn('DRY-RUN Modus — keine Daten werden verändert');
} else {
$this->warn('ACHTUNG: Diese Operation verändert Produktionsdaten.');
if (!$this->option('force') && !$this->confirm('Fortfahren?')) {
$this->info('Abgebrochen.');
return self::SUCCESS;
}
}
$this->newLine();
DB::transaction(function () {
$this->processLevel(
'HIGH',
fn () => $this->findByEmail()
);
$this->processLevel(
'MEDIUM',
fn () => $this->findByNameBirthdate()
);
$this->processLevel(
'LOW',
fn () => $this->findByNameZip()
);
});
$this->newLine();
$this->info(sprintf(
'%s %d Duplikate zusammengeführt | %d leads aktualisiert | %d bookings aktualisiert',
$this->dryRun ? '[DRY-RUN]' : '',
$this->mergedCount,
$this->updatedLeads,
$this->updatedBookings
));
if ($this->dryRun) {
$this->newLine();
$this->line('Zum Ausführen: php artisan contacts:merge-duplicates');
}
return self::SUCCESS;
}
// ─────────────────────────────────────────────────────────────────────────
// Duplikat-Gruppen ermitteln
// ─────────────────────────────────────────────────────────────────────────
private function findByEmail(): array
{
return DB::table('contacts')
->select('email', DB::raw('GROUP_CONCAT(id ORDER BY updated_at DESC, id DESC) as ids'))
->whereNotNull('email')
->where('email', '!=', '')
->whereNull('merged_into_id')
->groupBy('email')
->having(DB::raw('COUNT(*)'), '>', 1)
->pluck('ids')
->map(fn ($ids) => explode(',', $ids))
->all();
}
private function findByNameBirthdate(): array
{
return DB::table('contacts')
->select(DB::raw('GROUP_CONCAT(id ORDER BY updated_at DESC, id DESC) as ids'))
->whereNotNull('name')
->whereNotNull('firstname')
->whereNotNull('birthdate')
->whereNull('merged_into_id')
->groupBy('name', 'firstname', 'birthdate')
->having(DB::raw('COUNT(*)'), '>', 1)
->pluck('ids')
->map(fn ($ids) => explode(',', $ids))
->all();
}
private function findByNameZip(): array
{
return DB::table('contacts')
->select(DB::raw('GROUP_CONCAT(id ORDER BY updated_at DESC, id DESC) as ids'))
->whereNotNull('name')
->whereNotNull('firstname')
->whereNotNull('zip')
->where('zip', '!=', '')
->whereNull('merged_into_id')
->groupBy('name', 'firstname', 'zip')
->having(DB::raw('COUNT(*)'), '>', 1)
->pluck('ids')
->map(fn ($ids) => explode(',', $ids))
->all();
}
// ─────────────────────────────────────────────────────────────────────────
// Verarbeitung
// ─────────────────────────────────────────────────────────────────────────
private function processLevel(string $level, callable $finder): void
{
if ($filter = $this->option('confidence')) {
if (strtoupper($filter) !== $level) {
return;
}
}
$groups = $finder();
if (empty($groups)) {
$this->line("[{$level}] Keine Duplikate.");
return;
}
$this->line(sprintf('[%s] %d Gruppe(n) gefunden', $level, count($groups)));
foreach ($groups as $ids) {
$masterId = (int) $ids[0]; // erster = neuester
$duplicateIds = array_map('intval', array_slice($ids, 1));
$this->line(sprintf(
' Master: #%d ← Duplikate: %s',
$masterId,
implode(', ', array_map(fn ($id) => '#' . $id, $duplicateIds))
));
foreach ($duplicateIds as $dupeId) {
$this->mergeInto($masterId, $dupeId);
}
}
}
private function mergeInto(int $masterId, int $dupeId): void
{
// 1. Leads umhängen
$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('inquiries')
->where('customer_id', $dupeId)
->update(['customer_id' => $masterId]);
}
$this->updatedLeads += $leadCount;
}
// 2. Bookings umhängen
$bookingCount = DB::table('booking')->where('customer_id', $dupeId)->count();
if ($bookingCount > 0) {
$this->line(" booking.customer_id: {$bookingCount} Zeile(n) → #{$masterId}");
if (!$this->dryRun) {
DB::table('booking')
->where('customer_id', $dupeId)
->update(['customer_id' => $masterId]);
}
$this->updatedBookings += $bookingCount;
}
// 3. customer_mails umhängen
$mailCount = DB::table('customer_mails')->where('customer_id', $dupeId)->count();
if ($mailCount > 0) {
$this->line(" customer_mails.customer_id: {$mailCount} Zeile(n) → #{$masterId}");
if (!$this->dryRun) {
DB::table('customer_mails')
->where('customer_id', $dupeId)
->update(['customer_id' => $masterId]);
}
}
// 4. lead_mails umhängen
$leadMailCount = DB::table('lead_mails')->where('customer_id', $dupeId)->count();
if ($leadMailCount > 0) {
$this->line(" lead_mails.customer_id: {$leadMailCount} Zeile(n) → #{$masterId}");
if (!$this->dryRun) {
DB::table('lead_mails')
->where('customer_id', $dupeId)
->update(['customer_id' => $masterId]);
}
}
// 5. Duplikat als zusammengeführt markieren
if (!$this->dryRun) {
DB::table('contacts')
->where('id', $dupeId)
->update([
'merged_into_id' => $masterId,
'merged_at' => now(),
]);
}
$this->mergedCount++;
}
}

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

View file

@ -22,10 +22,14 @@ class Kernel extends ConsoleKernel
* @param \Illuminate\Console\Scheduling\Schedule $schedule
* @return void
*/
protected function schedule(Schedule $schedule)
protected function schedule(Schedule $schedule): void
{
// $schedule->command('inspire')
// ->hourly();
// Duplikate täglich um 02:00 Uhr automatisch zusammenführen (nur HIGH-Konfidenz = exakte E-Mail-Treffer)
$schedule->command('contacts:merge-duplicates --confidence=HIGH --force')
->dailyAt('02:00')
->withoutOverlapping()
->runInBackground()
->appendOutputTo(storage_path('logs/contacts-merge.log'));
}
/**
@ -35,7 +39,7 @@ class Kernel extends ConsoleKernel
*/
protected function commands()
{
$this->load(__DIR__.'/Commands');
$this->load(__DIR__ . '/Commands');
require base_path('routes/console.php');
}