diff --git a/.env.example b/.env.example index 51fcea1..f6ebd03 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,7 @@ # Database DATABASE_URL=postgresql://checklist_user:checklist_pass_2024@localhost:5432/checklist_db -# Backend +# Backend SECRET_KEY=your-super-secret-key-min-32-characters-change-this ALGORITHM=HS256 ACCESS_TOKEN_EXPIRE_MINUTES=10080 diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..49f431a --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,133 @@ +# No olvides dar lo comentarios de los cambios que se hicieron para el backend y el front en para los comentarios de git +# Siempre actuliza la version del front y del back la version del front esta en el archivo package.json y la del backend en el archivo main.py en una variable llamada BACKEND_VERSION +# Si el FrontEnd no sufre modificaciones no es necesario actualizar su version, al igual que el backend, solo poner en el comentario de git que no se hicieron cambios en el front o en el backend segun sea el caso + +# Ayudetec - Intelligent Checklist System for Automotive Workshops + +## Architecture Overview + +**Tech Stack**: FastAPI (Python 3.11) + React 18 + PostgreSQL 15 + MinIO/S3 +**Deployment**: Docker Compose for dev, Docker Stack for production +**Key Feature**: AI-powered inspection analysis with OpenAI/Gemini integration + +### Service Structure +- `backend/` - FastAPI monolith with JWT auth, S3 file storage, PDF generation +- `frontend/` - React SPA with Vite, TailwindCSS, client-side routing +- `postgres` - Main data store with checklist templates, inspections, answers +- MinIO/S3 - Image and PDF storage (configurable endpoint) + +### Core Data Model +**Users** (`role`: admin/mechanic/asesor) → **Checklists** (templates with questions) → **Inspections** (mechanic executions) → **Answers** (responses with photos/scores) + +- **Permissions**: `ChecklistPermission` table controls mechanic access (empty = global access) +- **Nested Questions**: 5-level deep conditional subquestions via `parent_question_id` + `show_if_answer` +- **Scoring**: Auto-calculated from answer points, stored in `Inspection.score/percentage` +- **Question Types**: boolean, single_choice, multiple_choice, scale, text, number, date, time (config in `Question.options` JSON) + +## Development Workflows + +### Running Locally +```powershell +docker-compose up -d # Start all services +docker-compose logs backend # Debug backend issues +``` +**URLs**: Frontend `http://localhost:5173`, Backend API `http://localhost:8000/docs` + +### Database Initialization +Use `init_users.py` to create default admin/mechanic users: +```powershell +docker-compose exec backend python init_users.py +``` +**Default credentials**: `admin/admin123`, `mecanico/mec123` + +### Migrations +Manual SQL scripts in `migrations/` - run via: +```powershell +docker-compose exec backend python -c " +from app.core.database import engine +with open('migrations/your_migration.sql') as f: + engine.execute(f.read()) +" +``` + +### Building for Production +```powershell +.\build-and-push.ps1 # Builds images, pushes to Docker Hub (configured in script) +``` +Then deploy with `docker-compose.prod.yml` or `docker-stack.yml` (Swarm mode) + +## Project-Specific Conventions + +### API Architecture (`backend/app/main.py`) +- **Single 2800+ line file** - all endpoints in main.py (no routers/controllers split) +- Auth via `get_current_user()` dependency returning `models.User` +- File uploads use boto3 S3 client configured from `app/core/config.py` (MinIO compatible) +- PDF generation inline with ReportLab (starts ~line 1208, function `generate_pdf`) + +### AI Integration Modes (see `AI_FUNCTIONALITY.md`) +- `ai_mode` on Checklist: `"off"` (manual) | `"assisted"` (suggestions) | `"copilot"` (auto-complete) +- AI analysis triggered on photo upload, stored in `Answer.ai_analysis` JSON +- Webhook notifications to n8n on completion: `send_completed_inspection_to_n8n()` + +### Frontend Patterns (`frontend/src/App.jsx`) +- **5400+ line single-file component** - all views in App.jsx (Login, Dashboard, Admin panels) +- Auth state in `localStorage` (token + user object) +- API calls use `fetch()` with `import.meta.env.VITE_API_URL` base +- Signature capture via `react-signature-canvas` (saved as base64) + +### Permission Model +When fetching checklists: +- Admins see all checklists +- Mechanics only see checklists with either: + - No `ChecklistPermission` records (global access), OR + - A permission record linking that mechanic +- Implement via JOIN in `GET /api/checklists` endpoint + +### Environment Variables +Critical settings in `.env`: +- `DATABASE_URL` - Postgres connection string +- `SECRET_KEY` - JWT signing (min 32 chars) +- `OPENAI_API_KEY` / `GEMINI_API_KEY` - Optional for AI features +- `MINIO_*` vars - S3-compatible storage (MinIO/AWS S3) +- `NOTIFICACION_ENDPOINT` - n8n webhook for inspection events +- `ALLOWED_ORIGINS` - Comma-separated CORS origins + +## Key Integration Points + +### S3/MinIO Storage +- Configured globally in `main.py` via `boto3.client()` + `Config(signature_version='s3v4')` +- Two buckets: `MINIO_IMAGE_BUCKET` (photos), `MINIO_PDF_BUCKET` (reports) +- Upload pattern: generate UUID filename, `s3_client.upload_fileobj()`, store URL in DB + +### PDF Generation +- ReportLab library generates inspection reports with photos, signatures, scoring +- Triggered on inspection completion, stored to S3, URL saved in `Inspection.pdf_url` +- Uses checklist logo from `Checklist.logo_url` if available + +### Webhook Notifications +When `Question.send_notification = true`, answering triggers `send_answer_notification()` to `NOTIFICACION_ENDPOINT` +On inspection completion, sends full data via `send_completed_inspection_to_n8n()` + +## Common Gotchas + +- **No Alembic auto-migrations** - use manual SQL scripts in `migrations/` +- **Token expiry is 7 days** - set in `config.ACCESS_TOKEN_EXPIRE_MINUTES = 10080` +- **Photos stored as S3 URLs** - not base64 in DB (except signatures) +- **Nested questions limited to 5 levels** - enforced in `Question.depth_level` +- **PowerShell scripts** - Windows-first project (see `.ps1` build scripts) +- **Frontend has no state management** - uses React `useState` only, no Redux/Context + +## Checklist Management + +### Editing Checklists +- Admins can edit checklist name, description, AI mode, and scoring settings via "✏️ Editar" button +- Edit modal (`showEditChecklistModal`) uses PUT `/api/checklists/{id}` endpoint +- Backend endpoint supports partial updates via `exclude_unset=True` in Pydantic model +- Logo and permissions have separate management modals for focused UI + +## Example Tasks + +**Add new question type**: Update `Question.type` validation, modify `QuestionTypeEditor.jsx` UI, handle in `QuestionAnswerInput.jsx` +**Change scoring logic**: Edit `backend/app/main.py` answer submission endpoint, recalculate `Inspection.score` +**Add new user role**: Update `User.role` enum, modify `get_current_user()` checks, adjust frontend role conditionals in `App.jsx` +**Edit checklist properties**: Use existing PUT endpoint, add fields to `editChecklistData` state, update modal form diff --git a/.gitignore b/.gitignore index fa49ffd..f0da76c 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,8 @@ dist-ssr/ *.swp *.swo *~ +__pycache__/ +*.pyc # OS .DS_Store diff --git a/README.md b/README.md index b276f6a..6b5d5a1 100644 --- a/README.md +++ b/README.md @@ -360,6 +360,54 @@ UPDATE checklists SET max_score = ( MIT License - Uso libre para proyectos comerciales y personales +## 📝 Control de Versiones + +### Instrucciones para commits de Git + +**IMPORTANTE**: Siempre incluir la versión actualizada en los mensajes de commit. + +Formato recomendado: +```bash +git add . +git commit -m "tipo: descripción del cambio + +- Detalle 1 +- Detalle 2 +- Frontend vX.X.XX / Backend vX.X.XX" +``` + +Tipos de commit: +- `feat`: Nueva funcionalidad +- `fix`: Corrección de bugs +- `refactor`: Refactorización de código +- `style`: Cambios de formato/estilo +- `docs`: Actualización de documentación +- `perf`: Mejoras de rendimiento +- `test`: Añadir o actualizar tests + +**Ejemplo real**: +```bash +git add . +git commit -m "feat: Add pagination (10 items/page) to all main tabs + +- Pagination for Inspections, Checklists, and Reports +- Auto-reset on filter changes +- Smart page navigation with ellipsis +- Result counters showing X-Y of Z items +- Frontend v1.0.64" +``` + +### Versionado + +Seguir **Semantic Versioning** (MAJOR.MINOR.PATCH): +- **MAJOR**: Cambios incompatibles en la API +- **MINOR**: Nueva funcionalidad compatible con versiones anteriores +- **PATCH**: Correcciones de bugs + +Ubicación de versiones: +- Frontend: `frontend/package.json` → `"version": "X.X.XX"` +- Backend: `backend/app/main.py` → `version="X.X.XX"` en FastAPI app + ## 🆘 Soporte Para problemas o preguntas: diff --git a/TIMEZONE_SETUP.md b/TIMEZONE_SETUP.md new file mode 100644 index 0000000..367471e --- /dev/null +++ b/TIMEZONE_SETUP.md @@ -0,0 +1,209 @@ +# Configuración de Zona Horaria - Atlantic/Canary + +## Cambios Implementados + +Se ha configurado la zona horaria de **Atlantic/Canary (Islas Canarias, España)** en toda la aplicación: + +### 1. Base de Datos PostgreSQL +- **Zona horaria**: `Atlantic/Canary` (UTC+0 en invierno, UTC+1 en verano con horario de verano) +- Variables de entorno agregadas: + - `TZ=Atlantic/Canary` + - `PGTZ=Atlantic/Canary` + +### 2. Backend FastAPI +- Configuración de zona horaria de Python al inicio de la aplicación +- Conexión a PostgreSQL configurada con timezone +- Event listener para establecer timezone en cada conexión +- Variable de entorno: `TZ=Atlantic/Canary` + +### 3. Frontend React +- Los filtros de fecha usan el constructor de Date con zona horaria local +- Las fechas se muestran en formato español (es-ES) + +## Aplicar los Cambios + +### Desarrollo Local + +1. **Parar los contenedores actuales**: + ```powershell + docker-compose down + ``` + +2. **Aplicar la migración de zona horaria a la base de datos**: + ```powershell + docker-compose up -d postgres + + # Esperar a que PostgreSQL esté listo + Start-Sleep -Seconds 5 + + # Aplicar migración + docker-compose exec postgres psql -U checklist_user -d checklist_db -f /docker-entrypoint-initdb.d/../migrations/set_timezone_canary.sql + ``` + + **Alternativa manual**: + ```powershell + # Copiar el archivo SQL al contenedor + docker cp migrations/set_timezone_canary.sql checklist-db:/tmp/ + + # Ejecutarlo + docker-compose exec postgres psql -U checklist_user -d checklist_db -f /tmp/set_timezone_canary.sql + ``` + +3. **Reconstruir y levantar todos los servicios**: + ```powershell + docker-compose build + docker-compose up -d + ``` + +4. **Verificar la configuración**: + ```powershell + # Verificar timezone en PostgreSQL + docker-compose exec postgres psql -U checklist_user -d checklist_db -c "SHOW timezone;" + + # Verificar timezone en backend + docker-compose exec backend python -c "import time; print(time.tzname)" + ``` + +### Producción + +#### Docker Compose Production + +1. **Parar servicios**: + ```bash + docker-compose -f docker-compose.prod.yml down + ``` + +2. **Aplicar migración**: + ```bash + docker-compose -f docker-compose.prod.yml up -d postgres + docker cp migrations/set_timezone_canary.sql syntria-db-prod:/tmp/ + docker-compose -f docker-compose.prod.yml exec postgres psql -U syntria_user -d syntria_db -f /tmp/set_timezone_canary.sql + ``` + +3. **Reconstruir imágenes y desplegar**: + ```bash + ./build-and-push.sh # o build-and-push.ps1 en Windows + docker-compose -f docker-compose.prod.yml pull + docker-compose -f docker-compose.prod.yml up -d + ``` + +#### Docker Swarm + +1. **Aplicar migración en el nodo manager**: + ```bash + # Encontrar el contenedor de PostgreSQL + docker ps | grep syntria_db + + # Copiar y ejecutar migración + docker cp migrations/set_timezone_canary.sql :/tmp/ + docker exec psql -U syntria_user -d syntria_db -f /tmp/set_timezone_canary.sql + ``` + +2. **Actualizar stack**: + ```bash + docker stack deploy -c docker-stack.yml syntria + ``` + +## Verificación Post-Despliegue + +### 1. Verificar Zona Horaria de PostgreSQL +```sql +-- Debería mostrar: Atlantic/Canary +SHOW timezone; + +-- Verificar hora actual del servidor +SELECT NOW(); +SELECT CURRENT_TIMESTAMP; +``` + +### 2. Verificar Backend +```bash +docker-compose exec backend python -c " +from datetime import datetime +import time +print('Timezone:', time.tzname) +print('Hora actual:', datetime.now()) +print('UTC:', datetime.utcnow()) +" +``` + +### 3. Probar en la Aplicación +1. Crear una nueva inspección +2. Verificar que la fecha/hora de inicio coincida con la hora local de Canarias +3. Filtrar por fecha y verificar que el filtro funcione correctamente + +## Comportamiento de las Fechas + +### Fechas Existentes +- Las fechas ya guardadas en la base de datos **no se modifican** +- PostgreSQL las almacena internamente en UTC +- Se mostrarán en hora de Canarias al consultarlas + +### Nuevas Fechas +- Se guardarán con timezone de Canarias +- Se convertirán automáticamente a UTC para almacenamiento +- Se mostrarán en hora de Canarias al recuperarlas + +## Horario de Verano + +La zona horaria `Atlantic/Canary` maneja automáticamente el horario de verano: +- **Invierno (octubre - marzo)**: UTC+0 / WET (Western European Time) +- **Verano (marzo - octubre)**: UTC+1 / WEST (Western European Summer Time) + +## Troubleshooting + +### Las fechas siguen mostrándose incorrectas + +1. Verificar que los contenedores se reiniciaron después de los cambios: + ```powershell + docker-compose restart + ``` + +2. Verificar logs del backend: + ```powershell + docker-compose logs backend | Select-String -Pattern "timezone|TZ" + ``` + +3. Limpiar caché del navegador y recargar la aplicación + +### Error al conectar a la base de datos + +Si ves errores relacionados con timezone al conectar: +``` +could not find timezone "Atlantic/Canary" +``` + +Solución: +```bash +# Entrar al contenedor de PostgreSQL +docker-compose exec postgres sh + +# Instalar datos de zona horaria (si no están instalados) +apk add --no-cache tzdata + +# Salir y reiniciar +exit +docker-compose restart postgres +``` + +### Las fechas en el frontend no coinciden + +El frontend usa la zona horaria del navegador del usuario. Si el usuario está en una zona diferente a Canarias, verá las fechas en su hora local. Para forzar visualización en hora de Canarias en el frontend, se pueden usar bibliotecas como `date-fns-tz` o `luxon`. + +## Archivos Modificados + +- ✅ `docker-compose.yml` - Variables TZ para postgres y backend +- ✅ `docker-compose.prod.yml` - Variables TZ para postgres y backend +- ✅ `docker-stack.yml` - Variables TZ para db y backend +- ✅ `backend/app/core/database.py` - Configuración de conexión con timezone +- ✅ `backend/app/main.py` - Configuración de TZ de Python +- ✅ `migrations/set_timezone_canary.sql` - Script de migración +- ✅ `frontend/src/App.jsx` - Uso correcto de fechas locales + +## Notas Importantes + +⚠️ **Después de aplicar estos cambios en producción**: +- Hacer backup de la base de datos antes de aplicar cambios +- Aplicar en horario de bajo tráfico +- Verificar que todas las funcionalidades de fecha/hora funcionan correctamente +- Informar a los usuarios si hay cambios visibles en las fechas mostradas diff --git a/apply-timezone.bat b/apply-timezone.bat new file mode 100644 index 0000000..ad1b71e --- /dev/null +++ b/apply-timezone.bat @@ -0,0 +1,40 @@ +@echo off +echo ==================================== +echo Aplicando configuracion de timezone +echo ==================================== + +echo. +echo 1. Copiando script de migracion... +docker cp migrations/force_timezone_all_sessions.sql checklist-db:/tmp/ + +echo. +echo 2. Ejecutando migracion en checklist_db... +docker exec checklist-db psql -U checklist_user -d checklist_db -f /tmp/force_timezone_all_sessions.sql + +echo. +echo 3. Ejecutando migracion en syntria_db (si existe)... +docker exec checklist-db psql -U checklist_user -d postgres -c "SELECT 1 FROM pg_database WHERE datname = 'syntria_db'" | find "1" >nul +if %ERRORLEVEL% equ 0 ( + docker exec checklist-db psql -U syntria_user -d syntria_db -f /tmp/force_timezone_all_sessions.sql + echo Migración aplicada a syntria_db +) else ( + echo syntria_db no existe, omitiendo... +) + +echo. +echo 4. Recargando configuracion de PostgreSQL... +docker exec checklist-db psql -U checklist_user -d checklist_db -c "SELECT pg_reload_conf();" + +echo. +echo 5. Verificando timezone... +docker exec checklist-db psql -U checklist_user -d checklist_db -c "SHOW timezone;" + +echo. +echo ==================================== +echo Completado! +echo ==================================== +echo. +echo IMPORTANTE: Desconecta y reconecta tu cliente PostgreSQL +echo para que aplique la nueva zona horaria. +echo. +pause diff --git a/apply-timezone.sh b/apply-timezone.sh new file mode 100644 index 0000000..839b5c6 --- /dev/null +++ b/apply-timezone.sh @@ -0,0 +1,39 @@ +#!/bin/bash + +echo "====================================" +echo "Aplicando configuración de timezone" +echo "====================================" + +echo "" +echo "1. Copiando script de migración..." +docker cp migrations/force_timezone_all_sessions.sql checklist-db:/tmp/ + +echo "" +echo "2. Ejecutando migración en checklist_db..." +docker exec checklist-db psql -U checklist_user -d checklist_db -f /tmp/force_timezone_all_sessions.sql + +echo "" +echo "3. Ejecutando migración en syntria_db (si existe)..." +if docker exec checklist-db psql -U checklist_user -d postgres -tc "SELECT 1 FROM pg_database WHERE datname = 'syntria_db'" | grep -q 1; then + docker exec checklist-db psql -U syntria_user -d syntria_db -f /tmp/force_timezone_all_sessions.sql + echo "Migración aplicada a syntria_db" +else + echo "syntria_db no existe, omitiendo..." +fi + +echo "" +echo "4. Recargando configuración de PostgreSQL..." +docker exec checklist-db psql -U checklist_user -d checklist_db -c "SELECT pg_reload_conf();" + +echo "" +echo "5. Verificando timezone..." +docker exec checklist-db psql -U checklist_user -d checklist_db -c "SHOW timezone;" + +echo "" +echo "====================================" +echo "Completado!" +echo "====================================" +echo "" +echo "IMPORTANTE: Desconecta y reconecta tu cliente PostgreSQL" +echo "para que aplique la nueva zona horaria." +echo "" diff --git a/backend/app/core/database.py b/backend/app/core/database.py index e728703..5ac6c32 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -1,4 +1,4 @@ -from sqlalchemy import create_engine +from sqlalchemy import create_engine, event from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import sessionmaker from app.core.config import settings @@ -6,9 +6,17 @@ from app.core.config import settings engine = create_engine( settings.DATABASE_URL, pool_pre_ping=True, - echo=settings.ENVIRONMENT == "development" + echo=settings.ENVIRONMENT == "development", + connect_args={"options": "-c timezone=Atlantic/Canary"} ) +# Configurar zona horaria al conectar +@event.listens_for(engine, "connect") +def set_timezone(dbapi_conn, connection_record): + cursor = dbapi_conn.cursor() + cursor.execute("SET TIME ZONE 'Atlantic/Canary';") + cursor.close() + SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) Base = declarative_base() diff --git a/backend/app/main.py b/backend/app/main.py index 44cc9aa..4e2837d 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,12 +1,20 @@ +# ============= CONFIGURACIÓN DE ZONA HORARIA ============= +import os +os.environ['TZ'] = 'Atlantic/Canary' +import time +time.tzset() + # ============= LOGO CONFIGURABLE ============= from fastapi import FastAPI, File, UploadFile, Form, Depends, HTTPException, status from fastapi.middleware.cors import CORSMiddleware from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from fastapi.responses import StreamingResponse from sqlalchemy.orm import Session, joinedload -from sqlalchemy import func, case +from sqlalchemy import func, case, or_ from typing import List, Optional +from io import BytesIO import os import boto3 from botocore.client import Config @@ -19,6 +27,7 @@ import shutil from datetime import datetime, timedelta import sys import requests +import json # Función para enviar notificaciones al webhook def send_answer_notification(answer, question, mechanic, db): @@ -56,7 +65,7 @@ def send_answer_notification(answer, question, mechanic, db): "vehiculo_placa": inspection.vehicle_plate, "vehiculo_marca": inspection.vehicle_brand, "vehiculo_modelo": inspection.vehicle_model, - "cliente": inspection.client_name, + "pedido": inspection.order_number, "or_number": inspection.or_number }, "mecanico": { @@ -83,7 +92,199 @@ def send_answer_notification(answer, question, mechanic, db): print(f"❌ Error enviando notificación: {e}") # No lanzamos excepción para no interrumpir el flujo normal -BACKEND_VERSION = "1.0.25" + +def send_completed_inspection_to_n8n(inspection, db): + """Envía la inspección completa con todas las respuestas e imágenes a n8n""" + try: + if not app_config.settings.NOTIFICACION_ENDPOINT: + print("No hay endpoint de notificación configurado") + return + + print(f"\n🚀 Enviando inspección #{inspection.id} a n8n...") + + # Obtener datos del mecánico + mechanic = db.query(models.User).filter(models.User.id == inspection.mechanic_id).first() + + # Obtener checklist + checklist = db.query(models.Checklist).filter(models.Checklist.id == inspection.checklist_id).first() + + # Obtener todas las respuestas con sus imágenes - SOLO de preguntas NO eliminadas + answers = db.query(models.Answer).options( + joinedload(models.Answer.media_files), + joinedload(models.Answer.question) + ).join(models.Question).filter( + models.Answer.inspection_id == inspection.id, + models.Question.is_deleted == False # Excluir preguntas eliminadas + ).all() + + # Preparar respuestas con imágenes + respuestas_data = [] + for answer in answers: + # Obtener URLs de imágenes + imagenes = [] + for media in answer.media_files: + if media.file_type == "image": + # Extraer filename del file_path (última parte de la URL) + filename = media.file_path.split('/')[-1] if media.file_path else "imagen.jpg" + + imagenes.append({ + "id": media.id, + "url": media.file_path, + "filename": filename + }) + + respuestas_data.append({ + "id": answer.id, + "pregunta": { + "id": answer.question.id, + "texto": answer.question.text, + "seccion": answer.question.section, + "orden": answer.question.order, + "tipo": answer.question.type + }, + "respuesta": answer.answer_value, + "estado": answer.status, + "comentario": answer.comment, + "puntos_obtenidos": answer.points_earned, + "es_critico": answer.is_flagged, + "imagenes": imagenes, + "ai_analysis": answer.ai_analysis, + "chat_history": answer.chat_history # Incluir historial de chat si existe + }) + + # Preparar datos completos de la inspección + inspeccion_data = { + "tipo": "inspeccion_completada", + "inspeccion": { + "id": inspection.id, + "estado": inspection.status, + "or_number": inspection.or_number, + "work_order_number": inspection.work_order_number, + "vehiculo": { + "placa": inspection.vehicle_plate, + "marca": inspection.vehicle_brand, + "modelo": inspection.vehicle_model, + "kilometraje": inspection.vehicle_km + }, + "pedido": inspection.order_number, + "mecanico": { + "id": mechanic.id if mechanic else None, + "nombre": mechanic.full_name if mechanic else None, + "email": mechanic.email if mechanic else None, + "codigo_operario": inspection.mechanic_employee_code + }, + "checklist": { + "id": checklist.id if checklist else None, + "nombre": checklist.name if checklist else None + }, + "puntuacion": { + "obtenida": inspection.score, + "maxima": inspection.max_score, + "porcentaje": round(inspection.percentage, 2), + "items_criticos": inspection.flagged_items_count + }, + "fechas": { + "inicio": inspection.started_at.isoformat() if inspection.started_at else None, + "completado": inspection.completed_at.isoformat() if inspection.completed_at else None + }, + "pdf_url": inspection.pdf_url, + "firma": inspection.signature_data + }, + "respuestas": respuestas_data, + "timestamp": datetime.utcnow().isoformat() + } + + # Enviar al webhook de n8n + print(f"📤 Enviando {len(respuestas_data)} respuestas con imágenes a n8n...") + response = requests.post( + app_config.settings.NOTIFICACION_ENDPOINT, + json=inspeccion_data, + timeout=30 # Timeout más largo para inspecciones completas + ) + + if response.status_code == 200: + print(f"✅ Inspección #{inspection.id} enviada exitosamente a n8n") + print(f" - {len(respuestas_data)} respuestas") + print(f" - {sum(len(r['imagenes']) for r in respuestas_data)} imágenes") + else: + print(f"⚠️ Error al enviar inspección a n8n: {response.status_code}") + print(f" Response: {response.text[:200]}") + + except Exception as e: + print(f"❌ Error enviando inspección a n8n: {e}") + import traceback + traceback.print_exc() + # No lanzamos excepción para no interrumpir el flujo normal + + +# ============================================================================ +# UTILIDADES PARA PROCESAMIENTO DE PDFs +# ============================================================================ + +def extract_pdf_text_smart(pdf_content: bytes, max_chars: int = None) -> dict: + """ + Extrae texto de un PDF de forma inteligente, evitando duplicaciones + y manejando PDFs grandes. + + Args: + pdf_content: Contenido del PDF en bytes + max_chars: Límite máximo de caracteres (None = sin límite) + + Returns: + dict con 'text', 'pages', 'total_chars', 'truncated' + """ + from pypdf import PdfReader + from io import BytesIO + + try: + pdf_file = BytesIO(pdf_content) + pdf_reader = PdfReader(pdf_file) + + full_text = "" + pages_processed = 0 + total_pages = len(pdf_reader.pages) + + for page_num, page in enumerate(pdf_reader.pages, 1): + page_text = page.extract_text() + + # Limpiar y validar texto de la página + if page_text and page_text.strip(): + # Evitar duplicación: verificar si el texto ya existe + # (algunos PDFs pueden tener páginas repetidas) + if page_text.strip() not in full_text: + full_text += f"\n--- Página {page_num}/{total_pages} ---\n{page_text.strip()}\n" + pages_processed += 1 + + # Si hay límite y lo alcanzamos, detener + if max_chars and len(full_text) >= max_chars: + break + + total_chars = len(full_text) + truncated = False + + # Aplicar límite si se especificó + if max_chars and total_chars > max_chars: + full_text = full_text[:max_chars] + truncated = True + + return { + 'text': full_text, + 'pages': total_pages, + 'pages_processed': pages_processed, + 'total_chars': total_chars, + 'truncated': truncated, + 'success': True + } + + except Exception as e: + return { + 'text': '', + 'error': str(e), + 'success': False + } + + +BACKEND_VERSION = "1.2.13" app = FastAPI(title="Checklist Inteligente API", version=BACKEND_VERSION) # S3/MinIO configuration @@ -187,6 +388,8 @@ async def upload_logo( if current_user.role != "admin": raise HTTPException(status_code=403, detail="Solo administradores pueden cambiar el logo") + print(f"\n📝 SUBIENDO LOGO DE EMPRESA...") + # Subir imagen a MinIO file_extension = file.filename.split(".")[-1] now = datetime.now() @@ -195,24 +398,44 @@ async def upload_logo( s3_key = f"{folder}/{file_name}" s3_client.upload_fileobj(file.file, S3_IMAGE_BUCKET, s3_key, ExtraArgs={"ContentType": file.content_type}) logo_url = f"{S3_ENDPOINT}/{S3_IMAGE_BUCKET}/{s3_key}" + print(f"✅ Logo subido a S3: {logo_url}") - # Guardar en configuración (puedes tener una tabla Config o usar AIConfiguration) + # Guardar en configuración (crear si no existe) config = db.query(models.AIConfiguration).filter(models.AIConfiguration.is_active == True).first() if config: + print(f"🔄 Actualizando logo en configuración existente (ID: {config.id})") config.logo_url = logo_url db.commit() db.refresh(config) - # Si no hay config, solo retorna la url + else: + # Crear configuración básica solo para guardar el logo + print("⚠️ No hay configuración de IA activa, creando una básica para guardar el logo") + new_config = models.AIConfiguration( + provider="openai", + api_key="pending", # Placeholder, se actualizará luego + model_name="gpt-4o", + logo_url=logo_url, + is_active=True + ) + db.add(new_config) + db.commit() + db.refresh(new_config) + print(f"✅ Configuración creada con ID: {new_config.id}") + + print(f"✅ Logo guardado correctamente: {logo_url}\n") return {"logo_url": logo_url} @app.get("/api/config/logo", response_model=dict) def get_logo_url( db: Session = Depends(get_db) ): + print(f"\n🔍 OBTENIENDO LOGO DE EMPRESA...") config = db.query(models.AIConfiguration).filter(models.AIConfiguration.is_active == True).first() if config and getattr(config, "logo_url", None): + print(f"✅ Logo encontrado: {config.logo_url}\n") return {"logo_url": config.logo_url} # Default logo (puedes poner una url por defecto) + print(f"⚠️ No hay logo configurado, retornando default\n") return {"logo_url": f"{S3_ENDPOINT}/{S3_IMAGE_BUCKET}/logo/default_logo.png"} @@ -325,6 +548,7 @@ def create_user( username=user.username, email=user.email, full_name=user.full_name, + employee_code=user.employee_code, role=user.role, password_hash=hashed_password, is_active=True @@ -374,6 +598,9 @@ def update_user( if user_update.full_name is not None: db_user.full_name = user_update.full_name + if user_update.employee_code is not None: + db_user.employee_code = user_update.employee_code + # Solo admin puede cambiar roles if user_update.role is not None: if current_user.role != "admin": @@ -655,13 +882,17 @@ def get_checklists( @app.get("/api/checklists/{checklist_id}", response_model=schemas.ChecklistWithQuestions) def get_checklist(checklist_id: int, db: Session = Depends(get_db)): - checklist = db.query(models.Checklist).options( - joinedload(models.Checklist.questions) - ).filter(models.Checklist.id == checklist_id).first() + checklist = db.query(models.Checklist).filter(models.Checklist.id == checklist_id).first() if not checklist: raise HTTPException(status_code=404, detail="Checklist no encontrado") + # Cargar solo preguntas NO eliminadas + checklist.questions = db.query(models.Question).filter( + models.Question.checklist_id == checklist_id, + models.Question.is_deleted == False + ).order_by(models.Question.order).all() + # Agregar allowed_mechanics permissions = db.query(models.ChecklistPermission.mechanic_id).filter( models.ChecklistPermission.checklist_id == checklist.id @@ -754,6 +985,71 @@ def update_checklist( return db_checklist +@app.post("/api/checklists/{checklist_id}/upload-logo") +async def upload_checklist_logo( + checklist_id: int, + file: UploadFile = File(...), + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user) +): + """Subir logo para un checklist (solo admin)""" + if current_user.role != "admin": + raise HTTPException(status_code=403, detail="Solo administradores pueden subir logos") + + # Verificar que el checklist existe + checklist = db.query(models.Checklist).filter(models.Checklist.id == checklist_id).first() + if not checklist: + raise HTTPException(status_code=404, detail="Checklist no encontrado") + + # Validar que es una imagen + if not file.content_type.startswith('image/'): + raise HTTPException(status_code=400, detail="El archivo debe ser una imagen") + + # Subir a S3/MinIO + file_extension = file.filename.split(".")[-1] + now = datetime.now() + folder = f"checklist-logos/{now.year}/{now.month:02d}" + file_name = f"checklist_{checklist_id}_{uuid.uuid4().hex}.{file_extension}" + s3_key = f"{folder}/{file_name}" + + file_content = await file.read() + s3_client.upload_fileobj( + BytesIO(file_content), + S3_IMAGE_BUCKET, + s3_key, + ExtraArgs={"ContentType": file.content_type} + ) + + logo_url = f"{S3_ENDPOINT}/{S3_IMAGE_BUCKET}/{s3_key}" + + # Actualizar checklist + checklist.logo_url = logo_url + db.commit() + db.refresh(checklist) + + return {"logo_url": logo_url, "message": "Logo subido exitosamente"} + + +@app.delete("/api/checklists/{checklist_id}/logo") +def delete_checklist_logo( + checklist_id: int, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user) +): + """Eliminar logo de un checklist (solo admin)""" + if current_user.role != "admin": + raise HTTPException(status_code=403, detail="Solo administradores pueden eliminar logos") + + checklist = db.query(models.Checklist).filter(models.Checklist.id == checklist_id).first() + if not checklist: + raise HTTPException(status_code=404, detail="Checklist no encontrado") + + checklist.logo_url = None + db.commit() + + return {"message": "Logo eliminado exitosamente"} + + # ============= QUESTION ENDPOINTS ============= @app.post("/api/questions", response_model=schemas.Question) def create_question( @@ -764,21 +1060,82 @@ def create_question( if current_user.role != "admin": raise HTTPException(status_code=403, detail="No autorizado") - db_question = models.Question(**question.dict()) + # Calcular el order correcto automáticamente + question_data = question.dict() + + if question_data.get('parent_question_id'): + # Es una subpregunta: obtener el order del padre y colocar después de sus hermanos + parent_question = db.query(models.Question).filter( + models.Question.id == question_data['parent_question_id'] + ).first() + + if parent_question: + # Obtener todas las subpreguntas del mismo padre + siblings = db.query(models.Question).filter( + models.Question.parent_question_id == question_data['parent_question_id'] + ).all() + + if siblings: + # Colocar después del último hermano + max_sibling_order = max(s.order for s in siblings) + question_data['order'] = max_sibling_order + 1 + else: + # Es la primera subpregunta de este padre + question_data['order'] = parent_question.order + 1 + else: + # Es pregunta padre: obtener el último order de preguntas padre + max_order = db.query(func.max(models.Question.order)).filter( + models.Question.checklist_id == question_data['checklist_id'], + models.Question.parent_question_id == None + ).scalar() + + if max_order is not None: + # Redondear al siguiente múltiplo de 10 para dejar espacio a subpreguntas + question_data['order'] = ((max_order // 10) + 1) * 10 + else: + # Es la primera pregunta del checklist + question_data['order'] = 0 + + db_question = models.Question(**question_data) db.add(db_question) - - # Actualizar max_score del checklist - checklist = db.query(models.Checklist).filter( - models.Checklist.id == question.checklist_id - ).first() - if checklist: - checklist.max_score += question.points - db.commit() db.refresh(db_question) + + # Recalcular max_score del checklist DESPUÉS de persistir + recalculate_checklist_max_score(question.checklist_id, db) + db.commit() + + # Registrar auditoría + audit_log = models.QuestionAuditLog( + question_id=db_question.id, + checklist_id=question.checklist_id, + user_id=current_user.id, + action="created", + new_value=f"Pregunta creada: {question.text}", + comment=f"Sección: {question.section}, Tipo: {question.type}, Puntos: {question.points}" + ) + db.add(audit_log) + db.commit() + return db_question +# Helper function para recalcular max_score de un checklist +def recalculate_checklist_max_score(checklist_id: int, db: Session): + """Recalcula el max_score sumando los puntos de todas las preguntas NO eliminadas""" + total_score = db.query(func.sum(models.Question.points)).filter( + models.Question.checklist_id == checklist_id, + models.Question.is_deleted == False + ).scalar() or 0 + + checklist = db.query(models.Checklist).filter(models.Checklist.id == checklist_id).first() + if checklist: + checklist.max_score = total_score + print(f"✅ Checklist #{checklist_id} max_score recalculado: {total_score}") + + return total_score + + @app.put("/api/questions/{question_id}", response_model=schemas.Question) def update_question( question_id: int, @@ -793,14 +1150,101 @@ def update_question( if not db_question: raise HTTPException(status_code=404, detail="Pregunta no encontrada") + # Guardar valores anteriores para auditoría + import json + changes = [] + for key, value in question.dict(exclude_unset=True).items(): - setattr(db_question, key, value) + old_value = getattr(db_question, key) + if old_value != value: + # Convertir a string para comparación y almacenamiento + old_str = json.dumps(old_value, ensure_ascii=False) if isinstance(old_value, (dict, list)) else str(old_value) + new_str = json.dumps(value, ensure_ascii=False) if isinstance(value, (dict, list)) else str(value) + + changes.append({ + 'field': key, + 'old': old_str, + 'new': new_str + }) + setattr(db_question, key, value) + + # Si cambiaron los puntos, hacer flush y recalcular + points_changed = any(change['field'] == 'points' for change in changes) db.commit() db.refresh(db_question) + + # Registrar auditoría para cada campo cambiado + for change in changes: + audit_log = models.QuestionAuditLog( + question_id=question_id, + checklist_id=db_question.checklist_id, + user_id=current_user.id, + action="updated", + field_name=change['field'], + old_value=change['old'], + new_value=change['new'], + comment=f"Campo '{change['field']}' modificado" + ) + db.add(audit_log) + + if changes: + db.commit() + + # Si cambiaron los puntos, recalcular DESPUÉS del commit + if points_changed: + recalculate_checklist_max_score(db_question.checklist_id, db) + db.commit() + return db_question +@app.patch("/api/checklists/{checklist_id}/questions/reorder") +def reorder_questions( + checklist_id: int, + reorder_data: List[schemas.QuestionReorder], + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user) +): + """Reordenar preguntas de un checklist""" + if current_user.role != "admin": + raise HTTPException(status_code=403, detail="No autorizado") + + # Verificar que el checklist existe + checklist = db.query(models.Checklist).filter(models.Checklist.id == checklist_id).first() + if not checklist: + raise HTTPException(status_code=404, detail="Checklist no encontrado") + + # Actualizar el orden de cada pregunta + for item in reorder_data: + question = db.query(models.Question).filter( + models.Question.id == item.question_id, + models.Question.checklist_id == checklist_id + ).first() + + if question: + old_order = question.order + question.order = item.new_order + question.updated_at = datetime.utcnow() + + # Registrar auditoría + audit_log = models.QuestionAuditLog( + question_id=question.id, + checklist_id=checklist_id, + user_id=current_user.id, + action="updated", + field_name="order", + old_value=str(old_order), + new_value=str(item.new_order), + comment="Orden de pregunta actualizado" + ) + db.add(audit_log) + + db.commit() + + return {"message": "Orden de preguntas actualizado exitosamente", "updated_count": len(reorder_data)} + + @app.delete("/api/questions/{question_id}") def delete_question( question_id: int, @@ -814,9 +1258,97 @@ def delete_question( if not db_question: raise HTTPException(status_code=404, detail="Pregunta no encontrada") - db.delete(db_question) + if db_question.is_deleted: + raise HTTPException(status_code=400, detail="La pregunta ya está eliminada") + + # Registrar auditoría antes de eliminar + audit_log = models.QuestionAuditLog( + question_id=question_id, + checklist_id=db_question.checklist_id, + user_id=current_user.id, + action="deleted", + old_value=f"Pregunta eliminada: {db_question.text}", + comment=f"Sección: {db_question.section}, Tipo: {db_question.type}, Puntos: {db_question.points}" + ) + db.add(audit_log) + + # SOFT DELETE: marcar como eliminada + db_question.is_deleted = True + db_question.updated_at = datetime.utcnow() + + # También marcar como eliminadas todas las subpreguntas (en cascada) + subquestions = db.query(models.Question).filter( + models.Question.parent_question_id == question_id, + models.Question.is_deleted == False + ).all() + + subquestion_count = 0 + for subq in subquestions: + subq.is_deleted = True + subq.updated_at = datetime.utcnow() + subquestion_count += 1 + + # Registrar auditoría de subpregunta + sub_audit_log = models.QuestionAuditLog( + question_id=subq.id, + checklist_id=subq.checklist_id, + user_id=current_user.id, + action="deleted", + old_value=f"Subpregunta eliminada en cascada: {subq.text}", + comment=f"Eliminada junto con pregunta padre #{question_id}" + ) + db.add(sub_audit_log) + db.commit() - return {"message": "Pregunta eliminada"} + + # Recalcular max_score del checklist DESPUÉS del commit + recalculate_checklist_max_score(db_question.checklist_id, db) + db.commit() + + message = "Pregunta eliminada exitosamente" + if subquestion_count > 0: + message += f" junto con {subquestion_count} subpregunta(s)" + + return { + "message": message, + "id": question_id, + "subquestions_deleted": subquestion_count, + "note": "Las respuestas históricas se mantienen intactas. Las preguntas no aparecerán en nuevas inspecciones." + } + + +@app.get("/api/questions/{question_id}/audit", response_model=List[schemas.QuestionAuditLog]) +def get_question_audit_history( + question_id: int, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user) +): + """Obtener historial de cambios de una pregunta""" + if current_user.role != "admin": + raise HTTPException(status_code=403, detail="Solo administradores pueden ver el historial") + + audit_logs = db.query(models.QuestionAuditLog).filter( + models.QuestionAuditLog.question_id == question_id + ).order_by(models.QuestionAuditLog.created_at.desc()).all() + + return audit_logs + + +@app.get("/api/checklists/{checklist_id}/questions/audit", response_model=List[schemas.QuestionAuditLog]) +def get_checklist_questions_audit_history( + checklist_id: int, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user) +): + """Obtener historial de cambios de todas las preguntas de un checklist""" + if current_user.role != "admin": + raise HTTPException(status_code=403, detail="Solo administradores pueden ver el historial") + + audit_logs = db.query(models.QuestionAuditLog).filter( + models.QuestionAuditLog.checklist_id == checklist_id + ).order_by(models.QuestionAuditLog.created_at.desc()).all() + + return audit_logs # ============= INSPECTION ENDPOINTS ============= @@ -856,7 +1388,7 @@ def get_inspection( current_user: models.User = Depends(get_current_user) ): inspection = db.query(models.Inspection).options( - joinedload(models.Inspection.checklist).joinedload(models.Checklist.questions), + joinedload(models.Inspection.checklist), joinedload(models.Inspection.mechanic), joinedload(models.Inspection.answers).joinedload(models.Answer.question), joinedload(models.Inspection.answers).joinedload(models.Answer.media_files) @@ -865,6 +1397,13 @@ def get_inspection( if not inspection: raise HTTPException(status_code=404, detail="Inspección no encontrada") + # Cargar solo preguntas NO eliminadas del checklist + if inspection.checklist: + inspection.checklist.questions = db.query(models.Question).filter( + models.Question.checklist_id == inspection.checklist.id, + models.Question.is_deleted == False + ).order_by(models.Question.order).all() + return inspection @@ -882,8 +1421,12 @@ def create_inspection( if not checklist: raise HTTPException(status_code=404, detail="Checklist no encontrado") + # Crear inspección con el employee_code del mecánico actual + inspection_data = inspection.dict() + inspection_data['mechanic_employee_code'] = current_user.employee_code # Agregar código de operario automáticamente + db_inspection = models.Inspection( - **inspection.dict(), + **inspection_data, mechanic_id=current_user.id, max_score=checklist.max_score ) @@ -915,6 +1458,808 @@ def update_inspection( return db_inspection +async def generate_chat_summary(chat_history: list, question_text: str) -> dict: + """ + Genera un resumen estructurado de una conversación de chat con el asistente IA. + Retorna un dict con: problema_identificado, hallazgos, diagnostico, recomendaciones + """ + import asyncio + import json + import openai + import google.generativeai as genai + + if not chat_history or len(chat_history) == 0: + return { + "problema_identificado": "Sin conversación registrada", + "hallazgos": [], + "diagnostico": "N/A", + "recomendaciones": [] + } + + # Obtener configuración de IA + db = next(get_db()) + config = db.query(models.AIConfiguration).filter(models.AIConfiguration.is_active == True).first() + + if not config: + # Fallback: devolver resumen simple + return { + "problema_identificado": f"Consulta sobre: {question_text}", + "hallazgos": ["Conversación completada con el asistente"], + "diagnostico": "Ver conversación completa en el sistema", + "recomendaciones": ["Revisar historial de chat para detalles"] + } + + # Construir contexto de la conversación + conversation_text = "" + for msg in chat_history: + role = "Mecánico" if msg.get("role") == "user" else "Asistente" + content = msg.get("content", "") + conversation_text += f"{role}: {content}\n\n" + + # Prompt para generar resumen estructurado + summary_prompt = f"""Analiza la siguiente conversación entre un mecánico y un asistente de diagnóstico automotriz, y genera un resumen ejecutivo estructurado para incluir en un informe PDF. + +CONVERSACIÓN: +{conversation_text} + +INSTRUCCIONES: +Genera un resumen profesional en formato JSON con esta estructura exacta: +{{ + "problema_identificado": "Descripción breve del problema o consulta principal (máximo 2 líneas)", + "hallazgos": ["Hallazgo 1", "Hallazgo 2", "Hallazgo 3"], + "diagnostico": "Conclusión técnica del diagnóstico (máximo 3 líneas)", + "recomendaciones": ["Recomendación 1", "Recomendación 2"] +}} + +REGLAS: +- Usa lenguaje técnico pero claro +- Sé conciso y directo +- Si no hay información suficiente para algún campo, usa "N/A" o lista vacía [] +- NO incluyas información que no esté en la conversación +- El JSON debe ser válido y parseable +""" + + try: + # Usar OpenAI, Anthropic o Gemini según configuración + if config.provider == "openai": + client = openai.OpenAI(api_key=config.api_key) + response = await asyncio.to_thread( + client.chat.completions.create, + model=config.model_name or "gpt-4o", + messages=[{"role": "user", "content": summary_prompt}], + temperature=0.3, + max_tokens=800, + response_format={"type": "json_object"} + ) + summary_json = response.choices[0].message.content + + elif config.provider == "anthropic": + import anthropic as anthropic_lib + client = anthropic_lib.Anthropic(api_key=config.api_key) + response = await asyncio.to_thread( + client.messages.create, + model=config.model_name or "claude-sonnet-4-5", + max_tokens=800, + temperature=0.3, + messages=[{"role": "user", "content": summary_prompt + "\n\nRespuesta en formato JSON:"}] + ) + summary_json = response.content[0].text + + elif config.provider == "gemini": + genai.configure(api_key=config.api_key) + model = genai.GenerativeModel( + model_name=config.model_name or "gemini-2.5-pro", + generation_config={ + "temperature": 0.3, + "max_output_tokens": 800, + "response_mime_type": "application/json" + } + ) + response = await asyncio.to_thread(model.generate_content, summary_prompt) + summary_json = response.text + else: + raise Exception("No hay proveedor de IA configurado") + + # Parsear JSON + summary = json.loads(summary_json) + + # Validar estructura + required_keys = ["problema_identificado", "hallazgos", "diagnostico", "recomendaciones"] + for key in required_keys: + if key not in summary: + summary[key] = "N/A" if key in ["problema_identificado", "diagnostico"] else [] + + return summary + + except Exception as e: + print(f"❌ Error generando resumen de chat: {e}") + # Fallback + return { + "problema_identificado": f"Consulta sobre: {question_text}", + "hallazgos": ["Error al generar resumen automático"], + "diagnostico": "Ver conversación completa en el sistema", + "recomendaciones": ["Revisar historial de chat para detalles completos"] + } + + +def generate_inspection_pdf(inspection_id: int, db: Session) -> str: + """ + Genera el PDF de una inspección y lo sube a S3. + Retorna la URL del PDF generado. + """ + from reportlab.lib.pagesizes import A4 + from reportlab.lib import colors + from reportlab.lib.units import inch, mm + from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer, Image as RLImage, PageBreak, KeepTogether + from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle + from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_RIGHT, TA_JUSTIFY + from io import BytesIO + import requests + + inspection = db.query(models.Inspection).filter(models.Inspection.id == inspection_id).first() + if not inspection: + raise HTTPException(status_code=404, detail="Inspección no encontrada") + + buffer = BytesIO() + doc = SimpleDocTemplate( + buffer, + pagesize=A4, + rightMargin=15*mm, + leftMargin=15*mm, + topMargin=20*mm, + bottomMargin=20*mm + ) + + elements = [] + styles = getSampleStyleSheet() + + # Estilos personalizados + title_style = ParagraphStyle( + 'CustomTitle', + parent=styles['Heading1'], + fontSize=24, + textColor=colors.HexColor('#1e3a8a'), + spaceAfter=6, + alignment=TA_CENTER, + fontName='Helvetica-Bold' + ) + + subtitle_style = ParagraphStyle( + 'CustomSubtitle', + parent=styles['Normal'], + fontSize=11, + textColor=colors.HexColor('#475569'), + spaceAfter=20, + alignment=TA_CENTER + ) + + section_header_style = ParagraphStyle( + 'SectionHeader', + parent=styles['Heading2'], + fontSize=14, + textColor=colors.HexColor('#1e40af'), + spaceBefore=16, + spaceAfter=10, + fontName='Helvetica-Bold', + borderWidth=0, + borderColor=colors.HexColor('#3b82f6'), + borderPadding=6, + backColor=colors.HexColor('#eff6ff') + ) + + info_style = ParagraphStyle( + 'InfoStyle', + parent=styles['Normal'], + fontSize=10, + textColor=colors.HexColor('#334155'), + spaceAfter=4 + ) + + small_style = ParagraphStyle( + 'SmallStyle', + parent=styles['Normal'], + fontSize=8, + textColor=colors.HexColor('#64748b') + ) + + # Estilos mejorados para preguntas y respuestas + question_style = ParagraphStyle( + 'QuestionStyle', + parent=styles['Normal'], + fontSize=11, + textColor=colors.HexColor('#1f2937'), + spaceAfter=3, + fontName='Helvetica-Bold' + ) + + answer_style = ParagraphStyle( + 'AnswerStyle', + parent=styles['Normal'], + fontSize=10, + textColor=colors.HexColor('#374151'), + spaceAfter=4 + ) + + comment_style = ParagraphStyle( + 'CommentStyle', + parent=styles['Normal'], + fontSize=9, + textColor=colors.HexColor('#6b7280'), + spaceAfter=6, + leftIndent=10, + rightIndent=10 + ) + + # Obtener datos + mechanic = db.query(models.User).filter(models.User.id == inspection.mechanic_id).first() + checklist = db.query(models.Checklist).filter(models.Checklist.id == inspection.checklist_id).first() + + print(f"🔍 DEBUG: Checklist ID: {inspection.checklist_id}") + print(f"🔍 DEBUG: Checklist obtenido: {checklist}") + if checklist: + print(f"🔍 DEBUG: Checklist.logo_url = {getattr(checklist, 'logo_url', 'NO EXISTE')}") + + # Obtener logo principal de configuración (empresa) + config = db.query(models.AIConfiguration).filter(models.AIConfiguration.is_active == True).first() + company_logo_url = None + if config: + print(f"🔍 Configuración de IA encontrada (ID: {config.id})") + if getattr(config, "logo_url", None): + company_logo_url = config.logo_url + print(f"📸 Logo de la empresa: {company_logo_url}") + else: + print("⚠️ Configuración de IA existe pero no tiene logo_url configurado") + print(" 💡 Ve a Settings y sube el logo de la empresa") + else: + print("⚠️ No hay configuración de IA activa en la base de datos") + print(" 💡 Ve a Settings, configura la IA y sube el logo de la empresa") + + # Obtener logo del checklist (NO usar fallback) + checklist_logo_url = None + if checklist and getattr(checklist, "logo_url", None): + checklist_logo_url = checklist.logo_url + print(f"📋 Logo del checklist: {checklist_logo_url}") + else: + print(f"ℹ️ Checklist sin logo propio") + + print(f"🎯 RESULTADO: company_logo={company_logo_url}, checklist_logo={checklist_logo_url}") + + # ===== PORTADA ===== + elements.append(Spacer(1, 10*mm)) + + # Función helper para cargar y dimensionar logos (optimizada) + def load_logo(logo_url, max_width_mm=45, max_height_mm=35): + """Carga un logo desde URL y retorna objeto Image con dimensiones ajustadas""" + if not logo_url: + return None + try: + # Reducir timeout para respuestas más rápidas + logo_resp = requests.get(logo_url, timeout=5) + + if logo_resp.status_code == 200: + logo_bytes = BytesIO(logo_resp.content) + logo_img = RLImage(logo_bytes) + + # Ajustar tamaño manteniendo aspect ratio + aspect = logo_img.imageHeight / float(logo_img.imageWidth) + logo_width = max_width_mm * mm + logo_height = logo_width * aspect + + # Si la altura excede el máximo, ajustar por altura + if logo_height > max_height_mm * mm: + logo_height = max_height_mm * mm + logo_width = logo_height / aspect + + logo_img.drawWidth = logo_width + logo_img.drawHeight = logo_height + + return logo_img + else: + print(f"❌ Error HTTP cargando logo: {logo_resp.status_code}") + except Exception as e: + print(f"⚠️ Error cargando logo: {str(e)[:100]}") + return None + + # Cargar ambos logos en paralelo usando ThreadPoolExecutor + from concurrent.futures import ThreadPoolExecutor, as_completed + + company_logo = None + checklist_logo = None + + with ThreadPoolExecutor(max_workers=2) as executor: + futures = {} + if company_logo_url: + futures[executor.submit(load_logo, company_logo_url, 50, 35)] = 'company' + if checklist_logo_url: + futures[executor.submit(load_logo, checklist_logo_url, 50, 35)] = 'checklist' + + for future in as_completed(futures): + logo_type = futures[future] + try: + result = future.result() + if logo_type == 'company': + company_logo = result + if result: + print(f"✅ Logo empresa cargado") + elif logo_type == 'checklist': + checklist_logo = result + if result: + print(f"✅ Logo checklist cargado") + except Exception as e: + print(f"❌ Error procesando logo {logo_type}: {e}") + + # Crear tabla con logos en los extremos (ancho total disponible ~180mm) + logo_row = [] + + # Logo empresa (izquierda) + if company_logo: + logo_row.append(company_logo) + else: + logo_row.append(Paragraph("", styles['Normal'])) # Espacio vacío + + # Espaciador central flexible + logo_row.append(Paragraph("", styles['Normal'])) + + # Logo checklist (derecha) + if checklist_logo: + logo_row.append(checklist_logo) + else: + logo_row.append(Paragraph("", styles['Normal'])) # Espacio vacío + + # Crear tabla con logos - columnas ajustadas para maximizar separación + # Columna 1: 55mm (logo empresa), Columna 2: 70mm (espacio), Columna 3: 55mm (logo checklist) + logo_table = Table([logo_row], colWidths=[55*mm, 70*mm, 55*mm]) + logo_table.setStyle(TableStyle([ + ('ALIGN', (0, 0), (0, 0), 'LEFT'), # Logo empresa a la izquierda + ('ALIGN', (1, 0), (1, 0), 'CENTER'), # Centro vacío + ('ALIGN', (2, 0), (2, 0), 'RIGHT'), # Logo checklist a la derecha + ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), # Alineación vertical al centro + # DEBUG: Agregar bordes para ver la distribución + # ('GRID', (0, 0), (-1, -1), 0.5, colors.red), + ])) + elements.append(logo_table) + elements.append(Spacer(1, 5*mm)) + + # Título con diseño moderno + elements.append(Paragraph("📋 INFORME DE INSPECCIÓN VEHICULAR", title_style)) + elements.append(Paragraph(f"N° {inspection.id}", subtitle_style)) + elements.append(Spacer(1, 10*mm)) + + # Estilo para etiquetas de información + label_style = ParagraphStyle( + 'LabelStyle', + parent=styles['Normal'], + fontSize=9, + textColor=colors.HexColor('#64748b'), + spaceAfter=2 + ) + + value_style = ParagraphStyle( + 'ValueStyle', + parent=styles['Normal'], + fontSize=11, + textColor=colors.HexColor('#1e293b'), + fontName='Helvetica-Bold' + ) + + # Cuadro de información del vehículo con diseño moderno + vehicle_header = Table( + [[Paragraph("🚗 INFORMACIÓN DEL VEHÍCULO", ParagraphStyle('veh_header', parent=info_style, fontSize=12, textColor=colors.white))]], + colWidths=[85*mm] + ) + vehicle_header.setStyle(TableStyle([ + ('BACKGROUND', (0, 0), (-1, -1), colors.HexColor('#2563eb')), + ('PADDING', (0, 0), (-1, -1), 10), + ('ALIGN', (0, 0), (-1, -1), 'LEFT'), + ('ROUNDEDCORNERS', [6, 6, 0, 0]), + ])) + + vehicle_content = Table([ + [Paragraph("Placa", label_style), Paragraph(f"{inspection.vehicle_plate}", value_style)], + [Paragraph("Marca", label_style), Paragraph(f"{inspection.vehicle_brand or 'N/A'}", value_style)], + [Paragraph("Modelo", label_style), Paragraph(f"{inspection.vehicle_model or 'N/A'}", value_style)], + [Paragraph("Kilometraje", label_style), Paragraph(f"{inspection.vehicle_km or 'N/A'} km", value_style)] + ], colWidths=[25*mm, 60*mm]) + vehicle_content.setStyle(TableStyle([ + ('PADDING', (0, 0), (-1, -1), 10), + ('BACKGROUND', (0, 0), (-1, -1), colors.white), + ('VALIGN', (0, 0), (-1, -1), 'TOP'), + ('LINEBELOW', (0, 0), (-1, -2), 0.5, colors.HexColor('#e2e8f0')), + ])) + + vehicle_table = Table( + [[vehicle_header], [vehicle_content]], + colWidths=[85*mm] + ) + vehicle_table.setStyle(TableStyle([ + ('BOX', (0, 0), (-1, -1), 1.5, colors.HexColor('#2563eb')), + ('VALIGN', (0, 0), (-1, -1), 'TOP'), + ('ROUNDEDCORNERS', [6, 6, 6, 6]), + ])) + + # Cuadro de información del cliente e inspección (sin nombre de mecánico por privacidad) + client_header = Table( + [[Paragraph("📄 INFORMACIÓN DE LA INSPECCIÓN", ParagraphStyle('client_header', parent=info_style, fontSize=12, textColor=colors.white))]], + colWidths=[85*mm] + ) + client_header.setStyle(TableStyle([ + ('BACKGROUND', (0, 0), (-1, -1), colors.HexColor('#16a34a')), + ('PADDING', (0, 0), (-1, -1), 10), + ('ALIGN', (0, 0), (-1, -1), 'LEFT'), + ('ROUNDEDCORNERS', [6, 6, 0, 0]), + ])) + + client_content = Table([ + [Paragraph("Nº Pedido", label_style), Paragraph(f"{inspection.order_number or 'N/A'}", value_style)], + [Paragraph("OR N°", label_style), Paragraph(f"{inspection.or_number or 'N/A'}", value_style)], + [Paragraph("Cód. Operario", label_style), Paragraph(f"{inspection.mechanic_employee_code or 'N/A'}", value_style)], + [Paragraph("Fecha", label_style), Paragraph(f"{inspection.started_at.strftime('%d/%m/%Y %H:%M') if inspection.started_at else 'N/A'}", value_style)] + ], colWidths=[25*mm, 60*mm]) + client_content.setStyle(TableStyle([ + ('PADDING', (0, 0), (-1, -1), 10), + ('BACKGROUND', (0, 0), (-1, -1), colors.white), + ('VALIGN', (0, 0), (-1, -1), 'TOP'), + ('LINEBELOW', (0, 0), (-1, -2), 0.5, colors.HexColor('#e2e8f0')), + ])) + + inspection_info_table = Table( + [[client_header], [client_content]], + colWidths=[85*mm] + ) + inspection_info_table.setStyle(TableStyle([ + ('BOX', (0, 0), (-1, -1), 1.5, colors.HexColor('#16a34a')), + ('VALIGN', (0, 0), (-1, -1), 'TOP'), + ('ROUNDEDCORNERS', [6, 6, 6, 6]), + ])) + + # Tabla con ambos cuadros lado a lado + info_table = Table([[vehicle_table, inspection_info_table]], colWidths=[90*mm, 90*mm]) + info_table.setStyle(TableStyle([ + ('ALIGN', (0, 0), (-1, -1), 'CENTER'), + ('VALIGN', (0, 0), (-1, -1), 'TOP'), + ])) + elements.append(info_table) + elements.append(Spacer(1, 8*mm)) + + # Resumen de puntuación con diseño mejorado + percentage = inspection.percentage + score_color = colors.HexColor('#22c55e') if percentage >= 80 else colors.HexColor('#eab308') if percentage >= 60 else colors.HexColor('#ef4444') + score_label = "EXCELENTE" if percentage >= 80 else "ACEPTABLE" if percentage >= 60 else "DEFICIENTE" + + # Título de resumen + score_title = Table( + [[Paragraph("📊 RESUMEN DE EVALUACIÓN", ParagraphStyle('score_title', parent=info_style, fontSize=14, textColor=colors.HexColor('#1e293b'), alignment=TA_CENTER))]], + colWidths=[180*mm] + ) + score_title.setStyle(TableStyle([ + ('PADDING', (0, 0), (-1, -1), 8), + ('ALIGN', (0, 0), (-1, -1), 'CENTER'), + ])) + elements.append(score_title) + elements.append(Spacer(1, 3*mm)) + + # Cuadro de métricas con diseño moderno + metric_label = ParagraphStyle('metric_label', parent=small_style, fontSize=10, textColor=colors.HexColor('#64748b'), alignment=TA_CENTER) + metric_value = ParagraphStyle('metric_value', parent=info_style, fontSize=18, fontName='Helvetica-Bold', alignment=TA_CENTER) + + metrics_data = [ + [Paragraph("Puntuación", metric_label), Paragraph("Ítems Críticos", metric_label)], + [ + Paragraph(f"{inspection.score} / {inspection.max_score}", metric_value), + Paragraph(f"{inspection.flagged_items_count}", metric_value) + ] + ] + + score_table = Table(metrics_data, colWidths=[90*mm, 90*mm], rowHeights=[12*mm, 18*mm]) + score_table.setStyle(TableStyle([ + ('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#f8fafc')), + ('BACKGROUND', (0, 1), (-1, -1), colors.white), + ('ALIGN', (0, 0), (-1, -1), 'CENTER'), + ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), + ('PADDING', (0, 0), (-1, -1), 16), + ('BOX', (0, 0), (-1, -1), 2, score_color), + ('LINEABOVE', (0, 1), (-1, 1), 1.5, score_color), + ('GRID', (0, 0), (-1, -1), 0.5, colors.HexColor('#e2e8f0')), + ('ROUNDEDCORNERS', [8, 8, 8, 8]), + ])) + elements.append(score_table) + elements.append(PageBreak()) + + # ===== DETALLE DE RESPUESTAS ===== + elements.append(Paragraph("📝 DETALLE DE LA INSPECCIÓN", section_header_style)) + elements.append(Spacer(1, 5*mm)) + + # Obtener respuestas agrupadas por sección - SOLO de preguntas NO eliminadas + answers = db.query(models.Answer).options( + joinedload(models.Answer.media_files), + joinedload(models.Answer.question) + ).join(models.Question).filter( + models.Answer.inspection_id == inspection_id, + models.Question.is_deleted == False # Excluir preguntas eliminadas + ).order_by( + models.Question.section, + models.Question.order + ).all() + + # Función helper para convertir valores técnicos a etiquetas legibles + def get_readable_answer(answer_value, question_options): + """ + Convierte el valor técnico de la respuesta a su etiqueta legible. + Ej: 'option1' -> 'Bueno', 'pass' -> 'Pasa' + """ + if not answer_value or not question_options: + return answer_value or 'Sin respuesta' + + config = question_options + question_type = config.get('type', '') + + # Para tipos con choices (boolean, single_choice, multiple_choice) + if question_type in ['boolean', 'single_choice', 'multiple_choice'] and config.get('choices'): + # Si es multiple_choice, puede tener varios valores separados por coma + if question_type == 'multiple_choice' and ',' in answer_value: + values = answer_value.split(',') + labels = [] + for val in values: + val = val.strip() + choice = next((c for c in config['choices'] if c.get('value') == val), None) + if choice: + labels.append(choice.get('label', val)) + else: + labels.append(val) + return ', '.join(labels) + else: + # Buscar la etiqueta correspondiente al valor + choice = next((c for c in config['choices'] if c.get('value') == answer_value), None) + if choice: + return choice.get('label', answer_value) + + # Para tipos scale, text, number, date, time - devolver el valor tal cual + return answer_value + + current_section = None + for ans in answers: + question = ans.question + + # Nueva sección + if question.section != current_section: + if current_section is not None: + elements.append(Spacer(1, 5*mm)) + current_section = question.section + elements.append(Paragraph(f"▶ {question.section or 'General'}", section_header_style)) + elements.append(Spacer(1, 3*mm)) + + # Detectar si es pregunta con chat assistant + is_ai_assistant = question.options and question.options.get('type') == 'ai_assistant' + + # Detectar tipo de pregunta para determinar si debe mostrar estado + question_type = question.options.get('type') if question.options else question.type + + # Tipos de pregunta que NO deben mostrar estado de color (son informativas/texto libre) + text_based_types = ['text', 'number', 'date', 'time', 'photo_only', 'ai_assistant'] + should_show_status = question_type not in text_based_types + + # Estado visual (solo para preguntas con estado) + status_colors = { + 'ok': colors.HexColor('#22c55e'), + 'warning': colors.HexColor('#eab308'), + 'critical': colors.HexColor('#ef4444') + } + status_icons = { + 'ok': '✓', + 'warning': '⚠', + 'critical': '✕' + } + + if should_show_status: + status_color = status_colors.get(ans.status, colors.HexColor('#64748b')) + status_icon = status_icons.get(ans.status, '●') + else: + # Para preguntas de texto/info, usar estilo neutral + status_color = colors.HexColor('#64748b') + status_icon = '📝' + + # Tabla de pregunta/respuesta + question_data = [] + + # Fila 1: Pregunta con estilo mejorado + question_data.append([ + Paragraph(f"{status_icon} {question.text}", question_style), + ]) + + # ===== LÓGICA ESPECIAL PARA AI_ASSISTANT ===== + if is_ai_assistant and ans.chat_history: + # Mostrar resumen simple SIN generar con IA (para evitar lentitud y peso) + try: + chat_data = ans.chat_history if isinstance(ans.chat_history, list) else json.loads(ans.chat_history) + total_messages = len(chat_data) + user_messages = sum(1 for m in chat_data if m.get('role') == 'user') + assistant_messages = sum(1 for m in chat_data if m.get('role') == 'assistant') + + question_data.append([ + Paragraph(f"💬 DIAGNÓSTICO ASISTIDO POR IA", + ParagraphStyle('chat_title', parent=answer_style, fontSize=11, + textColor=colors.HexColor('#2563eb'), fontName='Helvetica-Bold')) + ]) + + question_data.append([ + Paragraph(f"📊 Resumen de Conversación:
" + f"• Total de mensajes: {total_messages}
" + f"• Consultas del mecánico: {user_messages}
" + f"• Respuestas del asistente: {assistant_messages}

" + f"Nota: El historial completo está disponible en el sistema para administradores.", + comment_style) + ]) + + except Exception as e: + print(f"❌ Error procesando chat en PDF: {e}") + # Fallback: mostrar que hubo conversación + question_data.append([ + Table([ + [ + Paragraph(f"Respuesta: Diagnóstico asistido completado", answer_style), + Paragraph(f"Estado: {ans.status.upper()}", + ParagraphStyle('status', parent=answer_style, + textColor=status_color, fontName='Helvetica-Bold')) + ] + ], colWidths=[120*mm, 50*mm]) + ]) + question_data.append([ + Paragraph(f"ℹ️ Nota: Ver historial de conversación completo en el sistema", + comment_style) + ]) + + # ===== LÓGICA NORMAL PARA OTROS TIPOS ===== + else: + # Fila 2: Respuesta - Convertir valor técnico a etiqueta legible + answer_text = get_readable_answer(ans.answer_value, question.options) + + # Solo mostrar estado para preguntas que lo requieren (no texto libre) + if should_show_status: + question_data.append([ + Table([ + [ + Paragraph(f"Respuesta: {answer_text}", answer_style), + Paragraph(f"Estado: {ans.status.upper()}", + ParagraphStyle('status', parent=answer_style, + textColor=status_color, fontName='Helvetica-Bold')) + ] + ], colWidths=[120*mm, 50*mm]) + ]) + else: + # Para preguntas de texto, solo mostrar la respuesta sin estado + question_data.append([ + Paragraph(f"Respuesta: {answer_text}", answer_style) + ]) + + # Fila 3: Comentario mejorado (si existe) + if ans.comment: + comment_text = ans.comment + + # Limpiar prefijo de análisis automático/IA si existe (con cualquier porcentaje) + import re + # Patrón para detectar "Análisis Automático (XX% confianza): " o "Análisis IA (XX% confianza): " + comment_text = re.sub(r'^(Análisis Automático|Análisis IA)\s*\(\d+%\s*confianza\):\s*', '', comment_text) + # También remover variantes sin emoji + comment_text = re.sub(r'^🤖\s*(Análisis Automático|Análisis IA)\s*\(\d+%\s*confianza\):\s*', '', comment_text) + + # Separar análisis y recomendaciones con salto de línea + if "Recomendaciones:" in comment_text or "Recomendación:" in comment_text: + comment_text = comment_text.replace("Recomendaciones:", "

Recomendaciones:") + comment_text = comment_text.replace("Recomendación:", "

Recomendación:") + + question_data.append([ + Paragraph(f"Comentario: {comment_text}", comment_style) + ]) + + # Fila 4: Imágenes (si existen) - COMÚN PARA TODOS LOS TIPOS + if ans.media_files: + media_imgs = [] + for media in ans.media_files: + if media.file_type == "image": + try: + img_resp = requests.get(media.file_path, timeout=10) + if img_resp.status_code == 200: + img_bytes = BytesIO(img_resp.content) + rl_img = RLImage(img_bytes, width=25*mm, height=25*mm) + media_imgs.append(rl_img) + except Exception as e: + print(f"Error cargando imagen {media.file_path}: {e}") + + if media_imgs: + # Crear tabla de miniaturas (máximo 6 por fila) + img_rows = [] + for i in range(0, len(media_imgs), 6): + img_rows.append(media_imgs[i:i+6]) + + img_table = Table(img_rows) + img_table.setStyle(TableStyle([ + ('ALIGN', (0, 0), (-1, -1), 'LEFT'), + ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), + ('PADDING', (0, 0), (-1, -1), 2), + ])) + question_data.append([img_table]) + + # Tabla de la pregunta completa + q_table = Table(question_data, colWidths=[180*mm]) + q_table.setStyle(TableStyle([ + ('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#f1f5f9')), + ('PADDING', (0, 0), (-1, -1), 6), + ('BOX', (0, 0), (-1, -1), 0.5, colors.HexColor('#cbd5e1')), + ('LEFTPADDING', (0, 0), (-1, -1), 8), + ('VALIGN', (0, 0), (-1, -1), 'TOP'), + ])) + + elements.append(KeepTogether(q_table)) + elements.append(Spacer(1, 3*mm)) + + # ===== FIRMA ===== + if inspection.signature_data: + elements.append(PageBreak()) + elements.append(Spacer(1, 10*mm)) + elements.append(Paragraph("✍️ FIRMA DEL OPERARIO", section_header_style)) + elements.append(Spacer(1, 5*mm)) + + try: + # Decodificar firma base64 + import base64 + signature_bytes = base64.b64decode(inspection.signature_data.split(',')[1] if ',' in inspection.signature_data else inspection.signature_data) + signature_img_buffer = BytesIO(signature_bytes) + signature_img = RLImage(signature_img_buffer, width=80*mm, height=40*mm) + + # Tabla con la firma y datos + signature_data = [ + [signature_img], + [Paragraph(f"Operario: {inspection.mechanic_employee_code or 'N/A'}", info_style)], + [Paragraph(f"Fecha de finalización: {inspection.completed_at.strftime('%d/%m/%Y %H:%M') if inspection.completed_at else 'N/A'}", info_style)] + ] + + signature_table = Table(signature_data, colWidths=[180*mm]) + signature_table.setStyle(TableStyle([ + ('ALIGN', (0, 0), (-1, -1), 'CENTER'), + ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), + ('LINEABOVE', (0, 0), (0, 0), 1, colors.HexColor('#cbd5e1')), + ('PADDING', (0, 0), (-1, -1), 8), + ])) + + elements.append(signature_table) + print(f"✅ Firma agregada al PDF") + except Exception as e: + print(f"⚠️ Error agregando firma al PDF: {e}") + elements.append(Paragraph( + f"Error al cargar la firma", + ParagraphStyle('error', parent=small_style, alignment=TA_CENTER, textColor=colors.HexColor('#ef4444')) + )) + + # ===== FOOTER ===== + elements.append(Spacer(1, 10*mm)) + elements.append(Paragraph( + f"Documento generado automáticamente por Checklist Inteligente el {datetime.now().strftime('%d/%m/%Y a las %H:%M')}", + ParagraphStyle('footer', parent=small_style, alignment=TA_CENTER, textColor=colors.HexColor('#94a3b8')) + )) + + # Generar PDF + try: + doc.build(elements) + except Exception as e: + print(f"❌ Error al generar PDF: {e}") + import traceback + traceback.print_exc() + raise HTTPException(status_code=500, detail=f"Error al generar PDF: {str(e)}") + + # Subir a S3 + buffer.seek(0) + now = datetime.now() + folder = f"{now.year}/{now.month:02d}" + filename = f"inspeccion_{inspection_id}_{inspection.vehicle_plate or 'sin-patente'}.pdf" + s3_key = f"{folder}/{filename}" + buffer.seek(0) + s3_client.upload_fileobj(buffer, S3_PDF_BUCKET, s3_key, ExtraArgs={"ContentType": "application/pdf"}) + pdf_url = f"{S3_ENDPOINT}/{S3_PDF_BUCKET}/{s3_key}" + + print(f"✅ PDF generado y subido a S3: {pdf_url}") + return pdf_url + + @app.post("/api/inspections/{inspection_id}/complete", response_model=schemas.Inspection) def complete_inspection( inspection_id: int, @@ -928,8 +2273,11 @@ def complete_inspection( if not inspection: raise HTTPException(status_code=404, detail="Inspección no encontrada") - # Calcular score - answers = db.query(models.Answer).filter(models.Answer.inspection_id == inspection_id).all() + # Calcular score - SOLO de preguntas NO eliminadas + answers = db.query(models.Answer).join(models.Question).filter( + models.Answer.inspection_id == inspection_id, + models.Question.is_deleted == False # Excluir preguntas eliminadas + ).all() total_score = sum(a.points_earned for a in answers) flagged_count = sum(1 for a in answers if a.is_flagged) @@ -939,87 +2287,21 @@ def complete_inspection( inspection.status = "completed" inspection.completed_at = datetime.utcnow() - # Generar PDF con miniaturas de imágenes y subir a MinIO - from reportlab.lib.pagesizes import A4 - from reportlab.lib import colors - from reportlab.lib.units import inch - from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer, Image as RLImage - from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle - from reportlab.lib.enums import TA_CENTER - from io import BytesIO - import requests - buffer = BytesIO() - doc = SimpleDocTemplate(buffer, pagesize=A4, rightMargin=30, leftMargin=30, topMargin=30, bottomMargin=30) - elements = [] - styles = getSampleStyleSheet() - title_style = styles['Title'] - normal_style = styles['Normal'] - header_style = ParagraphStyle('Header', parent=styles['Heading2'], alignment=TA_CENTER, spaceAfter=12) - # Portada - elements.append(Paragraph(f"Informe de Inspección #{inspection.id}", title_style)) - elements.append(Spacer(1, 12)) - elements.append(Paragraph(f"Vehículo: {inspection.vehicle_brand or ''} {inspection.vehicle_model or ''} - Placa: {inspection.vehicle_plate}", normal_style)) - elements.append(Paragraph(f"Cliente: {inspection.client_name or ''}", normal_style)) - mechanic = db.query(models.User).filter(models.User.id == inspection.mechanic_id).first() - checklist = db.query(models.Checklist).filter(models.Checklist.id == inspection.checklist_id).first() - elements.append(Paragraph(f"Mecánico: {mechanic.full_name if mechanic else ''}", normal_style)) - elements.append(Paragraph(f"Checklist: {checklist.name if checklist else ''}", normal_style)) - elements.append(Paragraph(f"Fecha: {inspection.started_at.strftime('%d/%m/%Y %H:%M') if inspection.started_at else ''}", normal_style)) - elements.append(Spacer(1, 18)) - # Tabla de respuestas con miniaturas - answers = db.query(models.Answer).options(joinedload(models.Answer.media_files)).join(models.Question).filter(models.Answer.inspection_id == inspection_id).order_by(models.Question.section, models.Question.order).all() - table_data = [["Sección", "Pregunta", "Respuesta", "Estado", "Comentario", "Miniaturas"]] - for ans in answers: - question = ans.question - media_imgs = [] - for media in ans.media_files: - if media.file_type == "image": - try: - img_resp = requests.get(media.file_path) - if img_resp.status_code == 200: - img_bytes = BytesIO(img_resp.content) - rl_img = RLImage(img_bytes, width=0.7*inch, height=0.7*inch) - media_imgs.append(rl_img) - except Exception as e: - print(f"Error cargando imagen {media.file_path}: {e}") - row = [ - question.section or "", - question.text, - ans.answer_value, - ans.status, - ans.comment or "", - media_imgs if media_imgs else "" - ] - table_data.append(row) - table = Table(table_data, colWidths=[1.2*inch, 2.5*inch, 1*inch, 0.8*inch, 2*inch, 1.5*inch]) - table.setStyle(TableStyle([ - ('BACKGROUND', (0,0), (-1,0), colors.lightgrey), - ('TEXTCOLOR', (0,0), (-1,0), colors.black), - ('ALIGN', (0,0), (-1,-1), 'LEFT'), - ('VALIGN', (0,0), (-1,-1), 'TOP'), - ('FONTNAME', (0,0), (-1,0), 'Helvetica-Bold'), - ('FONTSIZE', (0,0), (-1,0), 10), - ('BOTTOMPADDING', (0,0), (-1,0), 8), - ('GRID', (0,0), (-1,-1), 0.5, colors.grey), - ])) - elements.append(table) - elements.append(Spacer(1, 18)) - elements.append(Paragraph(f"Generado por Checklist Inteligente - {datetime.now().strftime('%d/%m/%Y %H:%M')}", header_style)) - try: - doc.build(elements) - except Exception as e: - print(f"Error al generar PDF: {e}") - buffer.seek(0) - now = datetime.now() - folder = f"{now.year}/{now.month:02d}" - filename = f"inspeccion_{inspection_id}_{inspection.vehicle_plate or 'sin-patente'}.pdf" - s3_key = f"{folder}/{filename}" - buffer.seek(0) - s3_client.upload_fileobj(buffer, S3_PDF_BUCKET, s3_key, ExtraArgs={"ContentType": "application/pdf"}) - pdf_url = f"{S3_ENDPOINT}/{S3_PDF_BUCKET}/{s3_key}" - inspection.pdf_url = pdf_url + # Generar PDF solo si el checklist lo tiene habilitado + if inspection.checklist.generate_pdf: + pdf_url = generate_inspection_pdf(inspection_id, db) + inspection.pdf_url = pdf_url + print(f"✅ PDF generado para inspección #{inspection_id}") + else: + inspection.pdf_url = None + print(f"⏭️ PDF NO generado (deshabilitado en checklist) para inspección #{inspection_id}") + db.commit() db.refresh(inspection) + + # Enviar inspección completa a n8n con todas las respuestas e imágenes + send_completed_inspection_to_n8n(inspection, db) + return inspection @@ -1062,12 +2344,12 @@ def create_answer( if not question: raise HTTPException(status_code=404, detail="Pregunta no encontrada") - # Calcular puntos según status + # Sistema simplificado: 1 punto por pregunta correcta points_earned = 0 if answer.status == "ok": - points_earned = question.points + points_earned = 1 elif answer.status == "warning": - points_earned = int(question.points * 0.5) + points_earned = 0.5 # Buscar si ya existe una respuesta para esta inspección y pregunta existing_answer = db.query(models.Answer).filter( @@ -1090,9 +2372,16 @@ def create_answer( db.commit() db.refresh(existing_answer) - # Enviar notificación si la pregunta lo requiere - if question.send_notification: + + # Solo enviar si tiene valor real (no vacío ni None) + if question.send_notification and answer.answer_value: + print(f"✅ Enviando notificación para pregunta #{question.id}") send_answer_notification(existing_answer, question, current_user, db) + else: + if not question.send_notification: + print(f"❌ NO se envía notificación (send_notification=False) para pregunta #{question.id}") + else: + print(f"⏭️ NO se envía notificación (respuesta vacía) para pregunta #{question.id}") return existing_answer else: @@ -1108,9 +2397,17 @@ def create_answer( db.commit() db.refresh(db_answer) - # Enviar notificación si la pregunta lo requiere - if question.send_notification: + + + # Solo enviar si tiene valor real (no vacío ni None) + if question.send_notification and answer.answer_value: + print(f"✅ Enviando notificación para pregunta #{question.id}") send_answer_notification(db_answer, question, current_user, db) + else: + if not question.send_notification: + print(f"❌ NO se envía notificación (send_notification=False) para pregunta #{question.id}") + else: + print(f"⏭️ NO se envía notificación (respuesta vacía) para pregunta #{question.id}") return db_answer @@ -1127,6 +2424,14 @@ def update_answer( if not db_answer: raise HTTPException(status_code=404, detail="Respuesta no encontrada") + # Obtener la inspección para verificar si está completada + inspection = db.query(models.Inspection).filter( + models.Inspection.id == db_answer.inspection_id + ).first() + + if not inspection: + raise HTTPException(status_code=404, detail="Inspección no encontrada") + # Recalcular puntos si cambió el status if answer.status and answer.status != db_answer.status: question = db.query(models.Question).filter( @@ -1145,6 +2450,33 @@ def update_answer( db.commit() db.refresh(db_answer) + + # Si la inspección está completada, regenerar PDF con los cambios + if inspection.status == "completed": + print(f"🔄 Regenerando PDF para inspección completada #{inspection.id}") + + # Recalcular score de la inspección - SOLO de preguntas NO eliminadas + answers = db.query(models.Answer).join(models.Question).filter( + models.Answer.inspection_id == inspection.id, + models.Question.is_deleted == False # Excluir preguntas eliminadas + ).all() + + inspection.score = sum(a.points_earned for a in answers) + inspection.percentage = (inspection.score / inspection.max_score * 100) if inspection.max_score > 0 else 0 + inspection.flagged_items_count = sum(1 for a in answers if a.is_flagged) + + # Regenerar PDF + try: + pdf_url = generate_inspection_pdf(inspection.id, db) + inspection.pdf_url = pdf_url + db.commit() + print(f"✅ PDF regenerado exitosamente: {pdf_url}") + except Exception as e: + print(f"❌ Error regenerando PDF: {e}") + import traceback + traceback.print_exc() + # No lanzamos excepción para no interrumpir la actualización de la respuesta + return db_answer @@ -1281,6 +2613,36 @@ def admin_edit_answer( db.commit() db.refresh(db_answer) + # Si la inspección está completada, regenerar PDF con los cambios + inspection = db.query(models.Inspection).filter( + models.Inspection.id == db_answer.inspection_id + ).first() + + if inspection and inspection.status == "completed": + print(f"🔄 Regenerando PDF para inspección completada #{inspection.id} (admin-edit)") + + # Recalcular score de la inspección - SOLO de preguntas NO eliminadas + answers = db.query(models.Answer).join(models.Question).filter( + models.Answer.inspection_id == inspection.id, + models.Question.is_deleted == False # Excluir preguntas eliminadas + ).all() + + inspection.score = sum(a.points_earned for a in answers) + inspection.percentage = (inspection.score / inspection.max_score * 100) if inspection.max_score > 0 else 0 + inspection.flagged_items_count = sum(1 for a in answers if a.is_flagged) + + # Regenerar PDF + try: + pdf_url = generate_inspection_pdf(inspection.id, db) + inspection.pdf_url = pdf_url + db.commit() + print(f"✅ PDF regenerado exitosamente: {pdf_url}") + except Exception as e: + print(f"❌ Error regenerando PDF: {e}") + import traceback + traceback.print_exc() + # No lanzamos excepción para no interrumpir la actualización de la respuesta + return db_answer @@ -1387,6 +2749,25 @@ def get_available_ai_models(current_user: models.User = Depends(get_current_user "name": "Gemini 1.5 Flash Latest", "provider": "gemini", "description": "Modelo 1.5 rápido para análisis básicos" + }, + # Anthropic Claude Models + { + "id": "claude-sonnet-4-5", + "name": "Claude Sonnet 4.5 (Recomendado)", + "provider": "anthropic", + "description": "Equilibrio perfecto entre velocidad e inteligencia, ideal para diagnósticos automotrices" + }, + { + "id": "claude-opus-4-5", + "name": "Claude Opus 4.5", + "provider": "anthropic", + "description": "Máxima capacidad para análisis complejos y razonamiento profundo" + }, + { + "id": "claude-haiku-4-5", + "name": "Claude Haiku 4.5", + "provider": "anthropic", + "description": "Ultra rápido y económico, perfecto para análisis en tiempo real" } ] @@ -1412,42 +2793,86 @@ def get_ai_configuration( return config +@app.get("/api/ai/api-keys") +def get_all_api_keys( + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user) +): + """Obtener todas las API keys guardadas (sin mostrar las keys completas)""" + if current_user.role != "admin": + raise HTTPException(status_code=403, detail="Solo administradores pueden ver API keys") + + configs = db.query(models.AIConfiguration).all() + + result = {} + for config in configs: + # Solo devolver si tiene API key guardada (enmascarada) + if config.api_key: + masked_key = config.api_key[:8] + "..." + config.api_key[-4:] if len(config.api_key) > 12 else "***" + result[config.provider] = { + "has_key": True, + "masked_key": masked_key, + "is_active": config.is_active + } + + return result + + @app.post("/api/ai/configuration", response_model=schemas.AIConfiguration) def create_ai_configuration( config: schemas.AIConfigurationCreate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user) ): - """Crear o actualizar configuración de IA""" + """Crear o actualizar configuración de IA - ACTIVA el proveedor seleccionado""" if current_user.role != "admin": raise HTTPException(status_code=403, detail="Solo administradores pueden configurar IA") - # Desactivar configuraciones anteriores + # Desactivar TODAS las configuraciones db.query(models.AIConfiguration).update({"is_active": False}) - # Determinar modelo por defecto según el proveedor si no se especifica + # Buscar si ya existe configuración para este proveedor + existing_config = db.query(models.AIConfiguration).filter( + models.AIConfiguration.provider == config.provider + ).first() + + # Determinar modelo por defecto si no se especifica model_name = config.model_name if not model_name: if config.provider == "openai": model_name = "gpt-4o" elif config.provider == "gemini": model_name = "gemini-2.5-pro" + elif config.provider == "anthropic": + model_name = "claude-sonnet-4-5" else: model_name = "default" - # Crear nueva configuración - new_config = models.AIConfiguration( - provider=config.provider, - api_key=config.api_key, - model_name=model_name, - is_active=True - ) - - db.add(new_config) - db.commit() - db.refresh(new_config) - - return new_config + if existing_config: + # Actualizar configuración existente + # Solo actualizar API key si se proporciona una nueva (no vacía) + if config.api_key and config.api_key.strip(): + existing_config.api_key = config.api_key + existing_config.model_name = model_name + existing_config.is_active = True # Activar este proveedor + db.commit() + db.refresh(existing_config) + return existing_config + else: + # Crear nueva configuración (requiere API key) + if not config.api_key or not config.api_key.strip(): + raise HTTPException(status_code=400, detail="API key es requerida para nuevo proveedor") + + new_config = models.AIConfiguration( + provider=config.provider, + api_key=config.api_key, + model_name=model_name, + is_active=True # Activar este proveedor + ) + db.add(new_config) + db.commit() + db.refresh(new_config) + return new_config @app.put("/api/ai/configuration/{config_id}", response_model=schemas.AIConfiguration) @@ -1504,15 +2929,26 @@ def delete_ai_configuration( @app.post("/api/analyze-image") async def analyze_image( file: UploadFile = File(...), - question_id: int = None, - custom_prompt: str = None, + question_id: int = Form(None), + inspection_id: int = Form(None), + custom_prompt: str = Form(None), db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user) ): """ Analiza una imagen usando IA para sugerir respuestas Usa la configuración de IA activa (OpenAI o Gemini) + Incluye contexto del vehículo si se proporciona inspection_id """ + print("\n" + "="*80) + print("🔍 ANALYZE IMAGE - DEBUG") + print("="*80) + print(f"📥 Parámetros recibidos:") + print(f" - file: {file.filename}") + print(f" - question_id: {question_id}") + print(f" - inspection_id: {inspection_id}") + print(f" - custom_prompt (del Form): {custom_prompt[:100] if custom_prompt else 'NO RECIBIDO'}") + # Obtener configuración de IA activa ai_config = db.query(models.AIConfiguration).filter( models.AIConfiguration.is_active == True @@ -1524,44 +2960,137 @@ async def analyze_image( "message": "No hay configuración de IA activa. Configure en Settings." } - # Guardar imagen temporalmente + # Guardar archivo temporalmente y procesar según tipo import base64 contents = await file.read() - image_b64 = base64.b64encode(contents).decode('utf-8') + file_type = file.content_type + + print(f"📄 Tipo de archivo: {file_type}") + + # Detectar si es PDF + is_pdf = file_type == 'application/pdf' or file.filename.lower().endswith('.pdf') + + if is_pdf: + print("📕 Detectado PDF - extrayendo texto...") + + # Usar función inteligente de extracción + # Para análisis de imagen usamos hasta 100k caracteres (Gemini soporta mucho más) + pdf_result = extract_pdf_text_smart(contents, max_chars=100000) + + if not pdf_result['success']: + return { + "status": "error", + "message": f"Error al procesar PDF: {pdf_result.get('error', 'Unknown')}" + } + + pdf_text = pdf_result['text'] + print(f"✅ Texto extraído: {pdf_result['total_chars']} caracteres de {pdf_result['pages_processed']}/{pdf_result['pages']} páginas") + if pdf_result['truncated']: + print(f"⚠️ PDF truncado a 100k caracteres") + + if not pdf_text.strip(): + return { + "status": "error", + "message": "No se pudo extraer texto del PDF. Puede ser un PDF escaneado sin OCR." + } + + # Para PDFs usamos análisis de texto, no de imagen + image_b64 = None + else: + # Es una imagen + image_b64 = base64.b64encode(contents).decode('utf-8') + pdf_text = None # Obtener contexto de la pregunta si se proporciona question_obj = None + question_options = [] if question_id: question_obj = db.query(models.Question).filter(models.Question.id == question_id).first() + print(f"📋 Pregunta encontrada:") + print(f" - ID: {question_obj.id}") + print(f" - Texto: {question_obj.text}") + print(f" - Tipo: {question_obj.options.get('type') if question_obj.options else 'N/A'}") + print(f" - ai_prompt en DB: {question_obj.ai_prompt[:100] if question_obj.ai_prompt else 'NO TIENE'}") + + # Extraer opciones de respuesta si existen + if question_obj.options and 'options' in question_obj.options: + question_options = question_obj.options['options'] + print(f" - Opciones disponibles: {question_options}") + + # Si no se proporciona custom_prompt en el Form, usar el de la pregunta + if not custom_prompt and question_obj and question_obj.ai_prompt: + custom_prompt = question_obj.ai_prompt + print(f"✅ Usando ai_prompt de la pregunta de la DB") + elif custom_prompt: + print(f"✅ Usando custom_prompt del Form") + else: + print(f"⚠️ NO HAY custom_prompt (ni del Form ni de la DB)") + + print(f"📝 Custom prompt FINAL a usar: {custom_prompt[:150] if custom_prompt else 'NINGUNO'}...") + + # Obtener contexto del vehículo si se proporciona inspection_id + vehicle_context = "" + if inspection_id: + inspection = db.query(models.Inspection).filter(models.Inspection.id == inspection_id).first() + if inspection: + print(f"🚗 Contexto del vehículo agregado: {inspection.vehicle_brand} {inspection.vehicle_model}") + vehicle_context = f""" +INFORMACIÓN DEL VEHÍCULO INSPECCIONADO: +- Marca: {inspection.vehicle_brand} +- Modelo: {inspection.vehicle_model} +- Placa: {inspection.vehicle_plate} +- Kilometraje: {inspection.vehicle_km} km +- Nº Pedido: {inspection.order_number} +- OR/Orden: {inspection.or_number} +""" + else: + print(f"⚠️ inspection_id {inspection_id} no encontrado en DB") + else: + print(f"⚠️ NO se proporcionó inspection_id, sin contexto de vehículo") try: # Construir prompt dinámico basado en la pregunta específica if question_obj: + # Agregar información de opciones de respuesta al prompt + options_context = "" + if question_options: + options_context = f"\n\nOPCIONES DE RESPUESTA DISPONIBLES:\n{', '.join(question_options)}\n\nEn el campo 'expected_answer', indica cuál de estas opciones es la más apropiada según lo que observas en la imagen." + # Usar prompt personalizado si está disponible if custom_prompt: - # Prompt 100% personalizado por el administrador + # Prompt personalizado - DIRECTO Y SIMPLE system_prompt = f"""Eres un mecánico experto realizando una inspección vehicular. -INSTRUCCIONES ESPECÍFICAS PARA ESTA PREGUNTA: -{custom_prompt} +{vehicle_context} -PREGUNTA A RESPONDER: "{question_obj.text}" -Sección: {question_obj.section} +TAREA ESPECÍFICA: +{custom_prompt}{options_context} -Analiza la imagen siguiendo EXACTAMENTE las instrucciones proporcionadas arriba. - -VALIDACIÓN DE IMAGEN: -- Si la imagen NO corresponde al contexto de la pregunta (por ejemplo, si piden luces pero muestran motor), indica en "recommendation" que deben cambiar la foto -- Si la imagen es borrosa, oscura o no permite análisis, indica en "recommendation" que tomen otra foto más clara - -Responde en formato JSON: +Responde SOLO en formato JSON válido (sin markdown, sin ```json): {{ - "status": "ok|minor|critical", - "observations": "Análisis específico según el prompt personalizado", - "recommendation": "Si la imagen no es apropiada, indica 'Por favor tome una foto de [componente correcto]'. Si es apropiada, da recomendación técnica.", - "confidence": 0.0-1.0 -}}""" + "status": "ok", + "observations": "Describe lo que observas en la imagen en relación a la tarea solicitada", + "recommendation": "Acción sugerida basada en lo observado", + "expected_answer": "La respuesta que debería seleccionar el mecánico según lo observado (si hay opciones disponibles)", + "confidence": 0.85, + "context_match": true +}} + +VALORES DE STATUS: +- "ok": Cumple con lo esperado según la tarea +- "minor": Presenta observaciones menores o advertencias +- "critical": Presenta problemas graves o no cumple con lo esperado + +VALOR DE CONTEXT_MATCH: +- true: La imagen SÍ corresponde al contexto de la pregunta/tarea +- false: La imagen NO corresponde (ej: pregunta sobre luces pero muestra motor) + +IMPORTANTE: +- Si la imagen NO corresponde al contexto de la pregunta, establece context_match=false y en observations indica qué se esperaba ver vs qué se muestra +- Si la tarea requiere verificar funcionamiento (algo encendido, prendido, activo) pero la imagen muestra el componente apagado o en reposo, usa status "critical" y context_match=false, indica en "recommendation" que se necesita una foto con el componente funcionando o un video.""" + + user_message = f"Pregunta de inspección: {question_obj.text}\n\nAnaliza esta imagen según la tarea especificada." else: # Prompt altamente específico para la pregunta question_text = question_obj.text @@ -1570,56 +3099,107 @@ Responde en formato JSON: system_prompt = f"""Eres un mecánico experto realizando una inspección vehicular. +{vehicle_context} + PREGUNTA ESPECÍFICA A RESPONDER: "{question_text}" -Sección: {section} +Sección: {section}{options_context} Analiza la imagen ÚNICAMENTE para responder esta pregunta específica. Sé directo y enfócate solo en lo que la pregunta solicita. +Considera el kilometraje y características del vehículo para contextualizar tu análisis. VALIDACIÓN DE IMAGEN: -- Si la imagen NO corresponde al contexto de la pregunta, indica en "recommendation" que deben cambiar la foto -- Si la imagen es borrosa o no permite análisis, indica en "recommendation" que tomen otra foto más clara +- Si la imagen NO corresponde al contexto de la pregunta, establece context_match=false y explica en observations qué se esperaba vs qué se muestra +- Si la imagen es borrosa o no permite análisis, establece context_match=false e indica en recommendation que tomen otra foto más clara -Responde en formato JSON: +Responde SOLO en formato JSON válido (sin markdown, sin ```json): {{ - "status": "ok|minor|critical", - "observations": "Respuesta específica a: {question_text}", - "recommendation": "Si la imagen no es apropiada, indica 'Por favor tome una foto de [componente correcto]'. Si es apropiada, da acción técnica si aplica.", - "confidence": 0.0-1.0 + "status": "ok", + "observations": "Respuesta técnica específica a: {question_text}", + "recommendation": "Acción técnica recomendada o mensaje si la foto no es apropiada", + "expected_answer": "La respuesta correcta que debería seleccionar según lo observado", + "confidence": 0.85, + "context_match": true }} -IMPORTANTE: +NOTA IMPORTANTE sobre el campo "status": +- Usa "ok" si el componente está en buen estado y pasa la inspección +- Usa "minor" si hay problemas leves que requieren atención pero no son críticos +- Usa "critical" si hay problemas graves que requieren reparación inmediata + +VALOR DE CONTEXT_MATCH: +- true: La imagen SÍ corresponde y es apropiada para responder la pregunta +- false: La imagen NO corresponde al contexto de la pregunta (ej: pregunta sobre luces pero imagen muestra motor) + +RECUERDA: - Responde SOLO lo que la pregunta pide - No des información genérica del vehículo -- Sé específico y técnico -- Si la pregunta es pass/fail, indica claramente si pasa o falla -- Si la pregunta es bueno/regular/malo, indica el estado específico del componente""" +- Sé específico y técnico""" - user_message = f"Inspecciona la imagen y responde específicamente: {question_obj.text}" + if vehicle_context: + user_message = f"Inspecciona esta imagen del vehículo y responde específicamente: {question_obj.text}. En tus observaciones, menciona si el estado es apropiado para el kilometraje y marca/modelo del vehículo." + else: + user_message = f"Inspecciona la imagen y responde específicamente: {question_obj.text}" else: # Fallback para análisis general - system_prompt = """Eres un experto mecánico automotriz. Analiza la imagen y proporciona: + system_prompt = f"""Eres un experto mecánico automotriz. + +{vehicle_context} + +Analiza la imagen y proporciona: 1. Estado del componente (bueno/regular/malo) 2. Nivel de criticidad (ok/minor/critical) 3. Observaciones técnicas breves 4. Recomendación de acción +5. Si la imagen corresponde al contexto automotriz -Responde en formato JSON: -{ - "status": "ok|minor|critical", - "observations": "descripción técnica", +Responde SOLO en formato JSON válido (sin markdown, sin ```json): +{{ + "status": "ok", + "observations": "descripción técnica del componente", "recommendation": "acción sugerida", - "confidence": 0.0-1.0 -}""" + "confidence": 0.85, + "context_match": true +}} + +NOTA: +- "status" debe ser "ok" (bueno), "minor" (problemas leves) o "critical" (problemas graves) +- "context_match" debe ser true si la imagen muestra un componente vehicular relevante, false si no corresponde.""" user_message = "Analiza este componente del vehículo para la inspección general." + + # Ajustar prompt si es PDF en lugar de imagen + if is_pdf: + system_prompt = system_prompt.replace("Analiza la imagen", "Analiza el documento PDF") + system_prompt = system_prompt.replace("la imagen", "el documento") + system_prompt = system_prompt.replace("context_match", "document_relevance") + user_message = user_message.replace("imagen", "documento PDF") + + print(f"\n🤖 PROMPT ENVIADO AL AI:") + print(f"Provider: {ai_config.provider}") + print(f"Model: {ai_config.model_name}") + print(f"System prompt (primeros 200 chars): {system_prompt[:200]}...") + print(f"User message: {user_message}") + print("="*80 + "\n") if ai_config.provider == "openai": import openai - openai.api_key = ai_config.api_key - response = openai.ChatCompletion.create( - model=ai_config.model_name, - messages=[ + # Crear cliente de OpenAI + client = openai.OpenAI(api_key=ai_config.api_key) + + # Construir mensaje según si es PDF o imagen + if is_pdf: + # Para PDF, solo texto + messages_content = [ + {"role": "system", "content": system_prompt}, + { + "role": "user", + "content": f"{user_message}\n\n--- CONTENIDO DEL DOCUMENTO PDF ({len(pdf_text)} caracteres) ---\n{pdf_text[:30000]}" # 30k chars para GPT-4 + } + ] + else: + # Para imagen, usar vision + messages_content = [ {"role": "system", "content": system_prompt}, { "role": "user", @@ -1634,12 +3214,63 @@ Responde en formato JSON: } ] } - ], + ] + + response = client.chat.completions.create( + model=ai_config.model_name, + messages=messages_content, max_tokens=500 ) ai_response = response.choices[0].message.content + elif ai_config.provider == "anthropic": + import anthropic + + client = anthropic.Anthropic(api_key=ai_config.api_key) + + if is_pdf: + # Para PDF, solo texto + response = client.messages.create( + model=ai_config.model_name or "claude-sonnet-4-5", + max_tokens=500, + system=system_prompt, + messages=[ + { + "role": "user", + "content": f"{user_message}\n\n--- CONTENIDO DEL DOCUMENTO PDF ({len(pdf_text)} caracteres) ---\n{pdf_text[:100000]}" + } + ] + ) + else: + # Para imagen, usar vision + response = client.messages.create( + model=ai_config.model_name or "claude-sonnet-4-5", + max_tokens=500, + system=system_prompt, + messages=[ + { + "role": "user", + "content": [ + { + "type": "text", + "text": user_message + }, + { + "type": "image", + "source": { + "type": "base64", + "media_type": "image/jpeg", + "data": image_b64 + } + } + ] + } + ] + ) + + ai_response = response.content[0].text + elif ai_config.provider == "gemini": import google.generativeai as genai from PIL import Image @@ -1648,11 +3279,17 @@ Responde en formato JSON: genai.configure(api_key=ai_config.api_key) model = genai.GenerativeModel(ai_config.model_name) - # Convertir base64 a imagen PIL - image = Image.open(BytesIO(contents)) - prompt = f"{system_prompt}\n\n{user_message}" - response = model.generate_content([prompt, image]) + + if is_pdf: + # Para PDF, solo texto - Gemini puede manejar contextos muy largos (2M tokens) + prompt_with_content = f"{prompt}\n\n--- CONTENIDO DEL DOCUMENTO PDF ({len(pdf_text)} caracteres) ---\n{pdf_text[:100000]}" + response = model.generate_content(prompt_with_content) + else: + # Para imagen, incluir imagen + image = Image.open(BytesIO(contents)) + response = model.generate_content([prompt, image]) + ai_response = response.text else: @@ -1706,7 +3343,6 @@ Responde en formato JSON: try: import openai - openai.api_key = settings.OPENAI_API_KEY # Prompt especializado para inspección vehicular system_prompt = """Eres un experto mecánico automotriz. Analiza la imagen y proporciona: @@ -1723,8 +3359,11 @@ Responde en formato JSON: "confidence": 0.0-1.0 }""" - response = openai.ChatCompletion.create( - model="gpt-4-vision-preview" if "gpt-4" in str(settings.OPENAI_API_KEY) else "gpt-4o", + # Crear cliente de OpenAI + client = openai.OpenAI(api_key=settings.OPENAI_API_KEY) + + response = client.chat.completions.create( + model="gpt-4o", messages=[ { "role": "system", @@ -1780,6 +3419,391 @@ Responde en formato JSON: } +@app.post("/api/ai/chat-assistant") +async def chat_with_ai_assistant( + question_id: int = Form(...), + inspection_id: int = Form(...), + user_message: str = Form(""), + chat_history: str = Form("[]"), + context_photos: str = Form("[]"), + context_answers: str = Form("[]"), + vehicle_info: str = Form("{}"), + assistant_prompt: str = Form(""), + assistant_instructions: str = Form(""), + response_length: str = Form("medium"), + files: List[UploadFile] = File(default=[]), + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user) +): + """ + Chat conversacional con IA usando contexto de fotos anteriores + El asistente tiene acceso a fotos de preguntas previas para dar mejor contexto + Ahora soporta archivos adjuntos (imágenes y PDFs) + """ + print("\n" + "="*80) + print("🤖 AI CHAT ASSISTANT") + print("="*80) + + # Parsear JSON strings + import json + chat_history_list = json.loads(chat_history) + context_photos_list = json.loads(context_photos) + context_answers_list = json.loads(context_answers) + vehicle_info_dict = json.loads(vehicle_info) + + print(f"📋 Question ID: {question_id}") + print(f"🚗 Inspection ID: {inspection_id}") + print(f"💬 User message: {user_message}") + print(f"📎 Attached files: {len(files)}") + print(f"📸 Context photos: {len(context_photos_list)} fotos") + print(f"📝 Context answers: {len(context_answers_list)} respuestas previas") + print(f"💭 Chat history: {len(chat_history_list)} mensajes previos") + + # Detectar si es el mensaje inicial de bienvenida + is_initial_greeting = (user_message == "__INITIAL_GREETING__") + if is_initial_greeting: + print("🎉 MENSAJE INICIAL DE BIENVENIDA") + + # Procesar archivos adjuntos + attached_files_data = [] + if files: + import base64 + + for file in files: + file_content = await file.read() + file_type = file.content_type + + file_info = { + 'filename': file.filename, + 'type': file_type, + 'size': len(file_content) + } + + # Si es PDF, extraer texto + if file_type == 'application/pdf' or file.filename.lower().endswith('.pdf'): + # Usar función inteligente - límite de 50k para chat (balance entre contexto y tokens) + pdf_result = extract_pdf_text_smart(file_content, max_chars=50000) + + if pdf_result['success']: + file_info['content_type'] = 'pdf' + file_info['text'] = pdf_result['text'] + file_info['total_chars'] = pdf_result['total_chars'] + file_info['pages'] = pdf_result['pages'] + file_info['pages_processed'] = pdf_result['pages_processed'] + file_info['truncated'] = pdf_result['truncated'] + + truncated_msg = " (TRUNCADO)" if pdf_result['truncated'] else "" + print(f"📄 PDF procesado: {file.filename} - {pdf_result['total_chars']} caracteres, {pdf_result['pages_processed']}/{pdf_result['pages']} páginas{truncated_msg}") + else: + print(f"❌ Error procesando PDF {file.filename}: {pdf_result.get('error', 'Unknown')}") + file_info['error'] = pdf_result.get('error', 'Error desconocido') + + # Si es imagen, convertir a base64 Y subir a S3 + elif file_type.startswith('image/'): + file_info['content_type'] = 'image' + file_info['base64'] = base64.b64encode(file_content).decode('utf-8') + + # Subir imagen a S3 para que tenga URL permanente + try: + file_extension = file.filename.split('.')[-1] if '.' in file.filename else 'jpg' + unique_filename = f"chat_{inspection_id}_{uuid.uuid4()}.{file_extension}" + + s3_client.upload_fileobj( + BytesIO(file_content), + S3_IMAGE_BUCKET, + unique_filename, + ExtraArgs={'ContentType': file_type} + ) + + # Generar URL + image_url = f"{S3_ENDPOINT}/{S3_IMAGE_BUCKET}/{unique_filename}" + file_info['url'] = image_url + print(f"🖼️ Imagen subida a S3: {unique_filename}") + except Exception as upload_error: + print(f"⚠️ Error subiendo imagen a S3: {upload_error}") + # Continuar sin URL, usar base64 + print(f"🖼️ Imagen procesada: {file.filename}") + + attached_files_data.append(file_info) + + # Obtener configuración de IA + ai_config = db.query(models.AIConfiguration).filter( + models.AIConfiguration.is_active == True + ).first() + + if not ai_config: + return { + "success": False, + "response": "No hay configuración de IA activa. Por favor configura en Settings.", + "confidence": 0 + } + + try: + # Construir el contexto del vehículo + vehicle_context = f""" +INFORMACIÓN DEL VEHÍCULO: +- Marca: {vehicle_info_dict.get('brand', 'N/A')} +- Modelo: {vehicle_info_dict.get('model', 'N/A')} +- Placa: {vehicle_info_dict.get('plate', 'N/A')} +- Kilometraje: {vehicle_info_dict.get('km', 'N/A')} km +""" + + # Construir el contexto de las fotos anteriores + photos_context = "" + if context_photos_list: + photos_context = f"\n\nFOTOS ANALIZADAS PREVIAMENTE ({len(context_photos_list)} imágenes):\n" + for idx, photo in enumerate(context_photos_list[:10], 1): # Limitar a 10 fotos + ai_analysis = photo.get('aiAnalysis', []) + if ai_analysis and len(ai_analysis) > 0: + analysis_text = ai_analysis[0].get('analysis', {}) + obs = analysis_text.get('observations', 'Sin análisis') + status = analysis_text.get('status', 'unknown') + photos_context += f"\n{idx}. Pregunta ID {photo.get('questionId')}: Status={status}\n Observaciones: {obs[:200]}...\n" + + # NUEVO: Construir contexto de respuestas de texto de preguntas anteriores + answers_context = "" + if context_answers_list: + answers_context = f"\n\nRESPUESTAS DE PREGUNTAS ANTERIORES ({len(context_answers_list)} respuestas):\n" + for idx, ans in enumerate(context_answers_list, 1): + question_text = ans.get('questionText', f"Pregunta {ans.get('questionId')}") + answer_value = ans.get('answer', '') + observations = ans.get('observations', '') + + answers_context += f"\n{idx}. {question_text}\n" + if answer_value: + answers_context += f" Respuesta: {answer_value}\n" + if observations: + answers_context += f" Observaciones: {observations}\n" + + # Si es mensaje inicial, generar saludo contextualizado + if is_initial_greeting: + greeting_parts = ["¡Hola! Soy tu Asistente Ayutec."] + + # Mencionar instrucciones específicas si existen + if assistant_instructions: + greeting_parts.append(f"\n\n{assistant_instructions}") + elif assistant_prompt: + greeting_parts.append(f"\n\nEstoy aquí para ayudarte con: {assistant_prompt}") + + # Mencionar contexto disponible + context_info = [] + if context_answers_list: + context_info.append(f"{len(context_answers_list)} respuestas anteriores") + if context_photos_list: + context_info.append(f"{len(context_photos_list)} fotografías") + + if context_info: + greeting_parts.append(f"\n\nHe analizado {' y '.join(context_info)} de esta inspección del vehículo {vehicle_info_dict.get('brand', '')} {vehicle_info_dict.get('model', '')} (Placa: {vehicle_info_dict.get('plate', '')}).") + + # Pregunta específica o solicitud de información + if assistant_prompt: + greeting_parts.append("\n\n¿Qué información necesitas o deseas que analice?") + else: + greeting_parts.append("\n\n¿En qué puedo ayudarte con esta inspección?") + + # Reemplazar el mensaje especial con el saludo generado + user_message = "".join(greeting_parts) + print(f"✅ Mensaje de bienvenida generado: {user_message[:100]}...") + + # Definir la longitud de respuesta + max_tokens_map = { + 'short': 200, + 'medium': 400, + 'long': 800 + } + max_tokens = max_tokens_map.get(response_length, 400) + + # Construir contexto de archivos adjuntos + attached_context = "" + if attached_files_data: + attached_context = f"\n\nARCHIVOS ADJUNTOS EN ESTE MENSAJE ({len(attached_files_data)} archivos):\n" + for idx, file_info in enumerate(attached_files_data, 1): + if file_info.get('content_type') == 'pdf': + truncated_indicator = " ⚠️TRUNCADO" if file_info.get('truncated') else "" + pages_info = f" ({file_info.get('pages_processed', '?')}/{file_info.get('pages', '?')} páginas, {file_info.get('total_chars', '?')} caracteres{truncated_indicator})" if 'pages' in file_info else "" + attached_context += f"\n{idx}. PDF: {file_info['filename']}{pages_info}\n" + if 'text' in file_info: + # Mostrar más contexto del PDF (primeros 2000 caracteres como preview) + attached_context += f" Contenido: {file_info['text'][:2000]}...\n" + elif file_info.get('content_type') == 'image': + attached_context += f"\n{idx}. Imagen: {file_info['filename']}\n" + + # Construir el system prompt + # Si hay assistant_prompt en la pregunta, úsalo como base principal + if assistant_prompt: + base_prompt = assistant_prompt + else: + base_prompt = "Eres un experto mecánico automotriz que ayuda a diagnosticar problemas." + + # Agregar instrucciones anti-alucinación y contexto al prompt del usuario + system_prompt = f"""ROL Y COMPORTAMIENTO: +{base_prompt} + +IMPORTANTE: Las instrucciones anteriores definen tu COMPORTAMIENTO, NO son texto para copiar literalmente. +Tu tarea es ANALIZAR y RESPONDER al usuario basándote en el contexto proporcionado. + +CONTEXTO DEL VEHÍCULO Y PREGUNTA: +{vehicle_context} + +{answers_context} + +{photos_context} + +{attached_context} + +REGLAS DE RESPUESTA: +- Analiza ACTIVAMENTE el contenido de los documentos/imágenes enviados +- Basa tus respuestas SOLO en la información visible +- Si necesitas datos técnicos que no están en los archivos, pídelos explícitamente +- No inventes códigos DTC, voltajes, presiones ni valores que no estén visibles +- Si hay discrepancia entre lo que ves y lo que te preguntan, señálalo +- NUNCA repitas las instrucciones del sistema como respuesta + +{assistant_instructions if assistant_instructions else ""} + +Longitud de respuesta: {response_length} +""" + + # Construir el historial de mensajes para la IA + messages = [{"role": "system", "content": system_prompt}] + + # Agregar historial previo (últimos 10 mensajes para no saturar) + for msg in chat_history_list[-10:]: + messages.append({ + "role": msg.get('role'), + "content": msg.get('content') + }) + + # Agregar el mensaje actual del usuario con imágenes si hay + has_images = any(f.get('content_type') == 'image' for f in attached_files_data) + + if has_images: + # Formato multimodal para OpenAI/Gemini + user_content = [] + if user_message: + user_content.append({"type": "text", "text": user_message}) + + # Agregar imágenes + for file_info in attached_files_data: + if file_info.get('content_type') == 'image': + user_content.append({ + "type": "image_url", + "image_url": {"url": f"data:image/jpeg;base64,{file_info['base64']}"} + }) + + messages.append({ + "role": "user", + "content": user_content + }) + else: + # Solo texto + messages.append({ + "role": "user", + "content": user_message + }) + + print(f"🔧 Enviando a {ai_config.provider} con {len(messages)} mensajes") + + # Llamar a la IA según el proveedor + if ai_config.provider == 'openai': + import openai + + # Crear cliente sin argumentos adicionales que puedan causar conflicto + client = openai.OpenAI( + api_key=ai_config.api_key + ) + + # Usar streaming para respuestas más fluidas + stream = client.chat.completions.create( + model=ai_config.model_name or "gpt-4", + messages=messages, + max_tokens=max_tokens, + temperature=0.7, + stream=True + ) + + # Recolectar respuesta completa del stream + ai_response = "" + for chunk in stream: + if chunk.choices[0].delta.content is not None: + ai_response += chunk.choices[0].delta.content + + confidence = 0.85 # OpenAI no devuelve confidence directo + + elif ai_config.provider == 'anthropic': + import anthropic + + # Crear cliente de Anthropic + client = anthropic.Anthropic(api_key=ai_config.api_key) + + # Antropic usa un formato diferente: system separado de messages + # El primer mensaje es el system prompt + system_content = messages[0]['content'] if messages[0]['role'] == 'system' else "" + user_messages = [msg for msg in messages if msg['role'] != 'system'] + + response = client.messages.create( + model=ai_config.model_name or "claude-sonnet-4-5", + max_tokens=max_tokens, + system=system_content, + messages=user_messages, + temperature=0.7 + ) + + ai_response = response.content[0].text + confidence = 0.85 + + elif ai_config.provider == 'gemini': + import google.generativeai as genai + genai.configure(api_key=ai_config.api_key) + + model = genai.GenerativeModel(ai_config.model_name or 'gemini-pro') + + # Gemini maneja el chat diferente + # Convertir mensajes al formato de Gemini + chat_content = "" + for msg in messages[1:]: # Skip system message + role_label = "Usuario" if msg['role'] == 'user' else "Asistente" + chat_content += f"\n{role_label}: {msg['content']}\n" + + full_prompt = f"{system_prompt}\n\nCONVERSACIÓN:\n{chat_content}\n\nAsistente:" + + response = model.generate_content(full_prompt) + ai_response = response.text + confidence = 0.80 + + else: + raise ValueError(f"Proveedor no soportado: {ai_config.provider}") + + print(f"✅ Respuesta generada: {len(ai_response)} caracteres") + + return { + "success": True, + "response": ai_response, + "confidence": confidence, + "provider": ai_config.provider, + "model": ai_config.model_name, + "attached_files": [ + { + 'filename': f['filename'], + 'type': f['type'], + 'url': f.get('url') # URL de S3 si es imagen + } + for f in attached_files_data + ] + } + + except Exception as e: + print(f"❌ Error en chat IA: {e}") + import traceback + traceback.print_exc() + + return { + "success": False, + "response": f"Error al comunicarse con el asistente: {str(e)}", + "confidence": 0 + } + + # ============= REPORTS ============= @app.get("/api/reports/dashboard", response_model=schemas.DashboardData) def get_dashboard_data( @@ -2060,7 +4084,11 @@ def get_inspections_report( .join(models.Checklist, models.Inspection.checklist_id == models.Checklist.id)\ .join(models.User, models.Inspection.mechanic_id == models.User.id)\ .outerjoin(models.Answer, models.Answer.inspection_id == models.Inspection.id)\ - .filter(models.Inspection.is_active == True) + .outerjoin(models.Question, models.Answer.question_id == models.Question.id)\ + .filter( + models.Inspection.is_active == True, + or_(models.Question.is_deleted == False, models.Question.id == None) # Solo contar answers de preguntas no eliminadas o si no hay answer + ) # Aplicar filtros if start_date: diff --git a/backend/app/models.py b/backend/app/models.py index a88659d..5a9178e 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -12,6 +12,7 @@ class User(Base): password_hash = Column(String(255), nullable=False) role = Column(String(20), nullable=False) # admin, mechanic, asesor full_name = Column(String(100)) + employee_code = Column(String(50)) # Nro Operario - código de otro sistema is_active = Column(Boolean, default=True) created_at = Column(DateTime(timezone=True), server_default=func.now()) @@ -46,6 +47,7 @@ class Checklist(Base): scoring_enabled = Column(Boolean, default=True) max_score = Column(Integer, default=0) logo_url = Column(String(500)) + generate_pdf = Column(Boolean, default=True) # Controla si se genera PDF al completar is_active = Column(Boolean, default=True) created_by = Column(Integer, ForeignKey("users.id")) created_at = Column(DateTime(timezone=True), server_default=func.now()) @@ -65,23 +67,29 @@ class Question(Base): checklist_id = Column(Integer, ForeignKey("checklists.id"), nullable=False) section = Column(String(100)) # Sistema eléctrico, Frenos, etc text = Column(Text, nullable=False) - type = Column(String(30), nullable=False) # pass_fail, good_bad, text, etc + type = Column(String(30), nullable=False) # boolean, single_choice, multiple_choice, scale, text, number, date, time points = Column(Integer, default=1) - options = Column(JSON) # Para multiple choice + options = Column(JSON) # Configuración flexible según tipo de pregunta order = Column(Integer, default=0) - allow_photos = Column(Boolean, default=True) + allow_photos = Column(Boolean, default=True) # DEPRECATED: usar photo_requirement + photo_requirement = Column(String(20), default='optional') # none, optional, required max_photos = Column(Integer, default=3) requires_comment_on_fail = Column(Boolean, default=False) send_notification = Column(Boolean, default=False) - # Conditional logic + # Conditional logic - Subpreguntas anidadas hasta 5 niveles parent_question_id = Column(Integer, ForeignKey("questions.id"), nullable=True) show_if_answer = Column(String(50), nullable=True) # Valor que dispara esta pregunta + depth_level = Column(Integer, default=0) # 0=principal, 1-5=subpreguntas anidadas # AI Analysis ai_prompt = Column(Text, nullable=True) # Prompt personalizado para análisis de IA de esta pregunta + # Soft Delete + is_deleted = Column(Boolean, default=False) # Soft delete: mantiene integridad de respuestas históricas + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) # Relationships checklist = relationship("Checklist", back_populates="questions") @@ -106,7 +114,10 @@ class Inspection(Base): vehicle_brand = Column(String(50)) vehicle_model = Column(String(100)) vehicle_km = Column(Integer) - client_name = Column(String(200)) + order_number = Column(String(200)) # Nº de Pedido + + # Datos del mecánico + mechanic_employee_code = Column(String(50)) # Código de operario del mecánico # Scoring score = Column(Integer, default=0) @@ -115,7 +126,7 @@ class Inspection(Base): flagged_items_count = Column(Integer, default=0) # Estado - status = Column(String(20), default="draft") # draft, completed, inactive + status = Column(String(20), default="incomplete") # incomplete, completed, inactive is_active = Column(Boolean, default=True) # Firma @@ -148,6 +159,7 @@ class Answer(Base): comment = Column(Text) # Comentarios adicionales ai_analysis = Column(JSON) # Análisis de IA si aplica + chat_history = Column(JSON) # Historial de chat con AI Assistant (para tipo ai_assistant) is_flagged = Column(Boolean, default=False) # Si requiere atención created_at = Column(DateTime(timezone=True), server_default=func.now()) @@ -203,6 +215,27 @@ class ChecklistPermission(Base): mechanic = relationship("User") +class QuestionAuditLog(Base): + """Registro de auditoría para cambios en preguntas de checklists""" + __tablename__ = "question_audit_log" + + id = Column(Integer, primary_key=True, index=True) + question_id = Column(Integer, ForeignKey("questions.id", ondelete="CASCADE"), nullable=False) + checklist_id = Column(Integer, ForeignKey("checklists.id", ondelete="CASCADE"), nullable=False) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + action = Column(String(50), nullable=False) # created, updated, deleted + field_name = Column(String(100), nullable=True) # Campo modificado + old_value = Column(Text, nullable=True) # Valor anterior + new_value = Column(Text, nullable=True) # Valor nuevo + comment = Column(Text, nullable=True) # Comentario del cambio + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + # Relationships + question = relationship("Question") + checklist = relationship("Checklist") + user = relationship("User") + + class InspectionAuditLog(Base): """Registro de auditoría para cambios en inspecciones y respuestas""" __tablename__ = "inspection_audit_log" diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 5ba49b2..8003f65 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -7,6 +7,7 @@ class UserBase(BaseModel): username: str email: Optional[EmailStr] = None full_name: Optional[str] = None + employee_code: Optional[str] = None # Nro Operario - código de otro sistema role: str = "mechanic" class UserCreate(UserBase): @@ -16,6 +17,7 @@ class UserUpdate(BaseModel): username: Optional[str] = None email: Optional[EmailStr] = None full_name: Optional[str] = None + employee_code: Optional[str] = None role: Optional[str] = None class UserPasswordUpdate(BaseModel): @@ -31,6 +33,7 @@ class UserLogin(BaseModel): class User(UserBase): id: int + employee_code: Optional[str] = None is_active: bool created_at: datetime @@ -68,6 +71,7 @@ class ChecklistBase(BaseModel): ai_mode: str = "off" scoring_enabled: bool = True logo_url: Optional[str] = None + generate_pdf: bool = True class ChecklistCreate(ChecklistBase): mechanic_ids: Optional[List[int]] = [] # IDs de mecánicos autorizados @@ -78,12 +82,14 @@ class ChecklistUpdate(BaseModel): ai_mode: Optional[str] = None scoring_enabled: Optional[bool] = None logo_url: Optional[str] = None + generate_pdf: Optional[bool] = None is_active: Optional[bool] = None mechanic_ids: Optional[List[int]] = None # IDs de mecánicos autorizados class Checklist(ChecklistBase): id: int max_score: int + generate_pdf: bool is_active: bool created_by: int created_at: datetime @@ -94,20 +100,37 @@ class Checklist(ChecklistBase): # Question Schemas +# Tipos de preguntas soportados: +# - boolean: Dos opciones personalizables (ej: Sí/No, Pasa/Falla) +# - single_choice: Selección única con N opciones +# - multiple_choice: Selección múltiple +# - scale: Escala numérica (1-5, 1-10, etc.) +# - text: Texto libre +# - number: Valor numérico +# - date: Fecha +# - time: Hora + class QuestionBase(BaseModel): section: Optional[str] = None text: str - type: str + type: str # boolean, single_choice, multiple_choice, scale, text, number, date, time points: int = 1 - options: Optional[dict] = None + options: Optional[dict] = None # Configuración flexible según tipo + # Estructura de options: + # Boolean: {"type": "boolean", "choices": [{"value": "yes", "label": "Sí", "points": 1, "status": "ok"}, ...]} + # Single/Multiple Choice: {"type": "single_choice", "choices": [{"value": "opt1", "label": "Opción 1", "points": 2}, ...]} + # Scale: {"type": "scale", "min": 1, "max": 5, "step": 1, "labels": {"min": "Muy malo", "max": "Excelente"}} + # Text: {"type": "text", "multiline": true, "max_length": 500} order: int = 0 - allow_photos: bool = True + allow_photos: bool = True # DEPRECATED: mantener por compatibilidad + photo_requirement: Optional[str] = 'optional' # none, optional, required max_photos: int = 3 requires_comment_on_fail: bool = False send_notification: bool = False parent_question_id: Optional[int] = None show_if_answer: Optional[str] = None ai_prompt: Optional[str] = None + is_deleted: bool = False class QuestionCreate(QuestionBase): checklist_id: int @@ -115,15 +138,37 @@ class QuestionCreate(QuestionBase): class QuestionUpdate(QuestionBase): pass +class QuestionReorder(BaseModel): + question_id: int + new_order: int + class Question(QuestionBase): id: int checklist_id: int created_at: datetime + updated_at: Optional[datetime] = None class Config: from_attributes = True +# Question Audit Schemas +class QuestionAuditLog(BaseModel): + id: int + question_id: int + checklist_id: int + user_id: int + action: str + field_name: Optional[str] = None + old_value: Optional[str] = None + new_value: Optional[str] = None + comment: Optional[str] = None + created_at: datetime + user: Optional['User'] = None + + class Config: + from_attributes = True + # Inspection Schemas class InspectionBase(BaseModel): @@ -133,7 +178,8 @@ class InspectionBase(BaseModel): vehicle_brand: Optional[str] = None vehicle_model: Optional[str] = None vehicle_km: Optional[int] = None - client_name: Optional[str] = None + order_number: Optional[str] = None # Nº de Pedido + mechanic_employee_code: Optional[str] = None class InspectionCreate(InspectionBase): checklist_id: int @@ -149,6 +195,7 @@ class Inspection(InspectionBase): id: int checklist_id: int mechanic_id: int + mechanic_employee_code: Optional[str] = None score: int max_score: int percentage: float @@ -163,7 +210,7 @@ class Inspection(InspectionBase): # Answer Schemas class AnswerBase(BaseModel): - answer_value: str + answer_value: Optional[str] = None # Opcional para permitir guardar solo análisis IA status: str = "ok" comment: Optional[str] = None is_flagged: bool = False @@ -171,6 +218,8 @@ class AnswerBase(BaseModel): class AnswerCreate(AnswerBase): inspection_id: int question_id: int + ai_analysis: Optional[list] = None # Lista de análisis de IA (soporta múltiples imágenes) + chat_history: Optional[list] = None # Historial de chat con AI Assistant class AnswerUpdate(AnswerBase): pass @@ -180,7 +229,8 @@ class Answer(AnswerBase): inspection_id: int question_id: int points_earned: int - ai_analysis: Optional[dict] = None + ai_analysis: Optional[list] = None # Lista de análisis de IA + chat_history: Optional[list] = None # Historial de chat con AI Assistant created_at: datetime class Config: @@ -222,9 +272,10 @@ class InspectionDetail(Inspection): # AI Configuration Schemas class AIConfigurationBase(BaseModel): - provider: str # openai, gemini + provider: str # openai, gemini, anthropic api_key: str model_name: Optional[str] = None + logo_url: Optional[str] = None class AIConfigurationCreate(AIConfigurationBase): pass @@ -233,6 +284,7 @@ class AIConfigurationUpdate(BaseModel): provider: Optional[str] = None api_key: Optional[str] = None model_name: Optional[str] = None + logo_url: Optional[str] = None is_active: Optional[bool] = None class AIConfiguration(AIConfigurationBase): diff --git a/backend/docker.ps1 b/backend/docker.ps1 index 3218410..d9974a4 100644 --- a/backend/docker.ps1 +++ b/backend/docker.ps1 @@ -22,4 +22,4 @@ if ($LASTEXITCODE -ne 0) { } Write-Host "`n=== Proceso completado exitosamente ===`n" -ForegroundColor Green -pause + diff --git a/backend/migrations/add_question_audit_log.sql b/backend/migrations/add_question_audit_log.sql new file mode 100644 index 0000000..6a319e0 --- /dev/null +++ b/backend/migrations/add_question_audit_log.sql @@ -0,0 +1,44 @@ +-- Migration: Add question_audit_log table +-- Date: 2025-11-27 +-- Description: Add audit logging for question changes + +CREATE TABLE IF NOT EXISTS question_audit_log ( + id SERIAL PRIMARY KEY, + question_id INTEGER NOT NULL, + checklist_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + action VARCHAR(50) NOT NULL, + field_name VARCHAR(100), + old_value TEXT, + new_value TEXT, + comment TEXT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + + -- Foreign keys + CONSTRAINT fk_question_audit_question + FOREIGN KEY (question_id) + REFERENCES questions(id) + ON DELETE CASCADE, + + CONSTRAINT fk_question_audit_checklist + FOREIGN KEY (checklist_id) + REFERENCES checklists(id) + ON DELETE CASCADE, + + CONSTRAINT fk_question_audit_user + FOREIGN KEY (user_id) + REFERENCES users(id) +); + +-- Create indexes for better query performance +CREATE INDEX idx_question_audit_question_id ON question_audit_log(question_id); +CREATE INDEX idx_question_audit_checklist_id ON question_audit_log(checklist_id); +CREATE INDEX idx_question_audit_created_at ON question_audit_log(created_at); +CREATE INDEX idx_question_audit_action ON question_audit_log(action); + +-- Add comment to table +COMMENT ON TABLE question_audit_log IS 'Registro de auditoría para cambios en preguntas de checklists'; +COMMENT ON COLUMN question_audit_log.action IS 'Tipo de acción: created, updated, deleted'; +COMMENT ON COLUMN question_audit_log.field_name IS 'Nombre del campo modificado (solo para updates)'; +COMMENT ON COLUMN question_audit_log.old_value IS 'Valor anterior del campo'; +COMMENT ON COLUMN question_audit_log.new_value IS 'Valor nuevo del campo'; diff --git a/backend/migrations/rename_client_name_to_order_number.sql b/backend/migrations/rename_client_name_to_order_number.sql new file mode 100644 index 0000000..2b163a1 --- /dev/null +++ b/backend/migrations/rename_client_name_to_order_number.sql @@ -0,0 +1,10 @@ +-- Migration: Rename client_name to order_number +-- Date: 2025-11-27 +-- Description: Cambiar campo client_name a order_number en tabla inspections + +-- Renombrar la columna +ALTER TABLE inspections +RENAME COLUMN client_name TO order_number; + +-- Actualizar comentario de la columna +COMMENT ON COLUMN inspections.order_number IS 'Número de pedido asociado a la inspección'; diff --git a/backend/requirements.txt b/backend/requirements.txt index 7f2fefd..c25e44f 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -10,10 +10,12 @@ python-jose[cryptography]==3.3.0 passlib==1.7.4 bcrypt==4.0.1 python-multipart==0.0.6 -openai==1.10.0 +openai==1.57.4 +anthropic==0.40.0 google-generativeai==0.3.2 Pillow==10.2.0 reportlab==4.0.9 +pypdf==4.0.1 python-dotenv==1.0.0 boto3==1.34.89 requests==2.31.0 \ No newline at end of file diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 3453500..c593611 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -6,6 +6,8 @@ services: POSTGRES_DB: ${POSTGRES_DB:-syntria_db} POSTGRES_USER: ${POSTGRES_USER:-syntria_user} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + TZ: Atlantic/Canary + PGTZ: Atlantic/Canary ports: - "5432:5432" volumes: @@ -29,6 +31,7 @@ services: OPENAI_API_KEY: ${OPENAI_API_KEY} ENVIRONMENT: production ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-http://localhost,http://localhost:5173} + TZ: Atlantic/Canary ports: - "8000:8000" volumes: diff --git a/docker-compose.yml b/docker-compose.yml index 16ba8d8..0e36d77 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,11 +6,15 @@ services: POSTGRES_DB: ${POSTGRES_DB:-checklist_db} POSTGRES_USER: ${POSTGRES_USER:-checklist_user} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-checklist_pass_2024} + TZ: Atlantic/Canary + PGTZ: Atlantic/Canary ports: - "5432:5432" volumes: - postgres_data:/var/lib/postgresql/data - ./init-db.sh:/docker-entrypoint-initdb.d/init-db.sh + - ./postgres-custom.conf:/etc/postgresql/postgresql.conf + command: postgres -c config_file=/etc/postgresql/postgresql.conf healthcheck: test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-checklist_user} -d ${POSTGRES_DB:-checklist_db}"] interval: 10s @@ -25,6 +29,7 @@ services: SECRET_KEY: ${SECRET_KEY:-your-secret-key-change-in-production-min-32-chars} OPENAI_API_KEY: ${OPENAI_API_KEY} ENVIRONMENT: ${ENVIRONMENT:-development} + TZ: Atlantic/Canary ports: - "8000:8000" volumes: diff --git a/docker-stack.yml b/docker-stack.yml index 328fe18..2f5523e 100644 --- a/docker-stack.yml +++ b/docker-stack.yml @@ -7,6 +7,8 @@ services: POSTGRES_DB: syntria_db POSTGRES_USER: syntria_user POSTGRES_PASSWORD: syntria_secure_2024 + TZ: Atlantic/Canary + PGTZ: Atlantic/Canary volumes: - postgres_data:/var/lib/postgresql/data networks: @@ -35,6 +37,7 @@ services: GEMINI_API_KEY: tu_api_key_de_gemini ENVIRONMENT: production ALLOWED_ORIGINS: http://localhost,https://syntria.tudominio.com + TZ: Atlantic/Canary networks: - syntria_network - network_public diff --git a/docker.ps1 b/docker.ps1 new file mode 100644 index 0000000..e69de29 diff --git a/docs/pdf-extraction-improvements.md b/docs/pdf-extraction-improvements.md new file mode 100644 index 0000000..ac8cc61 --- /dev/null +++ b/docs/pdf-extraction-improvements.md @@ -0,0 +1,155 @@ +# Mejoras en la Extracción de PDFs con IA + +## Versión Backend: 1.0.95 + +## Problema Original + +El sistema tenía limitaciones al procesar PDFs con IA: + +1. **Límites muy pequeños**: Solo extraía 2,000-4,000 caracteres +2. **Sin manejo de duplicaciones**: Páginas repetidas se procesaban múltiples veces +3. **No aprovechaba contextos largos**: Los modelos modernos soportan millones de tokens +4. **Falta de información**: No reportaba páginas procesadas o si el contenido fue truncado + +## Solución Implementada + +### 1. Función Centralizada de Extracción + +Nueva función `extract_pdf_text_smart()` que: +- ✅ Extrae texto de forma inteligente +- ✅ Detecta y evita páginas duplicadas +- ✅ Maneja límites configurables +- ✅ Reporta estadísticas completas (páginas, caracteres, truncado) +- ✅ Manejo robusto de errores + +```python +pdf_result = extract_pdf_text_smart(pdf_content, max_chars=50000) +# Retorna: +# { +# 'text': '...', +# 'pages': 10, +# 'pages_processed': 9, # Si una página estaba duplicada +# 'total_chars': 45000, +# 'truncated': False, +# 'success': True +# } +``` + +### 2. Límites Optimizados por Caso de Uso + +| Endpoint | Límite Anterior | Límite Nuevo | Modelo Objetivo | +|----------|----------------|--------------|-----------------| +| `/api/analyze-image` (OpenAI) | 4,000 chars | 30,000 chars | GPT-4 (128k tokens) | +| `/api/analyze-image` (Gemini) | 4,000 chars | 100,000 chars | Gemini 1.5/2.0 (2M tokens) | +| `/api/ai/chat-assistant` | 2,000 chars | 50,000 chars | Equilibrado para contexto | + +### 3. Detección de Duplicaciones + +El sistema ahora verifica si el contenido de una página ya existe antes de agregarlo: + +```python +if page_text.strip() not in full_text: + full_text += f"\n--- Página {page_num}/{total_pages} ---\n{page_text.strip()}\n" +``` + +Esto previene: +- PDFs con páginas idénticas repetidas +- Documentos mal generados con contenido duplicado +- Uso innecesario de tokens en el análisis IA + +### 4. Información Mejorada + +El sistema ahora reporta: +- **Páginas totales**: Total de páginas en el PDF +- **Páginas procesadas**: Páginas únicas con contenido +- **Caracteres totales**: Tamaño real del texto extraído +- **Indicador de truncado**: Si el PDF fue limitado + +Ejemplo de output: +``` +📄 PDF procesado: manual-vehiculo.pdf - 87450 caracteres, 8/10 páginas (TRUNCADO) +``` + +## Capacidades de Contexto por Modelo + +### OpenAI GPT-4 +- **Contexto**: ~128,000 tokens (~500,000 caracteres) +- **Límite aplicado**: 30,000 caracteres +- **Razón**: Balance entre contexto útil y costo + +### Gemini 1.5/2.0 Pro +- **Contexto**: 2,000,000 tokens (~8,000,000 caracteres) +- **Límite aplicado**: 100,000 caracteres +- **Razón**: Aprovechar contexto masivo sin sobrecargar + +### Chat Assistant +- **Límite**: 50,000 caracteres +- **Razón**: Incluye historial + contexto de fotos + PDF + +## Casos de Uso Soportados + +### ✅ PDFs Pequeños (1-5 páginas) +Extracción completa sin truncado + +### ✅ PDFs Medianos (5-20 páginas) +Extracción completa o parcial según contenido + +### ✅ PDFs Grandes (20+ páginas) +Extracción inteligente con truncado después de límite + +### ✅ PDFs con Páginas Duplicadas +Detección automática y eliminación + +### ✅ Múltiples PDFs en Chat +Cada uno procesado independientemente con su límite + +## Indicadores de Estado + +### En Logs del Servidor +``` +📄 PDF procesado: documento.pdf - 25000 caracteres, 10/10 páginas +📄 PDF procesado: manual.pdf - 50000 caracteres, 15/20 páginas (TRUNCADO) +``` + +### En Respuesta al Cliente +```json +{ + "attached_files": [ + { + "filename": "manual.pdf", + "type": "application/pdf", + "pages": 20, + "pages_processed": 15, + "total_chars": 75000, + "truncated": true + } + ] +} +``` + +## Próximas Mejoras Potenciales + +1. **Chunking Inteligente**: Para PDFs muy grandes, dividir en chunks semánticos +2. **OCR Integrado**: Detectar PDFs escaneados y aplicar OCR automático +3. **Resumen Automático**: Para PDFs grandes, generar resumen antes de análisis +4. **Cache de Extracciones**: Guardar texto extraído en DB para reutilización + +## Migración + +No requiere migración de base de datos. Los cambios son retrocompatibles. + +## Testing + +Para probar las mejoras: + +1. **PDF pequeño** (< 10 páginas): Debe procesarse completo +2. **PDF grande** (> 50 páginas): Debe truncarse y reportar info +3. **PDF con duplicados**: Debe eliminar páginas repetidas +4. **Múltiples PDFs**: Cada uno procesado independientemente + +## Notas Técnicas + +- La función `extract_pdf_text_smart()` está en `main.py` línea ~210 +- Usa `pypdf.PdfReader` para extracción +- Maneja encoding UTF-8 automáticamente +- Thread-safe (usa BytesIO) diff --git a/docs/pdf-regeneration.md b/docs/pdf-regeneration.md new file mode 100644 index 0000000..c473723 --- /dev/null +++ b/docs/pdf-regeneration.md @@ -0,0 +1,177 @@ +# Regeneración Automática de PDF al Editar Respuestas + +## Descripción General + +Se ha implementado la funcionalidad de regeneración automática del PDF de inspección cuando se editan respuestas en inspecciones completadas. + +## Cambios Implementados + +### 1. Nueva Función Reutilizable: `generate_inspection_pdf()` + +**Ubicación**: `backend/app/main.py` (línea ~1046) + +**Propósito**: Generar el PDF de una inspección y subirlo a S3. + +**Parámetros**: +- `inspection_id: int` - ID de la inspección +- `db: Session` - Sesión de base de datos + +**Retorna**: `str` - URL del PDF generado en S3 + +**Características**: +- Genera PDF profesional con diseño A4 +- Incluye toda la información de la inspección +- Sube automáticamente a S3/MinIO +- Sobrescribe PDF existente si ya existe +- Maneja errores y excepciones + +### 2. Actualización de `complete_inspection()` + +**Ubicación**: `backend/app/main.py` (línea ~1358) + +**Cambios**: +- Removido código duplicado de generación de PDF +- Ahora usa la función `generate_inspection_pdf()` +- Código más limpio y mantenible + +**Antes**: +```python +# 300+ líneas de código de generación de PDF inline +``` + +**Después**: +```python +# Generar PDF usando función reutilizable +pdf_url = generate_inspection_pdf(inspection_id, db) +inspection.pdf_url = pdf_url +``` + +### 3. Actualización de `update_answer()` + +**Ubicación**: `backend/app/main.py` (línea ~1497) + +**Nuevas Funcionalidades**: + +1. **Verificación de Estado**: Comprueba si la inspección está completada +2. **Recálculo de Puntuación**: Actualiza score, porcentaje y contadores +3. **Regeneración de PDF**: Genera nuevo PDF con los cambios +4. **Manejo de Errores**: No interrumpe la actualización si falla la generación del PDF + +**Flujo de Trabajo**: +```python +1. Usuario edita respuesta +2. Backend actualiza Answer en BD +3. Backend verifica si inspection.status == "completed" +4. Si está completada: + a. Recalcula score total + b. Recalcula porcentaje + c. Recalcula items críticos + d. Genera nuevo PDF + e. Actualiza inspection.pdf_url +5. Retorna Answer actualizado +``` + +## Casos de Uso + +### Caso 1: Editar Respuesta en Inspección en Progreso +``` +- Usuario edita respuesta +- Respuesta se actualiza +- PDF NO se regenera (inspección no completada) +``` + +### Caso 2: Editar Respuesta en Inspección Completada +``` +- Usuario edita respuesta +- Respuesta se actualiza +- Sistema detecta que inspección está completada +- Score se recalcula automáticamente +- PDF se regenera con los nuevos datos +- PDF anterior es sobrescrito en S3 +``` + +## Ventajas de la Nueva Implementación + +1. **DRY (Don't Repeat Yourself)**: Código de generación de PDF existe una sola vez +2. **Mantenibilidad**: Cambios al PDF solo se hacen en un lugar +3. **Automatización**: PDFs siempre reflejan el estado actual +4. **Consistencia**: Mismo diseño profesional en todas partes +5. **Robustez**: Manejo de errores sin interrumpir flujo principal + +## Estructura del PDF Generado + +El PDF incluye: + +### Portada +- Título e ID de inspección +- Cuadro de información del vehículo (azul) +- Cuadro de información del cliente y mecánico (verde) +- Resumen de puntuación con colores según porcentaje + +### Detalle de Inspección +- Agrupado por secciones +- Cada pregunta con: + - Icono de estado (✓ ok, ⚠ warning, ✕ critical) + - Respuesta y estado + - Comentarios + - Galería de imágenes (6 por fila) + +### Footer +- Timestamp de generación + +## Logs y Debugging + +El sistema imprime logs útiles: + +```python +# Al regenerar PDF +🔄 Regenerando PDF para inspección completada #123 + +# Al completar regeneración +✅ PDF generado y subido a S3: https://... + +# Si hay error +❌ Error regenerando PDF: [detalle] +``` + +## Versión del Backend + +**Versión actual**: `1.0.26` + +Se incrementó la versión para reflejar esta nueva funcionalidad. + +## Notas Técnicas + +### S3/MinIO +- Los PDFs sobrescriben el archivo anterior con el mismo nombre +- Ruta: `{año}/{mes}/inspeccion_{id}_{placa}.pdf` +- Content-Type: `application/pdf` + +### Base de Datos +- Campo `inspection.pdf_url` se actualiza automáticamente +- Score, porcentaje y flagged_items_count se recalculan +- Todo en una sola transacción + +### Manejo de Errores +- Si falla la generación del PDF, se registra el error +- La actualización de la respuesta NO se revierte +- Se imprime traceback completo para debugging + +## Próximos Pasos Sugeridos + +1. ✅ Implementar regeneración de PDF (COMPLETADO) +2. ⏳ Ejecutar migraciones SQL para employee_code +3. ⏳ Probar flujo completo en ambiente de desarrollo +4. ⏳ Considerar notificación a n8n cuando se edita inspección completada +5. ⏳ Agregar campo `updated_at` a inspecciones para tracking de cambios + +## Testing + +Para probar la funcionalidad: + +1. Completar una inspección +2. Verificar que se genera el PDF +3. Editar una respuesta (cambiar status, comentario, etc.) +4. Verificar en logs que se regenera el PDF +5. Descargar el PDF y confirmar que refleja los cambios +6. Verificar que el score se recalculó correctamente diff --git a/docs/webhook-n8n.md b/docs/webhook-n8n.md new file mode 100644 index 0000000..b57df76 --- /dev/null +++ b/docs/webhook-n8n.md @@ -0,0 +1,196 @@ +# Documentación de Webhook - n8n + +## Endpoint +El endpoint configurado en `.env`: +``` +NOTIFICACION_ENDPOINT=https://n8nw.comercialarmin.com.py/webhook/53284540-edc4-418f-b1bf-a70a805f8212 +``` + +## Evento: Inspección Completada + +### Cuándo se envía +Cuando se completa una inspección (endpoint: `POST /api/inspections/{id}/complete`) + +### Estructura del JSON + +```json +{ + "tipo": "inspeccion_completada", + "inspeccion": { + "id": 123, + "estado": "completed", + "or_number": "OR-001", + "work_order_number": "WO-123", + "vehiculo": { + "placa": "ABC-123", + "marca": "Toyota", + "modelo": "Corolla 2020", + "kilometraje": 50000 + }, + "cliente": "Juan Pérez", + "mecanico": { + "id": 5, + "nombre": "Carlos Méndez", + "email": "carlos@example.com", + "codigo_operario": "OPR-001" + }, + "checklist": { + "id": 1, + "nombre": "Inspección Vehicular Completa" + }, + "puntuacion": { + "obtenida": 85, + "maxima": 100, + "porcentaje": 85.0, + "items_criticos": 2 + }, + "fechas": { + "inicio": "2025-11-26T10:30:00", + "completado": "2025-11-26T11:45:00" + }, + "pdf_url": "https://minioapi.ayutec.es/pdfs/2025/11/inspeccion_123_ABC-123.pdf", + "firma": "data:image/png;base64,..." + }, + "respuestas": [ + { + "id": 1, + "pregunta": { + "id": 10, + "texto": "¿Estado de los neumáticos?", + "seccion": "Neumáticos", + "orden": 1 + }, + "respuesta": "ok", + "estado": "ok", + "comentario": "Neumáticos en buen estado", + "observaciones": "Presión correcta en las 4 ruedas", + "puntos_obtenidos": 1, + "es_critico": false, + "imagenes": [ + { + "id": 100, + "url": "https://minioapi.ayutec.es/images/2025/11/foto1.jpg", + "filename": "neumatico_delantero.jpg" + }, + { + "id": 101, + "url": "https://minioapi.ayutec.es/images/2025/11/foto2.jpg", + "filename": "neumatico_trasero.jpg" + } + ], + "ai_analysis": { + "status": "ok", + "observations": "Los neumáticos presentan un desgaste uniforme...", + "recommendation": "Continuar con el mantenimiento preventivo", + "confidence": 0.95 + } + }, + { + "id": 2, + "pregunta": { + "id": 11, + "texto": "¿Luces delanteras funcionan?", + "seccion": "Iluminación", + "orden": 2 + }, + "respuesta": "warning", + "estado": "warning", + "comentario": "Faro izquierdo opaco", + "observaciones": "Requiere restauración de faro", + "puntos_obtenidos": 0.5, + "es_critico": true, + "imagenes": [ + { + "id": 102, + "url": "https://minioapi.ayutec.es/images/2025/11/foto3.jpg", + "filename": "faro_izquierdo.jpg" + } + ], + "ai_analysis": { + "status": "minor", + "observations": "Se detecta opacidad en el faro izquierdo...", + "recommendation": "Pulir o restaurar el lente del faro", + "confidence": 0.9 + } + } + ], + "timestamp": "2025-11-26T11:45:30.123456" +} +``` + +## Campos Importantes + +### Imágenes +- Cada respuesta incluye un array `imagenes` con: + - `id`: ID del archivo en la base de datos + - `url`: **URL directa** de la imagen en MinIO (lista para descargar/mostrar) + - `filename`: Nombre original del archivo + +### AI Analysis +- Si la pregunta fue analizada por IA, incluye: + - `status`: ok/minor/critical + - `observations`: Observaciones del análisis + - `recommendation`: Recomendaciones + - `confidence`: Nivel de confianza (0-1) + +### Código de Operario +- Se incluye en `inspeccion.mecanico.codigo_operario` +- Se copia automáticamente del perfil del mecánico al crear la inspección + +### PDF +- URL del PDF generado en `inspeccion.pdf_url` +- Incluye miniaturas de todas las imágenes + +## Uso en n8n + +### 1. Webhook Trigger +Configura un nodo Webhook con la URL del archivo `.env` + +### 2. Filtrar por tipo +```javascript +// Verificar si es una inspección completada +{{ $json.tipo === "inspeccion_completada" }} +``` + +### 3. Acceder a las imágenes +```javascript +// Obtener todas las URLs de imágenes +{{ $json.respuestas.map(r => r.imagenes.map(i => i.url)).flat() }} + +// Primera imagen de cada respuesta +{{ $json.respuestas.map(r => r.imagenes[0]?.url) }} + +// Imágenes de respuestas críticas +{{ $json.respuestas.filter(r => r.es_critico).map(r => r.imagenes).flat() }} +``` + +### 4. Descargar imágenes +Las URLs son públicas y directas, se pueden: +- Descargar con HTTP Request +- Enviar por email como adjuntos +- Procesar con Computer Vision +- Subir a otro servicio (Google Drive, Dropbox, etc.) + +### 5. Ejemplo: Enviar por email +```javascript +// En un nodo Email +To: {{ $json.inspeccion.cliente_email }} +Subject: Inspección Completada - {{ $json.inspeccion.vehiculo.placa }} +Attachments: {{ $json.inspeccion.pdf_url }} +``` + +## Logs +El backend imprime logs detallados: +``` +🚀 Enviando inspección #123 a n8n... +📤 Enviando 15 respuestas con imágenes a n8n... +✅ Inspección #123 enviada exitosamente a n8n + - 15 respuestas + - 23 imágenes +``` + +## Seguridad +- El webhook es HTTPS +- Las URLs de imágenes son públicas en MinIO +- No se envían passwords ni tokens +- Se incluyen solo datos relevantes de la inspección diff --git a/frontend/PWA-UPDATE-GUIDE.md b/frontend/PWA-UPDATE-GUIDE.md new file mode 100644 index 0000000..3f5d643 --- /dev/null +++ b/frontend/PWA-UPDATE-GUIDE.md @@ -0,0 +1,168 @@ +# Sistema de Actualización PWA - AYUTEC + +## 🚀 Características + +- ✅ **Detección automática** de nuevas versiones +- ✅ **Modal de actualización** grande y visible +- ✅ **Service Worker** con estrategia Network-First +- ✅ **Cache inteligente** para funcionamiento offline +- ✅ **Actualización forzada** al usuario cuando hay nueva versión + +## 📱 Instalación como PWA + +### En Android/iOS: +1. Abre la app en Chrome/Safari +2. Toca el menú (⋮) +3. Selecciona "Agregar a pantalla de inicio" +4. Confirma la instalación + +### En Desktop: +1. Abre la app en Chrome/Edge +2. Haz clic en el ícono de instalación (➕) en la barra de direcciones +3. Confirma "Instalar" + +## 🔄 Proceso de Actualización + +### Para el Usuario: +1. Cuando hay una actualización, aparece automáticamente un **modal grande** +2. El modal muestra: "¡Nueva Actualización!" +3. Botón grande: **"🚀 ACTUALIZAR AHORA"** +4. Al presionar, la app se recarga con la nueva versión + +### Para el Desarrollador: + +#### Opción 1: Script Automático (Recomendado) +```powershell +cd frontend +.\update-version.ps1 +``` +Este script: +- Incrementa automáticamente la versión patch (1.0.87 → 1.0.88) +- Actualiza `package.json` +- Actualiza `public/service-worker.js` + +#### Opción 2: Manual +1. **Actualizar `package.json`:** + ```json + "version": "1.0.88" // Incrementar número + ``` + +2. **Actualizar `public/service-worker.js`:** + ```javascript + const CACHE_NAME = 'ayutec-v1.0.88'; // Mismo número + ``` + +3. **Hacer build y deploy:** + ```powershell + npm run build + docker build -t tu-registry/checklist-frontend:latest . + docker push tu-registry/checklist-frontend:latest + ``` + +## 🔧 Cómo Funciona + +### 1. Service Worker +- Registrado en `App.jsx` +- Cache con nombre versionado: `ayutec-v1.0.87` +- Estrategia: **Network First, Cache Fallback** +- Al cambiar la versión, se crea nuevo cache + +### 2. Detección de Actualización +```javascript +// En App.jsx +registration.addEventListener('updatefound', () => { + // Nueva versión detectada + setUpdateAvailable(true) +}) +``` + +### 3. Modal de Actualización +- Overlay negro semi-transparente (z-index: 9999) +- Modal animado con bounce +- Botón grande con gradiente +- **No se puede cerrar** - obliga a actualizar + +### 4. Aplicación de Actualización +```javascript +waitingWorker.postMessage({ type: 'SKIP_WAITING' }); +// Activa el nuevo service worker +// Recarga la página automáticamente +``` + +## 📊 Versionado + +Seguimos **Semantic Versioning**: +- **MAJOR**: Cambios incompatibles (1.0.0 → 2.0.0) +- **MINOR**: Nueva funcionalidad compatible (1.0.0 → 1.1.0) +- **PATCH**: Correcciones de bugs (1.0.0 → 1.0.1) + +El script `update-version.ps1` incrementa automáticamente **PATCH**. + +## 🧪 Probar Localmente + +1. **Compilar en modo producción:** + ```bash + npm run build + npm run preview + ``` + +2. **Simular actualización:** + - Abre la app en navegador + - Incrementa versión en `service-worker.js` + - Recarga la página (Ctrl+F5) + - Debe aparecer el modal de actualización + +## 🐛 Troubleshooting + +### El modal no aparece +- Verifica que el service worker esté registrado (F12 → Application → Service Workers) +- Asegúrate de cambiar el `CACHE_NAME` en `service-worker.js` +- Desregistra el SW antiguo: `Application → Service Workers → Unregister` + +### La app no se actualiza +- Fuerza actualización: Ctrl+Shift+R (hard reload) +- Limpia cache del navegador +- Verifica que la nueva versión esté deployada + +### PWA no se instala +- Verifica que `site.webmanifest` esté accesible +- Requiere HTTPS (excepto localhost) +- Verifica íconos en `/public/` + +## 📝 Checklist de Deploy + +- [ ] Incrementar versión con `update-version.ps1` +- [ ] Verificar que ambos archivos tengan la misma versión +- [ ] Hacer commit: `git commit -m "chore: bump version to X.X.X"` +- [ ] Build de producción: `npm run build` +- [ ] Build de Docker: `docker build -t frontend:vX.X.X .` +- [ ] Push a registry +- [ ] Deploy en servidor +- [ ] Verificar que usuarios vean el modal de actualización + +## 🎯 Mejores Prácticas + +1. **Siempre** incrementar versión antes de deploy +2. **Nunca** reutilizar números de versión +3. **Probar** localmente antes de deploy +4. **Documentar** cambios en commit message +5. **Notificar** a usuarios si es actualización crítica + +## 🔐 Seguridad + +- Service Worker solo funciona en HTTPS +- Manifest require `start_url` y `scope` correctos +- Cache no almacena datos sensibles (solo assets estáticos) + +## 📱 Compatibilidad + +- ✅ Chrome/Edge (Desktop y Mobile) +- ✅ Safari (iOS 11.3+) +- ✅ Firefox (Desktop y Mobile) +- ✅ Samsung Internet +- ⚠️ IE11 no soportado + +--- + +**Versión actual:** 1.0.87 +**Última actualización:** 2025-11-30 diff --git a/frontend/buildFront.ps1 b/frontend/buildFront.ps1 index 227f5fd..acc0c33 100644 --- a/frontend/buildFront.ps1 +++ b/frontend/buildFront.ps1 @@ -24,4 +24,4 @@ if ($LASTEXITCODE -ne 0) { } Write-Host "`n=== Proceso completado exitosamente ===`n" -ForegroundColor Green -pause + diff --git a/frontend/index.html b/frontend/index.html index 9557828..4aa0ade 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,8 +2,16 @@ - - + + + + + + + + + + AYUTEC - Sistema Inteligente de Inspecciones diff --git a/frontend/package.json b/frontend/package.json index 6895a1b..90029eb 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "checklist-frontend", "private": true, - "version": "1.0.0", + "version": "1.4.0", "type": "module", "scripts": { "dev": "vite", @@ -15,7 +15,8 @@ "axios": "^1.6.5", "react-signature-canvas": "^1.0.6", "lucide-react": "^0.303.0", - "clsx": "^2.1.0" + "clsx": "^2.1.0", + "react-markdown": "^9.0.1" }, "devDependencies": { "@types/react": "^18.2.48", @@ -26,4 +27,4 @@ "tailwindcss": "^3.4.1", "vite": "^5.0.11" } -} +} \ No newline at end of file diff --git a/frontend/public/ayutec_logo.webp b/frontend/public/ayutec_logo.webp new file mode 100644 index 0000000..2fe08dc Binary files /dev/null and b/frontend/public/ayutec_logo.webp differ diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico new file mode 100644 index 0000000..4e4de17 Binary files /dev/null and b/frontend/public/favicon.ico differ diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..61ca79e --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1,3 @@ + \ No newline at end of file diff --git a/frontend/public/service-worker.js b/frontend/public/service-worker.js new file mode 100644 index 0000000..bb0c151 --- /dev/null +++ b/frontend/public/service-worker.js @@ -0,0 +1,72 @@ +// Service Worker para PWA con detección de actualizaciones +// IMPORTANTE: Actualizar esta versión cada vez que se despliegue una nueva versión +const CACHE_NAME = 'ayutec-v1.4.0'; +const urlsToCache = [ + '/', + '/index.html' +]; + +// Instalación del service worker +self.addEventListener('install', (event) => { + console.log('Service Worker: Installing version', CACHE_NAME); + event.waitUntil( + caches.open(CACHE_NAME) + .then((cache) => { + console.log('Service Worker: Caching files'); + return cache.addAll(urlsToCache); + }) + // NO hacer skipWaiting automáticamente - esperar a que el usuario lo active + ); +}); + +// Activación del service worker +self.addEventListener('activate', (event) => { + console.log('Service Worker: Activating...'); + event.waitUntil( + caches.keys().then((cacheNames) => { + return Promise.all( + cacheNames.map((cacheName) => { + if (cacheName !== CACHE_NAME) { + console.log('Service Worker: Deleting old cache:', cacheName); + return caches.delete(cacheName); + } + }) + ); + }) + // NO hacer claim automáticamente - solo cuando el usuario actualice manualmente + ); +}); + +// Estrategia: Network First, fallback to Cache +self.addEventListener('fetch', (event) => { + // Solo cachear peticiones GET + if (event.request.method !== 'GET') { + return; + } + + event.respondWith( + fetch(event.request) + .then((response) => { + // Clone la respuesta + const responseToCache = response.clone(); + + // Actualizar cache con la nueva respuesta + caches.open(CACHE_NAME).then((cache) => { + cache.put(event.request, responseToCache); + }); + + return response; + }) + .catch(() => { + // Si falla la red, usar cache + return caches.match(event.request); + }) + ); +}); + +// Mensaje para notificar actualización +self.addEventListener('message', (event) => { + if (event.data && event.data.type === 'SKIP_WAITING') { + self.skipWaiting(); + } +}); diff --git a/frontend/public/site.webmanifest b/frontend/public/site.webmanifest new file mode 100644 index 0000000..d70c3bb --- /dev/null +++ b/frontend/public/site.webmanifest @@ -0,0 +1,24 @@ +{ + "name": "AYUTEC - Sistema de Inspecciones", + "short_name": "AYUTEC", + "start_url": "/", + "scope": "/", + "icons": [ + { + "src": "/web-app-manifest-192x192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "/web-app-manifest-512x512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ], + "theme_color": "#4f46e5", + "background_color": "#ffffff", + "display": "standalone", + "orientation": "portrait" +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index eddf82c..bde9ba6 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,11 +1,73 @@ import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom' import { useState, useEffect, useRef } from 'react' import SignatureCanvas from 'react-signature-canvas' +import ReactMarkdown from 'react-markdown' import Sidebar from './Sidebar' +import QuestionTypeEditor from './QuestionTypeEditor' +import QuestionAnswerInput from './QuestionAnswerInput' function App() { const [user, setUser] = useState(null) const [loading, setLoading] = useState(true) + const [updateAvailable, setUpdateAvailable] = useState(false) + const [waitingWorker, setWaitingWorker] = useState(null) + + // Detectar actualizaciones del Service Worker + useEffect(() => { + if ('serviceWorker' in navigator) { + // Registrar service worker + navigator.serviceWorker.register('/service-worker.js') + .then((registration) => { + console.log('✅ Service Worker registrado:', registration); + + // Verificar si hay actualización esperando + if (registration.waiting) { + console.log('⚠️ Hay una actualización esperando'); + setWaitingWorker(registration.waiting); + setUpdateAvailable(true); + } + + // Detectar cuando hay nueva versión instalándose + registration.addEventListener('updatefound', () => { + const newWorker = registration.installing; + console.log('🔄 Nueva versión detectada, instalando...'); + + newWorker.addEventListener('statechange', () => { + if (newWorker.state === 'installed' && navigator.serviceWorker.controller) { + // Hay nueva versión disponible - MOSTRAR MODAL, NO ACTIVAR AUTOMÁTICAMENTE + console.log('✨ Nueva versión instalada - esperando confirmación del usuario'); + setWaitingWorker(newWorker); + setUpdateAvailable(true); + } + }); + }); + }) + .catch((error) => { + console.error('❌ Error al registrar Service Worker:', error); + }); + + // Escuchar cambios de controlador (cuando se activa nueva versión) + // SOLO se dispara DESPUÉS de que el usuario presione el botón + let refreshing = false; + navigator.serviceWorker.addEventListener('controllerchange', () => { + if (!refreshing) { + refreshing = true; + console.log('🔄 Controlador cambiado, recargando página...'); + window.location.reload(); + } + }); + } + }, []); + + // Función para actualizar la app - SOLO cuando el usuario presiona el botón + const handleUpdate = () => { + if (waitingWorker) { + console.log('👆 Usuario confirmó actualización - activando nueva versión...'); + // Enviar mensaje al service worker para que se active + waitingWorker.postMessage({ type: 'SKIP_WAITING' }); + // El controllerchange listener manejará la recarga + } + }; useEffect(() => { // Verificar si hay token guardado @@ -29,6 +91,38 @@ function App() { return (
+ {/* Modal de actualización disponible */} + {updateAvailable && ( +
+
+
+
+
+ 🔄 +
+
+

+ ¡Nueva Actualización! +

+

+ Hay una nueva versión disponible con mejoras y correcciones. +
+ Por favor actualiza para continuar. +

+ +

+ La página se recargará automáticamente +

+
+
+
+ )} + {!user ? ( ) : ( @@ -101,9 +195,9 @@ function LoginPage({ setUser }) {
{logoUrl ? ( - Logo + Logo ) : ( -
Sin logo
+
Sin logo
)}

AYUTEC

@@ -176,7 +270,8 @@ function DashboardPage({ user, setUser }) { const [loading, setLoading] = useState(true) const [activeTab, setActiveTab] = useState('checklists') const [activeInspection, setActiveInspection] = useState(null) - const [sidebarOpen, setSidebarOpen] = useState(true) + // Sidebar cerrado por defecto en móvil + const [sidebarOpen, setSidebarOpen] = useState(window.innerWidth >= 1024) const [logoUrl, setLogoUrl] = useState(null); useEffect(() => { const fetchLogo = async () => { @@ -231,14 +326,18 @@ function DashboardPage({ user, setUser }) { if (checklistsRes.ok) { const checklistsData = await checklistsRes.json() console.log('Checklists data:', checklistsData) - setChecklists(Array.isArray(checklistsData) ? checklistsData : []) + // Ordenar por ID descendente para mantener orden consistente + const sortedChecklists = Array.isArray(checklistsData) + ? checklistsData.sort((a, b) => b.id - a.id) + : [] + setChecklists(sortedChecklists) } else { console.error('Error loading checklists:', checklistsRes.status) setChecklists([]) } // Cargar inspecciones - const inspectionsRes = await fetch(`${API_URL}/api/inspections?limit=10`, { + const inspectionsRes = await fetch(`${API_URL}/api/inspections?limit=1000`, { headers: { 'Authorization': `Bearer ${token}`, }, @@ -249,7 +348,11 @@ function DashboardPage({ user, setUser }) { if (inspectionsRes.ok) { const inspectionsData = await inspectionsRes.json() console.log('Inspections data:', inspectionsData) - setInspections(Array.isArray(inspectionsData) ? inspectionsData : []) + // Ordenar por ID descendente para mantener orden consistente + const sortedInspections = Array.isArray(inspectionsData) + ? inspectionsData.sort((a, b) => b.id - a.id) + : [] + setInspections(sortedInspections) } else { console.error('Error loading inspections:', inspectionsRes.status) setInspections([]) @@ -283,34 +386,43 @@ function DashboardPage({ user, setUser }) { /> {/* Main Content */} -
+
{/* Header */}
-
-
+
+
+ {/* Botón hamburguesa (solo móvil) */} + + {/* Logo y Nombre del Sistema */} -
+
{logoUrl ? ( - Logo + Logo ) : ( -
Sin logo
+
Sin logo
)} -
-

AYUTEC

+
+

AYUTEC

Sistema Inteligente de Inspecciones

{/* Sección Activa */} -
- +
+ {activeTab === 'checklists' && '📋'} {activeTab === 'inspections' && '🔍'} {activeTab === 'users' && '👥'} {activeTab === 'reports' && '📊'} {activeTab === 'settings' && '⚙️'} - + {activeTab === 'checklists' && 'Checklists'} {activeTab === 'inspections' && 'Inspecciones'} {activeTab === 'users' && 'Usuarios'} @@ -318,14 +430,24 @@ function DashboardPage({ user, setUser }) { {activeTab === 'settings' && 'Configuración'}
+ {/* Indicador móvil (solo icono) */} +
+ + {activeTab === 'checklists' && '📋'} + {activeTab === 'inspections' && '🔍'} + {activeTab === 'users' && '👥'} + {activeTab === 'reports' && '📊'} + {activeTab === 'settings' && '⚙️'} + +
{/* Content */} -
-
-
+
+
+
{loading ? (
Cargando datos...
@@ -338,7 +460,7 @@ function DashboardPage({ user, setUser }) { onStartInspection={setActiveInspection} /> ) : activeTab === 'inspections' ? ( - + ) : activeTab === 'settings' ? ( ) : activeTab === 'api-tokens' ? ( @@ -355,7 +477,8 @@ function DashboardPage({ user, setUser }) { {/* Modal de Inspección Activa */} {activeInspection && ( setActiveInspection(null)} onComplete={() => { @@ -382,6 +505,14 @@ function SettingsTab({ user }) { api_key: '', model_name: 'gpt-4o' }); + + // Estado para guardar todas las API keys y proveedor activo + const [savedApiKeys, setSavedApiKeys] = useState({ + openai: '', + anthropic: '', + gemini: '' + }); + const [activeProvider, setActiveProvider] = useState(null); // Proveedor actualmente activo useEffect(() => { const fetchLogo = async () => { @@ -408,6 +539,7 @@ function SettingsTab({ user }) { try { const token = localStorage.getItem('token'); const API_URL = import.meta.env.VITE_API_URL || ''; + // Cargar modelos disponibles const modelsRes = await fetch(`${API_URL}/api/ai/models`, { headers: { 'Authorization': `Bearer ${token}` } @@ -416,22 +548,52 @@ function SettingsTab({ user }) { const models = await modelsRes.json(); setAvailableModels(models); } - // Cargar configuración actual + + // Cargar configuración activa const configRes = await fetch(`${API_URL}/api/ai/configuration`, { headers: { 'Authorization': `Bearer ${token}` } }); + if (configRes.ok) { const config = await configRes.json(); setAiConfig(config); + setActiveProvider(config.provider); setFormData({ - provider: config.provider, - api_key: config.api_key, - model_name: config.model_name + provider: config.provider || 'openai', + api_key: config.api_key || '', + model_name: config.model_name || 'gpt-4o' }); + } else if (configRes.status === 404) { + // No hay configuración guardada, usar valores por defecto + console.log('No hay configuración de IA guardada'); + setActiveProvider(null); + } + + // Cargar todas las API keys guardadas + const keysRes = await fetch(`${API_URL}/api/ai/api-keys`, { + headers: { 'Authorization': `Bearer ${token}` } + }); + + if (keysRes.ok) { + const keys = await keysRes.json(); + const newSavedKeys = { + openai: '', + anthropic: '', + gemini: '' + }; + + // Las keys vienen enmascaradas, solo indicamos que existen + Object.keys(keys).forEach(provider => { + if (keys[provider].has_key) { + newSavedKeys[provider] = keys[provider].masked_key; + } + }); + + setSavedApiKeys(newSavedKeys); } - setLoading(false); } catch (error) { console.error('Error loading settings:', error); + } finally { setLoading(false); } }; @@ -501,9 +663,9 @@ function SettingsTab({ user }) {

Logo del Sistema

{logoUrl ? ( - Logo + Logo ) : ( -
Sin logo
+
Sin logo
)}
@@ -524,15 +686,42 @@ function SettingsTab({ user }) {
🤖
OpenAI
GPT-4, GPT-4 Vision
+ {activeProvider === 'openai' && ( +
✓ ACTIVO
+ )} + {savedApiKeys.openai && activeProvider !== 'openai' && ( +
Configurado
+ )} +
@@ -540,19 +729,27 @@ function SettingsTab({ user }) {

API Key

+ {savedApiKeys[formData.provider] && ( +
+ ✓ Ya tienes una API key guardada: {savedApiKeys[formData.provider]} + (Deja vacío para mantener la actual o ingresa una nueva) +
+ )} setFormData({ ...formData, api_key: e.target.value })} className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" - placeholder={formData.provider === 'openai' ? 'sk-...' : 'AIza...'} - required + placeholder={formData.provider === 'openai' ? 'sk-...' : formData.provider === 'anthropic' ? 'sk-ant-...' : 'AIza...'} + required={!savedApiKeys[formData.provider]} />

{formData.provider === 'openai' ? ( <>Obtén tu API key en OpenAI Platform + ) : formData.provider === 'anthropic' ? ( + <>Obtén tu API key en Anthropic Console ) : ( <>Obtén tu API key en Google AI Studio )} @@ -561,22 +758,36 @@ function SettingsTab({ user }) {

Modelo de IA

-
- {filteredModels.map((model) => ( - - ))} -
+ {loading ? ( +
Cargando modelos...
+ ) : filteredModels.length === 0 ? ( +
No hay modelos disponibles para {formData.provider}
+ ) : ( +
+ {filteredModels.map((model) => { + // Mostrar como seleccionado si coincide con formData.model_name + // (independientemente de si el proveedor está activo o no) + const isSelected = formData.model_name === model.id; + + return ( + + ); + })} +
+ )}
- {/* Create Form */} - {showCreateForm && ( + {/* Create/Edit Form */} + {(showCreateForm || editingQuestion) && (
-
+

+ {editingQuestion ? '✏️ Editar Pregunta' : '➕ Nueva Pregunta'} +

+
- setFormData({ ...formData, points: parseInt(e.target.value) || 1 })} className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500" - required - > - - - - - + />
@@ -1097,67 +1755,139 @@ function QuestionsManagerModal({ checklist, onClose }) { />
- {/* Pregunta Condicional */} + {/* Configuración del Tipo de Pregunta */} +
+

📝 Configuración de la Pregunta

+ { + setFormData({ + ...formData, + type: config.type, + options: config + }) + }} + maxPoints={formData.points} + /> +
+ + {/* Subpreguntas y Preguntas Condicionales - Anidadas hasta 5 niveles */}
-

⚡ Pregunta Condicional (opcional)

-
+

+ 📋 Subpreguntas y Preguntas Condicionales (hasta 5 niveles) +

+ +
+ {/* Selector de pregunta padre - SIEMPRE disponible */}

- Esta pregunta aparecerá solo si se responde la pregunta padre -

-
-
- - -

- La pregunta solo se mostrará con esta respuesta + Si seleccionas una pregunta padre, esta pregunta se mostrará como subpregunta debajo de ella

+ + {/* Condición - Solo si hay padre y el padre es boolean/single_choice */} + {formData.parent_question_id && (() => { + const parentQ = questions.find(q => q.id === formData.parent_question_id) + if (!parentQ) return null + + const config = parentQ.options || {} + const parentType = config.type || parentQ.type + const isConditionalParent = ['boolean', 'single_choice'].includes(parentType) + + if (!isConditionalParent) { + return ( +
+

+ ✓ Esta subpregunta aparecerá SIEMPRE debajo de la pregunta padre seleccionada +

+
+ ) + } + + return ( +
+ + +

+ {formData.show_if_answer + ? '⚡ Se mostrará SOLO cuando la respuesta del padre coincida' + : '✓ Se mostrará SIEMPRE debajo de la pregunta padre'} +

+
+ ) + })()} + + {/* Indicador de profundidad */} + {formData.parent_question_id && (() => { + const parentQ = questions.find(q => q.id === formData.parent_question_id) + const parentDepth = parentQ?.depth_level || 0 + const newDepth = parentDepth + 1 + + return ( +
= 5 ? 'bg-red-50 border border-red-200' : 'bg-blue-100'}`}> +

+ 📊 Profundidad: Nivel {newDepth} de 5 máximo + {newDepth >= 5 && ' ⚠️ Máximo alcanzado'} +

+
+ ) + })()}
@@ -1196,30 +1926,51 @@ function QuestionsManagerModal({ checklist, onClose }) { className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500" />
-
- setFormData({ ...formData, allow_photos: e.target.checked })} - className="w-4 h-4 text-purple-600 border-gray-300 rounded focus:ring-purple-500" - /> - -
-
- - setFormData({ ...formData, max_photos: parseInt(e.target.value) })} - className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500" - disabled={!formData.allow_photos} - /> +
+ + {/* Configuración de fotos/archivos */} +
+

+ 📷 Fotos y Archivos Adjuntos +

+
+
+ + +

+ {formData.photo_requirement === 'none' && '• No se podrán adjuntar fotos/archivos'} + {formData.photo_requirement === 'optional' && '• El mecánico puede adjuntar si lo desea'} + {formData.photo_requirement === 'required' && '• El mecánico DEBE adjuntar al menos 1 archivo'} +

+
+ {formData.photo_requirement !== 'none' && ( +
+ + setFormData({ ...formData, max_photos: parseInt(e.target.value) })} + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500" + /> +

+ Cantidad máxima de fotos/PDFs permitidos +

+
+ )}
@@ -1244,7 +1995,7 @@ function QuestionsManagerModal({ checklist, onClose }) { type="submit" className="w-full px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition" > - Crear Pregunta + {editingQuestion ? 'Actualizar Pregunta' : 'Crear Pregunta'}
@@ -1262,7 +2013,14 @@ function QuestionsManagerModal({ checklist, onClose }) {
) : (
- {Object.entries(questionsBySection).map(([section, sectionQuestions]) => ( + {Object.entries(questionsBySection) + .sort(([, questionsA], [, questionsB]) => { + // Ordenar secciones por el 'order' mínimo de sus preguntas + const minOrderA = Math.min(...questionsA.map(q => q.order)) + const minOrderB = Math.min(...questionsB.map(q => q.order)) + return minOrderA - minOrderB + }) + .map(([section, sectionQuestions]) => (

{section}

@@ -1277,26 +2035,56 @@ function QuestionsManagerModal({ checklist, onClose }) { return (
handleDragStart(e, question)} + onDragEnd={handleDragEnd} + onDragOver={(e) => handleDragOver(e, question)} + onDragLeave={handleDragLeave} + onDrop={(e) => handleDrop(e, question)} + className={`p-4 hover:bg-gray-50 flex justify-between items-start cursor-move transition-all duration-200 relative ${ isSubQuestion ? 'bg-blue-50 ml-8 border-l-4 border-blue-300' : '' + } ${ + draggedQuestion?.id === question.id ? 'opacity-40 scale-95' : '' + } ${ + dragOverQuestion?.id === question.id + ? 'bg-purple-50 border-t-4 border-purple-500 shadow-lg pt-8' + : '' }`} > + {/* Indicador visual de zona de drop */} + {dragOverQuestion?.id === question.id && ( +
+
+ + + + Se moverá ANTES de esta pregunta +
+
+ )}
#{question.id}
{isSubQuestion && ( - - ⚡ Condicional + + {question.show_if_answer ? '⚡ Condicional' : '📎 Subpregunta'} )}

{question.text}

{isSubQuestion && parentQuestion && ( -

- → Aparece si #{question.parent_question_id} es {question.show_if_answer} +

+ {question.show_if_answer + ? `→ Aparece si #${question.parent_question_id} es ${question.show_if_answer}` + : `→ Siempre visible debajo de #${question.parent_question_id}` + }

)}
@@ -1304,19 +2092,54 @@ function QuestionsManagerModal({ checklist, onClose }) { {question.type} {question.points} pts - {question.allow_photos && ( + {question.photo_requirement === 'required' && ( + + ⚠️ Fotos obligatorias + + )} + {question.photo_requirement === 'optional' && ( 📷 Máx {question.max_photos} fotos )} + {(!question.photo_requirement || question.allow_photos) && !question.photo_requirement && ( + 📷 Máx {question.max_photos} fotos + )} + {question.send_notification && ( + + 🔔 Notificación + + )}
- +
+ {/* Indicador de drag */} +
+ + + +
+
+ + + +
) })} @@ -1337,6 +2160,118 @@ function QuestionsManagerModal({ checklist, onClose }) {
+ + {/* Audit History Modal */} + {viewingAudit && ( +
+
+ {/* Header */} +
+

📜 Historial de Cambios - Pregunta #{viewingAudit}

+ +
+ + {/* Content */} +
+ {loadingAudit ? ( +
Cargando historial...
+ ) : auditHistory.length === 0 ? ( +
No hay cambios registrados
+ ) : ( +
+ {auditHistory.map((log) => ( +
+
+
+ + {log.action === 'created' ? '➕ Creado' : + log.action === 'updated' ? '✏️ Modificado' : + '🗑️ Eliminado'} + + {log.field_name && ( + + Campo: {log.field_name} + + )} +
+ + {new Date(log.created_at).toLocaleString('es-PY', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + })} + +
+ + {log.field_name && ( +
+
+
Valor anterior:
+
+ {log.old_value || '-'} +
+
+
+
Valor nuevo:
+
+ {log.new_value || '-'} +
+
+
+ )} + + {!log.field_name && (log.old_value || log.new_value) && ( +
+ {log.old_value || log.new_value} +
+ )} + + {log.comment && ( +
+ 💬 {log.comment} +
+ )} + + {log.user && ( +
+ Por: {log.user.full_name || log.user.username} +
+ )} +
+ ))} +
+ )} +
+ + {/* Footer */} +
+ +
+
+
+ )}
) } @@ -1345,10 +2280,17 @@ function ChecklistsTab({ checklists, user, onChecklistCreated, onStartInspection const [showCreateModal, setShowCreateModal] = useState(false) const [showQuestionsModal, setShowQuestionsModal] = useState(false) const [showEditPermissionsModal, setShowEditPermissionsModal] = useState(false) + const [showEditChecklistModal, setShowEditChecklistModal] = useState(false) + const [showLogoModal, setShowLogoModal] = useState(false) const [selectedChecklist, setSelectedChecklist] = useState(null) const [creating, setCreating] = useState(false) const [updating, setUpdating] = useState(false) + const [uploadingLogo, setUploadingLogo] = useState(false) const [mechanics, setMechanics] = useState([]) + const [searchTerm, setSearchTerm] = useState('') + const [aiModeFilter, setAiModeFilter] = useState('all') // all, off, optional, required + const [currentPage, setCurrentPage] = useState(1) + const itemsPerPage = 10 const [formData, setFormData] = useState({ name: '', description: '', @@ -1356,6 +2298,13 @@ function ChecklistsTab({ checklists, user, onChecklistCreated, onStartInspection scoring_enabled: true, mechanic_ids: [] }) + const [editChecklistData, setEditChecklistData] = useState({ + name: '', + description: '', + ai_mode: 'off', + scoring_enabled: true, + generate_pdf: true + }) const [editPermissionsData, setEditPermissionsData] = useState({ mechanic_ids: [] }) @@ -1384,6 +2333,30 @@ function ChecklistsTab({ checklists, user, onChecklistCreated, onStartInspection } } + // Filtrar checklists + const filteredChecklists = checklists.filter(checklist => { + const matchesSearch = + checklist.name?.toLowerCase().includes(searchTerm.toLowerCase()) || + checklist.description?.toLowerCase().includes(searchTerm.toLowerCase()) || + checklist.id?.toString().includes(searchTerm) + + const matchesAiMode = + aiModeFilter === 'all' || checklist.ai_mode === aiModeFilter + + return matchesSearch && matchesAiMode + }) + + // Calcular paginación + const totalPages = Math.ceil(filteredChecklists.length / itemsPerPage) + const startIndex = (currentPage - 1) * itemsPerPage + const endIndex = startIndex + itemsPerPage + const paginatedChecklists = filteredChecklists.slice(startIndex, endIndex) + + // Reset a página 1 cuando cambian los filtros + useEffect(() => { + setCurrentPage(1) + }, [searchTerm, aiModeFilter]) + const handleCreate = async (e) => { e.preventDefault() setCreating(true) @@ -1455,6 +2428,111 @@ function ChecklistsTab({ checklists, user, onChecklistCreated, onStartInspection } } + const handleEditChecklist = async (e) => { + e.preventDefault() + setUpdating(true) + + try { + const token = localStorage.getItem('token') + const API_URL = import.meta.env.VITE_API_URL || '' + + const response = await fetch(`${API_URL}/api/checklists/${selectedChecklist.id}`, { + method: 'PUT', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(editChecklistData), + }) + + if (response.ok) { + setShowEditChecklistModal(false) + setSelectedChecklist(null) + setEditChecklistData({ name: '', description: '', ai_mode: 'off', scoring_enabled: true }) + onChecklistCreated() // Reload checklists + } else { + alert('Error al actualizar checklist') + } + } catch (error) { + console.error('Error:', error) + alert('Error al actualizar checklist') + } finally { + setUpdating(false) + } + } + + const handleUploadLogo = async (e) => { + const file = e.target.files[0] + if (!file) return + + // Validar que sea imagen + if (!file.type.startsWith('image/')) { + alert('Por favor selecciona una imagen válida') + return + } + + setUploadingLogo(true) + + try { + const token = localStorage.getItem('token') + const API_URL = import.meta.env.VITE_API_URL || '' + + const formData = new FormData() + formData.append('file', file) + + const response = await fetch(`${API_URL}/api/checklists/${selectedChecklist.id}/upload-logo`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}` + }, + body: formData + }) + + if (response.ok) { + setShowLogoModal(false) + setSelectedChecklist(null) + onChecklistCreated() // Reload checklists + alert('Logo subido exitosamente') + } else { + const error = await response.json() + alert(error.detail || 'Error al subir el logo') + } + } catch (error) { + console.error('Error:', error) + alert('Error al subir el logo') + } finally { + setUploadingLogo(false) + } + } + + const handleDeleteLogo = async () => { + if (!confirm('¿Estás seguro de eliminar el logo?')) return + + try { + const token = localStorage.getItem('token') + const API_URL = import.meta.env.VITE_API_URL || '' + + const response = await fetch(`${API_URL}/api/checklists/${selectedChecklist.id}/logo`, { + method: 'DELETE', + headers: { + 'Authorization': `Bearer ${token}` + } + }) + + if (response.ok) { + setShowLogoModal(false) + setSelectedChecklist(null) + onChecklistCreated() // Reload checklists + alert('Logo eliminado exitosamente') + } else { + alert('Error al eliminar el logo') + } + } catch (error) { + console.error('Error:', error) + alert('Error al eliminar el logo') + } + } + return (
{user.role === 'admin' && ( @@ -1468,6 +2546,41 @@ function ChecklistsTab({ checklists, user, onChecklistCreated, onStartInspection
)} + {/* Buscador y Filtros */} + {checklists.length > 0 && ( +
+
+ {/* Buscador */} +
+ setSearchTerm(e.target.value)} + className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" + /> +
+ + {/* Filtro de Modo IA */} + +
+ + {/* Contador de resultados */} +
+ Mostrando {startIndex + 1}-{Math.min(endIndex, filteredChecklists.length)} de {filteredChecklists.length} checklists +
+
+ )} + {checklists.length === 0 ? (
{user.role === 'admin' ? ( @@ -1490,10 +2603,39 @@ function ChecklistsTab({ checklists, user, onChecklistCreated, onStartInspection )}
+ ) : filteredChecklists.length === 0 ? ( +
+

No se encontraron checklists con los filtros aplicados

+ +
) : ( - checklists.map((checklist) => ( + <> + {paginatedChecklists.map((checklist) => (
-
+
+ {/* Logo del Checklist */} +
+ {checklist.logo_url ? ( + {`Logo + ) : ( +
+ 📋 +
+ )} +
+

{checklist.name}

{checklist.description}

@@ -1522,9 +2664,36 @@ function ChecklistsTab({ checklists, user, onChecklistCreated, onStartInspection
)}
-
+
{user.role === 'admin' && ( <> + +
- )) + ))} + + {/* Controles de paginación */} + {filteredChecklists.length > itemsPerPage && ( +
+ + +
+ {[...Array(totalPages)].map((_, index) => { + const page = index + 1 + // Mostrar solo páginas cercanas a la actual + if ( + page === 1 || + page === totalPages || + (page >= currentPage - 1 && page <= currentPage + 1) + ) { + return ( + + ) + } else if (page === currentPage - 2 || page === currentPage + 2) { + return ... + } + return null + })} +
+ + +
+ )} + )} {/* Modal Gestionar Preguntas */} @@ -1618,38 +2838,38 @@ function ChecklistsTab({ checklists, user, onChecklistCreated, onStartInspection
{/* Descripción del modo seleccionado */}
{formData.ai_mode === 'off' && (
- Sin IA: El mecánico completa manualmente todas las respuestas. - Sin dependencia de internet o API. + Modo Manual: El operario completa manualmente todas las respuestas. + Sin dependencia de internet o sistemas externos.
)} {formData.ai_mode === 'assisted' && (
- IA Asistida: Cuando se suben fotos, la IA analiza y sugiere - estado, criticidad y observaciones. El mecánico acepta o modifica. -
⚠️ Requiere OPENAI_API_KEY configurada
+ Modo Asistido: Cuando se suben fotos, el sistema analiza y sugiere + estado, criticidad y observaciones. El operario acepta o modifica. +
⚠️ Requiere configuración de API externa
)} {formData.ai_mode === 'full' && (
- IA Completa: El mecánico solo toma fotos y la IA responde + Modo Automático: El operario solo toma fotos y el sistema responde automáticamente todas las preguntas. Ideal para inspecciones rápidas masivas. -
⚠️ Requiere OPENAI_API_KEY configurada
+
⚠️ Requiere configuración de API externa
)}
@@ -1764,6 +2984,118 @@ function ChecklistsTab({ checklists, user, onChecklistCreated, onStartInspection
)} + {/* Modal Editar Checklist */} + {showEditChecklistModal && selectedChecklist && ( +
+
+
+

✏️ Editar Checklist

+ +
+
+ + setEditChecklistData({ ...editChecklistData, name: e.target.value })} + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent" + placeholder="Ej: Inspección Pre-entrega" + required + /> +
+ +
+ +