option('apply'); $skipMismatch = (bool) $this->option('skip-mismatch'); $includeInactive = (bool) $this->option('include-inactive'); $reactivate = (bool) $this->option('reactivate'); $stabileId = $this->option('stabile-id'); $stabileId = $stabileId !== null && $stabileId !== '' ? (int) $stabileId : null; $nameLike = trim((string) ($this->option('name-like') ?? '')); $limit = (int) $this->option('limit'); if ($limit < 1) $limit = 1; if ($limit > 20000) $limit = 20000; if (!$stabileId) { $this->error('Specifica --stabile-id'); return self::FAILURE; } if (!Schema::hasTable('rubrica_ruoli') || !Schema::hasTable('rubrica_universale') || !Schema::hasTable('unita_immobiliari')) { $this->error('Tabelle richieste mancanti (rubrica_ruoli/rubrica_universale/unita_immobiliari).'); 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_deactivated_spurious_inquilino' => 0, 'roles_deactivated_mismatch_legacy' => 0, 'roles_deactivated_duplicate' => 0, 'roles_reactivated_inquilino' => 0, 'roles_kept' => 0, 'skipped_no_unit' => 0, 'skipped_no_legacy_match' => 0, ]; // Carica ruoli attivi dello stabile; opzionalmente filtra per nominativo. $q = DB::table('rubrica_ruoli as rr') ->join('rubrica_universale as ru', 'ru.id', '=', 'rr.rubrica_id') ->leftJoin('unita_immobiliari as ui', 'ui.id', '=', 'rr.unita_immobiliare_id') ->where('rr.stabile_id', $stabileId) ->when(!$includeInactive, fn($qq) => $qq->where('rr.is_attivo', 1)) ->orderBy('rr.id') ->limit($limit) ->select([ 'rr.id', 'rr.rubrica_id', 'rr.ruolo_standard', 'rr.ruolo_custom', 'rr.unita_immobiliare_id', 'rr.is_attivo', 'rr.meta', 'ru.nome', 'ru.cognome', 'ru.ragione_sociale', 'ru.codice_fiscale', 'ui.scala', 'ui.interno', 'ui.codice_unita', ]); if ($nameLike !== '') { $needle = '%' . $nameLike . '%'; $q->where(function ($w) use ($needle) { $w->where('ru.cognome', 'like', $needle) ->orWhere('ru.nome', 'like', $needle) ->orWhere('ru.ragione_sociale', 'like', $needle); }); } $rows = $q->get(); if ($rows->isEmpty()) { $this->info('Nessun ruolo attivo trovato (con i filtri selezionati).'); return self::SUCCESS; } // Dedup: per rubrica+ruolo_standard+unità mantiene il record con id più alto. $seen = []; $inquilinoMarker = []; foreach ($rows as $rr) { $ruolo = strtolower(trim((string) ($rr->ruolo_standard ?? ''))); if ($ruolo !== 'inquilino') { continue; } $metaArr = json_decode((string) ($rr->meta ?? ''), true); $metaArr = is_array($metaArr) ? $metaArr : []; $metaCodCond = $this->normalizeCodCondRaw(data_get($metaArr, 'cod_cond')); $metaCodCondNum = $this->normalizeCodCond(data_get($metaArr, 'cod_cond')); if ($metaCodCond && $this->isInquilinoCodCond($metaCodCond)) { $unitaId = $rr->unita_immobiliare_id !== null ? (int) $rr->unita_immobiliare_id : null; if ($unitaId) { $inquilinoMarker[$unitaId] = true; } } } foreach ($rows as $rr) { $stats['roles_scanned']++; $ruolo = strtolower(trim((string) ($rr->ruolo_standard ?? ''))); $isAttivo = (int) ($rr->is_attivo ?? 0) === 1; $unitaId = $rr->unita_immobiliare_id !== null ? (int) $rr->unita_immobiliare_id : null; if (!$unitaId) { $stats['skipped_no_unit']++; continue; } if ($isAttivo) { $dedupKey = (int) $rr->rubrica_id . '|' . $ruolo . '|' . $unitaId; if (!isset($seen[$dedupKey])) { $seen[$dedupKey] = (int) $rr->id; } else { // Se già visto, disattiva il più vecchio e tieni il più nuovo. $keepId = max($seen[$dedupKey], (int) $rr->id); $dropId = min($seen[$dedupKey], (int) $rr->id); $seen[$dedupKey] = $keepId; if ($dropId === (int) $rr->id) { $this->deactivateRubricaRuolo((int) $rr->id, $apply, 'duplicate role per rubrica+ruolo+unita'); $stats['roles_deactivated_duplicate']++; continue; } // Se devo droppare quello precedente, lo farò dopo con una passata mirata. } } $metaArr = json_decode((string) ($rr->meta ?? ''), true); $metaArr = is_array($metaArr) ? $metaArr : []; $metaCodCond = $this->normalizeCodCondRaw(data_get($metaArr, 'cod_cond')); if ($ruolo === 'inquilino' && $unitaId && ($inquilinoMarker[$unitaId] ?? false)) { if (! $metaCodCond || ! $this->isInquilinoCodCond($metaCodCond)) { if ($isAttivo) { $this->deactivateRubricaRuolo((int) $rr->id, $apply, 'duplicate inquilino without INQ marker'); $stats['roles_deactivated_duplicate']++; continue; } } } if ($ruolo === 'condomino' && $metaCodCond && $this->isInquilinoCodCond($metaCodCond)) { if ($isAttivo) { $this->deactivateRubricaRuolo((int) $rr->id, $apply, 'condomino with INQ marker'); $stats['roles_deactivated_duplicate']++; continue; } } $scala = $this->firstNonEmpty([ data_get($metaArr, 'scala'), $rr->scala ?? null, ]); $interno = $this->firstNonEmpty([ data_get($metaArr, 'interno'), $rr->interno ?? null, ]); $codStabile = $this->firstNonEmpty([ data_get($metaArr, 'cod_stabile'), ]); $legacyDir = $this->firstNonEmpty([ data_get($metaArr, 'legacy_anno_dir'), ]); if ($scala === null || $interno === null || $codStabile === null) { $stats['skipped_no_legacy_match']++; continue; } // Legacy snapshot: per una data unità, cerca separatamente ownerRow e tenantRow. $legacyBase = DB::connection($conn)->table('condomin') ->where('cod_stabile', $codStabile); if ($metaCodCondNum !== null) { $legacyBase->where('cod_cond', $metaCodCondNum); } else { $legacyBase->where('scala', $scala) ->where('interno', $interno); } $legacyAny = null; if ($legacyDir !== null && Schema::connection($conn)->hasColumn('condomin', 'legacy_year')) { $legacyAny = (clone $legacyBase) ->where('legacy_year', $legacyDir) ->orderByDesc('id') ->first([ 'id', 'legacy_year', 'proprietario', 'inquilino', 'cognome', 'nome', 'nom_cond', 'cond_cod_fisc', 'inquilino_denominazione', 'inquil_nome', 'inquil_cod_fisc', ]); } if (!$legacyAny) { $legacyAny = (clone $legacyBase) ->orderByDesc('id') ->first([ 'id', 'legacy_year', 'proprietario', 'inquilino', 'cognome', 'nome', 'nom_cond', 'cond_cod_fisc', 'inquilino_denominazione', 'inquil_nome', 'inquil_cod_fisc', ]); } if (!$legacyAny) { $stats['skipped_no_legacy_match']++; continue; } $ownerRow = null; if (Schema::connection($conn)->hasColumn('condomin', 'proprietario')) { $ownerRowQ = (clone $legacyBase); if ($legacyDir !== null && Schema::connection($conn)->hasColumn('condomin', 'legacy_year')) { $ownerRowQ->where('legacy_year', $legacyDir); } $ownerRow = $ownerRowQ ->where('proprietario', 1) ->orderByDesc('id') ->first([ 'id', 'legacy_year', 'nom_cond', 'nome', 'cognome', 'cond_cod_fisc', ]); } if (!$ownerRow && Schema::connection($conn)->hasColumn('condomin', 'proprietario')) { // Fallback: se per l'anno richiesto non esiste la riga proprietario=1, // cerca su qualsiasi anno. Evita che ownerRow si trasformi in una riga inquilino. $ownerRow = (clone $legacyBase) ->where('proprietario', 1) ->orderByDesc('id') ->first([ 'id', 'legacy_year', 'nom_cond', 'nome', 'cognome', 'cond_cod_fisc', ]); } if (!$ownerRow) { $ownerRow = $legacyAny; } $tenantRow = null; $tenantRowQ = (clone $legacyBase); if ($legacyDir !== null && Schema::connection($conn)->hasColumn('condomin', 'legacy_year')) { $tenantRowQ->where('legacy_year', $legacyDir); } if (Schema::connection($conn)->hasColumn('condomin', 'inquilino')) { $tenantRow = (clone $tenantRowQ) ->where('inquilino', 1) ->orderByDesc('id') ->first([ 'id', 'legacy_year', 'inquilino_denominazione', 'inquil_nome', 'inquil_cod_fisc', ]); } if (!$tenantRow) { // Fallback: tenant presente anche se flag incoerente (campi identità valorizzati) $tenantRow = (clone $tenantRowQ) ->where(function ($w) { $w->whereNotNull('inquilino_denominazione') ->where('inquilino_denominazione', '!=', '') ->orWhere(function ($w2) { $w2->whereNotNull('inquil_nome')->where('inquil_nome', '!=', ''); }) ->orWhere(function ($w3) { $w3->whereNotNull('inquil_cod_fisc')->where('inquil_cod_fisc', '!=', ''); }); }) ->orderByDesc('id') ->first([ 'id', 'legacy_year', 'inquilino_denominazione', 'inquil_nome', 'inquil_cod_fisc', ]); } $rubricaName = $this->normalizeIdentityStr($rr->ragione_sociale ?: trim((string) ($rr->nome ?? '') . ' ' . (string) ($rr->cognome ?? ''))); $rubricaCf = $this->normalizeCf($rr->codice_fiscale ?? null); $legacyOwnerName = $this->normalizeIdentityStr($ownerRow->nom_cond ?? trim((string) ($ownerRow->nome ?? '') . ' ' . (string) ($ownerRow->cognome ?? ''))); $legacyOwnerCf = $this->normalizeCf($ownerRow->cond_cod_fisc ?? null); $legacyTenantName = $this->normalizeIdentityStr($this->firstNonEmpty([ $tenantRow->inquilino_denominazione ?? null, $tenantRow->inquil_nome ?? null, ])); $legacyTenantCf = $this->normalizeCf($tenantRow->inquil_cod_fisc ?? null); $hasTenant = ($legacyTenantName !== '' || $legacyTenantCf !== ''); $hasOwner = ($legacyOwnerName !== '' || $legacyOwnerCf !== ''); // Evita falsi positivi: se "inquilino" coincide col proprietario, consideralo assente. if ($hasTenant && $this->identityMatches($legacyTenantName, $legacyTenantCf, $legacyOwnerName, $legacyOwnerCf)) { $hasTenant = false; } if ($ruolo === 'inquilino') { if (!$hasTenant) { if ($isAttivo) { $this->deactivateRubricaRuolo((int) $rr->id, $apply, 'spurious inquilino: legacy has no tenant'); $stats['roles_deactivated_spurious_inquilino']++; continue; } } else { $matchesTenant = $this->identityMatches($rubricaName, $rubricaCf, $legacyTenantName, $legacyTenantCf); if (!$skipMismatch && !$matchesTenant && $isAttivo) { $this->deactivateRubricaRuolo((int) $rr->id, $apply, 'mismatch legacy tenant for unit'); $stats['roles_deactivated_mismatch_legacy']++; continue; } if ($reactivate && $apply && $includeInactive && !$isAttivo && $matchesTenant) { $this->reactivateRubricaRuolo((int) $rr->id, 'reactivated: matches legacy tenant for unit'); $stats['roles_reactivated_inquilino']++; // Non fare continue: lo consideriamo tenuto. } if ($reactivate && !$apply && $includeInactive && !$isAttivo && $matchesTenant) { $stats['roles_reactivated_inquilino']++; } } } elseif ($ruolo === 'condomino') { if (!$hasOwner) { if (!$skipMismatch) { $this->deactivateRubricaRuolo((int) $rr->id, $apply, 'spurious condomino: legacy has no owner'); $stats['roles_deactivated_mismatch_legacy']++; continue; } } if (!$skipMismatch && !$this->identityMatches($rubricaName, $rubricaCf, $legacyOwnerName, $legacyOwnerCf)) { $this->deactivateRubricaRuolo((int) $rr->id, $apply, 'mismatch legacy owner for unit'); $stats['roles_deactivated_mismatch_legacy']++; continue; } } $stats['roles_kept']++; } // Seconda passata: se per una key abbiamo visto un record più nuovo, droppa i più vecchi rimasti attivi. foreach ($seen as $key => $keepId) { [$rubricaId, $ruolo, $unitaId] = explode('|', $key); $oldIds = DB::table('rubrica_ruoli') ->where('rubrica_id', (int) $rubricaId) ->where('ruolo_standard', $ruolo) ->where('unita_immobiliare_id', (int) $unitaId) ->where('is_attivo', 1) ->where('id', '!=', (int) $keepId) ->pluck('id') ->all(); foreach ($oldIds as $oldId) { $this->deactivateRubricaRuolo((int) $oldId, $apply, 'duplicate role per rubrica+ruolo+unita (post-pass)'); $stats['roles_deactivated_duplicate']++; } } $this->info('✅ Bonifica ruoli legacy completata' . ($apply ? '' : ' (dry-run)')); $this->line(json_encode($stats, JSON_UNESCAPED_UNICODE)); return self::SUCCESS; } private function deactivateRubricaRuolo(int $id, bool $apply, string $reason): void { if (!$apply) { return; } $row = DB::table('rubrica_ruoli')->where('id', $id)->first(['id', 'meta']); $meta = []; if ($row && !empty($row->meta)) { $decoded = json_decode((string) $row->meta, true); if (is_array($decoded)) { $meta = $decoded; } } $meta['bonifica'] = array_merge(is_array($meta['bonifica'] ?? null) ? $meta['bonifica'] : [], [ 'disabled_at' => now()->toISOString(), 'disabled_reason' => $reason, 'source' => 'gescon:bonifica-ruoli-legacy', ]); DB::table('rubrica_ruoli') ->where('id', $id) ->update([ 'is_attivo' => 0, 'meta' => json_encode($meta, JSON_UNESCAPED_UNICODE), 'updated_at' => now(), ]); } private function reactivateRubricaRuolo(int $id, string $reason): void { $row = DB::table('rubrica_ruoli')->where('id', $id)->first(['id', 'meta']); $meta = []; if ($row && !empty($row->meta)) { $decoded = json_decode((string) $row->meta, true); if (is_array($decoded)) { $meta = $decoded; } } $meta['bonifica'] = array_merge(is_array($meta['bonifica'] ?? null) ? $meta['bonifica'] : [], [ 'reactivated_at' => now()->toISOString(), 'reactivated_reason' => $reason, 'source' => 'gescon:bonifica-ruoli-legacy', ]); DB::table('rubrica_ruoli') ->where('id', $id) ->update([ 'is_attivo' => 1, 'meta' => json_encode($meta, JSON_UNESCAPED_UNICODE), 'updated_at' => now(), ]); } private function normalizeCf($cf): string { $cf = strtoupper(preg_replace('/\s+/', '', (string) ($cf ?? ''))); $cf = preg_replace('/[^A-Z0-9]/', '', $cf); return $cf ?: ''; } private function normalizeCodCondRaw(?string $value): ?string { $v = trim((string) $value); if ($v === '') return null; return strtoupper($v); } private function normalizeCodCond(?string $value): ?string { $v = trim((string) $value); if ($v === '') return null; if (preg_match('/(\d{1,})/', $v, $m)) { return ltrim($m[1], '0') !== '' ? ltrim($m[1], '0') : $m[1]; } return null; } private function isInquilinoCodCond(string $value): bool { $v = strtoupper(trim($value)); return str_starts_with($v, 'I') || str_contains($v, 'INQ'); } private function normalizeIdentityStr($s): string { $s = strtoupper((string) ($s ?? '')); $s = str_replace(['.', ',', ';', ':', '-', '/', '\\', '\t', "\n", "\r"], ' ', $s); $s = preg_replace('/\s+/', ' ', $s); $s = trim($s); return $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; } // Match permissivo: token-based (ignora l'ordine). $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; } // Fallback: contenimento stringa. return str_contains($nameA, $nameB) || str_contains($nameB, $nameA); } /** @param array $candidates */ private function firstNonEmpty(array $candidates): ?string { foreach ($candidates as $c) { $v = trim((string) ($c ?? '')); if ($v !== '') { return $v; } } return null; } }