"""Parser service for extracting incrementables data from PDF text.""" import re import logging from typing import Dict, Optional, List, Tuple from decimal import Decimal logger = logging.getLogger(__name__) class ParsingError(Exception): """Custom exception for parsing errors.""" pass class IncrementablesParser: """Parser for extracting incrementables data from PDF text.""" # Anchor patterns to find the incrementables section ANCHOR_PATTERNS = [ r"AJUSTE\s+DE\s+INCREMENTABLES\s+EN:", r"INCREMENTABLES\s+EN:", r"AJUSTE\s+INCREMENTABLES:", ] # Field patterns FIELD_PATTERNS = { "fletes": r"Fletes[:\s]*\$?\s*([\d,]+\.?\d*)\s*(USD|MXN|EUR)?", "seguros": r"Seguros[:\s]*(?:\$?\s*([\d,]+\.?\d*)\s*)?(USD|MXN|EUR)?", "almacenaje": r"(?:Almacenaje[/\s]*(?:Consolidaci[oó]n)?)[:\s]*\$?\s*([\d,]+\.?\d*)\s*(USD|MXN|EUR)?", "regalias": r"(?:Regal[ií]as?)[:\s]*(?:\$?\s*([\d,]+\.?\d*)\s*)?(USD|MXN|EUR)?", } def __init__(self, text: str): """ Initialize parser with PDF text. Args: text: Extracted text from PDF """ self.text = text self.warnings: List[str] = [] self.anchors_found: List[str] = [] def _find_incrementables_section(self) -> Optional[str]: """ Find the incrementables section in the text. Returns: Text snippet containing incrementables data, or None if not found """ for pattern in self.ANCHOR_PATTERNS: match = re.search(pattern, self.text, re.IGNORECASE | re.MULTILINE) if match: anchor_text = match.group(0) self.anchors_found.append(anchor_text) logger.info(f"Found anchor: {anchor_text}") # Extract the next ~500 characters after the anchor start_pos = match.end() section = self.text[start_pos:start_pos + 500] return section return None def _extract_currency(self, section: str) -> str: """ Extract currency from the section. Args: section: Text section to search Returns: Currency code (USD, MXN, EUR) or "USD" as default """ currency_pattern = r"\b(USD|MXN|EUR)\b" match = re.search(currency_pattern, section) if match: return match.group(1) # Default to USD but add warning self.warnings.append("Currency not explicitly found, defaulting to USD") return "USD" def _parse_amount(self, value: Optional[str]) -> Optional[float]: """ Parse monetary amount from string. Args: value: String containing amount (e.g., "1,591.20" or "$1,591.20") Returns: Float value or None if empty/invalid """ if not value or value.strip() == "": return None try: # Remove $ and commas cleaned = value.replace("$", "").replace(",", "").strip() if not cleaned: return None # Convert to Decimal for precision, then to float for JSON amount = float(Decimal(cleaned)) return amount except Exception as e: logger.warning(f"Failed to parse amount '{value}': {str(e)}") return None def _extract_field(self, section: str, field_name: str) -> Tuple[Optional[float], Optional[str]]: """ Extract a specific field from the section. Args: section: Text section to search field_name: Name of field (fletes, seguros, almacenaje, regalias) Returns: Tuple of (amount, currency) or (None, None) """ pattern = self.FIELD_PATTERNS.get(field_name) if not pattern: return None, None match = re.search(pattern, section, re.IGNORECASE | re.MULTILINE) if not match: logger.warning(f"Field '{field_name}' not found in section") return None, None groups = match.groups() # Extract amount (first group) amount_str = groups[0] if len(groups) > 0 else None amount = self._parse_amount(amount_str) # Extract currency (second group) currency = groups[1] if len(groups) > 1 else None return amount, currency def parse(self) -> Dict: """ Parse incrementables data from text. Returns: Dictionary with parsed data including: - currency - fletes - seguros (may be None) - almacenaje_consolidacion - regalias (may be None) - warnings - anchors_found Raises: ParsingError: If incrementables section not found or parsing fails """ # Find the section section = self._find_incrementables_section() if not section: raise ParsingError( "Incrementables section not found. Expected anchor like 'AJUSTE DE INCREMENTABLES EN:'" ) logger.debug(f"Found section: {section[:200]}...") # Extract currency currency = self._extract_currency(section) # Extract fields fletes, _ = self._extract_field(section, "fletes") seguros, _ = self._extract_field(section, "seguros") almacenaje, _ = self._extract_field(section, "almacenaje") regalias, _ = self._extract_field(section, "regalias") # Validate required fields if fletes is None: raise ParsingError("Required field 'fletes' not found or invalid") if almacenaje is None: raise ParsingError("Required field 'almacenaje/consolidacion' not found or invalid") # seguros and regalias can be None (empty) return { "currency": currency, "fletes": fletes, "seguros": seguros, "almacenaje_consolidacion": almacenaje, "regalias": regalias, "warnings": self.warnings, "anchors_found": self.anchors_found, } def parse_incrementables(text: str) -> Dict: """ Parse incrementables data from PDF text. Args: text: Extracted text from PDF Returns: Dictionary with parsed incrementables data Raises: ParsingError: If parsing fails """ parser = IncrementablesParser(text) return parser.parse()