Initial commit
This commit is contained in:
61
.gitignore
vendored
Normal file
61
.gitignore
vendored
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
|
||||||
|
# Django (legacy)
|
||||||
|
*.log
|
||||||
|
local_settings.py
|
||||||
|
db.sqlite3
|
||||||
|
db.sqlite3-journal
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
.venv
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Project specific
|
||||||
|
pedidos_clientes_pdf/
|
||||||
|
albaranes_escaneados/
|
||||||
|
emails_proveedores/
|
||||||
|
media/
|
||||||
|
staticfiles/
|
||||||
|
logs/
|
||||||
|
|
||||||
|
# Prisma
|
||||||
|
node_modules/
|
||||||
|
.prisma/
|
||||||
|
|
||||||
|
# Frontend (si se compila)
|
||||||
|
frontend/dist/
|
||||||
|
frontend/build/
|
||||||
231
GUIA_INSTALACION.md
Normal file
231
GUIA_INSTALACION.md
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
# Guía de Instalación y Ejecución - Paso a Paso
|
||||||
|
|
||||||
|
## Requisitos Previos
|
||||||
|
|
||||||
|
1. **Python 3.8+** instalado
|
||||||
|
2. **Node.js y npm** instalados (para Prisma CLI)
|
||||||
|
3. **PostgreSQL** instalado y corriendo
|
||||||
|
4. **OpenAI API Key** (para OCR)
|
||||||
|
|
||||||
|
## Paso 1: Instalar Dependencias Node.js
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
Esto instalará Prisma CLI necesario para gestionar la base de datos.
|
||||||
|
|
||||||
|
## Paso 2: Instalar Dependencias Python
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -r requirements_prisma.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
## Paso 3: Configurar Base de Datos PostgreSQL
|
||||||
|
|
||||||
|
### Opción A: PostgreSQL local
|
||||||
|
|
||||||
|
1. Crear base de datos:
|
||||||
|
```sql
|
||||||
|
CREATE DATABASE pedidos_clientes;
|
||||||
|
```
|
||||||
|
|
||||||
|
2. O usar el usuario por defecto `postgres`
|
||||||
|
|
||||||
|
### Opción B: Docker (Recomendado)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose up -d db
|
||||||
|
```
|
||||||
|
|
||||||
|
Esto iniciará PostgreSQL en un contenedor Docker.
|
||||||
|
|
||||||
|
## Paso 4: Configurar Variables de Entorno
|
||||||
|
|
||||||
|
Crear archivo `.env` en la raíz del proyecto:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Base de datos
|
||||||
|
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/pedidos_clientes
|
||||||
|
|
||||||
|
# OpenAI API Key
|
||||||
|
OPENAI_API_KEY=tu-openai-api-key-aqui
|
||||||
|
|
||||||
|
# Opcional
|
||||||
|
DEBUG=True
|
||||||
|
```
|
||||||
|
|
||||||
|
**Importante:** Reemplaza `postgres:postgres` con tu usuario y contraseña de PostgreSQL.
|
||||||
|
|
||||||
|
## Paso 5: Generar Cliente Prisma
|
||||||
|
|
||||||
|
```bash
|
||||||
|
prisma generate
|
||||||
|
```
|
||||||
|
|
||||||
|
Esto generará el cliente Prisma Python que se usará en la aplicación.
|
||||||
|
|
||||||
|
## Paso 6: Crear Migraciones de Base de Datos
|
||||||
|
|
||||||
|
```bash
|
||||||
|
prisma migrate dev --name init
|
||||||
|
```
|
||||||
|
|
||||||
|
Esto creará todas las tablas en la base de datos según el schema de Prisma.
|
||||||
|
|
||||||
|
**Nota:** Si ya tienes datos, puedes usar `prisma db push` en lugar de migrate.
|
||||||
|
|
||||||
|
## Paso 7: Verificar Base de Datos (Opcional)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
prisma studio
|
||||||
|
```
|
||||||
|
|
||||||
|
Esto abrirá una interfaz web para ver y gestionar la base de datos.
|
||||||
|
|
||||||
|
## Paso 8: Ejecutar Backend
|
||||||
|
|
||||||
|
En una terminal:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python run.py
|
||||||
|
```
|
||||||
|
|
||||||
|
O con uvicorn directamente:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uvicorn app.main:app --reload
|
||||||
|
```
|
||||||
|
|
||||||
|
El backend estará disponible en:
|
||||||
|
- **API:** http://localhost:8000
|
||||||
|
- **Documentación:** http://localhost:8000/docs
|
||||||
|
- **Health Check:** http://localhost:8000/health
|
||||||
|
|
||||||
|
## Paso 9: Configurar Frontend
|
||||||
|
|
||||||
|
Editar `frontend/js/config.js` y verificar que la URL de la API sea correcta:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const API_CONFIG = {
|
||||||
|
BASE_URL: 'http://localhost:8000/api',
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Paso 10: Ejecutar Frontend
|
||||||
|
|
||||||
|
En otra terminal:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
python -m http.server 3000
|
||||||
|
```
|
||||||
|
|
||||||
|
O con Node.js:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npx http-server -p 3000
|
||||||
|
```
|
||||||
|
|
||||||
|
El frontend estará disponible en:
|
||||||
|
- **Aplicación:** http://localhost:3000
|
||||||
|
|
||||||
|
## Verificación
|
||||||
|
|
||||||
|
1. Abre http://localhost:3000 en tu navegador
|
||||||
|
2. Deberías ver el Kanban (aunque esté vacío si no hay datos)
|
||||||
|
3. Abre http://localhost:8000/docs para ver la documentación de la API
|
||||||
|
4. Prueba hacer una petición GET a http://localhost:8000/api/kanban/
|
||||||
|
|
||||||
|
## Solución de Problemas
|
||||||
|
|
||||||
|
### Error: "No module named 'prisma'"
|
||||||
|
```bash
|
||||||
|
pip install prisma
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error: "DATABASE_URL not found"
|
||||||
|
Verifica que el archivo `.env` existe y tiene la variable `DATABASE_URL`
|
||||||
|
|
||||||
|
### Error: "Connection refused" (PostgreSQL)
|
||||||
|
- Verifica que PostgreSQL esté corriendo
|
||||||
|
- Verifica las credenciales en `.env`
|
||||||
|
- Verifica que el puerto 5432 esté disponible
|
||||||
|
|
||||||
|
### Error: "prisma: command not found"
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
# O instalar Prisma globalmente:
|
||||||
|
npm install -g prisma
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error CORS en el navegador
|
||||||
|
- Verifica que el backend esté corriendo en el puerto 8000
|
||||||
|
- Verifica la URL en `frontend/js/config.js`
|
||||||
|
- Revisa la consola del navegador para más detalles
|
||||||
|
|
||||||
|
### Frontend no carga
|
||||||
|
- Verifica que el servidor HTTP esté corriendo
|
||||||
|
- Abre la consola del navegador (F12) para ver errores
|
||||||
|
- Verifica que los archivos estén en `frontend/`
|
||||||
|
|
||||||
|
## Comandos Útiles
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Ver logs del backend
|
||||||
|
python run.py
|
||||||
|
|
||||||
|
# Regenerar cliente Prisma (si cambias el schema)
|
||||||
|
prisma generate
|
||||||
|
|
||||||
|
# Crear nueva migración
|
||||||
|
prisma migrate dev --name nombre_migracion
|
||||||
|
|
||||||
|
# Ver estado de migraciones
|
||||||
|
prisma migrate status
|
||||||
|
|
||||||
|
# Abrir Prisma Studio (GUI para BD)
|
||||||
|
prisma studio
|
||||||
|
|
||||||
|
# Verificar sintaxis del schema
|
||||||
|
prisma validate
|
||||||
|
```
|
||||||
|
|
||||||
|
## Estructura de Carpetas Importante
|
||||||
|
|
||||||
|
```
|
||||||
|
pedidosClientesAyutec/
|
||||||
|
├── .env # ⚠️ Crear este archivo con tus credenciales
|
||||||
|
├── app/ # Backend FastAPI
|
||||||
|
├── frontend/ # Frontend HTML/JS
|
||||||
|
├── prisma/
|
||||||
|
│ └── schema.prisma # Schema de base de datos
|
||||||
|
└── requirements_prisma.txt # Dependencias Python
|
||||||
|
```
|
||||||
|
|
||||||
|
## Próximos Pasos
|
||||||
|
|
||||||
|
1. **Crear datos de prueba:**
|
||||||
|
- Usa Prisma Studio para crear clientes y proveedores
|
||||||
|
- O crea un script de seeding
|
||||||
|
|
||||||
|
2. **Probar funcionalidades:**
|
||||||
|
- Subir un albarán desde el frontend
|
||||||
|
- Crear un pedido de cliente
|
||||||
|
- Ver el Kanban actualizarse
|
||||||
|
|
||||||
|
3. **Configurar producción:**
|
||||||
|
- Cambiar CORS a dominios específicos
|
||||||
|
- Configurar variables de entorno seguras
|
||||||
|
- Usar Gunicorn para el backend
|
||||||
|
- Servir frontend con Nginx
|
||||||
|
|
||||||
|
## ¿Necesitas Ayuda?
|
||||||
|
|
||||||
|
Si encuentras algún error, verifica:
|
||||||
|
1. Que todas las dependencias estén instaladas
|
||||||
|
2. Que PostgreSQL esté corriendo
|
||||||
|
3. Que el archivo `.env` esté configurado correctamente
|
||||||
|
4. Que los puertos 8000 y 3000 estén disponibles
|
||||||
|
|
||||||
152
INSTALL.md
Normal file
152
INSTALL.md
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
# Guía de Instalación - Sistema de Gestión de Pedidos
|
||||||
|
|
||||||
|
## Requisitos Previos
|
||||||
|
|
||||||
|
- Python 3.8 o superior
|
||||||
|
- PostgreSQL 12 o superior
|
||||||
|
- pip (gestor de paquetes de Python)
|
||||||
|
- Git (opcional)
|
||||||
|
|
||||||
|
## Paso 1: Clonar/Descargar el Proyecto
|
||||||
|
|
||||||
|
Si tienes el proyecto en un repositorio Git:
|
||||||
|
```bash
|
||||||
|
git clone <url-del-repositorio>
|
||||||
|
cd pedidosClientesAyutec
|
||||||
|
```
|
||||||
|
|
||||||
|
## Paso 2: Crear Entorno Virtual
|
||||||
|
|
||||||
|
**Windows:**
|
||||||
|
```bash
|
||||||
|
python -m venv venv
|
||||||
|
venv\Scripts\activate
|
||||||
|
```
|
||||||
|
|
||||||
|
**Linux/Mac:**
|
||||||
|
```bash
|
||||||
|
python3 -m venv venv
|
||||||
|
source venv/bin/activate
|
||||||
|
```
|
||||||
|
|
||||||
|
## Paso 3: Instalar Dependencias
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
## Paso 4: Configurar Base de Datos PostgreSQL
|
||||||
|
|
||||||
|
1. Instalar PostgreSQL si no lo tienes instalado
|
||||||
|
2. Crear una base de datos:
|
||||||
|
```sql
|
||||||
|
CREATE DATABASE pedidos_clientes;
|
||||||
|
CREATE USER pedidos_user WITH PASSWORD 'tu_password';
|
||||||
|
GRANT ALL PRIVILEGES ON DATABASE pedidos_clientes TO pedidos_user;
|
||||||
|
```
|
||||||
|
|
||||||
|
O usar Docker:
|
||||||
|
```bash
|
||||||
|
docker-compose up -d db
|
||||||
|
```
|
||||||
|
|
||||||
|
## Paso 5: Configurar Variables de Entorno
|
||||||
|
|
||||||
|
1. Copiar el archivo de ejemplo:
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Editar `.env` con tus credenciales:
|
||||||
|
```env
|
||||||
|
SECRET_KEY=tu-secret-key-generado
|
||||||
|
DB_NAME=pedidos_clientes
|
||||||
|
DB_USER=postgres
|
||||||
|
DB_PASSWORD=tu-password
|
||||||
|
DB_HOST=localhost
|
||||||
|
DB_PORT=5432
|
||||||
|
OPENAI_API_KEY=tu-openai-api-key
|
||||||
|
```
|
||||||
|
|
||||||
|
Para generar un SECRET_KEY:
|
||||||
|
```bash
|
||||||
|
python -c "from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Paso 6: Ejecutar Migraciones
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python manage.py makemigrations
|
||||||
|
python manage.py migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
## Paso 7: Crear Superusuario
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python manage.py createsuperuser
|
||||||
|
```
|
||||||
|
|
||||||
|
Sigue las instrucciones para crear un usuario administrador.
|
||||||
|
|
||||||
|
## Paso 8: Recopilar Archivos Estáticos (Opcional)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python manage.py collectstatic --noinput
|
||||||
|
```
|
||||||
|
|
||||||
|
## Paso 9: Iniciar el Servidor
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python manage.py runserver
|
||||||
|
```
|
||||||
|
|
||||||
|
El servidor estará disponible en: http://127.0.0.1:8000
|
||||||
|
|
||||||
|
## Paso 10: Iniciar File Watcher (Opcional)
|
||||||
|
|
||||||
|
En una terminal separada, para procesar automáticamente PDFs y albaranes:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python manage.py start_file_watcher
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verificación
|
||||||
|
|
||||||
|
1. Accede a http://127.0.0.1:8000/admin/ y inicia sesión
|
||||||
|
2. Accede a http://127.0.0.1:8000/api/kanban/ para ver el Kanban
|
||||||
|
3. Verifica que las carpetas se hayan creado:
|
||||||
|
- `pedidos_clientes_pdf/`
|
||||||
|
- `albaranes_escaneados/`
|
||||||
|
- `emails_proveedores/`
|
||||||
|
|
||||||
|
## Solución de Problemas
|
||||||
|
|
||||||
|
### Error de conexión a PostgreSQL
|
||||||
|
- Verifica que PostgreSQL esté corriendo
|
||||||
|
- Verifica las credenciales en `.env`
|
||||||
|
- Verifica que la base de datos exista
|
||||||
|
|
||||||
|
### Error de módulos faltantes
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt --upgrade
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error de migraciones
|
||||||
|
```bash
|
||||||
|
python manage.py makemigrations --empty gestion_pedidos
|
||||||
|
python manage.py migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error de permisos en carpetas
|
||||||
|
Asegúrate de que el usuario tenga permisos de escritura en las carpetas del proyecto.
|
||||||
|
|
||||||
|
## Producción
|
||||||
|
|
||||||
|
Para producción, considera:
|
||||||
|
- Usar un servidor WSGI como Gunicorn
|
||||||
|
- Configurar Nginx como proxy reverso
|
||||||
|
- Usar variables de entorno seguras
|
||||||
|
- Configurar HTTPS
|
||||||
|
- Configurar backups de base de datos
|
||||||
|
- Configurar logs rotativos
|
||||||
|
|
||||||
66
MIGRATION_GUIDE.md
Normal file
66
MIGRATION_GUIDE.md
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
# Guía de Migración Django → FastAPI + Prisma
|
||||||
|
|
||||||
|
## Cambios Realizados
|
||||||
|
|
||||||
|
### 1. Estructura del Proyecto
|
||||||
|
- ✅ Migrado de Django a FastAPI
|
||||||
|
- ✅ Reemplazado Django ORM por Prisma Client Python
|
||||||
|
- ✅ Convertidos serializers Django → Modelos Pydantic
|
||||||
|
- ✅ Convertidas vistas Django → Routers FastAPI
|
||||||
|
|
||||||
|
### 2. Base de Datos
|
||||||
|
- ✅ Schema Prisma creado con todos los modelos
|
||||||
|
- ✅ Migraciones Prisma configuradas
|
||||||
|
- ✅ Índices y relaciones mantenidos
|
||||||
|
|
||||||
|
### 3. API Endpoints
|
||||||
|
Todos los endpoints mantienen la misma funcionalidad:
|
||||||
|
- ✅ `/api/clientes` - CRUD de clientes
|
||||||
|
- ✅ `/api/pedidos-cliente` - CRUD de pedidos
|
||||||
|
- ✅ `/api/proveedores` - CRUD de proveedores
|
||||||
|
- ✅ `/api/albaranes` - Gestión de albaranes
|
||||||
|
- ✅ `/api/kanban` - Datos del Kanban
|
||||||
|
- ✅ `/api/alertas` - Alertas de pedidos urgentes
|
||||||
|
|
||||||
|
### 4. Servicios
|
||||||
|
- ✅ OCR Service actualizado
|
||||||
|
- ✅ Albaran Processor actualizado para Prisma
|
||||||
|
- ✅ PDF Parser mantenido
|
||||||
|
|
||||||
|
## Próximos Pasos
|
||||||
|
|
||||||
|
1. **Instalar dependencias:**
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
pip install -r requirements_prisma.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Configurar base de datos:**
|
||||||
|
```bash
|
||||||
|
# Editar .env con DATABASE_URL
|
||||||
|
prisma generate
|
||||||
|
prisma migrate dev --name init
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Migrar datos existentes (si aplica):**
|
||||||
|
Si tienes datos en Django, necesitarás exportarlos e importarlos.
|
||||||
|
|
||||||
|
4. **Probar la aplicación:**
|
||||||
|
```bash
|
||||||
|
python run.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas Importantes
|
||||||
|
|
||||||
|
- Los nombres de campos en Prisma usan camelCase (ej: `numeroPedido`)
|
||||||
|
- Los modelos Pydantic usan snake_case (ej: `numero_pedido`)
|
||||||
|
- La conversión se hace automáticamente en los endpoints
|
||||||
|
- Prisma Studio está disponible para gestionar la BD: `prisma studio`
|
||||||
|
|
||||||
|
## Archivos a Revisar
|
||||||
|
|
||||||
|
- `prisma/schema.prisma` - Verificar que los modelos coincidan
|
||||||
|
- `app/models/*.py` - Verificar modelos Pydantic
|
||||||
|
- `app/api/routes/*.py` - Verificar endpoints
|
||||||
|
- `.env` - Configurar DATABASE_URL y OPENAI_API_KEY
|
||||||
|
|
||||||
83
MIGRATION_PRISMA.md
Normal file
83
MIGRATION_PRISMA.md
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
# Guía de Migración a Prisma ORM
|
||||||
|
|
||||||
|
## Opción 1: FastAPI + Prisma (Recomendado)
|
||||||
|
|
||||||
|
### Pasos para migrar:
|
||||||
|
|
||||||
|
1. **Instalar Prisma CLI y dependencias:**
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
pip install -r requirements_prisma.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Configurar DATABASE_URL en .env:**
|
||||||
|
```env
|
||||||
|
DATABASE_URL="postgresql://usuario:password@localhost:5432/pedidos_clientes"
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Generar cliente Prisma:**
|
||||||
|
```bash
|
||||||
|
npm run prisma:generate
|
||||||
|
# O directamente:
|
||||||
|
prisma generate
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Crear migración inicial:**
|
||||||
|
```bash
|
||||||
|
npm run prisma:migrate
|
||||||
|
# O:
|
||||||
|
prisma migrate dev --name init
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Aplicar migraciones:**
|
||||||
|
```bash
|
||||||
|
prisma migrate deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
## Opción 2: Mantener Django con Prisma Client Python
|
||||||
|
|
||||||
|
Es posible pero requiere:
|
||||||
|
- Usar Prisma solo para queries complejas
|
||||||
|
- Mantener Django ORM para la mayoría de operaciones
|
||||||
|
- Configurar ambos clientes
|
||||||
|
|
||||||
|
**No recomendado** - Mejor migrar completamente a FastAPI.
|
||||||
|
|
||||||
|
## Estructura con Prisma
|
||||||
|
|
||||||
|
```
|
||||||
|
prisma/
|
||||||
|
schema.prisma # Schema de base de datos
|
||||||
|
migrations/ # Migraciones generadas
|
||||||
|
|
||||||
|
app/
|
||||||
|
prisma_client.py # Cliente Prisma inicializado
|
||||||
|
models/ # Modelos Pydantic (opcional)
|
||||||
|
api/ # Endpoints FastAPI
|
||||||
|
services/ # Lógica de negocio
|
||||||
|
```
|
||||||
|
|
||||||
|
## Ventajas de Prisma
|
||||||
|
|
||||||
|
- ✅ Type-safe queries
|
||||||
|
- ✅ Migraciones automáticas
|
||||||
|
- ✅ Mejor performance
|
||||||
|
- ✅ Schema como código
|
||||||
|
- ✅ Prisma Studio (GUI para BD)
|
||||||
|
|
||||||
|
## Desventajas
|
||||||
|
|
||||||
|
- ❌ Requiere Node.js para Prisma CLI
|
||||||
|
- ❌ Cambio de framework (Django → FastAPI)
|
||||||
|
- ❌ Necesita reescribir vistas y serializers
|
||||||
|
|
||||||
|
## ¿Continuar con la migración completa?
|
||||||
|
|
||||||
|
Si decides migrar, necesitaré:
|
||||||
|
1. Reescribir todas las vistas Django → FastAPI
|
||||||
|
2. Convertir serializers → Pydantic models
|
||||||
|
3. Actualizar servicios para usar Prisma Client
|
||||||
|
4. Mantener la misma funcionalidad
|
||||||
|
|
||||||
|
¿Quieres que proceda con la migración completa a FastAPI + Prisma?
|
||||||
|
|
||||||
86
QUICK_START.md
Normal file
86
QUICK_START.md
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
# Inicio Rápido 🚀
|
||||||
|
|
||||||
|
## Instalación Rápida (Windows)
|
||||||
|
|
||||||
|
1. **Ejecutar script de configuración:**
|
||||||
|
```bash
|
||||||
|
setup.bat
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Editar `.env` con tus credenciales:**
|
||||||
|
```env
|
||||||
|
DATABASE_URL=postgresql://usuario:password@localhost:5432/pedidos_clientes
|
||||||
|
OPENAI_API_KEY=tu-api-key
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Iniciar sistema:**
|
||||||
|
```bash
|
||||||
|
start.bat
|
||||||
|
```
|
||||||
|
|
||||||
|
## Instalación Rápida (Linux/Mac)
|
||||||
|
|
||||||
|
1. **Dar permisos de ejecución:**
|
||||||
|
```bash
|
||||||
|
chmod +x setup.sh start.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Ejecutar configuración:**
|
||||||
|
```bash
|
||||||
|
./setup.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Editar `.env` con tus credenciales**
|
||||||
|
|
||||||
|
4. **Iniciar sistema:**
|
||||||
|
```bash
|
||||||
|
./start.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Instalación Manual
|
||||||
|
|
||||||
|
### 1. Instalar dependencias
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
pip install -r requirements_prisma.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Configurar `.env`
|
||||||
|
```env
|
||||||
|
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/pedidos_clientes
|
||||||
|
OPENAI_API_KEY=tu-api-key
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Generar Prisma y crear BD
|
||||||
|
```bash
|
||||||
|
prisma generate
|
||||||
|
prisma migrate dev --name init
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Iniciar Backend (Terminal 1)
|
||||||
|
```bash
|
||||||
|
python run.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Iniciar Frontend (Terminal 2)
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
python -m http.server 3000
|
||||||
|
```
|
||||||
|
|
||||||
|
## URLs
|
||||||
|
|
||||||
|
- **Frontend:** http://localhost:3000
|
||||||
|
- **Backend API:** http://localhost:8000
|
||||||
|
- **API Docs:** http://localhost:8000/docs
|
||||||
|
|
||||||
|
## Verificación
|
||||||
|
|
||||||
|
Abre http://localhost:3000 y deberías ver el Kanban.
|
||||||
|
|
||||||
|
Si ves errores, revisa:
|
||||||
|
- ✅ PostgreSQL está corriendo
|
||||||
|
- ✅ `.env` está configurado
|
||||||
|
- ✅ Dependencias instaladas
|
||||||
|
- ✅ Puertos 8000 y 3000 disponibles
|
||||||
|
|
||||||
135
README.md
Normal file
135
README.md
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
# Sistema de Gestión de Pedidos de Recambios
|
||||||
|
|
||||||
|
Sistema Kanban visual para gestionar pedidos de recambios desde presupuesto hasta recepción, con procesamiento automático de documentos mediante OCR y seguimiento en tiempo real del estado de cada pedido.
|
||||||
|
|
||||||
|
**Arquitectura:** Backend FastAPI + Prisma ORM | Frontend HTML/CSS/JS separado
|
||||||
|
|
||||||
|
## Estructura del Proyecto
|
||||||
|
|
||||||
|
```
|
||||||
|
pedidosClientesAyutec/
|
||||||
|
├── app/ # Backend FastAPI
|
||||||
|
│ ├── main.py # Aplicación principal
|
||||||
|
│ ├── config.py # Configuración
|
||||||
|
│ ├── prisma_client.py # Cliente Prisma
|
||||||
|
│ ├── api/ # Endpoints API
|
||||||
|
│ ├── models/ # Modelos Pydantic
|
||||||
|
│ └── services/ # Lógica de negocio
|
||||||
|
├── frontend/ # Frontend separado
|
||||||
|
│ ├── index.html
|
||||||
|
│ ├── css/
|
||||||
|
│ └── js/
|
||||||
|
├── prisma/ # Schema Prisma
|
||||||
|
│ └── schema.prisma
|
||||||
|
└── requirements_prisma.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
## Instalación
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
1. **Instalar dependencias:**
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
pip install -r requirements_prisma.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Configurar `.env`:**
|
||||||
|
```env
|
||||||
|
DATABASE_URL=postgresql://usuario:password@localhost:5432/pedidos_clientes
|
||||||
|
OPENAI_API_KEY=tu-api-key
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Generar cliente Prisma:**
|
||||||
|
```bash
|
||||||
|
prisma generate
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Crear migraciones:**
|
||||||
|
```bash
|
||||||
|
prisma migrate dev --name init
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Ejecutar backend:**
|
||||||
|
```bash
|
||||||
|
python run.py
|
||||||
|
# O
|
||||||
|
uvicorn app.main:app --reload
|
||||||
|
```
|
||||||
|
|
||||||
|
Backend disponible en: http://localhost:8000
|
||||||
|
API Docs: http://localhost:8000/docs
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
1. **Configurar API URL en `frontend/js/config.js`:**
|
||||||
|
```javascript
|
||||||
|
const API_CONFIG = {
|
||||||
|
BASE_URL: 'http://localhost:8000/api',
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Ejecutar servidor HTTP:**
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
python -m http.server 3000
|
||||||
|
```
|
||||||
|
|
||||||
|
Frontend disponible en: http://localhost:3000
|
||||||
|
|
||||||
|
## Características
|
||||||
|
|
||||||
|
- ✅ **Vista Kanban**: Visualización de pedidos por estados
|
||||||
|
- ✅ **Procesamiento OCR**: Extracción automática de datos con GPT-4 Vision
|
||||||
|
- ✅ **Gestión de Stock**: Marcado manual de referencias en stock
|
||||||
|
- ✅ **Pedidos a Proveedor**: Creación manual y automática
|
||||||
|
- ✅ **Vista por Proveedor**: Referencias pendientes por proveedor
|
||||||
|
- ✅ **Gestión de Devoluciones**: Registro y seguimiento
|
||||||
|
- ✅ **Alertas**: Notificaciones para pedidos urgentes
|
||||||
|
- ✅ **Upload Móvil**: Interfaz para subir albaranes
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
- `GET /api/clientes` - Listar clientes
|
||||||
|
- `GET /api/pedidos-cliente` - Listar pedidos
|
||||||
|
- `GET /api/kanban` - Datos del Kanban
|
||||||
|
- `GET /api/alertas` - Alertas de pedidos urgentes
|
||||||
|
- `POST /api/albaranes/upload` - Subir albarán
|
||||||
|
- `GET /docs` - Documentación Swagger
|
||||||
|
|
||||||
|
## Desarrollo
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
```bash
|
||||||
|
# Ver logs
|
||||||
|
uvicorn app.main:app --reload --log-level debug
|
||||||
|
|
||||||
|
# Prisma Studio (GUI para BD)
|
||||||
|
prisma studio
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
```bash
|
||||||
|
# Servidor de desarrollo
|
||||||
|
cd frontend
|
||||||
|
python -m http.server 3000
|
||||||
|
```
|
||||||
|
|
||||||
|
## Producción
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
- Usar Gunicorn o similar
|
||||||
|
- Configurar Nginx como reverse proxy
|
||||||
|
- Configurar CORS para dominios específicos
|
||||||
|
- Variables de entorno seguras
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
- Servir con Nginx
|
||||||
|
- O usar un CDN
|
||||||
|
- Configurar CORS en backend
|
||||||
|
|
||||||
|
## Documentación
|
||||||
|
|
||||||
|
- `README_PRISMA.md` - Documentación de Prisma
|
||||||
|
- `MIGRATION_GUIDE.md` - Guía de migración
|
||||||
|
- `frontend/README.md` - Documentación del frontend
|
||||||
106
README_PRISMA.md
Normal file
106
README_PRISMA.md
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
# Sistema de Gestión de Pedidos - FastAPI + Prisma
|
||||||
|
|
||||||
|
## Instalación
|
||||||
|
|
||||||
|
### 1. Instalar dependencias Node.js (para Prisma CLI)
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Instalar dependencias Python
|
||||||
|
```bash
|
||||||
|
pip install -r requirements_prisma.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Configurar base de datos
|
||||||
|
Editar `.env`:
|
||||||
|
```env
|
||||||
|
DATABASE_URL="postgresql://usuario:password@localhost:5432/pedidos_clientes"
|
||||||
|
OPENAI_API_KEY="tu-api-key"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Generar cliente Prisma
|
||||||
|
```bash
|
||||||
|
npm run prisma:generate
|
||||||
|
# O
|
||||||
|
prisma generate
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Crear y aplicar migraciones
|
||||||
|
```bash
|
||||||
|
npm run prisma:migrate
|
||||||
|
# O
|
||||||
|
prisma migrate dev --name init
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Ejecutar aplicación
|
||||||
|
```bash
|
||||||
|
python run.py
|
||||||
|
# O
|
||||||
|
uvicorn app.main:app --reload
|
||||||
|
```
|
||||||
|
|
||||||
|
## Estructura del Proyecto
|
||||||
|
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
├── __init__.py
|
||||||
|
├── main.py # Aplicación FastAPI principal
|
||||||
|
├── config.py # Configuración
|
||||||
|
├── prisma_client.py # Cliente Prisma
|
||||||
|
├── models/ # Modelos Pydantic
|
||||||
|
│ ├── cliente.py
|
||||||
|
│ ├── pedido_cliente.py
|
||||||
|
│ ├── proveedor.py
|
||||||
|
│ └── ...
|
||||||
|
├── api/ # Endpoints FastAPI
|
||||||
|
│ ├── routes/
|
||||||
|
│ │ ├── clientes.py
|
||||||
|
│ │ ├── pedidos_cliente.py
|
||||||
|
│ │ └── ...
|
||||||
|
│ └── dependencies.py
|
||||||
|
└── services/ # Lógica de negocio
|
||||||
|
├── ocr_service.py
|
||||||
|
└── albaran_processor.py
|
||||||
|
|
||||||
|
prisma/
|
||||||
|
└── schema.prisma # Schema de base de datos
|
||||||
|
```
|
||||||
|
|
||||||
|
## Endpoints API
|
||||||
|
|
||||||
|
- `GET /api/clientes` - Listar clientes
|
||||||
|
- `GET /api/pedidos-cliente` - Listar pedidos
|
||||||
|
- `GET /api/kanban` - Datos del Kanban
|
||||||
|
- `GET /api/alertas` - Alertas de pedidos urgentes
|
||||||
|
- `POST /api/albaranes/upload` - Subir albarán
|
||||||
|
- `GET /docs` - Documentación Swagger
|
||||||
|
|
||||||
|
## Comandos Útiles
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Generar cliente Prisma
|
||||||
|
prisma generate
|
||||||
|
|
||||||
|
# Crear migración
|
||||||
|
prisma migrate dev --name nombre_migracion
|
||||||
|
|
||||||
|
# Aplicar migraciones
|
||||||
|
prisma migrate deploy
|
||||||
|
|
||||||
|
# Abrir Prisma Studio (GUI para BD)
|
||||||
|
prisma studio
|
||||||
|
|
||||||
|
# Ver estado de migraciones
|
||||||
|
prisma migrate status
|
||||||
|
```
|
||||||
|
|
||||||
|
## Ventajas de Prisma
|
||||||
|
|
||||||
|
- ✅ Type-safe queries
|
||||||
|
- ✅ Migraciones automáticas
|
||||||
|
- ✅ Mejor performance
|
||||||
|
- ✅ Schema como código
|
||||||
|
- ✅ Prisma Studio (GUI para BD)
|
||||||
|
- ✅ Validación automática de tipos
|
||||||
|
|
||||||
84
SEPARACION_FRONTEND_BACKEND.md
Normal file
84
SEPARACION_FRONTEND_BACKEND.md
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
# Separación Frontend/Backend
|
||||||
|
|
||||||
|
El proyecto ahora está completamente separado en frontend y backend.
|
||||||
|
|
||||||
|
## Estructura
|
||||||
|
|
||||||
|
```
|
||||||
|
pedidosClientesAyutec/
|
||||||
|
├── app/ # Backend FastAPI
|
||||||
|
│ ├── main.py # API REST
|
||||||
|
│ ├── api/ # Endpoints
|
||||||
|
│ └── services/ # Lógica de negocio
|
||||||
|
│
|
||||||
|
└── frontend/ # Frontend separado
|
||||||
|
├── index.html # Kanban
|
||||||
|
├── proveedores.html # Vista proveedores
|
||||||
|
├── upload.html # Subir albaranes
|
||||||
|
├── admin.html # Panel admin
|
||||||
|
├── css/ # Estilos
|
||||||
|
└── js/ # JavaScript
|
||||||
|
```
|
||||||
|
|
||||||
|
## Ejecutar
|
||||||
|
|
||||||
|
### Backend (Puerto 8000)
|
||||||
|
```bash
|
||||||
|
python run.py
|
||||||
|
# O
|
||||||
|
uvicorn app.main:app --reload
|
||||||
|
```
|
||||||
|
|
||||||
|
Backend: http://localhost:8000
|
||||||
|
API Docs: http://localhost:8000/docs
|
||||||
|
|
||||||
|
### Frontend (Puerto 3000)
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
python -m http.server 3000
|
||||||
|
```
|
||||||
|
|
||||||
|
Frontend: http://localhost:3000
|
||||||
|
|
||||||
|
## Configuración
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
- CORS configurado para permitir todas las origenes (cambiar en producción)
|
||||||
|
- API disponible en `/api/*`
|
||||||
|
- Media files en `/media/*`
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
- Configurar URL de API en `frontend/js/config.js`:
|
||||||
|
```javascript
|
||||||
|
const API_CONFIG = {
|
||||||
|
BASE_URL: 'http://localhost:8000/api',
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Ventajas de la Separación
|
||||||
|
|
||||||
|
1. **Desarrollo Independiente**: Frontend y backend pueden desarrollarse por separado
|
||||||
|
2. **Despliegue Independiente**: Pueden desplegarse en servidores diferentes
|
||||||
|
3. **Escalabilidad**: Escalar frontend y backend independientemente
|
||||||
|
4. **Tecnologías Diferentes**: Fácil migrar frontend a React/Vue/Angular
|
||||||
|
5. **CDN**: Frontend puede servirse desde CDN
|
||||||
|
|
||||||
|
## Producción
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
- Usar Gunicorn/Uvicorn con Nginx
|
||||||
|
- Configurar CORS para dominios específicos
|
||||||
|
- Variables de entorno seguras
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
- Servir con Nginx
|
||||||
|
- O usar un CDN
|
||||||
|
- Configurar proxy reverso si es necesario
|
||||||
|
|
||||||
|
## Comunicación
|
||||||
|
|
||||||
|
El frontend se comunica con el backend vía API REST:
|
||||||
|
- Todas las peticiones van a `http://localhost:8000/api/*`
|
||||||
|
- CORS está habilitado para permitir peticiones desde el frontend
|
||||||
|
- Los archivos de media se sirven desde `/media/*`
|
||||||
|
|
||||||
0
app/__init__.py
Normal file
0
app/__init__.py
Normal file
0
app/api/__init__.py
Normal file
0
app/api/__init__.py
Normal file
11
app/api/dependencies.py
Normal file
11
app/api/dependencies.py
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
"""
|
||||||
|
Dependencies para FastAPI
|
||||||
|
"""
|
||||||
|
from app.prisma_client import get_db
|
||||||
|
from prisma import Prisma
|
||||||
|
|
||||||
|
|
||||||
|
async def get_prisma() -> Prisma:
|
||||||
|
"""Dependency para obtener el cliente Prisma"""
|
||||||
|
return await get_db()
|
||||||
|
|
||||||
13
app/api/routes/__init__.py
Normal file
13
app/api/routes/__init__.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
from fastapi import APIRouter
|
||||||
|
from . import clientes, pedidos_cliente, proveedores, albaranes, kanban, alertas, referencias_proveedor
|
||||||
|
|
||||||
|
api_router = APIRouter()
|
||||||
|
|
||||||
|
api_router.include_router(clientes.router)
|
||||||
|
api_router.include_router(pedidos_cliente.router)
|
||||||
|
api_router.include_router(proveedores.router)
|
||||||
|
api_router.include_router(albaranes.router)
|
||||||
|
api_router.include_router(kanban.router)
|
||||||
|
api_router.include_router(alertas.router)
|
||||||
|
api_router.include_router(referencias_proveedor.router)
|
||||||
|
|
||||||
104
app/api/routes/albaranes.py
Normal file
104
app/api/routes/albaranes.py
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
|
||||||
|
from prisma import Prisma
|
||||||
|
from typing import List, Optional
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from app.models.albaran import AlbaranCreate, AlbaranUpdate, AlbaranResponse
|
||||||
|
from app.api.dependencies import get_prisma
|
||||||
|
from app.config import settings
|
||||||
|
from app.services.albaran_processor import AlbaranProcessor
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/albaranes", tags=["albaranes"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=List[AlbaranResponse])
|
||||||
|
async def listar_albaranes(
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 100,
|
||||||
|
estado_procesado: Optional[str] = None,
|
||||||
|
db: Prisma = Depends(get_prisma)
|
||||||
|
):
|
||||||
|
"""Listar todos los albaranes"""
|
||||||
|
where = {}
|
||||||
|
if estado_procesado:
|
||||||
|
where["estadoProcesado"] = estado_procesado
|
||||||
|
|
||||||
|
albaranes = await db.albaran.find_many(
|
||||||
|
where=where,
|
||||||
|
skip=skip,
|
||||||
|
take=limit,
|
||||||
|
include={"proveedor": True, "referencias": True},
|
||||||
|
order_by={"createdAt": "desc"}
|
||||||
|
)
|
||||||
|
return albaranes
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{albaran_id}", response_model=AlbaranResponse)
|
||||||
|
async def obtener_albaran(
|
||||||
|
albaran_id: int,
|
||||||
|
db: Prisma = Depends(get_prisma)
|
||||||
|
):
|
||||||
|
"""Obtener un albarán por ID"""
|
||||||
|
albaran = await db.albaran.find_unique(
|
||||||
|
where={"id": albaran_id},
|
||||||
|
include={"proveedor": True, "referencias": True}
|
||||||
|
)
|
||||||
|
if not albaran:
|
||||||
|
raise HTTPException(status_code=404, detail="Albarán no encontrado")
|
||||||
|
return albaran
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/upload", response_model=AlbaranResponse, status_code=201)
|
||||||
|
async def subir_albaran(
|
||||||
|
archivo: UploadFile = File(...),
|
||||||
|
db: Prisma = Depends(get_prisma)
|
||||||
|
):
|
||||||
|
"""Subir un albarán desde móvil o web"""
|
||||||
|
# Guardar archivo
|
||||||
|
upload_dir = settings.ALBARANES_ESCANEADOS_DIR
|
||||||
|
file_path = upload_dir / archivo.filename
|
||||||
|
|
||||||
|
with open(file_path, "wb") as f:
|
||||||
|
content = await archivo.read()
|
||||||
|
f.write(content)
|
||||||
|
|
||||||
|
# Procesar
|
||||||
|
try:
|
||||||
|
processor = AlbaranProcessor(db)
|
||||||
|
albaran = await processor.process_albaran_file(file_path)
|
||||||
|
return albaran
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{albaran_id}/vincular-proveedor", response_model=AlbaranResponse)
|
||||||
|
async def vincular_proveedor(
|
||||||
|
albaran_id: int,
|
||||||
|
proveedor_id: int = Query(...),
|
||||||
|
db: Prisma = Depends(get_prisma)
|
||||||
|
):
|
||||||
|
"""Vincula un albarán a un proveedor manualmente"""
|
||||||
|
albaran = await db.albaran.find_unique(where={"id": albaran_id})
|
||||||
|
if not albaran:
|
||||||
|
raise HTTPException(status_code=404, detail="Albarán no encontrado")
|
||||||
|
|
||||||
|
proveedor = await db.proveedor.find_unique(where={"id": proveedor_id})
|
||||||
|
if not proveedor:
|
||||||
|
raise HTTPException(status_code=404, detail="Proveedor no encontrado")
|
||||||
|
|
||||||
|
albaran_actualizado = await db.albaran.update(
|
||||||
|
where={"id": albaran_id},
|
||||||
|
data={
|
||||||
|
"proveedorId": proveedor_id,
|
||||||
|
"estadoProcesado": "procesado",
|
||||||
|
"fechaProcesado": datetime.now()
|
||||||
|
},
|
||||||
|
include={"proveedor": True, "referencias": True}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Reprocesar para vincular referencias
|
||||||
|
processor = AlbaranProcessor(db)
|
||||||
|
await processor.match_and_update_referencias(albaran_actualizado)
|
||||||
|
|
||||||
|
return albaran_actualizado
|
||||||
|
|
||||||
50
app/api/routes/alertas.py
Normal file
50
app/api/routes/alertas.py
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from prisma import Prisma
|
||||||
|
from typing import List, Dict
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from app.api.dependencies import get_prisma
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/alertas", tags=["alertas"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/")
|
||||||
|
async def obtener_alertas(
|
||||||
|
db: Prisma = Depends(get_prisma)
|
||||||
|
) -> List[Dict]:
|
||||||
|
"""Obtener alertas de pedidos urgentes (menos de 12 horas)"""
|
||||||
|
ahora = datetime.now()
|
||||||
|
limite = ahora + timedelta(hours=12)
|
||||||
|
|
||||||
|
pedidos_urgentes = await db.pedidocliente.find_many(
|
||||||
|
where={
|
||||||
|
"fechaCita": {
|
||||||
|
"gte": ahora,
|
||||||
|
"lte": limite
|
||||||
|
},
|
||||||
|
"estado": {
|
||||||
|
"in": ["pendiente_revision", "en_revision", "pendiente_materiales"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
include={
|
||||||
|
"cliente": True,
|
||||||
|
"referencias": True
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
alertas = []
|
||||||
|
for pedido in pedidos_urgentes:
|
||||||
|
referencias_faltantes = [
|
||||||
|
ref.dict() for ref in pedido.referencias
|
||||||
|
if ref.unidadesPendientes > 0
|
||||||
|
]
|
||||||
|
|
||||||
|
if referencias_faltantes:
|
||||||
|
tiempo_restante = (pedido.fechaCita - ahora).total_seconds() / 3600
|
||||||
|
alertas.append({
|
||||||
|
"pedido": pedido.dict(),
|
||||||
|
"referencias_faltantes": referencias_faltantes,
|
||||||
|
"horas_restantes": tiempo_restante
|
||||||
|
})
|
||||||
|
|
||||||
|
return alertas
|
||||||
|
|
||||||
77
app/api/routes/clientes.py
Normal file
77
app/api/routes/clientes.py
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from prisma import Prisma
|
||||||
|
from typing import List
|
||||||
|
from app.models.cliente import ClienteCreate, ClienteUpdate, ClienteResponse
|
||||||
|
from app.api.dependencies import get_prisma
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/clientes", tags=["clientes"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=List[ClienteResponse])
|
||||||
|
async def listar_clientes(
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 100,
|
||||||
|
db: Prisma = Depends(get_prisma)
|
||||||
|
):
|
||||||
|
"""Listar todos los clientes"""
|
||||||
|
clientes = await db.cliente.find_many(skip=skip, take=limit)
|
||||||
|
return clientes
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{cliente_id}", response_model=ClienteResponse)
|
||||||
|
async def obtener_cliente(
|
||||||
|
cliente_id: int,
|
||||||
|
db: Prisma = Depends(get_prisma)
|
||||||
|
):
|
||||||
|
"""Obtener un cliente por ID"""
|
||||||
|
cliente = await db.cliente.find_unique(where={"id": cliente_id})
|
||||||
|
if not cliente:
|
||||||
|
raise HTTPException(status_code=404, detail="Cliente no encontrado")
|
||||||
|
return cliente
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", response_model=ClienteResponse, status_code=201)
|
||||||
|
async def crear_cliente(
|
||||||
|
cliente: ClienteCreate,
|
||||||
|
db: Prisma = Depends(get_prisma)
|
||||||
|
):
|
||||||
|
"""Crear un nuevo cliente"""
|
||||||
|
try:
|
||||||
|
nuevo_cliente = await db.cliente.create(data=cliente.dict())
|
||||||
|
return nuevo_cliente
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{cliente_id}", response_model=ClienteResponse)
|
||||||
|
async def actualizar_cliente(
|
||||||
|
cliente_id: int,
|
||||||
|
cliente: ClienteUpdate,
|
||||||
|
db: Prisma = Depends(get_prisma)
|
||||||
|
):
|
||||||
|
"""Actualizar un cliente"""
|
||||||
|
cliente_existente = await db.cliente.find_unique(where={"id": cliente_id})
|
||||||
|
if not cliente_existente:
|
||||||
|
raise HTTPException(status_code=404, detail="Cliente no encontrado")
|
||||||
|
|
||||||
|
data = {k: v for k, v in cliente.dict().items() if v is not None}
|
||||||
|
cliente_actualizado = await db.cliente.update(
|
||||||
|
where={"id": cliente_id},
|
||||||
|
data=data
|
||||||
|
)
|
||||||
|
return cliente_actualizado
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{cliente_id}", status_code=204)
|
||||||
|
async def eliminar_cliente(
|
||||||
|
cliente_id: int,
|
||||||
|
db: Prisma = Depends(get_prisma)
|
||||||
|
):
|
||||||
|
"""Eliminar un cliente"""
|
||||||
|
cliente = await db.cliente.find_unique(where={"id": cliente_id})
|
||||||
|
if not cliente:
|
||||||
|
raise HTTPException(status_code=404, detail="Cliente no encontrado")
|
||||||
|
|
||||||
|
await db.cliente.delete(where={"id": cliente_id})
|
||||||
|
return None
|
||||||
|
|
||||||
47
app/api/routes/kanban.py
Normal file
47
app/api/routes/kanban.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from prisma import Prisma
|
||||||
|
from typing import Dict, List
|
||||||
|
from app.api.dependencies import get_prisma
|
||||||
|
from app.models.pedido_cliente import PedidoClienteResponse
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/kanban", tags=["kanban"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/")
|
||||||
|
async def obtener_kanban(
|
||||||
|
db: Prisma = Depends(get_prisma)
|
||||||
|
) -> Dict[str, List[Dict]]:
|
||||||
|
"""Obtener datos del Kanban agrupados por estado"""
|
||||||
|
pedidos = await db.pedidocliente.find_many(
|
||||||
|
include={
|
||||||
|
"cliente": True,
|
||||||
|
"referencias": True
|
||||||
|
},
|
||||||
|
order_by={"fechaPedido": "desc"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Agrupar por estado
|
||||||
|
kanban_data = {
|
||||||
|
"pendiente_revision": [],
|
||||||
|
"en_revision": [],
|
||||||
|
"pendiente_materiales": [],
|
||||||
|
"completado": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
for pedido in pedidos:
|
||||||
|
pedido_dict = pedido.dict()
|
||||||
|
# Calcular es_urgente
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
if pedido.fechaCita:
|
||||||
|
ahora = datetime.now(pedido.fechaCita.tzinfo) if pedido.fechaCita.tzinfo else datetime.now()
|
||||||
|
tiempo_restante = pedido.fechaCita - ahora
|
||||||
|
pedido_dict["es_urgente"] = 0 < tiempo_restante.total_seconds() < 12 * 3600
|
||||||
|
else:
|
||||||
|
pedido_dict["es_urgente"] = False
|
||||||
|
|
||||||
|
estado = pedido.estado
|
||||||
|
if estado in kanban_data:
|
||||||
|
kanban_data[estado].append(pedido_dict)
|
||||||
|
|
||||||
|
return kanban_data
|
||||||
|
|
||||||
246
app/api/routes/pedidos_cliente.py
Normal file
246
app/api/routes/pedidos_cliente.py
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from prisma import Prisma
|
||||||
|
from typing import List, Optional
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from app.models.pedido_cliente import (
|
||||||
|
PedidoClienteCreate, PedidoClienteUpdate, PedidoClienteResponse,
|
||||||
|
ReferenciaPedidoClienteUpdate
|
||||||
|
)
|
||||||
|
from app.api.dependencies import get_prisma
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/pedidos-cliente", tags=["pedidos-cliente"])
|
||||||
|
|
||||||
|
|
||||||
|
def calcular_estado_referencia(unidades_solicitadas: int, unidades_en_stock: int) -> str:
|
||||||
|
"""Calcula el estado de una referencia"""
|
||||||
|
unidades_pendientes = max(0, unidades_solicitadas - unidades_en_stock)
|
||||||
|
if unidades_pendientes <= 0:
|
||||||
|
return "completo"
|
||||||
|
elif unidades_pendientes < unidades_solicitadas:
|
||||||
|
return "parcial"
|
||||||
|
return "pendiente"
|
||||||
|
|
||||||
|
|
||||||
|
def es_urgente(fecha_cita: Optional[datetime]) -> bool:
|
||||||
|
"""Verifica si un pedido es urgente (menos de 12 horas)"""
|
||||||
|
if not fecha_cita:
|
||||||
|
return False
|
||||||
|
ahora = datetime.now(fecha_cita.tzinfo) if fecha_cita.tzinfo else datetime.now()
|
||||||
|
tiempo_restante = fecha_cita - ahora
|
||||||
|
return 0 < tiempo_restante.total_seconds() < 12 * 3600
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=List[PedidoClienteResponse])
|
||||||
|
async def listar_pedidos(
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 100,
|
||||||
|
estado: Optional[str] = None,
|
||||||
|
urgente: Optional[bool] = None,
|
||||||
|
matricula: Optional[str] = None,
|
||||||
|
db: Prisma = Depends(get_prisma)
|
||||||
|
):
|
||||||
|
"""Listar pedidos de cliente"""
|
||||||
|
where = {}
|
||||||
|
if estado:
|
||||||
|
where["estado"] = estado
|
||||||
|
if matricula:
|
||||||
|
where["cliente"] = {"matriculaVehiculo": {"contains": matricula, "mode": "insensitive"}}
|
||||||
|
|
||||||
|
pedidos = await db.pedidocliente.find_many(
|
||||||
|
where=where,
|
||||||
|
skip=skip,
|
||||||
|
take=limit,
|
||||||
|
include={
|
||||||
|
"cliente": True,
|
||||||
|
"referencias": True
|
||||||
|
},
|
||||||
|
order_by={"fechaPedido": "desc"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Filtrar por urgente si se solicita
|
||||||
|
if urgente is not None:
|
||||||
|
pedidos = [p for p in pedidos if es_urgente(p.fechaCita) == urgente]
|
||||||
|
|
||||||
|
# Agregar es_urgente a cada pedido
|
||||||
|
result = []
|
||||||
|
for pedido in pedidos:
|
||||||
|
pedido_dict = pedido.dict()
|
||||||
|
pedido_dict["es_urgente"] = es_urgente(pedido.fechaCita)
|
||||||
|
result.append(pedido_dict)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{pedido_id}", response_model=PedidoClienteResponse)
|
||||||
|
async def obtener_pedido(
|
||||||
|
pedido_id: int,
|
||||||
|
db: Prisma = Depends(get_prisma)
|
||||||
|
):
|
||||||
|
"""Obtener un pedido por ID"""
|
||||||
|
pedido = await db.pedidocliente.find_unique(
|
||||||
|
where={"id": pedido_id},
|
||||||
|
include={"cliente": True, "referencias": True}
|
||||||
|
)
|
||||||
|
if not pedido:
|
||||||
|
raise HTTPException(status_code=404, detail="Pedido no encontrado")
|
||||||
|
|
||||||
|
pedido_dict = pedido.dict()
|
||||||
|
pedido_dict["es_urgente"] = es_urgente(pedido.fechaCita)
|
||||||
|
return pedido_dict
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", response_model=PedidoClienteResponse, status_code=201)
|
||||||
|
async def crear_pedido(
|
||||||
|
pedido: PedidoClienteCreate,
|
||||||
|
db: Prisma = Depends(get_prisma)
|
||||||
|
):
|
||||||
|
"""Crear un nuevo pedido de cliente"""
|
||||||
|
try:
|
||||||
|
# Verificar que el cliente existe
|
||||||
|
cliente = await db.cliente.find_unique(where={"id": pedido.cliente_id})
|
||||||
|
if not cliente:
|
||||||
|
raise HTTPException(status_code=404, detail="Cliente no encontrado")
|
||||||
|
|
||||||
|
# Crear pedido
|
||||||
|
referencias_data = pedido.referencias or []
|
||||||
|
pedido_data = pedido.dict(exclude={"referencias"})
|
||||||
|
# Convertir snake_case a camelCase para Prisma
|
||||||
|
pedido_data_prisma = {
|
||||||
|
"numeroPedido": pedido_data["numero_pedido"],
|
||||||
|
"clienteId": pedido_data["cliente_id"],
|
||||||
|
"fechaCita": pedido_data.get("fecha_cita"),
|
||||||
|
"estado": pedido_data["estado"],
|
||||||
|
"presupuestoId": pedido_data.get("presupuesto_id"),
|
||||||
|
"archivoPdfPath": pedido_data.get("archivo_pdf_path"),
|
||||||
|
"referencias": {
|
||||||
|
"create": [
|
||||||
|
{
|
||||||
|
"referencia": ref.referencia,
|
||||||
|
"denominacion": ref.denominacion,
|
||||||
|
"unidadesSolicitadas": ref.unidades_solicitadas,
|
||||||
|
"unidadesEnStock": ref.unidades_en_stock,
|
||||||
|
"unidadesPendientes": max(0, ref.unidades_solicitadas - ref.unidades_en_stock),
|
||||||
|
"estado": calcular_estado_referencia(ref.unidades_solicitadas, ref.unidades_en_stock)
|
||||||
|
}
|
||||||
|
for ref in referencias_data
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nuevo_pedido = await db.pedidocliente.create(
|
||||||
|
data=pedido_data_prisma,
|
||||||
|
include={"cliente": True, "referencias": True}
|
||||||
|
)
|
||||||
|
|
||||||
|
nuevo_pedido_dict = nuevo_pedido.dict()
|
||||||
|
nuevo_pedido_dict["es_urgente"] = es_urgente(nuevo_pedido.fechaCita)
|
||||||
|
return nuevo_pedido_dict
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{pedido_id}", response_model=PedidoClienteResponse)
|
||||||
|
async def actualizar_pedido(
|
||||||
|
pedido_id: int,
|
||||||
|
pedido: PedidoClienteUpdate,
|
||||||
|
db: Prisma = Depends(get_prisma)
|
||||||
|
):
|
||||||
|
"""Actualizar un pedido"""
|
||||||
|
pedido_existente = await db.pedidocliente.find_unique(where={"id": pedido_id})
|
||||||
|
if not pedido_existente:
|
||||||
|
raise HTTPException(status_code=404, detail="Pedido no encontrado")
|
||||||
|
|
||||||
|
data = {k: v for k, v in pedido.dict().items() if v is not None}
|
||||||
|
# Convertir snake_case a camelCase
|
||||||
|
data_prisma = {}
|
||||||
|
field_mapping = {
|
||||||
|
"numero_pedido": "numeroPedido",
|
||||||
|
"cliente_id": "clienteId",
|
||||||
|
"fecha_cita": "fechaCita",
|
||||||
|
"presupuesto_id": "presupuestoId",
|
||||||
|
"archivo_pdf_path": "archivoPdfPath"
|
||||||
|
}
|
||||||
|
for key, value in data.items():
|
||||||
|
prisma_key = field_mapping.get(key, key)
|
||||||
|
data_prisma[prisma_key] = value
|
||||||
|
|
||||||
|
pedido_actualizado = await db.pedidocliente.update(
|
||||||
|
where={"id": pedido_id},
|
||||||
|
data=data_prisma,
|
||||||
|
include={"cliente": True, "referencias": True}
|
||||||
|
)
|
||||||
|
|
||||||
|
pedido_dict = pedido_actualizado.dict()
|
||||||
|
pedido_dict["es_urgente"] = es_urgente(pedido_actualizado.fechaCita)
|
||||||
|
return pedido_dict
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{pedido_id}/actualizar-estado")
|
||||||
|
async def actualizar_estado_pedido(
|
||||||
|
pedido_id: int,
|
||||||
|
estado: str,
|
||||||
|
db: Prisma = Depends(get_prisma)
|
||||||
|
):
|
||||||
|
"""Actualizar el estado de un pedido"""
|
||||||
|
estados_validos = ["pendiente_revision", "en_revision", "pendiente_materiales", "completado"]
|
||||||
|
if estado not in estados_validos:
|
||||||
|
raise HTTPException(status_code=400, detail="Estado inválido")
|
||||||
|
|
||||||
|
pedido = await db.pedidocliente.find_unique(where={"id": pedido_id})
|
||||||
|
if not pedido:
|
||||||
|
raise HTTPException(status_code=404, detail="Pedido no encontrado")
|
||||||
|
|
||||||
|
await db.pedidocliente.update(
|
||||||
|
where={"id": pedido_id},
|
||||||
|
data={"estado": estado}
|
||||||
|
)
|
||||||
|
return {"status": "Estado actualizado"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/referencias/{referencia_id}/marcar-stock")
|
||||||
|
async def marcar_stock_referencia(
|
||||||
|
referencia_id: int,
|
||||||
|
unidades_en_stock: int,
|
||||||
|
db: Prisma = Depends(get_prisma)
|
||||||
|
):
|
||||||
|
"""Marcar unidades en stock para una referencia"""
|
||||||
|
referencia = await db.referenciapedidocliente.find_unique(
|
||||||
|
where={"id": referencia_id},
|
||||||
|
include={"pedidoCliente": True}
|
||||||
|
)
|
||||||
|
if not referencia:
|
||||||
|
raise HTTPException(status_code=404, detail="Referencia no encontrada")
|
||||||
|
|
||||||
|
unidades_en_stock = max(0, unidades_en_stock)
|
||||||
|
unidades_pendientes = max(0, referencia.unidadesSolicitadas - unidades_en_stock)
|
||||||
|
estado = calcular_estado_referencia(referencia.unidadesSolicitadas, unidades_en_stock)
|
||||||
|
|
||||||
|
referencia_actualizada = await db.referenciapedidocliente.update(
|
||||||
|
where={"id": referencia_id},
|
||||||
|
data={
|
||||||
|
"unidadesEnStock": unidades_en_stock,
|
||||||
|
"unidadesPendientes": unidades_pendientes,
|
||||||
|
"estado": estado
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verificar si todas las referencias están completas
|
||||||
|
todas_referencias = await db.referenciapedidocliente.find_many(
|
||||||
|
where={"pedidoClienteId": referencia.pedidoClienteId}
|
||||||
|
)
|
||||||
|
|
||||||
|
todas_completas = all(ref.unidadesPendientes == 0 for ref in todas_referencias)
|
||||||
|
|
||||||
|
if todas_completas and referencia.pedidoCliente.estado != "completado":
|
||||||
|
await db.pedidocliente.update(
|
||||||
|
where={"id": referencia.pedidoClienteId},
|
||||||
|
data={"estado": "completado"}
|
||||||
|
)
|
||||||
|
elif referencia.pedidoCliente.estado == "pendiente_revision":
|
||||||
|
await db.pedidocliente.update(
|
||||||
|
where={"id": referencia.pedidoClienteId},
|
||||||
|
data={"estado": "en_revision"}
|
||||||
|
)
|
||||||
|
|
||||||
|
return referencia_actualizada
|
||||||
|
|
||||||
85
app/api/routes/proveedores.py
Normal file
85
app/api/routes/proveedores.py
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from prisma import Prisma
|
||||||
|
from typing import List
|
||||||
|
from app.models.proveedor import ProveedorCreate, ProveedorUpdate, ProveedorResponse
|
||||||
|
from app.api.dependencies import get_prisma
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/proveedores", tags=["proveedores"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=List[ProveedorResponse])
|
||||||
|
async def listar_proveedores(
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 100,
|
||||||
|
activo: bool = True,
|
||||||
|
db: Prisma = Depends(get_prisma)
|
||||||
|
):
|
||||||
|
"""Listar todos los proveedores"""
|
||||||
|
proveedores = await db.proveedor.find_many(
|
||||||
|
where={"activo": activo} if activo else {},
|
||||||
|
skip=skip,
|
||||||
|
take=limit
|
||||||
|
)
|
||||||
|
return proveedores
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{proveedor_id}", response_model=ProveedorResponse)
|
||||||
|
async def obtener_proveedor(
|
||||||
|
proveedor_id: int,
|
||||||
|
db: Prisma = Depends(get_prisma)
|
||||||
|
):
|
||||||
|
"""Obtener un proveedor por ID"""
|
||||||
|
proveedor = await db.proveedor.find_unique(where={"id": proveedor_id})
|
||||||
|
if not proveedor:
|
||||||
|
raise HTTPException(status_code=404, detail="Proveedor no encontrado")
|
||||||
|
return proveedor
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", response_model=ProveedorResponse, status_code=201)
|
||||||
|
async def crear_proveedor(
|
||||||
|
proveedor: ProveedorCreate,
|
||||||
|
db: Prisma = Depends(get_prisma)
|
||||||
|
):
|
||||||
|
"""Crear un nuevo proveedor"""
|
||||||
|
try:
|
||||||
|
nuevo_proveedor = await db.proveedor.create(data=proveedor.dict())
|
||||||
|
return nuevo_proveedor
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{proveedor_id}", response_model=ProveedorResponse)
|
||||||
|
async def actualizar_proveedor(
|
||||||
|
proveedor_id: int,
|
||||||
|
proveedor: ProveedorUpdate,
|
||||||
|
db: Prisma = Depends(get_prisma)
|
||||||
|
):
|
||||||
|
"""Actualizar un proveedor"""
|
||||||
|
proveedor_existente = await db.proveedor.find_unique(where={"id": proveedor_id})
|
||||||
|
if not proveedor_existente:
|
||||||
|
raise HTTPException(status_code=404, detail="Proveedor no encontrado")
|
||||||
|
|
||||||
|
data = {k: v for k, v in proveedor.dict().items() if v is not None}
|
||||||
|
proveedor_actualizado = await db.proveedor.update(
|
||||||
|
where={"id": proveedor_id},
|
||||||
|
data=data
|
||||||
|
)
|
||||||
|
return proveedor_actualizado
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{proveedor_id}", status_code=204)
|
||||||
|
async def eliminar_proveedor(
|
||||||
|
proveedor_id: int,
|
||||||
|
db: Prisma = Depends(get_prisma)
|
||||||
|
):
|
||||||
|
"""Eliminar un proveedor (soft delete)"""
|
||||||
|
proveedor = await db.proveedor.find_unique(where={"id": proveedor_id})
|
||||||
|
if not proveedor:
|
||||||
|
raise HTTPException(status_code=404, detail="Proveedor no encontrado")
|
||||||
|
|
||||||
|
await db.proveedor.update(
|
||||||
|
where={"id": proveedor_id},
|
||||||
|
data={"activo": False}
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
102
app/api/routes/referencias_proveedor.py
Normal file
102
app/api/routes/referencias_proveedor.py
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from prisma import Prisma
|
||||||
|
from typing import List, Dict, Optional
|
||||||
|
from app.api.dependencies import get_prisma
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/referencias-proveedor", tags=["referencias-proveedor"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/")
|
||||||
|
async def obtener_referencias_proveedor(
|
||||||
|
proveedor_id: Optional[int] = Query(None),
|
||||||
|
db: Prisma = Depends(get_prisma)
|
||||||
|
) -> List[Dict]:
|
||||||
|
"""Obtener referencias pendientes por proveedor"""
|
||||||
|
# Obtener referencias pendientes de pedidos a proveedor
|
||||||
|
where = {
|
||||||
|
"estado": {"in": ["pendiente", "parcial"]}
|
||||||
|
}
|
||||||
|
|
||||||
|
if proveedor_id:
|
||||||
|
where["pedidoProveedor"] = {"proveedorId": proveedor_id}
|
||||||
|
|
||||||
|
referencias = await db.referenciapedidoproveedor.find_many(
|
||||||
|
where=where,
|
||||||
|
include={
|
||||||
|
"pedidoProveedor": {
|
||||||
|
"include": {
|
||||||
|
"proveedor": True
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"referenciaPedidoCliente": {
|
||||||
|
"include": {
|
||||||
|
"pedidoCliente": {
|
||||||
|
"include": {
|
||||||
|
"cliente": True
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Agrupar por proveedor
|
||||||
|
proveedores_data = {}
|
||||||
|
for ref in referencias:
|
||||||
|
proveedor = ref.pedidoProveedor.proveedor
|
||||||
|
if proveedor.id not in proveedores_data:
|
||||||
|
proveedores_data[proveedor.id] = {
|
||||||
|
"proveedor": {
|
||||||
|
"id": proveedor.id,
|
||||||
|
"nombre": proveedor.nombre,
|
||||||
|
"email": proveedor.email,
|
||||||
|
"tiene_web": proveedor.tieneWeb,
|
||||||
|
"activo": proveedor.activo,
|
||||||
|
},
|
||||||
|
"referencias_pendientes": [],
|
||||||
|
"referencias_devolucion": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
proveedores_data[proveedor.id]["referencias_pendientes"].append({
|
||||||
|
"id": ref.id,
|
||||||
|
"referencia": ref.referencia,
|
||||||
|
"denominacion": ref.denominacion,
|
||||||
|
"unidades_pedidas": ref.unidadesPedidas,
|
||||||
|
"unidades_recibidas": ref.unidadesRecibidas,
|
||||||
|
"estado": ref.estado,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Agregar devoluciones pendientes
|
||||||
|
devoluciones_where = {"estadoAbono": "pendiente"}
|
||||||
|
if proveedor_id:
|
||||||
|
devoluciones_where["proveedorId"] = proveedor_id
|
||||||
|
|
||||||
|
devoluciones = await db.devolucion.find_many(
|
||||||
|
where=devoluciones_where,
|
||||||
|
include={"proveedor": True}
|
||||||
|
)
|
||||||
|
|
||||||
|
for dev in devoluciones:
|
||||||
|
if dev.proveedorId not in proveedores_data:
|
||||||
|
proveedores_data[dev.proveedorId] = {
|
||||||
|
"proveedor": {
|
||||||
|
"id": dev.proveedor.id,
|
||||||
|
"nombre": dev.proveedor.nombre,
|
||||||
|
"email": dev.proveedor.email,
|
||||||
|
"tiene_web": dev.proveedor.tieneWeb,
|
||||||
|
"activo": dev.proveedor.activo,
|
||||||
|
},
|
||||||
|
"referencias_pendientes": [],
|
||||||
|
"referencias_devolucion": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
proveedores_data[dev.proveedorId]["referencias_devolucion"].append({
|
||||||
|
"id": dev.id,
|
||||||
|
"referencia": dev.referencia,
|
||||||
|
"denominacion": dev.denominacion,
|
||||||
|
"unidades": dev.unidades,
|
||||||
|
"fecha_devolucion": dev.fechaDevolucion.isoformat() if dev.fechaDevolucion else None,
|
||||||
|
})
|
||||||
|
|
||||||
|
return list(proveedores_data.values())
|
||||||
|
|
||||||
47
app/config.py
Normal file
47
app/config.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
"""
|
||||||
|
Configuración de la aplicación
|
||||||
|
"""
|
||||||
|
from pydantic_settings import BaseSettings
|
||||||
|
from pathlib import Path
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
# Base
|
||||||
|
PROJECT_NAME: str = "Sistema de Gestión de Pedidos"
|
||||||
|
VERSION: str = "1.0.0"
|
||||||
|
DEBUG: bool = True
|
||||||
|
|
||||||
|
# Database
|
||||||
|
DATABASE_URL: str = os.getenv(
|
||||||
|
"DATABASE_URL",
|
||||||
|
"postgresql://postgres:postgres@localhost:5432/pedidos_clientes"
|
||||||
|
)
|
||||||
|
|
||||||
|
# OpenAI
|
||||||
|
OPENAI_API_KEY: str = os.getenv("OPENAI_API_KEY", "")
|
||||||
|
|
||||||
|
# Paths
|
||||||
|
BASE_DIR: Path = Path(__file__).resolve().parent.parent
|
||||||
|
PEDIDOS_CLIENTES_PDF_DIR: Path = BASE_DIR / "pedidos_clientes_pdf"
|
||||||
|
ALBARANES_ESCANEADOS_DIR: Path = BASE_DIR / "albaranes_escaneados"
|
||||||
|
EMAILS_PROVEEDORES_DIR: Path = BASE_DIR / "emails_proveedores"
|
||||||
|
MEDIA_DIR: Path = BASE_DIR / "media"
|
||||||
|
STATIC_DIR: Path = BASE_DIR / "static"
|
||||||
|
|
||||||
|
# CORS
|
||||||
|
CORS_ORIGINS: list = ["*"]
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
env_file = ".env"
|
||||||
|
case_sensitive = True
|
||||||
|
|
||||||
|
|
||||||
|
# Crear carpetas si no existen
|
||||||
|
settings = Settings()
|
||||||
|
settings.PEDIDOS_CLIENTES_PDF_DIR.mkdir(exist_ok=True)
|
||||||
|
settings.ALBARANES_ESCANEADOS_DIR.mkdir(exist_ok=True)
|
||||||
|
settings.EMAILS_PROVEEDORES_DIR.mkdir(exist_ok=True)
|
||||||
|
settings.MEDIA_DIR.mkdir(exist_ok=True)
|
||||||
|
settings.STATIC_DIR.mkdir(exist_ok=True)
|
||||||
|
|
||||||
69
app/main.py
Normal file
69
app/main.py
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
"""
|
||||||
|
Aplicación principal FastAPI
|
||||||
|
"""
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.prisma_client import connect_db, disconnect_db
|
||||||
|
from app.api.routes import api_router
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
"""Lifecycle events"""
|
||||||
|
# Startup
|
||||||
|
logger.info("Iniciando aplicación...")
|
||||||
|
await connect_db()
|
||||||
|
yield
|
||||||
|
# Shutdown
|
||||||
|
logger.info("Cerrando aplicación...")
|
||||||
|
await disconnect_db()
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title=settings.PROJECT_NAME,
|
||||||
|
version=settings.VERSION,
|
||||||
|
lifespan=lifespan
|
||||||
|
)
|
||||||
|
|
||||||
|
# CORS - Permitir todas las origenes en desarrollo
|
||||||
|
# En producción, especificar los dominios permitidos
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"], # En producción cambiar a dominios específicos
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Static files (solo para media, el frontend se sirve por separado)
|
||||||
|
app.mount("/media", StaticFiles(directory=str(settings.MEDIA_DIR)), name="media")
|
||||||
|
|
||||||
|
# API Routes
|
||||||
|
app.include_router(api_router, prefix="/api")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
async def root():
|
||||||
|
"""API Root - Redirige a documentación"""
|
||||||
|
return {
|
||||||
|
"message": "Sistema de Gestión de Pedidos API",
|
||||||
|
"version": settings.VERSION,
|
||||||
|
"docs": "/docs",
|
||||||
|
"frontend": "El frontend debe ejecutarse por separado en http://localhost:3000 o similar"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health():
|
||||||
|
"""Health check"""
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
26
app/models/__init__.py
Normal file
26
app/models/__init__.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
from .cliente import ClienteCreate, ClienteUpdate, ClienteResponse
|
||||||
|
from .pedido_cliente import (
|
||||||
|
PedidoClienteCreate, PedidoClienteUpdate, PedidoClienteResponse,
|
||||||
|
ReferenciaPedidoClienteCreate, ReferenciaPedidoClienteUpdate, ReferenciaPedidoClienteResponse
|
||||||
|
)
|
||||||
|
from .proveedor import ProveedorCreate, ProveedorUpdate, ProveedorResponse
|
||||||
|
from .pedido_proveedor import (
|
||||||
|
PedidoProveedorCreate, PedidoProveedorUpdate, PedidoProveedorResponse,
|
||||||
|
ReferenciaPedidoProveedorCreate, ReferenciaPedidoProveedorResponse
|
||||||
|
)
|
||||||
|
from .albaran import AlbaranCreate, AlbaranUpdate, AlbaranResponse, ReferenciaAlbaranResponse
|
||||||
|
from .devolucion import DevolucionCreate, DevolucionUpdate, DevolucionResponse
|
||||||
|
from .stock import StockReferenciaCreate, StockReferenciaUpdate, StockReferenciaResponse
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ClienteCreate", "ClienteUpdate", "ClienteResponse",
|
||||||
|
"PedidoClienteCreate", "PedidoClienteUpdate", "PedidoClienteResponse",
|
||||||
|
"ReferenciaPedidoClienteCreate", "ReferenciaPedidoClienteUpdate", "ReferenciaPedidoClienteResponse",
|
||||||
|
"ProveedorCreate", "ProveedorUpdate", "ProveedorResponse",
|
||||||
|
"PedidoProveedorCreate", "PedidoProveedorUpdate", "PedidoProveedorResponse",
|
||||||
|
"ReferenciaPedidoProveedorCreate", "ReferenciaPedidoProveedorResponse",
|
||||||
|
"AlbaranCreate", "AlbaranUpdate", "AlbaranResponse", "ReferenciaAlbaranResponse",
|
||||||
|
"DevolucionCreate", "DevolucionUpdate", "DevolucionResponse",
|
||||||
|
"StockReferenciaCreate", "StockReferenciaUpdate", "StockReferenciaResponse",
|
||||||
|
]
|
||||||
|
|
||||||
57
app/models/albaran.py
Normal file
57
app/models/albaran.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional, List, Dict, Any
|
||||||
|
from datetime import datetime, date
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
|
||||||
|
class ReferenciaAlbaranBase(BaseModel):
|
||||||
|
referencia: str
|
||||||
|
denominacion: str
|
||||||
|
unidades: int = 1
|
||||||
|
precio_unitario: Decimal = Decimal("0")
|
||||||
|
impuesto_tipo: str = "21"
|
||||||
|
impuesto_valor: Decimal = Decimal("0")
|
||||||
|
|
||||||
|
|
||||||
|
class ReferenciaAlbaranResponse(ReferenciaAlbaranBase):
|
||||||
|
id: int
|
||||||
|
albaran_id: int
|
||||||
|
referencia_pedido_proveedor_id: Optional[int] = None
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class AlbaranBase(BaseModel):
|
||||||
|
proveedor_id: Optional[int] = None
|
||||||
|
numero_albaran: Optional[str] = None
|
||||||
|
fecha_albaran: Optional[date] = None
|
||||||
|
archivo_path: str
|
||||||
|
estado_procesado: str = "pendiente"
|
||||||
|
|
||||||
|
|
||||||
|
class AlbaranCreate(AlbaranBase):
|
||||||
|
datos_ocr: Optional[Dict[str, Any]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
class AlbaranUpdate(BaseModel):
|
||||||
|
proveedor_id: Optional[int] = None
|
||||||
|
numero_albaran: Optional[str] = None
|
||||||
|
fecha_albaran: Optional[date] = None
|
||||||
|
estado_procesado: Optional[str] = None
|
||||||
|
datos_ocr: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
|
|
||||||
|
class AlbaranResponse(AlbaranBase):
|
||||||
|
id: int
|
||||||
|
fecha_procesado: Optional[datetime] = None
|
||||||
|
datos_ocr: Dict[str, Any] = {}
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
proveedor: Optional[dict] = None
|
||||||
|
referencias: Optional[List[ReferenciaAlbaranResponse]] = []
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
31
app/models/cliente.py
Normal file
31
app/models/cliente.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
from pydantic import BaseModel, EmailStr
|
||||||
|
from typing import Optional
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
class ClienteBase(BaseModel):
|
||||||
|
nombre: str
|
||||||
|
matricula_vehiculo: str
|
||||||
|
telefono: Optional[str] = None
|
||||||
|
email: Optional[EmailStr] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ClienteCreate(ClienteBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ClienteUpdate(BaseModel):
|
||||||
|
nombre: Optional[str] = None
|
||||||
|
matricula_vehiculo: Optional[str] = None
|
||||||
|
telefono: Optional[str] = None
|
||||||
|
email: Optional[EmailStr] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ClienteResponse(ClienteBase):
|
||||||
|
id: int
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
37
app/models/devolucion.py
Normal file
37
app/models/devolucion.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
class DevolucionBase(BaseModel):
|
||||||
|
proveedor_id: int
|
||||||
|
referencia: str
|
||||||
|
denominacion: Optional[str] = None
|
||||||
|
unidades: int = 1
|
||||||
|
estado_abono: str = "pendiente"
|
||||||
|
|
||||||
|
|
||||||
|
class DevolucionCreate(DevolucionBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class DevolucionUpdate(BaseModel):
|
||||||
|
proveedor_id: Optional[int] = None
|
||||||
|
referencia: Optional[str] = None
|
||||||
|
denominacion: Optional[str] = None
|
||||||
|
unidades: Optional[int] = None
|
||||||
|
estado_abono: Optional[str] = None
|
||||||
|
albaran_abono_id: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
class DevolucionResponse(DevolucionBase):
|
||||||
|
id: int
|
||||||
|
fecha_devolucion: datetime
|
||||||
|
albaran_abono_id: Optional[int] = None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
proveedor: Optional[dict] = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
70
app/models/pedido_cliente.py
Normal file
70
app/models/pedido_cliente.py
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional, List
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
class ReferenciaPedidoClienteBase(BaseModel):
|
||||||
|
referencia: str
|
||||||
|
denominacion: str
|
||||||
|
unidades_solicitadas: int = 1
|
||||||
|
unidades_en_stock: int = 0
|
||||||
|
estado: str = "pendiente"
|
||||||
|
|
||||||
|
|
||||||
|
class ReferenciaPedidoClienteCreate(ReferenciaPedidoClienteBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ReferenciaPedidoClienteUpdate(BaseModel):
|
||||||
|
referencia: Optional[str] = None
|
||||||
|
denominacion: Optional[str] = None
|
||||||
|
unidades_solicitadas: Optional[int] = None
|
||||||
|
unidades_en_stock: Optional[int] = None
|
||||||
|
estado: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ReferenciaPedidoClienteResponse(ReferenciaPedidoClienteBase):
|
||||||
|
id: int
|
||||||
|
pedido_cliente_id: int
|
||||||
|
unidades_pendientes: int
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class PedidoClienteBase(BaseModel):
|
||||||
|
numero_pedido: str
|
||||||
|
cliente_id: int
|
||||||
|
fecha_cita: Optional[datetime] = None
|
||||||
|
estado: str = "pendiente_revision"
|
||||||
|
presupuesto_id: Optional[str] = None
|
||||||
|
archivo_pdf_path: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class PedidoClienteCreate(PedidoClienteBase):
|
||||||
|
referencias: Optional[List[ReferenciaPedidoClienteCreate]] = []
|
||||||
|
|
||||||
|
|
||||||
|
class PedidoClienteUpdate(BaseModel):
|
||||||
|
numero_pedido: Optional[str] = None
|
||||||
|
cliente_id: Optional[int] = None
|
||||||
|
fecha_cita: Optional[datetime] = None
|
||||||
|
estado: Optional[str] = None
|
||||||
|
presupuesto_id: Optional[str] = None
|
||||||
|
archivo_pdf_path: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class PedidoClienteResponse(PedidoClienteBase):
|
||||||
|
id: int
|
||||||
|
fecha_pedido: datetime
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
cliente: Optional[dict] = None
|
||||||
|
referencias: Optional[List[ReferenciaPedidoClienteResponse]] = []
|
||||||
|
es_urgente: bool = False
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
59
app/models/pedido_proveedor.py
Normal file
59
app/models/pedido_proveedor.py
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional, List
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
class ReferenciaPedidoProveedorBase(BaseModel):
|
||||||
|
referencia: str
|
||||||
|
denominacion: str
|
||||||
|
unidades_pedidas: int = 1
|
||||||
|
estado: str = "pendiente"
|
||||||
|
|
||||||
|
|
||||||
|
class ReferenciaPedidoProveedorCreate(ReferenciaPedidoProveedorBase):
|
||||||
|
referencia_pedido_cliente_id: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ReferenciaPedidoProveedorResponse(ReferenciaPedidoProveedorBase):
|
||||||
|
id: int
|
||||||
|
pedido_proveedor_id: int
|
||||||
|
referencia_pedido_cliente_id: Optional[int] = None
|
||||||
|
unidades_recibidas: int = 0
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class PedidoProveedorBase(BaseModel):
|
||||||
|
proveedor_id: int
|
||||||
|
numero_pedido: Optional[str] = None
|
||||||
|
tipo: str = "web"
|
||||||
|
estado: str = "pendiente_recepcion"
|
||||||
|
email_confirmacion_path: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class PedidoProveedorCreate(PedidoProveedorBase):
|
||||||
|
referencias: Optional[List[ReferenciaPedidoProveedorCreate]] = []
|
||||||
|
|
||||||
|
|
||||||
|
class PedidoProveedorUpdate(BaseModel):
|
||||||
|
proveedor_id: Optional[int] = None
|
||||||
|
numero_pedido: Optional[str] = None
|
||||||
|
tipo: Optional[str] = None
|
||||||
|
estado: Optional[str] = None
|
||||||
|
email_confirmacion_path: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class PedidoProveedorResponse(PedidoProveedorBase):
|
||||||
|
id: int
|
||||||
|
fecha_pedido: datetime
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
proveedor: Optional[dict] = None
|
||||||
|
referencias: Optional[List[ReferenciaPedidoProveedorResponse]] = []
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
31
app/models/proveedor.py
Normal file
31
app/models/proveedor.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
from pydantic import BaseModel, EmailStr
|
||||||
|
from typing import Optional
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
class ProveedorBase(BaseModel):
|
||||||
|
nombre: str
|
||||||
|
email: Optional[EmailStr] = None
|
||||||
|
tiene_web: bool = True
|
||||||
|
activo: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class ProveedorCreate(ProveedorBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ProveedorUpdate(BaseModel):
|
||||||
|
nombre: Optional[str] = None
|
||||||
|
email: Optional[EmailStr] = None
|
||||||
|
tiene_web: Optional[bool] = None
|
||||||
|
activo: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ProveedorResponse(ProveedorBase):
|
||||||
|
id: int
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
27
app/models/stock.py
Normal file
27
app/models/stock.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
class StockReferenciaBase(BaseModel):
|
||||||
|
referencia: str
|
||||||
|
unidades_disponibles: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class StockReferenciaCreate(StockReferenciaBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class StockReferenciaUpdate(BaseModel):
|
||||||
|
referencia: Optional[str] = None
|
||||||
|
unidades_disponibles: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
class StockReferenciaResponse(StockReferenciaBase):
|
||||||
|
id: int
|
||||||
|
ultima_actualizacion: datetime
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
30
app/prisma_client.py
Normal file
30
app/prisma_client.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
"""
|
||||||
|
Cliente Prisma singleton
|
||||||
|
"""
|
||||||
|
from prisma import Prisma
|
||||||
|
from app.config import settings
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
prisma = Prisma()
|
||||||
|
|
||||||
|
|
||||||
|
async def connect_db():
|
||||||
|
"""Conectar a la base de datos"""
|
||||||
|
await prisma.connect()
|
||||||
|
logger.info("Conectado a la base de datos")
|
||||||
|
|
||||||
|
|
||||||
|
async def disconnect_db():
|
||||||
|
"""Desconectar de la base de datos"""
|
||||||
|
await prisma.disconnect()
|
||||||
|
logger.info("Desconectado de la base de datos")
|
||||||
|
|
||||||
|
|
||||||
|
async def get_db():
|
||||||
|
"""Dependency para obtener el cliente Prisma"""
|
||||||
|
if not prisma.is_connected():
|
||||||
|
await prisma.connect()
|
||||||
|
return prisma
|
||||||
|
|
||||||
0
app/services/__init__.py
Normal file
0
app/services/__init__.py
Normal file
196
app/services/albaran_processor.py
Normal file
196
app/services/albaran_processor.py
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
"""
|
||||||
|
Procesador de albaranes con OCR y vinculación automática
|
||||||
|
"""
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, Optional, List
|
||||||
|
from datetime import datetime
|
||||||
|
from prisma import Prisma
|
||||||
|
from app.services.ocr_service import OCRService
|
||||||
|
|
||||||
|
|
||||||
|
class AlbaranProcessor:
|
||||||
|
"""Procesa albaranes y los vincula con pedidos pendientes"""
|
||||||
|
|
||||||
|
def __init__(self, db: Prisma):
|
||||||
|
self.db = db
|
||||||
|
self.ocr_service = OCRService()
|
||||||
|
|
||||||
|
async def _find_proveedor(self, datos: Dict) -> Optional[int]:
|
||||||
|
"""Busca el proveedor basándose en los datos del albarán"""
|
||||||
|
nombre = datos.get('proveedor', {}).get('nombre', '').strip()
|
||||||
|
|
||||||
|
if nombre:
|
||||||
|
proveedor = await self.db.proveedor.find_first(
|
||||||
|
where={"nombre": {"contains": nombre, "mode": "insensitive"}}
|
||||||
|
)
|
||||||
|
if proveedor:
|
||||||
|
return proveedor.id
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _parse_fecha(self, fecha_str: str) -> Optional[datetime]:
|
||||||
|
"""Parsea una fecha desde string"""
|
||||||
|
if not fecha_str:
|
||||||
|
return None
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
formatos = [
|
||||||
|
'%Y-%m-%d',
|
||||||
|
'%d/%m/%Y',
|
||||||
|
'%d-%m-%Y',
|
||||||
|
'%Y/%m/%d',
|
||||||
|
]
|
||||||
|
|
||||||
|
for fmt in formatos:
|
||||||
|
try:
|
||||||
|
return datetime.strptime(fecha_str, fmt).date()
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _match_referencias(
|
||||||
|
self,
|
||||||
|
referencias_albaran: List[Dict],
|
||||||
|
proveedor_id: int
|
||||||
|
) -> Dict[str, int]:
|
||||||
|
"""
|
||||||
|
Busca referencias del albarán en pedidos pendientes del proveedor
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict mapping referencia -> referencia_pedido_proveedor_id
|
||||||
|
"""
|
||||||
|
matches = {}
|
||||||
|
|
||||||
|
# Obtener todas las referencias pendientes del proveedor
|
||||||
|
pedidos_pendientes = await self.db.pedidoproveedor.find_many(
|
||||||
|
where={
|
||||||
|
"proveedorId": proveedor_id,
|
||||||
|
"estado": {"in": ["pendiente_recepcion", "parcial"]}
|
||||||
|
},
|
||||||
|
include={"referencias": True}
|
||||||
|
)
|
||||||
|
|
||||||
|
for pedido in pedidos_pendientes:
|
||||||
|
for ref_pedido in pedido.referencias:
|
||||||
|
if ref_pedido.estado in ["pendiente", "parcial"]:
|
||||||
|
# Buscar coincidencia en el albarán
|
||||||
|
for ref_albaran in referencias_albaran:
|
||||||
|
if ref_albaran['referencia'].strip().upper() == ref_pedido.referencia.strip().upper():
|
||||||
|
if ref_pedido.referencia not in matches:
|
||||||
|
matches[ref_pedido.referencia] = ref_pedido.id
|
||||||
|
break
|
||||||
|
|
||||||
|
return matches
|
||||||
|
|
||||||
|
async def match_and_update_referencias(self, albaran):
|
||||||
|
"""Vincula y actualiza referencias del albarán con pedidos pendientes"""
|
||||||
|
if not albaran.proveedorId:
|
||||||
|
return
|
||||||
|
|
||||||
|
referencias_albaran = await self.db.referenciaalbaran.find_many(
|
||||||
|
where={"albaranId": albaran.id}
|
||||||
|
)
|
||||||
|
|
||||||
|
matches = await self._match_referencias(
|
||||||
|
[{"referencia": ref.referencia} for ref in referencias_albaran],
|
||||||
|
albaran.proveedorId
|
||||||
|
)
|
||||||
|
|
||||||
|
for ref_albaran in referencias_albaran:
|
||||||
|
ref_pedido_proveedor_id = matches.get(ref_albaran.referencia.strip().upper())
|
||||||
|
|
||||||
|
if ref_pedido_proveedor_id:
|
||||||
|
# Actualizar referencia albarán
|
||||||
|
await self.db.referenciaalbaran.update(
|
||||||
|
where={"id": ref_albaran.id},
|
||||||
|
data={"referenciaPedidoProveedorId": ref_pedido_proveedor_id}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Actualizar pedido proveedor
|
||||||
|
ref_pedido = await self.db.referenciapedidoproveedor.find_unique(
|
||||||
|
where={"id": ref_pedido_proveedor_id}
|
||||||
|
)
|
||||||
|
|
||||||
|
nuevas_unidades_recibidas = ref_pedido.unidadesRecibidas + ref_albaran.unidades
|
||||||
|
|
||||||
|
nuevo_estado = "recibido"
|
||||||
|
if nuevas_unidades_recibidas < ref_pedido.unidadesPedidas:
|
||||||
|
nuevo_estado = "parcial" if nuevas_unidades_recibidas > 0 else "pendiente"
|
||||||
|
|
||||||
|
await self.db.referenciapedidoproveedor.update(
|
||||||
|
where={"id": ref_pedido_proveedor_id},
|
||||||
|
data={
|
||||||
|
"unidadesRecibidas": nuevas_unidades_recibidas,
|
||||||
|
"estado": nuevo_estado
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Actualizar referencia pedido cliente
|
||||||
|
if ref_pedido.referenciaPedidoClienteId:
|
||||||
|
ref_cliente = await self.db.referenciapedidocliente.find_unique(
|
||||||
|
where={"id": ref_pedido.referenciaPedidoClienteId}
|
||||||
|
)
|
||||||
|
|
||||||
|
await self.db.referenciapedidocliente.update(
|
||||||
|
where={"id": ref_cliente.id},
|
||||||
|
data={
|
||||||
|
"unidadesEnStock": ref_cliente.unidadesEnStock + ref_albaran.unidades,
|
||||||
|
"unidadesPendientes": max(0, ref_cliente.unidadesSolicitadas - (ref_cliente.unidadesEnStock + ref_albaran.unidades))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def process_albaran_file(self, file_path: Path):
|
||||||
|
"""
|
||||||
|
Procesa un archivo de albarán (imagen o PDF)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Albaran creado
|
||||||
|
"""
|
||||||
|
# Procesar con OCR
|
||||||
|
datos = self.ocr_service.process_albaran(file_path)
|
||||||
|
|
||||||
|
# Buscar proveedor
|
||||||
|
proveedor_id = await self._find_proveedor(datos)
|
||||||
|
|
||||||
|
# Parsear fecha
|
||||||
|
fecha_albaran = self._parse_fecha(datos.get('fecha_albaran', ''))
|
||||||
|
|
||||||
|
# Crear albarán
|
||||||
|
albaran = await self.db.albaran.create(
|
||||||
|
data={
|
||||||
|
"proveedorId": proveedor_id,
|
||||||
|
"numeroAlbaran": datos.get('numero_albaran', '').strip() or None,
|
||||||
|
"fechaAlbaran": fecha_albaran,
|
||||||
|
"archivoPath": str(file_path),
|
||||||
|
"estadoProcesado": "procesado" if proveedor_id else "clasificacion",
|
||||||
|
"fechaProcesado": datetime.now() if proveedor_id else None,
|
||||||
|
"datosOcr": datos,
|
||||||
|
"referencias": {
|
||||||
|
"create": [
|
||||||
|
{
|
||||||
|
"referencia": ref_data.get('referencia', '').strip(),
|
||||||
|
"denominacion": ref_data.get('denominacion', '').strip(),
|
||||||
|
"unidades": int(ref_data.get('unidades', 1)),
|
||||||
|
"precioUnitario": float(ref_data.get('precio_unitario', 0)),
|
||||||
|
"impuestoTipo": ref_data.get('impuesto_tipo', '21'),
|
||||||
|
"impuestoValor": float(ref_data.get('impuesto_valor', 0)),
|
||||||
|
}
|
||||||
|
for ref_data in datos.get('referencias', [])
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
include={"proveedor": True, "referencias": True}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Vincular referencias si hay proveedor
|
||||||
|
if proveedor_id:
|
||||||
|
await self.match_and_update_referencias(albaran)
|
||||||
|
# Recargar albarán con referencias actualizadas
|
||||||
|
albaran = await self.db.albaran.find_unique(
|
||||||
|
where={"id": albaran.id},
|
||||||
|
include={"proveedor": True, "referencias": True}
|
||||||
|
)
|
||||||
|
|
||||||
|
return albaran
|
||||||
|
|
||||||
180
app/services/ocr_service.py
Normal file
180
app/services/ocr_service.py
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
"""
|
||||||
|
Servicio de OCR usando GPT-4 Vision API
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict
|
||||||
|
from openai import OpenAI
|
||||||
|
from app.config import settings
|
||||||
|
from PIL import Image
|
||||||
|
import io
|
||||||
|
|
||||||
|
|
||||||
|
class OCRService:
|
||||||
|
"""Servicio para procesar imágenes y PDFs con GPT-4 Vision"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.client = OpenAI(api_key=settings.OPENAI_API_KEY)
|
||||||
|
|
||||||
|
def _encode_image(self, image_path: Path) -> str:
|
||||||
|
"""Codifica una imagen en base64"""
|
||||||
|
with open(image_path, "rb") as image_file:
|
||||||
|
return base64.b64encode(image_file.read()).decode('utf-8')
|
||||||
|
|
||||||
|
def _encode_pil_image(self, image: Image.Image) -> str:
|
||||||
|
"""Codifica una imagen PIL en base64"""
|
||||||
|
buffered = io.BytesIO()
|
||||||
|
image.save(buffered, format="PNG")
|
||||||
|
return base64.b64encode(buffered.getvalue()).decode('utf-8')
|
||||||
|
|
||||||
|
def process_pdf_pedido_cliente(self, pdf_path: Path) -> Dict:
|
||||||
|
"""
|
||||||
|
Procesa un PDF de pedido de cliente y extrae la información
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict con: numero_pedido, matricula, fecha_cita, referencias (lista)
|
||||||
|
"""
|
||||||
|
from pdf2image import convert_from_path
|
||||||
|
|
||||||
|
# Convertir PDF a imágenes
|
||||||
|
images = convert_from_path(pdf_path, dpi=200)
|
||||||
|
|
||||||
|
if not images:
|
||||||
|
raise ValueError("No se pudieron extraer imágenes del PDF")
|
||||||
|
|
||||||
|
# Procesar la primera página (o todas si es necesario)
|
||||||
|
image = images[0]
|
||||||
|
base64_image = self._encode_pil_image(image)
|
||||||
|
|
||||||
|
prompt = """
|
||||||
|
Analiza este documento de pedido de cliente de recambios. Extrae la siguiente información en formato JSON:
|
||||||
|
|
||||||
|
{
|
||||||
|
"numero_pedido": "número único del pedido",
|
||||||
|
"matricula": "matrícula del vehículo",
|
||||||
|
"fecha_cita": "fecha de la cita en formato YYYY-MM-DD o YYYY-MM-DD HH:MM",
|
||||||
|
"referencias": [
|
||||||
|
{
|
||||||
|
"referencia": "código de la referencia",
|
||||||
|
"denominacion": "descripción/nombre de la pieza",
|
||||||
|
"unidades": número de unidades
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
Si algún campo no está disponible, usa null. La fecha debe estar en formato ISO si es posible.
|
||||||
|
"""
|
||||||
|
|
||||||
|
response = self.client.chat.completions.create(
|
||||||
|
model="gpt-4o",
|
||||||
|
messages=[
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": [
|
||||||
|
{"type": "text", "text": prompt},
|
||||||
|
{
|
||||||
|
"type": "image_url",
|
||||||
|
"image_url": {
|
||||||
|
"url": f"data:image/png;base64,{base64_image}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
max_tokens=2000
|
||||||
|
)
|
||||||
|
|
||||||
|
content = response.choices[0].message.content
|
||||||
|
|
||||||
|
# Extraer JSON de la respuesta
|
||||||
|
try:
|
||||||
|
json_start = content.find('{')
|
||||||
|
json_end = content.rfind('}') + 1
|
||||||
|
if json_start != -1 and json_end > json_start:
|
||||||
|
json_str = content[json_start:json_end]
|
||||||
|
return json.loads(json_str)
|
||||||
|
else:
|
||||||
|
raise ValueError("No se encontró JSON en la respuesta")
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
raise ValueError(f"Error al parsear JSON: {e}. Respuesta: {content}")
|
||||||
|
|
||||||
|
def process_albaran(self, image_path: Path) -> Dict:
|
||||||
|
"""
|
||||||
|
Procesa un albarán y extrae la información
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict con: proveedor (nombre o número), numero_albaran, fecha_albaran,
|
||||||
|
referencias (lista con precios e impuestos)
|
||||||
|
"""
|
||||||
|
base64_image = self._encode_image(image_path)
|
||||||
|
|
||||||
|
prompt = """
|
||||||
|
Analiza este albarán de proveedor. Extrae la siguiente información en formato JSON:
|
||||||
|
|
||||||
|
{
|
||||||
|
"proveedor": {
|
||||||
|
"nombre": "nombre del proveedor",
|
||||||
|
"numero": "número de proveedor si está visible"
|
||||||
|
},
|
||||||
|
"numero_albaran": "número del albarán",
|
||||||
|
"fecha_albaran": "fecha del albarán en formato YYYY-MM-DD",
|
||||||
|
"referencias": [
|
||||||
|
{
|
||||||
|
"referencia": "código de la referencia",
|
||||||
|
"denominacion": "descripción de la pieza",
|
||||||
|
"unidades": número de unidades,
|
||||||
|
"precio_unitario": precio por unidad (número decimal),
|
||||||
|
"impuesto_tipo": "21", "10", "7", "4", "3" o "0" según el porcentaje de IVA,
|
||||||
|
"impuesto_valor": valor del impuesto (número decimal)
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"totales": {
|
||||||
|
"base_imponible": total base imponible,
|
||||||
|
"iva_21": total IVA al 21%,
|
||||||
|
"iva_10": total IVA al 10%,
|
||||||
|
"iva_7": total IVA al 7%,
|
||||||
|
"iva_4": total IVA al 4%,
|
||||||
|
"iva_3": total IVA al 3%,
|
||||||
|
"total": total general
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IMPORTANTE:
|
||||||
|
- Si hay múltiples tipos de impuestos, identifica qué referencias tienen cada tipo
|
||||||
|
- Si el impuesto no está claro por referencia, intenta calcularlo basándote en los totales
|
||||||
|
- Si algún campo no está disponible, usa null
|
||||||
|
"""
|
||||||
|
|
||||||
|
response = self.client.chat.completions.create(
|
||||||
|
model="gpt-4o",
|
||||||
|
messages=[
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": [
|
||||||
|
{"type": "text", "text": prompt},
|
||||||
|
{
|
||||||
|
"type": "image_url",
|
||||||
|
"image_url": {
|
||||||
|
"url": f"data:image/png;base64,{base64_image}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
max_tokens=3000
|
||||||
|
)
|
||||||
|
|
||||||
|
content = response.choices[0].message.content
|
||||||
|
|
||||||
|
try:
|
||||||
|
json_start = content.find('{')
|
||||||
|
json_end = content.rfind('}') + 1
|
||||||
|
if json_start != -1 and json_end > json_start:
|
||||||
|
json_str = content[json_start:json_end]
|
||||||
|
return json.loads(json_str)
|
||||||
|
else:
|
||||||
|
raise ValueError("No se encontró JSON en la respuesta")
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
raise ValueError(f"Error al parsear JSON: {e}. Respuesta: {content}")
|
||||||
|
|
||||||
22
docker-compose.yml
Normal file
22
docker-compose.yml
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:15
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: pedidos_clientes
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: postgres
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
|
||||||
67
frontend/README.md
Normal file
67
frontend/README.md
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
# Frontend - Sistema de Gestión de Pedidos
|
||||||
|
|
||||||
|
Frontend separado del backend, construido con HTML, CSS y JavaScript vanilla.
|
||||||
|
|
||||||
|
## Estructura
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend/
|
||||||
|
├── index.html # Página principal (Kanban)
|
||||||
|
├── proveedores.html # Vista de proveedores
|
||||||
|
├── upload.html # Subir albaranes
|
||||||
|
├── admin.html # Panel de administración (pendiente)
|
||||||
|
├── css/
|
||||||
|
│ ├── base.css # Estilos base
|
||||||
|
│ ├── kanban.css # Estilos del Kanban
|
||||||
|
│ ├── proveedores.css # Estilos de proveedores
|
||||||
|
│ └── upload.css # Estilos de upload
|
||||||
|
└── js/
|
||||||
|
├── config.js # Configuración de API
|
||||||
|
├── kanban.js # Lógica del Kanban
|
||||||
|
├── proveedores.js # Lógica de proveedores
|
||||||
|
└── upload.js # Lógica de upload
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuración
|
||||||
|
|
||||||
|
Editar `js/config.js` para cambiar la URL de la API:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const API_CONFIG = {
|
||||||
|
BASE_URL: 'http://localhost:8000/api',
|
||||||
|
// En producción: BASE_URL: 'https://tu-dominio.com/api'
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Ejecutar
|
||||||
|
|
||||||
|
### Opción 1: Servidor HTTP simple (Python)
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
python -m http.server 3000
|
||||||
|
```
|
||||||
|
|
||||||
|
### Opción 2: Servidor HTTP simple (Node.js)
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npx http-server -p 3000
|
||||||
|
```
|
||||||
|
|
||||||
|
### Opción 3: Servidor de desarrollo (Live Server en VS Code)
|
||||||
|
Usar la extensión "Live Server" en VS Code
|
||||||
|
|
||||||
|
Luego abrir: http://localhost:3000
|
||||||
|
|
||||||
|
## Producción
|
||||||
|
|
||||||
|
Para producción, puedes:
|
||||||
|
1. Servir los archivos estáticos con Nginx
|
||||||
|
2. Usar un CDN
|
||||||
|
3. Integrar con un framework como React/Vue si prefieres
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
- El frontend se comunica con el backend vía API REST
|
||||||
|
- CORS está configurado en el backend para permitir todas las origenes
|
||||||
|
- En producción, configurar CORS para dominios específicos
|
||||||
|
|
||||||
42
frontend/admin.html
Normal file
42
frontend/admin.html
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Panel de Administración</title>
|
||||||
|
<link rel="stylesheet" href="css/base.css">
|
||||||
|
<link rel="stylesheet" href="css/admin.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="header">
|
||||||
|
<h1>Gestión de Pedidos de Recambios</h1>
|
||||||
|
</div>
|
||||||
|
<nav class="nav">
|
||||||
|
<a href="index.html">Kanban</a>
|
||||||
|
<a href="proveedores.html">Proveedores</a>
|
||||||
|
<a href="admin.html" class="active">Administración</a>
|
||||||
|
<a href="upload.html">Subir Albarán</a>
|
||||||
|
</nav>
|
||||||
|
<div class="container">
|
||||||
|
<div class="admin-container">
|
||||||
|
<h1 style="margin-bottom: 2rem;">Panel de Administración</h1>
|
||||||
|
|
||||||
|
<div class="tabs">
|
||||||
|
<button class="tab active" onclick="showTab('albaranes')">Albaranes</button>
|
||||||
|
<button class="tab" onclick="showTab('clasificacion')">Pendientes de Clasificación</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="tab-albaranes" class="tab-content active">
|
||||||
|
<div id="albaranes-container"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="tab-clasificacion" class="tab-content">
|
||||||
|
<div id="clasificacion-container"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script src="js/config.js"></script>
|
||||||
|
<script src="js/admin.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
112
frontend/css/admin.css
Normal file
112
frontend/css/admin.css
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
.admin-container {
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
border-bottom: 2px solid #ecf0f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
padding: 1rem 2rem;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: #7f8c8d;
|
||||||
|
border-bottom: 3px solid transparent;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab.active {
|
||||||
|
color: #3498db;
|
||||||
|
border-bottom-color: #3498db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.albaran-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.albaran-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
border-bottom: 1px solid #ecf0f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.albaran-image {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 400px;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ocr-data {
|
||||||
|
background: #f8f9fa;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin: 1rem 0;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.referencias-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.referencias-table th,
|
||||||
|
.referencias-table td {
|
||||||
|
padding: 0.75rem;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid #ecf0f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.referencias-table th {
|
||||||
|
background: #f8f9fa;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.pendiente {
|
||||||
|
background: #fff3cd;
|
||||||
|
color: #856404;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.procesado {
|
||||||
|
background: #d4edda;
|
||||||
|
color: #155724;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.clasificacion {
|
||||||
|
background: #f8d7da;
|
||||||
|
color: #721c24;
|
||||||
|
}
|
||||||
|
|
||||||
75
frontend/css/base.css
Normal file
75
frontend/css/base.css
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||||
|
background: #f5f5f5;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
background: #2c3e50;
|
||||||
|
color: white;
|
||||||
|
padding: 1rem 2rem;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav {
|
||||||
|
background: #34495e;
|
||||||
|
padding: 0.5rem 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav a {
|
||||||
|
color: white;
|
||||||
|
text-decoration: none;
|
||||||
|
margin-right: 2rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
display: inline-block;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav a:hover, .nav a.active {
|
||||||
|
background: #2c3e50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: #3498db;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: #2980b9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-success {
|
||||||
|
background: #27ae60;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: #e74c3c;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
147
frontend/css/kanban.css
Normal file
147
frontend/css/kanban.css
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
.kanban-container {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding: 1rem 0;
|
||||||
|
min-height: calc(100vh - 200px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-column {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 300px;
|
||||||
|
background: #ecf0f1;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-header {
|
||||||
|
background: #34495e;
|
||||||
|
color: white;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-weight: bold;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-header.pendiente-revision { background: #95a5a6; }
|
||||||
|
.column-header.en-revision { background: #f39c12; }
|
||||||
|
.column-header.pendiente-materiales { background: #e74c3c; }
|
||||||
|
.column-header.completado { background: #27ae60; }
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.urgente {
|
||||||
|
border-left: 4px solid #e74c3c;
|
||||||
|
animation: pulse 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.8; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-subtitle {
|
||||||
|
color: #7f8c8d;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.urgente-badge {
|
||||||
|
background: #e74c3c;
|
||||||
|
color: white;
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.referencias-list {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.referencia-item {
|
||||||
|
padding: 0.5rem;
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.referencia-item.completo {
|
||||||
|
background: #d5f4e6;
|
||||||
|
color: #27ae60;
|
||||||
|
}
|
||||||
|
|
||||||
|
.referencia-item.parcial {
|
||||||
|
background: #fff3cd;
|
||||||
|
color: #856404;
|
||||||
|
}
|
||||||
|
|
||||||
|
.referencia-item.pendiente {
|
||||||
|
background: #f8d7da;
|
||||||
|
color: #721c24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.referencia-codigo {
|
||||||
|
font-weight: bold;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters {
|
||||||
|
background: white;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-input {
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-btn {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-column {
|
||||||
|
text-align: center;
|
||||||
|
color: #95a5a6;
|
||||||
|
padding: 2rem;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
59
frontend/css/proveedores.css
Normal file
59
frontend/css/proveedores.css
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
.proveedores-container {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.proveedor-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.proveedor-header {
|
||||||
|
background: #3498db;
|
||||||
|
color: white;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-weight: bold;
|
||||||
|
margin: 1rem 0 0.5rem 0;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
border-bottom: 2px solid #ecf0f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.referencia-item {
|
||||||
|
padding: 0.75rem;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
border-left: 4px solid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.referencia-item.pendiente {
|
||||||
|
background: #e3f2fd;
|
||||||
|
border-color: #2196f3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.referencia-item.devolucion {
|
||||||
|
background: #ffebee;
|
||||||
|
border-color: #f44336;
|
||||||
|
}
|
||||||
|
|
||||||
|
.referencia-codigo {
|
||||||
|
font-weight: bold;
|
||||||
|
font-family: monospace;
|
||||||
|
color: #2c3e50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.referencia-info {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #7f8c8d;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
70
frontend/css/upload.css
Normal file
70
frontend/css/upload.css
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
.upload-container {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 2rem auto;
|
||||||
|
background: white;
|
||||||
|
padding: 2rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-area {
|
||||||
|
border: 2px dashed #3498db;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 3rem;
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s;
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-area:hover {
|
||||||
|
background: #e9ecef;
|
||||||
|
border-color: #2980b9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-area.dragover {
|
||||||
|
background: #d4edda;
|
||||||
|
border-color: #27ae60;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-icon {
|
||||||
|
font-size: 3rem;
|
||||||
|
color: #3498db;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-image {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 400px;
|
||||||
|
margin-top: 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-upload {
|
||||||
|
margin-top: 1rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-message {
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-message.success {
|
||||||
|
background: #d4edda;
|
||||||
|
color: #155724;
|
||||||
|
border: 1px solid #c3e6cb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-message.error {
|
||||||
|
background: #f8d7da;
|
||||||
|
color: #721c24;
|
||||||
|
border: 1px solid #f5c6cb;
|
||||||
|
}
|
||||||
|
|
||||||
59
frontend/index.html
Normal file
59
frontend/index.html
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Gestión de Pedidos - Kanban</title>
|
||||||
|
<link rel="stylesheet" href="css/base.css">
|
||||||
|
<link rel="stylesheet" href="css/kanban.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="header">
|
||||||
|
<h1>Gestión de Pedidos de Recambios</h1>
|
||||||
|
</div>
|
||||||
|
<nav class="nav">
|
||||||
|
<a href="index.html" class="active">Kanban</a>
|
||||||
|
<a href="proveedores.html">Proveedores</a>
|
||||||
|
<a href="admin.html">Administración</a>
|
||||||
|
<a href="upload.html">Subir Albarán</a>
|
||||||
|
</nav>
|
||||||
|
<div class="container">
|
||||||
|
<div class="filters">
|
||||||
|
<input type="text" id="search-input" class="filter-input" placeholder="Buscar por matrícula o número de pedido...">
|
||||||
|
<select id="estado-filter" class="filter-input">
|
||||||
|
<option value="">Todos los estados</option>
|
||||||
|
<option value="pendiente_revision">Pendiente Revisión</option>
|
||||||
|
<option value="en_revision">En Revisión</option>
|
||||||
|
<option value="pendiente_materiales">Pendiente Materiales</option>
|
||||||
|
<option value="completado">Completado</option>
|
||||||
|
</select>
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" id="urgente-only"> Solo urgentes
|
||||||
|
</label>
|
||||||
|
<button class="btn btn-primary refresh-btn" onclick="loadKanban()">Actualizar</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="kanban-container" id="kanban-container">
|
||||||
|
<div class="kanban-column">
|
||||||
|
<div class="column-header pendiente-revision">Pendiente Revisión</div>
|
||||||
|
<div id="col-pendiente-revision" class="cards-container"></div>
|
||||||
|
</div>
|
||||||
|
<div class="kanban-column">
|
||||||
|
<div class="column-header en-revision">En Revisión</div>
|
||||||
|
<div id="col-en-revision" class="cards-container"></div>
|
||||||
|
</div>
|
||||||
|
<div class="kanban-column">
|
||||||
|
<div class="column-header pendiente-materiales">Pendiente Materiales</div>
|
||||||
|
<div id="col-pendiente-materiales" class="cards-container"></div>
|
||||||
|
</div>
|
||||||
|
<div class="kanban-column">
|
||||||
|
<div class="column-header completado">Completado</div>
|
||||||
|
<div id="col-completado" class="cards-container"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script src="js/config.js"></script>
|
||||||
|
<script src="js/kanban.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
163
frontend/js/admin.js
Normal file
163
frontend/js/admin.js
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
// Admin panel functionality
|
||||||
|
|
||||||
|
function showTab(tabName) {
|
||||||
|
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||||||
|
document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active'));
|
||||||
|
|
||||||
|
event.target.classList.add('active');
|
||||||
|
document.getElementById(`tab-${tabName}`).classList.add('active');
|
||||||
|
|
||||||
|
if (tabName === 'albaranes') {
|
||||||
|
loadAlbaranes();
|
||||||
|
} else if (tabName === 'clasificacion') {
|
||||||
|
loadClasificacion();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAlbaranes() {
|
||||||
|
try {
|
||||||
|
const data = await apiRequest('/albaranes/');
|
||||||
|
const albaranes = Array.isArray(data) ? data : (data.results || []);
|
||||||
|
renderAlbaranes(albaranes);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error al cargar albaranes:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadClasificacion() {
|
||||||
|
try {
|
||||||
|
const data = await apiRequest('/albaranes/?estado_procesado=clasificacion');
|
||||||
|
const albaranes = Array.isArray(data) ? data : (data.results || []);
|
||||||
|
renderClasificacion(albaranes);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error al cargar clasificación:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAlbaranes(albaranes) {
|
||||||
|
const container = document.getElementById('albaranes-container');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
container.innerHTML = '';
|
||||||
|
|
||||||
|
albaranes.forEach(albaran => {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'albaran-card';
|
||||||
|
|
||||||
|
const estadoClass = albaran.estado_procesado === 'procesado' ? 'procesado' :
|
||||||
|
albaran.estado_procesado === 'clasificacion' ? 'clasificacion' : 'pendiente';
|
||||||
|
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="albaran-header">
|
||||||
|
<div>
|
||||||
|
<h3>Albarán ${albaran.numero_albaran || albaran.id}</h3>
|
||||||
|
<p>Proveedor: ${albaran.proveedor ? albaran.proveedor.nombre : 'Sin asignar'}</p>
|
||||||
|
<p>Fecha: ${albaran.fecha_albaran || 'N/A'}</p>
|
||||||
|
</div>
|
||||||
|
<span class="badge ${estadoClass}">${albaran.estado_procesado}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<img src="${API_CONFIG.BASE_URL.replace('/api', '')}/media/${albaran.archivo_path}" class="albaran-image" alt="Albarán" onerror="this.style.display='none'">
|
||||||
|
|
||||||
|
<div class="ocr-data">${JSON.stringify(albaran.datos_ocr || {}, null, 2)}</div>
|
||||||
|
|
||||||
|
${albaran.referencias && albaran.referencias.length > 0 ? `
|
||||||
|
<table class="referencias-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Referencia</th>
|
||||||
|
<th>Descripción</th>
|
||||||
|
<th>Unidades</th>
|
||||||
|
<th>Precio</th>
|
||||||
|
<th>IVA</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${albaran.referencias.map(ref => `
|
||||||
|
<tr>
|
||||||
|
<td>${ref.referencia}</td>
|
||||||
|
<td>${ref.denominacion}</td>
|
||||||
|
<td>${ref.unidades}</td>
|
||||||
|
<td>${ref.precio_unitario}€</td>
|
||||||
|
<td>${ref.impuesto_tipo}%</td>
|
||||||
|
</tr>
|
||||||
|
`).join('')}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
` : ''}
|
||||||
|
`;
|
||||||
|
|
||||||
|
container.appendChild(card);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderClasificacion(albaranes) {
|
||||||
|
const container = document.getElementById('clasificacion-container');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
container.innerHTML = '';
|
||||||
|
|
||||||
|
// Cargar proveedores primero
|
||||||
|
let proveedores = [];
|
||||||
|
try {
|
||||||
|
proveedores = await apiRequest('/proveedores/');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error al cargar proveedores:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
albaranes.forEach(albaran => {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'albaran-card';
|
||||||
|
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="albaran-header">
|
||||||
|
<h3>Albarán ${albaran.numero_albaran || albaran.id}</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<img src="${API_CONFIG.BASE_URL.replace('/api', '')}/media/${albaran.archivo_path}" class="albaran-image" alt="Albarán" onerror="this.style.display='none'">
|
||||||
|
|
||||||
|
<div style="margin-top: 1rem;">
|
||||||
|
<label>Asignar Proveedor:</label>
|
||||||
|
<select id="proveedor-${albaran.id}" style="padding: 0.5rem; margin: 0.5rem 0; width: 100%;">
|
||||||
|
<option value="">Seleccionar proveedor...</option>
|
||||||
|
${proveedores.map(prov => `
|
||||||
|
<option value="${prov.id}">${prov.nombre}</option>
|
||||||
|
`).join('')}
|
||||||
|
</select>
|
||||||
|
<button class="btn btn-primary" onclick="vincularProveedor(${albaran.id})" style="margin-top: 0.5rem;">
|
||||||
|
Vincular Proveedor
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
container.appendChild(card);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function vincularProveedor(albaranId) {
|
||||||
|
const select = document.getElementById(`proveedor-${albaranId}`);
|
||||||
|
if (!select) return;
|
||||||
|
|
||||||
|
const proveedorId = select.value;
|
||||||
|
|
||||||
|
if (!proveedorId) {
|
||||||
|
alert('Selecciona un proveedor');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiRequest(`/albaranes/${albaranId}/vincular-proveedor?proveedor_id=${proveedorId}`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
alert('Proveedor vinculado correctamente');
|
||||||
|
loadClasificacion();
|
||||||
|
} catch (error) {
|
||||||
|
alert('Error al vincular proveedor: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cargar inicial
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
loadAlbaranes();
|
||||||
|
});
|
||||||
|
|
||||||
32
frontend/js/config.js
Normal file
32
frontend/js/config.js
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
// Configuración de la API
|
||||||
|
const API_CONFIG = {
|
||||||
|
BASE_URL: 'http://localhost:8000/api',
|
||||||
|
// En producción cambiar a: BASE_URL: 'https://tu-dominio.com/api'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Función helper para hacer requests
|
||||||
|
async function apiRequest(endpoint, options = {}) {
|
||||||
|
const url = `${API_CONFIG.BASE_URL}${endpoint}`;
|
||||||
|
const defaultOptions = {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = { ...defaultOptions, ...options };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, config);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json().catch(() => ({ detail: 'Error desconocido' }));
|
||||||
|
throw new Error(error.detail || `Error ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('API Error:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
131
frontend/js/kanban.js
Normal file
131
frontend/js/kanban.js
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
// Kanban functionality
|
||||||
|
|
||||||
|
function createCard(pedido) {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'card';
|
||||||
|
if (pedido.es_urgente) {
|
||||||
|
card.classList.add('urgente');
|
||||||
|
}
|
||||||
|
|
||||||
|
const fechaCita = pedido.fecha_cita ? new Date(pedido.fecha_cita).toLocaleString('es-ES') : 'Sin fecha';
|
||||||
|
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="card-header">
|
||||||
|
<div>
|
||||||
|
<div class="card-title">Pedido ${pedido.numero_pedido}</div>
|
||||||
|
<div class="card-subtitle">${pedido.cliente?.matricula_vehiculo || 'N/A'} - ${pedido.cliente?.nombre || 'N/A'}</div>
|
||||||
|
</div>
|
||||||
|
${pedido.es_urgente ? '<span class="urgente-badge">URGENTE</span>' : ''}
|
||||||
|
</div>
|
||||||
|
<div class="card-subtitle" style="margin-bottom: 0.5rem;">Cita: ${fechaCita}</div>
|
||||||
|
<div class="referencias-list">
|
||||||
|
${(pedido.referencias || []).map(ref => {
|
||||||
|
const estadoClass = ref.estado === 'completo' ? 'completo' :
|
||||||
|
ref.estado === 'parcial' ? 'parcial' : 'pendiente';
|
||||||
|
return `
|
||||||
|
<div class="referencia-item ${estadoClass}">
|
||||||
|
<div>
|
||||||
|
<span class="referencia-codigo">${ref.referencia}</span>
|
||||||
|
<div style="font-size: 0.8rem;">${ref.denominacion}</div>
|
||||||
|
</div>
|
||||||
|
<div style="text-align: right;">
|
||||||
|
<div>${ref.unidades_solicitadas} unidades</div>
|
||||||
|
<div style="font-size: 0.8rem;">
|
||||||
|
Stock: ${ref.unidades_en_stock} | Pendiente: ${ref.unidades_pendientes}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('')}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderKanban(data) {
|
||||||
|
const columns = {
|
||||||
|
'pendiente_revision': document.getElementById('col-pendiente-revision'),
|
||||||
|
'en_revision': document.getElementById('col-en-revision'),
|
||||||
|
'pendiente_materiales': document.getElementById('col-pendiente-materiales'),
|
||||||
|
'completado': document.getElementById('col-completado'),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Limpiar columnas
|
||||||
|
Object.values(columns).forEach(col => {
|
||||||
|
if (col) col.innerHTML = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Renderizar tarjetas
|
||||||
|
Object.entries(data).forEach(([estado, pedidos]) => {
|
||||||
|
const column = columns[estado];
|
||||||
|
if (!column) return;
|
||||||
|
|
||||||
|
if (pedidos.length === 0) {
|
||||||
|
column.innerHTML = '<div class="empty-column">No hay pedidos</div>';
|
||||||
|
} else {
|
||||||
|
pedidos.forEach(pedido => {
|
||||||
|
column.appendChild(createCard(pedido));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadKanban() {
|
||||||
|
try {
|
||||||
|
const data = await apiRequest('/kanban/');
|
||||||
|
renderKanban(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error al cargar Kanban:', error);
|
||||||
|
alert('Error al cargar los datos: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtros
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const searchInput = document.getElementById('search-input');
|
||||||
|
const estadoFilter = document.getElementById('estado-filter');
|
||||||
|
const urgenteOnly = document.getElementById('urgente-only');
|
||||||
|
|
||||||
|
if (searchInput) {
|
||||||
|
searchInput.addEventListener('input', (e) => {
|
||||||
|
const search = e.target.value.toLowerCase();
|
||||||
|
document.querySelectorAll('.card').forEach(card => {
|
||||||
|
const text = card.textContent.toLowerCase();
|
||||||
|
card.style.display = text.includes(search) ? 'block' : 'none';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (estadoFilter) {
|
||||||
|
estadoFilter.addEventListener('change', async (e) => {
|
||||||
|
const estado = e.target.value;
|
||||||
|
if (!estado) {
|
||||||
|
loadKanban();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await apiRequest(`/pedidos-cliente/?estado=${estado}`);
|
||||||
|
const pedidos = Array.isArray(data) ? data : (data.results || []);
|
||||||
|
const grouped = {
|
||||||
|
'pendiente_revision': [],
|
||||||
|
'en_revision': [],
|
||||||
|
'pendiente_materiales': [],
|
||||||
|
'completado': [],
|
||||||
|
};
|
||||||
|
grouped[estado] = pedidos;
|
||||||
|
renderKanban(grouped);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error al filtrar:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-refresh cada 30 segundos
|
||||||
|
setInterval(loadKanban, 30000);
|
||||||
|
|
||||||
|
// Cargar inicial
|
||||||
|
loadKanban();
|
||||||
|
});
|
||||||
|
|
||||||
75
frontend/js/proveedores.js
Normal file
75
frontend/js/proveedores.js
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
// Proveedores functionality
|
||||||
|
|
||||||
|
function renderProveedores(data) {
|
||||||
|
const container = document.getElementById('proveedores-container');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
container.innerHTML = '';
|
||||||
|
|
||||||
|
if (data.length === 0) {
|
||||||
|
container.innerHTML = '<p>No hay referencias pendientes</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
data.forEach(proveedorData => {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'proveedor-card';
|
||||||
|
|
||||||
|
const pendientes = proveedorData.referencias_pendientes || [];
|
||||||
|
const devoluciones = proveedorData.referencias_devolucion || [];
|
||||||
|
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="proveedor-header">
|
||||||
|
${proveedorData.proveedor?.nombre || 'Sin nombre'}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${pendientes.length > 0 ? `
|
||||||
|
<div class="section-title">Referencias Pendientes de Recepción</div>
|
||||||
|
${pendientes.map(ref => `
|
||||||
|
<div class="referencia-item pendiente">
|
||||||
|
<div class="referencia-codigo">${ref.referencia}</div>
|
||||||
|
<div>${ref.denominacion}</div>
|
||||||
|
<div class="referencia-info">
|
||||||
|
Pedidas: ${ref.unidades_pedidas} | Recibidas: ${ref.unidades_recibidas} | Pendiente: ${ref.unidades_pedidas - ref.unidades_recibidas}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
${devoluciones.length > 0 ? `
|
||||||
|
<div class="section-title">Referencias Pendientes de Abono</div>
|
||||||
|
${devoluciones.map(dev => `
|
||||||
|
<div class="referencia-item devolucion">
|
||||||
|
<div class="referencia-codigo">${dev.referencia}</div>
|
||||||
|
<div>${dev.denominacion || ''}</div>
|
||||||
|
<div class="referencia-info">
|
||||||
|
Unidades: ${dev.unidades} | Fecha: ${new Date(dev.fecha_devolucion).toLocaleDateString('es-ES')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
${pendientes.length === 0 && devoluciones.length === 0 ?
|
||||||
|
'<p style="color: #95a5a6; text-align: center; padding: 2rem;">Sin referencias pendientes</p>' : ''}
|
||||||
|
`;
|
||||||
|
|
||||||
|
container.appendChild(card);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadProveedores() {
|
||||||
|
try {
|
||||||
|
const data = await apiRequest('/referencias-proveedor/');
|
||||||
|
renderProveedores(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error al cargar proveedores:', error);
|
||||||
|
alert('Error al cargar los datos: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-refresh cada 30 segundos
|
||||||
|
setInterval(loadProveedores, 30000);
|
||||||
|
|
||||||
|
// Cargar inicial
|
||||||
|
document.addEventListener('DOMContentLoaded', loadProveedores);
|
||||||
|
|
||||||
105
frontend/js/upload.js
Normal file
105
frontend/js/upload.js
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
// Upload functionality
|
||||||
|
let selectedFile = null;
|
||||||
|
|
||||||
|
const uploadArea = document.getElementById('upload-area');
|
||||||
|
const fileInput = document.getElementById('file-input');
|
||||||
|
const previewContainer = document.getElementById('preview-container');
|
||||||
|
const previewImage = document.getElementById('preview-image');
|
||||||
|
const statusMessage = document.getElementById('status-message');
|
||||||
|
|
||||||
|
if (uploadArea && fileInput) {
|
||||||
|
uploadArea.addEventListener('click', () => fileInput.click());
|
||||||
|
|
||||||
|
uploadArea.addEventListener('dragover', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
uploadArea.classList.add('dragover');
|
||||||
|
});
|
||||||
|
|
||||||
|
uploadArea.addEventListener('dragleave', () => {
|
||||||
|
uploadArea.classList.remove('dragover');
|
||||||
|
});
|
||||||
|
|
||||||
|
uploadArea.addEventListener('drop', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
uploadArea.classList.remove('dragover');
|
||||||
|
const files = e.dataTransfer.files;
|
||||||
|
if (files.length > 0) {
|
||||||
|
handleFile(files[0]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
fileInput.addEventListener('change', (e) => {
|
||||||
|
if (e.target.files.length > 0) {
|
||||||
|
handleFile(e.target.files[0]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFile(file) {
|
||||||
|
selectedFile = file;
|
||||||
|
|
||||||
|
if (file.type.startsWith('image/')) {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
previewImage.src = e.target.result;
|
||||||
|
previewContainer.style.display = 'block';
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
} else if (file.type === 'application/pdf') {
|
||||||
|
previewImage.src = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCIgZmlsbD0iI2VjZjBmMSIvPjx0ZXh0IHg9IjUwJSIgeT0iNTAlIiBmb250LWZhbWlseT0iQXJpYWwiIGZvbnQtc2l6ZT0iMTgiIGZpbGw9IiM3ZjhjOGQiIHRleHQtYW5jaG9yPSJtaWRkbGUiIGR5PSIuM2VtIj5QREY8L3RleHQ+PC9zdmc+';
|
||||||
|
previewContainer.style.display = 'block';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadFile() {
|
||||||
|
if (!selectedFile) return;
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('archivo', selectedFile);
|
||||||
|
|
||||||
|
statusMessage.style.display = 'none';
|
||||||
|
const btn = document.querySelector('.btn-upload');
|
||||||
|
if (btn) {
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Subiendo...';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_CONFIG.BASE_URL}/albaranes/upload/`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json().catch(() => ({ detail: 'Error desconocido' }));
|
||||||
|
throw new Error(error.detail || `Error ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
statusMessage.className = 'status-message success';
|
||||||
|
statusMessage.textContent = 'Albarán subido y procesado correctamente';
|
||||||
|
statusMessage.style.display = 'block';
|
||||||
|
|
||||||
|
// Reset
|
||||||
|
setTimeout(() => {
|
||||||
|
selectedFile = null;
|
||||||
|
fileInput.value = '';
|
||||||
|
previewContainer.style.display = 'none';
|
||||||
|
statusMessage.style.display = 'none';
|
||||||
|
if (btn) {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Subir Albarán';
|
||||||
|
}
|
||||||
|
}, 3000);
|
||||||
|
} catch (error) {
|
||||||
|
statusMessage.className = 'status-message error';
|
||||||
|
statusMessage.textContent = 'Error al subir el albarán: ' + error.message;
|
||||||
|
statusMessage.style.display = 'block';
|
||||||
|
if (btn) {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Subir Albarán';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
29
frontend/proveedores.html
Normal file
29
frontend/proveedores.html
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Referencias por Proveedor</title>
|
||||||
|
<link rel="stylesheet" href="css/base.css">
|
||||||
|
<link rel="stylesheet" href="css/proveedores.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="header">
|
||||||
|
<h1>Gestión de Pedidos de Recambios</h1>
|
||||||
|
</div>
|
||||||
|
<nav class="nav">
|
||||||
|
<a href="index.html">Kanban</a>
|
||||||
|
<a href="proveedores.html" class="active">Proveedores</a>
|
||||||
|
<a href="admin.html">Administración</a>
|
||||||
|
<a href="upload.html">Subir Albarán</a>
|
||||||
|
</nav>
|
||||||
|
<div class="container">
|
||||||
|
<div class="proveedores-container" id="proveedores-container">
|
||||||
|
<!-- Se llenará con JavaScript -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script src="js/config.js"></script>
|
||||||
|
<script src="js/proveedores.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
45
frontend/upload.html
Normal file
45
frontend/upload.html
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Subir Albarán</title>
|
||||||
|
<link rel="stylesheet" href="css/base.css">
|
||||||
|
<link rel="stylesheet" href="css/upload.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="header">
|
||||||
|
<h1>Gestión de Pedidos de Recambios</h1>
|
||||||
|
</div>
|
||||||
|
<nav class="nav">
|
||||||
|
<a href="index.html">Kanban</a>
|
||||||
|
<a href="proveedores.html">Proveedores</a>
|
||||||
|
<a href="admin.html">Administración</a>
|
||||||
|
<a href="upload.html" class="active">Subir Albarán</a>
|
||||||
|
</nav>
|
||||||
|
<div class="container">
|
||||||
|
<div class="upload-container">
|
||||||
|
<h2 style="margin-bottom: 2rem;">Subir Albarán</h2>
|
||||||
|
|
||||||
|
<div class="upload-area" id="upload-area">
|
||||||
|
<div class="upload-icon">📄</div>
|
||||||
|
<p>Arrastra una imagen aquí o haz clic para seleccionar</p>
|
||||||
|
<p style="font-size: 0.9rem; color: #7f8c8d; margin-top: 0.5rem;">
|
||||||
|
Formatos soportados: JPG, PNG, PDF
|
||||||
|
</p>
|
||||||
|
<input type="file" id="file-input" class="file-input" accept="image/*,.pdf">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="preview-container" style="display: none;">
|
||||||
|
<img id="preview-image" class="preview-image" alt="Vista previa">
|
||||||
|
<button class="btn btn-primary btn-upload" onclick="uploadFile()">Subir Albarán</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="status-message" class="status-message"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script src="js/config.js"></script>
|
||||||
|
<script src="js/upload.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
0
gestion_pedidos/__init__.py
Normal file
0
gestion_pedidos/__init__.py
Normal file
84
gestion_pedidos/admin.py
Normal file
84
gestion_pedidos/admin.py
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
from .models import (
|
||||||
|
Cliente, PedidoCliente, ReferenciaPedidoCliente,
|
||||||
|
Proveedor, PedidoProveedor, ReferenciaPedidoProveedor,
|
||||||
|
Albaran, ReferenciaAlbaran, Devolucion, StockReferencia
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Cliente)
|
||||||
|
class ClienteAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('id', 'nombre', 'matricula_vehiculo', 'telefono', 'email')
|
||||||
|
search_fields = ('nombre', 'matricula_vehiculo', 'email')
|
||||||
|
list_filter = ('nombre',)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(PedidoCliente)
|
||||||
|
class PedidoClienteAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('id', 'numero_pedido', 'cliente', 'fecha_pedido', 'fecha_cita', 'estado')
|
||||||
|
search_fields = ('numero_pedido', 'cliente__nombre', 'cliente__matricula_vehiculo')
|
||||||
|
list_filter = ('estado', 'fecha_pedido', 'fecha_cita')
|
||||||
|
date_hierarchy = 'fecha_pedido'
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(ReferenciaPedidoCliente)
|
||||||
|
class ReferenciaPedidoClienteAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('id', 'pedido_cliente', 'referencia', 'denominacion', 'unidades_solicitadas',
|
||||||
|
'unidades_en_stock', 'unidades_pendientes', 'estado')
|
||||||
|
search_fields = ('referencia', 'denominacion', 'pedido_cliente__numero_pedido')
|
||||||
|
list_filter = ('estado',)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Proveedor)
|
||||||
|
class ProveedorAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('id', 'nombre', 'email', 'tiene_web', 'activo')
|
||||||
|
search_fields = ('nombre', 'email')
|
||||||
|
list_filter = ('tiene_web', 'activo')
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(PedidoProveedor)
|
||||||
|
class PedidoProveedorAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('id', 'proveedor', 'numero_pedido', 'fecha_pedido', 'estado', 'tipo')
|
||||||
|
search_fields = ('numero_pedido', 'proveedor__nombre')
|
||||||
|
list_filter = ('estado', 'tipo', 'fecha_pedido')
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(ReferenciaPedidoProveedor)
|
||||||
|
class ReferenciaPedidoProveedorAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('id', 'pedido_proveedor', 'referencia', 'unidades_pedidas',
|
||||||
|
'unidades_recibidas', 'estado')
|
||||||
|
search_fields = ('referencia', 'pedido_proveedor__numero_pedido')
|
||||||
|
list_filter = ('estado',)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Albaran)
|
||||||
|
class AlbaranAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('id', 'proveedor', 'numero_albaran', 'fecha_albaran',
|
||||||
|
'estado_procesado', 'fecha_procesado')
|
||||||
|
search_fields = ('numero_albaran', 'proveedor__nombre')
|
||||||
|
list_filter = ('estado_procesado', 'fecha_albaran')
|
||||||
|
date_hierarchy = 'fecha_albaran'
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(ReferenciaAlbaran)
|
||||||
|
class ReferenciaAlbaranAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('id', 'albaran', 'referencia', 'denominacion', 'unidades',
|
||||||
|
'precio_unitario', 'impuesto_tipo')
|
||||||
|
search_fields = ('referencia', 'denominacion', 'albaran__numero_albaran')
|
||||||
|
list_filter = ('impuesto_tipo',)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Devolucion)
|
||||||
|
class DevolucionAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('id', 'proveedor', 'referencia', 'unidades', 'fecha_devolucion',
|
||||||
|
'estado_abono')
|
||||||
|
search_fields = ('referencia', 'proveedor__nombre')
|
||||||
|
list_filter = ('estado_abono', 'fecha_devolucion')
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(StockReferencia)
|
||||||
|
class StockReferenciaAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('id', 'referencia', 'unidades_disponibles', 'ultima_actualizacion')
|
||||||
|
search_fields = ('referencia',)
|
||||||
|
list_filter = ('ultima_actualizacion',)
|
||||||
|
|
||||||
7
gestion_pedidos/apps.py
Normal file
7
gestion_pedidos/apps.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class GestionPedidosConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'gestion_pedidos'
|
||||||
|
|
||||||
0
gestion_pedidos/management/__init__.py
Normal file
0
gestion_pedidos/management/__init__.py
Normal file
0
gestion_pedidos/management/commands/__init__.py
Normal file
0
gestion_pedidos/management/commands/__init__.py
Normal file
28
gestion_pedidos/management/commands/start_file_watcher.py
Normal file
28
gestion_pedidos/management/commands/start_file_watcher.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
"""
|
||||||
|
Comando para iniciar el file watcher que monitorea carpetas
|
||||||
|
"""
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from gestion_pedidos.services.file_watcher import FileWatcherService
|
||||||
|
import time
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = 'Inicia el servicio de monitoreo de carpetas para procesar PDFs y albaranes automáticamente'
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
self.stdout.write(self.style.SUCCESS('Iniciando file watcher...'))
|
||||||
|
|
||||||
|
watcher = FileWatcherService()
|
||||||
|
watcher.start()
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
time.sleep(1)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
self.stdout.write(self.style.WARNING('\nDeteniendo file watcher...'))
|
||||||
|
watcher.stop()
|
||||||
|
self.stdout.write(self.style.SUCCESS('File watcher detenido.'))
|
||||||
|
|
||||||
0
gestion_pedidos/migrations/__init__.py
Normal file
0
gestion_pedidos/migrations/__init__.py
Normal file
354
gestion_pedidos/models.py
Normal file
354
gestion_pedidos/models.py
Normal file
@@ -0,0 +1,354 @@
|
|||||||
|
from django.db import models
|
||||||
|
from django.core.validators import MinValueValidator
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
|
||||||
|
class Cliente(models.Model):
|
||||||
|
"""Cliente con información del vehículo"""
|
||||||
|
nombre = models.CharField(max_length=200)
|
||||||
|
matricula_vehiculo = models.CharField(max_length=20, unique=True)
|
||||||
|
telefono = models.CharField(max_length=20, blank=True, null=True)
|
||||||
|
email = models.EmailField(blank=True, null=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = 'clientes'
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['matricula_vehiculo']),
|
||||||
|
models.Index(fields=['nombre']),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.nombre} - {self.matricula_vehiculo}"
|
||||||
|
|
||||||
|
|
||||||
|
class PedidoCliente(models.Model):
|
||||||
|
"""Pedido de recambios de un cliente"""
|
||||||
|
ESTADOS = [
|
||||||
|
('pendiente_revision', 'Pendiente Revisión'),
|
||||||
|
('en_revision', 'En Revisión'),
|
||||||
|
('pendiente_materiales', 'Pendiente Materiales'),
|
||||||
|
('completado', 'Completado'),
|
||||||
|
]
|
||||||
|
|
||||||
|
numero_pedido = models.CharField(max_length=50, unique=True, db_index=True)
|
||||||
|
cliente = models.ForeignKey(Cliente, on_delete=models.CASCADE, related_name='pedidos')
|
||||||
|
fecha_pedido = models.DateTimeField(default=timezone.now)
|
||||||
|
fecha_cita = models.DateTimeField(blank=True, null=True)
|
||||||
|
estado = models.CharField(max_length=30, choices=ESTADOS, default='pendiente_revision')
|
||||||
|
presupuesto_id = models.CharField(max_length=50, blank=True, null=True)
|
||||||
|
archivo_pdf_path = models.CharField(max_length=500, blank=True, null=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = 'pedidos_cliente'
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['numero_pedido']),
|
||||||
|
models.Index(fields=['estado']),
|
||||||
|
models.Index(fields=['fecha_cita']),
|
||||||
|
models.Index(fields=['fecha_pedido']),
|
||||||
|
]
|
||||||
|
ordering = ['-fecha_pedido']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Pedido {self.numero_pedido} - {self.cliente.matricula_vehiculo}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def es_urgente(self):
|
||||||
|
"""Verifica si el pedido es urgente (menos de 12 horas para la cita)"""
|
||||||
|
if not self.fecha_cita:
|
||||||
|
return False
|
||||||
|
tiempo_restante = self.fecha_cita - timezone.now()
|
||||||
|
return tiempo_restante.total_seconds() < 12 * 3600 and tiempo_restante.total_seconds() > 0
|
||||||
|
|
||||||
|
|
||||||
|
class ReferenciaPedidoCliente(models.Model):
|
||||||
|
"""Referencias (piezas) de un pedido de cliente"""
|
||||||
|
ESTADOS = [
|
||||||
|
('pendiente', 'Pendiente'),
|
||||||
|
('parcial', 'Parcial'),
|
||||||
|
('completo', 'Completo'),
|
||||||
|
]
|
||||||
|
|
||||||
|
pedido_cliente = models.ForeignKey(
|
||||||
|
PedidoCliente,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='referencias'
|
||||||
|
)
|
||||||
|
referencia = models.CharField(max_length=100, db_index=True)
|
||||||
|
denominacion = models.CharField(max_length=500)
|
||||||
|
unidades_solicitadas = models.IntegerField(validators=[MinValueValidator(1)])
|
||||||
|
unidades_en_stock = models.IntegerField(default=0, validators=[MinValueValidator(0)])
|
||||||
|
unidades_pendientes = models.IntegerField(default=0, validators=[MinValueValidator(0)])
|
||||||
|
estado = models.CharField(max_length=20, choices=ESTADOS, default='pendiente')
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = 'referencias_pedido_cliente'
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['referencia']),
|
||||||
|
models.Index(fields=['estado']),
|
||||||
|
models.Index(fields=['pedido_cliente', 'estado']),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.referencia} - {self.denominacion} ({self.unidades_solicitadas} unidades)"
|
||||||
|
|
||||||
|
def calcular_estado(self):
|
||||||
|
"""Calcula el estado basado en unidades"""
|
||||||
|
if self.unidades_pendientes <= 0:
|
||||||
|
return 'completo'
|
||||||
|
elif self.unidades_pendientes < self.unidades_solicitadas:
|
||||||
|
return 'parcial'
|
||||||
|
return 'pendiente'
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
self.unidades_pendientes = max(0, self.unidades_solicitadas - self.unidades_en_stock)
|
||||||
|
self.estado = self.calcular_estado()
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class Proveedor(models.Model):
|
||||||
|
"""Proveedor de recambios"""
|
||||||
|
nombre = models.CharField(max_length=200, db_index=True)
|
||||||
|
email = models.EmailField(blank=True, null=True)
|
||||||
|
tiene_web = models.BooleanField(default=True)
|
||||||
|
activo = models.BooleanField(default=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = 'proveedores'
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['nombre']),
|
||||||
|
models.Index(fields=['activo']),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.nombre
|
||||||
|
|
||||||
|
|
||||||
|
class PedidoProveedor(models.Model):
|
||||||
|
"""Pedido realizado a un proveedor"""
|
||||||
|
ESTADOS = [
|
||||||
|
('pendiente_recepcion', 'Pendiente Recepción'),
|
||||||
|
('parcial', 'Parcial'),
|
||||||
|
('completado', 'Completado'),
|
||||||
|
('cancelado', 'Cancelado'),
|
||||||
|
]
|
||||||
|
|
||||||
|
TIPOS = [
|
||||||
|
('web', 'Web'),
|
||||||
|
('manual', 'Manual'),
|
||||||
|
]
|
||||||
|
|
||||||
|
proveedor = models.ForeignKey(
|
||||||
|
Proveedor,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='pedidos'
|
||||||
|
)
|
||||||
|
numero_pedido = models.CharField(max_length=100, blank=True, null=True)
|
||||||
|
fecha_pedido = models.DateTimeField(default=timezone.now)
|
||||||
|
email_confirmacion_path = models.CharField(max_length=500, blank=True, null=True)
|
||||||
|
estado = models.CharField(max_length=30, choices=ESTADOS, default='pendiente_recepcion')
|
||||||
|
tipo = models.CharField(max_length=20, choices=TIPOS, default='web')
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = 'pedidos_proveedor'
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['proveedor', 'estado']),
|
||||||
|
models.Index(fields=['fecha_pedido']),
|
||||||
|
models.Index(fields=['estado']),
|
||||||
|
]
|
||||||
|
ordering = ['-fecha_pedido']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Pedido {self.numero_pedido or self.id} - {self.proveedor.nombre}"
|
||||||
|
|
||||||
|
|
||||||
|
class ReferenciaPedidoProveedor(models.Model):
|
||||||
|
"""Referencias pedidas a un proveedor"""
|
||||||
|
ESTADOS = [
|
||||||
|
('pendiente', 'Pendiente'),
|
||||||
|
('parcial', 'Parcial'),
|
||||||
|
('recibido', 'Recibido'),
|
||||||
|
]
|
||||||
|
|
||||||
|
pedido_proveedor = models.ForeignKey(
|
||||||
|
PedidoProveedor,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='referencias'
|
||||||
|
)
|
||||||
|
referencia_pedido_cliente = models.ForeignKey(
|
||||||
|
ReferenciaPedidoCliente,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='pedidos_proveedor',
|
||||||
|
blank=True,
|
||||||
|
null=True
|
||||||
|
)
|
||||||
|
referencia = models.CharField(max_length=100, db_index=True)
|
||||||
|
denominacion = models.CharField(max_length=500)
|
||||||
|
unidades_pedidas = models.IntegerField(validators=[MinValueValidator(1)])
|
||||||
|
unidades_recibidas = models.IntegerField(default=0, validators=[MinValueValidator(0)])
|
||||||
|
estado = models.CharField(max_length=20, choices=ESTADOS, default='pendiente')
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = 'referencias_pedido_proveedor'
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['referencia']),
|
||||||
|
models.Index(fields=['estado']),
|
||||||
|
models.Index(fields=['pedido_proveedor', 'estado']),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.referencia} - {self.denominacion} ({self.unidades_pedidas} unidades)"
|
||||||
|
|
||||||
|
|
||||||
|
class Albaran(models.Model):
|
||||||
|
"""Albarán recibido de un proveedor"""
|
||||||
|
ESTADOS_PROCESADO = [
|
||||||
|
('pendiente', 'Pendiente'),
|
||||||
|
('procesado', 'Procesado'),
|
||||||
|
('clasificacion', 'Pendiente Clasificación'),
|
||||||
|
('error', 'Error'),
|
||||||
|
]
|
||||||
|
|
||||||
|
proveedor = models.ForeignKey(
|
||||||
|
Proveedor,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='albaranes',
|
||||||
|
blank=True,
|
||||||
|
null=True
|
||||||
|
)
|
||||||
|
numero_albaran = models.CharField(max_length=100, blank=True, null=True)
|
||||||
|
fecha_albaran = models.DateField(blank=True, null=True)
|
||||||
|
archivo_path = models.CharField(max_length=500)
|
||||||
|
estado_procesado = models.CharField(
|
||||||
|
max_length=30,
|
||||||
|
choices=ESTADOS_PROCESADO,
|
||||||
|
default='pendiente'
|
||||||
|
)
|
||||||
|
fecha_procesado = models.DateTimeField(blank=True, null=True)
|
||||||
|
datos_ocr = models.JSONField(default=dict, blank=True) # Datos extraídos por OCR
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = 'albaranes'
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['proveedor', 'estado_procesado']),
|
||||||
|
models.Index(fields=['numero_albaran']),
|
||||||
|
models.Index(fields=['fecha_albaran']),
|
||||||
|
models.Index(fields=['estado_procesado']),
|
||||||
|
]
|
||||||
|
ordering = ['-created_at']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Albarán {self.numero_albaran or self.id} - {self.proveedor.nombre if self.proveedor else 'Sin proveedor'}"
|
||||||
|
|
||||||
|
|
||||||
|
class ReferenciaAlbaran(models.Model):
|
||||||
|
"""Referencias contenidas en un albarán"""
|
||||||
|
TIPOS_IMPUESTO = [
|
||||||
|
('21', '21%'),
|
||||||
|
('10', '10%'),
|
||||||
|
('7', '7%'),
|
||||||
|
('4', '4%'),
|
||||||
|
('3', '3%'),
|
||||||
|
('0', '0%'),
|
||||||
|
]
|
||||||
|
|
||||||
|
albaran = models.ForeignKey(
|
||||||
|
Albaran,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='referencias'
|
||||||
|
)
|
||||||
|
referencia = models.CharField(max_length=100, db_index=True)
|
||||||
|
denominacion = models.CharField(max_length=500)
|
||||||
|
unidades = models.IntegerField(validators=[MinValueValidator(1)])
|
||||||
|
precio_unitario = models.DecimalField(max_digits=10, decimal_places=2, default=0)
|
||||||
|
impuesto_tipo = models.CharField(max_length=5, choices=TIPOS_IMPUESTO, default='21')
|
||||||
|
impuesto_valor = models.DecimalField(max_digits=10, decimal_places=2, default=0)
|
||||||
|
referencia_pedido_proveedor = models.ForeignKey(
|
||||||
|
ReferenciaPedidoProveedor,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
related_name='referencias_albaran',
|
||||||
|
blank=True,
|
||||||
|
null=True
|
||||||
|
)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = 'referencias_albaran'
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['referencia']),
|
||||||
|
models.Index(fields=['albaran']),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.referencia} - {self.denominacion} ({self.unidades} unidades)"
|
||||||
|
|
||||||
|
|
||||||
|
class Devolucion(models.Model):
|
||||||
|
"""Devolución de material a proveedor"""
|
||||||
|
ESTADOS_ABONO = [
|
||||||
|
('pendiente', 'Pendiente Abono'),
|
||||||
|
('abonado', 'Abonado'),
|
||||||
|
]
|
||||||
|
|
||||||
|
proveedor = models.ForeignKey(
|
||||||
|
Proveedor,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='devoluciones'
|
||||||
|
)
|
||||||
|
referencia = models.CharField(max_length=100, db_index=True)
|
||||||
|
denominacion = models.CharField(max_length=500, blank=True, null=True)
|
||||||
|
unidades = models.IntegerField(validators=[MinValueValidator(1)])
|
||||||
|
fecha_devolucion = models.DateTimeField(default=timezone.now)
|
||||||
|
estado_abono = models.CharField(max_length=20, choices=ESTADOS_ABONO, default='pendiente')
|
||||||
|
albaran_abono = models.ForeignKey(
|
||||||
|
Albaran,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
related_name='devoluciones',
|
||||||
|
blank=True,
|
||||||
|
null=True
|
||||||
|
)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = 'devoluciones'
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['proveedor', 'estado_abono']),
|
||||||
|
models.Index(fields=['referencia']),
|
||||||
|
models.Index(fields=['estado_abono']),
|
||||||
|
]
|
||||||
|
ordering = ['-fecha_devolucion']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Devolución {self.referencia} - {self.proveedor.nombre} ({self.unidades} unidades)"
|
||||||
|
|
||||||
|
|
||||||
|
class StockReferencia(models.Model):
|
||||||
|
"""Stock disponible de una referencia"""
|
||||||
|
referencia = models.CharField(max_length=100, unique=True, db_index=True)
|
||||||
|
unidades_disponibles = models.IntegerField(default=0, validators=[MinValueValidator(0)])
|
||||||
|
ultima_actualizacion = models.DateTimeField(auto_now=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = 'stock_referencias'
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['referencia']),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.referencia} - {self.unidades_disponibles} unidades"
|
||||||
|
|
||||||
124
gestion_pedidos/serializers.py
Normal file
124
gestion_pedidos/serializers.py
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
from rest_framework import serializers
|
||||||
|
from .models import (
|
||||||
|
Cliente, PedidoCliente, ReferenciaPedidoCliente,
|
||||||
|
Proveedor, PedidoProveedor, ReferenciaPedidoProveedor,
|
||||||
|
Albaran, ReferenciaAlbaran, Devolucion, StockReferencia
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ClienteSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = Cliente
|
||||||
|
fields = '__all__'
|
||||||
|
|
||||||
|
|
||||||
|
class ReferenciaPedidoClienteSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = ReferenciaPedidoCliente
|
||||||
|
fields = '__all__'
|
||||||
|
|
||||||
|
|
||||||
|
class PedidoClienteSerializer(serializers.ModelSerializer):
|
||||||
|
cliente = ClienteSerializer(read_only=True)
|
||||||
|
cliente_id = serializers.PrimaryKeyRelatedField(
|
||||||
|
queryset=Cliente.objects.all(),
|
||||||
|
source='cliente',
|
||||||
|
write_only=True,
|
||||||
|
required=False
|
||||||
|
)
|
||||||
|
referencias = ReferenciaPedidoClienteSerializer(many=True, read_only=True)
|
||||||
|
es_urgente = serializers.ReadOnlyField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = PedidoCliente
|
||||||
|
fields = '__all__'
|
||||||
|
|
||||||
|
|
||||||
|
class ProveedorSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = Proveedor
|
||||||
|
fields = '__all__'
|
||||||
|
|
||||||
|
|
||||||
|
class ReferenciaPedidoProveedorSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = ReferenciaPedidoProveedor
|
||||||
|
fields = '__all__'
|
||||||
|
|
||||||
|
|
||||||
|
class PedidoProveedorSerializer(serializers.ModelSerializer):
|
||||||
|
proveedor = ProveedorSerializer(read_only=True)
|
||||||
|
proveedor_id = serializers.PrimaryKeyRelatedField(
|
||||||
|
queryset=Proveedor.objects.all(),
|
||||||
|
source='proveedor',
|
||||||
|
write_only=True
|
||||||
|
)
|
||||||
|
referencias = ReferenciaPedidoProveedorSerializer(many=True, read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = PedidoProveedor
|
||||||
|
fields = '__all__'
|
||||||
|
|
||||||
|
|
||||||
|
class ReferenciaAlbaranSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = ReferenciaAlbaran
|
||||||
|
fields = '__all__'
|
||||||
|
|
||||||
|
|
||||||
|
class AlbaranSerializer(serializers.ModelSerializer):
|
||||||
|
proveedor = ProveedorSerializer(read_only=True)
|
||||||
|
proveedor_id = serializers.PrimaryKeyRelatedField(
|
||||||
|
queryset=Proveedor.objects.all(),
|
||||||
|
source='proveedor',
|
||||||
|
write_only=True,
|
||||||
|
required=False,
|
||||||
|
allow_null=True
|
||||||
|
)
|
||||||
|
referencias = ReferenciaAlbaranSerializer(many=True, read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Albaran
|
||||||
|
fields = '__all__'
|
||||||
|
|
||||||
|
|
||||||
|
class DevolucionSerializer(serializers.ModelSerializer):
|
||||||
|
proveedor = ProveedorSerializer(read_only=True)
|
||||||
|
proveedor_id = serializers.PrimaryKeyRelatedField(
|
||||||
|
queryset=Proveedor.objects.all(),
|
||||||
|
source='proveedor',
|
||||||
|
write_only=True
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Devolucion
|
||||||
|
fields = '__all__'
|
||||||
|
|
||||||
|
|
||||||
|
class StockReferenciaSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = StockReferencia
|
||||||
|
fields = '__all__'
|
||||||
|
|
||||||
|
|
||||||
|
# Serializers para actualización de stock
|
||||||
|
class UpdateStockSerializer(serializers.Serializer):
|
||||||
|
referencia_id = serializers.IntegerField()
|
||||||
|
unidades_en_stock = serializers.IntegerField(min_value=0)
|
||||||
|
|
||||||
|
|
||||||
|
class BulkUpdateStockSerializer(serializers.Serializer):
|
||||||
|
updates = UpdateStockSerializer(many=True)
|
||||||
|
|
||||||
|
|
||||||
|
# Serializer para crear pedido a proveedor manualmente
|
||||||
|
class CrearPedidoProveedorSerializer(serializers.Serializer):
|
||||||
|
proveedor_id = serializers.IntegerField()
|
||||||
|
numero_pedido = serializers.CharField(required=False, allow_blank=True)
|
||||||
|
tipo = serializers.ChoiceField(choices=['web', 'manual'], default='manual')
|
||||||
|
referencias = serializers.ListField(
|
||||||
|
child=serializers.DictField(
|
||||||
|
child=serializers.CharField()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
0
gestion_pedidos/services/__init__.py
Normal file
0
gestion_pedidos/services/__init__.py
Normal file
188
gestion_pedidos/services/albaran_processor.py
Normal file
188
gestion_pedidos/services/albaran_processor.py
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
"""
|
||||||
|
Procesador de albaranes con OCR y vinculación automática
|
||||||
|
"""
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, Optional, List
|
||||||
|
from django.utils import timezone
|
||||||
|
from datetime import datetime
|
||||||
|
from .ocr_service import OCRService
|
||||||
|
from ..models import (
|
||||||
|
Proveedor, Albaran, ReferenciaAlbaran,
|
||||||
|
ReferenciaPedidoProveedor, PedidoProveedor
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AlbaranProcessor:
|
||||||
|
"""Procesa albaranes y los vincula con pedidos pendientes"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.ocr_service = OCRService()
|
||||||
|
|
||||||
|
def _find_proveedor(self, datos: Dict) -> Optional[Proveedor]:
|
||||||
|
"""Busca el proveedor basándose en los datos del albarán"""
|
||||||
|
nombre = datos.get('proveedor', {}).get('nombre', '').strip()
|
||||||
|
numero = datos.get('proveedor', {}).get('numero', '').strip()
|
||||||
|
|
||||||
|
# Buscar por nombre
|
||||||
|
if nombre:
|
||||||
|
proveedor = Proveedor.objects.filter(
|
||||||
|
nombre__icontains=nombre
|
||||||
|
).first()
|
||||||
|
if proveedor:
|
||||||
|
return proveedor
|
||||||
|
|
||||||
|
# Buscar por número (si se implementa un campo número_proveedor)
|
||||||
|
# Por ahora, retornamos None si no se encuentra
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _parse_fecha(self, fecha_str: str) -> Optional[datetime]:
|
||||||
|
"""Parsea una fecha desde string"""
|
||||||
|
if not fecha_str:
|
||||||
|
return None
|
||||||
|
|
||||||
|
formatos = [
|
||||||
|
'%Y-%m-%d',
|
||||||
|
'%d/%m/%Y',
|
||||||
|
'%d-%m-%Y',
|
||||||
|
'%Y/%m/%d',
|
||||||
|
]
|
||||||
|
|
||||||
|
for fmt in formatos:
|
||||||
|
try:
|
||||||
|
return datetime.strptime(fecha_str, fmt).date()
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _match_referencias(
|
||||||
|
self,
|
||||||
|
referencias_albaran: List[Dict],
|
||||||
|
proveedor: Proveedor
|
||||||
|
) -> Dict[str, ReferenciaPedidoProveedor]:
|
||||||
|
"""
|
||||||
|
Busca referencias del albarán en pedidos pendientes del proveedor
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict mapping referencia -> ReferenciaPedidoProveedor
|
||||||
|
"""
|
||||||
|
matches = {}
|
||||||
|
|
||||||
|
# Obtener todas las referencias pendientes del proveedor
|
||||||
|
pedidos_pendientes = PedidoProveedor.objects.filter(
|
||||||
|
proveedor=proveedor,
|
||||||
|
estado__in=['pendiente_recepcion', 'parcial']
|
||||||
|
).prefetch_related('referencias')
|
||||||
|
|
||||||
|
for pedido in pedidos_pendientes:
|
||||||
|
for ref_pedido in pedido.referencias.filter(estado__in=['pendiente', 'parcial']):
|
||||||
|
# Buscar coincidencia en el albarán
|
||||||
|
for ref_albaran in referencias_albaran:
|
||||||
|
if ref_albaran['referencia'].strip().upper() == ref_pedido.referencia.strip().upper():
|
||||||
|
if ref_pedido.referencia not in matches:
|
||||||
|
matches[ref_pedido.referencia] = ref_pedido
|
||||||
|
break
|
||||||
|
|
||||||
|
return matches
|
||||||
|
|
||||||
|
def _match_and_update_referencias(self, albaran: Albaran):
|
||||||
|
"""Vincula y actualiza referencias del albarán con pedidos pendientes"""
|
||||||
|
if not albaran.proveedor:
|
||||||
|
return
|
||||||
|
|
||||||
|
referencias_albaran = albaran.referencias.all()
|
||||||
|
matches = self._match_referencias(
|
||||||
|
[{'referencia': ref.referencia} for ref in referencias_albaran],
|
||||||
|
albaran.proveedor
|
||||||
|
)
|
||||||
|
|
||||||
|
for ref_albaran in referencias_albaran:
|
||||||
|
ref_pedido_proveedor = matches.get(ref_albaran.referencia.strip().upper())
|
||||||
|
|
||||||
|
if ref_pedido_proveedor:
|
||||||
|
ref_albaran.referencia_pedido_proveedor = ref_pedido_proveedor
|
||||||
|
ref_albaran.save()
|
||||||
|
|
||||||
|
# Actualizar pedido proveedor
|
||||||
|
ref_pedido_proveedor.unidades_recibidas += ref_albaran.unidades
|
||||||
|
|
||||||
|
if ref_pedido_proveedor.unidades_recibidas >= ref_pedido_proveedor.unidades_pedidas:
|
||||||
|
ref_pedido_proveedor.estado = 'recibido'
|
||||||
|
elif ref_pedido_proveedor.unidades_recibidas > 0:
|
||||||
|
ref_pedido_proveedor.estado = 'parcial'
|
||||||
|
|
||||||
|
ref_pedido_proveedor.save()
|
||||||
|
|
||||||
|
# Actualizar referencia pedido cliente
|
||||||
|
if ref_pedido_proveedor.referencia_pedido_cliente:
|
||||||
|
ref_cliente = ref_pedido_proveedor.referencia_pedido_cliente
|
||||||
|
ref_cliente.unidades_en_stock += ref_albaran.unidades
|
||||||
|
ref_cliente.save()
|
||||||
|
|
||||||
|
def process_albaran_file(self, file_path: Path) -> Albaran:
|
||||||
|
"""
|
||||||
|
Procesa un archivo de albarán (imagen o PDF)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Albaran creado o actualizado
|
||||||
|
"""
|
||||||
|
# Procesar con OCR
|
||||||
|
datos = self.ocr_service.process_albaran(file_path)
|
||||||
|
|
||||||
|
# Buscar proveedor
|
||||||
|
proveedor = self._find_proveedor(datos)
|
||||||
|
|
||||||
|
# Parsear fecha
|
||||||
|
fecha_albaran = self._parse_fecha(datos.get('fecha_albaran', ''))
|
||||||
|
|
||||||
|
# Crear albarán
|
||||||
|
albaran = Albaran.objects.create(
|
||||||
|
proveedor=proveedor,
|
||||||
|
numero_albaran=datos.get('numero_albaran', '').strip() or None,
|
||||||
|
fecha_albaran=fecha_albaran,
|
||||||
|
archivo_path=str(file_path),
|
||||||
|
estado_procesado='procesado' if proveedor else 'clasificacion',
|
||||||
|
fecha_procesado=timezone.now(),
|
||||||
|
datos_ocr=datos,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Crear referencias del albarán
|
||||||
|
referencias_data = datos.get('referencias', [])
|
||||||
|
matches = {}
|
||||||
|
|
||||||
|
if proveedor:
|
||||||
|
matches = self._match_referencias(referencias_data, proveedor)
|
||||||
|
|
||||||
|
for ref_data in referencias_data:
|
||||||
|
ref_pedido_proveedor = matches.get(ref_data['referencia'].strip().upper())
|
||||||
|
|
||||||
|
referencia = ReferenciaAlbaran.objects.create(
|
||||||
|
albaran=albaran,
|
||||||
|
referencia=ref_data.get('referencia', '').strip(),
|
||||||
|
denominacion=ref_data.get('denominacion', '').strip(),
|
||||||
|
unidades=int(ref_data.get('unidades', 1)),
|
||||||
|
precio_unitario=float(ref_data.get('precio_unitario', 0)),
|
||||||
|
impuesto_tipo=ref_data.get('impuesto_tipo', '21'),
|
||||||
|
impuesto_valor=float(ref_data.get('impuesto_valor', 0)),
|
||||||
|
referencia_pedido_proveedor=ref_pedido_proveedor,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Actualizar pedido proveedor si hay match
|
||||||
|
if ref_pedido_proveedor:
|
||||||
|
ref_pedido_proveedor.unidades_recibidas += referencia.unidades
|
||||||
|
|
||||||
|
# Actualizar estado
|
||||||
|
if ref_pedido_proveedor.unidades_recibidas >= ref_pedido_proveedor.unidades_pedidas:
|
||||||
|
ref_pedido_proveedor.estado = 'recibido'
|
||||||
|
elif ref_pedido_proveedor.unidades_recibidas > 0:
|
||||||
|
ref_pedido_proveedor.estado = 'parcial'
|
||||||
|
|
||||||
|
ref_pedido_proveedor.save()
|
||||||
|
|
||||||
|
# Actualizar referencia pedido cliente
|
||||||
|
if ref_pedido_proveedor.referencia_pedido_cliente:
|
||||||
|
ref_cliente = ref_pedido_proveedor.referencia_pedido_cliente
|
||||||
|
ref_cliente.unidades_en_stock += referencia.unidades
|
||||||
|
ref_cliente.save()
|
||||||
|
|
||||||
|
return albaran
|
||||||
104
gestion_pedidos/services/email_parser.py
Normal file
104
gestion_pedidos/services/email_parser.py
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
"""
|
||||||
|
Parser para emails de confirmación de pedidos a proveedores
|
||||||
|
"""
|
||||||
|
import email
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
import re
|
||||||
|
|
||||||
|
|
||||||
|
class EmailPedidoParser:
|
||||||
|
"""Parser para extraer información de emails de confirmación de pedidos"""
|
||||||
|
|
||||||
|
def parse_email_file(self, email_path: Path) -> Dict:
|
||||||
|
"""
|
||||||
|
Parsea un archivo de email (.eml)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict con información del pedido
|
||||||
|
"""
|
||||||
|
with open(email_path, 'rb') as f:
|
||||||
|
msg = email.message_from_bytes(f.read())
|
||||||
|
|
||||||
|
# Extraer información básica
|
||||||
|
subject = msg.get('Subject', '')
|
||||||
|
from_addr = msg.get('From', '')
|
||||||
|
date = msg.get('Date', '')
|
||||||
|
|
||||||
|
# Extraer cuerpo del email
|
||||||
|
body = self._get_email_body(msg)
|
||||||
|
|
||||||
|
# Buscar número de pedido
|
||||||
|
numero_pedido = self._extract_numero_pedido(subject, body)
|
||||||
|
|
||||||
|
# Buscar referencias en el cuerpo
|
||||||
|
referencias = self._extract_referencias(body)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'numero_pedido': numero_pedido,
|
||||||
|
'proveedor_email': from_addr,
|
||||||
|
'fecha': date,
|
||||||
|
'asunto': subject,
|
||||||
|
'cuerpo': body,
|
||||||
|
'referencias': referencias,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _get_email_body(self, msg: email.message.Message) -> str:
|
||||||
|
"""Extrae el cuerpo del email"""
|
||||||
|
body = ""
|
||||||
|
|
||||||
|
if msg.is_multipart():
|
||||||
|
for part in msg.walk():
|
||||||
|
content_type = part.get_content_type()
|
||||||
|
if content_type == "text/plain":
|
||||||
|
try:
|
||||||
|
body = part.get_payload(decode=True).decode('utf-8', errors='ignore')
|
||||||
|
break
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
body = msg.get_payload(decode=True).decode('utf-8', errors='ignore')
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return body
|
||||||
|
|
||||||
|
def _extract_numero_pedido(self, subject: str, body: str) -> Optional[str]:
|
||||||
|
"""Extrae el número de pedido del asunto o cuerpo"""
|
||||||
|
# Patrones comunes
|
||||||
|
patterns = [
|
||||||
|
r'pedido[:\s]+([A-Z0-9\-]+)',
|
||||||
|
r'pedido[:\s]+#?(\d+)',
|
||||||
|
r'ref[:\s]+([A-Z0-9\-]+)',
|
||||||
|
r'order[:\s]+([A-Z0-9\-]+)',
|
||||||
|
]
|
||||||
|
|
||||||
|
text = f"{subject} {body}".lower()
|
||||||
|
|
||||||
|
for pattern in patterns:
|
||||||
|
match = re.search(pattern, text, re.IGNORECASE)
|
||||||
|
if match:
|
||||||
|
return match.group(1).strip()
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _extract_referencias(self, body: str) -> List[Dict]:
|
||||||
|
"""Extrae referencias del cuerpo del email"""
|
||||||
|
referencias = []
|
||||||
|
|
||||||
|
# Buscar líneas que parezcan referencias
|
||||||
|
# Formato común: REF123 - Descripción - Cantidad
|
||||||
|
pattern = r'([A-Z0-9\-]+)\s*[-–]\s*([^-\n]+?)\s*[-–]\s*(\d+)'
|
||||||
|
|
||||||
|
matches = re.finditer(pattern, body, re.IGNORECASE | re.MULTILINE)
|
||||||
|
|
||||||
|
for match in matches:
|
||||||
|
referencias.append({
|
||||||
|
'referencia': match.group(1).strip(),
|
||||||
|
'denominacion': match.group(2).strip(),
|
||||||
|
'unidades': int(match.group(3)),
|
||||||
|
})
|
||||||
|
|
||||||
|
return referencias
|
||||||
|
|
||||||
113
gestion_pedidos/services/file_watcher.py
Normal file
113
gestion_pedidos/services/file_watcher.py
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
"""
|
||||||
|
Servicio para monitorear carpetas y procesar nuevos archivos
|
||||||
|
"""
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from watchdog.observers import Observer
|
||||||
|
from watchdog.events import FileSystemEventHandler
|
||||||
|
from django.conf import settings
|
||||||
|
from .pdf_parser import PDFPedidoParser
|
||||||
|
from .albaran_processor import AlbaranProcessor
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class PDFPedidoHandler(FileSystemEventHandler):
|
||||||
|
"""Handler para procesar nuevos PDFs de pedidos de cliente"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.parser = PDFPedidoParser()
|
||||||
|
self.procesados = set()
|
||||||
|
|
||||||
|
def on_created(self, event):
|
||||||
|
if event.is_directory:
|
||||||
|
return
|
||||||
|
|
||||||
|
file_path = Path(event.src_path)
|
||||||
|
|
||||||
|
# Solo procesar PDFs
|
||||||
|
if file_path.suffix.lower() != '.pdf':
|
||||||
|
return
|
||||||
|
|
||||||
|
# Evitar procesar el mismo archivo múltiples veces
|
||||||
|
if str(file_path) in self.procesados:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Esperar un poco para asegurar que el archivo esté completamente escrito
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.info(f"Procesando nuevo PDF: {file_path}")
|
||||||
|
pedido = self.parser.create_pedido_from_pdf(file_path)
|
||||||
|
logger.info(f"Pedido creado exitosamente: {pedido.numero_pedido}")
|
||||||
|
self.procesados.add(str(file_path))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error al procesar PDF {file_path}: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
class AlbaranHandler(FileSystemEventHandler):
|
||||||
|
"""Handler para procesar nuevos albaranes"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.processor = AlbaranProcessor()
|
||||||
|
self.procesados = set()
|
||||||
|
|
||||||
|
def on_created(self, event):
|
||||||
|
if event.is_directory:
|
||||||
|
return
|
||||||
|
|
||||||
|
file_path = Path(event.src_path)
|
||||||
|
|
||||||
|
# Procesar imágenes y PDFs
|
||||||
|
if file_path.suffix.lower() not in ['.pdf', '.jpg', '.jpeg', '.png']:
|
||||||
|
return
|
||||||
|
|
||||||
|
if str(file_path) in self.procesados:
|
||||||
|
return
|
||||||
|
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.info(f"Procesando nuevo albarán: {file_path}")
|
||||||
|
albaran = self.processor.process_albaran_file(file_path)
|
||||||
|
logger.info(f"Albarán procesado: {albaran.id}")
|
||||||
|
self.procesados.add(str(file_path))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error al procesar albarán {file_path}: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
class FileWatcherService:
|
||||||
|
"""Servicio para monitorear carpetas"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.observer = Observer()
|
||||||
|
self.running = False
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
"""Inicia el monitoreo de carpetas"""
|
||||||
|
if self.running:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Monitorear carpeta de pedidos de cliente
|
||||||
|
pedidos_dir = settings.PEDIDOS_CLIENTES_PDF_DIR
|
||||||
|
pedidos_handler = PDFPedidoHandler()
|
||||||
|
self.observer.schedule(pedidos_handler, str(pedidos_dir), recursive=False)
|
||||||
|
|
||||||
|
# Monitorear carpeta de albaranes
|
||||||
|
albaranes_dir = settings.ALBARANES_ESCANEADOS_DIR
|
||||||
|
albaranes_handler = AlbaranHandler()
|
||||||
|
self.observer.schedule(albaranes_handler, str(albaranes_dir), recursive=False)
|
||||||
|
|
||||||
|
self.observer.start()
|
||||||
|
self.running = True
|
||||||
|
logger.info("File watcher iniciado")
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
"""Detiene el monitoreo"""
|
||||||
|
if self.running:
|
||||||
|
self.observer.stop()
|
||||||
|
self.observer.join()
|
||||||
|
self.running = False
|
||||||
|
logger.info("File watcher detenido")
|
||||||
|
|
||||||
196
gestion_pedidos/services/ocr_service.py
Normal file
196
gestion_pedidos/services/ocr_service.py
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
"""
|
||||||
|
Servicio de OCR usando GPT-4 Vision API
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
from openai import OpenAI
|
||||||
|
from django.conf import settings
|
||||||
|
from PIL import Image
|
||||||
|
import io
|
||||||
|
|
||||||
|
|
||||||
|
class OCRService:
|
||||||
|
"""Servicio para procesar imágenes y PDFs con GPT-4 Vision"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.client = OpenAI(api_key=settings.OPENAI_API_KEY)
|
||||||
|
|
||||||
|
def _encode_image(self, image_path: Path) -> str:
|
||||||
|
"""Codifica una imagen en base64"""
|
||||||
|
with open(image_path, "rb") as image_file:
|
||||||
|
return base64.b64encode(image_file.read()).decode('utf-8')
|
||||||
|
|
||||||
|
def _encode_pil_image(self, image: Image.Image) -> str:
|
||||||
|
"""Codifica una imagen PIL en base64"""
|
||||||
|
buffered = io.BytesIO()
|
||||||
|
image.save(buffered, format="PNG")
|
||||||
|
return base64.b64encode(buffered.getvalue()).decode('utf-8')
|
||||||
|
|
||||||
|
def process_pdf_pedido_cliente(self, pdf_path: Path) -> Dict:
|
||||||
|
"""
|
||||||
|
Procesa un PDF de pedido de cliente y extrae la información
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict con: numero_pedido, matricula, fecha_cita, referencias (lista)
|
||||||
|
"""
|
||||||
|
from pdf2image import convert_from_path
|
||||||
|
|
||||||
|
# Convertir PDF a imágenes
|
||||||
|
images = convert_from_path(pdf_path, dpi=200)
|
||||||
|
|
||||||
|
if not images:
|
||||||
|
raise ValueError("No se pudieron extraer imágenes del PDF")
|
||||||
|
|
||||||
|
# Procesar la primera página (o todas si es necesario)
|
||||||
|
image = images[0]
|
||||||
|
base64_image = self._encode_pil_image(image)
|
||||||
|
|
||||||
|
prompt = """
|
||||||
|
Analiza este documento de pedido de cliente de recambios. Extrae la siguiente información en formato JSON:
|
||||||
|
|
||||||
|
{
|
||||||
|
"numero_pedido": "número único del pedido",
|
||||||
|
"matricula": "matrícula del vehículo",
|
||||||
|
"fecha_cita": "fecha de la cita en formato YYYY-MM-DD o YYYY-MM-DD HH:MM",
|
||||||
|
"referencias": [
|
||||||
|
{
|
||||||
|
"referencia": "código de la referencia",
|
||||||
|
"denominacion": "descripción/nombre de la pieza",
|
||||||
|
"unidades": número de unidades
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
Si algún campo no está disponible, usa null. La fecha debe estar en formato ISO si es posible.
|
||||||
|
"""
|
||||||
|
|
||||||
|
response = self.client.chat.completions.create(
|
||||||
|
model="gpt-4o",
|
||||||
|
messages=[
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": [
|
||||||
|
{"type": "text", "text": prompt},
|
||||||
|
{
|
||||||
|
"type": "image_url",
|
||||||
|
"image_url": {
|
||||||
|
"url": f"data:image/png;base64,{base64_image}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
max_tokens=2000
|
||||||
|
)
|
||||||
|
|
||||||
|
content = response.choices[0].message.content
|
||||||
|
|
||||||
|
# Extraer JSON de la respuesta
|
||||||
|
try:
|
||||||
|
# Buscar JSON en la respuesta
|
||||||
|
json_start = content.find('{')
|
||||||
|
json_end = content.rfind('}') + 1
|
||||||
|
if json_start != -1 and json_end > json_start:
|
||||||
|
json_str = content[json_start:json_end]
|
||||||
|
return json.loads(json_str)
|
||||||
|
else:
|
||||||
|
raise ValueError("No se encontró JSON en la respuesta")
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
raise ValueError(f"Error al parsear JSON: {e}. Respuesta: {content}")
|
||||||
|
|
||||||
|
def process_albaran(self, image_path: Path) -> Dict:
|
||||||
|
"""
|
||||||
|
Procesa un albarán y extrae la información
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict con: proveedor (nombre o número), numero_albaran, fecha_albaran,
|
||||||
|
referencias (lista con precios e impuestos)
|
||||||
|
"""
|
||||||
|
base64_image = self._encode_image(image_path)
|
||||||
|
|
||||||
|
prompt = """
|
||||||
|
Analiza este albarán de proveedor. Extrae la siguiente información en formato JSON:
|
||||||
|
|
||||||
|
{
|
||||||
|
"proveedor": {
|
||||||
|
"nombre": "nombre del proveedor",
|
||||||
|
"numero": "número de proveedor si está visible"
|
||||||
|
},
|
||||||
|
"numero_albaran": "número del albarán",
|
||||||
|
"fecha_albaran": "fecha del albarán en formato YYYY-MM-DD",
|
||||||
|
"referencias": [
|
||||||
|
{
|
||||||
|
"referencia": "código de la referencia",
|
||||||
|
"denominacion": "descripción de la pieza",
|
||||||
|
"unidades": número de unidades,
|
||||||
|
"precio_unitario": precio por unidad (número decimal),
|
||||||
|
"impuesto_tipo": "21", "10", "7", "4", "3" o "0" según el porcentaje de IVA,
|
||||||
|
"impuesto_valor": valor del impuesto (número decimal)
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"totales": {
|
||||||
|
"base_imponible": total base imponible,
|
||||||
|
"iva_21": total IVA al 21%,
|
||||||
|
"iva_10": total IVA al 10%,
|
||||||
|
"iva_7": total IVA al 7%,
|
||||||
|
"iva_4": total IVA al 4%,
|
||||||
|
"iva_3": total IVA al 3%,
|
||||||
|
"total": total general
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IMPORTANTE:
|
||||||
|
- Si hay múltiples tipos de impuestos, identifica qué referencias tienen cada tipo
|
||||||
|
- Si el impuesto no está claro por referencia, intenta calcularlo basándote en los totales
|
||||||
|
- Si algún campo no está disponible, usa null
|
||||||
|
"""
|
||||||
|
|
||||||
|
response = self.client.chat.completions.create(
|
||||||
|
model="gpt-4o",
|
||||||
|
messages=[
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": [
|
||||||
|
{"type": "text", "text": prompt},
|
||||||
|
{
|
||||||
|
"type": "image_url",
|
||||||
|
"image_url": {
|
||||||
|
"url": f"data:image/png;base64,{base64_image}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
max_tokens=3000
|
||||||
|
)
|
||||||
|
|
||||||
|
content = response.choices[0].message.content
|
||||||
|
|
||||||
|
try:
|
||||||
|
json_start = content.find('{')
|
||||||
|
json_end = content.rfind('}') + 1
|
||||||
|
if json_start != -1 and json_end > json_start:
|
||||||
|
json_str = content[json_start:json_end]
|
||||||
|
return json.loads(json_str)
|
||||||
|
else:
|
||||||
|
raise ValueError("No se encontró JSON en la respuesta")
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
raise ValueError(f"Error al parsear JSON: {e}. Respuesta: {content}")
|
||||||
|
|
||||||
|
def process_image(self, image_path: Path, tipo: str = 'albaran') -> Dict:
|
||||||
|
"""
|
||||||
|
Procesa una imagen (albarán o pedido)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
image_path: Ruta a la imagen
|
||||||
|
tipo: 'albaran' o 'pedido_cliente'
|
||||||
|
"""
|
||||||
|
if tipo == 'albaran':
|
||||||
|
return self.process_albaran(image_path)
|
||||||
|
elif tipo == 'pedido_cliente':
|
||||||
|
return self.process_pdf_pedido_cliente(image_path)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Tipo no soportado: {tipo}")
|
||||||
|
|
||||||
95
gestion_pedidos/services/pdf_parser.py
Normal file
95
gestion_pedidos/services/pdf_parser.py
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
"""
|
||||||
|
Servicio para procesar PDFs de pedidos de cliente
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, Optional
|
||||||
|
from django.utils import timezone
|
||||||
|
from datetime import datetime
|
||||||
|
from .ocr_service import OCRService
|
||||||
|
from ..models import Cliente, PedidoCliente, ReferenciaPedidoCliente
|
||||||
|
|
||||||
|
|
||||||
|
class PDFPedidoParser:
|
||||||
|
"""Parser para procesar PDFs de pedidos de cliente"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.ocr_service = OCRService()
|
||||||
|
|
||||||
|
def parse_pdf(self, pdf_path: Path) -> Dict:
|
||||||
|
"""
|
||||||
|
Parsea un PDF de pedido de cliente
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict con los datos extraídos
|
||||||
|
"""
|
||||||
|
return self.ocr_service.process_pdf_pedido_cliente(pdf_path)
|
||||||
|
|
||||||
|
def create_pedido_from_pdf(self, pdf_path: Path) -> PedidoCliente:
|
||||||
|
"""
|
||||||
|
Crea un PedidoCliente y sus referencias desde un PDF
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
PedidoCliente creado
|
||||||
|
"""
|
||||||
|
# Parsear PDF
|
||||||
|
datos = self.parse_pdf(pdf_path)
|
||||||
|
|
||||||
|
# Obtener o crear cliente
|
||||||
|
matricula = datos.get('matricula', '').strip().upper()
|
||||||
|
if not matricula:
|
||||||
|
raise ValueError("No se pudo extraer la matrícula del PDF")
|
||||||
|
|
||||||
|
cliente, _ = Cliente.objects.get_or_create(
|
||||||
|
matricula_vehiculo=matricula,
|
||||||
|
defaults={
|
||||||
|
'nombre': datos.get('nombre_cliente', f'Cliente {matricula}'),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Parsear fecha de cita
|
||||||
|
fecha_cita = None
|
||||||
|
fecha_cita_str = datos.get('fecha_cita')
|
||||||
|
if fecha_cita_str:
|
||||||
|
try:
|
||||||
|
# Intentar diferentes formatos
|
||||||
|
for fmt in ['%Y-%m-%d %H:%M', '%Y-%m-%d', '%d/%m/%Y %H:%M', '%d/%m/%Y']:
|
||||||
|
try:
|
||||||
|
fecha_cita = datetime.strptime(fecha_cita_str, fmt)
|
||||||
|
fecha_cita = timezone.make_aware(fecha_cita)
|
||||||
|
break
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Crear pedido
|
||||||
|
numero_pedido = datos.get('numero_pedido', '').strip()
|
||||||
|
if not numero_pedido:
|
||||||
|
raise ValueError("No se pudo extraer el número de pedido del PDF")
|
||||||
|
|
||||||
|
# Verificar si ya existe
|
||||||
|
if PedidoCliente.objects.filter(numero_pedido=numero_pedido).exists():
|
||||||
|
raise ValueError(f"El pedido {numero_pedido} ya existe")
|
||||||
|
|
||||||
|
pedido = PedidoCliente.objects.create(
|
||||||
|
numero_pedido=numero_pedido,
|
||||||
|
cliente=cliente,
|
||||||
|
fecha_cita=fecha_cita,
|
||||||
|
estado='pendiente_revision',
|
||||||
|
archivo_pdf_path=str(pdf_path),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Crear referencias
|
||||||
|
referencias_data = datos.get('referencias', [])
|
||||||
|
for ref_data in referencias_data:
|
||||||
|
ReferenciaPedidoCliente.objects.create(
|
||||||
|
pedido_cliente=pedido,
|
||||||
|
referencia=ref_data.get('referencia', '').strip(),
|
||||||
|
denominacion=ref_data.get('denominacion', '').strip(),
|
||||||
|
unidades_solicitadas=int(ref_data.get('unidades', 1)),
|
||||||
|
unidades_en_stock=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
return pedido
|
||||||
|
|
||||||
21
gestion_pedidos/urls.py
Normal file
21
gestion_pedidos/urls.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
from django.urls import path, include
|
||||||
|
from rest_framework.routers import DefaultRouter
|
||||||
|
from . import views
|
||||||
|
|
||||||
|
router = DefaultRouter()
|
||||||
|
router.register(r'clientes', views.ClienteViewSet)
|
||||||
|
router.register(r'pedidos-cliente', views.PedidoClienteViewSet, basename='pedido-cliente')
|
||||||
|
router.register(r'referencias-pedido-cliente', views.ReferenciaPedidoClienteViewSet, basename='referencia-pedido-cliente')
|
||||||
|
router.register(r'proveedores', views.ProveedorViewSet)
|
||||||
|
router.register(r'pedidos-proveedor', views.PedidoProveedorViewSet, basename='pedido-proveedor')
|
||||||
|
router.register(r'albaranes', views.AlbaranViewSet, basename='albaran')
|
||||||
|
router.register(r'devoluciones', views.DevolucionViewSet)
|
||||||
|
router.register(r'stock', views.StockReferenciaViewSet)
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('', include(router.urls)),
|
||||||
|
path('kanban/', views.KanbanView.as_view(), name='kanban'),
|
||||||
|
path('referencias-proveedor/', views.ReferenciasProveedorView.as_view(), name='referencias-proveedor'),
|
||||||
|
path('alertas/', views.AlertasView.as_view(), name='alertas'),
|
||||||
|
path('mobile/upload/', views.AlbaranViewSet.as_view({'post': 'upload'}), name='mobile-upload'),
|
||||||
|
]
|
||||||
367
gestion_pedidos/views.py
Normal file
367
gestion_pedidos/views.py
Normal file
@@ -0,0 +1,367 @@
|
|||||||
|
from rest_framework import viewsets, status
|
||||||
|
from rest_framework.decorators import action
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
from django.db.models import Q, Prefetch
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.shortcuts import render
|
||||||
|
from datetime import timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
import json
|
||||||
|
|
||||||
|
from .models import (
|
||||||
|
Cliente, PedidoCliente, ReferenciaPedidoCliente,
|
||||||
|
Proveedor, PedidoProveedor, ReferenciaPedidoProveedor,
|
||||||
|
Albaran, ReferenciaAlbaran, Devolucion, StockReferencia
|
||||||
|
)
|
||||||
|
from .serializers import (
|
||||||
|
ClienteSerializer, PedidoClienteSerializer, ReferenciaPedidoClienteSerializer,
|
||||||
|
ProveedorSerializer, PedidoProveedorSerializer, ReferenciaPedidoProveedorSerializer,
|
||||||
|
AlbaranSerializer, ReferenciaAlbaranSerializer, DevolucionSerializer,
|
||||||
|
StockReferenciaSerializer, UpdateStockSerializer, BulkUpdateStockSerializer,
|
||||||
|
CrearPedidoProveedorSerializer
|
||||||
|
)
|
||||||
|
from .services.albaran_processor import AlbaranProcessor
|
||||||
|
from .services.pdf_parser import PDFPedidoParser
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
|
||||||
|
class ClienteViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = Cliente.objects.all()
|
||||||
|
serializer_class = ClienteSerializer
|
||||||
|
filterset_fields = ['nombre', 'matricula_vehiculo']
|
||||||
|
|
||||||
|
|
||||||
|
class PedidoClienteViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = PedidoCliente.objects.select_related('cliente').prefetch_related('referencias').all()
|
||||||
|
serializer_class = PedidoClienteSerializer
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
queryset = super().get_queryset()
|
||||||
|
|
||||||
|
# Filtros
|
||||||
|
estado = self.request.query_params.get('estado')
|
||||||
|
if estado:
|
||||||
|
queryset = queryset.filter(estado=estado)
|
||||||
|
|
||||||
|
urgente = self.request.query_params.get('urgente')
|
||||||
|
if urgente == 'true':
|
||||||
|
queryset = [p for p in queryset if p.es_urgente]
|
||||||
|
|
||||||
|
matricula = self.request.query_params.get('matricula')
|
||||||
|
if matricula:
|
||||||
|
queryset = queryset.filter(cliente__matricula_vehiculo__icontains=matricula)
|
||||||
|
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
@action(detail=True, methods=['post'])
|
||||||
|
def actualizar_estado(self, request, pk=None):
|
||||||
|
"""Actualiza el estado del pedido"""
|
||||||
|
pedido = self.get_object()
|
||||||
|
nuevo_estado = request.data.get('estado')
|
||||||
|
|
||||||
|
if nuevo_estado in dict(PedidoCliente.ESTADOS):
|
||||||
|
pedido.estado = nuevo_estado
|
||||||
|
pedido.save()
|
||||||
|
return Response({'status': 'Estado actualizado'})
|
||||||
|
return Response({'error': 'Estado inválido'}, status=400)
|
||||||
|
|
||||||
|
|
||||||
|
class ReferenciaPedidoClienteViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = ReferenciaPedidoCliente.objects.all()
|
||||||
|
serializer_class = ReferenciaPedidoClienteSerializer
|
||||||
|
|
||||||
|
@action(detail=True, methods=['post'])
|
||||||
|
def marcar_stock(self, request, pk=None):
|
||||||
|
"""Marca unidades en stock para una referencia"""
|
||||||
|
referencia = self.get_object()
|
||||||
|
unidades = request.data.get('unidades_en_stock', 0)
|
||||||
|
|
||||||
|
referencia.unidades_en_stock = max(0, unidades)
|
||||||
|
referencia.save()
|
||||||
|
|
||||||
|
# Actualizar estado del pedido si es necesario
|
||||||
|
pedido = referencia.pedido_cliente
|
||||||
|
todas_completas = all(
|
||||||
|
ref.unidades_pendientes == 0
|
||||||
|
for ref in pedido.referencias.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
if todas_completas and pedido.estado != 'completado':
|
||||||
|
pedido.estado = 'completado'
|
||||||
|
pedido.save()
|
||||||
|
elif pedido.estado == 'pendiente_revision':
|
||||||
|
pedido.estado = 'en_revision'
|
||||||
|
pedido.save()
|
||||||
|
|
||||||
|
return Response(ReferenciaPedidoClienteSerializer(referencia).data)
|
||||||
|
|
||||||
|
|
||||||
|
class ProveedorViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = Proveedor.objects.filter(activo=True)
|
||||||
|
serializer_class = ProveedorSerializer
|
||||||
|
|
||||||
|
|
||||||
|
class PedidoProveedorViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = PedidoProveedor.objects.select_related('proveedor').prefetch_related('referencias').all()
|
||||||
|
serializer_class = PedidoProveedorSerializer
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
queryset = super().get_queryset()
|
||||||
|
proveedor_id = self.request.query_params.get('proveedor_id')
|
||||||
|
if proveedor_id:
|
||||||
|
queryset = queryset.filter(proveedor_id=proveedor_id)
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
@action(detail=False, methods=['post'])
|
||||||
|
def crear_manual(self, request):
|
||||||
|
"""Crea un pedido a proveedor manualmente"""
|
||||||
|
serializer = CrearPedidoProveedorSerializer(data=request.data)
|
||||||
|
if not serializer.is_valid():
|
||||||
|
return Response(serializer.errors, status=400)
|
||||||
|
|
||||||
|
data = serializer.validated_data
|
||||||
|
proveedor = Proveedor.objects.get(id=data['proveedor_id'])
|
||||||
|
|
||||||
|
pedido = PedidoProveedor.objects.create(
|
||||||
|
proveedor=proveedor,
|
||||||
|
numero_pedido=data.get('numero_pedido', ''),
|
||||||
|
tipo=data['tipo'],
|
||||||
|
estado='pendiente_recepcion'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Crear referencias
|
||||||
|
for ref_data in data['referencias']:
|
||||||
|
ref_pedido_cliente_id = ref_data.get('referencia_pedido_cliente_id')
|
||||||
|
ref_pedido_cliente = None
|
||||||
|
if ref_pedido_cliente_id:
|
||||||
|
try:
|
||||||
|
ref_pedido_cliente = ReferenciaPedidoCliente.objects.get(id=ref_pedido_cliente_id)
|
||||||
|
except ReferenciaPedidoCliente.DoesNotExist:
|
||||||
|
pass
|
||||||
|
|
||||||
|
ReferenciaPedidoProveedor.objects.create(
|
||||||
|
pedido_proveedor=pedido,
|
||||||
|
referencia_pedido_cliente=ref_pedido_cliente,
|
||||||
|
referencia=ref_data['referencia'],
|
||||||
|
denominacion=ref_data.get('denominacion', ''),
|
||||||
|
unidades_pedidas=int(ref_data['unidades']),
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response(PedidoProveedorSerializer(pedido).data, status=201)
|
||||||
|
|
||||||
|
|
||||||
|
class AlbaranViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = Albaran.objects.select_related('proveedor').prefetch_related('referencias').all()
|
||||||
|
serializer_class = AlbaranSerializer
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
queryset = super().get_queryset()
|
||||||
|
estado = self.request.query_params.get('estado_procesado')
|
||||||
|
if estado:
|
||||||
|
queryset = queryset.filter(estado_procesado=estado)
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
@action(detail=False, methods=['post'])
|
||||||
|
def upload(self, request):
|
||||||
|
"""Sube un albarán desde móvil o web"""
|
||||||
|
if 'archivo' not in request.FILES:
|
||||||
|
return Response({'error': 'No se proporcionó archivo'}, status=400)
|
||||||
|
|
||||||
|
archivo = request.FILES['archivo']
|
||||||
|
|
||||||
|
# Guardar archivo
|
||||||
|
upload_dir = settings.ALBARANES_ESCANEADOS_DIR
|
||||||
|
upload_dir.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
file_path = upload_dir / archivo.name
|
||||||
|
with open(file_path, 'wb+') as destination:
|
||||||
|
for chunk in archivo.chunks():
|
||||||
|
destination.write(chunk)
|
||||||
|
|
||||||
|
# Procesar
|
||||||
|
try:
|
||||||
|
processor = AlbaranProcessor()
|
||||||
|
albaran = processor.process_albaran_file(file_path)
|
||||||
|
return Response(AlbaranSerializer(albaran).data, status=201)
|
||||||
|
except Exception as e:
|
||||||
|
return Response({'error': str(e)}, status=400)
|
||||||
|
|
||||||
|
@action(detail=True, methods=['post'])
|
||||||
|
def vincular_proveedor(self, request, pk=None):
|
||||||
|
"""Vincula un albarán a un proveedor manualmente"""
|
||||||
|
albaran = self.get_object()
|
||||||
|
proveedor_id = request.data.get('proveedor_id')
|
||||||
|
|
||||||
|
if not proveedor_id:
|
||||||
|
return Response({'error': 'proveedor_id requerido'}, status=400)
|
||||||
|
|
||||||
|
try:
|
||||||
|
proveedor = Proveedor.objects.get(id=proveedor_id)
|
||||||
|
albaran.proveedor = proveedor
|
||||||
|
albaran.estado_procesado = 'procesado'
|
||||||
|
albaran.save()
|
||||||
|
|
||||||
|
# Reprocesar para vincular referencias
|
||||||
|
processor = AlbaranProcessor()
|
||||||
|
processor._match_and_update_referencias(albaran)
|
||||||
|
|
||||||
|
return Response(AlbaranSerializer(albaran).data)
|
||||||
|
except Proveedor.DoesNotExist:
|
||||||
|
return Response({'error': 'Proveedor no encontrado'}, status=404)
|
||||||
|
|
||||||
|
|
||||||
|
class DevolucionViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = Devolucion.objects.select_related('proveedor').all()
|
||||||
|
serializer_class = DevolucionSerializer
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
queryset = super().get_queryset()
|
||||||
|
estado = self.request.query_params.get('estado_abono')
|
||||||
|
if estado:
|
||||||
|
queryset = queryset.filter(estado_abono=estado)
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
@action(detail=True, methods=['post'])
|
||||||
|
def vincular_abono(self, request, pk=None):
|
||||||
|
"""Vincula una devolución con un albarán de abono"""
|
||||||
|
devolucion = self.get_object()
|
||||||
|
albaran_id = request.data.get('albaran_id')
|
||||||
|
|
||||||
|
if not albaran_id:
|
||||||
|
return Response({'error': 'albaran_id requerido'}, status=400)
|
||||||
|
|
||||||
|
try:
|
||||||
|
albaran = Albaran.objects.get(id=albaran_id)
|
||||||
|
devolucion.albaran_abono = albaran
|
||||||
|
devolucion.estado_abono = 'abonado'
|
||||||
|
devolucion.save()
|
||||||
|
|
||||||
|
return Response(DevolucionSerializer(devolucion).data)
|
||||||
|
except Albaran.DoesNotExist:
|
||||||
|
return Response({'error': 'Albarán no encontrado'}, status=404)
|
||||||
|
|
||||||
|
|
||||||
|
class StockReferenciaViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = StockReferencia.objects.all()
|
||||||
|
serializer_class = StockReferenciaSerializer
|
||||||
|
|
||||||
|
|
||||||
|
# Vista para Kanban
|
||||||
|
class KanbanView(APIView):
|
||||||
|
"""Vista para obtener datos del Kanban"""
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
pedidos = PedidoCliente.objects.select_related('cliente').prefetch_related(
|
||||||
|
Prefetch('referencias', queryset=ReferenciaPedidoCliente.objects.all())
|
||||||
|
).all()
|
||||||
|
|
||||||
|
# Agrupar por estado
|
||||||
|
kanban_data = {
|
||||||
|
'pendiente_revision': [],
|
||||||
|
'en_revision': [],
|
||||||
|
'pendiente_materiales': [],
|
||||||
|
'completado': [],
|
||||||
|
}
|
||||||
|
|
||||||
|
for pedido in pedidos:
|
||||||
|
pedido_data = PedidoClienteSerializer(pedido).data
|
||||||
|
kanban_data[pedido.estado].append(pedido_data)
|
||||||
|
|
||||||
|
# Si es request HTML, renderizar template
|
||||||
|
if request.accepted_renderer.format == 'html' or 'text/html' in request.META.get('HTTP_ACCEPT', ''):
|
||||||
|
return render(request, 'kanban.html')
|
||||||
|
|
||||||
|
return Response(kanban_data)
|
||||||
|
|
||||||
|
|
||||||
|
# Vista para referencias pendientes por proveedor
|
||||||
|
class ReferenciasProveedorView(APIView):
|
||||||
|
"""Vista para ver referencias pendientes por proveedor"""
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
proveedor_id = request.query_params.get('proveedor_id')
|
||||||
|
|
||||||
|
# Obtener referencias pendientes de pedidos a proveedor
|
||||||
|
queryset = ReferenciaPedidoProveedor.objects.filter(
|
||||||
|
estado__in=['pendiente', 'parcial']
|
||||||
|
).select_related(
|
||||||
|
'pedido_proveedor__proveedor',
|
||||||
|
'referencia_pedido_cliente__pedido_cliente__cliente'
|
||||||
|
)
|
||||||
|
|
||||||
|
if proveedor_id:
|
||||||
|
queryset = queryset.filter(pedido_proveedor__proveedor_id=proveedor_id)
|
||||||
|
|
||||||
|
# Agrupar por proveedor
|
||||||
|
proveedores_data = {}
|
||||||
|
for ref in queryset:
|
||||||
|
proveedor = ref.pedido_proveedor.proveedor
|
||||||
|
if proveedor.id not in proveedores_data:
|
||||||
|
proveedores_data[proveedor.id] = {
|
||||||
|
'proveedor': ProveedorSerializer(proveedor).data,
|
||||||
|
'referencias_pendientes': [],
|
||||||
|
'referencias_devolucion': [],
|
||||||
|
}
|
||||||
|
|
||||||
|
proveedores_data[proveedor.id]['referencias_pendientes'].append(
|
||||||
|
ReferenciaPedidoProveedorSerializer(ref).data
|
||||||
|
)
|
||||||
|
|
||||||
|
# Agregar devoluciones pendientes
|
||||||
|
devoluciones = Devolucion.objects.filter(estado_abono='pendiente')
|
||||||
|
if proveedor_id:
|
||||||
|
devoluciones = devoluciones.filter(proveedor_id=proveedor_id)
|
||||||
|
|
||||||
|
for dev in devoluciones:
|
||||||
|
if dev.proveedor.id not in proveedores_data:
|
||||||
|
proveedores_data[dev.proveedor.id] = {
|
||||||
|
'proveedor': ProveedorSerializer(dev.proveedor).data,
|
||||||
|
'referencias_pendientes': [],
|
||||||
|
'referencias_devolucion': [],
|
||||||
|
}
|
||||||
|
|
||||||
|
proveedores_data[dev.proveedor.id]['referencias_devolucion'].append(
|
||||||
|
DevolucionSerializer(dev).data
|
||||||
|
)
|
||||||
|
|
||||||
|
# Si es request HTML, renderizar template
|
||||||
|
if request.accepted_renderer.format == 'html' or 'text/html' in request.META.get('HTTP_ACCEPT', ''):
|
||||||
|
return render(request, 'proveedores.html', {
|
||||||
|
'proveedores_data': list(proveedores_data.values())
|
||||||
|
})
|
||||||
|
|
||||||
|
return Response(list(proveedores_data.values()))
|
||||||
|
|
||||||
|
|
||||||
|
# Vista para alertas
|
||||||
|
class AlertasView(APIView):
|
||||||
|
"""Vista para obtener alertas (pedidos urgentes)"""
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
ahora = timezone.now()
|
||||||
|
limite = ahora + timedelta(hours=12)
|
||||||
|
|
||||||
|
pedidos_urgentes = PedidoCliente.objects.filter(
|
||||||
|
fecha_cita__gte=ahora,
|
||||||
|
fecha_cita__lte=limite,
|
||||||
|
estado__in=['pendiente_revision', 'en_revision', 'pendiente_materiales']
|
||||||
|
).select_related('cliente').prefetch_related('referencias')
|
||||||
|
|
||||||
|
alertas = []
|
||||||
|
for pedido in pedidos_urgentes:
|
||||||
|
referencias_faltantes = [
|
||||||
|
ref for ref in pedido.referencias.all()
|
||||||
|
if ref.unidades_pendientes > 0
|
||||||
|
]
|
||||||
|
|
||||||
|
if referencias_faltantes:
|
||||||
|
alertas.append({
|
||||||
|
'pedido': PedidoClienteSerializer(pedido).data,
|
||||||
|
'referencias_faltantes': ReferenciaPedidoClienteSerializer(
|
||||||
|
referencias_faltantes, many=True
|
||||||
|
).data,
|
||||||
|
'horas_restantes': (pedido.fecha_cita - ahora).total_seconds() / 3600,
|
||||||
|
})
|
||||||
|
|
||||||
|
return Response(alertas)
|
||||||
23
manage.py
Normal file
23
manage.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
"""Django's command-line utility for administrative tasks."""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Run administrative tasks."""
|
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'pedidos_clientes.settings')
|
||||||
|
try:
|
||||||
|
from django.core.management import execute_from_command_line
|
||||||
|
except ImportError as exc:
|
||||||
|
raise ImportError(
|
||||||
|
"Couldn't import Django. Are you sure it's installed and "
|
||||||
|
"available on your PYTHONPATH environment variable? Did you "
|
||||||
|
"forget to activate a virtual environment?"
|
||||||
|
) from exc
|
||||||
|
execute_from_command_line(sys.argv)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
|
|
||||||
18
package.json
Normal file
18
package.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "pedidos-clientes-ayutec",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Sistema de gestión de pedidos con Prisma",
|
||||||
|
"scripts": {
|
||||||
|
"prisma:generate": "prisma generate",
|
||||||
|
"prisma:migrate": "prisma migrate dev",
|
||||||
|
"prisma:studio": "prisma studio",
|
||||||
|
"prisma:push": "prisma db push"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"prisma": "^5.7.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@prisma/client": "^5.7.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
0
pedidos_clientes/__init__.py
Normal file
0
pedidos_clientes/__init__.py
Normal file
17
pedidos_clientes/asgi.py
Normal file
17
pedidos_clientes/asgi.py
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
"""
|
||||||
|
ASGI config for pedidos_clientes project.
|
||||||
|
|
||||||
|
It exposes the ASGI callable as a module-level variable named ``application``.
|
||||||
|
|
||||||
|
For more information on this file, see
|
||||||
|
https://docs.djangoproject.com/en/4.2/howto/deployment/asgi/
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from django.core.asgi import get_asgi_application
|
||||||
|
|
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'pedidos_clientes.settings')
|
||||||
|
|
||||||
|
application = get_asgi_application()
|
||||||
|
|
||||||
199
pedidos_clientes/settings.py
Normal file
199
pedidos_clientes/settings.py
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
"""
|
||||||
|
Django settings for pedidos_clientes project.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
import os
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
|
||||||
|
# Quick-start development settings - unsuitable for production
|
||||||
|
# See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/
|
||||||
|
|
||||||
|
# SECURITY WARNING: keep the secret key used in production secret!
|
||||||
|
SECRET_KEY = os.getenv('SECRET_KEY', 'django-insecure-change-this-in-production')
|
||||||
|
|
||||||
|
# SECURITY WARNING: don't run with debug turned on in production!
|
||||||
|
DEBUG = True
|
||||||
|
|
||||||
|
ALLOWED_HOSTS = ['*']
|
||||||
|
|
||||||
|
|
||||||
|
# Application definition
|
||||||
|
|
||||||
|
INSTALLED_APPS = [
|
||||||
|
'django.contrib.admin',
|
||||||
|
'django.contrib.auth',
|
||||||
|
'django.contrib.contenttypes',
|
||||||
|
'django.contrib.sessions',
|
||||||
|
'django.contrib.messages',
|
||||||
|
'django.contrib.staticfiles',
|
||||||
|
'rest_framework',
|
||||||
|
'corsheaders',
|
||||||
|
'gestion_pedidos',
|
||||||
|
]
|
||||||
|
|
||||||
|
MIDDLEWARE = [
|
||||||
|
'django.middleware.security.SecurityMiddleware',
|
||||||
|
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||||
|
'corsheaders.middleware.CorsMiddleware',
|
||||||
|
'django.middleware.common.CommonMiddleware',
|
||||||
|
'django.middleware.csrf.CsrfViewMiddleware',
|
||||||
|
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||||
|
'django.contrib.messages.middleware.MessageMiddleware',
|
||||||
|
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||||
|
]
|
||||||
|
|
||||||
|
ROOT_URLCONF = 'pedidos_clientes.urls'
|
||||||
|
|
||||||
|
TEMPLATES = [
|
||||||
|
{
|
||||||
|
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||||
|
'DIRS': [BASE_DIR / 'templates'],
|
||||||
|
'APP_DIRS': True,
|
||||||
|
'OPTIONS': {
|
||||||
|
'context_processors': [
|
||||||
|
'django.template.context_processors.debug',
|
||||||
|
'django.template.context_processors.request',
|
||||||
|
'django.contrib.auth.context_processors.auth',
|
||||||
|
'django.contrib.messages.context_processors.messages',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
WSGI_APPLICATION = 'pedidos_clientes.wsgi.application'
|
||||||
|
|
||||||
|
|
||||||
|
# Database
|
||||||
|
# https://docs.djangoproject.com/en/4.2/ref/settings/#databases
|
||||||
|
|
||||||
|
DATABASES = {
|
||||||
|
'default': {
|
||||||
|
'ENGINE': 'django.db.backends.postgresql',
|
||||||
|
'NAME': os.getenv('DB_NAME', 'pedidos_clientes'),
|
||||||
|
'USER': os.getenv('DB_USER', 'postgres'),
|
||||||
|
'PASSWORD': os.getenv('DB_PASSWORD', 'postgres'),
|
||||||
|
'HOST': os.getenv('DB_HOST', 'localhost'),
|
||||||
|
'PORT': os.getenv('DB_PORT', '5432'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Password validation
|
||||||
|
# https://docs.djangoproject.com/en/4.2/ref/settings/#auth-password-validators
|
||||||
|
|
||||||
|
AUTH_PASSWORD_VALIDATORS = [
|
||||||
|
{
|
||||||
|
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# Internationalization
|
||||||
|
# https://docs.djangoproject.com/en/4.2/topics/i18n/
|
||||||
|
|
||||||
|
LANGUAGE_CODE = 'es-es'
|
||||||
|
|
||||||
|
TIME_ZONE = 'Europe/Madrid'
|
||||||
|
|
||||||
|
USE_I18N = True
|
||||||
|
|
||||||
|
USE_TZ = True
|
||||||
|
|
||||||
|
|
||||||
|
# Static files (CSS, JavaScript, Images)
|
||||||
|
# https://docs.djangoproject.com/en/4.2/howto/static-files/
|
||||||
|
|
||||||
|
STATIC_URL = 'static/'
|
||||||
|
STATIC_ROOT = BASE_DIR / 'staticfiles'
|
||||||
|
STATICFILES_DIRS = [BASE_DIR / 'static']
|
||||||
|
|
||||||
|
# Media files
|
||||||
|
MEDIA_URL = 'media/'
|
||||||
|
MEDIA_ROOT = BASE_DIR / 'media'
|
||||||
|
|
||||||
|
# Default primary key field type
|
||||||
|
# https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field
|
||||||
|
|
||||||
|
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||||
|
|
||||||
|
# REST Framework
|
||||||
|
REST_FRAMEWORK = {
|
||||||
|
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
|
||||||
|
'PAGE_SIZE': 100,
|
||||||
|
'DEFAULT_RENDERER_CLASSES': [
|
||||||
|
'rest_framework.renderers.JSONRenderer',
|
||||||
|
'rest_framework.renderers.BrowsableAPIRenderer',
|
||||||
|
],
|
||||||
|
'DEFAULT_PARSER_CLASSES': [
|
||||||
|
'rest_framework.parsers.JSONParser',
|
||||||
|
'rest_framework.parsers.MultiPartParser',
|
||||||
|
'rest_framework.parsers.FormParser',
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
# CORS
|
||||||
|
CORS_ALLOW_ALL_ORIGINS = True # Cambiar en producción
|
||||||
|
|
||||||
|
# OpenAI Configuration
|
||||||
|
OPENAI_API_KEY = os.getenv('OPENAI_API_KEY', '')
|
||||||
|
|
||||||
|
# Carpetas para archivos
|
||||||
|
PEDIDOS_CLIENTES_PDF_DIR = BASE_DIR / 'pedidos_clientes_pdf'
|
||||||
|
ALBARANES_ESCANEADOS_DIR = BASE_DIR / 'albaranes_escaneados'
|
||||||
|
EMAILS_PROVEEDORES_DIR = BASE_DIR / 'emails_proveedores'
|
||||||
|
|
||||||
|
# Crear carpetas si no existen
|
||||||
|
PEDIDOS_CLIENTES_PDF_DIR.mkdir(exist_ok=True)
|
||||||
|
ALBARANES_ESCANEADOS_DIR.mkdir(exist_ok=True)
|
||||||
|
EMAILS_PROVEEDORES_DIR.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
LOGGING = {
|
||||||
|
'version': 1,
|
||||||
|
'disable_existing_loggers': False,
|
||||||
|
'handlers': {
|
||||||
|
'console': {
|
||||||
|
'class': 'logging.StreamHandler',
|
||||||
|
},
|
||||||
|
'file': {
|
||||||
|
'class': 'logging.FileHandler',
|
||||||
|
'filename': BASE_DIR / 'logs' / 'app.log',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'root': {
|
||||||
|
'handlers': ['console', 'file'],
|
||||||
|
'level': 'INFO',
|
||||||
|
},
|
||||||
|
'loggers': {
|
||||||
|
'django': {
|
||||||
|
'handlers': ['console', 'file'],
|
||||||
|
'level': 'INFO',
|
||||||
|
'propagate': False,
|
||||||
|
},
|
||||||
|
'gestion_pedidos': {
|
||||||
|
'handlers': ['console', 'file'],
|
||||||
|
'level': 'DEBUG',
|
||||||
|
'propagate': False,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Crear carpeta de logs
|
||||||
|
(BASE_DIR / 'logs').mkdir(exist_ok=True)
|
||||||
|
|
||||||
18
pedidos_clientes/urls.py
Normal file
18
pedidos_clientes/urls.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
"""
|
||||||
|
URL configuration for pedidos_clientes project.
|
||||||
|
"""
|
||||||
|
from django.contrib import admin
|
||||||
|
from django.urls import path, include
|
||||||
|
from django.conf import settings
|
||||||
|
from django.conf.urls.static import static
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('admin/', admin.site.urls),
|
||||||
|
path('api/', include('gestion_pedidos.urls')),
|
||||||
|
path('', include('gestion_pedidos.urls')),
|
||||||
|
]
|
||||||
|
|
||||||
|
if settings.DEBUG:
|
||||||
|
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||||
|
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
|
||||||
|
|
||||||
17
pedidos_clientes/wsgi.py
Normal file
17
pedidos_clientes/wsgi.py
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
"""
|
||||||
|
WSGI config for pedidos_clientes project.
|
||||||
|
|
||||||
|
It exposes the WSGI callable as a module-level variable named ``application``.
|
||||||
|
|
||||||
|
For more information on this file, see
|
||||||
|
https://docs.djangoproject.com/en/4.2/howto/deployment/wsgi/
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from django.core.wsgi import get_wsgi_application
|
||||||
|
|
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'pedidos_clientes.settings')
|
||||||
|
|
||||||
|
application = get_wsgi_application()
|
||||||
|
|
||||||
211
prisma/schema.prisma
Normal file
211
prisma/schema.prisma
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
// This is your Prisma schema file,
|
||||||
|
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||||
|
|
||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
output = "../node_modules/.prisma/client"
|
||||||
|
}
|
||||||
|
|
||||||
|
generator python {
|
||||||
|
provider = "prisma-client-py"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Cliente {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
nombre String @db.VarChar(200)
|
||||||
|
matriculaVehiculo String @unique @map("matricula_vehiculo") @db.VarChar(20)
|
||||||
|
telefono String? @db.VarChar(20)
|
||||||
|
email String? @db.VarChar(255)
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
pedidos PedidoCliente[]
|
||||||
|
|
||||||
|
@@index([matriculaVehiculo])
|
||||||
|
@@index([nombre])
|
||||||
|
@@map("clientes")
|
||||||
|
}
|
||||||
|
|
||||||
|
model PedidoCliente {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
numeroPedido String @unique @map("numero_pedido") @db.VarChar(50)
|
||||||
|
clienteId Int @map("cliente_id")
|
||||||
|
fechaPedido DateTime @default(now()) @map("fecha_pedido")
|
||||||
|
fechaCita DateTime? @map("fecha_cita")
|
||||||
|
estado String @default("pendiente_revision") @db.VarChar(30)
|
||||||
|
presupuestoId String? @map("presupuesto_id") @db.VarChar(50)
|
||||||
|
archivoPdfPath String? @map("archivo_pdf_path") @db.VarChar(500)
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
cliente Cliente @relation(fields: [clienteId], references: [id], onDelete: Cascade)
|
||||||
|
referencias ReferenciaPedidoCliente[]
|
||||||
|
|
||||||
|
@@index([numeroPedido])
|
||||||
|
@@index([estado])
|
||||||
|
@@index([fechaCita])
|
||||||
|
@@index([fechaPedido])
|
||||||
|
@@map("pedidos_cliente")
|
||||||
|
}
|
||||||
|
|
||||||
|
model ReferenciaPedidoCliente {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
pedidoClienteId Int @map("pedido_cliente_id")
|
||||||
|
referencia String @db.VarChar(100)
|
||||||
|
denominacion String @db.VarChar(500)
|
||||||
|
unidadesSolicitadas Int @default(1) @map("unidades_solicitadas")
|
||||||
|
unidadesEnStock Int @default(0) @map("unidades_en_stock")
|
||||||
|
unidadesPendientes Int @default(0) @map("unidades_pendientes")
|
||||||
|
estado String @default("pendiente") @db.VarChar(20)
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
pedidoCliente PedidoCliente @relation(fields: [pedidoClienteId], references: [id], onDelete: Cascade)
|
||||||
|
pedidosProveedor ReferenciaPedidoProveedor[]
|
||||||
|
|
||||||
|
@@index([referencia])
|
||||||
|
@@index([estado])
|
||||||
|
@@index([pedidoClienteId, estado])
|
||||||
|
@@map("referencias_pedido_cliente")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Proveedor {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
nombre String @db.VarChar(200)
|
||||||
|
email String? @db.VarChar(255)
|
||||||
|
tieneWeb Boolean @default(true) @map("tiene_web")
|
||||||
|
activo Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
pedidos PedidoProveedor[]
|
||||||
|
albaranes Albaran[]
|
||||||
|
devoluciones Devolucion[]
|
||||||
|
|
||||||
|
@@index([nombre])
|
||||||
|
@@index([activo])
|
||||||
|
@@map("proveedores")
|
||||||
|
}
|
||||||
|
|
||||||
|
model PedidoProveedor {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
proveedorId Int @map("proveedor_id")
|
||||||
|
numeroPedido String? @map("numero_pedido") @db.VarChar(100)
|
||||||
|
fechaPedido DateTime @default(now()) @map("fecha_pedido")
|
||||||
|
emailConfirmacionPath String? @map("email_confirmacion_path") @db.VarChar(500)
|
||||||
|
estado String @default("pendiente_recepcion") @db.VarChar(30)
|
||||||
|
tipo String @default("web") @db.VarChar(20)
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
proveedor Proveedor @relation(fields: [proveedorId], references: [id], onDelete: Cascade)
|
||||||
|
referencias ReferenciaPedidoProveedor[]
|
||||||
|
|
||||||
|
@@index([proveedorId, estado])
|
||||||
|
@@index([fechaPedido])
|
||||||
|
@@index([estado])
|
||||||
|
@@map("pedidos_proveedor")
|
||||||
|
}
|
||||||
|
|
||||||
|
model ReferenciaPedidoProveedor {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
pedidoProveedorId Int @map("pedido_proveedor_id")
|
||||||
|
referenciaPedidoClienteId Int? @map("referencia_pedido_cliente_id")
|
||||||
|
referencia String @db.VarChar(100)
|
||||||
|
denominacion String @db.VarChar(500)
|
||||||
|
unidadesPedidas Int @default(1) @map("unidades_pedidas")
|
||||||
|
unidadesRecibidas Int @default(0) @map("unidades_recibidas")
|
||||||
|
estado String @default("pendiente") @db.VarChar(20)
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
pedidoProveedor PedidoProveedor @relation(fields: [pedidoProveedorId], references: [id], onDelete: Cascade)
|
||||||
|
referenciaPedidoCliente ReferenciaPedidoCliente? @relation(fields: [referenciaPedidoClienteId], references: [id], onDelete: Cascade)
|
||||||
|
referenciasAlbaran ReferenciaAlbaran[]
|
||||||
|
|
||||||
|
@@index([referencia])
|
||||||
|
@@index([estado])
|
||||||
|
@@index([pedidoProveedorId, estado])
|
||||||
|
@@map("referencias_pedido_proveedor")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Albaran {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
proveedorId Int? @map("proveedor_id")
|
||||||
|
numeroAlbaran String? @map("numero_albaran") @db.VarChar(100)
|
||||||
|
fechaAlbaran DateTime? @map("fecha_albaran") @db.Date
|
||||||
|
archivoPath String @map("archivo_path") @db.VarChar(500)
|
||||||
|
estadoProcesado String @default("pendiente") @map("estado_procesado") @db.VarChar(30)
|
||||||
|
fechaProcesado DateTime? @map("fecha_procesado")
|
||||||
|
datosOcr Json @default("{}") @map("datos_ocr")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
proveedor Proveedor? @relation(fields: [proveedorId], references: [id], onDelete: Cascade)
|
||||||
|
referencias ReferenciaAlbaran[]
|
||||||
|
devoluciones Devolucion[]
|
||||||
|
|
||||||
|
@@index([proveedorId, estadoProcesado])
|
||||||
|
@@index([numeroAlbaran])
|
||||||
|
@@index([fechaAlbaran])
|
||||||
|
@@index([estadoProcesado])
|
||||||
|
@@map("albaranes")
|
||||||
|
}
|
||||||
|
|
||||||
|
model ReferenciaAlbaran {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
albaranId Int @map("albaran_id")
|
||||||
|
referencia String @db.VarChar(100)
|
||||||
|
denominacion String @db.VarChar(500)
|
||||||
|
unidades Int @default(1)
|
||||||
|
precioUnitario Decimal @default(0) @map("precio_unitario") @db.Decimal(10, 2)
|
||||||
|
impuestoTipo String @default("21") @map("impuesto_tipo") @db.VarChar(5)
|
||||||
|
impuestoValor Decimal @default(0) @map("impuesto_valor") @db.Decimal(10, 2)
|
||||||
|
referenciaPedidoProveedorId Int? @map("referencia_pedido_proveedor_id")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
albaran Albaran @relation(fields: [albaranId], references: [id], onDelete: Cascade)
|
||||||
|
referenciaPedidoProveedor ReferenciaPedidoProveedor? @relation(fields: [referenciaPedidoProveedorId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
@@index([referencia])
|
||||||
|
@@index([albaranId])
|
||||||
|
@@map("referencias_albaran")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Devolucion {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
proveedorId Int @map("proveedor_id")
|
||||||
|
referencia String @db.VarChar(100)
|
||||||
|
denominacion String? @db.VarChar(500)
|
||||||
|
unidades Int @default(1)
|
||||||
|
fechaDevolucion DateTime @default(now()) @map("fecha_devolucion")
|
||||||
|
estadoAbono String @default("pendiente") @map("estado_abono") @db.VarChar(20)
|
||||||
|
albaranAbonoId Int? @map("albaran_abono_id")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
proveedor Proveedor @relation(fields: [proveedorId], references: [id], onDelete: Cascade)
|
||||||
|
albaranAbono Albaran? @relation(fields: [albaranAbonoId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
@@index([proveedorId, estadoAbono])
|
||||||
|
@@index([referencia])
|
||||||
|
@@index([estadoAbono])
|
||||||
|
@@map("devoluciones")
|
||||||
|
}
|
||||||
|
|
||||||
|
model StockReferencia {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
referencia String @unique @db.VarChar(100)
|
||||||
|
unidadesDisponibles Int @default(0) @map("unidades_disponibles")
|
||||||
|
ultimaActualizacion DateTime @updatedAt @map("ultima_actualizacion")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
@@index([referencia])
|
||||||
|
@@map("stock_referencias")
|
||||||
|
}
|
||||||
|
|
||||||
13
requirements.txt
Normal file
13
requirements.txt
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
Django==4.2.7
|
||||||
|
djangorestframework==3.14.0
|
||||||
|
psycopg2-binary==2.9.9
|
||||||
|
openai==1.3.5
|
||||||
|
Pillow==10.1.0
|
||||||
|
pdf2image==1.16.3
|
||||||
|
python-dotenv==1.0.0
|
||||||
|
watchdog==3.0.0
|
||||||
|
celery==5.3.4
|
||||||
|
redis==5.0.1
|
||||||
|
django-cors-headers==4.3.1
|
||||||
|
python-multipart==0.0.6
|
||||||
|
|
||||||
13
requirements_prisma.txt
Normal file
13
requirements_prisma.txt
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# Django removido, usando FastAPI con Prisma
|
||||||
|
fastapi==0.104.1
|
||||||
|
uvicorn[standard]==0.24.0
|
||||||
|
prisma==0.11.0
|
||||||
|
python-dotenv==1.0.0
|
||||||
|
openai==1.3.5
|
||||||
|
Pillow==10.1.0
|
||||||
|
pdf2image==1.16.3
|
||||||
|
watchdog==3.0.0
|
||||||
|
python-multipart==0.0.6
|
||||||
|
pydantic==2.5.0
|
||||||
|
pydantic-settings==2.1.0
|
||||||
|
|
||||||
15
run.py
Normal file
15
run.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
"""
|
||||||
|
Script para ejecutar la aplicación FastAPI
|
||||||
|
"""
|
||||||
|
import uvicorn
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
uvicorn.run(
|
||||||
|
"app.main:app",
|
||||||
|
host="0.0.0.0",
|
||||||
|
port=8000,
|
||||||
|
reload=settings.DEBUG,
|
||||||
|
log_level="info"
|
||||||
|
)
|
||||||
|
|
||||||
67
setup.bat
Normal file
67
setup.bat
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
@echo off
|
||||||
|
echo ========================================
|
||||||
|
echo Configuracion del Sistema
|
||||||
|
echo ========================================
|
||||||
|
echo.
|
||||||
|
|
||||||
|
echo [1/5] Instalando dependencias Node.js...
|
||||||
|
call npm install
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo ERROR: No se pudo instalar dependencias Node.js
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo [2/5] Instalando dependencias Python...
|
||||||
|
pip install -r requirements_prisma.txt
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo ERROR: No se pudo instalar dependencias Python
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo [3/5] Verificando archivo .env...
|
||||||
|
if not exist .env (
|
||||||
|
echo.
|
||||||
|
echo ADVERTENCIA: El archivo .env no existe
|
||||||
|
echo Creando archivo .env de ejemplo...
|
||||||
|
(
|
||||||
|
echo DATABASE_URL=postgresql://postgres:postgres@localhost:5432/pedidos_clientes
|
||||||
|
echo OPENAI_API_KEY=tu-openai-api-key-aqui
|
||||||
|
echo DEBUG=True
|
||||||
|
) > .env
|
||||||
|
echo.
|
||||||
|
echo Por favor edita .env con tus credenciales antes de continuar
|
||||||
|
pause
|
||||||
|
)
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo [4/5] Generando cliente Prisma...
|
||||||
|
call prisma generate
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo ERROR: No se pudo generar el cliente Prisma
|
||||||
|
echo Verifica que Prisma CLI esté instalado: npm install
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo [5/5] Creando migraciones de base de datos...
|
||||||
|
echo.
|
||||||
|
echo IMPORTANTE: Asegurate de que PostgreSQL esté corriendo
|
||||||
|
echo y que la base de datos esté configurada en .env
|
||||||
|
echo.
|
||||||
|
pause
|
||||||
|
call prisma migrate dev --name init
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo ========================================
|
||||||
|
echo Configuracion completada!
|
||||||
|
echo ========================================
|
||||||
|
echo.
|
||||||
|
echo Siguiente paso: Ejecutar start.bat para iniciar el sistema
|
||||||
|
echo.
|
||||||
|
pause
|
||||||
|
|
||||||
27
start.bat
Normal file
27
start.bat
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
@echo off
|
||||||
|
echo ========================================
|
||||||
|
echo Iniciando Sistema de Gestion de Pedidos
|
||||||
|
echo ========================================
|
||||||
|
echo.
|
||||||
|
|
||||||
|
echo [1/2] Iniciando Backend...
|
||||||
|
start "Backend - FastAPI" cmd /k "python run.py"
|
||||||
|
|
||||||
|
timeout /t 3 /nobreak >nul
|
||||||
|
|
||||||
|
echo [2/2] Iniciando Frontend...
|
||||||
|
cd frontend
|
||||||
|
start "Frontend - HTTP Server" cmd /k "python -m http.server 3000"
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo ========================================
|
||||||
|
echo Sistema iniciado!
|
||||||
|
echo.
|
||||||
|
echo Backend: http://localhost:8000
|
||||||
|
echo Frontend: http://localhost:3000
|
||||||
|
echo Docs: http://localhost:8000/docs
|
||||||
|
echo ========================================
|
||||||
|
echo.
|
||||||
|
pause
|
||||||
|
|
||||||
34
start.sh
Normal file
34
start.sh
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
echo "========================================"
|
||||||
|
echo "Iniciando Sistema de Gestión de Pedidos"
|
||||||
|
echo "========================================"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "[1/2] Iniciando Backend..."
|
||||||
|
python run.py &
|
||||||
|
BACKEND_PID=$!
|
||||||
|
|
||||||
|
sleep 3
|
||||||
|
|
||||||
|
echo "[2/2] Iniciando Frontend..."
|
||||||
|
cd frontend
|
||||||
|
python -m http.server 3000 &
|
||||||
|
FRONTEND_PID=$!
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "========================================"
|
||||||
|
echo "Sistema iniciado!"
|
||||||
|
echo ""
|
||||||
|
echo "Backend: http://localhost:8000"
|
||||||
|
echo "Frontend: http://localhost:3000"
|
||||||
|
echo "Docs: http://localhost:8000/docs"
|
||||||
|
echo "========================================"
|
||||||
|
echo ""
|
||||||
|
echo "Presiona Ctrl+C para detener ambos servidores"
|
||||||
|
|
||||||
|
# Esperar a que el usuario presione Ctrl+C
|
||||||
|
trap "kill $BACKEND_PID $FRONTEND_PID; exit" INT
|
||||||
|
wait
|
||||||
|
|
||||||
0
static/.gitkeep
Normal file
0
static/.gitkeep
Normal file
310
templates/admin_panel.html
Normal file
310
templates/admin_panel.html
Normal file
@@ -0,0 +1,310 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% block title %}Panel de Administración{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_css %}
|
||||||
|
<style>
|
||||||
|
.admin-container {
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
border-bottom: 2px solid #ecf0f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
padding: 1rem 2rem;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: #7f8c8d;
|
||||||
|
border-bottom: 3px solid transparent;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab.active {
|
||||||
|
color: #3498db;
|
||||||
|
border-bottom-color: #3498db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.albaran-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.albaran-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
border-bottom: 1px solid #ecf0f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.albaran-image {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 400px;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ocr-data {
|
||||||
|
background: #f8f9fa;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin: 1rem 0;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.referencias-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.referencias-table th,
|
||||||
|
.referencias-table td {
|
||||||
|
padding: 0.75rem;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid #ecf0f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.referencias-table th {
|
||||||
|
background: #f8f9fa;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.pendiente {
|
||||||
|
background: #fff3cd;
|
||||||
|
color: #856404;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.procesado {
|
||||||
|
background: #d4edda;
|
||||||
|
color: #155724;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.clasificacion {
|
||||||
|
background: #f8d7da;
|
||||||
|
color: #721c24;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="admin-container">
|
||||||
|
<h1 style="margin-bottom: 2rem;">Panel de Administración</h1>
|
||||||
|
|
||||||
|
<div class="tabs">
|
||||||
|
<button class="tab active" onclick="showTab('albaranes')">Albaranes</button>
|
||||||
|
<button class="tab" onclick="showTab('clasificacion')">Pendientes de Clasificación</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="tab-albaranes" class="tab-content active">
|
||||||
|
<div id="albaranes-container"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="tab-clasificacion" class="tab-content">
|
||||||
|
<div id="clasificacion-container"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script>
|
||||||
|
const API_BASE = '/api';
|
||||||
|
|
||||||
|
function showTab(tabName) {
|
||||||
|
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||||||
|
document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active'));
|
||||||
|
|
||||||
|
event.target.classList.add('active');
|
||||||
|
document.getElementById(`tab-${tabName}`).classList.add('active');
|
||||||
|
|
||||||
|
if (tabName === 'albaranes') {
|
||||||
|
loadAlbaranes();
|
||||||
|
} else if (tabName === 'clasificacion') {
|
||||||
|
loadClasificacion();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadAlbaranes() {
|
||||||
|
fetch(`${API_BASE}/albaranes/`)
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
const albaranes = data.results || data;
|
||||||
|
renderAlbaranes(albaranes);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadClasificacion() {
|
||||||
|
fetch(`${API_BASE}/albaranes/?estado_procesado=clasificacion`)
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
const albaranes = data.results || data;
|
||||||
|
renderClasificacion(albaranes);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAlbaranes(albaranes) {
|
||||||
|
const container = document.getElementById('albaranes-container');
|
||||||
|
container.innerHTML = '';
|
||||||
|
|
||||||
|
albaranes.forEach(albaran => {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'albaran-card';
|
||||||
|
|
||||||
|
const estadoClass = albaran.estado_procesado === 'procesado' ? 'procesado' :
|
||||||
|
albaran.estado_procesado === 'clasificacion' ? 'clasificacion' : 'pendiente';
|
||||||
|
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="albaran-header">
|
||||||
|
<div>
|
||||||
|
<h3>Albarán ${albaran.numero_albaran || albaran.id}</h3>
|
||||||
|
<p>Proveedor: ${albaran.proveedor ? albaran.proveedor.nombre : 'Sin asignar'}</p>
|
||||||
|
<p>Fecha: ${albaran.fecha_albaran || 'N/A'}</p>
|
||||||
|
</div>
|
||||||
|
<span class="badge ${estadoClass}">${albaran.estado_procesado}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<img src="/media/${albaran.archivo_path}" class="albaran-image" alt="Albarán">
|
||||||
|
|
||||||
|
<div class="ocr-data">${JSON.stringify(albaran.datos_ocr, null, 2)}</div>
|
||||||
|
|
||||||
|
${albaran.referencias && albaran.referencias.length > 0 ? `
|
||||||
|
<table class="referencias-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Referencia</th>
|
||||||
|
<th>Descripción</th>
|
||||||
|
<th>Unidades</th>
|
||||||
|
<th>Precio</th>
|
||||||
|
<th>IVA</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${albaran.referencias.map(ref => `
|
||||||
|
<tr>
|
||||||
|
<td>${ref.referencia}</td>
|
||||||
|
<td>${ref.denominacion}</td>
|
||||||
|
<td>${ref.unidades}</td>
|
||||||
|
<td>${ref.precio_unitario}€</td>
|
||||||
|
<td>${ref.impuesto_tipo}%</td>
|
||||||
|
</tr>
|
||||||
|
`).join('')}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
` : ''}
|
||||||
|
`;
|
||||||
|
|
||||||
|
container.appendChild(card);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderClasificacion(albaranes) {
|
||||||
|
const container = document.getElementById('clasificacion-container');
|
||||||
|
container.innerHTML = '';
|
||||||
|
|
||||||
|
albaranes.forEach(albaran => {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'albaran-card';
|
||||||
|
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="albaran-header">
|
||||||
|
<h3>Albarán ${albaran.numero_albaran || albaran.id}</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<img src="/media/${albaran.archivo_path}" class="albaran-image" alt="Albarán">
|
||||||
|
|
||||||
|
<div style="margin-top: 1rem;">
|
||||||
|
<label>Asignar Proveedor:</label>
|
||||||
|
<select id="proveedor-${albaran.id}" style="padding: 0.5rem; margin: 0.5rem 0; width: 100%;">
|
||||||
|
<option value="">Seleccionar proveedor...</option>
|
||||||
|
</select>
|
||||||
|
<button class="btn btn-primary" onclick="vincularProveedor(${albaran.id})" style="margin-top: 0.5rem;">
|
||||||
|
Vincular Proveedor
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
container.appendChild(card);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cargar proveedores
|
||||||
|
loadProveedores();
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadProveedores() {
|
||||||
|
fetch(`${API_BASE}/proveedores/`)
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
const proveedores = data.results || data;
|
||||||
|
proveedores.forEach(prov => {
|
||||||
|
document.querySelectorAll('select[id^="proveedor-"]').forEach(select => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = prov.id;
|
||||||
|
option.textContent = prov.nombre;
|
||||||
|
select.appendChild(option);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function vincularProveedor(albaranId) {
|
||||||
|
const select = document.getElementById(`proveedor-${albaranId}`);
|
||||||
|
const proveedorId = select.value;
|
||||||
|
|
||||||
|
if (!proveedorId) {
|
||||||
|
alert('Selecciona un proveedor');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch(`${API_BASE}/albaranes/${albaranId}/vincular_proveedor/`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ proveedor_id: proveedorId })
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
alert('Proveedor vinculado correctamente');
|
||||||
|
loadClasificacion();
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
alert('Error al vincular proveedor: ' + error.message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cargar inicial
|
||||||
|
loadAlbaranes();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
100
templates/base.html
Normal file
100
templates/base.html
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{% block title %}Gestión de Pedidos{% endblock %}</title>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||||
|
background: #f5f5f5;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
background: #2c3e50;
|
||||||
|
color: white;
|
||||||
|
padding: 1rem 2rem;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav {
|
||||||
|
background: #34495e;
|
||||||
|
padding: 0.5rem 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav a {
|
||||||
|
color: white;
|
||||||
|
text-decoration: none;
|
||||||
|
margin-right: 2rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
display: inline-block;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav a:hover, .nav a.active {
|
||||||
|
background: #2c3e50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: #3498db;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: #2980b9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-success {
|
||||||
|
background: #27ae60;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: #e74c3c;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% block extra_css %}{% endblock %}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="header">
|
||||||
|
<h1>Gestión de Pedidos de Recambios</h1>
|
||||||
|
</div>
|
||||||
|
<nav class="nav">
|
||||||
|
<a href="{% url 'kanban' %}" class="{% if request.resolver_match.url_name == 'kanban' %}active{% endif %}">Kanban</a>
|
||||||
|
<a href="{% url 'referencias-proveedor' %}" class="{% if request.resolver_match.url_name == 'referencias-proveedor' %}active{% endif %}">Proveedores</a>
|
||||||
|
<a href="/admin/" target="_blank">Administración</a>
|
||||||
|
</nav>
|
||||||
|
<div class="container">
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
{% block extra_js %}{% endblock %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
319
templates/kanban.html
Normal file
319
templates/kanban.html
Normal file
@@ -0,0 +1,319 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% block title %}Kanban - Gestión de Pedidos{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_css %}
|
||||||
|
<style>
|
||||||
|
.kanban-container {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding: 1rem 0;
|
||||||
|
min-height: calc(100vh - 200px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-column {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 300px;
|
||||||
|
background: #ecf0f1;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-header {
|
||||||
|
background: #34495e;
|
||||||
|
color: white;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-weight: bold;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-header.pendiente-revision { background: #95a5a6; }
|
||||||
|
.column-header.en-revision { background: #f39c12; }
|
||||||
|
.column-header.pendiente-materiales { background: #e74c3c; }
|
||||||
|
.column-header.completado { background: #27ae60; }
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.urgente {
|
||||||
|
border-left: 4px solid #e74c3c;
|
||||||
|
animation: pulse 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.8; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-subtitle {
|
||||||
|
color: #7f8c8d;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.urgente-badge {
|
||||||
|
background: #e74c3c;
|
||||||
|
color: white;
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.referencias-list {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.referencia-item {
|
||||||
|
padding: 0.5rem;
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.referencia-item.completo {
|
||||||
|
background: #d5f4e6;
|
||||||
|
color: #27ae60;
|
||||||
|
}
|
||||||
|
|
||||||
|
.referencia-item.parcial {
|
||||||
|
background: #fff3cd;
|
||||||
|
color: #856404;
|
||||||
|
}
|
||||||
|
|
||||||
|
.referencia-item.pendiente {
|
||||||
|
background: #f8d7da;
|
||||||
|
color: #721c24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.referencia-codigo {
|
||||||
|
font-weight: bold;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters {
|
||||||
|
background: white;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-input {
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-btn {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-column {
|
||||||
|
text-align: center;
|
||||||
|
color: #95a5a6;
|
||||||
|
padding: 2rem;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="filters">
|
||||||
|
<input type="text" id="search-input" class="filter-input" placeholder="Buscar por matrícula o número de pedido...">
|
||||||
|
<select id="estado-filter" class="filter-input">
|
||||||
|
<option value="">Todos los estados</option>
|
||||||
|
<option value="pendiente_revision">Pendiente Revisión</option>
|
||||||
|
<option value="en_revision">En Revisión</option>
|
||||||
|
<option value="pendiente_materiales">Pendiente Materiales</option>
|
||||||
|
<option value="completado">Completado</option>
|
||||||
|
</select>
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" id="urgente-only"> Solo urgentes
|
||||||
|
</label>
|
||||||
|
<button class="btn btn-primary refresh-btn" onclick="loadKanban()">Actualizar</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="kanban-container" id="kanban-container">
|
||||||
|
<div class="kanban-column">
|
||||||
|
<div class="column-header pendiente-revision">Pendiente Revisión</div>
|
||||||
|
<div id="col-pendiente-revision" class="cards-container"></div>
|
||||||
|
</div>
|
||||||
|
<div class="kanban-column">
|
||||||
|
<div class="column-header en-revision">En Revisión</div>
|
||||||
|
<div id="col-en-revision" class="cards-container"></div>
|
||||||
|
</div>
|
||||||
|
<div class="kanban-column">
|
||||||
|
<div class="column-header pendiente-materiales">Pendiente Materiales</div>
|
||||||
|
<div id="col-pendiente-materiales" class="cards-container"></div>
|
||||||
|
</div>
|
||||||
|
<div class="kanban-column">
|
||||||
|
<div class="column-header completado">Completado</div>
|
||||||
|
<div id="col-completado" class="cards-container"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script>
|
||||||
|
const API_BASE = '/api';
|
||||||
|
|
||||||
|
function createCard(pedido) {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'card';
|
||||||
|
if (pedido.es_urgente) {
|
||||||
|
card.classList.add('urgente');
|
||||||
|
}
|
||||||
|
|
||||||
|
const fechaCita = pedido.fecha_cita ? new Date(pedido.fecha_cita).toLocaleString('es-ES') : 'Sin fecha';
|
||||||
|
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="card-header">
|
||||||
|
<div>
|
||||||
|
<div class="card-title">Pedido ${pedido.numero_pedido}</div>
|
||||||
|
<div class="card-subtitle">${pedido.cliente.matricula_vehiculo} - ${pedido.cliente.nombre}</div>
|
||||||
|
</div>
|
||||||
|
${pedido.es_urgente ? '<span class="urgente-badge">URGENTE</span>' : ''}
|
||||||
|
</div>
|
||||||
|
<div class="card-subtitle" style="margin-bottom: 0.5rem;">Cita: ${fechaCita}</div>
|
||||||
|
<div class="referencias-list">
|
||||||
|
${pedido.referencias.map(ref => {
|
||||||
|
const estadoClass = ref.estado === 'completo' ? 'completo' :
|
||||||
|
ref.estado === 'parcial' ? 'parcial' : 'pendiente';
|
||||||
|
return `
|
||||||
|
<div class="referencia-item ${estadoClass}">
|
||||||
|
<div>
|
||||||
|
<span class="referencia-codigo">${ref.referencia}</span>
|
||||||
|
<div style="font-size: 0.8rem;">${ref.denominacion}</div>
|
||||||
|
</div>
|
||||||
|
<div style="text-align: right;">
|
||||||
|
<div>${ref.unidades_solicitadas} unidades</div>
|
||||||
|
<div style="font-size: 0.8rem;">
|
||||||
|
Stock: ${ref.unidades_en_stock} | Pendiente: ${ref.unidades_pendientes}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('')}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
card.onclick = () => {
|
||||||
|
window.location.href = `/admin/gestion_pedidos/pedidocliente/${pedido.id}/change/`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderKanban(data) {
|
||||||
|
const columns = {
|
||||||
|
'pendiente_revision': document.getElementById('col-pendiente-revision'),
|
||||||
|
'en_revision': document.getElementById('col-en-revision'),
|
||||||
|
'pendiente_materiales': document.getElementById('col-pendiente-materiales'),
|
||||||
|
'completado': document.getElementById('col-completado'),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Limpiar columnas
|
||||||
|
Object.values(columns).forEach(col => {
|
||||||
|
col.innerHTML = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Renderizar tarjetas
|
||||||
|
Object.entries(data).forEach(([estado, pedidos]) => {
|
||||||
|
const column = columns[estado];
|
||||||
|
if (!column) return;
|
||||||
|
|
||||||
|
if (pedidos.length === 0) {
|
||||||
|
column.innerHTML = '<div class="empty-column">No hay pedidos</div>';
|
||||||
|
} else {
|
||||||
|
pedidos.forEach(pedido => {
|
||||||
|
column.appendChild(createCard(pedido));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadKanban() {
|
||||||
|
fetch(`${API_BASE}/kanban/`)
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
renderKanban(data);
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error al cargar Kanban:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtros
|
||||||
|
document.getElementById('search-input').addEventListener('input', (e) => {
|
||||||
|
const search = e.target.value.toLowerCase();
|
||||||
|
document.querySelectorAll('.card').forEach(card => {
|
||||||
|
const text = card.textContent.toLowerCase();
|
||||||
|
card.style.display = text.includes(search) ? 'block' : 'none';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('estado-filter').addEventListener('change', (e) => {
|
||||||
|
const estado = e.target.value;
|
||||||
|
if (!estado) {
|
||||||
|
loadKanban();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch(`${API_BASE}/pedidos-cliente/?estado=${estado}`)
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
const grouped = {
|
||||||
|
'pendiente_revision': [],
|
||||||
|
'en_revision': [],
|
||||||
|
'pendiente_materiales': [],
|
||||||
|
'completado': [],
|
||||||
|
};
|
||||||
|
grouped[estado] = data.results || data;
|
||||||
|
renderKanban(grouped);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-refresh cada 30 segundos
|
||||||
|
setInterval(loadKanban, 30000);
|
||||||
|
|
||||||
|
// Cargar inicial
|
||||||
|
loadKanban();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
195
templates/mobile_upload.html
Normal file
195
templates/mobile_upload.html
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% block title %}Subir Albarán{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_css %}
|
||||||
|
<style>
|
||||||
|
.upload-container {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 2rem auto;
|
||||||
|
background: white;
|
||||||
|
padding: 2rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-area {
|
||||||
|
border: 2px dashed #3498db;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 3rem;
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s;
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-area:hover {
|
||||||
|
background: #e9ecef;
|
||||||
|
border-color: #2980b9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-area.dragover {
|
||||||
|
background: #d4edda;
|
||||||
|
border-color: #27ae60;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-icon {
|
||||||
|
font-size: 3rem;
|
||||||
|
color: #3498db;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-image {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 400px;
|
||||||
|
margin-top: 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-upload {
|
||||||
|
margin-top: 1rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-message {
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-message.success {
|
||||||
|
background: #d4edda;
|
||||||
|
color: #155724;
|
||||||
|
border: 1px solid #c3e6cb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-message.error {
|
||||||
|
background: #f8d7da;
|
||||||
|
color: #721c24;
|
||||||
|
border: 1px solid #f5c6cb;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="upload-container">
|
||||||
|
<h2 style="margin-bottom: 2rem;">Subir Albarán</h2>
|
||||||
|
|
||||||
|
<div class="upload-area" id="upload-area">
|
||||||
|
<div class="upload-icon">📄</div>
|
||||||
|
<p>Arrastra una imagen aquí o haz clic para seleccionar</p>
|
||||||
|
<p style="font-size: 0.9rem; color: #7f8c8d; margin-top: 0.5rem;">
|
||||||
|
Formatos soportados: JPG, PNG, PDF
|
||||||
|
</p>
|
||||||
|
<input type="file" id="file-input" class="file-input" accept="image/*,.pdf">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="preview-container" style="display: none;">
|
||||||
|
<img id="preview-image" class="preview-image" alt="Vista previa">
|
||||||
|
<button class="btn btn-primary btn-upload" onclick="uploadFile()">Subir Albarán</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="status-message" class="status-message"></div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script>
|
||||||
|
const API_BASE = '/api';
|
||||||
|
let selectedFile = null;
|
||||||
|
|
||||||
|
const uploadArea = document.getElementById('upload-area');
|
||||||
|
const fileInput = document.getElementById('file-input');
|
||||||
|
const previewContainer = document.getElementById('preview-container');
|
||||||
|
const previewImage = document.getElementById('preview-image');
|
||||||
|
const statusMessage = document.getElementById('status-message');
|
||||||
|
|
||||||
|
uploadArea.addEventListener('click', () => fileInput.click());
|
||||||
|
|
||||||
|
uploadArea.addEventListener('dragover', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
uploadArea.classList.add('dragover');
|
||||||
|
});
|
||||||
|
|
||||||
|
uploadArea.addEventListener('dragleave', () => {
|
||||||
|
uploadArea.classList.remove('dragover');
|
||||||
|
});
|
||||||
|
|
||||||
|
uploadArea.addEventListener('drop', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
uploadArea.classList.remove('dragover');
|
||||||
|
const files = e.dataTransfer.files;
|
||||||
|
if (files.length > 0) {
|
||||||
|
handleFile(files[0]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
fileInput.addEventListener('change', (e) => {
|
||||||
|
if (e.target.files.length > 0) {
|
||||||
|
handleFile(e.target.files[0]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleFile(file) {
|
||||||
|
selectedFile = file;
|
||||||
|
|
||||||
|
if (file.type.startsWith('image/')) {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
previewImage.src = e.target.result;
|
||||||
|
previewContainer.style.display = 'block';
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
} else if (file.type === 'application/pdf') {
|
||||||
|
previewImage.src = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCIgZmlsbD0iI2VjZjBmMSIvPjx0ZXh0IHg9IjUwJSIgeT0iNTAlIiBmb250LWZhbWlseT0iQXJpYWwiIGZvbnQtc2l6ZT0iMTgiIGZpbGw9IiM3ZjhjOGQiIHRleHQtYW5jaG9yPSJtaWRkbGUiIGR5PSIuM2VtIj5QREY8L3RleHQ+PC9zdmc+';
|
||||||
|
previewContainer.style.display = 'block';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function uploadFile() {
|
||||||
|
if (!selectedFile) return;
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('archivo', selectedFile);
|
||||||
|
|
||||||
|
statusMessage.style.display = 'none';
|
||||||
|
const btn = document.querySelector('.btn-upload');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Subiendo...';
|
||||||
|
|
||||||
|
fetch(`${API_BASE}/albaranes/upload/`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
statusMessage.className = 'status-message success';
|
||||||
|
statusMessage.textContent = 'Albarán subido y procesado correctamente';
|
||||||
|
statusMessage.style.display = 'block';
|
||||||
|
|
||||||
|
// Reset
|
||||||
|
setTimeout(() => {
|
||||||
|
selectedFile = null;
|
||||||
|
fileInput.value = '';
|
||||||
|
previewContainer.style.display = 'none';
|
||||||
|
statusMessage.style.display = 'none';
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Subir Albarán';
|
||||||
|
}, 3000);
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
statusMessage.className = 'status-message error';
|
||||||
|
statusMessage.textContent = 'Error al subir el albarán: ' + error.message;
|
||||||
|
statusMessage.style.display = 'block';
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Subir Albarán';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
151
templates/proveedores.html
Normal file
151
templates/proveedores.html
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% block title %}Referencias por Proveedor{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_css %}
|
||||||
|
<style>
|
||||||
|
.proveedores-container {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.proveedor-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.proveedor-header {
|
||||||
|
background: #3498db;
|
||||||
|
color: white;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-weight: bold;
|
||||||
|
margin: 1rem 0 0.5rem 0;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
border-bottom: 2px solid #ecf0f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.referencia-item {
|
||||||
|
padding: 0.75rem;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
border-left: 4px solid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.referencia-item.pendiente {
|
||||||
|
background: #e3f2fd;
|
||||||
|
border-color: #2196f3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.referencia-item.devolucion {
|
||||||
|
background: #ffebee;
|
||||||
|
border-color: #f44336;
|
||||||
|
}
|
||||||
|
|
||||||
|
.referencia-codigo {
|
||||||
|
font-weight: bold;
|
||||||
|
font-family: monospace;
|
||||||
|
color: #2c3e50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.referencia-info {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #7f8c8d;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="proveedores-container" id="proveedores-container">
|
||||||
|
<!-- Se llenará con JavaScript -->
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script>
|
||||||
|
const API_BASE = '/api';
|
||||||
|
|
||||||
|
function renderProveedores(data) {
|
||||||
|
const container = document.getElementById('proveedores-container');
|
||||||
|
container.innerHTML = '';
|
||||||
|
|
||||||
|
if (data.length === 0) {
|
||||||
|
container.innerHTML = '<p>No hay referencias pendientes</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
data.forEach(proveedorData => {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'proveedor-card';
|
||||||
|
|
||||||
|
const pendientes = proveedorData.referencias_pendientes || [];
|
||||||
|
const devoluciones = proveedorData.referencias_devolucion || [];
|
||||||
|
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="proveedor-header">
|
||||||
|
${proveedorData.proveedor.nombre}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${pendientes.length > 0 ? `
|
||||||
|
<div class="section-title">Referencias Pendientes de Recepción</div>
|
||||||
|
${pendientes.map(ref => `
|
||||||
|
<div class="referencia-item pendiente">
|
||||||
|
<div class="referencia-codigo">${ref.referencia}</div>
|
||||||
|
<div>${ref.denominacion}</div>
|
||||||
|
<div class="referencia-info">
|
||||||
|
Pedidas: ${ref.unidades_pedidas} | Recibidas: ${ref.unidades_recibidas} | Pendiente: ${ref.unidades_pedidas - ref.unidades_recibidas}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
${devoluciones.length > 0 ? `
|
||||||
|
<div class="section-title">Referencias Pendientes de Abono</div>
|
||||||
|
${devoluciones.map(dev => `
|
||||||
|
<div class="referencia-item devolucion">
|
||||||
|
<div class="referencia-codigo">${dev.referencia}</div>
|
||||||
|
<div>${dev.denominacion || ''}</div>
|
||||||
|
<div class="referencia-info">
|
||||||
|
Unidades: ${dev.unidades} | Fecha: ${new Date(dev.fecha_devolucion).toLocaleDateString('es-ES')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
${pendientes.length === 0 && devoluciones.length === 0 ?
|
||||||
|
'<p style="color: #95a5a6; text-align: center; padding: 2rem;">Sin referencias pendientes</p>' : ''}
|
||||||
|
`;
|
||||||
|
|
||||||
|
container.appendChild(card);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadProveedores() {
|
||||||
|
fetch(`${API_BASE}/referencias-proveedor/`)
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
renderProveedores(data);
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error al cargar proveedores:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-refresh cada 30 segundos
|
||||||
|
setInterval(loadProveedores, 30000);
|
||||||
|
|
||||||
|
// Cargar inicial
|
||||||
|
loadProveedores();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
Reference in New Issue
Block a user