diff --git a/public/userarea/routine_admin.php b/public/userarea/routine_admin.php
new file mode 100644
index 00000000..cf4be4fb
--- /dev/null
+++ b/public/userarea/routine_admin.php
@@ -0,0 +1,549 @@
+hasRole('Admin') && !$user->hasRole('SuperUser')) {
+ header('Location: import_dashboard.php');
+ exit;
+}
+
+$db = DBHandlerSelect::getInstance();
+$pdo = $db->getConnection();
+
+/*
+ * Nome file del motore generico condiviso da tutte le routine engine.
+ * Deve corrispondere al file presente in routines/.
+ */
+$engineFilename = '_engine.php';
+
+/*
+ * CSRF token.
+ */
+if (empty($_SESSION['routine_admin_csrf'])) {
+ $_SESSION['routine_admin_csrf'] = bin2hex(random_bytes(32));
+}
+$csrfToken = $_SESSION['routine_admin_csrf'];
+
+function cleanInput($value)
+{
+ return trim((string)($value ?? ''));
+}
+
+function redirectWithMessage($type, $message)
+{
+ $_SESSION['routine_admin_flash'] = [
+ 'type' => $type,
+ 'message' => $message
+ ];
+ header('Location: ' . $_SERVER['PHP_SELF']);
+ exit;
+}
+
+/*
+ * Gestione azioni POST.
+ */
+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ try {
+ $postedToken = $_POST['csrf_token'] ?? '';
+ if (!hash_equals($csrfToken, $postedToken)) {
+ throw new Exception('Token di sicurezza non valido.');
+ }
+
+ $action = cleanInput($_POST['action'] ?? '');
+
+ if ($action === 'create' || $action === 'update') {
+ $name = cleanInput($_POST['name'] ?? '');
+ $description = cleanInput($_POST['description'] ?? '');
+ $engine = (int)($_POST['engine'] ?? 0) === 1 ? 1 : 0;
+ $headerrow = (int)($_POST['headerrow'] ?? 1);
+ $filename = cleanInput($_POST['filename'] ?? '');
+
+ if ($name === '') {
+ throw new Exception('Il nome della routine è obbligatorio.');
+ }
+ if ($headerrow < 1) {
+ $headerrow = 1;
+ }
+
+ /*
+ * Se è una routine a motore, il filename è forzato al file del motore.
+ * Se è legacy, serve un filename di file reale.
+ */
+ if ($engine === 1) {
+ $filename = $GLOBALS['engineFilename'];
+ } else {
+ if ($filename === '') {
+ throw new Exception('Per una routine di tipo File indica il nome del file (es. fendi.php).');
+ }
+ }
+
+ if ($action === 'create') {
+ // instruction è NOT NULL nello schema: valorizziamo sempre.
+ $instruction = $engine === 1
+ ? 'Routine gestita dal motore generico (routine_step).'
+ : ('Routine su file: ' . $filename);
+
+ $stmt = $pdo->prepare("
+ INSERT INTO routine (name, description, instruction, filename, engine, headerrow)
+ VALUES (:name, :descr, :instr, :fn, :engine, :hr)
+ ");
+ $stmt->execute([
+ ':name' => $name,
+ ':descr' => ($description !== '' ? $description : null),
+ ':instr' => $instruction,
+ ':fn' => $filename,
+ ':engine' => $engine,
+ ':hr' => $headerrow,
+ ]);
+
+ redirectWithMessage('success', 'Routine creata correttamente.');
+ } else {
+ $idroutine = (int)($_POST['idroutine'] ?? 0);
+ if ($idroutine <= 0) {
+ throw new Exception('ID routine non valido.');
+ }
+
+ $stmt = $pdo->prepare("
+ UPDATE routine
+ SET name = :name,
+ description = :descr,
+ filename = :fn,
+ engine = :engine,
+ headerrow = :hr
+ WHERE idroutine = :id
+ ");
+ $stmt->execute([
+ ':name' => $name,
+ ':descr' => ($description !== '' ? $description : null),
+ ':fn' => $filename,
+ ':engine' => $engine,
+ ':hr' => $headerrow,
+ ':id' => $idroutine,
+ ]);
+
+ redirectWithMessage('success', 'Routine aggiornata correttamente.');
+ }
+ }
+
+ if ($action === 'delete') {
+ $idroutine = (int)($_POST['idroutine'] ?? 0);
+ if ($idroutine <= 0) {
+ throw new Exception('ID routine non valido.');
+ }
+
+ // Rimuove prima gli eventuali step collegati, poi la routine.
+ $stmt = $pdo->prepare("DELETE FROM routine_step WHERE idroutine = :id");
+ $stmt->execute([':id' => $idroutine]);
+
+ $stmt = $pdo->prepare("DELETE FROM routine WHERE idroutine = :id");
+ $stmt->execute([':id' => $idroutine]);
+
+ redirectWithMessage('success', 'Routine eliminata correttamente.');
+ }
+
+ throw new Exception('Azione non valida.');
+ } catch (Exception $e) {
+ redirectWithMessage('error', $e->getMessage());
+ }
+}
+
+/*
+ * Caricamento routine + conteggio step.
+ */
+$stmt = $pdo->query("
+ SELECT
+ r.idroutine,
+ r.name,
+ r.description,
+ r.filename,
+ r.engine,
+ r.headerrow,
+ (SELECT COUNT(*) FROM routine_step s WHERE s.idroutine = r.idroutine) AS step_count
+ FROM routine r
+ ORDER BY r.name ASC
+");
+$routines = $stmt->fetchAll(PDO::FETCH_ASSOC);
+
+$flash = $_SESSION['routine_admin_flash'] ?? null;
+unset($_SESSION['routine_admin_flash']);
+?>
+
+
+
+
+
+
+
+
+
+
Gestione Routine - = htmlspecialchars($titlewebsite, ENT_QUOTES, 'UTF-8'); ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | Nome |
+ Descrizione |
+ Tipo |
+ File |
+ Header row |
+ Step |
+ Azioni |
+
+
+
+
+
+
+ | = htmlspecialchars($row['name'] ?? '', ENT_QUOTES, 'UTF-8'); ?> |
+ = htmlspecialchars($row['description'] ?? '', ENT_QUOTES, 'UTF-8'); ?> |
+
+
+ Motore
+
+ File
+
+ |
+ = htmlspecialchars($row['filename'] ?? '', ENT_QUOTES, 'UTF-8'); ?> |
+ = (int)$row['headerrow']; ?> |
+
+
+ = (int)$row['step_count']; ?>
+
+ —
+
+ |
+
+
+
+
+
+
+
+
+
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/public/userarea/routine_preview.php b/public/userarea/routine_preview.php
new file mode 100644
index 00000000..2d18b05e
--- /dev/null
+++ b/public/userarea/routine_preview.php
@@ -0,0 +1,184 @@
+ N, 'data' => [0=>.., 1=>.., ...]] ]
+ * - Include _engine.php e applica applyRoutine().
+ * - Risponde JSON con la riga elaborata + intestazioni + lettere colonna.
+ */
+
+ob_start();
+session_start();
+require_once '../../vendor/autoload.php';
+
+use PhpOffice\PhpSpreadsheet\IOFactory;
+use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
+
+Dotenv\Dotenv::createImmutable(dirname(__DIR__, 2))->safeLoad();
+date_default_timezone_set($_ENV['APP_TIMEZONE'] ?? 'Europe/Rome');
+
+header('Content-Type: application/json');
+
+$response = [
+ 'error' => '',
+ 'headers' => [], // intestazioni prese dalla header row
+ 'letters' => [], // lettere colonna (A, B, C, ...) allineate ai valori
+ 'row' => [], // valori della riga DOPO l'applicazione del motore
+ 'excelrow' => 0, // numero riga excel elaborata
+];
+
+/**
+ * Indice zero-based -> lettera colonna Excel (0 -> A, 25 -> Z, 26 -> AA...).
+ */
+function previewIndexToCol($index)
+{
+ $index = (int)$index;
+ if ($index < 0) {
+ return '';
+ }
+ $letters = '';
+ $index += 1;
+ while ($index > 0) {
+ $rem = ($index - 1) % 26;
+ $letters = chr(ord('A') + $rem) . $letters;
+ $index = intdiv($index - 1, 26);
+ }
+ return $letters;
+}
+
+try {
+ if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
+ throw new Exception('Richiesta non valida.');
+ }
+
+ $idroutine = (int)($_POST['idroutine'] ?? 0);
+ if ($idroutine <= 0) {
+ throw new Exception('ID routine mancante.');
+ }
+
+ if (
+ empty($_FILES['file']) ||
+ !isset($_FILES['file']['tmp_name']) ||
+ $_FILES['file']['error'] !== UPLOAD_ERR_OK
+ ) {
+ throw new Exception('Nessun file caricato o upload non riuscito.');
+ }
+
+ // --- Recupera la routine e verifica che sia a motore ---
+ require_once __DIR__ . '/class/db-functions.php';
+ $pdo = DBHandlerSelect::getInstance()->getConnection();
+
+ $stmt = $pdo->prepare("SELECT idroutine, name, engine, headerrow FROM routine WHERE idroutine = :id LIMIT 1");
+ $stmt->execute([':id' => $idroutine]);
+ $routine = $stmt->fetch(PDO::FETCH_ASSOC);
+
+ if (!$routine) {
+ throw new Exception('Routine non trovata.');
+ }
+ if ((int)$routine['engine'] !== 1) {
+ throw new Exception('Questa routine non è di tipo Motore: anteprima non disponibile.');
+ }
+
+ $headerRow = max(1, (int)$routine['headerrow']); // riga 1-based dell'intestazione
+
+ // --- Legge l'XLS dal file temporaneo (nessun salvataggio) ---
+ $tmpPath = $_FILES['file']['tmp_name'];
+
+ $reader = IOFactory::createReaderForFile($tmpPath);
+ $reader->setReadDataOnly(true);
+ $spreadsheet = $reader->load($tmpPath);
+ $sheet = $spreadsheet->getActiveSheet();
+
+ $highestColIndex = Coordinate::columnIndexFromString($sheet->getHighestDataColumn()); // 1-based
+ $highestRow = $sheet->getHighestDataRow();
+
+ // --- Intestazioni dalla header row ---
+ $headers = [];
+ $letters = [];
+ for ($c = 1; $c <= $highestColIndex; $c++) {
+ $val = $sheet->getCell([$c, $headerRow])->getValue();
+ $headers[] = ($val === null ? '' : (string)$val);
+ $letters[] = previewIndexToCol($c - 1); // zero-based -> lettera
+ }
+
+ // --- Trova la prima riga dati valorizzata dopo l'header ---
+ $dataRowNumber = null;
+ $rowValues = null;
+
+ for ($r = $headerRow + 1; $r <= $highestRow; $r++) {
+ $vals = [];
+ $hasValue = false;
+ for ($c = 1; $c <= $highestColIndex; $c++) {
+ $v = $sheet->getCell([$c, $r])->getValue();
+ $v = ($v === null ? '' : (string)$v);
+ if ($v !== '') {
+ $hasValue = true;
+ }
+ $vals[$c - 1] = $v; // indice zero-based, allineato alle colonne
+ }
+ if ($hasValue) {
+ $dataRowNumber = $r;
+ $rowValues = $vals;
+ break;
+ }
+ }
+
+ if ($dataRowNumber === null) {
+ throw new Exception('Nessuna riga dati valorizzata trovata dopo la riga di intestazione (' . $headerRow . ').');
+ }
+
+ // --- Impacchetta nel formato excelData e applica il motore ---
+ $excelData = [
+ [
+ 'excelrow' => $dataRowNumber,
+ 'data' => $rowValues,
+ ],
+ ];
+
+ // Il motore legge gli step dal DB in base a idroutine.
+ $routineData = [
+ 'idroutine' => $idroutine,
+ 'name' => $routine['name'],
+ 'headerrow' => $headerRow,
+ 'xls_headers' => $headers,
+ ];
+
+ require_once __DIR__ . '/routines/_engine.php';
+ applyRoutine($excelData, $routineData); // modifica in place
+
+ $processed = $excelData[0]['data'] ?? [];
+
+ // Riallinea l'output all'ampiezza delle intestazioni (il merge può creare indici nuovi).
+ $maxIndex = max(
+ count($headers) - 1,
+ empty($processed) ? 0 : max(array_keys($processed))
+ );
+
+ $outRow = [];
+ $outLetters = [];
+ $outHeaders = [];
+ for ($i = 0; $i <= $maxIndex; $i++) {
+ $outRow[] = array_key_exists($i, $processed) ? (string)$processed[$i] : '';
+ $outLetters[] = previewIndexToCol($i);
+ $outHeaders[] = array_key_exists($i, $headers) ? $headers[$i] : '';
+ }
+
+ $response['headers'] = $outHeaders;
+ $response['letters'] = $outLetters;
+ $response['row'] = $outRow;
+ $response['excelrow'] = $dataRowNumber;
+} catch (Throwable $e) {
+ $response['error'] = 'Errore anteprima: ' . $e->getMessage();
+ error_log('routine_preview.php: ' . $e->getMessage());
+}
+
+ob_end_clean();
+echo json_encode($response, JSON_UNESCAPED_UNICODE);
+exit;
diff --git a/public/userarea/routine_steps.php b/public/userarea/routine_steps.php
new file mode 100644
index 00000000..a56ff03a
--- /dev/null
+++ b/public/userarea/routine_steps.php
@@ -0,0 +1,976 @@
+hasRole('Admin') && !$user->hasRole('SuperUser')) {
+ header('Location: import_dashboard.php');
+ exit;
+}
+
+$db = DBHandlerSelect::getInstance();
+$pdo = $db->getConnection();
+
+/*
+ * CSRF token.
+ */
+if (empty($_SESSION['routine_steps_csrf'])) {
+ $_SESSION['routine_steps_csrf'] = bin2hex(random_bytes(32));
+}
+$csrfToken = $_SESSION['routine_steps_csrf'];
+
+function cleanInput($value)
+{
+ return trim((string)($value ?? ''));
+}
+
+/*
+ * Determina l'id routine dalla querystring (GET) o dal POST.
+ */
+$idroutine = 0;
+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ $idroutine = (int)($_POST['idroutine'] ?? 0);
+} else {
+ $idroutine = (int)($_GET['idroutine'] ?? 0);
+}
+
+function redirectToSteps($idroutine, $type, $message)
+{
+ $_SESSION['routine_steps_flash'] = [
+ 'type' => $type,
+ 'message' => $message
+ ];
+ header('Location: routine_steps.php?idroutine=' . (int)$idroutine);
+ exit;
+}
+
+/*
+ * Converte lettera colonna Excel in indice zero-based. Accetta anche numeri.
+ * A=0, B=1, ... Z=25, AA=26, ...
+ */
+function colToIndex($ref)
+{
+ $ref = strtoupper(trim((string)$ref));
+ if ($ref === '') {
+ return null;
+ }
+ if (ctype_digit($ref)) {
+ return (int)$ref;
+ }
+ $index = 0;
+ $len = strlen($ref);
+ for ($i = 0; $i < $len; $i++) {
+ $c = $ref[$i];
+ if ($c < 'A' || $c > 'Z') {
+ return null;
+ }
+ $index = $index * 26 + (ord($c) - ord('A') + 1);
+ }
+ return $index - 1;
+}
+
+/*
+ * Converte indice zero-based in lettera Excel (per mostrare gli step salvati).
+ */
+function indexToCol($index)
+{
+ $index = (int)$index;
+ if ($index < 0) {
+ return '';
+ }
+ $letters = '';
+ $index += 1;
+ while ($index > 0) {
+ $rem = ($index - 1) % 26;
+ $letters = chr(ord('A') + $rem) . $letters;
+ $index = intdiv($index - 1, 26);
+ }
+ return $letters;
+}
+
+/*
+ * Normalizza una stringa di colonne separate da virgola ("D, F, H") in
+ * un array di indici zero-based, validando ogni riferimento.
+ */
+function parseColumnList($raw)
+{
+ $raw = trim((string)$raw);
+ if ($raw === '') {
+ return [];
+ }
+ $parts = preg_split('/[\s,;]+/', $raw);
+ $out = [];
+ foreach ($parts as $p) {
+ if ($p === '') {
+ continue;
+ }
+ $idx = colToIndex($p);
+ if ($idx === null) {
+ throw new Exception('Riferimento colonna non valido: "' . $p . '". Usa lettere come D, F, H.');
+ }
+ $out[] = $idx;
+ }
+ return $out;
+}
+
+/*
+ * Verifica esistenza routine ed engine = 1.
+ */
+$routine = null;
+if ($idroutine > 0) {
+ $stmt = $pdo->prepare("SELECT idroutine, name, description, engine, headerrow FROM routine WHERE idroutine = :id LIMIT 1");
+ $stmt->execute([':id' => $idroutine]);
+ $routine = $stmt->fetch(PDO::FETCH_ASSOC);
+}
+
+if (!$routine) {
+ $_SESSION['routine_admin_flash'] = ['type' => 'error', 'message' => 'Routine non trovata.'];
+ header('Location: routine_admin.php');
+ exit;
+}
+
+if ((int)$routine['engine'] !== 1) {
+ $_SESSION['routine_admin_flash'] = ['type' => 'error', 'message' => 'Questa routine è di tipo File: gli step non sono configurabili da qui.'];
+ header('Location: routine_admin.php');
+ exit;
+}
+
+/*
+ * Vocabolario operazioni disponibili (deve combaciare con gli handler di _engine.php).
+ */
+$operations = ['merge', 'copy', 'clear', 'trim', 'replace', 'uppercase', 'lowercase'];
+
+/*
+ * Gestione azioni POST.
+ */
+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ try {
+ $postedToken = $_POST['csrf_token'] ?? '';
+ if (!hash_equals($csrfToken, $postedToken)) {
+ throw new Exception('Token di sicurezza non valido.');
+ }
+
+ $action = cleanInput($_POST['action'] ?? '');
+
+ if ($action === 'create' || $action === 'update') {
+ $operation = strtolower(cleanInput($_POST['operation'] ?? ''));
+ $active = (int)($_POST['active'] ?? 1) === 1 ? 1 : 0;
+
+ if (!in_array($operation, $operations, true)) {
+ throw new Exception('Operazione non valida.');
+ }
+
+ /*
+ * Costruzione params in base all'operazione.
+ * Le colonne arrivano come lettere e vengono convertite in indici.
+ */
+ $params = [];
+
+ if ($operation === 'merge') {
+ $sources = parseColumnList($_POST['source_columns'] ?? '');
+ $target = colToIndex($_POST['target_column'] ?? '');
+ if (empty($sources)) {
+ throw new Exception('Indica almeno una colonna sorgente (es. D, F, H).');
+ }
+ if ($target === null) {
+ throw new Exception('Indica la colonna di destinazione (es. J).');
+ }
+ $params = [
+ 'source_columns' => $sources,
+ 'target_column' => $target,
+ 'separator' => (string)($_POST['separator'] ?? ' '),
+ 'skip_empty' => isset($_POST['skip_empty']) ? true : false,
+ ];
+ } elseif ($operation === 'copy') {
+ $src = colToIndex($_POST['source_column'] ?? '');
+ $dst = colToIndex($_POST['target_column'] ?? '');
+ if ($src === null || $dst === null) {
+ throw new Exception('Indica colonna sorgente e destinazione (es. D e J).');
+ }
+ $params = ['source_column' => $src, 'target_column' => $dst];
+ } elseif ($operation === 'clear' || $operation === 'trim' || $operation === 'uppercase' || $operation === 'lowercase') {
+ $cols = parseColumnList($_POST['columns'] ?? '');
+ if (empty($cols)) {
+ throw new Exception('Indica almeno una colonna (es. D, F).');
+ }
+ $params = ['columns' => $cols];
+ if ($operation === 'trim') {
+ $chars = (string)($_POST['chars'] ?? '');
+ if ($chars !== '') {
+ $params['chars'] = $chars;
+ }
+ }
+ } elseif ($operation === 'replace') {
+ $cols = parseColumnList($_POST['columns'] ?? '');
+ if (empty($cols)) {
+ throw new Exception('Indica almeno una colonna (es. D, F).');
+ }
+ $params = [
+ 'columns' => $cols,
+ 'search' => (string)($_POST['search'] ?? ''),
+ 'replace' => (string)($_POST['replace'] ?? ''),
+ 'regex' => isset($_POST['regex']) ? true : false,
+ ];
+ }
+
+ $paramsJson = json_encode($params, JSON_UNESCAPED_UNICODE);
+
+ if ($action === 'create') {
+ // step_order = max + 1
+ $stmt = $pdo->prepare("SELECT COALESCE(MAX(step_order), 0) + 1 AS next_order FROM routine_step WHERE idroutine = :id");
+ $stmt->execute([':id' => $idroutine]);
+ $nextOrder = (int)$stmt->fetchColumn();
+
+ $stmt = $pdo->prepare("
+ INSERT INTO routine_step (idroutine, step_order, operation, params, active)
+ VALUES (:id, :ord, :op, :params, :active)
+ ");
+ $stmt->execute([
+ ':id' => $idroutine,
+ ':ord' => $nextOrder,
+ ':op' => $operation,
+ ':params' => $paramsJson,
+ ':active' => $active,
+ ]);
+
+ redirectToSteps($idroutine, 'success', 'Step aggiunto correttamente.');
+ } else {
+ $idstep = (int)($_POST['idstep'] ?? 0);
+ if ($idstep <= 0) {
+ throw new Exception('ID step non valido.');
+ }
+ $stmt = $pdo->prepare("
+ UPDATE routine_step
+ SET operation = :op, params = :params, active = :active
+ WHERE idstep = :idstep AND idroutine = :id
+ ");
+ $stmt->execute([
+ ':op' => $operation,
+ ':params' => $paramsJson,
+ ':active' => $active,
+ ':idstep' => $idstep,
+ ':id' => $idroutine,
+ ]);
+
+ redirectToSteps($idroutine, 'success', 'Step aggiornato correttamente.');
+ }
+ }
+
+ if ($action === 'delete') {
+ $idstep = (int)($_POST['idstep'] ?? 0);
+ if ($idstep <= 0) {
+ throw new Exception('ID step non valido.');
+ }
+ $stmt = $pdo->prepare("DELETE FROM routine_step WHERE idstep = :idstep AND idroutine = :id");
+ $stmt->execute([':idstep' => $idstep, ':id' => $idroutine]);
+ redirectToSteps($idroutine, 'success', 'Step eliminato.');
+ }
+
+ if ($action === 'move') {
+ $idstep = (int)($_POST['idstep'] ?? 0);
+ $dir = cleanInput($_POST['direction'] ?? '');
+ if ($idstep <= 0 || !in_array($dir, ['up', 'down'], true)) {
+ throw new Exception('Parametri di spostamento non validi.');
+ }
+
+ // Carica step correnti ordinati.
+ $stmt = $pdo->prepare("SELECT idstep, step_order FROM routine_step WHERE idroutine = :id ORDER BY step_order ASC, idstep ASC");
+ $stmt->execute([':id' => $idroutine]);
+ $all = $stmt->fetchAll(PDO::FETCH_ASSOC);
+
+ // Trova posizione corrente.
+ $pos = null;
+ foreach ($all as $i => $s) {
+ if ((int)$s['idstep'] === $idstep) {
+ $pos = $i;
+ break;
+ }
+ }
+
+ if ($pos !== null) {
+ $swapWith = $dir === 'up' ? $pos - 1 : $pos + 1;
+ if ($swapWith >= 0 && $swapWith < count($all)) {
+ $a = $all[$pos];
+ $b = $all[$swapWith];
+ // Scambia gli step_order dei due record.
+ $upd = $pdo->prepare("UPDATE routine_step SET step_order = :ord WHERE idstep = :idstep AND idroutine = :id");
+ $upd->execute([':ord' => (int)$b['step_order'], ':idstep' => (int)$a['idstep'], ':id' => $idroutine]);
+ $upd->execute([':ord' => (int)$a['step_order'], ':idstep' => (int)$b['idstep'], ':id' => $idroutine]);
+ }
+ }
+
+ redirectToSteps($idroutine, 'success', 'Ordine aggiornato.');
+ }
+
+ throw new Exception('Azione non valida.');
+ } catch (Exception $e) {
+ redirectToSteps($idroutine, 'error', $e->getMessage());
+ }
+}
+
+/*
+ * Caricamento step della routine.
+ */
+$stmt = $pdo->prepare("SELECT idstep, step_order, operation, params, active FROM routine_step WHERE idroutine = :id ORDER BY step_order ASC, idstep ASC");
+$stmt->execute([':id' => $idroutine]);
+$steps = $stmt->fetchAll(PDO::FETCH_ASSOC);
+
+/*
+ * Costruisce una descrizione leggibile dei params per la tabella.
+ */
+function describeStep($operation, array $p)
+{
+ $colsToLetters = function ($arr) {
+ return implode(', ', array_map('indexToCol', $arr));
+ };
+ switch ($operation) {
+ case 'merge':
+ return 'Unisce ' . $colsToLetters($p['source_columns'] ?? []) .
+ ' → ' . indexToCol($p['target_column'] ?? 0) .
+ ' (sep: "' . ($p['separator'] ?? ' ') . '")';
+ case 'copy':
+ return 'Copia ' . indexToCol($p['source_column'] ?? 0) . ' → ' . indexToCol($p['target_column'] ?? 0);
+ case 'clear':
+ return 'Svuota ' . $colsToLetters($p['columns'] ?? []);
+ case 'trim':
+ return 'Trim ' . $colsToLetters($p['columns'] ?? []) . (isset($p['chars']) ? ' (chars: "' . $p['chars'] . '")' : '');
+ case 'uppercase':
+ return 'Maiuscolo ' . $colsToLetters($p['columns'] ?? []);
+ case 'lowercase':
+ return 'Minuscolo ' . $colsToLetters($p['columns'] ?? []);
+ case 'replace':
+ return 'Sostituisci in ' . $colsToLetters($p['columns'] ?? []) .
+ ': "' . ($p['search'] ?? '') . '" → "' . ($p['replace'] ?? '') . '"' .
+ (!empty($p['regex']) ? ' (regex)' : '');
+ default:
+ return $operation;
+ }
+}
+
+/*
+ * Prepara i dati step per il JS (params già in lettere per il form di modifica).
+ */
+$stepsForJs = [];
+foreach ($steps as $s) {
+ $p = json_decode($s['params'] ?? '[]', true);
+ if (!is_array($p)) {
+ $p = [];
+ }
+ // Converte indici -> lettere per il form.
+ $letters = function ($arr) {
+ return implode(', ', array_map('indexToCol', $arr));
+ };
+ $formData = ['operation' => $s['operation'], 'active' => (int)$s['active']];
+ if ($s['operation'] === 'merge') {
+ $formData['source_columns'] = $letters($p['source_columns'] ?? []);
+ $formData['target_column'] = indexToCol($p['target_column'] ?? 0);
+ $formData['separator'] = $p['separator'] ?? ' ';
+ $formData['skip_empty'] = !empty($p['skip_empty']);
+ } elseif ($s['operation'] === 'copy') {
+ $formData['source_column'] = indexToCol($p['source_column'] ?? 0);
+ $formData['target_column'] = indexToCol($p['target_column'] ?? 0);
+ } elseif (in_array($s['operation'], ['clear', 'trim', 'uppercase', 'lowercase'], true)) {
+ $formData['columns'] = $letters($p['columns'] ?? []);
+ if ($s['operation'] === 'trim') {
+ $formData['chars'] = $p['chars'] ?? '';
+ }
+ } elseif ($s['operation'] === 'replace') {
+ $formData['columns'] = $letters($p['columns'] ?? []);
+ $formData['search'] = $p['search'] ?? '';
+ $formData['replace'] = $p['replace'] ?? '';
+ $formData['regex'] = !empty($p['regex']);
+ }
+ $stepsForJs[(int)$s['idstep']] = $formData;
+}
+
+$flash = $_SESSION['routine_steps_flash'] ?? null;
+unset($_SESSION['routine_steps_flash']);
+?>
+
+
+
+
+
+
+
+
+
+
Step Routine - = htmlspecialchars($titlewebsite, ENT_QUOTES, 'UTF-8'); ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Le colonne si indicano con le lettere dell'Excel (A, B, C, …). Per più colonne separale con la virgola: D, F, H.
+
+
+
+
+
+
+ | Ordine |
+ Operazione |
+ Dettaglio |
+ Stato |
+ Azioni |
+
+
+
+
+
+ |
+ Nessuno step configurato. Usa "Aggiungi step" per iniziare.
+ |
+
+
+ $s): ?>
+
+
+ |
+
+
+
+
+ |
+ = htmlspecialchars($s['operation'], ENT_QUOTES, 'UTF-8'); ?> |
+ = htmlspecialchars(describeStep($s['operation'], $p), ENT_QUOTES, 'UTF-8'); ?> |
+
+
+ Attivo
+
+ Disattivo
+
+ |
+
+
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+
Prova la routine
+ Carica un XLS di esempio: viene mostrata la prima riga dati dopo l'intestazione, elaborata con gli step qui sopra. Il file non viene salvato.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | Colonna |
+ Intestazione |
+ Valore dopo la routine |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/public/userarea/routines/_engine.php b/public/userarea/routines/_engine.php
new file mode 100644
index 00000000..b07a4ead
--- /dev/null
+++ b/public/userarea/routines/_engine.php
@@ -0,0 +1,296 @@
+ indice diretto
+ if (ctype_digit($ref)) {
+ return (int)$ref;
+ }
+ // Lettere Excel -> indice zero-based (A=0, B=1, ..., Z=25, AA=26, ...)
+ $ref = strtoupper($ref);
+ $index = 0;
+ $len = strlen($ref);
+ for ($i = 0; $i < $len; $i++) {
+ $c = $ref[$i];
+ if ($c < 'A' || $c > 'Z') {
+ continue;
+ }
+ $index = $index * 26 + (ord($c) - ord('A') + 1);
+ }
+ return $index - 1;
+ }
+
+ /** Normalizza una lista di riferimenti colonna in indici zero-based. */
+ function routineEngineColsToIndexes(array $refs): array
+ {
+ return array_map('routineEngineColToIndex', $refs);
+ }
+
+ /**
+ * Carica gli step attivi di una routine dal DB, ordinati per step_order.
+ * Ritorna array di ['operation' => string, 'params' => array].
+ */
+ function routineEngineLoadSteps(int $idroutine): array
+ {
+ // Riusa la connessione PDO del progetto.
+ // db-functions.php sta in .../class/ rispetto alla cartella routines/.
+ // Aggiusta il require se nel tuo albero il path differisce.
+ $dbFile = dirname(__DIR__) . '/class/db-functions.php';
+ if (!class_exists('DBHandlerSelect') && file_exists($dbFile)) {
+ require_once $dbFile;
+ }
+
+ if (!class_exists('DBHandlerSelect')) {
+ throw new Exception("Motore routine: classe DBHandlerSelect non trovata (path db-functions.php da verificare).");
+ }
+
+ $pdo = DBHandlerSelect::getInstance()->getConnection();
+
+ $stmt = $pdo->prepare(
+ "SELECT operation, params
+ FROM routine_step
+ WHERE idroutine = :id AND active = 1
+ ORDER BY step_order ASC, idstep ASC"
+ );
+ $stmt->execute([':id' => $idroutine]);
+ $rows = $stmt->fetchAll();
+
+ $steps = [];
+ foreach ($rows as $r) {
+ $params = [];
+ if (!empty($r['params'])) {
+ $decoded = json_decode($r['params'], true);
+ if (is_array($decoded)) {
+ $params = $decoded;
+ } else {
+ error_log("Motore routine: params JSON non valido per operazione {$r['operation']} (routine {$idroutine}).");
+ }
+ }
+ $steps[] = ['operation' => strtolower(trim($r['operation'])), 'params' => $params];
+ }
+ return $steps;
+ }
+
+ /* ------------------------------------------------------------------ *
+ * HANDLERS – ogni operazione modifica $excelData in place.
+ * Aggiungere una capacità nuova = aggiungere un handler qui, una volta.
+ * ------------------------------------------------------------------ */
+
+ /**
+ * merge: unisce N colonne sorgente in una destinazione.
+ * params: source_columns [], target_column, separator=" ", skip_empty=true
+ */
+ function routineOpMerge(array &$excelData, array $params): void
+ {
+ $sources = routineEngineColsToIndexes($params['source_columns'] ?? []);
+ $target = routineEngineColToIndex($params['target_column'] ?? 0);
+ $sep = array_key_exists('separator', $params) ? (string)$params['separator'] : ' ';
+ $skip = $params['skip_empty'] ?? true;
+
+ foreach ($excelData as $rowIndex => &$row) {
+ if (!isset($row['data']) || !is_array($row['data'])) {
+ error_log("merge: struttura riga non valida all'indice {$rowIndex}.");
+ continue;
+ }
+ $parts = [];
+ foreach ($sources as $col) {
+ $val = trim((string)($row['data'][$col] ?? ''));
+ if ($skip && $val === '') {
+ continue;
+ }
+ $parts[] = $val;
+ }
+ $row['data'][$target] = implode($sep, $parts);
+ }
+ unset($row);
+ }
+
+ /**
+ * copy: copia una colonna sorgente in una destinazione.
+ * params: source_column, target_column
+ */
+ function routineOpCopy(array &$excelData, array $params): void
+ {
+ $src = routineEngineColToIndex($params['source_column'] ?? 0);
+ $dst = routineEngineColToIndex($params['target_column'] ?? 0);
+ foreach ($excelData as &$row) {
+ if (!isset($row['data']) || !is_array($row['data'])) {
+ continue;
+ }
+ $row['data'][$dst] = $row['data'][$src] ?? '';
+ }
+ unset($row);
+ }
+
+ /**
+ * clear: svuota una o più colonne.
+ * params: columns [] (oppure column singola)
+ */
+ function routineOpClear(array &$excelData, array $params): void
+ {
+ $cols = $params['columns'] ?? (isset($params['column']) ? [$params['column']] : []);
+ $cols = routineEngineColsToIndexes($cols);
+ foreach ($excelData as &$row) {
+ if (!isset($row['data']) || !is_array($row['data'])) {
+ continue;
+ }
+ foreach ($cols as $c) {
+ $row['data'][$c] = '';
+ }
+ }
+ unset($row);
+ }
+
+ /**
+ * trim: rimuove spazi (o caratteri indicati) da una o più colonne.
+ * params: columns [] (o column), chars (opzionale)
+ */
+ function routineOpTrim(array &$excelData, array $params): void
+ {
+ $cols = $params['columns'] ?? (isset($params['column']) ? [$params['column']] : []);
+ $cols = routineEngineColsToIndexes($cols);
+ $chars = $params['chars'] ?? null;
+ foreach ($excelData as &$row) {
+ if (!isset($row['data']) || !is_array($row['data'])) {
+ continue;
+ }
+ foreach ($cols as $c) {
+ $v = (string)($row['data'][$c] ?? '');
+ $row['data'][$c] = $chars === null ? trim($v) : trim($v, $chars);
+ }
+ }
+ unset($row);
+ }
+
+ /**
+ * replace: sostituzione testo in una o più colonne.
+ * params: columns [] (o column), search, replace="", regex=false
+ */
+ function routineOpReplace(array &$excelData, array $params): void
+ {
+ $cols = $params['columns'] ?? (isset($params['column']) ? [$params['column']] : []);
+ $cols = routineEngineColsToIndexes($cols);
+ $search = $params['search'] ?? '';
+ $replace = $params['replace'] ?? '';
+ $regex = $params['regex'] ?? false;
+
+ foreach ($excelData as &$row) {
+ if (!isset($row['data']) || !is_array($row['data'])) {
+ continue;
+ }
+ foreach ($cols as $c) {
+ $v = (string)($row['data'][$c] ?? '');
+ if ($regex) {
+ $out = @preg_replace($search, $replace, $v);
+ $row['data'][$c] = $out === null ? $v : $out;
+ } else {
+ $row['data'][$c] = str_replace($search, $replace, $v);
+ }
+ }
+ }
+ unset($row);
+ }
+
+ /** uppercase: maiuscolo su una o più colonne. params: columns [] (o column) */
+ function routineOpUppercase(array &$excelData, array $params): void
+ {
+ $cols = $params['columns'] ?? (isset($params['column']) ? [$params['column']] : []);
+ $cols = routineEngineColsToIndexes($cols);
+ foreach ($excelData as &$row) {
+ if (!isset($row['data']) || !is_array($row['data'])) {
+ continue;
+ }
+ foreach ($cols as $c) {
+ $row['data'][$c] = mb_strtoupper((string)($row['data'][$c] ?? ''), 'UTF-8');
+ }
+ }
+ unset($row);
+ }
+
+ /** lowercase: minuscolo su una o più colonne. params: columns [] (o column) */
+ function routineOpLowercase(array &$excelData, array $params): void
+ {
+ $cols = $params['columns'] ?? (isset($params['column']) ? [$params['column']] : []);
+ $cols = routineEngineColsToIndexes($cols);
+ foreach ($excelData as &$row) {
+ if (!isset($row['data']) || !is_array($row['data'])) {
+ continue;
+ }
+ foreach ($cols as $c) {
+ $row['data'][$c] = mb_strtolower((string)($row['data'][$c] ?? ''), 'UTF-8');
+ }
+ }
+ unset($row);
+ }
+
+ /**
+ * Entry point richiamato da apply_routine.php.
+ * Stessa firma delle routine legacy.
+ */
+ function applyRoutine(&$excelData, $routineData = [])
+ {
+ $idroutine = isset($routineData['idroutine']) ? (int)$routineData['idroutine'] : 0;
+ if ($idroutine <= 0) {
+ throw new Exception("Motore routine: idroutine mancante in routineData.");
+ }
+
+ $steps = routineEngineLoadSteps($idroutine);
+ if (empty($steps)) {
+ error_log("Motore routine: nessuno step attivo per routine {$idroutine}.");
+ return;
+ }
+
+ // Dispatch table verbo -> handler
+ $handlers = [
+ 'merge' => 'routineOpMerge',
+ 'copy' => 'routineOpCopy',
+ 'clear' => 'routineOpClear',
+ 'trim' => 'routineOpTrim',
+ 'replace' => 'routineOpReplace',
+ 'uppercase' => 'routineOpUppercase',
+ 'lowercase' => 'routineOpLowercase',
+ ];
+
+ foreach ($steps as $i => $step) {
+ $op = $step['operation'];
+ if (!isset($handlers[$op])) {
+ error_log("Motore routine: operazione sconosciuta '{$op}' (routine {$idroutine}, step " . ($i + 1) . ") — saltata.");
+ continue;
+ }
+ $handlers[$op]($excelData, $step['params']);
+ error_log("Motore routine: applicata operazione '{$op}' (routine {$idroutine}, step " . ($i + 1) . ").");
+ }
+
+ error_log("Motore routine {$idroutine} completato (" . count($steps) . " step).");
+ }
+}
diff --git a/public/userarea/schemi_base_response.json b/public/userarea/schemi_base_response.json
index 1178d4ed..bcbfd8dd 100644
--- a/public/userarea/schemi_base_response.json
+++ b/public/userarea/schemi_base_response.json
@@ -748,7 +748,7 @@
{
"IdSchemaCustomFields": 180,
"ConteggioClienti": 0,
- "Nome": "Chloe'",
+ "Nome": "Chlo\u00e9_Leathergoods",
"Descrizione": "\r\n"
},
{
@@ -838,7 +838,7 @@
{
"IdSchemaCustomFields": 195,
"ConteggioClienti": 0,
- "Nome": "Chloe'_APPAREL",
+ "Nome": "Chlo\u00e9_Apparel",
"Descrizione": "Schema aggiornato il 18\/12\/25 secondo modulo excel x codifica Elims\r\nGR"
},
{