nis2-agile/application/controllers/SupplyChainController.php
DevEnv nis2-agile 5c7ed9abcb [FIX] Import fornitori: valida scope org di category_id + allinea header migrazioni 032/033
- bulkUpsertSuppliers: il ramo category_id esplicito (import API/CSV) ora verifica
  che la categoria sia un preset (org 0) o della stessa org, come gia' fa il ramo
  category_slug. Evita di scrivere suppliers.category_id di un'altra org (dato sporco
  cross-org). Finding review multi-agente (MINORE, correttezza dati).
- docs/sql/032,033: header "PROPOSTA DI DESIGN (NON applicata)" -> "APPLICATA su
  produzione 2026-05-31" (sono effettivamente applicate). Evita confusione operativa.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 11:16:43 +02:00

729 lines
33 KiB
PHP

<?php
/**
* NIS2 Agile - Supply Chain Controller
*
* Gestione fornitori, assessment cybersecurity, risk scoring.
*/
require_once __DIR__ . '/BaseController.php';
class SupplyChainController extends BaseController
{
public function list(): void
{
$this->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',
"<p>Gentile {$supplier['name']},</p><p>vi chiediamo di compilare il questionario di sicurezza al seguente link (valido 30 giorni):</p><p><a href=\"{$link}\">{$link}</a></p>");
}
}
} 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;
}
}