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 .= '' . $esc($e['executed_at']) . '' . $esc(str_replace('_', ' ', $e['outcome'])) . '' . '' . $esc($e['by_name'] ?? '') . '' . $esc($e['ncr_code'] ?? '') . '' . $esc($e['notes'] ?? '') . ''; } if ($rows === '') { $rows = 'Nessuna esecuzione registrata.'; } $h = 'Controllo periodico ' . $esc($c['code']) . '' . ''; $h .= '

Scheda controllo periodico ' . $esc($c['code']) . '

'; $h .= '
' . $esc($c['org_name']) . ' · generato il ' . date('d/m/Y H:i') . '
'; $h .= '' . '' . '' . '' . '' . '' . '' . '
Titolo' . $esc($c['title']) . '
Categoria' . $esc($c['category'] ?? '-') . '
Controllo collegato' . $esc($c['control_ref'] ?? '-') . '
Responsabile' . $esc($c['owner_role'] ?? '-') . '
Frequenza' . $esc($c['frequency']) . ($c['frequency'] === 'custom' ? ' (' . $esc($c['frequency_days']) . ' gg)' : '') . '
Prossima scadenza' . $esc($c['next_due_date'] ?? '-') . '
Metodo' . $esc($c['method'] ?? '-') . '
'; $h .= '

Storico esecuzioni (evidenza)

'; $h .= '' . $rows . '
DataEsitoEseguito daNCNote
'; $h .= '

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.

'; $h .= ''; return $h; } }