feat: Add client extraction, amount cross-validation, and workflow fixes
OCR improvements: - Extract client data (name, CUI, address) from B2B receipts - Cross-validate amounts using payment methods and TVA entries - OCR-tolerant patterns for "TOTAL LEI" with common OCR errors - Better BON FISCAL vs CHITANTA detection Backend workflow fixes: - Fix SQLAlchemy deleted instance error in resubmit/submit workflow - Add session.refresh() after deleting accounting entries - Add unapprove endpoint (APPROVED → PENDING_REVIEW) - Add direction filter for receipt listing Frontend improvements: - Fix Vue v-else-if chain broken by Menu component - Unified OCR Preview layout with values table - Receipt list filter by direction (plati/incasari) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -39,6 +39,22 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SECTION: CLIENT (always visible) -->
|
||||
<div class="ocr-section">
|
||||
<div class="ocr-section-title">CLIENT</div>
|
||||
<div class="ocr-section-content">
|
||||
<template v-if="data.client_name || data.client_cui || data.client_address">
|
||||
<div class="client-name" v-if="data.client_name">
|
||||
{{ data.client_name }}
|
||||
<OCRConfidenceIndicator v-if="data.confidence_client" :confidence="data.confidence_client" size="small" />
|
||||
</div>
|
||||
<div class="client-cui" v-if="data.client_cui">CUI: {{ data.client_cui }}</div>
|
||||
<div class="client-address" v-if="data.client_address">{{ data.client_address }}</div>
|
||||
</template>
|
||||
<div v-else class="no-data">-</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SECTION: DOCUMENT -->
|
||||
<div class="ocr-section" v-if="data.receipt_type || data.receipt_number || data.receipt_date">
|
||||
<div class="ocr-section-title">DOCUMENT</div>
|
||||
@@ -61,46 +77,63 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SECTION: TOTAL + PLATA (combined) -->
|
||||
<div class="ocr-section ocr-section-total" v-if="data.amount">
|
||||
<div class="ocr-section-title">TOTAL</div>
|
||||
<div class="ocr-section-content">
|
||||
<div class="ocr-total-box">
|
||||
<div class="total-main">
|
||||
<span class="total-amount">{{ formatAmount(data.amount) }} LEI</span>
|
||||
<OCRConfidenceIndicator :confidence="data.confidence_amount" size="small" />
|
||||
</div>
|
||||
<!-- Payment methods integrated into total box -->
|
||||
<div v-if="data.payment_methods?.length > 0" class="payment-methods-inline">
|
||||
<Tag
|
||||
v-for="(pm, idx) in data.payment_methods"
|
||||
:key="idx"
|
||||
:severity="pm.method === 'CARD' ? 'info' : 'success'"
|
||||
:value="`${pm.method}: ${formatAmount(pm.amount)} LEI`"
|
||||
class="payment-tag"
|
||||
/>
|
||||
</div>
|
||||
<!-- SECTION: TOTAL + PLATA + TVA (unified flat layout) -->
|
||||
<div class="ocr-section" v-if="data.amount || data.tva_entries?.length > 0 || paymentSum > 0">
|
||||
<div class="ocr-values-table">
|
||||
<!-- TOTAL row - when extracted -->
|
||||
<div class="value-row" v-if="data.amount">
|
||||
<span class="value-label">TOTAL</span>
|
||||
<span class="value-amount">
|
||||
{{ formatAmount(data.amount) }} LEI
|
||||
<OCRConfidenceIndicator :confidence="data.confidence_amount" size="small" class="confidence-inline" />
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="data.items_count" class="items-count">
|
||||
{{ data.items_count }} articole
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SECTION: TVA -->
|
||||
<div class="ocr-section" v-if="data.tva_entries?.length > 0 || data.tva_total">
|
||||
<div class="ocr-section-title">TVA</div>
|
||||
<div class="ocr-section-content">
|
||||
<div class="ocr-tva-table">
|
||||
<div v-for="(entry, idx) in data.tva_entries" :key="idx" class="tva-row">
|
||||
<span class="tva-code" v-if="entry.code">{{ entry.code }}</span>
|
||||
<span class="tva-percent">({{ entry.percent }}%)</span>
|
||||
<span class="tva-amount">{{ formatAmount(entry.amount) }} LEI</span>
|
||||
</div>
|
||||
<div v-if="computedTvaTotal > 0" class="tva-row tva-total-row">
|
||||
<span class="tva-label">Total TVA:</span>
|
||||
<span class="tva-amount">{{ formatAmount(computedTvaTotal) }} LEI</span>
|
||||
</div>
|
||||
<!-- TOTAL row - warning when not found but can be calculated from payments -->
|
||||
<div class="value-row warning-row" v-else-if="paymentSum > 0">
|
||||
<span class="value-label">
|
||||
<i class="pi pi-exclamation-triangle warning-icon"></i>
|
||||
TOTAL (calculat)
|
||||
</span>
|
||||
<span class="value-amount calculated">
|
||||
{{ formatAmount(paymentSum) }} LEI
|
||||
<span class="hint">(din plati)</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Validation warning: total ≠ payment sum -->
|
||||
<div class="validation-warning" v-if="totalMismatchPayment">
|
||||
<i class="pi pi-exclamation-triangle"></i>
|
||||
Total ({{ formatAmount(data.amount) }}) ≠ Suma plati ({{ formatAmount(paymentSum) }})
|
||||
</div>
|
||||
|
||||
<!-- Validation info: TVA-implied total mismatch -->
|
||||
<div class="validation-info" v-if="totalMismatchTva">
|
||||
<i class="pi pi-info-circle"></i>
|
||||
Total din TVA: {{ formatAmount(tvaImpliedTotal) }} LEI
|
||||
</div>
|
||||
|
||||
<!-- Payment methods as plain text rows -->
|
||||
<div class="value-row" v-for="(pm, idx) in data.payment_methods" :key="'pm-'+idx">
|
||||
<span class="value-label">{{ pm.method }}</span>
|
||||
<span class="value-amount">{{ formatAmount(pm.amount) }} LEI</span>
|
||||
</div>
|
||||
|
||||
<!-- TVA entries -->
|
||||
<div class="value-row" v-for="(entry, idx) in data.tva_entries" :key="'tva-'+idx">
|
||||
<span class="value-label">TVA {{ entry.code }} ({{ entry.percent }}%)</span>
|
||||
<span class="value-amount">{{ formatAmount(entry.amount) }} LEI</span>
|
||||
</div>
|
||||
|
||||
<!-- TVA Total -->
|
||||
<div class="value-row total-row" v-if="computedTvaTotal > 0">
|
||||
<span class="value-label">Total TVA</span>
|
||||
<span class="value-amount">{{ formatAmount(computedTvaTotal) }} LEI</span>
|
||||
</div>
|
||||
|
||||
<!-- Items count -->
|
||||
<div v-if="data.items_count" class="items-count-inline">
|
||||
{{ data.items_count }} articole
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -174,6 +207,34 @@ const computedTvaTotal = computed(() => {
|
||||
return props.data.tva_entries.reduce((sum, e) => sum + parseFloat(e.amount || 0), 0)
|
||||
})
|
||||
|
||||
// Cross-validation computed properties
|
||||
const paymentSum = computed(() => {
|
||||
if (!props.data.payment_methods?.length) return 0
|
||||
return props.data.payment_methods.reduce((sum, pm) => sum + parseFloat(pm.amount || 0), 0)
|
||||
})
|
||||
|
||||
const tvaImpliedTotal = computed(() => {
|
||||
// Calculate total from TVA: total = tva * (100 + rate) / rate
|
||||
if (!props.data.tva_entries?.length) return 0
|
||||
const mainEntry = props.data.tva_entries[0]
|
||||
const rate = mainEntry.percent || 19
|
||||
const tvaAmount = parseFloat(mainEntry.amount || 0)
|
||||
if (tvaAmount === 0 || rate === 0) return 0
|
||||
return tvaAmount * (100 + rate) / rate
|
||||
})
|
||||
|
||||
const totalMismatchPayment = computed(() => {
|
||||
if (!props.data.amount || paymentSum.value === 0) return false
|
||||
const total = parseFloat(props.data.amount)
|
||||
return Math.abs(total - paymentSum.value) > 0.02 // Tolerance 2 bani
|
||||
})
|
||||
|
||||
const totalMismatchTva = computed(() => {
|
||||
if (!props.data.amount || tvaImpliedTotal.value === 0) return false
|
||||
const total = parseFloat(props.data.amount)
|
||||
return Math.abs(total - tvaImpliedTotal.value) > 0.50 // Tolerance 50 bani (TVA calc has rounding)
|
||||
})
|
||||
|
||||
const getSuggestedPaymentLabel = (mode) => {
|
||||
const labels = {
|
||||
'casa': 'Casa (numerar firma)',
|
||||
@@ -320,12 +381,6 @@ const formatProcessingTime = (ms) => {
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
/* TOTAL section - prominent styling */
|
||||
.ocr-section-total {
|
||||
background: linear-gradient(135deg, rgba(220, 252, 231, 0.8) 0%, rgba(187, 247, 208, 0.6) 100%);
|
||||
border: 2px solid #86efac;
|
||||
}
|
||||
|
||||
/* FURNIZOR section */
|
||||
.vendor-name {
|
||||
font-weight: 600;
|
||||
@@ -347,6 +402,33 @@ const formatProcessingTime = (ms) => {
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
|
||||
/* CLIENT section */
|
||||
.client-name {
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.client-cui {
|
||||
font-size: 0.85rem;
|
||||
color: #475569;
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
|
||||
.client-address {
|
||||
font-size: 0.8rem;
|
||||
color: #64748b;
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
|
||||
/* Placeholder when no data extracted */
|
||||
.no-data {
|
||||
color: #9ca3af;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* DOCUMENT section */
|
||||
.document-row {
|
||||
display: flex;
|
||||
@@ -373,92 +455,117 @@ const formatProcessingTime = (ms) => {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
/* TOTAL section - prominent */
|
||||
.ocr-total-box {
|
||||
background: linear-gradient(135deg, #dcfce7 0%, #bbf7d0 100%);
|
||||
border: 2px solid #86efac;
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem 1rem;
|
||||
/* Unified values table (TOTAL + Payment + TVA) */
|
||||
.ocr-values-table {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.value-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 0.9rem;
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
.value-label {
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.value-amount {
|
||||
font-weight: 600;
|
||||
margin-left: auto;
|
||||
color: #1f2937;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.total-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.total-amount {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
/* Payment methods inside total box */
|
||||
.payment-methods-inline {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
.value-row.total-row {
|
||||
margin-top: 0.35rem;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px dashed #86efac;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.payment-tag {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.items-count {
|
||||
text-align: center;
|
||||
/* Items count - subtle, at bottom of values section */
|
||||
.items-count-inline {
|
||||
font-size: 0.8rem;
|
||||
color: #64748b;
|
||||
margin-top: 0.35rem;
|
||||
text-align: right;
|
||||
padding-top: 0.35rem;
|
||||
}
|
||||
|
||||
/* TVA section */
|
||||
.ocr-tva-table {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
/* Confidence indicator inline */
|
||||
.confidence-inline {
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
.tva-row {
|
||||
/* Warning row for calculated total */
|
||||
.value-row.warning-row {
|
||||
background: #fef3c7;
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.value-row.warning-row .value-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.tva-code {
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
min-width: 1rem;
|
||||
.warning-icon {
|
||||
color: #f59e0b;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.tva-percent {
|
||||
color: #64748b;
|
||||
.value-amount.calculated {
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.value-amount .hint {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 400;
|
||||
color: #a3a3a3;
|
||||
}
|
||||
|
||||
/* Validation warnings */
|
||||
.validation-warning {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
margin: 0.25rem 0;
|
||||
background: #fee2e2;
|
||||
border-radius: 6px;
|
||||
font-size: 0.8rem;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.tva-amount {
|
||||
font-weight: 500;
|
||||
margin-left: auto;
|
||||
.validation-warning i {
|
||||
color: #dc2626;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.tva-total-row {
|
||||
margin-top: 0.25rem;
|
||||
padding-top: 0.25rem;
|
||||
border-top: 1px dashed #86efac;
|
||||
/* Validation info */
|
||||
.validation-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
margin: 0.25rem 0;
|
||||
background: #dbeafe;
|
||||
border-radius: 6px;
|
||||
font-size: 0.8rem;
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
.tva-label {
|
||||
font-weight: 600;
|
||||
color: #166534;
|
||||
.validation-info i {
|
||||
color: #2563eb;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.raw-text-section {
|
||||
|
||||
@@ -26,6 +26,7 @@ export const useReceiptsStore = defineStore('receipts', {
|
||||
filters: {
|
||||
status: null,
|
||||
search: '',
|
||||
direction: null,
|
||||
dateFrom: null,
|
||||
dateTo: null,
|
||||
},
|
||||
@@ -60,6 +61,9 @@ export const useReceiptsStore = defineStore('receipts', {
|
||||
if (this.filters.search) {
|
||||
params.search = this.filters.search
|
||||
}
|
||||
if (this.filters.direction) {
|
||||
params.direction = this.filters.direction
|
||||
}
|
||||
if (this.filters.dateFrom) {
|
||||
params.date_from = this.filters.dateFrom
|
||||
}
|
||||
@@ -193,6 +197,20 @@ export const useReceiptsStore = defineStore('receipts', {
|
||||
}
|
||||
},
|
||||
|
||||
async unapproveReceipt(id) {
|
||||
this.loading = true
|
||||
this.error = null
|
||||
try {
|
||||
const response = await api.post(`/${id}/unapprove`)
|
||||
return response.data
|
||||
} catch (error) {
|
||||
this.error = error.response?.data?.detail || 'Failed to unapprove receipt'
|
||||
throw error
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
// ============ Pending Receipts ============
|
||||
|
||||
async fetchPendingReceipts() {
|
||||
@@ -409,6 +427,7 @@ export const useReceiptsStore = defineStore('receipts', {
|
||||
this.filters = {
|
||||
status: null,
|
||||
search: '',
|
||||
direction: null,
|
||||
dateFrom: null,
|
||||
dateTo: null,
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user