stabile -> unita -> rubrica_ruoli.'; public function handle(): int { foreach (['rubrica_universale', 'stabili', 'unita_immobiliari'] as $table) { if (! Schema::hasTable($table)) { $this->error('Tabella richiesta mancante: ' . $table); return self::FAILURE; } } $rubricaIds = array_values(array_filter(array_map( fn($value): int => is_numeric($value) ? (int) $value : 0, (array) $this->option('rubrica-id') ))); $query = RubricaUniversale::query() ->where('categoria', 'condomino') ->orderByDesc('id'); if ($rubricaIds !== []) { $query->whereIn('id', $rubricaIds); } else { $query->where(function ($inner): void { $inner->whereNull('riferimento_stabile') ->orWhereNull('riferimento_unita'); }); } $rows = $query->limit(max(1, min(500, (int) $this->option('limit'))))->get(); if ($rows->isEmpty()) { $this->info('Nessun record rubrica da analizzare.'); return self::SUCCESS; } $applySafe = (bool) $this->option('apply-safe'); $updatedReferences = 0; $createdRuoli = 0; $ambiguous = 0; $manualReview = 0; foreach ($rows as $rubrica) { $analysis = $this->analyzeRubrica($rubrica); $this->line(''); $this->info('Rubrica #' . (int) $rubrica->id . ' - ' . $this->displayRubricaName($rubrica)); $this->line('Legacy: cod_stabile=' . ($analysis['legacy']['cod_stabile'] ?: '-') . ' | cod_cond=' . ($analysis['legacy']['cod_cond'] ?: '-') . ' | ruolo=' . ($analysis['legacy']['ruolo'] ?: '-')); $this->line('Stabile candidato: ' . ($analysis['stabile_label'] ?: '-')); $this->line('Ruoli esistenti: ' . count($analysis['existing_ruoli'])); $this->line('Persona match: ' . ($analysis['persona_summary'] !== '' ? $analysis['persona_summary'] : '-')); if ($analysis['candidate_units'] === []) { $this->warn('Nessuna unita candidata trovata.'); } else { foreach ($analysis['candidate_units'] as $candidate) { $this->line('- unita #' . $candidate['unit_id'] . ' [' . $candidate['source'] . '] ' . $candidate['unit_label'] . ' | ruolo=' . ($candidate['role_standard'] ?: '-') . ' | fix-ruolo=' . ($candidate['safe_role_creation'] ? 'si' : 'no')); } } if ($analysis['unique_unit'] === null && $analysis['candidate_units'] !== []) { $ambiguous++; $this->warn('Caso ambiguo: piu unita candidate o ruolo non sicuro.'); } elseif ($analysis['requires_manual_review']) { $manualReview++; $this->warn('Caso da revisione manuale: unita unica trovata, ma il ruolo strutturato non e abbastanza sicuro da creare rubrica_ruoli.'); } if (! $applySafe) { continue; } $applied = $this->applySafeFixes($rubrica, $analysis); $updatedReferences += $applied['references_updated']; $createdRuoli += $applied['ruoli_created']; } $this->line(''); $this->info('QA legami completato.'); $this->line('Record analizzati: ' . $rows->count()); $this->line('Casi ambigui: ' . $ambiguous); $this->line('Casi da revisione manuale: ' . $manualReview); if ($applySafe) { $this->line('Riferimenti aggiornati: ' . $updatedReferences); $this->line('Rubrica ruoli creati: ' . $createdRuoli); } return ($ambiguous > 0 || $manualReview > 0) ? self::INVALID : self::SUCCESS; } /** * @return array */ private function analyzeRubrica(RubricaUniversale $rubrica): array { $legacy = $this->parseLegacyMeta((string) ($rubrica->note ?? '')); $stabile = $this->resolveStabile($legacy['cod_stabile']); $existingRuoli = Schema::hasTable('rubrica_ruoli') ? DB::table('rubrica_ruoli')->where('rubrica_id', (int) $rubrica->id)->get()->all() : []; $personaMatches = $this->findPersonaMatches($rubrica, $stabile['id']); $candidateUnits = $this->mergeCandidates( $this->collectLegacyUnitCandidates($stabile['id'], $legacy), $this->collectPersonaRelationCandidates($personaMatches, $stabile['id']), $this->collectImportUnitCandidates($rubrica, $stabile['id'], $legacy) ); $uniqueUnit = count($candidateUnits) === 1 ? $candidateUnits[0] : null; if ($uniqueUnit !== null && ($uniqueUnit['role_standard'] ?? null) === null) { $uniqueUnit['role_standard'] = $this->mapRoleStandard($legacy['ruolo']); } if ($uniqueUnit !== null && ($uniqueUnit['role_standard'] ?? null) === null) { $uniqueUnit = null; } $requiresManualReview = $uniqueUnit !== null && ! empty($stabile['id']) && ! $uniqueUnit['safe_role_creation'] && count($existingRuoli) === 0; return [ 'legacy' => $legacy, 'stabile' => $stabile, 'stabile_label' => $this->buildStabileReference($stabile), 'existing_ruoli' => $existingRuoli, 'candidate_units' => $candidateUnits, 'unique_unit' => $uniqueUnit, 'persona_summary' => $this->summarizePersonas($personaMatches), 'requires_manual_review' => $requiresManualReview, ]; } /** * @return array{references_updated:int,ruoli_created:int} */ private function applySafeFixes(RubricaUniversale $rubrica, array $analysis): array { $referencesUpdated = 0; $ruoliCreated = 0; $stabile = $analysis['stabile']; $uniqueUnit = $analysis['unique_unit']; $dirty = false; if (($rubrica->riferimento_stabile === null || trim((string) $rubrica->riferimento_stabile) === '') && ! empty($stabile['id'])) { $rubrica->riferimento_stabile = $this->buildStabileReference($stabile); $dirty = true; } if (($rubrica->riferimento_unita === null || trim((string) $rubrica->riferimento_unita) === '') && $uniqueUnit !== null) { $rubrica->riferimento_unita = $uniqueUnit['unit_reference']; $dirty = true; } if ($dirty) { $rubrica->data_ultima_modifica = now(); $rubrica->save(); $referencesUpdated++; } if ( $uniqueUnit !== null && ! empty($stabile['id']) && $uniqueUnit['safe_role_creation'] && ($uniqueUnit['role_standard'] ?? null) !== null && Schema::hasTable('rubrica_ruoli') && count($analysis['existing_ruoli']) === 0 ) { DB::table('rubrica_ruoli')->updateOrInsert( [ 'rubrica_id' => (int) $rubrica->id, 'stabile_id' => (int) $stabile['id'], 'unita_immobiliare_id' => (int) $uniqueUnit['unit_id'], 'ruolo_standard' => $uniqueUnit['role_standard'], ], [ 'is_attivo' => true, 'is_preferito' => true, 'meta' => json_encode([ 'source' => $uniqueUnit['source'], 'created_by' => 'gescon:qa-rubrica-legami', ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), 'updated_at' => now(), 'created_at' => now(), ] ); $ruoliCreated++; } return [ 'references_updated' => $referencesUpdated, 'ruoli_created' => $ruoliCreated, ]; } /** * @return array{cod_stabile:?string,cod_cond:?string,ruolo:?string} */ private function parseLegacyMeta(string $note): array { $payload = [ 'cod_stabile' => null, 'cod_cond' => null, 'ruolo' => null, ]; foreach (['cod_stabile', 'cod_cond', 'ruolo'] as $key) { if (preg_match('/' . preg_quote($key, '/') . '=([^|\n\r]+)/i', $note, $matches) === 1) { $payload[$key] = trim((string) ($matches[1] ?? '')) ?: null; } } return $payload; } /** * @return array{id:int|null,codice_stabile:?string,cod_stabile:?string,denominazione:?string} */ private function resolveStabile(?string $legacyCode): array { $blank = ['id' => null, 'codice_stabile' => null, 'cod_stabile' => null, 'denominazione' => null]; $code = trim((string) $legacyCode); if ($code === '') { return $blank; } $trimmed = ltrim($code, '0'); $row = DB::table('stabili') ->select(['id', 'codice_stabile', 'cod_stabile', 'denominazione']) ->where(function ($query) use ($code, $trimmed): void { $query->where('codice_stabile', $code) ->orWhere('cod_stabile', $code); if ($trimmed !== '' && $trimmed !== $code) { $query->orWhere('codice_stabile', $trimmed) ->orWhere('cod_stabile', $trimmed); } }) ->orderBy('id') ->first(); if (! $row) { return $blank; } return [ 'id' => (int) $row->id, 'codice_stabile' => $row->codice_stabile ? (string) $row->codice_stabile : null, 'cod_stabile' => $row->cod_stabile ? (string) $row->cod_stabile : null, 'denominazione' => $row->denominazione ? (string) $row->denominazione : null, ]; } /** * @return array> */ private function collectLegacyUnitCandidates(?int $stabileId, array $legacy): array { if (! Schema::hasTable('unita_immobiliare_nominativi') || ! $stabileId || empty($legacy['cod_cond'])) { return []; } $rows = DB::table('unita_immobiliare_nominativi as uin') ->join('unita_immobiliari as ui', 'ui.id', '=', 'uin.unita_immobiliare_id') ->where('uin.stabile_id', $stabileId) ->where('uin.legacy_cond_id', trim((string) $legacy['cod_cond'])) ->select([ 'ui.id as unit_id', 'ui.codice_unita', 'ui.denominazione', 'ui.scala', 'ui.interno', 'uin.ruolo', ]) ->orderBy('ui.id') ->get(); $grouped = []; foreach ($rows as $row) { $unitId = (int) $row->unit_id; $grouped[$unitId] ??= [ 'unit_id' => $unitId, 'unit_label' => $this->buildUnitLabel($row), 'unit_reference' => $this->buildUnitReference($row), 'source' => 'legacy_local', 'role_tokens' => [], 'safe_role_creation' => true, ]; $roleToken = $this->mapLegacyRoleToken((string) ($row->ruolo ?? '')); if ($roleToken !== null) { $grouped[$unitId]['role_tokens'][$roleToken] = true; } } return array_values(array_map(function (array $candidate) use ($legacy): array { $roleStandard = $this->mapRoleStandard($legacy['ruolo']); if ($roleStandard === null) { $tokens = array_keys($candidate['role_tokens']); $roleStandard = count($tokens) === 1 ? $tokens[0] : null; } unset($candidate['role_tokens']); $candidate['role_standard'] = $roleStandard; return $candidate; }, $grouped)); } /** * @param array> $personaMatches * @return array> */ private function collectPersonaRelationCandidates(array $personaMatches, ?int $stabileId): array { if (! Schema::hasTable('persone_unita_relazioni') || $personaMatches === []) { return []; } $personaIds = array_values(array_unique(array_map(fn(array $row): int => (int) $row['id'], $personaMatches))); $rows = DB::table('persone_unita_relazioni as pur') ->join('unita_immobiliari as ui', 'ui.id', '=', 'pur.unita_id') ->whereIn('pur.persona_id', $personaIds) ->when($stabileId, fn($query) => $query->where('ui.stabile_id', $stabileId)) ->select([ 'pur.persona_id', 'pur.tipo_relazione', 'ui.id as unit_id', 'ui.codice_unita', 'ui.denominazione', 'ui.scala', 'ui.interno', ]) ->orderBy('ui.id') ->get(); $grouped = []; foreach ($rows as $row) { $unitId = (int) $row->unit_id; $grouped[$unitId] ??= [ 'unit_id' => $unitId, 'unit_label' => $this->buildUnitLabel($row), 'unit_reference' => $this->buildUnitReference($row), 'source' => 'persona_relazione', 'role_tokens' => [], 'safe_role_creation' => true, ]; $roleStandard = $this->mapRoleStandard((string) ($row->tipo_relazione ?? '')); if ($roleStandard !== null) { $grouped[$unitId]['role_tokens'][$roleStandard] = true; } } return array_values(array_map(function (array $candidate): array { $tokens = array_keys($candidate['role_tokens']); unset($candidate['role_tokens']); $candidate['role_standard'] = count($tokens) === 1 ? $tokens[0] : null; return $candidate; }, $grouped)); } /** * @return array> */ private function collectImportUnitCandidates(RubricaUniversale $rubrica, ?int $stabileId, array $legacy): array { if (! $stabileId || empty($legacy['cod_stabile']) || empty($legacy['cod_cond']) || ! $this->hasGesconImportCondomin()) { return []; } $rows = DB::connection('gescon_import') ->table('condomin') ->where('cod_stabile', trim((string) $legacy['cod_stabile'])) ->where('cod_cond', trim((string) $legacy['cod_cond'])) ->select(['nom_cond', 'scala', 'interno', 'proprietario', 'inquilino']) ->get(); $candidates = []; foreach ($rows as $row) { $unit = $this->resolveUnitFromImportRow($stabileId, [ 'nom_cond' => (string) ($row->nom_cond ?? ''), 'scala' => (string) ($row->scala ?? ''), 'interno' => (string) ($row->interno ?? ''), ]); if ($unit === null) { continue; } $roleFromNote = $this->mapRoleStandard($legacy['ruolo']); $candidates[] = [ 'unit_id' => (int) $unit->unit_id, 'unit_label' => $this->buildUnitLabel($unit), 'unit_reference' => $this->buildUnitReference($unit), 'source' => 'gescon_import', 'role_standard' => $roleFromNote, 'safe_role_creation' => false, ]; } return $candidates; } /** * @return array> */ private function findPersonaMatches(RubricaUniversale $rubrica, ?int $stabileId): array { if (! Schema::hasTable('persone')) { return []; } $query = DB::table('persone')->select(['id', 'nome', 'cognome', 'ragione_sociale', 'codice_fiscale', 'email_principale', 'telefono_principale']); $cf = trim((string) ($rubrica->codice_fiscale ?? '')); if ($cf !== '') { $rows = (clone $query)->where('codice_fiscale', $cf)->get(); if ($rows->isNotEmpty()) { return $rows->map(fn($row) => (array) $row)->all(); } } $email = mb_strtolower(trim((string) ($rubrica->email ?? ''))); if ($email !== '') { $rows = (clone $query)->whereRaw('LOWER(email_principale) = ?', [$email])->get(); if ($rows->isNotEmpty()) { return $rows->map(fn($row) => (array) $row)->all(); } } $phone = $this->normalizeDigits((string) ($rubrica->telefono_cellulare ?: $rubrica->telefono_ufficio ?: '')); if ($phone !== '') { $rows = (clone $query) ->whereRaw("REPLACE(REPLACE(REPLACE(COALESCE(telefono_principale, ''), ' ', ''), '+', ''), '-', '') = ?", [$phone]) ->get(); if ($rows->isNotEmpty()) { return $rows->map(fn($row) => (array) $row)->all(); } } $ragioneSociale = $this->normalizeText((string) ($rubrica->ragione_sociale ?: $rubrica->nome_completo)); if ($ragioneSociale !== '') { return (clone $query) ->whereRaw("UPPER(REGEXP_REPLACE(COALESCE(ragione_sociale, ''), '[^A-Z0-9]', '')) = ?", [$ragioneSociale]) ->get() ->map(fn($row) => (array) $row) ->all(); } return []; } /** * @param array> ...$candidateSets * @return array> */ private function mergeCandidates(array ...$candidateSets): array { $merged = []; foreach ($candidateSets as $candidateSet) { foreach ($candidateSet as $candidate) { $unitId = (int) ($candidate['unit_id'] ?? 0); if ($unitId <= 0) { continue; } if (! isset($merged[$unitId])) { $merged[$unitId] = $candidate; continue; } $merged[$unitId]['source'] .= '+' . $candidate['source']; $merged[$unitId]['safe_role_creation'] = (bool) $merged[$unitId]['safe_role_creation'] || (bool) $candidate['safe_role_creation']; if (($merged[$unitId]['role_standard'] ?? null) === null && ($candidate['role_standard'] ?? null) !== null) { $merged[$unitId]['role_standard'] = $candidate['role_standard']; } } } ksort($merged); return array_values($merged); } private function resolveUnitFromImportRow(int $stabileId, array $row): ?object { $query = DB::table('unita_immobiliari') ->where('stabile_id', $stabileId) ->select(['id as unit_id', 'codice_unita', 'denominazione', 'scala', 'interno']); $scala = trim((string) ($row['scala'] ?? '')); $interno = trim((string) ($row['interno'] ?? '')); if ($scala !== '' || $interno !== '') { $strict = (clone $query) ->when($scala !== '', fn($inner) => $inner->where('scala', $scala)) ->when($interno !== '', fn($inner) => $inner->where('interno', $interno)) ->get(); if ($strict->count() === 1) { return $strict->first(); } } $normalizedName = $this->normalizeText((string) ($row['nom_cond'] ?? '')); if ($normalizedName === '') { return null; } $matches = (clone $query)->get()->filter(function ($unit) use ($normalizedName): bool { $haystack = $this->normalizeText((string) ($unit->denominazione ?? '')); return $haystack !== '' && ($haystack === $normalizedName || str_contains($haystack, $normalizedName) || str_contains($normalizedName, $haystack)); })->values(); return $matches->count() === 1 ? $matches->first() : null; } private function hasGesconImportCondomin(): bool { try { return Schema::connection('gescon_import')->hasTable('condomin'); } catch (\Throwable) { return false; } } private function buildStabileReference(array $stabile): ?string { if (empty($stabile['id'])) { return null; } $parts = array_values(array_filter([ trim((string) ($stabile['cod_stabile'] ?? '')), trim((string) ($stabile['codice_stabile'] ?? '')), trim((string) ($stabile['denominazione'] ?? '')), ])); return $parts !== [] ? implode(' - ', $parts) : ('Stabile #' . (int) $stabile['id']); } private function buildUnitReference(object | array $row): string { $code = trim((string) data_get((array) $row, 'codice_unita', data_get($row, 'codice_unita', ''))); $label = trim((string) data_get((array) $row, 'denominazione', data_get($row, 'denominazione', ''))); return trim($code . ($label !== '' ? ' - ' . $label : '')); } private function buildUnitLabel(object | array $row): string { $code = trim((string) data_get((array) $row, 'codice_unita', data_get($row, 'codice_unita', ''))); $scala = trim((string) data_get((array) $row, 'scala', data_get($row, 'scala', ''))); $interno = trim((string) data_get((array) $row, 'interno', data_get($row, 'interno', ''))); $denominazione = trim((string) data_get((array) $row, 'denominazione', data_get($row, 'denominazione', ''))); $bits = array_values(array_filter([$code, $scala, $interno, $denominazione])); return implode(' / ', $bits); } private function mapRoleStandard(?string $value): ?string { $normalized = strtolower(trim((string) $value)); return match ($normalized) { 'c', 'condomino', 'proprietario', 'comproprietario', 'nudo_proprietario', 'usufruttuario', 'titolare' => 'condomino', 'i', 'inquilino', 'conduttore', 'locatario' => 'inquilino', default => null, }; } private function mapLegacyRoleToken(string $value): ?string { return $this->mapRoleStandard($value); } private function displayRubricaName(RubricaUniversale $rubrica): string { return trim((string) ($rubrica->nome_completo ?: $rubrica->ragione_sociale ?: ('Rubrica #' . $rubrica->id))); } /** * @param array> $personaMatches */ private function summarizePersonas(array $personaMatches): string { if ($personaMatches === []) { return ''; } return implode(', ', array_map(function (array $row): string { $label = trim((string) (($row['ragione_sociale'] ?? '') ?: trim((string) (($row['cognome'] ?? '') . ' ' . ($row['nome'] ?? ''))))); return '#' . (int) $row['id'] . ' ' . ($label !== '' ? $label : 'persona'); }, $personaMatches)); } private function normalizeDigits(string $value): string { return preg_replace('/\D+/', '', $value) ?: ''; } private function normalizeText(string $value): string { $upper = mb_strtoupper(trim($value)); $normalized = preg_replace('/[^A-Z0-9]+/u', '', $upper); return is_string($normalized) ? $normalized : ''; } }