309 lines
13 KiB
PHP
Executable File
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, '');
|
|
}
|
|
}
|