Benjamin Harris 2 ماه پیش
والد
کامیت
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 \\\\`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(grep -A 20 \"CREATE TABLE \\\\`crop_info\\\\`\" \"f:/GIT_REPO/crop_monitor/cropmonitor.sql\")",
       "Bash(composer install:*)",
       "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
  * 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:
  * 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 () {
 (function () {
     'use strict';
     'use strict';
 
 
     // ── DOM refs ──────────────────────────────────────────────────────────────
     // ── 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') {
     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();
         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;
         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 {
             } 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');
             const tr = document.createElement('tr');
+            tr.dataset.idx = idx;
             tr.innerHTML = `
             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>
                 <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>
                     </button>
                 </td>`;
                 </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 {
         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) {
             if (!data.success) {
-                showStatus('Import failed: ' + data.error, 'danger');
+                showStatus('Could not map fields: ' + escHtml(data.error), 'danger');
                 return;
                 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(
             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' });
             document.getElementById('SoilcsvForm')?.scrollIntoView({ behavior: 'smooth' });
 
 
         } catch (err) {
         } catch (err) {
-            showStatus('Error: ' + err.message, 'danger');
+            showStatus('Error: ' + escHtml(err.message), 'danger');
         } finally {
         } finally {
             setProgress(false);
             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
  * 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
  * 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/database.php';
 require_once __DIR__ . '/../config/ai.php';
 require_once __DIR__ . '/../config/ai.php';
 require_once __DIR__ . '/../lib/auth.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) {
 if (session_status() === PHP_SESSION_NONE) {
     session_start();
     session_start();
 }
 }
 
 
 requireLogin();
 requireLogin();
-
 header('Content-Type: application/json');
 header('Content-Type: application/json');
 
 
 // ─── helpers ─────────────────────────────────────────────────────────────────
 // ─── helpers ─────────────────────────────────────────────────────────────────
@@ -48,7 +50,7 @@ if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
 }
 }
 
 
 $action = $_POST['action'] ?? 'parse';
 $action = $_POST['action'] ?? 'parse';
-if (!in_array($action, ['parse', 'import'], true)) {
+if (!in_array($action, ['parse', 'import_one', 'import_bulk'], true)) {
     jsonError('Invalid action');
     jsonError('Invalid action');
 }
 }
 
 
@@ -67,24 +69,15 @@ if (!in_array($ext, ['xls', 'xlsx', 'csv', 'ods'], true)) {
 
 
 // ─── PhpSpreadsheet ───────────────────────────────────────────────────────────
 // ─── 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\IOFactory;
-use PhpOffice\PhpSpreadsheet\Spreadsheet;
 
 
 try {
 try {
     $spreadsheet = IOFactory::load($uploadedFile['tmp_name']);
     $spreadsheet = IOFactory::load($uploadedFile['tmp_name']);
@@ -92,16 +85,13 @@ try {
     jsonError('Could not read file: ' . $e->getMessage());
     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 = [];
 $rawData = [];
 foreach ($sheet->getRowIterator() as $row) {
 foreach ($sheet->getRowIterator() as $row) {
     $cells = [];
     $cells = [];
     foreach ($row->getCellIterator() as $cell) {
     foreach ($row->getCellIterator() as $cell) {
         $cells[] = trim((string) $cell->getFormattedValue());
         $cells[] = trim((string) $cell->getFormattedValue());
     }
     }
-    // Strip trailing empty cells
     while ($cells && end($cells) === '') {
     while ($cells && end($cells) === '') {
         array_pop($cells);
         array_pop($cells);
     }
     }
@@ -114,39 +104,188 @@ if (empty($rawData)) {
     jsonError('The spreadsheet appears to be empty.');
     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 = [
     $transposedSignals = [
         '1:5', 'total p', 'total k', 'total ca', 'total mg', 'total na',
         '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 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 = [
     $rowBasedSignals = [
         'lab_number', 'custno', 'paddock', 'ph_cacl2', 'ph_h2o',
         'lab_number', 'custno', 'paddock', 'ph_cacl2', 'ph_h2o',
         'dtpa_cu', 'dtpa_zn', 'dtpa_mn', 'dtpa_fe', 'conducty',
         'dtpa_cu', 'dtpa_zn', 'dtpa_mn', 'dtpa_fe', 'conducty',
@@ -156,309 +295,135 @@ function detectFormat(array $rawData): array
         'sat_k', 'sat_na', 'crop', 'sp%',
         'sat_k', 'sat_na', 'crop', 'sp%',
     ];
     ];
 
 
-    // Step 1: score column-0 values for transposed signals
     $transposedScore = 0;
     $transposedScore = 0;
     foreach (array_slice($rawData, 0, 20) as $row) {
     foreach (array_slice($rawData, 0, 20) as $row) {
         $cell = strtolower($row[0] ?? '');
         $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) {
     if ($transposedScore >= 2) {
         return ['transposed' => true, 'headerRow' => 0];
         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++) {
     for ($i = 0; $i < min(10, count($rawData)); $i++) {
         $score = 0;
         $score = 0;
         foreach ($rawData[$i] as $cell) {
         foreach ($rawData[$i] as $cell) {
             $cell = strtolower(trim($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];
     return ['transposed' => false, 'headerRow' => $bestRow];
 }
 }
 
 
-// ─── extract samples ──────────────────────────────────────────────────────────
-
-function extractSamplesRowBased(array $rawData, int $headerRow): array
+function extractRowBased(array $rawData, int $headerRow): array
 {
 {
     $headers = $rawData[$headerRow];
     $headers = $rawData[$headerRow];
     $samples = [];
     $samples = [];
     for ($r = $headerRow + 1; $r < count($rawData); $r++) {
     for ($r = $headerRow + 1; $r < count($rawData); $r++) {
-        $row    = $rawData[$r];
-        $sample = [];
+        $row = $rawData[$r]; $sample = [];
         foreach ($headers as $c => $header) {
         foreach ($headers as $c => $header) {
-            if ($header === '') {
-                continue;
-            }
+            if ($header === '') continue;
             $sample[$header] = $row[$c] ?? '';
             $sample[$header] = $row[$c] ?? '';
         }
         }
-        if (array_filter($sample)) {
-            $samples[] = $sample;
-        }
+        if (count(array_filter($sample)) >= 3) $samples[] = $sample;
     }
     }
     return $samples;
     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++) {
     for ($col = 1; $col < $maxCols; $col++) {
         $sample = [];
         $sample = [];
-        foreach ($rawData as $rowIdx => $row) {
-            $label = trim($labels[$rowIdx] ?? '');
+        foreach ($rawData as $ri => $row) {
+            $label = trim($labels[$ri] ?? '');
             $value = trim($row[$col] ?? '');
             $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;
     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
 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}
 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;
 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 ────────────────────────────── -->
                         <!-- ── Lab File Import ────────────────────────────── -->
                         <div class="card mb-4">
                         <div class="card mb-4">
                             <div class="card-header d-flex align-items-center gap-2">
                             <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>
                                 <h5 class="mb-0">Import from Lab File</h5>
                             </div>
                             </div>
                             <div class="card-body">
                             <div class="card-body">
                                 <p class="text-muted mb-3">
                                 <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>
                                 </p>
 
 
                                 <div class="row g-2 align-items-end">
                                 <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"
                                         <input type="file"
                                                class="form-control form-control-sm"
                                                class="form-control form-control-sm"
                                                id="lab-file-input"
                                                id="lab-file-input"
                                                accept=".xls,.xlsx,.csv,.ods">
                                                accept=".xls,.xlsx,.csv,.ods">
                                     </div>
                                     </div>
-                                    <div class="col-md-4">
+                                    <div class="col-md-3">
                                         <button class="btn btn-success w-100"
                                         <button class="btn btn-success w-100"
                                                 type="button"
                                                 type="button"
                                                 id="lab-analyse-btn"
                                                 id="lab-analyse-btn"
                                                 disabled>
                                                 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>
                                         </button>
                                     </div>
                                     </div>
                                 </div>
                                 </div>
 
 
-                                <!-- Progress indicator -->
+                                <!-- Progress -->
                                 <div id="import-progress" class="mt-3 d-flex align-items-center gap-2 text-muted" hidden>
                                 <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>
                                     <div class="spinner-border spinner-border-sm text-success" role="status"></div>
                                     <span class="progress-label">Processing…</span>
                                     <span class="progress-label">Processing…</span>
                                 </div>
                                 </div>
 
 
-                                <!-- Status / result message -->
+                                <!-- Status message -->
                                 <div id="import-status" class="alert mt-3" hidden></div>
                                 <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">
                                     <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">
                                             <thead class="table-light">
                                                 <tr>
                                                 <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>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>
                                                 </tr>
                                             </thead>
                                             </thead>
-                                            <tbody></tbody>
+                                            <tbody id="bulk-import-tbody"></tbody>
                                         </table>
                                         </table>
                                     </div>
                                     </div>
                                 </div>
                                 </div>
 
 
                                 <p class="text-muted small mt-3 mb-0">
                                 <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>
                                 </p>
                             </div>
                             </div>
                         </div>
                         </div>

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