[FEAT] Gap Analysis ACN: assessment misure/requisiti Det. 164179/2025 (backend)

Assessment di SECONDO LIVELLO sulla Determinazione ACN 164179/2025 (non le 10
lettere generiche Art.21, gia coperte). Distingue soggetti importanti/essenziali:
- IMPORTANTI (All.1): 37 misure, 87 requisiti
- ESSENZIALI (All.2): 43 misure, 116 requisiti

- application/data/acn_measures.json: dataset canonico estratto dai testi
  UFFICIALI ACN (Allegati 1+2), testi requisiti INTEGRALI (no troncamenti),
  flag per-requisito importante/essenziale. Validato 37/87 + 43/116, zero
  discrepanze vs codici di riferimento.
- AcnAssessmentController: catalog/list/create/get/requirements/respond/complete/
  report/aiAnalyze. Pre-popola requisiti applicabili per entity_level, scoring
  per funzione FW (GOVERN/IDENTIFY/PROTECT/DETECT/RESPOND/RECOVER), grounding AI
  sui 203 requisiti ACN gia in KB. Anti-IDOR, snapshot testo immutabile.
- Migrazione 036: acn_assessments + acn_assessment_responses (APPLICATA su host).
- Router: acn-gap controllerMap + actionMap.

Origine: finding revisore (la Gap Analysis Art.21 non e l'autovalutazione ACN).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
DevEnv nis2-agile 2026-06-01 08:02:59 +02:00
parent 4af5e4caab
commit ea2a291325
4 changed files with 1906 additions and 0 deletions

View File

@ -0,0 +1,591 @@
<?php
/**
* NIS2 Agile - Gap Analysis ACN (Determinazione 164179/2025)
* ----------------------------------------------------------------------------
* Assessment di conformita di SECONDO LIVELLO: misure e requisiti puntuali
* della Determinazione ACN n. 164179/2025 (Allegati 1 e 2), NON le 10 lettere
* generiche dell'Art. 21 NIS2 (quelle restano in AssessmentController).
*
* - soggetti IMPORTANTI (Allegato 1): 37 misure, 87 requisiti
* - soggetti ESSENZIALI (Allegato 2): 43 misure, 116 requisiti (= 87 + 29)
*
* La codifica dei requisiti e identica per i due livelli; agli essenziali si
* aggiungono misure e punti. Il perimetro applicabile e filtrato in base al
* livello del soggetto (acn_assessments.entity_level, derivato da
* organizations.entity_type).
*
* Fonte testi: application/data/acn_measures.json (estratto integrale ACN).
*
* Endpoint (base /api/acn-gap):
* GET /catalog - catalogo misure/requisiti per il livello dell'org
* GET /list - assessment ACN dell'org
* POST /create - crea assessment (pre-popola requisiti applicabili)
* GET /{id} - intestazione + stats
* GET /{id}/requirements - requisiti raggruppati per funzione/categoria/misura
* POST /{id}/respond - salva risposta (singola o batch)
* POST /{id}/complete - calcola score e completa
* GET /{id}/report - report completo
* POST /{id}/aiAnalyze - analisi AI con grounding sui requisiti ACN
*/
require_once __DIR__ . '/BaseController.php';
class AcnAssessmentController extends BaseController
{
/** Cache in-process del dataset misure. */
private ?array $measuresCache = null;
// ════════════════════════ CATALOGO ════════════════════════
/**
* GET /api/acn-gap/catalog
* Catalogo dei requisiti ACN applicabili al livello del soggetto corrente
* (importante/essenziale), raggruppato per funzione FW. Sola lettura,
* non crea assessment. Utile per anteprima/consultazione normativa.
*/
public function catalog(): void
{
$this->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<int,array{key:string,measure_code:string,index:int,function_code:string,category_code:string,text:string}>
*/
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)];
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,76 @@
-- ============================================================================
-- Migration 036 - Gap Analysis ACN (Determinazione 164179/2025, Allegati 1 e 2)
-- ----------------------------------------------------------------------------
-- Aggiunge l'assessment di conformita di SECONDO LIVELLO: non le 10 lettere
-- generiche dell'Art. 21 NIS2 (gia coperte da assessments/assessment_responses),
-- ma le MISURE e i REQUISITI puntuali della Determinazione ACN n. 164179/2025:
-- - soggetti IMPORTANTI (Allegato 1): 37 misure, 87 requisiti
-- - soggetti ESSENZIALI (Allegato 2): 43 misure, 116 requisiti (= 87 + 29)
--
-- La codifica dei requisiti e IDENTICA per importanti ed essenziali (stessi
-- codici GV/ID/PR/DE/RS/RC); agli essenziali si aggiungono misure e punti.
-- L'app filtra i requisiti applicabili in base a organizations.entity_type.
--
-- Fonte testi: application/data/acn_measures.json (estratto integrale dagli
-- Allegati ufficiali ACN). Nessuna tabella esistente modificata.
--
-- IDEMPOTENTE: CREATE TABLE IF NOT EXISTS. Applicare su host MySQL
-- (mysql -h localhost nis2_agile_db), NON docker exec nis2-db.
-- STATO: APPLICATA su host 2026-06-01 (entrambe le tabelle verificate).
-- ============================================================================
-- Intestazione assessment ACN (una riga per ciclo di valutazione).
CREATE TABLE IF NOT EXISTS acn_assessments (
id INT AUTO_INCREMENT PRIMARY KEY,
organization_id INT NOT NULL,
title VARCHAR(255) NOT NULL,
-- livello soggetto al momento della creazione: determina il perimetro
-- dei requisiti applicabili (importante=87, essenziale=116).
entity_level ENUM('important','essential') NOT NULL,
status ENUM('draft','in_progress','completed') DEFAULT 'draft',
-- punteggio complessivo 0-100 (media pesata requisiti applicabili)
overall_score DECIMAL(5,2) NULL,
-- punteggi per funzione FW (GOVERN/IDENTIFY/PROTECT/DETECT/RESPOND/RECOVER) JSON
function_scores JSON NULL,
-- conteggi sintetici: applicabili / conformi / parziali / non conformi / n.a.
stats JSON NULL,
ai_summary TEXT NULL,
ai_recommendations JSON NULL,
completed_by INT NULL,
completed_at DATETIME NULL,
created_by INT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE,
FOREIGN KEY (completed_by) REFERENCES users(id) ON DELETE SET NULL,
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL,
INDEX idx_acn_org (organization_id),
INDEX idx_acn_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Risposta per singolo REQUISITO ACN (granularita: misura.punto).
CREATE TABLE IF NOT EXISTS acn_assessment_responses (
id INT AUTO_INCREMENT PRIMARY KEY,
assessment_id INT NOT NULL,
organization_id INT NOT NULL,
-- chiave requisito: codice misura + indice punto, es. "GV.RR-04#4"
requirement_key VARCHAR(40) NOT NULL,
measure_code VARCHAR(20) NOT NULL, -- es. GV.RR-04
requirement_index INT NOT NULL, -- es. 4
function_code VARCHAR(12) NOT NULL, -- GOVERN/IDENTIFY/PROTECT/DETECT/RESPOND/RECOVER
category_code VARCHAR(12) NOT NULL, -- es. GV.RR
-- snapshot del testo requisito al momento della compilazione (immutabilita)
requirement_text TEXT NULL,
response_value ENUM('not_implemented','partial','implemented','not_applicable') NULL,
evidence_description TEXT NULL,
notes TEXT NULL,
answered_by INT NULL,
answered_at DATETIME NULL,
FOREIGN KEY (assessment_id) REFERENCES acn_assessments(id) ON DELETE CASCADE,
FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE,
FOREIGN KEY (answered_by) REFERENCES users(id) ON DELETE SET NULL,
UNIQUE KEY uk_acn_assessment_req (assessment_id, requirement_key),
INDEX idx_acn_resp_assessment (assessment_id),
INDEX idx_acn_resp_measure (measure_code),
INDEX idx_acn_resp_function (function_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@ -87,6 +87,7 @@ $controllerMap = [
'auth' => 'AuthController',
'organizations' => 'OrganizationController',
'assessments' => 'AssessmentController',
'acn-gap' => 'AcnAssessmentController', // Gap Analysis ACN (Det. 164179/2025, misure/requisiti)
'dashboard' => 'DashboardController',
'risks' => 'RiskController',
'incidents' => 'IncidentController',
@ -203,6 +204,19 @@ $actionMap = [
'POST:{id}/aiAnalyze' => 'aiAnalyze',
],
// ── AcnAssessmentController — Gap Analysis ACN (Determinazione 164179/2025) ──
'acn-gap' => [
'GET:catalog' => 'catalog',
'GET:list' => 'list',
'POST:create' => 'create',
'GET:{id}' => 'get',
'GET:{id}/requirements' => 'getRequirements',
'POST:{id}/respond' => 'saveResponse',
'POST:{id}/complete' => 'complete',
'GET:{id}/report' => 'getReport',
'POST:{id}/aiAnalyze' => 'aiAnalyze',
],
// ── DashboardController ─────────────────────────
'dashboard' => [
'GET:overview' => 'overview',