*/ public array $interbankMappings = []; protected static ?bool $supportsWindowFunctions = null; protected static ?string $navigationLabel = 'Casse e banche'; protected static ?string $title = 'Casse e banche'; protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-banknotes'; protected static UnitEnum|string|null $navigationGroup = 'Contabilità'; protected static ?int $navigationSort = 10; protected static ?string $slug = 'contabilita/casse-banche'; protected string $view = 'filament.pages.contabilita.casse-banche-riepilogo'; 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 { $this->mountInteractsWithTable(); $this->loadInterbankMappings(); } public function getActiveStabile(): ?Stabile { $user = Auth::user(); if (! $user instanceof User) { return null; } return StabileContext::getActiveStabile($user); } protected function getHeaderActions(): array { return [ Action::make('importa_unicredit_wri') ->label('Importa estratto (Unicredit WRI)') ->icon('heroicon-o-arrow-up-tray') ->modalWidth('7xl') ->form([ Select::make('iban') ->label('Conto (IBAN)') ->native(false) ->searchable() ->options(fn(): array=> $this->getIbanOptions()) ->helperText('Opzionale: se non selezionato, l\'import prova a rilevare il conto dai dati.'), Select::make('gestione_id') ->label('Gestione (opzionale)') ->native(false) ->searchable() ->options(fn(): array=> $this->getGestioniOptions()) ->default(fn(): ?string => $this->resolveDefaultGestioneId()), FileUpload::make('file') ->label('File estratto') ->directory('tmp/banca') ->disk('local') ->required(), Toggle::make('replace') ->label('Sostituisci import nel periodo (consigliato se hai già importato con valori “slittati”)') ->helperText('Elimina SOLO i movimenti importati e NON riconciliati (registrazione vuota) 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; } $path = (string) ($data['file'] ?? ''); if ($path === '') { Notification::make()->title('Seleziona un file')->danger()->send(); return; } try { $content = Storage::disk('local')->get($path); $importer = new MovimentiBancaImporter(new UnicreditWriParser(), new IntesaCsvParser(), new ExcelMovimentiParser()); $res = $importer->importUnicreditWri( $content, $stabileId, basename($path), isset($data['iban']) && is_string($data['iban']) && trim($data['iban']) !== '' ? trim((string) $data['iban']) : null, isset($data['gestione_id']) && is_numeric($data['gestione_id']) ? (int) $data['gestione_id'] : null, isset($data['replace']) ? (bool) $data['replace'] : false, ); Notification::make() ->title('Import completato') ->body('Importati: ' . ($res['imported'] ?? 0) . ' · Duplicati: ' . ($res['duplicates'] ?? 0)) ->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('inserisci_saldi') ->label('Inserisci saldi (periodo)') ->icon('heroicon-o-scale') ->modalWidth('7xl') ->form([ Select::make('iban') ->label('Conto (IBAN)') ->native(false) ->searchable() ->options(fn(): array=> $this->getIbanOptions()) ->required(), Textarea::make('note') ->label('Note (opzionale)') ->rows(2) ->maxLength(255), Repeater::make('saldi') ->label('Saldi') ->defaultItems(1) ->reorderable(true) ->schema([ DatePicker::make('data_saldo') ->label('Data') ->required(), TextInput::make('saldo') ->label('Saldo') ->numeric() ->required(), ]) ->columns(2) ->required(), ]) ->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; } $iban = isset($data['iban']) && is_string($data['iban']) ? trim((string) $data['iban']) : ''; if ($iban === '') { Notification::make()->title('Seleziona un IBAN')->danger()->send(); return; } $rows = $data['saldi'] ?? []; if (! is_array($rows) || $rows === []) { Notification::make()->title('Inserisci almeno un saldo')->danger()->send(); return; } $note = isset($data['note']) && is_string($data['note']) ? trim((string) $data['note']) : ''; $contoId = null; try { if (DB::getSchemaBuilder()->hasTable('dati_bancari') && DB::getSchemaBuilder()->hasColumn('contabilita_saldi_conti', 'conto_id')) { $contoId = DatiBancari::query() ->where('stabile_id', (int) $stabileId) ->where('iban', $iban) ->orderBy('id') ->value('id'); $contoId = is_numeric($contoId) ? (int) $contoId : null; } } catch (\Throwable) { $contoId = null; } $created = 0; DB::transaction(function () use ($rows, $stabileId, $contoId, $iban, $note, &$created, $user): void { foreach ($rows as $row) { $date = $row['data_saldo'] ?? null; $saldo = $row['saldo'] ?? null; if (! $date || ! is_numeric($saldo)) { continue; } SaldoConto::query()->create([ 'stabile_id' => (int) $stabileId, 'conto_id' => $contoId, 'iban' => $iban, 'data_saldo' => $date, 'saldo' => (float) $saldo, 'note' => $note !== '' ? $note : null, 'created_by' => (int) ($user->id ?? 0) ?: null, 'updated_by' => (int) ($user->id ?? 0) ?: null, ]); $created++; } }); Notification::make() ->title('Saldi salvati') ->body('Inseriti: ' . $created) ->success() ->send(); }), ]; } protected function getTableQuery(): Builder { $user = Auth::user(); if (! $user instanceof User) { return DatiBancari::query()->whereRaw('1 = 0'); } $stabileId = StabileContext::resolveActiveStabileId($user); if (! $stabileId) { return DatiBancari::query()->whereRaw('1 = 0'); } return DatiBancari::query() ->where('stabile_id', $stabileId) ->orderBy('stato_conto') ->orderBy('denominazione_banca') ->orderBy('iban') ->orderBy('id'); } public function table(Table $table): Table { return $table ->striped() ->filters([ Filter::make('periodo') ->label('Periodo') ->form([ DatePicker::make('from')->label('Dal'), DatePicker::make('to')->label('Al'), ]), Filter::make('gestioni') ->label('Gestioni') ->form([ Select::make('gestione_ids') ->label('Gestione') ->multiple() ->options(fn(): array=> $this->getGestioniOptions()) ->searchable(), ]), ]) ->columns([ TextColumn::make('denominazione_banca') ->label('Banca') ->searchable() ->wrap(), TextColumn::make('tipo_conto') ->label('Tipo') ->toggleable(isToggledHiddenByDefault: true), TextColumn::make('intestazione_conto') ->label('Intestazione') ->wrap() ->toggleable(isToggledHiddenByDefault: true), TextColumn::make('iban') ->label('IBAN') ->searchable() ->copyable(), TextColumn::make('saldo_calcolato') ->label('Saldo') ->alignEnd() ->getStateUsing(fn(DatiBancari $record): ?float => $this->resolveSaldoConto($record)) ->formatStateUsing(fn($state) => $state !== null ? ('€ ' . number_format((float) $state, 2, ',', '.')) : '—'), TextColumn::make('saldo_ufficiale') ->label('Saldo ufficiale') ->alignEnd() ->getStateUsing(fn(DatiBancari $record): ?float => $this->resolveLatestSaldoSnapshotValue($record)) ->formatStateUsing(fn($state) => $state !== null ? ('€ ' . number_format((float) $state, 2, ',', '.')) : '—') ->toggleable(), TextColumn::make('ultimo_estratto') ->label('Ultimo estratto') ->getStateUsing(fn(DatiBancari $record): string => $this->resolveLatestSaldoSnapshotLabel($record)) ->wrap() ->toggleable(), TextColumn::make('stato_conto') ->label('Stato') ->toggleable(isToggledHiddenByDefault: true), ]) ->actions([ Action::make('movimenti') ->label('Movimenti') ->icon('heroicon-o-arrow-right') ->url(function (DatiBancari $record): string { return CasseBancheMovimenti::getUrl(panel: 'admin-filament') . '?conto_id=' . (int) $record->id; }), ]); } public function getTotaleDisponibilita(): ?float { $user = Auth::user(); if (! $user instanceof User) { return null; } $stabileId = StabileContext::resolveActiveStabileId($user); if (! $stabileId) { return null; } $periodo = $this->getTableFilterState('periodo'); $from = is_array($periodo) ? ($periodo['from'] ?? null) : null; $to = is_array($periodo) ? ($periodo['to'] ?? null) : null; $gestioni = $this->getTableFilterState('gestioni'); $gestioneIds = is_array($gestioni) ? ($gestioni['gestione_ids'] ?? null) : null; $gestioneIds = is_array($gestioneIds) ? array_values(array_filter(array_map('intval', $gestioneIds), fn(int $v) => $v > 0)) : []; $conti = DatiBancari::query() ->where('stabile_id', $stabileId) ->get(); if ($conti->isEmpty()) { return 0.0; } $total = 0.0; foreach ($conti as $c) { $saldo = $this->resolveSaldoConto($c); if ($saldo !== null) { $total += $saldo; } } return $total; } protected function resolveSaldoConto(DatiBancari $record): ?float { $user = Auth::user(); if (! $user instanceof User) { return null; } $stabileId = StabileContext::resolveActiveStabileId($user); if (! $stabileId) { return null; } $iban = is_string($record->iban) ? trim((string) $record->iban) : ''; $periodo = $this->getTableFilterState('periodo'); $from = is_array($periodo) ? ($periodo['from'] ?? null) : null; $to = is_array($periodo) ? ($periodo['to'] ?? null) : null; $gestioni = $this->getTableFilterState('gestioni'); $gestioneIds = is_array($gestioni) ? ($gestioni['gestione_ids'] ?? null) : null; $gestioneIds = is_array($gestioneIds) ? array_values(array_filter(array_map('intval', $gestioneIds), fn(int $v) => $v > 0)) : []; $saldoIniziale = is_numeric($record->saldo_iniziale) ? (float) $record->saldo_iniziale : 0.0; $q = MovimentoBanca::query() ->where('stabile_id', $stabileId) ->when($record->id, fn($q) => $q->where('conto_id', (int) $record->id)) ->when($iban !== '', fn($q) => $q->where('iban', $iban)); if ($from) { $q->whereDate('data', '>=', $from); } if ($to) { $q->whereDate('data', '<=', $to); } if ($gestioneIds !== []) { $q->whereIn('gestione_id', $gestioneIds); } // Base da saldi intermedi: se c'è un saldo <= from (se filtrato) o altrimenti ultimo saldo disponibile. $baseSaldo = null; $baseDate = null; if (DB::getSchemaBuilder()->hasTable('contabilita_saldi_conti')) { $qSaldo = SaldoConto::query() ->where('stabile_id', $stabileId) ->where('conto_id', (int) $record->id); if ($from) { $qSaldo->whereDate('data_saldo', '<=', (string) $from); } $row = $qSaldo->orderByDesc('data_saldo')->orderByDesc('id')->first(['data_saldo', 'saldo']); if ($row) { $baseSaldo = (float) $row->saldo; $baseDate = $row->data_saldo->toDateString(); } } if ($from && $baseSaldo !== null) { // Se filtrato da una data, la base è già il saldo al from (o più vicino precedente). $saldoIniziale = $baseSaldo; } if ($baseSaldo !== null && ! $from) { // Non filtrato: usa ultimo saldo inserito come base. $saldoIniziale = $baseSaldo; } if ($baseDate && ! $from) { $q->whereDate('data', '>', $baseDate); } $sum = (float) ($q->sum('importo') ?? 0.0); return $saldoIniziale + $sum; } protected function resolveSaldoIniziale(int $stabileId, string $iban, string $fromDate): ?float { if (! DB::getSchemaBuilder()->hasTable('contabilita_saldi_conti')) { return null; } $row = SaldoConto::query() ->where('stabile_id', $stabileId) ->where('iban', $iban) ->whereDate('data_saldo', '<=', $fromDate) ->orderByDesc('data_saldo') ->orderByDesc('id') ->first(); return $row ? (float) $row->saldo : null; } protected function resolveLatestSaldoSnapshot(DatiBancari $record): ?SaldoConto { static $cache = []; $cacheKey = (int) $record->id; if (array_key_exists($cacheKey, $cache)) { return $cache[$cacheKey]; } if (! DB::getSchemaBuilder()->hasTable('contabilita_saldi_conti')) { return $cache[$cacheKey] = null; } return $cache[$cacheKey] = SaldoConto::query() ->with('documento') ->where('stabile_id', (int) $record->stabile_id) ->where('conto_id', (int) $record->id) ->orderByDesc('data_saldo') ->orderByDesc('id') ->first(); } protected function resolveLatestSaldoSnapshotValue(DatiBancari $record): ?float { $snapshot = $this->resolveLatestSaldoSnapshot($record); return $snapshot ? (float) $snapshot->saldo : null; } protected function resolveLatestSaldoSnapshotLabel(DatiBancari $record): string { $snapshot = $this->resolveLatestSaldoSnapshot($record); if (! $snapshot) { return 'Nessuno estratto registrato'; } $parts = []; $parts[] = $snapshot->tipo_estratto ? ($this->getTipoEstrattoOptions()[$snapshot->tipo_estratto] ?? 'Saldo registrato') : 'Saldo registrato'; if ($snapshot->periodo_da || $snapshot->periodo_a) { $from = $snapshot->periodo_da?->format('d/m/Y'); $to = $snapshot->periodo_a?->format('d/m/Y'); $parts[] = trim(($from ?: '...') . ' - ' . ($to ?: '...')); } if ($snapshot->documento instanceof DocumentoStabile) { $parts[] = $snapshot->documento->nome_originale ?: 'Documento allegato'; } return implode(' · ', $parts); } protected function getTipoEstrattoOptions(): array { return [ 'estratto_conto' => 'Estratto conto', 'saldo_banca' => 'Saldo banca', 'riconciliazione' => 'Riconciliazione', 'altro' => 'Altro', ]; } protected function getContiOptions(): array { $user = Auth::user(); if (! $user instanceof User) { return []; } $stabileId = StabileContext::resolveActiveStabileId($user); if (! $stabileId) { return []; } return DatiBancari::query() ->where('stabile_id', $stabileId) ->orderBy('denominazione_banca') ->orderBy('legacy_cod_cassa') ->orderBy('numero_conto') ->orderBy('id') ->get(['id', 'denominazione_banca', 'iban', 'legacy_cod_cassa', 'numero_conto']) ->mapWithKeys(function (DatiBancari $conto): array { $parts = []; $parts[] = trim((string) ($conto->denominazione_banca ?: 'Conto')); $legacyCodCassa = is_string($conto->legacy_cod_cassa) ? trim((string) $conto->legacy_cod_cassa) : ''; $numeroConto = is_string($conto->numero_conto) ? trim((string) $conto->numero_conto) : ''; $iban = is_string($conto->iban) ? trim((string) $conto->iban) : ''; if ($legacyCodCassa !== '') { $parts[] = 'cassa ' . strtoupper($legacyCodCassa); } if ($iban !== '') { $parts[] = $iban; } elseif ($numeroConto !== '') { $parts[] = 'n. ' . $numeroConto; } return [(int) $conto->id => implode(' · ', $parts)]; }) ->all(); } /** @return array */ protected function getIbanOptions(): array { $user = Auth::user(); if (! $user instanceof User) { return []; } $stabileId = StabileContext::resolveActiveStabileId($user); if (! $stabileId) { return []; } return DatiBancari::query() ->where('stabile_id', $stabileId) ->whereNotNull('iban') ->where('iban', '!=', '') ->orderBy('denominazione_banca') ->orderBy('iban') ->limit(500) ->get(['iban', 'denominazione_banca']) ->mapWithKeys(fn(DatiBancari $r) => [ trim((string) $r->iban) => trim((string) ($r->denominazione_banca ?: 'Conto') . ' · ' . (string) $r->iban), ]) ->all(); } /** @return array */ protected function getGestioniOptions(): array { $user = Auth::user(); if (! $user instanceof User) { return []; } $stabileId = StabileContext::resolveActiveStabileId($user); if (! $stabileId) { return []; } if (! 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(); } protected function resolveDefaultGestioneId(): ?string { $user = Auth::user(); if (! $user instanceof User) { return null; } $stabileId = StabileContext::resolveActiveStabileId($user); if (! $stabileId) { return null; } if (! DB::getSchemaBuilder()->hasTable('gestioni_contabili')) { return null; } $anno = AnnoGestioneContext::resolveActiveAnno(); $tipo = GestioneContext::resolveActiveGestione(); $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) { $match = GestioneContabile::query() ->where('stabile_id', $stabileId) ->where('stato', 'aperta') ->orderByDesc('gestione_attiva') ->orderByDesc('id') ->first(); } return $match ? (string) $match->id : null; } public function loadInterbankMappings(): void { $user = Auth::user(); if (! $user instanceof User) { return; } $stabileId = StabileContext::resolveActiveStabileId($user); if (! $stabileId) { return; } $codes = ['198', '219', '012', '048', '208', 'F24']; $this->interbankMappings = []; foreach ($codes as $code) { $rule = \App\Modules\Contabilita\Models\RegolaPrimaNota::query() ->where('stabile_id', $stabileId) ->where('origine', 'banca') ->where('chiave', $code) ->first(); $this->interbankMappings[$code] = $rule ? (int) $rule->conto_avere_id : null; } } public function saveInterbankMappings(): void { $user = Auth::user(); if (! $user instanceof User) { return; } $stabileId = StabileContext::resolveActiveStabileId($user); if (! $stabileId) { Notification::make()->title('Seleziona uno stabile')->danger()->send(); return; } $bancaContoId = DB::table('contabilita_piano_conti')->where('codice', '1020')->value('id') ?: 7; $labels = [ '198' => 'Canone Conto / Costo Fisso (198)', '219' => 'Imposta di Bollo (219)', '012' => 'Bollettino PagoPA / CBILL (012)', '048' => 'Spese / Commissioni Bancarie (048)', '208' => 'Interessi Passivi (208)', 'F24' => 'Deleghe F24 (RA)', ]; foreach ($this->interbankMappings as $code => $contoAvereId) { if ($contoAvereId) { \App\Modules\Contabilita\Models\RegolaPrimaNota::updateOrCreate([ 'stabile_id' => $stabileId, 'origine' => 'banca', 'chiave' => $code, ], [ 'label' => $labels[$code] ?? "Regola interbancaria {$code}", 'conto_dare_id' => $bancaContoId, 'conto_avere_id' => (int) $contoAvereId, 'attiva' => true, ]); } else { \App\Modules\Contabilita\Models\RegolaPrimaNota::query() ->where('stabile_id', $stabileId) ->where('origine', 'banca') ->where('chiave', $code) ->delete(); } } Notification::make()->title('Configurazione salvata con successo.')->success()->send(); } /** @return array */ public function getPianoContiOptions(): array { return DB::table('contabilita_piano_conti') ->where('attivo', true) ->orderBy('codice') ->get(['id', 'codice', 'denominazione']) ->mapWithKeys(fn($r) => [(string) $r->id => "{$r->codice} - {$r->denominazione}"]) ->all(); } public function riconciliaSpeseBancarieAutomaticamente(): void { $user = Auth::user(); if (! $user instanceof User) { return; } $stabileId = StabileContext::resolveActiveStabileId($user); if (! $stabileId) { Notification::make()->title('Errore')->body('Seleziona uno stabile prima.')->danger()->send(); return; } $gestioneId = null; if (Schema::hasTable('gestioni_contabili')) { $gestioneId = $this->resolveDefaultGestioneId(); if ($gestioneId) { $gestioneId = (int) $gestioneId; } } if (! $gestioneId) { Notification::make()->title('Errore')->body('Nessuna gestione contabile attiva e aperta trovata per questo stabile.')->danger()->send(); return; } $movements = \App\Modules\Contabilita\Models\MovimentoBanca::query() ->where('stabile_id', $stabileId) ->whereNull('registrazione_id') ->get(); if ($movements->isEmpty()) { Notification::make()->title('Riconciliazione completata')->body('Nessun movimento bancario non registrato trovato.')->info()->send(); return; } $primaNotaService = new \App\Modules\Contabilita\Services\MovimentoBancaPrimaNotaService(); $fornitori = \App\Models\Fornitore::query()->get(['id', 'ragione_sociale']); $registeredCosts = 0; $matchedCbill = 0; $matchedF24 = 0; foreach ($movements as $m) { $causale = trim((string) $m->causale); $descText = strtoupper(($m->descrizione ?? '') . ' ' . ($m->descrizione_estesa ?? '')); // Fallback per identificare spese bancarie, bollettini CBILL o F24 dalla descrizione if (str_contains($descText, 'PAGAMENTO DELEGHE') || str_contains($descText, 'F24') || str_contains($descText, 'F23') || str_contains($descText, 'ENTRATEL') || str_contains($descText, 'PAGAMENTO FISCO')) { $causale = 'F24'; } elseif ($causale === '' || ! in_array($causale, ['198', '219', '048', '208', '012'], true)) { if (str_contains($descText, 'IMPOSTA BOLLO') || str_contains($descText, 'IMPOSTA DI BOLLO') || str_contains($descText, 'DPR642/72') || str_contains($descText, 'BOLLO CONTO')) { $causale = '219'; } elseif (str_contains($descText, 'COMPETENZE') || str_contains($descText, 'INTERESSI') || str_contains($descText, 'ONERI') || str_contains($descText, 'TENUTA CONTO') || str_contains($descText, 'SPESE COMPILAZIONE')) { $causale = '198'; } elseif (str_contains($descText, 'COMMISSION') || str_contains($descText, 'COMMESS')) { $causale = '048'; } elseif (str_contains($descText, 'PAGAGO') || str_contains($descText, 'PAGOPA') || str_contains($descText, 'CBILL') || str_contains($descText, 'BOLLETTINO')) { $causale = '012'; } } if ($causale !== $m->causale) { $m->causale = $causale; $m->save(); } // A. Ritenute d'Acconto / F24 if ($causale === 'F24' && (float) $m->importo < 0) { $outstandingRAs = \App\Models\RegistroRitenuteAcconto::query() ->where('stato_versamento', 'da_versare') ->whereHas('gestione', function ($q) use ($stabileId) { $q->where('stabile_id', $stabileId); }) ->get(); $targetAmount = abs((float) $m->importo); $matchedRAs = collect(); // Caso A.1: Cerca una singola ritenuta con importo esatto $singleMatch = $outstandingRAs->first(fn($ra) => abs((float) $ra->importo_ritenuta - $targetAmount) < 0.01); if ($singleMatch) { $matchedRAs->push($singleMatch); } else { // Caso A.2: Cerca se la somma di tutte le ritenute in sospeso corrisponde esattamente al pagamento $totalOutstandingSum = $outstandingRAs->sum(fn($ra) => (float) $ra->importo_ritenuta); if (abs($totalOutstandingSum - $targetAmount) < 0.01) { $matchedRAs = $outstandingRAs; } } if ($matchedRAs->isNotEmpty()) { foreach ($matchedRAs as $ra) { $ra->stato_versamento = 'versata'; $ra->data_versamento = $m->data; $ra->importo_versato = $ra->importo_ritenuta; $ra->f24_riferimento = substr('F24 ' . $m->descrizione, 0, 100); $ra->save(); } // Registra in Prima Nota se è presente la regola F24 $rule = \App\Modules\Contabilita\Models\RegolaPrimaNota::query() ->where('stabile_id', $stabileId) ->where('origine', 'banca') ->where('chiave', 'F24') ->where('attiva', true) ->first(); if ($rule) { try { if (! $m->gestione_id) { $m->gestione_id = $gestioneId; $m->save(); } $primaNotaService->generaRegistrazioneDaMovimento($user, $m); } catch (\Throwable $e) { \Illuminate\Support\Facades\Log::error("Errore registrazione Prima Nota F24: " . $e->getMessage()); } } else { // Se non c'è una regola contabile configurata, creiamo comunque una registrazione di default per quadrare il movimento try { if (! $m->gestione_id) { $m->gestione_id = $gestioneId; $m->save(); } $primaNotaService->generaRegistrazioneDaMovimento($user, $m); } catch (\Throwable $e) { // Ignora se fallisce } } $matchedF24++; continue; } } // B. Spese Bancarie Standard (198, 219, 048, 208) if (in_array($causale, ['198', '219', '048', '208'], true)) { $rule = \App\Modules\Contabilita\Models\RegolaPrimaNota::query() ->where('stabile_id', $stabileId) ->where('origine', 'banca') ->where('chiave', $causale) ->where('attiva', true) ->first(); if ($rule) { try { if (! $m->gestione_id) { $m->gestione_id = $gestioneId; $m->save(); } $primaNotaService->generaRegistrazioneDaMovimento($user, $m); $registeredCosts++; } catch (\Throwable $e) { dump("EXCEPTION: " . $e->getMessage() . "\n" . $e->getTraceAsString()); } } } // C. PagoPA / CBILL (012) if ($causale === '012' && (float) $m->importo < 0) { // Tenta di estrarre un codice CBILL / avviso numerico di 15-20 cifre dalla descrizione $cbillCode = null; $descCombined = ($m->descrizione ?? '') . ' ' . ($m->descrizione_estesa ?? ''); if (preg_match('/\b([0-9]{15,20})\b/', $descCombined, $cbillMatches)) { $cbillCode = trim($cbillMatches[1]); } $invoice = null; $fornIdMatched = null; if ($cbillCode) { $invoiceRow = DB::table('contabilita_fatture_fornitori as f') ->join('fatture_elettroniche as fe', 'fe.id', '=', 'f.fattura_elettronica_id') ->where('f.stabile_id', $stabileId) ->where(function ($q) { $q->where('f.stato', '!=', 'pagato') ->orWhereNull('f.stato') ->orWhereNull('f.data_pagamento'); }) ->where(function ($q) use ($cbillCode) { $q->where('fe.pagamento_cbill', $cbillCode) ->orWhere('fe.pagamento_codice_avviso', $cbillCode); }) ->select('f.*') ->first(); if ($invoiceRow) { $invoice = $invoiceRow; $fornIdMatched = $invoiceRow->fornitore_id; } } // Fallback sul matching tramite ragione sociale del fornitore e importo if (! $invoice) { $descNormalized = $this->normalizeStringForMatching($descCombined); foreach ($fornitori as $forn) { $ragSociale = $this->normalizeStringForMatching((string) $forn->ragione_sociale); if ($ragSociale !== '' && str_contains($descNormalized, $ragSociale)) { $invoiceRow = DB::table('contabilita_fatture_fornitori') ->where('stabile_id', $stabileId) ->where('fornitore_id', $forn->id) ->where(function ($q) { $q->where('stato', '!=', 'pagato') ->orWhereNull('stato') ->orWhereNull('data_pagamento'); }) ->where(function ($q) use ($m) { $targetVal = abs((float) $m->importo); $q->where('totale', $targetVal) ->orWhere('netto_da_pagare', $targetVal); }) ->first(); if ($invoiceRow) { $invoice = $invoiceRow; $fornIdMatched = $forn->id; break; } } } } if ($invoice) { DB::table('contabilita_fatture_fornitori') ->where('id', $invoice->id) ->update([ 'stato' => 'pagato', 'data_pagamento' => $m->data, 'movimento_pagamento_id' => $m->id, ]); $matchData = is_string($m->match_data) ? json_decode($m->match_data, true) : (is_array($m->match_data) ? $m->match_data : []); $matchData['riconciliato_operativo'] = true; $matchData['riconciliazione_tipo'] = 'fattura_fornitore'; $matchData['riconciliazione_label'] = 'Pagamento CBILL/PagoPA fattura #' . $invoice->numero_documento; $matchData['riconciliazione_at'] = now()->toDateTimeString(); $matchData['fattura_fornitore_id'] = $invoice->id; if ($fornIdMatched) { $m->fornitore_id = $fornIdMatched; } $m->registrazione_id = $invoice->registrazione_id; $m->match_data = $matchData; if (Schema::hasColumn('contabilita_movimenti_banca', 'da_confermare')) { $m->da_confermare = false; } $m->save(); $matchedCbill++; } } } if ($registeredCosts > 0 || $matchedCbill > 0 || $matchedF24 > 0) { Notification::make() ->title('Elaborazione completata') ->body("Registrate {$registeredCosts} spese bancarie. Riconciliati {$matchedCbill} bollettini CBILL e {$matchedF24} deleghe F24.") ->success() ->send(); } else { Notification::make()->title('Nessuna operazione eseguita')->body('Non è stato possibile abbinare spese bancarie, bollettini CBILL o deleghe F24 con le regole attuali.')->warning()->send(); } $this->loadInterbankMappings(); } private function normalizeStringForMatching(string $str): string { $str = strtoupper($str); // Rimuovi suffissi aziendali comuni per facilitare il matching $str = str_replace( ['S.P.A.', 'S.R.L.', 'S.N.C.', 'S.P.A', 'S.R.L', 'S.N.C', ' SPA', ' SRL', ' SNC', ' COOP', ' S.S.', ' S.S', ' S.P.A.'], ' ', $str ); // Rimuovi punteggiatura e spazi multipli $str = preg_replace('/[^A-Z0-9\s]/', '', $str); $str = preg_replace('/\s+/', ' ', $str); return trim($str); } }