nis2-agile/application/services/VisuraService.php
DevEnv nis2-agile 75a678f60e [FEAT] CertiSource atti-service.php integration: structured data, PAT auth, ATECO fix
- VisuraService::fetchFromCertiSource: new atti-service.php API (POST richiesta → polling stato → GET dati)
- Structured data mapping: sedi/ateco_codes/cariche/addetti → formato interno
- mapAtecoToNis2Sector: allineato ENUM DB (digital_infra, water, waste, public_admin, ecc.)
- config.php: CERTISOURCE_API_URL, CERTISOURCE_API_KEY, CERTISOURCE_POLL_MAX/SEC
- PHP 8.4: curl_close → unset,  usato in logAiInteraction

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 13:24:03 +01:00

470 lines
20 KiB
PHP

<?php
/**
* NIS2 Agile - Visura Service
*
* Estrae dati aziendali da visura camerale PDF tramite AI (upload)
* oppure tramite CertiSource atti-service.php (ricerca per P.IVA).
*
* CertiSource API: https://certisource.it/atti-service.php
* Auth: Authorization: Bearer CERTISOURCE_API_KEY (cs_pat_...)
*/
class VisuraService
{
// ─────────────────────────────────────────────────────────────────────────
// METODI PUBBLICI
// ─────────────────────────────────────────────────────────────────────────
/**
* Estrai dati aziendali da un PDF visura caricato dall'utente (via Claude AI).
*/
public function extractFromPdf(string $filePath): array
{
if (!file_exists($filePath)) {
throw new RuntimeException('File visura non trovato');
}
if (!ANTHROPIC_API_KEY) {
throw new RuntimeException('Chiave API Anthropic non configurata');
}
$base64Pdf = base64_encode(file_get_contents($filePath));
$response = $this->callClaudeApi([
[
'type' => 'document',
'source' => [
'type' => 'base64',
'media_type' => 'application/pdf',
'data' => $base64Pdf,
],
],
[
'type' => 'text',
'text' => "Analizza questa visura camerale italiana ed estrai i seguenti dati in formato JSON. Rispondi SOLO con il JSON, senza testo aggiuntivo, senza markdown code blocks.\n\nCampi da estrarre:\n- company_name: ragione sociale completa\n- vat_number: partita IVA (solo numeri, senza prefisso IT)\n- fiscal_code: codice fiscale\n- legal_form: forma giuridica (es. S.R.L., S.P.A., ecc.)\n- address: indirizzo sede legale (via/piazza e numero civico)\n- city: comune sede legale\n- province: sigla provincia (es. MI, RM, TO)\n- zip_code: CAP\n- pec: indirizzo PEC se presente\n- phone: telefono se presente\n- ateco_code: codice ATECO principale se presente\n- ateco_description: descrizione attività ATECO se presente\n- incorporation_date: data di costituzione (formato YYYY-MM-DD)\n- share_capital: capitale sociale in EUR (solo numero)\n- employees_range: stima range dipendenti se indicato (es. \"10-49\", \"50-249\", \"250+\")\n- legal_representative: nome e cognome del legale rappresentante\n\nSe un campo non è presente nella visura, usa null come valore.",
],
]);
if (!$response) {
throw new RuntimeException("Nessuna risposta dall'AI");
}
$jsonStr = preg_replace('/^```(?:json)?\s*/i', '', trim($response));
$jsonStr = preg_replace('/\s*```$/', '', $jsonStr);
$data = json_decode($jsonStr, true);
if (json_last_error() !== JSON_ERROR_NONE) {
error_log('[VISURA_PARSE_ERROR] ' . $jsonStr);
throw new RuntimeException('Impossibile interpretare i dati estratti dalla visura');
}
$data['suggested_sector'] = $this->mapAtecoToNis2Sector(
$data['ateco_code'] ?? '',
$data['ateco_description'] ?? ''
);
$data['source'] = 'pdf_upload';
$this->logAiInteraction('visura_extraction', 'Estrazione dati da visura camerale PDF');
return $data;
}
/**
* Recupera dati aziendali da CertiSource atti-service.php tramite P.IVA.
*
* Flusso:
* POST ?action=richiesta → ottieni request ID
* GET ?action=stato → polling fino a completed
* GET ?action=dati → structured_data JSON
*/
public function fetchFromCertiSource(string $vatNumber): array
{
$apiKey = defined('CERTISOURCE_API_KEY') ? CERTISOURCE_API_KEY : '';
if ($apiKey === '') {
error_log('[CERTISOURCE] API key non configurata (CERTISOURCE_API_KEY)');
throw new RuntimeException('Servizio visure non disponibile. Inserire i dati manualmente.');
}
// ── Step 1: richiesta visura ─────────────────────────────────────────
$richiesta = $this->callCertiSource('POST', 'richiesta', [
'vat' => $vatNumber,
'type' => 'visura_camerale',
]);
if (empty($richiesta['success'])) {
$msg = $richiesta['error'] ?? $richiesta['message'] ?? 'Errore richiesta visura';
throw new RuntimeException('CertiSource: ' . $msg);
}
$requestId = (int) $richiesta['id'];
$status = $richiesta['status'] ?? 'processing';
// ── Step 2: polling se non già completato (cache hit) ────────────────
if ($status !== 'completed') {
$pollMax = defined('CERTISOURCE_POLL_MAX') ? (int) CERTISOURCE_POLL_MAX : 30;
$pollSec = defined('CERTISOURCE_POLL_SEC') ? (int) CERTISOURCE_POLL_SEC : 3;
for ($i = 0; $i < $pollMax; $i++) {
sleep($pollSec);
$stato = $this->callCertiSource('GET', 'stato', null, ['id' => $requestId]);
if (!empty($stato['completed'])) {
$status = 'completed';
break;
}
if (($stato['status'] ?? '') === 'failed') {
throw new RuntimeException('CertiSource: elaborazione visura fallita');
}
}
if ($status !== 'completed') {
throw new RuntimeException('Timeout: visura camerale non disponibile. Riprova tra qualche minuto.');
}
}
// ── Step 3: dati strutturati ─────────────────────────────────────────
$datiRes = $this->callCertiSource('GET', 'dati', null, ['id' => $requestId]);
if (empty($datiRes['success']) || empty($datiRes['structured_data'])) {
throw new RuntimeException('CertiSource: dati strutturati non disponibili');
}
return $this->mapStructuredData($datiRes['structured_data'], $vatNumber, $requestId);
}
// ─────────────────────────────────────────────────────────────────────────
// METODI PRIVATI — CertiSource
// ─────────────────────────────────────────────────────────────────────────
/**
* Chiama l'API CertiSource atti-service.php.
*
* @param string $method GET|POST
* @param string $action action=richiesta|stato|dati|...
* @param array|null $body JSON body per POST
* @param array $params Query params extra (es. ['id'=>42])
*/
private function callCertiSource(string $method, string $action, ?array $body = null, array $params = []): array
{
$apiKey = defined('CERTISOURCE_API_KEY') ? CERTISOURCE_API_KEY : '';
$baseUrl = defined('CERTISOURCE_API_URL') ? CERTISOURCE_API_URL : 'https://certisource.it/atti-service.php';
$qs = http_build_query(array_merge(['action' => $action], $params));
$url = $baseUrl . '?' . $qs;
$ch = curl_init($url);
$headers = [
'Authorization: Bearer ' . $apiKey,
'Content-Type: application/json',
'Accept: application/json',
];
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
CURLOPT_HTTPHEADER => $headers,
]);
if ($method === 'POST' && $body !== null) {
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
}
$raw = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlErr = curl_error($ch);
unset($ch);
if ($curlErr) {
throw new RuntimeException('CertiSource connessione: ' . $curlErr);
}
if ($httpCode === 402) {
throw new RuntimeException('CertiSource: credito insufficiente');
}
if ($httpCode >= 500) {
throw new RuntimeException('CertiSource: errore server (HTTP ' . $httpCode . ')');
}
$result = json_decode($raw ?: '{}', true);
if (!is_array($result)) {
throw new RuntimeException('CertiSource: risposta non valida');
}
return $result;
}
/**
* Mappa structured_data CertiSource nel formato interno NIS2 Agile.
*/
private function mapStructuredData(array $sd, string $vatNumber, int $requestId): array
{
$sedeLegale = $this->extractSedeLegale($sd['sedi'] ?? []);
$ateco = $this->extractPrimaryAteco($sd['ateco_codes'] ?? []);
$atecoCode = $ateco['code'] ?? null;
$atecoDesc = $ateco['description'] ?? '';
return [
'company_name' => $sd['ragione_sociale'] ?? null,
'vat_number' => $sd['partita_iva'] ?? $vatNumber,
'fiscal_code' => $sd['codice_fiscale'] ?? null,
'legal_form' => $sd['forma_giuridica'] ?? null,
'address' => $sedeLegale['via'] ?? null,
'city' => $sedeLegale['comune'] ?? null,
'province' => $sedeLegale['provincia'] ?? null,
'zip_code' => $sedeLegale['cap'] ?? null,
'pec' => $sd['pec'] ?? $sedeLegale['pec'] ?? null,
'phone' => null,
'ateco_code' => $atecoCode,
'ateco_description' => $atecoDesc ?: null,
'incorporation_date' => null,
'share_capital' => isset($sd['capitale_sociale']) ? (float) $sd['capitale_sociale'] : null,
'employees_range' => $this->computeEmployeesRange($sd['addetti'] ?? null),
'legal_representative' => $this->findLegalRepresentative($sd['cariche'] ?? []),
'suggested_sector' => $this->mapAtecoToNis2Sector($atecoCode ?? '', $atecoDesc),
'source' => 'certisource_atti',
'certisource_id' => $requestId,
];
}
/**
* Trova la sede legale nell'array sedi.
*/
private function extractSedeLegale(array $sedi): array
{
foreach ($sedi as $sede) {
if (($sede['tipo'] ?? '') === 'sede_legale') {
return $sede;
}
}
return $sedi[0] ?? [];
}
/**
* Trova il codice ATECO primario.
*/
private function extractPrimaryAteco(array $atecos): array
{
foreach ($atecos as $a) {
if (($a['type'] ?? '') === 'primary') {
return $a;
}
}
return $atecos[0] ?? [];
}
/**
* Calcola il range dipendenti dai dati INPS (ultimo trimestre disponibile).
*/
private function computeEmployeesRange(?array $addetti): ?string
{
if (empty($addetti['trimestri'])) {
return null;
}
$last = end($addetti['trimestri']);
$n = (int) ($last['dipendenti'] ?? 0);
if ($n === 0) return null;
if ($n < 10) return '1-9';
if ($n < 50) return '10-49';
if ($n < 250) return '50-249';
return '250+';
}
/**
* Trova il legale rappresentante tra le cariche.
*/
private function findLegalRepresentative(array $cariche): ?string
{
foreach ($cariche as $c) {
if (!empty($c['rappresentante_legale']) || str_contains(strtolower($c['ruolo'] ?? ''), 'legale')) {
return $c['nome'] ?? null;
}
}
// Fallback: primo amministratore
foreach ($cariche as $c) {
if (str_contains(strtolower($c['ruolo'] ?? ''), 'ammin')) {
return $c['nome'] ?? null;
}
}
return null;
}
// ─────────────────────────────────────────────────────────────────────────
// ATECO → SETTORE NIS2 (valori ENUM DB)
// ─────────────────────────────────────────────────────────────────────────
/**
* Mappa codice ATECO + descrizione al settore NIS2 (valori ENUM organizations.sector).
*
* Valori validi: energy, transport, banking, health, water, digital_infra,
* public_admin, manufacturing, postal, chemical, food, waste,
* ict_services, digital_providers, space, research, other
*/
private function mapAtecoToNis2Sector(string $atecoCode, string $atecoDesc): ?string
{
$code2 = substr(preg_replace('/[^0-9]/', '', $atecoCode), 0, 2);
$desc = strtolower($atecoDesc);
$byCode = [
'35' => 'energy', // Fornitura energia elettrica, gas
'06' => 'energy', // Estrazione petrolio e gas
'19' => 'energy', // Raffinazione petrolio
'49' => 'transport', // Trasporto terrestre
'50' => 'transport', // Trasporto marittimo
'51' => 'transport', // Trasporto aereo
'52' => 'transport', // Magazzinaggio e attività connesse
'64' => 'banking', // Servizi finanziari
'65' => 'banking', // Assicurazioni
'66' => 'banking', // Attività ausiliarie servizi finanziari
'86' => 'health', // Assistenza sanitaria
'87' => 'health', // Strutture di assistenza
'36' => 'water', // Raccolta e distribuzione acqua
'37' => 'waste', // Raccolta e trattamento acque reflue
'38' => 'waste', // Raccolta e smaltimento rifiuti
'61' => 'digital_infra', // Telecomunicazioni
'62' => 'ict_services', // Produzione software e IT
'63' => 'digital_providers',// Servizi di informazione
'84' => 'public_admin', // Pubblica amministrazione
'53' => 'postal', // Servizi postali e corrieri
'20' => 'chemical', // Fabbricazione prodotti chimici
'10' => 'food', // Industria alimentare
'11' => 'food', // Industria delle bevande
'21' => 'manufacturing', // Fabbricazione farmaci
'26' => 'manufacturing', // Fabbricazione computer e elettronica
'27' => 'manufacturing', // Fabbricazione apparecchiature elettriche
'28' => 'manufacturing', // Fabbricazione macchinari
'29' => 'manufacturing', // Fabbricazione autoveicoli
'30' => 'manufacturing', // Fabbricazione altri mezzi di trasporto
'72' => 'research', // Ricerca e sviluppo
'85' => 'research', // Istruzione
];
if (isset($byCode[$code2])) {
return $byCode[$code2];
}
// Fallback per keyword nella descrizione
$byKeyword = [
'energia' => 'energy',
'elettric' => 'energy',
'gas' => 'energy',
'petroli' => 'energy',
'idrogeno' => 'energy',
'trasport' => 'transport',
'ferrov' => 'transport',
'maritt' => 'transport',
'aereo' => 'transport',
'aere' => 'transport',
'logistic' => 'transport',
'banc' => 'banking',
'finanz' => 'banking',
'assicur' => 'banking',
'sanit' => 'health',
'osped' => 'health',
'clinic' => 'health',
'medic' => 'health',
'acqua' => 'water',
'idric' => 'water',
'rifiut' => 'waste',
'smaltim' => 'waste',
'telecom' => 'digital_infra',
'teleco' => 'digital_infra',
'fibra' => 'digital_infra',
'internet' => 'digital_infra',
'informatica' => 'ict_services',
'software' => 'ict_services',
'cloud' => 'ict_services',
'digital' => 'digital_providers',
'piattaform' => 'digital_providers',
'postale' => 'postal',
'corriere' => 'postal',
'spediz' => 'postal',
'chimic' => 'chemical',
'farmac' => 'manufacturing',
'alimentar' => 'food',
'bevand' => 'food',
'ricerca' => 'research',
'spazial' => 'space',
'aerospaz' => 'space',
'manifattur' => 'manufacturing',
];
foreach ($byKeyword as $kw => $sector) {
if (str_contains($desc, $kw)) {
return $sector;
}
}
return null;
}
// ─────────────────────────────────────────────────────────────────────────
// METODI PRIVATI — Claude AI
// ─────────────────────────────────────────────────────────────────────────
private function callClaudeApi(array $content): ?string
{
$payload = [
'model' => ANTHROPIC_MODEL,
'max_tokens' => ANTHROPIC_MAX_TOKENS,
'messages' => [['role' => 'user', 'content' => $content]],
];
$ch = curl_init('https://api.anthropic.com/v1/messages');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 60,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'x-api-key: ' . ANTHROPIC_API_KEY,
'anthropic-version: 2023-06-01',
],
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
unset($ch);
if ($error) throw new RuntimeException('Claude API error: ' . $error);
if ($httpCode !== 200) {
error_log('[CLAUDE_API_ERROR] HTTP ' . $httpCode . ': ' . $response);
throw new RuntimeException('Claude API returned HTTP ' . $httpCode);
}
$result = json_decode($response, true);
return $result['content'][0]['text'] ?? null;
}
private function logAiInteraction(string $type, string $summary): void
{
try {
$userId = null;
$token = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
if (preg_match('/Bearer\s+(.+)$/i', $token, $m)) {
$parts = explode('.', $m[1]);
if (count($parts) === 3) {
$pl = json_decode(base64_decode(strtr($parts[1], '-_', '+/')), true);
$userId = $pl['sub'] ?? null;
}
}
if ($userId) {
Database::insert('ai_interactions', [
'organization_id' => 0,
'user_id' => $userId,
'interaction_type' => $type,
'prompt_summary' => $summary,
'response_summary' => 'Dati estratti',
'tokens_used' => 0,
'model_used' => ANTHROPIC_MODEL,
]);
}
} catch (Throwable $e) {
error_log('[AI_LOG_ERROR] ' . $e->getMessage());
}
}
}