[FEAT] Visura auto-fill, adesione volontaria, modulo NCR/CAPA

1. Fix auto-fill visura: mapping corretto suggested_sector e employees_range,
   indicatori visivi verdi sui campi auto-compilati, fatturato sempre manuale
2. Adesione volontaria: colonna voluntary_compliance, checkbox in onboarding
   step 5 quando not_applicable, toggle in settings, reset su ri-classificazione
3. Modulo NCR/CAPA: NonConformityController con 10 endpoint API,
   tabelle non_conformities + capa_actions, generazione NCR dai gap assessment,
   predisposizione integrazione SistemiG.agile (webhook + sync)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Cristiano Benassati 2026-02-18 08:12:57 +01:00
parent 517cab76a5
commit 4e3408e9f6
11 changed files with 985 additions and 21 deletions

View File

@ -0,0 +1,502 @@
<?php
/**
* NIS2 Agile - Non-Conformity & CAPA Controller
*
* Gestione non conformità e azioni correttive/preventive.
* Predisposizione integrazione SistemiG.agile.
*/
require_once __DIR__ . '/BaseController.php';
class NonConformityController extends BaseController
{
// ═══════════════════════════════════════════════════════════════════════
// NCR CRUD
// ═══════════════════════════════════════════════════════════════════════
/**
* POST /api/ncr/create
*/
public function create(): void
{
$this->requireOrgRole(['org_admin', 'compliance_manager', 'auditor']);
$this->validateRequired(['title', 'source']);
$orgId = $this->getCurrentOrgId();
$ncrCode = $this->generateCode('NCR');
$ncrId = Database::insert('non_conformities', [
'organization_id' => $orgId,
'ncr_code' => $ncrCode,
'title' => trim($this->getParam('title')),
'description' => $this->getParam('description'),
'source' => $this->getParam('source'),
'source_entity_type' => $this->getParam('source_entity_type'),
'source_entity_id' => $this->getParam('source_entity_id'),
'severity' => $this->getParam('severity', 'minor'),
'category' => $this->getParam('category'),
'nis2_article' => $this->getParam('nis2_article'),
'identified_by' => $this->getCurrentUserId(),
'assigned_to' => $this->getParam('assigned_to'),
'target_close_date' => $this->getParam('target_close_date'),
]);
$this->logAudit('ncr_created', 'non_conformity', $ncrId, [
'ncr_code' => $ncrCode,
'source' => $this->getParam('source'),
]);
$this->jsonSuccess([
'id' => $ncrId,
'ncr_code' => $ncrCode,
], 'Non conformita\' creata', 201);
}
/**
* GET /api/ncr/list
*/
public function list(): void
{
$this->requireOrgAccess();
$orgId = $this->getCurrentOrgId();
[$page, $perPage] = $this->getPagination();
$offset = ($page - 1) * $perPage;
// Filters
$where = 'n.organization_id = ?';
$params = [$orgId];
$status = $this->getParam('status');
if ($status) {
$where .= ' AND n.status = ?';
$params[] = $status;
}
$severity = $this->getParam('severity');
if ($severity) {
$where .= ' AND n.severity = ?';
$params[] = $severity;
}
$source = $this->getParam('source');
if ($source) {
$where .= ' AND n.source = ?';
$params[] = $source;
}
$total = Database::count('non_conformities n', $where, $params);
$ncrs = Database::fetchAll(
"SELECT n.*, u1.full_name as identified_by_name, u2.full_name as assigned_to_name,
(SELECT COUNT(*) FROM capa_actions WHERE ncr_id = n.id) as capa_count
FROM non_conformities n
LEFT JOIN users u1 ON u1.id = n.identified_by
LEFT JOIN users u2 ON u2.id = n.assigned_to
WHERE {$where}
ORDER BY n.created_at DESC
LIMIT {$perPage} OFFSET {$offset}",
$params
);
$this->jsonPaginated($ncrs, $total, $page, $perPage);
}
/**
* GET /api/ncr/{id}
*/
public function get(int $id): void
{
$this->requireOrgAccess();
$ncr = Database::fetchOne(
'SELECT n.*, u1.full_name as identified_by_name, u2.full_name as assigned_to_name
FROM non_conformities n
LEFT JOIN users u1 ON u1.id = n.identified_by
LEFT JOIN users u2 ON u2.id = n.assigned_to
WHERE n.id = ? AND n.organization_id = ?',
[$id, $this->getCurrentOrgId()]
);
if (!$ncr) {
$this->jsonError('Non conformita\' non trovata', 404, 'NCR_NOT_FOUND');
}
// Load CAPA actions
$ncr['capa_actions'] = Database::fetchAll(
'SELECT ca.*, u1.full_name as responsible_name, u2.full_name as verified_by_name
FROM capa_actions ca
LEFT JOIN users u1 ON u1.id = ca.responsible_user_id
LEFT JOIN users u2 ON u2.id = ca.verified_by
WHERE ca.ncr_id = ?
ORDER BY ca.created_at ASC',
[$id]
);
$this->jsonSuccess($ncr);
}
/**
* PUT /api/ncr/{id}
*/
public function update(int $id): void
{
$this->requireOrgRole(['org_admin', 'compliance_manager', 'auditor']);
$ncr = Database::fetchOne(
'SELECT id FROM non_conformities WHERE id = ? AND organization_id = ?',
[$id, $this->getCurrentOrgId()]
);
if (!$ncr) {
$this->jsonError('Non conformita\' non trovata', 404, 'NCR_NOT_FOUND');
}
$allowedFields = [
'title', 'description', 'severity', 'category', 'nis2_article',
'status', 'assigned_to', 'target_close_date', 'actual_close_date',
'root_cause_analysis', 'root_cause_method',
];
$updates = [];
foreach ($allowedFields as $field) {
if ($this->hasParam($field)) {
$updates[$field] = $this->getParam($field);
}
}
if (empty($updates)) {
$this->jsonError('Nessun campo da aggiornare', 400, 'NO_UPDATES');
}
// Auto-set close date when status becomes closed
if (isset($updates['status']) && $updates['status'] === 'closed' && !isset($updates['actual_close_date'])) {
$updates['actual_close_date'] = date('Y-m-d');
}
Database::update('non_conformities', $updates, 'id = ?', [$id]);
$this->logAudit('ncr_updated', 'non_conformity', $id, $updates);
$this->jsonSuccess($updates, 'Non conformita\' aggiornata');
}
// ═══════════════════════════════════════════════════════════════════════
// CAPA ACTIONS
// ═══════════════════════════════════════════════════════════════════════
/**
* POST /api/ncr/{id}/capa
*/
public function addCapa(int $ncrId): void
{
$this->requireOrgRole(['org_admin', 'compliance_manager', 'auditor']);
$this->validateRequired(['title', 'action_type']);
$orgId = $this->getCurrentOrgId();
// Verify NCR belongs to org
$ncr = Database::fetchOne(
'SELECT id FROM non_conformities WHERE id = ? AND organization_id = ?',
[$ncrId, $orgId]
);
if (!$ncr) {
$this->jsonError('Non conformita\' non trovata', 404, 'NCR_NOT_FOUND');
}
$capaCode = $this->generateCode('CAPA');
$capaId = Database::insert('capa_actions', [
'ncr_id' => $ncrId,
'organization_id' => $orgId,
'capa_code' => $capaCode,
'action_type' => $this->getParam('action_type', 'corrective'),
'title' => trim($this->getParam('title')),
'description' => $this->getParam('description'),
'responsible_user_id' => $this->getParam('responsible_user_id'),
'due_date' => $this->getParam('due_date'),
'notes' => $this->getParam('notes'),
]);
// Update NCR status to action_planned if still open/investigating
$ncrStatus = Database::fetchOne('SELECT status FROM non_conformities WHERE id = ?', [$ncrId]);
if ($ncrStatus && in_array($ncrStatus['status'], ['open', 'investigating'])) {
Database::update('non_conformities', ['status' => 'action_planned'], 'id = ?', [$ncrId]);
}
$this->logAudit('capa_created', 'capa_action', $capaId, [
'ncr_id' => $ncrId,
'capa_code' => $capaCode,
]);
$this->jsonSuccess([
'id' => $capaId,
'capa_code' => $capaCode,
], 'Azione CAPA creata', 201);
}
/**
* PUT /api/ncr/capa/{subId}
*/
public function updateCapa(int $capaId): void
{
$this->requireOrgRole(['org_admin', 'compliance_manager', 'auditor']);
$capa = Database::fetchOne(
'SELECT id, ncr_id FROM capa_actions WHERE id = ? AND organization_id = ?',
[$capaId, $this->getCurrentOrgId()]
);
if (!$capa) {
$this->jsonError('Azione CAPA non trovata', 404, 'CAPA_NOT_FOUND');
}
$allowedFields = [
'title', 'description', 'action_type', 'status',
'responsible_user_id', 'due_date', 'completion_date',
'verification_date', 'verified_by', 'effectiveness_review',
'is_effective', 'notes',
];
$updates = [];
foreach ($allowedFields as $field) {
if ($this->hasParam($field)) {
$updates[$field] = $this->getParam($field);
}
}
if (empty($updates)) {
$this->jsonError('Nessun campo da aggiornare', 400, 'NO_UPDATES');
}
// Auto-set completion date
if (isset($updates['status']) && $updates['status'] === 'completed' && !isset($updates['completion_date'])) {
$updates['completion_date'] = date('Y-m-d');
}
Database::update('capa_actions', $updates, 'id = ?', [$capaId]);
$this->logAudit('capa_updated', 'capa_action', $capaId, $updates);
$this->jsonSuccess($updates, 'Azione CAPA aggiornata');
}
// ═══════════════════════════════════════════════════════════════════════
// BULK CREATE FROM ASSESSMENT
// ═══════════════════════════════════════════════════════════════════════
/**
* POST /api/ncr/fromAssessment
* Genera NCR dai gap (risposte not_implemented/partial) di un assessment
*/
public function fromAssessment(): void
{
$this->requireOrgRole(['org_admin', 'compliance_manager']);
$this->validateRequired(['assessment_id']);
$orgId = $this->getCurrentOrgId();
$assessmentId = (int) $this->getParam('assessment_id');
// Verify assessment belongs to org
$assessment = Database::fetchOne(
'SELECT id, title FROM assessments WHERE id = ? AND organization_id = ?',
[$assessmentId, $orgId]
);
if (!$assessment) {
$this->jsonError('Assessment non trovato', 404, 'ASSESSMENT_NOT_FOUND');
}
// Check for existing NCRs from this assessment to avoid duplicates
$existingCount = Database::count(
'non_conformities',
'organization_id = ? AND source = ? AND source_entity_type = ? AND source_entity_id IN (SELECT id FROM assessment_responses WHERE assessment_id = ?)',
[$orgId, 'assessment', 'assessment_response', $assessmentId]
);
if ($existingCount > 0) {
$this->jsonError(
"Esistono gia' {$existingCount} non conformita' generate da questo assessment",
409,
'NCR_ALREADY_GENERATED'
);
}
// Find gaps
$gaps = Database::fetchAll(
'SELECT * FROM assessment_responses
WHERE assessment_id = ?
AND response_value IN ("not_implemented", "partial")
ORDER BY category, question_code',
[$assessmentId]
);
if (empty($gaps)) {
$this->jsonSuccess(['created' => [], 'total' => 0], 'Nessun gap trovato nell\'assessment');
return;
}
$created = [];
$assessmentTitle = $assessment['title'];
foreach ($gaps as $gap) {
$severity = $gap['response_value'] === 'not_implemented' ? 'major' : 'minor';
$ncrCode = $this->generateCode('NCR');
$questionText = $gap['question_text'] ?? '';
$title = mb_strlen($questionText) > 200
? 'Gap: ' . mb_substr($questionText, 0, 197) . '...'
: 'Gap: ' . $questionText;
$ncrId = Database::insert('non_conformities', [
'organization_id' => $orgId,
'ncr_code' => $ncrCode,
'title' => $title,
'description' => "Gap identificato nell'assessment \"{$assessmentTitle}\": {$questionText}",
'source' => 'assessment',
'source_entity_type' => 'assessment_response',
'source_entity_id' => $gap['id'],
'severity' => $severity,
'category' => $gap['category'] ?? null,
'nis2_article' => $gap['nis2_article'] ?? null,
'identified_by' => $this->getCurrentUserId(),
'status' => 'open',
]);
$created[] = [
'id' => $ncrId,
'ncr_code' => $ncrCode,
'severity' => $severity,
'category' => $gap['category'] ?? null,
];
}
$this->logAudit('ncr_bulk_created', 'assessment', $assessmentId, [
'count' => count($created),
]);
$this->jsonSuccess([
'created' => $created,
'total' => count($created),
], count($created) . ' non conformita\' generate dall\'assessment');
}
// ═══════════════════════════════════════════════════════════════════════
// STATISTICS
// ═══════════════════════════════════════════════════════════════════════
/**
* GET /api/ncr/stats
*/
public function stats(): void
{
$this->requireOrgAccess();
$orgId = $this->getCurrentOrgId();
// NCR counts by status
$byStatus = Database::fetchAll(
'SELECT status, COUNT(*) as count FROM non_conformities WHERE organization_id = ? GROUP BY status',
[$orgId]
);
// NCR counts by severity
$bySeverity = Database::fetchAll(
'SELECT severity, COUNT(*) as count FROM non_conformities WHERE organization_id = ? GROUP BY severity',
[$orgId]
);
// CAPA counts by status
$capaByStatus = Database::fetchAll(
'SELECT status, COUNT(*) as count FROM capa_actions WHERE organization_id = ? GROUP BY status',
[$orgId]
);
// Overdue NCRs
$overdueNcr = Database::count(
'non_conformities',
'organization_id = ? AND target_close_date < CURDATE() AND status NOT IN ("closed", "cancelled")',
[$orgId]
);
// Overdue CAPAs
$overdueCapa = Database::count(
'capa_actions',
'organization_id = ? AND due_date < CURDATE() AND status NOT IN ("completed", "verified")',
[$orgId]
);
$totalNcr = Database::count('non_conformities', 'organization_id = ?', [$orgId]);
$openNcr = Database::count('non_conformities', 'organization_id = ? AND status NOT IN ("closed", "cancelled")', [$orgId]);
$this->jsonSuccess([
'total_ncr' => $totalNcr,
'open_ncr' => $openNcr,
'overdue_ncr' => $overdueNcr,
'overdue_capa' => $overdueCapa,
'ncr_by_status' => $byStatus,
'ncr_by_severity' => $bySeverity,
'capa_by_status' => $capaByStatus,
]);
}
// ═══════════════════════════════════════════════════════════════════════
// EXTERNAL INTEGRATION (SistemiG.agile - Future)
// ═══════════════════════════════════════════════════════════════════════
/**
* POST /api/ncr/{id}/sync
* Sincronizza NCR con sistema esterno (SistemiG.agile)
*/
public function syncExternal(int $id): void
{
$this->requireOrgRole(['org_admin', 'compliance_manager']);
$ncr = Database::fetchOne(
'SELECT * FROM non_conformities WHERE id = ? AND organization_id = ?',
[$id, $this->getCurrentOrgId()]
);
if (!$ncr) {
$this->jsonError('Non conformita\' non trovata', 404, 'NCR_NOT_FOUND');
}
$targetUrl = defined('SISTEMIG_API_URL') ? SISTEMIG_API_URL : '';
if (empty($targetUrl)) {
$this->jsonError('Integrazione SistemiG non configurata', 501, 'INTEGRATION_NOT_CONFIGURED');
}
// Placeholder: in future this will POST to SistemiG API
$this->jsonSuccess([
'ncr_id' => $id,
'ncr_code' => $ncr['ncr_code'],
'sync_status' => 'not_implemented',
'message' => 'Integrazione SistemiG.agile in fase di sviluppo',
]);
}
/**
* POST /api/ncr/webhook
* Webhook per ricezione aggiornamenti da sistema esterno
* Autenticato via API key (X-SistemiG-Key header), non JWT
*/
public function webhook(): void
{
// Authenticate via API key instead of JWT
$apiKey = $_SERVER['HTTP_X_SISTEMIG_KEY'] ?? '';
$expectedKey = defined('SISTEMIG_API_KEY') ? SISTEMIG_API_KEY : '';
if (empty($expectedKey) || $apiKey !== $expectedKey) {
$this->jsonError('Chiave API non valida', 401, 'INVALID_API_KEY');
}
// Placeholder: process incoming webhook payload
$payload = $this->getJsonBody();
$this->jsonSuccess([
'received' => true,
'message' => 'Webhook ricevuto. Integrazione SistemiG.agile in fase di sviluppo.',
'payload_keys' => array_keys($payload),
]);
}
}

View File

@ -170,8 +170,12 @@ class OnboardingController extends BaseController
(float) ($orgData['annual_turnover_eur'] ?? 0)
);
// Handle voluntary compliance
$voluntaryCompliance = ($entityType === 'not_applicable' && (int) $this->getParam('voluntary_compliance', 0)) ? 1 : 0;
Database::update('organizations', [
'entity_type' => $entityType,
'voluntary_compliance' => $voluntaryCompliance,
], 'id = ?', [$orgId]);
// Link user as org_admin
@ -210,6 +214,7 @@ class OnboardingController extends BaseController
'organization_id' => $orgId,
'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)),
], 'Onboarding completato', 201);

View File

@ -154,7 +154,7 @@ class OrganizationController extends BaseController
$allowedFields = [
'name', 'vat_number', 'fiscal_code', 'sector', 'employee_count',
'annual_turnover_eur', 'country', 'city', 'address', 'website',
'contact_email', 'contact_phone',
'contact_email', 'contact_phone', 'voluntary_compliance',
];
foreach ($allowedFields as $field) {
@ -170,11 +170,25 @@ class OrganizationController extends BaseController
// Ri-classifica se cambiano settore, dipendenti o fatturato
if (isset($updates['sector']) || isset($updates['employee_count']) || isset($updates['annual_turnover_eur'])) {
$org = Database::fetchOne('SELECT * FROM organizations WHERE id = ?', [$id]);
$updates['entity_type'] = $this->classifyNis2Entity(
$newEntityType = $this->classifyNis2Entity(
$updates['sector'] ?? $org['sector'],
(int) ($updates['employee_count'] ?? $org['employee_count']),
(float) ($updates['annual_turnover_eur'] ?? $org['annual_turnover_eur'])
);
$updates['entity_type'] = $newEntityType;
// Reset voluntary compliance if no longer not_applicable
if ($newEntityType !== 'not_applicable') {
$updates['voluntary_compliance'] = 0;
}
}
// Ensure voluntary_compliance is only set when entity_type is not_applicable
if (isset($updates['voluntary_compliance'])) {
$currentOrg = $org ?? Database::fetchOne('SELECT entity_type FROM organizations WHERE id = ?', [$id]);
$currentType = $updates['entity_type'] ?? $currentOrg['entity_type'];
if ($currentType !== 'not_applicable') {
$updates['voluntary_compliance'] = 0;
}
}
Database::update('organizations', $updates, 'id = ?', [$id]);
@ -306,6 +320,7 @@ class OrganizationController extends BaseController
'sector' => $sector,
'employee_count' => $employees,
'annual_turnover_eur' => $turnover,
'allows_voluntary' => $entityType === 'not_applicable',
'explanation' => $this->getClassificationExplanation($entityType, $sector, $employees, $turnover),
]);
}

View File

@ -62,6 +62,8 @@ services:
- nis2-db-data:/var/lib/mysql
- ../docs/sql/001_initial_schema.sql:/docker-entrypoint-initdb.d/001_initial_schema.sql:ro
- ../docs/sql/002_email_log.sql:/docker-entrypoint-initdb.d/002_email_log.sql:ro
- ../docs/sql/003_voluntary_compliance.sql:/docker-entrypoint-initdb.d/003_voluntary_compliance.sql:ro
- ../docs/sql/004_ncr_capa.sql:/docker-entrypoint-initdb.d/004_ncr_capa.sql:ro
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${DB_ROOT_PASSWORD:-rootpass}"]
interval: 10s

View File

@ -0,0 +1,7 @@
-- ═══════════════════════════════════════════════════════════════════
-- NIS2 Agile - Migration 003: Voluntary Compliance
-- Aggiunge colonna per adesione volontaria NIS2
-- ═══════════════════════════════════════════════════════════════════
ALTER TABLE organizations
ADD COLUMN voluntary_compliance TINYINT(1) NOT NULL DEFAULT 0 AFTER entity_type;

94
docs/sql/004_ncr_capa.sql Normal file
View File

@ -0,0 +1,94 @@
-- ═══════════════════════════════════════════════════════════════════
-- NIS2 Agile - Migration 004: Non-Conformity & CAPA
-- Tabelle per gestione non conformità e azioni correttive
-- Predisposizione integrazione SistemiG.agile
-- ═══════════════════════════════════════════════════════════════════
-- Non-Conformity Reports (NCR)
CREATE TABLE IF NOT EXISTS non_conformities (
id INT AUTO_INCREMENT PRIMARY KEY,
organization_id INT NOT NULL,
ncr_code VARCHAR(20) NOT NULL,
title VARCHAR(255) NOT NULL,
description TEXT,
source ENUM(
'assessment', 'audit', 'incident', 'supplier_review',
'management_review', 'external_audit', 'other'
) NOT NULL DEFAULT 'assessment',
-- Link to source entity
source_entity_type VARCHAR(50),
source_entity_id INT,
-- Classification
severity ENUM('minor', 'major', 'critical', 'observation') NOT NULL DEFAULT 'minor',
category VARCHAR(100),
nis2_article VARCHAR(20),
-- Lifecycle
status ENUM(
'open', 'investigating', 'action_planned',
'correcting', 'verifying', 'closed', 'cancelled'
) NOT NULL DEFAULT 'open',
-- Ownership
identified_by INT,
assigned_to INT,
-- Dates
identified_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
target_close_date DATE,
actual_close_date DATE,
-- Root Cause Analysis
root_cause_analysis TEXT,
root_cause_method ENUM('five_whys', 'fishbone', 'fta', 'other'),
-- External integration (SistemiG.agile)
external_system VARCHAR(50),
external_id VARCHAR(100),
external_synced_at DATETIME,
-- Metadata
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE,
FOREIGN KEY (identified_by) REFERENCES users(id) ON DELETE SET NULL,
FOREIGN KEY (assigned_to) REFERENCES users(id) ON DELETE SET NULL,
INDEX idx_ncr_org (organization_id),
INDEX idx_ncr_status (status),
INDEX idx_ncr_severity (severity),
INDEX idx_ncr_source (source_entity_type, source_entity_id),
INDEX idx_ncr_external (external_system, external_id),
UNIQUE KEY uk_org_ncr_code (organization_id, ncr_code)
) ENGINE=InnoDB;
-- Corrective and Preventive Actions (CAPA)
CREATE TABLE IF NOT EXISTS capa_actions (
id INT AUTO_INCREMENT PRIMARY KEY,
ncr_id INT NOT NULL,
organization_id INT NOT NULL,
capa_code VARCHAR(20) NOT NULL,
action_type ENUM('corrective', 'preventive', 'containment') NOT NULL DEFAULT 'corrective',
title VARCHAR(255) NOT NULL,
description TEXT,
-- Lifecycle
status ENUM('planned', 'in_progress', 'completed', 'verified', 'ineffective') NOT NULL DEFAULT 'planned',
-- Ownership & Dates
responsible_user_id INT,
due_date DATE,
completion_date DATE,
verification_date DATE,
verified_by INT,
-- Effectiveness
effectiveness_review TEXT,
is_effective TINYINT(1),
-- External integration (SistemiG.agile)
external_system VARCHAR(50),
external_id VARCHAR(100),
-- Metadata
notes TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (ncr_id) REFERENCES non_conformities(id) ON DELETE CASCADE,
FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE,
FOREIGN KEY (responsible_user_id) REFERENCES users(id) ON DELETE SET NULL,
FOREIGN KEY (verified_by) REFERENCES users(id) ON DELETE SET NULL,
INDEX idx_capa_ncr (ncr_id),
INDEX idx_capa_org (organization_id),
INDEX idx_capa_status (status),
INDEX idx_capa_due (due_date),
UNIQUE KEY uk_org_capa_code (organization_id, capa_code)
) ENGINE=InnoDB;

View File

@ -99,6 +99,25 @@
</div>
<div class="card-body" id="ai-analysis-content"></div>
</div>
<!-- Generate NCR from gaps -->
<div class="card mb-24" id="ncr-generation-card">
<div class="card-header">
<h3>Non Conformita'</h3>
</div>
<div class="card-body" style="text-align:center; padding:32px;">
<p style="color:var(--gray-600); margin-bottom:16px; font-size:0.875rem;">
Genera automaticamente le non conformita' (NCR) dai gap identificati nell'assessment.
I controlli con stato "Non Implementato" saranno classificati come <strong>major</strong>,
quelli "Parzialmente Implementato" come <strong>minor</strong>.
</p>
<button class="btn btn-primary" id="btn-generate-ncr" onclick="generateNCRs()">
<svg viewBox="0 0 20 20" fill="currentColor" width="16" height="16"><path fill-rule="evenodd" d="M6 2a2 2 0 00-2 2v12a2 2 0 002 2h8a2 2 0 002-2V7.414A2 2 0 0015.414 6L12 2.586A2 2 0 0010.586 2H6zm5 6a1 1 0 10-2 0v2H7a1 1 0 100 2h2v2a1 1 0 102 0v-2h2a1 1 0 100-2h-2V8z" clip-rule="evenodd"/></svg>
Genera Non Conformita' dai Gap
</button>
<div id="ncr-generation-result" style="display:none; margin-top:16px;"></div>
</div>
</div>
</div>
</div>
</main>
@ -590,6 +609,54 @@
showNotification('Errore di connessione.', 'error');
}
}
// ── Generate NCRs from Assessment Gaps ─────────────────────
async function generateNCRs() {
if (!currentAssessmentId) {
showNotification('Nessun assessment selezionato.', 'warning');
return;
}
const btn = document.getElementById('btn-generate-ncr');
btn.disabled = true;
btn.innerHTML = '<div class="spinner" style="width:16px;height:16px;border-width:2px;margin:0;"></div> Generazione in corso...';
try {
const result = await api.createNCRsFromAssessment(currentAssessmentId);
const resultDiv = document.getElementById('ncr-generation-result');
resultDiv.style.display = 'block';
if (result.success && result.data) {
const total = result.data.total || 0;
if (total > 0) {
const majorCount = (result.data.created || []).filter(n => n.severity === 'major').length;
const minorCount = total - majorCount;
resultDiv.innerHTML = `
<div style="background:var(--secondary-bg); border:1px solid #bbf7d0; border-radius:var(--border-radius); padding:16px; text-align:left;">
<strong style="color:#15803d;">${total} non conformita' generate con successo</strong>
<div style="margin-top:8px; font-size:0.8125rem; color:var(--gray-600);">
${majorCount > 0 ? `<span style="color:var(--danger); font-weight:600;">${majorCount} major</span> ` : ''}
${minorCount > 0 ? `<span style="color:var(--warning); font-weight:600;">${minorCount} minor</span>` : ''}
</div>
</div>`;
showNotification(`${total} non conformita' generate.`, 'success');
} else {
resultDiv.innerHTML = '<p style="color:var(--gray-500);">Nessun gap trovato — tutti i controlli sono implementati.</p>';
showNotification('Nessun gap trovato.', 'info');
}
btn.style.display = 'none';
} else {
resultDiv.innerHTML = `<p style="color:var(--danger);">${escapeHtml(result.message || 'Errore nella generazione.')}</p>`;
showNotification(result.message || 'Errore.', 'error');
btn.disabled = false;
btn.innerHTML = '<svg viewBox="0 0 20 20" fill="currentColor" width="16" height="16"><path fill-rule="evenodd" d="M6 2a2 2 0 00-2 2v12a2 2 0 002 2h8a2 2 0 002-2V7.414A2 2 0 0015.414 6L12 2.586A2 2 0 0010.586 2H6zm5 6a1 1 0 10-2 0v2H7a1 1 0 100 2h2v2a1 1 0 102 0v-2h2a1 1 0 100-2h-2V8z" clip-rule="evenodd"/></svg> Genera Non Conformita\' dai Gap';
}
} catch (e) {
showNotification('Errore di connessione.', 'error');
btn.disabled = false;
btn.innerHTML = '<svg viewBox="0 0 20 20" fill="currentColor" width="16" height="16"><path fill-rule="evenodd" d="M6 2a2 2 0 00-2 2v12a2 2 0 002 2h8a2 2 0 002-2V7.414A2 2 0 0015.414 6L12 2.586A2 2 0 0010.586 2H6zm5 6a1 1 0 10-2 0v2H7a1 1 0 100 2h2v2a1 1 0 102 0v-2h2a1 1 0 100-2h-2V8z" clip-rule="evenodd"/></svg> Genera Non Conformita\' dai Gap';
}
}
</script>
</body>
</html>

View File

@ -98,6 +98,7 @@ $controllerMap = [
'audit' => 'AuditController',
'admin' => 'AdminController',
'onboarding' => 'OnboardingController',
'ncr' => 'NonConformityController',
];
if (!isset($controllerMap[$controllerName])) {
@ -275,6 +276,20 @@ $actionMap = [
'POST:fetchCompany' => 'fetchCompany',
'POST:complete' => 'complete',
],
// ── NonConformityController (NCR & CAPA) ───────
'ncr' => [
'POST:create' => 'create',
'GET:list' => 'list',
'GET:{id}' => 'get',
'PUT:{id}' => 'update',
'POST:{id}/capa' => 'addCapa',
'PUT:capa/{subId}' => 'updateCapa',
'POST:fromAssessment' => 'fromAssessment',
'GET:stats' => 'stats',
'POST:{id}/sync' => 'syncExternal',
'POST:webhook' => 'webhook',
],
];
// ═══════════════════════════════════════════════════════════════════════════

View File

@ -264,6 +264,19 @@ class NIS2API {
fetchCompany(vatNumber) { return this.post('/onboarding/fetch-company', { vat_number: vatNumber }); }
completeOnboarding(data) { return this.post('/onboarding/complete', data); }
// ═══════════════════════════════════════════════════════════════════
// Non-Conformity & CAPA
// ═══════════════════════════════════════════════════════════════════
listNCRs(params = {}) { return this.get('/ncr/list?' + new URLSearchParams(params)); }
createNCR(data) { return this.post('/ncr/create', data); }
getNCR(id) { return this.get(`/ncr/${id}`); }
updateNCR(id, data) { return this.put(`/ncr/${id}`, data); }
addCapa(ncrId, data) { return this.post(`/ncr/${ncrId}/capa`, data); }
updateCapa(capaId, data) { return this.put(`/ncr/capa/${capaId}`, data); }
createNCRsFromAssessment(assessmentId) { return this.post('/ncr/from-assessment', { assessment_id: assessmentId }); }
getNCRStats() { return this.get('/ncr/stats'); }
}
// Singleton globale

View File

@ -598,6 +598,91 @@
gap: 12px;
}
/* ── Auto-fill indicators ──────────────────────────────────── */
.auto-filled .form-input,
.auto-filled .form-select {
border-color: #86efac;
background: #f0fdf4;
}
.auto-fill-badge {
display: inline-flex;
align-items: center;
padding: 2px 8px;
margin-left: 8px;
font-size: 0.65rem;
font-weight: 600;
color: #15803d;
background: #dcfce7;
border-radius: 100px;
text-transform: uppercase;
letter-spacing: 0.03em;
vertical-align: middle;
}
/* ── Voluntary Compliance ──────────────────────────────────── */
.voluntary-section {
display: none;
margin-bottom: 24px;
}
.voluntary-section.visible {
display: block;
}
.voluntary-card {
border: 2px solid var(--primary-light);
border-radius: var(--border-radius-lg);
padding: 24px;
display: flex;
align-items: flex-start;
gap: 16px;
background: var(--card-bg);
transition: all var(--transition);
}
.voluntary-card.checked {
border-color: var(--primary);
background: var(--primary-bg);
}
.voluntary-card input[type="checkbox"] {
width: 20px;
height: 20px;
cursor: pointer;
accent-color: var(--primary);
flex-shrink: 0;
margin-top: 2px;
}
.voluntary-card h4 {
font-size: 0.9375rem;
font-weight: 700;
color: var(--gray-900);
margin-bottom: 6px;
}
.voluntary-card p {
font-size: 0.8125rem;
color: var(--gray-600);
line-height: 1.6;
margin: 0;
}
.classification-result.voluntary {
border-color: var(--primary);
background: linear-gradient(135deg, #eff6ff, #dbeafe);
}
.classification-result.voluntary .classification-result-icon {
background: var(--primary);
color: #fff;
}
.classification-result.voluntary .classification-result-label {
color: var(--primary);
}
/* ── Responsive ─────────────────────────────────────────────── */
@media (max-width: 768px) {
.wizard-header {
@ -1040,6 +1125,22 @@
<div class="classification-result-desc" id="classification-desc"></div>
</div>
<!-- Voluntary Compliance Option -->
<div class="voluntary-section" id="voluntary-section">
<div class="voluntary-card" id="voluntary-card">
<input type="checkbox" id="voluntary-checkbox" onchange="onVoluntaryChange(this.checked)">
<div>
<h4>Adesione Volontaria alla NIS2</h4>
<p>
Anche se la tua organizzazione non rientra nell'ambito di applicazione obbligatorio della
Direttiva NIS2, puoi scegliere di aderire volontariamente adottando le misure di sicurezza
previste dalla normativa. Questo consente di migliorare la postura di cybersicurezza e di
prepararsi a possibili future designazioni.
</p>
</div>
</div>
</div>
<!-- Summary -->
<div class="summary-card">
<div class="summary-card-header">Riepilogo Dati Aziendali</div>
@ -1142,9 +1243,22 @@
role: '',
phone: ''
},
classification: null
classification: null,
autoFilledFields: new Set(),
voluntaryCompliance: false
};
// ── Helper: parse employees range string to number ────────
function parseEmployeesRange(range) {
if (!range) return '';
if (typeof range === 'number') return range;
const str = String(range).trim();
if (str.endsWith('+')) return parseInt(str);
const parts = str.split('-');
if (parts.length === 2) return parseInt(parts[0]);
return parseInt(str) || '';
}
// ── Step 1: Method selection ────────────────────────────────────
function selectMethod(method) {
wizardState.method = method;
@ -1308,11 +1422,24 @@
wizardState.company.website = d.website || d.sito_web || '';
wizardState.company.email = d.email || d.pec || '';
wizardState.company.phone = d.phone || d.telefono || '';
wizardState.company.sector = d.sector || '';
wizardState.company.employee_count = d.employee_count || d.employees || '';
wizardState.company.annual_turnover = d.annual_turnover || d.turnover || '';
wizardState.company.sector = d.suggested_sector || d.sector || '';
wizardState.company.employee_count = parseEmployeesRange(d.employees_range) || d.employee_count || d.employees || '';
// Fatturato resta SEMPRE manuale — non auto-compilare dalla visura
wizardState.company.annual_turnover = '';
wizardState.dataSource = 'Dati precompilati dalla visura camerale';
// Track auto-filled fields
wizardState.autoFilledFields.clear();
if (wizardState.company.name) wizardState.autoFilledFields.add('company-name');
if (wizardState.company.vat_number) wizardState.autoFilledFields.add('vat-number');
if (wizardState.company.fiscal_code) wizardState.autoFilledFields.add('fiscal-code');
if (wizardState.company.address) wizardState.autoFilledFields.add('address');
if (wizardState.company.city) wizardState.autoFilledFields.add('city');
if (wizardState.company.email) wizardState.autoFilledFields.add('company-email');
if (wizardState.company.phone) wizardState.autoFilledFields.add('company-phone');
if (wizardState.company.sector) wizardState.autoFilledFields.add('sector');
if (wizardState.company.employee_count) wizardState.autoFilledFields.add('employee-count');
showNotification('Visura analizzata con successo!', 'success');
// Auto-advance to step 3
@ -1361,11 +1488,24 @@
wizardState.company.website = d.website || d.sito_web || '';
wizardState.company.email = d.email || d.pec || '';
wizardState.company.phone = d.phone || d.telefono || '';
wizardState.company.sector = d.sector || '';
wizardState.company.employee_count = d.employee_count || d.employees || '';
wizardState.company.annual_turnover = d.annual_turnover || d.turnover || '';
wizardState.company.sector = d.suggested_sector || d.sector || '';
wizardState.company.employee_count = parseEmployeesRange(d.employees_range) || d.employee_count || d.employees || '';
// Fatturato resta SEMPRE manuale
wizardState.company.annual_turnover = '';
wizardState.dataSource = 'Dati recuperati da CertiSource';
// Track auto-filled fields
wizardState.autoFilledFields.clear();
if (wizardState.company.name) wizardState.autoFilledFields.add('company-name');
if (wizardState.company.vat_number) wizardState.autoFilledFields.add('vat-number');
if (wizardState.company.fiscal_code) wizardState.autoFilledFields.add('fiscal-code');
if (wizardState.company.address) wizardState.autoFilledFields.add('address');
if (wizardState.company.city) wizardState.autoFilledFields.add('city');
if (wizardState.company.email) wizardState.autoFilledFields.add('company-email');
if (wizardState.company.phone) wizardState.autoFilledFields.add('company-phone');
if (wizardState.company.sector) wizardState.autoFilledFields.add('sector');
if (wizardState.company.employee_count) wizardState.autoFilledFields.add('employee-count');
showNotification('Dati aziendali recuperati con successo!', 'success');
// Auto-advance to step 3
@ -1413,6 +1553,27 @@
} else {
banner.classList.remove('visible');
}
// Apply auto-fill visual indicators
const autoFields = wizardState.autoFilledFields;
document.querySelectorAll('#step-3 .form-input, #step-3 .form-select').forEach(el => {
const wrapper = el.closest('.form-group');
if (!wrapper) return;
// Remove previous indicators
wrapper.classList.remove('auto-filled');
const existing = wrapper.querySelector('.auto-fill-badge');
if (existing) existing.remove();
if (autoFields.has(el.id)) {
wrapper.classList.add('auto-filled');
const badge = document.createElement('span');
badge.className = 'auto-fill-badge';
badge.textContent = 'da visura';
const label = wrapper.querySelector('.form-label');
if (label) label.appendChild(badge);
}
});
}
function submitStep3() {
@ -1553,6 +1714,21 @@
const result = classifyLocally(sector, employees, turnover);
wizardState.classification = result;
// Reset voluntary state when re-entering step 5
wizardState.voluntaryCompliance = false;
const voluntaryCheckbox = document.getElementById('voluntary-checkbox');
if (voluntaryCheckbox) voluntaryCheckbox.checked = false;
const voluntaryCard = document.getElementById('voluntary-card');
if (voluntaryCard) voluntaryCard.classList.remove('checked');
// Show/hide voluntary compliance section
const voluntarySection = document.getElementById('voluntary-section');
if (result.classification === 'not_applicable') {
voluntarySection.classList.add('visible');
} else {
voluntarySection.classList.remove('visible');
}
// Update classification UI
const container = document.getElementById('classification-result');
const labelEl = document.getElementById('classification-label');
@ -1589,6 +1765,32 @@
document.getElementById('sum-turnover').textContent = turnover ? '\u20AC ' + turnover.toLocaleString('it-IT') : '-';
}
// ── Voluntary Compliance Toggle ───────────────────────────────
function onVoluntaryChange(checked) {
wizardState.voluntaryCompliance = checked;
const card = document.getElementById('voluntary-card');
const container = document.getElementById('classification-result');
const labelEl = document.getElementById('classification-label');
const sumEntityType = document.getElementById('sum-entity-type');
card.classList.toggle('checked', checked);
if (checked) {
container.className = 'classification-result voluntary';
container.querySelector('.classification-result-icon').innerHTML = '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm0 2.18l7 3.12v4.7c0 4.83-3.23 9.36-7 10.57-3.77-1.21-7-5.74-7-10.57V6.3l7-3.12z"/><path d="M10 12.5l-2-2-1.41 1.41L10 15.32l5.41-5.41L14 8.5l-4 4z"/></svg>';
labelEl.textContent = 'Adesione Volontaria';
document.getElementById('classification-desc').textContent = 'La tua organizzazione aderisce volontariamente alla Direttiva NIS2, adottando le misure di sicurezza previste dalla normativa per migliorare la propria postura di cybersicurezza.';
sumEntityType.textContent = 'Adesione Volontaria';
} else {
container.className = 'classification-result not-applicable';
container.querySelector('.classification-result-icon').innerHTML = '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/></svg>';
labelEl.textContent = wizardState.classification.label;
document.getElementById('classification-desc').textContent = wizardState.classification.description;
sumEntityType.textContent = wizardState.classification.label;
}
}
// ── Complete Onboarding ─────────────────────────────────────────
async function completeOnboarding() {
const btn = document.getElementById('complete-btn');
@ -1612,6 +1814,8 @@
sector: c.sector,
employee_count: parseInt(c.employee_count) || 0,
annual_turnover_eur: parseInt(c.annual_turnover) || 0,
// Voluntary compliance
voluntary_compliance: wizardState.voluntaryCompliance ? 1 : 0,
// Profile data
full_name: p.full_name,
phone: p.phone,

View File

@ -79,6 +79,11 @@
color: var(--gray-600);
border: 1px solid var(--gray-300);
}
.entity-badge.voluntary {
background: var(--primary-bg);
color: var(--primary);
border: 1px solid var(--primary-light);
}
.plan-badge {
display: inline-flex;
align-items: center;
@ -573,10 +578,14 @@
function renderEntityClassification(org) {
const type = org.entity_type || 'not_applicable';
const label = entityTypeLabels[type] || 'Non Applicabile';
const isVoluntary = org.voluntary_compliance == 1 && type === 'not_applicable';
const displayLabel = isVoluntary ? 'Adesione Volontaria' : (entityTypeLabels[type] || 'Non Applicabile');
const badgeClass = isVoluntary ? 'voluntary' : type;
let description = '';
if (type === 'essential') {
if (isVoluntary) {
description = 'L\'organizzazione ha scelto di aderire <strong>volontariamente</strong> ai requisiti della Direttiva NIS2, pur non rientrando nell\'ambito di applicazione obbligatorio. Le misure di sicurezza sono adottate su base volontaria.';
} else if (type === 'essential') {
description = 'L\'organizzazione rientra tra le entita\' <strong>Essenziali</strong> ai sensi della Direttiva NIS2. Soggetta a supervisione proattiva con sanzioni fino a EUR 10M o 2% del fatturato globale.';
} else if (type === 'important') {
description = 'L\'organizzazione rientra tra le entita\' <strong>Importanti</strong> ai sensi della Direttiva NIS2. Soggetta a supervisione reattiva con sanzioni fino a EUR 7M o 1,4% del fatturato globale.';
@ -584,15 +593,46 @@
description = 'In base ai parametri attuali, l\'organizzazione <strong>non rientra</strong> nell\'ambito di applicazione della Direttiva NIS2. Si consiglia comunque di adottare le best practice di cybersecurity.';
}
// Voluntary toggle (only visible for not_applicable entities)
const voluntaryToggle = type === 'not_applicable' ? `
<div style="margin-top:16px; padding:16px; background:var(--gray-50); border-radius:var(--border-radius); border:1px solid var(--gray-200);">
<label style="display:flex; align-items:center; gap:10px; cursor:pointer;">
<input type="checkbox" id="org-voluntary" ${isVoluntary ? 'checked' : ''}
onchange="toggleVoluntaryCompliance(this.checked)"
style="accent-color:var(--primary); width:18px; height:18px; flex-shrink:0;">
<span style="font-size:0.875rem; font-weight:600; color:var(--gray-700);">Adesione Volontaria alla NIS2</span>
</label>
<p style="font-size:0.75rem; color:var(--gray-500); margin-top:6px; margin-left:28px;">
Aderisci volontariamente ai requisiti NIS2 anche se non obbligato dalla normativa.
</p>
</div>` : '';
document.getElementById('entity-classification').innerHTML = `
<div class="entity-badge ${type}">
<div class="entity-badge ${badgeClass}">
<svg viewBox="0 0 20 20" fill="currentColor" width="20" height="20"><path fill-rule="evenodd" d="M10 1.944A11.954 11.954 0 012.166 5C2.056 5.649 2 6.319 2 7c0 5.225 3.34 9.67 8 11.317C14.66 16.67 18 12.225 18 7c0-.682-.057-1.35-.166-2.001A11.954 11.954 0 0110 1.944zM11 14a1 1 0 11-2 0 1 1 0 012 0zm0-7a1 1 0 10-2 0v3a1 1 0 102 0V7z" clip-rule="evenodd"/></svg>
Entita' ${label}
Entita': ${displayLabel}
</div>
<p style="font-size:0.8125rem; color:var(--gray-600); line-height:1.6; max-width:640px;">${description}</p>
${voluntaryToggle}
`;
}
async function toggleVoluntaryCompliance(checked) {
if (!currentOrg) return;
try {
const result = await api.updateOrganization(currentOrg.id, { voluntary_compliance: checked ? 1 : 0 });
if (result.success) {
currentOrg.voluntary_compliance = checked ? 1 : 0;
renderEntityClassification(currentOrg);
showNotification(checked ? 'Adesione volontaria attivata.' : 'Adesione volontaria disattivata.', 'success');
} else {
showNotification(result.message || 'Errore.', 'error');
}
} catch (e) {
showNotification('Errore di connessione.', 'error');
}
}
function renderSubscriptionPlan(org) {
const plan = org.subscription_plan || 'free';
const planLabels = { free: 'Free', professional: 'Professional', enterprise: 'Enterprise' };