option('all-admin'); $adminId = (int) ($this->argument('amministratore_id') ?: 0); if (! $allAdmin && $adminId <= 0) { $this->error('amministratore_id non valido (oppure usa --all-admin)'); return self::FAILURE; } $dry = (bool) $this->option('dry-run'); $deleteEmpties = (bool) $this->option('delete-empties'); $includeNullAdmin = (bool) $this->option('include-null-admin'); if (! Schema::hasTable('rubrica_universale')) { $this->error('Tabella rubrica_universale non disponibile.'); return self::FAILURE; } $stats = [ 'groups' => 0, 'duplicates' => 0, 'merged' => 0, 'rubrica_soft_deleted' => 0, 'rubrica_empties_soft_deleted' => 0, 'moved_ruoli' => 0, 'moved_canali' => 0, 'moved_fornitori' => 0, 'moved_stabili' => 0, 'moved_links_generic' => 0, ]; $runner = function () use ($adminId, $allAdmin, $includeNullAdmin, $dry, $deleteEmpties, &$stats): void { // Carica i contatti target (escludi già soft-deleted per evitare merge strani). $rubricheQuery = RubricaUniversale::query()->whereNull('deleted_at'); if (! $allAdmin) { $rubricheQuery->where(function ($q) use ($adminId, $includeNullAdmin): void { $q->where('amministratore_id', $adminId); if ($includeNullAdmin) { $q->orWhereNull('amministratore_id'); } }); } $rubriche = $rubricheQuery ->get([ 'id', 'codice_univoco', 'amministratore_id', 'tipo_contatto', 'ragione_sociale', 'nome', 'cognome', 'codice_fiscale', 'partita_iva', 'email', 'pec', 'telefono_ufficio', 'telefono_cellulare', 'telefono_casa', 'indirizzo', 'cap', 'citta', 'provincia', 'note', 'categoria', 'stato', ]); /** @var array> $groups */ $groups = []; foreach ($rubriche as $r) { $keys = $this->buildIdentityKeys($r); if (empty($keys)) { continue; } // Usa una chiave primaria stabile (preferisci fiscale), altrimenti email/telefono. $primary = $keys[0]; $groups[$primary] ??= []; $groups[$primary][] = $r; } foreach ($groups as $key => $items) { if (count($items) < 2) { continue; } $stats['groups']++; $stats['duplicates'] += (count($items) - 1); $master = $this->pickMaster($items); $others = array_values(array_filter($items, fn($x) => (int) $x->id !== (int) $master->id)); foreach ($others as $dup) { if ($dry) { $stats['merged']++; continue; } // 1) Merge campi (non sovrascrivere i valori del master, riempi solo i null/vuoti) $payload = $this->mergeRubricaPayload($master, $dup); if (! empty($payload)) { $master->fill($payload); if ($master->isDirty()) { $master->save(); } } // 2) Sposta relazioni verso master if (Schema::hasTable('rubrica_ruoli')) { $stats['moved_ruoli'] += RubricaRuolo::query() ->where('rubrica_id', (int) $dup->id) ->update(['rubrica_id' => (int) $master->id]); } if (Schema::hasTable('rubrica_contatto_canali')) { $stats['moved_canali'] += RubricaContattoCanale::query() ->where('rubrica_id', (int) $dup->id) ->update(['rubrica_id' => (int) $master->id]); } if (Schema::hasTable('fornitori')) { $fornitoriMove = Fornitore::query()->where('rubrica_id', (int) $dup->id); if (! $allAdmin && $adminId > 0) { $fornitoriMove->where(function ($q) use ($adminId, $includeNullAdmin): void { $q->where('amministratore_id', $adminId); if ($includeNullAdmin) { $q->orWhereNull('amministratore_id'); } }); } $stats['moved_fornitori'] += $fornitoriMove->update(['rubrica_id' => (int) $master->id]); } if (Schema::hasTable('stabili') && Schema::hasColumn('stabili', 'rubrica_id')) { $stabiliMove = Stabile::query()->where('rubrica_id', (int) $dup->id); if (! $allAdmin && $adminId > 0) { $stabiliMove->where('amministratore_id', $adminId); } $stats['moved_stabili'] += $stabiliMove->update(['rubrica_id' => (int) $master->id]); } $stats['moved_links_generic'] += $this->moveGenericRubricaLinks( (int) $dup->id, (int) $master->id, $adminId, $allAdmin, $includeNullAdmin, ); // 3) Soft-delete duplicato $note = trim((string) ($dup->note ?? '')); $suffix = 'Merged into rubrica_id=' . (int) $master->id; if ($note === '') { $dup->note = $suffix; } elseif (! str_contains($note, $suffix)) { $dup->note = mb_substr($note . "\n" . $suffix, 0, 65535); } $dup->save(); $dup->delete(); $stats['rubrica_soft_deleted']++; $stats['merged']++; } } if (! $deleteEmpties) { return; } // Soft-delete contatti completamente vuoti e non collegati a nulla. $emptiesQ = RubricaUniversale::query()->whereNull('deleted_at'); if (! $allAdmin) { $emptiesQ->where(function ($q) use ($adminId, $includeNullAdmin): void { $q->where('amministratore_id', $adminId); if ($includeNullAdmin) { $q->orWhereNull('amministratore_id'); } }); } $empties = $emptiesQ ->get(['id', 'ragione_sociale', 'nome', 'cognome', 'codice_fiscale', 'partita_iva', 'email', 'pec', 'telefono_ufficio', 'telefono_cellulare', 'telefono_casa']); foreach ($empties as $r) { if (! $this->isRubricaEmptyIdentity($r)) { continue; } $hasRuoli = Schema::hasTable('rubrica_ruoli') ? RubricaRuolo::query()->where('rubrica_id', (int) $r->id)->exists() : false; $hasCanali = Schema::hasTable('rubrica_contatto_canali') ? RubricaContattoCanale::query()->where('rubrica_id', (int) $r->id)->exists() : false; $hasFornitori = Schema::hasTable('fornitori') ? Fornitore::query()->where('rubrica_id', (int) $r->id)->exists() : false; $hasStabili = (Schema::hasTable('stabili') && Schema::hasColumn('stabili', 'rubrica_id')) ? Stabile::query()->where('rubrica_id', (int) $r->id)->exists() : false; if ($hasRuoli || $hasCanali || $hasFornitori || $hasStabili) { continue; } $stats['rubrica_empties_soft_deleted']++; if (! $dry) { $r->note = trim(((string) ($r->note ?? '')) . "\n" . 'Auto-clean: empty identity'); $r->save(); $r->delete(); } } }; if ($dry) { $runner(); } else { DB::transaction($runner); } $this->info('✅ Rubrica dedup completata' . ($dry ? ' (dry-run)' : '')); $this->line(json_encode($stats, JSON_UNESCAPED_UNICODE)); return self::SUCCESS; } private function moveGenericRubricaLinks( int $fromRubricaId, int $toRubricaId, int $adminId, bool $allAdmin, bool $includeNullAdmin, ): int { if ($fromRubricaId <= 0 || $toRubricaId <= 0 || $fromRubricaId === $toRubricaId) { return 0; } $db = DB::getDatabaseName(); $rows = DB::select( "SELECT table_name, column_name FROM information_schema.columns WHERE table_schema = ? AND column_name IN ('rubrica_id', 'rubrica_universale_id') AND table_name <> 'rubrica_universale'", [$db] ); $moved = 0; foreach ($rows as $row) { $table = (string) ($row->TABLE_NAME ?? ''); $column = (string) ($row->COLUMN_NAME ?? ''); if ($table === '' || $column === '' || ! Schema::hasTable($table) || ! Schema::hasColumn($table, $column)) { continue; } $query = DB::table($table)->where($column, $fromRubricaId); if (! $allAdmin && $adminId > 0 && Schema::hasColumn($table, 'amministratore_id')) { $query->where(function ($q) use ($adminId, $includeNullAdmin): void { $q->where('amministratore_id', $adminId); if ($includeNullAdmin) { $q->orWhereNull('amministratore_id'); } }); } $moved += (int) $query->update([$column => $toRubricaId]); } return $moved; } /** @return list */ private function buildIdentityKeys(RubricaUniversale $r): array { $keys = []; $cf = $this->normalizeCf($r->codice_fiscale); $piva = $this->normalizeCf($r->partita_iva); $fiscal = $this->normalizeFiscalIds($cf ?: $piva); if ($fiscal['cf']) { $keys[] = 'CF:' . $fiscal['cf']; } if ($fiscal['piva']) { $keys[] = 'PIVA:' . $fiscal['piva']; } $email = $this->normalizeEmail($r->email); if ($email) { $keys[] = 'EMAIL:' . $email; } $phones = array_values(array_unique(array_filter([ $this->normalizePhone($r->telefono_ufficio), $this->normalizePhone($r->telefono_cellulare), $this->normalizePhone($r->telefono_casa), ]))); foreach ($phones as $p) { $keys[] = 'TEL:' . $p; } // Ordina: fiscale > email > telefono usort($keys, function (string $a, string $b): int { $wa = str_starts_with($a, 'CF:') || str_starts_with($a, 'PIVA:') ? 0 : (str_starts_with($a, 'EMAIL:') ? 1 : 2); $wb = str_starts_with($b, 'CF:') || str_starts_with($b, 'PIVA:') ? 0 : (str_starts_with($b, 'EMAIL:') ? 1 : 2); return $wa <=> $wb; }); return $keys; } /** @param array $items */ private function pickMaster(array $items): RubricaUniversale { $best = null; $bestScore = -1; foreach ($items as $r) { $score = 0; foreach ( [ 'ragione_sociale', 'nome', 'cognome', 'codice_fiscale', 'partita_iva', 'email', 'pec', 'telefono_ufficio', 'telefono_cellulare', 'telefono_casa', 'indirizzo', 'cap', 'citta', 'provincia', ] as $f ) { $v = trim((string) ($r->{$f} ?? '')); if ($v !== '') { $score += 2; } } $note = trim((string) ($r->note ?? '')); if ($note !== '') { $score += 1; } // Preferisci record che hanno già codice_univoco (stabile) e min id per stabilità. $code = trim((string) ($r->codice_univoco ?? '')); if ($code !== '') { $score += 1; } if ($best === null || $score > $bestScore || ($score === $bestScore && (int) $r->id < (int) $best->id)) { $best = $r; $bestScore = $score; } } return $best ?? $items[0]; } private function mergeRubricaPayload(RubricaUniversale $master, RubricaUniversale $dup): array { $fields = [ 'tipo_contatto', 'ragione_sociale', 'nome', 'cognome', 'codice_fiscale', 'partita_iva', 'email', 'pec', 'telefono_ufficio', 'telefono_cellulare', 'telefono_casa', 'indirizzo', 'cap', 'citta', 'provincia', 'categoria', 'stato', ]; $payload = []; foreach ($fields as $f) { $cur = trim((string) ($master->{$f} ?? '')); $val = trim((string) ($dup->{$f} ?? '')); if ($cur === '' && $val !== '') { $payload[$f] = $dup->{$f}; } } // Note: concatena solo se il master è vuoto. $mNote = trim((string) ($master->note ?? '')); $dNote = trim((string) ($dup->note ?? '')); if ($mNote === '' && $dNote !== '') { $payload['note'] = $dup->note; } return $payload; } private function isRubricaEmptyIdentity(RubricaUniversale $r): bool { foreach (['ragione_sociale', 'nome', 'cognome', 'codice_fiscale', 'partita_iva', 'email', 'pec', 'telefono_ufficio', 'telefono_cellulare', 'telefono_casa'] as $f) { $v = trim((string) ($r->{$f} ?? '')); if ($v !== '') { return false; } } return true; } private function normalizeCf(?string $cf): ?string { $cf = strtoupper(trim((string) ($cf ?? ''))); $cf = preg_replace('/\s+/', '', $cf); return $cf !== '' ? $cf : null; } private function normalizeEmail(?string $email): ?string { $email = strtolower(trim((string) ($email ?? ''))); return $email !== '' ? $email : null; } private function normalizePhone(?string $phone): ?string { $phone = trim((string) ($phone ?? '')); if ($phone === '') { return null; } $digits = preg_replace('/[^0-9+]/', '', $phone); $digits = trim((string) $digits); return $digits !== '' ? $digits : null; } /** @return array{cf:?string,piva:?string} */ private function normalizeFiscalIds(?string $raw): array { $raw = trim((string) ($raw ?? '')); if ($raw === '') { return ['cf' => null, 'piva' => null]; } $norm = $this->normalizeCf($raw); if (! $norm) { return ['cf' => null, 'piva' => null]; } // Valori placeholder/import legacy non devono guidare dedup automatico. if (in_array($norm, ['0', '00', '000', '0000', 'ND', 'N/D', 'NULL'], true)) { return ['cf' => null, 'piva' => null]; } if (preg_match('/^0+$/', $norm) === 1) { return ['cf' => null, 'piva' => null]; } if (preg_match('/^\d+$/', $norm)) { if (strlen($norm) < 11) { $norm = str_pad($norm, 11, '0', STR_PAD_LEFT); } if (strlen($norm) === 11) { return ['cf' => $norm, 'piva' => $norm]; } } return ['cf' => $norm, 'piva' => null]; } }