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

@@ -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>