From 804bacfbe3649014d6894635c786ca6169960980 Mon Sep 17 00:00:00 2001 From: ronalds Date: Fri, 5 Dec 2025 11:27:16 -0300 Subject: [PATCH] Initial commit --- .gitignore | 61 +++ GUIA_INSTALACION.md | 231 +++++++++++ INSTALL.md | 152 ++++++++ MIGRATION_GUIDE.md | 66 ++++ MIGRATION_PRISMA.md | 83 ++++ QUICK_START.md | 86 ++++ README.md | 135 +++++++ README_PRISMA.md | 106 +++++ SEPARACION_FRONTEND_BACKEND.md | 84 ++++ app/__init__.py | 0 app/api/__init__.py | 0 app/api/dependencies.py | 11 + app/api/routes/__init__.py | 13 + app/api/routes/albaranes.py | 104 +++++ app/api/routes/alertas.py | 50 +++ app/api/routes/clientes.py | 77 ++++ app/api/routes/kanban.py | 47 +++ app/api/routes/pedidos_cliente.py | 246 ++++++++++++ app/api/routes/proveedores.py | 85 ++++ app/api/routes/referencias_proveedor.py | 102 +++++ app/config.py | 47 +++ app/main.py | 69 ++++ app/models/__init__.py | 26 ++ app/models/albaran.py | 57 +++ app/models/cliente.py | 31 ++ app/models/devolucion.py | 37 ++ app/models/pedido_cliente.py | 70 ++++ app/models/pedido_proveedor.py | 59 +++ app/models/proveedor.py | 31 ++ app/models/stock.py | 27 ++ app/prisma_client.py | 30 ++ app/services/__init__.py | 0 app/services/albaran_processor.py | 196 ++++++++++ app/services/ocr_service.py | 180 +++++++++ docker-compose.yml | 22 ++ frontend/README.md | 67 ++++ frontend/admin.html | 42 ++ frontend/css/admin.css | 112 ++++++ frontend/css/base.css | 75 ++++ frontend/css/kanban.css | 147 +++++++ frontend/css/proveedores.css | 59 +++ frontend/css/upload.css | 70 ++++ frontend/index.html | 59 +++ frontend/js/admin.js | 163 ++++++++ frontend/js/config.js | 32 ++ frontend/js/kanban.js | 131 +++++++ frontend/js/proveedores.js | 75 ++++ frontend/js/upload.js | 105 +++++ frontend/proveedores.html | 29 ++ frontend/upload.html | 45 +++ gestion_pedidos/__init__.py | 0 gestion_pedidos/admin.py | 84 ++++ gestion_pedidos/apps.py | 7 + gestion_pedidos/management/__init__.py | 0 .../management/commands/__init__.py | 0 .../management/commands/start_file_watcher.py | 28 ++ gestion_pedidos/migrations/__init__.py | 0 gestion_pedidos/models.py | 354 +++++++++++++++++ gestion_pedidos/serializers.py | 124 ++++++ gestion_pedidos/services/__init__.py | 0 gestion_pedidos/services/albaran_processor.py | 188 +++++++++ gestion_pedidos/services/email_parser.py | 104 +++++ gestion_pedidos/services/file_watcher.py | 113 ++++++ gestion_pedidos/services/ocr_service.py | 196 ++++++++++ gestion_pedidos/services/pdf_parser.py | 95 +++++ gestion_pedidos/urls.py | 21 + gestion_pedidos/views.py | 367 ++++++++++++++++++ manage.py | 23 ++ package.json | 18 + pedidos_clientes/__init__.py | 0 pedidos_clientes/asgi.py | 17 + pedidos_clientes/settings.py | 199 ++++++++++ pedidos_clientes/urls.py | 18 + pedidos_clientes/wsgi.py | 17 + prisma/schema.prisma | 211 ++++++++++ requirements.txt | 13 + requirements_prisma.txt | 13 + run.py | 15 + setup.bat | 67 ++++ start.bat | 27 ++ start.sh | 34 ++ static/.gitkeep | 0 templates/admin_panel.html | 310 +++++++++++++++ templates/base.html | 100 +++++ templates/kanban.html | 319 +++++++++++++++ templates/mobile_upload.html | 195 ++++++++++ templates/proveedores.html | 151 +++++++ 87 files changed, 7260 insertions(+) create mode 100644 .gitignore create mode 100644 GUIA_INSTALACION.md create mode 100644 INSTALL.md create mode 100644 MIGRATION_GUIDE.md create mode 100644 MIGRATION_PRISMA.md create mode 100644 QUICK_START.md create mode 100644 README.md create mode 100644 README_PRISMA.md create mode 100644 SEPARACION_FRONTEND_BACKEND.md create mode 100644 app/__init__.py create mode 100644 app/api/__init__.py create mode 100644 app/api/dependencies.py create mode 100644 app/api/routes/__init__.py create mode 100644 app/api/routes/albaranes.py create mode 100644 app/api/routes/alertas.py create mode 100644 app/api/routes/clientes.py create mode 100644 app/api/routes/kanban.py create mode 100644 app/api/routes/pedidos_cliente.py create mode 100644 app/api/routes/proveedores.py create mode 100644 app/api/routes/referencias_proveedor.py create mode 100644 app/config.py create mode 100644 app/main.py create mode 100644 app/models/__init__.py create mode 100644 app/models/albaran.py create mode 100644 app/models/cliente.py create mode 100644 app/models/devolucion.py create mode 100644 app/models/pedido_cliente.py create mode 100644 app/models/pedido_proveedor.py create mode 100644 app/models/proveedor.py create mode 100644 app/models/stock.py create mode 100644 app/prisma_client.py create mode 100644 app/services/__init__.py create mode 100644 app/services/albaran_processor.py create mode 100644 app/services/ocr_service.py create mode 100644 docker-compose.yml create mode 100644 frontend/README.md create mode 100644 frontend/admin.html create mode 100644 frontend/css/admin.css create mode 100644 frontend/css/base.css create mode 100644 frontend/css/kanban.css create mode 100644 frontend/css/proveedores.css create mode 100644 frontend/css/upload.css create mode 100644 frontend/index.html create mode 100644 frontend/js/admin.js create mode 100644 frontend/js/config.js create mode 100644 frontend/js/kanban.js create mode 100644 frontend/js/proveedores.js create mode 100644 frontend/js/upload.js create mode 100644 frontend/proveedores.html create mode 100644 frontend/upload.html create mode 100644 gestion_pedidos/__init__.py create mode 100644 gestion_pedidos/admin.py create mode 100644 gestion_pedidos/apps.py create mode 100644 gestion_pedidos/management/__init__.py create mode 100644 gestion_pedidos/management/commands/__init__.py create mode 100644 gestion_pedidos/management/commands/start_file_watcher.py create mode 100644 gestion_pedidos/migrations/__init__.py create mode 100644 gestion_pedidos/models.py create mode 100644 gestion_pedidos/serializers.py create mode 100644 gestion_pedidos/services/__init__.py create mode 100644 gestion_pedidos/services/albaran_processor.py create mode 100644 gestion_pedidos/services/email_parser.py create mode 100644 gestion_pedidos/services/file_watcher.py create mode 100644 gestion_pedidos/services/ocr_service.py create mode 100644 gestion_pedidos/services/pdf_parser.py create mode 100644 gestion_pedidos/urls.py create mode 100644 gestion_pedidos/views.py create mode 100644 manage.py create mode 100644 package.json create mode 100644 pedidos_clientes/__init__.py create mode 100644 pedidos_clientes/asgi.py create mode 100644 pedidos_clientes/settings.py create mode 100644 pedidos_clientes/urls.py create mode 100644 pedidos_clientes/wsgi.py create mode 100644 prisma/schema.prisma create mode 100644 requirements.txt create mode 100644 requirements_prisma.txt create mode 100644 run.py create mode 100644 setup.bat create mode 100644 start.bat create mode 100644 start.sh create mode 100644 static/.gitkeep create mode 100644 templates/admin_panel.html create mode 100644 templates/base.html create mode 100644 templates/kanban.html create mode 100644 templates/mobile_upload.html create mode 100644 templates/proveedores.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..edafa28 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/GUIA_INSTALACION.md b/GUIA_INSTALACION.md new file mode 100644 index 0000000..4728ac6 --- /dev/null +++ b/GUIA_INSTALACION.md @@ -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 + diff --git a/INSTALL.md b/INSTALL.md new file mode 100644 index 0000000..57bba08 --- /dev/null +++ b/INSTALL.md @@ -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 +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 + diff --git a/MIGRATION_GUIDE.md b/MIGRATION_GUIDE.md new file mode 100644 index 0000000..add2173 --- /dev/null +++ b/MIGRATION_GUIDE.md @@ -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 + diff --git a/MIGRATION_PRISMA.md b/MIGRATION_PRISMA.md new file mode 100644 index 0000000..5f5dbaf --- /dev/null +++ b/MIGRATION_PRISMA.md @@ -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? + diff --git a/QUICK_START.md b/QUICK_START.md new file mode 100644 index 0000000..2c8e85c --- /dev/null +++ b/QUICK_START.md @@ -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 + diff --git a/README.md b/README.md new file mode 100644 index 0000000..875fab4 --- /dev/null +++ b/README.md @@ -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 diff --git a/README_PRISMA.md b/README_PRISMA.md new file mode 100644 index 0000000..bae7336 --- /dev/null +++ b/README_PRISMA.md @@ -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 + diff --git a/SEPARACION_FRONTEND_BACKEND.md b/SEPARACION_FRONTEND_BACKEND.md new file mode 100644 index 0000000..454bc21 --- /dev/null +++ b/SEPARACION_FRONTEND_BACKEND.md @@ -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/*` + diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/dependencies.py b/app/api/dependencies.py new file mode 100644 index 0000000..56fad19 --- /dev/null +++ b/app/api/dependencies.py @@ -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() + diff --git a/app/api/routes/__init__.py b/app/api/routes/__init__.py new file mode 100644 index 0000000..e15e76c --- /dev/null +++ b/app/api/routes/__init__.py @@ -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) + diff --git a/app/api/routes/albaranes.py b/app/api/routes/albaranes.py new file mode 100644 index 0000000..5812e96 --- /dev/null +++ b/app/api/routes/albaranes.py @@ -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 + diff --git a/app/api/routes/alertas.py b/app/api/routes/alertas.py new file mode 100644 index 0000000..2cb0723 --- /dev/null +++ b/app/api/routes/alertas.py @@ -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 + diff --git a/app/api/routes/clientes.py b/app/api/routes/clientes.py new file mode 100644 index 0000000..e7ec4d5 --- /dev/null +++ b/app/api/routes/clientes.py @@ -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 + diff --git a/app/api/routes/kanban.py b/app/api/routes/kanban.py new file mode 100644 index 0000000..bc368a2 --- /dev/null +++ b/app/api/routes/kanban.py @@ -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 + diff --git a/app/api/routes/pedidos_cliente.py b/app/api/routes/pedidos_cliente.py new file mode 100644 index 0000000..1a1e884 --- /dev/null +++ b/app/api/routes/pedidos_cliente.py @@ -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 + diff --git a/app/api/routes/proveedores.py b/app/api/routes/proveedores.py new file mode 100644 index 0000000..ebb2553 --- /dev/null +++ b/app/api/routes/proveedores.py @@ -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 + diff --git a/app/api/routes/referencias_proveedor.py b/app/api/routes/referencias_proveedor.py new file mode 100644 index 0000000..f35ef08 --- /dev/null +++ b/app/api/routes/referencias_proveedor.py @@ -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()) + diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..6806829 --- /dev/null +++ b/app/config.py @@ -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) + diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..25759d5 --- /dev/null +++ b/app/main.py @@ -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"} + diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..3976b5d --- /dev/null +++ b/app/models/__init__.py @@ -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", +] + diff --git a/app/models/albaran.py b/app/models/albaran.py new file mode 100644 index 0000000..2ff546d --- /dev/null +++ b/app/models/albaran.py @@ -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 + diff --git a/app/models/cliente.py b/app/models/cliente.py new file mode 100644 index 0000000..a72649e --- /dev/null +++ b/app/models/cliente.py @@ -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 + diff --git a/app/models/devolucion.py b/app/models/devolucion.py new file mode 100644 index 0000000..b596257 --- /dev/null +++ b/app/models/devolucion.py @@ -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 + diff --git a/app/models/pedido_cliente.py b/app/models/pedido_cliente.py new file mode 100644 index 0000000..39163a2 --- /dev/null +++ b/app/models/pedido_cliente.py @@ -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 + diff --git a/app/models/pedido_proveedor.py b/app/models/pedido_proveedor.py new file mode 100644 index 0000000..8c12e08 --- /dev/null +++ b/app/models/pedido_proveedor.py @@ -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 + diff --git a/app/models/proveedor.py b/app/models/proveedor.py new file mode 100644 index 0000000..b5d28fb --- /dev/null +++ b/app/models/proveedor.py @@ -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 + diff --git a/app/models/stock.py b/app/models/stock.py new file mode 100644 index 0000000..bee9ace --- /dev/null +++ b/app/models/stock.py @@ -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 + diff --git a/app/prisma_client.py b/app/prisma_client.py new file mode 100644 index 0000000..5e631de --- /dev/null +++ b/app/prisma_client.py @@ -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 + diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/services/albaran_processor.py b/app/services/albaran_processor.py new file mode 100644 index 0000000..7ef2969 --- /dev/null +++ b/app/services/albaran_processor.py @@ -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 + diff --git a/app/services/ocr_service.py b/app/services/ocr_service.py new file mode 100644 index 0000000..0d7a9f6 --- /dev/null +++ b/app/services/ocr_service.py @@ -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}") + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..75e949d --- /dev/null +++ b/docker-compose.yml @@ -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: + diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..155e010 --- /dev/null +++ b/frontend/README.md @@ -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 + diff --git a/frontend/admin.html b/frontend/admin.html new file mode 100644 index 0000000..e03bd15 --- /dev/null +++ b/frontend/admin.html @@ -0,0 +1,42 @@ + + + + + + Panel de Administración + + + + +
+

Gestión de Pedidos de Recambios

+
+ +
+
+

Panel de Administración

+ +
+ + +
+ +
+
+
+ +
+
+
+
+
+ + + + + diff --git a/frontend/css/admin.css b/frontend/css/admin.css new file mode 100644 index 0000000..60567e2 --- /dev/null +++ b/frontend/css/admin.css @@ -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; +} + diff --git a/frontend/css/base.css b/frontend/css/base.css new file mode 100644 index 0000000..74165d9 --- /dev/null +++ b/frontend/css/base.css @@ -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; +} + diff --git a/frontend/css/kanban.css b/frontend/css/kanban.css new file mode 100644 index 0000000..8751207 --- /dev/null +++ b/frontend/css/kanban.css @@ -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; +} + diff --git a/frontend/css/proveedores.css b/frontend/css/proveedores.css new file mode 100644 index 0000000..bb35893 --- /dev/null +++ b/frontend/css/proveedores.css @@ -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; +} + diff --git a/frontend/css/upload.css b/frontend/css/upload.css new file mode 100644 index 0000000..07e5b21 --- /dev/null +++ b/frontend/css/upload.css @@ -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; +} + diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..f018b5e --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,59 @@ + + + + + + Gestión de Pedidos - Kanban + + + + +
+

Gestión de Pedidos de Recambios

+
+ +
+
+ + + + +
+ +
+
+
Pendiente Revisión
+
+
+
+
En Revisión
+
+
+
+
Pendiente Materiales
+
+
+
+
Completado
+
+
+
+
+ + + + + diff --git a/frontend/js/admin.js b/frontend/js/admin.js new file mode 100644 index 0000000..e8dffc2 --- /dev/null +++ b/frontend/js/admin.js @@ -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 = ` +
+
+

Albarán ${albaran.numero_albaran || albaran.id}

+

Proveedor: ${albaran.proveedor ? albaran.proveedor.nombre : 'Sin asignar'}

+

Fecha: ${albaran.fecha_albaran || 'N/A'}

+
+ ${albaran.estado_procesado} +
+ + Albarán + +
${JSON.stringify(albaran.datos_ocr || {}, null, 2)}
+ + ${albaran.referencias && albaran.referencias.length > 0 ? ` + + + + + + + + + + + + ${albaran.referencias.map(ref => ` + + + + + + + + `).join('')} + +
ReferenciaDescripciónUnidadesPrecioIVA
${ref.referencia}${ref.denominacion}${ref.unidades}${ref.precio_unitario}€${ref.impuesto_tipo}%
+ ` : ''} + `; + + 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 = ` +
+

Albarán ${albaran.numero_albaran || albaran.id}

+
+ + Albarán + +
+ + + +
+ `; + + 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(); +}); + diff --git a/frontend/js/config.js b/frontend/js/config.js new file mode 100644 index 0000000..79eb155 --- /dev/null +++ b/frontend/js/config.js @@ -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; + } +} + diff --git a/frontend/js/kanban.js b/frontend/js/kanban.js new file mode 100644 index 0000000..b2bffe5 --- /dev/null +++ b/frontend/js/kanban.js @@ -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 = ` +
+
+
Pedido ${pedido.numero_pedido}
+
${pedido.cliente?.matricula_vehiculo || 'N/A'} - ${pedido.cliente?.nombre || 'N/A'}
+
+ ${pedido.es_urgente ? 'URGENTE' : ''} +
+
Cita: ${fechaCita}
+
+ ${(pedido.referencias || []).map(ref => { + const estadoClass = ref.estado === 'completo' ? 'completo' : + ref.estado === 'parcial' ? 'parcial' : 'pendiente'; + return ` +
+
+ ${ref.referencia} +
${ref.denominacion}
+
+
+
${ref.unidades_solicitadas} unidades
+
+ Stock: ${ref.unidades_en_stock} | Pendiente: ${ref.unidades_pendientes} +
+
+
+ `; + }).join('')} +
+ `; + + 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 = '
No hay pedidos
'; + } 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(); +}); + diff --git a/frontend/js/proveedores.js b/frontend/js/proveedores.js new file mode 100644 index 0000000..4adfdc0 --- /dev/null +++ b/frontend/js/proveedores.js @@ -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 = '

No hay referencias pendientes

'; + 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 = ` +
+ ${proveedorData.proveedor?.nombre || 'Sin nombre'} +
+ + ${pendientes.length > 0 ? ` +
Referencias Pendientes de Recepción
+ ${pendientes.map(ref => ` +
+
${ref.referencia}
+
${ref.denominacion}
+
+ Pedidas: ${ref.unidades_pedidas} | Recibidas: ${ref.unidades_recibidas} | Pendiente: ${ref.unidades_pedidas - ref.unidades_recibidas} +
+
+ `).join('')} + ` : ''} + + ${devoluciones.length > 0 ? ` +
Referencias Pendientes de Abono
+ ${devoluciones.map(dev => ` +
+
${dev.referencia}
+
${dev.denominacion || ''}
+
+ Unidades: ${dev.unidades} | Fecha: ${new Date(dev.fecha_devolucion).toLocaleDateString('es-ES')} +
+
+ `).join('')} + ` : ''} + + ${pendientes.length === 0 && devoluciones.length === 0 ? + '

Sin referencias pendientes

' : ''} + `; + + 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); + diff --git a/frontend/js/upload.js b/frontend/js/upload.js new file mode 100644 index 0000000..40e795f --- /dev/null +++ b/frontend/js/upload.js @@ -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'; + } + } +} + diff --git a/frontend/proveedores.html b/frontend/proveedores.html new file mode 100644 index 0000000..01ed421 --- /dev/null +++ b/frontend/proveedores.html @@ -0,0 +1,29 @@ + + + + + + Referencias por Proveedor + + + + +
+

Gestión de Pedidos de Recambios

+
+ +
+
+ +
+
+ + + + + diff --git a/frontend/upload.html b/frontend/upload.html new file mode 100644 index 0000000..d2eec67 --- /dev/null +++ b/frontend/upload.html @@ -0,0 +1,45 @@ + + + + + + Subir Albarán + + + + +
+

Gestión de Pedidos de Recambios

+
+ +
+
+

Subir Albarán

+ +
+
📄
+

Arrastra una imagen aquí o haz clic para seleccionar

+

+ Formatos soportados: JPG, PNG, PDF +

+ +
+ + + +
+
+
+ + + + + diff --git a/gestion_pedidos/__init__.py b/gestion_pedidos/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/gestion_pedidos/admin.py b/gestion_pedidos/admin.py new file mode 100644 index 0000000..6aec19e --- /dev/null +++ b/gestion_pedidos/admin.py @@ -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',) + diff --git a/gestion_pedidos/apps.py b/gestion_pedidos/apps.py new file mode 100644 index 0000000..0197467 --- /dev/null +++ b/gestion_pedidos/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class GestionPedidosConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'gestion_pedidos' + diff --git a/gestion_pedidos/management/__init__.py b/gestion_pedidos/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/gestion_pedidos/management/commands/__init__.py b/gestion_pedidos/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/gestion_pedidos/management/commands/start_file_watcher.py b/gestion_pedidos/management/commands/start_file_watcher.py new file mode 100644 index 0000000..23a3027 --- /dev/null +++ b/gestion_pedidos/management/commands/start_file_watcher.py @@ -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.')) + diff --git a/gestion_pedidos/migrations/__init__.py b/gestion_pedidos/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/gestion_pedidos/models.py b/gestion_pedidos/models.py new file mode 100644 index 0000000..d31a9a6 --- /dev/null +++ b/gestion_pedidos/models.py @@ -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" + diff --git a/gestion_pedidos/serializers.py b/gestion_pedidos/serializers.py new file mode 100644 index 0000000..6f871c5 --- /dev/null +++ b/gestion_pedidos/serializers.py @@ -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() + ) + ) + diff --git a/gestion_pedidos/services/__init__.py b/gestion_pedidos/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/gestion_pedidos/services/albaran_processor.py b/gestion_pedidos/services/albaran_processor.py new file mode 100644 index 0000000..bd31352 --- /dev/null +++ b/gestion_pedidos/services/albaran_processor.py @@ -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 diff --git a/gestion_pedidos/services/email_parser.py b/gestion_pedidos/services/email_parser.py new file mode 100644 index 0000000..34d0ca3 --- /dev/null +++ b/gestion_pedidos/services/email_parser.py @@ -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 + diff --git a/gestion_pedidos/services/file_watcher.py b/gestion_pedidos/services/file_watcher.py new file mode 100644 index 0000000..0905138 --- /dev/null +++ b/gestion_pedidos/services/file_watcher.py @@ -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") + diff --git a/gestion_pedidos/services/ocr_service.py b/gestion_pedidos/services/ocr_service.py new file mode 100644 index 0000000..189c803 --- /dev/null +++ b/gestion_pedidos/services/ocr_service.py @@ -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}") + diff --git a/gestion_pedidos/services/pdf_parser.py b/gestion_pedidos/services/pdf_parser.py new file mode 100644 index 0000000..c20bc47 --- /dev/null +++ b/gestion_pedidos/services/pdf_parser.py @@ -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 + diff --git a/gestion_pedidos/urls.py b/gestion_pedidos/urls.py new file mode 100644 index 0000000..1628f29 --- /dev/null +++ b/gestion_pedidos/urls.py @@ -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'), +] diff --git a/gestion_pedidos/views.py b/gestion_pedidos/views.py new file mode 100644 index 0000000..bb10e5c --- /dev/null +++ b/gestion_pedidos/views.py @@ -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) diff --git a/manage.py b/manage.py new file mode 100644 index 0000000..0b139c4 --- /dev/null +++ b/manage.py @@ -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() + diff --git a/package.json b/package.json new file mode 100644 index 0000000..ad3c260 --- /dev/null +++ b/package.json @@ -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" + } +} + diff --git a/pedidos_clientes/__init__.py b/pedidos_clientes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pedidos_clientes/asgi.py b/pedidos_clientes/asgi.py new file mode 100644 index 0000000..6696fd8 --- /dev/null +++ b/pedidos_clientes/asgi.py @@ -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() + diff --git a/pedidos_clientes/settings.py b/pedidos_clientes/settings.py new file mode 100644 index 0000000..606c14b --- /dev/null +++ b/pedidos_clientes/settings.py @@ -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) + diff --git a/pedidos_clientes/urls.py b/pedidos_clientes/urls.py new file mode 100644 index 0000000..b88100c --- /dev/null +++ b/pedidos_clientes/urls.py @@ -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) + diff --git a/pedidos_clientes/wsgi.py b/pedidos_clientes/wsgi.py new file mode 100644 index 0000000..be0f332 --- /dev/null +++ b/pedidos_clientes/wsgi.py @@ -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() + diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..5171789 --- /dev/null +++ b/prisma/schema.prisma @@ -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") +} + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f26a229 --- /dev/null +++ b/requirements.txt @@ -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 + diff --git a/requirements_prisma.txt b/requirements_prisma.txt new file mode 100644 index 0000000..af3250f --- /dev/null +++ b/requirements_prisma.txt @@ -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 + diff --git a/run.py b/run.py new file mode 100644 index 0000000..09c7baa --- /dev/null +++ b/run.py @@ -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" + ) + diff --git a/setup.bat b/setup.bat new file mode 100644 index 0000000..8e57455 --- /dev/null +++ b/setup.bat @@ -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 + diff --git a/start.bat b/start.bat new file mode 100644 index 0000000..6e85f30 --- /dev/null +++ b/start.bat @@ -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 + diff --git a/start.sh b/start.sh new file mode 100644 index 0000000..658d80f --- /dev/null +++ b/start.sh @@ -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 + diff --git a/static/.gitkeep b/static/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/templates/admin_panel.html b/templates/admin_panel.html new file mode 100644 index 0000000..e341a85 --- /dev/null +++ b/templates/admin_panel.html @@ -0,0 +1,310 @@ +{% extends 'base.html' %} + +{% block title %}Panel de Administración{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
+

Panel de Administración

+ +
+ + +
+ +
+
+
+ +
+
+
+
+{% endblock %} + +{% block extra_js %} + +{% endblock %} + diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..2fa7b99 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,100 @@ + + + + + + {% block title %}Gestión de Pedidos{% endblock %} + + {% block extra_css %}{% endblock %} + + +
+

Gestión de Pedidos de Recambios

+
+ +
+ {% block content %}{% endblock %} +
+ {% block extra_js %}{% endblock %} + + + diff --git a/templates/kanban.html b/templates/kanban.html new file mode 100644 index 0000000..7422e71 --- /dev/null +++ b/templates/kanban.html @@ -0,0 +1,319 @@ +{% extends 'base.html' %} + +{% block title %}Kanban - Gestión de Pedidos{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
+ + + + +
+ +
+
+
Pendiente Revisión
+
+
+
+
En Revisión
+
+
+
+
Pendiente Materiales
+
+
+
+
Completado
+
+
+
+{% endblock %} + +{% block extra_js %} + +{% endblock %} + diff --git a/templates/mobile_upload.html b/templates/mobile_upload.html new file mode 100644 index 0000000..5643e3b --- /dev/null +++ b/templates/mobile_upload.html @@ -0,0 +1,195 @@ +{% extends 'base.html' %} + +{% block title %}Subir Albarán{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
+

Subir Albarán

+ +
+
📄
+

Arrastra una imagen aquí o haz clic para seleccionar

+

+ Formatos soportados: JPG, PNG, PDF +

+ +
+ + + +
+
+{% endblock %} + +{% block extra_js %} + +{% endblock %} + diff --git a/templates/proveedores.html b/templates/proveedores.html new file mode 100644 index 0000000..78a8143 --- /dev/null +++ b/templates/proveedores.html @@ -0,0 +1,151 @@ +{% extends 'base.html' %} + +{% block title %}Referencias por Proveedor{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
+ +
+{% endblock %} + +{% block extra_js %} + +{% endblock %} +