From 6ac70ce38e1383d4148b03963bcd4175b6f92df6 Mon Sep 17 00:00:00 2001 From: michele Date: Tue, 21 Apr 2026 17:16:50 +0000 Subject: [PATCH] Improve bank movements and e-invoice import workflow --- CHANGELOG.md | 1 + VERSION | 2 +- .../Contabilita/CasseBancheMovimenti.php | 1820 +++++++++++++++-- .../Contabilita/MovimentiBancaImporter.php | 61 + .../CassettoFiscaleDownloadService.php | 4 +- .../FatturaElettronicaImporter.php | 238 ++- .../FatturaElettronicaProtocolloService.php | 53 +- app/Support/ArchivioPaths.php | 87 + config/contabilita.php | 17 + resources/css/filament/admin/theme.css | 61 + .../components/legacy-admin-assets.blade.php | 61 + .../casse-banche-movimenti.blade.php | 242 ++- 12 files changed, 2460 insertions(+), 187 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c49e67..0d2d7ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ # Changelog ## [Unreleased] +- Reworked the banking operations hub so the topbar stays full-width, the sidebar stays fixed below it, the movements tab remains focused on imported/manual bank movements, and archive or gestione structure moved into a dedicated tab. The same slice also added first-pass QIF cleanup metadata for CBILL, SIA, commissions and provider hints, plus stronger bank-to-supplier matching and ACQ assignment for water invoices extracted from FE/PDF data. - Fixed document archive visibility queries to work safely even when single-user ACL columns are not yet present in older databases, and added the missing `documenti.allowed_user_ids` schema field for per-document access control. - Restored a cleaner two-column layout in Post-it Gestione boards and hid visual noise from calls that terminate on internal response group `601` while keeping the underlying records in the system. - Added actionable banking reconciliation for tenant rents and supplier payments directly from the MPS movement queue, with automatic operational matching metadata and supplier RA register synchronization when a withholding-bearing supplier invoice is paid. diff --git a/VERSION b/VERSION index 6f4eebd..100435b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.8.1 +0.8.2 diff --git a/app/Filament/Pages/Contabilita/CasseBancheMovimenti.php b/app/Filament/Pages/Contabilita/CasseBancheMovimenti.php index b294e7c..fcaaed7 100644 --- a/app/Filament/Pages/Contabilita/CasseBancheMovimenti.php +++ b/app/Filament/Pages/Contabilita/CasseBancheMovimenti.php @@ -1,18 +1,22 @@ */ + public array $editingSaldoData = []; + + public ?string $pdfViewerUrl = null; + + public ?string $pdfViewerOpenUrl = null; + + public ?string $pdfViewerTitle = null; + + #[Url( as : 'tab')] public string $hubTab = 'conti'; public ?string $movimentiFocusFrom = null; @@ -86,7 +104,7 @@ class CasseBancheMovimenti extends Page implements HasTable protected static UnitEnum|string|null $navigationGroup = 'Contabilità'; protected static ?int $navigationSort = 11; - protected static ?string $slug = 'contabilita/casse-banche/movimenti'; + protected static ?string $slug = 'contabilita/casse-banche/movimenti'; protected string $view = 'filament.pages.contabilita.casse-banche-movimenti'; @@ -202,7 +220,7 @@ public function selectContoAndTab(int $contoId, string $tab = 'movimenti'): void public function clearMovimentiFocus(): void { $this->movimentiFocusFrom = null; - $this->movimentiFocusTo = null; + $this->movimentiFocusTo = null; $this->resetTable(); } @@ -226,11 +244,11 @@ public function openSaldoPeriodo(int $saldoId): void $this->syncSelectedContoFromId((int) $saldo->conto_id); } else { $this->contoId = null; - $this->iban = $this->normalizeIbanValue($saldo->iban); + $this->iban = $this->normalizeIbanValue($saldo->iban); } $from = $saldo->periodo_da?->toDateString(); - $to = $saldo->periodo_a?->toDateString(); + $to = $saldo->periodo_a?->toDateString(); if (! $from && $saldo->data_saldo) { $from = $saldo->data_saldo->copy()->startOfMonth()->toDateString(); @@ -240,12 +258,156 @@ public function openSaldoPeriodo(int $saldoId): void } $this->movimentiFocusFrom = $from; - $this->movimentiFocusTo = $to; + $this->movimentiFocusTo = $to; $this->applyPeriodoStateFromFocusRange(); $this->hubTab = 'movimenti'; $this->resetTable(); } + public function startEditingSaldo(int $saldoId): void + { + $saldo = $this->getSaldoForActiveContext($saldoId); + if (! $saldo) { + Notification::make()->title('Saldo non trovato')->danger()->send(); + return; + } + + $this->editingSaldoId = (int) $saldo->id; + $this->confirmingDeleteSaldoId = null; + $this->editingSaldoData = [ + 'data_saldo' => $saldo->data_saldo?->toDateString(), + 'periodo_da' => $saldo->periodo_da?->toDateString(), + 'periodo_a' => $saldo->periodo_a?->toDateString(), + 'saldo' => number_format((float) $saldo->saldo, 2, '.', ''), + 'is_partenza_contabile' => (bool) ($saldo->is_partenza_contabile ?? false), + 'tipo_estratto' => $saldo->tipo_estratto, + 'note' => $saldo->note, + ]; + } + + public function cancelEditingSaldo(): void + { + $this->editingSaldoId = null; + $this->editingSaldoData = []; + } + + public function saveEditingSaldo(): void + { + $saldo = $this->editingSaldoId ? $this->getSaldoForActiveContext($this->editingSaldoId) : null; + if (! $saldo) { + $this->cancelEditingSaldo(); + Notification::make()->title('Saldo non trovato')->danger()->send(); + return; + } + + $dataSaldo = $this->parseOptionalDate($this->editingSaldoData['data_saldo'] ?? null); + if (! $dataSaldo) { + Notification::make()->title('Data saldo non valida')->danger()->send(); + return; + } + + $periodoDa = $this->parseOptionalDate($this->editingSaldoData['periodo_da'] ?? null); + $periodoA = $this->parseOptionalDate($this->editingSaldoData['periodo_a'] ?? null); + if ($periodoDa && $periodoA && $periodoDa->gt($periodoA)) { + Notification::make()->title('Il periodo non e valido')->body('La data iniziale non puo essere successiva alla data finale.')->danger()->send(); + return; + } + + $saldoValue = $this->parseNumericAmount($this->editingSaldoData['saldo'] ?? null); + if ($saldoValue === null) { + Notification::make()->title('Saldo non valido')->danger()->send(); + return; + } + + $user = Auth::user(); + + $saldo->fill([ + 'data_saldo' => $dataSaldo->toDateString(), + 'periodo_da' => $periodoDa?->toDateString(), + 'periodo_a' => $periodoA?->toDateString(), + 'saldo' => $saldoValue, + 'is_partenza_contabile' => (bool) ($this->editingSaldoData['is_partenza_contabile'] ?? false), + 'tipo_estratto' => $this->normalizeTipoEstrattoValue($this->editingSaldoData['tipo_estratto'] ?? null), + 'note' => $this->normalizeOptionalText($this->editingSaldoData['note'] ?? null), + 'updated_by' => $user instanceof User ? ((int) $user->id ?: null): null, + ]); + $saldo->save(); + + $this->syncContabilitaAnchorForSaldo($saldo); + + $this->cancelEditingSaldo(); + + Notification::make() + ->title('Saldo aggiornato') + ->success() + ->send(); + } + + public function requestDeleteSaldo(int $saldoId): void + { + $saldo = $this->getSaldoForActiveContext($saldoId); + if (! $saldo) { + Notification::make()->title('Saldo non trovato')->danger()->send(); + return; + } + + $this->editingSaldoId = null; + $this->editingSaldoData = []; + $this->confirmingDeleteSaldoId = (int) $saldo->id; + } + + public function cancelDeleteSaldo(): void + { + $this->confirmingDeleteSaldoId = null; + } + + public function deleteSaldo(int $saldoId): void + { + $saldo = $this->getSaldoForActiveContext($saldoId); + if (! $saldo) { + $this->cancelDeleteSaldo(); + Notification::make()->title('Saldo non trovato')->danger()->send(); + return; + } + + $this->cleanupSaldoDocumentoIfUnused($saldo); + $saldo->delete(); + + if ($this->editingSaldoId === $saldoId) { + $this->cancelEditingSaldo(); + } + $this->confirmingDeleteSaldoId = null; + + Notification::make() + ->title('Saldo eliminato') + ->success() + ->send(); + } + + public function openSaldoDocumento(int $saldoId): void + { + $saldo = $this->getSaldoForActiveContext($saldoId); + if (! $saldo || ! ($saldo->documento instanceof DocumentoStabile) || ! $saldo->documento->fileEsiste()) { + Notification::make()->title('Documento non disponibile')->warning()->send(); + return; + } + + $openUrl = route('filament.documenti-stabili.download', $saldo->documento); + + $this->pdfViewerTitle = is_string($saldo->documento->nome_originale) && trim((string) $saldo->documento->nome_originale) !== '' + ? trim((string) $saldo->documento->nome_originale) + : 'Estratto conto'; + $this->pdfViewerOpenUrl = $openUrl; + $this->pdfViewerUrl = $openUrl . '?inline=1'; + } + + public function closePdfViewer(): void + { + $this->pdfViewerUrl = null; + $this->pdfViewerOpenUrl = null; + $this->pdfViewerTitle = null; + } + public function getPeriodoTotaliProperty(): array { $user = Auth::user(); @@ -489,6 +651,11 @@ protected function getHeaderActions(): array ->options($this->getTipoEstrattoOptions()) ->default('estratto_conto'), + Toggle::make('is_partenza_contabile') + ->label('Usa questo saldo come partenza contabile') + ->helperText('Da questa data in poi la ricostruzione del saldo parte da questo valore ufficiale, senza richiedere tutta la storia bancaria precedente.') + ->default(false), + FileUpload::make('documento') ->label('Allega estratto conto') ->directory('tmp/banca') @@ -542,23 +709,26 @@ protected function getHeaderActions(): array $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, + $saldo = 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, + 'is_partenza_contabile' => (bool) ($data['is_partenza_contabile'] ?? false), + '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->syncContabilitaAnchorForSaldo($saldo); + $this->contoId = (int) $conto->id; - $this->iban = $this->normalizeIbanValue($conto->iban); + $this->iban = $this->normalizeIbanValue($conto->iban); Notification::make() ->title('Saldo ufficiale registrato') @@ -715,6 +885,8 @@ protected function getHeaderActions(): array $replace = isset($data['replace']) ? (bool) $data['replace'] : false; try { + $this->archiveImportedBankSourceFile($conto, $path, $gestioneId, $format); + $importer = $this->makeMovimentiImporter(); $filename = basename($path); @@ -780,6 +952,189 @@ protected function getHeaderActions(): array } } }), + + Action::make('aggiungi_movimento_manuale') + ->label('Nuovo movimento manuale') + ->icon('heroicon-o-plus-circle') + ->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(), + + Select::make('gestione_id') + ->label('Gestione (opzionale)') + ->native(false) + ->searchable() + ->options(function (): array { + $user = Auth::user(); + if (! $user instanceof User) { + return []; + } + + $stabileId = StabileContext::resolveActiveStabileId($user); + if (! $stabileId || ! DB::getSchemaBuilder()->hasTable('gestioni_contabili')) { + return []; + } + + return GestioneContabile::query() + ->where('stabile_id', $stabileId) + ->orderByDesc('anno_gestione') + ->orderBy('tipo_gestione') + ->orderByDesc('id') + ->get(['id', 'denominazione', 'protocollo_prefix', 'stato']) + ->mapWithKeys(fn(GestioneContabile $g) => [ + (string) $g->id => trim(($g->protocollo_prefix ? $g->protocollo_prefix . ' · ' : '') . $g->denominazione) . ($g->stato !== 'aperta' ? (' (' . $g->stato . ')') : ''), + ]) + ->all(); + }) + ->default(function (): ?string { + $user = Auth::user(); + if (! $user instanceof User) { + return null; + } + + $stabileId = StabileContext::resolveActiveStabileId($user); + if (! $stabileId) { + return null; + } + + return $this->resolveDefaultGestioneId($stabileId); + }), + + DatePicker::make('data') + ->label('Data movimento') + ->required(), + + DatePicker::make('valuta') + ->label('Valuta'), + + TextInput::make('importo') + ->label('Importo') + ->numeric() + ->required() + ->helperText('Positivo per entrate, negativo per uscite.'), + + TextInput::make('descrizione') + ->label('Descrizione breve') + ->required() + ->maxLength(255), + + Textarea::make('descrizione_estesa') + ->label('Descrizione estesa') + ->rows(3), + + TextInput::make('causale') + ->label('Causale') + ->maxLength(20), + + Textarea::make('note') + ->label('Note interne') + ->rows(2), + ]) + ->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', 'iban']); + + if (! $conto) { + Notification::make()->title('Conto non valido per lo stabile attivo')->danger()->send(); + return; + } + + $importo = $this->parseNumericAmount($data['importo'] ?? null); + if ($importo === null || abs($importo) < 0.00001) { + Notification::make()->title('Importo non valido')->danger()->send(); + return; + } + + $dataMovimento = $this->parseOptionalDate($data['data'] ?? null); + if (! $dataMovimento) { + Notification::make()->title('Data movimento non valida')->danger()->send(); + return; + } + + $dataValuta = $this->parseOptionalDate($data['valuta'] ?? null) ?: $dataMovimento; + $descrizione = $this->normalizeOptionalText($data['descrizione'] ?? null); + if (! $descrizione) { + Notification::make()->title('Descrizione obbligatoria')->danger()->send(); + return; + } + + $payload = [ + 'stabile_id' => $stabileId, + 'conto_id' => (int) $conto->id, + 'gestione_id' => isset($data['gestione_id']) && is_numeric($data['gestione_id']) ? (int) $data['gestione_id'] : null, + 'iban' => $this->normalizeIbanValue($conto->iban), + 'data' => $dataMovimento->toDateString(), + 'valuta' => $dataValuta->toDateString(), + 'descrizione' => $descrizione, + 'importo' => $importo, + 'causale' => $this->normalizeOptionalText($data['causale'] ?? null), + 'source_file' => 'manuale:hub-banca', + 'raw_line' => $this->normalizeOptionalText($data['note'] ?? null) ?: 'Inserimento manuale da hub banca', + 'row_hash' => $this->generateManualMovementRowHash($stabileId, (int) $conto->id, $dataMovimento->toDateString(), $importo, $descrizione), + ]; + + if (Schema::hasColumn('contabilita_movimenti_banca', 'descrizione_estesa')) { + $payload['descrizione_estesa'] = $this->normalizeOptionalText($data['descrizione_estesa'] ?? null); + } + if (Schema::hasColumn('contabilita_movimenti_banca', 'da_confermare')) { + $payload['da_confermare'] = false; + } + if (Schema::hasColumn('contabilita_movimenti_banca', 'match_data')) { + $payload['match_data'] = array_filter([ + 'source' => 'manuale_hub_banca', + 'note_manuali' => $this->normalizeOptionalText($data['note'] ?? null), + ], fn($value): bool => $value !== null && $value !== ''); + } + + MovimentoBanca::query()->create($payload); + + $this->contoId = (int) $conto->id; + $this->iban = $this->normalizeIbanValue($conto->iban); + $this->hubTab = 'movimenti'; + $this->resetTable(); + + Notification::make() + ->title('Movimento manuale registrato') + ->success() + ->send(); + }), ]; } @@ -1176,6 +1531,148 @@ public function table(Table $table): Table $this->resetTable(); }), + Action::make('modifica_movimento_manuale') + ->label('Modifica') + ->icon('heroicon-o-pencil-square') + ->visible(fn(MovimentoBanca $record): bool => $this->isManualMovement($record) && empty($record->registrazione_id)) + ->form([ + Select::make('gestione_id') + ->label('Gestione (opzionale)') + ->native(false) + ->searchable() + ->options(function (): array { + $user = Auth::user(); + if (! $user instanceof User) { + return []; + } + + $stabileId = StabileContext::resolveActiveStabileId($user); + if (! $stabileId || ! DB::getSchemaBuilder()->hasTable('gestioni_contabili')) { + return []; + } + + return GestioneContabile::query() + ->where('stabile_id', $stabileId) + ->orderByDesc('anno_gestione') + ->orderBy('tipo_gestione') + ->orderByDesc('id') + ->get(['id', 'denominazione', 'protocollo_prefix', 'stato']) + ->mapWithKeys(fn(GestioneContabile $g) => [ + (string) $g->id => trim(($g->protocollo_prefix ? $g->protocollo_prefix . ' · ' : '') . $g->denominazione) . ($g->stato !== 'aperta' ? (' (' . $g->stato . ')') : ''), + ]) + ->all(); + }), + + DatePicker::make('data') + ->label('Data movimento') + ->required(), + + DatePicker::make('valuta') + ->label('Valuta'), + + TextInput::make('importo') + ->label('Importo') + ->numeric() + ->required(), + + TextInput::make('descrizione') + ->label('Descrizione breve') + ->required() + ->maxLength(255), + + Textarea::make('descrizione_estesa') + ->label('Descrizione estesa') + ->rows(3), + + TextInput::make('causale') + ->label('Causale') + ->maxLength(20), + + Textarea::make('note') + ->label('Note interne') + ->rows(2), + ]) + ->fillForm(function (MovimentoBanca $record): array { + return [ + 'gestione_id' => $record->gestione_id, + 'data' => $record->data, + 'valuta' => $record->valuta, + 'importo' => (float) $record->importo, + 'descrizione' => $record->descrizione, + 'descrizione_estesa' => $record->descrizione_estesa, + 'causale' => $record->causale, + 'note' => $this->extractManualMovementNote($record), + ]; + }) + ->action(function (MovimentoBanca $record, array $data): void { + if (! $this->isManualMovement($record) || ! empty($record->registrazione_id)) { + Notification::make()->title('Movimento non modificabile')->danger()->send(); + return; + } + + $dataMovimento = $this->parseOptionalDate($data['data'] ?? null); + if (! $dataMovimento) { + Notification::make()->title('Data movimento non valida')->danger()->send(); + return; + } + + $dataValuta = $this->parseOptionalDate($data['valuta'] ?? null) ?: $dataMovimento; + $importo = $this->parseNumericAmount($data['importo'] ?? null); + if ($importo === null || abs($importo) < 0.00001) { + Notification::make()->title('Importo non valido')->danger()->send(); + return; + } + + $descrizione = $this->normalizeOptionalText($data['descrizione'] ?? null); + if (! $descrizione) { + Notification::make()->title('Descrizione obbligatoria')->danger()->send(); + return; + } + + $payload = [ + 'gestione_id' => isset($data['gestione_id']) && is_numeric($data['gestione_id']) ? (int) $data['gestione_id'] : null, + 'data' => $dataMovimento->toDateString(), + 'valuta' => $dataValuta->toDateString(), + 'descrizione' => $descrizione, + 'importo' => $importo, + 'causale' => $this->normalizeOptionalText($data['causale'] ?? null), + 'raw_line' => $this->normalizeOptionalText($data['note'] ?? null) ?: 'Inserimento manuale da hub banca', + ]; + + if (Schema::hasColumn('contabilita_movimenti_banca', 'descrizione_estesa')) { + $payload['descrizione_estesa'] = $this->normalizeOptionalText($data['descrizione_estesa'] ?? null); + } + if (Schema::hasColumn('contabilita_movimenti_banca', 'match_data')) { + $matchData = is_array($record->match_data ?? null) ? $record->match_data : []; + $matchData['source'] = 'manuale_hub_banca'; + $matchData['note_manuali'] = $this->normalizeOptionalText($data['note'] ?? null); + $payload['match_data'] = array_filter($matchData, fn($value): bool => $value !== null && $value !== ''); + } + + $record->fill($payload); + $record->save(); + + Notification::make()->title('Movimento manuale aggiornato')->success()->send(); + $this->resetTable(); + }), + + Action::make('elimina_movimento_manuale') + ->label('Elimina') + ->icon('heroicon-o-trash') + ->color('danger') + ->visible(fn(MovimentoBanca $record): bool => $this->isManualMovement($record) && empty($record->registrazione_id)) + ->requiresConfirmation() + ->action(function (MovimentoBanca $record): void { + if (! $this->isManualMovement($record) || ! empty($record->registrazione_id)) { + Notification::make()->title('Movimento non eliminabile')->danger()->send(); + return; + } + + $record->delete(); + Notification::make()->title('Movimento manuale eliminato')->success()->send(); + $this->resetTable(); + }), + Action::make('genera_prima_nota') ->label('Genera prima nota') ->icon('heroicon-o-book-open') @@ -1482,27 +1979,57 @@ protected function resolveSaldoProgressivoConSaldoIniziale(MovimentoBanca $recor protected function resolveSaldoConSaldi(MovimentoBanca $record): ?float { - $baseProgressivo = $this->resolveSaldoProgressivo($record); + $stabileId = (int) $record->stabile_id; + $contoId = isset($record->conto_id) && is_numeric($record->conto_id) ? (int) $record->conto_id : null; + $iban = $this->normalizeIbanValue($record->iban); + $base = $this->resolveContabilitaBaseSaldo($stabileId, $contoId, $iban, $record->data); + + $baseProgressivo = $base['anchor_date'] + ? $this->resolveSaldoProgressivoFromAnchor($record, $base['anchor_date']) + : $this->resolveSaldoProgressivo($record); + if ($baseProgressivo === null) { return null; } - $stabileId = (int) $record->stabile_id; - $contoId = isset($record->conto_id) && is_numeric($record->conto_id) ? (int) $record->conto_id : null; - $iban = $this->normalizeIbanValue($record->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, $contoId, $iban, $record->data); + $offset = $this->resolveSaldoOffsetDaSaldi( + $stabileId, + $contoId, + $iban, + $record->data, + $base['anchor_date'], + $base['anchor_id'], + $base['saldo'], + ); - return $saldoIniziale + (float) $baseProgressivo + $offset; + return (float) $base['saldo'] + (float) $baseProgressivo + $offset; + } + + /** @return array{saldo:float,anchor_date:?string,anchor_id:?int} */ + protected function resolveContabilitaBaseSaldo(int $stabileId, ?int $contoId, ?string $iban, Carbon $recordDate): array + { + $anchor = $this->getContabilitaAnchorSaldo($stabileId, $contoId, $iban, $recordDate); + + if ($anchor) { + return [ + 'saldo' => (float) $anchor->saldo, + 'anchor_date' => $anchor->data_saldo?->toDateString(), + 'anchor_id' => (int) $anchor->id, + ]; + } + + return [ + 'saldo' => $this->resolveSaldoInizialeDaDatiBancari($stabileId, $contoId, $iban), + 'anchor_date' => null, + 'anchor_id' => null, + ]; } 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]; } @@ -1522,8 +2049,15 @@ 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, + ?string $anchorDate = null, + ?int $anchorId = null, + ?float $anchorSaldo = null, + ): float { if (! DB::getSchemaBuilder()->hasTable('contabilita_saldi_conti')) { return 0.0; } @@ -1533,7 +2067,7 @@ protected function resolveSaldoOffsetDaSaldi(int $stabileId, ?int $contoId, ?str } 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) { @@ -1558,6 +2092,15 @@ protected function resolveSaldoOffsetDaSaldi(int $stabileId, ?int $contoId, ?str if (! $s || ! isset($s->data_saldo)) { continue; } + if ($anchorDate !== null) { + $saldoDate = $s->data_saldo->toDateString(); + if ($saldoDate < $anchorDate) { + continue; + } + if ($saldoDate === $anchorDate && $anchorId !== null && (int) $s->id <= $anchorId) { + continue; + } + } if ($s->data_saldo->toDateString() <= $recordDate->toDateString()) { $match = $s; } else { @@ -1577,16 +2120,43 @@ protected function resolveSaldoOffsetDaSaldi(int $stabileId, ?int $contoId, ?str } elseif ($iban) { $q->where('iban', $iban); } + if ($anchorDate !== null) { + $q->whereDate('data', '>', $anchorDate); + } $q->whereDate('data', '<=', $match->data_saldo->toDateString()); $sumToDateCache[$sumKey] = (float) ($q->sum('importo') ?? 0.0); } - $saldoIniziale = $this->resolveSaldoInizialeDaDatiBancari($stabileId, $contoId, $iban); + $saldoIniziale = $anchorDate !== null && $anchorSaldo !== null + ? (float) $anchorSaldo + : $this->resolveSaldoInizialeDaDatiBancari($stabileId, $contoId, $iban); $saldoAttesoAllaDataSaldo = (float) $match->saldo; $saldoCalcolatoAllaDataSaldo = $saldoIniziale + (float) $sumToDateCache[$sumKey]; return $saldoAttesoAllaDataSaldo - $saldoCalcolatoAllaDataSaldo; } + protected function resolveSaldoProgressivoFromAnchor(MovimentoBanca $record, string $anchorDate): float + { + $query = MovimentoBanca::query() + ->where('stabile_id', (int) $record->stabile_id) + ->where(function (Builder $q) use ($record): void { + $q->whereDate('data', '<', $record->data->toDateString()) + ->orWhere(function (Builder $sameDay) use ($record): void { + $sameDay->whereDate('data', $record->data->toDateString()) + ->where('id', '<=', (int) $record->id); + }); + }) + ->whereDate('data', '>', $anchorDate); + + if (is_numeric($record->conto_id)) { + $query->where('conto_id', (int) $record->conto_id); + } elseif ($this->normalizeIbanValue($record->iban)) { + $query->where('iban', $this->normalizeIbanValue($record->iban)); + } + + return (float) ($query->sum('importo') ?? 0.0); + } + protected function resolveSaldoIniziale(int $stabileId, string $iban, string $fromDate): ?float { if (! DB::getSchemaBuilder()->hasTable('contabilita_saldi_conti')) { @@ -1613,6 +2183,10 @@ protected function resolveSaldoIniziale(int $stabileId, string $iban, string $fr protected function resolveStatoQuadratura(MovimentoBanca $record): string { + if ($this->hasOperationalRiconciliazione($record)) { + return 'collegato_operativo'; + } + if ($record->registrazione_id === null) { return 'non_collegato'; } @@ -1667,6 +2241,60 @@ protected function resolveDefaultGestioneId(int $stabileId): ?string return $match ? (string) $match->id : null; } + protected function resolveGestioneForYear(int $stabileId, int $year, ?string $preferredType = null): ?GestioneContabile + { + if (! DB::getSchemaBuilder()->hasTable('gestioni_contabili')) { + return null; + } + + $query = GestioneContabile::query() + ->where('stabile_id', $stabileId) + ->where('anno_gestione', $year); + + if (is_string($preferredType) && trim($preferredType) !== '') { + $preferred = trim((string) $preferredType); + $match = (clone $query) + ->where('tipo_gestione', $preferred) + ->orderByDesc('gestione_attiva') + ->orderByRaw("CASE WHEN stato = 'aperta' THEN 0 ELSE 1 END") + ->orderByDesc('id') + ->first(); + + if ($match) { + return $match; + } + } + + return $query + ->orderByRaw("CASE WHEN tipo_gestione = 'ordinaria' THEN 0 WHEN tipo_gestione = 'straordinaria' THEN 1 ELSE 2 END") + ->orderByDesc('gestione_attiva') + ->orderByRaw("CASE WHEN stato = 'aperta' THEN 0 ELSE 1 END") + ->orderByDesc('id') + ->first(); + } + + protected function resolveArchivioReferenceYear(): int + { + $stabileId = $this->getActiveStabileId(); + if (! $stabileId) { + return (int) now()->format('Y'); + } + + $latestFeDate = FatturaElettronica::query() + ->where('stabile_id', $stabileId) + ->max('data_fattura'); + + if (is_string($latestFeDate) && trim($latestFeDate) !== '') { + try { + return (int) Carbon::parse($latestFeDate)->format('Y'); + } catch (\Throwable) { + // ignore + } + } + + return (int) now()->format('Y'); + } + protected function resolveSaldoProgressivo(MovimentoBanca $record): ?float { if (isset($record->saldo_progressivo)) { @@ -1761,12 +2389,12 @@ protected function getActiveStabileId(): ?int protected function normalizeHubTabValue(?string $value): string { $value = is_string($value) ? trim(strtolower($value)) : ''; - return in_array($value, ['conti', 'saldi', 'movimenti', 'riconciliazione'], true) ? $value : 'conti'; + return in_array($value, ['conti', 'saldi', 'movimenti', 'struttura', 'riconciliazione'], true) ? $value : 'conti'; } protected function syncSelectedContoFromId(?int $contoId): void { - $contoId = $contoId && $contoId > 0 ? $contoId : null; + $contoId = $contoId && $contoId > 0 ? $contoId : null; $this->contoId = $contoId; if (! $contoId) { @@ -1777,7 +2405,7 @@ protected function syncSelectedContoFromId(?int $contoId): void $stabileId = $this->getActiveStabileId(); if (! $stabileId) { $this->contoId = null; - $this->iban = null; + $this->iban = null; return; } @@ -1788,7 +2416,7 @@ protected function syncSelectedContoFromId(?int $contoId): void if (! $conto) { $this->contoId = null; - $this->iban = null; + $this->iban = null; return; } @@ -1797,8 +2425,8 @@ protected function syncSelectedContoFromId(?int $contoId): void protected function resetContoDependentState(): void { - $this->periodoRateMap = []; - $this->periodoRate = null; + $this->periodoRateMap = []; + $this->periodoRate = null; $this->riconciliazioneOffset = 0; $this->clearMovimentiFocus(); } @@ -1811,7 +2439,7 @@ protected function applyPeriodoStateFromFocusRange(): void try { $from = Carbon::parse($this->movimentiFocusFrom); - $to = Carbon::parse($this->movimentiFocusTo); + $to = Carbon::parse($this->movimentiFocusTo); } catch (\Throwable) { return; } @@ -1819,14 +2447,14 @@ protected function applyPeriodoStateFromFocusRange(): void $this->periodoAnno = (int) $from->year; $quarterStart = $from->copy()->startOfQuarter(); - $quarterEnd = $from->copy()->endOfQuarter(); + $quarterEnd = $from->copy()->endOfQuarter(); if ($from->isSameDay($quarterStart) && $to->isSameDay($quarterEnd)) { - $this->periodoTipo = 'trimestre'; + $this->periodoTipo = 'trimestre'; $this->periodoValore = (int) $from->quarter; return; } - $this->periodoTipo = 'mese'; + $this->periodoTipo = 'mese'; $this->periodoValore = (int) $from->month; } @@ -1845,8 +2473,8 @@ public function getHubContiRows(): array continue; } - $iban = $this->normalizeIbanValue($conto['iban'] ?? null); - $latestSaldo = $this->getLatestSaldoSnapshotForConto($stabileId, $contoId, $iban); + $iban = $this->normalizeIbanValue($conto['iban'] ?? null); + $latestSaldo = $this->getLatestSaldoSnapshotForConto($stabileId, $contoId, $iban); $lastMovement = MovimentoBanca::query() ->where('stabile_id', $stabileId) ->where('conto_id', $contoId) @@ -1855,16 +2483,16 @@ public function getHubContiRows(): array ->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(), + '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, + 'selected' => $this->contoId === $contoId, ]; } @@ -1895,22 +2523,31 @@ public function getSaldoArchivioByYear(): array $groups = []; foreach ($query->orderByDesc('data_saldo')->orderByDesc('id')->get() as $saldo) { - $year = $saldo->data_saldo?->format('Y') ?? 'Senza data'; + $quadratura = $this->resolveSaldoArchivioQuadratura($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, + 'id' => (int) $saldo->id, + 'data' => $saldo->data_saldo?->format('d/m/Y') ?? '—', + 'saldo' => (float) $saldo->saldo, + 'is_partenza_contabile' => (bool) ($saldo->is_partenza_contabile ?? false), + '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_id' => $saldo->documento?->id, + 'documento_label' => $saldo->documento?->nome_originale, + 'documento_url' => $saldo->documento?->url_view, + 'quadratura_status' => $quadratura['status'], + 'quadratura_icon' => $quadratura['icon'], + 'quadratura_label' => $quadratura['label'], + 'quadratura_expected' => $quadratura['expected_saldo'], + 'quadratura_delta' => $quadratura['delta'], + 'quadratura_movimenti_count' => $quadratura['movimenti_count'], ]; } @@ -1934,22 +2571,20 @@ public function getRiconciliazioneStats(): array $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; + $records = (clone $query)->get(['id', 'registrazione_id', 'match_data']); + + $totale = $records->count(); + $senzaPrimaNota = $records->filter(fn(MovimentoBanca $record): bool => empty($record->registrazione_id) && ! $this->hasOperationalRiconciliazione($record))->count(); + $collegati = $records->filter(fn(MovimentoBanca $record): bool => ! empty($record->registrazione_id) || $this->hasOperationalRiconciliazione($record))->count(); $daConfermare = Schema::hasColumn('contabilita_movimenti_banca', 'da_confermare') ? (clone $query)->where('da_confermare', true)->count() : 0; return [ - 'totale' => $totale, + 'totale' => $totale, 'senza_prima_nota' => $senzaPrimaNota, - 'collegati' => $collegati, - 'da_confermare' => $daConfermare, + 'collegati' => $collegati, + 'da_confermare' => $daConfermare, ]; } @@ -1980,19 +2615,22 @@ public function getRiconciliazioneQueue(): array } return $query - ->limit(200) + ->limit(500) ->get(['id', 'data', 'importo', 'descrizione', 'descrizione_estesa', 'match_data', 'registrazione_id']) + ->filter(fn(MovimentoBanca $movimento): bool => ! $this->hasOperationalRiconciliazione($movimento)) + ->take(200) ->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, + '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, + 'match_label' => $this->getOperationalRiconciliazioneLabel($movimento), + 'record' => $movimento, ]; }) ->all(); @@ -2007,7 +2645,7 @@ public function getRiconciliazioneCurrent(): ?array return null; } - $max = max(0, count($queue) - 1); + $max = max(0, count($queue) - 1); $this->riconciliazioneOffset = max(0, min($this->riconciliazioneOffset, $max)); return $queue[$this->riconciliazioneOffset] ?? null; @@ -2037,19 +2675,120 @@ public function getRiconciliazioneCandidates(): array return []; } - $record = $current['record']; + $record = $current['record']; $stabileId = $this->getActiveStabileId(); if (! $stabileId) { return []; } if ((float) $record->importo >= 0) { - return $this->buildIncassoCandidates($record, $stabileId); + return collect(array_merge( + $this->buildAffittoCandidates($record, $stabileId), + $this->buildIncassoCandidates($record, $stabileId) + )) + ->sortByDesc('score') + ->take(6) + ->values() + ->all(); } return $this->buildFatturaCandidates($record, $stabileId); } + public function riconciliaCanoneAffitto(int $canoneDovutoId): void + { + $user = Auth::user(); + $current = $this->getRiconciliazioneCurrent(); + $stabileId = $this->getActiveStabileId(); + + if (! $user instanceof User || ! $current || ! ($current['record'] instanceof MovimentoBanca) || ! $stabileId) { + Notification::make()->title('Riconciliazione non disponibile.')->danger()->send(); + return; + } + + $canone = AffittoCanoneDovuto::query() + ->with(['affitto.rubricaInquilino']) + ->whereHas('affitto', fn(Builder $query): Builder => $query->where('stabile_id', $stabileId)) + ->find($canoneDovutoId); + + if (! $canone) { + Notification::make()->title('Canone affitto non trovato.')->danger()->send(); + return; + } + + try { + $pagato = app(MovimentoBancaRiconciliazioneService::class) + ->registraCanoneAffittoDaMovimento($user, $current['record'], $canone); + + Notification::make() + ->title('Canone affitto riconciliato.') + ->body('Registrato il pagamento del canone ' . str_pad((string) $pagato->mese, 2, '0', STR_PAD_LEFT) . '/' . (string) $pagato->anno . '.') + ->success() + ->send(); + } catch (\Throwable $e) { + Notification::make()->title('Errore di riconciliazione affitto.')->body($e->getMessage())->danger()->send(); + } + } + + public function riconciliaPagamentoFornitore(int $fatturaFornitoreId): void + { + $user = Auth::user(); + $current = $this->getRiconciliazioneCurrent(); + $stabileId = $this->getActiveStabileId(); + + if (! $user instanceof User || ! $current || ! ($current['record'] instanceof MovimentoBanca) || ! $stabileId) { + Notification::make()->title('Riconciliazione non disponibile.')->danger()->send(); + return; + } + + $fattura = FatturaFornitore::query() + ->with('fornitore') + ->where('stabile_id', $stabileId) + ->find($fatturaFornitoreId); + + if (! $fattura || (int) ($fattura->fornitore_id ?? 0) <= 0) { + Notification::make()->title('Fattura fornitore non valida.')->danger()->send(); + return; + } + + try { + app(PagamentoFornitorePrimaNotaService::class)->generaPagamentoDaMovimento( + $user, + $current['record'], + (int) $fattura->fornitore_id, + [ + 'gestione_id' => (int) (($fattura->gestione_id ?? 0) ?: ($current['record']->gestione_id ?? 0)), + 'fattura_fornitore_id' => (int) $fattura->id, + 'descrizione' => 'Pagamento fattura fornitore ' . trim((string) ($fattura->numero_documento ?: $fattura->id)), + ] + ); + + $matchData = is_array($current['record']->match_data ?? null) ? $current['record']->match_data : []; + $matchData['riconciliato_operativo'] = true; + $matchData['riconciliazione_tipo'] = 'pagamento_fattura_fornitore'; + $matchData['riconciliazione_label'] = 'Pagamento fattura fornitore'; + $matchData['riconciliazione_at'] = now()->toDateTimeString(); + $matchData['fattura_fornitore_id'] = (int) $fattura->id; + $matchData['fornitore_id'] = (int) $fattura->fornitore_id; + + if (Schema::hasColumn('contabilita_movimenti_banca', 'match_data')) { + $current['record']->match_data = $matchData; + } + if (Schema::hasColumn('contabilita_movimenti_banca', 'da_confermare')) { + $current['record']->da_confermare = false; + } + $current['record']->save(); + + Notification::make() + ->title('Pagamento fornitore registrato.') + ->body('Prima nota generata e registro ritenute aggiornato se la fattura prevede RA.') + ->success() + ->send(); + } catch (\Throwable $e) { + Notification::make()->title('Errore nel pagamento fornitore.')->body($e->getMessage())->danger()->send(); + } + } + protected function getLatestSaldoSnapshotForConto(int $stabileId, int $contoId, ?string $iban): ?SaldoConto { if (! DB::getSchemaBuilder()->hasTable('contabilita_saldi_conti')) { @@ -2068,11 +2807,36 @@ protected function getLatestSaldoSnapshotForConto(int $stabileId, int $contoId, return $query->orderByDesc('data_saldo')->orderByDesc('id')->first(); } + protected function getContabilitaAnchorSaldo(int $stabileId, ?int $contoId, ?string $iban, ?Carbon $referenceDate = null): ?SaldoConto + { + if (! DB::getSchemaBuilder()->hasTable('contabilita_saldi_conti') || ! Schema::hasColumn('contabilita_saldi_conti', 'is_partenza_contabile')) { + return null; + } + + $query = SaldoConto::query() + ->where('stabile_id', $stabileId) + ->where('is_partenza_contabile', true); + + if ($contoId) { + $query->where('conto_id', $contoId); + } elseif ($iban) { + $query->where('iban', $iban); + } else { + return null; + } + + if ($referenceDate) { + $query->whereDate('data_saldo', '<=', $referenceDate->toDateString()); + } + + 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(); + $saldoBase = $latestSaldo ? (float) $latestSaldo->saldo : $this->resolveSaldoInizialeDaDatiBancari($stabileId, $contoId, $iban); + $dataBase = $latestSaldo?->data_saldo?->toDateString(); $query = MovimentoBanca::query() ->where('stabile_id', $stabileId) @@ -2088,11 +2852,11 @@ protected function resolveSaldoAttualeConto(int $stabileId, int $contoId, ?strin 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); + $toDate = $to instanceof Carbon ? $to : ($to ? Carbon::parse((string) $to) : null); if ($fromDate && $toDate) { $quarterStart = $fromDate->copy()->startOfQuarter(); - $quarterEnd = $fromDate->copy()->endOfQuarter(); + $quarterEnd = $fromDate->copy()->endOfQuarter(); if ($fromDate->isSameDay($quarterStart) && $toDate->isSameDay($quarterEnd)) { return 'T' . $fromDate->quarter . ' / ' . $fromDate->format('Y'); } @@ -2116,39 +2880,66 @@ protected function buildIncassoCandidates(MovimentoBanca $movimento, int $stabil return []; } - $importo = abs((float) $movimento->importo); + $dateColumn = Schema::hasColumn('incassi_pagamenti', 'data_incasso') + ? 'data_incasso' + : (Schema::hasColumn('incassi_pagamenti', 'data_pagamento') ? 'data_pagamento' : null); + $importoColumn = Schema::hasColumn('incassi_pagamenti', 'importo') + ? 'importo' + : (Schema::hasColumn('incassi_pagamenti', 'importo_pagato_euro') + ? 'importo_pagato_euro' + : (Schema::hasColumn('incassi_pagamenti', 'importo_pagato') ? 'importo_pagato' : null)); + + if (! $dateColumn || ! $importoColumn) { + 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', [ + ->whereBetween($dateColumn, [ $movimento->data->copy()->subDays(30)->toDateString(), $movimento->data->copy()->addDays(30)->toDateString(), ]) - ->whereBetween('importo', [$importo - 10, $importo + 10]) - ->orderBy('data_incasso') + ->whereBetween($importoColumn, [$importo - 10, $importo + 10]) + ->orderBy($dateColumn) ->limit(20) ->get() - ->map(function (IncassoPagamento $incasso) use ($movimento, $descrizione, $importo): array { + ->map(function (IncassoPagamento $incasso) use ($movimento, $descrizione, $importo, $dateColumn, $importoColumn): array { + $candidateDateRaw = data_get($incasso, $dateColumn); + $candidateDate = $candidateDateRaw instanceof Carbon + ? $candidateDateRaw + : ($candidateDateRaw ? Carbon::parse((string) $candidateDateRaw) : null); + $candidateImporto = (float) data_get($incasso, $importoColumn, 0); + $candidateLabel = trim( + (string) (data_get($incasso, 'causale') + ?: data_get($incasso, 'descrizione') + ?: data_get($incasso, 'modalita_pagamento_label') + ?: 'Incasso') + ); + $candidateNote = trim((string) (data_get($incasso, 'note_bancarie') ?: data_get($incasso, 'descrizione') ?: '')); + $score = $this->scoreCandidate( $importo, $movimento->data, $descrizione, - (float) $incasso->importo, - $incasso->data_incasso, - trim((string) ($incasso->causale ?? '')) . ' ' . trim((string) ($incasso->note_bancarie ?? '')) + $candidateImporto, + $candidateDate, + trim($candidateLabel . ' ' . $candidateNote) ); 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'], + 'tipo' => 'Incasso registrato', + 'titolo' => 'Incasso #' . (int) $incasso->id . ($incasso->rata ? (' · rata ' . (string) $incasso->rata->numero_rata) : ''), + 'score' => $score['totale'], + 'importo' => $candidateImporto, + 'data' => $candidateDate?->format('d/m/Y') ?? '—', + 'dettaglio' => $candidateLabel, + 'motivo' => $score['motivo'], + 'azione' => null, ]; }) ->sortByDesc('score') @@ -2158,15 +2949,226 @@ protected function buildIncassoCandidates(MovimentoBanca $movimento, int $stabil } /** @return array> */ - protected function buildFatturaCandidates(MovimentoBanca $movimento, int $stabileId): array + protected function buildAffittoCandidates(MovimentoBanca $movimento, int $stabileId): array { - if (! Schema::hasTable('fatture_elettroniche')) { + if (! Schema::hasTable('affitti_canoni_dovuti') || ! Schema::hasTable('affitti_canoni_pagati') || ! Schema::hasTable('affitti_immobili')) { return []; } $importo = abs((float) $movimento->importo); $descrizione = (string) ($movimento->descrizione_estesa_pulita ?? $movimento->descrizione_estesa ?? $movimento->descrizione ?? ''); + return AffittoCanoneDovuto::query() + ->with(['affitto.rubricaInquilino']) + ->whereHas('affitto', function (Builder $query) use ($stabileId, $movimento): Builder { + return $query + ->where('stabile_id', $stabileId) + ->when((int) ($movimento->conto_id ?? 0) > 0, function (Builder $sub) use ($movimento): Builder { + return $sub->where(function (Builder $inner) use ($movimento): Builder { + return $inner + ->where('conto_bancario_id', (int) $movimento->conto_id) + ->orWhereNull('conto_bancario_id'); + }); + }); + }) + ->whereNotExists(function ($query) { + $query->selectRaw('1') + ->from('affitti_canoni_pagati as acp') + ->whereColumn('acp.affitto_id', 'affitti_canoni_dovuti.affitto_id') + ->whereColumn('acp.anno', 'affitti_canoni_dovuti.anno') + ->whereColumn('acp.mese', 'affitti_canoni_dovuti.mese'); + }) + ->whereBetween('totale', [$importo - 15, $importo + 15]) + ->where(function (Builder $query) use ($movimento): Builder { + return $query + ->whereBetween('data_scadenza', [ + $movimento->data->copy()->subDays(90)->toDateString(), + $movimento->data->copy()->addDays(45)->toDateString(), + ]) + ->orWhereBetween('data_emissione', [ + $movimento->data->copy()->subDays(90)->toDateString(), + $movimento->data->copy()->addDays(45)->toDateString(), + ]); + }) + ->orderByDesc('anno') + ->orderByDesc('mese') + ->limit(20) + ->get() + ->filter(function (AffittoCanoneDovuto $canone): bool { + $affitto = $canone->affitto; + if (! $affitto) { + return false; + } + + $managementStart = null; + if (! empty($affitto->inizio_contratto)) { + $managementStart = Carbon::parse($affitto->inizio_contratto)->startOfMonth(); + } + if (! empty($affitto->presa_in_carico_operativa_dal)) { + $takeoverDate = Carbon::parse($affitto->presa_in_carico_operativa_dal)->startOfMonth(); + $managementStart = $managementStart ? $managementStart->max($takeoverDate) : $takeoverDate; + } + + if (! $managementStart) { + return true; + } + + $candidateMonth = Carbon::create((int) ($canone->anno ?? 0), max(1, (int) ($canone->mese ?? 1)), 1)->startOfMonth(); + + return $candidateMonth->gte($managementStart); + }) + ->map(function (AffittoCanoneDovuto $canone) use ($movimento, $descrizione, $importo): array { + $affitto = $canone->affitto; + $tenantLabel = $this->resolveRubricaLabel($affitto?->rubricaInquilino) ?: trim((string) ($affitto?->nome_inquilino ?? 'Inquilino')); + $immobileLabel = trim((string) ($affitto?->descrizione_immobile ?? $affitto?->indirizzo_immobile ?? 'immobile')); + + $score = $this->scoreCandidate( + $importo, + $movimento->data, + $descrizione, + (float) $canone->totale, + $canone->data_scadenza ?? $canone->data_emissione, + $tenantLabel . ' ' . $immobileLabel . ' ' . trim((string) ($canone->n_ricevuta ?? '')) + ); + + if ((int) ($affitto?->conto_bancario_id ?? 0) > 0 && (int) ($movimento->conto_id ?? 0) === (int) $affitto->conto_bancario_id) { + $score['totale'] = min(100, $score['totale'] + 10); + $score['motivo'] .= ' · stesso conto affitto'; + } + + return [ + 'tipo' => 'Canone affitto aperto', + 'titolo' => trim($tenantLabel . ' · ' . $immobileLabel), + 'score' => $score['totale'], + 'importo' => (float) $canone->totale, + 'data' => ($canone->data_scadenza ?? $canone->data_emissione)?->format('d/m/Y') ?? '—', + 'dettaglio' => 'Canone ' . str_pad((string) $canone->mese, 2, '0', STR_PAD_LEFT) . '/' . (string) $canone->anno . ($canone->n_ricevuta ? (' · ricevuta ' . $canone->n_ricevuta) : ''), + 'motivo' => $score['motivo'], + 'azione' => 'riconcilia_affitto', + 'canone_dovuto_id' => (int) $canone->id, + ]; + }) + ->sortByDesc('score') + ->take(4) + ->values() + ->all(); + } + + /** @return array> */ + protected function buildFatturaCandidates(MovimentoBanca $movimento, int $stabileId): array + { + if (Schema::hasTable('contabilita_fatture_fornitori')) { + $importo = abs((float) $movimento->importo); + $descrizione = (string) ($movimento->descrizione_estesa_pulita ?? $movimento->descrizione_estesa ?? $movimento->descrizione ?? ''); + $movementHints = is_array($movimento->match_data ?? null) ? $movimento->match_data : []; + + return FatturaFornitore::query() + ->with(['fornitore', 'fatturaElettronica']) + ->where('stabile_id', $stabileId) + ->where(function (Builder $query): Builder { + return $query + ->whereNull('data_pagamento') + ->orWhereNull('movimento_pagamento_id'); + }) + ->where(function (Builder $query) use ($movimento): Builder { + return $query + ->whereBetween('data_documento', [ + $movimento->data->copy()->subDays(180)->toDateString(), + $movimento->data->copy()->addDays(45)->toDateString(), + ]) + ->orWhereNull('data_documento'); + }) + ->orderByDesc('data_documento') + ->limit(25) + ->get(['id', 'fornitore_id', 'fattura_elettronica_id', 'numero_documento', 'data_documento', 'totale', 'netto_da_pagare', 'ritenuta_importo', 'stato', 'dati_fornitura']) + ->map(function (FatturaFornitore $fattura) use ($movimento, $descrizione, $importo, $movementHints): ?array { + $targetImporto = round((float) (($fattura->netto_da_pagare ?? 0) > 0 ? $fattura->netto_da_pagare : $fattura->totale), 2); + if (abs($targetImporto - $importo) > 15) { + return null; + } + + $fornitoreLabel = is_string($fattura->fornitore?->ragione_sociale ?? null) + ? trim((string) $fattura->fornitore->ragione_sociale) + : 'Fattura fornitore'; + + $score = $this->scoreCandidate( + $importo, + $movimento->data, + $descrizione, + $targetImporto, + $fattura->data_documento, + $fornitoreLabel . ' ' . trim((string) ($fattura->numero_documento ?? '')) + ); + + $fatturaHints = $this->extractFatturaPaymentHints($fattura); + $movementProvider = strtoupper(trim((string) ($movementHints['payment_provider'] ?? $movementHints['beneficiario'] ?? ''))); + $fatturaProvider = strtoupper(trim((string) ($fatturaHints['provider'] ?? $fornitoreLabel))); + + if ($movementProvider !== '' && $fatturaProvider !== '' && str_contains($fatturaProvider, $movementProvider)) { + $score['totale'] = min(100, $score['totale'] + 18); + $score['motivo'] .= ' · fornitore coerente con il movimento'; + } + + if (($movementHints['cbill'] ?? null) && ($fatturaHints['cbill'] ?? null) && (string) $movementHints['cbill'] === (string) $fatturaHints['cbill']) { + $score['totale'] = min(100, $score['totale'] + 35); + $score['motivo'] .= ' · CBILL coincidente'; + } + + if (($movementHints['sia'] ?? null) && ($fatturaHints['sia'] ?? null) && strtoupper((string) $movementHints['sia']) === strtoupper((string) $fatturaHints['sia'])) { + $score['totale'] = min(100, $score['totale'] + 15); + $score['motivo'] .= ' · SIA coerente'; + } + + if (($movementHints['suggested_voce_spesa_code'] ?? null) === 'ACQ' && ($fatturaHints['is_water'] ?? false)) { + $score['totale'] = min(100, $score['totale'] + 20); + $score['motivo'] .= ' · spesa acqua/ACQ'; + } + + if ((float) ($fattura->ritenuta_importo ?? 0) > 0) { + $score['totale'] = min(100, $score['totale'] + 5); + $score['motivo'] .= ' · netto coerente con RA'; + } + + $detailBits = ['Stato contabile: ' . trim((string) ($fattura->stato ?: '—'))]; + if (($fatturaHints['voce_spesa_code'] ?? null) === 'ACQ') { + $detailBits[] = 'Voce ACQ'; + } + if (! empty($fatturaHints['cbill'])) { + $detailBits[] = 'CBILL ' . $fatturaHints['cbill']; + } + if (! empty($fatturaHints['sia'])) { + $detailBits[] = 'SIA ' . strtoupper((string) $fatturaHints['sia']); + } + if ((float) ($fattura->ritenuta_importo ?? 0) > 0) { + $detailBits[] = 'RA € ' . number_format((float) $fattura->ritenuta_importo, 2, ',', '.'); + } + + return [ + 'tipo' => 'Fattura fornitore', + 'titolo' => $fornitoreLabel . ' · n. ' . trim((string) ($fattura->numero_documento ?: $fattura->id)), + 'score' => $score['totale'], + 'importo' => $targetImporto, + 'data' => $fattura->data_documento?->format('d/m/Y') ?? '—', + 'dettaglio' => implode(' · ', $detailBits), + 'motivo' => $score['motivo'], + 'azione' => 'riconcilia_pagamento_fornitore', + 'fattura_fornitore_id'=> (int) $fattura->id, + ]; + }) + ->filter() + ->sortByDesc('score') + ->take(5) + ->values() + ->all(); + } + + 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', [ @@ -2188,13 +3190,14 @@ protected function buildFatturaCandidates(MovimentoBanca $movimento, int $stabil ); 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') ?? '—', + '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'], + 'motivo' => $score['motivo'], + 'azione' => null, ]; }) ->sortByDesc('score') @@ -2203,35 +3206,118 @@ protected function buildFatturaCandidates(MovimentoBanca $movimento, int $stabil ->all(); } + protected function hasOperationalRiconciliazione(MovimentoBanca $record): bool + { + $matchData = is_array($record->match_data ?? null) ? $record->match_data : []; + if (($matchData['riconciliato_operativo'] ?? false) === true) { + return true; + } + + $tipo = trim((string) ($matchData['riconciliazione_tipo'] ?? '')); + return in_array($tipo, ['affitto_canone', 'pagamento_fattura_fornitore'], true); + } + + protected function getOperationalRiconciliazioneLabel(MovimentoBanca $record): ?string + { + if (! $this->hasOperationalRiconciliazione($record)) { + return null; + } + + $matchData = is_array($record->match_data ?? null) ? $record->match_data : []; + $label = trim((string) ($matchData['riconciliazione_label'] ?? '')); + if ($label !== '') { + return $label; + } + + return match ((string) ($matchData['riconciliazione_tipo'] ?? '')) { + 'affitto_canone' => 'Canone affitto chiuso', + 'pagamento_fattura_fornitore' => 'Pagamento fattura fornitore', + default => 'Riconciliato operativamente', + }; + } + + protected function resolveRubricaLabel(?RubricaUniversale $rubrica): ?string + { + if (! $rubrica) { + return null; + } + + $label = trim((string) ($rubrica->nome_completo ?? $rubrica->ragione_sociale ?? '')); + if ($label !== '') { + return $label; + } + + return trim((string) (($rubrica->nome ?? '') . ' ' . ($rubrica->cognome ?? ''))); + } + + /** @return array{provider?: string, cbill?: string, sia?: string, voce_spesa_code?: string, is_water?: bool} */ + protected function extractFatturaPaymentHints(FatturaFornitore $fattura): array + { + $hints = []; + + $provider = trim((string) ($fattura->fornitore?->ragione_sociale ?? '')); + if ($provider === '') { + $provider = trim((string) ($fattura->fatturaElettronica?->fornitore_denominazione ?? '')); + } + if ($provider !== '') { + $hints['provider'] = $provider; + } + + $payload = is_array($fattura->dati_fornitura ?? null) ? $fattura->dati_fornitura : []; + $payment = is_array($payload['pagamento'] ?? null) ? $payload['pagamento'] : []; + if (is_string($payment['cbill'] ?? null) && trim((string) $payment['cbill']) !== '') { + $hints['cbill'] = trim((string) $payment['cbill']); + } + if (is_string($payment['sia'] ?? null) && trim((string) $payment['sia']) !== '') { + $hints['sia'] = trim((string) $payment['sia']); + } + + if (is_string($payload['voce_spesa_code'] ?? null) && trim((string) $payload['voce_spesa_code']) !== '') { + $hints['voce_spesa_code'] = strtoupper(trim((string) $payload['voce_spesa_code'])); + } + + $type = strtoupper(trim((string) ($payload['type'] ?? $payload['tipo'] ?? ''))); + $fe = $fattura->fatturaElettronica; + $isWater = $type === 'ACQUA' + || $type === 'WATER' + || (($hints['voce_spesa_code'] ?? null) === 'ACQ') + || (is_string($provider) && (str_contains(strtoupper($provider), 'ACEA ATO2') || str_contains(strtoupper($provider), 'ACEA ACQUA'))) + || (is_string($fe?->consumo_raw ?? null) && str_contains(strtoupper((string) $fe->consumo_raw), 'ACQUA')); + + $hints['is_water'] = $isWater; + + return $hints; + } + /** @return array{totale:int,motivo:string} */ protected function scoreCandidate(float $movimentoImporto, ?Carbon $movimentoData, string $movimentoText, float $candidateImporto, mixed $candidateData, string $candidateText): array { - $score = 0; + $score = 0; $motivi = []; $diffImporto = abs($movimentoImporto - $candidateImporto); if ($diffImporto < 0.01) { - $score += 50; - $motivi[] = 'importo identico'; + $score += 50; + $motivi[] = 'importo identico'; } elseif ($diffImporto <= 1) { - $score += 42; - $motivi[] = 'importo quasi identico'; + $score += 42; + $motivi[] = 'importo quasi identico'; } elseif ($diffImporto <= 5) { - $score += 30; - $motivi[] = 'importo vicino'; + $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)); + $days = abs($movimentoData->diffInDays($candidateCarbon)); if ($days === 0) { - $score += 25; - $motivi[] = 'stessa data'; + $score += 25; + $motivi[] = 'stessa data'; } elseif ($days <= 3) { - $score += 20; - $motivi[] = 'entro 3 giorni'; + $score += 20; + $motivi[] = 'entro 3 giorni'; } elseif ($days <= 10) { $score += 12; } elseif ($days <= 30) { @@ -2239,10 +3325,10 @@ protected function scoreCandidate(float $movimentoImporto, ?Carbon $movimentoDat } } - $overlap = $this->calculateTokenOverlap($movimentoText, $candidateText); + $overlap = $this->calculateTokenOverlap($movimentoText, $candidateText); if ($overlap >= 3) { - $score += 25; - $motivi[] = 'descrizione coerente'; + $score += 25; + $motivi[] = 'descrizione coerente'; } elseif ($overlap === 2) { $score += 18; } elseif ($overlap === 1) { @@ -2257,7 +3343,7 @@ protected function scoreCandidate(float $movimentoImporto, ?Carbon $movimentoDat protected function calculateTokenOverlap(string $left, string $right): int { - $leftTokens = $this->extractMeaningfulTokens($left); + $leftTokens = $this->extractMeaningfulTokens($left); $rightTokens = $this->extractMeaningfulTokens($right); if ($leftTokens === [] || $rightTokens === []) { @@ -2424,6 +3510,99 @@ public function getSelectedContoImportInfo(): ?array return null; } + /** @return array> */ + public function getGestioniContabiliRilevate(): array + { + $stabileId = $this->getActiveStabileId(); + if (! $stabileId || ! DB::getSchemaBuilder()->hasTable('gestioni_contabili')) { + return []; + } + + return GestioneContabile::query() + ->where('stabile_id', $stabileId) + ->whereIn('tipo_gestione', ['ordinaria', 'straordinaria']) + ->orderByDesc('anno_gestione') + ->orderByRaw("CASE WHEN tipo_gestione = 'ordinaria' THEN 0 ELSE 1 END") + ->orderByDesc('id') + ->get(['id', 'anno_gestione', 'tipo_gestione', 'stato', 'protocollo_prefix', 'denominazione', 'gestione_attiva']) + ->map(function (GestioneContabile $gestione): array { + $folder = ArchivioPaths::gestioneFolderName($gestione, (int) $gestione->anno_gestione, (string) $gestione->tipo_gestione); + + return [ + 'id' => (int) $gestione->id, + 'year' => (int) ($gestione->anno_gestione ?? 0), + 'tipo' => (string) ($gestione->tipo_gestione ?? ''), + 'folder' => $folder, + 'label' => trim(($gestione->protocollo_prefix ? $gestione->protocollo_prefix . ' · ' : '') . ($gestione->denominazione ?: $folder)), + 'stato' => (string) ($gestione->stato ?? ''), + 'active' => (bool) ($gestione->gestione_attiva ?? false), + ]; + }) + ->values() + ->all(); + } + + /** @return array */ + public function getArchivioOperativoSummary(): array + { + $stabile = $this->getActiveStabile(); + $conto = $this->contoId + ? DatiBancari::query()->where('stabile_id', $this->getActiveStabileId())->whereKey($this->contoId)->first() + : null; + + $referenceYear = $this->resolveArchivioReferenceYear(); + $activeGestione = null; + $stabileId = $this->getActiveStabileId(); + if ($stabileId) { + $defaultGestioneId = $this->resolveDefaultGestioneId($stabileId); + if ($defaultGestioneId && is_numeric($defaultGestioneId)) { + $activeGestione = GestioneContabile::query()->find((int) $defaultGestioneId); + } + if (! $activeGestione) { + $activeGestione = $this->resolveGestioneForYear($stabileId, $referenceYear, 'ordinaria'); + } + } + + $bancaFolder = $conto instanceof DatiBancari + ? 'banca_cc/' . ArchivioPaths::bankAccountFolderName($conto) . '/' . $referenceYear + : null; + + return [ + 'reference_year' => $referenceYear, + 'gestione_folder' => ArchivioPaths::gestioneFolderName($activeGestione, $referenceYear, 'ordinaria'), + 'gestione_label' => $activeGestione + ? trim(((string) ($activeGestione->protocollo_prefix ?? '') !== '' ? $activeGestione->protocollo_prefix . ' · ' : '') . (string) ($activeGestione->denominazione ?? 'Gestione')) + : 'Gestione da associare', + 'banca_folder' => $bancaFolder, + 'fatture_xml_folder' => 'fatture_xml/' . $referenceYear, + 'fatture_pdf_folder' => 'fatture/' . ArchivioPaths::gestioneFolderName($activeGestione, $referenceYear, 'ordinaria'), + 'base_folder' => $stabile ? ArchivioPaths::stabileBase($stabile, $stabile->amministratore) : null, + ]; + } + + /** @return array> */ + public function getRiconciliazioneBucketRows(): array + { + return [ + [ + 'title' => 'Costi / Entrate', + 'detail' => 'Lettura economica standard dei movimenti: incassi, spese, pagamenti, bonifici e competenze correnti.', + ], + [ + 'title' => 'Crediti / Debiti', + 'detail' => 'Base già pronta con crediti verso condòmini e debiti verso fornitori, utile per incassi rate, FE passive e note di rettifica.', + ], + [ + 'title' => 'Fondi / Accantonamenti', + 'detail' => 'Predisposti bucket separati per fondo riserva e accantonamenti, così la riconciliazione non confonde disponibilità corrente e somme vincolate.', + ], + [ + 'title' => 'Rimborsi', + 'detail' => 'Separazione tra rimborsi da distribuire e rimborsi da utilizzare, per tenere distinta la quadratura patrimoniale dalla spesa/ricavo puro.', + ], + ]; + } + protected function resolvePreferredImportFormatForConto(?int $contoId): string { if (! $contoId) { @@ -2471,6 +3650,51 @@ protected function makeMovimentiImporter(): MovimentiBancaImporter ); } + protected function archiveImportedBankSourceFile(DatiBancari $conto, string $tempPath, ?int $gestioneId = null, ?string $format = null): ?string + { + $stabile = $this->getActiveStabile(); + if (! $stabile instanceof Stabile) { + return null; + } + + $sourcePath = trim($tempPath); + if ($sourcePath === '' || ! Storage::disk('local')->exists($sourcePath)) { + return null; + } + + $year = (int) now()->format('Y'); + $gestione = null; + if ($gestioneId && DB::getSchemaBuilder()->hasTable('gestioni_contabili')) { + $gestione = GestioneContabile::query()->find($gestioneId); + if ($gestione && is_numeric($gestione->anno_gestione)) { + $year = (int) $gestione->anno_gestione; + } + } + + $targetDir = ArchivioPaths::bankYearPath($stabile, $conto, $year, $stabile->amministratore); + if (! is_string($targetDir) || $targetDir === '') { + return null; + } + + $formatPrefix = is_string($format) && trim($format) !== '' + ? strtoupper(trim((string) $format)) . '-' + : ''; + + $originalName = basename($sourcePath); + $safeName = preg_replace('/[^A-Za-z0-9._-]+/', '-', $originalName) ?? $originalName; + $targetPath = $targetDir . '/import/' . now()->format('Ymd-His') . '-' . $formatPrefix . $safeName; + + Storage::disk('local')->makeDirectory($targetDir . '/import'); + Storage::disk('local')->put($targetPath, Storage::disk('local')->get($sourcePath)); + + $matchData = [ + 'archived_bank_source_path' => $targetPath, + 'gestione_folder' => ArchivioPaths::gestioneFolderName($gestione, $year, 'ordinaria'), + ]; + + return $targetPath; + } + public function getSelectedIbanLabel(): ?string { $iban = is_string($this->iban) ? trim($this->iban) : ''; @@ -2621,12 +3845,14 @@ public function getSaldoSnapshotCards(): array ->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, + 'id' => (int) $saldo->id, + 'data' => $saldo->data_saldo?->format('d/m/Y'), + 'saldo' => (float) $saldo->saldo, + 'is_partenza_contabile' => (bool) ($saldo->is_partenza_contabile ?? false), + '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(); @@ -2635,10 +3861,10 @@ public function getSaldoSnapshotCards(): array protected function getTipoEstrattoOptions(): array { return [ - 'estratto_conto' => 'Estratto conto', - 'saldo_banca' => 'Saldo banca', + 'estratto_conto' => 'Estratto conto', + 'saldo_banca' => 'Saldo banca', 'riconciliazione' => 'Riconciliazione', - 'altro' => 'Altro', + 'altro' => 'Altro', ]; } @@ -2665,7 +3891,7 @@ protected function formatTipoEstrattoLabel(?string $value): string 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); + $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'); @@ -2684,12 +3910,12 @@ protected function formatPeriodoEstrattoLabel(mixed $from, mixed $to): string protected function buildContoLabel(DatiBancari $conto): string { - $parts = []; + $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); + $numeroConto = is_string($conto->numero_conto ?? null) ? trim((string) $conto->numero_conto) : ''; + $iban = $this->normalizeIbanValue($conto->iban); if ($legacyCodCassa !== '') { $parts[] = 'cassa ' . strtoupper($legacyCodCassa); @@ -2709,6 +3935,276 @@ protected function normalizeIbanValue(mixed $value): ?string return $iban !== '' ? $iban : null; } + protected function getSaldoForActiveContext(int $saldoId): ?SaldoConto + { + $stabileId = $this->getActiveStabileId(); + if (! $stabileId || ! DB::getSchemaBuilder()->hasTable('contabilita_saldi_conti')) { + return null; + } + + return SaldoConto::query() + ->with('conto', 'documento') + ->where('stabile_id', $stabileId) + ->whereKey($saldoId) + ->first(); + } + + protected function parseOptionalDate(mixed $value): ?Carbon + { + $value = is_string($value) ? trim($value) : $value; + if ($value === null || $value === '') { + return null; + } + + try { + return $value instanceof Carbon ? $value->copy()->startOfDay() : Carbon::parse((string) $value)->startOfDay(); + } catch (\Throwable) { + return null; + } + } + + protected function parseNumericAmount(mixed $value): ?float + { + if (is_int($value) || is_float($value)) { + return round((float) $value, 2); + } + + if (! is_string($value)) { + return null; + } + + $normalized = trim($value); + if ($normalized === '') { + return null; + } + + $normalized = str_replace(' ', '', $normalized); + $normalized = str_replace('.', '', $normalized); + $normalized = str_replace(',', '.', $normalized); + + return is_numeric($normalized) ? round((float) $normalized, 2) : null; + } + + protected function normalizeOptionalText(mixed $value): ?string + { + if (! is_string($value)) { + return null; + } + + $text = trim($value); + + return $text !== '' ? $text : null; + } + + protected function generateManualMovementRowHash(int $stabileId, int $contoId, string $data, float $importo, string $descrizione): string + { + return hash('sha256', implode('|', [ + 'manuale_hub_banca', + $stabileId, + $contoId, + $data, + number_format($importo, 2, '.', ''), + mb_strtolower(trim($descrizione)), + microtime(true), + random_int(1000, 999999), + ])); + } + + protected function isManualMovement(MovimentoBanca $record): bool + { + $sourceFile = strtolower(trim((string) ($record->source_file ?? ''))); + if (str_starts_with($sourceFile, 'manuale')) { + return true; + } + + $matchData = is_array($record->match_data ?? null) ? $record->match_data : []; + $source = strtolower(trim((string) ($matchData['source'] ?? ''))); + + return str_starts_with($source, 'manuale'); + } + + protected function extractManualMovementNote(MovimentoBanca $record): ?string + { + $matchData = is_array($record->match_data ?? null) ? $record->match_data : []; + $note = $this->normalizeOptionalText($matchData['note_manuali'] ?? null); + if ($note) { + return $note; + } + + return $this->normalizeOptionalText($record->raw_line); + } + + /** @return array{status:string,icon:string,label:string,expected_saldo:?float,delta:?float,movimenti_count:int} */ + protected function resolveSaldoArchivioQuadratura(SaldoConto $saldo): array + { + $contoId = isset($saldo->conto_id) && is_numeric($saldo->conto_id) ? (int) $saldo->conto_id : null; + $iban = $this->normalizeIbanValue($saldo->iban); + + if ((bool) ($saldo->is_partenza_contabile ?? false)) { + return [ + 'status' => 'anchor', + 'icon' => 'flag', + 'label' => 'Punto di partenza contabile dal ' . ($saldo->data_saldo?->format('d/m/Y') ?? 'saldo registrato') . '.', + 'expected_saldo' => (float) $saldo->saldo, + 'delta' => 0.0, + 'movimenti_count' => 0, + ]; + } + + if (! $contoId && ! $iban) { + return [ + 'status' => 'neutral', + 'icon' => 'minus', + 'label' => 'Conto non risolto per il confronto.', + 'expected_saldo' => null, + 'delta' => null, + 'movimenti_count' => 0, + ]; + } + + $dataSaldo = $saldo->data_saldo?->copy()->startOfDay(); + $periodoDa = $saldo->periodo_da?->copy()->startOfDay(); + $periodoA = $saldo->periodo_a?->copy()->startOfDay(); + $anchorDate = $periodoDa ?: $dataSaldo; + $endDate = $periodoA ?: $dataSaldo; + + if (! $anchorDate || ! $endDate) { + return [ + 'status' => 'neutral', + 'icon' => 'minus', + 'label' => 'Mancano le date per il confronto con i movimenti.', + 'expected_saldo' => null, + 'delta' => null, + 'movimenti_count' => 0, + ]; + } + + $previousSaldoQuery = SaldoConto::query() + ->where('stabile_id', (int) $saldo->stabile_id) + ->whereKeyNot($saldo->id); + + if ($contoId) { + $previousSaldoQuery->where('conto_id', $contoId); + } else { + $previousSaldoQuery->where('iban', $iban); + } + + $previousSaldo = $previousSaldoQuery + ->whereDate('data_saldo', '<', $anchorDate->toDateString()) + ->orderByDesc('data_saldo') + ->orderByDesc('id') + ->first(['id', 'data_saldo', 'saldo']); + + $baseSaldo = $previousSaldo + ? (float) $previousSaldo->saldo + : $this->resolveSaldoInizialeDaDatiBancari((int) $saldo->stabile_id, $contoId, $iban); + + $movimentiQuery = MovimentoBanca::query() + ->where('stabile_id', (int) $saldo->stabile_id); + + if ($contoId) { + $movimentiQuery->where('conto_id', $contoId); + } else { + $movimentiQuery->where('iban', $iban); + } + + if ($periodoDa) { + $movimentiQuery->whereDate('data', '>=', $periodoDa->toDateString()); + } elseif ($previousSaldo?->data_saldo) { + $movimentiQuery->whereDate('data', '>', $previousSaldo->data_saldo->toDateString()); + } + + $movimentiQuery->whereDate('data', '<=', $endDate->toDateString()); + + $movimentiCount = (int) (clone $movimentiQuery)->count(); + $movimentiSaldo = (float) ((clone $movimentiQuery)->sum('importo') ?? 0.0); + $expectedSaldo = round($baseSaldo + $movimentiSaldo, 2); + $delta = round((float) $saldo->saldo - $expectedSaldo, 2); + + $baseLabel = $previousSaldo + ? ('saldo precedente ' . $previousSaldo->data_saldo?->format('d/m/Y')) + : 'saldo iniziale conto'; + $periodoLabel = ($periodoDa || $periodoA) + ? ('Periodo ' . $this->formatPeriodoEstrattoLabel($periodoDa, $periodoA ?: $endDate)) + : ('Movimenti fino al ' . $endDate->format('d/m/Y')); + + if (abs($delta) <= 0.01) { + return [ + 'status' => 'ok', + 'icon' => 'check', + 'label' => $periodoLabel . ' quadrato con ' . $baseLabel . '.', + 'expected_saldo' => $expectedSaldo, + 'delta' => $delta, + 'movimenti_count' => $movimentiCount, + ]; + } + + return [ + 'status' => 'mismatch', + 'icon' => 'warning', + 'label' => $periodoLabel . ' non quadrato: atteso € ' . number_format($expectedSaldo, 2, ',', '.') . ' da ' . $baseLabel . '.', + 'expected_saldo' => $expectedSaldo, + 'delta' => $delta, + 'movimenti_count' => $movimentiCount, + ]; + } + + protected function cleanupSaldoDocumentoIfUnused(SaldoConto $saldo): void + { + $documentoId = isset($saldo->documento_stabile_id) && is_numeric($saldo->documento_stabile_id) + ? (int) $saldo->documento_stabile_id + : 0; + + if ($documentoId <= 0) { + return; + } + + $isStillUsed = SaldoConto::query() + ->where('documento_stabile_id', $documentoId) + ->whereKeyNot($saldo->id) + ->exists(); + + if ($isStillUsed) { + return; + } + + $documento = DocumentoStabile::query()->find($documentoId); + if (! $documento) { + return; + } + + try { + $documento->eliminaFile(); + } catch (\Throwable) { + // ignore cleanup issues to avoid blocking saldo deletion + } + + $documento->delete(); + } + + protected function syncContabilitaAnchorForSaldo(SaldoConto $saldo): void + { + if (! ((bool) ($saldo->is_partenza_contabile ?? false))) { + return; + } + + $query = SaldoConto::query() + ->where('stabile_id', (int) $saldo->stabile_id) + ->whereKeyNot($saldo->id); + + if (is_numeric($saldo->conto_id)) { + $query->where('conto_id', (int) $saldo->conto_id); + } elseif ($this->normalizeIbanValue($saldo->iban)) { + $query->where('iban', $this->normalizeIbanValue($saldo->iban)); + } else { + return; + } + + if (Schema::hasColumn('contabilita_saldi_conti', 'is_partenza_contabile')) { + $query->update(['is_partenza_contabile' => false]); + } + } + protected function storeSaldoDocumento(int $stabileId, DatiBancari $conto, mixed $tempPath, User $user, array $data): ?DocumentoStabile { $path = is_string($tempPath) ? trim($tempPath) : ''; @@ -2716,10 +4212,26 @@ protected function storeSaldoDocumento(int $stabileId, DatiBancari $conto, mixed 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; + $year = null; + foreach (['data_saldo', 'periodo_a', 'periodo_da'] as $field) { + $raw = $data[$field] ?? null; + if (! $raw) { + continue; + } + + try { + $year = (int) Carbon::parse((string) $raw)->format('Y'); + break; + } catch (\Throwable) { + // ignore + } + } + $year = $year && $year >= 2000 ? $year : (int) now()->format('Y'); + + $extension = strtolower(pathinfo($path, PATHINFO_EXTENSION)); + $extension = $extension !== '' ? $extension : 'bin'; + $filename = 'estratto-conto-' . $stabileId . '-' . (int) $conto->id . '-' . now()->format('YmdHis') . '.' . $extension; + $publicPath = 'documenti/stabili/ID-' . $stabileId . '/banca_cc/' . ArchivioPaths::bankAccountFolderName($conto) . '/' . $year . '/saldi/' . $filename; Storage::disk('public')->put($publicPath, Storage::disk('local')->get($path)); @@ -2730,21 +4242,21 @@ protected function storeSaldoDocumento(int $stabileId, DatiBancari $conto, mixed } $descrizione = 'Estratto conto ' . $this->buildContoLabel($conto); - $periodo = $this->formatPeriodoEstrattoLabel($data['periodo_da'] ?? null, $data['periodo_a'] ?? null); + $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, + '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, + '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/Services/Contabilita/MovimentiBancaImporter.php b/app/Services/Contabilita/MovimentiBancaImporter.php index 572cffb..607a6e2 100644 --- a/app/Services/Contabilita/MovimentiBancaImporter.php +++ b/app/Services/Contabilita/MovimentiBancaImporter.php @@ -994,6 +994,54 @@ private function parseDescrizioneEstesa(string $text): array $res['periodo_rate'] = trim($m[1]); } + if (preg_match('/\b(Altri\s+pagamenti|Bonifici?|Addebiti?|Prelievi?|Commissioni?)\b/i', $text, $m)) { + $res['payment_group'] = strtoupper(trim((string) $m[1])); + } + + if (preg_match('/\bFav\.?\s+([^\n]+?)(?=\s+(?:Cbill|CBILL|SIA\b|Da\s+Digital\s+Banking|Di\s+Cui\s+Commissioni|T[\-\+0-9\.,]|$))/i', $text, $m)) { + $provider = trim((string) $m[1]); + if ($provider !== '') { + $res['payment_provider'] = $provider; + if (empty($res['beneficiario'])) { + $res['beneficiario'] = $provider; + } + } + } + + if (preg_match('/\bCbill\b\s*(?:N\.)?\s*\.?\s*([0-9]{9,})/i', $text, $m)) { + $res['cbill'] = trim((string) $m[1]); + } + + if (preg_match('/\bSIA\b[:\s]*([A-Z0-9]{3,10})/i', $text, $m)) { + $res['sia'] = strtoupper(trim((string) $m[1])); + } elseif (preg_match('/\b([A-Z][A-Z0-9]{3,6})\b(?=\s+Cbill\b)/i', $text, $m)) { + $candidate = strtoupper(trim((string) $m[1])); + if (! in_array($candidate, ['CBILL', 'FAV'], true)) { + $res['sia'] = $candidate; + } + } + + if (preg_match('/Di\s+Cui\s+Commissioni:\s*([0-9\.,]+)\s*Euro/i', $text, $m)) { + $commissione = $this->parseItalianDecimal($m[1] ?? null); + if ($commissione !== null) { + $res['commissioni_euro'] = $commissione; + } + } + + if (preg_match('/\bDa\s+Digital\s+Banking\b/i', $text)) { + $res['payment_channel'] = 'digital_banking'; + } + + $providerUpper = strtoupper(trim((string) ($res['payment_provider'] ?? $res['beneficiario'] ?? ''))); + if ($providerUpper !== '') { + if (str_contains($providerUpper, 'ACEA ATO2') || str_contains($providerUpper, 'ACEA ACQUA')) { + $res['utility_type'] = 'acqua'; + $res['suggested_voce_spesa_code'] = 'ACQ'; + } elseif (str_contains($providerUpper, 'A2A')) { + $res['utility_type'] = 'utenza'; + } + } + if (empty($res) && str_contains($text, 'Bonifico a Vostro favore')) { $res['descrizione_raw'] = $text; } @@ -1040,4 +1088,17 @@ private function matchFornitoreId(string $name): ?int return $rows->count() === 1 ? (int) $rows->first()->id : null; } + + private function parseItalianDecimal(mixed $value): ?float + { + $value = trim((string) $value); + if ($value === '') { + return null; + } + + $value = str_replace(['.', ' '], ['', ''], $value); + $value = str_replace(',', '.', $value); + + return is_numeric($value) ? (float) $value : null; + } } diff --git a/app/Services/FattureElettroniche/CassettoFiscaleDownloadService.php b/app/Services/FattureElettroniche/CassettoFiscaleDownloadService.php index 2f157ec..5f27aeb 100644 --- a/app/Services/FattureElettroniche/CassettoFiscaleDownloadService.php +++ b/app/Services/FattureElettroniche/CassettoFiscaleDownloadService.php @@ -256,8 +256,8 @@ public function downloadAndImport( Storage::disk('local')->makeDirectory($errorsDir); $errorsLogPath = $errorsDir . '/' . $uuid . '-download-errors.jsonl'; - // Salva i file scaricati in una cartella stabile XML dentro lo stabile. - $xmlBase = $stabileBase . '/XML'; + // Salva gli XML in una struttura leggibile e annuale: fatture_xml/ANNO. + $xmlBase = ArchivioPaths::fattureXmlYearPath($stabile, $year, $amministratore) ?: ($stabileBase . '/fatture_xml/' . $year); Storage::disk('local')->makeDirectory($xmlBase); foreach ($files as $filePath) { diff --git a/app/Services/FattureElettroniche/FatturaElettronicaImporter.php b/app/Services/FattureElettroniche/FatturaElettronicaImporter.php index 68230d9..31fd800 100644 --- a/app/Services/FattureElettroniche/FatturaElettronicaImporter.php +++ b/app/Services/FattureElettroniche/FatturaElettronicaImporter.php @@ -11,6 +11,7 @@ use App\Modules\Contabilita\Models\FatturaFornitore; use App\Modules\Contabilita\Models\RegolaPrimaNota; use App\Services\Catalog\FornitoreProductCatalogService; +use App\Services\Consumi\AcquaPdfTextParser; use App\Support\ArchivioPaths; use DOMDocument; use DOMXPath; @@ -531,9 +532,6 @@ private function updateContabilitaFornituraDaPdf(FatturaElettronica $fattura): v } $existing = is_array($fatturaContabile->dati_fornitura) ? $fatturaContabile->dati_fornitura : []; - if (count($existing) > 0) { - return; - } $pdfPath = is_string($fattura->allegato_pdf_path ?? null) ? trim((string) $fattura->allegato_pdf_path) : ''; if ($pdfPath === '') { @@ -550,26 +548,56 @@ private function updateContabilitaFornituraDaPdf(FatturaElettronica $fattura): v } $fullPath = $disk->path($resolvedPath); - $data = $this->extractGasFornituraDataFromPdf($fullPath); - if (! is_array($data) || $data === []) { + $text = $this->extractPdfText($fullPath); + if (! is_string($text) || trim($text) === '') { return; } - $fatturaContabile->dati_fornitura = $data; - $fatturaContabile->save(); + $gasData = $this->extractGasFornituraDataFromPdfText($text); + $waterData = $this->extractAcquaFornituraDataFromPdfText($text); + $paymentData = $this->extractPaymentReferenceDataFromPdfText($text, $fattura); + + $data = is_array($waterData) && $waterData !== [] ? $waterData : $gasData; + if (! is_array($data)) { + $data = []; + } + if ($paymentData !== []) { + $data['pagamento'] = array_merge(is_array($data['pagamento'] ?? null) ? $data['pagamento'] : [], $paymentData); + } + + $merged = $this->mergeFornituraPayload($existing, $data); + if ($merged === []) { + return; + } + + if (($merged['type'] ?? $merged['tipo'] ?? null) === 'acqua') { + $this->enrichWaterInvoiceFromParsedData($fattura, $merged); + $this->assignWaterExpenseCode($fatturaContabile, $merged); + } + + if ($merged !== $existing) { + $fatturaContabile->dati_fornitura = $merged; + $fatturaContabile->save(); + } } /** @return array|null */ private function extractGasFornituraDataFromPdf(string $fullPath): ?array + { + $text = $this->extractPdfText($fullPath); + return is_string($text) ? $this->extractGasFornituraDataFromPdfText($text) : null; + } + + /** @return array|null */ + private function extractGasFornituraDataFromPdfText(string $text): ?array { try { - $parser = new Parser(); - $text = $parser->parseFile($fullPath)->getText(); + $text = trim($text); } catch (\Throwable) { return null; } - if (! is_string($text) || trim($text) === '') { + if ($text === '') { return null; } @@ -636,6 +664,183 @@ private function extractGasFornituraDataFromPdf(string $fullPath): ?array return $payload !== [] ? $payload : null; } + private function extractPdfText(string $fullPath): ?string + { + try { + $parser = new Parser(); + $text = $parser->parseFile($fullPath)->getText(); + } catch (\Throwable) { + return null; + } + + $text = is_string($text) ? trim($text) : ''; + return $text !== '' ? $text : null; + } + + /** @return array|null */ + private function extractAcquaFornituraDataFromPdfText(string $text): ?array + { + try { + $parsed = app(AcquaPdfTextParser::class)->parse($text); + } catch (\Throwable) { + return null; + } + + $codici = is_array($parsed['codici'] ?? null) ? $parsed['codici'] : []; + $contatore = is_array($parsed['contatore'] ?? null) ? $parsed['contatore'] : []; + $consumi = is_array($parsed['consumi'] ?? null) ? $parsed['consumi'] : []; + $generale = is_array($parsed['generale'] ?? null) ? $parsed['generale'] : []; + $quadro = is_array($parsed['quadro_dettaglio'] ?? null) ? $parsed['quadro_dettaglio'] : []; + + $hasIdentifiers = count(array_filter([ + $codici['utenza'] ?? null, + $codici['cliente'] ?? null, + $codici['contratto'] ?? null, + $contatore['matricola'] ?? null, + $generale['numero_fattura_pdf'] ?? null, + ], static fn($value): bool => is_string($value) ? trim($value) !== '' : $value !== null)) > 0; + + if (! $hasIdentifiers && $consumi === []) { + return null; + } + + $payload = [ + 'type' => 'acqua', + 'voce_spesa_code' => 'ACQ', + 'codici' => [ + 'utenza' => $codici['utenza'] ?? null, + 'cliente' => $codici['cliente'] ?? null, + 'contratto' => $codici['contratto'] ?? null, + ], + 'contatore' => [ + 'matricola' => $contatore['matricola'] ?? null, + ], + 'generale' => $generale, + 'quadro_dettaglio' => $quadro, + 'consumi' => array_values($consumi), + ]; + + return array_filter($payload, fn($value) => $value !== null && $value !== ''); + } + + /** @return array */ + private function extractPaymentReferenceDataFromPdfText(string $text, FatturaElettronica $fattura): array + { + $provider = trim((string) ($fattura->fornitore_denominazione ?? '')); + $cbill = null; + $sia = null; + + if (preg_match('/\bCBILL\b(?:\s*N\.)?\s*\.?\s*([0-9]{9,})/i', $text, $m)) { + $cbill = trim((string) ($m[1] ?? '')); + } + if (preg_match('/\bSIA\b[:\s]*([A-Z0-9]{3,10})/i', $text, $m)) { + $sia = strtoupper(trim((string) ($m[1] ?? ''))); + } elseif (preg_match('/\b([A-Z][A-Z0-9]{3,6})\b(?=\s+CBILL\b)/i', $text, $m)) { + $candidate = strtoupper(trim((string) ($m[1] ?? ''))); + if (! in_array($candidate, ['CBILL', 'FAV'], true)) { + $sia = $candidate; + } + } + + $payload = [ + 'provider' => $provider !== '' ? $provider : null, + 'cbill' => $cbill, + 'sia' => $sia, + ]; + + return array_filter($payload, fn($value) => $value !== null && $value !== ''); + } + + /** @param array $existing @param array $incoming @return array */ + private function mergeFornituraPayload(array $existing, array $incoming): array + { + foreach ($incoming as $key => $value) { + if (is_array($value)) { + $existing[$key] = $this->mergeFornituraPayload(is_array($existing[$key] ?? null) ? $existing[$key] : [], $value); + continue; + } + + if ($value !== null && $value !== '') { + $existing[$key] = $value; + } + } + + return $existing; + } + + /** @param array $payload */ + private function enrichWaterInvoiceFromParsedData(FatturaElettronica $fattura, array $payload): void + { + $consumi = is_array($payload['consumi'] ?? null) ? $payload['consumi'] : []; + if ($consumi === []) { + return; + } + + $sum = 0.0; + $hasNumeric = false; + foreach ($consumi as $consumo) { + if (! is_array($consumo) || ! is_numeric($consumo['valore'] ?? null)) { + continue; + } + $sum += (float) $consumo['valore']; + $hasNumeric = true; + } + + $reference = null; + $first = is_array($consumi[0] ?? null) ? $consumi[0] : []; + if (is_string($first['dal'] ?? null) && is_string($first['al'] ?? null) && $first['dal'] !== '' && $first['al'] !== '') { + $reference = $first['dal'] . ' - ' . $first['al']; + } + + $dirty = false; + if ($hasNumeric && (! is_numeric($fattura->consumo_valore) || (float) $fattura->consumo_valore <= 0)) { + $fattura->consumo_valore = $sum; + $dirty = true; + } + if ((! is_string($fattura->consumo_unita ?? null) || trim((string) $fattura->consumo_unita) === '') && $hasNumeric) { + $fattura->consumo_unita = 'mc'; + $dirty = true; + } + if ($reference !== null && (! is_string($fattura->consumo_riferimento ?? null) || trim((string) $fattura->consumo_riferimento) === '')) { + $fattura->consumo_riferimento = $reference; + $dirty = true; + } + if (! is_string($fattura->consumo_raw ?? null) || trim((string) $fattura->consumo_raw) === '') { + $fattura->consumo_raw = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + $dirty = true; + } + + if ($dirty) { + $fattura->save(); + } + } + + /** @param array $payload */ + private function assignWaterExpenseCode(FatturaFornitore $fatturaContabile, array $payload): void + { + if (($payload['voce_spesa_code'] ?? null) !== 'ACQ') { + return; + } + if (! Schema::hasTable('voci_spesa') || ! Schema::hasTable('contabilita_fatture_fornitori_righe') || ! Schema::hasColumn('contabilita_fatture_fornitori_righe', 'voce_spesa_id')) { + return; + } + + $voceId = DB::table('voci_spesa') + ->where('stabile_id', (int) $fatturaContabile->stabile_id) + ->where('codice', 'ACQ') + ->orderBy('id') + ->value('id'); + + if (! $voceId) { + return; + } + + $fatturaContabile->righe() + ->whereNull('voce_spesa_id') + ->where('descrizione', '!=', 'Riepilogo FE / arrotondamenti') + ->update(['voce_spesa_id' => (int) $voceId]); + } + /** @param array $lines */ private function findValueAfterLabel(array $lines, string $labelPattern, int $maxLookahead = 6, ?string $valuePattern = null): ?string { @@ -1140,21 +1345,28 @@ private function postProcess(FatturaElettronica $fattura, string $xml, array $ex private function archiveBaseForStabile(int $stabileId, string $ym): string { + $year = null; + try { + $year = (int) Carbon::parse($ym . '/01')->format('Y'); + } catch (\Throwable) { + $year = (int) now()->format('Y'); + } + $stabile = Stabile::query() ->with(['amministratore:id,codice_amministratore,codice_univoco']) ->select(['id', 'amministratore_id', 'codice_stabile', 'codice_univoco']) ->find($stabileId); if ($stabile) { - $base = ArchivioPaths::stabileBase($stabile, $stabile->amministratore); + $base = ArchivioPaths::fattureXmlYearPath($stabile, $year, $stabile->amministratore); if (is_string($base) && $base !== '') { - return $base . '/fatture-ricevute/' . $ym; + return $base; } } // fallback ultra-conservativo $adminId = (int) ($stabile?->amministratore_id ?: 0); - return 'amministratori/ID-' . $adminId . '/stabili/ID-' . (int) $stabileId . '/fatture-ricevute/' . $ym; + return 'amministratori/ID-' . $adminId . '/stabili/ID-' . (int) $stabileId . '/fatture_xml/' . $year; } /** diff --git a/app/Services/FattureElettroniche/FatturaElettronicaProtocolloService.php b/app/Services/FattureElettroniche/FatturaElettronicaProtocolloService.php index 7d17469..3b67c52 100644 --- a/app/Services/FattureElettroniche/FatturaElettronicaProtocolloService.php +++ b/app/Services/FattureElettroniche/FatturaElettronicaProtocolloService.php @@ -4,6 +4,7 @@ use App\Models\Documento; use App\Models\FatturaElettronica; +use App\Models\GestioneContabile; use App\Models\Stabile; use App\Support\ArchivioPaths; use Dompdf\Dompdf; @@ -40,7 +41,12 @@ public function ensureDocumentoProtocollato(FatturaElettronica $fattura, ?int $u $stabileFolder = $stabileCode ?: ('ID-' . (int) $fattura->stabile_id); $stabileBase = ArchivioPaths::stabileBase($stabile, $stabile->amministratore); - $base = (is_string($stabileBase) && $stabileBase !== '') + $gestione = $this->resolveGestioneForYear($stabile, $anno); + $pdfBase = ArchivioPaths::fatturePdfGestionePath($stabile, $gestione, $anno, 'ordinaria', $stabile->amministratore) + ?: $this->basePath($adminFolder, $stabileFolder, $anno); + $xmlBase = ArchivioPaths::fattureXmlYearPath($stabile, $anno, $stabile->amministratore) + ?: $this->basePath($adminFolder, $stabileFolder, $anno); + $legacyBase = (is_string($stabileBase) && $stabileBase !== '') ? ($stabileBase . '/documenti/' . $anno . '/fatture-ricevute') : $this->basePath($adminFolder, $stabileFolder, $anno); @@ -50,7 +56,9 @@ public function ensureDocumentoProtocollato(FatturaElettronica $fattura, ?int $u } // Recupero soft dal base path (file già spostati manualmente). - $this->recoverMissingPathsFromBase($fattura, $base); + $this->recoverMissingPathsFromBase($fattura, $legacyBase); + $this->recoverMissingPathsFromBase($fattura, $pdfBase); + $this->recoverMissingPathsFromBase($fattura, $xmlBase); // Ripara PDF se salvato in vecchio schema con ID numerici. $newBase = (is_string($stabileBase) && $stabileBase !== '') @@ -170,8 +178,8 @@ public function ensureDocumentoProtocollato(FatturaElettronica $fattura, ?int $u || (is_string($fattura->xml_path) && $fattura->xml_path !== '' && Storage::disk('local')->exists($fattura->xml_path)); if (! $hasPdfNow && $hasXmlNow) { $targetName = $protocollo . ' ' . $dateForName . ' ' . $sanFornitore . ' ' . $sanNumero . '.pdf'; - $targetPath = $base . '/' . $targetName; - $this->ensureDir($base); + $targetPath = $pdfBase . '/' . $targetName; + $this->ensureDir($pdfBase); // Evita overwrite: se esiste già, lo riusiamo. if (! Storage::disk('local')->exists($targetPath)) { @@ -188,8 +196,8 @@ public function ensureDocumentoProtocollato(FatturaElettronica $fattura, ?int $u if (is_string($fattura->allegato_pdf_path) && $fattura->allegato_pdf_path !== '' && Storage::disk('local')->exists($fattura->allegato_pdf_path)) { $targetName = $protocollo . ' ' . $dateForName . ' ' . $sanFornitore . ' ' . $sanNumero . '.pdf'; - $targetPath = $base . '/' . $targetName; - $this->ensureDir($base); + $targetPath = $pdfBase . '/' . $targetName; + $this->ensureDir($pdfBase); if ($fattura->allegato_pdf_path !== $targetPath) { Storage::disk('local')->move($fattura->allegato_pdf_path, $targetPath); $fattura->allegato_pdf_path = $targetPath; @@ -199,8 +207,8 @@ public function ensureDocumentoProtocollato(FatturaElettronica $fattura, ?int $u if (is_string($fattura->xml_path) && $fattura->xml_path !== '' && Storage::disk('local')->exists($fattura->xml_path)) { $targetName = $protocollo . ' ' . $dateForName . ' ' . $sanFornitore . ' ' . $sanNumero . '.xml'; - $targetPath = $base . '/' . $targetName; - $this->ensureDir($base); + $targetPath = $xmlBase . '/' . $targetName; + $this->ensureDir($xmlBase); if ($fattura->xml_path !== $targetPath) { Storage::disk('local')->move($fattura->xml_path, $targetPath); $fattura->xml_path = $targetPath; @@ -271,6 +279,35 @@ public function ensureDocumentoProtocollato(FatturaElettronica $fattura, ?int $u }); } + private function resolveGestioneForYear(Stabile $stabile, int $year): ?GestioneContabile + { + if (! Schema::hasTable('gestioni_contabili')) { + return null; + } + + $query = GestioneContabile::query() + ->where('stabile_id', (int) $stabile->id) + ->where('anno_gestione', $year); + + $ordinaria = (clone $query) + ->where('tipo_gestione', 'ordinaria') + ->orderByDesc('gestione_attiva') + ->orderByRaw("CASE WHEN stato = 'aperta' THEN 0 ELSE 1 END") + ->orderByDesc('id') + ->first(); + + if ($ordinaria) { + return $ordinaria; + } + + return $query + ->orderByRaw("CASE WHEN tipo_gestione = 'straordinaria' THEN 0 ELSE 1 END") + ->orderByDesc('gestione_attiva') + ->orderByRaw("CASE WHEN stato = 'aperta' THEN 0 ELSE 1 END") + ->orderByDesc('id') + ->first(); + } + private function getDocumentoProtocolColumn(): ?string { static $cached = null; diff --git a/app/Support/ArchivioPaths.php b/app/Support/ArchivioPaths.php index 376ace7..5d972f2 100644 --- a/app/Support/ArchivioPaths.php +++ b/app/Support/ArchivioPaths.php @@ -2,6 +2,8 @@ namespace App\Support; use App\Models\Amministratore; +use App\Models\DatiBancari; +use App\Models\GestioneContabile; use App\Models\Fornitore; use App\Models\Stabile; use App\Models\User; @@ -66,6 +68,91 @@ public static function stabileBase(Stabile $stabile, ?Amministratore $admin = nu return self::adminBase($admin) . '/stabili/' . $folder; } + public static function gestioneFolderName(?GestioneContabile $gestione, ?int $fallbackYear = null, ?string $fallbackType = 'ordinaria'): string + { + $year = $fallbackYear ?: (int) now()->format('Y'); + $type = $fallbackType ?: 'ordinaria'; + + if ($gestione instanceof GestioneContabile) { + $year = (int) ($gestione->anno_gestione ?: $year); + $type = (string) ($gestione->tipo_gestione ?: $type); + } + + $prefix = match (strtolower(trim($type))) { + 'straordinaria', 'straordinario', 's' => 'S', + 'riscaldamento', 'r', 'acqua', 'a' => 'R', + default => 'O', + }; + + return $prefix . $year; + } + + public static function fattureXmlYearPath(Stabile $stabile, int|string $year, ?Amministratore $admin = null): ?string + { + $base = self::stabileBase($stabile, $admin); + if (! is_string($base) || $base === '') { + return null; + } + + return $base . '/fatture_xml/' . (int) $year; + } + + public static function fatturePdfGestionePath(Stabile $stabile, ?GestioneContabile $gestione = null, ?int $fallbackYear = null, ?string $fallbackType = 'ordinaria', ?Amministratore $admin = null): ?string + { + $base = self::stabileBase($stabile, $admin); + if (! is_string($base) || $base === '') { + return null; + } + + return $base . '/fatture/' . self::gestioneFolderName($gestione, $fallbackYear, $fallbackType); + } + + public static function bankAccountFolderName(DatiBancari $conto): string + { + $bankLabel = strtolower(trim((string) ($conto->denominazione_banca ?? ''))); + + $bankCode = match (true) { + str_contains($bankLabel, 'monte') || str_contains($bankLabel, 'paschi') || str_contains($bankLabel, 'mps') => 'MPS', + str_contains($bankLabel, 'intesa') => 'INTESA', + str_contains($bankLabel, 'unicredit') => 'UNICREDIT', + str_contains($bankLabel, 'bper') => 'BPER', + str_contains($bankLabel, 'bcc') => 'BCC', + default => strtoupper(self::sanitizeFolder((string) ($conto->denominazione_banca ?: 'BANCA'))), + }; + + if ($bankCode === '') { + $bankCode = 'BANCA'; + } + + $typeCode = strtolower(trim((string) ($conto->tipo_conto ?? 'corrente'))) === 'cassa' + ? 'CA' + : 'CC'; + + $identifier = preg_replace('/\D+/', '', (string) ($conto->numero_conto ?? '')) ?? ''; + if ($identifier === '') { + $identifier = self::sanitizeFolder((string) ($conto->legacy_cod_cassa ?? '')); + } + if ($identifier === '') { + $iban = strtoupper(preg_replace('/[^A-Z0-9]+/', '', (string) ($conto->iban ?? '')) ?? ''); + $identifier = $iban !== '' ? substr($iban, -8) : ''; + } + if ($identifier === '') { + $identifier = 'ID' . (int) $conto->id; + } + + return $bankCode . '-' . $typeCode . '-' . $identifier; + } + + public static function bankYearPath(Stabile $stabile, DatiBancari $conto, int|string $year, ?Amministratore $admin = null): ?string + { + $base = self::stabileBase($stabile, $admin); + if (! is_string($base) || $base === '') { + return null; + } + + return $base . '/banca_cc/' . self::bankAccountFolderName($conto) . '/' . (int) $year; + } + public static function fornitoreBase(Fornitore $fornitore, ?Amministratore $admin = null): ?string { $code = (string) ($fornitore->codice_univoco ?: ''); diff --git a/config/contabilita.php b/config/contabilita.php index 99013b0..d572730 100644 --- a/config/contabilita.php +++ b/config/contabilita.php @@ -29,6 +29,23 @@ 'ritenute_da_versare_codice' => env('CONTABILITA_CONTO_RITENUTE_DA_VERSARE', '2010'), 'ritenute_da_versare_descrizione' => env('CONTABILITA_CONTO_RITENUTE_DA_VERSARE_DESC', 'Erario c/ritenute da versare'), 'ritenute_da_versare_tipo' => env('CONTABILITA_CONTO_RITENUTE_DA_VERSARE_TIPO', 'Passività'), + + // Bucket patrimoniali e di quadratura da usare nella riconciliazione operativa. + 'fondo_riserva_codice' => env('CONTABILITA_CONTO_FONDO_RISERVA', '3100'), + 'fondo_riserva_descrizione' => env('CONTABILITA_CONTO_FONDO_RISERVA_DESC', 'Fondo di riserva'), + 'fondo_riserva_tipo' => env('CONTABILITA_CONTO_FONDO_RISERVA_TIPO', 'Patrimonio Netto'), + + 'accantonamenti_codice' => env('CONTABILITA_CONTO_ACCANTONAMENTI', '3110'), + 'accantonamenti_descrizione' => env('CONTABILITA_CONTO_ACCANTONAMENTI_DESC', 'Accantonamenti da destinare'), + 'accantonamenti_tipo' => env('CONTABILITA_CONTO_ACCANTONAMENTI_TIPO', 'Patrimonio Netto'), + + 'rimborsi_da_distribuire_codice' => env('CONTABILITA_CONTO_RIMBORSI_DISTRIBUIRE', '2105'), + 'rimborsi_da_distribuire_descrizione' => env('CONTABILITA_CONTO_RIMBORSI_DISTRIBUIRE_DESC', 'Rimborsi da distribuire'), + 'rimborsi_da_distribuire_tipo' => env('CONTABILITA_CONTO_RIMBORSI_DISTRIBUIRE_TIPO', 'Passività'), + + 'rimborsi_da_utilizzare_codice' => env('CONTABILITA_CONTO_RIMBORSI_UTILIZZARE', '1115'), + 'rimborsi_da_utilizzare_descrizione' => env('CONTABILITA_CONTO_RIMBORSI_UTILIZZARE_DESC', 'Rimborsi da utilizzare'), + 'rimborsi_da_utilizzare_tipo' => env('CONTABILITA_CONTO_RIMBORSI_UTILIZZARE_TIPO', 'Attività'), ], 'fatture_elettroniche' => [ diff --git a/resources/css/filament/admin/theme.css b/resources/css/filament/admin/theme.css index 4d8298d..4270929 100644 --- a/resources/css/filament/admin/theme.css +++ b/resources/css/filament/admin/theme.css @@ -23,6 +23,21 @@ .fi-main-ctn>main { min-height: 0; } +.fi-sidebar { + position: sticky !important; + top: 0 !important; + align-self: flex-start !important; + height: 100vh !important; + height: 100dvh !important; + overflow: hidden !important; +} + +.fi-sidebar-header { + position: sticky !important; + top: 0 !important; + z-index: 20 !important; +} + .fi-main { flex: 1 1 auto; min-height: 0; @@ -40,6 +55,52 @@ .netgescon-footer { .fi-sidebar-nav, .fi-sidebar-nav-groups { row-gap: calc(var(--spacing) * 1.5) !important; + overflow-y: auto !important; + max-height: calc(100vh - 5rem) !important; + max-height: calc(100dvh - 5rem) !important; +} + +@media (min-width: 64rem) { + .fi-body.fi-body-has-navigation { + overflow: hidden !important; + } + + .fi-body.fi-body-has-navigation .fi-sidebar { + position: fixed !important; + top: 4.5rem !important; + left: 0 !important; + bottom: 0 !important; + width: 20rem !important; + height: calc(100vh - 4.5rem) !important; + height: calc(100dvh - 4.5rem) !important; + z-index: 40 !important; + border-top: 1px solid rgba(148, 163, 184, 0.2) !important; + background: inherit !important; + } + + .fi-body.fi-body-has-navigation .fi-topbar { + position: fixed !important; + top: 0 !important; + left: 0 !important; + right: 0 !important; + width: 100% !important; + margin-left: 0 !important; + z-index: 50 !important; + } + + .fi-body.fi-body-has-navigation .fi-main-ctn { + margin-left: 0 !important; + width: 100% !important; + padding-top: 4.5rem !important; + padding-left: 20rem !important; + box-sizing: border-box !important; + } + + .fi-body.fi-body-has-navigation .fi-main-ctn > main { + height: calc(100vh - 4.5rem) !important; + height: calc(100dvh - 4.5rem) !important; + overflow-y: auto !important; + } } .fi-sidebar-group-btn, diff --git a/resources/views/filament/components/legacy-admin-assets.blade.php b/resources/views/filament/components/legacy-admin-assets.blade.php index 12b954f..f2c5e69 100644 --- a/resources/views/filament/components/legacy-admin-assets.blade.php +++ b/resources/views/filament/components/legacy-admin-assets.blade.php @@ -3,9 +3,70 @@