From b3e626b6137eb91b604176bd5cb46716094e1129 Mon Sep 17 00:00:00 2001 From: michele Date: Sat, 18 Apr 2026 08:47:02 +0000 Subject: [PATCH] chore: checkpoint current workspace state --- CHANGELOG.md | 2 + .../GesconSyncOrdinariaPreventivoCommand.php | 359 +++--- ...TecnoRepairImportRubricaClientiCommand.php | 4 +- .../Contabilita/CasseBancheMovimenti.php | 1031 ++++++++++++++++- .../Contabilita/CasseBancheRiepilogo.php | 163 ++- .../Pages/Contabilita/SaldiContiArchivio.php | 166 ++- .../Pages/Contabilita/SituazioneIniziale.php | 538 ++++++++- .../Pages/Contabilita/VociSpesaArchivio.php | 166 +-- app/Filament/Pages/Fornitore/Contabilita.php | 4 +- app/Filament/Pages/Gescon/Ordinarie.php | 222 +++- app/Filament/Pages/Strumenti/PostIt.php | 223 +++- app/Filament/Pages/Supporto/TicketAcqua.php | 37 + .../Condomini/StabileDatiBancariTable.php | 75 ++ app/Livewire/Filament/TopbarLiveCall.php | 41 +- app/Models/DocumentoStabile.php | 25 +- app/Modules/Contabilita/Models/SaldoConto.php | 12 + .../Filament/AdminFilamentPanelProvider.php | 2 +- .../Contabilita/MovimentiBancaImporter.php | 183 +-- app/Services/Contabilita/MpsQifParser.php | 35 +- .../PreventivoOrdinarioPreviewService.php | 761 ++++++++++++ ...glio_millesimi_unique_for_ruolo_legacy.php | 5 +- ...nt_metadata_to_contabilita_saldi_conti.php | 56 + docs/ai/SESSION_HANDOFF.md | 77 ++ .../casse-banche-movimenti.blade.php | 474 ++++++-- .../contabilita/situazione-iniziale.blade.php | 128 +- .../voci-spesa-prospetto.blade.php | 38 +- .../filament/pages/gescon/section.blade.php | 349 +++++- .../pages/strumenti/post-it.blade.php | 17 +- .../supporto/ticket-acqua-light.blade.php | 44 +- .../filament/topbar-live-call.blade.php | 10 +- 30 files changed, 4517 insertions(+), 730 deletions(-) create mode 100644 app/Services/Contabilita/PreventivoOrdinarioPreviewService.php create mode 100644 database/migrations/2026_04_17_120000_add_statement_metadata_to_contabilita_saldi_conti.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 25092ad..df82099 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ # Changelog ## [Unreleased] +- Consolidated the banking workflow around Contabilita > Casse e banche > Movimenti, now used as a 4-tab hub for account overview, official balances and statement PDFs, imported bank ledger movements, and first-pass reconciliation candidates. +- Moved official bank balance and statement management out of Situazione iniziale, fixed bank document URLs to use storage-backed paths, and anchored balance reconstruction on `conto_id` so legacy accounts without IBAN still reconcile correctly. - Added stable-level Documentazione, Posta ufficiale and Protocollo comunicazioni tabs, plus a real admin-wide Comunicazioni regia page reusing Google accounts, Gmail import, Drive templates and protocol records. - Seeded the official Gmail path for stabile `0023` `GERMANICO 79` (`viagermanico79@gmail.com`), hardened PEC studio persistence against the missing `pec_amministratore` DB column, and linked the new stable documentation hub to the existing Google settings workflow. - Applied `gescon:auto-sync-anagrafiche` for `GERMANICO 79` with stable/unit role linking and imported the 2024 legacy water dataset from `generale_stabile.mdb` into servizi/letture without closing historic years. diff --git a/app/Console/Commands/GesconSyncOrdinariaPreventivoCommand.php b/app/Console/Commands/GesconSyncOrdinariaPreventivoCommand.php index 9a67aad..a752591 100644 --- a/app/Console/Commands/GesconSyncOrdinariaPreventivoCommand.php +++ b/app/Console/Commands/GesconSyncOrdinariaPreventivoCommand.php @@ -1,5 +1,4 @@ argument('stabile_code')); - $year = (int) $this->argument('year'); + $year = (int) $this->argument('year'); if ($stableCode === '' || $year < 1900) { $this->error('Parametri non validi.'); @@ -28,7 +27,7 @@ public function handle(): int return self::FAILURE; } - $stableId = $this->resolveStableId($stableCode); + $stableId = $this->resolveStableId($stableCode); $gestioneId = $this->resolveGestioneId($stableId, $year); $legacyVoci = $this->loadLegacyVoci($stableCode, $year); @@ -38,15 +37,16 @@ public function handle(): int return self::FAILURE; } - $tableCodes = $legacyVoci - ->pluck('tabella') - ->filter(fn ($value): bool => is_string($value) && trim($value) !== '') - ->map(fn (string $value): string => trim($value)) - ->unique() - ->values(); + $legacyHeaders = $this->loadLegacyTableHeaders($stableCode); + if ($legacyHeaders === []) { + $this->error('Nessuna intestazione tabella legacy trovata per stabile ' . $stableCode . '.'); + return self::FAILURE; + } + + $tableCodes = collect(array_keys($legacyHeaders))->values(); $legacyDetails = $this->loadLegacyDettagli($stableCode, $year, $tableCodes); - $legacyHeaders = $this->loadLegacyTableHeaders($stableCode, $tableCodes); + $this->assertLegacyVociHaveKnownTables($legacyVoci, $tableCodes); DB::transaction(function () use ($stableCode, $stableId, $year, $gestioneId, $legacyVoci, $legacyDetails, $tableCodes, $legacyHeaders): void { $this->ensureLegacyUnitCoverage($stableCode, $stableId, $legacyDetails); @@ -76,7 +76,7 @@ private function resolveStableId(string $stableCode): int return (int) $optionStableId; } - $query = DB::table('stabili'); + $query = DB::table('stabili'); $stable = $query ->where('codice_stabile', $stableCode) ->orWhere('codice_stabile', ltrim($stableCode, '0')) @@ -159,13 +159,13 @@ private function loadLegacyDettagli(string $stableCode, int $year, Collection $t /** * @return array */ - private function loadLegacyTableHeaders(string $stableCode, Collection $tableCodes): array + private function loadLegacyTableHeaders(string $stableCode): array { - if ($tableCodes->isEmpty() || ! Schema::connection('gescon_import')->hasTable('tabelle_millesimali')) { + if (! Schema::connection('gescon_import')->hasTable('tabelle_millesimali')) { return []; } - $columns = Schema::connection('gescon_import')->getColumnListing('tabelle_millesimali'); + $columns = Schema::connection('gescon_import')->getColumnListing('tabelle_millesimali'); $codeColumn = in_array('cod_tabella', $columns, true) ? 'cod_tabella' : (in_array('cod_tab', $columns, true) ? 'cod_tab' : null); @@ -179,8 +179,13 @@ private function loadLegacyTableHeaders(string $stableCode, Collection $tableCod $query->where('cod_stabile', $stableCode); } + if (in_array('nord', $columns, true)) { + $query + ->orderByRaw('CASE WHEN nord IS NULL THEN 1 ELSE 0 END') + ->orderBy('nord'); + } + $rows = $query - ->whereIn($codeColumn, $tableCodes->all()) ->orderBy($codeColumn) ->get(); @@ -198,12 +203,29 @@ private function loadLegacyTableHeaders(string $stableCode, Collection $tableCod return $headers; } + private function assertLegacyVociHaveKnownTables(Collection $legacyVoci, Collection $tableCodes): void + { + $knownTables = array_fill_keys($tableCodes->all(), true); + $unknown = $legacyVoci + ->pluck('tabella') + ->filter(fn($value): bool => is_string($value) && trim($value) !== '') + ->map(fn(string $value): string => trim($value)) + ->reject(fn(string $value): bool => isset($knownTables[$value])) + ->unique() + ->values() + ->all(); + + if ($unknown !== []) { + throw new \RuntimeException('Tabelle legacy referenziate dalle voci ma assenti nelle intestazioni archive: ' . implode(', ', $unknown) . '.'); + } + } + private function ensureLegacyUnitCoverage(string $stableCode, int $stableId, Collection $legacyDetails): void { $legacyIds = $legacyDetails ->pluck('id_cond') - ->filter(fn ($value): bool => is_string($value) && trim($value) !== '') - ->map(fn (string $value): string => trim($value)) + ->filter(fn($value): bool => is_string($value) && trim($value) !== '') + ->map(fn(string $value): string => trim($value)) ->unique() ->values(); @@ -212,7 +234,7 @@ private function ensureLegacyUnitCoverage(string $stableCode, int $stableId, Col ->whereNull('deleted_at') ->whereIn('legacy_cond_id', $legacyIds->all()) ->pluck('legacy_cond_id') - ->map(fn ($value): string => trim((string) $value)) + ->map(fn($value): string => trim((string) $value)) ->all(); $existingSet = array_fill_keys($existingIds, true); @@ -263,9 +285,9 @@ private function createMissingLegacyUnit(string $stableCode, int $stableId, stri throw new \RuntimeException('Interno mancante per il legacy id ' . $legacyId . '.'); } - $scale = trim((string) ($sourceRow->scala ?? '')); - $scale = $scale !== '' ? strtoupper($scale) : 'A'; - $floor = $this->normalizeFloor($sourceRow->piano ?? null); + $scale = trim((string) ($sourceRow->scala ?? '')); + $scale = $scale !== '' ? strtoupper($scale) : 'A'; + $floor = $this->normalizeFloor($sourceRow->piano ?? null); $displayName = trim((string) (($sourceRow->cognome ?? '') . ' ' . ($sourceRow->nome ?? ''))); if ($displayName === '') { $displayName = trim((string) ($sourceRow->nom_cond ?? '')); @@ -278,21 +300,21 @@ private function createMissingLegacyUnit(string $stableCode, int $stableId, stri } DB::table('unita_immobiliari')->insert([ - 'stabile_id' => $stableId, - 'codice_unita' => $code, - 'denominazione' => $displayName !== '' ? $displayName : ('Unita legacy ' . $legacyId), - 'scala' => $scale, - 'piano' => $floor, - 'interno' => $internal, - 'legacy_cond_id' => $legacyId, - 'tipo_unita' => 'abitazione', - 'stato_occupazione' => 'occupata_proprietario', + 'stabile_id' => $stableId, + 'codice_unita' => $code, + 'denominazione' => $displayName !== '' ? $displayName : ('Unita legacy ' . $legacyId), + 'scala' => $scale, + 'piano' => $floor, + 'interno' => $internal, + 'legacy_cond_id' => $legacyId, + 'tipo_unita' => 'abitazione', + 'stato_occupazione' => 'occupata_proprietario', 'stato_conservazione' => 'buono', - 'millesimi_generali' => 0, - 'attiva' => true, - 'unita_demo' => false, - 'created_at' => now(), - 'updated_at' => now(), + 'millesimi_generali' => 0, + 'attiva' => true, + 'unita_demo' => false, + 'created_at' => now(), + 'updated_at' => now(), ]); } @@ -301,31 +323,64 @@ private function createMissingLegacyUnit(string $stableCode, int $stableId, stri */ private function syncTabelleMillesimali(int $stableId, int $year, Collection $tableCodes, Collection $legacyDetails, array $legacyHeaders): array { + $existingRows = DB::table('tabelle_millesimali') + ->where('stabile_id', $stableId) + ->get(['id', 'codice_tabella', 'nome_tabella']); + + $existingByCode = []; + foreach ($existingRows as $existingRow) { + foreach ([ + strtoupper(trim((string) ($existingRow->codice_tabella ?? ''))), + strtoupper(trim((string) ($existingRow->nome_tabella ?? ''))), + ] as $existingCode) { + if ($existingCode === '' || isset($existingByCode[$existingCode])) { + continue; + } + + $existingByCode[$existingCode] = (int) $existingRow->id; + } + } + + $tableIdsToRefresh = []; + foreach ($tableCodes as $tableCode) { + $normalizedCode = strtoupper(trim((string) $tableCode)); + $existingId = $existingByCode[$normalizedCode] ?? null; + if ($existingId) { + $tableIdsToRefresh[] = $existingId; + } + } + + if ($tableIdsToRefresh !== []) { + DB::table('dettaglio_millesimi') + ->whereIn('tabella_millesimale_id', $tableIdsToRefresh) + ->delete(); + } + $detailSummary = $legacyDetails ->groupBy('cod_tab') ->map(function (Collection $rows): array { return [ - 'total_mm' => (float) $rows->sum(fn ($row): float => is_numeric($row->mm ?? null) ? (float) $row->mm : 0.0), - 'rows' => $rows->count(), + 'total_mm' => (float) $rows->sum(fn($row): float => is_numeric($row->mm ?? null) ? (float) $row->mm : 0.0), + 'rows' => $rows->count(), ]; }); - $tableMap = []; + $tableMap = []; $sortOrder = 1; foreach ($tableCodes as $tableCode) { $normalizedCode = strtoupper(trim((string) $tableCode)); - $summary = $detailSummary->get($tableCode, ['total_mm' => 0.0, 'rows' => 0]); - $header = $legacyHeaders[$normalizedCode] ?? null; - $rawCalcolo = $header->tipo_calcolo ?? $header->calcolo ?? null; - $calcolo = $this->normalizeLegacyCalcolo($rawCalcolo); - $tipoLegacy = $this->normalizeLegacyTipo($header->tipologia ?? $header->tipo ?? null) ?? 'O'; - $tipoTabella = $this->resolveTipoTabellaFromLegacy($tipoLegacy, $calcolo, $normalizedCode); - $tipoCalcoloDb = in_array($calcolo, ['millesimi', 'parti', 'fisso'], true) + $summary = $detailSummary->get($tableCode, ['total_mm' => 0.0, 'rows' => 0]); + $header = $legacyHeaders[$normalizedCode] ?? null; + $rawCalcolo = $header->tipo_calcolo ?? $header->calcolo ?? null; + $calcolo = $this->normalizeLegacyCalcolo($rawCalcolo); + $tipoLegacy = $this->normalizeLegacyTipo($header->tipologia ?? $header->tipo ?? null) ?? 'O'; + $tipoTabella = $this->resolveTipoTabellaFromLegacy($tipoLegacy, $calcolo, $normalizedCode); + $tipoCalcoloDb = in_array($calcolo, ['millesimi', 'parti', 'fisso'], true) ? $calcolo : ($normalizedCode === 'ACQUA' ? 'parti' : 'millesimi'); $denominazione = trim((string) ($header->denominazione ?? $header->descrizione ?? $tableCode)); - $ordine = null; + $ordine = null; foreach (['nord', 'ordinamento'] as $orderKey) { if (isset($header->{$orderKey}) && is_numeric($header->{$orderKey})) { @@ -337,38 +392,38 @@ private function syncTabelleMillesimali(int $stableId, int $year, Collection $ta $sortValue = $ordine ?? $sortOrder; $payload = [ - 'stabile_id' => $stableId, - 'anno_gestione' => $year, - 'nome_tabella' => $tableCode, - 'denominazione' => $denominazione !== '' ? $denominazione : $tableCode, - 'codice_tabella' => $tableCode, - 'legacy_codice' => $tableCode, + 'stabile_id' => $stableId, + 'anno_gestione' => $year, + 'nome_tabella' => $tableCode, + 'denominazione' => $denominazione !== '' ? $denominazione : $tableCode, + 'codice_tabella' => $tableCode, + 'legacy_codice' => $tableCode, 'ordine_visualizzazione' => $sortValue, - 'tipo_tabella' => $tipoTabella, - 'tipo_calcolo' => $tipoCalcoloDb, - 'attiva' => true, - 'ordinamento' => $sortValue, - 'nord' => $sortValue, - 'totale_millesimi' => $summary['total_mm'], - 'meta_legacy' => json_encode([ + 'tipo_tabella' => $tipoTabella, + 'tipo_calcolo' => $tipoCalcoloDb, + 'attiva' => true, + 'ordinamento' => $sortValue, + 'nord' => $sortValue, + 'totale_millesimi' => $summary['total_mm'], + 'meta_legacy' => json_encode([ 'strict_source' => 'gescon_import', - 'legacy_year' => $year, - 'cod_tab' => $normalizedCode, - 'tipo' => $tipoLegacy, - 'calcolo' => is_string($rawCalcolo) && trim($rawCalcolo) !== '' ? strtoupper(trim($rawCalcolo)) : null, - 'detail_rows' => $summary['rows'], + 'legacy_year' => $year, + 'cod_tab' => $normalizedCode, + 'tipo' => $tipoLegacy, + 'calcolo' => is_string($rawCalcolo) && trim($rawCalcolo) !== '' ? strtoupper(trim($rawCalcolo)) : null, + 'detail_rows' => $summary['rows'], ]), - 'updated_at' => now(), + 'updated_at' => now(), ]; - $existing = DB::table('tabelle_millesimali') - ->where('stabile_id', $stableId) - ->where('codice_tabella', $tableCode) - ->first(['id']); + $existingId = $existingByCode[$normalizedCode] ?? null; - if ($existing) { - DB::table('tabelle_millesimali')->where('id', (int) $existing->id)->update($payload); - $tableMap[$tableCode] = (int) $existing->id; + if ($existingId) { + DB::table('tabelle_millesimali') + ->where('id', $existingId) + ->update($payload); + + $tableMap[$tableCode] = (int) $existingId; } else { $payload['created_at'] = now(); DB::table('tabelle_millesimali')->insert($payload); @@ -381,9 +436,49 @@ private function syncTabelleMillesimali(int $stableId, int $year, Collection $ta $sortOrder++; } + $this->pruneUnusedTablesOutsideLegacySet($stableId, $tableCodes); + return $tableMap; } + private function pruneUnusedTablesOutsideLegacySet(int $stableId, Collection $tableCodes): void + { + $allowedCodes = $tableCodes + ->map(fn($value): string => strtoupper(trim((string) $value))) + ->filter(fn(string $value): bool => $value !== '') + ->values() + ->all(); + + $rows = DB::table('tabelle_millesimali') + ->where('stabile_id', $stableId) + ->get(['id', 'codice_tabella', 'nome_tabella']); + + foreach ($rows as $row) { + $code = strtoupper(trim((string) ($row->codice_tabella ?: $row->nome_tabella ?: ''))); + if ($code === '' || in_array($code, $allowedCodes, true)) { + continue; + } + + $tableId = (int) $row->id; + $hasVoci = DB::table('voci_spesa') + ->where('stabile_id', $stableId) + ->where('tabella_millesimale_default_id', $tableId) + ->exists(); + + $hasDettagli = DB::table('dettaglio_millesimi') + ->where('tabella_millesimale_id', $tableId) + ->exists(); + + if ($hasVoci || $hasDettagli) { + continue; + } + + DB::table('tabelle_millesimali') + ->where('id', $tableId) + ->delete(); + } + } + /** * @param array $tableMap */ @@ -409,7 +504,7 @@ private function syncDettagliMillesimali(int $stableId, int $year, Collection $l foreach ($groupedRows as $groupKey => $rows) { [$tableCode, $legacyId, $role] = explode('|', $groupKey); - $tableId = $tableMap[$tableCode] ?? null; + $tableId = $tableMap[$tableCode] ?? null; if (! $tableId) { throw new \RuntimeException('Tabella millesimale non risolta per codice ' . $tableCode . '.'); } @@ -424,29 +519,29 @@ private function syncDettagliMillesimali(int $stableId, int $year, Collection $l throw new \RuntimeException('Unita NetGescon non risolta per legacy_cond_id ' . $legacyId . '.'); } - $millesimi = (float) $rows->sum(fn ($row): float => is_numeric($row->mm ?? null) ? (float) $row->mm : 0.0); - $valorePrev = (float) $rows->sum(fn ($row): float => is_numeric($row->prev_euro ?? null) ? (float) $row->prev_euro : 0.0); - $valoreCons = (float) $rows->sum(fn ($row): float => is_numeric($row->cons_euro ?? null) ? (float) $row->cons_euro : 0.0); + $millesimi = (float) $rows->sum(fn($row): float => is_numeric($row->mm ?? null) ? (float) $row->mm : 0.0); + $valorePrev = (float) $rows->sum(fn($row): float => is_numeric($row->prev_euro ?? null) ? (float) $row->prev_euro : 0.0); + $valoreCons = (float) $rows->sum(fn($row): float => is_numeric($row->cons_euro ?? null) ? (float) $row->cons_euro : 0.0); DB::table('dettaglio_millesimi')->insert([ 'tabella_millesimale_id' => $tableId, - 'unita_immobiliare_id' => (int) $unitId, - 'millesimi' => $millesimi, - 'valore_prev' => $valorePrev, - 'valore_cons' => $valoreCons, - 'ruolo_legacy' => $role !== '' ? $role : null, - 'legacy_interno' => $legacyId, - 'partecipa' => true, - 'note' => null, - 'legacy_payload' => json_encode([ + 'unita_immobiliare_id' => (int) $unitId, + 'millesimi' => $millesimi, + 'valore_prev' => $valorePrev, + 'valore_cons' => $valoreCons, + 'ruolo_legacy' => $role !== '' ? $role : null, + 'legacy_interno' => $legacyId, + 'partecipa' => true, + 'note' => null, + 'legacy_payload' => json_encode([ 'strict_source' => 'gescon_import', - 'legacy_year' => $year, - 'cod_tab' => $tableCode, - 'id_cond' => $legacyId, - 'cond_inquil' => $role !== '' ? $role : null, + 'legacy_year' => $year, + 'cod_tab' => $tableCode, + 'id_cond' => $legacyId, + 'cond_inquil' => $role !== '' ? $role : null, ]), - 'created_at' => now(), - 'updated_at' => now(), + 'created_at' => now(), + 'updated_at' => now(), ]); } } @@ -456,6 +551,11 @@ private function syncDettagliMillesimali(int $stableId, int $year, Collection $l */ private function syncVociSpesa(int $stableId, int $gestioneId, int $year, Collection $legacyVoci, array $tableMap): void { + DB::table('voci_spesa') + ->where('stabile_id', $stableId) + ->where('gestione_contabile_id', $gestioneId) + ->delete(); + $sortOrder = 1; foreach ($legacyVoci as $voce) { @@ -465,51 +565,41 @@ private function syncVociSpesa(int $stableId, int $gestioneId, int $year, Collec } $tableCode = trim((string) ($voce->tabella ?? '')); - $tableId = $tableCode !== '' ? ($tableMap[$tableCode] ?? null) : null; + $tableId = $tableCode !== '' ? ($tableMap[$tableCode] ?? null) : null; if ($tableCode !== '' && ! $tableId) { throw new \RuntimeException('Tabella legacy non risolta per la voce ' . $code . ': ' . $tableCode . '.'); } $payload = [ - 'stabile_id' => $stableId, - 'gestione_contabile_id' => $gestioneId, - 'codice' => $code, - 'legacy_codice' => $code, - 'descrizione' => trim((string) ($voce->descriz ?? '')), - 'categoria' => 'ordinaria', - 'tipo_gestione' => 'ordinaria', + 'stabile_id' => $stableId, + 'gestione_contabile_id' => $gestioneId, + 'codice' => $code, + 'legacy_codice' => $code, + 'descrizione' => trim((string) ($voce->descriz ?? '')), + 'categoria' => 'ordinaria', + 'tipo_gestione' => 'ordinaria', 'tabella_millesimale_default_id' => $tableId, - 'importo_default' => is_numeric($voce->preventivo_euro ?? null) ? (float) $voce->preventivo_euro : 0.0, - 'importo_consuntivo' => is_numeric($voce->consuntivo_euro ?? null) ? (float) $voce->consuntivo_euro : 0.0, - 'percentuale_condomino' => is_numeric($voce->perc_proprietario ?? null) ? (float) $voce->perc_proprietario : 0.0, - 'percentuale_inquilino' => is_numeric($voce->perc_inquilino ?? null) ? (float) $voce->perc_inquilino : 0.0, - 'attiva' => true, - 'ordinamento' => $sortOrder, - 'nord' => $sortOrder, - 'tipo' => 'ordinaria', - 'legacy_tipo' => trim((string) ($voce->v_ors ?? 'O')) ?: 'O', - 'note' => trim((string) ($voce->note ?? '')) !== '' ? trim((string) ($voce->note ?? '')) : null, - 'meta_legacy' => json_encode([ + 'importo_default' => is_numeric($voce->preventivo_euro ?? null) ? (float) $voce->preventivo_euro : 0.0, + 'importo_consuntivo' => is_numeric($voce->consuntivo_euro ?? null) ? (float) $voce->consuntivo_euro : 0.0, + 'percentuale_condomino' => is_numeric($voce->perc_proprietario ?? null) ? (float) $voce->perc_proprietario : 0.0, + 'percentuale_inquilino' => is_numeric($voce->perc_inquilino ?? null) ? (float) $voce->perc_inquilino : 0.0, + 'attiva' => true, + 'ordinamento' => $sortOrder, + 'nord' => $sortOrder, + 'tipo' => 'ordinaria', + 'legacy_tipo' => trim((string) ($voce->v_ors ?? 'O')) ?: 'O', + 'note' => trim((string) ($voce->note ?? '')) !== '' ? trim((string) ($voce->note ?? '')) : null, + 'meta_legacy' => json_encode([ 'strict_source' => 'gescon_import', - 'legacy_year' => $year, - 'cod_tab' => $tableCode !== '' ? $tableCode : null, + 'legacy_year' => $year, + 'cod_tab' => $tableCode !== '' ? $tableCode : null, ]), - 'updated_at' => now(), + 'updated_at' => now(), ]; - $existing = DB::table('voci_spesa') - ->where('stabile_id', $stableId) - ->where('gestione_contabile_id', $gestioneId) - ->where('codice', $code) - ->first(['id']); - - if ($existing) { - DB::table('voci_spesa')->where('id', (int) $existing->id)->update($payload); - } else { - $payload['created_at'] = now(); - DB::table('voci_spesa')->insert($payload); - } + $payload['created_at'] = now(); + DB::table('voci_spesa')->insert($payload); $sortOrder++; } @@ -528,8 +618,8 @@ private function normalizeFloor(mixed $rawFloor): int private function buildUnitCode(string $stableCode, string $scale, string $internal, string $legacyId): string { - $normalizedStable = trim($stableCode); - $normalizedScale = trim($scale) !== '' ? trim($scale) : 'A'; + $normalizedStable = trim($stableCode); + $normalizedScale = trim($scale) !== '' ? trim($scale) : 'A'; $normalizedInternal = strtoupper(trim(preg_replace('/\s+/', '', $internal) ?? $internal)); return $normalizedStable . '-' . $normalizedScale . '-' . $normalizedInternal . '-C' . $legacyId; @@ -543,10 +633,10 @@ private function normalizeLegacyCalcolo(?string $raw): ?string } return match ($value) { - 'm' => 'millesimi', + 'm' => 'millesimi', 'a', 'c' => 'a_consumo', - 'x' => 'conguagli', - 'p' => 'personali', + 'x' => 'conguagli', + 'p' => 'personali', default => $value, }; } @@ -559,7 +649,7 @@ private function normalizeLegacyTipo(?string $raw): ?string } return match ($value) { - 'O', 'ORD', 'ORDINARIA' => 'O', + 'O', 'ORD', 'ORDINARIA' => 'O', 'R', 'RISC', 'RISCALDAMENTO' => 'R', 'S', 'STRA', 'STRAORDINARIA' => 'S', default => $value, @@ -569,16 +659,15 @@ private function normalizeLegacyTipo(?string $raw): ?string private function resolveTipoTabellaFromLegacy(?string $tipoLegacy, ?string $calcolo, ?string $codice): string { $normalizedCalcolo = strtolower(trim((string) ($calcolo ?? ''))); - $normalizedCode = strtoupper(trim((string) ($codice ?? ''))); + $normalizedCode = strtoupper(trim((string) ($codice ?? ''))); if ($normalizedCode === 'ACQUA' || in_array($normalizedCalcolo, ['a_consumo', 'acqua', 'consumo', 'c', 'a'], true)) { return 'custom'; } return match ($tipoLegacy) { - 'R' => 'riscaldamento', - 'S' => 'straordinaria', + 'R' => 'riscaldamento', default => 'custom', }; } -} \ No newline at end of file +} diff --git a/app/Console/Commands/TecnoRepairImportRubricaClientiCommand.php b/app/Console/Commands/TecnoRepairImportRubricaClientiCommand.php index a47f072..db99554 100644 --- a/app/Console/Commands/TecnoRepairImportRubricaClientiCommand.php +++ b/app/Console/Commands/TecnoRepairImportRubricaClientiCommand.php @@ -215,8 +215,8 @@ private function collectUniqueCustomers(Collection $rows): Collection } foreach (['display_name', 'phone', 'phone_alt', 'email', 'indirizzo', 'cap', 'citta', 'provincia', 'partita_iva', 'codice_fiscale', 'note', 'imported_from_path'] as $field) { - if (($customers[$identity][$field] ?? null) === null && ${Str::camel($field)} !== null) { - $customers[$identity][$field] = ${Str::camel($field)}; + if (($customers[$identity][$field] ?? null) === null && ${Str::camel($field);} !== null) { + $customers[$identity][$field] = ${Str::camel($field);} } } } diff --git a/app/Filament/Pages/Contabilita/CasseBancheMovimenti.php b/app/Filament/Pages/Contabilita/CasseBancheMovimenti.php index dafd2d0..b294e7c 100644 --- a/app/Filament/Pages/Contabilita/CasseBancheMovimenti.php +++ b/app/Filament/Pages/Contabilita/CasseBancheMovimenti.php @@ -2,8 +2,11 @@ namespace App\Filament\Pages\Contabilita; use App\Models\DatiBancari; +use App\Models\DocumentoStabile; +use App\Models\FatturaElettronica; use App\Models\Fornitore; use App\Models\GestioneContabile; +use App\Models\IncassoPagamento; use App\Models\Stabile; use App\Models\User; use App\Modules\Contabilita\Models\CausaleBancaria; @@ -14,8 +17,8 @@ use App\Modules\Contabilita\Services\PagamentoFornitorePrimaNotaService; use App\Services\Contabilita\ExcelMovimentiParser; use App\Services\Contabilita\IntesaCsvParser; -use App\Services\Contabilita\MpsQifParser; use App\Services\Contabilita\MovimentiBancaImporter; +use App\Services\Contabilita\MpsQifParser; use App\Services\Contabilita\UnicreditWriParser; use App\Support\AnnoGestioneContext; use App\Support\GestioneContext; @@ -25,6 +28,7 @@ use Filament\Forms\Components\DatePicker; use Filament\Forms\Components\FileUpload; use Filament\Forms\Components\Select; +use Filament\Forms\Components\Textarea; use Filament\Forms\Components\TextInput; use Filament\Forms\Components\Toggle; use Filament\Notifications\Notification; @@ -41,6 +45,7 @@ use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Storage; +use Livewire\Attributes\Url; use UnitEnum; class CasseBancheMovimenti extends Page implements HasTable @@ -53,6 +58,15 @@ class CasseBancheMovimenti extends Page implements HasTable public ?int $detailMovementId = null; + #[Url(as: 'tab')] + public string $hubTab = 'conti'; + + public ?string $movimentiFocusFrom = null; + + public ?string $movimentiFocusTo = null; + + public int $riconciliazioneOffset = 0; + public string $periodoTipo = 'mese'; public int $periodoAnno; public int $periodoValore; @@ -96,6 +110,7 @@ public function mount(): void $today = now(); $this->periodoAnno = (int) $today->year; $this->periodoValore = (int) $today->month; + $this->hubTab = $this->normalizeHubTabValue($this->hubTab); $this->contoId = is_numeric(request()->query('conto_id')) ? (int) request()->query('conto_id') : null; $this->contoId = $this->contoId && $this->contoId > 0 ? $this->contoId : null; @@ -154,6 +169,81 @@ public function mount(): void } } } + + if ($this->contoId !== null) { + $this->syncSelectedContoFromId($this->contoId); + } + } + + public function updatedHubTab(): void + { + $this->hubTab = $this->normalizeHubTabValue($this->hubTab); + } + + public function updatedContoId(mixed $value): void + { + $contoId = is_numeric($value) ? (int) $value : null; + $this->syncSelectedContoFromId($contoId); + $this->resetContoDependentState(); + } + + public function goToHubTab(string $tab): void + { + $this->hubTab = $this->normalizeHubTabValue($tab); + } + + public function selectContoAndTab(int $contoId, string $tab = 'movimenti'): void + { + $this->syncSelectedContoFromId($contoId); + $this->resetContoDependentState(); + $this->hubTab = $this->normalizeHubTabValue($tab); + } + + public function clearMovimentiFocus(): void + { + $this->movimentiFocusFrom = null; + $this->movimentiFocusTo = null; + $this->resetTable(); + } + + public function openSaldoPeriodo(int $saldoId): void + { + $stabileId = $this->getActiveStabileId(); + if (! $stabileId || ! DB::getSchemaBuilder()->hasTable('contabilita_saldi_conti')) { + return; + } + + $saldo = SaldoConto::query() + ->where('stabile_id', $stabileId) + ->whereKey($saldoId) + ->first(['id', 'conto_id', 'iban', 'data_saldo', 'periodo_da', 'periodo_a']); + + if (! $saldo) { + return; + } + + if (is_numeric($saldo->conto_id)) { + $this->syncSelectedContoFromId((int) $saldo->conto_id); + } else { + $this->contoId = null; + $this->iban = $this->normalizeIbanValue($saldo->iban); + } + + $from = $saldo->periodo_da?->toDateString(); + $to = $saldo->periodo_a?->toDateString(); + + if (! $from && $saldo->data_saldo) { + $from = $saldo->data_saldo->copy()->startOfMonth()->toDateString(); + } + if (! $to && $saldo->data_saldo) { + $to = $saldo->data_saldo->copy()->endOfMonth()->toDateString(); + } + + $this->movimentiFocusFrom = $from; + $this->movimentiFocusTo = $to; + $this->applyPeriodoStateFromFocusRange(); + $this->hubTab = 'movimenti'; + $this->resetTable(); } public function getPeriodoTotaliProperty(): array @@ -339,15 +429,142 @@ protected function normalizePeriodoRate(?string $value): ?string protected function getHeaderActions(): array { return [ - Action::make('torna_riepilogo') - ->label('Torna a Casse e banche') - ->icon('heroicon-o-arrow-left') - ->url(fn() => CasseBancheRiepilogo::getUrl(panel: 'admin-filament')), + Action::make('hub_conti') + ->label('Tab conti') + ->icon('heroicon-o-banknotes') + ->action(fn() => $this->goToHubTab('conti')), - Action::make('saldi_conti') - ->label('Saldi conti') + Action::make('hub_saldi') + ->label('Tab saldi') ->icon('heroicon-o-scale') - ->url(fn() => SaldiContiArchivio::getUrl(panel: 'admin-filament')), + ->action(fn() => $this->goToHubTab('saldi')), + + Action::make('hub_riconciliazione') + ->label('Riconciliazione') + ->icon('heroicon-o-sparkles') + ->action(fn() => $this->goToHubTab('riconciliazione')), + + Action::make('registra_saldo_estratto') + ->label('Registra saldo / estratto') + ->icon('heroicon-o-document-duplicate') + ->modalWidth('4xl') + ->form([ + Select::make('conto_id') + ->label('Conto / cassa') + ->native(false) + ->searchable() + ->options(function (): array { + $opts = []; + foreach ($this->getContiImportabili() as $conto) { + $contoId = isset($conto['id']) && is_numeric($conto['id']) ? (int) $conto['id'] : 0; + if ($contoId <= 0) { + continue; + } + $opts[$contoId] = (string) ($conto['label'] ?? ('Conto #' . $contoId)); + } + + return $opts; + }) + ->default(fn(): ?int => $this->contoId) + ->required(), + + DatePicker::make('data_saldo') + ->label('Data saldo ufficiale') + ->required(), + + TextInput::make('saldo') + ->label('Saldo ufficiale') + ->numeric() + ->required(), + + DatePicker::make('periodo_da') + ->label('Periodo estratto dal'), + + DatePicker::make('periodo_a') + ->label('Periodo estratto al'), + + Select::make('tipo_estratto') + ->label('Tipo riferimento') + ->native(false) + ->options($this->getTipoEstrattoOptions()) + ->default('estratto_conto'), + + FileUpload::make('documento') + ->label('Allega estratto conto') + ->directory('tmp/banca') + ->disk('local') + ->acceptedFileTypes([ + 'application/pdf', + 'text/plain', + 'text/csv', + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + '.pdf', + '.csv', + '.txt', + '.qif', + '.xlsx', + ]), + + Textarea::make('note') + ->label('Note') + ->rows(3) + ->maxLength(255), + ]) + ->action(function (array $data): void { + $user = Auth::user(); + if (! $user instanceof User) { + Notification::make()->title('Utente non valido')->danger()->send(); + return; + } + + $stabileId = StabileContext::resolveActiveStabileId($user); + if (! $stabileId) { + Notification::make()->title('Seleziona uno stabile')->danger()->send(); + return; + } + + $contoId = isset($data['conto_id']) && is_numeric($data['conto_id']) ? (int) $data['conto_id'] : 0; + if ($contoId <= 0) { + Notification::make()->title('Seleziona un conto valido')->danger()->send(); + return; + } + + $conto = DatiBancari::query() + ->where('stabile_id', $stabileId) + ->where('id', $contoId) + ->first(['id', 'denominazione_banca', 'iban', 'numero_conto', 'legacy_cod_cassa']); + + if (! $conto) { + Notification::make()->title('Conto non valido per lo stabile attivo')->danger()->send(); + return; + } + + $documento = $this->storeSaldoDocumento($stabileId, $conto, $data['documento'] ?? null, $user, $data); + + SaldoConto::query()->create([ + 'stabile_id' => $stabileId, + 'conto_id' => (int) $conto->id, + 'iban' => $this->normalizeIbanValue($conto->iban), + 'data_saldo' => $data['data_saldo'] ?? null, + 'periodo_da' => $data['periodo_da'] ?? null, + 'periodo_a' => $data['periodo_a'] ?? null, + 'saldo' => isset($data['saldo']) ? (float) $data['saldo'] : 0.0, + 'tipo_estratto' => $this->normalizeTipoEstrattoValue($data['tipo_estratto'] ?? null), + 'note' => isset($data['note']) && is_string($data['note']) ? trim((string) $data['note']) : null, + 'documento_stabile_id' => $documento?->id, + 'created_by' => (int) $user->id, + 'updated_by' => (int) $user->id, + ]); + + $this->contoId = (int) $conto->id; + $this->iban = $this->normalizeIbanValue($conto->iban); + + Notification::make() + ->title('Saldo ufficiale registrato') + ->success() + ->send(); + }), Action::make('importa_estratto_unificato') ->label('Importa estratto') @@ -378,11 +595,11 @@ protected function getHeaderActions(): array ->native(false) ->options(function (): array { return [ - 'auto_csv' => 'CSV banca (rilevamento automatico)', - 'mps_qif' => 'MPS QIF', + 'auto_csv' => 'CSV banca (rilevamento automatico)', + 'mps_qif' => 'MPS QIF', 'unicredit_wri' => 'Unicredit WRI', - 'intesa_csv' => 'Intesa CSV', - 'xlsx' => 'Excel XLSX', + 'intesa_csv' => 'Intesa CSV', + 'xlsx' => 'Excel XLSX', ]; }) ->default(fn(): string => $this->resolvePreferredImportFormatForConto($this->contoId)) @@ -495,14 +712,14 @@ protected function getHeaderActions(): array } $gestioneId = isset($data['gestione_id']) && is_numeric($data['gestione_id']) ? (int) $data['gestione_id'] : null; - $replace = isset($data['replace']) ? (bool) $data['replace'] : false; + $replace = isset($data['replace']) ? (bool) $data['replace'] : false; try { $importer = $this->makeMovimentiImporter(); $filename = basename($path); $res = match ($format) { - 'xlsx' => $importer->importExcelXlsxForConto( + 'xlsx' => $importer->importExcelXlsxForConto( Storage::disk('local')->path($path), $stabileId, $contoId, @@ -518,7 +735,7 @@ protected function getHeaderActions(): array $gestioneId, $replace, ), - 'intesa_csv' => $importer->importIntesaCsvForConto( + 'intesa_csv' => $importer->importIntesaCsvForConto( Storage::disk('local')->get($path), $stabileId, $contoId, @@ -526,7 +743,7 @@ protected function getHeaderActions(): array $gestioneId, $replace, ), - 'mps_qif' => $importer->importMpsQifForConto( + 'mps_qif' => $importer->importMpsQifForConto( Storage::disk('local')->get($path), $stabileId, $contoId, @@ -534,7 +751,7 @@ protected function getHeaderActions(): array $gestioneId, $replace, ), - default => $importer->importCsvAutoForConto( + default => $importer->importCsvAutoForConto( Storage::disk('local')->get($path), $stabileId, $contoId, @@ -545,8 +762,8 @@ protected function getHeaderActions(): array }; $this->contoId = $contoId; - $iban = is_string($conto->iban) ? trim((string) $conto->iban) : ''; - $this->iban = $iban !== '' ? $iban : null; + $iban = is_string($conto->iban) ? trim((string) $conto->iban) : ''; + $this->iban = $iban !== '' ? $iban : null; Notification::make() ->title('Import completato') @@ -583,6 +800,8 @@ protected function getTableQuery(): Builder ->where('stabile_id', $activeStabileId) ->when($this->contoId !== null, fn(Builder $q): Builder => $q->where('conto_id', $this->contoId)) ->when($this->contoId === null && $this->iban !== null, fn(Builder $q): Builder => $q->where('iban', $this->iban)) + ->when($this->movimentiFocusFrom, fn(Builder $q): Builder => $q->whereDate('data', '>=', $this->movimentiFocusFrom)) + ->when($this->movimentiFocusTo, fn(Builder $q): Builder => $q->whereDate('data', '<=', $this->movimentiFocusTo)) ->orderBy('data') ->orderBy('id'); @@ -1269,24 +1488,21 @@ protected function resolveSaldoConSaldi(MovimentoBanca $record): ?float } $stabileId = (int) $record->stabile_id; - $iban = is_string($record->iban) ? trim((string) $record->iban) : ''; - if ($iban === '') { - // Per conti senza IBAN, al momento i movimenti non sono importati: saldo progressivo = somma importi. - return (float) $baseProgressivo; - } + $contoId = isset($record->conto_id) && is_numeric($record->conto_id) ? (int) $record->conto_id : null; + $iban = $this->normalizeIbanValue($record->iban); - $saldoIniziale = $this->resolveSaldoInizialeDaDatiBancari($stabileId, $this->contoId, $iban); + $saldoIniziale = $this->resolveSaldoInizialeDaDatiBancari($stabileId, $contoId, $iban); // Offset da eventuali saldi intermedi: saldo atteso alla data_saldo meno saldo calcolato fino a quella data. - $offset = $this->resolveSaldoOffsetDaSaldi($stabileId, $this->contoId, $iban, $record->data); + $offset = $this->resolveSaldoOffsetDaSaldi($stabileId, $contoId, $iban, $record->data); return $saldoIniziale + (float) $baseProgressivo + $offset; } - protected function resolveSaldoInizialeDaDatiBancari(int $stabileId, ?int $contoId, string $iban): float + protected function resolveSaldoInizialeDaDatiBancari(int $stabileId, ?int $contoId, ?string $iban): float { static $cache = []; - $key = $stabileId . '|' . ($contoId ?: 0) . '|' . $iban; + $key = $stabileId . '|' . ($contoId ?: 0) . '|' . ($iban ?: '-'); if (array_key_exists($key, $cache)) { return (float) $cache[$key]; } @@ -1294,8 +1510,11 @@ protected function resolveSaldoInizialeDaDatiBancari(int $stabileId, ?int $conto $q = DatiBancari::query()->where('stabile_id', $stabileId); if ($contoId) { $q->where('id', $contoId); - } else { + } elseif ($iban) { $q->where('iban', $iban); + } else { + $cache[$key] = 0.0; + return 0.0; } $row = $q->first(['saldo_iniziale']); @@ -1303,19 +1522,23 @@ protected function resolveSaldoInizialeDaDatiBancari(int $stabileId, ?int $conto return (float) $cache[$key]; } - protected function resolveSaldoOffsetDaSaldi(int $stabileId, ?int $contoId, string $iban, Carbon $recordDate): float + protected function resolveSaldoOffsetDaSaldi(int $stabileId, ?int $contoId, ?string $iban, Carbon $recordDate): float { if (! DB::getSchemaBuilder()->hasTable('contabilita_saldi_conti')) { return 0.0; } + if (! $contoId && ! $iban) { + return 0.0; + } + static $saldiCache = []; - $cacheKey = $stabileId . '|' . ($contoId ?: 0) . '|' . $iban; + $cacheKey = $stabileId . '|' . ($contoId ?: 0) . '|' . ($iban ?: '-'); if (! array_key_exists($cacheKey, $saldiCache)) { $q = SaldoConto::query()->where('stabile_id', $stabileId); if ($contoId) { $q->where('conto_id', $contoId); - } else { + } elseif ($iban) { $q->where('iban', $iban); } $saldiCache[$cacheKey] = $q @@ -1348,13 +1571,13 @@ protected function resolveSaldoOffsetDaSaldi(int $stabileId, ?int $contoId, stri static $sumToDateCache = []; $sumKey = $cacheKey . '|' . $match->data_saldo->toDateString() . '|' . (int) $match->id; if (! array_key_exists($sumKey, $sumToDateCache)) { - $q = MovimentoBanca::query() - ->where('stabile_id', $stabileId) - ->where('iban', $iban) - ->whereDate('data', '<=', $match->data_saldo->toDateString()); + $q = MovimentoBanca::query()->where('stabile_id', $stabileId); if ($contoId) { $q->where('conto_id', $contoId); + } elseif ($iban) { + $q->where('iban', $iban); } + $q->whereDate('data', '<=', $match->data_saldo->toDateString()); $sumToDateCache[$sumKey] = (float) ($q->sum('importo') ?? 0.0); } @@ -1525,6 +1748,543 @@ public function getActiveStabile(): ?Stabile return StabileContext::getActiveStabile($user); } + protected function getActiveStabileId(): ?int + { + $user = Auth::user(); + if (! $user instanceof User) { + return null; + } + + return StabileContext::resolveActiveStabileId($user); + } + + protected function normalizeHubTabValue(?string $value): string + { + $value = is_string($value) ? trim(strtolower($value)) : ''; + return in_array($value, ['conti', 'saldi', 'movimenti', 'riconciliazione'], true) ? $value : 'conti'; + } + + protected function syncSelectedContoFromId(?int $contoId): void + { + $contoId = $contoId && $contoId > 0 ? $contoId : null; + $this->contoId = $contoId; + + if (! $contoId) { + $this->iban = null; + return; + } + + $stabileId = $this->getActiveStabileId(); + if (! $stabileId) { + $this->contoId = null; + $this->iban = null; + return; + } + + $conto = DatiBancari::query() + ->where('stabile_id', $stabileId) + ->where('id', $contoId) + ->first(['id', 'iban']); + + if (! $conto) { + $this->contoId = null; + $this->iban = null; + return; + } + + $this->iban = $this->normalizeIbanValue($conto->iban); + } + + protected function resetContoDependentState(): void + { + $this->periodoRateMap = []; + $this->periodoRate = null; + $this->riconciliazioneOffset = 0; + $this->clearMovimentiFocus(); + } + + protected function applyPeriodoStateFromFocusRange(): void + { + if (! $this->movimentiFocusFrom || ! $this->movimentiFocusTo) { + return; + } + + try { + $from = Carbon::parse($this->movimentiFocusFrom); + $to = Carbon::parse($this->movimentiFocusTo); + } catch (\Throwable) { + return; + } + + $this->periodoAnno = (int) $from->year; + + $quarterStart = $from->copy()->startOfQuarter(); + $quarterEnd = $from->copy()->endOfQuarter(); + if ($from->isSameDay($quarterStart) && $to->isSameDay($quarterEnd)) { + $this->periodoTipo = 'trimestre'; + $this->periodoValore = (int) $from->quarter; + return; + } + + $this->periodoTipo = 'mese'; + $this->periodoValore = (int) $from->month; + } + + /** @return array> */ + public function getHubContiRows(): array + { + $stabileId = $this->getActiveStabileId(); + if (! $stabileId) { + return []; + } + + $rows = []; + foreach ($this->getContiImportabili() as $conto) { + $contoId = isset($conto['id']) && is_numeric($conto['id']) ? (int) $conto['id'] : 0; + if ($contoId <= 0) { + continue; + } + + $iban = $this->normalizeIbanValue($conto['iban'] ?? null); + $latestSaldo = $this->getLatestSaldoSnapshotForConto($stabileId, $contoId, $iban); + $lastMovement = MovimentoBanca::query() + ->where('stabile_id', $stabileId) + ->where('conto_id', $contoId) + ->orderByDesc('data') + ->orderByDesc('id') + ->first(['data']); + + $rows[] = [ + 'id' => $contoId, + 'label' => (string) ($conto['label'] ?? ('Conto #' . $contoId)), + 'iban' => $iban, + 'saldo_attuale' => $this->resolveSaldoAttualeConto($stabileId, $contoId, $iban), + 'saldo_snapshot' => $latestSaldo?->saldo !== null ? (float) $latestSaldo->saldo : null, + 'snapshot_data' => $latestSaldo?->data_saldo?->format('d/m/Y'), + 'snapshot_tipo' => $latestSaldo ? $this->formatTipoEstrattoLabel($latestSaldo->tipo_estratto) : null, + 'movimenti_count' => MovimentoBanca::query()->where('stabile_id', $stabileId)->where('conto_id', $contoId)->count(), + 'ultima_movimentazione' => $lastMovement?->data?->format('d/m/Y'), + 'selected' => $this->contoId === $contoId, + ]; + } + + return $rows; + } + + /** @return array> */ + public function getSaldoArchivioByYear(): array + { + $stabileId = $this->getActiveStabileId(); + if (! $stabileId || ! DB::getSchemaBuilder()->hasTable('contabilita_saldi_conti')) { + return []; + } + + if (! $this->contoId && ! $this->normalizeIbanValue($this->iban)) { + return []; + } + + $query = SaldoConto::query() + ->with('documento') + ->where('stabile_id', $stabileId); + + if ($this->contoId) { + $query->where('conto_id', $this->contoId); + } else { + $query->where('iban', $this->normalizeIbanValue($this->iban)); + } + + $groups = []; + foreach ($query->orderByDesc('data_saldo')->orderByDesc('id')->get() as $saldo) { + $year = $saldo->data_saldo?->format('Y') ?? 'Senza data'; + $groups[$year] ??= [ + 'year' => $year, + 'rows' => [], + ]; + + $groups[$year]['rows'][] = [ + 'id' => (int) $saldo->id, + 'data' => $saldo->data_saldo?->format('d/m/Y') ?? '—', + 'saldo' => (float) $saldo->saldo, + 'tipo' => $this->formatTipoEstrattoLabel($saldo->tipo_estratto), + 'periodo' => $this->formatPeriodoEstrattoLabel($saldo->periodo_da, $saldo->periodo_a), + 'periodo_breve' => $this->formatPeriodoSinteticoLabel($saldo->periodo_da, $saldo->periodo_a, $saldo->data_saldo), + 'note' => is_string($saldo->note) ? trim((string) $saldo->note) : '', + 'documento_label' => $saldo->documento?->nome_originale, + 'documento_url' => $saldo->documento?->url_view, + ]; + } + + krsort($groups); + + return array_values($groups); + } + + /** @return array */ + public function getRiconciliazioneStats(): array + { + $stabileId = $this->getActiveStabileId(); + if (! $stabileId) { + return ['totale' => 0, 'senza_prima_nota' => 0, 'collegati' => 0, 'da_confermare' => 0]; + } + + $query = MovimentoBanca::query()->where('stabile_id', $stabileId); + if ($this->contoId) { + $query->where('conto_id', $this->contoId); + } elseif ($this->iban) { + $query->where('iban', $this->iban); + } + + $totale = (clone $query)->count(); + $senzaPrimaNota = Schema::hasColumn('contabilita_movimenti_banca', 'registrazione_id') + ? (clone $query)->whereNull('registrazione_id')->count() + : 0; + $collegati = Schema::hasColumn('contabilita_movimenti_banca', 'registrazione_id') + ? (clone $query)->whereNotNull('registrazione_id')->count() + : 0; + $daConfermare = Schema::hasColumn('contabilita_movimenti_banca', 'da_confermare') + ? (clone $query)->where('da_confermare', true)->count() + : 0; + + return [ + 'totale' => $totale, + 'senza_prima_nota' => $senzaPrimaNota, + 'collegati' => $collegati, + 'da_confermare' => $daConfermare, + ]; + } + + /** @return array> */ + public function getRiconciliazioneQueue(): array + { + $stabileId = $this->getActiveStabileId(); + if (! $stabileId) { + return []; + } + + $query = MovimentoBanca::query() + ->where('stabile_id', $stabileId) + ->when($this->contoId !== null, fn(Builder $q): Builder => $q->where('conto_id', $this->contoId)) + ->when($this->contoId === null && $this->iban !== null, fn(Builder $q): Builder => $q->where('iban', $this->iban)) + ->orderBy('data') + ->orderBy('id'); + + if (Schema::hasColumn('contabilita_movimenti_banca', 'registrazione_id') || Schema::hasColumn('contabilita_movimenti_banca', 'da_confermare')) { + $query->where(function (Builder $sub): void { + if (Schema::hasColumn('contabilita_movimenti_banca', 'registrazione_id')) { + $sub->whereNull('registrazione_id'); + } + if (Schema::hasColumn('contabilita_movimenti_banca', 'da_confermare')) { + $sub->orWhere('da_confermare', true); + } + }); + } + + return $query + ->limit(200) + ->get(['id', 'data', 'importo', 'descrizione', 'descrizione_estesa', 'match_data', 'registrazione_id']) + ->map(function (MovimentoBanca $movimento): array { + $stato = $this->resolveStatoQuadratura($movimento); + + return [ + 'id' => (int) $movimento->id, + 'data' => $movimento->data?->format('d/m/Y') ?? '—', + 'importo' => (float) $movimento->importo, + 'descrizione' => (string) ($movimento->descrizione_estesa_pulita ?? $movimento->descrizione_estesa ?? $movimento->descrizione ?? '—'), + 'stato' => $stato, + 'has_registrazione' => ! empty($movimento->registrazione_id), + 'record' => $movimento, + ]; + }) + ->all(); + } + + /** @return array|null */ + public function getRiconciliazioneCurrent(): ?array + { + $queue = $this->getRiconciliazioneQueue(); + if ($queue === []) { + $this->riconciliazioneOffset = 0; + return null; + } + + $max = max(0, count($queue) - 1); + $this->riconciliazioneOffset = max(0, min($this->riconciliazioneOffset, $max)); + + return $queue[$this->riconciliazioneOffset] ?? null; + } + + public function previousRiconciliazione(): void + { + $this->riconciliazioneOffset = max(0, $this->riconciliazioneOffset - 1); + } + + public function nextRiconciliazione(): void + { + $queue = $this->getRiconciliazioneQueue(); + if ($queue === []) { + $this->riconciliazioneOffset = 0; + return; + } + + $this->riconciliazioneOffset = min(count($queue) - 1, $this->riconciliazioneOffset + 1); + } + + /** @return array> */ + public function getRiconciliazioneCandidates(): array + { + $current = $this->getRiconciliazioneCurrent(); + if (! $current || ! ($current['record'] instanceof MovimentoBanca)) { + return []; + } + + $record = $current['record']; + $stabileId = $this->getActiveStabileId(); + if (! $stabileId) { + return []; + } + + if ((float) $record->importo >= 0) { + return $this->buildIncassoCandidates($record, $stabileId); + } + + return $this->buildFatturaCandidates($record, $stabileId); + } + + protected function getLatestSaldoSnapshotForConto(int $stabileId, int $contoId, ?string $iban): ?SaldoConto + { + if (! DB::getSchemaBuilder()->hasTable('contabilita_saldi_conti')) { + return null; + } + + $query = SaldoConto::query()->where('stabile_id', $stabileId); + if ($contoId > 0) { + $query->where('conto_id', $contoId); + } elseif ($iban) { + $query->where('iban', $iban); + } else { + return null; + } + + return $query->orderByDesc('data_saldo')->orderByDesc('id')->first(); + } + + protected function resolveSaldoAttualeConto(int $stabileId, int $contoId, ?string $iban): float + { + $latestSaldo = $this->getLatestSaldoSnapshotForConto($stabileId, $contoId, $iban); + $saldoBase = $latestSaldo ? (float) $latestSaldo->saldo : $this->resolveSaldoInizialeDaDatiBancari($stabileId, $contoId, $iban); + $dataBase = $latestSaldo?->data_saldo?->toDateString(); + + $query = MovimentoBanca::query() + ->where('stabile_id', $stabileId) + ->where('conto_id', $contoId); + + if ($dataBase) { + $query->whereDate('data', '>', $dataBase); + } + + return $saldoBase + (float) ($query->sum('importo') ?? 0.0); + } + + protected function formatPeriodoSinteticoLabel(mixed $from, mixed $to, mixed $fallbackDate = null): string + { + $fromDate = $from instanceof Carbon ? $from : ($from ? Carbon::parse((string) $from) : null); + $toDate = $to instanceof Carbon ? $to : ($to ? Carbon::parse((string) $to) : null); + + if ($fromDate && $toDate) { + $quarterStart = $fromDate->copy()->startOfQuarter(); + $quarterEnd = $fromDate->copy()->endOfQuarter(); + if ($fromDate->isSameDay($quarterStart) && $toDate->isSameDay($quarterEnd)) { + return 'T' . $fromDate->quarter . ' / ' . $fromDate->format('Y'); + } + + if ($fromDate->isSameDay($fromDate->copy()->startOfMonth()) && $toDate->isSameDay($fromDate->copy()->endOfMonth())) { + return $fromDate->format('m/Y'); + } + } + + if ($fallbackDate instanceof Carbon) { + return $fallbackDate->format('m/Y'); + } + + return $this->formatPeriodoEstrattoLabel($from, $to); + } + + /** @return array> */ + protected function buildIncassoCandidates(MovimentoBanca $movimento, int $stabileId): array + { + if (! Schema::hasTable('incassi_pagamenti')) { + return []; + } + + $importo = abs((float) $movimento->importo); + $descrizione = (string) ($movimento->descrizione_estesa_pulita ?? $movimento->descrizione_estesa ?? $movimento->descrizione ?? ''); + + return IncassoPagamento::query() + ->with('rata') + ->where('stabile_id', $stabileId) + ->where('riconciliato', false) + ->whereBetween('data_incasso', [ + $movimento->data->copy()->subDays(30)->toDateString(), + $movimento->data->copy()->addDays(30)->toDateString(), + ]) + ->whereBetween('importo', [$importo - 10, $importo + 10]) + ->orderBy('data_incasso') + ->limit(20) + ->get() + ->map(function (IncassoPagamento $incasso) use ($movimento, $descrizione, $importo): array { + $score = $this->scoreCandidate( + $importo, + $movimento->data, + $descrizione, + (float) $incasso->importo, + $incasso->data_incasso, + trim((string) ($incasso->causale ?? '')) . ' ' . trim((string) ($incasso->note_bancarie ?? '')) + ); + + return [ + 'tipo' => 'Incasso registrato', + 'titolo' => 'Incasso #' . (int) $incasso->id . ($incasso->rata ? (' · rata ' . (string) $incasso->rata->numero_rata) : ''), + 'score' => $score['totale'], + 'importo' => (float) $incasso->importo, + 'data' => $incasso->data_incasso?->format('d/m/Y') ?? '—', + 'dettaglio' => trim((string) ($incasso->causale ?? $incasso->modalita_pagamento_label ?? 'Incasso')), + 'motivo' => $score['motivo'], + ]; + }) + ->sortByDesc('score') + ->take(5) + ->values() + ->all(); + } + + /** @return array> */ + protected function buildFatturaCandidates(MovimentoBanca $movimento, int $stabileId): array + { + if (! Schema::hasTable('fatture_elettroniche')) { + return []; + } + + $importo = abs((float) $movimento->importo); + $descrizione = (string) ($movimento->descrizione_estesa_pulita ?? $movimento->descrizione_estesa ?? $movimento->descrizione ?? ''); + + return FatturaElettronica::query() + ->where('stabile_id', $stabileId) + ->whereBetween('data_fattura', [ + $movimento->data->copy()->subDays(120)->toDateString(), + $movimento->data->copy()->addDays(30)->toDateString(), + ]) + ->whereBetween('totale', [$importo - 15, $importo + 15]) + ->orderByDesc('data_fattura') + ->limit(20) + ->get(['id', 'numero_fattura', 'data_fattura', 'totale', 'fornitore_denominazione', 'stato']) + ->map(function (FatturaElettronica $fattura) use ($movimento, $descrizione, $importo): array { + $score = $this->scoreCandidate( + $importo, + $movimento->data, + $descrizione, + (float) $fattura->totale, + $fattura->data_fattura, + trim((string) ($fattura->fornitore_denominazione ?? '')) . ' ' . trim((string) ($fattura->numero_fattura ?? '')) + ); + + return [ + 'tipo' => 'Fattura fornitore', + 'titolo' => trim((string) ($fattura->fornitore_denominazione ?: 'Fattura')) . ' · n. ' . trim((string) ($fattura->numero_fattura ?: $fattura->id)), + 'score' => $score['totale'], + 'importo' => (float) $fattura->totale, + 'data' => $fattura->data_fattura?->format('d/m/Y') ?? '—', + 'dettaglio' => 'Stato FE: ' . trim((string) ($fattura->stato ?: '—')), + 'motivo' => $score['motivo'], + ]; + }) + ->sortByDesc('score') + ->take(5) + ->values() + ->all(); + } + + /** @return array{totale:int,motivo:string} */ + protected function scoreCandidate(float $movimentoImporto, ?Carbon $movimentoData, string $movimentoText, float $candidateImporto, mixed $candidateData, string $candidateText): array + { + $score = 0; + $motivi = []; + + $diffImporto = abs($movimentoImporto - $candidateImporto); + if ($diffImporto < 0.01) { + $score += 50; + $motivi[] = 'importo identico'; + } elseif ($diffImporto <= 1) { + $score += 42; + $motivi[] = 'importo quasi identico'; + } elseif ($diffImporto <= 5) { + $score += 30; + $motivi[] = 'importo vicino'; + } elseif ($diffImporto <= 15) { + $score += 15; + } + + if ($movimentoData && $candidateData) { + $candidateCarbon = $candidateData instanceof Carbon ? $candidateData : Carbon::parse((string) $candidateData); + $days = abs($movimentoData->diffInDays($candidateCarbon)); + if ($days === 0) { + $score += 25; + $motivi[] = 'stessa data'; + } elseif ($days <= 3) { + $score += 20; + $motivi[] = 'entro 3 giorni'; + } elseif ($days <= 10) { + $score += 12; + } elseif ($days <= 30) { + $score += 6; + } + } + + $overlap = $this->calculateTokenOverlap($movimentoText, $candidateText); + if ($overlap >= 3) { + $score += 25; + $motivi[] = 'descrizione coerente'; + } elseif ($overlap === 2) { + $score += 18; + } elseif ($overlap === 1) { + $score += 8; + } + + return [ + 'totale' => min(100, $score), + 'motivo' => $motivi !== [] ? implode(' · ', $motivi) : 'match debole da confermare', + ]; + } + + protected function calculateTokenOverlap(string $left, string $right): int + { + $leftTokens = $this->extractMeaningfulTokens($left); + $rightTokens = $this->extractMeaningfulTokens($right); + + if ($leftTokens === [] || $rightTokens === []) { + return 0; + } + + return count(array_intersect($leftTokens, $rightTokens)); + } + + /** @return array */ + protected function extractMeaningfulTokens(string $value): array + { + $value = mb_strtolower($value); + $value = preg_replace('/[^a-z0-9]+/u', ' ', $value) ?? $value; + $parts = preg_split('/\s+/', trim($value)) ?: []; + + $stopWords = ['bonifico', 'pagamento', 'banca', 'conto', 'del', 'della', 'per', 'con', 'the', 'iban']; + + return array_values(array_unique(array_filter($parts, function (?string $token) use ($stopWords): bool { + if (! is_string($token) || strlen($token) < 3) { + return false; + } + + return ! in_array($token, $stopWords, true); + }))); + } + /** @return array */ public function getDisponibilitaFinanziarie(): array { @@ -1615,16 +2375,16 @@ public function getContiImportabili(): array ->orderBy('numero_conto') ->get(['id', 'denominazione_banca', 'iban', 'numero_conto', 'legacy_cod_cassa']) ->map(function (DatiBancari $conto): array { - $iban = is_string($conto->iban) ? trim((string) $conto->iban) : null; - $iban = $iban !== '' ? $iban : null; - $numeroConto = is_string($conto->numero_conto) ? trim((string) $conto->numero_conto) : null; - $numeroConto = $numeroConto !== '' ? $numeroConto : null; + $iban = is_string($conto->iban) ? trim((string) $conto->iban) : null; + $iban = $iban !== '' ? $iban : null; + $numeroConto = is_string($conto->numero_conto) ? trim((string) $conto->numero_conto) : null; + $numeroConto = $numeroConto !== '' ? $numeroConto : null; $legacyCodCassa = is_string($conto->legacy_cod_cassa) ? strtoupper(trim((string) $conto->legacy_cod_cassa)) : null; $legacyCodCassa = $legacyCodCassa !== '' ? $legacyCodCassa : null; - $denominazione = is_string($conto->denominazione_banca) ? trim((string) $conto->denominazione_banca) : null; - $denominazione = $denominazione !== '' ? $denominazione : null; + $denominazione = is_string($conto->denominazione_banca) ? trim((string) $conto->denominazione_banca) : null; + $denominazione = $denominazione !== '' ? $denominazione : null; - $parts = []; + $parts = []; $parts[] = $denominazione ?: 'Conto'; if ($legacyCodCassa) { $parts[] = 'cassa ' . $legacyCodCassa; @@ -1636,11 +2396,11 @@ public function getContiImportabili(): array } return [ - 'id' => (int) $conto->id, - 'iban' => $iban, - 'label' => implode(' · ', $parts), - 'legacy_cod_cassa' => $legacyCodCassa, - 'numero_conto' => $numeroConto, + 'id' => (int) $conto->id, + 'iban' => $iban, + 'label' => implode(' · ', $parts), + 'legacy_cod_cassa' => $legacyCodCassa, + 'numero_conto' => $numeroConto, 'denominazione_banca' => $denominazione, ]; }) @@ -1767,8 +2527,8 @@ public function getHeaderSaldoInfo(): array } $label = $conto - ? trim((string) ($conto->denominazione_banca ?: 'Conto') . ($conto->iban ? (' · ' . $conto->iban) : '')) - : (string) ($this->getSelectedIbanLabel() ?: 'Movimenti'); + ? $this->buildContoLabel($conto) + : (string) (($this->getSelectedContoImportInfo()['label'] ?? null) ?: ($this->getSelectedIbanLabel() ?: 'Movimenti')); $movQ = MovimentoBanca::query()->where('stabile_id', $stabileId); if ($this->contoId) { @@ -1780,11 +2540,10 @@ public function getHeaderSaldoInfo(): array $lastMov = $movQ->orderByDesc('data')->orderByDesc('id')->first(['data']); // Saldo attuale: usa l'ultimo saldo inserito come base (se presente), poi somma i movimenti successivi. - $iban = $this->iban; - if (! is_string($iban) || trim($iban) === '') { + $iban = $this->normalizeIbanValue($this->iban); + if (! $this->contoId && ! $iban) { return ['label' => $label, 'data' => $lastMov ? $lastMov->data->format('d/m/Y') : null, 'saldo' => null]; } - $iban = trim($iban); $saldoBase = null; $dataBase = null; @@ -1797,7 +2556,7 @@ public function getHeaderSaldoInfo(): array $qSaldo = SaldoConto::query()->where('stabile_id', $stabileId); if ($this->contoId) { $qSaldo->where('conto_id', $this->contoId); - } else { + } elseif ($iban) { $qSaldo->where('iban', $iban); } if ($periodFrom) { @@ -1812,9 +2571,11 @@ public function getHeaderSaldoInfo(): array $saldoIniziale = $this->resolveSaldoInizialeDaDatiBancari($stabileId, $this->contoId, $iban); - $sumQ = MovimentoBanca::query()->where('stabile_id', $stabileId)->where('iban', $iban); + $sumQ = MovimentoBanca::query()->where('stabile_id', $stabileId); if ($this->contoId) { $sumQ->where('conto_id', $this->contoId); + } elseif ($iban) { + $sumQ->where('iban', $iban); } if ($dataBase) { $sumQ->whereDate('data', '>', $dataBase); @@ -1826,4 +2587,164 @@ public function getHeaderSaldoInfo(): array return ['label' => $label, 'data' => $dataLabel, 'saldo' => $saldoAttuale]; } + + public function getSaldoSnapshotCards(): array + { + $user = Auth::user(); + if (! $user instanceof User) { + return []; + } + + $stabileId = StabileContext::resolveActiveStabileId($user); + if (! $stabileId || ! DB::getSchemaBuilder()->hasTable('contabilita_saldi_conti')) { + return []; + } + + if (! $this->contoId && ! $this->normalizeIbanValue($this->iban)) { + return []; + } + + $query = SaldoConto::query() + ->with('documento') + ->where('stabile_id', $stabileId); + + if ($this->contoId) { + $query->where('conto_id', $this->contoId); + } else { + $query->where('iban', $this->normalizeIbanValue($this->iban)); + } + + return $query + ->orderByDesc('data_saldo') + ->orderByDesc('id') + ->limit(6) + ->get() + ->map(function (SaldoConto $saldo): array { + return [ + 'data' => $saldo->data_saldo?->format('d/m/Y'), + 'saldo' => (float) $saldo->saldo, + 'tipo' => $this->formatTipoEstrattoLabel($saldo->tipo_estratto), + 'periodo' => $this->formatPeriodoEstrattoLabel($saldo->periodo_da, $saldo->periodo_a), + 'documento_label' => $saldo->documento?->nome_originale, + 'documento_url' => $saldo->documento?->url_view, + ]; + }) + ->all(); + } + + protected function getTipoEstrattoOptions(): array + { + return [ + 'estratto_conto' => 'Estratto conto', + 'saldo_banca' => 'Saldo banca', + 'riconciliazione' => 'Riconciliazione', + 'altro' => 'Altro', + ]; + } + + protected function normalizeTipoEstrattoValue(mixed $value): ?string + { + $value = is_string($value) ? trim($value) : ''; + if ($value === '') { + return null; + } + + return array_key_exists($value, $this->getTipoEstrattoOptions()) ? $value : 'altro'; + } + + protected function formatTipoEstrattoLabel(?string $value): string + { + $value = $this->normalizeTipoEstrattoValue($value); + if (! $value) { + return 'Saldo registrato'; + } + + return $this->getTipoEstrattoOptions()[$value] ?? 'Saldo registrato'; + } + + protected function formatPeriodoEstrattoLabel(mixed $from, mixed $to): string + { + $fromDate = $from instanceof Carbon ? $from : ($from ? Carbon::parse((string) $from) : null); + $toDate = $to instanceof Carbon ? $to : ($to ? Carbon::parse((string) $to) : null); + + if ($fromDate && $toDate) { + return $fromDate->format('d/m/Y') . ' - ' . $toDate->format('d/m/Y'); + } + + if ($toDate) { + return 'Fino al ' . $toDate->format('d/m/Y'); + } + + if ($fromDate) { + return 'Dal ' . $fromDate->format('d/m/Y'); + } + + return 'Periodo non indicato'; + } + + protected function buildContoLabel(DatiBancari $conto): string + { + $parts = []; + $parts[] = trim((string) ($conto->denominazione_banca ?: 'Conto')); + + $legacyCodCassa = is_string($conto->legacy_cod_cassa ?? null) ? trim((string) $conto->legacy_cod_cassa) : ''; + $numeroConto = is_string($conto->numero_conto ?? null) ? trim((string) $conto->numero_conto) : ''; + $iban = $this->normalizeIbanValue($conto->iban); + + if ($legacyCodCassa !== '') { + $parts[] = 'cassa ' . strtoupper($legacyCodCassa); + } + if ($iban) { + $parts[] = $iban; + } elseif ($numeroConto !== '') { + $parts[] = 'n. ' . $numeroConto; + } + + return implode(' · ', array_filter($parts, fn(?string $part): bool => is_string($part) && trim($part) !== '')); + } + + protected function normalizeIbanValue(mixed $value): ?string + { + $iban = is_string($value) ? trim((string) $value) : ''; + return $iban !== '' ? $iban : null; + } + + protected function storeSaldoDocumento(int $stabileId, DatiBancari $conto, mixed $tempPath, User $user, array $data): ?DocumentoStabile + { + $path = is_string($tempPath) ? trim($tempPath) : ''; + if ($path === '') { + return null; + } + + $extension = strtolower(pathinfo($path, PATHINFO_EXTENSION)); + $extension = $extension !== '' ? $extension : 'bin'; + $filename = 'estratto-conto-' . $stabileId . '-' . (int) $conto->id . '-' . now()->format('YmdHis') . '.' . $extension; + $publicPath = 'documenti/stabili/' . $stabileId . '/bancari/' . $filename; + + Storage::disk('public')->put($publicPath, Storage::disk('local')->get($path)); + + try { + Storage::disk('local')->delete($path); + } catch (\Throwable) { + // ignore + } + + $descrizione = 'Estratto conto ' . $this->buildContoLabel($conto); + $periodo = $this->formatPeriodoEstrattoLabel($data['periodo_da'] ?? null, $data['periodo_a'] ?? null); + if ($periodo !== 'Periodo non indicato') { + $descrizione .= ' · ' . $periodo; + } + + return DocumentoStabile::query()->create([ + 'stabile_id' => $stabileId, + 'nome_file' => $filename, + 'nome_originale' => $filename, + 'percorso_file' => $publicPath, + 'categoria' => 'bancari', + 'tipo_mime' => Storage::disk('public')->mimeType($publicPath), + 'dimensione' => Storage::disk('public')->size($publicPath), + 'descrizione' => $descrizione, + 'caricato_da' => (int) $user->id, + ]); + } } diff --git a/app/Filament/Pages/Contabilita/CasseBancheRiepilogo.php b/app/Filament/Pages/Contabilita/CasseBancheRiepilogo.php index 981f235..4f39f13 100644 --- a/app/Filament/Pages/Contabilita/CasseBancheRiepilogo.php +++ b/app/Filament/Pages/Contabilita/CasseBancheRiepilogo.php @@ -3,6 +3,7 @@ namespace App\Filament\Pages\Contabilita; use App\Models\DatiBancari; +use App\Models\DocumentoStabile; use App\Models\GestioneContabile; use App\Models\Stabile; use App\Models\User; @@ -343,6 +344,19 @@ public function table(Table $table): Table ->getStateUsing(fn(DatiBancari $record): ?float => $this->resolveSaldoConto($record)) ->formatStateUsing(fn($state) => $state !== null ? ('€ ' . number_format((float) $state, 2, ',', '.')) : '—'), + TextColumn::make('saldo_ufficiale') + ->label('Saldo ufficiale') + ->alignEnd() + ->getStateUsing(fn(DatiBancari $record): ?float => $this->resolveLatestSaldoSnapshotValue($record)) + ->formatStateUsing(fn($state) => $state !== null ? ('€ ' . number_format((float) $state, 2, ',', '.')) : '—') + ->toggleable(), + + TextColumn::make('ultimo_estratto') + ->label('Ultimo estratto') + ->getStateUsing(fn(DatiBancari $record): string => $this->resolveLatestSaldoSnapshotLabel($record)) + ->wrap() + ->toggleable(), + TextColumn::make('stato_conto') ->label('Stato') ->toggleable(isToggledHiddenByDefault: true), @@ -379,56 +393,18 @@ public function getTotaleDisponibilita(): ?float $conti = DatiBancari::query() ->where('stabile_id', $stabileId) - ->whereNotNull('iban') - ->where('iban', '!=', '') - ->get(['iban', 'saldo_iniziale']); + ->get(); if ($conti->isEmpty()) { return 0.0; } - $ibans = $conti->pluck('iban')->map(fn($v) => trim((string) $v))->filter()->values()->all(); - if ($ibans === []) { - return 0.0; - } - - $movQ = MovimentoBanca::query() - ->where('stabile_id', $stabileId) - ->whereIn('iban', $ibans); - - if ($from) { - $movQ->whereDate('data', '>=', $from); - } - if ($to) { - $movQ->whereDate('data', '<=', $to); - } - if ($gestioneIds !== []) { - $movQ->whereIn('gestione_id', $gestioneIds); - } - - $sums = $movQ - ->groupBy('iban') - ->selectRaw('iban, COALESCE(SUM(importo), 0) AS saldo_movimenti') - ->pluck('saldo_movimenti', 'iban') - ->map(fn($v) => (float) $v) - ->all(); - $total = 0.0; foreach ($conti as $c) { - $iban = trim((string) ($c->iban ?? '')); - if ($iban === '') { - continue; + $saldo = $this->resolveSaldoConto($c); + if ($saldo !== null) { + $total += $saldo; } - - $saldoIniziale = is_numeric($c->saldo_iniziale) ? (float) $c->saldo_iniziale : 0.0; - if ($from) { - $alt = $this->resolveSaldoIniziale($stabileId, $iban, (string) $from); - if ($alt !== null) { - $saldoIniziale = $alt; - } - } - - $total += $saldoIniziale + ($sums[$iban] ?? 0.0); } return $total; @@ -527,6 +503,109 @@ protected function resolveSaldoIniziale(int $stabileId, string $iban, string $fr return $row ? (float) $row->saldo : null; } + protected function resolveLatestSaldoSnapshot(DatiBancari $record): ?SaldoConto + { + static $cache = []; + + $cacheKey = (int) $record->id; + if (array_key_exists($cacheKey, $cache)) { + return $cache[$cacheKey]; + } + + if (! DB::getSchemaBuilder()->hasTable('contabilita_saldi_conti')) { + return $cache[$cacheKey] = null; + } + + return $cache[$cacheKey] = SaldoConto::query() + ->with('documento') + ->where('stabile_id', (int) $record->stabile_id) + ->where('conto_id', (int) $record->id) + ->orderByDesc('data_saldo') + ->orderByDesc('id') + ->first(); + } + + protected function resolveLatestSaldoSnapshotValue(DatiBancari $record): ?float + { + $snapshot = $this->resolveLatestSaldoSnapshot($record); + + return $snapshot ? (float) $snapshot->saldo : null; + } + + protected function resolveLatestSaldoSnapshotLabel(DatiBancari $record): string + { + $snapshot = $this->resolveLatestSaldoSnapshot($record); + if (! $snapshot) { + return 'Nessuno estratto registrato'; + } + + $parts = []; + $parts[] = $snapshot->tipo_estratto + ? ($this->getTipoEstrattoOptions()[$snapshot->tipo_estratto] ?? 'Saldo registrato') + : 'Saldo registrato'; + if ($snapshot->periodo_da || $snapshot->periodo_a) { + $from = $snapshot->periodo_da?->format('d/m/Y'); + $to = $snapshot->periodo_a?->format('d/m/Y'); + $parts[] = trim(($from ?: '...') . ' - ' . ($to ?: '...')); + } + if ($snapshot->documento instanceof DocumentoStabile) { + $parts[] = $snapshot->documento->nome_originale ?: 'Documento allegato'; + } + + return implode(' · ', $parts); + } + + protected function getTipoEstrattoOptions(): array + { + return [ + 'estratto_conto' => 'Estratto conto', + 'saldo_banca' => 'Saldo banca', + 'riconciliazione' => 'Riconciliazione', + 'altro' => 'Altro', + ]; + } + + protected function getContiOptions(): array + { + $user = Auth::user(); + if (! $user instanceof User) { + return []; + } + + $stabileId = StabileContext::resolveActiveStabileId($user); + if (! $stabileId) { + return []; + } + + return DatiBancari::query() + ->where('stabile_id', $stabileId) + ->orderBy('denominazione_banca') + ->orderBy('legacy_cod_cassa') + ->orderBy('numero_conto') + ->orderBy('id') + ->get(['id', 'denominazione_banca', 'iban', 'legacy_cod_cassa', 'numero_conto']) + ->mapWithKeys(function (DatiBancari $conto): array { + $parts = []; + $parts[] = trim((string) ($conto->denominazione_banca ?: 'Conto')); + + $legacyCodCassa = is_string($conto->legacy_cod_cassa) ? trim((string) $conto->legacy_cod_cassa) : ''; + $numeroConto = is_string($conto->numero_conto) ? trim((string) $conto->numero_conto) : ''; + $iban = is_string($conto->iban) ? trim((string) $conto->iban) : ''; + + if ($legacyCodCassa !== '') { + $parts[] = 'cassa ' . strtoupper($legacyCodCassa); + } + if ($iban !== '') { + $parts[] = $iban; + } elseif ($numeroConto !== '') { + $parts[] = 'n. ' . $numeroConto; + } + + return [(int) $conto->id => implode(' · ', $parts)]; + }) + ->all(); + } + /** @return array */ protected function getIbanOptions(): array { diff --git a/app/Filament/Pages/Contabilita/SaldiContiArchivio.php b/app/Filament/Pages/Contabilita/SaldiContiArchivio.php index 197df50..1738b3a 100644 --- a/app/Filament/Pages/Contabilita/SaldiContiArchivio.php +++ b/app/Filament/Pages/Contabilita/SaldiContiArchivio.php @@ -3,12 +3,14 @@ namespace App\Filament\Pages\Contabilita; use App\Models\DatiBancari; +use App\Models\DocumentoStabile; use App\Models\User; use App\Modules\Contabilita\Models\SaldoConto; use App\Support\StabileContext; use BackedEnum; use Filament\Actions\Action; use Filament\Forms\Components\DatePicker; +use Filament\Forms\Components\FileUpload; use Filament\Forms\Components\Select; use Filament\Forms\Components\Textarea; use Filament\Forms\Components\TextInput; @@ -117,6 +119,24 @@ public function table(Table $table): Table ->alignEnd() ->formatStateUsing(fn($state) => '€ ' . number_format((float) $state, 2, ',', '.')), + TextColumn::make('tipo_estratto') + ->label('Tipo') + ->getStateUsing(fn(SaldoConto $record): string => $this->formatTipoEstrattoLabel($record->tipo_estratto)) + ->toggleable(), + + TextColumn::make('periodo_estratto') + ->label('Periodo estratto') + ->getStateUsing(fn(SaldoConto $record): string => $this->formatPeriodoEstrattoLabel($record->periodo_da, $record->periodo_a)) + ->wrap() + ->toggleable(), + + TextColumn::make('documento') + ->label('Documento') + ->getStateUsing(fn(SaldoConto $record): string => $record->documento?->nome_originale ?: '—') + ->url(fn(SaldoConto $record): ?string => $record->documento?->url_view) + ->openUrlInNewTab() + ->toggleable(), + TextColumn::make('note') ->label('Note') ->wrap() @@ -140,7 +160,30 @@ public function table(Table $table): Table ->required(), DatePicker::make('data_saldo')->label('Data')->required(), + DatePicker::make('periodo_da')->label('Periodo dal'), + DatePicker::make('periodo_a')->label('Periodo al'), TextInput::make('saldo')->label('Saldo')->numeric()->required(), + Select::make('tipo_estratto') + ->label('Tipo riferimento') + ->native(false) + ->options($this->getTipoEstrattoOptions()) + ->default('estratto_conto'), + FileUpload::make('documento') + ->label('Allega estratto') + ->directory('tmp/banca') + ->disk('local') + ->acceptedFileTypes([ + 'application/pdf', + 'text/plain', + 'text/csv', + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + '.pdf', + '.csv', + '.txt', + '.qif', + '.xlsx', + ]), Textarea::make('note')->label('Note (opzionale)')->rows(2)->maxLength(255), ]) ->action(function (array $data): void { @@ -161,18 +204,26 @@ public function table(Table $table): Table $conto = DatiBancari::query() ->where('stabile_id', $stabileId) ->where('id', $contoId) - ->first(['id', 'iban']); + ->first(['id', 'denominazione_banca', 'iban', 'numero_conto', 'legacy_cod_cassa']); $iban = $conto && is_string($conto->iban) ? trim((string) $conto->iban) : null; $iban = is_string($iban) && $iban !== '' ? $iban : null; + $documento = $conto + ? $this->storeSaldoDocumento($stabileId, $conto, $data['documento'] ?? null, $user, $data) + : null; + SaldoConto::query()->create([ 'stabile_id' => (int) $stabileId, 'conto_id' => $contoId, 'iban' => $iban, 'data_saldo' => $data['data_saldo'] ?? null, + 'periodo_da' => $data['periodo_da'] ?? null, + 'periodo_a' => $data['periodo_a'] ?? null, 'saldo' => isset($data['saldo']) ? (float) $data['saldo'] : 0.0, + 'tipo_estratto' => $this->normalizeTipoEstrattoValue($data['tipo_estratto'] ?? null), 'note' => isset($data['note']) && is_string($data['note']) ? trim((string) $data['note']) : null, + 'documento_stabile_id' => $documento?->id, 'created_by' => (int) ($user->id ?? 0) ?: null, 'updated_by' => (int) ($user->id ?? 0) ?: null, ]); @@ -184,20 +235,53 @@ public function table(Table $table): Table ->icon('heroicon-o-pencil-square') ->form([ DatePicker::make('data_saldo')->label('Data')->required(), + DatePicker::make('periodo_da')->label('Periodo dal'), + DatePicker::make('periodo_a')->label('Periodo al'), TextInput::make('saldo')->label('Saldo')->numeric()->required(), + Select::make('tipo_estratto') + ->label('Tipo riferimento') + ->native(false) + ->options($this->getTipoEstrattoOptions()), + FileUpload::make('documento') + ->label('Sostituisci / aggiungi allegato') + ->directory('tmp/banca') + ->disk('local') + ->acceptedFileTypes([ + 'application/pdf', + 'text/plain', + 'text/csv', + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + '.pdf', + '.csv', + '.txt', + '.qif', + '.xlsx', + ]), Textarea::make('note')->label('Note (opzionale)')->rows(2)->maxLength(255), ]) ->fillForm(fn(SaldoConto $record): array => [ 'data_saldo' => $record->data_saldo, + 'periodo_da' => $record->periodo_da, + 'periodo_a' => $record->periodo_a, 'saldo' => (float) $record->saldo, + 'tipo_estratto' => $record->tipo_estratto, 'note' => $record->note, ]) ->action(function (SaldoConto $record, array $data): void { $user = Auth::user(); + $documento = $record->conto + ? $this->storeSaldoDocumento((int) $record->stabile_id, $record->conto, $data['documento'] ?? null, $user instanceof User ? $user : null, $data) + : null; + $record->fill([ 'data_saldo' => $data['data_saldo'] ?? $record->data_saldo, + 'periodo_da' => $data['periodo_da'] ?? null, + 'periodo_a' => $data['periodo_a'] ?? null, 'saldo' => isset($data['saldo']) ? (float) $data['saldo'] : (float) $record->saldo, + 'tipo_estratto' => $this->normalizeTipoEstrattoValue($data['tipo_estratto'] ?? null), 'note' => isset($data['note']) && is_string($data['note']) ? trim((string) $data['note']) : null, + 'documento_stabile_id' => $documento?->id ?: $record->documento_stabile_id, 'updated_by' => $user instanceof User ? ((int) $user->id ?: null) : null, ]); $record->save(); @@ -243,4 +327,84 @@ protected function getContiOptions(): array }) ->all(); } + + protected function getTipoEstrattoOptions(): array + { + return [ + 'estratto_conto' => 'Estratto conto', + 'saldo_banca' => 'Saldo banca', + 'riconciliazione' => 'Riconciliazione', + 'altro' => 'Altro', + ]; + } + + protected function normalizeTipoEstrattoValue(mixed $value): ?string + { + $value = is_string($value) ? trim((string) $value) : ''; + if ($value === '') { + return null; + } + + return array_key_exists($value, $this->getTipoEstrattoOptions()) ? $value : 'altro'; + } + + protected function formatTipoEstrattoLabel(?string $value): string + { + $normalized = $this->normalizeTipoEstrattoValue($value); + if (! $normalized) { + return 'Saldo registrato'; + } + + return $this->getTipoEstrattoOptions()[$normalized] ?? 'Saldo registrato'; + } + + protected function formatPeriodoEstrattoLabel(mixed $from, mixed $to): string + { + $fromDate = $from ? \Illuminate\Support\Carbon::parse((string) $from) : null; + $toDate = $to ? \Illuminate\Support\Carbon::parse((string) $to) : null; + + if ($fromDate && $toDate) { + return $fromDate->format('d/m/Y') . ' - ' . $toDate->format('d/m/Y'); + } + if ($toDate) { + return 'Fino al ' . $toDate->format('d/m/Y'); + } + if ($fromDate) { + return 'Dal ' . $fromDate->format('d/m/Y'); + } + + return 'Periodo non indicato'; + } + + protected function storeSaldoDocumento(int $stabileId, DatiBancari $conto, mixed $tempPath, ?User $user, array $data): ?DocumentoStabile + { + $path = is_string($tempPath) ? trim((string) $tempPath) : ''; + if ($path === '' || ! $user instanceof User) { + return null; + } + + $extension = strtolower(pathinfo($path, PATHINFO_EXTENSION)); + $extension = $extension !== '' ? $extension : 'bin'; + $filename = 'estratto-conto-' . $stabileId . '-' . (int) $conto->id . '-' . now()->format('YmdHis') . '.' . $extension; + $publicPath = 'documenti/stabili/' . $stabileId . '/bancari/' . $filename; + + \Illuminate\Support\Facades\Storage::disk('public')->put($publicPath, \Illuminate\Support\Facades\Storage::disk('local')->get($path)); + try { + \Illuminate\Support\Facades\Storage::disk('local')->delete($path); + } catch (\Throwable) { + // ignore + } + + return DocumentoStabile::query()->create([ + 'stabile_id' => $stabileId, + 'nome_file' => $filename, + 'nome_originale' => $filename, + 'percorso_file' => $publicPath, + 'categoria' => 'bancari', + 'tipo_mime' => \Illuminate\Support\Facades\Storage::disk('public')->mimeType($publicPath), + 'dimensione' => \Illuminate\Support\Facades\Storage::disk('public')->size($publicPath), + 'descrizione' => 'Estratto conto ' . trim((string) ($conto->denominazione_banca ?: 'Conto')) . ' · ' . $this->formatPeriodoEstrattoLabel($data['periodo_da'] ?? null, $data['periodo_a'] ?? null), + 'caricato_da' => (int) $user->id, + ]); + } } diff --git a/app/Filament/Pages/Contabilita/SituazioneIniziale.php b/app/Filament/Pages/Contabilita/SituazioneIniziale.php index 07a1791..a66e6d5 100644 --- a/app/Filament/Pages/Contabilita/SituazioneIniziale.php +++ b/app/Filament/Pages/Contabilita/SituazioneIniziale.php @@ -3,10 +3,14 @@ namespace App\Filament\Pages\Contabilita; use App\Models\DatiBancari; +use App\Models\DocumentoStabile; use App\Models\FatturaElettronica; use App\Models\GestioneContabile; +use App\Models\PersonaUnitaRelazione; use App\Models\RegistroRitenuteAcconto; use App\Models\Stabile; +use App\Models\UnitaImmobiliare; +use App\Models\UnitaImmobiliareNominativo; use App\Models\User; use App\Modules\Contabilita\Models\MovimentoBanca; use App\Modules\Contabilita\Models\SaldoConto; @@ -16,12 +20,17 @@ use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; +use Illuminate\Support\Facades\Storage; use Livewire\Attributes\Url; +use Livewire\Features\SupportFileUploads\TemporaryUploadedFile; +use Livewire\WithFileUploads; use UnitEnum; use BackedEnum; class SituazioneIniziale extends Page { + use WithFileUploads; + protected static ?string $navigationLabel = 'Situazione iniziale'; protected static ?string $title = 'Situazione iniziale'; @@ -116,6 +125,25 @@ class SituazioneIniziale extends Page public array $detailRows = []; + public ?string $saldoContoId = null; + + public ?string $saldoSnapshotData = null; + + public ?string $saldoSnapshotPeriodoDa = null; + + public ?string $saldoSnapshotPeriodoA = null; + + public ?string $saldoSnapshotSaldo = null; + + public string $saldoSnapshotTipo = 'estratto_conto'; + + public ?string $saldoSnapshotNote = null; + + public TemporaryUploadedFile|string|null $saldoSnapshotPdf = null; + + /** @var array> */ + public array $saldoSnapshots = []; + /** @var array */ public array $vocSpeOptions = []; // Editing "a riga" (riduce la pagina: si modifica una riga alla volta) @@ -239,7 +267,7 @@ public function cancelEditDettTabRow(): void private function loadCondominiLabels(string $codStabileLegacy, ?string $legacyYear): array { - $out = []; + $out = $this->loadLocalCondominiLabels(); // Tabella "condomini" (più snella) se presente if (Schema::connection('gescon_import')->hasTable('condomini')) { @@ -260,12 +288,14 @@ private function loadCondominiLabels(string $codStabileLegacy, ?string $legacyYe if ($label === '') { $label = trim((string) ($r->cod_cond ?? '')); } - $out[$id] = $label !== '' ? $label : $id; + if (! isset($out[$id]) || trim((string) $out[$id]) === '') { + $out[$id] = $label !== '' ? $label : $id; + } } } // Fallback su "condomin" (più completa) - if (empty($out) && Schema::connection('gescon_import')->hasTable('condomin')) { + if (Schema::connection('gescon_import')->hasTable('condomin')) { $q = DB::connection('gescon_import')->table('condomin') ->where('cod_stabile', $codStabileLegacy); @@ -289,13 +319,144 @@ private function loadCondominiLabels(string $codStabileLegacy, ?string $legacyYe if ($label === '') { $label = trim((string) ($r->cod_cond ?? '')); } - $out[$id] = $label !== '' ? $label : $id; + if (! isset($out[$id]) || trim((string) $out[$id]) === '') { + $out[$id] = $label !== '' ? $label : $id; + } } } return $out; } + /** + * @return array + */ + private function loadLocalCondominiLabels(): array + { + $stabileId = $this->getActiveStabile()?->id; + if (! $stabileId || ! Schema::hasTable('unita_immobiliari') || ! Schema::hasColumn('unita_immobiliari', 'legacy_cond_id')) { + return []; + } + + $referenceDate = $this->resolveReferenceDate(); + $units = UnitaImmobiliare::query() + ->where('stabile_id', (int) $stabileId) + ->whereNotNull('legacy_cond_id') + ->get(['id', 'legacy_cond_id', 'scala', 'interno', 'denominazione']); + + if ($units->isEmpty()) { + return []; + } + + $unitIds = $units->pluck('id')->map(fn ($id) => (int) $id)->all(); + $namesByUnit = []; + + if (Schema::hasTable('persone_unita_relazioni')) { + $relations = PersonaUnitaRelazione::query() + ->with('persona') + ->whereIn('unita_id', $unitIds) + ->where('attivo', true) + ->where(function ($query) use ($referenceDate): void { + $query->whereNull('data_inizio')->orWhere('data_inizio', '<=', $referenceDate->toDateString()); + }) + ->where(function ($query) use ($referenceDate): void { + $query->whereNull('data_fine')->orWhere('data_fine', '>=', $referenceDate->toDateString()); + }) + ->get(); + + foreach ($relations as $relation) { + $unitId = (int) $relation->unita_id; + $role = strtoupper((string) ($relation->ruolo_rate ?: PersonaUnitaRelazione::deriveRuoloRate($relation->tipo_relazione))); + if (! in_array($role, ['C', 'I'], true)) { + continue; + } + + $name = trim((string) ($relation->persona?->nome_display ?? $relation->persona?->nome_completo ?? '')); + if ($name === '') { + continue; + } + + $namesByUnit[$unitId][$role][$name] = $name; + } + } + + if (Schema::hasTable('unita_immobiliare_nominativi')) { + $legacyNames = UnitaImmobiliareNominativo::query() + ->where('stabile_id', (int) $stabileId) + ->whereIn('unita_immobiliare_id', $unitIds) + ->where(function ($query) use ($referenceDate): void { + $query->whereNull('data_inizio')->orWhere('data_inizio', '<=', $referenceDate->toDateString()); + }) + ->where(function ($query) use ($referenceDate): void { + $query->whereNull('data_fine')->orWhere('data_fine', '>=', $referenceDate->toDateString()); + }) + ->get(['unita_immobiliare_id', 'ruolo', 'nominativo']); + + foreach ($legacyNames as $row) { + $unitId = (int) ($row->unita_immobiliare_id ?? 0); + $role = strtoupper((string) ($row->ruolo ?? '')); + if (! in_array($role, ['C', 'I'], true)) { + continue; + } + + $name = trim((string) ($row->nominativo ?? '')); + if ($name === '') { + continue; + } + + $namesByUnit[$unitId][$role][$name] = $name; + } + } + + $labels = []; + foreach ($units as $unit) { + $legacyCondId = trim((string) ($unit->legacy_cond_id ?? '')); + if ($legacyCondId === '') { + continue; + } + + $pieces = []; + $scala = trim((string) ($unit->scala ?? '')); + $interno = trim((string) ($unit->interno ?? '')); + $denominazione = trim((string) ($unit->denominazione ?? '')); + + if ($scala !== '' || $interno !== '') { + $unitLabel = trim(($scala !== '' ? ('Scala ' . $scala) : '') . ($interno !== '' ? (' Int. ' . $interno) : '')); + if ($unitLabel !== '') { + $pieces[] = $unitLabel; + } + } elseif ($denominazione !== '') { + $pieces[] = $denominazione; + } + + $owners = array_values($namesByUnit[(int) $unit->id]['C'] ?? []); + $tenants = array_values($namesByUnit[(int) $unit->id]['I'] ?? []); + + if ($owners !== []) { + $pieces[] = 'Prop: ' . implode(', ', $owners); + } + if ($tenants !== []) { + $pieces[] = 'Inq: ' . implode(', ', $tenants); + } + if ($pieces === [] && $denominazione !== '') { + $pieces[] = $denominazione; + } + + $labels[$legacyCondId] = $pieces !== [] ? implode(' · ', $pieces) : $legacyCondId; + } + + return $labels; + } + + private function resolveReferenceDate(): Carbon + { + try { + return $this->dataBilancio ? Carbon::parse($this->dataBilancio)->startOfDay() : now()->startOfDay(); + } catch (\Throwable) { + return now()->startOfDay(); + } + } + public function updatedLegacyYear(): void { $this->legacyYear = is_string($this->legacyYear) ? trim((string) $this->legacyYear) : null; @@ -317,6 +478,13 @@ public function updatedSaldoBancaManuale(): void $this->reloadData(); } + public function updatedSaldoContoId(): void + { + $this->saldoContoId = is_string($this->saldoContoId) ? trim((string) $this->saldoContoId) : null; + $this->saldoContoId = $this->saldoContoId !== '' ? $this->saldoContoId : null; + $this->loadSaldoSnapshots(); + } + public function updatedDett(): void { $this->dett = is_string($this->dett) ? trim((string) $this->dett) : null; @@ -359,6 +527,13 @@ private function reloadData(): void $stabileId = $stabile?->id ? (int) $stabile->id : null; $this->conti = $stabileId ? $this->loadContiOptions($stabileId) : []; + if (($this->saldoContoId === null || $this->saldoContoId === '') && $this->conti !== []) { + $this->saldoContoId = (string) array_key_first($this->conti); + } + if ($this->saldoContoId !== null && $this->saldoContoId !== '' && ! array_key_exists($this->saldoContoId, $this->conti)) { + $this->saldoContoId = $this->conti !== [] ? (string) array_key_first($this->conti) : null; + } + if (! $this->dataBilancio) { $this->dataBilancio = $this->defaultDataBilancio(); } @@ -370,6 +545,7 @@ private function reloadData(): void $this->conguagliExcelCols = []; $this->detailMeta = []; $this->detailRows = []; + $this->saldoSnapshots = []; return; } @@ -379,7 +555,8 @@ private function reloadData(): void $this->vocSpeOptions = $this->loadVocSpeOptions($codStabileLegacy, $this->legacyYear); if ($this->tab === 'conguagli') { - [$this->conguagliExcelCols, $this->conguagliExcel] = $this->computeConguagliExcel($codStabileLegacy, $this->legacyYear); + $this->conguagliExcel = []; + $this->conguagliExcelCols = []; [$this->dettTabRows, $this->dettTabEdit] = $this->loadDettTabCrudRows($codStabileLegacy, $this->legacyYear); } else { $this->conguagliExcel = []; @@ -405,6 +582,219 @@ private function reloadData(): void } $this->reloadDettaglio($codStabileLegacy, $this->legacyYear); + $this->loadSaldoSnapshots(); + } + + public function saveSaldoSnapshot(): void + { + $stabile = $this->getActiveStabile(); + $stabileId = $stabile?->id ? (int) $stabile->id : null; + if (! $stabileId) { + $this->addError('saldoContoId', 'Seleziona prima uno stabile attivo.'); + return; + } + + $validated = $this->validate([ + 'saldoContoId' => ['required', 'string'], + 'saldoSnapshotData' => ['required', 'date'], + 'saldoSnapshotPeriodoDa' => ['nullable', 'date'], + 'saldoSnapshotPeriodoA' => ['nullable', 'date'], + 'saldoSnapshotSaldo' => ['required', 'string'], + 'saldoSnapshotTipo' => ['required', 'string', 'in:estratto_conto,saldo_banca,riconciliazione,altro'], + 'saldoSnapshotNote' => ['nullable', 'string', 'max:255'], + 'saldoSnapshotPdf' => ['nullable', 'file', 'mimes:pdf', 'max:20480'], + ]); + + $contoId = (int) ($validated['saldoContoId'] ?? 0); + $conto = DatiBancari::query() + ->where('stabile_id', $stabileId) + ->where('id', $contoId) + ->first(['id', 'denominazione_banca', 'iban', 'numero_conto', 'legacy_cod_cassa']); + + if (! $conto) { + $this->addError('saldoContoId', 'Conto non valido per lo stabile attivo.'); + return; + } + + $saldo = $this->parseMoney((string) ($validated['saldoSnapshotSaldo'] ?? '')); + if ($saldo === null) { + $this->addError('saldoSnapshotSaldo', 'Saldo non valido.'); + return; + } + + $user = Auth::user(); + $documento = $this->storeSaldoSnapshotDocumento($stabileId, $conto, $this->saldoSnapshotPdf, $validated); + + SaldoConto::query()->updateOrCreate( + [ + 'stabile_id' => $stabileId, + 'conto_id' => $contoId, + 'data_saldo' => $validated['saldoSnapshotData'], + ], + [ + 'iban' => $this->normalizeIbanValue($conto->iban), + 'periodo_da' => $validated['saldoSnapshotPeriodoDa'] ?? null, + 'periodo_a' => $validated['saldoSnapshotPeriodoA'] ?? null, + 'saldo' => $saldo, + 'tipo_estratto' => $validated['saldoSnapshotTipo'], + 'note' => isset($validated['saldoSnapshotNote']) ? trim((string) $validated['saldoSnapshotNote']) : null, + 'documento_stabile_id' => $documento?->id, + 'created_by' => (int) ($user?->id ?? 0) ?: null, + 'updated_by' => (int) ($user?->id ?? 0) ?: null, + ] + ); + + $this->resetSaldoSnapshotForm(false); + $this->reloadData(); + } + + public function deleteSaldoSnapshot(int $snapshotId): void + { + if ($snapshotId <= 0) { + return; + } + + $stabile = $this->getActiveStabile(); + $stabileId = $stabile?->id ? (int) $stabile->id : null; + if (! $stabileId) { + return; + } + + $snapshot = SaldoConto::query() + ->where('stabile_id', $stabileId) + ->where('id', $snapshotId) + ->first(); + + if (! $snapshot) { + return; + } + + $documentoId = (int) ($snapshot->documento_stabile_id ?? 0); + $snapshot->delete(); + + if ($documentoId > 0) { + DocumentoStabile::query()->where('id', $documentoId)->delete(); + } + + $this->reloadData(); + } + + private function loadSaldoSnapshots(): void + { + $this->saldoSnapshots = []; + + $stabile = $this->getActiveStabile(); + $stabileId = $stabile?->id ? (int) $stabile->id : null; + $contoId = is_numeric($this->saldoContoId) ? (int) $this->saldoContoId : 0; + if (! $stabileId || $contoId <= 0 || ! DB::getSchemaBuilder()->hasTable('contabilita_saldi_conti')) { + return; + } + + $this->saldoSnapshots = SaldoConto::query() + ->with('documento') + ->where('stabile_id', $stabileId) + ->where('conto_id', $contoId) + ->orderByDesc('data_saldo') + ->orderByDesc('id') + ->get() + ->map(function (SaldoConto $row): array { + return [ + 'id' => (int) $row->id, + 'data_saldo' => $row->data_saldo?->format('d/m/Y'), + 'periodo' => $this->formatPeriodoEstrattoLabel($row->periodo_da, $row->periodo_a), + 'tipo' => $this->formatTipoEstrattoLabel($row->tipo_estratto), + 'saldo' => (float) $row->saldo, + 'note' => (string) ($row->note ?? ''), + 'documento_nome' => $row->documento?->nome_originale, + 'documento_url' => $row->documento?->url_view, + ]; + }) + ->all(); + } + + private function resetSaldoSnapshotForm(bool $resetConto = true): void + { + if ($resetConto) { + $this->saldoContoId = $this->conti !== [] ? (string) array_key_first($this->conti) : null; + } + + $this->saldoSnapshotData = $this->dataBilancio; + $this->saldoSnapshotPeriodoDa = null; + $this->saldoSnapshotPeriodoA = null; + $this->saldoSnapshotSaldo = null; + $this->saldoSnapshotTipo = 'estratto_conto'; + $this->saldoSnapshotNote = null; + $this->saldoSnapshotPdf = null; + $this->resetValidation([ + 'saldoContoId', + 'saldoSnapshotData', + 'saldoSnapshotPeriodoDa', + 'saldoSnapshotPeriodoA', + 'saldoSnapshotSaldo', + 'saldoSnapshotTipo', + 'saldoSnapshotNote', + 'saldoSnapshotPdf', + ]); + } + + private function formatTipoEstrattoLabel(?string $value): string + { + return match (trim((string) $value)) { + 'estratto_conto' => 'Estratto conto', + 'saldo_banca' => 'Saldo banca', + 'riconciliazione' => 'Riconciliazione', + 'altro' => 'Altro', + default => 'Saldo registrato', + }; + } + + private function formatPeriodoEstrattoLabel(mixed $from, mixed $to): string + { + $fromDate = $from instanceof Carbon ? $from : ($from ? Carbon::parse((string) $from) : null); + $toDate = $to instanceof Carbon ? $to : ($to ? Carbon::parse((string) $to) : null); + + if ($fromDate && $toDate) { + return $fromDate->format('d/m/Y') . ' - ' . $toDate->format('d/m/Y'); + } + if ($toDate) { + return 'Fino al ' . $toDate->format('d/m/Y'); + } + if ($fromDate) { + return 'Dal ' . $fromDate->format('d/m/Y'); + } + + return 'Periodo non indicato'; + } + + private function normalizeIbanValue(mixed $value): ?string + { + $iban = is_string($value) ? trim((string) $value) : ''; + return $iban !== '' ? $iban : null; + } + + private function storeSaldoSnapshotDocumento(int $stabileId, DatiBancari $conto, TemporaryUploadedFile|string|null $file, array $validated): ?DocumentoStabile + { + if (! $file instanceof TemporaryUploadedFile) { + return null; + } + + $originalName = trim((string) $file->getClientOriginalName()); + $extension = strtolower((string) $file->getClientOriginalExtension()); + $extension = $extension !== '' ? $extension : 'pdf'; + $storedName = 'estratto-conto-' . $stabileId . '-' . (int) $conto->id . '-' . now()->format('YmdHis') . '.' . $extension; + $storedPath = $file->storeAs('documenti/stabili/' . $stabileId . '/bancari', $storedName, 'public'); + + return DocumentoStabile::query()->create([ + 'stabile_id' => $stabileId, + 'nome_file' => $storedName, + 'nome_originale' => $originalName !== '' ? $originalName : $storedName, + 'percorso_file' => $storedPath, + 'categoria' => 'bancari', + 'tipo_mime' => Storage::disk('public')->mimeType($storedPath), + 'dimensione' => Storage::disk('public')->size($storedPath), + 'descrizione' => 'Saldo/estratto ' . ($this->conti[(string) $conto->id] ?? ('Conto #' . $conto->id)) . ' · ' . $this->formatPeriodoEstrattoLabel($validated['saldoSnapshotPeriodoDa'] ?? null, $validated['saldoSnapshotPeriodoA'] ?? null), + 'caricato_da' => Auth::id(), + ]); } /** @@ -427,14 +817,18 @@ private function loadLegacyYearLabels(string $codStabileLegacy): array if ($cartella === '') { continue; } - $label = trim((string) ($r->anno_riscaldamento ?? '')); - if ($label === '') { - $label = trim((string) ($r->anno_ordinario ?? '')); + $ord = trim((string) ($r->anno_ordinario ?? '')); + $risc = trim((string) ($r->anno_riscaldamento ?? '')); + + $parts = []; + if ($ord !== '') { + $parts[] = 'Ord. ' . $ord; } - if ($label === '') { - $label = $cartella; + if ($risc !== '' && $risc !== $ord) { + $parts[] = 'Risc. ' . $risc; } - $out[$cartella] = $label; + $parts[] = 'Dir. ' . $cartella; + $out[$cartella] = implode(' · ', $parts); } return $out; } @@ -884,7 +1278,10 @@ private function loadDettTabCrudRows(string $codStabileLegacy, ?string $legacyYe } $q = DB::connection('gescon_import')->table('dett_tab') - ->where('cod_tab', 'like', 'CONG.%'); + ->where('cod_tab', 'CONG.O') + ->where(function ($query) { + $query->whereNull('n_stra')->orWhere('n_stra', '<=', 0); + }); if (Schema::connection('gescon_import')->hasColumn('dett_tab', 'cod_stabile')) { $hasForStabile = DB::connection('gescon_import')->table('dett_tab') @@ -1061,12 +1458,12 @@ public function saveDettTabRow(int $id): void DB::connection('gescon_import')->table('dett_tab') ->where('id', $id) ->update([ - 'cod_tab' => is_string($edit['cod_tab'] ?? null) ? trim((string) $edit['cod_tab']) : null, + 'cod_tab' => 'CONG.O', 'id_cond' => is_string($edit['id_cond'] ?? null) ? trim((string) $edit['id_cond']) : null, 'cond_inquil' => is_string($edit['cond_inquil'] ?? null) ? trim((string) $edit['cond_inquil']) : null, 'mm' => is_numeric($edit['mm'] ?? null) ? (float) $edit['mm'] : null, 'cons_euro' => round((float) $cons, 2), - 'n_stra' => is_numeric($edit['n_stra'] ?? null) ? (int) $edit['n_stra'] : null, + 'n_stra' => null, 'unico' => ! empty($edit['unico']) ? 1 : 0, 'updated_at' => now(), ]); @@ -1097,7 +1494,7 @@ public function addDettTabRow(): void return; } - $codTab = is_string($this->formDettTabAdd['cod_tab'] ?? null) ? trim((string) $this->formDettTabAdd['cod_tab']) : ''; + $codTab = 'CONG.O'; $idCond = is_string($this->formDettTabAdd['id_cond'] ?? null) ? trim((string) $this->formDettTabAdd['id_cond']) : ''; if ($codTab === '' || $idCond === '') { return; @@ -1114,7 +1511,7 @@ public function addDettTabRow(): void 'cond_inquil' => is_string($this->formDettTabAdd['cond_inquil'] ?? null) ? trim((string) $this->formDettTabAdd['cond_inquil']) : null, 'mm' => is_numeric($this->formDettTabAdd['mm'] ?? null) ? (float) $this->formDettTabAdd['mm'] : null, 'cons_euro' => round((float) $cons, 2), - 'n_stra' => is_numeric($this->formDettTabAdd['n_stra'] ?? null) ? (int) $this->formDettTabAdd['n_stra'] : null, + 'n_stra' => null, 'unico' => ! empty($this->formDettTabAdd['unico']) ? 1 : 0, 'created_at' => now(), 'updated_at' => now(), @@ -1283,11 +1680,14 @@ private function loadContiOptions(int $stabileId): array return DatiBancari::query() ->where('stabile_id', $stabileId) ->orderBy('id') - ->get(['id', 'denominazione_banca', 'iban']) + ->get(['id', 'denominazione_banca', 'iban', 'numero_conto', 'legacy_cod_cassa']) ->mapWithKeys(function (DatiBancari $c): array { $iban = is_string($c->iban) ? trim((string) $c->iban) : ''; + $numeroConto = is_string($c->numero_conto) ? trim((string) ($c->numero_conto ?? '')) : ''; + $legacyCodCassa = is_string($c->legacy_cod_cassa) ? strtoupper(trim((string) ($c->legacy_cod_cassa ?? ''))) : ''; $label = trim((string) ($c->denominazione_banca ?? '')); - $label = $label !== '' ? ($label . ($iban !== '' ? (' · ' . $iban) : '')) : ($iban !== '' ? $iban : ('Conto #' . $c->id)); + $identifier = $iban !== '' ? $iban : ($numeroConto !== '' ? $numeroConto : ($legacyCodCassa !== '' ? ('Cassa ' . $legacyCodCassa) : ('Conto #' . $c->id))); + $label = $label !== '' ? ($label . ' · ' . $identifier) : $identifier; return [(string) $c->id => $label]; }) ->all(); @@ -1367,9 +1767,6 @@ private function computeBucketOrdinaria(string $codStabileLegacy, ?string $legac $debiti = $this->sumCreDebPreced($codStabileLegacy, $legacyYear, 'D', 'non_stra') + $this->sumManualVoci('ordinaria', 'debiti', null, $legacyYear); [$congPos, $congNeg] = $this->sumConguagli($codStabileLegacy, $legacyYear, 'CONG.O', 'non_stra'); - [$mPos, $mNeg] = $this->sumManualConguagli($legacyYear, 'CONG.O', 'non_stra'); - $congPos += $mPos; - $congNeg += $mNeg; $att = round($crediti + $congPos, 2); $pas = round($debiti + $congNeg, 2); @@ -1393,9 +1790,6 @@ private function computeBucketRiscaldamento(string $codStabileLegacy, ?string $l $crediti = 0.0; $debiti = 0.0; [$congPos, $congNeg] = $this->sumConguagli($codStabileLegacy, $legacyYear, 'CONG.R', 'non_stra'); - [$mPos, $mNeg] = $this->sumManualConguagli($legacyYear, 'CONG.R', 'non_stra'); - $congPos += $mPos; - $congNeg += $mNeg; $att = round($crediti + $congPos, 2); $pas = round($debiti + $congNeg, 2); @@ -1417,9 +1811,6 @@ private function computeBucketStraordinaria(string $codStabileLegacy, ?string $l $debiti = $this->sumCreDebPreced($codStabileLegacy, $legacyYear, 'D', $nStra) + $this->sumManualVoci('straordinaria', 'debiti', $nStra, $legacyYear); [$congPos, $congNeg] = $this->sumConguagli($codStabileLegacy, $legacyYear, null, $nStra); - [$mPos, $mNeg] = $this->sumManualConguagli($legacyYear, null, $nStra); - $congPos += $mPos; - $congNeg += $mNeg; $titolo = $this->resolveStraordinariaTitolo($codStabileLegacy, $legacyYear, $nStra); @@ -1830,10 +2221,13 @@ private function computeRisorseFinanziarie(int $stabileId, string $date): array $conti = DatiBancari::query() ->where('stabile_id', $stabileId) - ->whereNotNull('iban') - ->where('iban', '!=', '') + ->when(Schema::hasColumn('dati_bancari', 'stato_conto'), function ($query) { + $query->where(function ($query) { + $query->whereNull('stato_conto')->orWhere('stato_conto', '!=', 'chiuso'); + }); + }) ->orderBy('id') - ->get(['id', 'denominazione_banca', 'iban', 'saldo_iniziale']); + ->get(['id', 'denominazione_banca', 'iban', 'numero_conto', 'legacy_cod_cassa', 'saldo_iniziale', 'data_saldo_iniziale']); if ($conti->isEmpty()) { return []; @@ -1843,24 +2237,13 @@ private function computeRisorseFinanziarie(int $stabileId, string $date): array $out = []; foreach ($conti as $conto) { $iban = is_string($conto->iban) ? trim((string) $conto->iban) : ''; - if ($iban === '') { - continue; - } - - $saldoIniziale = is_numeric($conto->saldo_iniziale) ? (float) $conto->saldo_iniziale : 0.0; - $sumImporti = (float) (MovimentoBanca::query() - ->where('stabile_id', $stabileId) - ->where('conto_id', (int) $conto->id) - ->where('iban', $iban) - ->whereDate('data', '<=', $dateC->toDateString()) - ->sum('importo') ?? 0.0); - - $offset = $this->resolveSaldoOffsetDaSaldi($stabileId, (int) $conto->id, $iban, $dateC); - - $saldo = round($saldoIniziale + $sumImporti + $offset, 2); + $numeroConto = is_string($conto->numero_conto) ? trim((string) ($conto->numero_conto ?? '')) : ''; + $legacyCodCassa = is_string($conto->legacy_cod_cassa) ? strtoupper(trim((string) ($conto->legacy_cod_cassa ?? ''))) : ''; + $saldo = $this->resolveContoSaldoAllaData($stabileId, $conto, $dateC); $label = trim((string) ($conto->denominazione_banca ?? '')); - $label = $label !== '' ? ($label . ' · ' . $iban) : $iban; + $identifier = $iban !== '' ? $iban : ($numeroConto !== '' ? $numeroConto : ($legacyCodCassa !== '' ? ('Cassa ' . $legacyCodCassa) : ('Conto #' . $conto->id))); + $label = $label !== '' ? ($label . ' · ' . $identifier) : $identifier; $out[] = [ 'conto_id' => (int) $conto->id, @@ -1873,6 +2256,66 @@ private function computeRisorseFinanziarie(int $stabileId, string $date): array return $out; } + private function resolveContoSaldoAllaData(int $stabileId, DatiBancari $conto, Carbon $date): float + { + $contoId = (int) $conto->id; + + $snapshot = SaldoConto::query() + ->where('stabile_id', $stabileId) + ->where('conto_id', $contoId) + ->whereDate('data_saldo', '<=', $date->toDateString()) + ->orderByDesc('data_saldo') + ->orderByDesc('id') + ->first(['data_saldo', 'saldo']); + + if ($snapshot) { + $baseDate = $snapshot->data_saldo instanceof Carbon + ? $snapshot->data_saldo->copy()->startOfDay() + : Carbon::parse((string) $snapshot->data_saldo)->startOfDay(); + $delta = (float) (MovimentoBanca::query() + ->where('stabile_id', $stabileId) + ->where('conto_id', $contoId) + ->whereDate('data', '>', $baseDate->toDateString()) + ->whereDate('data', '<=', $date->toDateString()) + ->sum('importo') ?? 0.0); + + return round((float) ($snapshot->saldo ?? 0.0) + $delta, 2); + } + + $saldoBase = is_numeric($conto->saldo_iniziale) ? (float) $conto->saldo_iniziale : 0.0; + $dataSaldoIniziale = $conto->data_saldo_iniziale instanceof Carbon + ? $conto->data_saldo_iniziale->copy()->startOfDay() + : ($conto->data_saldo_iniziale ? Carbon::parse((string) $conto->data_saldo_iniziale)->startOfDay() : null); + + $movimenti = MovimentoBanca::query() + ->where('stabile_id', $stabileId) + ->where('conto_id', $contoId); + + if ($dataSaldoIniziale instanceof Carbon) { + if ($dataSaldoIniziale->lte($date)) { + $delta = (float) (clone $movimenti) + ->whereDate('data', '>', $dataSaldoIniziale->toDateString()) + ->whereDate('data', '<=', $date->toDateString()) + ->sum('importo'); + + return round($saldoBase + $delta, 2); + } + + $delta = (float) (clone $movimenti) + ->whereDate('data', '>', $date->toDateString()) + ->whereDate('data', '<=', $dataSaldoIniziale->toDateString()) + ->sum('importo'); + + return round($saldoBase - $delta, 2); + } + + $delta = (float) (clone $movimenti) + ->whereDate('data', '<=', $date->toDateString()) + ->sum('importo'); + + return round($saldoBase + $delta, 2); + } + private function computeSaldoBancaTotaleLetto(array $risorseFinanziarie): ?float { if ($risorseFinanziarie === []) { @@ -2082,6 +2525,8 @@ private function loadDettTabRows(string $codStabileLegacy, ?string $legacyYear, return []; } + $labels = $this->loadCondominiLabels($codStabileLegacy, $legacyYear); + $q = DB::connection('gescon_import')->table('dett_tab') ->where('cod_tab', 'like', 'CONG.%'); @@ -2127,6 +2572,7 @@ private function loadDettTabRows(string $codStabileLegacy, ?string $legacyYear, ->map(fn ($r) => [ 'cod_tab' => (string) ($r->cod_tab ?? ''), 'id_cond' => (string) ($r->id_cond ?? ''), + 'cond_label' => (string) ($labels[trim((string) ($r->id_cond ?? ''))] ?? ''), 'cond_inquil' => (string) ($r->cond_inquil ?? ''), 'mm' => $r->mm !== null ? (float) $r->mm : null, 'cons_euro' => (float) ($r->cons_euro ?? 0), diff --git a/app/Filament/Pages/Contabilita/VociSpesaArchivio.php b/app/Filament/Pages/Contabilita/VociSpesaArchivio.php index 432a010..c04d89c 100644 --- a/app/Filament/Pages/Contabilita/VociSpesaArchivio.php +++ b/app/Filament/Pages/Contabilita/VociSpesaArchivio.php @@ -85,13 +85,6 @@ public function mount(): void $this->tipoGestioneTab = in_array($tab, ['ordinaria', 'acqua', 'riscaldamento', 'straordinaria'], true) ? $tab : 'ordinaria'; $this->gestioneContabileId = $this->resolveGestioneContabileId(); - if (! $this->gestioneContabileId) { - $opts = $this->getGestioniOptions(); - $firstKey = array_key_first($opts); - if (is_numeric($firstKey)) { - $this->gestioneContabileId = (int) $firstKey; - } - } $preset = (string) request()->query('preset', 'manuale'); $this->presetRipartizione = in_array($preset, ['manuale', 'confedilizia'], true) ? $preset : 'manuale'; @@ -202,9 +195,7 @@ private function mapTabellaToAnno(?int $sourceTabellaId, int $stabileId, int $ta } if ($hasAnno) { - $q->where(function (Builder $qq) use ($targetAnno) { - $qq->where('anno_gestione', $targetAnno)->orWhereNull('anno_gestione'); - })->orderByRaw('CASE WHEN anno_gestione IS NULL THEN 1 ELSE 0 END'); + $q->where('anno_gestione', $targetAnno); } $id = $q->orderBy('ordine_visualizzazione') @@ -464,9 +455,7 @@ public function getTabelleMillesimaliOptions(): array ->orderBy('nome_tabella'); if ($hasAnno) { - $q->where(function (Builder $qq) use ($anno) { - $qq->where('anno_gestione', $anno)->orWhereNull('anno_gestione'); - })->orderByRaw('CASE WHEN anno_gestione IS NULL THEN 1 ELSE 0 END'); + $q->where('anno_gestione', $anno); } if ($hasLegacyTipo) { @@ -625,23 +614,8 @@ private function applyTipoGestioneFilter(Builder $q, string $tipoTab, bool $hasL { $tipo = $this->normalizeTipoGestione($tipoTab); - if ($hasLegacyTipo) { - $legacy = $this->getLegacyTipoValues($tipo); - $q->where(function (Builder $qq) use ($legacy, $tipo) { - $qq->whereIn('tm.tipo', $legacy) - ->orWhereIn('voci_spesa.tipo_gestione', $legacy) - ->orWhere('voci_spesa.tipo_gestione', $tipo) - ->orWhereNull('tm.tipo'); - }); - } else { - if ($tipo === 'ordinaria') { - $q->where(function (Builder $qq) { - $qq->where('voci_spesa.tipo_gestione', 'ordinaria') - ->orWhereNull('voci_spesa.tipo_gestione'); - }); - } else { - $q->where('voci_spesa.tipo_gestione', $tipo); - } + if (Schema::hasColumn('voci_spesa', 'tipo_gestione')) { + $q->where('voci_spesa.tipo_gestione', $tipo); } if ($tipoTab === 'acqua') { @@ -650,9 +624,7 @@ private function applyTipoGestioneFilter(Builder $q, string $tipoTab, bool $hasL ->orWhere('voci_spesa.categoria', 'acqua'); }); } elseif ($tipoTab === 'ordinaria') { - $q->where(function (Builder $qq) { - $qq->whereNull('tm.codice_tabella')->orWhereRaw('UPPER(TRIM(tm.codice_tabella)) != ?', ['ACQUA']); - }); + $q->whereRaw('(tm.codice_tabella IS NULL OR UPPER(TRIM(tm.codice_tabella)) != ?)', ['ACQUA']); $q->where(function (Builder $qq) { $qq->whereNull('voci_spesa.categoria')->orWhere('voci_spesa.categoria', '!=', 'acqua'); }); @@ -747,9 +719,7 @@ private function getNordOptions(): array $q = TabellaMillesimale::query()->where('stabile_id', $stabileId); if (Schema::hasColumn('tabelle_millesimali', 'anno_gestione')) { $anno = AnnoGestioneContext::resolveActiveAnno($user); - $q->where(function (Builder $qq) use ($anno) { - $qq->where('anno_gestione', $anno)->orWhereNull('anno_gestione'); - }); + $q->where('anno_gestione', $anno); } $values = $q->selectRaw('DISTINCT ' . $expr . ' as nord_val') @@ -779,9 +749,7 @@ private function getFirstTabellaCodesToHide(int $stabileId): array $q = TabellaMillesimale::query()->where('stabile_id', $stabileId); if ($hasAnno) { - $q->where(function (Builder $qq) use ($anno) { - $qq->where('anno_gestione', $anno)->orWhereNull('anno_gestione'); - })->orderByRaw('CASE WHEN anno_gestione IS NULL THEN 1 ELSE 0 END'); + $q->where('anno_gestione', $anno); } if ($hasLegacyTipo) { @@ -950,16 +918,6 @@ protected function resolveGestioneContabileId(): ?int ->orderByDesc('id') ->first(); - if (! $match && $tipo === 'ordinaria') { - $match = GestioneContabile::query() - ->where('stabile_id', $stabileId) - ->where('stato', 'aperta') - ->orderByDesc('gestione_attiva') - ->orderByDesc('anno_gestione') - ->orderByDesc('id') - ->first(); - } - return $match?->id ? (int) $match->id : null; } @@ -975,6 +933,10 @@ protected function getTableQuery(): Builder return VoceSpesa::query()->whereRaw('1 = 0'); } + if (Schema::hasColumn('voci_spesa', 'gestione_contabile_id') && ! $this->gestioneContabileId) { + return VoceSpesa::query()->whereRaw('1 = 0'); + } + $tipoTab = $this->tipoGestioneTab; // Requisito (legacy): tabs filtrano per `tabelle.tipo` e ordinano per `tabelle.nord`. @@ -1028,6 +990,10 @@ public function getTotalePreventivo(): float return 0.0; } + if (Schema::hasColumn('voci_spesa', 'gestione_contabile_id') && ! $this->gestioneContabileId) { + return 0.0; + } + $tipoTab = $this->tipoGestioneTab; $hasLegacyTipo = Schema::hasColumn('tabelle_millesimali', 'tipo'); @@ -1044,6 +1010,20 @@ public function getTotalePreventivo(): float return (float) $q->sum('importo_default'); } + public function getTotalePreventivoAcqua(): float + { + return $this->getTotaleByTipoTab('acqua', 'importo_default'); + } + + public function getTotalePreventivoGenerale(): float + { + if (($this->tipoGestioneTab ?? 'ordinaria') === 'acqua') { + return $this->getTotalePreventivo(); + } + + return $this->getTotalePreventivo() + $this->getTotalePreventivoAcqua(); + } + public function getTotaleConsuntivo(): float { $user = Auth::user(); @@ -1056,6 +1036,10 @@ public function getTotaleConsuntivo(): float return 0.0; } + if (Schema::hasColumn('voci_spesa', 'gestione_contabile_id') && ! $this->gestioneContabileId) { + return 0.0; + } + if (! Schema::hasColumn('voci_spesa', 'importo_consuntivo')) { return 0.0; } @@ -1074,6 +1058,53 @@ public function getTotaleConsuntivo(): float return (float) $q->sum('importo_consuntivo'); } + public function getTotaleConsuntivoAcqua(): float + { + return $this->getTotaleByTipoTab('acqua', 'importo_consuntivo'); + } + + public function getTotaleConsuntivoGenerale(): float + { + if (($this->tipoGestioneTab ?? 'ordinaria') === 'acqua') { + return $this->getTotaleConsuntivo(); + } + + return $this->getTotaleConsuntivo() + $this->getTotaleConsuntivoAcqua(); + } + + private function getTotaleByTipoTab(string $tipoTab, string $column): float + { + $user = Auth::user(); + if (! $user instanceof User) { + return 0.0; + } + + $activeStabileId = StabileContext::resolveActiveStabileId($user); + if (! $activeStabileId) { + return 0.0; + } + + if (Schema::hasColumn('voci_spesa', 'gestione_contabile_id') && ! $this->gestioneContabileId) { + return 0.0; + } + + if (! Schema::hasColumn('voci_spesa', $column)) { + return 0.0; + } + + $hasLegacyTipo = Schema::hasColumn('tabelle_millesimali', 'tipo'); + + $q = VoceSpesa::query()->where('voci_spesa.stabile_id', $activeStabileId); + if (Schema::hasColumn('voci_spesa', 'gestione_contabile_id') && $this->gestioneContabileId) { + $q->where('gestione_contabile_id', $this->gestioneContabileId); + } + + $q->leftJoin('tabelle_millesimali as tm', 'tm.id', '=', 'voci_spesa.tabella_millesimale_default_id'); + $this->applyTipoGestioneFilter($q, $tipoTab, $hasLegacyTipo); + + return (float) $q->sum($column); + } + /** * Totali per tabella (codice_tabella) e per Prev/Cons. * @return array @@ -1090,11 +1121,12 @@ public function getTotaliPerTabella(): array return []; } + if (Schema::hasColumn('voci_spesa', 'gestione_contabile_id') && ! $this->gestioneContabileId) { + return []; + } + $tipoTab = $this->tipoGestioneTab; $hasLegacyTipo = Schema::hasColumn('tabelle_millesimali', 'tipo'); - $hasAnno = Schema::hasColumn('tabelle_millesimali', 'anno_gestione'); - $anno = AnnoGestioneContext::resolveActiveAnno($user); - $q = VoceSpesa::query() ->leftJoin('tabelle_millesimali as tm', 'tm.id', '=', 'voci_spesa.tabella_millesimale_default_id') ->where('voci_spesa.stabile_id', $activeStabileId) @@ -1102,12 +1134,13 @@ public function getTotaliPerTabella(): array $qq->where('voci_spesa.gestione_contabile_id', $this->gestioneContabileId); }); - $q->selectRaw('COALESCE(tm.codice_tabella, "—") as tabella, SUM(voci_spesa.importo_default) as totale_prev, SUM(COALESCE(voci_spesa.importo_consuntivo, 0)) as totale_cons'); + $q->selectRaw('COALESCE(tm.codice_tabella, "—") as tabella, COALESCE(tm.nord, tm.ordine_visualizzazione, tm.ordinamento, 9999) as tabella_nord, SUM(voci_spesa.importo_default) as totale_prev, SUM(COALESCE(voci_spesa.importo_consuntivo, 0)) as totale_cons'); $this->applyTipoGestioneFilter($q, $tipoTab, $hasLegacyTipo); return $q - ->groupBy('tabella') + ->groupBy('tabella', 'tabella_nord') + ->orderBy('tabella_nord') ->orderByRaw('tabella = "—"') ->orderBy('tabella') ->get() @@ -1159,50 +1192,52 @@ public function table(Table $table): Table ->label('Descrizione') ->searchable() ->wrap() - ->extraAttributes(['class' => 'text-[11px]']), + ->extraAttributes(['class' => 'text-[13px] leading-tight']), TextColumn::make('importo_default') ->label('Prev.') ->formatStateUsing(fn($state) => number_format((float) ($state ?? 0), 2, ',', '.')) ->alignRight() ->summarize(Sum::make()->label('Subtotale')->formatStateUsing(fn($state) => number_format((float) ($state ?? 0), 2, ',', '.'))) - ->extraAttributes(['class' => 'text-[11px]']), + ->extraAttributes(['class' => 'text-[13px] leading-tight']), TextColumn::make('importo_consuntivo') ->label('Cons.') ->formatStateUsing(fn($state) => number_format((float) ($state ?? 0), 2, ',', '.')) ->alignRight() ->summarize(Sum::make()->label('Subtotale')->formatStateUsing(fn($state) => number_format((float) ($state ?? 0), 2, ',', '.'))) - ->extraAttributes(['class' => 'text-[11px]']), + ->extraAttributes(['class' => 'text-[13px] leading-tight']), TextColumn::make('percentuale_condomino') ->label('% Prop.') ->formatStateUsing(fn($state) => number_format((float) ($state ?? 0), 2, ',', '.')) ->alignRight() - ->extraAttributes(['class' => 'text-[11px]']), + ->extraAttributes(['class' => 'text-[13px] leading-tight']), TextColumn::make('percentuale_inquilino') ->label('% Inq.') ->formatStateUsing(fn($state) => number_format((float) ($state ?? 0), 2, ',', '.')) ->alignRight() - ->extraAttributes(['class' => 'text-[11px]']), + ->extraAttributes(['class' => 'text-[13px] leading-tight']), IconColumn::make('attiva') ->label('Attiva') ->boolean() - ->extraAttributes(['class' => 'text-[11px]']), + ->extraAttributes(['class' => 'text-[13px] leading-tight']), ]) ->actions([ Action::make('mastrino') - ->label('Mastrino') + ->hiddenLabel() ->icon('heroicon-o-document-chart-bar') + ->tooltip('Mastrino') ->url(fn (VoceSpesa $record) => VoceSpesaMastrino::getUrl([ 'record' => (int) $record->id, 'gestione' => $this->gestioneContabileId, ], panel: 'admin-filament')), Action::make('modifica') - ->label('Modifica') + ->hiddenLabel() ->icon('heroicon-o-pencil-square') + ->tooltip('Modifica') ->modalHeading('Modifica voce di spesa') ->form([ Select::make('tabella_millesimale_default_id') @@ -1279,8 +1314,9 @@ public function table(Table $table): Table $this->dispatch('$refresh'); }), Action::make('assegnaTabella') - ->label('Tabella') + ->hiddenLabel() ->icon('heroicon-o-rectangle-stack') + ->tooltip('Tabella') ->modalHeading('Imposta tabella di riferimento') ->form([ Select::make('tabella_millesimale_default_id') @@ -1303,8 +1339,8 @@ public function table(Table $table): Table $this->dispatch('$refresh'); }), ]) - ->paginationPageOptions([16, 32, 64, 100]) - ->defaultPaginationPageOption(32) + ->paginationPageOptions([24, 48, 96, 150]) + ->defaultPaginationPageOption(48) ->bulkActions([ BulkAction::make('bulkAssegnaTabella') ->label('Assegna tabella') diff --git a/app/Filament/Pages/Fornitore/Contabilita.php b/app/Filament/Pages/Fornitore/Contabilita.php index 440a5e3..19cba1c 100644 --- a/app/Filament/Pages/Fornitore/Contabilita.php +++ b/app/Filament/Pages/Fornitore/Contabilita.php @@ -288,7 +288,7 @@ protected function loadData(): void /** * @param array $options - * @return array{imported:int,duplicates:int,skipped:int,errors:int,messages:array} + * @return array{imported:int,duplicates:int,skipped:int,errors:int,messages:array} */ private function importSupplierUploadedFeFile( string $path, @@ -410,7 +410,7 @@ private function importSupplierUploadedFeFile( /** * @param array $options - * @return array{status:string,message:string} + * @return array{status:string,message:string} */ private function importSupplierParsedXml( string $xml, diff --git a/app/Filament/Pages/Gescon/Ordinarie.php b/app/Filament/Pages/Gescon/Ordinarie.php index ea9c44c..ddd57ec 100644 --- a/app/Filament/Pages/Gescon/Ordinarie.php +++ b/app/Filament/Pages/Gescon/Ordinarie.php @@ -3,6 +3,7 @@ use App\Models\User; use App\Models\UserSetting; +use App\Services\Contabilita\PreventivoOrdinarioPreviewService; use App\Support\AnnoGestioneContext; use App\Support\StabileContext; use BackedEnum; @@ -64,7 +65,17 @@ public static function canAccess(): bool public ?string $mastrinoCodSpe = null; public array $mastrinoRows = []; - public array $acquaRipartoRows = []; + public array $acquaRipartoRows = []; + public array $preventivoEditorRows = []; + public array $preventivoSummaryRows = []; + public array $preventivoTabellaOptions = []; + public array $ripartoPreviewColumns = []; + public array $ripartoPreviewRows = []; + public array $ripartoRateMonths = []; + public array $ripartoFocusBreakdown = []; + public array $personeCollegateAudit = []; + public array $preventivoGestioneMeta = []; + public ?int $ripartoFocusUnitId = null; public function mount(): void { @@ -74,7 +85,7 @@ public function mount(): void } $tab = request()->query('tab'); - if (in_array($tab, ['operazioni', 'consuntivo', 'straordinarie', 'acqua', 'incassi', 'fatture'], true)) { + if (in_array($tab, ['operazioni', 'preventivo', 'riparto', 'persone', 'consuntivo', 'straordinarie', 'acqua', 'incassi', 'fatture'], true)) { $this->viewTab = $tab; } @@ -93,6 +104,183 @@ public function mount(): void $this->sortField = 'dt_spe'; $this->sortDirection = 'asc'; } + + $this->hydratePreventivoWorkspace(); + } + + public function updatedFilterAnno(): void + { + $this->hydratePreventivoWorkspace(); + } + + public function updatedRipartoFocusUnitId(): void + { + $this->applyFocusRipartoBreakdown(); + } + + public function savePreventivoOrdinario(): void + { + $activeStabileId = StabileContext::resolveActiveStabileId(Auth::user()); + $gestioneId = (int) ($this->preventivoGestioneMeta['id'] ?? 0); + + if (! $activeStabileId || ! $gestioneId || ! Schema::hasTable('voci_spesa')) { + Notification::make() + ->title('Preventivo non disponibile') + ->body('Manca una gestione ordinaria attiva o la struttura dati delle voci spesa.') + ->danger() + ->send(); + + return; + } + + DB::transaction(function (): void { + foreach ($this->preventivoEditorRows as $row) { + $payload = [ + 'importo_default' => is_numeric($row['importo_default'] ?? null) ? round((float) $row['importo_default'], 2) : 0.0, + 'importo_consuntivo' => is_numeric($row['importo_consuntivo'] ?? null) ? round((float) $row['importo_consuntivo'], 2) : 0.0, + 'percentuale_inquilino' => is_numeric($row['percentuale_inquilino'] ?? null) ? round((float) $row['percentuale_inquilino'], 2) : 0.0, + 'percentuale_condomino' => max(0, 100 - (is_numeric($row['percentuale_inquilino'] ?? null) ? round((float) $row['percentuale_inquilino'], 2) : 0.0)), + 'tabella_millesimale_default_id' => ! empty($row['tabella_millesimale_default_id']) ? (int) $row['tabella_millesimale_default_id'] : null, + ]; + + DB::table('voci_spesa') + ->where('id', (int) ($row['id'] ?? 0)) + ->where('stabile_id', (int) $activeStabileId) + ->where('gestione_contabile_id', $gestioneId) + ->update($payload); + } + }); + + $this->hydratePreventivoWorkspace(); + + Notification::make() + ->title('Preventivo aggiornato') + ->body('Le voci della gestione ordinaria sono state salvate e il riparto è stato ricalcolato.') + ->success() + ->send(); + } + + protected function hydratePreventivoWorkspace(): void + { + $this->preventivoEditorRows = []; + $this->preventivoSummaryRows = []; + $this->preventivoTabellaOptions = []; + $this->ripartoPreviewColumns = []; + $this->ripartoPreviewRows = []; + $this->ripartoRateMonths = []; + $this->ripartoFocusBreakdown = []; + $this->personeCollegateAudit = []; + $this->preventivoGestioneMeta = []; + + $activeStabileId = StabileContext::resolveActiveStabileId(Auth::user()); + if (! $activeStabileId || ! $this->filterAnno) { + return; + } + + $service = app(PreventivoOrdinarioPreviewService::class); + $payload = $service->build((int) $activeStabileId, (int) $this->filterAnno); + + $this->preventivoGestioneMeta = $payload['gestione'] ?? []; + $this->preventivoEditorRows = $payload['preventivo_rows'] ?? []; + $this->preventivoSummaryRows = $payload['preventivo_summary'] ?? []; + $this->preventivoTabellaOptions = $payload['tabella_options'] ?? []; + $this->ripartoPreviewColumns = $payload['riparto_columns'] ?? []; + $this->ripartoPreviewRows = $payload['riparto_rows'] ?? []; + $this->ripartoRateMonths = $payload['mesi_rate'] ?? []; + $this->personeCollegateAudit = $payload['people_audit'] ?? []; + + $focusId = $payload['focus_unit_id'] ?? null; + if ($this->ripartoFocusUnitId === null || ! collect($this->ripartoPreviewRows)->contains(fn(array $row): bool => (int) $row['unit_id'] === (int) $this->ripartoFocusUnitId)) { + $this->ripartoFocusUnitId = $focusId ? (int) $focusId : null; + } + + $this->applyFocusRipartoBreakdown($payload['focus_breakdown'] ?? []); + } + + protected function applyFocusRipartoBreakdown(array $fallback = []): void + { + if ($this->ripartoFocusUnitId === null) { + $this->ripartoFocusBreakdown = []; + return; + } + + if ($fallback !== []) { + $this->ripartoFocusBreakdown = $fallback; + return; + } + + $row = collect($this->ripartoPreviewRows)->firstWhere('unit_id', (int) $this->ripartoFocusUnitId); + if (! is_array($row)) { + $this->ripartoFocusBreakdown = []; + return; + } + + $subjects = []; + $owners = $this->expandRipartoSubjects($row, 'owners'); + $tenants = $this->expandRipartoSubjects($row, 'tenants'); + foreach ($owners as $owner) { + $subjects[] = $owner; + } + foreach ($tenants as $tenant) { + $subjects[] = $tenant; + } + + $this->ripartoFocusBreakdown = $subjects; + } + + protected function expandRipartoSubjects(array $row, string $bucket): array + { + $source = $bucket === 'owners' ? ($row['owners'] ?? []) : ($row['tenants'] ?? []); + $pieces = []; + + if (is_array($source) && $source !== []) { + foreach ($source as $item) { + $pieces[] = [ + 'name' => (string) ($item['name'] ?? 'Soggetto'), + 'quota' => is_numeric($item['quota'] ?? null) ? (float) $item['quota'] : null, + ]; + } + } + + if ($pieces === []) { + $summary = (string) ($row['people_summary'] ?? ''); + $names = $bucket === 'owners' + ? preg_split('/\s*,\s*/', preg_replace('/^Prop:\s*/', '', preg_match('/Prop:\s*([^·]+)/', $summary, $m) ? $m[1] : ''), -1, PREG_SPLIT_NO_EMPTY) + : preg_split('/\s*,\s*/', preg_replace('/^Inq:\s*/', '', preg_match('/Inq:\s*(.+)$/', $summary, $m) ? $m[1] : ''), -1, PREG_SPLIT_NO_EMPTY); + + $pieces = array_map(fn(string $name): array=> ['name' => $name, 'quota' => null], $names ?: []); + } + + if ($pieces === []) { + return []; + } + + $count = count($pieces); + $declared = collect($pieces)->sum(fn(array $item): float => is_numeric($item['quota'] ?? null) ? (float) $item['quota'] : 0.0); + $baseKey = $bucket === 'owners' ? 'totale_condomini' : 'totale_inquilini'; + $rows = []; + + foreach ($pieces as $item) { + $share = $declared > 0 ? (((float) ($item['quota'] ?? 0.0)) / $declared) : (1 / max($count, 1)); + $columns = []; + $totale = 0.0; + foreach (($row['table_breakdown'] ?? []) as $code => $values) { + $base = (float) ($values[$bucket === 'owners' ? 'condomini' : 'inquilini'] ?? 0.0); + $quota = $base * $share; + $columns[$code] = $quota; + $totale += $quota; + } + + $rows[] = [ + 'name' => $item['name'], + 'role' => $bucket === 'owners' ? 'Condomino' : 'Inquilino', + 'quota_percent' => $share * 100, + 'columns' => $columns, + 'totale' => $totale > 0 ? $totale : ((float) ($row[$baseKey] ?? 0.0) * $share), + ]; + } + + return $rows; } public function getStraordinarieGestioniProperty(): array @@ -267,7 +455,7 @@ public function getAcquaSummaryProperty(): array ->where('cod_tab', 'ACQUA') ->when($legacyCode, fn($q) => $q->where('cod_stabile', $legacyCode)) ->when($legacyYear, fn($q) => $q->where('legacy_year', $legacyYear)) - ->sum(DB::raw('COALESCE(cons_euro, 0)')); + ->sum(DB::raw('COALESCE(prev_euro, 0)')); $totaleFatture = array_sum($byCode); @@ -319,7 +507,7 @@ public function getAcquaRipartoProperty(): array $ripartoRows = $ripartoQuery ->orderBy('id_cond') ->orderBy('cond_inquil') - ->get(['id_cond', 'cond_inquil', 'cons_euro']); + ->get(['id_cond', 'cond_inquil', 'prev_euro']); $out = []; $totale = 0.0; @@ -349,7 +537,7 @@ public function getAcquaRipartoProperty(): array $nominativo = '—'; } - $val = (float) ($r->cons_euro ?? 0); + $val = (float) ($r->prev_euro ?? 0); $totale += $val; $out[] = [ 'id_cond' => $r->id_cond, @@ -358,13 +546,35 @@ public function getAcquaRipartoProperty(): array 'scala' => $cond->scala ?? null, 'interno' => $cond->interno ?? null, 'piano' => $cond->piano ?? null, - 'cons_euro' => $val, + 'prev_euro' => $val, ]; } + usort($out, function (array $left, array $right): int { + return strnatcasecmp($this->buildAcquaRipartoSortKey($left), $this->buildAcquaRipartoSortKey($right)); + }); + return ['rows' => $out, 'totale' => $totale]; } + private function buildAcquaRipartoSortKey(array $row): string + { + return implode('|', [ + $this->normalizeAcquaNaturalToken((string) ($row['scala'] ?? '')), + $this->normalizeAcquaNaturalToken((string) ($row['interno'] ?? '')), + $this->normalizeAcquaNaturalToken((string) ($row['piano'] ?? '')), + $this->normalizeAcquaNaturalToken((string) ($row['nominativo'] ?? '')), + str_pad((string) ((int) ($row['id_cond'] ?? 0)), 10, '0', STR_PAD_LEFT), + ]); + } + + private function normalizeAcquaNaturalToken(string $value): string + { + $normalized = strtoupper(trim($value)); + + return preg_replace('/[\s\.\-\/]+/', '', $normalized) ?? ''; + } + public ?int $dettPersNSpe = null; public array $dettPersRows = []; public float $dettPersTotale = 0.0; diff --git a/app/Filament/Pages/Strumenti/PostIt.php b/app/Filament/Pages/Strumenti/PostIt.php index 5f6f071..bc43669 100644 --- a/app/Filament/Pages/Strumenti/PostIt.php +++ b/app/Filament/Pages/Strumenti/PostIt.php @@ -52,6 +52,7 @@ class PostIt extends Page public ?int $assegnazioneFornitoreId = null; public ?int $selectedSuggerimentoId = null; + public bool $disableAutoStabileAssociation = false; /** @var \Illuminate\Support\Collection */ public $suggerimentiRubrica; @@ -65,6 +66,8 @@ public function mount(): void $focus = (int) request()->query('focus_post_it', 0); $this->focusPostItId = $focus > 0 ? $focus : null; $this->selectedPostItId = $this->focusPostItId; + + $this->applyQueryPrefill(); } public function getGestionePostItUrl(): string @@ -201,9 +204,11 @@ public function updatedTelefono(?string $value): void $this->nomeChiamante = $match->nome_completo ?: $match->ragione_sociale; } - $stabile = Stabile::query()->where('rubrica_id', $match->id)->first(); - if ($stabile) { - $this->stabileId = $stabile->id; + if (! $this->disableAutoStabileAssociation) { + $stabile = Stabile::query()->where('rubrica_id', $match->id)->first(); + if ($stabile) { + $this->stabileId = $stabile->id; + } } if (blank($this->ricercaChiamante)) { @@ -228,8 +233,10 @@ public function selezionaSuggerimentoRubrica(int $rubricaId): void $this->telefono = $match->telefono_cellulare ?: ($match->telefono_ufficio ?: $match->telefono_casa); $this->ricercaChiamante = $match->nome_completo ?: ($match->ragione_sociale ?: ''); - $stabile = Stabile::query()->where('rubrica_id', $match->id)->first(); - $this->stabileId = $stabile?->id; + if (! $this->disableAutoStabileAssociation) { + $stabile = Stabile::query()->where('rubrica_id', $match->id)->first(); + $this->stabileId = $stabile?->id; + } $this->suggerimentiRubrica = $this->searchRubrica((string) $this->ricercaChiamante); } @@ -393,15 +400,38 @@ public function getRecentiProperty() $items = ChiamataPostIt::query() ->with(['rubrica', 'stabile', 'ticket', 'creatoDa', 'assegnatoAUser:id,name', 'assegnatoAFornitore:id,ragione_sociale,nome,cognome']) ->orderByDesc('chiamata_il') - ->limit(25) + ->limit(60) ->get(); - if (! $this->selectedPostItId && $items->isNotEmpty()) { - $this->selectedPostItId = (int) ($this->focusPostItId ?: $items->first()->id); + $grouped = $items + ->groupBy(fn(ChiamataPostIt $item): string => $this->buildRecenteGroupKey($item)) + ->map(function (Collection $group): array { + /** @var ChiamataPostIt $primary */ + $primary = $group->first(); + $callerLabel = $this->resolveRecenteCallerLabel($group); + $phoneLabel = $this->resolveRecentePhoneLabel($group); + + return [ + 'primary' => $primary, + 'items' => $group->values(), + 'count' => $group->count(), + 'caller_label' => $callerLabel, + 'phone' => $phoneLabel, + 'latest_at' => optional($primary->chiamata_il)->format('d/m/Y H:i'), + 'contains_selected' => $this->selectedPostItId + ? $group->contains(fn(ChiamataPostIt $item): bool => (int) $item->id === (int) $this->selectedPostItId) + : false, + ]; + }) + ->values(); + + if (! $this->selectedPostItId && $grouped->isNotEmpty()) { + $first = $grouped->first(); + $this->selectedPostItId = (int) ($this->focusPostItId ?: ($first['primary']->id ?? 0)); $this->loadSelectedPostItAssignment(); } - return $items; + return $grouped; } catch (QueryException) { return collect(); } @@ -518,6 +548,181 @@ private function createPostItRecord(): ChiamataPostIt ]); } + private function applyQueryPrefill(): void + { + $source = trim((string) request()->query('source', '')); + $this->disableAutoStabileAssociation = request()->boolean('lock_stabile') || $source === 'topbar_live_call'; + + $telefono = trim((string) request()->query('telefono', '')); + $nome = trim((string) request()->query('nome', '')); + $nota = trim((string) request()->query('nota', '')); + $oggetto = trim((string) request()->query('oggetto', '')); + $direzione = trim((string) request()->query('direzione', '')); + $rubricaId = (int) request()->query('rubrica_id', 0); + + if ($telefono !== '') { + $this->telefono = $telefono; + } + if ($nome !== '') { + $this->nomeChiamante = $nome; + $this->ricercaChiamante = $nome; + } + if ($nota !== '') { + $this->nota = $nota; + } + if ($oggetto !== '') { + $this->oggetto = $oggetto; + } + if (in_array($direzione, ['in_arrivo', 'in_uscita', 'persa'], true)) { + $this->direzione = $direzione; + } + + if ($rubricaId > 0) { + $rubrica = RubricaUniversale::query()->find($rubricaId); + if ($rubrica) { + $this->rubricaId = (int) $rubrica->id; + if ($this->nomeChiamante === null || trim((string) $this->nomeChiamante) === '') { + $this->nomeChiamante = $rubrica->nome_completo ?: $rubrica->ragione_sociale; + } + } + } + + if ($this->disableAutoStabileAssociation) { + $this->stabileId = null; + } + + if ($source === 'topbar_live_call' && $telefono !== '') { + $this->hydrateRubricaForPhonePrefill($telefono, $nome); + if ($this->nota === null || trim((string) $this->nota) === '') { + $this->nota = 'Chiamata in ingresso da ' . $telefono; + } + if ($this->oggetto === null || trim((string) $this->oggetto) === '') { + $this->oggetto = 'Chiamata in arrivo da valutare'; + } + } + } + + private function hydrateRubricaForPhonePrefill(string $phone, string $name = ''): void + { + $match = $this->findRubricaByPhone($phone); + if (! $match && $this->canCreateRubricaDraftFromPhone($phone)) { + $match = $this->createRubricaDraftFromPhone($phone, $name); + } + + if (! $match) { + return; + } + + $this->rubricaId = (int) $match->id; + if ($this->nomeChiamante === null || trim((string) $this->nomeChiamante) === '') { + $this->nomeChiamante = $match->nome_completo ?: $match->ragione_sociale; + } + if ($this->ricercaChiamante === null || trim((string) $this->ricercaChiamante) === '') { + $this->ricercaChiamante = $match->nome_completo ?: ($match->ragione_sociale ?: ''); + } + } + + private function canCreateRubricaDraftFromPhone(string $phone): bool + { + $digits = preg_replace('/\D+/', '', $phone) ?: ''; + + return strlen($digits) >= 6; + } + + private function createRubricaDraftFromPhone(string $phone, string $name = ''): ?RubricaUniversale + { + $digits = preg_replace('/\D+/', '', $phone) ?: ''; + if ($digits === '') { + return null; + } + + $normalizedPhone = PhoneNumber::toE164Italy($phone) ?? $digits; + $adminId = $this->resolveCurrentAmministratoreId(); + $cleanName = trim($name); + + $payload = [ + 'amministratore_id' => $adminId > 0 ? $adminId : null, + 'tipo_contatto' => str_contains($cleanName, ' ') ? 'persona_fisica' : 'persona_giuridica', + 'telefono_cellulare' => $normalizedPhone, + 'categoria' => 'altro', + 'stato' => 'attivo', + 'data_inserimento' => now()->toDateString(), + 'data_ultima_modifica' => now()->toDateString(), + 'creato_da' => Auth::id(), + 'modificato_da' => Auth::id(), + 'note_segreteria' => 'Bozza creata dal box Post-it in topbar per una chiamata in ingresso.', + ]; + + if ($cleanName !== '' && str_contains($cleanName, ' ')) { + $parts = preg_split('/\s+/', $cleanName) ?: []; + $payload['nome'] = array_shift($parts) ?: null; + $payload['cognome'] = $parts !== [] ? implode(' ', $parts) : null; + } elseif ($cleanName !== '') { + $payload['ragione_sociale'] = $cleanName; + } else { + $payload['ragione_sociale'] = 'Contatto ' . $digits; + } + + return RubricaUniversale::query()->create($payload); + } + + private function buildRecenteGroupKey(ChiamataPostIt $item): string + { + $phone = preg_replace('/\D+/', '', (string) ($item->telefono ?? '')) ?: ''; + if ($phone !== '') { + return 'phone:' . $phone; + } + + if ((int) ($item->rubrica_id ?? 0) > 0) { + return 'rubrica:' . (int) $item->rubrica_id; + } + + return 'caller:' . mb_strtolower(trim((string) ($item->nome_chiamante ?? 'sconosciuto'))); + } + + private function resolveRecenteCallerLabel(Collection $group): string + { + foreach ($group as $item) { + if ($item instanceof ChiamataPostIt && $item->rubrica) { + return $item->rubrica->nome_completo ?: ($item->rubrica->ragione_sociale ?: 'Contatto'); + } + } + + foreach ($group as $item) { + $label = trim((string) ($item->nome_chiamante ?? '')); + if ($label !== '' && ! $this->isGenericRecenteCallerLabel($label, (string) ($item->telefono ?? ''))) { + return $label; + } + } + + return 'Contatto non in Rubrica'; + } + + private function resolveRecentePhoneLabel(Collection $group): string + { + foreach ($group as $item) { + $phone = trim((string) ($item->telefono ?? '')); + if ($phone !== '') { + return $phone; + } + } + + return ''; + } + + private function isGenericRecenteCallerLabel(string $label, string $phone = ''): bool + { + $normalizedLabel = mb_strtolower(trim($label)); + if ($normalizedLabel === '' || in_array($normalizedLabel, ['chiamante non identificato', 'contatto non in rubrica'], true)) { + return true; + } + + $labelDigits = preg_replace('/\D+/', '', $label) ?: ''; + $phoneDigits = preg_replace('/\D+/', '', $phone) ?: ''; + + return $labelDigits !== '' && $phoneDigits !== '' && $labelDigits === $phoneDigits; + } + private function loadSelectedPostItAssignment(): void { $postIt = $this->selectedPostIt; diff --git a/app/Filament/Pages/Supporto/TicketAcqua.php b/app/Filament/Pages/Supporto/TicketAcqua.php index 78bbc30..fbfd9c9 100644 --- a/app/Filament/Pages/Supporto/TicketAcqua.php +++ b/app/Filament/Pages/Supporto/TicketAcqua.php @@ -57,6 +57,9 @@ class TicketAcqua extends Page /** @var array */ public array $pendingWaterPhotos = []; + /** @var array */ + public array $pendingWaterLibraryPhotos = []; + /** @var array */ public array $waterPhotoDescriptions = []; @@ -151,6 +154,11 @@ public function updatedPendingWaterPhotos(): void $this->appendPendingWaterPhotos(); } + public function updatedPendingWaterLibraryPhotos(): void + { + $this->appendPendingWaterLibraryPhotos(); + } + public function removeWaterPhoto(int $index): void { $file = $this->newWaterPhotos[$index] ?? null; @@ -648,10 +656,39 @@ private function appendPendingWaterPhotos(): void $this->syncWaterDescriptionSlots(); } + private function appendPendingWaterLibraryPhotos(): void + { + $pending = array_values(array_filter($this->pendingWaterLibraryPhotos, fn($file) => is_object($file))); + if ($pending === []) { + return; + } + + $available = max(0, 4 - count($this->newWaterPhotos)); + if ($available <= 0) { + $this->pendingWaterLibraryPhotos = []; + + Notification::make() + ->title('Limite foto raggiunto') + ->body('Puoi tenere in coda al massimo 4 foto per una lettura acqua.') + ->warning() + ->send(); + + return; + } + + $accepted = array_slice($pending, 0, $available); + $currentTotal = count($this->newWaterPhotos); + $this->newWaterPhotos = array_values(array_merge($this->newWaterPhotos, $accepted)); + $this->pendingWaterLibraryPhotos = []; + $this->assignDraftUploadCodes($accepted, $currentTotal); + $this->syncWaterDescriptionSlots(); + } + private function resetWaterDraftUploadState(): void { $this->newWaterPhotos = []; $this->pendingWaterPhotos = []; + $this->pendingWaterLibraryPhotos = []; $this->waterPhotoDescriptions = []; $this->waterUploadCodes = []; $this->waterClientMetadata = []; diff --git a/app/Livewire/Condomini/StabileDatiBancariTable.php b/app/Livewire/Condomini/StabileDatiBancariTable.php index 8f8fce9..3282844 100644 --- a/app/Livewire/Condomini/StabileDatiBancariTable.php +++ b/app/Livewire/Condomini/StabileDatiBancariTable.php @@ -3,11 +3,13 @@ namespace App\Livewire\Condomini; use App\Filament\Pages\Contabilita\CasseBancheMovimenti; +use App\Filament\Pages\Contabilita\SaldiContiArchivio; use App\Filament\Pages\Gescon\RubricaUniversaleScheda; use App\Models\DatiBancari; use App\Models\RubricaUniversale; use App\Models\Stabile; use App\Models\User; +use App\Modules\Contabilita\Models\SaldoConto; use App\Services\GesconImport\StabileEnrichmentService; use App\Support\StabileContext; use Filament\Actions\Action; @@ -93,6 +95,18 @@ public function table(Table $table): Table ->date('d/m/Y') ->toggleable(isToggledHiddenByDefault: true), + TextColumn::make('saldo_ufficiale') + ->label('Saldo ufficiale') + ->stateUsing(fn(DatiBancari $record): ?float => $this->resolveLatestSaldoSnapshotValue($record)) + ->money('EUR') + ->toggleable(), + + TextColumn::make('ultimo_estratto') + ->label('Ultimo estratto') + ->stateUsing(fn(DatiBancari $record): string => $this->resolveLatestSaldoSnapshotLabel($record)) + ->wrap() + ->toggleable(), + TextColumn::make('contatto.nome_completo') ->label('Contatto') ->wrap() @@ -198,6 +212,12 @@ public function table(Table $table): Table }) ->openUrlInNewTab(), + Action::make('saldi') + ->label('Saldi') + ->icon('heroicon-o-scale') + ->url(fn(): string => SaldiContiArchivio::getUrl(panel: 'admin-filament')) + ->openUrlInNewTab(), + Action::make('contatto') ->label('Contatto') ->icon('heroicon-o-identification') @@ -419,4 +439,59 @@ private function normalizePayload(array $data): array 'note' => isset($data['note']) ? trim((string) $data['note']) : null, ]; } + + private function resolveLatestSaldoSnapshot(DatiBancari $record): ?SaldoConto + { + static $cache = []; + + $cacheKey = (int) $record->id; + if (array_key_exists($cacheKey, $cache)) { + return $cache[$cacheKey]; + } + + return $cache[$cacheKey] = SaldoConto::query() + ->with('documento') + ->where('stabile_id', (int) $record->stabile_id) + ->where('conto_id', (int) $record->id) + ->orderByDesc('data_saldo') + ->orderByDesc('id') + ->first(); + } + + private function resolveLatestSaldoSnapshotValue(DatiBancari $record): ?float + { + $snapshot = $this->resolveLatestSaldoSnapshot($record); + + return $snapshot ? (float) $snapshot->saldo : null; + } + + private function resolveLatestSaldoSnapshotLabel(DatiBancari $record): string + { + $snapshot = $this->resolveLatestSaldoSnapshot($record); + if (! $snapshot) { + return 'Nessun estratto registrato'; + } + + $parts = []; + if ($snapshot->tipo_estratto) { + $parts[] = match ($snapshot->tipo_estratto) { + 'estratto_conto' => 'Estratto conto', + 'saldo_banca' => 'Saldo banca', + 'riconciliazione' => 'Riconciliazione', + default => 'Altro', + }; + } + if ($snapshot->periodo_da || $snapshot->periodo_a) { + $from = $snapshot->periodo_da?->format('d/m/Y') ?: '...'; + $to = $snapshot->periodo_a?->format('d/m/Y') ?: '...'; + $parts[] = $from . ' - ' . $to; + } else { + $parts[] = $snapshot->data_saldo?->format('d/m/Y') ?: 'Data non indicata'; + } + if ($snapshot->documento?->nome_originale) { + $parts[] = $snapshot->documento->nome_originale; + } + + return implode(' · ', $parts); + } } diff --git a/app/Livewire/Filament/TopbarLiveCall.php b/app/Livewire/Filament/TopbarLiveCall.php index 8f27162..1a252ab 100644 --- a/app/Livewire/Filament/TopbarLiveCall.php +++ b/app/Livewire/Filament/TopbarLiveCall.php @@ -1,12 +1,9 @@ title('Seleziona prima uno stabile attivo')->warning()->send(); - return; - } - - $postIt = ChiamataPostIt::query()->create([ - 'stabile_id' => (int) $stabileId, - 'creato_da_user_id' => (int) $user->id, - 'rubrica_id' => (int) ($this->liveIncomingCall['rubrica_id'] ?? 0) ?: null, - 'telefono' => (string) ($this->liveIncomingCall['phone'] ?? ''), - 'nome_chiamante' => (string) (($this->liveIncomingCall['rubrica_nome'] ?? '') ?: ('Chiamata ' . ($this->liveIncomingCall['phone'] ?? ''))), - 'direzione' => 'in_arrivo', - 'origine' => 'topbar_live_call', - 'origine_id' => (string) ($this->liveIncomingCall['message_id'] ?? ''), - 'oggetto' => 'Chiamata in arrivo da valutare', - 'nota' => (string) ($this->liveIncomingCall['line'] ?? ('Chiamata in ingresso da ' . ($this->liveIncomingCall['phone'] ?? ''))), - 'priorita' => 'Media', - 'stato' => 'post_it', - 'chiamata_il' => now(), - ]); - - Notification::make()->title('Post-it creato dalla testata')->success()->send(); - $this->redirect( - PostItGestione::getUrl([ - 'tab' => 'storico', - 'focus_post_it' => (int) $postIt->id, + PostIt::getUrl([ + 'source' => 'topbar_live_call', + 'telefono' => (string) ($this->liveIncomingCall['phone'] ?? ''), + 'nome' => (string) ($this->liveIncomingCall['rubrica_nome'] ?? ''), + 'rubrica_id' => (int) ($this->liveIncomingCall['rubrica_id'] ?? 0) ?: null, + 'direzione' => 'in_arrivo', + 'oggetto' => 'Chiamata in arrivo da valutare', + 'nota' => (string) ($this->liveIncomingCall['line'] ?? ('Chiamata in ingresso da ' . ($this->liveIncomingCall['phone'] ?? ''))), + 'lock_stabile' => 1, ], panel: 'admin-filament'), navigate: true, ); diff --git a/app/Models/DocumentoStabile.php b/app/Models/DocumentoStabile.php index 67513af..0dc09a2 100755 --- a/app/Models/DocumentoStabile.php +++ b/app/Models/DocumentoStabile.php @@ -170,7 +170,11 @@ public function getStatoScadenzaAttribute() */ public function getUrlDownloadAttribute() { - return route('admin.documenti.download', $this->id); + if (is_string($this->percorso_file) && trim($this->percorso_file) !== '' && Storage::disk('public')->exists($this->percorso_file)) { + return Storage::disk('public')->url($this->percorso_file); + } + + return null; } /** @@ -178,7 +182,7 @@ public function getUrlDownloadAttribute() */ public function getUrlViewAttribute() { - return route('admin.documenti.view', $this->id); + return $this->url_download; } /** @@ -195,7 +199,9 @@ public function incrementaDownload() */ public function fileEsiste() { - return Storage::exists($this->percorso_file); + return is_string($this->percorso_file) + && trim($this->percorso_file) !== '' + && (Storage::disk('public')->exists($this->percorso_file) || Storage::disk('local')->exists($this->percorso_file)); } /** @@ -203,9 +209,18 @@ public function fileEsiste() */ public function eliminaFile() { - if ($this->fileEsiste()) { - return Storage::delete($this->percorso_file); + if (! is_string($this->percorso_file) || trim($this->percorso_file) === '') { + return true; } + + if (Storage::disk('public')->exists($this->percorso_file)) { + return Storage::disk('public')->delete($this->percorso_file); + } + + if (Storage::disk('local')->exists($this->percorso_file)) { + return Storage::disk('local')->delete($this->percorso_file); + } + return true; } diff --git a/app/Modules/Contabilita/Models/SaldoConto.php b/app/Modules/Contabilita/Models/SaldoConto.php index 34cc7b1..62a4949 100644 --- a/app/Modules/Contabilita/Models/SaldoConto.php +++ b/app/Modules/Contabilita/Models/SaldoConto.php @@ -3,6 +3,7 @@ namespace App\Modules\Contabilita\Models; use App\Models\DatiBancari; +use App\Models\DocumentoStabile; use Illuminate\Database\Eloquent\Model; class SaldoConto extends Model @@ -14,14 +15,20 @@ class SaldoConto extends Model 'conto_id', 'iban', 'data_saldo', + 'periodo_da', + 'periodo_a', 'saldo', + 'tipo_estratto', 'note', + 'documento_stabile_id', 'created_by', 'updated_by', ]; protected $casts = [ 'data_saldo' => 'date', + 'periodo_da' => 'date', + 'periodo_a' => 'date', 'saldo' => 'decimal:2', ]; @@ -29,4 +36,9 @@ public function conto() { return $this->belongsTo(DatiBancari::class, 'conto_id'); } + + public function documento() + { + return $this->belongsTo(DocumentoStabile::class, 'documento_stabile_id'); + } } diff --git a/app/Providers/Filament/AdminFilamentPanelProvider.php b/app/Providers/Filament/AdminFilamentPanelProvider.php index 4f90bb6..8288869 100644 --- a/app/Providers/Filament/AdminFilamentPanelProvider.php +++ b/app/Providers/Filament/AdminFilamentPanelProvider.php @@ -171,4 +171,4 @@ protected function resolveSupplierCatalogUrl(): string return TicketOperativi::getUrl(panel: 'admin-filament'); } -} +} \ No newline at end of file diff --git a/app/Services/Contabilita/MovimentiBancaImporter.php b/app/Services/Contabilita/MovimentiBancaImporter.php index 929ab09..572cffb 100644 --- a/app/Services/Contabilita/MovimentiBancaImporter.php +++ b/app/Services/Contabilita/MovimentiBancaImporter.php @@ -1,10 +1,9 @@ unicreditSemicolonCsvParser = $unicreditSemicolonCsvParser ?? new UnicreditSemicolonCsvParser(); - $this->cedhouseCsvParser = $cedhouseCsvParser ?? new CedhouseCsvParser(); - $this->mpsQifParser = $mpsQifParser ?? new MpsQifParser(); + $this->cedhouseCsvParser = $cedhouseCsvParser ?? new CedhouseCsvParser(); + $this->mpsQifParser = $mpsQifParser ?? new MpsQifParser(); } /** @@ -51,7 +50,7 @@ public function importUnicreditSemicolonCsvForConto( $iban = null; } - $imported = 0; + $imported = 0; $duplicates = 0; $minDateStr = null; @@ -61,7 +60,10 @@ public function importUnicreditSemicolonCsvForConto( $max = null; foreach ($parsed['rows'] as $r) { $d = $r['data'] ?? null; - if (! $d) continue; + if (! $d) { + continue; + } + $min = $min ? ($d->lt($min) ? $d : $min) : $d; $max = $max ? ($d->gt($max) ? $d : $max) : $d; } @@ -117,18 +119,18 @@ public function importUnicreditSemicolonCsvForConto( } $payload = [ - 'stabile_id' => $stabileId, - 'conto_id' => $contoId, + 'stabile_id' => $stabileId, + 'conto_id' => $contoId, 'gestione_id' => $gestioneId, - 'iban' => $iban, - 'data' => $row['data'], - 'valuta' => $row['valuta'], + 'iban' => $iban, + 'data' => $row['data'], + 'valuta' => $row['valuta'], 'descrizione' => $row['descrizione'], - 'importo' => $row['importo'], - 'causale' => $row['causale'], + 'importo' => $row['importo'], + 'causale' => $row['causale'], 'source_file' => $sourceFile, - 'raw_line' => $row['raw_line'], - 'row_hash' => $rowHash, + 'raw_line' => $row['raw_line'], + 'row_hash' => $rowHash, ]; if (Schema::hasColumn('contabilita_movimenti_banca', 'descrizione_estesa')) { @@ -136,8 +138,8 @@ public function importUnicreditSemicolonCsvForConto( ? trim($row['descrizione_estesa']) : null; if (($payload['descrizione_estesa'] ?? null) === null && is_string($row['raw_line'] ?? null) && str_contains($row['raw_line'], '|')) { - $parts = explode('|', (string) $row['raw_line'], 2); - $fallback = trim((string) ($parts[1] ?? '')); + $parts = explode('|', (string) $row['raw_line'], 2); + $fallback = trim((string) ($parts[1] ?? '')); $payload['descrizione_estesa'] = $fallback !== '' ? $fallback : null; } } @@ -190,7 +192,7 @@ public function importCsvAutoForConto( $format = $this->detectCsvFormat($content); if ($format === 'unicredit_semicolon') { - $res = $this->importUnicreditSemicolonCsvForConto($content, $stabileId, $contoId, $sourceFile, $gestioneId, $replace); + $res = $this->importUnicreditSemicolonCsvForConto($content, $stabileId, $contoId, $sourceFile, $gestioneId, $replace); $res['meta'] = array_merge(['format' => $format], is_array($res['meta'] ?? null) ? $res['meta'] : []); return $res; } @@ -239,7 +241,7 @@ public function importCsvAutoForConto( foreach (['unicredit_wri', 'intesa_csv', 'cedhouse_csv', 'unicredit_semicolon'] as $try) { try { if ($try === 'unicredit_semicolon') { - $res = $this->importUnicreditSemicolonCsvForConto($content, $stabileId, $contoId, $sourceFile, $gestioneId, $replace); + $res = $this->importUnicreditSemicolonCsvForConto($content, $stabileId, $contoId, $sourceFile, $gestioneId, $replace); $res['meta'] = array_merge(['format' => $try], is_array($res['meta'] ?? null) ? $res['meta'] : []); return $res; } @@ -357,7 +359,7 @@ private function importParsedRowsForConto( $iban = null; } - $imported = 0; + $imported = 0; $duplicates = 0; $hasDescrizioneEstesa = false; @@ -369,11 +371,14 @@ private function importParsedRowsForConto( $minDateStr = null; $maxDateStr = null; - $min = null; - $max = null; + $min = null; + $max = null; foreach ($rows as $r) { $d = $r['data'] ?? null; - if (! $d) continue; + if (! $d) { + continue; + } + $min = $min ? ($d->lt($min) ? $d : $min) : $d; $max = $max ? ($d->gt($max) ? $d : $max) : $d; } @@ -386,7 +391,10 @@ private function importParsedRowsForConto( $maxDate = null; foreach ($rows as $r) { $d = $r['data'] ?? null; - if (! $d) continue; + if (! $d) { + continue; + } + $minDate = $minDate ? ($d->lt($minDate) ? $d : $minDate) : $d; $maxDate = $maxDate ? ($d->gt($maxDate) ? $d : $maxDate) : $d; } @@ -423,18 +431,18 @@ private function importParsedRowsForConto( } $payload = [ - 'stabile_id' => $stabileId, - 'conto_id' => $contoId, + 'stabile_id' => $stabileId, + 'conto_id' => $contoId, 'gestione_id' => $gestioneId, - 'iban' => $iban, - 'data' => $row['data'], - 'valuta' => $row['valuta'], + 'iban' => $iban, + 'data' => $row['data'], + 'valuta' => $row['valuta'], 'descrizione' => $row['descrizione'], - 'importo' => $row['importo'], - 'causale' => $row['causale'], + 'importo' => $row['importo'], + 'causale' => $row['causale'], 'source_file' => $sourceFile, - 'raw_line' => $row['raw_line'], - 'row_hash' => $rowHash, + 'raw_line' => $row['raw_line'], + 'row_hash' => $rowHash, ]; if ($hasDescrizioneEstesa) { @@ -504,7 +512,7 @@ public function importUnicreditWri( bool $replace = false, ): array { $parsed = $this->unicreditParser->parse($content); - $iban = $forcedIban ?: ($parsed['iban'] ?? null); + $iban = $forcedIban ?: ($parsed['iban'] ?? null); $contoId = null; try { @@ -524,11 +532,11 @@ public function importUnicreditWri( $contoId = null; } - $imported = 0; + $imported = 0; $duplicates = 0; DB::transaction(function () use ($parsed, $stabileId, $contoId, $iban, $sourceFile, $gestioneId, $replace, &$imported, &$duplicates) { - if ($replace && !empty($parsed['rows'])) { + if ($replace && ! empty($parsed['rows'])) { $minDate = null; $maxDate = null; foreach ($parsed['rows'] as $r) { @@ -582,18 +590,18 @@ public function importUnicreditWri( } MovimentoBanca::query()->create([ - 'stabile_id' => $stabileId, - 'conto_id' => $contoId, + 'stabile_id' => $stabileId, + 'conto_id' => $contoId, 'gestione_id' => $gestioneId, - 'iban' => $iban, - 'data' => $row['data'], - 'valuta' => $row['valuta'], + 'iban' => $iban, + 'data' => $row['data'], + 'valuta' => $row['valuta'], 'descrizione' => $row['descrizione'], - 'importo' => $row['importo'], - 'causale' => $row['causale'], + 'importo' => $row['importo'], + 'causale' => $row['causale'], 'source_file' => $sourceFile, - 'raw_line' => $row['raw_line'], - 'row_hash' => $rowHash, + 'raw_line' => $row['raw_line'], + 'row_hash' => $rowHash, ]); $imported++; @@ -615,7 +623,7 @@ public function importIntesaCsv( bool $replace = false, ): array { $parsed = $this->intesaParser->parse($content); - $iban = $forcedIban ?: ($parsed['iban'] ?? null); + $iban = $forcedIban ?: ($parsed['iban'] ?? null); $hasDescrizioneEstesa = false; try { @@ -642,7 +650,7 @@ public function importIntesaCsv( $contoId = null; } - $imported = 0; + $imported = 0; $duplicates = 0; DB::transaction(function () use ($parsed, $stabileId, $contoId, $iban, $sourceFile, $gestioneId, $replace, $hasDescrizioneEstesa, &$imported, &$duplicates) { @@ -699,18 +707,18 @@ public function importIntesaCsv( } $payload = [ - 'stabile_id' => $stabileId, - 'conto_id' => $contoId, + 'stabile_id' => $stabileId, + 'conto_id' => $contoId, 'gestione_id' => $gestioneId, - 'iban' => $iban, - 'data' => $row['data'], - 'valuta' => $row['valuta'], + 'iban' => $iban, + 'data' => $row['data'], + 'valuta' => $row['valuta'], 'descrizione' => $row['descrizione'], - 'importo' => $row['importo'], - 'causale' => $row['causale'], + 'importo' => $row['importo'], + 'causale' => $row['causale'], 'source_file' => $sourceFile, - 'raw_line' => $row['raw_line'], - 'row_hash' => $rowHash, + 'raw_line' => $row['raw_line'], + 'row_hash' => $rowHash, ]; if ($hasDescrizioneEstesa) { @@ -753,7 +761,7 @@ public function importExcelXlsxForConto( $iban = null; } - $imported = 0; + $imported = 0; $duplicates = 0; $minDateStr = null; @@ -763,7 +771,10 @@ public function importExcelXlsxForConto( $max = null; foreach ($parsed['rows'] as $r) { $d = $r['data'] ?? null; - if (! $d) continue; + if (! $d) { + continue; + } + $min = $min ? ($d->lt($min) ? $d : $min) : $d; $max = $max ? ($d->gt($max) ? $d : $max) : $d; } @@ -819,18 +830,18 @@ public function importExcelXlsxForConto( } $payload = [ - 'stabile_id' => $stabileId, - 'conto_id' => $contoId, + 'stabile_id' => $stabileId, + 'conto_id' => $contoId, 'gestione_id' => $gestioneId, - 'iban' => $iban, - 'data' => $row['data'], - 'valuta' => $row['valuta'], + 'iban' => $iban, + 'data' => $row['data'], + 'valuta' => $row['valuta'], 'descrizione' => $row['descrizione'], - 'importo' => $row['importo'], - 'causale' => $row['causale'], + 'importo' => $row['importo'], + 'causale' => $row['causale'], 'source_file' => $sourceFile, - 'raw_line' => $row['raw_line'], - 'row_hash' => $rowHash, + 'raw_line' => $row['raw_line'], + 'row_hash' => $rowHash, ]; if (Schema::hasColumn('contabilita_movimenti_banca', 'descrizione_estesa')) { @@ -838,9 +849,9 @@ public function importExcelXlsxForConto( ? trim($row['descrizione_estesa']) : null; if (($descEst ?? '') === '' && is_string($row['raw_line'] ?? null) && str_contains((string) $row['raw_line'], '|')) { - $parts = explode('|', (string) $row['raw_line'], 2); + $parts = explode('|', (string) $row['raw_line'], 2); $fallback = trim((string) ($parts[1] ?? '')); - $descEst = $fallback !== '' ? $fallback : null; + $descEst = $fallback !== '' ? $fallback : null; } $payload['descrizione_estesa'] = $descEst !== '' ? $descEst : null; } @@ -867,11 +878,11 @@ public function importExcelXlsxForConto( private function appendMatchData(array $payload, ?string $descrizioneEstesa, ?string $fallbackRaw, ?string $fallbackDescrizione): array { - $hasMatch = Schema::hasColumn('contabilita_movimenti_banca', 'match_data'); - $hasConfirm = Schema::hasColumn('contabilita_movimenti_banca', 'da_confermare'); + $hasMatch = Schema::hasColumn('contabilita_movimenti_banca', 'match_data'); + $hasConfirm = Schema::hasColumn('contabilita_movimenti_banca', 'da_confermare'); $hasRubricaMitt = Schema::hasColumn('contabilita_movimenti_banca', 'rubrica_mittente_id'); - $hasRubricaBen = Schema::hasColumn('contabilita_movimenti_banca', 'rubrica_beneficiario_id'); - $hasFornitore = Schema::hasColumn('contabilita_movimenti_banca', 'fornitore_id'); + $hasRubricaBen = Schema::hasColumn('contabilita_movimenti_banca', 'rubrica_beneficiario_id'); + $hasFornitore = Schema::hasColumn('contabilita_movimenti_banca', 'fornitore_id'); if (! $hasMatch && ! $hasConfirm && ! $hasRubricaMitt && ! $hasRubricaBen && ! $hasFornitore) { return $payload; @@ -892,16 +903,16 @@ private function appendMatchData(array $payload, ?string $descrizioneEstesa, ?st $payload['match_data'] = $match; } - $mittente = $match['mittente'] ?? null; + $mittente = $match['mittente'] ?? null; $beneficiario = $match['beneficiario'] ?? null; - $rubricaMittenteId = null; + $rubricaMittenteId = null; $rubricaBeneficiarioId = null; - $fornitoreId = null; + $fornitoreId = null; if ($mittente) { $rubricaMittenteId = $this->matchRubricaId($mittente); - $fornitoreId = $this->matchFornitoreId($mittente); + $fornitoreId = $this->matchFornitoreId($mittente); } if ($beneficiario) { @@ -919,7 +930,7 @@ private function appendMatchData(array $payload, ?string $descrizioneEstesa, ?st } if ($hasConfirm) { - $needs = ! empty($match) && ! $rubricaMittenteId && ! $fornitoreId; + $needs = ! empty($match) && ! $rubricaMittenteId && ! $fornitoreId; $payload['da_confermare'] = $needs; } @@ -928,9 +939,11 @@ private function appendMatchData(array $payload, ?string $descrizioneEstesa, ?st private function parseDescrizioneEstesa(string $text): array { - $res = []; + $res = []; $text = trim(preg_replace('/\s+/', ' ', $text)); - if ($text === '') return $res; + if ($text === '') { + return $res; + } if (preg_match('/COD\.\s*DISP\.:\s*([A-Z0-9]+)/i', $text, $m)) { $res['cod_disp'] = trim($m[1]); @@ -991,7 +1004,10 @@ private function parseDescrizioneEstesa(string $text): array private function matchRubricaId(string $name): ?int { $term = trim($name); - if ($term === '') return null; + if ($term === '') { + return null; + } + $like = '%' . str_replace(['%', '_'], ['\%', '\_'], $term) . '%'; $rows = RubricaUniversale::query() @@ -1008,7 +1024,10 @@ private function matchRubricaId(string $name): ?int private function matchFornitoreId(string $name): ?int { $term = trim($name); - if ($term === '') return null; + if ($term === '') { + return null; + } + $like = '%' . str_replace(['%', '_'], ['\%', '\_'], $term) . '%'; $rows = Fornitore::query() diff --git a/app/Services/Contabilita/MpsQifParser.php b/app/Services/Contabilita/MpsQifParser.php index e892390..369e61c 100644 --- a/app/Services/Contabilita/MpsQifParser.php +++ b/app/Services/Contabilita/MpsQifParser.php @@ -1,5 +1,4 @@ trim($line) !== '')); + $lines = array_values(array_filter(explode("\n", $content), static fn(string $line): bool => trim($line) !== '')); if ($lines === []) { throw new \RuntimeException('File QIF vuoto.'); } - $type = null; - $rows = []; + $type = null; + $rows = []; $transaction = []; foreach ($lines as $line) { - $line = preg_replace('/^\xEF\xBB\xBF/', '', $line) ?? $line; + $line = preg_replace('/^\xEF\xBB\xBF/', '', $line) ?? $line; $trimmed = trim($line); if ($trimmed === '') { @@ -44,7 +43,7 @@ public function parse(string $content): array continue; } - $code = substr($trimmed, 0, 1); + $code = substr($trimmed, 0, 1); $value = trim(substr($trimmed, 1)); if (! isset($transaction[$code])) { @@ -68,7 +67,7 @@ public function parse(string $content): array return [ 'rows' => $rows, 'meta' => [ - 'format' => 'mps_qif', + 'format' => 'mps_qif', 'qif_type' => $type, ], ]; @@ -80,17 +79,17 @@ public function parse(string $content): array */ private function buildRow(array $transaction, ?string $type): ?array { - $date = $this->parseDate($transaction['D'][0] ?? null); + $date = $this->parseDate($transaction['D'][0] ?? null); $amount = $this->parseMoney($transaction['T'][0] ?? null); if (! $date || $amount === null) { return null; } - $payee = $this->joinParts($transaction['P'] ?? []); - $memo = $this->joinParts($transaction['M'] ?? []); + $payee = $this->joinParts($transaction['P'] ?? []); + $memo = $this->joinParts($transaction['M'] ?? []); $description = $payee !== '' ? $payee : ($memo !== '' ? mb_substr($memo, 0, 255) : 'Movimento QIF'); - $extended = trim($payee . ($payee !== '' && $memo !== '' ? ' · ' : '') . $memo); + $extended = trim($payee . ($payee !== '' && $memo !== '' ? ' · ' : '') . $memo); $rawLines = []; foreach ($transaction as $code => $values) { @@ -100,13 +99,13 @@ private function buildRow(array $transaction, ?string $type): ?array } return [ - 'data' => $date, - 'valuta' => $date->copy(), - 'descrizione' => $description, + 'data' => $date, + 'valuta' => $date->copy(), + 'descrizione' => $description, 'descrizione_estesa' => $extended !== '' ? $extended : null, - 'importo' => $amount, - 'causale' => $type ? ('QIF:' . strtoupper($type)) : 'QIF', - 'raw_line' => implode("\n", $rawLines), + 'importo' => $amount, + 'causale' => $type ? ('QIF:' . strtoupper($type)) : 'QIF', + 'raw_line' => implode("\n", $rawLines), ]; } @@ -159,4 +158,4 @@ private function parseMoney(?string $value): ?float return is_numeric($value) ? (float) $value : null; } -} \ No newline at end of file +} diff --git a/app/Services/Contabilita/PreventivoOrdinarioPreviewService.php b/app/Services/Contabilita/PreventivoOrdinarioPreviewService.php new file mode 100644 index 0000000..4dbe6e8 --- /dev/null +++ b/app/Services/Contabilita/PreventivoOrdinarioPreviewService.php @@ -0,0 +1,761 @@ +resolveGestione($stabileId, $annoGestione, $gestioneId); + $gestioneId = $gestione?->id ? (int) $gestione->id : $gestioneId; + + if (! $gestioneId) { + return [ + 'gestione' => [ + 'id' => null, + 'anno' => $annoGestione, + 'denominazione' => (string) ($gestione?->denominazione ?? ''), + ], + 'mesi_rate' => [], + 'preventivo_rows' => [], + 'preventivo_summary' => [], + 'tabella_options' => [], + 'riparto_columns' => [], + 'riparto_rows' => [], + 'focus_unit_id' => null, + 'focus_breakdown' => [], + 'people_audit' => [], + ]; + } + + $mesiRate = $this->normalizeMonths($gestione?->mesi_rate_ordinaria ?? null); + $voci = $this->loadVoci($stabileId, $gestioneId); + $tabellaOptions = $this->buildTabellaOptions($stabileId, $annoGestione); + $tabellaMap = $tabellaOptions['map']; + + $summaryByTable = $this->buildTableSummary($voci, $tabellaMap); + $unitaRows = $this->loadUnitaRows($stabileId); + $detailRows = $this->loadDetailRows($stabileId, $annoGestione); + $peopleMap = $this->loadPeopleMap($stabileId, $unitaRows->pluck('id')->all()); + + $matrix = $this->buildMatrixRows($unitaRows, $summaryByTable, $detailRows, $peopleMap, $mesiRate); + $focusUnitId = $this->resolveFocusUnitId($matrix); + $focusBreakdown = $this->buildFocusBreakdown($matrix, $focusUnitId); + + return [ + 'gestione' => [ + 'id' => $gestioneId, + 'anno' => $annoGestione, + 'denominazione' => (string) ($gestione?->denominazione ?? ''), + ], + 'mesi_rate' => $mesiRate, + 'preventivo_rows' => $voci->map(function (array $row) use ($tabellaMap): array { + $tabella = $tabellaMap[$row['tabella_millesimale_default_id'] ?? 0] ?? null; + + return [ + 'id' => $row['id'], + 'codice' => $row['codice'], + 'descrizione' => $row['descrizione'], + 'tabella_millesimale_default_id' => $row['tabella_millesimale_default_id'], + 'tabella_codice' => $tabella['codice'] ?? '', + 'tabella_nome' => $tabella['label'] ?? '', + 'importo_default' => $row['importo_default'], + 'importo_consuntivo' => $row['importo_consuntivo'], + 'percentuale_inquilino' => $row['percentuale_inquilino'], + 'percentuale_condomino' => $row['percentuale_condomino'], + 'conto_pd' => $row['conto_pd'], + 'sottoconto_pd' => $row['sottoconto_pd'], + ]; + })->all(), + 'preventivo_summary' => array_values($summaryByTable), + 'tabella_options' => $tabellaOptions['options'], + 'riparto_columns' => array_values(array_map(fn(array $table): array=> [ + 'codice' => $table['codice'], + 'label' => $table['codice'], + 'descrizione' => $table['label'], + ], $summaryByTable)), + 'riparto_rows' => $matrix->values()->all(), + 'focus_unit_id' => $focusUnitId, + 'focus_breakdown' => $focusBreakdown, + 'people_audit' => $this->buildPeopleAudit($matrix, $peopleMap), + ]; + } + + private function resolveGestione(int $stabileId, int $annoGestione, ?int $gestioneId): ?object + { + if (! Schema::hasTable('gestioni_contabili')) { + return null; + } + + $query = DB::table('gestioni_contabili') + ->where('stabile_id', $stabileId) + ->where('tipo_gestione', 'ordinaria'); + + if ($gestioneId) { + $query->where('id', $gestioneId); + } else { + $query->where('anno_gestione', $annoGestione) + ->orderByDesc('gestione_attiva') + ->orderByDesc('id'); + } + + return $query->first(); + } + + private function normalizeMonths(mixed $raw): array + { + $months = []; + + if (is_array($raw)) { + $months = $raw; + } elseif (is_string($raw) && $raw !== '') { + $decoded = json_decode($raw, true); + if (is_array($decoded)) { + $months = $decoded; + } + } + + $months = collect($months) + ->map(fn($month) => (int) $month) + ->filter(fn(int $month) => $month >= 1 && $month <= 12) + ->unique() + ->sort() + ->values() + ->all(); + + return array_map(fn(int $month): array=> [ + 'month' => $month, + 'label' => $this->monthLabel($month), + ], $months); + } + + private function buildTabellaOptions(int $stabileId, int $annoGestione): array + { + $query = DB::table('tabelle_millesimali') + ->where('stabile_id', $stabileId); + + if (Schema::hasColumn('tabelle_millesimali', 'anno_gestione')) { + $query->where('anno_gestione', $annoGestione); + } + + $rows = $query + ->orderByRaw('COALESCE(nord, ordine_visualizzazione, ordinamento, 999999)') + ->orderBy('codice_tabella') + ->get([ + 'id', + 'codice_tabella', + 'denominazione', + 'totale_millesimi', + 'tipo_calcolo', + 'meta_legacy', + 'nord', + 'ordine_visualizzazione', + 'ordinamento', + ]); + + $options = []; + $map = []; + + foreach ($rows as $row) { + $metaLegacy = $this->decodeJson($row->meta_legacy ?? null); + $code = trim((string) ($row->codice_tabella ?? '')); + $label = trim((string) ($row->denominazione ?? '')); + $display = $code !== '' ? $code . ' · ' . ($label !== '' ? $label : $code) : ($label !== '' ? $label : 'Tabella'); + $nord = null; + + foreach ([$row->nord ?? null, $row->ordine_visualizzazione ?? null, $row->ordinamento ?? null] as $orderValue) { + if (is_numeric($orderValue)) { + $nord = (int) $orderValue; + break; + } + } + + $options[(int) $row->id] = $display; + $map[(int) $row->id] = [ + 'id' => (int) $row->id, + 'codice' => $code, + 'label' => $label !== '' ? $label : $code, + 'totale_millesimi' => is_numeric($row->totale_millesimi ?? null) ? (float) $row->totale_millesimi : 0.0, + 'tipo_calcolo' => $row->tipo_calcolo, + 'legacy_tipo' => $metaLegacy['tipo'] ?? null, + 'nord' => $nord, + ]; + } + + return ['options' => $options, 'map' => $map]; + } + + private function loadVoci(int $stabileId, ?int $gestioneId): Collection + { + if (! Schema::hasTable('voci_spesa')) { + return collect(); + } + + if (Schema::hasColumn('voci_spesa', 'gestione_contabile_id') && ! $gestioneId) { + return collect(); + } + + $query = DB::table('voci_spesa')->where('stabile_id', $stabileId); + + if (Schema::hasColumn('voci_spesa', 'gestione_contabile_id') && $gestioneId) { + $query->where('gestione_contabile_id', $gestioneId); + } + + if (Schema::hasColumn('voci_spesa', 'tipo_gestione')) { + $query->where('tipo_gestione', 'ordinaria'); + } + + $rows = $query + ->orderByRaw('COALESCE(nord, ordinamento, 999999)') + ->orderBy('codice') + ->get([ + 'id', + 'codice', + 'descrizione', + 'tabella_millesimale_default_id', + 'importo_default', + 'importo_consuntivo', + 'percentuale_condomino', + 'percentuale_inquilino', + 'conto_pd', + '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 ?? ''), + ]); + } + + private function buildTableSummary(Collection $voci, array $tabellaMap): array + { + return $voci + ->groupBy(fn(array $row): int => (int) ($row['tabella_millesimale_default_id'] ?? 0)) + ->map(function (Collection $rows, int $tabellaId) use ($tabellaMap): array { + $tabella = $tabellaMap[$tabellaId] ?? [ + 'codice' => '', + 'label' => '', + 'tipo_calcolo' => null, + 'legacy_tipo' => null, + 'totale_millesimi' => 0.0, + 'nord' => 999999, + ]; + $totalePreventivo = (float) $rows->sum('importo_default'); + $totaleConsuntivo = (float) $rows->sum('importo_consuntivo'); + $quotaInquilini = (float) $rows->sum(function (array $row): float { + return $row['importo_default'] * (($row['percentuale_inquilino'] ?? 0.0) / 100); + }); + + return [ + 'tabella_id' => $tabellaId, + 'codice' => $tabella['codice'], + 'label' => $tabella['label'], + 'tipo_calcolo' => $tabella['tipo_calcolo'], + 'legacy_tipo' => $tabella['legacy_tipo'], + 'nord' => $tabella['nord'] ?? 999999, + 'totale_millesimi' => $tabella['totale_millesimi'], + 'totale_preventivo' => $totalePreventivo, + 'totale_consuntivo' => $totaleConsuntivo, + 'quota_inquilini' => $quotaInquilini, + 'quota_condomini' => $totalePreventivo - $quotaInquilini, + 'voci' => $rows->values()->all(), + ]; + }) + ->sortBy(fn(array $row): string => sprintf('%06d|%s', (int) ($row['nord'] ?? 999999), (string) ($row['codice'] ?? ''))) + ->mapWithKeys(fn(array $row): array=> [$row['codice'] => $row]) + ->all(); + } + + private function loadUnitaRows(int $stabileId): Collection + { + $query = DB::table('unita_immobiliari')->where('stabile_id', $stabileId); + + if (Schema::hasColumn('unita_immobiliari', 'deleted_at')) { + $query->whereNull('deleted_at'); + } + + return $query + ->orderBy('scala') + ->orderBy('piano') + ->orderBy('interno') + ->orderBy('id') + ->get([ + 'id', + 'codice_unita', + 'denominazione', + 'scala', + 'piano', + 'interno', + 'stato_occupazione', + 'legacy_cond_id', + ]) + ->map(fn(object $row): array=> [ + 'id' => (int) $row->id, + 'label' => $this->buildUnitLabel($row), + 'sort_key' => $this->buildUnitSortKey($row), + 'stato_occupazione' => (string) ($row->stato_occupazione ?? ''), + 'legacy_cond_id' => $row->legacy_cond_id !== null ? (string) $row->legacy_cond_id : null, + ]); + } + + private function loadDetailRows(int $stabileId, int $annoGestione): Collection + { + if (! Schema::hasTable('dettaglio_millesimi') || ! Schema::hasTable('tabelle_millesimali')) { + return collect(); + } + + $query = DB::table('dettaglio_millesimi as dm') + ->join('tabelle_millesimali as tm', 'tm.id', '=', 'dm.tabella_millesimale_id') + ->where('tm.stabile_id', $stabileId); + + if (Schema::hasColumn('tabelle_millesimali', 'anno_gestione')) { + $query->where('tm.anno_gestione', $annoGestione); + } + + return $query + ->get([ + 'dm.unita_immobiliare_id', + 'tm.codice_tabella', + 'tm.totale_millesimi', + 'dm.millesimi', + 'dm.valore_prev', + 'dm.ruolo_legacy', + ]) + ->map(fn(object $row): array=> [ + 'unit_id' => (int) $row->unita_immobiliare_id, + 'tabella_codice' => (string) ($row->codice_tabella ?? ''), + 'totale_millesimi' => is_numeric($row->totale_millesimi ?? null) ? (float) $row->totale_millesimi : 0.0, + 'millesimi' => is_numeric($row->millesimi ?? null) ? (float) $row->millesimi : 0.0, + 'valore_prev' => is_numeric($row->valore_prev ?? null) ? (float) $row->valore_prev : 0.0, + 'ruolo_legacy' => strtoupper(trim((string) ($row->ruolo_legacy ?? ''))), + ]); + } + + private function buildMatrixRows(Collection $unitaRows, array $summaryByTable, Collection $detailRows, array $peopleMap, array $mesiRate): Collection + { + $detailsByUnitTable = $detailRows->groupBy(fn(array $row): string => $row['unit_id'] . '|' . $row['tabella_codice']); + + return $unitaRows->map(function (array $unit) use ($summaryByTable, $detailsByUnitTable, $peopleMap, $mesiRate): array { + $columns = []; + $breakdown = []; + $totaleUnit = 0.0; + $totaleCondomini = 0.0; + $totaleInquilini = 0.0; + $owners = $peopleMap[$unit['id']]['owners'] ?? []; + $tenants = $peopleMap[$unit['id']]['tenants'] ?? []; + + foreach ($summaryByTable as $tabellaCode => $table) { + $detail = collect($detailsByUnitTable->get($unit['id'] . '|' . $tabellaCode, [])); + $computed = $this->computeUnitTableAllocation($table, $detail); + + $columns[$tabellaCode] = $computed['totale']; + $breakdown[$tabellaCode] = $computed; + $totaleUnit += $computed['totale']; + $totaleCondomini += $computed['condomini']; + $totaleInquilini += $computed['inquilini']; + } + + return [ + 'unit_id' => $unit['id'], + 'unit_label' => $unit['label'], + 'stato_occupazione' => $unit['stato_occupazione'], + 'people_summary' => $peopleMap[$unit['id']]['summary'] ?? 'Nessun soggetto collegato', + 'owners' => $owners, + 'tenants' => $tenants, + 'legacy_cond_id' => $unit['legacy_cond_id'], + 'columns' => $columns, + 'table_breakdown' => $breakdown, + 'totale_unita' => $totaleUnit, + 'totale_condomini' => $totaleCondomini, + 'totale_inquilini' => $totaleInquilini, + 'rate' => $this->splitAcrossMonths($totaleUnit, $mesiRate), + ]; + })->sort(function (array $left, array $right): int { + return strnatcasecmp((string) ($left['sort_key'] ?? $left['unit_label'] ?? ''), (string) ($right['sort_key'] ?? $right['unit_label'] ?? '')); + })->values(); + } + + private function computeUnitTableAllocation(array $table, Collection $detailRows): array + { + $totaleTabella = (float) ($table['totale_preventivo'] ?? 0.0); + $tabellaMillesimi = (float) ($table['totale_millesimi'] ?? 0.0); + + $millesimiUnita = (float) $detailRows + ->filter(fn(array $row): bool => $row['ruolo_legacy'] !== 'I') + ->sum('millesimi'); + + $ratio = $tabellaMillesimi > 0 ? ($millesimiUnita / $tabellaMillesimi) : 0.0; + $impProp = (float) $detailRows + ->filter(fn(array $row): bool => $row['ruolo_legacy'] !== 'I') + ->sum('valore_prev'); + $impInq = (float) $detailRows + ->filter(fn(array $row): bool => $row['ruolo_legacy'] === 'I') + ->sum('valore_prev'); + $impTot = $impProp + $impInq; + $hasImporti = abs($impTot) > 0.00001; + $useImportiSplit = $hasImporti && abs($impInq) > 0.00001; + $quotaUnit = $hasImporti ? $impTot : ($totaleTabella * $ratio); + + $quotaCondomini = 0.0; + $quotaInquilini = 0.0; + + if ($useImportiSplit) { + $quotaCondomini = $impProp; + $quotaInquilini = $impInq; + } else { + foreach ($table['voci'] as $voce) { + $share = $totaleTabella != 0.0 ? ((float) $voce['importo_default'] / $totaleTabella) : 0.0; + $quotaVoce = $hasImporti ? ($quotaUnit * $share) : ((float) $voce['importo_default'] * $ratio); + $quotaInqVoce = $quotaVoce * (((float) ($voce['percentuale_inquilino'] ?? 0.0)) / 100); + $quotaCondomini += $quotaVoce - $quotaInqVoce; + $quotaInquilini += $quotaInqVoce; + } + } + + return [ + 'totale' => $quotaUnit, + 'condomini' => $quotaCondomini, + 'inquilini' => $quotaInquilini, + 'millesimi_unita' => $millesimiUnita, + 'millesimi_tabella' => $tabellaMillesimi, + ]; + } + + private function loadPeopleMap(int $stabileId, array $unitIds): array + { + $map = []; + + if ($unitIds === []) { + return $map; + } + + if (Schema::hasTable('persone_unita_relazioni')) { + $relations = PersonaUnitaRelazione::query() + ->with('persona') + ->whereIn('unita_id', $unitIds) + ->attive() + ->get(); + + foreach ($relations as $relation) { + $unitId = (int) $relation->unita_id; + $role = strtoupper((string) ($relation->ruolo_rate ?: PersonaUnitaRelazione::deriveRuoloRate($relation->tipo_relazione))); + if (! in_array($role, ['C', 'I'], true)) { + continue; + } + + $map[$unitId]['relations'][$role][] = [ + 'name' => (string) ($relation->persona?->nome_display ?? $relation->persona?->nome_completo ?? 'Persona'), + 'quota' => is_numeric($relation->quota_relazione ?? null) ? (float) $relation->quota_relazione : null, + 'source' => 'relazione_attiva', + ]; + } + } + + if (Schema::hasTable('unita_immobiliare_nominativi')) { + $today = now()->toDateString(); + $legacyRows = UnitaImmobiliareNominativo::query() + ->where('stabile_id', $stabileId) + ->whereIn('unita_immobiliare_id', $unitIds) + ->where(function ($query) use ($today): void { + $query->whereNull('data_inizio')->orWhere('data_inizio', '<=', $today); + }) + ->where(function ($query) use ($today): void { + $query->whereNull('data_fine')->orWhere('data_fine', '>=', $today); + }) + ->get(); + + foreach ($legacyRows as $row) { + $unitId = (int) $row->unita_immobiliare_id; + $role = strtoupper((string) ($row->ruolo ?? '')); + if (! in_array($role, ['C', 'I'], true)) { + continue; + } + + $map[$unitId]['legacy'][$role][] = [ + 'name' => (string) ($row->nominativo ?? 'Nominativo legacy'), + 'quota' => is_numeric($row->percentuale ?? null) ? (float) $row->percentuale : null, + 'source' => (string) ($row->fonte ?? 'legacy'), + ]; + } + } + + foreach ($unitIds as $unitId) { + $owners = $map[$unitId]['relations']['C'] ?? $map[$unitId]['legacy']['C'] ?? []; + $tenants = $map[$unitId]['relations']['I'] ?? $map[$unitId]['legacy']['I'] ?? []; + + $ownerNames = collect($owners)->pluck('name')->filter()->values()->all(); + $tenantNames = collect($tenants)->pluck('name')->filter()->values()->all(); + + $summary = []; + if ($ownerNames !== []) { + $summary[] = 'Prop: ' . implode(', ', $ownerNames); + } + if ($tenantNames !== []) { + $summary[] = 'Inq: ' . implode(', ', $tenantNames); + } + + $map[$unitId]['owners'] = $owners; + $map[$unitId]['tenants'] = $tenants; + $map[$unitId]['summary'] = $summary === [] ? 'Nessun soggetto collegato' : implode(' · ', $summary); + } + + return $map; + } + + private function buildPeopleAudit(Collection $matrix, array $peopleMap): array + { + return $matrix->map(function (array $row) use ($peopleMap): array { + $people = $peopleMap[$row['unit_id']] ?? ['owners' => [], 'tenants' => [], 'relations' => [], 'legacy' => []]; + + $owners = $people['owners'] ?? []; + $tenants = $people['tenants'] ?? []; + $ownerQuota = $this->sumDeclaredQuota($owners); + $tenantQuota = $this->sumDeclaredQuota($tenants); + $flags = []; + + if ($owners === []) { + $flags[] = 'Manca proprietario attivo'; + } + if ($row['stato_occupazione'] === 'occupata_inquilino' && $tenants === []) { + $flags[] = 'Unità occupata da inquilino senza inquilino attivo'; + } + if ($owners !== [] && $ownerQuota !== null && abs($ownerQuota - 100.0) > 0.01) { + $flags[] = 'Quote proprietari ' . number_format($ownerQuota, 2, ',', '.') . '%'; + } + if (($people['relations']['C'] ?? []) === [] && ($people['legacy']['C'] ?? []) !== []) { + $flags[] = 'Solo nominativi legacy per proprietari'; + } + if (($people['relations']['I'] ?? []) === [] && ($people['legacy']['I'] ?? []) !== []) { + $flags[] = 'Solo nominativi legacy per inquilini'; + } + + return [ + 'unit_id' => $row['unit_id'], + 'unit_label' => $row['unit_label'], + 'owners' => implode(', ', array_map(fn(array $item): string => $item['name'], $owners)), + 'tenants' => implode(', ', array_map(fn(array $item): string => $item['name'], $tenants)), + 'owner_quota' => $ownerQuota, + 'tenant_quota' => $tenantQuota, + 'flags' => $flags, + ]; + })->all(); + } + + private function buildFocusBreakdown(Collection $matrix, ?int $focusUnitId): array + { + if (! $focusUnitId) { + return []; + } + + $row = $matrix->firstWhere('unit_id', $focusUnitId); + if (! is_array($row)) { + return []; + } + + $subjects = []; + $ownerShares = $this->normalizePeopleShares($row['table_breakdown'], 'owners'); + $tenantShares = $this->normalizePeopleShares($row['table_breakdown'], 'tenants'); + + foreach ($ownerShares as $share) { + $subjects[] = $share; + } + foreach ($tenantShares as $share) { + $subjects[] = $share; + } + + return $subjects; + } + + private function normalizePeopleShares(array $tableBreakdown, string $bucket): array + { + $first = reset($tableBreakdown); + if (! is_array($first) || ! isset($first[$bucket]) || ! is_array($first[$bucket])) { + return []; + } + + $people = $first[$bucket]; + $totalBase = $bucket === 'owners' ? 'condomini' : 'inquilini'; + $shares = $this->normalizeShares($people); + $rows = []; + + foreach ($shares as $person) { + $totale = 0.0; + $columns = []; + + foreach ($tableBreakdown as $code => $values) { + $base = (float) ($values[$totalBase] ?? 0.0); + $quota = $base * $person['share']; + $columns[$code] = $quota; + $totale += $quota; + } + + $rows[] = [ + 'name' => $person['name'], + 'role' => $bucket === 'owners' ? 'Condomino' : 'Inquilino', + 'quota_percent' => $person['share'] * 100, + 'columns' => $columns, + 'totale' => $totale, + ]; + } + + return $rows; + } + + private function normalizeShares(array $people): array + { + if ($people === []) { + return []; + } + + $declared = collect($people)->sum(fn(array $row): float => is_numeric($row['quota'] ?? null) ? (float) $row['quota'] : 0.0); + $count = count($people); + + return array_map(function (array $row) use ($declared, $count): array { + $share = $declared > 0 + ? (((float) ($row['quota'] ?? 0.0)) / $declared) + : (1 / max($count, 1)); + + return [ + 'name' => $row['name'], + 'share' => $share, + ]; + }, $people); + } + + private function sumDeclaredQuota(array $people): ?float + { + $declared = collect($people) + ->filter(fn(array $row): bool => is_numeric($row['quota'] ?? null)) + ->sum(fn(array $row): float => (float) $row['quota']); + + return $declared > 0 ? $declared : null; + } + + private function splitAcrossMonths(float $total, array $mesiRate): array + { + $count = count($mesiRate); + if ($count === 0) { + return []; + } + + $base = round($total / $count, 2); + $out = []; + $allocated = 0.0; + + foreach ($mesiRate as $index => $month) { + $amount = $index === ($count - 1) ? round($total - $allocated, 2) : $base; + $allocated += $amount; + $out[] = [ + 'month' => $month['month'], + 'label' => $month['label'], + 'amount' => $amount, + ]; + } + + return $out; + } + + private function resolveFocusUnitId(Collection $matrix): ?int + { + $first = $matrix->first(); + + return is_array($first) ? (int) ($first['unit_id'] ?? 0) : null; + } + + private function buildUnitLabel(object $row): string + { + $parts = []; + + $scala = trim((string) ($row->scala ?? '')); + $piano = trim((string) ($row->piano ?? '')); + $interno = trim((string) ($row->interno ?? '')); + $denominazione = trim((string) ($row->denominazione ?? '')); + $codice = trim((string) ($row->codice_unita ?? '')); + + if ($scala !== '') { + $parts[] = 'Scala ' . $scala; + } + if ($piano !== '') { + $parts[] = 'Piano ' . $piano; + } + if ($interno !== '') { + $parts[] = 'Int. ' . $interno; + } + + $label = implode(' · ', $parts); + if ($label === '') { + $label = $denominazione !== '' ? $denominazione : ($codice !== '' ? $codice : 'Unità #' . $row->id); + } + + if ($codice !== '') { + $label .= ' [' . $codice . ']'; + } + + return $label; + } + + private function buildUnitSortKey(object $row): string + { + return implode('|', [ + $this->normalizeNaturalToken((string) ($row->scala ?? '')), + $this->normalizeNaturalToken((string) ($row->piano ?? '')), + $this->normalizeNaturalToken((string) ($row->interno ?? '')), + $this->normalizeNaturalToken((string) ($row->denominazione ?? '')), + str_pad((string) ((int) ($row->id ?? 0)), 10, '0', STR_PAD_LEFT), + ]); + } + + private function normalizeNaturalToken(string $value): string + { + $normalized = strtoupper(trim($value)); + + return preg_replace('/[\s\.\-\/]+/', '', $normalized) ?? ''; + } + + private function monthLabel(int $month): string + { + return [ + 1 => 'Gen', + 2 => 'Feb', + 3 => 'Mar', + 4 => 'Apr', + 5 => 'Mag', + 6 => 'Giu', + 7 => 'Lug', + 8 => 'Ago', + 9 => 'Set', + 10 => 'Ott', + 11 => 'Nov', + 12 => 'Dic', + ][$month] ?? (string) $month; + } + + private function decodeJson(mixed $value): array + { + if (is_array($value)) { + return $value; + } + + if (is_string($value) && $value !== '') { + $decoded = json_decode($value, true); + if (is_array($decoded)) { + return $decoded; + } + } + + return []; + } +} diff --git a/database/migrations/2026_04_15_220000_update_dettaglio_millesimi_unique_for_ruolo_legacy.php b/database/migrations/2026_04_15_220000_update_dettaglio_millesimi_unique_for_ruolo_legacy.php index 1ab30fa..c221bc7 100644 --- a/database/migrations/2026_04_15_220000_update_dettaglio_millesimi_unique_for_ruolo_legacy.php +++ b/database/migrations/2026_04_15_220000_update_dettaglio_millesimi_unique_for_ruolo_legacy.php @@ -5,7 +5,8 @@ use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; -return new class extends Migration { +return new class extends Migration +{ public function up(): void { if (! Schema::hasTable('dettaglio_millesimi') || ! Schema::hasColumn('dettaglio_millesimi', 'ruolo_legacy')) { @@ -46,4 +47,4 @@ public function down(): void } }); } -}; \ No newline at end of file +}; diff --git a/database/migrations/2026_04_17_120000_add_statement_metadata_to_contabilita_saldi_conti.php b/database/migrations/2026_04_17_120000_add_statement_metadata_to_contabilita_saldi_conti.php new file mode 100644 index 0000000..09f11ff --- /dev/null +++ b/database/migrations/2026_04_17_120000_add_statement_metadata_to_contabilita_saldi_conti.php @@ -0,0 +1,56 @@ +date('periodo_da')->nullable()->after('data_saldo'); + } + if (! Schema::hasColumn('contabilita_saldi_conti', 'periodo_a')) { + $table->date('periodo_a')->nullable()->after('periodo_da'); + } + if (! Schema::hasColumn('contabilita_saldi_conti', 'tipo_estratto')) { + $table->string('tipo_estratto', 50)->nullable()->after('saldo'); + } + if (! Schema::hasColumn('contabilita_saldi_conti', 'documento_stabile_id')) { + $table->foreignId('documento_stabile_id') + ->nullable() + ->after('note') + ->constrained('documenti_stabili') + ->nullOnDelete(); + } + }); + } + + public function down(): void + { + if (! Schema::hasTable('contabilita_saldi_conti')) { + return; + } + + Schema::table('contabilita_saldi_conti', function (Blueprint $table) { + if (Schema::hasColumn('contabilita_saldi_conti', 'documento_stabile_id')) { + $table->dropConstrainedForeignId('documento_stabile_id'); + } + if (Schema::hasColumn('contabilita_saldi_conti', 'tipo_estratto')) { + $table->dropColumn('tipo_estratto'); + } + if (Schema::hasColumn('contabilita_saldi_conti', 'periodo_a')) { + $table->dropColumn('periodo_a'); + } + if (Schema::hasColumn('contabilita_saldi_conti', 'periodo_da')) { + $table->dropColumn('periodo_da'); + } + }); + } +}; \ No newline at end of file diff --git a/docs/ai/SESSION_HANDOFF.md b/docs/ai/SESSION_HANDOFF.md index 30fcf46..00b5f39 100644 --- a/docs/ai/SESSION_HANDOFF.md +++ b/docs/ai/SESSION_HANDOFF.md @@ -42454,3 +42454,80 @@ ### Prossimo step operativo ### Prompt di ripartenza Riparti da docs/ai/SESSION_HANDOFF.md, usa l'ultimo CHECKPOINT, continua dal "Prossimo step operativo" senza rifare analisi generale. + +--- + +## CHECKPOINT 2026-04-18 08:46:35 + +### Stato rapido +- Branch: main +- Ultimo commit: 09022d5 2026-04-16 Stabilize supplier ops and strict legacy sync + +### File modificati (git status --short) +``` + M CHANGELOG.md + M app/Console/Commands/GesconSyncOrdinariaPreventivoCommand.php + M app/Console/Commands/TecnoRepairImportRubricaClientiCommand.php + M app/Filament/Pages/Contabilita/CasseBancheMovimenti.php + M app/Filament/Pages/Contabilita/CasseBancheRiepilogo.php + M app/Filament/Pages/Contabilita/SaldiContiArchivio.php +MM app/Filament/Pages/Contabilita/SituazioneIniziale.php + M app/Filament/Pages/Contabilita/VociSpesaArchivio.php + M app/Filament/Pages/Fornitore/Contabilita.php + M app/Filament/Pages/Gescon/Ordinarie.php + M app/Filament/Pages/Strumenti/PostIt.php + M app/Filament/Pages/Supporto/TicketAcqua.php + M app/Livewire/Condomini/StabileDatiBancariTable.php + M app/Livewire/Filament/TopbarLiveCall.php + M app/Models/DocumentoStabile.php + M app/Modules/Contabilita/Models/SaldoConto.php + M app/Providers/Filament/AdminFilamentPanelProvider.php + M app/Services/Contabilita/MovimentiBancaImporter.php + M app/Services/Contabilita/MpsQifParser.php + M database/migrations/2026_04_15_220000_update_dettaglio_millesimi_unique_for_ruolo_legacy.php + M resources/views/filament/pages/contabilita/casse-banche-movimenti.blade.php +MM resources/views/filament/pages/contabilita/situazione-iniziale.blade.php + M resources/views/filament/pages/contabilita/voci-spesa-prospetto.blade.php + M resources/views/filament/pages/gescon/section.blade.php + M resources/views/filament/pages/strumenti/post-it.blade.php + M resources/views/filament/pages/supporto/ticket-acqua-light.blade.php + M resources/views/livewire/filament/topbar-live-call.blade.php +?? app/Services/Contabilita/PreventivoOrdinarioPreviewService.php +?? database/migrations/2026_04_17_120000_add_statement_metadata_to_contabilita_saldi_conti.php +``` + +### Ultimi file toccati (max 20) +``` +CHANGELOG.md +app/Console/Commands/GesconSyncOrdinariaPreventivoCommand.php +app/Console/Commands/TecnoRepairImportRubricaClientiCommand.php +app/Filament/Pages/Contabilita/CasseBancheMovimenti.php +app/Filament/Pages/Contabilita/CasseBancheRiepilogo.php +app/Filament/Pages/Contabilita/SaldiContiArchivio.php +app/Filament/Pages/Contabilita/SituazioneIniziale.php +app/Filament/Pages/Contabilita/VociSpesaArchivio.php +app/Filament/Pages/Fornitore/Contabilita.php +app/Filament/Pages/Gescon/Ordinarie.php +app/Filament/Pages/Strumenti/PostIt.php +app/Filament/Pages/Supporto/TicketAcqua.php +app/Livewire/Condomini/StabileDatiBancariTable.php +app/Livewire/Filament/TopbarLiveCall.php +app/Models/DocumentoStabile.php +app/Modules/Contabilita/Models/SaldoConto.php +app/Providers/Filament/AdminFilamentPanelProvider.php +app/Services/Contabilita/MovimentiBancaImporter.php +app/Services/Contabilita/MpsQifParser.php +database/migrations/2026_04_15_220000_update_dettaglio_millesimi_unique_for_ruolo_legacy.php +``` + +### Bug trovati / verificati in questa sessione +- + +### Decisioni prese +- + +### Prossimo step operativo +- + +### Prompt di ripartenza +Riparti da docs/ai/SESSION_HANDOFF.md, usa l'ultimo CHECKPOINT, continua dal "Prossimo step operativo" senza rifare analisi generale. diff --git a/resources/views/filament/pages/contabilita/casse-banche-movimenti.blade.php b/resources/views/filament/pages/contabilita/casse-banche-movimenti.blade.php index f5f455f..90f9d8c 100644 --- a/resources/views/filament/pages/contabilita/casse-banche-movimenti.blade.php +++ b/resources/views/filament/pages/contabilita/casse-banche-movimenti.blade.php @@ -7,98 +7,420 @@ ]" /> - @if(! $this->getActiveStabile()) - -
Seleziona uno stabile per importare e verificare i movimenti banca.
-
- @endif - @php + $stabile = $this->getActiveStabile(); + $contiRows = $this->getHubContiRows(); + $contiOptions = $this->getContiImportabili(); $hdr = $this->getHeaderSaldoInfo(); $periodo = $this->periodoTotali; $periodoOptions = $this->periodoOptions; $periodoRateOptions = $this->periodoRateOptions; $periodoRate = $this->periodoRateTotali; $contoImport = $this->getSelectedContoImportInfo(); + $snapshotCards = $this->getSaldoSnapshotCards(); + $saldoArchivio = $this->getSaldoArchivioByYear(); + $ricStats = $this->getRiconciliazioneStats(); + $ricQueue = $this->getRiconciliazioneQueue(); + $ricCurrent = $this->getRiconciliazioneCurrent(); + $ricCandidates = $this->getRiconciliazioneCandidates(); @endphp - -
-
-
Import unico estratti
-
- Seleziona il conto o la cassa corretta e importa il file nel formato relativo. Il collegamento al conto resta esplicito, senza inferenze dal file. + @if(! $stabile) + +
Seleziona uno stabile per importare e verificare i movimenti banca.
+
+ @else + +
+
+
Stabile attivo
+
+ {{ $stabile->codice_operatore ?? $stabile->codice_stabile ?? '—' }} — {{ $stabile->denominazione ?? 'Stabile' }} +
+
Hub operativo del conto corrente: conti, saldi ufficiali, movimenti importati e riconciliazione.
- @if($contoImport) -
- Conto selezionato: {{ $contoImport['label'] }} + +
+
Conto attivo
+
+ +
+
+ @if($contoImport) +
{{ $contoImport['label'] }}
+ @else +
Nessun conto selezionato.
+ @endif +
+
+ + Importa estratto + + + Registra saldo + +
+
+
+ + + + Conti + Saldi e estratti + Movimenti + Riconciliazione + + + @if($this->hubTab === 'conti') + + Conti dello stabile + Vista generale equivalente a “Casse e banche”, ma dentro l'hub operativo del conto corrente. + +
+ @forelse($contiRows as $row) +
$row['selected'], + 'bg-white' => ! $row['selected'], + ])> +
+
+
{{ $row['label'] }}
+
+ @if($row['snapshot_data']) + Ultimo saldo ufficiale: {{ $row['snapshot_data'] }} + @if($row['snapshot_tipo']) + · {{ $row['snapshot_tipo'] }} + @endif + @else + Nessun saldo ufficiale registrato. + @endif +
+
+ @if($row['selected']) + Attivo + @endif +
+ +
+
+
Saldo attuale
+
€ {{ number_format((float) ($row['saldo_attuale'] ?? 0), 2, ',', '.') }}
+
+
+
Movimenti importati
+
{{ $row['movimenti_count'] ?? 0 }}
+
Ultima data: {{ $row['ultima_movimentazione'] ?? '—' }}
+
+
+ +
+ Saldi / estratti + Movimenti + Riconciliazione +
+
+ @empty +
Nessun conto configurato per lo stabile attivo.
+ @endforelse +
+
+ @elseif($this->hubTab === 'saldi') + +
+
+
Archivio saldi ufficiali ed estratti conto
+
Vista annuale del conto selezionato, separata dal mastrino dei movimenti.
+ @if($contoImport) +
Conto: {{ $contoImport['label'] }}
+ @endif +
+ + Nuovo saldo / estratto + +
+ +
+ @forelse($saldoArchivio as $yearBlock) +
+
+
Anno {{ $yearBlock['year'] }}
+
{{ count($yearBlock['rows']) }} estratti / saldi
+
+ +
+ + + + + + + + + + + + + + @foreach($yearBlock['rows'] as $row) + + + + + + + + + + @endforeach + +
Data saldoPeriodoTipoSaldoDocumentoNoteAzioni
{{ $row['data'] }} +
{{ $row['periodo_breve'] }}
+
{{ $row['periodo'] }}
+
{{ $row['tipo'] }}€ {{ number_format((float) $row['saldo'], 2, ',', '.') }} + @if($row['documento_url'] && $row['documento_label']) + {{ $row['documento_label'] }} + @else + + @endif + {{ $row['note'] !== '' ? $row['note'] : '—' }} + Apri movimenti +
+
+
+ @empty +
Nessun saldo ufficiale registrato per il conto selezionato.
+ @endforelse +
+
+ @elseif($this->hubTab === 'riconciliazione') + + Riconciliazione guidata + Pre-riconciliazione automatica con candidati. L’operatore verifica un movimento alla volta e usa il mastrino dove il match non è certo. + +
+
+
Totale movimenti
+
{{ $ricStats['totale'] ?? 0 }}
+
+
+
Senza prima nota
+
{{ $ricStats['senza_prima_nota'] ?? 0 }}
+
+
+
Già collegati
+
{{ $ricStats['collegati'] ?? 0 }}
+
+
+
Da confermare
+
{{ $ricStats['da_confermare'] ?? 0 }}
+
+
+ + @if(! $contoImport) +
Seleziona un conto per avviare la riconciliazione.
+ @elseif(! $ricCurrent) +
Nessun movimento in coda. Il conto sembra già lavorato oppure i movimenti sono tutti collegati.
+ @else +
+
+
+
+
Movimento in lavorazione
+
{{ ($this->riconciliazioneOffset ?? 0) + 1 }} di {{ count($ricQueue) }}
+
+
+ Precedente + Successivo +
+
+ +
+
+
+
{{ $ricCurrent['data'] }}
+
Stato: {{ $ricCurrent['stato'] }}
+
+
+ € {{ number_format((float) $ricCurrent['importo'], 2, ',', '.') }} +
+
+
{{ $ricCurrent['descrizione'] }}
+
+ Apri nel mastrino + @if($ricCurrent['has_registrazione']) + Ha già una prima nota + @else + Da collegare + @endif +
+
+
+ +
+
Candidati automatici
+
+ @if($ricCurrent['importo'] >= 0) + Match su incassi registrati per importo, data e testo della causale. + @else + Match su fatture fornitore per importo, data e denominazione del fornitore. + @endif +
+ +
+ @forelse($ricCandidates as $candidate) +
+
+
+
{{ $candidate['tipo'] }}
+
{{ $candidate['titolo'] }}
+
+
{{ $candidate['score'] }}%
+
+
{{ $candidate['dettaglio'] }}
+
+ Data: {{ $candidate['data'] }} + Importo: € {{ number_format((float) $candidate['importo'], 2, ',', '.') }} +
+
{{ $candidate['motivo'] }}
+
+ @empty +
Nessun candidato convincente. In questo caso l’operatore lavora dal mastrino e conferma manualmente il collegamento.
+ @endforelse +
+
@endif -
- - Importa estratto - -
- + + @else + +
+
+
Import unico estratti
+
+ Seleziona il conto o la cassa corretta e importa il file nel formato relativo. Il collegamento al conto resta esplicito, senza inferenze dal file. +
+ @if($contoImport) +
+ Conto selezionato: {{ $contoImport['label'] }} +
+ @endif +
+ + Importa estratto + +
+
- -
-
- {{ $hdr['label'] }} - @if($hdr['saldo'] !== null && $hdr['data']) - · saldo al: {{ $hdr['data'] }} € {{ number_format((float) $hdr['saldo'], 2, ',', '.') }} + +
+
+ {{ $hdr['label'] }} + @if($hdr['saldo'] !== null && $hdr['data']) + · saldo al: {{ $hdr['data'] }} € {{ number_format((float) $hdr['saldo'], 2, ',', '.') }} + @endif +
+
Filtra per periodo/gestione dai filtri tabella.
+
+ + @if($this->movimentiFocusFrom || $this->movimentiFocusTo) +
+
+ Filtro rapido attivo sul periodo + {{ $this->movimentiFocusFrom ?: '—' }} + → + {{ $this->movimentiFocusTo ?: '—' }} +
+ Rimuovi focus +
@endif -
-
Filtra per periodo/gestione dai filtri tabella.
-
-
-
-
Periodo
-
- - - + +
+
+
Periodo
+
+ + + +
+
+
+
Totali periodo ({{ $periodo['label'] ?? '' }})
+
+
Movimenti: {{ $periodo['count'] ?? 0 }}
+
Entrate: € {{ number_format((float)($periodo['entrate'] ?? 0), 2, ',', '.') }}
+
Uscite: € {{ number_format((float)($periodo['uscite'] ?? 0), 2, ',', '.') }}
+
+
+
+
Periodo rate (senza anno)
+
+ +
+
+
Movimenti: {{ $periodoRate['count'] ?? 0 }}
+
Entrate: € {{ number_format((float)($periodoRate['entrate'] ?? 0), 2, ',', '.') }}
+
Uscite: € {{ number_format((float)($periodoRate['uscite'] ?? 0), 2, ',', '.') }}
+
+
+
+
Saldo periodo
+
€ {{ number_format((float)($periodo['saldo'] ?? 0), 2, ',', '.') }}
+
-
-
-
Totali periodo ({{ $periodo['label'] ?? '' }})
-
-
Movimenti: {{ $periodo['count'] ?? 0 }}
-
Entrate: € {{ number_format((float)($periodo['entrate'] ?? 0), 2, ',', '.') }}
-
Uscite: € {{ number_format((float)($periodo['uscite'] ?? 0), 2, ',', '.') }}
+ +
+
+
+
Snapshot ufficiali / estratti conto
+
I saldi ufficiali ancorano il ricalcolo del conto anche quando il conto non ha IBAN.
+
+ + Registra saldo / estratto + +
+ +
+ @forelse($snapshotCards as $card) +
+
+
{{ $card['tipo'] }}
+
{{ $card['data'] ?? '—' }}
+
+
€ {{ number_format((float) ($card['saldo'] ?? 0), 2, ',', '.') }}
+
{{ $card['periodo'] }}
+ @if(!empty($card['documento_url']) && !empty($card['documento_label'])) + {{ $card['documento_label'] }} + @endif +
+ @empty +
Nessuno snapshot ufficiale registrato per il conto selezionato.
+ @endforelse +
-
-
-
Periodo rate (senza anno)
-
- + +
+ {{ $this->table }}
-
-
Movimenti: {{ $periodoRate['count'] ?? 0 }}
-
Entrate: € {{ number_format((float)($periodoRate['entrate'] ?? 0), 2, ',', '.') }}
-
Uscite: € {{ number_format((float)($periodoRate['uscite'] ?? 0), 2, ',', '.') }}
-
-
-
-
Saldo periodo
-
€ {{ number_format((float)($periodo['saldo'] ?? 0), 2, ',', '.') }}
-
-
-
- {{ $this->table }} -
- + + @endif + @endif
diff --git a/resources/views/filament/pages/contabilita/situazione-iniziale.blade.php b/resources/views/filament/pages/contabilita/situazione-iniziale.blade.php index e7701aa..5bee155 100644 --- a/resources/views/filament/pages/contabilita/situazione-iniziale.blade.php +++ b/resources/views/filament/pages/contabilita/situazione-iniziale.blade.php @@ -23,7 +23,7 @@ @endif
-
Periodo legacy
+
Esercizio archivio
- - - - - - -
- -
-
n_stra (opzionale)
- - - -
- -
-
Condomino (id_cond)
- - - -
- -
-
Consuntivo € (positivo=incasso, negativo=rimborso)
- - - -
- -
- Aggiungi conguaglio (manuale) -
- - @endif - -
- - - - - @foreach(($conguagliExcelCols ?? []) as $c) - - @endforeach - - - - @forelse(($conguagliExcel ?? []) as $r) - - - @foreach(($conguagliExcelCols ?? []) as $c) - @php $v = $r['cells'][$c['key']] ?? 0.0; @endphp - - @endforeach - - @empty - - - - @endforelse - -
Condomino{{ $c['label'] }}
-
{{ $r['id_cond'] }}
-
{{ $r['cond_label'] ?? '' }}
-
{{ $fmt($v) }}
Nessun conguaglio disponibile.
-
+ Conguagli di apertura + Fonte unica: gescon_import.dett_tab con cod_tab = CONG.O. Qui manteniamo solo i residui iniziali ordinari del passaggio consegne.
Modifica righe (dett_tab)
Suggerimento: clicca una riga per modificarla (una alla volta).
-
+
-
Tabella
+
Fonte
- +
@@ -265,12 +187,6 @@
-
-
n_stra
- - - -
cons_euro
@@ -295,7 +211,6 @@ Tabella id_cond Condomino - n_stra Cons. € MM Unico @@ -328,15 +243,6 @@
{{ $r['cond_label'] ?? '' }}
- - @if($isEditing) - - - - @else - {{ $r['n_stra'] ?? '—' }} - @endif - @if($isEditing) @@ -374,7 +280,7 @@ @empty - Nessuna riga dett_tab disponibile. + Nessuna riga CONG.O disponibile. @endforelse diff --git a/resources/views/filament/pages/contabilita/voci-spesa-prospetto.blade.php b/resources/views/filament/pages/contabilita/voci-spesa-prospetto.blade.php index 806e27b..6152273 100644 --- a/resources/views/filament/pages/contabilita/voci-spesa-prospetto.blade.php +++ b/resources/views/filament/pages/contabilita/voci-spesa-prospetto.blade.php @@ -67,7 +67,7 @@ class="fi-input fi-input-wrp fi-select-input text-xs"
-
+
{{ $this->table }}
@@ -100,8 +100,8 @@ class="fi-input fi-input-wrp fi-select-input text-xs" @php($voci = is_array($focus['voci'] ?? null) ? $focus['voci'] : [])
- - +
+ @@ -117,13 +117,13 @@ class="fi-input fi-input-wrp fi-select-input text-xs" @php($code = (string) ($v['codice'] ?? '')) @php($desc = (string) ($v['descrizione'] ?? '')) - - - - - - - + + + + + + + @empty @@ -146,6 +146,24 @@ class="fi-input fi-input-wrp fi-select-input text-xs" Totale Cons:{{ number_format((float) $this->getTotaleConsuntivo(), 2, ',', '.') }} + @if(($this->tipoGestioneTab ?? 'ordinaria') !== 'acqua' && ((float) $this->getTotalePreventivoAcqua() > 0 || (float) $this->getTotaleConsuntivoAcqua() > 0)) +
+ ACQUA Prev: + {{ number_format((float) $this->getTotalePreventivoAcqua(), 2, ',', '.') }} +
+
+ ACQUA Cons: + {{ number_format((float) $this->getTotaleConsuntivoAcqua(), 2, ',', '.') }} +
+
+ Totale Gen. Prev: + {{ number_format((float) $this->getTotalePreventivoGenerale(), 2, ',', '.') }} +
+
+ Totale Gen. Cons: + {{ number_format((float) $this->getTotaleConsuntivoGenerale(), 2, ',', '.') }} +
+ @endif diff --git a/resources/views/filament/pages/gescon/section.blade.php b/resources/views/filament/pages/gescon/section.blade.php index 485181e..388e6ac 100644 --- a/resources/views/filament/pages/gescon/section.blade.php +++ b/resources/views/filament/pages/gescon/section.blade.php @@ -114,6 +114,21 @@ wire:click="$set('viewTab','consuntivo')" >Consuntivo @if(! $isStraordPage) + Preventivo + Riparto + Persone @endif + @if($activeTab === 'preventivo') + @php + $preventivoSummaryCollection = collect($this->preventivoSummaryRows ?? []); + $preventivoRowsCollection = collect($this->preventivoEditorRows ?? []); + $preventivoRowsGrouped = $preventivoSummaryCollection + ->map(function ($summary) use ($preventivoRowsCollection) { + $code = (string) ($summary['codice'] ?? ''); + + return [ + 'summary' => $summary, + 'rows' => $preventivoRowsCollection + ->filter(fn ($row) => (string) ($row['tabella_codice'] ?? '') === $code) + ->values() + ->all(), + ]; + }) + ->filter(fn (array $group) => ! empty($group['rows']) || ! empty($group['summary'])) + ->values(); + $preventivoOrdTotal = (float) $preventivoSummaryCollection + ->reject(fn ($summary) => strtoupper((string) ($summary['codice'] ?? '')) === 'ACQUA') + ->sum(fn ($summary) => (float) ($summary['totale_preventivo'] ?? 0)); + $preventivoAcquaTotal = (float) $preventivoSummaryCollection + ->filter(fn ($summary) => strtoupper((string) ($summary['codice'] ?? '')) === 'ACQUA') + ->sum(fn ($summary) => (float) ($summary['totale_preventivo'] ?? 0)); + $preventivoGeneralTotal = $preventivoOrdTotal + $preventivoAcquaTotal; + @endphp +
+
+
Preventivo ordinario operativo
+
+ Gestione {{ $this->preventivoGestioneMeta['denominazione'] ?? 'ordinaria' }} + @if(!empty($this->preventivoGestioneMeta['anno'])) + · anno {{ $this->preventivoGestioneMeta['anno'] }} + @endif + · rate emesse su + @if(!empty($this->ripartoRateMonths)) + {{ collect($this->ripartoRateMonths)->pluck('label')->implode(', ') }} + @else + mesi non configurati + @endif +
+
Visualizzazione ordinata per tabella, con ACQUA separata ma riportata anche nel totale finale.
+
+ +
+
+ Voci di spesa per tabella +
+
+
Cod. Descrizione
{{ $code !== '' ? $code : '—' }}{{ $desc !== '' ? $desc : '—' }}{{ number_format((float) ($v['prev'] ?? 0), 2, ',', '.') }}{{ number_format((float) ($v['cons'] ?? 0), 2, ',', '.') }}{{ number_format((float) ($v['prop'] ?? 0), 2, ',', '.') }}{{ number_format((float) ($v['inq'] ?? 0), 2, ',', '.') }}{{ !empty($v['attiva']) ? 'Sì' : 'No' }}{{ $code !== '' ? $code : '—' }}{{ $desc !== '' ? $desc : '—' }}{{ number_format((float) ($v['prev'] ?? 0), 2, ',', '.') }}{{ number_format((float) ($v['cons'] ?? 0), 2, ',', '.') }}{{ number_format((float) ($v['prop'] ?? 0), 2, ',', '.') }}{{ number_format((float) ($v['inq'] ?? 0), 2, ',', '.') }}{{ !empty($v['attiva']) ? 'Sì' : 'No' }}
+ + + + + + + + + + + + + @forelse($preventivoRowsGrouped as $group) + @php + $summary = $group['summary'] ?? []; + @endphp + + + + @foreach(($group['rows'] ?? []) as $row) + + + + + + + + + + @endforeach + + + + + + + + @empty + + + + @endforelse + +
CodiceDescrizioneTabellaPreventivo €Consuntivo €% InquilinoConto PD
+ {{ $summary['codice'] ?? '—' }} + @if(!empty($summary['label'])) + · {{ $summary['label'] }} + @endif +
{{ $row['codice'] }} +
{{ $row['descrizione'] }}
+
{{ ($row['tabella_codice'] ?? '—') !== '' ? $row['tabella_codice'] : '—' }}{{ number_format((float) ($row['importo_default'] ?? 0), 2, ',', '.') }}{{ number_format((float) ($row['importo_consuntivo'] ?? 0), 2, ',', '.') }}{{ number_format((float) ($row['percentuale_inquilino'] ?? 0), 2, ',', '.') }} + {{ $row['conto_pd'] ?: '—' }} + @if(!empty($row['sottoconto_pd'])) +
{{ $row['sottoconto_pd'] }}
+ @endif +
Subtotale {{ $summary['codice'] ?? '—' }}{{ number_format((float) ($summary['totale_preventivo'] ?? 0), 2, ',', '.') }}{{ number_format((float) ($summary['totale_consuntivo'] ?? 0), 2, ',', '.') }}
Nessuna voce ordinaria trovata per la gestione attiva.
+
+
+ + @if(!empty($this->preventivoSummaryRows)) +
+
Riepilogo per tabella
+
+ + + + + + + + + + + + + @foreach($this->preventivoSummaryRows as $summary) + + + + + + + + + @endforeach + + + + + + + + @if($preventivoAcquaTotal > 0) + + + + + + + + @endif + + + + + + + + +
TabellaTipoPreventivo €Consuntivo €Quota condomini €Quota inquilini €
+
{{ $summary['codice'] }}
+
{{ $summary['label'] }}
+
{{ $summary['legacy_tipo'] ?? $summary['tipo_calcolo'] ?? '—' }}{{ number_format((float) $summary['totale_preventivo'], 2, ',', '.') }}{{ number_format((float) $summary['totale_consuntivo'], 2, ',', '.') }}{{ number_format((float) $summary['quota_condomini'], 2, ',', '.') }}{{ number_format((float) $summary['quota_inquilini'], 2, ',', '.') }}
Totale ordinarie senza ACQUA{{ number_format((float) $preventivoOrdTotal, 2, ',', '.') }}
ACQUA{{ number_format((float) $preventivoAcquaTotal, 2, ',', '.') }}
Totale generale{{ number_format((float) $preventivoGeneralTotal, 2, ',', '.') }}
+
+
+ @endif +
+ @endif + + @if($activeTab === 'riparto') +
+
+
Ripartizione simulata per unità
+
Sequenza applicata: totale voce → tabella/unità → quota condomini e quota inquilini → rate sui mesi configurati.
+
Le colonne `TAB.A`, `TAB.B`, `ACQUA` ecc. sono il formato base stampabile richiesto per distribuire il riparto per unità immobiliari.
+
+ +
+
Matrice ripartizione per unità
+
+ + + + + + @foreach($this->ripartoPreviewColumns as $column) + + @endforeach + + + + @foreach($this->ripartoRateMonths as $month) + + @endforeach + + + + @forelse($this->ripartoPreviewRows as $row) + + + + @foreach($this->ripartoPreviewColumns as $column) + + @endforeach + + + + @foreach($row['rate'] as $rate) + + @endforeach + + @empty + + + + @endforelse + +
UnitàSoggetti{{ $column['label'] }}Condomini €Inquilini €Totale €{{ $month['label'] }}
+
{{ $row['unit_label'] }}
+
{{ $row['stato_occupazione'] ?: 'occupazione non indicata' }}
+
{{ $row['people_summary'] }}{{ number_format((float) ($row['columns'][$column['codice']] ?? 0), 2, ',', '.') }}{{ number_format((float) $row['totale_condomini'], 2, ',', '.') }}{{ number_format((float) $row['totale_inquilini'], 2, ',', '.') }}{{ number_format((float) $row['totale_unita'], 2, ',', '.') }}{{ number_format((float) $rate['amount'], 2, ',', '.') }}
Riparto non disponibile per lo stabile o per l'anno selezionato.
+
+
+ + @if(!empty($this->ripartoPreviewRows)) +
+
+ Dettaglio soggetti per unità + +
+
+ + + + + + + @foreach($this->ripartoPreviewColumns as $column) + + @endforeach + + + + + @forelse($this->ripartoFocusBreakdown as $subject) + + + + + @foreach($this->ripartoPreviewColumns as $column) + + @endforeach + + + @empty + + + + @endforelse + +
SoggettoRuoloQuota %{{ $column['label'] }}Totale €
{{ $subject['name'] }}{{ $subject['role'] }}{{ number_format((float) $subject['quota_percent'], 2, ',', '.') }}{{ number_format((float) ($subject['columns'][$column['codice']] ?? 0), 2, ',', '.') }}{{ number_format((float) $subject['totale'], 2, ',', '.') }}
Nessun soggetto disponibile per il dettaglio dell'unità selezionata.
+
+
+ @endif +
+ @endif + + @if($activeTab === 'persone') +
+
+
Controllo persone collegate alle unità
+
Questo controllo evidenzia unità senza proprietario attivo, unità con solo nominativi legacy e unità occupate da inquilino senza un inquilino attivo collegato.
+
+ +
+
Audit collegamenti stabile attivo
+
+ + + + + + + + + + + + @forelse($this->personeCollegateAudit as $audit) + + + + + + + + @empty + + + + @endforelse + +
UnitàProprietariInquiliniQuota prop. %Segnalazioni
{{ $audit['unit_label'] }}{{ $audit['owners'] ?: '—' }}{{ $audit['tenants'] ?: '—' }} + @if($audit['owner_quota'] !== null) + {{ number_format((float) $audit['owner_quota'], 2, ',', '.') }} + @else + — + @endif + + @if(!empty($audit['flags'])) + {{ implode(' · ', $audit['flags']) }} + @else + Nessuna anomalia immediata + @endif +
Nessun dato persone/unità disponibile per lo stabile attivo.
+
+
+
+ @endif + @if($activeTab === 'incassi')
@@ -823,7 +1162,7 @@
€ {{ number_format((float)($acqua['totale'] ?? 0), 2, ',', '.') }}
-
Totale riparto (dett_tab.cons_euro)
+
Totale riparto (dett_tab.prev_euro)
€ {{ number_format((float)($acqua['riparto_totale'] ?? 0), 2, ',', '.') }}
@@ -844,7 +1183,7 @@ Scala Interno Piano - Consuntivo € + Preventivo € @@ -855,7 +1194,7 @@ {{ $row['scala'] ?? '—' }} {{ $row['interno'] ?? '—' }} {{ $row['piano'] ?? '—' }} - {{ number_format((float)($row['cons_euro'] ?? 0), 2, ',', '.') }} + {{ number_format((float)($row['prev_euro'] ?? 0), 2, ',', '.') }} @empty @@ -975,7 +1314,7 @@ Scala Interno Piano - Consuntivo € + Preventivo € @@ -986,7 +1325,7 @@ {{ $row['scala'] ?? '—' }} {{ $row['interno'] ?? '—' }} {{ $row['piano'] ?? '—' }} - {{ number_format((float)($row['cons_euro'] ?? 0), 2, ',', '.') }} + {{ number_format((float)($row['prev_euro'] ?? 0), 2, ',', '.') }} @empty diff --git a/resources/views/filament/pages/strumenti/post-it.blade.php b/resources/views/filament/pages/strumenti/post-it.blade.php index 772b049..2dac7db 100644 --- a/resources/views/filament/pages/strumenti/post-it.blade.php +++ b/resources/views/filament/pages/strumenti/post-it.blade.php @@ -120,25 +120,28 @@
- @forelse($this->recenti as $riga) -
@endforeach
@@ -312,7 +336,7 @@
- + Registra lettura light Salvataggio... diff --git a/resources/views/livewire/filament/topbar-live-call.blade.php b/resources/views/livewire/filament/topbar-live-call.blade.php index 0e38dc0..b0fa1aa 100644 --- a/resources/views/livewire/filament/topbar-live-call.blade.php +++ b/resources/views/livewire/filament/topbar-live-call.blade.php @@ -19,16 +19,8 @@
- - @if(!empty($liveIncomingCall['rubrica_url'])) - - Rubrica - - @endif
@else