Sebastian Gonzalez commited on
Commit
0a6b0fb
·
1 Parent(s): d4f735f

Deploy OCR Service via Script

Browse files
Files changed (8) hide show
  1. .gitignore +5 -0
  2. Dockerfile +31 -0
  3. app.py +67 -0
  4. azure_ocr_processor.py +315 -0
  5. dollar_correction.py +169 -0
  6. ocr_processors.py +352 -0
  7. requirements.txt +9 -0
  8. unified_extractors.py +1478 -0
.gitignore ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.pyc
3
+ .DS_Store
4
+ .env
5
+ venv/
Dockerfile ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.9-slim
2
+
3
+ # Install system dependencies for OpenCV and Tesseract
4
+ RUN apt-get update && apt-get install -y \
5
+ tesseract-ocr \
6
+ libtesseract-dev \
7
+ libgl1-mesa-glx \
8
+ libglib2.0-0 \
9
+ && rm -rf /var/lib/apt/lists/*
10
+
11
+ # Set working directory
12
+ WORKDIR /app
13
+
14
+ # Copy requirements first to leverage cache
15
+ COPY requirements.txt .
16
+ RUN pip install --no-cache-dir -r requirements.txt
17
+
18
+ # Create a user to run the app (Hugging Face Spaces requirement for security)
19
+ RUN useradd -m -u 1000 user
20
+ USER user
21
+ ENV HOME=/home/user \
22
+ PATH=/home/user/.local/bin:$PATH
23
+
24
+ # Copy the rest of the application
25
+ COPY --chown=user . .
26
+
27
+ # Expose the port (Hugging Face Spaces expects 7860)
28
+ EXPOSE 7860
29
+
30
+ # Command to run the application
31
+ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
app.py ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import FastAPI, HTTPException, Body
2
+ from pydantic import BaseModel
3
+ import numpy as np
4
+ import cv2
5
+ import base64
6
+ from typing import Dict, List, Any
7
+ import os
8
+ import sys
9
+
10
+ # Add current directory to path to ensure imports work
11
+ sys.path.append(os.path.dirname(os.path.abspath(__file__)))
12
+
13
+ from ocr_processors import OCRManager
14
+ from unified_extractors import Vendor, VendorSchemaManager
15
+
16
+ app = FastAPI(title="OCR Service")
17
+
18
+ # Initialize managers globally
19
+ ocr_manager = OCRManager()
20
+ schema_manager = VendorSchemaManager()
21
+
22
+ class OCRRequest(BaseModel):
23
+ image: str # Base64 encoded image
24
+ vendor_id: str
25
+
26
+ @app.get("/")
27
+ def health_check():
28
+ return {"status": "ok", "service": "OCR Service"}
29
+
30
+ @app.post("/process")
31
+ def process_image(request: OCRRequest):
32
+ try:
33
+ # Decode image
34
+ image_data = base64.b64decode(request.image)
35
+ nparr = np.frombuffer(image_data, np.uint8)
36
+ image = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
37
+
38
+ if image is None:
39
+ raise HTTPException(status_code=400, detail="Invalid image data")
40
+
41
+ # Resolve vendor
42
+ try:
43
+ vendor = Vendor(request.vendor_id)
44
+ except ValueError:
45
+ # Fallback for unknown vendors if necessary, or error
46
+ # For now, let's assume valid vendor or default
47
+ vendor = Vendor.DEFAULT
48
+
49
+ # Extract text using the EXACT same logic as the original app
50
+ # The OCRManager inside this service is the original code
51
+ results = ocr_manager.extract_text_with_positions(
52
+ image,
53
+ vendor,
54
+ schema_manager
55
+ )
56
+
57
+ return {"status": "success", "text_blocks": results}
58
+
59
+ except Exception as e:
60
+ print(f"ERROR in OCR Service: {str(e)}")
61
+ import traceback
62
+ traceback.print_exc()
63
+ raise HTTPException(status_code=500, detail=str(e))
64
+
65
+ if __name__ == "__main__":
66
+ import uvicorn
67
+ uvicorn.run(app, host="0.0.0.0", port=7860)
azure_ocr_processor.py ADDED
@@ -0,0 +1,315 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # azure_ocr_processor.py
2
+ # Procesador OCR usando Azure Document Intelligence
3
+
4
+ import json
5
+ import numpy as np
6
+ from io import BytesIO
7
+ from typing import Dict, List
8
+
9
+ try:
10
+ from azure.core.credentials import AzureKeyCredential
11
+ from azure.ai.documentintelligence import DocumentIntelligenceClient
12
+ AZURE_AVAILABLE = True
13
+ except ImportError:
14
+ AZURE_AVAILABLE = False
15
+ print("ADVERTENCIA: azure-ai-documentintelligence no está disponible.")
16
+
17
+
18
+ class AzureOCRProcessor:
19
+ """Procesador usando Azure Document Intelligence"""
20
+
21
+ def __init__(self, endpoint: str = None, key: str = None):
22
+ if not AZURE_AVAILABLE:
23
+ raise RuntimeError("Azure Document Intelligence no está disponible")
24
+
25
+ # Usar credenciales desde variables de entorno o parámetros
26
+ import os
27
+
28
+ # Prioridad: parámetros > variables de entorno > valores por defecto
29
+ self.endpoint = endpoint or os.environ.get(
30
+ "AZURE_DOCUMENT_INTELLIGENCE_ENDPOINT",
31
+ "https://invoicerecog.cognitiveservices.azure.com/"
32
+ )
33
+
34
+ self.key = key or os.environ.get(
35
+ "AZURE_DOCUMENT_INTELLIGENCE_KEY",
36
+ "BnvYqZbBSscFxbxZurfTEj9H6ZP4anDzvE2gQTB8fvau0wzlAk0TJQQJ99BKACYeBjFXJ3w3AAALACOGyauB"
37
+ )
38
+
39
+ if not self.endpoint or not self.key:
40
+ raise ValueError(
41
+ "Se requieren credenciales de Azure. "
42
+ "Define las variables de entorno AZURE_DOCUMENT_INTELLIGENCE_ENDPOINT "
43
+ "y AZURE_DOCUMENT_INTELLIGENCE_KEY, o pásalas como parámetros."
44
+ )
45
+
46
+ print(f"INFO: Inicializando Azure Document Intelligence")
47
+ print(f"INFO: Endpoint: {self.endpoint}")
48
+
49
+ self.client = DocumentIntelligenceClient(
50
+ endpoint=self.endpoint,
51
+ credential=AzureKeyCredential(self.key)
52
+ )
53
+
54
+ def process(self, image: np.ndarray, ocr_config: Dict) -> List[Dict]:
55
+ """
56
+ Procesa la imagen usando Azure Document Intelligence.
57
+ Retorna text_blocks simulando el formato de otros OCR pero con datos estructurados.
58
+ """
59
+ model = ocr_config.get("model", "prebuilt-invoice")
60
+
61
+ print(f"INFO: Procesando con Azure Document Intelligence, modelo: {model}")
62
+
63
+ # === NUEVO: COMPRESIÓN DE IMAGEN PARA AZURE (PROCESO INDEPENDIENTE) ===
64
+ # Esta compresión se ejecuta antes del procesamiento normal y no afecta la funcionalidad original
65
+ image_to_process = self._compress_image_for_azure(image)
66
+ # === FIN COMPRESIÓN ===
67
+
68
+ # Convertir numpy array a bytes (formato PNG) - CÓDIGO ORIGINAL INTACTO
69
+ import cv2
70
+ success, encoded_image = cv2.imencode('.png', image_to_process)
71
+ if not success:
72
+ raise RuntimeError("No se pudo codificar la imagen")
73
+
74
+ image_bytes = encoded_image.tobytes()
75
+
76
+ print(f"INFO: Imagen codificada: {len(image_bytes)} bytes")
77
+
78
+ # Analizar con Azure - CÓDIGO ORIGINAL INTACTO
79
+ try:
80
+ print("INFO: Enviando imagen a Azure Document Intelligence...")
81
+
82
+ poller = self.client.begin_analyze_document(
83
+ model,
84
+ body=BytesIO(image_bytes),
85
+ content_type="image/png"
86
+ )
87
+
88
+ print("INFO: Esperando respuesta de Azure...")
89
+ result = poller.result()
90
+
91
+ print(f"INFO: Análisis completado. Documentos encontrados: {len(result.documents) if result.documents else 0}")
92
+
93
+ # Convertir resultado de Azure a formato de texto estructurado
94
+ formatted_text = self._format_azure_result_as_text(result)
95
+
96
+ # Retornar como un único text_block con flag especial
97
+ return [{
98
+ 'text': formatted_text,
99
+ 'x': 0,
100
+ 'y': 0,
101
+ 'width': 0,
102
+ 'height': 0,
103
+ 'confidence': 95.0,
104
+ 'engine': 'azure',
105
+ 'is_azure_structured': True
106
+ }]
107
+
108
+ except Exception as e:
109
+ print(f"ERROR en Azure Document Intelligence: {e}")
110
+ import traceback
111
+ traceback.print_exc()
112
+ raise
113
+
114
+ def _compress_image_for_azure(self, image: np.ndarray) -> np.ndarray:
115
+ """
116
+ COMPRESIÓN INDEPENDIENTE: Comprime la imagen para Azure sin afectar el procesamiento original.
117
+ Esta función es completamente independiente y no modifica la lógica existente.
118
+ """
119
+ import cv2
120
+
121
+ # Obtener información de la imagen original
122
+ height, width = image.shape[:2]
123
+ original_size_mb = image.nbytes / (1024 * 1024)
124
+ print(f"INFO: Compresión Azure - Imagen original: {width}x{height}, {original_size_mb:.2f}MB")
125
+
126
+ # Si la imagen ya es pequeña, no comprimir
127
+ if original_size_mb <= 4.5:
128
+ print("INFO: Compresión Azure - Imagen ya está dentro del límite, no se requiere compresión")
129
+ return image
130
+
131
+ print("INFO: Compresión Azure - Aplicando compresión...")
132
+
133
+ # Redimensionar si es muy grande (manteniendo relación de aspecto)
134
+ max_dimension = 2000
135
+ if width > max_dimension or height > max_dimension:
136
+ if width > height:
137
+ new_width = max_dimension
138
+ new_height = int((max_dimension / width) * height)
139
+ else:
140
+ new_height = max_dimension
141
+ new_width = int((max_dimension / height) * width)
142
+
143
+ print(f"INFO: Compresión Azure - Redimensionando a {new_width}x{new_height}")
144
+ compressed_image = cv2.resize(image, (new_width, new_height), interpolation=cv2.INTER_AREA)
145
+ compressed_size_mb = compressed_image.nbytes / (1024 * 1024)
146
+ print(f"INFO: Compresión Azure - Después de redimensionar: {compressed_size_mb:.2f}MB")
147
+
148
+ # Verificar si después de redimensionar ya está dentro del límite
149
+ if compressed_size_mb <= 4.5:
150
+ return compressed_image
151
+ else:
152
+ compressed_image = image
153
+
154
+ # Si aún es grande después de redimensionar, aplicar compresión JPEG temporal
155
+ temp_quality = 85
156
+ while temp_quality >= 50:
157
+ # Codificar temporalmente como JPEG para ver el tamaño
158
+ success, jpeg_encoded = cv2.imencode('.jpg', compressed_image, [cv2.IMWRITE_JPEG_QUALITY, temp_quality])
159
+ if success:
160
+ jpeg_size_mb = len(jpeg_encoded.tobytes()) / (1024 * 1024)
161
+ print(f"INFO: Compresión Azure - Calidad {temp_quality}: {jpeg_size_mb:.2f}MB")
162
+
163
+ if jpeg_size_mb <= 4.5:
164
+ print(f"INFO: Compresión Azure - Calidad {temp_quality} aceptada")
165
+ # Decodificar de vuelta a numpy array para mantener compatibilidad
166
+ decoded_image = cv2.imdecode(jpeg_encoded, cv2.IMREAD_COLOR)
167
+ if decoded_image is not None:
168
+ final_size_mb = decoded_image.nbytes / (1024 * 1024)
169
+ print(f"INFO: Compresión Azure - Imagen final: {final_size_mb:.2f}MB")
170
+ return decoded_image
171
+
172
+ temp_quality -= 10
173
+
174
+ # Si llegamos aquí, usar la imagen redimensionada sin compresión JPEG
175
+ print("INFO: Compresión Azure - Usando imagen redimensionada sin compresión JPEG adicional")
176
+ return compressed_image
177
+
178
+ def _format_azure_result_as_text(self, result) -> str:
179
+ """
180
+ Convierte el resultado de Azure a un texto formateado limpio (sin líneas de confianza).
181
+ """
182
+ output_lines = []
183
+
184
+ if not result.documents:
185
+ return "ERROR: No se encontraron documentos en la factura"
186
+
187
+ # Procesar el primer documento
188
+ document = result.documents[0]
189
+ fields = document.fields
190
+
191
+ output_lines.append("-------- Análisis de Azure Document Intelligence --------")
192
+ output_lines.append("")
193
+
194
+ # Información del proveedor
195
+ vendor_name = fields.get("VendorName")
196
+ if vendor_name:
197
+ output_lines.append(f"Proveedor: {vendor_name.content}")
198
+
199
+ vendor_address = fields.get("VendorAddress")
200
+ if vendor_address:
201
+ output_lines.append(f"Dirección: {vendor_address.content}")
202
+
203
+ vendor_tax = fields.get("VendorTaxId")
204
+ if vendor_tax:
205
+ output_lines.append(f"GST/HST: {vendor_tax.content}")
206
+
207
+ output_lines.append("")
208
+
209
+ # Información de la factura
210
+ invoice_id = fields.get("InvoiceId")
211
+ if invoice_id:
212
+ output_lines.append(f"Invoice ID: {invoice_id.content}")
213
+
214
+ invoice_date = fields.get("InvoiceDate")
215
+ if invoice_date:
216
+ output_lines.append(f"Fecha: {invoice_date.content}")
217
+
218
+ customer_name = fields.get("CustomerName")
219
+ if customer_name:
220
+ output_lines.append(f"Cliente: {customer_name.content}")
221
+
222
+ output_lines.append("")
223
+ output_lines.append("=" * 60)
224
+ output_lines.append("ÍTEMS DE LA FACTURA")
225
+ output_lines.append("=" * 60)
226
+ output_lines.append("")
227
+
228
+ # Extraer items
229
+ items_field = fields.get("Items")
230
+ total_items = 0
231
+
232
+ if items_field and hasattr(items_field, "value_array"):
233
+ total_items = len(items_field.value_array)
234
+ print(f"INFO: Procesando {total_items} items...")
235
+
236
+ for item_idx, item in enumerate(items_field.value_array):
237
+ item_obj = item.value_object if hasattr(item, "value_object") else {}
238
+
239
+ output_lines.append(f"--- Ítem #{item_idx + 1} ---")
240
+
241
+ # Código de producto
242
+ product_code = item_obj.get("ProductCode")
243
+ if product_code and product_code.content:
244
+ output_lines.append(f"Código: {product_code.content}")
245
+
246
+ # Descripción
247
+ description = item_obj.get("Description")
248
+ if description and description.content:
249
+ output_lines.append(f"Descripción: {description.content}")
250
+
251
+ # Cantidad
252
+ quantity = item_obj.get("Quantity")
253
+ if quantity and quantity.content:
254
+ output_lines.append(f"Cantidad: {quantity.content}")
255
+
256
+ # Precio unitario
257
+ unit_price = item_obj.get("UnitPrice")
258
+ if unit_price and unit_price.content:
259
+ output_lines.append(f"Precio unitario: {unit_price.content}")
260
+
261
+ # Impuesto por ítem - SOLO si es > 0
262
+ tax = item_obj.get("Tax")
263
+ if tax and tax.content:
264
+ try:
265
+ # Extraer el valor numérico del tax
266
+ tax_value_str = tax.content.replace('$', '').replace(',', '').strip()
267
+ tax_value = float(tax_value_str)
268
+
269
+ # Solo incluir si es mayor a 0
270
+ if tax_value > 0:
271
+ output_lines.append(f"Impuesto (H): {tax.content}")
272
+ except (ValueError, AttributeError):
273
+ pass
274
+
275
+ # Total por ítem
276
+ amount = item_obj.get("Amount")
277
+ if amount and amount.content:
278
+ output_lines.append(f"Total por ítem: {amount.content}")
279
+
280
+ output_lines.append("")
281
+ else:
282
+ output_lines.append("No se encontraron items en la factura")
283
+
284
+ # Totales
285
+ output_lines.append("=" * 60)
286
+ output_lines.append("TOTALES")
287
+ output_lines.append("=" * 60)
288
+ output_lines.append("")
289
+
290
+ subtotal = fields.get("SubTotal")
291
+ if subtotal and subtotal.content:
292
+ output_lines.append(f"Subtotal: {subtotal.content}")
293
+
294
+ total_tax = fields.get("TotalTax")
295
+ if total_tax and total_tax.content:
296
+ output_lines.append(f"Total impuestos: {total_tax.content}")
297
+
298
+ invoice_total = fields.get("InvoiceTotal")
299
+ if invoice_total and invoice_total.content:
300
+ output_lines.append(f"Total de la factura: {invoice_total.content}")
301
+
302
+ output_lines.append("")
303
+ output_lines.append("=" * 60)
304
+ output_lines.append(f"Total de items extraídos: {total_items}")
305
+ output_lines.append("=" * 60)
306
+
307
+ formatted_text = "\n".join(output_lines)
308
+
309
+ print(f"\n{'='*60}")
310
+ print("TEXTO FORMATEADO GENERADO:")
311
+ print(f"{'='*60}")
312
+ print(formatted_text[:800] + "..." if len(formatted_text) > 800 else formatted_text)
313
+ print(f"{'='*60}\n")
314
+
315
+ return formatted_text
dollar_correction.py ADDED
@@ -0,0 +1,169 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # dollar_correction.py
2
+ # Proceso independiente para corrección de confusión $ vs 8
3
+
4
+ import re
5
+ from typing import Dict, List
6
+
7
+
8
+ class DollarSignCorrectionProcessor:
9
+ """
10
+ Proceso independiente para corregir confusiones del OCR entre $ y 8.
11
+ Similar al proceso multilinea, puede ser aplicado a cualquier proveedor.
12
+ """
13
+
14
+ def __init__(self, config: Dict = None):
15
+ """
16
+ Args:
17
+ config: Configuración del procesador
18
+ - aggressive: bool - Si True, aplica correcciones más agresivas
19
+ - context_aware: bool - Si True, usa contexto para decidir correcciones
20
+ - min_confidence: float - Confianza mínima para aplicar corrección
21
+ """
22
+ self.config = config or {}
23
+ self.aggressive = self.config.get("aggressive", False)
24
+ self.context_aware = self.config.get("context_aware", True)
25
+ self.min_confidence = self.config.get("min_confidence", 0.7)
26
+
27
+ def process(self, text_blocks: List[Dict]) -> List[Dict]:
28
+ """
29
+ Procesa los bloques de texto y corrige confusiones entre $ y 8.
30
+
31
+ Args:
32
+ text_blocks: Lista de bloques de texto del OCR
33
+
34
+ Returns:
35
+ Lista de bloques de texto corregidos
36
+ """
37
+ corrected_blocks = []
38
+ corrections_made = 0
39
+
40
+ for block in text_blocks:
41
+ original_text = block['text']
42
+ corrected_text = self._correct_text(original_text, block)
43
+
44
+ if corrected_text != original_text:
45
+ corrections_made += 1
46
+ print(f"DEBUG: Corrección $ vs 8: '{original_text}' -> '{corrected_text}'")
47
+
48
+ # Crear nuevo bloque con texto corregido
49
+ corrected_block = block.copy()
50
+ corrected_block['text'] = corrected_text
51
+ corrected_block['was_corrected'] = True
52
+ corrected_block['original_text'] = original_text
53
+ corrected_blocks.append(corrected_block)
54
+ else:
55
+ corrected_blocks.append(block)
56
+
57
+ print(f"INFO: Correcciones $ vs 8 aplicadas: {corrections_made} de {len(text_blocks)} bloques")
58
+ return corrected_blocks
59
+
60
+ def _correct_text(self, text: str, block: Dict) -> str:
61
+ """
62
+ Aplica correcciones al texto basándose en patrones y contexto.
63
+
64
+ Args:
65
+ text: Texto a corregir
66
+ block: Bloque de texto con metadata (posición, confianza, etc.)
67
+
68
+ Returns:
69
+ Texto corregido
70
+ """
71
+ corrected = text
72
+
73
+ # Patrón 1: "8" seguido de números (probablemente es "$")
74
+ # Ejemplo: "8 12.99" -> "$ 12.99"
75
+ # Ejemplo: "812.99" -> "$12.99"
76
+ corrected = re.sub(
77
+ r'\b8\s*(\d+\.?\d*)\b',
78
+ lambda m: f"$ {m.group(1)}" if self._is_likely_price(m.group(1)) else m.group(0),
79
+ corrected
80
+ )
81
+
82
+ # Patrón 2: "8" al inicio de línea seguido de espacio y números
83
+ # Ejemplo: "8 Total" -> "$ Total"
84
+ if self.context_aware:
85
+ corrected = re.sub(
86
+ r'^8\s+(Total|Subtotal|HST|Tax|Amount|Price)',
87
+ r'$ \1',
88
+ corrected,
89
+ flags=re.IGNORECASE
90
+ )
91
+
92
+ # Patrón 3: "8" en contexto de moneda (después de palabras clave)
93
+ # Ejemplo: "Total 8 123.45" -> "Total $ 123.45"
94
+ corrected = re.sub(
95
+ r'(Total|Subtotal|HST|Tax|Amount|Price|Cost)\s+8\s*(\d+\.?\d*)',
96
+ r'\1 $ \2',
97
+ corrected,
98
+ flags=re.IGNORECASE
99
+ )
100
+
101
+ # Patrón 4: Múltiples "8" en secuencia (probablemente "$")
102
+ # Ejemplo: "88" -> "$$" (raro pero posible)
103
+ if self.aggressive:
104
+ corrected = re.sub(r'88', '$$', corrected)
105
+
106
+ # Patrón 5: "8" entre espacios y números decimales
107
+ # Ejemplo: "Item 8 12.99 8 24.98" -> "Item $ 12.99 $ 24.98"
108
+ corrected = re.sub(
109
+ r'\s8\s+(\d+\.\d{2})\b',
110
+ r' $ \1',
111
+ corrected
112
+ )
113
+
114
+ # Patrón 6: "8" al final de palabra seguido de números
115
+ # Ejemplo: "Price8123.45" -> "Price$123.45"
116
+ corrected = re.sub(
117
+ r'([a-zA-Z])8(\d+\.?\d*)',
118
+ lambda m: f"{m.group(1)}${m.group(2)}" if self._is_likely_price(m.group(2)) else m.group(0),
119
+ corrected
120
+ )
121
+
122
+ # Patrón 7: "8" solo seguido de espacio y dígitos con decimales
123
+ # Ejemplo: "8 1.99" -> "$ 1.99"
124
+ corrected = re.sub(
125
+ r'\b8\s+(\d+\.\d{2})\b',
126
+ r'$ \1',
127
+ corrected
128
+ )
129
+
130
+ # Patrón 8: Líneas que empiezan con "8" y tienen formato de precio
131
+ # Ejemplo: "8123.45" -> "$123.45"
132
+ corrected = re.sub(
133
+ r'^8(\d+\.\d{2})\b',
134
+ r'$\1',
135
+ corrected,
136
+ flags=re.MULTILINE
137
+ )
138
+
139
+ return corrected
140
+
141
+ def _is_likely_price(self, number_str: str) -> bool:
142
+ """
143
+ Determina si un número es probablemente un precio.
144
+
145
+ Args:
146
+ number_str: String con el número
147
+
148
+ Returns:
149
+ True si parece un precio
150
+ """
151
+ try:
152
+ value = float(number_str)
153
+
154
+ # Precios típicos: entre 0.01 y 10000
155
+ if value < 0.01 or value > 10000:
156
+ return False
157
+
158
+ # Si tiene 2 decimales, muy probable que sea precio
159
+ if '.' in number_str and len(number_str.split('.')[1]) == 2:
160
+ return True
161
+
162
+ # Si es un número redondo pequeño, menos probable
163
+ if value < 10 and '.' not in number_str:
164
+ return False
165
+
166
+ return True
167
+
168
+ except ValueError:
169
+ return False
ocr_processors.py ADDED
@@ -0,0 +1,352 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ocr_processors.py
2
+ # Procesadores OCR independientes y su gestor
3
+
4
+ import cv2
5
+ import numpy as np
6
+ import easyocr
7
+ from typing import Dict, List
8
+ from dollar_correction import DollarSignCorrectionProcessor
9
+ from unified_extractors import Vendor, VendorSchemaManager
10
+
11
+ try:
12
+ import pytesseract
13
+ from pytesseract import Output
14
+ PYTESSERACT_AVAILABLE = True
15
+ except ImportError:
16
+ PYTESSERACT_AVAILABLE = False
17
+ print("ADVERTENCIA: pytesseract no está disponible. Usando EasyOCR por defecto.")
18
+
19
+ from azure_ocr_processor import AzureOCRProcessor, AZURE_AVAILABLE
20
+
21
+ class OCRProcessor:
22
+ """Clase base para procesadores OCR"""
23
+
24
+ def __init__(self):
25
+ pass
26
+
27
+ def process(self, image: np.ndarray, ocr_config: Dict) -> List[Dict]:
28
+ """Procesa la imagen y retorna bloques de texto"""
29
+ raise NotImplementedError
30
+
31
+
32
+ class EasyOCRProcessor(OCRProcessor):
33
+ """Procesador usando EasyOCR"""
34
+
35
+ def __init__(self):
36
+ super().__init__()
37
+ self.reader = easyocr.Reader(['en', 'fr'], gpu=False)
38
+
39
+ def process(self, image: np.ndarray, ocr_config: Dict) -> List[Dict]:
40
+ """Extrae texto usando EasyOCR"""
41
+ results = self.reader.readtext(
42
+ image,
43
+ contrast_ths=0.05,
44
+ adjust_contrast=0.7,
45
+ low_text=0.3,
46
+ detail=1
47
+ )
48
+
49
+ text_blocks = []
50
+ for (bbox, text, confidence) in results:
51
+ if confidence > 0.3:
52
+ x_coords = [point[0] for point in bbox]
53
+ y_coords = [point[1] for point in bbox]
54
+ text_blocks.append({
55
+ 'text': text.strip(),
56
+ 'x': min(x_coords),
57
+ 'y': min(y_coords),
58
+ 'width': max(x_coords) - min(x_coords),
59
+ 'height': max(y_coords) - min(y_coords),
60
+ 'confidence': confidence * 100,
61
+ 'engine': 'easyocr'
62
+ })
63
+
64
+ return sorted(text_blocks, key=lambda b: (b['y'], b['x']))
65
+
66
+
67
+ class PytesseractOCRProcessor(OCRProcessor):
68
+ """Procesador usando Pytesseract con soporte para tablas"""
69
+
70
+ def __init__(self):
71
+ super().__init__()
72
+ if not PYTESSERACT_AVAILABLE:
73
+ raise RuntimeError("Pytesseract no está disponible")
74
+
75
+ def process(self, image: np.ndarray, ocr_config: Dict) -> List[Dict]:
76
+ """Extrae texto usando Pytesseract"""
77
+ mode = ocr_config.get("mode", "block")
78
+
79
+ # Preprocesar imagen
80
+ processed_image = self._preprocess_image(image, ocr_config)
81
+
82
+ if mode == "table":
83
+ text_blocks = self._extract_table_structure(processed_image, ocr_config)
84
+
85
+ # Si se requiere reconstrucción multilinea
86
+ if ocr_config.get("requires_reconstruction", False):
87
+ reconstructed_text = self._reconstruct_multiline_text(text_blocks, ocr_config)
88
+ if reconstructed_text:
89
+ text_blocks.append({
90
+ 'text': f"TEXTO_RECONSTRUIDO:\n{reconstructed_text}",
91
+ 'x': 0,
92
+ 'y': 0,
93
+ 'width': 100,
94
+ 'height': 100,
95
+ 'confidence': 100,
96
+ 'engine': 'reconstructed',
97
+ 'is_reconstructed': True
98
+ })
99
+ else:
100
+ text_blocks = self._extract_block_structure(processed_image)
101
+
102
+ return text_blocks
103
+
104
+ def _preprocess_image(self, image: np.ndarray, ocr_config: Dict) -> np.ndarray:
105
+ """Preprocesa la imagen según configuración"""
106
+ preprocessing = ocr_config.get("preprocessing", {})
107
+
108
+ # Convertir a escala de grises
109
+ if len(image.shape) == 3:
110
+ gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
111
+ else:
112
+ gray = image
113
+
114
+ # Aplicar denoising si está configurado
115
+ if preprocessing.get("denoise", False):
116
+ gray = cv2.medianBlur(gray, 3)
117
+
118
+ # Aplicar enhancement si está configurado
119
+ if preprocessing.get("enhance", False):
120
+ clahe = cv2.createCLAHE(clipLimit=3.0, tileGridSize=(8,8))
121
+ gray = clahe.apply(gray)
122
+
123
+ # Aplicar binarización si está configurado
124
+ if preprocessing.get("binarize", False):
125
+ gray = cv2.adaptiveThreshold(
126
+ gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
127
+ cv2.THRESH_BINARY, 15, 8
128
+ )
129
+
130
+ # Limpieza morfológica
131
+ kernel = np.ones((2,2), np.uint8)
132
+ gray = cv2.morphologyEx(gray, cv2.MORPH_CLOSE, kernel)
133
+ gray = cv2.morphologyEx(gray, cv2.MORPH_OPEN, kernel)
134
+
135
+ return gray
136
+
137
+ def _extract_table_structure(self, image: np.ndarray, ocr_config: Dict) -> List[Dict]:
138
+ """Extrae estructura de tabla"""
139
+ custom_config = r'--oem 3 --psm 6 -c preserve_interword_spaces=1'
140
+ table_data = pytesseract.image_to_data(image, output_type=Output.DICT, config=custom_config)
141
+
142
+ text_blocks = []
143
+ n_boxes = len(table_data['text'])
144
+
145
+ for i in range(n_boxes):
146
+ text = table_data['text'][i].strip()
147
+ confidence = int(table_data['conf'][i])
148
+
149
+ if text and confidence > 20:
150
+ text_blocks.append({
151
+ 'text': text,
152
+ 'x': table_data['left'][i],
153
+ 'y': table_data['top'][i],
154
+ 'width': table_data['width'][i],
155
+ 'height': table_data['height'][i],
156
+ 'confidence': confidence,
157
+ 'block_num': table_data['block_num'][i],
158
+ 'par_num': table_data['par_num'][i],
159
+ 'line_num': table_data['line_num'][i],
160
+ 'word_num': table_data['word_num'][i],
161
+ 'engine': 'pytesseract'
162
+ })
163
+
164
+ # Si hay muy pocos bloques, intentar con métodos alternativos
165
+ if len(text_blocks) < 10:
166
+ return self._extract_with_alternative_methods(image)
167
+
168
+ return text_blocks
169
+
170
+ def _extract_with_alternative_methods(self, image: np.ndarray) -> List[Dict]:
171
+ """Intenta extraer con múltiples configuraciones"""
172
+ configs = [
173
+ r'--oem 3 --psm 4',
174
+ r'--oem 3 --psm 6',
175
+ r'--oem 3 --psm 8',
176
+ r'--oem 3 --psm 11',
177
+ ]
178
+
179
+ all_blocks = []
180
+ for config in configs:
181
+ try:
182
+ data = pytesseract.image_to_data(image, output_type=Output.DICT, config=config)
183
+ for i in range(len(data['text'])):
184
+ text = data['text'][i].strip()
185
+ if text and int(data['conf'][i]) > 10:
186
+ all_blocks.append({
187
+ 'text': text,
188
+ 'x': data['left'][i],
189
+ 'y': data['top'][i],
190
+ 'width': data['width'][i],
191
+ 'height': data['height'][i],
192
+ 'confidence': int(data['conf'][i]),
193
+ 'engine': 'pytesseract_alt'
194
+ })
195
+ except Exception as e:
196
+ print(f"ADVERTENCIA: Falló configuración {config}: {e}")
197
+
198
+ # Eliminar duplicados
199
+ unique_blocks = []
200
+ seen_positions = set()
201
+
202
+ for block in all_blocks:
203
+ position_key = (block['x'], block['y'], block['text'])
204
+ if position_key not in seen_positions:
205
+ seen_positions.add(position_key)
206
+ unique_blocks.append(block)
207
+
208
+ return sorted(unique_blocks, key=lambda b: (b['y'], b['x']))
209
+
210
+ def _extract_block_structure(self, image: np.ndarray) -> List[Dict]:
211
+ """Extrae estructura de bloques"""
212
+ custom_config = r'--oem 3 --psm 1'
213
+ data = pytesseract.image_to_data(image, output_type=Output.DICT, config=custom_config)
214
+
215
+ text_blocks = []
216
+ n_boxes = len(data['text'])
217
+
218
+ for i in range(n_boxes):
219
+ text = data['text'][i].strip()
220
+ confidence = int(data['conf'][i])
221
+
222
+ if text and confidence > 30:
223
+ text_blocks.append({
224
+ 'text': text,
225
+ 'x': data['left'][i],
226
+ 'y': data['top'][i],
227
+ 'width': data['width'][i],
228
+ 'height': data['height'][i],
229
+ 'confidence': confidence,
230
+ 'engine': 'pytesseract'
231
+ })
232
+
233
+ return sorted(text_blocks, key=lambda b: (b['y'], b['x']))
234
+
235
+ def _reconstruct_multiline_text(self, text_blocks: List[Dict], ocr_config: Dict) -> str:
236
+ """Reconstruye texto multilinea para proveedores que lo requieren"""
237
+ # Filtrar bloques reconstruidos previos
238
+ original_blocks = [block for block in text_blocks if not block.get('is_reconstructed')]
239
+
240
+ if not original_blocks:
241
+ return ""
242
+
243
+ # Agrupar en líneas
244
+ line_threshold = ocr_config.get("line_threshold", 20)
245
+ lines = self._group_into_lines(original_blocks, line_threshold)
246
+
247
+ # Reconstruir texto
248
+ reconstructed_text = ""
249
+ for line_blocks in lines:
250
+ line_blocks.sort(key=lambda b: b['x'])
251
+ line_text = ' '.join(block['text'].strip() for block in line_blocks)
252
+ if line_text.strip():
253
+ reconstructed_text += line_text + "\n"
254
+
255
+ return reconstructed_text
256
+
257
+ def _group_into_lines(self, sorted_blocks: List[Dict], line_threshold: int = 20) -> List[List[Dict]]:
258
+ """Agrupa bloques en líneas"""
259
+ if not sorted_blocks:
260
+ return []
261
+
262
+ sorted_blocks = sorted(sorted_blocks, key=lambda b: b['y'])
263
+ lines = []
264
+ current_line = [sorted_blocks[0]]
265
+ current_y = sorted_blocks[0]['y']
266
+
267
+ for block in sorted_blocks[1:]:
268
+ y_diff = abs(block['y'] - current_y)
269
+
270
+ if y_diff <= line_threshold:
271
+ current_line.append(block)
272
+ current_y = sum(b['y'] for b in current_line) / len(current_line)
273
+ else:
274
+ current_line.sort(key=lambda b: b['x'])
275
+ lines.append(current_line)
276
+ current_line = [block]
277
+ current_y = block['y']
278
+
279
+ if current_line:
280
+ current_line.sort(key=lambda b: b['x'])
281
+ lines.append(current_line)
282
+
283
+ return lines
284
+
285
+
286
+ # Modificar la clase OCRManager:
287
+ class OCRManager:
288
+ """Gestiona los diferentes procesadores OCR según el proveedor"""
289
+
290
+ def __init__(self):
291
+ self.processors = {
292
+ 'easyocr': EasyOCRProcessor(),
293
+ 'pytesseract': PytesseractOCRProcessor() if PYTESSERACT_AVAILABLE else None,
294
+ 'azure': None # Se inicializará bajo demanda
295
+ }
296
+
297
+ def _get_azure_processor(self):
298
+ """Inicializa el procesador Azure bajo demanda"""
299
+ if self.processors['azure'] is None and AZURE_AVAILABLE:
300
+ try:
301
+ self.processors['azure'] = AzureOCRProcessor()
302
+ print("INFO: Procesador Azure Document Intelligence inicializado")
303
+ except Exception as e:
304
+ print(f"ERROR al inicializar Azure: {e}")
305
+ return None
306
+ return self.processors['azure']
307
+
308
+ def extract_text_with_positions(self, image: np.ndarray, vendor: Vendor, schema_manager: VendorSchemaManager) -> List[Dict]:
309
+ """Extrae texto usando el procesador apropiado para el proveedor"""
310
+ # Obtener configuración OCR del proveedor
311
+ ocr_config = schema_manager.get_ocr_config(vendor)
312
+ engine = ocr_config.get("engine", "easyocr")
313
+
314
+ print(f"INFO: Usando engine '{engine}' para proveedor {vendor.value}")
315
+ print(f"INFO: Configuración OCR: {ocr_config}")
316
+
317
+ # Seleccionar procesador
318
+ if engine == 'azure':
319
+ processor = self._get_azure_processor()
320
+ if processor is None:
321
+ print("ADVERTENCIA: Azure no disponible, usando EasyOCR como fallback")
322
+ processor = self.processors['easyocr']
323
+ ocr_config = {"engine": "easyocr", "mode": "block"}
324
+ else:
325
+ processor = self.processors.get(engine)
326
+ if processor is None:
327
+ print(f"ADVERTENCIA: Engine '{engine}' no disponible, usando EasyOCR")
328
+ processor = self.processors['easyocr']
329
+ ocr_config = {"engine": "easyocr", "mode": "block"}
330
+
331
+ # Procesar imagen
332
+ try:
333
+ text_blocks = processor.process(image, ocr_config)
334
+ print(f"INFO: Extraídos {len(text_blocks)} bloques de texto con {engine}")
335
+
336
+ # NO aplicar corrección $ vs 8 para Azure (ya viene procesado)
337
+ if engine != 'azure':
338
+ dollar_correction_config = ocr_config.get("dollar_sign_correction", {})
339
+ if dollar_correction_config.get("enabled", False):
340
+ print(f"INFO: Aplicando corrección $ vs 8 para {vendor.value}")
341
+ corrector = DollarSignCorrectionProcessor(dollar_correction_config)
342
+ text_blocks = corrector.process(text_blocks)
343
+
344
+ return text_blocks
345
+
346
+ except Exception as e:
347
+ print(f"ERROR en procesamiento OCR con {engine}: {e}")
348
+ # Fallback a EasyOCR
349
+ if engine != 'easyocr':
350
+ print("INFO: Intentando con EasyOCR como fallback...")
351
+ return self.processors['easyocr'].process(image, {"engine": "easyocr"})
352
+ raise
requirements.txt ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ fastapi
2
+ uvicorn
3
+ python-multipart
4
+ numpy
5
+ opencv-python-headless
6
+ easyocr
7
+ pytesseract
8
+ requests
9
+ pydantic
unified_extractors.py ADDED
@@ -0,0 +1,1478 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Sistema unificado de extracción de facturas basado en patrones regex y reglas
3
+ Incluye configuración de proveedores y esquemas
4
+ Sin dependencia de LLMs - más rápido y confiable
5
+ """
6
+
7
+ import re
8
+ import json
9
+ from typing import Dict, List, Optional, Tuple
10
+ from datetime import datetime
11
+ from dataclasses import dataclass, asdict
12
+ from enum import Enum
13
+
14
+ # ==== CONFIGURACIÓN DE PROVEEDORES ====
15
+ class Vendor(Enum):
16
+ """
17
+ Define los proveedores soportados en el sistema.
18
+ El valor de la enumeración se usa como ID en la URL y en el sistema de esquemas.
19
+ """
20
+ A1 = "A1 Cash and Carry_Fisico"
21
+ COSTCO = "Costco_Formato1"
22
+ COSTCO2 = "Costco_Formato2"
23
+ DEFAULT = "Default"
24
+
25
+
26
+ # ==== CONFIGURACIÓN OCR POR PROVEEDOR ====
27
+ # Cada proveedor puede tener su propia configuración de OCR
28
+ VENDOR_OCR_CONFIG = {
29
+ Vendor.A1: {
30
+ "engine": "easyocr",
31
+ "mode": "block",
32
+ "add_blank_lines_on_spacing": True,
33
+ "spacing_threshold": 1,
34
+ # NUEVO: Configuración para corrección $ vs 8
35
+ "dollar_sign_correction": {
36
+ "enabled": True,
37
+ "aggressive": False,
38
+ "context_aware": False,
39
+ "min_confidence": 0.05
40
+ }
41
+ },
42
+ Vendor.COSTCO: {
43
+ "engine": "pytesseract",
44
+ "mode": "table",
45
+ "columns": 7,
46
+ "multiline": True,
47
+ "requires_reconstruction": True,
48
+ "line_threshold": 20,
49
+ "preprocessing": {
50
+ "denoise": True,
51
+ "enhance": True,
52
+ "binarize": True
53
+ }
54
+ },
55
+ Vendor.COSTCO2: {
56
+ "engine": "pytesseract",
57
+ "mode": "table",
58
+ "columns": 7,
59
+ "multiline": True,
60
+ "requires_reconstruction": True,
61
+ "line_threshold": 20,
62
+ "preprocessing": {
63
+ "denoise": True,
64
+ "enhance": True,
65
+ "binarize": True
66
+ }
67
+ },
68
+ Vendor.DEFAULT: {
69
+ "engine": "azure", # Motor especial para Azure
70
+ "mode": "document_intelligence",
71
+ "model": "prebuilt-invoice" # Modelo de Azure a usar
72
+ }
73
+ }
74
+
75
+
76
+ # ==== CLASES DE DATOS ====
77
+ @dataclass
78
+ class InvoiceItem:
79
+ description: str
80
+ amount: float
81
+ quantity: float = 1.0
82
+ unit_price: float = 0.0
83
+ sku: Optional[str] = None
84
+ unit_of_measure: Optional[str] = None
85
+ discount: float = 0.0
86
+ tax_code: Optional[str] = None
87
+ category: Optional[str] = None
88
+
89
+ @dataclass
90
+ class Invoice:
91
+ vendor: str
92
+ issuer: str
93
+ date: str = ""
94
+ transaction_id: str = ""
95
+ items: List[InvoiceItem] = None
96
+ subtotal: float = 0.0
97
+ hst: Optional[float] = None
98
+ total: float = 0.0
99
+ raw_text: str = ""
100
+ confidence: float = 0.0
101
+ issuer_address: Optional[str] = None
102
+ gst_hst_number: Optional[str] = None
103
+ invoice_number: str = ""
104
+ customer_name: Optional[str] = None
105
+ # Campos adicionales para gestión
106
+ invoice_id: str = ""
107
+ status: str = "procesado"
108
+ created_at: str = ""
109
+ file_path: str = ""
110
+ job_id: str = ""
111
+
112
+ def __post_init__(self):
113
+ if self.items is None:
114
+ self.items = []
115
+
116
+
117
+ # ==== CLASE BASE PARA EXTRACTORES ====
118
+ class BasePatternExtractor:
119
+ """Clase base para extractores de patrones"""
120
+
121
+ def __init__(self, raw_text: str, text_blocks: List[Dict] = None, ocr_config: Dict = None):
122
+ self.raw_text = raw_text
123
+ self.text_blocks = text_blocks or []
124
+ self.ocr_config = ocr_config or {}
125
+
126
+ # Aplicar proceso de inserción de líneas en blanco si está habilitado
127
+ if self.ocr_config.get("add_blank_lines_on_spacing", False):
128
+ processed_text = self._add_blank_lines_on_spacing(raw_text, text_blocks)
129
+ self.raw_text = processed_text
130
+
131
+ self.lines = [line.strip() for line in self.raw_text.split('\n') if line.strip()]
132
+
133
+ def _add_blank_lines_on_spacing(self, raw_text: str, text_blocks: List[Dict]) -> str:
134
+ """
135
+ Inserta líneas en blanco cuando detecta espacios significativos entre renglones consecutivos.
136
+ Este proceso es independiente y reutilizable para cualquier proveedor.
137
+ """
138
+ if not text_blocks:
139
+ return raw_text
140
+
141
+ spacing_threshold = self.ocr_config.get("spacing_threshold", 15)
142
+
143
+ # Ordenar bloques por posición Y y X
144
+ sorted_blocks = sorted(text_blocks, key=lambda b: (b.get('page_number', 1), b['y'], b['x']))
145
+
146
+ # Construir texto con líneas en blanco insertadas
147
+ processed_lines = []
148
+ prev_block = None
149
+
150
+ for block in sorted_blocks:
151
+ current_y = block['y']
152
+ current_height = block.get('height', 0)
153
+ current_page = block.get('page_number', 1)
154
+
155
+ # Si hay un bloque anterior, calcular el espacio entre renglones
156
+ if prev_block is not None:
157
+ prev_y = prev_block['y']
158
+ prev_height = prev_block.get('height', 0)
159
+ prev_page = prev_block.get('page_number', 1)
160
+
161
+ # Si cambiamos de página, resetear
162
+ if current_page != prev_page:
163
+ processed_lines.append("") # Línea en blanco entre páginas
164
+ else:
165
+ # Calcular el espacio vertical entre el final del bloque anterior y el inicio del actual
166
+ prev_bottom = prev_y + prev_height
167
+ vertical_gap = current_y - prev_bottom
168
+
169
+ # Si el espacio supera el threshold, insertar línea en blanco
170
+ if vertical_gap > spacing_threshold:
171
+ processed_lines.append("")
172
+ print(f"DEBUG: Línea en blanco insertada (gap vertical de {vertical_gap:.1f}px entre renglones)")
173
+
174
+ # Agregar el texto del bloque actual
175
+ processed_lines.append(block['text'])
176
+ prev_block = block
177
+
178
+ return '\n'.join(processed_lines)
179
+
180
+ def extract_date(self, patterns: List[str]) -> str:
181
+ """Extrae fecha usando múltiples patrones"""
182
+ for pattern in patterns:
183
+ match = re.search(pattern, self.raw_text, re.IGNORECASE)
184
+ if match:
185
+ date_str = match.group(1).strip()
186
+ try:
187
+ for fmt in ['%m/%d/%Y', '%d/%m/%Y', '%Y-%m-%d', '%d %b %Y', '%d %B %Y']:
188
+ try:
189
+ dt = datetime.strptime(date_str, fmt)
190
+ return dt.strftime('%Y-%m-%d')
191
+ except:
192
+ continue
193
+ return date_str
194
+ except:
195
+ return date_str
196
+ return datetime.now().strftime('%Y-%m-%d')
197
+
198
+ def extract_amount(self, patterns: List[str], multiline: bool = False) -> Optional[float]:
199
+ """Extrae montos monetarios"""
200
+ text = self.raw_text if multiline else ' '.join(self.lines)
201
+ for pattern in patterns:
202
+ match = re.search(pattern, text, re.IGNORECASE | (re.MULTILINE if multiline else 0))
203
+ if match:
204
+ amount_str = match.group(1).replace('$', '').replace(',', '').strip()
205
+ try:
206
+ return float(amount_str)
207
+ except:
208
+ continue
209
+ return None
210
+
211
+ def extract_text(self, patterns: List[str]) -> Optional[str]:
212
+ """Extrae texto usando patrones"""
213
+ for pattern in patterns:
214
+ match = re.search(pattern, self.raw_text, re.IGNORECASE | re.MULTILINE)
215
+ if match:
216
+ return match.group(1).strip()
217
+ return None
218
+
219
+ def extract_invoice(self) -> Invoice:
220
+ """Método principal - debe ser implementado por cada extractor"""
221
+ raise NotImplementedError
222
+
223
+ class A1PatternExtractor(BasePatternExtractor):
224
+ """Extractor ultra-optimizado para Burlington Cash and Carry"""
225
+
226
+ def extract_invoice(self) -> Invoice:
227
+ issuer = self.extract_text([
228
+ r'(Burlington\s+Cash\s+and\s+Carry)',
229
+ r'(burlington\s*icashandcarry)',
230
+ r'(A1\s*Cash\s*and\s*Carry)',
231
+ ]) or "Burlington Cash and Carry"
232
+
233
+ gst_hst = self.extract_text([
234
+ r'GST/HST\s*[:\s]*([0-9\s]+RT\s+[0-9]+)',
235
+ r'HST\s*#?\s*[:\s]*([0-9\s]+)',
236
+ ])
237
+
238
+ date = self.extract_date([
239
+ r'Date[:\s]*(\d{1,2}/\d{1,2}/\d{4})',
240
+ r'(\d{1,2}/\d{1,2}/\d{4})',
241
+ ])
242
+
243
+ transaction_id = self.extract_text([
244
+ r'Transaction\s*#?\s*[:\s]*(BL\s*\w+)',
245
+ r'(L\d{12})',
246
+ r'Transaction\s*#?\s*[:\s]*([A-Z0-9]+)',
247
+ ]) or ""
248
+
249
+ customer_name = self.extract_text([
250
+ r'Customer\s*[:\s]*([A-Za-z\s]+)',
251
+ r'(Familia\s+Fine\s+Foods)',
252
+ ]) or "FAMILIA FINE FOODS"
253
+
254
+ address = self.extract_text([
255
+ r'(\d+\s*[\'#]?\s*Service\s+Rd)',
256
+ r'(\d+\s+[A-Za-z\s]+Rd)',
257
+ ]) or "3495 Service Rd Burlington"
258
+
259
+ customer_code = self.extract_text([
260
+ r'Customer\s*[:\s]*[A-Za-z\s]+\s+([A-Z0-9]{7,})',
261
+ r'(C\d{6,})',
262
+ ])
263
+
264
+ items = self._extract_a1_items_ultra()
265
+
266
+ # Buscar totales con el patrón correcto
267
+ subtotal, hst, total = self._extract_totals_sequential()
268
+
269
+ print(f"DEBUG TOTALES FINALES: Subtotal=${subtotal}, HST=${hst}, Total=${total}")
270
+
271
+ return Invoice(
272
+ vendor="A1",
273
+ issuer=issuer,
274
+ date=date,
275
+ transaction_id=transaction_id,
276
+ customer_name=customer_name,
277
+ issuer_address=address,
278
+ gst_hst_number=gst_hst,
279
+ invoice_number=customer_code or transaction_id,
280
+ items=items,
281
+ subtotal=subtotal,
282
+ hst=hst,
283
+ total=total,
284
+ raw_text=self.raw_text,
285
+ confidence=95.0 if len(items) > 0 else 85.0
286
+ )
287
+
288
+ def _clean_amount(self, amount_str: str) -> float:
289
+ """Limpia y convierte montos con errores OCR"""
290
+ if not amount_str:
291
+ return 0.0
292
+
293
+ # Eliminar espacios y símbolos de dólar
294
+ cleaned = amount_str.replace(' ', '').replace('$', '')
295
+
296
+ # Manejar casos como "17,.01" o "176 .85" o "21,99"
297
+ if ',' in cleaned and '.' in cleaned:
298
+ cleaned = cleaned.replace(',', '')
299
+ elif ',' in cleaned:
300
+ parts = cleaned.split(',')
301
+ if len(parts) == 2 and len(parts[1]) <= 2:
302
+ cleaned = cleaned.replace(',', '.')
303
+ else:
304
+ cleaned = cleaned.replace(',', '')
305
+
306
+ try:
307
+ return float(cleaned)
308
+ except (ValueError, TypeError):
309
+ print(f"DEBUG: No se pudo convertir '{amount_str}' a float")
310
+ return 0.0
311
+
312
+ def _extract_totals_sequential(self) -> tuple:
313
+ """Extrae Subtotal, HST y Total según su patrón de ubicación"""
314
+ subtotal = 0.0
315
+ hst = 0.0
316
+ total = 0.0
317
+
318
+ # Trabajar con las últimas 30 líneas
319
+ end_lines = self.lines[-30:] if len(self.lines) > 30 else self.lines
320
+
321
+ # Buscar SUBTOTAL: valor está en la línea ANTERIOR a "Sub Total"
322
+ for i, line in enumerate(end_lines):
323
+ if re.search(r'Sub\s*Total', line, re.IGNORECASE):
324
+ print(f"DEBUG: Línea 'Sub Total' encontrada en índice {i}: '{line.strip()}'")
325
+ if i > 0:
326
+ prev_line = end_lines[i - 1]
327
+ print(f"DEBUG: Buscando subtotal en línea anterior: '{prev_line.strip()}'")
328
+ amount_match = re.search(r'\$?\s*([\d,\s\.]+)', prev_line)
329
+ if amount_match:
330
+ subtotal = self._clean_amount(amount_match.group(1))
331
+ print(f"DEBUG: ✓ Subtotal encontrado: ${subtotal}")
332
+ break
333
+
334
+ # Buscar HST: valor está en la línea POSTERIOR a "HST"
335
+ for i, line in enumerate(end_lines):
336
+ if re.search(r'^HST\s*$', line.strip(), re.IGNORECASE):
337
+ print(f"DEBUG: Línea 'HST' encontrada en índice {i}: '{line.strip()}'")
338
+ if i + 1 < len(end_lines):
339
+ next_line = end_lines[i + 1]
340
+ print(f"DEBUG: Buscando HST en línea siguiente: '{next_line.strip()}'")
341
+ amount_match = re.search(r'\$?\s*([\d,\s\.]+)', next_line)
342
+ if amount_match:
343
+ hst = self._clean_amount(amount_match.group(1))
344
+ print(f"DEBUG: ✓ HST encontrado: ${hst}")
345
+ break
346
+
347
+ # Buscar TOTAL: valor está en la línea ANTERIOR a "Total"
348
+ for i, line in enumerate(end_lines):
349
+ if re.search(r'^Total\s*$', line.strip(), re.IGNORECASE) or re.search(r'^[Tt]ota[l1]\s*$', line.strip()):
350
+ print(f"DEBUG: Línea 'Total' encontrada en índice {i}: '{line.strip()}'")
351
+ if i > 0:
352
+ prev_line = end_lines[i - 1]
353
+ print(f"DEBUG: Buscando total en línea anterior: '{prev_line.strip()}'")
354
+ amount_match = re.search(r'\$?\s*([\d,\s\.]+)', prev_line)
355
+ if amount_match:
356
+ total = self._clean_amount(amount_match.group(1))
357
+ print(f"DEBUG: ✓ Total encontrado: ${total}")
358
+ break
359
+
360
+ # Validación
361
+ if subtotal > 0 and hst > 0 and total == 0:
362
+ total = subtotal + hst
363
+ print(f"DEBUG: Total calculado: ${total}")
364
+
365
+ return subtotal, hst, total
366
+
367
+ def _is_sku_line(self, line: str) -> str:
368
+ """Determina si una línea es un SKU y lo retorna normalizado"""
369
+ line_stripped = line.strip()
370
+
371
+ # Debe tener entre 5 y 10 caracteres
372
+ if not (5 <= len(line_stripped) <= 10):
373
+ return ""
374
+
375
+ # Debe contener al menos una letra y un número
376
+ has_letter = bool(re.search(r'[A-Za-z]', line_stripped))
377
+ has_number = bool(re.search(r'\d', line_stripped))
378
+
379
+ if not (has_letter and has_number):
380
+ return ""
381
+
382
+ # No debe contener símbolos de dinero, espacios múltiples, o palabras clave
383
+ if re.search(r'\$|:|\s{2,}', line_stripped):
384
+ return ""
385
+
386
+ if re.search(r'^(Total|Sub|HST|Change|Details|Customer|Date|Transaction)', line_stripped, re.IGNORECASE):
387
+ return ""
388
+
389
+ # Patrones específicos conocidos
390
+ patterns = [
391
+ r'^[A-Z]{2,}[0-9]{2,}$', # ALU104, FLRO58, ST0221, BAGO10
392
+ r'^[A-Z][a-z][A-Z][a-z][0-9]{2}$', # HaTo67
393
+ r'^[A-Z][a-z][A-Z][a-z0-9]{2}[0-9]{2}$', # WaTo66
394
+ r'^[A-Z]{2}[0-9]{4}$', # KS1598
395
+ r'^[A-Z]{4}[0-9]{2}$', # WRPO4A
396
+ ]
397
+
398
+ for pat in patterns:
399
+ if re.match(pat, line_stripped):
400
+ return line_stripped.upper()
401
+
402
+ # Patrón genérico: combinación de letras y números
403
+ # Debe empezar con letra
404
+ if re.match(r'^[A-Z][A-Za-z0-9]{4,9}$', line_stripped, re.IGNORECASE):
405
+ # Verificar que no sea solo letras
406
+ if not line_stripped.isalpha():
407
+ return line_stripped.upper()
408
+
409
+ return ""
410
+
411
+ def _extract_a1_items_ultra(self) -> List[InvoiceItem]:
412
+ """Extractor ultra-robusto para items de A1/Burlington Cash and Carry
413
+
414
+ Patrón esperado:
415
+ 1. SKU (línea sola)
416
+ 2. Descripción (una o más líneas)
417
+ 3. Precio unitario con/sin H
418
+ 4. Precio total con/sin H
419
+ 5. Cantidad de unidades compradas
420
+ 6. Cantidad por unidad de empaque (última línea antes del espacio)
421
+ """
422
+ items = []
423
+ item_matches = []
424
+
425
+ # Encontrar inicio y fin del área de items
426
+ start_idx = 0
427
+ end_idx = len(self.lines)
428
+
429
+ for i, line in enumerate(self.lines):
430
+ if re.search(r'^(Details|SKU)\s*$', line.strip(), re.IGNORECASE):
431
+ start_idx = i + 1
432
+ print(f"DEBUG: Inicio de items en línea {start_idx}")
433
+ break
434
+
435
+ for i, line in enumerate(self.lines[start_idx:], start=start_idx):
436
+ if re.search(r'Sub\s*Total', line, re.IGNORECASE):
437
+ end_idx = i
438
+ print(f"DEBUG: Fin de items en línea {end_idx}")
439
+ break
440
+
441
+ print(f"\nDEBUG: Escaneando líneas {start_idx} a {end_idx} buscando SKUs...")
442
+ print(f"{'='*70}\n")
443
+
444
+ # Buscar TODOS los SKUs usando el método robusto
445
+ for i in range(start_idx, end_idx):
446
+ line = self.lines[i]
447
+ sku = self._is_sku_line(line)
448
+
449
+ if sku:
450
+ item_matches.append({
451
+ 'line_index': i,
452
+ 'sku': sku
453
+ })
454
+ print(f"DEBUG: ✓ SKU '{sku}' detectado en línea {i}: '{line.strip()}'")
455
+
456
+ print(f"\nDEBUG: Encontrados {len(item_matches)} SKUs en total")
457
+ print(f"{'='*70}\n")
458
+
459
+ # Procesar cada item
460
+ for idx, item_data in enumerate(item_matches):
461
+ i = item_data['line_index']
462
+ sku = item_data['sku']
463
+
464
+ # Determinar rango hasta el siguiente SKU
465
+ if idx + 1 < len(item_matches):
466
+ search_end = item_matches[idx + 1]['line_index']
467
+ else:
468
+ search_end = min(i + 25, end_idx)
469
+
470
+ item_lines = self.lines[i+1:search_end]
471
+
472
+ print(f"\n{'='*70}")
473
+ print(f"DEBUG: Procesando SKU #{idx+1}: {sku} (líneas {i+1} a {search_end-1})")
474
+ print(f"{'='*70}")
475
+
476
+ for j, line in enumerate(item_lines, start=1):
477
+ print(f" [{j:2d}] '{line.strip()}'")
478
+
479
+ # Extraer según el patrón de abajo hacia arriba
480
+ description_parts = []
481
+ unit_price = 0.0
482
+ line_total = 0.0
483
+ quantity_packages = 0.0
484
+ quantity_per_package = ""
485
+ tax_code = ""
486
+
487
+ # Iterar desde el final hacia arriba
488
+ num_lines = len(item_lines)
489
+
490
+ # Última línea: cantidad por unidad de empaque (ej: "100 ct", "12x355 ml")
491
+ if num_lines >= 1:
492
+ last_line = item_lines[-1].strip()
493
+ # Patrones más flexibles para unidades
494
+ unit_match = re.search(r'(\d+)\s*(ct|pk|ea|case|box)', last_line, re.IGNORECASE)
495
+ if not unit_match:
496
+ unit_match = re.search(r'(\d+)\s*x\s*(\d+)\s*(m1|ml)', last_line, re.IGNORECASE)
497
+
498
+ if unit_match:
499
+ quantity_per_package = unit_match.group(0)
500
+ print(f"\nDEBUG: ✓ Cantidad por paquete: '{quantity_per_package}'")
501
+ else:
502
+ print(f"\nDEBUG: ⚠ No se encontró cantidad por paquete en: '{last_line}'")
503
+
504
+ # Antepenúltima línea: cantidad de unidades compradas
505
+ if num_lines >= 2:
506
+ qty_line = item_lines[-2].strip()
507
+ qty_match = re.match(r'^(\d+[,\.]?\d*)\s*$', qty_line)
508
+ if qty_match:
509
+ qty_str = qty_match.group(1).replace(',', '.')
510
+ try:
511
+ quantity_packages = float(qty_str)
512
+ print(f"DEBUG: ✓ Cantidad de paquetes: {quantity_packages}")
513
+ except ValueError:
514
+ print(f"DEBUG: ⚠ No se pudo parsear cantidad: '{qty_str}'")
515
+ else:
516
+ print(f"DEBUG: ⚠ No se encontró cantidad en: '{qty_line}'")
517
+
518
+ # Líneas anteriores: precios (total y unitario, con posible H)
519
+ # Buscar las líneas con $ en los últimos renglones antes de la cantidad
520
+ price_lines = []
521
+ search_limit = max(0, num_lines - 6) # Buscar en las últimas 6 líneas
522
+
523
+ for k in range(search_limit, max(0, num_lines - 2)):
524
+ if k < len(item_lines):
525
+ line = item_lines[k].strip()
526
+ # Buscar líneas con precios ($)
527
+ if re.search(r'\$', line):
528
+ price_lines.append({'index': k, 'line': line})
529
+ print(f"DEBUG: Línea con precio [{k}]: '{line}'")
530
+
531
+ print(f"DEBUG: Total líneas con precios: {len(price_lines)}")
532
+
533
+ # Extraer precios de las líneas encontradas
534
+ all_prices = []
535
+ for price_info in price_lines:
536
+ line = price_info['line']
537
+ # Extraer todos los precios de la línea
538
+ price_matches = re.findall(r'\$\s*([\d,\s\.]+)\s*(H)?', line, re.IGNORECASE)
539
+ for pm in price_matches:
540
+ price_val = self._clean_amount(pm[0])
541
+ has_h = bool(pm[1])
542
+ if price_val > 0:
543
+ all_prices.append({
544
+ 'value': price_val,
545
+ 'has_h': has_h,
546
+ 'line_idx': price_info['index']
547
+ })
548
+ if has_h:
549
+ tax_code = "H"
550
+
551
+
552
+ # Asignar precios: tomar los dos últimos valores únicos
553
+ if len(all_prices) >= 2:
554
+ # Ordenar por valor
555
+ unique_prices = []
556
+ seen_values = set()
557
+ for p in all_prices:
558
+ if p['value'] not in seen_values:
559
+ unique_prices.append(p)
560
+ seen_values.add(p['value'])
561
+
562
+ if len(unique_prices) >= 2:
563
+ unique_prices.sort(key=lambda x: x['value'])
564
+ unit_price = unique_prices[0]['value']
565
+ line_total = unique_prices[-1]['value']
566
+ print(f"DEBUG: ✓ Unitario: ${unit_price}, Total: ${line_total}")
567
+ elif len(unique_prices) == 1:
568
+ unit_price = unique_prices[0]['value']
569
+ line_total = unit_price
570
+ print(f"DEBUG: ✓ Precio único: ${unit_price}")
571
+ elif len(all_prices) == 1:
572
+ unit_price = all_prices[0]['value']
573
+ line_total = unit_price
574
+ if all_prices[0]['has_h']:
575
+ tax_code = "H"
576
+ print(f"DEBUG: ✓ Precio único: ${unit_price}")
577
+
578
+ # Buscar H en líneas cercanas si no se encontró
579
+ if not tax_code:
580
+ for k in range(max(0, num_lines - 6), num_lines):
581
+ if k < len(item_lines):
582
+ if re.search(r'\bH\b', item_lines[k]):
583
+ tax_code = "H"
584
+ print(f"DEBUG: ✓ H encontrado en línea {k}")
585
+ break
586
+
587
+ # Descripción: todas las líneas antes de los precios
588
+ desc_end = price_lines[0]['index'] if price_lines else max(0, num_lines - 4)
589
+ for k in range(0, desc_end):
590
+ if k < len(item_lines):
591
+ line = item_lines[k].strip()
592
+ # Excluir líneas con solo precios, números, o símbolos
593
+ if line and not re.match(r'^[\$\d,\.\s]+$', line) and not re.match(r'^[,\.\s]+$', line):
594
+ desc_clean = re.sub(r'[^\w\s\-\.,/\'"#%&()x]', ' ', line)
595
+ desc_clean = ' '.join(desc_clean.split())
596
+ if desc_clean and len(desc_clean) > 2:
597
+ description_parts.append(desc_clean)
598
+
599
+ description = ' '.join(description_parts) if description_parts else ""
600
+
601
+ print(f"\nDEBUG: Resumen extraído:")
602
+ print(f" Descripción: '{description}'")
603
+ print(f" Unitario: ${unit_price}")
604
+ print(f" Total: ${line_total}")
605
+ print(f" Cantidad: {quantity_packages}")
606
+ print(f" Tax: {tax_code}")
607
+
608
+ # Validaciones y cálculos
609
+ if not description:
610
+ print(f"DEBUG: ✗ Item {sku} - SIN DESCRIPCIÓN, omitido\n")
611
+ continue
612
+
613
+ if quantity_packages == 0:
614
+ quantity_packages = 1.0
615
+ print(f"DEBUG: Cantidad por defecto: 1.0")
616
+
617
+ if line_total == 0 and unit_price > 0:
618
+ line_total = quantity_packages * unit_price
619
+ print(f"DEBUG: Total calculado: ${line_total}")
620
+
621
+ if unit_price == 0 and line_total > 0 and quantity_packages > 0:
622
+ unit_price = line_total / quantity_packages
623
+ print(f"DEBUG: Unitario calculado: ${unit_price}")
624
+
625
+ # Agregar item
626
+ if description and (unit_price > 0 or line_total > 0):
627
+ items.append(InvoiceItem(
628
+ sku=sku,
629
+ description=description.strip(),
630
+ quantity=quantity_packages,
631
+ unit_price=unit_price,
632
+ amount=line_total,
633
+ tax_code=tax_code
634
+ ))
635
+
636
+ print(f"\nDEBUG: ✓✓✓ ITEM #{len(items)} AGREGADO EXITOSAMENTE")
637
+ print(f" SKU: {sku}")
638
+ print(f" Desc: {description[:60]}...")
639
+ print(f" Qty: {quantity_packages}")
640
+ print(f" Unit: ${unit_price}")
641
+ print(f" Total: ${line_total}")
642
+ print(f" Tax: {tax_code}\n")
643
+ else:
644
+ print(f"\nDEBUG: ✗✗✗ Item {sku} - DATOS INCOMPLETOS, omitido\n")
645
+
646
+ print(f"\n{'='*70}")
647
+ print(f"DEBUG: RESUMEN FINAL - {len(items)} items extraídos de {len(item_matches)} SKUs detectados")
648
+ print(f"{'='*70}\n")
649
+
650
+ return items
651
+
652
+
653
+ class DefaultAzureExtractor(BasePatternExtractor):
654
+ """Extractor que parsea el texto formateado de Azure Document Intelligence"""
655
+
656
+ def extract_invoice(self) -> Invoice:
657
+ """
658
+ Extrae datos desde el formato de texto generado por Azure.
659
+ """
660
+ # Extraer información básica
661
+ issuer = self.extract_text([
662
+ r'Proveedor:\s*(.+)',
663
+ r'Supplier:\s*(.+)',
664
+ r'Vendor:\s*(.+)'
665
+ ]) or "Proveedor Desconocido"
666
+
667
+ date = self.extract_text([
668
+ r'Fecha:\s*(.+)',
669
+ r'Date:\s*(.+)'
670
+ ]) or ""
671
+
672
+ transaction_id = self.extract_text([
673
+ r'Invoice ID:\s*(.+)',
674
+ r'Invoice No\.?:\s*(.+)',
675
+ r'Factura N°?:\s*(.+)'
676
+ ]) or ""
677
+
678
+ customer_name = self.extract_text([
679
+ r'Cliente:\s*(.+)',
680
+ r'Customer:\s*(.+)'
681
+ ]) or ""
682
+
683
+ address = self.extract_text([
684
+ r'Dirección:\s*(.+)',
685
+ r'Address:\s*(.+)'
686
+ ]) or ""
687
+
688
+ gst_hst = self.extract_text([
689
+ r'GST/HST:\s*(.+)',
690
+ r'Tax ID:\s*(.+)'
691
+ ]) or ""
692
+
693
+ # Extraer items
694
+ items = self._extract_azure_items()
695
+
696
+ # Extraer totales de manera más robusta
697
+ subtotal, total_tax, total = self._extract_totals()
698
+
699
+ # Calcular confidence
700
+ confidence = 90.0 if len(items) > 0 else 70.0
701
+
702
+ print(f"\nDEBUG: Extracción Azure completada:")
703
+ print(f" Proveedor: {issuer}")
704
+ print(f" Fecha: {date}")
705
+ print(f" Transaction ID: {transaction_id}")
706
+ print(f" Items: {len(items)}")
707
+ print(f" Subtotal: ${subtotal}")
708
+ print(f" Tax: ${total_tax}")
709
+ print(f" Total: ${total}\n")
710
+
711
+ return Invoice(
712
+ vendor="Default",
713
+ issuer=issuer,
714
+ date=date,
715
+ transaction_id=transaction_id,
716
+ customer_name=customer_name,
717
+ issuer_address=address,
718
+ gst_hst_number=gst_hst,
719
+ invoice_number=transaction_id,
720
+ items=items,
721
+ subtotal=subtotal,
722
+ hst=total_tax,
723
+ total=total,
724
+ raw_text=self.raw_text,
725
+ confidence=confidence
726
+ )
727
+
728
+ def _extract_totals(self) -> tuple:
729
+ """Extrae subtotal, impuestos y total de manera robusta"""
730
+ # Primero buscar total de la factura (el más importante)
731
+ total = self._find_total()
732
+
733
+ # Luego buscar subtotal
734
+ subtotal = self._find_subtotal()
735
+
736
+ # Finalmente buscar impuestos
737
+ total_tax = self._find_tax()
738
+
739
+ # Validaciones cruzadas
740
+ if total > 0 and subtotal == 0:
741
+ # Si tenemos total pero no subtotal, estimar
742
+ if total_tax > 0:
743
+ subtotal = total - total_tax
744
+ else:
745
+ subtotal = total
746
+
747
+ return subtotal, total_tax, total
748
+
749
+ def _find_total(self) -> float:
750
+ """Encuentra el total de la factura"""
751
+ patterns = [
752
+ r'Total de la factura:\s*\$?\s*([\d,\.]+)',
753
+ r'Total:\s*\$?\s*([\d,\.]+)',
754
+ r'Invoice Total:\s*\$?\s*([\d,\.]+)',
755
+ r'Amount Due:\s*\$?\s*([\d,\.]+)',
756
+ r'Grand Total:\s*\$?\s*([\d,\.]+)',
757
+ r'TOTAL:\s*\$?\s*([\d,\.]+)'
758
+ ]
759
+
760
+ for pattern in patterns:
761
+ amount = self._extract_single_amount(pattern)
762
+ if amount > 0:
763
+ print(f"DEBUG: Total encontrado con patrón '{pattern}': ${amount}")
764
+ return amount
765
+
766
+ # Si no encontramos con patrones, buscar numéricamente el monto más grande cerca de "Total"
767
+ total_matches = list(re.finditer(r'Total[^\d]*\$?\s*([\d,\.]+)', self.raw_text, re.IGNORECASE))
768
+ if total_matches:
769
+ amounts = []
770
+ for match in total_matches:
771
+ try:
772
+ amount_str = match.group(1).replace('$', '').replace(',', '').strip()
773
+ amount = float(amount_str)
774
+ amounts.append(amount)
775
+ except ValueError:
776
+ continue
777
+
778
+ if amounts:
779
+ max_amount = max(amounts)
780
+ print(f"DEBUG: Total inferido como máximo encontrado: ${max_amount}")
781
+ return max_amount
782
+
783
+ return 0.0
784
+
785
+ def _find_subtotal(self) -> float:
786
+ """Encuentra el subtotal"""
787
+ patterns = [
788
+ r'Subtotal:\s*\$?\s*([\d,\.]+)',
789
+ r'Sub Total:\s*\$?\s*([\d,\.]+)',
790
+ r'SUB-TOTAL:\s*\$?\s*([\d,\.]+)'
791
+ ]
792
+
793
+ for pattern in patterns:
794
+ amount = self._extract_single_amount(pattern)
795
+ if amount > 0:
796
+ return amount
797
+
798
+ return 0.0
799
+
800
+ def _find_tax(self) -> float:
801
+ """Encuentra los impuestos"""
802
+ patterns = [
803
+ r'Total impuestos:\s*\$?\s*([\d,\.]+)',
804
+ r'Total taxes?:\s*\$?\s*([\d,\.]+)',
805
+ r'Tax:\s*\$?\s*([\d,\.]+)',
806
+ r'HST:\s*\$?\s*([\d,\.]+)',
807
+ r'GST:\s*\$?\s*([\d,\.]+)',
808
+ r'Impuesto:\s*\$?\s*([\d,\.]+)'
809
+ ]
810
+
811
+ for pattern in patterns:
812
+ amount = self._extract_single_amount(pattern)
813
+ if amount > 0:
814
+ return amount
815
+
816
+ return 0.0
817
+
818
+ def _extract_single_amount(self, pattern: str) -> float:
819
+ """Extrae un solo monto usando un patrón"""
820
+ match = re.search(pattern, self.raw_text, re.IGNORECASE)
821
+ if match:
822
+ try:
823
+ amount_str = match.group(1).replace('$', '').replace(',', '').strip()
824
+ return float(amount_str)
825
+ except ValueError:
826
+ pass
827
+ return 0.0
828
+
829
+ def _extract_azure_items(self) -> List[InvoiceItem]:
830
+ """
831
+ Extrae items usando un enfoque más directo y robusto
832
+ """
833
+ items = []
834
+
835
+ # Estrategia principal: buscar todas las ocurrencias de "--- Ítem #"
836
+ item_starts = list(re.finditer(r'---\s*Ítem\s*#\d+\s*---', self.raw_text))
837
+
838
+ if not item_starts:
839
+ # Intentar con formato alternativo
840
+ item_starts = list(re.finditer(r'---\s*Item\s*#\d+\s*---', self.raw_text))
841
+
842
+ print(f"DEBUG: Encontrados {len(item_starts)} inicios de items")
843
+
844
+ for i, start_match in enumerate(item_starts):
845
+ start_pos = start_match.end() # Comenzar después del separador
846
+
847
+ # Encontrar el final de este item (siguiente item o sección TOTALES)
848
+ if i < len(item_starts) - 1:
849
+ end_pos = item_starts[i + 1].start()
850
+ else:
851
+ # Para el último item, buscar el inicio de TOTALES
852
+ totales_match = re.search(r'TOTALES|===|Subtotal:|Total:', self.raw_text[start_pos:])
853
+ if totales_match:
854
+ end_pos = start_pos + totales_match.start()
855
+ else:
856
+ end_pos = start_pos + 1000 # Límite por seguridad
857
+
858
+ section = self.raw_text[start_pos:end_pos].strip()
859
+ item = self._parse_item_section(section, i + 1)
860
+
861
+ if item and item.amount > 0: # Solo incluir items con total > 0
862
+ items.append(item)
863
+
864
+ # Si no encontramos items con separadores, usar método alternativo
865
+ if not items:
866
+ items = self._fallback_item_extraction()
867
+
868
+ print(f"DEBUG: Total de items extraídos: {len(items)}")
869
+ return items
870
+
871
+ def _parse_item_section(self, section: str, item_number: int) -> Optional[InvoiceItem]:
872
+ """Parsea una sección de item individual"""
873
+ print(f"\nDEBUG: Procesando Item #{item_number}")
874
+
875
+ # Extraer SKU
876
+ sku = self._extract_field(section, [
877
+ r'Código:\s*([^\n]+)',
878
+ r'Code:\s*([^\n]+)',
879
+ r'SKU:\s*([^\n]+)'
880
+ ])
881
+
882
+ # Extraer descripción (manejar multilínea)
883
+ description = self._extract_multiline_field(section, [
884
+ r'Descripción:\s*(.+?)(?=\n\s*(?:Cantidad|Precio|Impuesto|Total|Código|Code|$))',
885
+ r'Description:\s*(.+?)(?=\n\s*(?:Quantity|Price|Tax|Total|Code|$))'
886
+ ])
887
+
888
+ # Si no hay código pero la descripción empieza con patrón de SKU, extraerlo
889
+ if not sku and description:
890
+ first_line = description.split('\n')[0].strip()
891
+ if self._is_potential_sku(first_line):
892
+ sku = first_line
893
+ # Remover el SKU de la descripción
894
+ lines = description.split('\n')
895
+ if len(lines) > 1:
896
+ description = '\n'.join(lines[1:]).strip()
897
+ else:
898
+ description = ""
899
+
900
+ # Validar que tengamos al menos código O descripción
901
+ if not sku and not description:
902
+ print(f"DEBUG: ✗ Item #{item_number} omitido - sin código ni descripción")
903
+ return None
904
+
905
+ # Extraer valores numéricos
906
+ quantity = self._extract_numeric_value(section, [
907
+ r'Cantidad:\s*([\d,\.]+)',
908
+ r'Quantity:\s*([\d,\.]+)'
909
+ ], default=1.0)
910
+
911
+ unit_price = self._extract_numeric_value(section, [
912
+ r'Precio unitario:\s*\$?\s*([\d,\.]+)',
913
+ r'Unit Price:\s*\$?\s*([\d,\.]+)',
914
+ r'Price:\s*\$?\s*([\d,\.]+)'
915
+ ])
916
+
917
+ amount = self._extract_numeric_value(section, [
918
+ r'Total por ítem:\s*\$?\s*([\d,\.]+)',
919
+ r'Item Total:\s*\$?\s*([\d,\.]+)',
920
+ r'Total:\s*\$?\s*([\d,\.]+)'
921
+ ])
922
+
923
+ # Determinar tax code
924
+ tax_code = ""
925
+ tax_amount = self._extract_numeric_value(section, [
926
+ r'Impuesto\s*\(?H\)?:\s*\$?\s*([\d,\.]+)',
927
+ r'Tax\s*\(?H\)?:\s*\$?\s*([\d,\.]+)'
928
+ ])
929
+ if tax_amount > 0:
930
+ tax_code = "H"
931
+
932
+ # Calcular valores faltantes
933
+ if amount == 0 and unit_price > 0 and quantity > 0:
934
+ amount = quantity * unit_price
935
+ print(f"DEBUG: Total calculado: {quantity} × ${unit_price} = ${amount}")
936
+
937
+ if unit_price == 0 and amount > 0 and quantity > 0:
938
+ unit_price = amount / quantity
939
+ print(f"DEBUG: Precio unitario calculado: ${amount} ÷ {quantity} = ${unit_price}")
940
+
941
+ # Si aún no tenemos amount, usar unit_price como último recurso
942
+ if amount == 0 and unit_price > 0:
943
+ amount = unit_price
944
+ quantity = 1.0
945
+ print(f"DEBUG: Usando precio unitario como total: ${amount}")
946
+
947
+ # Para el caso BEER STORE: si el amount tiene "T" al final, limpiarlo
948
+ if amount == 0:
949
+ # Buscar patrones alternativos de total
950
+ amount_match = re.search(r'Total por ítem:\s*([\d,\.]+)\s*T', section, re.IGNORECASE)
951
+ if amount_match:
952
+ try:
953
+ amount = float(amount_match.group(1).replace(',', ''))
954
+ print(f"DEBUG: Total extraído con 'T': ${amount}")
955
+ except ValueError:
956
+ pass
957
+
958
+ # Validación final: solo incluir si tenemos amount > 0
959
+ if amount == 0:
960
+ print(f"DEBUG: ✗ Item #{item_number} omitido - amount = 0")
961
+ return None
962
+
963
+ item = InvoiceItem(
964
+ sku=sku or "",
965
+ description=description or "",
966
+ quantity=quantity,
967
+ unit_price=unit_price,
968
+ amount=amount,
969
+ tax_code=tax_code,
970
+ category=""
971
+ )
972
+
973
+ print(f"DEBUG: ✓ Item #{item_number} extraído:")
974
+ print(f" SKU: '{sku or 'N/A'}'")
975
+ print(f" Descripción: '{description[:50] if description else 'N/A'}...'")
976
+ print(f" Cantidad: {quantity}")
977
+ print(f" Precio unitario: ${unit_price:.2f}")
978
+ print(f" Total: ${amount:.2f}")
979
+ print(f" Tax code: '{tax_code}'")
980
+
981
+ return item
982
+
983
+ def _fallback_item_extraction(self) -> List[InvoiceItem]:
984
+ """Método de respaldo para extraer items cuando falla el método principal"""
985
+ print("DEBUG: Usando método de respaldo para extracción de items")
986
+ items = []
987
+
988
+ # Buscar por patrones de "Código:" seguidos de otros campos
989
+ code_pattern = r'Código:\s*([^\n]+)'
990
+ code_matches = list(re.finditer(code_pattern, self.raw_text))
991
+
992
+ for i, code_match in enumerate(code_matches):
993
+ start_pos = code_match.start()
994
+
995
+ # Encontrar el final de este item
996
+ if i < len(code_matches) - 1:
997
+ end_pos = code_matches[i + 1].start()
998
+ else:
999
+ end_pos = start_pos + 500
1000
+
1001
+ section = self.raw_text[start_pos:end_pos]
1002
+ item = self._parse_item_section(section, i + 1)
1003
+
1004
+ if item and item.amount > 0:
1005
+ items.append(item)
1006
+
1007
+ # Si aún no tenemos items, buscar por "Total por ítem"
1008
+ if not items:
1009
+ total_pattern = r'Total por ítem:\s*\$?\s*([\d,\.]+)'
1010
+ total_matches = list(re.finditer(total_pattern, self.raw_text))
1011
+
1012
+ for i, total_match in enumerate(total_matches):
1013
+ # Buscar sección alrededor de este total
1014
+ start_pos = max(0, total_match.start() - 200)
1015
+ end_pos = total_match.end() + 100
1016
+ section = self.raw_text[start_pos:end_pos]
1017
+ item = self._parse_item_section(section, i + 1)
1018
+
1019
+ if item and item.amount > 0:
1020
+ items.append(item)
1021
+
1022
+ print(f"DEBUG: Método de respaldo encontró {len(items)} items")
1023
+ return items
1024
+
1025
+ def _extract_field(self, text: str, patterns: List[str]) -> str:
1026
+ """Extrae un campo de texto usando múltiples patrones"""
1027
+ for pattern in patterns:
1028
+ match = re.search(pattern, text, re.IGNORECASE)
1029
+ if match:
1030
+ value = match.group(1).strip()
1031
+ # Limpiar confianza si existe
1032
+ value = re.sub(r'\s*\(Confianza:.*?\)', '', value).strip()
1033
+ return value
1034
+ return ""
1035
+
1036
+ def _extract_multiline_field(self, text: str, patterns: List[str]) -> str:
1037
+ """Extrae un campo multilínea"""
1038
+ for pattern in patterns:
1039
+ match = re.search(pattern, text, re.IGNORECASE | re.DOTALL)
1040
+ if match:
1041
+ value = match.group(1).strip()
1042
+ # Limpiar confianza
1043
+ value = re.sub(r'\s*\(Confianza:.*?\)', '', value).strip()
1044
+ return value
1045
+ return ""
1046
+
1047
+ def _extract_numeric_value(self, text: str, patterns: List[str], default: float = 0.0) -> float:
1048
+ """Extrae un valor numérico"""
1049
+ for pattern in patterns:
1050
+ match = re.search(pattern, text, re.IGNORECASE)
1051
+ if match:
1052
+ value_str = match.group(1).replace('$', '').replace(',', '').strip()
1053
+ try:
1054
+ return float(value_str)
1055
+ except ValueError:
1056
+ continue
1057
+ return default
1058
+
1059
+ def _is_potential_sku(self, text: str) -> bool:
1060
+ """
1061
+ Determina si un texto parece ser un código SKU.
1062
+ """
1063
+ text = text.strip()
1064
+
1065
+ if len(text) > 20 or len(text) < 2:
1066
+ return False
1067
+
1068
+ # No debe tener espacios (a menos que sea muy corto)
1069
+ if ' ' in text and len(text) > 10:
1070
+ return False
1071
+
1072
+ # Patrón 1: Solo números (3-15 dígitos)
1073
+ if text.replace('-', '').isdigit() and 3 <= len(text.replace('-', '')) <= 15:
1074
+ return True
1075
+
1076
+ # Patrón 2: Mezcla de letras y números (como "TOC774", "OIL093")
1077
+ if re.match(r'^[A-Z]{2,5}\d{2,4}$', text):
1078
+ return True
1079
+
1080
+ # Patrón 3: Principalmente números
1081
+ digit_ratio = sum(c.isdigit() for c in text) / len(text)
1082
+ if digit_ratio >= 0.6:
1083
+ return True
1084
+
1085
+ return False
1086
+
1087
+ class CostcoPatternExtractor(BasePatternExtractor):
1088
+ """
1089
+ Extractor ultra-optimizado para Costco Business Centre.
1090
+ Versión (v6) con lógica condicional para manejar formatos de ítems COMPACTOS vs. DETALLADOS.
1091
+ """
1092
+
1093
+ def __init__(self, raw_text: str, text_blocks: List[Dict] = None, ocr_config: Dict = None):
1094
+ super().__init__(raw_text, text_blocks, ocr_config)
1095
+
1096
+ def extract_invoice(self) -> Invoice:
1097
+ # (Los metadatos se mantienen igual)
1098
+ issuer = "Costco Wholesale Business Centre"
1099
+ gst_hst = self.extract_text([r'GST/HST\s*\[([0-9\s]+RT\s+[0-9]+)\]'])
1100
+ date = self.extract_date([r'Order Date[:\s]*(\d{1,2}/\d{1,2}/\d{4})', r'(\d{1,2}/\d{1,2}/\d{4})'])
1101
+ transaction_id = self.extract_text([r'Order Number[:\s]*(\d{10})', r'(\d{10})']) or ""
1102
+ customer_name = "FAMILIA FINE FOODS"
1103
+ address = self.extract_text([r'(\d+\s+NORTH\s+SERVICE\s+RD)']) or "3 NORTH SERVICE RD ST. CATHARINES, ON"
1104
+ membership = self.extract_text([r'Membership\s*\.?\s*Number[:\s]*(\d+)'])
1105
+
1106
+ items = self._extract_costco_items_ultra()
1107
+
1108
+ subtotal = self.extract_amount([r'Subtotal\s*\(\d+\s*Items\)\s*\$\s*([\d,]+\.?\d*)', r'Subtotal[^\$]*\$\s*([\d,]+\.?\d*)',]) or 0.0
1109
+ hst = self.extract_amount([r'HST\s*\(H\)\s*\$\s*([\d,]+\.?\d*)']) or 0.0
1110
+ total = self.extract_amount([r'Invoice Total\s*\$\s*([\d,]+\.?\d*)', r'Order Total\s*\$\s*([\d,]+\.?\d*)',]) or 0.0
1111
+
1112
+ confidence = 85.0
1113
+ if len(items) > 20: confidence = 95.0
1114
+
1115
+ return Invoice(
1116
+ vendor="Costco", issuer=issuer, date=date, transaction_id=transaction_id, customer_name=customer_name,
1117
+ issuer_address=address, gst_hst_number=gst_hst, invoice_number=membership or transaction_id,
1118
+ items=items, subtotal=subtotal, hst=hst, total=total, raw_text=self.raw_text, confidence=confidence
1119
+ )
1120
+
1121
+ def _extract_costco_items_ultra(self) -> List[InvoiceItem]:
1122
+ """
1123
+ Extractor ultra-robusto (v6).
1124
+ Implementa lógica condicional para detectar ítems compactos.
1125
+ """
1126
+ items = []
1127
+ item_matches = []
1128
+
1129
+ # Regex de ítem flexible (V5)
1130
+ item_pattern = r'^\s*(?:I|l)tem\s+(\d+)\s+\$\s*([\d,]+\.?\d*)\s*(?:\([A-Z]\))?\s*$'
1131
+
1132
+ for i, line in enumerate(self.lines):
1133
+ match = re.search(item_pattern, line)
1134
+ if match:
1135
+ item_matches.append({
1136
+ 'line_index': i,
1137
+ 'sku': match.group(1),
1138
+ 'unit_price': float(match.group(2).replace(',', ''))
1139
+ })
1140
+
1141
+ print(f"DEBUG: Encontrados {len(item_matches)} SKUs con regex flexible")
1142
+
1143
+ for item_data in item_matches:
1144
+ i = item_data['line_index']
1145
+ sku = item_data['sku']
1146
+ unit_price = item_data['unit_price']
1147
+
1148
+ description = ""
1149
+ quantity = 0.0
1150
+ line_total = 0.0
1151
+ tax_code = ""
1152
+ status = ""
1153
+ is_compact = False # Nuevo indicador para el formato
1154
+
1155
+ try:
1156
+ # 1. Descripción (i-1)
1157
+ if i == 0: continue
1158
+ description = self.lines[i-1]
1159
+
1160
+ if 'http' in description or 'Orders & Purchases' in description or 'Invoice Total' in description:
1161
+ continue
1162
+
1163
+ # 2. Determinar el formato: Compacto (3 líneas después) o Detallado (5 líneas después)
1164
+
1165
+ # Intentamos leer la Cantidad Enviada (i+2) para el formato Detallado
1166
+ # Es el mejor indicador, ya que la línea (i+1) puede ser Qty Ordered o Status.
1167
+ if len(self.lines) > i + 2:
1168
+ qty_shipped_match = re.match(r'^(\d+(?:\.\d+)?)$', self.lines[i+2])
1169
+ else:
1170
+ qty_shipped_match = None
1171
+
1172
+ if qty_shipped_match:
1173
+ # Formato Detallado (5 líneas después): QtyO, QtyS, Status, TotalO, TotalS
1174
+ is_compact = False
1175
+ quantity = float(qty_shipped_match.group(1))
1176
+
1177
+ # 3. Estado (i+3)
1178
+ if len(self.lines) <= i + 3: continue
1179
+ status = self.lines[i+3]
1180
+
1181
+ # Índices de total
1182
+ total_index = i + 4
1183
+ invoice_total_index = i + 5
1184
+
1185
+ else:
1186
+ # Formato Compacto (3 líneas después): Status, TotalO, TotalS
1187
+ is_compact = True
1188
+ quantity = 1.0 # Asumimos 1 si no hay líneas de cantidad
1189
+
1190
+ # 3. Estado (i+1)
1191
+ if len(self.lines) <= i + 1: continue
1192
+ status = self.lines[i+1]
1193
+
1194
+ # Índices de total
1195
+ total_index = i + 2
1196
+ invoice_total_index = i + 3
1197
+
1198
+ # Manejo del estado
1199
+ if status.lower() == 'cancelled':
1200
+ print(f"DEBUG: Item {sku} cancelado")
1201
+ continue
1202
+ if status not in ['Delivered', 'Shipped', 'Pending']:
1203
+ print(f"DEBUG: Item {sku} - estado no válido '{status}'")
1204
+ continue
1205
+
1206
+ # 4. Impuesto y Totales (aplicando el offset correcto)
1207
+ current_index = total_index
1208
+
1209
+ # Chequear por código de impuesto (Si está presente, avanza el índice)
1210
+ if len(self.lines) > current_index:
1211
+ tax_match = re.match(r'^\((H|G|P|Q)\)$', self.lines[current_index])
1212
+ if tax_match:
1213
+ tax_code = tax_match.group(1)
1214
+ current_index += 1 # Índice avanzado
1215
+
1216
+ # El Total de Factura (Total Invoiced) siempre es la siguiente línea válida después del Total de Pedido (Total Ordered)
1217
+ final_total_index = current_index + 1
1218
+
1219
+ if len(self.lines) <= final_total_index: continue
1220
+
1221
+ total_invoice_match = re.match(r'^\$\s*([\d,]+\.?\d*)$', self.lines[final_total_index])
1222
+ if total_invoice_match:
1223
+ line_total = float(total_invoice_match.group(1).replace(',', ''))
1224
+ else:
1225
+ print(f"DEBUG: Item {sku} - no se encontró el total de factura en '{self.lines[final_total_index]}'")
1226
+ continue
1227
+
1228
+ # 5. Agregar item
1229
+ if description and status:
1230
+ items.append(InvoiceItem(
1231
+ sku=sku, description=description, quantity=quantity, unit_price=unit_price,
1232
+ amount=line_total, tax_code=tax_code
1233
+ ))
1234
+ # print(f"DEBUG: Item {sku} ({'Compacto' if is_compact else 'Detallado'}): {description[:30]}... qty={quantity}")
1235
+
1236
+ except IndexError:
1237
+ print(f"DEBUG: Item {sku} - Error de índice procesando item")
1238
+ continue
1239
+ except Exception as e:
1240
+ print(f"DEBUG: Item {sku} - Excepción: {e}")
1241
+ continue
1242
+
1243
+ # Eliminar duplicados
1244
+ final_items = []
1245
+ seen_keys = set()
1246
+ for item in items:
1247
+ item_key = (item.sku, item.quantity, item.amount, item.description)
1248
+ if item_key not in seen_keys:
1249
+ final_items.append(item)
1250
+ seen_keys.add(item_key)
1251
+
1252
+ print(f"DEBUG: Total items finales: {len(final_items)}")
1253
+ return final_items
1254
+
1255
+ class Costco2PatternExtractor(BasePatternExtractor):
1256
+ """Extractor ultra-optimizado para Costco Business Centre"""
1257
+
1258
+ def extract_invoice(self) -> Invoice:
1259
+ issuer = "Costco Wholesale Business Centre"
1260
+
1261
+ gst_hst = self.extract_text([
1262
+ r'GST/HST\s*\[([0-9\s]+RT\s+[0-9]+)\]',
1263
+ ])
1264
+
1265
+ date = self.extract_date([
1266
+ r'Order Date[:\s]*(\d{1,2}/\d{1,2}/\d{4})',
1267
+ r'(\d{1,2}/\d{1,2}/\d{4})',
1268
+ ])
1269
+
1270
+ transaction_id = self.extract_text([
1271
+ r'Order Number[:\s]*(\d{10})',
1272
+ r'(\d{10})',
1273
+ ]) or ""
1274
+
1275
+ customer_name = "FAMILIA FINE FOODS"
1276
+
1277
+ address = self.extract_text([
1278
+ r'(\d+\s+NORTH\s+SERVICE\s+RD)',
1279
+ ]) or "3 NORTH SERVICE RD ST. CATHARINES, ON"
1280
+
1281
+ membership = self.extract_text([
1282
+ r'Membership Number[:\s]*(\d+)',
1283
+ ])
1284
+
1285
+ items = self._extract_costco_items_ultra()
1286
+
1287
+ subtotal = self.extract_amount([
1288
+ r'Subtotal\s*\(\d+\s*Items\)\s*\$\s*([\d,]+\.?\d*)',
1289
+ r'Subtotal[^\$]*\$\s*([\d,]+\.?\d*)',
1290
+ ]) or 0.0
1291
+
1292
+ hst = self.extract_amount([
1293
+ r'HST\s*\(H\)\s*\$\s*([\d,]+\.?\d*)',
1294
+ ]) or 0.0
1295
+
1296
+ total = self.extract_amount([
1297
+ r'Invoice Total\s*\$\s*([\d,]+\.?\d*)',
1298
+ r'Order Total\s*\$\s*([\d,]+\.?\d*)',
1299
+ ]) or 0.0
1300
+
1301
+ return Invoice(
1302
+ vendor="Costco",
1303
+ issuer=issuer,
1304
+ date=date,
1305
+ transaction_id=transaction_id,
1306
+ customer_name=customer_name,
1307
+ issuer_address=address,
1308
+ gst_hst_number=gst_hst,
1309
+ invoice_number=membership or transaction_id,
1310
+ items=items,
1311
+ subtotal=subtotal,
1312
+ hst=hst,
1313
+ total=total,
1314
+ raw_text=self.raw_text,
1315
+ confidence=95.0 if len(items) > 30 else 85.0
1316
+ )
1317
+
1318
+ def _extract_costco_items_ultra(self) -> List[InvoiceItem]:
1319
+ """Extractor ultra-robusto para items de Costco"""
1320
+ items = []
1321
+ item_matches = []
1322
+
1323
+ # Encontrar todos los SKUs
1324
+ for i, line in enumerate(self.lines):
1325
+ match = re.search(r'Item\s+(\d+)\s+\$\s*([\d,]+\.?\d*)', line)
1326
+ if match:
1327
+ item_matches.append({
1328
+ 'line_index': i,
1329
+ 'sku': match.group(1),
1330
+ 'unit_price': float(match.group(2).replace(',', ''))
1331
+ })
1332
+
1333
+ print(f"DEBUG: Encontrados {len(item_matches)} SKUs")
1334
+
1335
+ # Extraer cada item
1336
+ for item_data in item_matches:
1337
+ i = item_data['line_index']
1338
+ sku = item_data['sku']
1339
+ unit_price = item_data['unit_price']
1340
+
1341
+ description = ""
1342
+ quantity = 0.0
1343
+ line_total = 0.0
1344
+ tax_code = ""
1345
+ status = ""
1346
+
1347
+ search_start = max(0, i - 5)
1348
+ search_lines = self.lines[search_start:i]
1349
+
1350
+ # Buscar patrón completo
1351
+ for prev_line in reversed(search_lines):
1352
+ pattern = r'^(.+?)\s+(\d+(?:\.\d+)?)\s+(\d+(?:\.\d+)?)\s+(Delivered|Cancelled|Shipped)\s*(\(H\))?\s*\$\s*([\d,]+\.?\d*)\s+\$\s*([\d,]+\.?\d*)$'
1353
+ match = re.search(pattern, prev_line)
1354
+
1355
+ if match:
1356
+ description = match.group(1).strip()
1357
+ quantity = float(match.group(3))
1358
+ status = match.group(4)
1359
+ tax_code = match.group(5).strip('()') if match.group(5) else ""
1360
+ line_total = float(match.group(7).replace(',', ''))
1361
+ break
1362
+
1363
+ # Buscar descripción si no se encontró
1364
+ if not description:
1365
+ for prev_line in reversed(search_lines):
1366
+ if re.match(r'^[A-Z][A-Za-z\s,\.%-]+', prev_line) and len(prev_line) > 10:
1367
+ desc_match = re.match(r'^([A-Za-z\s,\.%-]+?)(?:\s+\d|\s+$|$)', prev_line)
1368
+ if desc_match:
1369
+ potential_desc = desc_match.group(1).strip()
1370
+ if potential_desc and not re.match(r'^(Item|Order|Status|Qty)', potential_desc):
1371
+ description = potential_desc
1372
+ break
1373
+
1374
+ # Buscar cantidades
1375
+ if not quantity:
1376
+ combined = ' '.join(search_lines)
1377
+ qty_pattern = r'(\d+(?:\.\d+)?)\s+(\d+(?:\.\d+)?)\s+(Delivered|Cancelled|Shipped)\s*(\(H\))?\s*\$\s*([\d,]+\.?\d*)\s+\$\s*([\d,]+\.?\d*)'
1378
+ qty_match = re.search(qty_pattern, combined)
1379
+
1380
+ if qty_match:
1381
+ quantity = float(qty_match.group(2))
1382
+ status = qty_match.group(3)
1383
+ tax_code = qty_match.group(4).strip('()') if qty_match.group(4) else ""
1384
+ line_total = float(qty_match.group(6).replace(',', ''))
1385
+
1386
+ # Búsqueda simple
1387
+ if description and not quantity:
1388
+ for prev_line in search_lines:
1389
+ simple = re.search(r'(\d+(?:\.\d+)?)\s+(\d+(?:\.\d+)?)\s+Delivered', prev_line)
1390
+ if simple:
1391
+ quantity = float(simple.group(2))
1392
+ status = "Delivered"
1393
+ totals = re.findall(r'\$\s*([\d,]+\.?\d*)', prev_line)
1394
+ if len(totals) >= 2:
1395
+ line_total = float(totals[-1].replace(',', ''))
1396
+ break
1397
+
1398
+ # Agregar item
1399
+ if description and status:
1400
+ if status.lower() == 'cancelled':
1401
+ print(f"DEBUG: Item {sku} cancelado")
1402
+ continue
1403
+
1404
+ if line_total == 0 and quantity > 0:
1405
+ line_total = quantity * unit_price
1406
+
1407
+ if quantity == 0 and line_total > 0:
1408
+ quantity = line_total / unit_price if unit_price > 0 else 1.0
1409
+
1410
+ items.append(InvoiceItem(
1411
+ sku=sku,
1412
+ description=description,
1413
+ quantity=quantity if quantity > 0 else 1.0,
1414
+ unit_price=unit_price,
1415
+ amount=line_total if line_total > 0 else unit_price,
1416
+ tax_code=tax_code
1417
+ ))
1418
+
1419
+ print(f"DEBUG: Item {sku}: {description[:30]}... qty={quantity}")
1420
+ else:
1421
+ print(f"DEBUG: Item {sku} - datos incompletos")
1422
+
1423
+ return items
1424
+
1425
+ # ==== FACTORY ====
1426
+ class ExtractorFactory:
1427
+ """Factory para crear extractores"""
1428
+
1429
+ EXTRACTORS = {
1430
+ "A1 Cash and Carry_Fisico": A1PatternExtractor,
1431
+ "Costco_Formato1": CostcoPatternExtractor,
1432
+ "Costco_Formato2": Costco2PatternExtractor,
1433
+ "Default": DefaultAzureExtractor,
1434
+ }
1435
+
1436
+ @classmethod
1437
+ def create_extractor(cls, vendor: str, raw_text: str, text_blocks: List[Dict] = None):
1438
+ """Crea el extractor apropiado"""
1439
+ extractor_class = cls.EXTRACTORS.get(vendor)
1440
+
1441
+ # Obtener configuración OCR para el vendor
1442
+ ocr_config = {}
1443
+ for vendor_enum, config in VENDOR_OCR_CONFIG.items():
1444
+ if vendor_enum.value == vendor:
1445
+ ocr_config = config
1446
+ break
1447
+
1448
+ if extractor_class:
1449
+ return extractor_class(raw_text, text_blocks, ocr_config)
1450
+ return A1PatternExtractor(raw_text, text_blocks, ocr_config)
1451
+
1452
+ @classmethod
1453
+ def get_supported_vendors(cls) -> List[str]:
1454
+ """Retorna vendors soportados"""
1455
+ return list(cls.EXTRACTORS.keys())
1456
+
1457
+
1458
+ # ==== GESTOR DE ESQUEMAS DE PROVEEDORES ====
1459
+ class VendorSchemaManager:
1460
+ """Maneja los esquemas de diferentes proveedores."""
1461
+
1462
+ # Definición de la lista de proveedores disponibles como atributo de clase
1463
+ vendor_list: List[Vendor] = [Vendor.A1, Vendor.COSTCO, Vendor.COSTCO2, Vendor.DEFAULT]
1464
+
1465
+ def __init__(self):
1466
+ # No necesitamos esquemas JSON ya que usamos extractores de patrones
1467
+ pass
1468
+
1469
+ def get_ocr_config(self, vendor: Vendor) -> Dict:
1470
+ """Obtiene la configuración OCR para un proveedor específico."""
1471
+ return VENDOR_OCR_CONFIG.get(vendor, {"engine": "easyocr", "mode": "block"})
1472
+
1473
+ def get_vendor_list(self) -> List[Dict]:
1474
+ """Obtiene la lista de proveedores para el frontend."""
1475
+ return [
1476
+ {"id": v.value, "name": v.value, "description": f"Facturas de {v.value}"}
1477
+ for v in self.vendor_list
1478
+ ]