[FEAT] Epic C / C3 — rimosso modulo 'Analisi GAP ACN' (superato da C1)
Simon C3: l'analisi gap ACN non è un artefatto normativo e va eliminata; la conformità emerge da C1 (elenco Misure/Requisiti + valutazione per requisito in org_requisito_state). Ricognizione → migrazione → backup → rimozione (sicuro, 0 perdite): - Tabelle acn_assessments/acn_assessment_responses VUOTE (0 righe) -> droppate (mig.049, guard anti-drop se >0 righe). Ricreabili da 036. Nessun dato utente perso. - Integrazione lg231: /api/services/gap-analysis legge 'assessments' generale, NON il modulo ACN -> intatta. Verificato. - RaciController ripuntato su cfg_nis2_misure (fonte canonica) al posto di acn_measures.json (rimosso); validazione measure_code verificata (/raci/objects?type=measure -> 43 misure). Rimossi: AcnAssessmentController, public/acn-gap.html, application/data/acn_measures.json, routing acn-gap, voci sidebar (common.js + common-bi.js), metodi api.js acn*, sezione help 'acn', chiavi i18n acn.*, mapping demo-selectors. Tour demo step 3 RIPUNTATO su misure-requisiti (no step rotto, 10 step). Sidebar BI ora mostra 'Misure e Requisiti' (gap di common-bi.js sanato). Verifiche prod: /acn-gap.html 404, /api/acn-gap/* 404, /misure-requisiti 200, RaciController OK. Cache-buster ?v=20260624, version 1.18.3, SW v1.18.3. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
45e6c3599c
commit
e281aa4970
@@ -1,607 +0,0 @@
|
||||
<?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();
|
||||
if ($level === null) {
|
||||
// Coerente con create(): senza classificazione non c'e perimetro.
|
||||
// Il frontend intercetta ENTITY_LEVEL_REQUIRED e mostra l'invito a classificare.
|
||||
$this->jsonError(
|
||||
'Il soggetto non e classificato come importante o essenziale. Completa prima la classificazione NIS2 dell\'organizzazione.',
|
||||
422,
|
||||
'ENTITY_LEVEL_REQUIRED'
|
||||
);
|
||||
}
|
||||
$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,
|
||||
];
|
||||
// askWithRag ritorna ['answer'=>string, 'sources'=>array, 'rag_used'=>bool]:
|
||||
// estraiamo il testo, NON salviamo l'intero array.
|
||||
$result = $ai->askWithRag($question, $userContext);
|
||||
$answer = is_array($result) ? (string) ($result['answer'] ?? '') : (string) $result;
|
||||
$sources = (is_array($result) && isset($result['sources'])) ? $result['sources'] : [];
|
||||
} 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' => $answer,
|
||||
'ai_recommendations' => json_encode($sources, JSON_UNESCAPED_UNICODE),
|
||||
], 'id = ?', [$id]);
|
||||
|
||||
$this->logAudit('acn_assessment_ai_analyzed', 'acn_assessment', $id, ['gaps' => count($gaps)]);
|
||||
$this->jsonSuccess(['ai_summary' => $answer, 'sources' => $sources], '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++;
|
||||
}
|
||||
|
||||
// Se nessun requisito e applicabile (tutti not_applicable) il punteggio
|
||||
// non e definito: null, non 0.0 (che significherebbe "0% conforme").
|
||||
$overall = $cnt > 0 ? round($sum / $cnt, 2) : null;
|
||||
$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)];
|
||||
}
|
||||
}
|
||||
@@ -22,8 +22,8 @@
|
||||
* - 'inventario' = `assets`; 'procedure' = `policies`; rischi = `risks`;
|
||||
* fornitori = `suppliers`. Label: assets.name, policies.title, risks.title,
|
||||
* suppliers.name, org_roles.role_name.
|
||||
* - misure ACN = application/data/acn_measures.json (campo 'code'); NON e una
|
||||
* tabella DB -> risk_measure.measure_code e una stringa validata sul JSON.
|
||||
* - misure = tabella canonica cfg_nis2_misure (campo misura_code); risk_measure.measure_code
|
||||
* e una stringa validata su quella tabella (Epic C / C1; ex acn_measures.json, rimosso con C3).
|
||||
* - raci_assignments.object_id e POLIMORFICO -> niente FK; esistenza verificata
|
||||
* in PHP. I nomi tabella/colonna provengono SEMPRE dalle const-map qui sotto
|
||||
* dopo validazione enum (mai da stringhe grezze della request).
|
||||
@@ -461,15 +461,18 @@ class RaciController extends BaseController
|
||||
return $lt;
|
||||
}
|
||||
|
||||
/** Carica e memoizza il catalogo misure ACN dal JSON. */
|
||||
/**
|
||||
* Carica e memoizza il catalogo misure dalla tabella canonica cfg_nis2_misure
|
||||
* (Epic C / C1). Forma di ritorno invariata (['code','title']) per compatibilità
|
||||
* con assertMeasureExists/measureLabel. Sostituisce il vecchio acn_measures.json.
|
||||
*/
|
||||
private function loadMeasures(): array
|
||||
{
|
||||
if (self::$measuresCache === null) {
|
||||
$path = APP_PATH . '/data/acn_measures.json';
|
||||
$raw = is_readable($path) ? file_get_contents($path) : false;
|
||||
$data = $raw !== false ? json_decode($raw, true) : null;
|
||||
self::$measuresCache = is_array($data) && isset($data['measures']) && is_array($data['measures'])
|
||||
? $data['measures'] : [];
|
||||
$rows = Database::fetchAll('SELECT misura_code, misura_descr FROM cfg_nis2_misure ORDER BY ord ASC');
|
||||
self::$measuresCache = array_map(static function ($r) {
|
||||
return ['code' => $r['misura_code'], 'title' => $r['misura_descr']];
|
||||
}, $rows);
|
||||
}
|
||||
return self::$measuresCache;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user