feat: Add OCR integration for automatic receipt data extraction

Implement Tesseract-based OCR to automatically extract vendor name,
date, total amount, and VAT from uploaded receipt images/PDFs,
reducing manual data entry and improving accuracy.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-12 11:48:29 +02:00
parent 5960154094
commit 41ae97180e
16 changed files with 2773 additions and 32 deletions

View File

@@ -0,0 +1,125 @@
<template>
<span
class="confidence-indicator"
:class="confidenceClass"
:title="tooltipText"
>
<i :class="iconClass"></i>
<span v-if="showPercentage" class="percentage">{{ percentageText }}</span>
</span>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
confidence: {
type: Number,
required: true,
validator: (value) => value >= 0 && value <= 1
},
showPercentage: {
type: Boolean,
default: false
},
size: {
type: String,
default: 'normal',
validator: (value) => ['small', 'normal', 'large'].includes(value)
}
})
const percentageText = computed(() => {
return Math.round(props.confidence * 100) + '%'
})
const confidenceClass = computed(() => {
const classes = [`size-${props.size}`]
if (props.confidence >= 0.85) {
classes.push('high')
} else if (props.confidence >= 0.6) {
classes.push('medium')
} else {
classes.push('low')
}
return classes
})
const iconClass = computed(() => {
if (props.confidence >= 0.85) {
return 'pi pi-check-circle'
} else if (props.confidence >= 0.6) {
return 'pi pi-exclamation-circle'
} else {
return 'pi pi-question-circle'
}
})
const tooltipText = computed(() => {
const percent = Math.round(props.confidence * 100)
if (props.confidence >= 0.85) {
return `Incredere ridicata: ${percent}%`
} else if (props.confidence >= 0.6) {
return `Incredere medie: ${percent}% - verifica valoarea`
} else {
return `Incredere scazuta: ${percent}% - completeaza manual`
}
})
</script>
<style scoped>
.confidence-indicator {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.15rem 0.5rem;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 500;
}
/* Sizes */
.size-small {
font-size: 0.7rem;
padding: 0.1rem 0.35rem;
}
.size-small i {
font-size: 0.75rem;
}
.size-normal i {
font-size: 0.85rem;
}
.size-large {
font-size: 0.85rem;
padding: 0.2rem 0.6rem;
}
.size-large i {
font-size: 1rem;
}
/* Confidence levels */
.high {
background: #dcfce7;
color: #166534;
}
.medium {
background: #fef9c3;
color: #854d0e;
}
.low {
background: #fee2e2;
color: #991b1b;
}
.percentage {
font-variant-numeric: tabular-nums;
}
</style>

View File

@@ -0,0 +1,279 @@
<template>
<div class="ocr-preview">
<div class="preview-header">
<div class="header-left">
<i class="pi pi-check-circle" style="color: #22c55e; font-size: 1.25rem;"></i>
<span class="title">Date extrase din imagine</span>
</div>
<div class="header-right">
<span class="overall-confidence">
Incredere generala:
<OCRConfidenceIndicator
:confidence="data.overall_confidence"
:show-percentage="true"
size="normal"
/>
</span>
</div>
</div>
<div class="preview-content">
<div class="preview-grid">
<!-- Receipt Type -->
<div class="preview-field" v-if="data.receipt_type">
<label>Tip Document</label>
<div class="field-value">
<Tag
:value="data.receipt_type === 'bon_fiscal' ? 'Bon Fiscal' : 'Chitanta'"
:severity="data.receipt_type === 'bon_fiscal' ? 'info' : 'success'"
/>
</div>
</div>
<!-- Amount -->
<div class="preview-field" v-if="data.amount">
<label>
Suma
<OCRConfidenceIndicator :confidence="data.confidence_amount" size="small" />
</label>
<div class="field-value amount">
{{ formatAmount(data.amount) }} RON
</div>
</div>
<!-- Date -->
<div class="preview-field" v-if="data.receipt_date">
<label>
Data
<OCRConfidenceIndicator :confidence="data.confidence_date" size="small" />
</label>
<div class="field-value">
{{ formatDate(data.receipt_date) }}
</div>
</div>
<!-- Receipt Number -->
<div class="preview-field" v-if="data.receipt_number">
<label>Numar Bon</label>
<div class="field-value">
{{ data.receipt_series ? data.receipt_series + ' ' : '' }}{{ data.receipt_number }}
</div>
</div>
<!-- Vendor -->
<div class="preview-field full-width" v-if="data.partner_name">
<label>
Furnizor
<OCRConfidenceIndicator :confidence="data.confidence_vendor" size="small" />
</label>
<div class="field-value">
{{ data.partner_name }}
<span v-if="data.cui" class="cui-badge">CUI: {{ data.cui }}</span>
</div>
</div>
</div>
<!-- Raw Text Toggle -->
<div class="raw-text-section" v-if="data.raw_text">
<Button
:label="showRawText ? 'Ascunde text OCR' : 'Arata text OCR'"
:icon="showRawText ? 'pi pi-eye-slash' : 'pi pi-eye'"
severity="secondary"
size="small"
text
@click="showRawText = !showRawText"
/>
<div v-if="showRawText" class="raw-text">
<pre>{{ data.raw_text }}</pre>
</div>
</div>
</div>
<div class="preview-actions">
<Button
label="Ignora"
icon="pi pi-times"
severity="secondary"
@click="$emit('dismiss')"
/>
<Button
label="Aplica datele in formular"
icon="pi pi-check"
@click="$emit('apply', data)"
/>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import OCRConfidenceIndicator from './OCRConfidenceIndicator.vue'
const props = defineProps({
data: {
type: Object,
required: true
}
})
defineEmits(['apply', 'dismiss'])
const showRawText = ref(false)
const formatAmount = (amount) => {
const num = parseFloat(amount)
return num.toLocaleString('ro-RO', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
})
}
const formatDate = (dateStr) => {
if (!dateStr) return ''
const date = new Date(dateStr)
return date.toLocaleDateString('ro-RO', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
})
}
</script>
<style scoped>
.ocr-preview {
background: #f0fdf4;
border: 1px solid #86efac;
border-radius: 12px;
margin: 1rem 0;
overflow: hidden;
}
.preview-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
background: #dcfce7;
border-bottom: 1px solid #86efac;
}
.header-left {
display: flex;
align-items: center;
gap: 0.5rem;
}
.title {
font-weight: 600;
color: #166534;
}
.overall-confidence {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
color: #166534;
}
.preview-content {
padding: 1rem;
}
.preview-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 1rem;
}
.preview-field {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.preview-field.full-width {
grid-column: 1 / -1;
}
.preview-field label {
font-size: 0.8rem;
color: #64748b;
display: flex;
align-items: center;
gap: 0.5rem;
}
.field-value {
font-weight: 500;
color: #1e293b;
}
.field-value.amount {
font-size: 1.25rem;
color: #166534;
}
.cui-badge {
display: inline-block;
margin-left: 0.5rem;
padding: 0.15rem 0.5rem;
background: #e2e8f0;
border-radius: 4px;
font-size: 0.8rem;
color: #475569;
}
.raw-text-section {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px dashed #86efac;
}
.raw-text {
margin-top: 0.5rem;
padding: 0.75rem;
background: white;
border: 1px solid #e2e8f0;
border-radius: 8px;
max-height: 200px;
overflow: auto;
}
.raw-text pre {
margin: 0;
font-size: 0.75rem;
white-space: pre-wrap;
word-break: break-word;
color: #475569;
}
.preview-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
padding: 0.75rem 1rem;
background: #f8fafc;
border-top: 1px solid #e2e8f0;
}
@media (max-width: 640px) {
.preview-header {
flex-direction: column;
gap: 0.5rem;
align-items: flex-start;
}
.preview-grid {
grid-template-columns: 1fr;
}
.preview-actions {
flex-direction: column;
}
.preview-actions :deep(.p-button) {
width: 100%;
}
}
</style>

View File

@@ -0,0 +1,291 @@
<template>
<div class="ocr-upload-zone">
<div
class="upload-dropzone"
:class="{ 'dragging': isDragging, 'processing': processing }"
@dragover.prevent="onDragOver"
@dragleave.prevent="onDragLeave"
@drop.prevent="onDrop"
@click="triggerFileInput"
>
<input
ref="fileInput"
type="file"
accept="image/*,application/pdf"
class="hidden-input"
@change="onFileSelected"
/>
<div v-if="processing" class="processing-state">
<ProgressSpinner
style="width: 50px; height: 50px"
strokeWidth="4"
/>
<p class="processing-text">Se proceseaza imaginea...</p>
<p class="processing-subtext">Acest proces poate dura cateva secunde</p>
</div>
<div v-else-if="selectedFile" class="file-selected-state">
<i class="pi pi-check-circle" style="font-size: 2.5rem; color: #22c55e;"></i>
<p class="file-name">{{ selectedFile.name }}</p>
<p class="file-size">{{ formatFileSize(selectedFile.size) }}</p>
<div class="file-actions">
<Button
label="Schimba fisierul"
icon="pi pi-refresh"
severity="secondary"
size="small"
@click.stop="triggerFileInput"
/>
<Button
label="Proceseaza cu OCR"
icon="pi pi-cog"
size="small"
@click.stop="processOCR"
/>
</div>
</div>
<div v-else class="empty-state">
<i class="pi pi-camera" style="font-size: 3rem; color: #667eea;"></i>
<p class="main-text">
<span v-if="isDragging">Elibereaza pentru a incarca</span>
<span v-else>Trage poza bonului aici sau click pentru a selecta</span>
</p>
<p class="sub-text">
Formate acceptate: JPG, PNG, PDF (max 10MB)
</p>
<p class="ocr-hint">
<i class="pi pi-sparkles"></i>
OCR va extrage automat datele din bon
</p>
</div>
</div>
<!-- OCR Error Message -->
<Message v-if="error" severity="error" :closable="true" @close="error = null">
{{ error }}
</Message>
</div>
</template>
<script setup>
import { ref } from 'vue'
import axios from 'axios'
const emit = defineEmits(['ocr-result', 'file-selected', 'error'])
const fileInput = ref(null)
const selectedFile = ref(null)
const isDragging = ref(false)
const processing = ref(false)
const error = ref(null)
const onDragOver = () => {
isDragging.value = true
}
const onDragLeave = () => {
isDragging.value = false
}
const onDrop = (event) => {
isDragging.value = false
const files = event.dataTransfer?.files
if (files?.length > 0) {
handleFile(files[0])
}
}
const triggerFileInput = () => {
fileInput.value?.click()
}
const onFileSelected = (event) => {
const files = event.target?.files
if (files?.length > 0) {
handleFile(files[0])
}
}
const handleFile = (file) => {
// Validate file type
const allowedTypes = ['image/jpeg', 'image/png', 'application/pdf']
if (!allowedTypes.includes(file.type)) {
error.value = 'Tip de fisier invalid. Sunt acceptate doar: JPG, PNG, PDF'
return
}
// Validate file size (10MB)
if (file.size > 10 * 1024 * 1024) {
error.value = 'Fisierul este prea mare. Dimensiunea maxima este 10MB.'
return
}
error.value = null
selectedFile.value = file
emit('file-selected', file)
}
const processOCR = async () => {
if (!selectedFile.value) return
processing.value = true
error.value = null
try {
const formData = new FormData()
formData.append('file', selectedFile.value)
const response = await axios.post('/api/ocr/extract', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 60000, // 60 second timeout for OCR
})
if (response.data.success) {
emit('ocr-result', response.data.data)
} else {
error.value = response.data.message || 'OCR processing failed'
emit('error', error.value)
}
} catch (err) {
const message = err.response?.data?.detail || err.message || 'Eroare la procesarea OCR'
error.value = message
emit('error', message)
} finally {
processing.value = false
}
}
const formatFileSize = (bytes) => {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
}
const reset = () => {
selectedFile.value = null
error.value = null
if (fileInput.value) {
fileInput.value.value = ''
}
}
// Expose methods for parent components
defineExpose({ reset, processOCR })
</script>
<style scoped>
.ocr-upload-zone {
margin-bottom: 1rem;
}
.upload-dropzone {
border: 2px dashed #cbd5e1;
border-radius: 12px;
padding: 2rem;
text-align: center;
cursor: pointer;
transition: all 0.3s ease;
background: #f8fafc;
}
.upload-dropzone:hover {
border-color: #667eea;
background: #f1f5f9;
}
.upload-dropzone.dragging {
border-color: #667eea;
background: #eef2ff;
transform: scale(1.02);
}
.upload-dropzone.processing {
cursor: default;
background: #fefefe;
}
.hidden-input {
display: none;
}
/* Empty state */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
}
.main-text {
font-size: 1rem;
color: #475569;
margin: 0.5rem 0;
}
.sub-text {
font-size: 0.85rem;
color: #94a3b8;
margin: 0;
}
.ocr-hint {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
color: #667eea;
margin-top: 0.5rem;
padding: 0.5rem 1rem;
background: #eef2ff;
border-radius: 20px;
}
/* File selected state */
.file-selected-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
}
.file-name {
font-weight: 600;
color: #1e293b;
margin: 0.5rem 0 0 0;
word-break: break-all;
}
.file-size {
font-size: 0.85rem;
color: #64748b;
margin: 0;
}
.file-actions {
display: flex;
gap: 0.75rem;
margin-top: 1rem;
}
/* Processing state */
.processing-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
}
.processing-text {
font-size: 1rem;
color: #475569;
margin: 0.5rem 0 0 0;
}
.processing-subtext {
font-size: 0.85rem;
color: #94a3b8;
margin: 0;
}
</style>

View File

@@ -15,14 +15,43 @@
</div>
<form @submit.prevent="saveReceipt">
<!-- Upload Section -->
<div class="upload-section">
<!-- OCR Upload Section (only for new receipts) -->
<div class="upload-section" v-if="!isEditMode">
<h3>
<i class="pi pi-camera"></i>
Poza Bon (obligatoriu)
</h3>
<!-- OCR Upload Zone -->
<OCRUploadZone
ref="ocrUploadZone"
@ocr-result="onOCRResult"
@file-selected="onOCRFileSelected"
@error="onOCRError"
/>
<!-- OCR Preview (when results are available) -->
<OCRPreview
v-if="ocrData"
:data="ocrData"
@apply="applyOCRData"
@dismiss="dismissOCRData"
/>
</div>
<!-- Standard Upload Section (for edit mode or additional files) -->
<div class="upload-section" v-if="isEditMode || selectedFiles.length > 0">
<h3 v-if="isEditMode">
<i class="pi pi-camera"></i>
Poza Bon (obligatoriu)
</h3>
<h3 v-else-if="selectedFiles.length > 0">
<i class="pi pi-paperclip"></i>
Fisiere Selectate
</h3>
<FileUpload
v-if="isEditMode"
ref="fileUpload"
mode="advanced"
:multiple="true"
@@ -70,6 +99,26 @@
/>
</div>
</div>
<!-- Selected files preview (create mode) -->
<div v-if="!isEditMode && selectedFiles.length" class="selected-files-list">
<div
v-for="(file, index) in selectedFiles"
:key="index"
class="selected-file-item"
>
<i :class="file.type.startsWith('image/') ? 'pi pi-image' : 'pi pi-file-pdf'"></i>
<span class="file-name">{{ file.name }}</span>
<span class="file-size">{{ formatFileSize(file.size) }}</span>
<Button
icon="pi pi-times"
severity="danger"
rounded
size="small"
@click="removeSelectedFile(index)"
/>
</div>
</div>
</div>
<Divider />
@@ -235,10 +284,12 @@
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useToast } from 'primevue/usetoast'
import { useReceiptsStore } from '../../stores/receiptsStore'
import OCRUploadZone from '../../components/ocr/OCRUploadZone.vue'
import OCRPreview from '../../components/ocr/OCRPreview.vue'
const route = useRoute()
const router = useRouter()
@@ -270,6 +321,11 @@ const existingAttachments = ref([])
const saving = ref(false)
const submitting = ref(false)
// OCR related refs
const ocrUploadZone = ref(null)
const ocrData = ref(null)
const ocrFile = ref(null)
const partners = computed(() => store.partners)
const expenseTypes = computed(() => store.expenseTypes)
const cashRegisters = computed(() => store.cashRegisters)
@@ -315,6 +371,85 @@ const loadReceipt = async () => {
}
}
// OCR handlers
const onOCRFileSelected = (file) => {
ocrFile.value = file
// Add to selected files for upload
if (!selectedFiles.value.some(f => f.name === file.name)) {
selectedFiles.value = [file, ...selectedFiles.value]
}
}
const onOCRResult = (data) => {
ocrData.value = data
toast.add({
severity: 'success',
summary: 'OCR Procesare',
detail: 'Datele au fost extrase din imagine',
life: 3000,
})
}
const onOCRError = (message) => {
toast.add({
severity: 'error',
summary: 'Eroare OCR',
detail: message,
life: 5000,
})
}
const applyOCRData = (data) => {
// Apply OCR data to form
if (data.receipt_type) {
form.value.receipt_type = data.receipt_type
}
if (data.receipt_date) {
form.value.receipt_date = new Date(data.receipt_date)
}
if (data.amount) {
form.value.amount = parseFloat(data.amount)
}
if (data.receipt_number) {
form.value.receipt_number = data.receipt_number
}
// Try to find matching partner by name or CUI
if (data.partner_name || data.cui) {
const matchingPartner = partners.value.find(p => {
const nameMatch = data.partner_name &&
p.name.toLowerCase().includes(data.partner_name.toLowerCase())
const cuiMatch = data.cui && p.cui === data.cui
return nameMatch || cuiMatch
})
if (matchingPartner) {
form.value.partner_id = matchingPartner.id
form.value.partner_name = matchingPartner.name
} else if (data.partner_name) {
// Store the extracted name even if no match
form.value.partner_name = data.partner_name
}
}
// Clear OCR preview
ocrData.value = null
toast.add({
severity: 'success',
summary: 'Date aplicate',
detail: 'Datele OCR au fost aplicate in formular',
life: 3000,
})
}
const dismissOCRData = () => {
ocrData.value = null
}
const onPartnerChange = (event) => {
const partner = partners.value.find(p => p.id === event.value)
form.value.partner_name = partner?.name || null
@@ -334,6 +469,10 @@ const onFileRemove = (event) => {
selectedFiles.value = selectedFiles.value.filter(f => f.name !== event.file.name)
}
const removeSelectedFile = (index) => {
selectedFiles.value = selectedFiles.value.filter((_, i) => i !== index)
}
const removeExistingAttachment = async (attachmentId) => {
try {
await store.deleteAttachment(attachmentId)
@@ -354,7 +493,24 @@ const removeExistingAttachment = async (attachmentId) => {
}
}
const formatFileSize = (bytes) => {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
}
const validateForm = () => {
// Check if we have at least one file (for new receipts)
if (!isEditMode.value && selectedFiles.value.length === 0) {
toast.add({
severity: 'warn',
summary: 'Validare',
detail: 'Trebuie sa adaugi cel putin o poza a bonului',
life: 3000,
})
return false
}
if (!form.value.receipt_date) {
toast.add({
severity: 'warn',
@@ -532,4 +688,41 @@ const submitForReview = async () => {
text-align: center;
word-break: break-word;
}
/* Selected files list */
.selected-files-list {
margin-top: 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.selected-file-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 0.75rem;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
}
.selected-file-item i {
color: #667eea;
font-size: 1.25rem;
}
.selected-file-item .file-name {
flex: 1;
font-weight: 500;
color: #1e293b;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.selected-file-item .file-size {
font-size: 0.85rem;
color: #64748b;
}
</style>