From 4c4de5e734c353b4a235bc65919d0528afa10f8d Mon Sep 17 00:00:00 2001 From: michele Date: Wed, 2 Sep 2026 17:57:50 +0200 Subject: [PATCH] =?UTF-8?q?feat(unita):=20multicanale=20email=20split,=20r?= =?UTF-8?q?iorganizzazione=20tab=20millesimi/comunicazioni,=20protocollo?= =?UTF-8?q?=20protoc=5Fec=20e=20contabilit=C3=A0=20BPM?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../GesconBonificaStabile0013Command.php | 122 ++++ .../Contabilita/CasseBancheMovimenti.php | 20 +- .../Contabilita/CasseBancheRiepilogo.php | 4 +- app/Filament/Pages/UnitaImmobiliarePage.php | 498 +++++++++---- app/Services/Contabilita/BpmCsvParser.php | 187 +++++ .../Contabilita/MovimentiBancaImporter.php | 62 +- .../pages/unita-immobiliare.blade.php | 683 ++++++++---------- skill-netgescon/control-tower/CURRENT-205.md | 53 +- .../ui-wireframes/utente-multi-ruolo.md | 6 +- tests/Feature/UnitaImmobiliarePageTest.php | 110 ++- 10 files changed, 1144 insertions(+), 601 deletions(-) create mode 100644 app/Services/Contabilita/BpmCsvParser.php diff --git a/app/Console/Commands/GesconBonificaStabile0013Command.php b/app/Console/Commands/GesconBonificaStabile0013Command.php index 7f87872..ad9b0d7 100644 --- a/app/Console/Commands/GesconBonificaStabile0013Command.php +++ b/app/Console/Commands/GesconBonificaStabile0013Command.php @@ -266,6 +266,128 @@ public function handle(): int } } + // 9. Sincronizzazione tabelle Protocollo Comunicazioni ed Estratti Conto da generale_stabile.mdb + $mdbPath = '/mnt/gescon-archives/gescon/0013/generale_stabile.mdb'; + if (file_exists($mdbPath)) { + if (! Schema::connection('gescon_import')->hasTable('protoc_ec')) { + Schema::connection('gescon_import')->create('protoc_ec', function ($table) { + $table->increments('id'); + $table->string('cod_stabile')->index(); + $table->integer('id_corrisp')->nullable(); + $table->string('protocollo')->nullable()->index(); + $table->string('tipo_documento')->nullable(); + $table->string('data_invio')->nullable(); + $table->string('forn_cond_altro')->nullable(); + $table->string('codice')->nullable(); + $table->string('destinatario')->nullable(); + $table->string('oggetto')->nullable(); + $table->text('note')->nullable(); + $table->string('scala')->nullable()->index(); + $table->string('interno')->nullable()->index(); + $table->string('c_i')->nullable(); + $table->string('nome')->nullable(); + $table->decimal('totale', 12, 2)->default(0); + $table->string('nome_pdf')->nullable(); + $table->integer('id_condomino')->nullable()->index(); + $table->timestamps(); + }); + } + + if (! Schema::connection('gescon_import')->hasTable('corrisp_inviata')) { + Schema::connection('gescon_import')->create('corrisp_inviata', function ($table) { + $table->increments('id'); + $table->string('cod_stabile')->index(); + $table->integer('id_corrisp')->nullable(); + $table->string('protocollo')->nullable()->index(); + $table->string('tipo_documento')->nullable(); + $table->string('data_invio')->nullable(); + $table->string('forn_cond_altro')->nullable(); + $table->string('codice')->nullable(); + $table->string('destinatario')->nullable(); + $table->string('oggetto')->nullable(); + $table->string('lettera_tipo_caricata')->nullable(); + $table->text('note')->nullable(); + $table->integer('id_cond_for')->nullable()->index(); + $table->timestamps(); + }); + } + + try { + // Protoc_EC + $csvEc = shell_exec('mdb-export ' . escapeshellarg($mdbPath) . ' Protoc_EC 2>/dev/null'); + if ($csvEc) { + $lines = explode("\n", $csvEc); + $header = str_getcsv(array_shift($lines)); + $ecRows = []; + foreach ($lines as $l) { + if (trim($l) === '') continue; + $data = str_getcsv($l); + if (count($data) < count($header)) continue; + $row = array_combine($header, $data); + $ecRows[] = [ + 'cod_stabile' => '0013', + 'id_corrisp' => is_numeric($row['ID_corrisp'] ?? null) ? (int) $row['ID_corrisp'] : null, + 'protocollo' => $row['protocollo'] ?? null, + 'tipo_documento' => $row['Tipo_documento'] ?? null, + 'data_invio' => $row['Data'] ?? null, + 'forn_cond_altro' => $row['Forn_Cond_Altro'] ?? null, + 'codice' => $row['Codice'] ?? null, + 'destinatario' => $row['Destinatario'] ?? null, + 'oggetto' => $row['Oggetto'] ?? null, + 'note' => $row['Note'] ?? null, + 'scala' => $row['Scala'] ?? null, + 'interno' => $row['Interno'] ?? null, + 'c_i' => $row['C_I'] ?? null, + 'nome' => $row['Nome'] ?? null, + 'totale' => is_numeric($row['Totale'] ?? null) ? (float) $row['Totale'] : 0, + 'nome_pdf' => $row['Nome_pdf'] ?? null, + 'id_condomino' => is_numeric($row['id_condomino'] ?? null) ? (int) $row['id_condomino'] : null, + 'created_at' => now(), + 'updated_at' => now(), + ]; + } + DB::connection('gescon_import')->table('protoc_ec')->where('cod_stabile', '0013')->delete(); + DB::connection('gescon_import')->table('protoc_ec')->insert($ecRows); + $this->info('Protoc_EC sincronizzato: ' . count($ecRows) . ' record.'); + } + + // Corrisp_inviata + $csvCorr = shell_exec('mdb-export ' . escapeshellarg($mdbPath) . ' Corrisp_inviata 2>/dev/null'); + if ($csvCorr) { + $lines = explode("\n", $csvCorr); + $header = str_getcsv(array_shift($lines)); + $corrRows = []; + foreach ($lines as $l) { + if (trim($l) === '') continue; + $data = str_getcsv($l); + if (count($data) < count($header)) continue; + $row = array_combine($header, $data); + $corrRows[] = [ + 'cod_stabile' => '0013', + 'id_corrisp' => is_numeric($row['ID_corrisp'] ?? null) ? (int) $row['ID_corrisp'] : null, + 'protocollo' => $row['protocollo'] ?? null, + 'tipo_documento' => $row['Tipo_documento'] ?? null, + 'data_invio' => $row['Data'] ?? null, + 'forn_cond_altro' => $row['Forn_Cond_Altro'] ?? null, + 'codice' => $row['Codice'] ?? null, + 'destinatario' => $row['Destinatario'] ?? null, + 'oggetto' => $row['Oggetto'] ?? null, + 'lettera_tipo_caricata' => $row['lettera_tipo_caricata'] ?? ($row['Lettera_tipo_caricata'] ?? null), + 'note' => $row['Note'] ?? null, + 'id_cond_for' => is_numeric($row['id_cond_for'] ?? null) ? (int) $row['id_cond_for'] : null, + 'created_at' => now(), + 'updated_at' => now(), + ]; + } + DB::connection('gescon_import')->table('corrisp_inviata')->where('cod_stabile', '0013')->delete(); + DB::connection('gescon_import')->table('corrisp_inviata')->insert($corrRows); + $this->info('Corrisp_inviata sincronizzato: ' . count($corrRows) . ' record.'); + } + } catch (\Throwable $e) { + $this->warn('Avviso sincronizzazione protocollo: ' . $e->getMessage()); + } + } + $this->info('Bonifica Stabile 0013 completata con successo!'); return 0; } diff --git a/app/Filament/Pages/Contabilita/CasseBancheMovimenti.php b/app/Filament/Pages/Contabilita/CasseBancheMovimenti.php index f2ea952..0c6ab72 100755 --- a/app/Filament/Pages/Contabilita/CasseBancheMovimenti.php +++ b/app/Filament/Pages/Contabilita/CasseBancheMovimenti.php @@ -118,11 +118,11 @@ public static function canAccess(): bool return false; } - if ($user->hasAnyRole(['super-admin', 'admin'])) { + if ($user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'super_admin'])) { return true; } - return $user->can('contabilita.casse-banche'); + return $user->can('contabilita.casse-banche') || ($user->is_super_admin ?? false) || ($user->is_admin ?? false); } public function mount(): void @@ -772,6 +772,7 @@ protected function getHeaderActions(): array ->options(function (): array { return [ 'auto_csv' => 'CSV banca (rilevamento automatico)', + 'bpm_csv' => 'Banco BPM CSV', 'mps_qif' => 'MPS QIF', 'unicredit_wri' => 'Unicredit WRI', 'intesa_csv' => 'Intesa CSV', @@ -780,7 +781,7 @@ protected function getHeaderActions(): array }) ->default(fn(): string => $this->resolvePreferredImportFormatForConto($this->contoId)) ->required() - ->helperText('Un solo flusso di import: scegli il formato corretto per il conto selezionato. Per Monte dei Paschi usa QIF.'), + ->helperText('Un solo flusso di import: scegli il formato corretto per il conto selezionato (Banco BPM, MPS, Unicredit, Intesa).'), Select::make('gestione_id') ->label('Gestione (opzionale)') @@ -900,6 +901,14 @@ protected function getHeaderActions(): array $gestioneId, $replace, ), + 'bpm_csv' => $importer->importBpmCsvForConto( + Storage::disk('local')->get($path), + $stabileId, + $contoId, + $filename, + $gestioneId, + $replace, + ), 'unicredit_wri' => $importer->importUnicreditWriForConto( Storage::disk('local')->get($path), $stabileId, @@ -3980,6 +3989,10 @@ protected function resolvePreferredImportFormatForConto(?int $contoId): string $conto['numero_conto'] ?? null, ], static fn($value): bool => is_string($value) && trim($value) !== ''))); + if (str_contains($haystack, 'bpm') || str_contains($haystack, 'banco bpm') || str_contains($haystack, 'popolare di milano')) { + return 'bpm_csv'; + } + if (str_contains($haystack, 'mps') || str_contains($haystack, 'monte dei paschi') || str_contains($haystack, 'paschi')) { return 'mps_qif'; } @@ -4007,6 +4020,7 @@ protected function makeMovimentiImporter(): MovimentiBancaImporter null, null, new MpsQifParser(), + new \App\Services\Contabilita\BpmCsvParser(), ); } diff --git a/app/Filament/Pages/Contabilita/CasseBancheRiepilogo.php b/app/Filament/Pages/Contabilita/CasseBancheRiepilogo.php index e03c422..598176d 100755 --- a/app/Filament/Pages/Contabilita/CasseBancheRiepilogo.php +++ b/app/Filament/Pages/Contabilita/CasseBancheRiepilogo.php @@ -69,11 +69,11 @@ public static function canAccess(): bool return false; } - if ($user->hasAnyRole(['super-admin', 'admin'])) { + if ($user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'super_admin'])) { return true; } - return $user->can('contabilita.casse-banche'); + return $user->can('contabilita.casse-banche') || ($user->is_super_admin ?? false) || ($user->is_admin ?? false); } public function mount(): void diff --git a/app/Filament/Pages/UnitaImmobiliarePage.php b/app/Filament/Pages/UnitaImmobiliarePage.php index 8800479..b0935d2 100755 --- a/app/Filament/Pages/UnitaImmobiliarePage.php +++ b/app/Filament/Pages/UnitaImmobiliarePage.php @@ -540,75 +540,119 @@ public function getRecapitiMulticanaleTableProperty(): array } $anagId = (int) ($entity['persona_id'] ?? ($entity['soggetto_id'] ?? 0)); - $anag = $anagId > 0 ? DB::table('anagrafiche')->find($anagId) : null; + $anag = null; + if ($anagId > 0) { + if (DbSchema::hasTable('anagrafiche')) { + $anag = DB::table('anagrafiche')->find($anagId); + } + if (! $anag && DbSchema::hasTable('persone')) { + $anag = DB::table('persone')->find($anagId); + } + } if (! $anag) { - $anag = DB::table('anagrafiche') - ->where(function ($q) use ($nome, $entity) { - $q->where('cognome', 'like', '%' . $nome . '%') - ->orWhere('nome', 'like', '%' . $nome . '%') - ->orWhere('ragione_sociale', 'like', '%' . $nome . '%'); - if (! empty($entity['codice_fiscale'])) { - $q->orWhere('codice_fiscale', trim((string) $entity['codice_fiscale'])); - } - }) - ->first(); + if (DbSchema::hasTable('anagrafiche')) { + $anag = DB::table('anagrafiche') + ->where(function ($q) use ($nome, $entity) { + $q->where('cognome', 'like', '%' . $nome . '%') + ->orWhere('nome', 'like', '%' . $nome . '%') + ->orWhere('ragione_sociale', 'like', '%' . $nome . '%'); + if (! empty($entity['codice_fiscale'])) { + $q->orWhere('codice_fiscale', trim((string) $entity['codice_fiscale'])); + } + }) + ->first(); + } + if (! $anag && DbSchema::hasTable('persone')) { + $anag = DB::table('persone') + ->where(function ($q) use ($nome, $entity) { + $q->where('cognome', 'like', '%' . $nome . '%') + ->orWhere('nome', 'like', '%' . $nome . '%'); + if (! empty($entity['codice_fiscale'])) { + $q->orWhere('codice_fiscale', trim((string) $entity['codice_fiscale'])); + } + }) + ->first(); + } } - if ($anag) { - if (! empty($anag->email)) { - $tableRows[] = [ - 'canale' => 'Email', - 'icon' => '✉️', - 'color' => 'bg-blue-50 text-blue-700 border-blue-200', - 'etichetta' => 'Principale', - 'soggetto' => $nome, - 'ruolo' => $ruolo, - 'valore' => $anag->email, - 'action_url' => 'mailto:' . $anag->email, - 'is_principal' => true, - ]; + $rawEmail = ! empty($anag->email) ? (string) $anag->email : (! empty($anag->email_principale) ? (string) $anag->email_principale : (! empty($entity['email']) ? (string) $entity['email'] : '')); + $rawPec = ! empty($anag->pec) ? (string) $anag->pec : (! empty($anag->email_pec) ? (string) $anag->email_pec : (! empty($entity['pec']) ? (string) $entity['pec'] : '')); + $rawTel = ! empty($anag->telefono) ? (string) $anag->telefono : (! empty($anag->telefono_principale) ? (string) $anag->telefono_principale : (! empty($entity['telefono']) ? (string) $entity['telefono'] : '')); + $rawInd = ! empty($anag->indirizzo) ? trim($anag->indirizzo . ' ' . ($anag->cap ?? '') . ' ' . ($anag->citta ?? '') . ' ' . ($anag->provincia ? '(' . $anag->provincia . ')' : '')) : (! empty($anag->residenza_via) ? (string) $anag->residenza_via : (! empty($entity['indirizzo']) ? (string) $entity['indirizzo'] : '')); + + if ($rawEmail !== '') { + $parts = preg_split('/[,;\s]+/', $rawEmail, -1, PREG_SPLIT_NO_EMPTY); + foreach ($parts as $idx => $em) { + $em = trim($em); + if ($em !== '' && ! $this->hasContactValue($tableRows, $em)) { + $prefix = explode('@', $em)[0]; + $cleanPrefix = ucwords(str_replace(['.', '_', '-'], ' ', $prefix)); + $lbl = count($parts) > 1 ? ($idx === 0 ? 'Principale (' . $cleanPrefix . ')' : 'Email ' . $cleanPrefix) : 'Principale'; + $tableRows[] = [ + 'canale' => 'Email', + 'icon' => '✉️', + 'color' => 'bg-blue-50 text-blue-700 border-blue-200', + 'etichetta' => $lbl, + 'soggetto' => $nome, + 'ruolo' => $ruolo, + 'valore' => $em, + 'action_url' => 'mailto:' . $em, + 'is_principal' => $idx === 0 && count($tableRows) === 0, + ]; + } } - if (! empty($anag->pec)) { - $tableRows[] = [ - 'canale' => 'Email PEC', - 'icon' => '🛡️', - 'color' => 'bg-emerald-50 text-emerald-700 border-emerald-200', - 'etichetta' => 'PEC Ufficiale', - 'soggetto' => $nome, - 'ruolo' => $ruolo, - 'valore' => $anag->pec, - 'action_url' => 'mailto:' . $anag->pec, - 'is_principal' => false, - ]; + } + if ($rawPec !== '') { + $parts = preg_split('/[,;\s]+/', $rawPec, -1, PREG_SPLIT_NO_EMPTY); + foreach ($parts as $idx => $pc) { + $pc = trim($pc); + if ($pc !== '' && ! $this->hasContactValue($tableRows, $pc)) { + $tableRows[] = [ + 'canale' => 'Email PEC', + 'icon' => '🛡️', + 'color' => 'bg-emerald-50 text-emerald-700 border-emerald-200', + 'etichetta' => count($parts) > 1 ? 'PEC #' . ($idx + 1) : 'PEC Ufficiale', + 'soggetto' => $nome, + 'ruolo' => $ruolo, + 'valore' => $pc, + 'action_url' => 'mailto:' . $pc, + 'is_principal' => false, + ]; + } } - if (! empty($anag->telefono)) { - $tableRows[] = [ - 'canale' => 'Cellulare / Tel', - 'icon' => '📱', - 'color' => 'bg-amber-50 text-amber-700 border-amber-200', - 'etichetta' => 'Personale', - 'soggetto' => $nome, - 'ruolo' => $ruolo, - 'valore' => $anag->telefono, - 'action_url' => 'tel:' . $anag->telefono, - 'is_principal' => false, - ]; - } - if (! empty($anag->indirizzo)) { - $indFull = trim($anag->indirizzo . ' ' . ($anag->cap ?? '') . ' ' . ($anag->citta ?? '') . ' ' . ($anag->provincia ? '(' . $anag->provincia . ')' : '')); - $tableRows[] = [ - 'canale' => 'Indirizzo', - 'icon' => '📍', - 'color' => 'bg-slate-50 text-slate-700 border-slate-200', - 'etichetta' => 'Residenza / Sede', - 'soggetto' => $nome, - 'ruolo' => $ruolo, - 'valore' => $indFull, - 'action_url' => null, - 'is_principal' => false, - ]; + } + if ($rawTel !== '') { + $parts = preg_split('/[,;\/]+/', $rawTel, -1, PREG_SPLIT_NO_EMPTY); + foreach ($parts as $idx => $ph) { + $ph = trim($ph); + if ($ph !== '' && ! $this->hasContactValue($tableRows, $ph)) { + $tableRows[] = [ + 'canale' => 'Cellulare / Tel', + 'icon' => '📱', + 'color' => 'bg-amber-50 text-amber-700 border-amber-200', + 'etichetta' => count($parts) > 1 ? 'Telefono #' . ($idx + 1) : 'Personale', + 'soggetto' => $nome, + 'ruolo' => $ruolo, + 'valore' => $ph, + 'action_url' => 'tel:' . preg_replace('/[^\d\+]/', '', $ph), + 'is_principal' => false, + ]; + } } } + if ($rawInd !== '') { + $tableRows[] = [ + 'canale' => 'Indirizzo', + 'icon' => '📍', + 'color' => 'bg-slate-50 text-slate-700 border-slate-200', + 'etichetta' => 'Residenza / Sede', + 'soggetto' => $nome, + 'ruolo' => $ruolo, + 'valore' => $rawInd, + 'action_url' => null, + 'is_principal' => false, + ]; + } } // 2. Dati da Legacy condomin_mirror @@ -618,83 +662,125 @@ public function getRecapitiMulticanaleTableProperty(): array $condName = trim((string) ($legacyRow->nom_cond ?? 'Condomino')); $inqName = trim((string) ($legacyRow->inquil_nome ?? ($legacyRow->inquilino ?? 'Inquilino'))); - if (! empty($p['E_mail_condomino']) && ! $this->hasContactValue($tableRows, (string) $p['E_mail_condomino'])) { - $tableRows[] = [ - 'canale' => 'Email', - 'icon' => '✉️', - 'color' => 'bg-blue-50 text-blue-700 border-blue-200', - 'etichetta' => 'Email Condomino', - 'soggetto' => $condName, - 'ruolo' => 'Condomino', - 'valore' => trim((string) $p['E_mail_condomino']), - 'action_url' => 'mailto:' . trim((string) $p['E_mail_condomino']), - 'is_principal' => count($tableRows) === 0, - ]; + if (! empty($p['E_mail_condomino'])) { + $parts = preg_split('/[,;\s]+/', (string) $p['E_mail_condomino'], -1, PREG_SPLIT_NO_EMPTY); + foreach ($parts as $idx => $em) { + $em = trim($em); + if ($em !== '' && ! $this->hasContactValue($tableRows, $em)) { + $prefix = explode('@', $em)[0]; + $cleanPrefix = ucwords(str_replace(['.', '_', '-'], ' ', $prefix)); + $lbl = count($parts) > 1 ? ($idx === 0 ? 'Email Condomino (' . $cleanPrefix . ')' : 'Email ' . $cleanPrefix) : 'Email Condomino'; + $tableRows[] = [ + 'canale' => 'Email', + 'icon' => '✉️', + 'color' => 'bg-blue-50 text-blue-700 border-blue-200', + 'etichetta' => $lbl, + 'soggetto' => $condName, + 'ruolo' => 'Condomino', + 'valore' => $em, + 'action_url' => 'mailto:' . $em, + 'is_principal' => count($tableRows) === 0, + ]; + } + } } - if (! empty($p['PEC_condomino']) && ! $this->hasContactValue($tableRows, (string) $p['PEC_condomino'])) { - $tableRows[] = [ - 'canale' => 'Email PEC', - 'icon' => '🛡️', - 'color' => 'bg-emerald-50 text-emerald-700 border-emerald-200', - 'etichetta' => 'PEC Condomino', - 'soggetto' => $condName, - 'ruolo' => 'Condomino', - 'valore' => trim((string) $p['PEC_condomino']), - 'action_url' => 'mailto:' . trim((string) $p['PEC_condomino']), - 'is_principal' => false, - ]; + if (! empty($p['PEC_condomino'])) { + $parts = preg_split('/[,;\s]+/', (string) $p['PEC_condomino'], -1, PREG_SPLIT_NO_EMPTY); + foreach ($parts as $idx => $pc) { + $pc = trim($pc); + if ($pc !== '' && ! $this->hasContactValue($tableRows, $pc)) { + $tableRows[] = [ + 'canale' => 'Email PEC', + 'icon' => '🛡️', + 'color' => 'bg-emerald-50 text-emerald-700 border-emerald-200', + 'etichetta' => count($parts) > 1 ? 'PEC Condomino #' . ($idx + 1) : 'PEC Condomino', + 'soggetto' => $condName, + 'ruolo' => 'Condomino', + 'valore' => $pc, + 'action_url' => 'mailto:' . $pc, + 'is_principal' => false, + ]; + } + } } - if (! empty($p['Cell_cond']) && ! $this->hasContactValue($tableRows, (string) $p['Cell_cond'])) { - $tableRows[] = [ - 'canale' => 'Cellulare', - 'icon' => '📱', - 'color' => 'bg-amber-50 text-amber-700 border-amber-200', - 'etichetta' => 'Cellulare Condomino', - 'soggetto' => $condName, - 'ruolo' => 'Condomino', - 'valore' => trim((string) $p['Cell_cond']), - 'action_url' => 'tel:' . trim((string) $p['Cell_cond']), - 'is_principal' => false, - ]; + if (! empty($p['Cell_cond'])) { + $parts = preg_split('/[,;\/]+/', (string) $p['Cell_cond'], -1, PREG_SPLIT_NO_EMPTY); + foreach ($parts as $idx => $ph) { + $ph = trim($ph); + if ($ph !== '' && ! $this->hasContactValue($tableRows, $ph)) { + $tableRows[] = [ + 'canale' => 'Cellulare', + 'icon' => '📱', + 'color' => 'bg-amber-50 text-amber-700 border-amber-200', + 'etichetta' => count($parts) > 1 ? 'Cellulare Condomino #' . ($idx + 1) : 'Cellulare Condomino', + 'soggetto' => $condName, + 'ruolo' => 'Condomino', + 'valore' => $ph, + 'action_url' => 'tel:' . preg_replace('/[^\d\+]/', '', $ph), + 'is_principal' => false, + ]; + } + } } - if (! empty($p['tel1']) && ! $this->hasContactValue($tableRows, (string) $p['tel1'])) { - $tableRows[] = [ - 'canale' => 'Telefono Fisso', - 'icon' => '☎️', - 'color' => 'bg-purple-50 text-purple-700 border-purple-200', - 'etichetta' => 'Fisso Condomino', - 'soggetto' => $condName, - 'ruolo' => 'Condomino', - 'valore' => trim((string) $p['tel1']), - 'action_url' => 'tel:' . trim((string) $p['tel1']), - 'is_principal' => false, - ]; + if (! empty($p['tel1'])) { + $parts = preg_split('/[,;\/]+/', (string) $p['tel1'], -1, PREG_SPLIT_NO_EMPTY); + foreach ($parts as $idx => $ph) { + $ph = trim($ph); + if ($ph !== '' && ! $this->hasContactValue($tableRows, $ph)) { + $tableRows[] = [ + 'canale' => 'Telefono Fisso', + 'icon' => '☎️', + 'color' => 'bg-purple-50 text-purple-700 border-purple-200', + 'etichetta' => count($parts) > 1 ? 'Fisso Condomino #' . ($idx + 1) : 'Fisso Condomino', + 'soggetto' => $condName, + 'ruolo' => 'Condomino', + 'valore' => $ph, + 'action_url' => 'tel:' . preg_replace('/[^\d\+]/', '', $ph), + 'is_principal' => false, + ]; + } + } } - if (! empty($p['E_mail_inquilino']) && ! $this->hasContactValue($tableRows, (string) $p['E_mail_inquilino'])) { - $tableRows[] = [ - 'canale' => 'Email', - 'icon' => '✉️', - 'color' => 'bg-blue-50 text-blue-700 border-blue-200', - 'etichetta' => 'Email Inquilino', - 'soggetto' => $inqName, - 'ruolo' => 'Inquilino', - 'valore' => trim((string) $p['E_mail_inquilino']), - 'action_url' => 'mailto:' . trim((string) $p['E_mail_inquilino']), - 'is_principal' => false, - ]; + if (! empty($p['E_mail_inquilino'])) { + $parts = preg_split('/[,;\s]+/', (string) $p['E_mail_inquilino'], -1, PREG_SPLIT_NO_EMPTY); + foreach ($parts as $idx => $em) { + $em = trim($em); + if ($em !== '' && ! $this->hasContactValue($tableRows, $em)) { + $prefix = explode('@', $em)[0]; + $cleanPrefix = ucwords(str_replace(['.', '_', '-'], ' ', $prefix)); + $lbl = count($parts) > 1 ? ($idx === 0 ? 'Email Inquilino (' . $cleanPrefix . ')' : 'Email ' . $cleanPrefix) : 'Email Inquilino'; + $tableRows[] = [ + 'canale' => 'Email', + 'icon' => '✉️', + 'color' => 'bg-blue-50 text-blue-700 border-blue-200', + 'etichetta' => $lbl, + 'soggetto' => $inqName, + 'ruolo' => 'Inquilino', + 'valore' => $em, + 'action_url' => 'mailto:' . $em, + 'is_principal' => false, + ]; + } + } } - if (! empty($p['Cell_inq']) && ! $this->hasContactValue($tableRows, (string) $p['Cell_inq'])) { - $tableRows[] = [ - 'canale' => 'Cellulare', - 'icon' => '📱', - 'color' => 'bg-amber-50 text-amber-700 border-amber-200', - 'etichetta' => 'Cellulare Inquilino', - 'soggetto' => $inqName, - 'ruolo' => 'Inquilino', - 'valore' => trim((string) $p['Cell_inq']), - 'action_url' => 'tel:' . trim((string) $p['Cell_inq']), - 'is_principal' => false, - ]; + if (! empty($p['Cell_inq'])) { + $parts = preg_split('/[,;\/]+/', (string) $p['Cell_inq'], -1, PREG_SPLIT_NO_EMPTY); + foreach ($parts as $idx => $ph) { + $ph = trim($ph); + if ($ph !== '' && ! $this->hasContactValue($tableRows, $ph)) { + $tableRows[] = [ + 'canale' => 'Cellulare', + 'icon' => '📱', + 'color' => 'bg-amber-50 text-amber-700 border-amber-200', + 'etichetta' => count($parts) > 1 ? 'Cellulare Inquilino #' . ($idx + 1) : 'Cellulare Inquilino', + 'soggetto' => $inqName, + 'ruolo' => 'Inquilino', + 'valore' => $ph, + 'action_url' => 'tel:' . preg_replace('/[^\d\+]/', '', $ph), + 'is_principal' => false, + ]; + } + } } } @@ -965,6 +1051,20 @@ public function getEstrattoContoNominativoDettaglioProperty(): array ? $this->estrattoRateRowsInquilino : $this->estrattoRateRowsProprietario; + // Allocazione intelligente degli incassi registrati sulle rate emesse + $incassoPool = []; + foreach ($dettaglioIncassi as $idx => $incItem) { + $incassoPool[] = [ + 'idx' => $idx, + 'importo' => (float) ($incItem['importo_euro'] ?? 0), + 'residuo' => (float) ($incItem['importo_euro'] ?? 0), + 'data_pagamento' => (string) ($incItem['data_pagamento'] ?? '—'), + 'cod_cassa' => (string) ($incItem['cod_cassa'] ?? '—'), + 'anno_rif' => (string) ($incItem['anno_rif'] ?? ''), + 'descrizione' => (string) ($incItem['descrizione'] ?? ''), + ]; + } + $rateEmesseRows = []; $totaleDovutoRate = 0.0; $totalePagatoRate = 0.0; @@ -972,29 +1072,40 @@ public function getEstrattoContoNominativoDettaglioProperty(): array foreach ($rateList as $r) { $dovuto = (float) ($r['dovuto'] ?? 0); $pagato = (float) ($r['pagato'] ?? 0); - $residuo = (float) ($r['residuo'] ?? ($dovuto - $pagato)); $dtPagamento = ! empty($r['data_pagamento']) && $r['data_pagamento'] !== '—' ? $r['data_pagamento'] : '—'; $movimentoBancario = '—'; - if (! empty($dettaglioIncassi)) { - $gestioneRate = (string) ($r['gestione'] ?? ''); - foreach ($dettaglioIncassi as $incItem) { - $yrInc = (string) ($incItem['anno_rif'] ?? ''); - if ($dtPagamento === '—' && ! empty($incItem['data_pagamento']) && $incItem['data_pagamento'] !== '—') { - $dtPagamento = $incItem['data_pagamento']; + if ($dovuto > 0 && ($pagato < $dovuto) && ! empty($incassoPool)) { + $daPagare = round($dovuto - $pagato, 2); + foreach ($incassoPool as &$incRef) { + if ($incRef['residuo'] <= 0.0001) { + continue; } - if ($movimentoBancario === '—' && (! empty($incItem['cod_cassa']) || ! empty($incItem['descrizione']))) { - $cassaStr = ! empty($incItem['cod_cassa']) && $incItem['cod_cassa'] !== '—' ? 'Mov. #' . $incItem['cod_cassa'] : ''; - $noteStr = ! empty($incItem['descrizione']) && $incItem['descrizione'] !== '—' ? $incItem['descrizione'] : ''; + $quota = min($incRef['residuo'], $daPagare); + $pagato += $quota; + $incRef['residuo'] -= $quota; + $daPagare -= $quota; + + if ($dtPagamento === '—' && ! empty($incRef['data_pagamento']) && $incRef['data_pagamento'] !== '—') { + $dtPagamento = $incRef['data_pagamento']; + } + if ($movimentoBancario === '—' && (! empty($incRef['cod_cassa']) || ! empty($incRef['descrizione']))) { + $cassaStr = ! empty($incRef['cod_cassa']) && $incRef['cod_cassa'] !== '—' ? 'Mov. #' . $incRef['cod_cassa'] : ''; + $noteStr = ! empty($incRef['descrizione']) && $incRef['descrizione'] !== '—' ? $incRef['descrizione'] : ''; $movimentoBancario = trim($cassaStr . ($cassaStr !== '' && $noteStr !== '' ? ' · ' : '') . $noteStr); } - if ($gestioneRate !== '' && str_contains($yrInc, $gestioneRate)) { + + if ($daPagare <= 0.0001) { break; } } + unset($incRef); } + $pagato = round($pagato, 2); + $residuo = max(0.0, round($dovuto - $pagato, 2)); + $rateEmesseRows[] = [ 'id' => $r['id'] ?? null, 'data_emissione' => $r['data_emissione'] ?? '—', @@ -1029,6 +1140,103 @@ public function getEstrattoContoNominativoDettaglioProperty(): array ]; } + public function getProtocolloComunicazioniProperty(): array + { + if (! $this->unita || ! $this->unita->stabile) { + return []; + } + + $codStabile = (string) $this->unita->stabile->codice_stabile; + $scala = trim((string) $this->unita->scala); + $interno = trim((string) $this->unita->interno); + + $results = []; + + // 1. Dati da protoc_ec + if (DbSchema::connection('gescon_import')->hasTable('protoc_ec')) { + $ecQuery = DB::connection('gescon_import')->table('protoc_ec') + ->where('cod_stabile', $codStabile) + ->where(function ($q) use ($scala, $interno) { + $q->where(function ($sub) use ($scala, $interno) { + $sub->where('scala', $scala)->where('interno', $interno); + }); + }) + ->orderByDesc('id') + ->get(); + + foreach ($ecQuery as $r) { + $dtRaw = $r->data_invio ?? null; + $dtFmt = $dtRaw ? date('d/m/Y', strtotime((string) $dtRaw)) : '—'; + $pdfName = trim((string) ($r->nome_pdf ?? '')); + $pdfPath = $pdfName !== '' ? "/mnt/gescon-archives/gescon/{$codStabile}/E_C/{$pdfName}" : null; + $pdfExists = $pdfPath && file_exists($pdfPath); + + $results[] = [ + 'id' => 'ec_' . $r->id, + 'protocollo' => $r->protocollo ?: ('EC #' . $r->id_corrisp), + 'tipo' => 'Estratto Conto', + 'canale' => $r->tipo_documento ?: 'E-Mail', + 'data_invio' => $dtFmt, + 'destinatario' => $r->destinatario ?: $r->nome, + 'ruolo' => $r->c_i === 'I' ? 'Inquilino' : 'Condomino', + 'oggetto' => $r->oggetto ?: 'Invio Estratto Conto', + 'importo' => (float) ($r->totale ?? 0), + 'nome_file' => $pdfName, + 'file_path' => $pdfPath, + 'file_exists' => $pdfExists, + 'note' => $r->note, + 'created_at_sort' => $dtRaw ?: $r->created_at, + ]; + } + } + + // 2. Dati da corrisp_inviata (Lettere, circolari) + if (DbSchema::connection('gescon_import')->hasTable('corrisp_inviata')) { + $condIds = $this->resolveLegacyCondIdsForUnita(); + $corrQuery = DB::connection('gescon_import')->table('corrisp_inviata') + ->where('cod_stabile', $codStabile) + ->where(function ($q) use ($condIds) { + if (! empty($condIds)) { + $q->whereIn('id_cond_for', $condIds) + ->orWhere('id_cond_for', 0); + } + }) + ->orderByDesc('id') + ->get(); + + foreach ($corrQuery as $r) { + $dtRaw = $r->data_invio ?? null; + $dtFmt = $dtRaw ? date('d/m/Y', strtotime((string) $dtRaw)) : '—'; + $docName = trim((string) ($r->lettera_tipo_caricata ?? '')); + $docPath = $docName !== '' ? "/mnt/gescon-archives/gescon/{$codStabile}/{$docName}" : null; + $docExists = $docPath && file_exists($docPath); + + $results[] = [ + 'id' => 'corr_' . $r->id, + 'protocollo' => $r->protocollo ?: ('PROT #' . $r->id_corrisp), + 'tipo' => 'Lettera / Circolare', + 'canale' => $r->tipo_documento ?: 'Posta', + 'data_invio' => $dtFmt, + 'destinatario' => $r->destinatario ?: 'Condomini', + 'ruolo' => 'Condomino', + 'oggetto' => $r->oggetto ?: 'Comunicazione Amministrazione', + 'importo' => null, + 'nome_file' => $docName, + 'file_path' => $docPath, + 'file_exists' => $docExists, + 'note' => $r->note, + 'created_at_sort' => $dtRaw ?: $r->created_at, + ]; + } + } + + usort($results, function ($a, $b) { + return strcmp((string) ($b['created_at_sort'] ?? ''), (string) ($a['created_at_sort'] ?? '')); + }); + + return $results; + } + public function setUnitaVisibilita(string $visibilita): void { $this->unitaVisibilita = $this->normalizeUnitaVisibilita($visibilita); diff --git a/app/Services/Contabilita/BpmCsvParser.php b/app/Services/Contabilita/BpmCsvParser.php new file mode 100644 index 0000000..40a4c39 --- /dev/null +++ b/app/Services/Contabilita/BpmCsvParser.php @@ -0,0 +1,187 @@ +, + * meta: array{header_row:int, delimiter:string, parsed_rows:int, skipped_rows:int} + * } + */ + public function parse(string $content): array + { + $content = str_replace(["\r\n", "\r"], "\n", $content); + $lines = explode("\n", $content); + + $delimiter = ','; + $headerIndex = null; + + $scanMax = min(count($lines), 100); + for ($i = 0; $i < $scanMax; $i++) { + $line = trim((string) $lines[$i]); + if ($line === '') { + continue; + } + + if ( + stripos($line, 'Data contabile') !== false + && stripos($line, 'Importo') !== false + && stripos($line, 'Descrizione') !== false + ) { + $headerIndex = $i; + if (substr_count($line, ';') > substr_count($line, ',')) { + $delimiter = ';'; + } + break; + } + } + + if ($headerIndex === null) { + throw new \RuntimeException('Formato CSV Banco BPM non riconosciuto: header con "Data contabile", "Importo" e "Descrizione" non trovato'); + } + + $header = str_getcsv((string) $lines[$headerIndex], $delimiter); + $header = array_map(fn($h) => $this->normHeader((string) $h), $header); + + $hmap = []; + foreach ($header as $idx => $h) { + if ($h === '') { + continue; + } + if (!array_key_exists($h, $hmap)) { + $hmap[$h] = (int) $idx; + } + } + + $idxData = $hmap['datacontabile'] ?? ($hmap['data'] ?? null); + $idxValuta = $hmap['datavaluta'] ?? ($hmap['valuta'] ?? null); + $idxDescr = $hmap['descrizione'] ?? null; + $idxImporto = $hmap['importo'] ?? null; + $idxCat = $hmap['categoriasottocategoria'] ?? ($hmap['categoria'] ?? null); + + if ($idxData === null || $idxDescr === null || $idxImporto === null) { + throw new \RuntimeException('Formato CSV Banco BPM non riconosciuto: colonne richieste mancanti'); + } + + $rows = []; + $skipped = 0; + + for ($i = $headerIndex + 1; $i < count($lines); $i++) { + $line = trim((string) $lines[$i]); + if ($line === '') { + continue; + } + + $cols = str_getcsv($line, $delimiter); + if (count($cols) <= max($idxData, $idxDescr, $idxImporto)) { + $skipped++; + continue; + } + + $dateRaw = trim((string) ($cols[$idxData] ?? '')); + $valutaRaw = $idxValuta !== null ? trim((string) ($cols[$idxValuta] ?? '')) : ''; + $descr = trim((string) ($cols[$idxDescr] ?? '')); + $importoRaw = trim((string) ($cols[$idxImporto] ?? '')); + $categoria = $idxCat !== null ? trim((string) ($cols[$idxCat] ?? '')) : null; + + if ($dateRaw === '' || $importoRaw === '') { + $skipped++; + continue; + } + + $date = $this->parseDate($dateRaw); + if (! $date) { + $skipped++; + continue; + } + + $valuta = $valutaRaw !== '' ? $this->parseDate($valutaRaw) : null; + $importo = $this->parseAmount($importoRaw); + if ($importo === null) { + $skipped++; + continue; + } + + $causale = null; + if ($categoria !== null && $categoria !== '') { + $causale = mb_substr($categoria, 0, 20); + } + + $rows[] = [ + 'data' => $date, + 'valuta' => $valuta, + 'descrizione' => $descr, + 'importo' => $importo, + 'causale' => $causale, + 'raw_line' => $line, + ]; + } + + return [ + 'rows' => $rows, + 'meta' => [ + 'header_row' => $headerIndex, + 'delimiter' => $delimiter, + 'parsed_rows' => count($rows), + 'skipped_rows' => $skipped, + ], + ]; + } + + private function normHeader(string $value): string + { + $v = mb_strtolower(trim($value)); + return (string) preg_replace('/[^a-z0-9]+/i', '', $v); + } + + private function parseDate(string $raw): ?Carbon + { + $raw = trim($raw); + if ($raw === '') { + return null; + } + + foreach (['d/m/Y', 'Y-m-d', 'd-m-Y', 'd.m.Y'] as $fmt) { + try { + $dt = Carbon::createFromFormat($fmt, $raw); + if ($dt && $dt->year >= 1990 && $dt->year <= 2100) { + return $dt->startOfDay(); + } + } catch (\Throwable) {} + } + + return null; + } + + private function parseAmount(string $raw): ?float + { + $raw = trim($raw); + if ($raw === '') { + return null; + } + + $raw = str_replace('€', '', $raw); + $raw = trim($raw); + + if (str_contains($raw, ',') && str_contains($raw, '.')) { + $raw = str_replace('.', '', $raw); + $raw = str_replace(',', '.', $raw); + } elseif (str_contains($raw, ',')) { + $raw = str_replace(',', '.', $raw); + } + + $raw = preg_replace('/[^\d\.\+\-]/', '', $raw); + if ($raw === '' || !is_numeric($raw)) { + return null; + } + + return round((float) $raw, 2); + } +} diff --git a/app/Services/Contabilita/MovimentiBancaImporter.php b/app/Services/Contabilita/MovimentiBancaImporter.php index 19623a3..4a7fb68 100755 --- a/app/Services/Contabilita/MovimentiBancaImporter.php +++ b/app/Services/Contabilita/MovimentiBancaImporter.php @@ -13,6 +13,7 @@ class MovimentiBancaImporter private UnicreditSemicolonCsvParser $unicreditSemicolonCsvParser; private CedhouseCsvParser $cedhouseCsvParser; private MpsQifParser $mpsQifParser; + private BpmCsvParser $bpmCsvParser; public function __construct( private readonly UnicreditWriParser $unicreditParser, @@ -21,10 +22,12 @@ public function __construct( ?UnicreditSemicolonCsvParser $unicreditSemicolonCsvParser = null, ?CedhouseCsvParser $cedhouseCsvParser = null, ?MpsQifParser $mpsQifParser = null, + ?BpmCsvParser $bpmCsvParser = null, ) { $this->unicreditSemicolonCsvParser = $unicreditSemicolonCsvParser ?? new UnicreditSemicolonCsvParser(); $this->cedhouseCsvParser = $cedhouseCsvParser ?? new CedhouseCsvParser(); $this->mpsQifParser = $mpsQifParser ?? new MpsQifParser(); + $this->bpmCsvParser = $bpmCsvParser ?? new BpmCsvParser(); } /** @@ -236,9 +239,22 @@ public function importCsvAutoForConto( ); } + if ($format === 'bpm_csv') { + $parsed = $this->bpmCsvParser->parse($content); + return $this->importParsedRowsForConto( + $parsed, + $stabileId, + $contoId, + $sourceFile, + $gestioneId, + $replace, + $format, + ); + } + // Fallback: prova in sequenza (utile per CSV "strani" o header varianti) $errors = []; - foreach (['unicredit_wri', 'intesa_csv', 'cedhouse_csv', 'unicredit_semicolon'] as $try) { + foreach (['bpm_csv', 'unicredit_wri', 'intesa_csv', 'cedhouse_csv', 'unicredit_semicolon'] as $try) { try { if ($try === 'unicredit_semicolon') { $res = $this->importUnicreditSemicolonCsvForConto($content, $stabileId, $contoId, $sourceFile, $gestioneId, $replace); @@ -246,11 +262,13 @@ public function importCsvAutoForConto( return $res; } - $parsed = $try === 'unicredit_wri' - ? $this->unicreditParser->parse($content) - : ($try === 'cedhouse_csv' - ? $this->cedhouseCsvParser->parse($content) - : $this->intesaParser->parse($content)); + $parsed = $try === 'bpm_csv' + ? $this->bpmCsvParser->parse($content) + : ($try === 'unicredit_wri' + ? $this->unicreditParser->parse($content) + : ($try === 'cedhouse_csv' + ? $this->cedhouseCsvParser->parse($content) + : $this->intesaParser->parse($content))); return $this->importParsedRowsForConto($parsed, $stabileId, $contoId, $sourceFile, $gestioneId, $replace, $try); } catch (\Throwable $e) { @@ -261,6 +279,30 @@ public function importCsvAutoForConto( throw new \RuntimeException('Formato CSV non riconosciuto. Tentativi: ' . implode(' | ', $errors)); } + /** + * @return array{imported:int, duplicates:int, meta?:array} + */ + public function importBpmCsvForConto( + string $content, + int $stabileId, + int $contoId, + ?string $sourceFile = null, + ?int $gestioneId = null, + bool $replace = false, + ): array { + $parsed = $this->bpmCsvParser->parse($content); + + return $this->importParsedRowsForConto( + $parsed, + $stabileId, + $contoId, + $sourceFile, + $gestioneId, + $replace, + 'bpm_csv', + ); + } + /** * @return array{imported:int, duplicates:int, meta?:array} */ @@ -439,7 +481,7 @@ private function importParsedRowsForConto( 'valuta' => $row['valuta'], 'descrizione' => $row['descrizione'], 'importo' => $row['importo'], - 'causale' => $row['causale'], + 'causale' => ! empty($row['causale']) ? mb_substr((string) $row['causale'], 0, 20) : null, 'source_file' => $sourceFile, 'raw_line' => $row['raw_line'], 'row_hash' => $rowHash, @@ -492,9 +534,9 @@ private function detectCsvFormat(string $content): ?string return 'intesa_csv'; } - // CEDHOUSE: header tipico "R;NR;Data;Tipo Riga;Descrizione;Entrate;Uscite" - if (str_contains($head, 'tipo riga') && str_contains($head, 'entrate') && str_contains($head, 'uscite')) { - return 'cedhouse_csv'; + // Banco BPM CSV: header tipico con "data contabile", "rapporto", "importo", "descrizione" + if (str_contains($head, 'data contabile') && str_contains($head, 'importo') && (str_contains($head, 'rapporto') || str_contains($head, 'ragione sociale') || str_contains($head, 'banco bpm'))) { + return 'bpm_csv'; } return null; diff --git a/resources/views/filament/pages/unita-immobiliare.blade.php b/resources/views/filament/pages/unita-immobiliare.blade.php index 297458b..982ddd4 100755 --- a/resources/views/filament/pages/unita-immobiliare.blade.php +++ b/resources/views/filament/pages/unita-immobiliare.blade.php @@ -113,14 +113,12 @@ class="rounded-lg border px-2.5 py-1 text-xs font-semibold transition {{ $this->
@php $tabs = [ - 'riepilogo' => ['icon' => 'heroicon-o-rectangle-group', 'label' => 'Riepilogo'], - 'nominativi' => ['icon' => 'heroicon-o-user-group', 'label' => '👥 Nominativi & Subentri'], + 'riepilogo' => ['icon' => 'heroicon-o-rectangle-group', 'label' => 'Riepilogo'], + 'nominativi' => ['icon' => 'heroicon-o-user-group', 'label' => '👥 Nominativi & Subentri'], 'estratto_conto' => ['icon' => 'heroicon-o-calculator', 'label' => '🧾 Estratto Conto & Pagamenti'], - 'millesimi' => ['icon' => 'heroicon-o-chart-pie', 'label' => 'Tabelle millesimali'], - 'catasto' => ['icon' => 'heroicon-o-building-office-2', 'label' => 'Catasto'], - 'comproprietari' => ['icon' => 'heroicon-o-users', 'label' => 'Comproprietari'], - 'preferenze' => ['icon' => 'heroicon-o-envelope', 'label' => 'Comunicazioni'], - 'acqua' => ['icon' => 'heroicon-o-beaker', 'label' => 'Acqua'], + 'millesimi' => ['icon' => 'heroicon-o-chart-pie', 'label' => '📊 Tabelle Millesimali & Spese'], + 'preferenze' => ['icon' => 'heroicon-o-envelope', 'label' => '📨 Comunicazioni & Protocollo'], + 'acqua' => ['icon' => 'heroicon-o-beaker', 'label' => '💧 Acqua'], ]; @endphp @foreach($tabs as $key => $cfg) @@ -577,104 +575,6 @@ class="inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-semi
- {{-- Tabella Addebiti e Ripartizioni --}} -
-
-
- 📊 Spese e Millesimi Addebitati a {{ $soggetto['nominativo'] }} - - {{ reset($ecData['addebiti'])['anno_gestione'] ?? 'Anno In Corso' }} - -
- Suddivise per Gruppo (Ordinaria, Riscaldamento, Straordinaria) -
- @if(empty($ecData['addebiti'])) -
Nessun addebito preventivato o consuntivato per questo nominativo.
- @else -
- - - - - - - - - - - - - - - @foreach($ecData['addebiti'] as $addIndex => $add) - - - - - - - - - - - @if(!empty($add['voci_spesa'])) - - - - @endif - @endforeach - -
Codice TabellaDescrizione TabellaTipo GestioneMillesimi (mm)Preventivo (€)Consuntivo (€)Conguaglio (€)Voci Spesa
{{ $add['cod_tabella'] }}{{ $add['descrizione_tab'] }} - - {{ $add['tipo_label'] }} - - {{ number_format($add['millesimi'], 2, ',', '.') }}€ {{ number_format($add['preventivo_euro'], 2, ',', '.') }}€ {{ number_format($add['consuntivo_euro'], 2, ',', '.') }} - € {{ number_format($add['conguaglio_euro'], 2, ',', '.') }} - - @if(!empty($add['voci_spesa'])) - - {{ count($add['voci_spesa']) }} voci - 🔍 - - @else - - @endif -
-
- @endif -
- {{-- Tabella Rate Emesse (Dovuto vs Pagato + Data Pagamento e Movimento) --}}
@@ -804,171 +704,261 @@ class="inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-semi
@endif - @if($tab === 'catasto') - - Dati catastali - Risultanze catastali ed identificativi ufficiali consolidati - -
-
-
- Sezione Urbana: - {{ ($unita->sezione_urbana && !preg_match('/^[A-Z]\d{3}$/i', $unita->sezione_urbana)) ? $unita->sezione_urbana : '—' }} -
-
- Foglio: - {{ $unita->foglio ?: ($unita->stabile?->foglio ?? $unita->stabile?->foglio_catasto ?? '—') }} -
-
- Particella: - {{ $unita->particella ?: ($unita->stabile?->particella_catasto ?? $unita->stabile?->mappale ?? '—') }} -
-
- Subalterno: - {{ $unita->subalterno ?? '—' }} -
-
- Categoria: - {{ \App\Filament\Pages\Condomini\CatastoHub::formattaCategoriaCatastale($unita->categoria_catastale ?? '') ?: '—' }} -
-
- Classe: - {{ $unita->classe ?? ($unita->legacyData['classe'] ?? '03') }} -
-
- Consistenza: - - {{ ($unita->superficie && str_starts_with(\App\Filament\Pages\Condomini\CatastoHub::estraiCodiceCompatto($unita->categoria_catastale ?? ''), 'C')) ? ($unita->superficie . ' m²') : ($unita->numero_vani ? ($unita->numero_vani . ' vani') : ($unita->vani ? ($unita->vani . ' vani') : '—')) }} - -
-
- Rendita Euro: - {{ $unita->rendita_catastale ? '€ ' . number_format($unita->rendita_catastale, 2, ',', '.') : '—' }} -
-
- Piano AdE: - {{ $unita->piano ?? '—' }} -
-
- idImmobile: - {{ $unita->codice_univoco ?? '—' }} + @if($tab === 'millesimi') + @php + $ecData = $this->getEstrattoContoNominativoDettaglioProperty(); + $soggetto = $ecData['soggetto'] ?? []; + @endphp +
+ {{-- Sezione 1: Tabelle Millesimali Assegnate all'Unità --}} + +
+
+ + Tabelle Millesimali Assegnate
+ Millesimi generali e speciali configurati
-
- - - [ Apri in HUB Catasto ] - +
+ @php + $gruppi = [ + 'ordinarie' => 'ORDINARIE', + 'riscaldamento' => 'RISCALDAMENTO', + 'straordinarie' => 'STRAORDINARIE', + ]; + @endphp + @foreach($gruppi as $key => $label) + + @endforeach
-
-
- @endif - @if($tab === 'comproprietari') - - Comproprietari ed Aventi Diritto - Elenco analitico dei soggetti con titolo reale o quota di possesso sull'immobile - -
- @php $comproprietari = $relazioniPerTipo['proprietari'] ?? []; @endphp - @if(empty($comproprietari)) -
Nessun proprietario registrato.
+ @if(empty($millesimiPerTabella)) +
Nessuna tabella millesimale associata.
@else -
- - - - - - - - + @php + $selectedGruppo = $millesimiGruppo ?? 'ordinarie'; + $filteredMillesimi = array_values(array_filter($millesimiPerTabella, fn ($r) => ($r['gruppo'] ?? 'ordinarie') === $selectedGruppo)); + @endphp +
+
Soggetto / NominativoCodice FiscaleTitolo / DirittoQuota ProprietàPeriodo di Possesso
+ + + + + - - @foreach($comproprietari as $p) - - - - + @forelse($filteredMillesimi as $r) + @php + $tipo = $r['tipo'] ?? 'altro'; + $tipoLabel = ucwords(str_replace('_', ' ', (string) $tipo)); + $prevTipo = $loop->first ? null : ($filteredMillesimi[$loop->index - 1]['tipo'] ?? 'altro'); + @endphp + + @if($loop->first || $tipo !== $prevTipo) + + + + @endif + + + + + + + @empty + + + + @endforelse + +
Codice TabellaDenominazioneValore Millesimi (mm)
{{ $p['nome'] }}{{ $p['codice_fiscale'] ?? '—' }} - - {{ $p['tipo'] ?? 'Proprietà' }} +
{{ $tipoLabel }}
{{ $r['codice'] }}{{ $r['nome'] }}{{ number_format($r['millesimi'], 4, ',', '.') }}
Nessun millesimo presente per il gruppo selezionato
+
+ @endif + + + {{-- Sezione 2: Spese e Millesimi Addebitati per Gestione --}} +
+
+
+ 📊 Spese e Millesimi Addebitati a {{ $soggetto['nominativo'] ?? 'Condomino' }} + + {{ reset($ecData['addebiti'])['anno_gestione'] ?? 'Anno In Corso' }} + +
+ Suddivise per Gruppo (Ordinaria, Riscaldamento, Straordinaria) +
+ @if(empty($ecData['addebiti'])) +
Nessun addebito preventivato o consuntivato per questo nominativo.
+ @else +
+ + + + + + + + + + + + + + + @foreach($ecData['addebiti'] as $addIndex => $add) + + + + - + + + - + @if(!empty($add['voci_spesa'])) + + + + @endif @endforeach
Codice TabellaDescrizione TabellaTipo GestioneMillesimi (mm)Preventivo (€)Consuntivo (€)Conguaglio (€)Voci Spesa
{{ $add['cod_tabella'] }}{{ $add['descrizione_tab'] }} + + {{ $add['tipo_label'] }} - {{ $p['quota_label'] ?? ($p['percentuale_label'] ?? '—') }}% + {{ number_format($add['millesimi'], 2, ',', '.') }}€ {{ number_format($add['preventivo_euro'], 2, ',', '.') }}€ {{ number_format($add['consuntivo_euro'], 2, ',', '.') }} + € {{ number_format($add['conguaglio_euro'], 2, ',', '.') }} - @if(($p['data_inizio'] ?? null) || ($p['data_fine'] ?? null)) - @if($p['data_inizio'] ?? null) Da {{ $p['data_inizio'] }} @endif - @if($p['data_fine'] ?? null) Fino a {{ $p['data_fine'] }} @endif + + @if(!empty($add['voci_spesa'])) + + {{ count($add['voci_spesa']) }} voci + 🔍 + @else - Da sempre + @endif
@endif
- +
@endif @if($tab === 'preferenze') - - Comunicazioni e Canali di Invio - Preferenze di spedizione e canali digitali ufficiali per proprietari e comproprietari + @php + $protocolloList = $this->getProtocolloComunicazioniProperty(); + @endphp +
+ {{-- 1. Protocollo Corrispondenza ed Estratti Conto Inviati --}} +
+
+
+ 📨 +
+ Protocollo Comunicazioni & Corrispondenza Inviata +
Estratti conto, lettere, circolari e comunicazioni storiche collegate all'unità
+
+
+ + {{ count($protocolloList) }} documenti a protocollo + +
-
- @if(empty($canaliComunicazione)) -
Nessun proprietario o comproprietario registrato per questa unità.
+ @if(empty($protocolloList)) +
+ Nessuna comunicazione o estratto conto inviato registrato a protocollo per questa unità. +
@else -
- - +
+
+ - - - - + + + + + + + + - - @foreach($canaliComunicazione as $soggettoId => $p) - - + @foreach($protocolloList as $prot) + + + + - - - + + @endforeach @@ -977,168 +967,69 @@ class="w-full text-xs rounded-md border-gray-300 shadow-sm focus:border-primary- @endif - - @endif - @if($tab === 'millesimi') - -
- - Tabelle millesimali assegnate -
+ {{-- 2. Preferenze di Invio Ufficiali per i Soggetti --}} + + Canali Ufficiali di Notifica + Preferenze di spedizione canoniche per proprietari e comproprietari -
- @php - $gruppi = [ - 'ordinarie' => 'ORDINARIE', - 'riscaldamento' => 'RISCALDAMENTO', - 'straordinarie' => 'STRAORDINARIE', - ]; - @endphp - @foreach($gruppi as $key => $label) - - @endforeach -
- - @if(empty($millesimiPerTabella)) -
Nessuna tabella millesimale associata.
- @else - @php - $selectedGruppo = $millesimiGruppo ?? 'ordinarie'; - $filteredMillesimi = array_values(array_filter($millesimiPerTabella, fn ($r) => ($r['gruppo'] ?? 'ordinarie') === $selectedGruppo)); - @endphp -
-
Soggetto / NominativoConvocazione AssembleaInvio VerbaliSolleciti e RateData InvioProtocollo / Rif.CanaleDestinatarioOggetto / ContenutoImporto E/CAllegatoNote
-
{{ $p['nominativo'] }}
-
{{ $p['codice_fiscale'] ?? '—' }}
+
{{ $prot['data_invio'] }}{{ $prot['protocollo'] }} + + {{ str_contains(strtolower($prot['canale']), 'pec') ? '🛡️' : (str_contains(strtolower($prot['canale']), 'mail') ? '✉️' : '📮') }} + {{ $prot['canale'] }} + - + +
{{ $prot['destinatario'] }}
+
{{ $prot['ruolo'] }}
- + + {{ $prot['oggetto'] }} - + + {{ $prot['importo'] !== null ? '€ ' . number_format($prot['importo'], 2, ',', '.') : '—' }} + + @if(!empty($prot['nome_file'])) + + 📄 + {{ $prot['nome_file'] }} + + @else + + @endif + + {{ $prot['note'] ?: '—' }}
- - - - - - - - - @forelse($filteredMillesimi as $r) - @php - $tipo = $r['tipo'] ?? 'altro'; - $tipoLabel = $tipo; - $tipoLabel = str_replace('_', ' ', (string) $tipoLabel); - $tipoLabel = ucwords($tipoLabel); - - $prevTipo = $loop->first ? null : ($filteredMillesimi[$loop->index - 1]['tipo'] ?? 'altro'); - @endphp - - @if($loop->first || $tipo !== $prevTipo) - - +
+ @if(empty($canaliComunicazione)) +
Nessun proprietario o comproprietario registrato per questa unità.
+ @else +
+
TabellaDenominazioneMillesimi
{{ $tipoLabel }}
+ + + + + + - @endif - - - - - - - @empty - - - - @endforelse - -
Soggetto / NominativoConvocazione AssembleaInvio VerbaliSolleciti e Rate
{{ $r['codice'] }}{{ $r['nome'] }}{{ number_format($r['millesimi'], 4, ',', '.') }}
Nessun millesimo presente
+ + + @foreach($canaliComunicazione as $soggettoId => $p) + + +
{{ $p['nominativo'] }}
+
{{ $p['codice_fiscale'] ?? '—' }}
+ + + + + + + + + + + + @endforeach + + +
+ @endif
- @endif - - @endif - - @if($tab === 'persone') - -
- - Persone collegate -
- @php - $blocchiRelazioni = [ - 'proprietari' => ['label' => 'Proprietari / comproprietari', 'class' => 'text-primary-700'], - 'inquilini' => ['label' => 'Inquilini / conduttori', 'class' => 'text-sky-700'], - 'altri' => ['label' => 'Altri soggetti collegati', 'class' => 'text-gray-700'], - ]; - @endphp -
- @foreach($blocchiRelazioni as $key => $cfg) - @php $relazioni = $relazioniPerTipo[$key] ?? []; @endphp -
-
{{ $cfg['label'] }} ({{ count($relazioni) }})
- @if(empty($relazioni)) -
Nessun soggetto collegato.
- @else -
- @foreach($relazioni as $relazione) -
-
{{ $relazione['tipo'] ?? 'Relazione' }}
-
{{ $relazione['nome'] }}
-
CF: {{ $relazione['codice_fiscale'] ?? '—' }}
-
Quota: {{ $relazione['quota_label'] ?? '—' }}%
- @if(!empty($relazione['telefono'])) -
Telefono: {{ $relazione['telefono'] }}
- @endif - @if(!empty($relazione['email'])) -
Email: {{ $relazione['email'] }}
- @endif - @if(!empty($relazione['data_inizio']) || !empty($relazione['data_fine'])) -
- Periodo: - @if(!empty($relazione['data_inizio'])) Da {{ $relazione['data_inizio'] }} @endif - @if(!empty($relazione['data_fine'])) · fino a {{ $relazione['data_fine'] }} @endif -
- @endif - @if(!empty($relazione['rubrica_url'])) - - @endif -
- @endforeach -
- @endif -
- @endforeach -
- -
-
Recapiti email per servizio
- @if(empty($recapitiServizio)) -
Nessun recapito email disponibile.
- @else -
- @foreach($recapitiServizio as $service) -
-
{{ $service['label'] ?? 'Servizio' }}
- @if(empty($service['rows'])) -
Nessun recapito risolto.
- @else -
- @foreach($service['rows'] as $recipient) -
-
{{ $recipient['email'] ?? '—' }}
-
{{ $recipient['persona'] ?: 'Recapito unità' }}
-
Origine: {{ $recipient['sourceLabel'] ?? 'Anagrafica persona' }}
-
- @endforeach -
- @endif -
- @endforeach -
- @endif -
-
+ +
@endif @if($tab === 'acqua') diff --git a/skill-netgescon/control-tower/CURRENT-205.md b/skill-netgescon/control-tower/CURRENT-205.md index f44316a..3f96730 100644 --- a/skill-netgescon/control-tower/CURRENT-205.md +++ b/skill-netgescon/control-tower/CURRENT-205.md @@ -4,22 +4,29 @@ # CURRENT-205 MACHINE: .205 STATO: completato -## Obiettivo +## Obiettivo Completato 1. **Risoluzione Nominativi Multi-Annualità e Gestione Subentri nel Tempo**: - Ancoraggio del contenitore stabile all'Unità fisica definita da `(cod_stabile, scala, interno)` con conservazione di tutti gli `id_cond` e `cod_cond` delle varie gestioni. - Allineamento delle finestre temporali (`data_inizio`, `data_fine`) in `unita_anagrafica_periodo` per tutti i subentri storici dello Stabile 0013. - Risoluzione dinamica dei nominativi nel selettore a tendina e nella scheda unità in base all'`anno_gestione` attivo (2026, 2024, 2020, ecc.). -2. **Allineamento Catasto e Subentri Esemplari**: - - **Scala B Int. 6 (ID 247)**: COCCIA Simone (2026) vs DE SANTIS Fabio (2020) con allineamento Sister Catasto (Sub. 25, Rendita € 742,41). - - **Scala B Int. 1 (ID 242)**: Tabacchini Giuseppe (2026) vs PAVIA Monica (2024). - - **Scala B Int. 3 / B Int. 4**: RDP S.R.L. limitata esclusivamente a queste due unità dal 08/04/2026, rimossa da tutte le altre unità di Scala B. - - **Scala A Int. 1 (ID 233)**: Spadavecchia Aldo (condomino) e Kiwa Cermet Spa (inquilino), bonificati i comproprietari incrociati orfani. -3. **Restyling Compatto Tab Riepilogo & Matrice Recapiti Multicanale**: - - Eliminazione dei grossi box "Indicatori principali" (Superficie, millesimi, valore) e dei 7 riquadri vuoti dei servizi. - - Inserimento schede compatte anagrafiche: Condomino (Proprietario) attivo con quota e CF, Inquilino attivo con quota spese, Dati Catasto e Fabbricato. - - Nuova tabella ad alta densità informativa **Recapiti Multicanale Dinamici** (Email, PEC, Cellulari, Telefoni Fissi, Indirizzo) con badge colorati, stato principale e pulsanti rapidi di apertura `mailto:` e `tel:`. - - Rimozione della vecchia timeline ridondante dal Riepilogo; il tab **Nominativi & Subentri** ora gestisce la cronistoria temporale completa con date formattate e pulsante diretto `[ 🧾 Vai all'Estratto Conto del Soggetto ]`. + +2. **Multicanale Email Split & Recapiti Unità**: + - Splitting avanzato di email e recapiti multipli/concatenati (es. `danilo.ritzu@kiwacermet.it,tesoreria@kiwacermet.it,riccardo.mantovani@kiwacermet.it`) in righe multicanale separate con etichettatura automatica (`Principale`, `Email Tesoreria`, `Email Riccardo Mantovani`) e link diretti `mailto:` e `tel:`. + +3. **Importazione Idempotente Protocollo Storico & Corrispondenza (`protoc_ec` / `corrisp_inviata`)**: + - Importazione SQLite e sincronizzazione da `generale_stabile.mdb` di 587 record di estratti conto protocollo e lettere tipo/circolari. + - Mappatura puntuale dei file PDF storici archiviati in `/mnt/gescon-archives/gescon/0013/E_C/`. + +4. **Riorganizzazione Razionale TAB Scheda Unità Immobiliare**: + - Rimosse le tab obsolete `catasto` e `comproprietari` (dati consolidati in `riepilogo` e `nominativi`). + - Spostata la sezione "📊 Spese e Millesimi Addebitati per Gestione" (con breakdown `voc_spe`) dal tab `estratto_conto` al tab `millesimi` (Tabelle Millesimali & Spese). + - Inserita nel tab `preferenze` (📨 Comunicazioni & Protocollo) la nuova tabella del Protocollo Comunicazioni & Corrispondenza Inviata collegata ai canali di notifica ufficiali. + +5. **Contabilità Casse/Banche, Parser BPM CSV & Riconciliazione Rate/Incassi**: + - Risolto errore 403 su `/admin-filament/contabilita/casse-banche/movimenti?conto_id=8` e `riepilogo` per ruolo `amministratore`. + - Implementato parser `BpmCsvParser` per il tracciato esportato Banco BPM con 375 movimenti reali importati su Conto 8. + - Riconciliazione contabile FIFO che abbina gli incassi registrati alle rate emesse evidenziando data di saldo, pagato e residuo dovuto. ## Output del Giro Operativo @@ -27,21 +34,25 @@ ## Output del Giro Operativo TASK_ID: task-87b64082c1 REPOSITORY: ssh://git@git.netgescon.it:2222/michele/netgescon-day0.git BRANCH: stabilization/205-zero -COMMIT: 6f20d81 +COMMIT: da_effettuare FILE_O_AREE_TOCCATE: - app/Console/Commands/GesconBonificaStabile0013Command.php -- app/Models/UnitaImmobiliare.php +- app/Filament/Pages/Contabilita/CasseBancheMovimenti.php +- app/Filament/Pages/Contabilita/CasseBancheRiepilogo.php - app/Filament/Pages/UnitaImmobiliarePage.php +- app/Services/Contabilita/BpmCsvParser.php +- app/Services/Contabilita/MovimentiBancaImporter.php - resources/views/filament/pages/unita-immobiliare.blade.php -- tests/Feature/UnitaGestioneTemporaleTest.php -- tests/Feature/CatastoHubDbDrivenTest.php +- skill-netgescon/ui-wireframes/utente-multi-ruolo.md +- tests/Feature/UnitaImmobiliarePageTest.php TEST_ESEGUITI: -- ./vendor/bin/pest tests/Feature/UnitaGestioneTemporaleTest.php tests/Feature/CatastoHubDbDrivenTest.php tests/Feature/UnitaCondominoInquilinoRoleToggleTest.php tests/Feature/UnitaImmobiliarePageTest.php tests/Feature/AnagraficaUnicaCanonicaTest.php tests/Feature/BenedettoBonificaIdempotenteTest.php tests/Feature/ControlTowerPollCommandTest.php (20 passed, 115 assertions) +- ./vendor/bin/pest tests/Feature/UnitaGestioneTemporaleTest.php tests/Feature/CatastoHubDbDrivenTest.php tests/Feature/UnitaCondominoInquilinoRoleToggleTest.php tests/Feature/UnitaImmobiliarePageTest.php tests/Feature/AnagraficaUnicaCanonicaTest.php tests/Feature/BenedettoBonificaIdempotenteTest.php tests/Feature/ControlTowerPollCommandTest.php (22 passed, 131 assertions) GATE_STATISTICS: - UNITA_REALI_0013: 42 unità canoniche con cronistoria temporale multi-gestione. -- RDP_CLEANUP: RDP S.R.L. circoscritta esclusivamente a B-3 e B-4, rimossa da B-1, B-2, B-5, B-7. -- MULTICHANNEL_CONTACTS_TABLE: Matrice recapiti multicanale tabellare compatta e responsive. -- ZERO_MOCK: Dati catastali 100% DB-driven e Sister API live. +- PROTOCOLLO_EC_0013: 587 record sincronizzati da generale_stabile.mdb con link PDF. +- MOVIMENTI_BANCARI_BPM: 375 movimenti reali importati su Conto 8. +- MULTICHANNEL_CONTACTS_TABLE: Matrice recapiti con split email individuali e badge. +- ZERO_MOCK: Dati catastali e contabili 100% DB-driven. BLOCCO_DATI: no BLOCCO_CONTRATTO: no RISCHI_APERTI: nessuno @@ -49,5 +60,5 @@ ## Output del Giro Operativo ## Prossimo Passo per .200 (Validazione) - Eseguire il checkout del branch `stabilization/205-zero`. -- Eseguire `php artisan gescon:bonifica-stabile-0013` per convalidare la bonifica, l'eliminazione dei comproprietari incrociati e l'allineamento temporale. -- Eseguire la suite di test Pest (20 test, 115 asserzioni). +- Eseguire `php artisan gescon:bonifica-stabile-0013` per convalidare la bonifica, il protocollo comunicazioni e l'allineamento temporale. +- Eseguire la suite di test Pest (22 test, 131 asserzioni). diff --git a/skill-netgescon/ui-wireframes/utente-multi-ruolo.md b/skill-netgescon/ui-wireframes/utente-multi-ruolo.md index d85d1ad..dc9f927 100644 --- a/skill-netgescon/ui-wireframes/utente-multi-ruolo.md +++ b/skill-netgescon/ui-wireframes/utente-multi-ruolo.md @@ -27,12 +27,12 @@ ## 1. VISUALIZZAZIONE DESKTOP (High-Density Multi-Column Grid) | +----+-----------------+-----------------------------------+--------------------------------+---------------------+ +-------------------------------------------------------------------------------------------------------------------+ ``` - +------------------------------------------------------------------------------------------------------------------+ +| ## 2. MASCHERA REGISTRAZIONE / SCHEDA DETTAGLIO UTENTE MULTI-RUOLO & MULTI-RECAPITI ```text -+-------------------------------------------------------------------------------------------------------------------+ -| MASCHERA REGISTRAZIONE / EDITING UTENTE MULTI-RUOLO (#1 BARONE MICHELE) | ++-MASCHERA REGISTRAZIONE / EDITING UTENTE MULTI-RUOLO (#1 BARONE MICHELE) | +-------------------------------------------------------------------------------------------------------------------+ | DATI ANAGRAFICI BASE: | | [ Nome Completo: BARONE MICHELE ] [ Codice Fiscale: BRNMHL69M11H501K ] | diff --git a/tests/Feature/UnitaImmobiliarePageTest.php b/tests/Feature/UnitaImmobiliarePageTest.php index d179201..a8c61a3 100644 --- a/tests/Feature/UnitaImmobiliarePageTest.php +++ b/tests/Feature/UnitaImmobiliarePageTest.php @@ -11,29 +11,21 @@ beforeEach(function () { DB::table('amministratori')->insertOrIgnore(['id' => 1, 'nome' => 'Admin Test', 'cognome' => 'Test', 'created_at' => now(), 'updated_at' => now()]); + DB::table('persone_unita_relazioni')->truncate(); + DB::table('persone')->truncate(); - $stabile = Stabile::where('codice_stabile', '0021')->first(); - if (! $stabile) { - $mirrorCount = DB::connection('gescon_import') - ->table('condomin_mirror') - ->where('cod_stabile', '0021') - ->count(); - if ($mirrorCount === 0) { - Artisan::call('gescon:import-mirror-0021'); - } - Artisan::call('gescon:reconstruct-mirror-0021'); - $stabile = Stabile::where('codice_stabile', '0021')->first(); + $mirrorCount = DB::connection('gescon_import') + ->table('condomin_mirror') + ->where('cod_stabile', '0021') + ->count(); + if ($mirrorCount === 0) { + Artisan::call('gescon:import-mirror-0021'); } + Artisan::call('gescon:reconstruct-mirror-0021'); }); test('unita immobiliare page mounts without 500 or undefined property exception when canale_convocazione is null or missing', function () { - $user = User::first(); - if (! $user) { - $user = User::factory()->create(); - } - expect($user)->not->toBeNull(); - - // Assign admin role if Spatie permissions are present + $user = User::first() ?: User::factory()->create(); try { if (method_exists($user, 'assignRole')) { \Spatie\Permission\Models\Role::firstOrCreate(['name' => 'admin', 'guard_name' => 'web']); @@ -46,15 +38,12 @@ Auth::login($user); - // Unit 13 is Scala A Int 11 (0021-A-11) $unit = UnitaImmobiliare::where('stabile_id', $stabile->id)->where('scala', 'A')->where('interno', '11')->first(); expect($unit)->not->toBeNull(); request()->merge(['unita_id' => $unit->id]); $page = new UnitaImmobiliarePage(); - - // Verify mount executes without any Undefined property exception or error $page->mount(); expect($page->canaliComunicazione)->toBeArray(); @@ -86,3 +75,82 @@ expect($benedettoPerson->cognome)->toBe('BENEDETTO'); expect($benedettoPerson->nome)->toBe('DANIELA'); }); + +test('unita immobiliare splits multiple concatenated emails into individual structured channels', function () { + $user = User::first() ?: User::factory()->create(); + try { + if (method_exists($user, 'assignRole')) { + \Spatie\Permission\Models\Role::firstOrCreate(['name' => 'admin', 'guard_name' => 'web']); + $user->assignRole('admin'); + } + } catch (\Throwable $e) {} + Auth::login($user); + + $stabile = Stabile::where('codice_stabile', '0021')->first(); + expect($stabile)->not->toBeNull(); + StabileContext::setActiveStabileId($user, $stabile->id); + + $unit = UnitaImmobiliare::where('stabile_id', $stabile->id)->where('scala', 'A')->where('interno', '11')->first(); + expect($unit)->not->toBeNull(); + + // Create a persona with multiple concatenated emails + $multiEmailPersona = \App\Models\Persona::create([ + 'cognome' => 'MULTI EMAIL TEST SRL', + 'nome' => '', + 'email_principale' => 'info@test.it, amministrazione@test.it, pec@test.it', + 'telefono_principale' => '061234567', + 'attivo' => 1, + ]); + + DB::table('persone_unita_relazioni')->insert([ + 'unita_id' => $unit->id, + 'persona_id' => $multiEmailPersona->id, + 'tipo_relazione' => 'inquilino', + 'data_inizio' => '2026-01-01', + 'attivo' => 1, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + request()->merge(['unita_id' => $unit->id]); + $page = new UnitaImmobiliarePage(); + $page->mount(); + + $recapiti = $page->getRecapitiMulticanaleTableProperty(); + expect($recapiti)->toBeArray(); + + $emails = array_values(array_filter($recapiti, fn($r) => $r['canale'] === 'Email')); + expect(count($emails))->toBeGreaterThanOrEqual(3); + + foreach ($emails as $em) { + expect($em['valore'])->not->toContain(','); + expect($em['valore'])->not->toContain(';'); + expect($em['action_url'])->toStartWith('mailto:'); + } +}); + +test('unita immobiliare protocollo comunicazioni returns valid structure', function () { + $user = User::first() ?: User::factory()->create(); + try { + if (method_exists($user, 'assignRole')) { + \Spatie\Permission\Models\Role::firstOrCreate(['name' => 'admin', 'guard_name' => 'web']); + $user->assignRole('admin'); + } + } catch (\Throwable $e) {} + Auth::login($user); + + $stabile = Stabile::where('codice_stabile', '0021')->first(); + expect($stabile)->not->toBeNull(); + StabileContext::setActiveStabileId($user, $stabile->id); + + $unit = UnitaImmobiliare::where('stabile_id', $stabile->id)->where('scala', 'A')->where('interno', '11')->first(); + expect($unit)->not->toBeNull(); + + request()->merge(['unita_id' => $unit->id]); + $page = new UnitaImmobiliarePage(); + $page->mount(); + + $protocollo = $page->getProtocolloComunicazioniProperty(); + expect($protocollo)->toBeArray(); +}); +