baseUrl(); $path = $this->path(); $active = $enabled && $baseUrl !== ''; return [ 'enabled' => $enabled, 'active' => $active, 'base_url' => $baseUrl, 'path' => $path, 'message' => $active ? 'Mini AI configurata.' : $this->disabledMessage(), 'docs_path' => 'docs/support/MINI-AI-202-RICONCILIAZIONE-RUNBOOK.md', ]; } public function buildIncassiRequest(array $snapshot): array { $miniSnapshot = $this->buildMiniAiSnapshot($snapshot); return [ 'task' => 'reconcile_incassi_movimenti_bancari', 'mode' => 'read_only_suggestions', 'instructions' => [ 'Non modificare dati sul gestionale.', 'Restituisci solo proposte di abbinamento e motivazione.', 'Dare priorita a importo, data pagamento, nominativo, scala/interno, ricevuta/protocollo.', 'Se sono presenti candidate_rates, proponi anche come imputare il versamento alle rate C/I aperte.', 'Per i versamenti a ridosso dell emissione considera prima la gestione aperta e l esercizio chiuso immediatamente precedente; non andare su gestioni molto vecchie salvo indicazione esplicita nella descrizione banca.', 'Se la descrizione banca indica saldo anno precedente e acconto anno corrente, distribuisci il pagamento prima sui residui dell esercizio precedente e poi sulle prime rate della gestione corrente.', 'Segnala duplicati o casi ambigui invece di forzare un match.', 'Formato risposta richiesto: JSON con suggestions[], warnings[], summary. Ogni suggestion puo contenere allocations[].', ], 'expected_response_schema' => [ 'summary' => 'string', 'warnings' => ['string'], 'suggestions' => [[ 'legacy_key' => 'string', 'confidence' => 'number 0..1', 'suggested_incasso_id' => 'integer|null', 'suggested_movimento_id' => 'integer|null', 'suggested_action' => 'link|review|ignore|duplicate', 'reason' => 'string', 'allocations' => [[ 'rate_id' => 'integer', 'amount' => 'number', 'ci' => 'C|I|null', 'gestione' => 'string|null', 'reason' => 'string', ]], ]], ], 'snapshot' => $miniSnapshot, ]; } private function buildMiniAiSnapshot(array $snapshot): array { $records = []; foreach ((array) ($snapshot['records'] ?? []) as $record) { if (! is_array($record)) { continue; } $legacyEntry = is_array($record['ai_payload']['legacy_entry'] ?? null) ? $record['ai_payload']['legacy_entry'] : []; $incassoCandidate = is_array($record['incasso_candidates'][0] ?? null) ? $record['incasso_candidates'][0] : []; $scalaInterno = $this->splitScalaInterno((string) ($record['sc_int'] ?? $legacyEntry['sc_int'] ?? '')); $candidateRates = is_array($record['candidate_rates'] ?? null) ? $record['candidate_rates'] : []; $bankMovement = is_array($record['ai_payload']['bank_movement'] ?? null) ? $record['ai_payload']['bank_movement'] : []; $records[] = [ 'legacy_key' => (string) ($record['key'] ?? ''), 'incasso_id' => $legacyEntry['incasso_id'] ?? $incassoCandidate['id'] ?? null, 'movimento_id' => $bankMovement['id'] ?? null, 'importo' => $record['importo'] ?? $legacyEntry['importo'] ?? $bankMovement['importo'] ?? null, 'data' => $record['data_pag'] ?? $legacyEntry['data_pag'] ?? $bankMovement['data'] ?? null, 'descrizione' => $record['descrizione_aggintiva'] ?? $incassoCandidate['label'] ?? $bankMovement['descrizione_estesa'] ?? null, 'nominativo' => $record['nome_condomino'] ?? $incassoCandidate['nome'] ?? $bankMovement['nominativo'] ?? null, 'scala' => $scalaInterno['scala'], 'interno' => $scalaInterno['interno'], 'ricevuta' => $record['num_incasso'] ?? $legacyEntry['num_incasso'] ?? null, 'protocollo' => $record['protocollo'] ?? $legacyEntry['protocollo'] ?? null, 'payer_reference' => [ 'holder_name' => $record['payer_reference']['holder_name'] ?? $bankMovement['ordinante_nome'] ?? null, 'holder_iban' => $record['payer_reference']['holder_iban'] ?? $bankMovement['ordinante_iban'] ?? null, 'bank_label' => $record['payer_reference']['bank_label'] ?? $bankMovement['banca_ordinante'] ?? null, 'bic' => $record['payer_reference']['bic'] ?? $bankMovement['banca_bic'] ?? null, 'cro' => $record['payer_reference']['cro'] ?? $bankMovement['cro'] ?? null, 'fingerprint' => $record['payer_reference']['fingerprint'] ?? $bankMovement['bank_reference_fingerprint'] ?? null, ], 'candidate_movements' => $this->mapCandidateMovements((array) ($record['movimento_candidates'] ?? [])), 'candidate_rates' => $this->mapCandidateRates($candidateRates), ]; } return [ 'meta' => [ 'stabile_id' => $snapshot['meta']['stabile_id'] ?? null, 'codice_stabile' => $snapshot['meta']['codice_stabile'] ?? null, 'source' => $snapshot['meta']['source'] ?? 'netgescon', 'source_label' => $snapshot['meta']['source_label'] ?? null, 'records_count' => count($records), ], 'records' => $records, ]; } private function mapCandidateMovements(array $candidates): array { $out = []; foreach ($candidates as $candidate) { if (! is_array($candidate)) { continue; } $out[] = [ 'id' => $candidate['id'] ?? null, 'data' => $candidate['data'] ?? null, 'importo' => $candidate['importo'] ?? null, 'descrizione_estesa' => $candidate['label'] ?? $candidate['nome'] ?? null, 'score_locale' => $candidate['score'] ?? null, 'riferimento' => $candidate['riferimento'] ?? null, 'source_type' => $candidate['type'] ?? null, ]; } return $out; } private function mapCandidateRates(array $candidates): array { $out = []; foreach ($candidates as $candidate) { if (! is_array($candidate)) { continue; } $out[] = [ 'id' => $candidate['id'] ?? null, 'gestione' => $candidate['gestione'] ?? null, 'ci' => $candidate['ci'] ?? null, 'data_emissione' => $candidate['data_emissione'] ?? null, 'descrizione' => $candidate['descrizione'] ?? null, 'ricevuta' => $candidate['ricevuta'] ?? null, 'mese' => $candidate['mese'] ?? null, 'importo' => $candidate['importo'] ?? null, 'residuo' => $candidate['residuo'] ?? null, 'gia_pagato' => $candidate['gia_pagato'] ?? null, 'raggruppamento' => $candidate['raggruppamento'] ?? null, ]; } return $out; } private function splitScalaInterno(string $value): array { $raw = strtoupper(trim($value)); if ($raw !== '' && preg_match('/([A-Z])\s*[-\/]?\s*(\d+[A-Z]?)/', $raw, $matches)) { return [ 'scala' => $matches[1], 'interno' => $matches[2], ]; } return ['scala' => null, 'interno' => null]; } public function sendIncassiSnapshot(array $snapshot, ?User $user = null): array { $requestPayload = $this->buildIncassiRequest($snapshot); $requestText = json_encode($requestPayload, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '{}'; $aiRequest = AiRequest::query()->create([ 'context' => 'incassi-hub-mini-ai', 'request_text' => $requestText, 'response_text' => null, 'filters_json' => [ 'stabile_id' => $snapshot['meta']['stabile_id'] ?? null, 'codice_stabile' => $snapshot['meta']['codice_stabile'] ?? null, 'source' => $snapshot['meta']['source'] ?? null, ], 'meta_json' => [ 'target' => $this->baseUrl(), 'path' => $this->path(), 'records_count' => $snapshot['meta']['records_count'] ?? 0, 'mode' => 'read_only_suggestions', ], 'status' => 'open', 'created_by' => $user?->id, 'updated_by' => $user?->id, ]); if (! $this->isEnabled()) { $message = $this->disabledMessage(); $aiRequest->update([ 'response_text' => $message, 'status' => 'open', 'updated_by' => $user?->id, ]); return [ 'ok' => false, 'ai_request_id' => $aiRequest->id, 'message' => $message, 'response' => null, ]; } try { $http = Http::timeout(max(5, (int) config('services.mini_ai.timeout', 30))) ->acceptJson() ->asJson(); $token = trim((string) config('services.mini_ai.token', '')); if ($token !== '') { $http = $http->withToken($token); } $response = $http->post($this->url(), $requestPayload); $responseText = $this->formatResponseText($response->json(), $response->body()); $aiRequest->update([ 'response_text' => $responseText, 'status' => $response->successful() ? 'answered' : 'open', 'updated_by' => $user?->id, ]); return [ 'ok' => $response->successful(), 'ai_request_id' => $aiRequest->id, 'status' => $response->status(), 'message' => $response->successful() ? 'Risposta mini AI ricevuta.' : 'Mini AI ha risposto con stato HTTP ' . $response->status() . '.', 'response' => $response->json() ?? $response->body(), ]; } catch (\Throwable $e) { $aiRequest->update([ 'response_text' => 'Errore chiamata mini AI: ' . $e->getMessage(), 'status' => 'open', 'updated_by' => $user?->id, ]); return [ 'ok' => false, 'ai_request_id' => $aiRequest->id, 'message' => $e->getMessage(), 'response' => null, ]; } } private function isEnabled(): bool { return (bool) config('services.mini_ai.enabled', false) && $this->baseUrl() !== ''; } private function disabledMessage(): string { $baseUrl = $this->baseUrl() !== '' ? $this->baseUrl() : 'non impostato'; return 'Mini AI non abilitata: impostare MINI_AI_ENABLED=true e verificare MINI_AI_BASE_URL verso la macchina .202. Base URL corrente: ' . $baseUrl . '. Runbook: docs/support/MINI-AI-202-RICONCILIAZIONE-RUNBOOK.md'; } private function url(): string { return rtrim($this->baseUrl(), '/') . '/' . ltrim($this->path(), '/'); } private function baseUrl(): string { return trim((string) config('services.mini_ai.base_url', '')); } private function path(): string { return trim((string) config('services.mini_ai.reconciliation_path', '/api/v1/reconciliation/incassi')); } private function formatResponseText(mixed $json, string $body): string { if (is_array($json)) { return json_encode($json, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: ''; } return Str::limit($body, 100000, ''); } }