From c5fce7bdf73bbe69cae5d5a1abe7e2b66c111771 Mon Sep 17 00:00:00 2001 From: DevEnv nis2-agile Date: Mon, 15 Jun 2026 17:06:17 +0200 Subject: [PATCH] [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) --- .../controllers/OnboardingController.php | 70 ++++---- .../controllers/OrganizationController.php | 63 +++----- .../services/Nis2ClassificationService.php | 151 ++++++++++++++++++ 3 files changed, 208 insertions(+), 76 deletions(-) create mode 100644 application/services/Nis2ClassificationService.php diff --git a/application/controllers/OnboardingController.php b/application/controllers/OnboardingController.php index 6fc90b9..570025a 100644 --- a/application/controllers/OnboardingController.php +++ b/application/controllers/OnboardingController.php @@ -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, + '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 diff --git a/application/controllers/OrganizationController.php b/application/controllers/OrganizationController.php index 5d72a55..3aab1ac 100644 --- a/application/controllers/OrganizationController.php +++ b/application/controllers/OrganizationController.php @@ -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); } /** diff --git a/application/services/Nis2ClassificationService.php b/application/services/Nis2ClassificationService.php new file mode 100644 index 0000000..1359cd3 --- /dev/null +++ b/application/services/Nis2ClassificationService.php @@ -0,0 +1,151 @@ +=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.", + ]; + } +}