Initial commit
This commit is contained in:
0
app/__init__.py
Normal file
0
app/__init__.py
Normal file
0
app/api/__init__.py
Normal file
0
app/api/__init__.py
Normal file
11
app/api/dependencies.py
Normal file
11
app/api/dependencies.py
Normal 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()
|
||||
|
||||
13
app/api/routes/__init__.py
Normal file
13
app/api/routes/__init__.py
Normal 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
104
app/api/routes/albaranes.py
Normal 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
50
app/api/routes/alertas.py
Normal 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
|
||||
|
||||
77
app/api/routes/clientes.py
Normal file
77
app/api/routes/clientes.py
Normal 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
47
app/api/routes/kanban.py
Normal 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
|
||||
|
||||
246
app/api/routes/pedidos_cliente.py
Normal file
246
app/api/routes/pedidos_cliente.py
Normal 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
|
||||
|
||||
85
app/api/routes/proveedores.py
Normal file
85
app/api/routes/proveedores.py
Normal 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
|
||||
|
||||
102
app/api/routes/referencias_proveedor.py
Normal file
102
app/api/routes/referencias_proveedor.py
Normal 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
47
app/config.py
Normal 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
69
app/main.py
Normal 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
26
app/models/__init__.py
Normal 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
57
app/models/albaran.py
Normal 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
31
app/models/cliente.py
Normal 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
37
app/models/devolucion.py
Normal 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
|
||||
|
||||
70
app/models/pedido_cliente.py
Normal file
70
app/models/pedido_cliente.py
Normal 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
|
||||
|
||||
59
app/models/pedido_proveedor.py
Normal file
59
app/models/pedido_proveedor.py
Normal 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
31
app/models/proveedor.py
Normal 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
27
app/models/stock.py
Normal 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
30
app/prisma_client.py
Normal 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
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