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:
2025-12-12 16:23:53 +02:00
parent 41ae97180e
commit 20448f7aa0
11 changed files with 1021 additions and 68 deletions

View File

@@ -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;

View File

@@ -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>

View File

@@ -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>