feat: Add payment methods extraction, OCR improvements, and AutoComplete fix
Backend: - Add payment_methods and payment_mode fields to Receipt model - Add payment method extraction (CARD/NUMERAR) with auto-suggestion logic - Improve OCR service with TVA validation and reverse calculation - Fix nomenclature service supplier limit (was 50, now unlimited) - Add OCR fields migrations (ocr_raw_text, ocr_confidence, payment_mode) Frontend: - Fix AutoComplete to properly display supplier name after OCR - Add payment methods display in OCR preview with suggested payment mode - Improve ReceiptCreateView form handling and OCR data application Database migrations: - 20251215_add_ocr_fields_to_receipt.py - 20251215_remove_partner_id.py - 20251216_add_payment_mode.py 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -87,6 +87,24 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Payment Methods from OCR -->
|
||||
<div class="preview-field full-width" v-if="data.payment_methods?.length > 0">
|
||||
<label>Modalitati Plata (OCR)</label>
|
||||
<div class="payment-methods-list">
|
||||
<Tag
|
||||
v-for="(pm, idx) in data.payment_methods"
|
||||
:key="idx"
|
||||
:severity="pm.method === 'CARD' ? 'info' : 'success'"
|
||||
:value="`${pm.method}: ${formatAmount(pm.amount)} RON`"
|
||||
class="mr-1"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="data.suggested_payment_mode" class="suggested-payment-mode">
|
||||
<i class="pi pi-lightbulb" style="color: #f59e0b;"></i>
|
||||
<span>Sugestie: <strong>{{ getSuggestedPaymentLabel(data.suggested_payment_mode) }}</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Items Count -->
|
||||
<div class="preview-field" v-if="data.items_count">
|
||||
<label>Nr. Articole</label>
|
||||
@@ -152,6 +170,7 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import OCRConfidenceIndicator from './OCRConfidenceIndicator.vue'
|
||||
import Tag from 'primevue/tag'
|
||||
|
||||
const props = defineProps({
|
||||
data: {
|
||||
@@ -164,6 +183,15 @@ defineEmits(['apply', 'dismiss'])
|
||||
|
||||
const showRawText = ref(false)
|
||||
|
||||
const getSuggestedPaymentLabel = (mode) => {
|
||||
const labels = {
|
||||
'casa': 'Casa (numerar firma)',
|
||||
'banca': 'Banca (virament/POS)',
|
||||
'avans_decontare': 'Avans Decontare'
|
||||
}
|
||||
return labels[mode] || mode
|
||||
}
|
||||
|
||||
const formatAmount = (amount) => {
|
||||
const num = parseFloat(amount)
|
||||
return num.toLocaleString('ro-RO', {
|
||||
@@ -346,6 +374,25 @@ const formatProcessingTime = (ms) => {
|
||||
border-top: 1px dashed #cbd5e1;
|
||||
}
|
||||
|
||||
.payment-methods-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.suggested-payment-mode {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
background: #fef3c7;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.address-text {
|
||||
font-size: 0.9rem;
|
||||
color: #475569;
|
||||
|
||||
@@ -15,11 +15,11 @@
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="saveReceipt">
|
||||
<!-- OCR Upload Section (only for new receipts) -->
|
||||
<div class="upload-section" v-if="!isEditMode">
|
||||
<!-- OCR Upload Section (for both create and edit modes) -->
|
||||
<div class="upload-section">
|
||||
<h3>
|
||||
<i class="pi pi-camera"></i>
|
||||
Poza Bon (obligatoriu)
|
||||
{{ isEditMode ? 'Re-scanare OCR (optional)' : 'Poza Bon (obligatoriu)' }}
|
||||
</h3>
|
||||
|
||||
<!-- OCR Upload Zone -->
|
||||
@@ -199,16 +199,30 @@
|
||||
|
||||
<div class="form-field">
|
||||
<label>Furnizor</label>
|
||||
<Dropdown
|
||||
v-model="form.partner_id"
|
||||
:options="partners"
|
||||
<AutoComplete
|
||||
v-model="form.partner_name"
|
||||
:suggestions="filteredPartners"
|
||||
optionLabel="name"
|
||||
optionValue="id"
|
||||
placeholder="Selecteaza furnizor"
|
||||
filter
|
||||
showClear
|
||||
@change="onPartnerChange"
|
||||
field="name"
|
||||
@complete="searchPartners"
|
||||
@item-select="onPartnerSelect"
|
||||
placeholder="Cauta furnizor..."
|
||||
dropdown
|
||||
:forceSelection="false"
|
||||
/>
|
||||
<small v-if="supplierSource" class="p-text-success supplier-selected">
|
||||
<i class="pi pi-check-circle"></i>
|
||||
Validat ({{ supplierSource }})
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<label>CUI (Cod Fiscal)</label>
|
||||
<InputText v-model="form.cui" placeholder="Ex: RO12345678" />
|
||||
<small v-if="supplierWarning.show" class="p-text-warning supplier-warning">
|
||||
<i class="pi pi-exclamation-triangle"></i>
|
||||
CUI {{ supplierWarning.cui }} negasit in nomenclator
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
@@ -224,16 +238,18 @@
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<label>Casa / Banca *</label>
|
||||
<label>Mod Plata</label>
|
||||
<Dropdown
|
||||
v-model="form.cash_register_id"
|
||||
:options="cashRegisters"
|
||||
optionLabel="name"
|
||||
optionValue="id"
|
||||
placeholder="Selecteaza casa/banca"
|
||||
@change="onCashRegisterChange"
|
||||
required
|
||||
v-model="form.payment_mode"
|
||||
:options="paymentModeOptions"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
placeholder="Selecteaza mod plata"
|
||||
showClear
|
||||
/>
|
||||
<small class="field-hint text-secondary" v-if="!form.payment_mode">
|
||||
Obligatoriu la trimiterea pentru aprobare
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
@@ -252,7 +268,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Detalii Suplimentare (populated from OCR) -->
|
||||
<div v-if="form.tva_breakdown?.length > 0 || form.items_count || form.vendor_address" class="extra-details-section">
|
||||
<div v-if="form.tva_breakdown?.length > 0 || form.items_count || form.vendor_address || form.payment_methods?.length > 0" class="extra-details-section">
|
||||
<h3>
|
||||
<i class="pi pi-list"></i>
|
||||
Detalii Suplimentare (din OCR)
|
||||
@@ -280,6 +296,19 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Payment Methods (from OCR) -->
|
||||
<div class="form-field form-field-full" v-if="form.payment_methods?.length > 0">
|
||||
<label>Modalitati Plata</label>
|
||||
<div class="payment-methods-display">
|
||||
<Tag
|
||||
v-for="pm in form.payment_methods"
|
||||
:key="pm.method"
|
||||
:severity="pm.method === 'CARD' ? 'info' : 'success'"
|
||||
:value="`${pm.method}: ${formatCurrency(pm.amount)}`"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-grid">
|
||||
<div class="form-field" v-if="form.items_count">
|
||||
<label>Nr. Articole</label>
|
||||
@@ -376,6 +405,8 @@ import { useCompanyStore } from '../../stores/companies'
|
||||
import OCRUploadZone from '../../components/ocr/OCRUploadZone.vue'
|
||||
import OCRPreview from '../../components/ocr/OCRPreview.vue'
|
||||
import Dialog from 'primevue/dialog'
|
||||
import Tag from 'primevue/tag'
|
||||
import AutoComplete from 'primevue/autocomplete'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -397,10 +428,13 @@ const form = ref({
|
||||
direction: 'cheltuiala',
|
||||
receipt_date: new Date(),
|
||||
amount: null,
|
||||
partner_id: null,
|
||||
// partner_id removed - supplier data is text-only
|
||||
partner_name: null,
|
||||
cui: '', // Fiscal code from OCR
|
||||
ocr_raw_text: '', // Raw OCR text for debugging
|
||||
expense_type_code: null,
|
||||
cash_register_id: null,
|
||||
payment_mode: null, // NEW: casa/banca/avans_decontare
|
||||
cash_register_id: null, // Legacy - keep for backwards compatibility
|
||||
cash_register_name: null,
|
||||
cash_register_account: null,
|
||||
receipt_number: '',
|
||||
@@ -411,6 +445,7 @@ const form = ref({
|
||||
tva_total: null,
|
||||
items_count: null,
|
||||
vendor_address: '',
|
||||
payment_methods: [], // Array of {method, amount}
|
||||
})
|
||||
|
||||
const selectedFiles = ref([])
|
||||
@@ -426,11 +461,32 @@ const ocrFile = ref(null)
|
||||
// Supplier dialog refs
|
||||
const showCreateSupplierDialog = ref(false)
|
||||
const pendingSupplierData = ref(null)
|
||||
const supplierWarning = ref({ show: false, cui: '', name: '' })
|
||||
|
||||
// AutoComplete support
|
||||
const filteredPartners = ref([])
|
||||
const supplierSource = ref(null) // 'local', 'synced', or null
|
||||
|
||||
const partners = computed(() => store.partners)
|
||||
const expenseTypes = computed(() => store.expenseTypes)
|
||||
const cashRegisters = computed(() => store.cashRegisters)
|
||||
|
||||
// Payment mode options
|
||||
const paymentModeOptions = ref([
|
||||
{ value: 'casa', label: 'Casa (numerar firma)' },
|
||||
{ value: 'banca', label: 'Banca (virament/POS)' },
|
||||
{ value: 'avans_decontare', label: 'Avans Decontare (decont angajat)' },
|
||||
])
|
||||
|
||||
// AutoComplete search function
|
||||
const searchPartners = (event) => {
|
||||
const query = event.query.toLowerCase()
|
||||
filteredPartners.value = partners.value.filter(p =>
|
||||
p.name.toLowerCase().includes(query) ||
|
||||
(p.fiscal_code && p.fiscal_code.toLowerCase().includes(query))
|
||||
)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await store.fetchAllNomenclatures()
|
||||
|
||||
@@ -452,17 +508,28 @@ const loadReceipt = async () => {
|
||||
direction: receipt.value.direction,
|
||||
receipt_date: new Date(receipt.value.receipt_date),
|
||||
amount: parseFloat(receipt.value.amount),
|
||||
partner_id: receipt.value.partner_id,
|
||||
// partner_id removed - supplier data is text-only
|
||||
partner_name: receipt.value.partner_name,
|
||||
cui: receipt.value.cui || '',
|
||||
ocr_raw_text: receipt.value.ocr_raw_text || '',
|
||||
expense_type_code: receipt.value.expense_type_code,
|
||||
cash_register_id: receipt.value.cash_register_id,
|
||||
payment_mode: receipt.value.payment_mode || null, // NEW
|
||||
cash_register_id: receipt.value.cash_register_id, // Legacy
|
||||
cash_register_name: receipt.value.cash_register_name,
|
||||
cash_register_account: receipt.value.cash_register_account,
|
||||
receipt_number: receipt.value.receipt_number || '',
|
||||
description: receipt.value.description || '',
|
||||
company_id: receipt.value.company_id,
|
||||
// TVA info
|
||||
tva_breakdown: receipt.value.tva_breakdown || [],
|
||||
tva_total: receipt.value.tva_total || null,
|
||||
items_count: receipt.value.items_count || null,
|
||||
vendor_address: receipt.value.vendor_address || '',
|
||||
payment_methods: receipt.value.payment_methods || [],
|
||||
}
|
||||
|
||||
// form.partner_name is bound directly to AutoComplete, no separate selectedPartner needed
|
||||
|
||||
existingAttachments.value = receipt.value.attachments || []
|
||||
} catch (error) {
|
||||
toast.add({
|
||||
@@ -518,6 +585,16 @@ const applyOCRData = async (data) => {
|
||||
form.value.receipt_number = data.receipt_number
|
||||
}
|
||||
|
||||
// Save CUI from OCR
|
||||
if (data.cui) {
|
||||
form.value.cui = data.cui
|
||||
}
|
||||
|
||||
// Save raw OCR text for debugging
|
||||
if (data.raw_text) {
|
||||
form.value.ocr_raw_text = data.raw_text
|
||||
}
|
||||
|
||||
// Apply TVA entries
|
||||
if (data.tva_entries?.length > 0) {
|
||||
form.value.tva_breakdown = data.tva_entries.map(e => ({
|
||||
@@ -530,6 +607,19 @@ const applyOCRData = async (data) => {
|
||||
if (data.items_count) form.value.items_count = data.items_count
|
||||
if (data.address) form.value.vendor_address = data.address
|
||||
|
||||
// Apply payment methods
|
||||
if (data.payment_methods?.length > 0) {
|
||||
form.value.payment_methods = data.payment_methods.map(pm => ({
|
||||
method: pm.method,
|
||||
amount: parseFloat(pm.amount)
|
||||
}))
|
||||
}
|
||||
|
||||
// Auto-suggest payment_mode if OCR detected CARD
|
||||
if (data.suggested_payment_mode) {
|
||||
form.value.payment_mode = data.suggested_payment_mode
|
||||
}
|
||||
|
||||
// Auto-search supplier by CUI if available
|
||||
if (data.cui) {
|
||||
toast.add({
|
||||
@@ -542,9 +632,27 @@ const applyOCRData = async (data) => {
|
||||
const result = await store.searchSupplier(data.cui)
|
||||
|
||||
if (result.found && result.supplier) {
|
||||
// Found! Auto-select
|
||||
form.value.partner_id = result.supplier.id
|
||||
// Build supplier object for AutoComplete
|
||||
const supplierObj = {
|
||||
name: result.supplier.name,
|
||||
fiscal_code: result.supplier.fiscal_code,
|
||||
address: result.supplier.address,
|
||||
source: result.source
|
||||
}
|
||||
|
||||
// Fill form fields (strings for saving) - form.partner_name is bound directly to AutoComplete
|
||||
form.value.partner_name = result.supplier.name
|
||||
form.value.cui = result.supplier.fiscal_code || data.cui
|
||||
form.value.vendor_address = result.supplier.address || data.address || form.value.vendor_address
|
||||
|
||||
// Set source for visual indicator
|
||||
supplierSource.value = result.source
|
||||
|
||||
// Add supplier to store's partners list if not already there (for future suggestions)
|
||||
const existsInPartners = store.partners.some(p => p.name === result.supplier.name)
|
||||
if (!existsInPartners) {
|
||||
store.partners.push(supplierObj)
|
||||
}
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
@@ -553,23 +661,36 @@ const applyOCRData = async (data) => {
|
||||
life: 3000,
|
||||
})
|
||||
} else {
|
||||
// Not found - offer to create
|
||||
pendingSupplierData.value = {
|
||||
name: data.partner_name || '',
|
||||
fiscal_code: data.cui,
|
||||
address: data.address || '',
|
||||
// Not found - show non-blocking warning, allow continuing
|
||||
supplierWarning.value = {
|
||||
show: true,
|
||||
cui: data.cui,
|
||||
name: data.partner_name || ''
|
||||
}
|
||||
showCreateSupplierDialog.value = true
|
||||
// Still set form values from OCR
|
||||
form.value.partner_name = data.partner_name || ''
|
||||
// CUI already set above
|
||||
|
||||
toast.add({
|
||||
severity: 'warn',
|
||||
summary: 'Furnizor negasit',
|
||||
detail: `CUI ${data.cui} nu a fost gasit in nomenclator`,
|
||||
life: 5000,
|
||||
})
|
||||
}
|
||||
} else if (data.partner_name) {
|
||||
// No CUI but have name - try name search
|
||||
// No CUI but have name - try name search in partners list
|
||||
const matchingPartner = partners.value.find(p =>
|
||||
p.name.toLowerCase().includes(data.partner_name.toLowerCase())
|
||||
)
|
||||
if (matchingPartner) {
|
||||
form.value.partner_id = matchingPartner.id
|
||||
// Fill form fields - form.partner_name is bound directly to AutoComplete
|
||||
form.value.partner_name = matchingPartner.name
|
||||
form.value.cui = matchingPartner.fiscal_code || ''
|
||||
form.value.vendor_address = matchingPartner.address || form.value.vendor_address || ''
|
||||
supplierSource.value = matchingPartner.source || 'local'
|
||||
} else {
|
||||
// Just set the name from OCR (no matching partner found)
|
||||
form.value.partner_name = data.partner_name
|
||||
}
|
||||
}
|
||||
@@ -623,9 +744,14 @@ const cancelCreateSupplier = () => {
|
||||
pendingSupplierData.value = null
|
||||
}
|
||||
|
||||
const onPartnerChange = (event) => {
|
||||
const partner = partners.value.find(p => p.id === event.value)
|
||||
form.value.partner_name = partner?.name || null
|
||||
const onPartnerSelect = (event) => {
|
||||
const partner = event.value
|
||||
if (partner && typeof partner === 'object') {
|
||||
form.value.partner_name = partner.name
|
||||
form.value.cui = partner.fiscal_code || ''
|
||||
form.value.vendor_address = partner.address || form.value.vendor_address || ''
|
||||
supplierSource.value = partner.source || 'oracle'
|
||||
}
|
||||
}
|
||||
|
||||
const onCashRegisterChange = (event) => {
|
||||
@@ -672,6 +798,11 @@ const formatFileSize = (bytes) => {
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
|
||||
}
|
||||
|
||||
const formatCurrency = (value) => {
|
||||
if (value === null || value === undefined) return '0.00'
|
||||
return parseFloat(value).toLocaleString('ro-RO', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
}
|
||||
|
||||
const formatTvaTotal = () => {
|
||||
if (!form.value.tva_breakdown?.length) return '0.00'
|
||||
const total = form.value.tva_breakdown.reduce((sum, e) => sum + (e.amount || 0), 0)
|
||||
@@ -720,15 +851,8 @@ const validateForm = () => {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!form.value.cash_register_id) {
|
||||
toast.add({
|
||||
severity: 'warn',
|
||||
summary: 'Validare',
|
||||
detail: 'Casa/Banca este obligatorie',
|
||||
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)
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -956,6 +1080,40 @@ const submitForReview = async () => {
|
||||
color: #0284c7;
|
||||
}
|
||||
|
||||
/* Supplier warning */
|
||||
.supplier-warning {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
margin-top: 0.25rem;
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
/* Supplier selected indicator */
|
||||
.supplier-selected {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
margin-top: 0.25rem;
|
||||
color: #22c55e;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Field hint */
|
||||
.field-hint {
|
||||
display: block;
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.8rem;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
/* Payment methods display */
|
||||
.payment-methods-display {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Dialog content */
|
||||
.dialog-content {
|
||||
display: flex;
|
||||
|
||||
Reference in New Issue
Block a user