diff --git a/application/controllers/ReviewScheduleController.php b/application/controllers/ReviewScheduleController.php new file mode 100644 index 0000000..0a763e6 --- /dev/null +++ b/application/controllers/ReviewScheduleController.php @@ -0,0 +1,491 @@ + entity_type='procedure' + * - compliance_controls.next_review_date -> entity_type='custom' + * - risk_treatments.due_date -> entity_type='risk' (JOIN risks: la + * tabella risk_treatments NON ha organization_id, si filtra via risks) + * + * Ancoraggio normativo (docs/DESIGN_A4_RELATIONAL.md): + * - GV.PO-02: policy/procedure riesaminate periodicamente + * - GV.SC-07: sicurezza fornitori monitorata e rivista + * - PR.AT: formazione/sensibilizzazione ricorrenti + * - DE.CM: monitoraggio continuo + * - art. 24 D.Lgs. 138/2024: misure di gestione del rischio mantenute aggiornate + * Disclaimer: la periodicita' puntuale e' buona prassi dove non fissata da norma; + * strumento di supporto, non un parere legale. + * + * NOTE strutturali (verificate sul codice reale): + * - DB API: Database::query/fetchAll/fetchOne/insert/update/delete/count + * (NON esiste Database::execute). + * - Le AZIONI sono capa_actions (figlie di non_conformities): 4.4 non le usa. + */ + +require_once __DIR__ . '/BaseController.php'; + +class ReviewScheduleController extends BaseController +{ + private const MANAGE_ROLES = ['org_admin', 'compliance_manager']; + + private const ENTITY_TYPES = [ + 'role', 'skill', 'inventory', 'procedure', 'risk', 'supplier', 'measure', 'custom', + ]; + + // ═══════════════════════════════════════════════════════════════════════ + // LETTURA + // ═══════════════════════════════════════════════════════════════════════ + + /** + * GET /api/review-schedule/list + * Elenco scadenze dell'org, ordinate per prossima revisione. Lo stato e' + * ricalcolato LIVE (override del valore in colonna). Include il nome del + * ruolo owner e i contatori di sintesi. + */ + public function list(): void + { + $this->requireOrgAccess(); + $orgId = $this->getCurrentOrgId(); + + $rows = Database::fetchAll( + 'SELECT rs.id, rs.entity_type, rs.entity_id, rs.title, rs.owner_role_id, + rs.frequency_months, rs.last_reviewed_at, rs.next_review_date, + rs.notes, rs.created_at, rs.updated_at, + r.role_name AS owner_role_name + FROM review_schedule rs + LEFT JOIN org_roles r ON r.id = rs.owner_role_id + WHERE rs.organization_id = ? + ORDER BY rs.next_review_date ASC, rs.id ASC', + [$orgId] + ); + + $items = []; + $counts = ['ok' => 0, 'due' => 0, 'overdue' => 0]; + foreach ($rows as $r) { + $status = $this->computeStatus($r['next_review_date']); + $counts[$status]++; + $items[] = [ + 'id' => (int) $r['id'], + 'entity_type' => $r['entity_type'], + 'entity_id' => $r['entity_id'] !== null ? (int) $r['entity_id'] : null, + 'title' => $r['title'], + 'owner_role_id' => $r['owner_role_id'] !== null ? (int) $r['owner_role_id'] : null, + 'owner_role_name' => $r['owner_role_name'], + 'frequency_months' => $r['frequency_months'] !== null ? (int) $r['frequency_months'] : null, + 'last_reviewed_at' => $r['last_reviewed_at'], + 'next_review_date' => $r['next_review_date'], + 'status' => $status, + 'notes' => $r['notes'], + 'created_at' => $r['created_at'], + 'updated_at' => $r['updated_at'], + ]; + } + + $this->jsonSuccess([ + 'items' => $items, + 'total' => count($items), + 'ok' => $counts['ok'], + 'due' => $counts['due'], + 'overdue' => $counts['overdue'], + ]); + } + + // ═══════════════════════════════════════════════════════════════════════ + // SCRITTURA + // ═══════════════════════════════════════════════════════════════════════ + + /** + * POST /api/review-schedule/create + * Body: {title*, next_review_date*, entity_type?, entity_id?, owner_role_id?, + * frequency_months?, notes?} + */ + public function create(): void + { + $this->requireOrgRole(self::MANAGE_ROLES); + $this->validateRequired(['title', 'next_review_date']); + $orgId = $this->getCurrentOrgId(); + + $type = $this->validateEntityType($this->getParam('entity_type', 'custom')); + $nextDate = $this->validateDate($this->getParam('next_review_date')); + $ownerId = $this->resolveOwnerRoleId($this->getParam('owner_role_id')); + $freq = $this->nullablePositiveInt($this->getParam('frequency_months')); + $entityId = $this->nullableInt($this->getParam('entity_id')); + + $id = Database::insert('review_schedule', [ + 'organization_id' => $orgId, + 'entity_type' => $type, + 'entity_id' => $entityId, + 'title' => trim((string) $this->getParam('title')), + 'owner_role_id' => $ownerId, + 'frequency_months' => $freq, + 'last_reviewed_at' => null, + 'next_review_date' => $nextDate, + 'status' => $this->computeStatus($nextDate), + 'notes' => $this->nullableText($this->getParam('notes')), + 'created_by' => $this->getCurrentUserId(), + ]); + + $this->logAudit('review_schedule_created', 'review_schedule', $id, [ + 'entity_type' => $type, 'next_review_date' => $nextDate, + ]); + $this->jsonSuccess(['id' => $id], 'Voce di scadenziario creata', 201); + } + + /** + * PUT /api/review-schedule/{id} + * Aggiorna solo i campi presenti nel body. Ricalcola lo stato se cambia la + * prossima revisione. + */ + public function update(int $id): void + { + $this->requireOrgRole(self::MANAGE_ROLES); + $orgId = $this->getCurrentOrgId(); + + $row = $this->fetchEntryOrFail($id, $orgId); + + $updates = []; + if ($this->hasParam('title')) { + $title = trim((string) $this->getParam('title')); + if ($title === '') { + $this->jsonError('Il titolo non puo essere vuoto', 422, 'INVALID_TITLE'); + } + $updates['title'] = $title; + } + if ($this->hasParam('entity_type')) { + $updates['entity_type'] = $this->validateEntityType($this->getParam('entity_type')); + } + if ($this->hasParam('entity_id')) { + $updates['entity_id'] = $this->nullableInt($this->getParam('entity_id')); + } + if ($this->hasParam('owner_role_id')) { + $updates['owner_role_id'] = $this->resolveOwnerRoleId($this->getParam('owner_role_id')); + } + if ($this->hasParam('frequency_months')) { + $updates['frequency_months'] = $this->nullablePositiveInt($this->getParam('frequency_months')); + } + if ($this->hasParam('notes')) { + $updates['notes'] = $this->nullableText($this->getParam('notes')); + } + if ($this->hasParam('next_review_date')) { + $nextDate = $this->validateDate($this->getParam('next_review_date')); + $updates['next_review_date'] = $nextDate; + $updates['status'] = $this->computeStatus($nextDate); + } + + if (empty($updates)) { + $this->jsonError('Nessun campo da aggiornare', 400, 'NO_FIELDS'); + } + + Database::update('review_schedule', $updates, 'id = ? AND organization_id = ?', [$id, $orgId]); + + $this->logAudit('review_schedule_updated', 'review_schedule', $id, array_keys($updates)); + $this->jsonSuccess(['id' => $id], 'Voce di scadenziario aggiornata'); + } + + /** + * DELETE /api/review-schedule/{id} + */ + public function delete(int $id): void + { + $this->requireOrgRole(self::MANAGE_ROLES); + $orgId = $this->getCurrentOrgId(); + + $deleted = Database::delete('review_schedule', 'id = ? AND organization_id = ?', [$id, $orgId]); + if ($deleted === 0) { + $this->jsonError('Voce di scadenziario non trovata', 404, 'REVIEW_NOT_FOUND'); + } + + $this->logAudit('review_schedule_deleted', 'review_schedule', $id, null); + $this->jsonSuccess(null, 'Voce di scadenziario eliminata'); + } + + /** + * POST /api/review-schedule/{id}/complete + * Segna la voce come revisionata oggi. Se ha una frequenza, avanza la + * prossima revisione di frequency_months a partire da oggi. + */ + public function complete(int $id): void + { + $this->requireOrgRole(self::MANAGE_ROLES); + $orgId = $this->getCurrentOrgId(); + + $row = $this->fetchEntryOrFail($id, $orgId); + + $today = date('Y-m-d'); + $updates = ['last_reviewed_at' => $today]; + + $freq = $row['frequency_months'] !== null ? (int) $row['frequency_months'] : null; + if ($freq !== null && $freq > 0) { + $nextDate = date('Y-m-d', strtotime("+{$freq} months", strtotime($today))); + $updates['next_review_date'] = $nextDate; + $updates['status'] = $this->computeStatus($nextDate); + } else { + // Nessuna cadenza: la prossima revisione resta invariata, ricalcolo lo stato. + $updates['status'] = $this->computeStatus($row['next_review_date']); + $nextDate = $row['next_review_date']; + } + + Database::update('review_schedule', $updates, 'id = ? AND organization_id = ?', [$id, $orgId]); + + $this->logAudit('review_schedule_completed', 'review_schedule', $id, [ + 'last_reviewed_at' => $today, 'next_review_date' => $nextDate, + ]); + $this->jsonSuccess([ + 'id' => $id, + 'last_reviewed_at' => $today, + 'next_review_date' => $nextDate, + 'status' => $updates['status'], + ], 'Revisione registrata'); + } + + /** + * POST /api/review-schedule/sync + * Upsert idempotente dalle scadenze gia' presenti (policy / controlli / + * trattamenti rischio), tutte org-scoped. Anti-dup su (entity_type, entity_id): + * se la voce esiste e la data sorgente e' cambiata la aggiorna, altrimenti + * crea. Le voci create a mano (entity_type='custom', entity_id NULL) non + * vengono toccate. + */ + public function sync(): void + { + $this->requireOrgRole(self::MANAGE_ROLES); + $orgId = $this->getCurrentOrgId(); + $userId = $this->getCurrentUserId(); + + $created = 0; + $updated = 0; + $skipped = 0; + + // 1. Procedure (policies) con next_review_date impostata. + $policies = Database::fetchAll( + 'SELECT id, title, next_review_date + FROM policies + WHERE organization_id = ? AND next_review_date IS NOT NULL AND deleted_at IS NULL', + [$orgId] + ); + foreach ($policies as $p) { + $this->upsertSyncEntry( + $orgId, $userId, 'procedure', (int) $p['id'], + (string) $p['title'], (string) $p['next_review_date'], + 'Sincronizzato da procedura/policy', + $created, $updated, $skipped + ); + } + + // 2. Controlli di compliance con next_review_date impostata (entity_type='custom'). + $controls = Database::fetchAll( + 'SELECT id, control_code, title, next_review_date + FROM compliance_controls + WHERE organization_id = ? AND next_review_date IS NOT NULL', + [$orgId] + ); + foreach ($controls as $c) { + $label = trim(((string) ($c['control_code'] ?? '')) . ' — ' . ((string) ($c['title'] ?? ''))); + $label = trim($label, ' —'); + if ($label === '') { + $label = 'Controllo #' . (int) $c['id']; + } + $this->upsertSyncEntry( + $orgId, $userId, 'custom', (int) $c['id'], + $label, (string) $c['next_review_date'], + 'Sincronizzato da controllo di compliance', + $created, $updated, $skipped + ); + } + + // 3. Trattamenti rischio (risk_treatments.due_date) — org via JOIN su risks + // (risk_treatments NON ha organization_id). + $treatments = Database::fetchAll( + 'SELECT rt.id, rt.due_date, r.title + FROM risk_treatments rt + JOIN risks r ON r.id = rt.risk_id + WHERE r.organization_id = ? AND rt.due_date IS NOT NULL AND r.deleted_at IS NULL', + [$orgId] + ); + foreach ($treatments as $t) { + $title = (string) ($t['title'] ?? ''); + if ($title === '') { + $title = 'Rischio (trattamento #' . (int) $t['id'] . ')'; + } + $this->upsertSyncEntry( + $orgId, $userId, 'risk', (int) $t['id'], + $title, (string) $t['due_date'], + 'Sincronizzato da trattamento del rischio', + $created, $updated, $skipped + ); + } + + $this->logAudit('review_schedule_synced', 'review_schedule', null, [ + 'created' => $created, 'updated' => $updated, 'skipped' => $skipped, + ]); + $this->jsonSuccess([ + 'created' => $created, + 'updated' => $updated, + 'skipped' => $skipped, + ], 'Sincronizzazione completata'); + } + + // ═══════════════════════════════════════════════════════════════════════ + // PRIVATI + // ═══════════════════════════════════════════════════════════════════════ + + /** + * Stato calcolato LIVE: scaduto (overdue) se la data e' passata; in scadenza + * (due) se entro 30 giorni; altrimenti ok. Confronto a granularita' giorno. + */ + private function computeStatus(string $nextDate): string + { + $next = strtotime($nextDate); + $today = strtotime(date('Y-m-d')); + if ($next === false) { + return 'ok'; + } + if ($next < $today) { + return 'overdue'; + } + if ($next <= strtotime('+30 days', $today)) { + return 'due'; + } + return 'ok'; + } + + /** Voce di scadenziario org-scoped oppure 404 (anti-IDOR). */ + private function fetchEntryOrFail(int $id, int $orgId): array + { + $row = Database::fetchOne( + 'SELECT * FROM review_schedule WHERE id = ? AND organization_id = ?', + [$id, $orgId] + ); + if (!$row) { + $this->jsonError('Voce di scadenziario non trovata', 404, 'REVIEW_NOT_FOUND'); + } + return $row; + } + + /** + * owner_role_id opzionale: NULL/'' => null; altrimenti il ruolo deve + * appartenere all'org corrente (anti-IDOR), 404 se non trovato. + */ + private function resolveOwnerRoleId($raw): ?int + { + if ($raw === null || $raw === '' || (int) $raw === 0) { + return null; + } + $roleId = (int) $raw; + $exists = Database::count( + 'org_roles', 'id = ? AND organization_id = ?', [$roleId, $this->getCurrentOrgId()] + ); + if ($exists === 0) { + $this->jsonError('Ruolo owner non trovato in questa organizzazione', 404, 'ROLE_NOT_FOUND'); + } + return $roleId; + } + + private function validateEntityType($t): string + { + $t = strtolower((string) $t); + if ($t === '') { + return 'custom'; + } + if (!in_array($t, self::ENTITY_TYPES, true)) { + $this->jsonError('Tipo entita non valido', 422, 'INVALID_ENTITY_TYPE'); + } + return $t; + } + + /** Valida data in formato Y-m-d (rifiuta valori non-data o impossibili). */ + private function validateDate($raw): string + { + $d = trim((string) $raw); + $dt = DateTime::createFromFormat('Y-m-d', $d); + if (!$dt || $dt->format('Y-m-d') !== $d) { + $this->jsonError('Data non valida (atteso formato AAAA-MM-GG)', 422, 'INVALID_DATE'); + } + return $d; + } + + private function nullableInt($v): ?int + { + if ($v === null || $v === '') { + return null; + } + return (int) $v; + } + + private function nullablePositiveInt($v): ?int + { + if ($v === null || $v === '') { + return null; + } + $i = (int) $v; + return $i > 0 ? $i : null; + } + + private function nullableText($v): ?string + { + if ($v === null) { + return null; + } + $s = trim((string) $v); + return $s === '' ? null : $s; + } + + /** + * Upsert idempotente di una voce di sync su (entity_type, entity_id): + * - non esiste -> insert (status calcolato, owner/frequenza null) + * - esiste con data diversa -> update next_review_date + status + * - esiste con stessa data -> skip + */ + private function upsertSyncEntry( + int $orgId, ?int $userId, string $type, int $entityId, + string $title, string $nextDate, string $note, + int &$created, int &$updated, int &$skipped + ): void { + $existing = Database::fetchOne( + 'SELECT id, next_review_date FROM review_schedule + WHERE organization_id = ? AND entity_type = ? AND entity_id = ?', + [$orgId, $type, $entityId] + ); + + if ($existing) { + if ((string) $existing['next_review_date'] !== $nextDate) { + Database::update('review_schedule', [ + 'next_review_date' => $nextDate, + 'status' => $this->computeStatus($nextDate), + ], 'id = ? AND organization_id = ?', [(int) $existing['id'], $orgId]); + $updated++; + } else { + $skipped++; + } + return; + } + + Database::insert('review_schedule', [ + 'organization_id' => $orgId, + 'entity_type' => $type, + 'entity_id' => $entityId, + 'title' => mb_substr($title, 0, 255), + 'owner_role_id' => null, + 'frequency_months' => null, + 'last_reviewed_at' => null, + 'next_review_date' => $nextDate, + 'status' => $this->computeStatus($nextDate), + 'notes' => $note, + 'created_by' => $userId, + ]); + $created++; + } +} diff --git a/application/controllers/SupplyChainController.php b/application/controllers/SupplyChainController.php index 7b5e697..68c3359 100644 --- a/application/controllers/SupplyChainController.php +++ b/application/controllers/SupplyChainController.php @@ -29,6 +29,7 @@ class SupplyChainController extends BaseController $supplierId = Database::insert('suppliers', [ 'organization_id' => $this->getCurrentOrgId(), 'name' => trim($this->getParam('name')), + 'stakeholder_type' => $this->validateStakeholderType($this->getParam('stakeholder_type', 'supplier')), 'vat_number' => $this->getParam('vat_number'), 'contact_email' => $this->getParam('contact_email'), 'contact_name' => $this->getParam('contact_name'), @@ -76,6 +77,11 @@ class SupplyChainController extends BaseController } } + // stakeholder_type (GV.SC-02): gestito a parte per validare l'enum + if ($this->hasParam('stakeholder_type')) { + $updates['stakeholder_type'] = $this->validateStakeholderType($this->getParam('stakeholder_type')); + } + if (!empty($updates)) { Database::update('suppliers', $updates, 'id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()]); $this->logAudit('supplier_updated', 'supplier', $id, $updates); @@ -146,6 +152,58 @@ class SupplyChainController extends BaseController ]); } + // ══════════════════════════════════════════════════════════════════════ + // STAKEHOLDER ESTESI (A4 Fase 4.5) — GV.SC-02: fornitori + clienti + partner + // ══════════════════════════════════════════════════════════════════════ + + /** + * GET /api/supply-chain/stakeholder-map — mappa di sintesi degli stakeholder + * dell'org raggruppati per tipo (supplier/customer/partner). RIUSA la tabella + * suppliers (nessun duplicato): il dettaglio fornitori resta nel modulo Supply + * Chain. Ancoraggio GV.SC-02 (ruoli/responsabilita verso fornitori, clienti E + * partner) + GV.SC-04/05/07. Org-scoped, esclude i soft-deleted. + */ + public function stakeholderMap(): void + { + $this->requireOrgAccess(); + $orgId = $this->getCurrentOrgId(); + + $rows = Database::fetchAll( + 'SELECT id, name, stakeholder_type, service_type, criticality, status, + contact_email, contact_name, risk_score + FROM suppliers + WHERE organization_id = ? AND deleted_at IS NULL + ORDER BY stakeholder_type, criticality DESC, name', + [$orgId] + ); + + $groups = ['supplier' => [], 'customer' => [], 'partner' => []]; + foreach ($rows as $r) { + $t = $r['stakeholder_type'] ?? 'supplier'; + if (!isset($groups[$t])) { + $t = 'supplier'; + } + $groups[$t][] = $r; + } + + $this->jsonSuccess([ + 'groups' => $groups, + 'counts' => [ + 'supplier' => count($groups['supplier']), + 'customer' => count($groups['customer']), + 'partner' => count($groups['partner']), + ], + 'total' => count($rows), + ]); + } + + /** Normalizza/valida il tipo stakeholder (default 'supplier' se non valido). */ + private function validateStakeholderType($t): string + { + $t = strtolower((string) $t); + return in_array($t, ['supplier', 'customer', 'partner'], true) ? $t : 'supplier'; + } + private function calculateSupplierRiskScore(array $responses): int { if (empty($responses)) return 0; diff --git a/docs/sql/044_review_schedule.sql b/docs/sql/044_review_schedule.sql new file mode 100644 index 0000000..dcc06a0 --- /dev/null +++ b/docs/sql/044_review_schedule.sql @@ -0,0 +1,50 @@ +-- ============================================================================ +-- Migration 044 — A4 Fase 4.4: Scadenziario centralizzato delle revisioni. +-- ---------------------------------------------------------------------------- +-- 1 tabella ADDITIVA. Idempotente (CREATE TABLE IF NOT EXISTS). Applicare con la +-- STESSA connessione PDO dell'app (container nis2-db via TCP+TLS), via runner +-- scripts/migrate-a4.php. Runner-safe: solo CREATE TABLE, niente DELIMITER / +-- stored procedure, nessun ';' dentro commenti o stringhe. +-- +-- Cosa fa: raccoglie in un unico registro le scadenze di revisione periodica +-- (ruoli, competenze, inventario, procedure, rischi, fornitori, misure, voci +-- custom). Lo stato (ok/due/overdue) e' calcolato LIVE dal controller in base a +-- next_review_date e NON ci si affida alla colonna persistita. +-- +-- Ancoraggio normativo (docs/DESIGN_A4_RELATIONAL.md): +-- - GV.PO-02 (NIST CSF 2.0): le policy/procedure sono riesaminate periodicamente +-- - GV.SC-07 (NIST CSF 2.0): la sicurezza dei fornitori e' monitorata/rivista +-- - PR.AT: formazione e sensibilizzazione ricorrenti +-- - DE.CM: monitoraggio continuo +-- - art. 24 D.Lgs. 138/2024: misure di gestione del rischio mantenute aggiornate +-- Disclaimer: la periodicita' puntuale e' buona prassi dove non fissata da norma; +-- e' uno strumento di supporto, non un parere legale. +-- +-- Multi-tenancy: organization_id NOT NULL (FK organizations, ON DELETE CASCADE). +-- owner_role_id -> org_roles (mig.041) ON DELETE SET NULL. created_by -> users +-- ON DELETE SET NULL. UNIQUE (org, entity_type, entity_id) abilita l'upsert +-- idempotente di sync() (le voci custom con entity_id NULL non collidono in MySQL). +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS review_schedule ( + id INT AUTO_INCREMENT PRIMARY KEY, + organization_id INT NOT NULL, + entity_type ENUM('role','skill','inventory','procedure','risk','supplier','measure','custom') NOT NULL, + entity_id INT NULL, + title VARCHAR(255) NOT NULL, + owner_role_id INT NULL, + frequency_months INT NULL, + last_reviewed_at DATE NULL, + next_review_date DATE NOT NULL, + status ENUM('ok','due','overdue') NOT NULL DEFAULT 'ok', + notes TEXT NULL, + created_by INT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE, + FOREIGN KEY (owner_role_id) REFERENCES org_roles(id) ON DELETE SET NULL, + FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL, + UNIQUE KEY uk_rs_entity (organization_id, entity_type, entity_id), + INDEX idx_rs_org_next (organization_id, next_review_date), + INDEX idx_rs_entity (organization_id, entity_type, entity_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/docs/sql/045_stakeholders.sql b/docs/sql/045_stakeholders.sql new file mode 100644 index 0000000..5fccc7f --- /dev/null +++ b/docs/sql/045_stakeholders.sql @@ -0,0 +1,42 @@ +-- ============================================================================ +-- Migration 045 — A4 Fase 4.5: Stakeholder estesi (GV.SC-02) +-- ---------------------------------------------------------------------------- +-- ADDITIVO: aggiunge la colonna suppliers.stakeholder_type per distinguere +-- FORNITORI, CLIENTI e PARTNER. RIUSA la tabella suppliers esistente (NESSUNA +-- nuova tabella, NESSUNA duplicazione del modulo Supply Chain): la pagina +-- "Stakeholder" e' solo una vista di sintesi raggruppata per tipo. +-- +-- Ancoraggio normativo (docs/DESIGN_A4_RELATIONAL.md): +-- - GV.SC-02 (NIST CSF 2.0): ruoli e responsabilita per fornitori, CLIENTI e +-- PARTNER stabiliti, comunicati e coordinati internamente ed esternamente. +-- - GV.SC-04/05/07: catalogazione, requisiti contrattuali e revisione periodica +-- degli stakeholder della supply chain. +-- - Gli OBBLIGHI fanno capo al D.Lgs. 138/2024 (governance art. 23, rischio +-- art. 24): NON alla Direttiva. La tassonomia degli stakeholder e' buona prassi. +-- +-- Multi-tenancy: suppliers.organization_id resta il discriminante tenant. L'indice +-- (organization_id, stakeholder_type) supporta la mappa raggruppata per tipo. +-- +-- IDEMPOTENZA / RUNNER: +-- MySQL 8 Ubuntu NON supporta "ADD COLUMN IF NOT EXISTS". Questo file contiene +-- SOLO statement nudi (niente DELIMITER, niente stored procedure, nessun ';' +-- dentro i commenti) ed e' percio' RUNNER-SAFE: scripts/migrate-a4.php esegue un +-- pre-check su information_schema PRIMA di questa ALTER (stesso pattern di +-- mig.040_onboarding_classification.sql) e la salta se colonna/indice esistono +-- gia'. Eseguire SEMPRE tramite il runner: +-- docker exec nis2-app php /var/www/nis2-agile/scripts/migrate-a4.php +-- NB: NON applicare questo file con un client che fa "split su ';'" SENZA il +-- pre-check del runner, altrimenti una seconda esecuzione fallirebbe (colonna +-- duplicata). Il runner garantisce l'idempotenza. +-- ============================================================================ + +ALTER TABLE suppliers + ADD COLUMN stakeholder_type ENUM('supplier','customer','partner') NOT NULL DEFAULT 'supplier' + COMMENT 'GV.SC-02: tipo di stakeholder (fornitore/cliente/partner)' AFTER name; + +ALTER TABLE suppliers + ADD INDEX idx_suppliers_stakeholder (organization_id, stakeholder_type); + +-- ROLLBACK (manuale): +-- ALTER TABLE suppliers DROP INDEX idx_suppliers_stakeholder +-- ALTER TABLE suppliers DROP COLUMN stakeholder_type diff --git a/public/_app-bi-demo.html b/public/_app-bi-demo.html index 6078be7..28ef554 100644 --- a/public/_app-bi-demo.html +++ b/public/_app-bi-demo.html @@ -70,8 +70,8 @@ - - + + - + + @@ -164,8 +164,8 @@ } - - + + - + + @@ -329,7 +329,7 @@ } - + - + + @@ -159,7 +159,7 @@ } - + - + + @@ -181,7 +181,7 @@ } - + - - - + + + + - + + @@ -166,8 +166,8 @@ } - - + + - + + @@ -378,8 +378,8 @@ } - - + + - + + - - + + - + + - - + + diff --git a/public/cross-analysis.html b/public/cross-analysis.html index 48094d7..94c53d9 100644 --- a/public/cross-analysis.html +++ b/public/cross-analysis.html @@ -382,8 +382,8 @@ - - + + @@ -394,7 +394,7 @@ } - + - + + @@ -155,8 +155,8 @@ } - - + + - + - + + @@ -1153,8 +1153,8 @@ } - - + + - + + @@ -363,8 +363,8 @@ } - - + + - + + - + + @@ -196,8 +196,8 @@ } - - + + diff --git a/public/js/api.js b/public/js/api.js index 093fcaf..bff3563 100644 --- a/public/js/api.js +++ b/public/js/api.js @@ -270,6 +270,33 @@ class NIS2API { raciLink(d) { return this._acn(this.post('/raci/link', d)); } raciUnlink(d) { return this._acn(this.del('/raci/link?link_type=' + encodeURIComponent(d.link_type) + '&a_id=' + encodeURIComponent(d.a_id) + '&b_id=' + encodeURIComponent(d.b_id))); } + // ═══════════════════════════════════════════════════════════════════ + // Scadenziario centralizzato (A4 Fase 4.4) — review_schedule. _acn. + // Revisioni periodiche (GV.PO-02/GV.SC-07/PR.AT/DE.CM). Status calcolato live. + // DELETE per {id} numerico (no body). sync = import idempotente da scadenze esistenti. + // Alias rs* === rev* (stessa firma) per compatibilità di naming. + // ═══════════════════════════════════════════════════════════════════ + revList() { return this._acn(this.get('/review-schedule/list')); } + revCreate(d) { return this._acn(this.post('/review-schedule/create', d || {})); } + revUpdate(id, d) { return this._acn(this.put(`/review-schedule/${id}`, d)); } + revDelete(id) { return this._acn(this.del(`/review-schedule/${id}`)); } + revComplete(id) { return this._acn(this.post(`/review-schedule/${id}/complete`, {})); } + revSync() { return this._acn(this.post('/review-schedule/sync', {})); } + rsList() { return this.revList(); } + rsCreate(d) { return this.revCreate(d); } + rsUpdate(id, d) { return this.revUpdate(id, d); } + rsDelete(id) { return this.revDelete(id); } + rsComplete(id) { return this.revComplete(id); } + rsSync() { return this.revSync(); } + + // ═══════════════════════════════════════════════════════════════════ + // Stakeholder estesi (A4 Fase 4.5) — riusa suppliers (GV.SC-02). + // stakeholderMap = vista di sintesi raggruppata (supplier/customer/partner). _acn. + // Per aggiungere/etichettare clienti/partner riusare createSupplier/updateSupplier + // passando `stakeholder_type` nel data (NB: NON sono _acn → gestire r.success). + // ═══════════════════════════════════════════════════════════════════ + stakeholderMap() { return this._acn(this.get('/supply-chain/stakeholder-map')); } + // ═══════════════════════════════════════════════════════════════════ // Dashboard // ═══════════════════════════════════════════════════════════════════ diff --git a/public/js/common.js b/public/js/common.js index 2bdbb9f..2bfcb12 100644 --- a/public/js/common.js +++ b/public/js/common.js @@ -198,10 +198,12 @@ function loadSidebar() { { name: 'Organigramma', href: 'organigramma.html', icon: ``, i18nKey: 'nav.org_chart' }, { name: 'Competenze', href: 'competenze.html', icon: ``, i18nKey: 'nav.competences' }, { name: 'Matrice RACI', href: 'raci.html', icon: ``, i18nKey: 'nav.raci' }, + { name: 'Scadenziario', href: 'review-schedule.html', icon: ``, i18nKey: 'nav.review_schedule' }, { name: 'Rischi', href: 'risks.html', icon: iconShieldExclamation(), i18nKey: 'nav.risks' }, { name: 'Incidenti', href: 'incidents.html', icon: iconBell(), i18nKey: 'nav.incidents' }, { name: 'Policy', href: 'policies.html', icon: iconDocumentText(), i18nKey: 'nav.policies' }, { name: 'Supply Chain', href: 'supply-chain.html', icon: iconLink(), i18nKey: 'nav.supply_chain' }, + { name: 'Stakeholder', href: 'stakeholders.html', icon: ``, i18nKey: 'nav.stakeholders' }, { name: 'Segnalazioni', href: 'whistleblowing.html', icon: `` }, { name: 'Normative', href: 'normative.html', icon: `` }, { name: 'AI Cross-Analysis', href: 'cross-analysis.html', icon: `` }, diff --git a/public/js/help.js b/public/js/help.js index 96ffbb5..77a3491 100644 --- a/public/js/help.js +++ b/public/js/help.js @@ -317,6 +317,90 @@ const HelpSystem = (function () { ] }, + // ─── Scadenziario centralizzato (A4 Fase 4.4) ───────────────── + 'review-schedule': { + title: 'Guida - Scadenziario', + intro: 'Lo Scadenziario raccoglie in un unico posto le revisioni periodiche di policy, controlli, trattamenti del rischio, ruoli, competenze, inventario, fornitori e misure. Per ciascuna voce indica chi è il responsabile (ruolo), ogni quanto va rivista e quando scade la prossima revisione, segnalandone lo stato. È uno strumento di supporto organizzativo, non un parere legale.', + sections: [ + { + heading: 'Stati e badge', + items: [ + 'OK (verde): la prossima revisione è oltre i 30 giorni.', + 'In scadenza (arancione): la prossima revisione cade entro 30 giorni.', + 'Scaduto (rosso): la data di prossima revisione è già passata.', + 'Lo stato è ricalcolato automaticamente in base alla data odierna: non serve aggiornarlo a mano.' + ] + }, + { + heading: 'Come si usa', + items: [ + 'Nuova scadenza: crei una voce indicando titolo, tipo di entità, ruolo responsabile, frequenza (in mesi) e data della prossima revisione.', + 'Segna revisionato: registra la revisione di oggi; se è impostata una frequenza, la prossima data avanza automaticamente.', + 'Sincronizza scadenze esistenti: importa (senza duplicare) le scadenze già presenti nel sistema — riesami delle policy, dei controlli e le date dei trattamenti del rischio della tua organizzazione.' + ] + }, + { + heading: 'Riferimenti normativi', + items: [ + 'GV.PO-02: le politiche di cybersicurezza sono riviste periodicamente e aggiornate.', + 'GV.SC-07: i rischi e i rapporti con i fornitori sono monitorati e rivisti nel tempo.', + 'PR.AT: la formazione e consapevolezza è mantenuta e ripetuta nel tempo.', + 'DE.CM: il monitoraggio continuo presuppone controlli rivisti con regolarità.', + 'In Italia gli obblighi fanno capo al D.Lgs. 138/2024 (in particolare l\'art. 24 sulla gestione del rischio). La periodicità puntuale, dove non fissata dalla norma, è buona prassi.' + ] + } + ], + references: [ + 'NIST CSF 2.0 / GV.PO-02 - Politiche riviste e aggiornate periodicamente', + 'NIST CSF 2.0 / GV.SC-07 - Monitoraggio e revisione dei fornitori', + 'NIST CSF 2.0 / PR.AT - Formazione e consapevolezza mantenute nel tempo', + 'NIST CSF 2.0 / DE.CM - Monitoraggio continuo', + 'Obblighi: art. 24 D.Lgs. 138/2024 (gestione del rischio)', + 'NOTA: gli obblighi normativi in Italia derivano dal D.Lgs. 138/2024; lo scadenziario e i framework NIST CSF/ACN sono strumenti di supporto. La periodicità puntuale, dove non fissata, è buona prassi, non un parere legale.' + ] + }, + + // ─── Stakeholder estesi (A4 Fase 4.5) ───────────────────────── + 'stakeholders': { + title: 'Guida - Stakeholder', + intro: 'La Mappa Stakeholder è una vista di sintesi delle parti interessate dell\'organizzazione, raggruppate in fornitori, clienti e partner. Riusa i dati del modulo Supply Chain (nessuna duplicazione): il dettaglio e la valutazione dei fornitori restano lì, mentre qui puoi avere il quadro d\'insieme e aggiungere o etichettare clienti e partner. È uno strumento di supporto organizzativo, non un parere legale.', + sections: [ + { + heading: 'I tre gruppi', + items: [ + 'Fornitori: chi fornisce beni o servizi (inclusi i fornitori ICT). Il loro dettaglio e la valutazione di sicurezza si gestiscono nel modulo Supply Chain.', + 'Clienti: le parti a cui l\'organizzazione eroga servizi rilevanti per la continuità.', + 'Partner: soggetti con cui esistono accordi o collaborazioni rilevanti per la sicurezza.' + ] + }, + { + heading: 'Come si usa', + items: [ + 'Aggiungi stakeholder: registri nome, tipo (fornitore/cliente/partner), tipo di servizio o relazione, contatto e criticità.', + 'Per i fornitori, usa il modulo Supply Chain per il dettaglio completo, i questionari e la valutazione del rischio (qui niente duplicati).', + 'La vista raggruppata aiuta a documentare ruoli e responsabilità verso tutte le parti interessate, non solo i fornitori.' + ] + }, + { + heading: 'Riferimenti normativi', + items: [ + 'GV.SC-02: ruoli e responsabilità per la sicurezza della catena di fornitura sono stabiliti verso fornitori, clienti e partner.', + 'GV.SC-04: i fornitori sono noti e prioritizzati in base alla criticità (l\'estensione a clienti e partner è coperta da GV.SC-02).', + 'GV.SC-05: i requisiti di sicurezza sono integrati nei contratti e negli accordi con i fornitori e le altre terze parti rilevanti.', + 'GV.SC-07: i rischi e i rapporti con le parti interessate sono monitorati e rivisti nel tempo.' + ] + } + ], + references: [ + 'NIST CSF 2.0 / GV.SC-02 - Ruoli e responsabilità verso fornitori, clienti e partner', + 'NIST CSF 2.0 / GV.SC-04 - Fornitori noti e prioritizzati per criticità (clienti/partner: GV.SC-02)', + 'NIST CSF 2.0 / GV.SC-05 - Requisiti di sicurezza negli accordi', + 'NIST CSF 2.0 / GV.SC-07 - Monitoraggio e revisione delle parti interessate', + 'Obblighi: la gestione della supply chain è prevista dall\'art. 24 del D.Lgs. 138/2024.', + 'NOTA: gli obblighi normativi in Italia derivano dal D.Lgs. 138/2024; la mappa stakeholder e i framework NIST CSF/ACN sono strumenti di supporto, non un parere legale.' + ] + }, + // ─── Risk Management ───────────────────────────────────────── 'risks': { title: 'Guida - Gestione Rischi', @@ -1161,6 +1245,10 @@ const HelpSystem = (function () { 'competenze': 'competences', 'raci.html': 'raci', 'raci': 'raci', + 'review-schedule.html': 'review-schedule', + 'review-schedule': 'review-schedule', + 'stakeholders.html': 'stakeholders', + 'stakeholders': 'stakeholders', 'risks.html': 'risks', 'risks': 'risks', 'incidents.html': 'incidents', diff --git a/public/js/i18n.js b/public/js/i18n.js index 3a883cb..5482da4 100644 --- a/public/js/i18n.js +++ b/public/js/i18n.js @@ -86,6 +86,10 @@ const I18n = (function () { 'org.new_role': { it: 'Nuovo ruolo', en: 'New role' }, 'comp.title': { it: 'Competenze', en: 'Skills' }, 'raci.title': { it: 'Matrice RACI', en: 'RACI Matrix' }, + 'nav.review_schedule': { it: 'Scadenziario', en: 'Review schedule' }, + 'nav.stakeholders': { it: 'Stakeholder', en: 'Stakeholders' }, + 'rev.title': { it: 'Scadenziario', en: 'Review Schedule' }, + 'stk.title': { it: 'Stakeholder', en: 'Stakeholders' }, '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 2db9144..d05aa8c 100644 --- a/public/kb.html +++ b/public/kb.html @@ -151,8 +151,8 @@ - - + + @@ -162,8 +162,8 @@ } - - + + + - - + + + diff --git a/public/normative.html b/public/normative.html index f932c6a..1914db6 100644 --- a/public/normative.html +++ b/public/normative.html @@ -112,8 +112,8 @@ - - + + @@ -124,8 +124,8 @@ } - - + + - - + + - + + - - + + diff --git a/public/policies.html b/public/policies.html index b18d9ce..dc991f4 100644 --- a/public/policies.html +++ b/public/policies.html @@ -333,8 +333,8 @@ - - + + @@ -345,8 +345,8 @@ } - - + + - + + - - + + diff --git a/public/register.html b/public/register.html index fd5133a..b9cf213 100644 --- a/public/register.html +++ b/public/register.html @@ -268,8 +268,8 @@ - - + + - + + @@ -455,8 +455,8 @@ } - - + + + + + +
+ +
+
+

Scadenziario

+
+ +
+
+ +
+
+ Revisioni e scadenze in un unico posto. + Tieni traccia delle revisioni periodiche di ruoli, competenze, inventario, procedure, rischi, fornitori e misure. Ogni voce mostra quando è prevista la prossima revisione e ti avvisa quando è in scadenza o scaduta. +
+
+ Ancoraggio: GV.PO-02 (riesame periodico delle policy), GV.SC-07 (revisione dei fornitori), PR.AT, DE.CM (monitoraggio continuo). Gli obblighi di gestione del rischio fanno capo all'art. 24 D.Lgs. 138/2024. La periodicità puntuale è una buona prassi dove non fissata da norma o contratto. Strumento di supporto, non un parere legale. +
+ + + +
+ + + + + +
+ +
+
+
+
+ + + + + + + + + + + + + diff --git a/public/risks.html b/public/risks.html index 713bf30..5a90533 100644 --- a/public/risks.html +++ b/public/risks.html @@ -494,8 +494,8 @@ - - + + @@ -506,8 +506,8 @@ } - - + + - + + @@ -684,8 +684,8 @@ } - - + + - + + + + + +
+ +
+
+

Stakeholder

+
+ +
+
+ +
+
+ Mappa degli stakeholder. + Vista di sintesi di fornitori, clienti e partner della tua organizzazione, raggruppati per tipo. Riusa i dati della Supply Chain: il dettaglio e la valutazione dei fornitori restano nel modulo dedicato. +
+
+ Ancoraggio: GV.SC-02 (ruoli e responsabilità definiti verso fornitori, clienti e partner) con GV.SC-04/05/07. Gli obblighi di gestione del rischio della catena di fornitura fanno capo all'art. 24 D.Lgs. 138/2024. Strumento di supporto, non un parere legale. +
+ + + +
+
+
+
+ + + + + + + + + + + + + diff --git a/public/supply-chain.html b/public/supply-chain.html index deed64a..3c41208 100644 --- a/public/supply-chain.html +++ b/public/supply-chain.html @@ -477,8 +477,8 @@ - - + + @@ -489,8 +489,8 @@ } - - + + - + + @@ -304,8 +304,8 @@ } - - + + - + + @@ -219,8 +219,8 @@ } - - + + - - + + +