netgescon-day0/app/Console/Commands/GesconRubricaDedupCommand.php

499 lines
18 KiB
PHP
Executable File

<?php
namespace App\Console\Commands;
use App\Models\Fornitore;
use App\Models\RubricaContattoCanale;
use App\Models\RubricaRuolo;
use App\Models\RubricaUniversale;
use App\Models\Stabile;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
class GesconRubricaDedupCommand extends Command
{
protected $signature = 'gescon:rubrica-dedup
{amministratore_id? : ID amministratore (opzionale se --all-admin)}
{--all-admin : Elabora tutte le anagrafiche attive}
{--include-null-admin : Include anche rubriche con amministratore_id NULL}
{--dry-run : Non scrive, mostra solo conteggi}
{--delete-empties : Soft-delete dei contatti vuoti e orfani}';
protected $description = 'Deduplica Rubrica Universale per amministratore (merge su CF/P.IVA/email/telefono) e pulisce contatti vuoti orfani.';
public function handle(): int
{
$allAdmin = (bool) $this->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<string, array<int, RubricaUniversale>> $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<string> */
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<int, RubricaUniversale> $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];
}
}