[FEAT] Modulo Controlli periodici (control monitoring §9.1/A.8.16) — v1.23.0

Registro controlli ricorrenti (owner/frequenza/metodo/controllo SoA-NIS2 collegato)
+ registro esecuzioni come EVIDENZA (data/esito/note) con avanzamento automatico
della scadenza; da esito non conforme generazione NC o NC+azione correttiva
(riuso non_conformities/capa_actions, nuova source 'monitoring'); report
scheda+storico stampabile; scadenze nel Calendario unico.

mig.057: periodic_controls + periodic_control_executions (FK CASCADE, UNIQUE
(organization_id, code)). PeriodicControlController (slug periodic-controls) +
controlli-periodici.html + CalendarController.srcPeriodicControls.

Fix da flotta di verifica avversariale (9 finding confermati):
- advance(): correzione overflow strtotime '+1 month' (31/01 -> 28/02, no salto
  mese) via DateTimeImmutable con clamp all'ultimo giorno valido (mensile/
  trimestrale/semestrale/annuale); giornaliero/settimanale/custom invariati.
- create(): retry-on-duplicate del codice CTL-NNN con UNIQUE (org_id, code).
- update(): array_key_exists per azzerare owner_role_id / next_due_date.
- delete(): rimossa cleanup morta su review_schedule (il calendario legge diretto).
- CalendarController.srcReviewSchedule: esclude internal_audit/stakeholder_activity
  (gia' con sorgente diretta) -> fix doppia comparsa nel calendario.
- calendario.html: TYPE_META 'periodic_control' (chip filtro + label + colore).

Additivo. Cache-buster ?v= bump (sidebar) + sw cache nis2-shell-v1.23.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
DevEnv nis2-agile
2026-06-17 09:21:03 +02:00
co-authored by Claude Opus 4.8
parent 0738504fdd
commit 4fdeae7cee
52 changed files with 1043 additions and 169 deletions
@@ -0,0 +1,74 @@
<?php
/** DDL idempotente Controlli periodici (mig.057). Crea le tabelle ed estende
* l'ENUM non_conformities.source con 'monitoring' (preservando i valori).
* Uso: docker exec nis2-app php /var/www/nis2-agile/application/cli/seed_periodic_controls.php */
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 = [
"CREATE TABLE IF NOT EXISTS periodic_controls (
id INT NOT NULL AUTO_INCREMENT, organization_id INT NOT NULL,
code VARCHAR(20) NULL, title VARCHAR(255) NOT NULL, description TEXT NULL, category VARCHAR(100) NULL,
control_ref VARCHAR(32) NULL, owner_role_id INT NULL,
frequency ENUM('giornaliero','settimanale','mensile','trimestrale','semestrale','annuale','custom') NOT NULL DEFAULT 'mensile',
frequency_days INT NULL, method TEXT NULL, next_due_date DATE NULL, last_executed_at DATE NULL,
status ENUM('active','suspended') NOT NULL DEFAULT 'active', created_by INT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id), UNIQUE KEY uk_pctl_code (organization_id, code),
KEY idx_pctl_org (organization_id), KEY idx_pctl_due (organization_id, next_due_date),
CONSTRAINT fk_pctl_org FOREIGN KEY (organization_id) REFERENCES organizations (id) ON DELETE CASCADE,
CONSTRAINT fk_pctl_role FOREIGN KEY (owner_role_id) REFERENCES org_roles (id) ON DELETE SET NULL,
CONSTRAINT fk_pctl_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 periodic_control_executions (
id INT NOT NULL AUTO_INCREMENT, control_id INT NOT NULL, executed_at DATE NOT NULL, executed_by INT NULL,
outcome ENUM('conforme','non_conforme','parziale','non_applicabile') NOT NULL DEFAULT 'conforme',
notes TEXT NULL, ncr_id INT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id), KEY idx_pcexec_control (control_id),
CONSTRAINT fk_pcexec_control FOREIGN KEY (control_id) REFERENCES periodic_controls (id) ON DELETE CASCADE,
CONSTRAINT fk_pcexec_user FOREIGN KEY (executed_by) REFERENCES users (id) ON DELETE SET NULL,
CONSTRAINT fk_pcexec_ncr FOREIGN KEY (ncr_id) REFERENCES non_conformities (id) ON DELETE SET NULL
) 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; } }
}
// UNIQUE (organization_id, code) idempotente — la tabella su prod può preesistere senza
// l'indice (creata prima del fix). Anti-doppione su CTL-NNN concorrenti (vedi retry in create()).
try { $pdo->exec("ALTER TABLE periodic_controls ADD UNIQUE KEY uk_pctl_code (organization_id, code)"); }
catch (PDOException $e) {
$c = $e->errorInfo[1] ?? 0;
if ($c === 1062) { fwrite(STDERR, "WARN uk_pctl_code: doppioni esistenti, indice NON aggiunto.\n"); }
elseif (!in_array($c, [1061], true)) { throw $e; } // 1061 = indice già presente
}
// Estende non_conformities.source con 'monitoring' preservando i valori esistenti.
try {
$cur = (string) $pdo->query("SELECT COLUMN_TYPE FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'non_conformities' AND COLUMN_NAME = 'source'")->fetchColumn();
if (stripos($cur, "'monitoring'") === false) {
$vals = [];
if (preg_match_all("/'([^']+)'/", $cur, $m)) { $vals = $m[1]; }
if (!in_array('monitoring', $vals, true)) { $vals[] = 'monitoring'; }
if (!$vals) { $vals = ['assessment','audit','incident','supplier_review','management_review','external_audit','monitoring','other']; }
$enum = implode(',', array_map(fn($v) => "'" . $v . "'", $vals));
$pdo->exec("ALTER TABLE non_conformities MODIFY COLUMN source ENUM($enum) NOT NULL DEFAULT 'assessment'");
}
} catch (PDOException $e) {
fwrite(STDERR, "WARN ALTER non_conformities.source: " . $e->getMessage() . "\n");
}
$counts = [];
foreach (['periodic_controls', 'periodic_control_executions'] as $t) {
$counts[$t] = (int) $pdo->query("SELECT COUNT(*) FROM $t")->fetchColumn();
}
$src = (string) $pdo->query("SELECT COLUMN_TYPE FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'non_conformities' AND COLUMN_NAME = 'source'")->fetchColumn();
echo "OK seed-periodic-controls — " . json_encode($counts, JSON_UNESCAPED_UNICODE) . "\n";
echo "non_conformities.source has monitoring: " . (str_contains($src, 'monitoring') ? 'YES' : 'NO') . "\n";