Initial commit
This commit is contained in:
0
app/services/__init__.py
Normal file
0
app/services/__init__.py
Normal file
196
app/services/albaran_processor.py
Normal file
196
app/services/albaran_processor.py
Normal 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
180
app/services/ocr_service.py
Normal 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}")
|
||||
|
||||
Reference in New Issue
Block a user