2168 lines
92 KiB
PHP
Executable File
2168 lines
92 KiB
PHP
Executable File
<?php
|
|
namespace App\Filament\Pages\Contabilita;
|
|
|
|
use App\Filament\Pages\Contabilita\CasseBancheMovimenti;
|
|
use App\Filament\Pages\Contabilita\EstrattoContoSoggetto;
|
|
use App\Filament\Pages\Contabilita\PrimaNotaArchivio;
|
|
use App\Models\AiRequest;
|
|
use App\Models\Condomino;
|
|
use App\Models\ContoBancario;
|
|
use App\Models\GestioneContabile;
|
|
use App\Models\Incasso;
|
|
use App\Models\IncassoAiFeedback;
|
|
use App\Models\MovimentoBancario;
|
|
use App\Models\Soggetto;
|
|
use App\Models\User;
|
|
use App\Modules\Contabilita\Services\IncassoPrimaNotaService;
|
|
use App\Services\Contabilita\IncassiHubReadService;
|
|
use App\Services\Contabilita\MiniAiReconciliationClient;
|
|
use App\Support\StabileContext;
|
|
use BackedEnum;
|
|
use Filament\Actions\Action;
|
|
use Filament\Forms\Components\Placeholder;
|
|
use Filament\Forms\Components\Select;
|
|
use Filament\Forms\Components\Textarea;
|
|
use Filament\Forms\Components\TextInput;
|
|
use Filament\Notifications\Notification;
|
|
use Filament\Pages\Page;
|
|
use Filament\Schemas\Schema as FilamentSchema;
|
|
use Filament\Tables\Columns\IconColumn;
|
|
use Filament\Tables\Columns\Summarizers\Sum;
|
|
use Filament\Tables\Columns\TextColumn;
|
|
use Filament\Tables\Concerns\InteractsWithTable;
|
|
use Filament\Tables\Contracts\HasTable;
|
|
use Filament\Tables\Filters\SelectFilter;
|
|
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\File;
|
|
use Illuminate\Support\Facades\Schema;
|
|
use Illuminate\Support\Facades\Schema as DbSchema;
|
|
use Illuminate\Support\Str;
|
|
use UnitEnum;
|
|
|
|
class IncassiArchivio extends Page implements HasTable
|
|
{
|
|
use InteractsWithTable;
|
|
|
|
protected function normalizeCodCassa(Incasso $record): string
|
|
{
|
|
$v = (string) ($record->cod_cassa_gescon ?? $record->cod_cassa ?? $record->cod_cassa_legacy ?? '');
|
|
$v = strtoupper(trim($v));
|
|
|
|
return $v;
|
|
}
|
|
|
|
protected function resolveContoBancarioIdFromCodCassa(Incasso $record): ?int
|
|
{
|
|
$cod = $this->normalizeCodCassa($record);
|
|
if ($cod === '') {
|
|
return null;
|
|
}
|
|
|
|
$tenantId = is_string($record->tenant_id ?? null) ? trim((string) $record->tenant_id) : '';
|
|
if ($tenantId === '') {
|
|
return null;
|
|
}
|
|
|
|
$conto = ContoBancario::query()
|
|
->where('tenant_id', $tenantId)
|
|
->whereRaw('UPPER(codice) = ?', [$cod])
|
|
->orderBy('id')
|
|
->first(['id']);
|
|
|
|
return $conto ? (int) $conto->id : null;
|
|
}
|
|
|
|
protected static ?string $navigationLabel = 'Incassi';
|
|
|
|
protected static ?string $title = 'Incassi';
|
|
|
|
protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-currency-euro';
|
|
|
|
protected static UnitEnum|string|null $navigationGroup = 'Contabilità';
|
|
|
|
protected static ?int $navigationSort = 46;
|
|
|
|
protected static ?string $slug = 'contabilita/incassi';
|
|
|
|
protected string $view = 'filament.pages.contabilita.incassi-archivio';
|
|
|
|
public string $aiRequestsText = '';
|
|
public string $aiResponseText = '';
|
|
public array $legacyHubRows = [];
|
|
public array $legacyHubMeta = [];
|
|
public string $legacyHubExportText = '';
|
|
public ?int $selectedAiRequestId = null;
|
|
public array $bankIncassiQueue = [];
|
|
public ?int $selectedBankMovementId = null;
|
|
public array $selectedBankMovementSnapshot = [];
|
|
public string $workspaceTab = 'gestione';
|
|
public string $debtorSearch = '';
|
|
public array $debtorSearchResults = [];
|
|
public ?string $selectedLegacyCondId = null;
|
|
public ?int $selectedSubjectId = null;
|
|
public ?int $selectedUnitId = null;
|
|
public array $selectedCiLedger = [];
|
|
public array $selectedCiIdentity = [];
|
|
public array $manualRateInputs = [];
|
|
public array $manualCompetenceOptions = [];
|
|
public ?string $manualCompetenceLabel = null;
|
|
public ?string $manualPayerAlias = null;
|
|
public ?string $manualNote = null;
|
|
public array $miniAiStatus = [];
|
|
|
|
public static function canAccess(): bool
|
|
{
|
|
$user = Auth::user();
|
|
|
|
return $user instanceof User
|
|
&& $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore']);
|
|
}
|
|
|
|
public function mount(): void
|
|
{
|
|
$this->mountInteractsWithTable();
|
|
$this->loadBankIncassiQueue();
|
|
$this->loadLegacyHubSnapshot();
|
|
$this->loadAiRequests();
|
|
$this->loadAiResponse();
|
|
$this->miniAiStatus = app(MiniAiReconciliationClient::class)->status();
|
|
|
|
if ($this->selectedBankMovementId) {
|
|
$this->loadSelectedBankMovementSnapshot();
|
|
}
|
|
}
|
|
|
|
private function loadBankIncassiQueue(int $limit = 40): void
|
|
{
|
|
$this->bankIncassiQueue = [];
|
|
|
|
$user = Auth::user();
|
|
if (! $user instanceof User || ! Schema::hasTable('contabilita_movimenti_banca')) {
|
|
return;
|
|
}
|
|
|
|
$stabileId = StabileContext::resolveActiveStabileId($user);
|
|
if (! $stabileId) {
|
|
return;
|
|
}
|
|
|
|
$operativeContext = app(IncassiHubReadService::class)->operativeOrdinaryContext((int) $stabileId);
|
|
$dateStart = is_string($operativeContext['date_start'] ?? null) ? $operativeContext['date_start'] : null;
|
|
$dateEnd = is_string($operativeContext['date_end'] ?? null) ? $operativeContext['date_end'] : null;
|
|
$allowedYears = array_values(array_filter(
|
|
array_map('intval', $operativeContext['years'] ?? []),
|
|
static fn(int $year): bool => $year >= 2000 && $year <= 2100,
|
|
));
|
|
|
|
$query = DB::table('contabilita_movimenti_banca')
|
|
->where('stabile_id', (int) $stabileId)
|
|
->where('importo', '>', 0)
|
|
->whereNotExists(function ($query): void {
|
|
$query->selectRaw('1')
|
|
->from('incassi as i')
|
|
->whereColumn('i.movimento_bancario_id', 'contabilita_movimenti_banca.id');
|
|
})
|
|
->orderBy('data')
|
|
->orderBy('id');
|
|
|
|
if ($dateStart !== null && $dateEnd !== null) {
|
|
$query->whereBetween('data', [$dateStart, $dateEnd]);
|
|
} elseif ($allowedYears !== []) {
|
|
$query->whereIn(DB::raw('YEAR(data)'), $allowedYears);
|
|
}
|
|
|
|
$rows = $query
|
|
->limit(max(1, min($limit, 100)))
|
|
->get(['id', 'data', 'importo', 'descrizione', 'descrizione_estesa', 'registrazione_id', 'da_confermare'])
|
|
->map(function (object $row): array {
|
|
$latestAi = $this->findLatestAiRequestForMovement((int) $row->id);
|
|
|
|
return [
|
|
'id' => (int) $row->id,
|
|
'data' => ! empty($row->data) ? Carbon::parse((string) $row->data)->format('d/m/Y') : '—',
|
|
'importo' => (float) ($row->importo ?? 0),
|
|
'importo_label' => '€ ' . number_format((float) ($row->importo ?? 0), 2, ',', '.'),
|
|
'descrizione' => trim((string) (($row->descrizione_estesa ?? '') ?: ($row->descrizione ?? ''))),
|
|
'registrazione_id' => is_numeric($row->registrazione_id ?? null) ? (int) $row->registrazione_id : null,
|
|
'da_confermare' => (bool) ($row->da_confermare ?? false),
|
|
'ai_request_id' => $latestAi['id'] ?? null,
|
|
'ai_status' => $latestAi['status'] ?? null,
|
|
'ai_action' => $latestAi['suggested_action'] ?? null,
|
|
'ai_confidence' => $latestAi['confidence'] ?? null,
|
|
'selected' => $this->selectedBankMovementId === (int) $row->id,
|
|
];
|
|
})
|
|
->all();
|
|
|
|
$this->bankIncassiQueue = $rows;
|
|
|
|
$selectedStillVisible = $this->selectedBankMovementId !== null
|
|
&& collect($rows)->contains(fn(array $row): bool => (int) ($row['id'] ?? 0) === $this->selectedBankMovementId);
|
|
|
|
if (! $selectedStillVisible) {
|
|
$this->selectedBankMovementId = (int) ($rows[0]['id'] ?? 0) ?: null;
|
|
}
|
|
}
|
|
|
|
public function selectBankMovement(int $movementId): void
|
|
{
|
|
$this->selectedBankMovementId = $movementId > 0 ? $movementId : null;
|
|
$this->resetManualWorkspace();
|
|
$this->loadSelectedBankMovementSnapshot();
|
|
$this->loadBankIncassiQueue();
|
|
}
|
|
|
|
public function sendSelectedMovementToMiniAi(): void
|
|
{
|
|
$user = Auth::user();
|
|
if (! $user instanceof User) {
|
|
Notification::make()->title('Utente non valido')->danger()->send();
|
|
return;
|
|
}
|
|
|
|
if (! $this->selectedBankMovementId) {
|
|
Notification::make()->title('Seleziona un movimento bancario')->warning()->send();
|
|
return;
|
|
}
|
|
|
|
$snapshot = app(IncassiHubReadService::class)->forBankMovement($this->selectedBankMovementId, 40);
|
|
$result = app(MiniAiReconciliationClient::class)->sendIncassiSnapshot($snapshot, $user);
|
|
|
|
$this->loadSelectedBankMovementSnapshot();
|
|
$this->loadBankIncassiQueue();
|
|
|
|
$body = 'Q/A #' . ($result['ai_request_id'] ?? 'n/d') . ' · ' . (string) ($result['message'] ?? '');
|
|
Notification::make()
|
|
->title(($result['ok'] ?? false) ? 'Mini AI contattata sul movimento' : 'Richiesta registrata')
|
|
->body($body)
|
|
->color(($result['ok'] ?? false) ? 'success' : 'warning')
|
|
->send();
|
|
}
|
|
|
|
private function loadSelectedBankMovementSnapshot(): void
|
|
{
|
|
$this->selectedBankMovementSnapshot = [];
|
|
|
|
if (! $this->selectedBankMovementId) {
|
|
return;
|
|
}
|
|
|
|
$snapshot = app(IncassiHubReadService::class)->forBankMovement($this->selectedBankMovementId, 40);
|
|
$record = is_array($snapshot['records'][0] ?? null) ? $snapshot['records'][0] : [];
|
|
if ($record === []) {
|
|
return;
|
|
}
|
|
|
|
$legacyCondId = trim((string) ($record['id_condomino'] ?? ''));
|
|
$subjectId = is_numeric($record['subject_id'] ?? null)
|
|
? (int) $record['subject_id']
|
|
: $this->resolveLegacySoggettoId(null, $legacyCondId !== '' ? $legacyCondId : null);
|
|
$unitId = is_numeric($record['unit_id'] ?? null)
|
|
? (int) $record['unit_id']
|
|
: $this->resolveUnitIdFromLegacyCond($legacyCondId);
|
|
$aiReview = $this->findLatestAiRequestForMovement($this->selectedBankMovementId);
|
|
$stabileId = (int) ($record['stabile_id'] ?? $snapshot['meta']['stabile_id'] ?? 0);
|
|
|
|
$this->loadManualCompetenceOptions($stabileId, is_string($record['data_pag'] ?? null) ? (string) $record['data_pag'] : null, is_array($record['candidate_rates'] ?? null) ? $record['candidate_rates'] : []);
|
|
|
|
$estrattoUrl = null;
|
|
if ($subjectId) {
|
|
$estrattoUrl = EstrattoContoSoggetto::getUrl(['record' => $subjectId], panel: 'admin-filament');
|
|
if ($unitId) {
|
|
$estrattoUrl .= '?vista=unita&unita_id=' . $unitId;
|
|
}
|
|
}
|
|
|
|
$primaNotaUrl = null;
|
|
if (! empty($aiReview['registrazione_id'])) {
|
|
$primaNotaUrl = PrimaNotaArchivio::getUrl(panel: 'admin-filament') . '?search=' . (int) $aiReview['registrazione_id'];
|
|
}
|
|
|
|
$bankUrl = CasseBancheMovimenti::getUrl(panel: 'admin-filament');
|
|
$bankUrl .= '?tab=movimenti';
|
|
|
|
$this->selectedBankMovementSnapshot = [
|
|
'meta' => is_array($snapshot['meta'] ?? null) ? $snapshot['meta'] : [],
|
|
'record' => $record,
|
|
'subject_id' => $subjectId,
|
|
'unit_id' => $unitId,
|
|
'estratto_url' => $estrattoUrl,
|
|
'bank_url' => $bankUrl,
|
|
'prima_nota_url' => $primaNotaUrl,
|
|
'ai_review' => $aiReview,
|
|
];
|
|
|
|
if ($legacyCondId !== '') {
|
|
$this->selectCiTarget($legacyCondId, $subjectId, $unitId, false);
|
|
}
|
|
}
|
|
|
|
public function searchCiTargets(): void
|
|
{
|
|
$this->debtorSearchResults = [];
|
|
|
|
$user = Auth::user();
|
|
if (! $user instanceof User) {
|
|
return;
|
|
}
|
|
|
|
$stabileId = (int) (StabileContext::resolveActiveStabileId($user) ?? 0);
|
|
if ($stabileId <= 0) {
|
|
return;
|
|
}
|
|
|
|
$this->debtorSearchResults = app(IncassiHubReadService::class)->searchReceivableTargets($stabileId, $this->debtorSearch, 15);
|
|
}
|
|
|
|
public function selectCiTarget(string $legacyCondId, ?int $subjectId = null, ?int $unitId = null, bool $notify = true): void
|
|
{
|
|
$this->selectedLegacyCondId = trim($legacyCondId) !== '' ? trim($legacyCondId) : null;
|
|
$this->selectedSubjectId = $subjectId;
|
|
$this->selectedUnitId = $unitId;
|
|
$this->manualRateInputs = [];
|
|
$this->refreshSelectedCiIdentity();
|
|
|
|
$this->loadSelectedCiLedger();
|
|
|
|
if ($notify && $this->selectedLegacyCondId) {
|
|
Notification::make()->title('C/I selezionato')->success()->send();
|
|
}
|
|
}
|
|
|
|
public function selectCompetenceLabel(?string $label): void
|
|
{
|
|
$value = trim((string) ($label ?? ''));
|
|
$this->manualCompetenceLabel = $value !== '' ? $value : null;
|
|
$this->manualRateInputs = [];
|
|
$this->loadSelectedCiLedger();
|
|
}
|
|
|
|
private function loadSelectedCiLedger(): void
|
|
{
|
|
$this->selectedCiLedger = [];
|
|
|
|
$legacyCondId = trim((string) ($this->selectedLegacyCondId ?? ''));
|
|
if ($legacyCondId === '') {
|
|
return;
|
|
}
|
|
|
|
$user = Auth::user();
|
|
$stabileId = $user instanceof User ? (int) (StabileContext::resolveActiveStabileId($user) ?? 0) : 0;
|
|
if ($stabileId <= 0) {
|
|
return;
|
|
}
|
|
|
|
$paymentDate = $this->selectedBankMovementSnapshot['record']['data_pag'] ?? null;
|
|
$allowedGestioni = $this->manualCompetenceLabel ? [$this->manualCompetenceLabel] : null;
|
|
$ledger = app(IncassiHubReadService::class)->ledgerForLegacyCondomin($stabileId, $legacyCondId, is_string($paymentDate) ? $paymentDate : null, 160, $allowedGestioni);
|
|
|
|
$ledger['selected_competence'] = $this->manualCompetenceLabel;
|
|
$ledger['competence_options'] = $this->manualCompetenceOptions;
|
|
$ledger['identity'] = $this->selectedCiIdentity;
|
|
|
|
$this->selectedCiLedger = $ledger;
|
|
}
|
|
|
|
public function setManualAllocationAmount(int $rateId, string $amount): void
|
|
{
|
|
$normalized = str_replace(['.', ','], ['', '.'], trim($amount));
|
|
$value = is_numeric($normalized) ? round((float) $normalized, 2) : 0.0;
|
|
|
|
if ($value <= 0) {
|
|
unset($this->manualRateInputs[$rateId]);
|
|
return;
|
|
}
|
|
|
|
$dueMap = collect((array) ($this->selectedCiLedger['due'] ?? []))->keyBy('id');
|
|
$rate = $dueMap->get($rateId);
|
|
if (! is_array($rate)) {
|
|
return;
|
|
}
|
|
|
|
$max = round((float) ($rate['residuo'] ?? 0), 2);
|
|
if ($max <= 0) {
|
|
unset($this->manualRateInputs[$rateId]);
|
|
return;
|
|
}
|
|
|
|
$this->manualRateInputs[$rateId] = min($value, $max);
|
|
}
|
|
|
|
public function useSuggestedResidualAmount(int $rateId): void
|
|
{
|
|
$dueMap = collect((array) ($this->selectedCiLedger['due'] ?? []))->keyBy('id');
|
|
$rate = $dueMap->get($rateId);
|
|
if (! is_array($rate)) {
|
|
return;
|
|
}
|
|
|
|
$amount = round((float) ($rate['residuo'] ?? 0), 2);
|
|
if ($amount <= 0) {
|
|
unset($this->manualRateInputs[$rateId]);
|
|
return;
|
|
}
|
|
|
|
$this->manualRateInputs[$rateId] = $amount;
|
|
}
|
|
|
|
public function applySelectedAiAllocations(): void
|
|
{
|
|
$allocations = is_array($this->selectedBankMovementSnapshot['ai_review']['allocations'] ?? null)
|
|
? $this->selectedBankMovementSnapshot['ai_review']['allocations']
|
|
: [];
|
|
|
|
if ($allocations === []) {
|
|
Notification::make()->title('Nessuna allocazione AI disponibile')->warning()->send();
|
|
return;
|
|
}
|
|
|
|
$dueMap = collect((array) ($this->selectedCiLedger['due'] ?? []))->keyBy('id');
|
|
$applied = 0;
|
|
|
|
foreach ($allocations as $allocation) {
|
|
if (! is_array($allocation)) {
|
|
continue;
|
|
}
|
|
|
|
$rateId = is_numeric($allocation['rate_id'] ?? null) ? (int) $allocation['rate_id'] : 0;
|
|
$amount = is_numeric($allocation['amount'] ?? null) ? round((float) $allocation['amount'], 2) : 0.0;
|
|
|
|
if ($rateId <= 0 || $amount <= 0) {
|
|
continue;
|
|
}
|
|
|
|
$rate = $dueMap->get($rateId);
|
|
if (! is_array($rate)) {
|
|
$gestione = trim((string) ($allocation['gestione'] ?? ''));
|
|
if ($gestione !== '' && $gestione !== $this->manualCompetenceLabel) {
|
|
$this->manualCompetenceLabel = $gestione;
|
|
$this->loadSelectedCiLedger();
|
|
$dueMap = collect((array) ($this->selectedCiLedger['due'] ?? []))->keyBy('id');
|
|
$rate = $dueMap->get($rateId);
|
|
}
|
|
}
|
|
|
|
if (! is_array($rate)) {
|
|
continue;
|
|
}
|
|
|
|
$max = round((float) ($rate['residuo'] ?? 0), 2);
|
|
if ($max <= 0) {
|
|
continue;
|
|
}
|
|
|
|
$this->manualRateInputs[$rateId] = min($amount, $max);
|
|
$applied++;
|
|
}
|
|
|
|
if ($applied <= 0) {
|
|
Notification::make()->title('Allocazioni AI non applicabili')->warning()->send();
|
|
return;
|
|
}
|
|
|
|
Notification::make()->title('Allocazioni AI applicate')->success()->send();
|
|
}
|
|
|
|
public function clearManualAllocations(): void
|
|
{
|
|
$this->manualRateInputs = [];
|
|
}
|
|
|
|
public function saveManualReconciliation(): void
|
|
{
|
|
$user = Auth::user();
|
|
if (! $user instanceof User) {
|
|
Notification::make()->title('Utente non valido')->danger()->send();
|
|
return;
|
|
}
|
|
|
|
if (! $this->selectedBankMovementId) {
|
|
Notification::make()->title('Seleziona un movimento banca')->warning()->send();
|
|
return;
|
|
}
|
|
|
|
$legacyCondId = trim((string) ($this->selectedLegacyCondId ?? ''));
|
|
if ($legacyCondId === '') {
|
|
Notification::make()->title('Seleziona un C/I')->warning()->send();
|
|
return;
|
|
}
|
|
|
|
$bankRecord = is_array($this->selectedBankMovementSnapshot['record'] ?? null) ? $this->selectedBankMovementSnapshot['record'] : [];
|
|
$bankAmount = round((float) ($bankRecord['importo'] ?? 0), 2);
|
|
$allocationTotal = round($this->getManualAllocationTotalProperty(), 2);
|
|
|
|
if ($bankAmount <= 0 || abs($bankAmount - $allocationTotal) > 0.009) {
|
|
Notification::make()->title('Quadratura obbligatoria')->body('Il totale assegnato deve coincidere con l\'importo del movimento banca.')->danger()->send();
|
|
return;
|
|
}
|
|
|
|
$movement = DB::table('contabilita_movimenti_banca')->where('id', $this->selectedBankMovementId)->first();
|
|
if (! $movement) {
|
|
Notification::make()->title('Movimento banca non trovato')->danger()->send();
|
|
return;
|
|
}
|
|
|
|
$stabileId = (int) ($movement->stabile_id ?? 0);
|
|
$contoBancarioId = $this->resolveContoBancarioIdFromMovement($movement);
|
|
if (! $contoBancarioId) {
|
|
Notification::make()->title('Conto bancario non risolto')->body('Associare prima il movimento a un conto banca/cassa.')->danger()->send();
|
|
return;
|
|
}
|
|
|
|
$selectedRates = collect((array) ($this->selectedCiLedger['due'] ?? []))
|
|
->keyBy('id')
|
|
->only(array_map('intval', array_keys($this->manualRateInputs)))
|
|
->map(function (array $rate, int $rateId): array {
|
|
$amount = round((float) ($this->manualRateInputs[$rateId] ?? 0), 2);
|
|
return [
|
|
'rate_id' => $rateId,
|
|
'amount' => $amount,
|
|
'gestione' => $rate['gestione'] ?? null,
|
|
'ci' => $rate['ci'] ?? null,
|
|
'descrizione' => $rate['descrizione'] ?? null,
|
|
'ricevuta' => $rate['ricevuta'] ?? null,
|
|
'residuo' => $rate['residuo'] ?? null,
|
|
];
|
|
})
|
|
->filter(fn(array $row): bool => $row['amount'] > 0)
|
|
->values();
|
|
|
|
if ($selectedRates->isEmpty()) {
|
|
Notification::make()->title('Seleziona almeno una rata')->warning()->send();
|
|
return;
|
|
}
|
|
|
|
$gestioneId = $this->resolveGestioneIdFromRates($stabileId, $selectedRates->all(), (string) ($movement->data ?? ''), $this->manualCompetenceLabel);
|
|
$tenantId = $this->resolveTenantIdForConto($contoBancarioId, $user);
|
|
$subjectId = $this->selectedSubjectId ?: $this->resolveLegacySoggettoId(null, $legacyCondId);
|
|
$competenceYear = $this->extractCompetenceStartYear($this->manualCompetenceLabel);
|
|
$incasso = Incasso::query()->create([
|
|
'tenant_id' => $tenantId,
|
|
'gestione_id' => $gestioneId,
|
|
'condominio_id' => $stabileId,
|
|
'conto_bancario_id' => $contoBancarioId,
|
|
'condomino_id' => $subjectId,
|
|
'movimento_bancario_id' => (int) $this->selectedBankMovementId,
|
|
'cod_cond_gescon' => $legacyCondId,
|
|
'cond_inquil' => $selectedRates->first()['ci'] ?? null,
|
|
'anno_rif' => $competenceYear ?: (! empty($movement->data) ? (int) Carbon::parse((string) $movement->data)->format('Y') : (int) now()->format('Y')),
|
|
'importo_pagato' => $bankAmount,
|
|
'importo_pagato_euro' => $bankAmount,
|
|
'dt_empag' => ! empty($movement->data) ? Carbon::parse((string) $movement->data) : now(),
|
|
'data_pagamento' => ! empty($movement->data) ? Carbon::parse((string) $movement->data)->toDateString() : now()->toDateString(),
|
|
'descrizione' => trim((string) (($movement->descrizione_estesa ?? '') ?: ($movement->descrizione ?? ''))),
|
|
'note' => $this->manualNote,
|
|
'stato_riconciliazione' => 'manuale',
|
|
'scostamento_importo' => 0,
|
|
'giorni_scostamento' => 0,
|
|
'confidenza_match' => is_numeric($this->selectedBankMovementSnapshot['ai_review']['confidence'] ?? null)
|
|
? (float) $this->selectedBankMovementSnapshot['ai_review']['confidence']
|
|
: null,
|
|
'rate_associate' => $selectedRates->all(),
|
|
'tipo_incasso' => 'rata_ordinaria',
|
|
'cod_cassa_gescon' => ContoBancario::query()->whereKey($contoBancarioId)->value('codice'),
|
|
'metadati_gescon' => [
|
|
'nominativo' => $bankRecord['nome_condomino'] ?? null,
|
|
'sc_int' => $bankRecord['sc_int'] ?? null,
|
|
'unita_id' => $this->selectedUnitId,
|
|
'soggetto_id' => $subjectId,
|
|
'gestione_competenza' => $this->manualCompetenceLabel,
|
|
'alias_pagante' => $this->manualPayerAlias,
|
|
'payer_reference' => is_array($bankRecord['payer_reference'] ?? null) ? $bankRecord['payer_reference'] : null,
|
|
],
|
|
'log_riconciliazione' => [
|
|
'source' => 'manual-bank-hub',
|
|
'movement_id' => (int) $this->selectedBankMovementId,
|
|
'ai_request_id' => $this->selectedBankMovementSnapshot['ai_review']['id'] ?? null,
|
|
'saved_by' => Auth::id(),
|
|
'saved_at' => now()->toDateTimeString(),
|
|
],
|
|
]);
|
|
|
|
$this->persistPayerReference($stabileId, $subjectId, $legacyCondId, $this->selectedUnitId);
|
|
|
|
if (! empty($this->selectedBankMovementSnapshot['ai_review']['id'])) {
|
|
$this->setAiRequestLearningState((int) $this->selectedBankMovementSnapshot['ai_review']['id'], 'confirmed');
|
|
}
|
|
|
|
$this->resetManualWorkspace();
|
|
$this->loadBankIncassiQueue();
|
|
$this->loadSelectedBankMovementSnapshot();
|
|
|
|
Notification::make()
|
|
->title('Riconciliazione manuale salvata')
|
|
->body('Incasso registrato con quadratura esatta sul movimento banca.')
|
|
->success()
|
|
->send();
|
|
}
|
|
|
|
private function loadLegacyHubSnapshot(): void
|
|
{
|
|
$this->legacyHubRows = [];
|
|
$this->legacyHubMeta = [];
|
|
$this->legacyHubExportText = '';
|
|
|
|
$user = Auth::user();
|
|
if (! $user instanceof User) {
|
|
return;
|
|
}
|
|
|
|
$stabileId = StabileContext::resolveActiveStabileId($user);
|
|
if (! $stabileId) {
|
|
return;
|
|
}
|
|
|
|
$snapshot = app(IncassiHubReadService::class)->forStabile((int) $stabileId, 5);
|
|
$this->legacyHubRows = is_array($snapshot['records'] ?? null) ? $snapshot['records'] : [];
|
|
$this->legacyHubMeta = is_array($snapshot['meta'] ?? null) ? $snapshot['meta'] : [];
|
|
$this->legacyHubExportText = (string) ($snapshot['export_text'] ?? '');
|
|
}
|
|
|
|
private function aiRequestsPath(): string
|
|
{
|
|
return base_path('incoming/ai_requests.md');
|
|
}
|
|
|
|
private function aiResponsePath(): string
|
|
{
|
|
return base_path('incoming/ai_responses.md');
|
|
}
|
|
|
|
private function loadAiRequests(): void
|
|
{
|
|
$path = $this->aiRequestsPath();
|
|
if (File::exists($path)) {
|
|
$this->aiRequestsText = (string) File::get($path);
|
|
return;
|
|
}
|
|
|
|
$this->aiRequestsText = '';
|
|
}
|
|
|
|
private function loadAiResponse(): void
|
|
{
|
|
$path = $this->aiResponsePath();
|
|
if (File::exists($path)) {
|
|
$this->aiResponseText = (string) File::get($path);
|
|
return;
|
|
}
|
|
|
|
$this->aiResponseText = $this->defaultAiResponse();
|
|
}
|
|
|
|
public function saveAiResponse(): void
|
|
{
|
|
$path = $this->aiResponsePath();
|
|
File::put($path, $this->aiResponseText);
|
|
Notification::make()->title('Risposta AI salvata')->success()->send();
|
|
}
|
|
|
|
public function sendLegacyHubToMiniAi(): 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('Nessuno stabile attivo')->warning()->send();
|
|
return;
|
|
}
|
|
|
|
$snapshot = app(IncassiHubReadService::class)->forStabile((int) $stabileId, 20);
|
|
$result = app(MiniAiReconciliationClient::class)->sendIncassiSnapshot($snapshot, $user);
|
|
|
|
$this->loadLegacyHubSnapshot();
|
|
$this->loadAiRequests();
|
|
|
|
$body = 'Q/A #' . ($result['ai_request_id'] ?? 'n/d') . ' · ' . (string) ($result['message'] ?? '');
|
|
Notification::make()
|
|
->title(($result['ok'] ?? false) ? 'Mini AI contattata' : 'Richiesta mini AI registrata')
|
|
->body($body)
|
|
->color(($result['ok'] ?? false) ? 'success' : 'warning')
|
|
->send();
|
|
}
|
|
|
|
public function loadAiRequestForReview(int $aiRequestId): void
|
|
{
|
|
if (! Schema::hasTable('ai_requests')) {
|
|
Notification::make()->title('Tabella ai_requests non disponibile')->warning()->send();
|
|
return;
|
|
}
|
|
|
|
$request = AiRequest::query()->find($aiRequestId);
|
|
if (! $request instanceof AiRequest) {
|
|
Notification::make()->title('Richiesta AI non trovata')->warning()->send();
|
|
return;
|
|
}
|
|
|
|
$this->selectedAiRequestId = (int) $request->id;
|
|
$this->aiRequestsText = trim((string) ($request->request_text ?? ''));
|
|
$this->aiResponseText = trim((string) ($request->response_text ?? ''));
|
|
|
|
Notification::make()
|
|
->title('Richiesta AI caricata in editor')
|
|
->body('Ora puoi correggere la risposta e salvarla sul record selezionato.')
|
|
->success()
|
|
->send();
|
|
}
|
|
|
|
public function saveSelectedAiRequestReview(): void
|
|
{
|
|
if (! Schema::hasTable('ai_requests')) {
|
|
Notification::make()->title('Tabella ai_requests non disponibile')->warning()->send();
|
|
return;
|
|
}
|
|
|
|
if (! $this->selectedAiRequestId) {
|
|
Notification::make()->title('Seleziona prima una richiesta AI')->warning()->send();
|
|
return;
|
|
}
|
|
|
|
$request = AiRequest::query()->find($this->selectedAiRequestId);
|
|
if (! $request instanceof AiRequest) {
|
|
Notification::make()->title('Richiesta AI non trovata')->warning()->send();
|
|
return;
|
|
}
|
|
|
|
$meta = is_array($request->meta_json) ? $request->meta_json : [];
|
|
$review = is_array($meta['operator_review'] ?? null) ? $meta['operator_review'] : [];
|
|
$review['edited_at'] = now()->toDateTimeString();
|
|
$review['edited_by'] = Auth::id();
|
|
$meta['operator_review'] = $review;
|
|
|
|
$responseText = trim($this->aiResponseText);
|
|
|
|
$request->fill([
|
|
'request_text' => trim($this->aiRequestsText) !== '' ? trim($this->aiRequestsText) : $request->request_text,
|
|
'response_text' => $responseText !== '' ? $responseText : null,
|
|
'meta_json' => $meta,
|
|
'status' => $responseText !== '' ? ($request->status === 'confirmed' ? 'confirmed' : 'answered') : 'open',
|
|
'updated_by' => Auth::id(),
|
|
]);
|
|
$request->save();
|
|
|
|
Notification::make()->title('Review AI aggiornata')->success()->send();
|
|
}
|
|
|
|
public function confirmAiRequestLearning(int $aiRequestId): void
|
|
{
|
|
$this->setAiRequestLearningState($aiRequestId, 'confirmed');
|
|
}
|
|
|
|
public function rejectAiRequestLearning(int $aiRequestId): void
|
|
{
|
|
$this->setAiRequestLearningState($aiRequestId, 'rejected');
|
|
}
|
|
|
|
public function saveAiQaToDb(): void
|
|
{
|
|
$requestText = trim($this->aiRequestsText);
|
|
$responseText = trim($this->aiResponseText);
|
|
|
|
if ($requestText === '' && $responseText === '') {
|
|
Notification::make()->title('Nessun contenuto da salvare')->warning()->send();
|
|
return;
|
|
}
|
|
|
|
$filters = $this->buildAiFiltersPayload();
|
|
$meta = $this->buildAiMetaPayload();
|
|
|
|
AiRequest::query()->create([
|
|
'context' => 'incassi-archivio',
|
|
'request_text' => $requestText !== '' ? $requestText : '(vuoto)',
|
|
'response_text' => $responseText !== '' ? $responseText : null,
|
|
'filters_json' => $filters !== [] ? $filters : null,
|
|
'meta_json' => $meta !== [] ? $meta : null,
|
|
'status' => $responseText !== '' ? 'answered' : 'open',
|
|
'created_by' => Auth::id(),
|
|
'updated_by' => Auth::id(),
|
|
]);
|
|
|
|
Notification::make()->title('Q/A registrate in tabella')->success()->send();
|
|
}
|
|
|
|
private function defaultAiResponse(): string
|
|
{
|
|
return <<<'MD'
|
|
# Risposte a richieste AI
|
|
|
|
1) **Fonte condomini attivi**
|
|
- Primaria: `persone_unita_relazioni` (attive) → `persone` → `unita_immobiliari`.
|
|
- Legacy fallback: `diritti_reali` → `anagrafica_condominiale` → `unita_immobiliari`.
|
|
|
|
2) **Mapping `rate_emesse.soggetto_responsabile_id`**
|
|
- È collegato a `soggetti.id`.
|
|
- `soggetti.persona_id` → `persone.id` (se presente).
|
|
|
|
3) **Regole rateizzazione**
|
|
- Tabelle: `piani_rateizzazione` (o `piano_rateizzazione`) e `rate_emesse`.
|
|
- Config di stabile: `stabili.rate_ordinarie_mesi`, `stabili.rate_riscaldamento_mesi`, `stabili.descrizione_rate`.
|
|
|
|
4) **Movimenti banca**
|
|
- Descrizione completa: `contabilita_movimenti_banca.descrizione_estesa` (fallback su `descrizione`).
|
|
|
|
5) **Preferenze riconciliazione**
|
|
- Default: allocazione FIFO sulle rate più vecchie (per data_scadenza), con tolleranza importo ±1.00.
|
|
|
|
MD;
|
|
}
|
|
|
|
public function getAiRequestsDbProperty(): array
|
|
{
|
|
if (! Schema::hasTable('ai_requests')) {
|
|
return [];
|
|
}
|
|
|
|
return AiRequest::query()
|
|
->orderByDesc('id')
|
|
->limit(50)
|
|
->get([
|
|
'id',
|
|
'context',
|
|
'status',
|
|
'request_text',
|
|
'response_text',
|
|
'filters_json',
|
|
'meta_json',
|
|
'created_at',
|
|
])
|
|
->map(function (AiRequest $row): array {
|
|
return [
|
|
'id' => $row->id,
|
|
'context' => $row->context,
|
|
'status' => $row->status,
|
|
'request_text' => Str::limit((string) $row->request_text, 140),
|
|
'response_text' => Str::limit((string) $row->response_text, 140),
|
|
'filters_json' => $row->filters_json,
|
|
'meta_json' => $row->meta_json,
|
|
'created_at' => $row->created_at?->format('d/m/Y H:i'),
|
|
];
|
|
})
|
|
->all();
|
|
}
|
|
|
|
public function getMiniAiReviewItemsProperty(): array
|
|
{
|
|
if (! Schema::hasTable('ai_requests')) {
|
|
return [];
|
|
}
|
|
|
|
$user = Auth::user();
|
|
$stabileId = $user instanceof User ? (int) (StabileContext::resolveActiveStabileId($user) ?? 0) : 0;
|
|
|
|
return AiRequest::query()
|
|
->where('context', 'incassi-hub-mini-ai')
|
|
->whereNotNull('response_text')
|
|
->orderByDesc('id')
|
|
->limit(12)
|
|
->get()
|
|
->map(function (AiRequest $row) use ($stabileId): ?array {
|
|
$requestPayload = $this->decodeAiJsonPayload($row->request_text);
|
|
$responsePayload = $this->decodeAiJsonPayload($row->response_text);
|
|
|
|
$requestMeta = is_array($requestPayload['snapshot']['meta'] ?? null) ? $requestPayload['snapshot']['meta'] : [];
|
|
$requestStabileId = (int) ($requestMeta['stabile_id'] ?? 0);
|
|
if ($stabileId > 0 && $requestStabileId > 0 && $requestStabileId !== $stabileId) {
|
|
return null;
|
|
}
|
|
|
|
$record = is_array($requestPayload['snapshot']['records'][0] ?? null) ? $requestPayload['snapshot']['records'][0] : [];
|
|
$suggestion = is_array($responsePayload['suggestions'][0] ?? null) ? $responsePayload['suggestions'][0] : [];
|
|
$meta = is_array($row->meta_json) ? $row->meta_json : [];
|
|
$review = is_array($meta['operator_review'] ?? null) ? $meta['operator_review'] : [];
|
|
|
|
$allocations = collect(is_array($suggestion['allocations'] ?? null) ? $suggestion['allocations'] : [])
|
|
->map(function (array $allocation): array {
|
|
return [
|
|
'rate_id' => (int) ($allocation['rate_id'] ?? 0),
|
|
'amount_label' => number_format((float) ($allocation['amount'] ?? 0), 2, ',', '.'),
|
|
'ci' => $allocation['ci'] ?? null,
|
|
'gestione' => $allocation['gestione'] ?? null,
|
|
'reason' => $allocation['reason'] ?? null,
|
|
];
|
|
})
|
|
->all();
|
|
|
|
return [
|
|
'id' => (int) $row->id,
|
|
'status' => (string) $row->status,
|
|
'summary' => (string) ($responsePayload['summary'] ?? ''),
|
|
'warnings' => array_values(array_filter(array_map('strval', (array) ($responsePayload['warnings'] ?? [])))),
|
|
'legacy_key' => (string) ($suggestion['legacy_key'] ?? $record['legacy_key'] ?? ''),
|
|
'movimento_id' => (int) ($record['movimento_id'] ?? 0),
|
|
'importo_label' => number_format((float) ($record['importo'] ?? 0), 2, ',', '.'),
|
|
'data' => (string) ($record['data'] ?? ''),
|
|
'nominativo' => (string) ($record['nominativo'] ?? 'N/D'),
|
|
'scala' => (string) ($record['scala'] ?? ''),
|
|
'interno' => (string) ($record['interno'] ?? ''),
|
|
'descrizione' => (string) ($record['descrizione'] ?? ''),
|
|
'suggested_action' => (string) ($suggestion['suggested_action'] ?? 'review'),
|
|
'confidence' => is_numeric($suggestion['confidence'] ?? null) ? (float) $suggestion['confidence'] : null,
|
|
'reason' => (string) ($suggestion['reason'] ?? ''),
|
|
'allocations' => $allocations,
|
|
'review_status' => (string) ($review['state'] ?? 'pending'),
|
|
'reviewed_at' => (string) ($review['reviewed_at'] ?? ''),
|
|
'selected' => $this->selectedAiRequestId === (int) $row->id,
|
|
];
|
|
})
|
|
->filter()
|
|
->values()
|
|
->all();
|
|
}
|
|
|
|
private function findLatestAiRequestForMovement(int $movementId): array
|
|
{
|
|
if ($movementId <= 0 || ! Schema::hasTable('ai_requests')) {
|
|
return [];
|
|
}
|
|
|
|
$request = AiRequest::query()
|
|
->where('context', 'incassi-hub-mini-ai')
|
|
->where('request_text', 'like', '%"movimento_id": ' . $movementId . '%')
|
|
->orderByDesc('id')
|
|
->first();
|
|
|
|
if (! $request instanceof AiRequest) {
|
|
return [];
|
|
}
|
|
|
|
$responsePayload = $this->decodeAiJsonPayload($request->response_text);
|
|
$requestPayload = $this->decodeAiJsonPayload($request->request_text);
|
|
$suggestion = is_array($responsePayload['suggestions'][0] ?? null) ? $responsePayload['suggestions'][0] : [];
|
|
$meta = is_array($request->meta_json) ? $request->meta_json : [];
|
|
$record = is_array($requestPayload['snapshot']['records'][0] ?? null) ? $requestPayload['snapshot']['records'][0] : [];
|
|
$candidateRates = collect(is_array($record['candidate_rates'] ?? null) ? $record['candidate_rates'] : [])->keyBy('id');
|
|
$allocations = collect(is_array($suggestion['allocations'] ?? null) ? $suggestion['allocations'] : [])
|
|
->map(function (array $allocation) use ($candidateRates): array {
|
|
$rateId = is_numeric($allocation['rate_id'] ?? null) ? (int) $allocation['rate_id'] : 0;
|
|
$rate = $candidateRates->get($rateId, []);
|
|
|
|
return [
|
|
'rate_id' => $rateId,
|
|
'amount' => is_numeric($allocation['amount'] ?? null) ? (float) $allocation['amount'] : 0.0,
|
|
'amount_label' => number_format((float) ($allocation['amount'] ?? 0), 2, ',', '.'),
|
|
'ci' => $allocation['ci'] ?? ($rate['ci'] ?? null),
|
|
'gestione' => $allocation['gestione'] ?? ($rate['gestione'] ?? null),
|
|
'descrizione' => $rate['descrizione'] ?? null,
|
|
'ricevuta' => $rate['ricevuta'] ?? null,
|
|
'data_emissione' => $rate['data_emissione'] ?? null,
|
|
'reason' => $allocation['reason'] ?? null,
|
|
];
|
|
})
|
|
->values()
|
|
->all();
|
|
|
|
return [
|
|
'id' => (int) $request->id,
|
|
'status' => (string) $request->status,
|
|
'summary' => (string) ($responsePayload['summary'] ?? ''),
|
|
'warnings' => array_values(array_filter(array_map('strval', (array) ($responsePayload['warnings'] ?? [])))),
|
|
'confidence' => is_numeric($suggestion['confidence'] ?? null) ? (float) $suggestion['confidence'] : null,
|
|
'suggested_action' => (string) ($suggestion['suggested_action'] ?? 'review'),
|
|
'reason' => (string) ($suggestion['reason'] ?? ''),
|
|
'allocations' => $allocations,
|
|
'candidate_rates_count' => count($record['candidate_rates'] ?? []),
|
|
'response_text' => (string) ($request->response_text ?? ''),
|
|
'request_text' => (string) ($request->request_text ?? ''),
|
|
'review_status' => (string) data_get($meta, 'operator_review.state', 'pending'),
|
|
'registrazione_id' => is_numeric(data_get($meta, 'registrazione_id')) ? (int) data_get($meta, 'registrazione_id') : null,
|
|
];
|
|
}
|
|
|
|
private function resolveLegacySoggettoId(?string $codiceFiscale, mixed $legacyId = null): ?int
|
|
{
|
|
$cf = strtoupper(trim((string) ($codiceFiscale ?? '')));
|
|
if ($cf !== '') {
|
|
$id = Soggetto::query()->whereRaw('UPPER(codice_fiscale) = ?', [$cf])->value('id');
|
|
if (is_numeric($id) && (int) $id > 0) {
|
|
return (int) $id;
|
|
}
|
|
}
|
|
|
|
if (is_numeric($legacyId) && (int) $legacyId > 0) {
|
|
$id = Soggetto::query()->where('old_id', (int) $legacyId)->value('id');
|
|
if (is_numeric($id) && (int) $id > 0) {
|
|
return (int) $id;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private function resolveUnitIdFromLegacyCond(?string $legacyCondId): ?int
|
|
{
|
|
$value = trim((string) ($legacyCondId ?? ''));
|
|
if ($value === '' || ! Schema::hasTable('unita_immobiliari') || ! Schema::hasColumn('unita_immobiliari', 'legacy_cond_id')) {
|
|
return null;
|
|
}
|
|
|
|
$user = Auth::user();
|
|
$stabileId = $user instanceof User ? (int) (StabileContext::resolveActiveStabileId($user) ?? 0) : 0;
|
|
|
|
$query = DB::table('unita_immobiliari')->where('legacy_cond_id', $value);
|
|
if ($stabileId > 0 && Schema::hasColumn('unita_immobiliari', 'stabile_id')) {
|
|
$query->where('stabile_id', $stabileId);
|
|
}
|
|
|
|
$id = $query->value('id');
|
|
|
|
return is_numeric($id) && (int) $id > 0 ? (int) $id : null;
|
|
}
|
|
|
|
private function buildAiFiltersPayload(): array
|
|
{
|
|
$filters = [];
|
|
|
|
$anno = $this->getTableFilterState('anno_rif');
|
|
if (! empty($anno)) {
|
|
$filters['anno_rif'] = $anno;
|
|
}
|
|
|
|
$contoId = $this->getTableFilterState('conto_bancario_id');
|
|
if (! empty($contoId)) {
|
|
$filters['conto_bancario_id'] = $contoId;
|
|
}
|
|
|
|
return $filters;
|
|
}
|
|
|
|
private function buildAiMetaPayload(): array
|
|
{
|
|
$user = Auth::user();
|
|
$stabileId = $user instanceof User ? StabileContext::resolveActiveStabileId($user) : null;
|
|
|
|
return array_filter([
|
|
'stabile_id' => $stabileId,
|
|
'user_id' => $user?->id,
|
|
'source' => 'incassi-archivio',
|
|
], fn($v) => ! is_null($v));
|
|
}
|
|
|
|
private function decodeAiJsonPayload(null | string $payload): array
|
|
{
|
|
if (! is_string($payload) || trim($payload) === '') {
|
|
return [];
|
|
}
|
|
|
|
$decoded = json_decode($payload, true);
|
|
|
|
return is_array($decoded) ? $decoded : [];
|
|
}
|
|
|
|
private function setAiRequestLearningState(int $aiRequestId, string $state): void
|
|
{
|
|
if (! Schema::hasTable('ai_requests')) {
|
|
Notification::make()->title('Tabella ai_requests non disponibile')->warning()->send();
|
|
return;
|
|
}
|
|
|
|
$request = AiRequest::query()->find($aiRequestId);
|
|
if (! $request instanceof AiRequest) {
|
|
Notification::make()->title('Richiesta AI non trovata')->warning()->send();
|
|
return;
|
|
}
|
|
|
|
$meta = is_array($request->meta_json) ? $request->meta_json : [];
|
|
$review = is_array($meta['operator_review'] ?? null) ? $meta['operator_review'] : [];
|
|
$review['state'] = $state;
|
|
$review['reviewed_at'] = now()->toDateTimeString();
|
|
$review['reviewed_by'] = Auth::id();
|
|
$meta['operator_review'] = $review;
|
|
|
|
$request->fill([
|
|
'meta_json' => $meta,
|
|
'status' => $state,
|
|
'updated_by' => Auth::id(),
|
|
]);
|
|
$request->save();
|
|
|
|
Notification::make()
|
|
->title($state === 'confirmed' ? 'Risposta confermata per apprendimento' : 'Risposta marcata da rivedere')
|
|
->success()
|
|
->send();
|
|
}
|
|
|
|
public function getManualAllocationTotalProperty(): float
|
|
{
|
|
return round(array_reduce($this->manualRateInputs, fn(float $carry, $value): float => $carry + (is_numeric($value) ? (float) $value : 0.0), 0.0), 2);
|
|
}
|
|
|
|
public function getManualBankAmountProperty(): float
|
|
{
|
|
return round((float) data_get($this->selectedBankMovementSnapshot, 'record.importo', 0), 2);
|
|
}
|
|
|
|
public function getManualAllocationDifferenceProperty(): float
|
|
{
|
|
return round($this->manualBankAmount - $this->manualAllocationTotal, 2);
|
|
}
|
|
|
|
public function getManualCanSaveProperty(): bool
|
|
{
|
|
return $this->manualBankAmount > 0
|
|
&& $this->manualAllocationTotal > 0
|
|
&& abs($this->manualAllocationDifference) < 0.01
|
|
&& trim((string) ($this->selectedLegacyCondId ?? '')) !== '';
|
|
}
|
|
|
|
private function resetManualWorkspace(): void
|
|
{
|
|
$this->debtorSearch = '';
|
|
$this->debtorSearchResults = [];
|
|
$this->selectedLegacyCondId = null;
|
|
$this->selectedSubjectId = null;
|
|
$this->selectedUnitId = null;
|
|
$this->selectedCiLedger = [];
|
|
$this->selectedCiIdentity = [];
|
|
$this->manualRateInputs = [];
|
|
$this->manualCompetenceOptions = [];
|
|
$this->manualCompetenceLabel = null;
|
|
$this->manualPayerAlias = null;
|
|
$this->manualNote = null;
|
|
}
|
|
|
|
private function loadManualCompetenceOptions(int $stabileId, ?string $paymentDate = null, array $candidateRates = []): void
|
|
{
|
|
$labels = app(IncassiHubReadService::class)->visibleOrdinaryCompetenceLabels($stabileId);
|
|
$this->manualCompetenceOptions = array_map(
|
|
fn(string $label): array=> ['value' => $label, 'label' => $label],
|
|
$labels,
|
|
);
|
|
|
|
if ($this->manualCompetenceLabel && in_array($this->manualCompetenceLabel, $labels, true)) {
|
|
return;
|
|
}
|
|
|
|
$candidateLabel = trim((string) ($candidateRates[0]['gestione'] ?? ''));
|
|
if ($candidateLabel !== '' && in_array($candidateLabel, $labels, true)) {
|
|
$this->manualCompetenceLabel = $candidateLabel;
|
|
return;
|
|
}
|
|
|
|
$guessed = $this->guessCompetenceLabelFromDate($paymentDate);
|
|
if ($guessed !== null && in_array($guessed, $labels, true)) {
|
|
$this->manualCompetenceLabel = $guessed;
|
|
return;
|
|
}
|
|
|
|
$this->manualCompetenceLabel = $labels[0] ?? null;
|
|
}
|
|
|
|
private function guessCompetenceLabelFromDate(?string $paymentDate): ?string
|
|
{
|
|
if (! is_string($paymentDate) || trim($paymentDate) === '') {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
$date = Carbon::parse($paymentDate);
|
|
$startYear = (int) $date->format('m') >= 10 ? (int) $date->format('Y') : (int) $date->copy()->subYear()->format('Y');
|
|
|
|
return $this->legacyGestioneLabel($startYear);
|
|
} catch (\Throwable) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private function legacyGestioneLabel(int $startYear): string
|
|
{
|
|
return $startYear . '/' . substr((string) ($startYear + 1), -2);
|
|
}
|
|
|
|
private function extractCompetenceStartYear(?string $label): ?int
|
|
{
|
|
if (! is_string($label) || trim($label) === '') {
|
|
return null;
|
|
}
|
|
|
|
if (preg_match('/\b((?:19|20)\d{2})\/\d{2}\b/', $label, $matches)) {
|
|
return (int) $matches[1];
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private function refreshSelectedCiIdentity(): void
|
|
{
|
|
$this->selectedCiIdentity = [];
|
|
|
|
$legacyCondId = trim((string) ($this->selectedLegacyCondId ?? ''));
|
|
if ($legacyCondId === '' || ! Schema::hasTable('unita_immobiliari')) {
|
|
return;
|
|
}
|
|
|
|
$user = Auth::user();
|
|
$stabileId = $user instanceof User ? (int) (StabileContext::resolveActiveStabileId($user) ?? 0) : 0;
|
|
|
|
$query = DB::table('unita_immobiliari as ui')
|
|
->leftJoin('stabili as st', 'st.id', '=', 'ui.stabile_id')
|
|
->leftJoin('soggetti as s', 's.old_id', '=', 'ui.legacy_cond_id')
|
|
->leftJoin('gescon_import.condomin as gc', function ($join): void {
|
|
$join->on('gc.id_cond', '=', 'ui.legacy_cond_id');
|
|
|
|
if (Schema::hasColumn('unita_immobiliari', 'codice_stabile')) {
|
|
$join->on('gc.cod_stabile', '=', 'ui.codice_stabile');
|
|
} elseif (Schema::hasTable('stabili') && Schema::hasColumn('stabili', 'codice_stabile')) {
|
|
$join->on('gc.cod_stabile', '=', 'st.codice_stabile');
|
|
}
|
|
})
|
|
->where('ui.legacy_cond_id', $legacyCondId);
|
|
|
|
if ($stabileId > 0 && Schema::hasColumn('unita_immobiliari', 'stabile_id')) {
|
|
$query->where('ui.stabile_id', $stabileId);
|
|
}
|
|
|
|
if ($this->selectedUnitId) {
|
|
$query->where('ui.id', $this->selectedUnitId);
|
|
}
|
|
|
|
$row = $query->orderBy('ui.id')->first([
|
|
'ui.id as unita_id',
|
|
'ui.codice_unita',
|
|
'ui.denominazione',
|
|
'ui.scala',
|
|
'ui.interno',
|
|
'gc.nom_cond as legacy_nom_cond',
|
|
's.id as soggetto_id',
|
|
's.nome as soggetto_nome',
|
|
's.cognome as soggetto_cognome',
|
|
's.ragione_sociale as soggetto_rs',
|
|
's.codice_fiscale as soggetto_cf',
|
|
]);
|
|
|
|
if (! $row) {
|
|
return;
|
|
}
|
|
|
|
$subjectLabel = trim((string) ($row->denominazione ?? ''));
|
|
if ($subjectLabel === '') {
|
|
$subjectLabel = trim((string) ($row->legacy_nom_cond ?? ''));
|
|
}
|
|
if ($subjectLabel === '') {
|
|
$subjectLabel = trim((string) (($row->soggetto_rs ?? '') ?: trim((string) (($row->soggetto_nome ?? '') . ' ' . ($row->soggetto_cognome ?? '')))));
|
|
}
|
|
$unitLabel = implode(' · ', array_filter([
|
|
trim((string) ($row->codice_unita ?? '')),
|
|
trim((string) ($row->denominazione ?? '')),
|
|
trim((string) ($row->scala ?? '')) !== '' || trim((string) ($row->interno ?? '')) !== ''
|
|
? ('Scala ' . trim((string) ($row->scala ?? '—')) . ' / Int. ' . trim((string) ($row->interno ?? '—')))
|
|
: null,
|
|
]));
|
|
|
|
$this->selectedSubjectId = $this->selectedSubjectId ?: (is_numeric($row->soggetto_id ?? null) ? (int) $row->soggetto_id : null);
|
|
$this->selectedUnitId = $this->selectedUnitId ?: (is_numeric($row->unita_id ?? null) ? (int) $row->unita_id : null);
|
|
|
|
$this->selectedCiIdentity = [
|
|
'legacy_cond_id' => $legacyCondId,
|
|
'subject_label' => $subjectLabel !== '' ? $subjectLabel : null,
|
|
'subject_cf' => trim((string) ($row->soggetto_cf ?? '')) ?: null,
|
|
'unit_label' => $unitLabel !== '' ? $unitLabel : null,
|
|
'subject_id' => $this->selectedSubjectId,
|
|
'unit_id' => $this->selectedUnitId,
|
|
];
|
|
}
|
|
|
|
private function persistPayerReference(int $stabileId, ?int $subjectId, string $legacyCondId, ?int $unitId): void
|
|
{
|
|
$payerReference = data_get($this->selectedBankMovementSnapshot, 'record.payer_reference');
|
|
if (! is_array($payerReference) || $stabileId <= 0 || ! $subjectId || ! Schema::hasTable('soggetti_riferimenti_bancari')) {
|
|
return;
|
|
}
|
|
|
|
$fingerprint = trim((string) ($payerReference['fingerprint'] ?? ''));
|
|
$ordinanteIban = strtoupper(preg_replace('/\s+/', '', trim((string) ($payerReference['holder_iban'] ?? ''))) ?? '');
|
|
|
|
if ($fingerprint === '' && $ordinanteIban === '') {
|
|
return;
|
|
}
|
|
|
|
$payload = [
|
|
'stabile_id' => $stabileId,
|
|
'soggetto_id' => $subjectId,
|
|
'legacy_cond_id' => $legacyCondId !== '' ? $legacyCondId : null,
|
|
'unita_id' => $unitId,
|
|
'ordinante_nome' => trim((string) ($payerReference['holder_name'] ?? '')) ?: null,
|
|
'ordinante_iban' => $ordinanteIban !== '' ? $ordinanteIban : null,
|
|
'banca_ordinante' => trim((string) ($payerReference['bank_label'] ?? '')) ?: null,
|
|
'bic_swift' => trim((string) ($payerReference['bic'] ?? '')) ?: null,
|
|
'cro' => trim((string) ($payerReference['cro'] ?? '')) ?: null,
|
|
'bank_reference_fingerprint' => $fingerprint !== '' ? $fingerprint : null,
|
|
'ultimo_movimento_banca_id' => $this->selectedBankMovementId,
|
|
'ultimo_movimento_data' => data_get($this->selectedBankMovementSnapshot, 'record.data_pag'),
|
|
'attivo' => true,
|
|
'note' => trim((string) ($this->manualNote ?? '')) ?: null,
|
|
'updated_at' => now(),
|
|
];
|
|
|
|
$existing = DB::table('soggetti_riferimenti_bancari')
|
|
->where('stabile_id', $stabileId)
|
|
->where(function ($query) use ($fingerprint, $ordinanteIban): void {
|
|
if ($fingerprint !== '') {
|
|
$query->orWhere('bank_reference_fingerprint', $fingerprint);
|
|
}
|
|
|
|
if ($ordinanteIban !== '') {
|
|
$query->orWhere('ordinante_iban', $ordinanteIban);
|
|
}
|
|
})
|
|
->orderByDesc('id')
|
|
->first(['id', 'created_at']);
|
|
|
|
if ($existing) {
|
|
DB::table('soggetti_riferimenti_bancari')
|
|
->where('id', $existing->id)
|
|
->update($payload);
|
|
|
|
return;
|
|
}
|
|
|
|
$payload['created_at'] = now();
|
|
DB::table('soggetti_riferimenti_bancari')->insert($payload);
|
|
}
|
|
|
|
private function resolveContoBancarioIdFromMovement(object $movement): ?int
|
|
{
|
|
$iban = trim((string) ($movement->iban ?? ''));
|
|
if ($iban !== '') {
|
|
$id = ContoBancario::query()->where('iban', $iban)->value('id');
|
|
if (is_numeric($id) && (int) $id > 0) {
|
|
return (int) $id;
|
|
}
|
|
}
|
|
|
|
$stabileId = (int) ($movement->stabile_id ?? 0);
|
|
if ($stabileId > 0 && Schema::hasTable('dati_bancari')) {
|
|
$defaultIban = DB::table('dati_bancari')
|
|
->where('stabile_id', $stabileId)
|
|
->orderBy('id')
|
|
->value('iban');
|
|
|
|
if (is_string($defaultIban) && trim($defaultIban) !== '') {
|
|
$id = ContoBancario::query()->where('iban', trim($defaultIban))->value('id');
|
|
if (is_numeric($id) && (int) $id > 0) {
|
|
return (int) $id;
|
|
}
|
|
}
|
|
}
|
|
|
|
$fallback = ContoBancario::query()->where('codice', 'CCB')->value('id');
|
|
|
|
return is_numeric($fallback) && (int) $fallback > 0 ? (int) $fallback : null;
|
|
}
|
|
|
|
private function resolveTenantIdForConto(int $contoBancarioId, User $user): string
|
|
{
|
|
$tenant = ContoBancario::query()->whereKey($contoBancarioId)->value('tenant_id');
|
|
$tenant = is_string($tenant) ? trim($tenant) : '';
|
|
|
|
if ($tenant !== '') {
|
|
return $tenant;
|
|
}
|
|
|
|
$userTenant = trim((string) ($user->tenant_id ?? ''));
|
|
return $userTenant !== '' ? $userTenant : 'default';
|
|
}
|
|
|
|
private function resolveGestioneIdFromRates(int $stabileId, array $rates, string $movementDate, ?string $overrideLabel = null): ?int
|
|
{
|
|
$label = trim((string) ($overrideLabel ?: ($rates[0]['gestione'] ?? '')));
|
|
$year = null;
|
|
if ($label !== '' && preg_match('/\b((?:19|20)\d{2})\/(\d{2})\b/', $label, $matches)) {
|
|
$year = (int) $matches[1];
|
|
}
|
|
|
|
$query = GestioneContabile::query()->where('stabile_id', $stabileId);
|
|
if ($year) {
|
|
$query->where('anno_gestione', $year);
|
|
} elseif ($movementDate !== '') {
|
|
try {
|
|
$movementYear = (int) Carbon::parse($movementDate)->format('Y');
|
|
$query->where('anno_gestione', $movementYear);
|
|
} catch (\Throwable) {
|
|
}
|
|
}
|
|
|
|
$gestione = $query
|
|
->orderByRaw("case when denominazione like '%/%' then 0 else 1 end")
|
|
->orderByDesc('data_fine')
|
|
->orderByDesc('id')
|
|
->first(['id']);
|
|
|
|
return $gestione ? (int) $gestione->id : null;
|
|
}
|
|
|
|
protected function resolveCodCol(): ?string
|
|
{
|
|
if (DbSchema::hasColumn('incassi', 'cod_cond_gescon')) {
|
|
return 'cod_cond_gescon';
|
|
}
|
|
if (DbSchema::hasColumn('incassi', 'cod_cond')) {
|
|
return 'cod_cond';
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
protected function resolveTipoCol(): ?string
|
|
{
|
|
if (DbSchema::hasColumn('incassi', 'cond_inquil')) {
|
|
return 'cond_inquil';
|
|
}
|
|
if (DbSchema::hasColumn('incassi', 'cond_inq')) {
|
|
return 'cond_inq';
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
protected function resolveOrderCol(): string
|
|
{
|
|
if (DbSchema::hasColumn('incassi', 'dt_empag')) {
|
|
return 'dt_empag';
|
|
}
|
|
if (DbSchema::hasColumn('incassi', 'data_pagamento')) {
|
|
return 'data_pagamento';
|
|
}
|
|
|
|
return 'id';
|
|
}
|
|
|
|
protected function getTableQuery(): Builder
|
|
{
|
|
$user = Auth::user();
|
|
if (! $user instanceof User) {
|
|
return Incasso::query()->whereRaw('1 = 0');
|
|
}
|
|
|
|
$stabileId = StabileContext::resolveActiveStabileId($user);
|
|
if (! $stabileId) {
|
|
return Incasso::query()->whereRaw('1 = 0');
|
|
}
|
|
|
|
$orderCol = $this->resolveOrderCol();
|
|
|
|
$query = Incasso::query()->with(['contoBancario:id,tenant_id,codice,descrizione']);
|
|
|
|
// Per NetGescon NG, l'import incassi salva condominio_id = stabileId.
|
|
if (DbSchema::hasColumn('incassi', 'condominio_id')) {
|
|
$query->where('condominio_id', $stabileId);
|
|
}
|
|
|
|
return $query
|
|
->orderByDesc($orderCol)
|
|
->orderByDesc('id');
|
|
}
|
|
|
|
public function table(Table $table): Table
|
|
{
|
|
$codCol = $this->resolveCodCol();
|
|
$tipoCol = $this->resolveTipoCol();
|
|
|
|
$contoMovimentiBase = CasseBancheMovimenti::getUrl(panel: 'admin-filament');
|
|
|
|
return $table
|
|
->striped()
|
|
->columns([
|
|
TextColumn::make('data_pagamento')
|
|
->label('Data')
|
|
->state(function (Incasso $record): ?string {
|
|
$dt = null;
|
|
if (! empty($record->dt_empag)) {
|
|
try {
|
|
$dt = Carbon::parse($record->dt_empag);
|
|
} catch (\Throwable $e) {
|
|
$dt = null;
|
|
}
|
|
}
|
|
if (! $dt && ! empty($record->data_pagamento)) {
|
|
try {
|
|
$dt = Carbon::parse($record->data_pagamento);
|
|
} catch (\Throwable $e) {
|
|
$dt = null;
|
|
}
|
|
}
|
|
|
|
return $dt ? $dt->format('d/m/Y') : null;
|
|
})
|
|
->sortable(query: function (Builder $query, string $direction): Builder {
|
|
// Ordina sempre per colonna più affidabile disponibile (dt_empag, poi data_pagamento).
|
|
if (DbSchema::hasColumn('incassi', 'dt_empag')) {
|
|
return $query->orderBy('dt_empag', $direction);
|
|
}
|
|
if (DbSchema::hasColumn('incassi', 'data_pagamento')) {
|
|
return $query->orderBy('data_pagamento', $direction);
|
|
}
|
|
return $query->orderBy('id', $direction);
|
|
}),
|
|
|
|
TextColumn::make('anno_rif')
|
|
->label('Anno')
|
|
->state(function (Incasso $record): string {
|
|
$dt = null;
|
|
if (! empty($record->dt_empag)) {
|
|
try {
|
|
$dt = Carbon::parse($record->dt_empag);
|
|
} catch (\Throwable $e) {
|
|
$dt = null;
|
|
}
|
|
}
|
|
$dp = null;
|
|
if (! empty($record->data_pagamento)) {
|
|
try {
|
|
$dp = Carbon::parse($record->data_pagamento);
|
|
} catch (\Throwable $e) {
|
|
$dp = null;
|
|
}
|
|
}
|
|
|
|
$annoRaw = $record->anno_rif;
|
|
$annoRawStr = is_null($annoRaw) ? '' : (string) $annoRaw;
|
|
$annoNum = is_numeric($annoRawStr) ? (int) $annoRawStr : null;
|
|
|
|
$annoUmano = null;
|
|
if ($annoNum !== null && $annoNum >= 1900) {
|
|
$annoUmano = $annoNum;
|
|
} elseif ($dt) {
|
|
$annoUmano = (int) $dt->format('Y');
|
|
} elseif ($dp) {
|
|
$annoUmano = (int) $dp->format('Y');
|
|
}
|
|
|
|
$annoLabel = $annoUmano ? (string) $annoUmano : ($annoRawStr !== '' ? ltrim($annoRawStr, '0') : '—');
|
|
if ($annoUmano && $annoNum !== null && $annoNum > 0 && $annoNum < 1900) {
|
|
$annoLabel .= ' (' . str_pad((string) $annoNum, 4, '0', STR_PAD_LEFT) . ')';
|
|
}
|
|
|
|
return $annoLabel;
|
|
})
|
|
->sortable(),
|
|
|
|
TextColumn::make('tipo')
|
|
->label('C/I')
|
|
->state(function (Incasso $record) use ($tipoCol): ?string {
|
|
if (! $tipoCol) {
|
|
return null;
|
|
}
|
|
$v = (string) (data_get($record, $tipoCol) ?? '');
|
|
$v = strtoupper(trim($v));
|
|
return $v !== '' ? $v : null;
|
|
})
|
|
->toggleable(),
|
|
|
|
TextColumn::make('nominativo')
|
|
->label('Nominativo')
|
|
->state(function (Incasso $record): ?string {
|
|
$meta = is_array($record->metadati_gescon ?? null) ? $record->metadati_gescon : [];
|
|
$v = (string) ($meta['nominativo'] ?? '');
|
|
$v = trim($v);
|
|
if ($v !== '') {
|
|
return $v;
|
|
}
|
|
|
|
if ($record->condomino) {
|
|
$nome = trim((string) ($record->condomino->nome ?? ''));
|
|
$cognome = trim((string) ($record->condomino->cognome ?? ''));
|
|
$full = trim($nome . ' ' . $cognome);
|
|
if ($full !== '') {
|
|
return $full;
|
|
}
|
|
}
|
|
|
|
if (! empty($record->condomino_id)) {
|
|
$s = Soggetto::query()->find((int) $record->condomino_id);
|
|
if ($s) {
|
|
$rs = trim((string) ($s->ragione_sociale ?? ''));
|
|
if ($rs !== '') {
|
|
return $rs;
|
|
}
|
|
$nome = trim((string) ($s->nome ?? ''));
|
|
$cognome = trim((string) ($s->cognome ?? ''));
|
|
$full = trim($nome . ' ' . $cognome);
|
|
if ($full !== '') {
|
|
return $full;
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
})
|
|
->searchable(query: function (Builder $query, string $search): Builder {
|
|
$s = trim($search);
|
|
if ($s === '') {
|
|
return $query;
|
|
}
|
|
// JSON search (MySQL): metadati_gescon->$.nominativo
|
|
if (DbSchema::hasColumn('incassi', 'metadati_gescon')) {
|
|
return $query->whereRaw("JSON_UNQUOTE(JSON_EXTRACT(metadati_gescon, '$.nominativo')) like ?", ['%' . $s . '%']);
|
|
}
|
|
return $query;
|
|
})
|
|
->toggleable(),
|
|
|
|
TextColumn::make('cod_cond')
|
|
->label('Cod. cond')
|
|
->state(function (Incasso $record) use ($codCol): ?string {
|
|
if (! $codCol) {
|
|
return null;
|
|
}
|
|
$v = (string) (data_get($record, $codCol) ?? '');
|
|
$v = trim($v);
|
|
return $v !== '' ? $v : null;
|
|
})
|
|
->searchable(query: function (Builder $query, string $search) use ($codCol): Builder {
|
|
$s = trim($search);
|
|
if ($s === '' || ! $codCol) {
|
|
return $query;
|
|
}
|
|
return $query->where($codCol, 'like', '%' . $s . '%');
|
|
})
|
|
->toggleable(),
|
|
|
|
TextColumn::make('importo_pagato_euro')
|
|
->label('Importo')
|
|
->state(function (Incasso $record): float {
|
|
$val = $record->importo_pagato_euro ?? $record->importo_pagato ?? 0;
|
|
return is_numeric($val) ? (float) $val : 0.0;
|
|
})
|
|
->formatStateUsing(fn(float $state): string => '€ ' . number_format($state, 2, ',', '.'))
|
|
->summarize(
|
|
Sum::make()
|
|
->label('Totale')
|
|
->formatStateUsing(fn($state): string => '€ ' . number_format((float) $state, 2, ',', '.'))
|
|
)
|
|
->alignEnd()
|
|
->sortable(),
|
|
|
|
TextColumn::make('descrizione')
|
|
->label('Descrizione')
|
|
->wrap()
|
|
->searchable(),
|
|
|
|
TextColumn::make('cod_cassa')
|
|
->label('Cod. cassa')
|
|
->state(function (Incasso $record): ?string {
|
|
$v = $this->normalizeCodCassa($record);
|
|
if ($v === '') {
|
|
return null;
|
|
}
|
|
|
|
$contoCode = $record->contoBancario && is_string($record->contoBancario->codice)
|
|
? strtoupper(trim((string) $record->contoBancario->codice))
|
|
: '';
|
|
if ($contoCode !== '' && $contoCode !== $v) {
|
|
return $v . ' (≠ ' . $contoCode . ')';
|
|
}
|
|
|
|
return $v;
|
|
})
|
|
->toggleable(),
|
|
|
|
TextColumn::make('conto_bancario_codice')
|
|
->label('Banca (codice)')
|
|
->state(function (Incasso $record): ?string {
|
|
$cod = $record->contoBancario && is_string($record->contoBancario->codice)
|
|
? trim((string) $record->contoBancario->codice)
|
|
: '';
|
|
return $cod !== '' ? $cod : null;
|
|
})
|
|
->toggleable(isToggledHiddenByDefault: true),
|
|
|
|
IconColumn::make('semaforo')
|
|
->label('')
|
|
->icon('heroicon-s-circle')
|
|
->color(function (Incasso $record): string {
|
|
$stato = strtolower(trim((string) ($record->stato_riconciliazione ?? '')));
|
|
$conf = is_numeric($record->confidenza_match ?? null) ? (float) $record->confidenza_match : null;
|
|
$hasMov = ! empty($record->movimento_bancario_id);
|
|
|
|
if ($stato === 'anomala') {
|
|
return 'danger';
|
|
}
|
|
|
|
if ($hasMov || $stato === 'riconciliato' || $stato === 'automatica' || $stato === 'manuale') {
|
|
return 'success';
|
|
}
|
|
|
|
if ($conf !== null && $conf >= 0.7) {
|
|
return 'warning';
|
|
}
|
|
|
|
return 'warning';
|
|
})
|
|
->alignCenter(),
|
|
|
|
IconColumn::make('conto_bancario_id')
|
|
->label('Banca')
|
|
->icon(fn(Incasso $record): ?string => ! empty($record->conto_bancario_id) ? 'heroicon-o-building-library' : null)
|
|
->color(fn(Incasso $record): string => ! empty($record->conto_bancario_id) ? 'gray' : 'secondary')
|
|
->url(function (Incasso $record) use ($contoMovimentiBase): ?string {
|
|
$contoId = is_numeric($record->conto_bancario_id ?? null) ? (int) $record->conto_bancario_id : null;
|
|
return $contoId ? ($contoMovimentiBase . '?conto_id=' . $contoId) : null;
|
|
})
|
|
->openUrlInNewTab()
|
|
->toggleable(),
|
|
])
|
|
->actions([
|
|
Action::make('allinea_banca_da_cod_cassa')
|
|
->label('Allinea banca da Cod. cassa')
|
|
->icon('heroicon-o-arrow-path')
|
|
->visible(function (Incasso $record): bool {
|
|
$cod = $this->normalizeCodCassa($record);
|
|
if ($cod === '') {
|
|
return false;
|
|
}
|
|
$contoCode = $record->contoBancario && is_string($record->contoBancario->codice)
|
|
? strtoupper(trim((string) $record->contoBancario->codice))
|
|
: '';
|
|
return $contoCode === '' || $contoCode !== $cod;
|
|
})
|
|
->action(function (Incasso $record): void {
|
|
$targetId = $this->resolveContoBancarioIdFromCodCassa($record);
|
|
if (! $targetId) {
|
|
Notification::make()->title('Nessuna banca trovata')->body('Impossibile risolvere il conto per questo Cod. cassa.')->warning()->send();
|
|
return;
|
|
}
|
|
|
|
$record->conto_bancario_id = $targetId;
|
|
$record->save();
|
|
|
|
Notification::make()->title('Banca aggiornata')->success()->send();
|
|
$this->resetTable();
|
|
}),
|
|
|
|
Action::make('genera_prima_nota')
|
|
->label('Genera prima nota')
|
|
->icon('heroicon-o-book-open')
|
|
->visible(function (Incasso $record): bool {
|
|
if (! DbSchema::hasTable('contabilita_registrazioni') || ! DbSchema::hasTable('contabilita_movimenti')) {
|
|
return false;
|
|
}
|
|
if (! DbSchema::hasColumn('incassi', '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 GestioneContabile::query()
|
|
->where('stabile_id', $stabileId)
|
|
->orderByDesc('anno_gestione')
|
|
->orderBy('tipo_gestione')
|
|
->orderBy('numero_straordinaria')
|
|
->get(['id', 'denominazione', 'tipo_gestione', 'anno_gestione', 'numero_straordinaria'])
|
|
->mapWithKeys(fn(GestioneContabile $g) => [
|
|
(string) $g->id => $g->denominazione
|
|
. ' (' . $g->tipo_gestione . ' ' . $g->anno_gestione
|
|
. ($g->numero_straordinaria ? ' #' . $g->numero_straordinaria : '')
|
|
. ')',
|
|
])
|
|
->all();
|
|
})
|
|
->searchable()
|
|
->visible(fn(Incasso $record) => empty($record->gestione_id))
|
|
->required(fn(Incasso $record) => empty($record->gestione_id)),
|
|
|
|
Select::make('modalita_incasso')
|
|
->label('Modalità incasso')
|
|
->options([
|
|
'contanti' => 'Contanti',
|
|
'carta' => 'Carta/POS',
|
|
'bonifico' => 'Bonifico',
|
|
'cBill' => 'CBILL',
|
|
'mav' => 'MAV',
|
|
'sdd' => 'SDD/Addebito diretto',
|
|
'assegno' => 'Assegno',
|
|
'altro' => 'Altro',
|
|
])
|
|
->default(fn(Incasso $record): ?string => DbSchema::hasColumn('incassi', 'modalita_incasso')
|
|
? (is_string($record->modalita_incasso ?? null) ? (string) $record->modalita_incasso : null)
|
|
: null)
|
|
->searchable()
|
|
->nullable(),
|
|
|
|
Select::make('condomino_id')
|
|
->label('Pagante (condomino / inquilino / avente diritto)')
|
|
->helperText('Se non selezionato, verrà usato il conto crediti generico (se configurato).')
|
|
->options(function (Incasso $record): array {
|
|
if (! Schema::hasTable('condomini')) {
|
|
return [];
|
|
}
|
|
|
|
$q = Condomino::query();
|
|
|
|
if (Schema::hasColumn('condomini', 'tenant_id') && ! empty($record->tenant_id)) {
|
|
$q->where('tenant_id', (string) $record->tenant_id);
|
|
}
|
|
|
|
// Se disponibile, filtriamo per condominio/stabile Gescon.
|
|
if (Schema::hasColumn('condomini', 'cod_condominio_gescon') && ! empty($record->cod_cond_gescon)) {
|
|
$q->where('cod_condominio_gescon', (string) $record->cod_cond_gescon);
|
|
}
|
|
|
|
// Se disponibile, filtriamo per tipologia (inquilino/proprietario)
|
|
if (Schema::hasColumn('condomini', 'inquilino_proprietario') && ! empty($record->cond_inquil)) {
|
|
$q->where('inquilino_proprietario', (string) $record->cond_inquil);
|
|
}
|
|
|
|
// Ordinamento “best effort”
|
|
if (Schema::hasColumn('condomini', 'cognome')) {
|
|
$q->orderBy('cognome');
|
|
}
|
|
if (Schema::hasColumn('condomini', 'nome')) {
|
|
$q->orderBy('nome');
|
|
}
|
|
|
|
return $q
|
|
->limit(500)
|
|
->get()
|
|
->mapWithKeys(fn(Condomino $c) => [
|
|
(string) $c->getKey() => $c->display_name,
|
|
])
|
|
->all();
|
|
})
|
|
->searchable()
|
|
->default(fn(Incasso $record): ?int => ! empty($record->condomino_id) ? (int) $record->condomino_id : null)
|
|
->nullable(),
|
|
])
|
|
->action(function (Incasso $record, array $data): void {
|
|
$user = Auth::user();
|
|
if (! $user instanceof User) {
|
|
Notification::make()->title('Utente non valido')->danger()->send();
|
|
return;
|
|
}
|
|
|
|
// Best-effort: se Cod. cassa (legacy) non coincide col conto associato, riallinea.
|
|
try {
|
|
$cod = $this->normalizeCodCassa($record);
|
|
$contoCode = $record->contoBancario && is_string($record->contoBancario->codice)
|
|
? strtoupper(trim((string) $record->contoBancario->codice))
|
|
: '';
|
|
if ($cod !== '' && ($contoCode === '' || $contoCode !== $cod)) {
|
|
$targetId = $this->resolveContoBancarioIdFromCodCassa($record);
|
|
if ($targetId) {
|
|
$record->conto_bancario_id = $targetId;
|
|
$record->save();
|
|
$record->refresh();
|
|
}
|
|
}
|
|
} catch (\Throwable) {
|
|
// ignore
|
|
}
|
|
|
|
try {
|
|
$service = app(IncassoPrimaNotaService::class);
|
|
$service->generaRegistrazioneDaIncasso($user, $record, [
|
|
'gestione_id' => (empty($record->gestione_id) && isset($data['gestione_id']) && is_numeric($data['gestione_id']))
|
|
? (int) $data['gestione_id']
|
|
: (int) ($record->gestione_id ?? 0),
|
|
'modalita_incasso' => isset($data['modalita_incasso']) && is_string($data['modalita_incasso'])
|
|
? (string) $data['modalita_incasso']
|
|
: null,
|
|
'condomino_id' => isset($data['condomino_id']) && is_numeric($data['condomino_id'])
|
|
? (int) $data['condomino_id']
|
|
: null,
|
|
]);
|
|
Notification::make()->title('Prima nota generata')->success()->send();
|
|
$this->resetTable();
|
|
} catch (\Throwable $e) {
|
|
Notification::make()->title('Errore')->body($e->getMessage())->danger()->send();
|
|
}
|
|
}),
|
|
|
|
Action::make('dettaglio_nominativo')
|
|
->label('Dettaglio nominativo')
|
|
->icon('heroicon-o-identification')
|
|
->modalHeading('Dettaglio nominativo / estratto')
|
|
->modalContent(function (Incasso $record) {
|
|
$data = $this->buildNominativoEstrattoData($record);
|
|
return view('filament.pages.contabilita.incassi-nominativo-modal', $data);
|
|
})
|
|
->modalSubmitAction(false)
|
|
->modalCancelActionLabel('Chiudi'),
|
|
|
|
Action::make('ai_feedback')
|
|
->label('AI feedback')
|
|
->icon('heroicon-o-cpu-chip')
|
|
->visible(fn() => Schema::hasTable('incassi_ai_feedback'))
|
|
->form([
|
|
Select::make('ai_status')
|
|
->label('Stato AI')
|
|
->options([
|
|
'pending' => 'In attesa',
|
|
'suggested' => 'Suggerito',
|
|
'accepted' => 'Accettato',
|
|
'rejected' => 'Respinto',
|
|
])
|
|
->required(),
|
|
TextInput::make('ai_confidenza')
|
|
->label('Confidenza')
|
|
->numeric()
|
|
->step(0.01)
|
|
->minValue(0)
|
|
->maxValue(1)
|
|
->nullable(),
|
|
TextInput::make('movimento_bancario_id')
|
|
->label('Movimento bancario ID')
|
|
->numeric()
|
|
->nullable(),
|
|
TextInput::make('registrazione_id')
|
|
->label('Registrazione prima nota ID')
|
|
->numeric()
|
|
->nullable(),
|
|
Textarea::make('ai_note')
|
|
->label('Note AI')
|
|
->rows(3)
|
|
->nullable(),
|
|
Textarea::make('user_feedback')
|
|
->label('Feedback operatore')
|
|
->rows(3)
|
|
->nullable(),
|
|
Placeholder::make('ai_payload')
|
|
->label('Payload AI')
|
|
->content(fn(Incasso $record): string => $this->getAiPayloadSummary($record)),
|
|
])
|
|
->mountUsing(function (FilamentSchema $form, Incasso $record): void {
|
|
$form->fill($this->getAiFeedbackFormData($record));
|
|
})
|
|
->action(function (Incasso $record, array $data): void {
|
|
$this->saveAiFeedback($record, $data);
|
|
Notification::make()->title('Feedback AI salvato')->success()->send();
|
|
}),
|
|
])
|
|
->filters([
|
|
SelectFilter::make('anno_rif')
|
|
->label('Anno')
|
|
->options(function (): array {
|
|
$user = Auth::user();
|
|
if (! $user instanceof User) {
|
|
return [];
|
|
}
|
|
$stabileId = StabileContext::resolveActiveStabileId($user);
|
|
if (! $stabileId) {
|
|
return [];
|
|
}
|
|
|
|
$q = Incasso::query();
|
|
if (DbSchema::hasColumn('incassi', 'condominio_id')) {
|
|
$q->where('condominio_id', $stabileId);
|
|
}
|
|
|
|
// In Gescon, anno_rif è spesso 0001, 0002, 0003... e non coincide con l'anno calendario.
|
|
if (! DbSchema::hasColumn('incassi', 'anno_rif')) {
|
|
return [];
|
|
}
|
|
|
|
$years = $q->selectRaw('DISTINCT anno_rif as y')
|
|
->whereNotNull('anno_rif')
|
|
->orderByDesc('y')
|
|
->pluck('y')
|
|
->filter(fn($v) => is_numeric($v) && (int) $v > 0)
|
|
->values()
|
|
->all();
|
|
|
|
$out = [];
|
|
foreach ($years as $y) {
|
|
$yy = (int) $y;
|
|
$out[(string) $yy] = str_pad((string) $yy, 4, '0', STR_PAD_LEFT);
|
|
}
|
|
|
|
return $out;
|
|
})
|
|
->query(function (Builder $query, array $data): Builder {
|
|
$year = isset($data['value']) ? (int) $data['value'] : 0;
|
|
if ($year <= 0) {
|
|
return $query;
|
|
}
|
|
if (DbSchema::hasColumn('incassi', 'anno_rif')) {
|
|
return $query->where('anno_rif', $year);
|
|
}
|
|
return $query;
|
|
}),
|
|
|
|
SelectFilter::make('conto_bancario_id')
|
|
->label('Conto corrente')
|
|
->searchable()
|
|
->options(function (): array {
|
|
$user = Auth::user();
|
|
if (! $user instanceof User) {
|
|
return [];
|
|
}
|
|
$stabileId = StabileContext::resolveActiveStabileId($user);
|
|
if (! $stabileId) {
|
|
return [];
|
|
}
|
|
|
|
$q = Incasso::query();
|
|
if (DbSchema::hasColumn('incassi', 'condominio_id')) {
|
|
$q->where('condominio_id', $stabileId);
|
|
}
|
|
|
|
$tenantIds = $q->selectRaw('DISTINCT tenant_id as t')
|
|
->whereNotNull('tenant_id')
|
|
->pluck('t')
|
|
->filter(fn($v) => is_string($v) && trim($v) !== '')
|
|
->map(fn($v) => trim((string) $v))
|
|
->values()
|
|
->all();
|
|
|
|
if (! is_array($tenantIds) || count($tenantIds) === 0) {
|
|
return [];
|
|
}
|
|
|
|
return ContoBancario::query()
|
|
->whereIn('tenant_id', $tenantIds)
|
|
->orderBy('codice')
|
|
->orderBy('descrizione')
|
|
->get(['id', 'codice', 'descrizione'])
|
|
->mapWithKeys(function (ContoBancario $c): array {
|
|
$code = trim((string) ($c->codice ?? ''));
|
|
$desc = trim((string) ($c->descrizione ?? ''));
|
|
$label = $code !== '' ? $code : ('Conto #' . $c->id);
|
|
if ($desc !== '') {
|
|
$label .= ' — ' . $desc;
|
|
}
|
|
return [(string) $c->id => $label];
|
|
})
|
|
->all();
|
|
})
|
|
->query(function (Builder $query, array $data): Builder {
|
|
$id = isset($data['value']) ? (int) $data['value'] : 0;
|
|
return $id > 0 ? $query->where('conto_bancario_id', $id) : $query;
|
|
}),
|
|
])
|
|
->defaultPaginationPageOption(50);
|
|
}
|
|
|
|
private function getAiFeedbackFormData(Incasso $record): array
|
|
{
|
|
$feedback = IncassoAiFeedback::query()
|
|
->where('incasso_id', $record->id)
|
|
->orderByDesc('id')
|
|
->first();
|
|
|
|
return [
|
|
'ai_status' => $feedback?->ai_status ?? 'pending',
|
|
'ai_confidenza' => $feedback?->ai_confidenza,
|
|
'movimento_bancario_id' => $feedback?->movimento_bancario_id ?? $record->movimento_bancario_id,
|
|
'registrazione_id' => $feedback?->registrazione_id ?? $record->registrazione_id,
|
|
'ai_note' => $feedback?->ai_note,
|
|
'user_feedback' => $feedback?->user_feedback,
|
|
];
|
|
}
|
|
|
|
private function getAiPayloadSummary(Incasso $record): string
|
|
{
|
|
$feedback = IncassoAiFeedback::query()
|
|
->where('incasso_id', $record->id)
|
|
->orderByDesc('id')
|
|
->first();
|
|
|
|
$payload = $feedback?->ai_payload;
|
|
if (empty($payload)) {
|
|
return '—';
|
|
}
|
|
|
|
return is_array($payload) ? json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : (string) $payload;
|
|
}
|
|
|
|
private function saveAiFeedback(Incasso $record, array $data): void
|
|
{
|
|
$user = Auth::user();
|
|
$feedback = IncassoAiFeedback::query()
|
|
->firstOrNew(['incasso_id' => $record->id]);
|
|
|
|
$feedback->fill([
|
|
'ai_status' => $data['ai_status'] ?? 'pending',
|
|
'ai_confidenza' => $data['ai_confidenza'] ?? null,
|
|
'movimento_bancario_id' => $data['movimento_bancario_id'] ?? null,
|
|
'registrazione_id' => $data['registrazione_id'] ?? null,
|
|
'ai_note' => $data['ai_note'] ?? null,
|
|
'user_feedback' => $data['user_feedback'] ?? null,
|
|
'updated_by' => $user?->id,
|
|
]);
|
|
|
|
if (! $feedback->exists) {
|
|
$feedback->created_by = $user?->id;
|
|
}
|
|
|
|
$feedback->save();
|
|
}
|
|
|
|
private function buildNominativoEstrattoData(Incasso $record): array
|
|
{
|
|
$condomino = $record->condomino;
|
|
$movimento = null;
|
|
if (! empty($record->movimento_bancario_id)) {
|
|
$movimento = MovimentoBancario::query()->find($record->movimento_bancario_id);
|
|
}
|
|
|
|
$incassi = [];
|
|
if (! empty($record->condomino_id)) {
|
|
$incassi = Incasso::query()
|
|
->where('condomino_id', $record->condomino_id)
|
|
->orderByDesc($this->resolveOrderCol())
|
|
->limit(200)
|
|
->get();
|
|
}
|
|
|
|
$rateEmesse = [];
|
|
if (Schema::hasTable('rate_emesse')) {
|
|
$rateQuery = DB::table('rate_emesse');
|
|
if (Schema::hasColumn('rate_emesse', 'condomino_id') && ! empty($record->condomino_id)) {
|
|
$rateQuery->where('condomino_id', $record->condomino_id);
|
|
} elseif (
|
|
Schema::hasColumn('rate_emesse', 'soggetto_responsabile_id')
|
|
&& Schema::hasTable('condomini')
|
|
&& Schema::hasColumn('condomini', 'soggetto_responsabile_id')
|
|
&& ! empty($record->condomino_id)
|
|
) {
|
|
$soggettoId = DB::table('condomini')->where('id', $record->condomino_id)->value('soggetto_responsabile_id');
|
|
if ($soggettoId) {
|
|
$rateQuery->where('soggetto_responsabile_id', $soggettoId);
|
|
} else {
|
|
$rateQuery = null;
|
|
}
|
|
}
|
|
|
|
if ($rateQuery) {
|
|
$rateEmesse = $rateQuery
|
|
->orderByDesc('data_scadenza')
|
|
->limit(200)
|
|
->get();
|
|
}
|
|
}
|
|
|
|
return [
|
|
'record' => $record,
|
|
'condomino' => $condomino,
|
|
'movimento' => $movimento,
|
|
'incassi' => $incassi,
|
|
'rateEmesse' => $rateEmesse,
|
|
'registrazione_id' => $record->registrazione_id,
|
|
];
|
|
}
|
|
}
|