diff --git a/application/cli/seed_stakeholders.php b/application/cli/seed_stakeholders.php
new file mode 100644
index 0000000..01507e1
--- /dev/null
+++ b/application/cli/seed_stakeholders.php
@@ -0,0 +1,151 @@
+exec("SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci");
+
+// --- DDL (idempotente, FK dentro il CREATE; allineato a docs/sql/051_*.sql) ---
+$ddl = [
+"CREATE TABLE IF NOT EXISTS cfg_stakeholder_types (
+ code VARCHAR(16) NOT NULL, organization_id INT NULL,
+ tipo ENUM('Interno','Esterno') NOT NULL, descr TEXT NOT NULL, ord INT NOT NULL DEFAULT 0,
+ created_by INT NULL,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ PRIMARY KEY (code), KEY idx_cfg_stk_type_org (organization_id),
+ CONSTRAINT fk_cfg_stk_type_org FOREIGN KEY (organization_id) REFERENCES organizations (id) ON DELETE CASCADE,
+ CONSTRAINT fk_cfg_stk_type_user 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 cfg_stakeholder_quadrants (
+ code VARCHAR(4) NOT NULL, organization_id INT NULL,
+ label VARCHAR(64) NOT NULL, strategy TEXT NULL,
+ power_min TINYINT NOT NULL, power_max TINYINT NOT NULL,
+ interest_min TINYINT NOT NULL, interest_max TINYINT NOT NULL, ord INT NOT NULL DEFAULT 0,
+ PRIMARY KEY (code), KEY idx_cfg_stk_quad_org (organization_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
+
+"CREATE TABLE IF NOT EXISTS stakeholders (
+ id INT NOT NULL AUTO_INCREMENT, organization_id INT NOT NULL,
+ stak_code VARCHAR(16) NOT NULL, name VARCHAR(255) NOT NULL,
+ org_role_id INT NULL, supplier_id INT NULL,
+ power TINYINT NULL, interest TINYINT NULL,
+ contact_name VARCHAR(255) NULL, contact_email VARCHAR(255) NULL, notes 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_stk_org (organization_id), KEY idx_stk_org_code (organization_id, stak_code),
+ KEY idx_stk_role (org_role_id), KEY idx_stk_supplier (supplier_id),
+ CONSTRAINT fk_stk_org FOREIGN KEY (organization_id) REFERENCES organizations (id) ON DELETE CASCADE,
+ CONSTRAINT fk_stk_code FOREIGN KEY (stak_code) REFERENCES cfg_stakeholder_types (code),
+ CONSTRAINT fk_stk_role FOREIGN KEY (org_role_id) REFERENCES org_roles (id) ON DELETE SET NULL,
+ CONSTRAINT fk_stk_supplier FOREIGN KEY (supplier_id) REFERENCES suppliers (id) ON DELETE SET NULL,
+ CONSTRAINT fk_stk_user 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 stakeholder_procedures (
+ stakeholder_id INT NOT NULL, policy_id INT NOT NULL,
+ PRIMARY KEY (stakeholder_id, policy_id), KEY idx_stk_proc_policy (policy_id),
+ CONSTRAINT fk_stk_proc_stk FOREIGN KEY (stakeholder_id) REFERENCES stakeholders (id) ON DELETE CASCADE,
+ CONSTRAINT fk_stk_proc_policy FOREIGN KEY (policy_id) REFERENCES policies (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; } }
+}
+
+// --- Dati di sistema ---
+$seedPath = __DIR__ . '/../data/nis2_framework_seed.json';
+$seed = json_decode(@file_get_contents($seedPath), true);
+if (!$seed || empty($seed['stakeholder_types'])) {
+ fwrite(STDERR, "SEED non leggibile o privo di stakeholder_types: $seedPath\n");
+ exit(1);
+}
+
+// 4 quadranti di sistema — matrice di Mendelow (x=interesse, y=potere).
+// REFUSO corretto: Q4 (basso-sinistra, Monitorare) ha potere 0-2 (non 3-5).
+$quadrants = [
+ // code, label, strategy, power_min, power_max, interest_min, interest_max, ord
+ ['Q1', 'Tenere soddisfatti (Keep Satisfied)',
+ 'Stakeholder istituzionali: forte potere di blocco, scarso interesse quotidiano. Tenerli soddisfatti ed evitare scontenti improvvisi.',
+ 3, 5, 0, 2, 1],
+ ['Q2', 'Gestire attivamente (Manage Closely)',
+ 'Stakeholder chiave: decisori principali, supporto vitale. Gestione attiva e coinvolgimento costante.',
+ 3, 5, 3, 5, 2],
+ ['Q3', 'Tenere informati (Keep Informed)',
+ 'Stakeholder operativi: molto coinvolti ma scarso peso decisionale. Tenerli informati.',
+ 0, 2, 3, 5, 3],
+ ['Q4', 'Monitorare (Monitor)',
+ 'Stakeholder marginali: basso potere e basso interesse. Monitoraggio costante col minimo sforzo, comunicazioni periodiche non dispendiose.',
+ 0, 2, 0, 2, 4],
+];
+
+$pdo->beginTransaction();
+try {
+ $stQ = $pdo->prepare(
+ "INSERT INTO cfg_stakeholder_quadrants
+ (code,label,strategy,power_min,power_max,interest_min,interest_max,ord,organization_id)
+ VALUES (?,?,?,?,?,?,?,?,NULL)
+ ON DUPLICATE KEY UPDATE label=VALUES(label),strategy=VALUES(strategy),
+ power_min=VALUES(power_min),power_max=VALUES(power_max),
+ interest_min=VALUES(interest_min),interest_max=VALUES(interest_max),ord=VALUES(ord)"
+ );
+ foreach ($quadrants as $q) { $stQ->execute($q); }
+
+ // 30 tipi di sistema: code PK, organization_id NULL, tipo Interno/Esterno, descr verbatim.
+ $stT = $pdo->prepare(
+ "INSERT INTO cfg_stakeholder_types (code,organization_id,tipo,descr,ord)
+ VALUES (?,NULL,?,?,?)
+ ON DUPLICATE KEY UPDATE tipo=VALUES(tipo),descr=VALUES(descr),ord=VALUES(ord)"
+ );
+ $ord = 0;
+ foreach ($seed['stakeholder_types'] as $t) {
+ $ord++;
+ $tipo = ($t['tipo'] === 'Interno') ? 'Interno' : 'Esterno';
+ $stT->execute([$t['code'], $tipo, $t['descr'], $ord]);
+ }
+ $pdo->commit();
+} catch (Throwable $e) {
+ $pdo->rollBack();
+ fwrite(STDERR, "SEED FALLITO: " . $e->getMessage() . "\n");
+ exit(1);
+}
+
+$counts = [
+ 'quadranti' => (int) $pdo->query("SELECT COUNT(*) FROM cfg_stakeholder_quadrants WHERE organization_id IS NULL")->fetchColumn(),
+ 'tipi_sistema' => (int) $pdo->query("SELECT COUNT(*) FROM cfg_stakeholder_types WHERE organization_id IS NULL")->fetchColumn(),
+ 'tipi_interni' => (int) $pdo->query("SELECT COUNT(*) FROM cfg_stakeholder_types WHERE organization_id IS NULL AND tipo='Interno'")->fetchColumn(),
+ 'tipi_esterni' => (int) $pdo->query("SELECT COUNT(*) FROM cfg_stakeholder_types WHERE organization_id IS NULL AND tipo='Esterno'")->fetchColumn(),
+];
+echo "OK seed-stakeholders — " . json_encode($counts, JSON_UNESCAPED_UNICODE) . "\n";
diff --git a/application/controllers/StakeholderController.php b/application/controllers/StakeholderController.php
new file mode 100644
index 0000000..5ed09f0
--- /dev/null
+++ b/application/controllers/StakeholderController.php
@@ -0,0 +1,479 @@
+ art. 24 D.Lgs. 138/2024). La matrice di Mendelow
+ * (potere/interesse) e' BUONA PRASSI, NON un obbligo NIS2.
+ *
+ * Multi-tenancy: ogni query filtra organization_id. Scritture: org_admin /
+ * compliance_manager. Anti-IDOR su tutti i link (org_role_id/supplier_id/policy_id/
+ * stak_code) verificati appartenere all'org corrente (o di sistema per i tipi).
+ *
+ * NOTE strutturali: DB API Database::query/fetchAll/fetchOne/insert/update/delete
+ * (NON Database::execute). jsonSuccess/jsonError fanno exit.
+ */
+
+require_once __DIR__ . '/BaseController.php';
+
+class StakeholderController extends BaseController
+{
+ private const MANAGE_ROLES = ['org_admin', 'compliance_manager'];
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // LETTURE
+ // ─────────────────────────────────────────────────────────────────────────
+
+ /**
+ * GET /api/stakeholders/list
+ * Registro dell'org + quadrante calcolato + procedure collegate + i 4 quadranti
+ * (per disegnare gli assi anche quando non ci sono stakeholder).
+ */
+ public function list(): void
+ {
+ $this->requireOrgAccess();
+ $orgId = $this->getCurrentOrgId();
+
+ $quads = $this->loadQuadrants();
+
+ $rows = Database::fetchAll(
+ 'SELECT s.id, s.stak_code, t.tipo, t.descr AS type_descr, s.name,
+ s.org_role_id, r.role_name AS org_role_name,
+ s.supplier_id, sup.name AS supplier_name,
+ s.power, s.interest, s.contact_name, s.contact_email, s.notes,
+ s.created_at, s.updated_at
+ FROM stakeholders s
+ JOIN cfg_stakeholder_types t ON t.code = s.stak_code
+ LEFT JOIN org_roles r ON r.id = s.org_role_id
+ LEFT JOIN suppliers sup ON sup.id = s.supplier_id
+ WHERE s.organization_id = ?
+ ORDER BY t.tipo ASC, s.stak_code ASC, s.name ASC',
+ [$orgId]
+ );
+
+ // Procedure collegate (m2m) per tutti gli stakeholder in un colpo solo
+ $procMap = [];
+ if ($rows) {
+ $ids = array_map(static fn($r) => (int) $r['id'], $rows);
+ $place = implode(',', array_fill(0, count($ids), '?'));
+ foreach (Database::fetchAll(
+ "SELECT sp.stakeholder_id, sp.policy_id, p.title AS policy_title
+ FROM stakeholder_procedures sp
+ JOIN policies p ON p.id = sp.policy_id
+ WHERE sp.stakeholder_id IN ($place)",
+ $ids
+ ) as $lp) {
+ $sid = (int) $lp['stakeholder_id'];
+ $procMap[$sid][] = ['id' => (int) $lp['policy_id'], 'title' => $lp['policy_title']];
+ }
+ }
+
+ $stakeholders = [];
+ foreach ($rows as $r) {
+ $power = $r['power'] !== null ? (int) $r['power'] : null;
+ $interest = $r['interest'] !== null ? (int) $r['interest'] : null;
+ $rated = ($power !== null && $interest !== null);
+ $quad = $rated ? $this->quadrantFor($quads, $power, $interest) : null;
+ $sid = (int) $r['id'];
+ $procs = $procMap[$sid] ?? [];
+
+ $stakeholders[] = [
+ 'id' => $sid,
+ 'stak_code' => $r['stak_code'],
+ 'tipo' => $r['tipo'],
+ 'kind' => $r['tipo'] === 'Interno' ? 'internal' : 'external',
+ 'type_descr' => $r['type_descr'],
+ 'name' => $r['name'],
+ 'org_role_id' => $r['org_role_id'] !== null ? (int) $r['org_role_id'] : null,
+ 'org_role_name' => $r['org_role_name'],
+ 'supplier_id' => $r['supplier_id'] !== null ? (int) $r['supplier_id'] : null,
+ 'supplier_name' => $r['supplier_name'],
+ 'power' => $power,
+ 'interest' => $interest,
+ 'rated' => $rated,
+ 'quadrant_code' => $quad['code'] ?? null,
+ 'quadrant_label' => $quad['label'] ?? null,
+ 'contact_name' => $r['contact_name'],
+ 'contact_email' => $r['contact_email'],
+ 'notes' => $r['notes'],
+ 'policies' => $procs,
+ 'policy_ids' => array_map(static fn($p) => $p['id'], $procs),
+ 'updated_at' => $r['updated_at'],
+ ];
+ }
+
+ $this->jsonSuccess([
+ 'stakeholders' => $stakeholders,
+ 'quadrants' => $quads,
+ 'total' => count($stakeholders),
+ 'mendelow_note'=> 'La matrice di Mendelow (potere/interesse) e\' una buona prassi di gestione degli stakeholder, non un obbligo NIS2. L\'obbligo e\' GV.SC-02 (ruoli e responsabilita verso fornitori, clienti e partner).',
+ ]);
+ }
+
+ /**
+ * GET /api/stakeholders/types
+ * Tipi visibili all'org: 30 di sistema (organization_id NULL) + quelli aggiunti
+ * dall'org. La codifica di sistema NON e' modificabile dall'utente.
+ */
+ public function types(): void
+ {
+ $this->requireOrgAccess();
+ $orgId = $this->getCurrentOrgId();
+ $rows = Database::fetchAll(
+ 'SELECT code, tipo, descr, (organization_id IS NULL) AS is_default, ord
+ FROM cfg_stakeholder_types
+ WHERE organization_id IS NULL OR organization_id = ?
+ ORDER BY ord ASC, code ASC',
+ [$orgId]
+ );
+ $types = array_map(static fn($t) => [
+ 'code' => $t['code'],
+ 'tipo' => $t['tipo'],
+ 'descr' => $t['descr'],
+ 'is_default' => ((int) $t['is_default'] === 1),
+ ], $rows);
+ $this->jsonSuccess(['types' => $types]);
+ }
+
+ /**
+ * GET /api/stakeholders/quadrants — i 4 quadranti di sistema (per gli assi).
+ */
+ public function quadrants(): void
+ {
+ $this->requireOrgAccess();
+ $this->jsonSuccess(['quadrants' => $this->loadQuadrants()]);
+ }
+
+ /**
+ * GET /api/stakeholders/pickers — opzioni leggere per i selettori del form:
+ * ruoli dell'organigramma (interni) + procedure (m2m) + fornitori (link esterni).
+ */
+ public function pickers(): void
+ {
+ $this->requireOrgAccess();
+ $orgId = $this->getCurrentOrgId();
+ $roles = Database::fetchAll(
+ 'SELECT id, role_name FROM org_roles WHERE organization_id = ? ORDER BY sort_order ASC, role_name ASC',
+ [$orgId]
+ );
+ $policies = Database::fetchAll(
+ 'SELECT id, title FROM policies WHERE organization_id = ? AND deleted_at IS NULL ORDER BY title ASC',
+ [$orgId]
+ );
+ $suppliers = Database::fetchAll(
+ 'SELECT id, name, stakeholder_type FROM suppliers WHERE organization_id = ? AND deleted_at IS NULL ORDER BY name ASC',
+ [$orgId]
+ );
+ $this->jsonSuccess([
+ 'org_roles' => array_map(static fn($r) => ['id' => (int) $r['id'], 'role_name' => $r['role_name']], $roles),
+ 'policies' => array_map(static fn($p) => ['id' => (int) $p['id'], 'title' => $p['title']], $policies),
+ 'suppliers' => array_map(static fn($s) => ['id' => (int) $s['id'], 'name' => $s['name'], 'stakeholder_type' => $s['stakeholder_type']], $suppliers),
+ ]);
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // SCRITTURE
+ // ─────────────────────────────────────────────────────────────────────────
+
+ /**
+ * POST /api/stakeholders/create
+ * Body: {stak_code*, name*, org_role_id?, supplier_id?, power?, interest?,
+ * contact_name?, contact_email?, notes?, policy_ids?:[]}
+ */
+ public function create(): void
+ {
+ $this->requireOrgRole(self::MANAGE_ROLES);
+ $orgId = $this->getCurrentOrgId();
+ $body = $this->getJsonBody();
+
+ $code = trim((string) ($body['stak_code'] ?? ''));
+ $name = trim((string) ($body['name'] ?? ''));
+ if ($code === '') { $this->jsonError('Tipo stakeholder (stak_code) obbligatorio', 422, 'MISSING_TYPE'); }
+ if ($name === '' || mb_strlen($name) > 255) { $this->jsonError('Nome obbligatorio (max 255 caratteri)', 422, 'INVALID_NAME'); }
+
+ $tipo = $this->resolveTypeTipo($code, $orgId); // 422 se non visibile
+
+ $orgRoleId = $this->validateOrgRoleForKind($body['org_role_id'] ?? null, $tipo, $orgId);
+ $supplierId = $this->validateSupplierForKind($body['supplier_id'] ?? null, $tipo, $orgId);
+ $power = $this->validateScore($body['power'] ?? null, 'power');
+ $interest = $this->validateScore($body['interest'] ?? null, 'interest');
+ $policyIds = $this->validatePolicyIds($body['policy_ids'] ?? null, $orgId);
+
+ // dup soft: stesso tipo + stesso nome nell'org
+ $dup = Database::fetchOne(
+ 'SELECT id FROM stakeholders WHERE organization_id = ? AND stak_code = ? AND name = ?',
+ [$orgId, $code, $name]
+ );
+ if ($dup) { $this->jsonError('Stakeholder gia\' presente con questo tipo e nome', 409, 'DUPLICATE'); }
+
+ $id = Database::insert('stakeholders', [
+ 'organization_id' => $orgId,
+ 'stak_code' => $code,
+ 'name' => $name,
+ 'org_role_id' => $orgRoleId,
+ 'supplier_id' => $supplierId,
+ 'power' => $power,
+ 'interest' => $interest,
+ 'contact_name' => $this->nullableStr($body['contact_name'] ?? null, 255),
+ 'contact_email' => $this->nullableStr($body['contact_email'] ?? null, 255),
+ 'notes' => $this->nullableStr($body['notes'] ?? null),
+ 'created_by' => $this->getCurrentUserId(),
+ ]);
+
+ $this->syncPolicies((int) $id, $policyIds);
+ $this->logAudit('stakeholder_created', 'stakeholder', (int) $id, ['stak_code' => $code, 'name' => $name]);
+
+ $this->jsonSuccess(['id' => (int) $id], 'Stakeholder creato', 201);
+ }
+
+ /**
+ * PUT /api/stakeholders/{id} — update parziale.
+ */
+ public function update(int $id): void
+ {
+ $this->requireOrgRole(self::MANAGE_ROLES);
+ $orgId = $this->getCurrentOrgId();
+ $body = $this->getJsonBody();
+
+ $existing = Database::fetchOne(
+ 'SELECT id, stak_code FROM stakeholders WHERE id = ? AND organization_id = ?',
+ [$id, $orgId]
+ );
+ if (!$existing) { $this->jsonError('Stakeholder non trovato', 404, 'NOT_FOUND'); }
+
+ // Il tipo (e quindi il "kind") puo' cambiare: determina quello effettivo
+ $code = $existing['stak_code'];
+ if ($this->hasParam('stak_code')) {
+ $code = trim((string) ($body['stak_code'] ?? ''));
+ if ($code === '') { $this->jsonError('Tipo stakeholder non valido', 422, 'INVALID_TYPE'); }
+ }
+ $tipo = $this->resolveTypeTipo($code, $orgId);
+
+ $updates = [];
+ if ($this->hasParam('stak_code')) { $updates['stak_code'] = $code; }
+ if ($this->hasParam('name')) {
+ $name = trim((string) ($body['name'] ?? ''));
+ if ($name === '' || mb_strlen($name) > 255) { $this->jsonError('Nome non valido (max 255 caratteri)', 422, 'INVALID_NAME'); }
+ $updates['name'] = $name;
+ }
+ // Link coerenti col kind: se cambia il tipo verso Interno azzero supplier (e viceversa)
+ if ($this->hasParam('org_role_id') || $tipo === 'Esterno') {
+ $updates['org_role_id'] = $this->validateOrgRoleForKind($body['org_role_id'] ?? null, $tipo, $orgId);
+ }
+ if ($this->hasParam('supplier_id') || $tipo === 'Interno') {
+ $updates['supplier_id'] = $this->validateSupplierForKind($body['supplier_id'] ?? null, $tipo, $orgId);
+ }
+ if ($this->hasParam('power')) { $updates['power'] = $this->validateScore($body['power'] ?? null, 'power'); }
+ if ($this->hasParam('interest')) { $updates['interest'] = $this->validateScore($body['interest'] ?? null, 'interest'); }
+ if ($this->hasParam('contact_name')) { $updates['contact_name'] = $this->nullableStr($body['contact_name'] ?? null, 255); }
+ if ($this->hasParam('contact_email')) { $updates['contact_email'] = $this->nullableStr($body['contact_email'] ?? null, 255); }
+ if ($this->hasParam('notes')) { $updates['notes'] = $this->nullableStr($body['notes'] ?? null); }
+
+ if (!empty($updates)) {
+ Database::update('stakeholders', $updates, 'id = ? AND organization_id = ?', [$id, $orgId]);
+ }
+ if ($this->hasParam('policy_ids')) {
+ $this->syncPolicies($id, $this->validatePolicyIds($body['policy_ids'] ?? null, $orgId));
+ }
+
+ $this->logAudit('stakeholder_updated', 'stakeholder', $id, array_keys($updates));
+ $this->jsonSuccess(['id' => $id, 'updated' => array_keys($updates)], 'Stakeholder aggiornato');
+ }
+
+ /**
+ * DELETE /api/stakeholders/{id}
+ */
+ public function delete(int $id): void
+ {
+ $this->requireOrgRole(['org_admin']);
+ $deleted = Database::delete('stakeholders', 'id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()]);
+ if ($deleted === 0) { $this->jsonError('Stakeholder non trovato', 404, 'NOT_FOUND'); }
+ $this->logAudit('stakeholder_deleted', 'stakeholder', $id);
+ $this->jsonSuccess(null, 'Stakeholder eliminato');
+ }
+
+ /**
+ * POST /api/stakeholders/types — aggiunge un TIPO org-scoped, codifica
+ * automatica proseguendo da Stak.31 (codice globalmente univoco: e' PK).
+ * Body: {tipo*, descr*}
+ */
+ public function addType(): void
+ {
+ $this->requireOrgRole(self::MANAGE_ROLES);
+ $orgId = $this->getCurrentOrgId();
+ $body = $this->getJsonBody();
+
+ $tipo = ($body['tipo'] ?? '') === 'Interno' ? 'Interno' : (($body['tipo'] ?? '') === 'Esterno' ? 'Esterno' : '');
+ $descr = trim((string) ($body['descr'] ?? ''));
+ if ($tipo === '') { $this->jsonError('Tipo deve essere "Interno" o "Esterno"', 422, 'INVALID_TIPO'); }
+ if ($descr === '') { $this->jsonError('Descrizione obbligatoria', 422, 'INVALID_DESCR'); }
+
+ // Codice successivo: MAX suffisso numerico tra TUTTI i codici Stak.NN (sistema + org).
+ $next = $this->nextStakCode();
+
+ try {
+ Database::insert('cfg_stakeholder_types', [
+ 'code' => $next,
+ 'organization_id' => $orgId,
+ 'tipo' => $tipo,
+ 'descr' => $descr,
+ 'ord' => 1000, // le voci org si ordinano dopo le 30 di sistema
+ 'created_by' => $this->getCurrentUserId(),
+ ]);
+ } catch (PDOException $e) {
+ // 1062 = race su PK duplicata: ritenta una volta con il codice ricalcolato
+ if (($e->errorInfo[1] ?? 0) === 1062) {
+ $next = $this->nextStakCode();
+ Database::insert('cfg_stakeholder_types', [
+ 'code' => $next, 'organization_id' => $orgId, 'tipo' => $tipo,
+ 'descr' => $descr, 'ord' => 1000, 'created_by' => $this->getCurrentUserId(),
+ ]);
+ } else { throw $e; }
+ }
+
+ $this->logAudit('stakeholder_type_added', 'cfg_stakeholder_types', null, ['code' => $next, 'tipo' => $tipo]);
+ $this->jsonSuccess(['code' => $next, 'tipo' => $tipo, 'descr' => $descr], 'Tipo stakeholder aggiunto', 201);
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // HELPER
+ // ─────────────────────────────────────────────────────────────────────────
+
+ /** Carica i 4 quadranti di sistema (ordinati). */
+ private function loadQuadrants(): array
+ {
+ $rows = Database::fetchAll(
+ 'SELECT code, label, strategy, power_min, power_max, interest_min, interest_max, ord
+ FROM cfg_stakeholder_quadrants WHERE organization_id IS NULL ORDER BY ord ASC, code ASC'
+ );
+ return array_map(static fn($q) => [
+ 'code' => $q['code'],
+ 'label' => $q['label'],
+ 'strategy' => $q['strategy'],
+ 'power_min' => (int) $q['power_min'],
+ 'power_max' => (int) $q['power_max'],
+ 'interest_min' => (int) $q['interest_min'],
+ 'interest_max' => (int) $q['interest_max'],
+ ], $rows);
+ }
+
+ /** Trova il quadrante che contiene (power, interest) dai range di config. */
+ private function quadrantFor(array $quads, int $power, int $interest): ?array
+ {
+ foreach ($quads as $q) {
+ if ($power >= $q['power_min'] && $power <= $q['power_max']
+ && $interest >= $q['interest_min'] && $interest <= $q['interest_max']) {
+ return $q;
+ }
+ }
+ return null;
+ }
+
+ /** Verifica che il tipo sia visibile all'org (sistema o proprio) e ne ritorna il "tipo". */
+ private function resolveTypeTipo(string $code, int $orgId): string
+ {
+ $row = Database::fetchOne(
+ 'SELECT tipo FROM cfg_stakeholder_types WHERE code = ? AND (organization_id IS NULL OR organization_id = ?)',
+ [$code, $orgId]
+ );
+ if (!$row) { $this->jsonError('Tipo stakeholder inesistente o non accessibile', 422, 'INVALID_TYPE'); }
+ return $row['tipo'];
+ }
+
+ /** org_role consentito solo agli INTERNI; deve appartenere all'org (anti-IDOR). */
+ private function validateOrgRoleForKind($id, string $tipo, int $orgId): ?int
+ {
+ $id = ($id === null || $id === '') ? null : (int) $id;
+ if ($id === null) { return null; }
+ if ($tipo !== 'Interno') {
+ $this->jsonError('Il collegamento all\'organigramma e\' previsto solo per stakeholder interni', 422, 'ROLE_NOT_ALLOWED');
+ }
+ $row = Database::fetchOne('SELECT id FROM org_roles WHERE id = ? AND organization_id = ?', [$id, $orgId]);
+ if (!$row) { $this->jsonError('Ruolo dell\'organigramma non valido', 422, 'INVALID_ORG_ROLE'); }
+ return $id;
+ }
+
+ /** supplier consentito solo agli ESTERNI; deve appartenere all'org (anti-IDOR). */
+ private function validateSupplierForKind($id, string $tipo, int $orgId): ?int
+ {
+ $id = ($id === null || $id === '') ? null : (int) $id;
+ if ($id === null) { return null; }
+ if ($tipo !== 'Esterno') {
+ $this->jsonError('Il collegamento a un fornitore e\' previsto solo per stakeholder esterni', 422, 'SUPPLIER_NOT_ALLOWED');
+ }
+ $row = Database::fetchOne('SELECT id FROM suppliers WHERE id = ? AND organization_id = ?', [$id, $orgId]);
+ if (!$row) { $this->jsonError('Fornitore collegato non valido', 422, 'INVALID_SUPPLIER'); }
+ return $id;
+ }
+
+ /** Punteggio 0-5 o null (assente / vuoto = non valutato). */
+ private function validateScore($v, string $field): ?int
+ {
+ if ($v === null || $v === '') { return null; }
+ if (!is_numeric($v)) { $this->jsonError("Valore $field non numerico", 422, 'INVALID_SCORE'); }
+ $n = (int) $v;
+ if ($n < 0 || $n > 5) { $this->jsonError("Il valore $field deve essere tra 0 e 5", 422, 'SCORE_OUT_OF_RANGE'); }
+ return $n;
+ }
+
+ /** Normalizza array di policy_id e verifica che TUTTE appartengano all'org (anti-IDOR). */
+ private function validatePolicyIds($raw, int $orgId): array
+ {
+ if ($raw === null) { return []; }
+ if (!is_array($raw)) { $this->jsonError('policy_ids deve essere un array', 422, 'INVALID_POLICIES'); }
+ $ids = array_values(array_unique(array_filter(array_map('intval', $raw), static fn($i) => $i > 0)));
+ if (!$ids) { return []; }
+ $place = implode(',', array_fill(0, count($ids), '?'));
+ $rows = Database::fetchAll(
+ "SELECT id FROM policies WHERE id IN ($place) AND organization_id = ? AND deleted_at IS NULL",
+ array_merge($ids, [$orgId])
+ );
+ if (count($rows) !== count($ids)) {
+ $this->jsonError('Una o piu\' procedure collegate non sono valide', 422, 'INVALID_POLICIES');
+ }
+ return $ids;
+ }
+
+ /** Sostituisce le procedure collegate (m2m) per uno stakeholder. */
+ private function syncPolicies(int $stakeholderId, array $policyIds): void
+ {
+ Database::delete('stakeholder_procedures', 'stakeholder_id = ?', [$stakeholderId]);
+ foreach ($policyIds as $pid) {
+ Database::insert('stakeholder_procedures', ['stakeholder_id' => $stakeholderId, 'policy_id' => $pid]);
+ }
+ }
+
+ /** Prossimo codice Stak.NN (globale: il codice e' PK). */
+ private function nextStakCode(): string
+ {
+ $max = Database::fetchOne(
+ "SELECT MAX(CAST(SUBSTRING(code, 6) AS UNSIGNED)) AS m
+ FROM cfg_stakeholder_types
+ WHERE code REGEXP '^Stak\\\\.[0-9]+$'"
+ );
+ $n = ((int) ($max['m'] ?? 0)) + 1;
+ return 'Stak.' . str_pad((string) $n, 2, '0', STR_PAD_LEFT);
+ }
+
+ 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;
+ }
+}
diff --git a/docs/sql/051_stakeholder_registry.sql b/docs/sql/051_stakeholder_registry.sql
new file mode 100644
index 0000000..61fae9b
--- /dev/null
+++ b/docs/sql/051_stakeholder_registry.sql
@@ -0,0 +1,103 @@
+-- =====================================================================
+-- 051 — Registro Stakeholder + matrice di Mendelow (Epic C / C5.1)
+-- =====================================================================
+-- Simon C5: registro degli stakeholder con tipo configurabile
+-- (Stak.01-30 di sistema + voci aggiunte dall'org da Stak.31), doppia
+-- valutazione POTERE/INTERESSE 0-5, collegamento all'organigramma
+-- (org_roles, per gli stakeholder INTERNI) e alle procedure (policies),
+-- e collocazione automatica su una matrice a 4 quadranti.
+--
+-- Ancoraggio normativo: GV.SC-02 (ruoli/responsabilita verso fornitori,
+-- clienti e partner) -> obblighi art. 24 D.Lgs. 138/2024. La matrice di
+-- Mendelow (potere/interesse) e' BUONA PRASSI, NON un obbligo NIS2.
+--
+-- Modello: tabella DEDICATA `stakeholders` (NON una colonna su suppliers):
+-- gli stakeholder interni (Dipendenti, Management, Azionisti...) non sono
+-- fornitori. Gli esterni possono COLLEGARSI a un supplier esistente
+-- (supplier_id, nessuna copia di dati): il modulo Supply Chain resta
+-- intatto e coesiste.
+--
+-- Config-driven: righe di sistema con organization_id NULL; le voci
+-- aggiunte dall'org hanno organization_id valorizzato.
+--
+-- Additivo, reversibile. Runner-safe: solo CREATE TABLE IF NOT EXISTS con
+-- le FK DENTRO il corpo del CREATE (il runner non ha pre-check per
+-- ADD CONSTRAINT; con IF NOT EXISTS il CREATE e' nativamente idempotente).
+-- Nessun DELIMITER, nessuna stored procedure, nessun ';' nei commenti.
+--
+-- I dati (4 quadranti + 30 tipi di sistema) sono seedati dal CLI
+-- idempotente application/cli/seed_stakeholders.php (modello seed_framework.php),
+-- che crea anche le tabelle se mancanti. Eseguire dentro il container app:
+-- docker exec nis2-app php /var/www/nis2-agile/application/cli/seed_stakeholders.php
+-- =====================================================================
+
+CREATE TABLE IF NOT EXISTS cfg_stakeholder_types (
+ code VARCHAR(16) NOT NULL,
+ organization_id INT NULL, -- NULL = tipo di sistema; valorizzato = tipo dell'org
+ tipo ENUM('Interno','Esterno') NOT NULL,
+ descr TEXT NOT NULL,
+ ord INT NOT NULL DEFAULT 0,
+ created_by INT NULL,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ PRIMARY KEY (code),
+ KEY idx_cfg_stk_type_org (organization_id),
+ CONSTRAINT fk_cfg_stk_type_org FOREIGN KEY (organization_id) REFERENCES organizations (id) ON DELETE CASCADE,
+ CONSTRAINT fk_cfg_stk_type_user 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 cfg_stakeholder_quadrants (
+ code VARCHAR(4) NOT NULL, -- Q1..Q4
+ organization_id INT NULL, -- NULL = quadrante di sistema (per C5.1 solo sistema)
+ label VARCHAR(64) NOT NULL,
+ strategy TEXT NULL,
+ power_min TINYINT NOT NULL,
+ power_max TINYINT NOT NULL,
+ interest_min TINYINT NOT NULL,
+ interest_max TINYINT NOT NULL,
+ ord INT NOT NULL DEFAULT 0,
+ PRIMARY KEY (code),
+ KEY idx_cfg_stk_quad_org (organization_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE IF NOT EXISTS stakeholders (
+ id INT NOT NULL AUTO_INCREMENT,
+ organization_id INT NOT NULL,
+ stak_code VARCHAR(16) NOT NULL, -- FK -> cfg_stakeholder_types.code
+ name VARCHAR(255) NOT NULL,
+ org_role_id INT NULL, -- solo INTERNI: nodo dell'organigramma
+ supplier_id INT NULL, -- solo ESTERNI: link opzionale al modulo Supply Chain
+ power TINYINT NULL, -- 0..5, NULL = non ancora valutato
+ interest TINYINT NULL, -- 0..5, NULL = non ancora valutato
+ contact_name VARCHAR(255) NULL,
+ contact_email VARCHAR(255) NULL,
+ notes 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_stk_org (organization_id),
+ KEY idx_stk_org_code (organization_id, stak_code),
+ KEY idx_stk_role (org_role_id),
+ KEY idx_stk_supplier (supplier_id),
+ CONSTRAINT fk_stk_org FOREIGN KEY (organization_id) REFERENCES organizations (id) ON DELETE CASCADE,
+ CONSTRAINT fk_stk_code FOREIGN KEY (stak_code) REFERENCES cfg_stakeholder_types (code),
+ CONSTRAINT fk_stk_role FOREIGN KEY (org_role_id) REFERENCES org_roles (id) ON DELETE SET NULL,
+ CONSTRAINT fk_stk_supplier FOREIGN KEY (supplier_id) REFERENCES suppliers (id) ON DELETE SET NULL,
+ CONSTRAINT fk_stk_user 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 stakeholder_procedures (
+ stakeholder_id INT NOT NULL,
+ policy_id INT NOT NULL,
+ PRIMARY KEY (stakeholder_id, policy_id),
+ KEY idx_stk_proc_policy (policy_id),
+ CONSTRAINT fk_stk_proc_stk FOREIGN KEY (stakeholder_id) REFERENCES stakeholders (id) ON DELETE CASCADE,
+ CONSTRAINT fk_stk_proc_policy FOREIGN KEY (policy_id) REFERENCES policies (id) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+-- ROLLBACK (manuale):
+-- DROP TABLE IF EXISTS stakeholder_procedures
+-- DROP TABLE IF EXISTS stakeholders
+-- DROP TABLE IF EXISTS cfg_stakeholder_quadrants
+-- DROP TABLE IF EXISTS cfg_stakeholder_types
diff --git a/public/_app-bi-demo.html b/public/_app-bi-demo.html
index 204e9f4..39c72be 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');
}
-
-
-
+
+
+
-
+
+
-
-
-
+
+
+
-
+
+
-
-
-
+
+
+