[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:
parent
4af5e4caab
commit
ea2a291325
591
application/controllers/AcnAssessmentController.php
Normal file
591
application/controllers/AcnAssessmentController.php
Normal 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)];
|
||||
}
|
||||
}
|
||||
1225
application/data/acn_measures.json
Normal file
1225
application/data/acn_measures.json
Normal file
File diff suppressed because it is too large
Load Diff
76
docs/sql/036_acn_gap_assessment.sql
Normal file
76
docs/sql/036_acn_gap_assessment.sql
Normal 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;
|
||||
@ -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',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user