diff --git a/application/cli/migrate_067_requisito_residual_and_actions.php b/application/cli/migrate_067_requisito_residual_and_actions.php
new file mode 100644
index 0000000..c296cf8
--- /dev/null
+++ b/application/cli/migrate_067_requisito_residual_and_actions.php
@@ -0,0 +1,64 @@
+prepare(
+ "SELECT 1 FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?"
+ );
+ $st->execute([$table, $col]);
+ return (bool) $st->fetchColumn();
+}
+function tblExists(PDO $pdo, string $table): bool {
+ $st = $pdo->prepare(
+ "SELECT 1 FROM information_schema.TABLES
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?"
+ );
+ $st->execute([$table]);
+ return (bool) $st->fetchColumn();
+}
+
+$adds = [
+ 'residual_likelihood' => "ADD COLUMN residual_likelihood TINYINT NULL COMMENT 'Probabilita residua 1-5' AFTER impact",
+ 'residual_impact' => "ADD COLUMN residual_impact TINYINT NULL COMMENT 'Impatto residuo 1-5' AFTER residual_likelihood",
+];
+foreach ($adds as $col => $clause) {
+ if (colExists($pdo, 'org_requisito_state', $col)) { echo " - $col gia presente, skip\n"; continue; }
+ $pdo->exec("ALTER TABLE org_requisito_state $clause");
+ echo " + aggiunta colonna org_requisito_state.$col\n";
+}
+
+if (tblExists($pdo, 'requisito_actions')) {
+ echo " - tabella requisito_actions gia presente, skip\n";
+} else {
+ $pdo->exec(
+ "CREATE TABLE requisito_actions (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ organization_id INT NOT NULL,
+ requisito_id INT NOT NULL,
+ risk_code VARCHAR(64) NULL,
+ description TEXT NOT NULL,
+ status ENUM('bozza','in_corso','archiviata') NOT NULL DEFAULT 'bozza',
+ created_by INT NULL,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ INDEX idx_reqact_org_req (organization_id, requisito_id, created_at),
+ INDEX idx_reqact_org (organization_id, created_at)
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
+ );
+ echo " + creata tabella requisito_actions\n";
+}
+
+echo "Migrazione 067 — residuo + requisito_actions OK. Prossima mig=068.\n";
diff --git a/application/controllers/FrameworkController.php b/application/controllers/FrameworkController.php
index 382906a..31e0da7 100644
--- a/application/controllers/FrameworkController.php
+++ b/application/controllers/FrameworkController.php
@@ -207,24 +207,29 @@ class FrameworkController extends BaseController
};
$likelihood = array_key_exists('likelihood', $body) ? $scale($body['likelihood']) : null;
$impact = array_key_exists('impact', $body) ? $scale($body['impact']) : null;
+ // Rischio RESIDUO (mig 067) — dopo il trattamento. Il client invia sempre la riga intera.
+ $resLikelihood = array_key_exists('residual_likelihood', $body) ? $scale($body['residual_likelihood']) : null;
+ $resImpact = array_key_exists('residual_impact', $body) ? $scale($body['residual_impact']) : null;
// upsert org-scoped (UNIQUE org+requisito)
Database::query(
'INSERT INTO org_requisito_state
- (organization_id, requisito_id, stato, valutazione_rischio, note, likelihood, impact, updated_by)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+ (organization_id, requisito_id, stato, valutazione_rischio, note, likelihood, impact, residual_likelihood, residual_impact, updated_by)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
stato = VALUES(stato),
valutazione_rischio = VALUES(valutazione_rischio),
note = VALUES(note),
likelihood = VALUES(likelihood),
impact = VALUES(impact),
+ residual_likelihood = VALUES(residual_likelihood),
+ residual_impact = VALUES(residual_impact),
updated_by = VALUES(updated_by)',
- [$orgId, $reqId, $stato ?? 'da_valutare', $valutazione, $note, $likelihood, $impact, $this->getCurrentUserId()]
+ [$orgId, $reqId, $stato ?? 'da_valutare', $valutazione, $note, $likelihood, $impact, $resLikelihood, $resImpact, $this->getCurrentUserId()]
);
$row = Database::fetchOne(
- 'SELECT requisito_id, stato, valutazione_rischio, note, likelihood, impact, updated_at
+ 'SELECT requisito_id, stato, valutazione_rischio, note, likelihood, impact, residual_likelihood, residual_impact, updated_at
FROM org_requisito_state WHERE organization_id = ? AND requisito_id = ?',
[$orgId, $reqId]
);
@@ -235,6 +240,8 @@ class FrameworkController extends BaseController
'note' => $row['note'],
'likelihood' => $row['likelihood'] !== null ? (int) $row['likelihood'] : null,
'impact' => $row['impact'] !== null ? (int) $row['impact'] : null,
+ 'residual_likelihood' => $row['residual_likelihood'] !== null ? (int) $row['residual_likelihood'] : null,
+ 'residual_impact' => $row['residual_impact'] !== null ? (int) $row['residual_impact'] : null,
'updated_at' => $row['updated_at'],
], 'Valutazione salvata');
}
diff --git a/application/controllers/RiskController.php b/application/controllers/RiskController.php
index d870b54..7f73601 100644
--- a/application/controllers/RiskController.php
+++ b/application/controllers/RiskController.php
@@ -69,10 +69,13 @@ class RiskController extends BaseController
$rows = Database::fetchAll(
"SELECT q.id AS requisito_id, q.n, q.misura_code, q.requisito_code,
q.requisito_descr, q.applies_important, q.applies_essential,
+ q.risk_code AS default_risk_code, rk.risk_descr AS default_risk_descr,
m.misura_descr, m.nist_code, m.area_politica, m.ord,
- s.stato, s.valutazione_rischio, s.note, s.likelihood, s.impact
+ s.stato, s.valutazione_rischio, s.note, s.likelihood, s.impact,
+ s.residual_likelihood, s.residual_impact
FROM cfg_nis2_requisiti q
JOIN cfg_nis2_misure m ON m.misura_code = q.misura_code
+ LEFT JOIN cfg_nis2_rischi rk ON rk.risk_code = q.risk_code
LEFT JOIN org_requisito_state s
ON s.requisito_id = q.id AND s.organization_id = ?
ORDER BY m.ord ASC, q.n ASC, q.id ASC",
@@ -100,15 +103,24 @@ class RiskController extends BaseController
'requisito_code' => $q['requisito_code'],
'comma' => $commaN,
'requisito_descr' => $q['requisito_descr'],
+ // RISCHIO(DEFAULT) del framework (cfg_nis2_rischi.risk_descr); fallback = requisito_descr.
+ 'risk_descr' => ($q['default_risk_descr'] !== null && $q['default_risk_descr'] !== '')
+ ? $q['default_risk_descr'] : $q['requisito_descr'],
'applies_important' => (int) $q['applies_important'],
'applies_essential' => (int) $q['applies_essential'],
'stato' => $q['stato'] ?? 'da_valutare',
'valutazione_rischio' => $q['valutazione_rischio'] ?? null,
'note' => $q['note'] ?? null,
+ // Rischio INERENTE (prima del trattamento) = likelihood/impact (mig 066)
'likelihood' => isset($q['likelihood']) && $q['likelihood'] !== null ? (int) $q['likelihood'] : null,
'impact' => isset($q['impact']) && $q['impact'] !== null ? (int) $q['impact'] : null,
'score' => (isset($q['likelihood'], $q['impact']) && $q['likelihood'] !== null && $q['impact'] !== null)
? (int) $q['likelihood'] * (int) $q['impact'] : null,
+ // Rischio RESIDUO (dopo il trattamento) = residual_* (mig 067)
+ 'residual_likelihood' => isset($q['residual_likelihood']) && $q['residual_likelihood'] !== null ? (int) $q['residual_likelihood'] : null,
+ 'residual_impact' => isset($q['residual_impact']) && $q['residual_impact'] !== null ? (int) $q['residual_impact'] : null,
+ 'residual_score' => (isset($q['residual_likelihood'], $q['residual_impact']) && $q['residual_likelihood'] !== null && $q['residual_impact'] !== null)
+ ? (int) $q['residual_likelihood'] * (int) $q['residual_impact'] : null,
];
}
@@ -611,4 +623,57 @@ class RiskController extends BaseController
{
return ($v === null || $v === '') ? null : (float) $v;
}
+
+ /**
+ * GET /api/risks/requisitoActions?requisito_id=NN
+ * Azioni collegate al rischio-da-requisito (seme del Modulo Azioni). Piu' recenti prima.
+ */
+ public function requisitoActionsList(): void
+ {
+ $this->requireOrgAccess();
+ $orgId = $this->getCurrentOrgId();
+ $reqId = (int) $this->getParam('requisito_id', 0);
+ if ($reqId <= 0) { $this->jsonSuccess(['actions' => []]); return; }
+
+ $rows = Database::fetchAll(
+ 'SELECT id, risk_code, description, status, created_at
+ FROM requisito_actions
+ WHERE organization_id = ? AND requisito_id = ?
+ ORDER BY created_at DESC, id DESC',
+ [$orgId, $reqId]
+ );
+ $this->jsonSuccess(['actions' => $rows]);
+ }
+
+ /**
+ * POST /api/risks/requisitoActions {requisito_id*, description*, risk_code?}
+ * Crea un'azione collegata al rischio-da-requisito. NON tocca lo stato di conformita'
+ * del requisito (org_requisito_state.stato): la conformita' resta atto di attuazione+evidenza.
+ */
+ public function requisitoActionsCreate(): void
+ {
+ $this->requireOrgRole(['super_admin', 'org_admin', 'compliance_manager']);
+ $orgId = $this->getCurrentOrgId();
+ $reqId = (int) $this->getParam('requisito_id', 0);
+ $descr = trim((string) $this->getParam('description', ''));
+ $riskCode = $this->getParam('risk_code');
+ if ($reqId <= 0 || $descr === '') {
+ $this->jsonError('requisito_id e description sono obbligatori', 422);
+ return;
+ }
+ $id = Database::insert('requisito_actions', [
+ 'organization_id' => $orgId,
+ 'requisito_id' => $reqId,
+ 'risk_code' => $riskCode ? substr((string) $riskCode, 0, 64) : null,
+ 'description' => $descr,
+ 'status' => 'bozza',
+ 'created_by' => $this->getCurrentUserId(),
+ ]);
+ $this->logAudit('requisito_action_created', 'requisito_action', $id);
+ $row = Database::fetchOne(
+ 'SELECT id, risk_code, description, status, created_at FROM requisito_actions WHERE id = ?',
+ [$id]
+ );
+ $this->jsonSuccess(['action' => $row], 'Azione creata');
+ }
}
diff --git a/docs/sql/067_requisito_residual_and_actions.sql b/docs/sql/067_requisito_residual_and_actions.sql
new file mode 100644
index 0000000..907df13
--- /dev/null
+++ b/docs/sql/067_requisito_residual_and_actions.sql
@@ -0,0 +1,25 @@
+-- 067_requisito_residual_and_actions.sql — Integrazione prototipo "Modulo Azioni" (Simon), Fase 1.
+-- (1) org_requisito_state += valutazione del RISCHIO RESIDUO (dopo il trattamento):
+-- residual_likelihood / residual_impact (1-5). L'inerente resta likelihood/impact (mig 066).
+-- (2) requisito_actions: azioni collegate al rischio-da-requisito (seme del modulo Azioni).
+-- NB normativo: NON tocca lo stato di conformità del requisito (org_requisito_state.stato),
+-- che resta un atto esplicito di attuazione+evidenza. Vedi eccezione #1 (parere nis2-expert).
+-- Additiva e idempotente. Runner: application/cli/migrate_067_requisito_residual_and_actions.php
+
+ALTER TABLE org_requisito_state
+ ADD COLUMN residual_likelihood TINYINT NULL COMMENT 'Probabilita residua 1-5 (dopo trattamento)' AFTER impact,
+ ADD COLUMN residual_impact TINYINT NULL COMMENT 'Impatto residuo 1-5 (dopo trattamento)' AFTER residual_likelihood;
+
+CREATE TABLE IF NOT EXISTS requisito_actions (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ organization_id INT NOT NULL,
+ requisito_id INT NOT NULL,
+ risk_code VARCHAR(64) NULL,
+ description TEXT NOT NULL,
+ status ENUM('bozza','in_corso','archiviata') NOT NULL DEFAULT 'bozza',
+ created_by INT NULL,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ INDEX idx_reqact_org_req (organization_id, requisito_id, created_at),
+ INDEX idx_reqact_org (organization_id, created_at)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
diff --git a/public/_app-bi-demo.html b/public/_app-bi-demo.html
index ab338c7..50fa908 100644
--- a/public/_app-bi-demo.html
+++ b/public/_app-bi-demo.html
@@ -70,7 +70,7 @@
-
+
+
-
-
+
+
+
-
-
+
+
+
-
-
+
+
+
-
-
+
+
+
-
-
+
+
+
-
-
+
+
+
-
-
+
+
+
-
-
+
+
+
-
-
+
+
+
-
-
+
+
+
-
-
+
+
+
-
-
+
+
+
-
-
+
+
-
+
+
-
-
+
+
+
-
-
+
+
+
-
-
+
+
+
-
-
+
+
+
-
-
+
+
+
-
-
+
+
+
-
+
+
-
-
+
+
+
-
-
+
+
+
-
-
+
+
-
+
+
-
-
+
+
+
-
-
+
+
+
-
-
+
+
-
+
+
-
-
+
+
+
-
-
+
+
+
-
-
+
+
+
-
-
+
+
+
-
-
+
+
+
-
-
+
+
+
-
-
+
+
+
-
-
+
+
+
-
-
+
+
+
-
-
+
+