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:
parent
389d5d1820
commit
e3dc1afd8e
165 changed files with 21914 additions and 3516 deletions
233
app/Console/Commands/ContactsMergeDuplicates.php
Normal file
233
app/Console/Commands/ContactsMergeDuplicates.php
Normal 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++;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue