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'); // Retry-on-duplicate: il codice RD-AAAA-NN è MAX+1 (race su create concorrenti); // la UNIQUE (organization_id, code) [mig.058] blocca i doppioni e qui si rigenera. $base = [ 'organization_id' => $orgId, '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(), ]; $code = null; $id = 0; for ($attempt = 0; ; $attempt++) { $code = $this->nextCode($orgId, $year); try { $id = (int) Database::insert('management_reviews', ['code' => $code] + $base); break; } catch (\PDOException $e) { if (($e->errorInfo[1] ?? 0) === 1062 && $attempt < 4) { continue; } throw $e; } } $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 = '
Nessun partecipante indicato.
'; } // Tabella decisioni $decRows = ''; if ($decisions) { foreach ($decisions as $d) { $stMap = ['open' => 'Aperta', 'in_progress' => 'In corso', 'done' => 'Conclusa']; $decRows .= '' . nl2br($esc($r['conclusions'])) . '
' : 'Nessuna conclusione registrata.
') . '| Decisione | Responsabile (ruolo) | Scadenza | Stato | CAPA |
|---|
Dato non disponibile.
'; continue; } $html .= '' . $esc($this->summarizeSection($key, $sec)) . '
'; $items = $sec['items'] ?? null; if (is_array($items) && $items) { $html .= '