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:
@@ -19,7 +19,8 @@ export const menuSections = [
|
||||
title: 'Sistem',
|
||||
items: [
|
||||
{ to: '/reports/telegram', icon: 'pi pi-telegram', label: 'Telegram Bot' },
|
||||
{ to: '/reports/cache-stats', icon: 'pi pi-chart-bar', label: 'Statistici Cache' }
|
||||
{ to: '/reports/cache-stats', icon: 'pi pi-chart-bar', label: 'Statistici Cache' },
|
||||
{ to: '/data-entry/ocr-metrics', icon: 'pi pi-eye', label: 'Statistici OCR' }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -263,6 +263,12 @@ const formatDate = (dateStr) => {
|
||||
|
||||
const getEngineClass = (engine) => {
|
||||
if (!engine) return ''
|
||||
// docTR engines
|
||||
if (engine === 'doctr-light') return 'doctr-fast'
|
||||
if (engine === 'doctr-medium') return 'doctr'
|
||||
if (engine === 'doctr-adaptive') return 'doctr-adaptive'
|
||||
if (engine.includes('doctr')) return 'doctr'
|
||||
// PaddleOCR engines
|
||||
if (engine === 'paddle-light') return 'fast'
|
||||
if (engine === 'paddle-adaptive') return 'adaptive'
|
||||
if (engine === 'adaptive-full') return 'full'
|
||||
@@ -273,13 +279,23 @@ const getEngineClass = (engine) => {
|
||||
|
||||
const getEngineIcon = (engine) => {
|
||||
if (!engine) return 'pi pi-cog'
|
||||
if (engine === 'paddle-light') return 'pi pi-bolt' // Fast/lightning
|
||||
if (engine === 'adaptive-full') return 'pi pi-cog' // Full pipeline
|
||||
// docTR - use bolt for fast modes
|
||||
if (engine === 'doctr-light') return 'pi pi-bolt'
|
||||
if (engine.includes('doctr')) return 'pi pi-bolt'
|
||||
// PaddleOCR
|
||||
if (engine === 'paddle-light') return 'pi pi-bolt'
|
||||
if (engine === 'adaptive-full') return 'pi pi-cog'
|
||||
return 'pi pi-cog'
|
||||
}
|
||||
|
||||
const getEngineLabel = (engine) => {
|
||||
if (!engine) return ''
|
||||
// docTR engines
|
||||
if (engine === 'doctr-light') return 'docTR Fast'
|
||||
if (engine === 'doctr-medium') return 'docTR Medium'
|
||||
if (engine === 'doctr-adaptive') return 'docTR Adaptive'
|
||||
if (engine.includes('doctr')) return 'docTR'
|
||||
// PaddleOCR engines
|
||||
if (engine === 'paddle-light') return 'Fast Mode (PaddleOCR)'
|
||||
if (engine === 'paddle-adaptive') return 'Adaptive (Paddle dual)'
|
||||
if (engine === 'adaptive-full') return 'Full Pipeline'
|
||||
@@ -615,6 +631,22 @@ const formatProcessingTime = (ms) => {
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
/* docTR engine styles */
|
||||
.ocr-engine-badge.doctr {
|
||||
background: #ede9fe;
|
||||
color: #5b21b6;
|
||||
}
|
||||
|
||||
.ocr-engine-badge.doctr-fast {
|
||||
background: #d1fae5;
|
||||
color: #047857;
|
||||
}
|
||||
|
||||
.ocr-engine-badge.doctr-adaptive {
|
||||
background: #e0e7ff;
|
||||
color: #3730a3;
|
||||
}
|
||||
|
||||
.ocr-message-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -60,7 +60,14 @@
|
||||
optionValue="value"
|
||||
placeholder="Motor OCR"
|
||||
class="engine-selector dropdown-borderless"
|
||||
/>
|
||||
>
|
||||
<template #option="{ option }">
|
||||
<div class="engine-option">
|
||||
<span class="engine-label">{{ option.label }}</span>
|
||||
<span class="engine-desc">{{ option.desc }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</Dropdown>
|
||||
<Button
|
||||
label="Proceseaza OCR"
|
||||
icon="pi pi-cog"
|
||||
@@ -77,9 +84,10 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import Dropdown from 'primevue/dropdown'
|
||||
import api from '@data-entry/services/api'
|
||||
import { useOCRSettingsStore } from '@data-entry/stores/ocrSettingsStore'
|
||||
|
||||
const emit = defineEmits(['ocr-result', 'file-selected', 'error'])
|
||||
|
||||
@@ -89,20 +97,73 @@ const isDragging = ref(false)
|
||||
const processing = ref(false)
|
||||
const error = ref(null)
|
||||
|
||||
// OCR Engine selection
|
||||
// OCR Settings Store - manages user preferences
|
||||
const ocrStore = useOCRSettingsStore()
|
||||
|
||||
// OCR Engine selection (synced with store)
|
||||
const selectedEngine = ref('auto')
|
||||
const engineOptions = [
|
||||
{ label: 'Auto (Recomandat)', value: 'auto' },
|
||||
{ label: 'PaddleOCR', value: 'paddleocr' },
|
||||
{ label: 'Tesseract', value: 'tesseract' }
|
||||
]
|
||||
|
||||
// Engine config - labels and descriptions for dropdown
|
||||
const engineConfig = {
|
||||
'auto': {
|
||||
label: 'Auto',
|
||||
desc: 'docTR→Paddle→Tess · General'
|
||||
},
|
||||
'doctr': {
|
||||
label: 'docTR',
|
||||
desc: 'Rapid, bună acuratețe'
|
||||
},
|
||||
'paddleocr': {
|
||||
label: 'PaddleOCR',
|
||||
desc: 'Cea mai bună calitate'
|
||||
},
|
||||
'tesseract': {
|
||||
label: 'Tesseract',
|
||||
desc: 'Cel mai rapid, calitate redusă'
|
||||
},
|
||||
'hybrid': {
|
||||
label: 'Hybrid',
|
||||
desc: 'docTR+Tess paralel · Recomandat'
|
||||
},
|
||||
'hybrid-quality': {
|
||||
label: 'Hybrid Calitate',
|
||||
desc: 'Paddle→docTR→Tess · Acuratețe max'
|
||||
},
|
||||
}
|
||||
|
||||
// Compute engine options from store's available engines
|
||||
const engineOptions = computed(() => {
|
||||
return ocrStore.availableEngines.map(engine => ({
|
||||
label: engineConfig[engine]?.label || engine,
|
||||
desc: engineConfig[engine]?.desc || '',
|
||||
value: engine
|
||||
}))
|
||||
})
|
||||
|
||||
// Load user's preferred engine on mount
|
||||
onMounted(async () => {
|
||||
await ocrStore.loadPreference()
|
||||
selectedEngine.value = ocrStore.preferredEngine
|
||||
console.log('[OCRUploadZone] Loaded user preference:', selectedEngine.value)
|
||||
})
|
||||
|
||||
// Save preference when user changes engine
|
||||
watch(selectedEngine, async (newEngine, oldEngine) => {
|
||||
if (oldEngine && newEngine !== oldEngine && ocrStore.initialized) {
|
||||
try {
|
||||
await ocrStore.setPreference(newEngine)
|
||||
console.log('[OCRUploadZone] Saved user preference:', newEngine)
|
||||
} catch (err) {
|
||||
console.error('[OCRUploadZone] Failed to save preference:', err)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Job queue state
|
||||
const jobId = ref(null)
|
||||
const queuePosition = ref(null)
|
||||
const estimatedWait = ref(null)
|
||||
const jobStatus = ref(null)
|
||||
let pollInterval = null
|
||||
|
||||
// Dynamic processing messages
|
||||
const processingMessage = computed(() => {
|
||||
@@ -223,26 +284,36 @@ const processOCR = async () => {
|
||||
}
|
||||
|
||||
const pollJobStatus = async (id) => {
|
||||
const maxAttempts = 120 // 2 minutes max (120 * 1s)
|
||||
let attempts = 0
|
||||
const LONG_POLL_TIMEOUT = 30 // seconds
|
||||
const MAX_TOTAL_TIME = 120 // 2 minutes max
|
||||
const startTime = Date.now()
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
const response = await api.get(`/ocr/jobs/${id}`)
|
||||
const job = response.data
|
||||
// Check if exceeded max total time
|
||||
const elapsed = (Date.now() - startTime) / 1000
|
||||
if (elapsed >= MAX_TOTAL_TIME) {
|
||||
processing.value = false
|
||||
error.value = 'Timeout - procesarea a durat prea mult'
|
||||
emit('error', error.value)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Long-poll with 30s server timeout, 35s axios timeout
|
||||
const response = await api.get(`/ocr/jobs/${id}/wait`, {
|
||||
params: { timeout: LONG_POLL_TIMEOUT },
|
||||
timeout: (LONG_POLL_TIMEOUT + 5) * 1000
|
||||
})
|
||||
|
||||
const job = response.data
|
||||
jobStatus.value = job.status
|
||||
queuePosition.value = job.queue_position
|
||||
estimatedWait.value = job.estimated_wait_seconds
|
||||
|
||||
console.log('📊 OCR Poll:', { status: job.status, position: job.queue_position })
|
||||
console.log('📊 OCR Long-Poll:', { status: job.status, position: job.queue_position })
|
||||
|
||||
if (job.status === 'completed') {
|
||||
// Success! Emit result
|
||||
clearInterval(pollInterval)
|
||||
pollInterval = null
|
||||
processing.value = false
|
||||
|
||||
if (job.result) {
|
||||
console.log('✅ OCR Complete:', job.result)
|
||||
emit('ocr-result', {
|
||||
@@ -257,47 +328,36 @@ const pollJobStatus = async (id) => {
|
||||
}
|
||||
|
||||
if (job.status === 'failed') {
|
||||
// Failed
|
||||
clearInterval(pollInterval)
|
||||
pollInterval = null
|
||||
processing.value = false
|
||||
|
||||
error.value = job.error || 'OCR processing failed'
|
||||
emit('error', error.value)
|
||||
return
|
||||
}
|
||||
|
||||
// Still pending/processing - continue polling
|
||||
attempts++
|
||||
if (attempts >= maxAttempts) {
|
||||
clearInterval(pollInterval)
|
||||
pollInterval = null
|
||||
processing.value = false
|
||||
error.value = 'Timeout - procesarea a durat prea mult'
|
||||
emit('error', error.value)
|
||||
// Still pending/processing - long-poll again
|
||||
if (processing.value) {
|
||||
await poll()
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error('🔴 Poll Error:', err.message)
|
||||
attempts++
|
||||
// Don't stop on poll errors - network might be flaky
|
||||
if (attempts >= maxAttempts) {
|
||||
clearInterval(pollInterval)
|
||||
pollInterval = null
|
||||
processing.value = false
|
||||
error.value = 'Eroare la verificarea starii job-ului'
|
||||
emit('error', error.value)
|
||||
// Handle timeout (normal for long-poll)
|
||||
if (err.code === 'ECONNABORTED' || err.message?.includes('timeout')) {
|
||||
console.log('⏱️ Long-poll timeout, retrying...')
|
||||
if (processing.value) {
|
||||
await poll()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Real error
|
||||
console.error('🔴 Poll Error:', err.message)
|
||||
processing.value = false
|
||||
error.value = 'Eroare la verificarea starii job-ului'
|
||||
emit('error', error.value)
|
||||
}
|
||||
}
|
||||
|
||||
// Initial poll immediately
|
||||
await poll()
|
||||
|
||||
// Continue polling every 1 second if still processing
|
||||
if (processing.value) {
|
||||
pollInterval = setInterval(poll, 1000)
|
||||
}
|
||||
}
|
||||
|
||||
const formatFileSize = (bytes) => {
|
||||
@@ -313,10 +373,7 @@ const reset = () => {
|
||||
queuePosition.value = null
|
||||
estimatedWait.value = null
|
||||
jobStatus.value = null
|
||||
if (pollInterval) {
|
||||
clearInterval(pollInterval)
|
||||
pollInterval = null
|
||||
}
|
||||
processing.value = false // Stop any ongoing long-poll
|
||||
if (fileInput.value) {
|
||||
fileInput.value.value = ''
|
||||
}
|
||||
@@ -415,7 +472,7 @@ defineExpose({ reset, processOCR })
|
||||
|
||||
/* Engine selector dropdown */
|
||||
.engine-selector {
|
||||
min-width: 150px;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.engine-selector:deep(.p-dropdown-label) {
|
||||
@@ -428,6 +485,25 @@ defineExpose({ reset, processOCR })
|
||||
width: 2rem !important;
|
||||
}
|
||||
|
||||
/* Engine dropdown option with description */
|
||||
.engine-option {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.engine-label {
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.engine-desc {
|
||||
font-size: 0.75rem;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
/* Processing state */
|
||||
.processing-state {
|
||||
display: flex;
|
||||
|
||||
173
src/modules/data-entry/stores/ocrSettingsStore.js
Normal file
173
src/modules/data-entry/stores/ocrSettingsStore.js
Normal file
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* OCR Settings Store
|
||||
*
|
||||
* Manages user's OCR engine preference and metrics.
|
||||
* - Auto-loads user's preferred engine on mount
|
||||
* - Saves preference to backend on change
|
||||
* - Provides OCR metrics for dashboard
|
||||
*/
|
||||
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import api from '@data-entry/services/api'
|
||||
|
||||
export const useOCRSettingsStore = defineStore('ocrSettings', () => {
|
||||
// State
|
||||
const preferredEngine = ref('doctr_plus')
|
||||
// Available engines
|
||||
// NOTE: This default list is overwritten by loadPreference() from backend
|
||||
// Backend filters engines based on OCR_ENABLE_PADDLEOCR and OCR_ENABLE_TESSERACT
|
||||
const availableEngines = ref([
|
||||
'tesseract',
|
||||
'doctr',
|
||||
'doctr_plus', // Recommended: 2-tier sequential with early exit
|
||||
'paddleocr',
|
||||
])
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
const initialized = ref(false)
|
||||
|
||||
// Metrics state
|
||||
const metrics = ref({
|
||||
summary: [],
|
||||
stats: null,
|
||||
history: [],
|
||||
historyTotal: 0,
|
||||
})
|
||||
const metricsLoading = ref(false)
|
||||
|
||||
// Computed
|
||||
const isLoading = computed(() => loading.value)
|
||||
const hasError = computed(() => !!error.value)
|
||||
|
||||
// Actions
|
||||
async function loadPreference() {
|
||||
if (initialized.value) return
|
||||
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const response = await api.get('/settings/ocr-preference')
|
||||
preferredEngine.value = response.data.preferred_engine
|
||||
availableEngines.value = response.data.available_engines
|
||||
initialized.value = true
|
||||
console.log('[OCRSettings] Loaded preference:', preferredEngine.value)
|
||||
} catch (err) {
|
||||
console.error('[OCRSettings] Failed to load preference:', err)
|
||||
error.value = err.message
|
||||
// Use defaults on error
|
||||
preferredEngine.value = 'doctr_plus'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function setPreference(engine) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const response = await api.post('/settings/ocr-preference', {
|
||||
preferred_engine: engine
|
||||
})
|
||||
preferredEngine.value = response.data.preferred_engine
|
||||
console.log('[OCRSettings] Saved preference:', preferredEngine.value)
|
||||
} catch (err) {
|
||||
console.error('[OCRSettings] Failed to save preference:', err)
|
||||
error.value = err.message
|
||||
throw err
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMetricsSummary(days = 30) {
|
||||
metricsLoading.value = true
|
||||
|
||||
try {
|
||||
const response = await api.get('/metrics/ocr/summary', { params: { days } })
|
||||
metrics.value.summary = response.data
|
||||
console.log('[OCRSettings] Loaded metrics summary:', metrics.value.summary.length, 'engines')
|
||||
} catch (err) {
|
||||
console.error('[OCRSettings] Failed to load metrics summary:', err)
|
||||
} finally {
|
||||
metricsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMetricsStats(days = 30) {
|
||||
try {
|
||||
const response = await api.get('/metrics/ocr/stats', { params: { days } })
|
||||
metrics.value.stats = response.data
|
||||
console.log('[OCRSettings] Loaded metrics stats:', metrics.value.stats)
|
||||
} catch (err) {
|
||||
console.error('[OCRSettings] Failed to load metrics stats:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMetricsHistory(limit = 50, offset = 0) {
|
||||
try {
|
||||
const response = await api.get('/metrics/ocr/history', { params: { limit, offset } })
|
||||
metrics.value.history = response.data.items
|
||||
metrics.value.historyTotal = response.data.total
|
||||
console.log('[OCRSettings] Loaded metrics history:', metrics.value.history.length, 'items')
|
||||
} catch (err) {
|
||||
console.error('[OCRSettings] Failed to load metrics history:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAllMetrics(days = 30) {
|
||||
metricsLoading.value = true
|
||||
try {
|
||||
await Promise.all([
|
||||
loadMetricsSummary(days),
|
||||
loadMetricsStats(days),
|
||||
loadMetricsHistory(20),
|
||||
])
|
||||
} finally {
|
||||
metricsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Reset state
|
||||
function $reset() {
|
||||
preferredEngine.value = 'doctr_plus'
|
||||
availableEngines.value = [
|
||||
'tesseract', 'doctr', 'doctr_plus', 'paddleocr',
|
||||
]
|
||||
loading.value = false
|
||||
error.value = null
|
||||
initialized.value = false
|
||||
metrics.value = {
|
||||
summary: [],
|
||||
stats: null,
|
||||
history: [],
|
||||
historyTotal: 0,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
preferredEngine,
|
||||
availableEngines,
|
||||
loading,
|
||||
error,
|
||||
initialized,
|
||||
metrics,
|
||||
metricsLoading,
|
||||
|
||||
// Computed
|
||||
isLoading,
|
||||
hasError,
|
||||
|
||||
// Actions
|
||||
loadPreference,
|
||||
setPreference,
|
||||
loadMetricsSummary,
|
||||
loadMetricsStats,
|
||||
loadMetricsHistory,
|
||||
loadAllMetrics,
|
||||
$reset,
|
||||
}
|
||||
})
|
||||
@@ -399,7 +399,9 @@ export const useReceiptsStore = defineStore('receipts', {
|
||||
this.partners.push({
|
||||
id: response.data.id,
|
||||
name: response.data.name,
|
||||
code: response.data.fiscal_code,
|
||||
fiscal_code: response.data.fiscal_code,
|
||||
address: response.data.address,
|
||||
source: 'local',
|
||||
})
|
||||
return response.data
|
||||
} catch (error) {
|
||||
@@ -407,6 +409,20 @@ export const useReceiptsStore = defineStore('receipts', {
|
||||
}
|
||||
},
|
||||
|
||||
async syncSuppliers() {
|
||||
try {
|
||||
// Use apiClient directly - nomenclature endpoints at /api/nomenclature
|
||||
const response = await apiClient.post('/nomenclature/sync/suppliers')
|
||||
console.log('[receiptsStore] Synced suppliers:', response.data)
|
||||
// Refresh partners list after sync
|
||||
await this.fetchPartners()
|
||||
return response.data
|
||||
} catch (error) {
|
||||
console.error('[receiptsStore] Supplier sync failed:', error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
// ============ Stats ============
|
||||
|
||||
async fetchStats() {
|
||||
|
||||
1322
src/modules/data-entry/views/OCRMetricsView.vue
Normal file
1322
src/modules/data-entry/views/OCRMetricsView.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
@@ -78,6 +78,12 @@ const routes = [
|
||||
name: 'ReceiptEdit',
|
||||
component: () => import('@data-entry/views/receipts/ReceiptCreateView.vue'),
|
||||
meta: { requiresAuth: true, title: 'Editare Bon - ROA2WEB' }
|
||||
},
|
||||
{
|
||||
path: 'ocr-metrics',
|
||||
name: 'OCRMetrics',
|
||||
component: () => import('@data-entry/views/OCRMetricsView.vue'),
|
||||
meta: { requiresAuth: true, title: 'Metrici OCR - ROA2WEB' }
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user