option('apply'); $stabile = trim((string) ($this->option('stabile') ?? '')); $limit = max(1, min(50000, (int) ($this->option('limit') ?? 10000))); foreach (['unita_immobiliare_nominativi', 'unita_immobiliari', 'stabili', 'persone', 'persone_unita_relazioni'] as $table) { if (! Schema::hasTable($table)) { $this->error("Tabella {$table} non disponibile."); return 1; } } $query = DB::table('unita_immobiliare_nominativi as uin') ->join('unita_immobiliari as ui', 'ui.id', '=', 'uin.unita_immobiliare_id') ->join('stabili as s', 's.id', '=', 'ui.stabile_id') ->select([ 'uin.id', 'uin.unita_immobiliare_id', 'uin.legacy_cond_id', 'uin.ruolo', 'uin.nominativo', 'uin.data_inizio', 'uin.data_fine', 'uin.percentuale', 'uin.fonte', 'uin.legacy_payload', 'ui.stabile_id', 'ui.codice_unita', 'ui.scala', 'ui.interno', 'ui.piano', 's.codice_stabile', 's.amministratore_id', ]) ->orderBy('s.codice_stabile') ->orderBy('ui.id') ->orderBy('uin.id'); if ($stabile !== '') { $trimmed = ltrim($stabile, '0'); $query->where(function ($builder) use ($stabile, $trimmed): void { $builder->where('s.codice_stabile', $stabile); if ($trimmed !== '' && $trimmed !== $stabile) { $builder->orWhere('s.codice_stabile', $trimmed); } }); } $rows = $query->limit($limit)->get(); if ($rows->isEmpty()) { $this->info('Nessun nominativo unita da materializzare.'); return 0; } $stats = [ 'processed' => 0, 'skipped' => 0, 'persona_created' => 0, 'persona_updated' => 0, 'rel_created' => 0, 'rel_updated' => 0, ]; foreach ($rows as $row) { $stats['processed']++; $nominativo = trim((string) ($row->nominativo ?? '')); if ($nominativo === '') { $stats['skipped']++; continue; } $tipoRelazione = $this->resolveTipoRelazione($row); $ruoloRate = $this->deriveRuoloRate($tipoRelazione); $rubrica = $this->findMatchingRubrica($row, $tipoRelazione); $payload = $this->buildPersonaPayload($row, $rubrica); if (! $this->hasPersonaIdentity($payload)) { $stats['skipped']++; continue; } $persona = $this->resolvePersonaMatch($payload); if ($apply) { if ($persona) { $updated = $this->fillPersonaMissingFields($persona, $payload); if ($updated) { $stats['persona_updated']++; } } else { $persona = $this->createPersonaFromPayload($payload); if (! $persona) { $stats['skipped']++; continue; } $stats['persona_created']++; } $relationPayload = [ 'quota_relazione' => $this->normalizePercentValue($row->percentuale ?? null), 'data_inizio' => $this->normalizeDateToYmd($row->data_inizio ?? null) ?: '1900-01-01', 'data_fine' => $this->normalizeDateToYmd($row->data_fine ?? null), 'attivo' => $this->isRelationActive($row->data_fine ?? null), 'riceve_comunicazioni' => true, 'riceve_convocazioni' => $ruoloRate === 'C', 'vota_assemblea' => $ruoloRate === 'C', 'note_relazione' => $this->buildRelationNote($row, $tipoRelazione), ]; if (Schema::hasColumn('persone_unita_relazioni', 'ruolo_rate')) { $relationPayload['ruolo_rate'] = $ruoloRate; } $existingRelationId = $this->findExistingRelationId( (int) $persona->id, (int) $row->unita_immobiliare_id, $tipoRelazione, $relationPayload['data_inizio'], $relationPayload['data_fine'], ); if ($existingRelationId) { DB::table('persone_unita_relazioni') ->where('id', $existingRelationId) ->update($relationPayload + ['updated_at' => now()]); $stats['rel_updated']++; } else { DB::table('persone_unita_relazioni')->insert([ 'persona_id' => (int) $persona->id, 'unita_id' => (int) $row->unita_immobiliare_id, 'tipo_relazione' => $tipoRelazione, 'quota_relazione' => $relationPayload['quota_relazione'], 'data_inizio' => $relationPayload['data_inizio'], 'data_fine' => $relationPayload['data_fine'], 'attivo' => $relationPayload['attivo'], 'riceve_comunicazioni' => $relationPayload['riceve_comunicazioni'], 'riceve_convocazioni' => $relationPayload['riceve_convocazioni'], 'vota_assemblea' => $relationPayload['vota_assemblea'], 'note_relazione' => $relationPayload['note_relazione'], 'ruolo_rate' => $relationPayload['ruolo_rate'] ?? null, 'created_at' => now(), 'updated_at' => now(), ]); $stats['rel_created']++; } } } $mode = $apply ? 'Materializzazione eseguita' : 'Dry-run materializzazione'; $this->info($mode . " - processati: {$stats['processed']}" . ", persone create: {$stats['persona_created']}" . ", persone aggiornate: {$stats['persona_updated']}" . ", relazioni create: {$stats['rel_created']}" . ", relazioni aggiornate: {$stats['rel_updated']}" . ", saltati: {$stats['skipped']}"); return 0; } private function findMatchingRubrica(object $row, string $tipoRelazione): ?RubricaUniversale { if (! Schema::hasTable('rubrica_universale')) { return null; } $role = $this->deriveRuoloRate($tipoRelazione) === 'I' ? 'inquilino' : 'condomino'; $codCond = trim((string) ($row->legacy_cond_id ?? '')); $codStabile = trim((string) ($row->codice_stabile ?? '')); $adminId = (int) ($row->amministratore_id ?? 0); if ($codCond !== '' && $codStabile !== '') { $stabileVariants = array_values(array_unique(array_filter([$codStabile, ltrim($codStabile, '0')]))); foreach ($stabileVariants as $stabileVariant) { $rubrica = RubricaUniversale::query() ->when($adminId > 0, fn($query) => $query->where('amministratore_id', $adminId)) ->where('categoria', 'condomino') ->whereNotNull('note') ->where('note', 'like', '%cod_stabile=' . $stabileVariant . '%') ->where('note', 'like', '%cod_cond=' . $codCond . '%') ->where('note', 'like', '%ruolo=' . $role . '%') ->orderBy('id') ->first(); if ($rubrica) { return $rubrica; } } } $identityKey = $this->normalizeIdentityKey($row->nominativo ?? null); if (! $identityKey) { return null; } return RubricaUniversale::query() ->when($adminId > 0, fn($query) => $query->where('amministratore_id', $adminId)) ->where(function ($query) use ($identityKey): void { $query->whereRaw("UPPER(REGEXP_REPLACE(COALESCE(ragione_sociale, ''), '[^A-Z0-9]', '')) = ?", [$identityKey]) ->orWhereRaw("UPPER(REGEXP_REPLACE(CONCAT(COALESCE(cognome, ''), COALESCE(nome, '')), '[^A-Z0-9]', '')) = ?", [$identityKey]); }) ->orderBy('id') ->first(); } private function buildPersonaPayload(object $row, ?RubricaUniversale $rubrica): array { if ($rubrica) { $ragione = $this->sanitizeString($rubrica->ragione_sociale, 255); $nome = $this->sanitizeString($rubrica->nome, 100); $cognome = $this->sanitizeString($rubrica->cognome, 100); } else { [$cognome, $nome, $ragione] = $this->splitNomeCognomeOrRagione((string) ($row->nominativo ?? '')); } if ($ragione && ! $cognome) { $cognome = $ragione; } if (! $nome) { $nome = $ragione ? 'Impresa' : ($cognome ?: 'N/D'); } if (! $cognome) { $cognome = $nome ?: 'N/D'; } $rawFiscal = $rubrica?->codice_fiscale ?: $rubrica?->partita_iva; $fiscal = $this->normalizeFiscalIds(is_string($rawFiscal) ? $rawFiscal : null); return [ 'tipologia' => $ragione ? 'giuridica' : 'fisica', 'nome' => $nome, 'cognome' => $cognome, 'ragione_sociale' => $ragione, 'codice_fiscale' => $fiscal['cf'], 'partita_iva' => $fiscal['piva'], 'email_principale' => $this->sanitizeEmail($rubrica?->email), 'email_pec' => $this->sanitizeEmail($rubrica?->pec), 'telefono_principale' => $this->normalizePhone($rubrica?->telefono_cellulare ?: $rubrica?->telefono_ufficio), 'telefono_secondario' => $this->normalizePhone($rubrica?->telefono_ufficio), 'residenza_via' => $this->sanitizeString($rubrica?->indirizzo, 255), 'note' => $this->buildPersonaNote($row, $rubrica), 'attivo' => true, 'name_key' => $this->normalizeNameKey($nome, $cognome), ]; } private function resolvePersonaMatch(array $payload): ?object { $cf = $payload['codice_fiscale'] ?? null; if ($cf) { $found = DB::table('persone')->where('codice_fiscale', $cf)->orderBy('id')->first(); if ($found) { return $found; } } $piva = $payload['partita_iva'] ?? null; if ($piva) { $found = DB::table('persone') ->where(function ($query) use ($piva): void { $query->where('partita_iva', $piva) ->orWhere('partita_iva_societa', $piva); }) ->orderBy('id') ->first(); if ($found) { return $found; } } $telefono = $payload['telefono_principale'] ?? null; if ($telefono) { $found = DB::table('persone')->where('telefono_principale', $telefono)->orderBy('id')->first(); if ($found) { return $found; } } $email = $payload['email_principale'] ?? null; if ($email) { $found = DB::table('persone')->where('email_principale', $email)->orderBy('id')->first(); if ($found) { return $found; } } $nameKey = $payload['name_key'] ?? null; if ($nameKey) { return DB::table('persone') ->whereRaw("UPPER(CONCAT(TRIM(cognome), ' ', TRIM(nome))) = ?", [$nameKey]) ->orderBy('id') ->first(); } return null; } private function createPersonaFromPayload(array $payload): ?Persona { $data = [ 'tipologia' => $payload['tipologia'] ?? 'fisica', 'cognome' => $payload['cognome'], 'nome' => $payload['nome'], 'ragione_sociale' => $payload['ragione_sociale'], 'codice_fiscale' => $payload['codice_fiscale'], 'partita_iva' => $payload['partita_iva'], 'residenza_via' => $payload['residenza_via'], 'telefono_principale' => $payload['telefono_principale'], 'telefono_secondario' => $payload['telefono_secondario'], 'email_principale' => $payload['email_principale'], 'email_pec' => $payload['email_pec'], 'note' => $payload['note'], 'attivo' => $payload['attivo'] ?? true, ]; if ($data['telefono_principale'] && DB::table('persone')->where('telefono_principale', $data['telefono_principale'])->exists()) { $data['telefono_principale'] = null; } try { return Persona::query()->create($data); } catch (\Throwable $e) { $this->warn('Creazione persona fallita per nominativo ' . ($payload['ragione_sociale'] ?: trim($payload['cognome'] . ' ' . $payload['nome'])) . ': ' . $e->getMessage()); return null; } } private function fillPersonaMissingFields(object $persona, array $payload): bool { $updates = []; foreach ([ 'tipologia', 'cognome', 'nome', 'ragione_sociale', 'codice_fiscale', 'partita_iva', 'residenza_via', 'telefono_principale', 'telefono_secondario', 'email_principale', 'email_pec', ] as $field) { $value = $payload[$field] ?? null; if (! $value) { continue; } $current = $persona->{$field} ?? null; if (empty($current)) { if ($field === 'telefono_principale' && DB::table('persone')->where('telefono_principale', $value)->where('id', '!=', $persona->id)->exists()) { continue; } $updates[$field] = $value; } } if (! empty($updates)) { $updates['updated_at'] = now(); DB::table('persone')->where('id', $persona->id)->update($updates); return true; } return false; } private function findExistingRelationId(int $personaId, int $unitaId, string $tipoRelazione, string $dataInizio, ?string $dataFine): ?int { $query = DB::table('persone_unita_relazioni') ->where('persona_id', $personaId) ->where('unita_id', $unitaId) ->where('tipo_relazione', $tipoRelazione) ->where('data_inizio', $dataInizio); if ($dataFine === null) { $query->whereNull('data_fine'); } else { $query->where('data_fine', $dataFine); } $id = $query->orderBy('id')->value('id'); return $id ? (int) $id : null; } private function resolveTipoRelazione(object $row): string { $ruolo = strtoupper(trim((string) ($row->ruolo ?? 'C'))); if ($ruolo === 'I') { return 'inquilino'; } $payload = $this->decodeLegacyPayload($row->legacy_payload ?? null); $diritto = $this->normalizeRoleCustom((string) ($payload['diritto_reale'] ?? $payload['diritto_label'] ?? '')); if ($diritto !== null && in_array($diritto, ['comproprietario', 'nudo_proprietario', 'usufruttuario', 'titolare'], true)) { return $diritto; } $percentuale = $this->normalizePercentValue($row->percentuale ?? null); if (($row->fonte ?? null) === 'legacy_comproprietari' && $percentuale !== null && $percentuale < 100) { return 'comproprietario'; } return 'proprietario'; } private function deriveRuoloRate(string $tipoRelazione): string { return in_array($tipoRelazione, ['inquilino', 'locatario', 'conduttore'], true) ? 'I' : 'C'; } private function buildRelationNote(object $row, string $tipoRelazione): string { $parts = [ 'source=unita_immobiliare_nominativi', 'uin_id=' . (int) ($row->id ?? 0), 'fonte=' . trim((string) ($row->fonte ?? 'manuale')), 'cod_stabile=' . trim((string) ($row->codice_stabile ?? '')), 'cod_cond=' . trim((string) ($row->legacy_cond_id ?? '')), 'tipo=' . $tipoRelazione, ]; return implode(' | ', array_filter($parts)); } private function buildPersonaNote(object $row, ?RubricaUniversale $rubrica): string { $parts = [ 'Materializzato da unita_immobiliare_nominativi', 'uin_id=' . (int) ($row->id ?? 0), 'fonte=' . trim((string) ($row->fonte ?? 'manuale')), 'cod_stabile=' . trim((string) ($row->codice_stabile ?? '')), 'cod_cond=' . trim((string) ($row->legacy_cond_id ?? '')), ]; if ($rubrica) { $parts[] = 'rubrica_id=' . (int) $rubrica->id; } return implode(' | ', array_filter($parts)); } private function hasPersonaIdentity(array $payload): bool { foreach (['ragione_sociale', 'nome', 'cognome', 'codice_fiscale', 'partita_iva', 'email_principale', 'telefono_principale'] as $field) { if (! empty($payload[$field])) { return true; } } return false; } private function sanitizeString(?string $value, int $maxLength = 255): ?string { if ($value === null) { return null; } $value = trim($value); return $value === '' ? null : mb_substr($value, 0, $maxLength); } private function sanitizeEmail(?string $value): ?string { if ($value === null) { return null; } $value = strtolower(trim($value)); $value = preg_replace('/\s+/', '', $value); return $value === '' ? null : $value; } private function normalizePhone(?string $value): ?string { if ($value === null) { return null; } $normalized = preg_replace('/[^\d+]/', '', (string) $value); return $normalized === '' ? null : $normalized; } private function normalizeFiscalIds(?string $raw): array { $raw = strtoupper(trim((string) ($raw ?? ''))); $raw = preg_replace('/[^A-Z0-9]/', '', $raw); if ($raw === '') { return ['cf' => null, 'piva' => null]; } if (preg_match('/^\d+$/', $raw)) { $raw = strlen($raw) < 11 ? str_pad($raw, 11, '0', STR_PAD_LEFT) : $raw; if (strlen($raw) === 11) { return ['cf' => $raw, 'piva' => $raw]; } } return ['cf' => $raw, 'piva' => null]; } private function normalizeIdentityKey(mixed $value): ?string { if ($value === null) { return null; } $value = Str::upper(trim((string) $value)); $value = preg_replace('/[^A-Z0-9]+/u', '', $value); return $value !== '' ? $value : null; } private function normalizeNameKey(?string $nome, ?string $cognome): ?string { $full = trim(strtoupper(trim((string) ($cognome ?? '')) . ' ' . trim((string) ($nome ?? '')))); $full = preg_replace('/\s+/', ' ', $full); $full = preg_replace('/[^A-Z0-9 ]/', '', $full); return $full !== '' ? $full : null; } private function splitNomeCognomeOrRagione(?string $raw): array { $value = trim((string) ($raw ?? '')); if ($value === '') { return [null, null, null]; } if (preg_match('/\b(SRL|SPA|S\.R\.L\.|S\.P\.A\.|SAS|SNC|ASSOCIAZIONE|CONDOMINIO)\b/i', $value)) { return [null, null, mb_substr($value, 0, 255)]; } $parts = array_values(array_filter(preg_split('/\s+/', $value) ?: [])); if (count($parts) >= 2) { $nome = array_pop($parts); $cognome = implode(' ', $parts); return [mb_substr($cognome, 0, 100), mb_substr($nome, 0, 100), null]; } return [null, null, mb_substr($value, 0, 255)]; } private function normalizeDateToYmd(mixed $value): ?string { if ($value === null) { return null; } $value = trim((string) $value); if ($value === '') { return null; } if (preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $value)) { return $value; } $date = \DateTime::createFromFormat('Y-m-d H:i:s', $value) ?: \DateTime::createFromFormat('d/m/Y', $value) ?: \DateTime::createFromFormat('d/m/y', $value) ?: \DateTime::createFromFormat('m/d/Y', $value) ?: \DateTime::createFromFormat('m/d/y', $value) ?: \DateTime::createFromFormat('d/m/Y H:i:s', $value) ?: \DateTime::createFromFormat('m/d/Y H:i:s', $value); if ($date instanceof \DateTimeInterface) { return $date->format('Y-m-d'); } $timestamp = @strtotime($value); return $timestamp ? date('Y-m-d', $timestamp) : null; } private function normalizePercentValue(mixed $value): ?float { if ($value === null) { return null; } if (is_string($value)) { $value = str_replace(['%', ' '], '', trim($value)); $value = str_replace(',', '.', $value); } return is_numeric($value) ? (float) $value : null; } private function isRelationActive(mixed $dataFine): bool { $normalized = $this->normalizeDateToYmd($dataFine); return $normalized === null || $normalized >= now()->toDateString(); } private function normalizeRoleCustom(string $value): ?string { $value = strtolower(trim($value)); if ($value === '') { return null; } $value = str_replace(['à', 'è', 'é', 'ì', 'ò', 'ù'], ['a', 'e', 'e', 'i', 'o', 'u'], $value); $value = preg_replace('/[^a-z0-9_\s-]+/', '', $value) ?? $value; $value = str_replace([' ', '-'], '_', $value); $value = preg_replace('/_+/', '_', $value) ?? $value; $value = trim($value, '_'); return match ($value) { 'comproprietario', 'co_proprietario' => 'comproprietario', 'nudo_proprietario', 'nuda_proprieta' => 'nudo_proprietario', 'usufruttuario', 'usufrutto' => 'usufruttuario', 'titolare' => 'titolare', default => null, }; } private function decodeLegacyPayload(mixed $payload): array { if (is_array($payload)) { return $payload; } if (is_string($payload) && trim($payload) !== '') { $decoded = json_decode($payload, true); return is_array($decoded) ? $decoded : []; } return []; } }