[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:
co-authored by
Claude Opus 4.8
parent
419162594d
commit
c782aa54fa
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user