[FEAT] A2 backend: Nis2ClassificationService v2 (matrice+bilancio, size-independent art.3 c.5, criteri sotto-soglia c.9 b/c/d, obblighi D.Lgs. art.23/24/25, esito 'preliminare non vincolante'); logica centralizzata (no duplicazione) + complete() salva i nuovi campi

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
DevEnv nis2-agile
2026-06-15 17:06:17 +02:00
co-authored by Claude Opus 4.8
parent b2f47c0a47
commit c5fce7bdf7
3 changed files with 208 additions and 76 deletions
@@ -8,6 +8,7 @@
require_once __DIR__ . '/BaseController.php';
require_once __DIR__ . '/../services/VisuraService.php';
require_once APP_PATH . '/services/RateLimitService.php';
require_once __DIR__ . '/../services/Nis2ClassificationService.php';
class OnboardingController extends BaseController
{
@@ -217,6 +218,15 @@ class OnboardingController extends BaseController
'sector' => $this->getParam('sector'),
'employee_count' => $this->getParam('employee_count'),
'annual_turnover_eur' => $this->getParam('annual_turnover_eur'),
'total_balance_eur' => $this->getParam('total_balance_eur'),
'is_autonomous' => (int) $this->getParam('is_autonomous', 1),
'size_independent_category' => $this->getParam('size_independent_category', 'none'),
'crit_sole_supplier' => $this->hasParam('crit_sole_supplier') ? (int) $this->getParam('crit_sole_supplier') : null,
'crit_public_impact' => $this->hasParam('crit_public_impact') ? (int) $this->getParam('crit_public_impact') : null,
'crit_systemic_risk' => $this->hasParam('crit_systemic_risk') ? (int) $this->getParam('crit_systemic_risk') : null,
'mission' => $this->getParam('mission'),
'has_code_of_ethics' => (int) $this->getParam('has_code_of_ethics', 0),
'code_of_ethics_note' => $this->getParam('code_of_ethics_note'),
'country' => $this->getParam('country', 'IT'),
'city' => $this->getParam('city'),
'address' => $this->getParam('address'),
@@ -227,19 +237,28 @@ class OnboardingController extends BaseController
$orgId = Database::insert('organizations', $orgData);
// Auto-classify NIS2 entity type
$entityType = $this->classifyNis2Entity(
$orgData['sector'],
(int) ($orgData['employee_count'] ?? 0),
(float) ($orgData['annual_turnover_eur'] ?? 0)
);
// Auto-classify NIS2 (AUTOVALUTAZIONE PRELIMINARE) — logica centralizzata v2
$cls = Nis2ClassificationService::classify([
'sector' => $orgData['sector'],
'employees' => (int) ($orgData['employee_count'] ?? 0),
'turnover' => (float) ($orgData['annual_turnover_eur'] ?? 0),
'balance' => (float) ($orgData['total_balance_eur'] ?? 0),
'size_independent_category' => $orgData['size_independent_category'],
'is_autonomous' => $orgData['is_autonomous'],
'crit_sole_supplier' => $orgData['crit_sole_supplier'],
'crit_public_impact' => $orgData['crit_public_impact'],
'crit_systemic_risk' => $orgData['crit_systemic_risk'],
]);
$entityType = $cls['entity_type'];
// Handle voluntary compliance
// Handle voluntary compliance (solo per soggetti non in ambito)
$voluntaryCompliance = ($entityType === 'not_applicable' && (int) $this->getParam('voluntary_compliance', 0)) ? 1 : 0;
Database::update('organizations', [
'entity_type' => $entityType,
'voluntary_compliance' => $voluntaryCompliance,
'designation_basis' => $cls['designation_basis'],
'self_assessment_status' => $cls['self_assessment_status'],
], 'id = ?', [$orgId]);
// Link user: consulente → ruolo 'consultant', is_primary solo se prima org
@@ -282,7 +301,7 @@ class OnboardingController extends BaseController
'name' => $orgData['name'],
'entity_type' => $entityType,
'voluntary_compliance' => $voluntaryCompliance,
'classification' => $this->getClassificationDetails($entityType, $orgData['sector'], (int)($orgData['employee_count'] ?? 0), (float)($orgData['annual_turnover_eur'] ?? 0)),
'classification' => $cls,
], 'Onboarding completato', 201);
} catch (Throwable $e) {
@@ -297,35 +316,8 @@ class OnboardingController extends BaseController
private function classifyNis2Entity(string $sector, int $employees, float $turnover): string
{
// Map detailed sector codes to NIS2 categories
$essentialSectors = [
'energy', 'energy_electricity', 'energy_district_heating', 'energy_oil', 'energy_gas', 'energy_hydrogen',
'transport', 'transport_air', 'transport_rail', 'transport_water', 'transport_road',
'banking', 'financial_markets', 'health', 'water', 'drinking_water', 'waste_water',
'digital_infra', 'digital_infrastructure', 'ict_service_management',
'public_admin', 'public_administration', 'space',
];
$importantSectors = [
'manufacturing', 'manufacturing_medical', 'manufacturing_computers', 'manufacturing_electrical',
'manufacturing_machinery', 'manufacturing_vehicles', 'manufacturing_transport',
'postal', 'postal_courier', 'chemical', 'chemicals', 'food',
'waste', 'waste_management', 'ict_services', 'digital_providers', 'research',
];
$isLarge = $employees >= 250 || $turnover >= 50000000;
$isMedium = ($employees >= 50 || $turnover >= 10000000) && !$isLarge;
if (in_array($sector, $essentialSectors)) {
if ($isLarge) return 'essential';
if ($isMedium) return 'important';
}
if (in_array($sector, $importantSectors)) {
if ($isLarge || $isMedium) return 'important';
}
return 'not_applicable';
// Logica centralizzata in Nis2ClassificationService (A2).
return Nis2ClassificationService::entityType($sector, $employees, $turnover);
}
private function getClassificationDetails(string $type, string $sector, int $employees, float $turnover): array
@@ -6,6 +6,7 @@
*/
require_once __DIR__ . '/BaseController.php';
require_once __DIR__ . '/../services/Nis2ClassificationService.php';
class OrganizationController extends BaseController
{
@@ -309,19 +310,31 @@ class OrganizationController extends BaseController
{
$this->validateRequired(['sector', 'employee_count', 'annual_turnover_eur']);
$sector = $this->getParam('sector');
$employees = (int) $this->getParam('employee_count');
$turnover = (float) $this->getParam('annual_turnover_eur');
$entityType = $this->classifyNis2Entity($sector, $employees, $turnover);
$r = Nis2ClassificationService::classify([
'sector' => $this->getParam('sector'),
'employees' => (int) $this->getParam('employee_count'),
'turnover' => (float) $this->getParam('annual_turnover_eur'),
'balance' => (float) $this->getParam('total_balance_eur', 0),
'size_independent_category' => $this->getParam('size_independent_category', 'none'),
'is_autonomous' => (int) $this->getParam('is_autonomous', 1),
'crit_sole_supplier' => (int) $this->getParam('crit_sole_supplier', 0),
'crit_public_impact' => (int) $this->getParam('crit_public_impact', 0),
'crit_systemic_risk' => (int) $this->getParam('crit_systemic_risk', 0),
]);
$this->jsonSuccess([
'entity_type' => $entityType,
'sector' => $sector,
'employee_count' => $employees,
'annual_turnover_eur' => $turnover,
'allows_voluntary' => $entityType === 'not_applicable',
'explanation' => $this->getClassificationExplanation($entityType, $sector, $employees, $turnover),
'entity_type' => $r['entity_type'],
'sector' => $this->getParam('sector'),
'allows_voluntary' => $r['entity_type'] === 'not_applicable',
'designation_basis' => $r['designation_basis'],
'self_assessment_status' => $r['self_assessment_status'],
'sub_threshold_candidate' => $r['sub_threshold_candidate'],
'label' => $r['label'],
'size' => $r['size'],
'explanation' => $r['description'],
'obligations' => $r['obligations'],
'warnings' => $r['warnings'],
'disclaimer' => $r['disclaimer'],
]);
}
@@ -334,32 +347,8 @@ class OrganizationController extends BaseController
*/
private function classifyNis2Entity(string $sector, int $employees, float $turnover): string
{
// Settori Essenziali (Allegato I)
$essentialSectors = [
'energy', 'transport', 'banking', 'health', 'water',
'digital_infra', 'public_admin', 'space',
];
// Settori Importanti (Allegato II)
$importantSectors = [
'manufacturing', 'postal', 'chemical', 'food', 'waste',
'ict_services', 'digital_providers', 'research',
];
// Soglie dimensionali
$isLarge = $employees >= 250 || $turnover >= 50000000;
$isMedium = ($employees >= 50 || $turnover >= 10000000) && !$isLarge;
if (in_array($sector, $essentialSectors)) {
if ($isLarge) return 'essential';
if ($isMedium) return 'important';
}
if (in_array($sector, $importantSectors)) {
if ($isLarge || $isMedium) return 'important';
}
return 'not_applicable';
// Logica centralizzata in Nis2ClassificationService (A2). Delego per evitare duplicazione.
return Nis2ClassificationService::entityType($sector, $employees, $turnover);
}
/**
@@ -0,0 +1,151 @@
<?php
/**
* Nis2ClassificationService — AUTOVALUTAZIONE PRELIMINARE del soggetto NIS2.
*
* Centralizza la logica prima DUPLICATA in OnboardingController e OrganizationController (A2).
* L'esito NON e' una qualifica giuridica: la classificazione formale e la registrazione spettano
* all'ACN (Det. 333017/2025: dichiarazione ex DPR 445/2000, finestra 1/1-28/2, verifiche art. 14).
*
* Riferimenti (D.Lgs. 138/2024 art. 3 — vedi docs/nis2/Dlgs138_art3_ambito.md):
* - c. 2 regola dimensionale (Allegato I/II oltre i massimali delle piccole imprese)
* - c. 5 soggetti A PRESCINDERE dalla dimensione (DNS/TLD/IXP, fiduciari, reti/servizi TLC pubblici, ...)
* - c. 9 lett. b/c/d criteri qualitativi sotto-soglia (unico fornitore / impatto sicurezza-salute pubblica / rischio sistemico)
* - c. 13 individuazione da parte dell'Autorita' (ACN) su proposta delle Autorita' di settore
* Soglie dimensionali: Racc. 2003/361/CE (grande >=250 dip o >50M EUR fatt o >43M EUR bilancio; media >=50 o >10M).
*/
class Nis2ClassificationService
{
/** Allegato I (alta criticita') -> essenziale se grande, importante se media */
private const ESSENTIAL_SECTORS = [
'energy','energy_electricity','energy_district_heating','energy_oil','energy_gas','energy_hydrogen',
'transport','transport_air','transport_rail','transport_water','transport_road',
'banking','financial_markets','health','water','drinking_water','waste_water',
'digital_infra','digital_infrastructure','ict_service_management',
'public_admin','public_administration','space',
];
/** Allegato II (altri settori critici) -> importante se media o grande */
private const IMPORTANT_SECTORS = [
'manufacturing','manufacturing_medical','manufacturing_computers','manufacturing_electrical',
'manufacturing_machinery','manufacturing_vehicles','manufacturing_transport',
'postal','postal_courier','chemical','chemicals','food',
'waste','waste_management','ict_services','digital_providers','research',
];
/** Categorie in ambito a prescindere dalla dimensione (art. 3 c. 5) -> classe fissa */
private const SIZE_INDEPENDENT = [
'dns' => 'essential', 'tld' => 'essential', 'ixp' => 'essential',
'trust_qualified' => 'essential', 'trust_unqualified' => 'important',
'ecomm_public_network' => 'essential', 'ecomm_public_service' => 'essential',
'domain_registrar' => 'important', 'pa_central' => 'essential', 'pa_regional_local' => 'important',
];
/**
* Classifica (autovalutazione). Input opzionali oltre a sector/employees/turnover:
* balance, size_independent_category, is_autonomous, crit_sole_supplier, crit_public_impact, crit_systemic_risk.
* @return array {entity_type, designation_basis, self_assessment_status, sub_threshold_candidate,
* label, size, description, obligations[], warnings[], disclaimer}
*/
public static function classify(array $in): array
{
$sector = (string) ($in['sector'] ?? '');
$employees = (int) ($in['employees'] ?? 0);
$turnover = (float) ($in['turnover'] ?? 0);
$balance = (float) ($in['balance'] ?? 0);
$sizeIndep = (string) ($in['size_independent_category'] ?? 'none');
$autonomous = array_key_exists('is_autonomous', $in) ? (int) $in['is_autonomous'] : 1;
$critSole = !empty($in['crit_sole_supplier']);
$critImpact = !empty($in['crit_public_impact']);
$critSystemic = !empty($in['crit_systemic_risk']);
$warnings = [];
if (!$autonomous) {
$warnings[] = "Impresa non autonoma (gruppo/collegate): il calcolo dimensionale va consolidato ai sensi dell'art. 6 della Raccomandazione 2003/361/CE. L'esito automatico potrebbe sottostimare la classe.";
}
// 1) Soggetti a prescindere dalla dimensione (art. 3 c. 5)
if ($sizeIndep !== '' && $sizeIndep !== 'none' && isset(self::SIZE_INDEPENDENT[$sizeIndep])) {
return self::result(self::SIZE_INDEPENDENT[$sizeIndep], 'size_independent', $employees, $turnover, $balance, $warnings,
"Soggetto incluso a prescindere dalla dimensione (art. 3, comma 5, D.Lgs. 138/2024).");
}
// 2) Matrice settore x dimensione (art. 3 c. 2)
$size = self::sizeClass($employees, $turnover, $balance); // large|medium|small
$isLarge = $size === 'large';
$isMedium = $size === 'medium';
if (in_array($sector, self::ESSENTIAL_SECTORS, true)) {
if ($isLarge) return self::result('essential', 'dimensional', $employees, $turnover, $balance, $warnings);
if ($isMedium) return self::result('important', 'dimensional', $employees, $turnover, $balance, $warnings);
}
if (in_array($sector, self::IMPORTANT_SECTORS, true)) {
if ($isLarge || $isMedium) return self::result('important', 'dimensional', $employees, $turnover, $balance, $warnings);
}
// 3) Sotto-soglia con criteri qualitativi (art. 3 c. 9 lett. b/c/d) -> CANDIDATO (no auto-classe)
if ($critSole || $critImpact || $critSystemic) {
$r = self::result('not_applicable', 'sub_threshold_candidate', $employees, $turnover, $balance, $warnings,
"Soggetto sotto la soglia dimensionale che soddisfa uno o piu' criteri qualitativi dell'art. 3, comma 9, lett. b/c/d del D.Lgs. 138/2024. NON e' una classificazione automatica: l'individuazione come soggetto essenziale o importante spetta all'Autorita' (ACN) ex art. 3, comma 13. Verificare/segnalarsi presso ACN.");
$r['sub_threshold_candidate'] = true;
$r['label'] = 'Potenziale soggetto individuabile (rinvio ACN)';
return $r;
}
// 4) Fuori ambito
return self::result('not_applicable', 'out_of_scope', $employees, $turnover, $balance, $warnings);
}
/** Solo il tipo (compat con i vecchi chiamanti). */
public static function entityType(string $sector, int $employees, float $turnover, float $balance = 0): string
{
return self::classify(['sector' => $sector, 'employees' => $employees, 'turnover' => $turnover, 'balance' => $balance])['entity_type'];
}
/** large | medium | small (micro inclusa) — Racc. 2003/361/CE (criteri alternativi su fatturato/bilancio). */
private static function sizeClass(int $employees, float $turnover, float $balance): string
{
if ($employees >= 250 || $turnover > 50000000 || $balance > 43000000) return 'large';
if ($employees >= 50 || $turnover > 10000000 || $balance > 10000000) return 'medium';
return 'small';
}
private static function result(string $type, string $basis, int $employees, float $turnover, float $balance, array $warnings, ?string $note = null): array
{
$sizeLabel = ($employees >= 250 || $turnover > 50000000 || $balance > 43000000) ? 'Grande impresa'
: (($employees >= 50 || $turnover > 10000000 || $balance > 10000000) ? 'Media impresa' : 'Piccola/micro impresa');
$obligations = match ($type) {
'essential', 'important' => [
'Misure di gestione del rischio di cybersicurezza (art. 24 D.Lgs. 138/2024)',
'Notifica degli incidenti significativi: preallarme 24h, notifica 72h, relazione finale 1 mese (art. 25 D.Lgs. 138/2024)',
'Responsabilita\' e formazione degli organi di amministrazione e direttivi (art. 23 D.Lgs. 138/2024)',
$type === 'essential' ? 'Vigilanza proattiva (ex ante) da parte dell\'Autorita\'' : 'Vigilanza reattiva (ex post) da parte dell\'Autorita\'',
$type === 'essential' ? 'Sanzioni fino a 10M EUR o 2% del fatturato mondiale annuo' : 'Sanzioni fino a 7M EUR o 1,4% del fatturato mondiale annuo',
],
default => [
'Nessun obbligo NIS2 automatico in base ai dati forniti',
'Buone pratiche di cybersicurezza comunque raccomandate',
],
};
$labels = ['essential' => 'Soggetto Essenziale', 'important' => 'Soggetto Importante', 'not_applicable' => 'Non in ambito (in base ai dati)'];
$descr = match ($type) {
'essential' => "Autovalutazione: la vostra organizzazione rientrerebbe tra i soggetti ESSENZIALI (settore ad alta criticita', Allegato I, dimensione di grande impresa).",
'important' => "Autovalutazione: la vostra organizzazione rientrerebbe tra i soggetti IMPORTANTI ai sensi del D.Lgs. 138/2024.",
default => "Autovalutazione: in base ai dati forniti la vostra organizzazione non rientrerebbe automaticamente nell'ambito del D.Lgs. 138/2024.",
};
if ($note) $descr .= ' ' . $note;
return [
'entity_type' => $type,
'designation_basis' => $basis,
'self_assessment_status' => 'preliminary',
'sub_threshold_candidate' => false,
'label' => $labels[$type] ?? $type,
'size' => $sizeLabel,
'description' => $descr,
'obligations' => $obligations,
'warnings' => $warnings,
'disclaimer' => "Autovalutazione PRELIMINARE non vincolante. La qualifica formale di soggetto essenziale/importante e la registrazione spettano esclusivamente all'ACN tramite la piattaforma (Determinazione ACN 333017/2025): dichiarazione ex DPR 445/2000 confermata dal punto di contatto, finestra di registrazione 1 gennaio - 28 febbraio, verifiche di coerenza ex art. 14. La classificazione formale e i pareri legali competono a Direzione/Legale.",
];
}
}