Initial commit

This commit is contained in:
2025-12-05 11:27:16 -03:00
commit 804bacfbe3
87 changed files with 7260 additions and 0 deletions

0
app/services/__init__.py Normal file
View File

View File

@@ -0,0 +1,196 @@
"""
Procesador de albaranes con OCR y vinculación automática
"""
from pathlib import Path
from typing import Dict, Optional, List
from datetime import datetime
from prisma import Prisma
from app.services.ocr_service import OCRService
class AlbaranProcessor:
"""Procesa albaranes y los vincula con pedidos pendientes"""
def __init__(self, db: Prisma):
self.db = db
self.ocr_service = OCRService()
async def _find_proveedor(self, datos: Dict) -> Optional[int]:
"""Busca el proveedor basándose en los datos del albarán"""
nombre = datos.get('proveedor', {}).get('nombre', '').strip()
if nombre:
proveedor = await self.db.proveedor.find_first(
where={"nombre": {"contains": nombre, "mode": "insensitive"}}
)
if proveedor:
return proveedor.id
return None
def _parse_fecha(self, fecha_str: str) -> Optional[datetime]:
"""Parsea una fecha desde string"""
if not fecha_str:
return None
from datetime import datetime
formatos = [
'%Y-%m-%d',
'%d/%m/%Y',
'%d-%m-%Y',
'%Y/%m/%d',
]
for fmt in formatos:
try:
return datetime.strptime(fecha_str, fmt).date()
except ValueError:
continue
return None
async def _match_referencias(
self,
referencias_albaran: List[Dict],
proveedor_id: int
) -> Dict[str, int]:
"""
Busca referencias del albarán en pedidos pendientes del proveedor
Returns:
Dict mapping referencia -> referencia_pedido_proveedor_id
"""
matches = {}
# Obtener todas las referencias pendientes del proveedor
pedidos_pendientes = await self.db.pedidoproveedor.find_many(
where={
"proveedorId": proveedor_id,
"estado": {"in": ["pendiente_recepcion", "parcial"]}
},
include={"referencias": True}
)
for pedido in pedidos_pendientes:
for ref_pedido in pedido.referencias:
if ref_pedido.estado in ["pendiente", "parcial"]:
# Buscar coincidencia en el albarán
for ref_albaran in referencias_albaran:
if ref_albaran['referencia'].strip().upper() == ref_pedido.referencia.strip().upper():
if ref_pedido.referencia not in matches:
matches[ref_pedido.referencia] = ref_pedido.id
break
return matches
async def match_and_update_referencias(self, albaran):
"""Vincula y actualiza referencias del albarán con pedidos pendientes"""
if not albaran.proveedorId:
return
referencias_albaran = await self.db.referenciaalbaran.find_many(
where={"albaranId": albaran.id}
)
matches = await self._match_referencias(
[{"referencia": ref.referencia} for ref in referencias_albaran],
albaran.proveedorId
)
for ref_albaran in referencias_albaran:
ref_pedido_proveedor_id = matches.get(ref_albaran.referencia.strip().upper())
if ref_pedido_proveedor_id:
# Actualizar referencia albarán
await self.db.referenciaalbaran.update(
where={"id": ref_albaran.id},
data={"referenciaPedidoProveedorId": ref_pedido_proveedor_id}
)
# Actualizar pedido proveedor
ref_pedido = await self.db.referenciapedidoproveedor.find_unique(
where={"id": ref_pedido_proveedor_id}
)
nuevas_unidades_recibidas = ref_pedido.unidadesRecibidas + ref_albaran.unidades
nuevo_estado = "recibido"
if nuevas_unidades_recibidas < ref_pedido.unidadesPedidas:
nuevo_estado = "parcial" if nuevas_unidades_recibidas > 0 else "pendiente"
await self.db.referenciapedidoproveedor.update(
where={"id": ref_pedido_proveedor_id},
data={
"unidadesRecibidas": nuevas_unidades_recibidas,
"estado": nuevo_estado
}
)
# Actualizar referencia pedido cliente
if ref_pedido.referenciaPedidoClienteId:
ref_cliente = await self.db.referenciapedidocliente.find_unique(
where={"id": ref_pedido.referenciaPedidoClienteId}
)
await self.db.referenciapedidocliente.update(
where={"id": ref_cliente.id},
data={
"unidadesEnStock": ref_cliente.unidadesEnStock + ref_albaran.unidades,
"unidadesPendientes": max(0, ref_cliente.unidadesSolicitadas - (ref_cliente.unidadesEnStock + ref_albaran.unidades))
}
)
async def process_albaran_file(self, file_path: Path):
"""
Procesa un archivo de albarán (imagen o PDF)
Returns:
Albaran creado
"""
# Procesar con OCR
datos = self.ocr_service.process_albaran(file_path)
# Buscar proveedor
proveedor_id = await self._find_proveedor(datos)
# Parsear fecha
fecha_albaran = self._parse_fecha(datos.get('fecha_albaran', ''))
# Crear albarán
albaran = await self.db.albaran.create(
data={
"proveedorId": proveedor_id,
"numeroAlbaran": datos.get('numero_albaran', '').strip() or None,
"fechaAlbaran": fecha_albaran,
"archivoPath": str(file_path),
"estadoProcesado": "procesado" if proveedor_id else "clasificacion",
"fechaProcesado": datetime.now() if proveedor_id else None,
"datosOcr": datos,
"referencias": {
"create": [
{
"referencia": ref_data.get('referencia', '').strip(),
"denominacion": ref_data.get('denominacion', '').strip(),
"unidades": int(ref_data.get('unidades', 1)),
"precioUnitario": float(ref_data.get('precio_unitario', 0)),
"impuestoTipo": ref_data.get('impuesto_tipo', '21'),
"impuestoValor": float(ref_data.get('impuesto_valor', 0)),
}
for ref_data in datos.get('referencias', [])
]
}
},
include={"proveedor": True, "referencias": True}
)
# Vincular referencias si hay proveedor
if proveedor_id:
await self.match_and_update_referencias(albaran)
# Recargar albarán con referencias actualizadas
albaran = await self.db.albaran.find_unique(
where={"id": albaran.id},
include={"proveedor": True, "referencias": True}
)
return albaran

180
app/services/ocr_service.py Normal file
View File

@@ -0,0 +1,180 @@
"""
Servicio de OCR usando GPT-4 Vision API
"""
import base64
import json
from pathlib import Path
from typing import Dict
from openai import OpenAI
from app.config import settings
from PIL import Image
import io
class OCRService:
"""Servicio para procesar imágenes y PDFs con GPT-4 Vision"""
def __init__(self):
self.client = OpenAI(api_key=settings.OPENAI_API_KEY)
def _encode_image(self, image_path: Path) -> str:
"""Codifica una imagen en base64"""
with open(image_path, "rb") as image_file:
return base64.b64encode(image_file.read()).decode('utf-8')
def _encode_pil_image(self, image: Image.Image) -> str:
"""Codifica una imagen PIL en base64"""
buffered = io.BytesIO()
image.save(buffered, format="PNG")
return base64.b64encode(buffered.getvalue()).decode('utf-8')
def process_pdf_pedido_cliente(self, pdf_path: Path) -> Dict:
"""
Procesa un PDF de pedido de cliente y extrae la información
Returns:
Dict con: numero_pedido, matricula, fecha_cita, referencias (lista)
"""
from pdf2image import convert_from_path
# Convertir PDF a imágenes
images = convert_from_path(pdf_path, dpi=200)
if not images:
raise ValueError("No se pudieron extraer imágenes del PDF")
# Procesar la primera página (o todas si es necesario)
image = images[0]
base64_image = self._encode_pil_image(image)
prompt = """
Analiza este documento de pedido de cliente de recambios. Extrae la siguiente información en formato JSON:
{
"numero_pedido": "número único del pedido",
"matricula": "matrícula del vehículo",
"fecha_cita": "fecha de la cita en formato YYYY-MM-DD o YYYY-MM-DD HH:MM",
"referencias": [
{
"referencia": "código de la referencia",
"denominacion": "descripción/nombre de la pieza",
"unidades": número de unidades
}
]
}
Si algún campo no está disponible, usa null. La fecha debe estar en formato ISO si es posible.
"""
response = self.client.chat.completions.create(
model="gpt-4o",
messages=[
{
"role": "user",
"content": [
{"type": "text", "text": prompt},
{
"type": "image_url",
"image_url": {
"url": f"data:image/png;base64,{base64_image}"
}
}
]
}
],
max_tokens=2000
)
content = response.choices[0].message.content
# Extraer JSON de la respuesta
try:
json_start = content.find('{')
json_end = content.rfind('}') + 1
if json_start != -1 and json_end > json_start:
json_str = content[json_start:json_end]
return json.loads(json_str)
else:
raise ValueError("No se encontró JSON en la respuesta")
except json.JSONDecodeError as e:
raise ValueError(f"Error al parsear JSON: {e}. Respuesta: {content}")
def process_albaran(self, image_path: Path) -> Dict:
"""
Procesa un albarán y extrae la información
Returns:
Dict con: proveedor (nombre o número), numero_albaran, fecha_albaran,
referencias (lista con precios e impuestos)
"""
base64_image = self._encode_image(image_path)
prompt = """
Analiza este albarán de proveedor. Extrae la siguiente información en formato JSON:
{
"proveedor": {
"nombre": "nombre del proveedor",
"numero": "número de proveedor si está visible"
},
"numero_albaran": "número del albarán",
"fecha_albaran": "fecha del albarán en formato YYYY-MM-DD",
"referencias": [
{
"referencia": "código de la referencia",
"denominacion": "descripción de la pieza",
"unidades": número de unidades,
"precio_unitario": precio por unidad (número decimal),
"impuesto_tipo": "21", "10", "7", "4", "3" o "0" según el porcentaje de IVA,
"impuesto_valor": valor del impuesto (número decimal)
}
],
"totales": {
"base_imponible": total base imponible,
"iva_21": total IVA al 21%,
"iva_10": total IVA al 10%,
"iva_7": total IVA al 7%,
"iva_4": total IVA al 4%,
"iva_3": total IVA al 3%,
"total": total general
}
}
IMPORTANTE:
- Si hay múltiples tipos de impuestos, identifica qué referencias tienen cada tipo
- Si el impuesto no está claro por referencia, intenta calcularlo basándote en los totales
- Si algún campo no está disponible, usa null
"""
response = self.client.chat.completions.create(
model="gpt-4o",
messages=[
{
"role": "user",
"content": [
{"type": "text", "text": prompt},
{
"type": "image_url",
"image_url": {
"url": f"data:image/png;base64,{base64_image}"
}
}
]
}
],
max_tokens=3000
)
content = response.choices[0].message.content
try:
json_start = content.find('{')
json_end = content.rfind('}') + 1
if json_start != -1 and json_end > json_start:
json_str = content[json_start:json_end]
return json.loads(json_str)
else:
raise ValueError("No se encontró JSON en la respuesta")
except json.JSONDecodeError as e:
raise ValueError(f"Error al parsear JSON: {e}. Respuesta: {content}")