499 lines
18 KiB
PHP
Executable File
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];
|
|
}
|
|
}
|