Rilascio unico (3 moduli) costruito in flotta parallela + verifica avversariale. MODULO A — Audit interni (ISO 27001 §9.2, mig.055): internal_audits + internal_audit_items; codice AUD-NNN; checklist pre-popolata (clausole 4-10 + Annex A applicabili dal SoA); esiti per riga; apertura non conformità collegata a NCR/CAPA (source polimorfico); report HTML stampabile. InternalAuditController + internal-audits.html. MODULO B — Riesame di Direzione (ISO 27001 §9.3, mig.056): management_reviews + management_review_decisions; codice RD-AAAA-NN; INPUT aggregati automaticamente dai moduli (gather: NC/CAPA, audit interni, rischi+trattamenti, KPI/score, obiettivi, formazione, stakeholder, normative, scadenze), congelati nello snapshot all'approvazione; decisioni; verbale stampabile. ManagementReviewController + management-review.html. MODULO C — Calendario unico scadenze: aggregatore SOLA LETTURA (nessuna migrazione) di tutte le scadenze (incidenti/policy/rischi/NC-CAPA/formazione/stakeholder/audit/riesame/review_schedule), griglia mensile + lista + filtri + deep-link. CalendarController + calendario.html. Integrazione: router (3 controller+actionMap), api.js, sidebar V2+legacy, help.js (3 sezioni), i18n (IT+EN), review_schedule ENUM += internal_audit. v1.22.0 + sw cache + cache-buster 20260630. Verifica flotta (28 agenti, 4 dim + avversariale): 9 finding confermati, TUTTI corretti — MAJOR gatherRisks (risk_treatments.organization_id inesistente → JOIN risks); nextCode numerico (no dup >99/anno); footer report audit con etichetta "ISO buona prassi, non obbligo"; help 24→25 clausole; ARIA tab/calendario; focus modali; tasti su celle calendario; rimossi helper api morti. Smoke prod OK (calendario 18 eventi, audit AUD-001 25 item + report + audit→NCR + audit→calendario, riesame gather/decisione/approve/report, gatherRisks ora available); org 151 ripulita. Additivo. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
836 lines
44 KiB
PHP
836 lines
44 KiB
PHP
<?php
|
|
/**
|
|
* NIS2 Agile — Riesame di Direzione (ISO 27001 §9.3) — Modulo B
|
|
* ----------------------------------------------------------------------------
|
|
* Produce il VERBALE del riesame periodico del SGSI con:
|
|
* - INPUT del riesame AGGREGATI automaticamente dai moduli esistenti (gather),
|
|
* rivisti dall'utente e CONGELATI nello `snapshot` JSON all'approvazione;
|
|
* - OUTPUT = decisioni (management_review_decisions) con owner (ruolo org),
|
|
* scadenza e link facoltativo a una CAPA.
|
|
*
|
|
* Il `gather` è difensivo by-design: ogni sezione legge il proprio modulo in
|
|
* try/catch isolato (alcune tabelle potrebbero non esistere ancora — es.
|
|
* internal_audits/Modulo A, isms_*, normative_*). Se una fonte fallisce o manca,
|
|
* la sezione viene marcata `available=false` SENZA rompere il resto.
|
|
*
|
|
* Multi-tenancy: OGNI query filtra organization_id. Anti-IDOR: owner_role_id e
|
|
* capa_id collegati a una decisione sono verificati appartenere all'org.
|
|
* NOTE: Database::query/fetchAll/fetchOne/insert/update/delete; jsonSuccess/Error exit.
|
|
*/
|
|
|
|
require_once __DIR__ . '/BaseController.php';
|
|
|
|
class ManagementReviewController extends BaseController
|
|
{
|
|
private const MANAGE_ROLES = ['org_admin', 'compliance_manager'];
|
|
private const APPROVE_ROLES = ['org_admin'];
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// VERBALI (CRUD)
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
/** GET /api/management-reviews/list */
|
|
public function list(): void
|
|
{
|
|
$this->requireOrgAccess();
|
|
$orgId = $this->getCurrentOrgId();
|
|
$rows = Database::fetchAll(
|
|
'SELECT r.id, r.code, r.review_date, r.period_label, r.status, r.approved_at, r.updated_at,
|
|
u.full_name AS chair_name,
|
|
(SELECT COUNT(*) FROM management_review_decisions d WHERE d.review_id = r.id) AS n_decisions,
|
|
(SELECT COUNT(*) FROM management_review_decisions d WHERE d.review_id = r.id AND d.status = \'done\') AS n_done
|
|
FROM management_reviews r
|
|
LEFT JOIN users u ON u.id = r.chair_user_id
|
|
WHERE r.organization_id = ?
|
|
ORDER BY (r.review_date IS NULL), r.review_date DESC, r.id DESC',
|
|
[$orgId]
|
|
);
|
|
$out = array_map(static fn($r) => [
|
|
'id' => (int) $r['id'],
|
|
'code' => $r['code'],
|
|
'review_date' => $r['review_date'],
|
|
'period_label' => $r['period_label'],
|
|
'status' => $r['status'],
|
|
'chair_name' => $r['chair_name'],
|
|
'approved_at' => $r['approved_at'],
|
|
'n_decisions' => (int) $r['n_decisions'],
|
|
'n_done' => (int) $r['n_done'],
|
|
'updated_at' => $r['updated_at'],
|
|
], $rows);
|
|
$this->jsonSuccess(['reviews' => $out]);
|
|
}
|
|
|
|
/** GET /api/management-reviews/{id} (con decisions + snapshot) */
|
|
public function get(int $id): void
|
|
{
|
|
$this->requireOrgAccess();
|
|
$orgId = $this->getCurrentOrgId();
|
|
$r = Database::fetchOne(
|
|
'SELECT r.*, u.full_name AS chair_name, a.full_name AS approver_name
|
|
FROM management_reviews r
|
|
LEFT JOIN users u ON u.id = r.chair_user_id
|
|
LEFT JOIN users a ON a.id = r.approved_by
|
|
WHERE r.id = ? AND r.organization_id = ?',
|
|
[$id, $orgId]
|
|
);
|
|
if (!$r) { $this->jsonError('Riesame non trovato', 404, 'NOT_FOUND'); }
|
|
|
|
$att = $r['attendees'] ? json_decode($r['attendees'], true) : [];
|
|
$snap = $r['snapshot'] ? json_decode($r['snapshot'], true) : null;
|
|
$this->jsonSuccess([
|
|
'id' => (int) $r['id'],
|
|
'code' => $r['code'],
|
|
'review_date' => $r['review_date'],
|
|
'period_label' => $r['period_label'],
|
|
'chair_user_id' => $r['chair_user_id'] !== null ? (int) $r['chair_user_id'] : null,
|
|
'chair_name' => $r['chair_name'],
|
|
'attendees' => is_array($att) ? $att : [],
|
|
'status' => $r['status'],
|
|
'approved_by' => $r['approved_by'] !== null ? (int) $r['approved_by'] : null,
|
|
'approver_name' => $r['approver_name'],
|
|
'approved_at' => $r['approved_at'],
|
|
'conclusions' => $r['conclusions'],
|
|
'snapshot' => is_array($snap) ? $snap : null,
|
|
'created_at' => $r['created_at'],
|
|
'updated_at' => $r['updated_at'],
|
|
'decisions' => $this->loadDecisions($id),
|
|
]);
|
|
}
|
|
|
|
/** POST /api/management-reviews/create (genera code RD-AAAA-NN) */
|
|
public function create(): void
|
|
{
|
|
$this->requireOrgRole(self::MANAGE_ROLES);
|
|
$orgId = $this->getCurrentOrgId();
|
|
$b = $this->getJsonBody();
|
|
|
|
$reviewDate = $this->validateDate($b['review_date'] ?? null, 'review_date');
|
|
$period = $this->nullableStr($b['period_label'] ?? null, 100);
|
|
$chairId = $this->validateUser($b['chair_user_id'] ?? null, $orgId);
|
|
$attendees = $this->validateAttendees($b['attendees'] ?? null);
|
|
$conclusions = $this->nullableStr($b['conclusions'] ?? null);
|
|
|
|
$year = $reviewDate ? (int) substr($reviewDate, 0, 4) : (int) date('Y');
|
|
$code = $this->nextCode($orgId, $year);
|
|
|
|
$id = Database::insert('management_reviews', [
|
|
'organization_id' => $orgId,
|
|
'code' => $code,
|
|
'review_date' => $reviewDate,
|
|
'period_label' => $period,
|
|
'chair_user_id' => $chairId,
|
|
'attendees' => $attendees !== null ? json_encode($attendees, JSON_UNESCAPED_UNICODE) : null,
|
|
'status' => 'draft',
|
|
'conclusions' => $conclusions,
|
|
'created_by' => $this->getCurrentUserId(),
|
|
]);
|
|
$this->logAudit('mgmt_review_created', 'management_review', (int) $id, ['code' => $code]);
|
|
$this->jsonSuccess(['id' => (int) $id, 'code' => $code], 'Riesame creato', 201);
|
|
}
|
|
|
|
/** PUT /api/management-reviews/{id} */
|
|
public function update(int $id): void
|
|
{
|
|
$this->requireOrgRole(self::MANAGE_ROLES);
|
|
$orgId = $this->getCurrentOrgId();
|
|
$b = $this->getJsonBody();
|
|
|
|
$r = Database::fetchOne('SELECT id, status FROM management_reviews WHERE id = ? AND organization_id = ?', [$id, $orgId]);
|
|
if (!$r) { $this->jsonError('Riesame non trovato', 404, 'NOT_FOUND'); }
|
|
if ($r['status'] === 'approved') { $this->jsonError('Il riesame è approvato e non può essere modificato', 409, 'REVIEW_APPROVED'); }
|
|
|
|
$updates = [];
|
|
if ($this->hasParam('review_date')) { $updates['review_date'] = $this->validateDate($b['review_date'] ?? null, 'review_date'); }
|
|
if ($this->hasParam('period_label')) { $updates['period_label'] = $this->nullableStr($b['period_label'] ?? null, 100); }
|
|
if ($this->hasParam('chair_user_id')) { $updates['chair_user_id'] = $this->validateUser($b['chair_user_id'] ?? null, $orgId); }
|
|
if ($this->hasParam('attendees')) {
|
|
$att = $this->validateAttendees($b['attendees'] ?? null);
|
|
$updates['attendees'] = $att !== null ? json_encode($att, JSON_UNESCAPED_UNICODE) : null;
|
|
}
|
|
if ($this->hasParam('conclusions')) { $updates['conclusions'] = $this->nullableStr($b['conclusions'] ?? null); }
|
|
// snapshot editabile finché draft: l'utente rivede gli input prima del congelamento
|
|
if ($this->hasParam('snapshot')) {
|
|
$snap = $b['snapshot'] ?? null;
|
|
$updates['snapshot'] = is_array($snap) ? json_encode($snap, JSON_UNESCAPED_UNICODE) : null;
|
|
}
|
|
|
|
if (!empty($updates)) {
|
|
Database::update('management_reviews', $updates, 'id = ? AND organization_id = ?', [$id, $orgId]);
|
|
}
|
|
$this->logAudit('mgmt_review_updated', 'management_review', $id, array_keys($updates));
|
|
$this->jsonSuccess(['id' => $id, 'updated' => array_keys($updates)], 'Riesame aggiornato');
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// GATHER — aggregazione automatica degli INPUT del riesame
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* GET /api/management-reviews/gather
|
|
* Aggrega gli INPUT del riesame (ISO 27001 §9.3.2) leggendo i moduli esistenti.
|
|
* Ogni sezione è in try/catch isolato e org-scoped: una fonte mancante/rotta
|
|
* non blocca le altre. L'utente rivede il risultato e lo congela nello snapshot.
|
|
*/
|
|
public function gather(): void
|
|
{
|
|
$this->requireOrgRole(self::MANAGE_ROLES);
|
|
$orgId = $this->getCurrentOrgId();
|
|
$this->jsonSuccess($this->buildGather($orgId));
|
|
}
|
|
|
|
/** Costruisce l'oggetto strutturato delle sezioni di input (riusato da approve). */
|
|
private function buildGather(int $orgId): array
|
|
{
|
|
$sections = [
|
|
'nonconformities' => $this->sec(fn() => $this->gatherNonConformities($orgId)),
|
|
'corrective_actions' => $this->sec(fn() => $this->gatherCorrectiveActions($orgId)),
|
|
'internal_audits' => $this->sec(fn() => $this->gatherInternalAudits($orgId)),
|
|
'risks' => $this->sec(fn() => $this->gatherRisks($orgId)),
|
|
'compliance_score' => $this->sec(fn() => $this->gatherComplianceScore($orgId)),
|
|
'objectives' => $this->sec(fn() => $this->gatherObjectives($orgId)),
|
|
'training' => $this->sec(fn() => $this->gatherTraining($orgId)),
|
|
'stakeholders' => $this->sec(fn() => $this->gatherStakeholders($orgId)),
|
|
'normative' => $this->sec(fn() => $this->gatherNormative($orgId)),
|
|
'upcoming_deadlines' => $this->sec(fn() => $this->gatherDeadlines($orgId)),
|
|
];
|
|
return [
|
|
'generated_at' => date('Y-m-d H:i:s'),
|
|
'sections' => $sections,
|
|
];
|
|
}
|
|
|
|
/** Wrapper difensivo: esegue $fn; se lancia (tabella mancante, ecc.) marca available=false. */
|
|
private function sec(callable $fn): array
|
|
{
|
|
try {
|
|
$data = $fn();
|
|
return array_merge(['available' => true], $data);
|
|
} catch (\Throwable $e) {
|
|
return ['available' => false, 'reason' => 'not_available'];
|
|
}
|
|
}
|
|
|
|
/** 4. Stato NC/azioni correttive — non_conformities aperte. */
|
|
private function gatherNonConformities(int $orgId): array
|
|
{
|
|
$open = ['open', 'investigating', 'action_planned', 'correcting', 'verifying'];
|
|
$place = implode(',', array_fill(0, count($open), '?'));
|
|
$rows = Database::fetchAll(
|
|
"SELECT id, ncr_code, title, severity, status, target_close_date
|
|
FROM non_conformities
|
|
WHERE organization_id = ? AND status IN ($place)
|
|
ORDER BY (target_close_date IS NULL), target_close_date ASC, id DESC
|
|
LIMIT 100",
|
|
array_merge([$orgId], $open)
|
|
);
|
|
return [
|
|
'open_count' => count($rows),
|
|
'items' => array_map(static fn($r) => [
|
|
'id' => (int) $r['id'], 'code' => $r['ncr_code'], 'title' => $r['title'],
|
|
'severity' => $r['severity'], 'status' => $r['status'], 'due_date' => $r['target_close_date'],
|
|
], $rows),
|
|
];
|
|
}
|
|
|
|
/** 1. Azioni dal riesame precedente / CAPA non chiuse. */
|
|
private function gatherCorrectiveActions(int $orgId): array
|
|
{
|
|
$open = ['planned', 'in_progress'];
|
|
$place = implode(',', array_fill(0, count($open), '?'));
|
|
$rows = Database::fetchAll(
|
|
"SELECT id, capa_code, title, status, due_date, action_type
|
|
FROM capa_actions
|
|
WHERE organization_id = ? AND status IN ($place)
|
|
ORDER BY (due_date IS NULL), due_date ASC, id DESC
|
|
LIMIT 100",
|
|
array_merge([$orgId], $open)
|
|
);
|
|
return [
|
|
'open_count' => count($rows),
|
|
'items' => array_map(static fn($r) => [
|
|
'id' => (int) $r['id'], 'code' => $r['capa_code'], 'title' => $r['title'],
|
|
'status' => $r['status'], 'due_date' => $r['due_date'], 'type' => $r['action_type'],
|
|
], $rows),
|
|
];
|
|
}
|
|
|
|
/** 3. Risultati audit interni (Modulo A — la tabella potrebbe non esistere). */
|
|
private function gatherInternalAudits(int $orgId): array
|
|
{
|
|
// Se internal_audits non esiste, la query lancia e sec() marca available=false.
|
|
$rows = Database::fetchAll(
|
|
'SELECT id, code, title, status, planned_date, executed_date, conclusion
|
|
FROM internal_audits
|
|
WHERE organization_id = ?
|
|
ORDER BY (planned_date IS NULL), planned_date DESC, id DESC
|
|
LIMIT 50',
|
|
[$orgId]
|
|
);
|
|
$completed = 0; $planned = 0;
|
|
foreach ($rows as $r) {
|
|
if ($r['status'] === 'completed') { $completed++; }
|
|
elseif (in_array($r['status'], ['planned', 'in_progress'], true)) { $planned++; }
|
|
}
|
|
return [
|
|
'total' => count($rows),
|
|
'completed' => $completed,
|
|
'planned' => $planned,
|
|
'items' => array_map(static fn($r) => [
|
|
'id' => (int) $r['id'], 'code' => $r['code'], 'title' => $r['title'],
|
|
'status' => $r['status'], 'planned_date' => $r['planned_date'],
|
|
'executed_date' => $r['executed_date'],
|
|
'conclusion' => $r['conclusion'] !== null ? mb_substr((string) $r['conclusion'], 0, 280) : null,
|
|
], $rows),
|
|
];
|
|
}
|
|
|
|
/** 7. Stato rischi + trattamenti. */
|
|
private function gatherRisks(int $orgId): array
|
|
{
|
|
$byStatus = Database::fetchAll(
|
|
'SELECT status, COUNT(*) AS c FROM risks WHERE organization_id = ? GROUP BY status',
|
|
[$orgId]
|
|
);
|
|
$statusMap = [];
|
|
foreach ($byStatus as $s) { $statusMap[$s['status']] = (int) $s['c']; }
|
|
|
|
// risk_treatments NON ha organization_id: si filtra via JOIN su risks (come CalendarController).
|
|
$treat = Database::fetchOne(
|
|
"SELECT
|
|
SUM(CASE WHEN rt.status = 'completed' THEN 1 ELSE 0 END) AS completed,
|
|
SUM(CASE WHEN rt.status IN ('planned','in_progress') THEN 1 ELSE 0 END) AS open,
|
|
SUM(CASE WHEN rt.status = 'overdue' OR (rt.due_date IS NOT NULL AND rt.due_date < CURDATE() AND rt.status <> 'completed') THEN 1 ELSE 0 END) AS overdue,
|
|
COUNT(*) AS total
|
|
FROM risk_treatments rt JOIN risks r ON r.id = rt.risk_id
|
|
WHERE r.organization_id = ?",
|
|
[$orgId]
|
|
) ?: [];
|
|
|
|
$top = Database::fetchAll(
|
|
"SELECT id, title, inherent_risk_score, residual_risk_score, status
|
|
FROM risks
|
|
WHERE organization_id = ? AND status <> 'closed'
|
|
ORDER BY inherent_risk_score DESC, id DESC LIMIT 10",
|
|
[$orgId]
|
|
);
|
|
return [
|
|
'total' => (int) array_sum($statusMap),
|
|
'by_status' => $statusMap,
|
|
'treatments' => [
|
|
'total' => (int) ($treat['total'] ?? 0),
|
|
'completed' => (int) ($treat['completed'] ?? 0),
|
|
'open' => (int) ($treat['open'] ?? 0),
|
|
'overdue' => (int) ($treat['overdue'] ?? 0),
|
|
],
|
|
'top_risks' => array_map(static fn($r) => [
|
|
'id' => (int) $r['id'], 'title' => $r['title'],
|
|
'inherent' => $r['inherent_risk_score'] !== null ? (int) $r['inherent_risk_score'] : null,
|
|
'residual' => $r['residual_risk_score'] !== null ? (int) $r['residual_risk_score'] : null,
|
|
'status' => $r['status'],
|
|
], $top),
|
|
];
|
|
}
|
|
|
|
/** 5. Risultati monitoraggio/KPI: avanzamento SoA (isms_soa) se presente. */
|
|
private function gatherComplianceScore(int $orgId): array
|
|
{
|
|
$soa = Database::fetchOne(
|
|
"SELECT COUNT(*) AS total,
|
|
SUM(CASE WHEN applicable = 1 THEN 1 ELSE 0 END) AS applicable,
|
|
ROUND(AVG(CASE WHEN applicable = 1 THEN implementation_pct END)) AS avg_pct,
|
|
SUM(CASE WHEN applicable = 1 AND implementation_status = 'implemented' THEN 1 ELSE 0 END) AS implemented,
|
|
SUM(CASE WHEN applicable = 1 AND implementation_status = 'verified' THEN 1 ELSE 0 END) AS verified
|
|
FROM isms_soa WHERE organization_id = ?",
|
|
[$orgId]
|
|
) ?: [];
|
|
return [
|
|
'soa_controls_total' => (int) ($soa['total'] ?? 0),
|
|
'soa_controls_applicable' => (int) ($soa['applicable'] ?? 0),
|
|
'soa_avg_implementation' => $soa['avg_pct'] !== null ? (int) $soa['avg_pct'] : null,
|
|
'soa_implemented' => (int) ($soa['implemented'] ?? 0),
|
|
'soa_verified' => (int) ($soa['verified'] ?? 0),
|
|
];
|
|
}
|
|
|
|
/** 6. Raggiungimento obiettivi SGSI: isms_models.isms_objectives (JSON) se presente. */
|
|
private function gatherObjectives(int $orgId): array
|
|
{
|
|
$row = Database::fetchOne('SELECT isms_objectives FROM isms_models WHERE organization_id = ?', [$orgId]);
|
|
$objs = ($row && $row['isms_objectives']) ? json_decode($row['isms_objectives'], true) : [];
|
|
$items = is_array($objs) ? array_values(array_filter(array_map(static function ($o) {
|
|
if (is_string($o)) { return ['title' => mb_substr($o, 0, 280)]; }
|
|
if (is_array($o)) {
|
|
return [
|
|
'title' => isset($o['title']) ? mb_substr((string) $o['title'], 0, 280)
|
|
: (isset($o['name']) ? mb_substr((string) $o['name'], 0, 280) : null),
|
|
'target' => $o['target'] ?? null,
|
|
'status' => $o['status'] ?? null,
|
|
];
|
|
}
|
|
return null;
|
|
}, $objs))) : [];
|
|
return ['count' => count($items), 'items' => $items];
|
|
}
|
|
|
|
/** Formazione non conforme: assegnazioni scadute/non completate. */
|
|
private function gatherTraining(int $orgId): array
|
|
{
|
|
$row = Database::fetchOne(
|
|
"SELECT
|
|
SUM(CASE WHEN status = 'overdue' OR (due_date IS NOT NULL AND due_date < CURDATE() AND status <> 'completed') THEN 1 ELSE 0 END) AS overdue,
|
|
SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) AS completed,
|
|
COUNT(*) AS total
|
|
FROM training_assignments WHERE organization_id = ?",
|
|
[$orgId]
|
|
) ?: [];
|
|
return [
|
|
'total' => (int) ($row['total'] ?? 0),
|
|
'completed' => (int) ($row['completed'] ?? 0),
|
|
'overdue' => (int) ($row['overdue'] ?? 0),
|
|
];
|
|
}
|
|
|
|
/** 8. Feedback parti interessate: attività stakeholder (stk_activities). */
|
|
private function gatherStakeholders(int $orgId): array
|
|
{
|
|
$row = Database::fetchOne(
|
|
"SELECT
|
|
COUNT(*) AS total,
|
|
SUM(CASE WHEN status = 'sent' THEN 1 ELSE 0 END) AS sent,
|
|
SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) AS completed
|
|
FROM stk_activities WHERE organization_id = ?",
|
|
[$orgId]
|
|
) ?: [];
|
|
$recent = Database::fetchAll(
|
|
'SELECT id, title, type, status, due_date FROM stk_activities
|
|
WHERE organization_id = ? ORDER BY id DESC LIMIT 10',
|
|
[$orgId]
|
|
);
|
|
return [
|
|
'total' => (int) ($row['total'] ?? 0),
|
|
'sent' => (int) ($row['sent'] ?? 0),
|
|
'completed' => (int) ($row['completed'] ?? 0),
|
|
'items' => array_map(static fn($a) => [
|
|
'id' => (int) $a['id'], 'title' => $a['title'], 'type' => $a['type'],
|
|
'status' => $a['status'], 'due_date' => $a['due_date'],
|
|
], $recent),
|
|
];
|
|
}
|
|
|
|
/** 9. Aggiornamenti normativi non ACK (normative_updates / normative_ack). */
|
|
private function gatherNormative(int $orgId): array
|
|
{
|
|
$rows = Database::fetchAll(
|
|
'SELECT u.id, u.title, u.source, u.reference, u.impact_level, u.effective_date
|
|
FROM normative_updates u
|
|
WHERE u.is_published = 1
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM normative_ack a
|
|
WHERE a.normative_update_id = u.id AND a.organization_id = ?
|
|
)
|
|
ORDER BY u.published_at DESC
|
|
LIMIT 50',
|
|
[$orgId]
|
|
);
|
|
return [
|
|
'pending_count' => count($rows),
|
|
'items' => array_map(static fn($r) => [
|
|
'id' => (int) $r['id'], 'title' => $r['title'], 'source' => $r['source'],
|
|
'reference' => $r['reference'], 'impact' => $r['impact_level'],
|
|
'effective_date' => $r['effective_date'],
|
|
], $rows),
|
|
];
|
|
}
|
|
|
|
/** Scadenze imminenti (review_schedule, 90 gg). */
|
|
private function gatherDeadlines(int $orgId): array
|
|
{
|
|
$rows = Database::fetchAll(
|
|
'SELECT id, entity_type, title, next_review_date
|
|
FROM review_schedule
|
|
WHERE organization_id = ? AND next_review_date <= DATE_ADD(CURDATE(), INTERVAL 90 DAY)
|
|
ORDER BY next_review_date ASC
|
|
LIMIT 100',
|
|
[$orgId]
|
|
);
|
|
$today = date('Y-m-d');
|
|
return [
|
|
'count' => count($rows),
|
|
'items' => array_map(static fn($r) => [
|
|
'id' => (int) $r['id'], 'entity_type' => $r['entity_type'], 'title' => $r['title'],
|
|
'next_review_date' => $r['next_review_date'],
|
|
'overdue' => ($r['next_review_date'] !== null && $r['next_review_date'] < $today),
|
|
], $rows),
|
|
];
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// DECISIONI (OUTPUT)
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
/** POST /api/management-reviews/{id}/decisions Body: {decision*, owner_role_id?, due_date?, status?, capa_id?} */
|
|
public function addDecision(int $id): void
|
|
{
|
|
$this->requireOrgRole(self::MANAGE_ROLES);
|
|
$orgId = $this->getCurrentOrgId();
|
|
$r = Database::fetchOne('SELECT id, status FROM management_reviews WHERE id = ? AND organization_id = ?', [$id, $orgId]);
|
|
if (!$r) { $this->jsonError('Riesame non trovato', 404, 'NOT_FOUND'); }
|
|
if ($r['status'] === 'approved') { $this->jsonError('Il riesame è approvato: non si possono aggiungere decisioni', 409, 'REVIEW_APPROVED'); }
|
|
$b = $this->getJsonBody();
|
|
|
|
$decision = trim((string) ($b['decision'] ?? ''));
|
|
if ($decision === '') { $this->jsonError('Testo della decisione obbligatorio', 422, 'EMPTY_DECISION'); }
|
|
|
|
$ord = (int) (Database::fetchOne('SELECT COALESCE(MAX(ord), 0) + 1 AS n FROM management_review_decisions WHERE review_id = ?', [$id])['n'] ?? 1);
|
|
|
|
$did = Database::insert('management_review_decisions', [
|
|
'review_id' => $id,
|
|
'decision' => mb_substr($decision, 0, 5000),
|
|
'owner_role_id' => $this->validateRole($b['owner_role_id'] ?? null, $orgId),
|
|
'due_date' => $this->validateDate($b['due_date'] ?? null, 'due_date'),
|
|
'status' => in_array($b['status'] ?? '', ['open', 'in_progress', 'done'], true) ? $b['status'] : 'open',
|
|
'capa_id' => $this->validateCapa($b['capa_id'] ?? null, $orgId),
|
|
'ord' => $ord,
|
|
]);
|
|
$this->logAudit('mgmt_review_decision_added', 'management_review', $id, ['decision_id' => (int) $did]);
|
|
$this->jsonSuccess(['id' => (int) $did], 'Decisione aggiunta', 201);
|
|
}
|
|
|
|
/** PUT /api/management-reviews/decisions/{subId} */
|
|
public function updateDecision(int $subId): void
|
|
{
|
|
$this->requireOrgRole(self::MANAGE_ROLES);
|
|
$orgId = $this->getCurrentOrgId();
|
|
$b = $this->getJsonBody();
|
|
|
|
// Anti-IDOR: la decisione deve appartenere a un riesame dell'org.
|
|
$d = Database::fetchOne(
|
|
'SELECT d.id, r.status AS review_status
|
|
FROM management_review_decisions d
|
|
JOIN management_reviews r ON r.id = d.review_id
|
|
WHERE d.id = ? AND r.organization_id = ?',
|
|
[$subId, $orgId]
|
|
);
|
|
if (!$d) { $this->jsonError('Decisione non trovata', 404, 'NOT_FOUND'); }
|
|
if ($d['review_status'] === 'approved') { $this->jsonError('Il riesame è approvato: decisione non modificabile', 409, 'REVIEW_APPROVED'); }
|
|
|
|
$updates = [];
|
|
if ($this->hasParam('decision')) {
|
|
$decision = trim((string) ($b['decision'] ?? ''));
|
|
if ($decision === '') { $this->jsonError('Testo della decisione obbligatorio', 422, 'EMPTY_DECISION'); }
|
|
$updates['decision'] = mb_substr($decision, 0, 5000);
|
|
}
|
|
if ($this->hasParam('owner_role_id')) { $updates['owner_role_id'] = $this->validateRole($b['owner_role_id'] ?? null, $orgId); }
|
|
if ($this->hasParam('due_date')) { $updates['due_date'] = $this->validateDate($b['due_date'] ?? null, 'due_date'); }
|
|
if ($this->hasParam('status') && in_array($b['status'], ['open', 'in_progress', 'done'], true)) { $updates['status'] = $b['status']; }
|
|
if ($this->hasParam('capa_id')) { $updates['capa_id'] = $this->validateCapa($b['capa_id'] ?? null, $orgId); }
|
|
|
|
if (!empty($updates)) {
|
|
Database::update('management_review_decisions', $updates, 'id = ?', [$subId]);
|
|
}
|
|
$this->logAudit('mgmt_review_decision_updated', 'management_review_decision', $subId, array_keys($updates));
|
|
$this->jsonSuccess(['id' => $subId, 'updated' => array_keys($updates)], 'Decisione aggiornata');
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// APPROVE — congela snapshot + immutabilità
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
/** POST /api/management-reviews/{id}/approve (org_admin) */
|
|
public function approve(int $id): void
|
|
{
|
|
$this->requireOrgRole(self::APPROVE_ROLES);
|
|
$orgId = $this->getCurrentOrgId();
|
|
$r = Database::fetchOne('SELECT id, status, snapshot FROM management_reviews WHERE id = ? AND organization_id = ?', [$id, $orgId]);
|
|
if (!$r) { $this->jsonError('Riesame non trovato', 404, 'NOT_FOUND'); }
|
|
if ($r['status'] === 'approved') { $this->jsonError('Riesame già approvato', 409, 'ALREADY_APPROVED'); }
|
|
|
|
// Congela lo snapshot: usa quello già salvato (rivisto dall'utente) oppure,
|
|
// se assente, ricalcola gli input aggregati al momento dell'approvazione.
|
|
$existing = $r['snapshot'] ? json_decode($r['snapshot'], true) : null;
|
|
$snapshot = is_array($existing) && !empty($existing) ? $existing : $this->buildGather($orgId);
|
|
$snapshot['frozen_at'] = date('Y-m-d H:i:s');
|
|
|
|
Database::update('management_reviews', [
|
|
'status' => 'approved',
|
|
'approved_by' => $this->getCurrentUserId(),
|
|
'approved_at' => date('Y-m-d H:i:s'),
|
|
'snapshot' => json_encode($snapshot, JSON_UNESCAPED_UNICODE),
|
|
], 'id = ? AND organization_id = ?', [$id, $orgId]);
|
|
|
|
$this->logAudit('mgmt_review_approved', 'management_review', $id, ['frozen' => true]);
|
|
$this->jsonSuccess(['id' => $id, 'status' => 'approved'], 'Riesame approvato');
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// REPORT — verbale HTML stampabile
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
/** GET /api/management-reviews/{id}/report (verbale HTML stampabile) */
|
|
public function report(int $id): void
|
|
{
|
|
$this->requireOrgRole(['org_admin', 'compliance_manager', 'board_member', 'auditor']);
|
|
$orgId = $this->getCurrentOrgId();
|
|
$r = Database::fetchOne(
|
|
'SELECT r.*, u.full_name AS chair_name, a.full_name AS approver_name, o.name AS org_name
|
|
FROM management_reviews r
|
|
LEFT JOIN users u ON u.id = r.chair_user_id
|
|
LEFT JOIN users a ON a.id = r.approved_by
|
|
LEFT JOIN organizations o ON o.id = r.organization_id
|
|
WHERE r.id = ? AND r.organization_id = ?',
|
|
[$id, $orgId]
|
|
);
|
|
if (!$r) { $this->jsonError('Riesame non trovato', 404, 'NOT_FOUND'); }
|
|
|
|
$decisions = $this->loadDecisions($id);
|
|
$snapshot = $r['snapshot'] ? json_decode($r['snapshot'], true) : ($r['status'] === 'approved' ? null : $this->buildGather($orgId));
|
|
|
|
header('Content-Type: text/html; charset=utf-8');
|
|
echo $this->renderReportHtml($r, $decisions, is_array($snapshot) ? $snapshot : ['sections' => []]);
|
|
exit;
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// HELPER
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
private function loadDecisions(int $reviewId): array
|
|
{
|
|
$rows = Database::fetchAll(
|
|
'SELECT d.id, d.decision, d.owner_role_id, ro.role_name AS owner_role_name,
|
|
d.due_date, d.status, d.capa_id, c.capa_code, d.ord
|
|
FROM management_review_decisions d
|
|
LEFT JOIN org_roles ro ON ro.id = d.owner_role_id
|
|
LEFT JOIN capa_actions c ON c.id = d.capa_id
|
|
WHERE d.review_id = ? ORDER BY d.ord ASC, d.id ASC',
|
|
[$reviewId]
|
|
);
|
|
return array_map(static fn($d) => [
|
|
'id' => (int) $d['id'],
|
|
'decision' => $d['decision'],
|
|
'owner_role_id' => $d['owner_role_id'] !== null ? (int) $d['owner_role_id'] : null,
|
|
'owner_role_name' => $d['owner_role_name'],
|
|
'due_date' => $d['due_date'],
|
|
'status' => $d['status'],
|
|
'capa_id' => $d['capa_id'] !== null ? (int) $d['capa_id'] : null,
|
|
'capa_code' => $d['capa_code'],
|
|
'ord' => (int) $d['ord'],
|
|
], $rows);
|
|
}
|
|
|
|
/** Genera RD-AAAA-NN univoco per org+anno (NN progressivo, 2 cifre). */
|
|
private function nextCode(int $orgId, int $year): string
|
|
{
|
|
$prefix = 'RD-' . $year . '-';
|
|
// progressivo NUMERICO (non lessicografico: 'RD-2026-100' verrebbe < 'RD-2026-99')
|
|
$row = Database::fetchOne(
|
|
"SELECT MAX(CAST(SUBSTRING_INDEX(code, '-', -1) AS UNSIGNED)) AS n
|
|
FROM management_reviews WHERE organization_id = ? AND code LIKE ?",
|
|
[$orgId, $prefix . '%']
|
|
);
|
|
$next = ((int) ($row['n'] ?? 0)) + 1;
|
|
return $prefix . str_pad((string) $next, 2, '0', STR_PAD_LEFT);
|
|
}
|
|
|
|
private function validateUser($id, int $orgId): ?int
|
|
{
|
|
$id = ($id === null || $id === '') ? null : (int) $id;
|
|
if ($id === null) { return null; }
|
|
// l'utente deve essere membro dell'org (anti-IDOR)
|
|
$ok = Database::fetchOne(
|
|
'SELECT user_id FROM user_organizations WHERE user_id = ? AND organization_id = ?',
|
|
[$id, $orgId]
|
|
);
|
|
if (!$ok) { $this->jsonError('Utente presidente non valido per questa organizzazione', 422, 'INVALID_CHAIR'); }
|
|
return $id;
|
|
}
|
|
|
|
private function validateRole($id, int $orgId): ?int
|
|
{
|
|
$id = ($id === null || $id === '') ? null : (int) $id;
|
|
if ($id === null) { return null; }
|
|
$ok = Database::fetchOne('SELECT id FROM org_roles WHERE id = ? AND organization_id = ?', [$id, $orgId]);
|
|
if (!$ok) { $this->jsonError('Ruolo owner non valido', 422, 'INVALID_ROLE'); }
|
|
return $id;
|
|
}
|
|
|
|
private function validateCapa($id, int $orgId): ?int
|
|
{
|
|
$id = ($id === null || $id === '') ? null : (int) $id;
|
|
if ($id === null) { return null; }
|
|
$ok = Database::fetchOne('SELECT id FROM capa_actions WHERE id = ? AND organization_id = ?', [$id, $orgId]);
|
|
if (!$ok) { $this->jsonError('Azione CAPA collegata non valida', 422, 'INVALID_CAPA'); }
|
|
return $id;
|
|
}
|
|
|
|
private function validateAttendees($raw): ?array
|
|
{
|
|
if ($raw === null) { return null; }
|
|
if (!is_array($raw)) { $this->jsonError('attendees deve essere un array', 422, 'INVALID_ATTENDEES'); }
|
|
$out = [];
|
|
foreach ($raw as $a) {
|
|
if (is_string($a)) { $name = trim($a); $role = ''; }
|
|
elseif (is_array($a)) { $name = trim((string) ($a['name'] ?? '')); $role = trim((string) ($a['role'] ?? '')); }
|
|
else { continue; }
|
|
if ($name === '') { continue; }
|
|
$item = ['name' => mb_substr($name, 0, 150)];
|
|
if ($role !== '') { $item['role'] = mb_substr($role, 0, 150); }
|
|
$out[] = $item;
|
|
}
|
|
return $out;
|
|
}
|
|
|
|
private function validateDate($v, string $field): ?string
|
|
{
|
|
if ($v === null || $v === '') { return null; }
|
|
$d = trim((string) $v);
|
|
$dt = DateTime::createFromFormat('Y-m-d', $d);
|
|
if (!$dt || $dt->format('Y-m-d') !== $d) { $this->jsonError("Data $field non valida (atteso AAAA-MM-GG)", 422, 'INVALID_DATE'); }
|
|
return $d;
|
|
}
|
|
|
|
private function nullableStr($v, ?int $max = null): ?string
|
|
{
|
|
if ($v === null) { return null; }
|
|
$s = trim((string) $v);
|
|
if ($s === '') { return null; }
|
|
if ($max !== null && mb_strlen($s) > $max) { $s = mb_substr($s, 0, $max); }
|
|
return $s;
|
|
}
|
|
|
|
/** HTML del verbale (stampabile). Tutto escapato (esc). */
|
|
private function renderReportHtml(array $r, array $decisions, array $snapshot): string
|
|
{
|
|
$esc = static fn($s) => htmlspecialchars((string) ($s ?? ''), ENT_QUOTES, 'UTF-8');
|
|
$sections = $snapshot['sections'] ?? [];
|
|
|
|
$stLabel = $r['status'] === 'approved' ? 'APPROVATO' : 'BOZZA';
|
|
$att = $r['attendees'] ? json_decode($r['attendees'], true) : [];
|
|
$attList = '';
|
|
if (is_array($att) && $att) {
|
|
$attList = '<ul>' . implode('', array_map(static function ($a) use ($esc) {
|
|
$n = is_array($a) ? ($a['name'] ?? '') : $a;
|
|
$ro = is_array($a) ? ($a['role'] ?? '') : '';
|
|
return '<li>' . $esc($n) . ($ro ? ' — <em>' . $esc($ro) . '</em>' : '') . '</li>';
|
|
}, $att)) . '</ul>';
|
|
} else {
|
|
$attList = '<p class="muted">Nessun partecipante indicato.</p>';
|
|
}
|
|
|
|
// Tabella decisioni
|
|
$decRows = '';
|
|
if ($decisions) {
|
|
foreach ($decisions as $d) {
|
|
$stMap = ['open' => 'Aperta', 'in_progress' => 'In corso', 'done' => 'Conclusa'];
|
|
$decRows .= '<tr><td>' . $esc($d['decision']) . '</td>'
|
|
. '<td>' . $esc($d['owner_role_name'] ?: '—') . '</td>'
|
|
. '<td>' . $esc($d['due_date'] ?: '—') . '</td>'
|
|
. '<td>' . $esc($stMap[$d['status']] ?? $d['status']) . '</td>'
|
|
. '<td>' . $esc($d['capa_code'] ?: '—') . '</td></tr>';
|
|
}
|
|
} else {
|
|
$decRows = '<tr><td colspan="5" class="muted">Nessuna decisione registrata.</td></tr>';
|
|
}
|
|
|
|
$inputs = $this->renderInputsHtml($sections, $esc);
|
|
|
|
return '<!DOCTYPE html><html lang="it"><head><meta charset="utf-8">'
|
|
. '<title>Verbale Riesame di Direzione ' . $esc($r['code']) . '</title>'
|
|
. '<style>'
|
|
. 'body{font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;color:#1f2937;max-width:900px;margin:24px auto;padding:0 20px;line-height:1.5;}'
|
|
. 'h1{font-size:1.5rem;border-bottom:3px solid #0066CC;padding-bottom:8px;}'
|
|
. 'h2{font-size:1.1rem;margin-top:28px;color:#0066CC;border-bottom:1px solid #e5e7eb;padding-bottom:4px;}'
|
|
. 'h3{font-size:.95rem;margin:18px 0 6px;}'
|
|
. '.meta{background:#f8fafc;border:1px solid #e5e7eb;border-radius:8px;padding:14px 18px;font-size:.9rem;}'
|
|
. '.meta div{margin:3px 0;} .badge{display:inline-block;padding:2px 10px;border-radius:6px;font-weight:700;font-size:.8rem;}'
|
|
. '.b-approved{background:#dcfce7;color:#166534;} .b-draft{background:#f3f4f6;color:#6b7280;}'
|
|
. 'table{width:100%;border-collapse:collapse;font-size:.86rem;margin:8px 0 16px;}'
|
|
. 'th,td{border:1px solid #e5e7eb;padding:7px 10px;text-align:left;vertical-align:top;}'
|
|
. 'th{background:#f1f5f9;font-size:.78rem;text-transform:uppercase;letter-spacing:.03em;}'
|
|
. '.muted{color:#9ca3af;} ul{margin:4px 0;padding-left:20px;}'
|
|
. '.foot{margin-top:36px;font-size:.78rem;color:#6b7280;border-top:1px solid #e5e7eb;padding-top:10px;}'
|
|
. '@media print{body{margin:0;} h2{page-break-after:avoid;}}'
|
|
. '</style></head><body>'
|
|
. '<h1>Verbale del Riesame di Direzione</h1>'
|
|
. '<div class="meta">'
|
|
. '<div><strong>Organizzazione:</strong> ' . $esc($r['org_name']) . '</div>'
|
|
. '<div><strong>Codice:</strong> ' . $esc($r['code']) . ' <span class="badge ' . ($r['status'] === 'approved' ? 'b-approved' : 'b-draft') . '">' . $stLabel . '</span></div>'
|
|
. '<div><strong>Data riesame:</strong> ' . $esc($r['review_date'] ?: '—') . '</div>'
|
|
. '<div><strong>Periodo di riferimento:</strong> ' . $esc($r['period_label'] ?: '—') . '</div>'
|
|
. '<div><strong>Presidente:</strong> ' . $esc($r['chair_name'] ?: '—') . '</div>'
|
|
. ($r['status'] === 'approved' ? '<div><strong>Approvato da:</strong> ' . $esc($r['approver_name'] ?: '—') . ' il ' . $esc($r['approved_at']) . '</div>' : '')
|
|
. '</div>'
|
|
. '<h2>Partecipanti</h2>' . $attList
|
|
. '<h2>Elementi in ingresso (ISO/IEC 27001 §9.3.2)</h2>' . $inputs
|
|
. '<h2>Conclusioni</h2>' . ($r['conclusions'] ? '<p>' . nl2br($esc($r['conclusions'])) . '</p>' : '<p class="muted">Nessuna conclusione registrata.</p>')
|
|
. '<h2>Decisioni e azioni (elementi in uscita §9.3.3)</h2>'
|
|
. '<table><thead><tr><th>Decisione</th><th>Responsabile (ruolo)</th><th>Scadenza</th><th>Stato</th><th>CAPA</th></tr></thead><tbody>'
|
|
. $decRows . '</tbody></table>'
|
|
. '<div class="foot">Documento generato da NIS2 Agile — riesame periodico del SGSI (ISO/IEC 27001 cl. 9.3; buona prassi di governance NIS2 GV.PO-02). Strumento di supporto, non sostituisce l\'auditor.</div>'
|
|
. '</body></html>';
|
|
}
|
|
|
|
/** Rende le sezioni di input dello snapshot in HTML compatto. */
|
|
private function renderInputsHtml(array $sections, callable $esc): string
|
|
{
|
|
$titles = [
|
|
'corrective_actions' => 'Stato azioni dal riesame precedente / CAPA aperte',
|
|
'stakeholders' => 'Cambiamenti del contesto e parti interessate',
|
|
'internal_audits' => 'Risultati degli audit interni',
|
|
'nonconformities' => 'Non conformità e azioni correttive',
|
|
'compliance_score' => 'Risultati del monitoraggio (avanzamento SoA / KPI)',
|
|
'objectives' => 'Raggiungimento degli obiettivi del SGSI',
|
|
'risks' => 'Valutazione dei rischi e stato del trattamento',
|
|
'normative' => 'Aggiornamenti normativi non riscontrati (ACK)',
|
|
'training' => 'Formazione e consapevolezza',
|
|
'upcoming_deadlines' => 'Scadenze imminenti (90 giorni)',
|
|
];
|
|
$html = '';
|
|
foreach ($titles as $key => $title) {
|
|
$sec = $sections[$key] ?? null;
|
|
$html .= '<h3>' . $esc($title) . '</h3>';
|
|
if (!is_array($sec) || empty($sec['available'])) {
|
|
$html .= '<p class="muted">Dato non disponibile.</p>';
|
|
continue;
|
|
}
|
|
$html .= '<p>' . $esc($this->summarizeSection($key, $sec)) . '</p>';
|
|
$items = $sec['items'] ?? null;
|
|
if (is_array($items) && $items) {
|
|
$html .= '<ul>';
|
|
foreach (array_slice($items, 0, 15) as $it) {
|
|
$label = $it['title'] ?? ($it['code'] ?? '');
|
|
$extra = [];
|
|
foreach (['code', 'status', 'severity', 'due_date', 'next_review_date', 'impact', 'reference'] as $f) {
|
|
if (!empty($it[$f]) && $it[$f] !== $label) { $extra[] = $esc($it[$f]); }
|
|
}
|
|
$html .= '<li>' . $esc($label) . ($extra ? ' <span class="muted">(' . implode(' · ', $extra) . ')</span>' : '') . '</li>';
|
|
}
|
|
$html .= '</ul>';
|
|
}
|
|
}
|
|
return $html;
|
|
}
|
|
|
|
/** Frase di sintesi numerica per sezione. */
|
|
private function summarizeSection(string $key, array $sec): string
|
|
{
|
|
switch ($key) {
|
|
case 'nonconformities': return (int) ($sec['open_count'] ?? 0) . ' non conformità aperte.';
|
|
case 'corrective_actions': return (int) ($sec['open_count'] ?? 0) . ' azioni correttive in corso.';
|
|
case 'internal_audits': return (int) ($sec['total'] ?? 0) . ' audit interni (' . (int) ($sec['completed'] ?? 0) . ' completati, ' . (int) ($sec['planned'] ?? 0) . ' pianificati).';
|
|
case 'risks':
|
|
$t = $sec['treatments'] ?? [];
|
|
return (int) ($sec['total'] ?? 0) . ' rischi censiti; trattamenti: ' . (int) ($t['completed'] ?? 0) . ' completati, ' . (int) ($t['open'] ?? 0) . ' aperti, ' . (int) ($t['overdue'] ?? 0) . ' in ritardo.';
|
|
case 'compliance_score':
|
|
$pct = $sec['soa_avg_implementation'];
|
|
return 'Avanzamento medio SoA: ' . ($pct !== null ? (int) $pct . '%' : 'n/d') . ' su ' . (int) ($sec['soa_controls_applicable'] ?? 0) . ' controlli applicabili.';
|
|
case 'objectives': return (int) ($sec['count'] ?? 0) . ' obiettivi SGSI definiti.';
|
|
case 'training': return (int) ($sec['completed'] ?? 0) . '/' . (int) ($sec['total'] ?? 0) . ' assegnazioni completate, ' . (int) ($sec['overdue'] ?? 0) . ' in ritardo.';
|
|
case 'stakeholders': return (int) ($sec['total'] ?? 0) . ' attività verso stakeholder (' . (int) ($sec['completed'] ?? 0) . ' completate).';
|
|
case 'normative': return (int) ($sec['pending_count'] ?? 0) . ' aggiornamenti normativi da riscontrare.';
|
|
case 'upcoming_deadlines': return (int) ($sec['count'] ?? 0) . ' scadenze nei prossimi 90 giorni.';
|
|
}
|
|
return '';
|
|
}
|
|
}
|