[FEAT] Gap Analysis estesa ai requisiti ACN (specifiche di base 164179/2025)

Allinea il PRODOTTO alla guida/normativa portando la compliance dal livello 10 misure Art.21
al livello operativo dei requisiti ACN (Framework Nazionale 2025).
- Migrazione 031: acn_requirements (catalogo) + org_acn_requirement_status (stato per-org)
- Seed da Allegati 1-2 ACN (fonte certa, parsing verificato): 87 importanti + 116 essenziali = 203 requisiti reali
- AuditController: acnRequirements (GET, per entity_type org: importanti 87 / essenziali 116, summary per funzione GV/ID/PR/DE/RS/RC, % compliance) + updateAcnRequirement (PUT stato+evidenza)
- Route audit/acnRequirements GET/PUT
- guida.html: fix refuso cap-5 (residuo 'otto categorie...no' -> '10 categorie x 8, quattro modalita')
E2E prod: org importante -> 87 req; PUT implemented -> compliance aggiornata.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
DevEnv nis2-agile
2026-05-31 08:07:38 +02:00
co-authored by Claude Opus 4.8
parent 6671d51969
commit 59205d05fb
5 changed files with 1800 additions and 0 deletions
@@ -470,4 +470,98 @@ class AuditController extends BaseController
'recent_evidence' => $recent,
]);
}
// ══════════════════════════════════════════════════════════════════════
// REQUISITI ACN (specifiche di base, Determina 164179/2025)
// Gap Analysis a livello requisiti: 87 (importanti) / 116 (essenziali).
// ══════════════════════════════════════════════════════════════════════
/** Mappa entity_type org (essential/important/...) -> entity catalogo ACN. */
private function acnEntityFor(int $orgId): string
{
$org = Database::fetchOne('SELECT entity_type FROM organizations WHERE id = ?', [$orgId]);
// I soggetti essenziali hanno il set esteso (116). Tutti gli altri (importanti o
// non classificati) usano il set base importanti (87).
return ($org && ($org['entity_type'] ?? '') === 'essential') ? 'essenziale' : 'importante';
}
/**
* GET /api/audit/acnRequirements
* Requisiti ACN applicabili all'org (per entity_type) con stato + sommario per funzione.
*/
public function acnRequirements(): void
{
$this->requireOrgAccess();
$orgId = $this->getCurrentOrgId();
$entity = $this->acnEntityFor($orgId);
$rows = Database::fetchAll(
"SELECT r.id, r.function_name, r.subcategory, r.subcategory_text, r.req_index, r.requirement,
COALESCE(s.status,'not_started') AS status, s.evidence_note, s.updated_at
FROM acn_requirements r
LEFT JOIN org_acn_requirement_status s
ON s.requirement_id = r.id AND s.organization_id = ?
WHERE r.entity = ?
ORDER BY r.subcategory, r.req_index",
[$orgId, $entity]
);
$summary = ['not_started'=>0,'in_progress'=>0,'implemented'=>0,'not_applicable'=>0];
$byFunc = [];
foreach ($rows as $r) {
$st = $r['status'];
if (isset($summary[$st])) $summary[$st]++;
$f = $r['function_name'];
if (!isset($byFunc[$f])) $byFunc[$f] = ['total'=>0,'implemented'=>0];
$byFunc[$f]['total']++;
if ($st === 'implemented') $byFunc[$f]['implemented']++;
}
$total = count($rows);
$applicable = $total - $summary['not_applicable'];
$compliance = $applicable > 0 ? (int) round($summary['implemented'] * 100 / $applicable) : 0;
$this->jsonSuccess([
'entity' => $entity,
'total' => $total,
'summary' => $summary,
'by_function' => $byFunc,
'compliance_percent'=> $compliance,
'requirements' => $rows,
'source' => 'Determina ACN 164179/2025, Allegati 1-2 (Framework Nazionale 2025)',
]);
}
/**
* PUT /api/audit/acnRequirements/{id}
* Aggiorna lo stato di un requisito ACN per l'org. Body: { status, evidence_note? }
*/
public function updateAcnRequirement(int $id): void
{
$this->requireOrgRole(['org_admin', 'compliance_manager', 'auditor']);
$orgId = $this->getCurrentOrgId();
$entity = $this->acnEntityFor($orgId);
// Il requisito deve appartenere al set applicabile all'org
$req = Database::fetchOne('SELECT id FROM acn_requirements WHERE id = ? AND entity = ?', [$id, $entity]);
if (!$req) {
$this->jsonError('Requisito non trovato o non applicabile a questa organizzazione', 404, 'NOT_FOUND');
}
$status = $this->getParam('status');
$valid = ['not_started','in_progress','implemented','not_applicable'];
if (!in_array($status, $valid, true)) {
$this->jsonError('Stato non valido', 422, 'INVALID_STATUS');
}
$note = $this->getParam('evidence_note');
Database::query(
'INSERT INTO org_acn_requirement_status (organization_id, requirement_id, status, evidence_note, updated_by)
VALUES (?,?,?,?,?)
ON DUPLICATE KEY UPDATE status=VALUES(status), evidence_note=VALUES(evidence_note),
updated_by=VALUES(updated_by), updated_at=NOW()',
[$orgId, $id, $status, $note, $this->getCurrentUserId()]
);
$this->logAudit('acn_requirement_updated', 'acn_requirement', $id, ['status' => $status]);
$this->jsonSuccess(['id' => $id, 'status' => $status], 'Requisito aggiornato');
}
}