feat: Ollama Vision OCR for bill/invoice scanning

- Ollama container running llama3.2-vision:11b on server
- OCR page in ops app: camera/upload → Ollama extracts vendor, date,
  amounts, line items → editable form → create Purchase Invoice
- nginx proxies /ollama/ to Ollama API (both ops + field containers)
- Added createDoc to erp.js API layer

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-03-30 23:57:21 -04:00
parent dc63462c0c
commit 2453bc6ef2
8 changed files with 428 additions and 0 deletions

View File

@ -16,6 +16,15 @@ server {
proxy_set_header X-Forwarded-Proto https; proxy_set_header X-Forwarded-Proto https;
} }
# Ollama Vision API proxy for bill/invoice OCR
location /ollama/ {
proxy_pass http://ollama:11434/;
proxy_set_header Host $host;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
client_max_body_size 20m;
}
# SPA fallback # SPA fallback
location / { location / {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;

83
apps/field/src/api/ocr.js Normal file
View File

@ -0,0 +1,83 @@
import { authFetch } from './auth'
const OLLAMA_URL = '/ollama/api/generate'
const OCR_PROMPT = `You are an invoice/bill OCR assistant. Extract the following fields from this image of a bill or invoice. Return ONLY valid JSON, no markdown, no explanation.
{
"vendor": "company name on the bill",
"vendor_address": "full address if visible",
"invoice_number": "invoice/bill number",
"date": "YYYY-MM-DD format",
"due_date": "YYYY-MM-DD if visible, null otherwise",
"subtotal": 0.00,
"tax_gst": 0.00,
"tax_qst": 0.00,
"total": 0.00,
"currency": "CAD",
"items": [
{ "description": "line item description", "qty": 1, "rate": 0.00, "amount": 0.00 }
],
"notes": "any other relevant text (account number, payment terms, etc.)"
}
If a field is not visible, set it to null. Always return valid JSON.`
/**
* Send an image to Ollama Vision for bill/invoice OCR.
* @param {string} base64Image base64 encoded image (no data: prefix)
* @returns {object} Parsed invoice data
*/
export async function ocrBill (base64Image) {
// Strip data:image/...;base64, prefix if present
const clean = base64Image.replace(/^data:image\/[^;]+;base64,/, '')
const res = await authFetch(OLLAMA_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'llama3.2-vision:11b',
prompt: OCR_PROMPT,
images: [clean],
stream: false,
options: {
temperature: 0.1,
num_predict: 2048,
},
}),
})
if (!res.ok) {
const text = await res.text()
throw new Error('OCR failed: ' + (text || res.status))
}
const data = await res.json()
const raw = data.response || ''
// Extract JSON from response (model might wrap it in markdown)
const jsonMatch = raw.match(/\{[\s\S]*\}/)
if (!jsonMatch) throw new Error('No JSON in OCR response')
try {
return JSON.parse(jsonMatch[0])
} catch (e) {
throw new Error('Invalid JSON from OCR: ' + e.message)
}
}
/**
* Check if Ollama is running and the vision model is available.
*/
export async function checkOllamaStatus () {
try {
const res = await authFetch('/ollama/api/tags')
if (!res.ok) return { online: false, error: 'HTTP ' + res.status }
const data = await res.json()
const models = (data.models || []).map(m => m.name)
const hasVision = models.some(m => m.includes('llama3.2-vision'))
return { online: true, models, hasVision }
} catch (e) {
return { online: false, error: e.message }
}
}

View File

@ -17,6 +17,15 @@ server {
proxy_set_header X-Forwarded-Proto https; proxy_set_header X-Forwarded-Proto https;
} }
# Ollama Vision API proxy for bill/invoice OCR
location /ollama/ {
proxy_pass http://ollama:11434/;
proxy_set_header Host $host;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
client_max_body_size 20m;
}
# SPA fallback all routes serve index.html # SPA fallback all routes serve index.html
location / { location / {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;

View File

@ -39,6 +39,18 @@ export async function searchDocs (doctype, text, { filters = {}, fields = ['name
return data.message || [] return data.message || []
} }
// Create a new document
export async function createDoc (doctype, data) {
const res = await authFetch(BASE_URL + '/api/resource/' + doctype, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (!res.ok) throw new Error('Create failed: ' + res.status)
const json = await res.json()
return json.data
}
// Update a document (partial update) // Update a document (partial update)
export async function updateDoc (doctype, name, data) { export async function updateDoc (doctype, name, data) {
const res = await authFetch(BASE_URL + '/api/resource/' + doctype + '/' + encodeURIComponent(name), { const res = await authFetch(BASE_URL + '/api/resource/' + doctype + '/' + encodeURIComponent(name), {

83
apps/ops/src/api/ocr.js Normal file
View File

@ -0,0 +1,83 @@
import { authFetch } from './auth'
const OLLAMA_URL = '/ollama/api/generate'
const OCR_PROMPT = `You are an invoice/bill OCR assistant. Extract the following fields from this image of a bill or invoice. Return ONLY valid JSON, no markdown, no explanation.
{
"vendor": "company name on the bill",
"vendor_address": "full address if visible",
"invoice_number": "invoice/bill number",
"date": "YYYY-MM-DD format",
"due_date": "YYYY-MM-DD if visible, null otherwise",
"subtotal": 0.00,
"tax_gst": 0.00,
"tax_qst": 0.00,
"total": 0.00,
"currency": "CAD",
"items": [
{ "description": "line item description", "qty": 1, "rate": 0.00, "amount": 0.00 }
],
"notes": "any other relevant text (account number, payment terms, etc.)"
}
If a field is not visible, set it to null. Always return valid JSON.`
/**
* Send an image to Ollama Vision for bill/invoice OCR.
* @param {string} base64Image base64 encoded image (no data: prefix)
* @returns {object} Parsed invoice data
*/
export async function ocrBill (base64Image) {
// Strip data:image/...;base64, prefix if present
const clean = base64Image.replace(/^data:image\/[^;]+;base64,/, '')
const res = await authFetch(OLLAMA_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'llama3.2-vision:11b',
prompt: OCR_PROMPT,
images: [clean],
stream: false,
options: {
temperature: 0.1,
num_predict: 2048,
},
}),
})
if (!res.ok) {
const text = await res.text()
throw new Error('OCR failed: ' + (text || res.status))
}
const data = await res.json()
const raw = data.response || ''
// Extract JSON from response (model might wrap it in markdown)
const jsonMatch = raw.match(/\{[\s\S]*\}/)
if (!jsonMatch) throw new Error('No JSON in OCR response')
try {
return JSON.parse(jsonMatch[0])
} catch (e) {
throw new Error('Invalid JSON from OCR: ' + e.message)
}
}
/**
* Check if Ollama is running and the vision model is available.
*/
export async function checkOllamaStatus () {
try {
const res = await authFetch('/ollama/api/tags')
if (!res.ok) return { online: false, error: 'HTTP ' + res.status }
const data = await res.json()
const models = (data.models || []).map(m => m.name)
const hasVision = models.some(m => m.includes('llama3.2-vision'))
return { online: true, models, hasVision }
} catch (e) {
return { online: false, error: e.message }
}
}

View File

@ -263,6 +263,7 @@ const navItems = [
{ path: '/tickets', icon: 'confirmation_number', label: 'Tickets' }, { path: '/tickets', icon: 'confirmation_number', label: 'Tickets' },
{ path: '/equipe', icon: 'groups', label: 'Équipe' }, { path: '/equipe', icon: 'groups', label: 'Équipe' },
{ path: '/rapports', icon: 'bar_chart', label: 'Rapports' }, { path: '/rapports', icon: 'bar_chart', label: 'Rapports' },
{ path: '/ocr', icon: 'document_scanner', label: 'OCR Factures' },
] ]
const currentNav = computed(() => navItems.find(n => n.path === route.path) || navItems.find(n => route.path.startsWith(n.path) && n.path !== '/')) const currentNav = computed(() => navItems.find(n => n.path === route.path) || navItems.find(n => route.path.startsWith(n.path) && n.path !== '/'))

View File

@ -0,0 +1,230 @@
<template>
<q-page padding>
<div class="row items-center q-mb-md">
<q-icon name="document_scanner" size="28px" class="q-mr-sm" color="indigo-6" />
<div class="text-h6">Scanner une facture</div>
<q-space />
<q-badge v-if="ollamaStatus" :color="ollamaStatus.online ? 'green' : 'red'"
:label="ollamaStatus.online ? 'Ollama en ligne' : 'Ollama hors ligne'" />
</div>
<!-- Upload / Camera -->
<q-card class="q-mb-md" v-if="!result">
<q-card-section>
<div class="text-center q-gutter-md">
<q-btn color="primary" icon="photo_camera" label="Prendre une photo" size="lg" @click="$refs.camera.click()" />
<q-btn color="secondary" icon="upload_file" label="Importer un fichier" size="lg" @click="$refs.fileInput.click()" />
</div>
<input ref="camera" type="file" accept="image/*" capture="environment" class="hidden" @change="onFile" />
<input ref="fileInput" type="file" accept="image/*,.pdf" class="hidden" @change="onFile" />
</q-card-section>
<!-- Preview -->
<q-card-section v-if="preview" class="text-center">
<img :src="preview" style="max-width:100%;max-height:400px;border-radius:8px" />
<div class="q-mt-md">
<q-btn color="indigo-6" icon="document_scanner" label="Analyser avec Ollama Vision" :loading="processing" @click="runOcr" size="lg" />
</div>
<q-linear-progress v-if="processing" indeterminate color="indigo-6" class="q-mt-sm" />
<div v-if="processing" class="text-caption text-grey q-mt-xs">Analyse en cours... (peut prendre 30-60s sur CPU)</div>
</q-card-section>
</q-card>
<!-- Error -->
<q-banner v-if="error" type="negative" class="q-mb-md text-negative" rounded>
<template v-slot:avatar><q-icon name="error" color="negative" /></template>
{{ error }}
<template v-slot:action>
<q-btn flat label="Réessayer" @click="error = null" />
</template>
</q-banner>
<!-- Results -->
<template v-if="result">
<q-card class="q-mb-md">
<q-card-section>
<div class="row items-center q-mb-sm">
<q-icon name="check_circle" color="positive" size="24px" class="q-mr-sm" />
<div class="text-subtitle1 text-weight-bold">Résultat OCR</div>
<q-space />
<q-btn flat dense icon="restart_alt" label="Nouvelle" @click="reset" />
</div>
<!-- Side by side: image + data -->
<div class="row q-gutter-md">
<div class="col-12 col-md-5">
<img :src="preview" style="width:100%;border-radius:8px" />
</div>
<div class="col">
<div class="ocr-grid">
<div class="ocr-field">
<label>Fournisseur</label>
<q-input v-model="result.vendor" dense outlined />
</div>
<div class="ocr-field">
<label>No facture</label>
<q-input v-model="result.invoice_number" dense outlined />
</div>
<div class="ocr-field">
<label>Date</label>
<q-input v-model="result.date" dense outlined type="date" />
</div>
<div class="ocr-field">
<label>Échéance</label>
<q-input v-model="result.due_date" dense outlined type="date" />
</div>
<div class="ocr-field">
<label>Sous-total</label>
<q-input v-model.number="result.subtotal" dense outlined type="number" prefix="$" />
</div>
<div class="ocr-field">
<label>TPS (5%)</label>
<q-input v-model.number="result.tax_gst" dense outlined type="number" prefix="$" />
</div>
<div class="ocr-field">
<label>TVQ (9.975%)</label>
<q-input v-model.number="result.tax_qst" dense outlined type="number" prefix="$" />
</div>
<div class="ocr-field">
<label>Total</label>
<q-input v-model.number="result.total" dense outlined type="number" prefix="$" class="text-weight-bold" />
</div>
</div>
<!-- Line items -->
<div v-if="result.items?.length" class="q-mt-md">
<div class="text-caption text-weight-bold text-grey-7 q-mb-xs">ARTICLES</div>
<q-table :rows="result.items" :columns="itemCols" row-key="description" flat dense hide-pagination
:pagination="{ rowsPerPage: 0 }" class="ops-table">
<template #body-cell-rate="p">
<q-td :props="p" class="text-right">{{ formatMoney(p.row.rate) }}</q-td>
</template>
<template #body-cell-amount="p">
<q-td :props="p" class="text-right">{{ formatMoney(p.row.amount) }}</q-td>
</template>
</q-table>
</div>
<!-- Notes -->
<div v-if="result.notes" class="q-mt-md">
<div class="text-caption text-weight-bold text-grey-7 q-mb-xs">NOTES</div>
<div class="text-body2" style="white-space:pre-line">{{ result.notes }}</div>
</div>
</div>
</div>
</q-card-section>
<q-card-actions align="right" class="q-px-md q-pb-md">
<q-btn flat label="Annuler" @click="reset" />
<q-btn color="primary" icon="save" label="Créer dans ERPNext" :loading="saving" @click="createInErpNext" />
</q-card-actions>
</q-card>
<!-- Raw JSON toggle -->
<q-expansion-item label="JSON brut" dense header-class="text-caption text-grey-7">
<pre class="q-pa-sm" style="background:#f1f5f9;border-radius:8px;font-size:12px;overflow-x:auto">{{ JSON.stringify(result, null, 2) }}</pre>
</q-expansion-item>
</template>
</q-page>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ocrBill, checkOllamaStatus } from 'src/api/ocr'
import { createDoc } from 'src/api/erp'
import { formatMoney } from 'src/composables/useFormatters'
import { Notify } from 'quasar'
const preview = ref(null)
const imageBase64 = ref(null)
const processing = ref(false)
const saving = ref(false)
const result = ref(null)
const error = ref(null)
const ollamaStatus = ref(null)
const itemCols = [
{ name: 'description', label: 'Description', field: 'description', align: 'left' },
{ name: 'qty', label: 'Qté', field: 'qty', align: 'center' },
{ name: 'rate', label: 'Prix', field: 'rate', align: 'right' },
{ name: 'amount', label: 'Montant', field: 'amount', align: 'right' },
]
function onFile (e) {
const file = e.target.files?.[0]
if (!file) return
error.value = null
result.value = null
preview.value = URL.createObjectURL(file)
const reader = new FileReader()
reader.onload = () => { imageBase64.value = reader.result }
reader.readAsDataURL(file)
}
async function runOcr () {
if (!imageBase64.value) return
processing.value = true
error.value = null
try {
result.value = await ocrBill(imageBase64.value)
} catch (e) {
error.value = e.message
} finally {
processing.value = false
}
}
async function createInErpNext () {
if (!result.value) return
saving.value = true
try {
const r = result.value
const doc = await createDoc('Purchase Invoice', {
supplier: r.vendor || 'Unknown',
bill_no: r.invoice_number || '',
bill_date: r.date || new Date().toISOString().slice(0, 10),
due_date: r.due_date || null,
items: (r.items || []).map((item, i) => ({
item_code: 'Expense - OCR',
description: item.description,
qty: item.qty || 1,
rate: item.rate || item.amount || 0,
idx: i + 1,
})),
remarks: r.notes || 'Imported via OCR',
})
Notify.create({ type: 'positive', message: 'Facture créée: ' + doc.name })
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message })
} finally {
saving.value = false
}
}
function reset () {
preview.value = null
imageBase64.value = null
result.value = null
error.value = null
}
onMounted(async () => {
ollamaStatus.value = await checkOllamaStatus()
})
</script>
<style scoped>
.ocr-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.ocr-field label {
font-size: 0.72rem;
font-weight: 600;
color: #6b7280;
text-transform: uppercase;
letter-spacing: 0.03em;
}
</style>

View File

@ -12,6 +12,7 @@ const routes = [
{ path: 'tickets', component: () => import('src/pages/TicketsPage.vue') }, { path: 'tickets', component: () => import('src/pages/TicketsPage.vue') },
{ path: 'equipe', component: () => import('src/pages/EquipePage.vue') }, { path: 'equipe', component: () => import('src/pages/EquipePage.vue') },
{ path: 'rapports', component: () => import('src/pages/RapportsPage.vue') }, { path: 'rapports', component: () => import('src/pages/RapportsPage.vue') },
{ path: 'ocr', component: () => import('src/pages/OcrPage.vue') },
], ],
}, },
// Dispatch V2 — full-screen immersive UI with its own header/sidebar // Dispatch V2 — full-screen immersive UI with its own header/sidebar