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

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,
];
}
}