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>
480 lines
23 KiB
PHP
480 lines
23 KiB
PHP
<?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;
|
|
}
|
|
}
|