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