Benjamin Harris 2 месяцев назад
Родитель
Сommit
8a7bbc6533

BIN
books/Hill-Laboratories_Field-Consultants-Guide_Soil_Plant-Analysis.pdf


BIN
books/hill-labs.pdf → books/The-anatomy-of-Life_Energy-in-agriculture.pdf


BIN
books/ancient-mysteries-modern-visions-the-magnetic-life-of-agriculture_compress.pdf


BIN
books/nature.pdf


+ 0 - 0
books/nutrition-rulespdf_compress.pdf → books/nutrition-rules.pdf


BIN
books/callahan-paramagnetismpdf_compress.pdf → books/paramagnetism_philip-callahan.pdf


BIN
books/tuning-in-to-nature.pdf


+ 241 - 0
client-assets/js/soil-import.js

@@ -0,0 +1,241 @@
+/**
+ * client-assets/js/soil-import.js
+ *
+ * Handles the lab spreadsheet import flow on the soil test entry page.
+ *
+ * Flow:
+ *  1. User picks a file → "Analyse File" button enabled
+ *  2. File is sent to soilImportController.php?action=parse
+ *  3. If one sample:  auto-import it
+ *     If many samples: show a sample picker table
+ *  4. User picks a sample → sent to soilImportController.php?action=import
+ *  5. Mapped values are filled into the soil analysis form
+ */
+
+(function () {
+    'use strict';
+
+    // ── DOM refs ──────────────────────────────────────────────────────────────
+
+    const fileInput      = document.getElementById('lab-file-input');
+    const analyseBtn     = document.getElementById('lab-analyse-btn');
+    const importStatus   = document.getElementById('import-status');
+    const samplePanel    = document.getElementById('sample-picker-panel');
+    const sampleTable    = document.getElementById('sample-picker-table');
+    const sampleTbody    = sampleTable ? sampleTable.querySelector('tbody') : null;
+    const importProgress = document.getElementById('import-progress');
+
+    if (!fileInput || !analyseBtn) return; // not on the right page
+
+    // ── helpers ───────────────────────────────────────────────────────────────
+
+    function showStatus(msg, type = 'info') {
+        if (!importStatus) return;
+        importStatus.className = `alert alert-${type} mt-2`;
+        importStatus.textContent = msg;
+        importStatus.hidden = false;
+    }
+
+    function hideStatus() {
+        if (importStatus) importStatus.hidden = true;
+    }
+
+    function setProgress(visible, text = '') {
+        if (!importProgress) return;
+        importProgress.hidden = !visible;
+        if (text) importProgress.querySelector('.progress-label').textContent = text;
+    }
+
+    function buildFormData(file, action, extraFields = {}) {
+        const fd = new FormData();
+        fd.append('file', file);
+        fd.append('action', action);
+        for (const [k, v] of Object.entries(extraFields)) {
+            fd.append(k, v);
+        }
+        return fd;
+    }
+
+    async function postToController(formData) {
+        const resp = await fetch('/controllers/soilImportController.php', {
+            method: 'POST',
+            body: formData,
+        });
+        if (!resp.ok) {
+            throw new Error(`Server error ${resp.status}`);
+        }
+        return resp.json();
+    }
+
+    // ── form population ───────────────────────────────────────────────────────
+
+    function populateForm(fields) {
+        let filled = 0;
+        let skipped = 0;
+
+        for (const [fieldName, value] of Object.entries(fields)) {
+            if (value === null || value === undefined || value === '') continue;
+
+            const el = document.getElementById(fieldName)
+                    || document.querySelector(`[name="${fieldName}"]`);
+
+            if (!el) { skipped++; continue; }
+
+            if (el.tagName === 'SELECT') {
+                // Try to find a matching option (case-insensitive)
+                const val = String(value).toLowerCase();
+                for (const opt of el.options) {
+                    if (opt.value.toLowerCase() === val || opt.text.toLowerCase() === val) {
+                        el.value = opt.value;
+                        filled++;
+                        break;
+                    }
+                }
+            } else {
+                el.value = value;
+                el.dispatchEvent(new Event('input', { bubbles: true }));
+                filled++;
+            }
+        }
+
+        return { filled, skipped };
+    }
+
+    // Highlight fields that were auto-filled so the user can review them
+    function highlightFilledFields(fields) {
+        for (const fieldName of Object.keys(fields)) {
+            if (fields[fieldName] === null || fields[fieldName] === '') continue;
+            const el = document.getElementById(fieldName)
+                     || document.querySelector(`[name="${fieldName}"]`);
+            if (el) {
+                el.classList.add('border-success', 'bg-success-subtle');
+            }
+        }
+    }
+
+    // ── sample picker ─────────────────────────────────────────────────────────
+
+    function renderSamplePicker(samples, file) {
+        if (!samplePanel || !sampleTbody) return;
+
+        sampleTbody.innerHTML = '';
+
+        samples.forEach((s) => {
+            const tr = document.createElement('tr');
+            tr.innerHTML = `
+                <td>${escHtml(s.lab_id)}</td>
+                <td>${escHtml(s.client)}</td>
+                <td>${escHtml(s.site)}</td>
+                <td>${escHtml(s.crop)}</td>
+                <td>
+                    <button class="btn btn-sm btn-success"
+                            data-idx="${s.idx}"
+                            type="button">
+                        Import
+                    </button>
+                </td>`;
+            sampleTbody.appendChild(tr);
+        });
+
+        // Wire import buttons
+        sampleTbody.querySelectorAll('button[data-idx]').forEach((btn) => {
+            btn.addEventListener('click', async () => {
+                const idx = btn.dataset.idx;
+                await importSample(file, idx);
+            });
+        });
+
+        samplePanel.hidden = false;
+        samplePanel.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
+    }
+
+    function escHtml(str) {
+        const d = document.createElement('div');
+        d.textContent = str || '';
+        return d.innerHTML;
+    }
+
+    // ── import a specific sample ──────────────────────────────────────────────
+
+    async function importSample(file, sampleIdx) {
+        setProgress(true, 'Mapping fields with AI…');
+        hideStatus();
+
+        try {
+            const fd   = buildFormData(file, 'import', { sample_idx: sampleIdx });
+            const data = await postToController(fd);
+
+            if (!data.success) {
+                showStatus('Import failed: ' + data.error, 'danger');
+                return;
+            }
+
+            const { filled, skipped } = populateForm(data.fields);
+            highlightFilledFields(data.fields);
+
+            if (samplePanel) samplePanel.hidden = true;
+
+            const method  = data.method === 'ai' ? 'AI-assisted' : 'pattern-matched';
+            const warning = data.warning ? ` (${data.warning})` : '';
+            showStatus(
+                `${filled} field${filled !== 1 ? 's' : ''} populated via ${method}${warning}. ` +
+                `Please review and adjust before submitting.`,
+                data.warning ? 'warning' : 'success'
+            );
+
+            // Scroll to the form
+            document.getElementById('SoilcsvForm')?.scrollIntoView({ behavior: 'smooth' });
+
+        } catch (err) {
+            showStatus('Error: ' + err.message, 'danger');
+        } finally {
+            setProgress(false);
+        }
+    }
+
+    // ── main: analyse file ────────────────────────────────────────────────────
+
+    fileInput.addEventListener('change', () => {
+        analyseBtn.disabled = !fileInput.files.length;
+        hideStatus();
+        if (samplePanel) samplePanel.hidden = true;
+    });
+
+    analyseBtn.addEventListener('click', async () => {
+        const file = fileInput.files[0];
+        if (!file) return;
+
+        setProgress(true, 'Parsing file…');
+        hideStatus();
+        if (samplePanel) samplePanel.hidden = true;
+        analyseBtn.disabled = true;
+
+        try {
+            const fd   = buildFormData(file, 'parse');
+            const data = await postToController(fd);
+
+            if (!data.success) {
+                showStatus('Parse failed: ' + data.error, 'danger');
+                return;
+            }
+
+            if (data.count === 1) {
+                // Single sample — go straight to import
+                await importSample(file, 0);
+            } else {
+                showStatus(
+                    `Found ${data.count} samples. Select the one you want to import.`,
+                    'info'
+                );
+                renderSamplePicker(data.samples, file);
+            }
+
+        } catch (err) {
+            showStatus('Error reading file: ' + err.message, 'danger');
+        } finally {
+            setProgress(false);
+            analyseBtn.disabled = false;
+        }
+    });
+
+})();

+ 2 - 1
composer.json

@@ -3,6 +3,7 @@
         "phpmailer/phpmailer": "^6.9",
         "smalot/pdfparser": "^2.0",
         "erusev/parsedown": "^1.7",
-        "daandesmedt/php-headless-chrome": "^1.1"
+        "daandesmedt/phpheadlesschrome": "^1.1",
+        "phpoffice/phpspreadsheet": "^3.0"
     }
 }

+ 12 - 0
config/ai.php

@@ -0,0 +1,12 @@
+<?php
+/**
+ * config/ai.php
+ *
+ * Ollama LLM configuration shared by controllers that need AI inference.
+ * Matches the setup used in controllers/ollamaGenerate.php.
+ */
+
+define('OLLAMA_HOST',       'http://192.168.8.73:11434');
+define('OLLAMA_MODEL',      'llama3.1:8b-instruct-q4_K_M');
+define('OLLAMA_TIMEOUT',    60);   // seconds — field mapping is fast
+define('OLLAMA_TEMPERATURE', 0.1); // low temp for deterministic JSON output

+ 395 - 0
controllers/soilImportController.php

@@ -0,0 +1,395 @@
+<?php
+/**
+ * controllers/soilImportController.php
+ *
+ * Handles XLS/XLSX/CSV upload from soil labs, parses the file with
+ * PhpSpreadsheet, then uses the local Ollama LLM to map lab-specific
+ * column headers to the soil_records database fields.
+ *
+ * POST /controllers/soilImportController.php
+ *   Accepts multipart/form-data with:
+ *     file       — the uploaded spreadsheet
+ *     action     — "parse"  → return list of samples found in the file
+ *                  "import" → return mapped field values for one sample
+ *     sample_idx — (import only) 0-based index of the sample to import
+ */
+
+require_once __DIR__ . '/../config/database.php';
+require_once __DIR__ . '/../config/ai.php';
+require_once __DIR__ . '/../lib/auth.php';
+
+if (session_status() === PHP_SESSION_NONE) {
+    session_start();
+}
+
+requireLogin();
+
+header('Content-Type: application/json');
+
+// ─── helpers ─────────────────────────────────────────────────────────────────
+
+function jsonError(string $message, int $code = 400): never
+{
+    http_response_code($code);
+    echo json_encode(['success' => false, 'error' => $message]);
+    exit;
+}
+
+function jsonOk(array $data): never
+{
+    echo json_encode(['success' => true, ...$data]);
+    exit;
+}
+
+// ─── request validation ───────────────────────────────────────────────────────
+
+if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
+    jsonError('Method not allowed', 405);
+}
+
+$action = $_POST['action'] ?? 'parse';
+if (!in_array($action, ['parse', 'import'], true)) {
+    jsonError('Invalid action');
+}
+
+// ─── file handling ────────────────────────────────────────────────────────────
+
+if (empty($_FILES['file']) || $_FILES['file']['error'] !== UPLOAD_ERR_OK) {
+    jsonError('No file uploaded or upload error: ' . ($_FILES['file']['error'] ?? 'missing'));
+}
+
+$uploadedFile = $_FILES['file'];
+$ext          = strtolower(pathinfo($uploadedFile['name'], PATHINFO_EXTENSION));
+
+if (!in_array($ext, ['xls', 'xlsx', 'csv', 'ods'], true)) {
+    jsonError('Unsupported file type. Please upload XLS, XLSX, CSV, or ODS.');
+}
+
+// ─── PhpSpreadsheet ───────────────────────────────────────────────────────────
+
+$autoloadPaths = [
+    __DIR__ . '/../vendor/autoload.php',
+    __DIR__ . '/../../vendor/autoload.php',
+];
+$autoloaded = false;
+foreach ($autoloadPaths as $path) {
+    if (file_exists($path)) {
+        require_once $path;
+        $autoloaded = true;
+        break;
+    }
+}
+if (!$autoloaded) {
+    jsonError('PhpSpreadsheet not installed. Run: composer require phpoffice/phpspreadsheet', 500);
+}
+
+use PhpOffice\PhpSpreadsheet\IOFactory;
+use PhpOffice\PhpSpreadsheet\Spreadsheet;
+
+try {
+    $spreadsheet = IOFactory::load($uploadedFile['tmp_name']);
+} catch (\Exception $e) {
+    jsonError('Could not read file: ' . $e->getMessage());
+}
+
+$sheet = $spreadsheet->getActiveSheet();
+
+// Convert sheet to a 2-D array (1-indexed rows and cols → 0-indexed)
+$rawData = [];
+foreach ($sheet->getRowIterator() as $row) {
+    $cells = [];
+    foreach ($row->getCellIterator() as $cell) {
+        $cells[] = trim((string) $cell->getFormattedValue());
+    }
+    // Strip trailing empty cells
+    while ($cells && end($cells) === '') {
+        array_pop($cells);
+    }
+    if ($cells) {
+        $rawData[] = $cells;
+    }
+}
+
+if (empty($rawData)) {
+    jsonError('The spreadsheet appears to be empty.');
+}
+
+// ─── format detection ─────────────────────────────────────────────────────────
+//
+// Two layouts found in CSBP lab files:
+//   ROW-BASED    — Row 0 = column headers (LAB_NUMBER, TEXTURE, PH_CACL2 …)
+//                  Rows 1-N = one sample per row.
+//   TRANSPOSED   — Column 0 = row labels (EC 1:5, Total P % …)
+//                  Columns 1-N = one sample per column.
+//
+// Heuristic: if the first cell of row 0 looks like a short code/identifier
+// (< 20 chars, no spaces, all-caps or underscored) → ROW-BASED.
+// Otherwise → TRANSPOSED.
+
+function isRowBased(array $rawData): bool
+{
+    $firstCell = $rawData[0][0] ?? '';
+    // Short, code-like headers signal a row-based layout
+    return strlen($firstCell) < 25
+        && !str_contains($firstCell, ' ')
+        && $firstCell !== '';
+}
+
+$transposed = !isRowBased($rawData);
+
+// ─── extract samples ──────────────────────────────────────────────────────────
+//
+// Returns an array of samples, each sample being an assoc array of
+// label → value.
+
+function extractSamplesRowBased(array $rawData): array
+{
+    $headers = $rawData[0];
+    $samples = [];
+    for ($r = 1; $r < count($rawData); $r++) {
+        $row    = $rawData[$r];
+        $sample = [];
+        foreach ($headers as $c => $header) {
+            if ($header === '') {
+                continue;
+            }
+            $sample[$header] = $row[$c] ?? '';
+        }
+        if (array_filter($sample)) {
+            $samples[] = $sample;
+        }
+    }
+    return $samples;
+}
+
+function extractSamplesTransposed(array $rawData): array
+{
+    // Column 0 = labels; columns 1-N = samples
+    $labels      = array_column($rawData, 0);
+    $numSamples  = max(array_map('count', $rawData)) - 1;
+    $samples     = [];
+
+    for ($col = 1; $col <= $numSamples; $col++) {
+        $sample = [];
+        foreach ($rawData as $rowIdx => $row) {
+            $label = $labels[$rowIdx] ?? '';
+            $value = $row[$col] ?? '';
+            if ($label !== '' && $value !== '') {
+                $sample[$label] = $value;
+            }
+        }
+        if (array_filter($sample)) {
+            $samples[] = $sample;
+        }
+    }
+    return $samples;
+}
+
+$samples = $transposed
+    ? extractSamplesTransposed($rawData)
+    : extractSamplesRowBased($rawData);
+
+if (empty($samples)) {
+    jsonError('No samples found in the file.');
+}
+
+// ─── action: parse ────────────────────────────────────────────────────────────
+// Return a lightweight list of samples so the UI can let the user pick one.
+
+if ($action === 'parse') {
+    $list = [];
+    foreach ($samples as $idx => $sample) {
+        // Try to find a meaningful display label
+        $labId  = $sample['LAB_NUMBER']    ?? $sample['Lab ID (Soil)'] ?? $sample['LAB_ID'] ?? "Sample " . ($idx + 1);
+        $client = $sample['CLIENT NAME']   ?? $sample['Consultant']    ?? $sample['CUSTNO']  ?? '';
+        $crop   = $sample['CROP']          ?? $sample['Material (manure, sawdust, etc.)'] ?? '';
+        $pad    = $sample['PADDOCK']       ?? $sample['Field Name (Sample ID)']            ?? '';
+
+        $list[] = [
+            'idx'    => $idx,
+            'lab_id' => $labId,
+            'client' => $client,
+            'crop'   => $crop,
+            'site'   => $pad,
+        ];
+    }
+    jsonOk(['samples' => $list, 'count' => count($samples)]);
+}
+
+// ─── action: import ───────────────────────────────────────────────────────────
+
+$sampleIdx = (int) ($_POST['sample_idx'] ?? 0);
+if ($sampleIdx < 0 || $sampleIdx >= count($samples)) {
+    jsonError('Invalid sample index.');
+}
+
+$sampleData = $samples[$sampleIdx];
+
+// ─── Ollama field mapping ─────────────────────────────────────────────────────
+
+$labJson = json_encode($sampleData, JSON_UNESCAPED_UNICODE);
+
+$prompt = <<<EOT
+You are a soil laboratory data mapper. Your only job is to output a JSON object.
+
+Map the LAB DATA below to these TARGET FIELDS. Output ONLY the JSON object — no explanation, no markdown, no code fences.
+
+TARGET FIELDS:
+lab_no=Lab reference number/Lab ID
+sample_id=Sample identifier/paddock name/field name
+site_id=Site identifier/block/customer number
+date_sampled=Date sampled as YYYY-MM-DD
+texture=Soil texture description
+gravel=Gravel % (number only)
+colour=Soil colour
+ocarbon=Organic carbon % (number only)
+omatter=Organic matter % LOI (number only)
+ph_cacl2=pH in CaCl2 (number only)
+ph_h2o=pH in water (number only)
+ec=Electrical conductivity dS/m (number only)
+NO3_N=Nitrate-N mg/kg (number only)
+NH3_N=Ammonium-N mg/kg (number only)
+p_mehlick=Phosphorus Mehlich-3 mg/kg (number only)
+p_morgan=Phosphorus extractable mg/kg (number only)
+k_morgan=Potassium mg/kg (number only)
+ca_morgan=Calcium mg/kg (number only)
+mg_morgan=Magnesium mg/kg (number only)
+na_morgan=Sodium mg/kg (number only)
+s_morgan=Sulphur mg/kg (number only)
+b_cacl2=Boron CaCl2 mg/kg (number only)
+mn_dtpa=Manganese DTPA mg/kg (number only)
+zn_dtpa=Zinc DTPA mg/kg (number only)
+fe_dtpa=Iron DTPA mg/kg (number only)
+cu_dtpa=Copper DTPA mg/kg (number only)
+al=Aluminium mg/kg (number only)
+tec=Total Exchange Capacity (number only)
+cec=CEC meq/100g (number only)
+ca_mehlick3=Calcium Mehlich-3 meq/100g (number only)
+mg_mehlick3=Magnesium Mehlich-3 meq/100g (number only)
+k_mehlick3=Potassium Mehlich-3 meq/100g (number only)
+na_mehlick3=Sodium Mehlich-3 meq/100g (number only)
+al_mehlick3=Aluminium Mehlich-3 meq/100g (number only)
+
+LAB DATA: {$labJson}
+
+Rules: only use values present in the lab data. Strip units from numbers. Use null for unmapped fields. Output JSON only.
+EOT;
+
+$payload = json_encode([
+    'model'  => OLLAMA_MODEL,
+    'prompt' => $prompt,
+    'stream' => false,
+    'options' => [
+        'temperature' => OLLAMA_TEMPERATURE,
+        'num_predict' => 1024,
+    ],
+]);
+
+$ch = curl_init(OLLAMA_HOST . '/api/generate');
+curl_setopt_array($ch, [
+    CURLOPT_POST           => true,
+    CURLOPT_POSTFIELDS     => $payload,
+    CURLOPT_HTTPHEADER     => ['Content-Type: application/json'],
+    CURLOPT_RETURNTRANSFER => true,
+    CURLOPT_TIMEOUT        => OLLAMA_TIMEOUT,
+    CURLOPT_CONNECTTIMEOUT => 5,
+]);
+
+$response = curl_exec($ch);
+$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
+$curlErr  = curl_error($ch);
+curl_close($ch);
+
+if ($curlErr || $httpCode !== 200) {
+    $mapped = staticFieldMap($sampleData);
+    $warning = $curlErr ?: "Ollama HTTP {$httpCode}";
+    jsonOk(['fields' => $mapped, 'method' => 'static', 'warning' => 'AI unavailable: ' . $warning]);
+}
+
+$ollamaData = json_decode($response, true);
+$rawText    = trim($ollamaData['response'] ?? '');
+
+if ($rawText === '') {
+    $mapped = staticFieldMap($sampleData);
+    jsonOk(['fields' => $mapped, 'method' => 'static', 'warning' => 'Ollama returned empty response']);
+}
+
+// Strip any markdown code fences the model might wrap around the JSON
+$rawText = preg_replace('/^```(?:json)?\s*/i', '', $rawText);
+$rawText = preg_replace('/\s*```$/m', '', $rawText);
+// Extract the first JSON object if the model added commentary
+if (preg_match('/\{[\s\S]+\}/', $rawText, $m)) {
+    $rawText = $m[0];
+}
+
+$mapped = json_decode($rawText, true);
+
+if (!is_array($mapped)) {
+    $mapped = staticFieldMap($sampleData);
+    jsonOk(['fields' => $mapped, 'method' => 'static', 'warning' => 'AI returned unparseable JSON']);
+}
+
+// Remove null/empty values
+$mapped = array_filter($mapped, fn($v) => $v !== null && $v !== '');
+
+jsonOk(['fields' => $mapped, 'method' => 'ai']);
+
+// ─── static fallback mapper ───────────────────────────────────────────────────
+// Simple keyword-based mapping used when the AI is unavailable.
+
+function staticFieldMap(array $data): array
+{
+    $map = [
+        // lab_no
+        'lab_no'      => ['LAB_NUMBER', 'Lab ID (Soil)', 'LAB_ID'],
+        // sample / site
+        'sample_id'   => ['PADDOCK', 'Field Name (Sample ID)', 'PADDOCK_NAME'],
+        'site_id'     => ['CUSTNO', 'Lab performing testing'],
+        // physical
+        'texture'     => ['TEXTURE'],
+        'gravel'      => ['GRAVEL'],
+        'colour'      => ['COLOUR', 'COLOR'],
+        // chemical
+        'ocarbon'     => ['ORGCARBON', 'Organic Carbon %', 'Total Organic Carbon %'],
+        'omatter'     => ['Total Organic Matter (L.O.I) %', 'Organic Matter %'],
+        'ph_cacl2'    => ['PH_CACL2', 'PH 1:5 (CaCl2)', 'pH CaCl2', 'ph_cacl2'],
+        'ph_h2o'      => ['PH_H2O', 'pH 1:5 (H2O)', 'pH Water'],
+        'ec'          => ['CONDUCTY', 'EC 1:5', 'EC'],
+        // nutrients
+        'NO3_N'       => ['NITRATE', 'Nitrate ppm', 'Nitrate-N', 'NO3_N'],
+        'NH3_N'       => ['NAMMONIUM', 'Ammonium', 'NH4_N'],
+        'p_morgan'    => ['PHOS', 'Total P %', 'Phosphorus'],
+        'k_morgan'    => ['POTASSIUM', 'Total K %', 'Potassium'],
+        'ca_morgan'   => ['EXC_CA', 'Total Ca %', 'Calcium'],
+        'mg_morgan'   => ['EXC_MG', 'Total Mg %', 'Magnesium'],
+        'na_morgan'   => ['EXC_NA', 'Total Na %', 'Sodium'],
+        's_morgan'    => ['SULPHUR', 'Total S %', 'Sulphur'],
+        // micronutrients
+        'b_cacl2'     => ['BORON_HOT', 'Total B ppm', 'Boron'],
+        'mn_dtpa'     => ['DTPA_MN', 'EDTA_MN', 'Total Mn ppm', 'Manganese'],
+        'zn_dtpa'     => ['DTPA_ZN', 'EDTA_ZN', 'Total Zn ppm', 'Zinc'],
+        'fe_dtpa'     => ['DTPA_FE', 'EDTA_FE', 'Total Fe ppm', 'Iron', 'IRON'],
+        'cu_dtpa'     => ['DTPA_CU', 'EDTA_CU', 'Total Cu ppm', 'Copper'],
+        'al'          => ['ALUM_CACL2', 'EXC_AL', 'Aluminium'],
+        // base saturation
+        'cec'         => ['CEC', 'COND', 'SAT_COND'],
+        'ca_mehlick3' => ['SAT_Ca', 'SAT_CA'],
+        'mg_mehlick3' => ['SAT_Mg', 'SAT_MG'],
+        'k_mehlick3'  => ['SAT_K'],
+        'na_mehlick3' => ['SAT_Na', 'SAT_NA'],
+    ];
+
+    $result = [];
+    foreach ($map as $dbField => $labKeys) {
+        foreach ($labKeys as $labKey) {
+            // Case-insensitive search
+            foreach ($data as $k => $v) {
+                if (strcasecmp($k, $labKey) === 0 && $v !== '') {
+                    $result[$dbField] = $v;
+                    break 2;
+                }
+            }
+        }
+    }
+    return $result;
+}

+ 64 - 6
dashboard/crop-analysis/soil-test-data/index.php

@@ -49,14 +49,71 @@ include __DIR__ . '/../../../layouts/navbar.php';
 
                         <hr />
 
-                        <div class="card">
+                        <!-- ── Lab File Import ────────────────────────────── -->
+                        <div class="card mb-4">
+                            <div class="card-header d-flex align-items-center gap-2">
+                                <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" class="bi bi-file-earmark-spreadsheet text-success" viewBox="0 0 16 16">
+                                    <path d="M14 14V4.5L9.5 0H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2M9.5 3A1.5 1.5 0 0 0 11 4.5h3V9H2V2a1 1 0 0 1 1-1h5.5zM2 10h1v1H2zm2 0h1v1H4zm3 0h1v1H7zm2 0h1v1H9zm2 0h1v1h-1zm1 1h1v1h-1zm-9 1h1v1H2zm2 0h1v1H4zm3 0h1v1H7zm2 0h1v1H9zm2 0h1v1h-1z"/>
+                                </svg>
+                                <h5 class="mb-0">Import from Lab File</h5>
+                            </div>
                             <div class="card-body">
-                                <h5 class="card-title">Excel/CSV Upload</h5>
-                                <p class="card-text">Download a CSV of this form for easy filling or upload a filled form to pre-populate.</p>
-                                <div class="input-group mt-3">
-                                    <input type="file" class="form-control border-success" id="upload" accept=".csv">
-                                    <button class="btn btn-success" type="button" id="download">Download</button>
+                                <p class="text-muted mb-3">
+                                    Upload an XLS, XLSX, CSV, or ODS file from your soil testing laboratory.
+                                    The system will use AI to automatically map the lab's column names to the
+                                    form fields below — works with CSBP and other lab formats.
+                                </p>
+
+                                <div class="row g-2 align-items-end">
+                                    <div class="col-md-8">
+                                        <label for="lab-file-input" class="form-label fw-semibold">Select lab file</label>
+                                        <input type="file"
+                                               class="form-control"
+                                               id="lab-file-input"
+                                               accept=".xls,.xlsx,.csv,.ods">
+                                    </div>
+                                    <div class="col-md-4">
+                                        <button class="btn btn-success w-100"
+                                                type="button"
+                                                id="lab-analyse-btn"
+                                                disabled>
+                                            <span class="spinner-border spinner-border-sm me-1 d-none" id="import-spinner" role="status"></span>
+                                            Analyse &amp; Import
+                                        </button>
+                                    </div>
+                                </div>
+
+                                <!-- Progress indicator -->
+                                <div id="import-progress" class="mt-3 d-flex align-items-center gap-2 text-muted" hidden>
+                                    <div class="spinner-border spinner-border-sm text-success" role="status"></div>
+                                    <span class="progress-label">Processing…</span>
                                 </div>
+
+                                <!-- Status / result message -->
+                                <div id="import-status" class="alert mt-3" hidden></div>
+
+                                <!-- Sample picker (multi-sample files) -->
+                                <div id="sample-picker-panel" hidden class="mt-3">
+                                    <h6 class="fw-semibold">Multiple samples found — choose one to import:</h6>
+                                    <div class="table-responsive">
+                                        <table id="sample-picker-table" class="table table-sm table-hover align-middle">
+                                            <thead class="table-light">
+                                                <tr>
+                                                    <th>Lab ID</th>
+                                                    <th>Client</th>
+                                                    <th>Site / Paddock</th>
+                                                    <th>Crop / Material</th>
+                                                    <th></th>
+                                                </tr>
+                                            </thead>
+                                            <tbody></tbody>
+                                        </table>
+                                    </div>
+                                </div>
+
+                                <p class="text-muted small mt-3 mb-0">
+                                    Fields highlighted in green were auto-populated. Always review values before submitting.
+                                </p>
                             </div>
                         </div>
 
@@ -72,4 +129,5 @@ include __DIR__ . '/../../../layouts/navbar.php';
     </div>
 </div>
 
+<script src="/client-assets/js/soil-import.js"></script>
 <?php include __DIR__ . '/../../../layouts/footer.php'; ?>

+ 9 - 0
dashboard/crop-analysis/soil-test-data/soil-analysis.php

@@ -253,6 +253,15 @@ include __DIR__.'/../../../layouts/header.php';
     </div>
     
     <div class="section" id="page-2">
+    <div class="row align-items-center mb-1 mt-3">
+        <div class="col-3">
+            <img class="img-fluid" src="/client-assets/images/crop-monitor.png" alt="Crop Monitor" style="max-height:55px;">
+        </div>
+        <div class="col-9 text-end">
+            <div class="fw-bold h5 mb-0">Soil Analysis</div>
+            <div class="text-muted small"></div>
+        </div>
+    </div>
     <div class="row">
         <table class="title w-100 mb-3 small">
         <tbody>

BIN
doc/S-C Soil Tests 2006.xls


BIN
doc/SOIL CONTROL XNS06189.xls


BIN
doc/SOIL CONTROL YOS06 42-48.xls