""" Auto-create Receipt from OCR results for bulk upload flow. This service handles automatic creation of Receipt records from OCR extraction results, enabling end-to-end processing without manual UI intervention. The service: 1. Maps OCR ExtractionData fields to Receipt fields 2. Creates attachment from the original uploaded file 3. Generates accounting entries 4. Links the receipt back to the batch job for tracking """ import logging import shutil import uuid from dataclasses import dataclass from datetime import date, datetime from decimal import Decimal from pathlib import Path from typing import Optional, List from sqlalchemy import select, update from sqlalchemy.ext.asyncio import AsyncSession from backend.modules.data_entry.db.models.receipt import ( Receipt, ReceiptAttachment, ReceiptStatus, ReceiptType, ReceiptDirection, ) from backend.modules.data_entry.db.models.batch import BatchJob from backend.modules.data_entry.db.crud.receipt import ReceiptCRUD from backend.modules.data_entry.db.crud.accounting_entry import AccountingEntryCRUD from backend.modules.data_entry.schemas.receipt import ReceiptCreate, TvaEntrySchema, PaymentMethodSchema from backend.modules.data_entry.schemas.ocr import ExtractionData from backend.modules.data_entry.services.receipt_service import ReceiptService from backend.modules.data_entry.services import sse_service from backend.config import settings logger = logging.getLogger(__name__) @dataclass class ReceiptCreateResult: """Result of auto-create operation.""" success: bool receipt_id: Optional[int] = None error_message: Optional[str] = None class ReceiptAutoCreateService: """ Service for automatically creating receipts from OCR results. Used by the bulk upload flow to create receipts without user intervention. Created receipts are in DRAFT status and require review before approval. """ @staticmethod def _validate_ocr_result(ocr_result: ExtractionData) -> tuple[bool, str]: """ Perform minimal validation on OCR result. Validates: - amount > 0 (required for receipt) - date is valid and not in future Args: ocr_result: Extracted data from OCR Returns: Tuple of (is_valid, error_message) """ # Validate amount exists and is positive if ocr_result.amount is None: return False, "Amount not extracted from receipt" if ocr_result.amount <= 0: return False, f"Invalid amount: {ocr_result.amount} (must be > 0)" # Validate date exists and is not in the future if ocr_result.receipt_date is None: return False, "Receipt date not extracted" today = date.today() if ocr_result.receipt_date > today: return False, f"Receipt date {ocr_result.receipt_date} is in the future" return True, "" @staticmethod def _map_ocr_to_receipt( ocr_result: ExtractionData, company_id: int, ) -> ReceiptCreate: """ Map OCR ExtractionData fields to ReceiptCreate schema. Args: ocr_result: Extracted data from OCR company_id: Company ID for the receipt Returns: ReceiptCreate schema ready for database insertion """ # Map receipt type receipt_type = ReceiptType.BON_FISCAL if ocr_result.receipt_type == "chitanta": receipt_type = ReceiptType.CHITANTA # Map TVA breakdown from OCR TvaEntry to schema TvaEntrySchema tva_breakdown: Optional[List[TvaEntrySchema]] = None if ocr_result.tva_entries: tva_breakdown = [ TvaEntrySchema( code=entry.code, percent=entry.percent, amount=entry.amount ) for entry in ocr_result.tva_entries ] # Map payment methods payment_methods: Optional[List[PaymentMethodSchema]] = None if ocr_result.payment_methods: payment_methods = [ PaymentMethodSchema( method=pm.method, amount=pm.amount ) for pm in ocr_result.payment_methods ] # Create receipt data return ReceiptCreate( receipt_type=receipt_type, direction=ReceiptDirection.CHELTUIALA, # Default to expense receipt_number=ocr_result.receipt_number, receipt_series=ocr_result.receipt_series, receipt_date=ocr_result.receipt_date, amount=ocr_result.amount, description=ocr_result.description, tva_breakdown=tva_breakdown, tva_total=ocr_result.tva_total, items_count=ocr_result.items_count, vendor_address=ocr_result.address, company_id=company_id, partner_name=ocr_result.partner_name, cui=ocr_result.cui, ocr_raw_text=ocr_result.raw_text[:5000] if ocr_result.raw_text else None, # Limit size payment_methods=payment_methods, payment_mode=ocr_result.suggested_payment_mode, ) @staticmethod async def _create_attachment_from_file( session: AsyncSession, receipt_id: int, source_file_path: str, original_filename: Optional[str] = None, ) -> Optional[ReceiptAttachment]: """ Create attachment by copying file from OCR job location. Args: session: Database session receipt_id: Receipt ID to attach to source_file_path: Path to the original file from OCR job original_filename: Original filename from upload (optional) Returns: Created ReceiptAttachment or None if failed """ source_path = Path(source_file_path) if not source_path.exists(): logger.warning(f"[ReceiptAutoCreate] Source file not found: {source_path}") return None # Generate stored filename ext = source_path.suffix.lower() stored_filename = f"{uuid.uuid4()}{ext}" # Determine relative path (organized by year/month) now = datetime.utcnow() relative_path = Path(str(now.year)) / f"{now.month:02d}" # Full destination path dest_dir = settings.data_entry_upload_path_resolved / relative_path dest_dir.mkdir(parents=True, exist_ok=True) dest_path = dest_dir / stored_filename # Copy file to attachments directory try: shutil.copy2(source_path, dest_path) except Exception as e: logger.error(f"[ReceiptAutoCreate] Failed to copy file: {e}") return None # Get file size file_size = dest_path.stat().st_size # Determine MIME type mime_map = { '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.pdf': 'application/pdf', } mime_type = mime_map.get(ext, 'application/octet-stream') # Use original filename if provided, otherwise use source filename display_filename = original_filename or source_path.name # Create attachment record attachment = ReceiptAttachment( receipt_id=receipt_id, filename=display_filename, stored_filename=stored_filename, file_path=str(relative_path / stored_filename), file_size=file_size, mime_type=mime_type, ) session.add(attachment) await session.flush() return attachment @staticmethod async def _update_batch_job_receipt_id( session: AsyncSession, job_id: str, receipt_id: int, ) -> None: """ Update batch_jobs table with the created receipt_id. Args: session: Database session job_id: OCR job UUID receipt_id: Created receipt ID """ await session.execute( update(BatchJob) .where(BatchJob.job_id == job_id) .values(receipt_id=receipt_id) ) @staticmethod async def create_from_ocr_result( session: AsyncSession, job_id: str, ocr_result: ExtractionData, username: str, batch_id: int, company_id: int, file_path: Optional[str] = None, original_filename: Optional[str] = None, file_hash: Optional[str] = None, ) -> ReceiptCreateResult: """ Create a receipt from OCR extraction result. This method: 1. Validates the OCR result (amount > 0, date valid) 2. Maps OCR fields to Receipt fields 3. Creates the Receipt in DRAFT status 4. Creates attachment from original file 5. Generates accounting entries 6. Updates batch_jobs with receipt_id Args: session: Database session job_id: OCR job UUID for tracking ocr_result: Extracted data from OCR processing username: User who initiated the upload batch_id: Batch ID for grouping company_id: Company ID for the receipt file_path: Path to the original uploaded file original_filename: Original filename from upload file_hash: SHA-256 hash of the file for duplicate detection (US-007) Returns: ReceiptCreateResult with success status and receipt_id or error """ try: # Step 1: Validate OCR result is_valid, error_msg = ReceiptAutoCreateService._validate_ocr_result(ocr_result) if not is_valid: logger.warning(f"[ReceiptAutoCreate] Validation failed for job {job_id}: {error_msg}") return ReceiptCreateResult( success=False, error_message=error_msg ) # Step 2: Map OCR to Receipt schema receipt_data = ReceiptAutoCreateService._map_ocr_to_receipt( ocr_result=ocr_result, company_id=company_id, ) # Step 3: Create receipt in DRAFT status receipt = await ReceiptCRUD.create(session, receipt_data, created_by=username) # Set batch tracking fields (US-007, US-011) receipt.batch_id = str(batch_id) receipt.file_hash = file_hash receipt.processing_status = "completed" session.add(receipt) await session.flush() logger.info( f"[ReceiptAutoCreate] Created receipt {receipt.id} for job {job_id}: " f"amount={receipt.amount}, vendor={receipt.partner_name}, file_hash={file_hash[:16] if file_hash else None}..." ) # Step 4: Create attachment from original file (if path provided) if file_path: attachment = await ReceiptAutoCreateService._create_attachment_from_file( session=session, receipt_id=receipt.id, source_file_path=file_path, original_filename=original_filename, ) if attachment: logger.info(f"[ReceiptAutoCreate] Created attachment for receipt {receipt.id}") else: logger.warning(f"[ReceiptAutoCreate] Failed to create attachment for receipt {receipt.id}") # Step 5: Generate accounting entries # Note: For DRAFT status, entries are generated but not required for validation try: entries = ReceiptService.generate_accounting_entries(receipt) if entries: await AccountingEntryCRUD.create_bulk( session, receipt.id, entries, is_auto_generated=True ) logger.info( f"[ReceiptAutoCreate] Generated {len(entries)} accounting entries " f"for receipt {receipt.id}" ) except Exception as e: # Don't fail the receipt creation if entry generation fails logger.warning( f"[ReceiptAutoCreate] Failed to generate entries for receipt {receipt.id}: {e}" ) # Step 6: Update batch_jobs with receipt_id await ReceiptAutoCreateService._update_batch_job_receipt_id( session=session, job_id=job_id, receipt_id=receipt.id, ) # Commit all changes await session.commit() # Broadcast SSE event for real-time updates (US-030) try: await sse_service.broadcast_status_change( receipt_id=receipt.id, status=receipt.status.value, processing_status=receipt.processing_status, batch_id=receipt.batch_id, ) except Exception as e: # Don't fail the receipt creation if SSE broadcast fails logger.warning(f"[ReceiptAutoCreate] SSE broadcast failed for receipt {receipt.id}: {e}") return ReceiptCreateResult( success=True, receipt_id=receipt.id ) except Exception as e: logger.error(f"[ReceiptAutoCreate] Failed to create receipt for job {job_id}: {e}") await session.rollback() return ReceiptCreateResult( success=False, error_message=str(e) )