diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b28c1a..cb542d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ # Changelog ## [Unreleased] - Initial open-source release prep. +- Added Git-first staging update guidance in Supporto > Modifiche, centered on the `netgescon:git-sync` command and UI sync flow from Gitea. +- Added materialization of `persone` and `persone_unita_relazioni` from imported unit nominatives, plus a dedicated `relazioni` step in `gescon:import-full`. ## [0.1.0] - 2026-01-17 - Docker production compose and update workflow. diff --git a/app/Console/Commands/CodeQualityScan.php b/app/Console/Commands/CodeQualityScan.php index 5d58d97..42de259 100644 --- a/app/Console/Commands/CodeQualityScan.php +++ b/app/Console/Commands/CodeQualityScan.php @@ -1,18 +1,19 @@ option('path'); - $severity = $this->option('severity'); - $module = $this->option('module'); - $menu = $this->option('menu'); - $includeDataChecks = !$this->option('no-data-checks'); - $format = strtolower($this->option('format')); - $output = $this->option('output'); + $path = $this->option('path'); + $severity = $this->option('severity'); + $module = $this->option('module'); + $menu = $this->option('menu'); + $stabile = $this->option('stabile'); + $anno = $this->option('anno'); + $includeDataChecks = ! $this->option('no-data-checks'); + $format = strtolower($this->option('format')); + $output = $this->option('output'); /** @var CodeQualityController $controller */ $controller = app(CodeQualityController::class); // Usa l'endpoint export per generare CSV/JSON consistente con la UI $request = Request::create('/admin/code-quality/export', 'POST', [ - 'path' => $path, - 'severity' => $severity, - 'module' => $module, - 'menu' => $menu, + 'path' => $path, + 'severity' => $severity, + 'module' => $module, + 'menu' => $menu, + 'stabile' => $stabile, + 'anno' => $anno, 'include_data_checks' => $includeDataChecks ? 1 : 0, - 'format' => $format, + 'format' => $format, ]); $response = $controller->export($request); - $content = $response->getContent(); + $content = $response->getContent(); if ($output) { file_put_contents($output, $content); diff --git a/app/Console/Commands/CreateAmministratore.php b/app/Console/Commands/CreateAmministratore.php index 3495496..412dc77 100755 --- a/app/Console/Commands/CreateAmministratore.php +++ b/app/Console/Commands/CreateAmministratore.php @@ -1,21 +1,20 @@ info('🏗️ Creazione nuovo amministratore...'); // Raccolta dati - $nome = $this->argument('nome'); - $cognome = $this->argument('cognome'); - $email = $this->argument('email'); + $nome = $this->argument('nome'); + $cognome = $this->argument('cognome'); + $email = $this->argument('email'); $password = $this->option('password') ?: $this->generatePassword(); // Verifica email univoca @@ -72,19 +71,19 @@ public function handle() // 3. Crea record amministratore $this->info("🏢 Creazione record amministratore..."); $amministratore = Amministratore::create([ - 'user_id' => $user->id, - 'nome' => $nome, - 'cognome' => $cognome, + 'user_id' => $user->id, + 'nome' => $nome, + 'cognome' => $cognome, 'denominazione_studio' => $this->option('studio') ?: "Studio {$cognome}", - 'partita_iva' => $this->option('piva') ?: null, + 'partita_iva' => $this->option('piva') ?: null, 'codice_fiscale_studio' => $this->option('cf') ?: null, - 'telefono_studio' => $this->option('telefono') ?: null, - 'indirizzo_studio' => $this->option('indirizzo') ?: null, - 'cap_studio' => $this->option('cap') ?: null, - 'citta_studio' => $this->option('citta') ?: null, - 'provincia_studio' => $this->option('provincia') ?: null, - 'email_studio' => $email, - 'pec_studio' => $this->option('pec') ?: null, + 'telefono_studio' => $this->option('telefono') ?: null, + 'indirizzo_studio' => $this->option('indirizzo') ?: null, + 'cap_studio' => $this->option('cap') ?: null, + 'citta_studio' => $this->option('citta') ?: null, + 'provincia_studio' => $this->option('provincia') ?: null, + 'email_studio' => $email, + 'pec_studio' => $this->option('pec') ?: null, ]); // 4. Crea struttura cartelle @@ -138,8 +137,8 @@ private function generatePassword(): string */ private function createAdminFolders(Amministratore $amministratore): void { - $basePath = "amministratori/{$amministratore->codice_univoco}"; - + $basePath = app(TenantArchivePathService::class)->amministratoreRelativePath($amministratore); + $folders = [ 'documenti/allegati', 'documenti/contratti', @@ -178,16 +177,16 @@ private function createAdminFolders(Amministratore $amministratore): void private function createExampleStabile(Amministratore $amministratore): void { $denominazione = $this->ask('Nome del condominio', "Condominio {$amministratore->cognome}"); - + $stabile = Stabile::create([ 'amministratore_id' => $amministratore->id, - 'denominazione' => $denominazione, - 'indirizzo' => $this->ask('Indirizzo', 'Via Roma 1'), - 'cap' => $this->ask('CAP', '00100'), - 'citta' => $this->ask('Città', 'Roma'), - 'provincia' => $this->ask('Provincia', 'RM'), - 'codice_fiscale' => strtoupper(substr($denominazione, 0, 3)) . rand(100000, 999999), - 'stato' => 'attivo', + 'denominazione' => $denominazione, + 'indirizzo' => $this->ask('Indirizzo', 'Via Roma 1'), + 'cap' => $this->ask('CAP', '00100'), + 'citta' => $this->ask('Città', 'Roma'), + 'provincia' => $this->ask('Provincia', 'RM'), + 'codice_fiscale' => strtoupper(substr($denominazione, 0, 3)) . rand(100000, 999999), + 'stato' => 'attivo', ]); $this->info("🏢 Stabile creato: {$stabile->denominazione}"); diff --git a/app/Console/Commands/CreateAmministratoreStructure.php b/app/Console/Commands/CreateAmministratoreStructure.php index c3e1b54..37f82a5 100644 --- a/app/Console/Commands/CreateAmministratoreStructure.php +++ b/app/Console/Commands/CreateAmministratoreStructure.php @@ -1,29 +1,31 @@ dbManager = $dbManager; + $this->dbManager = $dbManager; + $this->tenantArchivePath = $tenantArchivePath; } public function handle(): int { $codice = $this->argument('codice'); - $force = $this->option('force'); + $force = $this->option('force'); $this->info("🚀 Creazione struttura per amministratore: {$codice}"); @@ -32,13 +34,13 @@ public function handle(): int ->orWhere('codice_univoco', $codice) ->first(); - if (!$amministratore) { + if (! $amministratore) { $this->error("❌ Amministratore {$codice} non trovato"); return 1; } // Verifica se struttura esiste già - if ($amministratore->hasDedicatedDatabase() && !$force) { + if ($amministratore->hasDedicatedDatabase() && ! $force) { $this->warn("⚠️ Struttura già esistente. Usa --force per ricreare"); return 0; } @@ -51,7 +53,7 @@ public function handle(): int $this->info("📦 Creazione database dedicato..."); $dbResult = $this->dbManager->createAmministratoreDatabase($amministratore); - if (!$dbResult['success']) { + if (! $dbResult['success']) { $this->error("❌ Errore creazione database: " . $dbResult['error']); return 1; } @@ -67,7 +69,7 @@ public function handle(): int private function createAdvancedFolderStructure(Amministratore $amministratore): void { - $basePath = "amministratori/{$amministratore->codice_amministratore}"; + $basePath = $this->tenantArchivePath->amministratoreRelativePath($amministratore); $folders = [ // Documenti studio @@ -124,47 +126,47 @@ private function createAdvancedFolderStructure(Amministratore $amministratore): private function createConfigurations(Amministratore $amministratore): void { - $basePath = "amministratori/{$amministratore->codice_amministratore}"; + $basePath = $this->tenantArchivePath->amministratoreRelativePath($amministratore); // 1. Configurazione principale $mainConfig = [ 'amministratore' => [ 'codice_amministratore' => $amministratore->codice_amministratore, - 'nome_completo' => $amministratore->nome_completo, - 'denominazione_studio' => $amministratore->denominazione_studio, - 'database_principale' => $amministratore->getDatabaseName(), - 'created_at' => $amministratore->created_at->toISOString(), - 'version' => '2.1.0', + 'nome_completo' => $amministratore->nome_completo, + 'denominazione_studio' => $amministratore->denominazione_studio, + 'database_principale' => $amministratore->getDatabaseName(), + 'created_at' => $amministratore->created_at->toISOString(), + 'version' => '2.1.0', ], - 'database' => [ - 'host' => env('DB_HOST', '127.0.0.1'), - 'port' => env('DB_PORT', '3306'), - 'database' => $amministratore->getDatabaseName(), - 'backup_path' => 'database/backups', + 'database' => [ + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => env('DB_PORT', '3306'), + 'database' => $amministratore->getDatabaseName(), + 'backup_path' => 'database/backups', 'retention_days' => 90, ], - 'backup' => [ - 'enabled' => true, - 'frequency' => 'daily', - 'time' => '02:00', - 'retention_days' => 30, - 'include_files' => true, + 'backup' => [ + 'enabled' => true, + 'frequency' => 'daily', + 'time' => '02:00', + 'retention_days' => 30, + 'include_files' => true, 'include_database' => true, - 'compress' => true, + 'compress' => true, ], - 'folders' => [ + 'folders' => [ 'documenti' => 'documenti', - 'database' => 'database', - 'stabili' => 'stabili', - 'reports' => 'reports', - 'temp' => 'temp', - 'archivio' => 'archivio', + 'database' => 'database', + 'stabili' => 'stabili', + 'reports' => 'reports', + 'temp' => 'temp', + 'archivio' => 'archivio', ], - 'security' => [ - 'log_level' => 'info', - 'max_file_size' => '50MB', + 'security' => [ + 'log_level' => 'info', + 'max_file_size' => '50MB', 'allowed_extensions' => ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'jpg', 'png'], - 'encryption' => false, + 'encryption' => false, ], ]; @@ -201,7 +203,7 @@ private function generateAdminReadme(Amministratore $amministratore): string ### 🏗️ Documenti Studio - `documenti/studio/contratti/` - Contratti di amministrazione -- `documenti/studio/certificazioni/` - Certificazioni professionali +- `documenti/studio/certificazioni/` - Certificazioni professionali - `documenti/studio/assicurazioni/` - Polizze assicurative - `documenti/studio/comunicazioni/` - Comunicazioni ufficiali - `documenti/studio/fatturazione/` - Fatture emesse @@ -213,7 +215,7 @@ private function generateAdminReadme(Amministratore $amministratore): string - `database/exports/` - Esportazioni dati - `database/migrations/` - Script migrazione - `database/scripts/` - Script personalizzati - + - `stabili/[codice_stabile]/` - Cartella per ogni stabile - Ogni stabile ha database e cartelle dedicati - Backup indipendenti per ogni stabile @@ -314,8 +316,8 @@ private function generateAdminReadme(Amministratore $amministratore): string --- -*Struttura generata automaticamente da NetGescon v2.1.0* -*Ultimo aggiornamento: " . date('d/m/Y H:i') . "* +*Struttura generata automaticamente da NetGescon v2.1.0* +*Ultimo aggiornamento: " . date('d/m/Y H:i') . "* *Amministratore: {$amministratore->nome_completo}* "; } diff --git a/app/Console/Commands/CreateStabileStructure.php b/app/Console/Commands/CreateStabileStructure.php index a1e2ffa..cda7dc3 100644 --- a/app/Console/Commands/CreateStabileStructure.php +++ b/app/Console/Commands/CreateStabileStructure.php @@ -1,50 +1,54 @@ dbManager = $dbManager; + $this->dbManager = $dbManager; + $this->tenantArchivePath = $tenantArchivePath; } public function handle(): int { - $adminCode = $this->argument('admin_code'); + $adminCode = $this->argument('admin_code'); $stabileCode = $this->argument('stabile_code'); - $force = $this->option('force'); + $force = $this->option('force'); $this->info("🏗️ Creazione struttura stabile: {$stabileCode} (Admin: {$adminCode})"); // Trova amministratore - $amministratore = Amministratore::where('codice_univoco', $adminCode)->first(); - if (!$amministratore) { + $amministratore = Amministratore::where('codice_amministratore', $adminCode) + ->orWhere('codice_univoco', $adminCode) + ->first(); + if (! $amministratore) { $this->error("❌ Amministratore {$adminCode} non trovato"); return 1; } // Trova stabile $stabile = $amministratore->stabili()->where('codice_stabile', $stabileCode)->first(); - if (!$stabile) { + if (! $stabile) { $this->error("❌ Stabile {$stabileCode} non trovato per amministratore {$adminCode}"); return 1; } // Verifica se struttura esiste già - if ($stabile->database_dedicato && !$force) { + if ($stabile->database_dedicato && ! $force) { $this->warn("⚠️ Struttura stabile già esistente. Usa --force per ricreare"); return 0; } @@ -53,7 +57,7 @@ public function handle(): int $this->info("💾 Creazione database dedicato stabile..."); $dbResult = $this->dbManager->createStabileDatabase($stabile); - if (!$dbResult['success']) { + if (! $dbResult['success']) { $this->error("❌ Errore creazione database: " . $dbResult['error']); return 1; } @@ -79,9 +83,7 @@ public function handle(): int private function createDetailedStabileStructure(Stabile $stabile): void { - $adminCode = $stabile->amministratore->codice_univoco; - $stabileCode = $stabile->codice_stabile; - $basePath = "amministratori/{$adminCode}/stabili/{$stabileCode}"; + $basePath = $this->tenantArchivePath->stabileRelativePath($stabile); $folders = [ // Documenti amministrativi @@ -180,9 +182,9 @@ private function createDetailedStabileStructure(Stabile $stabile): void // File informativi per cartelle principali $infoFiles = [ - 'documenti/README.md' => $this->generateDocumentiReadme($stabile), - 'contabilita/README.md' => $this->generateContabilitaReadme($stabile), - 'anagrafica/README.md' => $this->generateAnagraficaReadme($stabile), + 'documenti/README.md' => $this->generateDocumentiReadme($stabile), + 'contabilita/README.md' => $this->generateContabilitaReadme($stabile), + 'anagrafica/README.md' => $this->generateAnagraficaReadme($stabile), 'manutenzioni/README.md' => $this->generateManutenzioniReadme($stabile), ]; @@ -193,71 +195,69 @@ private function createDetailedStabileStructure(Stabile $stabile): void private function createStabileConfigurations(Stabile $stabile): void { - $adminCode = $stabile->amministratore->codice_univoco; - $stabileCode = $stabile->codice_stabile; - $basePath = "amministratori/{$adminCode}/stabili/{$stabileCode}"; + $basePath = $this->tenantArchivePath->stabileRelativePath($stabile); // 1. Configurazione principale stabile $config = [ - 'stabile' => [ - 'codice' => $stabile->codice_stabile, - 'denominazione' => $stabile->denominazione, + 'stabile' => [ + 'codice' => $stabile->codice_stabile, + 'denominazione' => $stabile->denominazione, 'indirizzo_completo' => $stabile->indirizzo . ', ' . $stabile->cap . ' ' . $stabile->citta . ' (' . $stabile->provincia . ')', - 'amministratore' => [ - 'codice' => $stabile->amministratore->codice_univoco, + 'amministratore' => [ + 'codice' => $stabile->amministratore->codice_univoco, 'nome_completo' => $stabile->amministratore->nome_completo, - 'studio' => $stabile->amministratore->denominazione_studio, + 'studio' => $stabile->amministratore->denominazione_studio, ], - 'database_dedicato' => $stabile->database_dedicato, - 'created_at' => $stabile->created_at->toISOString(), - 'version' => '2.1.0', + 'database_dedicato' => $stabile->database_dedicato, + 'created_at' => $stabile->created_at->toISOString(), + 'version' => '2.1.0', ], - 'database' => [ - 'host' => env('DB_HOST', '127.0.0.1'), - 'port' => env('DB_PORT', '3306'), - 'database' => $stabile->database_dedicato, - 'backup_path' => 'database/backups', + 'database' => [ + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => env('DB_PORT', '3306'), + 'database' => $stabile->database_dedicato, + 'backup_path' => 'database/backups', 'retention_days' => 90, ], - 'backup' => [ - 'enabled' => true, - 'frequency' => 'daily', - 'time' => '03:00', - 'retention_days' => 90, - 'include_files' => true, + 'backup' => [ + 'enabled' => true, + 'frequency' => 'daily', + 'time' => '03:00', + 'retention_days' => 90, + 'include_files' => true, 'include_database' => true, - 'compress' => true, + 'compress' => true, ], - 'folders' => [ - 'documenti' => 'documenti', - 'contabilita' => 'contabilita', - 'anagrafica' => 'anagrafica', - 'manutenzioni' => 'manutenzioni', - 'database' => 'database', - 'reports' => 'reports', + 'folders' => [ + 'documenti' => 'documenti', + 'contabilita' => 'contabilita', + 'anagrafica' => 'anagrafica', + 'manutenzioni' => 'manutenzioni', + 'database' => 'database', + 'reports' => 'reports', 'corrispondenza' => 'corrispondenza', - 'archivio' => 'archivio', - 'temp' => 'temp', - 'logs' => 'logs', + 'archivio' => 'archivio', + 'temp' => 'temp', + 'logs' => 'logs', ], 'contabilita' => [ - 'anno_contabile' => date('Y'), - 'moneta' => 'EUR', - 'arrotondamento' => 2, - 'iva_default' => 22, + 'anno_contabile' => date('Y'), + 'moneta' => 'EUR', + 'arrotondamento' => 2, + 'iva_default' => 22, 'millesimi_totali' => 1000, ], - 'anagrafica' => [ - 'validazione_cf' => true, + 'anagrafica' => [ + 'validazione_cf' => true, 'richiedi_documenti' => true, - 'privacy_gdpr' => true, + 'privacy_gdpr' => true, ], - 'reports' => [ - 'automatici' => true, + 'reports' => [ + 'automatici' => true, 'frequenza_mensili' => true, - 'email_notifiche' => true, - 'formato_default' => 'pdf', - ] + 'email_notifiche' => true, + 'formato_default' => 'pdf', + ], ]; Storage::disk('local')->put( @@ -287,14 +287,14 @@ private function populateStabileDatabase(Stabile $stabile): void $connection->table('stabili_ref')->updateOrInsert( ['codice_stabile' => $stabile->codice_stabile], [ - 'denominazione' => $stabile->denominazione, - 'indirizzo' => $stabile->indirizzo, - 'cap' => $stabile->cap, - 'citta' => $stabile->citta, - 'provincia' => $stabile->provincia, + 'denominazione' => $stabile->denominazione, + 'indirizzo' => $stabile->indirizzo, + 'cap' => $stabile->cap, + 'citta' => $stabile->citta, + 'provincia' => $stabile->provincia, 'amministratore_codice' => $stabile->amministratore->codice_univoco, - 'updated_at' => now(), - 'created_at' => now(), + 'updated_at' => now(), + 'created_at' => now(), ] ); @@ -425,8 +425,8 @@ private function generateStabileReadme(Stabile $stabile): string --- -*Struttura generata automaticamente da NetGescon v2.1.0* -*Stabile: {$stabile->denominazione}* +*Struttura generata automaticamente da NetGescon v2.1.0* +*Stabile: {$stabile->denominazione}* *Amministratore: {$stabile->amministratore->nome_completo}* "; } @@ -487,7 +487,7 @@ private function generateContabilitaReadme(Stabile $stabile): string ### 💸 Uscite - **ordinarie/** - Spese ordinarie -- **straordinarie/** - Spese straordinarie +- **straordinarie/** - Spese straordinarie - **utenze/** - Bollette utenze ### 🧾 Ricevute @@ -591,7 +591,7 @@ private function generateManutenzioniReadme(Stabile $stabile): string private function generateStabileMaintenanceScript(Stabile $stabile): string { - $adminCode = $stabile->amministratore->codice_univoco; + $adminCode = $stabile->amministratore->codice_univoco; $stabileCode = $stabile->codice_stabile; return "#!/bin/bash @@ -611,7 +611,7 @@ private function generateStabileMaintenanceScript(Stabile $stabile): string find \"\$BASE_PATH/temp\" -type f -mtime +3 -delete 2>/dev/null find \"\$BASE_PATH/temp\" -type d -empty -delete 2>/dev/null -# Pulizia log vecchi (> 180 giorni) +# Pulizia log vecchi (> 180 giorni) echo \"📝 Pulizia log vecchi...\" | tee -a \$LOG_FILE find \"\$BASE_PATH/logs\" -name \"*.log\" -mtime +180 -delete 2>/dev/null diff --git a/app/Console/Commands/GesconAutoSyncAnagrafiche.php b/app/Console/Commands/GesconAutoSyncAnagrafiche.php index 8eec05c..b61102c 100644 --- a/app/Console/Commands/GesconAutoSyncAnagrafiche.php +++ b/app/Console/Commands/GesconAutoSyncAnagrafiche.php @@ -970,6 +970,28 @@ private function findExistingRubrica(int $amministratoreId, array $data): ?Rubri if ($found) { return $found; } + + $orphan = RubricaUniversale::query() + ->whereNull('amministratore_id') + ->where(function ($q) use ($cf, $piva) { + if ($cf) { + $q->orWhere('codice_fiscale', $cf); + if (preg_match('/^0+\d+$/', $cf)) { + $q->orWhere('codice_fiscale', ltrim($cf, '0')); + } + } + if ($piva) { + $q->orWhere('partita_iva', $piva); + if (preg_match('/^0+\d+$/', $piva)) { + $q->orWhere('partita_iva', ltrim($piva, '0')); + } + } + }) + ->orderBy('id') + ->first(); + if ($orphan) { + return $orphan; + } } $email = $this->normalizeEmail($data['email'] ?? null); @@ -981,6 +1003,15 @@ private function findExistingRubrica(int $amministratoreId, array $data): ?Rubri if ($found) { return $found; } + + $orphan = RubricaUniversale::query() + ->whereNull('amministratore_id') + ->where('email', $email) + ->orderBy('id') + ->first(); + if ($orphan) { + return $orphan; + } } $telefono = $this->normalizePhone($data['telefono_ufficio'] ?? null); @@ -1000,6 +1031,21 @@ private function findExistingRubrica(int $amministratoreId, array $data): ?Rubri if ($found) { return $found; } + + $orphan = RubricaUniversale::query() + ->whereNull('amministratore_id') + ->where(function ($q) use ($phones) { + foreach ($phones as $p) { + $q->orWhere('telefono_ufficio', $p) + ->orWhere('telefono_cellulare', $p) + ->orWhere('telefono_casa', $p); + } + }) + ->orderBy('id') + ->first(); + if ($orphan) { + return $orphan; + } } return null; diff --git a/app/Console/Commands/GesconBackfillRubricaLegacyIdentityCommand.php b/app/Console/Commands/GesconBackfillRubricaLegacyIdentityCommand.php new file mode 100644 index 0000000..9b87781 --- /dev/null +++ b/app/Console/Commands/GesconBackfillRubricaLegacyIdentityCommand.php @@ -0,0 +1,239 @@ +error('Tabella rubrica_universale non disponibile.'); + return self::FAILURE; + } + + $apply = (bool) $this->option('apply'); + $limit = max(1, min((int) $this->option('limit'), 50000)); + $ids = array_values(array_filter(array_map(fn($value) => is_numeric($value) ? (int) $value : 0, (array) $this->option('id')))); + + $query = RubricaUniversale::query() + ->where('note', 'like', '%Dati anagrafici legacy:%') + ->orderBy('id') + ->limit($limit); + + if ($ids !== []) { + $query->whereIn('id', $ids); + } + + $rows = $query->get(); + if ($rows->isEmpty()) { + $this->info('Nessun record da bonificare.'); + return self::SUCCESS; + } + + $processed = 0; + $updated = 0; + + foreach ($rows as $rubrica) { + $processed++; + $legacy = $this->extractLegacyIdentityFromNote($rubrica->note); + $changes = []; + + if (! $rubrica->titolo_id && ! empty($legacy['titolo_id'])) { + $changes['titolo_id'] = (int) $legacy['titolo_id']; + } + if (! $rubrica->sesso && ! empty($legacy['sesso'])) { + $changes['sesso'] = $legacy['sesso']; + } + if ((! $rubrica->data_nascita || (int) optional($rubrica->data_nascita)->format('Y') > (int) now()->format('Y') + 1) && ! empty($legacy['data_nascita'])) { + $changes['data_nascita'] = $legacy['data_nascita']; + } + if (! $rubrica->luogo_nascita && ! empty($legacy['luogo_nascita'])) { + $changes['luogo_nascita'] = $legacy['luogo_nascita']; + } + if (! $rubrica->provincia_nascita && ! empty($legacy['provincia_nascita'])) { + $changes['provincia_nascita'] = $legacy['provincia_nascita']; + } + + $cleanNote = $legacy['note_clean'] ?? null; + if (($rubrica->note ?? null) !== $cleanNote) { + $changes['note'] = $cleanNote; + } + + if ($changes === []) { + continue; + } + + $updated++; + + if ($apply) { + $rubrica->fill($changes); + $rubrica->save(); + } + } + + $this->info(($apply ? 'Bonifica eseguita' : 'Dry-run bonifica') . ": processati {$processed}, aggiornabili {$updated}"); + + return self::SUCCESS; + } + + /** @return array */ + private function extractLegacyIdentityFromNote(mixed $note): array + { + $raw = trim((string) ($note ?? '')); + if ($raw === '') { + return [ + 'note_clean' => null, + 'titolo_id' => null, + 'sesso' => null, + 'data_nascita' => null, + 'luogo_nascita' => null, + 'provincia_nascita' => null, + ]; + } + + $parsed = [ + 'titolo_id' => null, + 'sesso' => null, + 'data_nascita' => null, + 'luogo_nascita' => null, + 'provincia_nascita' => null, + ]; + + $cleanLines = []; + foreach (preg_split('/\r\n|\r|\n/', $raw) ?: [] as $line) { + $trimmed = trim($line); + if (! str_starts_with($trimmed, 'Dati anagrafici legacy:')) { + if ($trimmed !== '') { + $cleanLines[] = $line; + } + continue; + } + + $payload = trim(substr($trimmed, strlen('Dati anagrafici legacy:'))); + $bits = preg_split('/\s*\|\s*/', $payload) ?: []; + foreach ($bits as $bit) { + [$key, $value] = array_pad(explode(':', $bit, 2), 2, null); + $key = mb_strtolower(trim((string) $key)); + $value = trim((string) $value); + if ($value === '') { + continue; + } + + if ($key === 'titolo') { + $parsed['titolo_id'] = $this->resolveTitoloId($value); + continue; + } + if ($key === 'sesso') { + $parsed['sesso'] = $this->normalizeSessoValue($value); + continue; + } + if ($key === 'data nascita') { + $parsed['data_nascita'] = $this->normalizeLegacyDateValue($value); + continue; + } + if ($key === 'luogo nascita') { + $parsed['luogo_nascita'] = $value; + continue; + } + if ($key === 'provincia nascita') { + $parsed['provincia_nascita'] = $this->normalizeProvince($value); + } + } + } + + return $parsed + [ + 'note_clean' => $cleanLines === [] ? null : implode("\n", $cleanLines), + ]; + } + + private function normalizeLegacyDateValue(mixed $value): ?string + { + $raw = trim((string) ($value ?? '')); + if ($raw === '') { + return null; + } + + foreach (['Y-m-d', 'd/m/Y', 'd-m-Y', 'm/d/Y', 'd.m.Y', 'd/m/y', 'd-m-y', 'm/d/y', 'd.m.y'] as $format) { + $dt = \DateTime::createFromFormat($format, $raw); + if ($dt instanceof \DateTime) { + return $this->normalizeBirthDateYear($dt); + } + } + + $timestamp = strtotime($raw); + if ($timestamp === false) { + return null; + } + + return $this->normalizeBirthDateYear((new \DateTime())->setTimestamp($timestamp)); + } + + private function normalizeBirthDateYear(\DateTime $date): ?string + { + $year = (int) $date->format('Y'); + $currentYear = (int) now()->format('Y'); + + if ($year > $currentYear + 1) { + $date->modify('-100 years'); + $year = (int) $date->format('Y'); + } + + if ($year < 1900 || $year > $currentYear + 1) { + return null; + } + + return $date->format('Y-m-d'); + } + + private function normalizeSessoValue(mixed $value): ?string + { + $raw = strtoupper(trim((string) ($value ?? ''))); + + return match (true) { + $raw === 'M', str_starts_with($raw, 'MAS') => 'M', + $raw === 'F', str_starts_with($raw, 'FEM') => 'F', + default => null, + }; + } + + private function normalizeProvince(mixed $value): ?string + { + $raw = strtoupper(trim((string) ($value ?? ''))); + if ($raw === '') { + return null; + } + + return mb_substr($raw, 0, 2); + } + + private function resolveTitoloId(string $value): ?int + { + if (! Schema::hasTable('titoli')) { + return null; + } + + $key = mb_strtolower(trim($value)); + if ($key === '') { + return null; + } + + $titolo = Titolo::query() + ->whereRaw('LOWER(sigla) = ?', [$key]) + ->orWhereRaw('LOWER(nome) = ?', [$key]) + ->first(); + + return $titolo?->id ? (int) $titolo->id : null; + } +} diff --git a/app/Console/Commands/GesconImportFornitoriLegacyMdb.php b/app/Console/Commands/GesconImportFornitoriLegacyMdb.php index 8b3a76b..5f15d93 100644 --- a/app/Console/Commands/GesconImportFornitoriLegacyMdb.php +++ b/app/Console/Commands/GesconImportFornitoriLegacyMdb.php @@ -147,10 +147,6 @@ public function handle(): int $legacyLuoNas ? ('Luogo nascita: ' . $legacyLuoNas) : null, $legacyPrNas ? ('Provincia nascita: ' . $legacyPrNas) : null, ])); - if ($legacyIdentityBits !== []) { - $block = 'Dati anagrafici legacy: ' . implode(' | ', $legacyIdentityBits); - $note = $note ? ($note . "\n" . $block) : $block; - } $piva = $this->clean($row['p_iva'] ?? null); $cf = $this->clean($row['cod_fisc'] ?? null); @@ -167,6 +163,10 @@ public function handle(): int 'cognome' => $cognome, 'partita_iva' => $piva, 'codice_fiscale' => $cf, + 'sesso' => $legacySesso, + 'data_nascita' => $legacyDtNas, + 'luogo_nascita' => $legacyLuoNas, + 'provincia_nascita' => $legacyPrNas, 'indirizzo' => $this->clean($row['indirizzo'] ?? null), 'cap' => $this->clean($row['cap'] ?? null), 'citta' => $this->clean($row['citta'] ?? null), @@ -628,21 +628,38 @@ private function normalizeLegacyDate(mixed $value): ?string } $raw = trim($raw); - foreach (['d/m/Y', 'd-m-Y', 'Y-m-d', 'm/d/Y', 'd.m.Y'] as $format) { + foreach (['d/m/Y', 'd-m-Y', 'Y-m-d', 'm/d/Y', 'd.m.Y', 'd/m/y', 'd-m-y', 'm/d/y', 'd.m.y'] as $format) { $dt = \DateTime::createFromFormat($format, $raw); if ($dt instanceof \DateTime) { - return $dt->format('Y-m-d'); + return $this->normalizeBirthDateYear($dt); } } $ts = strtotime($raw); if ($ts !== false) { - return date('Y-m-d', $ts); + return $this->normalizeBirthDateYear((new \DateTime())->setTimestamp($ts)); } return null; } + private function normalizeBirthDateYear(\DateTime $date): ?string + { + $year = (int) $date->format('Y'); + $currentYear = (int) now()->format('Y'); + + if ($year > $currentYear + 1) { + $date->modify('-100 years'); + $year = (int) $date->format('Y'); + } + + if ($year < 1900 || $year > $currentYear + 1) { + return null; + } + + return $date->format('Y-m-d'); + } + private function normalizeSesso(mixed $value): ?string { $raw = strtoupper((string) $this->clean($value)); diff --git a/app/Console/Commands/GesconMaterializePersoneRelazioniCommand.php b/app/Console/Commands/GesconMaterializePersoneRelazioniCommand.php new file mode 100644 index 0000000..1843fee --- /dev/null +++ b/app/Console/Commands/GesconMaterializePersoneRelazioniCommand.php @@ -0,0 +1,659 @@ +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 []; + } +} \ No newline at end of file diff --git a/app/Console/Commands/GesconSyncRubricaRuoli.php b/app/Console/Commands/GesconSyncRubricaRuoli.php index 8859dae..38ba190 100644 --- a/app/Console/Commands/GesconSyncRubricaRuoli.php +++ b/app/Console/Commands/GesconSyncRubricaRuoli.php @@ -5,6 +5,7 @@ use Illuminate\Console\Command; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; +use Illuminate\Database\Eloquent\Builder; use App\Models\Persona; use App\Models\PersonaUnitaRelazione; use App\Models\RubricaRuolo; @@ -105,24 +106,58 @@ protected function trovaRubricaPerPersona(Persona $persona, $amministratoreId = return null; } - $cf = $persona->codice_fiscale; + $query = $this->rubricaScopedQuery($amministratoreId); + + $cf = trim((string) ($persona->codice_fiscale ?? '')); if ($cf) { - $found = RubricaUniversale::where('codice_fiscale', $cf)->first(); + $found = (clone $query)->where('codice_fiscale', $cf)->first(); if ($found) return $found; } - $email = $persona->email_principale; - if ($email) { - $found = RubricaUniversale::where('email', $email)->first(); + $piva = trim((string) ($persona->partita_iva ?? '')); + if ($piva) { + $found = (clone $query)->where('partita_iva', $piva)->first(); if ($found) return $found; } - $phone = $persona->telefono_principale; - if ($phone) { - $found = RubricaUniversale::where(function ($q) use ($phone) { - $q->where('telefono_ufficio', $phone)->orWhere('telefono_cellulare', $phone); + foreach ($this->collectPersonaEmails($persona) as $email) { + $found = (clone $query)->whereRaw('LOWER(email) = ?', [$email])->first(); + if ($found) { + return $found; + } + } + + foreach ($this->collectPersonaPhones($persona) as $phone) { + $found = (clone $query)->where(function ($q) use ($phone) { + $q->where('telefono_ufficio', $phone) + ->orWhere('telefono_cellulare', $phone) + ->orWhere('telefono_casa', $phone); })->first(); - if ($found) return $found; + if ($found) { + return $found; + } + } + + $ragioneSociale = trim((string) ($persona->ragione_sociale ?? '')); + if ($ragioneSociale !== '') { + $found = (clone $query) + ->whereRaw('LOWER(ragione_sociale) = ?', [mb_strtolower($ragioneSociale)]) + ->first(); + if ($found) { + return $found; + } + } + + $nome = trim((string) ($persona->nome ?? '')); + $cognome = trim((string) ($persona->cognome ?? '')); + if ($nome !== '' && $cognome !== '') { + $found = (clone $query) + ->whereRaw('LOWER(nome) = ?', [mb_strtolower($nome)]) + ->whereRaw('LOWER(cognome) = ?', [mb_strtolower($cognome)]) + ->first(); + if ($found) { + return $found; + } } if (!$createIfMissing) { @@ -152,6 +187,59 @@ protected function trovaRubricaPerPersona(Persona $persona, $amministratoreId = ]); } + private function rubricaScopedQuery($amministratoreId = null): Builder + { + $query = RubricaUniversale::query(); + + if (! Schema::hasColumn('rubrica_universale', 'amministratore_id') || $amministratoreId === null) { + return $query; + } + + return $query + ->where(function ($inner) use ($amministratoreId) { + $inner->where('amministratore_id', (int) $amministratoreId) + ->orWhereNull('amministratore_id'); + }) + ->orderByRaw('CASE WHEN amministratore_id = ? THEN 0 WHEN amministratore_id IS NULL THEN 1 ELSE 2 END', [(int) $amministratoreId]); + } + + /** @return array */ + private function collectPersonaEmails(Persona $persona): array + { + $emails = []; + + $primary = trim((string) ($persona->email_principale ?? '')); + if ($primary !== '') { + $emails[] = mb_strtolower($primary); + } + + if (Schema::hasTable('persone_email_multiple')) { + foreach ($persona->emailMultiple()->pluck('email')->all() as $email) { + $candidate = mb_strtolower(trim((string) $email)); + if ($candidate !== '') { + $emails[] = $candidate; + } + } + } + + return array_values(array_unique($emails)); + } + + /** @return array */ + private function collectPersonaPhones(Persona $persona): array + { + $phones = []; + + foreach ([$persona->telefono_principale, $persona->telefono_secondario] as $value) { + $candidate = trim((string) $value); + if ($candidate !== '') { + $phones[] = $candidate; + } + } + + return array_values(array_unique($phones)); + } + private function personaHasRubricaIdentity(Persona $persona): bool { $vals = [ diff --git a/app/Console/Commands/ImportGesconFullPipeline.php b/app/Console/Commands/ImportGesconFullPipeline.php index 63e0e5b..18b45f4 100644 --- a/app/Console/Commands/ImportGesconFullPipeline.php +++ b/app/Console/Commands/ImportGesconFullPipeline.php @@ -24,7 +24,7 @@ class ImportGesconFullPipeline extends Command {--dry-run : Simula senza scrivere sulle tabelle dominio} {--limit= : Limita numero record per debug} {--reset-temp : Svuota staging prima di importare} - {--solo= : Step singolo: unita|soggetti|diritti|millesimi|voci|operazioni|rate|incassi|giroconti|straord|addebiti|detrazioni|tutto} + {--solo= : Step singolo: unita|relazioni|soggetti|diritti|millesimi|voci|operazioni|rate|incassi|giroconti|straord|addebiti|detrazioni|tutto} {--no-views : Non ricrea le viste QA} {--force-mapping : Applica mapping anche sovrascrivendo valori esistenti} {--amministratore-id= : Forza l\'assegnazione dello stabile a questo amministratore} @@ -35,6 +35,7 @@ class ImportGesconFullPipeline extends Command private array $stepsOrder = [ 'stabili', 'unita', + 'relazioni', 'soggetti', 'diritti', 'millesimi', @@ -104,6 +105,38 @@ private function scopeCondominQuery($query) // Legacy row cache per stabili (usata da mapField migliorata) private ?array $stabileLegacyRow = null; + private function stepRelazioni(?int $limit = null): int + { + $params = [ + '--stabile' => (string) $this->option('stabile'), + '--limit' => $limit ?? 10000, + ]; + + if (! $this->option('dry-run')) { + $params['--apply'] = true; + } + + $exit = $this->call('gescon:materialize-persone-relazioni', $params); + if ($exit !== 0) { + throw new \RuntimeException('Materializzazione persone/relazioni non riuscita.'); + } + + return (int) 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') + ->when($this->option('stabile'), function ($query): void { + $stabile = trim((string) $this->option('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); + } + }); + }) + ->count(); + } + public function handle(): int { if (! $this->option('stabile')) { @@ -4966,25 +4999,166 @@ private function buildRoleHistoryPayloads(array $historyRows, string $role, ?str return $payloads; } + private function loadLegacyComproprietariHistoryRows(object $row, ?string $legacyCondId): array + { + if (! Schema::connection('gescon_import')->hasTable('comproprietari')) { + return []; + } + + $codStabile = trim((string) ($row->cod_stabile ?? '')); + $idCondRaw = trim((string) ($legacyCondId ?? $row->cod_cond ?? '')); + + if ($codStabile === '' || $idCondRaw === '') { + return []; + } + + $query = DB::connection('gescon_import') + ->table('comproprietari') + ->where('cod_stabile', $codStabile) + ->orderByDesc('legacy_year') + ->orderByDesc('id'); + + if (is_numeric($idCondRaw)) { + $query->where('id_cond', (int) $idCondRaw); + } else { + $query->where('id_cond', $idCondRaw); + } + + $historyRows = $query->get()->all(); + if ($historyRows === []) { + return []; + } + + $byYear = []; + foreach ($historyRows as $historyRow) { + $snapshotYear = $this->resolveLegacySnapshotYear(isset($historyRow->legacy_year) ? (string) $historyRow->legacy_year : null, $historyRow); + $bucket = $snapshotYear ? (string) $snapshotYear : ('legacy:' . (string) ($historyRow->legacy_year ?? '0')); + $nameKey = $this->normalizeLegacyPartyName($historyRow->nom_cond ?? null); + $rightKey = $this->normalizeLegacyPartyName($historyRow->diritto_reale ?? $historyRow->descriz ?? null); + $groupKey = $bucket . '|' . $nameKey . '|' . $rightKey; + if (! isset($byYear[$groupKey])) { + $byYear[$groupKey] = $historyRow; + } + } + + $historyRows = array_values($byYear); + usort($historyRows, function (object $a, object $b): int { + $yearA = $this->resolveLegacySnapshotYear(isset($a->legacy_year) ? (string) $a->legacy_year : null, $a) ?? 0; + $yearB = $this->resolveLegacySnapshotYear(isset($b->legacy_year) ? (string) $b->legacy_year : null, $b) ?? 0; + if ($yearA !== $yearB) { + return $yearB <=> $yearA; + } + + return ((int) ($b->id ?? 0)) <=> ((int) ($a->id ?? 0)); + }); + + return $historyRows; + } + + private function buildComproprietariHistoryPayloads(array $historyRows, ?string $defaultLegacyCondId = null): array + { + if ($historyRows === []) { + return []; + } + + $snapshots = []; + foreach ($historyRows as $historyRow) { + $legacyYear = isset($historyRow->legacy_year) ? trim((string) $historyRow->legacy_year) : null; + $snapshotYear = $this->resolveLegacySnapshotYear($legacyYear, $historyRow); + $name = trim((string) ($historyRow->nom_cond ?? '')); + $rightCode = trim((string) ($historyRow->diritto_reale ?? '')); + $rightLabel = trim((string) ($historyRow->descriz ?? '')) ?: ($rightCode !== '' ? $rightCode : 'Comproprietario'); + $percentuale = isset($historyRow->perc_diritto_reale) && is_numeric($historyRow->perc_diritto_reale) + ? (float) $historyRow->perc_diritto_reale + : null; + + if ($name === '') { + continue; + } + + $snapshots[] = [ + 'ruolo' => 'C', + 'legacy_year' => $legacyYear, + 'snapshot_year' => $snapshotYear, + 'legacy_cond_id' => trim((string) ($historyRow->id_cond ?? $defaultLegacyCondId ?? '')) ?: $defaultLegacyCondId, + 'nominativo' => $name, + 'data_inizio' => $this->startOfYear($snapshotYear), + 'data_fine' => null, + 'percentuale' => $percentuale, + 'diritto_reale' => $rightCode !== '' ? $rightCode : null, + 'diritto_label' => $rightLabel, + 'id_compr' => isset($historyRow->id_compr) ? (int) $historyRow->id_compr : null, + ]; + } + + if ($snapshots === []) { + return []; + } + + $payloads = []; + $current = null; + foreach ($snapshots as $snapshot) { + $snapshotNameKey = $this->normalizeLegacyPartyName($snapshot['nominativo'] ?? null); + $snapshotRightKey = $this->normalizeLegacyPartyName($snapshot['diritto_reale'] ?? $snapshot['diritto_label'] ?? null); + $snapshotStart = $snapshot['data_inizio'] ?? $this->startOfYear($snapshot['snapshot_year']); + + if ($current === null) { + $current = $snapshot + [ + 'legacy_years' => array_values(array_filter([$snapshot['legacy_year']])), + ]; + continue; + } + + $currentNameKey = $this->normalizeLegacyPartyName($current['nominativo'] ?? null); + $currentRightKey = $this->normalizeLegacyPartyName($current['diritto_reale'] ?? $current['diritto_label'] ?? null); + $sameName = $snapshotNameKey !== '' && $snapshotNameKey === $currentNameKey; + $sameRight = $snapshotRightKey === $currentRightKey; + $samePercent = $snapshot['percentuale'] === null || $current['percentuale'] === null + || abs((float) $snapshot['percentuale'] - (float) $current['percentuale']) < 0.0005; + + if ($sameName && $sameRight && $samePercent) { + $current['data_inizio'] = $snapshotStart ?? $current['data_inizio']; + if (! empty($snapshot['legacy_year'])) { + $current['legacy_years'][] = $snapshot['legacy_year']; + } + continue; + } + + $payloads[] = $current; + $current = $snapshot + [ + 'data_fine' => $this->previousDay($current['data_inizio'] ?? null), + 'legacy_years' => array_values(array_filter([$snapshot['legacy_year']])), + ]; + } + + if ($current !== null) { + $payloads[] = $current; + } + + return $payloads; + } + private function upsertUnitaNominativiFromLegacy(int $unitaId, int $stabileId, ?string $legacyCondId, object $row): void { if (! Schema::hasTable('unita_immobiliare_nominativi')) { return; } - $historyRows = $this->loadLegacyCondominHistoryRows($row, $legacyCondId); - $payloads = array_merge( - $this->buildRoleHistoryPayloads($historyRows, 'C', $legacyCondId), - $this->buildRoleHistoryPayloads($historyRows, 'I', $legacyCondId) + $historyRowsCondomin = $this->loadLegacyCondominHistoryRows($row, $legacyCondId); + $historyRowsComproprietari = $this->loadLegacyComproprietariHistoryRows($row, $legacyCondId); + $payloadsCondomin = array_merge( + $this->buildRoleHistoryPayloads($historyRowsCondomin, 'C', $legacyCondId), + $this->buildRoleHistoryPayloads($historyRowsCondomin, 'I', $legacyCondId) ); + $payloadsComproprietari = $this->buildComproprietariHistoryPayloads($historyRowsComproprietari, $legacyCondId); DB::table('unita_immobiliare_nominativi') ->where('unita_immobiliare_id', $unitaId) ->where('stabile_id', $stabileId) - ->where('fonte', 'legacy_condomin') + ->whereIn('fonte', ['legacy_condomin', 'legacy_comproprietari']) ->delete(); - foreach ($payloads as $payload) { + foreach ($payloadsCondomin as $payload) { DB::table('unita_immobiliare_nominativi')->insert([ 'unita_immobiliare_id' => $unitaId, 'stabile_id' => $stabileId, @@ -5004,6 +5178,30 @@ private function upsertUnitaNominativiFromLegacy(int $unitaId, int $stabileId, ? 'updated_at' => now(), ]); } + + foreach ($payloadsComproprietari as $payload) { + DB::table('unita_immobiliare_nominativi')->insert([ + 'unita_immobiliare_id' => $unitaId, + 'stabile_id' => $stabileId, + 'legacy_cond_id' => $payload['legacy_cond_id'] ?? $legacyCondId, + 'ruolo' => 'C', + 'nominativo' => $payload['nominativo'], + 'data_inizio' => $payload['data_inizio'], + 'data_fine' => $payload['data_fine'], + 'percentuale' => $payload['percentuale'], + 'fonte' => 'legacy_comproprietari', + 'legacy_payload' => json_encode([ + 'cod_cond' => $payload['legacy_cond_id'] ?? $legacyCondId, + 'id_compr' => $payload['id_compr'] ?? null, + 'diritto_reale' => $payload['diritto_reale'] ?? null, + 'diritto_label' => $payload['diritto_label'] ?? null, + 'legacy_years' => array_values(array_unique($payload['legacy_years'] ?? [])), + 'history_mode' => 'comproprietari-series', + ]), + 'created_at' => now(), + 'updated_at' => now(), + ]); + } } private function normalizeLegacyCalcolo(?string $raw): ?string diff --git a/app/Console/Commands/NetgesconApplyPbxPresetCommand.php b/app/Console/Commands/NetgesconApplyPbxPresetCommand.php new file mode 100644 index 0000000..53727dc --- /dev/null +++ b/app/Console/Commands/NetgesconApplyPbxPresetCommand.php @@ -0,0 +1,135 @@ +resolveAdmin((string) $this->argument('admin')); + if (! $admin) { + $this->error('Amministratore non trovato.'); + + return self::FAILURE; + } + + $impostazioni = is_array($admin->impostazioni) ? $admin->impostazioni : []; + $centralino = (array) ($impostazioni['centralino'] ?? []); + $pbx = (array) ($impostazioni['pbx'] ?? []); + + $dayGroup = $this->digits((string) $this->option('day-group')); + $nightGroup = $this->digits((string) $this->option('night-group')); + $dayFallback = $this->digits((string) $this->option('day-fallback')); + $nightFallback = $this->digits((string) $this->option('night-fallback')); + $adminLine = $this->digits((string) $this->option('admin-line')); + $adminTarget = $this->digits((string) $this->option('admin-target')); + + if ($adminTarget === '') { + $adminTarget = $this->digits((string) ($centralino['interno_amministratore'] ?? '')); + } + + if ($adminTarget !== '') { + $centralino['interno_amministratore'] = $adminTarget; + } + + $centralino['interno_gruppo_giorno'] = $dayGroup; + $centralino['interno_gruppo_notte'] = $nightGroup; + + $pbx['provider'] = (string) ($pbx['provider'] ?? 'panasonic_ns1000'); + $pbx['channel'] = (string) ($pbx['channel'] ?? 'hybrid'); + $pbx['bridge_mode'] = (string) ($pbx['bridge_mode'] ?? 'group-first'); + + $watch = preg_split('/\s*,\s*/', (string) $this->option('watch'), -1, PREG_SPLIT_NO_EMPTY) ?: []; + $watch = array_values(array_unique(array_filter(array_map( + fn (string $value): string => $this->digits($value), + array_merge($watch, [$dayGroup, $nightGroup, $dayFallback, $nightFallback, $adminLine, $adminTarget]) + )))); + + $pbx['watch_extensions'] = implode(',', $watch); + $pbx['response_groups'] = array_values(array_filter([ + [ + 'extension' => $dayGroup, + 'label' => 'Gruppo giorno', + 'mode' => 'giorno', + 'target_extension' => $dayFallback, + ], + [ + 'extension' => $nightGroup, + 'label' => 'Gruppo notte', + 'mode' => 'notte', + 'target_extension' => $nightFallback, + ], + ], fn (array $row): bool => ($row['extension'] ?? '') !== '')); + + $incomingLines = []; + if ($adminLine !== '') { + $incomingLines[] = [ + 'number' => $adminLine, + 'label' => 'Linea amministratore', + 'route_group' => '', + 'target_extension' => $adminTarget, + 'notes' => 'Smistamento dedicato amministratore su linea 0003.', + ]; + } + $pbx['incoming_lines'] = $incomingLines; + + $impostazioni['centralino'] = $centralino; + $impostazioni['pbx'] = $pbx; + + $this->line('Preset PBX Day0:'); + $this->line(json_encode([ + 'amministratore_id' => (int) $admin->id, + 'codice_amministratore' => (string) ($admin->codice_amministratore ?? ''), + 'centralino' => $centralino, + 'pbx' => $pbx, + ], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '{}'); + + if (! $this->option('apply')) { + $this->warn('Dry-run completato. Riesegui con --apply per salvare il preset.'); + + return self::SUCCESS; + } + + $admin->impostazioni = $impostazioni; + $admin->save(); + + $this->info('Preset PBX salvato correttamente.'); + + return self::SUCCESS; + } + + private function resolveAdmin(string $value): ?Amministratore + { + $value = trim($value); + if ($value === '') { + return null; + } + + return Amministratore::query() + ->when(is_numeric($value), fn ($query) => $query->orWhere('id', (int) $value)) + ->orWhere('codice_amministratore', $value) + ->orWhere('codice_univoco', $value) + ->first(); + } + + private function digits(string $value): string + { + return preg_replace('/\D+/', '', $value) ?: ''; + } +} \ No newline at end of file diff --git a/app/Console/Commands/NetgesconArchiveRegistrySyncCommand.php b/app/Console/Commands/NetgesconArchiveRegistrySyncCommand.php new file mode 100644 index 0000000..b5cc6a8 --- /dev/null +++ b/app/Console/Commands/NetgesconArchiveRegistrySyncCommand.php @@ -0,0 +1,87 @@ +option('amministratore')); + $stabileFilter = trim((string) $this->option('stabile')); + $dryRun = (bool) $this->option('dry-run'); + + $amministratori = Amministratore::query() + ->when($adminFilter !== '', function ($query) use ($adminFilter) { + $query->where('codice_amministratore', $adminFilter) + ->orWhere('codice_univoco', $adminFilter); + }) + ->orderBy('id') + ->get(); + + if ($amministratori->isEmpty()) { + $this->error('Nessun amministratore trovato per i criteri richiesti.'); + + return self::FAILURE; + } + + $stabili = Stabile::query() + ->with(['amministratore', 'latestAmministratoreTransfer']) + ->whereIn('amministratore_id', $amministratori->pluck('id')) + ->when($stabileFilter !== '', fn($query) => $query->where('codice_stabile', $stabileFilter)) + ->orderBy('id') + ->get(); + + $records = []; + + foreach ($amministratori as $amministratore) { + $records[] = $dryRun + ? $this->registryService->buildAmministratorePayload($amministratore) + : $this->registryService->syncAmministratore($amministratore)->toArray(); + } + + foreach ($stabili as $stabile) { + $records[] = $dryRun + ? $this->registryService->buildStabilePayload($stabile) + : $this->registryService->syncStabile($stabile)->toArray(); + } + + $this->info(sprintf( + '%s registry completato: %d amministratori, %d stabili.', + $dryRun ? 'Dry-run' : 'Sync', + $amministratori->count(), + $stabili->count() + )); + + $this->table( + ['Tipo', 'Codice', 'Owner', 'Database', 'Storage', 'Stato'], + collect($records)->map(function (array $record) { + return [ + 'tipo' => $record['archive_type'] ?? '-', + 'codice' => $record['archive_code'] ?? '-', + 'owner' => $record['owner_amministratore_code'] ?? '-', + 'database' => $record['database_name'] ?? ($record['database_strategy'] ?? '-'), + 'storage' => $record['storage_relative_path'] ?? '-', + 'stato' => $record['status'] ?? '-', + ]; + })->all() + ); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/NetgesconDevelopmentSnapshotCommand.php b/app/Console/Commands/NetgesconDevelopmentSnapshotCommand.php index 7a31acb..c3e720e 100644 --- a/app/Console/Commands/NetgesconDevelopmentSnapshotCommand.php +++ b/app/Console/Commands/NetgesconDevelopmentSnapshotCommand.php @@ -1,5 +1,4 @@ recentPages(); $commitNotes = $this->recentCommitNotes(); - $snapshot = []; + $snapshot = []; $snapshot[] = '# Snapshot continuita sviluppo'; $snapshot[] = ''; $snapshot[] = 'Generata automaticamente il ' . now()->format('d/m/Y H:i:s') . '.'; @@ -114,9 +113,9 @@ public function handle(): int File::put($outputPath, implode(PHP_EOL, $snapshot) . PHP_EOL); File::put($jsonPath, json_encode([ 'generated_at' => now()->toIso8601String(), - 'branch' => $branch, - 'commit' => $commit, - 'output_path' => $outputPath, + 'branch' => $branch, + 'commit' => $commit, + 'output_path' => $outputPath, 'source_files' => [ 'docs/support/MANUALE-SVILUPPO-OPERATIVO.md', 'docs/support/PBX-PANASONIC-STATO-OPERATIVO.md', @@ -165,8 +164,8 @@ private function recentCommits(): array } $rows[] = [ - 'hash' => (string) $parts[0], - 'date' => (string) $parts[1], + 'hash' => (string) $parts[0], + 'date' => (string) $parts[1], 'message' => (string) $parts[2], ]; } @@ -177,9 +176,9 @@ private function recentCommits(): array /** @return array */ private function recentPages(): array { - $rows = []; - $seen = []; - $result = Process::path(base_path())->timeout(120)->run([ + $rows = []; + $seen = []; + $result = Process::path(base_path())->timeout(120)->run([ 'git', 'log', '-n', '12', '--name-only', '--pretty=format:@@@%h', '--', 'app/Filament/Pages', 'resources/views/filament/pages', ]); @@ -209,9 +208,9 @@ private function recentPages(): array } $seen[$line] = true; - $rows[] = [ - 'page' => basename($line), - 'path' => $line, + $rows[] = [ + 'page' => basename($line), + 'path' => $line, 'commit' => $currentCommit, ]; @@ -244,9 +243,9 @@ private function recentCommitNotes(): array } $rows[] = [ - 'hash' => $commit['hash'], + 'hash' => $commit['hash'], 'message' => $commit['message'], - 'note' => trim($decoded[$fullHash]), + 'note' => trim($decoded[$fullHash]), ]; } @@ -292,4 +291,4 @@ private function normalizeSectionLines(string $text): string return $cleaned === [] ? '- Nessuna informazione disponibile.' : implode(PHP_EOL, $cleaned); } -} \ No newline at end of file +} diff --git a/app/Filament/Pages/Condomini/LettureServiziArchivio.php b/app/Filament/Pages/Condomini/LettureServiziArchivio.php index 74197c6..cb0d664 100644 --- a/app/Filament/Pages/Condomini/LettureServiziArchivio.php +++ b/app/Filament/Pages/Condomini/LettureServiziArchivio.php @@ -9,6 +9,7 @@ use App\Models\UnitaImmobiliare; use App\Models\User; use App\Models\VoceSpesa; +use App\Support\AnnoGestioneContext; use App\Support\StabileContext; use BackedEnum; use Filament\Actions\Action; @@ -69,6 +70,31 @@ public function mount(): void $this->mountInteractsWithTable(); } + private function resolveActiveAnnoGestione(): int + { + $user = Auth::user(); + + return AnnoGestioneContext::resolveActiveAnno($user instanceof User ? $user : null); + } + + private function applyActiveYearFilter(Builder $query): Builder + { + $year = $this->resolveActiveAnnoGestione(); + + return $query->where(function (Builder $inner) use ($year): void { + $inner->whereYear('periodo_al', $year) + ->orWhere(function (Builder $fallback) use ($year): void { + $fallback->whereNull('periodo_al') + ->whereYear('periodo_dal', $year); + }) + ->orWhere(function (Builder $fallback) use ($year): void { + $fallback->whereNull('periodo_al') + ->whereNull('periodo_dal') + ->whereYear('created_at', $year); + }); + }); + } + protected function getTableQuery(): Builder { $user = Auth::user(); @@ -97,6 +123,7 @@ protected function getTableQuery(): Builder fn(Builder $q) => $q->whereIn('stabile_id', $stabiliIds->all()), fn(Builder $q) => $q->where('stabile_id', (int) $activeStabileId) ) + ->tap(fn(Builder $query) => $this->applyActiveYearFilter($query)) ->when($this->servizioFilter, fn(Builder $q) => $q->where('stabile_servizio_id', (int) $this->servizioFilter)) ->with([ 'stabile:id,codice_stabile,denominazione', diff --git a/app/Filament/Pages/Condomini/ServiziStabileArchivio.php b/app/Filament/Pages/Condomini/ServiziStabileArchivio.php index 0c59ac5..7a25b62 100644 --- a/app/Filament/Pages/Condomini/ServiziStabileArchivio.php +++ b/app/Filament/Pages/Condomini/ServiziStabileArchivio.php @@ -8,6 +8,7 @@ use App\Models\StabileServizioTariffa; use App\Models\User; use App\Models\VoceSpesa; +use App\Support\AnnoGestioneContext; use App\Support\StabileContext; use BackedEnum; use Filament\Actions\Action; @@ -470,6 +471,39 @@ private function resolveActiveStabileId(): ?int return $stabileId ? (int) $stabileId : null; } + private function resolveActiveAnnoGestione(): int + { + $user = Auth::user(); + + return AnnoGestioneContext::resolveActiveAnno($user instanceof User ? $user : null); + } + + /** @return array */ + private function resolveLegacyCodiciStabile(): array + { + $user = Auth::user(); + + return StabileContext::legacyCodeCandidates(StabileContext::getActiveStabile($user instanceof User ? $user : null)); + } + + private function applyActiveYearFilterToReadingQuery(Builder $query): Builder + { + $year = $this->resolveActiveAnnoGestione(); + + return $query->where(function (Builder $inner) use ($year): void { + $inner->whereYear('periodo_al', $year) + ->orWhere(function (Builder $fallback) use ($year): void { + $fallback->whereNull('periodo_al') + ->whereYear('periodo_dal', $year); + }) + ->orWhere(function (Builder $fallback) use ($year): void { + $fallback->whereNull('periodo_al') + ->whereNull('periodo_dal') + ->whereYear('created_at', $year); + }); + }); + } + /** @return array{totale_fatture: float, totale_operazioni_ac12_legacy: float, delta_operazioni_vs_fatture: float, totale_letture: int, totale_letture_con_riferimento: int, totale_consumi_mc: float, totale_tariffe: int} */ public function getAcquaDashboardStatsProperty(): array { @@ -559,10 +593,23 @@ public function getAcquaLegacyOperazioniSummaryProperty(): array return ['totale' => 0.0, 'voci' => [], 'righe' => 0]; } - $rows = DB::connection('gescon_import') + $legacyCodes = $this->resolveLegacyCodiciStabile(); + $year = $this->resolveActiveAnnoGestione(); + + $query = DB::connection('gescon_import') ->table('operazioni') ->where('gestione', 'O') - ->whereIn('cod_spe', ['AC1', 'AC2']) + ->whereIn('cod_spe', ['AC1', 'AC2']); + + if ($legacyCodes !== [] && Schema::connection('gescon_import')->hasColumn('operazioni', 'cod_stabile')) { + $query->whereIn('cod_stabile', $legacyCodes); + } + + if (Schema::connection('gescon_import')->hasColumn('operazioni', 'dt_spe')) { + $query->whereYear('dt_spe', $year); + } + + $rows = $query ->select('cod_spe') ->selectRaw('COUNT(*) as righe') ->selectRaw('SUM(COALESCE(importo_euro, importo, 0)) as totale') @@ -603,10 +650,23 @@ public function getAcquaLegacyOperazioniRowsProperty(): array return []; } - $legacyRows = DB::connection('gescon_import') + $legacyCodes = $this->resolveLegacyCodiciStabile(); + $year = $this->resolveActiveAnnoGestione(); + + $query = DB::connection('gescon_import') ->table('operazioni') ->where('gestione', 'O') - ->whereIn('cod_spe', ['AC1', 'AC2']) + ->whereIn('cod_spe', ['AC1', 'AC2']); + + if ($legacyCodes !== [] && Schema::connection('gescon_import')->hasColumn('operazioni', 'cod_stabile')) { + $query->whereIn('cod_stabile', $legacyCodes); + } + + if (Schema::connection('gescon_import')->hasColumn('operazioni', 'dt_spe')) { + $query->whereYear('dt_spe', $year); + } + + $legacyRows = $query ->orderByDesc('dt_spe') ->orderByDesc('id_operaz') ->limit(120) @@ -841,38 +901,43 @@ public function getAcquaRipartoNominativiLegacyProperty(): array return []; } - $user = Auth::user(); - $legacyCode = trim((string) (StabileContext::getActiveStabile($user)?->codice_stabile ?? '')); - if ($legacyCode === '') { + $legacyCodes = $this->resolveLegacyCodiciStabile(); + if ($legacyCodes === []) { return []; } - $legacyYear = (string) (DB::connection('gescon_import') - ->table('dett_tab') - ->where('cod_stabile', $legacyCode) - ->max('legacy_year') ?: ''); + $legacyYear = (string) $this->resolveActiveAnnoGestione(); - $condRows = DB::connection('gescon_import') + $condQuery = DB::connection('gescon_import') ->table('condomin') - ->where('cod_stabile', $legacyCode) - ->when($legacyYear !== '', fn($q) => $q->where('legacy_year', $legacyYear)) - ->get([ - 'id_cond', - 'scala', - 'interno', - 'cognome', - 'nome', - 'proprietario_denominazione', - 'inquilino_denominazione', - 'inquil_nome', - ]) + ->whereIn('cod_stabile', $legacyCodes); + + if ($legacyYear !== '' && Schema::connection('gescon_import')->hasColumn('condomin', 'legacy_year')) { + $condQuery->where('legacy_year', $legacyYear); + } + + $condRows = $condQuery->get([ + 'id_cond', + 'scala', + 'interno', + 'cognome', + 'nome', + 'proprietario_denominazione', + 'inquilino_denominazione', + 'inquil_nome', + ]) ->keyBy('id_cond'); - $rows = DB::connection('gescon_import') + $rowsQuery = DB::connection('gescon_import') ->table('dett_tab') ->where('cod_tab', 'ACQUA') - ->where('cod_stabile', $legacyCode) - ->when($legacyYear !== '', fn($q) => $q->where('legacy_year', $legacyYear)) + ->whereIn('cod_stabile', $legacyCodes); + + if ($legacyYear !== '' && Schema::connection('gescon_import')->hasColumn('dett_tab', 'legacy_year')) { + $rowsQuery->where('legacy_year', $legacyYear); + } + + $rows = $rowsQuery ->orderBy('id_cond') ->orderBy('cond_inquil') ->get(['id_cond', 'cond_inquil', 'cons_euro']); @@ -984,6 +1049,8 @@ public function getAcquaFatturePerGestioneProperty(): array return []; } + $year = $this->resolveActiveAnnoGestione(); + if (Schema::hasTable('contabilita_fatture_fornitori')) { $fornitoreIds = StabileServizio::query() ->where('stabile_id', $stabileId) @@ -1001,6 +1068,8 @@ public function getAcquaFatturePerGestioneProperty(): array ->leftJoin('gestioni_contabili as g', 'g.id', '=', 'f.gestione_id') ->where('f.stabile_id', $stabileId) ->whereIn('f.fornitore_id', $fornitoreIds) + ->when(Schema::hasColumn('gestioni_contabili', 'anno_gestione'), fn($q) => $q->where('g.anno_gestione', $year)) + ->when(! Schema::hasColumn('gestioni_contabili', 'anno_gestione') && Schema::hasColumn('contabilita_fatture_fornitori', 'data_documento'), fn($q) => $q->whereYear('f.data_documento', $year)) ->get(['f.id', 'f.totale', 'f.fattura_elettronica_id', 'g.tipo_gestione']); if ($contabili->isNotEmpty()) { @@ -1047,6 +1116,7 @@ public function getAcquaFatturePerGestioneProperty(): array ->whereHas('servizio', fn(Builder $q) => $q->where('tipo', 'acqua')) ->whereNotNull('fattura_elettronica_id') ->with(['voceSpesa:id,tipo_gestione']) + ->tap(fn(Builder $query) => $this->applyActiveYearFilterToReadingQuery($query)) ->get(['fattura_elettronica_id', 'voce_spesa_id', 'importo_totale']); $byFattura = []; @@ -1110,6 +1180,7 @@ public function getAcquaLettureCondominiProperty(): array 'unitaImmobiliare:id,codice_unita,interno,scala', 'servizio:id,nome,contatore_matricola', ]) + ->tap(fn(Builder $query) => $this->applyActiveYearFilterToReadingQuery($query)) ->orderByDesc('created_at') ->limit(60) ->get() @@ -1140,6 +1211,7 @@ public function getAcquaTariffeRowsProperty(): array return StabileServizioTariffa::query() ->where('stabile_id', $stabileId) + ->whereYear('data_fattura', $this->resolveActiveAnnoGestione()) ->orderByDesc('data_fattura') ->orderByDesc('id') ->limit(80) @@ -1193,12 +1265,25 @@ public function getAcquaAltreVociLegacyProperty(): array return []; } - $rows = DB::connection('gescon_import') + $legacyCodes = $this->resolveLegacyCodiciStabile(); + $year = $this->resolveActiveAnnoGestione(); + + $query = DB::connection('gescon_import') ->table('operazioni') ->where('gestione', 'O') ->where('cod_spe', 'like', 'AC%') ->where('cod_spe', '!=', 'AC1') - ->where('cod_spe', '!=', 'AC2') + ->where('cod_spe', '!=', 'AC2'); + + if ($legacyCodes !== [] && Schema::connection('gescon_import')->hasColumn('operazioni', 'cod_stabile')) { + $query->whereIn('cod_stabile', $legacyCodes); + } + + if (Schema::connection('gescon_import')->hasColumn('operazioni', 'dt_spe')) { + $query->whereYear('dt_spe', $year); + } + + $rows = $query ->select('cod_spe') ->selectRaw('SUM(COALESCE(importo_euro, importo, 0)) as totale') ->groupBy('cod_spe') diff --git a/app/Filament/Pages/Gescon/Ordinarie.php b/app/Filament/Pages/Gescon/Ordinarie.php index 22907ad..ea9c44c 100644 --- a/app/Filament/Pages/Gescon/Ordinarie.php +++ b/app/Filament/Pages/Gescon/Ordinarie.php @@ -3,6 +3,7 @@ use App\Models\User; use App\Models\UserSetting; +use App\Support\AnnoGestioneContext; use App\Support\StabileContext; use BackedEnum; use Filament\Notifications\Notification; @@ -67,6 +68,11 @@ public static function canAccess(): bool public function mount(): void { + $user = Auth::user(); + if ($user instanceof User && $this->filterAnno === null) { + $this->filterAnno = AnnoGestioneContext::resolveActiveAnno($user); + } + $tab = request()->query('tab'); if (in_array($tab, ['operazioni', 'consuntivo', 'straordinarie', 'acqua', 'incassi', 'fatture'], true)) { $this->viewTab = $tab; @@ -152,25 +158,8 @@ private function resolveLegacyCodiceStabile(): ?string { $user = Auth::user(); $stabile = StabileContext::getActiveStabile($user); - if (! $stabile) { - return null; - } - // Preferisci il codice legacy "tecnico" (cod_stabile/old_id), poi codice_stabile. - // Questo evita mismatch quando in archivio import il campo cod_stabile e` numerico. - $candidates = [ - trim((string) ($stabile->cod_stabile ?? '')), - trim((string) ($stabile->old_id ?? '')), - trim((string) ($stabile->codice_stabile ?? '')), - ]; - - foreach ($candidates as $candidate) { - if ($candidate !== '') { - return $candidate; - } - } - - return null; + return StabileContext::preferredLegacyCode($stabile); } private function resolveLegacyYearForRiparto(?string $codStabile): ?string @@ -881,16 +870,10 @@ public function getConsuntivoProperty(): array WHEN (COALESCE(importo_spese, 0) + COALESCE(importo_entrate, 0) + COALESCE(importo_debiti, 0) + COALESCE(importo_crediti, 0)) = 0 THEN CASE WHEN cod_spe = 'RB1' THEN -1 * COALESCE(importo_euro, importo, 0) - $rowsQuery = DB::connection('gescon_import') + WHEN natura2 IN ('E', 'ENTRATA', 'ENTRATE', 'CREDITO') THEN -1 * COALESCE(importo_euro, importo, 0) ELSE COALESCE(importo_euro, importo, 0) END - ->whereIn('cod_spe', ['AC1', 'AC2']); - - if ($legacyCode && Schema::connection('gescon_import')->hasColumn('operazioni', 'cod_stabile')) { - $rowsQuery->where('cod_stabile', $legacyCode); - } - - $rows = $rowsQuery + ELSE (COALESCE(importo_spese, 0) + COALESCE(importo_debiti, 0) - COALESCE(importo_entrate, 0) - COALESCE(importo_crediti, 0)) END ) as totale") ->groupBy('tabella', 'cod_spe') diff --git a/app/Filament/Pages/Gescon/RubricaUniversaleScheda.php b/app/Filament/Pages/Gescon/RubricaUniversaleScheda.php index b3abbd6..2653be6 100644 --- a/app/Filament/Pages/Gescon/RubricaUniversaleScheda.php +++ b/app/Filament/Pages/Gescon/RubricaUniversaleScheda.php @@ -3,6 +3,7 @@ use App\Models\Fornitore; use App\Models\FornitoreDipendente; +use App\Models\PbxClickToCallRequest; use App\Models\Persona; use App\Models\PersonaEmailMultipla; use App\Models\RubricaContattoCanale; @@ -279,24 +280,28 @@ public function mount(int | string $record): void ->all(); // Contatti principali (per mostrarli velocemente nella scheda): prende canali principali + fallback ai campi telefono/email. - $principali = RubricaContattoCanale::query() - ->where('rubrica_id', (int) $this->rubrica->id) - ->orderByDesc('is_principale') - ->orderByDesc('data_inizio') - ->orderByDesc('id') - ->limit(20) - ->get(); + if (Schema::hasTable('rubrica_contatto_canali')) { + $principali = RubricaContattoCanale::query() + ->where('rubrica_id', (int) $this->rubrica->id) + ->orderByDesc('is_principale') + ->orderByDesc('data_inizio') + ->orderByDesc('id') + ->limit(20) + ->get(); - $this->contattiPrincipali = $principali - ->map(fn(RubricaContattoCanale $c) => [ - 'tipo' => (string) ($c->tipo ?? 'altro'), - 'etichetta' => (string) ($c->etichetta ?? ''), - 'valore' => (string) ($c->valore ?? ''), - 'principale' => (bool) ($c->is_principale ?? false), - ]) - ->all(); + $this->contattiPrincipali = $principali + ->map(fn(RubricaContattoCanale $c) => [ + 'tipo' => (string) ($c->tipo ?? 'altro'), + 'etichetta' => (string) ($c->etichetta ?? ''), + 'valore' => (string) ($c->valore ?? ''), + 'principale' => (bool) ($c->is_principale ?? false), + ]) + ->all(); + } else { + $this->contattiPrincipali = []; + } - $this->hydrateEmailMultiple(); + $this->loadEmailMultiple(); $this->fillInlineForm(); $this->hydrateFornitoriWorkspace(); $this->hydrateStudioCollaboratoreWorkspace(); @@ -684,6 +689,7 @@ protected function getHeaderActions(): array Action::make('contatti_avanzati') ->label('Contatti avanzati') ->icon('heroicon-o-rectangle-stack') + ->visible(fn(): bool => Schema::hasTable('rubrica_contatto_canali')) ->modalWidth('7xl') ->form([ Repeater::make('canali') @@ -1282,7 +1288,7 @@ private function resolveTitoloId(?string $raw): ?int return $title?->id ? (int) $title->id : null; } - private function hydrateEmailMultiple(): void + private function loadEmailMultiple(): void { $persona = $this->resolvePersonaForRubrica(false); if (! $persona instanceof Persona) { @@ -1364,7 +1370,80 @@ private function saveAdditionalEmails(array $rows): void $deleteQuery->delete(); }); - $this->hydrateEmailMultiple(); + $this->loadEmailMultiple(); + } + + private function getStudioCollaboratoreEmail(): ?string + { + $primary = mb_strtolower(trim((string) ($this->rubrica->email ?? ''))); + if ($primary !== '') { + return $primary; + } + + foreach ($this->emailMultiple as $row) { + $email = mb_strtolower(trim((string) ($row['email'] ?? ''))); + if ($email !== '' && (bool) ($row['attiva'] ?? true)) { + return $email; + } + } + + return null; + } + + public function canRequestClickToCall(): bool + { + $user = Auth::user(); + + return $user instanceof User + && (bool) ($user->pbx_click_to_call_enabled ?? false) + && trim((string) ($user->pbx_extension ?? '')) !== ''; + } + + public function richiediClickToCallRubrica(string $field): void + { + if (! in_array($field, ['telefono_ufficio', 'telefono_cellulare', 'telefono_casa'], true)) { + Notification::make()->title('Campo telefono non supportato')->warning()->send(); + return; + } + + $user = Auth::user(); + if (! $user instanceof User) { + return; + } + + $extension = trim((string) ($user->pbx_extension ?? '')); + if (! (bool) ($user->pbx_click_to_call_enabled ?? false) || $extension === '') { + Notification::make()->title('Click-to-call non abilitato per il tuo utente')->warning()->send(); + return; + } + + $phone = $this->normalizePhoneDigits(data_get($this->rubrica, $field)); + if ($phone === '') { + Notification::make()->title('Numero non valido')->warning()->send(); + return; + } + + PbxClickToCallRequest::query()->create([ + 'requested_by_user_id' => (int) $user->id, + 'assigned_user_id' => (int) $user->id, + 'stabile_id' => $this->resolveClickToCallStabileId($user), + 'source_extension' => $extension, + 'target_number' => $phone, + 'status' => 'pending', + 'note' => 'Richiesta da scheda rubrica', + 'requested_at' => now(), + 'metadata' => [ + 'requested_from' => 'rubrica_universale', + 'rubrica_id' => (int) $this->rubrica->id, + 'field' => $field, + ], + ]); + + Notification::make() + ->title('Richiesta click-to-call registrata') + ->body('Interno ' . $extension . ' -> ' . $phone) + ->success() + ->send(); } private function resolvePersonaForRubrica(bool $createIfMissing): ?Persona @@ -1797,12 +1876,12 @@ public function salvaInternoDipendente(int $dipendenteId): void public function abilitaAccessoCollaboratoreStudio(): void { - $email = mb_strtolower(trim((string) ($this->rubrica->email ?? ''))); - if ($email === '') { + $email = $this->getStudioCollaboratoreEmail(); + if ($email === null) { Notification::make() ->title('Email rubrica mancante') ->warning() - ->body('Per creare o collegare il collaboratore di studio serve un indirizzo email nella rubrica.') + ->body('Per creare o collegare il collaboratore di studio serve un indirizzo email principale o aggiuntivo attivo nella rubrica.') ->send(); return; } @@ -1877,7 +1956,7 @@ public function salvaCollaboratoreStudio(): void private function hydrateStudioCollaboratoreWorkspace(): void { $this->studioCollaboratoreUserId = null; - $this->studioCollaboratoreUserEmail = trim((string) ($this->rubrica->email ?? '')) ?: null; + $this->studioCollaboratoreUserEmail = $this->getStudioCollaboratoreEmail(); $this->studioCollaboratoreUserName = trim((string) ($this->rubrica->nome_completo ?: $this->rubrica->ragione_sociale ?: '')) ?: null; $this->studioCollaboratorePbxExtension = ''; $this->studioCollaboratoreRoles = []; @@ -1945,4 +2024,28 @@ private function syncStudioCollaboratoreStabili(User $user): void $user->stabiliAssegnati()->sync(array_values(array_unique(array_merge($outsideAdminIds, $requested)))); } + + private function normalizePhoneDigits(mixed $value): string + { + $digits = preg_replace('/\D+/', '', (string) ($value ?? '')); + + return is_string($digits) ? $digits : ''; + } + + private function resolveClickToCallStabileId(User $user): ?int + { + $activeStabileId = (int) (StabileContext::resolveActiveStabileId($user) ?? 0); + if ($activeStabileId > 0) { + return $activeStabileId; + } + + foreach ($this->stabili as $stabile) { + $candidate = (int) ($stabile['id'] ?? 0); + if ($candidate > 0) { + return $candidate; + } + } + + return null; + } } diff --git a/app/Filament/Pages/Impostazioni/SchedaAmministratore.php b/app/Filament/Pages/Impostazioni/SchedaAmministratore.php index 31b82b2..62df6e1 100644 --- a/app/Filament/Pages/Impostazioni/SchedaAmministratore.php +++ b/app/Filament/Pages/Impostazioni/SchedaAmministratore.php @@ -24,10 +24,11 @@ use Filament\Schemas\Components\Section; use Filament\Schemas\Components\Tabs; use Filament\Schemas\Components\Tabs\Tab; -use Filament\Schemas\Schema; +use Filament\Schemas\Schema as FilamentSchema; use Illuminate\Support\Arr; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Hash; +use Illuminate\Support\Facades\Schema; use Illuminate\Support\Str; use Symfony\Component\Process\Process; use UnitEnum; @@ -134,7 +135,7 @@ public function mount(): void ]); } - public function form(Schema $schema): Schema + public function form(FilamentSchema $schema): FilamentSchema { return $schema ->statePath('data') @@ -761,7 +762,7 @@ public function form(Schema $schema): Schema TextInput::make('impostazioni.pbx.watch_extensions') ->label('Interni monitorati') ->maxLength(255) - ->placeholder('201,205,206,601,603') + ->placeholder('201,206,601,603,0003') ->helperText('Lista CSV degli interni/gruppi che il watcher Windows deve osservare.'), Toggle::make('impostazioni.pbx.popup_enabled') ->label('Popup CRM attivi') @@ -796,7 +797,7 @@ public function form(Schema $schema): Schema TextInput::make('target_extension') ->label('Interno di fallback') ->maxLength(20) - ->placeholder('205'), + ->placeholder('201 oppure 206'), ]) ->columns(4) ->columnSpanFull(), @@ -811,19 +812,19 @@ public function form(Schema $schema): Schema TextInput::make('number') ->label('Numero / DID') ->maxLength(40) - ->placeholder('0811234567'), + ->placeholder('0003'), TextInput::make('label') ->label('Descrizione linea') ->maxLength(120) - ->placeholder('Linea assistenza'), + ->placeholder('Linea amministratore'), TextInput::make('route_group') ->label('Gruppo target') ->maxLength(20) - ->placeholder('601'), + ->placeholder('601 o 603'), TextInput::make('target_extension') ->label('Interno target') ->maxLength(20) - ->placeholder('205'), + ->placeholder('interno amministratore'), Textarea::make('notes') ->label('Note') ->rows(2) @@ -852,7 +853,7 @@ public function form(Schema $schema): Schema ]), ]), ]); - } + } public function runProductionUpgrade(): void { @@ -1344,11 +1345,12 @@ public function getPbxSummaryProperty(): array ]; } - $query = $this->pbxMessagesQuery(); - $latest = (clone $query)->orderByDesc('received_at')->orderByDesc('id')->first(); - $total = (clone $query)->count(); - $last24h = (clone $query)->where('received_at', '>=', now()->subDay())->count(); - $missedCalls = (clone $query)->where(function ($q): void { + $query = $this->pbxMessagesQuery(); + $rawQuery = $this->pbxRawMessagesQuery(); + $latest = (clone $query)->orderByDesc('received_at')->orderByDesc('id')->first(); + $total = (clone $query)->count(); + $last24h = (clone $query)->where('received_at', '>=', now()->subDay())->count(); + $missedCalls = (clone $rawQuery)->where(function ($q): void { $q->where('message_text', 'like', '%persa%') ->orWhere('metadata->outcome', 'like', '%miss%') ->orWhere('metadata->outcome', 'like', '%no_answer%'); @@ -1369,20 +1371,20 @@ public function getPbxSummaryProperty(): array : 'La configurazione e predisposta per usare gli eventi PBX direttamente nel CRM. Verifica i log del watcher Windows per confermare il flusso sul canale selezionato.'; return [ - 'status' => $status, - 'label' => $label, - 'provider' => $provider, - 'channel' => $channel, - 'bridge_mode' => $bridgeMode, - 'watched_extensions'=> $watchedExtensions, - 'response_groups' => $responseGroups, - 'incoming_lines' => $incomingLines, - 'last_event_at' => $latest?->received_at?->format('d/m/Y H:i:s'), - 'last_event_type' => $latest ? (string) data_get($latest->metadata, 'event_type', $latest->status) : null, - 'total_messages' => $total, - 'last_24h' => $last24h, - 'missed_calls' => $missedCalls, - 'assessment' => $assessment, + 'status' => $status, + 'label' => $label, + 'provider' => $provider, + 'channel' => $channel, + 'bridge_mode' => $bridgeMode, + 'watched_extensions' => $watchedExtensions, + 'response_groups' => $responseGroups, + 'incoming_lines' => $incomingLines, + 'last_event_at' => $latest?->received_at?->format('d/m/Y H:i:s'), + 'last_event_type' => $latest ? (string) data_get($latest->metadata, 'event_type', $latest->status) : null, + 'total_messages' => $total, + 'last_24h' => $last24h, + 'missed_calls' => $missedCalls, + 'assessment' => $assessment, ]; } @@ -1401,7 +1403,7 @@ public function getPbxRecentMessagesProperty(): array ->orderByDesc('id') ->limit(12) ->get(['id', 'phone_number', 'target_extension', 'assigned_user_id', 'direction', 'status', 'received_at', 'metadata']) - ->map(fn(CommunicationMessage $message): array => [ + ->map(fn(CommunicationMessage $message): array=> [ 'id' => (int) $message->id, 'received_at' => $message->received_at?->format('d/m/Y H:i:s') ?? '-', 'phone_number' => (string) ($message->phone_number ?? '-'), @@ -1428,39 +1430,39 @@ public function getPbxRoutingRowsProperty(): array $incoming = array_values(array_filter((array) ($settings['incoming_lines'] ?? []), fn($row): bool => is_array($row))); $routingRows = [ [ - 'type' => 'Studio', - 'label' => 'Interno centralino', - 'extension' => (string) Arr::get($centralino, 'interno_centralino', '-'), - 'target' => 'Smistamento studio', - 'note' => (string) Arr::get($centralino, 'numero_principale', '-'), + 'type' => 'Studio', + 'label' => 'Interno centralino', + 'extension' => (string) Arr::get($centralino, 'interno_centralino', '-'), + 'target' => 'Smistamento studio', + 'note' => (string) Arr::get($centralino, 'numero_principale', '-'), ], [ - 'type' => 'Studio', - 'label' => 'Operatore', - 'extension' => (string) Arr::get($centralino, 'interno_operatore', '-'), - 'target' => 'Utente operatore', - 'note' => 'Instradamento diretto', + 'type' => 'Studio', + 'label' => 'Operatore', + 'extension' => (string) Arr::get($centralino, 'interno_operatore', '-'), + 'target' => 'Utente operatore', + 'note' => 'Instradamento diretto', ], [ - 'type' => 'Studio', - 'label' => 'Amministratore', - 'extension' => (string) Arr::get($centralino, 'interno_amministratore', '-'), - 'target' => 'Utente amministratore', - 'note' => 'Instradamento diretto', + 'type' => 'Studio', + 'label' => 'Amministratore', + 'extension' => (string) Arr::get($centralino, 'interno_amministratore', '-'), + 'target' => 'Utente amministratore', + 'note' => 'Instradamento diretto', ], [ - 'type' => 'Gruppo', - 'label' => 'Gruppo giorno', - 'extension' => (string) Arr::get($centralino, 'interno_gruppo_giorno', '-'), - 'target' => 'Modalita giorno', - 'note' => 'Routing shared', + 'type' => 'Gruppo', + 'label' => 'Gruppo giorno', + 'extension' => (string) Arr::get($centralino, 'interno_gruppo_giorno', '-'), + 'target' => 'Modalita giorno', + 'note' => 'Routing shared', ], [ - 'type' => 'Gruppo', - 'label' => 'Gruppo notte', - 'extension' => (string) Arr::get($centralino, 'interno_gruppo_notte', '-'), - 'target' => 'Modalita notte', - 'note' => 'Routing shared', + 'type' => 'Gruppo', + 'label' => 'Gruppo notte', + 'extension' => (string) Arr::get($centralino, 'interno_gruppo_notte', '-'), + 'target' => 'Modalita notte', + 'note' => 'Routing shared', ], ]; @@ -1487,12 +1489,15 @@ public function getPbxRoutingRowsProperty(): array return $routingRows; } - private function pbxMessagesQuery() + private function pbxRawMessagesQuery() { + $settings = (array) Arr::get($this->amministratore->impostazioni ?? [], 'pbx', []); + $channels = $this->resolvePbxChannels($settings); $extensions = $this->resolveWatchedPbxExtensions((array) Arr::get($this->amministratore->impostazioni ?? [], 'pbx', [])); return CommunicationMessage::query() - ->where('channel', 'panasonic_csta') + ->whereIn('channel', $channels) + ->whereIn('direction', ['inbound', 'outbound']) ->where(function ($q) use ($extensions): void { $q->where('metadata->amministratore_id', (int) $this->amministratore->id); @@ -1502,6 +1507,37 @@ private function pbxMessagesQuery() }); } + private function pbxMessagesQuery() + { + return $this->pbxRawMessagesQuery() + ->where(function ($q): void { + $q->whereNull('metadata->outcome') + ->orWhere(function ($inner): void { + $inner->where('metadata->outcome', 'not like', '%miss%') + ->where('metadata->outcome', 'not like', '%no_answer%'); + }); + }) + ->where(function ($q): void { + $q->whereNull('message_text') + ->orWhere('message_text', 'not like', '%persa%'); + }); + } + + /** + * @param array $settings + * @return array + */ + private function resolvePbxChannels(array $settings): array + { + $channel = mb_strtolower(trim((string) ($settings['channel'] ?? 'hybrid'))); + + return match ($channel) { + 'panasonic', 'panasonic_csta', 'csta' => ['panasonic_csta'], + 'smdr' => ['smdr'], + default => ['panasonic_csta', 'smdr'], + }; + } + /** * @return array */ @@ -1515,9 +1551,9 @@ private function resolveWatchedPbxExtensions(array $settings): array (string) Arr::get($this->amministratore->impostazioni ?? [], 'centralino.interno_gruppo_notte', ''), ]; - $csvExtensions = preg_split('/\s*,\s*/', (string) ($settings['watch_extensions'] ?? ''), -1, PREG_SPLIT_NO_EMPTY) ?: []; + $csvExtensions = preg_split('/\s*,\s*/', (string) ($settings['watch_extensions'] ?? ''), -1, PREG_SPLIT_NO_EMPTY) ?: []; $groupExtensions = array_map(fn($row): string => (string) ($row['extension'] ?? ''), array_filter((array) ($settings['response_groups'] ?? []), fn($row): bool => is_array($row))); - $lineTargets = array_map(fn($row): string => (string) (($row['target_extension'] ?? '') !== '' ? $row['target_extension'] : ($row['route_group'] ?? '')), array_filter((array) ($settings['incoming_lines'] ?? []), fn($row): bool => is_array($row))); + $lineTargets = array_map(fn($row): string => (string) (($row['target_extension'] ?? '') !== '' ? $row['target_extension'] : ($row['route_group'] ?? '')), array_filter((array) ($settings['incoming_lines'] ?? []), fn($row): bool => is_array($row))); return array_values(array_unique(array_filter(array_map( fn($value): string => preg_replace('/\D+/', '', (string) $value) ?: '', diff --git a/app/Filament/Pages/SuperAdmin/ControlloQualita.php b/app/Filament/Pages/SuperAdmin/ControlloQualita.php index ab89a51..65ce9f2 100644 --- a/app/Filament/Pages/SuperAdmin/ControlloQualita.php +++ b/app/Filament/Pages/SuperAdmin/ControlloQualita.php @@ -1,5 +1,4 @@ > */ @@ -42,14 +45,14 @@ class ControlloQualita extends Page /** @var array */ public array $summary = [ 'total_scanned_files' => 0, - 'total_issues' => 0, - 'by_severity' => [ + 'total_issues' => 0, + 'by_severity' => [ 'critical' => 0, - 'error' => 0, - 'warning' => 0, - 'info' => 0, + 'error' => 0, + 'warning' => 0, + 'info' => 0, ], - 'by_rule' => [], + 'by_rule' => [], ]; public static function canAccess(): bool @@ -61,6 +64,8 @@ public static function canAccess(): bool public function mount(): void { + $this->stabile = (string) userSetting('code_quality.stabile', ''); + $this->anno = (string) userSetting('code_quality.anno', ''); $this->runScan(); } @@ -69,15 +74,17 @@ public function runScan(): void $controller = app(CodeQualityController::class); $request = Request::create('/admin/code-quality/scan', 'POST', [ - 'path' => $this->path, - 'severity' => $this->severity, - 'module' => $this->module, - 'menu' => $this->menu, + 'path' => $this->path, + 'severity' => $this->severity, + 'module' => $this->module, + 'menu' => $this->menu, + 'stabile' => $this->stabile, + 'anno' => $this->anno, 'include_data_checks' => $this->includeDataChecks ? 1 : 0, ]); $response = $controller->scan($request); - $payload = $response->getData(true); + $payload = $response->getData(true); $this->results = is_array($payload['results'] ?? null) ? $payload['results'] : []; $this->summary = is_array($payload['summary'] ?? null) ? $payload['summary'] : $this->summary; diff --git a/app/Filament/Pages/Supporto/Modifiche.php b/app/Filament/Pages/Supporto/Modifiche.php index 5313297..de9fde8 100644 --- a/app/Filament/Pages/Supporto/Modifiche.php +++ b/app/Filament/Pages/Supporto/Modifiche.php @@ -590,7 +590,7 @@ private function startUpdateJob(bool $fallback): void $this->updateProgressMessage = $this->updateSkipBackup ? 'Preparazione aggiornamento senza backup (modalita test)...' : 'Preparazione backup pre-update...'; - $this->updateProgressStatus = 'running'; + $this->updateProgressStatus = 'running'; @file_put_contents($progressPath, json_encode([ 'timestamp' => now()->toIso8601String(), @@ -725,8 +725,8 @@ private function startUpdateJob(bool $fallback): void Notification::make() ->title('Aggiornamento avviato') ->body($this->updateSkipBackup - ? 'Aggiornamento avviato senza backup pre-update (modalita test). Avanzamento visibile in tempo reale.' - : 'Backup pre-update completato. Avanzamento visibile in tempo reale.') + ? 'Aggiornamento avviato senza backup pre-update (modalita test). Avanzamento visibile in tempo reale.' + : 'Backup pre-update completato. Avanzamento visibile in tempo reale.') ->success() ->send(); } @@ -912,8 +912,8 @@ private function loadGitWorkspaceStatus(): void if (is_file($summaryPath)) { $summary = json_decode((string) @file_get_contents($summaryPath), true); if (is_array($summary)) { - $this->lastGitDocsSyncAt = isset($summary['synced_at']) ? (string) $summary['synced_at'] : $this->lastGitDocsSyncAt; - $this->lastGitSyncAt = isset($summary['synced_at']) ? (string) $summary['synced_at'] : $this->lastGitSyncAt; + $this->lastGitDocsSyncAt = isset($summary['synced_at']) ? (string) $summary['synced_at'] : $this->lastGitDocsSyncAt; + $this->lastGitSyncAt = isset($summary['synced_at']) ? (string) $summary['synced_at'] : $this->lastGitSyncAt; $this->lastGitSyncExitCode = isset($summary['exit_code']) && is_numeric($summary['exit_code']) ? (int) $summary['exit_code'] : $this->lastGitSyncExitCode; @@ -935,8 +935,8 @@ private function loadDevelopmentSnapshot(): void ? (string) @file_get_contents($path) : '# Snapshot sviluppo non disponibile' . PHP_EOL . PHP_EOL . '- Rigenera la snapshot dalla pagina Supporto > Modifiche.'; - $this->developmentSnapshotPath = $path; - $this->developmentSnapshotHtml = $this->renderSupportMarkdown($raw); + $this->developmentSnapshotPath = $path; + $this->developmentSnapshotHtml = $this->renderSupportMarkdown($raw); $this->developmentSnapshotGeneratedAt = null; if (is_file($jsonPath)) { @@ -1015,9 +1015,9 @@ private function startGitSyncJob(): void @file_put_contents($progressPath, json_encode([ 'timestamp' => now()->toIso8601String(), - 'percent' => 1, - 'message' => 'Preparazione sync Git da Gitea...', - 'status' => 'running', + 'percent' => 1, + 'message' => 'Preparazione sync Git da Gitea...', + 'status' => 'running', 'exit_code' => null, ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); @@ -1067,28 +1067,34 @@ private function loadManualSections(): void $documents = [ [ - 'key' => 'manuale-operativo', - 'title' => 'Manuale operativo sviluppo', + 'key' => 'manuale-operativo', + 'title' => 'Manuale operativo sviluppo', 'description' => 'Quadro principale: come stiamo sviluppando, cosa e stato implementato e come continuare senza ripartire da zero.', - 'path' => 'docs/support/MANUALE-SVILUPPO-OPERATIVO.md', + 'path' => 'docs/support/MANUALE-SVILUPPO-OPERATIVO.md', ], [ - 'key' => 'pbx-panasonic', - 'title' => 'PBX Panasonic stato operativo', + 'key' => 'aggiornamenti-staging-git', + 'title' => 'Aggiornamenti staging via Git', + 'description' => 'Flusso ufficiale Day0 -> Gitea -> staging, comando predisposto usato dalla UI e riepilogo dei fix strutturali che stiamo distribuendo.', + 'path' => 'docs/support/AGGIORNAMENTI-STAGING-GIT.md', + ], + [ + 'key' => 'pbx-panasonic', + 'title' => 'PBX Panasonic stato operativo', 'description' => 'Riepilogo strutturato del lavoro fatto su CTI, CSTA, click-to-call, stato attuale e prossimi test sul centralino Panasonic.', - 'path' => 'docs/support/PBX-PANASONIC-STATO-OPERATIVO.md', + 'path' => 'docs/support/PBX-PANASONIC-STATO-OPERATIVO.md', ], [ - 'key' => 'bug-risolti', - 'title' => 'Bug risolti e validazioni', + 'key' => 'bug-risolti', + 'title' => 'Bug risolti e validazioni', 'description' => 'Registro sintetico dei problemi corretti, dello stato di verifica e dei punti ancora da testare.', - 'path' => 'docs/support/BUG-RISOLTI-E-VALIDAZIONI.md', + 'path' => 'docs/support/BUG-RISOLTI-E-VALIDAZIONI.md', ], [ - 'key' => 'continuita-agent', - 'title' => 'Continuita sviluppo e agent', + 'key' => 'continuita-agent', + 'title' => 'Continuita sviluppo e agent', 'description' => 'Regole pratiche per aggiornare documentazione, note commit e materiali utili al riaggancio delle prossime sessioni.', - 'path' => 'docs/support/CONTINUITA-SVILUPPO-AGENT.md', + 'path' => 'docs/support/CONTINUITA-SVILUPPO-AGENT.md', ], ]; @@ -1101,12 +1107,12 @@ private function loadManualSections(): void : '# Documento non trovato' . PHP_EOL . PHP_EOL . '- Path atteso: `' . $relativePath . '`'; $this->manualSections[] = [ - 'key' => (string) $document['key'], - 'title' => (string) $document['title'], + 'key' => (string) $document['key'], + 'title' => (string) $document['title'], 'description' => (string) $document['description'], - 'path' => $relativePath, - 'exists' => $exists, - 'html' => $this->renderSupportMarkdown($raw), + 'path' => $relativePath, + 'exists' => $exists, + 'html' => $this->renderSupportMarkdown($raw), ]; } } @@ -1115,9 +1121,9 @@ public function renderSupportMarkdown(string $markdown): string { $replacements = [ '[E!]' => 'E!', - '[U]' => 'U', - '[P]' => 'P', - '[E]' => 'E', + '[U]' => 'U', + '[P]' => 'P', + '[E]' => 'E', ]; return str_replace( diff --git a/app/Filament/Pages/Supporto/TicketGestione.php b/app/Filament/Pages/Supporto/TicketGestione.php index 1be7387..359f32d 100644 --- a/app/Filament/Pages/Supporto/TicketGestione.php +++ b/app/Filament/Pages/Supporto/TicketGestione.php @@ -325,8 +325,8 @@ public function getSelectedTicketProperty(): ?Ticket return null; } - $stabileId = StabileContext::resolveActiveStabileId($user); - if (! $stabileId) { + $stabileIds = $this->resolveTicketScopeStabileIds(true); + if ($stabileIds === []) { return null; } @@ -341,7 +341,7 @@ public function getSelectedTicketProperty(): ?Ticket 'interventi.fornitore:id,ragione_sociale,nome,cognome', 'interventi.creatoDaUser:id,name', ]) - ->where('stabile_id', $stabileId) + ->whereIn('stabile_id', $stabileIds) ->find($this->selectedTicketId); } @@ -705,8 +705,8 @@ private function loadTickets(): void return; } - $stabileId = StabileContext::resolveActiveStabileId($user); - if (! $stabileId) { + $stabileIds = $this->resolveTicketScopeStabileIds($this->status === 'all'); + if ($stabileIds === []) { $this->tickets = collect(); return; } @@ -714,7 +714,7 @@ private function loadTickets(): void $query = Ticket::query() ->with(['categoriaTicket', 'assegnatoAFornitore:id,ragione_sociale,nome,cognome', 'assegnatoAUser:id,name']) ->withCount('attachments') - ->where('stabile_id', $stabileId); + ->whereIn('stabile_id', $stabileIds); if ($this->status === 'open') { $query->whereIn('stato', ['Aperto', 'Preso in Carico', 'In Lavorazione']); @@ -976,6 +976,36 @@ private function resolveFornitoriBaseQuery() return $query; } + /** + * @return array + */ + private function resolveTicketScopeStabileIds(bool $includeAllAccessible = false): array + { + $user = Auth::user(); + if (! $user instanceof User) { + return []; + } + + $accessibleIds = StabileContext::accessibleStabili($user) + ->pluck('id') + ->map(fn($value) => (int) $value) + ->filter(fn(int $value) => $value > 0) + ->values() + ->all(); + + if ($accessibleIds === []) { + return []; + } + + if ($includeAllAccessible) { + return $accessibleIds; + } + + $activeId = StabileContext::resolveActiveStabileId($user); + + return $activeId ? [$activeId] : [(int) $accessibleIds[0]]; + } + /** * @return array */ @@ -1349,13 +1379,13 @@ private function loadCounters(): void return; } - $stabileId = StabileContext::resolveActiveStabileId($user); - if (! $stabileId) { + $stabileIds = $this->resolveTicketScopeStabileIds($this->status === 'all'); + if ($stabileIds === []) { $this->ticketCounters = ['open' => 0, 'urgent' => 0, 'closed' => 0, 'all' => 0]; return; } - $base = Ticket::query()->where('stabile_id', $stabileId); + $base = Ticket::query()->whereIn('stabile_id', $stabileIds); $this->ticketCounters = [ 'open' => (clone $base)->whereIn('stato', ['Aperto', 'Preso in Carico', 'In Lavorazione'])->count(), @@ -1372,14 +1402,14 @@ private function aggiornaStatoTicket(int $ticketId, string $nuovoStato, bool $as return; } - $stabileId = StabileContext::resolveActiveStabileId($user); - if (! $stabileId) { + $stabileIds = $this->resolveTicketScopeStabileIds(true); + if ($stabileIds === []) { return; } $ticket = Ticket::query() ->where('id', $ticketId) - ->where('stabile_id', $stabileId) + ->whereIn('stabile_id', $stabileIds) ->first(); if (! $ticket) { diff --git a/app/Filament/Pages/Supporto/TicketMobile.php b/app/Filament/Pages/Supporto/TicketMobile.php index b80fb1c..e15f9c1 100644 --- a/app/Filament/Pages/Supporto/TicketMobile.php +++ b/app/Filament/Pages/Supporto/TicketMobile.php @@ -442,7 +442,7 @@ private function searchCaller(): void $needleText = '%' . mb_strtolower($raw) . '%'; $needle = '%' . $digits . '%'; - $this->callerMatches = RubricaUniversale::query() + $matches = RubricaUniversale::query() ->where(function ($q) use ($needle, $needleText, $digits): void { $q->orWhereRaw("LOWER(COALESCE(nome, '')) LIKE ?", [$needleText]) ->orWhereRaw("LOWER(COALESCE(cognome, '')) LIKE ?", [$needleText]) @@ -458,9 +458,18 @@ private function searchCaller(): void ->orderByRaw('CASE WHEN id = ? THEN 0 ELSE 1 END', [(int) ($this->selectedCallerId ?? 0)]) ->orderBy('nome') ->orderBy('cognome') - ->limit(12) + ->limit(50) ->get(); + $selectedCallerId = (int) ($this->selectedCallerId ?? 0); + $this->callerMatches = $matches + ->groupBy(fn(RubricaUniversale $match): string => $this->buildCallerIdentityKey($match)) + ->map(function (Collection $group) use ($selectedCallerId): RubricaUniversale { + return $this->selectCallerRepresentative($group, $selectedCallerId); + }) + ->take(12) + ->values(); + if ($this->selectedCallerId && ! $this->callerMatches->contains('id', $this->selectedCallerId)) { $this->selectedCallerId = null; } @@ -944,6 +953,68 @@ private function normalizePhoneDigits(string $value): string return preg_replace('/\D+/', '', $value) ?: ''; } + private function buildCallerIdentityKey(RubricaUniversale $match): string + { + $fiscalCode = strtoupper(trim((string) ($match->codice_fiscale ?? ''))); + if ($fiscalCode !== '') { + return 'cf:' . $fiscalCode; + } + + $vatNumber = strtoupper(trim((string) ($match->partita_iva ?? ''))); + if ($vatNumber !== '') { + return 'piva:' . $vatNumber; + } + + $email = mb_strtolower(trim((string) ($match->email ?? ''))); + if ($email !== '') { + return 'email:' . $email; + } + + $phone = $this->normalizePhoneDigits((string) ($match->telefono_cellulare ?: $match->telefono_ufficio ?: $match->telefono_casa ?: '')); + $name = mb_strtolower(trim(preg_replace('/\s+/', ' ', (string) ($match->nome_completo ?: $match->ragione_sociale ?: '')))); + + if ($phone !== '') { + return 'phone:' . $phone . '|name:' . $name; + } + + if ($name !== '') { + return 'name:' . $name; + } + + return 'id:' . (string) $match->id; + } + + private function selectCallerRepresentative(Collection $group, int $selectedCallerId): RubricaUniversale + { + $selected = $selectedCallerId > 0 + ? $group->firstWhere('id', $selectedCallerId) + : null; + + $representative = $selected instanceof RubricaUniversale + ? $selected + : $group + ->sortByDesc(fn(RubricaUniversale $match): array => [ + (int) (! empty($match->amministratore_id)), + (int) (! empty($match->riferimento_stabile)), + (int) (! empty($match->riferimento_unita)), + (int) (($match->categoria ?? '') === 'condomino'), + -1 * (int) $match->id, + ]) + ->first(); + + $duplicateCategories = $group + ->pluck('categoria') + ->filter(fn($value) => is_string($value) && trim($value) !== '') + ->unique() + ->values() + ->all(); + + $representative->setAttribute('duplicate_count', $group->count()); + $representative->setAttribute('duplicate_categories', $duplicateCategories); + + return $representative; + } + private function applyLiveCallQueryPrefill(): void { $phone = $this->normalizePhoneDigits((string) request()->query('live_phone', '')); diff --git a/app/Filament/Pages/UnitaImmobiliarePage.php b/app/Filament/Pages/UnitaImmobiliarePage.php index 0566b6c..fa7aa56 100644 --- a/app/Filament/Pages/UnitaImmobiliarePage.php +++ b/app/Filament/Pages/UnitaImmobiliarePage.php @@ -4,13 +4,16 @@ use App\Models\DettaglioRipartizioneSpese; use App\Models\GestioneContabile; use App\Models\Incasso; +use App\Models\Persona; use App\Models\Proprieta; use App\Models\RataEmessaNg; use App\Models\RubricaRuolo; use App\Models\RubricaUniversale; use App\Models\UnitaImmobiliare; +use App\Models\UnitaRecapitoServizio; use App\Models\User; use App\Models\VoceSpesa; +use App\Services\Comunicazioni\RecapitiServizioResolver; use App\Support\AnnoGestioneContext; use App\Support\StabileContext; use BackedEnum; @@ -20,6 +23,7 @@ use Filament\Forms\Components\Hidden; use Filament\Forms\Components\Repeater; use Filament\Forms\Components\Select; +use Filament\Forms\Components\TextInput; use Filament\Forms\Components\Toggle; use Filament\Forms\Concerns\InteractsWithForms; use Filament\Forms\Contracts\HasForms; @@ -81,6 +85,8 @@ public static function canAccess(): bool public array $nominativiStorici = []; + public array $recapitiServizio = []; + protected ?object $legacyCondominRow = null; public array $ripartizioniPerTabella = []; @@ -179,6 +185,7 @@ public function dehydrate(): void $this->dirittiProprieta = []; $this->relazioniPerTipo = []; $this->nominativiStorici = []; + $this->recapitiServizio = []; $this->ripartizioniPerTabella = []; $this->preventiviPerTabella = []; $this->totaliPerGestione = []; @@ -295,6 +302,67 @@ protected function getHeaderActions(): array ]) ->columns(9), ]), + Action::make('recapiti_email') + ->label('Recapiti email') + ->icon('heroicon-o-envelope') + ->modalWidth('7xl') + ->visible(fn(): bool => (bool) $this->unitaId) + ->form([ + Repeater::make('recapiti') + ->label('Recapiti email per servizio') + ->defaultItems(0) + ->reorderable(true) + ->schema([ + Hidden::make('id'), + Select::make('tipo_servizio') + ->label('Servizio') + ->options(UnitaRecapitoServizio::serviceLabels()) + ->native(false) + ->required() + ->columnSpan(3), + Select::make('persona_id') + ->label('Persona collegata') + ->options(fn(): array => $this->getPersonaRecapitoOptions()) + ->searchable() + ->preload() + ->native(false) + ->columnSpan(4), + TextInput::make('email') + ->label('Email da usare') + ->email() + ->required() + ->placeholder('nome@example.it') + ->helperText('Scegli la persona collegata e usa una delle email mostrate nel relativo riepilogo.') + ->columnSpan(5), + TextInput::make('etichetta') + ->label('Etichetta') + ->maxLength(120) + ->columnSpan(4), + TextInput::make('note') + ->label('Note') + ->maxLength(255) + ->columnSpan(4), + Toggle::make('is_default') + ->label('Predefinita') + ->default(true) + ->columnSpan(2), + Toggle::make('attivo') + ->label('Attiva') + ->default(true) + ->columnSpan(2), + ]) + ->columns(12), + ]) + ->fillForm(fn(): array => ['recapiti' => $this->getRecapitiServizioFormRows()]) + ->action(function (array $data): void { + $this->saveRecapitiServizioOverrides($data['recapiti'] ?? []); + $this->loadUnita(); + + \Filament\Notifications\Notification::make() + ->title('Recapiti email aggiornati') + ->success() + ->send(); + }), ]; } @@ -309,6 +377,91 @@ protected function getRuoliLabels(): array ]; } + private function getPersonaRecapitoOptions(): array + { + if (! $this->unita) { + return []; + } + + return $this->unita->relazioniPersoneAttive + ->map(fn($relazione) => $relazione->persona) + ->filter(fn($persona) => $persona instanceof Persona) + ->unique(fn(Persona $persona) => (int) $persona->id) + ->mapWithKeys(function (Persona $persona): array { + $emails = $this->extractPersonaEmails($persona); + $label = $persona->nome_completo; + if ($emails !== []) { + $label .= ' · ' . implode(', ', $emails); + } + + return [(int) $persona->id => $label]; + }) + ->all(); + } + + private function getRecapitiServizioFormRows(): array + { + if (! $this->unita) { + return []; + } + + return $this->unita->unitaRecapitiServizio + ->sortBy(['tipo_servizio', 'ordine', 'id']) + ->values() + ->map(function (UnitaRecapitoServizio $row): array { + return [ + 'id' => (int) $row->id, + 'tipo_servizio' => (string) $row->tipo_servizio, + 'persona_id' => $row->persona_id ? (int) $row->persona_id : null, + 'email' => (string) $row->email, + 'etichetta' => (string) ($row->etichetta ?? ''), + 'note' => (string) ($row->note ?? ''), + 'is_default' => (bool) $row->is_default, + 'attivo' => (bool) $row->attivo, + ]; + }) + ->all(); + } + + private function saveRecapitiServizioOverrides(array $rows): void + { + if (! $this->unita) { + return; + } + + $labels = UnitaRecapitoServizio::serviceLabels(); + + DB::transaction(function () use ($rows, $labels): void { + UnitaRecapitoServizio::query() + ->where('unita_id', (int) $this->unita->id) + ->delete(); + + $ordine = 1; + foreach ($rows as $row) { + $tipoServizio = trim((string) ($row['tipo_servizio'] ?? '')); + $email = mb_strtolower(trim((string) ($row['email'] ?? ''))); + + if ($tipoServizio === '' || $email === '' || ! array_key_exists($tipoServizio, $labels)) { + continue; + } + + UnitaRecapitoServizio::create([ + 'unita_id' => (int) $this->unita->id, + 'persona_id' => isset($row['persona_id']) && is_numeric($row['persona_id']) ? (int) $row['persona_id'] : null, + 'tipo_servizio' => $tipoServizio, + 'email' => $email, + 'etichetta' => $this->normalizeOptionalString($row['etichetta'] ?? null) ?: ($labels[$tipoServizio] ?? null), + 'note' => $this->normalizeOptionalString($row['note'] ?? null), + 'ordine' => $ordine++, + 'is_default' => (bool) ($row['is_default'] ?? true), + 'attivo' => (bool) ($row['attivo'] ?? true), + 'sorgente' => 'manuale', + 'last_confirmed_at' => now(), + ]); + } + }); + } + protected function makeSelfUrl(?int $unitaId = null): string { $base = static::getUrl(panel: 'admin-filament'); @@ -566,6 +719,7 @@ protected function loadUnita(): void $this->dirittiProprieta = []; $this->relazioniPerTipo = []; $this->nominativiStorici = []; + $this->recapitiServizio = []; $this->ripartizioniPerTabella = []; $this->preventiviPerTabella = []; $this->totaliPerGestione = []; @@ -581,6 +735,8 @@ protected function loadUnita(): void 'soggetti', 'stabile', 'dettagliMillesimi.tabellaMillesimale', + 'relazioniPersoneAttive.persona.emailMultiple', + 'unitaRecapitiServizio.persona', ]) ->where('stabile_id', $this->stabileId) ->whereKey($this->unitaId) @@ -591,6 +747,7 @@ protected function loadUnita(): void $this->hydrateDiritti(); $this->hydrateRelazioni(); $this->hydrateNominativiStorici(); + $this->hydrateRecapitiServizio(); $this->hydrateRateEmesse(); $this->hydrateEstrattoCompatto(); $this->hydrateConguagliIniziali(); @@ -626,6 +783,7 @@ public function hydrateNominativiStorici(): void $this->nominativiStorici = $rows->map(function ($row): array { $rawInizio = method_exists($row, 'getRawOriginal') ? $row->getRawOriginal('data_inizio') : null; $rawFine = method_exists($row, 'getRawOriginal') ? $row->getRawOriginal('data_fine') : null; + $payload = is_array($row->legacy_payload) ? $row->legacy_payload : []; $inizioVal = $this->normalizeLegacyDateValue($rawInizio ?? $row->data_inizio ?? null); $fineVal = $this->normalizeLegacyDateValue($rawFine ?? $row->data_fine ?? null); @@ -648,16 +806,107 @@ public function hydrateNominativiStorici(): void $percentuale = number_format((float) $row->percentuale, 3, ',', '.') . '%'; } + $details = []; + $dirittoLabel = trim((string) ($payload['diritto_label'] ?? '')); + if ($dirittoLabel !== '') { + $details[] = 'Diritto: ' . $dirittoLabel; + } + + $legacyYears = $payload['legacy_years'] ?? []; + if (is_array($legacyYears) && $legacyYears !== []) { + $details[] = 'Annualita: ' . implode(', ', array_map('strval', $legacyYears)); + } + + $sourceLabel = match ((string) ($row->fonte ?? '')) { + 'legacy_comproprietari' => 'Legacy comproprietari', + 'legacy_condomin' => 'Legacy condomin', + default => $row->fonte ?: null, + }; + return [ - 'ruolo' => $row->ruolo ?: '—', + 'ruolo' => $this->formatNominativoStoricoRuolo((string) ($row->ruolo ?? ''), (string) ($row->fonte ?? ''), $payload), 'nominativo' => $row->nominativo ?: '—', 'periodo' => $periodo, 'percentuale' => $percentuale, - 'fonte' => $row->fonte ?: null, + 'fonte' => $sourceLabel, + 'detail' => $details === [] ? null : implode(' · ', $details), ]; })->all(); } + private function hydrateRecapitiServizio(): void + { + $this->recapitiServizio = []; + + if (! $this->unita) { + return; + } + + $resolver = app(RecapitiServizioResolver::class); + foreach (UnitaRecapitoServizio::serviceLabels() as $serviceType => $label) { + $rows = $resolver->resolveForUnita($this->unita, $serviceType); + + $this->recapitiServizio[$serviceType] = [ + 'label' => $label, + 'rows' => array_map(function (array $row): array { + $source = (string) ($row['source'] ?? 'persona'); + + return [ + 'email' => (string) ($row['email'] ?? ''), + 'persona' => (string) ($row['persona'] ?? ''), + 'label' => (string) ($row['label'] ?? ''), + 'source' => $source, + 'sourceLabel' => $source === 'override_unita' ? 'Override unita' : 'Anagrafica persona', + ]; + }, $rows), + ]; + } + } + + private function formatNominativoStoricoRuolo(string $ruolo, string $fonte, array $payload): string + { + if ($fonte === 'legacy_comproprietari') { + $dirittoLabel = trim((string) ($payload['diritto_label'] ?? '')); + return $dirittoLabel !== '' ? $dirittoLabel : 'Comproprietario'; + } + + return match (strtoupper($ruolo)) { + 'I' => 'Inquilino', + 'C' => 'Condomino', + default => $ruolo !== '' ? $ruolo : '—', + }; + } + + private function extractPersonaEmails(Persona $persona): array + { + $emails = []; + + $primary = mb_strtolower(trim((string) ($persona->email_principale ?? ''))); + if ($primary !== '') { + $emails[] = $primary; + } + + foreach ($persona->emailMultiple as $row) { + if (! $row->attiva) { + continue; + } + + $email = mb_strtolower(trim((string) ($row->email ?? ''))); + if ($email !== '') { + $emails[] = $email; + } + } + + return array_values(array_unique($emails)); + } + + private function normalizeOptionalString(mixed $value): ?string + { + $string = trim((string) ($value ?? '')); + + return $string !== '' ? $string : null; + } + protected function hydrateRateEmesse(): void { $this->rateEmessePerCategoria = [ diff --git a/app/Http/Controllers/Admin/CodeQualityController.php b/app/Http/Controllers/Admin/CodeQualityController.php index 9c4a526..817f294 100755 --- a/app/Http/Controllers/Admin/CodeQualityController.php +++ b/app/Http/Controllers/Admin/CodeQualityController.php @@ -1,450 +1,455 @@ [ - 'name' => 'Layout NetGescon Standard', - 'description' => 'Le view Blade devono estendere admin.layouts.netgescon', - 'pattern' => "/@extends\(['\"]admin\\.layouts\\.netgescon['\"]\)/m", - 'suggestion' => "@extends('admin.layouts.netgescon')", - 'severity' => 'error', - 'mode' => 'require', - 'fileTypes' => ['blade'], + 'layout_required' => [ + 'name' => 'Layout NetGescon Standard', + 'description' => 'Le view Blade devono estendere admin.layouts.netgescon', + 'pattern' => "/@extends\(['\"]admin\\.layouts\\.netgescon['\"]\)/m", + 'suggestion' => "@extends('admin.layouts.netgescon')", + 'severity' => 'error', + 'mode' => 'require', + 'fileTypes' => ['blade'], // Escludi frammenti inclusi (partials/tabs) e componenti Blade 'excludeContains' => [ 'resources/views/admin/gescon-import/partials', 'resources/views/admin/gescon-import/tabs', 'resources/views/components/menu', 'resources/views/components/', - 'resources/views/admin/documenti/print-list.blade.php' - ] + 'resources/views/admin/documenti/print-list.blade.php', + ], ], // Vietato l'uso del vecchio layout - 'layout_forbidden_app' => [ - 'name' => 'Layout Obsoleto (layouts.app)', + 'layout_forbidden_app' => [ + 'name' => 'Layout Obsoleto (layouts.app)', 'description' => 'Evita l\'uso del vecchio layout layouts.app', - 'pattern' => "/@extends\(['\"]layouts\\.app['\"]\)/m", - 'suggestion' => "Sostituisci con @extends('admin.layouts.netgescon')", - 'severity' => 'error', - 'mode' => 'prohibit', - 'fileTypes' => ['blade'] + 'pattern' => "/@extends\(['\"]layouts\\.app['\"]\)/m", + 'suggestion' => "Sostituisci con @extends('admin.layouts.netgescon')", + 'severity' => 'error', + 'mode' => 'prohibit', + 'fileTypes' => ['blade'], ], - 'bootstrap_classes' => [ - 'name' => 'Classi Bootstrap/Tailwind Obsolete', - 'description' => 'Rileva usage di classi Bootstrap o Tailwind non allineate allo stile NetGescon', - 'pattern' => '/(' - . '(? 'Usare classi NetGescon: netgescon-btn, netgescon-card, netgescon-text, ecc.', - 'severity' => 'warning', - 'mode' => 'prohibit', - 'fileTypes' => ['blade'], + 'bootstrap_classes' => [ + 'name' => 'Classi Bootstrap/Tailwind Obsolete', + 'description' => 'Rileva usage di classi Bootstrap o Tailwind non allineate allo stile NetGescon', + 'pattern' => '/(' + . '(? 'Usare classi NetGescon: netgescon-btn, netgescon-card, netgescon-text, ecc.', + 'severity' => 'warning', + 'mode' => 'prohibit', + 'fileTypes' => ['blade'], 'excludeContains' => [ - 'resources/views/components/menu' - ] + 'resources/views/components/menu', + ], ], - 'netgescon_components' => [ - 'name' => 'Componenti NetGescon', + 'netgescon_components' => [ + 'name' => 'Componenti NetGescon', 'description' => 'Rileva uso dei componenti NetGescon standard (informativo)', - 'pattern' => '/(netgescon-btn|netgescon-card|netgescon-title|netgescon-text|netgescon-input)/m', - 'suggestion' => 'Componenti NetGescon rilevati', - 'severity' => 'info', - 'mode' => 'detect', - 'fileTypes' => ['blade'] + 'pattern' => '/(netgescon-btn|netgescon-card|netgescon-title|netgescon-text|netgescon-input)/m', + 'suggestion' => 'Componenti NetGescon rilevati', + 'severity' => 'info', + 'mode' => 'detect', + 'fileTypes' => ['blade'], ], - 'controller_namespace' => [ - 'name' => 'Namespace Controller', - 'description' => 'I controller devono avere un namespace valido', - 'pattern' => '/namespace\\s+App\\\\Http\\\\Controllers(\\\\[A-Za-z]+)?;/', - 'suggestion' => 'namespace App\\Http\\Controllers\\Admin; (per i controller admin)', - 'severity' => 'error', - 'mode' => 'require', - 'fileTypes' => ['php'], - 'pathContains' => ['app/Http/Controllers'] + 'controller_namespace' => [ + 'name' => 'Namespace Controller', + 'description' => 'I controller devono avere un namespace valido', + 'pattern' => '/namespace\\s+App\\\\Http\\\\Controllers(\\\\[A-Za-z]+)?;/', + 'suggestion' => 'namespace App\\Http\\Controllers\\Admin; (per i controller admin)', + 'severity' => 'error', + 'mode' => 'require', + 'fileTypes' => ['php'], + 'pathContains' => ['app/Http/Controllers'], ], - 'model_relationships' => [ - 'name' => 'Relazioni Eloquent', + 'model_relationships' => [ + 'name' => 'Relazioni Eloquent', 'description' => 'Rileva la definizione delle relazioni Eloquent (informativo)', - 'pattern' => '/(belongsTo|hasMany|hasOne|belongsToMany)\s*\(\s*[\'\"][A-Z]/m', - 'suggestion' => 'Relazioni Eloquent definite correttamente', - 'severity' => 'info', - 'mode' => 'detect', - 'fileTypes' => ['php'] + 'pattern' => '/(belongsTo|hasMany|hasOne|belongsToMany)\s*\(\s*[\'\"][A-Z]/m', + 'suggestion' => 'Relazioni Eloquent definite correttamente', + 'severity' => 'info', + 'mode' => 'detect', + 'fileTypes' => ['php'], ], - 'deprecated_helpers' => [ - 'name' => 'Helper Deprecati', + 'deprecated_helpers' => [ + 'name' => 'Helper Deprecati', 'description' => 'Rileva helper Laravel deprecati (array_get, array_set, str_is, starts_with, ends_with)', - 'pattern' => '/\\b(array_get|array_set|str_is|starts_with|ends_with)\s*\(/m', - 'suggestion' => 'Usare data_get(), Arr::set(), Str::is(), Str::startsWith(), Str::endsWith() o funzioni PHP 8', - 'severity' => 'warning', - 'mode' => 'prohibit', - 'fileTypes' => ['php'] + 'pattern' => '/\\b(array_get|array_set|str_is|starts_with|ends_with)\s*\(/m', + 'suggestion' => 'Usare data_get(), Arr::set(), Str::is(), Str::startsWith(), Str::endsWith() o funzioni PHP 8', + 'severity' => 'warning', + 'mode' => 'prohibit', + 'fileTypes' => ['php'], ], - 'security_issues' => [ - 'name' => 'Problemi di Sicurezza', + 'security_issues' => [ + 'name' => 'Problemi di Sicurezza', 'description' => 'Rileva accesso diretto a superglobali o SQL non parametrizzato', - 'pattern' => '/(\$_GET|\$_POST|\$_REQUEST|DB::raw\s*\(\s*[\'\"][^\'\"]*(SELECT|INSERT|UPDATE|DELETE))/im', - 'suggestion' => 'Usare Request validation e Query Builder/parametri', - 'severity' => 'critical', - 'mode' => 'prohibit', - 'fileTypes' => ['php'] + 'pattern' => '/(\$_GET|\$_POST|\$_REQUEST|DB::raw\s*\(\s*[\'\"][^\'\"]*(SELECT|INSERT|UPDATE|DELETE))/im', + 'suggestion' => 'Usare Request validation e Query Builder/parametri', + 'severity' => 'critical', + 'mode' => 'prohibit', + 'fileTypes' => ['php'], ], - 'migration_standards' => [ - 'name' => 'Standard Migration', - 'description' => 'Verifica uso delle API Laravel Migration (informativo)', - 'pattern' => '/(Schema::create|Schema::table|Schema::dropIfExists)/m', - 'suggestion' => 'Uso corretto delle Migration rilevato', - 'severity' => 'info', - 'mode' => 'detect', - 'fileTypes' => ['php'], - 'pathContains' => ['database/migrations'] + 'migration_standards' => [ + 'name' => 'Standard Migration', + 'description' => 'Verifica uso delle API Laravel Migration (informativo)', + 'pattern' => '/(Schema::create|Schema::table|Schema::dropIfExists)/m', + 'suggestion' => 'Uso corretto delle Migration rilevato', + 'severity' => 'info', + 'mode' => 'detect', + 'fileTypes' => ['php'], + 'pathContains' => ['database/migrations'], ], // Debug Blade: bilanciamento @section/@endsection - 'blade_sections_balance' => [ - 'name' => 'Bilanciamento Sezioni Blade', + 'blade_sections_balance' => [ + 'name' => 'Bilanciamento Sezioni Blade', 'description' => 'Ogni @section deve avere un @endsection corrispondente', - 'pattern' => '/@section\b|@endsection\b/m', - 'suggestion' => 'Aggiungi o rimuovi le direttive per bilanciare le sezioni', - 'severity' => 'error', - 'mode' => 'detect', - 'fileTypes' => ['blade'] + 'pattern' => '/@section\b|@endsection\b/m', + 'suggestion' => 'Aggiungi o rimuovi le direttive per bilanciare le sezioni', + 'severity' => 'error', + 'mode' => 'detect', + 'fileTypes' => ['blade'], ], // Debug Blade: div bilanciati - 'unbalanced_divs' => [ - 'name' => 'Div non bilanciati', + 'unbalanced_divs' => [ + 'name' => 'Div non bilanciati', 'description' => 'Numero di
aperti non corrisponde ai
chiusi', - 'pattern' => '//im', - 'balanceTag' => 'div', - 'suggestion' => 'Verifica la struttura dei contenitori e chiudi tutti i div', - 'severity' => 'error', - 'mode' => 'detect', - 'fileTypes' => ['blade'] + 'pattern' => '//im', + 'balanceTag' => 'div', + 'suggestion' => 'Verifica la struttura dei contenitori e chiudi tutti i div', + 'severity' => 'error', + 'mode' => 'detect', + 'fileTypes' => ['blade'], ], - 'unbalanced_navs' => [ - 'name' => 'Nav non bilanciati', + 'unbalanced_navs' => [ + 'name' => 'Nav non bilanciati', 'description' => 'Numero di chiusi', - 'pattern' => '//im', - 'balanceTag' => 'nav', - 'suggestion' => 'Verifica che