Files
nis2-agile/application/controllers/IsmsModelController.php
T
DevEnv nis2-agile 3f74165531 [FEAT] Modello Organizzativo SGSI (ISO 27001/27017/27018) + SoA pre-popolato da NIS2
Nuovo modulo guidato in 6 step (cl. 4-10 + Statement of Applicability):
- migration 037 (isms_models/roles/soa/documents) + 038 (dataset 111 controlli:
  93 Annex A:2022 + 7 CLD/27017 + 11 PII/27018) + runner scripts/migrate-isms.php
- IsmsModelController (16 endpoint) registrato in index.php
- SoA pre-popolato dalle risposte Gap Analysis NIS2 (mapping iso27001_control)
- estensioni cloud condizionali 27017/27018 via flag uses_public_cloud/
  is_cloud_provider/processes_pii_in_cloud
- AIService::generateIsmsDocument + fonti ISO in nis2_sources.php
- frontend isms.html/isms.js + api client + sidebar + help + i18n IT/EN
- ingest KB ISO (scope SYSTEM, solo titoli/sintesi: no testo coperto da copyright)
- version.json 1.14.0; doc studio + deploy handoff

Strumento di supporto/pre-audit (non certificazione). Migration DA APPLICARE su host.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 11:32:42 +02:00

653 lines
28 KiB
PHP

<?php
/**
* NIS2 Agile - Modello Organizzativo SGSI (ISO/IEC 27001:2022)
* ----------------------------------------------------------------------------
* Procedura guidata per costruire un Sistema di Gestione della Sicurezza delle
* Informazioni (SGSI/ISMS) secondo ISO/IEC 27001:2022 (clausole 4-10) +
* Statement of Applicability (SoA) sui controlli Annex A:2022, estendibile con
* i controlli cloud ISO/IEC 27017:2015 e privacy-cloud ISO/IEC 27018:2019.
*
* Valore chiave: il SoA viene PRE-POPOLATO dalle risposte dell'assessment NIS2
* Art.21 (ogni domanda del questionario porta gia un iso27001_control), cosi un
* assessment NIS2 esistente produce una prima bozza di SoA.
*
* NON sostituisce la valutazione di un auditor: e uno strumento di supporto /
* pre-audit. Le bozze AI riportano sempre un disclaimer.
*
* Endpoint (base /api/isms):
* GET /model - SGSI dell'org (null se non iniziato)
* POST /model - crea/aggiorna SGSI (upsert) — clausole 4-6
* PUT /model - alias di POST (salva step)
* GET /annex-controls - dataset controlli applicabili (filtrato per flag cloud/PII)
* GET /soa - Statement of Applicability (auto-derivato al 1o accesso)
* POST /soa/derive - (ri)deriva lo stato iniziale dal NIS2
* PUT /soa - aggiorna un controllo del SoA (body: control_code, ...)
* GET /roles - ruoli/RACI del SGSI
* POST /roles - crea ruolo
* DELETE /roles/{id} - elimina ruolo
* GET /documents - documented information
* POST /documents - crea documento
* POST /documents/ai-generate - genera bozza documento via AI
* PUT /documents/{id} - aggiorna documento
* GET /readiness - % completamento SGSI + checklist clausole 4-10
* GET /export - export completo (model + SoA + ruoli + documenti)
*/
require_once __DIR__ . '/BaseController.php';
class IsmsModelController extends BaseController
{
private const STANDARD_LABELS = [
'iso27001' => 'ISO/IEC 27001:2022 Annex A',
'iso27017' => 'ISO/IEC 27017:2015 (cloud)',
'iso27018' => 'ISO/IEC 27018:2019 (PII in cloud)',
];
// ════════════════════════ MODEL ════════════════════════
/** GET /api/isms/model */
public function getModel(): void
{
$this->requireOrgAccess();
$m = $this->loadModel();
if ($m) {
$m['interested_parties'] = $m['interested_parties'] ? json_decode($m['interested_parties'], true) : [];
$m['isms_objectives'] = $m['isms_objectives'] ? json_decode($m['isms_objectives'], true) : [];
}
$this->jsonSuccess(['model' => $m]);
}
/**
* POST/PUT /api/isms/model
* Upsert del SGSI dell'org (una riga per org). Salva i campi inviati;
* i campi assenti non vengono toccati (salvataggio per-step).
*/
public function saveModel(): void
{
$this->requireOrgRole(['org_admin', 'compliance_manager']);
$orgId = $this->getCurrentOrgId();
$userId = $this->getCurrentUserId();
$body = $this->getJsonBody();
// Whitelist campi testuali/flag.
$fields = [];
foreach (['scope_statement','context_internal','context_external','boundaries','exclusions','risk_methodology'] as $k) {
if (array_key_exists($k, $body)) {
$fields[$k] = $body[$k] !== null ? (string) $body[$k] : null;
}
}
foreach (['interested_parties','isms_objectives'] as $k) {
if (array_key_exists($k, $body)) {
$fields[$k] = json_encode($body[$k] ?? [], JSON_UNESCAPED_UNICODE);
}
}
foreach (['uses_public_cloud','is_cloud_provider','processes_pii_in_cloud'] as $k) {
if (array_key_exists($k, $body)) {
$fields[$k] = !empty($body[$k]) ? 1 : 0;
}
}
if (array_key_exists('status', $body) && in_array($body['status'], ['draft','active','under_review'], true)) {
$fields['status'] = $body['status'];
}
$existing = $this->loadModel();
if ($existing) {
if (!empty($fields)) {
Database::update('isms_models', $fields, 'id = ?', [$existing['id']]);
}
$modelId = (int) $existing['id'];
$this->logAudit('isms_model_updated', 'isms_model', $modelId, array_keys($fields));
} else {
$fields['organization_id'] = $orgId;
$fields['created_by'] = $userId;
$fields['status'] = $fields['status'] ?? 'draft';
$modelId = Database::insert('isms_models', $fields);
$this->logAudit('isms_model_created', 'isms_model', $modelId, null);
}
$this->jsonSuccess(['id' => $modelId], 'SGSI salvato');
}
// ════════════════════════ ANNEX CONTROLS ════════════════════════
/**
* GET /api/isms/annex-controls
* Dataset di riferimento, filtrato in base ai flag cloud/PII del modello:
* i controlli condizionali (27017/27018) compaiono solo se pertinenti.
*/
public function annexControls(): void
{
$this->requireOrgAccess();
$m = $this->loadModel();
$standards = $this->applicableStandards($m);
$place = implode(',', array_fill(0, count($standards), '?'));
$rows = Database::fetchAll(
"SELECT control_code, standard, theme, title_it, title_en, iso27002_ref, condition_tag
FROM iso27001_annex_controls
WHERE standard IN ($place)
ORDER BY sort_order",
$standards
);
$this->jsonSuccess([
'standards' => array_map(fn($s) => ['key' => $s, 'label' => self::STANDARD_LABELS[$s] ?? $s], $standards),
'controls' => $rows,
'total' => count($rows),
]);
}
// ════════════════════════ SoA ════════════════════════
/**
* GET /api/isms/soa
* Restituisce il SoA raggruppato per standard -> tema. Se vuoto, lo deriva
* automaticamente dal NIS2 al primo accesso.
*/
public function getSoa(): void
{
$this->requireOrgAccess();
$model = $this->requireModel();
$count = Database::count('isms_soa', 'isms_model_id = ?', [$model['id']]);
if ($count === 0) {
$this->doDerive($model);
}
$rows = Database::fetchAll(
"SELECT s.control_code, s.standard, s.applicable, s.justification_inclusion, s.justification_exclusion,
s.implementation_status, s.implementation_pct, s.derived_from_nis2, s.source_ref,
c.title_it, c.title_en, c.theme, c.condition_tag, c.sort_order
FROM isms_soa s
LEFT JOIN iso27001_annex_controls c ON c.control_code = s.control_code
WHERE s.isms_model_id = ?
ORDER BY c.sort_order, s.control_code",
[$model['id']]
);
$byStd = [];
foreach ($rows as $r) {
$std = $r['standard'];
$byStd[$std] ??= ['standard' => $std, 'label' => self::STANDARD_LABELS[$std] ?? $std, 'controls' => []];
$byStd[$std]['controls'][] = $r;
}
$this->jsonSuccess([
'model_id' => (int) $model['id'],
'groups' => array_values($byStd),
'stats' => $this->soaStats($model['id']),
]);
}
/** POST /api/isms/soa/derive — (ri)deriva lo stato iniziale dal NIS2. */
public function deriveSoa(): void
{
$this->requireOrgRole(['org_admin', 'compliance_manager']);
$model = $this->requireModel();
$added = $this->doDerive($model);
$this->logAudit('isms_soa_derived', 'isms_model', (int) $model['id'], ['added' => $added]);
$this->jsonSuccess(['added' => $added, 'stats' => $this->soaStats($model['id'])], 'SoA derivato dal NIS2');
}
/**
* PUT /api/isms/soa
* Body: { control_code, applicable?, justification_inclusion?, justification_exclusion?,
* implementation_status?, implementation_pct? }
*/
public function updateSoaControl(): void
{
$this->requireOrgRole(['org_admin', 'compliance_manager', 'auditor']);
$model = $this->requireModel();
$body = $this->getJsonBody();
$code = trim((string) ($body['control_code'] ?? ''));
if ($code === '') {
$this->jsonError('control_code mancante', 400, 'MISSING_CODE');
}
$row = Database::fetchOne('SELECT id FROM isms_soa WHERE isms_model_id = ? AND control_code = ?', [$model['id'], $code]);
if (!$row) {
$this->jsonError('Controllo non presente nel SoA', 404, 'NOT_FOUND');
}
$fields = ['updated_by' => $this->getCurrentUserId()];
if (array_key_exists('applicable', $body)) {
$fields['applicable'] = !empty($body['applicable']) ? 1 : 0;
}
foreach (['justification_inclusion','justification_exclusion'] as $k) {
if (array_key_exists($k, $body)) {
$fields[$k] = $body[$k] !== null ? (string) $body[$k] : null;
}
}
if (isset($body['implementation_status']) && in_array($body['implementation_status'], ['not_started','in_progress','implemented','verified'], true)) {
$fields['implementation_status'] = $body['implementation_status'];
}
if (array_key_exists('implementation_pct', $body)) {
$fields['implementation_pct'] = max(0, min(100, (int) $body['implementation_pct']));
}
Database::update('isms_soa', $fields, 'id = ?', [$row['id']]);
$this->jsonSuccess(['stats' => $this->soaStats($model['id'])], 'Controllo aggiornato');
}
// ════════════════════════ ROLES ════════════════════════
/** GET /api/isms/roles */
public function listRoles(): void
{
$this->requireOrgAccess();
$model = $this->requireModel();
$rows = Database::fetchAll(
"SELECT r.id, r.role_name, r.user_id, r.responsibility, r.raci,
u.full_name AS user_name
FROM isms_roles r
LEFT JOIN users u ON u.id = r.user_id
WHERE r.isms_model_id = ? ORDER BY r.id",
[$model['id']]
);
$this->jsonSuccess(['roles' => $rows]);
}
/** POST /api/isms/roles Body: { role_name, user_id?, responsibility?, raci? } */
public function saveRole(): void
{
$this->requireOrgRole(['org_admin', 'compliance_manager']);
$model = $this->requireModel();
$body = $this->getJsonBody();
$name = trim((string) ($body['role_name'] ?? ''));
if ($name === '') {
$this->jsonError('role_name obbligatorio', 400, 'MISSING_ROLE_NAME');
}
$raci = (string) ($body['raci'] ?? '');
$data = [
'isms_model_id' => $model['id'],
'organization_id' => $this->getCurrentOrgId(),
'role_name' => $name,
'user_id' => !empty($body['user_id']) ? (int) $body['user_id'] : null,
'responsibility' => isset($body['responsibility']) ? (string) $body['responsibility'] : null,
'raci' => in_array($raci, ['R','A','C','I'], true) ? $raci : null,
];
$id = Database::insert('isms_roles', $data);
$this->jsonSuccess(['id' => $id], 'Ruolo aggiunto', 201);
}
/** DELETE /api/isms/roles/{id} */
public function deleteRole(int $id): void
{
$this->requireOrgRole(['org_admin', 'compliance_manager']);
$model = $this->requireModel();
Database::delete('isms_roles', 'id = ? AND isms_model_id = ?', [$id, $model['id']]);
$this->jsonSuccess(null, 'Ruolo eliminato');
}
// ════════════════════════ DOCUMENTS ════════════════════════
/** GET /api/isms/documents */
public function listDocuments(): void
{
$this->requireOrgAccess();
$model = $this->requireModel();
$rows = Database::fetchAll(
"SELECT id, doc_type, title, status, ai_generated, version, updated_at
FROM isms_documents WHERE isms_model_id = ? ORDER BY updated_at DESC",
[$model['id']]
);
$this->jsonSuccess(['documents' => $rows]);
}
/** POST /api/isms/documents Body: { doc_type, title, body_html?, status? } */
public function createDocument(): void
{
$this->requireOrgRole(['org_admin', 'compliance_manager']);
$model = $this->requireModel();
$body = $this->getJsonBody();
$this->validateRequired(['doc_type', 'title']);
$id = Database::insert('isms_documents', [
'isms_model_id' => $model['id'],
'organization_id' => $this->getCurrentOrgId(),
'doc_type' => (string) $body['doc_type'],
'title' => (string) $body['title'],
'body_html' => isset($body['body_html']) ? (string) $body['body_html'] : null,
'status' => in_array($body['status'] ?? '', ['draft','review','approved'], true) ? $body['status'] : 'draft',
'ai_generated' => !empty($body['ai_generated']) ? 1 : 0,
'created_by' => $this->getCurrentUserId(),
]);
$this->jsonSuccess(['id' => $id], 'Documento creato', 201);
}
/** PUT /api/isms/documents/{id} */
public function updateDocument(int $id): void
{
$this->requireOrgRole(['org_admin', 'compliance_manager']);
$model = $this->requireModel();
$body = $this->getJsonBody();
$row = Database::fetchOne('SELECT id FROM isms_documents WHERE id = ? AND isms_model_id = ?', [$id, $model['id']]);
if (!$row) {
$this->jsonError('Documento non trovato', 404, 'NOT_FOUND');
}
$fields = [];
foreach (['title','body_html'] as $k) {
if (array_key_exists($k, $body)) $fields[$k] = (string) $body[$k];
}
if (isset($body['status']) && in_array($body['status'], ['draft','review','approved'], true)) {
$fields['status'] = $body['status'];
}
if (!empty($fields)) {
Database::update('isms_documents', $fields, 'id = ?', [$id]);
}
$this->jsonSuccess(['id' => $id], 'Documento aggiornato');
}
/**
* POST /api/isms/documents/ai-generate
* Body: { doc_type, title? } - genera una bozza con AI (grounding fonti certe).
*/
public function aiGenerateDocument(): void
{
$this->requireOrgRole(['org_admin', 'compliance_manager']);
$model = $this->requireModel();
$body = $this->getJsonBody();
$docType = trim((string) ($body['doc_type'] ?? ''));
if ($docType === '') {
$this->jsonError('doc_type obbligatorio', 400, 'MISSING_DOC_TYPE');
}
$org = Database::fetchOne('SELECT sector, entity_type, employee_count FROM organizations WHERE id = ?', [$this->getCurrentOrgId()]);
require_once APP_PATH . '/services/AIService.php';
$ai = new AIService();
try {
$ctx = [
'scope' => $model['scope_statement'] ?? null,
'methodology' => $model['risk_methodology'] ?? null,
'uses_cloud' => (bool) ($model['uses_public_cloud'] || $model['is_cloud_provider']),
'pii_in_cloud' => (bool) $model['processes_pii_in_cloud'],
];
$doc = $ai->generateIsmsDocument($docType, $org ?: [], $ctx);
} catch (Throwable $e) {
error_log('[ISMS] aiGenerateDocument fallita: ' . $e->getMessage());
$this->jsonError('Generazione AI temporaneamente non disponibile. Riprova piu tardi.', 503, 'AI_UNAVAILABLE');
}
$title = trim((string) ($body['title'] ?? ($doc['title'] ?? $docType)));
$id = Database::insert('isms_documents', [
'isms_model_id' => $model['id'],
'organization_id' => $this->getCurrentOrgId(),
'doc_type' => $docType,
'title' => $title,
'body_html' => (string) ($doc['body_html'] ?? $doc['content'] ?? ''),
'status' => 'draft',
'ai_generated' => 1,
'created_by' => $this->getCurrentUserId(),
]);
$this->logAudit('isms_document_ai_generated', 'isms_document', $id, ['doc_type' => $docType]);
$this->jsonSuccess([
'id' => $id,
'title' => $title,
'body_html' => (string) ($doc['body_html'] ?? $doc['content'] ?? ''),
'disclaimer' => 'Bozza generata dall\'AI: revisione umana obbligatoria prima dell\'approvazione.',
], 'Bozza generata');
}
// ════════════════════════ READINESS / EXPORT ════════════════════════
/** GET /api/isms/readiness — checklist clausole 4-10 + % complessiva. */
public function readiness(): void
{
$this->requireOrgAccess();
$model = $this->loadModel();
if (!$model) {
$this->jsonSuccess(['started' => false, 'overall_pct' => 0, 'checklist' => []]);
}
$rolesCount = Database::count('isms_roles', 'isms_model_id = ?', [$model['id']]);
$docsCount = Database::count('isms_documents', 'isms_model_id = ?', [$model['id']]);
$soa = $this->soaStats($model['id']);
$soaAnswered = $soa['total'] > 0 ? ($soa['total'] - $soa['not_started']) : 0;
$checklist = [
['clause' => '4', 'label' => 'Contesto e ambito', 'done' => !empty($model['scope_statement'])],
['clause' => '5', 'label' => 'Leadership: policy e ruoli (RACI)', 'done' => $rolesCount > 0],
['clause' => '6', 'label' => 'Risk: metodologia e obiettivi', 'done' => !empty($model['risk_methodology'])],
['clause' => 'SoA', 'label' => 'Statement of Applicability avviato', 'done' => $soa['total'] > 0],
['clause' => '7-8', 'label' => 'Documented information', 'done' => $docsCount > 0],
['clause' => '9-10', 'label' => 'Monitoraggio e miglioramento (audit/NCR esistenti)', 'done' => $soaAnswered > 0],
];
$done = count(array_filter($checklist, fn($c) => $c['done']));
$overall = (int) round($done / count($checklist) * 100);
$this->jsonSuccess([
'started' => true,
'overall_pct' => $overall,
'checklist' => $checklist,
'soa' => $soa,
'roles_count' => $rolesCount,
'docs_count' => $docsCount,
]);
}
/** GET /api/isms/export — model + SoA + ruoli + documenti (per stampa). */
public function export(): void
{
$this->requireOrgAccess();
$model = $this->requireModel();
$org = Database::fetchOne('SELECT name, sector, entity_type FROM organizations WHERE id = ?', [$this->getCurrentOrgId()]);
$model['interested_parties'] = $model['interested_parties'] ? json_decode($model['interested_parties'], true) : [];
$model['isms_objectives'] = $model['isms_objectives'] ? json_decode($model['isms_objectives'], true) : [];
$soa = Database::fetchAll(
"SELECT s.control_code, s.standard, s.applicable, s.implementation_status, s.implementation_pct,
s.justification_inclusion, s.justification_exclusion, s.derived_from_nis2, s.source_ref,
c.title_it
FROM isms_soa s LEFT JOIN iso27001_annex_controls c ON c.control_code = s.control_code
WHERE s.isms_model_id = ? ORDER BY c.sort_order",
[$model['id']]
);
$roles = Database::fetchAll('SELECT role_name, responsibility, raci FROM isms_roles WHERE isms_model_id = ? ORDER BY id', [$model['id']]);
$docs = Database::fetchAll('SELECT doc_type, title, status, version FROM isms_documents WHERE isms_model_id = ? ORDER BY id', [$model['id']]);
$this->logAudit('isms_export', 'isms_model', (int) $model['id'], null);
$this->jsonSuccess([
'organization' => $org,
'model' => $model,
'soa' => $soa,
'roles' => $roles,
'documents' => $docs,
'stats' => $this->soaStats($model['id']),
'generated_at' => date('c'),
'disclaimer' => 'Documento di supporto/pre-audit. Non costituisce certificazione ISO 27001 ne parere professionale vincolante.',
]);
}
// ════════════════════════ HELPER ════════════════════════
private function loadModel(): ?array
{
return Database::fetchOne('SELECT * FROM isms_models WHERE organization_id = ?', [$this->getCurrentOrgId()]);
}
private function requireModel(): array
{
$m = $this->loadModel();
if (!$m) {
$this->jsonError('SGSI non ancora avviato. Completa prima il passo Contesto e Ambito.', 422, 'ISMS_NOT_STARTED');
}
return $m;
}
/** Standard applicabili in base ai flag del modello. */
private function applicableStandards(?array $model): array
{
$standards = ['iso27001'];
if ($model && ($model['uses_public_cloud'] || $model['is_cloud_provider'])) {
$standards[] = 'iso27017';
}
if ($model && $model['processes_pii_in_cloud']) {
$standards[] = 'iso27018';
}
return $standards;
}
/**
* Deriva/integra il SoA: inserisce i controlli applicabili mancanti,
* pre-compilando stato e motivazione dalle risposte NIS2 dove possibile.
* INSERT IGNORE => non sovrascrive il lavoro manuale gia presente.
* @return int controlli aggiunti
*/
private function doDerive(array $model): int
{
$standards = $this->applicableStandards($model);
$place = implode(',', array_fill(0, count($standards), '?'));
$controls = Database::fetchAll(
"SELECT control_code, standard FROM iso27001_annex_controls WHERE standard IN ($place) ORDER BY sort_order",
$standards
);
$nis2 = $this->nis2ControlStatus(); // [iso_control_code => ['status'=>..,'pct'=>..,'sources'=>[..]]]
$orgId = $this->getCurrentOrgId();
$userId = $this->getCurrentUserId();
$existing = array_column(
Database::fetchAll('SELECT control_code FROM isms_soa WHERE isms_model_id = ?', [$model['id']]),
'control_code'
);
$existing = array_flip($existing);
$added = 0;
foreach ($controls as $c) {
$code = $c['control_code'];
if (isset($existing[$code])) {
continue;
}
$d = $nis2[$code] ?? null;
$row = [
'isms_model_id' => $model['id'],
'organization_id' => $orgId,
'control_code' => $code,
'standard' => $c['standard'],
'applicable' => 1,
'updated_by' => $userId,
];
if ($d) {
$row['implementation_status'] = $d['status'];
$row['implementation_pct'] = $d['pct'];
$row['derived_from_nis2'] = 1;
$row['source_ref'] = implode(', ', array_slice($d['sources'], 0, 4));
$row['justification_inclusion'] = 'Applicabile: collegato alle misure NIS2 ' . $row['source_ref']
. '. Stato derivato dall\'assessment Art.21 (da confermare).';
}
// INSERT IGNORE manuale tramite query (Database::insert non supporta IGNORE).
$cols = implode(',', array_keys($row));
$ph = implode(',', array_fill(0, count($row), '?'));
Database::query("INSERT IGNORE INTO isms_soa ($cols) VALUES ($ph)", array_values($row));
$added++;
}
return $added;
}
/**
* Aggrega lo stato dei controlli ISO 27001 a partire dalle risposte NIS2.
* Usa l'ultimo assessment dell'org e il mapping question->iso27001_control
* presente nel questionario.
* @return array<string,array{status:string,pct:int,sources:array}>
*/
private function nis2ControlStatus(): array
{
$orgId = $this->getCurrentOrgId();
$assessment = Database::fetchOne(
'SELECT id FROM assessments WHERE organization_id = ? ORDER BY created_at DESC LIMIT 1',
[$orgId]
);
if (!$assessment) {
return [];
}
$responses = Database::fetchAll(
'SELECT question_code, response_value FROM assessment_responses WHERE assessment_id = ?',
[$assessment['id']]
);
if (empty($responses)) {
return [];
}
$respByCode = [];
foreach ($responses as $r) {
$respByCode[$r['question_code']] = $r['response_value'];
}
// mappa question_code -> iso27001_control + nis2_article dal questionario
$q = $this->questionnaire();
$pctVal = ['implemented' => 100, 'partial' => 50, 'not_implemented' => 0];
$agg = []; // iso_code => ['sum'=>,'cnt'=>,'sources'=>[]]
foreach ($q['categories'] ?? [] as $cat) {
foreach ($cat['questions'] ?? [] as $question) {
$iso = $question['iso27001_control'] ?? null;
$code = $question['code'] ?? null;
if (!$iso || !$code || !isset($respByCode[$code])) {
continue;
}
$resp = $respByCode[$code];
if ($resp === 'not_applicable' || $resp === null || !isset($pctVal[$resp])) {
continue;
}
$agg[$iso] ??= ['sum' => 0, 'cnt' => 0, 'sources' => []];
$agg[$iso]['sum'] += $pctVal[$resp];
$agg[$iso]['cnt']++;
$art = $question['nis2_article'] ?? '';
$src = 'Art.' . $art . ' (' . $code . ')';
if (!in_array($src, $agg[$iso]['sources'], true)) {
$agg[$iso]['sources'][] = $src;
}
}
}
$out = [];
foreach ($agg as $iso => $a) {
$pct = $a['cnt'] > 0 ? (int) round($a['sum'] / $a['cnt']) : 0;
$status = $pct >= 100 ? 'implemented' : ($pct > 0 ? 'in_progress' : 'not_started');
$out[$iso] = ['status' => $status, 'pct' => $pct, 'sources' => $a['sources']];
}
return $out;
}
private ?array $questionnaireCache = null;
private function questionnaire(): array
{
if ($this->questionnaireCache === null) {
$path = APP_PATH . '/data/nis2_questionnaire.json';
$json = is_readable($path) ? json_decode((string) file_get_contents($path), true) : null;
$this->questionnaireCache = is_array($json) ? $json : ['categories' => []];
}
return $this->questionnaireCache;
}
private function soaStats(int $modelId): array
{
$rows = Database::fetchAll(
'SELECT applicable, implementation_status, COUNT(*) AS n
FROM isms_soa WHERE isms_model_id = ? GROUP BY applicable, implementation_status',
[$modelId]
);
$s = ['total' => 0, 'applicable' => 0, 'excluded' => 0,
'not_started' => 0, 'in_progress' => 0, 'implemented' => 0, 'verified' => 0];
foreach ($rows as $r) {
$n = (int) $r['n'];
$s['total'] += $n;
if ((int) $r['applicable'] === 1) {
$s['applicable'] += $n;
$st = $r['implementation_status'];
if (isset($s[$st])) $s[$st] += $n;
} else {
$s['excluded'] += $n;
}
}
// % implementazione media sui controlli applicabili
$impl = Database::fetchOne(
'SELECT AVG(implementation_pct) AS avg_pct FROM isms_soa WHERE isms_model_id = ? AND applicable = 1',
[$modelId]
);
$s['avg_implementation_pct'] = $impl && $impl['avg_pct'] !== null ? (int) round($impl['avg_pct']) : 0;
return $s;
}
}