[FIX] FAIR/KRI: aggiunti i metodi mancanti in RiskController (commit 1be3bd0 era incompleto)

Il commit 1be3bd0 conteneva migrazione 026 + FairService + route ma NON i metodi
computeFair/fairRegister/listKri/createKri/updateKri nel controller (Edit fallito
per ancora errata). Ora presenti e testati E2E in prod:
- FAIR compute ALE Monte Carlo (risk 432: ALE mean 174.806 EUR, deterministico)
- fairRegister portfolio ALE, KRI create/update/dashboard con semaforo amber->red

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
DevEnv nis2-agile 2026-05-30 09:29:36 +02:00
parent db4cc7f660
commit edf0394616

View File

@ -8,6 +8,7 @@
require_once __DIR__ . '/BaseController.php';
require_once APP_PATH . '/services/AIService.php';
require_once APP_PATH . '/services/WebhookService.php';
require_once APP_PATH . '/services/FairService.php';
class RiskController extends BaseController
{
@ -317,4 +318,199 @@ class RiskController extends BaseController
$this->jsonError('Errore AI: ' . $e->getMessage(), 500, 'AI_ERROR');
}
}
// ══════════════════════════════════════════════════════════════════════
// RISK QUANTITATIVO FAIR (P2) — analisi economica del rischio
// ══════════════════════════════════════════════════════════════════════
/**
* POST /api/risks/{id}/fair
* Calcola l'ALE (Annualized Loss Expectancy) via Monte Carlo FAIR e
* persiste parametri + risultati sul rischio.
* Body: { tef_min, tef_ml, tef_max, vuln (0-1), lm_min, lm_ml, lm_max, iterations? }
*/
public function computeFair(int $id): void
{
$this->requireOrgRole(['org_admin', 'compliance_manager', 'board_member']);
$risk = Database::fetchOne(
'SELECT id FROM risks WHERE id = ? AND organization_id = ? AND deleted_at IS NULL',
[$id, $this->getCurrentOrgId()]
);
if (!$risk) {
$this->jsonError('Rischio non trovato', 404, 'NOT_FOUND');
}
$params = [
'tef_min' => (float) $this->getParam('tef_min', 0),
'tef_ml' => (float) $this->getParam('tef_ml', 0),
'tef_max' => (float) $this->getParam('tef_max', 0),
'vuln' => (float) $this->getParam('vuln', 1),
'lm_min' => (float) $this->getParam('lm_min', 0),
'lm_ml' => (float) $this->getParam('lm_ml', 0),
'lm_max' => (float) $this->getParam('lm_max', 0),
];
$iterations = (int) $this->getParam('iterations', FairService::DEFAULT_ITERATIONS);
$sim = FairService::simulate($params, $iterations);
Database::query(
'UPDATE risks SET
fair_tef_min=?, fair_tef_ml=?, fair_tef_max=?, fair_vuln=?,
fair_lm_min=?, fair_lm_ml=?, fair_lm_max=?,
ale_min=?, ale_ml=?, ale_max=?, ale_mean=?, fair_computed_at=NOW()
WHERE id=? AND organization_id=?',
[
$params['tef_min'], $params['tef_ml'], $params['tef_max'], $params['vuln'],
$params['lm_min'], $params['lm_ml'], $params['lm_max'],
$sim['ale_min'], $sim['ale_ml'], $sim['ale_max'], $sim['ale_mean'],
$id, $this->getCurrentOrgId(),
]
);
$this->logAudit('risk_fair_computed', 'risk', $id, ['ale_mean' => $sim['ale_mean']]);
$this->jsonSuccess(['risk_id' => $id, 'parameters' => $params, 'result' => $sim], 'Analisi FAIR calcolata');
}
/**
* GET /api/risks/fairRegister
* Registro quantitativo: rischi con ALE calcolato, ordinati per esposizione
* economica, + ALE totale di portafoglio.
*/
public function fairRegister(): void
{
$this->requireOrgAccess();
$rows = Database::fetchAll(
'SELECT id, risk_code, title, category, status, inherent_risk_score,
ale_min, ale_ml, ale_max, ale_mean, fair_computed_at
FROM risks
WHERE organization_id = ? AND deleted_at IS NULL AND ale_mean IS NOT NULL
ORDER BY ale_mean DESC',
[$this->getCurrentOrgId()]
);
$totalAle = 0.0;
foreach ($rows as $r) { $totalAle += (float) $r['ale_mean']; }
$this->jsonSuccess([
'total_risks_quantified' => count($rows),
'portfolio_ale_mean' => round($totalAle, 2),
'currency' => 'EUR',
'risks' => $rows,
]);
}
// ══════════════════════════════════════════════════════════════════════
// KEY RISK INDICATORS (KRI) — dashboard
// ══════════════════════════════════════════════════════════════════════
/** GET /api/risks/kri — lista KRI con stato semaforo ricalcolato. */
public function listKri(): void
{
$this->requireOrgAccess();
$rows = Database::fetchAll(
'SELECT * FROM kri WHERE organization_id = ? ORDER BY FIELD(status,"red","amber","green","unknown"), name',
[$this->getCurrentOrgId()]
);
foreach ($rows as &$r) {
$r['status'] = self::evalKriStatus($r);
}
unset($r);
$summary = ['green' => 0, 'amber' => 0, 'red' => 0, 'unknown' => 0];
foreach ($rows as $r) { $summary[$r['status']]++; }
$this->jsonSuccess(['summary' => $summary, 'total' => count($rows), 'kris' => $rows]);
}
/** POST /api/risks/kri — crea un KRI. */
public function createKri(): void
{
$this->requireOrgRole(['org_admin', 'compliance_manager', 'board_member']);
$this->validateRequired(['name']);
$dir = $this->getParam('direction', 'higher_worse');
if (!in_array($dir, ['higher_worse', 'lower_worse'], true)) $dir = 'higher_worse';
$cat = $this->getParam('category', 'cyber');
if (!in_array($cat, ['cyber','operational','compliance','supply_chain','physical','human'], true)) $cat = 'cyber';
$data = [
'organization_id' => $this->getCurrentOrgId(),
'name' => trim($this->getParam('name')),
'description' => $this->getParam('description'),
'category' => $cat,
'unit' => $this->getParam('unit'),
'current_value' => $this->numOrNull($this->getParam('current_value')),
'target_value' => $this->numOrNull($this->getParam('target_value')),
'threshold_warning' => $this->numOrNull($this->getParam('threshold_warning')),
'threshold_critical' => $this->numOrNull($this->getParam('threshold_critical')),
'direction' => $dir,
'linked_risk_id' => $this->numOrNull($this->getParam('linked_risk_id')),
'measured_at' => $this->getParam('current_value') !== null ? date('Y-m-d H:i:s') : null,
];
$data['status'] = self::evalKriStatus($data);
$kriId = Database::insert('kri', $data);
$this->logAudit('kri_created', 'kri', $kriId);
$this->jsonSuccess(['id' => $kriId, 'status' => $data['status']], 'KRI creato', 201);
}
/** PUT /api/risks/kri/{id} — aggiorna valore/soglie KRI (ricalcola stato). */
public function updateKri(int $id): void
{
$this->requireOrgRole(['org_admin', 'compliance_manager', 'board_member']);
$kri = Database::fetchOne('SELECT * FROM kri WHERE id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()]);
if (!$kri) {
$this->jsonError('KRI non trovato', 404, 'NOT_FOUND');
}
$fields = ['name', 'description', 'category', 'unit', 'current_value', 'target_value', 'threshold_warning', 'threshold_critical', 'direction', 'linked_risk_id'];
$merged = $kri;
foreach ($fields as $f) {
$v = $this->getParam($f);
if ($v !== null) {
$merged[$f] = in_array($f, ['current_value','target_value','threshold_warning','threshold_critical','linked_risk_id'], true)
? $this->numOrNull($v) : $v;
}
}
$merged['status'] = self::evalKriStatus($merged);
$measuredAt = $this->getParam('current_value') !== null ? date('Y-m-d H:i:s') : $kri['measured_at'];
Database::query(
'UPDATE kri SET name=?, description=?, category=?, unit=?, current_value=?, target_value=?,
threshold_warning=?, threshold_critical=?, direction=?, linked_risk_id=?, status=?, measured_at=?
WHERE id=? AND organization_id=?',
[
$merged['name'], $merged['description'], $merged['category'], $merged['unit'],
$merged['current_value'], $merged['target_value'], $merged['threshold_warning'],
$merged['threshold_critical'], $merged['direction'], $merged['linked_risk_id'],
$merged['status'], $measuredAt, $id, $this->getCurrentOrgId(),
]
);
$this->logAudit('kri_updated', 'kri', $id);
$this->jsonSuccess(['id' => $id, 'status' => $merged['status']], 'KRI aggiornato');
}
/** Stato semaforo KRI in base a valore corrente, soglie e direzione. */
private static function evalKriStatus(array $k): string
{
$cur = $k['current_value'] ?? null;
if ($cur === null || $cur === '') return 'unknown';
$cur = (float) $cur;
$warn = isset($k['threshold_warning']) && $k['threshold_warning'] !== null ? (float) $k['threshold_warning'] : null;
$crit = isset($k['threshold_critical']) && $k['threshold_critical'] !== null ? (float) $k['threshold_critical'] : null;
$dir = $k['direction'] ?? 'higher_worse';
if ($dir === 'higher_worse') {
if ($crit !== null && $cur >= $crit) return 'red';
if ($warn !== null && $cur >= $warn) return 'amber';
return 'green';
}
if ($crit !== null && $cur <= $crit) return 'red';
if ($warn !== null && $cur <= $warn) return 'amber';
return 'green';
}
private function numOrNull($v)
{
return ($v === null || $v === '') ? null : (float) $v;
}
}