Benjamin Harris il y a 2 mois
Parent
commit
0b4152aa50

+ 2 - 1
.claude/settings.local.json

@@ -14,7 +14,8 @@
       "Bash(grep -A 20 \"CREATE TABLE \\\\`sensor_id\\\\`\" \"f:/GIT_REPO/crop_monitor/cropmonitor.sql\")",
       "Bash(grep -A 20 \"CREATE TABLE \\\\`crop_info\\\\`\" \"f:/GIT_REPO/crop_monitor/cropmonitor.sql\")",
       "Bash(composer install:*)",
-      "Bash(cmd /c \"where composer 2>nul && where php 2>nul\")"
+      "Bash(cmd /c \"where composer 2>nul && where php 2>nul\")",
+      "Bash(python3:*)"
     ]
   }
 }

+ 243 - 161
client-assets/js/soil-import.js

@@ -1,241 +1,323 @@
 /**
  * client-assets/js/soil-import.js
  *
- * Handles the lab spreadsheet import flow on the soil test entry page.
+ * Lab file import flow for 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
+ *  1. User selects lab + file → "Analyse" button enabled
+ *  2. File sent to soilImportController.php (action=parse)
+ *  3. Samples returned → shown in confirmation table
+ *     - User can edit sample_id (paddock) inline
+ *     - User can uncheck individual rows to skip them
+ *  4a. "Import All" / "Import Selected" → action=import_bulk → records saved to DB
+ *  4b. For single sample: "Fill Form" → action=import_one → pre-fills the form below
  */
 
 (function () {
     'use strict';
 
     // ── DOM refs ──────────────────────────────────────────────────────────────
+    const labSelect    = document.getElementById('lab-select');
+    const fileInput    = document.getElementById('lab-file-input');
+    const analyseBtn   = document.getElementById('lab-analyse-btn');
+    const statusEl     = document.getElementById('import-status');
+    const progressEl   = document.getElementById('import-progress');
+    const bulkPanel    = document.getElementById('bulk-import-panel');
+    const bulkTbody    = document.getElementById('bulk-import-tbody');
+    const bulkImportBtn= document.getElementById('bulk-import-btn');
+    const bulkAllChk   = document.getElementById('bulk-select-all');
 
-    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;
 
-    if (!fileInput || !analyseBtn) return; // not on the right page
+    let parsedSamples = [];   // raw from server
+    let detectedLab   = '';
 
-    // ── helpers ───────────────────────────────────────────────────────────────
+    // ── Helpers ───────────────────────────────────────────────────────────────
 
     function showStatus(msg, type = 'info') {
-        if (!importStatus) return;
-        importStatus.className = `alert alert-${type} mt-2`;
-        importStatus.textContent = msg;
-        importStatus.hidden = false;
+        if (!statusEl) return;
+        statusEl.className = `alert alert-${type} mt-2`;
+        statusEl.innerHTML = msg;
+        statusEl.hidden    = false;
     }
+    function hideStatus() { if (statusEl) statusEl.hidden = true; }
 
-    function hideStatus() {
-        if (importStatus) importStatus.hidden = true;
+    function setProgress(on, text = 'Processing…') {
+        if (!progressEl) return;
+        progressEl.hidden = !on;
+        const lbl = progressEl.querySelector('.progress-label');
+        if (lbl) lbl.textContent = text;
     }
 
-    function setProgress(visible, text = '') {
-        if (!importProgress) return;
-        importProgress.hidden = !visible;
-        if (text) importProgress.querySelector('.progress-label').textContent = text;
-    }
-
-    function buildFormData(file, action, extraFields = {}) {
+    function buildFormData(extra = {}) {
         const fd = new FormData();
-        fd.append('file', file);
-        fd.append('action', action);
-        for (const [k, v] of Object.entries(extraFields)) {
-            fd.append(k, v);
-        }
+        fd.append('file', fileInput.files[0]);
+        fd.append('lab',  labSelect ? labSelect.value : 'auto');
+        for (const [k, v] of Object.entries(extra)) 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();
+    async function post(fd) {
+        const r = await fetch('/controllers/soilImportController.php', { method: 'POST', body: fd });
+        if (!r.ok) throw new Error(`Server error ${r.status}`);
+        return r.json();
     }
 
-    // ── form population ───────────────────────────────────────────────────────
+    // ── Enable Analyse button when file chosen ────────────────────────────────
 
-    function populateForm(fields) {
-        let filled = 0;
-        let skipped = 0;
+    fileInput.addEventListener('change', () => {
+        analyseBtn.disabled = !fileInput.files.length;
+        hideStatus();
+        if (bulkPanel) bulkPanel.hidden = true;
+    });
 
-        for (const [fieldName, value] of Object.entries(fields)) {
-            if (value === null || value === undefined || value === '') continue;
+    // ── Step 1: Analyse ───────────────────────────────────────────────────────
 
-            const el = document.getElementById(fieldName)
-                    || document.querySelector(`[name="${fieldName}"]`);
+    analyseBtn.addEventListener('click', async () => {
+        if (!fileInput.files.length) return;
 
-            if (!el) { skipped++; continue; }
+        analyseBtn.disabled = true;
+        setProgress(true, 'Reading file…');
+        hideStatus();
+        if (bulkPanel) bulkPanel.hidden = true;
 
-            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;
-                    }
+        try {
+            const data = await post(buildFormData({ action: 'parse' }));
+
+            if (!data.success) {
+                showStatus('Parse error: ' + escHtml(data.error), 'danger');
+                return;
+            }
+
+            parsedSamples = data.samples;
+            detectedLab   = data.lab;
+
+            if (labSelect && labSelect.value === 'auto' && detectedLab) {
+                // Update the dropdown to reflect auto-detected lab
+                for (const opt of labSelect.options) {
+                    if (opt.value === detectedLab) { opt.selected = true; break; }
                 }
+            }
+
+            if (data.count === 1) {
+                // Single sample — offer both "fill form" and "bulk import"
+                showStatus(
+                    `1 sample found (<strong>${escHtml(parsedSamples[0]?.lab_no || 'unknown')}</strong>). ` +
+                    `Review below then import.`,
+                    'info'
+                );
             } else {
-                el.value = value;
-                el.dispatchEvent(new Event('input', { bubbles: true }));
-                filled++;
+                showStatus(
+                    `<strong>${data.count}</strong> samples found in this file. ` +
+                    `Review paddock IDs below, then import all or select individual rows.`,
+                    'info'
+                );
             }
-        }
 
-        return { filled, skipped };
-    }
+            renderBulkTable(data.samples);
 
-    // 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');
-            }
+        } catch (err) {
+            showStatus('Error: ' + escHtml(err.message), 'danger');
+        } finally {
+            setProgress(false);
+            analyseBtn.disabled = false;
         }
-    }
-
-    // ── sample picker ─────────────────────────────────────────────────────────
+    });
 
-    function renderSamplePicker(samples, file) {
-        if (!samplePanel || !sampleTbody) return;
+    // ── Step 2: Render confirmation table ─────────────────────────────────────
 
-        sampleTbody.innerHTML = '';
+    function renderBulkTable(samples) {
+        if (!bulkPanel || !bulkTbody) return;
+        bulkTbody.innerHTML = '';
 
-        samples.forEach((s) => {
+        samples.forEach((s, idx) => {
             const tr = document.createElement('tr');
+            tr.dataset.idx = idx;
             tr.innerHTML = `
-                <td>${escHtml(s.lab_id)}</td>
-                <td>${escHtml(s.client)}</td>
-                <td>${escHtml(s.site)}</td>
-                <td>${escHtml(s.crop)}</td>
+                <td class="text-center">
+                    <input type="checkbox" class="form-check-input row-check" checked>
+                </td>
+                <td class="text-muted small">${escHtml(s.lab_no)}</td>
                 <td>
-                    <button class="btn btn-sm btn-success"
-                            data-idx="${s.idx}"
-                            type="button">
-                        Import
+                    <input type="text"
+                           class="form-control form-control-sm paddock-input"
+                           value="${escHtml(s.sample_id)}"
+                           placeholder="Paddock / sample ID">
+                </td>
+                <td class="small">${escHtml(s.client)}</td>
+                <td class="small">${escHtml(s.crop)}</td>
+                <td class="text-center">
+                    <button class="btn btn-xs btn-sm btn-outline-secondary fill-form-btn"
+                            data-idx="${idx}" type="button" title="Pre-fill the form below with this sample">
+                        <i class="fas fa-pen fa-xs"></i>
                     </button>
                 </td>`;
-            sampleTbody.appendChild(tr);
+            bulkTbody.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);
-            });
+        // Wire "fill form" buttons
+        bulkTbody.querySelectorAll('.fill-form-btn').forEach(btn => {
+            btn.addEventListener('click', () => fillForm(parseInt(btn.dataset.idx)));
         });
 
-        samplePanel.hidden = false;
-        samplePanel.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
+        bulkPanel.hidden = false;
+        bulkPanel.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
     }
 
-    function escHtml(str) {
-        const d = document.createElement('div');
-        d.textContent = str || '';
-        return d.innerHTML;
+    // ── Select-all checkbox ───────────────────────────────────────────────────
+
+    if (bulkAllChk) {
+        bulkAllChk.addEventListener('change', () => {
+            document.querySelectorAll('.row-check').forEach(cb => {
+                cb.checked = bulkAllChk.checked;
+            });
+        });
     }
 
-    // ── import a specific sample ──────────────────────────────────────────────
+    // ── Step 3a: Bulk import ──────────────────────────────────────────────────
 
-    async function importSample(file, sampleIdx) {
-        setProgress(true, 'Mapping fields with AI…');
-        hideStatus();
+    if (bulkImportBtn) {
+        bulkImportBtn.addEventListener('click', async () => {
+            const clientId = document.querySelector('[name="client_id"]')?.value || '';
+            if (!clientId || clientId === 'new') {
+                showStatus('Please select a client before importing.', 'warning');
+                document.querySelector('[name="client_id"]')?.scrollIntoView({ behavior: 'smooth' });
+                return;
+            }
+
+            // Collect checked rows with updated paddock IDs
+            const toImport = [];
+            bulkTbody.querySelectorAll('tr').forEach(tr => {
+                const chk = tr.querySelector('.row-check');
+                if (!chk?.checked) return;
+                const idx       = parseInt(tr.dataset.idx);
+                const paddock   = tr.querySelector('.paddock-input')?.value.trim() || '';
+                const sample    = { ...parsedSamples[idx] };
+                sample.sample_id = paddock || sample.sample_id;
+                // Convert parsed display fields to db field names
+                toImport.push(rawSampleToDbFields(idx, sample));
+            });
+
+            if (!toImport.length) {
+                showStatus('No rows selected.', 'warning');
+                return;
+            }
+
+            setProgress(true, `Importing ${toImport.length} sample${toImport.length !== 1 ? 's' : ''}…`);
+            bulkImportBtn.disabled = true;
+
+            try {
+                const fd = buildFormData({
+                    action:    'import_bulk',
+                    client_id: clientId,
+                    samples:   JSON.stringify(toImport),
+                });
+                const data = await post(fd);
+
+                if (!data.success) {
+                    showStatus('Import failed: ' + escHtml(data.error), 'danger');
+                    return;
+                }
+
+                bulkPanel.hidden = true;
+                const skipHtml = data.skipped?.length
+                    ? `<br><small class="text-warning">Skipped: ${data.skipped.map(escHtml).join(', ')}</small>`
+                    : '';
+                showStatus(`<i class="fas fa-check-circle me-1"></i>${escHtml(data.message)}${skipHtml}`, 'success');
+
+            } catch (err) {
+                showStatus('Error: ' + escHtml(err.message), 'danger');
+            } finally {
+                setProgress(false);
+                bulkImportBtn.disabled = false;
+            }
+        });
+    }
+
+    // ── Step 3b: Fill form with one sample ────────────────────────────────────
+
+    async function fillForm(idx) {
+        setProgress(true, 'Mapping fields…');
 
         try {
-            const fd   = buildFormData(file, 'import', { sample_idx: sampleIdx });
-            const data = await postToController(fd);
+            const fd   = buildFormData({ action: 'import_one', sample_idx: idx });
+            const data = await post(fd);
 
             if (!data.success) {
-                showStatus('Import failed: ' + data.error, 'danger');
+                showStatus('Could not map fields: ' + escHtml(data.error), 'danger');
                 return;
             }
 
-            const { filled, skipped } = populateForm(data.fields);
-            highlightFilledFields(data.fields);
+            const { filled } = populateForm(data.fields);
+            highlightFilled(data.fields);
 
-            if (samplePanel) samplePanel.hidden = true;
-
-            const method  = data.method === 'ai' ? 'AI-assisted' : 'pattern-matched';
-            const warning = data.warning ? ` (${data.warning})` : '';
+            const method = data.method === 'csbp' ? 'CSBP direct map' : 'AI-assisted';
             showStatus(
-                `${filled} field${filled !== 1 ? 's' : ''} populated via ${method}${warning}. ` +
-                `Please review and adjust before submitting.`,
-                data.warning ? 'warning' : 'success'
+                `<i class="fas fa-check-circle me-1"></i>${filled} fields filled via ${method}. ` +
+                `Review highlighted fields before submitting.`,
+                'success'
             );
-
-            // Scroll to the form
             document.getElementById('SoilcsvForm')?.scrollIntoView({ behavior: 'smooth' });
 
         } catch (err) {
-            showStatus('Error: ' + err.message, 'danger');
+            showStatus('Error: ' + escHtml(err.message), 'danger');
         } finally {
             setProgress(false);
         }
     }
 
-    // ── main: analyse file ────────────────────────────────────────────────────
-
-    fileInput.addEventListener('change', () => {
-        analyseBtn.disabled = !fileInput.files.length;
-        hideStatus();
-        if (samplePanel) samplePanel.hidden = true;
-    });
+    // ── Form population helpers ───────────────────────────────────────────────
 
-    analyseBtn.addEventListener('click', async () => {
-        const file = fileInput.files[0];
-        if (!file) return;
+    function populateForm(fields) {
+        let filled = 0;
+        for (const [name, value] of Object.entries(fields)) {
+            if (value === null || value === undefined || value === '') continue;
+            const el = document.getElementById(name) || document.querySelector(`[name="${name}"]`);
+            if (!el) continue;
+            if (el.tagName === 'SELECT') {
+                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 };
+    }
 
-        setProgress(true, 'Parsing file…');
-        hideStatus();
-        if (samplePanel) samplePanel.hidden = true;
-        analyseBtn.disabled = true;
+    function highlightFilled(fields) {
+        // Clear old highlights first
+        document.querySelectorAll('.import-filled').forEach(el => {
+            el.classList.remove('import-filled', 'border-success', 'bg-success-subtle');
+        });
+        for (const name of Object.keys(fields)) {
+            if (!fields[name]) continue;
+            const el = document.getElementById(name) || document.querySelector(`[name="${name}"]`);
+            if (el) el.classList.add('import-filled', 'border-success', 'bg-success-subtle');
+        }
+    }
 
-        try {
-            const fd   = buildFormData(file, 'parse');
-            const data = await postToController(fd);
+    // ── Convert display sample → db field names for bulk save ─────────────────
+    // The parsed CSBP sample already uses db field names, so this is mostly pass-through.
+    // For generic lab samples (raw headers), the server handles mapping via Ollama.
 
-            if (!data.success) {
-                showStatus('Parse failed: ' + data.error, 'danger');
-                return;
-            }
+    function rawSampleToDbFields(idx, sample) {
+        // sample already has db keys from csbpParse / genericParse
+        // just ensure sample_id is updated from the editable paddock field
+        return sample;
+    }
 
-            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);
-            }
+    // ── Utility ───────────────────────────────────────────────────────────────
 
-        } catch (err) {
-            showStatus('Error reading file: ' + err.message, 'danger');
-        } finally {
-            setProgress(false);
-            analyseBtn.disabled = false;
-        }
-    });
+    function escHtml(str) {
+        const d = document.createElement('div');
+        d.textContent = str || '';
+        return d.innerHTML;
+    }
 
 })();

+ 184 - 0
controllers/labParsers/csbp.php

@@ -0,0 +1,184 @@
+<?php
+/**
+ * controllers/labParsers/csbp.php
+ *
+ * Dedicated parser for CSBP Soil Analysis Report files.
+ *
+ * Format (both .xls and .xlsx):
+ *   Row 1-2  empty
+ *   Row 3    "SOIL CONTROL SOIL ANALYSIS REPORT"
+ *   Row 4    "CSBP LIMITED ABN: ..."
+ *   Row 5    Units row  (mg/kg, pH, dS/m …)
+ *   Row 6    Column headers (47 columns — see MAP below)
+ *   Row 7+   One sample per row
+ *
+ * Provides:
+ *   csbpDetect(array $rawData): bool
+ *   csbpParse(array $rawData): array   — returns array of mapped sample arrays
+ */
+
+// ─── CSBP column → soil_records field map ────────────────────────────────────
+//
+// Keys are CSBP header names (case-insensitive match).
+// Values are soil_records column names.
+
+const CSBP_COLUMN_MAP = [
+    // Identity / metadata
+    'CLIENT NAME'  => 'client_name',
+    'PADDOCK'      => 'sample_id',       // paddock = sample identifier
+    'CROP'         => 'crop_type',
+    'SERIAL_NO'    => 'batch_no',
+    'LAB_NUMBER'   => 'lab_no',
+
+    // Physical
+    'TEXTURE'      => 'texture',
+    'GRAVEL'       => 'gravel',
+    'COLOUR'       => 'colour',
+    'MOISTURE'     => null,              // not stored in soil_records
+
+    // Chemical
+    'CONDUCTY'     => 'ec',
+    'PH_CACL2'     => 'ph_cacl2',
+    'PH_H2O'       => 'ph_h2o',
+
+    // Nitrogen
+    'NITRATEN'     => 'NO3_N',
+    'AMMONIUM'     => 'NH3_N',
+    'TOTALN'       => null,
+
+    // Phosphorus
+    'PHOS'         => 'p_morgan',
+    'PHOS_OLS'     => 'p_bray2',        // Olsen P ≈ closest mapped field
+    'PHOS_RETEN'   => null,
+    'TOTALP'       => null,
+    'PBI'          => null,
+
+    // Potassium
+    'POTASSIUM'    => 'k_morgan',
+
+    // Other macros
+    'SULPHUR'      => 's_morgan',
+    'ORGCARBON'    => 'ocarbon',
+    'CHLORIDE'     => null,
+    'FIZZ'         => null,
+
+    // DTPA micronutrients
+    'DTPA_CU'      => 'cu_dtpa',
+    'DTPA_ZN'      => 'zn_dtpa',
+    'DTPA_MN'      => 'mn_dtpa',
+    'DTPA_FE'      => 'fe_dtpa',
+
+    // EDTA micronutrients (map to same fields — DTPA takes priority if both present)
+    'EDTA_CU'      => 'cu_dtpa',
+    'EDTA_ZN'      => 'zn_dtpa',
+    'EDTA_MN'      => 'mn_dtpa',
+    'EDTA_FE'      => 'fe_dtpa',
+
+    // Other micros
+    'IRON'         => 'fe_dtpa',        // generic iron reading
+    'BORON_HOT'    => 'b_cacl2',
+    'ALUM_CACL2'   => 'al',
+
+    // Exchangeable cations (base saturation)
+    'EXC_CA'       => 'ca_morgan',
+    'EXC_MG'       => 'mg_morgan',
+    'EXC_NA'       => 'na_morgan',
+    'EXC_K'        => null,             // separate from k_morgan (extractable)
+    'EXC_AL'       => 'al_mehlick3',
+
+    // Base saturation %
+    'SAT_Ca'       => 'ca_mehlick3',
+    'SAT_Mg'       => 'mg_mehlick3',
+    'SAT_K'        => 'k_mehlick3',
+    'SAT_Na'       => 'na_mehlick3',
+    'SAT_COND'     => 'cec',            // conductance of saturation extract ≈ CEC proxy
+    'SP%'          => null,
+];
+
+/**
+ * Check if a 2-D raw data array looks like a CSBP file.
+ * Looks for "CSBP" or "SOIL CONTROL SOIL ANALYSIS" in the first 6 rows.
+ */
+function csbpDetect(array $rawData): bool
+{
+    foreach (array_slice($rawData, 0, 6) as $row) {
+        foreach ($row as $cell) {
+            $cell = strtoupper(trim((string) $cell));
+            if (str_contains($cell, 'CSBP') || str_contains($cell, 'SOIL CONTROL SOIL ANALYSIS')) {
+                return true;
+            }
+        }
+    }
+    return false;
+}
+
+/**
+ * Find the header row index inside $rawData.
+ * The CSBP header row contains "LAB_NUMBER" and "PADDOCK".
+ */
+function csbpFindHeaderRow(array $rawData): int
+{
+    foreach ($rawData as $idx => $row) {
+        $cells = array_map(fn($c) => strtoupper(trim((string) $c)), $row);
+        if (in_array('LAB_NUMBER', $cells, true) && in_array('PADDOCK', $cells, true)) {
+            return $idx;
+        }
+    }
+    return 5; // fallback: row index 5 (row 6 in 1-based)
+}
+
+/**
+ * Parse a CSBP raw data array into an array of mapped sample arrays.
+ * Each sample is keyed by soil_records column names.
+ *
+ * @param  array $rawData   2-D array from PhpSpreadsheet
+ * @return array            [ ['lab_no'=>..., 'ph_cacl2'=>..., ...], ... ]
+ */
+function csbpParse(array $rawData): array
+{
+    $headerRow = csbpFindHeaderRow($rawData);
+    $headers   = array_map(fn($c) => trim((string) $c), $rawData[$headerRow]);
+
+    // Build index: column position → soil_records field name (or null to skip)
+    $colIndex = [];
+    foreach ($headers as $col => $header) {
+        if ($header === '') continue;
+        // Case-insensitive lookup in the map
+        $upperHeader = strtoupper($header);
+        foreach (CSBP_COLUMN_MAP as $csbpCol => $dbField) {
+            if (strtoupper($csbpCol) === $upperHeader) {
+                $colIndex[$col] = $dbField; // null means skip
+                break;
+            }
+        }
+    }
+
+    $samples = [];
+    for ($r = $headerRow + 1; $r < count($rawData); $r++) {
+        $row    = $rawData[$r];
+        $sample = [];
+        $hasData = false;
+
+        foreach ($colIndex as $col => $dbField) {
+            $raw = trim((string) ($row[$col] ?? ''));
+            if ($dbField === null) continue; // unmapped column
+            if ($raw === '') continue;
+
+            // Don't overwrite a DTPA value with an EDTA value
+            if (isset($sample[$dbField]) && $sample[$dbField] !== '') continue;
+
+            $sample[$dbField] = $raw;
+            $hasData = true;
+        }
+
+        // Skip completely empty rows
+        if (!$hasData) continue;
+
+        // Mark the lab source
+        $sample['analysis_type'] = 'CSBP';
+
+        $samples[] = $sample;
+    }
+
+    return $samples;
+}

+ 274 - 309
controllers/soilImportController.php

@@ -2,28 +2,30 @@
 /**
  * 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.
+ * Handles XLS/XLSX/CSV upload from soil labs.
  *
  * 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
+ *   file        — multipart upload
+ *   lab         — lab identifier: "csbp" | "generic"
+ *   action      — "parse"       → detect lab, return sample list
+ *                 "import_one"  → map + return fields for one sample (form pre-fill)
+ *                 "import_bulk" → save all (or selected) samples directly to DB
+ *   sample_idx  — (import_one) 0-based index
+ *   samples     — (import_bulk) JSON array of sample objects with overrides applied by user
+ *   client_id   — (import_bulk) client_records.id to link records to
  */
 
 require_once __DIR__ . '/../config/database.php';
 require_once __DIR__ . '/../config/ai.php';
 require_once __DIR__ . '/../lib/auth.php';
+require_once __DIR__ . '/../lib/csrf.php';
+require_once __DIR__ . '/labParsers/csbp.php';
 
 if (session_status() === PHP_SESSION_NONE) {
     session_start();
 }
 
 requireLogin();
-
 header('Content-Type: application/json');
 
 // ─── helpers ─────────────────────────────────────────────────────────────────
@@ -48,7 +50,7 @@ if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
 }
 
 $action = $_POST['action'] ?? 'parse';
-if (!in_array($action, ['parse', 'import'], true)) {
+if (!in_array($action, ['parse', 'import_one', 'import_bulk'], true)) {
     jsonError('Invalid action');
 }
 
@@ -67,24 +69,15 @@ if (!in_array($ext, ['xls', 'xlsx', 'csv', 'ods'], true)) {
 
 // ─── 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;
-    }
+foreach ([__DIR__ . '/../vendor/autoload.php', __DIR__ . '/../../vendor/autoload.php'] as $p) {
+    if (file_exists($p)) { require_once $p; break; }
 }
-if (!$autoloaded) {
-    jsonError('PhpSpreadsheet not installed. Run: composer require phpoffice/phpspreadsheet', 500);
+
+if (!class_exists('\PhpOffice\PhpSpreadsheet\IOFactory')) {
+    jsonError('PhpSpreadsheet not installed. Run: composer install', 500);
 }
 
 use PhpOffice\PhpSpreadsheet\IOFactory;
-use PhpOffice\PhpSpreadsheet\Spreadsheet;
 
 try {
     $spreadsheet = IOFactory::load($uploadedFile['tmp_name']);
@@ -92,16 +85,13 @@ try {
     jsonError('Could not read file: ' . $e->getMessage());
 }
 
-$sheet = $spreadsheet->getActiveSheet();
-
-// Convert sheet to a 2-D array (1-indexed rows and cols → 0-indexed)
+$sheet   = $spreadsheet->getActiveSheet();
 $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);
     }
@@ -114,39 +104,188 @@ if (empty($rawData)) {
     jsonError('The spreadsheet appears to be empty.');
 }
 
-// ─── format detection ─────────────────────────────────────────────────────────
+// ─── Lab detection ────────────────────────────────────────────────────────────
 //
-// Two layouts found in CSBP lab files:
-//
-//   TRANSPOSED (lab card)  — Column 0 = row labels ("EC 1:5", "Total P %", …)
-//                            Columns 1-N = one sample each.
-//                            Example: SOIL CONTROL XNS06189.xls
-//
-//   ROW-BASED (report)     — One row = column headers; subsequent rows = samples.
-//                            May have 1-3 title/subtitle rows above the headers.
-//                            Example: S-C Soil Tests 2006.xls, YOS06 42-48.xlsx
-//
-// Detection strategy:
-//   1. Score column-0 values for soil-chemistry label patterns (units, element
-//      names, "1:5", "ppm", etc.).  ≥2 matches → transposed.
-//   2. Otherwise scan the first 10 rows for a "header row" — the row that best
-//      matches known CSBP column-code keywords.  Everything above it is a title.
+// Client can tell us the lab via POST['lab'], or we auto-detect.
 
-/**
- * Returns ['transposed' => bool, 'headerRow' => int]
- */
-function detectFormat(array $rawData): array
+$lab = strtolower(trim($_POST['lab'] ?? 'auto'));
+
+if ($lab === 'auto' || $lab === '') {
+    if (csbpDetect($rawData)) {
+        $lab = 'csbp';
+    } else {
+        $lab = 'generic';
+    }
+}
+
+// ─── Parse into samples ───────────────────────────────────────────────────────
+
+if ($lab === 'csbp') {
+    $samples = csbpParse($rawData);
+} else {
+    $samples = genericParse($rawData);
+}
+
+if (empty($samples)) {
+    jsonError('No samples found in the file. Check the correct lab is selected.');
+}
+
+// ─── action: parse ────────────────────────────────────────────────────────────
+
+if ($action === 'parse') {
+    $list = [];
+    foreach ($samples as $idx => $s) {
+        $list[] = [
+            'idx'       => $idx,
+            'lab_no'    => $s['lab_no']      ?? 'Sample ' . ($idx + 1),
+            'sample_id' => $s['sample_id']   ?? '',   // paddock
+            'client'    => $s['client_name'] ?? '',
+            'crop'      => $s['crop_type']   ?? '',
+        ];
+    }
+    jsonOk(['samples' => $list, 'count' => count($samples), 'lab' => $lab]);
+}
+
+// ─── action: import_one ───────────────────────────────────────────────────────
+// Returns mapped field values for a single sample to pre-fill the form.
+
+if ($action === 'import_one') {
+    $idx = (int) ($_POST['sample_idx'] ?? 0);
+    if ($idx < 0 || $idx >= count($samples)) {
+        jsonError('Invalid sample index.');
+    }
+
+    $fields = $samples[$idx];
+
+    // If generic lab, try AI mapping on top
+    if ($lab === 'generic') {
+        $fields = ollamaMap($fields);
+    }
+
+    $fields = array_filter($fields, fn($v) => $v !== null && $v !== '');
+    jsonOk(['fields' => $fields, 'method' => $lab === 'csbp' ? 'csbp' : 'ai']);
+}
+
+// ─── action: import_bulk ─────────────────────────────────────────────────────
+// Saves all samples (with user-confirmed paddock IDs) directly to soil_records.
+
+if ($action === 'import_bulk') {
+    $clientId       = (int) ($_POST['client_id'] ?? 0);
+    $userId         = (int) getCurrentUserId();
+    $confirmedJson  = $_POST['samples'] ?? '[]';
+    $confirmed      = json_decode($confirmedJson, true);
+
+    if (!is_array($confirmed) || empty($confirmed)) {
+        // Fall back to all parsed samples
+        $confirmed = $samples;
+    }
+
+    if (!$clientId) {
+        jsonError('Please select a client before bulk importing.');
+    }
+
+    $pdo = getDBConnection();
+    $inserted = 0;
+    $skipped  = [];
+
+    foreach ($confirmed as $s) {
+        // Require at minimum a lab number or sample ID
+        if (empty($s['lab_no']) && empty($s['sample_id'])) {
+            $skipped[] = 'Row missing lab number and paddock';
+            continue;
+        }
+
+        // Avoid duplicates: skip if this lab_no already exists for this client
+        if (!empty($s['lab_no'])) {
+            $dup = $pdo->prepare("
+                SELECT id FROM soil_records
+                WHERE lab_no = ? AND CAST(client_records_id AS UNSIGNED) = ?
+                LIMIT 1
+            ");
+            $dup->execute([$s['lab_no'], $clientId]);
+            if ($dup->fetch()) {
+                $skipped[] = ($s['lab_no'] ?? '') . ' (duplicate)';
+                continue;
+            }
+        }
+
+        $rand = (string) (mt_rand(1000, 9999) / 1000); // matches existing rand pattern
+
+        $stmt = $pdo->prepare("
+            INSERT INTO soil_records (
+                client_records_id, modx_user_id, date, client_name,
+                analysis_type, lab_no, batch_no, sample_id, site_id,
+                crop_type, soil_type, date_sampled,
+                texture, gravel, colour,
+                ph_cacl2, ph_h2o, ec, ocarbon, omatter, paramag,
+                NO3_N, NH3_N,
+                p_mehlick, p_bray2, p_morgan,
+                k_morgan, ca_morgan, mg_morgan, na_morgan, s_morgan,
+                b_cacl2, mn_dtpa, zn_dtpa, fe_dtpa, cu_dtpa, al, se,
+                tec, cec, ca_mehlick3, mg_mehlick3, k_mehlick3, na_mehlick3, al_mehlick3,
+                rand, status
+            ) VALUES (
+                ?, ?, NOW(), ?,
+                ?, ?, ?, ?, ?,
+                ?, ?, ?,
+                ?, ?, ?,
+                ?, ?, ?, ?, ?, ?,
+                ?, ?,
+                ?, ?, ?,
+                ?, ?, ?, ?, ?,
+                ?, ?, ?, ?, ?, ?, ?,
+                ?, ?, ?, ?, ?, ?, ?,
+                ?, '0'
+            )
+        ");
+
+        $n = fn(string $key) => isset($s[$key]) && $s[$key] !== '' ? (float) $s[$key] : null;
+        $t = fn(string $key) => $s[$key] ?? null;
+
+        $stmt->execute([
+            $clientId, $userId, $t('client_name'),
+            $t('analysis_type'), $t('lab_no'), $t('batch_no'), $t('sample_id'), $t('site_id'),
+            $t('crop_type'), $t('soil_type'), $t('date_sampled'),
+            $t('texture'), $n('gravel'), $t('colour'),
+            $n('ph_cacl2'), $n('ph_h2o'), $n('ec'), $n('ocarbon'), $n('omatter'), $n('paramag'),
+            $n('NO3_N'), $n('NH3_N'),
+            $n('p_mehlick'), $n('p_bray2'), $n('p_morgan'),
+            $n('k_morgan'), $n('ca_morgan'), $n('mg_morgan'), $n('na_morgan'), $n('s_morgan'),
+            $n('b_cacl2'), $n('mn_dtpa'), $n('zn_dtpa'), $n('fe_dtpa'), $n('cu_dtpa'), $n('al'), $n('se'),
+            $n('tec'), $n('cec'), $n('ca_mehlick3'), $n('mg_mehlick3'), $n('k_mehlick3'), $n('na_mehlick3'), $n('al_mehlick3'),
+            $rand,
+        ]);
+
+        $inserted++;
+    }
+
+    jsonOk([
+        'inserted' => $inserted,
+        'skipped'  => $skipped,
+        'message'  => "{$inserted} sample" . ($inserted !== 1 ? 's' : '') . " imported successfully."
+                    . (count($skipped) ? ' Skipped: ' . implode(', ', $skipped) : ''),
+    ]);
+}
+
+// ─── Generic parser (non-CSBP files) ─────────────────────────────────────────
+
+function genericParse(array $rawData): array
+{
+    // Reuse the original detection + extraction logic for unknown labs
+    $fmt = detectGenericFormat($rawData);
+    return $fmt['transposed']
+        ? extractTransposed($rawData)
+        : extractRowBased($rawData, $fmt['headerRow']);
+}
+
+function detectGenericFormat(array $rawData): array
 {
-    // Phrases that appear in column-0 of a transposed lab card
     $transposedSignals = [
         '1:5', 'total p', 'total k', 'total ca', 'total mg', 'total na',
         'total s', 'total n', 'total b', 'total zn', 'total mn', 'total fe',
-        'total cu', 'total cl', 'organic matter', 'organic carbon',
-        'lab id', 'lab performing', 'field name', 'nitrate ppm',
-        'ph 1:5', 'moisture %', 'consultant',
+        'total cu', 'organic matter', 'organic carbon', 'lab id', 'nitrate ppm',
+        'ph 1:5', 'moisture %', 'consultant', 'field name',
     ];
-
-    // Phrases that appear in the header row of a row-based file
     $rowBasedSignals = [
         'lab_number', 'custno', 'paddock', 'ph_cacl2', 'ph_h2o',
         'dtpa_cu', 'dtpa_zn', 'dtpa_mn', 'dtpa_fe', 'conducty',
@@ -156,309 +295,135 @@ function detectFormat(array $rawData): array
         'sat_k', 'sat_na', 'crop', 'sp%',
     ];
 
-    // Step 1: score column-0 values for transposed signals
     $transposedScore = 0;
     foreach (array_slice($rawData, 0, 20) as $row) {
         $cell = strtolower($row[0] ?? '');
-        if ($cell === '') {
-            continue;
-        }
-        foreach ($transposedSignals as $signal) {
-            if (str_contains($cell, $signal)) {
-                $transposedScore++;
-                break;
-            }
+        foreach ($transposedSignals as $sig) {
+            if (str_contains($cell, $sig)) { $transposedScore++; break; }
         }
     }
-
     if ($transposedScore >= 2) {
         return ['transposed' => true, 'headerRow' => 0];
     }
 
-    // Step 2: find the best header row in a row-based file
-    $bestRow   = 0;
-    $bestScore = 0;
+    $bestRow = 0; $bestScore = 0;
     for ($i = 0; $i < min(10, count($rawData)); $i++) {
         $score = 0;
         foreach ($rawData[$i] as $cell) {
             $cell = strtolower(trim($cell));
-            if ($cell === '') {
-                continue;
+            foreach ($rowBasedSignals as $sig) {
+                if (str_contains($cell, $sig)) { $score++; break; }
             }
-            foreach ($rowBasedSignals as $signal) {
-                if (str_contains($cell, $signal)) {
-                    $score++;
-                    break;
-                }
-            }
-        }
-        if ($score > $bestScore) {
-            $bestScore = $score;
-            $bestRow   = $i;
         }
+        if ($score > $bestScore) { $bestScore = $score; $bestRow = $i; }
     }
-
     return ['transposed' => false, 'headerRow' => $bestRow];
 }
 
-// ─── extract samples ──────────────────────────────────────────────────────────
-
-function extractSamplesRowBased(array $rawData, int $headerRow): array
+function extractRowBased(array $rawData, int $headerRow): array
 {
     $headers = $rawData[$headerRow];
     $samples = [];
     for ($r = $headerRow + 1; $r < count($rawData); $r++) {
-        $row    = $rawData[$r];
-        $sample = [];
+        $row = $rawData[$r]; $sample = [];
         foreach ($headers as $c => $header) {
-            if ($header === '') {
-                continue;
-            }
+            if ($header === '') continue;
             $sample[$header] = $row[$c] ?? '';
         }
-        if (array_filter($sample)) {
-            $samples[] = $sample;
-        }
+        if (count(array_filter($sample)) >= 3) $samples[] = $sample;
     }
     return $samples;
 }
 
-function extractSamplesTransposed(array $rawData): array
+function extractTransposed(array $rawData): array
 {
-    // Column 0 = labels; columns 1-N = samples.
-    // Count sample columns from the widest row.
-    $labels     = array_column($rawData, 0);
-    $maxCols    = max(array_map('count', $rawData));
-    $samples    = [];
-
+    $labels = array_column($rawData, 0);
+    $maxCols = max(array_map('count', $rawData));
+    $samples = [];
     for ($col = 1; $col < $maxCols; $col++) {
         $sample = [];
-        foreach ($rawData as $rowIdx => $row) {
-            $label = trim($labels[$rowIdx] ?? '');
+        foreach ($rawData as $ri => $row) {
+            $label = trim($labels[$ri] ?? '');
             $value = trim($row[$col] ?? '');
-            if ($label !== '' && $value !== '') {
-                $sample[$label] = $value;
-            }
-        }
-        // Only keep columns that have at least a few populated cells
-        if (count(array_filter($sample)) >= 3) {
-            $samples[] = $sample;
+            if ($label !== '' && $value !== '') $sample[$label] = $value;
         }
+        if (count(array_filter($sample)) >= 3) $samples[] = $sample;
     }
     return $samples;
 }
 
-$fmt     = detectFormat($rawData);
-$samples = $fmt['transposed']
-    ? extractSamplesTransposed($rawData)
-    : extractSamplesRowBased($rawData, $fmt['headerRow']);
-
-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.
+// ─── Ollama field mapping (generic lab fallback) ──────────────────────────────
 
-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),
-        'format'     => $fmt['transposed'] ? 'transposed' : 'row-based',
-        'header_row' => $fmt['headerRow'] ?? 0,
-    ]);
-}
-
-// ─── 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)
+function ollamaMap(array $sampleData): array
+{
+    $labJson = json_encode($sampleData, JSON_UNESCAPED_UNICODE);
+
+    $prompt = <<<EOT
+You are a soil laboratory data mapper. Output ONLY a JSON object — no explanation, no markdown.
+
+Map the LAB DATA to these TARGET FIELDS:
+lab_no=Lab reference number
+sample_id=Sample/paddock identifier
+site_id=Site/block identifier
+date_sampled=Date as YYYY-MM-DD
+texture=Soil texture
+gravel=Gravel % (number)
 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)
+ocarbon=Organic carbon % (number)
+omatter=Organic matter % (number)
+ph_cacl2=pH CaCl2 (number)
+ph_h2o=pH water (number)
+ec=EC dS/m (number)
+NO3_N=Nitrate-N mg/kg (number)
+NH3_N=Ammonium-N mg/kg (number)
+p_morgan=Phosphorus mg/kg (number)
+k_morgan=Potassium mg/kg (number)
+ca_morgan=Calcium mg/kg (number)
+mg_morgan=Magnesium mg/kg (number)
+na_morgan=Sodium mg/kg (number)
+s_morgan=Sulphur mg/kg (number)
+b_cacl2=Boron mg/kg (number)
+mn_dtpa=Manganese mg/kg (number)
+zn_dtpa=Zinc mg/kg (number)
+fe_dtpa=Iron mg/kg (number)
+cu_dtpa=Copper mg/kg (number)
+al=Aluminium mg/kg (number)
+cec=CEC meq/100g (number)
 
 LAB DATA: {$labJson}
 
-Rules: only use values present in the lab data. Strip units from numbers. Use null for unmapped fields. Output JSON only.
+Rules: only use values in the data. Strip units. Use null for unmapped. 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 !== '');
+    $payload = json_encode([
+        'model'  => OLLAMA_MODEL,
+        'prompt' => $prompt,
+        'stream' => false,
+        'options' => ['temperature' => OLLAMA_TEMPERATURE, 'num_predict' => 512],
+    ]);
 
-jsonOk(['fields' => $mapped, 'method' => 'ai']);
+    $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);
 
-// ─── static fallback mapper ───────────────────────────────────────────────────
-// Simple keyword-based mapping used when the AI is unavailable.
+    if ($curlErr || $httpCode !== 200) return $sampleData;
 
-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'],
-    ];
+    $data    = json_decode($response, true);
+    $rawText = trim($data['response'] ?? '');
+    $rawText = preg_replace('/^```(?:json)?\s*/i', '', $rawText);
+    $rawText = preg_replace('/\s*```$/m', '', $rawText);
+    if (preg_match('/\{[\s\S]+\}/', $rawText, $m)) $rawText = $m[0];
 
-    $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;
+    $mapped = json_decode($rawText, true);
+    return is_array($mapped) ? $mapped : $sampleData;
 }

+ 41 - 23
dashboard/crop-analysis/soil-test-data/index.php

@@ -52,67 +52,85 @@ include __DIR__ . '/../../../layouts/navbar.php';
                         <!-- ── 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>
+                                <i class="fas fa-file-excel text-success"></i>
                                 <h5 class="mb-0">Import from Lab File</h5>
                             </div>
                             <div class="card-body">
                                 <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.
+                                    Upload a report file from your soil testing laboratory.
+                                    Select your lab for accurate direct mapping, or choose <em>Auto-detect</em>.
+                                    Multi-sample files can be imported all at once.
                                 </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>
+                                    <div class="col-md-3">
+                                        <label for="lab-select" class="form-label fw-semibold">Laboratory</label>
+                                        <select id="lab-select" class="form-select form-select-sm">
+                                            <option value="auto">Auto-detect</option>
+                                            <option value="csbp">CSBP</option>
+                                            <option value="generic">Other (AI mapping)</option>
+                                        </select>
+                                    </div>
+                                    <div class="col-md-6">
+                                        <label for="lab-file-input" class="form-label fw-semibold">Select file</label>
                                         <input type="file"
                                                class="form-control form-control-sm"
                                                id="lab-file-input"
                                                accept=".xls,.xlsx,.csv,.ods">
                                     </div>
-                                    <div class="col-md-4">
+                                    <div class="col-md-3">
                                         <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
+                                            <i class="fas fa-search me-1"></i>Analyse
                                         </button>
                                     </div>
                                 </div>
 
-                                <!-- Progress indicator -->
+                                <!-- Progress -->
                                 <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 -->
+                                <!-- Status 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>
+                                <!-- ── Bulk confirmation table ──────────────── -->
+                                <div id="bulk-import-panel" hidden class="mt-3">
+                                    <div class="d-flex justify-content-between align-items-center mb-2">
+                                        <h6 class="fw-semibold mb-0">Review &amp; Import Samples</h6>
+                                        <button id="bulk-import-btn" class="btn btn-success btn-sm" type="button">
+                                            <i class="fas fa-database me-1"></i>Import Selected
+                                        </button>
+                                    </div>
+                                    <p class="text-muted small mb-2">
+                                        Edit the <strong>Paddock / Sample ID</strong> if needed, uncheck any rows to skip,
+                                        then click <em>Import Selected</em>. Records are saved directly — no need to fill the form below.
+                                    </p>
                                     <div class="table-responsive">
-                                        <table id="sample-picker-table" class="table table-sm table-hover align-middle">
+                                        <table class="table table-sm table-hover align-middle mb-0">
                                             <thead class="table-light">
                                                 <tr>
-                                                    <th>Lab ID</th>
+                                                    <th class="text-center" style="width:40px">
+                                                        <input type="checkbox" class="form-check-input" id="bulk-select-all" checked title="Select all">
+                                                    </th>
+                                                    <th>Lab No.</th>
+                                                    <th>Paddock / Sample ID</th>
                                                     <th>Client</th>
-                                                    <th>Site / Paddock</th>
-                                                    <th>Crop / Material</th>
-                                                    <th></th>
+                                                    <th>Crop</th>
+                                                    <th class="text-center" style="width:50px">Form</th>
                                                 </tr>
                                             </thead>
-                                            <tbody></tbody>
+                                            <tbody id="bulk-import-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.
+                                    <i class="fas fa-info-circle me-1"></i>
+                                    Fields highlighted in green were auto-populated. Always review before submitting.
                                 </p>
                             </div>
                         </div>

BIN
doc/SOIL CONTROL YOS06 42-48 - Copy.xlsx