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/__init__.py Normal file
View File

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

11
app/api/dependencies.py Normal file
View File

@@ -0,0 +1,11 @@
"""
Dependencies para FastAPI
"""
from app.prisma_client import get_db
from prisma import Prisma
async def get_prisma() -> Prisma:
"""Dependency para obtener el cliente Prisma"""
return await get_db()

View File

@@ -0,0 +1,13 @@
from fastapi import APIRouter
from . import clientes, pedidos_cliente, proveedores, albaranes, kanban, alertas, referencias_proveedor
api_router = APIRouter()
api_router.include_router(clientes.router)
api_router.include_router(pedidos_cliente.router)
api_router.include_router(proveedores.router)
api_router.include_router(albaranes.router)
api_router.include_router(kanban.router)
api_router.include_router(alertas.router)
api_router.include_router(referencias_proveedor.router)

104
app/api/routes/albaranes.py Normal file
View File

@@ -0,0 +1,104 @@
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
from prisma import Prisma
from typing import List, Optional
from datetime import datetime
from pathlib import Path
from app.models.albaran import AlbaranCreate, AlbaranUpdate, AlbaranResponse
from app.api.dependencies import get_prisma
from app.config import settings
from app.services.albaran_processor import AlbaranProcessor
router = APIRouter(prefix="/albaranes", tags=["albaranes"])
@router.get("/", response_model=List[AlbaranResponse])
async def listar_albaranes(
skip: int = 0,
limit: int = 100,
estado_procesado: Optional[str] = None,
db: Prisma = Depends(get_prisma)
):
"""Listar todos los albaranes"""
where = {}
if estado_procesado:
where["estadoProcesado"] = estado_procesado
albaranes = await db.albaran.find_many(
where=where,
skip=skip,
take=limit,
include={"proveedor": True, "referencias": True},
order_by={"createdAt": "desc"}
)
return albaranes
@router.get("/{albaran_id}", response_model=AlbaranResponse)
async def obtener_albaran(
albaran_id: int,
db: Prisma = Depends(get_prisma)
):
"""Obtener un albarán por ID"""
albaran = await db.albaran.find_unique(
where={"id": albaran_id},
include={"proveedor": True, "referencias": True}
)
if not albaran:
raise HTTPException(status_code=404, detail="Albarán no encontrado")
return albaran
@router.post("/upload", response_model=AlbaranResponse, status_code=201)
async def subir_albaran(
archivo: UploadFile = File(...),
db: Prisma = Depends(get_prisma)
):
"""Subir un albarán desde móvil o web"""
# Guardar archivo
upload_dir = settings.ALBARANES_ESCANEADOS_DIR
file_path = upload_dir / archivo.filename
with open(file_path, "wb") as f:
content = await archivo.read()
f.write(content)
# Procesar
try:
processor = AlbaranProcessor(db)
albaran = await processor.process_albaran_file(file_path)
return albaran
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
@router.post("/{albaran_id}/vincular-proveedor", response_model=AlbaranResponse)
async def vincular_proveedor(
albaran_id: int,
proveedor_id: int = Query(...),
db: Prisma = Depends(get_prisma)
):
"""Vincula un albarán a un proveedor manualmente"""
albaran = await db.albaran.find_unique(where={"id": albaran_id})
if not albaran:
raise HTTPException(status_code=404, detail="Albarán no encontrado")
proveedor = await db.proveedor.find_unique(where={"id": proveedor_id})
if not proveedor:
raise HTTPException(status_code=404, detail="Proveedor no encontrado")
albaran_actualizado = await db.albaran.update(
where={"id": albaran_id},
data={
"proveedorId": proveedor_id,
"estadoProcesado": "procesado",
"fechaProcesado": datetime.now()
},
include={"proveedor": True, "referencias": True}
)
# Reprocesar para vincular referencias
processor = AlbaranProcessor(db)
await processor.match_and_update_referencias(albaran_actualizado)
return albaran_actualizado

50
app/api/routes/alertas.py Normal file
View File

@@ -0,0 +1,50 @@
from fastapi import APIRouter, Depends
from prisma import Prisma
from typing import List, Dict
from datetime import datetime, timedelta
from app.api.dependencies import get_prisma
router = APIRouter(prefix="/alertas", tags=["alertas"])
@router.get("/")
async def obtener_alertas(
db: Prisma = Depends(get_prisma)
) -> List[Dict]:
"""Obtener alertas de pedidos urgentes (menos de 12 horas)"""
ahora = datetime.now()
limite = ahora + timedelta(hours=12)
pedidos_urgentes = await db.pedidocliente.find_many(
where={
"fechaCita": {
"gte": ahora,
"lte": limite
},
"estado": {
"in": ["pendiente_revision", "en_revision", "pendiente_materiales"]
}
},
include={
"cliente": True,
"referencias": True
}
)
alertas = []
for pedido in pedidos_urgentes:
referencias_faltantes = [
ref.dict() for ref in pedido.referencias
if ref.unidadesPendientes > 0
]
if referencias_faltantes:
tiempo_restante = (pedido.fechaCita - ahora).total_seconds() / 3600
alertas.append({
"pedido": pedido.dict(),
"referencias_faltantes": referencias_faltantes,
"horas_restantes": tiempo_restante
})
return alertas

View File

@@ -0,0 +1,77 @@
from fastapi import APIRouter, Depends, HTTPException
from prisma import Prisma
from typing import List
from app.models.cliente import ClienteCreate, ClienteUpdate, ClienteResponse
from app.api.dependencies import get_prisma
router = APIRouter(prefix="/clientes", tags=["clientes"])
@router.get("/", response_model=List[ClienteResponse])
async def listar_clientes(
skip: int = 0,
limit: int = 100,
db: Prisma = Depends(get_prisma)
):
"""Listar todos los clientes"""
clientes = await db.cliente.find_many(skip=skip, take=limit)
return clientes
@router.get("/{cliente_id}", response_model=ClienteResponse)
async def obtener_cliente(
cliente_id: int,
db: Prisma = Depends(get_prisma)
):
"""Obtener un cliente por ID"""
cliente = await db.cliente.find_unique(where={"id": cliente_id})
if not cliente:
raise HTTPException(status_code=404, detail="Cliente no encontrado")
return cliente
@router.post("/", response_model=ClienteResponse, status_code=201)
async def crear_cliente(
cliente: ClienteCreate,
db: Prisma = Depends(get_prisma)
):
"""Crear un nuevo cliente"""
try:
nuevo_cliente = await db.cliente.create(data=cliente.dict())
return nuevo_cliente
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
@router.put("/{cliente_id}", response_model=ClienteResponse)
async def actualizar_cliente(
cliente_id: int,
cliente: ClienteUpdate,
db: Prisma = Depends(get_prisma)
):
"""Actualizar un cliente"""
cliente_existente = await db.cliente.find_unique(where={"id": cliente_id})
if not cliente_existente:
raise HTTPException(status_code=404, detail="Cliente no encontrado")
data = {k: v for k, v in cliente.dict().items() if v is not None}
cliente_actualizado = await db.cliente.update(
where={"id": cliente_id},
data=data
)
return cliente_actualizado
@router.delete("/{cliente_id}", status_code=204)
async def eliminar_cliente(
cliente_id: int,
db: Prisma = Depends(get_prisma)
):
"""Eliminar un cliente"""
cliente = await db.cliente.find_unique(where={"id": cliente_id})
if not cliente:
raise HTTPException(status_code=404, detail="Cliente no encontrado")
await db.cliente.delete(where={"id": cliente_id})
return None

47
app/api/routes/kanban.py Normal file
View File

@@ -0,0 +1,47 @@
from fastapi import APIRouter, Depends
from prisma import Prisma
from typing import Dict, List
from app.api.dependencies import get_prisma
from app.models.pedido_cliente import PedidoClienteResponse
router = APIRouter(prefix="/kanban", tags=["kanban"])
@router.get("/")
async def obtener_kanban(
db: Prisma = Depends(get_prisma)
) -> Dict[str, List[Dict]]:
"""Obtener datos del Kanban agrupados por estado"""
pedidos = await db.pedidocliente.find_many(
include={
"cliente": True,
"referencias": True
},
order_by={"fechaPedido": "desc"}
)
# Agrupar por estado
kanban_data = {
"pendiente_revision": [],
"en_revision": [],
"pendiente_materiales": [],
"completado": [],
}
for pedido in pedidos:
pedido_dict = pedido.dict()
# Calcular es_urgente
from datetime import datetime, timedelta
if pedido.fechaCita:
ahora = datetime.now(pedido.fechaCita.tzinfo) if pedido.fechaCita.tzinfo else datetime.now()
tiempo_restante = pedido.fechaCita - ahora
pedido_dict["es_urgente"] = 0 < tiempo_restante.total_seconds() < 12 * 3600
else:
pedido_dict["es_urgente"] = False
estado = pedido.estado
if estado in kanban_data:
kanban_data[estado].append(pedido_dict)
return kanban_data

View File

@@ -0,0 +1,246 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from prisma import Prisma
from typing import List, Optional
from datetime import datetime, timedelta
from app.models.pedido_cliente import (
PedidoClienteCreate, PedidoClienteUpdate, PedidoClienteResponse,
ReferenciaPedidoClienteUpdate
)
from app.api.dependencies import get_prisma
router = APIRouter(prefix="/pedidos-cliente", tags=["pedidos-cliente"])
def calcular_estado_referencia(unidades_solicitadas: int, unidades_en_stock: int) -> str:
"""Calcula el estado de una referencia"""
unidades_pendientes = max(0, unidades_solicitadas - unidades_en_stock)
if unidades_pendientes <= 0:
return "completo"
elif unidades_pendientes < unidades_solicitadas:
return "parcial"
return "pendiente"
def es_urgente(fecha_cita: Optional[datetime]) -> bool:
"""Verifica si un pedido es urgente (menos de 12 horas)"""
if not fecha_cita:
return False
ahora = datetime.now(fecha_cita.tzinfo) if fecha_cita.tzinfo else datetime.now()
tiempo_restante = fecha_cita - ahora
return 0 < tiempo_restante.total_seconds() < 12 * 3600
@router.get("/", response_model=List[PedidoClienteResponse])
async def listar_pedidos(
skip: int = 0,
limit: int = 100,
estado: Optional[str] = None,
urgente: Optional[bool] = None,
matricula: Optional[str] = None,
db: Prisma = Depends(get_prisma)
):
"""Listar pedidos de cliente"""
where = {}
if estado:
where["estado"] = estado
if matricula:
where["cliente"] = {"matriculaVehiculo": {"contains": matricula, "mode": "insensitive"}}
pedidos = await db.pedidocliente.find_many(
where=where,
skip=skip,
take=limit,
include={
"cliente": True,
"referencias": True
},
order_by={"fechaPedido": "desc"}
)
# Filtrar por urgente si se solicita
if urgente is not None:
pedidos = [p for p in pedidos if es_urgente(p.fechaCita) == urgente]
# Agregar es_urgente a cada pedido
result = []
for pedido in pedidos:
pedido_dict = pedido.dict()
pedido_dict["es_urgente"] = es_urgente(pedido.fechaCita)
result.append(pedido_dict)
return result
@router.get("/{pedido_id}", response_model=PedidoClienteResponse)
async def obtener_pedido(
pedido_id: int,
db: Prisma = Depends(get_prisma)
):
"""Obtener un pedido por ID"""
pedido = await db.pedidocliente.find_unique(
where={"id": pedido_id},
include={"cliente": True, "referencias": True}
)
if not pedido:
raise HTTPException(status_code=404, detail="Pedido no encontrado")
pedido_dict = pedido.dict()
pedido_dict["es_urgente"] = es_urgente(pedido.fechaCita)
return pedido_dict
@router.post("/", response_model=PedidoClienteResponse, status_code=201)
async def crear_pedido(
pedido: PedidoClienteCreate,
db: Prisma = Depends(get_prisma)
):
"""Crear un nuevo pedido de cliente"""
try:
# Verificar que el cliente existe
cliente = await db.cliente.find_unique(where={"id": pedido.cliente_id})
if not cliente:
raise HTTPException(status_code=404, detail="Cliente no encontrado")
# Crear pedido
referencias_data = pedido.referencias or []
pedido_data = pedido.dict(exclude={"referencias"})
# Convertir snake_case a camelCase para Prisma
pedido_data_prisma = {
"numeroPedido": pedido_data["numero_pedido"],
"clienteId": pedido_data["cliente_id"],
"fechaCita": pedido_data.get("fecha_cita"),
"estado": pedido_data["estado"],
"presupuestoId": pedido_data.get("presupuesto_id"),
"archivoPdfPath": pedido_data.get("archivo_pdf_path"),
"referencias": {
"create": [
{
"referencia": ref.referencia,
"denominacion": ref.denominacion,
"unidadesSolicitadas": ref.unidades_solicitadas,
"unidadesEnStock": ref.unidades_en_stock,
"unidadesPendientes": max(0, ref.unidades_solicitadas - ref.unidades_en_stock),
"estado": calcular_estado_referencia(ref.unidades_solicitadas, ref.unidades_en_stock)
}
for ref in referencias_data
]
}
}
nuevo_pedido = await db.pedidocliente.create(
data=pedido_data_prisma,
include={"cliente": True, "referencias": True}
)
nuevo_pedido_dict = nuevo_pedido.dict()
nuevo_pedido_dict["es_urgente"] = es_urgente(nuevo_pedido.fechaCita)
return nuevo_pedido_dict
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
@router.put("/{pedido_id}", response_model=PedidoClienteResponse)
async def actualizar_pedido(
pedido_id: int,
pedido: PedidoClienteUpdate,
db: Prisma = Depends(get_prisma)
):
"""Actualizar un pedido"""
pedido_existente = await db.pedidocliente.find_unique(where={"id": pedido_id})
if not pedido_existente:
raise HTTPException(status_code=404, detail="Pedido no encontrado")
data = {k: v for k, v in pedido.dict().items() if v is not None}
# Convertir snake_case a camelCase
data_prisma = {}
field_mapping = {
"numero_pedido": "numeroPedido",
"cliente_id": "clienteId",
"fecha_cita": "fechaCita",
"presupuesto_id": "presupuestoId",
"archivo_pdf_path": "archivoPdfPath"
}
for key, value in data.items():
prisma_key = field_mapping.get(key, key)
data_prisma[prisma_key] = value
pedido_actualizado = await db.pedidocliente.update(
where={"id": pedido_id},
data=data_prisma,
include={"cliente": True, "referencias": True}
)
pedido_dict = pedido_actualizado.dict()
pedido_dict["es_urgente"] = es_urgente(pedido_actualizado.fechaCita)
return pedido_dict
@router.post("/{pedido_id}/actualizar-estado")
async def actualizar_estado_pedido(
pedido_id: int,
estado: str,
db: Prisma = Depends(get_prisma)
):
"""Actualizar el estado de un pedido"""
estados_validos = ["pendiente_revision", "en_revision", "pendiente_materiales", "completado"]
if estado not in estados_validos:
raise HTTPException(status_code=400, detail="Estado inválido")
pedido = await db.pedidocliente.find_unique(where={"id": pedido_id})
if not pedido:
raise HTTPException(status_code=404, detail="Pedido no encontrado")
await db.pedidocliente.update(
where={"id": pedido_id},
data={"estado": estado}
)
return {"status": "Estado actualizado"}
@router.post("/referencias/{referencia_id}/marcar-stock")
async def marcar_stock_referencia(
referencia_id: int,
unidades_en_stock: int,
db: Prisma = Depends(get_prisma)
):
"""Marcar unidades en stock para una referencia"""
referencia = await db.referenciapedidocliente.find_unique(
where={"id": referencia_id},
include={"pedidoCliente": True}
)
if not referencia:
raise HTTPException(status_code=404, detail="Referencia no encontrada")
unidades_en_stock = max(0, unidades_en_stock)
unidades_pendientes = max(0, referencia.unidadesSolicitadas - unidades_en_stock)
estado = calcular_estado_referencia(referencia.unidadesSolicitadas, unidades_en_stock)
referencia_actualizada = await db.referenciapedidocliente.update(
where={"id": referencia_id},
data={
"unidadesEnStock": unidades_en_stock,
"unidadesPendientes": unidades_pendientes,
"estado": estado
}
)
# Verificar si todas las referencias están completas
todas_referencias = await db.referenciapedidocliente.find_many(
where={"pedidoClienteId": referencia.pedidoClienteId}
)
todas_completas = all(ref.unidadesPendientes == 0 for ref in todas_referencias)
if todas_completas and referencia.pedidoCliente.estado != "completado":
await db.pedidocliente.update(
where={"id": referencia.pedidoClienteId},
data={"estado": "completado"}
)
elif referencia.pedidoCliente.estado == "pendiente_revision":
await db.pedidocliente.update(
where={"id": referencia.pedidoClienteId},
data={"estado": "en_revision"}
)
return referencia_actualizada

View File

@@ -0,0 +1,85 @@
from fastapi import APIRouter, Depends, HTTPException
from prisma import Prisma
from typing import List
from app.models.proveedor import ProveedorCreate, ProveedorUpdate, ProveedorResponse
from app.api.dependencies import get_prisma
router = APIRouter(prefix="/proveedores", tags=["proveedores"])
@router.get("/", response_model=List[ProveedorResponse])
async def listar_proveedores(
skip: int = 0,
limit: int = 100,
activo: bool = True,
db: Prisma = Depends(get_prisma)
):
"""Listar todos los proveedores"""
proveedores = await db.proveedor.find_many(
where={"activo": activo} if activo else {},
skip=skip,
take=limit
)
return proveedores
@router.get("/{proveedor_id}", response_model=ProveedorResponse)
async def obtener_proveedor(
proveedor_id: int,
db: Prisma = Depends(get_prisma)
):
"""Obtener un proveedor por ID"""
proveedor = await db.proveedor.find_unique(where={"id": proveedor_id})
if not proveedor:
raise HTTPException(status_code=404, detail="Proveedor no encontrado")
return proveedor
@router.post("/", response_model=ProveedorResponse, status_code=201)
async def crear_proveedor(
proveedor: ProveedorCreate,
db: Prisma = Depends(get_prisma)
):
"""Crear un nuevo proveedor"""
try:
nuevo_proveedor = await db.proveedor.create(data=proveedor.dict())
return nuevo_proveedor
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
@router.put("/{proveedor_id}", response_model=ProveedorResponse)
async def actualizar_proveedor(
proveedor_id: int,
proveedor: ProveedorUpdate,
db: Prisma = Depends(get_prisma)
):
"""Actualizar un proveedor"""
proveedor_existente = await db.proveedor.find_unique(where={"id": proveedor_id})
if not proveedor_existente:
raise HTTPException(status_code=404, detail="Proveedor no encontrado")
data = {k: v for k, v in proveedor.dict().items() if v is not None}
proveedor_actualizado = await db.proveedor.update(
where={"id": proveedor_id},
data=data
)
return proveedor_actualizado
@router.delete("/{proveedor_id}", status_code=204)
async def eliminar_proveedor(
proveedor_id: int,
db: Prisma = Depends(get_prisma)
):
"""Eliminar un proveedor (soft delete)"""
proveedor = await db.proveedor.find_unique(where={"id": proveedor_id})
if not proveedor:
raise HTTPException(status_code=404, detail="Proveedor no encontrado")
await db.proveedor.update(
where={"id": proveedor_id},
data={"activo": False}
)
return None

View File

@@ -0,0 +1,102 @@
from fastapi import APIRouter, Depends, Query
from prisma import Prisma
from typing import List, Dict, Optional
from app.api.dependencies import get_prisma
router = APIRouter(prefix="/referencias-proveedor", tags=["referencias-proveedor"])
@router.get("/")
async def obtener_referencias_proveedor(
proveedor_id: Optional[int] = Query(None),
db: Prisma = Depends(get_prisma)
) -> List[Dict]:
"""Obtener referencias pendientes por proveedor"""
# Obtener referencias pendientes de pedidos a proveedor
where = {
"estado": {"in": ["pendiente", "parcial"]}
}
if proveedor_id:
where["pedidoProveedor"] = {"proveedorId": proveedor_id}
referencias = await db.referenciapedidoproveedor.find_many(
where=where,
include={
"pedidoProveedor": {
"include": {
"proveedor": True
}
},
"referenciaPedidoCliente": {
"include": {
"pedidoCliente": {
"include": {
"cliente": True
}
}
}
}
}
)
# Agrupar por proveedor
proveedores_data = {}
for ref in referencias:
proveedor = ref.pedidoProveedor.proveedor
if proveedor.id not in proveedores_data:
proveedores_data[proveedor.id] = {
"proveedor": {
"id": proveedor.id,
"nombre": proveedor.nombre,
"email": proveedor.email,
"tiene_web": proveedor.tieneWeb,
"activo": proveedor.activo,
},
"referencias_pendientes": [],
"referencias_devolucion": [],
}
proveedores_data[proveedor.id]["referencias_pendientes"].append({
"id": ref.id,
"referencia": ref.referencia,
"denominacion": ref.denominacion,
"unidades_pedidas": ref.unidadesPedidas,
"unidades_recibidas": ref.unidadesRecibidas,
"estado": ref.estado,
})
# Agregar devoluciones pendientes
devoluciones_where = {"estadoAbono": "pendiente"}
if proveedor_id:
devoluciones_where["proveedorId"] = proveedor_id
devoluciones = await db.devolucion.find_many(
where=devoluciones_where,
include={"proveedor": True}
)
for dev in devoluciones:
if dev.proveedorId not in proveedores_data:
proveedores_data[dev.proveedorId] = {
"proveedor": {
"id": dev.proveedor.id,
"nombre": dev.proveedor.nombre,
"email": dev.proveedor.email,
"tiene_web": dev.proveedor.tieneWeb,
"activo": dev.proveedor.activo,
},
"referencias_pendientes": [],
"referencias_devolucion": [],
}
proveedores_data[dev.proveedorId]["referencias_devolucion"].append({
"id": dev.id,
"referencia": dev.referencia,
"denominacion": dev.denominacion,
"unidades": dev.unidades,
"fecha_devolucion": dev.fechaDevolucion.isoformat() if dev.fechaDevolucion else None,
})
return list(proveedores_data.values())

47
app/config.py Normal file
View File

@@ -0,0 +1,47 @@
"""
Configuración de la aplicación
"""
from pydantic_settings import BaseSettings
from pathlib import Path
import os
class Settings(BaseSettings):
# Base
PROJECT_NAME: str = "Sistema de Gestión de Pedidos"
VERSION: str = "1.0.0"
DEBUG: bool = True
# Database
DATABASE_URL: str = os.getenv(
"DATABASE_URL",
"postgresql://postgres:postgres@localhost:5432/pedidos_clientes"
)
# OpenAI
OPENAI_API_KEY: str = os.getenv("OPENAI_API_KEY", "")
# Paths
BASE_DIR: Path = Path(__file__).resolve().parent.parent
PEDIDOS_CLIENTES_PDF_DIR: Path = BASE_DIR / "pedidos_clientes_pdf"
ALBARANES_ESCANEADOS_DIR: Path = BASE_DIR / "albaranes_escaneados"
EMAILS_PROVEEDORES_DIR: Path = BASE_DIR / "emails_proveedores"
MEDIA_DIR: Path = BASE_DIR / "media"
STATIC_DIR: Path = BASE_DIR / "static"
# CORS
CORS_ORIGINS: list = ["*"]
class Config:
env_file = ".env"
case_sensitive = True
# Crear carpetas si no existen
settings = Settings()
settings.PEDIDOS_CLIENTES_PDF_DIR.mkdir(exist_ok=True)
settings.ALBARANES_ESCANEADOS_DIR.mkdir(exist_ok=True)
settings.EMAILS_PROVEEDORES_DIR.mkdir(exist_ok=True)
settings.MEDIA_DIR.mkdir(exist_ok=True)
settings.STATIC_DIR.mkdir(exist_ok=True)

69
app/main.py Normal file
View File

@@ -0,0 +1,69 @@
"""
Aplicación principal FastAPI
"""
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from fastapi.responses import HTMLResponse
from fastapi.middleware.cors import CORSMiddleware
from contextlib import asynccontextmanager
import logging
from app.config import settings
from app.prisma_client import connect_db, disconnect_db
from app.api.routes import api_router
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Lifecycle events"""
# Startup
logger.info("Iniciando aplicación...")
await connect_db()
yield
# Shutdown
logger.info("Cerrando aplicación...")
await disconnect_db()
app = FastAPI(
title=settings.PROJECT_NAME,
version=settings.VERSION,
lifespan=lifespan
)
# CORS - Permitir todas las origenes en desarrollo
# En producción, especificar los dominios permitidos
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # En producción cambiar a dominios específicos
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Static files (solo para media, el frontend se sirve por separado)
app.mount("/media", StaticFiles(directory=str(settings.MEDIA_DIR)), name="media")
# API Routes
app.include_router(api_router, prefix="/api")
@app.get("/")
async def root():
"""API Root - Redirige a documentación"""
return {
"message": "Sistema de Gestión de Pedidos API",
"version": settings.VERSION,
"docs": "/docs",
"frontend": "El frontend debe ejecutarse por separado en http://localhost:3000 o similar"
}
@app.get("/health")
async def health():
"""Health check"""
return {"status": "ok"}

26
app/models/__init__.py Normal file
View File

@@ -0,0 +1,26 @@
from .cliente import ClienteCreate, ClienteUpdate, ClienteResponse
from .pedido_cliente import (
PedidoClienteCreate, PedidoClienteUpdate, PedidoClienteResponse,
ReferenciaPedidoClienteCreate, ReferenciaPedidoClienteUpdate, ReferenciaPedidoClienteResponse
)
from .proveedor import ProveedorCreate, ProveedorUpdate, ProveedorResponse
from .pedido_proveedor import (
PedidoProveedorCreate, PedidoProveedorUpdate, PedidoProveedorResponse,
ReferenciaPedidoProveedorCreate, ReferenciaPedidoProveedorResponse
)
from .albaran import AlbaranCreate, AlbaranUpdate, AlbaranResponse, ReferenciaAlbaranResponse
from .devolucion import DevolucionCreate, DevolucionUpdate, DevolucionResponse
from .stock import StockReferenciaCreate, StockReferenciaUpdate, StockReferenciaResponse
__all__ = [
"ClienteCreate", "ClienteUpdate", "ClienteResponse",
"PedidoClienteCreate", "PedidoClienteUpdate", "PedidoClienteResponse",
"ReferenciaPedidoClienteCreate", "ReferenciaPedidoClienteUpdate", "ReferenciaPedidoClienteResponse",
"ProveedorCreate", "ProveedorUpdate", "ProveedorResponse",
"PedidoProveedorCreate", "PedidoProveedorUpdate", "PedidoProveedorResponse",
"ReferenciaPedidoProveedorCreate", "ReferenciaPedidoProveedorResponse",
"AlbaranCreate", "AlbaranUpdate", "AlbaranResponse", "ReferenciaAlbaranResponse",
"DevolucionCreate", "DevolucionUpdate", "DevolucionResponse",
"StockReferenciaCreate", "StockReferenciaUpdate", "StockReferenciaResponse",
]

57
app/models/albaran.py Normal file
View File

@@ -0,0 +1,57 @@
from pydantic import BaseModel
from typing import Optional, List, Dict, Any
from datetime import datetime, date
from decimal import Decimal
class ReferenciaAlbaranBase(BaseModel):
referencia: str
denominacion: str
unidades: int = 1
precio_unitario: Decimal = Decimal("0")
impuesto_tipo: str = "21"
impuesto_valor: Decimal = Decimal("0")
class ReferenciaAlbaranResponse(ReferenciaAlbaranBase):
id: int
albaran_id: int
referencia_pedido_proveedor_id: Optional[int] = None
created_at: datetime
class Config:
from_attributes = True
class AlbaranBase(BaseModel):
proveedor_id: Optional[int] = None
numero_albaran: Optional[str] = None
fecha_albaran: Optional[date] = None
archivo_path: str
estado_procesado: str = "pendiente"
class AlbaranCreate(AlbaranBase):
datos_ocr: Optional[Dict[str, Any]] = {}
class AlbaranUpdate(BaseModel):
proveedor_id: Optional[int] = None
numero_albaran: Optional[str] = None
fecha_albaran: Optional[date] = None
estado_procesado: Optional[str] = None
datos_ocr: Optional[Dict[str, Any]] = None
class AlbaranResponse(AlbaranBase):
id: int
fecha_procesado: Optional[datetime] = None
datos_ocr: Dict[str, Any] = {}
created_at: datetime
updated_at: datetime
proveedor: Optional[dict] = None
referencias: Optional[List[ReferenciaAlbaranResponse]] = []
class Config:
from_attributes = True

31
app/models/cliente.py Normal file
View File

@@ -0,0 +1,31 @@
from pydantic import BaseModel, EmailStr
from typing import Optional
from datetime import datetime
class ClienteBase(BaseModel):
nombre: str
matricula_vehiculo: str
telefono: Optional[str] = None
email: Optional[EmailStr] = None
class ClienteCreate(ClienteBase):
pass
class ClienteUpdate(BaseModel):
nombre: Optional[str] = None
matricula_vehiculo: Optional[str] = None
telefono: Optional[str] = None
email: Optional[EmailStr] = None
class ClienteResponse(ClienteBase):
id: int
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True

37
app/models/devolucion.py Normal file
View File

@@ -0,0 +1,37 @@
from pydantic import BaseModel
from typing import Optional
from datetime import datetime
class DevolucionBase(BaseModel):
proveedor_id: int
referencia: str
denominacion: Optional[str] = None
unidades: int = 1
estado_abono: str = "pendiente"
class DevolucionCreate(DevolucionBase):
pass
class DevolucionUpdate(BaseModel):
proveedor_id: Optional[int] = None
referencia: Optional[str] = None
denominacion: Optional[str] = None
unidades: Optional[int] = None
estado_abono: Optional[str] = None
albaran_abono_id: Optional[int] = None
class DevolucionResponse(DevolucionBase):
id: int
fecha_devolucion: datetime
albaran_abono_id: Optional[int] = None
created_at: datetime
updated_at: datetime
proveedor: Optional[dict] = None
class Config:
from_attributes = True

View File

@@ -0,0 +1,70 @@
from pydantic import BaseModel
from typing import Optional, List
from datetime import datetime
class ReferenciaPedidoClienteBase(BaseModel):
referencia: str
denominacion: str
unidades_solicitadas: int = 1
unidades_en_stock: int = 0
estado: str = "pendiente"
class ReferenciaPedidoClienteCreate(ReferenciaPedidoClienteBase):
pass
class ReferenciaPedidoClienteUpdate(BaseModel):
referencia: Optional[str] = None
denominacion: Optional[str] = None
unidades_solicitadas: Optional[int] = None
unidades_en_stock: Optional[int] = None
estado: Optional[str] = None
class ReferenciaPedidoClienteResponse(ReferenciaPedidoClienteBase):
id: int
pedido_cliente_id: int
unidades_pendientes: int
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class PedidoClienteBase(BaseModel):
numero_pedido: str
cliente_id: int
fecha_cita: Optional[datetime] = None
estado: str = "pendiente_revision"
presupuesto_id: Optional[str] = None
archivo_pdf_path: Optional[str] = None
class PedidoClienteCreate(PedidoClienteBase):
referencias: Optional[List[ReferenciaPedidoClienteCreate]] = []
class PedidoClienteUpdate(BaseModel):
numero_pedido: Optional[str] = None
cliente_id: Optional[int] = None
fecha_cita: Optional[datetime] = None
estado: Optional[str] = None
presupuesto_id: Optional[str] = None
archivo_pdf_path: Optional[str] = None
class PedidoClienteResponse(PedidoClienteBase):
id: int
fecha_pedido: datetime
created_at: datetime
updated_at: datetime
cliente: Optional[dict] = None
referencias: Optional[List[ReferenciaPedidoClienteResponse]] = []
es_urgente: bool = False
class Config:
from_attributes = True

View File

@@ -0,0 +1,59 @@
from pydantic import BaseModel
from typing import Optional, List
from datetime import datetime
class ReferenciaPedidoProveedorBase(BaseModel):
referencia: str
denominacion: str
unidades_pedidas: int = 1
estado: str = "pendiente"
class ReferenciaPedidoProveedorCreate(ReferenciaPedidoProveedorBase):
referencia_pedido_cliente_id: Optional[int] = None
class ReferenciaPedidoProveedorResponse(ReferenciaPedidoProveedorBase):
id: int
pedido_proveedor_id: int
referencia_pedido_cliente_id: Optional[int] = None
unidades_recibidas: int = 0
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class PedidoProveedorBase(BaseModel):
proveedor_id: int
numero_pedido: Optional[str] = None
tipo: str = "web"
estado: str = "pendiente_recepcion"
email_confirmacion_path: Optional[str] = None
class PedidoProveedorCreate(PedidoProveedorBase):
referencias: Optional[List[ReferenciaPedidoProveedorCreate]] = []
class PedidoProveedorUpdate(BaseModel):
proveedor_id: Optional[int] = None
numero_pedido: Optional[str] = None
tipo: Optional[str] = None
estado: Optional[str] = None
email_confirmacion_path: Optional[str] = None
class PedidoProveedorResponse(PedidoProveedorBase):
id: int
fecha_pedido: datetime
created_at: datetime
updated_at: datetime
proveedor: Optional[dict] = None
referencias: Optional[List[ReferenciaPedidoProveedorResponse]] = []
class Config:
from_attributes = True

31
app/models/proveedor.py Normal file
View File

@@ -0,0 +1,31 @@
from pydantic import BaseModel, EmailStr
from typing import Optional
from datetime import datetime
class ProveedorBase(BaseModel):
nombre: str
email: Optional[EmailStr] = None
tiene_web: bool = True
activo: bool = True
class ProveedorCreate(ProveedorBase):
pass
class ProveedorUpdate(BaseModel):
nombre: Optional[str] = None
email: Optional[EmailStr] = None
tiene_web: Optional[bool] = None
activo: Optional[bool] = None
class ProveedorResponse(ProveedorBase):
id: int
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True

27
app/models/stock.py Normal file
View File

@@ -0,0 +1,27 @@
from pydantic import BaseModel
from typing import Optional
from datetime import datetime
class StockReferenciaBase(BaseModel):
referencia: str
unidades_disponibles: int = 0
class StockReferenciaCreate(StockReferenciaBase):
pass
class StockReferenciaUpdate(BaseModel):
referencia: Optional[str] = None
unidades_disponibles: Optional[int] = None
class StockReferenciaResponse(StockReferenciaBase):
id: int
ultima_actualizacion: datetime
created_at: datetime
class Config:
from_attributes = True

30
app/prisma_client.py Normal file
View File

@@ -0,0 +1,30 @@
"""
Cliente Prisma singleton
"""
from prisma import Prisma
from app.config import settings
import logging
logger = logging.getLogger(__name__)
prisma = Prisma()
async def connect_db():
"""Conectar a la base de datos"""
await prisma.connect()
logger.info("Conectado a la base de datos")
async def disconnect_db():
"""Desconectar de la base de datos"""
await prisma.disconnect()
logger.info("Desconectado de la base de datos")
async def get_db():
"""Dependency para obtener el cliente Prisma"""
if not prisma.is_connected():
await prisma.connect()
return prisma

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}")