4173 lines
172 KiB
PHP
4173 lines
172 KiB
PHP
<?php
|
|
namespace App\Filament\Pages\Contabilita;
|
|
|
|
use App\Models\AffittoCanoneDovuto;
|
|
use App\Models\DatiBancari;
|
|
use App\Models\DocumentoStabile;
|
|
use App\Models\FatturaElettronica;
|
|
use App\Models\Fornitore;
|
|
use App\Models\GestioneContabile;
|
|
use App\Models\IncassoPagamento;
|
|
use App\Models\RubricaUniversale;
|
|
use App\Models\Stabile;
|
|
use App\Models\User;
|
|
use App\Modules\Contabilita\Models\CausaleBancaria;
|
|
use App\Modules\Contabilita\Models\FatturaFornitore;
|
|
use App\Modules\Contabilita\Models\MovimentoBanca;
|
|
use App\Modules\Contabilita\Models\PianoConti;
|
|
use App\Modules\Contabilita\Models\SaldoConto;
|
|
use App\Modules\Contabilita\Services\MovimentoBancaPrimaNotaService;
|
|
use App\Modules\Contabilita\Services\MovimentoBancaRiconciliazioneService;
|
|
use App\Modules\Contabilita\Services\PagamentoFornitorePrimaNotaService;
|
|
use App\Services\Contabilita\ExcelMovimentiParser;
|
|
use App\Services\Contabilita\IntesaCsvParser;
|
|
use App\Services\Contabilita\MovimentiBancaImporter;
|
|
use App\Services\Contabilita\MpsQifParser;
|
|
use App\Services\Contabilita\UnicreditWriParser;
|
|
use App\Support\AnnoGestioneContext;
|
|
use App\Support\ArchivioPaths;
|
|
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\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\IconColumn;
|
|
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\Carbon;
|
|
use Illuminate\Support\Facades\Auth;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Schema;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Livewire\Attributes\Url;
|
|
use UnitEnum;
|
|
|
|
class CasseBancheMovimenti extends Page implements HasTable
|
|
{
|
|
use InteractsWithTable;
|
|
|
|
public ?string $iban = null;
|
|
|
|
public ?int $contoId = null;
|
|
|
|
public ?int $detailMovementId = null;
|
|
|
|
public ?int $editingSaldoId = null;
|
|
|
|
public ?int $confirmingDeleteSaldoId = null;
|
|
|
|
/** @var array<string, mixed> */
|
|
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<string, array{label:string, raw:array<int,string>}> */
|
|
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<string,string>, map:array<string, array{label:string, raw:array<int,string>}>} */
|
|
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<string,string> */
|
|
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<string, string> */
|
|
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<int, array<string, mixed>> */
|
|
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<int, array<string, mixed>> */
|
|
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<string, int|float> */
|
|
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<int, array<string, mixed>> */
|
|
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<string, mixed>|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<int, array<string, mixed>> */
|
|
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<int, array<string, mixed>> */
|
|
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<int, array<string, mixed>> */
|
|
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<int, array<string, mixed>> */
|
|
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<int, string> */
|
|
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<int, array{iban:string,label:string,saldo:float|null}> */
|
|
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<int, array{id:int,iban:?string,label:string,legacy_cod_cassa:?string,numero_conto:?string,denominazione_banca:?string}> */
|
|
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<int, array<string, mixed>> */
|
|
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<string, mixed> */
|
|
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<int, array<string, string>> */
|
|
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,
|
|
]);
|
|
}
|
|
}
|