netgescon-day0/app/Services/Contabilita/MiniAiReconciliationClient.php

309 lines
13 KiB
PHP
Executable File

<?php
namespace App\Services\Contabilita;
use App\Models\AiRequest;
use App\Models\User;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
class MiniAiReconciliationClient
{
public function status(): array
{
$enabled = (bool) config('services.mini_ai.enabled', false);
$baseUrl = $this->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, '');
}
}