netgescon-day0/app/Filament/Pages/Contabilita/CasseBancheRiepilogo.php

1076 lines
42 KiB
PHP
Executable File

<?php
namespace App\Filament\Pages\Contabilita;
use App\Models\DatiBancari;
use App\Models\DocumentoStabile;
use App\Models\GestioneContabile;
use App\Models\Stabile;
use App\Models\User;
use App\Modules\Contabilita\Models\MovimentoBanca;
use App\Modules\Contabilita\Models\SaldoConto;
use App\Services\Contabilita\ExcelMovimentiParser;
use App\Services\Contabilita\IntesaCsvParser;
use App\Services\Contabilita\MovimentiBancaImporter;
use App\Services\Contabilita\UnicreditWriParser;
use App\Support\AnnoGestioneContext;
use App\Support\GestioneContext;
use App\Support\StabileContext;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Filters\Filter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage;
use UnitEnum;
class CasseBancheRiepilogo extends Page implements HasTable
{
use InteractsWithTable;
/** @var array<string, ?int> */
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<string,string> */
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<string,string> */
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<string, string> */
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);
}
}