diff --git a/app/Console/Commands/GesconBonificaRuoliLegacyCommand.php b/app/Console/Commands/GesconBonificaRuoliLegacyCommand.php index 344836f..7a5c174 100755 --- a/app/Console/Commands/GesconBonificaRuoliLegacyCommand.php +++ b/app/Console/Commands/GesconBonificaRuoliLegacyCommand.php @@ -156,6 +156,7 @@ public function handle(): int $metaArr = is_array($metaArr) ? $metaArr : []; $metaCodCond = $this->normalizeCodCondRaw(data_get($metaArr, 'cod_cond')); + $metaCodCondNum = $this->normalizeCodCond(data_get($metaArr, 'cod_cond')); if ($ruolo === 'inquilino' && $unitaId && ($inquilinoMarker[$unitaId] ?? false)) { if (! $metaCodCond || ! $this->isInquilinoCodCond($metaCodCond)) { @@ -207,41 +208,18 @@ public function handle(): int ->where('interno', $interno); } + $legacyAny = null; $legacyAny = null; if ($legacyDir !== null && Schema::connection($conn)->hasColumn('condomin', 'legacy_year')) { $legacyAny = (clone $legacyBase) ->where('legacy_year', $legacyDir) ->orderByDesc('id') - ->first([ - 'id', - 'legacy_year', - 'proprietario', - 'inquilino', - 'cognome', - 'nome', - 'nom_cond', - 'cond_cod_fisc', - 'inquilino_denominazione', - 'inquil_nome', - 'inquil_cod_fisc', - ]); + ->first(); } if (!$legacyAny) { $legacyAny = (clone $legacyBase) ->orderByDesc('id') - ->first([ - 'id', - 'legacy_year', - 'proprietario', - 'inquilino', - 'cognome', - 'nome', - 'nom_cond', - 'cond_cod_fisc', - 'inquilino_denominazione', - 'inquil_nome', - 'inquil_cod_fisc', - ]); + ->first(); } if (!$legacyAny) { @@ -258,14 +236,7 @@ public function handle(): int $ownerRow = $ownerRowQ ->where('proprietario', 1) ->orderByDesc('id') - ->first([ - 'id', - 'legacy_year', - 'nom_cond', - 'nome', - 'cognome', - 'cond_cod_fisc', - ]); + ->first(); } if (!$ownerRow && Schema::connection($conn)->hasColumn('condomin', 'proprietario')) { // Fallback: se per l'anno richiesto non esiste la riga proprietario=1, @@ -273,14 +244,7 @@ public function handle(): int $ownerRow = (clone $legacyBase) ->where('proprietario', 1) ->orderByDesc('id') - ->first([ - 'id', - 'legacy_year', - 'nom_cond', - 'nome', - 'cognome', - 'cond_cod_fisc', - ]); + ->first(); } if (!$ownerRow) { $ownerRow = $legacyAny; @@ -295,35 +259,36 @@ public function handle(): int $tenantRow = (clone $tenantRowQ) ->where('inquilino', 1) ->orderByDesc('id') - ->first([ - 'id', - 'legacy_year', - 'inquilino_denominazione', - 'inquil_nome', - 'inquil_cod_fisc', - ]); + ->first(); } if (!$tenantRow) { // Fallback: tenant presente anche se flag incoerente (campi identità valorizzati) $tenantRow = (clone $tenantRowQ) - ->where(function ($w) { - $w->whereNotNull('inquilino_denominazione') - ->where('inquilino_denominazione', '!=', '') - ->orWhere(function ($w2) { - $w2->whereNotNull('inquil_nome')->where('inquil_nome', '!=', ''); - }) - ->orWhere(function ($w3) { - $w3->whereNotNull('inquil_cod_fisc')->where('inquil_cod_fisc', '!=', ''); - }); + ->where(function ($w) use ($conn) { + $schema = Schema::connection($conn); + $hasInqDen = $schema->hasColumn('condomin', 'inquilino_denominazione'); + $hasInqNome = $schema->hasColumn('condomin', 'inquil_nome'); + $hasInqCf = $schema->hasColumn('condomin', 'inquil_cod_fisc'); + + $conditions = false; + if ($hasInqDen) { + $w->orWhere(fn($q) => $q->whereNotNull('inquilino_denominazione')->where('inquilino_denominazione', '!=', '')); + $conditions = true; + } + if ($hasInqNome) { + $w->orWhere(fn($q) => $q->whereNotNull('inquil_nome')->where('inquil_nome', '!=', '')); + $conditions = true; + } + if ($hasInqCf) { + $w->orWhere(fn($q) => $q->whereNotNull('inquil_cod_fisc')->where('inquil_cod_fisc', '!=', '')); + $conditions = true; + } + if (! $conditions) { + $w->whereRaw('1=0'); + } }) ->orderByDesc('id') - ->first([ - 'id', - 'legacy_year', - 'inquilino_denominazione', - 'inquil_nome', - 'inquil_cod_fisc', - ]); + ->first(); } $rubricaName = $this->normalizeIdentityStr($rr->ragione_sociale ?: trim((string) ($rr->nome ?? '') . ' ' . (string) ($rr->cognome ?? ''))); diff --git a/app/Console/Commands/GesconSyncOperazioniPrimaNota.php b/app/Console/Commands/GesconSyncOperazioniPrimaNota.php index 93f7643..1d7980b 100755 --- a/app/Console/Commands/GesconSyncOperazioniPrimaNota.php +++ b/app/Console/Commands/GesconSyncOperazioniPrimaNota.php @@ -52,6 +52,11 @@ public function handle(): int [$stabile?->codice_stabile ?? null, $stabile?->cod_stabile ?? null] )))); + if (! Schema::connection('gescon_import')->hasTable('operazioni')) { + $this->warn('Tabella di staging "operazioni" non trovata. Nessun movimento da riallineare.'); + return 0; + } + $query = DB::connection('gescon_import')->table('operazioni'); if (Schema::connection('gescon_import')->hasColumn('operazioni', 'cod_stabile') && $legacyStableCodes !== []) { @@ -69,7 +74,11 @@ public function handle(): int } if ($year) { - $query->whereYear('dt_spe', $year); + if (Schema::connection('gescon_import')->hasColumn('operazioni', 'legacy_year')) { + $query->where('legacy_year', (string) $year); + } else { + $query->whereYear('dt_spe', $year); + } } if ($tipo !== 'all') { @@ -84,29 +93,40 @@ public function handle(): int } } + $availableColumns = Schema::connection('gescon_import')->getColumnListing('operazioni'); + $desiredColumns = [ + 'id_operaz', + 'dt_spe', + 'benef', + 'num_fat', + 'note', + 'natura', + 'gestione', + 'n_stra', + 'cod_spe', + 'tabella', + 'cod_for', + 'importo', + 'importo_euro', + 'importo_spese', + 'importo_entrate', + 'compet', + 'cod_stabile', + 'legacy_year', + ]; + $selectColumns = []; + foreach ($desiredColumns as $col) { + if (in_array($col, $availableColumns, true)) { + $selectColumns[] = $col; + } else { + $selectColumns[] = DB::raw("NULL as `{$col}`"); + } + } + $rows = $query ->orderBy('dt_spe') ->orderBy('id_operaz') - ->get([ - 'id_operaz', - 'dt_spe', - 'benef', - 'num_fat', - 'note', - 'natura', - 'gestione', - 'n_stra', - 'cod_spe', - 'tabella', - 'cod_for', - 'importo', - 'importo_euro', - 'importo_spese', - 'importo_entrate', - 'compet', - 'cod_stabile', - 'legacy_year', - ]); + ->get($selectColumns); $created = 0; $updated = 0; diff --git a/app/Console/Commands/ImportGesconFullPipeline.php b/app/Console/Commands/ImportGesconFullPipeline.php index 04b5b91..2ec328b 100755 --- a/app/Console/Commands/ImportGesconFullPipeline.php +++ b/app/Console/Commands/ImportGesconFullPipeline.php @@ -29,6 +29,7 @@ class ImportGesconFullPipeline extends Command {--force-mapping : Applica mapping anche sovrascrivendo valori esistenti} {--fill-missing-millesimi : Crea righe mancanti a zero per ogni unita/tabella (uso eccezionale)} {--amministratore-id= : Forza l\'assegnazione dello stabile a questo amministratore} + {--update-only : Solo aggiornamento incrementale (evita la pulizia dei dati esistenti)} '; protected $description = 'Pipeline orchestrata di import completo da archivio Gescon (MDB singolo anno) verso schema dominio.'; @@ -51,6 +52,7 @@ class ImportGesconFullPipeline extends Command 'straord', 'addebiti', 'detrazioni', + 'affitti', ]; private array $legacyYearMap = []; @@ -245,6 +247,17 @@ private function resolveLatestStagingLegacyYear(string $table, ?string $stabile return $this->stagingLatestLegacyYearCache[$cacheKey] = $this->requestedLegacyYear(); } + $reqYear = $this->requestedLegacyYear(); + if ($reqYear !== null) { + $exists = DB::connection('gescon_import')->table($table) + ->where('legacy_year', $reqYear) + ->when($stabile !== null && $stabile !== '' && Schema::connection('gescon_import')->hasColumn($table, 'cod_stabile'), fn($q) => $q->where('cod_stabile', $stabile)) + ->exists(); + if ($exists) { + return $this->stagingLatestLegacyYearCache[$cacheKey] = $reqYear; + } + } + $query = DB::connection('gescon_import')->table($table) ->whereNotNull('legacy_year') ->where('legacy_year', '!=', ''); @@ -702,6 +715,15 @@ private function stepStabili(?int $limit): int } if ($created > 0 || $updated > 0) { $this->line(" → stabili creati: {$created}, aggiornati: {$updated}"); + if (!$this->isDryRun) { + $stabiliQuery = \App\Models\Stabile::query(); + if ($this->option('stabile')) { + $stabiliQuery->where('codice_stabile', $this->option('stabile')); + } + foreach ($stabiliQuery->get() as $stabile) { + $stabile->syncRubricaContatto(); + } + } } return $created + $updated; } @@ -831,14 +853,12 @@ private function stepUnita(?int $limit): int if ($normalizedInterno !== null) { $physicalMatch = DB::table('unita_immobiliari') ->where('stabile_id', $rowStabileId) - ->when(Schema::hasColumn('unita_immobiliari', 'deleted_at'), fn($q) => $q->whereNull('deleted_at')) ->where('scala', $scalaVal) ->where('interno', $normalizedInterno) ->first(); if (! $physicalMatch && $sub !== null && $sub !== '' && Schema::hasColumn('unita_immobiliari', 'subalterno')) { $physicalMatch = DB::table('unita_immobiliari') ->where('stabile_id', $rowStabileId) - ->when(Schema::hasColumn('unita_immobiliari', 'deleted_at'), fn($q) => $q->whereNull('deleted_at')) ->where('scala', $scalaVal) ->where('subalterno', $sub) ->first(); @@ -850,7 +870,6 @@ private function stepUnita(?int $limit): int if (! $exists && $legacyCondId !== null && $legacyCondId !== '' && Schema::hasColumn('unita_immobiliari', 'legacy_cond_id')) { $exists = DB::table('unita_immobiliari') ->where('stabile_id', $rowStabileId) - ->when(Schema::hasColumn('unita_immobiliari', 'deleted_at'), fn($q) => $q->whereNull('deleted_at')) ->where('legacy_cond_id', $legacyCondId) ->first(); } @@ -1144,7 +1163,7 @@ private function stepUnita(?int $limit): int } // Cleanup: rimuovi unità non presenti in condomin (evita "alieni") - if (! $this->isDryRun && $this->option('stabile')) { + if (! $this->isDryRun && $this->option('stabile') && ! $this->option('update-only')) { $condRows = $this->currentSnapshotCondominQuery() ->select('scala', 'interno', 'cod_cond') ->get(); @@ -2287,15 +2306,19 @@ private function stepMillesimi(?int $limit): int if ($headerOnlyTable && $mm <= 0) { goto importi_tabella; } - // Upsert by (tabella, unita): ignora ruolo C/I e usa il massimo mm - $exists = DB::table('dettaglio_millesimi') + // Upsert by (tabella, unita, ruolo_legacy) + $existsQuery = DB::table('dettaglio_millesimi') ->where('tabella_millesimale_id', $tab->id) - ->where('unita_immobiliare_id', $unitaId) - ->first(); + ->where('unita_immobiliare_id', $unitaId); + + if ($hasRuoloLegacy) { + $existsQuery->where('ruolo_legacy', $roleLegacy); + } + + $exists = $existsQuery->first(); if ($exists) { - $currentMm = is_numeric($exists->millesimi ?? null) ? (float) $exists->millesimi : 0.0; $upd = [ - 'millesimi' => max($currentMm, $mm), + 'millesimi' => $mm, 'updated_at' => now(), ]; // Se la tabella ha una colonna per ordine (es. posizione, ordinale, nord), prova ad aggiornarla @@ -2320,6 +2343,9 @@ private function stepMillesimi(?int $limit): int 'created_at' => now(), 'updated_at' => now(), ]; + if ($hasRuoloLegacy) { + $payload['ruolo_legacy'] = $roleLegacy; + } if ($nord !== null) { foreach (['nord', 'ordine', 'posizione', 'ordinale'] as $c) { if (in_array($c, $detailsCols, true)) { @@ -2507,12 +2533,26 @@ private function stepMillesimi(?int $limit): int } private function inferStraordinariaSequence(mixed ...$candidates): ?int { + if (isset($candidates[0]) && is_numeric(trim((string) $candidates[0]))) { + $val = (int) trim((string) $candidates[0]); + if ($val > 0) { + return $val; + } + } + foreach ($candidates as $candidate) { $value = strtoupper(trim((string) ($candidate ?? ''))); if ($value === '') { continue; } + if (is_numeric($value)) { + $val = (int) $value; + if ($val > 0) { + return $val; + } + } + if (preg_match('/([0-9]{4})$/', $value, $match)) { $sequence = (int) substr($match[1], -2); if ($sequence > 0) { @@ -3231,12 +3271,12 @@ private function stepOperazioni(?int $limit): int continue; } $exists = DB::table('operazioni_contabili')->where('legacy_id', $legacy)->first(); - if ($exists) { + if ($exists && ! $this->option('update-only')) { continue; } $gestioneId = $this->resolveGestioneId($o->legacy_year ?? ($o->anno ?? null), $o->compet ?? null, $o->gestione ?? null, $o->n_stra ?? null); - $protoNum = $this->nextProtocolNumber(); + $protoNum = (isset($o->n_spe) && is_numeric($o->n_spe) && (int) $o->n_spe > 0) ? (int) $o->n_spe : $this->nextProtocolNumber(); $prefix = null; if ($gestioneId) { $prefix = DB::table('gestioni_contabili')->where('id', $gestioneId)->value('protocollo_prefix'); @@ -3297,6 +3337,19 @@ private function stepOperazioni(?int $limit): int } } + $isEntrata = strtolower(trim((string) ($o->natura2 ?? ''))) === 'entrata'; + $amount = (float) ($o->importo_euro ?? ($o->imp_calcolato_euro ?? 0)); + $dareVal = (float) ($o->importo_spese ?? 0); + $avereVal = (float) ($o->importo_entrate ?? 0); + + if ($dareVal == 0 && $avereVal == 0 && $amount != 0) { + if ($isEntrata) { + $avereVal = $amount; + } else { + $dareVal = $amount; + } + } + $data = [ 'tenant_id' => null, 'gestione_id' => $gestioneId, @@ -3310,8 +3363,8 @@ private function stepOperazioni(?int $limit): int 'protocollo_completo' => $protoCompleto, 'voce_spesa_snapshot' => $voceSpesaSnapshot, 'fornitore_snapshot' => $fornitoreSnapshot, - 'dare' => (float) ($o->importo_spese ?? 0), - 'avere' => (float) ($o->importo_entrate ?? 0), + 'dare' => $dareVal, + 'avere' => $avereVal, 'conto_contabile' => $o->cod_spe ?? null, 'stato_operazione' => 'confermata', 'created_at' => now(), @@ -3343,6 +3396,38 @@ private function stepOperazioni(?int $limit): int } } } + if ($exists) { + $dirty = false; + $updateData = []; + $updatableFields = [ + 'descrizione', + 'data_operazione', + 'compet', + 'natura2', + 'n_stra', + 'dare', + 'avere', + 'conto_contabile', + 'voce_spesa_snapshot', + 'fornitore_snapshot', + ]; + foreach ($data as $k => $v) { + if (! in_array($k, $updatableFields, true)) { + continue; + } + if ((string) ($exists->{$k} ?? '') !== (string) ($v ?? '')) { + $updateData[$k] = $v; + $dirty = true; + } + } + if ($dirty) { + $updateData['updated_at'] = now(); + DB::table('operazioni_contabili')->where('id', $exists->id)->update($updateData); + } + $count++; + continue; + } + $this->dynamicInsert('operazioni_contabili', $data); $count++; } @@ -3351,6 +3436,11 @@ private function stepOperazioni(?int $limit): int private function purgeAuthoritativeLegacyReplica(array $steps): void { + if ($this->option('update-only')) { + $this->line('ℹ️ Opzione --update-only attiva: salto la pulizia dei dati esistenti per conservare le modifiche utente.'); + return; + } + $stabileId = (int) ($this->stabileId ?? 0); if ($stabileId <= 0) { return; @@ -4336,7 +4426,7 @@ private function stepIncassi(?int $limit): int $existsQuery->where('condominio_id', $this->stabileId); } $exists = $existsQuery->first(); - if ($exists) { + if ($exists && ! $this->option('update-only')) { continue; } @@ -4441,6 +4531,44 @@ private function stepIncassi(?int $limit): int 'updated_at' => now(), ]); } + if ($exists) { + $dirty = false; + $updateData = []; + $updatableFields = [ + 'data_incasso', + 'importo', + 'descrizione', + 'riferimento_orig', + 'ruolo_quota', + 'cod_cond_gescon', + 'cond_inquil', + 'n_mese', + 'o_r_s', + 'anno_rif', + 'dt_empag', + 'importo_pagato', + 'importo_pagato_euro', + 'n_riferimento', + 'cod_cassa_gescon', + 'proviene_n_stra', + ]; + foreach ($data as $k => $v) { + if (! in_array($k, $updatableFields, true)) { + continue; + } + if ((string) ($exists->{$k} ?? '') !== (string) ($v ?? '')) { + $updateData[$k] = $v; + $dirty = true; + } + } + if ($dirty) { + $updateData['updated_at'] = now(); + DB::table('incassi')->where('id', $exists->id)->update($updateData); + } + $count++; + continue; + } + $data['created_at'] = $data['created_at'] ?? now(); $data['updated_at'] = now(); $this->dynamicInsert('incassi', $data); @@ -4945,6 +5073,61 @@ private function stepDetrazioni(?int $limit): int return $count; } + private function stepAffitti(?int $limit): int + { + $basePath = (string) ($this->option('path') ?: ''); + $partiComuniMdb = $this->resolvePartiComuniMdbPath($basePath); + + if (!$partiComuniMdb || !is_file($partiComuniMdb)) { + $this->warn(" → File parti_comuni.mdb non trovato. Salto import affitti."); + return 0; + } + + $codStabile = $this->option('stabile'); + $this->info(" → Importazione affitti da {$partiComuniMdb} per lo stabile " . ($codStabile ?: 'tutti') . "..."); + + if ($this->option('dry-run')) { + $this->line(" (dry-run) import affitti skip"); + return 0; + } + + $params = [ + '--mdb' => $partiComuniMdb, + '--refresh-canoni' => true, + ]; + if ($codStabile) { + $params['--stabile'] = $codStabile; + } + + $this->call('affitti:import-mdb', $params); + + return 1; + } + + private function resolvePartiComuniMdbPath(string $basePath = ''): ?string + { + $candidates = []; + + $basePath = trim($basePath); + if ($basePath !== '') { + $normalizedBase = rtrim($basePath, '/'); + $candidates[] = $normalizedBase . '/parti_comuni.mdb'; + $candidates[] = $normalizedBase . '/../parti_comuni.mdb'; + $candidates[] = $normalizedBase . '/../../parti_comuni.mdb'; + } + + $candidates[] = '/mnt/gescon-archives/gescon/parti_comuni.mdb'; + + foreach (array_values(array_unique(array_filter($candidates))) as $candidate) { + if (is_file($candidate) && is_readable($candidate)) { + return $candidate; + } + } + + return null; + } + + private function stepAssemblee(?int $limit): int { if (! Schema::hasTable('assemblee')) { @@ -7580,6 +7763,20 @@ private function resolveGestioneContabileIdForStabile(?int $stabileId, ?string $ default => 'ordinaria', }; + if ($tipo === 'straordinaria' && $numeroStraordinaria !== null && $numeroStraordinaria > 1) { + $stabileCode = DB::table('stabili')->where('id', $stabileId)->value('codice_stabile'); + $stabileCode = trim((string) ($stabileCode ?? '')); + if ($stabileCode !== '' && Schema::connection('gescon_import')->hasTable('straordinarie')) { + $existsInStaging = DB::connection('gescon_import')->table('straordinarie') + ->where('cod_stabile', $stabileCode) + ->where('codice', (string) $numeroStraordinaria) + ->exists(); + if (! $existsInStaging) { + $numeroStraordinaria = 1; + } + } + } + $q = DB::table('gestioni_contabili') ->where('anno_gestione', (int) $anno) ->where('tipo_gestione', $tipo); diff --git a/app/Console/Commands/LoadGesconMdbToStaging.php b/app/Console/Commands/LoadGesconMdbToStaging.php index ea980b5..bee8194 100755 --- a/app/Console/Commands/LoadGesconMdbToStaging.php +++ b/app/Console/Commands/LoadGesconMdbToStaging.php @@ -108,18 +108,23 @@ private function ensureSaldoCasseTable(): void $this->warn('Impossibile creare la tabella s_cassa su staging: ' . $e->getMessage()); } } - private function ensureCondominColumns(): void { if (! Schema::connection('gescon_import')->hasTable('condomin')) { return; } - // Alcuni ambienti hanno gia' la tabella al limite della row size. - // I campi storici opzionali vanno gestiti come best-effort a valle, - // senza forzare ALTER TABLE durante il reload della staging. - } + $cols = ['inquil_nome', 'inquil_cod_fisc', 'e_mail_inquilino', 'inquil_tel1', 'inquil_indir']; + $connection = Schema::connection('gescon_import'); + Schema::connection('gescon_import')->table('condomin', function (Blueprint $table) use ($cols, $connection): void { + foreach ($cols as $col) { + if (! $connection->hasColumn('condomin', $col)) { + $table->string($col)->nullable(); + } + } + }); + } private function ensureSliceColumns(string $tableName): void { if (! Schema::connection('gescon_import')->hasTable($tableName)) { @@ -334,7 +339,7 @@ public function handle(): int // Estrai CSV con header $binExport = trim((string) shell_exec('command -v mdb-export')) ?: '/usr/bin/mdb-export'; - $process = new Process([$binExport, '-D', '%Y-%m-%d', $mdb, $tableEffective]); + $process = new Process([$binExport, '-D', '%Y-%m-%d', '-T', '%Y-%m-%d %H:%M:%S', $mdb, $tableEffective]); $process->setTimeout(120); $process->run(); if (! $process->isSuccessful()) { @@ -525,6 +530,21 @@ public function handle(): int if ($hasColumn('inquilino') && empty($filtered['inquilino'])) { $filtered['inquilino'] = ! empty($assoc['inquil_nome']) ? 1 : 0; } + if ($hasColumn('inquil_nome') && empty($filtered['inquil_nome'])) { + $filtered['inquil_nome'] = trim((string) ($assoc['inquil_nome'] ?? null)) ?: null; + } + if ($hasColumn('inquil_cod_fisc') && empty($filtered['inquil_cod_fisc'])) { + $filtered['inquil_cod_fisc'] = trim((string) ($assoc['inquil_cod_fisc'] ?? null)) ?: null; + } + if ($hasColumn('e_mail_inquilino') && empty($filtered['e_mail_inquilino'])) { + $filtered['e_mail_inquilino'] = trim((string) ($assoc['inquil_e_mail'] ?? $assoc['e_mail_inquilino'] ?? null)) ?: null; + } + if ($hasColumn('inquil_tel1') && empty($filtered['inquil_tel1'])) { + $filtered['inquil_tel1'] = trim((string) ($assoc['inquil_tel1'] ?? $assoc['inquil_tel'] ?? null)) ?: null; + } + if ($hasColumn('inquil_indir') && empty($filtered['inquil_indir'])) { + $filtered['inquil_indir'] = trim((string) ($assoc['inquil_indir'] ?? $assoc['inquil_presso'] ?? null)) ?: null; + } if ($hasColumn('proprietario') && empty($filtered['proprietario'])) { $filtered['proprietario'] = 1; } diff --git a/app/Filament/Pages/Condomini/Components/GestioniStabileTable.php b/app/Filament/Pages/Condomini/Components/GestioniStabileTable.php index 7ca1c3d..b0914ba 100755 --- a/app/Filament/Pages/Condomini/Components/GestioniStabileTable.php +++ b/app/Filament/Pages/Condomini/Components/GestioniStabileTable.php @@ -71,21 +71,15 @@ protected function getTableQuery(): Builder $mostraRiscaldamento = (bool) data_get($stabile?->configurazione_avanzata, 'mostra_riscaldamento', false); $query = GestioneContabile::query() - ->where('stabile_id', $activeStabileId) - ->where(function (Builder $builder) use ($user): void { - $tenantId = trim((string) ($user->tenant_id ?? '')); + ->where('stabile_id', $activeStabileId); - if ($tenantId !== '') { - $builder->where('tenant_id', $tenantId) - ->orWhere('tenant_id', '00000000'); - - return; - } - - $builder->whereNull('tenant_id') - ->orWhere('tenant_id', '') + $tenantId = trim((string) ($user->tenant_id ?? '')); + if ($tenantId !== '') { + $query->where(function (Builder $builder) use ($tenantId): void { + $builder->where('tenant_id', $tenantId) ->orWhere('tenant_id', '00000000'); }); + } if ($this->scope === 'archivio') { $query->whereIn('stato', ['chiusa', 'consolidata']); @@ -486,7 +480,7 @@ public function table(Table $table): Table Action::make('apri_archivio_gestioni') ->label('Apri archivio') - ->url(fn() => \App\Filament\Pages\Condomini\StabilePage::getUrl(panel: 'admin-filament') . '?tab=gestioni&gestioni_tab=archivio', shouldOpenInNewTab: true), + ->url(fn() => \App\Filament\Pages\Condomini\StabilePage::getUrl(panel: 'admin-filament') . '?tab=gestioni&gestioni_tab=archivio'), Action::make('archivia_gestione') ->label('Sposta in archivio') ->icon('heroicon-o-archive-box') diff --git a/app/Filament/Pages/Condomini/NominativiStabile.php b/app/Filament/Pages/Condomini/NominativiStabile.php index 6752502..9aaad1a 100755 --- a/app/Filament/Pages/Condomini/NominativiStabile.php +++ b/app/Filament/Pages/Condomini/NominativiStabile.php @@ -30,9 +30,98 @@ class NominativiStabile extends Page implements HasTable public ?int $stabileId = null; + public bool $cumulato = false; + /** @var array */ private array $rubricaTitoloCache = []; + protected function getHeaderActions(): array + { + $user = Auth::user(); + $activeStabileId = $user instanceof User ? (int) (StabileContext::resolveActiveStabileId($user) ?: 0): 0; + + if ($activeStabileId <= 0) { + return []; + } + + if (! config('netgescon.legacy_import_enabled', true)) { + return []; + } + + $stabile = Stabile::find($activeStabileId); + if (!$stabile) { + return []; + } + + $codStabile = $this->resolveLegacyStabileCode($activeStabileId); + if ($codStabile === '') { + return []; + } + + return [ + Action::make('sync_legacy_nominativi') + ->label('Sincronizza da Legacy') + ->icon('heroicon-m-arrow-path') + ->color('warning') + ->requiresConfirmation() + ->modalHeading('Sincronizza Nominativi e Unità da Legacy') + ->modalDescription('Questo processo importerà le unità e i soggetti dal database legacy di staging del Gescon per lo stabile corrente.') + ->action(function () { + $user = Auth::user(); + $activeStabileId = $user instanceof User ? (int) (StabileContext::resolveActiveStabileId($user) ?: 0): 0; + $stabile = Stabile::find($activeStabileId); + $codStabile = $this->resolveLegacyStabileCode($activeStabileId); + try { + $path = app(\App\Services\TenantArchivePathService::class)->stabileAbsolutePath($stabile); + // Trova l'anno legacy più recente importato in staging + $anno = (string) (DB::connection('gescon_import') + ->table('condomin') + ->where('cod_stabile', $codStabile) + ->max('legacy_year') ?: ''); + + if ($anno === '') { + \Filament\Notifications\Notification::make() + ->title('Errore Sincronizzazione') + ->body('Nessun anno legacy trovato per lo stabile corrente in staging.') + ->danger() + ->send(); + return; + } + + // Esegui gli step: unita, relazioni, soggetti, diritti + $steps = ['unita', 'relazioni', 'soggetti', 'diritti']; + foreach ($steps as $step) { + \Illuminate\Support\Facades\Artisan::call('gescon:import-full', [ + '--stabile' => $codStabile, + '--solo' => $step, + '--path' => $path, + '--anno' => $anno, + '--update-only' => true, + ]); + } + + // Forza la rigenerazione della vista + try { + DB::connection('gescon_import')->statement("DROP VIEW IF EXISTS vw_legacy_condomin_nominativi"); + } catch (\Throwable $e) {} + $this->ensureLegacyViewExists(); + + \Filament\Notifications\Notification::make() + ->title('Sincronizzazione completata') + ->body('Le unità e i soggetti dello stabile sono stati sincronizzati con successo dal legacy.') + ->success() + ->send(); + } catch (\Throwable $e) { + \Filament\Notifications\Notification::make() + ->title('Errore Sincronizzazione') + ->body($e->getMessage()) + ->danger() + ->send(); + } + }), + ]; + } + private function applyDomainNominativiSearch(Builder $query, string $search, int $activeStabileId): Builder { $search = trim($search); @@ -398,6 +487,13 @@ protected function getTableQuery(): Builder ->on('mx.interno', '=', 'vw_legacy_condomin_nominativi.interno') ->on('mx.legacy_year', '=', 'vw_legacy_condomin_nominativi.legacy_year'); }) + ->when($this->cumulato, function (Builder $q) use ($legacyTable): void { + $q->where(function (Builder $sub) use ($legacyTable) { + $sub->whereNull($legacyTable . '.cumulo_cond') + ->orWhere($legacyTable . '.cumulo_cond', '') + ->orWhereColumn($legacyTable . '.cumulo_cond', $legacyTable . '.cod_cond'); + }); + }) ->orderBy($legacyTable . '.scala') // Ordine richiesto: per interno (con fallback robusto) ->orderByRaw("CASE WHEN vw_legacy_condomin_nominativi.interno IS NULL OR vw_legacy_condomin_nominativi.interno = '' THEN 1 ELSE 0 END") @@ -416,12 +512,59 @@ protected function hasLegacyNominativiForStabile(string $codStabile): bool ->exists(); } + private function getAccumulatedUnits(string $codStabile, string $legacyYear, string $codCond): array + { + $units = DB::connection('gescon_import') + ->table('vw_legacy_condomin_nominativi') + ->where('cod_stabile', $codStabile) + ->where('legacy_year', $legacyYear) + ->where('cumulo_cond', $codCond) + ->where('cod_cond', '<>', $codCond) + ->orderBy('scala') + ->orderBy('interno') + ->get(['scala', 'interno', 'piano']); + + $result = []; + foreach ($units as $u) { + $parts = []; + if (!empty($u->scala)) $parts[] = 'Sc. ' . trim($u->scala); + if (!empty($u->interno)) $parts[] = 'Int. ' . trim($u->interno); + if ($u->piano !== '' && $u->piano !== null) $parts[] = 'P. ' . trim($u->piano); + $result[] = $parts === [] ? '—' : implode(' · ', $parts); + } + return $result; + } + + private function getAccumulatedDomainUnits(int $mainUnitId): array + { + $units = DB::table('unita_pertinenze as up') + ->join('unita_immobiliari as ui', 'ui.id', '=', 'up.unita_accessoria_id') + ->where('up.unita_principale_id', $mainUnitId) + ->where('up.cumulo', true) + ->orderBy('ui.scala') + ->orderBy('ui.interno') + ->get(['ui.scala', 'ui.interno', 'ui.piano']); + + $result = []; + foreach ($units as $u) { + $parts = []; + if (!empty($u->scala)) $parts[] = 'Sc. ' . trim($u->scala); + if (!empty($u->interno)) $parts[] = 'Int. ' . trim($u->interno); + if ($u->piano !== '' && $u->piano !== null) $parts[] = 'P. ' . trim($u->piano); + $result[] = $parts === [] ? '—' : implode(' · ', $parts); + } + return $result; + } + private function ensureLegacyViewExists(): void { $conn = DB::connection('gescon_import'); + $hasCumulo = false; try { - $conn->table('vw_legacy_condomin_nominativi')->limit(1)->first(); - } catch (\Throwable $e) { + $hasCumulo = DbSchema::connection('gescon_import')->hasColumn('vw_legacy_condomin_nominativi', 'cumulo_cond'); + } catch (\Throwable $e) {} + + if (!$hasCumulo) { $cols = DbSchema::connection('gescon_import')->getColumnListing('condomin'); $desiredFields = [ 'id', 'cod_stabile', 'legacy_year', 'legacy_period_label', 'legacy_file', @@ -433,7 +576,8 @@ private function ensureLegacyViewExists(): void 'inquil_tel2', 'cell_inq', 'fax_inq', 'e_mail_inquilino', 'pec_inquilino', 'inquil_cod_fisc', 'inquil_dal', 'inquil_al', 'inquil_contratto_dal', 'perc_diritto_reale', 'diritto_reale', 'diritto_godimento', 'note_cond', - 'inquil_note', 'note' + 'inquil_note', 'note', + 'cumulo_cond', 'cumulo_inq', 'cumulo_cond_ec', 'cumulo_inq_ec' ]; $selects = []; foreach ($desiredFields as $field) { @@ -448,6 +592,10 @@ private function ensureLegacyViewExists(): void } } $selectSql = implode(', ', $selects); + try { + $conn->statement("DROP VIEW IF EXISTS vw_legacy_condomin_nominativi"); + } catch (\Throwable $e) {} + $sql = $conn->getDriverName() === 'sqlite' ? "CREATE VIEW IF NOT EXISTS vw_legacy_condomin_nominativi AS SELECT {$selectSql} FROM condomin" : "CREATE OR REPLACE VIEW vw_legacy_condomin_nominativi AS SELECT {$selectSql} FROM condomin"; @@ -556,6 +704,15 @@ protected function buildDomainFallbackQuery(int $stabileId): Builder return UnitaImmobiliare::query() ->where('stabile_id', $stabileId) ->whereNull('deleted_at') + ->when($this->cumulato, function (Builder $query) use ($stabileId): void { + $query->whereNotExists(function ($sub) use ($stabileId) { + $sub->selectRaw('1') + ->from('unita_pertinenze') + ->where('stabile_id', $stabileId) + ->whereColumn('unita_accessoria_id', 'unita_immobiliari.id') + ->where('cumulo', true); + }); + }) ->where(function (Builder $q) use ($stabileId, $ownerRoles, $tenantRoles): void { $q->whereExists(function ($sub) use ($stabileId, $ownerRoles) { $sub->selectRaw('1') @@ -750,11 +907,92 @@ public function table(Table $table): Table $q->whereNull('inquil_nome')->orWhere('inquil_nome', ''); }), ), + SelectFilter::make('soggetto') + ->label('Cerca condomino') + ->searchable() + ->options(function () use ($codStabile, $legacyYear, $activeStabileId, $useLegacy): array { + if ($useLegacy) { + if ($codStabile === '') { + return []; + } + return DB::connection('gescon_import') + ->table('vw_legacy_condomin_nominativi') + ->where('cod_stabile', $codStabile) + ->where('legacy_year', $legacyYear) + ->whereNotNull('nom_cond') + ->where('nom_cond', '<>', '') + ->distinct() + ->orderBy('nom_cond') + ->pluck('nom_cond', 'nom_cond') + ->all(); + } else { + if ($activeStabileId <= 0) { + return []; + } + return DB::table('rubrica_ruoli as rr') + ->join('rubrica_universale as ru', 'ru.id', '=', 'rr.rubrica_id') + ->where('rr.stabile_id', $activeStabileId) + ->where('rr.is_attivo', true) + ->distinct() + ->orderByRaw("TRIM(CONCAT(COALESCE(ru.nome, ''), ' ', COALESCE(ru.cognome, '')))") + ->get(['ru.nome', 'ru.cognome', 'ru.ragione_sociale']) + ->mapWithKeys(function($row) { + $nome = trim(($row->nome ?? '') . ' ' . ($row->cognome ?? '')); + $label = $nome !== '' ? $nome : ($row->ragione_sociale ?? '—'); + return [$label => $label]; + }) + ->all(); + } + }) + ->query(function (Builder $query, array $data) use ($legacyTable, $useLegacy, $activeStabileId): Builder { + if (! isset($data['value']) || $data['value'] === '') { + return $query; + } + + if ($useLegacy) { + return $query->where($legacyTable . '.nom_cond', $data['value']); + } else { + return $this->applyDomainNominativiSearch($query, $data['value'], $activeStabileId); + } + }), + SelectFilter::make('inquilino') + ->label('Cerca inquilino') + ->searchable() + ->options(function () use ($codStabile, $legacyYear, $useLegacy): array { + if ($useLegacy) { + if ($codStabile === '') { + return []; + } + return DB::connection('gescon_import') + ->table('vw_legacy_condomin_nominativi') + ->where('cod_stabile', $codStabile) + ->where('legacy_year', $legacyYear) + ->whereNotNull('inquil_nome') + ->where('inquil_nome', '<>', '') + ->distinct() + ->orderBy('inquil_nome') + ->pluck('inquil_nome', 'inquil_nome') + ->all(); + } else { + return []; + } + }) + ->query(function (Builder $query, array $data) use ($legacyTable, $useLegacy, $activeStabileId): Builder { + if (! isset($data['value']) || $data['value'] === '') { + return $query; + } + + if ($useLegacy) { + return $query->where($legacyTable . '.inquil_nome', $data['value']); + } else { + return $this->applyDomainNominativiSearch($query, $data['value'], $activeStabileId); + } + }), ]) ->columns([ TextColumn::make('unita_ref') ->label('Unità') - ->formatStateUsing(function ($state, $record): string { + ->formatStateUsing(function ($state, $record) use ($useLegacy, $codStabile, $legacyYear): string { $scala = trim((string) ($record->scala ?? '')); $interno = trim((string) ($record->interno ?? '')); $piano = trim((string) ($record->piano ?? '')); @@ -770,7 +1008,26 @@ public function table(Table $table): Table $parts[] = 'P. ' . $piano; } - return $parts === [] ? '—' : implode(' · ', $parts); + $mainLabel = $parts === [] ? '—' : implode(' · ', $parts); + + if ($this->cumulato) { + if ($useLegacy) { + $codCond = $record->cod_cond ?? null; + if ($codCond) { + $accumulated = $this->getAccumulatedUnits($codStabile, $record->legacy_year ?? $legacyYear, $codCond); + if (!empty($accumulated)) { + return $mainLabel . ' (+ ' . implode(', ', $accumulated) . ')'; + } + } + } else { + $accumulated = $this->getAccumulatedDomainUnits((int) $record->id); + if (!empty($accumulated)) { + return $mainLabel . ' (+ ' . implode(', ', $accumulated) . ')'; + } + } + } + + return $mainLabel; }) ->wrap(), @@ -911,7 +1168,7 @@ public function table(Table $table): Table } return UnitaImmobiliarePage::getUrl(panel: 'admin-filament') . '?unita_id=' . $unitaId; - }, shouldOpenInNewTab: true) + }) ->visible(fn($record): bool => (bool) $this->resolveUnitaIdForRow($record, $activeStabileId)), Action::make('apri_scheda_unica') @@ -924,7 +1181,7 @@ public function table(Table $table): Table } return RubricaUniversaleScheda::getUrl(['record' => $rubricaId], panel: 'admin-filament'); - }, shouldOpenInNewTab: true) + }) ->visible(fn($record): bool => (bool) $this->resolveRubricaIdForRow($record)), ]); } diff --git a/app/Filament/Pages/Condomini/RiscaldamentoStabileArchivio.php b/app/Filament/Pages/Condomini/RiscaldamentoStabileArchivio.php new file mode 100755 index 0000000..4abd820 --- /dev/null +++ b/app/Filament/Pages/Condomini/RiscaldamentoStabileArchivio.php @@ -0,0 +1,4182 @@ + */ + public array $riscaldamentoSelectedFeIds = []; + + public ?int $riscaldamentoUiEditingId = null; + + /** @var array */ + public array $riscaldamentoUiReadingForm = []; + + public string $riscaldamentoCampagnaSort = 'scala_interno'; + + /** @var array> */ + public array $riscaldamentoCampagnaEditRows = []; + + public ?string $riscaldamentoDataInizioFiltro = null; + public ?string $riscaldamentoDataFineFiltro = null; + + public bool $showSendEmailModal = false; + public string $emailDestinatario = ''; + public string $emailOggetto = ''; + public string $emailCorpo = ''; + + public $riscaldamentoReadingsFile; + + public static function canAccess(): bool + { + $user = Auth::user(); + + return $user instanceof User + && $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore']) + && Schema::hasTable('stabile_servizi'); + } + + public function mount(): void + { + $this->riscaldamentoTab = $this->normalizeRiscaldamentoTab((string) request()->query('tab', 'dashboard')); + $this->resetRiscaldamentoUiReadingForm(); + $this->mountInteractsWithTable(); + $this->loadRiscaldamentoFilterDates(); + $this->initializeEmailDefaults(); + + $stabileId = $this->resolveActiveStabileId(); + if ($stabileId > 0) { + $this->ensureFornitoreStabileDefaultVoceSpesa($stabileId); + } + } + + public function setRiscaldamentoTab(string $tab): void + { + $this->riscaldamentoTab = $this->normalizeRiscaldamentoTab($tab); + $this->loadRiscaldamentoFilterDates(); + } + + private function loadRiscaldamentoFilterDates(): void + { + $stabileId = $this->resolveActiveStabileId(); + $year = $this->resolveActiveAnnoGestione(); + $service = $this->resolveActiveRiscaldamentoServizio(); + + $this->riscaldamentoDataInizioFiltro = null; + $this->riscaldamentoDataFineFiltro = null; + + if ($service instanceof StabileServizio) { + $periods = $service->meta['periodi_esercizio'] ?? []; + if (!empty($periods[$year]['dal'])) { + $this->riscaldamentoDataInizioFiltro = $periods[$year]['dal']; + } + if (!empty($periods[$year]['al'])) { + $this->riscaldamentoDataFineFiltro = $periods[$year]['al']; + } + } + } + + private function initializeEmailDefaults(): void + { + $stabileId = $this->resolveActiveStabileId(); + $stabile = $stabileId ? Stabile::query()->find($stabileId) : null; + + $this->emailOggetto = 'Riepilogo Ripartizione Riscaldamento - Stabile ' . ($stabile?->denominazione ?? ''); + $this->emailCorpo = "Gentile collaboratore,\nin allegato trovi il PDF del riepilogo contatori riscaldamento per lo stabile in oggetto.\n\nCordiali saluti,\nL'Amministrazione"; + } + + public function toggleRiscaldamentoFeSelection(int $fatturaId): void + { + $selected = $this->normalizeRiscaldamentoSelectedFeIds(); + + if (in_array($fatturaId, $selected, true)) { + $selected = array_values(array_filter($selected, static fn(int $value): bool => $value !== $fatturaId)); + } else { + $selected[] = $fatturaId; + } + + $this->riscaldamentoSelectedFeIds = array_values(array_unique($selected)); + } + + public function selectAllRiscaldamentoFe(): void + { + $this->riscaldamentoSelectedFeIds = $this->resolveRiscaldamentoCandidateInvoices() + ->pluck('id') + ->map(fn($value): int => (int) $value) + ->filter(fn(int $value): bool => $value > 0) + ->values() + ->all(); + } + + public function clearRiscaldamentoFeSelection(): void + { + $this->riscaldamentoSelectedFeIds = []; + } + + public function startCreateRiscaldamentoUiReading(): void + { + $this->resetRiscaldamentoUiReadingForm(); + } + + public function startEditRiscaldamentoUiReading(int $readingId): void + { + $stabileId = $this->resolveActiveStabileId(); + if (! $stabileId) { + return; + } + + $record = StabileServizioLettura::query() + ->where('id', $readingId) + ->where('stabile_id', $stabileId) + ->whereNotNull('unita_immobiliare_id') + ->whereHas('servizio', fn(Builder $q) => $q->where('tipo', 'riscaldamento')) + ->first(); + + if (! $record instanceof StabileServizioLettura) { + Notification::make()->title('Lettura UI non trovata')->warning()->send(); + return; + } + + $this->riscaldamentoUiEditingId = (int) $record->id; + $this->riscaldamentoUiReadingForm = [ + 'stabile_servizio_id' => (int) $record->stabile_servizio_id, + 'unita_immobiliare_id' => (int) ($record->unita_immobiliare_id ?? 0) ?: null, + 'periodo_dal' => optional($record->periodo_dal)->toDateString(), + 'periodo_al' => optional($record->periodo_al)->toDateString(), + 'canale_acquisizione' => (string) ($record->canale_acquisizione ?? 'manuale'), + 'riferimento_acquisizione' => (string) ($record->riferimento_acquisizione ?? ''), + 'workflow_stato' => (string) ($record->workflow_stato ?? 'ricevuta'), + 'rilevatore_tipo' => (string) ($record->rilevatore_tipo ?? 'amministrazione'), + 'rilevatore_nome' => (string) ($record->rilevatore_nome ?? ''), + 'lettura_precedente_valore' => $record->lettura_precedente_valore, + 'lettura_inizio' => $record->lettura_inizio, + 'lettura_fine' => $record->lettura_fine, + 'consumo_valore' => $record->consumo_valore, + 'consumo_unita' => (string) ($record->consumo_unita ?: 'mc'), + 'deadline_lettura_at' => optional($record->deadline_lettura_at)->toDateString(), + 'note' => trim((string) data_get($record->raw, 'inline_ui_editor.note', '')), + ]; + } + + public function cancelRiscaldamentoUiEditing(): void + { + $this->resetRiscaldamentoUiReadingForm(); + } + + public function saveRiscaldamentoUiReading(): void + { + $stabileId = $this->resolveActiveStabileId(); + $user = Auth::user(); + if (! $stabileId || ! $user instanceof User) { + return; + } + + $validated = $this->validate([ + 'riscaldamentoUiReadingForm.stabile_servizio_id' => ['required', 'integer'], + 'riscaldamentoUiReadingForm.unita_immobiliare_id' => ['required', 'integer'], + 'riscaldamentoUiReadingForm.periodo_dal' => ['nullable', 'date'], + 'riscaldamentoUiReadingForm.periodo_al' => ['nullable', 'date', 'after_or_equal:riscaldamentoUiReadingForm.periodo_dal'], + 'riscaldamentoUiReadingForm.canale_acquisizione' => ['nullable', 'string', 'max:50'], + 'riscaldamentoUiReadingForm.riferimento_acquisizione' => ['nullable', 'string', 'max:191'], + 'riscaldamentoUiReadingForm.workflow_stato' => ['nullable', 'string', 'max:50'], + 'riscaldamentoUiReadingForm.rilevatore_tipo' => ['nullable', 'string', 'max:80'], + 'riscaldamentoUiReadingForm.rilevatore_nome' => ['nullable', 'string', 'max:191'], + 'riscaldamentoUiReadingForm.lettura_precedente_valore' => ['nullable', 'numeric', 'min:0'], + 'riscaldamentoUiReadingForm.lettura_inizio' => ['nullable', 'numeric', 'min:0'], + 'riscaldamentoUiReadingForm.lettura_fine' => ['nullable', 'numeric', 'min:0'], + 'riscaldamentoUiReadingForm.consumo_valore' => ['nullable', 'numeric'], + 'riscaldamentoUiReadingForm.consumo_unita' => ['nullable', 'string', 'max:16'], + 'riscaldamentoUiReadingForm.deadline_lettura_at' => ['nullable', 'date'], + 'riscaldamentoUiReadingForm.note' => ['nullable', 'string', 'max:2000'], + ]); + + $data = $validated['riscaldamentoUiReadingForm'] ?? []; + + $servizio = StabileServizio::query() + ->where('id', (int) ($data['stabile_servizio_id'] ?? 0)) + ->where('stabile_id', $stabileId) + ->where('tipo', 'riscaldamento') + ->first(); + + $unita = UnitaImmobiliare::query() + ->where('id', (int) ($data['unita_immobiliare_id'] ?? 0)) + ->where('stabile_id', $stabileId) + ->first(); + + if (! $servizio instanceof StabileServizio || ! $unita instanceof UnitaImmobiliare) { + Notification::make()->title('Servizio o unità non validi')->warning()->send(); + return; + } + + $record = $this->riscaldamentoUiEditingId + ? StabileServizioLettura::query() + ->where('id', (int) $this->riscaldamentoUiEditingId) + ->where('stabile_id', $stabileId) + ->whereNotNull('unita_immobiliare_id') + ->whereHas('servizio', fn(Builder $q) => $q->where('tipo', 'riscaldamento')) + ->first() + : null; + + if ($this->riscaldamentoUiEditingId && ! $record instanceof StabileServizioLettura) { + Notification::make()->title('Lettura UI non trovata')->warning()->send(); + return; + } + + $previous = StabileServizioLettura::query() + ->where('stabile_servizio_id', (int) $servizio->id) + ->where('unita_immobiliare_id', (int) $unita->id) + ->when($record instanceof StabileServizioLettura, fn(Builder $q) => $q->where('id', '!=', (int) $record->id)) + ->whereNotNull('lettura_fine') + ->orderByDesc('periodo_al') + ->orderByDesc('id') + ->first(); + + $letturaPrecedente = is_numeric($data['lettura_precedente_valore'] ?? null) + ? round((float) $data['lettura_precedente_valore'], 3) + : ($previous?->lettura_fine !== null ? (float) $previous->lettura_fine : null); + $letturaInizio = is_numeric($data['lettura_inizio'] ?? null) + ? round((float) $data['lettura_inizio'], 3) + : $letturaPrecedente; + $letturaFine = is_numeric($data['lettura_fine'] ?? null) + ? round((float) $data['lettura_fine'], 3) + : null; + $consumo = is_numeric($data['consumo_valore'] ?? null) + ? round((float) $data['consumo_valore'], 3) + : (($letturaFine !== null && $letturaInizio !== null) ? round($letturaFine - $letturaInizio, 3) : null); + + if ($consumo !== null && $consumo < 0) { + $this->addError('riscaldamentoUiReadingForm.lettura_fine', 'La lettura finale non può essere inferiore alla precedente.'); + return; + } + + $raw = is_array($record?->raw ?? null) ? $record->raw : []; + $raw['inline_ui_editor'] = [ + 'updated_by' => (int) $user->id, + 'updated_at' => now()->toIso8601String(), + 'note' => trim((string) ($data['note'] ?? '')), + ]; + + $payload = [ + 'stabile_id' => $stabileId, + 'stabile_servizio_id' => (int) $servizio->id, + 'unita_immobiliare_id' => (int) $unita->id, + 'fornitore_id' => $servizio->fornitore_id, + 'periodo_dal' => $data['periodo_dal'] ?? null, + 'periodo_al' => $data['periodo_al'] ?? null, + 'tipologia_lettura' => 'manuale_ui', + 'canale_acquisizione' => trim((string) ($data['canale_acquisizione'] ?? 'manuale')) ?: 'manuale', + 'riferimento_acquisizione' => trim((string) ($data['riferimento_acquisizione'] ?? '')) ?: null, + 'workflow_stato' => trim((string) ($data['workflow_stato'] ?? 'ricevuta')) ?: 'ricevuta', + 'deadline_lettura_at' => $data['deadline_lettura_at'] ?? null, + 'rilevatore_tipo' => trim((string) ($data['rilevatore_tipo'] ?? 'amministrazione')) ?: 'amministrazione', + 'rilevatore_nome' => trim((string) ($data['rilevatore_nome'] ?? '')) ?: null, + 'lettura_precedente_valore' => $letturaPrecedente, + 'lettura_inizio' => $letturaInizio, + 'lettura_fine' => $letturaFine, + 'consumo_valore' => $consumo, + 'consumo_unita' => trim((string) ($data['consumo_unita'] ?? 'mc')) ?: 'mc', + 'raw' => $raw, + ]; + + if ($record instanceof StabileServizioLettura) { + $record->fill($payload); + $record->save(); + } else { + $payload['created_by'] = (int) $user->id; + StabileServizioLettura::query()->create($payload); + } + + Notification::make() + ->title($record instanceof StabileServizioLettura ? 'Lettura UI aggiornata' : 'Lettura UI creata') + ->success() + ->send(); + + $this->resetRiscaldamentoUiReadingForm(); + } + + public function deleteRiscaldamentoUiReading(int $readingId): void + { + $stabileId = $this->resolveActiveStabileId(); + if (! $stabileId) { + return; + } + + $record = StabileServizioLettura::query() + ->where('id', $readingId) + ->where('stabile_id', $stabileId) + ->whereNotNull('unita_immobiliare_id') + ->whereHas('servizio', fn(Builder $q) => $q->where('tipo', 'riscaldamento')) + ->first(); + + if (! $record instanceof StabileServizioLettura) { + Notification::make()->title('Lettura UI non trovata')->warning()->send(); + return; + } + + $record->delete(); + if ((int) $this->riscaldamentoUiEditingId === (int) $readingId) { + $this->resetRiscaldamentoUiReadingForm(); + } + + Notification::make()->title('Lettura UI eliminata')->success()->send(); + } + + public function saveRiscaldamentoFilterDates(): void + { + $stabileId = $this->resolveActiveStabileId(); + $year = $this->resolveActiveAnnoGestione(); + $service = $this->resolveActiveRiscaldamentoServizio(); + + if (! $stabileId || ! $service instanceof StabileServizio) { + Notification::make()->title('Configura prima un servizio riscaldamento attivo.')->warning()->send(); + return; + } + + $meta = $service->meta ?? []; + $periods = $meta['periodi_esercizio'] ?? []; + + $dal = trim((string) $this->riscaldamentoDataInizioFiltro) ?: null; + $al = trim((string) $this->riscaldamentoDataFineFiltro) ?: null; + + if ($dal || $al) { + $periods[$year] = [ + 'dal' => $dal, + 'al' => $al + ]; + + // PROPAGAZIONE AUTOMATICA ANNI PRECEDENTI E SUCCESSIVI + $dalCarbon = \Illuminate\Support\Carbon::parse($dal); + $alCarbon = \Illuminate\Support\Carbon::parse($al); + + $otherYears = \App\Models\GestioneContabile::query() + ->where('stabile_id', $stabileId) + ->where('tipo_gestione', 'riscaldamento') + ->pluck('anno_gestione') + ->toArray(); + + if (empty($otherYears)) { + $otherYears = \App\Models\GestioneContabile::query() + ->where('stabile_id', $stabileId) + ->where('tipo_gestione', 'ordinaria') + ->pluck('anno_gestione') + ->toArray(); + } + + foreach ($otherYears as $oy) { + if ($oy != $year && (! isset($periods[$oy]) || empty($periods[$oy]['dal']) || empty($periods[$oy]['al']))) { + $diff = $oy - $year; + $periods[$oy] = [ + 'dal' => $dalCarbon->copy()->addYears($diff)->format('Y-m-d'), + 'al' => $alCarbon->copy()->addYears($diff)->format('Y-m-d'), + ]; + } + } + } else { + unset($periods[$year]); + } + + $meta['periodi_esercizio'] = $periods; + $service->meta = $meta; + $service->save(); + + // Spostamento automatico delle fatture (Aggiornamento gestione_id) + if ($dal && $al) { + $gestione = \App\Models\GestioneContabile::query() + ->where('stabile_id', $stabileId) + ->where('anno_gestione', $year) + ->where('tipo_gestione', 'ordinaria') + ->first(); + + if ($gestione) { + $fornitoreIds = StabileServizio::query() + ->where('stabile_id', $stabileId) + ->where('tipo', 'riscaldamento') + ->whereNotNull('fornitore_id') + ->pluck('fornitore_id') + ->map(fn($value): int => (int) $value) + ->filter(fn(int $value): bool => $value > 0) + ->values() + ->all(); + + if ($fornitoreIds === []) { + $preferredSupplier = $this->resolvePreferredRiscaldamentoSupplier($stabileId); + if ($preferredSupplier instanceof Fornitore) { + $fornitoreIds = [(int) $preferredSupplier->id]; + } + } + + $fornitoreIds = $this->resolveEquivalentFornitoreIds($fornitoreIds); + + // Aggiorna le fatture in contabilita_fatture_fornitori + if (Schema::hasTable('contabilita_fatture_fornitori')) { + $q = DB::table('contabilita_fatture_fornitori') + ->where('stabile_id', $stabileId) + ->whereBetween('data_documento', [$dal, $al]); + + if ($fornitoreIds !== []) { + $q->whereIn('fornitore_id', $fornitoreIds); + } + + $q->update(['gestione_id' => $gestione->id]); + + // Inoltre, aggiorna le FE associate + $feIds = DB::table('fatture_elettroniche') + ->where('stabile_id', $stabileId) + ->whereBetween('data_fattura', [$dal, $al]) + ->when($fornitoreIds !== [], fn($b) => $b->whereIn('fornitore_id', $fornitoreIds)) + ->pluck('id') + ->all(); + + if (!empty($feIds)) { + DB::table('contabilita_fatture_fornitori') + ->where('stabile_id', $stabileId) + ->whereIn('fattura_elettronica_id', $feIds) + ->update(['gestione_id' => $gestione->id]); + } + } + } + } + + Notification::make()->title('Filtro date salvato e fatture allineate con successo.')->success()->send(); + + // Refresh properties + $this->loadRiscaldamentoFilterDates(); + } + + public function sendRipartizioneEmail(): void + { + $email = trim($this->emailDestinatario); + if ($email === '') { + Notification::make()->title('Destinatario mancante')->danger()->send(); + return; + } + + $stabileId = $this->resolveActiveStabileId(); + $year = $this->resolveActiveAnnoGestione(); + + try { + $request = new \Illuminate\Http\Request([ + 'stabile_id' => $stabileId, + 'year' => $year, + ]); + $selectedIds = $this->normalizeRiscaldamentoSelectedFeIds(); + if ($selectedIds !== []) { + $request->merge(['ids' => implode(',', $selectedIds)]); + } + + // Imposta l'utente loggato sulla request + $request->setUserResolver(fn() => Auth::user()); + + $printController = new \App\Http\Controllers\Admin\RiscaldamentoRipartizionePrintController(); + $reportResponse = $printController->pdf($request); + $pdfContent = $reportResponse->getContent(); + + Mail::send([], [], function ($message) use ($email, $pdfContent, $year): void { + $message->to($email) + ->subject($this->emailOggetto) + ->html(nl2br(e($this->emailCorpo))) + ->attachData($pdfContent, 'riepilogo-riscaldamento-' . $year . '.pdf', [ + 'mime' => 'application/pdf', + ]); + }); + + $this->showSendEmailModal = false; + Notification::make()->title('Email inviata con successo.')->success()->send(); + } catch (\Throwable $e) { + Notification::make()->title('Errore durante l invio dell email')->danger()->body($e->getMessage())->send(); + } + } + + public function importElectronicReadings(): void + { + if (! $this->riscaldamentoReadingsFile) { + Notification::make()->title('Carica prima un file CSV.')->warning()->send(); + return; + } + + $stabileId = $this->resolveActiveStabileId(); + $servizio = $this->resolveActiveRiscaldamentoServizio(); + if (! $stabileId || ! $servizio) { + Notification::make()->title('Nessun servizio riscaldamento configurato per lo stabile attivo.')->danger()->send(); + return; + } + + $user = Auth::user(); + $filePath = $this->riscaldamentoReadingsFile->getRealPath(); + + $handle = fopen($filePath, 'r'); + if (! $handle) { + Notification::make()->title('Impossibile aprire il file caricato.')->danger()->send(); + return; + } + + $headers = fgetcsv($handle, 0, ','); + if (! $headers) { + fclose($handle); + Notification::make()->title('File CSV vuoto o non valido.')->danger()->send(); + return; + } + + // Trova gli indici dei campi + $idIdx = -1; + $internoIdx = -1; + $letturaIdx = -1; + + foreach ($headers as $idx => $header) { + $headerClean = strtolower(trim((string) $header)); + if ($headerClean === '#id' || $headerClean === 'id' || $idx === 0) { + if ($idIdx === -1) $idIdx = $idx; + } + if ($headerClean === 'interno' || $headerClean === 'int') { + $internoIdx = $idx; + } + if ($headerClean === 'lettura' || $headerClean === 'valore') { + $letturaIdx = $idx; + } + } + + // Fallbacks se non trova per nome + if ($idIdx === -1) $idIdx = 0; + if ($internoIdx === -1) $internoIdx = 6; // Default standard del tracciato + if ($letturaIdx === -1) $letturaIdx = 7; // Default standard del tracciato + + $imported = 0; + $linked = 0; + $errors = 0; + $year = $this->resolveActiveAnnoGestione(); + + while (($row = fgetcsv($handle, 0, ',')) !== false) { + if (count($row) <= max($idIdx, $internoIdx, $letturaIdx)) { + continue; + } + + $deviceId = trim((string) ($row[$idIdx] ?? '')); + $internoCsv = trim((string) ($row[$internoIdx] ?? '')); + $letturaStr = trim((string) ($row[$letturaIdx] ?? '')); + + if ($deviceId === '' && $internoCsv === '') { + continue; + } + + // Pulisci il valore della lettura + $letturaClean = preg_replace('/[^\d,\.]/', '', $letturaStr); + $letturaClean = str_replace(',', '.', $letturaClean); + if (! is_numeric($letturaClean)) { + $errors++; + continue; + } + $finalValue = round((float) $letturaClean, 3); + + // Cerca l'unità immobiliare + $unit = null; + if ($deviceId !== '') { + $unit = UnitaImmobiliare::query() + ->where('stabile_id', $stabileId) + ->where('riscaldamento_gateway_device_id', $deviceId) + ->whereNull('deleted_at') + ->first(); + } + + if (! $unit && $internoCsv !== '') { + $unit = UnitaImmobiliare::query() + ->where('stabile_id', $stabileId) + ->where(function($query) use ($internoCsv) { + $query->where('interno', $internoCsv) + ->orWhere('codice_unita', $internoCsv); + }) + ->whereNull('deleted_at') + ->first(); + + if ($unit && $deviceId !== '') { + $unit->riscaldamento_gateway_device_id = $deviceId; + $unit->save(); + $linked++; + } + } + + if (! $unit) { + $errors++; + continue; + } + + // Trova la lettura precedente + $previous = StabileServizioLettura::query() + ->where('stabile_id', $stabileId) + ->where('stabile_servizio_id', (int) $servizio->id) + ->where('unita_immobiliare_id', $unit->id) + ->whereNotNull('lettura_fine') + ->orderByDesc('periodo_al') + ->orderByDesc('id') + ->first(); + + $previousValue = $previous?->lettura_fine !== null ? (float) $previous->lettura_fine : null; + $consumo = ($previousValue !== null && $finalValue >= $previousValue) ? round($finalValue - $previousValue, 3) : null; + + // Cerca se esiste già una lettura per quest'anno + $current = StabileServizioLettura::query() + ->where('stabile_id', $stabileId) + ->where('stabile_servizio_id', (int) $servizio->id) + ->where('unita_immobiliare_id', $unit->id) + ->whereYear('created_at', $year) + ->first(); + + $payload = [ + 'stabile_id' => $stabileId, + 'stabile_servizio_id' => (int) $servizio->id, + 'unita_immobiliare_id' => $unit->id, + 'fornitore_id' => $servizio->fornitore_id, + 'periodo_dal' => $previous?->periodo_al, + 'periodo_al' => now()->toDateString(), + 'tipologia_lettura' => 'elettronica_remota', + 'canale_acquisizione' => 'dispositivo_remoto', + 'riferimento_acquisizione' => 'Gateway ID ' . $deviceId, + 'workflow_stato' => 'ricevuta', + 'rilevatore_tipo' => 'sistema_remoto', + 'rilevatore_nome' => 'Gateway Elettronico', + 'lettura_precedente_valore' => $previousValue, + 'lettura_inizio' => $previousValue, + 'lettura_fine' => $finalValue, + 'consumo_valore' => $consumo, + 'consumo_unita' => 'mc', + 'raw' => [ + 'csv_import' => [ + 'device_id' => $deviceId, + 'interno' => $internoCsv, + 'original' => $row, + 'imported_at' => now()->toIso8601String(), + 'imported_by' => $user?->id, + ] + ] + ]; + + if ($current) { + $current->fill($payload); + $current->save(); + } else { + $payload['created_by'] = $user?->id; + StabileServizioLettura::query()->create($payload); + } + + $imported++; + } + + fclose($handle); + $this->riscaldamentoReadingsFile = null; + + $msg = "Caricate con successo $imported letture."; + if ($linked > 0) { + $msg .= " Associate $linked nuove unità via interno."; + } + if ($errors > 0) { + $msg .= " Saltate/non abbinate $errors righe."; + } + + Notification::make()->title('Importazione completata')->body($msg)->success()->send(); + + $this->dispatch('refresh-riscaldamento-fe-selections'); + } + + private function normalizeRiscaldamentoTab(string $tab): string + { + $allowed = ['dashboard', 'fatture', 'letture', 'generale', 'tariffe', 'servizi']; + + return in_array($tab, $allowed, true) ? $tab : 'dashboard'; + } + + private function resetRiscaldamentoUiReadingForm(): void + { + $defaultServiceId = collect($this->riscaldamentoUiServiceOptions)->keys()->map(fn($value) => (int) $value)->first(); + + $this->riscaldamentoUiEditingId = null; + $this->riscaldamentoUiReadingForm = [ + 'stabile_servizio_id' => $defaultServiceId, + 'unita_immobiliare_id' => null, + 'periodo_dal' => null, + 'periodo_al' => null, + 'canale_acquisizione' => 'manuale', + 'riferimento_acquisizione' => null, + 'workflow_stato' => 'ricevuta', + 'rilevatore_tipo' => 'amministrazione', + 'rilevatore_nome' => null, + 'lettura_precedente_valore' => null, + 'lettura_inizio' => null, + 'lettura_fine' => null, + 'consumo_valore' => null, + 'consumo_unita' => 'mc', + 'deadline_lettura_at' => null, + 'note' => null, + ]; + } + + public function backfillRiscaldamentoFromFatturePregresse(): void + { + $user = Auth::user(); + if (! $user instanceof User) { + Notification::make()->title('Utente non autenticato.')->danger()->send(); + return; + } + + $stabileId = $this->resolveActiveStabileId(); + $servizio = $this->resolveActiveRiscaldamentoServizio(); + if (! $stabileId || ! $servizio instanceof StabileServizio) { + Notification::make()->title('Configura prima un servizio riscaldamento attivo per lo stabile selezionato.')->warning()->send(); + return; + } + + $year = $this->resolveActiveAnnoGestione(); + $tipoGestione = in_array($this->riscaldamentoBackfillGestione, ['ordinaria', 'riscaldamento', 'straordinaria', 'tutte'], true) + ? $this->riscaldamentoBackfillGestione + : 'ordinaria'; + + $fatture = $this->resolveRiscaldamentoCandidateInvoices($tipoGestione) + ->when($this->normalizeRiscaldamentoSelectedFeIds() !== [], fn($collection) => $collection->whereIn('id', $this->normalizeRiscaldamentoSelectedFeIds())); + + if ($fatture->isEmpty()) { + Notification::make()->title('Nessuna FE riscaldamento trovata per lo stabile/gestione selezionati.')->warning()->send(); + return; + } + + if ($this->riscaldamentoBackfillOnlyMissing) { + $fatture = $fatture->filter(function (FatturaElettronica $fattura): bool { + $storedRaw = is_string($fattura->consumo_raw ?? null) ? trim((string) $fattura->consumo_raw) : ''; + if ($storedRaw === '') { + return true; + } + + $decoded = json_decode($storedRaw, true); + + return ! $this->payloadLooksLikeRiscaldamento(is_array($decoded) ? $decoded : null); + })->values(); + } + + if ($fatture->isEmpty()) { + Notification::make()->title('Nessuna FE riscaldamento da rielaborare con i filtri correnti.')->warning()->send(); + return; + } + + $processed = 0; + $ok = 0; + $skipped = 0; + $tickets = 0; + $tariffe = 0; + + foreach ($fatture as $fattura) { + $processed++; + + $parsed = $this->resolveRiscaldamentoParsedPayload($fattura, (int) $user->id); + if (! is_array($parsed)) { + $skipped++; + continue; + } + + $consumi = array_values(is_array($parsed['consumi'] ?? null) ? $parsed['consumi'] : []); + $fattura->consumo_unita = 'mc'; + $fattura->consumo_valore = $this->sumRiscaldamentoConsumi($consumi); + $fattura->consumo_riferimento = $this->buildRiscaldamentoConsumoReference($consumi); + $fattura->consumo_raw = json_encode([ + 'type' => 'riscaldamento', + 'source' => 'pdf_ocr', + 'codici' => is_array($parsed['codici'] ?? null) ? $parsed['codici'] : [], + 'contatore' => is_array($parsed['contatore'] ?? null) ? $parsed['contatore'] : [], + 'consumi' => $consumi, + 'generale' => is_array($parsed['generale'] ?? null) ? $parsed['generale'] : [], + 'pagamento' => is_array($parsed['pagamento'] ?? null) ? $parsed['pagamento'] : [], + 'finestra_autolettura' => is_array($parsed['finestra_autolettura'] ?? null) ? $parsed['finestra_autolettura'] : [], + 'riepilogo_letture' => array_values(is_array($parsed['riepilogo_letture'] ?? null) ? $parsed['riepilogo_letture'] : []), + 'quadro_dettaglio' => is_array($parsed['quadro_dettaglio'] ?? null) ? $parsed['quadro_dettaglio'] : [], + 'tariffe' => is_array($parsed['tariffe'] ?? null) ? $parsed['tariffe'] : [], + 'iva' => is_array($parsed['iva'] ?? null) ? $parsed['iva'] : [], + 'parsed_at' => now()->toISOString(), + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + $fattura->save(); + + try { + app(ConsumiRiscaldamentoTariffeIngestionService::class)->ingest($fattura, $parsed, (int) $servizio->id); + $tariffe++; + } catch (\Throwable) { + // best-effort + } + + $ingestion = app(ConsumiRiscaldamentoIngestionService::class)->ingest( + $fattura, + $parsed, + (int) ($servizio->voce_spesa_id ?? 0) ?: null, + (int) $user->id, + true, + (int) $servizio->id, + ); + + if (($ingestion['status'] ?? null) === 'ok') { + $ok++; + } else { + $skipped++; + } + + $tickets += (int) ($ingestion['tickets'] ?? 0); + } + + Notification::make() + ->title('Backfill FE riscaldamento completato') + ->body("Processate: {$processed} · Agganciate: {$ok} · Saltate: {$skipped} · Ticket: {$tickets} · Tariffe aggiornate: {$tariffe}") + ->success() + ->send(); + } + + public function prepareRiscaldamentoReadingCampaign(): void + { + $stabileId = $this->resolveActiveStabileId(); + $servizio = $this->resolveActiveRiscaldamentoServizio(); + + if (! $stabileId || ! $servizio) { + Notification::make()->title('Servizio riscaldamento attivo non disponibile per lo stabile selezionato.')->warning()->send(); + return; + } + + $units = UnitaImmobiliare::query() + ->where('stabile_id', $stabileId) + ->whereNull('deleted_at') + ->orderBy('scala') + ->orderBy('interno') + ->orderBy('id') + ->get(['id', 'codice_unita', 'scala', 'interno']); + + if ($units->isEmpty()) { + Notification::make()->title('Nessuna unità immobiliare disponibile per avviare la campagna letture.')->warning()->send(); + return; + } + + $year = $this->resolveActiveAnnoGestione(); + $created = 0; + $updated = 0; + + foreach ($units as $unit) { + $completed = StabileServizioLettura::query() + ->where('stabile_id', $stabileId) + ->where('stabile_servizio_id', (int) $servizio->id) + ->where('unita_immobiliare_id', (int) $unit->id) + ->whereYear('created_at', $year) + ->whereNotNull('lettura_fine') + ->exists(); + + if ($completed) { + continue; + } + + $previous = StabileServizioLettura::query() + ->where('stabile_id', $stabileId) + ->where('stabile_servizio_id', (int) $servizio->id) + ->where('unita_immobiliare_id', (int) $unit->id) + ->whereNotNull('lettura_fine') + ->orderByDesc('periodo_al') + ->orderByDesc('id') + ->first(); + + $requestRow = StabileServizioLettura::query() + ->where('stabile_id', $stabileId) + ->where('stabile_servizio_id', (int) $servizio->id) + ->where('unita_immobiliare_id', (int) $unit->id) + ->whereYear('created_at', $year) + ->whereNull('lettura_fine') + ->orderByDesc('id') + ->first(); + + $requestReference = 'PUBREQ:' . (int) $servizio->id . ':' . (int) $unit->id . ':' . $year; + $requestPayload = [ + 'stabile_id' => $stabileId, + 'stabile_servizio_id' => (int) $servizio->id, + 'unita_immobiliare_id' => (int) $unit->id, + 'fornitore_id' => $servizio->fornitore_id, + 'periodo_dal' => $previous?->periodo_al, + 'periodo_al' => null, + 'tipologia_lettura' => 'richiesta_autolettura', + 'canale_acquisizione' => 'portale_pubblico', + 'riferimento_acquisizione' => $requestReference, + 'workflow_stato' => 'richiesta_inviata', + 'richiesta_lettura_inviata_at' => now(), + 'deadline_lettura_at' => now()->addDays(7), + 'lettura_precedente_valore' => $previous?->lettura_fine, + 'lettura_inizio' => $previous?->lettura_fine, + 'raw' => [ + 'source' => 'campagna_letture_riscaldamento', + 'year' => $year, + 'unit_label' => trim((string) ($unit->codice_unita ?? '') . ' ' . (string) ($unit->interno ?? '')), + ], + ]; + + if ($requestRow) { + $requestRow->fill($requestPayload); + $requestRow->save(); + $updated++; + continue; + } + + StabileServizioLettura::query()->create($requestPayload); + $created++; + } + + Notification::make() + ->title('Campagna letture riscaldamento preparata') + ->body("Richieste create: {$created} · richieste aggiornate: {$updated}") + ->success() + ->send(); + } + + public function markRiscaldamentoReadingReminder(int $readingId): void + { + $reading = StabileServizioLettura::query()->find($readingId); + if (! $reading) { + return; + } + + $email = DB::table('unita_recapiti_servizio') + ->where('unita_id', (int) ($reading->unita_immobiliare_id ?? 0)) + ->where('attivo', true) + ->whereNotNull('email') + ->where('email', '!=', '') + ->orderByRaw("CASE WHEN tipo_servizio = 'riscaldamento' THEN 0 ELSE 1 END") + ->orderByDesc('is_default') + ->orderBy('ordine') + ->value('email'); + + if (is_string($email) && trim($email) !== '') { + $email = trim($email); + $publicUrl = $this->buildPublicRiscaldamentoReadingUrl((int) $reading->stabile_servizio_id, (int) $reading->unita_immobiliare_id, (int) $reading->id); + $body = implode("\n", array_filter([ + 'Oggetto: sollecito lettura riscaldamento', + '', + 'Gentile condomino,', + 'ti ricordiamo di comunicare la lettura del contatore riscaldamento tramite il link dedicato qui sotto.', + $publicUrl, + '', + 'Se hai già inviato la lettura puoi ignorare questo messaggio.', + 'In caso di problemi contatta l amministrazione.', + ])); + + try { + Mail::raw($body, function ($message) use ($email): void { + $message->to($email)->subject('Sollecito lettura riscaldamento'); + }); + } catch (\Throwable) { + Notification::make()->title('Invio email non riuscito')->warning()->body('Il recapito esiste ma l invio del sollecito non è riuscito.')->send(); + return; + } + } else { + Notification::make()->title('Nessuna email disponibile per questa unità')->warning()->body('Compila un recapito in unita_recapiti_servizio per inviare il sollecito diretto.')->send(); + return; + } + + $reading->fill([ + 'sollecito_inviato_at' => now(), + 'workflow_stato' => 'sollecito_inviato', + ]); + $reading->save(); + + Notification::make()->title('Sollecito inviato e registrato')->success()->send(); + } + + public function setRiscaldamentoCampagnaSort(string $sort): void + { + $this->riscaldamentoCampagnaSort = in_array($sort, ['scala_interno', 'nominativo'], true) ? $sort : 'scala_interno'; + } + + public function saveRiscaldamentoCampagnaRow(int $unitId): void + { + $this->persistRiscaldamentoCampagnaRows([$unitId]); + } + + public function saveAllRiscaldamentoCampagnaRows(): void + { + $this->persistRiscaldamentoCampagnaRows(array_map('intval', array_keys($this->riscaldamentoCampagnaEditRows))); + } + + public function ensureRiscaldamentoAceaContract(): void + { + $stabileId = $this->resolveActiveStabileId(); + if (! $stabileId) { + Notification::make()->title('Stabile attivo non disponibile.')->warning()->send(); + return; + } + + $fornitoreAcea = $this->resolvePreferredRiscaldamentoSupplier($stabileId); + + if (! $fornitoreAcea) { + Notification::make()->title('Fornitore Riscaldamento/Gas non trovato in anagrafica fornitori.')->warning()->send(); + return; + } + + $existing = StabileServizio::query() + ->where('stabile_id', $stabileId) + ->where('tipo', 'riscaldamento') + ->orderByDesc('attivo') + ->orderBy('id') + ->first(); + + if ($existing) { + $existing->fornitore_id = (int) $fornitoreAcea->id; + if (trim((string) ($existing->nome ?? '')) === '') { + $existing->nome = 'Utenza riscaldamento - Fornitore Gas/Caldaia'; + } + if (! $existing->attivo) { + $existing->attivo = true; + } + $existing->save(); + + Notification::make()->title('Contratto RISCALDAMENTO aggiornato.')->success()->send(); + return; + } + + StabileServizio::query()->create([ + 'stabile_id' => $stabileId, + 'tipo' => 'riscaldamento', + 'nome' => 'Utenza riscaldamento - Fornitore Gas/Caldaia', + 'fornitore_id' => (int) $fornitoreAcea->id, + 'attivo' => true, + 'meta' => [ + 'canale_acquisizione' => 'manuale', + ], + ]); + + Notification::make()->title('Contratto RISCALDAMENTO creato.')->success()->send(); + } + + protected function getTableQuery(): Builder + { + $user = Auth::user(); + if (! $user instanceof User) { + return StabileServizio::query()->whereRaw('1 = 0'); + } + + $stabileId = StabileContext::resolveActiveStabileId($user); + if (! $stabileId) { + return StabileServizio::query()->whereRaw('1 = 0'); + } + + return StabileServizio::query() + ->where('stabile_id', (int) $stabileId) + ->with([ + 'fornitore:id,ragione_sociale', + 'voceSpesa:id,descrizione,tipo_gestione', + ]) + ->withCount('letture') + ->orderBy('tipo') + ->orderBy('nome') + ->orderByDesc('id'); + } + + public function table(Table $table): Table + { + $tipoOptions = $this->getServiceTypeOptions(); + $commonScopeOptions = $this->getCommonAssetScopeOptions(); + $allocationOptions = $this->getAllocationScopeOptions(); + $fiscalScopeOptions = $this->getFiscalScopeOptions(); + $counterScopeOptions = $this->getCounterScopeOptions(); + + $gestioneOptions = [ + 'ordinaria' => 'Ordinaria', + 'riscaldamento' => 'Riscaldamento', + 'straordinaria' => 'Straordinaria', + ]; + + return $table + ->striped() + ->filters([ + SelectFilter::make('tipo') + ->label('Tipo servizio') + ->options($tipoOptions), + + SelectFilter::make('common_asset_scope') + ->label('Ambito bene comune') + ->options($commonScopeOptions) + ->query(function (Builder $query, array $data): Builder { + $value = trim((string) ($data['value'] ?? '')); + if ($value === '') { + return $query; + } + + return $query->where('meta->common_asset_scope', $value); + }), + + SelectFilter::make('counter_scope') + ->label('Tipo contatore') + ->options($counterScopeOptions) + ->query(function (Builder $query, array $data): Builder { + $value = trim((string) ($data['value'] ?? '')); + if ($value === '') { + return $query; + } + + return $query->where('meta->counter_scope', $value); + }), + + SelectFilter::make('tipo_gestione') + ->label('Gestione (O/R/S)') + ->options($gestioneOptions) + ->query(function (Builder $query, array $data): Builder { + $value = (string) ($data['value'] ?? ''); + if ($value === '') { + return $query; + } + + return $query->whereHas('voceSpesa', fn(Builder $q) => $q->where('tipo_gestione', $value)); + }), + ]) + ->columns([ + TextColumn::make('tipo') + ->label('Tipo') + ->formatStateUsing(fn($state) => $this->formatMetaOptionLabel($tipoOptions, $state)) + ->sortable(), + + TextColumn::make('nome')->label('Nome')->searchable()->wrap()->sortable(), + + TextColumn::make('meta.common_asset_label') + ->label('Bene / locale') + ->formatStateUsing(fn($state): string => trim((string) $state) !== '' ? trim((string) $state) : '—') + ->searchable() + ->wrap() + ->toggleable(), + + TextColumn::make('meta.common_asset_scope') + ->label('Ambito') + ->formatStateUsing(fn($state) => $this->formatMetaOptionLabel($commonScopeOptions, $state)) + ->toggleable(), + + TextColumn::make('meta.allocation_scope') + ->label('Addebito') + ->formatStateUsing(fn($state) => $this->formatMetaOptionLabel($allocationOptions, $state)) + ->wrap() + ->toggleable(), + + TextColumn::make('meta.fiscal_scope') + ->label('Trattamento') + ->formatStateUsing(fn($state) => $this->formatMetaOptionLabel($fiscalScopeOptions, $state)) + ->wrap() + ->toggleable(isToggledHiddenByDefault: true), + + TextColumn::make('fornitore.ragione_sociale')->label('Fornitore')->searchable()->wrap()->toggleable(), + + TextColumn::make('voceSpesa.descrizione')->label('Voce spesa')->searchable()->wrap()->toggleable(), + + TextColumn::make('voceSpesa.tipo_gestione') + ->label('Gestione') + ->formatStateUsing(fn($state) => $gestioneOptions[strtolower(trim((string) $state))] ?? (string) ($state ?? '—')) + ->toggleable(isToggledHiddenByDefault: true), + + TextColumn::make('letture_count')->label('Letture')->sortable(), + + TextColumn::make('meta.canale_acquisizione') + ->label('Canale') + ->formatStateUsing(function ($state): string { + $v = strtolower(trim((string) $state)); + return match ($v) { + 'manuale' => 'Manuale', + 'email' => 'Email', + 'whatsapp' => 'WhatsApp', + 'import_file' => 'Import file', + 'm_bus', 'mbus' => 'Contatore elettronico', + default => $v !== '' ? strtoupper($v) : '—', + }; + }) + ->toggleable(isToggledHiddenByDefault: true), + + TextColumn::make('meta.email_raccolta_letture')->label('Email raccolta')->toggleable(isToggledHiddenByDefault: true), + TextColumn::make('meta.whatsapp_raccolta_letture')->label('WhatsApp raccolta')->toggleable(isToggledHiddenByDefault: true), + TextColumn::make('meta.costo_lettura_unitario')->label('Costo lettura')->money('EUR')->toggleable(isToggledHiddenByDefault: true), + TextColumn::make('meta.costo_ripartizione')->label('Costo ripartizione')->money('EUR')->toggleable(isToggledHiddenByDefault: true), + + TextColumn::make('codice_utenza')->label('Utenza')->searchable()->toggleable(isToggledHiddenByDefault: true), + TextColumn::make('codice_cliente')->label('Cliente')->searchable()->toggleable(isToggledHiddenByDefault: true), + TextColumn::make('codice_contratto')->label('Contratto')->searchable()->toggleable(isToggledHiddenByDefault: true), + TextColumn::make('contatore_matricola')->label('Matricola')->searchable()->toggleable(), + + TextColumn::make('meta.counter_scope') + ->label('Contatore') + ->formatStateUsing(fn($state) => $this->formatMetaOptionLabel($counterScopeOptions, $state)) + ->toggleable(), + + IconColumn::make('meta.has_dedicated_meter') + ->label('Ded.') + ->boolean() + ->toggleable(isToggledHiddenByDefault: true), + + TextColumn::make('meta.served_area_label') + ->label('Area servita') + ->wrap() + ->toggleable(isToggledHiddenByDefault: true), + + IconColumn::make('attivo')->label('Attivo')->boolean()->sortable(), + TextColumn::make('updated_at')->label('Aggiornato')->dateTime('d/m/Y H:i')->toggleable(isToggledHiddenByDefault: true), + ]) + ->headerActions([ + Action::make('create') + ->label('Nuovo') + ->icon('heroicon-o-plus') + ->form([ + Select::make('tipo') + ->label('Tipo servizio') + ->options($tipoOptions) + ->required(), + TextInput::make('nome')->label('Nome')->maxLength(255), + TextInput::make('common_asset_label')->label('Bene comune / locale / servizio')->maxLength(255), + Select::make('common_asset_scope') + ->label('Ambito bene comune') + ->options($commonScopeOptions), + Select::make('allocation_scope') + ->label('Ripartizione / addebito') + ->options($allocationOptions), + Select::make('fiscal_scope') + ->label('Trattamento economico / fiscale') + ->options($fiscalScopeOptions), + Select::make('fornitore_id') + ->label('Fornitore') + ->searchable() + ->options(fn() => $this->getFornitoriOptions()), + Select::make('voce_spesa_id') + ->label('Voce spesa') + ->searchable() + ->options(fn() => $this->getVociSpesaOptions()), + TextInput::make('codice_utenza')->label('Codice utenza')->maxLength(255), + TextInput::make('codice_cliente')->label('Codice cliente')->maxLength(255), + TextInput::make('codice_contratto')->label('Codice contratto')->maxLength(255), + TextInput::make('contatore_matricola')->label('Matricola contatore')->maxLength(255), + Toggle::make('has_dedicated_meter')->label('Contatore dedicato')->default(false), + Select::make('counter_scope') + ->label('Tipo contatore') + ->options($counterScopeOptions) + ->default('assente'), + TextInput::make('served_area_label')->label('Area servita / utilizzo')->maxLength(255), + Select::make('canale_acquisizione') + ->label('Canale acquisizione letture') + ->options([ + 'manuale' => 'Manuale', + 'email' => 'Email', + 'whatsapp' => 'WhatsApp', + 'import_file' => 'Import file', + 'm_bus' => 'Contatore elettronico', + ]), + TextInput::make('email_raccolta_letture')->label('Email raccolta letture')->email()->maxLength(255), + TextInput::make('whatsapp_raccolta_letture')->label('Numero WhatsApp raccolta')->maxLength(50), + TextInput::make('costo_lettura_unitario')->label('Costo lettura (unitario)')->numeric(), + TextInput::make('costo_ripartizione')->label('Costo ripartizione')->numeric(), + Toggle::make('ripartizione_esterna')->label('Ripartizione affidata a ditta esterna')->default(false), + Textarea::make('operative_notes')->label('Note operative')->rows(3)->maxLength(2000), + Toggle::make('attivo')->label('Attivo')->default(true), + ]) + ->action(function (array $data): void { + $user = Auth::user(); + if (! $user instanceof User) { + return; + } + + $stabileId = StabileContext::resolveActiveStabileId($user); + if (! $stabileId) { + return; + } + + StabileServizio::query()->create([ + 'stabile_id' => (int) $stabileId, + 'tipo' => strtolower(trim((string) ($data['tipo'] ?? ''))), + 'nome' => $this->resolveServiceDisplayName($data), + 'fornitore_id' => (int) ($data['fornitore_id'] ?? 0) ?: null, + 'voce_spesa_id' => (int) ($data['voce_spesa_id'] ?? 0) ?: null, + 'codice_utenza' => trim((string) ($data['codice_utenza'] ?? '')) ?: null, + 'codice_cliente' => trim((string) ($data['codice_cliente'] ?? '')) ?: null, + 'codice_contratto' => trim((string) ($data['codice_contratto'] ?? '')) ?: null, + 'contatore_matricola' => trim((string) ($data['contatore_matricola'] ?? '')) ?: null, + 'attivo' => (bool) ($data['attivo'] ?? true), + 'meta' => $this->buildServiceMetaPayload([], $data), + ]); + }), + Action::make('propaga_impostazioni_riscaldamento') + ->label('Propaga impostazioni riscaldamento') + ->icon('heroicon-o-arrow-path') + ->color('info') + ->requiresConfirmation() + ->modalHeading('Propaga template riscaldamento agli altri stabili') + ->modalDescription('Copia il template operativo riscaldamento dello stabile attivo sugli altri stabili accessibili senza sovrascrivere utenza, cliente, contratto e matricola già presenti.') + ->action(function (): void { + $stats = $this->propagateRiscaldamentoSettingsToOtherStabili(); + + Notification::make() + ->title('Propagazione riscaldamento completata') + ->body('Stabili toccati: ' . $stats['touched'] . ' · creati: ' . $stats['created'] . ' · aggiornati: ' . $stats['updated'] . ' · saltati: ' . $stats['skipped']) + ->success() + ->send(); + }), + ]) + ->actions([ + Action::make('letture') + ->label('Letture') + ->icon('heroicon-o-clipboard-document-list') + ->url(fn(StabileServizio $record): string => LettureServiziArchivio::getUrl(['servizio' => (int) $record->id], panel: 'admin-filament')), + + Action::make('fatture') + ->label('Fatture') + ->icon('heroicon-o-document-text') + ->url(fn() => FattureElettronicheP7mRicevute::getUrl(panel: 'admin-filament')), + + Action::make('edit') + ->label('Modifica') + ->icon('heroicon-o-pencil-square') + ->form([ + Select::make('tipo') + ->label('Tipo servizio') + ->options($tipoOptions) + ->required(), + TextInput::make('nome')->label('Nome')->maxLength(255), + TextInput::make('common_asset_label')->label('Bene comune / locale / servizio')->maxLength(255), + Select::make('common_asset_scope') + ->label('Ambito bene comune') + ->options($commonScopeOptions), + Select::make('allocation_scope') + ->label('Ripartizione / addebito') + ->options($allocationOptions), + Select::make('fiscal_scope') + ->label('Trattamento economico / fiscale') + ->options($fiscalScopeOptions), + Select::make('fornitore_id') + ->label('Fornitore') + ->searchable() + ->options(fn() => $this->getFornitoriOptions()), + Select::make('voce_spesa_id') + ->label('Voce spesa') + ->searchable() + ->options(fn() => $this->getVociSpesaOptions()), + TextInput::make('codice_utenza')->label('Codice utenza')->maxLength(255), + TextInput::make('codice_cliente')->label('Codice cliente')->maxLength(255), + TextInput::make('codice_contratto')->label('Codice contratto')->maxLength(255), + TextInput::make('contatore_matricola')->label('Matricola contatore')->maxLength(255), + Toggle::make('has_dedicated_meter')->label('Contatore dedicato')->default(false), + Select::make('counter_scope') + ->label('Tipo contatore') + ->options($counterScopeOptions), + TextInput::make('served_area_label')->label('Area servita / utilizzo')->maxLength(255), + Select::make('canale_acquisizione') + ->label('Canale acquisizione letture') + ->options([ + 'manuale' => 'Manuale', + 'email' => 'Email', + 'whatsapp' => 'WhatsApp', + 'import_file' => 'Import file', + 'm_bus' => 'Contatore elettronico', + ]), + TextInput::make('email_raccolta_letture')->label('Email raccolta letture')->email()->maxLength(255), + TextInput::make('whatsapp_raccolta_letture')->label('Numero WhatsApp raccolta')->maxLength(50), + TextInput::make('costo_lettura_unitario')->label('Costo lettura (unitario)')->numeric(), + TextInput::make('costo_ripartizione')->label('Costo ripartizione')->numeric(), + Toggle::make('ripartizione_esterna')->label('Ripartizione affidata a ditta esterna')->default(false), + Textarea::make('operative_notes')->label('Note operative')->rows(3)->maxLength(2000), + Toggle::make('attivo')->label('Attivo')->default(true), + ]) + ->fillForm(fn(StabileServizio $record): array=> [ + 'tipo' => (string) ($record->tipo ?? ''), + 'nome' => (string) ($record->nome ?? ''), + 'common_asset_label' => (string) (($record->meta['common_asset_label'] ?? '') ?: ''), + 'common_asset_scope' => (string) (($record->meta['common_asset_scope'] ?? '') ?: ''), + 'allocation_scope' => (string) (($record->meta['allocation_scope'] ?? '') ?: ''), + 'fiscal_scope' => (string) (($record->meta['fiscal_scope'] ?? '') ?: ''), + 'fornitore_id' => $record->fornitore_id, + 'voce_spesa_id' => $record->voce_spesa_id, + 'codice_utenza' => (string) ($record->codice_utenza ?? ''), + 'codice_cliente' => (string) ($record->codice_cliente ?? ''), + 'codice_contratto' => (string) ($record->codice_contratto ?? ''), + 'contatore_matricola' => (string) ($record->contatore_matricola ?? ''), + 'has_dedicated_meter' => (bool) ($record->meta['has_dedicated_meter'] ?? false), + 'counter_scope' => (string) (($record->meta['counter_scope'] ?? '') ?: ''), + 'served_area_label' => (string) (($record->meta['served_area_label'] ?? '') ?: ''), + 'canale_acquisizione' => (string) (($record->meta['canale_acquisizione'] ?? '') ?: ''), + 'email_raccolta_letture' => (string) (($record->meta['email_raccolta_letture'] ?? '') ?: ''), + 'whatsapp_raccolta_letture' => (string) (($record->meta['whatsapp_raccolta_letture'] ?? '') ?: ''), + 'costo_lettura_unitario' => $record->meta['costo_lettura_unitario'] ?? null, + 'costo_ripartizione' => $record->meta['costo_ripartizione'] ?? null, + 'ripartizione_esterna' => (bool) ($record->meta['ripartizione_esterna'] ?? false), + 'operative_notes' => (string) (($record->meta['operative_notes'] ?? '') ?: ''), + 'attivo' => (bool) ($record->attivo ?? true), + ]) + ->action(function (StabileServizio $record, array $data): void { + $meta = $this->buildServiceMetaPayload(is_array($record->meta ?? null) ? $record->meta : [], $data); + + $record->fill([ + 'tipo' => strtolower(trim((string) ($data['tipo'] ?? ''))), + 'nome' => $this->resolveServiceDisplayName($data, (string) ($record->nome ?? '')), + 'fornitore_id' => (int) ($data['fornitore_id'] ?? 0) ?: null, + 'voce_spesa_id' => (int) ($data['voce_spesa_id'] ?? 0) ?: null, + 'codice_utenza' => trim((string) ($data['codice_utenza'] ?? '')) ?: null, + 'codice_cliente' => trim((string) ($data['codice_cliente'] ?? '')) ?: null, + 'codice_contratto' => trim((string) ($data['codice_contratto'] ?? '')) ?: null, + 'contatore_matricola' => trim((string) ($data['contatore_matricola'] ?? '')) ?: null, + 'attivo' => (bool) ($data['attivo'] ?? true), + 'meta' => $meta, + ]); + $record->save(); + }), + + Action::make('delete') + ->label('Elimina') + ->icon('heroicon-o-trash') + ->color('danger') + ->requiresConfirmation() + ->action(fn(StabileServizio $record) => $record->delete()), + ]); + } + + /** + * @return array + */ + private function getServiceTypeOptions(): array + { + return [ + 'riscaldamento' => 'Riscaldamento', + 'energia' => 'Energia', + 'gas' => 'Gas', + 'riscaldamento' => 'Riscaldamento', + 'ascensore' => 'Ascensore', + 'pulizie' => 'Pulizie', + 'assicurazione' => 'Assicurazione', + 'privacy' => 'Privacy', + 'antenna' => 'Antenna', + 'antincendio' => 'Antincendio', + 'citofonia' => 'Citofonia', + 'locale_comune' => 'Locale comune', + 'spazio_comune' => 'Spazio / area comune', + 'impianto' => 'Impianto comune', + 'altro' => 'Altro', + ]; + } + + /** + * @return array + */ + private function getCommonAssetScopeOptions(): array + { + return [ + 'locale' => 'Locale', + 'spazio' => 'Spazio comune', + 'impianto' => 'Impianto', + 'area_esterna' => 'Area esterna / giardino', + 'copertura' => 'Terrazzo / copertura', + 'accesso' => 'Portone / accesso', + 'servizio' => 'Servizio comune', + 'sicurezza' => 'Sicurezza / antincendio', + 'amministrativo' => 'Privacy / assicurazione / studio', + 'altro' => 'Altro', + ]; + } + + /** + * @return array + */ + private function getAllocationScopeOptions(): array + { + return [ + 'tutti_millesimi' => 'Tutti per millesimi', + 'condomini_utilizzatori' => 'Condomini utilizzatori', + 'inquilini_utilizzatori' => 'Inquilini utilizzatori', + 'condomini_inquilini_utilizzatori' => 'Condomini + inquilini utilizzatori', + 'rimborso_spese' => 'Rimborso spese uso bene comune', + 'gestione_studio' => 'Gestione studio / amministrazione', + 'altro' => 'Altro criterio', + ]; + } + + /** + * @return array + */ + private function getFiscalScopeOptions(): array + { + return [ + 'spesa_condominiale' => 'Spesa condominiale', + 'rimborso_non_fiscale' => 'Rimborso spese non fiscale', + 'da_fatturare' => 'Da fatturare', + 'polizza_o_servizio' => 'Polizza / servizio', + 'non_applicabile' => 'Non applicabile', + ]; + } + + /** + * @return array + */ + private function getCounterScopeOptions(): array + { + return [ + 'generale' => 'Generale', + 'particolare' => 'Particolare / dedicato', + 'assente' => 'Senza contatore', + ]; + } + + private function formatMetaOptionLabel(array $options, mixed $state): string + { + $value = strtolower(trim((string) $state)); + + return $options[$value] ?? ($value !== '' ? (string) $state : '—'); + } + + /** + * @param array $currentMeta + * @param array $data + * @return array + */ + private function buildServiceMetaPayload(array $currentMeta, array $data): array + { + $meta = $currentMeta; + $meta['common_asset_label'] = trim((string) ($data['common_asset_label'] ?? '')) ?: null; + $meta['common_asset_scope'] = trim((string) ($data['common_asset_scope'] ?? '')) ?: null; + $meta['allocation_scope'] = trim((string) ($data['allocation_scope'] ?? '')) ?: null; + $meta['fiscal_scope'] = trim((string) ($data['fiscal_scope'] ?? '')) ?: null; + $meta['has_dedicated_meter'] = (bool) ($data['has_dedicated_meter'] ?? false); + $meta['counter_scope'] = trim((string) ($data['counter_scope'] ?? '')) ?: (($meta['has_dedicated_meter'] ?? false) ? 'particolare' : 'assente'); + $meta['served_area_label'] = trim((string) ($data['served_area_label'] ?? '')) ?: null; + $meta['canale_acquisizione'] = trim((string) ($data['canale_acquisizione'] ?? '')) ?: null; + $meta['email_raccolta_letture'] = trim((string) ($data['email_raccolta_letture'] ?? '')) ?: null; + $meta['whatsapp_raccolta_letture'] = trim((string) ($data['whatsapp_raccolta_letture'] ?? '')) ?: null; + $meta['costo_lettura_unitario'] = is_numeric($data['costo_lettura_unitario'] ?? null) ? (float) $data['costo_lettura_unitario'] : null; + $meta['costo_ripartizione'] = is_numeric($data['costo_ripartizione'] ?? null) ? (float) $data['costo_ripartizione'] : null; + $meta['ripartizione_esterna'] = (bool) ($data['ripartizione_esterna'] ?? false); + $meta['operative_notes'] = trim((string) ($data['operative_notes'] ?? '')) ?: null; + + return $meta; + } + + /** + * @param array $data + */ + private function resolveServiceDisplayName(array $data, string $fallback = ''): string + { + $name = trim((string) ($data['nome'] ?? '')); + if ($name !== '') { + return $name; + } + + $commonLabel = trim((string) ($data['common_asset_label'] ?? '')); + if ($commonLabel !== '') { + return $commonLabel; + } + + return trim($fallback); + } + + /** + * @return array + */ + private function getFornitoriOptions(): array + { + $user = Auth::user(); + if (! $user instanceof User) { + return []; + } + + $query = Fornitore::query(); + + if (! $user->hasAnyRole(['super-admin', 'admin'])) { + $activeStabile = StabileContext::getActiveStabile($user); + $adminId = (int) ($activeStabile?->amministratore_id ?: 0); + if ($adminId > 0) { + $query->where('amministratore_id', $adminId); + } + } + + return $query + ->orderBy('ragione_sociale') + ->get(['id', 'ragione_sociale']) + ->mapWithKeys(fn(Fornitore $f) => [(int) $f->id => (string) ($f->ragione_sociale ?? ('Fornitore #' . $f->id))]) + ->all(); + } + + /** + * @return array + */ + private function getVociSpesaOptions(): array + { + $user = Auth::user(); + if (! $user instanceof User) { + return []; + } + + $stabileId = StabileContext::resolveActiveStabileId($user); + if (! $stabileId) { + return []; + } + + return VoceSpesa::query() + ->where('stabile_id', (int) $stabileId) + ->orderBy('tipo_gestione') + ->orderBy('descrizione') + ->get(['id', 'descrizione', 'tipo_gestione']) + ->mapWithKeys(function (VoceSpesa $v): array { + $tipo = strtolower(trim((string) ($v->tipo_gestione ?? ''))); + $prefix = $tipo !== '' ? strtoupper(substr($tipo, 0, 1)) . ' - ' : ''; + return [(int) $v->id => $prefix . (string) ($v->descrizione ?? ('Voce #' . $v->id))]; + }) + ->all(); + } + + private function resolveActiveStabileId(): ?int + { + $user = Auth::user(); + if (! $user instanceof User) { + return null; + } + + $stabileId = StabileContext::resolveActiveStabileId($user); + return $stabileId ? (int) $stabileId : null; + } + + private function resolveActiveAnnoGestione(): int + { + $user = Auth::user(); + + return AnnoGestioneContext::resolveActiveAnno($user instanceof User ? $user : null); + } + + private function resolveActiveRiscaldamentoServizio(): ?StabileServizio + { + $stabileId = $this->resolveActiveStabileId(); + if (! $stabileId) { + return null; + } + + return StabileServizio::query() + ->where('stabile_id', $stabileId) + ->where('tipo', 'riscaldamento') + ->orderByDesc('attivo') + ->orderBy('id') + ->first(); + } + + /** @return array */ + private function normalizeRiscaldamentoSelectedFeIds(): array + { + return array_values(array_unique(array_filter(array_map('intval', $this->riscaldamentoSelectedFeIds), static fn(int $value): bool => $value > 0))); + } + + private function resolveRiscaldamentoCandidateInvoices(?string $tipoGestione = null) + { + $stabileId = $this->resolveActiveStabileId(); + if (! $stabileId) { + return collect(); + } + + $year = $this->resolveActiveAnnoGestione(); + $fornitoreIds = StabileServizio::query() + ->where('stabile_id', $stabileId) + ->where('tipo', 'riscaldamento') + ->whereNotNull('fornitore_id') + ->pluck('fornitore_id') + ->map(fn($value): int => (int) $value) + ->filter(fn(int $value): bool => $value > 0) + ->values() + ->all(); + + if ($fornitoreIds === []) { + $preferredSupplier = $this->resolvePreferredRiscaldamentoSupplier($stabileId); + if ($preferredSupplier instanceof Fornitore) { + $fornitoreIds = [(int) $preferredSupplier->id]; + } + } + + $fornitoreIds = $this->resolveEquivalentFornitoreIds($fornitoreIds); + + $dal = $this->riscaldamentoDataInizioFiltro; + $al = $this->riscaldamentoDataFineFiltro; + + $query = FatturaElettronica::query() + ->where('stabile_id', $stabileId) + ->orderByDesc('data_fattura') + ->orderByDesc('id'); + + if ($dal && $al) { + $query->whereBetween('data_fattura', [$dal, $al]); + } else { + $query->whereYear('data_fattura', $year); + } + + if ($fornitoreIds !== []) { + $query->whereIn('fornitore_id', $fornitoreIds); + } + + if ($tipoGestione !== null && $tipoGestione !== '' && $tipoGestione !== 'tutte' && Schema::hasTable('contabilita_fatture_fornitori')) { + $invoiceIds = DB::table('contabilita_fatture_fornitori as f') + ->leftJoin('gestioni_contabili as g', 'g.id', '=', 'f.gestione_id') + ->where('f.stabile_id', $stabileId) + ->whereNotNull('f.fattura_elettronica_id') + ->when($fornitoreIds !== [], fn($builder) => $builder->whereIn('f.fornitore_id', $fornitoreIds)) + ->where('g.tipo_gestione', $tipoGestione) + ->when($dal && $al, fn($builder) => $builder->whereBetween('f.data_documento', [$dal, $al])) + ->when(!($dal && $al), function($builder) use ($year) { + $builder->when(Schema::hasColumn('gestioni_contabili', 'anno_gestione'), fn($b) => $b->where('g.anno_gestione', $year)) + ->when(! Schema::hasColumn('gestioni_contabili', 'anno_gestione') && Schema::hasColumn('contabilita_fatture_fornitori', 'data_documento'), fn($b) => $b->whereYear('f.data_documento', $year)); + }) + ->pluck('f.fattura_elettronica_id') + ->map(fn($value): int => (int) $value) + ->filter(fn(int $value): bool => $value > 0) + ->unique() + ->values() + ->all(); + + if ($invoiceIds === []) { + return collect(); + } + + $query->whereIn('id', $invoiceIds); + } + + $userId = (int) (Auth::id() ?? 0); + + return $query->get()->filter(function (FatturaElettronica $fattura) use ($fornitoreIds, $userId): bool { + if ($fornitoreIds !== [] && in_array((int) ($fattura->fornitore_id ?? 0), $fornitoreIds, true)) { + return true; + } + + return $this->payloadLooksLikeRiscaldamento($this->resolveRiscaldamentoParsedPayload($fattura, $userId)); + })->values(); + } + + private function payloadLooksLikeRiscaldamento(?array $payload): bool + { + if (! is_array($payload) || $payload === []) { + return false; + } + + if (($payload['type'] ?? null) === 'riscaldamento') { + return true; + } + + if (! empty($payload['consumi']) || ! empty($payload['riepilogo_letture']) || ! empty($payload['finestra_autolettura'])) { + return true; + } + + $quadro = is_array($payload['quadro_dettaglio'] ?? null) ? $payload['quadro_dettaglio'] : []; + foreach (['quota_fissa', 'acquedotto', 'fognatura', 'depurazione', 'oneri_perequazione', 'restituzione_acconti'] as $key) { + if (isset($quadro[$key]) && is_numeric($quadro[$key])) { + return true; + } + } + + return ! empty($payload['tariffe']); + } + + private function resolveRiscaldamentoParsedPayload(FatturaElettronica $fattura, int $userId): ?array + { + $storedRaw = is_string($fattura->consumo_raw ?? null) ? trim((string) $fattura->consumo_raw) : ''; + if ($storedRaw !== '') { + $decoded = json_decode($storedRaw, true); + if (is_array($decoded) && $this->payloadLooksLikeRiscaldamento($decoded)) { + return $decoded; + } + + $parsedStoredText = app(GasLucePdfTextParser::class)->parse($storedRaw); + if ($this->payloadLooksLikeRiscaldamento($parsedStoredText)) { + return $parsedStoredText; + } + } + + try { + if ($userId > 0) { + app(FatturaElettronicaProtocolloService::class)->ensureDocumentoProtocollato($fattura, $userId); + $fattura->refresh(); + } + } catch (\Throwable) { + // ignore + } + + $documento = Documento::query() + ->where('documentable_type', FatturaElettronica::class) + ->where('documentable_id', (int) $fattura->id) + ->first(); + + if (! $documento instanceof Documento) { + return null; + } + + $text = is_string($documento->contenuto_ocr ?? null) ? trim((string) $documento->contenuto_ocr) : ''; + if ($text === '') { + try { + $result = app(PdfTextExtractionService::class)->extractAndStore($documento, false); + if (($result['status'] ?? null) === 'ok') { + $documento->refresh(); + $text = is_string($documento->contenuto_ocr ?? null) ? trim((string) $documento->contenuto_ocr) : ''; + } + } catch (\Throwable) { + $text = ''; + } + } + + if ($text === '') { + return null; + } + + $parsed = app(GasLucePdfTextParser::class)->parse($text); + + return $this->payloadLooksLikeRiscaldamento($parsed) ? $parsed : null; + } + + /** @param array> $consumi */ + private function sumRiscaldamentoConsumi(array $consumi): ?float + { + $sum = 0.0; + $hasValues = false; + + foreach ($consumi as $consumo) { + if (! is_array($consumo) || ! is_numeric($consumo['valore'] ?? null)) { + continue; + } + + $sum += (float) $consumo['valore']; + $hasValues = true; + } + + return $hasValues ? round($sum, 3) : null; + } + + /** @param array> $consumi */ + private function buildRiscaldamentoConsumoReference(array $consumi): ?string + { + $first = $consumi[0] ?? null; + if (! is_array($first)) { + return null; + } + + $dal = is_string($first['dal'] ?? null) ? trim((string) $first['dal']) : ''; + $al = is_string($first['al'] ?? null) ? trim((string) $first['al']) : ''; + + if ($dal === '' || $al === '') { + return null; + } + + return $dal . ' - ' . $al; + } + + private function buildPublicRiscaldamentoReadingUrl(int $servizioId, int $unitaId, ?int $requestId = null): string + { + return URL::temporarySignedRoute( + 'public.water-reading.show', + now()->addDays(30), + array_filter([ + 'servizio' => $servizioId, + 'unita' => $unitaId, + 'request' => $requestId, + ], fn($value) => $value !== 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 getRiscaldamentoDashboardStatsProperty(): array + { + $stabileId = $this->resolveActiveStabileId(); + if (! $stabileId) { + return [ + 'totale_fatture' => 0.0, + 'totale_operazioni_ac12_legacy' => 0.0, + 'delta_operazioni_vs_fatture' => 0.0, + 'totale_letture' => 0, + 'totale_letture_con_riferimento' => 0, + 'totale_consumi_mc' => 0.0, + 'totale_tariffe' => 0, + ]; + } + + $fattureRows = $this->riscaldamentoFatturePerGestione; + $totaleFatture = (float) collect($fattureRows)->sum(fn(array $r): float => (float) ($r['totale_fatture'] ?? 0)); + + $totaleLetture = (int) StabileServizioLettura::query() + ->where('stabile_id', $stabileId) + ->whereHas('servizio', fn(Builder $q) => $q->where('tipo', 'riscaldamento')) + ->count(); + + $totaleLettureConRiferimento = (int) StabileServizioLettura::query() + ->where('stabile_id', $stabileId) + ->whereHas('servizio', fn(Builder $q) => $q->where('tipo', 'riscaldamento')) + ->whereNotNull('riferimento_acquisizione') + ->where('riferimento_acquisizione', '!=', '') + ->count(); + + $totaleConsumi = (float) StabileServizioLettura::query() + ->where('stabile_id', $stabileId) + ->whereHas('servizio', fn(Builder $q) => $q->where('tipo', 'riscaldamento')) + ->sum('consumo_valore'); + + $totaleTariffe = (int) StabileServizioTariffa::query() + ->where('stabile_id', $stabileId) + ->count(); + + $legacySummary = $this->riscaldamentoLegacyOperazioniSummary; + $totaleLegacyAc12 = (float) ($legacySummary['totale'] ?? 0); + + return [ + 'totale_fatture' => round($totaleFatture, 2), + 'totale_operazioni_ac12_legacy' => round($totaleLegacyAc12, 2), + 'delta_operazioni_vs_fatture' => round($totaleFatture - $totaleLegacyAc12, 2), + 'totale_letture' => $totaleLetture, + 'totale_letture_con_riferimento' => $totaleLettureConRiferimento, + 'totale_consumi_mc' => round($totaleConsumi, 3), + 'totale_tariffe' => $totaleTariffe, + ]; + } + + private function normalizeInvoiceNumber(mixed $number): ?string + { + if ($number === null) { + return null; + } + + $raw = strtoupper(trim((string) $number)); + if ($raw === '') { + return null; + } + + $normalized = preg_replace('/[^A-Z0-9]/', '', $raw); + return $normalized !== '' ? $normalized : null; + } + + private function parseLegacyDate(mixed $value): ?string + { + if ($value === null || $value === '') { + return null; + } + + try { + return (string) \Carbon\Carbon::parse((string) $value)->format('Y-m-d'); + } catch (\Throwable) { + return null; + } + } + + /** @return array{totale: float, voci: array, righe: int, scope_note?: string} */ + public function getRiscaldamentoLegacyOperazioniSummaryProperty(): array + { + if (! Schema::connection('gescon_import')->hasTable('operazioni')) { + return ['totale' => 0.0, 'voci' => [], 'righe' => 0, 'scope_note' => 'Archivio legacy non disponibile.']; + } + + if (! Schema::connection('gescon_import')->hasColumn('operazioni', 'cod_stabile')) { + $rows = $this->riscaldamentoLegacyOperazioniRows; + $voci = []; + $totale = 0.0; + + foreach ($rows as $row) { + $cod = strtoupper(trim((string) ($row['cod_spe'] ?? ''))); + if ($cod === '') { + continue; + } + + $importo = (float) ($row['importo'] ?? 0); + $voci[$cod] = round(($voci[$cod] ?? 0.0) + $importo, 2); + $totale += $importo; + } + + return [ + 'totale' => round($totale, 2), + 'voci' => $voci, + 'righe' => count($rows), + 'scope_note' => 'Riepilogo filtrato solo sulle righe legacy collegate alle fatture locali dello stabile corrente.', + ]; + } + + $legacyCodes = $this->resolveLegacyCodiciStabile(); + $year = $this->resolveActiveAnnoGestione(); + + $query = DB::connection('gescon_import') + ->table('operazioni') + ->whereIn('gestione', ['O', 'R']) + ->where('cod_spe', 'like', 'R%') + ->where('cod_spe', '!=', 'RAO'); + + 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); + } + + $columns = Schema::connection('gescon_import')->getColumnListing('operazioni'); + $sumFields = []; + if (in_array('importo_euro', $columns, true)) { + $sumFields[] = 'importo_euro'; + } + if (in_array('importo', $columns, true)) { + $sumFields[] = 'importo'; + } + $sumSql = '0'; + if ($sumFields !== []) { + $sumSql = 'COALESCE(' . implode(', ', $sumFields) . ', 0)'; + } + + $rows = $query + ->select('cod_spe') + ->selectRaw('COUNT(*) as righe') + ->selectRaw("SUM($sumSql) as totale") + ->groupBy('cod_spe') + ->get(); + + $voci = []; + $totale = 0.0; + $righe = 0; + foreach ($rows as $row) { + $cod = strtoupper(trim((string) ($row->cod_spe ?? ''))); + if ($cod === '') { + continue; + } + + $imp = (float) ($row->totale ?? 0); + $voci[$cod] = round($imp, 2); + $totale += $imp; + $righe += (int) ($row->righe ?? 0); + } + + return [ + 'totale' => round($totale, 2), + 'voci' => $voci, + 'righe' => $righe, + 'scope_note' => 'Riepilogo filtrato per stabile e anno sull archivio legacy.', + ]; + } + + /** @return array> */ + public function getRiscaldamentoLegacyOperazioniRowsProperty(): array + { + if (! Schema::connection('gescon_import')->hasTable('operazioni')) { + return []; + } + + $stabileId = $this->resolveActiveStabileId(); + if (! $stabileId) { + return []; + } + + $legacyCodes = $this->resolveLegacyCodiciStabile(); + $year = $this->resolveActiveAnnoGestione(); + $hasCodStabile = Schema::connection('gescon_import')->hasColumn('operazioni', 'cod_stabile'); + + $query = DB::connection('gescon_import') + ->table('operazioni') + ->whereIn('gestione', ['O', 'R']) + ->where('cod_spe', 'like', 'R%') + ->where('cod_spe', '!=', 'RAO'); + + if ($legacyCodes !== [] && $hasCodStabile) { + $query->whereIn('cod_stabile', $legacyCodes); + } + + if (! $hasCodStabile && Schema::connection('gescon_import')->hasColumn('operazioni', 'cod_for')) { + $legacySupplierCodes = $this->resolveLegacyOperazioniSupplierCodesForCurrentStabile(); + if ($legacySupplierCodes === []) { + return []; + } + + $query->whereIn('cod_for', $legacySupplierCodes); + } + + if (Schema::connection('gescon_import')->hasColumn('operazioni', 'dt_spe')) { + $query->whereYear('dt_spe', $year); + } + + $legacyRows = $query + ->orderByDesc('dt_spe') + ->orderByDesc('id_operaz') + ->limit(120) + ->get([ + 'id_operaz', + 'n_spe', + 'dt_spe', + 'cod_spe', + 'cod_for', + 'num_fat', + 'dt_fat', + 'benef', + 'importo_euro', + 'importo', + 'fe_uid', + ]); + + if ($legacyRows->isEmpty()) { + return []; + } + + $codForList = $legacyRows + ->pluck('cod_for') + ->filter() + ->map(fn($v) => trim((string) $v)) + ->filter() + ->unique() + ->values() + ->all(); + + $codForNoZero = array_values(array_filter(array_unique(array_map(fn($v) => ltrim((string) $v, '0'), $codForList)))); + $codForAll = array_values(array_unique(array_merge($codForList, $codForNoZero))); + + $legacyFornitoriMap = []; + $codFornToLegacyId = []; + if (! empty($codForAll)) { + $useStg = Schema::hasTable('stg_fornitori_gescon'); + if ($useStg) { + $fornRows = DB::table('stg_fornitori_gescon') + ->whereIn('cod_forn', $codForAll) + ->get(['cod_forn', 'legacy_id_fornitore', 'denominazione', 'cognome', 'nome']); + } else { + $stagingConn = DB::connection('gescon_import'); + $tableName = $stagingConn->getSchemaBuilder()->hasTable('mdb_fornitori') ? 'mdb_fornitori' : 'fornitori'; + $hasIdForn = $stagingConn->getSchemaBuilder()->hasColumn($tableName, 'id_fornitore'); + $hasDenom = $stagingConn->getSchemaBuilder()->hasColumn($tableName, 'denominazione'); + $hasRagione = $stagingConn->getSchemaBuilder()->hasColumn($tableName, 'ragione_sociale'); + + $selectFields = ['cod_forn']; + if ($hasIdForn) { + $selectFields[] = 'id_fornitore'; + } else { + $selectFields[] = 'id as id_fornitore'; + } + if ($hasDenom) { + $selectFields[] = 'denominazione'; + } elseif ($hasRagione) { + $selectFields[] = 'ragione_sociale as denominazione'; + } + if ($stagingConn->getSchemaBuilder()->hasColumn($tableName, 'cognome')) { + $selectFields[] = 'cognome'; + } + if ($stagingConn->getSchemaBuilder()->hasColumn($tableName, 'nome')) { + $selectFields[] = 'nome'; + } + + $fornRows = $stagingConn->table($tableName) + ->whereIn('cod_forn', $codForAll) + ->get($selectFields); + } + + foreach ($fornRows as $f) { + $code = trim((string) ($f->cod_forn ?? '')); + if ($code === '') { + continue; + } + + $label = trim((string) ($f->denominazione ?? '')); + if ($label === '') { + $label = trim((string) (($f->nome ?? '') . ' ' . ($f->cognome ?? ''))); + } + + $legacyId = $useStg ? ($f->cod_forn ?? null) : ($f->id_fornitore ?? null); + $legacyFornitoriMap[$code] = $label !== '' ? $label : null; + $codFornToLegacyId[$code] = $legacyId; + + $noZero = ltrim($code, '0'); + if ($noZero !== '') { + $legacyFornitoriMap[$noZero] = $legacyFornitoriMap[$code]; + $codFornToLegacyId[$noZero] = $legacyId; + } + } + } + + $legacyIds = array_values(array_filter(array_unique(array_values($codFornToLegacyId)))); + $fornitoriIdMap = []; + if (! empty($legacyIds) && Schema::hasTable('fornitori')) { + $fornitoriRows = DB::table('fornitori') + ->whereIn('old_id', $legacyIds) + ->get(['id', 'old_id']); + + foreach ($fornitoriRows as $fr) { + $fornitoriIdMap[(string) ($fr->old_id ?? '')] = (int) ($fr->id ?? 0); + } + } + + $fattureByFornitore = []; + $fattureByStrict = []; + $fattureByGlobal = []; + if (! empty($fornitoriIdMap) && Schema::hasTable('contabilita_fatture_fornitori')) { + $fornitoreIds = array_values(array_unique(array_filter(array_values($fornitoriIdMap)))); + if (! empty($fornitoreIds)) { + $fatture = DB::table('contabilita_fatture_fornitori') + ->where('stabile_id', $stabileId) + ->whereIn('fornitore_id', $fornitoreIds) + ->get([ + 'id', + 'fornitore_id', + 'fattura_elettronica_id', + 'numero_documento', + 'data_documento', + 'totale', + 'netto_da_pagare', + ]); + + foreach ($fatture as $f) { + $fid = (int) ($f->fornitore_id ?? 0); + if ($fid <= 0) { + continue; + } + + $fattureByFornitore[$fid][] = $f; + $docNumber = $this->normalizeInvoiceNumber($f->numero_documento ?? null); + $docDate = $this->parseLegacyDate($f->data_documento ?? null); + if ($docNumber && $docDate) { + $strictKey = $fid . '|' . $docNumber . '|' . $docDate; + $fattureByStrict[$strictKey][] = $f; + $globalKey = $docNumber . '|' . $docDate; + $fattureByGlobal[$globalKey][] = $f; + } + } + } + } + + $rows = $legacyRows->map(function ($row) use ($legacyFornitoriMap, $codFornToLegacyId, $fornitoriIdMap, $fattureByFornitore, $fattureByStrict, $fattureByGlobal): array { + $codFor = trim((string) ($row->cod_for ?? '')); + $codForNoZero = ltrim($codFor, '0'); + $legacyId = $codFornToLegacyId[$codFor] ?? ($codForNoZero !== '' ? ($codFornToLegacyId[$codForNoZero] ?? null) : null); + $fornitoreId = ($legacyId !== null && isset($fornitoriIdMap[(string) $legacyId])) ? (int) $fornitoriIdMap[(string) $legacyId] : null; + $fornitoreLegacy = $legacyFornitoriMap[$codFor] ?? ($codForNoZero !== '' ? ($legacyFornitoriMap[$codForNoZero] ?? null) : null); + + $numFatNorm = $this->normalizeInvoiceNumber($row->num_fat ?? null); + $dtFatIso = $this->parseLegacyDate($row->dt_fat ?? null); + $importo = (float) ($row->importo_euro ?? $row->importo ?? 0); + + $matched = null; + $matchType = 'none'; + + if ($fornitoreId && $numFatNorm && $dtFatIso) { + $key = $fornitoreId . '|' . $numFatNorm . '|' . $dtFatIso; + if (! empty($fattureByStrict[$key])) { + $matched = $fattureByStrict[$key][0]; + $matchType = 'strict'; + } + } + + if (! $matched && $numFatNorm && $dtFatIso) { + $gKey = $numFatNorm . '|' . $dtFatIso; + $globalMatches = $fattureByGlobal[$gKey] ?? []; + if (count($globalMatches) === 1) { + $matched = $globalMatches[0]; + $matchType = 'global'; + } + } + + if (! $matched && $fornitoreId && ! empty($fattureByFornitore[$fornitoreId])) { + foreach ($fattureByFornitore[$fornitoreId] as $f) { + $tot = (float) ($f->totale ?? 0); + $net = (float) ($f->netto_da_pagare ?? 0); + if (abs(abs($importo) - abs($tot)) <= 0.01 || abs(abs($importo) - abs($net)) <= 0.01) { + $matched = $f; + $matchType = 'amount'; + break; + } + } + } + + return [ + 'id_operaz' => (int) ($row->id_operaz ?? 0), + 'n_spe' => isset($row->n_spe) ? (int) $row->n_spe : null, + 'data_operazione' => $this->parseLegacyDate($row->dt_spe ?? null), + 'cod_spe' => strtoupper(trim((string) ($row->cod_spe ?? ''))), + 'cod_for' => $codFor, + 'fornitore_legacy' => $fornitoreLegacy, + 'num_fat' => (string) ($row->num_fat ?? ''), + 'dt_fat' => $dtFatIso, + 'benef' => (string) ($row->benef ?? ''), + 'importo' => round($importo, 2), + 'fe_uid' => (string) ($row->fe_uid ?? ''), + 'match_type' => $matchType, + 'matched_fattura_id' => $matched ? (int) ($matched->id ?? 0) : null, + 'matched_fe_id' => $matched ? ((int) ($matched->fattura_elettronica_id ?? 0) ?: null): null, + 'matched_numero' => $matched ? (string) ($matched->numero_documento ?? '') : null, + 'matched_data' => $matched ? $this->parseLegacyDate($matched->data_documento ?? null) : null, + 'matched_totale' => $matched ? (float) ($matched->totale ?? $matched->netto_da_pagare ?? 0) : null, + ]; + }); + + if (! $hasCodStabile) { + $rows = $rows->filter(fn(array $row): bool => (int) ($row['matched_fattura_id'] ?? 0) > 0); + } + + return $rows->values()->all(); + } + + /** @return array */ + private function resolveLegacyOperazioniSupplierCodesForCurrentStabile(): array + { + if (! Schema::hasTable('fornitori')) { + return []; + } + + $stabileId = $this->resolveActiveStabileId(); + if (! $stabileId) { + return []; + } + + $fornitoreIds = collect($this->resolveRiscaldamentoCandidateInvoices()) + ->pluck('fornitore_id') + ->map(fn($value): int => (int) $value) + ->filter(fn(int $value): bool => $value > 0) + ->unique() + ->values() + ->all(); + + if ($fornitoreIds === []) { + $fornitoreIds = DB::table('contabilita_fatture_fornitori') + ->where('stabile_id', $stabileId) + ->whereNotNull('fornitore_id') + ->pluck('fornitore_id') + ->map(fn($value): int => (int) $value) + ->filter(fn(int $value): bool => $value > 0) + ->unique() + ->values() + ->all(); + } + + if ($fornitoreIds === []) { + return []; + } + + $legacyIds = DB::table('fornitori') + ->whereIn('id', $fornitoreIds) + ->whereNotNull('old_id') + ->pluck('old_id') + ->map(fn($value): string => trim((string) $value)) + ->filter(fn(string $value): bool => $value !== '') + ->unique() + ->values() + ->all(); + + if ($legacyIds === []) { + return []; + } + + $useStg = Schema::hasTable('stg_fornitori_gescon'); + if ($useStg) { + $rows = DB::table('stg_fornitori_gescon')->whereIn('legacy_id_fornitore', $legacyIds)->get(['cod_forn']); + } else { + $stagingConn = DB::connection('gescon_import'); + $tableName = $stagingConn->getSchemaBuilder()->hasTable('mdb_fornitori') ? 'mdb_fornitori' : 'fornitori'; + $idCol = $stagingConn->getSchemaBuilder()->hasColumn($tableName, 'id_fornitore') ? 'id_fornitore' : 'id'; + $rows = $stagingConn->table($tableName)->whereIn($idCol, $legacyIds)->get(['cod_forn']); + } + + return $rows + ->pluck('cod_forn') + ->map(fn($value): string => trim((string) $value)) + ->filter(fn(string $value): bool => $value !== '') + ->unique() + ->values() + ->all(); + } + + /** @return array{servizio_id: int|null, servizio_nome: string|null, fornitore_id: int|null, fornitore_nome: string|null, codice_utenza: string|null, codice_cliente: string|null, codice_contratto: string|null, matricola: string|null, suggerito_fornitore: string|null} */ + public function getRiscaldamentoContrattoStabileProperty(): array + { + $stabileId = $this->resolveActiveStabileId(); + if (! $stabileId) { + return [ + 'servizio_id' => null, + 'servizio_nome' => null, + 'fornitore_id' => null, + 'fornitore_nome' => null, + 'codice_utenza' => null, + 'codice_cliente' => null, + 'codice_contratto' => null, + 'matricola' => null, + 'suggerito_fornitore' => null, + ]; + } + + $servizio = StabileServizio::query() + ->where('stabile_id', $stabileId) + ->where('tipo', 'riscaldamento') + ->with('fornitore:id,ragione_sociale') + ->orderByDesc('attivo') + ->orderBy('id') + ->first(); + + $fornitoreSuggerito = $this->resolvePreferredRiscaldamentoSupplier($stabileId); + + return [ + 'servizio_id' => $servizio?->id, + 'servizio_nome' => $servizio?->nome, + 'fornitore_id' => $servizio?->fornitore_id, + 'fornitore_nome' => $servizio?->fornitore?->ragione_sociale, + 'codice_utenza' => $servizio?->codice_utenza, + 'codice_cliente' => $servizio?->codice_cliente, + 'codice_contratto' => $servizio?->codice_contratto, + 'matricola' => $servizio?->contatore_matricola, + 'suggerito_fornitore' => $fornitoreSuggerito?->ragione_sociale, + ]; + } + + /** @return array */ + public function getRiscaldamentoRipartoNominativiLegacyProperty(): array + { + if (! Schema::connection('gescon_import')->hasTable('dett_tab') || ! Schema::connection('gescon_import')->hasTable('condomin')) { + return []; + } + + $legacyCodes = $this->resolveLegacyCodiciStabile(); + if ($legacyCodes === []) { + return []; + } + + $legacyYear = (string) $this->resolveActiveAnnoGestione(); + + $condQuery = DB::connection('gescon_import') + ->table('condomin') + ->whereIn('cod_stabile', $legacyCodes); + + if ($legacyYear !== '' && Schema::connection('gescon_import')->hasColumn('condomin', 'legacy_year')) { + $condQuery->where('legacy_year', $legacyYear); + } + + $condominCols = Schema::connection('gescon_import')->getColumnListing('condomin'); + $desired = [ + 'id_cond', + 'scala', + 'interno', + 'cognome', + 'nome', + 'proprietario_denominazione', + 'inquilino_denominazione', + 'inquil_nome', + ]; + $actual = array_intersect($desired, $condominCols); + $condRows = $condQuery->get($actual) + ->keyBy('id_cond'); + + $rowsQuery = DB::connection('gescon_import') + ->table('dett_tab') + ->where('cod_tab', 'RISCALDAMENTO') + ->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']); + + $stabileId = $this->resolveActiveStabileId(); + + $letturaByUi = []; + if ($stabileId) { + $lettureRows = StabileServizioLettura::query() + ->where('stabile_id', $stabileId) + ->whereHas('servizio', fn(Builder $q) => $q->where('tipo', 'riscaldamento')) + ->whereNotNull('unita_immobiliare_id') + ->orderByDesc('created_at') + ->get(['unita_immobiliare_id', 'lettura_inizio', 'consumo_valore']); + + foreach ($lettureRows as $lr) { + $uiId = (int) ($lr->unita_immobiliare_id ?? 0); + if ($uiId <= 0 || isset($letturaByUi[$uiId])) { + continue; + } + + $letturaByUi[$uiId] = [ + 'lettura_iniziale' => is_numeric($lr->lettura_inizio) ? (float) $lr->lettura_inizio : null, + 'consumo_mc' => is_numeric($lr->consumo_valore) ? (float) $lr->consumo_valore : null, + ]; + } + } + + $uiByLegacyCond = []; + $uiByScalaInt = []; + if ($stabileId && Schema::hasTable('unita_immobiliari')) { + $uiRows = DB::table('unita_immobiliari') + ->where('stabile_id', $stabileId) + ->get(['id', 'legacy_cond_id', 'scala', 'interno']); + + foreach ($uiRows as $ui) { + $uiId = (int) ($ui->id ?? 0); + if ($uiId <= 0) { + continue; + } + + if (is_numeric($ui->legacy_cond_id ?? null)) { + $uiByLegacyCond[(int) $ui->legacy_cond_id] = $uiId; + } + + $scala = strtoupper(trim((string) ($ui->scala ?? ''))); + $interno = strtoupper(trim((string) ($ui->interno ?? ''))); + if ($scala !== '' || $interno !== '') { + $uiByScalaInt[$scala . '|' . $interno] = $uiId; + } + } + } + + return $rows->map(function ($r) use ($condRows, $uiByLegacyCond, $uiByScalaInt, $letturaByUi): ?array { + $cond = $condRows[$r->id_cond] ?? null; + $ruolo = strtoupper(trim((string) ($r->cond_inquil ?? ''))); + $nominativo = ''; + if ($cond) { + if ($ruolo === 'I') { + $nominativo = trim((string) ($cond->inquilino_denominazione ?? '')); + if ($nominativo === '') { + $nominativo = trim((string) ($cond->inquil_nome ?? '')); + } + } else { + $nominativo = trim((string) ($cond->proprietario_denominazione ?? '')); + if ($nominativo === '') { + $nominativo = trim((string) (($cond->cognome ?? '') . ' ' . ($cond->nome ?? ''))); + } + } + } + + if ($nominativo === '') { + return null; + } + + $uiId = null; + if (is_numeric($r->id_cond ?? null)) { + $uiId = $uiByLegacyCond[(int) $r->id_cond] ?? null; + } + + if (! $uiId) { + $scala = strtoupper(trim((string) ($cond->scala ?? ''))); + $interno = strtoupper(trim((string) ($cond->interno ?? ''))); + $uiId = $uiByScalaInt[$scala . '|' . $interno] ?? null; + } + + $lettura = ($uiId && isset($letturaByUi[$uiId])) + ? $letturaByUi[$uiId] + : ['lettura_iniziale' => null, 'consumo_mc' => null]; + + return [ + 'id_cond' => is_numeric($r->id_cond ?? null) ? (int) $r->id_cond : null, + 'ruolo' => $ruolo !== '' ? $ruolo : 'C', + 'nominativo' => $nominativo, + 'scala' => trim((string) ($cond->scala ?? '')), + 'interno' => trim((string) ($cond->interno ?? '')), + 'quota_euro' => round((float) ($r->cons_euro ?? 0), 2), + 'lettura_iniziale' => isset($lettura['lettura_iniziale']) && is_numeric($lettura['lettura_iniziale']) ? (float) $lettura['lettura_iniziale'] : null, + 'consumo_mc' => isset($lettura['consumo_mc']) && is_numeric($lettura['consumo_mc']) ? (float) $lettura['consumo_mc'] : null, + ]; + })->filter()->values()->all(); + } + + /** @return array */ + public function getRiscaldamentoFatturePerGestioneProperty(): array + { + $stabileId = $this->resolveActiveStabileId(); + if (! $stabileId) { + return []; + } + + $fatture = $this->resolveRiscaldamentoCandidateInvoices(); + if ($fatture->isEmpty()) { + return []; + } + + $fattureById = $fatture->keyBy(fn(FatturaElettronica $fattura): int => (int) $fattura->id); + $fatturaIds = $fattureById->keys()->map(fn($value): int => (int) $value)->all(); + $rowsByFatturaId = collect($this->riscaldamentoFeEstratteRows)->keyBy(fn(array $row): int => (int) ($row['fattura_id'] ?? 0)); + + $enrichGroup = function (array $row, array $ids) use ($rowsByFatturaId): array { + $details = collect($ids) + ->map(fn(int $fatturaId): ?array=> $rowsByFatturaId->get($fatturaId)) + ->filter(fn(?array $detail): bool => is_array($detail)); + + $row['totale_mc'] = round((float) $details->sum(fn(array $detail): float => (float) ($detail['totale_mc'] ?? 0)), 3); + $row['contratti'] = $details->pluck('codice_contratto')->filter()->unique()->values()->all(); + $row['utenze'] = $details->pluck('codice_utenza')->filter()->unique()->values()->all(); + $row['matricole'] = $details->pluck('matricola')->filter()->unique()->values()->all(); + $row['cbill_codes'] = $details->pluck('cbill')->filter()->unique()->values()->all(); + + return $row; + }; + + if (Schema::hasTable('contabilita_fatture_fornitori')) { + $grouped = []; + $seen = []; + $groupIds = []; + + $contabili = DB::table('contabilita_fatture_fornitori as f') + ->leftJoin('gestioni_contabili as g', 'g.id', '=', 'f.gestione_id') + ->where('f.stabile_id', $stabileId) + ->whereIn('f.fattura_elettronica_id', $fatturaIds) + ->get(['f.fattura_elettronica_id', 'g.tipo_gestione']); + + foreach ($contabili as $row) { + $fatturaId = (int) ($row->fattura_elettronica_id ?? 0); + if ($fatturaId <= 0 || ! isset($fattureById[$fatturaId])) { + continue; + } + + $gestione = strtolower(trim((string) ($row->tipo_gestione ?? ''))); + if ($gestione === '') { + $gestione = 'selezione fe riscaldamento'; + } + + $bucket = $gestione . '|' . $fatturaId; + if (isset($seen[$bucket])) { + continue; + } + + if (! isset($grouped[$gestione])) { + $grouped[$gestione] = [ + 'gestione' => $gestione, + 'fatture_count' => 0, + 'totale_fatture' => 0.0, + 'fe_count' => 0, + 'manual_count' => 0, + ]; + } + + $grouped[$gestione]['fatture_count']++; + $grouped[$gestione]['fe_count']++; + $grouped[$gestione]['totale_fatture'] += (float) ($fattureById[$fatturaId]->totale ?? 0); + $seen[$bucket] = true; + $groupIds[$gestione][] = $fatturaId; + } + + $matchedIds = array_values(array_unique(array_map(static fn(string $bucket): int => (int) explode('|', $bucket)[1], array_keys($seen)))); + foreach ($fatture as $fattura) { + if (in_array((int) $fattura->id, $matchedIds, true)) { + continue; + } + + if (! isset($grouped['selezione fe riscaldamento'])) { + $grouped['selezione fe riscaldamento'] = [ + 'gestione' => 'selezione fe riscaldamento', + 'fatture_count' => 0, + 'totale_fatture' => 0.0, + 'fe_count' => 0, + 'manual_count' => 0, + ]; + } + + $grouped['selezione fe riscaldamento']['fatture_count']++; + $grouped['selezione fe riscaldamento']['fe_count']++; + $grouped['selezione fe riscaldamento']['totale_fatture'] += (float) ($fattura->totale ?? 0); + $groupIds['selezione fe riscaldamento'][] = (int) $fattura->id; + } + + if ($grouped !== []) { + $out = array_values($grouped); + usort($out, fn($left, $right) => strcmp((string) $left['gestione'], (string) $right['gestione'])); + foreach ($out as &$row) { + $row['totale_fatture'] = round((float) $row['totale_fatture'], 2); + $row = $enrichGroup($row, array_values(array_unique(array_map('intval', $groupIds[(string) $row['gestione']] ?? [])))); + } + + return $out; + } + } + + return [$enrichGroup([ + 'gestione' => 'selezione fe riscaldamento', + 'fatture_count' => $fatture->count(), + 'totale_fatture' => round((float) $fatture->sum(fn(FatturaElettronica $fattura): float => (float) ($fattura->totale ?? 0)), 2), + 'fe_count' => $fatture->count(), + 'manual_count' => 0, + ], $fatturaIds)]; + } + + /** @return array{fatture:int,periodi:int,totale_mc:float,totale_spesa:float,con_finestra:int,ultima_data:?string} */ + public function getRiscaldamentoFeEstratteSummaryProperty(): array + { + $rows = $this->riscaldamentoFeEstratteRows; + + if ($rows === []) { + return [ + 'fatture' => 0, + 'periodi' => 0, + 'totale_mc' => 0.0, + 'totale_spesa' => 0.0, + 'con_finestra' => 0, + 'ultima_data' => null, + ]; + } + + $dateValues = array_values(array_filter(array_map( + static fn(array $row): ?string => is_string($row['data_fattura_iso'] ?? null) && $row['data_fattura_iso'] !== '' + ? (string) $row['data_fattura_iso'] + : null, + $rows, + ))); + + return [ + 'fatture' => count($rows), + 'periodi' => (int) array_sum(array_map(static fn(array $row): int => (int) ($row['periodi_count'] ?? 0), $rows)), + 'totale_mc' => round((float) array_sum(array_map(static fn(array $row): float => (float) ($row['totale_mc'] ?? 0), $rows)), 3), + 'totale_spesa' => round((float) array_sum(array_map(static fn(array $row): float => (float) ($row['totale_spesa'] ?? 0), $rows)), 2), + 'con_finestra' => count(array_filter($rows, static fn(array $row): bool => (bool) ($row['ha_finestra'] ?? false))), + 'ultima_data' => $dateValues !== [] ? max($dateValues) : null, + ]; + } + + /** @return array{fatture:int,totale_spesa:float,totale_mc:float} */ + public function getRiscaldamentoFeSelezioneSummaryProperty(): array + { + $selectedIds = $this->normalizeRiscaldamentoSelectedFeIds(); + $rows = collect($this->riscaldamentoFeEstratteRows) + ->when($selectedIds !== [], fn($collection) => $collection->whereIn('fattura_id', $selectedIds)); + + return [ + 'fatture' => $rows->count(), + 'totale_spesa' => round((float) $rows->sum(fn(array $row): float => (float) ($row['totale_spesa'] ?? 0)), 2), + 'totale_mc' => round((float) $rows->sum(fn(array $row): float => (float) ($row['totale_mc'] ?? 0)), 3), + ]; + } + + /** @return array> */ + public function getRiscaldamentoFeEstratteRowsProperty(): array + { + $stabileId = $this->resolveActiveStabileId(); + if (! $stabileId) { + return []; + } + + $servizi = StabileServizio::query() + ->where('stabile_id', $stabileId) + ->where('tipo', 'riscaldamento') + ->orderByDesc('attivo') + ->orderBy('id') + ->get(['id', 'nome', 'contatore_matricola', 'codice_utenza', 'codice_cliente', 'codice_contratto']); + + $defaultServizio = $servizi->first(); + + $userId = (int) (Auth::id() ?? 0); + $selectedIds = $this->normalizeRiscaldamentoSelectedFeIds(); + $fatture = $this->resolveRiscaldamentoCandidateInvoices(); + + if ($fatture->isEmpty()) { + return []; + } + + return $fatture + ->map(function (FatturaElettronica $fattura) use ($selectedIds, $servizi, $defaultServizio, $stabileId, $userId): array { + $payload = $this->resolveRiscaldamentoParsedPayload($fattura, $userId) ?? []; + $consumi = is_array($payload['consumi'] ?? null) ? $payload['consumi'] : []; + $codici = is_array($payload['codici'] ?? null) ? $payload['codici'] : []; + $contatore = is_array($payload['contatore'] ?? null) ? $payload['contatore'] : []; + $generale = is_array($payload['generale'] ?? null) ? $payload['generale'] : []; + $pagamento = is_array($payload['pagamento'] ?? null) ? $payload['pagamento'] : []; + $riepilogoLetture = is_array($payload['riepilogo_letture'] ?? null) ? $payload['riepilogo_letture'] : []; + + $servizio = $this->resolveRiscaldamentoServizioForIdentifiers($servizi, $codici, $contatore) ?? $defaultServizio; + + [$periodoDal, $periodoAl] = $this->resolveRiscaldamentoPeriodsFromPayload($consumi, is_string($fattura->consumo_riferimento ?? null) ? (string) $fattura->consumo_riferimento : null); + $windowFrom = $this->extractRiscaldamentoWindowDate($payload, ['dal', 'from', 'inizio', 'start']); + $windowTo = $this->extractRiscaldamentoWindowDate($payload, ['al', 'to', 'fine', 'end']); + + $matricola = trim((string) ($contatore['matricola'] ?? $servizio?->contatore_matricola ?? '')); + $codiceUtenza = trim((string) ($codici['utenza'] ?? $servizio?->codice_utenza ?? '')); + $codiceCliente = trim((string) ($codici['cliente'] ?? $servizio?->codice_cliente ?? '')); + $codiceContratto = trim((string) ($codici['contratto'] ?? $servizio?->codice_contratto ?? '')); + $cbill = trim((string) ($pagamento['cbill'] ?? $fattura->pagamento_cbill ?? '')); + $codiceAvviso = trim((string) ($pagamento['codice_avviso'] ?? $fattura->pagamento_codice_avviso ?? '')); + $sia = trim((string) ($pagamento['sia'] ?? $fattura->pagamento_codice_sia ?? '')); + $totaleMc = $this->sumRiscaldamentoConsumi($consumi); + if ($totaleMc === null && is_numeric($fattura->consumo_valore ?? null) && strtolower(trim((string) ($fattura->consumo_unita ?? ''))) === 'mc') { + $totaleMc = round((float) $fattura->consumo_valore, 3); + } + + $letturePreview = collect($riepilogoLetture) + ->filter(fn($lettura): bool => is_array($lettura)) + ->map(function (array $lettura): string { + $tipologia = trim((string) ($lettura['tipologia'] ?? 'Lettura')); + $consumo = is_numeric($lettura['consumo'] ?? null) ? number_format((float) $lettura['consumo'], 3, ',', '.') . ' ' . trim((string) ($lettura['unita'] ?? 'mc')) : null; + $dataA = is_string($lettura['data_a'] ?? null) && trim((string) $lettura['data_a']) !== '' ? $this->formatRiscaldamentoPeriodLabel(null, (string) $lettura['data_a']) : null; + + return trim(implode(' · ', array_filter([$tipologia !== '' ? $tipologia : null, $consumo, $dataA]))); + }) + ->filter() + ->take(2) + ->values() + ->all(); + + $legacyMatch = null; + $numFatNorm = $this->normalizeInvoiceNumber($fattura->numero_fattura ?? null); + $dtFatIso = optional($fattura->data_fattura)->format('Y-m-d'); + $legacyOps = $this->riscaldamentoLegacyOperazioniRows; + + foreach ($legacyOps as $opRow) { + $opNumFatNorm = $this->normalizeInvoiceNumber($opRow['num_fat'] ?? null); + $opDtFatIso = $opRow['data_operazione'] ?? null; + + if ($numFatNorm && $opNumFatNorm && $numFatNorm === $opNumFatNorm && $dtFatIso && $opDtFatIso && $dtFatIso === $opDtFatIso) { + $legacyMatch = $opRow; + break; + } + + if ($opRow['fornitore_legacy'] && $fattura->fornitore && $opRow['fornitore_legacy'] === $fattura->fornitore->ragione_sociale) { + $opImporto = (float) ($opRow['importo'] ?? 0); + if (abs(abs($opImporto) - abs((float) $fattura->totale)) <= 0.01) { + $legacyMatch = $opRow; + break; + } + } + } + + $regFattura = \Illuminate\Support\Facades\DB::table('contabilita_fatture_fornitori') + ->where('stabile_id', $stabileId) + ->where('fattura_elettronica_id', $fattura->id) + ->first(); + + $imp = \App\Models\FornitoreStabileImpostazione::query() + ->where('stabile_id', $stabileId) + ->where('fornitore_id', $fattura->fornitore_id) + ->first(); + + $voceSpesaDefaultName = 'Non impostata'; + if ($imp && $imp->voce_spesa_default_id) { + $voceSpesaDefaultName = \App\Models\VoceSpesa::query() + ->where('id', $imp->voce_spesa_default_id) + ->value('codice') ?: 'Impostata'; + } + + return [ + 'fattura_id' => (int) $fattura->id, + 'numero_fattura' => trim((string) ($fattura->numero_fattura ?? $fattura->sdi_file ?? ('FE #' . (int) $fattura->id))), + 'data_fattura' => optional($fattura->data_fattura)?->format('d/m/Y'), + 'data_fattura_iso' => optional($fattura->data_fattura)?->format('Y-m-d'), + 'servizio' => trim((string) ($servizio?->nome ?? 'Riscaldamento stabile')), + 'servizio_id' => (int) ($servizio?->id ?? 0), + 'matricola' => $matricola, + 'codice_utenza' => $codiceUtenza, + 'codice_cliente' => $codiceCliente, + 'codice_contratto' => $codiceContratto, + 'cbill' => $cbill, + 'codice_avviso' => $codiceAvviso, + 'sia' => $sia, + 'cbill_display' => $cbill !== '' ? $cbill : ($codiceAvviso !== '' ? $codiceAvviso : ''), + 'numero_componenti' => is_numeric($generale['numero_componenti_totali'] ?? null) ? (int) $generale['numero_componenti_totali'] : null, + 'numero_unita_abitative' => is_numeric($generale['numero_unita_abitative'] ?? null) ? (int) $generale['numero_unita_abitative'] : null, + 'periodicita_fatturazione' => trim((string) ($generale['periodicita_fatturazione'] ?? '')), + 'periodo_label' => $this->formatRiscaldamentoPeriodLabel($periodoDal, $periodoAl), + 'periodi_count' => count($consumi), + 'letture_count' => count($riepilogoLetture), + 'letture_preview' => $letturePreview, + 'totale_mc' => $totaleMc ?? 0.0, + 'totale_spesa' => round((float) ($fattura->totale ?? 0), 2), + 'ha_finestra' => $windowFrom !== null || $windowTo !== null, + 'finestra_label' => $this->formatRiscaldamentoPeriodLabel($windowFrom, $windowTo), + 'selected' => in_array((int) $fattura->id, $selectedIds, true), + 'identificativi_noti' => $matricola !== '' || $codiceUtenza !== '' || $codiceCliente !== '' || $codiceContratto !== '' || $cbill !== '' || $codiceAvviso !== '' || $sia !== '', + 'scheda_fe_url' => FatturaElettronicaScheda::getUrl(panel: 'admin-filament', parameters: ['record' => (int) $fattura->id]), + 'ticket_generale_url' => TicketRiscaldamentoGenerale::getUrl(panel: 'admin-filament', parameters: ['stabile' => $stabileId, 'servizio' => (int) ($servizio?->id ?? 0)]), + 'contratti_url' => ContrattiHub::getUrl(panel: 'admin-filament'), + 'letture_servizio_url' => LettureServiziArchivio::getUrl(panel: 'admin-filament', parameters: ['servizio' => (int) ($servizio?->id ?? 0)]), + + 'legacy_n_spe' => $legacyMatch ? ($legacyMatch['n_spe'] ?? null) : null, + 'legacy_id' => $legacyMatch ? ($legacyMatch['id_operaz'] ?? null) : null, + 'legacy_cod_spe' => $legacyMatch ? ($legacyMatch['cod_spe'] ?? null) : null, + 'legacy_benef' => $legacyMatch ? ($legacyMatch['benef'] ?? null) : null, + 'legacy_importo' => $legacyMatch ? ($legacyMatch['importo'] ?? null) : null, + 'legacy_match_type' => $legacyMatch ? 'Abbinato' : 'Non abbinato', + + 'registrata_id' => $regFattura ? (int) $regFattura->id : null, + 'registrata_stato' => $regFattura ? (string) $regFattura->stato : 'non_registrata', + 'registrata_data_pagamento'=> $regFattura && $regFattura->data_pagamento ? \Carbon\Carbon::parse($regFattura->data_pagamento)->format('d/m/Y') : null, + 'registrata_netto_da_pagare'=> $regFattura ? (float) $regFattura->netto_da_pagare : 0.0, + 'voce_spesa_default_name' => $voceSpesaDefaultName, + ]; + }) + ->sortByDesc('data_fattura_iso') + ->take(24) + ->values() + ->all(); + } + + private function decodeRiscaldamentoPayload(mixed $value): array + { + if (is_array($value)) { + return $value; + } + + if (is_string($value) && trim($value) !== '') { + $decoded = json_decode($value, true); + if (is_array($decoded)) { + return $decoded; + } + } + + return []; + } + + private function extractRiscaldamentoWindowDate(array $payload, array $keys): ?string + { + $window = is_array($payload['finestra_autolettura'] ?? null) ? $payload['finestra_autolettura'] : []; + + foreach ($keys as $key) { + $value = $window[$key] ?? null; + if (! is_string($value) || trim($value) === '') { + continue; + } + + try { + return (string) \Carbon\Carbon::parse($value)->format('Y-m-d'); + } catch (\Throwable) { + continue; + } + } + + return null; + } + + private function formatRiscaldamentoPeriodLabel(?string $from, ?string $to): string + { + $fromLabel = $from ? \Carbon\Carbon::parse($from)->format('d/m/Y') : null; + $toLabel = $to ? \Carbon\Carbon::parse($to)->format('d/m/Y') : null; + + if ($fromLabel && $toLabel) { + return $fromLabel . ' - ' . $toLabel; + } + + if ($fromLabel) { + return 'Dal ' . $fromLabel; + } + + if ($toLabel) { + return 'Fino al ' . $toLabel; + } + + return '—'; + } + + /** @param \Illuminate\Support\Collection $servizi */ + private function resolveRiscaldamentoServizioForIdentifiers($servizi, array $codici, array $contatore): ?StabileServizio + { + if (! $servizi instanceof \Illuminate\Support\Collection || $servizi->isEmpty()) { + return null; + } + + $targetMatricola = $this->normalizeRiscaldamentoIdentifier($contatore['matricola'] ?? null); + $targetUtenza = $this->normalizeRiscaldamentoIdentifier($codici['utenza'] ?? null); + $targetCliente = $this->normalizeRiscaldamentoIdentifier($codici['cliente'] ?? null); + $targetContratto = $this->normalizeRiscaldamentoIdentifier($codici['contratto'] ?? null); + + return $servizi + ->map(function (StabileServizio $servizio) use ($targetMatricola, $targetUtenza, $targetCliente, $targetContratto): array { + $score = 0; + if ($targetMatricola !== '' && $this->normalizeRiscaldamentoIdentifier($servizio->contatore_matricola) === $targetMatricola) { + $score += 5; + } + if ($targetUtenza !== '' && $this->normalizeRiscaldamentoIdentifier($servizio->codice_utenza) === $targetUtenza) { + $score += 4; + } + if ($targetContratto !== '' && $this->normalizeRiscaldamentoIdentifier($servizio->codice_contratto) === $targetContratto) { + $score += 3; + } + if ($targetCliente !== '' && $this->normalizeRiscaldamentoIdentifier($servizio->codice_cliente) === $targetCliente) { + $score += 2; + } + + return ['servizio' => $servizio, 'score' => $score]; + }) + ->sortByDesc('score') + ->first(fn(array $candidate): bool => (int) ($candidate['score'] ?? 0) > 0)['servizio'] ?? null; + } + + private function normalizeRiscaldamentoIdentifier(mixed $value): string + { + return strtoupper(preg_replace('/[^A-Z0-9]/', '', trim((string) $value)) ?: ''); + } + + /** @param array> $consumi */ + private function resolveRiscaldamentoPeriodsFromPayload(array $consumi, ?string $fallbackRange): array + { + $periodoDal = collect($consumi)->pluck('dal')->filter()->sort()->first(); + $periodoAl = collect($consumi)->pluck('al')->filter()->sort()->last(); + + if (is_string($periodoDal) && $periodoDal !== '' && is_string($periodoAl) && $periodoAl !== '') { + return [$periodoDal, $periodoAl]; + } + + if (! is_string($fallbackRange) || trim($fallbackRange) === '') { + return [$periodoDal, $periodoAl]; + } + + if (preg_match('/(\d{4}-\d{2}-\d{2})\s*-\s*(\d{4}-\d{2}-\d{2})/', $fallbackRange, $matches)) { + return [$matches[1], $matches[2]]; + } + + return [$periodoDal, $periodoAl]; + } + + private function resolvePreferredRiscaldamentoSupplier(?int $stabileId = null): ?Fornitore + { + if (! Schema::hasTable('fornitori')) { + return null; + } + + $serviceSupplierId = null; + if ($stabileId) { + $serviceSupplierId = StabileServizio::query() + ->where('stabile_id', $stabileId) + ->where('tipo', 'riscaldamento') + ->whereNotNull('fornitore_id') + ->orderByDesc('attivo') + ->orderBy('id') + ->value('fornitore_id'); + } + + $candidates = Fornitore::query() + ->where(function (Builder $query): Builder { + return $query->whereRaw('LOWER(ragione_sociale) like ?', ['%eni%']) + ->orWhereRaw('LOWER(ragione_sociale) like ?', ['%enel%']) + ->orWhereRaw('LOWER(ragione_sociale) like ?', ['%gas%']) + ->orWhereRaw('LOWER(ragione_sociale) like ?', ['%caldaia%']) + ->orWhereRaw('LOWER(ragione_sociale) like ?', ['%combustibile%']) + ->orWhereRaw('LOWER(ragione_sociale) like ?', ['%riscaldamento%']); + }) + ->get([ + 'id', + 'amministratore_id', + 'rubrica_id', + 'ragione_sociale', + 'partita_iva', + 'codice_fiscale', + 'old_id', + 'fe_features', + ]); + + if ($candidates->isEmpty()) { + return null; + } + + $userAdminId = null; + $user = Auth::user(); + if ($user instanceof User && isset($user->amministratore_id) && is_numeric($user->amministratore_id)) { + $userAdminId = (int) $user->amministratore_id; + } + + $stableInvoiceCounts = []; + if ($stabileId) { + $candidateIds = $candidates->pluck('id') + ->map(fn($value) => (int) $value) + ->filter(fn($value) => $value > 0) + ->all(); + + if ($candidateIds !== []) { + $feCounts = DB::table('fatture_elettroniche') + ->selectRaw('fornitore_id, COUNT(*) as aggregate_count') + ->where('stabile_id', $stabileId) + ->whereIn('fornitore_id', $candidateIds) + ->groupBy('fornitore_id') + ->pluck('aggregate_count', 'fornitore_id') + ->all(); + + $contabiliCounts = DB::table('contabilita_fatture_fornitori') + ->selectRaw('fornitore_id, COUNT(*) as aggregate_count') + ->where('stabile_id', $stabileId) + ->whereIn('fornitore_id', $candidateIds) + ->groupBy('fornitore_id') + ->pluck('aggregate_count', 'fornitore_id') + ->all(); + + foreach ($candidateIds as $candidateId) { + $stableInvoiceCounts[$candidateId] = (int) ($feCounts[$candidateId] ?? 0) + (int) ($contabiliCounts[$candidateId] ?? 0); + } + } + } + + $sorted = $candidates->sort(function (Fornitore $left, Fornitore $right) use ($serviceSupplierId, $userAdminId, $stableInvoiceCounts): int { + $leftScore = $this->scorePreferredRiscaldamentoSupplier($left, $serviceSupplierId, $userAdminId, $stableInvoiceCounts); + $rightScore = $this->scorePreferredRiscaldamentoSupplier($right, $serviceSupplierId, $userAdminId, $stableInvoiceCounts); + + if ($leftScore === $rightScore) { + return (int) $left->id <=> (int) $right->id; + } + + return $rightScore <=> $leftScore; + }); + + return $sorted->first(); + } + + /** @return array */ + public function getRiscaldamentoUiServiceOptionsProperty(): array + { + $stabileId = $this->resolveActiveStabileId(); + if (! $stabileId) { + return []; + } + + return StabileServizio::query() + ->where('stabile_id', $stabileId) + ->where('tipo', 'riscaldamento') + ->where('attivo', true) + ->orderBy('nome') + ->orderBy('contatore_matricola') + ->get(['id', 'nome', 'contatore_matricola', 'codice_utenza']) + ->mapWithKeys(function (StabileServizio $service): array { + $parts = array_filter([ + trim((string) ($service->nome ?? '')) ?: ('Servizio #' . (int) $service->id), + trim((string) ($service->contatore_matricola ?? '')) !== '' ? 'Matr. ' . trim((string) $service->contatore_matricola) : null, + trim((string) ($service->codice_utenza ?? '')) !== '' ? 'Utenza ' . trim((string) $service->codice_utenza) : null, + ]); + + return [(int) $service->id => implode(' · ', $parts)]; + }) + ->all(); + } + + /** @return array */ + public function getRiscaldamentoUiUnitOptionsProperty(): array + { + $stabileId = $this->resolveActiveStabileId(); + if (! $stabileId) { + return []; + } + + return UnitaImmobiliare::query() + ->where('stabile_id', $stabileId) + ->whereNull('deleted_at') + ->orderBy('scala') + ->orderBy('interno') + ->orderBy('id') + ->get(['id', 'codice_unita', 'scala', 'interno', 'denominazione']) + ->mapWithKeys(function (UnitaImmobiliare $unit): array { + $label = trim(implode(' ', array_filter([ + trim((string) ($unit->codice_unita ?? '')), + trim((string) ($unit->scala ?? '')) !== '' ? 'Scala ' . trim((string) $unit->scala) : null, + trim((string) ($unit->interno ?? '')) !== '' ? 'Int. ' . trim((string) $unit->interno) : null, + trim((string) ($unit->denominazione ?? '')), + ]))); + + return [(int) $unit->id => ($label !== '' ? $label : ('UI #' . (int) $unit->id))]; + }) + ->all(); + } + + public function getRiscaldamentoRipartizionePrintUrlProperty(): string + { + return $this->buildRiscaldamentoRipartizioneRoute('admin.riscaldamento-ripartizione.print'); + } + + public function getRiscaldamentoRipartizionePdfUrlProperty(): string + { + return $this->buildRiscaldamentoRipartizioneRoute('admin.riscaldamento-ripartizione.pdf'); + } + + private function buildRiscaldamentoRipartizioneRoute(string $routeName): string + { + $params = [ + 'stabile_id' => $this->resolveActiveStabileId(), + 'year' => $this->resolveActiveAnnoGestione(), + ]; + + $selectedIds = $this->normalizeRiscaldamentoSelectedFeIds(); + if ($selectedIds !== []) { + $params['ids'] = implode(',', $selectedIds); + } + + return route($routeName, $params); + } + + /** @return array{touched:int,created:int,updated:int,skipped:int} */ + private function propagateRiscaldamentoSettingsToOtherStabili(): array + { + $user = Auth::user(); + $currentStabileId = $this->resolveActiveStabileId(); + if (! $user instanceof User || ! $currentStabileId) { + return ['touched' => 0, 'created' => 0, 'updated' => 0, 'skipped' => 0]; + } + + $template = StabileServizio::query() + ->where('stabile_id', $currentStabileId) + ->where('tipo', 'riscaldamento') + ->orderByDesc('attivo') + ->orderBy('id') + ->first(); + + if (! $template instanceof StabileServizio) { + return ['touched' => 0, 'created' => 0, 'updated' => 0, 'skipped' => 0]; + } + + $templateMeta = is_array($template->meta ?? null) ? $template->meta : []; + $targets = StabileContext::accessibleStabili($user) + ->filter(fn(Stabile $stabile): bool => (int) $stabile->id !== (int) $currentStabileId) + ->values(); + + $stats = ['touched' => 0, 'created' => 0, 'updated' => 0, 'skipped' => 0]; + + foreach ($targets as $target) { + $service = StabileServizio::query() + ->where('stabile_id', (int) $target->id) + ->where('tipo', 'riscaldamento') + ->orderByDesc('attivo') + ->orderBy('id') + ->first(); + + $targetSupplier = $this->resolvePreferredRiscaldamentoSupplier((int) $target->id); + $payload = [ + 'tipo' => 'riscaldamento', + 'nome' => trim((string) ($service?->nome ?: $template->nome ?: 'Utenza riscaldamento - Fornitore Gas/Caldaia')), + 'fornitore_id' => (int) ($targetSupplier?->id ?: $service?->fornitore_id ?: $template->fornitore_id) ?: null, + 'voce_spesa_id' => (int) ($service?->voce_spesa_id ?: $template->voce_spesa_id) ?: null, + 'attivo' => true, + 'meta' => array_merge(is_array($service?->meta ?? null) ? $service->meta : [], [ + 'common_asset_label' => $templateMeta['common_asset_label'] ?? null, + 'common_asset_scope' => $templateMeta['common_asset_scope'] ?? null, + 'allocation_scope' => $templateMeta['allocation_scope'] ?? null, + 'fiscal_scope' => $templateMeta['fiscal_scope'] ?? null, + 'has_dedicated_meter' => (bool) ($templateMeta['has_dedicated_meter'] ?? false), + 'counter_scope' => $templateMeta['counter_scope'] ?? 'assente', + 'served_area_label' => $templateMeta['served_area_label'] ?? null, + 'canale_acquisizione' => $templateMeta['canale_acquisizione'] ?? null, + 'email_raccolta_letture' => $templateMeta['email_raccolta_letture'] ?? null, + 'whatsapp_raccolta_letture' => $templateMeta['whatsapp_raccolta_letture'] ?? null, + 'costo_lettura_unitario' => $templateMeta['costo_lettura_unitario'] ?? null, + 'costo_ripartizione' => $templateMeta['costo_ripartizione'] ?? null, + 'ripartizione_esterna' => (bool) ($templateMeta['ripartizione_esterna'] ?? false), + 'operative_notes' => $templateMeta['operative_notes'] ?? null, + 'propagated_from_stabile_id' => (int) $currentStabileId, + 'propagated_at' => now()->toIso8601String(), + ]), + ]; + + if ($service instanceof StabileServizio) { + $service->fill($payload); + $service->save(); + $stats['updated']++; + } else { + StabileServizio::query()->create(array_merge($payload, [ + 'stabile_id' => (int) $target->id, + 'codice_utenza' => null, + 'codice_cliente' => null, + 'codice_contratto' => null, + 'contatore_matricola' => null, + ])); + $stats['created']++; + } + + $stats['touched']++; + } + + return $stats; + } + + /** @param array $supplierIds */ + private function resolveEquivalentFornitoreIds(array $supplierIds): array + { + $supplierIds = array_values(array_unique(array_filter(array_map('intval', $supplierIds), fn(int $value): bool => $value > 0))); + if ($supplierIds === [] || ! Schema::hasTable('fornitori')) { + return $supplierIds; + } + + $suppliers = Fornitore::query() + ->whereIn('id', $supplierIds) + ->get(['id', 'rubrica_id', 'partita_iva', 'codice_fiscale', 'old_id']); + + if ($suppliers->isEmpty()) { + return $supplierIds; + } + + $rubricaIds = $suppliers->pluck('rubrica_id') + ->filter(fn($value) => is_numeric($value) && (int) $value > 0) + ->map(fn($value) => (int) $value) + ->unique() + ->values() + ->all(); + + $partiteIva = $suppliers->pluck('partita_iva') + ->map(fn($value) => trim((string) $value)) + ->filter(fn(string $value): bool => $value !== '') + ->unique() + ->values() + ->all(); + + $codiciFiscali = $suppliers->pluck('codice_fiscale') + ->map(fn($value) => trim((string) $value)) + ->filter(fn(string $value): bool => $value !== '') + ->unique() + ->values() + ->all(); + + $legacyIds = $suppliers->pluck('old_id') + ->filter(fn($value) => is_numeric($value) && (int) $value > 0) + ->map(fn($value) => (int) $value) + ->unique() + ->values() + ->all(); + + $matches = Fornitore::query() + ->where(function (Builder $query) use ($supplierIds, $rubricaIds, $partiteIva, $codiciFiscali, $legacyIds): Builder { + $query->whereIn('id', $supplierIds); + + if ($rubricaIds !== []) { + $query->orWhereIn('rubrica_id', $rubricaIds); + } + + if ($partiteIva !== []) { + $query->orWhereIn('partita_iva', $partiteIva); + } + + if ($codiciFiscali !== []) { + $query->orWhereIn('codice_fiscale', $codiciFiscali); + } + + if ($legacyIds !== []) { + $query->orWhereIn('old_id', $legacyIds); + } + + return $query; + }) + ->pluck('id') + ->map(fn($value) => (int) $value) + ->filter(fn($value) => $value > 0) + ->unique() + ->values() + ->all(); + + return $matches !== [] ? $matches : $supplierIds; + } + + /** @param array $stableInvoiceCounts */ + private function scorePreferredRiscaldamentoSupplier(Fornitore $supplier, ?int $serviceSupplierId, ?int $userAdminId, array $stableInvoiceCounts): int + { + $score = 0; + + if ($serviceSupplierId && (int) $supplier->id === $serviceSupplierId) { + $score += 4000; + } + + $waterFeatures = is_array($supplier->fe_features ?? null) ? $supplier->fe_features : []; + if ((bool) data_get($waterFeatures, 'riscaldamento.enabled', false)) { + $score += 1000; + } + + if ($userAdminId && isset($supplier->amministratore_id) && (int) $supplier->amministratore_id === $userAdminId) { + $score += 500; + } + + $score += ((int) ($stableInvoiceCounts[(int) $supplier->id] ?? 0)) * 25; + + return $score; + } + + /** @return array> */ + public function getRiscaldamentoLettureCondominiProperty(): array + { + $stabileId = $this->resolveActiveStabileId(); + if (! $stabileId) { + return []; + } + + return StabileServizioLettura::query() + ->where('stabile_id', $stabileId) + ->whereHas('servizio', fn(Builder $q) => $q->where('tipo', 'riscaldamento')) + ->with([ + 'unitaImmobiliare:id,codice_unita,interno,scala', + 'servizio:id,nome,contatore_matricola', + ]) + ->tap(fn(Builder $query) => $this->applyActiveYearFilterToReadingQuery($query)) + ->orderByDesc('created_at') + ->limit(60) + ->get() + ->map(function (StabileServizioLettura $row): array { + return [ + 'id' => (int) $row->id, + 'data_ricezione' => optional($row->created_at)?->format('d/m/Y H:i'), + 'servizio' => (string) ($row->servizio?->nome ?? 'Riscaldamento'), + 'matricola' => (string) ($row->servizio?->contatore_matricola ?? '—'), + 'unita' => trim((string) ($row->unitaImmobiliare?->codice_unita ?? '') . ' ' . (string) ($row->unitaImmobiliare?->interno ?? '')), + 'canale' => (string) ($row->canale_acquisizione ?? '—'), + 'riferimento' => (string) ($row->riferimento_acquisizione ?? '—'), + 'periodo' => ((string) optional($row->periodo_dal)->format('d/m/Y')) . ' - ' . ((string) optional($row->periodo_al)->format('d/m/Y')), + 'consumo_valore' => is_numeric($row->consumo_valore) ? (float) $row->consumo_valore : null, + 'consumo_unita' => (string) ($row->consumo_unita ?: 'mc'), + ]; + }) + ->all(); + } + + /** @return array> */ + public function getRiscaldamentoLettureGeneraliProperty(): array + { + $stabileId = $this->resolveActiveStabileId(); + if (! $stabileId) { + return []; + } + + return StabileServizioLettura::query() + ->where('stabile_id', $stabileId) + ->whereNull('unita_immobiliare_id') + ->whereHas('servizio', fn(Builder $q) => $q->where('tipo', 'riscaldamento')) + ->with([ + 'servizio:id,nome,codice_utenza,codice_cliente,codice_contratto,contatore_matricola', + 'fatturaElettronica:id,numero_fattura,data_fattura', + ]) + ->tap(fn(Builder $query) => $this->applyActiveYearFilterToReadingQuery($query)) + ->orderByDesc('periodo_al') + ->orderByDesc('id') + ->limit(60) + ->get() + ->map(function (StabileServizioLettura $row): array { + $schedule = is_array(data_get($row->raw, 'acea_schedule')) ? data_get($row->raw, 'acea_schedule') : []; + $windowStart = data_get($schedule, 'window_start'); + $windowEnd = data_get($schedule, 'window_end'); + $windowLabel = trim(implode(' - ', array_filter([ + $windowStart ? optional(now()->parse((string) $windowStart))->format('d/m/Y') : null, + $windowEnd ? optional(now()->parse((string) $windowEnd))->format('d/m/Y') : null, + ]))); + + return [ + 'id' => (int) $row->id, + 'data_ricezione' => optional($row->created_at)?->format('d/m/Y H:i'), + 'servizio' => (string) ($row->servizio?->nome ?? 'Riscaldamento'), + 'matricola' => (string) ($row->servizio?->contatore_matricola ?? '—'), + 'codice_utenza' => (string) ($row->servizio?->codice_utenza ?? '—'), + 'codice_cliente' => (string) ($row->servizio?->codice_cliente ?? '—'), + 'codice_contratto' => (string) ($row->servizio?->codice_contratto ?? '—'), + 'canale' => (string) ($row->canale_acquisizione ?? '—'), + 'riferimento' => (string) ($row->riferimento_acquisizione ?? '—'), + 'periodo' => ((string) optional($row->periodo_dal)->format('d/m/Y')) . ' - ' . ((string) optional($row->periodo_al)->format('d/m/Y')), + 'lettura_fine' => is_numeric($row->lettura_fine) ? (float) $row->lettura_fine : null, + 'consumo_valore' => is_numeric($row->consumo_valore) ? (float) $row->consumo_valore : null, + 'consumo_unita' => (string) ($row->consumo_unita ?: 'mc'), + 'protocollo' => (string) ($row->protocollo_numero ?? '—'), + 'workflow' => (string) ($row->workflow_stato ?? '—'), + 'reader' => (string) ($row->rilevatore_nome ?? '—'), + 'window_label' => $windowLabel !== '' ? $windowLabel : null, + 'visit_date' => data_get($schedule, 'visit_date'), + 'fattura_label' => $row->fatturaElettronica + ? trim((string) ($row->fatturaElettronica->numero_fattura ?? 'FE #' . $row->fatturaElettronica->id)) + : null, + 'fattura_url' => $row->fattura_elettronica_id + ? FatturaElettronicaScheda::getUrl(['record' => (int) $row->fattura_elettronica_id], panel : 'admin-filament') + : null, + 'ticket_url' => TicketRiscaldamentoGenerale::getUrl([ + 'stabile' => (int) $row->stabile_id, + 'servizio' => (int) $row->stabile_servizio_id, + ], panel: 'admin-filament'), + 'archivio_url' => LettureServiziArchivio::getUrl([ + 'servizio' => (int) $row->stabile_servizio_id, + ], panel: 'admin-filament'), + ]; + }) + ->all(); + } + + /** @return array{totale:int,completate:int,pendenti:int,sollecitate:int} */ + public function getRiscaldamentoCampagnaStatsProperty(): array + { + $rows = $this->riscaldamentoCampagnaRows; + + return [ + 'totale' => count($rows), + 'completate' => count(array_filter($rows, fn(array $row): bool => $row['status'] === 'fatta')), + 'pendenti' => count(array_filter($rows, fn(array $row): bool => in_array($row['status'], ['richiesta', 'non_richiesta'], true))), + 'sollecitate' => count(array_filter($rows, fn(array $row): bool => $row['status'] === 'sollecito')), + ]; + } + + /** @return array> */ + public function getRiscaldamentoCampagnaRowsProperty(): array + { + $stabileId = $this->resolveActiveStabileId(); + $servizio = $this->resolveActiveRiscaldamentoServizio(); + if (! $stabileId || ! $servizio) { + return []; + } + + $year = $this->resolveActiveAnnoGestione(); + + $units = UnitaImmobiliare::query() + ->where('stabile_id', $stabileId) + ->whereNull('deleted_at') + ->orderBy('scala') + ->orderBy('interno') + ->orderBy('id') + ->get(['id', 'codice_unita', 'denominazione', 'scala', 'interno']); + + $nominativi = DB::table('unita_immobiliare_nominativi') + ->whereIn('unita_immobiliare_id', $units->pluck('id')->all()) + ->orderByDesc('updated_at') + ->orderByDesc('id') + ->get(['unita_immobiliare_id', 'nominativo']); + + $nominativiByUnit = []; + foreach ($nominativi as $row) { + $unitId = (int) ($row->unita_immobiliare_id ?? 0); + if ($unitId > 0 && ! isset($nominativiByUnit[$unitId])) { + $nominativiByUnit[$unitId] = trim((string) ($row->nominativo ?? '')); + } + } + + $recapitiRows = DB::table('unita_recapiti_servizio') + ->whereIn('unita_id', $units->pluck('id')->all()) + ->where('attivo', true) + ->whereNotNull('email') + ->where('email', '!=', '') + ->orderByRaw("CASE WHEN tipo_servizio = 'riscaldamento' THEN 0 ELSE 1 END") + ->orderByDesc('is_default') + ->orderBy('ordine') + ->get(['unita_id', 'email']); + + $emailsByUnit = []; + foreach ($recapitiRows as $row) { + $unitId = (int) ($row->unita_id ?? 0); + if ($unitId > 0 && ! isset($emailsByUnit[$unitId])) { + $emailsByUnit[$unitId] = trim((string) ($row->email ?? '')); + } + } + + $readingRows = StabileServizioLettura::query() + ->where('stabile_id', $stabileId) + ->where('stabile_servizio_id', (int) $servizio->id) + ->whereIn('unita_immobiliare_id', $units->pluck('id')->all()) + ->orderByDesc('created_at') + ->orderByDesc('id') + ->get(); + + $currentYearByUnit = []; + $latestRealByUnit = []; + foreach ($readingRows as $row) { + $unitId = (int) ($row->unita_immobiliare_id ?? 0); + if ($unitId <= 0) { + continue; + } + + $rowYear = optional($row->created_at)->year ?: optional($row->periodo_al)->year; + if ((int) $rowYear === $year && ! isset($currentYearByUnit[$unitId])) { + $currentYearByUnit[$unitId] = $row; + } + + if ($row->lettura_fine !== null && (int) $rowYear !== $year && ! isset($latestRealByUnit[$unitId])) { + $latestRealByUnit[$unitId] = $row; + } + } + + $rows = $units->map(function (UnitaImmobiliare $unit) use ($stabileId, $currentYearByUnit, $latestRealByUnit, $nominativiByUnit, $emailsByUnit, $servizio): array { + $current = $currentYearByUnit[(int) $unit->id] ?? null; + $latestReal = $latestRealByUnit[(int) $unit->id] ?? null; + + $status = 'non_richiesta'; + if ($current && $current->lettura_fine !== null) { + $status = 'fatta'; + } elseif ($current && $current->sollecito_inviato_at) { + $status = 'sollecito'; + } elseif ($current && $current->richiesta_lettura_inviata_at) { + $status = 'richiesta'; + } + + return [ + 'unit_id' => (int) $unit->id, + 'scala' => trim((string) ($unit->scala ?? '')), + 'interno' => trim((string) ($unit->interno ?? '')), + 'unit_label' => trim((string) ($unit->codice_unita ?? '') . ' ' . (string) ($unit->interno ?? '')) ?: ('UI #' . (int) $unit->id), + 'nominativo' => $nominativiByUnit[(int) $unit->id] ?? (trim((string) ($unit->denominazione ?? '')) ?: 'Nominativo da completare'), + 'email' => $emailsByUnit[(int) $unit->id] ?? null, + 'previous_reading' => $latestReal?->lettura_fine, + 'request_id' => $current?->id, + 'status' => $status, + 'requested_at' => optional($current?->richiesta_lettura_inviata_at)->format('d/m/Y H:i'), + 'deadline_at' => optional($current?->deadline_lettura_at)->format('d/m/Y'), + 'completed_at' => optional($current?->created_at)->format('d/m/Y H:i'), + 'latest_value' => $current?->lettura_fine, + 'edit' => $this->resolveRiscaldamentoCampagnaEditRow((int) $unit->id, $current, $latestReal), + 'public_url' => $this->buildPublicRiscaldamentoReadingUrl((int) $servizio->id, (int) $unit->id, $current?->id ? (int) $current->id : null), + 'ticket_url' => \App\Filament\Pages\Supporto\TicketRiscaldamentoLight::getUrl([ + 'stabile' => $stabileId, + 'servizio' => (int) $servizio->id, + 'unita' => (int) $unit->id, + ], panel: 'admin-filament'), + ]; + })->all(); + + usort($rows, function (array $left, array $right): int { + if ($this->riscaldamentoCampagnaSort === 'nominativo') { + return strnatcasecmp((string) ($left['nominativo'] ?? ''), (string) ($right['nominativo'] ?? '')); + } + + $leftKey = trim((string) ($left['scala'] ?? '') . ' ' . (string) ($left['interno'] ?? '')); + $rightKey = trim((string) ($right['scala'] ?? '') . ' ' . (string) ($right['interno'] ?? '')); + + return strnatcasecmp($leftKey, $rightKey); + }); + + return $rows; + } + + private function resolveRiscaldamentoCampagnaEditRow(int $unitId, ?StabileServizioLettura $current, ?StabileServizioLettura $latestReal): array + { + if (! isset($this->riscaldamentoCampagnaEditRows[$unitId])) { + $this->riscaldamentoCampagnaEditRows[$unitId] = [ + 'data_lettura' => optional($current?->periodo_al)->toDateString() ?: now()->toDateString(), + 'deadline_at' => optional($current?->deadline_lettura_at)->toDateString(), + 'canale' => (string) ($current->canale_acquisizione ?? 'manuale'), + 'riferimento' => (string) ($current->riferimento_acquisizione ?? ''), + 'lettura_finale' => $current?->lettura_fine, + 'note' => trim((string) data_get($current?->raw ?? [], 'campagna_editor.note', '')), + 'reader_name' => trim((string) ($current->rilevatore_nome ?? Auth::user()?->name ?? '')), + 'previous_reading' => $latestReal?->lettura_fine, + ]; + } + + return $this->riscaldamentoCampagnaEditRows[$unitId]; + } + + /** @param array $unitIds */ + private function persistRiscaldamentoCampagnaRows(array $unitIds): void + { + $stabileId = $this->resolveActiveStabileId(); + $servizio = $this->resolveActiveRiscaldamentoServizio(); + $user = Auth::user(); + if (! $stabileId || ! $servizio || ! $user instanceof User) { + return; + } + + $saved = 0; + foreach ($unitIds as $unitId) { + $unitId = (int) $unitId; + if ($unitId <= 0) { + continue; + } + + $unit = UnitaImmobiliare::query()->where('stabile_id', $stabileId)->find($unitId); + if (! $unit instanceof UnitaImmobiliare) { + continue; + } + + $edit = is_array($this->riscaldamentoCampagnaEditRows[$unitId] ?? null) ? $this->riscaldamentoCampagnaEditRows[$unitId] : []; + if ($edit === []) { + continue; + } + + $current = StabileServizioLettura::query() + ->where('stabile_id', $stabileId) + ->where('stabile_servizio_id', (int) $servizio->id) + ->where('unita_immobiliare_id', $unitId) + ->whereYear('created_at', $this->resolveActiveAnnoGestione()) + ->orderByDesc('created_at') + ->orderByDesc('id') + ->first(); + + $previous = StabileServizioLettura::query() + ->where('stabile_id', $stabileId) + ->where('stabile_servizio_id', (int) $servizio->id) + ->where('unita_immobiliare_id', $unitId) + ->whereNotNull('lettura_fine') + ->when($current instanceof StabileServizioLettura, fn(Builder $q) => $q->where('id', '!=', (int) $current->id)) + ->orderByDesc('periodo_al') + ->orderByDesc('id') + ->first(); + + $previousValue = is_numeric($edit['previous_reading'] ?? null) + ? round((float) $edit['previous_reading'], 3) + : ($previous?->lettura_fine !== null ? (float) $previous->lettura_fine : null); + $finalValue = is_numeric($edit['lettura_finale'] ?? null) ? round((float) $edit['lettura_finale'], 3) : null; + $consumo = ($previousValue !== null && $finalValue !== null) ? round($finalValue - $previousValue, 3) : null; + if ($consumo !== null && $consumo < 0) { + continue; + } + + $payload = [ + 'stabile_id' => $stabileId, + 'stabile_servizio_id' => (int) $servizio->id, + 'unita_immobiliare_id' => $unitId, + 'fornitore_id' => $servizio->fornitore_id, + 'periodo_dal' => $previous?->periodo_al, + 'periodo_al' => $edit['data_lettura'] ?? null, + 'tipologia_lettura' => $finalValue !== null ? 'manuale_ui' : 'richiesta_autolettura', + 'canale_acquisizione' => trim((string) ($edit['canale'] ?? 'manuale')) ?: 'manuale', + 'riferimento_acquisizione' => trim((string) ($edit['riferimento'] ?? '')) ?: null, + 'workflow_stato' => $finalValue !== null ? 'ricevuta' : ($current?->workflow_stato ?: 'richiesta_inviata'), + 'richiesta_lettura_inviata_at' => $current?->richiesta_lettura_inviata_at ?: now(), + 'deadline_lettura_at' => $edit['deadline_at'] ?? null, + 'rilevatore_tipo' => 'amministrazione', + 'rilevatore_nome' => trim((string) ($edit['reader_name'] ?? $user->name)) ?: $user->name, + 'lettura_precedente_valore' => $previousValue, + 'lettura_inizio' => $previousValue, + 'lettura_fine' => $finalValue, + 'consumo_valore' => $consumo, + 'consumo_unita' => 'mc', + 'raw' => array_merge(is_array($current?->raw ?? null) ? $current->raw : [], [ + 'campagna_editor' => [ + 'note' => trim((string) ($edit['note'] ?? '')), + 'updated_at' => now()->toIso8601String(), + 'updated_by' => (int) $user->id, + ], + ]), + ]; + + if ($current instanceof StabileServizioLettura) { + $current->fill($payload); + $current->save(); + } else { + $payload['created_by'] = (int) $user->id; + StabileServizioLettura::query()->create($payload); + } + + $saved++; + } + + Notification::make()->title('Campagna letture aggiornata')->body('Righe salvate: ' . $saved)->success()->send(); + } + + protected function ensureRiscaldamentoServicesForStabile(int $stabileId): void + { + if (! Schema::hasTable('stabile_servizi')) { + return; + } + + $exists = \App\Models\StabileServizio::query() + ->where('stabile_id', $stabileId) + ->where('tipo', 'riscaldamento') + ->exists(); + + if ($exists) { + return; + } + + $legacyCodes = $this->resolveLegacyCodiciStabile(); + if ($legacyCodes === [] || ! Schema::connection('gescon_import')->hasTable('operazioni')) { + return; + } + + $legacySupplierIds = \Illuminate\Support\Facades\DB::connection('gescon_import') + ->table('operazioni') + ->whereIn('cod_stabile', $legacyCodes) + ->whereIn('gestione', ['O', 'R']) + ->where('cod_spe', 'like', 'R%') + ->distinct() + ->pluck('cod_for') + ->all(); + + if ($legacySupplierIds === []) { + return; + } + + $fornitori = \App\Models\Fornitore::query() + ->whereIn('old_id', $legacySupplierIds) + ->get(); + + foreach ($fornitori as $fornitore) { + \App\Models\StabileServizio::query()->firstOrCreate([ + 'stabile_id' => $stabileId, + 'tipo' => 'riscaldamento', + 'fornitore_id' => $fornitore->id, + ], [ + 'nome' => 'Riscaldamento ' . $fornitore->ragione_sociale, + 'attivo' => true, + 'meta' => [ + 'source' => 'legacy_import_auto_link', + ], + ]); + } + } + + /** @return array> */ + public function getRiscaldamentoTariffeRowsProperty(): array + { + $stabileId = $this->resolveActiveStabileId(); + if (! $stabileId) { + return []; + } + + // Auto-sincronizza i servizi di riscaldamento dallo staging legacy + $this->ensureRiscaldamentoServicesForStabile($stabileId); + + $activeYear = $this->resolveActiveAnnoGestione(); + + // Carica le voci di spesa collegate al riscaldamento per lo stabile + $voci = \App\Models\VoceSpesa::query() + ->where('stabile_id', $stabileId) + ->where('attiva', true) + ->where(function ($query): void { + $query->where('tipo_gestione', 'riscaldamento') + ->orWhere('tipo', 'riscaldamento') + ->orWhere('categoria', 'riscaldamento') + ->orWhere('categoria', 'gas') + ->orWhere('codice', 'like', 'R%') + ->orWhere('codice', 'like', 'RL%'); + }) + ->where('codice', '!=', 'RAO') + ->with(['tabellaMillesimaleDefault']) + ->orderBy('codice') + ->get(); + + // Trova le gestioni attive per questo stabile ed anno + $gestioneIds = \App\Models\GestioneContabile::query() + ->where('stabile_id', $stabileId) + ->where('anno_gestione', $activeYear) + ->pluck('id') + ->all(); + + $rows = []; + foreach ($voci as $voce) { + $consuntivo = 0.0; + if ($gestioneIds !== []) { + // 1) Somma dalle righe fatture fornitore + $invoiceSum = (float) \App\Modules\Contabilita\Models\FatturaFornitoreRiga::query() + ->where('voce_spesa_id', $voce->id) + ->whereHas('fattura', function ($q) use ($stabileId, $gestioneIds) { + $q->where('stabile_id', $stabileId) + ->whereIn('gestione_id', $gestioneIds); + }) + ->sum('totale_euro'); + + // 2) Somma dalle operazioni contabili di prima nota (es. importate dal legacy) + $opSum = (float) \Illuminate\Support\Facades\DB::table('operazioni_contabili') + ->whereIn('gestione_id', $gestioneIds) + ->where(function ($query) use ($voce) { + $query->where('conto_contabile', (string) $voce->codice) + ->when($voce->legacy_codice, fn($q) => $q->orWhere('conto_contabile', (string) $voce->legacy_codice)) + ->orWhere('voce_spesa_snapshot', 'like', '%"id":' . $voce->id . '%') + ->orWhere('voce_spesa_snapshot', 'like', '%"id": "' . $voce->id . '"%'); + }) + ->sum('dare'); + + $consuntivo = $invoiceSum + $opSum; + } + + $preventivo = (float) ($voce->importo_default ?? 0.0); + $scostamento = $consuntivo - $preventivo; + + $rows[] = [ + 'codice' => (string) $voce->codice, + 'descrizione' => (string) $voce->descrizione, + 'tabella' => $voce->tabellaMillesimaleDefault?->nome_tabella ?: ($voce->tabellaMillesimaleDefault?->denominazione ?: 'Nessuna'), + 'preventivo' => $preventivo, + 'consuntivo' => $consuntivo, + 'scostamento' => $scostamento, + ]; + } + + return $rows; + } + + /** @return array{contratti_acea: bool, tariffe_acea_standard: bool, note: string} */ + public function getRiscaldamentoLegacyArchiveAvailabilityProperty(): array + { + $hasContratti = Schema::connection('gescon_import')->hasTable('contratti_ACEA') + || Schema::connection('gescon_import')->hasTable('contratti_acea'); + $hasTariffe = Schema::connection('gescon_import')->hasTable('Tariffe_ACEA_Standard') + || Schema::connection('gescon_import')->hasTable('tariffe_acea_standard'); + + return [ + 'contratti_acea' => $hasContratti, + 'tariffe_acea_standard' => $hasTariffe, + 'note' => ($hasContratti || $hasTariffe) + ? 'Archivio legacy disponibile su connessione gescon_import.' + : 'Tabelle legacy non presenti ora su gescon_import: caricare import da parti_comuni.mdb per abilitarle.', + ]; + } + + /** @return array */ + public function getRiscaldamentoAltreVociLegacyProperty(): array + { + if (! Schema::connection('gescon_import')->hasTable('operazioni')) { + return []; + } + + $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'); + + 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); + } + + $columns = Schema::connection('gescon_import')->getColumnListing('operazioni'); + $sumFields = []; + if (in_array('importo_euro', $columns, true)) { + $sumFields[] = 'importo_euro'; + } + if (in_array('importo', $columns, true)) { + $sumFields[] = 'importo'; + } + $sumSql = '0'; + if ($sumFields !== []) { + $sumSql = 'COALESCE(' . implode(', ', $sumFields) . ', 0)'; + } + + $rows = $query + ->select('cod_spe') + ->selectRaw("SUM($sumSql) as totale") + ->groupBy('cod_spe') + ->orderBy('cod_spe') + ->get(); + + return $rows->map(fn($r): array=> [ + 'cod_spe' => (string) ($r->cod_spe ?? '—'), + 'totale' => round((float) ($r->totale ?? 0), 2), + ])->all(); + } + + protected function ensureFornitoreStabileDefaultVoceSpesa(int $stabileId): void + { + if (! Schema::hasTable('fornitore_stabile_impostazioni')) { + return; + } + + $services = \App\Models\StabileServizio::query() + ->where('stabile_id', $stabileId) + ->where('tipo', 'riscaldamento') + ->whereNotNull('fornitore_id') + ->get(); + + $legacyCodes = $this->resolveLegacyCodiciStabile(); + if ($legacyCodes === [] || ! Schema::connection('gescon_import')->hasTable('operazioni')) { + return; + } + + foreach ($services as $service) { + $fornitoreId = (int) $service->fornitore_id; + $fornitore = \App\Models\Fornitore::query()->find($fornitoreId); + if (! $fornitore || ! $fornitore->old_id) { + continue; + } + + $imp = \App\Models\FornitoreStabileImpostazione::query() + ->where('stabile_id', $stabileId) + ->where('fornitore_id', $fornitoreId) + ->first(); + + if ($imp && $imp->voce_spesa_default_id) { + continue; + } + + $mostCommonVoceCode = \Illuminate\Support\Facades\DB::connection('gescon_import') + ->table('operazioni') + ->whereIn('cod_stabile', $legacyCodes) + ->where('cod_for', $fornitore->old_id) + ->where('cod_spe', 'like', 'R%') + ->where('cod_spe', '!=', 'RAO') + ->groupBy('cod_spe') + ->orderByRaw('count(*) desc') + ->value('cod_spe'); + + if (! $mostCommonVoceCode) { + continue; + } + + $voceSpesa = \App\Models\VoceSpesa::query() + ->where('stabile_id', $stabileId) + ->where(function ($q) use ($mostCommonVoceCode) { + $q->where('conto_contabile', $mostCommonVoceCode) + ->orWhere('codice', $mostCommonVoceCode) + ->orWhere('legacy_codice', $mostCommonVoceCode); + }) + ->first(); + + if (! $voceSpesa) { + continue; + } + + \App\Models\FornitoreStabileImpostazione::updateOrCreate([ + 'stabile_id' => $stabileId, + 'fornitore_id' => $fornitoreId, + ], [ + 'voce_spesa_default_id' => $voceSpesa->id, + 'meta' => array_merge($imp?->meta ?? [], [ + 'source' => 'legacy_import_auto_link', + 'most_common_legacy_code' => $mostCommonVoceCode, + ]), + ]); + } + } + + public function riconciliaAutomaticamentePagamenti(): void + { + $stabileId = $this->resolveActiveStabileId(); + if (! $stabileId) { + Notification::make()->title('Errore')->body('Nessuno stabile attivo selezionato.')->danger()->send(); + return; + } + + $fornitoreIds = StabileServizio::query() + ->where('stabile_id', $stabileId) + ->where('tipo', 'riscaldamento') + ->whereNotNull('fornitore_id') + ->pluck('fornitore_id') + ->map(fn($value): int => (int) $value) + ->filter(fn(int $value): bool => $value > 0) + ->values() + ->all(); + + if (empty($fornitoreIds)) { + Notification::make()->title('Avviso')->body('Nessun fornitore di riscaldamento censito per questo stabile.')->warning()->send(); + return; + } + + $unpaidInvoices = DB::table('contabilita_fatture_fornitori') + ->where('stabile_id', $stabileId) + ->whereIn('fornitore_id', $fornitoreIds) + ->where(function ($q) { + $q->where('stato', '!=', 'pagato') + ->orWhereNull('stato') + ->orWhereNull('data_pagamento'); + }) + ->get(); + + if ($unpaidInvoices->isEmpty()) { + Notification::make()->title('Riconciliazione completata')->body('Tutte le fatture risultano già pagate o non ci sono fatture registrate.')->info()->send(); + return; + } + + $matchedCount = 0; + + foreach ($unpaidInvoices as $invoice) { + $fornitoreRagioneSociale = DB::table('fornitori') + ->where('id', $invoice->fornitore_id) + ->value('ragione_sociale'); + + // 1. Prova prima con il riferimento preciso FAT-ID + $match = DB::table('contabilita_movimenti_banca') + ->where('stabile_id', $stabileId) + ->where('importo', '<', 0) + ->where(function ($q) use ($invoice) { + $q->where('descrizione', 'like', '%FAT-ID:' . $invoice->id . '%') + ->orWhere('descrizione_estesa', 'like', '%FAT-ID:' . $invoice->id . '%'); + }) + ->first(); + + // 2. Fallback alla ricerca generica per importo e fornitore + if (! $match) { + $match = DB::table('contabilita_movimenti_banca') + ->where('stabile_id', $stabileId) + ->where('importo', '<', 0) + ->where(function ($q) use ($invoice, $fornitoreRagioneSociale) { + $q->where('fornitore_id', $invoice->fornitore_id); + if ($fornitoreRagioneSociale) { + $q->orWhere('descrizione', 'like', '%' . $fornitoreRagioneSociale . '%') + ->orWhere('descrizione_estesa', 'like', '%' . $fornitoreRagioneSociale . '%'); + } + }) + ->where(function ($q) use ($invoice) { + $q->whereRaw('ABS(importo) = ?', [abs((float) $invoice->totale)]) + ->orWhereRaw('ABS(importo) = ?', [abs((float) ($invoice->netto_da_pagare ?? 0))]); + + if (!empty($invoice->numero_documento)) { + $q->orWhere('descrizione', 'like', '%' . $invoice->numero_documento . '%') + ->orWhere('descrizione_estesa', 'like', '%' . $invoice->numero_documento . '%'); + } + }) + ->first(); + } + + if ($match) { + DB::table('contabilita_fatture_fornitori') + ->where('id', $invoice->id) + ->update([ + 'stato' => 'pagato', + 'data_pagamento' => $match->data, + 'movimento_pagamento_id' => $match->id, + ]); + + $matchData = is_string($match->match_data) ? json_decode($match->match_data, true) : (is_array($match->match_data) ? $match->match_data : []); + $matchData['riconciliato_operativo'] = true; + $matchData['riconciliazione_tipo'] = 'fattura_fornitore'; + $matchData['riconciliazione_label'] = 'Pagamento fattura #' . $invoice->numero_documento; + $matchData['riconciliazione_at'] = now()->toDateTimeString(); + $matchData['fattura_fornitore_id'] = $invoice->id; + + DB::table('contabilita_movimenti_banca') + ->where('id', $match->id) + ->update([ + 'fornitore_id' => $invoice->fornitore_id, + 'registrazione_id' => $invoice->registrazione_id, + 'match_data' => json_encode($matchData), + ]); + + $matchedCount++; + } + } + + if ($matchedCount > 0) { + Notification::make()->title('Riconciliazione completata')->body("Abbinati e registrati con successo {$matchedCount} pagamenti di fatture.")->success()->send(); + } else { + Notification::make()->title('Riconciliazione completata')->body('Nessun movimento bancario corrispondente trovato per le fatture da pagare.')->warning()->send(); + } + } +} diff --git a/app/Filament/Pages/Condomini/ServiziStabileArchivio.php b/app/Filament/Pages/Condomini/ServiziStabileArchivio.php index 8d4aed0..7f9fb9d 100755 --- a/app/Filament/Pages/Condomini/ServiziStabileArchivio.php +++ b/app/Filament/Pages/Condomini/ServiziStabileArchivio.php @@ -42,11 +42,13 @@ use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\URL; +use Livewire\WithFileUploads; use UnitEnum; class ServiziStabileArchivio extends Page implements HasTable { use InteractsWithTable; + use WithFileUploads; protected static ?string $navigationLabel = 'Servizi / Beni comuni'; @@ -81,6 +83,16 @@ class ServiziStabileArchivio extends Page implements HasTable /** @var array> */ public array $acquaCampagnaEditRows = []; + public ?string $acquaDataInizioFiltro = null; + public ?string $acquaDataFineFiltro = null; + + public bool $showSendEmailModal = false; + public string $emailDestinatario = ''; + public string $emailOggetto = ''; + public string $emailCorpo = ''; + + public $electronicReadingsFile; + public static function canAccess(): bool { $user = Auth::user(); @@ -95,11 +107,43 @@ public function mount(): void $this->acquaTab = $this->normalizeAcquaTab((string) request()->query('tab', 'dashboard')); $this->resetAcquaUiReadingForm(); $this->mountInteractsWithTable(); + $this->loadAcquaFilterDates(); + $this->initializeEmailDefaults(); } public function setAcquaTab(string $tab): void { $this->acquaTab = $this->normalizeAcquaTab($tab); + $this->loadAcquaFilterDates(); + } + + private function loadAcquaFilterDates(): void + { + $stabileId = $this->resolveActiveStabileId(); + $year = $this->resolveActiveAnnoGestione(); + $service = $this->resolveActiveAcquaServizio(); + + $this->acquaDataInizioFiltro = null; + $this->acquaDataFineFiltro = null; + + if ($service instanceof StabileServizio) { + $periods = $service->meta['periodi_esercizio'] ?? []; + if (!empty($periods[$year]['dal'])) { + $this->acquaDataInizioFiltro = $periods[$year]['dal']; + } + if (!empty($periods[$year]['al'])) { + $this->acquaDataFineFiltro = $periods[$year]['al']; + } + } + } + + private function initializeEmailDefaults(): void + { + $stabileId = $this->resolveActiveStabileId(); + $stabile = $stabileId ? Stabile::query()->find($stabileId) : null; + + $this->emailOggetto = 'Riepilogo Ripartizione Acqua - Stabile ' . ($stabile?->denominazione ?? ''); + $this->emailCorpo = "Gentile collaboratore,\nin allegato trovi il PDF del riepilogo contatori acqua per lo stabile in oggetto.\n\nCordiali saluti,\nL'Amministrazione"; } public function toggleAcquaFeSelection(int $fatturaId): void @@ -338,6 +382,353 @@ public function deleteAcquaUiReading(int $readingId): void Notification::make()->title('Lettura UI eliminata')->success()->send(); } + public function saveAcquaFilterDates(): void + { + $stabileId = $this->resolveActiveStabileId(); + $year = $this->resolveActiveAnnoGestione(); + $service = $this->resolveActiveAcquaServizio(); + + if (! $stabileId || ! $service instanceof StabileServizio) { + Notification::make()->title('Configura prima un servizio acqua attivo.')->warning()->send(); + return; + } + + $meta = $service->meta ?? []; + $periods = $meta['periodi_esercizio'] ?? []; + + $dal = trim((string) $this->acquaDataInizioFiltro) ?: null; + $al = trim((string) $this->acquaDataFineFiltro) ?: null; + + if ($dal || $al) { + $periods[$year] = [ + 'dal' => $dal, + 'al' => $al + ]; + + // PROPAGAZIONE AUTOMATICA ANNI PRECEDENTI E SUCCESSIVI + $dalCarbon = \Illuminate\Support\Carbon::parse($dal); + $alCarbon = \Illuminate\Support\Carbon::parse($al); + + $otherYears = \App\Models\GestioneContabile::query() + ->where('stabile_id', $stabileId) + ->where('tipo_gestione', 'ordinaria') + ->pluck('anno_gestione') + ->toArray(); + + foreach ($otherYears as $oy) { + if ($oy != $year && (! isset($periods[$oy]) || empty($periods[$oy]['dal']) || empty($periods[$oy]['al']))) { + $diff = $oy - $year; + $periods[$oy] = [ + 'dal' => $dalCarbon->copy()->addYears($diff)->format('Y-m-d'), + 'al' => $alCarbon->copy()->addYears($diff)->format('Y-m-d'), + ]; + } + } + } else { + unset($periods[$year]); + } + + $meta['periodi_esercizio'] = $periods; + $service->meta = $meta; + $service->save(); + + // Spostamento automatico delle fatture (Aggiornamento gestione_id) + if ($dal && $al) { + $gestione = \App\Models\GestioneContabile::query() + ->where('stabile_id', $stabileId) + ->where('anno_gestione', $year) + ->where('tipo_gestione', 'ordinaria') + ->first(); + + if ($gestione) { + $fornitoreIds = StabileServizio::query() + ->where('stabile_id', $stabileId) + ->where('tipo', 'acqua') + ->whereNotNull('fornitore_id') + ->pluck('fornitore_id') + ->map(fn($value): int => (int) $value) + ->filter(fn(int $value): bool => $value > 0) + ->values() + ->all(); + + if ($fornitoreIds === []) { + $preferredSupplier = $this->resolvePreferredAcquaSupplier($stabileId); + if ($preferredSupplier instanceof Fornitore) { + $fornitoreIds = [(int) $preferredSupplier->id]; + } + } + + $fornitoreIds = $this->resolveEquivalentFornitoreIds($fornitoreIds); + + // Aggiorna le fatture in contabilita_fatture_fornitori + if (Schema::hasTable('contabilita_fatture_fornitori')) { + $q = DB::table('contabilita_fatture_fornitori') + ->where('stabile_id', $stabileId) + ->whereBetween('data_documento', [$dal, $al]); + + if ($fornitoreIds !== []) { + $q->whereIn('fornitore_id', $fornitoreIds); + } + + $q->update(['gestione_id' => $gestione->id]); + + // Inoltre, aggiorna le FE associate + $feIds = DB::table('fatture_elettroniche') + ->where('stabile_id', $stabileId) + ->whereBetween('data_fattura', [$dal, $al]) + ->when($fornitoreIds !== [], fn($b) => $b->whereIn('fornitore_id', $fornitoreIds)) + ->pluck('id') + ->all(); + + if (!empty($feIds)) { + DB::table('contabilita_fatture_fornitori') + ->where('stabile_id', $stabileId) + ->whereIn('fattura_elettronica_id', $feIds) + ->update(['gestione_id' => $gestione->id]); + } + } + } + } + + Notification::make()->title('Filtro date salvato e fatture allineate con successo.')->success()->send(); + + // Refresh properties + $this->loadAcquaFilterDates(); + } + + public function sendRipartizioneEmail(): void + { + $email = trim($this->emailDestinatario); + if ($email === '') { + Notification::make()->title('Destinatario mancante')->danger()->send(); + return; + } + + $stabileId = $this->resolveActiveStabileId(); + $year = $this->resolveActiveAnnoGestione(); + + try { + $request = new \Illuminate\Http\Request([ + 'stabile_id' => $stabileId, + 'year' => $year, + ]); + $selectedIds = $this->normalizeAcquaSelectedFeIds(); + if ($selectedIds !== []) { + $request->merge(['ids' => implode(',', $selectedIds)]); + } + + // Imposta l'utente loggato sulla request + $request->setUserResolver(fn() => Auth::user()); + + $printController = new \App\Http\Controllers\Admin\AcquaRipartizionePrintController(); + $reportResponse = $printController->pdf($request); + $pdfContent = $reportResponse->getContent(); + + Mail::send([], [], function ($message) use ($email, $pdfContent, $year): void { + $message->to($email) + ->subject($this->emailOggetto) + ->html(nl2br(e($this->emailCorpo))) + ->attachData($pdfContent, 'riepilogo-acqua-' . $year . '.pdf', [ + 'mime' => 'application/pdf', + ]); + }); + + $this->showSendEmailModal = false; + Notification::make()->title('Email inviata con successo.')->success()->send(); + } catch (\Throwable $e) { + Notification::make()->title('Errore durante l invio dell email')->danger()->body($e->getMessage())->send(); + } + } + + public function importElectronicReadings(): void + { + if (! $this->electronicReadingsFile) { + Notification::make()->title('Carica prima un file CSV.')->warning()->send(); + return; + } + + $stabileId = $this->resolveActiveStabileId(); + $servizio = $this->resolveActiveAcquaServizio(); + if (! $stabileId || ! $servizio) { + Notification::make()->title('Nessun servizio acqua configurato per lo stabile attivo.')->danger()->send(); + return; + } + + $user = Auth::user(); + $filePath = $this->electronicReadingsFile->getRealPath(); + + $handle = fopen($filePath, 'r'); + if (! $handle) { + Notification::make()->title('Impossibile aprire il file caricato.')->danger()->send(); + return; + } + + $headers = fgetcsv($handle, 0, ','); + if (! $headers) { + fclose($handle); + Notification::make()->title('File CSV vuoto o non valido.')->danger()->send(); + return; + } + + // Trova gli indici dei campi + $idIdx = -1; + $internoIdx = -1; + $letturaIdx = -1; + + foreach ($headers as $idx => $header) { + $headerClean = strtolower(trim((string) $header)); + if ($headerClean === '#id' || $headerClean === 'id' || $idx === 0) { + if ($idIdx === -1) $idIdx = $idx; + } + if ($headerClean === 'interno' || $headerClean === 'int') { + $internoIdx = $idx; + } + if ($headerClean === 'lettura' || $headerClean === 'valore') { + $letturaIdx = $idx; + } + } + + // Fallbacks se non trova per nome + if ($idIdx === -1) $idIdx = 0; + if ($internoIdx === -1) $internoIdx = 6; // Default standard del tracciato + if ($letturaIdx === -1) $letturaIdx = 7; // Default standard del tracciato + + $imported = 0; + $linked = 0; + $errors = 0; + $year = $this->resolveActiveAnnoGestione(); + + while (($row = fgetcsv($handle, 0, ',')) !== false) { + if (count($row) <= max($idIdx, $internoIdx, $letturaIdx)) { + continue; + } + + $deviceId = trim((string) ($row[$idIdx] ?? '')); + $internoCsv = trim((string) ($row[$internoIdx] ?? '')); + $letturaStr = trim((string) ($row[$letturaIdx] ?? '')); + + if ($deviceId === '' && $internoCsv === '') { + continue; + } + + // Pulisci il valore della lettura + $letturaClean = preg_replace('/[^\d,\.]/', '', $letturaStr); + $letturaClean = str_replace(',', '.', $letturaClean); + if (! is_numeric($letturaClean)) { + $errors++; + continue; + } + $finalValue = round((float) $letturaClean, 3); + + // Cerca l'unità immobiliare + $unit = null; + if ($deviceId !== '') { + $unit = UnitaImmobiliare::query() + ->where('stabile_id', $stabileId) + ->where('acqua_gateway_device_id', $deviceId) + ->whereNull('deleted_at') + ->first(); + } + + if (! $unit && $internoCsv !== '') { + $unit = UnitaImmobiliare::query() + ->where('stabile_id', $stabileId) + ->where(function($query) use ($internoCsv) { + $query->where('interno', $internoCsv) + ->orWhere('codice_unita', $internoCsv); + }) + ->whereNull('deleted_at') + ->first(); + + if ($unit && $deviceId !== '') { + $unit->acqua_gateway_device_id = $deviceId; + $unit->save(); + $linked++; + } + } + + if (! $unit) { + $errors++; + continue; + } + + // Trova la lettura precedente + $previous = StabileServizioLettura::query() + ->where('stabile_id', $stabileId) + ->where('stabile_servizio_id', (int) $servizio->id) + ->where('unita_immobiliare_id', $unit->id) + ->whereNotNull('lettura_fine') + ->orderByDesc('periodo_al') + ->orderByDesc('id') + ->first(); + + $previousValue = $previous?->lettura_fine !== null ? (float) $previous->lettura_fine : null; + $consumo = ($previousValue !== null && $finalValue >= $previousValue) ? round($finalValue - $previousValue, 3) : null; + + // Cerca se esiste già una lettura per quest'anno + $current = StabileServizioLettura::query() + ->where('stabile_id', $stabileId) + ->where('stabile_servizio_id', (int) $servizio->id) + ->where('unita_immobiliare_id', $unit->id) + ->whereYear('created_at', $year) + ->first(); + + $payload = [ + 'stabile_id' => $stabileId, + 'stabile_servizio_id' => (int) $servizio->id, + 'unita_immobiliare_id' => $unit->id, + 'fornitore_id' => $servizio->fornitore_id, + 'periodo_dal' => $previous?->periodo_al, + 'periodo_al' => now()->toDateString(), + 'tipologia_lettura' => 'elettronica_remota', + 'canale_acquisizione' => 'dispositivo_remoto', + 'riferimento_acquisizione' => 'Gateway ID ' . $deviceId, + 'workflow_stato' => 'ricevuta', + 'rilevatore_tipo' => 'sistema_remoto', + 'rilevatore_nome' => 'Gateway Elettronico', + 'lettura_precedente_valore' => $previousValue, + 'lettura_inizio' => $previousValue, + 'lettura_fine' => $finalValue, + 'consumo_valore' => $consumo, + 'consumo_unita' => 'mc', + 'raw' => [ + 'csv_import' => [ + 'device_id' => $deviceId, + 'interno' => $internoCsv, + 'original' => $row, + 'imported_at' => now()->toIso8601String(), + 'imported_by' => $user?->id, + ] + ] + ]; + + if ($current) { + $current->fill($payload); + $current->save(); + } else { + $payload['created_by'] = $user?->id; + StabileServizioLettura::query()->create($payload); + } + + $imported++; + } + + fclose($handle); + $this->electronicReadingsFile = null; + + $msg = "Caricate con successo $imported letture."; + if ($linked > 0) { + $msg .= " Associate $linked nuove unità via interno."; + } + if ($errors > 0) { + $msg .= " Saltate/non abbinate $errors righe."; + } + + Notification::make()->title('Importazione completata')->body($msg)->success()->send(); + + $this->dispatch('refresh-acqua-fe-selections'); + } + private function normalizeAcquaTab(string $tab): string { $allowed = ['dashboard', 'fatture', 'letture', 'generale', 'tariffe', 'servizi']; @@ -1338,12 +1729,20 @@ private function resolveAcquaCandidateInvoices(?string $tipoGestione = null) $fornitoreIds = $this->resolveEquivalentFornitoreIds($fornitoreIds); + $dal = $this->acquaDataInizioFiltro; + $al = $this->acquaDataFineFiltro; + $query = FatturaElettronica::query() ->where('stabile_id', $stabileId) - ->whereYear('data_fattura', $year) ->orderByDesc('data_fattura') ->orderByDesc('id'); + if ($dal && $al) { + $query->whereBetween('data_fattura', [$dal, $al]); + } else { + $query->whereYear('data_fattura', $year); + } + if ($fornitoreIds !== []) { $query->whereIn('fornitore_id', $fornitoreIds); } @@ -1355,8 +1754,11 @@ private function resolveAcquaCandidateInvoices(?string $tipoGestione = null) ->whereNotNull('f.fattura_elettronica_id') ->when($fornitoreIds !== [], fn($builder) => $builder->whereIn('f.fornitore_id', $fornitoreIds)) ->where('g.tipo_gestione', $tipoGestione) - ->when(Schema::hasColumn('gestioni_contabili', 'anno_gestione'), fn($builder) => $builder->where('g.anno_gestione', $year)) - ->when(! Schema::hasColumn('gestioni_contabili', 'anno_gestione') && Schema::hasColumn('contabilita_fatture_fornitori', 'data_documento'), fn($builder) => $builder->whereYear('f.data_documento', $year)) + ->when($dal && $al, fn($builder) => $builder->whereBetween('f.data_documento', [$dal, $al])) + ->when(!($dal && $al), function($builder) use ($year) { + $builder->when(Schema::hasColumn('gestioni_contabili', 'anno_gestione'), fn($b) => $b->where('g.anno_gestione', $year)) + ->when(! Schema::hasColumn('gestioni_contabili', 'anno_gestione') && Schema::hasColumn('contabilita_fatture_fornitori', 'data_documento'), fn($b) => $b->whereYear('f.data_documento', $year)); + }) ->pluck('f.fattura_elettronica_id') ->map(fn($value): int => (int) $value) ->filter(fn(int $value): bool => $value > 0) @@ -1665,10 +2067,23 @@ public function getAcquaLegacyOperazioniSummaryProperty(): array $query->whereYear('dt_spe', $year); } + $columns = Schema::connection('gescon_import')->getColumnListing('operazioni'); + $sumFields = []; + if (in_array('importo_euro', $columns, true)) { + $sumFields[] = 'importo_euro'; + } + if (in_array('importo', $columns, true)) { + $sumFields[] = 'importo'; + } + $sumSql = '0'; + if ($sumFields !== []) { + $sumSql = 'COALESCE(' . implode(', ', $sumFields) . ', 0)'; + } + $rows = $query ->select('cod_spe') ->selectRaw('COUNT(*) as righe') - ->selectRaw('SUM(COALESCE(importo_euro, importo, 0)) as totale') + ->selectRaw("SUM($sumSql) as totale") ->groupBy('cod_spe') ->get(); @@ -3132,7 +3547,7 @@ public function getAcquaCampagnaRowsProperty(): array $currentYearByUnit[$unitId] = $row; } - if ($row->lettura_fine !== null && ! isset($latestRealByUnit[$unitId])) { + if ($row->lettura_fine !== null && (int) $rowYear !== $year && ! isset($latestRealByUnit[$unitId])) { $latestRealByUnit[$unitId] = $row; } } @@ -3166,7 +3581,7 @@ public function getAcquaCampagnaRowsProperty(): array 'latest_value' => $current?->lettura_fine, 'edit' => $this->resolveAcquaCampagnaEditRow((int) $unit->id, $current, $latestReal), 'public_url' => $this->buildPublicAcquaReadingUrl((int) $servizio->id, (int) $unit->id, $current?->id ? (int) $current->id : null), - 'ticket_url' => TicketAcqua::getUrl([ + 'ticket_url' => \App\Filament\Pages\Supporto\TicketAcquaLight::getUrl([ 'stabile' => $stabileId, 'servizio' => (int) $servizio->id, 'unita' => (int) $unit->id, @@ -3386,9 +3801,22 @@ public function getAcquaAltreVociLegacyProperty(): array $query->whereYear('dt_spe', $year); } + $columns = Schema::connection('gescon_import')->getColumnListing('operazioni'); + $sumFields = []; + if (in_array('importo_euro', $columns, true)) { + $sumFields[] = 'importo_euro'; + } + if (in_array('importo', $columns, true)) { + $sumFields[] = 'importo'; + } + $sumSql = '0'; + if ($sumFields !== []) { + $sumSql = 'COALESCE(' . implode(', ', $sumFields) . ', 0)'; + } + $rows = $query ->select('cod_spe') - ->selectRaw('SUM(COALESCE(importo_euro, importo, 0)) as totale') + ->selectRaw("SUM($sumSql) as totale") ->groupBy('cod_spe') ->orderBy('cod_spe') ->get(); diff --git a/app/Filament/Pages/Condomini/TabelleMillesimaliArchivio.php b/app/Filament/Pages/Condomini/TabelleMillesimaliArchivio.php index 685ac66..a6070c5 100755 --- a/app/Filament/Pages/Condomini/TabelleMillesimaliArchivio.php +++ b/app/Filament/Pages/Condomini/TabelleMillesimaliArchivio.php @@ -262,6 +262,12 @@ public function table(Table $table): Table ->default(1000) ->rules(['nullable', 'numeric', 'min:0']), + TextInput::make('nord') + ->label('NORD') + ->numeric() + ->rules(['nullable', 'integer', 'min:0']) + ->helperText('Ordinamento legacy (es. 10 per TAB.A, 20 per TAB.A1).'), + TextInput::make('ordine_visualizzazione') ->label('Ord. (UI)') ->numeric() @@ -314,6 +320,7 @@ public function table(Table $table): Table 'totale_millesimi' => is_numeric($data['totale_millesimi'] ?? null) ? (float) $data['totale_millesimi'] : null, 'ordine_visualizzazione' => is_numeric($data['ordine_visualizzazione'] ?? null) ? (int) $data['ordine_visualizzazione'] : null, 'ordinamento' => is_numeric($data['ordinamento'] ?? null) ? (int) $data['ordinamento'] : null, + 'nord' => is_numeric($data['nord'] ?? null) ? (int) $data['nord'] : null, 'attiva' => (bool) ($data['attiva'] ?? true), 'note' => trim((string) ($data['note'] ?? '')) ?: null, 'meta_legacy' => $meta, @@ -526,12 +533,14 @@ public function table(Table $table): Table ->icon(fn(TabellaMillesimale $record): string => in_array((int) $record->id, $this->normalizeSelectedArchiveIds(), true) ? 'heroicon-o-check-circle' : 'heroicon-o-plus-circle') ->color(fn(TabellaMillesimale $record): string => in_array((int) $record->id, $this->normalizeSelectedArchiveIds(), true) ? 'primary' : 'gray') ->tooltip(fn(TabellaMillesimale $record): string => $this->getArchiveCleanupTooltip($record)) - ->action(fn(TabellaMillesimale $record): mixed => $this->toggleArchiveSelection((int) $record->id)), + ->action(fn(TabellaMillesimale $record): mixed => $this->toggleArchiveSelection((int) $record->id)) + ->iconButton(), Action::make('edit') ->label('Modifica') ->icon('heroicon-o-pencil-square') ->modalHeading('Modifica tabella millesimale') + ->iconButton() ->form([ TextInput::make('codice_tabella') ->label('Codice') @@ -558,6 +567,12 @@ public function table(Table $table): Table ->numeric() ->rules(['nullable', 'numeric', 'min:0']), + TextInput::make('nord') + ->label('NORD') + ->numeric() + ->rules(['nullable', 'integer', 'min:0']) + ->helperText('Ordinamento legacy (es. 10 per TAB.A, 20 per TAB.A1).'), + TextInput::make('ordine_visualizzazione') ->label('Ord. (UI)') ->numeric() @@ -599,6 +614,7 @@ public function table(Table $table): Table 'totale_millesimi' => $record->getRawOriginal('totale_millesimi'), 'ordine_visualizzazione' => $record->ordine_visualizzazione, 'ordinamento' => $record->ordinamento, + 'nord' => $record->nord, 'attiva' => (bool) ($record->attiva ?? true), 'note' => (string) ($record->note ?? ''), ]; @@ -630,6 +646,7 @@ public function table(Table $table): Table 'totale_millesimi' => is_numeric($data['totale_millesimi'] ?? null) ? (float) $data['totale_millesimi'] : null, 'ordine_visualizzazione' => is_numeric($data['ordine_visualizzazione'] ?? null) ? (int) $data['ordine_visualizzazione'] : null, 'ordinamento' => is_numeric($data['ordinamento'] ?? null) ? (int) $data['ordinamento'] : null, + 'nord' => is_numeric($data['nord'] ?? null) ? (int) $data['nord'] : null, 'attiva' => (bool) ($data['attiva'] ?? true), 'note' => trim((string) ($data['note'] ?? '')) ?: null, 'meta_legacy' => $meta, @@ -648,12 +665,14 @@ public function table(Table $table): Table ->icon('heroicon-o-trash') ->color('danger') ->requiresConfirmation() - ->action(fn(TabellaMillesimale $record): mixed => $this->deleteSingleArchive($record)), + ->action(fn(TabellaMillesimale $record): mixed => $this->deleteSingleArchive($record)) + ->iconButton(), Action::make('prospetto') ->label('Prospetto') ->icon('heroicon-o-rectangle-group') - ->url(fn(TabellaMillesimale $record) => static::getUrl(panel: 'admin-filament') . '?tab=prospetto&tabella_id=' . (int) $record->id), + ->url(fn(TabellaMillesimale $record) => static::getUrl(panel: 'admin-filament') . '?tab=prospetto&tabella_id=' . (int) $record->id) + ->iconButton(), ]); } @@ -961,6 +980,14 @@ protected function loadTabelle(): void $prev = $meta['tot_prev_euro'] ?? $meta['tot_prev'] ?? null; $cons = $meta['tot_cons_euro'] ?? $meta['tot_cons'] ?? null; + $vociCount = 0; + if (Schema::hasTable('voci_spesa')) { + $vociCount = DB::table('voci_spesa') + ->where('stabile_id', $this->stabileId) + ->where('tabella_millesimale_default_id', $t->id) + ->count(); + } + return [ 'id' => (int) $t->id, 'codice' => $codice, @@ -974,6 +1001,7 @@ protected function loadTabelle(): void 'is_bilanciata' => (bool) ($t->is_bilanciata ?? false), 'preventivo' => is_numeric($prev) ? (float) $prev : null, 'consuntivo' => is_numeric($cons) ? (float) $cons : null, + 'voci_count' => $vociCount, ]; }) ->values() @@ -1073,6 +1101,7 @@ protected function loadDettaglioTabella(): void $percentuale = $totale > 0 ? ($millesimi / $totale) * 100 : 0; return [ + 'id' => (int) $d->id, 'unita_id' => (int) ($u?->id ?? 0), 'codice_unita' => $u?->codice_unita ?? ($u?->codice_completo ?? null), 'codice_unita_display' => $this->formatCodiceUnita($u?->codice_unita ?? ($u?->codice_completo ?? null)), @@ -1084,6 +1113,8 @@ protected function loadDettaglioTabella(): void 'millesimi' => $millesimi, 'percentuale' => round($percentuale, 4), 'partecipa' => (bool) ($d->partecipa ?? true), + 'nord' => $d->nord, + 'ruolo_legacy' => $d->ruolo_legacy, ]; }) ->values() @@ -1512,10 +1543,13 @@ public function saveRighe(): void $validated = Validator::make( ['righe' => $this->righe], [ - 'righe' => ['array'], - 'righe.*.unita_id' => ['required', 'integer', 'min:1'], - 'righe.*.millesimi' => ['required', 'numeric', 'min:0'], - 'righe.*.partecipa' => ['nullable', 'boolean'], + 'righe' => ['array'], + 'righe.*.id' => ['nullable', 'integer'], + 'righe.*.unita_id' => ['required', 'integer', 'min:1'], + 'righe.*.millesimi' => ['required', 'numeric', 'min:0'], + 'righe.*.partecipa' => ['nullable', 'boolean'], + 'righe.*.nord' => ['nullable', 'integer'], + 'righe.*.ruolo_legacy' => ['nullable', 'string', 'max:100'], ] )->validate(); @@ -1548,16 +1582,28 @@ public function saveRighe(): void $millesimi = (float) ($r['millesimi'] ?? 0); $partecipa = ! empty($r['partecipa']) ? 1 : 0; + $nord = isset($r['nord']) && is_numeric($r['nord']) ? (int) $r['nord'] : null; + $ruolo = isset($r['ruolo_legacy']) ? trim((string) $r['ruolo_legacy']) : null; + $id = isset($r['id']) ? (int) $r['id'] : null; - $exists = DettaglioMillesimi::query() - ->where('tabella_millesimale_id', $this->tabellaId) - ->where('unita_immobiliare_id', $unitaId) - ->first(); + $exists = null; + if ($id) { + $exists = DettaglioMillesimi::find($id); + } + if (! $exists) { + $exists = DettaglioMillesimi::query() + ->where('tabella_millesimale_id', $this->tabellaId) + ->where('unita_immobiliare_id', $unitaId) + ->where('ruolo_legacy', $ruolo) + ->first(); + } if ($exists) { $exists->update([ - 'millesimi' => $millesimi, - 'partecipa' => $partecipa, + 'millesimi' => $millesimi, + 'partecipa' => $partecipa, + 'nord' => $nord, + 'ruolo_legacy' => $ruolo ?: null, ]); continue; } @@ -1567,6 +1613,8 @@ public function saveRighe(): void 'unita_immobiliare_id' => $unitaId, 'millesimi' => $millesimi, 'partecipa' => $partecipa, + 'nord' => $nord, + 'ruolo_legacy' => $ruolo ?: null, 'created_by' => $userId, 'created_at' => $now, 'updated_at' => $now, diff --git a/app/Filament/Pages/Condomini/TabelleMillesimaliProspetto.php b/app/Filament/Pages/Condomini/TabelleMillesimaliProspetto.php deleted file mode 100755 index 86d4fc9..0000000 --- a/app/Filament/Pages/Condomini/TabelleMillesimaliProspetto.php +++ /dev/null @@ -1,500 +0,0 @@ -> - */ - public array $tabelle = []; - - /** - * @var array> - */ - public array $righe = []; - - public ?array $tabellaInfo = null; - - protected static ?string $navigationLabel = 'Tabelle millesimali · Prospetto'; - - protected static ?string $title = 'Tabelle millesimali · Prospetto'; - - protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-chart-pie'; - - protected static UnitEnum|string|null $navigationGroup = 'Stabile'; - - protected static ?int $navigationSort = 41; - - protected static ?string $slug = 'condomini/tabelle-millesimali/prospetto'; - - protected string $view = 'filament.pages.condomini.tabelle-millesimali-prospetto'; - - public function inizializzaRighe(): void - { - if (! $this->stabileId || ! $this->tabellaId) { - return; - } - - if (! Schema::hasTable('dettaglio_millesimi')) { - Notification::make() - ->title('Tabella dettagli non disponibile') - ->body('La tabella dettaglio_millesimi non esiste nel database.') - ->danger() - ->send(); - return; - } - - $unitaIds = UnitaImmobiliare::query() - ->where('stabile_id', $this->stabileId) - ->orderBy('id') - ->pluck('id') - ->map(fn($v) => (int) $v) - ->all(); - - if ($unitaIds === []) { - Notification::make() - ->title('Nessuna unità') - ->body('Non ci sono unità immobiliari nello stabile attivo.') - ->warning() - ->send(); - return; - } - - DB::transaction(function () use ($unitaIds): void { - $existing = DettaglioMillesimi::query() - ->where('tabella_millesimale_id', $this->tabellaId) - ->whereIn('unita_immobiliare_id', $unitaIds) - ->pluck('unita_immobiliare_id') - ->map(fn($v) => (int) $v) - ->all(); - - $missing = array_values(array_diff($unitaIds, $existing)); - if ($missing === []) { - return; - } - - $userId = Auth::id(); - - $rows = array_map(function (int $unitaId) use ($userId): array { - return [ - 'tabella_millesimale_id' => (int) $this->tabellaId, - 'unita_immobiliare_id' => $unitaId, - 'millesimi' => 0, - 'partecipa' => 1, - 'created_by' => $userId, - 'created_at' => now(), - 'updated_at' => now(), - ]; - }, $missing); - - DB::table('dettaglio_millesimi')->insert($rows); - }); - - $this->loadDettaglioTabella(); - - Notification::make() - ->title('Righe inizializzate') - ->success() - ->send(); - } - - public function saveRighe(): void - { - if (! $this->stabileId || ! $this->tabellaId) { - return; - } - - $validated = Validator::make( - ['righe' => $this->righe], - [ - 'righe' => ['array'], - 'righe.*.unita_id' => ['required', 'integer', 'min:1'], - 'righe.*.millesimi' => ['required', 'numeric', 'min:0'], - 'righe.*.partecipa' => ['nullable', 'boolean'], - ] - )->validate(); - - /** @var array> $rows */ - $rows = (array) ($validated['righe'] ?? []); - $unitaIds = array_values(array_unique(array_map(fn($r) => (int) ($r['unita_id'] ?? 0), $rows))); - $unitaIds = array_values(array_filter($unitaIds, fn(int $v) => $v > 0)); - - if ($unitaIds === []) { - return; - } - - $allowedUnita = UnitaImmobiliare::query() - ->where('stabile_id', $this->stabileId) - ->whereIn('id', $unitaIds) - ->pluck('id') - ->map(fn($v) => (int) $v) - ->all(); - - $allowedSet = array_fill_keys($allowedUnita, true); - $now = now(); - $userId = Auth::id(); - - DB::transaction(function () use ($rows, $allowedSet, $now, $userId): void { - foreach ($rows as $r) { - $unitaId = (int) ($r['unita_id'] ?? 0); - if ($unitaId <= 0 || ! isset($allowedSet[$unitaId])) { - continue; - } - - $millesimi = (float) ($r['millesimi'] ?? 0); - $partecipa = ! empty($r['partecipa']) ? 1 : 0; - - $exists = DettaglioMillesimi::query() - ->where('tabella_millesimale_id', $this->tabellaId) - ->where('unita_immobiliare_id', $unitaId) - ->first(); - - if ($exists) { - $exists->update([ - 'millesimi' => $millesimi, - 'partecipa' => $partecipa, - ]); - continue; - } - - DettaglioMillesimi::query()->create([ - 'tabella_millesimale_id' => (int) $this->tabellaId, - 'unita_immobiliare_id' => $unitaId, - 'millesimi' => $millesimi, - 'partecipa' => $partecipa, - 'created_by' => $userId, - 'created_at' => $now, - 'updated_at' => $now, - ]); - } - }); - - $this->loadDettaglioTabella(); - - Notification::make() - ->title('Millesimi salvati') - ->success() - ->send(); - } - - public function getBackUrl(): ?string - { - $candidate = request()->query('back'); - if (! is_string($candidate) || trim($candidate) === '') { - return null; - } - - $candidate = trim($candidate); - - // Allow relative URLs only. - if (str_starts_with($candidate, '/')) { - return $candidate; - } - - $parts = parse_url($candidate); - if (! is_array($parts)) { - return null; - } - - $host = $parts['host'] ?? null; - if ($host && $host !== request()->getHost()) { - return null; - } - - $path = $parts['path'] ?? null; - if (! is_string($path) || $path === '') { - return null; - } - - $query = isset($parts['query']) ? ('?' . $parts['query']) : ''; - return $path . $query; - } - - public static function canAccess(): bool - { - $user = Auth::user(); - return $user instanceof User - && $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore']); - } - - public function mount(): void - { - $user = Auth::user(); - if (! $user instanceof User) { - abort(403); - } - - $stabileId = StabileContext::resolveActiveStabileId($user); - if (! $stabileId) { - Notification::make() - ->title('Nessuno stabile disponibile') - ->body('Seleziona uno stabile per vedere le tabelle millesimali.') - ->warning() - ->send(); - - return; - } - - $this->stabileId = $stabileId; - $this->annoGestione = AnnoGestioneContext::resolveActiveAnno($user); - - $this->loadTabelle(); - - $requested = request()->integer('tabella_id') ?: null; - $this->tabellaId = $this->pickTabellaId($requested); - - $this->loadDettaglioTabella(); - } - - protected function loadTabelle(): void - { - $q = TabellaMillesimale::query() - ->perStabile($this->stabileId) - ->when(Schema::hasColumn('tabelle_millesimali', 'anno_gestione') && $this->annoGestione, function ($qq) { - $qq->where(function ($q2) { - $q2->where('anno_gestione', $this->annoGestione)->orWhereNull('anno_gestione'); - })->orderByRaw('CASE WHEN anno_gestione IS NULL THEN 1 ELSE 0 END'); - }) - ->ordinato(); - - $this->tabelle = $q - ->get(['id', 'codice_tabella', 'nome_tabella', 'denominazione', 'tipo_tabella', 'tipo_calcolo', 'totale_millesimi', 'ordinamento', 'meta_legacy']) - ->map(function (TabellaMillesimale $t) { - $codice = $t->codice_tabella ?: ($t->nome_tabella ?: ('TAB ' . $t->id)); - $nome = $t->denominazione ?: ($t->nome_tabella_millesimale ?: ($t->nome_tabella ?: 'Tabella')); - - $calcoloRaw = $this->resolveCalcoloRaw($t); - $isConsumo = $this->isConsumoCalcolo($calcoloRaw); - $calcoloLabel = $this->formatCalcoloLabel($calcoloRaw); - $tipoLabel = $this->resolveTipoLabel($t, $calcoloRaw); - $meta = is_array($t->meta_legacy) - ? $t->meta_legacy - : (is_string($t->meta_legacy) ? json_decode($t->meta_legacy, true) : null); - $meta = is_array($meta) ? $meta : []; - $prev = $meta['tot_prev_euro'] ?? $meta['tot_prev'] ?? null; - $cons = $meta['tot_cons_euro'] ?? $meta['tot_cons'] ?? null; - - return [ - 'id' => (int) $t->id, - 'codice' => $codice, - 'nome' => $nome, - 'tipo' => $tipoLabel, - 'calcolo' => $calcoloLabel, - 'is_consumo' => $isConsumo, - 'totale' => is_numeric($t->totale_millesimi) ? (float) $t->totale_millesimi : null, - 'ordinamento' => (int) ($t->ordinamento ?? 999999), - 'is_bilanciata' => (bool) ($t->is_bilanciata ?? false), - 'preventivo' => is_numeric($prev) ? (float) $prev : null, - 'consuntivo' => is_numeric($cons) ? (float) $cons : null, - ]; - }) - ->values() - ->all(); - } - - protected function pickTabellaId(?int $candidate): ?int - { - if ($candidate && collect($this->tabelle)->contains(fn($t) => (int) $t['id'] === $candidate)) { - return $candidate; - } - - $first = $this->tabelle[0]['id'] ?? null; - return is_numeric($first) ? (int) $first : null; - } - - protected function loadDettaglioTabella(): void - { - $this->righe = []; - $this->tabellaInfo = null; - - if (! $this->tabellaId) { - return; - } - - $tabella = TabellaMillesimale::query() - ->perStabile($this->stabileId) - ->with(['dettagliMillesimali.unitaImmobiliare', 'dettagliMillesimali.unitaImmobiliare.palazzinaObj']) - ->when(Schema::hasColumn('tabelle_millesimali', 'anno_gestione') && $this->annoGestione, function ($qq) { - $qq->where(function ($q2) { - $q2->where('anno_gestione', $this->annoGestione)->orWhereNull('anno_gestione'); - }); - }) - ->whereKey($this->tabellaId) - ->first(); - - if (! $tabella) { - return; - } - - $totale = (float) ($tabella->calcolaTotaleMillesimi() ?? 0); - $codice = $tabella->codice_tabella ?: ($tabella->nome_tabella ?: ('TAB ' . $tabella->id)); - $nome = $tabella->denominazione ?: ($tabella->nome_tabella_millesimale ?: ($tabella->nome_tabella ?: 'Tabella')); - - $this->tabellaInfo = [ - 'id' => (int) $tabella->id, - 'codice' => $codice, - 'nome' => $nome, - 'tipo' => $this->resolveTipoLabel($tabella, $this->resolveCalcoloRaw($tabella)), - 'calcolo' => $this->formatCalcoloLabel($this->resolveCalcoloRaw($tabella)), - 'is_consumo' => $this->isConsumoCalcolo($this->resolveCalcoloRaw($tabella)), - 'totale' => $totale, - 'is_bilanciata' => (bool) $tabella->isBilanciata(), - 'preventivo' => $this->resolveLegacyImporto($tabella, 'tot_prev_euro', 'tot_prev'), - 'consuntivo' => $this->resolveLegacyImporto($tabella, 'tot_cons_euro', 'tot_cons'), - ]; - - $this->righe = $tabella->dettagliMillesimali - ->filter(fn($d) => $d->unitaImmobiliare !== null) - ->sortBy(function ($d) { - $u = $d->unitaImmobiliare; - return sprintf( - '%s|%s|%s|%s|%010d', - $u?->palazzina ?? '', - $u?->scala ?? '', - str_pad((string) ($u?->piano ?? 0), 6, '0', STR_PAD_LEFT), - $u?->interno ?? '', - (int) ($u?->id ?? 0) - ); - }) - ->map(function ($d) use ($totale) { - $u = $d->unitaImmobiliare; - $millesimi = (float) ($d->millesimi ?? 0); - $percentuale = $totale > 0 ? ($millesimi / $totale) * 100 : 0; - - return [ - 'unita_id' => (int) ($u?->id ?? 0), - 'codice_unita' => $u?->codice_unita ?? ($u?->codice_completo ?? null), - 'denominazione' => $u?->denominazione, - 'palazzina' => $u?->palazzina, - 'scala' => $u?->scala, - 'piano' => $u?->piano, - 'interno' => $u?->interno, - 'millesimi' => $millesimi, - 'percentuale' => round($percentuale, 4), - 'partecipa' => (bool) ($d->partecipa ?? true), - ]; - }) - ->values() - ->all(); - } - - private function resolveCalcoloRaw(TabellaMillesimale $t): ?string - { - $meta = is_array($t->meta_legacy) - ? $t->meta_legacy - : (is_string($t->meta_legacy) ? json_decode($t->meta_legacy, true) : null); - - $v = is_array($meta) ? ($meta['calcolo'] ?? null) : null; - if (! $v) { - $v = $t->tipo_calcolo; - } - - $v = strtolower(trim((string) ($v ?? ''))); - if ($v === '') { - return null; - } - - return match ($v) { - 'm' => 'millesimi', - 'a', 'c' => 'a_consumo', - 'x' => 'conguagli', - 'p' => 'personali', - default => $v, - }; - } - - private function formatCalcoloLabel(?string $calcolo): ?string - { - $c = strtolower(trim((string) ($calcolo ?? ''))); - if ($c === '') { - return null; - } - - return match ($c) { - 'acqua', 'a', 'consumo', 'c', 'a_consumo' => 'A CONSUMO', - 'millesimi', 'm' => 'MILLESIMI', - 'conguagli', 'x' => 'CONGUAGLI', - 'personali', 'p' => 'PERSONALI', - 'parti' => 'PARTI', - 'fisso' => 'FISSO', - default => strtoupper($c), - }; - } - - private function resolveTipoLabel(TabellaMillesimale $t, ?string $calcoloRaw): ?string - { - $meta = is_array($t->meta_legacy) - ? $t->meta_legacy - : (is_string($t->meta_legacy) ? json_decode($t->meta_legacy, true) : null); - $meta = is_array($meta) ? $meta : []; - - $codice = strtoupper((string) ($t->codice_tabella ?? '')); - if ($codice === 'ACQUA' || $this->isConsumoCalcolo($calcoloRaw)) { - return 'A (Acqua)'; - } - - $ors = isset($meta['tipo']) ? strtoupper((string) $meta['tipo']) : null; - if ($ors) { - return match ($ors) { - 'O' => 'O (Ordinaria)', - 'R' => 'R (Riscaldamento)', - 'S' => 'S (Straordinaria)', - default => $ors, - }; - } - - $tipologia = strtoupper((string) ($t->tipologia ?? '')); - if (in_array($tipologia, ['O', 'R', 'S'], true)) { - return match ($tipologia) { - 'R' => 'R (Riscaldamento)', - 'S' => 'S (Straordinaria)', - default => 'O (Ordinaria)', - }; - } - - $tipo = strtolower((string) ($t->tipo_tabella ?? '')); - if ($tipo === 'riscaldamento') return 'R (Riscaldamento)'; - if ($tipo === 'straordinaria') return 'S (Straordinaria)'; - if ($tipo === 'ordinaria') return 'O (Ordinaria)'; - - return $tipo !== '' ? strtoupper($tipo) : null; - } - - private function resolveLegacyImporto(TabellaMillesimale $t, string $primary, string $fallback): ?float - { - $meta = is_array($t->meta_legacy) - ? $t->meta_legacy - : (is_string($t->meta_legacy) ? json_decode($t->meta_legacy, true) : null); - $meta = is_array($meta) ? $meta : []; - $value = $meta[$primary] ?? $meta[$fallback] ?? null; - return is_numeric($value) ? (float) $value : null; - } - - private function isConsumoCalcolo(?string $calcolo): bool - { - $c = strtolower(trim((string) ($calcolo ?? ''))); - return in_array($c, ['acqua', 'a', 'consumo', 'c', 'a_consumo'], true); - } -} diff --git a/app/Filament/Pages/Contabilita/CasseBancheMovimenti.php b/app/Filament/Pages/Contabilita/CasseBancheMovimenti.php index 1e0ee1e..89d21ff 100755 --- a/app/Filament/Pages/Contabilita/CasseBancheMovimenti.php +++ b/app/Filament/Pages/Contabilita/CasseBancheMovimenti.php @@ -666,9 +666,13 @@ protected function getHeaderActions(): array 'text/csv', 'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'application/octet-stream', + 'application/x-mswrite', + 'application/mswrite', '.pdf', '.csv', '.txt', + '.wri', '.qif', '.xlsx', ]), @@ -818,6 +822,9 @@ protected function getHeaderActions(): array 'text/csv', 'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'application/octet-stream', + 'application/x-mswrite', + 'application/mswrite', '.csv', '.txt', '.wri', diff --git a/app/Filament/Pages/Contabilita/CasseBancheRiepilogo.php b/app/Filament/Pages/Contabilita/CasseBancheRiepilogo.php index 5d0add1..7455b63 100755 --- a/app/Filament/Pages/Contabilita/CasseBancheRiepilogo.php +++ b/app/Filament/Pages/Contabilita/CasseBancheRiepilogo.php @@ -34,6 +34,7 @@ use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Storage; use UnitEnum; @@ -41,6 +42,9 @@ class CasseBancheRiepilogo extends Page implements HasTable { use InteractsWithTable; + /** @var array */ + public array $interbankMappings = []; + protected static ?bool $supportsWindowFunctions = null; protected static ?string $navigationLabel = 'Casse e banche'; @@ -75,6 +79,7 @@ public static function canAccess(): bool public function mount(): void { $this->mountInteractsWithTable(); + $this->loadInterbankMappings(); } public function getActiveStabile(): ?Stabile @@ -700,4 +705,371 @@ protected function resolveDefaultGestioneId(): ?string return $match ? (string) $match->id : null; } + + public function loadInterbankMappings(): void + { + $user = Auth::user(); + if (! $user instanceof User) { + return; + } + + $stabileId = StabileContext::resolveActiveStabileId($user); + if (! $stabileId) { + return; + } + + $codes = ['198', '219', '012', '048', '208', 'F24']; + $this->interbankMappings = []; + + foreach ($codes as $code) { + $rule = \App\Modules\Contabilita\Models\RegolaPrimaNota::query() + ->where('stabile_id', $stabileId) + ->where('origine', 'banca') + ->where('chiave', $code) + ->first(); + + $this->interbankMappings[$code] = $rule ? (int) $rule->conto_avere_id : null; + } + } + + public function saveInterbankMappings(): void + { + $user = Auth::user(); + if (! $user instanceof User) { + return; + } + + $stabileId = StabileContext::resolveActiveStabileId($user); + if (! $stabileId) { + Notification::make()->title('Seleziona uno stabile')->danger()->send(); + return; + } + + $bancaContoId = DB::table('contabilita_piano_conti')->where('codice', '1020')->value('id') ?: 7; + + $labels = [ + '198' => 'Canone Conto / Costo Fisso (198)', + '219' => 'Imposta di Bollo (219)', + '012' => 'Bollettino PagoPA / CBILL (012)', + '048' => 'Spese / Commissioni Bancarie (048)', + '208' => 'Interessi Passivi (208)', + 'F24' => 'Deleghe F24 (RA)', + ]; + + foreach ($this->interbankMappings as $code => $contoAvereId) { + if ($contoAvereId) { + \App\Modules\Contabilita\Models\RegolaPrimaNota::updateOrCreate([ + 'stabile_id' => $stabileId, + 'origine' => 'banca', + 'chiave' => $code, + ], [ + 'label' => $labels[$code] ?? "Regola interbancaria {$code}", + 'conto_dare_id' => $bancaContoId, + 'conto_avere_id' => (int) $contoAvereId, + 'attiva' => true, + ]); + } else { + \App\Modules\Contabilita\Models\RegolaPrimaNota::query() + ->where('stabile_id', $stabileId) + ->where('origine', 'banca') + ->where('chiave', $code) + ->delete(); + } + } + + Notification::make()->title('Configurazione salvata con successo.')->success()->send(); + } + + /** @return array */ + public function getPianoContiOptions(): array + { + return DB::table('contabilita_piano_conti') + ->where('attivo', true) + ->orderBy('codice') + ->get(['id', 'codice', 'denominazione']) + ->mapWithKeys(fn($r) => [(string) $r->id => "{$r->codice} - {$r->denominazione}"]) + ->all(); + } + + public function riconciliaSpeseBancarieAutomaticamente(): void + { + $user = Auth::user(); + if (! $user instanceof User) { + return; + } + + $stabileId = StabileContext::resolveActiveStabileId($user); + if (! $stabileId) { + Notification::make()->title('Errore')->body('Seleziona uno stabile prima.')->danger()->send(); + return; + } + + $gestioneId = null; + if (Schema::hasTable('gestioni_contabili')) { + $gestioneId = $this->resolveDefaultGestioneId(); + if ($gestioneId) { + $gestioneId = (int) $gestioneId; + } + } + + if (! $gestioneId) { + Notification::make()->title('Errore')->body('Nessuna gestione contabile attiva e aperta trovata per questo stabile.')->danger()->send(); + return; + } + + $movements = \App\Modules\Contabilita\Models\MovimentoBanca::query() + ->where('stabile_id', $stabileId) + ->whereNull('registrazione_id') + ->get(); + + if ($movements->isEmpty()) { + Notification::make()->title('Riconciliazione completata')->body('Nessun movimento bancario non registrato trovato.')->info()->send(); + return; + } + + $primaNotaService = new \App\Modules\Contabilita\Services\MovimentoBancaPrimaNotaService(); + $fornitori = \App\Models\Fornitore::query()->get(['id', 'ragione_sociale']); + + $registeredCosts = 0; + $matchedCbill = 0; + $matchedF24 = 0; + + foreach ($movements as $m) { + $causale = trim((string) $m->causale); + $descText = strtoupper(($m->descrizione ?? '') . ' ' . ($m->descrizione_estesa ?? '')); + + // Fallback per identificare spese bancarie, bollettini CBILL o F24 dalla descrizione + if (str_contains($descText, 'PAGAMENTO DELEGHE') || str_contains($descText, 'F24') || str_contains($descText, 'F23') || str_contains($descText, 'ENTRATEL') || str_contains($descText, 'PAGAMENTO FISCO')) { + $causale = 'F24'; + } elseif ($causale === '' || ! in_array($causale, ['198', '219', '048', '208', '012'], true)) { + if (str_contains($descText, 'IMPOSTA BOLLO') || str_contains($descText, 'IMPOSTA DI BOLLO') || str_contains($descText, 'DPR642/72') || str_contains($descText, 'BOLLO CONTO')) { + $causale = '219'; + } elseif (str_contains($descText, 'COMPETENZE') || str_contains($descText, 'INTERESSI') || str_contains($descText, 'ONERI') || str_contains($descText, 'TENUTA CONTO') || str_contains($descText, 'SPESE COMPILAZIONE')) { + $causale = '198'; + } elseif (str_contains($descText, 'COMMISSION') || str_contains($descText, 'COMMESS')) { + $causale = '048'; + } elseif (str_contains($descText, 'PAGAGO') || str_contains($descText, 'PAGOPA') || str_contains($descText, 'CBILL') || str_contains($descText, 'BOLLETTINO')) { + $causale = '012'; + } + } + + if ($causale !== $m->causale) { + $m->causale = $causale; + $m->save(); + } + + // A. Ritenute d'Acconto / F24 + if ($causale === 'F24' && (float) $m->importo < 0) { + $outstandingRAs = \App\Models\RegistroRitenuteAcconto::query() + ->where('stato_versamento', 'da_versare') + ->whereHas('gestione', function ($q) use ($stabileId) { + $q->where('stabile_id', $stabileId); + }) + ->get(); + + $targetAmount = abs((float) $m->importo); + $matchedRAs = collect(); + + // Caso A.1: Cerca una singola ritenuta con importo esatto + $singleMatch = $outstandingRAs->first(fn($ra) => abs((float) $ra->importo_ritenuta - $targetAmount) < 0.01); + if ($singleMatch) { + $matchedRAs->push($singleMatch); + } else { + // Caso A.2: Cerca se la somma di tutte le ritenute in sospeso corrisponde esattamente al pagamento + $totalOutstandingSum = $outstandingRAs->sum(fn($ra) => (float) $ra->importo_ritenuta); + if (abs($totalOutstandingSum - $targetAmount) < 0.01) { + $matchedRAs = $outstandingRAs; + } + } + + if ($matchedRAs->isNotEmpty()) { + foreach ($matchedRAs as $ra) { + $ra->stato_versamento = 'versata'; + $ra->data_versamento = $m->data; + $ra->importo_versato = $ra->importo_ritenuta; + $ra->f24_riferimento = substr('F24 ' . $m->descrizione, 0, 100); + $ra->save(); + } + + // Registra in Prima Nota se è presente la regola F24 + $rule = \App\Modules\Contabilita\Models\RegolaPrimaNota::query() + ->where('stabile_id', $stabileId) + ->where('origine', 'banca') + ->where('chiave', 'F24') + ->where('attiva', true) + ->first(); + + if ($rule) { + try { + if (! $m->gestione_id) { + $m->gestione_id = $gestioneId; + $m->save(); + } + $primaNotaService->generaRegistrazioneDaMovimento($user, $m); + } catch (\Throwable $e) { + \Illuminate\Support\Facades\Log::error("Errore registrazione Prima Nota F24: " . $e->getMessage()); + } + } else { + // Se non c'è una regola contabile configurata, creiamo comunque una registrazione di default per quadrare il movimento + try { + if (! $m->gestione_id) { + $m->gestione_id = $gestioneId; + $m->save(); + } + $primaNotaService->generaRegistrazioneDaMovimento($user, $m); + } catch (\Throwable $e) { + // Ignora se fallisce + } + } + + $matchedF24++; + continue; + } + } + + // B. Spese Bancarie Standard (198, 219, 048, 208) + if (in_array($causale, ['198', '219', '048', '208'], true)) { + $rule = \App\Modules\Contabilita\Models\RegolaPrimaNota::query() + ->where('stabile_id', $stabileId) + ->where('origine', 'banca') + ->where('chiave', $causale) + ->where('attiva', true) + ->first(); + + if ($rule) { + try { + if (! $m->gestione_id) { + $m->gestione_id = $gestioneId; + $m->save(); + } + $primaNotaService->generaRegistrazioneDaMovimento($user, $m); + $registeredCosts++; + } catch (\Throwable $e) { + dump("EXCEPTION: " . $e->getMessage() . "\n" . $e->getTraceAsString()); + } + } + } + + // C. PagoPA / CBILL (012) + if ($causale === '012' && (float) $m->importo < 0) { + // Tenta di estrarre un codice CBILL / avviso numerico di 15-20 cifre dalla descrizione + $cbillCode = null; + $descCombined = ($m->descrizione ?? '') . ' ' . ($m->descrizione_estesa ?? ''); + if (preg_match('/\b([0-9]{15,20})\b/', $descCombined, $cbillMatches)) { + $cbillCode = trim($cbillMatches[1]); + } + + $invoice = null; + $fornIdMatched = null; + + if ($cbillCode) { + $invoiceRow = DB::table('contabilita_fatture_fornitori as f') + ->join('fatture_elettroniche as fe', 'fe.id', '=', 'f.fattura_elettronica_id') + ->where('f.stabile_id', $stabileId) + ->where(function ($q) { + $q->where('f.stato', '!=', 'pagato') + ->orWhereNull('f.stato') + ->orWhereNull('f.data_pagamento'); + }) + ->where(function ($q) use ($cbillCode) { + $q->where('fe.pagamento_cbill', $cbillCode) + ->orWhere('fe.pagamento_codice_avviso', $cbillCode); + }) + ->select('f.*') + ->first(); + + if ($invoiceRow) { + $invoice = $invoiceRow; + $fornIdMatched = $invoiceRow->fornitore_id; + } + } + + // Fallback sul matching tramite ragione sociale del fornitore e importo + if (! $invoice) { + $descNormalized = $this->normalizeStringForMatching($descCombined); + foreach ($fornitori as $forn) { + $ragSociale = $this->normalizeStringForMatching((string) $forn->ragione_sociale); + if ($ragSociale !== '' && str_contains($descNormalized, $ragSociale)) { + $invoiceRow = DB::table('contabilita_fatture_fornitori') + ->where('stabile_id', $stabileId) + ->where('fornitore_id', $forn->id) + ->where(function ($q) { + $q->where('stato', '!=', 'pagato') + ->orWhereNull('stato') + ->orWhereNull('data_pagamento'); + }) + ->where(function ($q) use ($m) { + $targetVal = abs((float) $m->importo); + $q->where('totale', $targetVal) + ->orWhere('netto_da_pagare', $targetVal); + }) + ->first(); + + if ($invoiceRow) { + $invoice = $invoiceRow; + $fornIdMatched = $forn->id; + break; + } + } + } + } + + if ($invoice) { + DB::table('contabilita_fatture_fornitori') + ->where('id', $invoice->id) + ->update([ + 'stato' => 'pagato', + 'data_pagamento' => $m->data, + 'movimento_pagamento_id' => $m->id, + ]); + + $matchData = is_string($m->match_data) ? json_decode($m->match_data, true) : (is_array($m->match_data) ? $m->match_data : []); + $matchData['riconciliato_operativo'] = true; + $matchData['riconciliazione_tipo'] = 'fattura_fornitore'; + $matchData['riconciliazione_label'] = 'Pagamento CBILL/PagoPA fattura #' . $invoice->numero_documento; + $matchData['riconciliazione_at'] = now()->toDateTimeString(); + $matchData['fattura_fornitore_id'] = $invoice->id; + + if ($fornIdMatched) { + $m->fornitore_id = $fornIdMatched; + } + $m->registrazione_id = $invoice->registrazione_id; + $m->match_data = $matchData; + if (Schema::hasColumn('contabilita_movimenti_banca', 'da_confermare')) { + $m->da_confermare = false; + } + $m->save(); + + $matchedCbill++; + } + } + } + + if ($registeredCosts > 0 || $matchedCbill > 0 || $matchedF24 > 0) { + Notification::make() + ->title('Elaborazione completata') + ->body("Registrate {$registeredCosts} spese bancarie. Riconciliati {$matchedCbill} bollettini CBILL e {$matchedF24} deleghe F24.") + ->success() + ->send(); + } else { + Notification::make()->title('Nessuna operazione eseguita')->body('Non è stato possibile abbinare spese bancarie, bollettini CBILL o deleghe F24 con le regole attuali.')->warning()->send(); + } + + $this->loadInterbankMappings(); + } + + private function normalizeStringForMatching(string $str): string + { + $str = strtoupper($str); + // Rimuovi suffissi aziendali comuni per facilitare il matching + $str = str_replace( + ['S.P.A.', 'S.R.L.', 'S.N.C.', 'S.P.A', 'S.R.L', 'S.N.C', ' SPA', ' SRL', ' SNC', ' COOP', ' S.S.', ' S.S', ' S.P.A.'], + ' ', + $str + ); + // Rimuovi punteggiatura e spazi multipli + $str = preg_replace('/[^A-Z0-9\s]/', '', $str); + $str = preg_replace('/\s+/', ' ', $str); + return trim($str); + } } diff --git a/app/Filament/Pages/Contabilita/DebitiPagareHub.php b/app/Filament/Pages/Contabilita/DebitiPagareHub.php new file mode 100644 index 0000000..252bb90 --- /dev/null +++ b/app/Filament/Pages/Contabilita/DebitiPagareHub.php @@ -0,0 +1,374 @@ + */ + public array $debiti = []; + + /** @var array */ + public array $movimenti = []; + + /** @var array */ + public array $debitiStorici = []; + + public float $totaleDebitiStorici = 0.0; + + public static function canAccess(): bool + { + $user = Auth::user(); + if (! $user instanceof User) { + return false; + } + + if ($user->hasAnyRole(['super-admin', 'admin'])) { + return true; + } + + return $user->can('contabilita.prima-nota') || $user->can('contabilita.fatture-fornitori'); + } + + public function getActiveStabile(): ?Stabile + { + $user = Auth::user(); + if (! $user instanceof User) { + return null; + } + + return StabileContext::getActiveStabile($user); + } + + public function mount(): void + { + $stabile = $this->getActiveStabile(); + if ($stabile) { + $gestioneId = $this->resolveActiveGestioneId((int) $stabile->id); + if ($gestioneId) { + $gestione = DB::table('gestioni_contabili')->where('id', $gestioneId)->first(); + if ($gestione && $gestione->data_fine) { + $this->targetDate = $gestione->data_fine; + } + } + } + + if (! $this->targetDate) { + $this->targetDate = now()->format('Y-12-31'); + } + + $this->loadTabContent(); + } + + public function setTab(string $tab): void + { + $this->activeTab = $tab; + $this->loadTabContent(); + } + + public function updatedTargetDate(): void + { + $this->loadTabContent(); + } + + public function loadTabContent(): void + { + if ($this->activeTab === 'debiti') { + $this->loadDebiti(); + } elseif ($this->activeTab === 'movimenti') { + $this->loadMovimenti(); + } elseif ($this->activeTab === 'stato_debiti') { + $this->loadDebitiStorici(); + } + } + + public function loadDebiti(): void + { + $user = Auth::user(); + if (! $user instanceof User) { + return; + } + + $stabileId = StabileContext::resolveActiveStabileId($user); + if (! $stabileId || ! Schema::hasTable('contabilita_fatture_fornitori')) { + $this->debiti = []; + return; + } + + $select = [ + 'f.id', + 'f.numero_documento', + 'f.data_documento', + 'f.totale', + 'f.netto_da_pagare', + 'f.stato', + 'f.fornitore_id', + 'f.fattura_elettronica_id', + ]; + + $query = DB::table('contabilita_fatture_fornitori as f') + ->where('f.stabile_id', $stabileId) + ->where(function ($q) { + $q->where('f.stato', '!=', 'pagato') + ->orWhereNull('f.stato'); + }); + + if (Schema::hasTable('fornitori')) { + $query->leftJoin('fornitori as forn', 'forn.id', '=', 'f.fornitore_id'); + $select[] = 'forn.ragione_sociale as fornitore_ragione_sociale'; + $select[] = 'forn.iban as fornitore_iban'; + } + + if (Schema::hasTable('fatture_elettroniche')) { + $query->leftJoin('fatture_elettroniche as fe', 'fe.id', '=', 'f.fattura_elettronica_id'); + $select[] = 'fe.pagamento_iban as fe_pagamento_iban'; + } + + $dbRows = $query->select($select) + ->orderBy('f.data_documento') + ->orderBy('f.id') + ->get(); + + $this->debiti = []; + foreach ($dbRows as $row) { + $iban = null; + + if (isset($row->fe_pagamento_iban) && $row->fe_pagamento_iban) { + $iban = $row->fe_pagamento_iban; + } + + if (! $iban && isset($row->fornitore_iban)) { + $iban = $row->fornitore_iban; + } + + $id = (int) $row->id; + $fornitoreName = $row->fornitore_ragione_sociale ?? 'Fornitore Sconosciuto'; + $numDoc = $row->numero_documento ?? 'N/D'; + + $prefix = "FAT-ID:{$id} "; + $suffix = " N.{$numDoc}"; + $avail = 140 - strlen($prefix) - strlen($suffix); + $cleanName = substr($fornitoreName, 0, max(0, $avail)); + $descrizione = $prefix . $cleanName . $suffix; + + $this->debiti[] = [ + 'id' => $id, + 'numero_documento' => $numDoc, + 'data_documento' => $row->data_documento ? Carbon::parse($row->data_documento)->format('d/m/Y') : '—', + 'totale' => (float) $row->totale, + 'netto_da_pagare' => (float) ($row->netto_da_pagare ?? $row->totale), + 'fornitore_ragione_sociale' => $fornitoreName, + 'iban' => $iban ? strtoupper(str_replace(' ', '', $iban)) : null, + 'descrizione_bonifico' => $descrizione, + ]; + } + } + + public function loadMovimenti(): void + { + $user = Auth::user(); + if (! $user instanceof User) { + return; + } + + $stabileId = StabileContext::resolveActiveStabileId($user); + if (! $stabileId || ! Schema::hasTable('contabilita_movimenti_banca')) { + $this->movimenti = []; + return; + } + + $query = DB::table('contabilita_movimenti_banca as m') + ->where('m.stabile_id', $stabileId); + + if (Schema::hasTable('contabilita_fatture_fornitori')) { + $query->leftJoin('contabilita_fatture_fornitori as f', 'f.movimento_pagamento_id', '=', 'm.id'); + } + if (Schema::hasTable('fornitori')) { + $query->leftJoin('fornitori as forn', 'forn.id', '=', 'm.fornitore_id'); + } + + $select = [ + 'm.id', + 'm.data', + 'm.descrizione', + 'm.descrizione_estesa', + 'm.importo', + 'm.registrazione_id', + 'm.causale', + ]; + + if (Schema::hasTable('contabilita_fatture_fornitori')) { + $select[] = 'f.id as invoice_id'; + $select[] = 'f.numero_documento as invoice_number'; + } + if (Schema::hasTable('fornitori')) { + $select[] = 'forn.ragione_sociale as fornitore_ragione_sociale'; + } + + $rows = $query->select($select) + ->orderByDesc('m.data') + ->orderByDesc('m.id') + ->get(); + + $this->movimenti = []; + foreach ($rows as $r) { + $invId = isset($r->invoice_id) ? $r->invoice_id : null; + $invNum = isset($r->invoice_number) ? $r->invoice_number : null; + $forn = isset($r->fornitore_ragione_sociale) ? $r->fornitore_ragione_sociale : null; + + $isReconciled = ! empty($r->registrazione_id) || ! empty($invId); + + $advice = ''; + if (! $isReconciled) { + if ((float) $r->importo < 0) { + if (in_array((string) $r->causale, ['198', '219', '048', '208'], true)) { + $advice = 'Spesa bancaria diretta. Configura la regola interbancaria per registrarla in prima nota.'; + } else { + $advice = 'Uscita bancaria. Registra la fattura fornitore corrispondente o riconcilia manualmente.'; + } + } else { + $advice = 'Entrata/Incasso rate. Riconcilia con le rate o gli incassi del condominio.'; + } + } + + $this->movimenti[] = [ + 'id' => (int) $r->id, + 'data' => $r->data ? Carbon::parse($r->data)->format('d/m/Y') : '—', + 'descrizione' => $r->descrizione ?: 'Movimento banca', + 'importo' => (float) $r->importo, + 'stato' => $isReconciled ? 'quadrato' : 'da_quadrare', + 'invoice_id' => $invId, + 'invoice_number' => $invNum, + 'fornitore' => $forn, + 'advice' => $advice, + ]; + } + } + + public function loadDebitiStorici(): void + { + $user = Auth::user(); + if (! $user instanceof User) { + return; + } + + $stabileId = StabileContext::resolveActiveStabileId($user); + if (! $stabileId || ! Schema::hasTable('contabilita_fatture_fornitori') || ! $this->targetDate) { + $this->debitiStorici = []; + $this->totaleDebitiStorici = 0.0; + return; + } + + $query = DB::table('contabilita_fatture_fornitori as f') + ->where('f.stabile_id', $stabileId) + ->where('f.data_documento', '<=', $this->targetDate) + ->where(function ($q) { + $q->where('f.stato', '!=', 'pagato') + ->orWhereNull('f.stato') + ->orWhereNull('f.data_pagamento') + ->orWhere('f.data_pagamento', '>', $this->targetDate); + }); + + if (Schema::hasTable('fornitori')) { + $query->leftJoin('fornitori as forn', 'forn.id', '=', 'f.fornitore_id'); + } + + $select = [ + 'f.id', + 'f.numero_documento', + 'f.data_documento', + 'f.totale', + 'f.netto_da_pagare', + 'f.stato', + 'f.data_pagamento', + ]; + + if (Schema::hasTable('fornitori')) { + $select[] = 'forn.ragione_sociale as fornitore_ragione_sociale'; + } + + $rows = $query->select($select) + ->orderBy('f.data_documento') + ->orderBy('f.id') + ->get(); + + $this->debitiStorici = []; + $this->totaleDebitiStorici = 0.0; + + foreach ($rows as $row) { + $tot = (float) $row->totale; + $due = (float) ($row->netto_da_pagare ?? $row->totale); + $this->totaleDebitiStorici += $due; + + $chiusoAl = 'Ancora aperto'; + if ($row->stato === 'pagato' && $row->data_pagamento) { + $chiusoAl = Carbon::parse($row->data_pagamento)->format('d/m/Y'); + } + + $this->debitiStorici[] = [ + 'id' => (int) $row->id, + 'numero_documento' => $row->numero_documento ?? 'N/D', + 'data_documento' => $row->data_documento ? Carbon::parse($row->data_documento)->format('d/m/Y') : '—', + 'totale' => $tot, + 'netto_da_pagare' => $due, + 'fornitore' => $row->fornitore_ragione_sociale ?? 'Fornitore Sconosciuto', + 'stato_corrente' => $row->stato ?? 'N/D', + 'data_chiusura' => $chiusoAl, + ]; + } + } + + private function resolveActiveGestioneId(int $stabileId): ?int + { + $anno = \App\Support\AnnoGestioneContext::resolveActiveAnno(); + $tipo = \App\Support\GestioneContext::resolveActiveGestione(); + + $match = DB::table('gestioni_contabili') + ->where('stabile_id', $stabileId) + ->where('anno_gestione', $anno) + ->where('tipo_gestione', $tipo) + ->where('stato', 'aperta') + ->orderByDesc('gestione_attiva') + ->orderByDesc('id') + ->first(); + + if (! $match) { + $match = DB::table('gestioni_contabili') + ->where('stabile_id', $stabileId) + ->where('stato', 'aperta') + ->orderByDesc('gestione_attiva') + ->orderByDesc('id') + ->first(); + } + + return $match ? (int) $match->id : null; + } +} diff --git a/app/Filament/Pages/Contabilita/FatturaFornitoreScheda.php b/app/Filament/Pages/Contabilita/FatturaFornitoreScheda.php index e688e08..855c720 100755 --- a/app/Filament/Pages/Contabilita/FatturaFornitoreScheda.php +++ b/app/Filament/Pages/Contabilita/FatturaFornitoreScheda.php @@ -2843,6 +2843,138 @@ public function getFornitoreOverview(): ?array ]; } + /** + * @return array + */ + public function getSuggerimentiVociSpesa(): array + { + if (! $this->record || ! $this->record->stabile_id || ! $this->record->fornitore_id) { + return []; + } + + $stabileId = (int) $this->record->stabile_id; + $fornitoreId = (int) $this->record->fornitore_id; + + $impostazione = \App\Models\FornitoreStabileImpostazione::query() + ->where('stabile_id', $stabileId) + ->where('fornitore_id', $fornitoreId) + ->first(); + + $defaultVoceId = $impostazione?->voce_spesa_default_id; + + if (! $impostazione) { + $fornitore = \App\Models\Fornitore::query()->find($fornitoreId); + if ($fornitore) { + app(\App\Services\Arera\AreraFornitoreClassifier::class)->applyDefaultStableMapping($fornitore, $stabileId); + $impostazione = \App\Models\FornitoreStabileImpostazione::query() + ->where('stabile_id', $stabileId) + ->where('fornitore_id', $fornitoreId) + ->first(); + $defaultVoceId = $impostazione?->voce_spesa_default_id; + } + } + + $settori = []; + if ($impostazione && is_array($impostazione->meta ?? null)) { + $settori = array_map('mb_strtolower', (array) ($impostazione->meta['arera_settori'] ?? [])); + } + + $query = \App\Models\VoceSpesa::query() + ->where('stabile_id', $stabileId) + ->where('attiva', true); + + $isGas = collect($settori)->contains(fn(string $s): bool => str_contains($s, 'gas')) || + ($impostazione && ($impostazione->meta['service_supplier_kind'] ?? '') === 'gas'); + $isAcqua = collect($settori)->contains(fn(string $s): bool => str_contains($s, 'idrico') || str_contains($s, 'acqua')); + $isElettrico = collect($settori)->contains(fn(string $s): bool => str_contains($s, 'elettr')); + + $query->where(function($q) use ($isGas, $isAcqua, $isElettrico, $defaultVoceId): void { + if ($defaultVoceId) { + $q->orWhere('id', $defaultVoceId); + } + if ($isGas) { + $q->orWhere('tipo_gestione', 'riscaldamento') + ->orWhere('tipo', 'riscaldamento') + ->orWhere('categoria', 'gas') + ->orWhere('categoria', 'riscaldamento') + ->orWhere('codice', 'like', 'R%') + ->orWhere('codice', 'like', 'RL%'); + } + if ($isAcqua) { + $q->orWhere('categoria', 'acqua') + ->orWhere('codice', 'like', 'A%'); + } + if ($isElettrico) { + $q->orWhere('categoria', 'energia_elettrica') + ->orWhere('codice', 'like', 'E%') + ->orWhere('codice', 'like', 'L%'); + } + }); + + $voci = $query->orderBy('codice')->get(); + + $rows = []; + foreach ($voci as $voce) { + $rows[] = [ + 'id' => (int) $voce->id, + 'codice' => (string) $voce->codice, + 'descrizione' => (string) $voce->descrizione, + 'is_default' => $voce->id === $defaultVoceId, + ]; + } + + return $rows; + } + + public function applicaVoceSpesaSuggerita(int $voceId): void + { + $righe = is_array($this->data['righe'] ?? null) ? $this->data['righe'] : []; + + if ($righe === []) { + $newKey = 'item_' . uniqid(); + $righe[$newKey] = [ + 'descrizione' => '', + 'imponibile_euro' => null, + 'iva_euro' => null, + 'totale_euro' => null, + 'aliquota_iva' => null, + 'voce_spesa_id' => $voceId, + 'voce_spesa_label' => $this->resolveVoceSpesaLabel($voceId), + ]; + } else { + $found = false; + foreach ($righe as $key => $item) { + if (empty($item['voce_spesa_id'])) { + $righe[$key]['voce_spesa_id'] = $voceId; + $righe[$key]['voce_spesa_label'] = $this->resolveVoceSpesaLabel($voceId); + $found = true; + break; + } + } + if (! $found) { + $newKey = 'item_' . uniqid(); + $righe[$newKey] = [ + 'descrizione' => '', + 'imponibile_euro' => null, + 'iva_euro' => null, + 'totale_euro' => null, + 'aliquota_iva' => null, + 'voce_spesa_id' => $voceId, + 'voce_spesa_label' => $this->resolveVoceSpesaLabel($voceId), + ]; + } + } + + $this->data['righe'] = $righe; + + Notification::make() + ->title('Voce applicata') + ->body('La voce di spesa è stata aggiunta/applicata alle righe della fattura.') + ->success() + ->send(); + } + + /** * Tabelle/"spese" straordinarie legacy (gescon_import.straordinarie): * vogliamo vederle TUTTE (es. 4 in 0001 + 5 in 0003 = 9), quindi nessun filtro per legacy_year. diff --git a/app/Filament/Pages/Contabilita/FattureElettronicheP7mRicevute.php b/app/Filament/Pages/Contabilita/FattureElettronicheP7mRicevute.php index cd3e33c..9dc46a6 100755 --- a/app/Filament/Pages/Contabilita/FattureElettronicheP7mRicevute.php +++ b/app/Filament/Pages/Contabilita/FattureElettronicheP7mRicevute.php @@ -517,13 +517,9 @@ public function downloadCassettoQuarter(string $quarter, string $dal, string $al return; } + $force = false; if ($this->isCassettoQuarterCompleteFor($amministratoreId, (int) $stabile->id, $dal, $al)) { - Notification::make() - ->title('Trimestre già completo') - ->body($this->safeUtf8("{$quarter} {$anno} risulta già scaricato (COMPLETO). Per rieseguire lo stesso periodo usa 'Scarica da Cassetto Fiscale' e abilita 'Reimport / completa' oppure 'Non saltare'.")) - ->warning() - ->send(); - return; + $force = true; } $cfSoggetto = $this->resolveStabileSoggettoCf($stabile); @@ -544,7 +540,7 @@ public function downloadCassettoQuarter(string $quarter, string $dal, string $al false, (int) $user->id, [ - 'force_update' => false, + 'force_update' => $force, 'no_skip' => true, 'importa_righe' => 'auto', 'create_fornitore_if_missing' => true, diff --git a/app/Filament/Pages/Contabilita/VociSpesaArchivio.php b/app/Filament/Pages/Contabilita/VociSpesaArchivio.php index c515651..13f07fa 100755 --- a/app/Filament/Pages/Contabilita/VociSpesaArchivio.php +++ b/app/Filament/Pages/Contabilita/VociSpesaArchivio.php @@ -1,7 +1,7 @@ label('Tabelle millesimali') ->icon('heroicon-o-rectangle-stack') - ->url(TabelleMillesimaliProspetto::getUrl(panel: 'admin-filament')), + ->url(TabelleMillesimaliArchivio::getUrl(panel: 'admin-filament') . '?tab=prospetto'), Action::make('importaVociDaAnno') ->label('Importa voci da anno') diff --git a/app/Filament/Pages/Gescon/GestioniArchivio.php b/app/Filament/Pages/Gescon/GestioniArchivio.php index 74eed5f..956f652 100755 --- a/app/Filament/Pages/Gescon/GestioniArchivio.php +++ b/app/Filament/Pages/Gescon/GestioniArchivio.php @@ -326,7 +326,7 @@ public function table(Table $table): Table Action::make('apri_stabile_classica') ->label('Apri stabile (UI classica)') ->icon('heroicon-o-arrow-top-right-on-square') - ->url(fn(GestioneContabile $record) => route('admin.stabili.show', $record->stabile_id), shouldOpenInNewTab: true), + ->url(fn(GestioneContabile $record) => route('admin.stabili.show', $record->stabile_id)), ]); } diff --git a/app/Filament/Pages/Gescon/ImportazioneArchivi.php b/app/Filament/Pages/Gescon/ImportazioneArchivi.php index 209844c..78f28f8 100755 --- a/app/Filament/Pages/Gescon/ImportazioneArchivi.php +++ b/app/Filament/Pages/Gescon/ImportazioneArchivi.php @@ -54,6 +54,10 @@ class ImportazioneArchivi extends Page implements HasForms public static function canAccess(): bool { + if (! config('netgescon.legacy_import_enabled', true)) { + return false; + } + $user = Auth::user(); return ModuleVisibility::canAccessGesconInternal($user, ['super-admin', 'admin', 'amministratore']) @@ -408,50 +412,69 @@ public function form(Schema $schema): Schema ->label('Importa anche modello F24 da generale_stabile.mdb') ->helperText('Legge F24.cod_tributo_1..6, Rateiz_1..6, Anno_1..6 e importi correlati.'), - Checkbox::make('with_unita')->label('Unità'), - Checkbox::make('with_soggetti')->label('Anagrafiche (soggetti)'), - Checkbox::make('with_diritti')->label('Diritti'), - Checkbox::make('with_millesimi')->label('Millesimi'), - Checkbox::make('with_voci') - ->label('Voci') - ->reactive() - ->afterStateUpdated(function (Set $set, $state): void { - if ($state) { - $set('with_millesimi', true); - } - }), - Checkbox::make('with_banche')->label('Banche / casse'), - Checkbox::make('with_gestioni')->label('Gestioni (anni)'), - Checkbox::make('with_assemblee')->label('Assemblee legacy'), - Checkbox::make('with_operazioni') - ->label('Operazioni') - ->reactive() - ->afterStateUpdated(function (Set $set, $state): void { - if ($state) { - $set('with_voci', true); - $set('with_millesimi', true); - $set('with_banche', true); - $set('with_unita', true); - $set('with_soggetti', true); - $set('with_diritti', true); - } - }), - Checkbox::make('with_rate')->label('Rate'), - Checkbox::make('with_incassi') - ->label('Incassi') - ->reactive() - ->afterStateUpdated(function (Set $set, $state): void { - if ($state) { - $set('with_rate', true); - $set('with_banche', true); - } - }), - Checkbox::make('with_giroconti')->label('Giroconti'), - Checkbox::make('with_straord')->label('Straordinari'), - Checkbox::make('with_addebiti')->label('Addebiti'), - Checkbox::make('with_detrazioni')->label('Detrazioni fiscali'), - Checkbox::make('sync_staging')->label('Aggiorna staging prima di importare'), - Checkbox::make('dry_run')->label('Dry-run (non scrive nel DB)'), + \Filament\Schemas\Components\Section::make('Seleziona dati da importare / sincronizzare') + ->description('Seleziona i moduli da importare e clicca sul pulsante in basso per avviare l\'importazione dei soli dati selezionati.') + ->columns(3) + ->schema([ + Checkbox::make('with_unita')->label('Unità'), + Checkbox::make('with_soggetti')->label('Anagrafiche (soggetti)'), + Checkbox::make('with_diritti')->label('Diritti'), + Checkbox::make('with_millesimi')->label('Millesimi'), + Checkbox::make('with_voci') + ->label('Voci di spesa') + ->reactive() + ->afterStateUpdated(function (Set $set, $state): void { + if ($state) { + $set('with_millesimi', true); + } + }), + Checkbox::make('with_banche')->label('Banche / casse'), + Checkbox::make('with_gestioni')->label('Gestioni (anni)'), + Checkbox::make('with_assemblee')->label('Assemblee legacy'), + Checkbox::make('with_operazioni') + ->label('Operazioni (Movimenti)') + ->reactive() + ->afterStateUpdated(function (Set $set, $state): void { + if ($state) { + $set('with_voci', true); + $set('with_millesimi', true); + $set('with_banche', true); + $set('with_unita', true); + $set('with_soggetti', true); + $set('with_diritti', true); + } + }), + Checkbox::make('with_rate')->label('Rate'), + Checkbox::make('with_incassi') + ->label('Incassi') + ->reactive() + ->afterStateUpdated(function (Set $set, $state): void { + if ($state) { + $set('with_rate', true); + $set('with_banche', true); + } + }), + Checkbox::make('with_giroconti')->label('Giroconti'), + Checkbox::make('with_straord')->label('Straordinari'), + Checkbox::make('with_addebiti')->label('Addebiti'), + Checkbox::make('with_detrazioni')->label('Detrazioni fiscali'), + Checkbox::make('sync_staging')->label('Aggiorna staging prima di importare'), + Checkbox::make('dry_run')->label('Dry-run (non scrive nel DB)'), + Checkbox::make('update_only') + ->label('Solo aggiornamento incrementale (non cancella i record esistenti)') + ->default(true), + + \Filament\Schemas\Components\Actions::make([ + \Filament\Actions\Action::make('importa_selezionati_btn') + ->label('Avvia Sincronizzazione / Importazione Dati Selezionati') + ->icon('heroicon-m-arrow-down-tray') + ->color('success') + ->requiresConfirmation() + ->action(function (\App\Services\GesconImport\EssentialImportService $service) { + $this->eseguiImportSelezionato($service); + }) + ])->columnSpanFull() + ]), ]); } @@ -1045,6 +1068,7 @@ public function eseguiImportSelezionato(EssentialImportService $service): void 'with_straord' => (bool) ($state['with_straord'] ?? false), 'with_addebiti' => (bool) ($state['with_addebiti'] ?? false), 'with_detrazioni' => (bool) ($state['with_detrazioni'] ?? false), + 'update_only' => (bool) ($state['update_only'] ?? true), ]; if (! empty($options['with_operazioni'])) { @@ -1054,6 +1078,23 @@ public function eseguiImportSelezionato(EssentialImportService $service): void $options['with_unita'] = true; $options['with_soggetti'] = true; $options['with_diritti'] = true; + + // Sincronizzazione automatica Fornitori collegata + try { + $fornMdb = (string) ($state['fornitori_mdb'] ?? ''); + if ($fornMdb === '') { + $fornMdb = rtrim($options['path'], '/') . '/dbc/Fornitori.mdb'; + } + if (is_file($fornMdb)) { + Artisan::call('gescon:auto-sync-fornitori', [ + '--fornitori-mdb' => $fornMdb, + '--path-root' => $options['path'], + '--apply' => true, + ]); + } + } catch (\Throwable $e) { + // Silently skip or log + } } if (! empty($options['with_rate'])) { @@ -1153,11 +1194,11 @@ public function eseguiImportSelezionato(EssentialImportService $service): void if ((! empty($options['with_banche']) || ! empty($options['with_operazioni']) || ! empty($options['with_incassi'])) && ! empty($options['path']) && ! empty($code)) { foreach ($this->resolveLegacySingoloMdbPaths($code, (string) $options['path'], $legacyYearArg !== '' ? $legacyYearArg : null) as $mdbPath => $legacyDir) { Artisan::call('gescon:load-mdb', [ - '--mdb' => $mdbPath, - '--table' => 'anagr_casse', - '--stabile' => $code, - '--legacy-year' => $legacyDir, - '--truncate' => $legacyDir === array_key_first($this->resolveLegacySingoloMdbPaths($code, (string) $options['path'], $legacyYearArg !== '' ? $legacyYearArg : null)), + '--mdb' => $mdbPath, + '--table' => 'anagr_casse', + '--stabile' => $code, + '--legacy-year' => $legacyDir, + '--refresh-slice' => true, ]); $outputs[] = trim((string) Artisan::output()); } @@ -1165,38 +1206,38 @@ public function eseguiImportSelezionato(EssentialImportService $service): void if (! empty($options['with_operazioni']) && is_string($singoloMdb)) { Artisan::call('gescon:load-mdb', [ - '--mdb' => $singoloMdb, - '--table' => 's_cassa', - '--stabile' => $code, - '--legacy-year' => (string) ($options['anno'] ?? ''), - '--truncate' => true, + '--mdb' => $singoloMdb, + '--table' => 's_cassa', + '--stabile' => $code, + '--legacy-year' => (string) ($options['anno'] ?? ''), + '--refresh-slice' => true, ]); $outputs[] = trim((string) Artisan::output()); Artisan::call('gescon:load-mdb', [ - '--mdb' => $singoloMdb, - '--table' => 'operazioni', - '--stabile' => $code, - '--legacy-year' => (string) ($options['anno'] ?? ''), - '--truncate' => true, + '--mdb' => $singoloMdb, + '--table' => 'operazioni', + '--stabile' => $code, + '--legacy-year' => (string) ($options['anno'] ?? ''), + '--refresh-slice' => true, ]); $outputs[] = trim((string) Artisan::output()); Artisan::call('gescon:load-mdb', [ - '--mdb' => $singoloMdb, - '--table' => 'voc_spe', - '--stabile' => $code, - '--legacy-year' => (string) ($options['anno'] ?? ''), - '--truncate' => true, + '--mdb' => $singoloMdb, + '--table' => 'voc_spe', + '--stabile' => $code, + '--legacy-year' => (string) ($options['anno'] ?? ''), + '--refresh-slice' => true, ]); $outputs[] = trim((string) Artisan::output()); Artisan::call('gescon:load-mdb', [ - '--mdb' => $singoloMdb, - '--table' => 'tabelle', - '--stabile' => $code, - '--legacy-year' => (string) ($options['anno'] ?? ''), - '--truncate' => true, + '--mdb' => $singoloMdb, + '--table' => 'tabelle', + '--stabile' => $code, + '--legacy-year' => (string) ($options['anno'] ?? ''), + '--refresh-slice' => true, ]); $outputs[] = trim((string) Artisan::output()); @@ -1204,31 +1245,31 @@ public function eseguiImportSelezionato(EssentialImportService $service): void if (! empty($options['with_incassi']) && is_string($singoloMdb)) { Artisan::call('gescon:load-mdb', [ - '--mdb' => $singoloMdb, - '--table' => 'incassi', - '--stabile' => $code, - '--legacy-year' => (string) ($options['anno'] ?? ''), - '--truncate' => true, + '--mdb' => $singoloMdb, + '--table' => 'incassi', + '--stabile' => $code, + '--legacy-year' => (string) ($options['anno'] ?? ''), + '--refresh-slice' => true, ]); $outputs[] = trim((string) Artisan::output()); } if (! empty($options['with_rate']) && is_string($generaleMdb)) { Artisan::call('gescon:load-mdb', [ - '--mdb' => $generaleMdb, - '--table' => 'emes_gen', - '--stabile' => $code, - '--legacy-year' => (string) ($options['anno'] ?? ''), - '--truncate' => true, + '--mdb' => $generaleMdb, + '--table' => 'emes_gen', + '--stabile' => $code, + '--legacy-year' => (string) ($options['anno'] ?? ''), + '--refresh-slice' => true, ]); $outputs[] = trim((string) Artisan::output()); Artisan::call('gescon:load-mdb', [ - '--mdb' => $generaleMdb, - '--table' => 'emes_det', - '--stabile' => $code, - '--legacy-year' => (string) ($options['anno'] ?? ''), - '--truncate' => true, + '--mdb' => $generaleMdb, + '--table' => 'emes_det', + '--stabile' => $code, + '--legacy-year' => (string) ($options['anno'] ?? ''), + '--refresh-slice' => true, ]); $outputs[] = trim((string) Artisan::output()); } @@ -1253,6 +1294,9 @@ public function eseguiImportSelezionato(EssentialImportService $service): void if (! empty($options['dry'])) { $params['--dry-run'] = true; } + if (! empty($options['update_only'])) { + $params['--update-only'] = true; + } Artisan::call('gescon:import-full', $params); $outputs[] = trim((string) Artisan::output()); @@ -1700,6 +1744,7 @@ private function upsertSelectedStabileFromImportState(Amministratore $amm): Stab 'attivo' => true, ]); $stabile->save(); + $stabile->syncRubricaContatto(); return $stabile->fresh(); } diff --git a/app/Filament/Pages/Gescon/Ordinarie.php b/app/Filament/Pages/Gescon/Ordinarie.php index 82bc848..3dec1f8 100755 --- a/app/Filament/Pages/Gescon/Ordinarie.php +++ b/app/Filament/Pages/Gescon/Ordinarie.php @@ -539,19 +539,22 @@ public function getLegacyGestioniHubProperty(): array return []; } - $totals = DB::connection('gescon_import') - ->table('operazioni') - ->where('cod_stabile', $legacyCode) - ->select('legacy_year', 'gestione') - ->selectRaw('COUNT(*) as totale_righe') - ->selectRaw('SUM(COALESCE(importo_spese, 0)) as totale_spese') - ->selectRaw('SUM(COALESCE(importo_entrate, 0)) as totale_entrate') - ->selectRaw('SUM(COALESCE(importo_debiti, 0)) as totale_debiti') - ->selectRaw('SUM(COALESCE(importo_crediti, 0)) as totale_crediti') - ->selectRaw('SUM(COALESCE(importo_euro, importo, 0)) as totale_lordo') - ->groupBy('legacy_year', 'gestione') - ->get() - ->groupBy(fn($row) => trim((string) ($row->legacy_year ?? ''))); + $totals = collect(); + if (Schema::connection('gescon_import')->hasTable('operazioni')) { + $totals = DB::connection('gescon_import') + ->table('operazioni') + ->where('cod_stabile', $legacyCode) + ->select('legacy_year', 'gestione') + ->selectRaw('COUNT(*) as totale_righe') + ->selectRaw('SUM(COALESCE(importo_spese, 0)) as totale_spese') + ->selectRaw('SUM(COALESCE(importo_entrate, 0)) as totale_entrate') + ->selectRaw('SUM(COALESCE(importo_debiti, 0)) as totale_debiti') + ->selectRaw('SUM(COALESCE(importo_crediti, 0)) as totale_crediti') + ->selectRaw('SUM(COALESCE(importo_euro, importo, 0)) as totale_lordo') + ->groupBy('legacy_year', 'gestione') + ->get() + ->groupBy(fn($row) => trim((string) ($row->legacy_year ?? ''))); + } $rows = []; foreach ($legacyAnnualMap as $cartella => $meta) { @@ -584,6 +587,10 @@ public function getAcquaSummaryProperty(): array $legacyCode = $this->resolveLegacyCodiceStabile(); $legacyYear = $this->resolveLegacyYearForRiparto($legacyCode); + if (! Schema::connection('gescon_import')->hasTable('operazioni')) { + return []; + } + $rowsQuery = DB::connection('gescon_import') ->table('operazioni') ->where('gestione', 'O') @@ -734,6 +741,7 @@ private function normalizeAcquaNaturalToken(string $value): string public ?int $dettPersNSpe = null; public array $dettPersRows = []; + public string $dettPersSearch = ''; public float $dettPersTotale = 0.0; public float $dettPersImportoOperazione = 0.0; public float $dettPersDelta = 0.0; @@ -1435,15 +1443,18 @@ public function getConsuntivoProperty(): array } } - $i05Operazioni = DB::connection('gescon_import') - ->table('operazioni') - ->where('cod_spe', 'I05') - ->when( - $this->resolveLegacyCodiceStabile() && Schema::connection('gescon_import')->hasColumn('operazioni', 'cod_stabile'), - fn($q) => $q->where('cod_stabile', $this->resolveLegacyCodiceStabile()) - ) - ->when($this->filterAnno, fn($q) => $q->whereYear('dt_spe', $this->filterAnno)) - ->get(['n_spe', 'importo_euro', 'importo']); + $i05Operazioni = collect(); + if (Schema::connection('gescon_import')->hasTable('operazioni')) { + $i05Operazioni = DB::connection('gescon_import') + ->table('operazioni') + ->where('cod_spe', 'I05') + ->when( + $this->resolveLegacyCodiceStabile() && Schema::connection('gescon_import')->hasColumn('operazioni', 'cod_stabile'), + fn($q) => $q->where('cod_stabile', $this->resolveLegacyCodiceStabile()) + ) + ->when($this->filterAnno, fn($q) => $q->whereYear('dt_spe', $this->filterAnno)) + ->get(['n_spe', 'importo_euro', 'importo']); + } $nSpeI05 = $i05Operazioni ->pluck('n_spe') @@ -3112,6 +3123,10 @@ public function openDettPersModal(int $nSpe): void { $legacyCode = $this->resolveLegacyCodiceStabile(); + if (! Schema::connection('gescon_import')->hasTable('operazioni')) { + return; + } + $operazione = DB::connection('gescon_import') ->table('operazioni') ->where('n_spe', $nSpe) @@ -3712,6 +3727,7 @@ private function buildOperazioniQuery() $query->select([ 'o.id as id_operaz', + 'o.protocollo_numero as n_spe', 'o.data_operazione as dt_spe', 'g.anno_gestione as legacy_year', DB::raw("case when g.tipo_gestione = 'ordinaria' then 'O' when g.tipo_gestione = 'riscaldamento' then 'R' else 'S' end as gestione"), @@ -3732,6 +3748,37 @@ private function buildOperazioniQuery() return $query; } + if (! Schema::connection('gescon_import')->hasTable('operazioni')) { + return DB::table('operazioni_contabili as o') + ->leftJoin('gestioni_contabili as g', 'g.id', '=', 'o.gestione_id') + ->whereRaw('1 = 0') + ->select([ + 'o.id as id_operaz', + 'o.protocollo_numero as n_spe', + 'o.data_operazione as dt_spe', + 'g.anno_gestione as legacy_year', + DB::raw("case when g.tipo_gestione = 'ordinaria' then 'O' when g.tipo_gestione = 'riscaldamento' then 'R' else 'S' end as gestione"), + 'o.compet', + 'o.conto_contabile as cod_spe', + DB::raw("null as tabella"), + DB::raw("null as cod_for"), + 'o.descrizione as benef', + 'o.n_stra', + 'o.protocollo_completo', + DB::raw("coalesce(o.dare, o.avere, 0) as importo_euro"), + DB::raw("coalesce(o.dare, o.avere, 0) as importo"), + 'o.num_fat', + 'o.fe_uid', + 'o.voce_spesa_snapshot', + 'o.fornitore_snapshot', + DB::raw("0 as importo_spese"), + DB::raw("0 as importo_entrate"), + DB::raw("0 as importo_debiti"), + DB::raw("0 as importo_crediti"), + DB::raw("null as natura2"), + ]); + } + $query = DB::connection('gescon_import')->table('operazioni'); $legacyCode = $this->resolveLegacyCodiceStabile(); @@ -3973,4 +4020,40 @@ private function applyOperazioniSorting($query) return $query; } + + public function riallineaScrittureLegacy(): void + { + $activeStabileId = \App\Support\StabileContext::resolveActiveStabileId(\Illuminate\Support\Facades\Auth::user()); + if (! $activeStabileId) { + \Filament\Notifications\Notification::make()->title('Nessuno stabile attivo')->danger()->send(); + return; + } + + try { + $params = [ + '--stabile-id' => $activeStabileId, + '--tipo' => 'all', + ]; + + if ($this->filterAnno) { + $params['--year'] = (int) $this->filterAnno; + } + + \Illuminate\Support\Facades\Artisan::call('gescon:sync-operazioni-prima-nota', $params); + + \Filament\Notifications\Notification::make() + ->title('Riallineamento completato') + ->body('Le scritture contabili legacy sono state riallineate con successo.') + ->success() + ->send(); + + $this->resetPage(); + } catch (\Throwable $e) { + \Filament\Notifications\Notification::make() + ->title('Errore durante il riallineamento') + ->body($e->getMessage()) + ->danger() + ->send(); + } + } } diff --git a/app/Filament/Pages/Strumenti/DocumentiArchivio.php b/app/Filament/Pages/Strumenti/DocumentiArchivio.php index 9ba5f54..7ad4448 100755 --- a/app/Filament/Pages/Strumenti/DocumentiArchivio.php +++ b/app/Filament/Pages/Strumenti/DocumentiArchivio.php @@ -344,6 +344,51 @@ protected function getHeaderActions(): array ->action(function (array $data): void { $this->createArchivioFolder($data); }), + + Action::make('esporta_archivio_xml') + ->label('Esporta archivio (XML)') + ->icon('heroicon-o-arrow-down-tray') + ->color('info') + ->visible(fn(): bool => $this->hubTab === 'archivio' && $this->archiveSection === 'fisico') + ->action(function () { + $stabileId = (int) ($this->resolveActiveStabile()?->id ?? 0); + if ($stabileId <= 0) { + Notification::make()->title('Errore')->body('Nessuno stabile attivo selezionato.')->danger()->send(); + return; + } + $xml = $this->exportPhysicalArchiveToXml(); + return response()->streamDownload(function () use ($xml) { + echo $xml; + }, 'archivio_fisico_stabile_' . $stabileId . '.xml', [ + 'Content-Type' => 'application/xml', + ]); + }), + + Action::make('importa_archivio_xml') + ->label('Importa archivio (XML)') + ->icon('heroicon-o-arrow-up-tray') + ->color('success') + ->visible(fn(): bool => $this->hubTab === 'archivio' && $this->archiveSection === 'fisico') + ->form([ + FileUpload::make('xml_file') + ->label('Seleziona il file XML') + ->required() + ->acceptedFileTypes(['application/xml', 'text/xml']), + ]) + ->action(function (array $data): void { + $filePath = Storage::path($data['xml_file']); + if (!file_exists($filePath)) { + Notification::make()->title('Errore')->body('File non trovato.')->danger()->send(); + return; + } + $content = file_get_contents($filePath); + try { + $imported = $this->importPhysicalArchiveFromXml($content); + Notification::make()->title('Importazione completata')->body("Importati con successo {$imported} elementi nell'archivio fisico.")->success()->send(); + } catch (\Throwable $e) { + Notification::make()->title('Errore importazione')->body($e->getMessage())->danger()->send(); + } + }), ]; } @@ -3804,4 +3849,164 @@ private function normalizeNullableText(mixed $value): ?string return $value !== '' ? $value : null; } + + public function exportPhysicalArchiveToXml(): string + { + $stabile = $this->resolveActiveStabile(); + $stabileId = (int) ($stabile?->id ?? 0); + if ($stabileId <= 0) { + return ''; + } + + $items = DocumentoArchivioFisicoItem::query() + ->where('stabile_id', $stabileId) + ->with('parent') + ->get(); + + $dom = new \DOMDocument('1.0', 'UTF-8'); + $dom->formatOutput = true; + + $root = $dom->createElement('netgescon_physical_archive'); + $root->setAttribute('stabile_id', (string) $stabileId); + $root->setAttribute('denominazione', (string) ($stabile?->denominazione ?? '')); + $dom->appendChild($root); + + $itemsNode = $dom->createElement('items'); + $root->appendChild($itemsNode); + + foreach ($items as $item) { + $itemNode = $dom->createElement('item'); + + $fields = [ + 'codice_univoco' => $item->codice_univoco, + 'parent_code' => $item->parent?->codice_univoco ?? '', + 'tipo_item' => $item->tipo_item, + 'titolo' => $item->titolo, + 'note' => $item->note, + 'data_archiviazione' => $item->data_archiviazione?->toDateString() ?? '', + 'supporto_fisico' => $item->supporto_fisico, + 'modello_etichetta' => $item->modello_etichetta, + 'magazzino' => $item->magazzino, + 'scaffale' => $item->scaffale, + 'ripiano' => $item->ripiano, + 'ubicazione_dettaglio' => $item->ubicazione_dettaglio, + 'stato' => $item->stato, + 'sort_order' => $item->sort_order, + 'voce_numero' => $item->voce_numero, + 'voce_titolo' => $item->voce_titolo, + ]; + + foreach ($fields as $key => $val) { + if ($val !== null && $val !== '') { + $child = $dom->createElement($key); + $child->appendChild($dom->createTextNode((string) $val)); + $itemNode->appendChild($child); + } + } + + $itemsNode->appendChild($itemNode); + } + + return $dom->saveXML(); + } + + public function importPhysicalArchiveFromXml(string $xmlContent): int + { + $stabileId = (int) ($this->resolveActiveStabile()?->id ?? 0); + if ($stabileId <= 0) { + throw new \Exception('Nessuno stabile attivo selezionato.'); + } + + $dom = new \DOMDocument(); + if (!@$dom->loadXML($xmlContent)) { + throw new \Exception('Formato XML non valido.'); + } + + $xpath = new \DOMXPath($dom); + $nodes = $xpath->query('//item'); + + if ($nodes->length === 0) { + return 0; + } + + $codeMap = []; // mappa old_code => new_item_id + + // Raccogliamo tutti gli item in un array + $itemsData = []; + foreach ($nodes as $node) { + $data = []; + foreach ($node->childNodes as $child) { + if ($child->nodeType === XML_ELEMENT_NODE) { + $data[$child->nodeName] = trim($child->nodeValue); + } + } + if (!empty($data['codice_univoco'])) { + $itemsData[] = $data; + } + } + + // Importazione in passaggi successivi per rispettare la gerarchia + $insertedCount = 0; + $maxAttempts = 10; + $attempts = 0; + + while (count($itemsData) > 0 && $attempts < $maxAttempts) { + $attempts++; + $remaining = []; + + foreach ($itemsData as $data) { + $parentCode = $data['parent_code'] ?? ''; + + // Se ha un parent_code ma quel parent non è ancora stato importato, posticipiamo + if ($parentCode !== '' && !isset($codeMap[$parentCode])) { + // Controlliamo se il parent esiste già nel DB dello stabile attivo + $existingParent = DocumentoArchivioFisicoItem::query() + ->where('stabile_id', $stabileId) + ->where('codice_univoco', $parentCode) + ->first(); + + if ($existingParent) { + $codeMap[$parentCode] = $existingParent->id; + } else { + // Il parent non è ancora nel DB né importato in questa sessione: posticipa + $remaining[] = $data; + continue; + } + } + + $parentId = $parentCode !== '' ? ($codeMap[$parentCode] ?? null) : null; + + // Generiamo un nuovo codice univoco per evitare conflitti con quelli esistenti dello stabile + $newCode = DocumentoArchivioFisicoItem::generateCodice($stabileId); + + $newItem = DocumentoArchivioFisicoItem::create([ + 'stabile_id' => $stabileId, + 'parent_id' => $parentId, + 'codice_univoco' => $newCode, + 'tipo_item' => $data['tipo_item'] ?? 'documento', + 'titolo' => $data['titolo'] ?? 'Importato', + 'note' => $data['note'] ?? null, + 'data_archiviazione' => !empty($data['data_archiviazione']) ? $data['data_archiviazione'] : now()->toDateString(), + 'supporto_fisico' => $data['supporto_fisico'] ?? null, + 'modello_etichetta' => $data['modello_etichetta'] ?? '11354', + 'magazzino' => $data['magazzino'] ?? null, + 'scaffale' => $data['scaffale'] ?? null, + 'ripiano' => $data['ripiano'] ?? null, + 'ubicazione_dettaglio' => $data['ubicazione_dettaglio'] ?? null, + 'stato' => $data['stato'] ?? 'disponibile', + 'sort_order' => isset($data['sort_order']) ? (int) $data['sort_order'] : 0, + 'voce_numero' => isset($data['voce_numero']) ? (int) $data['voce_numero'] : null, + 'voce_titolo' => $data['voce_titolo'] ?? null, + 'created_by' => Auth::id(), + ]); + + $codeMap[$data['codice_univoco']] = $newItem->id; + $insertedCount++; + } + + $itemsData = $remaining; + } + + return $insertedCount; + } } diff --git a/app/Filament/Pages/SuperAdmin/EstrattoreFatturePdf.php b/app/Filament/Pages/SuperAdmin/EstrattoreFatturePdf.php new file mode 100644 index 0000000..6d51527 --- /dev/null +++ b/app/Filament/Pages/SuperAdmin/EstrattoreFatturePdf.php @@ -0,0 +1,337 @@ +hasRole('super-admin'); + } + + public function mount(): void + { + $this->mountInteractsWithTable(); + } + + protected function getTableQuery(): Builder + { + return FatturaElettronica::query() + ->with(['stabile', 'fornitore']) + ->whereNotNull('allegato_pdf_path') + ->where('allegato_pdf_path', '!=', ''); + } + + public function table(Table $table): Table + { + return $table + ->striped() + ->defaultSort('data_fattura', 'desc') + ->columns([ + TextColumn::make('stabile.denominazione') + ->label('Stabile') + ->searchable() + ->sortable(), + TextColumn::make('fornitore_denominazione') + ->label('Fornitore') + ->searchable() + ->sortable() + ->description(fn (FatturaElettronica $record): string => "P.IVA: " . $record->fornitore_piva), + TextColumn::make('numero_fattura') + ->label('Numero') + ->searchable() + ->sortable(), + TextColumn::make('data_fattura') + ->label('Data') + ->date('d/m/Y') + ->sortable(), + TextColumn::make('totale') + ->label('Totale') + ->money('EUR') + ->sortable(), + IconColumn::make('has_ocr') + ->label('OCR Ready') + ->boolean() + ->getStateUsing(function (FatturaElettronica $record): bool { + $doc = Documento::query() + ->where('documentable_type', FatturaElettronica::class) + ->where('documentable_id', $record->id) + ->first(); + return $doc && !empty($doc->contenuto_ocr); + }), + TextColumn::make('dati_utenza') + ->label('Dati Utenza Estratti') + ->html() + ->getStateUsing(function (FatturaElettronica $record): string { + $pdr = $record->acqua_contatore_matricola ?: '-'; + $cliente = $record->acqua_codice_cliente ?: '-'; + $contratto = $record->acqua_codice_contratto ?: '-'; + $consumo = $record->consumo_valore !== null ? number_format((float) $record->consumo_valore, 2, ',', '.') . ' ' . $record->consumo_unita : '-'; + + // Controlla se c'è un payload salvato in consumo_raw + $raw = is_string($record->consumo_raw) ? json_decode($record->consumo_raw, true) : null; + if ($raw && isset($raw['codici'])) { + $pdr = $raw['codici']['pdr'] ?? ($raw['codici']['pod'] ?? ($raw['codici']['utenza'] ?? $pdr)); + $cliente = $raw['codici']['cliente'] ?? $cliente; + $contratto = $raw['codici']['contratto'] ?? $contratto; + } + + return "
" + . "
Matr/POD/PDR: $pdr
" + . "
Cod. Cliente: $cliente
" + . "
Contratto: $contratto
" + . "
Consumo: $consumo
" + . "
"; + }), + ]) + ->actions([ + TableAction::make('esegui_ocr') + ->label('Esegui OCR') + ->icon('heroicon-o-cpu-chip') + ->action(function (FatturaElettronica $record): void { + $doc = Documento::query() + ->where('documentable_type', FatturaElettronica::class) + ->where('documentable_id', $record->id) + ->first(); + + if (! $doc) { + $doc = Documento::query()->create([ + 'documentable_type' => FatturaElettronica::class, + 'documentable_id' => $record->id, + 'nome_file' => $record->allegato_pdf_nome ?: 'allegato.pdf', + 'path_file' => $record->allegato_pdf_path, + 'mime_type' => $record->allegato_pdf_mime ?: 'application/pdf', + 'stabile_id' => $record->stabile_id, + 'nome' => 'Allegato PDF Fattura #' . $record->numero_fattura, + ]); + } + + try { + $res = app(PdfTextExtractionService::class)->extractAndStore($doc, true); + if (($res['status'] ?? null) === 'ok') { + Notification::make() + ->title('OCR completato con successo') + ->success() + ->send(); + } else { + Notification::make() + ->title('Errore OCR') + ->body($res['error'] ?? 'Impossibile estrarre il testo dal PDF') + ->danger() + ->send(); + } + } catch (\Throwable $e) { + Notification::make() + ->title('Errore OCR') + ->body($e->getMessage()) + ->danger() + ->send(); + } + }), + + TableAction::make('estrai_dati') + ->label('Estrai Utenza') + ->icon('heroicon-o-magnifying-glass-circle') + ->color('info') + ->form([ + Select::make('tipo_utenza') + ->label('Tipo Utenza / Parser') + ->options([ + 'gas_luce' => 'Gas / Luce (Riscaldamento)', + 'acqua' => 'Acqua (Idrico)', + ]) + ->required() + ->default(function (FatturaElettronica $record) { + // Auto-detect + $piva = $record->fornitore_piva; + $op = \App\Models\AreraOperatore::query()->where('partita_iva_normalizzata', $piva)->first(); + if ($op) { + $settori = $op->settori->pluck('nome')->map('mb_strtolower')->all(); + foreach ($settori as $s) { + if (str_contains($s, 'idric') || str_contains($s, 'acqua')) { + return 'acqua'; + } + } + } + return 'gas_luce'; + }), + ]) + ->action(function (FatturaElettronica $record, array $data): void { + $doc = Documento::query() + ->where('documentable_type', FatturaElettronica::class) + ->where('documentable_id', $record->id) + ->first(); + + if (! $doc || empty($doc->contenuto_ocr)) { + Notification::make() + ->title('Testo OCR non disponibile') + ->body('Esegui prima l\'azione OCR sul documento.') + ->warning() + ->send(); + return; + } + + $text = (string) $doc->contenuto_ocr; + $userId = (int) Auth::id(); + + if ($data['tipo_utenza'] === 'acqua') { + $parsed = app(AcquaPdfTextParser::class)->parse($text); + $ingestion = app(ConsumiAcquaIngestionService::class)->ingest( + $record, + $parsed, + null, + $userId, + true + ); + + $record->update([ + 'acqua_codice_utenza' => $parsed['codici']['utenza'] ?? null, + 'acqua_codice_cliente' => $parsed['codici']['cliente'] ?? null, + 'acqua_codice_contratto' => $parsed['codici']['contratto'] ?? null, + 'acqua_contatore_matricola' => $parsed['contatore']['matricola'] ?? null, + 'consumo_valore' => isset($parsed['consumi'][0]['valore']) ? $parsed['consumi'][0]['valore'] : null, + 'consumo_unita' => 'mc', + 'consumo_raw' => json_encode($parsed), + ]); + } else { + $parsed = app(GasLucePdfTextParser::class)->parse($text); + $ingestion = app(ConsumiRiscaldamentoIngestionService::class)->ingest( + $record, + $parsed, + null, + $userId, + true + ); + + $record->update([ + 'acqua_codice_utenza' => $parsed['codici']['pdr'] ?? ($parsed['codici']['pod'] ?? null), + 'acqua_codice_cliente' => $parsed['codici']['cliente'] ?? null, + 'acqua_codice_contratto' => $parsed['codici']['contratto'] ?? null, + 'acqua_contatore_matricola' => $parsed['codici']['pdr'] ?? ($parsed['codici']['pod'] ?? null), + 'consumo_valore' => isset($parsed['consumi'][0]['valore']) ? $parsed['consumi'][0]['valore'] : null, + 'consumo_unita' => $parsed['codici']['pod'] ? 'kWh' : 'Smc', + 'consumo_raw' => json_encode($parsed), + ]); + } + + Notification::make() + ->title('Dati estratti e allineati') + ->body('Servizio: ' . ($ingestion['status'] ?? 'n/d') . ' - Letture create: ' . ($ingestion['created_letture'] ?? 0)) + ->success() + ->send(); + }), + + TableAction::make('modifica_utenza') + ->label('Modifica Dati') + ->icon('heroicon-o-pencil') + ->color('success') + ->mountUsing(function (TextInput $codice_cliente, TextInput $codice_contratto, TextInput $pdr_pod, TextInput $consumo_valore, Select $consumo_unita, FatturaElettronica $record) { + $raw = is_string($record->consumo_raw) ? json_decode($record->consumo_raw, true) : null; + + $pdrVal = $record->acqua_contatore_matricola; + $cliVal = $record->acqua_codice_cliente; + $conVal = $record->acqua_codice_contratto; + + if ($raw && isset($raw['codici'])) { + $pdrVal = $raw['codici']['pdr'] ?? ($raw['codici']['pod'] ?? ($raw['codici']['utenza'] ?? $pdrVal)); + $cliVal = $raw['codici']['cliente'] ?? $cliVal; + $conVal = $raw['codici']['contratto'] ?? $conVal; + } + + $codice_cliente->state($cliVal); + $codice_contratto->state($conVal); + $pdr_pod->state($pdrVal); + $consumo_valore->state($record->consumo_valore); + $consumo_unita->state($record->consumo_unita ?: 'Smc'); + }) + ->form([ + TextInput::make('codice_cliente')->label('Codice Cliente'), + TextInput::make('codice_contratto')->label('Codice Contratto'), + TextInput::make('pdr_pod')->label('Matricola / POD / PDR'), + TextInput::make('consumo_valore')->label('Valore Consumo')->numeric(), + Select::make('consumo_unita') + ->label('Unità di Misura') + ->options([ + 'Smc' => 'Smc (Gas)', + 'mc' => 'mc (Acqua)', + 'kWh' => 'kWh (Energia)', + ]), + ]) + ->action(function (FatturaElettronica $record, array $data): void { + $raw = is_string($record->consumo_raw) ? json_decode($record->consumo_raw, true) : []; + if (!is_array($raw)) { + $raw = []; + } + + $raw['codici'] = [ + 'cliente' => $data['codice_cliente'], + 'contratto' => $data['codice_contratto'], + 'pdr' => $data['consumo_unita'] === 'Smc' ? $data['pdr_pod'] : null, + 'pod' => $data['consumo_unita'] === 'kWh' ? $data['pdr_pod'] : null, + 'utenza' => $data['consumo_unita'] === 'mc' ? $data['pdr_pod'] : null, + ]; + + $record->update([ + 'acqua_codice_cliente' => $data['codice_cliente'], + 'acqua_codice_contratto' => $data['codice_contratto'], + 'acqua_codice_utenza' => $data['pdr_pod'], + 'acqua_contatore_matricola' => $data['pdr_pod'], + 'consumo_valore' => $data['consumo_valore'], + 'consumo_unita' => $data['consumo_unita'], + 'consumo_raw' => json_encode($raw), + ]); + + // Forza sincronizzazione della lettura collegata + $userId = (int) Auth::id(); + if ($data['consumo_unita'] === 'mc') { + app(ConsumiAcquaIngestionService::class)->ingest($record, $raw, null, $userId, true); + } else { + app(ConsumiRiscaldamentoIngestionService::class)->ingest($record, $raw, null, $userId, true); + } + + Notification::make() + ->title('Dati utenza aggiornati e sincronizzati con successo') + ->success() + ->send(); + }), + ]); + } +} \ No newline at end of file diff --git a/app/Filament/Pages/Supporto/TicketAcqua.php b/app/Filament/Pages/Supporto/TicketAcqua.php index b6d0a17..3588bcc 100755 --- a/app/Filament/Pages/Supporto/TicketAcqua.php +++ b/app/Filament/Pages/Supporto/TicketAcqua.php @@ -47,6 +47,8 @@ class TicketAcqua extends Page public ?string $rilevatoreNome = null; + public ?string $unitaContatoreSeriale = null; + public ?string $rilevatoreContatto = null; public ?string $noteGenerali = null; @@ -559,7 +561,11 @@ private function loadUnitOptions(): void ->with(['rubricaRuoliAttivi.contatto:id,nome,cognome,ragione_sociale,tipo_contatto', 'nominativiStorici']) ->where('stabile_id', (int) $this->stabileId) ->where(function ($query): void { - $query->whereNull('attiva')->orWhere('attiva', true); + if (Schema::hasColumn('unita_immobiliari', 'attiva')) { + $query->whereNull('attiva')->orWhere('attiva', true); + } else { + $query->whereNull('stato')->orWhere('stato', 'attiva'); + } }) ->get(['id', 'codice_unita', 'denominazione', 'scala', 'piano', 'interno']) ->map(function (UnitaImmobiliare $unita): array { @@ -630,8 +636,17 @@ public function getLightUnitaSearchResultsProperty(): array private function loadPreviousReading(): void { $this->previousReading = null; + $this->unitaContatoreSeriale = null; - if (! $this->servizioId || ! $this->unitaId) { + if (! $this->unitaId) { + return; + } + + $this->unitaContatoreSeriale = UnitaImmobiliare::query() + ->whereKey($this->unitaId) + ->value('acqua_contatore_seriale'); + + if (! $this->servizioId) { return; } diff --git a/app/Filament/Pages/Supporto/TicketRiscaldamento.php b/app/Filament/Pages/Supporto/TicketRiscaldamento.php new file mode 100755 index 0000000..120dcd5 --- /dev/null +++ b/app/Filament/Pages/Supporto/TicketRiscaldamento.php @@ -0,0 +1,1190 @@ + */ + public array $newWaterPhotos = []; + + /** @var array */ + public array $pendingWaterPhotos = []; + + /** @var array */ + public array $pendingWaterLibraryPhotos = []; + + /** @var array */ + public array $waterPhotoDescriptions = []; + + /** @var array */ + public array $waterUploadCodes = []; + + public string $waterDraftProtocol = ''; + + public int $waterDraftSequence = 1; + + /** @var array */ + public array $stabileOptions = []; + + /** @var array */ + public array $servizioOptions = []; + + /** @var array */ + public array $unitaOptions = []; + + /** @var array|null */ + public ?array $previousReading = null; + + public string $unitaSearch = ''; + + public ?string $suggestedReaderName = null; + + /** @var array> */ + public array $waterClientMetadata = []; + + public static function canAccess(): bool + { + $user = Auth::user(); + + return $user instanceof User + && $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore']); + } + + public function mount(): void + { + $user = Auth::user(); + $this->dataLettura = now()->toDateString(); + $this->rilevatoreNome = $user instanceof User ? trim((string) $user->name) : null; + $this->resetWaterDraftUploadState(); + $this->loadStabileOptions(); + $this->bootstrapDefaultSelection(); + $this->loadServiceOptions(); + $this->loadUnitOptions(); + $this->applyRequestSelection(); + $this->loadPreviousReading(); + $this->syncSuggestedReaderName(); + } + + public function updatedStabileId(): void + { + $this->unitaSearch = ''; + $this->loadServiceOptions(); + $this->loadUnitOptions(); + $this->loadPreviousReading(); + $this->syncSuggestedReaderName(); + } + + public function updatedServizioId(): void + { + $this->loadPreviousReading(); + } + + public function updatedUnitaId(): void + { + $this->loadPreviousReading(); + $this->syncSuggestedReaderName(); + } + + public function updatedUnitaSearch(): void + { + $needle = trim($this->unitaSearch); + if ($needle === '') { + return; + } + + $filtered = $this->getFilteredUnitaOptionsProperty(); + if (count($filtered) === 1) { + $this->applySelectedUnita((int) ($filtered[0]['id'] ?? 0)); + } + } + + public function selectUnita(int $unitaId): void + { + $this->applySelectedUnita($unitaId, true); + } + + public function updatedPendingWaterPhotos(): void + { + $this->appendPendingWaterPhotos(); + } + + public function updatedPendingWaterLibraryPhotos(): void + { + $this->appendPendingWaterLibraryPhotos(); + } + + public function removeWaterPhoto(int $index): void + { + $file = $this->newWaterPhotos[$index] ?? null; + unset($this->newWaterPhotos[$index]); + $this->newWaterPhotos = array_values($this->newWaterPhotos); + + if (is_object($file)) { + unset($this->waterUploadCodes[$this->resolveUploadQueueKey($file, $index)]); + } + + $this->syncWaterDescriptionSlots(); + } + + /** + * @param array> $items + */ + public function updateWaterClientMetadata(array $items): void + { + $this->waterClientMetadata = array_values(array_filter(array_map(function (mixed $item): ?array { + if (! is_array($item)) { + return null; + } + + $name = trim((string) ($item['name'] ?? '')); + $size = (int) ($item['size'] ?? 0); + if ($name === '' || $size < 0) { + return null; + } + + return [ + 'source' => trim((string) ($item['source'] ?? '')), + 'name' => $name, + 'size' => $size, + 'captured_at' => trim((string) ($item['captured_at'] ?? '')), + 'device' => trim((string) ($item['device'] ?? '')), + 'gps_decimal' => is_array($item['gps_decimal'] ?? null) ? $item['gps_decimal'] : [], + 'gps_accuracy_meters' => $item['gps_accuracy_meters'] ?? null, + ]; + }, $items))); + } + + /** + * @return array}> + */ + public function getSelectedWaterUploadsProperty(): array + { + $rows = []; + + foreach ($this->newWaterPhotos as $index => $file) { + if (! is_object($file)) { + continue; + } + + $name = (string) (method_exists($file, 'getClientOriginalName') ? $file->getClientOriginalName() : ('foto-' . $index)); + $mime = (string) (method_exists($file, 'getClientMimeType') ? $file->getClientMimeType() : 'application/octet-stream'); + $protocol = $this->resolveDraftUploadCode($file, $index); + $metadata = app(TicketAttachmentUploadService::class)->mergeClientCaptureMetadata( + app(TicketAttachmentUploadService::class)->inspectUpload($file), + $this->resolveClientCaptureMetadata($name, (int) (method_exists($file, 'getSize') ? $file->getSize() : 0)), + ); + $preview = null; + + if (method_exists($file, 'temporaryUrl')) { + try { + $preview = (string) $file->temporaryUrl(); + } catch (Throwable) { + $preview = null; + } + } + + $rows[] = [ + 'index' => (int) $index, + 'name' => $this->buildDraftUploadDisplayName($protocol, $name), + 'original_name' => $name, + 'protocol' => $protocol, + 'mime' => $mime, + 'is_image' => str_starts_with($mime, 'image/'), + 'preview_url' => $preview, + 'size' => (int) (method_exists($file, 'getSize') ? $file->getSize() : 0), + 'metadata' => is_array($metadata) ? $metadata : [], + ]; + } + + return $rows; + } + + public function registraLetturaRiscaldamento(): void + { + $user = Auth::user(); + if (! $user instanceof User) { + return; + } + + $this->ensureDefaultSelections(); + if (blank($this->rilevatoreNome)) { + $this->rilevatoreNome = trim((string) ($this->suggestedReaderName ?: $user->name)); + } + + $validated = $this->validate([ + 'stabileId' => ['required', 'integer'], + 'servizioId' => ['required', 'integer'], + 'unitaId' => ['required', 'integer'], + 'letturaAttuale' => ['required', 'numeric', 'min:0'], + 'dataLettura' => ['nullable', 'date'], + 'rilevatoreNome' => ['required', 'string', 'max:255'], + 'rilevatoreContatto' => ['nullable', 'string', 'max:255'], + 'noteGenerali' => ['nullable', 'string', 'max:2000'], + 'newWaterPhotos.*' => ['nullable', 'image', 'max:8192'], + ], [ + 'stabileId.required' => 'Seleziona lo stabile.', + 'servizioId.required' => 'Seleziona il servizio riscaldamento.', + 'unitaId.required' => 'Seleziona o cerca l\'unità immobiliare.', + 'letturaAttuale.required' => 'Inserisci la lettura attuale del contatore.', + 'letturaAttuale.numeric' => 'La lettura attuale deve essere numerica.', + 'rilevatoreNome.required' => 'Inserisci il nominativo del letturista.', + 'newWaterPhotos.*.image' => 'Ogni allegato deve essere una foto valida.', + 'newWaterPhotos.*.max' => 'Ogni foto può pesare al massimo 8 MB.', + ], [ + 'stabileId' => 'stabile', + 'servizioId' => 'servizio riscaldamento', + 'unitaId' => 'unità immobiliare', + 'letturaAttuale' => 'lettura attuale', + 'rilevatoreNome' => 'nominativo letturista', + ]); + + $stabileIds = $this->resolveAccessibleStabileIds($user); + $servizio = StabileServizio::query() + ->where('id', (int) $validated['servizioId']) + ->where('stabile_id', (int) $validated['stabileId']) + ->whereIn('stabile_id', $stabileIds) + ->where('tipo', 'riscaldamento') + ->first(); + + $unita = UnitaImmobiliare::query() + ->with(['rubricaRuoliAttivi.contatto:id,nome,cognome,ragione_sociale,tipo_contatto', 'nominativiStorici']) + ->where('id', (int) $validated['unitaId']) + ->where('stabile_id', (int) $validated['stabileId']) + ->first(); + + if (! $servizio || ! $unita) { + Notification::make() + ->title('Selezione riscaldamento non valida') + ->body('Controlla stabile, servizio e unità prima di inviare la lettura.') + ->warning() + ->send(); + + return; + } + + $previous = StabileServizioLettura::query() + ->where('stabile_servizio_id', (int) $servizio->id) + ->where('unita_immobiliare_id', (int) $unita->id) + ->whereNotNull('lettura_fine') + ->orderByDesc('periodo_al') + ->orderByDesc('id') + ->first(); + + $letturaInizio = $previous?->lettura_fine !== null ? (float) $previous->lettura_fine : null; + $letturaFine = round((float) $validated['letturaAttuale'], 3); + $consumo = $letturaInizio !== null ? round($letturaFine - $letturaInizio, 3) : null; + $readerName = trim((string) ($validated['rilevatoreNome'] ?? '')); + $consistency = $this->buildReadingConsistencyCheck((int) $servizio->id, (int) $unita->id, $letturaFine, $letturaInizio); + $readingDate = filled($validated['dataLettura'] ?? null) + ? Carbon::parse((string) $validated['dataLettura']) + : now(); + $storageDirectory = $this->buildWaterStorageDirectory($servizio, $readingDate); + + if ($consumo !== null && $consumo < 0) { + $this->addError('letturaAttuale', 'La lettura attuale non può essere inferiore alla precedente.'); + + Notification::make() + ->title('Lettura non coerente') + ->body('Il valore inserito è inferiore alla lettura precedente memorizzata.') + ->warning() + ->send(); + + return; + } + + $photoAssets = []; + foreach ($this->newWaterPhotos as $index => $file) { + if (! is_object($file) || ! method_exists($file, 'store')) { + continue; + } + + $protocol = $this->resolveDraftUploadCode($file, $index); + $naming = $this->buildWaterPhotoNaming( + $unita, + $this->resolveUnitReaderLabel($unita), + $protocol, + (string) $file->getClientOriginalName(), + $readingDate, + ); + $stored = app(TicketAttachmentUploadService::class)->store( + $file, + $storageDirectory, + [ + 'stored_basename' => $naming['stored_basename'], + 'display_name' => $naming['display_name'], + ] + ); + $storedMetadata = app(TicketAttachmentUploadService::class)->mergeClientCaptureMetadata( + is_array($stored['metadata'] ?? null) ? $stored['metadata'] : [], + $this->resolveClientCaptureMetadata( + (string) (method_exists($file, 'getClientOriginalName') ? $file->getClientOriginalName() : ''), + (int) (method_exists($file, 'getSize') ? $file->getSize() : 0), + ), + ); + + $photoAssets[] = [ + 'index' => $index, + 'protocol' => $protocol, + 'path' => $stored['path'], + 'original_name' => $stored['original_name'] ?? null, + 'source_original_name' => $stored['source_original_name'] ?? null, + 'mime' => $stored['mime'] ?? null, + 'size' => $stored['size'] ?? null, + 'metadata' => $storedMetadata, + 'description' => trim((string) ($this->waterPhotoDescriptions[$index] ?? '')), + 'drive_relative_directory' => $naming['drive_relative_directory'], + 'drive_display_name' => $naming['display_name'], + 'stored_basename' => $naming['stored_basename'], + ]; + } + + $row = StabileServizioLettura::query()->create([ + 'stabile_id' => (int) $servizio->stabile_id, + 'stabile_servizio_id' => (int) $servizio->id, + 'unita_immobiliare_id' => (int) $unita->id, + 'fornitore_id' => $servizio->fornitore_id, + 'periodo_dal' => $previous?->periodo_al, + 'periodo_al' => $readingDate->toDateString(), + 'tipologia_lettura' => 'ticket_riscaldamento_mobile', + 'canale_acquisizione' => 'filament_ticket_riscaldamento', + 'workflow_stato' => 'ricevuta', + 'riferimento_acquisizione' => 'TICKET-RISCALDAMENTO:' . (int) $servizio->id . ':' . (int) $unita->id . ':' . now()->format('YmdHis'), + 'rilevatore_tipo' => 'operatore_filament', + 'rilevatore_nome' => $readerName, + 'lettura_precedente_valore' => $letturaInizio, + 'lettura_inizio' => $letturaInizio, + 'lettura_fine' => $letturaFine, + 'lettura_foto_path' => $photoAssets[0]['path'] ?? null, + 'lettura_foto_original_name' => $photoAssets[0]['original_name'] ?? null, + 'lettura_foto_metadata' => [ + 'photos' => $photoAssets, + 'contact' => trim((string) ($validated['rilevatoreContatto'] ?? '')), + 'note' => trim((string) ($validated['noteGenerali'] ?? '')), + 'drive_relative_directory' => $storageDirectory, + ], + 'consumo_valore' => $consumo, + 'consumo_unita' => 'mc', + 'raw' => [ + 'source' => 'ticket_riscaldamento_filament', + 'contact' => trim((string) ($validated['rilevatoreContatto'] ?? '')), + 'note' => trim((string) ($validated['noteGenerali'] ?? '')), + 'photo_assets' => $photoAssets, + 'drive_relative_directory' => $storageDirectory, + 'reader_label' => $this->resolveUnitReaderLabel($unita), + 'consistency_check' => $consistency, + ], + 'created_by' => (int) $user->id, + ]); + + $this->letturaAttuale = null; + $this->noteGenerali = null; + $this->dataLettura = now()->toDateString(); + $this->resetWaterDraftUploadState(); + $this->loadPreviousReading(); + $this->syncSuggestedReaderName(); + $this->dispatch('ticket-riscaldamento-draft-reset'); + + Notification::make() + ->title('Ticket riscaldamento registrato') + ->body('Lettura salvata per unità ' . ((string) ($unita->codice_unita ?: ('#' . $unita->id))) . ' con riferimento #' . $row->id . '.') + ->success() + ->send(); + + if (($consistency['status'] ?? 'ok') !== 'ok') { + Notification::make() + ->title('Controllo coerenza lettura') + ->body((string) ($consistency['summary'] ?? 'La lettura va verificata rispetto allo storico.')) + ->warning() + ->send(); + } + } + + private function loadStabileOptions(): void + { + $user = Auth::user(); + if (! $user instanceof User) { + $this->stabileOptions = []; + return; + } + + $this->stabileOptions = StabileContext::accessibleStabili($user) + ->sortBy(fn($stabile) => mb_strtolower((string) ($stabile->denominazione ?? ''))) + ->map(fn($stabile): array=> [ + 'id' => (int) $stabile->id, + 'label' => trim((string) (($stabile->codice_stabile ?? '') !== '' + ? ($stabile->codice_stabile . ' - ' . $stabile->denominazione) + : ($stabile->denominazione ?? ('Stabile #' . $stabile->id)))), + ]) + ->values() + ->all(); + } + + private function bootstrapDefaultSelection(): void + { + $user = Auth::user(); + if (! $user instanceof User) { + return; + } + + $activeId = StabileContext::resolveActiveStabileId($user); + $optionIds = array_column($this->stabileOptions, 'id'); + + if ($activeId && in_array((int) $activeId, $optionIds, true)) { + $this->stabileId = (int) $activeId; + return; + } + + $this->stabileId = $optionIds[0] ?? null; + } + + private function applyRequestSelection(): void + { + $requestedStabile = request()->integer('stabile'); + $requestedServizio = request()->integer('servizio'); + $requestedUnita = request()->integer('unita'); + + $stabileIds = array_map(static fn(array $row): int => (int) ($row['id'] ?? 0), $this->stabileOptions); + if ($requestedStabile > 0 && in_array($requestedStabile, $stabileIds, true) && $requestedStabile !== (int) $this->stabileId) { + $this->stabileId = $requestedStabile; + $this->loadServiceOptions(); + $this->loadUnitOptions(); + } + + $serviceIds = array_map(static fn(array $row): int => (int) ($row['id'] ?? 0), $this->servizioOptions); + if ($requestedServizio > 0 && in_array($requestedServizio, $serviceIds, true)) { + $this->servizioId = $requestedServizio; + } + + $unitIds = array_map(static fn(array $row): int => (int) ($row['id'] ?? 0), $this->unitaOptions); + if ($requestedUnita > 0 && in_array($requestedUnita, $unitIds, true)) { + $this->unitaId = $requestedUnita; + $this->applySelectedUnita($requestedUnita, true); + } + } + + private function loadServiceOptions(): void + { + if (! $this->stabileId) { + $this->servizioOptions = []; + $this->servizioId = null; + return; + } + + $this->ensureWaterServiceBucket(); + + $this->servizioOptions = StabileServizio::query() + ->where('stabile_id', (int) $this->stabileId) + ->where('tipo', 'riscaldamento') + ->where('attivo', true) + ->orderBy('nome') + ->get(['id', 'nome', 'codice_utenza', 'contatore_matricola']) + ->map(function (StabileServizio $servizio): array { + $bits = [trim((string) ($servizio->nome ?: 'Servizio riscaldamento #' . $servizio->id))]; + if (filled($servizio->codice_utenza)) { + $bits[] = 'Utenza ' . trim((string) $servizio->codice_utenza); + } + if (filled($servizio->contatore_matricola)) { + $bits[] = 'Contatore ' . trim((string) $servizio->contatore_matricola); + } + + return [ + 'id' => (int) $servizio->id, + 'label' => implode(' - ', array_filter($bits)), + ]; + }) + ->all(); + + $serviceIds = array_column($this->servizioOptions, 'id'); + if (! in_array((int) $this->servizioId, $serviceIds, true)) { + $this->servizioId = $serviceIds[0] ?? null; + } + } + + private function loadUnitOptions(): void + { + if (! $this->stabileId) { + $this->unitaOptions = []; + $this->unitaId = null; + return; + } + + $this->unitaOptions = UnitaImmobiliare::query() + ->with(['rubricaRuoliAttivi.contatto:id,nome,cognome,ragione_sociale,tipo_contatto', 'nominativiStorici']) + ->where('stabile_id', (int) $this->stabileId) + ->where(function ($query): void { + if (Schema::hasColumn('unita_immobiliari', 'attiva')) { + $query->whereNull('attiva')->orWhere('attiva', true); + } else { + $query->whereNull('stato')->orWhere('stato', 'attiva'); + } + }) + ->get(['id', 'codice_unita', 'denominazione', 'scala', 'piano', 'interno']) + ->map(function (UnitaImmobiliare $unita): array { + $bits = [trim((string) ($unita->codice_unita ?: ('UI #' . $unita->id)))]; + if (filled($unita->denominazione)) { + $bits[] = trim((string) $unita->denominazione); + } + + $location = array_filter([ + filled($unita->scala) ? 'Scala ' . trim((string) $unita->scala) : null, + filled($unita->piano) ? 'Piano ' . trim((string) $unita->piano) : null, + filled($unita->interno) ? 'Int. ' . trim((string) $unita->interno) : null, + ]); + + if ($location !== []) { + $bits[] = implode(' ', $location); + } + + $readerLabel = $this->resolveUnitReaderLabel($unita); + $searchNames = $this->resolveUnitSearchNames($unita); + $searchText = mb_strtolower(implode(' ', array_filter([ + implode(' ', array_filter($bits)), + ...$searchNames, + ]))); + + return [ + 'id' => (int) $unita->id, + 'label' => implode(' - ', array_filter($bits)), + 'reader_label' => $readerLabel, + 'folder_prefix' => $this->buildUnitFolderPrefix($unita), + 'search_text' => $searchText, + 'sort_key' => $this->buildUnitNaturalSortKey($unita, $readerLabel), + ]; + }) + ->all(); + + usort($this->unitaOptions, static function (array $left, array $right): int { + return strnatcasecmp((string) ($left['sort_key'] ?? ''), (string) ($right['sort_key'] ?? '')); + }); + + $unitIds = array_column($this->unitaOptions, 'id'); + if (! in_array((int) $this->unitaId, $unitIds, true)) { + $this->unitaId = $unitIds[0] ?? null; + } + } + + /** + * @return array + */ + public function getFilteredUnitaOptionsProperty(): array + { + $needle = mb_strtolower(trim($this->unitaSearch)); + if ($needle === '') { + return $this->unitaOptions; + } + + return array_values(array_filter($this->unitaOptions, fn(array $option): bool => str_contains((string) ($option['search_text'] ?? ''), $needle))); + } + + /** + * @return array + */ + public function getLightUnitaSearchResultsProperty(): array + { + return array_slice($this->getFilteredUnitaOptionsProperty(), 0, 10); + } + + private function loadPreviousReading(): void + { + $this->previousReading = null; + $this->unitaContatoreSeriale = null; + + if (! $this->unitaId) { + return; + } + + $this->unitaContatoreSeriale = UnitaImmobiliare::query() + ->whereKey($this->unitaId) + ->value('riscaldamento_contatore_seriale'); + + if (! $this->servizioId) { + return; + } + + $previous = StabileServizioLettura::query() + ->where('stabile_servizio_id', (int) $this->servizioId) + ->where('unita_immobiliare_id', (int) $this->unitaId) + ->whereNotNull('lettura_fine') + ->orderByDesc('periodo_al') + ->orderByDesc('id') + ->first(); + + if (! $previous) { + return; + } + + $this->previousReading = [ + 'value' => $previous->lettura_fine, + 'date' => $previous->periodo_al?->format('d/m/Y'), + 'id' => (int) $previous->id, + ]; + } + + private function appendPendingWaterPhotos(): void + { + $pending = array_values(array_filter($this->pendingWaterPhotos, fn($file) => is_object($file))); + if ($pending === []) { + return; + } + + $available = max(0, 4 - count($this->newWaterPhotos)); + if ($available <= 0) { + $this->pendingWaterPhotos = []; + + Notification::make() + ->title('Limite foto raggiunto') + ->body('Puoi tenere in coda al massimo 4 foto per una lettura riscaldamento.') + ->warning() + ->send(); + + return; + } + + $accepted = array_slice($pending, 0, $available); + $currentTotal = count($this->newWaterPhotos); + $this->newWaterPhotos = array_values(array_merge($this->newWaterPhotos, $accepted)); + $this->pendingWaterPhotos = []; + $this->assignDraftUploadCodes($accepted, $currentTotal); + $this->syncWaterDescriptionSlots(); + } + + private function appendPendingWaterLibraryPhotos(): void + { + $pending = array_values(array_filter($this->pendingWaterLibraryPhotos, fn($file) => is_object($file))); + if ($pending === []) { + return; + } + + $available = max(0, 4 - count($this->newWaterPhotos)); + if ($available <= 0) { + $this->pendingWaterLibraryPhotos = []; + + Notification::make() + ->title('Limite foto raggiunto') + ->body('Puoi tenere in coda al massimo 4 foto per una lettura riscaldamento.') + ->warning() + ->send(); + + return; + } + + $accepted = array_slice($pending, 0, $available); + $currentTotal = count($this->newWaterPhotos); + $this->newWaterPhotos = array_values(array_merge($this->newWaterPhotos, $accepted)); + $this->pendingWaterLibraryPhotos = []; + $this->assignDraftUploadCodes($accepted, $currentTotal); + $this->syncWaterDescriptionSlots(); + } + + private function resetWaterDraftUploadState(): void + { + $this->newWaterPhotos = []; + $this->pendingWaterPhotos = []; + $this->pendingWaterLibraryPhotos = []; + $this->waterPhotoDescriptions = []; + $this->waterUploadCodes = []; + $this->waterClientMetadata = []; + $this->waterDraftProtocol = 'TA-' . now()->format('Ymd-His'); + $this->waterDraftSequence = 1; + } + + private function syncWaterDescriptionSlots(): void + { + $next = []; + + foreach ($this->newWaterPhotos as $index => $_file) { + $next[$index] = (string) ($this->waterPhotoDescriptions[$index] ?? ''); + } + + $this->waterPhotoDescriptions = $next; + } + + /** + * @param array $files + */ + private function assignDraftUploadCodes(array $files, int $fallbackIndexStart): void + { + foreach ($files as $offset => $file) { + if (! is_object($file)) { + continue; + } + + $key = $this->resolveUploadQueueKey($file, $fallbackIndexStart + $offset); + if (isset($this->waterUploadCodes[$key])) { + continue; + } + + $this->waterUploadCodes[$key] = $this->buildDraftProtocolCode($this->waterDraftSequence); + $this->waterDraftSequence++; + } + } + + private function resolveDraftUploadCode(object $file, int $fallbackIndex): string + { + $key = $this->resolveUploadQueueKey($file, $fallbackIndex); + + if (! isset($this->waterUploadCodes[$key])) { + $this->waterUploadCodes[$key] = $this->buildDraftProtocolCode($this->waterDraftSequence); + $this->waterDraftSequence++; + } + + return $this->waterUploadCodes[$key]; + } + + private function buildDraftProtocolCode(int $sequence): string + { + return $this->waterDraftProtocol . '-F' . str_pad((string) $sequence, 2, '0', STR_PAD_LEFT); + } + + private function buildDraftUploadDisplayName(string $protocol, string $originalName): string + { + $extension = strtolower((string) pathinfo($originalName, PATHINFO_EXTENSION)); + + return $protocol . ($extension !== '' ? '.' . $extension : ''); + } + + private function resolveUploadQueueKey(object $file, int $fallbackIndex): string + { + if (method_exists($file, 'getFilename')) { + $filename = trim((string) $file->getFilename()); + if ($filename !== '') { + return $filename; + } + } + + return spl_object_hash($file) ?: 'water-upload-' . $fallbackIndex; + } + + private function syncSuggestedReaderName(): void + { + $previous = $this->suggestedReaderName; + $current = $this->resolveCurrentUnitOption(); + + $this->suggestedReaderName = trim((string) ($current['reader_label'] ?? '')) ?: null; + + if (blank($this->rilevatoreNome) || ($previous !== null && $this->rilevatoreNome === $previous)) { + $this->rilevatoreNome = $this->suggestedReaderName; + } + } + + private function ensureWaterServiceBucket(): void + { + if (! $this->stabileId) { + return; + } + + $hasActiveWaterService = StabileServizio::query() + ->where('stabile_id', (int) $this->stabileId) + ->where('tipo', 'riscaldamento') + ->where('attivo', true) + ->exists(); + + if ($hasActiveWaterService) { + return; + } + + StabileServizio::query()->firstOrCreate( + [ + 'stabile_id' => (int) $this->stabileId, + 'tipo' => 'riscaldamento', + 'nome' => 'Archivio letture riscaldamento', + ], + [ + 'attivo' => true, + 'meta' => [ + 'auto_created_by' => 'ticket-riscaldamento', + 'purpose' => 'reading-bucket', + ], + ] + ); + } + + private function ensureDefaultSelections(): void + { + if (! $this->stabileId && $this->stabileOptions !== []) { + $this->stabileId = (int) ($this->stabileOptions[0]['id'] ?? 0) ?: null; + } + + if (! $this->servizioId && $this->servizioOptions !== []) { + $this->servizioId = (int) ($this->servizioOptions[0]['id'] ?? 0) ?: null; + } + + $filtered = $this->getFilteredUnitaOptionsProperty(); + if (! $this->unitaId && $filtered !== []) { + $this->unitaId = (int) ($filtered[0]['id'] ?? 0) ?: null; + } + } + + /** + * @return array|null + */ + private function resolveCurrentUnitOption(): ?array + { + foreach ($this->unitaOptions as $option) { + if ((int) ($option['id'] ?? 0) === (int) ($this->unitaId ?? 0)) { + return $option; + } + } + + return null; + } + + /** + * @return array{status:string,summary:string,delta:?float,average:?float,rule:?string} + */ + private function buildReadingConsistencyCheck(int $servizioId, int $unitaId, float $letturaFine, ?float $letturaInizio): array + { + if ($letturaInizio === null) { + return [ + 'status' => 'ok', + 'summary' => 'Prima lettura utile disponibile per questa unità.', + 'delta' => null, + 'average' => null, + 'rule' => 'first_reading', + ]; + } + + $delta = round($letturaFine - $letturaInizio, 3); + if ($delta < 0) { + return [ + 'status' => 'danger', + 'summary' => 'La lettura inserita è inferiore alla precedente.', + 'delta' => $delta, + 'average' => null, + 'rule' => 'lower_than_previous', + ]; + } + + if ($delta === 0.0) { + return [ + 'status' => 'warning', + 'summary' => 'La lettura coincide con la precedente: verificare se il contatore è fermo o se serve un controllo.', + 'delta' => $delta, + 'average' => 0.0, + 'rule' => 'same_as_previous', + ]; + } + + $history = StabileServizioLettura::query() + ->where('stabile_servizio_id', $servizioId) + ->where('unita_immobiliare_id', $unitaId) + ->whereNotNull('consumo_valore') + ->where('consumo_valore', '>', 0) + ->orderByDesc('periodo_al') + ->orderByDesc('id') + ->limit(5) + ->pluck('consumo_valore') + ->map(fn($value): float => (float) $value) + ->all(); + + if ($history === []) { + return [ + 'status' => 'ok', + 'summary' => 'Lettura registrata: manca ancora uno storico sufficiente per confronti automatici.', + 'delta' => $delta, + 'average' => null, + 'rule' => 'no_history', + ]; + } + + $average = round(array_sum($history) / count($history), 3); + if ($average > 0 && $delta > max(30.0, $average * 3)) { + return [ + 'status' => 'warning', + 'summary' => 'La lettura produce un consumo molto più alto della media storica (' . number_format($delta, 3, ',', '.') . ' mc contro media ' . number_format($average, 3, ',', '.') . ' mc).', + 'delta' => $delta, + 'average' => $average, + 'rule' => 'much_higher_than_average', + ]; + } + + if ($average >= 1 && $delta < max(0.5, $average * 0.2)) { + return [ + 'status' => 'warning', + 'summary' => 'La lettura produce un consumo molto basso rispetto alla media storica (' . number_format($delta, 3, ',', '.') . ' mc contro media ' . number_format($average, 3, ',', '.') . ' mc).', + 'delta' => $delta, + 'average' => $average, + 'rule' => 'much_lower_than_average', + ]; + } + + return [ + 'status' => 'ok', + 'summary' => 'Lettura coerente con lo storico recente.', + 'delta' => $delta, + 'average' => $average, + 'rule' => 'within_expected_range', + ]; + } + + private function resolveUnitReaderLabel(UnitaImmobiliare $unita): string + { + $role = $unita->rubricaRuoliAttivi + ?->sortByDesc(fn($item) => (bool) ($item->is_preferito ?? false)) + ->first(); + + if ($role?->contatto) { + $label = trim((string) ($role->contatto->nome_completo ?: $role->contatto->ragione_sociale)); + if ($label !== '') { + return $label; + } + } + + $historicLabel = $this->resolveRelevantHistoricNames($unita)[0] ?? ''; + if ($historicLabel !== '') { + return $historicLabel; + } + + return trim((string) ($unita->denominazione ?? '')); + } + + /** + * @return array + */ + private function resolveUnitSearchNames(UnitaImmobiliare $unita): array + { + $names = []; + + foreach (($unita->rubricaRuoliAttivi ?? collect()) as $role) { + $contact = $role->contatto; + if (! $contact) { + continue; + } + + $label = trim((string) ($contact->nome_completo ?: $contact->ragione_sociale)); + if ($label !== '') { + $names[] = $label; + } + } + + $names = array_merge($names, $this->resolveRelevantHistoricNames($unita)); + + $fallback = trim((string) ($unita->denominazione ?? '')); + if ($fallback !== '') { + $names[] = $fallback; + } + + return array_values(array_unique(array_filter($names))); + } + + /** + * @return array + */ + private function resolveRelevantHistoricNames(UnitaImmobiliare $unita): array + { + $today = now()->toDateString(); + $historic = collect($unita->nominativiStorici ?? []); + + $current = $historic + ->filter(function ($item) use ($today): bool { + $start = trim((string) ($item->data_inizio ?? '')); + $end = trim((string) ($item->data_fine ?? '')); + + return ($start === '' || $start <= $today) + && ($end === '' || $end >= $today); + }) + ->map(fn($item): string => trim((string) ($item->nominativo ?? ''))) + ->filter() + ->unique() + ->values() + ->all(); + + if ($current !== []) { + return $current; + } + + return $historic + ->sortByDesc(fn($item): string => sprintf( + '%s|%s|%010d', + (string) ($item->data_fine ?? '9999-12-31'), + (string) ($item->data_inizio ?? ''), + (int) ($item->id ?? 0) + )) + ->map(fn($item): string => trim((string) ($item->nominativo ?? ''))) + ->filter() + ->unique() + ->take(1) + ->values() + ->all(); + } + + private function applySelectedUnita(int $unitaId, bool $syncSearchLabel = false): void + { + if ($unitaId <= 0) { + return; + } + + foreach ($this->unitaOptions as $option) { + if ((int) ($option['id'] ?? 0) !== $unitaId) { + continue; + } + + $this->unitaId = $unitaId; + if ($syncSearchLabel) { + $this->unitaSearch = (string) ($option['label'] ?? ''); + } + + $this->loadPreviousReading(); + $this->syncSuggestedReaderName(); + + return; + } + } + + private function buildUnitNaturalSortKey(UnitaImmobiliare $unita, string $readerLabel): string + { + $parts = array_filter([ + trim((string) ($unita->scala ?? '')), + trim((string) ($unita->interno ?? '')), + trim((string) ($unita->codice_unita ?? '')), + trim((string) ($unita->piano ?? '')), + trim($readerLabel), + (string) $unita->id, + ]); + + return implode(' ', $parts); + } + + private function buildUnitFolderPrefix(UnitaImmobiliare $unita): string + { + $parts = array_filter([ + filled($unita->scala) ? Str::upper(trim((string) $unita->scala)) : null, + filled($unita->interno) ? trim((string) $unita->interno) : null, + ! filled($unita->interno) && filled($unita->codice_unita) ? trim((string) $unita->codice_unita) : null, + ]); + + $raw = implode('_', $parts); + $normalized = preg_replace('/[^A-Za-z0-9_\-]+/', '_', $raw) ?? ''; + + return trim($normalized, '_-') !== '' ? trim($normalized, '_-') : ('UI_' . (int) $unita->id); + } + + private function buildWaterStorageDirectory(StabileServizio $servizio, Carbon $readingDate): string + { + $servizio->loadMissing('stabile:id,denominazione,codice_stabile'); + + $stabileLabel = trim((string) ($servizio->stabile?->denominazione ?: $servizio->stabile?->codice_stabile ?: ('stabile-' . $servizio->stabile_id))); + $stabileSlug = $this->normalizeLinuxPathSegment($stabileLabel); + + return 'condomini/' . $stabileSlug . '/utenze/riscaldamento/letture/' . $readingDate->format('Y'); + } + + /** + * @return array{stored_basename:string,display_name:string,drive_relative_directory:string} + */ + private function buildWaterPhotoNaming(UnitaImmobiliare $unita, string $readerLabel, string $protocol, string $originalName, Carbon $readingDate): array + { + $unita->loadMissing('stabile:id,denominazione,codice_stabile'); + + $extension = strtolower((string) pathinfo($originalName, PATHINFO_EXTENSION)); + $suffix = $extension !== '' ? '.' . $extension : ''; + $readerSafe = $this->normalizeLinuxPathSegment($readerLabel !== '' ? $readerLabel : ('ui-' . $unita->id)); + $folderPrefix = Str::lower($this->buildUnitFolderPrefix($unita)); + $storedBase = trim($folderPrefix . '_' . $readerSafe . '_' . Str::lower(str_replace(['.', ' '], '-', $protocol)), '_'); + + $displayParts = array_filter([ + filled($unita->interno) ? 'Int. ' . trim((string) $unita->interno) : null, + $readerLabel !== '' ? $readerLabel : null, + ]); + $displayName = implode(' ', $displayParts); + if ($displayName === '') { + $displayName = trim((string) ($unita->codice_unita ?: ('UI ' . $unita->id))); + } + + return [ + 'stored_basename' => trim($storedBase, '_-') !== '' ? trim($storedBase, '_-') : ('water-photo-' . $unita->id . '-' . $readingDate->format('YmdHis')), + 'display_name' => $displayName . $suffix, + 'drive_relative_directory' => 'condomini/' . $this->normalizeLinuxPathSegment((string) ($unita->stabile?->denominazione ?? 'stabile')) . '/utenze/riscaldamento/letture/' . $readingDate->format('Y'), + ]; + } + + private function normalizeLinuxPathSegment(string $value): string + { + $ascii = (string) Str::of($value)->ascii()->lower(); + $normalized = preg_replace('/[^a-z0-9]+/', '_', $ascii) ?? ''; + + return trim($normalized, '_') !== '' ? trim($normalized, '_') : 'n_d'; + } + + /** + * @return array + */ + private function resolveClientCaptureMetadata(string $originalName, int $size): array + { + foreach ($this->waterClientMetadata as $item) { + if (($item['name'] ?? '') === $originalName && (int) ($item['size'] ?? -1) === $size) { + return $item; + } + } + + foreach ($this->waterClientMetadata as $item) { + if (($item['name'] ?? '') === $originalName) { + return $item; + } + } + + return []; + } + + /** + * @return array + */ + private function resolveAccessibleStabileIds(User $user): array + { + return StabileContext::accessibleStabili($user) + ->pluck('id') + ->map(fn($value) => (int) $value) + ->filter(fn(int $value) => $value > 0) + ->values() + ->all(); + } +} diff --git a/app/Filament/Pages/Supporto/TicketRiscaldamentoGenerale.php b/app/Filament/Pages/Supporto/TicketRiscaldamentoGenerale.php new file mode 100755 index 0000000..d407bc0 --- /dev/null +++ b/app/Filament/Pages/Supporto/TicketRiscaldamentoGenerale.php @@ -0,0 +1,1210 @@ + */ + public array $newGeneralWaterPhotos = []; + + /** @var array */ + public array $pendingGeneralWaterPhotos = []; + + /** @var array */ + public array $generalWaterPhotoDescriptions = []; + + /** @var array */ + public array $generalWaterUploadCodes = []; + + public string $generalWaterDraftProtocol = ''; + + public int $generalWaterDraftSequence = 1; + + /** @var array */ + public array $stabileOptions = []; + + /** @var array */ + public array $servizioOptions = []; + + /** @var array */ + public array $readerOptions = []; + + /** @var array|null */ + public ?array $lastGeneralReading = null; + + /** @var array> */ + public array $generalWaterClientMetadata = []; + + public static function canAccess(): bool + { + $user = Auth::user(); + + return $user instanceof User + && $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore']); + } + + public function mount(): void + { + $user = Auth::user(); + $this->dataLettura = now()->toDateString(); + $this->rilevatoreNome = $user instanceof User ? trim((string) $user->name) : null; + $this->resetGeneralWaterDraftUploadState(); + $this->loadStabileOptions(); + $this->bootstrapDefaultSelection(); + $this->loadServiceOptions(); + $this->applyRequestSelection(); + $this->loadReaderOptions(); + $this->loadLastGeneralReading(); + } + + public function updatedStabileId(): void + { + $this->loadServiceOptions(); + $this->loadReaderOptions(); + $this->loadLastGeneralReading(); + } + + public function updatedServizioId(): void + { + $this->loadLastGeneralReading(); + } + + public function updatedRilevatoreUserId(): void + { + foreach ($this->readerOptions as $option) { + if ((int) ($option['id'] ?? 0) !== (int) ($this->rilevatoreUserId ?? 0)) { + continue; + } + + $this->rilevatoreNome = (string) ($option['label'] ?? $this->rilevatoreNome); + $this->rilevatoreTipo = (string) ($option['tipo'] ?? $this->rilevatoreTipo); + break; + } + } + + public function updatedPendingGeneralWaterPhotos(): void + { + $this->appendPendingGeneralWaterPhotos(); + } + + public function removeGeneralWaterPhoto(int $index): void + { + $file = $this->newGeneralWaterPhotos[$index] ?? null; + unset($this->newGeneralWaterPhotos[$index]); + $this->newGeneralWaterPhotos = array_values($this->newGeneralWaterPhotos); + + if (is_object($file)) { + unset($this->generalWaterUploadCodes[$this->resolveUploadQueueKey($file, $index)]); + } + + $this->syncGeneralWaterDescriptionSlots(); + } + + /** + * @param array> $items + */ + public function updateGeneralWaterClientMetadata(array $items): void + { + $this->generalWaterClientMetadata = array_values(array_filter(array_map(function (mixed $item): ?array { + if (! is_array($item)) { + return null; + } + + $name = trim((string) ($item['name'] ?? '')); + $size = (int) ($item['size'] ?? 0); + if ($name === '' || $size < 0) { + return null; + } + + return [ + 'source' => trim((string) ($item['source'] ?? '')), + 'name' => $name, + 'size' => $size, + 'captured_at' => trim((string) ($item['captured_at'] ?? '')), + 'device' => trim((string) ($item['device'] ?? '')), + 'gps_decimal' => is_array($item['gps_decimal'] ?? null) ? $item['gps_decimal'] : [], + 'gps_accuracy_meters' => $item['gps_accuracy_meters'] ?? null, + ]; + }, $items))); + } + + /** + * @return array}> + */ + public function getSelectedGeneralWaterUploadsProperty(): array + { + $rows = []; + + foreach ($this->newGeneralWaterPhotos as $index => $file) { + if (! is_object($file)) { + continue; + } + + $name = (string) (method_exists($file, 'getClientOriginalName') ? $file->getClientOriginalName() : ('foto-' . $index)); + $mime = (string) (method_exists($file, 'getClientMimeType') ? $file->getClientMimeType() : 'application/octet-stream'); + $size = (int) (method_exists($file, 'getSize') ? $file->getSize() : 0); + $protocol = $this->resolveDraftUploadCode($file, $index); + $metadata = app(TicketAttachmentUploadService::class)->mergeClientCaptureMetadata( + app(TicketAttachmentUploadService::class)->inspectUpload($file), + $this->resolveClientCaptureMetadata($name, $size), + ); + $preview = null; + + if (method_exists($file, 'temporaryUrl')) { + try { + $preview = (string) $file->temporaryUrl(); + } catch (Throwable) { + $preview = null; + } + } + + $rows[] = [ + 'index' => (int) $index, + 'name' => $this->buildDraftUploadDisplayName($protocol, $name), + 'original_name' => $name, + 'protocol' => $protocol, + 'mime' => $mime, + 'is_image' => str_starts_with($mime, 'image/'), + 'preview_url' => $preview, + 'size' => $size, + 'metadata' => is_array($metadata) ? $metadata : [], + ]; + } + + return $rows; + } + + /** + * @return array|null + */ + public function getSelectedServiceSnapshotProperty(): ?array + { + if (! $this->servizioId) { + return null; + } + + $servizio = StabileServizio::query() + ->with(['fornitore:id,ragione_sociale']) + ->where('id', (int) $this->servizioId) + ->where('stabile_id', (int) ($this->stabileId ?? 0)) + ->first(); + + if (! $servizio instanceof StabileServizio) { + return null; + } + + $smsParts = array_filter([ + trim((string) ($servizio->codice_utenza ?? '')), + trim((string) ($servizio->codice_cliente ?? '')), + trim((string) ($this->letturaAttuale ?? '')), + ]); + + return [ + 'id' => (int) $servizio->id, + 'nome' => trim((string) ($servizio->nome ?? '')), + 'fornitore' => trim((string) ($servizio->fornitore?->ragione_sociale ?? '')), + 'codice_utenza' => trim((string) ($servizio->codice_utenza ?? '')), + 'codice_cliente' => trim((string) ($servizio->codice_cliente ?? '')), + 'codice_contratto' => trim((string) ($servizio->codice_contratto ?? '')), + 'matricola' => trim((string) ($servizio->contatore_matricola ?? '')), + 'common_asset' => trim((string) data_get($servizio->meta, 'common_asset_label', '')), + 'counter_scope' => trim((string) data_get($servizio->meta, 'counter_scope', '')), + 'served_area' => trim((string) data_get($servizio->meta, 'served_area_label', '')), + 'acea_sms_preview' => count($smsParts) === 3 ? implode('#', $smsParts) : '', + 'year_folder' => (string) Carbon::parse((string) ($this->dataLettura ?: now()->toDateString()))->format('Y'), + ]; + } + + /** + * @return array> + */ + public function getRecentGeneralReadingsProperty(): array + { + if (! $this->servizioId) { + return []; + } + + return StabileServizioLettura::query() + ->where('stabile_servizio_id', (int) $this->servizioId) + ->whereNull('unita_immobiliare_id') + ->orderByDesc('periodo_al') + ->orderByDesc('id') + ->limit(6) + ->get(['id', 'periodo_al', 'lettura_fine', 'consumo_valore', 'rilevatore_nome', 'workflow_stato', 'protocollo_numero']) + ->map(fn(StabileServizioLettura $row): array=> [ + 'id' => (int) $row->id, + 'date' => $row->periodo_al?->format('d/m/Y') ?: optional($row->created_at)->format('d/m/Y'), + 'value' => $row->lettura_fine, + 'consumo' => $row->consumo_valore, + 'reader' => trim((string) ($row->rilevatore_nome ?? '')), + 'workflow' => trim((string) ($row->workflow_stato ?? '')), + 'protocollo' => trim((string) ($row->protocollo_numero ?? '')), + ]) + ->all(); + } + + /** + * @return array|null + */ + public function getLatestInvoiceReadingWindowProperty(): ?array + { + $rows = $this->recentGeneralInvoiceExtractions; + + foreach ($rows as $row) { + if (! empty($row['window_label']) || ! empty($row['window_message'])) { + return $row; + } + } + + return null; + } + + /** + * @return array> + */ + public function getRecentGeneralInvoiceExtractionsProperty(): array + { + if (! $this->servizioId || ! $this->stabileId) { + return []; + } + + $servizio = StabileServizio::query() + ->where('id', (int) $this->servizioId) + ->where('stabile_id', (int) $this->stabileId) + ->where('tipo', 'riscaldamento') + ->first(['id', 'stabile_id', 'codice_utenza', 'codice_cliente', 'codice_contratto', 'contatore_matricola']); + + if (! $servizio instanceof StabileServizio) { + return []; + } + + $identifiers = array_values(array_filter(array_unique([ + $this->normalizeRiscaldamentoIdentifier((string) ($servizio->codice_utenza ?? '')), + $this->normalizeRiscaldamentoIdentifier((string) ($servizio->codice_cliente ?? '')), + $this->normalizeRiscaldamentoIdentifier((string) ($servizio->codice_contratto ?? '')), + $this->normalizeRiscaldamentoIdentifier((string) ($servizio->contatore_matricola ?? '')), + ]))); + + $fallbackSingleService = $identifiers === [] + && StabileServizio::query() + ->where('stabile_id', (int) $this->stabileId) + ->where('tipo', 'riscaldamento') + ->where('attivo', true) + ->count() === 1; + + if ($identifiers === [] && ! $fallbackSingleService) { + return []; + } + + return FatturaElettronica::query() + ->where('stabile_id', (int) $this->stabileId) + ->whereNotNull('consumo_raw') + ->orderByDesc('data_fattura') + ->orderByDesc('id') + ->limit(40) + ->get(['id', 'numero_fattura', 'data_fattura', 'consumo_raw', 'consumo_riferimento', 'consumo_valore']) + ->map(function (FatturaElettronica $fattura) use ($identifiers, $fallbackSingleService): ?array { + $raw = is_string($fattura->consumo_raw ?? null) ? trim((string) $fattura->consumo_raw) : ''; + if ($raw === '') { + return null; + } + + $payload = json_decode($raw, true); + if (! is_array($payload)) { + return null; + } + + $payloadIdentifiers = array_values(array_filter(array_unique([ + $this->normalizeRiscaldamentoIdentifier((string) data_get($payload, 'codici.utenza', '')), + $this->normalizeRiscaldamentoIdentifier((string) data_get($payload, 'codici.cliente', '')), + $this->normalizeRiscaldamentoIdentifier((string) data_get($payload, 'codici.contratto', '')), + $this->normalizeRiscaldamentoIdentifier((string) data_get($payload, 'contatore.matricola', '')), + ]))); + + $looksLikeRiscaldamentoPayload = $this->looksLikeRiscaldamentoPayload($payload); + if ($payloadIdentifiers === [] && ! $fallbackSingleService) { + return null; + } + + if (! $fallbackSingleService && array_intersect($identifiers, $payloadIdentifiers) === []) { + return null; + } + + if ($fallbackSingleService && ! $looksLikeRiscaldamentoPayload) { + return null; + } + + $windowStart = $this->formatIsoDate((string) data_get($payload, 'finestra_autolettura.dal', '')); + $windowEnd = $this->formatIsoDate((string) data_get($payload, 'finestra_autolettura.al', '')); + $windowLabel = trim(implode(' - ', array_filter([$windowStart, $windowEnd]))); + $readings = collect(is_array($payload['riepilogo_letture'] ?? null) ? $payload['riepilogo_letture'] : []) + ->take(3) + ->map(function (array $row): string { + $tipo = trim((string) ($row['tipologia'] ?? $row['tipo'] ?? '')); + $letturaValue = is_numeric($row['valore_a'] ?? null) + ? (float) $row['valore_a'] + : (is_numeric($row['valore_da'] ?? null) ? (float) $row['valore_da'] : null); + $lettura = $letturaValue !== null + ? number_format($letturaValue, 3, ',', '.') + : 'n/d'; + $data = $this->formatIsoDate((string) ($row['data_a'] ?? $row['data_da'] ?? $row['data'] ?? '')); + $consumo = is_numeric($row['consumo'] ?? null) + ? number_format((float) $row['consumo'], 3, ',', '.') . ' mc' + : null; + + return trim(implode(' · ', array_filter([$tipo !== '' ? ucfirst($tipo) : 'Lettura', $lettura . ' mc', $data, $consumo]))); + }) + ->filter(fn(string $value): bool => $value !== '') + ->values() + ->all(); + + return [ + 'id' => (int) $fattura->id, + 'date' => optional($fattura->data_fattura)->format('d/m/Y'), + 'numero_fattura' => trim((string) ($fattura->numero_fattura ?? '')), + 'periodo_label' => trim((string) ($fattura->consumo_riferimento ?? '')), + 'consumo_mc' => is_numeric($fattura->consumo_valore) ? (float) $fattura->consumo_valore : null, + 'window_label' => $windowLabel !== '' ? $windowLabel : null, + 'window_message' => trim((string) data_get($payload, 'finestra_autolettura.messaggio', '')), + 'readings' => $readings, + 'fe_url' => FatturaElettronicaScheda::getUrl(['record' => (int) $fattura->id], panel: 'admin-filament'), + ]; + }) + ->filter() + ->take(6) + ->values() + ->all(); + } + + public function getGeneralReadingArchiveUrlProperty(): string + { + return ServiziStabileArchivio::getUrl([ + 'tab' => 'generale', + ], panel: 'admin-filament'); + } + + public function getServiceReadingArchiveUrlProperty(): string + { + return LettureServiziArchivio::getUrl([ + 'servizio' => (int) ($this->servizioId ?? 0), + ], panel: 'admin-filament'); + } + + public function registraLetturaGenerale(): void + { + $user = Auth::user(); + if (! $user instanceof User) { + return; + } + + $validated = $this->validate([ + 'stabileId' => ['required', 'integer'], + 'servizioId' => ['required', 'integer'], + 'letturaAttuale' => ['required', 'numeric', 'min:0'], + 'dataLettura' => ['required', 'date'], + 'rilevatoreNome' => ['required', 'string', 'max:255'], + 'rilevatoreTipo' => ['nullable', 'string', 'max:80'], + 'aceaWindowStart' => ['nullable', 'date'], + 'aceaWindowEnd' => ['nullable', 'date', 'after_or_equal:aceaWindowStart'], + 'aceaVisitDate' => ['nullable', 'date'], + 'aceaVisitTimeFrom' => ['nullable', 'date_format:H:i'], + 'aceaVisitTimeTo' => ['nullable', 'date_format:H:i'], + 'noteGenerali' => ['nullable', 'string', 'max:4000'], + 'newGeneralWaterPhotos.*' => ['nullable', 'image', 'max:8192'], + ], [ + 'servizioId.required' => 'Seleziona il contratto/servizio riscaldamento da usare per la lettura generale.', + 'letturaAttuale.required' => 'Inserisci la lettura attuale del contatore generale.', + 'dataLettura.required' => 'Indica la data effettiva della lettura.', + 'rilevatoreNome.required' => 'Seleziona o inserisci il nominativo del letturista.', + 'aceaWindowEnd.after_or_equal' => 'La fine finestra autolettura non puo essere precedente all\'inizio.', + ]); + + $servizio = StabileServizio::query() + ->where('id', (int) $validated['servizioId']) + ->where('stabile_id', (int) $validated['stabileId']) + ->where('tipo', 'riscaldamento') + ->where('attivo', true) + ->first(); + + if (! $servizio instanceof StabileServizio) { + Notification::make() + ->title('Contratto riscaldamento non valido') + ->body('Controlla lo stabile e il contratto Acea selezionato prima di registrare la lettura generale.') + ->warning() + ->send(); + + return; + } + + $readingDate = Carbon::parse((string) $validated['dataLettura']); + $protocol = $this->generateGeneralReadingProtocol($readingDate); + $previous = StabileServizioLettura::query() + ->where('stabile_servizio_id', (int) $servizio->id) + ->whereNull('unita_immobiliare_id') + ->whereNotNull('lettura_fine') + ->orderByDesc('periodo_al') + ->orderByDesc('id') + ->first(); + + $letturaInizio = $previous?->lettura_fine !== null ? (float) $previous->lettura_fine : null; + $letturaFine = round((float) $validated['letturaAttuale'], 3); + $consumo = $letturaInizio !== null ? round($letturaFine - $letturaInizio, 3) : null; + $consistency = $this->buildReadingConsistencyCheck((int) $servizio->id, null, $letturaFine, $letturaInizio); + + if ($consumo !== null && $consumo < 0) { + $this->addError('letturaAttuale', 'La lettura attuale non puo essere inferiore alla precedente del contatore generale.'); + return; + } + + $storageDirectory = $this->buildGeneralWaterStorageDirectory($servizio, $readingDate); + $photoAssets = []; + + foreach ($this->newGeneralWaterPhotos as $index => $file) { + if (! is_object($file) || ! method_exists($file, 'store')) { + continue; + } + + $draftCode = $this->resolveDraftUploadCode($file, $index); + $naming = $this->buildGeneralWaterPhotoNaming($servizio, $protocol, $draftCode, (string) $file->getClientOriginalName(), $readingDate); + $stored = app(TicketAttachmentUploadService::class)->store( + $file, + $storageDirectory, + [ + 'stored_basename' => $naming['stored_basename'], + 'display_name' => $naming['display_name'], + ] + ); + $storedMetadata = app(TicketAttachmentUploadService::class)->mergeClientCaptureMetadata( + is_array($stored['metadata'] ?? null) ? $stored['metadata'] : [], + $this->resolveClientCaptureMetadata( + (string) (method_exists($file, 'getClientOriginalName') ? $file->getClientOriginalName() : ''), + (int) (method_exists($file, 'getSize') ? $file->getSize() : 0), + ), + ); + + $photoAssets[] = [ + 'path' => (string) ($stored['path'] ?? ''), + 'original_name' => (string) ($stored['original_name'] ?? ''), + 'metadata' => $storedMetadata, + 'description' => trim((string) ($this->generalWaterPhotoDescriptions[$index] ?? '')), + 'drive_directory' => (string) ($naming['drive_relative_directory'] ?? ''), + 'display_name' => (string) ($naming['display_name'] ?? ''), + 'draft_protocol' => $draftCode, + ]; + } + + $reference = 'TICKET-RISCALDAMENTO-GENERALE:' . (int) $servizio->id . ':' . now()->format('YmdHis'); + $readerLabel = trim((string) $validated['rilevatoreNome']); + $snapshot = $this->getSelectedServiceSnapshotProperty(); + $row = StabileServizioLettura::query()->create([ + 'stabile_id' => (int) $validated['stabileId'], + 'stabile_servizio_id' => (int) $servizio->id, + 'fornitore_id' => $servizio->fornitore_id, + 'periodo_dal' => $previous?->periodo_al, + 'periodo_al' => $readingDate, + 'tipologia_lettura' => 'autolettura_contatore_generale', + 'canale_acquisizione' => 'filament_ticket_riscaldamento_generale', + 'riferimento_acquisizione' => $reference, + 'workflow_stato' => 'ricevuta', + 'protocollo_categoria' => 'ACQ-GEN', + 'protocollo_numero' => $protocol, + 'prossima_lettura_scadenza_at' => filled($validated['aceaVisitDate'] ?? null) ? Carbon::parse((string) $validated['aceaVisitDate']) : null, + 'deadline_lettura_at' => filled($validated['aceaWindowEnd'] ?? null) + ? Carbon::parse((string) $validated['aceaWindowEnd']) + : (filled($validated['aceaVisitDate'] ?? null) ? Carbon::parse((string) $validated['aceaVisitDate']) : null), + 'rilevatore_tipo' => trim((string) ($validated['rilevatoreTipo'] ?? '')) ?: $this->inferReaderType(), + 'rilevatore_nome' => $readerLabel, + 'lettura_precedente_valore' => $letturaInizio, + 'lettura_inizio' => $letturaInizio, + 'lettura_fine' => $letturaFine, + 'lettura_foto_path' => $photoAssets[0]['path'] ?? null, + 'lettura_foto_original_name' => $photoAssets[0]['original_name'] ?? null, + 'lettura_foto_metadata' => $photoAssets[0]['metadata'] ?? null, + 'consumo_valore' => $consumo, + 'consumo_unita' => 'mc', + 'archivio_documentale_path' => $photoAssets[0]['drive_directory'] ?? null, + 'raw' => [ + 'source' => 'ticket_riscaldamento_generale', + 'service_snapshot' => $snapshot, + 'reader_user_id' => (int) ($this->rilevatoreUserId ?? 0) ?: null, + 'reader_assignment' => [ + 'user_id' => (int) ($this->rilevatoreUserId ?? 0) ?: null, + 'name' => $readerLabel, + 'type' => $this->inferReaderType(), + ], + 'acea_schedule' => [ + 'window_start' => $validated['aceaWindowStart'] ?? null, + 'window_end' => $validated['aceaWindowEnd'] ?? null, + 'visit_date' => $validated['aceaVisitDate'] ?? null, + 'visit_time_from' => $validated['aceaVisitTimeFrom'] ?? null, + 'visit_time_to' => $validated['aceaVisitTimeTo'] ?? null, + 'channel' => $this->aceaAutoSendChannel, + ], + 'acea_submission' => [ + 'sms_number' => '3399941808', + 'call_center' => '800 130 331', + 'sms_payload' => $snapshot['acea_sms_preview'] ?? null, + 'my_acea_url' => 'https://www.aceaato2.it', + ], + 'consistency_check' => $consistency, + 'notes' => trim((string) ($validated['noteGenerali'] ?? '')), + 'photos' => $photoAssets, + ], + 'created_by' => (int) $user->id, + ]); + + $this->syncGeneralWaterScadenze($row, $servizio, $readerLabel); + $this->resetGeneralWaterDraftUploadState(); + $this->letturaAttuale = null; + $this->noteGenerali = null; + $this->dispatch('ticket-riscaldamento-generale-draft-reset'); + $this->loadLastGeneralReading(); + + Notification::make() + ->title('Lettura contatore comune registrata') + ->body('Protocollo ' . $protocol . ' salvato per il contratto selezionato.') + ->success() + ->send(); + + if (($consistency['status'] ?? 'ok') !== 'ok') { + Notification::make() + ->title('Controllo coerenza lettura') + ->body((string) ($consistency['summary'] ?? 'La lettura va verificata rispetto allo storico.')) + ->warning() + ->send(); + } + } + + private function loadStabileOptions(): void + { + $user = Auth::user(); + if (! $user instanceof User) { + $this->stabileOptions = []; + return; + } + + $this->stabileOptions = StabileContext::accessibleStabili($user) + ->map(fn(Stabile $stabile): array=> [ + 'id' => (int) $stabile->id, + 'label' => trim((string) ($stabile->codice_stabile ?? '') . ' - ' . (string) ($stabile->denominazione ?? '')), + ]) + ->values() + ->all(); + } + + private function bootstrapDefaultSelection(): void + { + $user = Auth::user(); + if (! $user instanceof User) { + return; + } + + $activeId = StabileContext::resolveActiveStabileId($user); + $optionIds = array_column($this->stabileOptions, 'id'); + if ($activeId && in_array((int) $activeId, $optionIds, true)) { + $this->stabileId = (int) $activeId; + return; + } + + $this->stabileId = $optionIds[0] ?? null; + } + + private function applyRequestSelection(): void + { + $requestedStabile = request()->integer('stabile'); + $requestedServizio = request()->integer('servizio'); + + $stabileIds = array_map(static fn(array $row): int => (int) ($row['id'] ?? 0), $this->stabileOptions); + if ($requestedStabile > 0 && in_array($requestedStabile, $stabileIds, true) && $requestedStabile !== (int) $this->stabileId) { + $this->stabileId = $requestedStabile; + $this->loadServiceOptions(); + } + + $serviceIds = array_map(static fn(array $row): int => (int) ($row['id'] ?? 0), $this->servizioOptions); + if ($requestedServizio > 0 && in_array($requestedServizio, $serviceIds, true)) { + $this->servizioId = $requestedServizio; + } + } + + private function loadServiceOptions(): void + { + if (! $this->stabileId) { + $this->servizioOptions = []; + $this->servizioId = null; + return; + } + + $this->servizioOptions = StabileServizio::query() + ->where('stabile_id', (int) $this->stabileId) + ->where('tipo', 'riscaldamento') + ->where('attivo', true) + ->orderBy('nome') + ->orderBy('codice_utenza') + ->get(['id', 'nome', 'codice_utenza', 'codice_cliente', 'codice_contratto', 'contatore_matricola', 'meta']) + ->map(function (StabileServizio $servizio): array { + $parts = [trim((string) ($servizio->nome ?: 'Contratto riscaldamento #' . $servizio->id))]; + $commonAsset = trim((string) data_get($servizio->meta, 'common_asset_label', '')); + $servedArea = trim((string) data_get($servizio->meta, 'served_area_label', '')); + $counterType = trim((string) data_get($servizio->meta, 'counter_scope', '')); + + if ($commonAsset !== '') { + $parts[] = $commonAsset; + } + if ($servedArea !== '') { + $parts[] = $servedArea; + } + if ($counterType !== '') { + $parts[] = 'Contatore ' . $this->formatCounterScopeLabel($counterType); + } + if (filled($servizio->codice_contratto)) { + $parts[] = 'Contratto ' . trim((string) $servizio->codice_contratto); + } + if (filled($servizio->codice_utenza)) { + $parts[] = 'Utenza ' . trim((string) $servizio->codice_utenza); + } + if (filled($servizio->contatore_matricola)) { + $parts[] = 'Contatore ' . trim((string) $servizio->contatore_matricola); + } + + return [ + 'id' => (int) $servizio->id, + 'label' => implode(' - ', array_unique(array_filter($parts))), + ]; + }) + ->values() + ->all(); + + $serviceIds = array_column($this->servizioOptions, 'id'); + if (! in_array((int) ($this->servizioId ?? 0), $serviceIds, true)) { + $this->servizioId = $serviceIds[0] ?? null; + } + } + + private function loadReaderOptions(): void + { + $user = Auth::user(); + if (! $user instanceof User || ! $this->stabileId) { + $this->readerOptions = []; + return; + } + + $stabile = Stabile::query() + ->with(['amministratore.user:id,name', 'collaboratori:id,name']) + ->find((int) $this->stabileId); + + $options = []; + $push = static function (array &$options, ?User $candidate, string $tipo): void { + if (! $candidate instanceof User) { + return; + } + + $options[(int) $candidate->id] = [ + 'id' => (int) $candidate->id, + 'label' => trim((string) $candidate->name), + 'tipo' => $tipo, + ]; + }; + + $push($options, $user, $user->hasRole('collaboratore') ? 'collaboratore' : 'amministrazione'); + $push($options, $stabile?->amministratore?->user, 'amministrazione'); + + foreach (($stabile?->collaboratori ?? collect()) as $collaboratore) { + $push($options, $collaboratore, 'collaboratore'); + } + + uasort($options, static fn(array $left, array $right): int => strcasecmp((string) $left['label'], (string) $right['label'])); + + $this->readerOptions = array_values($options); + + $readerIds = array_column($this->readerOptions, 'id'); + if (! in_array((int) ($this->rilevatoreUserId ?? 0), $readerIds, true)) { + $this->rilevatoreUserId = (int) $user->id; + $this->updatedRilevatoreUserId(); + } + } + + private function loadLastGeneralReading(): void + { + $this->lastGeneralReading = null; + + if (! $this->servizioId) { + return; + } + + $previous = StabileServizioLettura::query() + ->where('stabile_servizio_id', (int) $this->servizioId) + ->whereNull('unita_immobiliare_id') + ->whereNotNull('lettura_fine') + ->orderByDesc('periodo_al') + ->orderByDesc('id') + ->first(); + + if (! $previous instanceof StabileServizioLettura) { + return; + } + + $this->lastGeneralReading = [ + 'id' => (int) $previous->id, + 'date' => $previous->periodo_al?->format('d/m/Y'), + 'value' => $previous->lettura_fine, + 'reader' => trim((string) ($previous->rilevatore_nome ?? '')), + 'workflow' => trim((string) ($previous->workflow_stato ?? '')), + 'protocol' => trim((string) ($previous->protocollo_numero ?? '')), + ]; + } + + private function appendPendingGeneralWaterPhotos(): void + { + $pending = array_values(array_filter($this->pendingGeneralWaterPhotos, fn($file) => is_object($file))); + if ($pending === []) { + return; + } + + $available = max(0, 4 - count($this->newGeneralWaterPhotos)); + if ($available <= 0) { + $this->pendingGeneralWaterPhotos = []; + return; + } + + $accepted = array_slice($pending, 0, $available); + $currentTotal = count($this->newGeneralWaterPhotos); + $this->newGeneralWaterPhotos = array_values(array_merge($this->newGeneralWaterPhotos, $accepted)); + $this->pendingGeneralWaterPhotos = []; + $this->assignDraftUploadCodes($accepted, $currentTotal); + $this->syncGeneralWaterDescriptionSlots(); + } + + private function resetGeneralWaterDraftUploadState(): void + { + $this->newGeneralWaterPhotos = []; + $this->pendingGeneralWaterPhotos = []; + $this->generalWaterPhotoDescriptions = []; + $this->generalWaterUploadCodes = []; + $this->generalWaterClientMetadata = []; + $this->generalWaterDraftProtocol = 'TAG-' . now()->format('Ymd-His'); + $this->generalWaterDraftSequence = 1; + } + + private function syncGeneralWaterDescriptionSlots(): void + { + $next = []; + foreach ($this->newGeneralWaterPhotos as $index => $_file) { + $next[$index] = (string) ($this->generalWaterPhotoDescriptions[$index] ?? ''); + } + + $this->generalWaterPhotoDescriptions = $next; + } + + /** + * @param array $files + */ + private function assignDraftUploadCodes(array $files, int $fallbackIndexStart): void + { + foreach ($files as $offset => $file) { + if (! is_object($file)) { + continue; + } + + $key = $this->resolveUploadQueueKey($file, $fallbackIndexStart + $offset); + if (isset($this->generalWaterUploadCodes[$key])) { + continue; + } + + $this->generalWaterUploadCodes[$key] = $this->buildDraftProtocolCode($this->generalWaterDraftSequence); + $this->generalWaterDraftSequence++; + } + } + + private function resolveDraftUploadCode(object $file, int $fallbackIndex): string + { + $key = $this->resolveUploadQueueKey($file, $fallbackIndex); + + if (! isset($this->generalWaterUploadCodes[$key])) { + $this->generalWaterUploadCodes[$key] = $this->buildDraftProtocolCode($this->generalWaterDraftSequence); + $this->generalWaterDraftSequence++; + } + + return $this->generalWaterUploadCodes[$key]; + } + + private function buildDraftProtocolCode(int $sequence): string + { + return $this->generalWaterDraftProtocol . '-F' . str_pad((string) $sequence, 2, '0', STR_PAD_LEFT); + } + + private function buildDraftUploadDisplayName(string $protocol, string $originalName): string + { + $extension = strtolower((string) pathinfo($originalName, PATHINFO_EXTENSION)); + + return $protocol . ($extension !== '' ? '.' . $extension : ''); + } + + private function resolveUploadQueueKey(object $file, int $fallbackIndex): string + { + if (method_exists($file, 'getFilename')) { + $filename = trim((string) $file->getFilename()); + if ($filename !== '') { + return $filename; + } + } + + return spl_object_hash($file) ?: 'general-water-upload-' . $fallbackIndex; + } + + private function generateGeneralReadingProtocol(Carbon $readingDate): string + { + $year = $readingDate->format('Y'); + $next = StabileServizioLettura::query() + ->where('tipologia_lettura', 'autolettura_contatore_generale') + ->whereYear('created_at', (int) $year) + ->count() + 1; + + return 'ACQ-GEN-' . $year . '-' . str_pad((string) $next, 4, '0', STR_PAD_LEFT); + } + + private function buildGeneralWaterStorageDirectory(StabileServizio $servizio, Carbon $readingDate): string + { + $servizio->loadMissing('stabile:id,denominazione,codice_stabile'); + + $stabileLabel = trim((string) ($servizio->stabile?->denominazione ?: $servizio->stabile?->codice_stabile ?: ('stabile-' . $servizio->stabile_id))); + + return 'condomini/' . $this->normalizeLinuxPathSegment($stabileLabel) . '/utenze/riscaldamento/generale/' . $readingDate->format('Y'); + } + + /** + * @return array{stored_basename:string,display_name:string,drive_relative_directory:string} + */ + private function buildGeneralWaterPhotoNaming(StabileServizio $servizio, string $protocol, string $draftCode, string $originalName, Carbon $readingDate): array + { + $extension = strtolower((string) pathinfo($originalName, PATHINFO_EXTENSION)); + $suffix = $extension !== '' ? '.' . $extension : ''; + $serviceKey = $this->normalizeLinuxPathSegment((string) ($servizio->codice_contratto ?: $servizio->codice_utenza ?: $servizio->id)); + $base = trim(implode('_', array_filter([ + 'contatore_generale', + $serviceKey, + Str::lower(str_replace(['.', ' '], '-', $protocol)), + Str::lower(str_replace(['.', ' '], '-', $draftCode)), + ])), '_'); + + return [ + 'stored_basename' => $base !== '' ? $base : ('contatore-generale-' . $readingDate->format('YmdHis')), + 'display_name' => $protocol . '-' . $draftCode . $suffix, + 'drive_relative_directory' => $this->buildGeneralWaterStorageDirectory($servizio, $readingDate), + ]; + } + + private function normalizeLinuxPathSegment(string $value): string + { + $ascii = (string) Str::of($value)->ascii()->lower(); + $normalized = preg_replace('/[^a-z0-9]+/', '_', $ascii) ?? ''; + + return trim($normalized, '_') !== '' ? trim($normalized, '_') : 'n_d'; + } + + private function normalizeRiscaldamentoIdentifier(string $value): string + { + $normalized = strtoupper(trim($value)); + + return preg_replace('/[^A-Z0-9]/', '', $normalized) ?? ''; + } + + private function formatIsoDate(string $value): ?string + { + $value = trim($value); + if ($value === '') { + return null; + } + + try { + return Carbon::parse($value)->format('d/m/Y'); + } catch (Throwable) { + return $value; + } + } + + /** @param array $payload */ + private function looksLikeRiscaldamentoPayload(array $payload): bool + { + if (strtolower(trim((string) ($payload['type'] ?? ''))) === 'riscaldamento') { + return true; + } + + return is_array($payload['consumi'] ?? null) + || is_array($payload['riepilogo_letture'] ?? null) + || is_array($payload['finestra_autolettura'] ?? null) + || is_array($payload['codici'] ?? null); + } + + /** + * @return array + */ + private function resolveClientCaptureMetadata(string $originalName, int $size): array + { + foreach ($this->generalWaterClientMetadata as $item) { + if (($item['name'] ?? '') === $originalName && (int) ($item['size'] ?? -1) === $size) { + return $item; + } + } + + foreach ($this->generalWaterClientMetadata as $item) { + if (($item['name'] ?? '') === $originalName) { + return $item; + } + } + + return []; + } + + private function inferReaderType(): string + { + foreach ($this->readerOptions as $option) { + if ((int) ($option['id'] ?? 0) === (int) ($this->rilevatoreUserId ?? 0)) { + return (string) ($option['tipo'] ?? 'amministrazione'); + } + } + + return $this->rilevatoreTipo ?: 'amministrazione'; + } + + /** + * @return array{status:string,summary:string,delta:?float,average:?float,rule:?string} + */ + private function buildReadingConsistencyCheck(int $servizioId, ?int $unitaId, float $letturaFine, ?float $letturaInizio): array + { + if ($letturaInizio === null) { + return [ + 'status' => 'ok', + 'summary' => 'Prima lettura utile disponibile per questo contatore.', + 'delta' => null, + 'average' => null, + 'rule' => 'first_reading', + ]; + } + + $delta = round($letturaFine - $letturaInizio, 3); + if ($delta < 0) { + return [ + 'status' => 'danger', + 'summary' => 'La lettura inserita e inferiore alla precedente.', + 'delta' => $delta, + 'average' => null, + 'rule' => 'lower_than_previous', + ]; + } + + if ($delta === 0.0) { + return [ + 'status' => 'warning', + 'summary' => 'La lettura coincide con la precedente: verificare se il contatore e fermo o se serve un controllo.', + 'delta' => $delta, + 'average' => 0.0, + 'rule' => 'same_as_previous', + ]; + } + + $history = StabileServizioLettura::query() + ->where('stabile_servizio_id', $servizioId) + ->when($unitaId === null, fn($query) => $query->whereNull('unita_immobiliare_id')) + ->when($unitaId !== null, fn($query) => $query->where('unita_immobiliare_id', (int) $unitaId)) + ->whereNotNull('consumo_valore') + ->where('consumo_valore', '>', 0) + ->orderByDesc('periodo_al') + ->orderByDesc('id') + ->limit(5) + ->pluck('consumo_valore') + ->map(fn($value): float => (float) $value) + ->all(); + + if ($history === []) { + return [ + 'status' => 'ok', + 'summary' => 'Lettura registrata: manca ancora uno storico sufficiente per confronti automatici.', + 'delta' => $delta, + 'average' => null, + 'rule' => 'no_history', + ]; + } + + $average = round(array_sum($history) / count($history), 3); + if ($average > 0 && $delta > max(30.0, $average * 3)) { + return [ + 'status' => 'warning', + 'summary' => 'La lettura produce un consumo molto piu alto della media storica (' . number_format($delta, 3, ',', '.') . ' mc contro media ' . number_format($average, 3, ',', '.') . ' mc).', + 'delta' => $delta, + 'average' => $average, + 'rule' => 'much_higher_than_average', + ]; + } + + if ($average >= 1 && $delta < max(0.5, $average * 0.2)) { + return [ + 'status' => 'warning', + 'summary' => 'La lettura produce un consumo molto basso rispetto alla media storica (' . number_format($delta, 3, ',', '.') . ' mc contro media ' . number_format($average, 3, ',', '.') . ' mc).', + 'delta' => $delta, + 'average' => $average, + 'rule' => 'much_lower_than_average', + ]; + } + + return [ + 'status' => 'ok', + 'summary' => 'Lettura coerente con lo storico recente.', + 'delta' => $delta, + 'average' => $average, + 'rule' => 'within_expected_range', + ]; + } + + private function formatCounterScopeLabel(string $value): string + { + return match (strtolower(trim($value))) { + 'generale' => 'generale', + 'particolare' => 'particolare', + 'assente' => 'senza contatore', + default => trim($value) !== '' ? trim($value) : 'n/d', + }; + } + + private function syncGeneralWaterScadenze(StabileServizioLettura $reading, StabileServizio $servizio, string $readerLabel): void + { + if (! Schema::hasTable('scadenze')) { + return; + } + + $serviceName = trim((string) ($servizio->nome ?: ('Contratto #' . $servizio->id))); + $contractBits = array_filter([ + trim((string) ($servizio->codice_contratto ?? '')), + trim((string) ($servizio->codice_utenza ?? '')), + ]); + $contractLabel = $contractBits !== [] ? ' [' . implode(' / ', $contractBits) . ']' : ''; + + if (filled($this->aceaWindowStart)) { + $this->createScadenza([ + 'stabile_id' => (int) $reading->stabile_id, + 'codice_stabile_legacy' => $reading->stabile?->codice_stabile, + 'descrizione_sintetica' => 'Finestra autolettura Acea ' . $serviceName . $contractLabel . (filled($this->aceaWindowEnd) ? ' fino al ' . Carbon::parse((string) $this->aceaWindowEnd)->format('d/m/Y') : ''), + 'data_scadenza' => Carbon::parse((string) $this->aceaWindowStart)->toDateString(), + 'ora_scadenza' => null, + 'natura' => 'riscaldamento', + 'natura_label' => 'Autolettura riscaldamento generale', + 'gestione_tipo' => 'operativa', + 'anno' => Carbon::parse((string) ($this->dataLettura ?: now()->toDateString()))->format('Y'), + 'richiede_pop_up' => true, + 'giorni_anticipo' => 2, + 'popup_dal' => Carbon::parse((string) $this->aceaWindowStart)->copy()->subDays(2)->toDateString(), + 'legacy_payload' => [ + 'source' => 'ticket_riscaldamento_generale', + 'schedule_type' => 'window', + 'stabile_servizio_id' => (int) $servizio->id, + 'reading_id' => (int) $reading->id, + 'reading_reference' => $reading->riferimento_acquisizione, + 'window_end' => $this->aceaWindowEnd, + 'assigned_user_id' => (int) ($this->rilevatoreUserId ?? 0) ?: null, + 'assigned_user_name' => $readerLabel, + 'sms_payload' => data_get($reading->raw, 'acea_submission.sms_payload'), + ], + ]); + } + + if (filled($this->aceaVisitDate)) { + $this->createScadenza([ + 'stabile_id' => (int) $reading->stabile_id, + 'codice_stabile_legacy' => $reading->stabile?->codice_stabile, + 'descrizione_sintetica' => 'Passaggio Acea contatore generale ' . $serviceName . $contractLabel . ' fascia ' . trim((string) $this->aceaVisitTimeFrom) . '-' . trim((string) $this->aceaVisitTimeTo), + 'data_scadenza' => Carbon::parse((string) $this->aceaVisitDate)->toDateString(), + 'ora_scadenza' => $this->aceaVisitTimeFrom, + 'natura' => 'riscaldamento', + 'natura_label' => 'Passaggio Acea contatore generale', + 'gestione_tipo' => 'operativa', + 'anno' => Carbon::parse((string) ($this->dataLettura ?: now()->toDateString()))->format('Y'), + 'richiede_pop_up' => true, + 'giorni_anticipo' => 1, + 'popup_dal' => Carbon::parse((string) $this->aceaVisitDate)->copy()->subDay()->toDateString(), + 'legacy_payload' => [ + 'source' => 'ticket_riscaldamento_generale', + 'schedule_type' => 'visit', + 'stabile_servizio_id' => (int) $servizio->id, + 'reading_id' => (int) $reading->id, + 'reading_reference' => $reading->riferimento_acquisizione, + 'visit_time_from' => $this->aceaVisitTimeFrom, + 'visit_time_to' => $this->aceaVisitTimeTo, + 'assigned_user_id' => (int) ($this->rilevatoreUserId ?? 0) ?: null, + 'assigned_user_name' => $readerLabel, + ], + ]); + } + } + + /** + * @param array $payload + */ + private function createScadenza(array $payload): void + { + Scadenza::query()->create($payload); + } +} diff --git a/app/Filament/Pages/Supporto/TicketRiscaldamentoLight.php b/app/Filament/Pages/Supporto/TicketRiscaldamentoLight.php new file mode 100755 index 0000000..7643b88 --- /dev/null +++ b/app/Filament/Pages/Supporto/TicketRiscaldamentoLight.php @@ -0,0 +1,54 @@ +integer('unita')) { + $this->resetLightUnitSelection(); + } + } + + public function updatedStabileId(): void + { + parent::updatedStabileId(); + + $this->resetLightUnitSelection(); + } + + private function resetLightUnitSelection(): void + { + $user = Auth::user(); + + $this->unitaId = null; + $this->unitaSearch = ''; + $this->previousReading = null; + $this->suggestedReaderName = null; + + if ($user instanceof User) { + $this->rilevatoreNome = trim((string) $user->name) ?: $this->rilevatoreNome; + } + } +} diff --git a/app/Http/Controllers/Admin/RiscaldamentoRipartizionePrintController.php b/app/Http/Controllers/Admin/RiscaldamentoRipartizionePrintController.php new file mode 100755 index 0000000..e5e4a76 --- /dev/null +++ b/app/Http/Controllers/Admin/RiscaldamentoRipartizionePrintController.php @@ -0,0 +1,167 @@ +buildReportData($request) + [ + 'autoprint' => $request->boolean('autoprint', true), + ]); + } + + public function pdf(Request $request): Response + { + $data = $this->buildReportData($request); + $html = view('filament.print.riscaldamento-ripartizione-riepilogo', $data + ['autoprint' => false])->render(); + + $dompdf = new Dompdf(); + $dompdf->loadHtml($html, 'UTF-8'); + $dompdf->setPaper('A4', 'portrait'); + $dompdf->render(); + + return response($dompdf->output(), 200, [ + 'Content-Type' => 'application/pdf', + 'Content-Disposition' => 'inline; filename="riscaldamento-ripartizione-' . $data['year'] . '.pdf"', + ]); + } + + /** @return array */ + private function buildReportData(Request $request): array + { + $user = $request->user(); + abort_unless($user instanceof User, 403); + + $stabileId = (int) $request->integer('stabile_id'); + $stabile = StabileContext::accessibleStabili($user)->firstWhere('id', $stabileId); + abort_unless($stabile, 403); + + $year = max(2000, min(2100, (int) $request->integer('year', now()->year))); + $selectedIds = collect(explode(',', (string) $request->query('ids', ''))) + ->map(fn(string $value): int => (int) trim($value)) + ->filter(fn(int $value): bool => $value > 0) + ->values() + ->all(); + + $fattureRows = FatturaElettronica::query() + ->where('stabile_id', (int) $stabile->id) + ->whereYear('data_fattura', $year) + ->when($selectedIds !== [], fn($query) => $query->whereIn('id', $selectedIds)) + ->whereNotNull('consumo_raw') + ->orderByDesc('data_fattura') + ->orderByDesc('id') + ->get(['id', 'numero_fattura', 'data_fattura', 'totale', 'consumo_raw', 'consumo_riferimento', 'consumo_valore']) + ->map(function (FatturaElettronica $fattura): ?array { + $payload = json_decode((string) $fattura->consumo_raw, true); + if (! $this->payloadLooksLikeRiscaldamento(is_array($payload) ? $payload : null)) { + return null; + } + + return [ + 'id' => (int) $fattura->id, + 'numero_fattura' => trim((string) ($fattura->numero_fattura ?? '')), + 'data_fattura' => optional($fattura->data_fattura)->format('d/m/Y'), + 'periodo' => trim((string) ($fattura->consumo_riferimento ?? '')), + 'totale_mc' => is_numeric($fattura->consumo_valore) ? (float) $fattura->consumo_valore : 0.0, + 'totale_spesa' => is_numeric($fattura->totale) ? (float) $fattura->totale : 0.0, + 'utenza' => trim((string) data_get($payload, 'codici.utenza', '')), + 'cliente' => trim((string) data_get($payload, 'codici.cliente', '')), + 'contratto' => trim((string) data_get($payload, 'codici.contratto', '')), + 'matricola' => trim((string) data_get($payload, 'contatore.matricola', '')), + 'cbill' => trim((string) data_get($payload, 'pagamento.cbill', '')), + 'codice_avviso' => trim((string) data_get($payload, 'pagamento.codice_avviso', '')), + ]; + }) + ->filter() + ->values(); + + $unitLabels = UnitaImmobiliare::query() + ->where('stabile_id', (int) $stabile->id) + ->get(['id', 'codice_unita', 'scala', 'interno']) + ->mapWithKeys(function (UnitaImmobiliare $unit): array { + $label = trim(implode(' ', array_filter([ + trim((string) ($unit->codice_unita ?? '')), + trim((string) ($unit->scala ?? '')) !== '' ? 'Scala ' . trim((string) $unit->scala) : null, + trim((string) ($unit->interno ?? '')) !== '' ? 'Int. ' . trim((string) $unit->interno) : null, + ]))); + + return [(int) $unit->id => ($label !== '' ? $label : ('UI #' . (int) $unit->id))]; + }) + ->all(); + + $lettureRows = StabileServizioLettura::query() + ->where('stabile_id', (int) $stabile->id) + ->whereNotNull('unita_immobiliare_id') + ->whereHas('servizio', fn($q) => $q->where('tipo', 'riscaldamento')) + ->where(function ($query) use ($year): void { + $query->whereYear('periodo_al', $year) + ->orWhere(function ($fallback) use ($year): void { + $fallback->whereNull('periodo_al')->whereYear('periodo_dal', $year); + }) + ->orWhere(function ($fallback) use ($year): void { + $fallback->whereNull('periodo_al')->whereNull('periodo_dal')->whereYear('created_at', $year); + }); + }) + ->with(['servizio:id,nome,contatore_matricola']) + ->orderByDesc('created_at') + ->orderByDesc('id') + ->get(['id', 'unita_immobiliare_id', 'created_at', 'canale_acquisizione', 'riferimento_acquisizione', 'periodo_dal', 'periodo_al', 'lettura_fine', 'consumo_valore', 'consumo_unita', 'stabile_servizio_id']) + ->map(function (StabileServizioLettura $row) use ($unitLabels): array { + return [ + 'data_ricezione' => optional($row->created_at)->format('d/m/Y H:i'), + 'servizio' => trim((string) ($row->servizio?->nome ?? 'Riscaldamento')), + 'matricola' => trim((string) ($row->servizio?->contatore_matricola ?? '')), + 'unita' => $unitLabels[(int) ($row->unita_immobiliare_id ?? 0)] ?? ('UI #' . (int) $row->unita_immobiliare_id), + 'canale' => strtoupper(trim((string) ($row->canale_acquisizione ?? '—'))), + 'riferimento' => trim((string) ($row->riferimento_acquisizione ?? '')), + 'periodo' => trim(implode(' - ', array_filter([ + optional($row->periodo_dal)->format('d/m/Y'), + optional($row->periodo_al)->format('d/m/Y'), + ]))), + 'lettura_fine' => is_numeric($row->lettura_fine) ? (float) $row->lettura_fine : null, + 'consumo_valore' => is_numeric($row->consumo_valore) ? (float) $row->consumo_valore : null, + 'consumo_unita' => trim((string) ($row->consumo_unita ?: 'mc')), + ]; + }) + ->all(); + + return [ + 'stabile' => $stabile, + 'year' => $year, + 'fattureRows' => $fattureRows, + 'lettureRows' => $lettureRows, + 'summary' => [ + 'fatture' => $fattureRows->count(), + 'totale_mc' => round((float) $fattureRows->sum('totale_mc'), 3), + 'totale_spesa' => round((float) $fattureRows->sum('totale_spesa'), 2), + 'letture_ui' => count($lettureRows), + ], + 'generatedAt' => Carbon::now(), + ]; + } + + private function payloadLooksLikeRiscaldamento(?array $payload): bool + { + if (! is_array($payload) || $payload === []) { + return false; + } + + return ($payload['type'] ?? null) === 'riscaldamento' + || is_array($payload['consumi'] ?? null) + || is_array($payload['riepilogo_letture'] ?? null) + || is_array($payload['finestra_autolettura'] ?? null) + || is_array($payload['codici'] ?? null); + } +} diff --git a/app/Livewire/Condomini/StabileDatiBancariTable.php b/app/Livewire/Condomini/StabileDatiBancariTable.php index 0217d1a..b2df96b 100755 --- a/app/Livewire/Condomini/StabileDatiBancariTable.php +++ b/app/Livewire/Condomini/StabileDatiBancariTable.php @@ -30,10 +30,16 @@ class StabileDatiBancariTable extends TableComponent { public int $stabileId; + public ?int $editingContoId = null; + public bool $isCreatingConto = false; + public array $contoFormState = []; + public array $contattiBancaOptions = []; + public function mount(int $stabileId): void { $this->stabileId = $stabileId; $this->mountInteractsWithTable(); + $this->loadContattiBancaOptions(); } protected function getTableQuery(): Builder @@ -169,25 +175,29 @@ public function table(Table $table): Table ->label('Nuovo conto') ->icon('heroicon-o-plus') ->color('success') - ->modalHeading('Nuovo conto') - ->form($this->formSchema()) - ->action(function (array $data): void { - $user = Auth::user(); - $uid = $user instanceof User ? (int) $user->id : null; - - $payload = $this->normalizePayload($data); - $payload['stabile_id'] = (int) $this->stabileId; - if ($uid) { - $payload['creato_da'] = $uid; - $payload['modificato_da'] = $uid; - } - - DatiBancari::create($payload); - - Notification::make() - ->title('Conto creato') - ->success() - ->send(); + ->action(function (): void { + $this->isCreatingConto = true; + $this->editingContoId = null; + $this->contoFormState = [ + 'denominazione_banca' => '', + 'tipo_conto' => 'corrente', + 'stato_conto' => 'attivo', + 'iban' => '', + 'numero_conto' => '', + 'abi' => '', + 'cab' => '', + 'cin' => '', + 'bic_swift' => '', + 'cuc' => '', + 'sia' => '', + 'intestazione_conto' => '', + 'data_saldo_iniziale' => null, + 'saldo_iniziale' => 0, + 'valuta' => 'EUR', + 'is_nostro_conto' => false, + 'contatto_id' => null, + 'note' => '', + ]; }), ]) ->actions([ @@ -208,9 +218,9 @@ public function table(Table $table): Table ->openUrlInNewTab(), Action::make('saldi') - ->label('Saldi') + ->label('Saldi ed estratti') ->icon('heroicon-o-scale') - ->url(fn(DatiBancari $record): string => SaldiContiArchivio::getUrl(['conto_id' => (int) $record->id], panel: 'admin-filament')) + ->url(fn(DatiBancari $record): string => CasseBancheMovimenti::getUrl(panel: 'admin-filament', parameters: ['conto_id' => (int) $record->id, 'tab' => 'saldi'])) ->openUrlInNewTab(), Action::make('contatto') @@ -224,46 +234,29 @@ public function table(Table $table): Table Action::make('modifica') ->label('Modifica') ->icon('heroicon-o-pencil-square') - ->modalHeading('Modifica conto') - ->fillForm(fn(DatiBancari $record): array=> [ - 'denominazione_banca' => $record->denominazione_banca, - 'tipo_conto' => $record->tipo_conto, - 'stato_conto' => $record->stato_conto, - 'iban' => $record->iban, - 'numero_conto' => $record->numero_conto, - 'abi' => $record->abi, - 'cab' => $record->cab, - 'cin' => $record->cin, - 'bic_swift' => $record->bic_swift, - 'cuc' => $record->cuc, - 'sia' => $record->sia, - 'intestazione_conto' => $record->intestazione_conto, - 'data_saldo_iniziale' => $record->data_saldo_iniziale, - 'saldo_iniziale' => $record->saldo_iniziale, - 'valuta' => $record->valuta, - 'is_nostro_conto' => (bool) $record->is_nostro_conto, - 'contatto_id' => $record->contatto_id, - 'note' => $record->note, - ]) - ->form($this->formSchema()) - ->action(function (DatiBancari $record, array $data): void { - $user = Auth::user(); - $uid = $user instanceof User ? (int) $user->id : null; - - $payload = $this->normalizePayload($data); - if ($uid) { - $payload['modificato_da'] = $uid; - } - - $record->fill($payload); - if ($record->isDirty()) { - $record->save(); - } - - Notification::make() - ->title('Conto aggiornato') - ->success() - ->send(); + ->action(function (DatiBancari $record): void { + $this->editingContoId = $record->id; + $this->isCreatingConto = false; + $this->contoFormState = [ + 'denominazione_banca' => $record->denominazione_banca, + 'tipo_conto' => $record->tipo_conto, + 'stato_conto' => $record->stato_conto, + 'iban' => $record->iban, + 'numero_conto' => $record->numero_conto, + 'abi' => $record->abi, + 'cab' => $record->cab, + 'cin' => $record->cin, + 'bic_swift' => $record->bic_swift, + 'cuc' => $record->cuc, + 'sia' => $record->sia, + 'intestazione_conto' => $record->intestazione_conto, + 'data_saldo_iniziale' => $record->data_saldo_iniziale?->format('Y-m-d'), + 'saldo_iniziale' => $record->saldo_iniziale, + 'valuta' => $record->valuta, + 'is_nostro_conto' => (bool) $record->is_nostro_conto, + 'contatto_id' => $record->contatto_id, + 'note' => $record->note, + ]; }), Action::make('elimina') @@ -489,4 +482,88 @@ private function resolveLatestSaldoSnapshotLabel(DatiBancari $record): string return implode(' · ', $parts); } + + public function loadContattiBancaOptions(): void + { + $records = RubricaUniversale::query() + ->whereNull('deleted_at') + ->orderBy('ragione_sociale') + ->orderBy('cognome') + ->orderBy('nome') + ->get(['id', 'ragione_sociale', 'nome', 'cognome']); + + $this->contattiBancaOptions = $records + ->mapWithKeys(function (RubricaUniversale $r): array { + $label = trim((string) ($r->nome_completo ?? '')); + if ($label === '') { + $label = trim((string) ($r->ragione_sociale ?? '')); + } + if ($label === '') { + $label = trim((string) (($r->cognome ?? '') . ' ' . ($r->nome ?? ''))); + } + if ($label === '') { + $label = 'Contatto #' . (int) $r->id; + } + return [(int) $r->id => $label]; + }) + ->all(); + } + + public function saveContoInline(): void + { + $user = Auth::user(); + $uid = $user instanceof User ? (int) $user->id : null; + + $validatedData = validator($this->contoFormState, [ + 'denominazione_banca' => ['required', 'string', 'max:255'], + 'tipo_conto' => ['required', 'string'], + 'stato_conto' => ['required', 'string'], + 'iban' => ['nullable', 'string', 'max:64'], + 'numero_conto' => ['nullable', 'string', 'max:64'], + 'abi' => ['nullable', 'string', 'max:16'], + 'cab' => ['nullable', 'string', 'max:16'], + 'cin' => ['nullable', 'string', 'max:8'], + 'bic_swift' => ['nullable', 'string', 'max:32'], + 'cuc' => ['nullable', 'string', 'max:32'], + 'sia' => ['nullable', 'string', 'max:32'], + 'intestazione_conto' => ['nullable', 'string', 'max:255'], + 'data_saldo_iniziale' => ['nullable', 'date'], + 'saldo_iniziale' => ['nullable', 'numeric'], + 'valuta' => ['nullable', 'string', 'max:8'], + 'is_nostro_conto' => ['boolean'], + 'contatto_id' => ['nullable', 'integer'], + 'note' => ['nullable', 'string'], + ])->validate(); + + $payload = $this->normalizePayload($validatedData); + + if ($this->isCreatingConto) { + $payload['stabile_id'] = (int) $this->stabileId; + if ($uid) { + $payload['creato_da'] = $uid; + $payload['modificato_da'] = $uid; + } + DatiBancari::create($payload); + Notification::make()->title('Conto creato con successo')->success()->send(); + } else { + $record = DatiBancari::find($this->editingContoId); + if ($record) { + if ($uid) { + $payload['modificato_da'] = $uid; + } + $record->fill($payload); + $record->save(); + Notification::make()->title('Conto aggiornato con successo')->success()->send(); + } + } + + $this->cancelInlineForm(); + } + + public function cancelInlineForm(): void + { + $this->isCreatingConto = false; + $this->editingContoId = null; + $this->contoFormState = []; + } } diff --git a/app/Models/DettaglioMillesimi.php b/app/Models/DettaglioMillesimi.php index c855637..2b7c889 100755 --- a/app/Models/DettaglioMillesimi.php +++ b/app/Models/DettaglioMillesimi.php @@ -16,13 +16,17 @@ class DettaglioMillesimi extends Model 'unita_immobiliare_id', 'millesimi', 'partecipa', + 'nord', + 'ruolo_legacy', 'note', 'created_by' ]; protected $casts = [ 'millesimi' => 'decimal:4', - 'partecipa' => 'boolean' + 'partecipa' => 'boolean', + 'nord' => 'integer', + 'ruolo_legacy' => 'string' ]; /** diff --git a/app/Models/RipartizioneSpese.php b/app/Models/RipartizioneSpese.php index 1cc8d82..e1d5cfb 100755 --- a/app/Models/RipartizioneSpese.php +++ b/app/Models/RipartizioneSpese.php @@ -219,6 +219,30 @@ public function calcolaRipartizione() throw new \Exception('Tabella millesimale non specificata'); } + $totaleMillesimi = (float) ($this->tabellaMillesimale->totale_millesimi ?? 1000); + $sommaDettagli = (float) $this->tabellaMillesimale->dettagli()->sum('millesimi'); + + $isConsuntivo = false; + if ($this->voceSpesa) { + if (isset($this->voceSpesa->importo_consuntivo) && $this->voceSpesa->importo_consuntivo !== null) { + $isConsuntivo = true; + } + if ($this->voceSpesa->gestioneContabile && in_array($this->voceSpesa->gestioneContabile->stato, ['chiusa', 'consolidata'], true)) { + $isConsuntivo = true; + } + } + if (request() && request()->input('tipo_ripartizione') === 'consuntivo') { + $isConsuntivo = true; + } + + if (abs($totaleMillesimi - $sommaDettagli) > 0.01) { + if ($isConsuntivo) { + throw new \Exception("La somma dei millesimi di dettaglio ({$sommaDettagli}) non corrisponde al totale della tabella {$this->tabellaMillesimale->codice_tabella} ({$totaleMillesimi}). Ripartizione bloccata a consuntivo."); + } else { + \Illuminate\Support\Facades\Log::warning("La somma dei millesimi di dettaglio ({$sommaDettagli}) non corrisponde al totale della tabella {$this->tabellaMillesimale->codice_tabella} ({$totaleMillesimi})."); + } + } + $unitaImmobiliari = $this->stabile->unitaImmobiliari; $dettagliMillesimali = $this->tabellaMillesimale->dettagli; diff --git a/app/Models/Stabile.php b/app/Models/Stabile.php index 19a98c3..5714f47 100755 --- a/app/Models/Stabile.php +++ b/app/Models/Stabile.php @@ -118,6 +118,7 @@ class Stabile extends Model 'registro_anagrafe', 'documenti_path', 'attivo', + 'rubrica_id', ]; protected $casts = [ @@ -564,6 +565,39 @@ public function gestioniAttive() return $this->gestioniCondominiali()->where('stato', 'ATTIVA'); } + /** + * Sincronizza o crea il contatto dello stabile nella Rubrica Universale. + */ + public function syncRubricaContatto(): RubricaUniversale + { + $rubrica = $this->rubrica; + + $data = [ + 'amministratore_id' => $this->amministratore_id, + 'codice_univoco' => 'STABILE-' . $this->codice_stabile, + 'categoria' => 'condomino', // Usiamo condomino o altra categoria gestita + 'tipo_contatto' => 'persona_giuridica', + 'ragione_sociale' => $this->denominazione, + 'cognome' => null, + 'nome' => null, + 'codice_fiscale' => $this->codice_fiscale, + 'indirizzo' => $this->indirizzo, + 'citta' => $this->citta, + 'cap' => $this->cap, + 'provincia' => $this->provincia, + 'stato' => 'attivo', + ]; + + if ($rubrica) { + $rubrica->update($data); + } else { + $rubrica = RubricaUniversale::create($data); + $this->update(['rubrica_id' => $rubrica->id]); + } + + return $rubrica; + } + /** * Collaboratori assegnati allo stabile (tramite pivot) */ diff --git a/app/Models/UnitaImmobiliare.php b/app/Models/UnitaImmobiliare.php index 6afabfe..9223fa2 100755 --- a/app/Models/UnitaImmobiliare.php +++ b/app/Models/UnitaImmobiliare.php @@ -253,7 +253,12 @@ public function unitaRecapitiServizio(): HasMany // Scope methods public function scopeAttive($query) { - return $query->where('attiva', true); + if (\Illuminate\Support\Facades\Schema::hasColumn('unita_immobiliari', 'attiva')) { + return $query->where('attiva', true); + } + return $query->where(function($q) { + $q->whereNull('stato')->orWhere('stato', 'attiva'); + }); } public function scopePerStabile($query, $stabileId) diff --git a/app/Modules/Contabilita/Models/Registrazione.php b/app/Modules/Contabilita/Models/Registrazione.php index 05a3dd4..0897d0b 100755 --- a/app/Modules/Contabilita/Models/Registrazione.php +++ b/app/Modules/Contabilita/Models/Registrazione.php @@ -42,6 +42,13 @@ protected static function booted(): void { static::creating(function (self $model): void { $model->applyAutoProtocols(); + + if (empty($model->entry_date) && ! empty($model->data_registrazione)) { + $model->entry_date = $model->data_registrazione; + } + if (empty($model->data_registrazione) && ! empty($model->entry_date)) { + $model->data_registrazione = $model->entry_date; + } }); } @@ -167,11 +174,23 @@ private function generateGlobalProtocol(int $stabileId, int $year): string { $base = 'PN' . $year; - $max = DB::table('contabilita_registrazioni') - ->where('stabile_id', $stabileId) - ->where('protocol', 'like', $base . '-%') - ->selectRaw('MAX(CAST(SUBSTRING_INDEX(protocol, "-", -1) AS UNSIGNED)) as mx') - ->value('mx'); + if (DB::getDriverName() === 'sqlite') { + $max = DB::table('contabilita_registrazioni') + ->where('stabile_id', $stabileId) + ->where('protocol', 'like', $base . '-%') + ->get(['protocol']) + ->map(function ($r) { + $parts = explode('-', $r->protocol); + return (int) end($parts); + }) + ->max() ?? 0; + } else { + $max = DB::table('contabilita_registrazioni') + ->where('stabile_id', $stabileId) + ->where('protocol', 'like', $base . '-%') + ->selectRaw('MAX(CAST(SUBSTRING_INDEX(protocol, "-", -1) AS UNSIGNED)) as mx') + ->value('mx'); + } $next = ((int) $max) + 1; @@ -292,4 +311,14 @@ public static function resolveGestioneIdOrDefault(int $gestioneId, int $stabileI return $match?->id ?: ($gestioneId > 0 ? $gestioneId : null); } + + public function getDescrizioneAttribute(): ?string + { + return $this->attributes['description'] ?? null; + } + + public function setDescrizioneAttribute(?string $value): void + { + $this->attributes['description'] = $value; + } } diff --git a/app/Modules/Contabilita/Services/MovimentoBancaPrimaNotaService.php b/app/Modules/Contabilita/Services/MovimentoBancaPrimaNotaService.php index 67ef028..eb3fe74 100755 --- a/app/Modules/Contabilita/Services/MovimentoBancaPrimaNotaService.php +++ b/app/Modules/Contabilita/Services/MovimentoBancaPrimaNotaService.php @@ -109,10 +109,16 @@ public function generaRegistrazioneDaMovimento(User $user, MovimentoBanca $movim : null), 'data_registrazione' => $data, 'descrizione' => $descr, - 'created_by' => (int) $user->id, - 'updated_by' => (int) $user->id, ]; + if (Schema::hasColumn('contabilita_registrazioni', 'created_by')) { + $regPayload['created_by'] = (int) $user->id; + } + + if (Schema::hasColumn('contabilita_registrazioni', 'updated_by')) { + $regPayload['updated_by'] = (int) $user->id; + } + if (Schema::hasColumn('contabilita_registrazioni', 'user_id')) { $regPayload['user_id'] = (int) $user->id; } diff --git a/app/Services/Consumi/ConsumiRiscaldamentoIngestionService.php b/app/Services/Consumi/ConsumiRiscaldamentoIngestionService.php new file mode 100644 index 0000000..34ba835 --- /dev/null +++ b/app/Services/Consumi/ConsumiRiscaldamentoIngestionService.php @@ -0,0 +1,155 @@ +} $parsed + * @return array{status: string, servizio_id?: int, created_letture?: int, updated_letture?: int, tickets?: int, message?: string} + */ + public function ingest( + FatturaElettronica $fattura, + array $parsed, + ?int $voceSpesaId, + int $userId, + bool $createTicketOnMismatch = true, + ?int $forceServizioId = null, + ): array { + if (! Schema::hasTable('stabile_servizi') || ! Schema::hasTable('stabile_servizio_letture')) { + return ['status' => 'skipped', 'message' => 'Tabelle servizi/letture non presenti']; + } + + $stabileId = (int) ($fattura->stabile_id ?: 0); + if ($stabileId <= 0) { + return ['status' => 'error', 'message' => 'Stabile non disponibile']; + } + + $codici = is_array($parsed['codici'] ?? null) ? $parsed['codici'] : []; + $consumi = is_array($parsed['consumi'] ?? null) ? $parsed['consumi'] : []; + $pagamento = is_array($parsed['pagamento'] ?? null) ? $parsed['pagamento'] : []; + + $pdr = is_string($codici['pdr'] ?? null) ? trim((string) $codici['pdr']) : ''; + $pod = is_string($codici['pod'] ?? null) ? trim((string) $codici['pod']) : ''; + $cliente = is_string($codici['cliente'] ?? null) ? trim((string) $codici['cliente']) : ''; + $contratto = is_string($codici['contratto'] ?? null) ? trim((string) $codici['contratto']) : ''; + + $tipoServizio = 'riscaldamento'; + $unitaMisura = 'mc'; + $identificativo = $pdr ?: $pod; + + if ($pod !== '') { + $tipoServizio = 'riscaldamento'; // Rimaniamo su riscaldamento come contenitore per riscaldamento/utenze + $unitaMisura = 'kWh'; + } + + $servizio = null; + if ($forceServizioId) { + $servizio = StabileServizio::query() + ->where('stabile_id', $stabileId) + ->where('tipo', 'riscaldamento') + ->whereKey($forceServizioId) + ->first(); + } + + if (! $servizio) { + // Cerca per PDR/POD o codici + $servizio = StabileServizio::query() + ->where('stabile_id', $stabileId) + ->where('tipo', 'riscaldamento') + ->where(function ($q) use ($pdr, $pod, $cliente, $contratto) { + if ($pdr !== '') $q->orWhere('codice_utenza', $pdr)->orWhere('contatore_matricola', $pdr); + if ($pod !== '') $q->orWhere('codice_utenza', $pod)->orWhere('contatore_matricola', $pod); + if ($cliente !== '') $q->orWhere('codice_cliente', $cliente); + if ($contratto !== '') $q->orWhere('codice_contratto', $contratto); + }) + ->first(); + } + + if (! $servizio) { + // Crea un nuovo servizio di riscaldamento + $name = 'Riscaldamento / Utenza'; + if ($identificativo !== '') { + $name .= ' - ' . $identificativo; + } + $servizio = new StabileServizio([ + 'stabile_id' => $stabileId, + 'tipo' => 'riscaldamento', + 'nome' => $name, + 'attivo' => true, + ]); + } + + if ((int) ($fattura->fornitore_id ?? 0) > 0 && ! $servizio->fornitore_id) { + $servizio->fornitore_id = (int) $fattura->fornitore_id; + } + + if ($voceSpesaId && $voceSpesaId > 0) { + $servizio->voce_spesa_id = $voceSpesaId; + } + + if ($pdr !== '' && empty($servizio->codice_utenza)) $servizio->codice_utenza = $pdr; + if ($pod !== '' && empty($servizio->codice_utenza)) $servizio->codice_utenza = $pod; + if ($cliente !== '' && empty($servizio->codice_cliente)) $servizio->codice_cliente = $cliente; + if ($contratto !== '' && empty($servizio->codice_contratto)) $servizio->codice_contratto = $contratto; + if ($identificativo !== '' && empty($servizio->contatore_matricola)) $servizio->contatore_matricola = $identificativo; + + $servizio->save(); + + $created = 0; + $updated = 0; + + foreach ($consumi as $c) { + $dal = $c['dal'] ?? null; + $al = $c['al'] ?? null; + $val = $c['valore'] ?? null; + $uni = $c['unita'] ?? $unitaMisura; + + $lettura = StabileServizioLettura::query()->firstOrNew([ + 'stabile_id' => $stabileId, + 'stabile_servizio_id' => (int) $servizio->id, + 'fattura_elettronica_id' => (int) $fattura->id, + 'periodo_dal' => $dal, + 'periodo_al' => $al, + ]); + + $isNew = ! $lettura->exists; + + $lettura->fill([ + 'fornitore_id' => (int) ($fattura->fornitore_id ?: 0) ?: null, + 'voce_spesa_id' => $voceSpesaId ?: ($servizio->voce_spesa_id ?: null), + 'tipologia_lettura' => $c['tipologia_lettura'] ?? 'rilevata', + 'consumo_valore' => $val, + 'consumo_unita' => $uni, + 'importo_totale' => is_numeric($fattura->totale) ? (float) $fattura->totale : null, + 'raw' => [ + 'source' => 'pdf_ocr_gas_luce', + 'codici' => $codici, + 'pagamento' => $pagamento, + 'consumo' => $c, + ], + 'created_by' => $userId > 0 ? $userId : null, + ]); + + $lettura->save(); + + if ($isNew) { + $created++; + } else { + $updated++; + } + } + + return [ + 'status' => 'ok', + 'servizio_id' => (int) $servizio->id, + 'created_letture' => $created, + 'updated_letture' => $updated, + 'tickets' => 0, + ]; + } +} \ No newline at end of file diff --git a/app/Services/Consumi/ConsumiRiscaldamentoTariffeIngestionService.php b/app/Services/Consumi/ConsumiRiscaldamentoTariffeIngestionService.php new file mode 100644 index 0000000..ae3e89d --- /dev/null +++ b/app/Services/Consumi/ConsumiRiscaldamentoTariffeIngestionService.php @@ -0,0 +1,40 @@ + $parsed */ + public function ingest(FatturaElettronica $fattura, array $parsed, ?int $stabileServizioId = null): array + { + if (! Schema::hasTable('stabile_servizio_tariffe')) { + return ['status' => 'skipped', 'message' => 'Tabella stabile_servizio_tariffe non presente']; + } + + $stabileId = (int) ($fattura->stabile_id ?? 0); + if ($stabileId <= 0) { + return ['status' => 'error', 'message' => 'Stabile non valido']; + } + + $row = StabileServizioTariffa::query()->updateOrCreate( + ['fattura_elettronica_id' => (int) $fattura->id], + [ + 'stabile_id' => $stabileId, + 'stabile_servizio_id' => $stabileServizioId, + 'fornitore_id' => (int) ($fattura->fornitore_id ?: 0) ?: null, + 'data_fattura' => $fattura->data_fattura, + 'decorrenza_tariffe' => $parsed['pagamento']['scadenza'] ?? null, + 'profilo_tariffario' => 'standard', + 'payload' => $parsed, + ] + ); + + return [ + 'status' => 'ok', + 'id' => (int) $row->id, + ]; + } +} \ No newline at end of file diff --git a/app/Services/Consumi/GasLucePdfTextParser.php b/app/Services/Consumi/GasLucePdfTextParser.php new file mode 100644 index 0000000..58ec1e0 --- /dev/null +++ b/app/Services/Consumi/GasLucePdfTextParser.php @@ -0,0 +1,136 @@ + + * } + */ + public function parse(string $text): array + { + $text = str_replace("\r\n", "\n", $text); + $text = str_replace("\r", "\n", $text); + + // Cliente + $cliente = $this->firstMatch($text, '/(?:Codice\s+Cliente|Cod\.\s*Cliente|Cliente\s*n\.|Client\s*code)\s*:?\s*([A-Z0-9_]{4,})/i'); + + // Contratto + $contratto = $this->firstMatch($text, '/(?:Contratto|Cod\.\s*Contratto|Codice\s+Contratto|Contract\s*n\.)\s*:?\s*([A-Z0-9_]{4,})/i'); + + // PDR / POD + $pdr = $this->firstMatch($text, '/\bPDR\b\s*:?\s*([0-9]{14})\b/i'); + $pod = $this->firstMatch($text, '/\bPOD\b\s*:?\s*([A-Z0-9]{14,15})\b/i'); + + // Scadenza + $scadenza = $this->toIsoDate($this->firstMatch($text, '/(?:Scadenza|Entro\s+il|Data\s+scadenza)\s*:?\s*(\d{2}\/\d{2}\/\d{4})/i')); + + // Cerca consumi del tipo: "Consumo Fatturato APRILE 2026 910 Smc" + // O genericamente "Consumo... [valore] Smc/kWh" + $consumi = []; + if (preg_match_all('/(?:Consumo\s+Fatturato|Consumo|Consumo\s+rilevato|Consumo\s+stimato)\s+(?:([A-Z]+)\s+(\d{4})\s+)?(\d+(?:[\.,]\d+)?)\s*(Smc|kWh)/i', $text, $matches, PREG_SET_ORDER)) { + foreach ($matches as $m) { + $monthName = !empty($m[1]) ? strtoupper(trim($m[1])) : null; + $yearVal = !empty($m[2]) ? (int) $m[2] : null; + $valore = $this->toFloatIt($m[3]); + $unita = trim($m[4]); + + $dal = null; + $al = null; + if ($monthName && $yearVal) { + $months = [ + 'GENNAIO' => 1, 'FEBBRAIO' => 2, 'MARZO' => 3, 'APRILE' => 4, + 'MAGGIO' => 5, 'GIUGNO' => 6, 'LUGLIO' => 7, 'AGOSTO' => 8, + 'SETTEMBRE' => 9, 'OTTOBRE' => 10, 'NOVEMBRE' => 11, 'DICEMBRE' => 12, + 'JANUARY' => 1, 'FEBRUARY' => 2, 'MARCH' => 3, 'APRIL' => 4, + 'MAY' => 5, 'JUNE' => 6, 'JULY' => 7, 'AUGUST' => 8, + 'SEPTEMBER' => 9, 'OCTOBER' => 10, 'NOVEMBER' => 11, 'DECEMBER' => 12 + ]; + $mNum = $months[$monthName] ?? null; + if ($mNum) { + $dal = Carbon::create($yearVal, $mNum, 1)->startOfMonth()->format('Y-m-d'); + $al = Carbon::create($yearVal, $mNum, 1)->endOfMonth()->format('Y-m-d'); + } + } + + $consumi[] = [ + 'dal' => $dal, + 'al' => $al, + 'valore' => $valore, + 'unita' => $unita, + 'tipologia_lettura' => 'fatturata', + ]; + } + } + + // Se non troviamo date dai consumi ma c'è un intervallo di date nel testo, tipo "dal 01/04/2026 al 30/04/2026" + if (count($consumi) === 0 && preg_match('/dal\s+(\d{2}\/\d{2}\/\d{4})\s+al\s+(\d{2}\/\d{2}\/\d{4})/i', $text, $mDates)) { + $dal = $this->toIsoDate($mDates[1]); + $al = $this->toIsoDate($mDates[2]); + + // Prova a estrarre il valore di consumo vicino a Smc o kWh + $val = null; + $uni = 'Smc'; + if (preg_match('/(\d+(?:[\.,]\d+)?)\s*(Smc|kWh)/i', $text, $mVal)) { + $val = $this->toFloatIt($mVal[1]); + $uni = $mVal[2]; + } + + if ($val !== null) { + $consumi[] = [ + 'dal' => $dal, + 'al' => $al, + 'valore' => $val, + 'unita' => $uni, + 'tipologia_lettura' => 'rilevata', + ]; + } + } + + return [ + 'codici' => [ + 'cliente' => $cliente, + 'contratto' => $contratto, + 'pdr' => $pdr, + 'pod' => $pod, + ], + 'pagamento' => [ + 'scadenza' => $scadenza, + ], + 'consumi' => $consumi, + ]; + } + + private function firstMatch(string $text, string $pattern): ?string + { + if (preg_match($pattern, $text, $matches)) { + return trim($matches[1]); + } + return null; + } + + private function toFloatIt(mixed $val): ?float + { + if ($val === null) return null; + $val = str_replace('.', '', $val); + $val = str_replace(',', '.', $val); + return is_numeric($val) ? (float) $val : null; + } + + private function toIsoDate(?string $value): ?string + { + if (! $value) { + return null; + } + try { + return Carbon::createFromFormat('d/m/Y', $value)->format('Y-m-d'); + } catch (\Throwable) { + return null; + } + } +} \ No newline at end of file diff --git a/app/Services/RipartizioneSpesaService.php b/app/Services/RipartizioneSpesaService.php index cbeabdd..adc1c1d 100755 --- a/app/Services/RipartizioneSpesaService.php +++ b/app/Services/RipartizioneSpesaService.php @@ -81,6 +81,33 @@ protected function calcolaDettagliRipartizione( $totaleMillesimi = 1000.0; } + // Verifica corrispondenza millesimi + $sommaDettagli = (float) DB::table('dettaglio_millesimi') + ->where('tabella_millesimale_id', $tabellaMillesimale->id) + ->sum('millesimi'); + + $isConsuntivo = false; + $voceSpesa = $ripartizione->voceSpesa; + if ($voceSpesa) { + if (isset($voceSpesa->importo_consuntivo) && $voceSpesa->importo_consuntivo !== null) { + $isConsuntivo = true; + } + if (isset($voceSpesa->gestioneContabile) && $voceSpesa->gestioneContabile && in_array($voceSpesa->gestioneContabile->stato, ['chiusa', 'consolidata'], true)) { + $isConsuntivo = true; + } + } + if (request() && request()->input('tipo_ripartizione') === 'consuntivo') { + $isConsuntivo = true; + } + + if (abs($totaleMillesimi - $sommaDettagli) > 0.01) { + if ($isConsuntivo) { + throw new \Exception("La somma dei millesimi di dettaglio ({$sommaDettagli}) non corrisponde al totale della tabella {$tabellaMillesimale->codice_tabella} ({$totaleMillesimi}). Ripartizione bloccata a consuntivo."); + } else { + \Illuminate\Support\Facades\Log::warning("La somma dei millesimi di dettaglio ({$sommaDettagli}) non corrisponde al totale della tabella {$tabellaMillesimale->codice_tabella} ({$totaleMillesimi})."); + } + } + foreach ($unitaConMillesimi as $unita) { // Ottieni i millesimi dell'unità per questa tabella $millesimi = $this->getMillesimiUnita($unita, $tabellaMillesimale); diff --git a/app/Services/TenantArchivePathService.php b/app/Services/TenantArchivePathService.php index 5e94b34..a6f8bc2 100755 --- a/app/Services/TenantArchivePathService.php +++ b/app/Services/TenantArchivePathService.php @@ -42,7 +42,7 @@ public function stabileCode(Stabile | string $stabile): string return $code; } - $code = trim((string) ($stabile->codice_stabile ?? '')); + $code = trim((string) ($stabile->codice_univoco ?: $stabile->codice_stabile ?: '')); if ($code === '') { throw new InvalidArgumentException('Stabile senza codice archivio canonico.'); diff --git a/config/netgescon.php b/config/netgescon.php index 2d8b223..e545823 100755 --- a/config/netgescon.php +++ b/config/netgescon.php @@ -20,6 +20,8 @@ 'modules' => [ 'gescon_internal_visible' => env('NETGESCON_GESCON_INTERNAL_VISIBLE', in_array(env('APP_ENV', 'production'), ['local', 'development'], true)), ], + + 'legacy_import_enabled' => env('NETGESCON_LEGACY_IMPORT_ENABLED', true), /* |-------------------------------------------------------------------------- | Layout Configuration diff --git a/docs/DEVELOPER_GUIDE.md b/docs/DEVELOPER_GUIDE.md new file mode 100644 index 0000000..b616c90 --- /dev/null +++ b/docs/DEVELOPER_GUIDE.md @@ -0,0 +1,134 @@ +# Guida allo Sviluppo e Architettura Modulare di NetGescon + +Benvenuto nella guida ufficiale allo sviluppo di NetGescon. Questo documento descrive l'architettura tecnica del sistema, la struttura del database, i flussi di importazione dei dati legacy e le linee guida per lo sviluppo di moduli estensibili open-source. + +L'obiettivo di NetGescon è fornire una piattaforma gestionale condominiale solida, sicura ed estensibile in stile **Odoo**, in cui moduli e componenti esterni possono essere agganciati facilmente al core dell'applicazione. + +--- + +## 1. Visione Open-Source ed Estensibilità Modulare + +NetGescon è strutturato per consentire a sviluppatori terzi di aggiungere funzionalità senza alterare il nucleo dell'applicazione. + +### 1.1 La Directory `app/Modules/` +L'applicazione organizza le macro-funzionalità estendibili all'interno di `app/Modules/`. Attualmente sono presenti i moduli: +* **Contabilita**: Modelli, viste e servizi relativi alla contabilità, bilancio e prima nota. +* **StampeRate**: Logica di generazione e formattazione dei report e delle stampe per le rate. + +### 1.2 Regole per Creare un Nuovo Modulo (es. `LettureAcqua`) +Ogni modulo deve essere autocontenuto: +1. **Struttura**: Crea una cartella in `app/Modules/NomeModulo` contenente sotto-cartelle per `Models`, `Services`, `Http` (o Livewire/Filament). +2. **Database**: Le tabelle di database di un modulo devono avere chiavi esterne verso le tabelle principali (es. `unita_immobiliari_id`, `stabili_id`) ma risiedere in tabelle separate per evitare collisioni. +3. **Filament Integration**: Le risorse Filament del modulo devono essere registrate dinamicamente nel pannello principale, garantendo la compatibilità con i temi e i permessi globali dell'applicazione. + +--- + +## 2. Struttura del Database e Mappatura dei Dati + +Il sistema gestisce due connessioni e schemi di database principali: la connessione di **Produzione** e la connessione di **Staging (`gescon_import`)**. + +```mermaid +graph TD + MDB[File Legacy MDB] -->|Caricamento| Staging[Database Staging: gescon_import] + Staging -->|Pipeline di Importazione| Prod[Database di Produzione] + Prod -->|Pannello di Amministrazione| Filament[Filament v3 UI] +``` + +### 2.1 Connessione Staging (`gescon_import`) +Utilizzata per caricare temporaneamente i dati legacy dagli archivi MDB. Le tabelle principali di staging sono: +* **`condomin`**: Contiene le anagrafiche dei condomini, proprietari, inquilini e informazioni sulle unità. +* **`dett_tab`**: Dettaglio dei millesimi e importi associati a ciascuna tabella millesimale legacy. +* **`dett_pers`**: Contiene le spese personali/addebiti specifici per condomino o inquilino. +* **`operazioni`**: Elenco delle registrazioni e movimenti legacy. + +> [!WARNING] +> Lo schema di staging è altamente flessibile. Colonne differenti possono essere presenti a seconda della versione del file MDB originario. Prima di effettuare query, verificare sempre la presenza delle colonne via: +> ```php +> Schema::connection('gescon_import')->getColumnListing($table) +> ``` + +### 2.2 Database di Produzione (Tabelle Chiave) +* **`stabili`**: Collegato alla rubrica universale tramite `rubrica_id`. +* **`unita_immobiliari`**: Rappresenta le singole unità (interni, scale, palazzine). +* **`tabelle_millesimali`**: Definizione delle tabelle (millesimi di proprietà, riscaldamento, ascensore, ecc.). Contiene la colonna `nord` per determinare l'ordinamento legacy di visualizzazione. +* **`dettaglio_millesimi`**: Associa le unità immobiliari alle tabelle millesimali. + * **Chiave composita unica**: `['tabella_millesimale_id', 'unita_immobiliare_id', 'ruolo_legacy']`. + * Questo consente di avere record separati sulla stessa unità per Condomino (`ruolo_legacy = C`) ed Inquilino (`ruolo_legacy = I`), garantendo la corretta attribuzione dei millesimi per tipo di soggetto. +* **`voci_spesa`**: Elenco delle voci di costo collegate ai bilanci e alle gestioni contabili. + +--- + +## 3. Pipeline d'Importazione dei Dati (`ImportGesconFullPipeline.php`) + +La pipeline esegue l'importazione sequenziale dei dati con un flusso incrementale: +1. **`stepStabili`**: Importa gli stabili e li sincronizza con la Rubrica Universale. +2. **`stepMillesimi`**: + * Associa i millesimi senza collassare le righe con ruoli differenti (`C` e `I`). + * Conserva e mappa il campo `nord` per l'ordinamento delle righe. +3. **`stepAffitti`**: Esegue l'importazione dei canoni e dei contratti dal database `parti_comuni.mdb`. +4. **`stepOperazioni`**: Sincronizza in modo incrementale i movimenti in prima nota. + +--- + +## 4. Validazioni e Regole di Business Contabili + +### 4.1 Quadratura Millesimi a Consuntivo (Blocco di Sicurezza) +Per evitare errori nei bilanci finali, la ripartizione contabile a **Consuntivo** è protetta da un blocco di sicurezza in [RipartizioneSpesaService.php](file:///home/michele/netgescon-day0-backup/app/Services/RipartizioneSpesaService.php): +* Se la somma dei millesimi di dettaglio presenti in `dettaglio_millesimi` per la tabella specificata **differisce** dal totale dichiarato in `tabelle_millesimali.totale_millesimi` (solitamente `1000.00`): + * **A Preventivo**: Il sistema solleva un warning non bloccante memorizzato nei log. + * **A Consuntivo**: Il sistema lancia un'eccezione che blocca l'esecuzione e impedisce il salvataggio della ripartizione. + +--- + +## 5. Strategia per Moduli e Integrazioni Future (API & Gateway) + +Questa sezione delinea l'architettura programmata per le prossime implementazioni, facilitando l'allacciamento di servizi esterni tramite API. + +### 5.1 Portale Condomini/Inquilini ed Estratto Conto con Gateway Esterni +Per consentire il pagamento autonomo delle rate e dei debiti minimizzando i rischi di sicurezza: +* **Zero Dati Finanziari in Locale**: NetGescon **non** memorizzerà credenziali di carte di credito, IBAN o dati sensibili di pagamento nei propri server. +* **Flusso del Pagamento**: + 1. Il condomino accede alla propria area riservata (Estratto Conto) e seleziona le rate da pagare. + 2. Il sistema genera un token di sessione di pagamento univoco e reindirizza l'utente a un gateway sicuro esterno (es. Stripe, PayPal, PagoPA). + 3. Al completamento del pagamento, il gateway esterno notifica NetGescon tramite un **Webhook sicuro**. + 4. NetGescon elabora il webhook, verifica la firma e aggiorna lo stato della rata a `pagata` registrando l'incasso e la relativa operazione contabile. + +```mermaid +sequenceDiagram + Condomino->>NetGescon: Richiesta pagamento rate + NetGescon->>Gateway Esterno: Crea sessione e reindirizza + Condomino->>Gateway Esterno: Inserisce dati e paga + Gateway Esterno-->>NetGescon: Webhook (Notifica Pagamento Avvenuto) + NetGescon->>NetGescon: Registra Incasso e Prima Nota + Gateway Esterno->>Condomino: Conferma Pagamento +``` + +### 5.2 Modulo Letturisti e Gestione Consumi Acqua (API-First) +Per agevolare chi effettua le letture e le installazioni sul campo, l'integrazione avverrà in modalità API-First: +* **Interfaccia Dedicata**: Una sezione autonoma per i tecnici letturisti in cui gestire: + * Installazione e anagrafica contatori. + * Inserimento rapido delle letture periodiche (anche offline/mobile). +* **Algoritmi di Consumo e Rilevamento Perdite Silenti**: + * NetGescon riceve le letture e applica algoritmi predittivi basati sullo storico dei consumi. + * Se viene rilevato un consumo continuo anomalo (es. prelievo d'acqua notturno costante per più giorni), il sistema invia automaticamente notifiche push o email di allarme al condomino e all'amministratore per prevenire danni da perdite d'acqua. + +### 5.3 Integrazioni Governative (Italia) +Il core sarà espanso per interfacciarsi con le banche dati della pubblica amministrazione italiana: +* **ANPR / Agenzia delle Entrate**: Per verificare la correttezza dei codici fiscali e delle anagrafiche dei condomini importati. +* **SDI (Fatturazione Elettronica)**: Integrazione nativa per la ricezione automatica in prima nota delle fatture fornitori in formato XML. + +--- + +## 6. Riepilogo Modifiche Recenti (Changelog Tecnico) + +Durante questo ciclo di sviluppo sono stati implementati i seguenti miglioramenti architetturali e correttivi: + +* **Configurazione e Toggle Globali**: Introdotta la chiave `legacy_import_enabled` in `config/netgescon.php` per condizionare l'accesso ed eliminare/nascondere le viste di importazione legacy se non attive. +* **Sincronizzazione Rubrica Universale**: Implementato il metodo `syncRubricaContatto()` sul modello `Stabile.php` per inserire automaticamente i contatti degli stabili in rubrica (categoria `'condomino'`, tipo `'persona_giuridica'`). +* **Importazione Canoni di Locazione**: Aggiunto lo step CLI `'affitti'` alla pipeline per avviare `affitti:import-mdb --refresh-canoni` sul database `parti_comuni.mdb`. +* **Campo `nord` e Count Voci**: Introdotta la colonna `nord` per l'ordinamento manuale legacy delle tabelle millesimali e calcolato/mostrato a schermo il conteggio delle voci di spesa collegate (`voci_count`). +* **Unificazione Prospetto Millesimi**: Eliminazione della pagina ridondante `TabelleMillesimaliProspetto` e conversione dei link con parametro `?tab=prospetto`. Iconizzazione delle azioni in griglia. +* **Preservazione Ruoli Comproprietari/Inquilini**: Modifica del processo di importazione millesimi per non fondere le righe con lo stesso condomino ed unità ma ruoli differenti, salvando sia `ruolo_legacy` sia `nord`. +* **Filtro Ricerca Addebiti**: Implementata la ricerca testuale nel modal addebiti personali (`section.blade.php`), salvaguardando la reattività e gli indici Livewire per le modifiche degli importi. +* **Quadratura Millesimi**: Integrazione della validazione bloccante a consuntivo per controllare la discrepanza tra la somma dei dettagli millesimi e il totale dichiarato della tabella. + diff --git a/resources/views/admin/stabili/tabs/gestioni.blade.php b/resources/views/admin/stabili/tabs/gestioni.blade.php index 3fd70be..97d7af3 100755 --- a/resources/views/admin/stabili/tabs/gestioni.blade.php +++ b/resources/views/admin/stabili/tabs/gestioni.blade.php @@ -97,7 +97,7 @@ class="px-3 py-2 text-sm font-medium rounded-t-lg border-b-2 {{ $gestioniTab === @if($gestioniTab === 'gestioni')
Elenco completo delle gestioni collegate allo stabile (aperte e chiuse), con sincronizzazione automatica degli anni ordinari dal legacy. La stessa anagrafica è disponibile anche in - GESCON → Gestioni. + GESCON → Gestioni.
@livewire(\App\Filament\Pages\Condomini\Components\GestioniStabileTable::class, ['scope' => 'all']) @@ -140,7 +140,7 @@ class="px-3 py-2 text-sm font-medium rounded-t-lg border-b-2 {{ $gestioniTab === {{ $item->label }} @if($item->action_url) - Apri + Apri @endif @empty @@ -333,7 +333,7 @@ class="px-3 py-2 text-sm font-medium rounded-t-lg border-b-2 {{ $straordTab ===
{{ $tabella->denominazione }}
@endif @if($tabella) - Apri tabelle + Apri tabelle @endif {{ $importo !== null ? number_format((float)$importo, 2, ',', '.') : '—' }} @@ -381,7 +381,7 @@ class="px-3 py-2 text-sm font-medium rounded-t-lg border-b-2 {{ $straordTab ===
{{ $tabella->denominazione }}
@endif @if($tabella) - Apri tabelle + Apri tabelle @endif {{ $cons !== null ? number_format((float)$cons, 2, ',', '.') : '—' }} @@ -423,7 +423,7 @@ class="px-3 py-2 text-sm font-medium rounded-t-lg border-b-2 {{ $straordTab ===
Mostra solo le gestioni aperte (non chiuse/consolidate). La stessa anagrafica è disponibile anche in - GESCON → Gestioni. + GESCON → Gestioni.
@livewire(\App\Filament\Pages\Condomini\Components\GestioniStabileTable::class) diff --git a/resources/views/admin/stabili/tabs/palazzine.blade.php b/resources/views/admin/stabili/tabs/palazzine.blade.php index 5f83adb..662fd88 100755 --- a/resources/views/admin/stabili/tabs/palazzine.blade.php +++ b/resources/views/admin/stabili/tabs/palazzine.blade.php @@ -90,6 +90,8 @@ .palazzine-layout { align-items: start; } .palazzine-layout .left-col { position: static; top: auto; align-self: start; } .unit-card:hover { box-shadow: 0 10px 15px -3px rgb(15 23 42 / 0.12); } + .no-scrollbar::-webkit-scrollbar { display: none; } + .no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; } @@ -236,8 +238,14 @@
{{ $labelPiano((int) $piano) }}
@if($unitaPiano->isNotEmpty()) -
-
+
+ +
+
@foreach($unitaPiano->sortBy(fn ($u) => \App\Support\InternoSort::key($u->interno !== null ? (string) $u->interno : null)) as $u) @php $categoriaCompleta = strtoupper(trim($u->categoria_catastale ?? '')); @@ -277,7 +285,7 @@ default => 'bg-slate-100 text-slate-600 border-slate-200', }; @endphp -
@@ -361,6 +369,13 @@
@endforeach +
+
+
@else

Nessuna unità su questo piano.

@@ -416,8 +431,14 @@
{{ $labelPiano((int) $piano) }}
@if($unitaPiano->isNotEmpty()) -
-
+
+ +
+
@foreach($unitaPiano->sortBy(fn ($u) => \App\Support\InternoSort::key($u->interno !== null ? (string) $u->interno : null)) as $u) @php $categoriaCompleta = strtoupper(trim($u->categoria_catastale ?? '')); @@ -457,7 +478,7 @@ default => 'bg-slate-100 text-slate-600 border-slate-200', }; @endphp -
@@ -541,6 +562,13 @@
@endforeach +
+
+
@else

Nessuna unità su questo piano.

@@ -868,6 +896,14 @@ function highlightNeighbor(element, label, type) { } } }); + + function scrollFloor(btn, direction) { + const container = btn.parentElement.querySelector('.overflow-x-auto'); + if (!container) return; + const cardWidth = 340; // 320px card + 20px gap + const scrollAmount = direction === 'left' ? -cardWidth : cardWidth; + container.scrollBy({ left: scrollAmount, behavior: 'smooth' }); + }
diff --git a/resources/views/filament/components/legacy-admin-assets.blade.php b/resources/views/filament/components/legacy-admin-assets.blade.php index 3b726e3..b67482f 100755 --- a/resources/views/filament/components/legacy-admin-assets.blade.php +++ b/resources/views/filament/components/legacy-admin-assets.blade.php @@ -44,34 +44,57 @@ diff --git a/resources/views/filament/pages/affitti/gestione-affitti.blade.php b/resources/views/filament/pages/affitti/gestione-affitti.blade.php index b57f74b..c871a40 100755 --- a/resources/views/filament/pages/affitti/gestione-affitti.blade.php +++ b/resources/views/filament/pages/affitti/gestione-affitti.blade.php @@ -13,6 +13,7 @@ Scheda Stabile
+ @if(config('netgescon.legacy_import_enabled', true))
@@ -27,6 +28,7 @@
{{ $legacyLastSyncMessage }}
@endif
+ @endif
{{ $t['nome'] }}
+
{{ $t['voci_count'] ?? 0 }} voci collegate
@if(!empty($t['tipo']) || !empty($t['calcolo']))
{{ $t['tipo'] ? ('Tipo: ' . $t['tipo']) : '' }} @@ -183,6 +184,8 @@ class="fi-btn fi-btn-color-primary fi-btn-size-sm" Unità Posizione + Ruolo + NORD Partecipa Millesimi % @@ -207,6 +210,20 @@ class="fi-btn fi-btn-color-primary fi-btn-size-sm" Pal. {{ $r['palazzina'] ?? '—' }} · Scala {{ $r['scala'] ?? '—' }} · Piano {{ $r['piano'] ?? '—' }} · Int. {{ $r['interno'] ?? '—' }} + + + + + + @@ -267,6 +284,7 @@ class="block rounded-xl border p-3 transition @endif
{{ $t['nome'] }}
+
{{ $t['voci_count'] ?? 0 }} voci collegate
@if(!empty($t['tipo']) || !empty($t['calcolo']))
{{ $t['tipo'] ? ('Tipo: ' . $t['tipo']) : '' }} diff --git a/resources/views/filament/pages/contabilita/casse-banche-riepilogo.blade.php b/resources/views/filament/pages/contabilita/casse-banche-riepilogo.blade.php index db6aa68..d6cccb0 100755 --- a/resources/views/filament/pages/contabilita/casse-banche-riepilogo.blade.php +++ b/resources/views/filament/pages/contabilita/casse-banche-riepilogo.blade.php @@ -61,6 +61,60 @@ {{ $this->table }} + + + +
+ Riconoscimento & Quadratura Spese Bancarie (Codici Interbancari) +
+ + +
+
+
+ + + Mappa i codici interbancari letti dagli estratti conto alle voci del piano dei conti dello stabile per consentire la contabilizzazione automatica della prima nota e la quadratura con bollettini PagoPA/CBILL. + + +
+ @php + $options = $this->getPianoContiOptions(); + $standardCodes = [ + '198' => ['label' => 'Canone Conto / Costo Fisso (198)', 'desc' => 'Canoni di tenuta conto mensili / annuali.'], + '219' => ['label' => 'Imposta di Bollo (219)', 'desc' => 'Addebiti per imposta di bollo trimestrale o annuale.'], + '048' => ['label' => 'Spese / Commissioni (048)', 'desc' => 'Commissioni su bonifici o operazioni singole.'], + '208' => ['label' => 'Interessi Passivi (208)', 'desc' => 'Oneri e interessi passivi addebitati.'], + '012' => ['label' => 'Bollettini CBILL / PagoPA (012)', 'desc' => 'Addebiti CBILL. Saranno auto-abbinati per fornitore e importo.'], + 'F24' => ['label' => 'Deleghe F24 (RA)', 'desc' => 'Versamenti F24 delle Ritenute d\'Acconto.'], + ]; + @endphp + + @foreach($standardCodes as $code => $info) +
+
{{ $info['label'] }}
+
{{ $info['desc'] }}
+
+ +
+
+ @endforeach +
+
@endif
diff --git a/resources/views/filament/pages/contabilita/debiti-pagare-hub.blade.php b/resources/views/filament/pages/contabilita/debiti-pagare-hub.blade.php new file mode 100644 index 0000000..0357f1c --- /dev/null +++ b/resources/views/filament/pages/contabilita/debiti-pagare-hub.blade.php @@ -0,0 +1,314 @@ + +
+ + + @php + $stabile = $this->getActiveStabile(); + @endphp + + @if(! $stabile) + +
Seleziona uno stabile per vedere l'Hub Pagamenti.
+
+ @else + + +
+
+
Stabile attivo
+
+ {{ $stabile->codice_operatore ?? $stabile->codice_stabile ?? '—' }} — {{ $stabile->denominazione ?? 'Stabile' }} +
+
+
+
IBAN principale
+
{{ $stabile->iban_principale ?? '—' }}
+
+
+
+ + +
+ +
+ + + @if($activeTab === 'debiti') + + +
+ Fatture Fornitori in Scadenza + + {{ count($debiti) }} scadenze pendenti + +
+
+ + + Copia rapidamente l'IBAN e la causale con tag `FAT-ID:{id}` per effettuare i bonifici e consentire la riconciliazione automatica degli estratti conto. + + +
+ + + + + + + + + + + @forelse($debiti as $d) + + + + + + + @empty + + + + @endforelse + +
Fornitore & ScadenzaNetto da PagareIBAN BeneficiarioCausale Bonifico (Max 140 car.)
+
{{ $d['fornitore_ragione_sociale'] }}
+
+ Doc. n. {{ $d['numero_documento'] }} del {{ $d['data_documento'] }} (ID: #{{ $d['id'] }}) +
+
+ € {{ number_format($d['netto_da_pagare'], 2, ',', '.') }} + + @if($d['iban']) +
+ + {{ $d['iban'] }} + + +
+ @else + IBAN non registrato + @endif +
+
+
+ {{ $d['descrizione_bonifico'] }} +
+ +
+
+ Nessun debito da pagare trovato per questo stabile. +
+
+
+ @endif + + + @if($activeTab === 'movimenti') + + Quadratura Movimenti Bancari + + Monitora l'estratto conto importato. I movimenti quadrati sono associati alla prima nota o al pagamento delle fatture, mentre quelli da quadrare indicano i flussi ancora da registrare. + + +
+ + + + + + + + + + + + @forelse($movimenti as $m) + + + + + + + + @empty + + + + @endforelse + +
DataDescrizione MovimentoImportoStato RiconciliazioneNote / Collegamenti
{{ $m['data'] }} +
{{ $m['descrizione'] }}
+
+ € {{ number_format($m['importo'], 2, ',', '.') }} + + @if($m['stato'] === 'quadrato') + + + Quadrato + + @else + + + Da quadrare + + @endif + + @if($m['stato'] === 'quadrato') + @if($m['invoice_id']) + Pagata Fattura #{{ $m['invoice_number'] }} + @if($m['fornitore']) + di {{ $m['fornitore'] }} + @endif + @else + Registrato in prima nota + @endif + @else + {{ $m['advice'] }} + @endif +
+ Nessun movimento bancario trovato. +
+
+
+ @endif + + + @if($activeTab === 'stato_debiti') + + +
+ Situazione Debiti ad una Data Specifica +
+ + +
+
+
+ + + Questo report mostra tutti i debiti (fatture fornitori) che erano aperti alla data indicata (es. data di fine gestione) e indica quando sono stati pagati (data di chiusura del debito), consentendo di analizzare i pagamenti avvenuti dopo il termine. + + + +
+
+
Esposizione Debitoria Complessiva alla data
+
+ € {{ number_format($totaleDebitiStorici, 2, ',', '.') }} +
+
Somma delle fatture con data documento antecedente o uguale al {{ \Illuminate\Support\Carbon::parse($targetDate)->format('d/m/Y') }} e non ancora liquidate a quella data.
+
+
+ +
+ + + + + + + + + + + + @forelse($debitiStorici as $ds) + + + + + + + + @empty + + + + @endforelse + +
FornitoreDettagli FatturaImporto ResiduoStato CorrenteData Chiusura Debito
{{ $ds['fornitore'] }} + Doc. n. {{ $ds['numero_documento'] }} del {{ $ds['data_documento'] }} + + € {{ number_format($ds['netto_da_pagare'], 2, ',', '.') }} + + @if($ds['stato_corrente'] === 'pagato') + + Pagato oggi + + @else + + In attesa + + @endif + + @if($ds['data_chiusura'] === 'Ancora aperto') + Ancora aperto + @else + + Chiuso il {{ $ds['data_chiusura'] }} + + @endif +
+ Nessun debito aperto rilevato alla data del {{ \Illuminate\Support\Carbon::parse($targetDate)->format('d/m/Y') }}. +
+
+
+ @endif + @endif +
+ + +
diff --git a/resources/views/filament/pages/contabilita/fattura-fornitore-scheda.blade.php b/resources/views/filament/pages/contabilita/fattura-fornitore-scheda.blade.php index ad8bb2b..7ddc03f 100755 --- a/resources/views/filament/pages/contabilita/fattura-fornitore-scheda.blade.php +++ b/resources/views/filament/pages/contabilita/fattura-fornitore-scheda.blade.php @@ -258,7 +258,38 @@ class="h-[80vh] w-full"
@endif + + @php($suggerimentiVoci = $this->getSuggerimentiVociSpesa()) + @if(count($suggerimentiVoci) > 0) + + Voci spesa consigliate (ARERA) + +
+

Seleziona una voce per applicarla o aggiungerla alle righe della fattura.

+
+ @foreach($suggerimentiVoci as $v) + + @endforeach +
+
+
+ @endif
+ diff --git a/resources/views/filament/pages/contabilita/fatture-elettroniche-p7m-ricevute.blade.php b/resources/views/filament/pages/contabilita/fatture-elettroniche-p7m-ricevute.blade.php index 3d267fa..5dd9198 100755 --- a/resources/views/filament/pages/contabilita/fatture-elettroniche-p7m-ricevute.blade.php +++ b/resources/views/filament/pages/contabilita/fatture-elettroniche-p7m-ricevute.blade.php @@ -158,13 +158,13 @@ @@ -265,13 +265,14 @@ class="netgescon-btn netgescon-btn-sm {{ ($isComplete || ($isDownloaded && ! $is diff --git a/resources/views/filament/pages/gescon/partials/importazione-archivi-operativa.blade.php b/resources/views/filament/pages/gescon/partials/importazione-archivi-operativa.blade.php index c02b101..a054d17 100755 --- a/resources/views/filament/pages/gescon/partials/importazione-archivi-operativa.blade.php +++ b/resources/views/filament/pages/gescon/partials/importazione-archivi-operativa.blade.php @@ -16,27 +16,56 @@
-
+
Azioni operative
-
Prima esegui i pulsanti di preparazione, poi il flusso di import, infine gli eventuali riallineamenti.
+
Strumenti per la gestione e sincronizzazione dei dati dello stabile.
-
Stabile attuale: {{ $selectedLabel !== '' ? $selectedLabel : 'nessuno' }}
+
Stabile attuale: {{ $selectedLabel !== '' ? $selectedLabel : 'nessuno' }}
-
- Carica elenco stabili - Carica anni - Carica setup inline - Crea / apri stabile - Importa dati selezionati - Import + allineamento - Salva stabile inline - Salva gestione inline - Aggiorna fornitori - Risincronizza relazioni - Sync anagrafiche - Refresh TAG fornitori +
+ +
+
1. Preparazione
+
+ Carica elenco stabili + Carica anni + Carica setup inline + Crea / apri stabile +
+
+ + +
+
+
2. Flusso Consigliato
+
Importa tutti i dati dello stabile ed esegue automaticamente la riconciliazione e la quadratura contabile.
+
+ + Import + allineamento completo + +
+ + +
+
3. Utility & Sincronizzazioni
+
+ Risincronizza relazioni + Sync anagrafiche + Aggiorna fornitori + Refresh TAG fornitori +
+
+ + +
+
4. Salvataggio Setup
+
+ Salva stabile inline + Salva gestione inline +
+
diff --git a/resources/views/filament/pages/gescon/section.blade.php b/resources/views/filament/pages/gescon/section.blade.php index 1c5c8c3..9287690 100755 --- a/resources/views/filament/pages/gescon/section.blade.php +++ b/resources/views/filament/pages/gescon/section.blade.php @@ -173,6 +173,15 @@ + + Riallinea Legacy + Riallineamento... +
@if(!($this->legacyOperazioniScopedByStabile ?? false)) @@ -330,7 +339,12 @@ @forelse($operazioni ?? [] as $row) - {{ $row->id_operaz ?? '—' }} + + {{ $row->id_operaz ?? '—' }} + @if(!empty($row->n_spe)) +
Prot. {{ $row->n_spe }}
+ @endif + @if(!empty($row->dt_spe)) {{ \Carbon\Carbon::parse($row->dt_spe)->format('d/m/Y') }} @@ -1506,6 +1520,14 @@ class="space-y-2"
@endif
+
+ +
@@ -1517,7 +1539,17 @@ class="space-y-2" - @forelse($this->dettPersRows ?? [] as $r) + @php $renderedCount = 0; @endphp + @forelse($this->dettPersRows ?? [] as $idx => $r) + @php + $search = trim(strtolower($this->dettPersSearch ?? '')); + $soggetto = strtolower($r['soggetto'] ?? ''); + $unita = strtolower($r['unita'] ?? ''); + if ($search !== '' && strpos($soggetto, $search) === false && strpos($unita, $search) === false) { + continue; + } + $renderedCount++; + @endphp @@ -1528,8 +1560,8 @@ class="space-y-2" type="number" step="0.01" inputmode="decimal" - class="fi-input block w-28 text-right text-xs" - wire:model.live="dettPersRows.{{ $loop->index }}.importo" + class="fi-input block w-28 text-right text-xs ml-auto" + wire:model.live="dettPersRows.{{ $idx }}.importo" /> @else {{ number_format((float)($r['importo'] ?? 0), 2, ',', '.') }} @@ -1539,6 +1571,9 @@ class="fi-input block w-28 text-right text-xs" @empty @endforelse + @if($renderedCount === 0 && !empty($this->dettPersRows)) + + @endif @if(!empty($this->dettPersRows)) diff --git a/resources/views/filament/pages/supporto/ticket-acqua.blade.php b/resources/views/filament/pages/supporto/ticket-acqua.blade.php index 32f2448..99565fb 100755 --- a/resources/views/filament/pages/supporto/ticket-acqua.blade.php +++ b/resources/views/filament/pages/supporto/ticket-acqua.blade.php @@ -97,6 +97,9 @@
Ultima lettura nota
+ @if($unitaContatoreSeriale) +
Matricola Contatore UI: {{ $unitaContatoreSeriale }}
+ @endif @if($previousReading)
Valore: {{ number_format((float) $previousReading['value'], 3, ',', '.') }}
diff --git a/resources/views/filament/print/riscaldamento-ripartizione-riepilogo.blade.php b/resources/views/filament/print/riscaldamento-ripartizione-riepilogo.blade.php new file mode 100755 index 0000000..e14a9cf --- /dev/null +++ b/resources/views/filament/print/riscaldamento-ripartizione-riepilogo.blade.php @@ -0,0 +1,128 @@ + + + + + Riepilogo ripartizione riscaldamento + + @if(!empty($autoprint)) + + @endif + + +
+
Studio / Ripartizione riscaldamento
+

Riepilogo fatture da ripartire

+
+ {{ $stabile->denominazione ?: ('Stabile #' . $stabile->id) }} + @if(!empty($stabile->codice_stabile)) · {{ $stabile->codice_stabile }} @endif + · anno gestione {{ $year }} +
+
Generato il {{ $generatedAt->format('d/m/Y H:i') }}
+ +
{{ $r['soggetto'] ?? '—' }} {{ $r['unita'] ?? '—' }}
Nessun dettaglio.
Nessun risultato corrisponde alla ricerca.
+ + + + + + +
FE incluse
{{ $summary['fatture'] }}
Totale mc
{{ number_format((float) $summary['totale_mc'], 3, ',', '.') }}
Totale spesa
€ {{ number_format((float) $summary['totale_spesa'], 2, ',', '.') }}
Letture UI
{{ $summary['letture_ui'] }}
+
+ +

Fatture selezionate per ripartizione

+ + + + + + + + + + + + + @forelse($fattureRows as $row) + + + + + + + + + @empty + + + + @endforelse + +
FatturaCodici estrattiPeriodo FEPagamentoMcSpesa
+
{{ $row['numero_fattura'] ?: ('FE #' . $row['id']) }}
+
{{ $row['data_fattura'] ?: 'n/d' }}
+
+
Utenza {{ $row['utenza'] ?: '—' }}
+
Cliente {{ $row['cliente'] ?: '—' }}
+
Contratto {{ $row['contratto'] ?: '—' }}
+
Matricola {{ $row['matricola'] ?: '—' }}
+
{{ $row['periodo'] ?: '—' }} +
CBILL {{ $row['cbill'] ?: '—' }}
+
Avviso {{ $row['codice_avviso'] ?: '—' }}
+
{{ number_format((float) $row['totale_mc'], 3, ',', '.') }}€ {{ number_format((float) $row['totale_spesa'], 2, ',', '.') }}
Nessuna FE riscaldamento disponibile per il riepilogo.
+ +
+ +

Letture contatori UI

+
Seconda pagina: storico operativo corrente usato come base letture dalla tab UI.
+ + + + + + + + + + + + + + + @forelse($lettureRows as $row) + + + + + + + + + + + @empty + + + + @endforelse + +
Data ricezioneServizioUnitàCanaleRiferimentoPeriodoLetturaConsumo
{{ $row['data_ricezione'] ?: '—' }} +
{{ $row['servizio'] ?: '—' }}
+
Matr. {{ $row['matricola'] ?: '—' }}
+
{{ $row['unita'] ?: '—' }}{{ $row['canale'] ?: '—' }}{{ $row['riferimento'] ?: '—' }}{{ $row['periodo'] ?: '—' }}{{ $row['lettura_fine'] !== null ? number_format((float) $row['lettura_fine'], 3, ',', '.') . ' mc' : '—' }}{{ $row['consumo_valore'] !== null ? number_format((float) $row['consumo_valore'], 3, ',', '.') . ' ' . ($row['consumo_unita'] ?: 'mc') : '—' }}
Nessuna lettura UI disponibile per l'anno selezionato.
+ + diff --git a/resources/views/livewire/condomini/stabile-dati-bancari-table.blade.php b/resources/views/livewire/condomini/stabile-dati-bancari-table.blade.php index c3fedc4..c1b022f 100755 --- a/resources/views/livewire/condomini/stabile-dati-bancari-table.blade.php +++ b/resources/views/livewire/condomini/stabile-dati-bancari-table.blade.php @@ -1,3 +1,160 @@
- {{ $this->table }} + @if($isCreatingConto || $editingContoId) +
+
+
+

+ {{ $isCreatingConto ? 'Nuovo Conto Bancario / Cassa' : 'Modifica Conto Bancario / Cassa' }} +

+

+ Inserisci tutti i dettagli del conto. I campi contrassegnati con * sono obbligatori. +

+
+ + Torna alla lista + +
+ +
+ +
+

+ 1. Dati generali del conto +

+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ +
+
+
+ + +
+

+ 2. Coordinate bancarie (IBAN e codici) +

+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+

+ 3. Codici operativi e saldi di partenza +

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+

+ 4. Referenze e note operative +

+
+
+ + +
+
+ + +
+
+
+ + +
+ + Annulla + + + Salva dati conto + +
+
+
+ @else + {{ $this->table }} + @endif
diff --git a/routes/web.php b/routes/web.php index d06ac27..dcde149 100755 --- a/routes/web.php +++ b/routes/web.php @@ -183,6 +183,8 @@ // Fatture elettroniche ricevute (azioni) Route::get('acqua-ripartizione/print', [AcquaRipartizionePrintController::class, 'print'])->name('acqua-ripartizione.print'); Route::get('acqua-ripartizione/pdf', [AcquaRipartizionePrintController::class, 'pdf'])->name('acqua-ripartizione.pdf'); + Route::get('riscaldamento-ripartizione/print', [\App\Http\Controllers\Admin\RiscaldamentoRipartizionePrintController::class, 'print'])->name('riscaldamento-ripartizione.print'); + Route::get('riscaldamento-ripartizione/pdf', [\App\Http\Controllers\Admin\RiscaldamentoRipartizionePrintController::class, 'pdf'])->name('riscaldamento-ripartizione.pdf'); Route::get('fatture-elettroniche/{fattura}/download-xml', [\App\Http\Controllers\Admin\FattureElettronicheRicevuteController::class, 'downloadXml']) ->name('fatture-elettroniche.download-xml'); diff --git a/tests/Feature/ContabilitaReconciliationHubTest.php b/tests/Feature/ContabilitaReconciliationHubTest.php new file mode 100644 index 0000000..1206e0e --- /dev/null +++ b/tests/Feature/ContabilitaReconciliationHubTest.php @@ -0,0 +1,731 @@ +create(); + $user->assignRole('super-admin'); + $this->actingAs($user); + + $amministratore = Amministratore::create([ + 'user_id' => $user->id, + 'nome' => 'Admin', + 'cognome' => 'Test', + 'denominazione' => 'Amministrazione Test', + 'codice_fiscale' => '12345678901', + ]); + + $stabile = Stabile::create([ + 'amministratore_id' => $amministratore->id, + 'codice_stabile' => '9999', + 'denominazione' => 'Condominio Test Contabilita', + 'codice_fiscale' => '12345678901', + 'indirizzo' => 'Via Test 1', + 'citta' => 'Roma', + 'cap' => '00100', + 'provincia' => 'RM', + 'codice_destinatario_sdi' => '0000000', + ]); + + $gestione = GestioneContabile::create([ + 'tenant_id' => 'default', + 'stabile_id' => $stabile->id, + 'anno_gestione' => 2026, + 'tipo_gestione' => 'ordinaria', + 'denominazione' => 'Gestione Ordinaria 2026', + 'data_inizio' => '2026-01-01', + 'data_fine' => '2026-12-31', + 'stato' => 'aperta', + 'protocollo_prefix' => 'O2026', + ]); + + $fornitore = Fornitore::create([ + 'amministratore_id' => $amministratore->id, + 'ragione_sociale' => 'ENEL ENERGIA SPA', + 'codice_fiscale' => '00325478911', + 'partita_iva' => '00325478911', + 'iban' => 'IT99X00000000000000000001234', + ]); + + DB::table('contabilita_fatture_fornitori')->insert([ + 'id' => 888, + 'stabile_id' => $stabile->id, + 'gestione_id' => $gestione->id, + 'fornitore_id' => $fornitore->id, + 'numero_documento' => '2026-INV-99', + 'totale' => 750.50, + 'netto_da_pagare' => 750.50, + 'stato' => 'da_pagare', + 'data_documento' => '2026-06-15', + ]); + + session(['netgescon.stabile_attivo_id' => $stabile->id]); + + $page = new DebitiPagareHub(); + $page->mount(); + + expect($page->debiti)->toHaveCount(1); + $debito = $page->debiti[0]; + expect($debito['id'])->toBe(888); + expect($debito['numero_documento'])->toBe('2026-INV-99'); + expect($debito['totale'])->toBe(750.50); + expect($debito['iban'])->toBe('IT99X00000000000000000001234'); + expect($debito['descrizione_bonifico'])->toContain('FAT-ID:888'); + expect($debito['descrizione_bonifico'])->toContain('ENEL ENERGIA SPA'); +}); + +test('it reconciles automatically via tracking tag FAT-ID', function () { + $user = User::factory()->create(); + $user->assignRole('super-admin'); + $this->actingAs($user); + + $amministratore = Amministratore::create([ + 'user_id' => $user->id, + 'nome' => 'Admin', + 'cognome' => 'Test', + 'denominazione' => 'Amministrazione Test', + 'codice_fiscale' => '12345678901', + ]); + + $stabile = Stabile::create([ + 'amministratore_id' => $amministratore->id, + 'codice_stabile' => '9999', + 'denominazione' => 'Condominio Test Contabilita', + 'codice_fiscale' => '12345678901', + 'indirizzo' => 'Via Test 1', + 'citta' => 'Roma', + 'cap' => '00100', + 'provincia' => 'RM', + 'codice_destinatario_sdi' => '0000000', + ]); + + $gestione = GestioneContabile::create([ + 'tenant_id' => 'default', + 'stabile_id' => $stabile->id, + 'anno_gestione' => 2026, + 'tipo_gestione' => 'riscaldamento', + 'denominazione' => 'Gestione Riscaldamento 2026', + 'data_inizio' => '2026-01-01', + 'data_fine' => '2026-12-31', + 'stato' => 'aperta', + 'protocollo_prefix' => 'R2026', + ]); + + $conto = DatiBancari::create([ + 'stabile_id' => $stabile->id, + 'tipo_conto' => 'corrente', + 'denominazione_banca' => 'Unicredit', + 'numero_conto' => '123456', + 'iban' => 'IT02X0000000000000000000', + 'saldo_iniziale' => 10000.00, + 'stato_conto' => 'attivo', + 'is_nostro_conto' => true, + ]); + + $fornitore = Fornitore::create([ + 'amministratore_id' => $amministratore->id, + 'ragione_sociale' => 'A2A ENERGIA', + 'codice_fiscale' => '00325478955', + 'partita_iva' => '00325478955', + 'old_id' => '9991', + ]); + + // Create service to allow finding this supplier + \App\Models\StabileServizio::create([ + 'stabile_id' => $stabile->id, + 'fornitore_id' => $fornitore->id, + 'tipo' => 'riscaldamento', + 'nome' => 'Riscaldamento', + 'attivo' => true, + ]); + + DB::table('contabilita_fatture_fornitori')->insert([ + 'id' => 777, + 'stabile_id' => $stabile->id, + 'gestione_id' => $gestione->id, + 'fornitore_id' => $fornitore->id, + 'numero_documento' => 'FATT-A2A-1', + 'totale' => 1500.00, + 'netto_da_pagare' => 1500.00, + 'stato' => 'da_pagare', + 'data_documento' => '2026-06-01', + ]); + + DB::table('contabilita_movimenti_banca')->insert([ + 'id' => 303, + 'stabile_id' => $stabile->id, + 'conto_id' => $conto->id, + 'gestione_id' => $gestione->id, + 'iban' => 'IT02X0000000000000000000', + 'data' => '2026-06-15', + 'valuta' => '2026-06-15', + 'descrizione' => 'Bonifico a A2A ENERGIA ref FAT-ID:777', + 'descrizione_estesa' => 'Bonifico a A2A ENERGIA ref FAT-ID:777', + 'importo' => -1500.00, + 'fornitore_id' => $fornitore->id, + 'row_hash' => md5('test_movement_303'), + ]); + + session(['netgescon.stabile_attivo_id' => $stabile->id]); + + $page = new RiscaldamentoStabileArchivio(); + $page->riconciliaAutomaticamentePagamenti(); + + $updatedInvoice = DB::table('contabilita_fatture_fornitori')->where('id', 777)->first(); + expect($updatedInvoice->stato)->toBe('pagato'); + expect($updatedInvoice->movimento_pagamento_id)->toBe(303); +}); + +test('it configures interbank mapping and auto registers bank costs and CBILL bollettini', function () { + $user = User::factory()->create(); + $user->assignRole('super-admin'); + $this->actingAs($user); + + $amministratore = Amministratore::create([ + 'user_id' => $user->id, + 'nome' => 'Admin', + 'cognome' => 'Test', + 'denominazione' => 'Amministrazione Test', + 'codice_fiscale' => '12345678901', + ]); + + $stabile = Stabile::create([ + 'amministratore_id' => $amministratore->id, + 'codice_stabile' => '9999', + 'denominazione' => 'Condominio Test Contabilita', + 'codice_fiscale' => '12345678901', + 'indirizzo' => 'Via Test 1', + 'citta' => 'Roma', + 'cap' => '00100', + 'provincia' => 'RM', + 'codice_destinatario_sdi' => '0000000', + ]); + + $gestione = GestioneContabile::create([ + 'tenant_id' => 'default', + 'stabile_id' => $stabile->id, + 'anno_gestione' => 2026, + 'tipo_gestione' => 'ordinaria', + 'denominazione' => 'Gestione Ordinaria 2026', + 'data_inizio' => '2026-01-01', + 'data_fine' => '2026-12-31', + 'stato' => 'aperta', + 'protocollo_prefix' => 'O2026', + ]); + + $contoBancario = DatiBancari::create([ + 'stabile_id' => $stabile->id, + 'tipo_conto' => 'corrente', + 'denominazione_banca' => 'Unicredit', + 'numero_conto' => '123456', + 'iban' => 'IT02X0000000000000000000', + 'saldo_iniziale' => 10000.00, + 'stato_conto' => 'attivo', + 'is_nostro_conto' => true, + ]); + + // 1. Setup global chart accounts + // Ensure PianoConti accounts 7 (Banca) and 27 (Oneri) exist + DB::table('contabilita_piano_conti')->insertOrIgnore([ + 'id' => 7, + 'codice' => '1020', + 'denominazione' => 'Banca c/c', + 'tipo_conto' => 'ATTIVO', + 'attivo' => 1, + 'livello' => 2, + 'codice_padre' => '100', + 'saldo_iniziale' => 0.00, + ]); + + DB::table('contabilita_piano_conti')->insertOrIgnore([ + 'id' => 27, + 'codice' => '5150', + 'denominazione' => 'Oneri bancari e postali', + 'tipo_conto' => 'COSTO', + 'attivo' => 1, + 'livello' => 2, + 'codice_padre' => '500', + 'saldo_iniziale' => 0.00, + ]); + + session(['netgescon.stabile_attivo_id' => $stabile->id]); + + $page = new CasseBancheRiepilogo(); + + // Propose mappings + $page->interbankMappings = [ + '198' => 27, // Map code 198 to account 27 + '219' => 27, // Map code 219 to account 27 + '012' => 27, + ]; + $page->saveInterbankMappings(); + + // Verify rules were persisted + $rule198 = RegolaPrimaNota::where('stabile_id', $stabile->id) + ->where('origine', 'banca') + ->where('chiave', '198') + ->first(); + + expect($rule198)->not->toBeNull(); + expect((int)$rule198->conto_avere_id)->toBe(27); + + // Mock direct bank movements + DB::table('contabilita_movimenti_banca')->insert([ + 'id' => 401, + 'stabile_id' => $stabile->id, + 'conto_id' => $contoBancario->id, + 'gestione_id' => $gestione->id, + 'iban' => 'IT02X0000000000000000000', + 'data' => '2026-07-01', + 'valuta' => '2026-07-01', + 'descrizione' => 'IMPRENDO CONDOMINIO LIGHT COSTO FISSO MESE DI GIUGNO 2026', + 'descrizione_estesa' => 'IMPRENDO CONDOMINIO LIGHT COSTO FISSO MESE DI GIUGNO 2026', + 'importo' => -22.00, + 'causale' => '198', + 'row_hash' => md5('test_movement_401'), + ]); + + // Mock a CBILL utility payment movement (causale 012) + $fornitoreCbill = Fornitore::create([ + 'amministratore_id' => $amministratore->id, + 'ragione_sociale' => 'ACEA ATO 2 SPA', + 'codice_fiscale' => '00325478977', + 'partita_iva' => '00325478977', + ]); + + DB::table('contabilita_fatture_fornitori')->insert([ + 'id' => 999, + 'stabile_id' => $stabile->id, + 'gestione_id' => $gestione->id, + 'fornitore_id' => $fornitoreCbill->id, + 'numero_documento' => 'ACEA-BOL-2', + 'totale' => 1310.76, + 'netto_da_pagare' => 1310.76, + 'stato' => 'da_pagare', + 'data_documento' => '2026-05-01', + ]); + + DB::table('contabilita_movimenti_banca')->insert([ + 'id' => 402, + 'stabile_id' => $stabile->id, + 'conto_id' => $contoBancario->id, + 'gestione_id' => $gestione->id, + 'iban' => 'IT02X0000000000000000000', + 'data' => '2026-05-11', + 'valuta' => '2026-05-11', + 'descrizione' => 'DISPOSIZIONE DI ADDEBITO GENERICA BOLLETTINO ACEA ATO 2 S.P.A. (PROFILO PAGOPA)', + 'descrizione_estesa' => 'DISPOSIZIONE DI ADDEBITO GENERICA BOLLETTINO ACEA ATO 2 S.P.A. (PROFILO PAGOPA)', + 'importo' => -1310.76, + 'causale' => '012', + 'row_hash' => md5('test_movement_402'), + ]); + + // Run batch reconciliation + $page->riconciliaSpeseBancarieAutomaticamente(); + + // Verify 401 has been registered in Prima Nota + $m401 = MovimentoBanca::find(401); + expect($m401->registrazione_id)->not->toBeNull(); + + // Verify 402 has matched and paid the Acea invoice + $invoiceAcea = DB::table('contabilita_fatture_fornitori')->where('id', 999)->first(); + expect($invoiceAcea->stato)->toBe('pagato'); + expect($invoiceAcea->movimento_pagamento_id)->toBe(402); +}); + +test('it renders the operations staging query even if the table does not exist', function () { + $user = User::factory()->create(); + $user->assignRole('super-admin'); + $this->actingAs($user); + + $amministratore = Amministratore::create([ + 'user_id' => $user->id, + 'nome' => 'Admin', + 'cognome' => 'Test', + 'denominazione' => 'Amministrazione Test', + 'codice_fiscale' => '12345678901', + ]); + + $stabile = Stabile::create([ + 'amministratore_id' => $amministratore->id, + 'codice_stabile' => '9999', + 'denominazione' => 'Condominio Test Contabilita', + 'codice_fiscale' => '12345678901', + 'indirizzo' => 'Via Test 1', + 'citta' => 'Roma', + 'cap' => '00100', + 'provincia' => 'RM', + 'codice_destinatario_sdi' => '0000000', + ]); + + session(['netgescon.stabile_attivo_id' => $stabile->id]); + + $page = new \App\Filament\Pages\Gescon\Ordinarie(); + $page->netgesconWorkflowMode = false; + + // Verify calling getOperazioniProperty() does NOT crash, even though operations table is not in sqlite test db + $result = $page->getOperazioniProperty(); + expect($result)->not->toBeNull(); +}); + +test('it loads movements and debiti storici tab contents correctly', function () { + $user = User::factory()->create(); + $user->assignRole('super-admin'); + $this->actingAs($user); + + $amministratore = Amministratore::create([ + 'user_id' => $user->id, + 'nome' => 'Admin', + 'cognome' => 'Test', + 'denominazione' => 'Amministrazione Test', + 'codice_fiscale' => '12345678901', + ]); + + $stabile = Stabile::create([ + 'amministratore_id' => $amministratore->id, + 'codice_stabile' => '9999', + 'denominazione' => 'Condominio Test Contabilita', + 'codice_fiscale' => '12345678901', + 'indirizzo' => 'Via Test 1', + 'citta' => 'Roma', + 'cap' => '00100', + 'provincia' => 'RM', + 'codice_destinatario_sdi' => '0000000', + ]); + + $gestione = GestioneContabile::create([ + 'tenant_id' => 'default', + 'stabile_id' => $stabile->id, + 'anno_gestione' => 2026, + 'tipo_gestione' => 'ordinaria', + 'denominazione' => 'Gestione Ordinaria 2026', + 'data_inizio' => '2026-01-01', + 'data_fine' => '2026-12-31', + 'stato' => 'aperta', + 'protocollo_prefix' => 'O2026', + ]); + + $fornitore = Fornitore::create([ + 'amministratore_id' => $amministratore->id, + 'ragione_sociale' => 'A2A ENERGIA', + 'codice_fiscale' => '00325478955', + 'partita_iva' => '00325478955', + ]); + + // 1. Invoice 1: Document date 2026-06-01, paid late on 2027-01-15 + DB::table('contabilita_fatture_fornitori')->insert([ + 'id' => 991, + 'stabile_id' => $stabile->id, + 'gestione_id' => $gestione->id, + 'fornitore_id' => $fornitore->id, + 'numero_documento' => 'A2A-LATE-1', + 'totale' => 500.00, + 'netto_da_pagare' => 500.00, + 'stato' => 'pagato', + 'data_documento' => '2026-06-01', + 'data_pagamento' => '2027-01-15', + ]); + + // 2. Invoice 2: Document date 2026-06-02, still unpaid + DB::table('contabilita_fatture_fornitori')->insert([ + 'id' => 992, + 'stabile_id' => $stabile->id, + 'gestione_id' => $gestione->id, + 'fornitore_id' => $fornitore->id, + 'numero_documento' => 'A2A-UNPAID', + 'totale' => 600.00, + 'netto_da_pagare' => 600.00, + 'stato' => 'da_pagare', + 'data_documento' => '2026-06-02', + ]); + + // 3. Invoice 3: Document date after management period, e.g. 2027-01-01 + DB::table('contabilita_fatture_fornitori')->insert([ + 'id' => 993, + 'stabile_id' => $stabile->id, + 'gestione_id' => $gestione->id, + 'fornitore_id' => $fornitore->id, + 'numero_documento' => 'A2A-FUTURE', + 'totale' => 300.00, + 'netto_da_pagare' => 300.00, + 'stato' => 'da_pagare', + 'data_documento' => '2027-01-01', + ]); + + // 4. Mock bank movement + $conto = DatiBancari::create([ + 'stabile_id' => $stabile->id, + 'tipo_conto' => 'corrente', + 'denominazione_banca' => 'Unicredit', + 'numero_conto' => '123456', + 'iban' => 'IT02X0000000000000000000', + 'saldo_iniziale' => 10000.00, + 'stato_conto' => 'attivo', + 'is_nostro_conto' => true, + ]); + + DB::table('contabilita_movimenti_banca')->insert([ + 'id' => 501, + 'stabile_id' => $stabile->id, + 'conto_id' => $conto->id, + 'gestione_id' => $gestione->id, + 'iban' => 'IT02X0000000000000000000', + 'data' => '2026-07-01', + 'valuta' => '2026-07-01', + 'descrizione' => 'Test movement unreconciled', + 'importo' => -100.00, + 'row_hash' => md5('test_movement_501'), + ]); + + session(['netgescon.stabile_attivo_id' => $stabile->id]); + + $page = new DebitiPagareHub(); + $page->mount(); + + // Verify movements tab loads correctly + $page->setTab('movimenti'); + expect($page->movimenti)->toHaveCount(1); + expect($page->movimenti[0]['id'])->toBe(501); + expect($page->movimenti[0]['stato'])->toBe('da_quadrare'); + + // Verify historical debts at target date (2026-12-31) + $page->setTab('stato_debiti'); + $page->targetDate = '2026-12-31'; + $page->loadDebitiStorici(); + + // 991 (paid late on 2027-01-15) and 992 (unpaid) are debts at 2026-12-31. + // 993 (dated 2027-01-01) is NOT a debt at 2026-12-31. + expect($page->debitiStorici)->toHaveCount(2); + + $ids = collect($page->debitiStorici)->pluck('id')->all(); + expect($ids)->toContain(991); + expect($ids)->toContain(992); + expect($ids)->not->toContain(993); + + // Verify pay dates/closures + $invoice992 = collect($page->debitiStorici)->firstWhere('id', 992); + expect($invoice992['data_chiusura'])->toBe('Ancora aperto'); + + expect($page->totaleDebitiStorici)->toBe(1100.0); +}); + +test('it automatically reconciles F24 withholding tax and CBILL via numeric code and fallback text', function () { + $user = User::factory()->create(); + $user->assignRole('super-admin'); + $this->actingAs($user); + + $amministratore = Amministratore::create([ + 'user_id' => $user->id, + 'nome' => 'Admin', + 'cognome' => 'Test', + 'denominazione' => 'Amministrazione Test', + 'codice_fiscale' => '12345678901', + ]); + + $stabile = Stabile::create([ + 'amministratore_id' => $amministratore->id, + 'codice_stabile' => '9999', + 'denominazione' => 'Condominio Test Contabilita', + 'codice_fiscale' => '12345678901', + 'indirizzo' => 'Via Test 1', + 'citta' => 'Roma', + 'cap' => '00100', + 'provincia' => 'RM', + 'codice_destinatario_sdi' => '0000000', + ]); + + $gestione = GestioneContabile::create([ + 'tenant_id' => 'default', + 'stabile_id' => $stabile->id, + 'anno_gestione' => 2026, + 'tipo_gestione' => 'ordinaria', + 'denominazione' => 'Gestione Ordinaria 2026', + 'data_inizio' => '2026-01-01', + 'data_fine' => '2026-12-31', + 'stato' => 'aperta', + 'protocollo_prefix' => 'O2026', + ]); + + $conto = DatiBancari::create([ + 'stabile_id' => $stabile->id, + 'tipo_conto' => 'corrente', + 'denominazione_banca' => 'Unicredit', + 'numero_conto' => '123456', + 'iban' => 'IT02X0000000000000000000', + 'saldo_iniziale' => 10000.00, + 'stato_conto' => 'attivo', + 'is_nostro_conto' => true, + ]); + + // Create a rule for F24 and Bollo/Competenze cost accounts + DB::table('contabilita_piano_conti')->insertOrIgnore([ + ['id' => 7, 'codice' => '1020', 'denominazione' => 'Banca c/c', 'tipo_conto' => 'ATTIVO', 'attivo' => 1, 'livello' => 2, 'codice_padre' => '100', 'saldo_iniziale' => 0.00], + ['id' => 822, 'codice' => '822', 'denominazione' => 'Spese postali e bolli', 'tipo_conto' => 'COSTO', 'attivo' => 1, 'livello' => 1, 'codice_padre' => null, 'saldo_iniziale' => 0.00], + ['id' => 198, 'codice' => '1980', 'denominazione' => 'Spese tenuta conto', 'tipo_conto' => 'COSTO', 'attivo' => 1, 'livello' => 1, 'codice_padre' => null, 'saldo_iniziale' => 0.00], + ['id' => 900, 'codice' => '900', 'denominazione' => 'Erario c/ritenute', 'tipo_conto' => 'PASSIVO', 'attivo' => 1, 'livello' => 1, 'codice_padre' => null, 'saldo_iniziale' => 0.00], + ]); + + RegolaPrimaNota::create([ + 'stabile_id' => $stabile->id, + 'origine' => 'banca', + 'chiave' => '219', // Bollo + 'conto_dare_id' => 7, + 'conto_avere_id' => 822, + 'attiva' => true, + 'label' => 'Bollo', + ]); + + RegolaPrimaNota::create([ + 'stabile_id' => $stabile->id, + 'origine' => 'banca', + 'chiave' => '198', // Competenze + 'conto_dare_id' => 7, + 'conto_avere_id' => 198, + 'attiva' => true, + 'label' => 'Competenze', + ]); + + RegolaPrimaNota::create([ + 'stabile_id' => $stabile->id, + 'origine' => 'banca', + 'chiave' => 'F24', // F24 + 'conto_dare_id' => 7, + 'conto_avere_id' => 900, + 'attiva' => true, + 'label' => 'F24', + ]); + + $fornitore = Fornitore::create([ + 'amministratore_id' => $amministratore->id, + 'ragione_sociale' => 'ACEA ATO 2 SPA', + 'codice_fiscale' => '00325478955', + 'partita_iva' => '00325478955', + ]); + + // Mock Withholding Tax (RA) record + $ra = \App\Models\RegistroRitenuteAcconto::create([ + 'tenant_id' => 'default', + 'gestione_id' => $gestione->id, + 'fornitore_id' => $fornitore->id, + 'numero_progressivo' => 1, + 'data_competenza' => '2026-06-15', + 'imponibile' => 1000.00, + 'aliquota_ritenuta' => 20.00, + 'importo_ritenuta' => 200.00, + 'stato_versamento' => 'da_versare', + 'data_scadenza_versamento' => '2026-07-16', + ]); + + // 1. Bank movement for F24 + DB::table('contabilita_movimenti_banca')->insert([ + 'id' => 601, + 'stabile_id' => $stabile->id, + 'conto_id' => $conto->id, + 'gestione_id' => $gestione->id, + 'iban' => 'IT02X0000000000000000000', + 'data' => '2026-07-15', + 'valuta' => '2026-07-15', + 'descrizione' => 'PAGAMENTO DELEGHE F23/F24 PRENOTATE PAGAMENTO FISCO/INPS/REGIONI DA ENTRATEL', + 'importo' => -200.00, + 'causale' => '', // Empty causale to test text fallback! + 'row_hash' => md5('test_601'), + ]); + + // 2. Bank movement for stamp duty with different code but text match + DB::table('contabilita_movimenti_banca')->insert([ + 'id' => 602, + 'stabile_id' => $stabile->id, + 'conto_id' => $conto->id, + 'gestione_id' => $gestione->id, + 'iban' => 'IT02X0000000000000000000', + 'data' => '2026-07-01', + 'valuta' => '2026-07-01', + 'descrizione' => 'IMPOSTA BOLLO CONTO CORRENTE DPR642/72-DM24/5/2012', + 'importo' => -29.41, + 'causale' => '822', // Mismatched code but description matches + 'row_hash' => md5('test_602'), + ]); + + $fe = \App\Models\FatturaElettronica::create([ + 'tenant_id' => 'default', + 'stabile_id' => $stabile->id, + 'fornitore_id' => $fornitore->id, + 'numero_fattura' => '2026-CBILL-9', + 'data_fattura' => '2026-06-12', + 'fornitore_piva' => '00325478955', + 'fornitore_denominazione' => 'ACEA ATO 2 SPA', + 'imponibile' => 1266.97, + 'iva' => 278.73, + 'totale' => 1545.70, + 'pagamento_cbill' => '125007300041415467', + 'pagamento_tipo' => 'MP08', + 'file_nome' => 'test.xml', + ]); + + DB::table('contabilita_fatture_fornitori')->insert([ + 'id' => 995, + 'stabile_id' => $stabile->id, + 'gestione_id' => $gestione->id, + 'fornitore_id' => $fornitore->id, + 'fattura_elettronica_id' => $fe->id, + 'numero_documento' => '2026-CBILL-9', + 'totale' => 1545.70, + 'netto_da_pagare' => 1545.70, + 'stato' => 'da_pagare', + 'data_documento' => '2026-06-12', + ]); + + DB::table('contabilita_movimenti_banca')->insert([ + 'id' => 603, + 'stabile_id' => $stabile->id, + 'conto_id' => $conto->id, + 'gestione_id' => $gestione->id, + 'iban' => 'IT02X0000000000000000000', + 'data' => '2026-07-20', + 'valuta' => '2026-07-20', + 'descrizione' => 'DISPOSIZIONE DI ADDEBITO GENERICA BOLLETTINO ACEA ATO2 SPA - 125007300041415467', + 'importo' => -1545.70, + 'causale' => '012', + 'row_hash' => md5('test_603'), + ]); + + session(['netgescon.stabile_attivo_id' => $stabile->id]); + + $page = new CasseBancheRiepilogo(); + $page->riconciliaSpeseBancarieAutomaticamente(); + + // 1. Verify RA F24 payment was reconciled + $raReloaded = \App\Models\RegistroRitenuteAcconto::find($ra->id); + expect($raReloaded->stato_versamento)->toBe('versata'); + expect($raReloaded->data_versamento->toDateString())->toBe('2026-07-15'); + expect($raReloaded->f24_riferimento)->toContain('ENTRATEL'); + + // 2. Verify 601 and 602 got a contabile registration (Prima Nota) + $m601 = MovimentoBanca::find(601); + expect($m601->registrazione_id)->not->toBeNull(); + + $m602 = MovimentoBanca::find(602); + expect($m602->registrazione_id)->not->toBeNull(); + + // 3. Verify CBILL numeric code matched and paid the invoice + $invoice = DB::table('contabilita_fatture_fornitori')->where('id', 995)->first(); + expect($invoice->stato)->toBe('pagato'); + expect($invoice->movimento_pagamento_id)->toBe(603); +}); diff --git a/tests/Feature/ElectronicReadingsTest.php b/tests/Feature/ElectronicReadingsTest.php new file mode 100644 index 0000000..29eac91 --- /dev/null +++ b/tests/Feature/ElectronicReadingsTest.php @@ -0,0 +1,66 @@ +create(); + $user->assignRole('super-admin'); + $this->actingAs($user); + + $stabile = Stabile::factory()->create(); + $servizio = StabileServizio::query()->create([ + 'stabile_id' => $stabile->id, + 'tipo' => 'acqua', + 'attivo' => true, + 'nome' => 'Acqua Potabile' + ]); + + $unit1 = UnitaImmobiliare::factory()->create([ + 'stabile_id' => $stabile->id, + 'interno' => '6', + 'acqua_gateway_device_id' => null + ]); + + $unit2 = UnitaImmobiliare::factory()->create([ + 'stabile_id' => $stabile->id, + 'interno' => '7', + 'acqua_gateway_device_id' => '22919618' + ]); + + \App\Support\StabileContext::setActiveStabileId($user, $stabile->id); + \App\Support\AnnoGestioneContext::setActiveAnno(2024); + + $csvContent = "#ID,Modulo n°,Contatore n°,Nome,Cognome,Indirizzo,Interno,Lettura,Auto-lettura,Tensione,Date e tipo Frode,Flusso inverso (m3),Data allarme perdita,Gennaio,Febbraio,Marzo,Aprile,Maggio,Giugno,Luglio,Agosto,Settembre,Ottobre,Novembre,Dicembre\n" . + "22919617,,,,,,6,\"411,560 m3\",0,,,\"0,000 m3\",,,,,\n" . + "22919618,,,,,,7,\"662,184 m3\",0,,,\"0,000 m3\",,,,,"; + + $file = UploadedFile::fake()->createWithContent('readings.csv', $csvContent); + + Livewire::test(ServiziStabileArchivio::class) + ->set('electronicReadingsFile', $file) + ->call('importElectronicReadings') + ->assertHasNoErrors(); + + $unit1->refresh(); + expect($unit1->acqua_gateway_device_id)->toBe('22919617'); + + $readings = StabileServizioLettura::query() + ->where('stabile_id', $stabile->id) + ->get(); + + expect($readings)->toHaveCount(2); + + $r1 = $readings->firstWhere('unita_immobiliare_id', $unit1->id); + expect((float)$r1->lettura_fine)->toEqual(411.560); + expect($r1->tipologia_lettura)->toBe('elettronica_remota'); + + $r2 = $readings->firstWhere('unita_immobiliare_id', $unit2->id); + expect((float)$r2->lettura_fine)->toEqual(662.184); +}); diff --git a/tests/Feature/ImportGesconFullPipelineTest.php b/tests/Feature/ImportGesconFullPipelineTest.php new file mode 100644 index 0000000..579fa1c --- /dev/null +++ b/tests/Feature/ImportGesconFullPipelineTest.php @@ -0,0 +1,122 @@ +create(); + $user->assignRole('super-admin'); + $this->actingAs($user); + + $amministratore = Amministratore::create([ + 'user_id' => $user->id, + 'nome' => 'Admin', + 'cognome' => 'Test', + 'denominazione' => 'Amministrazione Test', + 'codice_fiscale' => '12345678901', + ]); + + $stabile = Stabile::create([ + 'amministratore_id' => $amministratore->id, + 'codice_stabile' => '9999', + 'denominazione' => 'Condominio Test Contabilita', + 'codice_fiscale' => '12345678901', + 'indirizzo' => 'Via Test 1', + 'citta' => 'Roma', + 'cap' => '00100', + 'provincia' => 'RM', + 'codice_destinatario_sdi' => '0000000', + ]); + + $gestione = GestioneContabile::create([ + 'tenant_id' => 'default', + 'stabile_id' => $stabile->id, + 'anno_gestione' => 2026, + 'tipo_gestione' => 'ordinaria', + 'denominazione' => 'Gestione Ordinaria 2026', + 'data_inizio' => '2026-01-01', + 'data_fine' => '2026-12-31', + 'stato' => 'aperta', + 'protocollo_prefix' => 'O2026', + ]); + + // Setup staging operations table + Schema::connection('gescon_import')->dropIfExists('operazioni'); + Schema::connection('gescon_import')->create('operazioni', function ($table) { + $table->id('id_operaz'); + $table->string('cod_stabile')->nullable(); + $table->string('legacy_year')->nullable(); + $table->string('compet')->nullable(); + $table->string('gestione')->nullable(); + $table->integer('n_stra')->nullable(); + $table->integer('n_spe')->nullable(); + $table->string('cod_for')->nullable(); + $table->string('cod_spe')->nullable(); + $table->string('natura2')->nullable(); + $table->decimal('importo_euro', 15, 2)->nullable(); + $table->decimal('importo_spese', 15, 2)->nullable(); + $table->decimal('importo_entrate', 15, 2)->nullable(); + $table->string('note')->nullable(); + $table->string('natura')->nullable(); + $table->date('dt_spe')->nullable(); + }); + + // Seed initial staging operations data + DB::connection('gescon_import')->table('operazioni')->insert([ + 'id_operaz' => 888, + 'cod_stabile' => '9999', + 'legacy_year' => '2026', + 'compet' => 'C', + 'gestione' => 'O', + 'n_stra' => 0, + 'importo_euro' => 150.00, + 'importo_spese' => 150.00, + 'importo_entrate' => 0.00, + 'note' => 'Original Operation', + 'dt_spe' => '2026-07-01', + ]); + + // Run first import to create the domain entry + Artisan::call('gescon:import-full', [ + '--stabile' => '9999', + '--solo' => 'operazioni', + ]); + + // Verify it was created + $imported = DB::table('operazioni_contabili')->where('legacy_id', 888)->first(); + expect($imported)->not->toBeNull(); + expect($imported->descrizione)->toBe('Original Operation'); + + // Simulate user editing/reconciling this transaction in NetGescon + DB::table('operazioni_contabili')->where('id', $imported->id)->update([ + 'stato_operazione' => 'reconciled_by_user', + ]); + + // Update staging data in MDB (e.g. amount or note changes at source) + DB::connection('gescon_import')->table('operazioni')->where('id_operaz', 888)->update([ + 'note' => 'Updated Operation Note', + ]); + + // Run pipeline again WITH --update-only to check if the user modifications and record are preserved + Artisan::call('gescon:import-full', [ + '--stabile' => '9999', + '--solo' => 'operazioni', + '--update-only' => true, + ]); + + $reimported = DB::table('operazioni_contabili')->where('legacy_id', 888)->first(); + expect($reimported)->not->toBeNull(); + // Verify the record was NOT deleted and recreated (which would lose the status edited by user) + expect($reimported->id)->toBe($imported->id); + expect($reimported->stato_operazione)->toBe('reconciled_by_user'); + // Verify the note was updated incrementally + expect($reimported->descrizione)->toBe('Updated Operation Note'); + + // Clean up staging + Schema::connection('gescon_import')->dropIfExists('operazioni'); +}); diff --git a/tests/Feature/PhysicalArchiveXmlTest.php b/tests/Feature/PhysicalArchiveXmlTest.php new file mode 100644 index 0000000..71b3480 --- /dev/null +++ b/tests/Feature/PhysicalArchiveXmlTest.php @@ -0,0 +1,85 @@ +create(); + $user->assignRole('super-admin'); + $this->actingAs($user); + + $amministratore = Amministratore::create([ + 'user_id' => $user->id, + 'nome' => 'Admin', + 'cognome' => 'Test', + 'denominazione' => 'Amministrazione Test', + 'codice_fiscale' => '12345678901', + ]); + + $stabile = Stabile::create([ + 'amministratore_id' => $amministratore->id, + 'codice_stabile' => '9999', + 'denominazione' => 'Condominio Test XML', + 'codice_fiscale' => '12345678901', + 'indirizzo' => 'Via Test 1', + 'citta' => 'Roma', + 'cap' => '00100', + 'provincia' => 'RM', + 'codice_destinatario_sdi' => '0000000', + ]); + + // Simula resolveActiveStabile tramite mock o set nel contesto sessione + session(['netgescon.stabile_attivo_id' => $stabile->id]); + + // Crea elementi di test (faldone -> cartellina -> documento) + $faldone = DocumentoArchivioFisicoItem::create([ + 'stabile_id' => $stabile->id, + 'codice_univoco' => 'AF-' . $stabile->id . '-0001', + 'tipo_item' => 'faldone_anelli', + 'titolo' => 'Faldone Generale 2026', + 'data_archiviazione' => now()->toDateString(), + 'stato' => 'disponibile', + ]); + + $cartellina = DocumentoArchivioFisicoItem::create([ + 'stabile_id' => $stabile->id, + 'parent_id' => $faldone->id, + 'codice_univoco' => 'AF-' . $stabile->id . '-0002', + 'tipo_item' => 'cartellina', + 'titolo' => 'Cartellina Riscaldamento', + 'data_archiviazione' => now()->toDateString(), + 'stato' => 'disponibile', + ]); + + $page = new DocumentiArchivio(); + + $xml = $page->exportPhysicalArchiveToXml(); + + expect($xml)->toContain('netgescon_physical_archive') + ->toContain('Faldone Generale 2026') + ->toContain('Cartellina Riscaldamento'); + + // Cancella gli elementi per testare il re-import + DocumentoArchivioFisicoItem::query()->delete(); + expect(DocumentoArchivioFisicoItem::count())->toBe(0); + + // Re-importa + $importedCount = $page->importPhysicalArchiveFromXml($xml); + expect($importedCount)->toBe(2); + + expect(DocumentoArchivioFisicoItem::count())->toBe(2); + + $importedFaldone = DocumentoArchivioFisicoItem::where('tipo_item', 'faldone_anelli')->first(); + $importedCartellina = DocumentoArchivioFisicoItem::where('tipo_item', 'cartellina')->first(); + + expect($importedFaldone->titolo)->toBe('Faldone Generale 2026'); + expect($importedCartellina->titolo)->toBe('Cartellina Riscaldamento'); + expect($importedCartellina->parent_id)->toBe($importedFaldone->id); +}); diff --git a/tests/Feature/RiscaldamentoReconciliationTest.php b/tests/Feature/RiscaldamentoReconciliationTest.php new file mode 100644 index 0000000..8ee7d85 --- /dev/null +++ b/tests/Feature/RiscaldamentoReconciliationTest.php @@ -0,0 +1,172 @@ +create(); + $user->assignRole('super-admin'); + $this->actingAs($user); + + $amministratore = Amministratore::create([ + 'user_id' => $user->id, + 'nome' => 'Admin', + 'cognome' => 'Test', + 'denominazione' => 'Amministrazione Test', + 'codice_fiscale' => '12345678901', + ]); + + $stabile = Stabile::create([ + 'amministratore_id' => $amministratore->id, + 'codice_stabile' => '9999', + 'denominazione' => 'Condominio Test XML', + 'codice_fiscale' => '12345678901', + 'indirizzo' => 'Via Test 1', + 'citta' => 'Roma', + 'cap' => '00100', + 'provincia' => 'RM', + 'codice_destinatario_sdi' => '0000000', + ]); + + // Create a GestioneContabile record to satisfy foreign keys + $gestione = GestioneContabile::create([ + 'tenant_id' => 'default', + 'stabile_id' => $stabile->id, + 'anno_gestione' => 2026, + 'tipo_gestione' => 'riscaldamento', + 'denominazione' => 'Gestione Riscaldamento 2026', + 'data_inizio' => '2026-01-01', + 'data_fine' => '2026-12-31', + 'stato' => 'attiva', + 'protocollo_prefix' => 'R2026', + ]); + + // Create a DatiBancari record to satisfy foreign keys + $conto = DatiBancari::create([ + 'stabile_id' => $stabile->id, + 'tipo_conto' => 'corrente', + 'denominazione_banca' => 'Unicredit', + 'numero_conto' => '123456', + 'iban' => 'IT02X0000000000000000000', + 'saldo_iniziale' => 10000.00, + 'stato_conto' => 'attivo', + 'is_nostro_conto' => true, + ]); + + // 2. Setup Fornitore and StabileServizio + $fornitore = Fornitore::create([ + 'amministratore_id' => $amministratore->id, + 'ragione_sociale' => 'ENI PLENITUDE', + 'codice_fiscale' => '00215478900', + 'partita_iva' => '00215478900', + 'old_id' => '1234', // legacy ID matching operations + ]); + + $service = StabileServizio::create([ + 'stabile_id' => $stabile->id, + 'fornitore_id' => $fornitore->id, + 'tipo' => 'riscaldamento', + 'nome' => 'Riscaldamento Centrale', + 'attivo' => true, + ]); + + // 3. Setup VoceSpesa + $voceSpesa = VoceSpesa::create([ + 'stabile_id' => $stabile->id, + 'codice' => 'R1', + 'descrizione' => 'Spese Riscaldamento Metano', + 'categoria' => 'riscaldamento', + 'tipo' => 'riscaldamento', + 'attiva' => true, + ]); + + // 4. Setup staging database environment for MDB operations mapping + Schema::connection('gescon_import')->dropIfExists('operazioni'); + Schema::connection('gescon_import')->create('operazioni', function ($table) { + $table->id(); + $table->string('cod_stabile')->nullable(); + $table->string('cod_for')->nullable(); + $table->string('cod_spe')->nullable(); + $table->decimal('importo_euro', 15, 2)->nullable(); + }); + + DB::connection('gescon_import')->table('operazioni')->insert([ + 'cod_stabile' => '9999', + 'cod_for' => '1234', + 'cod_spe' => 'R1', + 'importo_euro' => 1500.00, + ]); + + session(['netgescon.stabile_attivo_id' => $stabile->id]); + + $page = new RiscaldamentoStabileArchivio(); + + // Call auto-link default mapping method + $page->mount(); + + // Verify FornitoreStabileImpostazione was created + $imp = FornitoreStabileImpostazione::where('stabile_id', $stabile->id) + ->where('fornitore_id', $fornitore->id) + ->first(); + + expect($imp)->not->toBeNull(); + expect((int)$imp->voce_spesa_default_id)->toBe($voceSpesa->id); + + // 5. Test bank reconciliation + DB::table('contabilita_fatture_fornitori')->insert([ + 'id' => 101, + 'stabile_id' => $stabile->id, + 'gestione_id' => $gestione->id, + 'fornitore_id' => $fornitore->id, + 'numero_documento' => '10045/A', + 'totale' => 1250.00, + 'netto_da_pagare' => 1250.00, + 'stato' => 'da_pagare', + 'data_documento' => now()->toDateString(), + 'created_at' => now(), + 'updated_at' => now(), + ]); + + // Create a mock bank movement entry + DB::table('contabilita_movimenti_banca')->insert([ + 'id' => 202, + 'stabile_id' => $stabile->id, + 'conto_id' => $conto->id, + 'gestione_id' => $gestione->id, + 'iban' => 'IT02X0000000000000000000', + 'data' => now()->toDateString(), + 'valuta' => now()->toDateString(), + 'descrizione' => 'Pagam. Fatt. 10045/A ENI PLENITUDE', + 'descrizione_estesa' => 'Pagam. Fatt. 10045/A ENI PLENITUDE', + 'importo' => -1250.00, // payment is negative + 'fornitore_id' => $fornitore->id, + 'row_hash' => md5('test_movement_202'), + 'created_at' => now(), + 'updated_at' => now(), + ]); + + // Run reconciliation + $page->riconciliaAutomaticamentePagamenti(); + + $updatedInvoice = DB::table('contabilita_fatture_fornitori')->where('id', 101)->first(); + expect($updatedInvoice->stato)->toBe('pagato'); + expect($updatedInvoice->movimento_pagamento_id)->toBe(202); + + // Cleanup staging tables + Schema::connection('gescon_import')->dropIfExists('operazioni'); +}); diff --git a/tests/Unit/RipartizioneSpesaServiceTest.php b/tests/Unit/RipartizioneSpesaServiceTest.php index 8ec59d1..5cc36cd 100755 --- a/tests/Unit/RipartizioneSpesaServiceTest.php +++ b/tests/Unit/RipartizioneSpesaServiceTest.php @@ -188,4 +188,27 @@ public function test_calcola_statistiche() $this->assertEquals(450.00, $statistiche['importo_max_unita']); $this->assertEquals(330.000, $statistiche['millesimi_totali']); } + + public function test_calcola_ripartizione_consuntivo_error_if_millesimi_mismatch() + { + $user = User::factory()->create(); + $this->actingAs($user); + + $stabile = Stabile::factory()->create(); + $tabellaMillesimale = TabellaMillesimale::factory()->create([ + 'stabile_id' => $stabile->id, + 'totale_millesimi' => 1000, + ]); + + $voceSpesa = VoceSpesa::factory()->create([ + 'stabile_id' => $stabile->id, + 'tabella_millesimale_default_id' => $tabellaMillesimale->id, + 'importo_consuntivo' => 500.00, + ]); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage("La somma dei millesimi di dettaglio"); + + $this->service->calcolaRipartizione($voceSpesa, 500.00); + } }