netgescon-day0/app/Console/Commands/GesconMaterializePersoneRelazioniCommand.php

659 lines
24 KiB
PHP

<?php
namespace App\Console\Commands;
use App\Models\Persona;
use App\Models\RubricaUniversale;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
class GesconMaterializePersoneRelazioniCommand extends Command
{
protected $signature = 'gescon:materialize-persone-relazioni
{--stabile= : Codice stabile dominio/legacy da filtrare}
{--limit=10000 : Max righe nominativi da processare}
{--apply : Esegue realmente le scritture. Senza flag e dry-run}';
protected $description = 'Materializza persone e persone_unita_relazioni a partire da unita_immobiliare_nominativi.';
public function handle(): int
{
$apply = (bool) $this->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 [];
}
}