*/ 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; public ?string $movimentiFocusTo = null; public int $riconciliazioneOffset = 0; public string $periodoTipo = 'mese'; public int $periodoAnno; public int $periodoValore; public ?string $periodoRate = null; /** @var array}> */ protected array $periodoRateMap = []; protected static ?bool $supportsWindowFunctions = null; protected static ?string $navigationLabel = 'Movimenti banca'; protected static ?string $title = 'Movimenti banca'; protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-banknotes'; protected static UnitEnum|string|null $navigationGroup = 'Contabilità'; protected static ?int $navigationSort = 11; protected static ?string $slug = 'contabilita/casse-banche/movimenti'; protected string $view = 'filament.pages.contabilita.casse-banche-movimenti'; public static function canAccess(): bool { $user = Auth::user(); if (! $user instanceof User) { return false; } if ($user->hasAnyRole(['super-admin', 'admin'])) { return true; } return $user->can('contabilita.casse-banche'); } public function mount(): void { $today = now(); $this->periodoAnno = (int) $today->year; $this->periodoValore = (int) $today->month; $this->hubTab = $this->normalizeHubTabValue($this->hubTab); $this->contoId = is_numeric(request()->query('conto_id')) ? (int) request()->query('conto_id') : null; $this->contoId = $this->contoId && $this->contoId > 0 ? $this->contoId : null; $this->iban = is_string(request()->query('iban')) ? trim((string) request()->query('iban')) : null; $this->iban = $this->iban !== '' ? $this->iban : null; $this->mountInteractsWithTable(); // Risolvi conto selezionato: preferisci conto_id, altrimenti iban->conto_id. $user = Auth::user(); if ($user instanceof User) { $stabileId = StabileContext::resolveActiveStabileId($user); if ($stabileId) { if ($this->contoId) { $conto = DatiBancari::query() ->where('stabile_id', $stabileId) ->where('id', $this->contoId) ->first(['id', 'iban']); if (! $conto) { $this->contoId = null; } else { $iban = is_string($conto->iban) ? trim((string) $conto->iban) : ''; $this->iban = $iban !== '' ? $iban : null; } } if (! $this->contoId && $this->iban) { $conto = DatiBancari::query() ->where('stabile_id', $stabileId) ->where('iban', $this->iban) ->orderBy('id') ->first(['id']); if ($conto) { $this->contoId = (int) $conto->id; } } } } // Default: primo conto con IBAN (mantiene comportamento "movimenti banca" predefinito). if ($this->contoId === null && $this->iban === null) { $first = $this->getDefaultIban(); $this->iban = $first !== '' ? $first : null; if ($user instanceof User) { $stabileId = StabileContext::resolveActiveStabileId($user); if ($stabileId && $this->iban) { $conto = DatiBancari::query() ->where('stabile_id', $stabileId) ->where('iban', $this->iban) ->orderBy('id') ->first(['id']); if ($conto) { $this->contoId = (int) $conto->id; } } } } if ($this->contoId !== null) { $this->syncSelectedContoFromId($this->contoId); } } public function updatedHubTab(): void { $this->hubTab = $this->normalizeHubTabValue($this->hubTab); } public function updatedContoId(mixed $value): void { $contoId = is_numeric($value) ? (int) $value : null; $this->syncSelectedContoFromId($contoId); $this->resetContoDependentState(); } public function goToHubTab(string $tab): void { $this->hubTab = $this->normalizeHubTabValue($tab); } public function selectContoAndTab(int $contoId, string $tab = 'movimenti'): void { $this->syncSelectedContoFromId($contoId); $this->resetContoDependentState(); $this->hubTab = $this->normalizeHubTabValue($tab); } public function clearMovimentiFocus(): void { $this->movimentiFocusFrom = null; $this->movimentiFocusTo = null; $this->resetTable(); } public function openSaldoPeriodo(int $saldoId): void { $stabileId = $this->getActiveStabileId(); if (! $stabileId || ! DB::getSchemaBuilder()->hasTable('contabilita_saldi_conti')) { return; } $saldo = SaldoConto::query() ->where('stabile_id', $stabileId) ->whereKey($saldoId) ->first(['id', 'conto_id', 'iban', 'data_saldo', 'periodo_da', 'periodo_a']); if (! $saldo) { return; } if (is_numeric($saldo->conto_id)) { $this->syncSelectedContoFromId((int) $saldo->conto_id); } else { $this->contoId = null; $this->iban = $this->normalizeIbanValue($saldo->iban); } $from = $saldo->periodo_da?->toDateString(); $to = $saldo->periodo_a?->toDateString(); if (! $from && $saldo->data_saldo) { $from = $saldo->data_saldo->copy()->startOfMonth()->toDateString(); } if (! $to && $saldo->data_saldo) { $to = $saldo->data_saldo->copy()->endOfMonth()->toDateString(); } $this->movimentiFocusFrom = $from; $this->movimentiFocusTo = $to; $this->applyPeriodoStateFromFocusRange(); $this->hubTab = 'movimenti'; $this->resetTable(); } public function 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(); if (! $user instanceof User) { return ['entrate' => 0, 'uscite' => 0, 'saldo' => 0, 'count' => 0, 'label' => '']; } $stabileId = StabileContext::resolveActiveStabileId($user); if (! $stabileId) { return ['entrate' => 0, 'uscite' => 0, 'saldo' => 0, 'count' => 0, 'label' => '']; } $anno = (int) ($this->periodoAnno ?: now()->year); $val = (int) ($this->periodoValore ?: now()->month); $start = null; $end = null; $label = ''; if ($this->periodoTipo === 'trimestre') { $q = max(1, min(4, $val)); $start = Carbon::create($anno, (($q - 1) * 3) + 1, 1)->startOfDay(); $end = (clone $start)->addMonths(3)->subDay()->endOfDay(); $label = 'Trimestre ' . $q . '/' . $anno; } else { $m = max(1, min(12, $val)); $start = Carbon::create($anno, $m, 1)->startOfDay(); $end = (clone $start)->endOfMonth()->endOfDay(); $label = $start->format('m/Y'); } $query = MovimentoBanca::query() ->where('stabile_id', $stabileId) ->when($this->contoId !== null, fn(Builder $q): Builder => $q->where('conto_id', $this->contoId)) ->when($this->contoId === null && $this->iban !== null, fn(Builder $q): Builder => $q->where('iban', $this->iban)) ->whereDate('data', '>=', $start->toDateString()) ->whereDate('data', '<=', $end->toDateString()); $entrate = (float) $query->sum(DB::raw('CASE WHEN importo > 0 THEN importo ELSE 0 END')); $uscite = (float) $query->sum(DB::raw('CASE WHEN importo < 0 THEN ABS(importo) ELSE 0 END')); $count = (int) $query->count(); return [ 'entrate' => $entrate, 'uscite' => $uscite, 'saldo' => $entrate - $uscite, 'count' => $count, 'label' => $label, ]; } public function getPeriodoOptionsProperty(): array { $months = []; for ($m = 1; $m <= 12; $m++) { $months[$m] = str_pad((string) $m, 2, '0', STR_PAD_LEFT); } return [ 'mese' => $months, 'trimestre' => [1 => 'T1', 2 => 'T2', 3 => 'T3', 4 => 'T4'], ]; } public function getPeriodoRateOptionsProperty(): array { $loaded = $this->loadPeriodoRateOptions(); $options = $loaded['options']; if ($this->periodoRate === null && ! empty($options)) { $this->periodoRate = array_key_first($options); } return $options; } public function getPeriodoRateTotaliProperty(): array { $user = Auth::user(); if (! $user instanceof User) { return ['entrate' => 0, 'uscite' => 0, 'saldo' => 0, 'count' => 0, 'label' => '']; } $stabileId = StabileContext::resolveActiveStabileId($user); if (! $stabileId) { return ['entrate' => 0, 'uscite' => 0, 'saldo' => 0, 'count' => 0, 'label' => '']; } $loaded = $this->loadPeriodoRateOptions(); $options = $loaded['options']; $map = $loaded['map']; $selected = $this->periodoRate; if (! $selected || ! isset($map[$selected])) { return ['entrate' => 0, 'uscite' => 0, 'saldo' => 0, 'count' => 0, 'label' => '']; } $rawValues = $map[$selected]['raw'] ?? []; if (empty($rawValues)) { return ['entrate' => 0, 'uscite' => 0, 'saldo' => 0, 'count' => 0, 'label' => (string) ($map[$selected]['label'] ?? $selected)]; } $query = MovimentoBanca::query() ->where('stabile_id', $stabileId) ->when($this->contoId !== null, fn(Builder $q): Builder => $q->where('conto_id', $this->contoId)) ->when($this->contoId === null && $this->iban !== null, fn(Builder $q): Builder => $q->where('iban', $this->iban)) ->whereRaw("JSON_UNQUOTE(JSON_EXTRACT(match_data, '$.periodo_rate')) in (" . implode(',', array_fill(0, count($rawValues), '?')) . ")", $rawValues); $entrate = (float) $query->sum(DB::raw('CASE WHEN importo > 0 THEN importo ELSE 0 END')); $uscite = (float) $query->sum(DB::raw('CASE WHEN importo < 0 THEN ABS(importo) ELSE 0 END')); $count = (int) $query->count(); return [ 'entrate' => $entrate, 'uscite' => $uscite, 'saldo' => $entrate - $uscite, 'count' => $count, 'label' => (string) ($map[$selected]['label'] ?? $selected), ]; } /** @return array{options:array, map:array}>} */ protected function loadPeriodoRateOptions(): array { if (! empty($this->periodoRateMap)) { $options = []; foreach ($this->periodoRateMap as $key => $payload) { $options[$key] = $payload['label']; } return ['options' => $options, 'map' => $this->periodoRateMap]; } $user = Auth::user(); if (! $user instanceof User) { return ['options' => [], 'map' => []]; } $stabileId = StabileContext::resolveActiveStabileId($user); if (! $stabileId) { return ['options' => [], 'map' => []]; } $values = MovimentoBanca::query() ->where('stabile_id', $stabileId) ->when($this->contoId !== null, fn(Builder $q): Builder => $q->where('conto_id', $this->contoId)) ->when($this->contoId === null && $this->iban !== null, fn(Builder $q): Builder => $q->where('iban', $this->iban)) ->whereRaw("JSON_EXTRACT(match_data, '$.periodo_rate') IS NOT NULL") ->selectRaw("DISTINCT JSON_UNQUOTE(JSON_EXTRACT(match_data, '$.periodo_rate')) AS periodo_rate") ->pluck('periodo_rate') ->filter(fn($v) => is_string($v) && trim($v) !== '') ->values() ->all(); $map = []; foreach ($values as $val) { $norm = $this->normalizePeriodoRate($val); if ($norm === null) { continue; } if (! isset($map[$norm])) { $map[$norm] = ['label' => $norm, 'raw' => []]; } $map[$norm]['raw'][] = $val; } ksort($map); $this->periodoRateMap = $map; $options = []; foreach ($map as $key => $payload) { $options[$key] = $payload['label']; } return ['options' => $options, 'map' => $map]; } protected function normalizePeriodoRate(?string $value): ?string { $v = trim((string) $value); if ($v === '') { return null; } $v = preg_replace('/\b(20)?\d{2}\b/', '', $v) ?? $v; $v = preg_replace('/\s{2,}/', ' ', $v) ?? $v; $v = trim($v, " \t\n\r\0\x0B-/"); return $v !== '' ? $v : null; } protected function getHeaderActions(): array { return [ Action::make('hub_conti') ->label('Tab conti') ->icon('heroicon-o-banknotes') ->action(fn() => $this->goToHubTab('conti')), Action::make('hub_saldi') ->label('Tab saldi') ->icon('heroicon-o-scale') ->action(fn() => $this->goToHubTab('saldi')), Action::make('hub_riconciliazione') ->label('Riconciliazione') ->icon('heroicon-o-sparkles') ->action(fn() => $this->goToHubTab('riconciliazione')), Action::make('registra_saldo_estratto') ->label('Registra saldo / estratto') ->icon('heroicon-o-document-duplicate') ->modalWidth('4xl') ->form([ Select::make('conto_id') ->label('Conto / cassa') ->native(false) ->searchable() ->options(function (): array { $opts = []; foreach ($this->getContiImportabili() as $conto) { $contoId = isset($conto['id']) && is_numeric($conto['id']) ? (int) $conto['id'] : 0; if ($contoId <= 0) { continue; } $opts[$contoId] = (string) ($conto['label'] ?? ('Conto #' . $contoId)); } return $opts; }) ->default(fn(): ?int => $this->contoId) ->required(), DatePicker::make('data_saldo') ->label('Data saldo ufficiale') ->required(), TextInput::make('saldo') ->label('Saldo ufficiale') ->numeric() ->required(), DatePicker::make('periodo_da') ->label('Periodo estratto dal'), DatePicker::make('periodo_a') ->label('Periodo estratto al'), Select::make('tipo_estratto') ->label('Tipo riferimento') ->native(false) ->options($this->getTipoEstrattoOptions()) ->default('estratto_conto'), 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') ->disk('local') ->acceptedFileTypes([ 'application/pdf', 'text/plain', 'text/csv', 'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', '.pdf', '.csv', '.txt', '.qif', '.xlsx', ]), Textarea::make('note') ->label('Note') ->rows(3) ->maxLength(255), ]) ->action(function (array $data): void { $user = Auth::user(); if (! $user instanceof User) { Notification::make()->title('Utente non valido')->danger()->send(); return; } $stabileId = StabileContext::resolveActiveStabileId($user); if (! $stabileId) { Notification::make()->title('Seleziona uno stabile')->danger()->send(); return; } $contoId = isset($data['conto_id']) && is_numeric($data['conto_id']) ? (int) $data['conto_id'] : 0; if ($contoId <= 0) { Notification::make()->title('Seleziona un conto valido')->danger()->send(); return; } $conto = DatiBancari::query() ->where('stabile_id', $stabileId) ->where('id', $contoId) ->first(['id', 'denominazione_banca', 'iban', 'numero_conto', 'legacy_cod_cassa']); if (! $conto) { Notification::make()->title('Conto non valido per lo stabile attivo')->danger()->send(); return; } $documento = $this->storeSaldoDocumento($stabileId, $conto, $data['documento'] ?? null, $user, $data); $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); Notification::make() ->title('Saldo ufficiale registrato') ->success() ->send(); }), Action::make('importa_estratto_unificato') ->label('Importa estratto') ->icon('heroicon-o-arrow-up-tray') ->modalWidth('7xl') ->form([ Select::make('conto_id') ->label('Conto / cassa') ->native(false) ->searchable() ->options(function (): array { $opts = []; foreach ($this->getContiImportabili() as $c) { $contoId = isset($c['id']) && is_numeric($c['id']) ? (int) $c['id'] : null; if (! $contoId) { continue; } $opts[$contoId] = (string) ($c['label'] ?? ('Conto #' . $contoId)); } return $opts; }) ->default(fn(): ?int => $this->contoId) ->required() ->helperText('Obbligatorio: l\'import viene sempre legato al conto/cassa selezionato, usando il codice legacy quando presente.'), Select::make('formato') ->label('Formato file') ->native(false) ->options(function (): array { return [ 'auto_csv' => 'CSV banca (rilevamento automatico)', 'mps_qif' => 'MPS QIF', 'unicredit_wri' => 'Unicredit WRI', 'intesa_csv' => 'Intesa CSV', 'xlsx' => 'Excel XLSX', ]; }) ->default(fn(): string => $this->resolvePreferredImportFormatForConto($this->contoId)) ->required() ->helperText('Un solo flusso di import: scegli il formato corretto per il conto selezionato. Per Monte dei Paschi usa QIF.'), 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) { return []; } if (! DB::getSchemaBuilder()->hasTable('gestioni_contabili')) { return []; } return $this->getVisibleGestioneOptions($stabileId); }) ->default(function (): ?string { $user = Auth::user(); if (! $user instanceof User) { return null; } $stabileId = StabileContext::resolveActiveStabileId($user); if (! $stabileId) { return null; } return $this->resolveDefaultGestioneId($stabileId); }), FileUpload::make('file') ->label('File estratto') ->directory('tmp/banca') ->disk('local') ->acceptedFileTypes([ 'text/plain', 'text/csv', 'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', '.csv', '.txt', '.wri', '.qif', '.xlsx', ]) ->required(), Toggle::make('replace') ->label('Sostituisci import nel periodo') ->helperText('Elimina SOLO i movimenti importati e NON riconciliati per lo stesso conto e per l\'intervallo date del file, poi reimporta.') ->default(false), ]) ->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'] : null; if (! $contoId) { Notification::make()->title('Seleziona un conto o una cassa')->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; } $path = (string) ($data['file'] ?? ''); if ($path === '') { Notification::make()->title('Seleziona un file')->danger()->send(); return; } $format = isset($data['formato']) && is_string($data['formato']) ? trim((string) $data['formato']) : ''; if ($format === '') { Notification::make()->title('Seleziona il formato del file')->danger()->send(); return; } $gestioneId = isset($data['gestione_id']) && is_numeric($data['gestione_id']) ? (int) $data['gestione_id'] : null; $replace = isset($data['replace']) ? (bool) $data['replace'] : false; try { $this->archiveImportedBankSourceFile($conto, $path, $gestioneId, $format); $importer = $this->makeMovimentiImporter(); $filename = basename($path); $res = match ($format) { 'xlsx' => $importer->importExcelXlsxForConto( Storage::disk('local')->path($path), $stabileId, $contoId, $filename, $gestioneId, $replace, ), 'unicredit_wri' => $importer->importUnicreditWriForConto( Storage::disk('local')->get($path), $stabileId, $contoId, $filename, $gestioneId, $replace, ), 'intesa_csv' => $importer->importIntesaCsvForConto( Storage::disk('local')->get($path), $stabileId, $contoId, $filename, $gestioneId, $replace, ), 'mps_qif' => $importer->importMpsQifForConto( Storage::disk('local')->get($path), $stabileId, $contoId, $filename, $gestioneId, $replace, ), default => $importer->importCsvAutoForConto( Storage::disk('local')->get($path), $stabileId, $contoId, $filename, $gestioneId, $replace, ), }; $this->contoId = $contoId; $iban = is_string($conto->iban) ? trim((string) $conto->iban) : ''; $this->iban = $iban !== '' ? $iban : null; Notification::make() ->title('Import completato') ->body('Formato: ' . $format . ' · Importati: ' . $res['imported'] . ' · Duplicati: ' . $res['duplicates']) ->success() ->send(); } catch (\Throwable $e) { Notification::make()->title('Errore import')->body($e->getMessage())->danger()->send(); } finally { try { Storage::disk('local')->delete($path); } catch (\Throwable) { // ignore } } }), 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(); }), ]; } protected function getTableQuery(): Builder { $user = Auth::user(); if (! $user instanceof User) { return MovimentoBanca::query()->whereRaw('1 = 0'); } $activeStabileId = StabileContext::resolveActiveStabileId($user); if (! $activeStabileId) { return MovimentoBanca::query()->whereRaw('1 = 0'); } $query = MovimentoBanca::query() ->with('gestioneContabile') ->where('stabile_id', $activeStabileId) ->when($this->contoId !== null, fn(Builder $q): Builder => $q->where('conto_id', $this->contoId)) ->when($this->contoId === null && $this->iban !== null, fn(Builder $q): Builder => $q->where('iban', $this->iban)) ->when($this->movimentiFocusFrom, fn(Builder $q): Builder => $q->whereDate('data', '>=', $this->movimentiFocusFrom)) ->when($this->movimentiFocusTo, fn(Builder $q): Builder => $q->whereDate('data', '<=', $this->movimentiFocusTo)) ->orderBy('data') ->orderBy('id'); if ($this->dbSupportsWindowFunctions()) { $query->select('contabilita_movimenti_banca.*') ->selectRaw( "SUM(importo) OVER (PARTITION BY stabile_id, COALESCE(iban, '') ORDER BY data, id) AS saldo_progressivo" ); } return $query; } public function table(Table $table): Table { return $table ->striped() ->filters([ Filter::make('ricerca_descrizione') ->label('Ricerca') ->form([ TextInput::make('q') ->label('Descrizione') ->placeholder('Es. Basta Monica') ->autocomplete(false), ]) ->query(function (Builder $query, array $data): Builder { $q = trim((string) ($data['q'] ?? '')); if ($q === '') { return $query; } $qLike = '%' . mb_strtolower($q) . '%'; return $query->where(function (Builder $sub) use ($qLike): Builder { $sub->whereRaw('LOWER(descrizione) LIKE ?', [$qLike]) ->orWhereRaw('LOWER(descrizione_estesa) LIKE ?', [$qLike]) ->orWhereRaw('LOWER(note) LIKE ?', [$qLike]); if (Schema::hasColumn('contabilita_movimenti_banca', 'mittente')) { $sub->orWhereRaw('LOWER(mittente) LIKE ?', [$qLike]); } if (Schema::hasColumn('contabilita_movimenti_banca', 'intestatario')) { $sub->orWhereRaw('LOWER(intestatario) LIKE ?', [$qLike]); } if (Schema::hasColumn('contabilita_movimenti_banca', 'ordinante')) { $sub->orWhereRaw('LOWER(ordinante) LIKE ?', [$qLike]); } return $sub; }); }), Filter::make('periodo') ->label('Periodo') ->form([ DatePicker::make('from')->label('Dal'), DatePicker::make('to')->label('Al'), ]) ->query(function (Builder $query, array $data): Builder { $from = $data['from'] ?? null; $to = $data['to'] ?? null; return $query ->when($from, fn(Builder $q) => $q->whereDate('data', '>=', $from)) ->when($to, fn(Builder $q) => $q->whereDate('data', '<=', $to)); }), Filter::make('causale_bancaria') ->label('Causale') ->form([ Select::make('causale') ->label('Causale') ->native(false) ->searchable() ->options(function (): array { if (! DB::getSchemaBuilder()->hasTable('contabilita_causali_bancarie')) { return []; } return CausaleBancaria::query() ->where('attivo', true) ->orderBy('banca') ->orderBy('codice') ->get(['banca', 'codice', 'descrizione']) ->mapWithKeys(fn(CausaleBancaria $c) => [ (string) $c->codice => (string) $c->banca . ' · ' . (string) $c->codice . ' · ' . (string) $c->descrizione, ]) ->all(); }), ]) ->query(function (Builder $query, array $data): Builder { $code = $data['causale'] ?? null; $code = is_string($code) ? trim($code) : ''; if ($code === '') { return $query; } return $query->where('causale', $code); }), Filter::make('gestioni') ->label('Gestioni') ->form([ Select::make('gestione_ids') ->label('Gestione') ->multiple() ->options(function (): array { $user = Auth::user(); if (! $user instanceof User) { return []; } $stabileId = StabileContext::resolveActiveStabileId($user); if (! $stabileId) { return []; } if (! DB::getSchemaBuilder()->hasTable('gestioni_contabili')) { return []; } return $this->getVisibleGestioneOptions($stabileId); }) ->searchable(), ]) ->query(function (Builder $query, array $data): Builder { $ids = $data['gestione_ids'] ?? null; if (! is_array($ids) || $ids === []) { return $query; } $ids = array_values(array_filter(array_map('intval', $ids), fn(int $v) => $v > 0)); if ($ids === []) { return $query; } return $query->whereIn('gestione_id', $ids); }), ]) ->columns([ TextColumn::make('data') ->label('Data') ->date('d/m/Y') ->sortable(), IconColumn::make('riconciliazione_stato') ->label('Sem') ->alignCenter() ->icon(fn(MovimentoBanca $record): string => $this->getMovimentoSemaphoreMeta($record)['icon']) ->color(fn(MovimentoBanca $record): string => $this->getMovimentoSemaphoreMeta($record)['color']) ->tooltip(fn(MovimentoBanca $record): string => $this->getMovimentoSemaphoreMeta($record)['label']), TextColumn::make('valuta') ->label('Valuta') ->date('d/m/Y'), TextColumn::make('causale') ->label('Caus'), TextColumn::make('descrizione') ->label('Descrizione') ->state(fn(MovimentoBanca $record): string => (string) ($record->descrizione_estesa_pulita ?? $record->descrizione_estesa ?? $record->descrizione ?? '')) ->wrap() ->searchable(), TextColumn::make('gestioneContabile.denominazione') ->label('Gestione') ->toggleable(isToggledHiddenByDefault: true) ->toggledHiddenByDefault(), TextColumn::make('entrate') ->label('Entrate') ->alignEnd() ->state(fn(MovimentoBanca $record): float => max(0, (float) $record->importo)) ->formatStateUsing(fn($state) => $state > 0 ? ('€ ' . number_format((float) $state, 2, ',', '.')) : ''), TextColumn::make('uscite') ->label('Uscite') ->alignEnd() ->state(fn(MovimentoBanca $record): float => max(0, abs((float) $record->importo) * ((float) $record->importo < 0 ? 1 : 0))) ->formatStateUsing(fn($state) => $state > 0 ? ('€ ' . number_format((float) $state, 2, ',', '.')) : ''), TextColumn::make('saldo_progressivo') ->label('Saldo') ->alignEnd() ->getStateUsing(fn(MovimentoBanca $record): ?float => $this->resolveSaldoConSaldi($record)) ->formatStateUsing(fn($state) => $state !== null ? ('€ ' . number_format((float) $state, 2, ',', '.')) : ''), TextColumn::make('importo') ->label('Importo (raw)') ->alignEnd() ->toggleable(isToggledHiddenByDefault: true) ->formatStateUsing(fn($state) => '€ ' . number_format((float) $state, 2, ',', '.')), TextColumn::make('iban') ->label('IBAN') ->toggleable(isToggledHiddenByDefault: true) ->toggledHiddenByDefault(), ]) ->actions([ Action::make('dettaglio_movimento') ->hiddenLabel() ->icon('heroicon-o-eye') ->tooltip('Apri dati') ->modalWidth('7xl') ->action(function (MovimentoBanca $record): void { $this->detailMovementId = $record->id; }) ->modalHeading(fn(): string => 'Dettaglio movimento') ->modalContent(function () { $record = $this->getDetailMovement(); return view('filament.pages.contabilita.partials.movimento-banca-dettaglio', [ 'record' => $record, 'prevId' => $this->getAdjacentMovementId('prev'), 'nextId' => $this->getAdjacentMovementId('next'), ]); }), Action::make('conferma_movimento') ->hiddenLabel() ->icon('heroicon-o-check-circle') ->tooltip('Conferma') ->visible(fn(MovimentoBanca $record): bool => (bool) ($record->da_confermare ?? false)) ->action(function (MovimentoBanca $record): void { if (! Schema::hasColumn('contabilita_movimenti_banca', 'da_confermare')) { return; } $record->da_confermare = false; $record->save(); Notification::make()->title('Movimento confermato')->success()->send(); $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 $this->getVisibleGestioneOptions($stabileId); }), 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') ->hiddenLabel() ->icon('heroicon-o-book-open') ->tooltip('Genera prima nota') ->visible(function (MovimentoBanca $record): bool { if (! Schema::hasColumn('contabilita_movimenti_banca', 'registrazione_id')) { return false; } return empty($record->registrazione_id); }) ->form([ Select::make('gestione_id') ->label('Gestione') ->options(function (): array { if (! DB::getSchemaBuilder()->hasTable('gestioni_contabili')) { return []; } $user = Auth::user(); if (! $user instanceof User) { return []; } $stabileId = StabileContext::resolveActiveStabileId($user); if (! $stabileId) { return []; } return $this->getVisibleGestioneOptions($stabileId); }) ->searchable() ->visible(fn(MovimentoBanca $record) => empty($record->gestione_id)) ->required(fn(MovimentoBanca $record) => empty($record->gestione_id)), ]) ->action(function (MovimentoBanca $record, array $data): void { $user = Auth::user(); if (! $user instanceof User) { Notification::make()->title('Utente non valido')->danger()->send(); return; } if (empty($record->gestione_id) && isset($data['gestione_id']) && is_numeric($data['gestione_id'])) { $record->gestione_id = (int) $data['gestione_id']; $record->save(); } try { $service = app(MovimentoBancaPrimaNotaService::class); $service->generaRegistrazioneDaMovimento($user, $record); Notification::make()->title('Prima nota generata')->success()->send(); $this->resetTable(); } catch (\Throwable $e) { Notification::make()->title('Errore')->body($e->getMessage())->danger()->send(); } }), Action::make('paga_fornitore') ->label('Paga fornitore') ->icon('heroicon-o-banknotes') ->visible(function (MovimentoBanca $record): bool { if (! Schema::hasColumn('contabilita_movimenti_banca', 'registrazione_id')) { return false; } if (! Schema::hasTable('contabilita_registrazioni') || ! Schema::hasTable('contabilita_movimenti')) { return false; } $importo = (float) ($record->importo ?? 0); if ($importo >= 0) { return false; } return empty($record->registrazione_id); }) ->form([ Select::make('gestione_id') ->label('Gestione') ->options(function (): array { if (! DB::getSchemaBuilder()->hasTable('gestioni_contabili')) { return []; } $user = Auth::user(); if (! $user instanceof User) { return []; } $stabileId = StabileContext::resolveActiveStabileId($user); if (! $stabileId) { return []; } return $this->getVisibleGestioneOptions($stabileId); }) ->searchable() ->visible(fn(MovimentoBanca $record) => empty($record->gestione_id)) ->required(fn(MovimentoBanca $record) => empty($record->gestione_id)), Select::make('fornitore_id') ->label('Fornitore') ->options(function (): array { $q = Fornitore::query(); if (Schema::hasColumn('fornitori', 'ragione_sociale')) { $q->orderBy('ragione_sociale'); } elseif (Schema::hasColumn('fornitori', 'cognome')) { $q->orderBy('cognome'); } return $q ->limit(500) ->get(['id', 'ragione_sociale', 'nome', 'cognome']) ->mapWithKeys(function (Fornitore $f): array { $label = is_string($f->ragione_sociale ?? null) && trim((string) $f->ragione_sociale) !== '' ? trim((string) $f->ragione_sociale) : trim(((string) ($f->cognome ?? '')) . ' ' . ((string) ($f->nome ?? ''))); $label = $label !== '' ? $label : ('Fornitore #' . (string) $f->id); return [(string) $f->id => $label]; }) ->all(); }) ->searchable() ->required(), Select::make('conto_finanziario_id') ->label('Conto finanziario (Banca/Cassa)') ->options(function (): array { $base = PianoConti::query(); if (Schema::hasColumn('contabilita_piano_conti', 'tipo')) { $base->where('tipo', 'Attività'); } $q = (clone $base)->where(function ($w) { $w->where('descrizione', 'like', '%Banca%') ->orWhere('descrizione', 'like', '%banca%') ->orWhere('descrizione', 'like', '%Cassa%') ->orWhere('descrizione', 'like', '%cassa%'); }); $rows = $q->orderBy('codice')->limit(500)->get(['id', 'codice', 'descrizione']); if ($rows->count() === 0) { $rows = $base->orderBy('codice')->limit(500)->get(['id', 'codice', 'descrizione']); } return $rows ->mapWithKeys(fn(PianoConti $c) => [(string) $c->id => $c->codice . ' - ' . $c->descrizione]) ->all(); }) ->searchable() ->required(), ]) ->action(function (MovimentoBanca $record, array $data): void { $user = Auth::user(); if (! $user instanceof User) { Notification::make()->title('Utente non valido')->danger()->send(); return; } $gestioneId = (empty($record->gestione_id) && isset($data['gestione_id']) && is_numeric($data['gestione_id'])) ? (int) $data['gestione_id'] : (int) ($record->gestione_id ?? 0); $fornitoreId = isset($data['fornitore_id']) && is_numeric($data['fornitore_id']) ? (int) $data['fornitore_id'] : 0; $contoFinId = isset($data['conto_finanziario_id']) && is_numeric($data['conto_finanziario_id']) ? (int) $data['conto_finanziario_id'] : 0; try { $service = app(PagamentoFornitorePrimaNotaService::class); $service->generaPagamentoDaMovimento($user, $record, $fornitoreId, [ 'gestione_id' => $gestioneId, 'conto_finanziario_id' => $contoFinId, ]); Notification::make()->title('Pagamento contabilizzato')->success()->send(); $this->resetTable(); } catch (\Throwable $e) { Notification::make()->title('Errore')->body($e->getMessage())->danger()->send(); } }), ]); } public function getDetailMovement(): ?MovimentoBanca { if (! $this->detailMovementId) { return null; } return MovimentoBanca::query()->where('id', $this->detailMovementId)->first(); } public function setDetailMovement(int $id): void { $this->detailMovementId = $id; } private function getAdjacentMovementId(string $dir): ?int { if (! $this->detailMovementId) { return null; } $current = MovimentoBanca::query()->where('id', $this->detailMovementId)->first(['id', 'data']); if (! $current) { return null; } $query = MovimentoBanca::query() ->when($this->contoId !== null, fn(Builder $q): Builder => $q->where('conto_id', $this->contoId)) ->when($this->contoId === null && $this->iban !== null, fn(Builder $q): Builder => $q->where('iban', $this->iban)); if ($dir === 'prev') { $query->where(function (Builder $q) use ($current): void { $q->where('data', '<', $current->data) ->orWhere(function (Builder $q2) use ($current): void { $q2->where('data', $current->data)->where('id', '<', $current->id); }); })->orderByDesc('data')->orderByDesc('id'); } else { $query->where(function (Builder $q) use ($current): void { $q->where('data', '>', $current->data) ->orWhere(function (Builder $q2) use ($current): void { $q2->where('data', $current->data)->where('id', '>', $current->id); }); })->orderBy('data')->orderBy('id'); } return $query->value('id'); } /** @return array */ protected function getActiveStabileIbans(): array { $user = Auth::user(); if (! $user instanceof User) { return []; } $stabile = StabileContext::getActiveStabile($user); if (! $stabile) { return []; } $ibans = []; foreach ( [ $stabile->iban_principale ?? null, $stabile->iban_secondario ?? null, $stabile->iban_condominio ?? null, ] as $iban ) { $iban = is_string($iban) ? trim($iban) : ''; if ($iban !== '') { $ibans[$iban] = $iban; } } return $ibans; } protected function resolveSaldoProgressivoConSaldoIniziale(MovimentoBanca $record): ?float { $base = $this->resolveSaldoProgressivo($record); if ($base === null) { return null; } $statePeriodo = $this->getTableFilterState('periodo'); $from = is_array($statePeriodo) ? ($statePeriodo['from'] ?? null) : null; if (! $from) { return $base; } $iban = is_string($this->iban) ? trim($this->iban) : ''; if ($iban === '') { return $base; } $saldoIniziale = $this->resolveSaldoIniziale((int) $record->stabile_id, $iban, (string) $from); if ($saldoIniziale === null) { return $base; } return $saldoIniziale + $base; } protected function resolveSaldoConSaldi(MovimentoBanca $record): ?float { $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; } // 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, $base['anchor_date'], $base['anchor_id'], $base['saldo'], ); 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 ?: '-'); if (array_key_exists($key, $cache)) { return (float) $cache[$key]; } $q = DatiBancari::query()->where('stabile_id', $stabileId); if ($contoId) { $q->where('id', $contoId); } elseif ($iban) { $q->where('iban', $iban); } else { $cache[$key] = 0.0; return 0.0; } $row = $q->first(['saldo_iniziale']); $cache[$key] = $row && is_numeric($row->saldo_iniziale) ? (float) $row->saldo_iniziale : 0.0; return (float) $cache[$key]; } 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; } if (! $contoId && ! $iban) { return 0.0; } static $saldiCache = []; $cacheKey = $stabileId . '|' . ($contoId ?: 0) . '|' . ($iban ?: '-'); if (! array_key_exists($cacheKey, $saldiCache)) { $q = SaldoConto::query()->where('stabile_id', $stabileId); if ($contoId) { $q->where('conto_id', $contoId); } elseif ($iban) { $q->where('iban', $iban); } $saldiCache[$cacheKey] = $q ->orderBy('data_saldo') ->orderBy('id') ->get(['id', 'data_saldo', 'saldo']) ->all(); } $saldi = $saldiCache[$cacheKey]; if (! is_array($saldi) || $saldi === []) { return 0.0; } $match = null; foreach ($saldi as $s) { 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 { break; } } if (! $match) { return 0.0; } static $sumToDateCache = []; $sumKey = $cacheKey . '|' . $match->data_saldo->toDateString() . '|' . (int) $match->id; if (! array_key_exists($sumKey, $sumToDateCache)) { $q = MovimentoBanca::query()->where('stabile_id', $stabileId); if ($contoId) { $q->where('conto_id', $contoId); } 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 = $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')) { return null; } static $cache = []; $key = $stabileId . '|' . $iban . '|' . $fromDate; if (array_key_exists($key, $cache)) { return $cache[$key]; } $row = SaldoConto::query() ->where('stabile_id', $stabileId) ->where('iban', $iban) ->whereDate('data_saldo', '<=', $fromDate) ->orderByDesc('data_saldo') ->orderByDesc('id') ->first(); $cache[$key] = $row ? (float) $row->saldo : null; return $cache[$key]; } protected function resolveStatoQuadratura(MovimentoBanca $record): string { if ($this->hasOperationalRiconciliazione($record)) { return 'collegato_operativo'; } if ($record->registrazione_id === null) { return 'non_collegato'; } try { $row = DB::table('contabilita_movimenti') ->where('registrazione_id', (int) $record->registrazione_id) ->selectRaw("SUM(CASE WHEN tipo = 'dare' THEN importo ELSE 0 END) AS dare") ->selectRaw("SUM(CASE WHEN tipo = 'avere' THEN importo ELSE 0 END) AS avere") ->first(); $dare = $row && isset($row->dare) ? (float) $row->dare : 0.0; $avere = $row && isset($row->avere) ? (float) $row->avere : 0.0; if (abs($dare - $avere) <= 0.01) { return 'quadrato'; } return 'collegato_non_quadrato'; } catch (\Throwable) { return 'collegato_non_quadrato'; } } protected function resolveDefaultGestioneId(int $stabileId): ?string { if (! DB::getSchemaBuilder()->hasTable('gestioni_contabili')) { return null; } $anno = AnnoGestioneContext::resolveActiveAnno(); $tipo = GestioneContext::resolveActiveGestione(); $stabile = Stabile::query()->find($stabileId); $match = GestioneContabile::query() ->where('stabile_id', $stabileId) ->where('anno_gestione', $anno) ->where('tipo_gestione', $tipo) ->where('stato', 'aperta') ->orderByDesc('gestione_attiva') ->orderByDesc('id') ->first(); if ($match && ! $this->isGestioneVisibleForStabile($match, $stabile)) { $match = null; } if (! $match) { $match = GestioneContabile::query() ->where('stabile_id', $stabileId) ->where('stato', 'aperta') ->orderByDesc('gestione_attiva') ->orderByDesc('id') ->first(); if ($match && ! $this->isGestioneVisibleForStabile($match, $stabile)) { $match = GestioneContabile::query() ->where('stabile_id', $stabileId) ->where('stato', 'aperta') ->get(['id', 'denominazione', 'tipo_gestione', 'anno_gestione', 'numero_straordinaria', 'protocollo_prefix', 'stato', 'gestione_attiva']) ->first(fn(GestioneContabile $gestione): bool => $this->isGestioneVisibleForStabile($gestione, $stabile)); } } return $match ? (string) $match->id : null; } /** @return array */ protected function getVisibleGestioneOptions(int $stabileId): array { $stabile = Stabile::query()->find($stabileId); return GestioneContabile::query() ->where('stabile_id', $stabileId) ->where('stato', 'aperta') ->orderByDesc('anno_gestione') ->orderBy('tipo_gestione') ->orderBy('numero_straordinaria') ->orderByDesc('id') ->get(['id', 'denominazione', 'tipo_gestione', 'anno_gestione', 'numero_straordinaria', 'protocollo_prefix', 'stato']) ->filter(fn(GestioneContabile $gestione): bool => $this->isGestioneVisibleForStabile($gestione, $stabile)) ->mapWithKeys(fn(GestioneContabile $gestione): array=> [(string) $gestione->id => $this->formatGestioneLabel($gestione, $stabile)]) ->all(); } protected function isGestioneVisibleForStabile(GestioneContabile $gestione, ?Stabile $stabile): bool { if (trim((string) ($gestione->stato ?? '')) !== 'aperta') { return false; } if (! $stabile instanceof Stabile) { return true; } if ((string) ($gestione->tipo_gestione ?? '') === 'riscaldamento' && ! $stabile->hasOperationalHeating()) { return false; } $year = is_numeric($gestione->anno_gestione) ? (int) $gestione->anno_gestione : null; return $stabile->isOperationalYearVisible($year); } protected function formatGestioneLabel(GestioneContabile $gestione, ?Stabile $stabile): string { $label = trim((string) ($gestione->denominazione ?? '')); if ($label === '') { $label = (string) $gestione->anno_gestione . ' - ' . (string) $gestione->tipo_gestione; } if (is_numeric($gestione->numero_straordinaria) && (int) $gestione->numero_straordinaria > 0) { $label .= ' #' . (int) $gestione->numero_straordinaria; } $prefix = trim((string) ($gestione->protocollo_prefix ?? '')); if ($prefix !== '') { $label = $prefix . ' · ' . $label; } if ($stabile instanceof Stabile && $stabile->getTakeoverDate()) { $year = is_numeric($gestione->anno_gestione) ? (int) $gestione->anno_gestione : null; if (is_int($year) && $year < (int) $stabile->getTakeoverDate()->year) { $label .= ' · pre-presa in carico'; } } return $label; } /** @return array{icon:string,color:string,label:string} */ protected function getMovimentoSemaphoreMeta(MovimentoBanca $record): array { if (! empty($record->registrazione_id) || $this->hasOperationalRiconciliazione($record)) { return ['icon' => 'heroicon-m-check-circle', 'color' => 'success', 'label' => 'Riconciliato']; } if ((bool) ($record->da_confermare ?? false)) { return ['icon' => 'heroicon-m-exclamation-triangle', 'color' => 'warning', 'label' => 'Da confermare']; } return ['icon' => 'heroicon-m-minus-circle', 'color' => 'gray', 'label' => 'Da lavorare']; } 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)) { return is_numeric($record->saldo_progressivo) ? (float) $record->saldo_progressivo : null; } // Fallback (DB senza window functions): calcolo best-effort sul conto (IBAN) fino a questa riga. $q = MovimentoBanca::query() ->where('stabile_id', (int) $record->stabile_id) ->where(function (Builder $q) use ($record) { if ($record->iban === null) { $q->whereNull('iban'); } else { $q->where('iban', $record->iban); } }) ->where(function (Builder $q) use ($record) { $q->whereDate('data', '<', $record->data->toDateString()) ->orWhere(function (Builder $q) use ($record) { $q->whereDate('data', '=', $record->data->toDateString()) ->where('id', '<=', (int) $record->id); }); }); return (float) ($q->sum('importo') ?? 0); } protected function dbSupportsWindowFunctions(): bool { if (self::$supportsWindowFunctions !== null) { return self::$supportsWindowFunctions; } try { $driver = DB::getDriverName(); if ($driver === 'pgsql' || $driver === 'sqlite') { return self::$supportsWindowFunctions = true; } if ($driver !== 'mysql') { return self::$supportsWindowFunctions = false; } $row = DB::selectOne('select version() as v'); $v = is_object($row) && isset($row->v) ? (string) $row->v : ''; $vLower = strtolower($v); if (str_contains($vLower, 'mariadb')) { // MariaDB window functions: 10.2+ if (preg_match('/(\d+)\.(\d+)\.(\d+)/', $v, $m)) { $major = (int) $m[1]; $minor = (int) $m[2]; return self::$supportsWindowFunctions = ($major > 10) || ($major === 10 && $minor >= 2); } return self::$supportsWindowFunctions = false; } // MySQL window functions: 8.0+ if (preg_match('/(\d+)\.(\d+)\.(\d+)/', $v, $m)) { $major = (int) $m[1]; $minor = (int) $m[2]; return self::$supportsWindowFunctions = ($major > 8) || ($major === 8 && $minor >= 0); } return self::$supportsWindowFunctions = false; } catch (\Throwable) { return self::$supportsWindowFunctions = false; } } public function getActiveStabile(): ?Stabile { $user = Auth::user(); if (! $user instanceof User) { return null; } return StabileContext::getActiveStabile($user); } protected function getActiveStabileId(): ?int { $user = Auth::user(); if (! $user instanceof User) { return null; } return StabileContext::resolveActiveStabileId($user); } protected function normalizeHubTabValue(?string $value): string { $value = is_string($value) ? trim(strtolower($value)) : ''; return in_array($value, ['conti', 'saldi', 'movimenti', 'struttura', 'riconciliazione'], true) ? $value : 'conti'; } protected function syncSelectedContoFromId(?int $contoId): void { $contoId = $contoId && $contoId > 0 ? $contoId : null; $this->contoId = $contoId; if (! $contoId) { $this->iban = null; return; } $stabileId = $this->getActiveStabileId(); if (! $stabileId) { $this->contoId = null; $this->iban = null; return; } $conto = DatiBancari::query() ->where('stabile_id', $stabileId) ->where('id', $contoId) ->first(['id', 'iban']); if (! $conto) { $this->contoId = null; $this->iban = null; return; } $this->iban = $this->normalizeIbanValue($conto->iban); } protected function resetContoDependentState(): void { $this->periodoRateMap = []; $this->periodoRate = null; $this->riconciliazioneOffset = 0; $this->clearMovimentiFocus(); } protected function applyPeriodoStateFromFocusRange(): void { if (! $this->movimentiFocusFrom || ! $this->movimentiFocusTo) { return; } try { $from = Carbon::parse($this->movimentiFocusFrom); $to = Carbon::parse($this->movimentiFocusTo); } catch (\Throwable) { return; } $this->periodoAnno = (int) $from->year; $quarterStart = $from->copy()->startOfQuarter(); $quarterEnd = $from->copy()->endOfQuarter(); if ($from->isSameDay($quarterStart) && $to->isSameDay($quarterEnd)) { $this->periodoTipo = 'trimestre'; $this->periodoValore = (int) $from->quarter; return; } $this->periodoTipo = 'mese'; $this->periodoValore = (int) $from->month; } /** @return array> */ public function getHubContiRows(): array { $stabileId = $this->getActiveStabileId(); if (! $stabileId) { return []; } $rows = []; foreach ($this->getContiImportabili() as $conto) { $contoId = isset($conto['id']) && is_numeric($conto['id']) ? (int) $conto['id'] : 0; if ($contoId <= 0) { continue; } $iban = $this->normalizeIbanValue($conto['iban'] ?? null); $latestSaldo = $this->getLatestSaldoSnapshotForConto($stabileId, $contoId, $iban); $lastMovement = MovimentoBanca::query() ->where('stabile_id', $stabileId) ->where('conto_id', $contoId) ->orderByDesc('data') ->orderByDesc('id') ->first(['data']); $rows[] = [ 'id' => $contoId, 'label' => (string) ($conto['label'] ?? ('Conto #' . $contoId)), 'iban' => $iban, 'saldo_attuale' => $this->resolveSaldoAttualeConto($stabileId, $contoId, $iban), 'saldo_snapshot' => $latestSaldo?->saldo !== null ? (float) $latestSaldo->saldo : null, 'snapshot_data' => $latestSaldo?->data_saldo?->format('d/m/Y'), 'snapshot_tipo' => $latestSaldo ? $this->formatTipoEstrattoLabel($latestSaldo->tipo_estratto) : null, 'movimenti_count' => MovimentoBanca::query()->where('stabile_id', $stabileId)->where('conto_id', $contoId)->count(), 'ultima_movimentazione' => $lastMovement?->data?->format('d/m/Y'), 'selected' => $this->contoId === $contoId, ]; } return $rows; } /** @return array> */ public function getSaldoArchivioByYear(): array { $stabileId = $this->getActiveStabileId(); if (! $stabileId || ! DB::getSchemaBuilder()->hasTable('contabilita_saldi_conti')) { return []; } if (! $this->contoId && ! $this->normalizeIbanValue($this->iban)) { return []; } $query = SaldoConto::query() ->with('documento') ->where('stabile_id', $stabileId); if ($this->contoId) { $query->where('conto_id', $this->contoId); } else { $query->where('iban', $this->normalizeIbanValue($this->iban)); } $groups = []; foreach ($query->orderByDesc('data_saldo')->orderByDesc('id')->get() as $saldo) { $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, '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'], ]; } krsort($groups); return array_values($groups); } /** @return array */ public function getRiconciliazioneStats(): array { $stabileId = $this->getActiveStabileId(); if (! $stabileId) { return ['totale' => 0, 'senza_prima_nota' => 0, 'collegati' => 0, 'da_confermare' => 0]; } $query = MovimentoBanca::query()->where('stabile_id', $stabileId); if ($this->contoId) { $query->where('conto_id', $this->contoId); } elseif ($this->iban) { $query->where('iban', $this->iban); } $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, 'senza_prima_nota' => $senzaPrimaNota, 'collegati' => $collegati, 'da_confermare' => $daConfermare, ]; } /** @return array> */ public function getRiconciliazioneQueue(): array { $stabileId = $this->getActiveStabileId(); if (! $stabileId) { return []; } $query = MovimentoBanca::query() ->where('stabile_id', $stabileId) ->when($this->contoId !== null, fn(Builder $q): Builder => $q->where('conto_id', $this->contoId)) ->when($this->contoId === null && $this->iban !== null, fn(Builder $q): Builder => $q->where('iban', $this->iban)) ->orderBy('data') ->orderBy('id'); if (Schema::hasColumn('contabilita_movimenti_banca', 'registrazione_id') || Schema::hasColumn('contabilita_movimenti_banca', 'da_confermare')) { $query->where(function (Builder $sub): void { if (Schema::hasColumn('contabilita_movimenti_banca', 'registrazione_id')) { $sub->whereNull('registrazione_id'); } if (Schema::hasColumn('contabilita_movimenti_banca', 'da_confermare')) { $sub->orWhere('da_confermare', true); } }); } return $query ->limit(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, 'has_registrazione' => ! empty($movimento->registrazione_id), 'match_label' => $this->getOperationalRiconciliazioneLabel($movimento), 'record' => $movimento, ]; }) ->all(); } /** @return array|null */ public function getRiconciliazioneCurrent(): ?array { $queue = $this->getRiconciliazioneQueue(); if ($queue === []) { $this->riconciliazioneOffset = 0; return null; } $max = max(0, count($queue) - 1); $this->riconciliazioneOffset = max(0, min($this->riconciliazioneOffset, $max)); return $queue[$this->riconciliazioneOffset] ?? null; } public function previousRiconciliazione(): void { $this->riconciliazioneOffset = max(0, $this->riconciliazioneOffset - 1); } public function nextRiconciliazione(): void { $queue = $this->getRiconciliazioneQueue(); if ($queue === []) { $this->riconciliazioneOffset = 0; return; } $this->riconciliazioneOffset = min(count($queue) - 1, $this->riconciliazioneOffset + 1); } /** @return array> */ public function getRiconciliazioneCandidates(): array { $current = $this->getRiconciliazioneCurrent(); if (! $current || ! ($current['record'] instanceof MovimentoBanca)) { return []; } $record = $current['record']; $stabileId = $this->getActiveStabileId(); if (! $stabileId) { return []; } if ((float) $record->importo >= 0) { return 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')) { return null; } $query = SaldoConto::query()->where('stabile_id', $stabileId); if ($contoId > 0) { $query->where('conto_id', $contoId); } elseif ($iban) { $query->where('iban', $iban); } else { return null; } return $query->orderByDesc('data_saldo')->orderByDesc('id')->first(); } protected function 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(); $query = MovimentoBanca::query() ->where('stabile_id', $stabileId) ->where('conto_id', $contoId); if ($dataBase) { $query->whereDate('data', '>', $dataBase); } return $saldoBase + (float) ($query->sum('importo') ?? 0.0); } protected function formatPeriodoSinteticoLabel(mixed $from, mixed $to, mixed $fallbackDate = null): string { $fromDate = $from instanceof Carbon ? $from : ($from ? Carbon::parse((string) $from) : null); $toDate = $to instanceof Carbon ? $to : ($to ? Carbon::parse((string) $to) : null); if ($fromDate && $toDate) { $quarterStart = $fromDate->copy()->startOfQuarter(); $quarterEnd = $fromDate->copy()->endOfQuarter(); if ($fromDate->isSameDay($quarterStart) && $toDate->isSameDay($quarterEnd)) { return 'T' . $fromDate->quarter . ' / ' . $fromDate->format('Y'); } if ($fromDate->isSameDay($fromDate->copy()->startOfMonth()) && $toDate->isSameDay($fromDate->copy()->endOfMonth())) { return $fromDate->format('m/Y'); } } if ($fallbackDate instanceof Carbon) { return $fallbackDate->format('m/Y'); } return $this->formatPeriodoEstrattoLabel($from, $to); } /** @return array> */ protected function buildIncassoCandidates(MovimentoBanca $movimento, int $stabileId): array { if (! Schema::hasTable('incassi_pagamenti')) { return []; } $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($dateColumn, [ $movimento->data->copy()->subDays(30)->toDateString(), $movimento->data->copy()->addDays(30)->toDateString(), ]) ->whereBetween($importoColumn, [$importo - 10, $importo + 10]) ->orderBy($dateColumn) ->limit(20) ->get() ->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, $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' => $candidateImporto, 'data' => $candidateDate?->format('d/m/Y') ?? '—', 'dettaglio' => $candidateLabel, 'motivo' => $score['motivo'], 'azione' => null, ]; }) ->sortByDesc('score') ->take(5) ->values() ->all(); } /** @return array> */ protected function buildAffittoCandidates(MovimentoBanca $movimento, int $stabileId): array { 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', [ $movimento->data->copy()->subDays(120)->toDateString(), $movimento->data->copy()->addDays(30)->toDateString(), ]) ->whereBetween('totale', [$importo - 15, $importo + 15]) ->orderByDesc('data_fattura') ->limit(20) ->get(['id', 'numero_fattura', 'data_fattura', 'totale', 'fornitore_denominazione', 'stato']) ->map(function (FatturaElettronica $fattura) use ($movimento, $descrizione, $importo): array { $score = $this->scoreCandidate( $importo, $movimento->data, $descrizione, (float) $fattura->totale, $fattura->data_fattura, trim((string) ($fattura->fornitore_denominazione ?? '')) . ' ' . trim((string) ($fattura->numero_fattura ?? '')) ); return [ 'tipo' => 'Fattura fornitore', 'titolo' => trim((string) ($fattura->fornitore_denominazione ?: 'Fattura')) . ' · n. ' . trim((string) ($fattura->numero_fattura ?: $fattura->id)), 'score' => $score['totale'], 'importo' => (float) $fattura->totale, 'data' => $fattura->data_fattura?->format('d/m/Y') ?? '—', 'dettaglio' => 'Stato FE: ' . trim((string) ($fattura->stato ?: '—')), 'motivo' => $score['motivo'], 'azione' => null, ]; }) ->sortByDesc('score') ->take(5) ->values() ->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 (empty($hints['sia']) && is_string($fattura->fornitore?->sia_effettivo ?? null) && trim((string) $fattura->fornitore->sia_effettivo) !== '') { $hints['sia'] = trim((string) $fattura->fornitore->sia_effettivo); } 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; $motivi = []; $diffImporto = abs($movimentoImporto - $candidateImporto); if ($diffImporto < 0.01) { $score += 50; $motivi[] = 'importo identico'; } elseif ($diffImporto <= 1) { $score += 42; $motivi[] = 'importo quasi identico'; } elseif ($diffImporto <= 5) { $score += 30; $motivi[] = 'importo vicino'; } elseif ($diffImporto <= 15) { $score += 15; } if ($movimentoData && $candidateData) { $candidateCarbon = $candidateData instanceof Carbon ? $candidateData : Carbon::parse((string) $candidateData); $days = abs($movimentoData->diffInDays($candidateCarbon)); if ($days === 0) { $score += 25; $motivi[] = 'stessa data'; } elseif ($days <= 3) { $score += 20; $motivi[] = 'entro 3 giorni'; } elseif ($days <= 10) { $score += 12; } elseif ($days <= 30) { $score += 6; } } $overlap = $this->calculateTokenOverlap($movimentoText, $candidateText); if ($overlap >= 3) { $score += 25; $motivi[] = 'descrizione coerente'; } elseif ($overlap === 2) { $score += 18; } elseif ($overlap === 1) { $score += 8; } return [ 'totale' => min(100, $score), 'motivo' => $motivi !== [] ? implode(' · ', $motivi) : 'match debole da confermare', ]; } protected function calculateTokenOverlap(string $left, string $right): int { $leftTokens = $this->extractMeaningfulTokens($left); $rightTokens = $this->extractMeaningfulTokens($right); if ($leftTokens === [] || $rightTokens === []) { return 0; } return count(array_intersect($leftTokens, $rightTokens)); } /** @return array */ protected function extractMeaningfulTokens(string $value): array { $value = mb_strtolower($value); $value = preg_replace('/[^a-z0-9]+/u', ' ', $value) ?? $value; $parts = preg_split('/\s+/', trim($value)) ?: []; $stopWords = ['bonifico', 'pagamento', 'banca', 'conto', 'del', 'della', 'per', 'con', 'the', 'iban']; return array_values(array_unique(array_filter($parts, function (?string $token) use ($stopWords): bool { if (! is_string($token) || strlen($token) < 3) { return false; } return ! in_array($token, $stopWords, true); }))); } /** @return array */ public function getDisponibilitaFinanziarie(): array { $user = Auth::user(); if (! $user instanceof User) { return []; } $stabileId = StabileContext::resolveActiveStabileId($user); if (! $stabileId) { return []; } $conti = []; if (DB::getSchemaBuilder()->hasTable('dati_bancari')) { $conti = DatiBancari::query() ->where('stabile_id', $stabileId) ->whereNotNull('iban') ->where('iban', '!=', '') ->orderBy('denominazione_banca') ->orderBy('iban') ->get(['id', 'denominazione_banca', 'iban', 'saldo_iniziale']) ->map(fn(DatiBancari $r) => [ 'id' => (int) $r->id, 'iban' => trim((string) $r->iban), 'label' => trim((string) ($r->denominazione_banca ?: 'Conto') . ' · ' . (string) $r->iban), 'saldo_iniziale' => is_numeric($r->saldo_iniziale) ? (float) $r->saldo_iniziale : 0.0, ]) ->filter(fn(array $r) => $r['iban'] !== '') ->values() ->all(); } $sums = MovimentoBanca::query() ->where('stabile_id', $stabileId) ->whereNotNull('iban') ->where('iban', '!=', '') ->groupBy('iban') ->selectRaw('iban, COALESCE(SUM(importo), 0) AS saldo_movimenti') ->pluck('saldo_movimenti', 'iban') ->map(fn($v) => (float) $v) ->all(); $byIban = []; foreach ($conti as $c) { $iban = $c['iban']; $saldo = ($c['saldo_iniziale'] ?? 0.0) + ($sums[$iban] ?? 0.0); $byIban[$iban] = [ 'id' => $c['id'] ?? null, 'iban' => $iban, 'label' => (string) $c['label'], 'saldo' => $saldo, ]; } foreach ($sums as $iban => $saldoMov) { if (! isset($byIban[$iban])) { $byIban[$iban] = [ 'id' => null, 'iban' => (string) $iban, 'label' => (string) $iban, 'saldo' => (float) $saldoMov, ]; } } uasort($byIban, fn($a, $b) => strcmp((string) $a['label'], (string) $b['label'])); return array_values($byIban); } /** @return array */ public function getContiImportabili(): array { $user = Auth::user(); if (! $user instanceof User) { return []; } $stabileId = StabileContext::resolveActiveStabileId($user); if (! $stabileId || ! DB::getSchemaBuilder()->hasTable('dati_bancari')) { return []; } return DatiBancari::query() ->where('stabile_id', $stabileId) ->orderBy('denominazione_banca') ->orderBy('legacy_cod_cassa') ->orderBy('iban') ->orderBy('numero_conto') ->get(['id', 'denominazione_banca', 'iban', 'numero_conto', 'legacy_cod_cassa']) ->map(function (DatiBancari $conto): array { $iban = is_string($conto->iban) ? trim((string) $conto->iban) : null; $iban = $iban !== '' ? $iban : null; $numeroConto = is_string($conto->numero_conto) ? trim((string) $conto->numero_conto) : null; $numeroConto = $numeroConto !== '' ? $numeroConto : null; $legacyCodCassa = is_string($conto->legacy_cod_cassa) ? strtoupper(trim((string) $conto->legacy_cod_cassa)) : null; $legacyCodCassa = $legacyCodCassa !== '' ? $legacyCodCassa : null; $denominazione = is_string($conto->denominazione_banca) ? trim((string) $conto->denominazione_banca) : null; $denominazione = $denominazione !== '' ? $denominazione : null; $parts = []; $parts[] = $denominazione ?: 'Conto'; if ($legacyCodCassa) { $parts[] = 'cassa ' . $legacyCodCassa; } if ($iban) { $parts[] = $iban; } elseif ($numeroConto) { $parts[] = 'n. ' . $numeroConto; } return [ 'id' => (int) $conto->id, 'iban' => $iban, 'label' => implode(' · ', $parts), 'legacy_cod_cassa' => $legacyCodCassa, 'numero_conto' => $numeroConto, 'denominazione_banca' => $denominazione, ]; }) ->values() ->all(); } /** @return array{id:int,iban:?string,label:string,legacy_cod_cassa:?string,numero_conto:?string,denominazione_banca:?string}|null */ public function getSelectedContoImportInfo(): ?array { if (! $this->contoId) { return null; } foreach ($this->getContiImportabili() as $conto) { if ((int) ($conto['id'] ?? 0) === $this->contoId) { return $conto; } } 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) { return 'auto_csv'; } foreach ($this->getContiImportabili() as $conto) { if ((int) ($conto['id'] ?? 0) !== $contoId) { continue; } $haystack = strtolower(implode(' ', array_filter([ $conto['denominazione_banca'] ?? null, $conto['legacy_cod_cassa'] ?? null, $conto['numero_conto'] ?? null, ], static fn($value): bool => is_string($value) && trim($value) !== ''))); if (str_contains($haystack, 'mps') || str_contains($haystack, 'monte dei paschi') || str_contains($haystack, 'paschi')) { return 'mps_qif'; } if (str_contains($haystack, 'intesa')) { return 'intesa_csv'; } if (str_contains($haystack, 'unicredit')) { return 'unicredit_wri'; } return 'auto_csv'; } return 'auto_csv'; } protected function makeMovimentiImporter(): MovimentiBancaImporter { return new MovimentiBancaImporter( new UnicreditWriParser(), new IntesaCsvParser(), new ExcelMovimentiParser(), null, null, new MpsQifParser(), ); } 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) : ''; if ($iban === '') { return null; } foreach ($this->getDisponibilitaFinanziarie() as $c) { if (($c['iban'] ?? '') === $iban) { return (string) ($c['label'] ?? $iban); } } return $iban; } protected function getDefaultIban(): string { foreach ($this->getDisponibilitaFinanziarie() as $c) { $iban = is_string($c['iban'] ?? null) ? trim((string) $c['iban']) : ''; if ($iban !== '') { return $iban; } } return ''; } /** @return array{label:string,data:?string,saldo:?float} */ public function getHeaderSaldoInfo(): array { $user = Auth::user(); if (! $user instanceof User) { return ['label' => 'Movimenti', 'data' => null, 'saldo' => null]; } $stabileId = StabileContext::resolveActiveStabileId($user); if (! $stabileId) { return ['label' => 'Movimenti', 'data' => null, 'saldo' => null]; } $conto = null; if ($this->contoId) { $conto = DatiBancari::query() ->where('stabile_id', $stabileId) ->where('id', $this->contoId) ->first(['id', 'denominazione_banca', 'iban']); } elseif ($this->iban) { $conto = DatiBancari::query() ->where('stabile_id', $stabileId) ->where('iban', $this->iban) ->orderBy('id') ->first(['id', 'denominazione_banca', 'iban']); } $label = $conto ? $this->buildContoLabel($conto) : (string) (($this->getSelectedContoImportInfo()['label'] ?? null) ?: ($this->getSelectedIbanLabel() ?: 'Movimenti')); $movQ = MovimentoBanca::query()->where('stabile_id', $stabileId); if ($this->contoId) { $movQ->where('conto_id', $this->contoId); } elseif ($this->iban) { $movQ->where('iban', $this->iban); } $lastMov = $movQ->orderByDesc('data')->orderByDesc('id')->first(['data']); // Saldo attuale: usa l'ultimo saldo inserito come base (se presente), poi somma i movimenti successivi. $iban = $this->normalizeIbanValue($this->iban); if (! $this->contoId && ! $iban) { return ['label' => $label, 'data' => $lastMov ? $lastMov->data->format('d/m/Y') : null, 'saldo' => null]; } $saldoBase = null; $dataBase = null; $periodFrom = null; if (is_array($this->tableFilters ?? null)) { $periodFrom = $this->tableFilters['periodo']['from'] ?? null; $periodFrom = is_string($periodFrom) && trim($periodFrom) !== '' ? trim($periodFrom) : null; } if (DB::getSchemaBuilder()->hasTable('contabilita_saldi_conti')) { $qSaldo = SaldoConto::query()->where('stabile_id', $stabileId); if ($this->contoId) { $qSaldo->where('conto_id', $this->contoId); } elseif ($iban) { $qSaldo->where('iban', $iban); } if ($periodFrom) { $qSaldo->whereDate('data_saldo', '<=', $periodFrom); } $row = $qSaldo->orderByDesc('data_saldo')->orderByDesc('id')->first(['data_saldo', 'saldo']); if ($row) { $saldoBase = (float) $row->saldo; $dataBase = $row->data_saldo->toDateString(); } } $saldoIniziale = $this->resolveSaldoInizialeDaDatiBancari($stabileId, $this->contoId, $iban); $sumQ = MovimentoBanca::query()->where('stabile_id', $stabileId); if ($this->contoId) { $sumQ->where('conto_id', $this->contoId); } elseif ($iban) { $sumQ->where('iban', $iban); } if ($dataBase) { $sumQ->whereDate('data', '>', $dataBase); } $sumAfter = (float) ($sumQ->sum('importo') ?? 0.0); $saldoAttuale = ($saldoBase !== null ? $saldoBase : $saldoIniziale) + $sumAfter; $dataLabel = $lastMov ? $lastMov->data->format('d/m/Y') : ($dataBase ? Carbon::parse($dataBase)->format('d/m/Y') : null); return ['label' => $label, 'data' => $dataLabel, 'saldo' => $saldoAttuale]; } public function getSaldoSnapshotCards(): array { $user = Auth::user(); if (! $user instanceof User) { return []; } $stabileId = StabileContext::resolveActiveStabileId($user); if (! $stabileId || ! DB::getSchemaBuilder()->hasTable('contabilita_saldi_conti')) { return []; } if (! $this->contoId && ! $this->normalizeIbanValue($this->iban)) { return []; } $query = SaldoConto::query() ->with('documento') ->where('stabile_id', $stabileId); if ($this->contoId) { $query->where('conto_id', $this->contoId); } else { $query->where('iban', $this->normalizeIbanValue($this->iban)); } return $query ->orderByDesc('data_saldo') ->orderByDesc('id') ->limit(6) ->get() ->map(function (SaldoConto $saldo): array { return [ '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(); } protected function getTipoEstrattoOptions(): array { return [ 'estratto_conto' => 'Estratto conto', 'saldo_banca' => 'Saldo banca', 'riconciliazione' => 'Riconciliazione', 'altro' => 'Altro', ]; } protected function normalizeTipoEstrattoValue(mixed $value): ?string { $value = is_string($value) ? trim($value) : ''; if ($value === '') { return null; } return array_key_exists($value, $this->getTipoEstrattoOptions()) ? $value : 'altro'; } protected function formatTipoEstrattoLabel(?string $value): string { $value = $this->normalizeTipoEstrattoValue($value); if (! $value) { return 'Saldo registrato'; } return $this->getTipoEstrattoOptions()[$value] ?? 'Saldo registrato'; } protected function formatPeriodoEstrattoLabel(mixed $from, mixed $to): string { $fromDate = $from instanceof Carbon ? $from : ($from ? Carbon::parse((string) $from) : null); $toDate = $to instanceof Carbon ? $to : ($to ? Carbon::parse((string) $to) : null); if ($fromDate && $toDate) { return $fromDate->format('d/m/Y') . ' - ' . $toDate->format('d/m/Y'); } if ($toDate) { return 'Fino al ' . $toDate->format('d/m/Y'); } if ($fromDate) { return 'Dal ' . $fromDate->format('d/m/Y'); } return 'Periodo non indicato'; } protected function buildContoLabel(DatiBancari $conto): string { $parts = []; $parts[] = trim((string) ($conto->denominazione_banca ?: 'Conto')); $legacyCodCassa = is_string($conto->legacy_cod_cassa ?? null) ? trim((string) $conto->legacy_cod_cassa) : ''; $numeroConto = is_string($conto->numero_conto ?? null) ? trim((string) $conto->numero_conto) : ''; $iban = $this->normalizeIbanValue($conto->iban); if ($legacyCodCassa !== '') { $parts[] = 'cassa ' . strtoupper($legacyCodCassa); } if ($iban) { $parts[] = $iban; } elseif ($numeroConto !== '') { $parts[] = 'n. ' . $numeroConto; } return implode(' · ', array_filter($parts, fn(?string $part): bool => is_string($part) && trim($part) !== '')); } protected function normalizeIbanValue(mixed $value): ?string { $iban = is_string($value) ? trim((string) $value) : ''; return $iban !== '' ? $iban : null; } protected function 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) : ''; if ($path === '') { return null; } $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)); try { Storage::disk('local')->delete($path); } catch (\Throwable) { // ignore } $descrizione = 'Estratto conto ' . $this->buildContoLabel($conto); $periodo = $this->formatPeriodoEstrattoLabel($data['periodo_da'] ?? null, $data['periodo_a'] ?? null); if ($periodo !== 'Periodo non indicato') { $descrizione .= ' · ' . $periodo; } return DocumentoStabile::query()->create([ 'stabile_id' => $stabileId, 'nome_file' => $filename, 'nome_originale' => $filename, 'percorso_file' => $publicPath, 'categoria' => 'bancari', 'tipo_mime' => Storage::disk('public')->mimeType($publicPath), 'dimensione' => Storage::disk('public')->size($publicPath), 'descrizione' => $descrizione, 'caricato_da' => (int) $user->id, ]); } }