From 79ca72fba93e2b4f8f336f2260cc57cd015d81a2 Mon Sep 17 00:00:00 2001 From: DevEnv nis2-agile Date: Wed, 17 Jun 2026 07:48:49 +0200 Subject: [PATCH] =?UTF-8?q?[FEAT]=20ISO-readiness:=20Audit=20interni=20(?= =?UTF-8?q?=C2=A79.2)=20+=20Riesame=20Direzione=20(=C2=A79.3)=20+=20Calend?= =?UTF-8?q?ario=20scadenze=20(mig.055-056,=20v1.22.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rilascio unico (3 moduli) costruito in flotta parallela + verifica avversariale. MODULO A — Audit interni (ISO 27001 §9.2, mig.055): internal_audits + internal_audit_items; codice AUD-NNN; checklist pre-popolata (clausole 4-10 + Annex A applicabili dal SoA); esiti per riga; apertura non conformità collegata a NCR/CAPA (source polimorfico); report HTML stampabile. InternalAuditController + internal-audits.html. MODULO B — Riesame di Direzione (ISO 27001 §9.3, mig.056): management_reviews + management_review_decisions; codice RD-AAAA-NN; INPUT aggregati automaticamente dai moduli (gather: NC/CAPA, audit interni, rischi+trattamenti, KPI/score, obiettivi, formazione, stakeholder, normative, scadenze), congelati nello snapshot all'approvazione; decisioni; verbale stampabile. ManagementReviewController + management-review.html. MODULO C — Calendario unico scadenze: aggregatore SOLA LETTURA (nessuna migrazione) di tutte le scadenze (incidenti/policy/rischi/NC-CAPA/formazione/stakeholder/audit/riesame/review_schedule), griglia mensile + lista + filtri + deep-link. CalendarController + calendario.html. Integrazione: router (3 controller+actionMap), api.js, sidebar V2+legacy, help.js (3 sezioni), i18n (IT+EN), review_schedule ENUM += internal_audit. v1.22.0 + sw cache + cache-buster 20260630. Verifica flotta (28 agenti, 4 dim + avversariale): 9 finding confermati, TUTTI corretti — MAJOR gatherRisks (risk_treatments.organization_id inesistente → JOIN risks); nextCode numerico (no dup >99/anno); footer report audit con etichetta "ISO buona prassi, non obbligo"; help 24→25 clausole; ARIA tab/calendario; focus modali; tasti su celle calendario; rimossi helper api morti. Smoke prod OK (calendario 18 eventi, audit AUD-001 25 item + report + audit→NCR + audit→calendario, riesame gather/decisione/approve/report, gatherRisks ora available); org 151 ripulita. Additivo. Co-Authored-By: Claude Opus 4.8 (1M context) --- application/cli/seed_internal_audits.php | 87 ++ application/cli/seed_management_reviews.php | 77 ++ .../controllers/CalendarController.php | 470 ++++++++++ .../controllers/InternalAuditController.php | 633 +++++++++++++ .../ManagementReviewController.php | 835 ++++++++++++++++++ .../controllers/ReviewScheduleController.php | 2 +- docs/sql/055_internal_audits.sql | 50 ++ docs/sql/056_management_reviews.sql | 52 ++ public/_app-bi-demo.html | 6 +- public/architecture.html | 8 +- public/assessment.html | 10 +- public/assets.html | 10 +- public/calendario.html | 403 +++++++++ public/companies.html | 10 +- public/competenze.html | 10 +- public/cross-analysis.html | 8 +- public/dashboard.html | 10 +- public/forgot-password.html | 2 +- public/guida.html | 10 +- public/incidents.html | 10 +- public/index.php | 35 + public/integrazioniext.html | 4 +- public/internal-audits.html | 390 ++++++++ public/isms.html | 10 +- public/js/api.js | 38 + public/js/common-bi.js | 3 + public/js/common.js | 3 + public/js/help.js | 120 +++ public/js/i18n.js | 6 + public/kb.html | 10 +- public/licenseExt.html | 2 +- public/login.html | 4 +- public/management-review.html | 424 +++++++++ public/misure-requisiti.html | 12 +- public/mktg-api-doc.html | 2 +- public/normative.html | 10 +- public/onboarding.html | 4 +- public/organigramma.html | 10 +- public/policies.html | 10 +- public/raci.html | 10 +- public/register.html | 4 +- public/reports.html | 10 +- public/review-schedule.html | 10 +- public/risks.html | 10 +- public/settings.html | 10 +- public/setup-org.html | 4 +- public/stakeholder-activities.html | 12 +- public/stakeholders.html | 12 +- public/supply-chain.html | 10 +- public/sw.js | 2 +- public/training.html | 10 +- public/version.json | 2 +- public/whistleblowing.html | 10 +- public/workflow.html | 6 +- 54 files changed, 3774 insertions(+), 148 deletions(-) create mode 100644 application/cli/seed_internal_audits.php create mode 100644 application/cli/seed_management_reviews.php create mode 100644 application/controllers/CalendarController.php create mode 100644 application/controllers/InternalAuditController.php create mode 100644 application/controllers/ManagementReviewController.php create mode 100644 docs/sql/055_internal_audits.sql create mode 100644 docs/sql/056_management_reviews.sql create mode 100644 public/calendario.html create mode 100644 public/internal-audits.html create mode 100644 public/management-review.html diff --git a/application/cli/seed_internal_audits.php b/application/cli/seed_internal_audits.php new file mode 100644 index 0000000..17b9f9f --- /dev/null +++ b/application/cli/seed_internal_audits.php @@ -0,0 +1,87 @@ +exec("SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci"); + +$ddl = [ +"CREATE TABLE IF NOT EXISTS internal_audits ( + id INT NOT NULL AUTO_INCREMENT, organization_id INT NOT NULL, + code VARCHAR(20) NULL, title VARCHAR(255) NOT NULL, + scope TEXT NULL, criteria TEXT NULL, + planned_date DATE NULL, executed_date DATE NULL, + status ENUM('planned','in_progress','completed','cancelled') NOT NULL DEFAULT 'planned', + lead_auditor_user_id INT NULL, lead_auditor_role_id INT NULL, + conclusion TEXT NULL, created_by INT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), KEY idx_intaud_org (organization_id), + CONSTRAINT fk_intaud_org FOREIGN KEY (organization_id) REFERENCES organizations (id) ON DELETE CASCADE, + CONSTRAINT fk_intaud_lead_user FOREIGN KEY (lead_auditor_user_id) REFERENCES users (id) ON DELETE SET NULL, + CONSTRAINT fk_intaud_lead_role FOREIGN KEY (lead_auditor_role_id) REFERENCES org_roles (id) ON DELETE SET NULL, + CONSTRAINT fk_intaud_creator FOREIGN KEY (created_by) REFERENCES users (id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", + +"CREATE TABLE IF NOT EXISTS internal_audit_items ( + id INT NOT NULL AUTO_INCREMENT, audit_id INT NOT NULL, + ref_type ENUM('clause','annex_control','nis2_measure','custom') NOT NULL DEFAULT 'clause', + ref_code VARCHAR(32) NULL, checkpoint TEXT NOT NULL, + result ENUM('da_verificare','conforme','non_conforme','osservazione','opportunita','non_applicabile') NOT NULL DEFAULT 'da_verificare', + note TEXT NULL, ord INT NOT NULL DEFAULT 0, + PRIMARY KEY (id), KEY idx_intauditem_audit (audit_id), + CONSTRAINT fk_intauditem_audit FOREIGN KEY (audit_id) REFERENCES internal_audits (id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", +]; + +foreach ($ddl as $stmt) { + try { $pdo->exec($stmt); } + catch (PDOException $e) { if (!in_array($e->errorInfo[1] ?? 0, [1050, 1061], true)) { throw $e; } } +} + +// Estende l'ENUM del calendario (review_schedule) con 'internal_audit' così la +// data pianificata dell'audit compare nel calendario NIS2. Idempotente in effetto. +// Include i valori già introdotti da seeder precedenti (stakeholder_activity) per +// non regredirli; se 'internal_audit' è già presente l'ALTER è un no-op. +try { + $cur = (string) $pdo->query("SELECT COLUMN_TYPE FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'review_schedule' AND COLUMN_NAME = 'entity_type'")->fetchColumn(); + if (!str_contains($cur, "'internal_audit'")) { + $vals = ['role','skill','inventory','procedure','risk','supplier','measure','custom','stakeholder_activity','internal_audit']; + // conserva eventuali valori extra già presenti nell'ENUM corrente + if (preg_match_all("/'([^']+)'/", $cur, $m)) { + foreach ($m[1] as $v) { if (!in_array($v, $vals, true)) { $vals[] = $v; } } + } + $enum = "'" . implode("','", $vals) . "'"; + $pdo->exec("ALTER TABLE review_schedule MODIFY COLUMN entity_type ENUM($enum) NOT NULL"); + } +} catch (PDOException $e) { + fwrite(STDERR, "WARN ALTER review_schedule: " . $e->getMessage() . "\n"); +} + +$counts = []; +foreach (['internal_audits', 'internal_audit_items'] as $t) { + $counts[$t] = (int) $pdo->query("SELECT COUNT(*) FROM $t")->fetchColumn(); +} +$enum = $pdo->query("SELECT COLUMN_TYPE FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'review_schedule' AND COLUMN_NAME = 'entity_type'")->fetchColumn(); +echo "OK seed-internal-audits — " . json_encode($counts, JSON_UNESCAPED_UNICODE) . "\n"; +echo "review_schedule.entity_type has internal_audit: " . (str_contains((string) $enum, 'internal_audit') ? 'YES' : 'NO') . "\n"; diff --git a/application/cli/seed_management_reviews.php b/application/cli/seed_management_reviews.php new file mode 100644 index 0000000..54e3340 --- /dev/null +++ b/application/cli/seed_management_reviews.php @@ -0,0 +1,77 @@ +exec("SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci"); + +$ddl = [ +"CREATE TABLE IF NOT EXISTS management_reviews ( + id INT NOT NULL AUTO_INCREMENT, + organization_id INT NOT NULL, + code VARCHAR(20) NULL, + review_date DATE NULL, + period_label VARCHAR(100) NULL, + chair_user_id INT NULL, + attendees JSON NULL, + status ENUM('draft','approved') NOT NULL DEFAULT 'draft', + approved_by INT NULL, + approved_at DATETIME NULL, + snapshot JSON NULL, + conclusions TEXT NULL, + created_by INT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_mgr_org (organization_id), + CONSTRAINT fk_mgr_org FOREIGN KEY (organization_id) REFERENCES organizations (id) ON DELETE CASCADE, + CONSTRAINT fk_mgr_chair FOREIGN KEY (chair_user_id) REFERENCES users (id) ON DELETE SET NULL, + CONSTRAINT fk_mgr_approved FOREIGN KEY (approved_by) REFERENCES users (id) ON DELETE SET NULL, + CONSTRAINT fk_mgr_creator FOREIGN KEY (created_by) REFERENCES users (id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", + +"CREATE TABLE IF NOT EXISTS management_review_decisions ( + id INT NOT NULL AUTO_INCREMENT, + review_id INT NOT NULL, + decision TEXT NOT NULL, + owner_role_id INT NULL, + due_date DATE NULL, + status ENUM('open','in_progress','done') NOT NULL DEFAULT 'open', + capa_id INT NULL, + ord INT NOT NULL DEFAULT 0, + PRIMARY KEY (id), + KEY idx_mrd_review (review_id), + CONSTRAINT fk_mrd_review FOREIGN KEY (review_id) REFERENCES management_reviews (id) ON DELETE CASCADE, + CONSTRAINT fk_mrd_owner FOREIGN KEY (owner_role_id) REFERENCES org_roles (id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", +]; + +foreach ($ddl as $stmt) { + try { $pdo->exec($stmt); } + catch (PDOException $e) { if (!in_array($e->errorInfo[1] ?? 0, [1050, 1061], true)) { throw $e; } } +} + +$counts = []; +foreach (['management_reviews', 'management_review_decisions'] as $t) { + $counts[$t] = (int) $pdo->query("SELECT COUNT(*) FROM $t")->fetchColumn(); +} +echo "OK seed-management-reviews — " . json_encode($counts, JSON_UNESCAPED_UNICODE) . "\n"; diff --git a/application/controllers/CalendarController.php b/application/controllers/CalendarController.php new file mode 100644 index 0000000..d9377a1 --- /dev/null +++ b/application/controllers/CalendarController.php @@ -0,0 +1,470 @@ +requireOrgAccess(); + $orgId = $this->getCurrentOrgId(); + + [$from, $to] = $this->resolveRange(); + $typeFilter = $this->resolveTypeFilter(); + + $events = $this->collect($orgId, $from, $to); + + // Filtro per tipo richiesto (applicato in PHP, gli eventi sono pochi per org). + if ($typeFilter !== null) { + $events = array_values(array_filter($events, static fn($e) => in_array($e['type'], $typeFilter, true))); + } + + // Ordina: overdue prima, poi per data crescente. + usort($events, static function ($a, $b) { + $oa = $a['status'] === 'overdue' ? 0 : 1; + $ob = $b['status'] === 'overdue' ? 0 : 1; + if ($oa !== $ob) { return $oa <=> $ob; } + return strcmp($a['date'], $b['date']); + }); + + $this->jsonSuccess([ + 'from' => $from, + 'to' => $to, + 'today' => date('Y-m-d'), + 'due_soon_days'=> self::DUE_SOON_DAYS, + 'known_types' => self::KNOWN_TYPES, + 'count' => count($events), + 'events' => $events, + ]); + } + + // ───────────────────────────────────────────────────────────────────────── + // GET /api/calendar/summary — conteggi per stato (e per tipo) + // ───────────────────────────────────────────────────────────────────────── + public function summary(): void + { + $this->requireOrgAccess(); + $orgId = $this->getCurrentOrgId(); + + [$from, $to] = $this->resolveRange(); + $events = $this->collect($orgId, $from, $to); + + $byStatus = ['overdue' => 0, 'due_soon' => 0, 'upcoming' => 0, 'done' => 0]; + $byType = []; + foreach ($events as $e) { + $byStatus[$e['status']] = ($byStatus[$e['status']] ?? 0) + 1; + $byType[$e['type']] = ($byType[$e['type']] ?? 0) + 1; + } + // Aperte = tutte tranne done (utile come badge dashboard). + $open = $byStatus['overdue'] + $byStatus['due_soon'] + $byStatus['upcoming']; + + $this->jsonSuccess([ + 'from' => $from, + 'to' => $to, + 'today' => date('Y-m-d'), + 'total' => count($events), + 'open' => $open, + 'by_status' => $byStatus, + 'by_type' => $byType, + ]); + } + + // ───────────────────────────────────────────────────────────────────────── + // RACCOLTA — UNION di tutte le sorgenti, ognuna difensiva (try/catch) + // ───────────────────────────────────────────────────────────────────────── + + /** + * Raccoglie e normalizza tutti gli eventi della finestra [from,to] per l'org. + * Ogni sorgente che fallisce (tabella/colonna mancante) viene saltata senza + * far cadere l'intero calendario. + */ + private function collect(int $orgId, string $from, string $to): array + { + $events = []; + $sources = [ + 'incidents' => fn() => $this->srcIncidents($orgId, $from, $to), + 'policies' => fn() => $this->srcPolicies($orgId, $from, $to), + 'risk_treatments' => fn() => $this->srcRiskTreatments($orgId, $from, $to), + 'controls' => fn() => $this->srcControls($orgId, $from, $to), + 'non_conformities' => fn() => $this->srcNonConformities($orgId, $from, $to), + 'capa_actions' => fn() => $this->srcCapaActions($orgId, $from, $to), + 'training' => fn() => $this->srcTraining($orgId, $from, $to), + 'stk_activities' => fn() => $this->srcStkActivities($orgId, $from, $to), + 'review_schedule' => fn() => $this->srcReviewSchedule($orgId, $from, $to), + 'internal_audits' => fn() => $this->srcInternalAudits($orgId, $from, $to), + 'mgmt_reviews' => fn() => $this->srcManagementReviewDecisions($orgId, $from, $to), + ]; + foreach ($sources as $rows) { + try { + foreach ($rows() as $ev) { + if ($ev !== null) { $events[] = $ev; } + } + } catch (\Throwable $e) { + // Sorgente non disponibile (es. tabella di un modulo non ancora migrato): + // si ignora senza compromettere le altre fonti. + continue; + } + } + return $events; + } + + /** Incidenti Art.23: early_warning / notification / final_report (datetime). */ + private function srcIncidents(int $orgId, string $from, string $to): array + { + $rows = Database::fetchAll( + 'SELECT id, title, severity, + early_warning_due, early_warning_sent_at, + notification_due, notification_sent_at, + final_report_due, final_report_sent_at + FROM incidents + WHERE organization_id = ? AND is_significant = 1 + AND status NOT IN ("closed", "post_mortem")', + [$orgId] + ); + $out = []; + foreach ($rows as $r) { + if ($r['early_warning_due'] && !$r['early_warning_sent_at']) { + $out[] = $this->makeEvent('incidents', 'incident_early_warning', + 'Early Warning: ' . $r['title'], $r['early_warning_due'], + 'critical', 'incident', (int) $r['id'], '/incidents.html', $from, $to, false); + } + if ($r['notification_due'] && !$r['notification_sent_at']) { + $out[] = $this->makeEvent('incidents', 'incident_notification', + 'Notifica CSIRT: ' . $r['title'], $r['notification_due'], + 'high', 'incident', (int) $r['id'], '/incidents.html', $from, $to, false); + } + if ($r['final_report_due'] && !$r['final_report_sent_at']) { + $out[] = $this->makeEvent('incidents', 'incident_final_report', + 'Report finale: ' . $r['title'], $r['final_report_due'], + 'medium', 'incident', (int) $r['id'], '/incidents.html', $from, $to, false); + } + } + return array_values(array_filter($out)); + } + + /** Revisione policy/procedure: policies.next_review_date (status != archived). */ + private function srcPolicies(int $orgId, string $from, string $to): array + { + $rows = Database::fetchAll( + 'SELECT id, title, next_review_date + FROM policies + WHERE organization_id = ? AND next_review_date IS NOT NULL + AND status NOT IN ("archived")', + [$orgId] + ); + $out = []; + foreach ($rows as $r) { + $out[] = $this->makeEvent('policies', 'policy_review', + 'Revisione policy: ' . $r['title'], $r['next_review_date'], + 'medium', 'policy', (int) $r['id'], '/policies.html', $from, $to, false); + } + return array_values(array_filter($out)); + } + + /** Trattamenti rischio: risk_treatments.due_date JOIN risks (org via risks). */ + private function srcRiskTreatments(int $orgId, string $from, string $to): array + { + $rows = Database::fetchAll( + 'SELECT rt.id, rt.due_date, r.title AS risk_title + FROM risk_treatments rt + JOIN risks r ON r.id = rt.risk_id + WHERE r.organization_id = ? AND rt.status IN ("planned", "in_progress") + AND rt.due_date IS NOT NULL', + [$orgId] + ); + $out = []; + foreach ($rows as $r) { + $out[] = $this->makeEvent('risk_treatments', 'risk_treatment', + 'Trattamento rischio: ' . $r['risk_title'], $r['due_date'], + 'medium', 'risk_treatment', (int) $r['id'], '/risks.html', $from, $to, false); + } + return array_values(array_filter($out)); + } + + /** Revisione controlli: compliance_controls.next_review_date (non verificati/in corso). */ + private function srcControls(int $orgId, string $from, string $to): array + { + $rows = Database::fetchAll( + 'SELECT id, control_code, title, next_review_date, status + FROM compliance_controls + WHERE organization_id = ? AND next_review_date IS NOT NULL', + [$orgId] + ); + $out = []; + foreach ($rows as $r) { + $title = trim((string) $r['control_code'] . ' ' . (string) $r['title']); + $out[] = $this->makeEvent('compliance_controls', 'control_review', + 'Revisione controllo: ' . $title, $r['next_review_date'], + 'medium', 'compliance_control', (int) $r['id'], '/reports.html', $from, $to, false); + } + return array_values(array_filter($out)); + } + + /** Non conformita': non_conformities.target_close_date (status non chiuso). */ + private function srcNonConformities(int $orgId, string $from, string $to): array + { + $rows = Database::fetchAll( + 'SELECT id, ncr_code, title, target_close_date + FROM non_conformities + WHERE organization_id = ? AND target_close_date IS NOT NULL + AND status NOT IN ("closed", "cancelled")', + [$orgId] + ); + $out = []; + foreach ($rows as $r) { + $title = trim((string) $r['ncr_code'] . ' ' . (string) $r['title']); + $out[] = $this->makeEvent('non_conformities', 'nc_target_close', + 'Chiusura NC: ' . $title, $r['target_close_date'], + 'high', 'non_conformity', (int) $r['id'], '/reports.html', $from, $to, false); + } + return array_values(array_filter($out)); + } + + /** Azioni correttive: capa_actions.due_date (status non completed/verified). */ + private function srcCapaActions(int $orgId, string $from, string $to): array + { + $rows = Database::fetchAll( + 'SELECT id, capa_code, title, due_date + FROM capa_actions + WHERE organization_id = ? AND due_date IS NOT NULL + AND status NOT IN ("completed", "verified")', + [$orgId] + ); + $out = []; + foreach ($rows as $r) { + $title = trim((string) $r['capa_code'] . ' ' . (string) $r['title']); + $out[] = $this->makeEvent('capa_actions', 'capa_action', + 'Azione correttiva: ' . $title, $r['due_date'], + 'medium', 'capa_action', (int) $r['id'], '/reports.html', $from, $to, false); + } + return array_values(array_filter($out)); + } + + /** Formazione: training_assignments.due_date (status assigned/in_progress). */ + private function srcTraining(int $orgId, string $from, string $to): array + { + $rows = Database::fetchAll( + 'SELECT ta.id, ta.due_date, tc.title, u.full_name + FROM training_assignments ta + JOIN training_courses tc ON tc.id = ta.course_id + LEFT JOIN users u ON u.id = ta.user_id + WHERE ta.organization_id = ? AND ta.status IN ("assigned", "in_progress") + AND ta.due_date IS NOT NULL', + [$orgId] + ); + $out = []; + foreach ($rows as $r) { + $who = $r['full_name'] ? (' - ' . $r['full_name']) : ''; + $out[] = $this->makeEvent('training', 'training_due', + 'Formazione: ' . $r['title'] . $who, $r['due_date'], + 'low', 'training', (int) $r['id'], '/training.html', $from, $to, false); + } + return array_values(array_filter($out)); + } + + /** + * Attivita' stakeholder (C5.2): stk_activities.due_date (preferita) e, se assente, + * planned_date. Esclude annullate/completate. due_date "assolta" se completed. + */ + private function srcStkActivities(int $orgId, string $from, string $to): array + { + $rows = Database::fetchAll( + 'SELECT id, title, type, planned_date, due_date, status + FROM stk_activities + WHERE organization_id = ? AND status NOT IN ("cancelled")', + [$orgId] + ); + $out = []; + foreach ($rows as $r) { + $done = ($r['status'] === 'completed'); + $date = $r['due_date'] ?: $r['planned_date']; + if (!$date) { continue; } + $label = ($r['due_date'] ? 'Scadenza attivita': 'Attivita pianificata') . ': ' . $r['title']; + $out[] = $this->makeEvent('stk_activities', 'stakeholder_activity', + $label, $date, 'medium', 'stk_activity', (int) $r['id'], + '/stakeholder-activities.html', $from, $to, $done); + } + return array_values(array_filter($out)); + } + + /** Scadenziario revisioni periodiche (A4 4.4): review_schedule.next_review_date. */ + private function srcReviewSchedule(int $orgId, string $from, string $to): array + { + $rows = Database::fetchAll( + 'SELECT id, title, entity_type, next_review_date, last_reviewed_at + FROM review_schedule + WHERE organization_id = ? AND next_review_date IS NOT NULL', + [$orgId] + ); + $out = []; + foreach ($rows as $r) { + $out[] = $this->makeEvent('review_schedule', 'review_schedule', + $r['title'], $r['next_review_date'], + 'medium', 'review_schedule', (int) $r['id'], '/review-schedule.html', $from, $to, false); + } + return array_values(array_filter($out)); + } + + /** Audit interni (Modulo A, opzionale): internal_audits.planned_date. */ + private function srcInternalAudits(int $orgId, string $from, string $to): array + { + $rows = Database::fetchAll( + 'SELECT id, code, title, planned_date, status + FROM internal_audits + WHERE organization_id = ? AND planned_date IS NOT NULL', + [$orgId] + ); + $out = []; + foreach ($rows as $r) { + $done = in_array($r['status'], ['completed', 'cancelled'], true); + $title = trim((string) $r['code'] . ' ' . (string) $r['title']); + $out[] = $this->makeEvent('internal_audits', 'internal_audit', + 'Audit interno: ' . $title, $r['planned_date'], + 'medium', 'internal_audit', (int) $r['id'], '/internal-audits.html', $from, $to, $done); + } + return array_values(array_filter($out)); + } + + /** + * Decisioni del riesame di direzione (Modulo B, opzionale): + * management_review_decisions.due_date JOIN management_reviews per filtrare per org. + */ + private function srcManagementReviewDecisions(int $orgId, string $from, string $to): array + { + $rows = Database::fetchAll( + 'SELECT d.id, d.decision, d.due_date, d.status, mr.code AS review_code + FROM management_review_decisions d + JOIN management_reviews mr ON mr.id = d.review_id + WHERE mr.organization_id = ? AND d.due_date IS NOT NULL', + [$orgId] + ); + $out = []; + foreach ($rows as $r) { + $done = ($r['status'] === 'done'); + $txt = mb_substr((string) $r['decision'], 0, 120); + $out[] = $this->makeEvent('management_reviews', 'management_review_decision', + 'Decisione riesame: ' . $txt, $r['due_date'], + 'medium', 'management_review_decision', (int) $r['id'], + '/management-review.html', $from, $to, $done); + } + return array_values(array_filter($out)); + } + + // ───────────────────────────────────────────────────────────────────────── + // HELPER + // ───────────────────────────────────────────────────────────────────────── + + /** + * Normalizza una riga in evento di calendario. Ritorna null se la data + * (normalizzata a Y-m-d) cade fuori dalla finestra [from,to]. $done forza + * lo status a 'done' (scadenza gia' assolta/chiusa), altrimenti lo calcola + * LIVE rispetto a oggi. + */ + private function makeEvent( + string $source, string $type, string $title, ?string $rawDate, + string $severity, string $entityType, int $entityId, string $link, + string $from, string $to, bool $done + ): ?array { + if (!$rawDate) { return null; } + $date = substr((string) $rawDate, 0, 10); // DATETIME o DATE → Y-m-d + if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) { return null; } + if ($date < $from || $date > $to) { return null; } + + return [ + 'source' => $source, + 'type' => $type, + 'title' => mb_substr($title, 0, 255), + 'date' => $date, + 'status' => $this->computeStatus($date, $done), + 'severity' => $severity, + 'entity_type' => $entityType, + 'entity_id' => $entityId, + 'link' => $link, + ]; + } + + /** Stato LIVE: done | overdue | due_soon (<=N gg) | upcoming. */ + private function computeStatus(string $date, bool $done): string + { + if ($done) { return 'done'; } + $today = new DateTimeImmutable('today'); + $d = DateTimeImmutable::createFromFormat('!Y-m-d', $date); + if (!$d) { return 'upcoming'; } + $diffDays = (int) $today->diff($d)->format('%r%a'); + if ($diffDays < 0) { return 'overdue'; } + if ($diffDays <= self::DUE_SOON_DAYS) { return 'due_soon'; } + return 'upcoming'; + } + + /** + * Risolve la finestra [from,to]. Default ampio: dal 1° giorno di 1 mese fa al + * 1° giorno di 13 mesi avanti (copre tutto cio' che ha senso vedere in un + * calendario annuale). Param from/to opzionali sovrascrivono (validati Y-m-d). + */ + private function resolveRange(): array + { + $from = $this->validDate($this->getParam('from')); + $to = $this->validDate($this->getParam('to')); + if (!$from) { $from = (new DateTimeImmutable('first day of this month'))->modify('-1 month')->format('Y-m-d'); } + if (!$to) { $to = (new DateTimeImmutable('first day of this month'))->modify('+13 months')->format('Y-m-d'); } + if ($from > $to) { [$from, $to] = [$to, $from]; } + return [$from, $to]; + } + + /** Filtro tipi: ?types=a,b,c → solo i tipi noti; null = nessun filtro. */ + private function resolveTypeFilter(): ?array + { + $raw = $this->getParam('types'); + if ($raw === null || $raw === '') { return null; } + $parts = array_filter(array_map('trim', explode(',', (string) $raw)), static fn($t) => $t !== ''); + $valid = array_values(array_intersect($parts, self::KNOWN_TYPES)); + return $valid ?: null; + } + + /** Valida una data Y-m-d; ritorna la stringa normalizzata o null. */ + private function validDate($v): ?string + { + if ($v === null || $v === '') { return null; } + $s = trim((string) $v); + $dt = DateTime::createFromFormat('Y-m-d', $s); + return ($dt && $dt->format('Y-m-d') === $s) ? $s : null; + } +} diff --git a/application/controllers/InternalAuditController.php b/application/controllers/InternalAuditController.php new file mode 100644 index 0000000..617846a --- /dev/null +++ b/application/controllers/InternalAuditController.php @@ -0,0 +1,633 @@ + checklist di conduzione -> esiti -> finding NC. + * - PROGRAMMA: internal_audits (code AUD-NNN progressivo per org, scope, criteri, + * auditor capo come utente o ruolo organigramma, date pianificate/eseguite, + * stato, conclusione). La data pianificata alimenta il calendario (review_schedule). + * - CHECKLIST: internal_audit_items pre-popolata al create con le clausole 4-10 + * ISO 27001 (lista statica) + i controlli Annex A applicabili dal SoA dell'org + * (isms_soa.applicable=1, query difensiva: la tabella potrebbe non esistere). + * - FINDING: una voce non_conforme può generare una NC (non_conformities, + * source='audit', source_entity_type='internal_audit_item'), che confluisce + * nel modulo NCR/CAPA esistente. + * - EVIDENZE: su evidence_files (entity_type='internal_audit') — gestite altrove. + * - REPORT: HTML stampabile (no PDF lib). + * + * Multi-tenancy: ogni query filtra organization_id. Anti-IDOR su audit/item/role + * (verificati appartenenti all'org). logAudit su create/update/delete. + * NOTE: Database::query/fetchAll/fetchOne/insert/update/delete; jsonSuccess/Error exit. + */ + +require_once __DIR__ . '/BaseController.php'; + +class InternalAuditController extends BaseController +{ + private const MANAGE_ROLES = ['org_admin', 'compliance_manager', 'auditor']; + + /** + * Clausole 4-10 ISO/IEC 27001:2022 — checklist statica (ref_type='clause'). + * Pre-popolata al create di ogni audit; l'auditor parte già con la checklist. + */ + private const ISO_CLAUSES = [ + ['4.1', 'Comprensione dell\'organizzazione e del suo contesto'], + ['4.2', 'Comprensione delle esigenze e aspettative delle parti interessate'], + ['4.3', 'Determinazione dello scopo del SGSI'], + ['4.4', 'Sistema di gestione per la sicurezza delle informazioni'], + ['5.1', 'Leadership e impegno della direzione'], + ['5.2', 'Politica per la sicurezza delle informazioni'], + ['5.3', 'Ruoli, responsabilità e autorità organizzative'], + ['6.1.1', 'Azioni per affrontare rischi e opportunità — generalità'], + ['6.1.2', 'Valutazione del rischio per la sicurezza delle informazioni'], + ['6.1.3', 'Trattamento del rischio per la sicurezza delle informazioni (SoA)'], + ['6.2', 'Obiettivi di sicurezza delle informazioni e pianificazione'], + ['6.3', 'Pianificazione delle modifiche'], + ['7.1', 'Risorse'], + ['7.2', 'Competenza'], + ['7.3', 'Consapevolezza'], + ['7.4', 'Comunicazione'], + ['7.5', 'Informazioni documentate'], + ['8.1', 'Pianificazione e controllo operativi'], + ['8.2', 'Valutazione del rischio per la sicurezza delle informazioni'], + ['8.3', 'Trattamento del rischio per la sicurezza delle informazioni'], + ['9.1', 'Monitoraggio, misurazione, analisi e valutazione'], + ['9.2', 'Audit interno'], + ['9.3', 'Riesame di direzione'], + ['10.1', 'Miglioramento continuo'], + ['10.2', 'Non conformità e azioni correttive'], + ]; + + // ───────────────────────────────────────────────────────────────────────── + // PROGRAMMA AUDIT + // ───────────────────────────────────────────────────────────────────────── + + /** GET /api/internal-audits/list */ + public function list(): void + { + $this->requireOrgAccess(); + $orgId = $this->getCurrentOrgId(); + $rows = Database::fetchAll( + 'SELECT a.id, a.code, a.title, a.status, a.planned_date, a.executed_date, + a.lead_auditor_user_id, u.full_name AS lead_auditor_name, + a.lead_auditor_role_id, r.role_name AS lead_auditor_role_name, a.updated_at, + (SELECT COUNT(*) FROM internal_audit_items i WHERE i.audit_id = a.id) AS n_items, + (SELECT COUNT(*) FROM internal_audit_items i WHERE i.audit_id = a.id AND i.result = \'non_conforme\') AS n_nc + FROM internal_audits a + LEFT JOIN users u ON u.id = a.lead_auditor_user_id + LEFT JOIN org_roles r ON r.id = a.lead_auditor_role_id + WHERE a.organization_id = ? + ORDER BY (a.planned_date IS NULL), a.planned_date DESC, a.id DESC', + [$orgId] + ); + $out = array_map(static fn($a) => [ + 'id' => (int) $a['id'], 'code' => $a['code'], 'title' => $a['title'], 'status' => $a['status'], + 'planned_date' => $a['planned_date'], 'executed_date' => $a['executed_date'], + 'lead_auditor_user_id' => $a['lead_auditor_user_id'] !== null ? (int) $a['lead_auditor_user_id'] : null, + 'lead_auditor_name' => $a['lead_auditor_name'], + 'lead_auditor_role_id' => $a['lead_auditor_role_id'] !== null ? (int) $a['lead_auditor_role_id'] : null, + 'lead_auditor_role_name' => $a['lead_auditor_role_name'], + 'n_items' => (int) $a['n_items'], 'n_nc' => (int) $a['n_nc'], 'updated_at' => $a['updated_at'], + ], $rows); + $this->jsonSuccess(['audits' => $out]); + } + + /** GET /api/internal-audits/{id} (con items) */ + public function get(int $id): void + { + $this->requireOrgAccess(); + $orgId = $this->getCurrentOrgId(); + $a = Database::fetchOne( + 'SELECT a.*, u.full_name AS lead_auditor_name, r.role_name AS lead_auditor_role_name + FROM internal_audits a + LEFT JOIN users u ON u.id = a.lead_auditor_user_id + LEFT JOIN org_roles r ON r.id = a.lead_auditor_role_id + WHERE a.id = ? AND a.organization_id = ?', + [$id, $orgId] + ); + if (!$a) { $this->jsonError('Audit non trovato', 404, 'NOT_FOUND'); } + $this->jsonSuccess([ + 'id' => (int) $a['id'], 'code' => $a['code'], 'title' => $a['title'], + 'scope' => $a['scope'], 'criteria' => $a['criteria'], + 'planned_date' => $a['planned_date'], 'executed_date' => $a['executed_date'], + 'status' => $a['status'], + 'lead_auditor_user_id' => $a['lead_auditor_user_id'] !== null ? (int) $a['lead_auditor_user_id'] : null, + 'lead_auditor_name' => $a['lead_auditor_name'], + 'lead_auditor_role_id' => $a['lead_auditor_role_id'] !== null ? (int) $a['lead_auditor_role_id'] : null, + 'lead_auditor_role_name' => $a['lead_auditor_role_name'], + 'conclusion' => $a['conclusion'], 'updated_at' => $a['updated_at'], + 'items' => $this->loadItems($id), + ]); + } + + /** + * POST /api/internal-audits/create + * Genera code AUD-NNN progressivo per org e PRE-POPOLA la checklist: + * - clausole 4-10 ISO 27001 (statiche), + * - controlli Annex A applicabili dal SoA dell'org (se isms_soa esiste). + */ + public function create(): void + { + $this->requireOrgRole(self::MANAGE_ROLES); + $orgId = $this->getCurrentOrgId(); + $b = $this->getJsonBody(); + + $title = trim((string) ($b['title'] ?? '')); + if ($title === '' || mb_strlen($title) > 255) { $this->jsonError('Titolo obbligatorio (max 255)', 422, 'INVALID_TITLE'); } + $status = in_array($b['status'] ?? '', ['planned', 'in_progress', 'completed', 'cancelled'], true) ? $b['status'] : 'planned'; + $planned = $this->validateDate($b['planned_date'] ?? null, 'planned_date'); + $executed = $this->validateDate($b['executed_date'] ?? null, 'executed_date'); + $leadUserId = $this->validateUser($b['lead_auditor_user_id'] ?? null, $orgId); + $leadRoleId = $this->validateRole($b['lead_auditor_role_id'] ?? null, $orgId); + + $code = $this->generateAuditCode($orgId); + $id = (int) Database::insert('internal_audits', [ + 'organization_id' => $orgId, + 'code' => $code, + 'title' => $title, + 'scope' => $this->nullableStr($b['scope'] ?? null), + 'criteria' => $this->nullableStr($b['criteria'] ?? null), + 'planned_date' => $planned, + 'executed_date' => $executed, + 'status' => $status, + 'lead_auditor_user_id' => $leadUserId, + 'lead_auditor_role_id' => $leadRoleId, + 'conclusion' => $this->nullableStr($b['conclusion'] ?? null), + 'created_by' => $this->getCurrentUserId(), + ]); + + $seeded = $this->seedChecklist($id, $orgId); + $this->upsertCalendar($id, $orgId, $code, $title, $planned); + $this->logAudit('internal_audit_created', 'internal_audit', $id, ['code' => $code, 'title' => $title, 'items' => $seeded]); + $this->jsonSuccess(['id' => $id, 'code' => $code, 'items_seeded' => $seeded], 'Audit creato', 201); + } + + /** PUT /api/internal-audits/{id} */ + public function update(int $id): void + { + $this->requireOrgRole(self::MANAGE_ROLES); + $orgId = $this->getCurrentOrgId(); + $b = $this->getJsonBody(); + + $a = Database::fetchOne('SELECT id, code, title, planned_date FROM internal_audits WHERE id = ? AND organization_id = ?', [$id, $orgId]); + if (!$a) { $this->jsonError('Audit non trovato', 404, 'NOT_FOUND'); } + + $updates = []; + if ($this->hasParam('title')) { + $title = trim((string) ($b['title'] ?? '')); + if ($title === '' || mb_strlen($title) > 255) { $this->jsonError('Titolo non valido (max 255)', 422, 'INVALID_TITLE'); } + $updates['title'] = $title; + } + if ($this->hasParam('scope')) { $updates['scope'] = $this->nullableStr($b['scope'] ?? null); } + if ($this->hasParam('criteria')) { $updates['criteria'] = $this->nullableStr($b['criteria'] ?? null); } + if ($this->hasParam('planned_date')) { $updates['planned_date'] = $this->validateDate($b['planned_date'] ?? null, 'planned_date'); } + if ($this->hasParam('executed_date')) { $updates['executed_date'] = $this->validateDate($b['executed_date'] ?? null, 'executed_date'); } + if ($this->hasParam('status') && in_array($b['status'], ['planned', 'in_progress', 'completed', 'cancelled'], true)) { $updates['status'] = $b['status']; } + if ($this->hasParam('conclusion')) { $updates['conclusion'] = $this->nullableStr($b['conclusion'] ?? null); } + if ($this->hasParam('lead_auditor_user_id')) { $updates['lead_auditor_user_id'] = $this->validateUser($b['lead_auditor_user_id'] ?? null, $orgId); } + if ($this->hasParam('lead_auditor_role_id')) { $updates['lead_auditor_role_id'] = $this->validateRole($b['lead_auditor_role_id'] ?? null, $orgId); } + + if (!empty($updates)) { + Database::update('internal_audits', $updates, 'id = ? AND organization_id = ?', [$id, $orgId]); + } + // riallinea il calendario + $title = $updates['title'] ?? $a['title']; + $planned = array_key_exists('planned_date', $updates) ? $updates['planned_date'] : $a['planned_date']; + $this->upsertCalendar($id, $orgId, $a['code'], $title, $planned); + + $this->logAudit('internal_audit_updated', 'internal_audit', $id, array_keys($updates)); + $this->jsonSuccess(['id' => $id, 'updated' => array_keys($updates)], 'Audit aggiornato'); + } + + /** DELETE /api/internal-audits/{id} (org_admin) */ + public function delete(int $id): void + { + $this->requireOrgRole(['org_admin']); + $orgId = $this->getCurrentOrgId(); + $a = Database::fetchOne('SELECT id FROM internal_audits WHERE id = ? AND organization_id = ?', [$id, $orgId]); + if (!$a) { $this->jsonError('Audit non trovato', 404, 'NOT_FOUND'); } + Database::delete('review_schedule', 'organization_id = ? AND entity_type = ? AND entity_id = ?', [$orgId, 'internal_audit', $id]); + Database::delete('internal_audits', 'id = ? AND organization_id = ?', [$id, $orgId]); // items in cascata + $this->logAudit('internal_audit_deleted', 'internal_audit', $id); + $this->jsonSuccess(null, 'Audit eliminato'); + } + + // ───────────────────────────────────────────────────────────────────────── + // VOCI DI CHECKLIST + // ───────────────────────────────────────────────────────────────────────── + + /** + * PUT /api/internal-audits/items/{subId} Body: {result?, note?} + * Aggiorna esito/note di una voce, verificando che appartenga a un audit dell'org. + */ + public function updateItem(int $itemId): void + { + $this->requireOrgRole(self::MANAGE_ROLES); + $orgId = $this->getCurrentOrgId(); + $item = $this->assertItem($itemId, $orgId); + $b = $this->getJsonBody(); + + $updates = []; + if ($this->hasParam('result')) { + $allowed = ['da_verificare', 'conforme', 'non_conforme', 'osservazione', 'opportunita', 'non_applicabile']; + if (!in_array($b['result'] ?? '', $allowed, true)) { $this->jsonError('Esito non valido', 422, 'INVALID_RESULT'); } + $updates['result'] = $b['result']; + } + if ($this->hasParam('note')) { $updates['note'] = $this->nullableStr($b['note'] ?? null); } + if ($this->hasParam('checkpoint')) { + $cp = trim((string) ($b['checkpoint'] ?? '')); + if ($cp === '') { $this->jsonError('Checkpoint non valido', 422, 'INVALID_CHECKPOINT'); } + $updates['checkpoint'] = mb_substr($cp, 0, 2000); + } + if (empty($updates)) { $this->jsonError('Nessun campo da aggiornare', 400, 'NO_UPDATES'); } + + Database::update('internal_audit_items', $updates, 'id = ?', [$itemId]); + $this->logAudit('internal_audit_item_updated', 'internal_audit_item', $itemId, ['audit_id' => (int) $item['audit_id']] + array_fill_keys(array_keys($updates), 1)); + $this->jsonSuccess(['id' => $itemId, 'updated' => array_keys($updates)], 'Voce aggiornata'); + } + + /** + * POST /api/internal-audits/items Body: {audit_id*, checkpoint*, ref_type?, ref_code?, note?, result?} + * Aggiunge una voce custom alla checklist (l'audit deve appartenere all'org). + */ + public function addItem(): void + { + $this->requireOrgRole(self::MANAGE_ROLES); + $orgId = $this->getCurrentOrgId(); + $b = $this->getJsonBody(); + + $auditId = (int) ($b['audit_id'] ?? 0); + $this->assertAudit($auditId, $orgId); + + $checkpoint = trim((string) ($b['checkpoint'] ?? '')); + if ($checkpoint === '') { $this->jsonError('Checkpoint obbligatorio', 422, 'INVALID_CHECKPOINT'); } + $refType = in_array($b['ref_type'] ?? '', ['clause', 'annex_control', 'nis2_measure', 'custom'], true) ? $b['ref_type'] : 'custom'; + $result = in_array($b['result'] ?? '', ['da_verificare', 'conforme', 'non_conforme', 'osservazione', 'opportunita', 'non_applicabile'], true) ? $b['result'] : 'da_verificare'; + + $maxOrd = (int) (Database::fetchOne('SELECT COALESCE(MAX(ord), 0) AS m FROM internal_audit_items WHERE audit_id = ?', [$auditId])['m'] ?? 0); + $id = (int) Database::insert('internal_audit_items', [ + 'audit_id' => $auditId, + 'ref_type' => $refType, + 'ref_code' => $this->nullableStr($b['ref_code'] ?? null, 32), + 'checkpoint' => mb_substr($checkpoint, 0, 2000), + 'result' => $result, + 'note' => $this->nullableStr($b['note'] ?? null), + 'ord' => $maxOrd + 1, + ]); + $this->logAudit('internal_audit_item_added', 'internal_audit_item', $id, ['audit_id' => $auditId]); + $this->jsonSuccess(['id' => $id], 'Voce aggiunta', 201); + } + + // ───────────────────────────────────────────────────────────────────────── + // FINDING -> NON CONFORMITA' + // ───────────────────────────────────────────────────────────────────────── + + /** + * POST /api/internal-audits/{id}/raiseNcr Body: {item_id?} + * Crea una NC (non_conformities) da una voce non conforme dell'audit. + * source='audit' (l'ENUM 004 NON ha 'internal_audit'), + * source_entity_type='internal_audit_item', source_entity_id=item_id. + * Idempotente: se esiste già una NC per quella voce la restituisce. + */ + public function raiseNcr(int $id): void + { + $this->requireOrgRole(self::MANAGE_ROLES); + $orgId = $this->getCurrentOrgId(); + $audit = Database::fetchOne('SELECT id, code FROM internal_audits WHERE id = ? AND organization_id = ?', [$id, $orgId]); + if (!$audit) { $this->jsonError('Audit non trovato', 404, 'NOT_FOUND'); } + $b = $this->getJsonBody(); + + $itemId = (int) ($b['item_id'] ?? 0); + if ($itemId <= 0) { $this->jsonError('item_id obbligatorio', 422, 'MISSING_ITEM'); } + $item = Database::fetchOne('SELECT id, audit_id, ref_type, ref_code, checkpoint FROM internal_audit_items WHERE id = ? AND audit_id = ?', [$itemId, $id]); + if (!$item) { $this->jsonError('Voce non trovata in questo audit', 404, 'ITEM_NOT_FOUND'); } + + // Idempotenza: una sola NC per voce + $existing = Database::fetchOne( + "SELECT id, ncr_code FROM non_conformities + WHERE organization_id = ? AND source = 'audit' AND source_entity_type = 'internal_audit_item' AND source_entity_id = ?", + [$orgId, $itemId] + ); + if ($existing) { + $this->jsonSuccess(['id' => (int) $existing['id'], 'ncr_code' => $existing['ncr_code'], 'already' => true], 'Non conformità già aperta per questa voce'); + } + + $checkpoint = (string) $item['checkpoint']; + $refCode = $item['ref_code'] ? '[' . $item['ref_code'] . '] ' : ''; + $titleBase = $refCode . $checkpoint; + $title = mb_strlen($titleBase) > 200 ? mb_substr($titleBase, 0, 197) . '...' : $titleBase; + if ($title === '') { $title = 'Non conformità da audit interno ' . $audit['code']; } + + $ncrCode = $this->generateCode('NCR'); + $ncrId = (int) Database::insert('non_conformities', [ + 'organization_id' => $orgId, + 'ncr_code' => $ncrCode, + 'title' => $title, + 'description' => "Rilevata nell'audit interno {$audit['code']}" . ($item['ref_code'] ? " (rif. {$item['ref_code']})" : '') . ": {$checkpoint}", + 'source' => 'audit', + 'source_entity_type' => 'internal_audit_item', + 'source_entity_id' => $itemId, + 'severity' => 'minor', + 'status' => 'open', + 'identified_by' => $this->getCurrentUserId(), + ]); + $this->logAudit('internal_audit_ncr_raised', 'non_conformity', $ncrId, ['ncr_code' => $ncrCode, 'audit_id' => $id, 'item_id' => $itemId]); + $this->jsonSuccess(['id' => $ncrId, 'ncr_code' => $ncrCode, 'already' => false], 'Non conformità creata', 201); + } + + // ───────────────────────────────────────────────────────────────────────── + // REPORT + // ───────────────────────────────────────────────────────────────────────── + + /** GET /api/internal-audits/{id}/report — HTML stampabile (no PDF lib). */ + public function report(int $id): void + { + $this->requireOrgRole(['org_admin', 'compliance_manager', 'auditor', 'board_member']); + $orgId = $this->getCurrentOrgId(); + $a = Database::fetchOne( + 'SELECT a.*, u.full_name AS lead_auditor_name, r.role_name AS lead_auditor_role_name, o.name AS org_name + FROM internal_audits a + LEFT JOIN users u ON u.id = a.lead_auditor_user_id + LEFT JOIN org_roles r ON r.id = a.lead_auditor_role_id + LEFT JOIN organizations o ON o.id = a.organization_id + WHERE a.id = ? AND a.organization_id = ?', + [$id, $orgId] + ); + if (!$a) { $this->jsonError('Audit non trovato', 404, 'NOT_FOUND'); } + $items = $this->loadItems($id); + + header('Content-Type: text/html; charset=utf-8'); + echo $this->renderReport($a, $items); + exit; + } + + // ───────────────────────────────────────────────────────────────────────── + // HELPER + // ───────────────────────────────────────────────────────────────────────── + + private function loadItems(int $auditId): array + { + $rows = Database::fetchAll( + 'SELECT id, ref_type, ref_code, checkpoint, result, note, ord + FROM internal_audit_items WHERE audit_id = ? + ORDER BY FIELD(ref_type, \'clause\',\'annex_control\',\'nis2_measure\',\'custom\'), ord ASC, id ASC', + [$auditId] + ); + // mappa item -> eventuale NC già aperta (per il pulsante "apri NC" del frontend) + $ncByItem = []; + $ncs = Database::fetchAll( + "SELECT source_entity_id, id, ncr_code FROM non_conformities + WHERE source = 'audit' AND source_entity_type = 'internal_audit_item' + AND source_entity_id IN (SELECT id FROM internal_audit_items WHERE audit_id = ?)", + [$auditId] + ); + foreach ($ncs as $n) { $ncByItem[(int) $n['source_entity_id']] = ['id' => (int) $n['id'], 'ncr_code' => $n['ncr_code']]; } + + return array_map(static function ($r) use ($ncByItem) { + $iid = (int) $r['id']; + return [ + 'id' => $iid, 'ref_type' => $r['ref_type'], 'ref_code' => $r['ref_code'], + 'checkpoint' => $r['checkpoint'], 'result' => $r['result'], 'note' => $r['note'], + 'ord' => (int) $r['ord'], + 'ncr' => $ncByItem[$iid] ?? null, + ]; + }, $rows); + } + + /** Genera code AUD-NNN progressivo per org (es. AUD-001). */ + private function generateAuditCode(int $orgId): string + { + $n = (int) (Database::fetchOne( + "SELECT COUNT(*) AS c FROM internal_audits WHERE organization_id = ?", + [$orgId] + )['c'] ?? 0); + // evita collisione su uno UNIQUE eventuale futuro: incrementa finché libero + for ($i = $n + 1; $i < $n + 1000; $i++) { + $code = 'AUD-' . str_pad((string) $i, 3, '0', STR_PAD_LEFT); + $exists = Database::fetchOne('SELECT id FROM internal_audits WHERE organization_id = ? AND code = ?', [$orgId, $code]); + if (!$exists) { return $code; } + } + return 'AUD-' . str_pad((string) ($n + 1), 3, '0', STR_PAD_LEFT); + } + + /** + * Pre-popola la checklist: clausole 4-10 (statiche) + Annex A applicabili da SoA. + * Ritorna il numero di voci create. + */ + private function seedChecklist(int $auditId, int $orgId): int + { + $ord = 0; + $count = 0; + foreach (self::ISO_CLAUSES as [$code, $checkpoint]) { + Database::insert('internal_audit_items', [ + 'audit_id' => $auditId, + 'ref_type' => 'clause', + 'ref_code' => $code, + 'checkpoint' => $checkpoint, + 'result' => 'da_verificare', + 'ord' => $ord++, + ]); + $count++; + } + // Annex A dal SoA dell'org (query difensiva: isms_soa potrebbe non esistere) + try { + $soa = Database::fetchAll( + "SELECT control_code, source_ref FROM isms_soa + WHERE organization_id = ? AND applicable = 1 + ORDER BY control_code ASC", + [$orgId] + ); + $ord = 0; + foreach ($soa as $s) { + $cp = $s['source_ref'] ? (string) $s['source_ref'] : ('Controllo applicabile (SoA): ' . $s['control_code']); + Database::insert('internal_audit_items', [ + 'audit_id' => $auditId, + 'ref_type' => 'annex_control', + 'ref_code' => mb_substr((string) $s['control_code'], 0, 32), + 'checkpoint' => mb_substr($cp, 0, 2000), + 'result' => 'da_verificare', + 'ord' => $ord++, + ]); + $count++; + } + } catch (PDOException $e) { + // tabella SoA assente o non popolata per l'org: la checklist resta con le sole clausole + error_log('[InternalAudit] SoA seed skipped: ' . $e->getMessage()); + } + return $count; + } + + /** Crea/aggiorna/elimina la riga di calendario (review_schedule) per l'audit. */ + private function upsertCalendar(int $auditId, int $orgId, ?string $code, string $title, ?string $plannedDate): void + { + if ($plannedDate === null) { + Database::delete('review_schedule', 'organization_id = ? AND entity_type = ? AND entity_id = ?', [$orgId, 'internal_audit', $auditId]); + return; + } + $label = mb_substr('Audit interno ' . ($code ? $code . ': ' : '') . $title, 0, 255); + try { + Database::query( + 'INSERT INTO review_schedule (organization_id, entity_type, entity_id, title, next_review_date, created_by) + VALUES (?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE title = VALUES(title), next_review_date = VALUES(next_review_date)', + [$orgId, 'internal_audit', $auditId, $label, $plannedDate, $this->getCurrentUserId()] + ); + } catch (PDOException $e) { + // l'ENUM review_schedule.entity_type potrebbe non includere ancora 'internal_audit' + // (estensione lato seeder/flotta): non bloccare la creazione dell'audit. + error_log('[InternalAudit] calendar upsert skipped: ' . $e->getMessage()); + } + } + + private function assertAudit(int $id, int $orgId): array + { + $a = Database::fetchOne('SELECT id, code FROM internal_audits WHERE id = ? AND organization_id = ?', [$id, $orgId]); + if (!$a) { $this->jsonError('Audit non trovato', 404, 'NOT_FOUND'); } + return $a; + } + + /** Verifica che la voce appartenga a un audit dell'org (anti-IDOR). */ + private function assertItem(int $itemId, int $orgId): array + { + $item = Database::fetchOne( + 'SELECT i.id, i.audit_id FROM internal_audit_items i + JOIN internal_audits a ON a.id = i.audit_id + WHERE i.id = ? AND a.organization_id = ?', + [$itemId, $orgId] + ); + if (!$item) { $this->jsonError('Voce non trovata', 404, 'NOT_FOUND'); } + return $item; + } + + private function validateUser($id, int $orgId): ?int + { + $id = ($id === null || $id === '') ? null : (int) $id; + if ($id === null) { return null; } + // l'utente deve essere membro dell'org (anti-IDOR) + $row = Database::fetchOne( + 'SELECT u.id FROM users u + JOIN user_organizations uo ON uo.user_id = u.id + WHERE u.id = ? AND uo.organization_id = ?', + [$id, $orgId] + ); + if (!$row) { $this->jsonError('Auditor capo non valido', 422, 'INVALID_AUDITOR'); } + return $id; + } + + private function validateRole($id, int $orgId): ?int + { + $id = ($id === null || $id === '') ? null : (int) $id; + if ($id === null) { return null; } + $row = Database::fetchOne('SELECT id FROM org_roles WHERE id = ? AND organization_id = ?', [$id, $orgId]); + if (!$row) { $this->jsonError('Ruolo auditor non valido', 422, 'INVALID_ROLE'); } + return $id; + } + + private function validateDate($v, string $field): ?string + { + if ($v === null || $v === '') { return null; } + $d = trim((string) $v); + $dt = DateTime::createFromFormat('Y-m-d', $d); + if (!$dt || $dt->format('Y-m-d') !== $d) { $this->jsonError("Data $field non valida (atteso AAAA-MM-GG)", 422, 'INVALID_DATE'); } + return $d; + } + + private function nullableStr($v, ?int $max = null): ?string + { + if ($v === null) { return null; } + $s = trim((string) $v); + if ($s === '') { return null; } + if ($max !== null && mb_strlen($s) > $max) { $s = mb_substr($s, 0, $max); } + return $s; + } + + /** Report HTML stampabile, self-contained (no PDF lib, no asset esterni). */ + private function renderReport(array $a, array $items): string + { + $esc = static fn($s) => htmlspecialchars((string) ($s ?? ''), ENT_QUOTES, 'UTF-8'); + $resLabels = [ + 'da_verificare' => 'Da verificare', 'conforme' => 'Conforme', 'non_conforme' => 'Non conforme', + 'osservazione' => 'Osservazione', 'opportunita' => 'Opportunità', 'non_applicabile' => 'Non applicabile', + ]; + $resColors = [ + 'da_verificare' => '#6b7280', 'conforme' => '#166534', 'non_conforme' => '#991b1b', + 'osservazione' => '#92400e', 'opportunita' => '#1e40af', 'non_applicabile' => '#6b7280', + ]; + $typeLabels = ['clause' => 'Clausole ISO 27001', 'annex_control' => 'Controlli Annex A', 'nis2_measure' => 'Misure NIS2', 'custom' => 'Voci aggiuntive']; + + // raggruppa per ref_type mantenendo l'ordine + $groups = []; + foreach ($items as $it) { $groups[$it['ref_type']][] = $it; } + + // conteggi esiti + $tally = []; + foreach ($items as $it) { $tally[$it['result']] = ($tally[$it['result']] ?? 0) + 1; } + + $leadParts = []; + if (!empty($a['lead_auditor_name'])) { $leadParts[] = $a['lead_auditor_name']; } + if (!empty($a['lead_auditor_role_name'])) { $leadParts[] = '(' . $a['lead_auditor_role_name'] . ')'; } + $lead = $leadParts ? implode(' ', $leadParts) : '—'; + + $h = ''; + $h .= ''; + $h .= 'Report audit interno ' . $esc($a['code']) . ''; + $h .= ''; + $h .= '

Report di audit interno

'; + $h .= '
' . $esc($a['org_name']) . ' · ISO/IEC 27001 §9.2 · generato il ' . date('d/m/Y H:i') . '
'; + + $h .= ''; + $h .= ''; + $h .= ''; + $h .= ''; + $h .= ''; + $h .= ''; + $h .= ''; + $h .= ''; + $h .= ''; + $h .= '
Codice' . $esc($a['code']) . '
Titolo' . $esc($a['title']) . '
Stato' . $esc($a['status']) . '
Auditor capo' . $esc($lead) . '
Data pianificata' . ($a['planned_date'] ? $esc(date('d/m/Y', strtotime($a['planned_date']))) : '—') . '
Data esecuzione' . ($a['executed_date'] ? $esc(date('d/m/Y', strtotime($a['executed_date']))) : '—') . '
Ambito (scope)' . nl2br($esc($a['scope'])) . '
Criteri' . nl2br($esc($a['criteria'])) . '
'; + + $h .= '

Sintesi esiti

'; + foreach ($resLabels as $k => $lbl) { + $c = $tally[$k] ?? 0; + $h .= '' . $esc($lbl) . ' ' . $c . ''; + } + $h .= '
'; + + foreach ($typeLabels as $type => $label) { + if (empty($groups[$type])) { continue; } + $h .= '

' . $esc($label) . '

'; + $h .= ''; + foreach ($groups[$type] as $it) { + $col = $resColors[$it['result']] ?? '#6b7280'; + $rl = $resLabels[$it['result']] ?? $it['result']; + $ncSuffix = $it['ncr'] ? ' NC ' . $esc($it['ncr']['ncr_code']) . '' : ''; + $h .= ''; + $h .= ''; + $h .= ''; + } + $h .= '
Rif.Punto di verificaEsitoNote
' . $esc($it['ref_code']) . '' . $esc($it['checkpoint']) . $ncSuffix . '' . $esc($rl) . '' . nl2br($esc($it['note'])) . '
'; + } + + if (!empty($a['conclusion'])) { + $h .= '

Conclusioni

' . nl2br($esc($a['conclusion'])) . '

'; + } + + $h .= '

Documento generato da NIS2 Agile. ISO/IEC 27001 §9.2 e\' una buona prassi volontaria; gli obblighi normativi in Italia derivano da NIS2 / D.Lgs. 138/2024. Strumento di supporto organizzativo, non un parere legale.

'; + $h .= ''; + return $h; + } +} diff --git a/application/controllers/ManagementReviewController.php b/application/controllers/ManagementReviewController.php new file mode 100644 index 0000000..96d76c5 --- /dev/null +++ b/application/controllers/ManagementReviewController.php @@ -0,0 +1,835 @@ +requireOrgAccess(); + $orgId = $this->getCurrentOrgId(); + $rows = Database::fetchAll( + 'SELECT r.id, r.code, r.review_date, r.period_label, r.status, r.approved_at, r.updated_at, + u.full_name AS chair_name, + (SELECT COUNT(*) FROM management_review_decisions d WHERE d.review_id = r.id) AS n_decisions, + (SELECT COUNT(*) FROM management_review_decisions d WHERE d.review_id = r.id AND d.status = \'done\') AS n_done + FROM management_reviews r + LEFT JOIN users u ON u.id = r.chair_user_id + WHERE r.organization_id = ? + ORDER BY (r.review_date IS NULL), r.review_date DESC, r.id DESC', + [$orgId] + ); + $out = array_map(static fn($r) => [ + 'id' => (int) $r['id'], + 'code' => $r['code'], + 'review_date' => $r['review_date'], + 'period_label' => $r['period_label'], + 'status' => $r['status'], + 'chair_name' => $r['chair_name'], + 'approved_at' => $r['approved_at'], + 'n_decisions' => (int) $r['n_decisions'], + 'n_done' => (int) $r['n_done'], + 'updated_at' => $r['updated_at'], + ], $rows); + $this->jsonSuccess(['reviews' => $out]); + } + + /** GET /api/management-reviews/{id} (con decisions + snapshot) */ + public function get(int $id): void + { + $this->requireOrgAccess(); + $orgId = $this->getCurrentOrgId(); + $r = Database::fetchOne( + 'SELECT r.*, u.full_name AS chair_name, a.full_name AS approver_name + FROM management_reviews r + LEFT JOIN users u ON u.id = r.chair_user_id + LEFT JOIN users a ON a.id = r.approved_by + WHERE r.id = ? AND r.organization_id = ?', + [$id, $orgId] + ); + if (!$r) { $this->jsonError('Riesame non trovato', 404, 'NOT_FOUND'); } + + $att = $r['attendees'] ? json_decode($r['attendees'], true) : []; + $snap = $r['snapshot'] ? json_decode($r['snapshot'], true) : null; + $this->jsonSuccess([ + 'id' => (int) $r['id'], + 'code' => $r['code'], + 'review_date' => $r['review_date'], + 'period_label' => $r['period_label'], + 'chair_user_id' => $r['chair_user_id'] !== null ? (int) $r['chair_user_id'] : null, + 'chair_name' => $r['chair_name'], + 'attendees' => is_array($att) ? $att : [], + 'status' => $r['status'], + 'approved_by' => $r['approved_by'] !== null ? (int) $r['approved_by'] : null, + 'approver_name' => $r['approver_name'], + 'approved_at' => $r['approved_at'], + 'conclusions' => $r['conclusions'], + 'snapshot' => is_array($snap) ? $snap : null, + 'created_at' => $r['created_at'], + 'updated_at' => $r['updated_at'], + 'decisions' => $this->loadDecisions($id), + ]); + } + + /** POST /api/management-reviews/create (genera code RD-AAAA-NN) */ + public function create(): void + { + $this->requireOrgRole(self::MANAGE_ROLES); + $orgId = $this->getCurrentOrgId(); + $b = $this->getJsonBody(); + + $reviewDate = $this->validateDate($b['review_date'] ?? null, 'review_date'); + $period = $this->nullableStr($b['period_label'] ?? null, 100); + $chairId = $this->validateUser($b['chair_user_id'] ?? null, $orgId); + $attendees = $this->validateAttendees($b['attendees'] ?? null); + $conclusions = $this->nullableStr($b['conclusions'] ?? null); + + $year = $reviewDate ? (int) substr($reviewDate, 0, 4) : (int) date('Y'); + $code = $this->nextCode($orgId, $year); + + $id = Database::insert('management_reviews', [ + 'organization_id' => $orgId, + 'code' => $code, + 'review_date' => $reviewDate, + 'period_label' => $period, + 'chair_user_id' => $chairId, + 'attendees' => $attendees !== null ? json_encode($attendees, JSON_UNESCAPED_UNICODE) : null, + 'status' => 'draft', + 'conclusions' => $conclusions, + 'created_by' => $this->getCurrentUserId(), + ]); + $this->logAudit('mgmt_review_created', 'management_review', (int) $id, ['code' => $code]); + $this->jsonSuccess(['id' => (int) $id, 'code' => $code], 'Riesame creato', 201); + } + + /** PUT /api/management-reviews/{id} */ + public function update(int $id): void + { + $this->requireOrgRole(self::MANAGE_ROLES); + $orgId = $this->getCurrentOrgId(); + $b = $this->getJsonBody(); + + $r = Database::fetchOne('SELECT id, status FROM management_reviews WHERE id = ? AND organization_id = ?', [$id, $orgId]); + if (!$r) { $this->jsonError('Riesame non trovato', 404, 'NOT_FOUND'); } + if ($r['status'] === 'approved') { $this->jsonError('Il riesame è approvato e non può essere modificato', 409, 'REVIEW_APPROVED'); } + + $updates = []; + if ($this->hasParam('review_date')) { $updates['review_date'] = $this->validateDate($b['review_date'] ?? null, 'review_date'); } + if ($this->hasParam('period_label')) { $updates['period_label'] = $this->nullableStr($b['period_label'] ?? null, 100); } + if ($this->hasParam('chair_user_id')) { $updates['chair_user_id'] = $this->validateUser($b['chair_user_id'] ?? null, $orgId); } + if ($this->hasParam('attendees')) { + $att = $this->validateAttendees($b['attendees'] ?? null); + $updates['attendees'] = $att !== null ? json_encode($att, JSON_UNESCAPED_UNICODE) : null; + } + if ($this->hasParam('conclusions')) { $updates['conclusions'] = $this->nullableStr($b['conclusions'] ?? null); } + // snapshot editabile finché draft: l'utente rivede gli input prima del congelamento + if ($this->hasParam('snapshot')) { + $snap = $b['snapshot'] ?? null; + $updates['snapshot'] = is_array($snap) ? json_encode($snap, JSON_UNESCAPED_UNICODE) : null; + } + + if (!empty($updates)) { + Database::update('management_reviews', $updates, 'id = ? AND organization_id = ?', [$id, $orgId]); + } + $this->logAudit('mgmt_review_updated', 'management_review', $id, array_keys($updates)); + $this->jsonSuccess(['id' => $id, 'updated' => array_keys($updates)], 'Riesame aggiornato'); + } + + // ───────────────────────────────────────────────────────────────────────── + // GATHER — aggregazione automatica degli INPUT del riesame + // ───────────────────────────────────────────────────────────────────────── + + /** + * GET /api/management-reviews/gather + * Aggrega gli INPUT del riesame (ISO 27001 §9.3.2) leggendo i moduli esistenti. + * Ogni sezione è in try/catch isolato e org-scoped: una fonte mancante/rotta + * non blocca le altre. L'utente rivede il risultato e lo congela nello snapshot. + */ + public function gather(): void + { + $this->requireOrgRole(self::MANAGE_ROLES); + $orgId = $this->getCurrentOrgId(); + $this->jsonSuccess($this->buildGather($orgId)); + } + + /** Costruisce l'oggetto strutturato delle sezioni di input (riusato da approve). */ + private function buildGather(int $orgId): array + { + $sections = [ + 'nonconformities' => $this->sec(fn() => $this->gatherNonConformities($orgId)), + 'corrective_actions' => $this->sec(fn() => $this->gatherCorrectiveActions($orgId)), + 'internal_audits' => $this->sec(fn() => $this->gatherInternalAudits($orgId)), + 'risks' => $this->sec(fn() => $this->gatherRisks($orgId)), + 'compliance_score' => $this->sec(fn() => $this->gatherComplianceScore($orgId)), + 'objectives' => $this->sec(fn() => $this->gatherObjectives($orgId)), + 'training' => $this->sec(fn() => $this->gatherTraining($orgId)), + 'stakeholders' => $this->sec(fn() => $this->gatherStakeholders($orgId)), + 'normative' => $this->sec(fn() => $this->gatherNormative($orgId)), + 'upcoming_deadlines' => $this->sec(fn() => $this->gatherDeadlines($orgId)), + ]; + return [ + 'generated_at' => date('Y-m-d H:i:s'), + 'sections' => $sections, + ]; + } + + /** Wrapper difensivo: esegue $fn; se lancia (tabella mancante, ecc.) marca available=false. */ + private function sec(callable $fn): array + { + try { + $data = $fn(); + return array_merge(['available' => true], $data); + } catch (\Throwable $e) { + return ['available' => false, 'reason' => 'not_available']; + } + } + + /** 4. Stato NC/azioni correttive — non_conformities aperte. */ + private function gatherNonConformities(int $orgId): array + { + $open = ['open', 'investigating', 'action_planned', 'correcting', 'verifying']; + $place = implode(',', array_fill(0, count($open), '?')); + $rows = Database::fetchAll( + "SELECT id, ncr_code, title, severity, status, target_close_date + FROM non_conformities + WHERE organization_id = ? AND status IN ($place) + ORDER BY (target_close_date IS NULL), target_close_date ASC, id DESC + LIMIT 100", + array_merge([$orgId], $open) + ); + return [ + 'open_count' => count($rows), + 'items' => array_map(static fn($r) => [ + 'id' => (int) $r['id'], 'code' => $r['ncr_code'], 'title' => $r['title'], + 'severity' => $r['severity'], 'status' => $r['status'], 'due_date' => $r['target_close_date'], + ], $rows), + ]; + } + + /** 1. Azioni dal riesame precedente / CAPA non chiuse. */ + private function gatherCorrectiveActions(int $orgId): array + { + $open = ['planned', 'in_progress']; + $place = implode(',', array_fill(0, count($open), '?')); + $rows = Database::fetchAll( + "SELECT id, capa_code, title, status, due_date, action_type + FROM capa_actions + WHERE organization_id = ? AND status IN ($place) + ORDER BY (due_date IS NULL), due_date ASC, id DESC + LIMIT 100", + array_merge([$orgId], $open) + ); + return [ + 'open_count' => count($rows), + 'items' => array_map(static fn($r) => [ + 'id' => (int) $r['id'], 'code' => $r['capa_code'], 'title' => $r['title'], + 'status' => $r['status'], 'due_date' => $r['due_date'], 'type' => $r['action_type'], + ], $rows), + ]; + } + + /** 3. Risultati audit interni (Modulo A — la tabella potrebbe non esistere). */ + private function gatherInternalAudits(int $orgId): array + { + // Se internal_audits non esiste, la query lancia e sec() marca available=false. + $rows = Database::fetchAll( + 'SELECT id, code, title, status, planned_date, executed_date, conclusion + FROM internal_audits + WHERE organization_id = ? + ORDER BY (planned_date IS NULL), planned_date DESC, id DESC + LIMIT 50', + [$orgId] + ); + $completed = 0; $planned = 0; + foreach ($rows as $r) { + if ($r['status'] === 'completed') { $completed++; } + elseif (in_array($r['status'], ['planned', 'in_progress'], true)) { $planned++; } + } + return [ + 'total' => count($rows), + 'completed' => $completed, + 'planned' => $planned, + 'items' => array_map(static fn($r) => [ + 'id' => (int) $r['id'], 'code' => $r['code'], 'title' => $r['title'], + 'status' => $r['status'], 'planned_date' => $r['planned_date'], + 'executed_date' => $r['executed_date'], + 'conclusion' => $r['conclusion'] !== null ? mb_substr((string) $r['conclusion'], 0, 280) : null, + ], $rows), + ]; + } + + /** 7. Stato rischi + trattamenti. */ + private function gatherRisks(int $orgId): array + { + $byStatus = Database::fetchAll( + 'SELECT status, COUNT(*) AS c FROM risks WHERE organization_id = ? GROUP BY status', + [$orgId] + ); + $statusMap = []; + foreach ($byStatus as $s) { $statusMap[$s['status']] = (int) $s['c']; } + + // risk_treatments NON ha organization_id: si filtra via JOIN su risks (come CalendarController). + $treat = Database::fetchOne( + "SELECT + SUM(CASE WHEN rt.status = 'completed' THEN 1 ELSE 0 END) AS completed, + SUM(CASE WHEN rt.status IN ('planned','in_progress') THEN 1 ELSE 0 END) AS open, + SUM(CASE WHEN rt.status = 'overdue' OR (rt.due_date IS NOT NULL AND rt.due_date < CURDATE() AND rt.status <> 'completed') THEN 1 ELSE 0 END) AS overdue, + COUNT(*) AS total + FROM risk_treatments rt JOIN risks r ON r.id = rt.risk_id + WHERE r.organization_id = ?", + [$orgId] + ) ?: []; + + $top = Database::fetchAll( + "SELECT id, title, inherent_risk_score, residual_risk_score, status + FROM risks + WHERE organization_id = ? AND status <> 'closed' + ORDER BY inherent_risk_score DESC, id DESC LIMIT 10", + [$orgId] + ); + return [ + 'total' => (int) array_sum($statusMap), + 'by_status' => $statusMap, + 'treatments' => [ + 'total' => (int) ($treat['total'] ?? 0), + 'completed' => (int) ($treat['completed'] ?? 0), + 'open' => (int) ($treat['open'] ?? 0), + 'overdue' => (int) ($treat['overdue'] ?? 0), + ], + 'top_risks' => array_map(static fn($r) => [ + 'id' => (int) $r['id'], 'title' => $r['title'], + 'inherent' => $r['inherent_risk_score'] !== null ? (int) $r['inherent_risk_score'] : null, + 'residual' => $r['residual_risk_score'] !== null ? (int) $r['residual_risk_score'] : null, + 'status' => $r['status'], + ], $top), + ]; + } + + /** 5. Risultati monitoraggio/KPI: avanzamento SoA (isms_soa) se presente. */ + private function gatherComplianceScore(int $orgId): array + { + $soa = Database::fetchOne( + "SELECT COUNT(*) AS total, + SUM(CASE WHEN applicable = 1 THEN 1 ELSE 0 END) AS applicable, + ROUND(AVG(CASE WHEN applicable = 1 THEN implementation_pct END)) AS avg_pct, + SUM(CASE WHEN applicable = 1 AND implementation_status = 'implemented' THEN 1 ELSE 0 END) AS implemented, + SUM(CASE WHEN applicable = 1 AND implementation_status = 'verified' THEN 1 ELSE 0 END) AS verified + FROM isms_soa WHERE organization_id = ?", + [$orgId] + ) ?: []; + return [ + 'soa_controls_total' => (int) ($soa['total'] ?? 0), + 'soa_controls_applicable' => (int) ($soa['applicable'] ?? 0), + 'soa_avg_implementation' => $soa['avg_pct'] !== null ? (int) $soa['avg_pct'] : null, + 'soa_implemented' => (int) ($soa['implemented'] ?? 0), + 'soa_verified' => (int) ($soa['verified'] ?? 0), + ]; + } + + /** 6. Raggiungimento obiettivi SGSI: isms_models.isms_objectives (JSON) se presente. */ + private function gatherObjectives(int $orgId): array + { + $row = Database::fetchOne('SELECT isms_objectives FROM isms_models WHERE organization_id = ?', [$orgId]); + $objs = ($row && $row['isms_objectives']) ? json_decode($row['isms_objectives'], true) : []; + $items = is_array($objs) ? array_values(array_filter(array_map(static function ($o) { + if (is_string($o)) { return ['title' => mb_substr($o, 0, 280)]; } + if (is_array($o)) { + return [ + 'title' => isset($o['title']) ? mb_substr((string) $o['title'], 0, 280) + : (isset($o['name']) ? mb_substr((string) $o['name'], 0, 280) : null), + 'target' => $o['target'] ?? null, + 'status' => $o['status'] ?? null, + ]; + } + return null; + }, $objs))) : []; + return ['count' => count($items), 'items' => $items]; + } + + /** Formazione non conforme: assegnazioni scadute/non completate. */ + private function gatherTraining(int $orgId): array + { + $row = Database::fetchOne( + "SELECT + SUM(CASE WHEN status = 'overdue' OR (due_date IS NOT NULL AND due_date < CURDATE() AND status <> 'completed') THEN 1 ELSE 0 END) AS overdue, + SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) AS completed, + COUNT(*) AS total + FROM training_assignments WHERE organization_id = ?", + [$orgId] + ) ?: []; + return [ + 'total' => (int) ($row['total'] ?? 0), + 'completed' => (int) ($row['completed'] ?? 0), + 'overdue' => (int) ($row['overdue'] ?? 0), + ]; + } + + /** 8. Feedback parti interessate: attività stakeholder (stk_activities). */ + private function gatherStakeholders(int $orgId): array + { + $row = Database::fetchOne( + "SELECT + COUNT(*) AS total, + SUM(CASE WHEN status = 'sent' THEN 1 ELSE 0 END) AS sent, + SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) AS completed + FROM stk_activities WHERE organization_id = ?", + [$orgId] + ) ?: []; + $recent = Database::fetchAll( + 'SELECT id, title, type, status, due_date FROM stk_activities + WHERE organization_id = ? ORDER BY id DESC LIMIT 10', + [$orgId] + ); + return [ + 'total' => (int) ($row['total'] ?? 0), + 'sent' => (int) ($row['sent'] ?? 0), + 'completed' => (int) ($row['completed'] ?? 0), + 'items' => array_map(static fn($a) => [ + 'id' => (int) $a['id'], 'title' => $a['title'], 'type' => $a['type'], + 'status' => $a['status'], 'due_date' => $a['due_date'], + ], $recent), + ]; + } + + /** 9. Aggiornamenti normativi non ACK (normative_updates / normative_ack). */ + private function gatherNormative(int $orgId): array + { + $rows = Database::fetchAll( + 'SELECT u.id, u.title, u.source, u.reference, u.impact_level, u.effective_date + FROM normative_updates u + WHERE u.is_published = 1 + AND NOT EXISTS ( + SELECT 1 FROM normative_ack a + WHERE a.normative_update_id = u.id AND a.organization_id = ? + ) + ORDER BY u.published_at DESC + LIMIT 50', + [$orgId] + ); + return [ + 'pending_count' => count($rows), + 'items' => array_map(static fn($r) => [ + 'id' => (int) $r['id'], 'title' => $r['title'], 'source' => $r['source'], + 'reference' => $r['reference'], 'impact' => $r['impact_level'], + 'effective_date' => $r['effective_date'], + ], $rows), + ]; + } + + /** Scadenze imminenti (review_schedule, 90 gg). */ + private function gatherDeadlines(int $orgId): array + { + $rows = Database::fetchAll( + 'SELECT id, entity_type, title, next_review_date + FROM review_schedule + WHERE organization_id = ? AND next_review_date <= DATE_ADD(CURDATE(), INTERVAL 90 DAY) + ORDER BY next_review_date ASC + LIMIT 100', + [$orgId] + ); + $today = date('Y-m-d'); + return [ + 'count' => count($rows), + 'items' => array_map(static fn($r) => [ + 'id' => (int) $r['id'], 'entity_type' => $r['entity_type'], 'title' => $r['title'], + 'next_review_date' => $r['next_review_date'], + 'overdue' => ($r['next_review_date'] !== null && $r['next_review_date'] < $today), + ], $rows), + ]; + } + + // ───────────────────────────────────────────────────────────────────────── + // DECISIONI (OUTPUT) + // ───────────────────────────────────────────────────────────────────────── + + /** POST /api/management-reviews/{id}/decisions Body: {decision*, owner_role_id?, due_date?, status?, capa_id?} */ + public function addDecision(int $id): void + { + $this->requireOrgRole(self::MANAGE_ROLES); + $orgId = $this->getCurrentOrgId(); + $r = Database::fetchOne('SELECT id, status FROM management_reviews WHERE id = ? AND organization_id = ?', [$id, $orgId]); + if (!$r) { $this->jsonError('Riesame non trovato', 404, 'NOT_FOUND'); } + if ($r['status'] === 'approved') { $this->jsonError('Il riesame è approvato: non si possono aggiungere decisioni', 409, 'REVIEW_APPROVED'); } + $b = $this->getJsonBody(); + + $decision = trim((string) ($b['decision'] ?? '')); + if ($decision === '') { $this->jsonError('Testo della decisione obbligatorio', 422, 'EMPTY_DECISION'); } + + $ord = (int) (Database::fetchOne('SELECT COALESCE(MAX(ord), 0) + 1 AS n FROM management_review_decisions WHERE review_id = ?', [$id])['n'] ?? 1); + + $did = Database::insert('management_review_decisions', [ + 'review_id' => $id, + 'decision' => mb_substr($decision, 0, 5000), + 'owner_role_id' => $this->validateRole($b['owner_role_id'] ?? null, $orgId), + 'due_date' => $this->validateDate($b['due_date'] ?? null, 'due_date'), + 'status' => in_array($b['status'] ?? '', ['open', 'in_progress', 'done'], true) ? $b['status'] : 'open', + 'capa_id' => $this->validateCapa($b['capa_id'] ?? null, $orgId), + 'ord' => $ord, + ]); + $this->logAudit('mgmt_review_decision_added', 'management_review', $id, ['decision_id' => (int) $did]); + $this->jsonSuccess(['id' => (int) $did], 'Decisione aggiunta', 201); + } + + /** PUT /api/management-reviews/decisions/{subId} */ + public function updateDecision(int $subId): void + { + $this->requireOrgRole(self::MANAGE_ROLES); + $orgId = $this->getCurrentOrgId(); + $b = $this->getJsonBody(); + + // Anti-IDOR: la decisione deve appartenere a un riesame dell'org. + $d = Database::fetchOne( + 'SELECT d.id, r.status AS review_status + FROM management_review_decisions d + JOIN management_reviews r ON r.id = d.review_id + WHERE d.id = ? AND r.organization_id = ?', + [$subId, $orgId] + ); + if (!$d) { $this->jsonError('Decisione non trovata', 404, 'NOT_FOUND'); } + if ($d['review_status'] === 'approved') { $this->jsonError('Il riesame è approvato: decisione non modificabile', 409, 'REVIEW_APPROVED'); } + + $updates = []; + if ($this->hasParam('decision')) { + $decision = trim((string) ($b['decision'] ?? '')); + if ($decision === '') { $this->jsonError('Testo della decisione obbligatorio', 422, 'EMPTY_DECISION'); } + $updates['decision'] = mb_substr($decision, 0, 5000); + } + if ($this->hasParam('owner_role_id')) { $updates['owner_role_id'] = $this->validateRole($b['owner_role_id'] ?? null, $orgId); } + if ($this->hasParam('due_date')) { $updates['due_date'] = $this->validateDate($b['due_date'] ?? null, 'due_date'); } + if ($this->hasParam('status') && in_array($b['status'], ['open', 'in_progress', 'done'], true)) { $updates['status'] = $b['status']; } + if ($this->hasParam('capa_id')) { $updates['capa_id'] = $this->validateCapa($b['capa_id'] ?? null, $orgId); } + + if (!empty($updates)) { + Database::update('management_review_decisions', $updates, 'id = ?', [$subId]); + } + $this->logAudit('mgmt_review_decision_updated', 'management_review_decision', $subId, array_keys($updates)); + $this->jsonSuccess(['id' => $subId, 'updated' => array_keys($updates)], 'Decisione aggiornata'); + } + + // ───────────────────────────────────────────────────────────────────────── + // APPROVE — congela snapshot + immutabilità + // ───────────────────────────────────────────────────────────────────────── + + /** POST /api/management-reviews/{id}/approve (org_admin) */ + public function approve(int $id): void + { + $this->requireOrgRole(self::APPROVE_ROLES); + $orgId = $this->getCurrentOrgId(); + $r = Database::fetchOne('SELECT id, status, snapshot FROM management_reviews WHERE id = ? AND organization_id = ?', [$id, $orgId]); + if (!$r) { $this->jsonError('Riesame non trovato', 404, 'NOT_FOUND'); } + if ($r['status'] === 'approved') { $this->jsonError('Riesame già approvato', 409, 'ALREADY_APPROVED'); } + + // Congela lo snapshot: usa quello già salvato (rivisto dall'utente) oppure, + // se assente, ricalcola gli input aggregati al momento dell'approvazione. + $existing = $r['snapshot'] ? json_decode($r['snapshot'], true) : null; + $snapshot = is_array($existing) && !empty($existing) ? $existing : $this->buildGather($orgId); + $snapshot['frozen_at'] = date('Y-m-d H:i:s'); + + Database::update('management_reviews', [ + 'status' => 'approved', + 'approved_by' => $this->getCurrentUserId(), + 'approved_at' => date('Y-m-d H:i:s'), + 'snapshot' => json_encode($snapshot, JSON_UNESCAPED_UNICODE), + ], 'id = ? AND organization_id = ?', [$id, $orgId]); + + $this->logAudit('mgmt_review_approved', 'management_review', $id, ['frozen' => true]); + $this->jsonSuccess(['id' => $id, 'status' => 'approved'], 'Riesame approvato'); + } + + // ───────────────────────────────────────────────────────────────────────── + // REPORT — verbale HTML stampabile + // ───────────────────────────────────────────────────────────────────────── + + /** GET /api/management-reviews/{id}/report (verbale HTML stampabile) */ + public function report(int $id): void + { + $this->requireOrgRole(['org_admin', 'compliance_manager', 'board_member', 'auditor']); + $orgId = $this->getCurrentOrgId(); + $r = Database::fetchOne( + 'SELECT r.*, u.full_name AS chair_name, a.full_name AS approver_name, o.name AS org_name + FROM management_reviews r + LEFT JOIN users u ON u.id = r.chair_user_id + LEFT JOIN users a ON a.id = r.approved_by + LEFT JOIN organizations o ON o.id = r.organization_id + WHERE r.id = ? AND r.organization_id = ?', + [$id, $orgId] + ); + if (!$r) { $this->jsonError('Riesame non trovato', 404, 'NOT_FOUND'); } + + $decisions = $this->loadDecisions($id); + $snapshot = $r['snapshot'] ? json_decode($r['snapshot'], true) : ($r['status'] === 'approved' ? null : $this->buildGather($orgId)); + + header('Content-Type: text/html; charset=utf-8'); + echo $this->renderReportHtml($r, $decisions, is_array($snapshot) ? $snapshot : ['sections' => []]); + exit; + } + + // ───────────────────────────────────────────────────────────────────────── + // HELPER + // ───────────────────────────────────────────────────────────────────────── + + private function loadDecisions(int $reviewId): array + { + $rows = Database::fetchAll( + 'SELECT d.id, d.decision, d.owner_role_id, ro.role_name AS owner_role_name, + d.due_date, d.status, d.capa_id, c.capa_code, d.ord + FROM management_review_decisions d + LEFT JOIN org_roles ro ON ro.id = d.owner_role_id + LEFT JOIN capa_actions c ON c.id = d.capa_id + WHERE d.review_id = ? ORDER BY d.ord ASC, d.id ASC', + [$reviewId] + ); + return array_map(static fn($d) => [ + 'id' => (int) $d['id'], + 'decision' => $d['decision'], + 'owner_role_id' => $d['owner_role_id'] !== null ? (int) $d['owner_role_id'] : null, + 'owner_role_name' => $d['owner_role_name'], + 'due_date' => $d['due_date'], + 'status' => $d['status'], + 'capa_id' => $d['capa_id'] !== null ? (int) $d['capa_id'] : null, + 'capa_code' => $d['capa_code'], + 'ord' => (int) $d['ord'], + ], $rows); + } + + /** Genera RD-AAAA-NN univoco per org+anno (NN progressivo, 2 cifre). */ + private function nextCode(int $orgId, int $year): string + { + $prefix = 'RD-' . $year . '-'; + // progressivo NUMERICO (non lessicografico: 'RD-2026-100' verrebbe < 'RD-2026-99') + $row = Database::fetchOne( + "SELECT MAX(CAST(SUBSTRING_INDEX(code, '-', -1) AS UNSIGNED)) AS n + FROM management_reviews WHERE organization_id = ? AND code LIKE ?", + [$orgId, $prefix . '%'] + ); + $next = ((int) ($row['n'] ?? 0)) + 1; + return $prefix . str_pad((string) $next, 2, '0', STR_PAD_LEFT); + } + + private function validateUser($id, int $orgId): ?int + { + $id = ($id === null || $id === '') ? null : (int) $id; + if ($id === null) { return null; } + // l'utente deve essere membro dell'org (anti-IDOR) + $ok = Database::fetchOne( + 'SELECT user_id FROM user_organizations WHERE user_id = ? AND organization_id = ?', + [$id, $orgId] + ); + if (!$ok) { $this->jsonError('Utente presidente non valido per questa organizzazione', 422, 'INVALID_CHAIR'); } + return $id; + } + + private function validateRole($id, int $orgId): ?int + { + $id = ($id === null || $id === '') ? null : (int) $id; + if ($id === null) { return null; } + $ok = Database::fetchOne('SELECT id FROM org_roles WHERE id = ? AND organization_id = ?', [$id, $orgId]); + if (!$ok) { $this->jsonError('Ruolo owner non valido', 422, 'INVALID_ROLE'); } + return $id; + } + + private function validateCapa($id, int $orgId): ?int + { + $id = ($id === null || $id === '') ? null : (int) $id; + if ($id === null) { return null; } + $ok = Database::fetchOne('SELECT id FROM capa_actions WHERE id = ? AND organization_id = ?', [$id, $orgId]); + if (!$ok) { $this->jsonError('Azione CAPA collegata non valida', 422, 'INVALID_CAPA'); } + return $id; + } + + private function validateAttendees($raw): ?array + { + if ($raw === null) { return null; } + if (!is_array($raw)) { $this->jsonError('attendees deve essere un array', 422, 'INVALID_ATTENDEES'); } + $out = []; + foreach ($raw as $a) { + if (is_string($a)) { $name = trim($a); $role = ''; } + elseif (is_array($a)) { $name = trim((string) ($a['name'] ?? '')); $role = trim((string) ($a['role'] ?? '')); } + else { continue; } + if ($name === '') { continue; } + $item = ['name' => mb_substr($name, 0, 150)]; + if ($role !== '') { $item['role'] = mb_substr($role, 0, 150); } + $out[] = $item; + } + return $out; + } + + private function validateDate($v, string $field): ?string + { + if ($v === null || $v === '') { return null; } + $d = trim((string) $v); + $dt = DateTime::createFromFormat('Y-m-d', $d); + if (!$dt || $dt->format('Y-m-d') !== $d) { $this->jsonError("Data $field non valida (atteso AAAA-MM-GG)", 422, 'INVALID_DATE'); } + return $d; + } + + private function nullableStr($v, ?int $max = null): ?string + { + if ($v === null) { return null; } + $s = trim((string) $v); + if ($s === '') { return null; } + if ($max !== null && mb_strlen($s) > $max) { $s = mb_substr($s, 0, $max); } + return $s; + } + + /** HTML del verbale (stampabile). Tutto escapato (esc). */ + private function renderReportHtml(array $r, array $decisions, array $snapshot): string + { + $esc = static fn($s) => htmlspecialchars((string) ($s ?? ''), ENT_QUOTES, 'UTF-8'); + $sections = $snapshot['sections'] ?? []; + + $stLabel = $r['status'] === 'approved' ? 'APPROVATO' : 'BOZZA'; + $att = $r['attendees'] ? json_decode($r['attendees'], true) : []; + $attList = ''; + if (is_array($att) && $att) { + $attList = ''; + } else { + $attList = '

Nessun partecipante indicato.

'; + } + + // Tabella decisioni + $decRows = ''; + if ($decisions) { + foreach ($decisions as $d) { + $stMap = ['open' => 'Aperta', 'in_progress' => 'In corso', 'done' => 'Conclusa']; + $decRows .= '' . $esc($d['decision']) . '' + . '' . $esc($d['owner_role_name'] ?: '—') . '' + . '' . $esc($d['due_date'] ?: '—') . '' + . '' . $esc($stMap[$d['status']] ?? $d['status']) . '' + . '' . $esc($d['capa_code'] ?: '—') . ''; + } + } else { + $decRows = 'Nessuna decisione registrata.'; + } + + $inputs = $this->renderInputsHtml($sections, $esc); + + return '' + . 'Verbale Riesame di Direzione ' . $esc($r['code']) . '' + . '' + . '

Verbale del Riesame di Direzione

' + . '
' + . '
Organizzazione: ' . $esc($r['org_name']) . '
' + . '
Codice: ' . $esc($r['code']) . '   ' . $stLabel . '
' + . '
Data riesame: ' . $esc($r['review_date'] ?: '—') . '
' + . '
Periodo di riferimento: ' . $esc($r['period_label'] ?: '—') . '
' + . '
Presidente: ' . $esc($r['chair_name'] ?: '—') . '
' + . ($r['status'] === 'approved' ? '
Approvato da: ' . $esc($r['approver_name'] ?: '—') . ' il ' . $esc($r['approved_at']) . '
' : '') + . '
' + . '

Partecipanti

' . $attList + . '

Elementi in ingresso (ISO/IEC 27001 §9.3.2)

' . $inputs + . '

Conclusioni

' . ($r['conclusions'] ? '

' . nl2br($esc($r['conclusions'])) . '

' : '

Nessuna conclusione registrata.

') + . '

Decisioni e azioni (elementi in uscita §9.3.3)

' + . '' + . $decRows . '
DecisioneResponsabile (ruolo)ScadenzaStatoCAPA
' + . '
Documento generato da NIS2 Agile — riesame periodico del SGSI (ISO/IEC 27001 cl. 9.3; buona prassi di governance NIS2 GV.PO-02). Strumento di supporto, non sostituisce l\'auditor.
' + . ''; + } + + /** Rende le sezioni di input dello snapshot in HTML compatto. */ + private function renderInputsHtml(array $sections, callable $esc): string + { + $titles = [ + 'corrective_actions' => 'Stato azioni dal riesame precedente / CAPA aperte', + 'stakeholders' => 'Cambiamenti del contesto e parti interessate', + 'internal_audits' => 'Risultati degli audit interni', + 'nonconformities' => 'Non conformità e azioni correttive', + 'compliance_score' => 'Risultati del monitoraggio (avanzamento SoA / KPI)', + 'objectives' => 'Raggiungimento degli obiettivi del SGSI', + 'risks' => 'Valutazione dei rischi e stato del trattamento', + 'normative' => 'Aggiornamenti normativi non riscontrati (ACK)', + 'training' => 'Formazione e consapevolezza', + 'upcoming_deadlines' => 'Scadenze imminenti (90 giorni)', + ]; + $html = ''; + foreach ($titles as $key => $title) { + $sec = $sections[$key] ?? null; + $html .= '

' . $esc($title) . '

'; + if (!is_array($sec) || empty($sec['available'])) { + $html .= '

Dato non disponibile.

'; + continue; + } + $html .= '

' . $esc($this->summarizeSection($key, $sec)) . '

'; + $items = $sec['items'] ?? null; + if (is_array($items) && $items) { + $html .= ''; + } + } + return $html; + } + + /** Frase di sintesi numerica per sezione. */ + private function summarizeSection(string $key, array $sec): string + { + switch ($key) { + case 'nonconformities': return (int) ($sec['open_count'] ?? 0) . ' non conformità aperte.'; + case 'corrective_actions': return (int) ($sec['open_count'] ?? 0) . ' azioni correttive in corso.'; + case 'internal_audits': return (int) ($sec['total'] ?? 0) . ' audit interni (' . (int) ($sec['completed'] ?? 0) . ' completati, ' . (int) ($sec['planned'] ?? 0) . ' pianificati).'; + case 'risks': + $t = $sec['treatments'] ?? []; + return (int) ($sec['total'] ?? 0) . ' rischi censiti; trattamenti: ' . (int) ($t['completed'] ?? 0) . ' completati, ' . (int) ($t['open'] ?? 0) . ' aperti, ' . (int) ($t['overdue'] ?? 0) . ' in ritardo.'; + case 'compliance_score': + $pct = $sec['soa_avg_implementation']; + return 'Avanzamento medio SoA: ' . ($pct !== null ? (int) $pct . '%' : 'n/d') . ' su ' . (int) ($sec['soa_controls_applicable'] ?? 0) . ' controlli applicabili.'; + case 'objectives': return (int) ($sec['count'] ?? 0) . ' obiettivi SGSI definiti.'; + case 'training': return (int) ($sec['completed'] ?? 0) . '/' . (int) ($sec['total'] ?? 0) . ' assegnazioni completate, ' . (int) ($sec['overdue'] ?? 0) . ' in ritardo.'; + case 'stakeholders': return (int) ($sec['total'] ?? 0) . ' attività verso stakeholder (' . (int) ($sec['completed'] ?? 0) . ' completate).'; + case 'normative': return (int) ($sec['pending_count'] ?? 0) . ' aggiornamenti normativi da riscontrare.'; + case 'upcoming_deadlines': return (int) ($sec['count'] ?? 0) . ' scadenze nei prossimi 90 giorni.'; + } + return ''; + } +} diff --git a/application/controllers/ReviewScheduleController.php b/application/controllers/ReviewScheduleController.php index 1e7e50a..aa51101 100644 --- a/application/controllers/ReviewScheduleController.php +++ b/application/controllers/ReviewScheduleController.php @@ -42,7 +42,7 @@ class ReviewScheduleController extends BaseController private const MANAGE_ROLES = ['org_admin', 'compliance_manager']; private const ENTITY_TYPES = [ - 'role', 'skill', 'inventory', 'procedure', 'risk', 'supplier', 'measure', 'custom', 'stakeholder_activity', + 'role', 'skill', 'inventory', 'procedure', 'risk', 'supplier', 'measure', 'custom', 'stakeholder_activity', 'internal_audit', ]; // ═══════════════════════════════════════════════════════════════════════ diff --git a/docs/sql/055_internal_audits.sql b/docs/sql/055_internal_audits.sql new file mode 100644 index 0000000..cca6c2b --- /dev/null +++ b/docs/sql/055_internal_audits.sql @@ -0,0 +1,50 @@ +-- ═══════════════════════════════════════════════════════════════════ +-- NIS2 Agile - Migration 055: Audit interni (ISO 27001 §9.2) +-- ─────────────────────────────────────────────────────────────────── +-- MODULO A del design DESIGN_AUDIT_INTERNI_RIESAME_DIREZIONE.md +-- Ciclo: programma audit -> checklist di conduzione -> esiti -> finding NC. +-- Additivo, runner-safe (CREATE TABLE IF NOT EXISTS + FK inline). +-- I finding NC confluiscono in non_conformities (source='audit', +-- source_entity_type='internal_audit_item'); le evidenze su evidence_files +-- (entity_type='internal_audit'). Nessuna nuova tabella file. +-- ═══════════════════════════════════════════════════════════════════ + +-- Programma / sessioni di audit interno +CREATE TABLE IF NOT EXISTS internal_audits ( + id INT NOT NULL AUTO_INCREMENT, + organization_id INT NOT NULL, + code VARCHAR(20) NULL, + title VARCHAR(255) NOT NULL, + scope TEXT NULL, + criteria TEXT NULL, + planned_date DATE NULL, + executed_date DATE NULL, + status ENUM('planned','in_progress','completed','cancelled') NOT NULL DEFAULT 'planned', + lead_auditor_user_id INT NULL, + lead_auditor_role_id INT NULL, + conclusion TEXT NULL, + created_by INT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_intaud_org (organization_id), + CONSTRAINT fk_intaud_org FOREIGN KEY (organization_id) REFERENCES organizations (id) ON DELETE CASCADE, + CONSTRAINT fk_intaud_lead_user FOREIGN KEY (lead_auditor_user_id) REFERENCES users (id) ON DELETE SET NULL, + CONSTRAINT fk_intaud_lead_role FOREIGN KEY (lead_auditor_role_id) REFERENCES org_roles (id) ON DELETE SET NULL, + CONSTRAINT fk_intaud_creator FOREIGN KEY (created_by) REFERENCES users (id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Voci di checklist (clausole 4-10, controlli Annex A, misure NIS2, custom) +CREATE TABLE IF NOT EXISTS internal_audit_items ( + id INT NOT NULL AUTO_INCREMENT, + audit_id INT NOT NULL, + ref_type ENUM('clause','annex_control','nis2_measure','custom') NOT NULL DEFAULT 'clause', + ref_code VARCHAR(32) NULL, + checkpoint TEXT NOT NULL, + result ENUM('da_verificare','conforme','non_conforme','osservazione','opportunita','non_applicabile') NOT NULL DEFAULT 'da_verificare', + note TEXT NULL, + ord INT NOT NULL DEFAULT 0, + PRIMARY KEY (id), + KEY idx_intauditem_audit (audit_id), + CONSTRAINT fk_intauditem_audit FOREIGN KEY (audit_id) REFERENCES internal_audits (id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/docs/sql/056_management_reviews.sql b/docs/sql/056_management_reviews.sql new file mode 100644 index 0000000..6b0ed99 --- /dev/null +++ b/docs/sql/056_management_reviews.sql @@ -0,0 +1,52 @@ +-- ═══════════════════════════════════════════════════════════════════════════ +-- NIS2 Agile — Migration 056: Riesame di Direzione (ISO 27001 §9.3) +-- ---------------------------------------------------------------------------- +-- Modulo B del design docs/DESIGN_AUDIT_INTERNI_RIESAME_DIREZIONE.md. +-- Produce il VERBALE del riesame periodico del SGSI con INPUT aggregati dai +-- moduli esistenti (snapshot congelato all'approvazione) e OUTPUT = decisioni. +-- +-- RUNNER-SAFE: SOLO CREATE TABLE IF NOT EXISTS con TUTTE le FK inline. +-- Niente ALTER ADD CONSTRAINT, niente DELIMITER o stored-proc, niente +-- separatori statement nei commenti. Idempotente (re-eseguibile). Charset utf8mb4. +-- Applicare con il seeder CLI application/cli/seed_management_reviews.php +-- (stessa connessione PDO dell'app — vedi nota mig.052/053). +-- ═══════════════════════════════════════════════════════════════════════════ + +CREATE TABLE IF NOT EXISTS management_reviews ( + id INT NOT NULL AUTO_INCREMENT, + organization_id INT NOT NULL, + code VARCHAR(20) NULL, + review_date DATE NULL, + period_label VARCHAR(100) NULL, + chair_user_id INT NULL, + attendees JSON NULL, + status ENUM('draft','approved') NOT NULL DEFAULT 'draft', + approved_by INT NULL, + approved_at DATETIME NULL, + snapshot JSON NULL, + conclusions TEXT NULL, + created_by INT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_mgr_org (organization_id), + CONSTRAINT fk_mgr_org FOREIGN KEY (organization_id) REFERENCES organizations (id) ON DELETE CASCADE, + CONSTRAINT fk_mgr_chair FOREIGN KEY (chair_user_id) REFERENCES users (id) ON DELETE SET NULL, + CONSTRAINT fk_mgr_approved FOREIGN KEY (approved_by) REFERENCES users (id) ON DELETE SET NULL, + CONSTRAINT fk_mgr_creator FOREIGN KEY (created_by) REFERENCES users (id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS management_review_decisions ( + id INT NOT NULL AUTO_INCREMENT, + review_id INT NOT NULL, + decision TEXT NOT NULL, + owner_role_id INT NULL, + due_date DATE NULL, + status ENUM('open','in_progress','done') NOT NULL DEFAULT 'open', + capa_id INT NULL, + ord INT NOT NULL DEFAULT 0, + PRIMARY KEY (id), + KEY idx_mrd_review (review_id), + CONSTRAINT fk_mrd_review FOREIGN KEY (review_id) REFERENCES management_reviews (id) ON DELETE CASCADE, + CONSTRAINT fk_mrd_owner FOREIGN KEY (owner_role_id) REFERENCES org_roles (id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/public/_app-bi-demo.html b/public/_app-bi-demo.html index 3751e59..2fb0ea4 100644 --- a/public/_app-bi-demo.html +++ b/public/_app-bi-demo.html @@ -70,9 +70,9 @@ - - - + + + - - - + + + + - + + @@ -165,9 +165,9 @@ bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts'); } - - - + + + - + + @@ -372,9 +372,9 @@ bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts'); } - - - + + + + + + +
+ +
+
+

Calendario scadenze

+
+
+
+ Tutte le scadenze in un solo posto. + Il calendario aggrega in tempo reale ogni scadenza del sistema — incidenti, revisioni policy e controlli, + trattamenti di rischio, non conformita e azioni correttive, formazione, attivita stakeholder, scadenziario revisioni, + audit interni e decisioni del riesame — con stato e link diretto all'elemento. +
+
+ Ancoraggio: buona prassi di monitoraggio scadenze e revisioni periodiche (ISO 9.1/9.3, + NIS2 GV.PO-02 / GV.SC-07 / DE.CM). Strumento di supporto operativo, non un parere legale. +
+ + +
+ + +
+
+ +

—

+ + +
+
+ + +
+
+ + +
+

Filtra per tipo

+
+
+

Filtra per stato

+
+
+
+ + +
+ + + + + +
+
+ + + +
+
+
+ + + + + + + + + + + + + diff --git a/public/companies.html b/public/companies.html index 18316b0..3fa6972 100644 --- a/public/companies.html +++ b/public/companies.html @@ -349,14 +349,14 @@ - - + + - - - + + + - + + - - - + + + diff --git a/public/cross-analysis.html b/public/cross-analysis.html index 65a78ea..99ab4fd 100644 --- a/public/cross-analysis.html +++ b/public/cross-analysis.html @@ -382,8 +382,8 @@ - - + + @@ -393,8 +393,8 @@ bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts'); } - - + + - + + @@ -154,9 +154,9 @@ bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts'); } - - - + + + - + - + + @@ -1152,9 +1152,9 @@ bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts'); } - - - + + + - + + @@ -362,9 +362,9 @@ bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts'); } - - - + + + - + + + + + +
+ +
+
+

Audit interni

+
+
+
+ Audit interni del SGSI (ISO/IEC 27001 §9.2). + Programma gli audit, conduci la checklist (clausole 4-10 ISO 27001 + controlli Annex A applicabili dal tuo SoA), + registra gli esiti riga per riga, apri una non conformità sui punti non conformi e genera il report stampabile. +
+
+ Ancoraggio: ISO/IEC 27001 §9.2 (audit interni) — buona prassi di governance anche per NIS2 + (GV.PO-02, monitoraggio del programma di sicurezza). Strumento di supporto organizzativo, non un parere legale. +
+ +
+
+
+
+
+ + + + + + + + + + + + + + + + diff --git a/public/isms.html b/public/isms.html index 3fa0940..03a484b 100644 --- a/public/isms.html +++ b/public/isms.html @@ -184,8 +184,8 @@ - - + + @@ -195,9 +195,9 @@ bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts'); } - - - + + + diff --git a/public/js/api.js b/public/js/api.js index cd4204c..71427c1 100644 --- a/public/js/api.js +++ b/public/js/api.js @@ -221,6 +221,7 @@ class NIS2API { // ═══════════════════════════════════════════════════════════════════ orgRolesList() { return this._acn(this.get('/org-roles/list')); } orgRolesAssignableUsers() { return this._acn(this.get('/org-roles/assignable-users')); } + orgMembers() { return this._acn(this.get('/organizations/' + (this.orgId || '') + '/members')); } orgRoleGet(id) { return this._acn(this.get(`/org-roles/${id}`)); } orgRoleCreate(data) { return this._acn(this.post('/org-roles/create', data || {})); } orgRoleUpdate(id, data) { return this._acn(this.put(`/org-roles/${id}`, data)); } @@ -333,6 +334,43 @@ class NIS2API { stkActAddComment(id, d) { return this._acn(this.post(`/stakeholder-activities/${id}/comments`, d || {})); } stkActAttachments(id) { return this._acn(this.get(`/stakeholder-activities/${id}/attachments`)); } + // ── Audit interni (Modulo A — ISO 27001 §9.2) ── + intAuditList() { return this._acn(this.get('/internal-audits/list')); } + intAuditGet(id) { return this._acn(this.get(`/internal-audits/${id}`)); } + intAuditCreate(data) { return this._acn(this.post('/internal-audits/create', data || {})); } + intAuditUpdate(id, data) { return this._acn(this.put(`/internal-audits/${id}`, data)); } + intAuditDelete(id) { return this._acn(this.del(`/internal-audits/${id}`)); } + intAuditAddItem(data) { return this._acn(this.post('/internal-audits/items', data || {})); } + intAuditUpdateItem(itemId, data) { return this._acn(this.put(`/internal-audits/items/${itemId}`, data)); } + intAuditRaiseNcr(id, data) { return this._acn(this.post(`/internal-audits/${id}/raise-ncr`, data || {})); } + + // ── Riesame di Direzione (ISO 27001 §9.3, Modulo B) ── + mgmtReviewList() { return this._acn(this.get('/management-reviews/list')); } + mgmtReviewGet(id) { return this._acn(this.get(`/management-reviews/${id}`)); } + mgmtReviewCreate(data) { return this._acn(this.post('/management-reviews/create', data || {})); } + mgmtReviewUpdate(id, data) { return this._acn(this.put(`/management-reviews/${id}`, data || {})); } + mgmtReviewGather() { return this._acn(this.get('/management-reviews/gather')); } + mgmtReviewAddDecision(id, data) { return this._acn(this.post(`/management-reviews/${id}/decisions`, data || {})); } + mgmtReviewUpdateDecision(subId, data) { return this._acn(this.put(`/management-reviews/decisions/${subId}`, data || {})); } + mgmtReviewApprove(id) { return this._acn(this.post(`/management-reviews/${id}/approve`, {})); } + + // ── Calendario unico scadenze (Modulo C) — aggregatore sola lettura ── + calendarEvents(params) { + const qs = new URLSearchParams(); + if (params && params.from) qs.set('from', params.from); + if (params && params.to) qs.set('to', params.to); + if (params && params.types) qs.set('types', Array.isArray(params.types) ? params.types.join(',') : params.types); + const q = qs.toString(); + return this._acn(this.get('/calendar/events' + (q ? '?' + q : ''))); + } + calendarSummary(params) { + const qs = new URLSearchParams(); + if (params && params.from) qs.set('from', params.from); + if (params && params.to) qs.set('to', params.to); + const q = qs.toString(); + return this._acn(this.get('/calendar/summary' + (q ? '?' + q : ''))); + } + // ═══════════════════════════════════════════════════════════════════ // Dashboard // ═══════════════════════════════════════════════════════════════════ diff --git a/public/js/common-bi.js b/public/js/common-bi.js index 333a499..e9e921f 100644 --- a/public/js/common-bi.js +++ b/public/js/common-bi.js @@ -62,6 +62,7 @@ label: 'Principale', i18nKey: 'nav.main', items: [ { name: 'Dashboard', href: 'dashboard.html', icon: iconGrid(), i18nKey: 'nav.dashboard' }, + { name: 'Calendario', href: 'calendario.html', icon: ``, i18nKey: 'nav.calendar' }, { name: 'Compliance Journey', href: 'workflow.html', icon: '' }, { name: 'Gap Analysis', href: 'assessment.html', icon: iconClipboardCheck(), i18nKey: 'nav.gap_analysis' }, { name: 'Misure e Requisiti', href: 'misure-requisiti.html', icon: '', i18nKey: 'nav.framework' }, @@ -90,6 +91,8 @@ items: [ { name: 'Formazione', href: 'training.html', icon: iconAcademicCap(), i18nKey: 'nav.training' }, { name: 'Inventario', href: 'assets.html', icon: iconServer(), i18nKey: 'nav.assets' }, + { name: 'Audit interni', href: 'internal-audits.html', icon: ``, i18nKey: 'nav.internal_audits' }, + { name: 'Riesame di Direzione', href: 'management-review.html', icon: ``, i18nKey: 'nav.management_review' }, { name: 'Audit & Report', href: 'reports.html', icon: iconChartBar(), i18nKey: 'nav.audit' } ] }, diff --git a/public/js/common.js b/public/js/common.js index eb023d6..22f12bb 100644 --- a/public/js/common.js +++ b/public/js/common.js @@ -186,6 +186,7 @@ function loadSidebar() { label: 'Principale', i18nKey: 'nav.main', items: [ { name: 'Dashboard', href: 'dashboard.html', icon: iconGrid(), i18nKey: 'nav.dashboard' }, + { name: 'Calendario', href: 'calendario.html', icon: ``, i18nKey: 'nav.calendar' }, { name: 'Compliance Journey', href: 'workflow.html', icon: `` }, { name: 'Gap Analysis', href: 'assessment.html', icon: iconClipboardCheck(), i18nKey: 'nav.gap_analysis' }, { name: 'Misure e Requisiti', href: 'misure-requisiti.html', icon: ``, i18nKey: 'nav.framework' }, @@ -216,6 +217,8 @@ function loadSidebar() { items: [ { name: 'Formazione', href: 'training.html', icon: iconAcademicCap(), i18nKey: 'nav.training' }, { name: 'Inventario', href: 'assets.html', icon: iconServer(), i18nKey: 'nav.assets' }, + { name: 'Audit interni', href: 'internal-audits.html', icon: ``, i18nKey: 'nav.internal_audits' }, + { name: 'Riesame di Direzione', href: 'management-review.html', icon: ``, i18nKey: 'nav.management_review' }, { name: 'Audit & Report',href: 'reports.html', icon: iconChartBar(), i18nKey: 'nav.audit' }, ] }, diff --git a/public/js/help.js b/public/js/help.js index 8b1efec..aadf19f 100644 --- a/public/js/help.js +++ b/public/js/help.js @@ -400,6 +400,120 @@ const HelpSystem = (function () { 'NOTA: gli obblighi normativi in Italia derivano dal D.Lgs. 138/2024; la matrice e i framework NIST CSF/ACN sono strumenti di supporto, non un parere legale.' ] }, + 'internal-audits': { + title: 'Guida - Audit interni', + intro: 'Il modulo Audit interni gestisce il ciclo degli audit del SGSI previsto dalla clausola 9.2 della ISO/IEC 27001: programmi gli audit, conduci la checklist (clausole 4-10 ISO 27001 + controlli Annex A applicabili dal tuo SoA), registri gli esiti riga per riga, apri una non conformità sui punti non conformi e generi il report stampabile. È uno strumento di supporto organizzativo, non un parere legale.', + sections: [ + { + heading: 'Programma audit', + items: [ + 'Crea un audit indicando titolo, ambito (scope), criteri (es. "ISO/IEC 27001:2022 cl.4-10 + Annex A"), data pianificata, data di esecuzione e auditor capo (un utente dell\'organizzazione oppure un ruolo dell\'organigramma).', + 'Il sistema assegna automaticamente un codice progressivo (AUD-001, AUD-002, …) per organizzazione.', + 'La data pianificata compare automaticamente nel calendario NIS2 (Scadenziario).' + ] + }, + { + heading: 'Checklist di conduzione', + items: [ + 'Alla creazione la checklist viene pre-popolata: le clausole 4-10 della ISO 27001 e i controlli Annex A marcati come applicabili nel tuo SoA (Modello SGSI). Parti già con la checklist, non da foglio bianco.', + 'Per ogni voce scegli l\'esito (Da verificare, Conforme, Non conforme, Osservazione, Opportunità, Non applicabile) e annoti note/evidenze; il salvataggio è immediato.', + 'Puoi aggiungere voci custom non previste dalle clausole o dall\'Annex A.' + ] + }, + { + heading: 'Non conformità e report', + items: [ + 'Sulle voci marcate Non conforme puoi premere Apri NC: il sistema crea una non conformità collegata (sorgente "audit") che confluisce nel modulo NCR/CAPA, dove ne gestisci root cause e azioni correttive.', + 'Il pulsante Report apre un verbale di audit stampabile (HTML) con metadati, sintesi degli esiti, checklist completa e conclusioni: usa "Stampa / Salva PDF" del browser.' + ] + }, + { + heading: 'Riferimenti normativi', + items: [ + 'ISO/IEC 27001:2022 §9.2: l\'organizzazione conduce audit interni a intervalli pianificati per verificare che il SGSI sia conforme ai requisiti e attuato efficacemente.', + 'NIST CSF 2.0 / GV.PO-02: la politica di sicurezza e i programmi di gestione sono rivisti e migliorati nel tempo (buona prassi anche per NIS2).', + 'Le azioni correttive/preventive si gestiscono nel modulo NCR/CAPA.' + ] + } + ], + references: [ + 'ISO/IEC 27001:2022 - Clausola 9.2 Audit interni', + 'ISO/IEC 27001:2022 - Clausole 4-10 e Annex A (controlli del SoA)', + 'NIST CSF 2.0 / GV.PO-02 - Revisione e miglioramento del programma di sicurezza', + 'Gli audit interni e il riesame periodico sono buona prassi di governance; gli obblighi normativi in Italia derivano dal D.Lgs. 138/2024.', + 'NOTA: strumento di supporto organizzativo, non un parere legale.' + ] + }, + 'management-review': { + title: 'Guida - Riesame di Direzione (ISO 27001 §9.3)', + intro: 'Il Riesame di Direzione e il momento in cui la direzione valuta periodicamente l\'idoneita, l\'adeguatezza e l\'efficacia del Sistema di Gestione della Sicurezza delle Informazioni (SGSI). Il modulo produce il VERBALE con gli elementi in ingresso (aggregati automaticamente dai moduli esistenti) e in uscita (le decisioni). E\' uno strumento di SUPPORTO e PRE-AUDIT: non sostituisce l\'auditor ne costituisce una certificazione.', + sections: [ + { + heading: 'Come si compila un verbale', + items: [ + '1. Crea il riesame: indica data, periodo di riferimento (es. "2026 H1"), presidente e partecipanti. Il codice RD-AAAA-NN viene generato automaticamente.', + '2. Aggrega i dati: il pulsante "Aggrega dati" raccoglie gli elementi in ingresso §9.3.2 da tutta la piattaforma (non conformita, audit interni, rischi, SoA, obiettivi, formazione, stakeholder, normativa, scadenze).', + '3. Rivedi e completa: gli input aggregati sono una proposta da rivedere; aggiungi le conclusioni della direzione.', + '4. Registra le decisioni: ogni decisione (elemento in uscita §9.3.3) ha un responsabile (ruolo dell\'organigramma), una scadenza e uno stato. Puo essere collegata a un\'azione CAPA.', + '5. Approva e congela: l\'approvazione (riservata all\'org_admin) salva uno snapshot immutabile degli input al momento del verbale e blocca ogni ulteriore modifica.' + ] + }, + { + heading: 'Elementi in ingresso aggregati automaticamente', + items: [ + 'Le sezioni vengono lette in modo difensivo: se un modulo non e ancora attivo (es. Audit interni) o non ha dati, la sezione mostra "dato non disponibile" senza bloccare le altre.', + 'Tutti i dati sono filtrati per la tua organizzazione e fotografati nello snapshot al momento dell\'approvazione, cosi il verbale resta coerente nel tempo.', + 'Dal verbale puoi aprire la versione stampabile (pulsante "Verbale") da archiviare o presentare in audit.' + ] + } + ], + references: [ + 'ISO/IEC 27001:2022 §9.3 - Riesame di direzione (elementi in ingresso 9.3.2 / in uscita 9.3.3)', + 'D.Lgs. 138/2024 art. 23 - Obblighi di governance degli organi di amministrazione e direttivi (NIS2 GV.PO-02)', + 'NOTA: ISO e best practice non vincolante; gli obblighi italiani derivano da NIS2 / D.Lgs. 138/2024 / Determinazioni ACN.' + ] + }, + 'calendario': { + title: 'Guida - Calendario scadenze', + intro: 'Il Calendario raccoglie in un unico posto ogni scadenza del sistema: incidenti Art.23, revisioni di policy e controlli, trattamenti di rischio, non conformita e azioni correttive, formazione, attivita stakeholder, scadenziario delle revisioni periodiche, audit interni e decisioni del riesame di direzione. E\' un aggregatore in SOLA LETTURA, sempre coerente con i moduli sorgente: ogni scadenza viene calcolata in tempo reale e rimanda direttamente all\'elemento che la genera. E\' uno strumento di supporto operativo, non un parere legale.', + sections: [ + { + heading: 'Le viste', + items: [ + 'Griglia mensile: ogni giorno mostra dei pallini colorati, uno per scadenza. Il colore indica il tipo, mentre la navigazione ‹ / › sposta il mese e "Oggi" torna al mese corrente. Clic su un giorno apre l\'elenco delle sue scadenze.', + 'Lista: tutte le scadenze ordinate per data, con quelle in ritardo in cima. Clic sul nome apre l\'elemento sorgente (incidente, policy, rischio, ...).' + ] + }, + { + heading: 'Stato delle scadenze', + items: [ + 'In ritardo (rosso): la data e\' gia\' passata e la scadenza non risulta assolta.', + 'In scadenza (arancione): mancano 14 giorni o meno.', + 'Futura (blu): oltre 14 giorni.', + 'Assolta (verde): la scadenza e\' stata chiusa/completata nel modulo di origine.' + ] + }, + { + heading: 'Filtri', + items: [ + 'Puoi filtrare per tipo (incidenti, policy, rischi, controlli, NC/CAPA, formazione, stakeholder, revisioni, audit interni, riesame) e per stato, combinando le due dimensioni.', + 'I conteggi in alto (in ritardo / in scadenza / future / assolte) danno il colpo d\'occhio complessivo sul periodo caricato.' + ] + }, + { + heading: 'Da dove arrivano le scadenze', + items: [ + 'Il calendario non duplica i dati: legge le scadenze direttamente dai moduli. Per modificarne una, apri l\'elemento dal link e aggiornala nel suo modulo (es. la data di revisione di una policy si cambia in Policy).', + 'Le scadenze dei moduli Audit interni e Riesame di direzione compaiono automaticamente non appena quei moduli sono attivi.' + ] + } + ], + references: [ + 'ISO/IEC 27001:2022, cl. 9.1 (monitoraggio e misurazione) e 9.3 (riesame di direzione) - buona prassi', + 'NIST CSF 2.0 / GV.PO-02, GV.SC-07, DE.CM (best practice) - revisione periodica e monitoraggio continuo', + 'NOTA: il calendario e\' uno strumento di supporto al monitoraggio delle scadenze; gli obblighi normativi derivano da NIS2 (Dir. UE 2022/2555) e dal D.Lgs. 138/2024.' + ] + }, 'stakeholder-activities': { title: 'Guida - Attività stakeholder', intro: 'Le attività verso gli stakeholder ti permettono di inviare questionari (da compilare o per firma di avvenuta lettura) e pianificare azioni, collegandoli alle procedure e alle misure/requisiti, con data e scadenza sul calendario NIS2. Strumento di supporto organizzativo, non un parere legale.', @@ -1283,6 +1397,12 @@ const HelpSystem = (function () { 'stakeholders.html': 'stakeholders', 'stakeholders': 'stakeholders', 'stakeholder-activities.html': 'stakeholder-activities', + 'internal-audits.html': 'internal-audits', + 'internal-audits': 'internal-audits', + 'management-review.html': 'management-review', + 'management-review': 'management-review', + 'calendario.html': 'calendario', + 'calendario': 'calendario', 'stakeholder-activities': 'stakeholder-activities', 'risks.html': 'risks', 'risks': 'risks', diff --git a/public/js/i18n.js b/public/js/i18n.js index 084a518..7594d35 100644 --- a/public/js/i18n.js +++ b/public/js/i18n.js @@ -76,6 +76,12 @@ const I18n = (function () { 'rev.title': { it: 'Scadenziario', en: 'Review Schedule' }, 'stk.title': { it: 'Stakeholder', en: 'Stakeholders' }, 'sact.title': { it: 'Attività stakeholder', en: 'Stakeholder activities' }, + 'nav.calendar': { it: 'Calendario', en: 'Calendar' }, + 'cal.title': { it: 'Calendario scadenze', en: 'Deadlines calendar' }, + 'nav.internal_audits': { it: 'Audit interni', en: 'Internal audits' }, + 'iaud.title': { it: 'Audit interni', en: 'Internal audits' }, + 'nav.management_review': { it: 'Riesame di Direzione', en: 'Management Review' }, + 'mreview.title': { it: 'Riesame di Direzione', en: 'Management Review' }, 'nav.risks': { it: 'Rischi', en: 'Risks' }, 'nav.incidents': { it: 'Incidenti', en: 'Incidents' }, 'nav.policies': { it: 'Policy', en: 'Policies' }, diff --git a/public/kb.html b/public/kb.html index a8a6082..e5c0a9b 100644 --- a/public/kb.html +++ b/public/kb.html @@ -151,8 +151,8 @@ - - + + @@ -161,9 +161,9 @@ bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts'); } - - - + + + + - - + + + + + +
+ +
+
+

Riesame di Direzione

+
+
+
+ Riesame periodico del SGSI da parte della direzione. + Produce il verbale con gli elementi in ingresso (aggregati automaticamente dai moduli esistenti) e in uscita + (le decisioni: azioni con responsabile e scadenza). Una volta approvato, il verbale viene congelato e reso immutabile. +
+
+ Ancoraggio: ISO/IEC 27001 §9.3 (riesame di direzione) e buona prassi di governance NIS2 (GV.PO-02, + art. 23 D.Lgs. 138/2024). Strumento di supporto e pre-audit: non sostituisce l'auditor. +
+ +
+
+
+
+
+ + + + + + + + + + + + + diff --git a/public/misure-requisiti.html b/public/misure-requisiti.html index f7fdbff..f04d73f 100644 --- a/public/misure-requisiti.html +++ b/public/misure-requisiti.html @@ -6,7 +6,7 @@ Misure e Requisiti - NIS2 Agile - +