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>
361 lines
22 KiB
PHP
361 lines
22 KiB
PHP
<?php
|
|
/**
|
|
* NIS2 Agile - Controlli periodici (control monitoring) — mig.057
|
|
* ----------------------------------------------------------------------------
|
|
* Registro dei controlli RICORRENTI (owner + frequenza + metodo) e relativo
|
|
* REGISTRO DELLE ESECUZIONI (data, esito, note, evidenze) = evidenza del
|
|
* monitoraggio periodico (ISO 27001 §9.1 / A.8.16 — buona prassi, non obbligo).
|
|
* Da un'esecuzione non conforme si genera una NC o un'azione correttiva (AC),
|
|
* riusando non_conformities / capa_actions. La scadenza avanza ad ogni esecuzione
|
|
* e alimenta il Calendario.
|
|
*
|
|
* Distinto da compliance_controls (registro stato), review_schedule (cadenza
|
|
* generica) e internal_audits (audit puntuale). Vedi docs/DESIGN_CONTROLLI_PERIODICI.md.
|
|
*
|
|
* NOTE: Database::query/fetchAll/fetchOne/insert/update/delete; jsonError/Success exit.
|
|
*/
|
|
|
|
require_once __DIR__ . '/BaseController.php';
|
|
|
|
class PeriodicControlController extends BaseController
|
|
{
|
|
private const MANAGE_ROLES = ['org_admin', 'compliance_manager', 'auditor'];
|
|
private const FREQS = ['giornaliero', 'settimanale', 'mensile', 'trimestrale', 'semestrale', 'annuale', 'custom'];
|
|
|
|
// ── LETTURE ──────────────────────────────────────────────────────────────
|
|
/** GET /api/periodic-controls/list */
|
|
public function list(): void
|
|
{
|
|
$this->requireOrgAccess();
|
|
$orgId = $this->getCurrentOrgId();
|
|
$rows = Database::fetchAll(
|
|
"SELECT c.id, c.code, c.title, c.category, c.control_ref, c.frequency, c.frequency_days,
|
|
c.next_due_date, c.last_executed_at, c.status, c.owner_role_id, r.role_name AS owner_role,
|
|
(SELECT COUNT(*) FROM periodic_control_executions e WHERE e.control_id = c.id) AS n_exec,
|
|
(SELECT outcome FROM periodic_control_executions e WHERE e.control_id = c.id ORDER BY e.executed_at DESC, e.id DESC LIMIT 1) AS last_outcome
|
|
FROM periodic_controls c
|
|
LEFT JOIN org_roles r ON r.id = c.owner_role_id
|
|
WHERE c.organization_id = ?
|
|
ORDER BY (c.next_due_date IS NULL), c.next_due_date ASC, c.id DESC",
|
|
[$orgId]
|
|
);
|
|
$today = date('Y-m-d');
|
|
$out = array_map(function ($c) use ($today) {
|
|
return [
|
|
'id' => (int) $c['id'], 'code' => $c['code'], 'title' => $c['title'], 'category' => $c['category'],
|
|
'control_ref' => $c['control_ref'], 'frequency' => $c['frequency'], 'frequency_days' => $c['frequency_days'] !== null ? (int) $c['frequency_days'] : null,
|
|
'owner_role_id' => $c['owner_role_id'] !== null ? (int) $c['owner_role_id'] : null, 'owner_role' => $c['owner_role'],
|
|
'next_due_date' => $c['next_due_date'], 'last_executed_at' => $c['last_executed_at'], 'status' => $c['status'],
|
|
'n_exec' => (int) $c['n_exec'], 'last_outcome' => $c['last_outcome'],
|
|
'due_status' => $this->dueStatus($c['next_due_date'], $today, $c['status']),
|
|
];
|
|
}, $rows);
|
|
$this->jsonSuccess(['controls' => $out]);
|
|
}
|
|
|
|
/** GET /api/periodic-controls/{id} */
|
|
public function get(int $id): void
|
|
{
|
|
$this->requireOrgAccess();
|
|
$orgId = $this->getCurrentOrgId();
|
|
$c = Database::fetchOne(
|
|
"SELECT c.*, r.role_name AS owner_role FROM periodic_controls c
|
|
LEFT JOIN org_roles r ON r.id = c.owner_role_id
|
|
WHERE c.id = ? AND c.organization_id = ?",
|
|
[$id, $orgId]
|
|
);
|
|
if (!$c) { $this->jsonError('Controllo non trovato', 404, 'NOT_FOUND'); }
|
|
$exec = Database::fetchAll(
|
|
"SELECT e.id, e.executed_at, e.outcome, e.notes, e.ncr_id, e.created_at,
|
|
u.full_name AS executed_by_name, n.ncr_code
|
|
FROM periodic_control_executions e
|
|
LEFT JOIN users u ON u.id = e.executed_by
|
|
LEFT JOIN non_conformities n ON n.id = e.ncr_id
|
|
WHERE e.control_id = ? ORDER BY e.executed_at DESC, e.id DESC",
|
|
[$id]
|
|
);
|
|
$this->jsonSuccess([
|
|
'id' => (int) $c['id'], 'code' => $c['code'], 'title' => $c['title'], 'description' => $c['description'],
|
|
'category' => $c['category'], 'control_ref' => $c['control_ref'], 'method' => $c['method'],
|
|
'frequency' => $c['frequency'], 'frequency_days' => $c['frequency_days'] !== null ? (int) $c['frequency_days'] : null,
|
|
'owner_role_id' => $c['owner_role_id'] !== null ? (int) $c['owner_role_id'] : null, 'owner_role' => $c['owner_role'],
|
|
'next_due_date' => $c['next_due_date'], 'last_executed_at' => $c['last_executed_at'], 'status' => $c['status'],
|
|
'executions' => array_map(static fn($e) => [
|
|
'id' => (int) $e['id'], 'executed_at' => $e['executed_at'], 'outcome' => $e['outcome'], 'notes' => $e['notes'],
|
|
'executed_by_name' => $e['executed_by_name'], 'ncr_id' => $e['ncr_id'] !== null ? (int) $e['ncr_id'] : null, 'ncr_code' => $e['ncr_code'],
|
|
], $exec),
|
|
]);
|
|
}
|
|
|
|
// ── SCRITTURE ────────────────────────────────────────────────────────────
|
|
/** POST /api/periodic-controls/create */
|
|
public function create(): void
|
|
{
|
|
$this->requireOrgRole(self::MANAGE_ROLES);
|
|
$orgId = $this->getCurrentOrgId();
|
|
$b = $this->getJsonBody();
|
|
|
|
$title = trim((string) ($b['title'] ?? ''));
|
|
if ($title === '' || mb_strlen($title) > 255) { $this->jsonError('Titolo obbligatorio (max 255)', 422, 'INVALID_TITLE'); }
|
|
$freq = in_array($b['frequency'] ?? '', self::FREQS, true) ? $b['frequency'] : 'mensile';
|
|
$fdays = ($freq === 'custom') ? max(1, (int) ($b['frequency_days'] ?? 0)) : null;
|
|
if ($freq === 'custom' && !$fdays) { $this->jsonError('Per frequenza custom indicare frequency_days', 422, 'INVALID_FREQ'); }
|
|
$ownerRole = $this->validateOwnerRole($b['owner_role_id'] ?? null, $orgId);
|
|
$next = $this->validateDate($b['next_due_date'] ?? null, 'next_due_date');
|
|
if ($next === null) { $next = $this->advance(date('Y-m-d'), $freq, $fdays); }
|
|
|
|
// Retry-on-duplicate: il codice CTL-NNN è MAX+1 (race possibile su create concorrenti);
|
|
// la UNIQUE (organization_id, code) blocca i doppioni e qui si rigenera il codice.
|
|
$base = [
|
|
'organization_id' => $orgId, 'title' => $title,
|
|
'description' => $this->nullableStr($b['description'] ?? null), 'category' => $this->nullableStr($b['category'] ?? null, 100),
|
|
'control_ref' => $this->nullableStr($b['control_ref'] ?? null, 32), 'owner_role_id' => $ownerRole,
|
|
'frequency' => $freq, 'frequency_days' => $fdays, 'method' => $this->nullableStr($b['method'] ?? null),
|
|
'next_due_date' => $next, 'status' => in_array($b['status'] ?? '', ['active', 'suspended'], true) ? $b['status'] : 'active',
|
|
'created_by' => $this->getCurrentUserId(),
|
|
];
|
|
$code = null; $id = 0;
|
|
for ($attempt = 0; ; $attempt++) {
|
|
$code = $this->nextCode($orgId);
|
|
try {
|
|
$id = (int) Database::insert('periodic_controls', ['code' => $code] + $base);
|
|
break;
|
|
} catch (\PDOException $e) {
|
|
if (($e->errorInfo[1] ?? 0) === 1062 && $attempt < 4) { continue; }
|
|
throw $e;
|
|
}
|
|
}
|
|
$this->logAudit('periodic_control_created', 'periodic_control', $id, ['code' => $code, 'title' => $title]);
|
|
$this->jsonSuccess(['id' => $id, 'code' => $code, 'next_due_date' => $next], 'Controllo periodico creato', 201);
|
|
}
|
|
|
|
/** PUT /api/periodic-controls/{id} */
|
|
public function update(int $id): void
|
|
{
|
|
$this->requireOrgRole(self::MANAGE_ROLES);
|
|
$orgId = $this->getCurrentOrgId();
|
|
$b = $this->getJsonBody();
|
|
$c = Database::fetchOne('SELECT id, frequency, frequency_days FROM periodic_controls WHERE id = ? AND organization_id = ?', [$id, $orgId]);
|
|
if (!$c) { $this->jsonError('Controllo non trovato', 404, 'NOT_FOUND'); }
|
|
|
|
$updates = [];
|
|
if ($this->hasParam('title')) {
|
|
$t = trim((string) ($b['title'] ?? '')); if ($t === '' || mb_strlen($t) > 255) { $this->jsonError('Titolo non valido', 422, 'INVALID_TITLE'); }
|
|
$updates['title'] = $t;
|
|
}
|
|
if ($this->hasParam('description')) { $updates['description'] = $this->nullableStr($b['description'] ?? null); }
|
|
if ($this->hasParam('category')) { $updates['category'] = $this->nullableStr($b['category'] ?? null, 100); }
|
|
if ($this->hasParam('control_ref')) { $updates['control_ref'] = $this->nullableStr($b['control_ref'] ?? null, 32); }
|
|
if ($this->hasParam('method')) { $updates['method'] = $this->nullableStr($b['method'] ?? null); }
|
|
// array_key_exists (non hasParam): distingue "chiave presente = null" (azzera) da "chiave assente" (lascia invariato).
|
|
if (array_key_exists('owner_role_id', $b)) { $updates['owner_role_id'] = $this->validateOwnerRole($b['owner_role_id'], $orgId); }
|
|
if ($this->hasParam('frequency') && in_array($b['frequency'], self::FREQS, true)) {
|
|
$updates['frequency'] = $b['frequency'];
|
|
$updates['frequency_days'] = ($b['frequency'] === 'custom') ? max(1, (int) ($b['frequency_days'] ?? $c['frequency_days'] ?? 30)) : null;
|
|
}
|
|
if (array_key_exists('next_due_date', $b)) { $updates['next_due_date'] = $this->validateDate($b['next_due_date'], 'next_due_date'); }
|
|
if ($this->hasParam('status') && in_array($b['status'], ['active', 'suspended'], true)) { $updates['status'] = $b['status']; }
|
|
|
|
if (!empty($updates)) { Database::update('periodic_controls', $updates, 'id = ? AND organization_id = ?', [$id, $orgId]); }
|
|
$this->logAudit('periodic_control_updated', 'periodic_control', $id, array_keys($updates));
|
|
$this->jsonSuccess(['id' => $id, 'updated' => array_keys($updates)], 'Controllo aggiornato');
|
|
}
|
|
|
|
/** DELETE /api/periodic-controls/{id} */
|
|
public function delete(int $id): void
|
|
{
|
|
$this->requireOrgRole(['org_admin']);
|
|
$orgId = $this->getCurrentOrgId();
|
|
// Le esecuzioni si cancellano via FK ON DELETE CASCADE; il calendario legge
|
|
// periodic_controls direttamente (CalendarController::srcPeriodicControls),
|
|
// quindi non esiste alcuna riga review_schedule da ripulire.
|
|
$del = Database::delete('periodic_controls', 'id = ? AND organization_id = ?', [$id, $orgId]);
|
|
if ($del === 0) { $this->jsonError('Controllo non trovato', 404, 'NOT_FOUND'); }
|
|
$this->logAudit('periodic_control_deleted', 'periodic_control', $id);
|
|
$this->jsonSuccess(null, 'Controllo eliminato');
|
|
}
|
|
|
|
/**
|
|
* POST /api/periodic-controls/{id}/executions
|
|
* Body: {executed_at?, outcome, notes?, generate?: 'none'|'nc'|'ac'}
|
|
* Registra l'esecuzione, avanza la scadenza; su esito negativo può generare NC o NC+azione correttiva.
|
|
*/
|
|
public function addExecution(int $id): void
|
|
{
|
|
$this->requireOrgRole(self::MANAGE_ROLES);
|
|
$orgId = $this->getCurrentOrgId();
|
|
$c = Database::fetchOne('SELECT * FROM periodic_controls WHERE id = ? AND organization_id = ?', [$id, $orgId]);
|
|
if (!$c) { $this->jsonError('Controllo non trovato', 404, 'NOT_FOUND'); }
|
|
$b = $this->getJsonBody();
|
|
|
|
$outcome = in_array($b['outcome'] ?? '', ['conforme', 'non_conforme', 'parziale', 'non_applicabile'], true) ? $b['outcome'] : null;
|
|
if ($outcome === null) { $this->jsonError('Esito non valido', 422, 'INVALID_OUTCOME'); }
|
|
$execDate = $this->validateDate($b['executed_at'] ?? null, 'executed_at') ?? date('Y-m-d');
|
|
$generate = in_array($b['generate'] ?? 'none', ['none', 'nc', 'ac'], true) ? $b['generate'] : 'none';
|
|
|
|
$execId = (int) Database::insert('periodic_control_executions', [
|
|
'control_id' => $id, 'executed_at' => $execDate, 'executed_by' => $this->getCurrentUserId(),
|
|
'outcome' => $outcome, 'notes' => $this->nullableStr($b['notes'] ?? null),
|
|
]);
|
|
|
|
// avanza la scadenza
|
|
$next = $this->advance($execDate, $c['frequency'], $c['frequency_days'] !== null ? (int) $c['frequency_days'] : null);
|
|
Database::update('periodic_controls', ['last_executed_at' => $execDate, 'next_due_date' => $next], 'id = ? AND organization_id = ?', [$id, $orgId]);
|
|
|
|
// generazione NC / AC su esito negativo
|
|
$ncr = null;
|
|
if (in_array($outcome, ['non_conforme', 'parziale'], true) && $generate !== 'none') {
|
|
$ncr = $this->generateNc($orgId, $c, $execId, $outcome, $generate === 'ac');
|
|
Database::update('periodic_control_executions', ['ncr_id' => $ncr['id']], 'id = ?', [$execId]);
|
|
}
|
|
|
|
$this->logAudit('periodic_control_executed', 'periodic_control', $id, ['execution_id' => $execId, 'outcome' => $outcome, 'ncr' => $ncr['ncr_code'] ?? null]);
|
|
$this->jsonSuccess(['execution_id' => $execId, 'next_due_date' => $next, 'ncr' => $ncr], 'Esecuzione registrata', 201);
|
|
}
|
|
|
|
/** GET /api/periodic-controls/{id}/report */
|
|
public function report(int $id): void
|
|
{
|
|
$this->requireOrgAccess();
|
|
$orgId = $this->getCurrentOrgId();
|
|
$c = Database::fetchOne(
|
|
"SELECT c.*, r.role_name AS owner_role, o.name AS org_name FROM periodic_controls c
|
|
LEFT JOIN org_roles r ON r.id = c.owner_role_id JOIN organizations o ON o.id = c.organization_id
|
|
WHERE c.id = ? AND c.organization_id = ?",
|
|
[$id, $orgId]
|
|
);
|
|
if (!$c) { $this->jsonError('Controllo non trovato', 404, 'NOT_FOUND'); }
|
|
$exec = Database::fetchAll(
|
|
"SELECT e.executed_at, e.outcome, e.notes, n.ncr_code, u.full_name AS by_name
|
|
FROM periodic_control_executions e LEFT JOIN non_conformities n ON n.id = e.ncr_id
|
|
LEFT JOIN users u ON u.id = e.executed_by WHERE e.control_id = ? ORDER BY e.executed_at DESC, e.id DESC",
|
|
[$id]
|
|
);
|
|
header('Content-Type: text/html; charset=utf-8');
|
|
echo $this->renderReport($c, $exec);
|
|
exit;
|
|
}
|
|
|
|
// ── HELPER ───────────────────────────────────────────────────────────────
|
|
private function dueStatus(?string $next, string $today, string $status): string
|
|
{
|
|
if ($status === 'suspended') { return 'suspended'; }
|
|
if ($next === null) { return 'none'; }
|
|
if ($next < $today) { return 'overdue'; }
|
|
$soon = date('Y-m-d', strtotime($today . ' +14 days'));
|
|
return ($next <= $soon) ? 'due_soon' : 'upcoming';
|
|
}
|
|
|
|
/**
|
|
* Avanza una data secondo la frequenza.
|
|
* Per le frequenze a mesi si "ancora" al primo del mese e si rimette il giorno
|
|
* limato all'ultimo valido del mese di destinazione: evita l'overflow di
|
|
* strtotime('+1 month') (es. 31/01 -> 03/03, febbraio saltato).
|
|
*/
|
|
private function advance(string $from, string $freq, ?int $days): string
|
|
{
|
|
$months = ['mensile' => 1, 'trimestrale' => 3, 'semestrale' => 6, 'annuale' => 12];
|
|
$d = new DateTimeImmutable($from); // $from è sempre un Y-m-d validato (validateDate / date('Y-m-d'))
|
|
if (isset($months[$freq])) {
|
|
$target = $d->modify('first day of this month')->modify('+' . $months[$freq] . ' months');
|
|
$day = min((int) $d->format('d'), (int) $target->format('t'));
|
|
return $target->setDate((int) $target->format('Y'), (int) $target->format('m'), $day)->format('Y-m-d');
|
|
}
|
|
// giornaliero / settimanale / custom (e qualsiasi freq sconosciuta -> a giorni)
|
|
$simple = ['giornaliero' => '+1 day', 'settimanale' => '+1 week'];
|
|
$mod = $simple[$freq] ?? ('+' . max(1, (int) $days) . ' days');
|
|
if ($freq === 'custom') { $mod = '+' . max(1, (int) $days) . ' days'; }
|
|
return $d->modify($mod)->format('Y-m-d');
|
|
}
|
|
|
|
/** Crea una NC (e opzionalmente una prima azione correttiva) da un'esecuzione fallita. */
|
|
private function generateNc(int $orgId, array $ctl, int $execId, string $outcome, bool $withAction): array
|
|
{
|
|
$titleBase = ($ctl['control_ref'] ? '[' . $ctl['control_ref'] . '] ' : '') . 'Controllo periodico: ' . $ctl['title'];
|
|
$title = mb_strlen($titleBase) > 200 ? mb_substr($titleBase, 0, 197) . '...' : $titleBase;
|
|
$sev = $outcome === 'non_conforme' ? 'major' : 'minor';
|
|
$ncrCode = $this->generateCode('NCR');
|
|
$ncrId = (int) Database::insert('non_conformities', [
|
|
'organization_id' => $orgId, 'ncr_code' => $ncrCode, 'title' => $title,
|
|
'description' => "Rilevata dal monitoraggio del controllo periodico {$ctl['code']} ({$ctl['title']}): esito " . str_replace('_', ' ', $outcome) . '.',
|
|
'source' => 'monitoring', 'source_entity_type' => 'periodic_control_execution', 'source_entity_id' => $execId,
|
|
'severity' => $sev, 'status' => 'open', 'identified_by' => $this->getCurrentUserId(),
|
|
]);
|
|
$out = ['id' => $ncrId, 'ncr_code' => $ncrCode];
|
|
if ($withAction) {
|
|
$capaCode = $this->generateCode('CAPA');
|
|
$capaId = (int) Database::insert('capa_actions', [
|
|
'ncr_id' => $ncrId, 'organization_id' => $orgId, 'capa_code' => $capaCode, 'action_type' => 'corrective',
|
|
'title' => 'Azione correttiva per ' . $title, 'status' => 'planned',
|
|
]);
|
|
$out['capa_id'] = $capaId; $out['capa_code'] = $capaCode;
|
|
}
|
|
return $out;
|
|
}
|
|
|
|
private function nextCode(int $orgId): string
|
|
{
|
|
$row = Database::fetchOne(
|
|
"SELECT MAX(CAST(SUBSTRING_INDEX(code, '-', -1) AS UNSIGNED)) AS n
|
|
FROM periodic_controls WHERE organization_id = ? AND code LIKE 'CTL-%'",
|
|
[$orgId]
|
|
);
|
|
return 'CTL-' . str_pad((string) (((int) ($row['n'] ?? 0)) + 1), 3, '0', STR_PAD_LEFT);
|
|
}
|
|
|
|
private function validateOwnerRole($id, int $orgId): ?int
|
|
{
|
|
$id = ($id === null || $id === '') ? null : (int) $id;
|
|
if ($id === null) { return null; }
|
|
$r = Database::fetchOne('SELECT id FROM org_roles WHERE id = ? AND organization_id = ?', [$id, $orgId]);
|
|
if (!$r) { $this->jsonError('Ruolo responsabile non valido', 422, 'INVALID_OWNER'); }
|
|
return $id;
|
|
}
|
|
|
|
private function validateDate($v, string $field): ?string
|
|
{
|
|
if ($v === null || $v === '') { return null; }
|
|
$d = trim((string) $v); $dt = DateTime::createFromFormat('Y-m-d', $d);
|
|
if (!$dt || $dt->format('Y-m-d') !== $d) { $this->jsonError("Data $field non valida (AAAA-MM-GG)", 422, 'INVALID_DATE'); }
|
|
return $d;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
private function renderReport(array $c, array $exec): string
|
|
{
|
|
$esc = static fn($s) => htmlspecialchars((string) $s, ENT_QUOTES, 'UTF-8');
|
|
$rows = '';
|
|
foreach ($exec as $e) {
|
|
$rows .= '<tr><td>' . $esc($e['executed_at']) . '</td><td>' . $esc(str_replace('_', ' ', $e['outcome'])) . '</td>'
|
|
. '<td>' . $esc($e['by_name'] ?? '') . '</td><td>' . $esc($e['ncr_code'] ?? '') . '</td><td>' . $esc($e['notes'] ?? '') . '</td></tr>';
|
|
}
|
|
if ($rows === '') { $rows = '<tr><td colspan="5" style="color:#888">Nessuna esecuzione registrata.</td></tr>'; }
|
|
$h = '<!DOCTYPE html><html lang="it"><head><meta charset="UTF-8"><title>Controllo periodico ' . $esc($c['code']) . '</title>'
|
|
. '<style>body{font-family:-apple-system,Segoe UI,Roboto,Arial,sans-serif;color:#1e293b;max-width:900px;margin:24px auto;padding:0 16px}'
|
|
. 'h1{font-size:20px;color:#1e40af} .sub{color:#64748b;font-size:13px;margin-bottom:18px} table{width:100%;border-collapse:collapse;font-size:13px;margin-top:8px}'
|
|
. 'th,td{border:1px solid #e2e8f0;padding:7px 10px;text-align:left} th{background:#f8fafc} .meta td{border:none;padding:3px 0}</style></head><body>';
|
|
$h .= '<h1>Scheda controllo periodico ' . $esc($c['code']) . '</h1>';
|
|
$h .= '<div class="sub">' . $esc($c['org_name']) . ' · generato il ' . date('d/m/Y H:i') . '</div>';
|
|
$h .= '<table class="meta">'
|
|
. '<tr><td style="width:200px"><strong>Titolo</strong></td><td>' . $esc($c['title']) . '</td></tr>'
|
|
. '<tr><td><strong>Categoria</strong></td><td>' . $esc($c['category'] ?? '-') . '</td></tr>'
|
|
. '<tr><td><strong>Controllo collegato</strong></td><td>' . $esc($c['control_ref'] ?? '-') . '</td></tr>'
|
|
. '<tr><td><strong>Responsabile</strong></td><td>' . $esc($c['owner_role'] ?? '-') . '</td></tr>'
|
|
. '<tr><td><strong>Frequenza</strong></td><td>' . $esc($c['frequency']) . ($c['frequency'] === 'custom' ? ' (' . $esc($c['frequency_days']) . ' gg)' : '') . '</td></tr>'
|
|
. '<tr><td><strong>Prossima scadenza</strong></td><td>' . $esc($c['next_due_date'] ?? '-') . '</td></tr>'
|
|
. '<tr><td><strong>Metodo</strong></td><td>' . $esc($c['method'] ?? '-') . '</td></tr></table>';
|
|
$h .= '<h2 style="font-size:15px;color:#1e40af;margin-top:22px">Storico esecuzioni (evidenza)</h2>';
|
|
$h .= '<table><thead><tr><th>Data</th><th>Esito</th><th>Eseguito da</th><th>NC</th><th>Note</th></tr></thead><tbody>' . $rows . '</tbody></table>';
|
|
$h .= '<p style="margin-top:30px;color:#9ca3af;font-size:.78rem;">Documento generato da NIS2 Agile. Il monitoraggio periodico dei controlli e\' buona prassi (ISO 27001 §9.1/A.8.16); gli obblighi normativi in Italia derivano da NIS2 / D.Lgs. 138/2024. Strumento di supporto, non un parere legale.</p>';
|
|
$h .= '</body></html>';
|
|
return $h;
|
|
}
|
|
}
|