diff --git a/application/cli/seed_periodic_controls.php b/application/cli/seed_periodic_controls.php new file mode 100644 index 0000000..321e85d --- /dev/null +++ b/application/cli/seed_periodic_controls.php @@ -0,0 +1,74 @@ +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"; diff --git a/application/controllers/CalendarController.php b/application/controllers/CalendarController.php index d9377a1..e220fd1 100644 --- a/application/controllers/CalendarController.php +++ b/application/controllers/CalendarController.php @@ -36,7 +36,7 @@ class CalendarController extends BaseController 'policy_review', 'risk_treatment', 'control_review', 'nc_target_close', 'capa_action', 'training_due', 'stakeholder_activity', 'review_schedule', - 'internal_audit', 'management_review_decision', + 'internal_audit', 'management_review_decision', 'periodic_control', ]; // ───────────────────────────────────────────────────────────────────────── @@ -131,6 +131,7 @@ class CalendarController extends BaseController 'review_schedule' => fn() => $this->srcReviewSchedule($orgId, $from, $to), 'internal_audits' => fn() => $this->srcInternalAudits($orgId, $from, $to), 'mgmt_reviews' => fn() => $this->srcManagementReviewDecisions($orgId, $from, $to), + 'periodic_controls'=> fn() => $this->srcPeriodicControls($orgId, $from, $to), ]; foreach ($sources as $rows) { try { @@ -328,10 +329,14 @@ class CalendarController extends BaseController /** Scadenziario revisioni periodiche (A4 4.4): review_schedule.next_review_date. */ private function srcReviewSchedule(int $orgId, string $from, string $to): array { + // Esclude i tipi che hanno già una sorgente diretta dedicata (internal_audit via + // srcInternalAudits, stakeholder_activity via srcStkActivities): altrimenti + // comparirebbero due volte nel calendario. Restano gli scheduler generici. $rows = Database::fetchAll( 'SELECT id, title, entity_type, next_review_date, last_reviewed_at FROM review_schedule - WHERE organization_id = ? AND next_review_date IS NOT NULL', + WHERE organization_id = ? AND next_review_date IS NOT NULL + AND entity_type NOT IN ("internal_audit", "stakeholder_activity")', [$orgId] ); $out = []; @@ -363,6 +368,25 @@ class CalendarController extends BaseController return array_values(array_filter($out)); } + /** Controlli periodici (mig.057, opzionale): periodic_controls.next_due_date. */ + private function srcPeriodicControls(int $orgId, string $from, string $to): array + { + $rows = Database::fetchAll( + "SELECT id, code, title, next_due_date, status + FROM periodic_controls + WHERE organization_id = ? AND next_due_date IS NOT NULL AND status = 'active'", + [$orgId] + ); + $out = []; + foreach ($rows as $r) { + $title = trim((string) $r['code'] . ' ' . (string) $r['title']); + $out[] = $this->makeEvent('periodic_controls', 'periodic_control', + 'Controllo periodico: ' . $title, $r['next_due_date'], + 'medium', 'periodic_control', (int) $r['id'], '/controlli-periodici.html', $from, $to, false); + } + return array_values(array_filter($out)); + } + /** * Decisioni del riesame di direzione (Modulo B, opzionale): * management_review_decisions.due_date JOIN management_reviews per filtrare per org. diff --git a/application/controllers/PeriodicControlController.php b/application/controllers/PeriodicControlController.php new file mode 100644 index 0000000..8481565 --- /dev/null +++ b/application/controllers/PeriodicControlController.php @@ -0,0 +1,360 @@ +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 .= '
| 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'] ?? '-') . ' |
| Data | Esito | Eseguito da | NC | Note |
|---|
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; + } +} diff --git a/docs/OPEN_TICKETS.md b/docs/OPEN_TICKETS.md index 33d3740..93c4709 100644 --- a/docs/OPEN_TICKETS.md +++ b/docs/OPEN_TICKETS.md @@ -3,4 +3,4 @@ Nessun ticket aperto. --- -_Ultimo sync: 2026-06-14 16:35:01_ +_Ultimo sync: 2026-06-17 09:20:02_ diff --git a/docs/sql/057_periodic_controls.sql b/docs/sql/057_periodic_controls.sql new file mode 100644 index 0000000..7e977b8 --- /dev/null +++ b/docs/sql/057_periodic_controls.sql @@ -0,0 +1,65 @@ +-- ===================================================================== +-- 057 — Controlli periodici (control monitoring con frequenza + esecuzioni + NC/AC) +-- ===================================================================== +-- Registro dei controlli RICORRENTI con frequenza, owner e metodo; ogni +-- ESECUZIONE è registrata (data, esito, note, evidenze su evidence_files) = +-- EVIDENZA del monitoraggio periodico (ISO 27001 §9.1 / A.8.16; buona prassi, +-- non obbligo). Da un esito non conforme si genera una NC o un'azione correttiva +-- (riuso non_conformities/capa_actions). Distinto da: compliance_controls +-- (registro stato), review_schedule (cadenza generica), internal_audits (audit +-- puntuale). Vedi docs/DESIGN_CONTROLLI_PERIODICI.md. +-- +-- Additivo, reversibile. Runner-safe: CREATE TABLE IF NOT EXISTS con FK inline; +-- l'ALTER dell'ENUM non_conformities.source è applicata dal seeder idempotente +-- (preservando i valori) — application/cli/seed_periodic_controls.php. +-- ===================================================================== + +CREATE TABLE IF NOT EXISTS periodic_controls ( + id INT NOT NULL AUTO_INCREMENT, + organization_id INT NOT NULL, + code VARCHAR(20) NULL, -- CTL-NNN per org + title VARCHAR(255) NOT NULL, + description TEXT NULL, + category VARCHAR(100) NULL, + control_ref VARCHAR(32) NULL, -- codice SoA/NIS2 collegato (es. A.8.13) + owner_role_id INT NULL, -- responsabile (org_roles) + frequency ENUM('giornaliero','settimanale','mensile','trimestrale','semestrale','annuale','custom') NOT NULL DEFAULT 'mensile', + frequency_days INT NULL, -- usato se frequency='custom' + method TEXT NULL, -- metodologia di verifica + 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), + 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, -- NC eventualmente generata + 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; + +-- ALTER non_conformities.source: aggiunge 'monitoring' (eseguita dal seeder, idempotente). +-- ALTER TABLE non_conformities MODIFY COLUMN source ENUM('assessment','audit','incident','supplier_review','management_review','external_audit','monitoring','other') NOT NULL DEFAULT 'assessment'; + +-- ROLLBACK (manuale): +-- DROP TABLE IF EXISTS periodic_control_executions +-- DROP TABLE IF EXISTS periodic_controls +-- (non_conformities.source: rimuovere 'monitoring' dall'ENUM dopo aver riclassificato le NC relative) diff --git a/public/_app-bi-demo.html b/public/_app-bi-demo.html index 2fb0ea4..8d3c6d9 100644 --- a/public/_app-bi-demo.html +++ b/public/_app-bi-demo.html @@ -70,9 +70,9 @@ - - - + + + - - - + + + + - + + @@ -165,9 +165,9 @@ bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts'); } - - - + + + - + + @@ -372,9 +372,9 @@ bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts'); } - - - + + + - + + - - - + + + - + + - - - + + + - + + - - - + + +