entity_type='procedure' * - compliance_controls.next_review_date -> entity_type='custom' * - risk_treatments.due_date -> entity_type='risk' (JOIN risks: la * tabella risk_treatments NON ha organization_id, si filtra via risks) * * Ancoraggio normativo (docs/DESIGN_A4_RELATIONAL.md): * - GV.PO-02: policy/procedure riesaminate periodicamente * - GV.SC-07: sicurezza fornitori monitorata e rivista * - PR.AT: formazione/sensibilizzazione ricorrenti * - DE.CM: monitoraggio continuo * - art. 24 D.Lgs. 138/2024: misure di gestione del rischio mantenute aggiornate * Disclaimer: la periodicita' puntuale e' buona prassi dove non fissata da norma; * strumento di supporto, non un parere legale. * * NOTE strutturali (verificate sul codice reale): * - DB API: Database::query/fetchAll/fetchOne/insert/update/delete/count * (NON esiste Database::execute). * - Le AZIONI sono capa_actions (figlie di non_conformities): 4.4 non le usa. */ require_once __DIR__ . '/BaseController.php'; class ReviewScheduleController extends BaseController { private const MANAGE_ROLES = ['org_admin', 'compliance_manager']; private const ENTITY_TYPES = [ 'role', 'skill', 'inventory', 'procedure', 'risk', 'supplier', 'measure', 'custom', 'stakeholder_activity', 'internal_audit', ]; // ═══════════════════════════════════════════════════════════════════════ // LETTURA // ═══════════════════════════════════════════════════════════════════════ /** * GET /api/review-schedule/list * Elenco scadenze dell'org, ordinate per prossima revisione. Lo stato e' * ricalcolato LIVE (override del valore in colonna). Include il nome del * ruolo owner e i contatori di sintesi. */ public function list(): void { $this->requireOrgAccess(); $orgId = $this->getCurrentOrgId(); $rows = Database::fetchAll( 'SELECT rs.id, rs.entity_type, rs.entity_id, rs.title, rs.owner_role_id, rs.frequency_months, rs.last_reviewed_at, rs.next_review_date, rs.notes, rs.created_at, rs.updated_at, r.role_name AS owner_role_name FROM review_schedule rs LEFT JOIN org_roles r ON r.id = rs.owner_role_id WHERE rs.organization_id = ? ORDER BY rs.next_review_date ASC, rs.id ASC', [$orgId] ); $items = []; $counts = ['ok' => 0, 'due' => 0, 'overdue' => 0]; foreach ($rows as $r) { $status = $this->computeStatus($r['next_review_date']); $counts[$status]++; $items[] = [ 'id' => (int) $r['id'], 'entity_type' => $r['entity_type'], 'entity_id' => $r['entity_id'] !== null ? (int) $r['entity_id'] : null, 'title' => $r['title'], 'owner_role_id' => $r['owner_role_id'] !== null ? (int) $r['owner_role_id'] : null, 'owner_role_name' => $r['owner_role_name'], 'frequency_months' => $r['frequency_months'] !== null ? (int) $r['frequency_months'] : null, 'last_reviewed_at' => $r['last_reviewed_at'], 'next_review_date' => $r['next_review_date'], 'status' => $status, 'notes' => $r['notes'], 'created_at' => $r['created_at'], 'updated_at' => $r['updated_at'], ]; } $this->jsonSuccess([ 'items' => $items, 'total' => count($items), 'ok' => $counts['ok'], 'due' => $counts['due'], 'overdue' => $counts['overdue'], ]); } // ═══════════════════════════════════════════════════════════════════════ // SCRITTURA // ═══════════════════════════════════════════════════════════════════════ /** * POST /api/review-schedule/create * Body: {title*, next_review_date*, entity_type?, entity_id?, owner_role_id?, * frequency_months?, notes?} */ public function create(): void { $this->requireOrgRole(self::MANAGE_ROLES); $this->validateRequired(['title', 'next_review_date']); $orgId = $this->getCurrentOrgId(); $type = $this->validateEntityType($this->getParam('entity_type', 'custom')); $nextDate = $this->validateDate($this->getParam('next_review_date')); $ownerId = $this->resolveOwnerRoleId($this->getParam('owner_role_id')); $freq = $this->nullablePositiveInt($this->getParam('frequency_months')); $entityId = $this->nullableInt($this->getParam('entity_id')); $id = Database::insert('review_schedule', [ 'organization_id' => $orgId, 'entity_type' => $type, 'entity_id' => $entityId, 'title' => trim((string) $this->getParam('title')), 'owner_role_id' => $ownerId, 'frequency_months' => $freq, 'last_reviewed_at' => null, 'next_review_date' => $nextDate, 'status' => $this->computeStatus($nextDate), 'notes' => $this->nullableText($this->getParam('notes')), 'created_by' => $this->getCurrentUserId(), ]); $this->logAudit('review_schedule_created', 'review_schedule', $id, [ 'entity_type' => $type, 'next_review_date' => $nextDate, ]); $this->jsonSuccess(['id' => $id], 'Voce di scadenziario creata', 201); } /** * PUT /api/review-schedule/{id} * Aggiorna solo i campi presenti nel body. Ricalcola lo stato se cambia la * prossima revisione. */ public function update(int $id): void { $this->requireOrgRole(self::MANAGE_ROLES); $orgId = $this->getCurrentOrgId(); $row = $this->fetchEntryOrFail($id, $orgId); $updates = []; if ($this->hasParam('title')) { $title = trim((string) $this->getParam('title')); if ($title === '') { $this->jsonError('Il titolo non puo essere vuoto', 422, 'INVALID_TITLE'); } $updates['title'] = $title; } if ($this->hasParam('entity_type')) { $updates['entity_type'] = $this->validateEntityType($this->getParam('entity_type')); } if ($this->hasParam('entity_id')) { $updates['entity_id'] = $this->nullableInt($this->getParam('entity_id')); } if ($this->hasParam('owner_role_id')) { $updates['owner_role_id'] = $this->resolveOwnerRoleId($this->getParam('owner_role_id')); } if ($this->hasParam('frequency_months')) { $updates['frequency_months'] = $this->nullablePositiveInt($this->getParam('frequency_months')); } if ($this->hasParam('notes')) { $updates['notes'] = $this->nullableText($this->getParam('notes')); } if ($this->hasParam('next_review_date')) { $nextDate = $this->validateDate($this->getParam('next_review_date')); $updates['next_review_date'] = $nextDate; $updates['status'] = $this->computeStatus($nextDate); } if (empty($updates)) { $this->jsonError('Nessun campo da aggiornare', 400, 'NO_FIELDS'); } Database::update('review_schedule', $updates, 'id = ? AND organization_id = ?', [$id, $orgId]); $this->logAudit('review_schedule_updated', 'review_schedule', $id, array_keys($updates)); $this->jsonSuccess(['id' => $id], 'Voce di scadenziario aggiornata'); } /** * DELETE /api/review-schedule/{id} */ public function delete(int $id): void { $this->requireOrgRole(self::MANAGE_ROLES); $orgId = $this->getCurrentOrgId(); $deleted = Database::delete('review_schedule', 'id = ? AND organization_id = ?', [$id, $orgId]); if ($deleted === 0) { $this->jsonError('Voce di scadenziario non trovata', 404, 'REVIEW_NOT_FOUND'); } $this->logAudit('review_schedule_deleted', 'review_schedule', $id, null); $this->jsonSuccess(null, 'Voce di scadenziario eliminata'); } /** * POST /api/review-schedule/{id}/complete * Segna la voce come revisionata oggi. Se ha una frequenza, avanza la * prossima revisione di frequency_months a partire da oggi. */ public function complete(int $id): void { $this->requireOrgRole(self::MANAGE_ROLES); $orgId = $this->getCurrentOrgId(); $row = $this->fetchEntryOrFail($id, $orgId); $today = date('Y-m-d'); $updates = ['last_reviewed_at' => $today]; $freq = $row['frequency_months'] !== null ? (int) $row['frequency_months'] : null; if ($freq !== null && $freq > 0) { $nextDate = date('Y-m-d', strtotime("+{$freq} months", strtotime($today))); $updates['next_review_date'] = $nextDate; $updates['status'] = $this->computeStatus($nextDate); } else { // Nessuna cadenza: la prossima revisione resta invariata, ricalcolo lo stato. $updates['status'] = $this->computeStatus($row['next_review_date']); $nextDate = $row['next_review_date']; } Database::update('review_schedule', $updates, 'id = ? AND organization_id = ?', [$id, $orgId]); $this->logAudit('review_schedule_completed', 'review_schedule', $id, [ 'last_reviewed_at' => $today, 'next_review_date' => $nextDate, ]); $this->jsonSuccess([ 'id' => $id, 'last_reviewed_at' => $today, 'next_review_date' => $nextDate, 'status' => $updates['status'], ], 'Revisione registrata'); } /** * POST /api/review-schedule/sync * Upsert idempotente dalle scadenze gia' presenti (policy / controlli / * trattamenti rischio), tutte org-scoped. Anti-dup su (entity_type, entity_id): * se la voce esiste e la data sorgente e' cambiata la aggiorna, altrimenti * crea. Le voci create a mano (entity_type='custom', entity_id NULL) non * vengono toccate. */ public function sync(): void { $this->requireOrgRole(self::MANAGE_ROLES); $orgId = $this->getCurrentOrgId(); $userId = $this->getCurrentUserId(); $created = 0; $updated = 0; $skipped = 0; // 1. Procedure (policies) con next_review_date impostata. $policies = Database::fetchAll( 'SELECT id, title, next_review_date FROM policies WHERE organization_id = ? AND next_review_date IS NOT NULL AND deleted_at IS NULL', [$orgId] ); foreach ($policies as $p) { $this->upsertSyncEntry( $orgId, $userId, 'procedure', (int) $p['id'], (string) $p['title'], (string) $p['next_review_date'], 'Sincronizzato da procedura/policy', $created, $updated, $skipped ); } // 2. Controlli di compliance con next_review_date impostata (entity_type='custom'). $controls = Database::fetchAll( 'SELECT id, control_code, title, next_review_date FROM compliance_controls WHERE organization_id = ? AND next_review_date IS NOT NULL', [$orgId] ); foreach ($controls as $c) { $label = trim(((string) ($c['control_code'] ?? '')) . ' — ' . ((string) ($c['title'] ?? ''))); $label = trim($label, ' —'); if ($label === '') { $label = 'Controllo #' . (int) $c['id']; } $this->upsertSyncEntry( $orgId, $userId, 'custom', (int) $c['id'], $label, (string) $c['next_review_date'], 'Sincronizzato da controllo di compliance', $created, $updated, $skipped ); } // 3. Trattamenti rischio (risk_treatments.due_date) — org via JOIN su risks // (risk_treatments NON ha organization_id). $treatments = Database::fetchAll( 'SELECT rt.id, rt.due_date, r.title FROM risk_treatments rt JOIN risks r ON r.id = rt.risk_id WHERE r.organization_id = ? AND rt.due_date IS NOT NULL AND r.deleted_at IS NULL', [$orgId] ); foreach ($treatments as $t) { $title = (string) ($t['title'] ?? ''); if ($title === '') { $title = 'Rischio (trattamento #' . (int) $t['id'] . ')'; } $this->upsertSyncEntry( $orgId, $userId, 'risk', (int) $t['id'], $title, (string) $t['due_date'], 'Sincronizzato da trattamento del rischio', $created, $updated, $skipped ); } $this->logAudit('review_schedule_synced', 'review_schedule', null, [ 'created' => $created, 'updated' => $updated, 'skipped' => $skipped, ]); $this->jsonSuccess([ 'created' => $created, 'updated' => $updated, 'skipped' => $skipped, ], 'Sincronizzazione completata'); } // ═══════════════════════════════════════════════════════════════════════ // PRIVATI // ═══════════════════════════════════════════════════════════════════════ /** * Stato calcolato LIVE: scaduto (overdue) se la data e' passata; in scadenza * (due) se entro 30 giorni; altrimenti ok. Confronto a granularita' giorno. */ private function computeStatus(string $nextDate): string { $next = strtotime($nextDate); $today = strtotime(date('Y-m-d')); if ($next === false) { return 'ok'; } if ($next < $today) { return 'overdue'; } if ($next <= strtotime('+30 days', $today)) { return 'due'; } return 'ok'; } /** Voce di scadenziario org-scoped oppure 404 (anti-IDOR). */ private function fetchEntryOrFail(int $id, int $orgId): array { $row = Database::fetchOne( 'SELECT * FROM review_schedule WHERE id = ? AND organization_id = ?', [$id, $orgId] ); if (!$row) { $this->jsonError('Voce di scadenziario non trovata', 404, 'REVIEW_NOT_FOUND'); } return $row; } /** * owner_role_id opzionale: NULL/'' => null; altrimenti il ruolo deve * appartenere all'org corrente (anti-IDOR), 404 se non trovato. */ private function resolveOwnerRoleId($raw): ?int { if ($raw === null || $raw === '' || (int) $raw === 0) { return null; } $roleId = (int) $raw; $exists = Database::count( 'org_roles', 'id = ? AND organization_id = ?', [$roleId, $this->getCurrentOrgId()] ); if ($exists === 0) { $this->jsonError('Ruolo owner non trovato in questa organizzazione', 404, 'ROLE_NOT_FOUND'); } return $roleId; } private function validateEntityType($t): string { $t = strtolower((string) $t); if ($t === '') { return 'custom'; } if (!in_array($t, self::ENTITY_TYPES, true)) { $this->jsonError('Tipo entita non valido', 422, 'INVALID_ENTITY_TYPE'); } return $t; } /** Valida data in formato Y-m-d (rifiuta valori non-data o impossibili). */ private function validateDate($raw): string { $d = trim((string) $raw); $dt = DateTime::createFromFormat('Y-m-d', $d); if (!$dt || $dt->format('Y-m-d') !== $d) { $this->jsonError('Data non valida (atteso formato AAAA-MM-GG)', 422, 'INVALID_DATE'); } return $d; } private function nullableInt($v): ?int { if ($v === null || $v === '') { return null; } return (int) $v; } private function nullablePositiveInt($v): ?int { if ($v === null || $v === '') { return null; } $i = (int) $v; return $i > 0 ? $i : null; } private function nullableText($v): ?string { if ($v === null) { return null; } $s = trim((string) $v); return $s === '' ? null : $s; } /** * Upsert idempotente di una voce di sync su (entity_type, entity_id): * - non esiste -> insert (status calcolato, owner/frequenza null) * - esiste con data diversa -> update next_review_date + status * - esiste con stessa data -> skip */ private function upsertSyncEntry( int $orgId, ?int $userId, string $type, int $entityId, string $title, string $nextDate, string $note, int &$created, int &$updated, int &$skipped ): void { $existing = Database::fetchOne( 'SELECT id, next_review_date FROM review_schedule WHERE organization_id = ? AND entity_type = ? AND entity_id = ?', [$orgId, $type, $entityId] ); if ($existing) { if ((string) $existing['next_review_date'] !== $nextDate) { Database::update('review_schedule', [ 'next_review_date' => $nextDate, 'status' => $this->computeStatus($nextDate), ], 'id = ? AND organization_id = ?', [(int) $existing['id'], $orgId]); $updated++; } else { $skipped++; } return; } Database::insert('review_schedule', [ 'organization_id' => $orgId, 'entity_type' => $type, 'entity_id' => $entityId, 'title' => mb_substr($title, 0, 255), 'owner_role_id' => null, 'frequency_months' => null, 'last_reviewed_at' => null, 'next_review_date' => $nextDate, 'status' => $this->computeStatus($nextDate), 'notes' => $note, 'created_by' => $userId, ]); $created++; } }