From 2bd97a6bd8f27b2de9329f7d0016f9c3fc004f55 Mon Sep 17 00:00:00 2001 From: DevEnv nis2-agile Date: Wed, 29 Jul 2026 14:33:08 +0200 Subject: [PATCH] [FEAT] Cruscotto Studio (backend): ConsultantController::portfolio MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /api/consultant/portfolio — vista d'insieme per consulente/studio: per ogni azienda visibile all'utente (super_admin=tutte, altri=proprie membership) ritorna compliance score, rischi/incidenti aperti, scadenze in ritardo/in arrivo; + elenco SCADENZE AGGREGATE su tutte le aziende (incidenti CSIRT, policy, trattamenti rischio, formazione) ordinate per data; + KPI d'insieme. Read-only, additivo. Rotta registrata in index.php. Verificato: 5 aziende, 20 scadenze, compliance media 74%. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../controllers/ConsultantController.php | 149 ++++++++++++++++++ public/index.php | 6 + 2 files changed, 155 insertions(+) create mode 100644 application/controllers/ConsultantController.php diff --git a/application/controllers/ConsultantController.php b/application/controllers/ConsultantController.php new file mode 100644 index 0000000..cb0cb29 --- /dev/null +++ b/application/controllers/ConsultantController.php @@ -0,0 +1,149 @@ +requireAuth(); + $user = $this->getCurrentUser() ?? []; + $uid = (int) $this->getCurrentUserId(); + + // Aziende visibili all'utente (stessa regola di OrganizationController::list). + if (($user['role'] ?? '') === 'super_admin') { + $orgs = Database::fetchAll( + "SELECT id, name, entity_type, voluntary_compliance, sector + FROM organizations WHERE is_active = 1 ORDER BY name LIMIT 200" + ); + } else { + $orgs = Database::fetchAll( + "SELECT o.id, o.name, o.entity_type, o.voluntary_compliance, o.sector + FROM organizations o + JOIN user_organizations uo ON uo.organization_id = o.id + WHERE uo.user_id = ? AND o.is_active = 1 ORDER BY o.name LIMIT 200", + [$uid] + ); + } + + $now = date('Y-m-d H:i:s'); + $companies = []; + $allDeadlines = []; + $sumScore = 0; $nScore = 0; $overdueTotal = 0; + + foreach ($orgs as $o) { + $oid = (int) $o['id']; + + $asmt = Database::fetchOne( + "SELECT overall_score FROM assessments + WHERE organization_id = ? AND status = 'completed' + ORDER BY completed_at DESC LIMIT 1", [$oid]); + $score = ($asmt && $asmt['overall_score'] !== null) ? (int) round((float) $asmt['overall_score']) : null; + + $risks = (int) (Database::fetchOne( + "SELECT COUNT(*) c FROM risks WHERE organization_id = ? AND status <> 'closed'", [$oid])['c'] ?? 0); + $incs = (int) (Database::fetchOne( + "SELECT COUNT(*) c FROM incidents WHERE organization_id = ? AND status NOT IN ('closed','post_mortem')", [$oid])['c'] ?? 0); + + $dls = $this->collectDeadlines($oid); + $overdue = 0; + foreach ($dls as $d) { + $d['org_id'] = $oid; + $d['org_name'] = $o['name']; + $d['overdue'] = ($d['due_date'] < $now); + if ($d['overdue']) { $overdue++; } + $allDeadlines[] = $d; + } + + if ($score !== null) { $sumScore += $score; $nScore++; } + $overdueTotal += $overdue; + + $companies[] = [ + 'id' => $oid, + 'name' => $o['name'], + 'entity_type' => $o['entity_type'], + 'voluntary_compliance' => (int) $o['voluntary_compliance'], + 'sector' => $o['sector'], + 'compliance_score' => $score, + 'open_risks' => $risks, + 'open_incidents' => $incs, + 'deadlines_overdue' => $overdue, + 'deadlines_upcoming' => count($dls) - $overdue, + ]; + } + + usort($allDeadlines, fn($a, $b) => strcmp((string) $a['due_date'], (string) $b['due_date'])); + + $this->jsonSuccess([ + 'companies' => $companies, + 'deadlines' => $allDeadlines, + 'kpis' => [ + 'total_companies' => count($companies), + 'avg_compliance' => $nScore ? (int) round($sumScore / $nScore) : null, + 'overdue_total' => $overdueTotal, + 'upcoming_total' => count($allDeadlines) - $overdueTotal, + ], + ]); + } + + /** + * Scadenze "aperte" di una org (incidenti CSIRT, policy, trattamenti rischio, + * formazione) con orizzonte a +30 giorni ma includendo anche gli scaduti (past-due), + * così il cruscotto distingue "in ritardo" da "in arrivo". Fonti coerenti con + * DashboardController::deadlines(). + */ + private function collectDeadlines(int $orgId): array + { + $out = []; + $horizon = "DATE_ADD(NOW(), INTERVAL 30 DAY)"; + + $inc = Database::fetchAll( + "SELECT id, title, 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]); + foreach ($inc as $i) { + if ($i['early_warning_due'] && !$i['early_warning_sent_at']) + $out[] = ['type'=>'incident_early_warning','title'=>"Early Warning: {$i['title']}",'due_date'=>$i['early_warning_due'],'severity'=>'critical','entity_type'=>'incident','entity_id'=>(int)$i['id']]; + if ($i['notification_due'] && !$i['notification_sent_at']) + $out[] = ['type'=>'incident_notification','title'=>"Notifica CSIRT: {$i['title']}",'due_date'=>$i['notification_due'],'severity'=>'high','entity_type'=>'incident','entity_id'=>(int)$i['id']]; + if ($i['final_report_due'] && !$i['final_report_sent_at']) + $out[] = ['type'=>'incident_final_report','title'=>"Report finale: {$i['title']}",'due_date'=>$i['final_report_due'],'severity'=>'medium','entity_type'=>'incident','entity_id'=>(int)$i['id']]; + } + + foreach (Database::fetchAll( + "SELECT id, title, next_review_date FROM policies + WHERE organization_id = ? AND next_review_date IS NOT NULL + AND next_review_date <= $horizon AND status NOT IN ('archived')", [$orgId]) as $p) { + $out[] = ['type'=>'policy_review','title'=>"Revisione policy: {$p['title']}",'due_date'=>$p['next_review_date'],'severity'=>'medium','entity_type'=>'policy','entity_id'=>(int)$p['id']]; + } + + foreach (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 AND rt.due_date <= $horizon", [$orgId]) as $t) { + $out[] = ['type'=>'risk_treatment','title'=>"Trattamento rischio: {$t['risk_title']}",'due_date'=>$t['due_date'],'severity'=>'medium','entity_type'=>'risk_treatment','entity_id'=>(int)$t['id']]; + } + + foreach (Database::fetchAll( + "SELECT ta.id, tc.title, ta.due_date FROM training_assignments ta + JOIN training_courses tc ON tc.id = ta.course_id + WHERE ta.organization_id = ? AND ta.status IN ('assigned','in_progress') + AND ta.due_date IS NOT NULL AND ta.due_date <= $horizon", [$orgId]) as $tr) { + $out[] = ['type'=>'training_due','title'=>"Formazione: {$tr['title']}",'due_date'=>$tr['due_date'],'severity'=>'low','entity_type'=>'training','entity_id'=>(int)$tr['id']]; + } + + return $out; + } +} diff --git a/public/index.php b/public/index.php index fb95825..9c72d31 100644 --- a/public/index.php +++ b/public/index.php @@ -91,6 +91,7 @@ $controllerMap = [ 'raci' => 'RaciController', // A4 Fase 4.3 — Matrice RACI + link m2m (procedure/inventario/rischi/misure) 'review-schedule' => 'ReviewScheduleController', // A4 Fase 4.4 — Scadenziario centralizzato (revisioni periodiche GV.PO-02/GV.SC-07/PR.AT/DE.CM) 'framework' => 'FrameworkController', // Epic C / C1 — Elenco canonico Misure/Requisiti (cfg_nis2_*, read-only) + 'consultant' => 'ConsultantController', // Cruscotto Studio — vista d'insieme aziende gestite + scadenze aggregate 'stakeholders' => 'StakeholderController', // Epic C / C5 — Registro stakeholder + matrice di Mendelow 'stakeholder-activities' => 'StakeholderActivityController', // Epic C / C5.2 — Attività stakeholder (questionari/azioni + calendario) 'stakeholder-portal' => 'StakeholderPortalController', // Epic C / C5.2b — Portale esterno stakeholder (token magic-link, NO JWT) @@ -266,6 +267,11 @@ $actionMap = [ 'POST:state' => 'setState', // upsert valutazione/stato per requisito (org-scoped) ], + // ── ConsultantController — Cruscotto Studio (vista d'insieme aziende gestite) ── + 'consultant' => [ + 'GET:portfolio' => 'portfolio', + ], + // ── AssessmentController ──────────────────────── 'assessments' => [ 'GET:list' => 'list',