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:
2025-12-16 13:43:15 +02:00
parent 46d9be0c08
commit c1220e86a6
15 changed files with 734 additions and 94 deletions

View File

@@ -37,6 +37,30 @@ def _serialize_tva_breakdown(tva_breakdown: Optional[List[Any]]) -> Optional[str
return json.dumps(serializable) return json.dumps(serializable)
def _serialize_payment_methods(payment_methods: Optional[List[Any]]) -> Optional[str]:
"""Serialize payment methods list to JSON string for SQLite storage."""
if payment_methods is None:
return None
serializable = []
for pm in payment_methods:
if hasattr(pm, 'model_dump'):
item = pm.model_dump()
elif isinstance(pm, dict):
item = pm.copy()
else:
item = dict(pm)
# Convert Decimal to float for JSON
if 'amount' in item:
if hasattr(item['amount'], '__float__'):
item['amount'] = float(item['amount'])
serializable.append(item)
return json.dumps(serializable)
class ReceiptCRUD: class ReceiptCRUD:
"""CRUD operations for Receipt model.""" """CRUD operations for Receipt model."""
@@ -47,9 +71,10 @@ class ReceiptCRUD:
created_by: str, created_by: str,
) -> Receipt: ) -> Receipt:
"""Create a new receipt.""" """Create a new receipt."""
# Get data as dict and serialize tva_breakdown to JSON string # Get data as dict and serialize tva_breakdown and payment_methods to JSON string
receipt_data = data.model_dump() receipt_data = data.model_dump()
receipt_data['tva_breakdown'] = _serialize_tva_breakdown(receipt_data.get('tva_breakdown')) receipt_data['tva_breakdown'] = _serialize_tva_breakdown(receipt_data.get('tva_breakdown'))
receipt_data['payment_methods'] = _serialize_payment_methods(receipt_data.get('payment_methods'))
receipt = Receipt( receipt = Receipt(
**receipt_data, **receipt_data,
@@ -165,9 +190,11 @@ class ReceiptCRUD:
"""Update receipt fields.""" """Update receipt fields."""
update_data = data.model_dump(exclude_unset=True) update_data = data.model_dump(exclude_unset=True)
# Serialize tva_breakdown to JSON string if present # Serialize tva_breakdown and payment_methods to JSON string if present
if 'tva_breakdown' in update_data: if 'tva_breakdown' in update_data:
update_data['tva_breakdown'] = _serialize_tva_breakdown(update_data['tva_breakdown']) update_data['tva_breakdown'] = _serialize_tva_breakdown(update_data['tva_breakdown'])
if 'payment_methods' in update_data:
update_data['payment_methods'] = _serialize_payment_methods(update_data['payment_methods'])
for field, value in update_data.items(): for field, value in update_data.items():
setattr(receipt, field, value) setattr(receipt, field, value)

View File

@@ -29,6 +29,13 @@ class ReceiptStatus(str, Enum):
SYNCED = "synced" # Synced to Oracle (Phase 2) SYNCED = "synced" # Synced to Oracle (Phase 2)
class PaymentMode(str, Enum):
"""Payment mode - how the expense was paid."""
CASA = "casa" # Numerar firma (5311)
BANCA = "banca" # Virament/POS (5121)
AVANS_DECONTARE = "avans_decontare" # Decont angajat (542)
if TYPE_CHECKING: if TYPE_CHECKING:
from .accounting_entry import AccountingEntry from .accounting_entry import AccountingEntry
@@ -62,11 +69,15 @@ class Receipt(SQLModel, table=True):
# Oracle references (nomenclatures) # Oracle references (nomenclatures)
company_id: int company_id: int
partner_id: Optional[int] = Field(default=None) # partner_id removed - supplier data is text-only (partner_name, cui)
partner_name: Optional[str] = Field(default=None, max_length=200) # Cache for display partner_name: Optional[str] = Field(default=None, max_length=200) # Supplier name from OCR/selection
cui: Optional[str] = Field(default=None, max_length=20) # Fiscal code from OCR
ocr_raw_text: Optional[str] = Field(default=None) # Raw OCR text for debugging
payment_methods: Optional[str] = Field(default=None, max_length=500) # JSON: [{"method":"CARD","amount":"50.00"}]
cash_register_id: Optional[int] = Field(default=None) # Cash/Bank ID from Oracle cash_register_id: Optional[int] = Field(default=None) # Cash/Bank ID from Oracle
cash_register_name: Optional[str] = Field(default=None, max_length=100) # Cache for display cash_register_name: Optional[str] = Field(default=None, max_length=100) # Cache for display
cash_register_account: Optional[str] = Field(default=None, max_length=20) # Account code (5311, 5121) cash_register_account: Optional[str] = Field(default=None, max_length=20) # Account code (5311, 5121)
payment_mode: Optional[str] = Field(default=None, max_length=20) # PaymentMode value: casa/banca/avans_decontare
# Workflow # Workflow
status: ReceiptStatus = Field(default=ReceiptStatus.DRAFT) status: ReceiptStatus = Field(default=ReceiptStatus.DRAFT)

View File

@@ -11,7 +11,7 @@ from app.db.database import get_session
from app.db.crud.attachment import AttachmentCRUD from app.db.crud.attachment import AttachmentCRUD
from app.services.ocr_service import ocr_service from app.services.ocr_service import ocr_service
from app.services.ocr_engine import OCREngine from app.services.ocr_engine import OCREngine
from app.schemas.ocr import OCRResponse, OCRStatusResponse, ExtractionData, TvaEntry from app.schemas.ocr import OCRResponse, OCRStatusResponse, ExtractionData, TvaEntry, PaymentMethod
# Auth integration (will be protected by middleware) # Auth integration (will be protected by middleware)
from auth.dependencies import get_current_user from auth.dependencies import get_current_user
@@ -88,6 +88,21 @@ async def extract_from_image(file: UploadFile = File(...)):
for e in result.tva_entries for e in result.tva_entries
] if result.tva_entries else [] ] if result.tva_entries else []
# Convert payment_methods from dict to PaymentMethod objects
from decimal import Decimal
payment_methods_list = [
PaymentMethod(method=pm['method'], amount=Decimal(str(pm['amount'])))
for pm in result.payment_methods
] if result.payment_methods else []
# Auto-suggest payment_mode based on detected methods
suggested_payment_mode = None
if payment_methods_list:
has_card = any(pm.method == 'CARD' for pm in payment_methods_list)
if has_card:
suggested_payment_mode = 'banca'
# NUMERAR -> no auto-suggestion, user chooses between casa/avans
data = ExtractionData( data = ExtractionData(
receipt_type=result.receipt_type, receipt_type=result.receipt_type,
receipt_number=result.receipt_number, receipt_number=result.receipt_number,
@@ -101,6 +116,8 @@ async def extract_from_image(file: UploadFile = File(...)):
tva_total=result.tva_total, tva_total=result.tva_total,
address=result.address, address=result.address,
items_count=result.items_count, items_count=result.items_count,
payment_methods=payment_methods_list,
suggested_payment_mode=suggested_payment_mode,
confidence_amount=result.confidence_amount, confidence_amount=result.confidence_amount,
confidence_date=result.confidence_date, confidence_date=result.confidence_date,
confidence_vendor=result.confidence_vendor, confidence_vendor=result.confidence_vendor,
@@ -159,6 +176,21 @@ async def extract_from_attachment(
for e in result.tva_entries for e in result.tva_entries
] if result.tva_entries else [] ] if result.tva_entries else []
# Convert payment_methods from dict to PaymentMethod objects
from decimal import Decimal
payment_methods_list = [
PaymentMethod(method=pm['method'], amount=Decimal(str(pm['amount'])))
for pm in result.payment_methods
] if result.payment_methods else []
# Auto-suggest payment_mode based on detected methods
suggested_payment_mode = None
if payment_methods_list:
has_card = any(pm.method == 'CARD' for pm in payment_methods_list)
if has_card:
suggested_payment_mode = 'banca'
# NUMERAR -> no auto-suggestion, user chooses between casa/avans
data = ExtractionData( data = ExtractionData(
receipt_type=result.receipt_type, receipt_type=result.receipt_type,
receipt_number=result.receipt_number, receipt_number=result.receipt_number,
@@ -172,6 +204,8 @@ async def extract_from_attachment(
tva_total=result.tva_total, tva_total=result.tva_total,
address=result.address, address=result.address,
items_count=result.items_count, items_count=result.items_count,
payment_methods=payment_methods_list,
suggested_payment_mode=suggested_payment_mode,
confidence_amount=result.confidence_amount, confidence_amount=result.confidence_amount,
confidence_date=result.confidence_date, confidence_date=result.confidence_date,
confidence_vendor=result.confidence_vendor, confidence_vendor=result.confidence_vendor,

View File

@@ -14,6 +14,12 @@ class TvaEntry(BaseModel):
amount: Decimal = Field(description="TVA amount for this rate") amount: Decimal = Field(description="TVA amount for this rate")
class PaymentMethod(BaseModel):
"""Payment method entry from OCR."""
method: str = Field(description="CARD or NUMERAR")
amount: Decimal = Field(description="Amount paid")
class ExtractionData(BaseModel): class ExtractionData(BaseModel):
"""Extracted receipt data from OCR.""" """Extracted receipt data from OCR."""
@@ -32,6 +38,10 @@ class ExtractionData(BaseModel):
address: Optional[str] = Field(default=None, description="Vendor address") address: Optional[str] = Field(default=None, description="Vendor address")
items_count: Optional[int] = Field(default=None, description="Number of items/articles") items_count: Optional[int] = Field(default=None, description="Number of items/articles")
# Payment methods extracted from receipt
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)")
confidence_amount: float = Field(default=0.0, ge=0, le=1, description="Amount extraction confidence") 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_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_vendor: float = Field(default=0.0, ge=0, le=1, description="Vendor extraction confidence")

View File

@@ -74,6 +74,12 @@ class TvaEntrySchema(BaseModel):
amount: Decimal = Field(description="TVA amount for this rate") amount: Decimal = Field(description="TVA amount for this rate")
class PaymentMethodSchema(BaseModel):
"""Payment method entry (CARD/NUMERAR)."""
method: str = Field(description="Payment method: CARD or NUMERAR")
amount: Decimal = Field(description="Amount paid with this method")
# ============ Receipt Schemas ============ # ============ Receipt Schemas ============
class ReceiptBase(BaseModel): class ReceiptBase(BaseModel):
@@ -93,11 +99,15 @@ class ReceiptBase(BaseModel):
# Other fields # Other fields
expense_type_code: Optional[str] = Field(default=None, max_length=20) expense_type_code: Optional[str] = Field(default=None, max_length=20)
company_id: int company_id: int
partner_id: Optional[int] = None # partner_id removed - supplier data is text-only (partner_name, cui)
partner_name: Optional[str] = Field(default=None, max_length=200) partner_name: Optional[str] = Field(default=None, max_length=200)
cui: Optional[str] = Field(default=None, max_length=20, description="Fiscal code (CUI) from OCR")
ocr_raw_text: Optional[str] = Field(default=None, description="Raw OCR text for debugging")
payment_methods: Optional[List[PaymentMethodSchema]] = Field(default=None, description="Payment methods from OCR")
cash_register_id: Optional[int] = None cash_register_id: Optional[int] = None
cash_register_name: Optional[str] = Field(default=None, max_length=100) cash_register_name: Optional[str] = Field(default=None, max_length=100)
cash_register_account: Optional[str] = Field(default=None, max_length=20) cash_register_account: Optional[str] = Field(default=None, max_length=20)
payment_mode: Optional[str] = Field(default=None, description="Payment mode: casa/banca/avans_decontare")
class ReceiptCreate(ReceiptBase): class ReceiptCreate(ReceiptBase):
@@ -121,11 +131,15 @@ class ReceiptUpdate(BaseModel):
vendor_address: Optional[str] = Field(default=None, max_length=500, description="Vendor address") vendor_address: Optional[str] = Field(default=None, max_length=500, description="Vendor address")
# Other fields # Other fields
expense_type_code: Optional[str] = Field(default=None, max_length=20) expense_type_code: Optional[str] = Field(default=None, max_length=20)
partner_id: Optional[int] = None # partner_id removed - supplier data is text-only (partner_name, cui)
partner_name: Optional[str] = Field(default=None, max_length=200) partner_name: Optional[str] = Field(default=None, max_length=200)
cui: Optional[str] = Field(default=None, max_length=20, description="Fiscal code (CUI) from OCR")
ocr_raw_text: Optional[str] = Field(default=None, description="Raw OCR text for debugging")
payment_methods: Optional[List[PaymentMethodSchema]] = Field(default=None, description="Payment methods from OCR")
cash_register_id: Optional[int] = None cash_register_id: Optional[int] = None
cash_register_name: Optional[str] = Field(default=None, max_length=100) cash_register_name: Optional[str] = Field(default=None, max_length=100)
cash_register_account: Optional[str] = Field(default=None, max_length=20) cash_register_account: Optional[str] = Field(default=None, max_length=20)
payment_mode: Optional[str] = Field(default=None, description="Payment mode: casa/banca/avans_decontare")
class ReceiptResponse(ReceiptBase): class ReceiptResponse(ReceiptBase):
@@ -164,6 +178,21 @@ class ReceiptResponse(ReceiptBase):
return v return v
return None return None
@field_validator('payment_methods', mode='before')
@classmethod
def parse_payment_methods(cls, v: Any) -> Optional[List[dict]]:
"""Deserialize payment_methods from JSON string if needed."""
if v is None:
return None
if isinstance(v, str):
try:
return json.loads(v)
except (json.JSONDecodeError, TypeError):
return None
if isinstance(v, list):
return v
return None
class ReceiptListResponse(BaseModel): class ReceiptListResponse(BaseModel):
"""Schema for paginated receipt list response.""" """Schema for paginated receipt list response."""
@@ -208,10 +237,11 @@ class EntriesUpdateRequest(BaseModel):
# ============ Nomenclature Schemas ============ # ============ Nomenclature Schemas ============
class PartnerOption(BaseModel): class PartnerOption(BaseModel):
"""Schema for partner dropdown option.""" """Schema for partner dropdown option (used for autocomplete assistance)."""
id: int
name: str name: str
code: Optional[str] = None fiscal_code: Optional[str] = None
address: Optional[str] = None
source: str = "oracle" # 'oracle' (synced) or 'local'
class AccountOption(BaseModel): class AccountOption(BaseModel):

View File

@@ -46,7 +46,7 @@ class NomenclatureService:
(SyncedSupplier.name.ilike(f"%{search}%")) | (SyncedSupplier.name.ilike(f"%{search}%")) |
(SyncedSupplier.fiscal_code.ilike(f"%{search}%")) (SyncedSupplier.fiscal_code.ilike(f"%{search}%"))
) )
stmt = stmt.limit(50) # Limit results stmt = stmt.order_by(SyncedSupplier.name) # Order alphabetically, no limit for AutoComplete
result = await session.execute(stmt) result = await session.execute(stmt)
suppliers = result.scalars().all() suppliers = result.scalars().all()
@@ -59,34 +59,44 @@ class NomenclatureService:
(LocalSupplier.name.ilike(f"%{search}%")) | (LocalSupplier.name.ilike(f"%{search}%")) |
(LocalSupplier.fiscal_code.ilike(f"%{search}%")) (LocalSupplier.fiscal_code.ilike(f"%{search}%"))
) )
local_stmt = local_stmt.limit(50) local_stmt = local_stmt.order_by(LocalSupplier.name) # Order alphabetically
local_result = await session.execute(local_stmt) local_result = await session.execute(local_stmt)
local_suppliers = local_result.scalars().all() local_suppliers = local_result.scalars().all()
# Combine both # Combine both - no IDs needed, just text data for autocomplete
partners = [] partners = []
for s in suppliers: for s in suppliers:
partners.append(PartnerOption(id=s.id, name=s.name, code=s.fiscal_code)) partners.append(PartnerOption(
name=s.name,
fiscal_code=s.fiscal_code,
address=s.address,
source="oracle"
))
for l in local_suppliers: for l in local_suppliers:
partners.append(PartnerOption(id=l.id, name=f"{l.name} (local)", code=l.fiscal_code)) partners.append(PartnerOption(
name=l.name, # No suffix - must match search results
fiscal_code=l.fiscal_code,
address=l.address,
source="local"
))
return partners return partners
# Fallback to mock data for Phase 1 # Fallback to mock data for Phase 1 (when no synced data)
mock_partners = [ mock_partners = [
PartnerOption(id=1, name="OMV Petrom", code="RO123456"), PartnerOption(name="OMV Petrom", fiscal_code="RO123456", source="mock"),
PartnerOption(id=2, name="Dedeman", code="RO789012"), PartnerOption(name="Dedeman", fiscal_code="RO789012", source="mock"),
PartnerOption(id=3, name="Kaufland", code="RO345678"), PartnerOption(name="Kaufland", fiscal_code="RO345678", source="mock"),
PartnerOption(id=4, name="Emag", code="RO901234"), PartnerOption(name="Emag", fiscal_code="RO901234", source="mock"),
PartnerOption(id=5, name="Altex", code="RO567890"), PartnerOption(name="Altex", fiscal_code="RO567890", source="mock"),
] ]
if search: if search:
search_lower = search.lower() search_lower = search.lower()
mock_partners = [ mock_partners = [
p for p in mock_partners p for p in mock_partners
if search_lower in p.name.lower() or (p.code and search_lower in p.code.lower()) if search_lower in p.name.lower() or (p.fiscal_code and search_lower in p.fiscal_code.lower())
] ]
return mock_partners return mock_partners

View File

@@ -2,6 +2,8 @@
import os import os
import logging import logging
import threading
import time
from dataclasses import dataclass from dataclasses import dataclass
from typing import List, Optional, Tuple from typing import List, Optional, Tuple
@@ -53,23 +55,26 @@ class OCREngine:
def __init__(self): def __init__(self):
self._paddle = None self._paddle = None
self._paddle_initialized = False self._paddle_init_started = False
self._paddle_ready = threading.Event() # Signals when PaddleOCR is FULLY ready
self._paddle_init_lock = threading.Lock()
def _init_paddle_lazy(self): def _init_paddle_lazy(self):
"""Lazy initialize PaddleOCR on first use (avoids slow startup).""" """Lazy initialize PaddleOCR on first use (avoids slow startup)."""
global PaddleOCR global PaddleOCR
if self._paddle_initialized: with self._paddle_init_lock:
return if self._paddle_init_started:
return # Already initializing or done
self._paddle_init_started = True
self._paddle_initialized = True
if PADDLE_AVAILABLE: if PADDLE_AVAILABLE:
try: try:
print("Importing PaddleOCR (first use, may take ~15-20 seconds)...") print("Importing PaddleOCR (first use, may take ~15-20 seconds)...", flush=True)
from paddleocr import PaddleOCR as _PaddleOCR from paddleocr import PaddleOCR as _PaddleOCR
PaddleOCR = _PaddleOCR PaddleOCR = _PaddleOCR
print("Initializing PaddleOCR engine...") print("Initializing PaddleOCR engine...", flush=True)
# PaddleOCR 3.x API - optimized for Romanian receipts # PaddleOCR 3.x API - optimized for Romanian receipts
# Note: 'latin' not available in PaddleOCR 3.x, 'en' works well for receipts # Note: 'latin' not available in PaddleOCR 3.x, 'en' works well for receipts
self._paddle = PaddleOCR( self._paddle = PaddleOCR(
@@ -81,11 +86,51 @@ class OCREngine:
rec_batch_num=6, # Batch size for recognition rec_batch_num=6, # Batch size for recognition
use_angle_cls=True, # Enable text angle classification use_angle_cls=True, # Enable text angle classification
) )
print("PaddleOCR initialized successfully with high-quality settings") print("PaddleOCR initialized successfully with high-quality settings", flush=True)
except Exception as e: except Exception as e:
print(f"Warning: Failed to initialize PaddleOCR: {e}") print(f"Warning: Failed to initialize PaddleOCR: {e}", flush=True)
self._paddle = None self._paddle = None
# Signal that initialization is complete (success or failure)
self._paddle_ready.set()
def wait_for_paddle(self, timeout: float = 30.0) -> bool:
"""
Wait for PaddleOCR to be fully initialized.
Args:
timeout: Max seconds to wait (default 30s)
Returns:
True if PaddleOCR is ready, False if timeout or unavailable
"""
if not PADDLE_AVAILABLE:
return False
if self._paddle is not None:
return True # Already ready
if not self._paddle_init_started:
# Start initialization if not already started
self._init_paddle_lazy()
# Wait for initialization to complete
print(f"[OCR] Waiting for PaddleOCR to be ready (max {timeout}s)...", flush=True)
start = time.time()
ready = self._paddle_ready.wait(timeout=timeout)
elapsed = time.time() - start
if ready and self._paddle is not None:
print(f"[OCR] PaddleOCR ready after {elapsed:.1f}s", flush=True)
return True
else:
print(f"[OCR] PaddleOCR not ready after {elapsed:.1f}s (timeout or failed)", flush=True)
return False
def is_paddle_ready(self) -> bool:
"""Check if PaddleOCR is ready without waiting."""
return self._paddle is not None
def recognize(self, image: np.ndarray) -> OCRResult: def recognize(self, image: np.ndarray) -> OCRResult:
"""Perform OCR on preprocessed image.""" """Perform OCR on preprocessed image."""
logger.info(f"[OCR] Starting recognition, image shape: {image.shape}, dtype: {image.dtype}") logger.info(f"[OCR] Starting recognition, image shape: {image.shape}, dtype: {image.dtype}")
@@ -107,6 +152,13 @@ class OCREngine:
def _paddle_recognize(self, image: np.ndarray) -> OCRResult: def _paddle_recognize(self, image: np.ndarray) -> OCRResult:
"""Recognize text using PaddleOCR 3.x API.""" """Recognize text using PaddleOCR 3.x API."""
# Wait for PaddleOCR to be fully ready (handles background init)
if not self.wait_for_paddle(timeout=30.0):
logger.warning("[PaddleOCR] Not ready, falling back to Tesseract")
if TESSERACT_AVAILABLE:
return self._tesseract_recognize(image)
raise RuntimeError("PaddleOCR not ready and Tesseract not available")
try: try:
logger.info(f"[PaddleOCR] Processing image, shape: {image.shape}") logger.info(f"[PaddleOCR] Processing image, shape: {image.shape}")

View File

@@ -170,14 +170,17 @@ class OCRService:
print(f"[OCR] PaddleOCR heavy failed: {e}", flush=True) print(f"[OCR] PaddleOCR heavy failed: {e}", flush=True)
# ══════════════════════════════════════════════════════════════ # ══════════════════════════════════════════════════════════════
# STEP 3: Tesseract fallback # STEP 3: Tesseract - ONLY to complete missing fields
# Uses Tesseract-optimized preprocessing (binarized, high contrast)
# ══════════════════════════════════════════════════════════════ # ══════════════════════════════════════════════════════════════
print("=" * 60, flush=True) print("=" * 60, flush=True)
print("[OCR] STEP 3: Tesseract fallback", flush=True) print("[OCR] STEP 3: Tesseract (complement only, not override)", flush=True)
print("=" * 60, flush=True) print("=" * 60, flush=True)
try: try:
tesseract_result = self.ocr_engine._tesseract_recognize(light_img) # Use Tesseract-specific preprocessing (Otsu binarization)
tesseract_img = self.preprocessor.preprocess_for_tesseract(image)
tesseract_result = self.ocr_engine._tesseract_recognize(tesseract_img)
if tesseract_result and tesseract_result.text: if tesseract_result and tesseract_result.text:
extraction_tess = self.extractor.extract(tesseract_result.text) extraction_tess = self.extractor.extract(tesseract_result.text)
extraction_tess.ocr_engine = "tesseract" extraction_tess.ocr_engine = "tesseract"
@@ -189,10 +192,17 @@ class OCRService:
print(f" - Date: {extraction_tess.receipt_date}", flush=True) print(f" - Date: {extraction_tess.receipt_date}", flush=True)
print(f" - CUI: {extraction_tess.cui}", flush=True) print(f" - CUI: {extraction_tess.cui}", flush=True)
extraction = self._merge_extractions(extraction, extraction_tess) # IMPORTANT: Tesseract only COMPLETES missing fields, never overrides!
extraction = self._complement_extraction(extraction, extraction_tess)
except Exception as e: except Exception as e:
print(f"[OCR] Tesseract failed: {e}", flush=True) print(f"[OCR] Tesseract failed: {e}", flush=True)
# ══════════════════════════════════════════════════════════════
# FINAL VALIDATION: Fix impossible values
# ══════════════════════════════════════════════════════════════
if extraction:
extraction = self._final_validation(extraction)
# Final result # Final result
if extraction is None: if extraction is None:
return False, "No text detected", None return False, "No text detected", None
@@ -438,6 +448,122 @@ class OCRService:
print(f"[OCR] ✓ All 5 fields found with {ext.overall_confidence:.0%} confidence", flush=True) print(f"[OCR] ✓ All 5 fields found with {ext.overall_confidence:.0%} confidence", flush=True)
return True return True
def _complement_extraction(
self,
primary: Optional[ExtractionResult],
secondary: Optional[ExtractionResult]
) -> ExtractionResult:
"""
Complement primary extraction with missing fields from secondary.
NEVER overrides existing values - only fills in gaps.
This is different from _merge_extractions which can override values.
"""
if primary is None and secondary is None:
return ExtractionResult()
if primary is None:
return secondary
if secondary is None:
return primary
print("[Complement] Adding missing fields from Tesseract...", flush=True)
# Only fill missing amount
if not primary.amount and secondary.amount:
primary.amount = secondary.amount
primary.confidence_amount = secondary.confidence_amount
print(f"[Complement] Added amount: {secondary.amount}", flush=True)
# Only fill missing date
if not primary.receipt_date and secondary.receipt_date:
primary.receipt_date = secondary.receipt_date
primary.confidence_date = secondary.confidence_date
print(f"[Complement] Added date: {secondary.receipt_date}", flush=True)
# Only fill missing vendor
if not primary.partner_name and secondary.partner_name:
primary.partner_name = secondary.partner_name
primary.confidence_vendor = secondary.confidence_vendor
print(f"[Complement] Added vendor: {secondary.partner_name}", flush=True)
# Only fill missing CUI
if not primary.cui and secondary.cui and self._is_valid_cui(secondary.cui):
primary.cui = secondary.cui
print(f"[Complement] Added CUI: {secondary.cui}", flush=True)
# Only fill missing TVA
if not primary.tva_entries and secondary.tva_entries:
primary.tva_entries = secondary.tva_entries
primary.tva_total = secondary.tva_total
print(f"[Complement] Added TVA: {secondary.tva_total}", flush=True)
# Only fill missing receipt number
if not primary.receipt_number and secondary.receipt_number:
primary.receipt_number = secondary.receipt_number
print(f"[Complement] Added number: {secondary.receipt_number}", flush=True)
# Only fill missing address
if not primary.address and secondary.address:
primary.address = secondary.address
print(f"[Complement] Added address: {secondary.address}", flush=True)
return primary
def _final_validation(self, extraction: ExtractionResult) -> ExtractionResult:
"""
Final validation and correction of impossible values.
Key rules:
1. TVA cannot be greater than TOTAL (it's always a fraction)
2. If TVA > TOTAL, recalculate TOTAL from TVA using known rates
3. Validate TVA entries sum equals TVA total
"""
print("[Final Validation] Checking extracted values...", flush=True)
# Rule 1: TVA cannot be greater than TOTAL
if extraction.tva_total and extraction.amount:
if extraction.tva_total > extraction.amount:
print(f"[Final Validation] TVA ({extraction.tva_total}) > TOTAL ({extraction.amount}) - IMPOSSIBLE!", flush=True)
# Calculate TOTAL from TVA using reverse formula:
# total = base + tva = tva * (100/rate + 1) = tva * (100 + rate) / rate
# For 9% TVA: total = tva * 109 / 9 = tva * 12.11
# For 19% TVA: total = tva * 119 / 19 = tva * 6.26
# For 21% TVA: total = tva * 121 / 21 = tva * 5.76
rate = 19 # Default rate assumption
if extraction.tva_entries:
# Use the rate from the first entry
rate = extraction.tva_entries[0].get('percent', 19)
if rate > 0:
# Formula: total = tva * (100 + rate) / rate
calculated_total = extraction.tva_total * (Decimal('100') + Decimal(str(rate))) / Decimal(str(rate))
calculated_total = calculated_total.quantize(Decimal('0.01'))
print(f"[Final Validation] Calculated TOTAL from TVA: {calculated_total} (using {rate}% rate)", flush=True)
extraction.amount = calculated_total
extraction.confidence_amount = 0.70 # Lower confidence for calculated value
# Rule 2: TVA cannot be more than ~25% of total (max Romanian rate is 21%)
if extraction.tva_total and extraction.amount:
tva_percent = extraction.tva_total / extraction.amount * Decimal('100')
if tva_percent > Decimal('25'):
print(f"[Final Validation] Warning: TVA is {tva_percent:.1f}% of total - suspicious", flush=True)
# Rule 3: Validate TVA entries sum
if extraction.tva_entries and extraction.tva_total:
entries_sum = sum(e.get('amount', Decimal('0')) for e in extraction.tva_entries)
tolerance = Decimal('0.05')
if abs(entries_sum - extraction.tva_total) > tolerance:
print(f"[Final Validation] TVA entries sum ({entries_sum}) != tva_total ({extraction.tva_total})", flush=True)
# Use the sum as it's more reliable
extraction.tva_total = entries_sum
print(f"[Final Validation] Done. Amount={extraction.amount}, TVA={extraction.tva_total}", flush=True)
return extraction
# Singleton instance # Singleton instance
ocr_service = OCRService() ocr_service = OCRService()

View File

@@ -20,6 +20,14 @@ from app.schemas.receipt import (
from app.services.expense_types import EXPENSE_TYPES, get_expense_type from app.services.expense_types import EXPENSE_TYPES, get_expense_type
# Payment mode to accounting account mapping
PAYMENT_MODE_ACCOUNTS = {
'casa': ('5311', 'Casa in lei'),
'banca': ('5121', 'Conturi la banci in lei'),
'avans_decontare': ('542', 'Avansuri de trezorerie'),
}
class ReceiptService: class ReceiptService:
"""Service for receipt business logic and workflow.""" """Service for receipt business logic and workflow."""
@@ -151,21 +159,36 @@ class ReceiptService:
partner_id=receipt.partner_id, partner_id=receipt.partner_id,
)) ))
# Credit: Cash/Bank # Credit entry - based on payment_mode (new) or cash_register (legacy)
cash_account = receipt.cash_register_account or "5311" if receipt.payment_mode and receipt.payment_mode in PAYMENT_MODE_ACCOUNTS:
cash_name = receipt.cash_register_name or "Casa in lei" credit_account, credit_name = PAYMENT_MODE_ACCOUNTS[receipt.payment_mode]
elif receipt.cash_register_account:
# Backwards compatibility for existing receipts
credit_account = receipt.cash_register_account
credit_name = receipt.cash_register_name or "Casa/Banca"
else:
# Default fallback
credit_account = "5311"
credit_name = "Casa in lei"
entries.append(AccountingEntryCreate( entries.append(AccountingEntryCreate(
entry_type=EntryType.CREDIT, entry_type=EntryType.CREDIT,
account_code=cash_account, account_code=credit_account,
account_name=cash_name, account_name=credit_name,
amount=amount, amount=amount,
)) ))
else: else:
# Income: Debit cash/bank, Credit income account # Income: Debit cash/bank, Credit income account
# For now, simple income posting # Based on payment_mode (new) or cash_register (legacy)
cash_account = receipt.cash_register_account or "5311" if receipt.payment_mode and receipt.payment_mode in PAYMENT_MODE_ACCOUNTS:
cash_name = receipt.cash_register_name or "Casa in lei" cash_account, cash_name = PAYMENT_MODE_ACCOUNTS[receipt.payment_mode]
elif receipt.cash_register_account:
cash_account = receipt.cash_register_account
cash_name = receipt.cash_register_name or "Casa/Banca"
else:
cash_account = "5311"
cash_name = "Casa in lei"
# Debit: Cash/Bank # Debit: Cash/Bank
entries.append(AccountingEntryCreate( entries.append(AccountingEntryCreate(
@@ -211,8 +234,9 @@ class ReceiptService:
if not receipt.expense_type_code: if not receipt.expense_type_code:
return False, "Expense type is required", None return False, "Expense type is required", None
if not receipt.cash_register_account: # Validate payment_mode or cash_register (backwards compatibility)
return False, "Cash register is required", None if not receipt.payment_mode and not receipt.cash_register_account:
return False, "Modul de plata este obligatoriu", None
# Generate accounting entries # Generate accounting entries
entries = ReceiptService.generate_accounting_entries(receipt) entries = ReceiptService.generate_accounting_entries(receipt)
@@ -239,6 +263,7 @@ class ReceiptService:
) -> Tuple[bool, str, Optional[Receipt]]: ) -> Tuple[bool, str, Optional[Receipt]]:
""" """
Approve receipt (PENDING_REVIEW → APPROVED). Approve receipt (PENDING_REVIEW → APPROVED).
Requires valid CUI (fiscal code) for approval.
""" """
receipt = await ReceiptCRUD.get_by_id(session, receipt_id) receipt = await ReceiptCRUD.get_by_id(session, receipt_id)
@@ -248,6 +273,10 @@ class ReceiptService:
if receipt.status != ReceiptStatus.PENDING_REVIEW: if receipt.status != ReceiptStatus.PENDING_REVIEW:
return False, "Receipt is not pending review", None return False, "Receipt is not pending review", None
# Validate CUI is present (required for Oracle import)
if not receipt.cui:
return False, "Trebuie completat codul fiscal (CUI) pentru aprobare", None
# Validate accounting entries # Validate accounting entries
if not receipt.entries: if not receipt.entries:
return False, "Receipt has no accounting entries", None return False, "Receipt has no accounting entries", None

View File

@@ -267,9 +267,8 @@ class SyncService:
supplier = result.scalar_one_or_none() supplier = result.scalar_one_or_none()
if supplier: if supplier:
# Return only text data - no IDs needed for autocomplete
return True, { return True, {
"id": supplier.id,
"oracle_id": supplier.oracle_id,
"name": supplier.name, "name": supplier.name,
"fiscal_code": supplier.fiscal_code, "fiscal_code": supplier.fiscal_code,
"address": supplier.address, "address": supplier.address,
@@ -291,12 +290,11 @@ class SyncService:
local = result.scalar_one_or_none() local = result.scalar_one_or_none()
if local: if local:
# Return only text data - no IDs needed for autocomplete
return True, { return True, {
"id": local.id,
"name": local.name, "name": local.name,
"fiscal_code": local.fiscal_code, "fiscal_code": local.fiscal_code,
"address": local.address, "address": local.address,
"is_local": True,
}, "local" }, "local"
# 3. Try live Oracle search (optional fallback for unsynced data) # 3. Try live Oracle search (optional fallback for unsynced data)

View File

@@ -0,0 +1,35 @@
"""add_ocr_fields_to_receipt
Revision ID: 4b8e5f2a1d93
Revises: 3a653da79002
Create Date: 2025-12-15 10:00:00.000000+00:00
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import sqlmodel
# revision identifiers, used by Alembic.
revision: str = '4b8e5f2a1d93'
down_revision: Union[str, None] = '3a653da79002'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Add OCR-related columns to receipts table
with op.batch_alter_table('receipts', schema=None) as batch_op:
batch_op.add_column(sa.Column('cui', sqlmodel.sql.sqltypes.AutoString(length=20), nullable=True))
batch_op.add_column(sa.Column('ocr_raw_text', sa.Text(), nullable=True))
batch_op.add_column(sa.Column('payment_methods', sqlmodel.sql.sqltypes.AutoString(length=500), nullable=True))
def downgrade() -> None:
# Remove OCR-related columns from receipts table
with op.batch_alter_table('receipts', schema=None) as batch_op:
batch_op.drop_column('payment_methods')
batch_op.drop_column('ocr_raw_text')
batch_op.drop_column('cui')

View File

@@ -0,0 +1,29 @@
"""Remove partner_id from receipts - supplier data is text-only
Revision ID: 20251215_remove_partner_id
Revises: 20251216_payment_mode
Create Date: 2025-12-15
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '20251215_remove_partner_id'
down_revision: Union[str, None] = '20251216_payment_mode'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Remove partner_id column - supplier data is now text-only (partner_name, cui)."""
# Drop the partner_id column
op.drop_column('receipts', 'partner_id')
def downgrade() -> None:
"""Re-add partner_id column."""
op.add_column('receipts', sa.Column('partner_id', sa.Integer(), nullable=True))

View File

@@ -0,0 +1,44 @@
"""Add payment_mode field to receipts table.
Revision ID: 20251216_payment_mode
Revises: 4b8e5f2a1d93
Create Date: 2024-12-16
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '20251216_payment_mode'
down_revision = '4b8e5f2a1d93'
branch_labels = None
depends_on = None
def upgrade() -> None:
"""Add payment_mode column and migrate existing data."""
with op.batch_alter_table('receipts', schema=None) as batch_op:
batch_op.add_column(sa.Column('payment_mode', sa.String(length=20), nullable=True))
# Migrate existing data based on cash_register_account
op.execute("""
UPDATE receipts
SET payment_mode = 'casa'
WHERE cash_register_account LIKE '531%' AND payment_mode IS NULL
""")
op.execute("""
UPDATE receipts
SET payment_mode = 'banca'
WHERE cash_register_account LIKE '512%' AND payment_mode IS NULL
""")
op.execute("""
UPDATE receipts
SET payment_mode = 'avans_decontare'
WHERE cash_register_account LIKE '542%' AND payment_mode IS NULL
""")
def downgrade() -> None:
"""Remove payment_mode column."""
with op.batch_alter_table('receipts', schema=None) as batch_op:
batch_op.drop_column('payment_mode')

View File

@@ -87,6 +87,24 @@
</div> </div>
</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 --> <!-- Items Count -->
<div class="preview-field" v-if="data.items_count"> <div class="preview-field" v-if="data.items_count">
<label>Nr. Articole</label> <label>Nr. Articole</label>
@@ -152,6 +170,7 @@
<script setup> <script setup>
import { ref } from 'vue' import { ref } from 'vue'
import OCRConfidenceIndicator from './OCRConfidenceIndicator.vue' import OCRConfidenceIndicator from './OCRConfidenceIndicator.vue'
import Tag from 'primevue/tag'
const props = defineProps({ const props = defineProps({
data: { data: {
@@ -164,6 +183,15 @@ defineEmits(['apply', 'dismiss'])
const showRawText = ref(false) 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 formatAmount = (amount) => {
const num = parseFloat(amount) const num = parseFloat(amount)
return num.toLocaleString('ro-RO', { return num.toLocaleString('ro-RO', {
@@ -346,6 +374,25 @@ const formatProcessingTime = (ms) => {
border-top: 1px dashed #cbd5e1; 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 { .address-text {
font-size: 0.9rem; font-size: 0.9rem;
color: #475569; color: #475569;

View File

@@ -15,11 +15,11 @@
</div> </div>
<form @submit.prevent="saveReceipt"> <form @submit.prevent="saveReceipt">
<!-- OCR Upload Section (only for new receipts) --> <!-- OCR Upload Section (for both create and edit modes) -->
<div class="upload-section" v-if="!isEditMode"> <div class="upload-section">
<h3> <h3>
<i class="pi pi-camera"></i> <i class="pi pi-camera"></i>
Poza Bon (obligatoriu) {{ isEditMode ? 'Re-scanare OCR (optional)' : 'Poza Bon (obligatoriu)' }}
</h3> </h3>
<!-- OCR Upload Zone --> <!-- OCR Upload Zone -->
@@ -199,16 +199,30 @@
<div class="form-field"> <div class="form-field">
<label>Furnizor</label> <label>Furnizor</label>
<Dropdown <AutoComplete
v-model="form.partner_id" v-model="form.partner_name"
:options="partners" :suggestions="filteredPartners"
optionLabel="name" optionLabel="name"
optionValue="id" field="name"
placeholder="Selecteaza furnizor" @complete="searchPartners"
filter @item-select="onPartnerSelect"
showClear placeholder="Cauta furnizor..."
@change="onPartnerChange" 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>
<div class="form-field"> <div class="form-field">
@@ -224,16 +238,18 @@
</div> </div>
<div class="form-field"> <div class="form-field">
<label>Casa / Banca *</label> <label>Mod Plata</label>
<Dropdown <Dropdown
v-model="form.cash_register_id" v-model="form.payment_mode"
:options="cashRegisters" :options="paymentModeOptions"
optionLabel="name" optionLabel="label"
optionValue="id" optionValue="value"
placeholder="Selecteaza casa/banca" placeholder="Selecteaza mod plata"
@change="onCashRegisterChange" showClear
required
/> />
<small class="field-hint text-secondary" v-if="!form.payment_mode">
Obligatoriu la trimiterea pentru aprobare
</small>
</div> </div>
<div class="form-field"> <div class="form-field">
@@ -252,7 +268,7 @@
</div> </div>
<!-- Detalii Suplimentare (populated from OCR) --> <!-- 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> <h3>
<i class="pi pi-list"></i> <i class="pi pi-list"></i>
Detalii Suplimentare (din OCR) Detalii Suplimentare (din OCR)
@@ -280,6 +296,19 @@
</div> </div>
</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-grid">
<div class="form-field" v-if="form.items_count"> <div class="form-field" v-if="form.items_count">
<label>Nr. Articole</label> <label>Nr. Articole</label>
@@ -376,6 +405,8 @@ import { useCompanyStore } from '../../stores/companies'
import OCRUploadZone from '../../components/ocr/OCRUploadZone.vue' import OCRUploadZone from '../../components/ocr/OCRUploadZone.vue'
import OCRPreview from '../../components/ocr/OCRPreview.vue' import OCRPreview from '../../components/ocr/OCRPreview.vue'
import Dialog from 'primevue/dialog' import Dialog from 'primevue/dialog'
import Tag from 'primevue/tag'
import AutoComplete from 'primevue/autocomplete'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@@ -397,10 +428,13 @@ const form = ref({
direction: 'cheltuiala', direction: 'cheltuiala',
receipt_date: new Date(), receipt_date: new Date(),
amount: null, amount: null,
partner_id: null, // partner_id removed - supplier data is text-only
partner_name: null, partner_name: null,
cui: '', // Fiscal code from OCR
ocr_raw_text: '', // Raw OCR text for debugging
expense_type_code: null, 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_name: null,
cash_register_account: null, cash_register_account: null,
receipt_number: '', receipt_number: '',
@@ -411,6 +445,7 @@ const form = ref({
tva_total: null, tva_total: null,
items_count: null, items_count: null,
vendor_address: '', vendor_address: '',
payment_methods: [], // Array of {method, amount}
}) })
const selectedFiles = ref([]) const selectedFiles = ref([])
@@ -426,11 +461,32 @@ const ocrFile = ref(null)
// Supplier dialog refs // Supplier dialog refs
const showCreateSupplierDialog = ref(false) const showCreateSupplierDialog = ref(false)
const pendingSupplierData = ref(null) 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 partners = computed(() => store.partners)
const expenseTypes = computed(() => store.expenseTypes) const expenseTypes = computed(() => store.expenseTypes)
const cashRegisters = computed(() => store.cashRegisters) 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 () => { onMounted(async () => {
await store.fetchAllNomenclatures() await store.fetchAllNomenclatures()
@@ -452,17 +508,28 @@ const loadReceipt = async () => {
direction: receipt.value.direction, direction: receipt.value.direction,
receipt_date: new Date(receipt.value.receipt_date), receipt_date: new Date(receipt.value.receipt_date),
amount: parseFloat(receipt.value.amount), 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, 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, 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_name: receipt.value.cash_register_name,
cash_register_account: receipt.value.cash_register_account, cash_register_account: receipt.value.cash_register_account,
receipt_number: receipt.value.receipt_number || '', receipt_number: receipt.value.receipt_number || '',
description: receipt.value.description || '', description: receipt.value.description || '',
company_id: receipt.value.company_id, 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 || [] existingAttachments.value = receipt.value.attachments || []
} catch (error) { } catch (error) {
toast.add({ toast.add({
@@ -518,6 +585,16 @@ const applyOCRData = async (data) => {
form.value.receipt_number = data.receipt_number 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 // Apply TVA entries
if (data.tva_entries?.length > 0) { if (data.tva_entries?.length > 0) {
form.value.tva_breakdown = data.tva_entries.map(e => ({ 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.items_count) form.value.items_count = data.items_count
if (data.address) form.value.vendor_address = data.address 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 // Auto-search supplier by CUI if available
if (data.cui) { if (data.cui) {
toast.add({ toast.add({
@@ -542,9 +632,27 @@ const applyOCRData = async (data) => {
const result = await store.searchSupplier(data.cui) const result = await store.searchSupplier(data.cui)
if (result.found && result.supplier) { if (result.found && result.supplier) {
// Found! Auto-select // Build supplier object for AutoComplete
form.value.partner_id = result.supplier.id 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.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({ toast.add({
severity: 'success', severity: 'success',
@@ -553,23 +661,36 @@ const applyOCRData = async (data) => {
life: 3000, life: 3000,
}) })
} else { } else {
// Not found - offer to create // Not found - show non-blocking warning, allow continuing
pendingSupplierData.value = { supplierWarning.value = {
name: data.partner_name || '', show: true,
fiscal_code: data.cui, cui: data.cui,
address: data.address || '', 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) { } 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 => const matchingPartner = partners.value.find(p =>
p.name.toLowerCase().includes(data.partner_name.toLowerCase()) p.name.toLowerCase().includes(data.partner_name.toLowerCase())
) )
if (matchingPartner) { 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.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 { } else {
// Just set the name from OCR (no matching partner found)
form.value.partner_name = data.partner_name form.value.partner_name = data.partner_name
} }
} }
@@ -623,9 +744,14 @@ const cancelCreateSupplier = () => {
pendingSupplierData.value = null pendingSupplierData.value = null
} }
const onPartnerChange = (event) => { const onPartnerSelect = (event) => {
const partner = partners.value.find(p => p.id === event.value) const partner = event.value
form.value.partner_name = partner?.name || null 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) => { const onCashRegisterChange = (event) => {
@@ -672,6 +798,11 @@ const formatFileSize = (bytes) => {
return (bytes / (1024 * 1024)).toFixed(1) + ' MB' 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 = () => { const formatTvaTotal = () => {
if (!form.value.tva_breakdown?.length) return '0.00' if (!form.value.tva_breakdown?.length) return '0.00'
const total = form.value.tva_breakdown.reduce((sum, e) => sum + (e.amount || 0), 0) const total = form.value.tva_breakdown.reduce((sum, e) => sum + (e.amount || 0), 0)
@@ -720,15 +851,8 @@ const validateForm = () => {
return false return false
} }
if (!form.value.cash_register_id) { // Payment mode is validated at submit time, not at draft save
toast.add({ // (can save draft without payment mode, but submit requires it)
severity: 'warn',
summary: 'Validare',
detail: 'Casa/Banca este obligatorie',
life: 3000,
})
return false
}
return true return true
} }
@@ -956,6 +1080,40 @@ const submitForReview = async () => {
color: #0284c7; 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 */
.dialog-content { .dialog-content {
display: flex; display: flex;