- 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>
231 lines
8.5 KiB
Vue
231 lines
8.5 KiB
Vue
<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>
|