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

61
.gitignore vendored Normal file
View File

@@ -0,0 +1,61 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
env/
venv/
ENV/
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# Django (legacy)
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Environment
.env
.venv
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Project specific
pedidos_clientes_pdf/
albaranes_escaneados/
emails_proveedores/
media/
staticfiles/
logs/
# Prisma
node_modules/
.prisma/
# Frontend (si se compila)
frontend/dist/
frontend/build/

231
GUIA_INSTALACION.md Normal file
View File

@@ -0,0 +1,231 @@
# Guía de Instalación y Ejecución - Paso a Paso
## Requisitos Previos
1. **Python 3.8+** instalado
2. **Node.js y npm** instalados (para Prisma CLI)
3. **PostgreSQL** instalado y corriendo
4. **OpenAI API Key** (para OCR)
## Paso 1: Instalar Dependencias Node.js
```bash
npm install
```
Esto instalará Prisma CLI necesario para gestionar la base de datos.
## Paso 2: Instalar Dependencias Python
```bash
pip install -r requirements_prisma.txt
```
## Paso 3: Configurar Base de Datos PostgreSQL
### Opción A: PostgreSQL local
1. Crear base de datos:
```sql
CREATE DATABASE pedidos_clientes;
```
2. O usar el usuario por defecto `postgres`
### Opción B: Docker (Recomendado)
```bash
docker-compose up -d db
```
Esto iniciará PostgreSQL en un contenedor Docker.
## Paso 4: Configurar Variables de Entorno
Crear archivo `.env` en la raíz del proyecto:
```env
# Base de datos
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/pedidos_clientes
# OpenAI API Key
OPENAI_API_KEY=tu-openai-api-key-aqui
# Opcional
DEBUG=True
```
**Importante:** Reemplaza `postgres:postgres` con tu usuario y contraseña de PostgreSQL.
## Paso 5: Generar Cliente Prisma
```bash
prisma generate
```
Esto generará el cliente Prisma Python que se usará en la aplicación.
## Paso 6: Crear Migraciones de Base de Datos
```bash
prisma migrate dev --name init
```
Esto creará todas las tablas en la base de datos según el schema de Prisma.
**Nota:** Si ya tienes datos, puedes usar `prisma db push` en lugar de migrate.
## Paso 7: Verificar Base de Datos (Opcional)
```bash
prisma studio
```
Esto abrirá una interfaz web para ver y gestionar la base de datos.
## Paso 8: Ejecutar Backend
En una terminal:
```bash
python run.py
```
O con uvicorn directamente:
```bash
uvicorn app.main:app --reload
```
El backend estará disponible en:
- **API:** http://localhost:8000
- **Documentación:** http://localhost:8000/docs
- **Health Check:** http://localhost:8000/health
## Paso 9: Configurar Frontend
Editar `frontend/js/config.js` y verificar que la URL de la API sea correcta:
```javascript
const API_CONFIG = {
BASE_URL: 'http://localhost:8000/api',
};
```
## Paso 10: Ejecutar Frontend
En otra terminal:
```bash
cd frontend
python -m http.server 3000
```
O con Node.js:
```bash
cd frontend
npx http-server -p 3000
```
El frontend estará disponible en:
- **Aplicación:** http://localhost:3000
## Verificación
1. Abre http://localhost:3000 en tu navegador
2. Deberías ver el Kanban (aunque esté vacío si no hay datos)
3. Abre http://localhost:8000/docs para ver la documentación de la API
4. Prueba hacer una petición GET a http://localhost:8000/api/kanban/
## Solución de Problemas
### Error: "No module named 'prisma'"
```bash
pip install prisma
```
### Error: "DATABASE_URL not found"
Verifica que el archivo `.env` existe y tiene la variable `DATABASE_URL`
### Error: "Connection refused" (PostgreSQL)
- Verifica que PostgreSQL esté corriendo
- Verifica las credenciales en `.env`
- Verifica que el puerto 5432 esté disponible
### Error: "prisma: command not found"
```bash
npm install
# O instalar Prisma globalmente:
npm install -g prisma
```
### Error CORS en el navegador
- Verifica que el backend esté corriendo en el puerto 8000
- Verifica la URL en `frontend/js/config.js`
- Revisa la consola del navegador para más detalles
### Frontend no carga
- Verifica que el servidor HTTP esté corriendo
- Abre la consola del navegador (F12) para ver errores
- Verifica que los archivos estén en `frontend/`
## Comandos Útiles
```bash
# Ver logs del backend
python run.py
# Regenerar cliente Prisma (si cambias el schema)
prisma generate
# Crear nueva migración
prisma migrate dev --name nombre_migracion
# Ver estado de migraciones
prisma migrate status
# Abrir Prisma Studio (GUI para BD)
prisma studio
# Verificar sintaxis del schema
prisma validate
```
## Estructura de Carpetas Importante
```
pedidosClientesAyutec/
├── .env # ⚠️ Crear este archivo con tus credenciales
├── app/ # Backend FastAPI
├── frontend/ # Frontend HTML/JS
├── prisma/
│ └── schema.prisma # Schema de base de datos
└── requirements_prisma.txt # Dependencias Python
```
## Próximos Pasos
1. **Crear datos de prueba:**
- Usa Prisma Studio para crear clientes y proveedores
- O crea un script de seeding
2. **Probar funcionalidades:**
- Subir un albarán desde el frontend
- Crear un pedido de cliente
- Ver el Kanban actualizarse
3. **Configurar producción:**
- Cambiar CORS a dominios específicos
- Configurar variables de entorno seguras
- Usar Gunicorn para el backend
- Servir frontend con Nginx
## ¿Necesitas Ayuda?
Si encuentras algún error, verifica:
1. Que todas las dependencias estén instaladas
2. Que PostgreSQL esté corriendo
3. Que el archivo `.env` esté configurado correctamente
4. Que los puertos 8000 y 3000 estén disponibles

152
INSTALL.md Normal file
View File

@@ -0,0 +1,152 @@
# Guía de Instalación - Sistema de Gestión de Pedidos
## Requisitos Previos
- Python 3.8 o superior
- PostgreSQL 12 o superior
- pip (gestor de paquetes de Python)
- Git (opcional)
## Paso 1: Clonar/Descargar el Proyecto
Si tienes el proyecto en un repositorio Git:
```bash
git clone <url-del-repositorio>
cd pedidosClientesAyutec
```
## Paso 2: Crear Entorno Virtual
**Windows:**
```bash
python -m venv venv
venv\Scripts\activate
```
**Linux/Mac:**
```bash
python3 -m venv venv
source venv/bin/activate
```
## Paso 3: Instalar Dependencias
```bash
pip install -r requirements.txt
```
## Paso 4: Configurar Base de Datos PostgreSQL
1. Instalar PostgreSQL si no lo tienes instalado
2. Crear una base de datos:
```sql
CREATE DATABASE pedidos_clientes;
CREATE USER pedidos_user WITH PASSWORD 'tu_password';
GRANT ALL PRIVILEGES ON DATABASE pedidos_clientes TO pedidos_user;
```
O usar Docker:
```bash
docker-compose up -d db
```
## Paso 5: Configurar Variables de Entorno
1. Copiar el archivo de ejemplo:
```bash
cp .env.example .env
```
2. Editar `.env` con tus credenciales:
```env
SECRET_KEY=tu-secret-key-generado
DB_NAME=pedidos_clientes
DB_USER=postgres
DB_PASSWORD=tu-password
DB_HOST=localhost
DB_PORT=5432
OPENAI_API_KEY=tu-openai-api-key
```
Para generar un SECRET_KEY:
```bash
python -c "from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())"
```
## Paso 6: Ejecutar Migraciones
```bash
python manage.py makemigrations
python manage.py migrate
```
## Paso 7: Crear Superusuario
```bash
python manage.py createsuperuser
```
Sigue las instrucciones para crear un usuario administrador.
## Paso 8: Recopilar Archivos Estáticos (Opcional)
```bash
python manage.py collectstatic --noinput
```
## Paso 9: Iniciar el Servidor
```bash
python manage.py runserver
```
El servidor estará disponible en: http://127.0.0.1:8000
## Paso 10: Iniciar File Watcher (Opcional)
En una terminal separada, para procesar automáticamente PDFs y albaranes:
```bash
python manage.py start_file_watcher
```
## Verificación
1. Accede a http://127.0.0.1:8000/admin/ y inicia sesión
2. Accede a http://127.0.0.1:8000/api/kanban/ para ver el Kanban
3. Verifica que las carpetas se hayan creado:
- `pedidos_clientes_pdf/`
- `albaranes_escaneados/`
- `emails_proveedores/`
## Solución de Problemas
### Error de conexión a PostgreSQL
- Verifica que PostgreSQL esté corriendo
- Verifica las credenciales en `.env`
- Verifica que la base de datos exista
### Error de módulos faltantes
```bash
pip install -r requirements.txt --upgrade
```
### Error de migraciones
```bash
python manage.py makemigrations --empty gestion_pedidos
python manage.py migrate
```
### Error de permisos en carpetas
Asegúrate de que el usuario tenga permisos de escritura en las carpetas del proyecto.
## Producción
Para producción, considera:
- Usar un servidor WSGI como Gunicorn
- Configurar Nginx como proxy reverso
- Usar variables de entorno seguras
- Configurar HTTPS
- Configurar backups de base de datos
- Configurar logs rotativos

66
MIGRATION_GUIDE.md Normal file
View File

@@ -0,0 +1,66 @@
# Guía de Migración Django → FastAPI + Prisma
## Cambios Realizados
### 1. Estructura del Proyecto
- ✅ Migrado de Django a FastAPI
- ✅ Reemplazado Django ORM por Prisma Client Python
- ✅ Convertidos serializers Django → Modelos Pydantic
- ✅ Convertidas vistas Django → Routers FastAPI
### 2. Base de Datos
- ✅ Schema Prisma creado con todos los modelos
- ✅ Migraciones Prisma configuradas
- ✅ Índices y relaciones mantenidos
### 3. API Endpoints
Todos los endpoints mantienen la misma funcionalidad:
-`/api/clientes` - CRUD de clientes
-`/api/pedidos-cliente` - CRUD de pedidos
-`/api/proveedores` - CRUD de proveedores
-`/api/albaranes` - Gestión de albaranes
-`/api/kanban` - Datos del Kanban
-`/api/alertas` - Alertas de pedidos urgentes
### 4. Servicios
- ✅ OCR Service actualizado
- ✅ Albaran Processor actualizado para Prisma
- ✅ PDF Parser mantenido
## Próximos Pasos
1. **Instalar dependencias:**
```bash
npm install
pip install -r requirements_prisma.txt
```
2. **Configurar base de datos:**
```bash
# Editar .env con DATABASE_URL
prisma generate
prisma migrate dev --name init
```
3. **Migrar datos existentes (si aplica):**
Si tienes datos en Django, necesitarás exportarlos e importarlos.
4. **Probar la aplicación:**
```bash
python run.py
```
## Notas Importantes
- Los nombres de campos en Prisma usan camelCase (ej: `numeroPedido`)
- Los modelos Pydantic usan snake_case (ej: `numero_pedido`)
- La conversión se hace automáticamente en los endpoints
- Prisma Studio está disponible para gestionar la BD: `prisma studio`
## Archivos a Revisar
- `prisma/schema.prisma` - Verificar que los modelos coincidan
- `app/models/*.py` - Verificar modelos Pydantic
- `app/api/routes/*.py` - Verificar endpoints
- `.env` - Configurar DATABASE_URL y OPENAI_API_KEY

83
MIGRATION_PRISMA.md Normal file
View File

@@ -0,0 +1,83 @@
# Guía de Migración a Prisma ORM
## Opción 1: FastAPI + Prisma (Recomendado)
### Pasos para migrar:
1. **Instalar Prisma CLI y dependencias:**
```bash
npm install
pip install -r requirements_prisma.txt
```
2. **Configurar DATABASE_URL en .env:**
```env
DATABASE_URL="postgresql://usuario:password@localhost:5432/pedidos_clientes"
```
3. **Generar cliente Prisma:**
```bash
npm run prisma:generate
# O directamente:
prisma generate
```
4. **Crear migración inicial:**
```bash
npm run prisma:migrate
# O:
prisma migrate dev --name init
```
5. **Aplicar migraciones:**
```bash
prisma migrate deploy
```
## Opción 2: Mantener Django con Prisma Client Python
Es posible pero requiere:
- Usar Prisma solo para queries complejas
- Mantener Django ORM para la mayoría de operaciones
- Configurar ambos clientes
**No recomendado** - Mejor migrar completamente a FastAPI.
## Estructura con Prisma
```
prisma/
schema.prisma # Schema de base de datos
migrations/ # Migraciones generadas
app/
prisma_client.py # Cliente Prisma inicializado
models/ # Modelos Pydantic (opcional)
api/ # Endpoints FastAPI
services/ # Lógica de negocio
```
## Ventajas de Prisma
- ✅ Type-safe queries
- ✅ Migraciones automáticas
- ✅ Mejor performance
- ✅ Schema como código
- ✅ Prisma Studio (GUI para BD)
## Desventajas
- ❌ Requiere Node.js para Prisma CLI
- ❌ Cambio de framework (Django → FastAPI)
- ❌ Necesita reescribir vistas y serializers
## ¿Continuar con la migración completa?
Si decides migrar, necesitaré:
1. Reescribir todas las vistas Django → FastAPI
2. Convertir serializers → Pydantic models
3. Actualizar servicios para usar Prisma Client
4. Mantener la misma funcionalidad
¿Quieres que proceda con la migración completa a FastAPI + Prisma?

86
QUICK_START.md Normal file
View File

@@ -0,0 +1,86 @@
# Inicio Rápido 🚀
## Instalación Rápida (Windows)
1. **Ejecutar script de configuración:**
```bash
setup.bat
```
2. **Editar `.env` con tus credenciales:**
```env
DATABASE_URL=postgresql://usuario:password@localhost:5432/pedidos_clientes
OPENAI_API_KEY=tu-api-key
```
3. **Iniciar sistema:**
```bash
start.bat
```
## Instalación Rápida (Linux/Mac)
1. **Dar permisos de ejecución:**
```bash
chmod +x setup.sh start.sh
```
2. **Ejecutar configuración:**
```bash
./setup.sh
```
3. **Editar `.env` con tus credenciales**
4. **Iniciar sistema:**
```bash
./start.sh
```
## Instalación Manual
### 1. Instalar dependencias
```bash
npm install
pip install -r requirements_prisma.txt
```
### 2. Configurar `.env`
```env
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/pedidos_clientes
OPENAI_API_KEY=tu-api-key
```
### 3. Generar Prisma y crear BD
```bash
prisma generate
prisma migrate dev --name init
```
### 4. Iniciar Backend (Terminal 1)
```bash
python run.py
```
### 5. Iniciar Frontend (Terminal 2)
```bash
cd frontend
python -m http.server 3000
```
## URLs
- **Frontend:** http://localhost:3000
- **Backend API:** http://localhost:8000
- **API Docs:** http://localhost:8000/docs
## Verificación
Abre http://localhost:3000 y deberías ver el Kanban.
Si ves errores, revisa:
- ✅ PostgreSQL está corriendo
-`.env` está configurado
- ✅ Dependencias instaladas
- ✅ Puertos 8000 y 3000 disponibles

135
README.md Normal file
View File

@@ -0,0 +1,135 @@
# Sistema de Gestión de Pedidos de Recambios
Sistema Kanban visual para gestionar pedidos de recambios desde presupuesto hasta recepción, con procesamiento automático de documentos mediante OCR y seguimiento en tiempo real del estado de cada pedido.
**Arquitectura:** Backend FastAPI + Prisma ORM | Frontend HTML/CSS/JS separado
## Estructura del Proyecto
```
pedidosClientesAyutec/
├── app/ # Backend FastAPI
│ ├── main.py # Aplicación principal
│ ├── config.py # Configuración
│ ├── prisma_client.py # Cliente Prisma
│ ├── api/ # Endpoints API
│ ├── models/ # Modelos Pydantic
│ └── services/ # Lógica de negocio
├── frontend/ # Frontend separado
│ ├── index.html
│ ├── css/
│ └── js/
├── prisma/ # Schema Prisma
│ └── schema.prisma
└── requirements_prisma.txt
```
## Instalación
### Backend
1. **Instalar dependencias:**
```bash
npm install
pip install -r requirements_prisma.txt
```
2. **Configurar `.env`:**
```env
DATABASE_URL=postgresql://usuario:password@localhost:5432/pedidos_clientes
OPENAI_API_KEY=tu-api-key
```
3. **Generar cliente Prisma:**
```bash
prisma generate
```
4. **Crear migraciones:**
```bash
prisma migrate dev --name init
```
5. **Ejecutar backend:**
```bash
python run.py
# O
uvicorn app.main:app --reload
```
Backend disponible en: http://localhost:8000
API Docs: http://localhost:8000/docs
### Frontend
1. **Configurar API URL en `frontend/js/config.js`:**
```javascript
const API_CONFIG = {
BASE_URL: 'http://localhost:8000/api',
};
```
2. **Ejecutar servidor HTTP:**
```bash
cd frontend
python -m http.server 3000
```
Frontend disponible en: http://localhost:3000
## Características
-**Vista Kanban**: Visualización de pedidos por estados
-**Procesamiento OCR**: Extracción automática de datos con GPT-4 Vision
-**Gestión de Stock**: Marcado manual de referencias en stock
-**Pedidos a Proveedor**: Creación manual y automática
-**Vista por Proveedor**: Referencias pendientes por proveedor
-**Gestión de Devoluciones**: Registro y seguimiento
-**Alertas**: Notificaciones para pedidos urgentes
-**Upload Móvil**: Interfaz para subir albaranes
## API Endpoints
- `GET /api/clientes` - Listar clientes
- `GET /api/pedidos-cliente` - Listar pedidos
- `GET /api/kanban` - Datos del Kanban
- `GET /api/alertas` - Alertas de pedidos urgentes
- `POST /api/albaranes/upload` - Subir albarán
- `GET /docs` - Documentación Swagger
## Desarrollo
### Backend
```bash
# Ver logs
uvicorn app.main:app --reload --log-level debug
# Prisma Studio (GUI para BD)
prisma studio
```
### Frontend
```bash
# Servidor de desarrollo
cd frontend
python -m http.server 3000
```
## Producción
### Backend
- Usar Gunicorn o similar
- Configurar Nginx como reverse proxy
- Configurar CORS para dominios específicos
- Variables de entorno seguras
### Frontend
- Servir con Nginx
- O usar un CDN
- Configurar CORS en backend
## Documentación
- `README_PRISMA.md` - Documentación de Prisma
- `MIGRATION_GUIDE.md` - Guía de migración
- `frontend/README.md` - Documentación del frontend

106
README_PRISMA.md Normal file
View File

@@ -0,0 +1,106 @@
# Sistema de Gestión de Pedidos - FastAPI + Prisma
## Instalación
### 1. Instalar dependencias Node.js (para Prisma CLI)
```bash
npm install
```
### 2. Instalar dependencias Python
```bash
pip install -r requirements_prisma.txt
```
### 3. Configurar base de datos
Editar `.env`:
```env
DATABASE_URL="postgresql://usuario:password@localhost:5432/pedidos_clientes"
OPENAI_API_KEY="tu-api-key"
```
### 4. Generar cliente Prisma
```bash
npm run prisma:generate
# O
prisma generate
```
### 5. Crear y aplicar migraciones
```bash
npm run prisma:migrate
# O
prisma migrate dev --name init
```
### 6. Ejecutar aplicación
```bash
python run.py
# O
uvicorn app.main:app --reload
```
## Estructura del Proyecto
```
app/
├── __init__.py
├── main.py # Aplicación FastAPI principal
├── config.py # Configuración
├── prisma_client.py # Cliente Prisma
├── models/ # Modelos Pydantic
│ ├── cliente.py
│ ├── pedido_cliente.py
│ ├── proveedor.py
│ └── ...
├── api/ # Endpoints FastAPI
│ ├── routes/
│ │ ├── clientes.py
│ │ ├── pedidos_cliente.py
│ │ └── ...
│ └── dependencies.py
└── services/ # Lógica de negocio
├── ocr_service.py
└── albaran_processor.py
prisma/
└── schema.prisma # Schema de base de datos
```
## Endpoints API
- `GET /api/clientes` - Listar clientes
- `GET /api/pedidos-cliente` - Listar pedidos
- `GET /api/kanban` - Datos del Kanban
- `GET /api/alertas` - Alertas de pedidos urgentes
- `POST /api/albaranes/upload` - Subir albarán
- `GET /docs` - Documentación Swagger
## Comandos Útiles
```bash
# Generar cliente Prisma
prisma generate
# Crear migración
prisma migrate dev --name nombre_migracion
# Aplicar migraciones
prisma migrate deploy
# Abrir Prisma Studio (GUI para BD)
prisma studio
# Ver estado de migraciones
prisma migrate status
```
## Ventajas de Prisma
- ✅ Type-safe queries
- ✅ Migraciones automáticas
- ✅ Mejor performance
- ✅ Schema como código
- ✅ Prisma Studio (GUI para BD)
- ✅ Validación automática de tipos

View File

@@ -0,0 +1,84 @@
# Separación Frontend/Backend
El proyecto ahora está completamente separado en frontend y backend.
## Estructura
```
pedidosClientesAyutec/
├── app/ # Backend FastAPI
│ ├── main.py # API REST
│ ├── api/ # Endpoints
│ └── services/ # Lógica de negocio
└── frontend/ # Frontend separado
├── index.html # Kanban
├── proveedores.html # Vista proveedores
├── upload.html # Subir albaranes
├── admin.html # Panel admin
├── css/ # Estilos
└── js/ # JavaScript
```
## Ejecutar
### Backend (Puerto 8000)
```bash
python run.py
# O
uvicorn app.main:app --reload
```
Backend: http://localhost:8000
API Docs: http://localhost:8000/docs
### Frontend (Puerto 3000)
```bash
cd frontend
python -m http.server 3000
```
Frontend: http://localhost:3000
## Configuración
### Backend
- CORS configurado para permitir todas las origenes (cambiar en producción)
- API disponible en `/api/*`
- Media files en `/media/*`
### Frontend
- Configurar URL de API en `frontend/js/config.js`:
```javascript
const API_CONFIG = {
BASE_URL: 'http://localhost:8000/api',
};
```
## Ventajas de la Separación
1. **Desarrollo Independiente**: Frontend y backend pueden desarrollarse por separado
2. **Despliegue Independiente**: Pueden desplegarse en servidores diferentes
3. **Escalabilidad**: Escalar frontend y backend independientemente
4. **Tecnologías Diferentes**: Fácil migrar frontend a React/Vue/Angular
5. **CDN**: Frontend puede servirse desde CDN
## Producción
### Backend
- Usar Gunicorn/Uvicorn con Nginx
- Configurar CORS para dominios específicos
- Variables de entorno seguras
### Frontend
- Servir con Nginx
- O usar un CDN
- Configurar proxy reverso si es necesario
## Comunicación
El frontend se comunica con el backend vía API REST:
- Todas las peticiones van a `http://localhost:8000/api/*`
- CORS está habilitado para permitir peticiones desde el frontend
- Los archivos de media se sirven desde `/media/*`

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

22
docker-compose.yml Normal file
View File

@@ -0,0 +1,22 @@
version: '3.8'
services:
db:
image: postgres:15
volumes:
- postgres_data:/var/lib/postgresql/data
environment:
POSTGRES_DB: pedidos_clientes
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
volumes:
postgres_data:

67
frontend/README.md Normal file
View File

@@ -0,0 +1,67 @@
# Frontend - Sistema de Gestión de Pedidos
Frontend separado del backend, construido con HTML, CSS y JavaScript vanilla.
## Estructura
```
frontend/
├── index.html # Página principal (Kanban)
├── proveedores.html # Vista de proveedores
├── upload.html # Subir albaranes
├── admin.html # Panel de administración (pendiente)
├── css/
│ ├── base.css # Estilos base
│ ├── kanban.css # Estilos del Kanban
│ ├── proveedores.css # Estilos de proveedores
│ └── upload.css # Estilos de upload
└── js/
├── config.js # Configuración de API
├── kanban.js # Lógica del Kanban
├── proveedores.js # Lógica de proveedores
└── upload.js # Lógica de upload
```
## Configuración
Editar `js/config.js` para cambiar la URL de la API:
```javascript
const API_CONFIG = {
BASE_URL: 'http://localhost:8000/api',
// En producción: BASE_URL: 'https://tu-dominio.com/api'
};
```
## Ejecutar
### Opción 1: Servidor HTTP simple (Python)
```bash
cd frontend
python -m http.server 3000
```
### Opción 2: Servidor HTTP simple (Node.js)
```bash
cd frontend
npx http-server -p 3000
```
### Opción 3: Servidor de desarrollo (Live Server en VS Code)
Usar la extensión "Live Server" en VS Code
Luego abrir: http://localhost:3000
## Producción
Para producción, puedes:
1. Servir los archivos estáticos con Nginx
2. Usar un CDN
3. Integrar con un framework como React/Vue si prefieres
## Notas
- El frontend se comunica con el backend vía API REST
- CORS está configurado en el backend para permitir todas las origenes
- En producción, configurar CORS para dominios específicos

42
frontend/admin.html Normal file
View File

@@ -0,0 +1,42 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Panel de Administración</title>
<link rel="stylesheet" href="css/base.css">
<link rel="stylesheet" href="css/admin.css">
</head>
<body>
<div class="header">
<h1>Gestión de Pedidos de Recambios</h1>
</div>
<nav class="nav">
<a href="index.html">Kanban</a>
<a href="proveedores.html">Proveedores</a>
<a href="admin.html" class="active">Administración</a>
<a href="upload.html">Subir Albarán</a>
</nav>
<div class="container">
<div class="admin-container">
<h1 style="margin-bottom: 2rem;">Panel de Administración</h1>
<div class="tabs">
<button class="tab active" onclick="showTab('albaranes')">Albaranes</button>
<button class="tab" onclick="showTab('clasificacion')">Pendientes de Clasificación</button>
</div>
<div id="tab-albaranes" class="tab-content active">
<div id="albaranes-container"></div>
</div>
<div id="tab-clasificacion" class="tab-content">
<div id="clasificacion-container"></div>
</div>
</div>
</div>
<script src="js/config.js"></script>
<script src="js/admin.js"></script>
</body>
</html>

112
frontend/css/admin.css Normal file
View File

@@ -0,0 +1,112 @@
.admin-container {
max-width: 1400px;
margin: 0 auto;
}
.tabs {
display: flex;
gap: 1rem;
margin-bottom: 2rem;
border-bottom: 2px solid #ecf0f1;
}
.tab {
padding: 1rem 2rem;
cursor: pointer;
border: none;
background: none;
font-size: 1rem;
color: #7f8c8d;
border-bottom: 3px solid transparent;
transition: all 0.2s;
}
.tab.active {
color: #3498db;
border-bottom-color: #3498db;
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
.albaran-card {
background: white;
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 1rem;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.albaran-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
padding-bottom: 1rem;
border-bottom: 1px solid #ecf0f1;
}
.albaran-image {
max-width: 100%;
max-height: 400px;
border-radius: 4px;
margin: 1rem 0;
}
.ocr-data {
background: #f8f9fa;
padding: 1rem;
border-radius: 4px;
margin: 1rem 0;
font-family: monospace;
font-size: 0.9rem;
white-space: pre-wrap;
max-height: 300px;
overflow-y: auto;
}
.referencias-table {
width: 100%;
border-collapse: collapse;
margin-top: 1rem;
}
.referencias-table th,
.referencias-table td {
padding: 0.75rem;
text-align: left;
border-bottom: 1px solid #ecf0f1;
}
.referencias-table th {
background: #f8f9fa;
font-weight: bold;
}
.badge {
padding: 0.25rem 0.5rem;
border-radius: 12px;
font-size: 0.75rem;
font-weight: bold;
}
.badge.pendiente {
background: #fff3cd;
color: #856404;
}
.badge.procesado {
background: #d4edda;
color: #155724;
}
.badge.clasificacion {
background: #f8d7da;
color: #721c24;
}

75
frontend/css/base.css Normal file
View File

@@ -0,0 +1,75 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: #f5f5f5;
color: #333;
}
.header {
background: #2c3e50;
color: white;
padding: 1rem 2rem;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.header h1 {
font-size: 1.5rem;
}
.nav {
background: #34495e;
padding: 0.5rem 2rem;
}
.nav a {
color: white;
text-decoration: none;
margin-right: 2rem;
padding: 0.5rem 1rem;
display: inline-block;
border-radius: 4px;
transition: background 0.2s;
}
.nav a:hover, .nav a.active {
background: #2c3e50;
}
.container {
max-width: 100%;
padding: 2rem;
}
.btn {
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
transition: all 0.2s;
}
.btn-primary {
background: #3498db;
color: white;
}
.btn-primary:hover {
background: #2980b9;
}
.btn-success {
background: #27ae60;
color: white;
}
.btn-danger {
background: #e74c3c;
color: white;
}

147
frontend/css/kanban.css Normal file
View File

@@ -0,0 +1,147 @@
.kanban-container {
display: flex;
gap: 1rem;
overflow-x: auto;
padding: 1rem 0;
min-height: calc(100vh - 200px);
}
.kanban-column {
flex: 1;
min-width: 300px;
background: #ecf0f1;
border-radius: 8px;
padding: 1rem;
display: flex;
flex-direction: column;
}
.column-header {
background: #34495e;
color: white;
padding: 1rem;
border-radius: 4px;
margin-bottom: 1rem;
font-weight: bold;
text-align: center;
}
.column-header.pendiente-revision { background: #95a5a6; }
.column-header.en-revision { background: #f39c12; }
.column-header.pendiente-materiales { background: #e74c3c; }
.column-header.completado { background: #27ae60; }
.card {
background: white;
border-radius: 6px;
padding: 1rem;
margin-bottom: 1rem;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
}
.card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
}
.card.urgente {
border-left: 4px solid #e74c3c;
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.8; }
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.card-title {
font-weight: bold;
font-size: 1.1rem;
}
.card-subtitle {
color: #7f8c8d;
font-size: 0.9rem;
}
.urgente-badge {
background: #e74c3c;
color: white;
padding: 0.2rem 0.5rem;
border-radius: 12px;
font-size: 0.75rem;
font-weight: bold;
}
.referencias-list {
margin-top: 0.5rem;
}
.referencia-item {
padding: 0.5rem;
margin: 0.25rem 0;
border-radius: 4px;
font-size: 0.9rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.referencia-item.completo {
background: #d5f4e6;
color: #27ae60;
}
.referencia-item.parcial {
background: #fff3cd;
color: #856404;
}
.referencia-item.pendiente {
background: #f8d7da;
color: #721c24;
}
.referencia-codigo {
font-weight: bold;
font-family: monospace;
}
.filters {
background: white;
padding: 1rem;
border-radius: 8px;
margin-bottom: 1rem;
display: flex;
gap: 1rem;
align-items: center;
flex-wrap: wrap;
}
.filter-input {
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 0.9rem;
}
.refresh-btn {
margin-left: auto;
}
.empty-column {
text-align: center;
color: #95a5a6;
padding: 2rem;
font-style: italic;
}

View File

@@ -0,0 +1,59 @@
.proveedores-container {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
gap: 1.5rem;
}
.proveedor-card {
background: white;
border-radius: 8px;
padding: 1.5rem;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.proveedor-header {
background: #3498db;
color: white;
padding: 1rem;
border-radius: 4px;
margin-bottom: 1rem;
font-weight: bold;
font-size: 1.1rem;
}
.section-title {
font-weight: bold;
margin: 1rem 0 0.5rem 0;
padding-bottom: 0.5rem;
border-bottom: 2px solid #ecf0f1;
}
.referencia-item {
padding: 0.75rem;
margin: 0.5rem 0;
border-radius: 4px;
border-left: 4px solid;
}
.referencia-item.pendiente {
background: #e3f2fd;
border-color: #2196f3;
}
.referencia-item.devolucion {
background: #ffebee;
border-color: #f44336;
}
.referencia-codigo {
font-weight: bold;
font-family: monospace;
color: #2c3e50;
}
.referencia-info {
font-size: 0.9rem;
color: #7f8c8d;
margin-top: 0.25rem;
}

70
frontend/css/upload.css Normal file
View File

@@ -0,0 +1,70 @@
.upload-container {
max-width: 600px;
margin: 2rem auto;
background: white;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.upload-area {
border: 2px dashed #3498db;
border-radius: 8px;
padding: 3rem;
text-align: center;
cursor: pointer;
transition: all 0.3s;
background: #f8f9fa;
}
.upload-area:hover {
background: #e9ecef;
border-color: #2980b9;
}
.upload-area.dragover {
background: #d4edda;
border-color: #27ae60;
}
.upload-icon {
font-size: 3rem;
color: #3498db;
margin-bottom: 1rem;
}
.file-input {
display: none;
}
.preview-image {
max-width: 100%;
max-height: 400px;
margin-top: 1rem;
border-radius: 4px;
}
.btn-upload {
margin-top: 1rem;
width: 100%;
}
.status-message {
margin-top: 1rem;
padding: 1rem;
border-radius: 4px;
display: none;
}
.status-message.success {
background: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.status-message.error {
background: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}

59
frontend/index.html Normal file
View File

@@ -0,0 +1,59 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Gestión de Pedidos - Kanban</title>
<link rel="stylesheet" href="css/base.css">
<link rel="stylesheet" href="css/kanban.css">
</head>
<body>
<div class="header">
<h1>Gestión de Pedidos de Recambios</h1>
</div>
<nav class="nav">
<a href="index.html" class="active">Kanban</a>
<a href="proveedores.html">Proveedores</a>
<a href="admin.html">Administración</a>
<a href="upload.html">Subir Albarán</a>
</nav>
<div class="container">
<div class="filters">
<input type="text" id="search-input" class="filter-input" placeholder="Buscar por matrícula o número de pedido...">
<select id="estado-filter" class="filter-input">
<option value="">Todos los estados</option>
<option value="pendiente_revision">Pendiente Revisión</option>
<option value="en_revision">En Revisión</option>
<option value="pendiente_materiales">Pendiente Materiales</option>
<option value="completado">Completado</option>
</select>
<label>
<input type="checkbox" id="urgente-only"> Solo urgentes
</label>
<button class="btn btn-primary refresh-btn" onclick="loadKanban()">Actualizar</button>
</div>
<div class="kanban-container" id="kanban-container">
<div class="kanban-column">
<div class="column-header pendiente-revision">Pendiente Revisión</div>
<div id="col-pendiente-revision" class="cards-container"></div>
</div>
<div class="kanban-column">
<div class="column-header en-revision">En Revisión</div>
<div id="col-en-revision" class="cards-container"></div>
</div>
<div class="kanban-column">
<div class="column-header pendiente-materiales">Pendiente Materiales</div>
<div id="col-pendiente-materiales" class="cards-container"></div>
</div>
<div class="kanban-column">
<div class="column-header completado">Completado</div>
<div id="col-completado" class="cards-container"></div>
</div>
</div>
</div>
<script src="js/config.js"></script>
<script src="js/kanban.js"></script>
</body>
</html>

163
frontend/js/admin.js Normal file
View File

@@ -0,0 +1,163 @@
// Admin panel functionality
function showTab(tabName) {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active'));
event.target.classList.add('active');
document.getElementById(`tab-${tabName}`).classList.add('active');
if (tabName === 'albaranes') {
loadAlbaranes();
} else if (tabName === 'clasificacion') {
loadClasificacion();
}
}
async function loadAlbaranes() {
try {
const data = await apiRequest('/albaranes/');
const albaranes = Array.isArray(data) ? data : (data.results || []);
renderAlbaranes(albaranes);
} catch (error) {
console.error('Error al cargar albaranes:', error);
}
}
async function loadClasificacion() {
try {
const data = await apiRequest('/albaranes/?estado_procesado=clasificacion');
const albaranes = Array.isArray(data) ? data : (data.results || []);
renderClasificacion(albaranes);
} catch (error) {
console.error('Error al cargar clasificación:', error);
}
}
function renderAlbaranes(albaranes) {
const container = document.getElementById('albaranes-container');
if (!container) return;
container.innerHTML = '';
albaranes.forEach(albaran => {
const card = document.createElement('div');
card.className = 'albaran-card';
const estadoClass = albaran.estado_procesado === 'procesado' ? 'procesado' :
albaran.estado_procesado === 'clasificacion' ? 'clasificacion' : 'pendiente';
card.innerHTML = `
<div class="albaran-header">
<div>
<h3>Albarán ${albaran.numero_albaran || albaran.id}</h3>
<p>Proveedor: ${albaran.proveedor ? albaran.proveedor.nombre : 'Sin asignar'}</p>
<p>Fecha: ${albaran.fecha_albaran || 'N/A'}</p>
</div>
<span class="badge ${estadoClass}">${albaran.estado_procesado}</span>
</div>
<img src="${API_CONFIG.BASE_URL.replace('/api', '')}/media/${albaran.archivo_path}" class="albaran-image" alt="Albarán" onerror="this.style.display='none'">
<div class="ocr-data">${JSON.stringify(albaran.datos_ocr || {}, null, 2)}</div>
${albaran.referencias && albaran.referencias.length > 0 ? `
<table class="referencias-table">
<thead>
<tr>
<th>Referencia</th>
<th>Descripción</th>
<th>Unidades</th>
<th>Precio</th>
<th>IVA</th>
</tr>
</thead>
<tbody>
${albaran.referencias.map(ref => `
<tr>
<td>${ref.referencia}</td>
<td>${ref.denominacion}</td>
<td>${ref.unidades}</td>
<td>${ref.precio_unitario}€</td>
<td>${ref.impuesto_tipo}%</td>
</tr>
`).join('')}
</tbody>
</table>
` : ''}
`;
container.appendChild(card);
});
}
async function renderClasificacion(albaranes) {
const container = document.getElementById('clasificacion-container');
if (!container) return;
container.innerHTML = '';
// Cargar proveedores primero
let proveedores = [];
try {
proveedores = await apiRequest('/proveedores/');
} catch (error) {
console.error('Error al cargar proveedores:', error);
}
albaranes.forEach(albaran => {
const card = document.createElement('div');
card.className = 'albaran-card';
card.innerHTML = `
<div class="albaran-header">
<h3>Albarán ${albaran.numero_albaran || albaran.id}</h3>
</div>
<img src="${API_CONFIG.BASE_URL.replace('/api', '')}/media/${albaran.archivo_path}" class="albaran-image" alt="Albarán" onerror="this.style.display='none'">
<div style="margin-top: 1rem;">
<label>Asignar Proveedor:</label>
<select id="proveedor-${albaran.id}" style="padding: 0.5rem; margin: 0.5rem 0; width: 100%;">
<option value="">Seleccionar proveedor...</option>
${proveedores.map(prov => `
<option value="${prov.id}">${prov.nombre}</option>
`).join('')}
</select>
<button class="btn btn-primary" onclick="vincularProveedor(${albaran.id})" style="margin-top: 0.5rem;">
Vincular Proveedor
</button>
</div>
`;
container.appendChild(card);
});
}
async function vincularProveedor(albaranId) {
const select = document.getElementById(`proveedor-${albaranId}`);
if (!select) return;
const proveedorId = select.value;
if (!proveedorId) {
alert('Selecciona un proveedor');
return;
}
try {
await apiRequest(`/albaranes/${albaranId}/vincular-proveedor?proveedor_id=${proveedorId}`, {
method: 'POST'
});
alert('Proveedor vinculado correctamente');
loadClasificacion();
} catch (error) {
alert('Error al vincular proveedor: ' + error.message);
}
}
// Cargar inicial
document.addEventListener('DOMContentLoaded', () => {
loadAlbaranes();
});

32
frontend/js/config.js Normal file
View File

@@ -0,0 +1,32 @@
// Configuración de la API
const API_CONFIG = {
BASE_URL: 'http://localhost:8000/api',
// En producción cambiar a: BASE_URL: 'https://tu-dominio.com/api'
};
// Función helper para hacer requests
async function apiRequest(endpoint, options = {}) {
const url = `${API_CONFIG.BASE_URL}${endpoint}`;
const defaultOptions = {
headers: {
'Content-Type': 'application/json',
},
};
const config = { ...defaultOptions, ...options };
try {
const response = await fetch(url, config);
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'Error desconocido' }));
throw new Error(error.detail || `Error ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('API Error:', error);
throw error;
}
}

131
frontend/js/kanban.js Normal file
View File

@@ -0,0 +1,131 @@
// Kanban functionality
function createCard(pedido) {
const card = document.createElement('div');
card.className = 'card';
if (pedido.es_urgente) {
card.classList.add('urgente');
}
const fechaCita = pedido.fecha_cita ? new Date(pedido.fecha_cita).toLocaleString('es-ES') : 'Sin fecha';
card.innerHTML = `
<div class="card-header">
<div>
<div class="card-title">Pedido ${pedido.numero_pedido}</div>
<div class="card-subtitle">${pedido.cliente?.matricula_vehiculo || 'N/A'} - ${pedido.cliente?.nombre || 'N/A'}</div>
</div>
${pedido.es_urgente ? '<span class="urgente-badge">URGENTE</span>' : ''}
</div>
<div class="card-subtitle" style="margin-bottom: 0.5rem;">Cita: ${fechaCita}</div>
<div class="referencias-list">
${(pedido.referencias || []).map(ref => {
const estadoClass = ref.estado === 'completo' ? 'completo' :
ref.estado === 'parcial' ? 'parcial' : 'pendiente';
return `
<div class="referencia-item ${estadoClass}">
<div>
<span class="referencia-codigo">${ref.referencia}</span>
<div style="font-size: 0.8rem;">${ref.denominacion}</div>
</div>
<div style="text-align: right;">
<div>${ref.unidades_solicitadas} unidades</div>
<div style="font-size: 0.8rem;">
Stock: ${ref.unidades_en_stock} | Pendiente: ${ref.unidades_pendientes}
</div>
</div>
</div>
`;
}).join('')}
</div>
`;
return card;
}
function renderKanban(data) {
const columns = {
'pendiente_revision': document.getElementById('col-pendiente-revision'),
'en_revision': document.getElementById('col-en-revision'),
'pendiente_materiales': document.getElementById('col-pendiente-materiales'),
'completado': document.getElementById('col-completado'),
};
// Limpiar columnas
Object.values(columns).forEach(col => {
if (col) col.innerHTML = '';
});
// Renderizar tarjetas
Object.entries(data).forEach(([estado, pedidos]) => {
const column = columns[estado];
if (!column) return;
if (pedidos.length === 0) {
column.innerHTML = '<div class="empty-column">No hay pedidos</div>';
} else {
pedidos.forEach(pedido => {
column.appendChild(createCard(pedido));
});
}
});
}
async function loadKanban() {
try {
const data = await apiRequest('/kanban/');
renderKanban(data);
} catch (error) {
console.error('Error al cargar Kanban:', error);
alert('Error al cargar los datos: ' + error.message);
}
}
// Filtros
document.addEventListener('DOMContentLoaded', () => {
const searchInput = document.getElementById('search-input');
const estadoFilter = document.getElementById('estado-filter');
const urgenteOnly = document.getElementById('urgente-only');
if (searchInput) {
searchInput.addEventListener('input', (e) => {
const search = e.target.value.toLowerCase();
document.querySelectorAll('.card').forEach(card => {
const text = card.textContent.toLowerCase();
card.style.display = text.includes(search) ? 'block' : 'none';
});
});
}
if (estadoFilter) {
estadoFilter.addEventListener('change', async (e) => {
const estado = e.target.value;
if (!estado) {
loadKanban();
return;
}
try {
const data = await apiRequest(`/pedidos-cliente/?estado=${estado}`);
const pedidos = Array.isArray(data) ? data : (data.results || []);
const grouped = {
'pendiente_revision': [],
'en_revision': [],
'pendiente_materiales': [],
'completado': [],
};
grouped[estado] = pedidos;
renderKanban(grouped);
} catch (error) {
console.error('Error al filtrar:', error);
}
});
}
// Auto-refresh cada 30 segundos
setInterval(loadKanban, 30000);
// Cargar inicial
loadKanban();
});

View File

@@ -0,0 +1,75 @@
// Proveedores functionality
function renderProveedores(data) {
const container = document.getElementById('proveedores-container');
if (!container) return;
container.innerHTML = '';
if (data.length === 0) {
container.innerHTML = '<p>No hay referencias pendientes</p>';
return;
}
data.forEach(proveedorData => {
const card = document.createElement('div');
card.className = 'proveedor-card';
const pendientes = proveedorData.referencias_pendientes || [];
const devoluciones = proveedorData.referencias_devolucion || [];
card.innerHTML = `
<div class="proveedor-header">
${proveedorData.proveedor?.nombre || 'Sin nombre'}
</div>
${pendientes.length > 0 ? `
<div class="section-title">Referencias Pendientes de Recepción</div>
${pendientes.map(ref => `
<div class="referencia-item pendiente">
<div class="referencia-codigo">${ref.referencia}</div>
<div>${ref.denominacion}</div>
<div class="referencia-info">
Pedidas: ${ref.unidades_pedidas} | Recibidas: ${ref.unidades_recibidas} | Pendiente: ${ref.unidades_pedidas - ref.unidades_recibidas}
</div>
</div>
`).join('')}
` : ''}
${devoluciones.length > 0 ? `
<div class="section-title">Referencias Pendientes de Abono</div>
${devoluciones.map(dev => `
<div class="referencia-item devolucion">
<div class="referencia-codigo">${dev.referencia}</div>
<div>${dev.denominacion || ''}</div>
<div class="referencia-info">
Unidades: ${dev.unidades} | Fecha: ${new Date(dev.fecha_devolucion).toLocaleDateString('es-ES')}
</div>
</div>
`).join('')}
` : ''}
${pendientes.length === 0 && devoluciones.length === 0 ?
'<p style="color: #95a5a6; text-align: center; padding: 2rem;">Sin referencias pendientes</p>' : ''}
`;
container.appendChild(card);
});
}
async function loadProveedores() {
try {
const data = await apiRequest('/referencias-proveedor/');
renderProveedores(data);
} catch (error) {
console.error('Error al cargar proveedores:', error);
alert('Error al cargar los datos: ' + error.message);
}
}
// Auto-refresh cada 30 segundos
setInterval(loadProveedores, 30000);
// Cargar inicial
document.addEventListener('DOMContentLoaded', loadProveedores);

105
frontend/js/upload.js Normal file
View File

@@ -0,0 +1,105 @@
// Upload functionality
let selectedFile = null;
const uploadArea = document.getElementById('upload-area');
const fileInput = document.getElementById('file-input');
const previewContainer = document.getElementById('preview-container');
const previewImage = document.getElementById('preview-image');
const statusMessage = document.getElementById('status-message');
if (uploadArea && fileInput) {
uploadArea.addEventListener('click', () => fileInput.click());
uploadArea.addEventListener('dragover', (e) => {
e.preventDefault();
uploadArea.classList.add('dragover');
});
uploadArea.addEventListener('dragleave', () => {
uploadArea.classList.remove('dragover');
});
uploadArea.addEventListener('drop', (e) => {
e.preventDefault();
uploadArea.classList.remove('dragover');
const files = e.dataTransfer.files;
if (files.length > 0) {
handleFile(files[0]);
}
});
fileInput.addEventListener('change', (e) => {
if (e.target.files.length > 0) {
handleFile(e.target.files[0]);
}
});
}
function handleFile(file) {
selectedFile = file;
if (file.type.startsWith('image/')) {
const reader = new FileReader();
reader.onload = (e) => {
previewImage.src = e.target.result;
previewContainer.style.display = 'block';
};
reader.readAsDataURL(file);
} else if (file.type === 'application/pdf') {
previewImage.src = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCIgZmlsbD0iI2VjZjBmMSIvPjx0ZXh0IHg9IjUwJSIgeT0iNTAlIiBmb250LWZhbWlseT0iQXJpYWwiIGZvbnQtc2l6ZT0iMTgiIGZpbGw9IiM3ZjhjOGQiIHRleHQtYW5jaG9yPSJtaWRkbGUiIGR5PSIuM2VtIj5QREY8L3RleHQ+PC9zdmc+';
previewContainer.style.display = 'block';
}
}
async function uploadFile() {
if (!selectedFile) return;
const formData = new FormData();
formData.append('archivo', selectedFile);
statusMessage.style.display = 'none';
const btn = document.querySelector('.btn-upload');
if (btn) {
btn.disabled = true;
btn.textContent = 'Subiendo...';
}
try {
const response = await fetch(`${API_CONFIG.BASE_URL}/albaranes/upload/`, {
method: 'POST',
body: formData
});
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'Error desconocido' }));
throw new Error(error.detail || `Error ${response.status}`);
}
const data = await response.json();
statusMessage.className = 'status-message success';
statusMessage.textContent = 'Albarán subido y procesado correctamente';
statusMessage.style.display = 'block';
// Reset
setTimeout(() => {
selectedFile = null;
fileInput.value = '';
previewContainer.style.display = 'none';
statusMessage.style.display = 'none';
if (btn) {
btn.disabled = false;
btn.textContent = 'Subir Albarán';
}
}, 3000);
} catch (error) {
statusMessage.className = 'status-message error';
statusMessage.textContent = 'Error al subir el albarán: ' + error.message;
statusMessage.style.display = 'block';
if (btn) {
btn.disabled = false;
btn.textContent = 'Subir Albarán';
}
}
}

29
frontend/proveedores.html Normal file
View File

@@ -0,0 +1,29 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Referencias por Proveedor</title>
<link rel="stylesheet" href="css/base.css">
<link rel="stylesheet" href="css/proveedores.css">
</head>
<body>
<div class="header">
<h1>Gestión de Pedidos de Recambios</h1>
</div>
<nav class="nav">
<a href="index.html">Kanban</a>
<a href="proveedores.html" class="active">Proveedores</a>
<a href="admin.html">Administración</a>
<a href="upload.html">Subir Albarán</a>
</nav>
<div class="container">
<div class="proveedores-container" id="proveedores-container">
<!-- Se llenará con JavaScript -->
</div>
</div>
<script src="js/config.js"></script>
<script src="js/proveedores.js"></script>
</body>
</html>

45
frontend/upload.html Normal file
View File

@@ -0,0 +1,45 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Subir Albarán</title>
<link rel="stylesheet" href="css/base.css">
<link rel="stylesheet" href="css/upload.css">
</head>
<body>
<div class="header">
<h1>Gestión de Pedidos de Recambios</h1>
</div>
<nav class="nav">
<a href="index.html">Kanban</a>
<a href="proveedores.html">Proveedores</a>
<a href="admin.html">Administración</a>
<a href="upload.html" class="active">Subir Albarán</a>
</nav>
<div class="container">
<div class="upload-container">
<h2 style="margin-bottom: 2rem;">Subir Albarán</h2>
<div class="upload-area" id="upload-area">
<div class="upload-icon">📄</div>
<p>Arrastra una imagen aquí o haz clic para seleccionar</p>
<p style="font-size: 0.9rem; color: #7f8c8d; margin-top: 0.5rem;">
Formatos soportados: JPG, PNG, PDF
</p>
<input type="file" id="file-input" class="file-input" accept="image/*,.pdf">
</div>
<div id="preview-container" style="display: none;">
<img id="preview-image" class="preview-image" alt="Vista previa">
<button class="btn btn-primary btn-upload" onclick="uploadFile()">Subir Albarán</button>
</div>
<div id="status-message" class="status-message"></div>
</div>
</div>
<script src="js/config.js"></script>
<script src="js/upload.js"></script>
</body>
</html>

View File

84
gestion_pedidos/admin.py Normal file
View File

@@ -0,0 +1,84 @@
from django.contrib import admin
from .models import (
Cliente, PedidoCliente, ReferenciaPedidoCliente,
Proveedor, PedidoProveedor, ReferenciaPedidoProveedor,
Albaran, ReferenciaAlbaran, Devolucion, StockReferencia
)
@admin.register(Cliente)
class ClienteAdmin(admin.ModelAdmin):
list_display = ('id', 'nombre', 'matricula_vehiculo', 'telefono', 'email')
search_fields = ('nombre', 'matricula_vehiculo', 'email')
list_filter = ('nombre',)
@admin.register(PedidoCliente)
class PedidoClienteAdmin(admin.ModelAdmin):
list_display = ('id', 'numero_pedido', 'cliente', 'fecha_pedido', 'fecha_cita', 'estado')
search_fields = ('numero_pedido', 'cliente__nombre', 'cliente__matricula_vehiculo')
list_filter = ('estado', 'fecha_pedido', 'fecha_cita')
date_hierarchy = 'fecha_pedido'
@admin.register(ReferenciaPedidoCliente)
class ReferenciaPedidoClienteAdmin(admin.ModelAdmin):
list_display = ('id', 'pedido_cliente', 'referencia', 'denominacion', 'unidades_solicitadas',
'unidades_en_stock', 'unidades_pendientes', 'estado')
search_fields = ('referencia', 'denominacion', 'pedido_cliente__numero_pedido')
list_filter = ('estado',)
@admin.register(Proveedor)
class ProveedorAdmin(admin.ModelAdmin):
list_display = ('id', 'nombre', 'email', 'tiene_web', 'activo')
search_fields = ('nombre', 'email')
list_filter = ('tiene_web', 'activo')
@admin.register(PedidoProveedor)
class PedidoProveedorAdmin(admin.ModelAdmin):
list_display = ('id', 'proveedor', 'numero_pedido', 'fecha_pedido', 'estado', 'tipo')
search_fields = ('numero_pedido', 'proveedor__nombre')
list_filter = ('estado', 'tipo', 'fecha_pedido')
@admin.register(ReferenciaPedidoProveedor)
class ReferenciaPedidoProveedorAdmin(admin.ModelAdmin):
list_display = ('id', 'pedido_proveedor', 'referencia', 'unidades_pedidas',
'unidades_recibidas', 'estado')
search_fields = ('referencia', 'pedido_proveedor__numero_pedido')
list_filter = ('estado',)
@admin.register(Albaran)
class AlbaranAdmin(admin.ModelAdmin):
list_display = ('id', 'proveedor', 'numero_albaran', 'fecha_albaran',
'estado_procesado', 'fecha_procesado')
search_fields = ('numero_albaran', 'proveedor__nombre')
list_filter = ('estado_procesado', 'fecha_albaran')
date_hierarchy = 'fecha_albaran'
@admin.register(ReferenciaAlbaran)
class ReferenciaAlbaranAdmin(admin.ModelAdmin):
list_display = ('id', 'albaran', 'referencia', 'denominacion', 'unidades',
'precio_unitario', 'impuesto_tipo')
search_fields = ('referencia', 'denominacion', 'albaran__numero_albaran')
list_filter = ('impuesto_tipo',)
@admin.register(Devolucion)
class DevolucionAdmin(admin.ModelAdmin):
list_display = ('id', 'proveedor', 'referencia', 'unidades', 'fecha_devolucion',
'estado_abono')
search_fields = ('referencia', 'proveedor__nombre')
list_filter = ('estado_abono', 'fecha_devolucion')
@admin.register(StockReferencia)
class StockReferenciaAdmin(admin.ModelAdmin):
list_display = ('id', 'referencia', 'unidades_disponibles', 'ultima_actualizacion')
search_fields = ('referencia',)
list_filter = ('ultima_actualizacion',)

7
gestion_pedidos/apps.py Normal file
View File

@@ -0,0 +1,7 @@
from django.apps import AppConfig
class GestionPedidosConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'gestion_pedidos'

View File

View File

@@ -0,0 +1,28 @@
"""
Comando para iniciar el file watcher que monitorea carpetas
"""
from django.core.management.base import BaseCommand
from gestion_pedidos.services.file_watcher import FileWatcherService
import time
import logging
logger = logging.getLogger(__name__)
class Command(BaseCommand):
help = 'Inicia el servicio de monitoreo de carpetas para procesar PDFs y albaranes automáticamente'
def handle(self, *args, **options):
self.stdout.write(self.style.SUCCESS('Iniciando file watcher...'))
watcher = FileWatcherService()
watcher.start()
try:
while True:
time.sleep(1)
except KeyboardInterrupt:
self.stdout.write(self.style.WARNING('\nDeteniendo file watcher...'))
watcher.stop()
self.stdout.write(self.style.SUCCESS('File watcher detenido.'))

View File

354
gestion_pedidos/models.py Normal file
View File

@@ -0,0 +1,354 @@
from django.db import models
from django.core.validators import MinValueValidator
from django.utils import timezone
class Cliente(models.Model):
"""Cliente con información del vehículo"""
nombre = models.CharField(max_length=200)
matricula_vehiculo = models.CharField(max_length=20, unique=True)
telefono = models.CharField(max_length=20, blank=True, null=True)
email = models.EmailField(blank=True, null=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'clientes'
indexes = [
models.Index(fields=['matricula_vehiculo']),
models.Index(fields=['nombre']),
]
def __str__(self):
return f"{self.nombre} - {self.matricula_vehiculo}"
class PedidoCliente(models.Model):
"""Pedido de recambios de un cliente"""
ESTADOS = [
('pendiente_revision', 'Pendiente Revisión'),
('en_revision', 'En Revisión'),
('pendiente_materiales', 'Pendiente Materiales'),
('completado', 'Completado'),
]
numero_pedido = models.CharField(max_length=50, unique=True, db_index=True)
cliente = models.ForeignKey(Cliente, on_delete=models.CASCADE, related_name='pedidos')
fecha_pedido = models.DateTimeField(default=timezone.now)
fecha_cita = models.DateTimeField(blank=True, null=True)
estado = models.CharField(max_length=30, choices=ESTADOS, default='pendiente_revision')
presupuesto_id = models.CharField(max_length=50, blank=True, null=True)
archivo_pdf_path = models.CharField(max_length=500, blank=True, null=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'pedidos_cliente'
indexes = [
models.Index(fields=['numero_pedido']),
models.Index(fields=['estado']),
models.Index(fields=['fecha_cita']),
models.Index(fields=['fecha_pedido']),
]
ordering = ['-fecha_pedido']
def __str__(self):
return f"Pedido {self.numero_pedido} - {self.cliente.matricula_vehiculo}"
@property
def es_urgente(self):
"""Verifica si el pedido es urgente (menos de 12 horas para la cita)"""
if not self.fecha_cita:
return False
tiempo_restante = self.fecha_cita - timezone.now()
return tiempo_restante.total_seconds() < 12 * 3600 and tiempo_restante.total_seconds() > 0
class ReferenciaPedidoCliente(models.Model):
"""Referencias (piezas) de un pedido de cliente"""
ESTADOS = [
('pendiente', 'Pendiente'),
('parcial', 'Parcial'),
('completo', 'Completo'),
]
pedido_cliente = models.ForeignKey(
PedidoCliente,
on_delete=models.CASCADE,
related_name='referencias'
)
referencia = models.CharField(max_length=100, db_index=True)
denominacion = models.CharField(max_length=500)
unidades_solicitadas = models.IntegerField(validators=[MinValueValidator(1)])
unidades_en_stock = models.IntegerField(default=0, validators=[MinValueValidator(0)])
unidades_pendientes = models.IntegerField(default=0, validators=[MinValueValidator(0)])
estado = models.CharField(max_length=20, choices=ESTADOS, default='pendiente')
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'referencias_pedido_cliente'
indexes = [
models.Index(fields=['referencia']),
models.Index(fields=['estado']),
models.Index(fields=['pedido_cliente', 'estado']),
]
def __str__(self):
return f"{self.referencia} - {self.denominacion} ({self.unidades_solicitadas} unidades)"
def calcular_estado(self):
"""Calcula el estado basado en unidades"""
if self.unidades_pendientes <= 0:
return 'completo'
elif self.unidades_pendientes < self.unidades_solicitadas:
return 'parcial'
return 'pendiente'
def save(self, *args, **kwargs):
self.unidades_pendientes = max(0, self.unidades_solicitadas - self.unidades_en_stock)
self.estado = self.calcular_estado()
super().save(*args, **kwargs)
class Proveedor(models.Model):
"""Proveedor de recambios"""
nombre = models.CharField(max_length=200, db_index=True)
email = models.EmailField(blank=True, null=True)
tiene_web = models.BooleanField(default=True)
activo = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'proveedores'
indexes = [
models.Index(fields=['nombre']),
models.Index(fields=['activo']),
]
def __str__(self):
return self.nombre
class PedidoProveedor(models.Model):
"""Pedido realizado a un proveedor"""
ESTADOS = [
('pendiente_recepcion', 'Pendiente Recepción'),
('parcial', 'Parcial'),
('completado', 'Completado'),
('cancelado', 'Cancelado'),
]
TIPOS = [
('web', 'Web'),
('manual', 'Manual'),
]
proveedor = models.ForeignKey(
Proveedor,
on_delete=models.CASCADE,
related_name='pedidos'
)
numero_pedido = models.CharField(max_length=100, blank=True, null=True)
fecha_pedido = models.DateTimeField(default=timezone.now)
email_confirmacion_path = models.CharField(max_length=500, blank=True, null=True)
estado = models.CharField(max_length=30, choices=ESTADOS, default='pendiente_recepcion')
tipo = models.CharField(max_length=20, choices=TIPOS, default='web')
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'pedidos_proveedor'
indexes = [
models.Index(fields=['proveedor', 'estado']),
models.Index(fields=['fecha_pedido']),
models.Index(fields=['estado']),
]
ordering = ['-fecha_pedido']
def __str__(self):
return f"Pedido {self.numero_pedido or self.id} - {self.proveedor.nombre}"
class ReferenciaPedidoProveedor(models.Model):
"""Referencias pedidas a un proveedor"""
ESTADOS = [
('pendiente', 'Pendiente'),
('parcial', 'Parcial'),
('recibido', 'Recibido'),
]
pedido_proveedor = models.ForeignKey(
PedidoProveedor,
on_delete=models.CASCADE,
related_name='referencias'
)
referencia_pedido_cliente = models.ForeignKey(
ReferenciaPedidoCliente,
on_delete=models.CASCADE,
related_name='pedidos_proveedor',
blank=True,
null=True
)
referencia = models.CharField(max_length=100, db_index=True)
denominacion = models.CharField(max_length=500)
unidades_pedidas = models.IntegerField(validators=[MinValueValidator(1)])
unidades_recibidas = models.IntegerField(default=0, validators=[MinValueValidator(0)])
estado = models.CharField(max_length=20, choices=ESTADOS, default='pendiente')
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'referencias_pedido_proveedor'
indexes = [
models.Index(fields=['referencia']),
models.Index(fields=['estado']),
models.Index(fields=['pedido_proveedor', 'estado']),
]
def __str__(self):
return f"{self.referencia} - {self.denominacion} ({self.unidades_pedidas} unidades)"
class Albaran(models.Model):
"""Albarán recibido de un proveedor"""
ESTADOS_PROCESADO = [
('pendiente', 'Pendiente'),
('procesado', 'Procesado'),
('clasificacion', 'Pendiente Clasificación'),
('error', 'Error'),
]
proveedor = models.ForeignKey(
Proveedor,
on_delete=models.CASCADE,
related_name='albaranes',
blank=True,
null=True
)
numero_albaran = models.CharField(max_length=100, blank=True, null=True)
fecha_albaran = models.DateField(blank=True, null=True)
archivo_path = models.CharField(max_length=500)
estado_procesado = models.CharField(
max_length=30,
choices=ESTADOS_PROCESADO,
default='pendiente'
)
fecha_procesado = models.DateTimeField(blank=True, null=True)
datos_ocr = models.JSONField(default=dict, blank=True) # Datos extraídos por OCR
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'albaranes'
indexes = [
models.Index(fields=['proveedor', 'estado_procesado']),
models.Index(fields=['numero_albaran']),
models.Index(fields=['fecha_albaran']),
models.Index(fields=['estado_procesado']),
]
ordering = ['-created_at']
def __str__(self):
return f"Albarán {self.numero_albaran or self.id} - {self.proveedor.nombre if self.proveedor else 'Sin proveedor'}"
class ReferenciaAlbaran(models.Model):
"""Referencias contenidas en un albarán"""
TIPOS_IMPUESTO = [
('21', '21%'),
('10', '10%'),
('7', '7%'),
('4', '4%'),
('3', '3%'),
('0', '0%'),
]
albaran = models.ForeignKey(
Albaran,
on_delete=models.CASCADE,
related_name='referencias'
)
referencia = models.CharField(max_length=100, db_index=True)
denominacion = models.CharField(max_length=500)
unidades = models.IntegerField(validators=[MinValueValidator(1)])
precio_unitario = models.DecimalField(max_digits=10, decimal_places=2, default=0)
impuesto_tipo = models.CharField(max_length=5, choices=TIPOS_IMPUESTO, default='21')
impuesto_valor = models.DecimalField(max_digits=10, decimal_places=2, default=0)
referencia_pedido_proveedor = models.ForeignKey(
ReferenciaPedidoProveedor,
on_delete=models.SET_NULL,
related_name='referencias_albaran',
blank=True,
null=True
)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'referencias_albaran'
indexes = [
models.Index(fields=['referencia']),
models.Index(fields=['albaran']),
]
def __str__(self):
return f"{self.referencia} - {self.denominacion} ({self.unidades} unidades)"
class Devolucion(models.Model):
"""Devolución de material a proveedor"""
ESTADOS_ABONO = [
('pendiente', 'Pendiente Abono'),
('abonado', 'Abonado'),
]
proveedor = models.ForeignKey(
Proveedor,
on_delete=models.CASCADE,
related_name='devoluciones'
)
referencia = models.CharField(max_length=100, db_index=True)
denominacion = models.CharField(max_length=500, blank=True, null=True)
unidades = models.IntegerField(validators=[MinValueValidator(1)])
fecha_devolucion = models.DateTimeField(default=timezone.now)
estado_abono = models.CharField(max_length=20, choices=ESTADOS_ABONO, default='pendiente')
albaran_abono = models.ForeignKey(
Albaran,
on_delete=models.SET_NULL,
related_name='devoluciones',
blank=True,
null=True
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'devoluciones'
indexes = [
models.Index(fields=['proveedor', 'estado_abono']),
models.Index(fields=['referencia']),
models.Index(fields=['estado_abono']),
]
ordering = ['-fecha_devolucion']
def __str__(self):
return f"Devolución {self.referencia} - {self.proveedor.nombre} ({self.unidades} unidades)"
class StockReferencia(models.Model):
"""Stock disponible de una referencia"""
referencia = models.CharField(max_length=100, unique=True, db_index=True)
unidades_disponibles = models.IntegerField(default=0, validators=[MinValueValidator(0)])
ultima_actualizacion = models.DateTimeField(auto_now=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'stock_referencias'
indexes = [
models.Index(fields=['referencia']),
]
def __str__(self):
return f"{self.referencia} - {self.unidades_disponibles} unidades"

View File

@@ -0,0 +1,124 @@
from rest_framework import serializers
from .models import (
Cliente, PedidoCliente, ReferenciaPedidoCliente,
Proveedor, PedidoProveedor, ReferenciaPedidoProveedor,
Albaran, ReferenciaAlbaran, Devolucion, StockReferencia
)
class ClienteSerializer(serializers.ModelSerializer):
class Meta:
model = Cliente
fields = '__all__'
class ReferenciaPedidoClienteSerializer(serializers.ModelSerializer):
class Meta:
model = ReferenciaPedidoCliente
fields = '__all__'
class PedidoClienteSerializer(serializers.ModelSerializer):
cliente = ClienteSerializer(read_only=True)
cliente_id = serializers.PrimaryKeyRelatedField(
queryset=Cliente.objects.all(),
source='cliente',
write_only=True,
required=False
)
referencias = ReferenciaPedidoClienteSerializer(many=True, read_only=True)
es_urgente = serializers.ReadOnlyField()
class Meta:
model = PedidoCliente
fields = '__all__'
class ProveedorSerializer(serializers.ModelSerializer):
class Meta:
model = Proveedor
fields = '__all__'
class ReferenciaPedidoProveedorSerializer(serializers.ModelSerializer):
class Meta:
model = ReferenciaPedidoProveedor
fields = '__all__'
class PedidoProveedorSerializer(serializers.ModelSerializer):
proveedor = ProveedorSerializer(read_only=True)
proveedor_id = serializers.PrimaryKeyRelatedField(
queryset=Proveedor.objects.all(),
source='proveedor',
write_only=True
)
referencias = ReferenciaPedidoProveedorSerializer(many=True, read_only=True)
class Meta:
model = PedidoProveedor
fields = '__all__'
class ReferenciaAlbaranSerializer(serializers.ModelSerializer):
class Meta:
model = ReferenciaAlbaran
fields = '__all__'
class AlbaranSerializer(serializers.ModelSerializer):
proveedor = ProveedorSerializer(read_only=True)
proveedor_id = serializers.PrimaryKeyRelatedField(
queryset=Proveedor.objects.all(),
source='proveedor',
write_only=True,
required=False,
allow_null=True
)
referencias = ReferenciaAlbaranSerializer(many=True, read_only=True)
class Meta:
model = Albaran
fields = '__all__'
class DevolucionSerializer(serializers.ModelSerializer):
proveedor = ProveedorSerializer(read_only=True)
proveedor_id = serializers.PrimaryKeyRelatedField(
queryset=Proveedor.objects.all(),
source='proveedor',
write_only=True
)
class Meta:
model = Devolucion
fields = '__all__'
class StockReferenciaSerializer(serializers.ModelSerializer):
class Meta:
model = StockReferencia
fields = '__all__'
# Serializers para actualización de stock
class UpdateStockSerializer(serializers.Serializer):
referencia_id = serializers.IntegerField()
unidades_en_stock = serializers.IntegerField(min_value=0)
class BulkUpdateStockSerializer(serializers.Serializer):
updates = UpdateStockSerializer(many=True)
# Serializer para crear pedido a proveedor manualmente
class CrearPedidoProveedorSerializer(serializers.Serializer):
proveedor_id = serializers.IntegerField()
numero_pedido = serializers.CharField(required=False, allow_blank=True)
tipo = serializers.ChoiceField(choices=['web', 'manual'], default='manual')
referencias = serializers.ListField(
child=serializers.DictField(
child=serializers.CharField()
)
)

View File

View File

@@ -0,0 +1,188 @@
"""
Procesador de albaranes con OCR y vinculación automática
"""
from pathlib import Path
from typing import Dict, Optional, List
from django.utils import timezone
from datetime import datetime
from .ocr_service import OCRService
from ..models import (
Proveedor, Albaran, ReferenciaAlbaran,
ReferenciaPedidoProveedor, PedidoProveedor
)
class AlbaranProcessor:
"""Procesa albaranes y los vincula con pedidos pendientes"""
def __init__(self):
self.ocr_service = OCRService()
def _find_proveedor(self, datos: Dict) -> Optional[Proveedor]:
"""Busca el proveedor basándose en los datos del albarán"""
nombre = datos.get('proveedor', {}).get('nombre', '').strip()
numero = datos.get('proveedor', {}).get('numero', '').strip()
# Buscar por nombre
if nombre:
proveedor = Proveedor.objects.filter(
nombre__icontains=nombre
).first()
if proveedor:
return proveedor
# Buscar por número (si se implementa un campo número_proveedor)
# Por ahora, retornamos None si no se encuentra
return None
def _parse_fecha(self, fecha_str: str) -> Optional[datetime]:
"""Parsea una fecha desde string"""
if not fecha_str:
return None
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
def _match_referencias(
self,
referencias_albaran: List[Dict],
proveedor: Proveedor
) -> Dict[str, ReferenciaPedidoProveedor]:
"""
Busca referencias del albarán en pedidos pendientes del proveedor
Returns:
Dict mapping referencia -> ReferenciaPedidoProveedor
"""
matches = {}
# Obtener todas las referencias pendientes del proveedor
pedidos_pendientes = PedidoProveedor.objects.filter(
proveedor=proveedor,
estado__in=['pendiente_recepcion', 'parcial']
).prefetch_related('referencias')
for pedido in pedidos_pendientes:
for ref_pedido in pedido.referencias.filter(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
break
return matches
def _match_and_update_referencias(self, albaran: Albaran):
"""Vincula y actualiza referencias del albarán con pedidos pendientes"""
if not albaran.proveedor:
return
referencias_albaran = albaran.referencias.all()
matches = self._match_referencias(
[{'referencia': ref.referencia} for ref in referencias_albaran],
albaran.proveedor
)
for ref_albaran in referencias_albaran:
ref_pedido_proveedor = matches.get(ref_albaran.referencia.strip().upper())
if ref_pedido_proveedor:
ref_albaran.referencia_pedido_proveedor = ref_pedido_proveedor
ref_albaran.save()
# Actualizar pedido proveedor
ref_pedido_proveedor.unidades_recibidas += ref_albaran.unidades
if ref_pedido_proveedor.unidades_recibidas >= ref_pedido_proveedor.unidades_pedidas:
ref_pedido_proveedor.estado = 'recibido'
elif ref_pedido_proveedor.unidades_recibidas > 0:
ref_pedido_proveedor.estado = 'parcial'
ref_pedido_proveedor.save()
# Actualizar referencia pedido cliente
if ref_pedido_proveedor.referencia_pedido_cliente:
ref_cliente = ref_pedido_proveedor.referencia_pedido_cliente
ref_cliente.unidades_en_stock += ref_albaran.unidades
ref_cliente.save()
def process_albaran_file(self, file_path: Path) -> Albaran:
"""
Procesa un archivo de albarán (imagen o PDF)
Returns:
Albaran creado o actualizado
"""
# Procesar con OCR
datos = self.ocr_service.process_albaran(file_path)
# Buscar proveedor
proveedor = self._find_proveedor(datos)
# Parsear fecha
fecha_albaran = self._parse_fecha(datos.get('fecha_albaran', ''))
# Crear albarán
albaran = Albaran.objects.create(
proveedor=proveedor,
numero_albaran=datos.get('numero_albaran', '').strip() or None,
fecha_albaran=fecha_albaran,
archivo_path=str(file_path),
estado_procesado='procesado' if proveedor else 'clasificacion',
fecha_procesado=timezone.now(),
datos_ocr=datos,
)
# Crear referencias del albarán
referencias_data = datos.get('referencias', [])
matches = {}
if proveedor:
matches = self._match_referencias(referencias_data, proveedor)
for ref_data in referencias_data:
ref_pedido_proveedor = matches.get(ref_data['referencia'].strip().upper())
referencia = ReferenciaAlbaran.objects.create(
albaran=albaran,
referencia=ref_data.get('referencia', '').strip(),
denominacion=ref_data.get('denominacion', '').strip(),
unidades=int(ref_data.get('unidades', 1)),
precio_unitario=float(ref_data.get('precio_unitario', 0)),
impuesto_tipo=ref_data.get('impuesto_tipo', '21'),
impuesto_valor=float(ref_data.get('impuesto_valor', 0)),
referencia_pedido_proveedor=ref_pedido_proveedor,
)
# Actualizar pedido proveedor si hay match
if ref_pedido_proveedor:
ref_pedido_proveedor.unidades_recibidas += referencia.unidades
# Actualizar estado
if ref_pedido_proveedor.unidades_recibidas >= ref_pedido_proveedor.unidades_pedidas:
ref_pedido_proveedor.estado = 'recibido'
elif ref_pedido_proveedor.unidades_recibidas > 0:
ref_pedido_proveedor.estado = 'parcial'
ref_pedido_proveedor.save()
# Actualizar referencia pedido cliente
if ref_pedido_proveedor.referencia_pedido_cliente:
ref_cliente = ref_pedido_proveedor.referencia_pedido_cliente
ref_cliente.unidades_en_stock += referencia.unidades
ref_cliente.save()
return albaran

View File

@@ -0,0 +1,104 @@
"""
Parser para emails de confirmación de pedidos a proveedores
"""
import email
from pathlib import Path
from typing import Dict, List, Optional
import re
class EmailPedidoParser:
"""Parser para extraer información de emails de confirmación de pedidos"""
def parse_email_file(self, email_path: Path) -> Dict:
"""
Parsea un archivo de email (.eml)
Returns:
Dict con información del pedido
"""
with open(email_path, 'rb') as f:
msg = email.message_from_bytes(f.read())
# Extraer información básica
subject = msg.get('Subject', '')
from_addr = msg.get('From', '')
date = msg.get('Date', '')
# Extraer cuerpo del email
body = self._get_email_body(msg)
# Buscar número de pedido
numero_pedido = self._extract_numero_pedido(subject, body)
# Buscar referencias en el cuerpo
referencias = self._extract_referencias(body)
return {
'numero_pedido': numero_pedido,
'proveedor_email': from_addr,
'fecha': date,
'asunto': subject,
'cuerpo': body,
'referencias': referencias,
}
def _get_email_body(self, msg: email.message.Message) -> str:
"""Extrae el cuerpo del email"""
body = ""
if msg.is_multipart():
for part in msg.walk():
content_type = part.get_content_type()
if content_type == "text/plain":
try:
body = part.get_payload(decode=True).decode('utf-8', errors='ignore')
break
except:
pass
else:
try:
body = msg.get_payload(decode=True).decode('utf-8', errors='ignore')
except:
pass
return body
def _extract_numero_pedido(self, subject: str, body: str) -> Optional[str]:
"""Extrae el número de pedido del asunto o cuerpo"""
# Patrones comunes
patterns = [
r'pedido[:\s]+([A-Z0-9\-]+)',
r'pedido[:\s]+#?(\d+)',
r'ref[:\s]+([A-Z0-9\-]+)',
r'order[:\s]+([A-Z0-9\-]+)',
]
text = f"{subject} {body}".lower()
for pattern in patterns:
match = re.search(pattern, text, re.IGNORECASE)
if match:
return match.group(1).strip()
return None
def _extract_referencias(self, body: str) -> List[Dict]:
"""Extrae referencias del cuerpo del email"""
referencias = []
# Buscar líneas que parezcan referencias
# Formato común: REF123 - Descripción - Cantidad
pattern = r'([A-Z0-9\-]+)\s*[-]\s*([^-\n]+?)\s*[-]\s*(\d+)'
matches = re.finditer(pattern, body, re.IGNORECASE | re.MULTILINE)
for match in matches:
referencias.append({
'referencia': match.group(1).strip(),
'denominacion': match.group(2).strip(),
'unidades': int(match.group(3)),
})
return referencias

View File

@@ -0,0 +1,113 @@
"""
Servicio para monitorear carpetas y procesar nuevos archivos
"""
import time
from pathlib import Path
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler
from django.conf import settings
from .pdf_parser import PDFPedidoParser
from .albaran_processor import AlbaranProcessor
import logging
logger = logging.getLogger(__name__)
class PDFPedidoHandler(FileSystemEventHandler):
"""Handler para procesar nuevos PDFs de pedidos de cliente"""
def __init__(self):
self.parser = PDFPedidoParser()
self.procesados = set()
def on_created(self, event):
if event.is_directory:
return
file_path = Path(event.src_path)
# Solo procesar PDFs
if file_path.suffix.lower() != '.pdf':
return
# Evitar procesar el mismo archivo múltiples veces
if str(file_path) in self.procesados:
return
# Esperar un poco para asegurar que el archivo esté completamente escrito
time.sleep(1)
try:
logger.info(f"Procesando nuevo PDF: {file_path}")
pedido = self.parser.create_pedido_from_pdf(file_path)
logger.info(f"Pedido creado exitosamente: {pedido.numero_pedido}")
self.procesados.add(str(file_path))
except Exception as e:
logger.error(f"Error al procesar PDF {file_path}: {e}")
class AlbaranHandler(FileSystemEventHandler):
"""Handler para procesar nuevos albaranes"""
def __init__(self):
self.processor = AlbaranProcessor()
self.procesados = set()
def on_created(self, event):
if event.is_directory:
return
file_path = Path(event.src_path)
# Procesar imágenes y PDFs
if file_path.suffix.lower() not in ['.pdf', '.jpg', '.jpeg', '.png']:
return
if str(file_path) in self.procesados:
return
time.sleep(1)
try:
logger.info(f"Procesando nuevo albarán: {file_path}")
albaran = self.processor.process_albaran_file(file_path)
logger.info(f"Albarán procesado: {albaran.id}")
self.procesados.add(str(file_path))
except Exception as e:
logger.error(f"Error al procesar albarán {file_path}: {e}")
class FileWatcherService:
"""Servicio para monitorear carpetas"""
def __init__(self):
self.observer = Observer()
self.running = False
def start(self):
"""Inicia el monitoreo de carpetas"""
if self.running:
return
# Monitorear carpeta de pedidos de cliente
pedidos_dir = settings.PEDIDOS_CLIENTES_PDF_DIR
pedidos_handler = PDFPedidoHandler()
self.observer.schedule(pedidos_handler, str(pedidos_dir), recursive=False)
# Monitorear carpeta de albaranes
albaranes_dir = settings.ALBARANES_ESCANEADOS_DIR
albaranes_handler = AlbaranHandler()
self.observer.schedule(albaranes_handler, str(albaranes_dir), recursive=False)
self.observer.start()
self.running = True
logger.info("File watcher iniciado")
def stop(self):
"""Detiene el monitoreo"""
if self.running:
self.observer.stop()
self.observer.join()
self.running = False
logger.info("File watcher detenido")

View File

@@ -0,0 +1,196 @@
"""
Servicio de OCR usando GPT-4 Vision API
"""
import base64
import json
from pathlib import Path
from typing import Dict, List, Optional
from openai import OpenAI
from django.conf 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:
# Buscar JSON en la respuesta
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}")
def process_image(self, image_path: Path, tipo: str = 'albaran') -> Dict:
"""
Procesa una imagen (albarán o pedido)
Args:
image_path: Ruta a la imagen
tipo: 'albaran' o 'pedido_cliente'
"""
if tipo == 'albaran':
return self.process_albaran(image_path)
elif tipo == 'pedido_cliente':
return self.process_pdf_pedido_cliente(image_path)
else:
raise ValueError(f"Tipo no soportado: {tipo}")

View File

@@ -0,0 +1,95 @@
"""
Servicio para procesar PDFs de pedidos de cliente
"""
import json
from pathlib import Path
from typing import Dict, Optional
from django.utils import timezone
from datetime import datetime
from .ocr_service import OCRService
from ..models import Cliente, PedidoCliente, ReferenciaPedidoCliente
class PDFPedidoParser:
"""Parser para procesar PDFs de pedidos de cliente"""
def __init__(self):
self.ocr_service = OCRService()
def parse_pdf(self, pdf_path: Path) -> Dict:
"""
Parsea un PDF de pedido de cliente
Returns:
Dict con los datos extraídos
"""
return self.ocr_service.process_pdf_pedido_cliente(pdf_path)
def create_pedido_from_pdf(self, pdf_path: Path) -> PedidoCliente:
"""
Crea un PedidoCliente y sus referencias desde un PDF
Returns:
PedidoCliente creado
"""
# Parsear PDF
datos = self.parse_pdf(pdf_path)
# Obtener o crear cliente
matricula = datos.get('matricula', '').strip().upper()
if not matricula:
raise ValueError("No se pudo extraer la matrícula del PDF")
cliente, _ = Cliente.objects.get_or_create(
matricula_vehiculo=matricula,
defaults={
'nombre': datos.get('nombre_cliente', f'Cliente {matricula}'),
}
)
# Parsear fecha de cita
fecha_cita = None
fecha_cita_str = datos.get('fecha_cita')
if fecha_cita_str:
try:
# Intentar diferentes formatos
for fmt in ['%Y-%m-%d %H:%M', '%Y-%m-%d', '%d/%m/%Y %H:%M', '%d/%m/%Y']:
try:
fecha_cita = datetime.strptime(fecha_cita_str, fmt)
fecha_cita = timezone.make_aware(fecha_cita)
break
except ValueError:
continue
except Exception:
pass
# Crear pedido
numero_pedido = datos.get('numero_pedido', '').strip()
if not numero_pedido:
raise ValueError("No se pudo extraer el número de pedido del PDF")
# Verificar si ya existe
if PedidoCliente.objects.filter(numero_pedido=numero_pedido).exists():
raise ValueError(f"El pedido {numero_pedido} ya existe")
pedido = PedidoCliente.objects.create(
numero_pedido=numero_pedido,
cliente=cliente,
fecha_cita=fecha_cita,
estado='pendiente_revision',
archivo_pdf_path=str(pdf_path),
)
# Crear referencias
referencias_data = datos.get('referencias', [])
for ref_data in referencias_data:
ReferenciaPedidoCliente.objects.create(
pedido_cliente=pedido,
referencia=ref_data.get('referencia', '').strip(),
denominacion=ref_data.get('denominacion', '').strip(),
unidades_solicitadas=int(ref_data.get('unidades', 1)),
unidades_en_stock=0,
)
return pedido

21
gestion_pedidos/urls.py Normal file
View File

@@ -0,0 +1,21 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from . import views
router = DefaultRouter()
router.register(r'clientes', views.ClienteViewSet)
router.register(r'pedidos-cliente', views.PedidoClienteViewSet, basename='pedido-cliente')
router.register(r'referencias-pedido-cliente', views.ReferenciaPedidoClienteViewSet, basename='referencia-pedido-cliente')
router.register(r'proveedores', views.ProveedorViewSet)
router.register(r'pedidos-proveedor', views.PedidoProveedorViewSet, basename='pedido-proveedor')
router.register(r'albaranes', views.AlbaranViewSet, basename='albaran')
router.register(r'devoluciones', views.DevolucionViewSet)
router.register(r'stock', views.StockReferenciaViewSet)
urlpatterns = [
path('', include(router.urls)),
path('kanban/', views.KanbanView.as_view(), name='kanban'),
path('referencias-proveedor/', views.ReferenciasProveedorView.as_view(), name='referencias-proveedor'),
path('alertas/', views.AlertasView.as_view(), name='alertas'),
path('mobile/upload/', views.AlbaranViewSet.as_view({'post': 'upload'}), name='mobile-upload'),
]

367
gestion_pedidos/views.py Normal file
View File

@@ -0,0 +1,367 @@
from rest_framework import viewsets, status
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.views import APIView
from django.db.models import Q, Prefetch
from django.utils import timezone
from django.shortcuts import render
from datetime import timedelta
from pathlib import Path
import json
from .models import (
Cliente, PedidoCliente, ReferenciaPedidoCliente,
Proveedor, PedidoProveedor, ReferenciaPedidoProveedor,
Albaran, ReferenciaAlbaran, Devolucion, StockReferencia
)
from .serializers import (
ClienteSerializer, PedidoClienteSerializer, ReferenciaPedidoClienteSerializer,
ProveedorSerializer, PedidoProveedorSerializer, ReferenciaPedidoProveedorSerializer,
AlbaranSerializer, ReferenciaAlbaranSerializer, DevolucionSerializer,
StockReferenciaSerializer, UpdateStockSerializer, BulkUpdateStockSerializer,
CrearPedidoProveedorSerializer
)
from .services.albaran_processor import AlbaranProcessor
from .services.pdf_parser import PDFPedidoParser
from django.conf import settings
class ClienteViewSet(viewsets.ModelViewSet):
queryset = Cliente.objects.all()
serializer_class = ClienteSerializer
filterset_fields = ['nombre', 'matricula_vehiculo']
class PedidoClienteViewSet(viewsets.ModelViewSet):
queryset = PedidoCliente.objects.select_related('cliente').prefetch_related('referencias').all()
serializer_class = PedidoClienteSerializer
def get_queryset(self):
queryset = super().get_queryset()
# Filtros
estado = self.request.query_params.get('estado')
if estado:
queryset = queryset.filter(estado=estado)
urgente = self.request.query_params.get('urgente')
if urgente == 'true':
queryset = [p for p in queryset if p.es_urgente]
matricula = self.request.query_params.get('matricula')
if matricula:
queryset = queryset.filter(cliente__matricula_vehiculo__icontains=matricula)
return queryset
@action(detail=True, methods=['post'])
def actualizar_estado(self, request, pk=None):
"""Actualiza el estado del pedido"""
pedido = self.get_object()
nuevo_estado = request.data.get('estado')
if nuevo_estado in dict(PedidoCliente.ESTADOS):
pedido.estado = nuevo_estado
pedido.save()
return Response({'status': 'Estado actualizado'})
return Response({'error': 'Estado inválido'}, status=400)
class ReferenciaPedidoClienteViewSet(viewsets.ModelViewSet):
queryset = ReferenciaPedidoCliente.objects.all()
serializer_class = ReferenciaPedidoClienteSerializer
@action(detail=True, methods=['post'])
def marcar_stock(self, request, pk=None):
"""Marca unidades en stock para una referencia"""
referencia = self.get_object()
unidades = request.data.get('unidades_en_stock', 0)
referencia.unidades_en_stock = max(0, unidades)
referencia.save()
# Actualizar estado del pedido si es necesario
pedido = referencia.pedido_cliente
todas_completas = all(
ref.unidades_pendientes == 0
for ref in pedido.referencias.all()
)
if todas_completas and pedido.estado != 'completado':
pedido.estado = 'completado'
pedido.save()
elif pedido.estado == 'pendiente_revision':
pedido.estado = 'en_revision'
pedido.save()
return Response(ReferenciaPedidoClienteSerializer(referencia).data)
class ProveedorViewSet(viewsets.ModelViewSet):
queryset = Proveedor.objects.filter(activo=True)
serializer_class = ProveedorSerializer
class PedidoProveedorViewSet(viewsets.ModelViewSet):
queryset = PedidoProveedor.objects.select_related('proveedor').prefetch_related('referencias').all()
serializer_class = PedidoProveedorSerializer
def get_queryset(self):
queryset = super().get_queryset()
proveedor_id = self.request.query_params.get('proveedor_id')
if proveedor_id:
queryset = queryset.filter(proveedor_id=proveedor_id)
return queryset
@action(detail=False, methods=['post'])
def crear_manual(self, request):
"""Crea un pedido a proveedor manualmente"""
serializer = CrearPedidoProveedorSerializer(data=request.data)
if not serializer.is_valid():
return Response(serializer.errors, status=400)
data = serializer.validated_data
proveedor = Proveedor.objects.get(id=data['proveedor_id'])
pedido = PedidoProveedor.objects.create(
proveedor=proveedor,
numero_pedido=data.get('numero_pedido', ''),
tipo=data['tipo'],
estado='pendiente_recepcion'
)
# Crear referencias
for ref_data in data['referencias']:
ref_pedido_cliente_id = ref_data.get('referencia_pedido_cliente_id')
ref_pedido_cliente = None
if ref_pedido_cliente_id:
try:
ref_pedido_cliente = ReferenciaPedidoCliente.objects.get(id=ref_pedido_cliente_id)
except ReferenciaPedidoCliente.DoesNotExist:
pass
ReferenciaPedidoProveedor.objects.create(
pedido_proveedor=pedido,
referencia_pedido_cliente=ref_pedido_cliente,
referencia=ref_data['referencia'],
denominacion=ref_data.get('denominacion', ''),
unidades_pedidas=int(ref_data['unidades']),
)
return Response(PedidoProveedorSerializer(pedido).data, status=201)
class AlbaranViewSet(viewsets.ModelViewSet):
queryset = Albaran.objects.select_related('proveedor').prefetch_related('referencias').all()
serializer_class = AlbaranSerializer
def get_queryset(self):
queryset = super().get_queryset()
estado = self.request.query_params.get('estado_procesado')
if estado:
queryset = queryset.filter(estado_procesado=estado)
return queryset
@action(detail=False, methods=['post'])
def upload(self, request):
"""Sube un albarán desde móvil o web"""
if 'archivo' not in request.FILES:
return Response({'error': 'No se proporcionó archivo'}, status=400)
archivo = request.FILES['archivo']
# Guardar archivo
upload_dir = settings.ALBARANES_ESCANEADOS_DIR
upload_dir.mkdir(exist_ok=True)
file_path = upload_dir / archivo.name
with open(file_path, 'wb+') as destination:
for chunk in archivo.chunks():
destination.write(chunk)
# Procesar
try:
processor = AlbaranProcessor()
albaran = processor.process_albaran_file(file_path)
return Response(AlbaranSerializer(albaran).data, status=201)
except Exception as e:
return Response({'error': str(e)}, status=400)
@action(detail=True, methods=['post'])
def vincular_proveedor(self, request, pk=None):
"""Vincula un albarán a un proveedor manualmente"""
albaran = self.get_object()
proveedor_id = request.data.get('proveedor_id')
if not proveedor_id:
return Response({'error': 'proveedor_id requerido'}, status=400)
try:
proveedor = Proveedor.objects.get(id=proveedor_id)
albaran.proveedor = proveedor
albaran.estado_procesado = 'procesado'
albaran.save()
# Reprocesar para vincular referencias
processor = AlbaranProcessor()
processor._match_and_update_referencias(albaran)
return Response(AlbaranSerializer(albaran).data)
except Proveedor.DoesNotExist:
return Response({'error': 'Proveedor no encontrado'}, status=404)
class DevolucionViewSet(viewsets.ModelViewSet):
queryset = Devolucion.objects.select_related('proveedor').all()
serializer_class = DevolucionSerializer
def get_queryset(self):
queryset = super().get_queryset()
estado = self.request.query_params.get('estado_abono')
if estado:
queryset = queryset.filter(estado_abono=estado)
return queryset
@action(detail=True, methods=['post'])
def vincular_abono(self, request, pk=None):
"""Vincula una devolución con un albarán de abono"""
devolucion = self.get_object()
albaran_id = request.data.get('albaran_id')
if not albaran_id:
return Response({'error': 'albaran_id requerido'}, status=400)
try:
albaran = Albaran.objects.get(id=albaran_id)
devolucion.albaran_abono = albaran
devolucion.estado_abono = 'abonado'
devolucion.save()
return Response(DevolucionSerializer(devolucion).data)
except Albaran.DoesNotExist:
return Response({'error': 'Albarán no encontrado'}, status=404)
class StockReferenciaViewSet(viewsets.ModelViewSet):
queryset = StockReferencia.objects.all()
serializer_class = StockReferenciaSerializer
# Vista para Kanban
class KanbanView(APIView):
"""Vista para obtener datos del Kanban"""
def get(self, request):
pedidos = PedidoCliente.objects.select_related('cliente').prefetch_related(
Prefetch('referencias', queryset=ReferenciaPedidoCliente.objects.all())
).all()
# Agrupar por estado
kanban_data = {
'pendiente_revision': [],
'en_revision': [],
'pendiente_materiales': [],
'completado': [],
}
for pedido in pedidos:
pedido_data = PedidoClienteSerializer(pedido).data
kanban_data[pedido.estado].append(pedido_data)
# Si es request HTML, renderizar template
if request.accepted_renderer.format == 'html' or 'text/html' in request.META.get('HTTP_ACCEPT', ''):
return render(request, 'kanban.html')
return Response(kanban_data)
# Vista para referencias pendientes por proveedor
class ReferenciasProveedorView(APIView):
"""Vista para ver referencias pendientes por proveedor"""
def get(self, request):
proveedor_id = request.query_params.get('proveedor_id')
# Obtener referencias pendientes de pedidos a proveedor
queryset = ReferenciaPedidoProveedor.objects.filter(
estado__in=['pendiente', 'parcial']
).select_related(
'pedido_proveedor__proveedor',
'referencia_pedido_cliente__pedido_cliente__cliente'
)
if proveedor_id:
queryset = queryset.filter(pedido_proveedor__proveedor_id=proveedor_id)
# Agrupar por proveedor
proveedores_data = {}
for ref in queryset:
proveedor = ref.pedido_proveedor.proveedor
if proveedor.id not in proveedores_data:
proveedores_data[proveedor.id] = {
'proveedor': ProveedorSerializer(proveedor).data,
'referencias_pendientes': [],
'referencias_devolucion': [],
}
proveedores_data[proveedor.id]['referencias_pendientes'].append(
ReferenciaPedidoProveedorSerializer(ref).data
)
# Agregar devoluciones pendientes
devoluciones = Devolucion.objects.filter(estado_abono='pendiente')
if proveedor_id:
devoluciones = devoluciones.filter(proveedor_id=proveedor_id)
for dev in devoluciones:
if dev.proveedor.id not in proveedores_data:
proveedores_data[dev.proveedor.id] = {
'proveedor': ProveedorSerializer(dev.proveedor).data,
'referencias_pendientes': [],
'referencias_devolucion': [],
}
proveedores_data[dev.proveedor.id]['referencias_devolucion'].append(
DevolucionSerializer(dev).data
)
# Si es request HTML, renderizar template
if request.accepted_renderer.format == 'html' or 'text/html' in request.META.get('HTTP_ACCEPT', ''):
return render(request, 'proveedores.html', {
'proveedores_data': list(proveedores_data.values())
})
return Response(list(proveedores_data.values()))
# Vista para alertas
class AlertasView(APIView):
"""Vista para obtener alertas (pedidos urgentes)"""
def get(self, request):
ahora = timezone.now()
limite = ahora + timedelta(hours=12)
pedidos_urgentes = PedidoCliente.objects.filter(
fecha_cita__gte=ahora,
fecha_cita__lte=limite,
estado__in=['pendiente_revision', 'en_revision', 'pendiente_materiales']
).select_related('cliente').prefetch_related('referencias')
alertas = []
for pedido in pedidos_urgentes:
referencias_faltantes = [
ref for ref in pedido.referencias.all()
if ref.unidades_pendientes > 0
]
if referencias_faltantes:
alertas.append({
'pedido': PedidoClienteSerializer(pedido).data,
'referencias_faltantes': ReferenciaPedidoClienteSerializer(
referencias_faltantes, many=True
).data,
'horas_restantes': (pedido.fecha_cita - ahora).total_seconds() / 3600,
})
return Response(alertas)

23
manage.py Normal file
View File

@@ -0,0 +1,23 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
"""Run administrative tasks."""
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'pedidos_clientes.settings')
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == '__main__':
main()

18
package.json Normal file
View File

@@ -0,0 +1,18 @@
{
"name": "pedidos-clientes-ayutec",
"version": "1.0.0",
"description": "Sistema de gestión de pedidos con Prisma",
"scripts": {
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"prisma:studio": "prisma studio",
"prisma:push": "prisma db push"
},
"devDependencies": {
"prisma": "^5.7.0"
},
"dependencies": {
"@prisma/client": "^5.7.0"
}
}

View File

17
pedidos_clientes/asgi.py Normal file
View File

@@ -0,0 +1,17 @@
"""
ASGI config for pedidos_clientes project.
It exposes the ASGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/4.2/howto/deployment/asgi/
"""
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'pedidos_clientes.settings')
application = get_asgi_application()

View File

@@ -0,0 +1,199 @@
"""
Django settings for pedidos_clientes project.
"""
from pathlib import Path
import os
from dotenv import load_dotenv
load_dotenv()
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = os.getenv('SECRET_KEY', 'django-insecure-change-this-in-production')
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
ALLOWED_HOSTS = ['*']
# Application definition
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'rest_framework',
'corsheaders',
'gestion_pedidos',
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'corsheaders.middleware.CorsMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
ROOT_URLCONF = 'pedidos_clientes.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [BASE_DIR / 'templates'],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]
WSGI_APPLICATION = 'pedidos_clientes.wsgi.application'
# Database
# https://docs.djangoproject.com/en/4.2/ref/settings/#databases
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': os.getenv('DB_NAME', 'pedidos_clientes'),
'USER': os.getenv('DB_USER', 'postgres'),
'PASSWORD': os.getenv('DB_PASSWORD', 'postgres'),
'HOST': os.getenv('DB_HOST', 'localhost'),
'PORT': os.getenv('DB_PORT', '5432'),
}
}
# Password validation
# https://docs.djangoproject.com/en/4.2/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
},
]
# Internationalization
# https://docs.djangoproject.com/en/4.2/topics/i18n/
LANGUAGE_CODE = 'es-es'
TIME_ZONE = 'Europe/Madrid'
USE_I18N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/4.2/howto/static-files/
STATIC_URL = 'static/'
STATIC_ROOT = BASE_DIR / 'staticfiles'
STATICFILES_DIRS = [BASE_DIR / 'static']
# Media files
MEDIA_URL = 'media/'
MEDIA_ROOT = BASE_DIR / 'media'
# Default primary key field type
# https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
# REST Framework
REST_FRAMEWORK = {
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
'PAGE_SIZE': 100,
'DEFAULT_RENDERER_CLASSES': [
'rest_framework.renderers.JSONRenderer',
'rest_framework.renderers.BrowsableAPIRenderer',
],
'DEFAULT_PARSER_CLASSES': [
'rest_framework.parsers.JSONParser',
'rest_framework.parsers.MultiPartParser',
'rest_framework.parsers.FormParser',
],
}
# CORS
CORS_ALLOW_ALL_ORIGINS = True # Cambiar en producción
# OpenAI Configuration
OPENAI_API_KEY = os.getenv('OPENAI_API_KEY', '')
# Carpetas para archivos
PEDIDOS_CLIENTES_PDF_DIR = BASE_DIR / 'pedidos_clientes_pdf'
ALBARANES_ESCANEADOS_DIR = BASE_DIR / 'albaranes_escaneados'
EMAILS_PROVEEDORES_DIR = BASE_DIR / 'emails_proveedores'
# Crear carpetas si no existen
PEDIDOS_CLIENTES_PDF_DIR.mkdir(exist_ok=True)
ALBARANES_ESCANEADOS_DIR.mkdir(exist_ok=True)
EMAILS_PROVEEDORES_DIR.mkdir(exist_ok=True)
# Logging
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'handlers': {
'console': {
'class': 'logging.StreamHandler',
},
'file': {
'class': 'logging.FileHandler',
'filename': BASE_DIR / 'logs' / 'app.log',
},
},
'root': {
'handlers': ['console', 'file'],
'level': 'INFO',
},
'loggers': {
'django': {
'handlers': ['console', 'file'],
'level': 'INFO',
'propagate': False,
},
'gestion_pedidos': {
'handlers': ['console', 'file'],
'level': 'DEBUG',
'propagate': False,
},
},
}
# Crear carpeta de logs
(BASE_DIR / 'logs').mkdir(exist_ok=True)

18
pedidos_clientes/urls.py Normal file
View File

@@ -0,0 +1,18 @@
"""
URL configuration for pedidos_clientes project.
"""
from django.contrib import admin
from django.urls import path, include
from django.conf import settings
from django.conf.urls.static import static
urlpatterns = [
path('admin/', admin.site.urls),
path('api/', include('gestion_pedidos.urls')),
path('', include('gestion_pedidos.urls')),
]
if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)

17
pedidos_clientes/wsgi.py Normal file
View File

@@ -0,0 +1,17 @@
"""
WSGI config for pedidos_clientes project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/4.2/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'pedidos_clientes.settings')
application = get_wsgi_application()

211
prisma/schema.prisma Normal file
View File

@@ -0,0 +1,211 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
output = "../node_modules/.prisma/client"
}
generator python {
provider = "prisma-client-py"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Cliente {
id Int @id @default(autoincrement())
nombre String @db.VarChar(200)
matriculaVehiculo String @unique @map("matricula_vehiculo") @db.VarChar(20)
telefono String? @db.VarChar(20)
email String? @db.VarChar(255)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
pedidos PedidoCliente[]
@@index([matriculaVehiculo])
@@index([nombre])
@@map("clientes")
}
model PedidoCliente {
id Int @id @default(autoincrement())
numeroPedido String @unique @map("numero_pedido") @db.VarChar(50)
clienteId Int @map("cliente_id")
fechaPedido DateTime @default(now()) @map("fecha_pedido")
fechaCita DateTime? @map("fecha_cita")
estado String @default("pendiente_revision") @db.VarChar(30)
presupuestoId String? @map("presupuesto_id") @db.VarChar(50)
archivoPdfPath String? @map("archivo_pdf_path") @db.VarChar(500)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
cliente Cliente @relation(fields: [clienteId], references: [id], onDelete: Cascade)
referencias ReferenciaPedidoCliente[]
@@index([numeroPedido])
@@index([estado])
@@index([fechaCita])
@@index([fechaPedido])
@@map("pedidos_cliente")
}
model ReferenciaPedidoCliente {
id Int @id @default(autoincrement())
pedidoClienteId Int @map("pedido_cliente_id")
referencia String @db.VarChar(100)
denominacion String @db.VarChar(500)
unidadesSolicitadas Int @default(1) @map("unidades_solicitadas")
unidadesEnStock Int @default(0) @map("unidades_en_stock")
unidadesPendientes Int @default(0) @map("unidades_pendientes")
estado String @default("pendiente") @db.VarChar(20)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
pedidoCliente PedidoCliente @relation(fields: [pedidoClienteId], references: [id], onDelete: Cascade)
pedidosProveedor ReferenciaPedidoProveedor[]
@@index([referencia])
@@index([estado])
@@index([pedidoClienteId, estado])
@@map("referencias_pedido_cliente")
}
model Proveedor {
id Int @id @default(autoincrement())
nombre String @db.VarChar(200)
email String? @db.VarChar(255)
tieneWeb Boolean @default(true) @map("tiene_web")
activo Boolean @default(true)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
pedidos PedidoProveedor[]
albaranes Albaran[]
devoluciones Devolucion[]
@@index([nombre])
@@index([activo])
@@map("proveedores")
}
model PedidoProveedor {
id Int @id @default(autoincrement())
proveedorId Int @map("proveedor_id")
numeroPedido String? @map("numero_pedido") @db.VarChar(100)
fechaPedido DateTime @default(now()) @map("fecha_pedido")
emailConfirmacionPath String? @map("email_confirmacion_path") @db.VarChar(500)
estado String @default("pendiente_recepcion") @db.VarChar(30)
tipo String @default("web") @db.VarChar(20)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
proveedor Proveedor @relation(fields: [proveedorId], references: [id], onDelete: Cascade)
referencias ReferenciaPedidoProveedor[]
@@index([proveedorId, estado])
@@index([fechaPedido])
@@index([estado])
@@map("pedidos_proveedor")
}
model ReferenciaPedidoProveedor {
id Int @id @default(autoincrement())
pedidoProveedorId Int @map("pedido_proveedor_id")
referenciaPedidoClienteId Int? @map("referencia_pedido_cliente_id")
referencia String @db.VarChar(100)
denominacion String @db.VarChar(500)
unidadesPedidas Int @default(1) @map("unidades_pedidas")
unidadesRecibidas Int @default(0) @map("unidades_recibidas")
estado String @default("pendiente") @db.VarChar(20)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
pedidoProveedor PedidoProveedor @relation(fields: [pedidoProveedorId], references: [id], onDelete: Cascade)
referenciaPedidoCliente ReferenciaPedidoCliente? @relation(fields: [referenciaPedidoClienteId], references: [id], onDelete: Cascade)
referenciasAlbaran ReferenciaAlbaran[]
@@index([referencia])
@@index([estado])
@@index([pedidoProveedorId, estado])
@@map("referencias_pedido_proveedor")
}
model Albaran {
id Int @id @default(autoincrement())
proveedorId Int? @map("proveedor_id")
numeroAlbaran String? @map("numero_albaran") @db.VarChar(100)
fechaAlbaran DateTime? @map("fecha_albaran") @db.Date
archivoPath String @map("archivo_path") @db.VarChar(500)
estadoProcesado String @default("pendiente") @map("estado_procesado") @db.VarChar(30)
fechaProcesado DateTime? @map("fecha_procesado")
datosOcr Json @default("{}") @map("datos_ocr")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
proveedor Proveedor? @relation(fields: [proveedorId], references: [id], onDelete: Cascade)
referencias ReferenciaAlbaran[]
devoluciones Devolucion[]
@@index([proveedorId, estadoProcesado])
@@index([numeroAlbaran])
@@index([fechaAlbaran])
@@index([estadoProcesado])
@@map("albaranes")
}
model ReferenciaAlbaran {
id Int @id @default(autoincrement())
albaranId Int @map("albaran_id")
referencia String @db.VarChar(100)
denominacion String @db.VarChar(500)
unidades Int @default(1)
precioUnitario Decimal @default(0) @map("precio_unitario") @db.Decimal(10, 2)
impuestoTipo String @default("21") @map("impuesto_tipo") @db.VarChar(5)
impuestoValor Decimal @default(0) @map("impuesto_valor") @db.Decimal(10, 2)
referenciaPedidoProveedorId Int? @map("referencia_pedido_proveedor_id")
createdAt DateTime @default(now()) @map("created_at")
albaran Albaran @relation(fields: [albaranId], references: [id], onDelete: Cascade)
referenciaPedidoProveedor ReferenciaPedidoProveedor? @relation(fields: [referenciaPedidoProveedorId], references: [id], onDelete: SetNull)
@@index([referencia])
@@index([albaranId])
@@map("referencias_albaran")
}
model Devolucion {
id Int @id @default(autoincrement())
proveedorId Int @map("proveedor_id")
referencia String @db.VarChar(100)
denominacion String? @db.VarChar(500)
unidades Int @default(1)
fechaDevolucion DateTime @default(now()) @map("fecha_devolucion")
estadoAbono String @default("pendiente") @map("estado_abono") @db.VarChar(20)
albaranAbonoId Int? @map("albaran_abono_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
proveedor Proveedor @relation(fields: [proveedorId], references: [id], onDelete: Cascade)
albaranAbono Albaran? @relation(fields: [albaranAbonoId], references: [id], onDelete: SetNull)
@@index([proveedorId, estadoAbono])
@@index([referencia])
@@index([estadoAbono])
@@map("devoluciones")
}
model StockReferencia {
id Int @id @default(autoincrement())
referencia String @unique @db.VarChar(100)
unidadesDisponibles Int @default(0) @map("unidades_disponibles")
ultimaActualizacion DateTime @updatedAt @map("ultima_actualizacion")
createdAt DateTime @default(now()) @map("created_at")
@@index([referencia])
@@map("stock_referencias")
}

13
requirements.txt Normal file
View File

@@ -0,0 +1,13 @@
Django==4.2.7
djangorestframework==3.14.0
psycopg2-binary==2.9.9
openai==1.3.5
Pillow==10.1.0
pdf2image==1.16.3
python-dotenv==1.0.0
watchdog==3.0.0
celery==5.3.4
redis==5.0.1
django-cors-headers==4.3.1
python-multipart==0.0.6

13
requirements_prisma.txt Normal file
View File

@@ -0,0 +1,13 @@
# Django removido, usando FastAPI con Prisma
fastapi==0.104.1
uvicorn[standard]==0.24.0
prisma==0.11.0
python-dotenv==1.0.0
openai==1.3.5
Pillow==10.1.0
pdf2image==1.16.3
watchdog==3.0.0
python-multipart==0.0.6
pydantic==2.5.0
pydantic-settings==2.1.0

15
run.py Normal file
View File

@@ -0,0 +1,15 @@
"""
Script para ejecutar la aplicación FastAPI
"""
import uvicorn
from app.config import settings
if __name__ == "__main__":
uvicorn.run(
"app.main:app",
host="0.0.0.0",
port=8000,
reload=settings.DEBUG,
log_level="info"
)

67
setup.bat Normal file
View File

@@ -0,0 +1,67 @@
@echo off
echo ========================================
echo Configuracion del Sistema
echo ========================================
echo.
echo [1/5] Instalando dependencias Node.js...
call npm install
if errorlevel 1 (
echo ERROR: No se pudo instalar dependencias Node.js
pause
exit /b 1
)
echo.
echo [2/5] Instalando dependencias Python...
pip install -r requirements_prisma.txt
if errorlevel 1 (
echo ERROR: No se pudo instalar dependencias Python
pause
exit /b 1
)
echo.
echo [3/5] Verificando archivo .env...
if not exist .env (
echo.
echo ADVERTENCIA: El archivo .env no existe
echo Creando archivo .env de ejemplo...
(
echo DATABASE_URL=postgresql://postgres:postgres@localhost:5432/pedidos_clientes
echo OPENAI_API_KEY=tu-openai-api-key-aqui
echo DEBUG=True
) > .env
echo.
echo Por favor edita .env con tus credenciales antes de continuar
pause
)
echo.
echo [4/5] Generando cliente Prisma...
call prisma generate
if errorlevel 1 (
echo ERROR: No se pudo generar el cliente Prisma
echo Verifica que Prisma CLI esté instalado: npm install
pause
exit /b 1
)
echo.
echo [5/5] Creando migraciones de base de datos...
echo.
echo IMPORTANTE: Asegurate de que PostgreSQL esté corriendo
echo y que la base de datos esté configurada en .env
echo.
pause
call prisma migrate dev --name init
echo.
echo ========================================
echo Configuracion completada!
echo ========================================
echo.
echo Siguiente paso: Ejecutar start.bat para iniciar el sistema
echo.
pause

27
start.bat Normal file
View File

@@ -0,0 +1,27 @@
@echo off
echo ========================================
echo Iniciando Sistema de Gestion de Pedidos
echo ========================================
echo.
echo [1/2] Iniciando Backend...
start "Backend - FastAPI" cmd /k "python run.py"
timeout /t 3 /nobreak >nul
echo [2/2] Iniciando Frontend...
cd frontend
start "Frontend - HTTP Server" cmd /k "python -m http.server 3000"
cd ..
echo.
echo ========================================
echo Sistema iniciado!
echo.
echo Backend: http://localhost:8000
echo Frontend: http://localhost:3000
echo Docs: http://localhost:8000/docs
echo ========================================
echo.
pause

34
start.sh Normal file
View File

@@ -0,0 +1,34 @@
#!/bin/bash
echo "========================================"
echo "Iniciando Sistema de Gestión de Pedidos"
echo "========================================"
echo ""
echo "[1/2] Iniciando Backend..."
python run.py &
BACKEND_PID=$!
sleep 3
echo "[2/2] Iniciando Frontend..."
cd frontend
python -m http.server 3000 &
FRONTEND_PID=$!
cd ..
echo ""
echo "========================================"
echo "Sistema iniciado!"
echo ""
echo "Backend: http://localhost:8000"
echo "Frontend: http://localhost:3000"
echo "Docs: http://localhost:8000/docs"
echo "========================================"
echo ""
echo "Presiona Ctrl+C para detener ambos servidores"
# Esperar a que el usuario presione Ctrl+C
trap "kill $BACKEND_PID $FRONTEND_PID; exit" INT
wait

0
static/.gitkeep Normal file
View File

310
templates/admin_panel.html Normal file
View File

@@ -0,0 +1,310 @@
{% extends 'base.html' %}
{% block title %}Panel de Administración{% endblock %}
{% block extra_css %}
<style>
.admin-container {
max-width: 1400px;
margin: 0 auto;
}
.tabs {
display: flex;
gap: 1rem;
margin-bottom: 2rem;
border-bottom: 2px solid #ecf0f1;
}
.tab {
padding: 1rem 2rem;
cursor: pointer;
border: none;
background: none;
font-size: 1rem;
color: #7f8c8d;
border-bottom: 3px solid transparent;
transition: all 0.2s;
}
.tab.active {
color: #3498db;
border-bottom-color: #3498db;
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
.albaran-card {
background: white;
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 1rem;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.albaran-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
padding-bottom: 1rem;
border-bottom: 1px solid #ecf0f1;
}
.albaran-image {
max-width: 100%;
max-height: 400px;
border-radius: 4px;
margin: 1rem 0;
}
.ocr-data {
background: #f8f9fa;
padding: 1rem;
border-radius: 4px;
margin: 1rem 0;
font-family: monospace;
font-size: 0.9rem;
white-space: pre-wrap;
max-height: 300px;
overflow-y: auto;
}
.referencias-table {
width: 100%;
border-collapse: collapse;
margin-top: 1rem;
}
.referencias-table th,
.referencias-table td {
padding: 0.75rem;
text-align: left;
border-bottom: 1px solid #ecf0f1;
}
.referencias-table th {
background: #f8f9fa;
font-weight: bold;
}
.badge {
padding: 0.25rem 0.5rem;
border-radius: 12px;
font-size: 0.75rem;
font-weight: bold;
}
.badge.pendiente {
background: #fff3cd;
color: #856404;
}
.badge.procesado {
background: #d4edda;
color: #155724;
}
.badge.clasificacion {
background: #f8d7da;
color: #721c24;
}
</style>
{% endblock %}
{% block content %}
<div class="admin-container">
<h1 style="margin-bottom: 2rem;">Panel de Administración</h1>
<div class="tabs">
<button class="tab active" onclick="showTab('albaranes')">Albaranes</button>
<button class="tab" onclick="showTab('clasificacion')">Pendientes de Clasificación</button>
</div>
<div id="tab-albaranes" class="tab-content active">
<div id="albaranes-container"></div>
</div>
<div id="tab-clasificacion" class="tab-content">
<div id="clasificacion-container"></div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
const API_BASE = '/api';
function showTab(tabName) {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active'));
event.target.classList.add('active');
document.getElementById(`tab-${tabName}`).classList.add('active');
if (tabName === 'albaranes') {
loadAlbaranes();
} else if (tabName === 'clasificacion') {
loadClasificacion();
}
}
function loadAlbaranes() {
fetch(`${API_BASE}/albaranes/`)
.then(response => response.json())
.then(data => {
const albaranes = data.results || data;
renderAlbaranes(albaranes);
});
}
function loadClasificacion() {
fetch(`${API_BASE}/albaranes/?estado_procesado=clasificacion`)
.then(response => response.json())
.then(data => {
const albaranes = data.results || data;
renderClasificacion(albaranes);
});
}
function renderAlbaranes(albaranes) {
const container = document.getElementById('albaranes-container');
container.innerHTML = '';
albaranes.forEach(albaran => {
const card = document.createElement('div');
card.className = 'albaran-card';
const estadoClass = albaran.estado_procesado === 'procesado' ? 'procesado' :
albaran.estado_procesado === 'clasificacion' ? 'clasificacion' : 'pendiente';
card.innerHTML = `
<div class="albaran-header">
<div>
<h3>Albarán ${albaran.numero_albaran || albaran.id}</h3>
<p>Proveedor: ${albaran.proveedor ? albaran.proveedor.nombre : 'Sin asignar'}</p>
<p>Fecha: ${albaran.fecha_albaran || 'N/A'}</p>
</div>
<span class="badge ${estadoClass}">${albaran.estado_procesado}</span>
</div>
<img src="/media/${albaran.archivo_path}" class="albaran-image" alt="Albarán">
<div class="ocr-data">${JSON.stringify(albaran.datos_ocr, null, 2)}</div>
${albaran.referencias && albaran.referencias.length > 0 ? `
<table class="referencias-table">
<thead>
<tr>
<th>Referencia</th>
<th>Descripción</th>
<th>Unidades</th>
<th>Precio</th>
<th>IVA</th>
</tr>
</thead>
<tbody>
${albaran.referencias.map(ref => `
<tr>
<td>${ref.referencia}</td>
<td>${ref.denominacion}</td>
<td>${ref.unidades}</td>
<td>${ref.precio_unitario}€</td>
<td>${ref.impuesto_tipo}%</td>
</tr>
`).join('')}
</tbody>
</table>
` : ''}
`;
container.appendChild(card);
});
}
function renderClasificacion(albaranes) {
const container = document.getElementById('clasificacion-container');
container.innerHTML = '';
albaranes.forEach(albaran => {
const card = document.createElement('div');
card.className = 'albaran-card';
card.innerHTML = `
<div class="albaran-header">
<h3>Albarán ${albaran.numero_albaran || albaran.id}</h3>
</div>
<img src="/media/${albaran.archivo_path}" class="albaran-image" alt="Albarán">
<div style="margin-top: 1rem;">
<label>Asignar Proveedor:</label>
<select id="proveedor-${albaran.id}" style="padding: 0.5rem; margin: 0.5rem 0; width: 100%;">
<option value="">Seleccionar proveedor...</option>
</select>
<button class="btn btn-primary" onclick="vincularProveedor(${albaran.id})" style="margin-top: 0.5rem;">
Vincular Proveedor
</button>
</div>
`;
container.appendChild(card);
});
// Cargar proveedores
loadProveedores();
}
function loadProveedores() {
fetch(`${API_BASE}/proveedores/`)
.then(response => response.json())
.then(data => {
const proveedores = data.results || data;
proveedores.forEach(prov => {
document.querySelectorAll('select[id^="proveedor-"]').forEach(select => {
const option = document.createElement('option');
option.value = prov.id;
option.textContent = prov.nombre;
select.appendChild(option);
});
});
});
}
function vincularProveedor(albaranId) {
const select = document.getElementById(`proveedor-${albaranId}`);
const proveedorId = select.value;
if (!proveedorId) {
alert('Selecciona un proveedor');
return;
}
fetch(`${API_BASE}/albaranes/${albaranId}/vincular_proveedor/`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ proveedor_id: proveedorId })
})
.then(response => response.json())
.then(data => {
alert('Proveedor vinculado correctamente');
loadClasificacion();
})
.catch(error => {
alert('Error al vincular proveedor: ' + error.message);
});
}
// Cargar inicial
loadAlbaranes();
</script>
{% endblock %}

100
templates/base.html Normal file
View File

@@ -0,0 +1,100 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Gestión de Pedidos{% endblock %}</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: #f5f5f5;
color: #333;
}
.header {
background: #2c3e50;
color: white;
padding: 1rem 2rem;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.header h1 {
font-size: 1.5rem;
}
.nav {
background: #34495e;
padding: 0.5rem 2rem;
}
.nav a {
color: white;
text-decoration: none;
margin-right: 2rem;
padding: 0.5rem 1rem;
display: inline-block;
border-radius: 4px;
transition: background 0.2s;
}
.nav a:hover, .nav a.active {
background: #2c3e50;
}
.container {
max-width: 100%;
padding: 2rem;
}
.btn {
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
transition: all 0.2s;
}
.btn-primary {
background: #3498db;
color: white;
}
.btn-primary:hover {
background: #2980b9;
}
.btn-success {
background: #27ae60;
color: white;
}
.btn-danger {
background: #e74c3c;
color: white;
}
</style>
{% block extra_css %}{% endblock %}
</head>
<body>
<div class="header">
<h1>Gestión de Pedidos de Recambios</h1>
</div>
<nav class="nav">
<a href="{% url 'kanban' %}" class="{% if request.resolver_match.url_name == 'kanban' %}active{% endif %}">Kanban</a>
<a href="{% url 'referencias-proveedor' %}" class="{% if request.resolver_match.url_name == 'referencias-proveedor' %}active{% endif %}">Proveedores</a>
<a href="/admin/" target="_blank">Administración</a>
</nav>
<div class="container">
{% block content %}{% endblock %}
</div>
{% block extra_js %}{% endblock %}
</body>
</html>

319
templates/kanban.html Normal file
View File

@@ -0,0 +1,319 @@
{% extends 'base.html' %}
{% block title %}Kanban - Gestión de Pedidos{% endblock %}
{% block extra_css %}
<style>
.kanban-container {
display: flex;
gap: 1rem;
overflow-x: auto;
padding: 1rem 0;
min-height: calc(100vh - 200px);
}
.kanban-column {
flex: 1;
min-width: 300px;
background: #ecf0f1;
border-radius: 8px;
padding: 1rem;
display: flex;
flex-direction: column;
}
.column-header {
background: #34495e;
color: white;
padding: 1rem;
border-radius: 4px;
margin-bottom: 1rem;
font-weight: bold;
text-align: center;
}
.column-header.pendiente-revision { background: #95a5a6; }
.column-header.en-revision { background: #f39c12; }
.column-header.pendiente-materiales { background: #e74c3c; }
.column-header.completado { background: #27ae60; }
.card {
background: white;
border-radius: 6px;
padding: 1rem;
margin-bottom: 1rem;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
}
.card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
}
.card.urgente {
border-left: 4px solid #e74c3c;
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.8; }
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.card-title {
font-weight: bold;
font-size: 1.1rem;
}
.card-subtitle {
color: #7f8c8d;
font-size: 0.9rem;
}
.urgente-badge {
background: #e74c3c;
color: white;
padding: 0.2rem 0.5rem;
border-radius: 12px;
font-size: 0.75rem;
font-weight: bold;
}
.referencias-list {
margin-top: 0.5rem;
}
.referencia-item {
padding: 0.5rem;
margin: 0.25rem 0;
border-radius: 4px;
font-size: 0.9rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.referencia-item.completo {
background: #d5f4e6;
color: #27ae60;
}
.referencia-item.parcial {
background: #fff3cd;
color: #856404;
}
.referencia-item.pendiente {
background: #f8d7da;
color: #721c24;
}
.referencia-codigo {
font-weight: bold;
font-family: monospace;
}
.filters {
background: white;
padding: 1rem;
border-radius: 8px;
margin-bottom: 1rem;
display: flex;
gap: 1rem;
align-items: center;
flex-wrap: wrap;
}
.filter-input {
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 0.9rem;
}
.refresh-btn {
margin-left: auto;
}
.empty-column {
text-align: center;
color: #95a5a6;
padding: 2rem;
font-style: italic;
}
</style>
{% endblock %}
{% block content %}
<div class="filters">
<input type="text" id="search-input" class="filter-input" placeholder="Buscar por matrícula o número de pedido...">
<select id="estado-filter" class="filter-input">
<option value="">Todos los estados</option>
<option value="pendiente_revision">Pendiente Revisión</option>
<option value="en_revision">En Revisión</option>
<option value="pendiente_materiales">Pendiente Materiales</option>
<option value="completado">Completado</option>
</select>
<label>
<input type="checkbox" id="urgente-only"> Solo urgentes
</label>
<button class="btn btn-primary refresh-btn" onclick="loadKanban()">Actualizar</button>
</div>
<div class="kanban-container" id="kanban-container">
<div class="kanban-column">
<div class="column-header pendiente-revision">Pendiente Revisión</div>
<div id="col-pendiente-revision" class="cards-container"></div>
</div>
<div class="kanban-column">
<div class="column-header en-revision">En Revisión</div>
<div id="col-en-revision" class="cards-container"></div>
</div>
<div class="kanban-column">
<div class="column-header pendiente-materiales">Pendiente Materiales</div>
<div id="col-pendiente-materiales" class="cards-container"></div>
</div>
<div class="kanban-column">
<div class="column-header completado">Completado</div>
<div id="col-completado" class="cards-container"></div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
const API_BASE = '/api';
function createCard(pedido) {
const card = document.createElement('div');
card.className = 'card';
if (pedido.es_urgente) {
card.classList.add('urgente');
}
const fechaCita = pedido.fecha_cita ? new Date(pedido.fecha_cita).toLocaleString('es-ES') : 'Sin fecha';
card.innerHTML = `
<div class="card-header">
<div>
<div class="card-title">Pedido ${pedido.numero_pedido}</div>
<div class="card-subtitle">${pedido.cliente.matricula_vehiculo} - ${pedido.cliente.nombre}</div>
</div>
${pedido.es_urgente ? '<span class="urgente-badge">URGENTE</span>' : ''}
</div>
<div class="card-subtitle" style="margin-bottom: 0.5rem;">Cita: ${fechaCita}</div>
<div class="referencias-list">
${pedido.referencias.map(ref => {
const estadoClass = ref.estado === 'completo' ? 'completo' :
ref.estado === 'parcial' ? 'parcial' : 'pendiente';
return `
<div class="referencia-item ${estadoClass}">
<div>
<span class="referencia-codigo">${ref.referencia}</span>
<div style="font-size: 0.8rem;">${ref.denominacion}</div>
</div>
<div style="text-align: right;">
<div>${ref.unidades_solicitadas} unidades</div>
<div style="font-size: 0.8rem;">
Stock: ${ref.unidades_en_stock} | Pendiente: ${ref.unidades_pendientes}
</div>
</div>
</div>
`;
}).join('')}
</div>
`;
card.onclick = () => {
window.location.href = `/admin/gestion_pedidos/pedidocliente/${pedido.id}/change/`;
};
return card;
}
function renderKanban(data) {
const columns = {
'pendiente_revision': document.getElementById('col-pendiente-revision'),
'en_revision': document.getElementById('col-en-revision'),
'pendiente_materiales': document.getElementById('col-pendiente-materiales'),
'completado': document.getElementById('col-completado'),
};
// Limpiar columnas
Object.values(columns).forEach(col => {
col.innerHTML = '';
});
// Renderizar tarjetas
Object.entries(data).forEach(([estado, pedidos]) => {
const column = columns[estado];
if (!column) return;
if (pedidos.length === 0) {
column.innerHTML = '<div class="empty-column">No hay pedidos</div>';
} else {
pedidos.forEach(pedido => {
column.appendChild(createCard(pedido));
});
}
});
}
function loadKanban() {
fetch(`${API_BASE}/kanban/`)
.then(response => response.json())
.then(data => {
renderKanban(data);
})
.catch(error => {
console.error('Error al cargar Kanban:', error);
});
}
// Filtros
document.getElementById('search-input').addEventListener('input', (e) => {
const search = e.target.value.toLowerCase();
document.querySelectorAll('.card').forEach(card => {
const text = card.textContent.toLowerCase();
card.style.display = text.includes(search) ? 'block' : 'none';
});
});
document.getElementById('estado-filter').addEventListener('change', (e) => {
const estado = e.target.value;
if (!estado) {
loadKanban();
return;
}
fetch(`${API_BASE}/pedidos-cliente/?estado=${estado}`)
.then(response => response.json())
.then(data => {
const grouped = {
'pendiente_revision': [],
'en_revision': [],
'pendiente_materiales': [],
'completado': [],
};
grouped[estado] = data.results || data;
renderKanban(grouped);
});
});
// Auto-refresh cada 30 segundos
setInterval(loadKanban, 30000);
// Cargar inicial
loadKanban();
</script>
{% endblock %}

View File

@@ -0,0 +1,195 @@
{% extends 'base.html' %}
{% block title %}Subir Albarán{% endblock %}
{% block extra_css %}
<style>
.upload-container {
max-width: 600px;
margin: 2rem auto;
background: white;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.upload-area {
border: 2px dashed #3498db;
border-radius: 8px;
padding: 3rem;
text-align: center;
cursor: pointer;
transition: all 0.3s;
background: #f8f9fa;
}
.upload-area:hover {
background: #e9ecef;
border-color: #2980b9;
}
.upload-area.dragover {
background: #d4edda;
border-color: #27ae60;
}
.upload-icon {
font-size: 3rem;
color: #3498db;
margin-bottom: 1rem;
}
.file-input {
display: none;
}
.preview-image {
max-width: 100%;
max-height: 400px;
margin-top: 1rem;
border-radius: 4px;
}
.btn-upload {
margin-top: 1rem;
width: 100%;
}
.status-message {
margin-top: 1rem;
padding: 1rem;
border-radius: 4px;
display: none;
}
.status-message.success {
background: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.status-message.error {
background: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
</style>
{% endblock %}
{% block content %}
<div class="upload-container">
<h2 style="margin-bottom: 2rem;">Subir Albarán</h2>
<div class="upload-area" id="upload-area">
<div class="upload-icon">📄</div>
<p>Arrastra una imagen aquí o haz clic para seleccionar</p>
<p style="font-size: 0.9rem; color: #7f8c8d; margin-top: 0.5rem;">
Formatos soportados: JPG, PNG, PDF
</p>
<input type="file" id="file-input" class="file-input" accept="image/*,.pdf">
</div>
<div id="preview-container" style="display: none;">
<img id="preview-image" class="preview-image" alt="Vista previa">
<button class="btn btn-primary btn-upload" onclick="uploadFile()">Subir Albarán</button>
</div>
<div id="status-message" class="status-message"></div>
</div>
{% endblock %}
{% block extra_js %}
<script>
const API_BASE = '/api';
let selectedFile = null;
const uploadArea = document.getElementById('upload-area');
const fileInput = document.getElementById('file-input');
const previewContainer = document.getElementById('preview-container');
const previewImage = document.getElementById('preview-image');
const statusMessage = document.getElementById('status-message');
uploadArea.addEventListener('click', () => fileInput.click());
uploadArea.addEventListener('dragover', (e) => {
e.preventDefault();
uploadArea.classList.add('dragover');
});
uploadArea.addEventListener('dragleave', () => {
uploadArea.classList.remove('dragover');
});
uploadArea.addEventListener('drop', (e) => {
e.preventDefault();
uploadArea.classList.remove('dragover');
const files = e.dataTransfer.files;
if (files.length > 0) {
handleFile(files[0]);
}
});
fileInput.addEventListener('change', (e) => {
if (e.target.files.length > 0) {
handleFile(e.target.files[0]);
}
});
function handleFile(file) {
selectedFile = file;
if (file.type.startsWith('image/')) {
const reader = new FileReader();
reader.onload = (e) => {
previewImage.src = e.target.result;
previewContainer.style.display = 'block';
};
reader.readAsDataURL(file);
} else if (file.type === 'application/pdf') {
previewImage.src = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCIgZmlsbD0iI2VjZjBmMSIvPjx0ZXh0IHg9IjUwJSIgeT0iNTAlIiBmb250LWZhbWlseT0iQXJpYWwiIGZvbnQtc2l6ZT0iMTgiIGZpbGw9IiM3ZjhjOGQiIHRleHQtYW5jaG9yPSJtaWRkbGUiIGR5PSIuM2VtIj5QREY8L3RleHQ+PC9zdmc+';
previewContainer.style.display = 'block';
}
}
function uploadFile() {
if (!selectedFile) return;
const formData = new FormData();
formData.append('archivo', selectedFile);
statusMessage.style.display = 'none';
const btn = document.querySelector('.btn-upload');
btn.disabled = true;
btn.textContent = 'Subiendo...';
fetch(`${API_BASE}/albaranes/upload/`, {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
statusMessage.className = 'status-message success';
statusMessage.textContent = 'Albarán subido y procesado correctamente';
statusMessage.style.display = 'block';
// Reset
setTimeout(() => {
selectedFile = null;
fileInput.value = '';
previewContainer.style.display = 'none';
statusMessage.style.display = 'none';
btn.disabled = false;
btn.textContent = 'Subir Albarán';
}, 3000);
})
.catch(error => {
statusMessage.className = 'status-message error';
statusMessage.textContent = 'Error al subir el albarán: ' + error.message;
statusMessage.style.display = 'block';
btn.disabled = false;
btn.textContent = 'Subir Albarán';
});
}
</script>
{% endblock %}

151
templates/proveedores.html Normal file
View File

@@ -0,0 +1,151 @@
{% extends 'base.html' %}
{% block title %}Referencias por Proveedor{% endblock %}
{% block extra_css %}
<style>
.proveedores-container {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
gap: 1.5rem;
}
.proveedor-card {
background: white;
border-radius: 8px;
padding: 1.5rem;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.proveedor-header {
background: #3498db;
color: white;
padding: 1rem;
border-radius: 4px;
margin-bottom: 1rem;
font-weight: bold;
font-size: 1.1rem;
}
.section-title {
font-weight: bold;
margin: 1rem 0 0.5rem 0;
padding-bottom: 0.5rem;
border-bottom: 2px solid #ecf0f1;
}
.referencia-item {
padding: 0.75rem;
margin: 0.5rem 0;
border-radius: 4px;
border-left: 4px solid;
}
.referencia-item.pendiente {
background: #e3f2fd;
border-color: #2196f3;
}
.referencia-item.devolucion {
background: #ffebee;
border-color: #f44336;
}
.referencia-codigo {
font-weight: bold;
font-family: monospace;
color: #2c3e50;
}
.referencia-info {
font-size: 0.9rem;
color: #7f8c8d;
margin-top: 0.25rem;
}
</style>
{% endblock %}
{% block content %}
<div class="proveedores-container" id="proveedores-container">
<!-- Se llenará con JavaScript -->
</div>
{% endblock %}
{% block extra_js %}
<script>
const API_BASE = '/api';
function renderProveedores(data) {
const container = document.getElementById('proveedores-container');
container.innerHTML = '';
if (data.length === 0) {
container.innerHTML = '<p>No hay referencias pendientes</p>';
return;
}
data.forEach(proveedorData => {
const card = document.createElement('div');
card.className = 'proveedor-card';
const pendientes = proveedorData.referencias_pendientes || [];
const devoluciones = proveedorData.referencias_devolucion || [];
card.innerHTML = `
<div class="proveedor-header">
${proveedorData.proveedor.nombre}
</div>
${pendientes.length > 0 ? `
<div class="section-title">Referencias Pendientes de Recepción</div>
${pendientes.map(ref => `
<div class="referencia-item pendiente">
<div class="referencia-codigo">${ref.referencia}</div>
<div>${ref.denominacion}</div>
<div class="referencia-info">
Pedidas: ${ref.unidades_pedidas} | Recibidas: ${ref.unidades_recibidas} | Pendiente: ${ref.unidades_pedidas - ref.unidades_recibidas}
</div>
</div>
`).join('')}
` : ''}
${devoluciones.length > 0 ? `
<div class="section-title">Referencias Pendientes de Abono</div>
${devoluciones.map(dev => `
<div class="referencia-item devolucion">
<div class="referencia-codigo">${dev.referencia}</div>
<div>${dev.denominacion || ''}</div>
<div class="referencia-info">
Unidades: ${dev.unidades} | Fecha: ${new Date(dev.fecha_devolucion).toLocaleDateString('es-ES')}
</div>
</div>
`).join('')}
` : ''}
${pendientes.length === 0 && devoluciones.length === 0 ?
'<p style="color: #95a5a6; text-align: center; padding: 2rem;">Sin referencias pendientes</p>' : ''}
`;
container.appendChild(card);
});
}
function loadProveedores() {
fetch(`${API_BASE}/referencias-proveedor/`)
.then(response => response.json())
.then(data => {
renderProveedores(data);
})
.catch(error => {
console.error('Error al cargar proveedores:', error);
});
}
// Auto-refresh cada 30 segundos
setInterval(loadProveedores, 30000);
// Cargar inicial
loadProveedores();
</script>
{% endblock %}