Compare commits
3 Commits
0e2774d1a6
...
a122b49721
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a122b49721 | ||
|
|
cfaead6121 | ||
|
|
75a678f60e |
@ -82,6 +82,14 @@ define('ANTHROPIC_API_KEY', Env::get('ANTHROPIC_API_KEY', ''));
|
||||
define('ANTHROPIC_MODEL', Env::get('ANTHROPIC_MODEL', 'claude-sonnet-4-5-20250929'));
|
||||
define('ANTHROPIC_MAX_TOKENS', Env::int('ANTHROPIC_MAX_TOKENS', 4096));
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// CERTISOURCE (atti-service.php)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
define('CERTISOURCE_API_URL', Env::get('CERTISOURCE_API_URL', 'https://certisource.it/atti-service.php'));
|
||||
define('CERTISOURCE_API_KEY', Env::get('CERTISOURCE_API_KEY', '')); // cs_pat_...
|
||||
define('CERTISOURCE_POLL_MAX', Env::int('CERTISOURCE_POLL_MAX', 30)); // max tentativi polling
|
||||
define('CERTISOURCE_POLL_SEC', Env::int('CERTISOURCE_POLL_SEC', 3)); // secondi tra poll
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// FEEDBACK & SEGNALAZIONI
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@ -1460,4 +1460,573 @@ class ServicesController extends BaseController
|
||||
if ($score >= 40) return 'partial';
|
||||
return 'significant_gaps';
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
// lg231 / GRC INTEGRATION ENDPOINTS
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* GET /api/services/gap-analysis
|
||||
* Gap analysis per dominio NIS2 Art.21 con mapping ai 7 pilastri MOG 231.
|
||||
* Basato sull'assessment più recente completato.
|
||||
*/
|
||||
public function gapAnalysis(): void
|
||||
{
|
||||
$this->requireApiKey('read:compliance');
|
||||
$orgId = $this->currentOrgId;
|
||||
|
||||
// Mapping NIS2 domain → MOG 231 pillar e articolo
|
||||
$domainMeta = [
|
||||
'governance' => ['article' => 'Art.20-21', 'mog_pillar' => 'pillar_1_governance'],
|
||||
'risk_management' => ['article' => 'Art.21.2.a', 'mog_pillar' => 'pillar_2_risk_assessment'],
|
||||
'incident_management' => ['article' => 'Art.21.2.b', 'mog_pillar' => 'pillar_7_segnalazioni'],
|
||||
'business_continuity' => ['article' => 'Art.21.2.c', 'mog_pillar' => 'pillar_5_monitoraggio'],
|
||||
'supply_chain' => ['article' => 'Art.21.2.d', 'mog_pillar' => 'pillar_3_procedure_operative'],
|
||||
'vulnerability' => ['article' => 'Art.21.2.e', 'mog_pillar' => 'pillar_5_monitoraggio'],
|
||||
'policy_measurement' => ['article' => 'Art.21.2.f', 'mog_pillar' => 'pillar_3_procedure_operative'],
|
||||
'training_awareness' => ['article' => 'Art.21.2.g', 'mog_pillar' => 'pillar_4_formazione'],
|
||||
'cryptography' => ['article' => 'Art.21.2.h', 'mog_pillar' => 'pillar_6_sicurezza_it'],
|
||||
'access_control' => ['article' => 'Art.21.2.i', 'mog_pillar' => 'pillar_6_sicurezza_it'],
|
||||
];
|
||||
|
||||
// Assessment più recente completato
|
||||
$assessment = Database::fetchOne(
|
||||
'SELECT id, overall_score, category_scores, ai_summary, ai_recommendations, completed_at
|
||||
FROM assessments
|
||||
WHERE organization_id = ? AND status = "completed"
|
||||
ORDER BY completed_at DESC LIMIT 1',
|
||||
[$orgId]
|
||||
);
|
||||
|
||||
if (!$assessment) {
|
||||
$this->jsonSuccess([
|
||||
'assessment_id' => null,
|
||||
'completed_at' => null,
|
||||
'overall_score' => null,
|
||||
'domains' => [],
|
||||
'ai_summary' => null,
|
||||
'ai_recommendations' => [],
|
||||
'note' => 'Nessun assessment completato. Avvia un gap analysis dal modulo Assessment.',
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Risposte per assessment
|
||||
$responses = Database::fetchAll(
|
||||
'SELECT category, response_value
|
||||
FROM assessment_responses
|
||||
WHERE assessment_id = ?',
|
||||
[$assessment['id']]
|
||||
);
|
||||
|
||||
// Calcola score per dominio dalle risposte
|
||||
$domainData = [];
|
||||
foreach ($responses as $r) {
|
||||
$cat = $r['category'];
|
||||
if (!isset($domainData[$cat])) {
|
||||
$domainData[$cat] = ['implemented' => 0, 'partial' => 0, 'not_implemented' => 0, 'not_applicable' => 0];
|
||||
}
|
||||
match ($r['response_value']) {
|
||||
'implemented' => $domainData[$cat]['implemented']++,
|
||||
'partial' => $domainData[$cat]['partial']++,
|
||||
'not_implemented' => $domainData[$cat]['not_implemented']++,
|
||||
'not_applicable' => $domainData[$cat]['not_applicable']++,
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
$domains = [];
|
||||
foreach ($domainData as $domain => $counts) {
|
||||
$scorable = $counts['implemented'] + $counts['partial'] + $counts['not_implemented'];
|
||||
$score = $scorable > 0
|
||||
? (int) round(($counts['implemented'] * 4 + $counts['partial'] * 2) / ($scorable * 4) * 100)
|
||||
: null;
|
||||
$gapLevel = match (true) {
|
||||
$score === null => 'not_assessed',
|
||||
$score >= 75 => 'low',
|
||||
$score >= 50 => 'medium',
|
||||
$score >= 25 => 'high',
|
||||
default => 'critical',
|
||||
};
|
||||
$meta = $domainMeta[$domain] ?? ['article' => 'Art.21', 'mog_pillar' => 'pillar_3_procedure_operative'];
|
||||
$domains[] = [
|
||||
'domain' => $domain,
|
||||
'nis2_article' => $meta['article'],
|
||||
'mog_pillar' => $meta['mog_pillar'],
|
||||
'score' => $score,
|
||||
'gap_level' => $gapLevel,
|
||||
'implemented' => $counts['implemented'],
|
||||
'partial' => $counts['partial'],
|
||||
'not_implemented' => $counts['not_implemented'],
|
||||
'not_applicable' => $counts['not_applicable'],
|
||||
];
|
||||
}
|
||||
|
||||
// Ordina per score ASC (gap peggiori prima)
|
||||
usort($domains, fn($a, $b) => ($a['score'] ?? 101) <=> ($b['score'] ?? 101));
|
||||
|
||||
$aiRecs = [];
|
||||
if (!empty($assessment['ai_recommendations'])) {
|
||||
$decoded = json_decode($assessment['ai_recommendations'], true);
|
||||
if (is_array($decoded)) $aiRecs = $decoded;
|
||||
}
|
||||
|
||||
$this->jsonSuccess([
|
||||
'assessment_id' => (int) $assessment['id'],
|
||||
'completed_at' => $assessment['completed_at'],
|
||||
'overall_score' => $assessment['overall_score'] !== null ? (int) $assessment['overall_score'] : null,
|
||||
'domains' => $domains,
|
||||
'ai_summary' => $assessment['ai_summary'],
|
||||
'ai_recommendations' => $aiRecs,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/services/measures
|
||||
* Misure di sicurezza Art.21 con stato implementazione e mapping MOG 231.
|
||||
* ?framework=nis2|iso27001|both (default: tutti)
|
||||
* ?status=not_started|in_progress|implemented|verified
|
||||
*/
|
||||
public function measures(): void
|
||||
{
|
||||
$this->requireApiKey('read:compliance');
|
||||
$orgId = $this->currentOrgId;
|
||||
|
||||
// Mapping control_code prefix → mog_area
|
||||
$mogAreaMap = [
|
||||
'NIS2-20' => 'governance',
|
||||
'NIS2-21' => 'governance',
|
||||
'NIS2-21.2.a' => 'risk_assessment',
|
||||
'NIS2-21.2.b' => 'incident_management',
|
||||
'NIS2-21.2.c' => 'business_continuity',
|
||||
'NIS2-21.2.d' => 'supply_chain',
|
||||
'NIS2-21.2.e' => 'vulnerability_management',
|
||||
'NIS2-21.2.f' => 'policy_procedure',
|
||||
'NIS2-21.2.g' => 'training_awareness',
|
||||
'NIS2-21.2.h' => 'cryptography',
|
||||
'NIS2-21.2.i' => 'access_control',
|
||||
'NIS2-21.2.j' => 'access_control',
|
||||
'ISO' => 'iso27001_control',
|
||||
];
|
||||
|
||||
$where = 'organization_id = ?';
|
||||
$params = [$orgId];
|
||||
|
||||
$filterFramework = $this->getParam('framework');
|
||||
if ($filterFramework && in_array($filterFramework, ['nis2', 'iso27001', 'both'])) {
|
||||
$where .= ' AND framework = ?';
|
||||
$params[] = $filterFramework;
|
||||
}
|
||||
|
||||
$filterStatus = $this->getParam('status');
|
||||
if ($filterStatus) {
|
||||
$where .= ' AND status = ?';
|
||||
$params[] = $filterStatus;
|
||||
}
|
||||
|
||||
$controls = Database::fetchAll(
|
||||
"SELECT id, control_code, framework, title, status,
|
||||
implementation_percentage, next_review_date
|
||||
FROM compliance_controls
|
||||
WHERE {$where}
|
||||
ORDER BY framework, control_code",
|
||||
$params
|
||||
);
|
||||
|
||||
$stats = ['total' => 0, 'not_started' => 0, 'in_progress' => 0, 'implemented' => 0, 'verified' => 0];
|
||||
$measures = [];
|
||||
foreach ($controls as $c) {
|
||||
$stats['total']++;
|
||||
$s = $c['status'] ?? 'not_started';
|
||||
if (isset($stats[$s])) $stats[$s]++;
|
||||
|
||||
$code = $c['control_code'] ?? '';
|
||||
// Derive mog_area from control_code
|
||||
$mogArea = 'other';
|
||||
foreach ($mogAreaMap as $prefix => $area) {
|
||||
if (str_starts_with($code, $prefix)) { $mogArea = $area; break; }
|
||||
}
|
||||
if ($mogArea === 'other' && str_starts_with($code, 'ISO')) $mogArea = 'iso27001_control';
|
||||
|
||||
// Derive nis2_article from code (e.g. "NIS2-21.2.a" → "Art.21.2.a")
|
||||
$nis2Article = null;
|
||||
if (preg_match('/^NIS2-(\S+)/', $code, $m)) {
|
||||
$nis2Article = 'Art.' . $m[1];
|
||||
}
|
||||
|
||||
$measures[] = [
|
||||
'id' => (int) $c['id'],
|
||||
'code' => $code,
|
||||
'framework' => $c['framework'],
|
||||
'title' => $c['title'],
|
||||
'status' => $s,
|
||||
'implementation_percentage' => (int) ($c['implementation_percentage'] ?? 0),
|
||||
'nis2_article' => $nis2Article,
|
||||
'mog_area' => $mogArea,
|
||||
'next_review_date' => $c['next_review_date'],
|
||||
];
|
||||
}
|
||||
|
||||
$completionPct = $stats['total'] > 0
|
||||
? (int) round(($stats['implemented'] + $stats['verified'] + $stats['in_progress'] * 0.5) / $stats['total'] * 100)
|
||||
: 0;
|
||||
|
||||
$this->jsonSuccess(array_merge($stats, [
|
||||
'completion_percentage' => $completionPct,
|
||||
'measures' => $measures,
|
||||
]));
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/services/incidents
|
||||
* Incidenti con stato Art.23 CSIRT (diverso da incidentsFeed: focus compliance).
|
||||
* ?significant=1 solo incidenti significativi
|
||||
* ?status=open solo aperti (non closed/post_mortem)
|
||||
* ?limit=50
|
||||
*/
|
||||
public function incidents(): void
|
||||
{
|
||||
$this->requireApiKey('read:incidents');
|
||||
$orgId = $this->currentOrgId;
|
||||
|
||||
$limit = min((int) ($this->getParam('limit') ?: 50), 200);
|
||||
$where = 'organization_id = ?';
|
||||
$params = [$orgId];
|
||||
|
||||
if ($this->getParam('significant') === '1') {
|
||||
$where .= ' AND is_significant = 1';
|
||||
}
|
||||
if ($this->getParam('status') === 'open') {
|
||||
$where .= ' AND status NOT IN ("closed","post_mortem")';
|
||||
}
|
||||
|
||||
$rows = Database::fetchAll(
|
||||
"SELECT id, title, classification, severity, status, is_significant,
|
||||
detected_at, closed_at,
|
||||
early_warning_due, early_warning_sent_at,
|
||||
notification_due, notification_sent_at,
|
||||
final_report_due, final_report_sent_at,
|
||||
affected_services, root_cause
|
||||
FROM incidents
|
||||
WHERE {$where}
|
||||
ORDER BY detected_at DESC
|
||||
LIMIT {$limit}",
|
||||
$params
|
||||
);
|
||||
|
||||
$now = time();
|
||||
$csirtOverdue = 0;
|
||||
$incidents = [];
|
||||
|
||||
foreach ($rows as $r) {
|
||||
$isOpen = !in_array($r['status'], ['closed', 'post_mortem']);
|
||||
$isSig = (bool) $r['is_significant'];
|
||||
|
||||
// Art.23 compliance per incidente significativo aperto
|
||||
$ewDue = $r['early_warning_due'] ? strtotime($r['early_warning_due']) : null;
|
||||
$notDue = $r['notification_due'] ? strtotime($r['notification_due']) : null;
|
||||
$frDue = $r['final_report_due'] ? strtotime($r['final_report_due']) : null;
|
||||
|
||||
$notOverdue = $isSig && $isOpen && $notDue && $now > $notDue && !$r['notification_sent_at'];
|
||||
if ($notOverdue) $csirtOverdue++;
|
||||
|
||||
$incidents[] = [
|
||||
'id' => (int) $r['id'],
|
||||
'title' => $r['title'],
|
||||
'classification' => $r['classification'],
|
||||
'severity' => $r['severity'],
|
||||
'status' => $r['status'],
|
||||
'is_significant' => $isSig,
|
||||
'detected_at' => $r['detected_at'],
|
||||
'closed_at' => $r['closed_at'],
|
||||
'art23' => [
|
||||
'early_warning_due' => $r['early_warning_due'],
|
||||
'early_warning_sent' => !empty($r['early_warning_sent_at']),
|
||||
'early_warning_overdue'=> $isSig && $isOpen && $ewDue && $now > $ewDue && !$r['early_warning_sent_at'],
|
||||
'notification_due' => $r['notification_due'],
|
||||
'notification_sent' => !empty($r['notification_sent_at']),
|
||||
'notification_overdue' => $notOverdue,
|
||||
'final_report_due' => $r['final_report_due'],
|
||||
'final_report_sent' => !empty($r['final_report_sent_at']),
|
||||
'final_report_overdue' => $isSig && $isOpen && $frDue && $now > $frDue && !$r['final_report_sent_at'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
// Statistiche totali (non solo quelle nella pagina)
|
||||
$stats = Database::fetchOne(
|
||||
'SELECT COUNT(*) as total,
|
||||
SUM(CASE WHEN status NOT IN ("closed","post_mortem") THEN 1 ELSE 0 END) as open_count,
|
||||
SUM(CASE WHEN is_significant = 1 THEN 1 ELSE 0 END) as significant
|
||||
FROM incidents WHERE organization_id = ?',
|
||||
[$orgId]
|
||||
);
|
||||
|
||||
$this->jsonSuccess([
|
||||
'total' => (int) ($stats['total'] ?? 0),
|
||||
'open' => (int) ($stats['open_count'] ?? 0),
|
||||
'significant' => (int) ($stats['significant'] ?? 0),
|
||||
'csirt_overdue' => $csirtOverdue,
|
||||
'fetched' => count($incidents),
|
||||
'incidents' => $incidents,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/services/training
|
||||
* Formazione NIS2 Art.20 — corsi, completamento, compliance board.
|
||||
* Utile per lg231 per evidenza OdV Pillar 4 (Formazione).
|
||||
*/
|
||||
public function training(): void
|
||||
{
|
||||
$this->requireApiKey('read:all');
|
||||
$orgId = $this->currentOrgId;
|
||||
|
||||
// Corsi dell'org + corsi globali (org_id IS NULL)
|
||||
$courses = Database::fetchAll(
|
||||
'SELECT id, title, target_role, nis2_article, is_mandatory, duration_minutes
|
||||
FROM training_courses
|
||||
WHERE (organization_id = ? OR organization_id IS NULL) AND is_active = 1
|
||||
ORDER BY is_mandatory DESC, target_role, title',
|
||||
[$orgId]
|
||||
);
|
||||
|
||||
if (empty($courses)) {
|
||||
$this->jsonSuccess([
|
||||
'courses_total' => 0, 'mandatory_total' => 0,
|
||||
'assignments_total' => 0, 'assignments_completed' => 0,
|
||||
'overall_completion_rate' => 0, 'board_completion_rate' => 0,
|
||||
'art20_compliance' => false, 'courses' => [],
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
$courseIds = array_column($courses, 'id');
|
||||
$placeholders = implode(',', array_fill(0, count($courseIds), '?'));
|
||||
|
||||
// Aggregazione assignments per corso
|
||||
$assignRows = Database::fetchAll(
|
||||
"SELECT course_id,
|
||||
COUNT(*) as total,
|
||||
SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed
|
||||
FROM training_assignments
|
||||
WHERE organization_id = ? AND course_id IN ({$placeholders})
|
||||
GROUP BY course_id",
|
||||
array_merge([$orgId], $courseIds)
|
||||
);
|
||||
$assignMap = [];
|
||||
foreach ($assignRows as $row) {
|
||||
$assignMap[(int)$row['course_id']] = ['total' => (int)$row['total'], 'completed' => (int)$row['completed']];
|
||||
}
|
||||
|
||||
// Completion board members: corsi mandatory × utenti board
|
||||
$boardAssign = Database::fetchOne(
|
||||
"SELECT COUNT(*) as total,
|
||||
SUM(CASE WHEN ta.status = 'completed' THEN 1 ELSE 0 END) as completed
|
||||
FROM training_assignments ta
|
||||
JOIN users u ON u.id = ta.user_id
|
||||
JOIN user_organizations uo ON uo.user_id = u.id AND uo.organization_id = ta.organization_id
|
||||
WHERE ta.organization_id = ? AND uo.role = 'board_member'",
|
||||
[$orgId]
|
||||
);
|
||||
|
||||
$totalAssign = 0;
|
||||
$totalCompleted = 0;
|
||||
$mandatoryBoardTotal = 0;
|
||||
$mandatoryBoardCompleted = 0;
|
||||
$courseList = [];
|
||||
|
||||
foreach ($courses as $c) {
|
||||
$cid = (int) $c['id'];
|
||||
$a = $assignMap[$cid] ?? ['total' => 0, 'completed' => 0];
|
||||
$rate = $a['total'] > 0 ? (int) round($a['completed'] / $a['total'] * 100) : 0;
|
||||
|
||||
$totalAssign += $a['total'];
|
||||
$totalCompleted += $a['completed'];
|
||||
|
||||
if ($c['is_mandatory'] && $c['target_role'] === 'board_member') {
|
||||
$mandatoryBoardTotal += $a['total'];
|
||||
$mandatoryBoardCompleted += $a['completed'];
|
||||
}
|
||||
|
||||
$courseList[] = [
|
||||
'id' => $cid,
|
||||
'title' => $c['title'],
|
||||
'target_role' => $c['target_role'],
|
||||
'nis2_article' => $c['nis2_article'],
|
||||
'is_mandatory' => (bool) $c['is_mandatory'],
|
||||
'duration_minutes' => (int) ($c['duration_minutes'] ?? 0),
|
||||
'assigned' => $a['total'],
|
||||
'completed' => $a['completed'],
|
||||
'completion_rate' => $rate,
|
||||
];
|
||||
}
|
||||
|
||||
$overallRate = $totalAssign > 0 ? (int) round($totalCompleted / $totalAssign * 100) : 0;
|
||||
$boardRate = ((int)($boardAssign['total'] ?? 0)) > 0
|
||||
? (int) round((int)($boardAssign['completed'] ?? 0) / (int)$boardAssign['total'] * 100)
|
||||
: 0;
|
||||
$art20ok = $mandatoryBoardTotal > 0 && $mandatoryBoardCompleted >= $mandatoryBoardTotal;
|
||||
|
||||
$this->jsonSuccess([
|
||||
'courses_total' => count($courses),
|
||||
'mandatory_total' => count(array_filter($courses, fn($c) => $c['is_mandatory'])),
|
||||
'assignments_total' => $totalAssign,
|
||||
'assignments_completed' => $totalCompleted,
|
||||
'overall_completion_rate' => $overallRate,
|
||||
'board_completion_rate' => $boardRate,
|
||||
'art20_compliance' => $art20ok,
|
||||
'courses' => $courseList,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/services/deadlines
|
||||
* Scadenze aggregate da 4 sorgenti: incidenti CSIRT, revisioni controlli,
|
||||
* assessment fornitori, training assignments.
|
||||
* ?days=30 finestra (default 30, max 365)
|
||||
*/
|
||||
public function deadlines(): void
|
||||
{
|
||||
$this->requireApiKey('read:all');
|
||||
$orgId = $this->currentOrgId;
|
||||
|
||||
$days = min((int) ($this->getParam('days') ?: 30), 365);
|
||||
$now = time();
|
||||
$horizon = $now + ($days * 86400);
|
||||
$deadlines = [];
|
||||
|
||||
// ── 1. Notifiche CSIRT incidenti significativi aperti ──────────────
|
||||
$openInc = Database::fetchAll(
|
||||
'SELECT id, title, severity,
|
||||
early_warning_due, early_warning_sent_at,
|
||||
notification_due, notification_sent_at,
|
||||
final_report_due, final_report_sent_at
|
||||
FROM incidents
|
||||
WHERE organization_id = ? AND is_significant = 1
|
||||
AND status NOT IN ("closed","post_mortem")',
|
||||
[$orgId]
|
||||
);
|
||||
|
||||
foreach ($openInc as $inc) {
|
||||
$steps = [
|
||||
['subtype' => 'early_warning_24h', 'due' => $inc['early_warning_due'], 'sent' => $inc['early_warning_sent_at']],
|
||||
['subtype' => 'notification_72h', 'due' => $inc['notification_due'], 'sent' => $inc['notification_sent_at']],
|
||||
['subtype' => 'final_report_30d', 'due' => $inc['final_report_due'], 'sent' => $inc['final_report_sent_at']],
|
||||
];
|
||||
foreach ($steps as $step) {
|
||||
if (!$step['due'] || $step['sent']) continue;
|
||||
$dueTs = strtotime($step['due']);
|
||||
if ($dueTs > $horizon) continue;
|
||||
$hoursLeft = (int) round(($dueTs - $now) / 3600);
|
||||
$deadlines[] = [
|
||||
'type' => 'incident_notification',
|
||||
'subtype' => $step['subtype'],
|
||||
'title' => $step['subtype'] . ': ' . $inc['title'],
|
||||
'due_date' => $step['due'],
|
||||
'overdue' => $dueTs < $now,
|
||||
'hours_remaining' => $hoursLeft,
|
||||
'priority' => 'critical',
|
||||
'reference_id' => (int) $inc['id'],
|
||||
'nis2_article' => 'Art.23',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// ── 2. Revisioni controlli compliance ─────────────────────────────
|
||||
$controls = Database::fetchAll(
|
||||
'SELECT id, control_code, title, next_review_date, status
|
||||
FROM compliance_controls
|
||||
WHERE organization_id = ? AND next_review_date IS NOT NULL
|
||||
AND status != "verified"',
|
||||
[$orgId]
|
||||
);
|
||||
foreach ($controls as $c) {
|
||||
$dueTs = strtotime($c['next_review_date']);
|
||||
if ($dueTs > $horizon) continue;
|
||||
$hoursLeft = (int) round(($dueTs - $now) / 3600);
|
||||
$deadlines[] = [
|
||||
'type' => 'control_review',
|
||||
'subtype' => $c['status'],
|
||||
'title' => 'Revisione: ' . $c['title'],
|
||||
'due_date' => $c['next_review_date'],
|
||||
'overdue' => $dueTs < $now,
|
||||
'hours_remaining' => $hoursLeft,
|
||||
'priority' => 'medium',
|
||||
'reference_id' => (int) $c['id'],
|
||||
'nis2_article' => 'Art.21',
|
||||
];
|
||||
}
|
||||
|
||||
// ── 3. Assessment fornitori ────────────────────────────────────────
|
||||
$suppliers = Database::fetchAll(
|
||||
'SELECT id, name, criticality, next_assessment_date
|
||||
FROM suppliers
|
||||
WHERE organization_id = ? AND deleted_at IS NULL
|
||||
AND next_assessment_date IS NOT NULL',
|
||||
[$orgId]
|
||||
);
|
||||
foreach ($suppliers as $s) {
|
||||
$dueTs = strtotime($s['next_assessment_date']);
|
||||
if ($dueTs > $horizon) continue;
|
||||
$hoursLeft = (int) round(($dueTs - $now) / 3600);
|
||||
$priority = in_array($s['criticality'], ['critical', 'high']) ? 'high' : 'medium';
|
||||
$deadlines[] = [
|
||||
'type' => 'supplier_assessment',
|
||||
'subtype' => $s['criticality'],
|
||||
'title' => 'Valutazione fornitore: ' . $s['name'],
|
||||
'due_date' => $s['next_assessment_date'],
|
||||
'overdue' => $dueTs < $now,
|
||||
'hours_remaining' => $hoursLeft,
|
||||
'priority' => $priority,
|
||||
'reference_id' => (int) $s['id'],
|
||||
'nis2_article' => 'Art.21.2.d',
|
||||
];
|
||||
}
|
||||
|
||||
// ── 4. Training assignments scaduti/in scadenza ────────────────────
|
||||
$trainings = Database::fetchAll(
|
||||
'SELECT ta.id, tc.title, ta.due_date, ta.status
|
||||
FROM training_assignments ta
|
||||
JOIN training_courses tc ON tc.id = ta.course_id
|
||||
WHERE ta.organization_id = ? AND ta.status NOT IN ("completed")
|
||||
AND ta.due_date IS NOT NULL',
|
||||
[$orgId]
|
||||
);
|
||||
foreach ($trainings as $t) {
|
||||
$dueTs = strtotime($t['due_date']);
|
||||
if ($dueTs > $horizon) continue;
|
||||
$hoursLeft = (int) round(($dueTs - $now) / 3600);
|
||||
$deadlines[] = [
|
||||
'type' => 'training_assignment',
|
||||
'subtype' => $t['status'],
|
||||
'title' => 'Formazione: ' . $t['title'],
|
||||
'due_date' => $t['due_date'],
|
||||
'overdue' => $dueTs < $now,
|
||||
'hours_remaining' => $hoursLeft,
|
||||
'priority' => 'low',
|
||||
'reference_id' => (int) $t['id'],
|
||||
'nis2_article' => 'Art.20',
|
||||
];
|
||||
}
|
||||
|
||||
// Ordina: overdue + urgenti prima
|
||||
usort($deadlines, function ($a, $b) {
|
||||
if ($a['overdue'] !== $b['overdue']) return $b['overdue'] <=> $a['overdue'];
|
||||
return strtotime($a['due_date']) <=> strtotime($b['due_date']);
|
||||
});
|
||||
|
||||
$overdue = count(array_filter($deadlines, fn($d) => $d['overdue']));
|
||||
$due7days = count(array_filter($deadlines, fn($d) => !$d['overdue'] && strtotime($d['due_date']) <= $now + 7 * 86400));
|
||||
$due30days = count(array_filter($deadlines, fn($d) => !$d['overdue']));
|
||||
|
||||
$this->jsonSuccess([
|
||||
'days_horizon' => $days,
|
||||
'overdue' => $overdue,
|
||||
'due_7_days' => $due7days,
|
||||
'due_30_days' => $due30days,
|
||||
'total' => count($deadlines),
|
||||
'deadlines' => $deadlines,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,14 +2,21 @@
|
||||
/**
|
||||
* NIS2 Agile - Visura Service
|
||||
*
|
||||
* Estrae dati aziendali da visura camerale PDF tramite AI
|
||||
* e recupera dati da CertiSource.
|
||||
* Estrae dati aziendali da visura camerale PDF tramite AI (upload)
|
||||
* oppure tramite CertiSource atti-service.php (ricerca per P.IVA).
|
||||
*
|
||||
* CertiSource API: https://certisource.it/atti-service.php
|
||||
* Auth: Authorization: Bearer CERTISOURCE_API_KEY (cs_pat_...)
|
||||
*/
|
||||
|
||||
class VisuraService
|
||||
{
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// METODI PUBBLICI
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Extract company data from a PDF visura camerale using Claude AI
|
||||
* Estrai dati aziendali da un PDF visura caricato dall'utente (via Claude AI).
|
||||
*/
|
||||
public function extractFromPdf(string $filePath): array
|
||||
{
|
||||
@ -21,18 +28,15 @@ class VisuraService
|
||||
throw new RuntimeException('Chiave API Anthropic non configurata');
|
||||
}
|
||||
|
||||
// Read PDF and base64 encode it
|
||||
$pdfContent = file_get_contents($filePath);
|
||||
$base64Pdf = base64_encode($pdfContent);
|
||||
$base64Pdf = base64_encode(file_get_contents($filePath));
|
||||
|
||||
// Call Claude API with the PDF
|
||||
$response = $this->callClaudeApi([
|
||||
[
|
||||
'type' => 'document',
|
||||
'type' => 'document',
|
||||
'source' => [
|
||||
'type' => 'base64',
|
||||
'type' => 'base64',
|
||||
'media_type' => 'application/pdf',
|
||||
'data' => $base64Pdf,
|
||||
'data' => $base64Pdf,
|
||||
],
|
||||
],
|
||||
[
|
||||
@ -42,170 +46,352 @@ class VisuraService
|
||||
]);
|
||||
|
||||
if (!$response) {
|
||||
throw new RuntimeException('Nessuna risposta dall\'AI');
|
||||
throw new RuntimeException("Nessuna risposta dall'AI");
|
||||
}
|
||||
|
||||
// Parse JSON response
|
||||
$jsonStr = trim($response);
|
||||
// Remove potential markdown code blocks
|
||||
$jsonStr = preg_replace('/^```(?:json)?\s*/i', '', $jsonStr);
|
||||
$jsonStr = preg_replace('/^```(?:json)?\s*/i', '', trim($response));
|
||||
$jsonStr = preg_replace('/\s*```$/', '', $jsonStr);
|
||||
|
||||
$data = json_decode($jsonStr, true);
|
||||
$data = json_decode($jsonStr, true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
error_log('[VISURA_PARSE_ERROR] Could not parse AI response: ' . $jsonStr);
|
||||
error_log('[VISURA_PARSE_ERROR] ' . $jsonStr);
|
||||
throw new RuntimeException('Impossibile interpretare i dati estratti dalla visura');
|
||||
}
|
||||
|
||||
// Map to suggested NIS2 sector based on ATECO code
|
||||
$data['suggested_sector'] = $this->mapAtecoToNis2Sector($data['ateco_code'] ?? '', $data['ateco_description'] ?? '');
|
||||
$data['suggested_sector'] = $this->mapAtecoToNis2Sector(
|
||||
$data['ateco_code'] ?? '',
|
||||
$data['ateco_description'] ?? ''
|
||||
);
|
||||
$data['source'] = 'pdf_upload';
|
||||
|
||||
// Log AI interaction
|
||||
$this->logAiInteraction('visura_extraction', 'Estrazione dati da visura camerale PDF');
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch company data from CertiSource API
|
||||
* Recupera dati aziendali da CertiSource atti-service.php tramite P.IVA.
|
||||
*
|
||||
* Flusso:
|
||||
* POST ?action=richiesta → ottieni request ID
|
||||
* GET ?action=stato → polling fino a completed
|
||||
* GET ?action=dati → structured_data JSON
|
||||
*/
|
||||
public function fetchFromCertiSource(string $vatNumber): array
|
||||
{
|
||||
// CertiSource is on the same server - call its API internally
|
||||
$certisourceUrl = $this->getCertiSourceBaseUrl() . '/api/company/enrich';
|
||||
$apiKey = defined('CERTISOURCE_API_KEY') ? CERTISOURCE_API_KEY : '';
|
||||
if ($apiKey === '') {
|
||||
error_log('[CERTISOURCE] API key non configurata (CERTISOURCE_API_KEY)');
|
||||
throw new RuntimeException('Servizio visure non disponibile. Inserire i dati manualmente.');
|
||||
}
|
||||
|
||||
$ch = curl_init();
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_URL => $certisourceUrl . '?vat=' . urlencode($vatNumber),
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Content-Type: application/json',
|
||||
'Accept: application/json',
|
||||
'X-Internal-Service: nis2-agile',
|
||||
],
|
||||
// Same server, skip SSL verification for internal calls
|
||||
CURLOPT_SSL_VERIFYPEER => false,
|
||||
// ── Step 1: richiesta visura ─────────────────────────────────────────
|
||||
$richiesta = $this->callCertiSource('POST', 'richiesta', [
|
||||
'vat' => $vatNumber,
|
||||
'type' => 'visura_camerale',
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
if (empty($richiesta['success'])) {
|
||||
$msg = $richiesta['error'] ?? $richiesta['message'] ?? 'Errore richiesta visura';
|
||||
throw new RuntimeException('CertiSource: ' . $msg);
|
||||
}
|
||||
|
||||
$requestId = (int) $richiesta['id'];
|
||||
$status = $richiesta['status'] ?? 'processing';
|
||||
|
||||
// ── Step 2: polling se non già completato (cache hit) ────────────────
|
||||
if ($status !== 'completed') {
|
||||
$pollMax = defined('CERTISOURCE_POLL_MAX') ? (int) CERTISOURCE_POLL_MAX : 30;
|
||||
$pollSec = defined('CERTISOURCE_POLL_SEC') ? (int) CERTISOURCE_POLL_SEC : 3;
|
||||
|
||||
for ($i = 0; $i < $pollMax; $i++) {
|
||||
sleep($pollSec);
|
||||
$stato = $this->callCertiSource('GET', 'stato', null, ['id' => $requestId]);
|
||||
|
||||
if (!empty($stato['completed'])) {
|
||||
$status = 'completed';
|
||||
break;
|
||||
}
|
||||
if (($stato['status'] ?? '') === 'failed') {
|
||||
throw new RuntimeException('CertiSource: elaborazione visura fallita');
|
||||
}
|
||||
}
|
||||
|
||||
if ($status !== 'completed') {
|
||||
throw new RuntimeException('Timeout: visura camerale non disponibile. Riprova tra qualche minuto.');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Step 3: dati strutturati ─────────────────────────────────────────
|
||||
$datiRes = $this->callCertiSource('GET', 'dati', null, ['id' => $requestId]);
|
||||
|
||||
if (empty($datiRes['success']) || empty($datiRes['structured_data'])) {
|
||||
throw new RuntimeException('CertiSource: dati strutturati non disponibili');
|
||||
}
|
||||
|
||||
return $this->mapStructuredData($datiRes['structured_data'], $vatNumber, $requestId);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// METODI PRIVATI — CertiSource
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Chiama l'API CertiSource atti-service.php.
|
||||
*
|
||||
* @param string $method GET|POST
|
||||
* @param string $action action=richiesta|stato|dati|...
|
||||
* @param array|null $body JSON body per POST
|
||||
* @param array $params Query params extra (es. ['id'=>42])
|
||||
*/
|
||||
private function callCertiSource(string $method, string $action, ?array $body = null, array $params = []): array
|
||||
{
|
||||
$apiKey = defined('CERTISOURCE_API_KEY') ? CERTISOURCE_API_KEY : '';
|
||||
$baseUrl = defined('CERTISOURCE_API_URL') ? CERTISOURCE_API_URL : 'https://certisource.it/atti-service.php';
|
||||
|
||||
$qs = http_build_query(array_merge(['action' => $action], $params));
|
||||
$url = $baseUrl . '?' . $qs;
|
||||
|
||||
$ch = curl_init($url);
|
||||
$headers = [
|
||||
'Authorization: Bearer ' . $apiKey,
|
||||
'Content-Type: application/json',
|
||||
'Accept: application/json',
|
||||
];
|
||||
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 15,
|
||||
CURLOPT_HTTPHEADER => $headers,
|
||||
]);
|
||||
|
||||
if ($method === 'POST' && $body !== null) {
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
|
||||
}
|
||||
|
||||
$raw = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$error = curl_error($ch);
|
||||
curl_close($ch);
|
||||
$curlErr = curl_error($ch);
|
||||
unset($ch);
|
||||
|
||||
if ($error) {
|
||||
error_log("[CERTISOURCE_CURL_ERROR] $error");
|
||||
throw new RuntimeException('Impossibile contattare CertiSource: ' . $error);
|
||||
if ($curlErr) {
|
||||
throw new RuntimeException('CertiSource connessione: ' . $curlErr);
|
||||
}
|
||||
|
||||
if ($httpCode !== 200) {
|
||||
error_log("[CERTISOURCE_HTTP_ERROR] HTTP $httpCode: $response");
|
||||
throw new RuntimeException('CertiSource ha restituito un errore (HTTP ' . $httpCode . ')');
|
||||
if ($httpCode === 402) {
|
||||
throw new RuntimeException('CertiSource: credito insufficiente');
|
||||
}
|
||||
|
||||
$result = json_decode($response, true);
|
||||
if (!$result) {
|
||||
throw new RuntimeException('Risposta CertiSource non valida');
|
||||
if ($httpCode >= 500) {
|
||||
throw new RuntimeException('CertiSource: errore server (HTTP ' . $httpCode . ')');
|
||||
}
|
||||
|
||||
// Map CertiSource response to our format
|
||||
// CertiSource typically returns data in its own format, normalize it
|
||||
$companyData = $result['data'] ?? $result;
|
||||
$result = json_decode($raw ?: '{}', true);
|
||||
if (!is_array($result)) {
|
||||
throw new RuntimeException('CertiSource: risposta non valida');
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mappa structured_data CertiSource nel formato interno NIS2 Agile.
|
||||
*/
|
||||
private function mapStructuredData(array $sd, string $vatNumber, int $requestId): array
|
||||
{
|
||||
$sedeLegale = $this->extractSedeLegale($sd['sedi'] ?? []);
|
||||
$ateco = $this->extractPrimaryAteco($sd['ateco_codes'] ?? []);
|
||||
$atecoCode = $ateco['code'] ?? null;
|
||||
$atecoDesc = $ateco['description'] ?? '';
|
||||
|
||||
return [
|
||||
'company_name' => $companyData['ragione_sociale'] ?? $companyData['denominazione'] ?? $companyData['company_name'] ?? null,
|
||||
'vat_number' => $companyData['partita_iva'] ?? $companyData['vat_number'] ?? $vatNumber,
|
||||
'fiscal_code' => $companyData['codice_fiscale'] ?? $companyData['fiscal_code'] ?? null,
|
||||
'legal_form' => $companyData['forma_giuridica'] ?? $companyData['legal_form'] ?? null,
|
||||
'address' => $companyData['indirizzo'] ?? $companyData['address'] ?? null,
|
||||
'city' => $companyData['comune'] ?? $companyData['city'] ?? null,
|
||||
'province' => $companyData['provincia'] ?? $companyData['province'] ?? null,
|
||||
'zip_code' => $companyData['cap'] ?? $companyData['zip_code'] ?? null,
|
||||
'pec' => $companyData['pec'] ?? null,
|
||||
'phone' => $companyData['telefono'] ?? $companyData['phone'] ?? null,
|
||||
'ateco_code' => $companyData['codice_ateco'] ?? $companyData['ateco_code'] ?? null,
|
||||
'ateco_description' => $companyData['descrizione_ateco'] ?? $companyData['ateco_description'] ?? null,
|
||||
'suggested_sector' => $this->mapAtecoToNis2Sector(
|
||||
$companyData['codice_ateco'] ?? '',
|
||||
$companyData['descrizione_ateco'] ?? ''
|
||||
),
|
||||
'source' => 'certisource',
|
||||
'company_name' => $sd['ragione_sociale'] ?? null,
|
||||
'vat_number' => $sd['partita_iva'] ?? $vatNumber,
|
||||
'fiscal_code' => $sd['codice_fiscale'] ?? null,
|
||||
'legal_form' => $sd['forma_giuridica'] ?? null,
|
||||
'address' => $sedeLegale['via'] ?? null,
|
||||
'city' => $sedeLegale['comune'] ?? null,
|
||||
'province' => $sedeLegale['provincia'] ?? null,
|
||||
'zip_code' => $sedeLegale['cap'] ?? null,
|
||||
'pec' => $sd['pec'] ?? $sedeLegale['pec'] ?? null,
|
||||
'phone' => null,
|
||||
'ateco_code' => $atecoCode,
|
||||
'ateco_description' => $atecoDesc ?: null,
|
||||
'incorporation_date' => null,
|
||||
'share_capital' => isset($sd['capitale_sociale']) ? (float) $sd['capitale_sociale'] : null,
|
||||
'employees_range' => $this->computeEmployeesRange($sd['addetti'] ?? null),
|
||||
'legal_representative' => $this->findLegalRepresentative($sd['cariche'] ?? []),
|
||||
'suggested_sector' => $this->mapAtecoToNis2Sector($atecoCode ?? '', $atecoDesc),
|
||||
'source' => 'certisource_atti',
|
||||
'certisource_id' => $requestId,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Map ATECO code to NIS2 sector
|
||||
* Trova la sede legale nell'array sedi.
|
||||
*/
|
||||
private function extractSedeLegale(array $sedi): array
|
||||
{
|
||||
foreach ($sedi as $sede) {
|
||||
if (($sede['tipo'] ?? '') === 'sede_legale') {
|
||||
return $sede;
|
||||
}
|
||||
}
|
||||
return $sedi[0] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Trova il codice ATECO primario.
|
||||
*/
|
||||
private function extractPrimaryAteco(array $atecos): array
|
||||
{
|
||||
foreach ($atecos as $a) {
|
||||
if (($a['type'] ?? '') === 'primary') {
|
||||
return $a;
|
||||
}
|
||||
}
|
||||
return $atecos[0] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcola il range dipendenti dai dati INPS (ultimo trimestre disponibile).
|
||||
*/
|
||||
private function computeEmployeesRange(?array $addetti): ?string
|
||||
{
|
||||
if (empty($addetti['trimestri'])) {
|
||||
return null;
|
||||
}
|
||||
$last = end($addetti['trimestri']);
|
||||
$n = (int) ($last['dipendenti'] ?? 0);
|
||||
if ($n === 0) return null;
|
||||
if ($n < 10) return '1-9';
|
||||
if ($n < 50) return '10-49';
|
||||
if ($n < 250) return '50-249';
|
||||
return '250+';
|
||||
}
|
||||
|
||||
/**
|
||||
* Trova il legale rappresentante tra le cariche.
|
||||
*/
|
||||
private function findLegalRepresentative(array $cariche): ?string
|
||||
{
|
||||
foreach ($cariche as $c) {
|
||||
if (!empty($c['rappresentante_legale']) || str_contains(strtolower($c['ruolo'] ?? ''), 'legale')) {
|
||||
return $c['nome'] ?? null;
|
||||
}
|
||||
}
|
||||
// Fallback: primo amministratore
|
||||
foreach ($cariche as $c) {
|
||||
if (str_contains(strtolower($c['ruolo'] ?? ''), 'ammin')) {
|
||||
return $c['nome'] ?? null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// ATECO → SETTORE NIS2 (valori ENUM DB)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Mappa codice ATECO + descrizione al settore NIS2 (valori ENUM organizations.sector).
|
||||
*
|
||||
* Valori validi: energy, transport, banking, health, water, digital_infra,
|
||||
* public_admin, manufacturing, postal, chemical, food, waste,
|
||||
* ict_services, digital_providers, space, research, other
|
||||
*/
|
||||
private function mapAtecoToNis2Sector(string $atecoCode, string $atecoDesc): ?string
|
||||
{
|
||||
$code = substr($atecoCode, 0, 2); // Use first 2 digits
|
||||
$descLower = strtolower($atecoDesc);
|
||||
$code2 = substr(preg_replace('/[^0-9]/', '', $atecoCode), 0, 2);
|
||||
$desc = strtolower($atecoDesc);
|
||||
|
||||
// ATECO to NIS2 mapping (approximate)
|
||||
$mapping = [
|
||||
'35' => 'energy_electricity', // Electricity, gas, steam
|
||||
'49' => 'transport_road', // Land transport
|
||||
'50' => 'transport_water', // Water transport
|
||||
'51' => 'transport_air', // Air transport
|
||||
'64' => 'banking', // Financial services
|
||||
'65' => 'banking', // Insurance
|
||||
'66' => 'financial_markets', // Financial auxiliaries
|
||||
'86' => 'health', // Health
|
||||
'36' => 'drinking_water', // Water supply
|
||||
'37' => 'waste_water', // Sewerage
|
||||
'38' => 'waste_management', // Waste management
|
||||
'61' => 'digital_infrastructure', // Telecommunications
|
||||
'62' => 'ict_service_management', // IT services
|
||||
'63' => 'digital_providers', // Information services
|
||||
'84' => 'public_administration', // Public admin
|
||||
'53' => 'postal_courier', // Postal services
|
||||
'20' => 'chemicals', // Chemicals manufacturing
|
||||
'10' => 'food', // Food manufacturing
|
||||
'11' => 'food', // Beverages
|
||||
'21' => 'manufacturing_medical', // Pharma/medical
|
||||
'26' => 'manufacturing_computers', // Electronics
|
||||
'27' => 'manufacturing_electrical', // Electrical equipment
|
||||
'28' => 'manufacturing_machinery', // Machinery
|
||||
'29' => 'manufacturing_vehicles', // Motor vehicles
|
||||
'30' => 'manufacturing_transport', // Other transport
|
||||
'72' => 'research', // Scientific research
|
||||
$byCode = [
|
||||
'35' => 'energy', // Fornitura energia elettrica, gas
|
||||
'06' => 'energy', // Estrazione petrolio e gas
|
||||
'19' => 'energy', // Raffinazione petrolio
|
||||
'49' => 'transport', // Trasporto terrestre
|
||||
'50' => 'transport', // Trasporto marittimo
|
||||
'51' => 'transport', // Trasporto aereo
|
||||
'52' => 'transport', // Magazzinaggio e attività connesse
|
||||
'64' => 'banking', // Servizi finanziari
|
||||
'65' => 'banking', // Assicurazioni
|
||||
'66' => 'banking', // Attività ausiliarie servizi finanziari
|
||||
'86' => 'health', // Assistenza sanitaria
|
||||
'87' => 'health', // Strutture di assistenza
|
||||
'36' => 'water', // Raccolta e distribuzione acqua
|
||||
'37' => 'waste', // Raccolta e trattamento acque reflue
|
||||
'38' => 'waste', // Raccolta e smaltimento rifiuti
|
||||
'61' => 'digital_infra', // Telecomunicazioni
|
||||
'62' => 'ict_services', // Produzione software e IT
|
||||
'63' => 'digital_providers',// Servizi di informazione
|
||||
'84' => 'public_admin', // Pubblica amministrazione
|
||||
'53' => 'postal', // Servizi postali e corrieri
|
||||
'20' => 'chemical', // Fabbricazione prodotti chimici
|
||||
'10' => 'food', // Industria alimentare
|
||||
'11' => 'food', // Industria delle bevande
|
||||
'21' => 'manufacturing', // Fabbricazione farmaci
|
||||
'26' => 'manufacturing', // Fabbricazione computer e elettronica
|
||||
'27' => 'manufacturing', // Fabbricazione apparecchiature elettriche
|
||||
'28' => 'manufacturing', // Fabbricazione macchinari
|
||||
'29' => 'manufacturing', // Fabbricazione autoveicoli
|
||||
'30' => 'manufacturing', // Fabbricazione altri mezzi di trasporto
|
||||
'72' => 'research', // Ricerca e sviluppo
|
||||
'85' => 'research', // Istruzione
|
||||
];
|
||||
|
||||
if (isset($mapping[$code])) {
|
||||
return $mapping[$code];
|
||||
if (isset($byCode[$code2])) {
|
||||
return $byCode[$code2];
|
||||
}
|
||||
|
||||
// Try to match by description keywords
|
||||
$keywords = [
|
||||
'energia' => 'energy_electricity',
|
||||
'elettric' => 'energy_electricity',
|
||||
'gas' => 'energy_gas',
|
||||
'petroli' => 'energy_oil',
|
||||
'trasport' => 'transport_road',
|
||||
'ferrov' => 'transport_rail',
|
||||
'maritt' => 'transport_water',
|
||||
'aere' => 'transport_air',
|
||||
'banc' => 'banking',
|
||||
'finanz' => 'financial_markets',
|
||||
'sanit' => 'health',
|
||||
'osped' => 'health',
|
||||
'farm' => 'manufacturing_medical',
|
||||
'acqua' => 'drinking_water',
|
||||
'rifiut' => 'waste_management',
|
||||
'telecom' => 'digital_infrastructure',
|
||||
'informatica' => 'ict_service_management',
|
||||
'software' => 'ict_service_management',
|
||||
'digital' => 'digital_providers',
|
||||
'postale' => 'postal_courier',
|
||||
'corriere' => 'postal_courier',
|
||||
'chimic' => 'chemicals',
|
||||
'alimentar' => 'food',
|
||||
'ricerca' => 'research',
|
||||
// Fallback per keyword nella descrizione
|
||||
$byKeyword = [
|
||||
'energia' => 'energy',
|
||||
'elettric' => 'energy',
|
||||
'gas' => 'energy',
|
||||
'petroli' => 'energy',
|
||||
'idrogeno' => 'energy',
|
||||
'trasport' => 'transport',
|
||||
'ferrov' => 'transport',
|
||||
'maritt' => 'transport',
|
||||
'aereo' => 'transport',
|
||||
'aere' => 'transport',
|
||||
'logistic' => 'transport',
|
||||
'banc' => 'banking',
|
||||
'finanz' => 'banking',
|
||||
'assicur' => 'banking',
|
||||
'sanit' => 'health',
|
||||
'osped' => 'health',
|
||||
'clinic' => 'health',
|
||||
'medic' => 'health',
|
||||
'acqua' => 'water',
|
||||
'idric' => 'water',
|
||||
'rifiut' => 'waste',
|
||||
'smaltim' => 'waste',
|
||||
'telecom' => 'digital_infra',
|
||||
'teleco' => 'digital_infra',
|
||||
'fibra' => 'digital_infra',
|
||||
'internet' => 'digital_infra',
|
||||
'informatica' => 'ict_services',
|
||||
'software' => 'ict_services',
|
||||
'cloud' => 'ict_services',
|
||||
'digital' => 'digital_providers',
|
||||
'piattaform' => 'digital_providers',
|
||||
'postale' => 'postal',
|
||||
'corriere' => 'postal',
|
||||
'spediz' => 'postal',
|
||||
'chimic' => 'chemical',
|
||||
'farmac' => 'manufacturing',
|
||||
'alimentar' => 'food',
|
||||
'bevand' => 'food',
|
||||
'ricerca' => 'research',
|
||||
'spazial' => 'space',
|
||||
'aerospaz' => 'space',
|
||||
'manifattur' => 'manufacturing',
|
||||
];
|
||||
|
||||
foreach ($keywords as $kw => $sector) {
|
||||
if (str_contains($descLower, $kw)) {
|
||||
foreach ($byKeyword as $kw => $sector) {
|
||||
if (str_contains($desc, $kw)) {
|
||||
return $sector;
|
||||
}
|
||||
}
|
||||
@ -213,20 +399,16 @@ class VisuraService
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Call Claude API
|
||||
*/
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// METODI PRIVATI — Claude AI
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private function callClaudeApi(array $content): ?string
|
||||
{
|
||||
$payload = [
|
||||
'model' => ANTHROPIC_MODEL,
|
||||
'max_tokens' => ANTHROPIC_MAX_TOKENS,
|
||||
'messages' => [
|
||||
[
|
||||
'role' => 'user',
|
||||
'content' => $content,
|
||||
],
|
||||
],
|
||||
'messages' => [['role' => 'user', 'content' => $content]],
|
||||
];
|
||||
|
||||
$ch = curl_init('https://api.anthropic.com/v1/messages');
|
||||
@ -244,58 +426,36 @@ class VisuraService
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$error = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($error) {
|
||||
throw new RuntimeException('Claude API error: ' . $error);
|
||||
}
|
||||
$error = curl_error($ch);
|
||||
unset($ch);
|
||||
|
||||
if ($error) throw new RuntimeException('Claude API error: ' . $error);
|
||||
if ($httpCode !== 200) {
|
||||
error_log("[CLAUDE_API_ERROR] HTTP $httpCode: $response");
|
||||
error_log('[CLAUDE_API_ERROR] HTTP ' . $httpCode . ': ' . $response);
|
||||
throw new RuntimeException('Claude API returned HTTP ' . $httpCode);
|
||||
}
|
||||
|
||||
$result = json_decode($response, true);
|
||||
|
||||
return $result['content'][0]['text'] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get CertiSource API base URL (same server)
|
||||
*/
|
||||
private function getCertiSourceBaseUrl(): string
|
||||
{
|
||||
// Both apps are on the same server, use internal URL
|
||||
if (defined('CERTISOURCE_API_URL')) {
|
||||
return CERTISOURCE_API_URL;
|
||||
}
|
||||
// Default: same server via localhost
|
||||
return 'https://certisource.it/certisource';
|
||||
}
|
||||
|
||||
/**
|
||||
* Log AI interaction to database
|
||||
*/
|
||||
private function logAiInteraction(string $type, string $summary): void
|
||||
{
|
||||
try {
|
||||
// Get current user from JWT if available
|
||||
$userId = null;
|
||||
$token = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
|
||||
if (preg_match('/Bearer\s+(.+)$/i', $token, $matches)) {
|
||||
$parts = explode('.', $matches[1]);
|
||||
$token = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
|
||||
if (preg_match('/Bearer\s+(.+)$/i', $token, $m)) {
|
||||
$parts = explode('.', $m[1]);
|
||||
if (count($parts) === 3) {
|
||||
$payload = json_decode(base64_decode(strtr($parts[1], '-_', '+/')), true);
|
||||
$userId = $payload['sub'] ?? null;
|
||||
$pl = json_decode(base64_decode(strtr($parts[1], '-_', '+/')), true);
|
||||
$userId = $pl['sub'] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
if ($userId) {
|
||||
Database::insert('ai_interactions', [
|
||||
'organization_id' => 0, // Not yet created during onboarding
|
||||
'organization_id' => 0,
|
||||
'user_id' => $userId,
|
||||
'interaction_type' => 'qa',
|
||||
'interaction_type' => $type,
|
||||
'prompt_summary' => $summary,
|
||||
'response_summary' => 'Dati estratti',
|
||||
'tokens_used' => 0,
|
||||
@ -303,7 +463,6 @@ class VisuraService
|
||||
]);
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
// Silently fail - logging should not break the flow
|
||||
error_log('[AI_LOG_ERROR] ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@ -328,6 +328,12 @@ $actionMap = [
|
||||
'GET:suppliersRisk' => 'suppliersRisk',
|
||||
'GET:policiesApproved' => 'policiesApproved',
|
||||
'GET:openapi' => 'openapi',
|
||||
// lg231 / GRC integration endpoints
|
||||
'GET:gapAnalysis' => 'gapAnalysis',
|
||||
'GET:measures' => 'measures',
|
||||
'GET:incidents' => 'incidents',
|
||||
'GET:training' => 'training',
|
||||
'GET:deadlines' => 'deadlines',
|
||||
],
|
||||
|
||||
// ── WebhookController (CRUD keys + subscriptions) ──
|
||||
|
||||
Loading…
Reference in New Issue
Block a user