feat: Add multiple TVA entries support for Romanian receipts
- Add TvaEntry schema supporting multiple TVA rates (A, B, C, D codes) - Update OCR extractor to extract multiple TVA entries from receipts - Support both old (19%, 9%, 5%) and new Romanian rates (21%, 11% from Aug 2025) - Add tva_breakdown, tva_total, items_count, vendor_address to Receipt model - Update OCRPreview.vue to display TVA entries with rate badges - Add "Detalii Suplimentare" section in ReceiptCreateView with editable TVA table - Add TVA breakdown display in ReceiptDetailView - Create database migration for new TVA columns 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -71,6 +71,37 @@
|
||||
<span v-if="data.cui" class="cui-badge">CUI: {{ data.cui }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TVA Entries (multiple rates) -->
|
||||
<div class="preview-field full-width" v-if="data.tva_entries?.length > 0 || data.tva_total">
|
||||
<label>TVA</label>
|
||||
<div class="tva-breakdown">
|
||||
<div v-for="(entry, idx) in data.tva_entries" :key="idx" class="tva-entry">
|
||||
<span class="tva-code" v-if="entry.code">{{ entry.code }}</span>
|
||||
<span class="tva-percent-badge">{{ entry.percent }}%</span>
|
||||
<span class="tva-amount">{{ formatAmount(entry.amount) }} RON</span>
|
||||
</div>
|
||||
<div v-if="data.tva_total && data.tva_entries?.length > 1" class="tva-total">
|
||||
<strong>Total TVA:</strong> {{ formatAmount(data.tva_total) }} RON
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Items Count -->
|
||||
<div class="preview-field" v-if="data.items_count">
|
||||
<label>Nr. Articole</label>
|
||||
<div class="field-value">
|
||||
{{ data.items_count }} articole
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Address -->
|
||||
<div class="preview-field full-width" v-if="data.address">
|
||||
<label>Adresa</label>
|
||||
<div class="field-value address-text">
|
||||
{{ data.address }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Raw Text Toggle -->
|
||||
@@ -224,6 +255,50 @@ const formatDate = (dateStr) => {
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.tva-breakdown {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.tva-entry {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.tva-code {
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
min-width: 1rem;
|
||||
}
|
||||
|
||||
.tva-percent-badge {
|
||||
display: inline-block;
|
||||
padding: 0.15rem 0.5rem;
|
||||
background: #dbeafe;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
color: #1e40af;
|
||||
min-width: 2.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.tva-amount {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tva-total {
|
||||
margin-top: 0.25rem;
|
||||
padding-top: 0.25rem;
|
||||
border-top: 1px dashed #cbd5e1;
|
||||
}
|
||||
|
||||
.address-text {
|
||||
font-size: 0.9rem;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.raw-text-section {
|
||||
margin-top: 1rem;
|
||||
padding-top: 1rem;
|
||||
|
||||
@@ -246,11 +246,59 @@
|
||||
<Textarea
|
||||
v-model="form.description"
|
||||
rows="3"
|
||||
placeholder="Detalii suplimentare..."
|
||||
placeholder="Descriere optionala..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Detalii Suplimentare (populated from OCR) -->
|
||||
<div v-if="form.tva_breakdown?.length > 0 || form.items_count || form.vendor_address" class="extra-details-section">
|
||||
<h3>
|
||||
<i class="pi pi-list"></i>
|
||||
Detalii Suplimentare (din OCR)
|
||||
</h3>
|
||||
|
||||
<!-- TVA Breakdown -->
|
||||
<div class="form-field form-field-full" v-if="form.tva_breakdown?.length > 0">
|
||||
<label>Defalcare TVA</label>
|
||||
<div class="tva-table">
|
||||
<div v-for="(entry, idx) in form.tva_breakdown" :key="idx" class="tva-row">
|
||||
<span class="tva-label">TVA {{ entry.code }} ({{ entry.percent }}%):</span>
|
||||
<InputNumber
|
||||
v-model="form.tva_breakdown[idx].amount"
|
||||
mode="currency"
|
||||
currency="RON"
|
||||
locale="ro-RO"
|
||||
:minFractionDigits="2"
|
||||
class="tva-input"
|
||||
/>
|
||||
</div>
|
||||
<div class="tva-row total" v-if="form.tva_breakdown.length > 0">
|
||||
<span class="tva-label"><strong>Total TVA:</strong></span>
|
||||
<span class="tva-value">{{ formatTvaTotal() }} RON</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-grid">
|
||||
<div class="form-field" v-if="form.items_count">
|
||||
<label>Nr. Articole</label>
|
||||
<InputNumber
|
||||
v-model="form.items_count"
|
||||
:min="1"
|
||||
placeholder="Ex: 17"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-field" v-if="form.vendor_address">
|
||||
<label>Adresa Furnizor</label>
|
||||
<InputText
|
||||
v-model="form.vendor_address"
|
||||
placeholder="Adresa din bon"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
<!-- Actions -->
|
||||
@@ -314,6 +362,11 @@ const form = ref({
|
||||
receipt_number: '',
|
||||
description: '',
|
||||
company_id: 1, // Default company for Phase 1
|
||||
// TVA info (multiple entries support)
|
||||
tva_breakdown: [], // Array of {code, percent, amount}
|
||||
tva_total: null,
|
||||
items_count: null,
|
||||
vendor_address: '',
|
||||
})
|
||||
|
||||
const selectedFiles = ref([])
|
||||
@@ -435,6 +488,24 @@ const applyOCRData = (data) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Apply TVA entries
|
||||
if (data.tva_entries?.length > 0) {
|
||||
form.value.tva_breakdown = data.tva_entries.map(e => ({
|
||||
code: e.code,
|
||||
percent: e.percent,
|
||||
amount: parseFloat(e.amount)
|
||||
}))
|
||||
}
|
||||
if (data.tva_total) {
|
||||
form.value.tva_total = parseFloat(data.tva_total)
|
||||
}
|
||||
if (data.items_count) {
|
||||
form.value.items_count = data.items_count
|
||||
}
|
||||
if (data.address) {
|
||||
form.value.vendor_address = data.address
|
||||
}
|
||||
|
||||
// Clear OCR preview
|
||||
ocrData.value = null
|
||||
|
||||
@@ -499,6 +570,12 @@ const formatFileSize = (bytes) => {
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
|
||||
}
|
||||
|
||||
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)
|
||||
return total.toLocaleString('ro-RO', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
}
|
||||
|
||||
const validateForm = () => {
|
||||
// Check if we have at least one file (for new receipts)
|
||||
if (!isEditMode.value && selectedFiles.value.length === 0) {
|
||||
@@ -725,4 +802,55 @@ const submitForReview = async () => {
|
||||
font-size: 0.85rem;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
/* Extra details section (TVA, items, address) */
|
||||
.extra-details-section {
|
||||
margin-top: 1.5rem;
|
||||
padding: 1rem;
|
||||
background: #f0f9ff;
|
||||
border: 1px solid #bae6fd;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.extra-details-section h3 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
color: #0284c7;
|
||||
}
|
||||
|
||||
.tva-table {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.tva-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.tva-row.total {
|
||||
margin-top: 0.5rem;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px dashed #0284c7;
|
||||
}
|
||||
|
||||
.tva-label {
|
||||
min-width: 150px;
|
||||
font-weight: 500;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.tva-input {
|
||||
max-width: 150px;
|
||||
}
|
||||
|
||||
.tva-value {
|
||||
font-weight: 600;
|
||||
color: #0284c7;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -112,6 +112,43 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Detalii Suplimentare (TVA, items, address from OCR) -->
|
||||
<template v-if="hasTvaData || receipt.items_count || receipt.vendor_address">
|
||||
<Divider />
|
||||
|
||||
<h4 style="margin-bottom: 0.75rem; color: #0284c7;">
|
||||
<i class="pi pi-list"></i>
|
||||
Detalii Suplimentare
|
||||
</h4>
|
||||
|
||||
<div class="detail-list">
|
||||
<!-- TVA Breakdown -->
|
||||
<div v-if="parsedTvaBreakdown?.length > 0" class="detail-item tva-detail">
|
||||
<span class="label">TVA</span>
|
||||
<div class="tva-breakdown-display">
|
||||
<div v-for="(entry, idx) in parsedTvaBreakdown" :key="idx" class="tva-line">
|
||||
<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) }}</span>
|
||||
</div>
|
||||
<div v-if="receipt.tva_total && parsedTvaBreakdown.length > 1" class="tva-total-line">
|
||||
<strong>Total TVA: {{ formatAmount(receipt.tva_total) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-item" v-if="receipt.items_count">
|
||||
<span class="label">Nr. Articole</span>
|
||||
<span class="value">{{ receipt.items_count }} articole</span>
|
||||
</div>
|
||||
|
||||
<div class="detail-item" v-if="receipt.vendor_address">
|
||||
<span class="label">Adresa Furnizor</span>
|
||||
<span class="value">{{ receipt.vendor_address }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<Divider />
|
||||
|
||||
<div class="detail-list">
|
||||
@@ -283,6 +320,22 @@ const isBalanced = computed(() => {
|
||||
return Math.abs(totalDebit.value - totalCredit.value) < 0.01
|
||||
})
|
||||
|
||||
const parsedTvaBreakdown = computed(() => {
|
||||
if (!receipt.value?.tva_breakdown) return []
|
||||
try {
|
||||
// Handle both string (JSON) and array formats
|
||||
return typeof receipt.value.tva_breakdown === 'string'
|
||||
? JSON.parse(receipt.value.tva_breakdown)
|
||||
: receipt.value.tva_breakdown
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
})
|
||||
|
||||
const hasTvaData = computed(() => {
|
||||
return parsedTvaBreakdown.value?.length > 0 || receipt.value?.tva_total
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await loadReceipt()
|
||||
})
|
||||
@@ -521,4 +574,56 @@ const resubmitReceipt = async () => {
|
||||
border-radius: 8px;
|
||||
color: #f57c00;
|
||||
}
|
||||
|
||||
/* TVA Breakdown Display */
|
||||
.detail-item.tva-detail {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.tva-breakdown-display {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
padding: 0.5rem;
|
||||
background: #f0f9ff;
|
||||
border-radius: 6px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tva-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.tva-code {
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
min-width: 1.5rem;
|
||||
}
|
||||
|
||||
.tva-percent {
|
||||
display: inline-block;
|
||||
padding: 0.1rem 0.4rem;
|
||||
background: #dbeafe;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85rem;
|
||||
color: #1e40af;
|
||||
min-width: 2.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.tva-amount {
|
||||
font-weight: 500;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.tva-total-line {
|
||||
margin-top: 0.25rem;
|
||||
padding-top: 0.25rem;
|
||||
border-top: 1px dashed #0284c7;
|
||||
color: #0284c7;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user