diff --git a/data-entry-app/backend/app/db/crud/receipt.py b/data-entry-app/backend/app/db/crud/receipt.py index 7bc9b39..f5ec6d8 100644 --- a/data-entry-app/backend/app/db/crud/receipt.py +++ b/data-entry-app/backend/app/db/crud/receipt.py @@ -122,6 +122,9 @@ class ReceiptCRUD: if filters.status: query = query.where(Receipt.status == filters.status) + if filters.direction: + query = query.where(Receipt.direction == filters.direction) + if filters.company_id: query = query.where(Receipt.company_id == filters.company_id) diff --git a/data-entry-app/backend/app/routers/receipts.py b/data-entry-app/backend/app/routers/receipts.py index cf128a3..5e2fd1b 100644 --- a/data-entry-app/backend/app/routers/receipts.py +++ b/data-entry-app/backend/app/routers/receipts.py @@ -29,7 +29,7 @@ from app.schemas.receipt import ( CashRegisterOption, ExpenseTypeOption, ) -from app.db.models.receipt import ReceiptStatus +from app.db.models.receipt import ReceiptStatus, ReceiptDirection # Auth integration from auth.dependencies import get_current_user @@ -121,6 +121,7 @@ async def create_receipt( @router.get("/", response_model=ReceiptListResponse) async def list_receipts( status: Optional[ReceiptStatus] = None, + direction: Optional[ReceiptDirection] = None, company_id: Optional[int] = None, created_by: Optional[str] = None, date_from: Optional[str] = None, @@ -136,6 +137,7 @@ async def list_receipts( filters = ReceiptFilter( status=status, + direction=direction, company_id=company_id or selected_company, created_by=created_by, date_from=date_type.fromisoformat(date_from) if date_from else None, @@ -301,6 +303,24 @@ async def resubmit_receipt( ) +@router.post("/{receipt_id}/unapprove", response_model=WorkflowAction) +async def unapprove_receipt( + receipt_id: int, + session: AsyncSession = Depends(get_session), + current_user: CurrentUser = Depends(get_current_user), +): + """Unapprove receipt (APPROVED → PENDING_REVIEW). Returns to pending for corrections.""" + success, message, receipt = await ReceiptService.unapprove_receipt( + session, receipt_id, current_user.username + ) + + return WorkflowAction( + success=success, + message=message, + receipt=ReceiptResponse.model_validate(receipt) if receipt else None, + ) + + # ============ Accounting Entries Endpoints ============ @router.get("/{receipt_id}/entries", response_model=List[AccountingEntryResponse]) diff --git a/data-entry-app/backend/app/schemas/ocr.py b/data-entry-app/backend/app/schemas/ocr.py index 78059cb..d38a7e8 100644 --- a/data-entry-app/backend/app/schemas/ocr.py +++ b/data-entry-app/backend/app/schemas/ocr.py @@ -42,9 +42,15 @@ class ExtractionData(BaseModel): payment_methods: List[PaymentMethod] = Field(default=[], description="Payment methods from receipt (CARD, NUMERAR)") suggested_payment_mode: Optional[str] = Field(default=None, description="Auto-suggested payment mode based on OCR (casa/banca)") + # Client data (for B2B receipts - buyer information) + client_name: Optional[str] = Field(default=None, description="Client/customer company name") + client_cui: Optional[str] = Field(default=None, description="Client CUI/CIF fiscal code") + client_address: Optional[str] = Field(default=None, description="Client address") + confidence_amount: float = Field(default=0.0, ge=0, le=1, description="Amount extraction confidence") confidence_date: float = Field(default=0.0, ge=0, le=1, description="Date extraction confidence") confidence_vendor: float = Field(default=0.0, ge=0, le=1, description="Vendor extraction confidence") + confidence_client: float = Field(default=0.0, ge=0, le=1, description="Client extraction confidence") overall_confidence: float = Field(default=0.0, ge=0, le=1, description="Overall confidence score") raw_text: str = Field(default="", description="Raw OCR text") ocr_engine: str = Field(default="", description="OCR engine used: paddleocr or tesseract") diff --git a/data-entry-app/backend/app/schemas/receipt.py b/data-entry-app/backend/app/schemas/receipt.py index eed3ba0..6c96426 100644 --- a/data-entry-app/backend/app/schemas/receipt.py +++ b/data-entry-app/backend/app/schemas/receipt.py @@ -147,6 +147,8 @@ class ReceiptResponse(ReceiptBase): model_config = ConfigDict(from_attributes=True) id: int + # Override amount to allow zero values in response (validation is on input, not output) + amount: Decimal status: ReceiptStatus created_by: str created_at: datetime @@ -206,6 +208,7 @@ class ReceiptListResponse(BaseModel): class ReceiptFilter(BaseModel): """Schema for filtering receipts.""" status: Optional[ReceiptStatus] = None + direction: Optional[ReceiptDirection] = None company_id: Optional[int] = None created_by: Optional[str] = None date_from: Optional[date] = None diff --git a/data-entry-app/backend/app/services/ocr_extractor.py b/data-entry-app/backend/app/services/ocr_extractor.py index d90d7c1..aeb2d06 100644 --- a/data-entry-app/backend/app/services/ocr_extractor.py +++ b/data-entry-app/backend/app/services/ocr_extractor.py @@ -25,9 +25,15 @@ class ExtractionResult: items_count: Optional[int] = None payment_methods: List[dict] = field(default_factory=list) # [{"method":"CARD","amount":Decimal}] + # Client data (for B2B receipts - buyer information) + client_name: Optional[str] = None + client_cui: Optional[str] = None + client_address: Optional[str] = None + confidence_amount: float = 0.0 confidence_date: float = 0.0 confidence_vendor: float = 0.0 + confidence_client: float = 0.0 raw_text: str = "" ocr_engine: str = "" # OCR engine used: paddleocr or tesseract processing_time_ms: int = 0 # Processing time in milliseconds @@ -51,8 +57,10 @@ class ReceiptExtractor: # Romanian receipts use various formats: TOTAL LEI, TOTAL:, TOTAL RON, etc. # OCR often produces errors, so patterns must be tolerant TOTAL_PATTERNS = [ - # Most common: TOTAL LEI followed by amount - (r'TOTAL\s+LEI\s*([\d\s.,]+)', 0.98), + # Most common: TOTAL LEI followed by amount (with OCR-tolerant variations) + # Handles: TOTAL LEI, TOTAL. LE!, T0TAL LEI, TOTAL LE1, etc. + (r'T[O0]TAL[.\s]+L[E3][I1!]\s*:?\s*([\d\s.,]+)', 0.98), # OCR-tolerant: TOTAL. LE!, T0TAL LEI + (r'TOTAL\s+LEI\s*([\d\s.,]+)', 0.98), # Standard clean pattern (r'[OT]?OTAL\s+LEI\s*([\d\s.,]+)', 0.95), # OCR may miss first letter # Standard patterns (r'TOTAL\s*:?\s*([\d\s.,]+)\s*(?:RON|LEI)?', 0.95), @@ -228,6 +236,41 @@ class ReceiptExtractor: (r'(JUD\.?\s+[A-Z]+,?\s*(?:MUN\.?|OR\.?|COM\.?)?\s*[A-Z]+)', 0.85), ] + # Client/Buyer patterns (for B2B receipts) + # CLIENT, CUMPARATOR, BENEFICIAR sections + CLIENT_SECTION_MARKERS = [ + r'C\.?\s*I\.?\s*F\.?\s+CLIENT\s*:', # CIF CLIENT: (reversed format) + r'C\.?\s*U\.?\s*I\.?\s+CLIENT\s*:', # CUI CLIENT: (reversed format) + r'CLIENT\s*:', + r'CUMPARATOR\s*:', + r'BENEFICIAR\s*:', + r'CUMP[AĂ]R[AĂ]TOR\s*:', + r'DATE\s+CLIENT', + r'LIENT\s*:', # OCR truncation + ] + + # Client CUI patterns (explicitly after CLIENT marker) + CLIENT_CUI_PATTERNS = [ + # CIF CLIENT: R01879856 (reversed format - CIF before CLIENT) + (r'C\.?\s*I\.?\s*F\.?\s+CLIENT\s*:?\s*(R[O0]?\d{6,10})', 0.98), + (r'C\.?\s*U\.?\s*I\.?\s+CLIENT\s*:?\s*(R[O0]?\d{6,10})', 0.98), + (r'C\.?\s*I\.?\s*F\.?\s+CLIENT\s*:?\s*(?:R[O0])?(\d{6,10})', 0.98), + (r'C\.?\s*U\.?\s*I\.?\s+CLIENT\s*:?\s*(?:R[O0])?(\d{6,10})', 0.98), + # CLIENT C.U.I./ C.I.F. :R01879855 (slash variant with both labels) + (r'CLIENT\s+C\.\s*U\.\s*I\.?\s*/\s*C\.\s*[I1]\.\s*F\.?\s*:?\s*(R[O0]?\d{6,10})', 0.97), + (r'CLIENT\s+C\.?\s*U\.?\s*I\.?(?:\s*/\s*C\.?\s*[I1]\.?\s*F\.?)?\s*:?\s*(R[O0]?\d{6,10})', 0.96), + # CLIENT C.U.I. or CLIENT CUI or CLIENT CIF + (r'CLIENT\s+C\.?\s*U\.?\s*I\.?\s*:?\s*(?:R[O0])?(\d{6,10})', 0.98), + (r'CLIENT\s+C\.?\s*I\.?\s*F\.?\s*:?\s*(?:R[O0])?(\d{6,10})', 0.98), + (r'CUMPARATOR\s+C\.?\s*U\.?\s*I\.?\s*:?\s*(?:R[O0])?(\d{6,10})', 0.95), + (r'CUMPARATOR\s+C\.?\s*I\.?\s*F\.?\s*:?\s*(?:R[O0])?(\d{6,10})', 0.95), + # CUI/CIF on line immediately after CLIENT marker + (r'CLIENT\s*:\s*\n\s*C\.?\s*U\.?\s*I\.?\s*:?\s*(?:R[O0])?(\d{6,10})', 0.95), + (r'CLIENT\s*:\s*\n\s*C\.?\s*I\.?\s*F\.?\s*:?\s*(?:R[O0])?(\d{6,10})', 0.95), + # CUI after client name: "CLIENT: COMPANY SRL\nCUI: 12345678" + (r'CLIENT\s*:.*\n.*C\.?\s*U\.?\s*I\.?\s*:?\s*(?:R[O0])?(\d{6,10})', 0.90), + ] + # Vendor name indicators (lines containing these are likely vendor names) # These should be company type suffixes, not generic words # Patterns must handle OCR spaces: "S. R. L." as well as "S.R.L." @@ -282,6 +325,13 @@ class ReceiptExtractor: result.address = self._extract_address(text_upper) result.payment_methods = self._extract_payment_methods(text_upper) + # Extract client data (B2B receipts) + client_name, client_cui, client_address, confidence_client = self._extract_client_data(text_upper, text) + result.client_name = client_name + result.client_cui = client_cui + result.client_address = client_address + result.confidence_client = confidence_client + # Detect receipt type result.receipt_type = self._detect_receipt_type(text_upper) @@ -291,6 +341,19 @@ class ReceiptExtractor: if not is_valid: print(f"[TVA Reverse Validation] {msg}", flush=True) + # Cross-validate amount using payment methods and TVA + validated_amount, validated_confidence, source = self._cross_validate_and_calculate_amount( + result.amount, + result.confidence_amount, + result.payment_methods, + result.tva_entries, + result.tva_total + ) + if validated_amount != result.amount: + print(f"[Cross-Validation] Amount updated: {result.amount} -> {validated_amount} (source: {source})", flush=True) + result.amount = validated_amount + result.confidence_amount = validated_confidence + return result def _extract_amount(self, text: str) -> Tuple[Optional[Decimal], float]: @@ -402,6 +465,97 @@ class ReceiptExtractor: return num_str + def _cross_validate_and_calculate_amount( + self, + amount: Optional[Decimal], + confidence_amount: float, + payment_methods: List[dict], + tva_entries: List[dict], + tva_total: Optional[Decimal] + ) -> Tuple[Optional[Decimal], float, str]: + """ + Cross-validate and potentially calculate total from payment methods and TVA. + + Returns: (amount, confidence, source_description) + + Logic: + 1. If amount is valid (>0) with high confidence (>=0.8), use it directly + 2. Calculate payment_sum = CARD + NUMERAR + other methods + 3. Calculate tva_implied_total = tva_total * (100 + rate) / rate + 4. Cross-validate: if payment_sum matches extracted amount, boost confidence + 5. If amount is 0/None, use payment_sum as total + 6. If payment_sum is 0, try to calculate from TVA + """ + # Calculate payment methods sum + payment_sum = Decimal('0') + if payment_methods: + for pm in payment_methods: + try: + pm_amount = pm.get('amount') + if pm_amount: + payment_sum += Decimal(str(pm_amount)) + except (InvalidOperation, ValueError, TypeError): + continue + + # Calculate TVA-implied total: total = tva * (100 + rate) / rate + tva_implied_total = None + if tva_entries: + # Use the main TVA entry (typically the largest or first one) + main_entry = tva_entries[0] + rate = main_entry.get('percent', 19) + tva_amount = main_entry.get('amount') + if tva_amount and rate > 0: + try: + tva_dec = Decimal(str(tva_amount)) + # total = tva * (100 + rate) / rate + tva_implied_total = (tva_dec * Decimal(100 + rate) / Decimal(rate)).quantize(Decimal('0.01')) + except (InvalidOperation, ValueError, TypeError): + pass + + # Case 1: Amount is valid with high confidence - just validate + if amount and amount > 0 and confidence_amount >= 0.8: + # Cross-validate: check if it matches payment methods + if payment_sum > 0 and abs(amount - payment_sum) <= Decimal('0.02'): + # Perfect match - boost confidence + return amount, min(0.98, confidence_amount + 0.05), "extracted (validated by payment methods)" + return amount, confidence_amount, "extracted" + + # Case 2: Amount exists but low confidence - try to validate/correct + if amount and amount > 0: + # Check if payment methods sum matches + if payment_sum > 0: + if abs(amount - payment_sum) <= Decimal('0.02'): + # Match - boost confidence + return amount, 0.90, "extracted (validated by payment methods)" + else: + # Mismatch - prefer payment_sum as it's more reliable + print(f"[Cross-Validation] Amount mismatch: extracted={amount}, payments={payment_sum}", flush=True) + return payment_sum, 0.85, "calculated from payment methods" + + # Check TVA-implied total + if tva_implied_total: + if abs(amount - tva_implied_total) <= Decimal('0.50'): + # Close match - use extracted amount + return amount, 0.80, "extracted (validated by TVA)" + else: + print(f"[Cross-Validation] TVA mismatch: extracted={amount}, tva_implied={tva_implied_total}", flush=True) + + # No validation possible - return as-is + return amount, confidence_amount, "extracted (unvalidated)" + + # Case 3: Amount is 0 or None - calculate from payment methods + if payment_sum > 0: + print(f"[Cross-Validation] Amount not found, using payment sum: {payment_sum}", flush=True) + return payment_sum, 0.85, "calculated from payment methods" + + # Case 4: Try TVA-implied total as last resort + if tva_implied_total and tva_implied_total > 0: + print(f"[Cross-Validation] Amount not found, using TVA-implied total: {tva_implied_total}", flush=True) + return tva_implied_total, 0.70, "calculated from TVA" + + # Nothing worked - return original + return amount, confidence_amount, "not found" + def _extract_date(self, text: str) -> Tuple[Optional[date], float]: """Extract receipt date from text.""" # First try standard patterns (clean dates) @@ -650,9 +804,17 @@ class ReceiptExtractor: return None, 0.0 def _detect_receipt_type(self, text: str) -> str: - """Detect receipt type from text content.""" + """Detect receipt type from text content. + + BON FISCAL variants: "BON FISCAL", "BON FISCAL.", "BON FISCAL" + CHITANTA variants: "CHITANTA", "CHITANȚĂ" + """ + # Check for explicit BON FISCAL first (handles OCR spacing variations) + if re.search(r'BON\s+FISCAL', text): + return 'bon_fiscal' if 'CHITANTA' in text or 'CHITANȚĂ' in text: return 'chitanta' + # Default to bon_fiscal if neither found return 'bon_fiscal' def _extract_tva_entries(self, text: str) -> Tuple[List[dict], Optional[Decimal]]: @@ -1237,3 +1399,103 @@ class ReceiptExtractor: continue return payment_methods + + def _extract_client_data( + self, text_upper: str, original_text: str + ) -> Tuple[Optional[str], Optional[str], Optional[str], float]: + """ + Extract client/buyer data from B2B receipts. + + Returns (client_name, client_cui, client_address, confidence) + """ + client_name = None + client_cui = None + client_address = None + confidence = 0.0 + + # Step 1: Find CLIENT section marker + client_section_start = None + for marker in self.CLIENT_SECTION_MARKERS: + match = re.search(marker, text_upper, re.IGNORECASE) + if match: + client_section_start = match.start() + break + + if client_section_start is None: + # No client section found + return None, None, None, 0.0 + + # Step 2: Extract client CUI + for pattern, conf in self.CLIENT_CUI_PATTERNS: + match = re.search(pattern, text_upper, re.IGNORECASE | re.MULTILINE) + if match: + cui = match.group(1) + if 6 <= len(cui) <= 10: + client_cui = cui + confidence = max(confidence, conf) + break + + # Step 3: Extract client name from CLIENT section + # Look for company name after CLIENT: marker + lines = original_text.split('\n') + for i, line in enumerate(lines): + line_upper = line.upper().strip() + + # Check if this line contains CLIENT marker + if any(re.search(marker, line_upper) for marker in self.CLIENT_SECTION_MARKERS): + # Check if name is on same line after ":" + if ':' in line: + name_part = line.split(':', 1)[1].strip() + if name_part and len(name_part) >= 3: + # Skip if it looks like a CUI (R/RO followed by digits) + if re.match(r'^R[O0]?\d{6,10}$', name_part.upper()): + # This is a CUI, not a name - extract it if not already found + if not client_cui: + cui_digits = re.sub(r'[^0-9]', '', name_part) + if 6 <= len(cui_digits) <= 10: + client_cui = cui_digits + confidence = max(confidence, 0.90) + continue + # Check for company indicators + if any(re.search(ind, name_part.upper()) for ind in self.VENDOR_INDICATORS): + client_name = self._clean_vendor_name(name_part) + confidence = max(confidence, 0.95) + break + elif len(name_part) >= 5 and not name_part.isdigit(): + client_name = self._clean_vendor_name(name_part) + confidence = max(confidence, 0.80) + break + + # Check next line for company name + if i + 1 < len(lines): + next_line = lines[i + 1].strip() + next_upper = next_line.upper() + + # Skip if it's a CUI/CIF line + if not re.search(r'C\.?\s*[UI]\.?\s*[IF]\.?', next_upper): + if any(re.search(ind, next_upper) for ind in self.VENDOR_INDICATORS): + client_name = self._clean_vendor_name(next_line) + confidence = max(confidence, 0.90) + break + elif len(next_line) >= 5 and not next_line.isdigit(): + # Check if it looks like a company name + if not any(kw in next_upper for kw in ['CUI', 'CIF', 'COD', 'FISCAL']): + client_name = self._clean_vendor_name(next_line) + confidence = max(confidence, 0.75) + break + + # Step 4: Extract client address (if present after client section) + if client_section_start: + # Look for address patterns after client section + client_region = text_upper[client_section_start:client_section_start + 500] + for pattern, _ in self.ADDRESS_PATTERNS: + match = re.search(pattern, client_region) + if match: + client_address = match.group(1).strip() + break + + # Log extraction result + if client_cui or client_name: + print(f"[Client Extraction] Found: name={client_name}, cui={client_cui}, conf={confidence}", flush=True) + + return client_name, client_cui, client_address, confidence diff --git a/data-entry-app/backend/app/services/receipt_service.py b/data-entry-app/backend/app/services/receipt_service.py index f61abe6..278eed4 100644 --- a/data-entry-app/backend/app/services/receipt_service.py +++ b/data-entry-app/backend/app/services/receipt_service.py @@ -139,7 +139,6 @@ class ReceiptService: account_code=expense_type.account_code, account_name=expense_type.account_name, amount=net_amount, - partner_id=receipt.partner_id, )) # Debit: VAT deductible @@ -156,7 +155,6 @@ class ReceiptService: account_code=expense_type.account_code, account_name=expense_type.account_name, amount=amount, - partner_id=receipt.partner_id, )) # Credit entry - based on payment_mode (new) or cash_register (legacy) @@ -245,6 +243,9 @@ class ReceiptService: await AccountingEntryCRUD.delete_all_for_receipt(session, receipt_id) await AccountingEntryCRUD.create_bulk(session, receipt_id, entries, is_auto_generated=True) + # Refresh receipt to clear stale relationship references after entry deletion + await session.refresh(receipt) + # Update status updated = await ReceiptCRUD.update_status( session, receipt, ReceiptStatus.PENDING_REVIEW @@ -288,6 +289,31 @@ class ReceiptService: return True, "Receipt approved", updated + @staticmethod + async def unapprove_receipt( + session: AsyncSession, + receipt_id: int, + username: str, + ) -> Tuple[bool, str, Optional[Receipt]]: + """ + Unapprove receipt (APPROVED → PENDING_REVIEW). + Returns receipt to pending review for corrections. + """ + receipt = await ReceiptCRUD.get_by_id(session, receipt_id) + + if not receipt: + return False, "Receipt not found", None + + if receipt.status != ReceiptStatus.APPROVED: + return False, "Receipt is not approved", None + + # Update status back to pending review + updated = await ReceiptCRUD.update_status( + session, receipt, ReceiptStatus.PENDING_REVIEW + ) + + return True, "Receipt returned to pending review", updated + @staticmethod async def reject_receipt( session: AsyncSession, @@ -342,6 +368,9 @@ class ReceiptService: await AccountingEntryCRUD.delete_all_for_receipt(session, receipt_id) await AccountingEntryCRUD.create_bulk(session, receipt_id, entries, is_auto_generated=True) + # Refresh receipt to clear stale relationship references after entry deletion + await session.refresh(receipt) + # Update status updated = await ReceiptCRUD.update_status( session, receipt, ReceiptStatus.PENDING_REVIEW diff --git a/data-entry-app/frontend/src/components/ocr/OCRPreview.vue b/data-entry-app/frontend/src/components/ocr/OCRPreview.vue index 1cac08d..afa8830 100644 --- a/data-entry-app/frontend/src/components/ocr/OCRPreview.vue +++ b/data-entry-app/frontend/src/components/ocr/OCRPreview.vue @@ -39,6 +39,22 @@ + +
+
CLIENT
+
+ +
-
+
+
+
DOCUMENT
@@ -61,46 +77,63 @@
- -
-
TOTAL
-
-
-
- {{ formatAmount(data.amount) }} LEI - -
- -
- -
+ +
+
+ +
+ TOTAL + + {{ formatAmount(data.amount) }} LEI + +
-
- {{ data.items_count }} articole -
-
-
- -
-
TVA
-
-
-
- {{ entry.code }} - ({{ entry.percent }}%) - {{ formatAmount(entry.amount) }} LEI -
-
- Total TVA: - {{ formatAmount(computedTvaTotal) }} LEI -
+ +
+ + + TOTAL (calculat) + + + {{ formatAmount(paymentSum) }} LEI + (din plati) + +
+ + +
+ + Total ({{ formatAmount(data.amount) }}) ≠ Suma plati ({{ formatAmount(paymentSum) }}) +
+ + +
+ + Total din TVA: {{ formatAmount(tvaImpliedTotal) }} LEI +
+ + +
+ {{ pm.method }} + {{ formatAmount(pm.amount) }} LEI +
+ + +
+ TVA {{ entry.code }} ({{ entry.percent }}%) + {{ formatAmount(entry.amount) }} LEI +
+ + +
+ Total TVA + {{ formatAmount(computedTvaTotal) }} LEI +
+ + +
+ {{ data.items_count }} articole
@@ -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 { diff --git a/data-entry-app/frontend/src/stores/receiptsStore.js b/data-entry-app/frontend/src/stores/receiptsStore.js index 21b71e8..979171b 100644 --- a/data-entry-app/frontend/src/stores/receiptsStore.js +++ b/data-entry-app/frontend/src/stores/receiptsStore.js @@ -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, } diff --git a/data-entry-app/frontend/src/views/receipts/ReceiptCreateView.vue b/data-entry-app/frontend/src/views/receipts/ReceiptCreateView.vue index 566936d..a2ad93b 100644 --- a/data-entry-app/frontend/src/views/receipts/ReceiptCreateView.vue +++ b/data-entry-app/frontend/src/views/receipts/ReceiptCreateView.vue @@ -23,63 +23,6 @@
- -
-
-
-
@@ -206,16 +149,36 @@ :key="att.id" class="image-preview-item" > + + +
+ + {{ att.filename }} +
{{ att.filename }}
+ +
- -
+ +
- +
- -
-
FURNIZOR
-
-
-
- - - - - Validat ({{ supplierSource }}) - -
-
- - - - - Negasit - -
+ +
+
+
+ + + + + Validat ({{ supplierSource }}) +
-
-
- - -
+
+ + + + + Negasit +
+ +
+ + {{ showAddressExpanded ? 'Ascunde adresa' : form.vendor_address }} +
+
+ +
- -
-
DOCUMENT
-
-
-
-
-
- - -
-
- - -
-
-
-
- - -
-
- - +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
-
-
-
-
-
- - -
-
- - -
-
-
-
-
-
- - -
-
TOTAL
-
-
-
- - -
-
- - -
-
-
-
- - -
-
-
-
- -