diff --git a/app/Console/Commands/GesconQaRubricaLegamiCommand.php b/app/Console/Commands/GesconQaRubricaLegamiCommand.php new file mode 100644 index 0000000..a0faf0e --- /dev/null +++ b/app/Console/Commands/GesconQaRubricaLegamiCommand.php @@ -0,0 +1,632 @@ + 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 : ''; + } +} \ No newline at end of file diff --git a/bootstrap/app.php b/bootstrap/app.php index 4b776ef..35c9431 100755 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -10,6 +10,7 @@ use App\Console\Commands\GesconImportFattureAdeCsv; use App\Console\Commands\GesconImportFornitoriLegacyMdb; use App\Console\Commands\GesconMaterializePersoneRelazioniCommand; +use App\Console\Commands\GesconQaRubricaLegamiCommand; use App\Console\Commands\GesconQaContatori; use App\Console\Commands\GesconQaEmissione; use App\Console\Commands\GesconResyncStabili; @@ -51,6 +52,7 @@ GesconImportFattureAdeCsv::class, GesconImportFornitoriLegacyMdb::class, GesconMaterializePersoneRelazioniCommand::class, + GesconQaRubricaLegamiCommand::class, GesconResyncStabili::class, GesconCollegaFornitoreStabili::class, SyncSoggettiToPersone::class, diff --git a/docs/support/CRM-ANAGRAFICA-LEGAMI.md b/docs/support/CRM-ANAGRAFICA-LEGAMI.md new file mode 100644 index 0000000..833e34b --- /dev/null +++ b/docs/support/CRM-ANAGRAFICA-LEGAMI.md @@ -0,0 +1,66 @@ +# CRM Anagrafica Legami + +Questa nota serve a verificare e riallineare i legami tra: + +1. rubrica_universale +2. persone +3. persone_unita_relazioni +4. rubrica_ruoli +5. riferimenti testuali stabile/unita usati dal CRM chiamate + +## Ordine corretto della pipeline + +Per i casi importati da Gescon il flusso corretto e: + +1. `gescon:auto-sync-anagrafiche` +2. `anagrafica:sync-soggetti` +3. `gescon:materialize-persone-relazioni` +4. `gescon:sync-rubrica-ruoli` + +Se un contatto e presente in rubrica ma non compare nel CRM con stabile/unita corretti, il punto da verificare non e solo la rubrica: quasi sempre manca uno dei passaggi centrali su `persone_unita_relazioni` o `rubrica_ruoli`. + +## Nuovo comando QA mirato + +Per analizzare casi singoli senza modificare nulla: + +```bash +php artisan gescon:qa-rubrica-legami --rubrica-id=3236 --rubrica-id=3470 +``` + +Per applicare solo fix sicuri: + +```bash +php artisan gescon:qa-rubrica-legami --rubrica-id=3236 --rubrica-id=3470 --apply-safe +``` + +Il comando applica solo questi interventi conservativi: + +1. aggiorna `riferimento_stabile` se lo stabile candidato e univoco +2. aggiorna `riferimento_unita` se l'unita candidata e univoca +3. crea `rubrica_ruoli` solo quando esiste un'unica unita candidata e il ruolo e non ambiguo + +## Regola pratica + +Se il candidato unita arriva solo da `gescon_import.condomin` ma non esiste ancora una relazione locale robusta, il comando non crea `rubrica_ruoli`. + +Questo evita di scrivere legami strutturati in casi ancora dubbi. + +## Casi gia emersi + +### Rubrica 3236 + +Caso tipico di fix sicuro: + +1. record rubrica presente +2. stabile e unita coerenti e univoci +3. assenza di `rubrica_ruoli` +4. fix automatico consentito + +### Rubrica 3470 + +Caso da verificare con piu cautela: + +1. rubrica presente +2. unita candidata individuabile +3. manca la catena locale completa o il ruolo e ancora ambiguo +4. consentito solo aggiornare i riferimenti testuali se il caso resta univoco, ma non forzare sempre `rubrica_ruoli` \ No newline at end of file