[FEAT] Add onboarding wizard with visura camerale and CertiSource integration
- New 5-step onboarding wizard (onboarding.html) replacing setup-org.html - Step 1: Choose data source (Upload Visura / CertiSource / Manual) - Step 2: PDF upload with AI extraction or CertiSource P.IVA lookup - Step 3: Verify/complete company data with NIS2 sector mapping - Step 4: User profile completion - Step 5: NIS2 classification (Essential/Important) with summary - OnboardingController with upload-visura, fetch-company, complete endpoints - VisuraService with Claude AI PDF extraction and ATECO-to-NIS2 mapping - CertiSource API integration for automatic company data retrieval - Updated login/register redirects to point to new onboarding wizard Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
73e78ea6b4
commit
9aa2788c68
332
application/controllers/OnboardingController.php
Normal file
332
application/controllers/OnboardingController.php
Normal file
@ -0,0 +1,332 @@
|
||||
<?php
|
||||
/**
|
||||
* NIS2 Agile - Onboarding Controller
|
||||
*
|
||||
* Gestisce il wizard di onboarding: upload visura, fetch CertiSource, completamento.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/BaseController.php';
|
||||
require_once __DIR__ . '/../services/VisuraService.php';
|
||||
|
||||
class OnboardingController extends BaseController
|
||||
{
|
||||
/**
|
||||
* POST /api/onboarding/upload-visura
|
||||
* Riceve PDF visura camerale, estrae dati con AI
|
||||
*/
|
||||
public function uploadVisura(): void
|
||||
{
|
||||
$this->requireAuth();
|
||||
|
||||
// Validate file upload
|
||||
if (!isset($_FILES['visura']) || $_FILES['visura']['error'] !== UPLOAD_ERR_OK) {
|
||||
$this->jsonError('Nessun file caricato o errore upload', 400, 'UPLOAD_ERROR');
|
||||
}
|
||||
|
||||
$file = $_FILES['visura'];
|
||||
|
||||
// Validate file type (PDF only)
|
||||
$allowedTypes = ['application/pdf'];
|
||||
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
||||
$mimeType = finfo_file($finfo, $file['tmp_name']);
|
||||
finfo_close($finfo);
|
||||
|
||||
if (!in_array($mimeType, $allowedTypes)) {
|
||||
$this->jsonError('Solo file PDF sono accettati', 400, 'INVALID_FILE_TYPE');
|
||||
}
|
||||
|
||||
// Max 10MB
|
||||
if ($file['size'] > 10 * 1024 * 1024) {
|
||||
$this->jsonError('File troppo grande (max 10MB)', 400, 'FILE_TOO_LARGE');
|
||||
}
|
||||
|
||||
// Save file temporarily
|
||||
$uploadDir = UPLOAD_PATH . '/visure';
|
||||
if (!is_dir($uploadDir)) {
|
||||
mkdir($uploadDir, 0755, true);
|
||||
}
|
||||
|
||||
$fileName = 'visura_' . $this->getCurrentUserId() . '_' . time() . '.pdf';
|
||||
$filePath = $uploadDir . '/' . $fileName;
|
||||
|
||||
if (!move_uploaded_file($file['tmp_name'], $filePath)) {
|
||||
$this->jsonError('Errore nel salvataggio del file', 500, 'SAVE_ERROR');
|
||||
}
|
||||
|
||||
try {
|
||||
// Extract data using AI
|
||||
$visuraService = new VisuraService();
|
||||
$extractedData = $visuraService->extractFromPdf($filePath);
|
||||
|
||||
$this->logAudit('visura_uploaded', 'onboarding', null, [
|
||||
'file' => $fileName,
|
||||
'extracted' => !empty($extractedData)
|
||||
]);
|
||||
|
||||
$this->jsonSuccess($extractedData, 'Dati estratti dalla visura');
|
||||
} catch (Throwable $e) {
|
||||
error_log('[VISURA_ERROR] ' . $e->getMessage());
|
||||
$this->jsonError(
|
||||
APP_DEBUG ? 'Errore estrazione: ' . $e->getMessage() : 'Errore nell\'analisi della visura',
|
||||
500,
|
||||
'EXTRACTION_ERROR'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/onboarding/fetch-company
|
||||
* Recupera dati aziendali da CertiSource tramite P.IVA
|
||||
*/
|
||||
public function fetchCompany(): void
|
||||
{
|
||||
$this->requireAuth();
|
||||
$this->validateRequired(['vat_number']);
|
||||
|
||||
$vatNumber = trim($this->getParam('vat_number'));
|
||||
|
||||
// Clean VAT: remove IT prefix, spaces
|
||||
$vatNumber = preg_replace('/^IT/i', '', $vatNumber);
|
||||
$vatNumber = preg_replace('/\s+/', '', $vatNumber);
|
||||
|
||||
// Validate Italian P.IVA format (11 digits)
|
||||
if (!preg_match('/^\d{11}$/', $vatNumber)) {
|
||||
$this->jsonError('Formato Partita IVA non valido (11 cifre)', 400, 'INVALID_VAT');
|
||||
}
|
||||
|
||||
try {
|
||||
$visuraService = new VisuraService();
|
||||
$companyData = $visuraService->fetchFromCertiSource($vatNumber);
|
||||
|
||||
$this->logAudit('certisource_fetch', 'onboarding', null, [
|
||||
'vat_number' => $vatNumber,
|
||||
'found' => !empty($companyData)
|
||||
]);
|
||||
|
||||
if (empty($companyData) || empty($companyData['company_name'])) {
|
||||
$this->jsonError(
|
||||
'Azienda non trovata per la P.IVA fornita',
|
||||
404,
|
||||
'COMPANY_NOT_FOUND'
|
||||
);
|
||||
}
|
||||
|
||||
$this->jsonSuccess($companyData, 'Dati aziendali recuperati');
|
||||
} catch (Throwable $e) {
|
||||
error_log('[CERTISOURCE_ERROR] ' . $e->getMessage());
|
||||
$this->jsonError(
|
||||
APP_DEBUG ? 'Errore CertiSource: ' . $e->getMessage() : 'Errore nel recupero dati aziendali',
|
||||
500,
|
||||
'CERTISOURCE_ERROR'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/onboarding/complete
|
||||
* Completa l'onboarding: crea organizzazione e aggiorna profilo utente
|
||||
*/
|
||||
public function complete(): void
|
||||
{
|
||||
$this->requireAuth();
|
||||
$this->validateRequired(['name', 'sector']);
|
||||
|
||||
$userId = $this->getCurrentUserId();
|
||||
|
||||
// Check if user already has an organization
|
||||
$existingOrg = Database::fetchOne(
|
||||
'SELECT organization_id FROM user_organizations WHERE user_id = ? AND is_primary = 1',
|
||||
[$userId]
|
||||
);
|
||||
|
||||
if ($existingOrg) {
|
||||
$this->jsonError('Hai già un\'organizzazione configurata', 409, 'ORG_EXISTS');
|
||||
}
|
||||
|
||||
Database::beginTransaction();
|
||||
try {
|
||||
// Create organization
|
||||
$orgData = [
|
||||
'name' => trim($this->getParam('name')),
|
||||
'vat_number' => $this->getParam('vat_number'),
|
||||
'fiscal_code' => $this->getParam('fiscal_code'),
|
||||
'sector' => $this->getParam('sector'),
|
||||
'employee_count' => $this->getParam('employee_count'),
|
||||
'annual_turnover_eur' => $this->getParam('annual_turnover_eur'),
|
||||
'country' => $this->getParam('country', 'IT'),
|
||||
'city' => $this->getParam('city'),
|
||||
'address' => $this->getParam('address'),
|
||||
'website' => $this->getParam('website'),
|
||||
'contact_email' => $this->getParam('contact_email'),
|
||||
'contact_phone' => $this->getParam('contact_phone'),
|
||||
];
|
||||
|
||||
$orgId = Database::insert('organizations', $orgData);
|
||||
|
||||
// Auto-classify NIS2 entity type
|
||||
$entityType = $this->classifyNis2Entity(
|
||||
$orgData['sector'],
|
||||
(int) ($orgData['employee_count'] ?? 0),
|
||||
(float) ($orgData['annual_turnover_eur'] ?? 0)
|
||||
);
|
||||
|
||||
Database::update('organizations', [
|
||||
'entity_type' => $entityType,
|
||||
], 'id = ?', [$orgId]);
|
||||
|
||||
// Link user as org_admin
|
||||
Database::insert('user_organizations', [
|
||||
'user_id' => $userId,
|
||||
'organization_id' => $orgId,
|
||||
'role' => 'org_admin',
|
||||
'is_primary' => 1,
|
||||
]);
|
||||
|
||||
// Update user profile if provided
|
||||
$profileUpdates = [];
|
||||
if ($this->hasParam('phone') && $this->getParam('phone')) {
|
||||
$profileUpdates['phone'] = $this->getParam('phone');
|
||||
}
|
||||
if ($this->hasParam('full_name') && $this->getParam('full_name')) {
|
||||
$profileUpdates['full_name'] = trim($this->getParam('full_name'));
|
||||
}
|
||||
if (!empty($profileUpdates)) {
|
||||
Database::update('users', $profileUpdates, 'id = ?', [$userId]);
|
||||
}
|
||||
|
||||
// Initialize NIS2 compliance controls
|
||||
$this->initializeComplianceControls($orgId);
|
||||
|
||||
Database::commit();
|
||||
|
||||
$this->currentOrgId = $orgId;
|
||||
$this->logAudit('onboarding_completed', 'organization', $orgId, [
|
||||
'name' => $orgData['name'],
|
||||
'sector' => $orgData['sector'],
|
||||
'entity_type' => $entityType
|
||||
]);
|
||||
|
||||
$this->jsonSuccess([
|
||||
'organization_id' => $orgId,
|
||||
'name' => $orgData['name'],
|
||||
'entity_type' => $entityType,
|
||||
'classification' => $this->getClassificationDetails($entityType, $orgData['sector'], (int)($orgData['employee_count'] ?? 0), (float)($orgData['annual_turnover_eur'] ?? 0)),
|
||||
], 'Onboarding completato', 201);
|
||||
|
||||
} catch (Throwable $e) {
|
||||
Database::rollback();
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// PRIVATE METHODS (copied from OrganizationController for independence)
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
private function classifyNis2Entity(string $sector, int $employees, float $turnover): string
|
||||
{
|
||||
// Map detailed sector codes to NIS2 categories
|
||||
$essentialSectors = [
|
||||
'energy', 'energy_electricity', 'energy_district_heating', 'energy_oil', 'energy_gas', 'energy_hydrogen',
|
||||
'transport', 'transport_air', 'transport_rail', 'transport_water', 'transport_road',
|
||||
'banking', 'financial_markets', 'health', 'water', 'drinking_water', 'waste_water',
|
||||
'digital_infra', 'digital_infrastructure', 'ict_service_management',
|
||||
'public_admin', 'public_administration', 'space',
|
||||
];
|
||||
|
||||
$importantSectors = [
|
||||
'manufacturing', 'manufacturing_medical', 'manufacturing_computers', 'manufacturing_electrical',
|
||||
'manufacturing_machinery', 'manufacturing_vehicles', 'manufacturing_transport',
|
||||
'postal', 'postal_courier', 'chemical', 'chemicals', 'food',
|
||||
'waste', 'waste_management', 'ict_services', 'digital_providers', 'research',
|
||||
];
|
||||
|
||||
$isLarge = $employees >= 250 || $turnover >= 50000000;
|
||||
$isMedium = ($employees >= 50 || $turnover >= 10000000) && !$isLarge;
|
||||
|
||||
if (in_array($sector, $essentialSectors)) {
|
||||
if ($isLarge) return 'essential';
|
||||
if ($isMedium) return 'important';
|
||||
}
|
||||
|
||||
if (in_array($sector, $importantSectors)) {
|
||||
if ($isLarge || $isMedium) return 'important';
|
||||
}
|
||||
|
||||
return 'not_applicable';
|
||||
}
|
||||
|
||||
private function getClassificationDetails(string $type, string $sector, int $employees, float $turnover): array
|
||||
{
|
||||
$sizeLabel = $employees >= 250 ? 'Grande Impresa' : ($employees >= 50 ? 'Media Impresa' : 'Piccola Impresa');
|
||||
|
||||
return match ($type) {
|
||||
'essential' => [
|
||||
'type' => 'essential',
|
||||
'label' => 'Soggetto Essenziale',
|
||||
'size' => $sizeLabel,
|
||||
'description' => "La vostra organizzazione rientra tra i soggetti essenziali ai sensi della Direttiva NIS2 (UE 2022/2555). Operate in un settore ad alta criticità e superate le soglie dimensionali.",
|
||||
'obligations' => [
|
||||
'Misure di sicurezza informatica (Art. 21)',
|
||||
'Notifica incidenti significativi al CSIRT entro 24h/72h/30gg (Art. 23)',
|
||||
'Formazione obbligatoria per gli organi di gestione (Art. 20)',
|
||||
'Vigilanza proattiva (ex ante) da parte delle autorità',
|
||||
'Sanzioni fino a EUR 10M o 2% del fatturato mondiale annuo',
|
||||
],
|
||||
],
|
||||
'important' => [
|
||||
'type' => 'important',
|
||||
'label' => 'Soggetto Importante',
|
||||
'size' => $sizeLabel,
|
||||
'description' => "La vostra organizzazione rientra tra i soggetti importanti ai sensi della Direttiva NIS2. Siete tenuti a rispettare gli obblighi di sicurezza e notifica incidenti.",
|
||||
'obligations' => [
|
||||
'Misure di sicurezza informatica (Art. 21)',
|
||||
'Notifica incidenti significativi al CSIRT entro 24h/72h/30gg (Art. 23)',
|
||||
'Formazione obbligatoria per gli organi di gestione (Art. 20)',
|
||||
'Vigilanza reattiva (ex post) da parte delle autorità',
|
||||
'Sanzioni fino a EUR 7M o 1,4% del fatturato mondiale annuo',
|
||||
],
|
||||
],
|
||||
default => [
|
||||
'type' => 'not_applicable',
|
||||
'label' => 'Non Applicabile',
|
||||
'size' => $sizeLabel,
|
||||
'description' => "In base ai dati forniti, la vostra organizzazione non sembra rientrare nell'ambito di applicazione della Direttiva NIS2. Consigliamo comunque di adottare le best practice di cybersecurity.",
|
||||
'obligations' => [
|
||||
'Nessun obbligo NIS2 specifico',
|
||||
'Si raccomandano comunque buone pratiche di sicurezza informatica',
|
||||
'Possibile designazione futura da parte delle autorità nazionali',
|
||||
],
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
private function initializeComplianceControls(int $orgId): void
|
||||
{
|
||||
$controls = [
|
||||
['NIS2-21.2.a', 'nis2', 'Politiche di analisi dei rischi e sicurezza dei sistemi informatici'],
|
||||
['NIS2-21.2.b', 'nis2', 'Gestione degli incidenti'],
|
||||
['NIS2-21.2.c', 'nis2', 'Continuità operativa e gestione delle crisi'],
|
||||
['NIS2-21.2.d', 'nis2', 'Sicurezza della catena di approvvigionamento'],
|
||||
['NIS2-21.2.e', 'nis2', 'Sicurezza acquisizione, sviluppo e manutenzione sistemi'],
|
||||
['NIS2-21.2.f', 'nis2', 'Politiche e procedure per valutare efficacia misure'],
|
||||
['NIS2-21.2.g', 'nis2', 'Pratiche di igiene informatica di base e formazione'],
|
||||
['NIS2-21.2.h', 'nis2', 'Politiche e procedure relative alla crittografia'],
|
||||
['NIS2-21.2.i', 'nis2', 'Sicurezza risorse umane, controllo accessi e gestione asset'],
|
||||
['NIS2-21.2.j', 'nis2', 'Autenticazione multi-fattore e comunicazioni sicure'],
|
||||
['NIS2-20.1', 'nis2', 'Governance: approvazione misure da parte degli organi di gestione'],
|
||||
['NIS2-20.2', 'nis2', 'Formazione obbligatoria per gli organi di gestione'],
|
||||
['NIS2-23.1', 'nis2', 'Notifica incidenti significativi al CSIRT (24h/72h/30gg)'],
|
||||
];
|
||||
|
||||
foreach ($controls as [$code, $framework, $title]) {
|
||||
Database::insert('compliance_controls', [
|
||||
'organization_id' => $orgId,
|
||||
'control_code' => $code,
|
||||
'framework' => $framework,
|
||||
'title' => $title,
|
||||
'status' => 'not_started',
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
310
application/services/VisuraService.php
Normal file
310
application/services/VisuraService.php
Normal file
@ -0,0 +1,310 @@
|
||||
<?php
|
||||
/**
|
||||
* NIS2 Agile - Visura Service
|
||||
*
|
||||
* Estrae dati aziendali da visura camerale PDF tramite AI
|
||||
* e recupera dati da CertiSource.
|
||||
*/
|
||||
|
||||
class VisuraService
|
||||
{
|
||||
/**
|
||||
* Extract company data from a PDF visura camerale using 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');
|
||||
}
|
||||
|
||||
// Read PDF and base64 encode it
|
||||
$pdfContent = file_get_contents($filePath);
|
||||
$base64Pdf = base64_encode($pdfContent);
|
||||
|
||||
// Call Claude API with the PDF
|
||||
$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');
|
||||
}
|
||||
|
||||
// Parse JSON response
|
||||
$jsonStr = trim($response);
|
||||
// Remove potential markdown code blocks
|
||||
$jsonStr = preg_replace('/^```(?:json)?\s*/i', '', $jsonStr);
|
||||
$jsonStr = preg_replace('/\s*```$/', '', $jsonStr);
|
||||
|
||||
$data = json_decode($jsonStr, true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
error_log('[VISURA_PARSE_ERROR] Could not parse AI response: ' . $jsonStr);
|
||||
throw new RuntimeException('Impossibile interpretare i dati estratti dalla visura');
|
||||
}
|
||||
|
||||
// Map to suggested NIS2 sector based on ATECO code
|
||||
$data['suggested_sector'] = $this->mapAtecoToNis2Sector($data['ateco_code'] ?? '', $data['ateco_description'] ?? '');
|
||||
|
||||
// Log AI interaction
|
||||
$this->logAiInteraction('visura_extraction', 'Estrazione dati da visura camerale PDF');
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch company data from CertiSource API
|
||||
*/
|
||||
public function fetchFromCertiSource(string $vatNumber): array
|
||||
{
|
||||
// CertiSource is on the same server - call its API internally
|
||||
$certisourceUrl = $this->getCertiSourceBaseUrl() . '/api/company/enrich';
|
||||
|
||||
$ch = curl_init();
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_URL => $certisourceUrl . '?vat=' . urlencode($vatNumber),
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Content-Type: application/json',
|
||||
'Accept: application/json',
|
||||
'X-Internal-Service: nis2-agile',
|
||||
],
|
||||
// Same server, skip SSL verification for internal calls
|
||||
CURLOPT_SSL_VERIFYPEER => false,
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$error = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($error) {
|
||||
error_log("[CERTISOURCE_CURL_ERROR] $error");
|
||||
throw new RuntimeException('Impossibile contattare CertiSource: ' . $error);
|
||||
}
|
||||
|
||||
if ($httpCode !== 200) {
|
||||
error_log("[CERTISOURCE_HTTP_ERROR] HTTP $httpCode: $response");
|
||||
throw new RuntimeException('CertiSource ha restituito un errore (HTTP ' . $httpCode . ')');
|
||||
}
|
||||
|
||||
$result = json_decode($response, true);
|
||||
if (!$result) {
|
||||
throw new RuntimeException('Risposta CertiSource non valida');
|
||||
}
|
||||
|
||||
// Map CertiSource response to our format
|
||||
// CertiSource typically returns data in its own format, normalize it
|
||||
$companyData = $result['data'] ?? $result;
|
||||
|
||||
return [
|
||||
'company_name' => $companyData['ragione_sociale'] ?? $companyData['denominazione'] ?? $companyData['company_name'] ?? null,
|
||||
'vat_number' => $companyData['partita_iva'] ?? $companyData['vat_number'] ?? $vatNumber,
|
||||
'fiscal_code' => $companyData['codice_fiscale'] ?? $companyData['fiscal_code'] ?? null,
|
||||
'legal_form' => $companyData['forma_giuridica'] ?? $companyData['legal_form'] ?? null,
|
||||
'address' => $companyData['indirizzo'] ?? $companyData['address'] ?? null,
|
||||
'city' => $companyData['comune'] ?? $companyData['city'] ?? null,
|
||||
'province' => $companyData['provincia'] ?? $companyData['province'] ?? null,
|
||||
'zip_code' => $companyData['cap'] ?? $companyData['zip_code'] ?? null,
|
||||
'pec' => $companyData['pec'] ?? null,
|
||||
'phone' => $companyData['telefono'] ?? $companyData['phone'] ?? null,
|
||||
'ateco_code' => $companyData['codice_ateco'] ?? $companyData['ateco_code'] ?? null,
|
||||
'ateco_description' => $companyData['descrizione_ateco'] ?? $companyData['ateco_description'] ?? null,
|
||||
'suggested_sector' => $this->mapAtecoToNis2Sector(
|
||||
$companyData['codice_ateco'] ?? '',
|
||||
$companyData['descrizione_ateco'] ?? ''
|
||||
),
|
||||
'source' => 'certisource',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Map ATECO code to NIS2 sector
|
||||
*/
|
||||
private function mapAtecoToNis2Sector(string $atecoCode, string $atecoDesc): ?string
|
||||
{
|
||||
$code = substr($atecoCode, 0, 2); // Use first 2 digits
|
||||
$descLower = strtolower($atecoDesc);
|
||||
|
||||
// ATECO to NIS2 mapping (approximate)
|
||||
$mapping = [
|
||||
'35' => 'energy_electricity', // Electricity, gas, steam
|
||||
'49' => 'transport_road', // Land transport
|
||||
'50' => 'transport_water', // Water transport
|
||||
'51' => 'transport_air', // Air transport
|
||||
'64' => 'banking', // Financial services
|
||||
'65' => 'banking', // Insurance
|
||||
'66' => 'financial_markets', // Financial auxiliaries
|
||||
'86' => 'health', // Health
|
||||
'36' => 'drinking_water', // Water supply
|
||||
'37' => 'waste_water', // Sewerage
|
||||
'38' => 'waste_management', // Waste management
|
||||
'61' => 'digital_infrastructure', // Telecommunications
|
||||
'62' => 'ict_service_management', // IT services
|
||||
'63' => 'digital_providers', // Information services
|
||||
'84' => 'public_administration', // Public admin
|
||||
'53' => 'postal_courier', // Postal services
|
||||
'20' => 'chemicals', // Chemicals manufacturing
|
||||
'10' => 'food', // Food manufacturing
|
||||
'11' => 'food', // Beverages
|
||||
'21' => 'manufacturing_medical', // Pharma/medical
|
||||
'26' => 'manufacturing_computers', // Electronics
|
||||
'27' => 'manufacturing_electrical', // Electrical equipment
|
||||
'28' => 'manufacturing_machinery', // Machinery
|
||||
'29' => 'manufacturing_vehicles', // Motor vehicles
|
||||
'30' => 'manufacturing_transport', // Other transport
|
||||
'72' => 'research', // Scientific research
|
||||
];
|
||||
|
||||
if (isset($mapping[$code])) {
|
||||
return $mapping[$code];
|
||||
}
|
||||
|
||||
// Try to match by description keywords
|
||||
$keywords = [
|
||||
'energia' => 'energy_electricity',
|
||||
'elettric' => 'energy_electricity',
|
||||
'gas' => 'energy_gas',
|
||||
'petroli' => 'energy_oil',
|
||||
'trasport' => 'transport_road',
|
||||
'ferrov' => 'transport_rail',
|
||||
'maritt' => 'transport_water',
|
||||
'aere' => 'transport_air',
|
||||
'banc' => 'banking',
|
||||
'finanz' => 'financial_markets',
|
||||
'sanit' => 'health',
|
||||
'osped' => 'health',
|
||||
'farm' => 'manufacturing_medical',
|
||||
'acqua' => 'drinking_water',
|
||||
'rifiut' => 'waste_management',
|
||||
'telecom' => 'digital_infrastructure',
|
||||
'informatica' => 'ict_service_management',
|
||||
'software' => 'ict_service_management',
|
||||
'digital' => 'digital_providers',
|
||||
'postale' => 'postal_courier',
|
||||
'corriere' => 'postal_courier',
|
||||
'chimic' => 'chemicals',
|
||||
'alimentar' => 'food',
|
||||
'ricerca' => 'research',
|
||||
];
|
||||
|
||||
foreach ($keywords as $kw => $sector) {
|
||||
if (str_contains($descLower, $kw)) {
|
||||
return $sector;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Call Claude API
|
||||
*/
|
||||
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);
|
||||
curl_close($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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get CertiSource API base URL (same server)
|
||||
*/
|
||||
private function getCertiSourceBaseUrl(): string
|
||||
{
|
||||
// Both apps are on the same server, use internal URL
|
||||
if (defined('CERTISOURCE_API_URL')) {
|
||||
return CERTISOURCE_API_URL;
|
||||
}
|
||||
// Default: same server via localhost
|
||||
return 'https://certisource.it/certisource';
|
||||
}
|
||||
|
||||
/**
|
||||
* Log AI interaction to database
|
||||
*/
|
||||
private function logAiInteraction(string $type, string $summary): void
|
||||
{
|
||||
try {
|
||||
// Get current user from JWT if available
|
||||
$userId = null;
|
||||
$token = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
|
||||
if (preg_match('/Bearer\s+(.+)$/i', $token, $matches)) {
|
||||
$parts = explode('.', $matches[1]);
|
||||
if (count($parts) === 3) {
|
||||
$payload = json_decode(base64_decode(strtr($parts[1], '-_', '+/')), true);
|
||||
$userId = $payload['sub'] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
if ($userId) {
|
||||
Database::insert('ai_interactions', [
|
||||
'organization_id' => 0, // Not yet created during onboarding
|
||||
'user_id' => $userId,
|
||||
'interaction_type' => 'qa',
|
||||
'prompt_summary' => $summary,
|
||||
'response_summary' => 'Dati estratti',
|
||||
'tokens_used' => 0,
|
||||
'model_used' => ANTHROPIC_MODEL,
|
||||
]);
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
// Silently fail - logging should not break the flow
|
||||
error_log('[AI_LOG_ERROR] ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -97,6 +97,7 @@ $controllerMap = [
|
||||
'assets' => 'AssetController',
|
||||
'audit' => 'AuditController',
|
||||
'admin' => 'AdminController',
|
||||
'onboarding' => 'OnboardingController',
|
||||
];
|
||||
|
||||
if (!isset($controllerMap[$controllerName])) {
|
||||
@ -265,6 +266,13 @@ $actionMap = [
|
||||
'GET:users' => 'listUsers',
|
||||
'GET:stats' => 'platformStats',
|
||||
],
|
||||
|
||||
// ── OnboardingController ──────────────────────
|
||||
'onboarding' => [
|
||||
'POST:uploadVisura' => 'uploadVisura',
|
||||
'POST:fetchCompany' => 'fetchCompany',
|
||||
'POST:complete' => 'complete',
|
||||
],
|
||||
];
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@ -238,6 +238,30 @@ class NIS2API {
|
||||
generateComplianceReport() { return this.get('/audit/report'); }
|
||||
getAuditLogs(params = {}) { return this.get('/audit/logs?' + new URLSearchParams(params)); }
|
||||
getIsoMapping() { return this.get('/audit/iso27001-mapping'); }
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Onboarding
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
async uploadVisura(file) {
|
||||
const formData = new FormData();
|
||||
formData.append('visura', file);
|
||||
const headers = { 'Authorization': 'Bearer ' + this.token };
|
||||
if (this.orgId) headers['X-Organization-Id'] = this.orgId;
|
||||
try {
|
||||
const response = await fetch(this.baseUrl + '/onboarding/upload-visura', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: formData,
|
||||
});
|
||||
return response.json();
|
||||
} catch (error) {
|
||||
return { success: false, message: 'Errore di connessione al server' };
|
||||
}
|
||||
}
|
||||
|
||||
fetchCompany(vatNumber) { return this.post('/onboarding/fetch-company', { vat_number: vatNumber }); }
|
||||
completeOnboarding(data) { return this.post('/onboarding/complete', data); }
|
||||
}
|
||||
|
||||
// Singleton globale
|
||||
|
||||
@ -86,7 +86,7 @@
|
||||
if (result.data.organizations && result.data.organizations.length > 0) {
|
||||
window.location.href = 'dashboard.html';
|
||||
} else {
|
||||
window.location.href = 'setup-org.html';
|
||||
window.location.href = 'onboarding.html';
|
||||
}
|
||||
} else {
|
||||
errorEl.textContent = result.message || 'Credenziali non valide.';
|
||||
|
||||
1661
public/onboarding.html
Normal file
1661
public/onboarding.html
Normal file
File diff suppressed because it is too large
Load Diff
@ -160,7 +160,7 @@
|
||||
showNotification('Account creato con successo!', 'success');
|
||||
// Dopo la registrazione, porta al setup organizzazione
|
||||
setTimeout(() => {
|
||||
window.location.href = 'setup-org.html';
|
||||
window.location.href = 'onboarding.html';
|
||||
}, 500);
|
||||
} else {
|
||||
errorEl.textContent = result.message || 'Errore durante la registrazione.';
|
||||
|
||||
Loading…
Reference in New Issue
Block a user