diff --git a/application/controllers/NonConformityController.php b/application/controllers/NonConformityController.php new file mode 100644 index 0000000..085b213 --- /dev/null +++ b/application/controllers/NonConformityController.php @@ -0,0 +1,502 @@ +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), + ]); + } +} diff --git a/application/controllers/OnboardingController.php b/application/controllers/OnboardingController.php index 4e2e8b5..9b5cb02 100644 --- a/application/controllers/OnboardingController.php +++ b/application/controllers/OnboardingController.php @@ -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 @@ -207,10 +211,11 @@ class OnboardingController extends BaseController ]); $this->jsonSuccess([ - 'organization_id' => $orgId, - 'name' => $orgData['name'], - 'entity_type' => $entityType, - 'classification' => $this->getClassificationDetails($entityType, $orgData['sector'], (int)($orgData['employee_count'] ?? 0), (float)($orgData['annual_turnover_eur'] ?? 0)), + '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); } catch (Throwable $e) { diff --git a/application/controllers/OrganizationController.php b/application/controllers/OrganizationController.php index 6b1ea89..67beaba 100644 --- a/application/controllers/OrganizationController.php +++ b/application/controllers/OrganizationController.php @@ -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]); @@ -302,11 +316,12 @@ class OrganizationController extends BaseController $entityType = $this->classifyNis2Entity($sector, $employees, $turnover); $this->jsonSuccess([ - 'entity_type' => $entityType, - 'sector' => $sector, - 'employee_count' => $employees, + 'entity_type' => $entityType, + 'sector' => $sector, + 'employee_count' => $employees, 'annual_turnover_eur' => $turnover, - 'explanation' => $this->getClassificationExplanation($entityType, $sector, $employees, $turnover), + 'allows_voluntary' => $entityType === 'not_applicable', + 'explanation' => $this->getClassificationExplanation($entityType, $sector, $employees, $turnover), ]); } diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index d66aca5..c06e14e 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -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 diff --git a/docs/sql/003_voluntary_compliance.sql b/docs/sql/003_voluntary_compliance.sql new file mode 100644 index 0000000..c238069 --- /dev/null +++ b/docs/sql/003_voluntary_compliance.sql @@ -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; diff --git a/docs/sql/004_ncr_capa.sql b/docs/sql/004_ncr_capa.sql new file mode 100644 index 0000000..15c5e77 --- /dev/null +++ b/docs/sql/004_ncr_capa.sql @@ -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; diff --git a/public/assessment.html b/public/assessment.html index 3e483eb..30c671e 100644 --- a/public/assessment.html +++ b/public/assessment.html @@ -99,6 +99,25 @@
+ + ++ Genera automaticamente le non conformita' (NCR) dai gap identificati nell'assessment. + I controlli con stato "Non Implementato" saranno classificati come major, + quelli "Parzialmente Implementato" come minor. +
+ + +Nessun gap trovato — tutti i controlli sono implementati.
'; + showNotification('Nessun gap trovato.', 'info'); + } + btn.style.display = 'none'; + } else { + resultDiv.innerHTML = `${escapeHtml(result.message || 'Errore nella generazione.')}
`; + showNotification(result.message || 'Errore.', 'error'); + btn.disabled = false; + btn.innerHTML = ' Genera Non Conformita\' dai Gap'; + } + } catch (e) { + showNotification('Errore di connessione.', 'error'); + btn.disabled = false; + btn.innerHTML = ' Genera Non Conformita\' dai Gap'; + } + }