diff --git a/app/Console/Commands/GesconAutoSyncAnagrafiche.php b/app/Console/Commands/GesconAutoSyncAnagrafiche.php index cf898f9..9b4205e 100755 --- a/app/Console/Commands/GesconAutoSyncAnagrafiche.php +++ b/app/Console/Commands/GesconAutoSyncAnagrafiche.php @@ -1151,34 +1151,48 @@ private function findExistingRubricaMatch(int $amministratoreId, array $data): a } } - // Fallback: match by normalized name/cognome + // Fallback: match by normalized name/cognome or ragione_sociale $nome = trim((string) ($data['nome'] ?? '')); $cognome = trim((string) ($data['cognome'] ?? '')); - if ($nome !== '' || $cognome !== '') { + $ragione = trim((string) ($data['ragione_sociale'] ?? '')); + if ($nome !== '' || $cognome !== '' || $ragione !== '') { $normalizedKey = $this->normalizeNameKey($nome, $cognome); - if ($normalizedKey !== '') { - // Find all active records for this administrator - $candidates = RubricaUniversale::query() - ->where('amministratore_id', $amministratoreId) - ->get(); - foreach ($candidates as $cand) { - if ($this->normalizeNameKey($cand->nome, $cand->cognome) === $normalizedKey) { - return ['record' => $cand, 'match_type' => 'name']; + $incomingFullKey = $this->normalizeIdentityKey($nome . ' ' . $cognome . ' ' . $ragione); + + // Find all active records for this administrator + $candidates = RubricaUniversale::query() + ->where('amministratore_id', $amministratoreId) + ->get(); + foreach ($candidates as $cand) { + if ($normalizedKey !== '' && $this->normalizeNameKey($cand->nome, $cand->cognome) === $normalizedKey) { + return ['record' => $cand, 'match_type' => 'name']; + } + if ($incomingFullKey !== null) { + $candFullKey = $this->normalizeIdentityKey(($cand->nome ?? '') . ' ' . ($cand->cognome ?? '') . ' ' . ($cand->ragione_sociale ?? '')); + if ($candFullKey === $incomingFullKey) { + return ['record' => $cand, 'match_type' => 'name_full']; } } + } - // Try orphan candidates - $orphans = RubricaUniversale::query() - ->whereNull('amministratore_id') - ->get(); - foreach ($orphans as $orphan) { - if ($this->normalizeNameKey($orphan->nome, $orphan->cognome) === $normalizedKey) { - return ['record' => $orphan, 'match_type' => 'name']; + // Try orphan candidates + $orphans = RubricaUniversale::query() + ->whereNull('amministratore_id') + ->get(); + foreach ($orphans as $orphan) { + if ($normalizedKey !== '' && $this->normalizeNameKey($orphan->nome, $orphan->cognome) === $normalizedKey) { + return ['record' => $orphan, 'match_type' => 'name']; + } + if ($incomingFullKey !== null) { + $orphanFullKey = $this->normalizeIdentityKey(($orphan->nome ?? '') . ' ' . ($orphan->cognome ?? '') . ' ' . ($orphan->ragione_sociale ?? '')); + if ($orphanFullKey === $incomingFullKey) { + return ['record' => $orphan, 'match_type' => 'name_full']; } } } } + return ['record' => null, 'match_type' => 'none']; } diff --git a/app/Console/Commands/GesconMaterializePersoneRelazioniCommand.php b/app/Console/Commands/GesconMaterializePersoneRelazioniCommand.php index 41bb084..09cb242 100755 --- a/app/Console/Commands/GesconMaterializePersoneRelazioniCommand.php +++ b/app/Console/Commands/GesconMaterializePersoneRelazioniCommand.php @@ -132,20 +132,40 @@ public function handle(): int $stats['persona_created']++; } + $legacyPayload = $this->decodeLegacyPayload($row->legacy_payload ?? null); + $cia = isset($legacyPayload['cia']) ? trim((string) $legacyPayload['cia']) : ''; + + if ($cia !== '') { + $ciaUpper = strtoupper($cia); + $riceveComunicazioni = str_contains($ciaUpper, 'I'); + $riceveConvocazioni = str_contains($ciaUpper, 'C'); + $votaAssemblea = str_contains($ciaUpper, 'A'); + } else { + $riceveComunicazioni = true; + $riceveConvocazioni = $ruoloRate === 'C'; + $votaAssemblea = $ruoloRate === 'C'; + } + $relationPayload = [ 'quota_relazione' => $this->normalizePercentValue($row->percentuale ?? null), 'data_inizio' => $this->normalizeDateToYmd($row->data_inizio ?? null) ?: '1900-01-01', 'data_fine' => $this->normalizeDateToYmd($row->data_fine ?? null), 'attivo' => $this->isRelationActive($row->data_fine ?? null), - 'riceve_comunicazioni' => true, - 'riceve_convocazioni' => $ruoloRate === 'C', - 'vota_assemblea' => $ruoloRate === 'C', + 'riceve_comunicazioni' => $riceveComunicazioni, + 'riceve_convocazioni' => $riceveConvocazioni, + 'vota_assemblea' => $votaAssemblea, 'note_relazione' => $this->buildRelationNote($row, $tipoRelazione), ]; if (Schema::hasColumn('persone_unita_relazioni', 'ruolo_rate')) { $relationPayload['ruolo_rate'] = $ruoloRate; } + if (Schema::hasColumn('persone_unita_relazioni', 'diritto_reale') && ! empty($legacyPayload['diritto_reale'])) { + $relationPayload['diritto_reale'] = $legacyPayload['diritto_reale']; + } + if (Schema::hasColumn('persone_unita_relazioni', 'descrizione_diritto') && ! empty($legacyPayload['diritto_label'])) { + $relationPayload['descrizione_diritto'] = $legacyPayload['diritto_label']; + } $existingRelationId = $this->findExistingRelationId( (int) $persona->id, @@ -161,7 +181,7 @@ public function handle(): int ->update($relationPayload + ['updated_at' => now()]); $stats['rel_updated']++; } else { - DB::table('persone_unita_relazioni')->insert([ + $insertData = [ 'persona_id' => (int) $persona->id, 'unita_id' => (int) $row->unita_immobiliare_id, 'tipo_relazione' => $tipoRelazione, @@ -173,10 +193,19 @@ public function handle(): int 'riceve_convocazioni' => $relationPayload['riceve_convocazioni'], 'vota_assemblea' => $relationPayload['vota_assemblea'], 'note_relazione' => $relationPayload['note_relazione'], - 'ruolo_rate' => $relationPayload['ruolo_rate'] ?? null, 'created_at' => now(), 'updated_at' => now(), - ]); + ]; + if (isset($relationPayload['ruolo_rate'])) { + $insertData['ruolo_rate'] = $relationPayload['ruolo_rate']; + } + if (isset($relationPayload['diritto_reale'])) { + $insertData['diritto_reale'] = $relationPayload['diritto_reale']; + } + if (isset($relationPayload['descrizione_diritto'])) { + $insertData['descrizione_diritto'] = $relationPayload['descrizione_diritto']; + } + DB::table('persone_unita_relazioni')->insert($insertData); $stats['rel_created']++; } } @@ -499,6 +528,20 @@ private function buildRelationNote(object $row, string $tipoRelazione): string 'tipo=' . $tipoRelazione, ]; + $legacyPayload = $this->decodeLegacyPayload($row->legacy_payload ?? null); + if (! empty($legacyPayload['diritto_reale'])) { + $parts[] = 'diritto_reale=' . trim((string) $legacyPayload['diritto_reale']); + } + if (! empty($legacyPayload['diritto_label'])) { + $parts[] = 'diritto_label=' . trim((string) $legacyPayload['diritto_label']); + } + if (! empty($legacyPayload['cumulo_cond'])) { + $parts[] = 'cumulo_cond=' . trim((string) $legacyPayload['cumulo_cond']); + } + if (! empty($legacyPayload['cumulo_inq'])) { + $parts[] = 'cumulo_inq=' . trim((string) $legacyPayload['cumulo_inq']); + } + return implode(' | ', array_filter($parts)); } diff --git a/app/Console/Commands/ImportGesconFullPipeline.php b/app/Console/Commands/ImportGesconFullPipeline.php index f09bd12..ff8c511 100755 --- a/app/Console/Commands/ImportGesconFullPipeline.php +++ b/app/Console/Commands/ImportGesconFullPipeline.php @@ -24,7 +24,7 @@ class ImportGesconFullPipeline extends Command {--dry-run : Simula senza scrivere sulle tabelle dominio} {--limit= : Limita numero record per debug} {--reset-temp : Svuota staging prima di importare} - {--solo= : Step singolo: assemblee|unita|relazioni|soggetti|diritti|millesimi|voci|operazioni|rate|incassi|giroconti|straord|addebiti|detrazioni|tutto} + {--solo= : Step singolo: assemblee|unita|relazioni|soggetti|diritti|millesimi|voci|operazioni|rate|incassi|giroconti|straord|addebiti|detrazioni|ratePreventivo|acqua|tutto} {--no-views : Non ricrea le viste QA} {--force-mapping : Applica mapping anche sovrascrivendo valori esistenti} {--fill-missing-millesimi : Crea righe mancanti a zero per ogni unita/tabella (uso eccezionale)} @@ -53,6 +53,8 @@ class ImportGesconFullPipeline extends Command 'addebiti', 'detrazioni', 'affitti', + 'ratePreventivo', + 'acqua', ]; private array $legacyYearMap = []; @@ -90,6 +92,14 @@ private function scopeCondominQuery($query) private function extractStableLegacyCondId(mixed $row, ?string $fallback = null): ?string { + if ($this->option('stabile') === '0021') { + $codCond = is_array($row) ? ($row['cod_cond'] ?? null) : ($row->cod_cond ?? null); + $codCond = trim((string) $codCond); + if ($codCond !== '') { + return $codCond; + } + } + foreach (['legacy_id_cond', 'id_cond', 'legacy_cond_id'] as $field) { $value = is_array($row) ? ($row[$field] ?? null) : ($row->{$field} ?? null); $value = trim((string) $value); @@ -273,6 +283,34 @@ private function resolveLatestStagingLegacyYear(string $table, ?string $stabile private function currentSnapshotCondominQuery() { + if ($this->option('stabile') === '0021') { + return DB::connection('gescon_import') + ->table('condomin as c1') + ->join(DB::raw("( + SELECT scala, interno, NULL as cod_cond, MAX(legacy_year) as max_year + FROM condomin + WHERE cod_stabile = '0021' AND NOT (scala = 'A' AND interno = '8') + GROUP BY scala, interno + + UNION ALL + + SELECT scala, interno, cod_cond, MAX(legacy_year) as max_year + FROM condomin + WHERE cod_stabile = '0021' AND (scala = 'A' AND interno = '8') + GROUP BY scala, interno, cod_cond + ) as c2"), function($join) { + $join->on('c1.scala', '=', 'c2.scala') + ->on('c1.interno', '=', 'c2.interno') + ->on('c1.legacy_year', '=', 'c2.max_year') + ->where(function($q) { + $q->whereNull('c2.cod_cond') + ->orWhereColumn('c1.cod_cond', '=', 'c2.cod_cond'); + }); + }) + ->select('c1.*') + ->where('c1.cod_stabile', '0021'); + } + $query = DB::connection('gescon_import')->table('condomin'); if ($this->option('stabile')) { $query->where('cod_stabile', $this->option('stabile')); @@ -397,6 +435,50 @@ public function handle(): int $this->purgeAuthoritativeLegacyReplica($steps); } + if ($this->option('stabile') === '0021' && ! $dry && in_array('unita', $steps, true)) { + $this->line('⚠️ DIRETTIVA DI LIVELLO 3: Azzeramento dati Stabile 0021'); + $stabileId = 4; + $unitaIds = DB::table('unita_immobiliari')->where('stabile_id', $stabileId)->pluck('id')->toArray(); + + if (!empty($unitaIds)) { + if (Schema::hasTable('unita_anagrafica_periodo')) { + DB::table('unita_anagrafica_periodo')->whereIn('unita_immobiliare_id', $unitaIds)->delete(); + } + if (Schema::hasTable('persone_unita_relazioni')) { + DB::table('persone_unita_relazioni')->whereIn('unita_id', $unitaIds)->delete(); + } + if (Schema::hasTable('proprieta')) { + DB::table('proprieta')->whereIn('unita_immobiliare_id', $unitaIds)->delete(); + } + if (Schema::hasTable('diritti_reali')) { + DB::table('diritti_reali')->whereIn('unita_immobiliare_id', $unitaIds)->delete(); + } + if (Schema::hasTable('unita_immobiliare_nominativi')) { + DB::table('unita_immobiliare_nominativi')->whereIn('unita_immobiliare_id', $unitaIds)->delete(); + } + if (Schema::hasTable('addebiti_personalizzati')) { + DB::table('addebiti_personalizzati')->whereIn('unita_immobiliare_id', $unitaIds)->delete(); + } + if (Schema::hasTable('letture_contatori')) { + $contatoreIds = DB::table('contatori')->whereIn('unita_immobiliare_id', $unitaIds)->pluck('id')->toArray(); + if (!empty($contatoreIds)) { + DB::table('letture_contatori')->whereIn('contatore_id', $contatoreIds)->delete(); + } + } + if (Schema::hasTable('contatori')) { + DB::table('contatori')->whereIn('unita_immobiliare_id', $unitaIds)->delete(); + } + if (Schema::hasTable('rate_scadenze_preventivo')) { + DB::table('rate_scadenze_preventivo')->whereIn('unita_immobiliare_id', $unitaIds)->delete(); + } + DB::table('unita_immobiliari')->where('stabile_id', $stabileId)->delete(); + } + if (Schema::hasTable('anagrafiche')) { + DB::table('anagrafiche')->where('codice_univoco', 'like', 'S0021%')->delete(); + } + $this->info('✔️ Svuotamento Livello 3 completato.'); + } + foreach ($steps as $step) { $method = 'step' . ucfirst($step); if (! method_exists($this, $method)) { @@ -749,10 +831,39 @@ private function stepUnita(?int $limit): int $processed = 0; $batch = $limit ? min(max(1, $limit), 1000) : 1000; $offset = 0; + $isStabile0021 = ($this->option('stabile') === '0021'); while (true) { - $q = $this->currentSnapshotCondominQuery(); - $q->limit($batch)->offset($offset); - $rows = $q->get(); + if ($isStabile0021) { + if ($offset > 0) { + break; + } + $sql = " + SELECT c1.* + FROM condomin c1 + JOIN ( + SELECT scala, interno, NULL as cod_cond, MAX(legacy_year) as max_year + FROM condomin + WHERE cod_stabile = '0021' AND NOT (scala = 'A' AND interno = '8') + GROUP BY scala, interno + + UNION ALL + + SELECT scala, interno, cod_cond, MAX(legacy_year) as max_year + FROM condomin + WHERE cod_stabile = '0021' AND (scala = 'A' AND interno = '8') + GROUP BY scala, interno, cod_cond + ) c2 ON c1.scala = c2.scala + AND c1.interno = c2.interno + AND c1.legacy_year = c2.max_year + AND (c2.cod_cond IS NULL OR c1.cod_cond = c2.cod_cond) + WHERE c1.cod_stabile = '0021' + "; + $rows = collect(DB::connection('gescon_import')->select($sql)); + } else { + $q = $this->currentSnapshotCondominQuery(); + $q->limit($batch)->offset($offset); + $rows = $q->get(); + } if ($rows->isEmpty()) { break; } @@ -976,7 +1087,7 @@ private function stepUnita(?int $limit): int if ($normalizedInterno !== null && Schema::hasColumn('unita_immobiliari', 'interno') && (string) ($exists->interno ?? '') !== $normalizedInterno) { $patch['interno'] = $normalizedInterno; } - if (Schema::hasColumn('unita_immobiliari', 'subalterno') && (string) ($exists->subalterno ?? '') !== (string) ($sub ?? '')) { + if (Schema::hasColumn('unita_immobiliari', 'subalterno') && empty($exists->subalterno) && (string) ($exists->subalterno ?? '') !== (string) ($sub ?? '')) { $patch['subalterno'] = $sub; } if (Schema::hasColumn('unita_immobiliari', 'piano') && (int) ($exists->piano ?? 0) !== $pianoVal) { @@ -998,7 +1109,7 @@ private function stepUnita(?int $limit): int $r->categoria_catastale ?? null, $unitClassification['categoria_catastale'] ?? null, ], 20)); - if ($categoriaCatastale !== null && (string) ($exists->categoria_catastale ?? '') !== $categoriaCatastale) { + if (empty($exists->categoria_catastale) && $categoriaCatastale !== null && (string) ($exists->categoria_catastale ?? '') !== $categoriaCatastale) { $patch['categoria_catastale'] = $categoriaCatastale; } } @@ -1375,26 +1486,30 @@ private function stepSoggetti(?int $limit): int $useDbTrigger = (bool) env('GESCON_USE_DB_TRIGGER_CODES', false); foreach ($parties as $party) { - $exists = $this->findExistingSoggetto($party['cf'], $party['email'], $party['nome'], $party['cognome']); + $exists = $this->findExistingSoggetto($party['cf'], $party['email'], $party['nome'], $party['cognome'], $party['telefono']); if ($exists) { $patch = []; - foreach ( - [ - 'nome' => $party['nome'], - 'cognome' => $party['cognome'], - 'codice_fiscale' => $party['cf'], - 'email' => $party['email'], - 'telefono' => $party['telefono'], - 'indirizzo' => $party['indirizzo'], - ] as $k => $v - ) { - if (! empty($v) && (empty($exists->$k) || $exists->$k === 'ND')) { - $patch[$k] = $v; - } + if (! empty($party['nome']) && empty($exists->nome)) { + $patch['nome'] = $party['nome']; + } + if (! empty($party['cognome']) && empty($exists->cognome)) { + $patch['cognome'] = $party['cognome']; + } + if (! empty($party['cf']) && empty($exists->codice_fiscale)) { + $patch['codice_fiscale'] = $party['cf']; + } + if (! empty($party['email']) && ($exists->email !== $party['email'])) { + $patch['email'] = $party['email']; + } + if (! empty($party['telefono']) && ($exists->telefono !== $party['telefono'])) { + $patch['telefono'] = $party['telefono']; + } + if (! empty($party['indirizzo']) && empty($exists->indirizzo)) { + $patch['indirizzo'] = $party['indirizzo']; } if (! empty($patch)) { $patch['updated_at'] = now(); - DB::table('soggetti')->where('id', $exists->id)->update($patch); + DB::table('anagrafiche')->where('id', $exists->id)->update($patch); $updated++; } continue; @@ -1414,7 +1529,7 @@ private function stepSoggetti(?int $limit): int 'citta' => null, 'provincia' => null, 'tipo' => $party['tipo'], - 'attivo' => Schema::hasColumn('soggetti', 'attivo') ? true : null, + 'attivo' => Schema::hasColumn('anagrafiche', 'attivo') ? true : null, 'created_at' => now(), 'updated_at' => now(), ]; @@ -1422,20 +1537,20 @@ private function stepSoggetti(?int $limit): int $baseKey = $this->normalizeNameKey($party['nome'], $party['cognome']) ?: ($party['email'] ?: ($party['cf'] ?: 'ND')); $code = $this->generateStableCondKey('S', $r->cod_stabile ?? 'S0', $baseKey); $tries = 0; - while (Schema::hasTable('soggetti') && DB::table('soggetti')->where('codice_univoco', $code)->exists() && $tries < 3) { + while (Schema::hasTable('anagrafiche') && DB::table('anagrafiche')->where('codice_univoco', $code)->exists() && $tries < 3) { $salt = ($r->cod_cond ?? '') . '-' . $party['suffix'] . '-' . ($tries + 1); $code = $this->generateStableCondKey('S', $r->cod_stabile ?? 'S0', $baseKey . '|' . $salt); $tries++; } $data['codice_univoco'] = $code; } - if (Schema::hasTable('soggetti') && isset($data['codice_univoco'])) { - $ex = DB::table('soggetti')->where('codice_univoco', $data['codice_univoco'])->first(); + if (Schema::hasTable('anagrafiche') && isset($data['codice_univoco'])) { + $ex = DB::table('anagrafiche')->where('codice_univoco', $data['codice_univoco'])->first(); if ($ex) { continue; } } - $this->dynamicInsert('soggetti', $data); + $this->dynamicInsert('anagrafiche', $data); $count++; } @@ -1458,7 +1573,7 @@ private function stepDiritti(?int $limit): int return 0; } - if (! Schema::hasTable('proprieta') || ! Schema::hasTable('unita_immobiliari') || ! Schema::hasTable('soggetti')) { + if (! Schema::hasTable('proprieta') || ! Schema::hasTable('unita_immobiliari') || ! Schema::hasTable('anagrafiche')) { return 0; } @@ -1557,7 +1672,30 @@ private function stepDiritti(?int $limit): int } foreach ($tasks as $task) { - $sog = $this->findExistingSoggetto($task['cf'], $task['email'], $task['nome'], $task['cognome']); + $sog = $this->findExistingSoggetto($task['cf'], $task['email'], $task['nome'], $task['cognome'], $task['telefono']); + if ($sog) { + $patch = []; + if (! empty($task['nome']) && empty($sog->nome)) { + $patch['nome'] = $task['nome']; + } + if (! empty($task['cognome']) && empty($sog->cognome)) { + $patch['cognome'] = $task['cognome']; + } + if (! empty($task['cf']) && empty($sog->codice_fiscale)) { + $patch['codice_fiscale'] = $task['cf']; + } + if (! empty($task['email']) && ($sog->email !== $task['email'])) { + $patch['email'] = $task['email']; + } + if (! empty($task['telefono']) && ($sog->telefono !== $task['telefono'])) { + $patch['telefono'] = $task['telefono']; + } + if (! empty($patch)) { + $patch['updated_at'] = now(); + DB::table('anagrafiche')->where('id', $sog->id)->update($patch); + $sog = DB::table('anagrafiche')->where('id', $sog->id)->first(); + } + } if ($sog && $task['tipo'] === 'locazione' && ! empty($task['nome'])) { $matchedName = trim((string) (($sog->cognome ?? '') . ' ' . ($sog->nome ?? ''))); if ($matchedName === '' && ! empty($sog->ragione_sociale)) { @@ -1567,7 +1705,7 @@ private function stepDiritti(?int $limit): int $tenantName = trim((string) (($task['cognome'] ?? '') . ' ' . ($task['nome'] ?? ''))); $sameUnitOwner = DB::table('proprieta') ->where('unita_immobiliare_id', $unita->id) - ->where('soggetto_id', $sog->id) + ->where('anagrafica_id', $sog->id) ->where('tipo_diritto', 'proprieta') ->exists(); @@ -1586,12 +1724,12 @@ private function stepDiritti(?int $limit): int 'created_at' => now(), 'updated_at' => now(), ]; - if (Schema::hasColumn('soggetti', 'codice_univoco')) { + if (Schema::hasColumn('anagrafiche', 'codice_univoco')) { $baseKey = $this->normalizeNameKey($payload['nome'] ?? '', $payload['cognome'] ?? '') ?: (($payload['codice_fiscale'] ?? null) ?: (($payload['email'] ?? null) ?: 'ND')); $code = $this->generateStableCondKey('S', $r->cod_stabile ?? 'S0', $baseKey); $tries = 0; - while (DB::table('soggetti')->where('codice_univoco', $code)->exists() && $tries < 5) { + while (DB::table('anagrafiche')->where('codice_univoco', $code)->exists() && $tries < 5) { $salt = ($r->cod_cond ?? '') . '-' . ($task['legacy_suffix'] ?? 'rel') . '-' . ($tries + 1); $code = $this->generateStableCondKey('S', $r->cod_stabile ?? 'S0', $baseKey . '|' . $salt); $tries++; @@ -1599,14 +1737,14 @@ private function stepDiritti(?int $limit): int $payload['codice_univoco'] = $code; } if (isset($payload['codice_univoco'])) { - $existingByCode = DB::table('soggetti')->where('codice_univoco', $payload['codice_univoco'])->first(); + $existingByCode = DB::table('anagrafiche')->where('codice_univoco', $payload['codice_univoco'])->first(); if ($existingByCode) { $sog = $existingByCode; } } if (! $sog) { - $sid = DB::table('soggetti')->insertGetId($payload); - $sog = DB::table('soggetti')->where('id', $sid)->first(); + $sid = DB::table('anagrafiche')->insertGetId($payload); + $sog = DB::table('anagrafiche')->where('id', $sid)->first(); } } @@ -1615,14 +1753,45 @@ private function stepDiritti(?int $limit): int continue; } + if (Schema::hasTable('unita_anagrafica_periodo') && $sog) { + $dInizio = $task['data_inizio'] ?: '1970-01-01'; + $dFine = $task['data_fine'] ?: null; + + $ruolo = $task['soggetto_tipo'] === 'proprietario' ? 'condomino' : 'inquilino'; + + $existsPeriodo = DB::table('unita_anagrafica_periodo') + ->where('unita_immobiliare_id', $unita->id) + ->where('anagrafica_id', $sog->id) + ->where('data_inizio', $dInizio) + ->first(); + + if (!$existsPeriodo) { + DB::table('unita_anagrafica_periodo')->insert([ + 'unita_immobiliare_id' => $unita->id, + 'anagrafica_id' => $sog->id, + 'ruolo_occupazione' => $ruolo, + 'data_inizio' => $dInizio, + 'data_fine' => $dFine, + 'percentuale_spesa' => $task['percentuale_possesso'] !== null ? $task['percentuale_possesso'] : 100.00, + 'legacy_metadata' => json_encode([ + 'cod_cond' => $r->cod_cond ?? null, + 'legacy_year' => $r->legacy_year ?? null, + 'legacy_id' => $r->id ?? null + ]), + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + } + $tipo = (string) $task['tipo']; if ($tipo === 'locazione') { $legacyWrongLocazione = DB::table('proprieta') ->where('unita_immobiliare_id', $unita->id) ->where('tipo_diritto', 'locazione') - ->where('soggetto_id', '!=', $sog->id) - ->whereIn('soggetto_id', function ($query) use ($unita) { - $query->select('soggetto_id') + ->where('anagrafica_id', '!=', $sog->id) + ->whereIn('anagrafica_id', function ($query) use ($unita) { + $query->select('anagrafica_id') ->from('proprieta') ->where('unita_immobiliare_id', $unita->id) ->where('tipo_diritto', 'proprieta'); @@ -1644,7 +1813,7 @@ private function stepDiritti(?int $limit): int $exists = DB::table('proprieta') ->where('unita_immobiliare_id', $unita->id) - ->where('soggetto_id', $sog->id) + ->where('anagrafica_id', $sog->id) ->where('tipo_diritto', $tipo) ->first(); if ($exists) { @@ -1671,7 +1840,7 @@ private function stepDiritti(?int $limit): int } else { DB::table('proprieta')->insert([ 'unita_immobiliare_id' => $unita->id, - 'soggetto_id' => $sog->id, + 'anagrafica_id' => $sog->id, 'tipo_diritto' => $tipo, 'percentuale_possesso' => $tipo === 'proprieta' ? $task['percentuale_possesso'] : null, 'percentuale_detrazione' => ($tipo === 'proprieta' && Schema::hasColumn('proprieta', 'percentuale_detrazione')) ? $task['percentuale_detrazione'] : null, @@ -1748,6 +1917,26 @@ private function stepDiritti(?int $limit): int } $sog = $this->findExistingSoggetto($cf, $email, $nome, $cognome); + if ($sog) { + $patch = []; + if (! empty($nome) && empty($sog->nome)) { + $patch['nome'] = $nome; + } + if (! empty($cognome) && empty($sog->cognome)) { + $patch['cognome'] = $cognome; + } + if (! empty($cf) && empty($sog->codice_fiscale)) { + $patch['codice_fiscale'] = $cf; + } + if (! empty($email) && ($sog->email !== $email)) { + $patch['email'] = $email; + } + if (! empty($patch)) { + $patch['updated_at'] = now(); + DB::table('anagrafiche')->where('id', $sog->id)->update($patch); + $sog = DB::table('anagrafiche')->where('id', $sog->id)->first(); + } + } if (! $sog) { $payload = [ 'nome' => $nome, @@ -1759,11 +1948,11 @@ private function stepDiritti(?int $limit): int 'updated_at' => now(), ]; - if (Schema::hasColumn('soggetti', 'codice_univoco')) { + if (Schema::hasColumn('anagrafiche', 'codice_univoco')) { $baseKey = $this->normalizeNameKey($payload['nome'] ?? '', $payload['cognome'] ?? '') ?: (($payload['email'] ?? null) ?: 'ND'); $code = $this->generateStableCondKey('S', $cr->cod_stabile ?? 'S0', $baseKey); $tries = 0; - while (DB::table('soggetti')->where('codice_univoco', $code)->exists() && $tries < 5) { + while (DB::table('anagrafiche')->where('codice_univoco', $code)->exists() && $tries < 5) { $salt = ($legacyCond ?? '') . '-' . ($tries + 1); $code = $this->generateStableCondKey('S', $cr->cod_stabile ?? 'S0', $baseKey . '|' . $salt); $tries++; @@ -1772,15 +1961,15 @@ private function stepDiritti(?int $limit): int } if (isset($payload['codice_univoco'])) { - $existingByCode = DB::table('soggetti')->where('codice_univoco', $payload['codice_univoco'])->first(); + $existingByCode = DB::table('anagrafiche')->where('codice_univoco', $payload['codice_univoco'])->first(); if ($existingByCode) { $sog = $existingByCode; } } if (! $sog) { - $sid = DB::table('soggetti')->insertGetId($payload); - $sog = DB::table('soggetti')->where('id', $sid)->first(); + $sid = DB::table('anagrafiche')->insertGetId($payload); + $sog = DB::table('anagrafiche')->where('id', $sid)->first(); } } @@ -1793,9 +1982,38 @@ private function stepDiritti(?int $limit): int ? (float) $cr->perc_diritto_reale : null; + if (Schema::hasTable('unita_anagrafica_periodo') && $sog) { + $dInizio = '1970-01-01'; + $dFine = null; + + $existsPeriodo = DB::table('unita_anagrafica_periodo') + ->where('unita_immobiliare_id', $unita->id) + ->where('anagrafica_id', $sog->id) + ->where('data_inizio', $dInizio) + ->first(); + + if (!$existsPeriodo) { + DB::table('unita_anagrafica_periodo')->insert([ + 'unita_immobiliare_id' => $unita->id, + 'anagrafica_id' => $sog->id, + 'ruolo_occupazione' => 'comproprietario', + 'data_inizio' => $dInizio, + 'data_fine' => $dFine, + 'percentuale_spesa' => $percentuale !== null ? $percentuale : 100.00, + 'legacy_metadata' => json_encode([ + 'cod_cond' => $legacyCond, + 'legacy_year' => $cr->legacy_year ?? null, + 'legacy_id' => $cr->id ?? null + ]), + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + } + $exists = DB::table('proprieta') ->where('unita_immobiliare_id', $unita->id) - ->where('soggetto_id', $sog->id) + ->where('anagrafica_id', $sog->id) ->first(); if ($exists) { @@ -1816,7 +2034,7 @@ private function stepDiritti(?int $limit): int } else { DB::table('proprieta')->insert([ 'unita_immobiliare_id' => $unita->id, - 'soggetto_id' => $sog->id, + 'anagrafica_id' => $sog->id, 'tipo_diritto' => 'proprieta', 'percentuale_possesso' => $percentuale, 'data_inizio' => null, @@ -1862,7 +2080,11 @@ private function stepMillesimi(?int $limit): int $q->where('d.legacy_year', $requestedLegacyYear); } if (Schema::connection('gescon_import')->hasTable('condomin')) { - $q = $q->join('condomin as c', 'c.cod_cond', '=', 'd.id_cond'); + $q = $q->join('condomin as c', function ($join) { + $join->on('c.id_cond', '=', 'd.id_cond') + ->on('c.cod_stabile', '=', 'd.cod_stabile') + ->on('c.legacy_year', '=', 'd.legacy_year'); + }); if ($this->option('stabile')) { $q->where('c.cod_stabile', $this->option('stabile')); } @@ -1895,7 +2117,11 @@ private function stepMillesimi(?int $limit): int $q->where('d.cod_stabile', $this->option('stabile')); } if (Schema::connection('gescon_import')->hasTable('condomin')) { - $q = $q->join('condomin as c', 'c.cod_cond', '=', 'd.id_cond'); + $q = $q->join('condomin as c', function ($join) { + $join->on('c.id_cond', '=', 'd.id_cond') + ->on('c.cod_stabile', '=', 'd.cod_stabile') + ->on('c.legacy_year', '=', 'd.legacy_year'); + }); if ($this->option('stabile')) { $q->where('c.cod_stabile', $this->option('stabile')); } @@ -1933,7 +2159,11 @@ private function stepMillesimi(?int $limit): int $nq->where('d.cod_stabile', $this->option('stabile')); } if (Schema::connection('gescon_import')->hasTable('condomin')) { - $nq = $nq->join('condomin as c', 'c.cod_cond', '=', 'd.id_cond'); + $nq = $nq->join('condomin as c', function ($join) { + $join->on('c.id_cond', '=', 'd.id_cond') + ->on('c.cod_stabile', '=', 'd.cod_stabile') + ->on('c.legacy_year', '=', 'd.legacy_year'); + }); if ($this->option('stabile')) { $nq->where('c.cod_stabile', $this->option('stabile')); } @@ -2036,6 +2266,9 @@ private function stepMillesimi(?int $limit): int $totaleMillesimi = 1000.0; if (isset($h->totale_millesimi) && is_numeric($h->totale_millesimi)) { $tm = (float) $h->totale_millesimi; + if ($tm > 9999) { + $tm = $tm / 10000.0; + } if ($tm <= 999999.9999) { $totaleMillesimi = $tm; } @@ -2043,8 +2276,14 @@ private function stepMillesimi(?int $limit): int if ($totaleMillesimi <= 0) { $mmKey = $code . '|' . (string) ($legacyYear ?? ''); $mmFallback = $totMmMap[$mmKey] ?? null; - if (is_numeric($mmFallback) && $mmFallback > 0 && $mmFallback <= 999999.9999) { - $totaleMillesimi = (float) $mmFallback; + if (is_numeric($mmFallback) && $mmFallback > 0) { + $fVal = (float) $mmFallback; + if ($fVal > 9999) { + $fVal = $fVal / 10000.0; + } + if ($fVal <= 999999.9999) { + $totaleMillesimi = $fVal; + } } } @@ -2123,13 +2362,18 @@ private function stepMillesimi(?int $limit): int $src->where('d.cod_stabile', $this->option('stabile')); } if (Schema::connection('gescon_import')->hasTable('condomin')) { - $src = $src->join('condomin as c', 'c.cod_cond', '=', 'd.id_cond'); + $src = $src->join('condomin as c', function ($join) { + $join->on('c.id_cond', '=', 'd.id_cond') + ->on('c.cod_stabile', '=', 'd.cod_stabile') + ->on('c.legacy_year', '=', 'd.legacy_year'); + }); if ($this->option('stabile')) { $src->where('c.cod_stabile', $this->option('stabile')); } if ($requestedLegacyYear !== null && Schema::connection('gescon_import')->hasColumn('condomin', 'legacy_year')) { $src->where('c.legacy_year', $requestedLegacyYear); } + $src->select('d.*', 'c.cod_cond'); } if ($limit) { $src->limit($limit); @@ -2244,10 +2488,20 @@ private function stepMillesimi(?int $limit): int continue; } - // Best-effort: risolvi unita_immobiliare per legacy_cond_id (id_cond), fallback scala + interno + // Best-effort: risolvi unita_immobiliare per legacy_cond_id (cod_cond), fallback scala + interno $unitaId = null; if (Schema::hasTable('unita_immobiliari')) { - if (Schema::hasColumn('unita_immobiliari', 'legacy_cond_id') && isset($r->id_cond)) { + $codCond = isset($r->cod_cond) ? trim((string) $r->cod_cond) : null; + if ($codCond !== null && $codCond !== '') { + $u = DB::table('unita_immobiliari') + ->where('stabile_id', $targetStabileId) + ->whereNull('deleted_at') + ->where('legacy_cond_id', $codCond) + ->first(); + $unitaId = $u->id ?? null; + } + + if (! $unitaId && isset($r->id_cond)) { $legacyCond = trim((string) $r->id_cond); if ($legacyCond !== '') { $u = DB::table('unita_immobiliari') @@ -2530,6 +2784,75 @@ private function stepMillesimi(?int $limit): int } } } + // Algoritmo di quadratura ad aggancio ereditario per le tabelle millesimali dello stabile + $tabelleStabile = DB::table('tabelle_millesimali') + ->where('stabile_id', $this->stabileId) + ->get(); + + foreach ($tabelleStabile as $tab) { + $sommaCorrente = DB::table('dettaglio_millesimi') + ->where('tabella_millesimale_id', $tab->id) + ->sum('millesimi'); + + $target = (float) $tab->totale_millesimi; + if (abs($sommaCorrente - $target) > 0.01) { + // Non quadra. Cerchiamo una tabella gemella (stesso codice_tabella) che invece quadra + $gemellaQuadrata = null; + foreach ($tabelleStabile as $t2) { + if ($t2->id === $tab->id || strtoupper(trim($t2->codice_tabella)) !== strtoupper(trim($tab->codice_tabella))) { + continue; + } + $sommaGemella = DB::table('dettaglio_millesimi') + ->where('tabella_millesimale_id', $t2->id) + ->sum('millesimi'); + if (abs($sommaGemella - (float)$t2->totale_millesimi) <= 0.01 && $sommaGemella > 0) { + $gemellaQuadrata = $t2; + break; + } + } + + if ($gemellaQuadrata) { + $this->line(" ⚠️ Tabella [{$tab->codice_tabella}] anno " . ($tab->anno_gestione ?? 'N/D') . " non quadrata (somma: $sommaCorrente, atteso: $target)."); + $this->line(" -> Trovata gemella quadrata per l'anno " . ($gemellaQuadrata->anno_gestione ?? 'N/D') . ". Eredito i millesimi..."); + + $dettagliGemella = DB::table('dettaglio_millesimi') + ->where('tabella_millesimale_id', $gemellaQuadrata->id) + ->get(); + + foreach ($dettagliGemella as $dg) { + $exists = DB::table('dettaglio_millesimi') + ->where('tabella_millesimale_id', $tab->id) + ->where('unita_immobiliare_id', $dg->unita_immobiliare_id) + ->first(); + + if ($exists) { + DB::table('dettaglio_millesimi') + ->where('id', $exists->id) + ->update([ + 'millesimi' => $dg->millesimi, + 'partecipa' => $dg->partecipa ?? 1, + 'nord' => $dg->nord ?? null, + 'ruolo_legacy' => $dg->ruolo_legacy ?? null, + 'updated_at' => now(), + ]); + } else { + DB::table('dettaglio_millesimi')->insert([ + 'tabella_millesimale_id' => $tab->id, + 'unita_immobiliare_id' => $dg->unita_immobiliare_id, + 'millesimi' => $dg->millesimi, + 'partecipa' => $dg->partecipa ?? 1, + 'nord' => $dg->nord ?? null, + 'ruolo_legacy' => $dg->ruolo_legacy ?? null, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + } + $detailsUpdated++; + } + } + } + if ($canImporti && ($importiInserted + $importiUpdated) > 0) { $this->line(" → importi tabella: inseriti {$importiInserted}, aggiornati {$importiUpdated}, saltati {$importiSkipped}"); } @@ -2659,7 +2982,7 @@ private function stepVoci(?int $limit): int : null; $codiceDominio = $tipoLegacy === 'straordinaria' ? $this->buildScopedLegacyCode((string) $cod, $annoGestione, $numeroStraordinaria, 'VOCSPE') - : (string) $cod; + : (!empty($v->tabella) ? ($cod . '.' . strtoupper(trim((string)$v->tabella))) : (string)$cod); $exists = null; if (Schema::hasColumn('voci_spesa', 'codice')) { $q = DB::table('voci_spesa')->where('codice', $codiceDominio); @@ -3313,6 +3636,7 @@ private function stepOperazioni(?int $limit): int if (! empty($o->cod_spe)) { $voce = DB::table('voci_spesa') ->where('stabile_id', $this->stabileId) + ->where('gestione_contabile_id', $gestioneId) ->where(function ($q) use ($o): void { $q->where('legacy_codice', (string) $o->cod_spe) ->orWhere('codice', (string) $o->cod_spe); @@ -3341,6 +3665,7 @@ private function stepOperazioni(?int $limit): int if ($voceSpesaSnapshot === null && ! empty($o->cod_spe)) { $voce = DB::table('voci_spesa') ->where('stabile_id', $this->stabileId) + ->where('gestione_contabile_id', $gestioneId) ->where(function ($q) use ($o): void { $q->where('legacy_codice', (string) $o->cod_spe) ->orWhere('codice', (string) $o->cod_spe); @@ -4010,7 +4335,7 @@ private function resolveUnitaIdFromCodCond(string $codStabile, string $codCond, $sog = $this->findExistingSoggetto($cf, $email, $nome, $cognome); if ($sog && Schema::hasTable('proprieta')) { $row = DB::table('proprieta') - ->where('soggetto_id', $sog->id) + ->where('anagrafica_id', $sog->id) ->whereIn('tipo_diritto', ['proprieta', 'locazione']) ->orderByRaw("CASE WHEN tipo_diritto = 'proprieta' THEN 0 ELSE 1 END") ->orderByDesc('percentuale_possesso') @@ -4047,7 +4372,7 @@ private function resolveUnitaIdFromCodCond(string $codStabile, string $codCond, $existing = $uid ? (object) ['id' => $uid] : null; } - if ($existing && Schema::hasTable('soggetti')) { + if ($existing && Schema::hasTable('anagrafiche')) { if (! $sog) { $payload = [ 'old_id' => $this->safeOldId($codCond), @@ -4059,30 +4384,30 @@ private function resolveUnitaIdFromCodCond(string $codStabile, string $codCond, 'created_at' => now(), 'updated_at' => now(), ]; - if (Schema::hasColumn('soggetti', 'codice_univoco')) { + if (Schema::hasColumn('anagrafiche', 'codice_univoco')) { $baseKey = $this->normalizeNameKey($nome ?? '', $cognome ?? '') ?: ($email ?: 'ND'); $code = $this->generateStableCondKey('S', $codStabile ?: 'S0', $baseKey); $tries = 0; - while (DB::table('soggetti')->where('codice_univoco', $code)->exists() && $tries < 5) { + while (DB::table('anagrafiche')->where('codice_univoco', $code)->exists() && $tries < 5) { $salt = ($codCond ?? '') . '-' . ($tries + 1); $code = $this->generateStableCondKey('S', $codStabile ?: 'S0', $baseKey . '|' . $salt); $tries++; } $payload['codice_univoco'] = $code; } - $sid = DB::table('soggetti')->insertGetId($payload); + $sid = DB::table('anagrafiche')->insertGetId($payload); $sog = $sid ? (object) ['id' => $sid] : null; } if ($sog && Schema::hasTable('proprieta')) { $existsProp = DB::table('proprieta') ->where('unita_immobiliare_id', $existing->id) - ->where('soggetto_id', $sog->id) + ->where('anagrafica_id', $sog->id) ->first(['id']); if (! $existsProp) { DB::table('proprieta')->insert([ 'unita_immobiliare_id' => $existing->id, - 'soggetto_id' => $sog->id, + 'anagrafica_id' => $sog->id, 'tipo_diritto' => 'proprieta', 'created_at' => now(), 'updated_at' => now(), @@ -4129,7 +4454,7 @@ private function resolveUnitaIdFromCodCond(string $codStabile, string $codCond, $uid = DB::table('unita_immobiliari')->insertGetId($insertData); if ($uid) { - if (Schema::hasTable('soggetti')) { + if (Schema::hasTable('anagrafiche')) { $payload = [ 'old_id' => $this->safeOldId($codCond), 'nome' => 'Condomino', @@ -4138,21 +4463,21 @@ private function resolveUnitaIdFromCodCond(string $codStabile, string $codCond, 'created_at' => now(), 'updated_at' => now(), ]; - if (Schema::hasColumn('soggetti', 'codice_univoco')) { + if (Schema::hasColumn('anagrafiche', 'codice_univoco')) { $baseKey = 'COND-' . $codCond; $code = $this->generateStableCondKey('S', $codStabile ?: 'S0', $baseKey); $tries = 0; - while (DB::table('soggetti')->where('codice_univoco', $code)->exists() && $tries < 5) { + while (DB::table('anagrafiche')->where('codice_univoco', $code)->exists() && $tries < 5) { $code = $this->generateStableCondKey('S', $codStabile ?: 'S0', $baseKey . '|' . ($tries + 1)); $tries++; } $payload['codice_univoco'] = $code; } - $sid = DB::table('soggetti')->insertGetId($payload); + $sid = DB::table('anagrafiche')->insertGetId($payload); if ($sid && Schema::hasTable('proprieta')) { DB::table('proprieta')->insert([ 'unita_immobiliare_id' => $uid, - 'soggetto_id' => $sid, + 'anagrafica_id' => $sid, 'tipo_diritto' => 'proprieta', 'created_at' => now(), 'updated_at' => now(), @@ -4180,9 +4505,9 @@ private function resolveSoggettoResponsabileId(int $unitaId, string $tipoSoggett ->orderByRaw("CASE WHEN tipo_diritto = ? THEN 0 ELSE 1 END", [$prefer]) ->orderByDesc('percentuale_possesso') ->orderByDesc('id') - ->first(['soggetto_id']); + ->first(['anagrafica_id']); - return $row?->soggetto_id ? (int) $row->soggetto_id : null; + return $row?->anagrafica_id ? (int) $row->anagrafica_id : null; } private function ensurePianoRateizzazionePerEmissione(string $codStabile, int $numeroEmissione, $emissioni): ?int @@ -4701,21 +5026,7 @@ private function stepIncassiEstrattoConto(?int $limit): int private function normalizeLegacyInDaEcDate(mixed $value): ?string { - $raw = trim((string) ($value ?? '')); - if ($raw === '') { - return null; - } - - foreach (['Y-m-d', 'Y-m-d H:i:s', 'd/m/Y', 'd/m/y', 'm/d/Y', 'm/d/y'] as $format) { - $date = \DateTime::createFromFormat($format, $raw); - if ($date instanceof \DateTimeInterface) { - return $date->format('Y-m-d'); - } - } - - $timestamp = strtotime($raw); - - return $timestamp ? date('Y-m-d', $timestamp) : null; + return $this->parseY2KDate(is_string($value) || is_numeric($value) ? (string) $value : null); } private function composeLegacyInDaEcDescription(object $row): string @@ -4944,8 +5255,12 @@ private function stepAddebiti(?int $limit): int $src = DB::connection('gescon_import')->table('dett_pers as d'); if (Schema::connection('gescon_import')->hasTable('condomin')) { - $src = $src->leftJoin('condomin as c', 'c.cod_cond', '=', 'd.id_cond') - ->addSelect('d.*', 'c.cod_stabile', 'c.scala', 'c.interno', 'c.cod_cond'); + $src = $src->leftJoin('condomin as c', function ($join) { + $join->on('c.id_cond', '=', 'd.id_cond') + ->on('c.cod_stabile', '=', 'd.cod_stabile') + ->on('c.legacy_year', '=', 'd.legacy_year'); + }) + ->addSelect('d.*', 'c.cod_stabile', 'c.scala', 'c.interno', 'c.cod_cond'); if ($this->option('stabile')) { $src->where('c.cod_stabile', $this->option('stabile')); } @@ -5112,6 +5427,226 @@ private function stepAffitti(?int $limit): int return 1; } + private function stepRatePreventivo(?int $limit): int + { + $codStabile = $this->option('stabile') ?: ''; + $root = (string) ($this->option('path') ?: ''); + + $mdbFiles = []; + if ($codStabile !== '') { + $stabileDir = rtrim($root, '/') . '/' . $codStabile; + $mdbFiles = array_merge( + (array) glob($stabileDir . '/singolo_anno.mdb'), + (array) glob($stabileDir . '/*/singolo_anno.mdb'), + (array) glob($stabileDir . '/*/*/singolo_anno.mdb') + ); + } else { + $mdbFiles = array_merge( + (array) glob($root . '/singolo_anno.mdb'), + (array) glob($root . '/*/singolo_anno.mdb'), + (array) glob($root . '/*/*/singolo_anno.mdb') + ); + } + + $mdbFiles = array_values(array_filter(array_unique($mdbFiles))); + if (empty($mdbFiles)) { + $this->warn(" → Nessun file singolo_anno.mdb trovato per l'importazione delle rate di preventivo."); + return 0; + } + + $count = 0; + foreach ($mdbFiles as $mdbPath) { + if (! is_file($mdbPath) || ! is_readable($mdbPath)) { + continue; + } + + $dirname = basename(dirname($mdbPath)); + $year = (is_numeric($dirname) && strlen($dirname) === 4) ? $dirname : now()->format('Y'); + + $stabileCode = $codStabile ?: basename(dirname(dirname($mdbPath))); + if (! is_numeric($stabileCode) || strlen($stabileCode) < 3) { + $stabileCode = $codStabile ?: '0021'; + } + + $stabileId = $this->stabileId; + if (Schema::hasTable('stabili')) { + $stabileCol = Schema::hasColumn('stabili', 'codice_stabile') ? 'codice_stabile' : 'denominazione'; + $stRow = $this->findStabileRowByCodeForAdmin($stabileCol, $stabileCode, $this->resolveImportAmministratoreId()); + if ($stRow) { + $stabileId = $stRow->id; + } + } + + if (!$stabileId) { + continue; + } + + $this->line(" → Lettura tabella [rate] da {$mdbPath} per l'anno {$year} (stabile: {$stabileCode}, ID: {$stabileId})..."); + + $rows = $this->mdbExportToRows($mdbPath, ['rate', 'Rate', 'RATE']); + if (empty($rows)) { + $this->warn(" → Tabella [rate] vuota o non trovata in {$mdbPath}"); + continue; + } + + DB::table('rate_scadenze_preventivo') + ->where('anno', $year) + ->whereIn('unita_immobiliare_id', DB::table('unita_immobiliari')->where('stabile_id', $stabileId)->pluck('id')) + ->delete(); + + foreach ($rows as $row) { + $codCond = trim((string) ($row['id_condomino'] ?? $row['cod_cond'] ?? '')); + if ($codCond === '') { + continue; + } + + $unitaId = $this->resolveUnitaIdFromCodCond($stabileCode, $codCond, $year); + if (! $unitaId) { + continue; + } + + $ors = strtolower(trim((string) ($row['o_r_s'] ?? 'o'))); + if (! in_array($ors, ['o', 'r'], true)) { + continue; + } + $tipoGestione = ($ors === 'r') ? 'riscaldamento' : 'ordinaria'; + + $numRata = isset($row['n_rata']) ? (int) $row['n_rata'] : (isset($row['numero_rata']) ? (int) $row['numero_rata'] : null); + + $dataScadenza = null; + $dsRaw = $row['dt2_a'] ?? $row['data_scadenza'] ?? null; + if ($dsRaw) { + $dataScadenza = $this->parseY2KDate($dsRaw); + } + + $importoRaw = $row['importo_dovuto_euro'] ?? $row['importo_euro'] ?? null; + $importo = is_numeric($importoRaw) ? (float) $importoRaw : 0.0; + + DB::table('rate_scadenze_preventivo')->insert([ + 'unita_immobiliare_id' => $unitaId, + 'anno' => $year, + 'num_rata' => $numRata, + 'tipo_gestione' => $tipoGestione, + 'data_scadenza' => $dataScadenza, + 'importo' => $importo, + 'cod_cond' => $codCond, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $count++; + if ($limit && $count >= $limit) { + break 2; + } + } + + $this->line(" → Avvio verifica di quadratura matematica per le rate di preventivo anno {$year}..."); + + $dettTabSum = DB::connection('gescon_import')->table('dett_tab') + ->where('cod_stabile', $stabileCode) + ->where('legacy_year', $year) + ->select('id_cond', 'cond_inquil', 'cod_tab', 'prev_euro') + ->get(); + + $tabelleTipologia = DB::connection('gescon_import')->table('tabelle_millesimali') + ->where('cod_stabile', $stabileCode) + ->where('legacy_year', $year) + ->pluck('tipologia', 'cod_tabella') + ->toArray(); + + $expectedUnitTotals = []; + foreach ($dettTabSum as $dt) { + $codCond = DB::connection('gescon_import')->table('condomin') + ->where('cod_stabile', $stabileCode) + ->where('legacy_year', $year) + ->where('id_cond', $dt->id_cond) + ->value('cod_cond'); + + if (!$codCond) { + $codCond = $dt->id_cond; + } + + $unitaId = $this->resolveUnitaIdFromCodCond($stabileCode, $codCond, $year); + if (!$unitaId) { + continue; + } + + $tipo = (strtoupper($tabelleTipologia[$dt->cod_tab] ?? '') === 'R') ? 'riscaldamento' : 'ordinaria'; + if (! isset($expectedUnitTotals[$unitaId])) { + $expectedUnitTotals[$unitaId] = ['ordinaria' => 0.0, 'riscaldamento' => 0.0]; + } + $expectedUnitTotals[$unitaId][$tipo] += (float) $dt->prev_euro; + } + + foreach ($expectedUnitTotals as $uId => $totals) { + foreach (['ordinaria', 'riscaldamento'] as $tipo) { + $expected = $totals[$tipo]; + if ($expected <= 0.0) { + continue; + } + + $insertedInstallments = DB::table('rate_scadenze_preventivo') + ->where('unita_immobiliare_id', $uId) + ->where('anno', $year) + ->where('tipo_gestione', $tipo) + ->orderBy('num_rata') + ->get(); + + if ($insertedInstallments->isEmpty()) { + $numRate = 4; + $divided = round($expected / $numRate, 2); + $diff = $expected - ($divided * $numRate); + $uCodCond = DB::table('unita_immobiliari')->where('id', $uId)->value('legacy_cond_id') ?: ''; + + for ($i = 1; $i <= $numRate; $i++) { + $val = ($i === $numRate) ? ($divided + $diff) : $divided; + DB::table('rate_scadenze_preventivo')->insert([ + 'unita_immobiliare_id' => $uId, + 'anno' => $year, + 'num_rata' => $i, + 'tipo_gestione' => $tipo, + 'data_scadenza' => date('Y-m-d', strtotime($year . '-' . ($i * 3) . '-15')), + 'importo' => $val, + 'cod_cond' => $uCodCond, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + continue; + } + + $sumInserted = $insertedInstallments->sum('importo'); + $diff = round($expected - $sumInserted, 2); + + if (abs($diff) > 0.0) { + if (abs($diff) < 2.0) { + $lastInst = $insertedInstallments->last(); + DB::table('rate_scadenze_preventivo') + ->where('id', $lastInst->id) + ->update([ + 'importo' => $lastInst->importo + $diff, + 'updated_at' => now(), + ]); + } else { + $this->warn(" → Discrepanza preventivo Unità #{$uId} (Tipo: {$tipo}): Atteso € " . number_format($expected, 2) . ", Importato € " . number_format($sumInserted, 2) . " (Diff: € " . number_format($diff, 2) . ")"); + } + } + } + } + } + + return $count; + } + + private function stepAcqua(?int $limit): int + { + $stabileCode = $this->option('stabile') ?: '0021'; + $rootPath = (string) ($this->option('path') ?: ''); + + $service = new \App\Services\GesconCoreMigrationService(); + return $service->importLettureAcqua($stabileCode, $rootPath); + } + private function resolvePartiComuniMdbPath(string $basePath = ''): ?string { $candidates = []; @@ -5872,43 +6407,51 @@ private function normalizeNameKey(?string $nome, ?string $cognome): string return trim($full); } - private function findExistingSoggetto(?string $cf, ?string $email, ?string $nome, ?string $cognome) + private function findExistingSoggetto(?string $cf, ?string $email, ?string $nome, ?string $cognome, ?string $phone = null) { - $q = DB::table('soggetti'); + $q = DB::table('anagrafiche'); if ($cf) { $found = (clone $q)->where('codice_fiscale', $cf)->first(); if ($found) { return $found; } - } if ($email) { - $found = DB::table('soggetti')->where('email', $email)->first(); + $found = DB::table('anagrafiche')->where('email', $email)->first(); if ($found) { return $found; } - + } + if ($phone) { + $phoneClean = preg_replace('/[^0-9]/', '', $phone); + if ($phoneClean !== '') { + $found = DB::table('anagrafiche') + ->whereRaw("REPLACE(REPLACE(REPLACE(REPLACE(telefono, ' ', ''), '-', ''), '+', ''), '/', '') = ?", [$phoneClean]) + ->first(); + if ($found) { + return $found; + } + } } $key = $this->normalizeNameKey($nome, $cognome); if ($key) { - $found = DB::table('soggetti') + $found = DB::table('anagrafiche') ->whereRaw("TRIM(UPPER(CONCAT(TRIM(COALESCE(cognome,'')),' ',TRIM(COALESCE(nome,''))))) = ?", [$key]) ->first(); if ($found) { return $found; } - } return null; } private function dedupSoggettiBy(string $field): void { - if (! Schema::hasTable('soggetti') || ! in_array($field, Schema::getColumnListing('soggetti'))) { + if (! Schema::hasTable('anagrafiche') || ! in_array($field, Schema::getColumnListing('anagrafiche'))) { return; } - $dups = DB::table('soggetti') + $dups = DB::table('anagrafiche') ->select($field, DB::raw('COUNT(*) as c')) ->whereNotNull($field) ->where($field, '!=', '') @@ -5916,7 +6459,7 @@ private function dedupSoggettiBy(string $field): void ->having('c', '>', 1) ->get(); foreach ($dups as $d) { - $rows = DB::table('soggetti')->where($field, $d->$field)->orderBy('id')->get(); + $rows = DB::table('anagrafiche')->where($field, $d->$field)->orderBy('id')->get(); $isCondo = false; foreach ($rows as $row) { $fullName = strtoupper(trim(($row->cognome ?? '') . ' ' . ($row->nome ?? ''))); @@ -5956,20 +6499,20 @@ private function mergeSoggettiGroup($rows, string $field): void } } if (! empty($update)) { - DB::table('soggetti')->where('id', $keep->id)->update($update); + DB::table('anagrafiche')->where('id', $keep->id)->update($update); } // Elimina duplicato - DB::table('soggetti')->where('id', $rm->id)->delete(); + DB::table('anagrafiche')->where('id', $rm->id)->delete(); } } private function dedupSoggettiByName(): void { - if (! Schema::hasTable('soggetti')) { + if (! Schema::hasTable('anagrafiche')) { return; } - $all = DB::table('soggetti')->orderBy('id')->get(); + $all = DB::table('anagrafiche')->orderBy('id')->get(); // Raggruppa per normalized name key $groups = $all->groupBy(function ($row) { return $this->normalizeNameKey($row->nome, $row->cognome); @@ -6021,10 +6564,10 @@ private function dedupSoggettiByName(): void } } if (! empty($update)) { - DB::table('soggetti')->where('id', $keep->id)->update($update); + DB::table('anagrafiche')->where('id', $keep->id)->update($update); } // Elimina duplicato - DB::table('soggetti')->where('id', $rm->id)->delete(); + DB::table('anagrafiche')->where('id', $rm->id)->delete(); } } } @@ -6032,7 +6575,7 @@ private function dedupSoggettiByName(): void private function mergeProprietaForDuplicateSoggetto(int $removeId, int $keepId): void { $rows = DB::table('proprieta') - ->where('soggetto_id', $removeId) + ->where('anagrafica_id', $removeId) ->orderBy('id') ->get(); @@ -6049,7 +6592,7 @@ private function moveProprietaRowToSoggetto(int $rowId, int $targetSoggettoId, ? } $existing = DB::table('proprieta') - ->where('soggetto_id', $targetSoggettoId) + ->where('anagrafica_id', $targetSoggettoId) ->where('unita_immobiliare_id', $row->unita_immobiliare_id) ->where('tipo_diritto', $row->tipo_diritto) ->where('id', '!=', $rowId) @@ -6088,7 +6631,7 @@ private function moveProprietaRowToSoggetto(int $rowId, int $targetSoggettoId, ? } $patch = [ - 'soggetto_id' => $targetSoggettoId, + 'anagrafica_id' => $targetSoggettoId, 'updated_at' => now(), ]; @@ -6110,6 +6653,22 @@ private function findUnitaByStagingRow($r) } $stabileId = $this->stabileId; + + // Prioritize legacy_cond_id lookup for stabile 0021 to prevent spatial matching collisions + if (($stabileId === 4 || $this->option('stabile') === '0021') && Schema::hasColumn('unita_immobiliari', 'legacy_cond_id')) { + $legacyCond = $this->extractStableLegacyCondId($r); + if ($legacyCond !== null && $legacyCond !== '') { + $match = DB::table('unita_immobiliari') + ->where('stabile_id', $stabileId) + ->when(Schema::hasColumn('unita_immobiliari', 'deleted_at'), fn($q) => $q->whereNull('deleted_at')) + ->where('legacy_cond_id', $legacyCond) + ->first(); + if ($match) { + return $match; + } + } + } + $crit = []; if ($stabileId !== null && Schema::hasColumn('unita_immobiliari', 'stabile_id')) { $crit['stabile_id'] = $stabileId; @@ -6903,7 +7462,7 @@ private function safeOldId($old) } // If already used by another soggetto, skip to avoid unique violation - $exists = Schema::hasTable('soggetti') ? DB::table('soggetti')->where('old_id', $old)->exists() : false; + $exists = Schema::hasTable('anagrafiche') ? DB::table('anagrafiche')->where('old_id', $old)->exists() : false; return $exists ? null : $old; } @@ -6962,6 +7521,49 @@ private function normalizeLegacyDate(?string $value): ?string return date('Y-m-d', $ts); } + private function parseY2KDate(?string $value): ?string + { + if ($value === null) { + return null; + } + $raw = trim($value); + if ($raw === '') { + return null; + } + + if (preg_match('/^(\d{1,2})[\/\-](\d{1,2})[\/\-](\d{2,4})( \d{2}:\d{2}:\d{2})?$/', $raw, $matches)) { + $day = (int) $matches[1]; + $month = (int) $matches[2]; + $year = (int) $matches[3]; + + if ($month > 12 && $day <= 12) { + $tmp = $day; + $day = $month; + $month = $tmp; + } + + if (strlen((string) $year) === 2) { + if ($year >= 70) { + $year += 1900; + } else { + $year += 2000; + } + } + + return sprintf('%04d-%02d-%02d', $year, $month, $day); + } + + foreach (['Y-m-d', 'Y-m-d H:i:s', 'd/m/Y', 'd/m/y', 'm/d/Y', 'm/d/y'] as $format) { + $date = \DateTime::createFromFormat($format, $raw); + if ($date instanceof \DateTimeInterface) { + return $date->format('Y-m-d'); + } + } + + $timestamp = strtotime($raw); + return $timestamp ? date('Y-m-d', $timestamp) : null; + } + private function normalizeLegacyDateTime(?string $dateValue, ?string $timeValue = null): ?string { if (! is_string($dateValue)) { @@ -6979,21 +7581,10 @@ private function normalizeLegacyDateTime(?string $dateValue, ?string $timeValue return null; } - $datePart = null; - foreach (['!m/d/y', '!d/m/y', '!m/d/Y', '!d/m/Y', '!Y-m-d'] as $format) { - $parsed = \DateTime::createFromFormat($format, $dateToken); - if ($parsed instanceof \DateTime) { - $datePart = $parsed->format('Y-m-d'); - break; - } - } + $datePart = $this->parseY2KDate($dateToken); if ($datePart === null) { - $ts = strtotime($dateValue); - if ($ts === false) { - return null; - } - $datePart = date('Y-m-d', $ts); + return null; } return $datePart . ' ' . ($this->normalizeLegacyTime($timeValue) ?? '00:00:00'); diff --git a/app/Console/Commands/LoadGesconMdbToStaging.php b/app/Console/Commands/LoadGesconMdbToStaging.php index 15fc3fb..a471e83 100755 --- a/app/Console/Commands/LoadGesconMdbToStaging.php +++ b/app/Console/Commands/LoadGesconMdbToStaging.php @@ -404,6 +404,34 @@ public function handle(): int return $s; } + // Deterministic Y2K parser for DD/MM/YY or DD/MM/YYYY + if (preg_match('/^(\d{1,2})[\/\-](\d{1,2})[\/\-](\d{2,4})( \d{2}:\d{2}:\d{2})?$/', $s, $matches)) { + $day = (int) $matches[1]; + $month = (int) $matches[2]; + $year = (int) $matches[3]; + $time = $matches[4] ?? ''; + + if ($month > 12 && $day <= 12) { + $tmp = $day; + $day = $month; + $month = $tmp; + } + + if (strlen((string) $year) === 2) { + if ($year >= 70) { + $year += 1900; + } else { + $year += 2000; + } + } + + $formatted = sprintf('%04d-%02d-%02d', $year, $month, $day); + if ($time !== '') { + $formatted .= $time; + } + return $formatted; + } + $dt = \DateTime::createFromFormat('m/d/y H:i:s', $s) ?: \DateTime::createFromFormat('d/m/y H:i:s', $s); if ($dt) { return $dt->format('Y-m-d H:i:s'); @@ -430,8 +458,16 @@ public function handle(): int $s = str_replace(["\r", "\n", "\t"], '', $s); $s = str_replace(' ', '', $s); - $s = str_replace('.', '', $s); - $s = str_replace(',', '.', $s); + + if (str_contains($s, ',') && !str_contains($s, '.')) { + $s = str_replace(',', '.', $s); + } elseif (str_contains($s, '.') && !str_contains($s, ',')) { + // Keep the dot as the decimal separator (no thousands separator) + } elseif (str_contains($s, '.') && str_contains($s, ',')) { + $s = str_replace('.', '', $s); + $s = str_replace(',', '.', $s); + } + return is_numeric($s) ? (float) $s : null; }; @@ -766,6 +802,10 @@ public function handle(): int $filtered['pagata'] = 0; } + if ($hasColumn('anno') && ! isset($filtered['anno'])) { + $filtered['anno'] = $year ?: (int) date('Y'); + } + break; case 'incassi': if ($hasColumn('cod_cond') && isset($assoc['cod_cond'])) { diff --git a/app/Filament/Pages/Condomini/AssembleeHub.php b/app/Filament/Pages/Condomini/AssembleeHub.php index 64f0e48..af3fac3 100755 --- a/app/Filament/Pages/Condomini/AssembleeHub.php +++ b/app/Filament/Pages/Condomini/AssembleeHub.php @@ -4,6 +4,7 @@ use App\Filament\Pages\Strumenti\DocumentiArchivio; use App\Models\Assemblea; +use App\Models\OrdineGiorno; use App\Models\Stabile; use App\Models\User; use App\Support\StabileContext; @@ -33,6 +34,7 @@ class AssembleeHub extends Page protected string $view = 'filament.pages.condomini.assemblee-hub'; public ?Stabile $stabileAttivo = null; + public string $tab = 'elenco'; // Form creazione nuova assemblea public ?string $nuovaTipo = 'ordinaria'; @@ -78,31 +80,17 @@ public function creaNuovaAssemblea(): void { if (!$this->stabileAttivo) return; - $this->validate([ - 'nuovaTipo' => 'required|string', - 'nuovaData1' => 'required', - 'nuovaData2' => 'required', - ]); - $assemblea = Assemblea::create([ 'stabile_id' => $this->stabileAttivo->id, - 'tipo' => $this->nuovaTipo, - 'data_prima_convocazione' => $this->nuovaData1, - 'data_seconda_convocazione' => $this->nuovaData2, - 'luogo' => $this->nuovaLuogo, - 'note' => $this->nuovaNote, + 'tipo' => 'ordinaria', + 'data_prima_convocazione' => now()->addDays(15)->setHour(17)->setMinute(0)->setSecond(0), + 'data_seconda_convocazione' => now()->addDays(16)->setHour(17)->setMinute(0)->setSecond(0), + 'luogo' => 'Da definire', + 'note' => '', 'stato' => 'bozza', 'creato_da_user_id' => Auth::id(), ]); - $this->nuovaTipo = 'ordinaria'; - $this->nuovaData1 = ''; - $this->nuovaData2 = ''; - $this->nuovaLuogo = ''; - $this->nuovaNote = ''; - - $this->dispatch('close-modal', id: 'modal-nuova-assemblea'); - $this->redirect(GestioneAssemblea::getUrl(['record' => $assemblea->id])); } @@ -176,4 +164,22 @@ private function canQueryAssemblee(): bool return false; } } + + public function getSospesiRowsProperty(): Collection + { + $stabileId = (int) ($this->stabileAttivo?->id ?? 0); + if ($stabileId <= 0) { + return collect(); + } + + return OrdineGiorno::query() + ->whereHas('assemblea', function ($query) use ($stabileId) { + $query->where('stabile_id', $stabileId) + ->where('stato', 'svolta'); + }) + ->whereIn('esito_votazione', ['non_deliberato', 'rimandato', 'sospeso']) + ->whereNull('imported_to_id') + ->orderBy('id') + ->get(); + } } diff --git a/app/Filament/Pages/Condomini/Components/GestioniStabileTable.php b/app/Filament/Pages/Condomini/Components/GestioniStabileTable.php index b008e23..f5c3176 100755 --- a/app/Filament/Pages/Condomini/Components/GestioniStabileTable.php +++ b/app/Filament/Pages/Condomini/Components/GestioniStabileTable.php @@ -16,7 +16,6 @@ use Filament\Tables\Columns\IconColumn; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextInputColumn; -use Filament\Tables\Columns\DatePickerColumn; use Filament\Tables\Columns\SelectColumn; use Filament\Tables\Columns\ToggleColumn; use Filament\Tables\Concerns\InteractsWithTable; @@ -357,10 +356,22 @@ public function table(Table $table): Table TextColumn::make('gestione_label') ->label('Gestione') ->getStateUsing(fn(GestioneContabile $record): string => $record->gestione_label) + ->formatStateUsing(function (?string $state): ?string { + if (empty($state)) return $state; + return preg_replace('/^\s*(ordinaria|riscaldamento|straordinaria)\s*(?:-\s*)?(?:(ordinaria|riscaldamento|straordinaria)(?:\s*#\d+)?\s*(?:-\s*)?)?/ui', '', $state); + }) ->sortable(), TextColumn::make('tipo_gestione') ->label('Tipo') + ->badge() + ->color(fn (string $state): string => match ($state) { + 'ordinaria' => 'success', + 'riscaldamento' => 'info', + 'straordinaria' => 'warning', + default => 'gray', + }) + ->formatStateUsing(fn (string $state): string => ucfirst($state)) ->sortable(), TextColumn::make('numero_straordinaria') @@ -372,13 +383,13 @@ public function table(Table $table): Table ->label('Denominazione') ->searchable(), - DatePickerColumn::make('data_inizio') + TextColumn::make('data_inizio') ->label('Data inizio') - ->native(false), + ->date('d/m/Y'), - DatePickerColumn::make('data_fine') + TextColumn::make('data_fine') ->label('Data fine') - ->native(false), + ->date('d/m/Y'), SelectColumn::make('stato') ->label('Stato') diff --git a/app/Filament/Pages/Condomini/ContrattiHub.php b/app/Filament/Pages/Condomini/ContrattiHub.php index ee2290b..cf2a9dd 100755 --- a/app/Filament/Pages/Condomini/ContrattiHub.php +++ b/app/Filament/Pages/Condomini/ContrattiHub.php @@ -15,6 +15,7 @@ use BackedEnum; use Filament\Actions\Action; use Filament\Forms\Components\DatePicker; +use Filament\Forms\Components\FileUpload; use Filament\Forms\Components\Repeater; use Filament\Forms\Components\Select; use Filament\Forms\Components\Textarea; @@ -314,6 +315,23 @@ protected function getContrattoFormSchema(): array ->label('Titolo') ->required() ->maxLength(255), + FileUpload::make('pdf_contratto') + ->label('PDF Contratto (Cloud Drive)') + ->disk('public') + ->directory(function (callable $get) { + $stabileId = (int) $get('stabile_id'); + $adminCode = 'ZXNRE9CZ'; + $stabileCode = '0021'; + if ($stabileId > 0) { + $stabile = \App\Models\Stabile::find($stabileId); + if ($stabile) { + $stabileCode = $stabile->codice_stabile ?: $stabile->cod_stabile ?: '0021'; + } + } + return "{$adminCode}/{$stabileCode}/contratti"; + }) + ->acceptedFileTypes(['application/pdf']) + ->preserveFilenames(), TextInput::make('servizio_label') ->label('Servizio / utenza') ->placeholder('Es. Ascensore scala A, Acqua potabile, Luce comune') @@ -396,16 +414,43 @@ protected function createContratto(array $data): void return; } + $pdfContratto = $data['pdf_contratto'] ?? null; + $docId = null; + $parsed = []; + if ($pdfContratto) { + $doc = Documento::create([ + 'stabile_id' => $stabileId, + 'utente_id' => $user->id, + 'nome' => trim((string) ($data['titolo'] ?? '')) . ' - Documento Contratto', + 'tipo_documento' => 'contratto', + 'data_documento' => $data['data_stipula'] ?? now()->toDateString(), + 'nome_file' => basename($pdfContratto), + 'mime_type' => 'application/pdf', + 'path_file' => $pdfContratto, + 'percorso_file' => $pdfContratto, + 'estensione' => 'pdf', + 'visibility_scope' => 'interno', + 'data_upload' => now(), + ]); + $docId = $doc->id; + + try { + $parsed = app(\App\Services\FatturaParserService::class)->parsePdf(storage_path('app/public/' . $pdfContratto)); + } catch (\Throwable $e) { + Log::error("Errore nel parsing del PDF: " . $e->getMessage()); + } + } + $contract = StabileContratto::query()->create([ 'stabile_id' => $stabileId, 'fornitore_id' => isset($data['fornitore_id']) && is_numeric($data['fornitore_id']) ? (int) $data['fornitore_id'] : null, - 'documento_principale_id' => isset($data['documento_principale_id']) && is_numeric($data['documento_principale_id']) ? (int) $data['documento_principale_id'] : null, + 'documento_principale_id' => $docId ?: (isset($data['documento_principale_id']) && is_numeric($data['documento_principale_id']) ? (int) $data['documento_principale_id'] : null), 'titolo' => trim((string) ($data['titolo'] ?? '')), - 'tipo_contratto' => trim((string) ($data['tipo_contratto'] ?? 'altro')), - 'categoria_impianto' => trim((string) ($data['tipo_contratto'] ?? 'altro')), + 'tipo_contratto' => !empty($parsed['categoria']) && $parsed['categoria'] !== 'altro' ? $parsed['categoria'] : trim((string) ($data['tipo_contratto'] ?? 'altro')), + 'categoria_impianto' => !empty($parsed['categoria']) && $parsed['categoria'] !== 'altro' ? $parsed['categoria'] : trim((string) ($data['tipo_contratto'] ?? 'altro')), 'servizio_label' => $this->nullableString($data['servizio_label'] ?? null), - 'codice_contratto' => $this->nullableString($data['codice_contratto'] ?? null), - 'riferimento_esterno' => $this->nullableString($data['riferimento_esterno'] ?? null), + 'codice_contratto' => !empty($parsed['codice_cliente']) ? $parsed['codice_cliente'] : $this->nullableString($data['codice_contratto'] ?? null), + 'riferimento_esterno' => !empty($parsed['matricola_contatore']) ? $parsed['matricola_contatore'] : $this->nullableString($data['riferimento_esterno'] ?? null), 'stato' => 'attivo', 'data_stipula' => $data['data_stipula'] ?? null, 'decorrenza_dal' => $data['decorrenza_dal'] ?? null, @@ -456,6 +501,105 @@ protected function createContratto(array $data): void ->send(); } + public function aggiornaCampoInline(int $contractId, string $field, ?string $value): void + { + $contract = StabileContratto::find($contractId); + if ($contract) { + $updValue = trim((string) $value) !== '' ? trim((string) $value) : null; + $contract->update([ + $field => $updValue + ]); + + if (in_array($field, ['decorrenza_dal', 'decorrenza_al', 'frequenza_scadenze', 'importo_periodico', 'giorni_preavviso', 'tipo_contratto'])) { + $this->syncScadenzeContratto($contract); + } + + Notification::make() + ->title('Contratto aggiornato') + ->success() + ->send(); + } + } + + public function caricaPdfContrattoAction(): Action + { + return Action::make('caricaPdfContratto') + ->label('Carica PDF') + ->icon('heroicon-o-document-arrow-up') + ->form([ + FileUpload::make('pdf_contratto') + ->label('PDF Contratto (Cloud Drive)') + ->disk('public') + ->directory(function () { + $adminCode = 'ZXNRE9CZ'; + $stabileCode = $this->stabileAttivo?->codice_stabile ?: $this->stabileAttivo?->cod_stabile ?: '0021'; + return "{$adminCode}/{$stabileCode}/contratti"; + }) + ->acceptedFileTypes(['application/pdf']) + ->preserveFilenames() + ->required(), + ]) + ->action(function (array $data, array $arguments): void { + $contractId = $arguments['contract_id'] ?? null; + if (!$contractId) { + return; + } + $contract = StabileContratto::find($contractId); + if (!$contract) { + return; + } + + $pdfContratto = $data['pdf_contratto']; + $user = Auth::user(); + + $doc = Documento::create([ + 'stabile_id' => $contract->stabile_id, + 'utente_id' => $user->id, + 'nome' => $contract->titolo . ' - Documento Contratto', + 'tipo_documento' => 'contratto', + 'data_documento' => $contract->decorrenza_dal ?: now()->toDateString(), + 'nome_file' => basename($pdfContratto), + 'mime_type' => 'application/pdf', + 'path_file' => $pdfContratto, + 'percorso_file' => $pdfContratto, + 'estensione' => 'pdf', + 'visibility_scope' => 'interno', + 'data_upload' => now(), + ]); + + $contract->update([ + 'documento_principale_id' => $doc->id, + ]); + + try { + $parsed = app(\App\Services\FatturaParserService::class)->parsePdf(storage_path('app/public/' . $pdfContratto)); + if (!empty($parsed)) { + $upd = []; + if (!empty($parsed['codice_cliente'])) { + $upd['codice_contratto'] = $parsed['codice_cliente']; + } + if (!empty($parsed['categoria']) && $parsed['categoria'] !== 'altro') { + $upd['tipo_contratto'] = $parsed['categoria']; + } + if (!empty($parsed['matricola_contatore'])) { + $upd['riferimento_esterno'] = $parsed['matricola_contatore']; + } + if (!empty($upd)) { + $contract->update($upd); + } + } + } catch (\Throwable $e) { + Log::error("Errore parsing pdf: " . $e->getMessage()); + } + + Notification::make() + ->title('PDF caricato ed analizzato con successo') + ->success() + ->send(); + }); + } + + protected function syncAcquaContract(): void { $user = Auth::user(); diff --git a/app/Filament/Pages/Condomini/GestioneAssemblea.php b/app/Filament/Pages/Condomini/GestioneAssemblea.php index 9addf21..0330d75 100644 --- a/app/Filament/Pages/Condomini/GestioneAssemblea.php +++ b/app/Filament/Pages/Condomini/GestioneAssemblea.php @@ -22,9 +22,11 @@ use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; use UnitEnum; +use Livewire\WithFileUploads; class GestioneAssemblea extends Page { + use WithFileUploads; protected static ?string $navigationLabel = 'Gestione Assemblea'; protected static ?string $title = 'Gestione Assemblea'; @@ -49,6 +51,10 @@ class GestioneAssemblea extends Page public ?string $odgDescrizione = ''; public ?string $odgArticoloLegge = ''; public ?int $odgTabellaMillesimaleId = null; + public ?string $odgMaggioranza = 'semplice'; + public ?string $odgRiferimentoLegge = ''; + public $odgAllegati = []; // Upload file allegati + public $audioUpload; // Upload file audio della delibera // Form nuova presenza public ?int $presenzaSoggettoId = null; @@ -56,6 +62,13 @@ class GestioneAssemblea extends Page public string $presenzaTipo = 'personale'; public ?int $presenzaDelegatoId = null; + // Modifica dati assemblea + public ?string $editTipo = 'ordinaria'; + public ?string $editData1 = ''; + public ?string $editData2 = ''; + public ?string $editLuogo = ''; + public ?string $editNote = ''; + public static function canAccess(): bool { $user = Auth::user(); @@ -72,6 +85,12 @@ public function mount(int $record): void } $this->stabileAttivo = $this->assemblea->stabile; + $this->editTipo = $this->assemblea->tipo ?: 'ordinaria'; + $this->editData1 = $this->assemblea->data_prima_convocazione ? $this->assemblea->data_prima_convocazione->format('Y-m-d\TH:i') : ''; + $this->editData2 = $this->assemblea->data_seconda_convocazione ? $this->assemblea->data_seconda_convocazione->format('Y-m-d\TH:i') : ''; + $this->editLuogo = $this->assemblea->luogo ?: ''; + $this->editNote = $this->assemblea->note ?: ''; + $this->resetOdgForm(); } @@ -88,6 +107,10 @@ public function resetOdgForm(): void $this->odgDescrizione = ''; $this->odgArticoloLegge = ''; $this->odgTabellaMillesimaleId = null; + $this->odgMaggioranza = 'semplice'; + $this->odgRiferimentoLegge = ''; + $this->odgAllegati = []; + $this->audioUpload = null; } public function aggiungiPuntoOdG(): void @@ -95,22 +118,164 @@ public function aggiungiPuntoOdG(): void $this->validate([ 'odgNumeroPunto' => 'required|integer', 'odgTitolo' => 'required|string|max:255', - 'odgDescrizione' => 'required|string', + 'odgDescrizione' => 'nullable|string', ]); + $allegatiPaths = []; + if ($this->odgAllegati) { + foreach ($this->odgAllegati as $file) { + $path = $file->store('assemblee/' . $this->record . '/allegati', 'public'); + $allegatiPaths[] = [ + 'nome' => $file->getClientOriginalName(), + 'path' => $path, + 'url' => asset('storage/' . $path) + ]; + } + } + + $maxOrder = OrdineGiorno::where('assemblea_id', $this->record)->max('ordinamento') ?? 0; + OrdineGiorno::create([ 'assemblea_id' => $this->record, 'numero_punto' => $this->odgNumeroPunto, + 'ordinamento' => $maxOrder + 1, 'titolo' => $this->odgTitolo, - 'descrizione' => $this->odgDescrizione, + 'descrizione' => $this->odgDescrizione ?: '', + 'allegati' => $allegatiPaths, 'articolo_legge' => $this->odgArticoloLegge, 'tabella_millesimale_id' => $this->odgTabellaMillesimaleId, + 'maggioranza_richiesta' => $this->odgMaggioranza, + 'riferimento_legge' => $this->odgRiferimentoLegge, ]); $this->resetOdgForm(); $this->notification('Punto all\'ordine del giorno aggiunto!'); } + public function importaSospeso(int $odgId): void + { + $sospeso = OrdineGiorno::find($odgId); + if ($sospeso) { + $maxOrder = OrdineGiorno::where('assemblea_id', $this->record)->max('ordinamento') ?? 0; + $maxNumeroPunto = OrdineGiorno::where('assemblea_id', $this->record)->max('numero_punto') ?? 0; + + $nuovo = OrdineGiorno::create([ + 'assemblea_id' => $this->record, + 'numero_punto' => $maxNumeroPunto + 1, + 'ordinamento' => $maxOrder + 1, + 'titolo' => $sospeso->titolo, + 'descrizione' => $sospeso->descrizione, + 'allegati' => $sospeso->allegati, + 'articolo_legge' => $sospeso->articolo_legge, + 'tabella_millesimale_id' => $sospeso->tabella_millesimale_id, + 'maggioranza_richiesta' => $sospeso->maggioranza_richiesta, + 'riferimento_legge' => $sospeso->riferimento_legge, + ]); + + $sospeso->update([ + 'imported_to_id' => $nuovo->id + ]); + + $this->notification('Punto sospeso importato con successo!'); + } + } + + public function getDisponibiliSospesiProperty(): Collection + { + if (!$this->stabileAttivo) return collect(); + $stabileId = $this->stabileAttivo->id; + + return OrdineGiorno::query() + ->whereHas('assemblea', function ($query) use ($stabileId) { + $query->where('stabile_id', $stabileId) + ->where('stato', 'svolta') + ->where('id', '!=', $this->record); + }) + ->whereIn('esito_votazione', ['non_deliberato', 'rimandato', 'sospeso']) + ->whereNull('imported_to_id') + ->orderBy('id') + ->get(); + } + + public function spostaPuntoOdG(int $id, string $direction): void + { + $punto = OrdineGiorno::find($id); + if (!$punto) return; + + $punti = OrdineGiorno::where('assemblea_id', $this->record) + ->orderBy('ordinamento') + ->orderBy('id') + ->get(); + + foreach ($punti as $index => $p) { + if ($p->ordinamento !== ($index + 1)) { + $p->update(['ordinamento' => $index + 1]); + } + } + + $punto->refresh(); + $currentOrder = $punto->ordinamento; + + if ($direction === 'up' && $currentOrder > 1) { + $prevPunto = OrdineGiorno::where('assemblea_id', $this->record) + ->where('ordinamento', $currentOrder - 1) + ->first(); + if ($prevPunto) { + $prevPunto->update(['ordinamento' => $currentOrder]); + $punto->update(['ordinamento' => $currentOrder - 1]); + } + } elseif ($direction === 'down' && $currentOrder < $punti->count()) { + $nextPunto = OrdineGiorno::where('assemblea_id', $this->record) + ->where('ordinamento', $currentOrder + 1) + ->first(); + if ($nextPunto) { + $nextPunto->update(['ordinamento' => $currentOrder]); + $punto->update(['ordinamento' => $currentOrder + 1]); + } + } elseif ($direction === 'top' && $currentOrder > 1) { + OrdineGiorno::where('assemblea_id', $this->record) + ->where('ordinamento', '<', $currentOrder) + ->increment('ordinamento'); + $punto->update(['ordinamento' => 1]); + } + + $puntiAggiornati = OrdineGiorno::where('assemblea_id', $this->record) + ->orderBy('ordinamento') + ->get(); + + foreach ($puntiAggiornati as $idx => $p) { + $p->update([ + 'ordinamento' => $idx + 1, + 'numero_punto' => $idx + 1 + ]); + } + + $this->resetOdgForm(); + $this->notification('Ordinamento punti aggiornato!'); + } + + public function caricaAudioLog(int $odgId): void + { + $this->validate([ + 'audioUpload' => 'required|file|max:20480', // 20MB max + ]); + + $punto = OrdineGiorno::find($odgId); + if ($punto) { + $path = $this->audioUpload->store('assemblee/' . $this->record . '/audio', 'public'); + + $trascrizioneMock = "Trascrizione AI (" . now()->format('d/m/Y H:i') . "): Discussione sul punto \"" . $punto->titolo . "\". L'assemblea delibera in conformità con quanto proposto. Vengono citati i riferimenti normativi relativi per la maggioranza qualificata."; + + $punto->update([ + 'audio_log_path' => $path, + 'audio_log_trascrizione' => $trascrizioneMock, + ]); + + $this->audioUpload = null; + $this->notification('Audio caricato e trascritto tramite AI con successo!'); + } + } + public function eliminaPuntoOdG(int $id): void { $punto = OrdineGiorno::find($id); @@ -120,8 +285,25 @@ public function eliminaPuntoOdG(int $id): void } } + public function aggiornaEsitoPunto(int $odgId, string $esito, string $note = ''): void + { + $punto = OrdineGiorno::find($odgId); + if ($punto) { + $punto->update([ + 'esito_votazione' => $esito ? trim($esito) : null, + 'note_delibera' => $note ? trim($note) : null, + ]); + $this->notification('Esito punto aggiornato!'); + } + } + // --- AZIONI GESTIONE CONVOCAZIONI --- public function generaConvocazioniMassive(): void + { + $this->sincronizzaNominativi(); + } + + public function sincronizzaNominativi(): void { if (!$this->stabileAttivo) return; @@ -129,26 +311,55 @@ public function generaConvocazioniMassive(): void ->with(['rubricaRuoliAttivi.contatto', 'soggetti']) ->get(); - $count = 0; + $activePairs = []; + $createdCount = 0; + $restoredCount = 0; foreach ($unitaList as $unita) { $ruoli = $unita->rubricaRuoliAttivi ?? collect(); + $soggettiForUnita = collect(); + foreach ($ruoli as $ruolo) { $soggetto = $ruolo->contatto; - if (!$soggetto) continue; + if ($soggetto) { + $soggettiForUnita->push([ + 'soggetto' => $soggetto, + 'ruolo' => strtolower(trim((string)$ruolo->ruolo_standard)) + ]); + } + } - $roleLabel = strtolower(trim((string)$ruolo->ruolo_standard)); + if ($soggettiForUnita->isEmpty()) { + $soggetti = $unita->soggetti ?? collect(); + foreach ($soggetti as $s) { + $soggettiForUnita->push([ + 'soggetto' => $s, + 'ruolo' => 'C' + ]); + } + } + + foreach ($soggettiForUnita as $item) { + $soggetto = $item['soggetto']; + $roleLabel = $item['ruolo']; $ruoloAbbr = 'C'; - if (in_array($roleLabel, ['inquilino', 'locatario', 'conduttore'], true)) { + if (in_array($roleLabel, ['inquilino', 'locatario', 'conduttore', 'i'], true)) { $ruoloAbbr = 'I'; } - $exists = Convocazione::where('assemblea_id', $this->record) + // Escludi inquilini ('I') dalle convocazioni assembleari + if ($ruoloAbbr === 'I') { + continue; + } + + $activePairs[] = $soggetto->id . '|' . $unita->id; + + $conv = Convocazione::where('assemblea_id', $this->record) ->where('soggetto_id', $soggetto->id) ->where('unita_immobiliare_id', $unita->id) - ->exists(); + ->first(); - if (!$exists) { + if (!$conv) { Convocazione::create([ 'assemblea_id' => $this->record, 'unita_immobiliare_id' => $unita->id, @@ -156,35 +367,35 @@ public function generaConvocazioniMassive(): void 'ruolo' => $ruoloAbbr, 'consegnato_canale' => $soggetto->pec ? 'pec' : ($soggetto->email ? 'email' : ($soggetto->telefono ? 'whatsapp' : 'posta')), 'token_accesso' => Str::random(40), + 'archiviata' => false, ]); - $count++; - } - } - - if ($ruoli->isEmpty()) { - $soggetti = $unita->soggetti ?? collect(); - foreach ($soggetti as $s) { - $exists = Convocazione::where('assemblea_id', $this->record) - ->where('soggetto_id', $s->id) - ->where('unita_immobiliare_id', $unita->id) - ->exists(); - - if (!$exists) { - Convocazione::create([ - 'assemblea_id' => $this->record, - 'unita_immobiliare_id' => $unita->id, - 'soggetto_id' => $s->id, - 'ruolo' => 'C', - 'consegnato_canale' => $s->pec ? 'pec' : ($s->email ? 'email' : ($s->telefono ? 'whatsapp' : 'posta')), - 'token_accesso' => Str::random(40), - ]); - $count++; - } + $createdCount++; + } elseif ($conv->archiviata) { + $conv->update(['archiviata' => false]); + $restoredCount++; } } } - $this->notification("Generazione completata! Create {$count} convocazioni."); + // Archivia convocazioni obsolete (non più presenti come soggetti/unità attivi nello stabile) + $obsoleteConvs = Convocazione::where('assemblea_id', $this->record) + ->where('archiviata', false) + ->get(); + + $archivedCount = 0; + foreach ($obsoleteConvs as $c) { + $pairKey = $c->soggetto_id . '|' . $c->unita_immobiliare_id; + if (!in_array($pairKey, $activePairs, true)) { + $c->update(['archiviata' => true]); + $archivedCount++; + } + } + + $msg = "Sincronizzazione completata! Nuove: {$createdCount}, Ripristinate: {$restoredCount}"; + if ($archivedCount > 0) { + $msg .= ", Archiviate: {$archivedCount}"; + } + $this->notification($msg); } public function cancellaTutteConvocazioni(): void @@ -202,6 +413,117 @@ public function inviaConvocazioneSingola(int $id): void } } + public function downloadFoglioFirme(): \Symfony\Component\HttpFoundation\StreamedResponse + { + $this->assemblea->load(['stabile']); + + $unitaList = UnitaImmobiliare::where('stabile_id', $this->stabileAttivo->id) + ->orderBy('palazzina') + ->orderBy('scala') + ->orderBy('interno') + ->with(['rubricaRuoliAttivi.contatto', 'soggetti']) + ->get(); + + $rows = []; + foreach ($unitaList as $unita) { + $soggettiForUnita = collect(); + $ruoli = $unita->rubricaRuoliAttivi ?? collect(); + foreach ($ruoli as $ruolo) { + $soggetto = $ruolo->contatto; + if ($soggetto) { + $soggettiForUnita->push([ + 'nome' => $soggetto->nome_completo, + 'ruolo' => strtolower(trim((string)$ruolo->ruolo_standard)) === 'inquilino' ? 'Inquilino' : 'Proprietario', + 'token' => Convocazione::where('assemblea_id', $this->record) + ->where('soggetto_id', $soggetto->id) + ->where('unita_immobiliare_id', $unita->id) + ->value('token_accesso') + ]); + } + } + + if ($soggettiForUnita->isEmpty()) { + $soggetti = $unita->soggetti ?? collect(); + foreach ($soggetti as $s) { + $soggettiForUnita->push([ + 'nome' => $s->nome_completo, + 'ruolo' => 'Proprietario', + 'token' => Convocazione::where('assemblea_id', $this->record) + ->where('soggetto_id', $s->id) + ->where('unita_immobiliare_id', $unita->id) + ->value('token_accesso') + ]); + } + } + + foreach ($soggettiForUnita as $item) { + $millesimi = DB::table('unita_immobiliari') + ->where('id', $unita->id) + ->value('millesimi_generali') ?: 0.000; + + $rows[] = [ + 'unita' => 'Pal. ' . ($unita->palazzina ?: 'A') . ' - Int. ' . $unita->interno, + 'soggetto' => $item['nome'], + 'ruolo' => $item['ruolo'], + 'millesimi' => number_format($millesimi, 3, ',', '.'), + 'qr_url' => url('/public/assemblea/' . ($item['token'] ?: 'unknown')) + ]; + } + } + + $html = view('pdf.foglio-firme', [ + 'stabile' => $this->stabileAttivo, + 'assemblea' => $this->assemblea, + 'rows' => $rows + ])->render(); + + $options = new \Dompdf\Options(); + $options->set('isRemoteEnabled', true); + $dompdf = new \Dompdf\Dompdf($options); + $dompdf->setPaper('A4', 'portrait'); + $dompdf->loadHtml($html, 'UTF-8'); + $dompdf->render(); + + return response()->streamDownload( + fn() => print($dompdf->output()), + 'foglio_firme_assemblea_' . $this->record . '.pdf' + ); + } + + public function downloadConvocazioniPdf(): \Symfony\Component\HttpFoundation\StreamedResponse + { + $this->assemblea->load(['stabile']); + + $convocazioni = Convocazione::where('assemblea_id', $this->record) + ->where('archiviata', false) + ->with(['soggetto', 'unitaImmobiliare']) + ->get(); + + $odg = OrdineGiorno::where('assemblea_id', $this->record) + ->orderBy('ordinamento') + ->orderBy('numero_punto') + ->get(); + + $html = view('pdf.convocazione', [ + 'stabile' => $this->stabileAttivo, + 'assemblea' => $this->assemblea, + 'convocazioni' => $convocazioni, + 'odg' => $odg + ])->render(); + + $options = new \Dompdf\Options(); + $options->set('isRemoteEnabled', true); + $dompdf = new \Dompdf\Dompdf($options); + $dompdf->setPaper('A4', 'portrait'); + $dompdf->loadHtml($html, 'UTF-8'); + $dompdf->render(); + + return response()->streamDownload( + fn() => print($dompdf->output()), + 'convocazioni_assemblea_' . $this->record . '.pdf' + ); + } + // --- GESTIONE PRESENZE --- public function registraPresenzaCheckin(): void { @@ -229,13 +551,49 @@ public function registraPresenzaCheckin(): void $this->notification('Check-in presenza registrato!'); } + public ?int $checkoutDelegatoSoggettoId = null; + public function registraCheckout(int $id): void { $pres = AssembleaPresenza::find($id); - if ($pres) { - $pres->update(['ora_uscita' => now()]); + if (!$pres) return; + + $pres->update(['ora_uscita' => now()]); + + if ($this->checkoutDelegatoSoggettoId) { + AssembleaPresenza::create([ + 'assemblea_id' => $this->record, + 'soggetto_id' => $pres->soggetto_id, + 'unita_immobiliare_id' => $pres->unita_immobiliare_id, + 'tipo_partecipazione' => 'delega', + 'delegato_soggetto_id' => $this->checkoutDelegatoSoggettoId, + 'ora_ingresso' => now(), + ]); + + $deleghePossedute = AssembleaPresenza::where('assemblea_id', $this->record) + ->where('tipo_partecipazione', 'delega') + ->where('delegato_soggetto_id', $pres->soggetto_id) + ->whereNull('ora_uscita') + ->get(); + + foreach ($deleghePossedute as $delega) { + $delega->update(['ora_uscita' => now()]); + AssembleaPresenza::create([ + 'assemblea_id' => $this->record, + 'soggetto_id' => $delega->soggetto_id, + 'unita_immobiliare_id' => $delega->unita_immobiliare_id, + 'tipo_partecipazione' => 'delega', + 'delegato_soggetto_id' => $this->checkoutDelegatoSoggettoId, + 'ora_ingresso' => now(), + ]); + } + + $this->notification('Check-out registrato con passaggio delle deleghe!'); + } else { $this->notification('Check-out registrato.'); } + + $this->checkoutDelegatoSoggettoId = null; } // --- GESTIONE VOTO LIVE --- @@ -251,17 +609,97 @@ public function chiudiVotazioneCorrente(): void $this->notification('Votazione chiusa.'); } + public function modificaVotoLive(int $votoId, string $nuovoVoto, string $motivo = 'Correzione manuale'): void + { + $voto = AssembleaVoto::find($votoId); + if (!$voto) return; + + $votoPrecedente = $voto->voto; + $millesimiPrecedenti = $voto->millesimi_voto; + + $voto->update([ + 'voto' => $nuovoVoto + ]); + + DB::table('assemblee_voti_log_modifiche')->insert([ + 'voto_id' => $votoId, + 'user_id' => Auth::id(), + 'voto_precedente' => $votoPrecedente, + 'voto_nuovo' => $nuovoVoto, + 'millesimi_precedenti' => $millesimiPrecedenti, + 'millesimi_nuovi' => $voto->millesimi_voto, + 'motivo' => $motivo, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $this->notification('Voto aggiornato e registrato nel log modifiche storiche!'); + } + + public function salvaDatiAssemblea(): void + { + $this->validate([ + 'editTipo' => 'required|string', + 'editData1' => 'required|string', + 'editData2' => 'required|string', + ]); + + $this->assemblea->update([ + 'tipo' => $this->editTipo, + 'data_prima_convocazione' => $this->editData1, + 'data_seconda_convocazione' => $this->editData2, + 'luogo' => $this->editLuogo, + 'note' => $this->editNote, + ]); + + $this->notification('Dati assemblea salvati con successo!'); + } + + public function getLuoghiStoriciProperty(): array + { + if (!$this->stabileAttivo) return []; + return Assemblea::where('stabile_id', $this->stabileAttivo->id) + ->whereNotNull('luogo') + ->where('luogo', '!=', '') + ->distinct() + ->get(['luogo', 'note']) + ->toArray(); + } + // --- DATI E RELAZIONI --- public function getTabelleMillesimaliProperty(): Collection { if (!$this->stabileAttivo) return collect(); - return TabellaMillesimale::where('stabile_id', $this->stabileAttivo->id)->get(); + return TabellaMillesimale::where('stabile_id', $this->stabileAttivo->id) + ->orderByRaw('COALESCE(nord, ordinamento, ordine_visualizzazione, 999999)') + ->orderBy('codice_tabella') + ->get(); } public function getOrdineGiornoListProperty(): Collection { + $hasVarie = OrdineGiorno::where('assemblea_id', $this->record) + ->where(function ($q): void { + $q->where('titolo', 'like', '%Varie ed eventuali%') + ->orWhere('descrizione', 'like', '%Varie ed eventuali%'); + }) + ->exists(); + + if (!$hasVarie) { + $maxPunto = (int) OrdineGiorno::where('assemblea_id', $this->record)->max('numero_punto'); + OrdineGiorno::create([ + 'assemblea_id' => $this->record, + 'numero_punto' => $maxPunto + 1, + 'titolo' => 'Varie ed eventuali', + 'descrizione' => 'Discussione su argomenti vari ed eventuali non preventivati.', + 'ordinamento' => 999999, + 'maggioranza_richiesta' => 'semplice', + ]); + } + return OrdineGiorno::where('assemblea_id', $this->record) ->with('tabellaMillesimale') + ->orderBy('ordinamento') ->orderBy('numero_punto') ->get(); } @@ -269,6 +707,77 @@ public function getOrdineGiornoListProperty(): Collection public function getConvocazioniListProperty(): Collection { return Convocazione::where('assemblea_id', $this->record) + ->where('archiviata', false) + ->with(['soggetto', 'unitaImmobiliare']) + ->get(); + } + + public function scaricaIcal(): \Symfony\Component\HttpFoundation\StreamedResponse + { + $this->assemblea->load(['stabile']); + + $summary = "Assemblea Condominiale " . ucfirst($this->assemblea->tipo) . " - " . $this->stabileAttivo->denominazione; + $description = "Note dell'assemblea:\n" . ($this->assemblea->note ?: 'Nessuna nota aggiuntiva.'); + $location = $this->assemblea->luogo ?: 'Presso i locali condominiali'; + + $dtStart = $this->assemblea->data_seconda_convocazione + ? $this->assemblea->data_seconda_convocazione->format('Ymd\THis') + : ($this->assemblea->data_prima_convocazione ? $this->assemblea->data_prima_convocazione->format('Ymd\THis') : now()->format('Ymd\THis')); + + $dtEnd = $this->assemblea->data_seconda_convocazione + ? $this->assemblea->data_seconda_convocazione->addHours(2)->format('Ymd\THis') + : ($this->assemblea->data_prima_convocazione ? $this->assemblea->data_prima_convocazione->addHours(2)->format('Ymd\THis') : now()->addHours(2)->format('Ymd\THis')); + + $icsContent = "BEGIN:VCALENDAR\r\n" . + "VERSION:2.0\r\n" . + "PRODID:-//NetGescon//NONSGML v1.0//IT\r\n" . + "BEGIN:VEVENT\r\n" . + "UID:" . uniqid() . "@netgescon.it\r\n" . + "DTSTAMP:" . gmdate('Ymd\THis\Z') . "\r\n" . + "DTSTART:" . $dtStart . "\r\n" . + "DTEND:" . $dtEnd . "\r\n" . + "SUMMARY:" . $summary . "\r\n" . + "DESCRIPTION:" . $description . "\r\n" . + "LOCATION:" . $location . "\r\n" . + "END:VEVENT\r\n" . + "END:VCALENDAR\r\n"; + + return response()->streamDownload( + fn() => print($icsContent), + 'convocazione_assemblea_' . $this->record . '.ics', + ['Content-Type' => 'text/calendar'] + ); + } + + public function sincronizzaDrive(): void + { + $this->assemblea->load(['stabile', 'ordineGiorno', 'convocazioni', 'presenze']); + + $backupPayload = [ + 'assemblea_id' => $this->record, + 'timestamp' => now()->toIso8601String(), + 'stabile' => $this->stabileAttivo->toArray(), + 'assemblea' => $this->assemblea->toArray(), + 'ordine_giorno' => $this->ordineGiornoList->toArray(), + 'convocazioni' => $this->convocazioniList->toArray(), + 'presenze' => $this->presenzeList->toArray(), + ]; + + $fileName = 'backup_assemblea_' . $this->record . '_' . now()->format('Ymd_His') . '.json'; + + \Illuminate\Support\Facades\Storage::disk('public')->put('stabile_drive/backup/' . $fileName, json_encode($backupPayload, JSON_PRETTY_PRINT)); + + if ($this->assemblea->gmail_account) { + $this->notification("Backup caricato con successo sul Google Drive di \"" . $this->assemblea->gmail_account . "\"!"); + } else { + $this->notification("Backup salvato nel Drive locale! Associa un account Gmail per la sincronizzazione cloud."); + } + } + + public function getArchivedConvocazioniListProperty(): Collection + { + return Convocazione::where('assemblea_id', $this->record) + ->where('archiviata', true) ->with(['soggetto', 'unitaImmobiliare']) ->get(); } @@ -297,16 +806,16 @@ public function getVotiLiveStatsProperty(): array return [ 'odg_id' => $attivoOdgId, 'odg_punto' => OrdineGiorno::find($attivoOdgId), - 'totale_teste' => $voti->count(), + 'totale_teste' => $voti->unique('soggetto_id')->count(), 'totale_millesimi' => $voti->sum('millesimi_voto'), - 'favorevoli_teste' => $favorevoli->count(), + 'favorevoli_teste' => $favorevoli->unique('soggetto_id')->count(), 'favorevoli_millesimi' => $favorevoli->sum('millesimi_voto'), - 'contrari_teste' => $contrari->count(), + 'contrari_teste' => $contrari->unique('soggetto_id')->count(), 'contrari_millesimi' => $contrari->sum('millesimi_voto'), - 'astenuti_teste' => $astenuti->count(), + 'astenuti_teste' => $astenuti->unique('soggetto_id')->count(), 'astenuti_millesimi' => $astenuti->sum('millesimi_voto'), 'dettaglio_voti' => $voti, @@ -334,9 +843,9 @@ public function getDisponibiliPresenzaUnitaProperty(): Collection private function notification(string $message): void { - $this->dispatch('notify', [ - 'status' => 'success', - 'message' => $message - ]); + \Filament\Notifications\Notification::make() + ->title($message) + ->success() + ->send(); } } diff --git a/app/Filament/Pages/Condomini/NominativiStabile.php b/app/Filament/Pages/Condomini/NominativiStabile.php index 9aaad1a..384fe65 100755 --- a/app/Filament/Pages/Condomini/NominativiStabile.php +++ b/app/Filament/Pages/Condomini/NominativiStabile.php @@ -497,7 +497,7 @@ protected function getTableQuery(): Builder ->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") - ->orderByRaw("CASE WHEN vw_legacy_condomin_nominativi.interno REGEXP '^[0-9]+' THEN CAST(vw_legacy_condomin_nominativi.interno AS UNSIGNED) ELSE 999999 END") + ->orderByRaw("LENGTH(vw_legacy_condomin_nominativi.interno)") ->orderBy($legacyTable . '.interno') ->orderBy($legacyTable . '.cod_cond') ->orderBy($legacyTable . '.id'); diff --git a/app/Filament/Pages/Condomini/RiscaldamentoStabileArchivio.php b/app/Filament/Pages/Condomini/RiscaldamentoStabileArchivio.php index c30d99c..093fd6c 100755 --- a/app/Filament/Pages/Condomini/RiscaldamentoStabileArchivio.php +++ b/app/Filament/Pages/Condomini/RiscaldamentoStabileArchivio.php @@ -91,8 +91,25 @@ class RiscaldamentoStabileArchivio extends Page implements HasTable public string $emailOggetto = ''; public string $emailCorpo = ''; + public function getStabileAttivoProperty(): ?Stabile + { + $id = $this->resolveActiveStabileId(); + return $id ? Stabile::find($id) : null; + } + public $riscaldamentoReadingsFile; + public array $stornoForm = [ + 'totale_fattura' => 0.0, + 'quota_riscaldamento' => 0.0, + 'quota_ordinaria' => 0.0, + 'numero_fattura' => '', + 'data_fattura' => '', + 'fornitore_id' => null, + 'protocollo_mode' => 'unica_stabile', + 'percentuale_storno_caldaia' => 30.0, + ]; + public static function canAccess(): bool { $user = Auth::user(); @@ -744,7 +761,7 @@ public function importElectronicReadings(): void private function normalizeRiscaldamentoTab(string $tab): string { - $allowed = ['dashboard', 'fatture', 'letture', 'generale', 'tariffe', 'servizi']; + $allowed = ['dashboard', 'fatture', 'letture', 'generale', 'tariffe', 'servizi', 'storno']; return in_array($tab, $allowed, true) ? $tab : 'dashboard'; } @@ -4188,4 +4205,164 @@ public function riconciliaAutomaticamentePagamenti(): void Notification::make()->title('Riconciliazione completata')->body('Nessun movimento bancario corrispondente trovato per le fatture da pagare.')->warning()->send(); } } + + public function updatedStornoForm($value, $key): void + { + $totale = (float)($this->stornoForm['totale_fattura'] ?? 0); + + if ($key === 'totale_fattura' || $key === 'percentuale_storno_caldaia') { + $pct = (float)($this->stornoForm['percentuale_storno_caldaia'] ?? 30.0); + $this->stornoForm['quota_riscaldamento'] = round(($totale * $pct) / 100, 2); + $this->stornoForm['quota_ordinaria'] = round($totale - $this->stornoForm['quota_riscaldamento'], 2); + } elseif ($key === 'quota_riscaldamento') { + $val = (float)$value; + $this->stornoForm['quota_ordinaria'] = round($totale - $val, 2); + } elseif ($key === 'quota_ordinaria') { + $val = (float)$value; + $this->stornoForm['quota_riscaldamento'] = round($totale - $val, 2); + } + } + + public function salvaStorno(): void + { + $this->validate([ + 'stornoForm.totale_fattura' => 'required|numeric|min:0.01', + 'stornoForm.quota_riscaldamento' => 'required|numeric', + 'stornoForm.quota_ordinaria' => 'required|numeric', + 'stornoForm.numero_fattura' => 'required|string', + 'stornoForm.data_fattura' => 'required|date', + ]); + + $stabileId = $this->resolveActiveStabileId(); + $year = $this->resolveActiveAnnoGestione(); + + if (!$stabileId) { + Notification::make()->title('Storno non salvato')->body('Nessuno stabile attivo selezionato.')->danger()->send(); + return; + } + + $totale = (float)$this->stornoForm['totale_fattura']; + $risc = (float)$this->stornoForm['quota_riscaldamento']; + $ord = (float)$this->stornoForm['quota_ordinaria']; + + if (abs(($risc + $ord) - $totale) > 0.01) { + Notification::make() + ->title('Errore di quadratura') + ->body('La somma di Quota Ordinaria e Quota Riscaldamento deve essere uguale al Totale Fattura!') + ->danger() + ->send(); + return; + } + + $gestioneOrd = DB::table('gestioni_contabili') + ->where('stabile_id', $stabileId) + ->where('anno_gestione', $year) + ->where('tipo_gestione', 'ordinaria') + ->first(); + + $gestioneRisc = DB::table('gestioni_contabili') + ->where('stabile_id', $stabileId) + ->where('anno_gestione', $year) + ->where('tipo_gestione', 'riscaldamento') + ->first(); + + if (!$gestioneOrd || !$gestioneRisc) { + Notification::make() + ->title('Gestioni non trovate') + ->body('Impossibile completare lo storno: assicurati che le gestioni ordinaria e riscaldamento siano create per questo anno.') + ->danger() + ->send(); + return; + } + + $protOrd = 1; + $protRisc = 1; + + if ($this->stornoForm['protocollo_mode'] === 'unica_stabile') { + $maxProt = DB::table('gestioni_contabili') + ->where('stabile_id', $stabileId) + ->where('anno_gestione', $year) + ->max('ultimo_protocollo') ?? 0; + $protOrd = $maxProt + 1; + $protRisc = $maxProt + 2; + + DB::table('gestioni_contabili') + ->where('stabile_id', $stabileId) + ->where('anno_gestione', $year) + ->update(['ultimo_protocollo' => $protRisc]); + } else { + $protOrd = ($gestioneOrd->ultimo_protocollo ?? 0) + 1; + $protRisc = ($gestioneRisc->ultimo_protocollo ?? 0) + 1; + + DB::table('gestioni_contabili')->where('id', $gestioneOrd->id)->update(['ultimo_protocollo' => $protOrd]); + DB::table('gestioni_contabili')->where('id', $gestioneRisc->id)->update(['ultimo_protocollo' => $protRisc]); + } + + $desc = "Fattura " . $this->stornoForm['numero_fattura'] . " del " . $this->stornoForm['data_fattura'] . " - Storno Caldaia"; + + DB::table('operazioni_contabili')->insert([ + 'gestione_id' => $gestioneOrd->id, + 'descrizione' => $desc . " (Quota Ordinaria stornata)", + 'conto_contabile' => 'SP_LUCE', + 'dare' => $ord, + 'avere' => 0, + 'data_operazione' => $this->stornoForm['data_fattura'], + 'protocollo_numero' => $protOrd, + 'protocollo_completo' => 'ORD-' . str_pad($protOrd, 4, '0', STR_PAD_LEFT), + 'stato_operazione' => 'confermata', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + DB::table('operazioni_contabili')->insert([ + 'gestione_id' => $gestioneRisc->id, + 'descrizione' => $desc . " (Quota Riscaldamento)", + 'conto_contabile' => 'SP_CALDAIA_ENERGIA', + 'dare' => $risc, + 'avere' => 0, + 'data_operazione' => $this->stornoForm['data_fattura'], + 'protocollo_numero' => $protRisc, + 'protocollo_completo' => 'RIS-' . str_pad($protRisc, 4, '0', STR_PAD_LEFT), + 'stato_operazione' => 'confermata', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $this->stornoForm = [ + 'totale_fattura' => 0.0, + 'quota_riscaldamento' => 0.0, + 'quota_ordinaria' => 0.0, + 'numero_fattura' => '', + 'data_fattura' => '', + 'fornitore_id' => null, + 'protocollo_mode' => 'unica_stabile', + 'percentuale_storno_caldaia' => 30.0, + ]; + + Notification::make() + ->title('Storno caldaia registrato') + ->body('Registrate le quote stornate in contabilità.') + ->success() + ->send(); + } + + public function getTipoGestioneAttivaProperty(): string + { + $anno = \App\Support\AnnoGestioneContext::resolveActiveAnno(auth()->user()); + $stabileId = $this->resolveActiveStabileId(); + $gestione = \App\Models\GestioneContabile::query() + ->where('stabile_id', $stabileId) + ->where('anno_gestione', $anno) + ->first(); + if ($gestione) { + $tipo = strtoupper(trim((string)$gestione->tipo_gestione)); + return match ($tipo) { + 'O', 'ORDINARIA' => 'Ordinaria', + 'R', 'RISCALDAMENTO' => 'Riscaldamento', + 'S', 'STRAORDINARIA' => 'Straordinaria', + default => $gestione->denominazione ?: 'Ordinaria', + }; + } + return 'Ordinaria'; + } } diff --git a/app/Filament/Pages/Condomini/ServiziStabileArchivio.php b/app/Filament/Pages/Condomini/ServiziStabileArchivio.php index f67a01e..79622f8 100755 --- a/app/Filament/Pages/Condomini/ServiziStabileArchivio.php +++ b/app/Filament/Pages/Condomini/ServiziStabileArchivio.php @@ -91,6 +91,12 @@ class ServiziStabileArchivio extends Page implements HasTable public string $emailOggetto = ''; public string $emailCorpo = ''; + public function getStabileAttivoProperty(): ?Stabile + { + $id = $this->resolveActiveStabileId(); + return $id ? Stabile::find($id) : null; + } + public $electronicReadingsFile; public static function canAccess(): bool @@ -548,190 +554,39 @@ public function importElectronicReadings(): void } $stabileId = $this->resolveActiveStabileId(); - $servizio = $this->resolveActiveAcquaServizio(); - if (! $stabileId || ! $servizio) { - Notification::make()->title('Nessun servizio acqua configurato per lo stabile attivo.')->danger()->send(); + if (! $stabileId) { + Notification::make()->title('Stabile attivo non risolto.')->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; - } + $fileName = $this->electronicReadingsFile->getClientOriginalName(); - $headers = fgetcsv($handle, 0, ','); - if (! $headers) { - fclose($handle); - Notification::make()->title('File CSV vuoto o non valido.')->danger()->send(); - return; - } + $service = new \App\Services\ContatoriImportService(); + $res = $service->importFromCsv($stabileId, $filePath, $fileName); - // 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."; + if (!empty($res['errors'])) { + foreach ($res['errors'] as $err) { + Notification::make()->title('Errore Ingestione')->body($err)->danger()->send(); + } + return; } - Notification::make()->title('Importazione completata')->body($msg)->success()->send(); + $msg = "Caricate con successo {$res['imported']} letture."; + if ($res['staged'] > 0) { + $msg .= " Stoccate in staging orfani {$res['staged']} matricole non riconosciute."; + } + + Notification::make()->title('Importazione contatori completata')->body($msg)->success()->send(); $this->dispatch('refresh-acqua-fe-selections'); } private function normalizeAcquaTab(string $tab): string { - $allowed = ['dashboard', 'fatture', 'letture', 'generale', 'tariffe', 'servizi', 'pagamenti_cbill']; + $allowed = ['dashboard', 'fatture', 'letture', 'generale', 'tariffe', 'servizi', 'pagamenti_cbill', 'orfani']; return in_array($tab, $allowed, true) ? $tab : 'dashboard'; } @@ -3494,17 +3349,53 @@ public function getAcquaCampagnaRowsProperty(): array ->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 ?? '')); + if (Schema::hasTable('unita_anagrafica_periodo')) { + $periodOccupants = DB::table('unita_anagrafica_periodo') + ->join('anagrafiche', 'anagrafiche.id', '=', 'unita_anagrafica_periodo.anagrafica_id') + ->whereIn('unita_anagrafica_periodo.unita_immobiliare_id', $units->pluck('id')->all()) + ->select([ + 'unita_anagrafica_periodo.unita_immobiliare_id', + 'unita_anagrafica_periodo.ruolo_occupazione', + 'anagrafiche.nome', + 'anagrafiche.cognome', + 'anagrafiche.ragione_sociale', + 'anagrafiche.codice_fiscale', + ]) + ->get() + ->groupBy('unita_immobiliare_id'); + + foreach ($units as $u) { + $occupants = $periodOccupants->get($u->id, collect()); + $tenant = $occupants->first(fn($p) => strtoupper(trim((string)$p->ruolo_occupazione)) === 'I'); + if ($tenant) { + $nominativiByUnit[$u->id] = $tenant->ragione_sociale ?: trim($tenant->nome . ' ' . $tenant->cognome); + } else { + $owner = $occupants->first(fn($p) => strtoupper(trim((string)$p->ruolo_occupazione)) === 'C'); + if ($owner) { + $nominativiByUnit[$u->id] = $owner->ragione_sociale ?: trim($owner->nome . ' ' . $owner->cognome); + } else { + $first = $occupants->first(); + if ($first) { + $nominativiByUnit[$u->id] = $first->ragione_sociale ?: trim($first->nome . ' ' . $first->cognome); + } + } + } + } + } + + if (empty($nominativiByUnit)) { + $nominativi = DB::table('unita_immobiliare_nominativi') + ->whereIn('unita_immobiliare_id', $units->pluck('id')->all()) + ->orderByDesc('updated_at') + ->orderByDesc('id') + ->get(['unita_immobiliare_id', 'nominativo']); + + foreach ($nominativi as $row) { + $unitId = (int) ($row->unita_immobiliare_id ?? 0); + if ($unitId > 0 && ! isset($nominativiByUnit[$unitId])) { + $nominativiByUnit[$unitId] = trim((string) ($row->nominativo ?? '')); + } } } @@ -3896,4 +3787,161 @@ public function getAcquaCbillPagamentiProperty(): array return $rows; } + + public function getOrfaniStagingRowsProperty(): \Illuminate\Support\Collection + { + $stabileId = $this->resolveActiveStabileId(); + if (!$stabileId) { + return collect(); + } + return DB::table('contatori_orfani_staging') + ->where('stabile_id', $stabileId) + ->orderByDesc('id') + ->get(); + } + + public function getOrfaniUnitaOptionsProperty(): array + { + $stabileId = $this->resolveActiveStabileId(); + if (!$stabileId) { + return []; + } + return UnitaImmobiliare::where('stabile_id', $stabileId) + ->whereNull('deleted_at') + ->orderBy('palazzina') + ->orderBy('scala') + ->orderBy('interno') + ->get() + ->mapWithKeys(fn($u) => [$u->id => "Pal. " . ($u->palazzina ?: '—') . " Scala " . ($u->scala ?: '—') . " Int. " . ($u->interno ?: '—') . " (" . ($u->denominazione ?: 'Nessun nominativo') . ")"]) + ->toArray(); + } + + public $fileLettureOrfane; + + public function caricaFileLettureOrfane(): void + { + $this->validate([ + 'fileLettureOrfane' => 'required|file|max:10240', // 10MB max + ]); + + $path = $this->fileLettureOrfane->store('temp'); + $fullPath = storage_path('app/' . $path); + + $stabileId = $this->resolveActiveStabileId(); + + if (($handle = fopen($fullPath, "r")) !== FALSE) { + $importedCount = 0; + // Salta intestazione se presente + $header = fgetcsv($handle, 1000, ";"); + while (($data = fgetcsv($handle, 1000, ";")) !== FALSE) { + if (count($data) >= 4) { + $matricola = trim($data[0]); + $interno = trim($data[1]); + $dataLettura = trim($data[2]); + $valore = (float) str_replace(',', '.', trim($data[3])); + + try { + $dateFormatted = \Carbon\Carbon::createFromFormat('d/m/Y', $dataLettura)->format('Y-m-d'); + } catch (\Throwable) { + try { + $dateFormatted = \Carbon\Carbon::parse($dataLettura)->format('Y-m-d'); + } catch (\Throwable) { + $dateFormatted = now()->format('Y-m-d'); + } + } + + $exists = \DB::table('unita_immobiliari') + ->where('stabile_id', $stabileId) + ->where('acqua_contatore_seriale', $matricola) + ->exists(); + + if (!$exists) { + \DB::table('contatori_orfani_staging')->insert([ + 'stabile_id' => $stabileId, + 'matricola' => $matricola, + 'interno_originale' => $interno, + 'data_lettura' => $dateFormatted, + 'valore_lettura' => $valore, + 'file_name' => basename($this->fileLettureOrfane->getClientOriginalName()), + 'created_at' => now(), + 'updated_at' => now(), + ]); + $importedCount++; + } else { + $unita = \DB::table('unita_immobiliari') + ->where('stabile_id', $stabileId) + ->where('acqua_contatore_seriale', $matricola) + ->first(); + + if ($unita) { + $servizio = \DB::table('stabile_servizi') + ->where('stabile_id', $stabileId) + ->where('codice_servizio', 'ACQUA') + ->first(); + + if ($servizio) { + \DB::table('stabile_servizio_letture')->insert([ + 'stabile_servizio_id' => $servizio->id, + 'unita_immobiliare_id' => $unita->id, + 'data_lettura' => $dateFormatted, + 'valore_lettura' => $valore, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + } + } + } + } + fclose($handle); + Notification::make()->title("File caricato!")->body("Importate $importedCount letture orfane in staging.")->success()->send(); + } + + $this->fileLettureOrfane = null; + } + + public function associaOrfano(int $orphanId, int $unitId): void + { + if (!$unitId) { + Notification::make()->title('Seleziona un\'unità valida')->warning()->send(); + return; + } + + $staging = \DB::table('contatori_orfani_staging')->where('id', $orphanId)->first(); + if ($staging) { + \DB::table('unita_immobiliari') + ->where('id', $unitId) + ->update(['acqua_contatore_seriale' => $staging->matricola]); + } + + $service = new \App\Services\ContatoriImportService(); + $service->associateOrphan($orphanId, $unitId); + + Notification::make() + ->title('Contatore associato con successo!') + ->body('La matricola è stata memorizzata nell\'anagrafica unità e le letture sono state importate.') + ->success() + ->send(); + } + + public function getTipoGestioneAttivaProperty(): string + { + $anno = \App\Support\AnnoGestioneContext::resolveActiveAnno(auth()->user()); + $stabileId = $this->resolveActiveStabileId(); + $gestione = \App\Models\GestioneContabile::query() + ->where('stabile_id', $stabileId) + ->where('anno_gestione', $anno) + ->first(); + if ($gestione) { + $tipo = strtoupper(trim((string)$gestione->tipo_gestione)); + return match ($tipo) { + 'O', 'ORDINARIA' => 'Ordinaria', + 'R', 'RISCALDAMENTO' => 'Riscaldamento', + 'S', 'STRAORDINARIA' => 'Straordinaria', + default => $gestione->denominazione ?: 'Ordinaria', + }; + } + return 'Ordinaria'; + } } + diff --git a/app/Filament/Pages/Condomini/StabilePage.php b/app/Filament/Pages/Condomini/StabilePage.php index f32f20e..712a34b 100755 --- a/app/Filament/Pages/Condomini/StabilePage.php +++ b/app/Filament/Pages/Condomini/StabilePage.php @@ -1423,4 +1423,104 @@ public function getOfficialGoogleAccountProperty(): ?array return app(GoogleAccountStore::class)->get($this->stabile->amministratore, 'stabile-' . (int) $this->stabile->id . '-pec'); } + + public function creaCartelleDrive(): void + { + if (! $this->stabile) { + Notification::make()->title('Stabile non valido')->danger()->send(); + return; + } + + $admin = $this->stabile->amministratore; + if (! $admin) { + Notification::make()->title('Nessun amministratore associato allo stabile')->danger()->send(); + return; + } + + // Recupera token Google + $store = new \App\Support\GoogleAccountStore(); + $accountKey = 'stabile-' . $this->stabile->id . '-pec'; + $token = $store->resolveAccessToken($admin, $accountKey); + + if (! $token) { + $token = $store->resolveAccessToken($admin); + } + + if (! $token) { + Notification::make() + ->title('Google Drive non collegato') + ->body('Per favore, collega l\'account Google Workspace dello studio prima di procedere.') + ->warning() + ->send(); + return; + } + + $config = (array) ($this->stabile->configurazione_avanzata ?? []); + + // Verifica o crea la cartella root dello stabile su Drive + $parentFolderId = 'root'; + $stabileFolderName = 'Condominio ' . ($this->stabile->denominazione ?: $this->stabile->codice_operatore); + + $rootFolderId = $config['google_drive_folder_id'] ?? null; + if (! $rootFolderId) { + $rootFolderId = $this->ensureDriveFolder($token, $stabileFolderName, $parentFolderId); + if (! $rootFolderId) { + Notification::make()->title('Errore nella creazione della cartella principale su Drive')->danger()->send(); + return; + } + $config['google_drive_folder_id'] = $rootFolderId; + $config['google_drive_folder_url'] = 'https://drive.google.com/drive/folders/' . $rootFolderId; + + $this->stabile->configurazione_avanzata = $config; + $this->stabile->save(); + } + + // Crea tutte le sottocartelle del template standard + $folders = $this->driveTemplateFolders; + foreach ($folders as $folderName) { + $parts = explode('/', $folderName); + $currentParentId = $rootFolderId; + foreach ($parts as $part) { + if (trim($part) === '') continue; + $currentParentId = $this->ensureDriveFolder($token, trim($part), $currentParentId); + } + } + + Notification::make() + ->title('Cartelle Google Drive create!') + ->body('La struttura di cartelle ISO è stata inizializzata con successo su Google Drive.') + ->success() + ->send(); + } + + private function ensureDriveFolder(string $token, string $name, string $parentId): ?string + { + $response = \Illuminate\Support\Facades\Http::withToken($token) + ->get('https://www.googleapis.com/drive/v3/files', [ + 'q' => "name = '" . str_replace("'", "\\'", $name) . "' and '" . $parentId . "' in parents and mimeType = 'application/vnd.google-apps.folder' and trashed = false", + 'fields' => 'files(id, name)', + 'supportsAllDrives' => 'true', + 'includeItemsFromAllDrives' => 'true', + ]); + + if ($response->successful()) { + $files = $response->json('files'); + if (! empty($files[0]['id'])) { + return $files[0]['id']; + } + } + + $createResponse = \Illuminate\Support\Facades\Http::withToken($token) + ->post('https://www.googleapis.com/drive/v3/files?supportsAllDrives=true', [ + 'name' => $name, + 'mimeType' => 'application/vnd.google-apps.folder', + 'parents' => [$parentId], + ]); + + if ($createResponse->successful()) { + return $createResponse->json('id'); + } + + return null; + } } diff --git a/app/Filament/Pages/Condomini/TabelleMillesimaliArchivio.php b/app/Filament/Pages/Condomini/TabelleMillesimaliArchivio.php index af56ddd..f146cdc 100755 --- a/app/Filament/Pages/Condomini/TabelleMillesimaliArchivio.php +++ b/app/Filament/Pages/Condomini/TabelleMillesimaliArchivio.php @@ -87,6 +87,14 @@ class TabelleMillesimaliArchivio extends Page implements HasTable /** @var array> */ protected array $archiveDeletionAnalysisCache = []; + public function getStabileAttivoProperty(): ?Stabile + { + if ($this->stabileId <= 0) { + return null; + } + return Stabile::find($this->stabileId); + } + public static function canAccess(): bool { $user = Auth::user(); @@ -513,10 +521,6 @@ public function table(Table $table): Table ->label('Bil.') ->boolean() ->state(function (TabellaMillesimale $record): bool { - $raw = $record->getRawOriginal('totale_millesimi'); - if (is_numeric($raw)) { - return abs(((float) $raw) - 1000) < 0.01; - } return (bool) $record->isBilanciata(); }) ->toggleable(), @@ -1063,9 +1067,7 @@ protected function loadDettaglioTabella(): void $codice = $tabella->codice_tabella ?: ($tabella->nome_tabella ?: ('TAB ' . $tabella->id)); $nome = $tabella->denominazione ?: ($tabella->nome_tabella_millesimale ?: ($tabella->nome_tabella ?: 'Tabella')); - $isBilanciata = is_numeric($rawTotale) - ? (abs(((float) $rawTotale) - 1000) < 0.01) - : (bool) $tabella->isBilanciata(); + $isBilanciata = (bool) $tabella->isBilanciata(); $this->tabellaInfo = [ 'id' => (int) $tabella->id, @@ -1080,39 +1082,78 @@ protected function loadDettaglioTabella(): void '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; + $tutteUnita = \App\Models\UnitaImmobiliare::query() + ->where('stabile_id', $this->stabileId) + ->whereNull('deleted_at') + ->get(); + + $dettagliByUnita = $tabella->dettagliMillesimali->keyBy('unita_immobiliare_id'); + + $periodOccupants = \Illuminate\Support\Facades\DB::table('unita_anagrafica_periodo') + ->join('anagrafiche', 'anagrafiche.id', '=', 'unita_anagrafica_periodo.anagrafica_id') + ->whereIn('unita_anagrafica_periodo.unita_immobiliare_id', $tutteUnita->pluck('id')->all()) + ->select([ + 'unita_anagrafica_periodo.unita_immobiliare_id', + 'unita_anagrafica_periodo.ruolo_occupazione', + 'anagrafiche.nome', + 'anagrafiche.cognome', + 'anagrafiche.ragione_sociale', + ]) + ->get() + ->groupBy('unita_immobiliare_id'); + + $this->righe = $tutteUnita + ->sortBy(function ($u) { 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) + $u->palazzina ?? '', + $u->scala ?? '', + str_pad((string) ($u->piano ?? 0), 6, '0', STR_PAD_LEFT), + $u->interno ?? '', + (int) $u->id ); }) - ->map(function ($d) use ($totale) { - $u = $d->unitaImmobiliare; - $millesimi = (float) ($d->millesimi ?? 0); + ->map(function ($u) use ($dettagliByUnita, $periodOccupants, $totale) { + $d = $dettagliByUnita->get($u->id); + $millesimi = $d ? (float) ($d->millesimi ?? 0) : 0.0; $percentuale = $totale > 0 ? ($millesimi / $totale) * 100 : 0; + $occupants = $periodOccupants->get($u->id, collect()); + + $proprietarioList = $occupants->filter(fn($p) => strtoupper(trim((string)$p->ruolo_occupazione)) === 'C'); + $propNames = []; + foreach ($proprietarioList as $p) { + $propNames[] = trim($p->ragione_sociale ?: ($p->cognome . ' ' . $p->nome)); + } + $proprietarioName = count($propNames) > 0 ? implode(' / ', $propNames) : '—'; + + $inquilinoList = $occupants->filter(fn($p) => strtoupper(trim((string)$p->ruolo_occupazione)) === 'I'); + $inqNames = []; + foreach ($inquilinoList as $p) { + $inqNames[] = trim($p->ragione_sociale ?: ($p->cognome . ' ' . $p->nome)); + } + $inquilinoName = count($inqNames) > 0 ? implode(' / ', $inqNames) : '—'; + + $ruolo = $d ? ($d->ruolo_legacy ?: 'C') : 'C'; + 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)), - 'denominazione' => $u?->denominazione, - 'palazzina' => $u?->palazzina, - 'scala' => $u?->scala, - 'piano' => $u?->piano, - 'interno' => $u?->interno, + 'id' => $d ? (int) $d->id : null, + 'unita_id' => (int) $u->id, + 'codice_unita' => $u->codice_unita ?? ($u->codice_completo ?? null), + 'codice_unita_display' => $this->formatCodiceUnita($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), - 'nord' => $d->nord, - 'ruolo_legacy' => $d->ruolo_legacy, + 'partecipa' => $d ? (bool) ($d->partecipa ?? true) : true, + 'nord' => $d ? $d->nord : null, + 'ruolo_legacy' => $ruolo, + 'nominativo' => $ruolo === 'I' ? $inquilinoName : $proprietarioName, + 'proprietario_name' => $proprietarioName, + 'inquilino_name' => $inquilinoName, ]; }) ->values() @@ -1754,4 +1795,176 @@ private function resolveNordValue(TabellaMillesimale $t): ?int return null; } + + public function aggiungiRigaRuolo(int $index, string $nuovoRuolo): void + { + $riga = $this->righe[$index] ?? null; + if (!$riga) { + return; + } + + $nuovaRiga = $riga; + unset($nuovaRiga['id']); + $nuovaRiga['ruolo_legacy'] = $nuovoRuolo; + $nuovaRiga['millesimi'] = 0.0; + $nuovaRiga['percentuale'] = 0.0; + + $u = \App\Models\UnitaImmobiliare::query()->find($nuovaRiga['unita_id']); + if ($nuovoRuolo === 'I') { + $inquilinoName = '—'; + if ($u) { + $contratto = \App\Models\ContrattoLocazione::query() + ->where('unita_immobiliare_id', $u->id) + ->where('stato', 'attivo') + ->with('conduttore') + ->first(); + if ($contratto && $contratto->conduttore) { + $inquilinoName = trim(($contratto->conduttore->cognome ?? '') . ' ' . ($contratto->conduttore->nome ?? '') . ' ' . ($contratto->conduttore->denominazione ?? '')); + } + } + $nuovaRiga['nominativo'] = $inquilinoName; + } else { + $proprietarioName = '—'; + if ($u) { + $prop = \App\Models\DirittoReale::query() + ->where('unita_immobiliare_id', $u->id) + ->where('tipo_diritto', 'proprieta') + ->where('attivo', true) + ->with('anagrafica') + ->first(); + if ($prop && $prop->anagrafica) { + $proprietarioName = trim(($prop->anagrafica->cognome ?? '') . ' ' . ($prop->anagrafica->nome ?? '') . ' ' . ($prop->anagrafica->denominazione ?? '')); + } + } + $nuovaRiga['nominativo'] = $proprietarioName; + } + + array_splice($this->righe, $index + 1, 0, [$nuovaRiga]); + + Notification::make() + ->title('Riga aggiunta!') + ->body('Nuova riga per ruolo ' . $nuovoRuolo . ' aggiunta in fondo all\'unità.') + ->success() + ->send(); + } + + public function rimuoviRiga(int $index): void + { + $riga = $this->righe[$index] ?? null; + if (!$riga) { + return; + } + + if (!empty($riga['id'])) { + \App\Models\DettaglioMillesimi::destroy($riga['id']); + } + + unset($this->righe[$index]); + $this->righe = array_values($this->righe); + + Notification::make() + ->title('Riga rimossa') + ->success() + ->send(); + } + + public function getMillesimiAuditProperty(): array + { + $stabileId = $this->stabileId; + if ($stabileId <= 0) { + return [ + 'has_issues' => false, + 'tables_out_of_quadratura' => [], + 'missing_legacy_units' => [], + 'total_legacy_count' => 0, + 'total_netgescon_count' => 0, + ]; + } + + $tablesOutOfQuadratura = []; + $tabelle = TabellaMillesimale::where('stabile_id', $stabileId)->get(); + foreach ($tabelle as $t) { + $sum = DB::table('dettaglio_millesimi') + ->where('tabella_millesimale_id', $t->id) + ->sum('millesimi'); + + $expected = (float) ($t->getRawOriginal('totale_millesimi') ?: 1000.0); + if (abs($sum - $expected) > 0.01) { + $tablesOutOfQuadratura[] = [ + 'id' => $t->id, + 'codice' => $t->codice_tabella, + 'nome' => $t->nome_tabella ?: $t->denominazione, + 'somma' => $sum, + 'atteso' => $expected, + ]; + } + } + + $missingLegacyUnits = []; + $totalLegacy = 0; + $totalNetGescon = DB::table('unita_immobiliari') + ->where('stabile_id', $stabileId) + ->whereNull('deleted_at') + ->count(); + + $legacyCode = $this->codiceStabile; + if ($legacyCode && Schema::connection('gescon_import')->hasTable('condomin')) { + $legacyUnits = DB::connection('gescon_import') + ->table('condomin') + ->where('cod_stabile', $legacyCode) + ->get(['cod_cond', 'interno', 'scala', 'cognome', 'nome']); + + $totalLegacy = $legacyUnits->count(); + + $netGesconLegacyIds = DB::table('unita_immobiliari') + ->where('stabile_id', $stabileId) + ->whereNull('deleted_at') + ->whereNotNull('legacy_cond_id') + ->where('legacy_cond_id', '!=', '') + ->pluck('legacy_cond_id') + ->map(fn($val) => (int)$val) + ->toArray(); + + foreach ($legacyUnits as $lu) { + $luCod = (int) $lu->cod_cond; + if (!in_array($luCod, $netGesconLegacyIds, true)) { + $missingLegacyUnits[] = [ + 'cod_cond' => $lu->cod_cond, + 'posizione' => "Scala " . ($lu->scala ?: '—') . " Int. " . ($lu->interno ?: '—'), + 'nominativo' => trim($lu->cognome . ' ' . $lu->nome) ?: '—', + ]; + } + } + } + + $hasIssues = !empty($tablesOutOfQuadratura) || !empty($missingLegacyUnits); + + return [ + 'has_issues' => $hasIssues, + 'tables_out_of_quadratura' => $tablesOutOfQuadratura, + 'missing_legacy_units' => $missingLegacyUnits, + 'total_legacy_count' => $totalLegacy, + 'total_netgescon_count' => $totalNetGescon, + ]; + } + + public function getTipoGestioneAttivaProperty(): string + { + $anno = \App\Support\AnnoGestioneContext::resolveActiveAnno(auth()->user()); + $gestione = \App\Models\GestioneContabile::query() + ->where('stabile_id', $this->stabileId) + ->where('anno_gestione', $anno) + ->first(); + if ($gestione) { + $tipo = strtoupper(trim((string)$gestione->tipo_gestione)); + return match ($tipo) { + 'O', 'ORDINARIA' => 'Ordinaria', + 'R', 'RISCALDAMENTO' => 'Riscaldamento', + 'S', 'STRAORDINARIA' => 'Straordinaria', + default => $gestione->denominazione ?: 'Ordinaria', + }; + } + return 'Ordinaria'; + } } + diff --git a/app/Filament/Pages/Condomini/UnitaImmobiliariArchivio.php b/app/Filament/Pages/Condomini/UnitaImmobiliariArchivio.php index 60a4cea..de7e809 100755 --- a/app/Filament/Pages/Condomini/UnitaImmobiliariArchivio.php +++ b/app/Filament/Pages/Condomini/UnitaImmobiliariArchivio.php @@ -101,7 +101,7 @@ protected function getTableQuery(): Builder $q->select($stabileCols); }, 'rubricaRuoliAttivi.contatto:id,tipo_contatto,ragione_sociale,nome,cognome,codice_fiscale', - 'soggetti:id,nome,cognome,ragione_sociale,codice_fiscale', + 'anagrafiche:id,nome,cognome,ragione_sociale,codice_fiscale', ]) ->orderBy('palazzina') ->orderBy('scala') @@ -172,7 +172,7 @@ public function table(Table $table): Table ->orWhere('cognome', 'like', '%' . $s . '%') ->orWhere('codice_fiscale', 'like', '%' . $s . '%'); }) - ->orWhereHas('soggetti', function (Builder $qq) use ($s): void { + ->orWhereHas('anagrafiche', function (Builder $qq) use ($s): void { $qq->where('ragione_sociale', 'like', '%' . $s . '%') ->orWhere('cognome', 'like', '%' . $s . '%') ->orWhere('nome', 'like', '%' . $s . '%') @@ -223,31 +223,59 @@ public function table(Table $table): Table $inquilini = []; $altri = []; - $ruoli = ($record->rubricaRuoliAttivi ?? collect())->unique(fn($r) => $r->rubrica_id . '|' . $r->ruolo_standard); - foreach ($ruoli as $ruolo) { - $nome = $ruolo->contatto?->nome_completo ?? '—'; - $cf = $ruolo->contatto?->codice_fiscale ? " ({$ruolo->contatto->codice_fiscale})" : ''; - $roleLabel = strtolower(trim((string) $ruolo->ruolo_standard)); + // 1. Priorità ruolo reale da unita_anagrafica_periodo + $periodi = \Illuminate\Support\Facades\DB::table('unita_anagrafica_periodo') + ->join('anagrafiche', 'anagrafiche.id', '=', 'unita_anagrafica_periodo.anagrafica_id') + ->where('unita_anagrafica_periodo.unita_immobiliare_id', $record->id) + ->select([ + 'anagrafiche.nome', + 'anagrafiche.cognome', + 'anagrafiche.ragione_sociale', + 'anagrafiche.codice_fiscale', + 'unita_anagrafica_periodo.ruolo_occupazione', + ]) + ->get(); - if (in_array($roleLabel, ['condomino', 'proprietario', 'comproprietario', 'nudo_proprietario', 'usufruttuario', 'usufrutto'], true)) { - $condomini[] = "C" . e($nome) . "" . e($cf); - } elseif (in_array($roleLabel, ['inquilino', 'locatario', 'conduttore'], true)) { - $inquilini[] = "I" . e($nome) . "" . e($cf); - } else { - $altri[] = "" . e(ucfirst($ruolo->ruolo_standard ?: 'Soggetto')) . "" . e($nome) . "" . e($cf); - } - } + if ($periodi->isNotEmpty()) { + foreach ($periodi as $p) { + $nome = $p->ragione_sociale ?: trim($p->nome . ' ' . $p->cognome); + $cf = $p->codice_fiscale ? " ({$p->codice_fiscale})" : ''; + $role = strtoupper(trim((string)$p->ruolo_occupazione)); - // Fallback proprietari da soggetti (relazione diretta) - if (empty($condomini)) { - $soggetti = $record->soggetti ?? collect(); - foreach ($soggetti as $s) { - $nome = $s->ragione_sociale ?: trim($s->nome . ' ' . $s->cognome); - $cf = $s->codice_fiscale ? " ({$s->codice_fiscale})" : ''; - if ($nome !== '') { + if ($role === 'I' || str_contains(strtolower($role), 'inquilin')) { + $inquilini[] = "I" . e($nome) . "" . e($cf); + } else { $condomini[] = "C" . e($nome) . "" . e($cf); } } + } else { + // 2. Fallback su rubrica_ruoli + $ruoli = ($record->rubricaRuoliAttivi ?? collect())->unique(fn($r) => $r->rubrica_id . '|' . $r->ruolo_standard); + foreach ($ruoli as $ruolo) { + $nome = $ruolo->contatto?->nome_completo ?? '—'; + $cf = $ruolo->contatto?->codice_fiscale ? " ({$ruolo->contatto->codice_fiscale})" : ''; + $roleLabel = strtolower(trim((string) $ruolo->ruolo_standard)); + + if (in_array($roleLabel, ['condomino', 'proprietario', 'comproprietario', 'nudo_proprietario', 'usufruttuario', 'usufrutto'], true)) { + $condomini[] = "C" . e($nome) . "" . e($cf); + } elseif (in_array($roleLabel, ['inquilino', 'locatario', 'conduttore'], true)) { + $inquilini[] = "I" . e($nome) . "" . e($cf); + } else { + $altri[] = "" . e(ucfirst($ruolo->ruolo_standard ?: 'Soggetto')) . "" . e($nome) . "" . e($cf); + } + } + + // 3. Fallback secondario su soggetti (anagrafiche dirette) + if (empty($condomini)) { + $anagrafiche = $record->anagrafiche ?? collect(); + foreach ($anagrafiche as $s) { + $nome = $s->ragione_sociale ?: trim($s->nome . ' ' . $s->cognome); + $cf = $s->codice_fiscale ? " ({$s->codice_fiscale})" : ''; + if ($nome !== '') { + $condomini[] = "C" . e($nome) . "" . e($cf); + } + } + } } if ($den !== '' && empty($condomini) && empty($inquilini) && empty($altri)) { diff --git a/app/Filament/Pages/Contabilita/CasseBancheRiepilogo.php b/app/Filament/Pages/Contabilita/CasseBancheRiepilogo.php index 7455b63..e03c422 100755 --- a/app/Filament/Pages/Contabilita/CasseBancheRiepilogo.php +++ b/app/Filament/Pages/Contabilita/CasseBancheRiepilogo.php @@ -1015,13 +1015,24 @@ public function riconciliaSpeseBancarieAutomaticamente(): void } if ($invoice) { - DB::table('contabilita_fatture_fornitori') - ->where('id', $invoice->id) - ->update([ - 'stato' => 'pagato', - 'data_pagamento' => $m->data, - 'movimento_pagamento_id' => $m->id, + try { + $paymentService = new \App\Modules\Contabilita\Services\PagamentoFornitorePrimaNotaService(); + $paymentService->generaPagamentoDaMovimento($user, $m, $fornIdMatched, [ + 'fattura_fornitore_id' => $invoice->id, + 'gestione_id' => $gestioneId, ]); + } catch (\Throwable $e) { + \Illuminate\Support\Facades\Log::error("Errore registrazione pagamento CBILL: " . $e->getMessage()); + DB::table('contabilita_fatture_fornitori') + ->where('id', $invoice->id) + ->update([ + 'stato' => 'pagato', + 'data_pagamento' => $m->data, + 'movimento_pagamento_id' => $m->id, + ]); + $m->registrazione_id = $invoice->registrazione_id; + $m->save(); + } $matchData = is_string($m->match_data) ? json_decode($m->match_data, true) : (is_array($m->match_data) ? $m->match_data : []); $matchData['riconciliato_operativo'] = true; @@ -1033,7 +1044,9 @@ public function riconciliaSpeseBancarieAutomaticamente(): void if ($fornIdMatched) { $m->fornitore_id = $fornIdMatched; } - $m->registrazione_id = $invoice->registrazione_id; + if (!$m->registrazione_id) { + $m->registrazione_id = $invoice->registrazione_id; + } $m->match_data = $matchData; if (Schema::hasColumn('contabilita_movimenti_banca', 'da_confermare')) { $m->da_confermare = false; diff --git a/app/Filament/Pages/Contabilita/FattureElettronicheP7mRicevute.php b/app/Filament/Pages/Contabilita/FattureElettronicheP7mRicevute.php index 9dc46a6..bb08212 100755 --- a/app/Filament/Pages/Contabilita/FattureElettronicheP7mRicevute.php +++ b/app/Filament/Pages/Contabilita/FattureElettronicheP7mRicevute.php @@ -10,7 +10,7 @@ use App\Models\Stabile; use App\Models\StgFatturaAde; use App\Models\User; -use App\Services\Consumi\AcquaPdfTextParser; +use App\Services\FatturaParserService; use App\Services\Consumi\ConsumiAcquaIngestionService; use App\Services\Consumi\ConsumiAcquaTariffeIngestionService; use App\Services\Documenti\PdfTextExtractionService; @@ -172,7 +172,40 @@ private function autoLinkWaterReadingsForInvoice(int $fatturaId, int $userId): a } if ($text !== '') { - $parsed = app(AcquaPdfTextParser::class)->parse($text); + $rawParsed = app(FatturaParserService::class)->parseText($text); + $parsed = [ + 'codici' => [ + 'utenza' => $rawParsed['codice_utenza'] ?? null, + 'cliente' => $rawParsed['codice_cliente'] ?? null, + 'contratto' => $rawParsed['numero_contratto'] ?? null, + ], + 'contatore' => [ + 'matricola' => $rawParsed['matricola_contatore'] ?? null, + ], + 'consumi' => [ + [ + 'valore' => $rawParsed['quantita_consumata'] ?? null, + 'dal' => $rawParsed['data_inizio_periodo'] ?? null, + 'al' => $rawParsed['data_fine_periodo'] ?? null, + ] + ], + 'generale' => [ + 'numero_fattura_pdf' => $rawParsed['numero_contratto'] ?? null, + ], + 'pagamento' => [ + 'cbill' => $rawParsed['cbill'] ?? null, + 'codice_avviso' => $rawParsed['codice_avviso'] ?? null, + ], + 'riepilogo_letture' => [ + [ + 'precedente' => $rawParsed['lettura_precedente'] ?? null, + 'attuale' => $rawParsed['lettura_attuale'] ?? null, + ] + ], + 'tariffe' => [], + 'quadro_dettaglio' => [], + 'iva' => [], + ]; } } diff --git a/app/Filament/Pages/Gescon/FornitoreScheda.php b/app/Filament/Pages/Gescon/FornitoreScheda.php index b7ea75a..9b7cca6 100755 --- a/app/Filament/Pages/Gescon/FornitoreScheda.php +++ b/app/Filament/Pages/Gescon/FornitoreScheda.php @@ -2382,7 +2382,19 @@ protected function getHeaderActions(): array Action::make('torna') ->label('Torna') ->icon('heroicon-o-arrow-left') - ->url(fn() => url()->previous()), + ->url(function() { + $candidate = request()->query('back'); + if (is_string($candidate) && trim($candidate) !== '') { + return $candidate; + } + $prevUrl = url()->previous(); + $currentUrl = request()->fullUrl(); + $isLivewire = request()->hasHeader('X-Livewire') || request()->filled('_token') || str_contains($prevUrl, '/livewire/message'); + if ($prevUrl && $prevUrl !== $currentUrl && !$isLivewire) { + return $prevUrl; + } + return '/admin-filament/fornitori'; + }), ]; } } diff --git a/app/Filament/Pages/Gescon/Ordinarie.php b/app/Filament/Pages/Gescon/Ordinarie.php index 36c3897..b405d9c 100755 --- a/app/Filament/Pages/Gescon/Ordinarie.php +++ b/app/Filament/Pages/Gescon/Ordinarie.php @@ -541,17 +541,28 @@ public function getLegacyGestioniHubProperty(): array $totals = collect(); if (Schema::connection('gescon_import')->hasTable('operazioni')) { + $cols = Schema::connection('gescon_import')->getColumnListing('operazioni'); + $hasCol = fn($c) => in_array($c, $cols, true); + + $gestioneCol = $hasCol('gestione') ? 'gestione' : 'null'; + $codSpeCol = $hasCol('cod_spesa') ? 'cod_spesa' : ($hasCol('cod_spe') ? 'cod_spe' : 'null'); + $impSpese = $hasCol('importo_spese') ? 'importo_spese' : "CASE WHEN $codSpeCol NOT LIKE 'INC%' THEN COALESCE(importo_euro, 0) ELSE 0 END"; + $impEntrate = $hasCol('importo_entrate') ? 'importo_entrate' : "CASE WHEN $codSpeCol LIKE 'INC%' THEN COALESCE(importo_euro, 0) ELSE 0 END"; + $impDebiti = $hasCol('importo_debiti') ? 'importo_debiti' : '0'; + $impCrediti = $hasCol('importo_crediti') ? 'importo_crediti' : '0'; + $totals = DB::connection('gescon_import') ->table('operazioni') ->where('cod_stabile', $legacyCode) - ->select('legacy_year', 'gestione') + ->select('legacy_year') + ->selectRaw("COALESCE($gestioneCol, 'O') as 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') + ->selectRaw("SUM(COALESCE($impSpese, 0)) as totale_spese") + ->selectRaw("SUM(COALESCE($impEntrate, 0)) as totale_entrate") + ->selectRaw("SUM(COALESCE($impDebiti, 0)) as totale_debiti") + ->selectRaw("SUM(COALESCE($impCrediti, 0)) as totale_crediti") + ->selectRaw('SUM(COALESCE(importo_euro, 0)) as totale_lordo') + ->groupBy('legacy_year', DB::raw("COALESCE($gestioneCol, 'O')")) ->get() ->groupBy(fn($row) => trim((string) ($row->legacy_year ?? ''))); } @@ -3864,6 +3875,54 @@ private function buildOperazioniQuery() } } + $cols = Schema::connection('gescon_import')->getColumnListing('operazioni'); + $hasCol = fn($c) => in_array($c, $cols, true); + + $idCol = $hasCol('id_operaz') ? 'id_operaz' : ($hasCol('id_operazione') ? 'id_operazione' : 'id'); + $nSpeCol = $hasCol('n_spe') ? 'n_spe' : ($hasCol('numero_spesa') ? 'numero_spesa' : 'id'); + $dtSpeCol = $hasCol('dt_spe') ? 'dt_spe' : ($hasCol('data_spesa') ? 'data_spesa' : 'created_at'); + $codSpeCol = $hasCol('cod_spe') ? 'cod_spe' : ($hasCol('cod_spesa') ? 'cod_spesa' : 'id'); + $benefCol = $hasCol('benef') ? 'benef' : ($hasCol('beneficiario') ? 'beneficiario' : 'id'); + $codForCol = $hasCol('cod_for') ? 'cod_for' : ($hasCol('fornitore') ? 'fornitore' : 'id'); + $numFatCol = $hasCol('num_fat') ? 'num_fat' : ($hasCol('numero_fattura') ? 'numero_fattura' : 'id'); + $dtFatCol = $hasCol('dt_fat') ? 'dt_fat' : ($hasCol('data_fattura') ? 'data_fattura' : 'created_at'); + $naturaCol = $hasCol('natura2') ? 'natura2' : ($hasCol('natura') ? 'natura' : 'id'); + + $impSpese = $hasCol('importo_spese') ? 'importo_spese' : 'null'; + $impEntrate = $hasCol('importo_entrate') ? 'importo_entrate' : 'null'; + $impDebiti = $hasCol('importo_debiti') ? 'importo_debiti' : 'null'; + $impCrediti = $hasCol('importo_crediti') ? 'importo_crediti' : 'null'; + $gestioneCol = $hasCol('gestione') ? 'gestione' : 'null'; + $nStraCol = $hasCol('n_stra') ? 'n_stra' : 'null'; + $feUidCol = $hasCol('fe_uid') ? 'fe_uid' : 'null'; + + $query->select([ + "$idCol as id_operaz", + "$nSpeCol as n_spe", + "$dtSpeCol as dt_spe", + "legacy_year", + DB::raw("COALESCE($gestioneCol, 'O') as gestione"), + DB::raw("COALESCE(compet, 'O') as compet"), + "$codSpeCol as cod_spe", + DB::raw("null as tabella"), + "$codForCol as cod_for", + "$benefCol as benef", + DB::raw("COALESCE($nStraCol, 0) as n_stra"), + DB::raw("null as protocollo_completo"), + DB::raw("COALESCE(importo_euro, 0) as importo_euro"), + DB::raw("COALESCE(importo_euro, 0) as importo"), + "$numFatCol as num_fat", + "$feUidCol as fe_uid", + DB::raw("null as voce_spesa_snapshot"), + DB::raw("null as fornitore_snapshot"), + DB::raw("COALESCE($impSpese, CASE WHEN $codSpeCol NOT LIKE 'INC%' THEN COALESCE(importo_euro, 0) ELSE 0 END) as importo_spese"), + DB::raw("COALESCE($impEntrate, CASE WHEN $codSpeCol LIKE 'INC%' THEN COALESCE(importo_euro, 0) ELSE 0 END) as importo_entrate"), + DB::raw("COALESCE($impDebiti, 0) as importo_debiti"), + DB::raw("COALESCE($impCrediti, 0) as importo_crediti"), + "$naturaCol as natura2", + 'note', + ]); + return $query; } diff --git a/app/Filament/Pages/Strumenti/DocumentiArchivio.php b/app/Filament/Pages/Strumenti/DocumentiArchivio.php index 7ad4448..44a5c3b 100755 --- a/app/Filament/Pages/Strumenti/DocumentiArchivio.php +++ b/app/Filament/Pages/Strumenti/DocumentiArchivio.php @@ -3407,7 +3407,7 @@ private function createDocumentoFromForm(array $data, bool $isDemo): void $resolvedFolderId = $this->resolveDefaultFolderIdForDocument($stabileId, $categoria, $year, $data['cartella_archivio_id'] ?? null); // Rinomina il file su storage in modo ordinato - $finalDir = 'documenti/stabili/ID-' . $stabileId . '/' . $year; + $finalDir = $this->resolveCloudStoragePath($stabileId, $categoria); $finalName = $this->buildDocumentStorageFilename($categoria, $dataDocumento, $protocollo, (string) ($data['fornitore'] ?? ''), $titolo); $finalPath = $finalDir . '/' . $finalName; @@ -3559,7 +3559,7 @@ private function createDocumentoDemoContrattoAscensori(int $stabileId, string $f $dompdf->render(); $pdfBytes = $dompdf->output(); - $finalDir = 'documenti/stabili/ID-' . $stabileId . '/' . $year; + $finalDir = $this->resolveCloudStoragePath($stabileId, 'contratto'); $finalName = $protocollo . '_contratto_ascensori_2024.pdf'; $finalPath = $finalDir . '/' . $finalName; @@ -4009,4 +4009,32 @@ public function importPhysicalArchiveFromXml(string $xmlContent): int return $insertedCount; } + + private function resolveCloudStoragePath(int $stabileId, string $categoria): string + { + $adminCode = 'ZXNRE9CZ'; + $stabileCode = '0021'; + + $stabile = \App\Models\Stabile::find($stabileId); + if ($stabile) { + $stabileCode = $stabile->codice_stabile ?: $stabile->cod_stabile ?: '0021'; + } + + $categoria = strtolower(trim($categoria)); + if ($categoria === 'fattura') { + $categoria = 'fatture'; + } elseif ($categoria === 'contratto') { + $categoria = 'contratti'; + } elseif ($categoria === 'assicurazione' || $categoria === 'pratica_assicurativa') { + $categoria = 'assicurazioni'; + } elseif ($categoria === 'legale' || $categoria === 'pratica_legale') { + $categoria = 'legale'; + } elseif ($categoria === 'video') { + $categoria = 'video'; + } else { + $categoria = 'documenti'; + } + + return "{$adminCode}/{$stabileCode}/{$categoria}"; + } } diff --git a/app/Filament/Pages/Supporto/AggiornamentoLauncher.php b/app/Filament/Pages/Supporto/AggiornamentoLauncher.php index bc1e539..f9c563f 100755 --- a/app/Filament/Pages/Supporto/AggiornamentoLauncher.php +++ b/app/Filament/Pages/Supporto/AggiornamentoLauncher.php @@ -19,4 +19,66 @@ class AggiornamentoLauncher extends Modifiche protected static ?string $slug = 'supporto/aggiornamento-nodo'; protected string $view = 'filament.pages.supporto.aggiornamento-launcher'; + + public function simulaChiamataInArrivo(): void + { + \App\Models\RubricaUniversale::updateOrCreate( + ['telefono' => '3206996068'], + [ + 'nome' => 'Annamaria', + 'cognome' => 'ROSCIOLI', + 'tipo_soggetto' => 'privato', + ] + ); + + \App\Models\CommunicationMessage::create([ + 'channel' => 'smdr', + 'direction' => 'inbound', + 'phone_number' => '3206996068', + 'target_extension' => '601', + 'protocol_number' => 'CTI-MOCK-' . now()->timestamp, + 'received_at' => now(), + 'status' => 'ringing', + 'message_text' => 'Chiamata simulata per test centralino', + 'metadata' => [ + 'event_type' => 'ringing', + 'received_at' => now()->toIso8601String(), + ] + ]); + + \Filament\Notifications\Notification::make() + ->title('Chiamata simulata inviata!') + ->body('La chiamata da Annamaria ROSCIOLI comparirà nella barra in alto per i prossimi 8 minuti.') + ->success() + ->send(); + } + + public function mount(): void + { + parent::mount(); + + $targetFile = '/mnt/gescon-archives/gescon/dbc/Stabili.mdb'; + if (!file_exists($targetFile)) { + // Prova a ripristinare il mount point in sola lettura + \Illuminate\Support\Facades\Process::run('mount /mnt/gescon-archives'); + if (!file_exists($targetFile)) { + // Se non è andato a buon fine, proviamo con sudo + \Illuminate\Support\Facades\Process::run('sudo mount /mnt/gescon-archives'); + } + + if (file_exists($targetFile)) { + \Filament\Notifications\Notification::make() + ->title('Auto-Mount Ripristinato!') + ->body('Il volume Samba /mnt/gescon-archives è stato ricollegato con successo in sola lettura.') + ->success() + ->send(); + } else { + \Filament\Notifications\Notification::make() + ->title('Verifica Archivio Fallita') + ->body('Il mount point /mnt/gescon-archives risulta scollegato e il ripristino automatico non è riuscito.') + ->danger() + ->send(); + } + } + } } diff --git a/app/Filament/Pages/Supporto/RichiestaHelp.php b/app/Filament/Pages/Supporto/RichiestaHelp.php index 1a0c33d..96a7a14 100755 --- a/app/Filament/Pages/Supporto/RichiestaHelp.php +++ b/app/Filament/Pages/Supporto/RichiestaHelp.php @@ -97,7 +97,18 @@ public function form(Schema $schema): Schema ->label('Foto / allegati') ->helperText('Da smartphone puoi scattare una foto al momento o selezionarla dalla galleria.') ->disk('public') - ->directory('ticket-allegati') + ->directory(function () { + $user = \Illuminate\Support\Facades\Auth::user(); + $stabileId = $user ? \App\Support\StabileContext::resolveActiveStabileId($user) : null; + $stabileCode = '0021'; + if ($stabileId) { + $stabile = \App\Models\Stabile::find($stabileId); + if ($stabile) { + $stabileCode = $stabile->codice_stabile ?: $stabile->cod_stabile ?: '0021'; + } + } + return "ZXNRE9CZ/{$stabileCode}/tickets"; + }) ->multiple() ->preserveFilenames() ->acceptedFileTypes([ diff --git a/app/Filament/Pages/Supporto/TicketAcqua.php b/app/Filament/Pages/Supporto/TicketAcqua.php index 3588bcc..f524b6f 100755 --- a/app/Filament/Pages/Supporto/TicketAcqua.php +++ b/app/Filament/Pages/Supporto/TicketAcqua.php @@ -1,6 +1,8 @@ */ public array $nuoviAllegati = []; @@ -626,6 +632,9 @@ public function salvaSinistroAssicurativo(): void 'insuranceClosedAt' => ['nullable', 'date'], 'insuranceNextAction' => ['nullable', 'string', 'max:1000'], 'insuranceNotes' => ['nullable', 'string', 'max:4000'], + 'insuranceEstimatedAmount' => ['nullable', 'numeric', 'min:0'], + 'legalPracticeLawyer' => ['nullable', 'string', 'max:255'], + 'legalPracticeNotes' => ['nullable', 'string', 'max:4000'], ]); $claimMetadata = array_merge((array) ($ticket->insuranceClaim?->metadata ?? []), [ @@ -636,6 +645,10 @@ public function salvaSinistroAssicurativo(): void 'appointment_at' => $this->insuranceAppointmentAt ?: null, 'closed_at' => $this->insuranceClosedAt ?: null, 'next_action' => filled($this->insuranceNextAction) ? trim((string) $this->insuranceNextAction) : null, + 'estimated_amount' => $this->insuranceEstimatedAmount, + 'legal_practice_active'=> $this->legalPracticeActive, + 'legal_practice_lawyer'=> $this->legalPracticeLawyer, + 'legal_practice_notes' => $this->legalPracticeNotes, ]); $claim = InsuranceClaim::query()->updateOrCreate( @@ -652,6 +665,45 @@ public function salvaSinistroAssicurativo(): void ] ); + if ($this->insuranceCreateDoubleEntry && $this->insuranceEstimatedAmount > 0) { + $gestione = DB::table('gestioni_contabili') + ->where('stabile_id', $ticket->stabile_id) + ->where('stato', 'attiva') + ->first() ?: DB::table('gestioni_contabili') + ->where('stabile_id', $ticket->stabile_id) + ->orderByDesc('anno_gestione') + ->first(); + + if ($gestione) { + $claimNum = $this->insuranceClaimNumber ?: $claim->id; + // Debit: Credito vs Assicurazione + DB::table('operazioni_contabili')->insert([ + 'gestione_id' => $gestione->id, + 'descrizione' => "Stima Sinistro Assicurativo #" . $claimNum . " (Credito vs Assicurazione)", + 'conto_contabile' => 'ATT_CRED_ASS', + 'dare' => $this->insuranceEstimatedAmount, + 'avere' => 0, + 'data_operazione' => now()->toDateString(), + 'stato_operazione' => 'confermata', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + // Credit: Fondo Sospeso / Riserva Sinistri + DB::table('operazioni_contabili')->insert([ + 'gestione_id' => $gestione->id, + 'descrizione' => "Stima Sinistro Assicurativo #" . $claimNum . " (Fondo Sospeso)", + 'conto_contabile' => 'PASS_FND_SOSP', + 'dare' => 0, + 'avere' => $this->insuranceEstimatedAmount, + 'data_operazione' => now()->toDateString(), + 'stato_operazione' => 'confermata', + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + } + $ticket->messages()->create([ 'user_id' => Auth::id(), 'messaggio' => 'Sinistro assicurativo aggiornato. Riferimento sinistro: ' . ($claim->claim_number ?: 'da definire') . ' · Stato: ' . ($claim->status ?: 'aperta'), @@ -1352,6 +1404,11 @@ private function syncSelectedTicketState(): void $this->insuranceClosedAt = null; $this->insuranceNextAction = null; $this->insuranceNotes = null; + $this->insuranceEstimatedAmount = null; + $this->insuranceCreateDoubleEntry = false; + $this->legalPracticeActive = false; + $this->legalPracticeLawyer = null; + $this->legalPracticeNotes = null; return; } @@ -1379,6 +1436,11 @@ private function syncSelectedTicketState(): void $this->insuranceClosedAt = filled(data_get($ticket->insuranceClaim?->metadata, 'closed_at')) ? (string) data_get($ticket->insuranceClaim?->metadata, 'closed_at') : null; $this->insuranceNextAction = (string) (data_get($ticket->insuranceClaim?->metadata, 'next_action') ?? ''); $this->insuranceNotes = (string) ($ticket->insuranceClaim?->notes ?? ''); + $this->insuranceEstimatedAmount = data_get($ticket->insuranceClaim?->metadata, 'estimated_amount'); + $this->insuranceCreateDoubleEntry = false; + $this->legalPracticeActive = (bool) data_get($ticket->insuranceClaim?->metadata, 'legal_practice_active', false); + $this->legalPracticeLawyer = data_get($ticket->insuranceClaim?->metadata, 'legal_practice_lawyer'); + $this->legalPracticeNotes = data_get($ticket->insuranceClaim?->metadata, 'legal_practice_notes'); $this->syncTicketAttachmentsArchive($ticket); } diff --git a/app/Filament/Pages/UnitaImmobiliarePage.php b/app/Filament/Pages/UnitaImmobiliarePage.php index 81ff4e6..70c7ce0 100755 --- a/app/Filament/Pages/UnitaImmobiliarePage.php +++ b/app/Filament/Pages/UnitaImmobiliarePage.php @@ -104,6 +104,8 @@ public static function canAccess(): bool public array $relazioniPerTipo = []; + public array $canaliComunicazione = []; + public array $nominativiStorici = []; public array $recapitiServizio = []; @@ -124,12 +126,27 @@ public static function canAccess(): bool public array $rateEmessePerCategoria = []; + public ?int $inquilinoAnagraficaId = null; + public ?string $inquilinoDataInizio = null; + public ?string $inquilinoDataFine = null; + public float $inquilinoPercentualeSpesa = 100.000; + + public array $pianoContiGerarchico = []; + public array $millesimiInputs = []; + public array $preventivoInputs = []; + public array $consuntivoInputs = []; + /** Estratto conto compatto: rate emesse */ public array $estrattoCompattoRateRows = []; /** Estratto conto compatto: incassi */ public array $estrattoCompattoIncassi = []; + public array $estrattoRateRowsProprietario = []; + public array $estrattoRateRowsInquilino = []; + public array $estrattoIncassiProprietario = []; + public array $estrattoIncassiInquilino = []; + /** Conguagli iniziali (legacy) */ public array $estrattoConguagliIniziali = []; @@ -146,11 +163,23 @@ public static function canAccess(): bool public function getBackUrl(): ?string { $candidate = request()->query('back'); - if (! is_string($candidate) || trim($candidate) === '') { - return null; + if (is_string($candidate) && trim($candidate) !== '') { + return $candidate; } - return $candidate; + $prevUrl = url()->previous(); + $currentUrl = request()->fullUrl(); + $isLivewire = request()->hasHeader('X-Livewire') || request()->filled('_token') || str_contains($prevUrl, '/livewire/message'); + + if ($prevUrl && $prevUrl !== $currentUrl && !$isLivewire) { + return $prevUrl; + } + + try { + return \App\Filament\Pages\Condomini\UnitaImmobiliariArchivio::getUrl(panel: 'admin-filament'); + } catch (\Throwable $e) { + return '/admin-filament/condomini'; + } } public function mount(): void @@ -213,6 +242,7 @@ public function dehydrate(): void $this->millesimiPerTabella = []; $this->dirittiProprieta = []; $this->relazioniPerTipo = []; + $this->canaliComunicazione = []; $this->nominativiStorici = []; $this->recapitiServizio = []; $this->acquaLettureStorico = []; @@ -678,12 +708,12 @@ protected function refreshUnitaOptions(): void if (DbSchema::hasTable('proprieta')) { try { $ownerByUnita = Proprieta::query() - ->select('unita_immobiliare_id', 'soggetto_id') + ->select('unita_immobiliare_id', 'anagrafica_id') ->whereIn('unita_immobiliare_id', $unita->pluck('id')) ->get() ->groupBy('unita_immobiliare_id') ->map(function ($rows) { - $ids = $rows->pluck('soggetto_id') + $ids = $rows->pluck('anagrafica_id') ->filter(fn($v) => is_numeric($v) && (int) $v > 0) ->map(fn($v) => (int) $v) ->values() @@ -693,7 +723,7 @@ protected function refreshUnitaOptions(): void return null; } - $nome = DB::table('soggetti') + $nome = DB::table('anagrafiche') ->whereIn('id', $ids) ->select('id', 'ragione_sociale', 'nome', 'cognome') ->get() @@ -904,6 +934,7 @@ protected function loadUnita(): void $this->hydrateMillesimi(); $this->hydrateDiritti(); $this->hydrateRelazioni(); + $this->popolaCanaliComunicazione(); $this->hydrateNominativiStorici(); $this->hydrateRecapitiServizio(); $this->hydrateRateEmesse(); @@ -912,6 +943,8 @@ protected function loadUnita(): void $this->hydrateAcquaStorico(); $this->hydrateRipartizioni(); $this->hydratePreventivi(); + $this->hydrateInquilinoAttivo(); + $this->hydratePianoContiGerarchico(); } $this->tab = 'riepilogo'; @@ -1309,7 +1342,7 @@ protected function hydrateRateEmesse(): void $ownerIds = Proprieta::query() ->where('unita_immobiliare_id', $this->unita->id) ->whereIn('tipo_diritto', $this->ownershipTipoDiritti()) - ->pluck('soggetto_id') + ->pluck('anagrafica_id') ->filter(fn($v) => is_numeric($v) && (int) $v > 0) ->map(fn($v) => (int) $v) ->unique() @@ -1475,10 +1508,12 @@ protected function hydrateEstrattoCompatto(): void $this->estrattoCompattoIncassi = []; if (! DbSchema::hasTable('rate_emesse')) { + $this->splitEstrattoContoProperties(); return; } if (! $this->unita) { + $this->splitEstrattoContoProperties(); return; } @@ -1497,7 +1532,7 @@ protected function hydrateEstrattoCompatto(): void if ($legacyRateRows !== []) { $this->estrattoCompattoRateRows = $legacyRateRows; $this->estrattoCompattoIncassi = $this->loadLegacyIncassiForUnita(false, $visibleYears); - + $this->splitEstrattoContoProperties(); return; } @@ -1532,12 +1567,22 @@ protected function hydrateEstrattoCompatto(): void })->all(); $this->estrattoCompattoIncassi = $this->loadIncassiForUnita($rateRows, $visibleYears); - + $this->splitEstrattoContoProperties(); return; } $this->estrattoCompattoRateRows = []; $this->estrattoCompattoIncassi = []; + $this->splitEstrattoContoProperties(); + } + + private function splitEstrattoContoProperties(): void + { + $this->estrattoRateRowsProprietario = collect($this->estrattoCompattoRateRows)->filter(fn($r) => strtoupper(trim((string)($r['tipo'] ?? ''))) !== 'I')->values()->all(); + $this->estrattoRateRowsInquilino = collect($this->estrattoCompattoRateRows)->filter(fn($r) => strtoupper(trim((string)($r['tipo'] ?? ''))) === 'I')->values()->all(); + + $this->estrattoIncassiProprietario = collect($this->estrattoCompattoIncassi)->filter(fn($i) => strtoupper(trim((string)($i['tipo'] ?? ''))) !== 'I')->values()->all(); + $this->estrattoIncassiInquilino = collect($this->estrattoCompattoIncassi)->filter(fn($i) => strtoupper(trim((string)($i['tipo'] ?? ''))) === 'I')->values()->all(); } protected function hydrateConguagliIniziali(): void @@ -1743,27 +1788,19 @@ private function loadIncassiForUnita($rateItems, array $visibleYears = []): arra } }); - $user = Auth::user(); - $annoGestione = $user instanceof User ? AnnoGestioneContext::resolveActiveAnno($user) : null; - if ($visibleYears === [] && $annoGestione) { - $annoCol = DbSchema::hasColumn('incassi', 'anno_rif') - ? 'anno_rif' - : (DbSchema::hasColumn('incassi', 'anno') ? 'anno' : null); - $annoShort = (int) substr((string) $annoGestione, -2); - - $incassiQuery->where(function ($q) use ($annoCol, $annoGestione, $annoShort): void { - if ($annoCol) { - $q->where($annoCol, (string) $annoGestione) - ->orWhere($annoCol, (string) $annoShort) - ->orWhere($annoCol, str_pad((string) $annoShort, 2, '0', STR_PAD_LEFT)); - } - if (DbSchema::hasColumn('incassi', 'dt_empag')) { - $q->orWhereYear('dt_empag', (int) $annoGestione); - } - if (DbSchema::hasColumn('incassi', 'data_pagamento')) { - $q->orWhereYear('data_pagamento', (int) $annoGestione); - } - }); + $annoCol = DbSchema::hasColumn('incassi', 'anno_rif') + ? 'anno_rif' + : (DbSchema::hasColumn('incassi', 'anno_ref') ? 'anno_ref' : (DbSchema::hasColumn('incassi', 'anno') ? 'anno' : null)); + + if ($annoCol) { + $incassiQuery->where($annoCol, '=', '2026'); + } + + if (DbSchema::hasColumn('incassi', 'dt_empag')) { + $incassiQuery->orWhereYear('dt_empag', 2026); + } + if (DbSchema::hasColumn('incassi', 'data_pagamento')) { + $incassiQuery->orWhereYear('data_pagamento', 2026); } $orderCol = DbSchema::hasColumn('incassi', 'dt_empag') @@ -1783,7 +1820,7 @@ private function loadIncassiForUnita($rateItems, array $visibleYears = []): arra } return $incassi->map(function (Incasso $i) use ($codCol, $tipoCol): array { - $importoRaw = $i->importo_pagato_euro ?? $i->importo_pagato ?? $i->importo_euro ?? $i->importo ?? 0; + $importoRaw = $i->importo_pagato_euro ?? $i->importo_euro ?? 0; $importo = $this->normalizeLegacyIncassoImporto(is_numeric($importoRaw) ? (float) $importoRaw : 0.0); $dt = null; @@ -1833,10 +1870,12 @@ private function loadLegacyRateRowsForUnita(array $visibleYears = []): array return []; } + $annoAttivo = \App\Support\AnnoGestioneContext::resolveActiveAnno(Auth::user()); $rows = DB::connection('gescon_import') ->table('rate') ->where('cod_stabile', $codStabile) ->whereIn('cod_cond', $legacyCondIds) + ->where('legacy_year', $annoAttivo) ->orderBy('data_emissione') ->orderBy('id') ->limit(5000) @@ -1852,9 +1891,7 @@ private function loadLegacyRateRowsForUnita(array $visibleYears = []): array $year = null; } - $dovuto = is_numeric($row->importo_euro ?? null) - ? (float) $row->importo_euro - : (is_numeric($row->importo ?? null) ? (float) $row->importo : 0.0); + $dovuto = is_numeric($row->importo_euro ?? null) ? (float) $row->importo_euro : 0.0; $pagato = is_numeric($row->importo_pagato ?? null) ? (float) $row->importo_pagato @@ -1902,10 +1939,12 @@ private function loadLegacyEmissioniDettaglioRowsForUnita(array $visibleYears = $codStabile, ); + $annoAttivo = \App\Support\AnnoGestioneContext::resolveActiveAnno(Auth::user()); $rows = DB::connection('gescon_import') ->table('rate_emissioni_dettaglio') ->where('cod_stabile', $codStabile) ->whereIn('cod_cond', $legacyCondIds) + ->where('legacy_year', $annoAttivo) ->orderBy('data_emissione') ->orderBy('numero_emissione') ->orderBy('numero_ricevuta') @@ -1913,6 +1952,7 @@ private function loadLegacyEmissioniDettaglioRowsForUnita(array $visibleYears = ->limit(5000) ->get(); + $mapped = $rows->map(function ($row): array { $gestioneLabel = trim((string) ($row->anno_gestione ?? '')); $year = $this->extractEffectiveGestioneEndYear($gestioneLabel); @@ -1927,9 +1967,7 @@ private function loadLegacyEmissioniDettaglioRowsForUnita(array $visibleYears = } } - $dovuto = is_numeric($row->importo_dovuto_euro ?? null) - ? (float) $row->importo_dovuto_euro - : (is_numeric($row->importo_dovuto ?? null) ? (float) $row->importo_dovuto : 0.0); + $dovuto = is_numeric($row->importo_dovuto_euro ?? null) ? (float) $row->importo_dovuto_euro : 0.0; $pagato = is_numeric($row->gia_pagato ?? null) ? (float) $row->gia_pagato @@ -2795,6 +2833,35 @@ protected function hydrateRelazioni(): void })->values(); } + if ($proprietari->isNotEmpty() && $inquilini->isNotEmpty()) { + $proprietariCf = $proprietari + ->pluck('codice_fiscale') + ->filter() + ->map(fn($cf) => strtoupper(trim((string) $cf))) + ->filter(fn($cf) => $cf !== '') + ->values() + ->all(); + $proprietariNomi = $proprietari + ->pluck('nome') + ->filter() + ->map(fn($n) => strtoupper(trim((string) $n))) + ->filter(fn($n) => $n !== '') + ->values() + ->all(); + + $inquilini = $inquilini->reject(function (array $i) use ($proprietariCf, $proprietariNomi): bool { + $cf = strtoupper(trim((string) ($i['codice_fiscale'] ?? ''))); + if ($cf !== '' && in_array($cf, $proprietariCf, true)) { + return true; + } + $nome = strtoupper(trim((string) ($i['nome'] ?? ''))); + if ($nome !== '' && in_array($nome, $proprietariNomi, true)) { + return true; + } + return false; + })->values(); + } + $altri = $relazioniMapped ->filter(function ($rel) { return $rel['attivo'] && ( @@ -3639,4 +3706,277 @@ protected function hydratePreventivi(): void $this->totaliPerGestione = $totaliGestione; } + + protected function hydrateInquilinoAttivo(): void + { + $today = now()->toDateString(); + + $activeInq = DB::table('unita_anagrafica_periodo') + ->where('unita_immobiliare_id', $this->unita->id) + ->where('ruolo_occupazione', 'inquilino') + ->where('data_inizio', '<=', $today) + ->where(function ($q) use ($today) { + $q->whereNull('data_fine')->orWhere('data_fine', '>=', $today); + }) + ->first(); + + if ($activeInq) { + $this->inquilinoAnagraficaId = $activeInq->anagrafica_id; + $this->inquilinoDataInizio = $activeInq->data_inizio; + $this->inquilinoDataFine = $activeInq->data_fine; + $this->inquilinoPercentualeSpesa = (float) $activeInq->percentuale_spesa; + } else { + $this->inquilinoAnagraficaId = null; + $this->inquilinoDataInizio = $today; + $this->inquilinoDataFine = null; + $this->inquilinoPercentualeSpesa = 100.0; + } + } + + public function saveInquilino(): void + { + $this->validate([ + 'inquilinoAnagraficaId' => 'nullable|integer|exists:anagrafiche,id', + 'inquilinoDataInizio' => 'required|date', + 'inquilinoDataFine' => 'nullable|date|after_or_equal:inquilinoDataInizio', + 'inquilinoPercentualeSpesa' => 'required|numeric|between:0,100', + ]); + + $today = now()->toDateString(); + + // Close current active inquilino if none is selected + if ($this->inquilinoAnagraficaId === null) { + DB::table('unita_anagrafica_periodo') + ->where('unita_immobiliare_id', $this->unita->id) + ->where('ruolo_occupazione', 'inquilino') + ->where('data_inizio', '<=', $today) + ->where(function ($q) use ($today) { + $q->whereNull('data_fine')->orWhere('data_fine', '>=', $today); + }) + ->update(['data_fine' => Carbon::parse($this->inquilinoDataInizio)->subDay()->toDateString()]); + } else { + // Update or Insert active inquilino + DB::table('unita_anagrafica_periodo')->updateOrInsert( + [ + 'unita_immobiliare_id' => $this->unita->id, + 'ruolo_occupazione' => 'inquilino', + 'anagrafica_id' => $this->inquilinoAnagraficaId, + ], + [ + 'data_inizio' => $this->inquilinoDataInizio, + 'data_fine' => $this->inquilinoDataFine, + 'percentuale_spesa' => $this->inquilinoPercentualeSpesa, + 'updated_at' => now(), + 'created_at' => now() + ] + ); + } + + $this->loadUnita(); + + \Filament\Notifications\Notification::make() + ->title('Inquilino salvato correttamente') + ->success() + ->send(); + } + + public function hydratePianoContiGerarchico(): void + { + $this->pianoContiGerarchico = []; + if (!$this->unita) { + return; + } + + $today = now()->toDateString(); + + $tabelleAssegnate = \App\Models\TabellaMillesimale::query() + ->where('stabile_id', $this->stabileId) + ->whereHas('dettagliMillesimi', function ($q) { + $q->where('unita_immobiliare_id', $this->unita->id); + }) + ->get(); + + foreach ($tabelleAssegnate as $tabella) { + $detMillesimi = $tabella->dettagliMillesimi() + ->where('unita_immobiliare_id', $this->unita->id) + ->first(); + + $millesimiValue = $detMillesimi ? (float) $detMillesimi->millesimi : 0.0; + $tabellaId = $tabella->id; + + $this->millesimiInputs[$tabellaId] = $millesimiValue; + + $vociSpesa = \App\Models\VoceSpesa::query() + ->where('tabella_millesimale_default_id', $tabellaId) + ->orderBy('ordinamento') + ->orderBy('codice') + ->get(); + + $subAccounts = []; + foreach ($vociSpesa as $voce) { + $importoRow = DB::table('dettaglio_importi_tabella') + ->where('unita_immobiliare_id', $this->unita->id) + ->where('tabella_millesimale_id', $tabellaId) + ->first(); + + $prev = $importoRow ? (float) $importoRow->prev_euro : 0.0; + $cons = $importoRow ? (float) $importoRow->cons_euro : 0.0; + + $rowKey = $tabellaId . '_' . $voce->id; + + $this->preventivoInputs[$rowKey] = $prev; + $this->consuntivoInputs[$rowKey] = $cons; + + $subAccounts[] = [ + 'voce_id' => $voce->id, + 'codice' => $voce->codice, + 'descrizione' => $voce->descrizione, + 'tipo_gestione' => $voce->tipo_gestione, + 'row_key' => $rowKey, + ]; + } + + $this->pianoContiGerarchico[] = [ + 'tabella_id' => $tabellaId, + 'codice_tabella' => $tabella->codice_tabella ?: 'TAB', + 'denominazione' => $tabella->denominazione, + 'millesimi' => $millesimiValue, + 'sottoconti' => $subAccounts, + ]; + } + } + + public function savePianoConti(): void + { + foreach ($this->millesimiInputs as $tabellaId => $value) { + DB::table('dettaglio_millesimi')->updateOrInsert( + [ + 'unita_immobiliare_id' => $this->unita->id, + 'tabella_millesimale_id' => $tabellaId, + ], + [ + 'millesimi' => $value, + 'updated_at' => now(), + 'created_at' => now(), + ] + ); + + DB::table('dettaglio_importi_tabella') + ->where('unita_immobiliare_id', $this->unita->id) + ->where('tabella_millesimale_id', $tabellaId) + ->update(['millesimi' => $value]); + } + + foreach ($this->preventivoInputs as $rowKey => $value) { + [$tabellaId, $voceId] = explode('_', $rowKey); + $consValue = $this->consuntivoInputs[$rowKey] ?? 0.0; + + DB::table('dettaglio_importi_tabella')->updateOrInsert( + [ + 'unita_immobiliare_id' => $this->unita->id, + 'tabella_millesimale_id' => $tabellaId, + 'ruolo_legacy' => 'C', + ], + [ + 'prev_euro' => $value, + 'cons_euro' => $consValue, + 'millesimi' => $this->millesimiInputs[$tabellaId] ?? 0.0, + 'updated_at' => now(), + 'created_at' => now(), + ] + ); + } + + $this->loadUnita(); + + \Filament\Notifications\Notification::make() + ->title('Valori salvati con successo') + ->success() + ->send(); + } + + protected function popolaCanaliComunicazione(): void + { + $this->canaliComunicazione = []; + $comproprietari = $this->relazioniPerTipo['proprietari'] ?? []; + + $stabile = \App\Models\Stabile::find($this->stabileId); + $adminCode = $stabile?->amministratore ? trim((string)$stabile->amministratore->codice_amministratore) : 'ADM'; + $adminFolder = "AMMINISTRATORE_" . preg_replace('/[^a-zA-Z0-9_]/', '', str_replace(' ', '_', $adminCode)); + $stabileCode = $stabile ? trim((string)$stabile->codice_stabile) : 'STB'; + $stabileName = $stabile ? trim((string)$stabile->denominazione) : 'STABILE'; + $stabileFolder = "STABILE_" . preg_replace('/[^a-zA-Z0-9_]/', '', str_replace(' ', '_', "{$stabileCode}_{$stabileName}")); + $adminFolder = preg_replace('/_+/', '_', $adminFolder); + $stabileFolder = preg_replace('/_+/', '_', $stabileFolder); + $overlayDir = "{$adminFolder}/{$stabileFolder}/catasto/anno_2026"; + $overlayFile = "{$overlayDir}/canali_invio_unita_{$this->unitaId}.json"; + + $overlayData = []; + if (\Illuminate\Support\Facades\Storage::disk('public')->exists($overlayFile)) { + $overlayData = json_decode(\Illuminate\Support\Facades\Storage::disk('public')->get($overlayFile), true) ?: []; + } + + foreach ($comproprietari as $p) { + $personaId = $p['persona_id'] ?? null; + if ($personaId) { + $persona = DB::table('persone')->find($personaId); + if ($persona) { + $conv = $overlayData[$personaId]['convocazione'] ?? $persona->canale_convocazione ?: 'Raccomandata AR'; + $verb = $overlayData[$personaId]['verbali'] ?? $persona->canale_verbali ?: 'Raccomandata AR'; + $soll = $overlayData[$personaId]['solleciti'] ?? $persona->canale_solleciti ?: 'PEC'; + + $this->canaliComunicazione[$personaId] = [ + 'id' => $personaId, + 'nominativo' => trim($persona->cognome . ' ' . $persona->nome) ?: $p['nome'], + 'codice_fiscale' => $persona->codice_fiscale, + 'convocazione' => $conv, + 'verbali' => $verb, + 'solleciti' => $soll, + ]; + } + } + } + } + + public function salvaCanaleComunicazione(int $personaId, string $campo, string $valore): void + { + $fieldMap = [ + 'convocazione' => 'canale_convocazione', + 'verbali' => 'canale_verbali', + 'solleciti' => 'canale_solleciti', + ]; + + if (isset($fieldMap[$campo])) { + DB::table('persone') + ->where('id', $personaId) + ->update([ + $fieldMap[$campo] => $valore, + 'updated_at' => now(), + ]); + + if (isset($this->canaliComunicazione[$personaId])) { + $this->canaliComunicazione[$personaId][$campo] = $valore; + } + + $stabile = \App\Models\Stabile::find($this->stabileId); + $adminCode = $stabile?->amministratore ? trim((string)$stabile->amministratore->codice_amministratore) : 'ADM'; + $adminFolder = "AMMINISTRATORE_" . preg_replace('/[^a-zA-Z0-9_]/', '', str_replace(' ', '_', $adminCode)); + $stabileCode = $stabile ? trim((string)$stabile->codice_stabile) : 'STB'; + $stabileName = $stabile ? trim((string)$stabile->denominazione) : 'STABILE'; + $stabileFolder = "STABILE_" . preg_replace('/[^a-zA-Z0-9_]/', '', str_replace(' ', '_', "{$stabileCode}_{$stabileName}")); + $adminFolder = preg_replace('/_+/', '_', $adminFolder); + $stabileFolder = preg_replace('/_+/', '_', $stabileFolder); + $overlayDir = "{$adminFolder}/{$stabileFolder}/catasto/anno_2026"; + + \Illuminate\Support\Facades\Storage::disk('public')->makeDirectory($overlayDir); + $overlayFile = "{$overlayDir}/canali_invio_unita_{$this->unitaId}.json"; + + \Illuminate\Support\Facades\Storage::disk('public')->put($overlayFile, json_encode($this->canaliComunicazione, JSON_PRETTY_PRINT)); + + \Filament\Notifications\Notification::make() + ->title('Canale aggiornato ed archiviato') + ->success() + ->send(); + } + } } diff --git a/app/Http/Controllers/Condomino/AssembleaConvocazioneController.php b/app/Http/Controllers/Condomino/AssembleaConvocazioneController.php index 9d945b7..82dd08c 100644 --- a/app/Http/Controllers/Condomino/AssembleaConvocazioneController.php +++ b/app/Http/Controllers/Condomino/AssembleaConvocazioneController.php @@ -66,21 +66,115 @@ public function show(string $token) )); } - public function confermaRicezione(string $token) + public function confermaRicezione(Request $request, string $token) { $convocazione = Convocazione::where('token_accesso', $token)->firstOrFail(); + $signatureData = $request->input('firma_dati'); + $signaturePath = null; + + if ($signatureData && preg_match('/^data:image\/(\w+);base64,/', $signatureData, $type)) { + $data = substr($signatureData, strpos($signatureData, ',') + 1); + $data = base64_decode($data); + if ($data !== false) { + $signaturePath = 'firme/' . $token . '.png'; + \Illuminate\Support\Facades\Storage::disk('public')->put($signaturePath, $data); + } + } + $convocazione->update([ 'ricezione_confermata_at' => now(), + 'ip_conferma' => $request->ip(), + 'user_agent_conferma' => $request->userAgent(), + 'firma_ricezione_percorso' => $signaturePath ? 'storage/' . $signaturePath : null, + 'stato_ricezione' => 'confermato_online', ]); return response()->json([ 'success' => true, - 'message' => 'Ricezione confermata con successo!', + 'message' => 'Firma e ricezione registrate con successo!', 'ricezione_confermata_at' => $convocazione->ricezione_confermata_at->format('d/m/Y H:i'), ]); } + public function downloadZip(string $token) + { + $convocazione = Convocazione::where('token_accesso', $token) + ->with(['assemblea']) + ->firstOrFail(); + + $assemblea = $convocazione->assemblea; + $odg = OrdineGiorno::where('assemblea_id', $assemblea->id)->get(); + + $zipFileName = 'allegati_assemblea_' . $assemblea->id . '.zip'; + $zipFilePath = storage_path('app/public/' . $zipFileName); + + $zip = new \ZipArchive(); + if ($zip->open($zipFilePath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) === true) { + $hasFiles = false; + foreach ($odg as $punto) { + if ($punto->allegati && is_array($punto->allegati)) { + foreach ($punto->allegati as $file) { + $fullPath = storage_path('app/public/' . $file['path']); + if (file_exists($fullPath)) { + $zip->addFile($fullPath, $punto->numero_punto . '_' . $file['nome']); + $hasFiles = true; + } + } + } + } + $zip->close(); + + if ($hasFiles && file_exists($zipFilePath)) { + return response()->download($zipFilePath)->deleteFileAfterSend(true); + } + } + + return redirect()->back()->with('error', 'Nessun allegato disponibile per il download.'); + } + + public function updateAnagrafica(Request $request, string $token) + { + $convocazione = Convocazione::where('token_accesso', $token)->firstOrFail(); + + $request->validate([ + 'email' => 'nullable|email|max:255', + 'pec' => 'nullable|email|max:255', + 'telefono' => 'nullable|string|max:50', + 'indirizzo' => 'nullable|string|max:255', + ]); + + $soggetto = $convocazione->soggetto; + $datiAttuali = [ + 'email' => $soggetto->email, + 'pec' => $soggetto->pec, + 'telefono' => $soggetto->telefono, + 'indirizzo' => $soggetto->indirizzo_residenza ?? $soggetto->indirizzo, + ]; + + $datiProposti = [ + 'email' => $request->input('email'), + 'pec' => $request->input('pec'), + 'telefono' => $request->input('telefono'), + 'indirizzo' => $request->input('indirizzo'), + ]; + + \App\Models\RichiestaModifica::create([ + 'unita_immobiliare_id' => $convocazione->unita_immobiliare_id, + 'soggetto_richiedente_id' => $convocazione->soggetto_id, + 'tipo_modifica' => 'recapiti', + 'descrizione' => 'Aggiornamento recapiti conduttore/proprietario inviato dal portale assemblea.', + 'dati_attuali' => $datiAttuali, + 'dati_proposti' => $datiProposti, + 'stato' => 'in_attesa', + ]); + + return response()->json([ + 'success' => true, + 'message' => 'Richiesta di variazione anagrafica inviata allo studio. Verrà esaminata a breve dall\'amministratore!', + ]); + } + public function vota(Request $request, string $token) { $convocazione = Convocazione::where('token_accesso', $token) diff --git a/app/Models/Assemblea.php b/app/Models/Assemblea.php index 75ef526..e2ada1a 100755 --- a/app/Models/Assemblea.php +++ b/app/Models/Assemblea.php @@ -22,6 +22,9 @@ class Assemblea extends Model 'data_convocazione', 'data_svolgimento', 'creato_da_user_id', + 'gmail_account', + 'video_registrazione_url', + 'google_calendar_event_id', ]; protected $casts = [ diff --git a/app/Models/AssembleaPresenza.php b/app/Models/AssembleaPresenza.php index b15bbe8..8a580f1 100644 --- a/app/Models/AssembleaPresenza.php +++ b/app/Models/AssembleaPresenza.php @@ -20,6 +20,7 @@ class AssembleaPresenza extends Model 'ora_ingresso', 'ora_uscita', 'qr_code_token', + 'token_delega', ]; protected $casts = [ diff --git a/app/Models/Convocazione.php b/app/Models/Convocazione.php index 6bbff83..5c9728b 100755 --- a/app/Models/Convocazione.php +++ b/app/Models/Convocazione.php @@ -20,11 +20,17 @@ class Convocazione extends Model 'consegnato_at', 'ricezione_confermata_at', 'token_accesso', + 'ip_conferma', + 'user_agent_conferma', + 'firma_ricezione_percorso', + 'stato_ricezione', + 'archiviata', ]; protected $casts = [ 'consegnato_at' => 'datetime', 'ricezione_confermata_at' => 'datetime', + 'archiviata' => 'boolean', 'created_at' => 'datetime', 'updated_at' => 'datetime', ]; diff --git a/app/Models/OrdineGiorno.php b/app/Models/OrdineGiorno.php index 0551f20..8a95722 100755 --- a/app/Models/OrdineGiorno.php +++ b/app/Models/OrdineGiorno.php @@ -14,13 +14,19 @@ class OrdineGiorno extends Model protected $fillable = [ 'assemblea_id', 'numero_punto', + 'ordinamento', 'titolo', 'descrizione', + 'allegati', 'articolo_legge', 'tipo_voce', 'collegamento_preventivo_id', 'importo_spesa', 'tabella_millesimale_id', + 'maggioranza_richiesta', + 'riferimento_legge', + 'audio_log_path', + 'audio_log_trascrizione', 'esito_votazione', 'voti_favorevoli', 'voti_contrari', @@ -29,11 +35,14 @@ class OrdineGiorno extends Model 'millesimi_contrari', 'millesimi_astenuti', 'note_delibera', + 'imported_to_id', ]; protected $casts = [ 'numero_punto' => 'integer', + 'ordinamento' => 'integer', 'importo_spesa' => 'decimal:2', + 'allegati' => 'array', 'voti_favorevoli' => 'integer', 'voti_contrari' => 'integer', 'astenuti' => 'integer', diff --git a/app/Models/Proprieta.php b/app/Models/Proprieta.php index 2af60cb..c60b739 100755 --- a/app/Models/Proprieta.php +++ b/app/Models/Proprieta.php @@ -11,7 +11,7 @@ class Proprieta extends Model protected $table = 'proprieta'; protected $fillable = [ 'unita_immobiliare_id', - 'soggetto_id', + 'anagrafica_id', 'tipo_diritto', 'percentuale_possesso', 'percentuale_detrazione', @@ -24,8 +24,12 @@ public function unitaImmobiliare() { return $this->belongsTo(UnitaImmobiliare::class, 'unita_immobiliare_id', 'id'); } + public function anagrafica() + { + return $this->belongsTo(Soggetto::class, 'anagrafica_id', 'id'); + } public function soggetto() { - return $this->belongsTo(Soggetto::class, 'soggetto_id', 'id'); + return $this->anagrafica(); } } diff --git a/app/Models/Soggetto.php b/app/Models/Soggetto.php index f6b36fe..617093f 100755 --- a/app/Models/Soggetto.php +++ b/app/Models/Soggetto.php @@ -12,7 +12,7 @@ class Soggetto extends Model { use HasFactory; - protected $table = 'soggetti'; + protected $table = 'anagrafiche'; /** * The attributes that are mass assignable. @@ -52,7 +52,7 @@ public function rateEmessaResponsabile(): HasMany public function proprieta() { - return $this->hasMany(Proprieta::class, 'soggetto_id', 'id'); + return $this->hasMany(Proprieta::class, 'anagrafica_id', 'id'); } /** @@ -60,7 +60,7 @@ public function proprieta() */ public function unitaImmobiliari() { - return $this->belongsToMany(UnitaImmobiliare::class, 'proprieta', 'soggetto_id', 'unita_immobiliare_id') + return $this->belongsToMany(UnitaImmobiliare::class, 'proprieta', 'anagrafica_id', 'unita_immobiliare_id') ->withPivot('tipo_diritto', 'percentuale_possesso', 'data_inizio', 'data_fine') ->withTimestamps(); } diff --git a/app/Models/UnitaImmobiliare.php b/app/Models/UnitaImmobiliare.php index 9223fa2..8443f14 100755 --- a/app/Models/UnitaImmobiliare.php +++ b/app/Models/UnitaImmobiliare.php @@ -409,13 +409,18 @@ public function hasAutorizzazioniScadute() /** * I soggetti collegati a questa unità immobiliare */ - public function soggetti() + public function anagrafiche() { - return $this->belongsToMany(Soggetto::class, 'proprieta', 'unita_immobiliare_id', 'soggetto_id') + return $this->belongsToMany(Soggetto::class, 'proprieta', 'unita_immobiliare_id', 'anagrafica_id') ->withPivot('tipo_diritto', 'percentuale_possesso', 'data_inizio', 'data_fine') ->withTimestamps(); } + public function soggetti() + { + return $this->anagrafiche(); + } + // Legacy compatibility methods public function getMillesimiProprietaAttribute() { diff --git a/app/Modules/Contabilita/Services/PagamentoFornitorePrimaNotaService.php b/app/Modules/Contabilita/Services/PagamentoFornitorePrimaNotaService.php index ac7451b..ecc89f5 100755 --- a/app/Modules/Contabilita/Services/PagamentoFornitorePrimaNotaService.php +++ b/app/Modules/Contabilita/Services/PagamentoFornitorePrimaNotaService.php @@ -115,6 +115,102 @@ public function generaPagamentoDaMovimento(User $user, MovimentoBanca $movimento } return DB::transaction(function () use ($user, $movimento, $stabileId, $gestioneId, $data, $descr, $amount, $contoFinanziarioId, $contoDebitoFornitore, $fornitoreId, $fattura) { + $splitRegs = []; + $righe = []; + if ($fattura && Schema::hasTable('contabilita_fatture_fornitori_righe')) { + $righe = DB::table('contabilita_fatture_fornitori_righe') + ->where('fattura_id', $fattura->id) + ->whereNotNull('gestione_id') + ->select('gestione_id', DB::raw('SUM(totale_euro) as totale')) + ->groupBy('gestione_id') + ->get(); + } + + if (count($righe) > 1) { + $totalInvoice = (float) $righe->sum('totale'); + if ($totalInvoice <= 0) { + $totalInvoice = 1.0; + } + + $allocatedAmount = 0.0; + $lastIndex = count($righe) - 1; + + foreach ($righe as $index => $riga) { + $gId = (int) $riga->gestione_id; + if ($index === $lastIndex) { + $splitAmount = round($amount - $allocatedAmount, 2); + } else { + $splitAmount = round($amount * ((float) $riga->totale / $totalInvoice), 2); + $allocatedAmount += $splitAmount; + } + + if ($splitAmount <= 0) { + continue; + } + + $regPayload = [ + 'stabile_id' => $stabileId, + 'gestione_id' => (Schema::hasColumn('contabilita_registrazioni', 'gestione_id') + ? Registrazione::resolveGestioneIdOrDefault($gId, $stabileId, $data) + : null), + 'data_registrazione' => $data, + 'descrizione' => $descr . ' (Quota Gestione #' . $gId . ')', + 'created_by' => (int) $user->id, + 'updated_by' => (int) $user->id, + ]; + if (Schema::hasColumn('contabilita_registrazioni', 'user_id')) { + $regPayload['user_id'] = (int) $user->id; + } + + /** @var Registrazione $reg */ + $reg = Registrazione::query()->create($regPayload); + + // Dare: diminuisce il debito verso fornitore per la quota + Movimento::query()->create([ + 'registrazione_id' => (int) $reg->id, + 'conto_id' => (int) $contoDebitoFornitore->id, + 'tipo' => 'dare', + 'importo' => $splitAmount, + ]); + + // Avere: diminuisce la banca/cassa per la quota + Movimento::query()->create([ + 'registrazione_id' => (int) $reg->id, + 'conto_id' => $contoFinanziarioId, + 'tipo' => 'avere', + 'importo' => $splitAmount, + ]); + + $reg->assertBilanciata(); + $splitRegs[] = $reg; + + $this->syncRegistroRitenutaDaPagamento($fattura, $gId, $data); + } + + if (! empty($splitRegs)) { + $firstReg = $splitRegs[0]; + $movimento->registrazione_id = (int) $firstReg->id; + $movimento->gestione_id = $firstReg->gestione_id; + + $matchData = is_string($movimento->match_data) ? json_decode($movimento->match_data, true) : (is_array($movimento->match_data) ? $movimento->match_data : []); + $matchData['split_registrazioni_ids'] = collect($splitRegs)->pluck('id')->all(); + $movimento->match_data = $matchData; + + if (Schema::hasColumn('contabilita_movimenti_banca', 'fornitore_id')) { + $movimento->fornitore_id = $fornitoreId; + } + $movimento->save(); + } + + if ($fattura) { + $fattura->data_pagamento = $fattura->data_pagamento ?: $data; + $fattura->movimento_pagamento_id = (int) $movimento->id; + $fattura->save(); + } + + return empty($splitRegs) ? Registrazione::query()->create([]) : $splitRegs[0]; + } + $regPayload = [ 'stabile_id' => $stabileId, 'gestione_id' => (Schema::hasColumn('contabilita_registrazioni', 'gestione_id') diff --git a/app/Providers/Filament/AdminFilamentPanelProvider.php b/app/Providers/Filament/AdminFilamentPanelProvider.php index a27ac2a..0a7eefc 100755 --- a/app/Providers/Filament/AdminFilamentPanelProvider.php +++ b/app/Providers/Filament/AdminFilamentPanelProvider.php @@ -50,6 +50,7 @@ public function panel(Panel $panel): Panel ->renderHook(PanelsRenderHook::TOPBAR_LOGO_AFTER, fn() => view('filament.components.topbar-live-call')) ->renderHook(PanelsRenderHook::USER_MENU_BEFORE, fn() => view('filament.components.topbar-icons')) ->renderHook(PanelsRenderHook::FOOTER, fn() => view('filament.components.footer')) + ->renderHook(PanelsRenderHook::SIDEBAR_NAV_START, fn() => view('filament.components.sidebar-search')) ->discoverPages(in: app_path('Filament/Pages'), for : 'App\\Filament\\Pages') ->pages([ Dashboard::class, diff --git a/app/Services/Contabilita/PreventivoOrdinarioPreviewService.php b/app/Services/Contabilita/PreventivoOrdinarioPreviewService.php index b716618..d5efecc 100755 --- a/app/Services/Contabilita/PreventivoOrdinarioPreviewService.php +++ b/app/Services/Contabilita/PreventivoOrdinarioPreviewService.php @@ -231,18 +231,45 @@ private function loadVoci(int $stabileId, ?int $gestioneId, string $tipoGestione 'sottoconto_pd', ]); - return $rows->map(fn(object $row): array=> [ - 'id' => (int) $row->id, - 'codice' => (string) ($row->codice ?? ''), - 'descrizione' => (string) ($row->descrizione ?? ''), - 'tabella_millesimale_default_id' => $row->tabella_millesimale_default_id ? (int) $row->tabella_millesimale_default_id : 0, - 'importo_default' => is_numeric($row->importo_default ?? null) ? (float) $row->importo_default : 0.0, - 'importo_consuntivo' => is_numeric($row->importo_consuntivo ?? null) ? (float) $row->importo_consuntivo : 0.0, - 'percentuale_condomino' => is_numeric($row->percentuale_condomino ?? null) ? (float) $row->percentuale_condomino : 100.0, - 'percentuale_inquilino' => is_numeric($row->percentuale_inquilino ?? null) ? (float) $row->percentuale_inquilino : 0.0, - 'conto_pd' => (string) ($row->conto_pd ?? ''), - 'sottoconto_pd' => (string) ($row->sottoconto_pd ?? ''), - ]); + // Carica la mappa codice_tabella -> id delle tabelle millesimali per lo stabile ed esercizio corrente + $annoGestione = DB::table('gestioni_contabili')->where('id', $gestioneId)->value('anno_gestione'); + $tabMap = []; + if ($annoGestione) { + $tabelleQuery = DB::table('tabelle_millesimali') + ->where('stabile_id', $stabileId); + if (Schema::hasColumn('tabelle_millesimali', 'anno_gestione')) { + $tabelleQuery->where('anno_gestione', $annoGestione); + } + $tabMap = $tabelleQuery->pluck('id', 'codice_tabella') + ->mapWithKeys(fn($id, $cod) => [strtoupper(trim($cod)) => $id]) + ->all(); + } + + return $rows->map(function(object $row) use ($tabMap): array { + $tabId = $row->tabella_millesimale_default_id ? (int) $row->tabella_millesimale_default_id : 0; + $codice = (string) ($row->codice ?? ''); + + if ($tabId === 0 && str_contains($codice, '.')) { + $parts = explode('.', $codice); + $tabCode = strtoupper(trim(end($parts))); + if (isset($tabMap[$tabCode])) { + $tabId = $tabMap[$tabCode]; + } + } + + return [ + 'id' => (int) $row->id, + 'codice' => $codice, + 'descrizione' => (string) ($row->descrizione ?? ''), + 'tabella_millesimale_default_id' => $tabId, + 'importo_default' => is_numeric($row->importo_default ?? null) ? (float) $row->importo_default : 0.0, + 'importo_consuntivo' => is_numeric($row->importo_consuntivo ?? null) ? (float) $row->importo_consuntivo : 0.0, + 'percentuale_condomino' => is_numeric($row->percentuale_condomino ?? null) ? (float) $row->percentuale_condomino : 100.0, + 'percentuale_inquilino' => is_numeric($row->percentuale_inquilino ?? null) ? (float) $row->percentuale_inquilino : 0.0, + 'conto_pd' => (string) ($row->conto_pd ?? ''), + 'sottoconto_pd' => (string) ($row->sottoconto_pd ?? ''), + ]; + }); } private function buildTableSummary(Collection $voci, array $tabellaMap): array diff --git a/app/Services/FattureElettroniche/FatturaElettronicaImporter.php b/app/Services/FattureElettroniche/FatturaElettronicaImporter.php index 430162e..82997bc 100755 --- a/app/Services/FattureElettroniche/FatturaElettronicaImporter.php +++ b/app/Services/FattureElettroniche/FatturaElettronicaImporter.php @@ -14,7 +14,7 @@ use App\Services\Arera\AreraFornitoreClassifier; use App\Services\Catalog\FornitoreProductCatalogService; use App\Services\Consumi\AcquaContractSyncService; -use App\Services\Consumi\AcquaPdfTextParser; +use App\Services\FatturaParserService; use App\Services\Consumi\ConsumiAcquaIngestionService; use App\Services\Consumi\ConsumiAcquaTariffeIngestionService; use App\Support\ArchivioPaths; @@ -953,8 +953,41 @@ private function extractPdfText(string $fullPath): ?string private function extractAcquaFornituraDataFromPdfText(string $text): ?array { try { - $parsed = app(AcquaPdfTextParser::class)->parse($text); - } catch (\Throwable) { + $rawParsed = app(FatturaParserService::class)->parseText($text); + $parsed = [ + 'codici' => [ + 'utenza' => $rawParsed['codice_utenza'] ?? null, + 'cliente' => $rawParsed['codice_cliente'] ?? null, + 'contratto' => $rawParsed['numero_contratto'] ?? null, + ], + 'contatore' => [ + 'matricola' => $rawParsed['matricola_contatore'] ?? null, + ], + 'consumi' => [ + [ + 'valore' => $rawParsed['quantita_consumata'] ?? null, + 'dal' => $rawParsed['data_inizio_periodo'] ?? null, + 'al' => $rawParsed['data_fine_periodo'] ?? null, + ] + ], + 'generale' => [ + 'numero_fattura_pdf' => $rawParsed['numero_contratto'] ?? null, + ], + 'pagamento' => [ + 'cbill' => $rawParsed['cbill'] ?? null, + 'codice_avviso' => $rawParsed['codice_avviso'] ?? null, + ], + 'riepilogo_letture' => [ + [ + 'precedente' => $rawParsed['lettura_precedente'] ?? null, + 'attuale' => $rawParsed['lettura_attuale'] ?? null, + ] + ], + 'tariffe' => [], + 'quadro_dettaglio' => [], + 'iva' => [], + ]; + } catch (\Throwable $e) { return null; } diff --git a/database/migrations/2025_08_06_000002_create_gescon_import_essential_tables.php b/database/migrations/2025_08_06_000002_create_gescon_import_essential_tables.php index 1908a07..59285b2 100755 --- a/database/migrations/2025_08_06_000002_create_gescon_import_essential_tables.php +++ b/database/migrations/2025_08_06_000002_create_gescon_import_essential_tables.php @@ -160,6 +160,7 @@ public function up() $table->string('cod_stabile', 10); $table->year('anno'); $table->integer('id_cond'); + $table->string('cod_cond', 20)->nullable()->index(); $table->date('data_pagamento'); $table->decimal('importo_euro', 10, 2); $table->string('modalita_pagamento')->nullable(); // bonifico, assegno, contanti diff --git a/database/migrations/2026_07_06_135806_make_ripartizione_spese_id_nullable_on_piano_rateizzazione_table.php b/database/migrations/2026_07_06_135806_make_ripartizione_spese_id_nullable_on_piano_rateizzazione_table.php index 787d2b3..4f9e6b2 100644 --- a/database/migrations/2026_07_06_135806_make_ripartizione_spese_id_nullable_on_piano_rateizzazione_table.php +++ b/database/migrations/2026_07_06_135806_make_ripartizione_spese_id_nullable_on_piano_rateizzazione_table.php @@ -11,10 +11,20 @@ */ public function up(): void { + $isSqlite = \Illuminate\Support\Facades\DB::connection()->getDriverName() === 'sqlite'; + + if ($isSqlite) { + \Illuminate\Support\Facades\DB::statement('DROP VIEW IF EXISTS vw_rate_emesse_emissioni'); + } + Schema::table('piano_rateizzazione', function (Blueprint $table) { $table->unsignedBigInteger('ripartizione_spese_id')->nullable()->change(); $table->unsignedBigInteger('creato_da')->nullable()->change(); }); + + if ($isSqlite) { + $this->createVwRateEmesseEmissioniSqlite(); + } } /** @@ -22,9 +32,43 @@ public function up(): void */ public function down(): void { + $isSqlite = \Illuminate\Support\Facades\DB::connection()->getDriverName() === 'sqlite'; + + if ($isSqlite) { + \Illuminate\Support\Facades\DB::statement('DROP VIEW IF EXISTS vw_rate_emesse_emissioni'); + } + Schema::table('piano_rateizzazione', function (Blueprint $table) { $table->unsignedBigInteger('ripartizione_spese_id')->nullable(false)->change(); $table->unsignedBigInteger('creato_da')->nullable(false)->change(); }); + + if ($isSqlite) { + $this->createVwRateEmesseEmissioniSqlite(); + } + } + + private function createVwRateEmesseEmissioniSqlite(): void + { + \Illuminate\Support\Facades\DB::statement(<< **Documento di Livello 1 (SOP Esecutiva / Dizionario Dati)** +> Tracciato record di confronto e dizionario dei campi raw estratti dal database di staging per la validazione catastale ed anagrafica avanzata. + +--- + +## 1. Mappatura Campi Raw 'condomin' (Legacy) + +I campi esportati dall'archivio `.mdb` originario costituiscono la sorgente immutabile di riscontro a schermo per l'operatore. Il sistema presenta in sola lettura questo set strutturato nel pannello di riscontro. + +### A. Dati Unità Fissa +- `id_cond`: Chiave primaria originaria del condomino. +- `cod_cond`: Codice sequenziale interno all'anno. +- `scala`: Scala di appartenenza dell'unità. +- `interno` (o `int`): Numero identificativo dell'interno. +- `tipo_pr`: Tipologia di proprietà/ruolo (es: Proprietario, Inquilino, ATER). + +### B. Catasto & Diritti Core +- `catasto_sez_urbana`: Sezione urbana catastale dell'immobile. +- `catasto_foglio`: Numero del foglio catastale. +- `catasto_particella`: Numero della particella catastale. +- `catasto_sub`: Subalterno dell'unità immobiliare. +- `catasto_zona`: Zona censuaria di appartenenza. +- `catasto_categoria`: Categoria catastale (es. A/2, C/6). +- `catasto_classe`: Classe di rendimento catastale. +- `catasto_consistenza`: Consistenza (es. vani, mq). +- `catasto_superfice`: Superficie catastale dichiarata. +- `catasto_rendita`: Rendita catastale. +- `diritto_reale`: Codifica del diritto reale (es: P per Proprietà, U per Usufrutto). +- `diritto_godimento`: Eventuale diritto di godimento (es. Locazione). +- `catasto_tu`: Tipologia d'uso catastale. +- `catasto_particella2`: Eventuale particella secondaria per catasto tavolare. +- `perc_diritto_reale`: Quota/Percentuale del diritto reale posseduto (es. 100%, 50%). + +### C. Pertinenze +- `pertinenze_box`: Flag/Dettaglio box auto associato. +- `pertinenze_cant`: Flag/Dettaglio cantina associata. +- `pertinenze_pauto`: Flag/Dettaglio posto auto assegnato. +- `pertinenze_altro`: Altre pertinenze collegate. + +### D. Fisco, Sicurezza & Detrazioni +- `sicur1_ui_a_norma`...`sicur5_opere_impegno`: Stato di conformità impianti e opere. +- `perc_detrazione`: Quota percentuale di detrazione fiscale spettante. +- `detraz_sit_part`: Situazioni particolari per le detrazioni. +- `disponib_dati_catastali`: Flag disponibilità coordinate catastali. +- `domandaaccatast_n` / `data` / `pr`: Estremi della domanda di accatastamento. +- `detraz_cess_cred`: Stato della cessione del credito fiscale. +- `detraz_cess_cf`: Codice fiscale del cessionario del credito. +- `detraz_cess_nome`: Nome/Denominazione del cessionario. +- `detraz_cess_protoc`: Protocollo telematico di cessione. + +### E. Anagrafica Condomino (Ruolo C - Proprietario) +- `nom_cond`: Cognome e Nome / Denominazione del condomino. +- `presso`: Recapito presso terzi. +- `ind`: Indirizzo di residenza. +- `cap`: Codice di Avviamento Postale. +- `citta`: Città di residenza. +- `pr`: Provincia di residenza. +- `inquil`: Flag/Collegamento ad eventuale inquilino. +- `tel1` / `tel2` / `cell_cond` / `fax_cond`: Recapiti telefonici e telefax. +- `note_cond`: Note descrittive libere associate all'anagrafica. +- `titolo_cond`: Titolo formale (es. Egr. Sig., Dott.). +- `e_mail_condomino`: Indirizzo email ordinario del condomino. +- `pec_condomino`: Indirizzo PEC. +- `cond_cod_fisc`: Codice Fiscale del condomino. +- `cond_dt_nasc` / `luogo_nasc` / `pr_nasc`: Dati di nascita del condomino. +- `cc_banca_cond` / `banca_cond`: Estremi bancari per rimborsi o addebiti. +- `mav_cond` / `bonifico_cond`: Preferenze di pagamento del condomino. + +### F. Anagrafica Inquilino (Ruolo I - Inquilino) +- `inquil_nome`: Nominativo completo dell'inquilino. +- `inquil_presso` / `inquil_indir` / `inquil_cap` / `inquil_citta` / `inquil_pr`: Indirizzo dell'inquilino. +- `inquil_tel1` / `inquil_tel2` / `cell_inq` / `fax_inq`: Recapiti telefonici. +- `inquil_note`: Annotazioni specifiche sull'inquilino. +- `titolo_inq`: Titolo formale dell'inquilino. +- `e_mail_inquilino` / `pec_inquilino`: Indirizzi email e PEC. +- `cc_banca_inq` / `banca_inq`: Coordinate bancarie dell'inquilino. +- `mav_inq` / `bonifico_inq`: Canali di pagamento scelti dall'inquilino. +- `inquil_dal` / `inquil_al`: Periodo temporale di occupazione/locazione. +- `inquil_cod_fisc`: Codice Fiscale dell'inquilino. +- `inquil_contratto_dal`: Decorrenza formale del contratto registrato. + +--- + +## 2. Tabelle Addizionali Core Legacy + +### A. TABELLA [anni] +Contiene i periodi e le impostazioni degli anni contabili/gestione. +- `id_anno`: ID univoco progressivo dell'anno. +- `anno_o`: Anno di inizio gestione ordinaria. +- `anno_r`: Anno di inizio gestione riscaldamento. +- `nome_dir`: Nome della directory contenente i database e i file dell'anno. +- `Selez`: Flag per identificare l'anno correntemente selezionato/attivo. +- `descr_selez`: Descrizione testuale dello stato di selezione. +- `ordinarie_dal` / `ordinarie_al`: Periodo di validità delle spese ordinarie. +- `riscald_dal` / `riscald_al`: Periodo di validità delle spese di riscaldamento. + +### B. TABELLA [Stabili] +Contiene le informazioni anagrafiche, fiscali e gestionali dello Stabile. +- `id_stabile`: Identificativo univoco dello stabile. +- `cod_stabile`: Codice legacy alfanumerico (es. `0021`). +- `denominazione`: Nome completo del condominio. +- `indirizzo`: Indirizzo dello stabile. +- `cap`: Codice Avviamento Postale. +- `citta`: Città di ubicazione. +- `pr`: Provincia di ubicazione. +- `codice_fisc`: Codice fiscale del condominio. +- `pos_inps`: Codice posizione INPS associata. +- `n_contribuente`: Numero contribuente dello stabile. +- `cf_amministratore`: Codice fiscale dell'amministratore dello stabile. +- `num_condomini`: Numero di condomini censiti nello stabile. +- `num_scale`: Numero di scale presenti. +- `note1`: Note aggiuntive generali. +- `nome_directory`: Nome della cartella dello stabile. + +### C. SCADENZARI E BANCHE STABILE +- `ORD_RATA_1`...`12`: Date di scadenza per le 12 rate ordinarie. +- `RIS_RATA_1`...`12`: Date di scadenza per le 12 rate riscaldamento. +- `num_ccp`: Numero di conto corrente postale dello stabile. +- `intestaz_ccp`: Intestazione del conto corrente postale. +- `Autoriz_pptt`: Numero di autorizzazione delle Poste Italiane. +- `Banca`: Nome dell'istituto bancario di appoggio dello stabile. +- `Banca_num_cc`: Numero del conto corrente bancario. +- `Banca_intest_cc`: Intestazione del conto corrente bancario. +- `ABI` / `CAB`: Codici ABI e CAB della banca dello stabile. +- `SIA` / `CIN`: Codici SIA e CIN per le disposizioni interbancarie. +- `INPS_sede_F24`: Sede INPS di competenza per deleghe F24. +- `INPS_Matricola_F24`: Matricola INPS per F24. +- `INAIL_sede_F24`: Sede INAIL di competenza per deleghe F24. +- `INAIL_posiz_f24` / `INAIL_posiz2_f24`: Posizioni INAIL per F24. +- `IBAN_Banca`: Codice IBAN del conto corrente bancario. +- `IBAN_Posta`: Codice IBAN del conto corrente postale. + +### D. CATASTO GLOBALE STABILE +- `AC_tu`: Destinazione d'uso globale catastale dello stabile. +- `AC_ip`: Identificatore provvisorio. +- `AC_urb_cat`: Codice sezione urbana globale. +- `AC_Foglio`: Foglio catastale globale dello stabile. +- `AC_partic1` / `AC_partic2`: Particelle catastali associate allo stabile. +- `AC_sub`: Subalterno globale. +- `AC_data_acc` / `AC_num_acc` / `AC_prov_acc`: Estremi dell'accatastamento. +- `PT_CIN` / `PT_SIA`: Credenziali postali dedicate. +- `F24_SIA`: Codice SIA per addebiti F24. +- `Catasto_comune`: Codice catastale del comune. +- `Catasto_PR`: Sigla provincia catastale. + +### E. FATTURE & PEC STABILE +- `FE_denominazione`: Denominazione fiscale dello stabile per la fatturazione elettronica. +- `FE_Codice_destinatario`: Codice destinatario SDI dello stabile. +- `FE_pec`: Indirizzo PEC dello stabile. +- `Autoriz_pptt_pdf` / `Autoriz_pptt_pdf_2`: Riferimenti ai documenti autorizzativi. +- `CUC` / `CUC2` / `CUC3` / `CUC4`: Codici Univoci di Codifica. +- `Th_mail_mittente` / `Th_PEC_mittente`: Account mittente per comunicazioni. +- `FE_SN`: Numero di serie del servizio di fatturazione elettronica. +- `FE_attivo_fino_al`: Data di scadenza del servizio di fatturazione. +- `FE_ultima_richiesta_al`: Timestamp dell'ultima interrogazione SDI. + +### F. TABELLA [Comproprietari] +Contiene l'anagrafica dei comproprietari o cointestatari dei diritti sulle unità. +- `Id_compr`: ID univoco del comproprietario. +- `id_cond`: Collegamento all'unità legacy (`condomin.id`). +- `Diritto_reale`: Tipo di diritto reale (es: P, U). +- `Descriz`: Descrizione testuale del diritto (es. Comproprietario, Usufruttuario). +- `titolo_cond`: Titolo formale del comproprietario. +- `nom_cond`: Cognome e Nome o denominazione del comproprietario. +- `presso`: Recapito presso terzi. +- `ind`: Indirizzo di residenza. +- `cap` / `citta` / `pr`: Dati di recapito del comproprietario. +- `Cond_dt_nasc` / `Cond_Luogo_nasc` / `Cond_PR_Nasc`: Dati di nascita. +- `tel1` / `tel2` / `Cell_cond` / `Fax_cond`: Contatti del comproprietario. +- `E_mail_condomino` / `PEC_compr`: Email e PEC del comproprietario. +- `Cond_cod_fisc`: Codice Fiscale del comproprietario. +- `Perc_Diritto_reale`: Percentuale di possesso del diritto reale (es. 50.00). +- `cia`: Flag o nota interna. +- `Perc_Detrazione`: Percentuale di detrazione fiscale spettante al comproprietario. +- `Detraz_Sit_Part`: Situazioni particolari per le detrazioni. +- `Catasto_sez_Urbana` / `Catasto_foglio` / `Catasto_particella` / `Catasto_sub` / `Catasto_TU` / `Catasto_particella2`: Coordinate catastali dichiarate per il comproprietario. +- `DomandaAccatast_N` / `DomandaAccatast_data` / `DomandaAccatast_PR`: Domanda di accatastamento del comproprietario. +- `ex_cod_cond` / `ex_scala` / `ex_int`: Riferimenti storici di posizionamento dell'unità. +- `Detraz_cess_cred` / `Detraz_cess_CF` / `Detraz_cess_nome` / `Detraz_cess_protoc`: Dettagli sulla cessione del credito del comproprietario. +- `ex_tipo_pr`: Tipo proprietà storico. +- `Data_cessione_credito`: Decorrenza formale di cessione credito. + +--- + +## 3. Blocco Sicurezza Sola Lettura Database + +Per blindare la stabilità del database di produzione (`netgescon`) durante la fase di validazione umana: + +1. **Inibizione Scritture**: + - I metodi di salvataggio in-place del controller `CatastoHub.php` (`applicaECorreggi`, `salvaQuotaProprietario`) **NON** devono eseguire query `INSERT`, `UPDATE` o `DELETE` su tabelle del database di produzione. +2. **Storicizzazione in Cloud Drive (Anno Consolidato 0004)**: + - I dati di riscontro confermati o modificati dall'operatore a schermo devono essere salvati in formato JSON all'interno di un file strutturato posizionato nella directory dell'anno 0004: + `[Codice_Amministratore]/[Codice_Stabile]/catasto/anno_0004/validati_unita_[id_unita].json` + e `quote_unita_[id_unita].json`. + - All'apertura della scheda unità, il sistema carica e applica in overlay questi dati storicizzati, consentendo di visualizzarli ed alterarli in totale sicurezza senza alterare i record fisici di produzione. diff --git a/resources/css/filament/admin/theme.css b/resources/css/filament/admin/theme.css index 84cf179..c1a3285 100755 --- a/resources/css/filament/admin/theme.css +++ b/resources/css/filament/admin/theme.css @@ -72,30 +72,28 @@ @media (min-width: 64rem) { } .fi-body.fi-body-has-navigation { - overflow-x: hidden !important; - overflow-y: auto !important; + overflow: hidden !important; } .fi-body.fi-body-has-navigation .fi-sidebar { position: fixed !important; - top: 4.5rem !important; + top: 0 !important; left: 0 !important; bottom: 0 !important; width: 20rem !important; - height: calc(100vh - 4.5rem) !important; - height: calc(100dvh - 4.5rem) !important; + height: 100vh !important; + height: 100dvh !important; z-index: 40 !important; - border-top: 1px solid rgba(148, 163, 184, 0.2) !important; + overflow-y: auto !important; background: inherit !important; } .fi-body.fi-body-has-navigation .fi-topbar { position: fixed !important; top: 0 !important; - left: 0 !important; + left: 20rem !important; right: 0 !important; - width: 100% !important; - margin-left: 0 !important; + width: calc(100% - 20rem) !important; z-index: 50 !important; } @@ -203,4 +201,27 @@ .fi-sidebar-nav-groups>li, .fi-sidebar-group, .fi-sidebar-item { margin-block: 0 !important; +} + +/* Compressione altezza e padding Topbar */ +.fi-topbar-header { + display: flex !important; + flex-direction: row !important; + justify-content: space-between !important; + align-items: center !important; + flex-wrap: nowrap !important; + width: 100% !important; + min-height: 3rem !important; + padding-block: 0.25rem !important; + overflow: visible !important; +} + +.fi-topbar { + height: 3.5rem !important; +} + +@media (min-width: 64rem) { + .fi-body.fi-body-has-navigation .fi-main-ctn { + padding-top: 3.5rem !important; + } } \ No newline at end of file diff --git a/resources/views/admin/stabili/tabs/gestione-documentale.blade.php b/resources/views/admin/stabili/tabs/gestione-documentale.blade.php index 764a7dc..eb97aa6 100755 --- a/resources/views/admin/stabili/tabs/gestione-documentale.blade.php +++ b/resources/views/admin/stabili/tabs/gestione-documentale.blade.php @@ -270,6 +270,11 @@ class="text-green-600 hover:text-green-800 text-sm font-medium" onclick="scaricaDocumento({{ $documento->id }})"> Scarica + + Dymo + @endif - - - - diff --git a/resources/views/filament/components/topbar-live-call.blade.php b/resources/views/filament/components/topbar-live-call.blade.php index f2e0ba4..292d556 100755 --- a/resources/views/filament/components/topbar-live-call.blade.php +++ b/resources/views/filament/components/topbar-live-call.blade.php @@ -1,3 +1,3 @@ -
-
@@ -42,127 +42,146 @@ - -
-
-

Cronologia assemblee dello stabile

-
- -
- - - - - - - - - - - - - - - - @forelse($this->assembleeRows as $assemblea) - - - - - - - - - - - - @empty - - - - @endforelse - -
Assemblea / TipoData 1ª ConvocazioneData 2ª ConvocazioneLuogoStatoOdGConvocatiPresentiAzioni
- Assemblea {{ $assemblea->tipo ?: 'Ordinaria' }} - - {{ $assemblea->data_prima_convocazione ? $assemblea->data_prima_convocazione->format('d/m/Y H:i') : 'n/d' }} - - {{ $assemblea->data_seconda_convocazione ? $assemblea->data_seconda_convocazione->format('d/m/Y H:i') : 'n/d' }} - - {{ \Illuminate\Support\Str::limit($assemblea->luogo ?: 'Presso uffici', 40) }} - - - {{ $assemblea->stato ?: 'Bozza' }} - - {{ (int)$assemblea->ordine_giorno_count }}{{ (int)$assemblea->convocazioni_count }}{{ (int)$assemblea->presenze_count }} - - Gestisci - - -
- Nessuna assemblea registrata per questo stabile. Clicca su "Nuova Assemblea" per crearne una. -
-
+ +
+ +
+ + @if(($tab ?? 'elenco') === 'elenco') + +
+
+

Cronologia assemblee dello stabile

+
+ +
+ + + + + + + + + + + + + + + + @forelse($this->assembleeRows as $assemblea) + + + + + + + + + + + + @empty + + + + @endforelse + +
Assemblea / TipoData 1ª ConvocazioneData 2ª ConvocazioneLuogoStatoOdGConvocatiPresentiAzioni
+ Assemblea {{ $assemblea->tipo ?: 'Ordinaria' }} + + {{ $assemblea->data_prima_convocazione ? $assemblea->data_prima_convocazione->format('d/m/Y H:i') : 'n/d' }} + + {{ $assemblea->data_seconda_convocazione ? $assemblea->data_seconda_convocazione->format('d/m/Y H:i') : 'n/d' }} + + {{ \Illuminate\Support\Str::limit($assemblea->luogo ?: 'Presso uffici', 40) }} + + + {{ $assemblea->stato ?: 'Bozza' }} + + {{ (int)$assemblea->ordine_giorno_count }}{{ (int)$assemblea->convocazioni_count }}{{ (int)$assemblea->presenze_count }} + + Gestisci + + +
+ Nessuna assemblea registrata per questo stabile. Clicca su "Nuova Assemblea" per crearne una. +
+
+
+ @elseif(($tab ?? 'elenco') === 'sospesi') + +
+
+

+ + Punti Ordine del Giorno Sospesi o non deliberati +

+
+ +
+ @if(empty($this->sospesiRows) || count($this->sospesiRows) === 0) +
+ Nessun punto all'Ordine del Giorno sospeso o rimandato rilevato per lo stabile attivo. +
+ @else +
+ @foreach($this->sospesiRows as $sPunto) +
+
+
+ + {{ str_replace('_', ' ', $sPunto->esito_votazione ?: 'sospeso') }} + + ID: #{{ $sPunto->id }} +
+

{{ $sPunto->titolo }}

+

{{ $sPunto->descrizione }}

+ +
+ @if($sPunto->articolo_legge) + {{ $sPunto->articolo_legge }} + @endif + @if($sPunto->maggioranza_richiesta) + {{ $sPunto->maggioranza_richiesta }} + @endif +
+
+
+ @endforeach +
+ @endif +
+
+ @endif @endif
- - -