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 @@
}
-
-
+
+
-
+
+
-
-
+
+
-
+
+
-
-
+
+