requireOrgAccess(); $suppliers = Database::fetchAll( 'SELECT * FROM suppliers WHERE organization_id = ? ORDER BY criticality DESC, name', [$this->getCurrentOrgId()] ); $this->jsonSuccess($suppliers); } public function create(): void { $this->requireOrgRole(['org_admin', 'compliance_manager']); $this->validateRequired(['name', 'service_type']); $supplierId = Database::insert('suppliers', [ 'organization_id' => $this->getCurrentOrgId(), 'name' => trim($this->getParam('name')), 'vat_number' => $this->getParam('vat_number'), 'contact_email' => $this->getParam('contact_email'), 'contact_name' => $this->getParam('contact_name'), 'service_type' => $this->getParam('service_type'), 'service_description' => $this->getParam('service_description'), 'criticality' => $this->getParam('criticality', 'medium'), 'contract_start_date' => $this->getParam('contract_start_date'), 'contract_expiry_date' => $this->getParam('contract_expiry_date'), 'notes' => $this->getParam('notes'), ]); $this->logAudit('supplier_created', 'supplier', $supplierId); $this->jsonSuccess(['id' => $supplierId], 'Fornitore aggiunto', 201); } public function get(int $id): void { $this->requireOrgAccess(); $supplier = Database::fetchOne( 'SELECT * FROM suppliers WHERE id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()] ); if (!$supplier) { $this->jsonError('Fornitore non trovato', 404, 'SUPPLIER_NOT_FOUND'); } $this->jsonSuccess($supplier); } public function update(int $id): void { $this->requireOrgRole(['org_admin', 'compliance_manager']); $updates = []; $fields = ['name', 'vat_number', 'contact_email', 'contact_name', 'service_type', 'service_description', 'criticality', 'contract_start_date', 'contract_expiry_date', 'security_requirements_met', 'notes', 'status']; foreach ($fields as $field) { if ($this->hasParam($field)) { $updates[$field] = $this->getParam($field); } } if (!empty($updates)) { Database::update('suppliers', $updates, 'id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()]); $this->logAudit('supplier_updated', 'supplier', $id, $updates); } $this->jsonSuccess($updates, 'Fornitore aggiornato'); } public function delete(int $id): void { $this->requireOrgRole(['org_admin']); $deleted = Database::delete('suppliers', 'id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()]); if ($deleted === 0) { $this->jsonError('Fornitore non trovato', 404, 'SUPPLIER_NOT_FOUND'); } $this->logAudit('supplier_deleted', 'supplier', $id); $this->jsonSuccess(null, 'Fornitore eliminato'); } public function assessSupplier(int $id): void { $this->requireOrgRole(['org_admin', 'compliance_manager']); $this->validateRequired(['assessment_responses']); $responses = $this->getParam('assessment_responses'); $riskScore = $this->calculateSupplierRiskScore($responses); Database::update('suppliers', [ 'assessment_responses' => json_encode($responses), 'risk_score' => $riskScore, 'last_assessment_date' => date('Y-m-d'), 'next_assessment_date' => date('Y-m-d', strtotime('+6 months')), 'security_requirements_met' => $riskScore >= 70 ? 1 : 0, ], 'id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()]); $this->logAudit('supplier_assessed', 'supplier', $id, ['risk_score' => $riskScore]); $this->jsonSuccess(['risk_score' => $riskScore], 'Assessment fornitore completato'); } public function riskOverview(): void { $this->requireOrgAccess(); $overview = Database::fetchAll( 'SELECT criticality, status, COUNT(*) as count, AVG(risk_score) as avg_risk_score, SUM(CASE WHEN security_requirements_met = 0 THEN 1 ELSE 0 END) as non_compliant FROM suppliers WHERE organization_id = ? GROUP BY criticality, status', [$this->getCurrentOrgId()] ); $expiring = Database::fetchAll( 'SELECT id, name, contract_expiry_date, criticality FROM suppliers WHERE organization_id = ? AND contract_expiry_date IS NOT NULL AND contract_expiry_date <= DATE_ADD(NOW(), INTERVAL 90 DAY) AND status = "active" ORDER BY contract_expiry_date', [$this->getCurrentOrgId()] ); $this->jsonSuccess([ 'overview' => $overview, 'expiring_contracts' => $expiring, ]); } private function calculateSupplierRiskScore(array $responses): int { if (empty($responses)) return 0; $totalScore = 0; $totalWeight = 0; foreach ($responses as $resp) { $weight = $resp['weight'] ?? 1; $value = match ($resp['value'] ?? '') { 'yes', 'implemented' => 100, 'partial' => 50, default => 0, }; $totalScore += $value * $weight; $totalWeight += 100 * $weight; } return $totalWeight > 0 ? (int) round($totalScore / $totalWeight * 100) : 0; } // ══════════════════════════════════════════════════════════════════════ // SELF-ASSESSMENT FORNITORI (P3) — questionario sicurezza Art.21.2.d // ══════════════════════════════════════════════════════════════════════ private const QUESTIONNAIRE = [ ['key' => 'iso27001', 'q' => 'Disponete di una certificazione ISO/IEC 27001 valida?', 'weight' => 15], ['key' => 'mfa', 'q' => 'Imponete autenticazione a piu fattori (MFA) per gli accessi ai sistemi che trattano dati del cliente?', 'weight' => 15], ['key' => 'patching', 'q' => 'Avete un processo formale di patch management e gestione vulnerabilita?', 'weight' => 12], ['key' => 'backup', 'q' => 'Eseguite backup regolari con test di ripristino documentati?', 'weight' => 12], ['key' => 'incident', 'q' => 'Disponete di un piano di gestione degli incidenti con notifica al cliente?', 'weight' => 14], ['key' => 'access_review', 'q' => 'Effettuate revisioni periodiche degli accessi e revoca tempestiva?', 'weight' => 10], ['key' => 'encryption', 'q' => 'I dati del cliente sono cifrati a riposo e in transito?', 'weight' => 12], ['key' => 'subcontractor', 'q' => 'Valutate la sicurezza dei vostri sub-fornitori (quarta parte)?', 'weight' => 10], ]; /** POST /api/supply-chain/{id}/send-questionnaire (JWT): genera token + link pubblico. */ public function sendQuestionnaire(int $id): void { $this->requireOrgRole(['org_admin', 'compliance_manager']); $supplier = Database::fetchOne( 'SELECT id, name, contact_email FROM suppliers WHERE id = ? AND organization_id = ? AND deleted_at IS NULL', [$id, $this->getCurrentOrgId()] ); if (!$supplier) { $this->jsonError('Fornitore non trovato', 404, 'NOT_FOUND'); } $rawToken = 'sq_' . bin2hex(random_bytes(20)); $email = $this->getParam('email') ?: $supplier['contact_email']; $expires = date('Y-m-d H:i:s', time() + 30 * 86400); Database::insert('supplier_questionnaires', [ 'organization_id' => $this->getCurrentOrgId(), 'supplier_id' => $id, 'token_hash' => hash('sha256', $rawToken), 'status' => 'sent', 'sent_to_email' => $email, 'expires_at' => $expires, ]); $link = 'https://nis2.agile.software/supplier-assessment.html?token=' . $rawToken; try { if ($email && class_exists('EmailService')) { $svc = new EmailService(); if (method_exists($svc, 'send')) { $svc->send($email, 'Questionario di sicurezza fornitore - NIS2', "

Gentile {$supplier['name']},

vi chiediamo di compilare il questionario di sicurezza al seguente link (valido 30 giorni):

{$link}

"); } } } catch (Throwable $e) { error_log('[SUPPLIER_Q] email: ' . $e->getMessage()); } $this->logAudit('supplier_questionnaire_sent', 'supplier', $id, ['email' => $email]); $this->jsonSuccess(['supplier_id' => $id, 'sent_to' => $email, 'link' => $link, 'expires_at' => $expires], 'Questionario generato', 201); } /** GET /api/supply-chain/public-questionnaire?token=xxx (NO AUTH). */ public function publicQuestionnaire(): void { $q = $this->resolveQuestionnaire($_GET['token'] ?? ''); if ($q['status'] === 'completed') { $this->jsonError('Questionario gia compilato', 409, 'ALREADY_COMPLETED'); } $supplier = Database::fetchOne('SELECT name FROM suppliers WHERE id = ?', [$q['supplier_id']]); $this->jsonSuccess([ 'supplier_name' => $supplier['name'] ?? 'Fornitore', 'questions' => array_map(fn($x) => ['key' => $x['key'], 'question' => $x['q']], self::QUESTIONNAIRE), 'answer_scale' => ['yes' => 'Si', 'partial' => 'Parzialmente', 'no' => 'No'], ]); } /** POST /api/supply-chain/submit-public-questionnaire (NO AUTH). Body: {token, answers}. */ public function submitPublicQuestionnaire(): void { $body = $this->getJsonBody(); $q = $this->resolveQuestionnaire($body['token'] ?? ''); if ($q['status'] === 'completed') { $this->jsonError('Questionario gia compilato', 409, 'ALREADY_COMPLETED'); } $answers = is_array($body['answers'] ?? null) ? $body['answers'] : []; $scoreMap = ['yes' => 1.0, 'partial' => 0.5, 'no' => 0.0]; $earned = 0; $maxScore = 0; $clean = []; foreach (self::QUESTIONNAIRE as $item) { $maxScore += $item['weight']; $a = strtolower((string) ($answers[$item['key']] ?? 'no')); if (!isset($scoreMap[$a])) $a = 'no'; $clean[$item['key']] = $a; $earned += $item['weight'] * $scoreMap[$a]; } $score = $maxScore > 0 ? (int) round($earned / $maxScore * 100) : 0; $riskLevel = $score >= 80 ? 'low' : ($score >= 60 ? 'medium' : ($score >= 40 ? 'high' : 'critical')); // Completamento ATOMICO: vincola lo UPDATE a status='sent' così due submit // concorrenti con lo stesso token non possono completare due volte (la seconda // tocca 0 righe -> 409). Previene il doppio-submit senza transazione esplicita. $upd = Database::query( 'UPDATE supplier_questionnaires SET status=?, answers=?, score=?, risk_level=?, completed_at=NOW() WHERE id=? AND status=?', ['completed', json_encode($clean, JSON_UNESCAPED_UNICODE), $score, $riskLevel, $q['id'], 'sent'] ); if ($upd->rowCount() === 0) { $this->jsonError('Questionario gia compilato', 409, 'ALREADY_COMPLETED'); } // Coerenza con assessSupplier: suppliers.risk_score = punteggio di COMPLIANCE // (alto = buono), e security_requirements_met soglia 70. NON sovrascriviamo // suppliers.criticality (è la criticità del fornitore, non l'esito del questionario). Database::query( 'UPDATE suppliers SET risk_score=?, security_requirements_met=?, last_assessment_date=CURDATE() WHERE id=? AND organization_id=?', [$score, $score >= 70 ? 1 : 0, $q['supplier_id'], $q['organization_id']] ); $this->jsonSuccess(['score' => $score, 'risk_level' => $riskLevel], 'Questionario inviato. Grazie.', 201); } /** GET /api/supply-chain/{id}/questionnaire-status (JWT). */ public function questionnaireStatus(int $id): void { $this->requireOrgAccess(); $rows = Database::fetchAll( 'SELECT id, status, score, risk_level, sent_to_email, sent_at, completed_at, expires_at FROM supplier_questionnaires WHERE supplier_id = ? AND organization_id = ? ORDER BY sent_at DESC', [$id, $this->getCurrentOrgId()] ); $this->jsonSuccess(['questionnaires' => $rows]); } // ══════════════════════════════════════════════════════════════════════ // MODULO QUESTIONARI CONFIGURABILE (Fase 1) — categorie, template, domande // ══════════════════════════════════════════════════════════════════════ /** GET /api/supply-chain/categories — preset di sistema (org 0) + categorie dell'org. */ public function categories(): void { $this->requireOrgAccess(); $rows = Database::fetchAll( 'SELECT id, organization_id, slug, name, description, is_system FROM supplier_categories WHERE organization_id = 0 OR organization_id = ? ORDER BY is_system DESC, name', [$this->getCurrentOrgId()] ); $this->jsonSuccess($rows); } /** POST /api/supply-chain/categories — crea categoria custom per l'org. */ public function createCategory(): void { $this->requireOrgRole(['org_admin', 'compliance_manager']); $this->validateRequired(['name']); $name = trim((string) $this->getParam('name')); $slug = $this->slugify($this->getParam('slug') ?: $name); if ($slug === '') { $this->jsonError('Nome categoria non valido', 422, 'INVALID_NAME'); } // Nessun conflitto con slug esistente (preset o della stessa org) $exists = Database::fetchOne( 'SELECT id FROM supplier_categories WHERE slug = ? AND organization_id IN (0, ?)', [$slug, $this->getCurrentOrgId()] ); if ($exists) { $this->jsonError('Esiste gia una categoria con questo identificativo', 409, 'DUPLICATE_SLUG'); } $id = Database::insert('supplier_categories', [ 'organization_id' => $this->getCurrentOrgId(), 'slug' => $slug, 'name' => $name, 'description' => $this->getParam('description'), 'is_system' => 0, ]); $this->logAudit('supplier_category_created', 'supplier_category', $id); $this->jsonSuccess(['id' => $id], 'Categoria creata', 201); } /** PUT /api/supply-chain/categories/{id} — modifica categoria custom (no preset). */ public function updateCategory(int $id): void { $this->requireOrgRole(['org_admin', 'compliance_manager']); $cat = Database::fetchOne( 'SELECT id FROM supplier_categories WHERE id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()] ); if (!$cat) { $this->jsonError('Categoria non trovata o non modificabile', 404, 'NOT_FOUND'); } $updates = []; foreach (['name', 'description'] as $f) { if ($this->hasParam($f)) $updates[$f] = $this->getParam($f); } if (!empty($updates)) { Database::update('supplier_categories', $updates, 'id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()]); $this->logAudit('supplier_category_updated', 'supplier_category', $id, $updates); } $this->jsonSuccess($updates, 'Categoria aggiornata'); } /** DELETE /api/supply-chain/categories/{id} — elimina categoria custom (no preset, no se in uso). */ public function deleteCategory(int $id): void { $this->requireOrgRole(['org_admin']); $cat = Database::fetchOne( 'SELECT id FROM supplier_categories WHERE id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()] ); if (!$cat) { $this->jsonError('Categoria non trovata o non eliminabile', 404, 'NOT_FOUND'); } $inUse = Database::fetchOne( 'SELECT COUNT(*) AS n FROM suppliers WHERE category_id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()] ); if ((int) ($inUse['n'] ?? 0) > 0) { $this->jsonError('Categoria in uso da uno o piu fornitori', 409, 'CATEGORY_IN_USE'); } Database::delete('supplier_categories', 'id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()]); $this->logAudit('supplier_category_deleted', 'supplier_category', $id); $this->jsonSuccess(null, 'Categoria eliminata'); } /** GET /api/supply-chain/templates — lista template dell'org (+ conteggio domande). */ public function templates(): void { $this->requireOrgAccess(); $rows = Database::fetchAll( 'SELECT t.id, t.name, t.description, t.category_id, t.current_version, t.status, t.is_default, t.pass_threshold, c.name AS category_name, (SELECT COUNT(*) FROM questionnaire_questions q WHERE q.template_id = t.id) AS question_count FROM questionnaire_templates t LEFT JOIN supplier_categories c ON c.id = t.category_id WHERE t.organization_id = ? ORDER BY t.is_default DESC, t.name', [$this->getCurrentOrgId()] ); $this->jsonSuccess($rows); } /** GET /api/supply-chain/templates/{id} — template + domande ordinate. */ public function getTemplate(int $id): void { $this->requireOrgAccess(); $tpl = Database::fetchOne( 'SELECT * FROM questionnaire_templates WHERE id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()] ); if (!$tpl) { $this->jsonError('Template non trovato', 404, 'NOT_FOUND'); } $tpl['questions'] = Database::fetchAll( 'SELECT id, question_code, question_text, question_type, options, weight, is_required, order_index, nis2_ref, vuln_flag, high_criticality_only FROM questionnaire_questions WHERE template_id = ? ORDER BY order_index, id', [$id] ); $this->jsonSuccess($tpl); } /** POST /api/supply-chain/templates — crea template (vuoto o da scope). */ public function createTemplate(): void { $this->requireOrgRole(['org_admin', 'compliance_manager']); $this->validateRequired(['name']); $categoryId = $this->getParam('category_id'); if ($categoryId !== null && $categoryId !== '') { $this->assertCategoryVisible((int) $categoryId); } $id = Database::insert('questionnaire_templates', [ 'organization_id' => $this->getCurrentOrgId(), 'category_id' => ($categoryId !== null && $categoryId !== '') ? (int) $categoryId : null, 'name' => trim((string) $this->getParam('name')), 'description' => $this->getParam('description'), 'current_version' => '1.0', 'status' => 'draft', 'pass_threshold' => $this->getParam('pass_threshold') !== null ? (int) $this->getParam('pass_threshold') : null, 'is_default' => $this->getParam('is_default') ? 1 : 0, 'created_by' => $this->getCurrentUserId(), ]); $this->logAudit('questionnaire_template_created', 'questionnaire_template', $id); $this->jsonSuccess(['id' => $id], 'Template creato', 201); } /** PUT /api/supply-chain/templates/{id} — aggiorna metadati template. */ public function updateTemplate(int $id): void { $this->requireOrgRole(['org_admin', 'compliance_manager']); $tpl = Database::fetchOne( 'SELECT id FROM questionnaire_templates WHERE id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()] ); if (!$tpl) { $this->jsonError('Template non trovato', 404, 'NOT_FOUND'); } $updates = []; foreach (['name', 'description', 'status'] as $f) { if ($this->hasParam($f)) $updates[$f] = $this->getParam($f); } if ($this->hasParam('category_id')) { $cid = $this->getParam('category_id'); if ($cid !== null && $cid !== '') $this->assertCategoryVisible((int) $cid); $updates['category_id'] = ($cid !== null && $cid !== '') ? (int) $cid : null; } if ($this->hasParam('pass_threshold')) { $updates['pass_threshold'] = $this->getParam('pass_threshold') !== null ? (int) $this->getParam('pass_threshold') : null; } if ($this->hasParam('is_default')) { $updates['is_default'] = $this->getParam('is_default') ? 1 : 0; } if (!empty($updates)) { Database::update('questionnaire_templates', $updates, 'id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()]); $this->logAudit('questionnaire_template_updated', 'questionnaire_template', $id, $updates); } $this->jsonSuccess($updates, 'Template aggiornato'); } /** POST /api/supply-chain/templates/{id}/questions — aggiunge una domanda. */ public function addTemplateQuestion(int $id): void { $this->requireOrgRole(['org_admin', 'compliance_manager']); $this->assertTemplateOwned($id); $this->validateRequired(['question_text']); $type = $this->getParam('question_type', 'yes_no_partial'); $allowed = ['yes_no_partial', 'single_choice', 'multi_choice', 'scale_1_5', 'text', 'number', 'file']; if (!in_array($type, $allowed, true)) { $this->jsonError('Tipo domanda non valido', 422, 'INVALID_TYPE'); } $options = $this->getParam('options'); $maxOrder = Database::fetchOne('SELECT COALESCE(MAX(order_index),0) AS m FROM questionnaire_questions WHERE template_id = ?', [$id]); $qid = Database::insert('questionnaire_questions', [ 'template_id' => $id, 'organization_id' => $this->getCurrentOrgId(), 'question_code' => $this->getParam('question_code'), 'question_text' => trim((string) $this->getParam('question_text')), 'question_type' => $type, 'options' => $options !== null ? json_encode($options, JSON_UNESCAPED_UNICODE) : null, 'weight' => $this->getParam('weight') !== null ? (float) $this->getParam('weight') : 1.0, 'is_required' => $this->getParam('is_required', true) ? 1 : 0, 'order_index' => $this->getParam('order_index') !== null ? (int) $this->getParam('order_index') : ((int) ($maxOrder['m'] ?? 0) + 1), 'nis2_ref' => $this->getParam('nis2_ref'), 'vuln_flag' => $this->getParam('vuln_flag'), 'high_criticality_only' => $this->getParam('high_criticality_only') ? 1 : 0, ]); $this->logAudit('questionnaire_question_added', 'questionnaire_template', $id, ['question_id' => $qid]); $this->jsonSuccess(['id' => $qid], 'Domanda aggiunta', 201); } /** PUT /api/supply-chain/questions/{id} — modifica domanda. */ public function updateTemplateQuestion(int $id): void { $this->requireOrgRole(['org_admin', 'compliance_manager']); $q = Database::fetchOne( 'SELECT id FROM questionnaire_questions WHERE id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()] ); if (!$q) { $this->jsonError('Domanda non trovata', 404, 'NOT_FOUND'); } $updates = []; foreach (['question_code', 'question_text', 'nis2_ref', 'vuln_flag'] as $f) { if ($this->hasParam($f)) $updates[$f] = $this->getParam($f); } if ($this->hasParam('question_type')) { $type = $this->getParam('question_type'); $allowed = ['yes_no_partial', 'single_choice', 'multi_choice', 'scale_1_5', 'text', 'number', 'file']; if (!in_array($type, $allowed, true)) $this->jsonError('Tipo domanda non valido', 422, 'INVALID_TYPE'); $updates['question_type'] = $type; } if ($this->hasParam('options')) { $opt = $this->getParam('options'); $updates['options'] = $opt !== null ? json_encode($opt, JSON_UNESCAPED_UNICODE) : null; } if ($this->hasParam('weight')) $updates['weight'] = (float) $this->getParam('weight'); if ($this->hasParam('order_index')) $updates['order_index'] = (int) $this->getParam('order_index'); if ($this->hasParam('is_required')) $updates['is_required'] = $this->getParam('is_required') ? 1 : 0; if ($this->hasParam('high_criticality_only')) $updates['high_criticality_only'] = $this->getParam('high_criticality_only') ? 1 : 0; if (!empty($updates)) { Database::update('questionnaire_questions', $updates, 'id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()]); $this->logAudit('questionnaire_question_updated', 'questionnaire_question', $id, $updates); } $this->jsonSuccess($updates, 'Domanda aggiornata'); } /** DELETE /api/supply-chain/questions/{id} — elimina domanda. */ public function deleteTemplateQuestion(int $id): void { $this->requireOrgRole(['org_admin', 'compliance_manager']); $deleted = Database::delete('questionnaire_questions', 'id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()]); if ($deleted === 0) { $this->jsonError('Domanda non trovata', 404, 'NOT_FOUND'); } $this->logAudit('questionnaire_question_deleted', 'questionnaire_question', $id); $this->jsonSuccess(null, 'Domanda eliminata'); } /** POST /api/supply-chain/import — import massivo fornitori (CSV/JSON), upsert per external_ref. */ public function importSuppliers(): void { $this->requireOrgRole(['org_admin', 'compliance_manager']); $body = $this->getJsonBody(); $rows = $body['suppliers'] ?? $body['rows'] ?? null; if (!is_array($rows) || empty($rows)) { $this->jsonError('Nessun fornitore da importare (campo "suppliers" array)', 422, 'NO_ROWS'); } if (count($rows) > 1000) { $this->jsonError('Troppi record in un singolo import (max 1000)', 422, 'TOO_MANY'); } $result = $this->bulkUpsertSuppliers($rows, 'csv'); $this->logAudit('suppliers_imported', 'supplier', null, $result); $this->jsonSuccess($result, 'Import completato'); } /** * Upsert massivo fornitori per (organization_id, external_ref). Riusato da import CSV * e da ServicesController (API key, scope write:suppliers). Sanitizza i campi. * @return array{created:int,updated:int,skipped:int,errors:array} */ public function bulkUpsertSuppliers(array $rows, string $source): array { $orgId = $this->getCurrentOrgId(); $created = 0; $updated = 0; $skipped = 0; $errors = []; foreach ($rows as $i => $r) { $name = trim((string) ($r['name'] ?? '')); if ($name === '') { $skipped++; $errors[] = ['row' => $i, 'error' => 'name mancante']; continue; } $extRef = isset($r['external_ref']) ? trim((string) $r['external_ref']) : null; $extRef = ($extRef === '') ? null : $extRef; $crit = strtolower((string) ($r['criticality'] ?? 'medium')); if (!in_array($crit, ['low', 'medium', 'high', 'critical'], true)) $crit = 'medium'; $catId = null; if (!empty($r['category_slug'])) { $cat = Database::fetchOne( 'SELECT id FROM supplier_categories WHERE slug = ? AND organization_id IN (0, ?)', [$this->slugify((string) $r['category_slug']), $orgId] ); $catId = $cat['id'] ?? null; } elseif (!empty($r['category_id'])) { // Verifica scope: la categoria deve essere un preset (org 0) o della stessa org, // altrimenti si scriverebbe un category_id di un'altra org (dato sporco cross-org). $cat = Database::fetchOne( 'SELECT id FROM supplier_categories WHERE id = ? AND organization_id IN (0, ?)', [(int) $r['category_id'], $orgId] ); $catId = $cat['id'] ?? null; } $data = [ 'name' => $name, 'vat_number' => $this->sanitizeCell($r['vat_number'] ?? null), 'contact_email' => $this->sanitizeCell($r['contact_email'] ?? null), 'contact_name' => $this->sanitizeCell($r['contact_name'] ?? null), 'service_type' => $this->sanitizeCell($r['service_type'] ?? 'altro'), 'service_description' => $this->sanitizeCell($r['service_description'] ?? null), 'criticality' => $crit, 'category_id' => $catId, ]; // Upsert per external_ref (se presente), altrimenti insert nuovo. $existing = null; if ($extRef !== null) { $existing = Database::fetchOne( 'SELECT id FROM suppliers WHERE organization_id = ? AND external_ref = ?', [$orgId, $extRef] ); } if ($existing) { Database::update('suppliers', $data, 'id = ? AND organization_id = ?', [$existing['id'], $orgId]); $updated++; } else { Database::insert('suppliers', array_merge($data, [ 'organization_id' => $orgId, 'external_ref' => $extRef, 'source' => $source, ])); $created++; } } return ['created' => $created, 'updated' => $updated, 'skipped' => $skipped, 'errors' => array_slice($errors, 0, 50)]; } // --- helper privati Fase 1 --- /** Verifica che la categoria sia visibile all'org (preset o propria). */ private function assertCategoryVisible(int $categoryId): void { $cat = Database::fetchOne( 'SELECT id FROM supplier_categories WHERE id = ? AND organization_id IN (0, ?)', [$categoryId, $this->getCurrentOrgId()] ); if (!$cat) { $this->jsonError('Categoria non valida', 422, 'INVALID_CATEGORY'); } } /** Verifica che il template appartenga all'org corrente. */ private function assertTemplateOwned(int $templateId): void { $tpl = Database::fetchOne( 'SELECT id FROM questionnaire_templates WHERE id = ? AND organization_id = ?', [$templateId, $this->getCurrentOrgId()] ); if (!$tpl) { $this->jsonError('Template non trovato', 404, 'NOT_FOUND'); } } /** Slug stabile: minuscole, alfanumerico + underscore. */ private function slugify(string $s): string { $s = strtolower(trim($s)); $s = preg_replace('/[^a-z0-9]+/', '_', $s); return trim((string) $s, '_'); } /** Sanitizza una cella import: anti formula-injection CSV + trim. */ private function sanitizeCell($v): ?string { if ($v === null) return null; $v = trim((string) $v); if ($v === '') return null; // neutralizza formula-injection (Excel/Sheets) prefissando un apice if (preg_match('/^[=+\-@]/', $v)) { $v = "'" . $v; } return $v; } /** Risolve un token grezzo nel record questionario, validando scadenza. */ private function resolveQuestionnaire(string $rawToken): array { if (!preg_match('/^sq_[a-f0-9]{40}$/', $rawToken)) { $this->jsonError('Token non valido', 404, 'INVALID_TOKEN'); } $q = Database::fetchOne('SELECT * FROM supplier_questionnaires WHERE token_hash = ?', [hash('sha256', $rawToken)]); if (!$q) { $this->jsonError('Questionario non trovato', 404, 'NOT_FOUND'); } if ($q['expires_at'] && strtotime($q['expires_at']) < time() && $q['status'] !== 'completed') { Database::query('UPDATE supplier_questionnaires SET status=? WHERE id=?', ['expired', $q['id']]); $this->jsonError('Questionario scaduto', 410, 'EXPIRED'); } return $q; } }