#1 CRITICO aiAnalyze: askWithRag ritorna ['answer','sources','rag_used'], non una stringa. Ora estrae 'answer' (ai_summary) e salva 'sources' in ai_recommendations. Prima salvava il JSON intero in ai_summary. #2 ALTO corpus RAG: acn_requirements.json aveva 188/203 testi TRONCATI alla prima riga PDF (es. GV.PO-01#1: 84 char invece di 838). Rigenerato dai testi INTEGRALI di acn_measures.json (87+116, zero troncamenti). Ri-ingest Qdrant. #3 MEDIO catalog(): org non classificata dava entity_level=null + warning PHP $totals[null] + TypeError frontend. Ora 422 ENTITY_LEVEL_REQUIRED come create(). #4 MEDIO guida cap-5 GV.RR-04: "figure chiave dell'organigramma" era errato e auto-contraddittorio -> "personale autorizzato + amministratori di sistema, valutazione esperienza/capacita/affidabilita" (allineato testo ACN). #5 BASSI: openAcn try/catch (no unhandled rejection su Riprendi); badge importante/essenziale IT/EN; overall_score=null (non 0.0) se tutti N/A. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
608 lines
24 KiB
PHP
608 lines
24 KiB
PHP
<?php
|
|
/**
|
|
* NIS2 Agile - Gap Analysis ACN (Determinazione 164179/2025)
|
|
* ----------------------------------------------------------------------------
|
|
* Assessment di conformita di SECONDO LIVELLO: misure e requisiti puntuali
|
|
* della Determinazione ACN n. 164179/2025 (Allegati 1 e 2), NON le 10 lettere
|
|
* generiche dell'Art. 21 NIS2 (quelle restano in AssessmentController).
|
|
*
|
|
* - soggetti IMPORTANTI (Allegato 1): 37 misure, 87 requisiti
|
|
* - soggetti ESSENZIALI (Allegato 2): 43 misure, 116 requisiti (= 87 + 29)
|
|
*
|
|
* La codifica dei requisiti e identica per i due livelli; agli essenziali si
|
|
* aggiungono misure e punti. Il perimetro applicabile e filtrato in base al
|
|
* livello del soggetto (acn_assessments.entity_level, derivato da
|
|
* organizations.entity_type).
|
|
*
|
|
* Fonte testi: application/data/acn_measures.json (estratto integrale ACN).
|
|
*
|
|
* Endpoint (base /api/acn-gap):
|
|
* GET /catalog - catalogo misure/requisiti per il livello dell'org
|
|
* GET /list - assessment ACN dell'org
|
|
* POST /create - crea assessment (pre-popola requisiti applicabili)
|
|
* GET /{id} - intestazione + stats
|
|
* GET /{id}/requirements - requisiti raggruppati per funzione/categoria/misura
|
|
* POST /{id}/respond - salva risposta (singola o batch)
|
|
* POST /{id}/complete - calcola score e completa
|
|
* GET /{id}/report - report completo
|
|
* POST /{id}/aiAnalyze - analisi AI con grounding sui requisiti ACN
|
|
*/
|
|
|
|
require_once __DIR__ . '/BaseController.php';
|
|
|
|
class AcnAssessmentController extends BaseController
|
|
{
|
|
/** Cache in-process del dataset misure. */
|
|
private ?array $measuresCache = null;
|
|
|
|
// ════════════════════════ CATALOGO ════════════════════════
|
|
|
|
/**
|
|
* GET /api/acn-gap/catalog
|
|
* Catalogo dei requisiti ACN applicabili al livello del soggetto corrente
|
|
* (importante/essenziale), raggruppato per funzione FW. Sola lettura,
|
|
* non crea assessment. Utile per anteprima/consultazione normativa.
|
|
*/
|
|
public function catalog(): void
|
|
{
|
|
$this->requireOrgAccess();
|
|
$level = $this->resolveEntityLevel();
|
|
if ($level === null) {
|
|
// Coerente con create(): senza classificazione non c'e perimetro.
|
|
// Il frontend intercetta ENTITY_LEVEL_REQUIRED e mostra l'invito a classificare.
|
|
$this->jsonError(
|
|
'Il soggetto non e classificato come importante o essenziale. Completa prima la classificazione NIS2 dell\'organizzazione.',
|
|
422,
|
|
'ENTITY_LEVEL_REQUIRED'
|
|
);
|
|
}
|
|
$grouped = $this->groupedRequirements($level, []);
|
|
|
|
$totals = $this->datasetTotals();
|
|
$this->jsonSuccess([
|
|
'entity_level' => $level,
|
|
'totals' => $totals[$level],
|
|
'functions' => $grouped,
|
|
'source' => $this->measures()['source'] ?? null,
|
|
]);
|
|
}
|
|
|
|
// ════════════════════════ LISTA / CREATE ════════════════════════
|
|
|
|
/** GET /api/acn-gap/list */
|
|
public function list(): void
|
|
{
|
|
$this->requireOrgAccess();
|
|
$orgId = $this->getCurrentOrgId();
|
|
$rows = Database::fetchAll(
|
|
'SELECT id, title, entity_level, status, overall_score, completed_at, created_at
|
|
FROM acn_assessments
|
|
WHERE organization_id = ?
|
|
ORDER BY created_at DESC',
|
|
[$orgId]
|
|
);
|
|
$this->jsonSuccess(['assessments' => $rows]);
|
|
}
|
|
|
|
/**
|
|
* POST /api/acn-gap/create Body: { title? }
|
|
* Crea un assessment ACN e pre-popola TUTTI i requisiti applicabili al
|
|
* livello del soggetto (snapshot del testo congelato).
|
|
*/
|
|
public function create(): void
|
|
{
|
|
$this->requireOrgRole(['org_admin', 'compliance_manager']);
|
|
$orgId = $this->getCurrentOrgId();
|
|
$userId = $this->getCurrentUserId();
|
|
$level = $this->resolveEntityLevel();
|
|
|
|
if ($level === null) {
|
|
$this->jsonError(
|
|
'Il soggetto non e classificato come importante o essenziale. Completa prima la classificazione NIS2 dell\'organizzazione.',
|
|
422,
|
|
'ENTITY_LEVEL_REQUIRED'
|
|
);
|
|
}
|
|
|
|
$title = trim((string) $this->getParam('title', ''));
|
|
if ($title === '') {
|
|
$title = 'Gap Analysis ACN ' . date('Y-m-d');
|
|
}
|
|
|
|
Database::beginTransaction();
|
|
try {
|
|
$assessmentId = Database::insert('acn_assessments', [
|
|
'organization_id' => $orgId,
|
|
'title' => $title,
|
|
'entity_level' => $level,
|
|
'status' => 'draft',
|
|
'created_by' => $userId,
|
|
]);
|
|
|
|
$applicable = $this->applicableRequirements($level);
|
|
foreach ($applicable as $r) {
|
|
Database::insert('acn_assessment_responses', [
|
|
'assessment_id' => $assessmentId,
|
|
'organization_id' => $orgId,
|
|
'requirement_key' => $r['key'],
|
|
'measure_code' => $r['measure_code'],
|
|
'requirement_index' => $r['index'],
|
|
'function_code' => $r['function_code'],
|
|
'category_code' => $r['category_code'],
|
|
'requirement_text' => $r['text'],
|
|
'response_value' => null,
|
|
]);
|
|
}
|
|
Database::commit();
|
|
} catch (Throwable $e) {
|
|
if (Database::getInstance()->inTransaction()) {
|
|
Database::rollback();
|
|
}
|
|
throw $e;
|
|
}
|
|
|
|
$this->logAudit('acn_assessment_created', 'acn_assessment', $assessmentId, [
|
|
'entity_level' => $level,
|
|
'requirements' => count($applicable),
|
|
]);
|
|
|
|
$this->jsonSuccess([
|
|
'id' => $assessmentId,
|
|
'entity_level' => $level,
|
|
'requirements' => count($applicable),
|
|
], 'Assessment ACN creato', 201);
|
|
}
|
|
|
|
// ════════════════════════ DETTAGLIO ════════════════════════
|
|
|
|
/** GET /api/acn-gap/{id} */
|
|
public function get(int $id): void
|
|
{
|
|
$this->requireOrgAccess();
|
|
$a = $this->loadOwned($id);
|
|
$a['stats'] = $this->computeStats($id);
|
|
$this->jsonSuccess($a);
|
|
}
|
|
|
|
/**
|
|
* GET /api/acn-gap/{id}/requirements
|
|
* Requisiti raggruppati per funzione -> categoria -> misura, con la risposta
|
|
* salvata. Struttura pensata per il rendering a wizard/accordion.
|
|
*/
|
|
public function getRequirements(int $id): void
|
|
{
|
|
$this->requireOrgAccess();
|
|
$a = $this->loadOwned($id);
|
|
|
|
$rows = Database::fetchAll(
|
|
'SELECT requirement_key, measure_code, requirement_index, function_code,
|
|
category_code, requirement_text, response_value, evidence_description, notes, answered_at
|
|
FROM acn_assessment_responses
|
|
WHERE assessment_id = ?
|
|
ORDER BY id',
|
|
[$id]
|
|
);
|
|
|
|
// arricchimento da dataset: titoli misura/categoria/funzione
|
|
$meta = $this->measureMeta();
|
|
$byFunc = [];
|
|
foreach ($rows as $r) {
|
|
$fc = $r['function_code'];
|
|
$cc = $r['category_code'];
|
|
$mc = $r['measure_code'];
|
|
$byFunc[$fc] ??= [
|
|
'function_code' => $fc,
|
|
'function_name' => $meta['functions'][$fc] ?? $fc,
|
|
'categories' => [],
|
|
];
|
|
$byFunc[$fc]['categories'][$cc] ??= [
|
|
'category_code' => $cc,
|
|
'category_title' => $meta['categories'][$cc] ?? $cc,
|
|
'measures' => [],
|
|
];
|
|
$byFunc[$fc]['categories'][$cc]['measures'][$mc] ??= [
|
|
'measure_code' => $mc,
|
|
'measure_title' => $meta['measures'][$mc] ?? $mc,
|
|
'requirements' => [],
|
|
];
|
|
$byFunc[$fc]['categories'][$cc]['measures'][$mc]['requirements'][] = [
|
|
'key' => $r['requirement_key'],
|
|
'index' => (int) $r['requirement_index'],
|
|
'text' => $r['requirement_text'],
|
|
'response' => $r['response_value'],
|
|
'evidence' => $r['evidence_description'],
|
|
'notes' => $r['notes'],
|
|
'answered_at' => $r['answered_at'],
|
|
];
|
|
}
|
|
|
|
// converti mappe associative in liste ordinate
|
|
$functions = [];
|
|
foreach ($byFunc as $f) {
|
|
$cats = [];
|
|
foreach ($f['categories'] as $c) {
|
|
$c['measures'] = array_values($c['measures']);
|
|
$cats[] = $c;
|
|
}
|
|
$f['categories'] = $cats;
|
|
$functions[] = $f;
|
|
}
|
|
|
|
$this->jsonSuccess([
|
|
'assessment' => ['id' => $id, 'entity_level' => $a['entity_level'], 'status' => $a['status']],
|
|
'functions' => $functions,
|
|
'stats' => $this->computeStats($id),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* POST /api/acn-gap/{id}/respond
|
|
* Body: { requirement_key, response_value, evidence?, notes? }
|
|
* oppure { responses: [ {requirement_key, response_value, ...}, ... ] }
|
|
*/
|
|
public function saveResponse(int $id): void
|
|
{
|
|
$this->requireOrgRole(['org_admin', 'compliance_manager', 'auditor']);
|
|
$a = $this->loadOwned($id);
|
|
if ($a['status'] === 'completed') {
|
|
$this->jsonError('Assessment gia completato: non modificabile.', 409, 'ALREADY_COMPLETED');
|
|
}
|
|
$userId = $this->getCurrentUserId();
|
|
$orgId = $this->getCurrentOrgId();
|
|
|
|
$body = $this->getJsonBody();
|
|
$batch = is_array($body['responses'] ?? null) ? $body['responses'] : [$body];
|
|
|
|
$valid = ['not_implemented', 'partial', 'implemented', 'not_applicable'];
|
|
$saved = 0;
|
|
foreach ($batch as $item) {
|
|
if (!is_array($item)) {
|
|
continue;
|
|
}
|
|
$key = trim((string) ($item['requirement_key'] ?? ''));
|
|
$val = (string) ($item['response_value'] ?? '');
|
|
if ($key === '' || !in_array($val, $valid, true)) {
|
|
continue;
|
|
}
|
|
$updated = Database::update('acn_assessment_responses', [
|
|
'response_value' => $val,
|
|
'evidence_description' => isset($item['evidence']) ? (string) $item['evidence'] : null,
|
|
'notes' => isset($item['notes']) ? (string) $item['notes'] : null,
|
|
'answered_by' => $userId,
|
|
'answered_at' => date('Y-m-d H:i:s'),
|
|
], 'assessment_id = ? AND requirement_key = ? AND organization_id = ?', [$id, $key, $orgId]);
|
|
$saved += $updated > 0 ? 1 : 0;
|
|
}
|
|
|
|
// porta a in_progress se era draft
|
|
if ($a['status'] === 'draft' && $saved > 0) {
|
|
Database::update('acn_assessments', ['status' => 'in_progress'], 'id = ?', [$id]);
|
|
}
|
|
|
|
$this->jsonSuccess(['saved' => $saved, 'stats' => $this->computeStats($id)], 'Risposte salvate');
|
|
}
|
|
|
|
/**
|
|
* POST /api/acn-gap/{id}/complete
|
|
* Calcola overall_score + function_scores + stats e marca completed.
|
|
* Richiede che tutti i requisiti applicabili abbiano una risposta.
|
|
*/
|
|
public function complete(int $id): void
|
|
{
|
|
$this->requireOrgRole(['org_admin', 'compliance_manager']);
|
|
$a = $this->loadOwned($id);
|
|
if ($a['status'] === 'completed') {
|
|
$this->jsonError('Assessment gia completato.', 409, 'ALREADY_COMPLETED');
|
|
}
|
|
|
|
$unanswered = (int) (Database::fetchOne(
|
|
'SELECT COUNT(*) AS n FROM acn_assessment_responses WHERE assessment_id = ? AND response_value IS NULL',
|
|
[$id]
|
|
)['n'] ?? 0);
|
|
if ($unanswered > 0) {
|
|
$this->jsonError(
|
|
"Mancano $unanswered requisiti senza risposta. Completa tutte le risposte prima di chiudere.",
|
|
422,
|
|
'INCOMPLETE',
|
|
['unanswered' => $unanswered]
|
|
);
|
|
}
|
|
|
|
[$overall, $funcScores, $stats] = $this->calculateScores($id);
|
|
|
|
Database::update('acn_assessments', [
|
|
'status' => 'completed',
|
|
'overall_score' => $overall,
|
|
'function_scores' => json_encode($funcScores, JSON_UNESCAPED_UNICODE),
|
|
'stats' => json_encode($stats, JSON_UNESCAPED_UNICODE),
|
|
'completed_by' => $this->getCurrentUserId(),
|
|
'completed_at' => date('Y-m-d H:i:s'),
|
|
], 'id = ?', [$id]);
|
|
|
|
$this->logAudit('acn_assessment_completed', 'acn_assessment', $id, [
|
|
'overall_score' => $overall,
|
|
'entity_level' => $a['entity_level'],
|
|
]);
|
|
|
|
$this->jsonSuccess([
|
|
'overall_score' => $overall,
|
|
'function_scores' => $funcScores,
|
|
'stats' => $stats,
|
|
], 'Assessment ACN completato');
|
|
}
|
|
|
|
/** GET /api/acn-gap/{id}/report */
|
|
public function getReport(int $id): void
|
|
{
|
|
$this->requireOrgAccess();
|
|
$a = $this->loadOwned($id);
|
|
|
|
// requisiti non conformi (priorita per il piano d'azione)
|
|
$gaps = Database::fetchAll(
|
|
"SELECT requirement_key, measure_code, function_code, category_code, requirement_text,
|
|
response_value, notes
|
|
FROM acn_assessment_responses
|
|
WHERE assessment_id = ? AND response_value IN ('not_implemented','partial')
|
|
ORDER BY FIELD(response_value,'not_implemented','partial'), function_code, measure_code, requirement_index",
|
|
[$id]
|
|
);
|
|
|
|
$a['function_scores'] = $a['function_scores'] ? json_decode($a['function_scores'], true) : null;
|
|
$a['stats'] = $a['stats'] ? json_decode($a['stats'], true) : $this->computeStats($id);
|
|
$a['ai_recommendations'] = $a['ai_recommendations'] ? json_decode($a['ai_recommendations'], true) : null;
|
|
|
|
$this->jsonSuccess(['assessment' => $a, 'gaps' => $gaps]);
|
|
}
|
|
|
|
/**
|
|
* POST /api/acn-gap/{id}/aiAnalyze
|
|
* Analisi AI dei gap con grounding sui requisiti ACN (la KB contiene gia i
|
|
* 203 requisiti, scope SYSTEM). Salva sintesi e raccomandazioni.
|
|
*/
|
|
public function aiAnalyze(int $id): void
|
|
{
|
|
$this->requireOrgRole(['org_admin', 'compliance_manager']);
|
|
$a = $this->loadOwned($id);
|
|
|
|
$gaps = Database::fetchAll(
|
|
"SELECT measure_code, requirement_index, requirement_text, response_value
|
|
FROM acn_assessment_responses
|
|
WHERE assessment_id = ? AND response_value IN ('not_implemented','partial')
|
|
ORDER BY function_code, measure_code, requirement_index
|
|
LIMIT 60",
|
|
[$id]
|
|
);
|
|
|
|
if (empty($gaps)) {
|
|
$this->jsonSuccess(['ai_summary' => 'Nessun gap rilevato: tutti i requisiti applicabili risultano conformi.'], 'Analisi completata');
|
|
}
|
|
|
|
require_once APP_PATH . '/services/AIService.php';
|
|
$ai = new AIService();
|
|
|
|
$lines = array_map(
|
|
fn($g) => "- {$g['measure_code']} punto {$g['requirement_index']} [{$g['response_value']}]: {$g['requirement_text']}",
|
|
$gaps
|
|
);
|
|
$question = "Analizza i seguenti gap di conformita rispetto alle misure di sicurezza di base ACN "
|
|
. "(Determinazione 164179/2025) per un soggetto " . ($a['entity_level'] === 'essential' ? 'ESSENZIALE' : 'IMPORTANTE') . ". "
|
|
. "Per ciascun gap suggerisci un'azione concreta e prioritizza. Cita SOLO requisiti ACN reali.\n\n"
|
|
. implode("\n", $lines);
|
|
|
|
try {
|
|
$userContext = [
|
|
'organization_id' => $this->getCurrentOrgId(),
|
|
'consulting_firm_id' => $this->currentUser['consulting_firm_id'] ?? null,
|
|
];
|
|
// askWithRag ritorna ['answer'=>string, 'sources'=>array, 'rag_used'=>bool]:
|
|
// estraiamo il testo, NON salviamo l'intero array.
|
|
$result = $ai->askWithRag($question, $userContext);
|
|
$answer = is_array($result) ? (string) ($result['answer'] ?? '') : (string) $result;
|
|
$sources = (is_array($result) && isset($result['sources'])) ? $result['sources'] : [];
|
|
} catch (Throwable $e) {
|
|
error_log('[AcnAssessment] aiAnalyze fallita: ' . $e->getMessage());
|
|
$this->jsonError('Analisi AI temporaneamente non disponibile. Riprova piu tardi.', 503, 'AI_UNAVAILABLE');
|
|
}
|
|
|
|
Database::update('acn_assessments', [
|
|
'ai_summary' => $answer,
|
|
'ai_recommendations' => json_encode($sources, JSON_UNESCAPED_UNICODE),
|
|
], 'id = ?', [$id]);
|
|
|
|
$this->logAudit('acn_assessment_ai_analyzed', 'acn_assessment', $id, ['gaps' => count($gaps)]);
|
|
$this->jsonSuccess(['ai_summary' => $answer, 'sources' => $sources], 'Analisi AI completata');
|
|
}
|
|
|
|
// ════════════════════════ HELPER ════════════════════════
|
|
|
|
/** Carica l'assessment verificando ownership org (anti-IDOR). */
|
|
private function loadOwned(int $id): array
|
|
{
|
|
$a = Database::fetchOne(
|
|
'SELECT * FROM acn_assessments WHERE id = ? AND organization_id = ?',
|
|
[$id, $this->getCurrentOrgId()]
|
|
);
|
|
if (!$a) {
|
|
$this->jsonError('Assessment non trovato', 404, 'NOT_FOUND');
|
|
}
|
|
return $a;
|
|
}
|
|
|
|
/**
|
|
* Determina il livello del soggetto dall'organization corrente.
|
|
* organizations.entity_type: 'essential'|'important'|'not_applicable'.
|
|
* Ritorna 'essential'|'important', oppure null se non classificato.
|
|
*/
|
|
private function resolveEntityLevel(): ?string
|
|
{
|
|
$org = Database::fetchOne('SELECT entity_type FROM organizations WHERE id = ?', [$this->getCurrentOrgId()]);
|
|
$t = $org['entity_type'] ?? null;
|
|
if ($t === 'essential' || $t === 'important') {
|
|
return $t;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** Carica e cache il dataset misure ACN. */
|
|
private function measures(): array
|
|
{
|
|
if ($this->measuresCache === null) {
|
|
$path = APP_PATH . '/data/acn_measures.json';
|
|
$json = is_readable($path) ? json_decode((string) file_get_contents($path), true) : null;
|
|
$this->measuresCache = is_array($json) ? $json : ['measures' => []];
|
|
}
|
|
return $this->measuresCache;
|
|
}
|
|
|
|
/** Totali requisiti per livello (da dataset). */
|
|
private function datasetTotals(): array
|
|
{
|
|
$t = $this->measures()['totals'] ?? [];
|
|
return [
|
|
'important' => [
|
|
'measures' => $t['measures_importante'] ?? 37,
|
|
'requirements' => $t['requirements_importante'] ?? 87,
|
|
],
|
|
'essential' => [
|
|
'measures' => $t['measures_essenziale'] ?? 43,
|
|
'requirements' => $t['requirements_essenziale'] ?? 116,
|
|
],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Lista piatta dei requisiti applicabili a un livello.
|
|
* @return array<int,array{key:string,measure_code:string,index:int,function_code:string,category_code:string,text:string}>
|
|
*/
|
|
private function applicableRequirements(string $level): array
|
|
{
|
|
$flag = $level === 'essential' ? 'essenziale' : 'importante';
|
|
$out = [];
|
|
foreach ($this->measures()['measures'] ?? [] as $m) {
|
|
$applies = $level === 'essential' ? ($m['applies_essenziale'] ?? false) : ($m['applies_importante'] ?? false);
|
|
if (!$applies) {
|
|
continue;
|
|
}
|
|
foreach ($m['requirements'] ?? [] as $r) {
|
|
if (empty($r[$flag])) {
|
|
continue; // punto non applicabile a questo livello
|
|
}
|
|
$out[] = [
|
|
'key' => $m['code'] . '#' . $r['index'],
|
|
'measure_code' => $m['code'],
|
|
'index' => (int) $r['index'],
|
|
'function_code' => $m['function_code'],
|
|
'category_code' => $m['category_code'],
|
|
'text' => $r['text'],
|
|
];
|
|
}
|
|
}
|
|
return $out;
|
|
}
|
|
|
|
/** Requisiti raggruppati per funzione (per il catalogo). */
|
|
private function groupedRequirements(?string $level, array $responses): array
|
|
{
|
|
if ($level === null) {
|
|
return [];
|
|
}
|
|
$reqs = $this->applicableRequirements($level);
|
|
$meta = $this->measureMeta();
|
|
$byFunc = [];
|
|
foreach ($reqs as $r) {
|
|
$fc = $r['function_code'];
|
|
$byFunc[$fc] ??= ['function_code' => $fc, 'function_name' => $meta['functions'][$fc] ?? $fc, 'measures' => []];
|
|
$mc = $r['measure_code'];
|
|
$byFunc[$fc]['measures'][$mc] ??= ['measure_code' => $mc, 'measure_title' => $meta['measures'][$mc] ?? $mc, 'requirements' => []];
|
|
$byFunc[$fc]['measures'][$mc]['requirements'][] = ['index' => $r['index'], 'text' => $r['text']];
|
|
}
|
|
$out = [];
|
|
foreach ($byFunc as $f) {
|
|
$f['measures'] = array_values($f['measures']);
|
|
$out[] = $f;
|
|
}
|
|
return $out;
|
|
}
|
|
|
|
/** Mappe codice->titolo per funzioni/categorie/misure (dal dataset). */
|
|
private function measureMeta(): array
|
|
{
|
|
static $meta = null;
|
|
if ($meta !== null) {
|
|
return $meta;
|
|
}
|
|
$functions = [];
|
|
$categories = [];
|
|
$measures = [];
|
|
foreach ($this->measures()['measures'] ?? [] as $m) {
|
|
$functions[$m['function_code']] = $m['function_name'] ?? $m['function_code'];
|
|
$categories[$m['category_code']] = $m['category_title'] ?? $m['category_code'];
|
|
$measures[$m['code']] = $m['title'] ?? $m['code'];
|
|
}
|
|
$meta = ['functions' => $functions, 'categories' => $categories, 'measures' => $measures];
|
|
return $meta;
|
|
}
|
|
|
|
/** Conteggi rapidi per stato risposta. */
|
|
private function computeStats(int $id): array
|
|
{
|
|
$rows = Database::fetchAll(
|
|
'SELECT response_value, COUNT(*) AS n FROM acn_assessment_responses WHERE assessment_id = ? GROUP BY response_value',
|
|
[$id]
|
|
);
|
|
$stats = ['applicable' => 0, 'implemented' => 0, 'partial' => 0, 'not_implemented' => 0, 'not_applicable' => 0, 'unanswered' => 0];
|
|
foreach ($rows as $r) {
|
|
$n = (int) $r['n'];
|
|
$stats['applicable'] += $n;
|
|
$v = $r['response_value'];
|
|
if ($v === null) {
|
|
$stats['unanswered'] += $n;
|
|
} elseif (isset($stats[$v])) {
|
|
$stats[$v] += $n;
|
|
}
|
|
}
|
|
return $stats;
|
|
}
|
|
|
|
/**
|
|
* Calcolo punteggi: overall + per funzione.
|
|
* Scoring per requisito: implemented=100, partial=50, not_implemented=0.
|
|
* not_applicable e unanswered ESCLUSI dal denominatore (coerente con
|
|
* l'assessment Art.21 esistente).
|
|
* @return array{0:float,1:array,2:array}
|
|
*/
|
|
private function calculateScores(int $id): array
|
|
{
|
|
$rows = Database::fetchAll(
|
|
'SELECT function_code, response_value FROM acn_assessment_responses WHERE assessment_id = ?',
|
|
[$id]
|
|
);
|
|
$val = ['implemented' => 100.0, 'partial' => 50.0, 'not_implemented' => 0.0];
|
|
|
|
$sum = 0.0; $cnt = 0;
|
|
$byFunc = [];
|
|
foreach ($rows as $r) {
|
|
$v = $r['response_value'];
|
|
if ($v === 'not_applicable' || $v === null || !isset($val[$v])) {
|
|
continue;
|
|
}
|
|
$fc = $r['function_code'];
|
|
$byFunc[$fc] ??= ['sum' => 0.0, 'cnt' => 0];
|
|
$byFunc[$fc]['sum'] += $val[$v];
|
|
$byFunc[$fc]['cnt']++;
|
|
$sum += $val[$v];
|
|
$cnt++;
|
|
}
|
|
|
|
// Se nessun requisito e applicabile (tutti not_applicable) il punteggio
|
|
// non e definito: null, non 0.0 (che significherebbe "0% conforme").
|
|
$overall = $cnt > 0 ? round($sum / $cnt, 2) : null;
|
|
$funcScores = [];
|
|
foreach ($byFunc as $fc => $d) {
|
|
$funcScores[$fc] = $d['cnt'] > 0 ? round($d['sum'] / $d['cnt'], 2) : 0.0;
|
|
}
|
|
|
|
return [$overall, $funcScores, $this->computeStats($id)];
|
|
}
|
|
}
|