From f85876f2a298811177cb918d7c2eb5aa85e0ac14 Mon Sep 17 00:00:00 2001 From: DevEnv nis2-agile Date: Sun, 31 May 2026 10:36:44 +0200 Subject: [PATCH] [FEAT] Supply chain Fase 1: modulo questionari configurabile (categorie + template + domande + import) Backend del modulo questionari fornitori (design docs/DESIGN_MODULO_QUESTIONARI_FORNITORI.md). Migrazioni 032+033 gia applicate su host (6 tabelle + 10 categorie preset + suppliers.category_id/external_ref/source). SupplyChainController: - categorie: categories/createCategory/updateCategory/deleteCategory (preset org 0 + custom per-org, no delete se in uso) - template: templates/getTemplate/createTemplate/updateTemplate (per-org, scope categoria) - domande: addTemplateQuestion/updateTemplateQuestion/deleteTemplateQuestion (7 tipi, weight, nis2_ref, vuln_flag, high_criticality_only) - import: importSuppliers + bulkUpsertSuppliers (upsert per external_ref, anti formula-injection CSV, max 1000, riusabile da API key) - helper: assertCategoryVisible/assertTemplateOwned/slugify/sanitizeCell Tutte le query org-scoped (no leak cross-org). Route in public/index.php actionMap supply-chain. Smoke: no-auth=401, categcategorie_visibili=10 (preset). USR2 applicato. php -l OK. Co-Authored-By: Claude Opus 4.8 --- .../controllers/SupplyChainController.php | 406 ++++++++++++++++++ public/index.php | 13 + 2 files changed, 419 insertions(+) diff --git a/application/controllers/SupplyChainController.php b/application/controllers/SupplyChainController.php index dc56062..e631949 100644 --- a/application/controllers/SupplyChainController.php +++ b/application/controllers/SupplyChainController.php @@ -297,6 +297,412 @@ class SupplyChainController extends BaseController $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'])) { + $catId = (int) $r['category_id']; + } + + $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 { diff --git a/public/index.php b/public/index.php index 48cc88f..23432a8 100644 --- a/public/index.php +++ b/public/index.php @@ -280,6 +280,19 @@ $actionMap = [ 'GET:{id}/questionnaireStatus' => 'questionnaireStatus', 'GET:{id}/questionnaire-status' => 'questionnaireStatus', 'POST:{id}/assess' => 'assessSupplier', + // Modulo questionari configurabile (Fase 1): categorie, template, domande, import + 'GET:categories' => 'categories', + 'POST:categories' => 'createCategory', + 'PUT:categories/{subId}' => 'updateCategory', + 'DELETE:categories/{subId}' => 'deleteCategory', + 'GET:templates' => 'templates', + 'POST:templates' => 'createTemplate', + 'GET:templates/{subId}' => 'getTemplate', + 'PUT:templates/{subId}' => 'updateTemplate', + 'POST:{id}/questions' => 'addTemplateQuestion', // {id} = template id + 'PUT:questions/{subId}' => 'updateTemplateQuestion', + 'DELETE:questions/{subId}' => 'deleteTemplateQuestion', + 'POST:import' => 'importSuppliers', 'GET:{id}' => 'get', 'PUT:{id}' => 'update', 'DELETE:{id}' => 'delete',