[FEAT] Epic C / C5.1 — Modulo Stakeholder: registro + matrice di Mendelow (mig.051)

Registro dedicato degli stakeholder (tabella `stakeholders`, NON una colonna su
suppliers: gli interni non sono fornitori; gli esterni si COLLEGANO opzionalmente
a un supplier esistente senza duplicare il dato).

- mig.051: cfg_stakeholder_types (30 di sistema Stak.01-30 + org-added da Stak.31),
  cfg_stakeholder_quadrants (4, range corretti: Q4 potere 0-2), stakeholders,
  stakeholder_procedures (m2m -> policies). Seeder idempotente seed_stakeholders.php.
- StakeholderController: list/create/update/delete + types/addType + quadrants + pickers.
  Quadrante CALCOLATO a read-time dai range config. Anti-IDOR su org_role/supplier/
  policy/tipo; coerenza kind (interni->organigramma, esterni->fornitore); punteggi 0-5.
- api.js: metodi stk*. stakeholders.html riscritta: registro + matrice di Mendelow
  in SVG dependency-free + modali (stakeholder, nuovo tipo). Voce sidebar V2 (common-bi.js).
- Help aggiornato; cache-buster ?v=20260626 su tutte le pagine; version 1.19.0; sw cache v1.19.0.

Ancoraggio GV.SC-02 (obbligo, art. 24 D.Lgs. 138/2024). La matrice di Mendelow e'
etichettata come BUONA PRASSI, non obbligo NIS2 (regola fonti-certe). Additivo.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
DevEnv nis2-agile
2026-06-16 20:54:57 +02:00
co-authored by Claude Opus 4.8
parent 419162594d
commit c782aa54fa
43 changed files with 1256 additions and 278 deletions
+151
View File
@@ -0,0 +1,151 @@
<?php
/**
* NIS2 Agile — Importer idempotente del registro Stakeholder (Epic C / C5.1).
* ----------------------------------------------------------------------------
* Crea (se mancanti) le tabelle del modulo Stakeholder (mig.051) e popola i dati
* di SISTEMA: i 4 quadranti della matrice di Mendelow (range corretti, Q4 potere
* 0-2) e i 30 tipi di stakeholder Stak.01-30 (6 Interni / 24 Esterni) dal seed
* autoritativo application/data/nis2_framework_seed.json (chiave stakeholder_types,
* generata dai file docs/simon/*.xlsx — codifica NON variabile).
*
* Idempotente: CREATE TABLE IF NOT EXISTS + INSERT ... ON DUPLICATE KEY UPDATE
* sul PK `code` (nessuna trappola NULL-dedupe: il codice E' la chiave primaria).
* Rilanciabile senza duplicare ne' rompere le FK. Sostituisce i runner usa-e-getta.
*
* NB: la matrice di Mendelow (potere/interesse) e' BUONA PRASSI, NON un obbligo
* NIS2; l'obbligo e' GV.SC-02 (ruoli/responsabilita verso fornitori/clienti/partner).
*
* Uso (dentro il container app):
* docker exec nis2-app php /var/www/nis2-agile/application/cli/seed_stakeholders.php
*
* NB: solo CLI. DB API via PDO singleton (Database::getInstance()), TLS gestito
* dalla config dell'app.
*/
if (PHP_SAPI !== 'cli') {
http_response_code(403);
exit("Solo da CLI.\n");
}
require_once __DIR__ . '/../config/env.php';
require_once __DIR__ . '/../config/database.php';
$pdo = Database::getInstance();
$pdo->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";
@@ -0,0 +1,479 @@
<?php
/**
* NIS2 Agile - Registro Stakeholder + matrice di Mendelow (Epic C / C5.1)
* ----------------------------------------------------------------------------
* Registro degli stakeholder dell'organizzazione con:
* - TIPO configurabile (cfg_stakeholder_types: 30 di sistema Stak.01-30 +
* voci aggiunte dall'org da Stak.31; codifica di sistema NON variabile);
* - doppia valutazione POTERE/INTERESSE 0-5;
* - collegamento all'organigramma (org_roles) per gli stakeholder INTERNI;
* - collegamento alle procedure (policies) in molti-a-molti;
* - collocazione automatica su una matrice a 4 quadranti (cfg_stakeholder_quadrants),
* calcolata a read-time dai range di config (x=interesse, y=potere).
*
* Modello DEDICATO (tabella stakeholders): gli stakeholder interni non sono
* fornitori. Gli esterni possono COLLEGARSI a un supplier esistente (supplier_id)
* senza copiare dati: il modulo Supply Chain resta intatto e coesiste.
*
* Ancoraggio normativo: GV.SC-02 (obbligo: ruoli/responsabilita verso
* fornitori/clienti/partner -> 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;
}
}