mein-sterntours/app/Console/Commands/ContactsMergeDuplicates.php
Phase-1-Rollback-Agent e3dc1afd8e 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
2026-04-17 13:40:31 +00:00

233 lines
8.5 KiB
PHP

<?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++;
}
}