feat(ocr): Add docTR OCR engine with metrics infrastructure

Add docTR as primary OCR engine with 2-tier sequential processing,
OCR metrics tracking, and simplified engine selection.

Features:
- docTR OCR engine with light+medium preprocessing tiers
- doctr_plus mode with early exit optimization (~65% fast path)
- OCR metrics dashboard with per-engine statistics
- User OCR preference persistence
- Parallel worker pool for OCR processing
- Cross-validation for extraction quality

Engine options: tesseract, doctr, doctr_plus (recommended), paddleocr

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-02 05:37:16 +02:00
parent 74f7aefc26
commit 495790411f
75 changed files with 23349 additions and 1311 deletions

View File

@@ -244,7 +244,20 @@
<div class="form-group">
<div class="form-row">
<div class="form-field flex-2">
<label>Furnizor</label>
<div class="label-with-action">
<label>Furnizor</label>
<Button
v-if="!isReadOnly"
icon="pi pi-sync"
size="small"
text
rounded
:loading="syncingSuppliers"
@click="resyncSuppliers"
v-tooltip.top="'Re-sincronizeaza furnizorii din Oracle'"
class="sync-btn"
/>
</div>
<AutoComplete
v-model="form.partner_name"
:suggestions="filteredPartners"
@@ -265,10 +278,22 @@
<div class="form-field flex-1">
<label>CUI</label>
<InputText v-model="form.cui" placeholder="RO12345678" :disabled="isReadOnly" />
<small v-if="supplierWarning.show" class="p-text-warning supplier-warning">
<i class="pi pi-exclamation-triangle"></i>
Negasit
</small>
<div v-if="supplierWarning.show" class="supplier-warning-box">
<small class="p-text-warning">
<i class="pi pi-exclamation-triangle"></i>
Negasit - se va crea automat la salvare
</small>
<Button
v-if="!isReadOnly"
label="Creaza acum"
icon="pi pi-plus"
size="small"
severity="warning"
text
@click="createLocalSupplierFromWarning"
class="supplier-create-btn"
/>
</div>
</div>
</div>
<!-- Adresa colapsata -->
@@ -406,14 +431,13 @@
<div class="form-group form-group-last">
<div class="form-row">
<div class="form-field flex-1">
<label>Tip Cheltuiala *</label>
<label>Tip Cheltuiala</label>
<Dropdown
v-model="form.expense_type_code"
:options="expenseTypes"
optionLabel="name"
optionValue="code"
placeholder="Selecteaza tip cheltuiala"
required
:disabled="isReadOnly"
class="dropdown-borderless"
/>
@@ -678,7 +702,7 @@
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { ref, computed, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useToast } from 'primevue/usetoast'
import { useReceiptsStore } from '@data-entry/stores/receiptsStore'
@@ -771,6 +795,7 @@ const missingClientWarning = ref(false)
// AutoComplete support
const filteredPartners = ref([])
const supplierSource = ref(null) // 'local', 'synced', or null
const syncingSuppliers = ref(false)
const partners = computed(() => store.partners)
const expenseTypes = computed(() => store.expenseTypes)
@@ -812,7 +837,6 @@ const missingRequiredFields = computed(() => {
const missing = []
if (!validationState.value.hasAmount) missing.push('Suma')
if (!validationState.value.hasDate) missing.push('Data')
if (!validationState.value.hasExpenseType) missing.push('Tip cheltuiala')
if (!validationState.value.hasAttachment) missing.push('Atasament')
return missing
})
@@ -848,6 +872,11 @@ const searchPartners = (event) => {
onMounted(async () => {
await store.fetchAllNomenclatures()
// Sync suppliers from Oracle if list is empty (first use or no synced data)
if (store.partners.length === 0) {
await syncSuppliersIfNeeded()
}
if (isEditMode.value || isViewMode.value) {
await loadReceipt()
} else {
@@ -856,6 +885,76 @@ onMounted(async () => {
}
})
// Sync suppliers from Oracle if not already synced
const syncSuppliersIfNeeded = async () => {
try {
toast.add({
severity: 'info',
summary: 'Sincronizare furnizori',
detail: 'Se sincronizeaza furnizorii din Oracle...',
life: 3000,
})
const result = await store.syncSuppliers()
toast.add({
severity: 'success',
summary: 'Sincronizare completa',
detail: `${result.synced || store.partners.length} furnizori sincronizati`,
life: 3000,
})
} catch (error) {
console.warn('[ReceiptCreateView] Supplier sync failed:', error)
toast.add({
severity: 'warn',
summary: 'Sincronizare esuata',
detail: 'Nu s-au putut sincroniza furnizorii. Puteti continua cu furnizori locali.',
life: 5000,
})
}
}
// Watch for company changes - sync suppliers in background
watch(
() => companyStore.selectedCompany,
async (newCompany, oldCompany) => {
// Only trigger if company actually changed (not on initial load)
if (newCompany && oldCompany && newCompany.id_firma !== oldCompany.id_firma) {
console.log('[ReceiptCreateView] Company changed, syncing suppliers in background...')
// Background sync - don't await, don't block UI
store.syncSuppliers().then(result => {
console.log('[ReceiptCreateView] Background sync complete:', result)
}).catch(error => {
console.warn('[ReceiptCreateView] Background sync failed:', error)
})
}
}
)
// Manual re-sync suppliers from Oracle (button click)
const resyncSuppliers = async () => {
syncingSuppliers.value = true
try {
const result = await store.syncSuppliers()
toast.add({
severity: 'success',
summary: 'Sincronizare completa',
detail: `${result.synced || 0} furnizori noi din Oracle`,
life: 3000,
})
} catch (error) {
console.warn('[ReceiptCreateView] Manual supplier sync failed:', error)
toast.add({
severity: 'error',
summary: 'Sincronizare esuata',
detail: error.message || 'Nu s-au putut sincroniza furnizorii',
life: 5000,
})
} finally {
syncingSuppliers.value = false
}
}
const loadReceipt = async () => {
try {
receipt.value = await store.fetchReceiptById(receiptId.value)
@@ -954,6 +1053,7 @@ const onOCRFileSelected = (file) => {
}
const onOCRResult = (data) => {
console.log('[OCR Result] Received data, suggested_payment_mode:', data.suggested_payment_mode, 'payment_methods:', data.payment_methods)
ocrData.value = data
toast.add({
severity: 'success',
@@ -1142,9 +1242,11 @@ const applyOCRData = async (data) => {
}
// Auto-suggest payment_mode if OCR detected CARD
console.log('[OCR Apply] suggested_payment_mode:', data.suggested_payment_mode, 'payment_methods:', data.payment_methods)
if (data.suggested_payment_mode) {
form.value.payment_mode = data.suggested_payment_mode
paymentSetFromOCR.value = true // Show OCR indicator
console.log('[OCR Apply] Set payment_mode to:', data.suggested_payment_mode)
}
// AUTO-DETECT DIRECTION (PLATĂ/ÎNCASARE) based on CUI matching
@@ -1367,6 +1469,36 @@ const cancelCreateSupplier = () => {
pendingSupplierData.value = null
}
// Create local supplier immediately from warning (inline button)
const createLocalSupplierFromWarning = async () => {
if (!form.value.cui) return
try {
await store.createLocalSupplier({
name: form.value.partner_name || supplierWarning.value.name || `Furnizor ${form.value.cui}`,
fiscal_code: form.value.cui,
address: form.value.vendor_address || null
})
toast.add({
severity: 'success',
summary: 'Furnizor creat',
detail: `${form.value.partner_name || form.value.cui} a fost adaugat`,
life: 3000,
})
supplierWarning.value = { show: false, cui: '', name: '' }
supplierSource.value = 'local'
} catch (error) {
toast.add({
severity: 'error',
summary: 'Eroare',
detail: error.message || 'Nu s-a putut crea furnizorul',
life: 5000,
})
}
}
// Helper function to calculate similarity between two CUI strings
// Returns a value between 0 and 1 (1 = identical)
const calculateCuiSimilarity = (cui1, cui2) => {
@@ -1727,16 +1859,6 @@ const validateForm = () => {
return false
}
if (!form.value.expense_type_code) {
toast.add({
severity: 'warn',
summary: 'Validare',
detail: 'Tipul cheltuielii este obligatoriu',
life: 3000,
})
return false
}
// Payment mode is validated at submit time, not at draft save
// (can save draft without payment mode, but submit requires it)
@@ -1749,6 +1871,31 @@ const saveReceipt = async () => {
saving.value = true
try {
// Auto-create local supplier if CUI is present but not found in database
if (form.value.cui && supplierWarning.value.show) {
try {
await store.createLocalSupplier({
name: form.value.partner_name || `Furnizor ${form.value.cui}`,
fiscal_code: form.value.cui,
address: form.value.vendor_address || null
})
toast.add({
severity: 'info',
summary: 'Furnizor local creat',
detail: `${form.value.partner_name || form.value.cui} adaugat automat`,
life: 3000,
})
// Clear warning since supplier is now created
supplierWarning.value = { show: false, cui: '', name: '' }
supplierSource.value = 'local'
} catch (error) {
console.warn('[saveReceipt] Failed to auto-create local supplier:', error)
// Continue with save anyway - supplier creation is optional
}
}
// Clean up payment_methods and tva_breakdown - convert null amounts to 0
const cleanedPaymentMethods = form.value.payment_methods?.map(pm => ({
...pm,
@@ -1898,6 +2045,46 @@ const submitForReview = async () => {
font-size: 1.1rem;
}
/* Supplier warning box with inline create button */
.supplier-warning-box {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.25rem;
}
.supplier-warning-box small {
display: flex;
align-items: center;
gap: 0.25rem;
}
.supplier-create-btn {
padding: 0.25rem 0.5rem !important;
font-size: 0.75rem !important;
}
/* Label with action button (sync) */
.label-with-action {
display: flex;
align-items: center;
gap: 0.25rem;
}
.label-with-action label {
margin-bottom: 0;
}
.sync-btn {
width: 1.5rem !important;
height: 1.5rem !important;
padding: 0 !important;
}
.sync-btn .pi {
font-size: 0.75rem;
}
/* 2-column layout */
.receipt-form-layout {
display: grid;