requireOrgAccess(); $level = $this->resolveEntityLevel(); $grouped = $this->groupedRequirements($level, []); $totals = $this->datasetTotals(); $this->jsonSuccess([ 'entity_level' => $level, 'totals' => $totals[$level], 'functions' => $grouped, 'source' => $this->measures()['source'] ?? null, ]); } // ════════════════════════ LISTA / CREATE ════════════════════════ /** GET /api/acn-gap/list */ public function list(): void { $this->requireOrgAccess(); $orgId = $this->getCurrentOrgId(); $rows = Database::fetchAll( 'SELECT id, title, entity_level, status, overall_score, completed_at, created_at FROM acn_assessments WHERE organization_id = ? ORDER BY created_at DESC', [$orgId] ); $this->jsonSuccess(['assessments' => $rows]); } /** * POST /api/acn-gap/create Body: { title? } * Crea un assessment ACN e pre-popola TUTTI i requisiti applicabili al * livello del soggetto (snapshot del testo congelato). */ public function create(): void { $this->requireOrgRole(['org_admin', 'compliance_manager']); $orgId = $this->getCurrentOrgId(); $userId = $this->getCurrentUserId(); $level = $this->resolveEntityLevel(); if ($level === null) { $this->jsonError( 'Il soggetto non e classificato come importante o essenziale. Completa prima la classificazione NIS2 dell\'organizzazione.', 422, 'ENTITY_LEVEL_REQUIRED' ); } $title = trim((string) $this->getParam('title', '')); if ($title === '') { $title = 'Gap Analysis ACN ' . date('Y-m-d'); } Database::beginTransaction(); try { $assessmentId = Database::insert('acn_assessments', [ 'organization_id' => $orgId, 'title' => $title, 'entity_level' => $level, 'status' => 'draft', 'created_by' => $userId, ]); $applicable = $this->applicableRequirements($level); foreach ($applicable as $r) { Database::insert('acn_assessment_responses', [ 'assessment_id' => $assessmentId, 'organization_id' => $orgId, 'requirement_key' => $r['key'], 'measure_code' => $r['measure_code'], 'requirement_index' => $r['index'], 'function_code' => $r['function_code'], 'category_code' => $r['category_code'], 'requirement_text' => $r['text'], 'response_value' => null, ]); } Database::commit(); } catch (Throwable $e) { if (Database::getInstance()->inTransaction()) { Database::rollback(); } throw $e; } $this->logAudit('acn_assessment_created', 'acn_assessment', $assessmentId, [ 'entity_level' => $level, 'requirements' => count($applicable), ]); $this->jsonSuccess([ 'id' => $assessmentId, 'entity_level' => $level, 'requirements' => count($applicable), ], 'Assessment ACN creato', 201); } // ════════════════════════ DETTAGLIO ════════════════════════ /** GET /api/acn-gap/{id} */ public function get(int $id): void { $this->requireOrgAccess(); $a = $this->loadOwned($id); $a['stats'] = $this->computeStats($id); $this->jsonSuccess($a); } /** * GET /api/acn-gap/{id}/requirements * Requisiti raggruppati per funzione -> categoria -> misura, con la risposta * salvata. Struttura pensata per il rendering a wizard/accordion. */ public function getRequirements(int $id): void { $this->requireOrgAccess(); $a = $this->loadOwned($id); $rows = Database::fetchAll( 'SELECT requirement_key, measure_code, requirement_index, function_code, category_code, requirement_text, response_value, evidence_description, notes, answered_at FROM acn_assessment_responses WHERE assessment_id = ? ORDER BY id', [$id] ); // arricchimento da dataset: titoli misura/categoria/funzione $meta = $this->measureMeta(); $byFunc = []; foreach ($rows as $r) { $fc = $r['function_code']; $cc = $r['category_code']; $mc = $r['measure_code']; $byFunc[$fc] ??= [ 'function_code' => $fc, 'function_name' => $meta['functions'][$fc] ?? $fc, 'categories' => [], ]; $byFunc[$fc]['categories'][$cc] ??= [ 'category_code' => $cc, 'category_title' => $meta['categories'][$cc] ?? $cc, 'measures' => [], ]; $byFunc[$fc]['categories'][$cc]['measures'][$mc] ??= [ 'measure_code' => $mc, 'measure_title' => $meta['measures'][$mc] ?? $mc, 'requirements' => [], ]; $byFunc[$fc]['categories'][$cc]['measures'][$mc]['requirements'][] = [ 'key' => $r['requirement_key'], 'index' => (int) $r['requirement_index'], 'text' => $r['requirement_text'], 'response' => $r['response_value'], 'evidence' => $r['evidence_description'], 'notes' => $r['notes'], 'answered_at' => $r['answered_at'], ]; } // converti mappe associative in liste ordinate $functions = []; foreach ($byFunc as $f) { $cats = []; foreach ($f['categories'] as $c) { $c['measures'] = array_values($c['measures']); $cats[] = $c; } $f['categories'] = $cats; $functions[] = $f; } $this->jsonSuccess([ 'assessment' => ['id' => $id, 'entity_level' => $a['entity_level'], 'status' => $a['status']], 'functions' => $functions, 'stats' => $this->computeStats($id), ]); } /** * POST /api/acn-gap/{id}/respond * Body: { requirement_key, response_value, evidence?, notes? } * oppure { responses: [ {requirement_key, response_value, ...}, ... ] } */ public function saveResponse(int $id): void { $this->requireOrgRole(['org_admin', 'compliance_manager', 'auditor']); $a = $this->loadOwned($id); if ($a['status'] === 'completed') { $this->jsonError('Assessment gia completato: non modificabile.', 409, 'ALREADY_COMPLETED'); } $userId = $this->getCurrentUserId(); $orgId = $this->getCurrentOrgId(); $body = $this->getJsonBody(); $batch = is_array($body['responses'] ?? null) ? $body['responses'] : [$body]; $valid = ['not_implemented', 'partial', 'implemented', 'not_applicable']; $saved = 0; foreach ($batch as $item) { if (!is_array($item)) { continue; } $key = trim((string) ($item['requirement_key'] ?? '')); $val = (string) ($item['response_value'] ?? ''); if ($key === '' || !in_array($val, $valid, true)) { continue; } $updated = Database::update('acn_assessment_responses', [ 'response_value' => $val, 'evidence_description' => isset($item['evidence']) ? (string) $item['evidence'] : null, 'notes' => isset($item['notes']) ? (string) $item['notes'] : null, 'answered_by' => $userId, 'answered_at' => date('Y-m-d H:i:s'), ], 'assessment_id = ? AND requirement_key = ? AND organization_id = ?', [$id, $key, $orgId]); $saved += $updated > 0 ? 1 : 0; } // porta a in_progress se era draft if ($a['status'] === 'draft' && $saved > 0) { Database::update('acn_assessments', ['status' => 'in_progress'], 'id = ?', [$id]); } $this->jsonSuccess(['saved' => $saved, 'stats' => $this->computeStats($id)], 'Risposte salvate'); } /** * POST /api/acn-gap/{id}/complete * Calcola overall_score + function_scores + stats e marca completed. * Richiede che tutti i requisiti applicabili abbiano una risposta. */ public function complete(int $id): void { $this->requireOrgRole(['org_admin', 'compliance_manager']); $a = $this->loadOwned($id); if ($a['status'] === 'completed') { $this->jsonError('Assessment gia completato.', 409, 'ALREADY_COMPLETED'); } $unanswered = (int) (Database::fetchOne( 'SELECT COUNT(*) AS n FROM acn_assessment_responses WHERE assessment_id = ? AND response_value IS NULL', [$id] )['n'] ?? 0); if ($unanswered > 0) { $this->jsonError( "Mancano $unanswered requisiti senza risposta. Completa tutte le risposte prima di chiudere.", 422, 'INCOMPLETE', ['unanswered' => $unanswered] ); } [$overall, $funcScores, $stats] = $this->calculateScores($id); Database::update('acn_assessments', [ 'status' => 'completed', 'overall_score' => $overall, 'function_scores' => json_encode($funcScores, JSON_UNESCAPED_UNICODE), 'stats' => json_encode($stats, JSON_UNESCAPED_UNICODE), 'completed_by' => $this->getCurrentUserId(), 'completed_at' => date('Y-m-d H:i:s'), ], 'id = ?', [$id]); $this->logAudit('acn_assessment_completed', 'acn_assessment', $id, [ 'overall_score' => $overall, 'entity_level' => $a['entity_level'], ]); $this->jsonSuccess([ 'overall_score' => $overall, 'function_scores' => $funcScores, 'stats' => $stats, ], 'Assessment ACN completato'); } /** GET /api/acn-gap/{id}/report */ public function getReport(int $id): void { $this->requireOrgAccess(); $a = $this->loadOwned($id); // requisiti non conformi (priorita per il piano d'azione) $gaps = Database::fetchAll( "SELECT requirement_key, measure_code, function_code, category_code, requirement_text, response_value, notes FROM acn_assessment_responses WHERE assessment_id = ? AND response_value IN ('not_implemented','partial') ORDER BY FIELD(response_value,'not_implemented','partial'), function_code, measure_code, requirement_index", [$id] ); $a['function_scores'] = $a['function_scores'] ? json_decode($a['function_scores'], true) : null; $a['stats'] = $a['stats'] ? json_decode($a['stats'], true) : $this->computeStats($id); $a['ai_recommendations'] = $a['ai_recommendations'] ? json_decode($a['ai_recommendations'], true) : null; $this->jsonSuccess(['assessment' => $a, 'gaps' => $gaps]); } /** * POST /api/acn-gap/{id}/aiAnalyze * Analisi AI dei gap con grounding sui requisiti ACN (la KB contiene gia i * 203 requisiti, scope SYSTEM). Salva sintesi e raccomandazioni. */ public function aiAnalyze(int $id): void { $this->requireOrgRole(['org_admin', 'compliance_manager']); $a = $this->loadOwned($id); $gaps = Database::fetchAll( "SELECT measure_code, requirement_index, requirement_text, response_value FROM acn_assessment_responses WHERE assessment_id = ? AND response_value IN ('not_implemented','partial') ORDER BY function_code, measure_code, requirement_index LIMIT 60", [$id] ); if (empty($gaps)) { $this->jsonSuccess(['ai_summary' => 'Nessun gap rilevato: tutti i requisiti applicabili risultano conformi.'], 'Analisi completata'); } require_once APP_PATH . '/services/AIService.php'; $ai = new AIService(); $lines = array_map( fn($g) => "- {$g['measure_code']} punto {$g['requirement_index']} [{$g['response_value']}]: {$g['requirement_text']}", $gaps ); $question = "Analizza i seguenti gap di conformita rispetto alle misure di sicurezza di base ACN " . "(Determinazione 164179/2025) per un soggetto " . ($a['entity_level'] === 'essential' ? 'ESSENZIALE' : 'IMPORTANTE') . ". " . "Per ciascun gap suggerisci un'azione concreta e prioritizza. Cita SOLO requisiti ACN reali.\n\n" . implode("\n", $lines); try { $userContext = [ 'organization_id' => $this->getCurrentOrgId(), 'consulting_firm_id' => $this->currentUser['consulting_firm_id'] ?? null, ]; $answer = $ai->askWithRag($question, $userContext); } catch (Throwable $e) { error_log('[AcnAssessment] aiAnalyze fallita: ' . $e->getMessage()); $this->jsonError('Analisi AI temporaneamente non disponibile. Riprova piu tardi.', 503, 'AI_UNAVAILABLE'); } Database::update('acn_assessments', [ 'ai_summary' => is_string($answer) ? $answer : json_encode($answer, JSON_UNESCAPED_UNICODE), ], 'id = ?', [$id]); $this->logAudit('acn_assessment_ai_analyzed', 'acn_assessment', $id, ['gaps' => count($gaps)]); $this->jsonSuccess(['ai_summary' => $answer], 'Analisi AI completata'); } // ════════════════════════ HELPER ════════════════════════ /** Carica l'assessment verificando ownership org (anti-IDOR). */ private function loadOwned(int $id): array { $a = Database::fetchOne( 'SELECT * FROM acn_assessments WHERE id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()] ); if (!$a) { $this->jsonError('Assessment non trovato', 404, 'NOT_FOUND'); } return $a; } /** * Determina il livello del soggetto dall'organization corrente. * organizations.entity_type: 'essential'|'important'|'not_applicable'. * Ritorna 'essential'|'important', oppure null se non classificato. */ private function resolveEntityLevel(): ?string { $org = Database::fetchOne('SELECT entity_type FROM organizations WHERE id = ?', [$this->getCurrentOrgId()]); $t = $org['entity_type'] ?? null; if ($t === 'essential' || $t === 'important') { return $t; } return null; } /** Carica e cache il dataset misure ACN. */ private function measures(): array { if ($this->measuresCache === null) { $path = APP_PATH . '/data/acn_measures.json'; $json = is_readable($path) ? json_decode((string) file_get_contents($path), true) : null; $this->measuresCache = is_array($json) ? $json : ['measures' => []]; } return $this->measuresCache; } /** Totali requisiti per livello (da dataset). */ private function datasetTotals(): array { $t = $this->measures()['totals'] ?? []; return [ 'important' => [ 'measures' => $t['measures_importante'] ?? 37, 'requirements' => $t['requirements_importante'] ?? 87, ], 'essential' => [ 'measures' => $t['measures_essenziale'] ?? 43, 'requirements' => $t['requirements_essenziale'] ?? 116, ], ]; } /** * Lista piatta dei requisiti applicabili a un livello. * @return array */ private function applicableRequirements(string $level): array { $flag = $level === 'essential' ? 'essenziale' : 'importante'; $out = []; foreach ($this->measures()['measures'] ?? [] as $m) { $applies = $level === 'essential' ? ($m['applies_essenziale'] ?? false) : ($m['applies_importante'] ?? false); if (!$applies) { continue; } foreach ($m['requirements'] ?? [] as $r) { if (empty($r[$flag])) { continue; // punto non applicabile a questo livello } $out[] = [ 'key' => $m['code'] . '#' . $r['index'], 'measure_code' => $m['code'], 'index' => (int) $r['index'], 'function_code' => $m['function_code'], 'category_code' => $m['category_code'], 'text' => $r['text'], ]; } } return $out; } /** Requisiti raggruppati per funzione (per il catalogo). */ private function groupedRequirements(?string $level, array $responses): array { if ($level === null) { return []; } $reqs = $this->applicableRequirements($level); $meta = $this->measureMeta(); $byFunc = []; foreach ($reqs as $r) { $fc = $r['function_code']; $byFunc[$fc] ??= ['function_code' => $fc, 'function_name' => $meta['functions'][$fc] ?? $fc, 'measures' => []]; $mc = $r['measure_code']; $byFunc[$fc]['measures'][$mc] ??= ['measure_code' => $mc, 'measure_title' => $meta['measures'][$mc] ?? $mc, 'requirements' => []]; $byFunc[$fc]['measures'][$mc]['requirements'][] = ['index' => $r['index'], 'text' => $r['text']]; } $out = []; foreach ($byFunc as $f) { $f['measures'] = array_values($f['measures']); $out[] = $f; } return $out; } /** Mappe codice->titolo per funzioni/categorie/misure (dal dataset). */ private function measureMeta(): array { static $meta = null; if ($meta !== null) { return $meta; } $functions = []; $categories = []; $measures = []; foreach ($this->measures()['measures'] ?? [] as $m) { $functions[$m['function_code']] = $m['function_name'] ?? $m['function_code']; $categories[$m['category_code']] = $m['category_title'] ?? $m['category_code']; $measures[$m['code']] = $m['title'] ?? $m['code']; } $meta = ['functions' => $functions, 'categories' => $categories, 'measures' => $measures]; return $meta; } /** Conteggi rapidi per stato risposta. */ private function computeStats(int $id): array { $rows = Database::fetchAll( 'SELECT response_value, COUNT(*) AS n FROM acn_assessment_responses WHERE assessment_id = ? GROUP BY response_value', [$id] ); $stats = ['applicable' => 0, 'implemented' => 0, 'partial' => 0, 'not_implemented' => 0, 'not_applicable' => 0, 'unanswered' => 0]; foreach ($rows as $r) { $n = (int) $r['n']; $stats['applicable'] += $n; $v = $r['response_value']; if ($v === null) { $stats['unanswered'] += $n; } elseif (isset($stats[$v])) { $stats[$v] += $n; } } return $stats; } /** * Calcolo punteggi: overall + per funzione. * Scoring per requisito: implemented=100, partial=50, not_implemented=0. * not_applicable e unanswered ESCLUSI dal denominatore (coerente con * l'assessment Art.21 esistente). * @return array{0:float,1:array,2:array} */ private function calculateScores(int $id): array { $rows = Database::fetchAll( 'SELECT function_code, response_value FROM acn_assessment_responses WHERE assessment_id = ?', [$id] ); $val = ['implemented' => 100.0, 'partial' => 50.0, 'not_implemented' => 0.0]; $sum = 0.0; $cnt = 0; $byFunc = []; foreach ($rows as $r) { $v = $r['response_value']; if ($v === 'not_applicable' || $v === null || !isset($val[$v])) { continue; } $fc = $r['function_code']; $byFunc[$fc] ??= ['sum' => 0.0, 'cnt' => 0]; $byFunc[$fc]['sum'] += $val[$v]; $byFunc[$fc]['cnt']++; $sum += $val[$v]; $cnt++; } $overall = $cnt > 0 ? round($sum / $cnt, 2) : 0.0; $funcScores = []; foreach ($byFunc as $fc => $d) { $funcScores[$fc] = $d['cnt'] > 0 ? round($d['sum'] / $d['cnt'], 2) : 0.0; } return [$overall, $funcScores, $this->computeStats($id)]; } }