option('apply'); $deactivateUnresolved = (bool) $this->option('deactivate-unresolved'); $includeInactive = (bool) $this->option('include-inactive'); $fixInquilinoOverwritten = (bool) $this->option('fix-inquilino-overwritten'); $limit = (int) $this->option('limit'); if ($limit < 1) $limit = 1; if ($limit > 20000) $limit = 20000; $stabileId = $this->option('stabile-id'); $stabileId = $stabileId !== null && $stabileId !== '' ? (int) $stabileId : null; if (!Schema::hasTable('rubrica_ruoli') || !Schema::hasTable('rubrica_universale')) { $this->error('Tabelle rubrica_ruoli/rubrica_universale non disponibili.'); return self::FAILURE; } $conn = Config::get('database.connections.gescon_import') ? 'gescon_import' : DB::getDefaultConnection(); if (!Schema::connection($conn)->hasTable('condomin')) { $this->error("Tabella {$conn}.condomin non disponibile."); return self::FAILURE; } $stats = [ 'roles_scanned' => 0, 'roles_with_empty_contact' => 0, 'contacts_fixed' => 0, 'roles_deactivated' => 0, 'skipped_missing_meta' => 0, 'skipped_no_staging_match' => 0, ]; /** @var array $stabileMap */ $stabileMap = []; if ($stabileId !== null) { $s = Stabile::query()->find($stabileId); if (!$s) { $this->error('stabile-id non trovato.'); return self::FAILURE; } $stabileMap[$stabileId] = ['amministratore_id' => (int) ($s->amministratore_id ?? 0)]; } $q = RubricaRuolo::query() ->with(['contatto']) ->orderBy('id') ->when(!$includeInactive, fn($qq) => $qq->where('is_attivo', true)); if ($stabileId !== null) { $q->where('stabile_id', $stabileId); } $q->limit($limit)->chunkById(200, function ($chunk) use (&$stats, $apply, $deactivateUnresolved, $conn, $stabileMap, $fixInquilinoOverwritten) { foreach ($chunk as $ruolo) { $stats['roles_scanned']++; /** @var RubricaUniversale|null $rubrica */ $rubrica = $ruolo->contatto; if (!$rubrica) { continue; } $role = strtolower(trim((string) ($ruolo->ruolo_standard ?? ''))); $isEmpty = $this->isRubricaEmptyIdentity($rubrica); $needsFix = $isEmpty; if (!$needsFix && $fixInquilinoOverwritten && $role === 'inquilino') { $needsFix = $this->inquilinoLooksOverwrittenFromOwner($ruolo, $rubrica, $conn); } if (!$needsFix) { continue; } if ($isEmpty) { $stats['roles_with_empty_contact']++; } $codStabile = trim((string) data_get($ruolo->meta, 'cod_stabile', '')); $codCond = trim((string) data_get($ruolo->meta, 'cod_cond', '')); if ($codStabile === '' || $codCond === '') { $stats['skipped_missing_meta']++; if ($deactivateUnresolved && $apply) { $this->deactivateRole($ruolo, 'missing meta.cod_stabile/cod_cond'); $stats['roles_deactivated']++; } continue; } $codStabileCandidates = $this->buildCodeCandidates($codStabile, 4); $codCondCandidates = $this->buildCodeCandidates($codCond, null); // Preferenza lookup: per inquilino usare anche scala/interno (cod_cond può essere quello del proprietario). $scala = trim((string) data_get($ruolo->meta, 'scala', '')); $interno = trim((string) data_get($ruolo->meta, 'interno', '')); $base = DB::connection($conn)->table('condomin') ->whereIn('cod_stabile', $codStabileCandidates); if ($role === 'inquilino' && $scala !== '' && $interno !== '') { $base->where('scala', $scala)->where('interno', $interno); } else { $base->whereIn('cod_cond', $codCondCandidates); } $row = null; // 1) Prova con filtro proprietario/inquilino (se presente) $stagingQuery = clone $base; if (Schema::connection($conn)->hasColumn('condomin', 'inquilino') && $role === 'inquilino') { $stagingQuery->where('inquilino', 1); } if (Schema::connection($conn)->hasColumn('condomin', 'proprietario') && $role === 'condomino') { $stagingQuery->where('proprietario', 1); } $row = $stagingQuery->orderByDesc('id')->first(); // 2) Fallback: senza filtro ruolo (alcuni archivi hanno flag incoerenti) if (!$row) { $row = $base->orderByDesc('id')->first(); } if (!$row) { $stats['skipped_no_staging_match']++; if ($deactivateUnresolved && $apply) { $this->deactivateRole($ruolo, 'no staging condomin match'); $stats['roles_deactivated']++; } continue; } $payload = $this->buildRubricaPayloadFromCondominRow($row, $role, $ruolo->stabile_id, $stabileMap); if (empty($payload)) { $stats['skipped_no_staging_match']++; if ($deactivateUnresolved && $apply) { $this->deactivateRole($ruolo, 'staging row has no identity'); $stats['roles_deactivated']++; } continue; } if (!$apply) { $stats['contacts_fixed']++; continue; } $rubrica->fill($payload); if ($rubrica->isDirty()) { $rubrica->data_ultima_modifica = now()->toDateString(); $rubrica->save(); } $stats['contacts_fixed']++; } }); $this->info('✅ Bonifica nominativi completata' . ($apply ? '' : ' (dry-run)')); $this->line(json_encode($stats, JSON_UNESCAPED_UNICODE)); return self::SUCCESS; } 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 inquilinoLooksOverwrittenFromOwner(RubricaRuolo $ruolo, RubricaUniversale $rubrica, string $conn): bool { $codStabile = trim((string) data_get($ruolo->meta, 'cod_stabile', '')); $scala = trim((string) data_get($ruolo->meta, 'scala', '')); $interno = trim((string) data_get($ruolo->meta, 'interno', '')); $legacyDir = trim((string) data_get($ruolo->meta, 'legacy_anno_dir', '')); if ($codStabile === '' || $scala === '' || $interno === '') { return false; } $codStabileCandidates = $this->buildCodeCandidates($codStabile, 4); if (empty($codStabileCandidates)) { return false; } $internoCandidates = array_values(array_unique(array_merge( $this->buildCodeCandidates($interno, null), $this->buildCodeCandidates($interno, 2), $this->buildCodeCandidates($interno, 3) ))); if (empty($internoCandidates)) { return false; } $base = DB::connection($conn)->table('condomin') ->whereIn('cod_stabile', $codStabileCandidates) ->where('scala', $scala) ->whereIn('interno', $internoCandidates); $row = null; if ($legacyDir !== '' && Schema::connection($conn)->hasColumn('condomin', 'legacy_year')) { $row = (clone $base) ->where('legacy_year', $legacyDir) ->orderByDesc('id') ->first([ 'nom_cond', 'nome', 'cognome', 'cond_cod_fisc', 'inquilino_denominazione', 'inquil_nome', 'inquil_cod_fisc', ]); } if (!$row) { $row = $base ->orderByDesc('id') ->first([ 'nom_cond', 'nome', 'cognome', 'cond_cod_fisc', 'inquilino_denominazione', 'inquil_nome', 'inquil_cod_fisc', ]); } if (!$row) { return false; } $ownerName = $this->normalizeIdentityStr(($row->nom_cond ?? null) ?: trim((string) ($row->nome ?? '') . ' ' . (string) ($row->cognome ?? ''))); $tenantNameRaw = $this->firstNonEmptyStr([ $row->inquilino_denominazione ?? null, $row->inquil_nome ?? null, ]); $tenantName = $this->normalizeIdentityStr($tenantNameRaw); $ownerCf = $this->normalizeCf($row->cond_cod_fisc ?? null); $tenantCf = $this->normalizeCf($row->inquil_cod_fisc ?? null); // Se non esiste tenant, non ha senso parlare di "overwritten". if ($tenantName === '' && $tenantCf === '') { return false; } // Se tenant coincide col proprietario, non forzare. if ($this->identityMatches($tenantName, $tenantCf, $ownerName, $ownerCf)) { return false; } $rubricaName = $this->normalizeIdentityStr(($rubrica->ragione_sociale ?? '') ?: trim((string) ($rubrica->nome ?? '') . ' ' . (string) ($rubrica->cognome ?? ''))); $rubricaCf = $this->normalizeCf($rubrica->codice_fiscale ?? null); // Se il contatto "inquilino" matcha il proprietario legacy, è molto probabile che sia stato sovrascritto. return $this->identityMatches($rubricaName, $rubricaCf, $ownerName, $ownerCf); } private function normalizeCf($cf): string { $cf = strtoupper(preg_replace('/\s+/', '', (string) ($cf ?? ''))); $cf = preg_replace('/[^A-Z0-9]/', '', $cf); return $cf ?: ''; } private function normalizeIdentityStr($s): string { $s = strtoupper((string) ($s ?? '')); $s = str_replace(['.', ',', ';', ':', '-', '/', '\\', "\t", "\n", "\r"], ' ', $s); $s = preg_replace('/\s+/', ' ', $s); return trim($s); } private function identityMatches(string $nameA, string $cfA, string $nameB, string $cfB): bool { if ($cfA !== '' && $cfB !== '') { return $cfA === $cfB; } if ($nameA === '' || $nameB === '') { return false; } if ($nameA === $nameB) { return true; } $tokensA = array_values(array_filter(array_unique(preg_split('/\s+/', $nameA) ?: []), fn($t) => $t !== '')); $tokensB = array_values(array_filter(array_unique(preg_split('/\s+/', $nameB) ?: []), fn($t) => $t !== '')); if (count($tokensA) === 0 || count($tokensB) === 0) { return false; } $common = array_values(array_intersect($tokensA, $tokensB)); $min = min(count($tokensA), count($tokensB)); $ratio = $min > 0 ? (count($common) / $min) : 0.0; if ($min >= 2 && $ratio >= 0.8) { return true; } return str_contains($nameA, $nameB) || str_contains($nameB, $nameA); } /** @param array $candidates */ private function firstNonEmptyStr(array $candidates): ?string { foreach ($candidates as $c) { $v = trim((string) ($c ?? '')); if ($v !== '') { return $v; } } return null; } /** @return array */ private function buildRubricaPayloadFromCondominRow(object $row, string $role, ?int $stabileId, array $stabileMap): array { $get = fn(string $k) => property_exists($row, $k) ? $row->{$k} : null; $role = strtolower(trim($role)); if ($role === 'inquilino') { // ⚠️ IMPORTANTE: per l'inquilino usare SOLO campi tenant. $den = $this->trimOrNull($get('inquilino_denominazione')) ?: $this->trimOrNull($get('inquil_nome')); $nome = null; $cognome = null; $cf = $this->trimOrNull($get('inquil_cod_fisc')); $email = $this->normalizeEmail($this->trimOrNull($get('e_mail_inquilino'))); $pec = $this->normalizeEmail($this->trimOrNull($get('pec_inquilino'))); $tel = $this->trimOrNull($get('inquil_tel1')); $cell = $this->trimOrNull($get('cell_inq')); $indirizzo = $this->trimOrNull($get('inquil_indir')); $isPersonaGiuridica = $den !== null && $nome === null && $cognome === null; } else { // Condomino/proprietario: usa campi owner. $den = $this->trimOrNull($get('denominazione')); $nome = $this->trimOrNull($get('nome')); $cognome = $this->trimOrNull($get('cognome')); // Normalizza: se denominazione presente e mancano nome/cognome, consideriamo persona giuridica. $isPersonaGiuridica = $den !== null && $nome === null && $cognome === null; $cf = $this->trimOrNull($get('codice_fiscale')); $email = $this->normalizeEmail($this->trimOrNull($get('email'))); $pec = $this->normalizeEmail($this->trimOrNull($get('pec'))); $tel = $this->trimOrNull($get('telefono')); $cell = $this->trimOrNull($get('cellulare')); $indirizzo = $this->trimOrNull($get('indirizzo_corrispondenza')); } // Identità minima: almeno uno tra denominazione/nome/cognome/cf/email/telefono/cell. if ($den === null && $nome === null && $cognome === null && $cf === null && $email === null && $pec === null && $tel === null && $cell === null) { return []; } $payload = [ 'ragione_sociale' => $isPersonaGiuridica ? mb_substr($den ?? '', 0, 255) : null, 'nome' => !$isPersonaGiuridica ? ($nome ? mb_substr($nome, 0, 100) : null) : null, 'cognome' => !$isPersonaGiuridica ? ($cognome ? mb_substr($cognome, 0, 100) : null) : null, 'tipo_contatto' => $isPersonaGiuridica ? 'persona_giuridica' : 'persona_fisica', 'codice_fiscale' => $cf ? mb_substr($cf, 0, 20) : null, 'email' => $email ? mb_substr($email, 0, 191) : null, 'pec' => $pec ? mb_substr($pec, 0, 191) : null, 'telefono_ufficio' => $tel ? mb_substr($tel, 0, 50) : null, 'telefono_cellulare' => $cell ? mb_substr($cell, 0, 50) : null, 'indirizzo' => $indirizzo ? mb_substr($indirizzo, 0, 255) : null, 'categoria' => 'condomino', 'stato' => 'attivo', ]; // Imposta amministratore_id se possibile. if ($stabileId && !empty($stabileMap[(int) $stabileId]['amministratore_id'])) { $payload['amministratore_id'] = (int) $stabileMap[(int) $stabileId]['amministratore_id']; } // Rimuovi null per evitare sovrascritture inutili. return array_filter($payload, fn($v) => $v !== null); } /** @return array */ private function buildCodeCandidates(string $raw, ?int $padTo): array { $raw = trim($raw); if ($raw === '') { return []; } $candidates = [$raw]; $trim = ltrim($raw, '0'); if ($trim !== '' && $trim !== $raw) { $candidates[] = $trim; } if ($padTo !== null) { $onlyDigits = preg_match('/^\d+$/', $trim !== '' ? $trim : $raw) === 1; if ($onlyDigits) { $base = $trim !== '' ? $trim : $raw; if (strlen($base) < $padTo) { $candidates[] = str_pad($base, $padTo, '0', STR_PAD_LEFT); } } } return array_values(array_unique(array_filter($candidates, fn($v) => trim((string) $v) !== ''))); } private function deactivateRole(RubricaRuolo $ruolo, string $reason): void { $meta = is_array($ruolo->meta) ? $ruolo->meta : []; $meta['bonifica'] = array_merge(is_array($meta['bonifica'] ?? null) ? $meta['bonifica'] : [], [ 'disabled_at' => now()->toISOString(), 'disabled_reason' => $reason, ]); $ruolo->is_attivo = false; $ruolo->meta = $meta; $ruolo->save(); } private function trimOrNull(mixed $v): ?string { if ($v === null) return null; $s = trim((string) $v); return $s === '' ? null : $s; } private function normalizeEmail(?string $email): ?string { $email = strtolower(trim((string) ($email ?? ''))); return $email !== '' ? $email : null; } }