[FEAT] Cruscotto Studio (backend): ConsultantController::portfolio
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) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
924963444e
commit
2bd97a6bd8
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/BaseController.php';
|
||||
|
||||
/**
|
||||
* ConsultantController — Cruscotto Studio (consulente / società di consulenza).
|
||||
* Vista d'insieme su TUTTE le aziende gestite dall'utente: compliance, rischi,
|
||||
* incidenti e SCADENZE GENERALI aggregate. Read-only, org-scoping = visibilità
|
||||
* dell'utente (super_admin = tutte le org attive; altri = le proprie membership).
|
||||
*/
|
||||
class ConsultantController extends BaseController
|
||||
{
|
||||
/**
|
||||
* GET /api/consultant/portfolio
|
||||
*/
|
||||
public function portfolio(): void
|
||||
{
|
||||
$this->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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user