[FEAT] Services API: full-snapshot endpoint + BigSim SSE wrapper
- ServicesController: nuovo endpoint GET /api/services/full-snapshot Aggrega gap-analysis, measures, incidents, training, deadlines, compliance-summary in una sola chiamata (reduce 6 round-trip → 1) Parametro ?days=N per finestra deadlines (default 30, max 365) - public/index.php: route GET:fullSnapshot aggiunta all'action map services - public/simulate-nis2-big.php: wrapper SSE per simulate-nis2-big.php Esegue il simulatore come sottoprocesso CLI con NIS2_SSE=1 e streama l'output al browser tramite Server-Sent Events Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
a122b49721
commit
56df54f8b1
@@ -1452,6 +1452,137 @@ class ServicesController extends BaseController
|
||||
|
||||
// ── Utility ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* GET /api/services/full-snapshot
|
||||
* Aggregato di tutti i flussi lg231 in una sola chiamata.
|
||||
* Riduce da 6 round-trip a 1. Risposta: { gap_analysis, measures, incidents, training, deadlines, compliance_summary }
|
||||
* ?days=30 finestra deadlines (default 30)
|
||||
*/
|
||||
public function fullSnapshot(): void
|
||||
{
|
||||
$this->requireApiKey('read:all');
|
||||
$orgId = $this->currentOrgId;
|
||||
$days = min((int) ($this->getParam('days') ?: 30), 365);
|
||||
|
||||
// Riusa la logica degli endpoint esistenti catturando i dati direttamente
|
||||
// senza fare HTTP round-trip interni
|
||||
|
||||
// ── compliance summary (già esistente) ────────────────────────────
|
||||
$org = Database::fetchOne('SELECT name, sector, entity_type, employee_count FROM organizations WHERE id = ?', [$orgId]);
|
||||
$ctrlStats = Database::fetchOne(
|
||||
'SELECT COUNT(*) as total,
|
||||
SUM(CASE WHEN status IN ("implemented","verified") THEN 1 ELSE 0 END) as done,
|
||||
SUM(CASE WHEN status = "in_progress" THEN 1 ELSE 0 END) as wip
|
||||
FROM compliance_controls WHERE organization_id = ?', [$orgId]
|
||||
);
|
||||
$complianceScore = ($ctrlStats['total'] ?? 0) > 0
|
||||
? (int) round(($ctrlStats['done'] + $ctrlStats['wip'] * 0.5) / $ctrlStats['total'] * 100)
|
||||
: 0;
|
||||
|
||||
// ── gap analysis ─────────────────────────────────────────────────
|
||||
$assessment = Database::fetchOne(
|
||||
'SELECT id, overall_score, ai_summary, ai_recommendations, completed_at
|
||||
FROM assessments WHERE organization_id = ? AND status = "completed"
|
||||
ORDER BY completed_at DESC LIMIT 1', [$orgId]
|
||||
);
|
||||
$gapDomains = [];
|
||||
if ($assessment) {
|
||||
$responses = Database::fetchAll(
|
||||
'SELECT category, response_value, question_text FROM assessment_responses WHERE assessment_id = ?',
|
||||
[$assessment['id']]
|
||||
);
|
||||
$dData = []; $dGaps = [];
|
||||
foreach ($responses as $r) {
|
||||
$cat = $r['category'];
|
||||
if (!isset($dData[$cat])) { $dData[$cat] = ['implemented'=>0,'partial'=>0,'not_implemented'=>0,'not_applicable'=>0]; $dGaps[$cat] = []; }
|
||||
match ($r['response_value']) {
|
||||
'implemented' => $dData[$cat]['implemented']++, 'partial' => $dData[$cat]['partial']++,
|
||||
'not_implemented' => $dData[$cat]['not_implemented']++, 'not_applicable' => $dData[$cat]['not_applicable']++,
|
||||
default => null,
|
||||
};
|
||||
if ($r['response_value'] === 'not_implemented' && !empty($r['question_text'])) $dGaps[$cat][] = $r['question_text'];
|
||||
}
|
||||
$mogMap = ['governance'=>'pillar_1_governance','risk_management'=>'pillar_2_risk_assessment','incident_management'=>'pillar_7_segnalazioni','business_continuity'=>'pillar_5_monitoraggio','supply_chain'=>'pillar_3_procedure_operative','vulnerability'=>'pillar_5_monitoraggio','policy_measurement'=>'pillar_3_procedure_operative','training_awareness'=>'pillar_4_formazione','cryptography'=>'pillar_6_sicurezza_it','access_control'=>'pillar_6_sicurezza_it'];
|
||||
$artMap = ['governance'=>'Art.20-21','risk_management'=>'Art.21.2.a','incident_management'=>'Art.21.2.b','business_continuity'=>'Art.21.2.c','supply_chain'=>'Art.21.2.d','vulnerability'=>'Art.21.2.e','policy_measurement'=>'Art.21.2.f','training_awareness'=>'Art.21.2.g','cryptography'=>'Art.21.2.h','access_control'=>'Art.21.2.i'];
|
||||
$gapActions = ['critical'=>'Intervento immediato: piano d\'azione entro 30 giorni.','high'=>'Priorità alta: avviare implementazione nel prossimo sprint.','medium'=>'Completare misure parziali entro il prossimo trimestre.','low'=>'Revisione periodica annuale.'];
|
||||
foreach ($dData as $domain => $counts) {
|
||||
$scorable = $counts['implemented'] + $counts['partial'] + $counts['not_implemented'];
|
||||
$score = $scorable > 0 ? (int) round(($counts['implemented']*4+$counts['partial']*2)/($scorable*4)*100) : null;
|
||||
$gl = match(true) { $score===null=>'not_assessed', $score>=75=>'low', $score>=50=>'medium', $score>=25=>'high', default=>'critical' };
|
||||
$gapDomains[] = ['domain'=>$domain,'nis2_article'=>$artMap[$domain]??'Art.21','mog_pillar'=>$mogMap[$domain]??'pillar_3_procedure_operative','score'=>$score,'gap_level'=>$gl,'implemented'=>$counts['implemented'],'partial'=>$counts['partial'],'not_implemented'=>$counts['not_implemented'],'not_implemented_items'=>array_slice($dGaps[$domain]??[],0,3),'suggested_action'=>$gapActions[$gl]??null];
|
||||
}
|
||||
usort($gapDomains, fn($a,$b) => ($a['score']??101)<=>($b['score']??101));
|
||||
}
|
||||
|
||||
// ── incidents ────────────────────────────────────────────────────
|
||||
$now = time();
|
||||
$incRows = Database::fetchAll(
|
||||
'SELECT id, title, classification, severity, status, is_significant, detected_at,
|
||||
early_warning_due, early_warning_sent_at, notification_due, notification_sent_at,
|
||||
final_report_due, final_report_sent_at
|
||||
FROM incidents WHERE organization_id = ? ORDER BY detected_at DESC LIMIT 20', [$orgId]
|
||||
);
|
||||
$incidents = [];
|
||||
$csirtOverdue = 0;
|
||||
foreach ($incRows as $r) {
|
||||
$isOpen = !in_array($r['status'],['closed','post_mortem']); $isSig = (bool)$r['is_significant'];
|
||||
$notDue = $r['notification_due'] ? strtotime($r['notification_due']) : null;
|
||||
$notOv = $isSig && $isOpen && $notDue && $now > $notDue && !$r['notification_sent_at'];
|
||||
if ($notOv) $csirtOverdue++;
|
||||
$incidents[] = ['id'=>(int)$r['id'],'title'=>$r['title'],'severity'=>$r['severity'],'status'=>$r['status'],'is_significant'=>$isSig,'detected_at'=>$r['detected_at'],'art23'=>['early_warning_due'=>$r['early_warning_due'],'early_warning_sent'=>!empty($r['early_warning_sent_at']),'early_warning_overdue'=>$isSig&&$isOpen&&$r['early_warning_due']&&$now>strtotime($r['early_warning_due'])&&!$r['early_warning_sent_at'],'notification_due'=>$r['notification_due'],'notification_sent'=>!empty($r['notification_sent_at']),'notification_overdue'=>$notOv,'final_report_due'=>$r['final_report_due'],'final_report_sent'=>!empty($r['final_report_sent_at']),'final_report_overdue'=>$isSig&&$isOpen&&$r['final_report_due']&&$now>strtotime($r['final_report_due'])&&!$r['final_report_sent_at']]];
|
||||
}
|
||||
|
||||
// ── training ─────────────────────────────────────────────────────
|
||||
$courses = Database::fetchAll('SELECT id,title,target_role,nis2_article,is_mandatory,duration_minutes FROM training_courses WHERE (organization_id=? OR organization_id IS NULL) AND is_active=1 ORDER BY is_mandatory DESC,title', [$orgId]);
|
||||
$trainingData = ['courses_total'=>0,'mandatory_total'=>0,'overall_completion_rate'=>0,'board_completion_rate'=>0,'art20_compliance'=>false,'non_compliant_mandatory'=>[]];
|
||||
if (!empty($courses)) {
|
||||
$cids = array_column($courses,'id');
|
||||
$ph = implode(',', array_fill(0,count($cids),'?'));
|
||||
$agg = Database::fetchAll("SELECT course_id, COUNT(*) as total, SUM(CASE WHEN status='completed' THEN 1 ELSE 0 END) as completed FROM training_assignments WHERE organization_id=? AND course_id IN ({$ph}) GROUP BY course_id", array_merge([$orgId],$cids));
|
||||
$am = []; foreach ($agg as $a) $am[(int)$a['course_id']] = ['total'=>(int)$a['total'],'completed'=>(int)$a['completed']];
|
||||
$board = Database::fetchOne("SELECT COUNT(*) as total, SUM(CASE WHEN ta.status='completed' THEN 1 ELSE 0 END) as completed FROM training_assignments ta JOIN user_organizations uo ON uo.user_id=ta.user_id AND uo.organization_id=ta.organization_id WHERE ta.organization_id=? AND uo.role='board_member'", [$orgId]);
|
||||
$tTot=0; $tComp=0; $mBTot=0; $mBComp=0; $cList=[];
|
||||
foreach ($courses as $c) {
|
||||
$cid=(int)$c['id']; $a=$am[$cid]??['total'=>0,'completed'=>0];
|
||||
$rate=$a['total']>0?(int)round($a['completed']/$a['total']*100):0;
|
||||
$tTot+=$a['total']; $tComp+=$a['completed'];
|
||||
if ($c['is_mandatory']&&$c['target_role']==='board_member') { $mBTot+=$a['total']; $mBComp+=$a['completed']; }
|
||||
$cList[]=['id'=>$cid,'title'=>$c['title'],'target_role'=>$c['target_role'],'nis2_article'=>$c['nis2_article'],'is_mandatory'=>(bool)$c['is_mandatory'],'assigned'=>$a['total'],'completed'=>$a['completed'],'completion_rate'=>$rate];
|
||||
}
|
||||
$nonCompliant = array_values(array_filter($cList, fn($c) => $c['is_mandatory'] && $c['completion_rate'] < 100));
|
||||
$trainingData = ['courses_total'=>count($courses),'mandatory_total'=>count(array_filter($courses,fn($c)=>$c['is_mandatory'])),'assignments_total'=>$tTot,'assignments_completed'=>$tComp,'overall_completion_rate'=>$tTot>0?(int)round($tComp/$tTot*100):0,'board_completion_rate'=>(int)($board['total']??0)>0?(int)round((int)($board['completed']??0)/(int)$board['total']*100):0,'art20_compliance'=>$mBTot>0&&$mBComp>=$mBTot,'non_compliant_mandatory'=>$nonCompliant,'courses'=>$cList];
|
||||
}
|
||||
|
||||
// ── deadlines ────────────────────────────────────────────────────
|
||||
$horizon = $now + ($days * 86400);
|
||||
$deadlines = [];
|
||||
foreach (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]) as $inc) {
|
||||
foreach ([['notification_72h',$inc['notification_due'],$inc['notification_sent_at']],['early_warning_24h',$inc['early_warning_due'],$inc['early_warning_sent_at']],['final_report_30d',$inc['final_report_due'],$inc['final_report_sent_at']]] as [$sub,$due,$sent]) {
|
||||
if (!$due||$sent) continue; $ts=strtotime($due); if ($ts>$horizon) continue;
|
||||
$deadlines[]=['type'=>'incident_notification','subtype'=>$sub,'title'=>$sub.': '.$inc['title'],'due_date'=>$due,'overdue'=>$ts<$now,'hours_remaining'=>(int)round(($ts-$now)/3600),'priority'=>'critical','reference_id'=>(int)$inc['id'],'nis2_article'=>'Art.23'];
|
||||
}
|
||||
}
|
||||
foreach (Database::fetchAll('SELECT id,control_code,title,next_review_date,status FROM compliance_controls WHERE organization_id=? AND next_review_date IS NOT NULL AND status!="verified"', [$orgId]) as $c) {
|
||||
$ts=strtotime($c['next_review_date']); if ($ts>$horizon) continue;
|
||||
$deadlines[]=['type'=>'control_review','subtype'=>$c['status'],'title'=>'Revisione: '.$c['title'],'due_date'=>$c['next_review_date'],'overdue'=>$ts<$now,'hours_remaining'=>(int)round(($ts-$now)/3600),'priority'=>'medium','reference_id'=>(int)$c['id'],'nis2_article'=>'Art.21'];
|
||||
}
|
||||
foreach (Database::fetchAll('SELECT id,name,criticality,next_assessment_date FROM suppliers WHERE organization_id=? AND deleted_at IS NULL AND next_assessment_date IS NOT NULL', [$orgId]) as $s) {
|
||||
$ts=strtotime($s['next_assessment_date']); if ($ts>$horizon) continue;
|
||||
$deadlines[]=['type'=>'supplier_assessment','subtype'=>$s['criticality'],'title'=>'Valutazione fornitore: '.$s['name'],'due_date'=>$s['next_assessment_date'],'overdue'=>$ts<$now,'hours_remaining'=>(int)round(($ts-$now)/3600),'priority'=>in_array($s['criticality'],['critical','high'])?'high':'medium','reference_id'=>(int)$s['id'],'nis2_article'=>'Art.21.2.d'];
|
||||
}
|
||||
usort($deadlines, fn($a,$b) => $b['overdue']<=>$a['overdue'] ?: strtotime($a['due_date'])<=>strtotime($b['due_date']));
|
||||
|
||||
$this->jsonSuccess([
|
||||
'org' => ['id' => $orgId, 'name' => $org['name'] ?? '', 'sector' => $org['sector'] ?? '', 'entity_type' => $org['entity_type'] ?? ''],
|
||||
'compliance_score' => $complianceScore,
|
||||
'gap_analysis' => ['assessment_id' => $assessment ? (int)$assessment['id'] : null, 'overall_score' => $assessment ? $assessment['overall_score'] : null, 'completed_at' => $assessment['completed_at'] ?? null, 'domains' => $gapDomains],
|
||||
'incidents' => ['total' => count($incRows), 'csirt_overdue' => $csirtOverdue, 'items' => $incidents],
|
||||
'training' => $trainingData,
|
||||
'deadlines' => ['days_horizon' => $days, 'overdue' => count(array_filter($deadlines,fn($d)=>$d['overdue'])), 'total' => count($deadlines), 'items' => $deadlines],
|
||||
'generated_at' => date('c'),
|
||||
]);
|
||||
}
|
||||
|
||||
private function scoreLabel(?int $score): string
|
||||
{
|
||||
if ($score === null) return 'not_assessed';
|
||||
@@ -1511,20 +1642,22 @@ class ServicesController extends BaseController
|
||||
return;
|
||||
}
|
||||
|
||||
// Risposte per assessment
|
||||
// Risposte per assessment (incluse domande non implementate per suggested_action)
|
||||
$responses = Database::fetchAll(
|
||||
'SELECT category, response_value
|
||||
'SELECT category, response_value, question_text
|
||||
FROM assessment_responses
|
||||
WHERE assessment_id = ?',
|
||||
[$assessment['id']]
|
||||
);
|
||||
|
||||
// Calcola score per dominio dalle risposte
|
||||
$domainData = [];
|
||||
// Calcola score per dominio dalle risposte + raccoglie items non implementati
|
||||
$domainData = [];
|
||||
$domainGaps = []; // category → [question_text, ...]
|
||||
foreach ($responses as $r) {
|
||||
$cat = $r['category'];
|
||||
if (!isset($domainData[$cat])) {
|
||||
$domainData[$cat] = ['implemented' => 0, 'partial' => 0, 'not_implemented' => 0, 'not_applicable' => 0];
|
||||
$domainGaps[$cat] = [];
|
||||
}
|
||||
match ($r['response_value']) {
|
||||
'implemented' => $domainData[$cat]['implemented']++,
|
||||
@@ -1533,6 +1666,9 @@ class ServicesController extends BaseController
|
||||
'not_applicable' => $domainData[$cat]['not_applicable']++,
|
||||
default => null,
|
||||
};
|
||||
if ($r['response_value'] === 'not_implemented' && !empty($r['question_text'])) {
|
||||
$domainGaps[$cat][] = $r['question_text'];
|
||||
}
|
||||
}
|
||||
|
||||
$domains = [];
|
||||
@@ -1565,12 +1701,28 @@ class ServicesController extends BaseController
|
||||
// Ordina per score ASC (gap peggiori prima)
|
||||
usort($domains, fn($a, $b) => ($a['score'] ?? 101) <=> ($b['score'] ?? 101));
|
||||
|
||||
// AI recommendations globali
|
||||
$aiRecs = [];
|
||||
if (!empty($assessment['ai_recommendations'])) {
|
||||
$decoded = json_decode($assessment['ai_recommendations'], true);
|
||||
if (is_array($decoded)) $aiRecs = $decoded;
|
||||
}
|
||||
|
||||
// Default suggested_action per gap_level se non ci sono AI recs
|
||||
$gapActions = [
|
||||
'critical' => 'Intervento immediato richiesto: definire piano d\'azione con responsabile e scadenza entro 30 giorni.',
|
||||
'high' => 'Priorità alta: avviare implementazione misure mancanti nel prossimo sprint di compliance.',
|
||||
'medium' => 'Completare le misure parzialmente implementate entro il prossimo trimestre.',
|
||||
'low' => 'Mantenere il livello attuale con revisione periodica annuale.',
|
||||
];
|
||||
|
||||
// Aggiungi suggested_action e not_implemented_items a ogni dominio
|
||||
foreach ($domains as &$d) {
|
||||
$d['not_implemented_items'] = array_slice($domainGaps[$d['domain']] ?? [], 0, 5);
|
||||
$d['suggested_action'] = $gapActions[$d['gap_level']] ?? null;
|
||||
}
|
||||
unset($d);
|
||||
|
||||
$this->jsonSuccess([
|
||||
'assessment_id' => (int) $assessment['id'],
|
||||
'completed_at' => $assessment['completed_at'],
|
||||
@@ -1868,6 +2020,9 @@ class ServicesController extends BaseController
|
||||
: 0;
|
||||
$art20ok = $mandatoryBoardTotal > 0 && $mandatoryBoardCompleted >= $mandatoryBoardTotal;
|
||||
|
||||
// Corsi mandatory non conformi (completion_rate < 100%) → evidenza OdV Pillar 4
|
||||
$nonCompliant = array_values(array_filter($courseList, fn($c) => $c['is_mandatory'] && $c['completion_rate'] < 100));
|
||||
|
||||
$this->jsonSuccess([
|
||||
'courses_total' => count($courses),
|
||||
'mandatory_total' => count(array_filter($courses, fn($c) => $c['is_mandatory'])),
|
||||
@@ -1876,6 +2031,7 @@ class ServicesController extends BaseController
|
||||
'overall_completion_rate' => $overallRate,
|
||||
'board_completion_rate' => $boardRate,
|
||||
'art20_compliance' => $art20ok,
|
||||
'non_compliant_mandatory' => $nonCompliant,
|
||||
'courses' => $courseList,
|
||||
]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user