Files
zibo-dashboard/public/userarea/import_worksheet_xlsx.php
2026-10-26 13:56:01 +01:00

643 lines
24 KiB
PHP

<?php
/**
* import_worksheet_xlsx.php
* -------------------------------------------------------------------------
* Riceve un file .xlsx (modulo ZIBOGOMMA "FOGLIO DI LAVORO") via POST
* multipart (campo file: "xlsx_file") e restituisce una PROPOSTA in JSON
* pronta per essere revisionata dall'utente in manage-worksheet.php.
*
* NON scrive nulla sul database: fa solo lettura (mescole, packaging_items)
* per proporre gli abbinamenti. Il salvataggio resta agli endpoint esistenti.
*
* Dipendenze: solo estensioni PHP standard (zip, xml/simplexml). Niente Composer.
* -------------------------------------------------------------------------
*/
ini_set('display_errors', 0);
error_reporting(E_ALL);
while (ob_get_level()) {
ob_end_clean();
}
header('Content-Type: application/json; charset=utf-8');
// Connessione DB (stesso pattern di manage-worksheet.php)
include('include/headscript.php');
require __DIR__ . '/../../vendor/autoload.php';
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Shared\Date as XlsDate;
function fail($msg)
{
echo json_encode(['success' => false, 'message' => $msg], JSON_UNESCAPED_UNICODE);
exit;
}
/* =========================================================================
* 1) READER XLSX (senza dipendenze)
* ========================================================================= */
function excel_serial_to_date($serial)
{
$days = (int)floor((float)$serial);
$base = new DateTime('1899-12-30');
$base->modify("+{$days} day");
return $base->format('Y-m-d');
}
function read_xlsx_grid($path)
{
try {
$reader = IOFactory::createReader('Xlsx');
// niente setReadDataOnly(true): serve il formato per riconoscere le date
$spreadsheet = $reader->load($path);
} catch (\Throwable $e) {
throw new Exception('Impossibile leggere il file .xlsx: ' . $e->getMessage());
}
$sheet = $spreadsheet->getSheet(0); // primo foglio
$grid = [];
foreach ($sheet->getRowIterator() as $row) {
$rowN = $row->getRowIndex();
$it = $row->getCellIterator();
$it->setIterateOnlyExistingCells(true);
foreach ($it as $c) {
$colL = $c->getColumn(); // lettera colonna, es. "D"
// IMPORTANTE: nel modulo ZIBOGOMMA quasi tutte le celle sono formule
// che puntano a un file esterno ('[1] Base2'!...). Il valore reale sta
// nella CACHE della cella. Perciò:
// - cella formula -> getOldCalculatedValue() (valore in cache, NO ricalcolo)
// - cella normale -> getValue()
$val = $c->isFormula() ? $c->getOldCalculatedValue() : $c->getValue();
if ($val === null || $val === '') continue;
if (XlsDate::isDateTime($c) && is_numeric($val)) {
$val = XlsDate::excelToDateTimeObject((float)$val)->format('Y-m-d');
} elseif (is_numeric($val)) {
$val = $val + 0;
}
$grid[$rowN][$colL] = $val;
}
}
return $grid;
}
/* =========================================================================
* 2) ESTRATTORE label-anchored (ancorato alle etichette in colonna A)
* ========================================================================= */
function norm($s)
{
$s = (string)$s;
$s = str_replace(
['à', 'è', 'é', 'ì', 'ò', 'ù', 'À', 'È', 'É', 'Ì', 'Ò', 'Ù'],
['a', 'e', 'e', 'i', 'o', 'u', 'a', 'e', 'e', 'i', 'o', 'u'],
$s
);
$s = strtolower($s);
$s = preg_replace('/[^a-z0-9\/ ]+/', ' ', $s);
$s = preg_replace('/\s+/', ' ', $s);
return trim($s);
}
function cell($grid, $row, $col)
{
return isset($grid[$row][$col]) ? trim((string)$grid[$row][$col]) : '';
}
function row_by_label($grid, $label, $col = 'A')
{
$target = norm($label);
foreach ($grid as $rn => $cols) {
$a = norm($cols[$col] ?? '');
if ($a === $target || ($target !== '' && strpos($a, $target) === 0)) return $rn;
}
return null;
}
function val_at_label($grid, $label, $valcol = 'D')
{
$r = row_by_label($grid, $label);
return $r ? cell($grid, $r, $valcol) : '';
}
function is_known_label($a)
{
$known = [
'foglio di lavoro',
'data',
'cliente',
'tipo profilo',
'peso a metro calcolato g/m',
'nome mescola in entrata',
'densita richiesta g/cm3',
'durezza richiesta shore a',
'marchiatura',
'lubrificazione',
'impostazione misure per il controllo della produzione',
'impostazione lunghezza spezzoni',
'frequenza di controllo',
'frequenza di controllo disegno',
'frequenza di controllo in dima',
'modalita di controllo in dima',
'confezione richiesta',
'metri/confezione',
'tipo scatola',
'n confezioni o pezzi per scatola',
'metri/scatola',
'tipo di bancale',
'n confezioni o scatole per bancale',
'velocita di produzione'
];
foreach ($known as $k) if (strpos($a, $k) === 0) return true;
return false;
}
function get_freq_cut($grid)
{
foreach ($grid as $rn => $cols) {
if (norm($cols['A'] ?? '') === 'frequenza di controllo') return cell($grid, $rn, 'D');
}
return '';
}
function num_or_null($v)
{
if ($v === '' || $v === null) return null;
return is_numeric($v) ? $v + 0 : null;
}
function dotdec_to_comma($v)
{
// 1.2 -> 1,2 ma lascia intatto "+/-0,02"
return preg_replace('/(\d)\.(\d)/', '$1,$2', (string)$v);
}
/* ---- sezione "Imballo e confezionamento" ---- */
/** primo numero intero della cella ("80", "160 m", "12 o 14" -> 12) */
function first_int($v)
{
if (is_int($v) || is_float($v)) return (int)round($v);
if (preg_match('/\d+(?:[.,]\d+)?/', (string)$v, $m)) return (int)round((float)str_replace(',', '.', $m[0]));
return null;
}
/** la cella ha più numeri alternativi? ("12 o 14") */
function has_multi_num($v)
{
return preg_match_all('/\d+/', (string)$v) > 1;
}
function is_tolerance_txt($v)
{
$v = strtolower((string)$v);
return strpos($v, 'toll') !== false || preg_match('/[+-]\s*\d/', $v);
}
function fmt_num($n)
{
$f = (float)$n;
return (floor($f) == $f) ? (string)(int)$f : rtrim(rtrim(number_format($f, 2, '.', ''), '0'), '.');
}
/** "Toll Metrat. + 1,5m/-0" -> "-0/+1.5m" "+/- 4m" -> "+/- 4m" */
function parse_tolerance($v)
{
$s = str_replace(',', '.', (string)$v);
$num = '(\d+(?:\.\d+)?)';
if (preg_match('/\+\s*\/\s*-\s*' . $num . '/', $s, $m) || preg_match('/±\s*' . $num . '/u', $s, $m)) {
return '+/- ' . fmt_num($m[1]) . 'm';
}
$plus = preg_match('/\+\s*' . $num . '/', $s, $mp) ? fmt_num($mp[1]) : null;
$minus = preg_match('/-\s*' . $num . '/', $s, $mm) ? fmt_num($mm[1]) : null;
if ($plus !== null || $minus !== null) return '-' . ($minus ?? '0') . '/+' . ($plus ?? '0') . 'm';
return trim((string)$v); // formato non riconosciuto: testo originale
}
function extract_packaging($grid)
{
$out = [
'meters_per_package' => null,
'meters_per_package_tolerance' => '',
'meters_per_package_notes' => '',
'packages_or_pieces_per_box' => null,
'meters_per_box' => null,
'boxes_or_packages_per_pallet' => null,
];
$notes = [];
// metri/confezione: valore in D, note in E, tolleranza in E o nelle righe sotto senza etichetta
$r = row_by_label($grid, 'metri/confezione');
if ($r) {
$d = cell($grid, $r, 'D');
$out['meters_per_package'] = first_int($d);
$cand = [cell($grid, $r, 'E')];
for ($i = $r + 1; $i <= $r + 3 && norm(cell($grid, $i, 'A')) === ''; $i++) {
$cand[] = cell($grid, $i, 'D');
$cand[] = cell($grid, $i, 'E');
}
if (is_tolerance_txt($d) && has_multi_num($d)) $cand[] = $d; // "80 +1,5/-0" nella stessa cella
foreach ($cand as $c) {
if ($c === '') continue;
if ($out['meters_per_package_tolerance'] === '' && is_tolerance_txt($c)) {
$out['meters_per_package_tolerance'] = parse_tolerance($c);
} elseif (!is_tolerance_txt($c)) {
$notes[] = $c; // es. "40x2"
}
}
}
$v = val_at_label($grid, 'n confezioni o pezzi per scatola', 'D');
$out['packages_or_pieces_per_box'] = first_int($v);
if (has_multi_num($v)) $notes[] = 'Conf. x scatola ' . $v;
$out['meters_per_box'] = first_int(val_at_label($grid, 'metri/scatola', 'D'));
// "12 o 14": nel campo numerico va 12, il testo completo nelle note
$v = val_at_label($grid, 'n confezioni o scatole per bancale', 'D');
$out['boxes_or_packages_per_pallet'] = first_int($v);
if (has_multi_num($v)) $notes[] = 'Scatole x Bancale ' . $v;
$out['meters_per_package_notes'] = implode('. ', array_unique($notes));
return $out;
}
function extract_fields($grid)
{
$out = [];
$wd = val_at_label($grid, 'data', 'D');
if ($wd !== '' && is_numeric($wd)) $wd = excel_serial_to_date($wd);
$out['worksheet_date'] = $wd;
$out['customer_name'] = val_at_label($grid, 'cliente', 'D');
$out['profile_type_code'] = val_at_label($grid, 'tipo profilo', 'D');
$out['marking'] = strtoupper(val_at_label($grid, 'marchiatura', 'D'));
// prod_control: riga "impostazione misure..." + continuazione senza label + "impostazione lunghezza spezzoni"
$lines = [];
$r = row_by_label($grid, 'impostazione misure per il controllo');
if ($r) {
for ($i = $r; $i <= $r + 1; $i++) {
$d = cell($grid, $i, 'D');
$e = cell($grid, $i, 'E');
if ($d !== '' && ($i === $r || norm(cell($grid, $i, 'A')) === '')) {
$lines[] = trim($d . ' ' . $e);
}
}
}
$rl = row_by_label($grid, 'impostazione lunghezza spezzoni');
if ($rl) {
$d = cell($grid, $rl, 'D');
$e = cell($grid, $rl, 'E');
if ($d !== '') $lines[] = trim($d . ' ' . $e);
}
$out['prod_control_measure_settings'] = implode("\n", $lines);
// lookup da evidenziare (RAW, value scelto dall'utente)
$out['raw_freq_cut'] = get_freq_cut($grid);
$out['raw_freq_drawing'] = val_at_label($grid, 'frequenza di controllo disegno', 'D');
$out['raw_freq_jig'] = val_at_label($grid, 'frequenza di controllo in dima', 'D');
$out['raw_mode_jig'] = val_at_label($grid, 'modalita di controllo in dima', 'D');
// mescola
$out['mescola_raw'] = val_at_label($grid, 'nome mescola in entrata', 'D');
$out['mix_weight_g_m'] = num_or_null(val_at_label($grid, 'peso a metro calcolato', 'D'));
$rd = row_by_label($grid, 'densita richiesta');
$out['required_density'] = $rd ? dotdec_to_comma(trim(cell($grid, $rd, 'D') . ' ' . cell($grid, $rd, 'E'))) : '';
$rh = row_by_label($grid, 'durezza richiesta');
$out['required_hardness_shore_a'] = $rh ? dotdec_to_comma(trim(cell($grid, $rh, 'D') . ' ' . cell($grid, $rh, 'E'))) : '';
$out['lubrication_type'] = strtoupper(val_at_label($grid, 'lubrificazione', 'D'));
// packaging (3 celle contenitore -> 3 righe)
$out['pkg_confezione_raw'] = val_at_label($grid, 'confezione richiesta', 'D');
$out['pkg_scatola_raw'] = val_at_label($grid, 'tipo scatola', 'D');
$out['pkg_bancale_raw'] = val_at_label($grid, 'tipo di bancale', 'D');
// metri/confezione, tolleranza, note, pezzi per scatola, metri/scatola, scatole per bancale
$out = array_merge($out, extract_packaging($grid));
// velocità
$out['speed_expected_kg_h'] = null;
$out['speed_expected_m_h'] = null;
$rv = row_by_label($grid, 'velocita di produzione');
if ($rv) {
$v = num_or_null(cell($grid, $rv, 'C'));
$out['speed_expected_kg_h'] = $v === null ? null : round($v, 2);
for ($i = $rv; $i <= $rv + 2; $i++) {
if (norm(cell($grid, $i, 'B')) === 'm/h') {
$v = num_or_null(cell($grid, $i, 'C'));
$out['speed_expected_m_h'] = $v === null ? null : round($v, 2);
break;
}
}
}
// note: annotazioni libere colonna E + righe istruzione in fondo
$noteBits = [];
// (metri/confezione colonna E ora va in "Note metri/confezione")
foreach (['confezione richiesta', 'metri/scatola'] as $lbl) {
$rr = row_by_label($grid, $lbl);
if ($rr && cell($grid, $rr, 'E') !== '') $noteBits[] = cell($grid, $rr, 'E');
}
foreach ($grid as $rn => $cols) {
$a = norm($cols['A'] ?? '');
if ($a !== '' && cell($grid, $rn, 'D') === '' && cell($grid, $rn, 'C') === '' && !is_known_label($a)) {
if (strlen($a) > 12) $noteBits[] = trim((string)$cols['A']);
}
}
$out['notes'] = implode("\n", array_values(array_unique(array_filter($noteBits))));
return $out;
}
/* =========================================================================
* 3) MATCHER su DB
* ========================================================================= */
function strip_code($s)
{
$s = strtolower((string)$s);
$s = str_replace(['à', 'è', 'é', 'ì', 'ò', 'ù'], ['a', 'e', 'e', 'i', 'o', 'u'], $s);
return preg_replace('/[^a-z0-9]+/', '', $s); // solo lettere + cifre (ignora spazi e punteggiatura)
}
/** porzione iniziale prima del primo separatore descrittivo (es. "ATES 71 - EPDM..." -> "ATES 71") */
function raw_head($s)
{
$parts = preg_split('/\s*[-–—|(:,]\s*/u', (string)$s, 2);
return trim($parts[0] ?? '');
}
function match_mescola($raw, array $mescole)
{
if (trim((string)$raw) === '') return [null, 'none', null];
$rawStrip = strip_code($raw);
$headStrip = strip_code(raw_head($raw));
// 1) uguaglianza ignorando spazi/punteggiatura, sul nome intero (gestisce "ATES 71" = "ATES71")
foreach ($mescole as $m) if ($rawStrip !== '' && strip_code($m['nome']) === $rawStrip) return [(int)$m['id'], 'exact', $m['nome']];
// 2) parte di nome composito (a / b / c)
foreach ($mescole as $m) foreach (explode('/', $m['nome']) as $p) if ($rawStrip !== '' && strip_code($p) === $rawStrip) return [(int)$m['id'], 'exact-part', $m['nome']];
// 3) nome in uscita
foreach ($mescole as $m) if ($rawStrip !== '' && strip_code($m['nomeuscita'] ?? '') === $rawStrip) return [(int)$m['id'], 'exact-uscita', $m['nome']];
// 4) codice iniziale prima di " - descrizione" (gestisce "ATES 71 - EPDM...")
if ($headStrip !== '' && $headStrip !== $rawStrip) {
foreach ($mescole as $m) if (strip_code($m['nome']) === $headStrip) return [(int)$m['id'], 'code', $m['nome']];
foreach ($mescole as $m) foreach (explode('/', $m['nome']) as $p) if (strip_code($p) === $headStrip) return [(int)$m['id'], 'code-part', $m['nome']];
}
return [null, 'none', null];
}
function dim_tokens($s)
{
$s = strtolower((string)$s);
$s = str_replace(',', '.', $s);
preg_match_all('/\d+(?:\.\d+)?/', $s, $m);
$nums = $m[0];
if (count($nums) < 2) return [];
$forms = [];
$forms[] = implode('x', $nums);
$mm = array_map(function ($n) {
$f = (float)$n;
if ($f < 10 && strpos($n, '.') !== false) return (string)round($f * 1000);
return (string)($f == (int)$f ? (int)$f : $f);
}, $nums);
$forms[] = implode('x', $mm);
return array_values(array_unique($forms));
}
function match_packaging($raw, array $packaging, $preferCat = null)
{
$rawStrip = strip_code($raw);
if ($rawStrip === '') return [null, 'none', null, null, true];
// 1) nome/codice ESATTO (categoria solo come spareggio) -> sicuro
$exact = [];
foreach ($packaging as $p) {
if (strip_code($p['item_code']) === $rawStrip || strip_code($p['item_name']) === $rawStrip) $exact[] = $p;
}
if ($exact) {
$pick = $exact[0];
foreach ($exact as $p) if ($preferCat && $p['category'] === $preferCat) {
$pick = $p;
break;
}
return [(int)$pick['id'], 'name-code', $pick['item_name'], $pick['category'], false];
}
// 2) DIMENSIONI -> sicuro se unico, proposta se ambiguo
$rd = dim_tokens($raw);
if ($rd) {
$dm = [];
foreach ($packaging as $p) {
$pf = array_merge(dim_tokens($p['item_name']), dim_tokens($p['item_code']));
if (array_intersect($rd, $pf)) $dm[] = $p;
}
if ($dm) {
$pick = $dm[0];
foreach ($dm as $p) if ($preferCat && $p['category'] === $preferCat) {
$pick = $p;
break;
}
$ambiguous = count($dm) > 1;
return [(int)$pick['id'], 'dimension', $pick['item_name'], $pick['category'], $ambiguous];
}
}
// 3) ABBREVIAZIONE: testo corto, prefisso UNICO di un nome -> solo proposta
if (strlen($rawStrip) <= 4) {
$cand = [];
foreach ($packaging as $p) if (strpos(strip_code($p['item_name']), $rawStrip) === 0) $cand[] = $p;
if (count($cand) === 1) return [(int)$cand[0]['id'], 'abbrev', $cand[0]['item_name'], $cand[0]['category'], true];
}
return [null, 'none', null, null, true];
}
/* ---- match generico su dropdown (ws_lookup_options) ---- */
function lk_norm($s)
{
$s = (string)$s;
$s = str_replace(
['à', 'è', 'é', 'ì', 'ò', 'ù', 'À', 'È', 'É', 'Ì', 'Ò', 'Ù'],
['a', 'e', 'e', 'i', 'o', 'u', 'a', 'e', 'e', 'i', 'o', 'u'],
$s
);
$s = strtolower($s);
$s = preg_replace('/[^a-z0-9 ]+/', ' ', $s);
$s = preg_replace('/\s+/', ' ', $s);
return trim($s);
}
function lk_toks($s)
{
$t = array_filter(explode(' ', lk_norm($s)), fn($x) => $x !== '');
return array_values(array_unique($t));
}
/** abbina il testo Excel a una voce del dropdown. Ritorna [value|null, label|null, confidence] */
function match_lookup($raw, array $options)
{
$r = lk_norm($raw);
if ($r === '') return [null, null, 'none'];
// tier 1: uguale a value o label
foreach ($options as $o) {
if (lk_norm($o['value']) === $r || lk_norm($o['label']) === $r) {
return [$o['value'], $o['label'], 'exact'];
}
}
// tier 2: sovrapposizione token >= 0.8 (set più piccolo di almeno 2 token)
$rt = lk_toks($raw);
$best = null;
$bestScore = 0.0;
foreach ($options as $o) {
$ot = lk_toks($o['label']);
if (count($ot) < 2 || count($rt) < 2) continue;
$score = count(array_intersect($rt, $ot)) / min(count($rt), count($ot));
if ($score > $bestScore) {
$bestScore = $score;
$best = $o;
}
}
if ($best && $bestScore >= 0.8) return [$best['value'], $best['label'], 'fuzzy'];
return [null, null, 'none'];
}
/** costruisce la voce JSON per un lookup di testata */
function build_lookup($raw, array $options)
{
[$v, $lab, $conf] = match_lookup($raw, $options);
return [
'raw' => $raw,
'value' => $v, // null = nessun suggerimento
'label' => $lab,
'confidence' => $conf,
'needs_review' => ($raw !== ''), // sempre da verificare se c'era un valore nel file
];
}
/* =========================================================================
* 4) MAIN
* ========================================================================= */
try {
if ($_SERVER['REQUEST_METHOD'] !== 'POST' || !isset($_FILES['xlsx_file'])) {
fail('Nessun file ricevuto.');
}
$file = $_FILES['xlsx_file'];
if ($file['error'] !== UPLOAD_ERR_OK) {
fail('Errore nel caricamento del file (codice ' . $file['error'] . ').');
}
if ($file['size'] > 5 * 1024 * 1024) {
fail('File troppo grande (max 5 MB).');
}
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
if ($ext !== 'xlsx') {
fail('Formato non supportato: carica un file .xlsx.');
}
$db = DBHandlerSelect::getInstance();
$pdo = $db->getConnection();
$grid = read_xlsx_grid($file['tmp_name']);
$fields = extract_fields($grid);
$warnings = [];
// enum di sicurezza
if ($fields['marking'] !== '' && !in_array($fields['marking'], ['S', 'N'], true)) {
$warnings[] = "Marchiatura '{$fields['marking']}' non riconosciuta (attese S/N).";
$fields['marking'] = '';
}
if ($fields['lubrication_type'] !== '' && !in_array($fields['lubrication_type'], ['S', 'G', 'N'], true)) {
$warnings[] = "Lubrificazione '{$fields['lubrication_type']}' non riconosciuta (attese S/G/N).";
$fields['lubrication_type'] = '';
}
// carica tabelle di riferimento
$mescole = $pdo->query("SELECT id, nome, nomeuscita FROM mescole WHERE is_active = 1")->fetchAll(PDO::FETCH_ASSOC);
$packaging = $pdo->query("SELECT id, category, item_name, item_code FROM packaging_items WHERE is_active = 1")->fetchAll(PDO::FETCH_ASSOC);
// opzioni dei dropdown (ws_lookup_options) raggruppate per categoria
$lookupOpts = [];
foreach ($pdo->query("SELECT category, value, label FROM ws_lookup_options WHERE is_active = 1 ORDER BY sort_order ASC")->fetchAll(PDO::FETCH_ASSOC) as $o) {
$lookupOpts[$o['category']][] = $o;
}
// --- mescola (1 riga) ---
[$mid, $mconf, $mname] = match_mescola($fields['mescola_raw'], $mescole);
$mescoleOut = [];
if ($fields['mescola_raw'] !== '') {
$mescoleOut[] = [
'raw' => $fields['mescola_raw'],
'idmescola' => $mid,
'matched_name' => $mname,
'confidence' => $mconf,
'needs_review' => ($mid === null || $mconf === 'contains'),
'mix_weight_g_m' => $fields['mix_weight_g_m'],
'required_density' => $fields['required_density'],
'required_hardness_shore_a' => $fields['required_hardness_shore_a'],
'lubrication_type' => $fields['lubrication_type'],
];
}
// --- packaging (3 celle contenitore -> righe) ---
$packagesOut = [];
$pkgSpecs = [
['source' => 'Confezione richiesta', 'raw' => $fields['pkg_confezione_raw'], 'cat' => null],
['source' => 'Tipo scatola', 'raw' => $fields['pkg_scatola_raw'], 'cat' => 'BOX'],
['source' => 'Tipo di bancale', 'raw' => $fields['pkg_bancale_raw'], 'cat' => 'PALLET'],
];
foreach ($pkgSpecs as $spec) {
if ($spec['raw'] === '') continue;
[$pid, $pconf, $pname, $pcat, $pneeds] = match_packaging($spec['raw'], $packaging, $spec['cat']);
$packagesOut[] = [
'source' => $spec['source'],
'raw' => $spec['raw'],
'package_code' => $pid,
'matched_name' => $pname,
'category' => $pcat,
'confidence' => $pconf,
'needs_review' => ($pid === null) ? true : $pneeds,
];
}
// --- risposta ---
echo json_encode([
'success' => true,
'header' => [
'worksheet_date' => $fields['worksheet_date'],
'customer_name' => $fields['customer_name'],
'profile_type_code' => $fields['profile_type_code'],
'marking' => $fields['marking'],
'prod_control_measure_settings' => $fields['prod_control_measure_settings'],
'meters_per_package' => $fields['meters_per_package'],
'meters_per_package_tolerance' => $fields['meters_per_package_tolerance'],
'meters_per_package_notes' => $fields['meters_per_package_notes'],
'meters_per_box' => $fields['meters_per_box'],
'packages_or_pieces_per_box' => $fields['packages_or_pieces_per_box'],
'boxes_or_packages_per_pallet' => $fields['boxes_or_packages_per_pallet'],
'speed_expected_kg_h' => $fields['speed_expected_kg_h'],
'speed_expected_m_h' => $fields['speed_expected_m_h'],
'notes' => $fields['notes'],
],
// lookup da EVIDENZIARE: value = null, l'utente sceglie dal menu
'header_lookups' => [
'control_frequency_cut' => build_lookup($fields['raw_freq_cut'], $lookupOpts['control_frequency_cut'] ?? []),
'control_frequency_drawing' => build_lookup($fields['raw_freq_drawing'], $lookupOpts['control_frequency_drawing'] ?? []),
'control_frequency_jig' => build_lookup($fields['raw_freq_jig'], $lookupOpts['control_frequency_jig'] ?? []),
'control_mode_jig' => build_lookup($fields['raw_mode_jig'], $lookupOpts['control_mode_jig'] ?? []),
],
'mescole' => $mescoleOut,
'packages' => $packagesOut,
'warnings' => $warnings,
], JSON_UNESCAPED_UNICODE);
exit;
} catch (Exception $e) {
fail($e->getMessage());
}