requireOrgAccess(); $orgId = $this->getCurrentOrgId(); [$from, $to] = $this->resolveRange(); $typeFilter = $this->resolveTypeFilter(); $events = $this->collect($orgId, $from, $to); // Filtro per tipo richiesto (applicato in PHP, gli eventi sono pochi per org). if ($typeFilter !== null) { $events = array_values(array_filter($events, static fn($e) => in_array($e['type'], $typeFilter, true))); } // Ordina: overdue prima, poi per data crescente. usort($events, static function ($a, $b) { $oa = $a['status'] === 'overdue' ? 0 : 1; $ob = $b['status'] === 'overdue' ? 0 : 1; if ($oa !== $ob) { return $oa <=> $ob; } return strcmp($a['date'], $b['date']); }); $this->jsonSuccess([ 'from' => $from, 'to' => $to, 'today' => date('Y-m-d'), 'due_soon_days'=> self::DUE_SOON_DAYS, 'known_types' => self::KNOWN_TYPES, 'count' => count($events), 'events' => $events, ]); } // ───────────────────────────────────────────────────────────────────────── // GET /api/calendar/summary — conteggi per stato (e per tipo) // ───────────────────────────────────────────────────────────────────────── public function summary(): void { $this->requireOrgAccess(); $orgId = $this->getCurrentOrgId(); [$from, $to] = $this->resolveRange(); $events = $this->collect($orgId, $from, $to); $byStatus = ['overdue' => 0, 'due_soon' => 0, 'upcoming' => 0, 'done' => 0]; $byType = []; foreach ($events as $e) { $byStatus[$e['status']] = ($byStatus[$e['status']] ?? 0) + 1; $byType[$e['type']] = ($byType[$e['type']] ?? 0) + 1; } // Aperte = tutte tranne done (utile come badge dashboard). $open = $byStatus['overdue'] + $byStatus['due_soon'] + $byStatus['upcoming']; $this->jsonSuccess([ 'from' => $from, 'to' => $to, 'today' => date('Y-m-d'), 'total' => count($events), 'open' => $open, 'by_status' => $byStatus, 'by_type' => $byType, ]); } // ───────────────────────────────────────────────────────────────────────── // RACCOLTA — UNION di tutte le sorgenti, ognuna difensiva (try/catch) // ───────────────────────────────────────────────────────────────────────── /** * Raccoglie e normalizza tutti gli eventi della finestra [from,to] per l'org. * Ogni sorgente che fallisce (tabella/colonna mancante) viene saltata senza * far cadere l'intero calendario. */ private function collect(int $orgId, string $from, string $to): array { $events = []; $sources = [ 'incidents' => fn() => $this->srcIncidents($orgId, $from, $to), 'policies' => fn() => $this->srcPolicies($orgId, $from, $to), 'risk_treatments' => fn() => $this->srcRiskTreatments($orgId, $from, $to), 'controls' => fn() => $this->srcControls($orgId, $from, $to), 'non_conformities' => fn() => $this->srcNonConformities($orgId, $from, $to), 'capa_actions' => fn() => $this->srcCapaActions($orgId, $from, $to), 'training' => fn() => $this->srcTraining($orgId, $from, $to), 'stk_activities' => fn() => $this->srcStkActivities($orgId, $from, $to), '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 { foreach ($rows() as $ev) { if ($ev !== null) { $events[] = $ev; } } } catch (\Throwable $e) { // Sorgente non disponibile (es. tabella di un modulo non ancora migrato): // si ignora senza compromettere le altre fonti. continue; } } return $events; } /** Incidenti Art.23: early_warning / notification / final_report (datetime). */ private function srcIncidents(int $orgId, string $from, string $to): array { $rows = Database::fetchAll( 'SELECT id, title, severity, early_warning_due, early_warning_sent_at, notification_due, notification_sent_at, final_report_due, final_report_sent_at FROM incidents WHERE organization_id = ? AND is_significant = 1 AND status NOT IN ("closed", "post_mortem")', [$orgId] ); $out = []; foreach ($rows as $r) { if ($r['early_warning_due'] && !$r['early_warning_sent_at']) { $out[] = $this->makeEvent('incidents', 'incident_early_warning', 'Early Warning: ' . $r['title'], $r['early_warning_due'], 'critical', 'incident', (int) $r['id'], '/incidents.html', $from, $to, false); } if ($r['notification_due'] && !$r['notification_sent_at']) { $out[] = $this->makeEvent('incidents', 'incident_notification', 'Notifica CSIRT: ' . $r['title'], $r['notification_due'], 'high', 'incident', (int) $r['id'], '/incidents.html', $from, $to, false); } if ($r['final_report_due'] && !$r['final_report_sent_at']) { $out[] = $this->makeEvent('incidents', 'incident_final_report', 'Report finale: ' . $r['title'], $r['final_report_due'], 'medium', 'incident', (int) $r['id'], '/incidents.html', $from, $to, false); } } return array_values(array_filter($out)); } /** Revisione policy/procedure: policies.next_review_date (status != archived). */ private function srcPolicies(int $orgId, string $from, string $to): array { $rows = Database::fetchAll( 'SELECT id, title, next_review_date FROM policies WHERE organization_id = ? AND next_review_date IS NOT NULL AND status NOT IN ("archived")', [$orgId] ); $out = []; foreach ($rows as $r) { $out[] = $this->makeEvent('policies', 'policy_review', 'Revisione policy: ' . $r['title'], $r['next_review_date'], 'medium', 'policy', (int) $r['id'], '/policies.html', $from, $to, false); } return array_values(array_filter($out)); } /** Trattamenti rischio: risk_treatments.due_date JOIN risks (org via risks). */ private function srcRiskTreatments(int $orgId, string $from, string $to): array { $rows = Database::fetchAll( 'SELECT rt.id, rt.due_date, r.title AS risk_title FROM risk_treatments rt JOIN risks r ON r.id = rt.risk_id WHERE r.organization_id = ? AND rt.status IN ("planned", "in_progress") AND rt.due_date IS NOT NULL', [$orgId] ); $out = []; foreach ($rows as $r) { $out[] = $this->makeEvent('risk_treatments', 'risk_treatment', 'Trattamento rischio: ' . $r['risk_title'], $r['due_date'], 'medium', 'risk_treatment', (int) $r['id'], '/risks.html', $from, $to, false); } return array_values(array_filter($out)); } /** Revisione controlli: compliance_controls.next_review_date (non verificati/in corso). */ private function srcControls(int $orgId, string $from, string $to): array { $rows = Database::fetchAll( 'SELECT id, control_code, title, next_review_date, status FROM compliance_controls WHERE organization_id = ? AND next_review_date IS NOT NULL', [$orgId] ); $out = []; foreach ($rows as $r) { $title = trim((string) $r['control_code'] . ' ' . (string) $r['title']); $out[] = $this->makeEvent('compliance_controls', 'control_review', 'Revisione controllo: ' . $title, $r['next_review_date'], 'medium', 'compliance_control', (int) $r['id'], '/reports.html', $from, $to, false); } return array_values(array_filter($out)); } /** Non conformita': non_conformities.target_close_date (status non chiuso). */ private function srcNonConformities(int $orgId, string $from, string $to): array { $rows = Database::fetchAll( 'SELECT id, ncr_code, title, target_close_date FROM non_conformities WHERE organization_id = ? AND target_close_date IS NOT NULL AND status NOT IN ("closed", "cancelled")', [$orgId] ); $out = []; foreach ($rows as $r) { $title = trim((string) $r['ncr_code'] . ' ' . (string) $r['title']); $out[] = $this->makeEvent('non_conformities', 'nc_target_close', 'Chiusura NC: ' . $title, $r['target_close_date'], 'high', 'non_conformity', (int) $r['id'], '/reports.html', $from, $to, false); } return array_values(array_filter($out)); } /** Azioni correttive: capa_actions.due_date (status non completed/verified). */ private function srcCapaActions(int $orgId, string $from, string $to): array { $rows = Database::fetchAll( 'SELECT id, capa_code, title, due_date FROM capa_actions WHERE organization_id = ? AND due_date IS NOT NULL AND status NOT IN ("completed", "verified")', [$orgId] ); $out = []; foreach ($rows as $r) { $title = trim((string) $r['capa_code'] . ' ' . (string) $r['title']); $out[] = $this->makeEvent('capa_actions', 'capa_action', 'Azione correttiva: ' . $title, $r['due_date'], 'medium', 'capa_action', (int) $r['id'], '/reports.html', $from, $to, false); } return array_values(array_filter($out)); } /** Formazione: training_assignments.due_date (status assigned/in_progress). */ private function srcTraining(int $orgId, string $from, string $to): array { $rows = Database::fetchAll( 'SELECT ta.id, ta.due_date, tc.title, u.full_name FROM training_assignments ta JOIN training_courses tc ON tc.id = ta.course_id LEFT JOIN users u ON u.id = ta.user_id WHERE ta.organization_id = ? AND ta.status IN ("assigned", "in_progress") AND ta.due_date IS NOT NULL', [$orgId] ); $out = []; foreach ($rows as $r) { $who = $r['full_name'] ? (' - ' . $r['full_name']) : ''; $out[] = $this->makeEvent('training', 'training_due', 'Formazione: ' . $r['title'] . $who, $r['due_date'], 'low', 'training', (int) $r['id'], '/training.html', $from, $to, false); } return array_values(array_filter($out)); } /** * Attivita' stakeholder (C5.2): stk_activities.due_date (preferita) e, se assente, * planned_date. Esclude annullate/completate. due_date "assolta" se completed. */ private function srcStkActivities(int $orgId, string $from, string $to): array { $rows = Database::fetchAll( 'SELECT id, title, type, planned_date, due_date, status FROM stk_activities WHERE organization_id = ? AND status NOT IN ("cancelled")', [$orgId] ); $out = []; foreach ($rows as $r) { $done = ($r['status'] === 'completed'); $date = $r['due_date'] ?: $r['planned_date']; if (!$date) { continue; } $label = ($r['due_date'] ? 'Scadenza attivita': 'Attivita pianificata') . ': ' . $r['title']; $out[] = $this->makeEvent('stk_activities', 'stakeholder_activity', $label, $date, 'medium', 'stk_activity', (int) $r['id'], '/stakeholder-activities.html', $from, $to, $done); } return array_values(array_filter($out)); } /** 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 AND entity_type NOT IN ("internal_audit", "stakeholder_activity")', [$orgId] ); $out = []; foreach ($rows as $r) { $out[] = $this->makeEvent('review_schedule', 'review_schedule', $r['title'], $r['next_review_date'], 'medium', 'review_schedule', (int) $r['id'], '/review-schedule.html', $from, $to, false); } return array_values(array_filter($out)); } /** Audit interni (Modulo A, opzionale): internal_audits.planned_date. */ private function srcInternalAudits(int $orgId, string $from, string $to): array { $rows = Database::fetchAll( 'SELECT id, code, title, planned_date, status FROM internal_audits WHERE organization_id = ? AND planned_date IS NOT NULL', [$orgId] ); $out = []; foreach ($rows as $r) { $done = in_array($r['status'], ['completed', 'cancelled'], true); $title = trim((string) $r['code'] . ' ' . (string) $r['title']); $out[] = $this->makeEvent('internal_audits', 'internal_audit', 'Audit interno: ' . $title, $r['planned_date'], 'medium', 'internal_audit', (int) $r['id'], '/internal-audits.html', $from, $to, $done); } 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. */ private function srcManagementReviewDecisions(int $orgId, string $from, string $to): array { $rows = Database::fetchAll( 'SELECT d.id, d.decision, d.due_date, d.status, mr.code AS review_code FROM management_review_decisions d JOIN management_reviews mr ON mr.id = d.review_id WHERE mr.organization_id = ? AND d.due_date IS NOT NULL', [$orgId] ); $out = []; foreach ($rows as $r) { $done = ($r['status'] === 'done'); $txt = mb_substr((string) $r['decision'], 0, 120); $out[] = $this->makeEvent('management_reviews', 'management_review_decision', 'Decisione riesame: ' . $txt, $r['due_date'], 'medium', 'management_review_decision', (int) $r['id'], '/management-review.html', $from, $to, $done); } return array_values(array_filter($out)); } // ───────────────────────────────────────────────────────────────────────── // HELPER // ───────────────────────────────────────────────────────────────────────── /** * Normalizza una riga in evento di calendario. Ritorna null se la data * (normalizzata a Y-m-d) cade fuori dalla finestra [from,to]. $done forza * lo status a 'done' (scadenza gia' assolta/chiusa), altrimenti lo calcola * LIVE rispetto a oggi. */ private function makeEvent( string $source, string $type, string $title, ?string $rawDate, string $severity, string $entityType, int $entityId, string $link, string $from, string $to, bool $done ): ?array { if (!$rawDate) { return null; } $date = substr((string) $rawDate, 0, 10); // DATETIME o DATE → Y-m-d if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) { return null; } if ($date < $from || $date > $to) { return null; } return [ 'source' => $source, 'type' => $type, 'title' => mb_substr($title, 0, 255), 'date' => $date, 'status' => $this->computeStatus($date, $done), 'severity' => $severity, 'entity_type' => $entityType, 'entity_id' => $entityId, 'link' => $link, ]; } /** Stato LIVE: done | overdue | due_soon (<=N gg) | upcoming. */ private function computeStatus(string $date, bool $done): string { if ($done) { return 'done'; } $today = new DateTimeImmutable('today'); $d = DateTimeImmutable::createFromFormat('!Y-m-d', $date); if (!$d) { return 'upcoming'; } $diffDays = (int) $today->diff($d)->format('%r%a'); if ($diffDays < 0) { return 'overdue'; } if ($diffDays <= self::DUE_SOON_DAYS) { return 'due_soon'; } return 'upcoming'; } /** * Risolve la finestra [from,to]. Default ampio: dal 1° giorno di 1 mese fa al * 1° giorno di 13 mesi avanti (copre tutto cio' che ha senso vedere in un * calendario annuale). Param from/to opzionali sovrascrivono (validati Y-m-d). */ private function resolveRange(): array { $from = $this->validDate($this->getParam('from')); $to = $this->validDate($this->getParam('to')); if (!$from) { $from = (new DateTimeImmutable('first day of this month'))->modify('-1 month')->format('Y-m-d'); } if (!$to) { $to = (new DateTimeImmutable('first day of this month'))->modify('+13 months')->format('Y-m-d'); } if ($from > $to) { [$from, $to] = [$to, $from]; } return [$from, $to]; } /** Filtro tipi: ?types=a,b,c → solo i tipi noti; null = nessun filtro. */ private function resolveTypeFilter(): ?array { $raw = $this->getParam('types'); if ($raw === null || $raw === '') { return null; } $parts = array_filter(array_map('trim', explode(',', (string) $raw)), static fn($t) => $t !== ''); $valid = array_values(array_intersect($parts, self::KNOWN_TYPES)); return $valid ?: null; } /** Valida una data Y-m-d; ritorna la stringa normalizzata o null. */ private function validDate($v): ?string { if ($v === null || $v === '') { return null; } $s = trim((string) $v); $dt = DateTime::createFromFormat('Y-m-d', $s); return ($dt && $dt->format('Y-m-d') === $s) ? $s : null; } }