diff --git a/DOCUMENTACION.md b/DOCUMENTACION.md index 09d8419..0c253d9 100644 --- a/DOCUMENTACION.md +++ b/DOCUMENTACION.md @@ -3,7 +3,23 @@ **Ciclo:** Desarrollo de Aplicaciones Multiplataforma (DAM) — Grado Superior **Proyecto:** Sistema de sincronización bidireccional entre ERP Dolibarr y tienda PrestaShop **Repositorio:** https://github.com/luklpz/ProyectoIntermodular -**Servicio desplegado:** https://proyectointermodular-production-a9c3.up.railway.app +**Servicio desplegado:** https://proyectointermodular-production-a9c3.up.railway.app +**Swagger UI:** https://proyectointermodular-production-a9c3.up.railway.app/swagger-ui.html + +--- + +## Cómo leer este documento + +Este documento está pensado para tres perfiles distintos. Según tu situación, empieza por una sección diferente: + +| Si quieres... | Ve a la sección | +|---|---| +| Entender qué hace el sistema y por qué existe | 1, 2, 3 | +| Entender la arquitectura y el código | 4, 5 | +| Arrancar el proyecto en local | 9 | +| Preparar la defensa oral ante el tribunal | 8, 10 | +| Entender los problemas que surgieron | 7, 8 | +| Saber qué queda pendiente | 11 | --- @@ -13,96 +29,145 @@ 2. [Arquitectura general del sistema](#2-arquitectura-general-del-sistema) 3. [Sistemas externos: Dolibarr y PrestaShop](#3-sistemas-externos-dolibarr-y-prestashop) 4. [sync-service — El núcleo del sistema](#4-sync-service--el-núcleo-del-sistema) - - 4.1 [Stack tecnológico y decisiones de diseño](#41-stack-tecnológico-y-decisiones-de-diseño) - - 4.2 [Estructura de paquetes](#42-estructura-de-paquetes) - - 4.3 [Modelo de dominio — entidades JPA](#43-modelo-de-dominio--entidades-jpa) - - 4.4 [Capa de integración — clientes HTTP](#44-capa-de-integración--clientes-http) - - 4.5 [Flujos de sincronización — servicios](#45-flujos-de-sincronización--servicios) - - 4.6 [Scheduling — tareas automáticas](#46-scheduling--tareas-automáticas) - - 4.7 [API REST propia](#47-api-rest-propia) - - 4.8 [Seguridad](#48-seguridad) - - 4.9 [Persistencia y migraciones](#49-persistencia-y-migraciones) - - 4.10 [Configuración externalizada](#410-configuración-externalizada) 5. [Apps cliente](#5-apps-cliente) - - 5.1 [JavaFX — Cliente escritorio](#51-javafx--cliente-escritorio) - - 5.2 [Android — Cliente móvil](#52-android--cliente-móvil) 6. [Despliegue en Railway](#6-despliegue-en-railway) -7. [Pruebas en entorno real y bugs encontrados](#7-pruebas-en-entorno-real-y-bugs-encontrados) -8. [Decisiones técnicas clave](#8-decisiones-técnicas-clave) -9. [Cómo arrancar el proyecto](#9-cómo-arrancar-el-proyecto) -10. [Glosario](#10-glosario) +7. [Dificultades encontradas](#7-dificultades-encontradas) +8. [Pruebas en entorno real y bugs de PrestaShop 8](#8-pruebas-en-entorno-real-y-bugs-de-prestashop-8) +9. [Decisiones técnicas clave](#9-decisiones-técnicas-clave) +10. [Limitaciones conocidas y trabajo futuro](#10-limitaciones-conocidas-y-trabajo-futuro) +11. [Conclusión](#11-conclusión) +12. [Cómo arrancar el proyecto](#12-cómo-arrancar-el-proyecto) +13. [Glosario](#13-glosario) --- ## 1. Objetivo del proyecto -El proyecto tiene como objetivo integrar dos sistemas comerciales que trabajan de forma independiente: +### El problema -- **Dolibarr**: software ERP (Enterprise Resource Planning) de código abierto. Gestiona el catálogo de productos, el stock y los pedidos internos de la empresa. -- **PrestaShop**: plataforma de comercio electrónico. Es la tienda online visible para los clientes. +Una empresa utiliza dos sistemas de software que no se comunican entre sí: -El problema que resuelve este proyecto es que ambos sistemas no se comunican entre sí por defecto. Esto obliga a los empleados a actualizar manualmente los productos, el stock y los pedidos en los dos sistemas, lo que es lento, propenso a errores y genera inconsistencias (por ejemplo, vender un producto sin stock porque el ERP aún no había actualizado la tienda). +- **Dolibarr** es el ERP (sistema de gestión empresarial). Aquí se gestiona el catálogo de productos, el stock real del almacén, los pedidos internos y la contabilidad. +- **PrestaShop** es la tienda online. Los clientes navegan aquí, ven productos, los añaden al carrito y realizan sus pedidos. -**Los dos requisitos funcionales del enunciado son:** +El problema es que cualquier cambio en uno de los sistemas hay que replicarlo manualmente en el otro. Si se añade un producto nuevo en Dolibarr, hay que crearlo también en PrestaShop. Si un cliente compra en la tienda, hay que registrar el pedido también en Dolibarr. Este proceso manual es lento, propenso a errores y genera inconsistencias: por ejemplo, un cliente puede comprar un producto que ya no tiene stock porque el ERP no había actualizado la tienda. -1. Sincronizar productos, propiedades y stock del ERP hacia PrestaShop. -2. Registrar en el ERP cualquier venta realizada en PrestaShop. +### La solución -La solución desarrollada es un **servicio intermediario** (`sync-service`) que actúa como puente entre los dos sistemas, consultando sus APIs y manteniendo la coherencia de datos de forma automática. +Este proyecto desarrolla un **sistema de sincronización automática** que actúa como puente entre los dos sistemas. Sus funciones son: + +1. **Publicar productos**: mantiene la tienda actualizada con los productos y precios del ERP. +2. **Sincronizar stock**: actualiza la disponibilidad de productos en la tienda con el stock real del almacén. +3. **Importar pedidos**: cuando un cliente compra en la tienda, el pedido aparece automáticamente en el ERP como comanda y factura. + +Todo esto ocurre de forma automática, periódica, y sin intervención humana. El personal puede monitorizar el estado desde una app de escritorio (Windows) o desde el móvil. + +### Lo que el proyecto NO hace + +- No modifica Dolibarr ni PrestaShop. Solo los consulta a través de sus APIs oficiales. +- No reemplaza ninguno de los dos sistemas. Los dos siguen siendo independientes. +- No es una integración en tiempo real (cada operación tiene su intervalo de ejecución). --- ## 2. Arquitectura general del sistema -El sistema se divide en tres capas: +El sistema se divide en tres capas independientes: ``` -┌──────────────────────────────────────────────────────────┐ -│ CAPA 1 — APPS CLIENTE │ -│ │ -│ ┌──────────────────┐ ┌────────────────────┐ │ -│ │ JavaFX │ │ Android │ │ -│ │ (escritorio) │ │ (móvil) │ │ -│ └────────┬─────────┘ └────────┬───────────┘ │ -└────────────┼────────────────────────── ┼────────────────-┘ - │ HTTP REST + Basic Auth │ - │ │ -┌────────────▼───────────────────────────▼────────────────┐ -│ CAPA 2 — SYNC-SERVICE │ -│ │ -│ Spring Boot 3 · Java 21 · Desplegado en Railway │ -│ │ -│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ -│ │ API REST │ │ Sync Jobs │ │ Persistencia │ │ -│ │ (endpoints │ │ (@Scheduled) │ │ JPA + H2/ │ │ -│ │ para apps) │ │ │ │ MySQL │ │ -│ └──────────────┘ └──────────────┘ └──────────────┘ │ -└───────────────┬──────────────────┬───────────────────────┘ - │ │ - ┌──────────▼──────┐ ┌────────▼───────────┐ - │ DOLIBARR │ │ PRESTASHOP │ - │ ERP · REST API │ │ E-commerce · WS │ - │ DOLAPIKEY auth │ │ ws_key query param│ - └─────────────────┘ └────────────────────┘ +╔══════════════════════════════════════════════════════════════╗ +║ CAPA 1 — APPS CLIENTE ║ +║ ║ +║ ┌─────────────────────┐ ┌─────────────────────┐ ║ +║ │ JavaFX │ │ Android │ ║ +║ │ Cliente escritorio│ │ Cliente móvil │ ║ +║ └──────────┬──────────┘ └──────────┬──────────┘ ║ +╚══════════════╪═══════════════════════════╪════════════════════╝ + │ HTTP REST + Basic Auth │ + │ (peticiones JSON) │ +╔══════════════▼═══════════════════════════▼════════════════════╗ +║ CAPA 2 — SYNC-SERVICE ║ +║ ║ +║ ┌────────────────┐ ┌─────────────────┐ ┌──────────────┐ ║ +║ │ API REST │ │ Sync Jobs │ │ Base datos │ ║ +║ │ (para apps │ │ (@Scheduled) │ │ H2 / MySQL │ ║ +║ │ cliente) │ │ automáticos │ │ │ ║ +║ └────────────────┘ └─────────────────┘ └──────────────┘ ║ +║ Spring Boot 3 · Java 21 · Railway ║ +╚═══════════════════╪══════════════════════╪════════════════════╝ + │ │ + ┌─────────────▼──────┐ ┌───────────▼────────────┐ + │ DOLIBARR │ │ PRESTASHOP │ + │ ERP · REST API │ │ E-commerce · Webservice│ + │ Auth: DOLAPIKEY │ │ Auth: ws_key param │ + └────────────────────┘ └─────────────────────────┘ ``` -**Capa 1 — Apps cliente:** Interfaces de usuario para que el personal de la empresa pueda monitorizar el estado de la sincronización, ver los mappings de productos y pedidos, y lanzar sincronizaciones manuales. Hay dos implementaciones: escritorio (JavaFX) y móvil (Android). +**Capa 1 — Apps cliente:** Interfaces de usuario para el personal de la empresa. Permiten ver el estado de la sincronización, revisar mappings de productos y pedidos, y lanzar sincronizaciones manuales cuando sea necesario. Hay dos implementaciones: JavaFX para escritorio Windows y Android para móvil. -**Capa 2 — sync-service:** El núcleo del sistema. Es un servidor Spring Boot que: +**Capa 2 — sync-service:** El núcleo del sistema. Es un servidor Spring Boot desplegado en la nube que: - Expone una API REST consumida por las apps cliente. -- Ejecuta tareas de sincronización periódicas de forma automática. -- Mantiene una base de datos local con el historial de sincronizaciones y el mapeo de IDs entre sistemas. +- Ejecuta automáticamente las sincronizaciones en intervalos configurables. +- Mantiene una base de datos local con el historial y los mappings de IDs. -**Capa 3 — Sistemas externos:** Dolibarr y PrestaShop ya existían con sus propias APIs. El proyecto no modifica ninguno de estos dos sistemas; solo los consulta a través de sus APIs oficiales. +**Capa 3 — Sistemas externos:** Dolibarr y PrestaShop ya existían. El proyecto no modifica ninguno de los dos; solo los consulta mediante sus APIs oficiales. -### ¿Por qué esta arquitectura? +### Flujo completo de una petición -La decisión de tener un servicio intermediario en lugar de conectar directamente las apps cliente con Dolibarr y PrestaShop tiene varias ventajas: +Para entender cómo se conectan todas las piezas, este es el recorrido de una sincronización de productos lanzada desde la app JavaFX: -- **Un único punto de acceso**: las apps cliente solo necesitan hablar con el sync-service. Si cambia la URL de Dolibarr o las claves de API, solo hay que cambiar la configuración del sync-service. -- **Lógica centralizada**: la lógica de sincronización (cómo se mapean los campos, qué hacer cuando falla un ítem, cómo se registran los errores) está en un solo lugar. -- **Historial persistente**: el sync-service guarda en base de datos el resultado de cada ejecución, algo que no sería posible si las apps cliente hicieran las llamadas directamente. -- **Sincronización automática sin intervención**: el scheduling solo puede vivir en un proceso servidor persistente, no en una app de escritorio que puede estar cerrada. +``` +Usuario pulsa "Sincronizar productos" en JavaFX + │ + ▼ +JavaFX llama POST https://railway.app/api/sync/products + con header: Authorization: Basic base64(admin:admin123) + │ + ▼ +Spring Security verifica las credenciales + │ + ▼ +SyncController.triggerProductSync() + │ + ▼ +ProductSyncService.synchronize() + ├── Escribe SyncLog(startedAt=ahora) en BD local + ├── DolibarrClient.getProducts() → GET /dolibarr/api/index.php/products + │ con header DOLAPIKEY: xxx + │ ← recibe lista de productos JSON + │ + ├── Para cada producto: + │ ├── PrestashopClient.getProductByReference(sku) + │ │ → GET /tienda/api/products?filter[reference]=SKU&ws_key=xxx + │ │ ← recibe producto existente O {"products": false} + │ │ + │ ├── Si NO existe: PrestashopClient.createProduct() + │ │ → POST /tienda/api/products?ws_key=xxx (cuerpo: XML) + │ │ ← recibe producto creado con nuevo ID + │ │ + │ ├── Si SÍ existe: PrestashopClient.updateProduct() + │ │ → PUT /tienda/api/products/{id}?ws_key=xxx (cuerpo: XML) + │ │ + │ └── Guarda/actualiza ProductMapping en BD local + │ + └── Actualiza SyncLog(finishedAt, itemsProcessed, itemsFailed) en BD local + │ + ▼ +Respuesta JSON a JavaFX: +{"syncType":"PRODUCT_PUSH","itemsProcessed":12,"itemsFailed":0} + │ + ▼ +JavaFX muestra: "Completado: 12 procesados" +``` + +### ¿Por qué esta arquitectura y no conectar directamente? + +La alternativa sería que cada app cliente hablara directamente con las APIs de Dolibarr y PrestaShop. Esta opción fue descartada por varios motivos: + +- **Las claves de API estarían en el dispositivo del usuario**: cualquiera con acceso al móvil o al PC podría ver las credenciales de Dolibarr. +- **Sin historial**: no habría base de datos local para registrar qué sincronizaciones han ocurrido y sus resultados. +- **Sin automatización**: las apps cliente pueden estar cerradas. El scheduling solo puede vivir en un proceso servidor que esté siempre activo. +- **Duplicación de lógica**: si hubiera tres apps cliente (JavaFX, Android, web), cada una tendría que implementar la misma lógica de sincronización. --- @@ -110,62 +175,124 @@ La decisión de tener un servicio intermediario en lugar de conectar directament ### 3.1 Dolibarr -**URL base de la API:** `https://prestashop.loading.net/dolibarr/api/index.php` +**URL base:** `https://prestashop.loading.net/dolibarr` +**URL API:** `https://prestashop.loading.net/dolibarr/api/index.php` -Dolibarr tiene una API REST nativa bien documentada. La autenticación se hace mediante un header HTTP: +Dolibarr tiene una API REST nativa bien documentada y estándar. La autenticación se hace con un header HTTP en cada petición: ``` -DOLAPIKEY: +DOLAPIKEY: 3f7a9e2b1c8d4f6a... ``` -La clave se obtiene desde el panel de administración de Dolibarr (Settings → Users → API key). La API es estándar: devuelve JSON y sigue convenciones REST (GET para leer, POST para crear, PUT para actualizar). +La clave se genera en el panel de administración de Dolibarr (Configuración → Usuarios → clave API). Es sencilla de usar y funciona sin problemas. -**Recursos utilizados en el proyecto:** -- `GET /products` — obtener lista de productos -- `GET /products/{id}` — obtener producto concreto -- `POST /products` — crear producto -- `PUT /products/{id}` — actualizar producto -- `GET /products/{id}/stock` — obtener stock -- `GET /thirdparties` — buscar clientes (terceros) -- `POST /thirdparties` — crear cliente -- `POST /orders` — crear comanda -- `POST /invoices` — crear factura +**Recursos de la API utilizados:** + +| Método | Endpoint | Para qué se usa | +|---|---|---| +| GET | `/products` | Obtener todos los productos activos | +| GET | `/products/{id}` | Obtener un producto concreto | +| PUT | `/products/{id}` | Actualizar un producto | +| GET | `/products/{id}/stock` | Obtener stock actual | +| GET | `/thirdparties?email=x` | Buscar cliente por email | +| POST | `/thirdparties` | Crear cliente nuevo | +| POST | `/orders` | Crear comanda | +| POST | `/invoices` | Crear factura | +| POST | `/invoices/{id}/setpaid` | Marcar factura como pagada | ### 3.2 PrestaShop -**URL base de la API:** `https://prestashop.loading.net/tienda/api` +**URL base:** `https://prestashop.loading.net/tienda` +**URL API:** `https://prestashop.loading.net/tienda/api` -PrestaShop tiene un Webservice nativo. Aquí surgió el primer problema: la autenticación oficial del Webservice usa **HTTP Basic Auth** (la clave como usuario, sin contraseña). Sin embargo, **el servidor nginx del hosting elimina el header `Authorization`** antes de que llegue a PHP, lo que hace que cada petición falle con 401. +PrestaShop tiene un Webservice nativo, pero presenta varias complicaciones respecto a Dolibarr. -**Solución:** Pasar la clave de autenticación como **query param** en lugar de header: +**Problema de autenticación con el hosting:** + +La autenticación oficial del Webservice de PS usa HTTP Basic Auth (la clave como nombre de usuario, sin contraseña). El problema es que el servidor nginx del hosting compartido **elimina el header `Authorization` antes de que llegue a PHP**, lo que hace que PS no reciba nunca la clave y rechace todas las peticiones con 401. + +La solución es pasar la clave como **query parameter** en la URL: ``` -GET /api/products?ws_key=TU_CLAVE&output_format=JSON +# Incorrecto (nginx elimina este header): +Authorization: Basic BASE64(mi_clave_ws:) + +# Correcto (sobrevive al proxy nginx): +GET /tienda/api/products?ws_key=mi_clave_ws&output_format=JSON ``` -Este comportamiento está documentado como limitación conocida en hostings compartidos con nginx como proxy inverso. +**Problema del formato XML:** -**Recursos utilizados:** -- `GET /api/products` — listar productos -- `GET /api/products?filter[reference]=SKU` — buscar por referencia -- `POST /api/products` — crear producto (XML) -- `PUT /api/products/{id}` — actualizar producto (XML) -- `GET /api/stock_availables` — consultar stock -- `PUT /api/stock_availables/{id}` — actualizar stock -- `GET /api/orders` — listar pedidos -- `GET /api/customers/{id}` — obtener datos del cliente +PrestaShop Webservice usa **XML para escrituras** y puede devolver JSON para lecturas. Esto significa que crear o actualizar un producto requiere construir un documento XML. No es posible enviar JSON en las peticiones POST/PUT. -> **Importante:** PrestaShop Webservice usa **XML** para crear y actualizar recursos (no JSON). JSON solo está disponible para las respuestas de lectura mediante `output_format=JSON`. +Ejemplo del XML que hay que enviar para crear un producto: + +```xml + + + + CAMISETA-001 + 19.99 + 1 + 1 + 2 + + Camiseta básica blanca + + + Descripción del producto + + + + 2 + + + + +``` + +Este XML se construye programáticamente en `PrestashopClient.java` usando `StringBuilder`. No se usa ninguna librería XML porque la estructura es simple y fija. + +**¿Por qué PS usa XML?** El Webservice de PrestaShop es antiguo (diseñado alrededor de 2010) y XML era el estándar de la época para intercambio de datos. PS ha añadido soporte JSON para lecturas, pero las escrituras siguen requiriendo XML por compatibilidad hacia atrás. + +**Recursos de la API utilizados:** + +| Método | Endpoint | Para qué se usa | +|---|---|---| +| GET | `/api/products?filter[reference]=SKU` | Buscar producto por SKU | +| POST | `/api/products` | Crear producto (XML) | +| PUT | `/api/products/{id}` | Actualizar producto (XML) | +| GET | `/api/stock_availables?filter[id_product]=ID` | Obtener stock | +| PUT | `/api/stock_availables/{id}` | Actualizar stock (XML) | +| GET | `/api/orders?filter[date_add]=[desde,hasta]` | Obtener pedidos recientes | +| GET | `/api/customers/{id}` | Obtener datos de un cliente | ### 3.3 El identificador común: SKU -El mayor reto de la integración es que Dolibarr y PrestaShop tienen IDs de producto completamente independientes. Un producto puede ser ID 15 en Dolibarr e ID 42 en PrestaShop, sin ninguna relación entre esos números. +El mayor reto técnico de la integración es que Dolibarr y PrestaShop tienen IDs de producto completamente independientes y sin ninguna relación entre sí. -La solución es usar el **SKU** (código de referencia del producto) como identificador común: -- En Dolibarr se llama `ref` -- En PrestaShop se llama `reference` +**Ejemplo concreto:** -El sync-service mantiene una tabla `product_mapping` que relaciona los dos IDs a través del SKU. Si el SKU es `CAMISETA-001`, la tabla guarda que ese producto es el ID 15 en Dolibarr y el ID 42 en PrestaShop. +| Campo | Dolibarr | PrestaShop | +|---|---|---| +| ID interno | 15 | 42 | +| Campo de referencia | `ref = "CAMISETA-001"` | `reference = "CAMISETA-001"` | +| Precio | 19.99 | 19.99 | + +El ID 15 de Dolibarr y el ID 42 de PrestaShop representan el mismo producto físico. La única forma de saberlo es que ambos tienen la misma referencia: `CAMISETA-001`. Este campo es el **SKU**. + +El sync-service mantiene una tabla `product_mapping` que actúa como diccionario de traducción: + +``` +product_mapping: +┌────┬───────────────┬─────────────┬──────────────┬────────────────┬─────────────┐ +│ id │ sku │ dolibarr_id │ prestashop_id│ last_synced_at │ sync_status │ +├────┼───────────────┼─────────────┼──────────────┼────────────────┼─────────────┤ +│ 1 │ CAMISETA-001 │ 15 │ 42 │ 2025-05-10 ... │ SYNCED │ +│ 2 │ PANTALON-002 │ 16 │ 43 │ 2025-05-10 ... │ SYNCED │ +│ 3 │ ZAPATOS-003 │ 17 │ (null) │ (null) │ PENDING │ +└────┴───────────────┴─────────────┴──────────────┴────────────────┴─────────────┘ +``` --- @@ -175,440 +302,484 @@ El sync-service mantiene una tabla `product_mapping` que relaciona los dos IDs a | Tecnología | Versión | Justificación | |---|---|---| -| Java | 21 | LTS activo. Records, pattern matching, sealed classes disponibles | -| Spring Boot | 3.x | Framework estándar de la industria. Reduce código boilerplate enormemente | -| Maven | 3.9 | Gestión de dependencias y build estándar en proyectos Java | -| Spring Data JPA + Hibernate | incluido en Boot | Abstracción sobre JDBC. Repositorios sin SQL para operaciones CRUD | -| H2 | dev | Base de datos en memoria para desarrollo. Sin instalación | -| MySQL | prod | Base de datos relacional estándar. Plugin de Railway | -| Flyway | incluido en Boot | Migraciones de esquema versionadas. SQL puro, sin XML | -| RestClient | Spring 6 | Cliente HTTP moderno. Reemplaza RestTemplate (deprecated) y WebClient (reactivo, innecesario) | -| Jackson | incluido en Boot | Serialización/deserialización JSON estándar | -| Lombok | 1.18 | Reduce boilerplate: getters, constructores, builders | -| SLF4J + Logback | incluido en Boot | Logging estándar. Configurado en `logback.xml` | -| SpringDoc OpenAPI | 2.x | Genera Swagger UI automáticamente desde las anotaciones | +| Java | 21 | LTS activo. Records, pattern matching disponibles. Versión estándar en empresas | +| Spring Boot | 3.x | Framework industrial estándar. Reduce enormemente el código de infraestructura | +| Maven | 3.9 | Gestión de dependencias estándar en proyectos Java | +| Spring Data JPA + Hibernate | incluido en Boot | Abstracción sobre JDBC. Repositorios sin SQL para operaciones CRUD estándar | +| H2 | dev | Base de datos en memoria. Cero instalación, arranca con la app | +| MySQL | prod | Base de datos relacional estándar en producción. Plugin de Railway | +| Flyway | incluido en Boot | Migraciones de esquema versionadas en SQL puro | +| RestClient | Spring 6 | Cliente HTTP moderno, síncrono, tipo-seguro | +| Jackson | incluido en Boot | Serialización/deserialización JSON estándar de facto en Java | +| Lombok | 1.18 | Elimina boilerplate: getters, setters, constructores, builders | +| SLF4J + Logback | incluido en Boot | Logging estándar. Sin dependencias adicionales | +| SpringDoc OpenAPI | 2.x | Genera Swagger UI automáticamente desde anotaciones del código | | Spring Security | incluido en Boot | HTTP Basic Auth para proteger todos los endpoints | -| JUnit 5 + Mockito | incluido en Boot | Tests unitarios e integración | +| JUnit 5 + Mockito | incluido en Boot | Tests unitarios | -**Qué NO se usó y por qué:** +**Tecnologías descartadas y por qué:** -- **No WebClient / Reactor**: el proyecto es síncrono. La programación reactiva añade complejidad sin beneficio real cuando no hay miles de peticiones concurrentes. -- **No Docker Compose / Kubernetes**: hosting compartido y Railway no requieren esto. Sobre-ingeniería para el contexto del proyecto. -- **No Kafka / RabbitMQ**: no hay comunicación asíncrona entre microservicios porque no hay microservicios. -- **No CQRS / Event Sourcing / DDD táctico**: patrones válidos en sistemas grandes. Para este proyecto añaden complejidad sin valor. -- **No RestTemplate**: está marcado como deprecated en Spring 6. +- **WebClient / Reactor**: programación reactiva non-blocking. Añade complejidad sin beneficio cuando no hay miles de peticiones concurrentes. Este sistema hace llamadas síncronas secuenciales. +- **Docker Compose**: innecesario. Railway gestiona el contenedor. En local, H2 en memoria elimina la necesidad de un contenedor de base de datos. +- **Kafka / RabbitMQ**: para comunicación asíncrona entre microservicios. Este proyecto es un monolito bien estructurado en capas; no hay microservicios. +- **RestTemplate**: deprecated en Spring 6. Reemplazado por RestClient. +- **CQRS / Event Sourcing / DDD táctico**: patrones válidos en sistemas grandes con equipos numerosos. Aquí añadirían complejidad sin valor real. ### 4.2 Estructura de paquetes ``` sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/ │ -├── SyncServiceApplication.java # Punto de entrada (@SpringBootApplication) +├── SyncServiceApplication.java ← Punto de entrada (@SpringBootApplication) │ -├── config/ # Configuración de Spring -│ ├── IntegrationProperties.java # @ConfigurationProperties para Dolibarr y PS -│ ├── SecurityConfig.java # Spring Security (Basic Auth) -│ ├── SchedulingConfig.java # ThreadPoolTaskScheduler de 1 hilo -│ ├── DolibarrClientConfig.java # Bean RestClient para Dolibarr -│ ├── PrestashopClientConfig.java # Bean RestClient para PrestaShop -│ └── SslConfig.java # Configuración SSL/TLS +├── config/ ← Configuración de Spring (beans, security...) +│ ├── IntegrationProperties.java ← @ConfigurationProperties validadas al inicio +│ ├── SecurityConfig.java ← Spring Security: Basic Auth, STATELESS, CSRF off +│ ├── SchedulingConfig.java ← ThreadPoolTaskScheduler de 1 hilo +│ ├── DolibarrClientConfig.java ← Bean RestClient para Dolibarr +│ ├── PrestashopClientConfig.java ← Bean RestClient para PrestaShop +│ └── SslConfig.java ← Configuración SSL/TLS para HTTPS │ -├── integration/ # Clientes HTTP hacia sistemas externos +├── integration/ ← Capa de acceso a APIs externas │ ├── dolibarr/ -│ │ ├── DolibarrClient.java # Todas las llamadas a la API de Dolibarr -│ │ ├── dto/ # DTOs: DolibarrProductDto, DolibarrOrderDto... +│ │ ├── DolibarrClient.java ← Todas las llamadas a la API de Dolibarr +│ │ ├── dto/ ← Objetos de datos del API de Dolibarr +│ │ │ ├── DolibarrProductDto.java +│ │ │ ├── DolibarrOrderDto.java +│ │ │ ├── DolibarrThirdpartyDto.java +│ │ │ ├── DolibarrInvoiceDto.java +│ │ │ └── DolibarrStockUpdateDto.java │ │ └── exception/DolibarrApiException.java │ └── prestashop/ -│ ├── PrestashopClient.java # Todas las llamadas al WS de PrestaShop -│ ├── dto/ # DTOs: PrestashopProductDto, PrestashopOrderDto... +│ ├── PrestashopClient.java ← Todas las llamadas al WS de PrestaShop +│ ├── dto/ ← Objetos de datos del WS de PrestaShop +│ │ ├── PrestashopProductDto.java +│ │ ├── PrestashopOrderDto.java +│ │ ├── PrestashopCustomerDto.java +│ │ └── PrestashopStockAvailableDto.java │ └── exception/PrestashopApiException.java │ -├── mapping/ # Entidades JPA y repositorios -│ ├── ProductMapping.java # Mapeo SKU ↔ IDs Dolibarr/PS -│ ├── OrderMapping.java # Mapeo ID pedido PS ↔ Dolibarr -│ ├── SyncLog.java # Registro de cada ejecución de sync -│ ├── SyncStatus.java # Enum: PENDING, SYNCED, ERROR -│ ├── OrderSyncStatus.java # Enum: IMPORTED, INVOICED, ERROR -│ ├── SyncType.java # Enum: PRODUCT_PUSH, STOCK_PUSH, ORDER_PULL -│ ├── ProductMappingRepository.java # Spring Data JPA +├── mapping/ ← Entidades JPA y repositorios (base de datos local) +│ ├── ProductMapping.java +│ ├── OrderMapping.java +│ ├── SyncLog.java +│ ├── SyncStatus.java ← Enum: PENDING, SYNCED, ERROR +│ ├── OrderSyncStatus.java ← Enum: IMPORTED, INVOICED, ERROR +│ ├── SyncType.java ← Enum: PRODUCT_PUSH, STOCK_PUSH, ORDER_PULL +│ ├── ProductMappingRepository.java │ ├── OrderMappingRepository.java │ └── SyncLogRepository.java │ -├── sync/ # Lógica de sincronización -│ ├── ProductSyncService.java # Flujo 1: productos Dolibarr → PS -│ ├── StockSyncService.java # Flujo 2: stock Dolibarr → PS -│ ├── OrderSyncService.java # Flujo 3: pedidos PS → Dolibarr -│ ├── SyncScheduler.java # @Scheduled que dispara los 3 flujos -│ └── SyncResult.java # Record con processed, failed, errors +├── sync/ ← Lógica de negocio: los tres flujos +│ ├── ProductSyncService.java ← Flujo 1: Dolibarr → PrestaShop (productos) +│ ├── StockSyncService.java ← Flujo 2: Dolibarr → PrestaShop (stock) +│ ├── OrderSyncService.java ← Flujo 3: PrestaShop → Dolibarr (pedidos) +│ ├── SyncScheduler.java ← @Scheduled: dispara los 3 flujos automáticamente +│ └── SyncResult.java ← Record(processed, failed, errors) │ -└── api/ # API REST expuesta a las apps cliente +└── api/ ← API REST expuesta a las apps cliente ├── controller/ - │ ├── SyncController.java # POST /api/sync/{products,stock,orders} - │ ├── MappingController.java # GET /api/mappings/{products,orders} - │ └── SyncLogController.java # GET /api/logs - └── dto/ # DTOs de respuesta para las apps cliente + │ ├── SyncController.java ← POST /api/sync/{products,stock,orders} + │ ├── MappingController.java ← GET /api/mappings/{products,orders} + │ └── SyncLogController.java ← GET /api/logs, GET /api/logs/{id} + └── dto/ ← Objetos de respuesta para las apps cliente ├── SyncTriggerResponse.java ├── SyncLogResponse.java ├── ProductMappingResponse.java └── OrderMappingResponse.java ``` -Esta estructura sigue el principio de **separación de responsabilidades**: -- `integration/` solo sabe hablar con APIs externas. No conoce la lógica de negocio. -- `sync/` contiene la lógica de negocio. No sabe cómo funciona HTTP. -- `mapping/` define el modelo de datos persistente. -- `api/` expone los datos al exterior. +**Principio de separación de responsabilidades:** +- `integration/` solo sabe hablar con APIs externas. No contiene lógica de negocio. +- `sync/` contiene la lógica de negocio. No sabe cómo se hace una petición HTTP. +- `mapping/` define el modelo de datos. No sabe nada del resto. +- `api/` expone datos al exterior. No ejecuta lógica de sincronización. + +Si mañana PrestaShop cambia el formato de su API, solo hay que modificar `PrestashopClient.java`. El resto del sistema no se toca. ### 4.3 Modelo de dominio — entidades JPA -El sistema persiste tres tipos de datos en la base de datos local. +#### Diagrama de la base de datos -#### ProductMapping +``` +┌──────────────────────────────┐ +│ product_mapping │ +├──────────────────────────────┤ +│ id BIGINT (PK) │ +│ sku VARCHAR(100) │ ← UNIQUE. Identificador común +│ dolibarr_id INT │ ← ID del producto en Dolibarr +│ prestashop_id INT │ ← ID del producto en PrestaShop +│ last_synced_at DATETIME │ ← Cuándo fue la última sync exitosa +│ sync_status VARCHAR(20) │ ← PENDING / SYNCED / ERROR +│ error_message TEXT │ ← null si SYNCED; mensaje si ERROR +└──────────────────────────────┘ -Relaciona cada producto mediante su SKU compartido con sus IDs en cada sistema. +┌──────────────────────────────┐ +│ order_mapping │ +├──────────────────────────────┤ +│ id BIGINT (PK) │ +│ prestashop_order_id INT │ ← ID del pedido en PrestaShop +│ dolibarr_order_id INT │ ← ID de la comanda en Dolibarr +│ dolibarr_invoice_id INT │ ← ID de la factura en Dolibarr +│ imported_at DATETIME │ +│ status VARCHAR │ ← IMPORTED / INVOICED / ERROR +└──────────────────────────────┘ -```java -@Entity @Table(name = "product_mapping") -public class ProductMapping { - Long id; - String sku; // identificador común, único en la tabla - Integer dolibarrId; // ID del producto en Dolibarr - Integer prestashopId; // ID del producto en PrestaShop - Instant lastSyncedAt; // cuándo fue la última sync exitosa - SyncStatus syncStatus; // PENDING / SYNCED / ERROR - String errorMessage; // null si syncStatus = SYNCED -} +┌──────────────────────────────┐ +│ sync_log │ +├──────────────────────────────┤ +│ id BIGINT (PK) │ +│ sync_type VARCHAR(30) │ ← PRODUCT_PUSH / STOCK_PUSH / ORDER_PULL +│ started_at DATETIME │ ← Cuándo empezó +│ finished_at DATETIME │ ← null si aún está ejecutando +│ items_processed INT │ ← Cuántos ítems se procesaron bien +│ items_failed INT │ ← Cuántos fallaron +│ error_details TEXT │ ← null si todo fue bien; errores si hubo fallos +└──────────────────────────────┘ ``` -#### OrderMapping +Las tres tablas son **independientes** entre sí (sin claves foráneas entre ellas). Esto es intencional: si se borra un mapping de producto, los logs de sincronización de ese producto siguen existiendo como historial. -Registra qué pedidos de PrestaShop han sido importados a Dolibarr y sus IDs resultantes. +**¿Por qué `Instant` para fechas?** +`Instant` representa un punto exacto en el tiempo en UTC, sin zona horaria. Es el tipo correcto para timestamps del sistema. Al guardarlo en MySQL, Hibernate lo convierte a `DATETIME`. Al leerlo, reconstruye el `Instant`. No hay ambigüedades de zona horaria. -```java -@Entity @Table(name = "order_mapping") -public class OrderMapping { - Long id; - Integer prestashopOrderId; // ID del pedido en PS - Integer dolibarrOrderId; // ID de la comanda creada en Dolibarr - Integer dolibarrInvoiceId; // ID de la factura (puede ser null) - Instant importedAt; - OrderSyncStatus status; // IMPORTED / INVOICED / ERROR -} -``` - -#### SyncLog - -Registra el resultado de cada ejecución de un flujo de sincronización. - -```java -@Entity @Table(name = "sync_log") -public class SyncLog { - Long id; - SyncType syncType; // PRODUCT_PUSH / STOCK_PUSH / ORDER_PULL - Instant startedAt; - Instant finishedAt; // null mientras está ejecutando - Integer itemsProcessed; - Integer itemsFailed; - String errorDetails; // null si todo fue bien; texto con errores si hubo fallos -} -``` - -**Por qué `Instant` para las fechas:** `Instant` representa un punto exacto en el tiempo en UTC, sin zona horaria. Es el tipo correcto para timestamps de sistema. Hibernate lo persiste como `DATETIME` en MySQL y funciona correctamente con H2 en modo MySQL. - -**Por qué no `@Data` de Lombok en entidades JPA:** `@Data` genera `equals()` y `hashCode()` basados en todos los campos. En entidades JPA esto puede causar bucles infinitos si hay relaciones bidireccionales y problemas de rendimiento al comparar entidades en colecciones. Se usan `@Getter`, `@Setter`, `@Builder`, etc. de forma explícita. +**¿Por qué no `@Data` de Lombok?** +`@Data` genera `equals()` y `hashCode()` basados en todos los campos del objeto. En entidades JPA esto es problemático: si hay relaciones bidireccionales, `equals()` puede llamar recursivamente al `equals()` de la entidad relacionada, generando un bucle infinito. Se usan `@Getter`, `@Setter`, `@Builder` de forma explícita para evitar esto. ### 4.4 Capa de integración — clientes HTTP #### DolibarrClient -Gestiona todas las llamadas a la API REST de Dolibarr. Se configura como un bean Spring con el header `DOLAPIKEY` añadido de forma global a todas las peticiones. +Se configura como un bean Spring con el header `DOLAPIKEY` añadido globalmente: ```java -// En DolibarrClientConfig.java RestClient.builder() - .baseUrl(properties.dolibarr().baseUrl()) - .defaultHeader("DOLAPIKEY", properties.dolibarr().apiKey()) + .baseUrl("https://.../dolibarr/api/index.php") + .defaultHeader("DOLAPIKEY", "3f7a9e2b1c8d...") .build(); ``` -Esto evita repetir el header en cada llamada. Si cambia la clave, solo hay que cambiarla en la configuración. - -**Métodos principales:** -- `getProducts()` — lista todos los productos activos -- `getProductByRef(String ref)` — busca un producto por SKU -- `createProduct(DolibarrProductDto)` — crea un producto nuevo -- `updateProduct(int id, DolibarrProductDto)` — actualiza un producto existente -- `getStockForProduct(int productId)` — obtiene el stock actual -- `getOrCreateThirdparty(String email, String name)` — busca o crea un cliente -- `createOrder(DolibarrOrderDto)` — crea una comanda -- `createInvoiceFromOrder(int orderId)` — genera factura desde comanda +Añadir el header una sola vez en la configuración evita repetirlo en cada llamada. Si la clave cambia, se cambia en un solo lugar (la variable de entorno `DOLIBARR_API_KEY`). #### PrestashopClient -Gestiona todas las llamadas al Webservice de PrestaShop. Más complejo que el cliente de Dolibarr por varias razones: +Más complejo. Cada URL debe incluir la clave como query param y el formato de salida: -1. **Autenticación por query param**: cada URL debe incluir `?ws_key=...&output_format=JSON`. -2. **XML para escrituras**: crear y actualizar recursos requiere construir un documento XML a mano, ya que el Webservice de PS no acepta JSON en las peticiones de escritura. -3. **Múltiples bugs de PS 8** que requirieron manejo especial (ver sección 7). +```java +restClient.get() + .uri("/products?ws_key={key}&output_format=JSON&filter[reference]={ref}", + apiKey, reference) + .retrieve() + .body(PrestashopProductDto.ListResponse.class); +``` -**Ejemplo de XML para crear un producto:** -```xml - - - - PROD-001 - 19.99 - 1 - 1 - Nombre del producto - - 2 - - - +El cliente construye el XML de creación/actualización mediante métodos privados `toProductXml()`, `toStockAvailableXml()`. Ejemplo simplificado: + +```java +private String toProductXml(PrestashopProductDto dto) { + return """ + + + + %s + %s + 1 + 1 + ... + + + """.formatted(dto.reference(), dto.price()); +} ``` ### 4.5 Flujos de sincronización — servicios -Los tres flujos de sincronización son los servicios más importantes del sistema. - #### Flujo 1: PRODUCT_PUSH — Productos Dolibarr → PrestaShop -**Objetivo:** que todos los productos del ERP estén disponibles en la tienda online. +**Objetivo:** mantener el catálogo de PrestaShop sincronizado con Dolibarr. -**Proceso:** +**Diagrama de secuencia:** -1. Obtiene todos los productos de Dolibarr via `DolibarrClient.getProducts()`. -2. Para cada producto con SKU válido: - a. Busca en PrestaShop si ya existe un producto con ese SKU. - b. Si **no existe** → lo crea en PrestaShop con `createProduct()`. - c. Si **ya existe** → lo actualiza con `updateProduct()`. - d. Guarda/actualiza el `ProductMapping` con los IDs de ambos sistemas. -3. Al finalizar, actualiza el `SyncLog` con el resultado. +``` +ProductSyncService DolibarrClient PrestashopClient BD local + │ │ │ │ + │── getProducts() ────────►│ │ │ + │◄── [lista productos] ───│ │ │ + │ │ │ │ + │ Para cada producto: │ │ │ + │── getProductByReference(sku) ─────────────────►│ │ + │◄── Optional ────────────────────────│ │ + │ │ │ │ + │ Si no existe: │ │ │ + │── createProduct(xml) ──────────────────────────►│ │ + │◄── producto creado (con nuevo ID) ────────────│ │ + │ │ │ │ + │ Si ya existe: │ │ │ + │── updateProduct(id, xml) ──────────────────────►│ │ + │ │ │ │ + │── upsertMapping(sku, dolId, psId) ────────────────────────────────►│ + │── updateSyncLog(processed, failed) ────────────────────────────────►│ +``` -**Patrón upsert por SKU:** En lugar de confiar en el mapping local para decidir si crear o actualizar, el servicio **siempre consulta PrestaShop** como fuente de verdad. Esto evita el problema de que un producto exista en PS pero haya sido eliminado y el mapping local quede desactualizado (ver Bug 6 en sección 7). +**Lógica de upsert por SKU:** -**Manejo de errores:** Si falla un producto concreto (error de API, timeout, etc.), el error se registra en el `ProductMapping` de ese SKU como `ERROR` con el mensaje del error, pero el bucle continúa con el siguiente producto. Esto permite sincronización parcial: si de 100 productos falla uno, los 99 restantes se sincronizan correctamente. +En cada sincronización, antes de crear un producto en PS, se busca si ya existe uno con ese SKU. Si existe, se actualiza. Si no, se crea. Esto hace el proceso idempotente: ejecutarlo dos veces produce el mismo resultado que ejecutarlo una vez. + +**¿Por qué no usar el mapping local para decidir si crear o actualizar?** +Si alguien borra un producto desde el admin de PS, el mapping local seguirá teniendo el antiguo ID de PS. El sync-service intentaría hacer PUT a ese ID y PS devolvería 200, pero el producto ya no existiría en la tienda (ver Bug 6 en sección 8). Consultar PS directamente por SKU es más fiable. #### Flujo 2: STOCK_PUSH — Stock Dolibarr → PrestaShop -**Objetivo:** que el stock visible en la tienda coincida con el stock real del ERP. +**Objetivo:** que el stock visible en la tienda coincida con el stock real del almacén. -**Proceso:** +**Diagrama de secuencia:** -1. Obtiene todos los `ProductMapping` con status `SYNCED` (solo productos que ya existen en los dos sistemas). -2. Para cada uno: - a. Consulta el stock actual en Dolibarr. - b. Busca el ID del `stock_available` en PrestaShop para ese producto. - c. Actualiza el `stock_available` en PrestaShop con la cantidad de Dolibarr. +``` +StockSyncService DolibarrClient PrestashopClient BD local + │ │ │ │ + │── findAll(status=SYNCED) ─────────────────────────────────────────►│ + │◄── [lista de mappings] ─────────────────────────────────────────── │ + │ │ │ │ + │ Para cada mapping: │ │ │ + │── getStockForProduct(dolId) ────────────────────►│ │ + │◄── cantidad ───────────│ │ │ + │ │ │ │ + │── getStockAvailableForProduct(psId) ──────────►│ │ + │◄── stockAvailableId ──────────────────────────│ │ + │ │ │ │ + │── updateStockAvailable(stockId, cantidad, xml) ►│ │ +``` -**Por qué stock separado de productos:** PrestaShop gestiona el stock en una tabla separada (`ps_stock_available`), no en `ps_product`. Por eso hay un flujo específico para stock. +PrestaShop gestiona el stock en una tabla separada `ps_stock_available`, no en `ps_product`. Por eso el stock tiene su propio flujo: no basta con actualizar el producto. #### Flujo 3: ORDER_PULL — Pedidos PrestaShop → Dolibarr -**Objetivo:** que los pedidos realizados en la tienda online queden registrados en el ERP. +**Objetivo:** registrar en el ERP los pedidos realizados en la tienda online. -**Proceso:** +**Diagrama de secuencia:** -1. Obtiene los pedidos de PrestaShop creados desde la última ejecución (usando `filter[date_add]`). -2. Para cada pedido no procesado aún (verificando que no existe en `OrderMapping`): - a. Obtiene los datos del cliente del pedido. - b. Busca en Dolibarr si ese cliente ya existe (por email); si no, lo crea. - c. Crea una comanda en Dolibarr con las líneas del pedido. - d. Crea una factura desde la comanda. - e. Guarda el `OrderMapping` con los IDs del pedido, la comanda y la factura. +``` +OrderSyncService PrestashopClient DolibarrClient BD local + │ │ │ │ + │── getOrdersSince(lastSync) ──────────►│ │ + │◄── [lista pedidos nuevos] ────────── │ │ + │ │ │ │ + │ Para cada pedido no procesado: │ │ + │── getCustomer(psCustomerId) ──────────►│ │ + │◄── datos del cliente ─────────────── │ │ + │ │ │ │ + │── getOrCreateThirdparty(email, nombre) ──────────────────► │ + │ │ busca cliente por email │ + │ │ si no existe → crea tercero │ + │◄── dolibarrThirdpartyId ──────────────────────────────────│ + │ │ │ │ + │── createOrder(lineas del pedido) ────────────────────────► │ + │◄── dolibarrOrderId ───────────────────────────────────────│ + │ │ │ │ + │── createInvoiceFromOrder(orderId) ───────────────────────► │ + │◄── dolibarrInvoiceId ─────────────────────────────────────│ + │ │ │ │ + │── saveOrderMapping(psOrderId, dolOrderId, dolInvoiceId) ──►│ +``` -**Por qué se crea también la factura:** Un pedido en PrestaShop implica que el cliente ha pagado o comprometido la compra. En el flujo contable de Dolibarr, eso corresponde tanto a una comanda (registro del compromiso) como a una factura (documento contable). +**El método `getOrCreateThirdparty`:** +Dolibarr llama "tercero" (thirdparty) a cualquier persona u organización externa: cliente, proveedor, etc. Cuando llega un pedido de PS, hay que crear la comanda a nombre de un tercero que exista en Dolibarr. El flujo es: +1. Buscar en Dolibarr si existe un tercero con el email del cliente de PS. +2. Si existe → usar su ID. +3. Si no existe → crear un nuevo tercero con los datos del cliente de PS (nombre, email, dirección). -#### El patrón `try/finally` en todos los servicios +Esto evita duplicar clientes en Dolibarr si el mismo cliente hace varios pedidos. -Todos los servicios de sincronización siguen este patrón: +**¿Por qué se crea también la factura?** +Un pedido completado en PrestaShop significa que el cliente ha pagado (o se ha comprometido a pagar). En la contabilidad de Dolibarr, esto tiene dos registros: +- **Comanda** (`commande`): el compromiso de compra, el pedido. +- **Factura** (`facture`): el documento contable que certifica la transacción. + +Ambos son necesarios para que Dolibarr refleje correctamente la venta. + +#### El patrón `try/finally` en los tres servicios + +Todos los servicios de sincronización tienen esta estructura: ```java public SyncResult synchronize() { + // 1. Crear el log ANTES de empezar SyncLog syncLog = syncLogRepository.save(SyncLog.builder() .syncType(SyncType.PRODUCT_PUSH) .startedAt(Instant.now()) .build()); - int processed = 0; - int failed = 0; + int processed = 0, failed = 0; List errors = new ArrayList<>(); try { - // lógica de sincronización + // 2. Lógica principal + List products = dolibarrClient.getProducts(); + for (DolibarrProductDto product : products) { + try { + pushProduct(product); + processed++; + } catch (Exception e) { + failed++; + errors.add("SKU %s: %s".formatted(product.ref(), e.getMessage())); + markError(product.ref(), e.getMessage()); + // No se relanza la excepción: el bucle continúa con el siguiente + } + } } finally { - // SIEMPRE se ejecuta, aunque explote algo inesperado + // 3. SIEMPRE se ejecuta, aunque haya una excepción inesperada syncLog.setFinishedAt(Instant.now()); syncLog.setItemsProcessed(processed); syncLog.setItemsFailed(failed); syncLogRepository.save(syncLog); } + + return new SyncResult(processed, failed, errors); } ``` -El bloque `finally` garantiza que **siempre** se escribe el log de sincronización, incluso si hay una excepción inesperada. Sin esto, si el servicio falla a mitad de ejecución, no habría ningún registro de lo ocurrido. +El bloque `finally` garantiza que **siempre** se escribe el resultado en el `SyncLog`, aunque el sistema explote con una `OutOfMemoryError`. Sin esto, si algo falla inesperadamente, no quedaría ningún registro. -**Por qué no `@Transactional` en los servicios de batch:** Si se usara `@Transactional`, un fallo en el ítem 50 haría rollback de los ítems 1-49, perdiendo todo el trabajo. El procesamiento parcial (procesar 49 de 50 bien) es mejor comportamiento que todo-o-nada en un sistema de sincronización. +**¿Por qué no `@Transactional` en los servicios batch?** +Si se anotaran con `@Transactional`, un fallo en el ítem 50 desharía todo el trabajo de los ítems 1 a 49 (rollback). En un sistema de sincronización, procesar correctamente 49 de 50 ítems es mucho mejor que no procesar ninguno. Cada `save()` es un commit independiente. ### 4.6 Scheduling — tareas automáticas -El `SyncScheduler` ejecuta los tres flujos de forma automática en producción. - ```java @Component @ConditionalOnProperty(name = "sync.scheduling.enabled", havingValue = "true") public class SyncScheduler { - @Scheduled(fixedDelayString = "${sync.scheduling.product-push-delay:PT15M}", - initialDelayString = "${sync.scheduling.initial-delay:PT30S}") - public void scheduleProductPush() { ... } + @Scheduled( + fixedDelayString = "${sync.scheduling.product-push-delay:PT15M}", + initialDelayString = "${sync.scheduling.initial-delay:PT30S}" + ) + public void scheduleProductPush() { ... } // cada 15 minutos - @Scheduled(fixedDelayString = "${sync.scheduling.stock-push-delay:PT5M}", - initialDelayString = "${sync.scheduling.initial-delay:PT30S}") - public void scheduleStockPush() { ... } + @Scheduled( + fixedDelayString = "${sync.scheduling.stock-push-delay:PT5M}", + initialDelayString = "${sync.scheduling.initial-delay:PT30S}" + ) + public void scheduleStockPush() { ... } // cada 5 minutos - @Scheduled(fixedDelayString = "${sync.scheduling.order-pull-delay:PT5M}", - initialDelayString = "${sync.scheduling.initial-delay:PT30S}") - public void scheduleOrderPull() { ... } + @Scheduled( + fixedDelayString = "${sync.scheduling.order-pull-delay:PT5M}", + initialDelayString = "${sync.scheduling.initial-delay:PT30S}" + ) + public void scheduleOrderPull() { ... } // cada 5 minutos } ``` -**Decisiones técnicas en el scheduling:** +El `initialDelayString = "PT30S"` hace que los tres flujos esperen 30 segundos antes de su primera ejecución, dando tiempo al servidor a arrancar completamente antes de hacer llamadas a APIs externas. -**`fixedDelay` en lugar de `fixedRate`:** -- `fixedRate` = espera X tiempo desde el **inicio** de la ejecución anterior. Si una sync tarda 16 minutos con `fixedRate=15m`, la siguiente ya debería haber arrancado hace 1 minuto → se encolan dos ejecuciones simultáneas. -- `fixedDelay` = espera X tiempo desde la **finalización** de la ejecución anterior. Si tarda 16 minutos, la siguiente arranca a los 16+15 = 31 minutos del inicio. Sin solapamiento. +**`@ConditionalOnProperty`:** El scheduler solo existe si `sync.scheduling.enabled=true`. En el perfil `dev` esta propiedad es `false`, lo que significa que en local no se ejecutan syncs automáticas. Hay que lanzarlas manualmente desde Swagger o la API. -**`ThreadPoolTaskScheduler(poolSize=1)`:** -El scheduler tiene un solo hilo de ejecución. Aunque tres métodos `@Scheduled` estén activos, solo puede ejecutarse uno a la vez. Esto es importante porque los tres flujos acceden a la misma base de datos local y a las mismas APIs externas. - -**`@ConditionalOnProperty`:** -El scheduler solo se crea si la propiedad `sync.scheduling.enabled=true`. En el perfil `dev` esta propiedad es `false`, lo que significa que las syncs automáticas no se ejecutan en local. Hay que lanzarlas manualmente desde la API o Swagger. - -**Intervalos configurables:** -Los intervalos están configurados en las variables de entorno del servicio en Railway. El valor por defecto en el código (`:PT15M`, `:PT5M`) usa notación ISO-8601 para duraciones. `PT15M` = 15 minutos, `PT5M` = 5 minutos. +**Intervalos en notación ISO-8601:** `PT15M` = 15 minutos, `PT5M` = 5 minutos. Los dos puntos son los valores por defecto en el código. Los intervalos reales en producción se configuran como variables de entorno en Railway. ### 4.7 API REST propia -El sync-service expone una API REST que consumen las apps cliente. Está protegida con Basic Auth. +La API REST es la interfaz que usan las apps cliente (JavaFX, Android) para interactuar con el sistema. -#### Endpoints de sincronización manual +#### Endpoints disponibles +**Sincronización manual:** ``` -POST /api/sync/products → lanza PRODUCT_PUSH inmediatamente -POST /api/sync/stock → lanza STOCK_PUSH inmediatamente -POST /api/sync/orders → lanza ORDER_PULL inmediatamente -GET /api/sync/diagnostics/dolibarr → lista productos de Dolibarr (test de conectividad) +POST /api/sync/products → lanza PRODUCT_PUSH ahora +POST /api/sync/stock → lanza STOCK_PUSH ahora +POST /api/sync/orders → lanza ORDER_PULL ahora +GET /api/sync/diagnostics/dolibarr → verifica conectividad con Dolibarr ``` -**Respuesta de los POST:** +**Ejemplo de respuesta de un POST /api/sync/products:** ```json { "syncType": "PRODUCT_PUSH", "itemsProcessed": 12, - "itemsFailed": 0, - "hasErrors": false, - "errors": [] + "itemsFailed": 1, + "hasErrors": true, + "errors": ["SKU CAMISETA-007: Connection timeout to PrestaShop"] } ``` -#### Endpoints de mappings - +**Mappings:** ``` -GET /api/mappings/products → todos los mappings de productos -GET /api/mappings/products?status=SYNCED → filtrado por estado -GET /api/mappings/orders → todos los mappings de pedidos +GET /api/mappings/products → todos los mappings +GET /api/mappings/products?status=ERROR → solo los que tienen error +GET /api/mappings/orders → todos los pedidos importados ``` -#### Endpoints de logs - +**Logs:** ``` -GET /api/logs → historial de todas las ejecuciones (ordenado por fecha desc) -GET /api/logs?type=PRODUCT_PUSH → filtrado por tipo -GET /api/logs/{id} → detalle de una ejecución concreta +GET /api/logs → historial completo (más reciente primero) +GET /api/logs?type=STOCK_PUSH → solo logs de stock +GET /api/logs/{id} → detalle de una ejecución concreta +``` + +**Ejemplo de respuesta de GET /api/logs/{id}:** +```json +{ + "id": 42, + "syncType": "PRODUCT_PUSH", + "startedAt": "2025-05-10T14:32:11Z", + "finishedAt": "2025-05-10T14:32:15Z", + "itemsProcessed": 11, + "itemsFailed": 1, + "errorDetails": "SKU CAMISETA-007: Connection timeout to PrestaShop" +} +``` + +**Health:** +``` +GET /actuator/health → {"status": "UP"} (no requiere autenticación) ``` #### Swagger UI -En producción: `https://proyectointermodular-production-a9c3.up.railway.app/swagger-ui.html` - -SpringDoc genera esta interfaz automáticamente desde las anotaciones `@RestController`, `@GetMapping`, etc. del código. No requiere mantener documentación separada. +SpringDoc genera automáticamente una interfaz web de documentación y pruebas en `/swagger-ui.html`. No hay que mantener documentación manual; se genera desde las anotaciones del código (`@RestController`, `@Operation`, `@Tag`, etc.). ### 4.8 Seguridad -Todos los endpoints (excepto `/actuator/health` y `/h2-console/**`) requieren autenticación. +Todos los endpoints requieren autenticación excepto `/actuator/health` (para que Railway pueda verificar que el servicio está vivo) y `/h2-console/**` (solo disponible en dev con H2). -Se usa **HTTP Basic Auth**: cada petición incluye un header `Authorization: Basic base64(usuario:contraseña)`. +**HTTP Basic Auth:** +Cada petición incluye el header `Authorization: Basic BASE64(usuario:contraseña)`. Es el esquema más simple y suficiente para una API interna usada por apps propias. -```java -// SecurityConfig.java -http - .csrf(csrf -> csrf.disable()) // sin sesiones → sin CSRF - .authorizeHttpRequests(auth -> auth - .requestMatchers("/actuator/health").permitAll() - .anyRequest().authenticated() - ) - .httpBasic(Customizer.withDefaults()) - .sessionManagement(session -> - session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)); -``` - -**CSRF desactivado:** CSRF (Cross-Site Request Forgery) es un ataque que funciona usando las cookies de sesión de un usuario autenticado. Como esta API es STATELESS (sin sesiones, sin cookies), no hay superficie de ataque CSRF. Desactivarlo es correcto y necesario. +**CSRF desactivado:** +CSRF (Cross-Site Request Forgery) es un ataque que roba la sesión de un usuario mediante cookies. Como esta API no usa sesiones (es `STATELESS`, sin cookies), no existe superficie de ataque CSRF. Desactivarlo es correcto y necesario; de lo contrario, Spring Security rechazaría todas las peticiones POST. **`DelegatingPasswordEncoder`:** -Permite tener contraseñas con diferentes algoritmos de hash en el mismo campo de configuración. El prefijo `{id}` indica el algoritmo: -- `{noop}admin123` → sin hash (solo para desarrollo, texto plano) -- `{bcrypt}$2a$10$...` → hash BCrypt (producción) +Permite almacenar contraseñas con distintos algoritmos de hash en el mismo campo. El prefijo entre llaves indica el algoritmo: -Con esto, el mismo campo `api.security.password` sirve tanto para dev como para prod sin cambios en el código. +``` +{noop}admin123 → texto plano (solo para desarrollo) +{bcrypt}$2a$10$... → hash BCrypt (producción, seguro) +``` + +La ventaja es que el código no cambia entre entornos. Solo cambia el valor de la variable de entorno `API_SECURITY_PASSWORD`. + +**Sesiones:** `SessionCreationPolicy.STATELESS` indica que el servidor no crea ni almacena sesiones HTTP. Cada petición es completamente independiente y debe incluir sus credenciales. Esto es el comportamiento correcto para una API REST. ### 4.9 Persistencia y migraciones -**Flyway** gestiona el esquema de la base de datos. Al arrancar la aplicación, Flyway compara las migraciones en `src/main/resources/db/migration/` con las ya aplicadas en la BD y ejecuta las que faltan. +**Flyway** gestiona el esquema de la base de datos. Al arrancar la aplicación, Flyway: +1. Busca el fichero `V1__init.sql` en `src/main/resources/db/migration/`. +2. Comprueba si ya fue aplicado (lo registra en su tabla interna `flyway_schema_history`). +3. Si no fue aplicado, ejecuta el SQL y crea las tablas. +4. Si ya fue aplicado, no hace nada. -El fichero `V1__init.sql` crea las tres tablas: +Esto garantiza que la base de datos siempre tenga el esquema correcto, independientemente de si es la primera vez que arranca o el sistema lleva meses en producción. -```sql -CREATE TABLE product_mapping ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, - sku VARCHAR(100) NOT NULL UNIQUE, - dolibarr_id INT, - prestashop_id INT, - last_synced_at DATETIME(6), - sync_status VARCHAR(20) NOT NULL DEFAULT 'PENDING', - error_message TEXT -); - -CREATE TABLE order_mapping ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, - prestashop_order_id INT NOT NULL, - dolibarr_order_id INT, - dolibarr_invoice_id INT, - imported_at DATETIME(6), - status VARCHAR(30) NOT NULL DEFAULT 'IMPORTED' -); - -CREATE TABLE sync_log ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, - sync_type VARCHAR(30) NOT NULL, - started_at DATETIME(6) NOT NULL, - finished_at DATETIME(6), - items_processed INT, - items_failed INT, - error_details TEXT -); +**H2 en modo MySQL:** +```yaml +url: jdbc:h2:mem:syncdb;MODE=MySQL ``` - -**H2 en modo MySQL:** El perfil dev usa H2 en memoria con `MODE=MySQL`. Esto hace que H2 acepte la misma sintaxis SQL de MySQL, por lo que el mismo `V1__init.sql` funciona en dev y en prod sin ninguna modificación. - -**Por qué Flyway sobre Liquibase:** Flyway usa SQL puro. Liquibase usa XML o YAML para describir los cambios, añadiendo una capa de abstracción innecesaria para este proyecto. Flyway es más simple y el resultado es el mismo. +Con `MODE=MySQL`, H2 acepta la misma sintaxis SQL que MySQL (tipos de datos, funciones, etc.). El mismo `V1__init.sql` funciona en desarrollo (H2) y en producción (MySQL) sin ninguna modificación. ### 4.10 Configuración externalizada -Todas las credenciales y URLs están externalizadas en ficheros de configuración, nunca en el código. +Las credenciales y URLs nunca están en el código fuente. Se leen desde ficheros de configuración o variables de entorno. -**`IntegrationProperties.java`** usa `@ConfigurationProperties` con validación: +`IntegrationProperties.java` usa `@ConfigurationProperties` con validación Jakarta: ```java @ConfigurationProperties(prefix = "integration") @@ -619,24 +790,19 @@ public record IntegrationProperties( ) { public record Dolibarr( @NotBlank String baseUrl, - @NotBlank String apiKey, + @NotBlank String apiKey, // si está vacío → la app NO arranca double defaultTaxRate ) {} public record Prestashop( @NotBlank String baseUrl, - @NotBlank String apiKey, + @NotBlank String apiKey, // si está vacío → la app NO arranca int defaultCategoryId ) {} } ``` -Si `apiKey` está vacío o no está configurado, la aplicación **no arranca** y muestra un error claro. Esto evita arrancar con configuración inválida y fallar silenciosamente en tiempo de ejecución. - -**Perfiles:** -- `application-dev.yml` — H2, logging DEBUG, scheduling desactivado, credenciales dev. **En `.gitignore`, nunca se sube al repositorio**. -- `application-prod.yml` — MySQL, logging INFO, scheduling activado. **En `.gitignore`, las credenciales reales se configuran como variables de entorno en Railway**. -- `application-dev.yml.example` y `application-prod.yml.example` — plantillas sin credenciales reales. Sí están en el repositorio. +Si `apiKey` está vacío o no está configurado, la aplicación **falla al arrancar** con un mensaje claro. Esto evita el caso silencioso de que el servicio arranque pero falle en todas las peticiones a las APIs externas. --- @@ -648,77 +814,133 @@ Si `apiKey` está vacío o no está configurado, la aplicación **no arranca** y **Tecnología:** JavaFX 21.0.2, Java 21, Maven **Arranque:** `mvn javafx:run` desde `javafx-client/` -La aplicación escritorio permite al personal de la empresa monitorizar y controlar la sincronización sin necesidad de usar Swagger o la terminal. +#### Pantallas y funcionalidades -#### Pantallas +**Login:** +Formulario de conexión al sync-service con campos para URL del servidor, usuario y contraseña. Las credenciales se guardan automáticamente al hacer login exitoso mediante `java.util.prefs.Preferences` (registro de Windows) y se pre-rellenan en los siguientes arranques. Un checkbox permite cambiar entre servidor Railway (producción) y `localhost:8080` (desarrollo local). -**Login:** Formulario de conexión al sync-service. Las credenciales se guardan automáticamente al hacer login exitoso y se pre-rellenan en los siguientes arranques de la app. Tiene un checkbox para cambiar rápidamente entre servidor Railway y servidor local. +**Panel (Dashboard):** +- Tres tarjetas de sincronización (Productos, Stock, Pedidos), cada una con el resultado de la última ejecución: cuándo fue, cuántos ítems procesó y si hubo errores. +- Botón "Sincronizar todo" que lanza los tres flujos en secuencia mostrando el progreso paso a paso. +- Cada tarjeta tiene su propio botón "Sincronizar" para lanzar solo ese flujo. +- Actualización automática cada 30 segundos via `Timeline` de JavaFX. -**Panel (Dashboard):** Vista principal con: -- Tres tarjetas de sincronización (Productos, Stock, Pedidos), cada una con la información de la última ejecución (fecha relativa, ítems procesados, posibles errores). -- Botón "Sincronizar todo" que lanza los tres flujos en secuencia mostrando el progreso. -- Botones individuales en cada tarjeta para lanzar solo ese flujo. -- Actualización automática cada 30 segundos. +**Productos:** +- Tabla con todos los mappings de productos (SKU, ID Dolibarr, ID PrestaShop, último sync, estado, error). +- Búsqueda por SKU en tiempo real (filtrado en cliente, sin peticiones al servidor). +- Filter chips para filtrar por estado: Todos / Sincronizados / Pendientes / Con error. +- Chips de estado con colores: verde (SYNCED), naranja (PENDING), rojo (ERROR). +- Botones para lanzar sincronización de productos y de stock. -**Productos:** Tabla con todos los mappings de productos. Incluye: -- Búsqueda por SKU en tiempo real (filtrado client-side). -- Filter chips para filtrar por estado (Todos / Sincronizados / Pendientes / Con error). -- Chips de estado con colores (verde = sincronizado, naranja = pendiente, rojo = error). -- Botones para sincronizar productos y stock. +**Pedidos:** +- Tabla con todos los mappings de pedidos importados (ID pedido PS, ID comanda Dolibarr, ID factura, estado, fecha). +- Chips de estado: gris (IMPORTED), azul (INVOICED), rojo (ERROR). +- Botón para lanzar sincronización de pedidos. -**Pedidos:** Tabla con todos los mappings de pedidos. Muestra el ID del pedido en PS, el ID de la comanda y factura en Dolibarr, el estado y la fecha de importación. +**Historial:** +- Tabla con el historial de todas las ejecuciones de sync. +- Filter chips por tipo: Todos / Productos / Stock / Pedidos. +- Icono de estado en cada fila: ⏱ (en proceso), ✓ (éxito), ✗ (con errores). +- Click en una fila abre un **diálogo modal** con el detalle completo: fechas, duración calculada, ítems procesados/fallidos, y el texto de error si lo hubo. -**Historial:** Tabla con todos los logs de sincronización. Filter chips por tipo. Al hacer click en una fila se abre un diálogo modal con el detalle completo (duración, ítems procesados, texto de error si lo hubo). +**Ajustes:** +- Formulario para cambiar la URL del servidor, usuario y contraseña sin necesidad de hacer logout. +- Botón "Probar conexión" que verifica la conectividad con los nuevos datos sin guardarlos ni modificar la sesión actual. +- Botón "Guardar" que actualiza los datos, reinicializa el cliente HTTP y persiste en el registro. -**Ajustes:** Formulario para cambiar la URL del servidor, usuario y contraseña en caliente. Botón "Probar conexión" verifica la conectividad sin guardar. Botón "Guardar" actualiza la configuración y reinicializa el cliente HTTP. - -#### Arquitectura interna +#### Arquitectura interna de la app JavaFX ``` -JavaFxClientApplication # Application de JavaFX, punto de entrada +JavaFxClientApplication ← Application de JavaFX, punto de entrada +│ ├── ui/stages/ -│ ├── LoginStage # Ventana de login -│ └── MainStage # Ventana principal con tabs -├── ui/controllers/ -│ ├── DashboardController # Lógica del tab Panel -│ ├── ProductsController # Lógica del tab Productos -│ ├── OrdersController # Lógica del tab Pedidos -│ ├── LogsController # Lógica del tab Historial -│ └── SettingsController # Lógica del tab Ajustes +│ ├── LoginStage ← Ventana de login (420×520 px, no redimensionable) +│ └── MainStage ← Ventana principal (1200×720 px, 5 tabs) +│ +├── ui/controllers/ ← Un controller por tab +│ ├── DashboardController ← Tarjetas, sync-all, auto-refresh +│ ├── ProductsController ← Tabla, búsqueda SKU, filter chips +│ ├── OrdersController ← Tabla de pedidos +│ ├── LogsController ← Tabla de logs, click → dialog +│ └── SettingsController ← Formulario de ajustes +│ ├── ui/dialogs/ -│ └── LogDetailDialog # Diálogo modal de detalle de log +│ └── LogDetailDialog ← Stage modal (Modality.APPLICATION_MODAL) +│ ├── api/ -│ └── SyncServiceClient # Cliente HTTP hacia el sync-service +│ └── SyncServiceClient ← Jackson ObjectMapper + HttpURLConnection +│ ├── model/ -│ ├── SessionManager # Gestiona la sesión actual (credenciales + cliente) -│ ├── AppSettings # DTO de ajustes (URL, usuario, contraseña) -│ ├── SyncLogResponse # DTO de respuesta de la API +│ ├── SessionManager ← Credenciales + instancia del cliente HTTP +│ ├── AppSettings ← DTO: baseUrl, username, password +│ ├── SyncLogResponse ← DTO de respuesta de la API │ ├── ProductMappingResponse │ ├── OrderMappingResponse │ └── SyncTriggerResponse +│ └── util/ - ├── SettingsStore # Persistencia de ajustes (java.util.prefs) - ├── DateUtils # Formateo de fechas y labels localizados - └── AlertUtil # Diálogos de alerta reutilizables + ├── SettingsStore ← java.util.prefs.Preferences (registro Windows) + ├── DateUtils ← "hace 5 min", "2h 30m", labels localizados + └── AlertUtil ← Diálogos de información/error/advertencia ``` -**Persistencia de ajustes:** Se usa `java.util.prefs.Preferences`, que en Windows guarda los datos en el registro del sistema. No requiere ninguna dependencia adicional y es la forma estándar de Java para guardar preferencias de usuario. +**Threading:** JavaFX tiene un único hilo de UI (el Application Thread). Hacer una petición HTTP en ese hilo congela la interfaz hasta que termina. Todas las llamadas al servidor se hacen en hilos de background: -**Threading:** Todas las llamadas HTTP se ejecutan en hilos de background (`new Thread(() -> { ... }).start()`) y el resultado se aplica al UI mediante `Platform.runLater()`. Esto evita que la interfaz se congele mientras se espera la respuesta del servidor. +```java +Thread t = new Thread(() -> { + // Esto corre en background (hilo separado del UI) + List logs = client.getLogs(null); -**Diseño visual:** Paleta de colores Material 3 idéntica a la app Android: -- Primario: `#1565C0` (azul) -- Éxito/Stock: `#2E7D32` (verde) -- Pedidos: `#E65100` (naranja) -- Error: `#C62828` (rojo) + // Platform.runLater(): vuelve al hilo de UI para actualizar la tabla + Platform.runLater(() -> logsTable.setItems(FXCollections.observableArrayList(logs))); +}); +t.setDaemon(true); // el hilo muere cuando se cierra la app +t.start(); +``` + +**Persistencia de ajustes:** `java.util.prefs.Preferences` guarda datos en el registro de Windows (en `HKCU\Software\JavaSoft\Prefs`). Es la API estándar de Java para preferencias de usuario, sin dependencias adicionales. + +**Diseño visual:** Paleta Material 3 idéntica a la app Android para consistencia visual entre plataformas. + +| Elemento | Color | +|---|---| +| Primario / Productos | `#1565C0` (azul) | +| Stock | `#2E7D32` (verde) | +| Pedidos | `#E65100` (naranja) | +| Error | `#C62828` (rojo) | +| Fondo | `#FAFAFA` (gris muy claro) | ### 5.2 Android — Cliente móvil **Directorio:** `android-app/` -**Tecnología:** Kotlin, Android SDK +**Tecnología:** Kotlin, Android SDK, Material 3 **Desarrollado por:** compañera del equipo -La app Android ofrece las mismas funcionalidades que la app JavaFX pero optimizada para dispositivos móviles con la misma paleta Material 3. +#### Pantallas y funcionalidades + +La app Android ofrece exactamente las mismas funcionalidades que la app JavaFX, adaptadas a pantalla táctil: + +**Login:** Formulario de conexión con URL, usuario y contraseña. Los datos se guardan con `DataStore` (equivalente Android a `Preferences` de Java) para no tener que introducirlos cada vez. + +**Dashboard:** Tarjetas de sincronización con información del último log por tipo. Botón "Sincronizar todo". Actualización pull-to-refresh y automática periódica. + +**Productos:** Lista con mappings, búsqueda por SKU, filtros por estado (chips). Navegación a detalle al pulsar un ítem. + +**Pedidos:** Lista con mappings importados, estado, fechas. + +**Historial:** Lista de logs de sincronización con indicadores visuales de estado. Tap en un ítem muestra el detalle completo. + +**Ajustes:** Formulario para cambiar la configuración del servidor. + +#### Arquitectura Android + +La app sigue la arquitectura recomendada por Google para Android moderno: + +- **ViewModel + LiveData / StateFlow**: gestión del estado de la UI separada de la lógica de negocio. +- **Repository pattern**: capa de acceso a datos que abstrae la fuente (la API REST del sync-service). +- **Retrofit**: cliente HTTP para Android. Hace las llamadas a la API del sync-service. +- **Coroutines**: para llamadas asíncronas sin bloquear el hilo principal de UI. +- **Navigation Component**: navegación entre pantallas. --- @@ -726,19 +948,121 @@ La app Android ofrece las mismas funcionalidades que la app JavaFX pero optimiza ### ¿Por qué Railway? -El enunciado especifica que Dolibarr y PrestaShop están en un hosting compartido bajo `httpdocs`. Este tipo de hosting no permite ejecutar procesos Java persistentes: solo ejecuta PHP bajo petición. Por tanto, el sync-service **no puede ejecutarse en el mismo servidor** que Dolibarr y PrestaShop. +El enunciado del TFG establece que Dolibarr y PrestaShop están en un **hosting compartido** bajo `httpdocs`. Este tipo de hosting solo ejecuta PHP bajo petición HTTP; **no permite mantener procesos Java activos en segundo plano**. Por tanto, el sync-service no puede ejecutarse en el mismo servidor que los sistemas que integra. -Railway es una plataforma PaaS (Platform as a Service) que permite desplegar aplicaciones como contenedores sin necesitar configurar servidores. Se elige por: -- Plan gratuito suficiente para el volumen de este proyecto. -- Integración directa con GitHub: cada push a `main` redespliega automáticamente. -- Plugin de MySQL gestionado con red privada interna. -- Variables de entorno seguras para las credenciales. +Railway es una plataforma PaaS (Platform as a Service) que permite desplegar aplicaciones sin gestionar servidores. Se eligió por: +- **Plan gratuito** suficiente para el volumen de este proyecto. +- **Integración con GitHub**: cada push a `main` dispara un redeploy automático. +- **Plugin MySQL** gestionado, con red privada interna. +- **Variables de entorno** seguras para credenciales. +- **Sin gestión de infraestructura**: sin Dockerfile, sin configuración de nginx, sin certificados SSL manuales. -### Configuración +### Pipeline de despliegue continuo (CD) -**Root directory del servicio:** `sync-service` (Railway usa esta subcarpeta como raíz del build). +Cada cambio en el código sigue este camino automáticamente: -**Build:** Railway detecta automáticamente que es un proyecto Maven y usa `mvn package` para compilar. La versión de Java y Maven se fija en `sync-service/.mise.toml`: +``` +Developer hace git push a GitHub (rama main) + │ + ▼ +GitHub notifica a Railway (webhook) + │ + ▼ +Railway clona el repositorio + │ + ▼ +Railpack detecta: proyecto Maven en directorio sync-service/ + │ + ▼ +Instalación de herramientas via .mise.toml: + - java = "21.0.2" + - maven = "3.9.9" + │ + ▼ +Build: mvn clean package -DskipTests + │ + ▼ +Nuevo contenedor arranca con el JAR compilado + │ + ▼ +Spring Boot arranca con SPRING_PROFILES_ACTIVE=prod + │ + ▼ +Flyway conecta a MySQL y ejecuta migraciones pendientes + │ + ▼ +Servicio disponible en la URL pública de Railway +``` + +Todo este proceso tarda aproximadamente 2-3 minutos. Si el build falla (error de compilación, test fallido), el deploy anterior sigue activo; no hay downtime por builds rotos. + +### Configuración de variables de entorno + +Las credenciales y configuración sensible se configuran como variables de entorno en el panel de Railway, nunca en el código: + +``` +SPRING_PROFILES_ACTIVE = prod +SPRING_DATASOURCE_URL = jdbc:mysql://mysql.railway.internal:3306/railway?... +SPRING_DATASOURCE_USERNAME = root +SPRING_DATASOURCE_PASSWORD = +SPRING_JPA_DATABASE_PLATFORM = org.hibernate.dialect.MySQLDialect +SPRING_JPA_HIBERNATE_DDL_AUTO = validate +SYNC_SCHEDULING_ENABLED = true +DOLIBARR_API_KEY = +PRESTASHOP_API_KEY = +API_SECURITY_PASSWORD = {noop}admin123 +``` + +`mysql.railway.internal:3306` es la dirección de red privada interna de Railway. El sync-service y la base de datos están en la misma red virtual, por lo que la comunicación es directa y más rápida que pasar por la red pública. Además, la base de datos no está expuesta al exterior. + +**Lección aprendida:** Railway tiene "Shared Variables" que en teoría se comparten entre servicios del mismo proyecto. En la práctica, estas variables **no se inyectan automáticamente** como variables de entorno en los servicios. Todas las variables hay que configurarlas directamente en el servicio `ProyectoIntermodular`. + +--- + +## 7. Dificultades encontradas + +Esta sección describe los problemas más relevantes que surgieron durante el desarrollo, más allá de los bugs técnicos puntuales de PrestaShop (que tienen su propia sección). + +### 7.1 Autenticación de PrestaShop en hosting con nginx + +**Descripción:** El Webservice de PrestaShop usa HTTP Basic Auth. Al implementar el cliente, las peticiones devolvían siempre 401 Unauthorized, aunque las credenciales eran correctas. + +**Diagnóstico:** El servidor de hosting usa nginx como proxy inverso delante de Apache/PHP. nginx, por seguridad, **elimina el header `Authorization` antes de reenviar la petición a PHP**. PrestaShop nunca recibe la clave de autenticación. + +**Solución:** La documentación de PrestaShop menciona un mecanismo alternativo: pasar la clave como query parameter `ws_key`. Este parámetro sí sobrevive al proxy nginx porque va en la URL, no en los headers. Se modificó `PrestashopClient` para añadir `ws_key` a cada URL. + +**Impacto:** Todos los métodos de `PrestashopClient` debían incluir `ws_key` en la URI. Se creó un método auxiliar que construye las URIs con el parámetro ya incluido. + +### 7.2 PrestaShop requiere XML para escrituras + +**Descripción:** Al intentar crear un producto enviando JSON en el cuerpo de la petición POST, PrestaShop devolvía error 400. + +**Diagnóstico:** El Webservice de PrestaShop es de una generación anterior y solo acepta XML para operaciones de escritura (POST y PUT). JSON está disponible solo para las respuestas de lectura (GET) mediante `output_format=JSON`. + +**Solución:** Implementar métodos `toProductXml()`, `toStockAvailableXml()` en `PrestashopClient` que construyen el documento XML manualmente con `StringBuilder`. Se eligió construcción manual sobre una librería XML porque la estructura es fija y simple, y añadir una dependencia XML solo para este uso sería sobredimensionado. + +**Aprendizaje:** Antes de diseñar la capa de integración, hay que verificar el formato de datos que acepta cada API, no asumirlo. + +### 7.3 Descubrimiento de bugs no documentados en PrestaShop 8.2.5 + +**Descripción:** PrestaShop 8.2.5 introdujo cambios en el Webservice y comportamientos no documentados que no existían en versiones anteriores. Ninguno de estos estaba en la documentación oficial ni en la mayoría de recursos online, porque la versión 8 es relativamente reciente. + +Estos bugs se descubrieron durante las pruebas en entorno real y requirieron depuración directa en la base de datos de PrestaShop con consultas SQL para entender qué estaba pasando. Están documentados en detalle en la sección 8. + +**Impacto en el desarrollo:** Lo que en teoría debería haber sido una verificación de 2-3 horas se convirtió en una sesión de depuración de varios días. Cada bug desvelaba el siguiente. + +### 7.4 Despliegue en Railway: versión de Maven inexistente + +**Descripción:** Al subir los cambios finales del proyecto (con los archivos de la app JavaFX), el deploy en Railway falló con el error: + +``` +mise ERROR Failed to install aqua:apache/maven@3.9.16: +HTTP status client error (404 Not Found) +``` + +**Causa:** Railway usa Railpack para detectar automáticamente las versiones de herramientas. Railpack intentó instalar `maven@3.9.16`, una versión que no existe en el repositorio oficial de Apache Maven. + +**Solución:** Crear el fichero `sync-service/.mise.toml` que fija las versiones de Java y Maven de forma explícita: ```toml [tools] @@ -746,42 +1070,43 @@ java = "21.0.2" maven = "3.9.9" ``` -**Base de datos:** Plugin MySQL de Railway. Se comunica con el sync-service a través de la red privada interna (`mysql.railway.internal:3306`), más rápida y segura que la pública. +**Aprendizaje:** La detección automática de versiones no siempre es fiable. Es buena práctica fijar explícitamente las versiones de las herramientas de build para garantizar reproducibilidad. -**Variables de entorno configuradas en Railway:** +### 7.5 Gestión de las credenciales en Railway -``` -SPRING_PROFILES_ACTIVE=prod -SPRING_DATASOURCE_URL=jdbc:mysql://mysql.railway.internal:3306/railway?useSSL=false&serverTimezone=UTC&characterEncoding=UTF-8&allowPublicKeyRetrieval=true -SPRING_DATASOURCE_USERNAME=root -SPRING_DATASOURCE_PASSWORD= -SPRING_JPA_DATABASE_PLATFORM=org.hibernate.dialect.MySQLDialect -SPRING_JPA_HIBERNATE_DDL_AUTO=validate -SYNC_SCHEDULING_ENABLED=true -DOLIBARR_API_KEY= -PRESTASHOP_API_KEY= -API_SECURITY_PASSWORD={noop}admin123 -``` +**Descripción:** La primera configuración del despliegue usó "Shared Variables" de Railway, pensando que se propagarían automáticamente al servicio. -> `allowPublicKeyRetrieval=true` es necesario porque MySQL 8 usa `caching_sha2_password` como método de autenticación por defecto, que requiere esta opción en el conector JDBC. +**Causa:** Las Shared Variables de Railway son variables globales del proyecto visibles para todos los servicios, pero **no se inyectan automáticamente** como variables de entorno. Solo sirven para referenciarlas con `${{NombreVariable}}` dentro de las variables propias del servicio. -**Lección aprendida — Variables compartidas de Railway:** Railway tiene "Shared Variables" que en teoría se comparten entre servicios. En la práctica, **no se inyectan automáticamente** en los servicios. Todas las variables deben configurarse directamente en el servicio `ProyectoIntermodular`, no como Shared Variables. +**Solución:** Configurar todas las variables directamente en el servicio `ProyectoIntermodular`, sin usar Shared Variables. + +**Impacto:** El servicio arrancaba pero fallaba porque las propiedades `integration.dolibarr.api-key` y `integration.prestashop.api-key` llegaban vacías, y la validación `@NotBlank` hacía que Spring Boot abortara el arranque. + +### 7.6 Fat JAR incompatible con JavaFX + +**Descripción:** Se intentó configurar `maven-shade-plugin` para generar un JAR ejecutable con todas las dependencias incluidas (fat JAR), de forma que la app JavaFX se pudiera distribuir como un único fichero. + +**Causa:** JavaFX no funciona con fat JARs. Las librerías nativas de JavaFX (ficheros `.dll` en Windows, `.so` en Linux, `.dylib` en macOS) no se pueden incluir dentro de un JAR. Al intentar ejecutar el fat JAR, la JVM no encontraba las librerías nativas y lanzaba `UnsatisfiedLinkError`. + +**Solución:** Eliminar `maven-shade-plugin` del `pom.xml` y usar el plugin oficial `javafx-maven-plugin` con el objetivo `javafx:run`. Este plugin gestiona correctamente las librerías nativas de JavaFX. + +**Limitación resultante:** La app JavaFX requiere que Maven esté instalado para ejecutarse. No existe un JAR distribuible de un solo fichero. Para una distribución real, habría que usar `jpackage` (herramienta incluida en el JDK desde Java 14) que genera un instalador nativo para cada plataforma. --- -## 7. Pruebas en entorno real y bugs encontrados +## 8. Pruebas en entorno real y bugs de PrestaShop 8 -Durante la verificación end-to-end del sistema con datos reales en Dolibarr y PrestaShop 8.2.5, se encontraron y corrigieron seis bugs. Todos estaban en el comportamiento de la API de PrestaShop 8, que difiere de la documentación oficial. +Durante la verificación end-to-end del sistema con datos reales en Dolibarr y PrestaShop 8.2.5, se encontraron y corrigieron seis bugs. Todos estaban relacionados con comportamientos no documentados o cambios introducidos en PS 8 que no existían en versiones anteriores. -### Bug 1 — `{"products": false}` en lugar de array vacío +### Bug 1 — `{"products": false}` en lugar de lista vacía -**Contexto:** Al buscar un producto por referencia y no encontrarlo, se esperaba recibir `{"products": []}` (lista vacía). +**Versiones afectadas:** PrestaShop 8.x +**Comportamiento esperado:** Al buscar un producto por referencia sin resultados: `{"products": []}` +**Comportamiento real:** `{"products": false}` -**Comportamiento real:** PrestaShop 8 devuelve `{"products": false}` cuando ningún producto coincide con el filtro. +Spring 6 RestClient intenta deserializar `false` como `List` y lanza `HttpMessageConversionException`. La sincronización fallaba completamente para cualquier producto que no existiera aún en PS. -**Consecuencia:** El cliente HTTP de Spring 6 lanzaba `HttpMessageConversionException` al intentar deserializar `false` como `List`. La sincronización fallaba completamente. - -**Fix:** Capturar `HttpMessageConversionException` y `RestClientException` en `getProductByReference()` y tratarlos como "no encontrado" (devolver `Optional.empty()`). +**Fix:** Capturar `HttpMessageConversionException` y `RestClientException` en `getProductByReference()` y devolver `Optional.empty()`. **Archivo:** `PrestashopClient.java` — método `getProductByReference` @@ -789,11 +1114,12 @@ Durante la verificación end-to-end del sistema con datos reales en Dolibarr y P ### Bug 2 — HTTP 404 en lugar de lista vacía -**Contexto:** Similar al Bug 1, pero en lugar de JSON inválido, PrestaShop devuelve HTTP 404. +**Versiones afectadas:** PrestaShop 8.x +**Relacionado con Bug 1.** -**Comportamiento real:** En algunos casos, cuando el filtro no coincide con ningún resultado, PS devuelve 404 en lugar de 200 con lista vacía. Esto no está documentado. +En algunos casos, cuando el filtro no coincide con ningún producto, PS devuelve HTTP 404 en lugar de 200 con lista vacía. Este comportamiento no está documentado. -**Fix:** Capturar `PrestashopApiException` con código 404 y devolver `Optional.empty()`. +**Fix:** Capturar `PrestashopApiException` con código de estado 404 y devolver `Optional.empty()`. **Archivo:** `PrestashopClient.java` — método `getProductByReference` @@ -801,24 +1127,32 @@ Durante la verificación end-to-end del sistema con datos reales en Dolibarr y P ### Bug 3 — Stock siempre "OUT OF STOCK" en la tienda -**Contexto:** Después de sincronizar productos y stock desde Dolibarr, la tienda mostraba todos los productos como "OUT OF STOCK" aunque el stock en la base de datos fuera mayor que 0. +**Síntoma:** Después de sincronizar productos y stock desde Dolibarr, la tienda mostraba todos los productos como "OUT OF STOCK" aunque el campo `quantity` en la base de datos tuviera valores correctos (ej. 50 unidades). -**Causa:** PrestaShop gestiona el stock en la tabla `ps_stock_available`. Tiene dos tipos de registros: -- `id_shop = 0` → stock global (sin tienda específica) -- `id_shop = 1` → stock para la tienda específica (ID 1) +**Diagnóstico:** PrestaShop gestiona el stock en la tabla `ps_stock_available`. Esta tabla puede tener dos tipos de registros para el mismo producto: +- `id_shop = 0` → stock "global", sin tienda específica. +- `id_shop = 1` → stock para la tienda con ID 1 (la tienda principal). -El storefront de PS **solo lee los registros `id_shop = 1`** para mostrar disponibilidad. Cuando el sync-service creaba un producto via Webservice, PS creaba automáticamente el `stock_available` con `id_shop = 0`. Los productos creados manualmente desde el admin de PS tenían `id_shop = 1`. +El **storefront** solo lee los registros con `id_shop = 1` para mostrar la disponibilidad. Cuando el Webservice crea un producto, PS crea automáticamente el `stock_available` con `id_shop = 0`. Los productos creados manualmente desde el admin tenían `id_shop = 1`. -**Evidencia:** Query SQL en la BD de PS: +**Evidencia SQL:** ```sql -SELECT id_product, id_shop, quantity FROM ps_stock_available +SELECT id_product, id_shop, quantity +FROM ps_stock_available WHERE id_product IN (42, 43); --- Resultado: id_shop = 0 para todos los creados via Webservice + +-- Resultado: +-- id_product | id_shop | quantity +-- 42 | 0 | 50 ← creado via WS, invisible para el storefront +-- 43 | 1 | 50 ← creado via admin, funciona ``` -**Fix código:** Añadir `1` en el XML de `toStockAvailableXml()`. +**Fix código:** Añadir `1` al XML de `toStockAvailableXml()`. -**Fix BD (one-time):** `UPDATE ps_stock_available SET id_shop=1 WHERE id_shop=0` para corregir los registros ya existentes. +**Fix base de datos (one-time):** +```sql +UPDATE ps_stock_available SET id_shop=1 WHERE id_shop=0; +``` **Archivo:** `PrestashopClient.java` — método `toStockAvailableXml` @@ -826,26 +1160,34 @@ WHERE id_product IN (42, 43); ### Bug 4 — Productos invisibles en el panel de administración de PS 8 -**Contexto:** Después de sincronizar productos, la tienda online los mostraba correctamente, pero el panel de administración de PrestaShop (Catalog → Products) mostraba 0 productos. +**Síntoma:** Los productos sincronizados aparecían correctamente en la tienda online para los clientes, pero el panel de administración (Catálogo → Productos) mostraba 0 productos. -**Causa:** PrestaShop 8 introdujo una nueva columna `state` en `ps_product`: -- `state = 0` → borrador (no publicado) -- `state = 1` → publicado +**Diagnóstico:** PrestaShop 8 introdujo una columna `state` en `ps_product`: +- `state = 0` → borrador (no publicado). +- `state = 1` → publicado. -El **admin de PS 8** filtra por `state = 1` en su listado. El **storefront** no filtra por `state`. Por tanto, un producto con `state = 0` es visible en la tienda pero invisible en el panel de administración. +El **admin de PS 8** filtra automáticamente por `state = 1` en su listado. El **storefront** no filtra por `state`. Un producto con `state = 0` es visible para los clientes pero invisible en el panel de administración. -Cuando se crea un producto via Webservice sin especificar `state`, PS lo crea con `state = 0` (valor por defecto de la columna). +Al crear productos via Webservice sin especificar `state`, PS los crea con `state = 0` (valor por defecto de la columna SQL). Este campo `state` no existía en PrestaShop 7 y no está documentado en el Webservice. -**Evidencia:** +**Evidencia SQL:** ```sql -SELECT id_product, reference, active, state FROM ps_product +SELECT id_product, reference, active, state +FROM ps_product WHERE id_product IN (42, 43); --- Resultado: state = 0 para todos los creados via Webservice + +-- Resultado: +-- id_product | reference | active | state +-- 42 | CAMISETA-001 | 1 | 0 ← invisible en admin +-- 43 | PANTALON-002 | 1 | 1 ← visible en admin ``` -**Fix código:** Añadir `1` en el XML de `toProductXml()`. +**Fix código:** Añadir `1` al XML de `toProductXml()`. -**Fix BD (one-time):** `UPDATE ps_product SET state=1 WHERE state=0` +**Fix base de datos (one-time):** +```sql +UPDATE ps_product SET state=1 WHERE state=0; +``` **Archivo:** `PrestashopClient.java` — método `toProductXml` @@ -853,152 +1195,245 @@ WHERE id_product IN (42, 43); ### Bug 5 — Productos duplicados (18 copias del mismo producto) -**Contexto:** Al revisar la BD de PrestaShop, se encontraron 18 instancias del mismo producto de prueba con IDs del 23 al 40. +**Síntoma:** Al revisar la base de datos de PrestaShop, había 18 instancias del mismo producto de prueba, con IDs del 23 al 40. -**Causa:** Consecuencia en cadena del Bug 4. El método `getProductByReference()` filtra por `filter[active]=[1]`. Los productos con `state = 0` (Bug 4), aunque estaban activos (`active = 1`), no eran devueltos correctamente. El sync-service interpretaba que el producto no existía en PS y lo creaba de nuevo en cada ejecución. +**Causa:** Consecuencia en cadena del Bug 4. +El método `getProductByReference()` filtra los productos de PS. Los productos con `state = 0` (Bug 4) no aparecían en los resultados del filtro aunque existieran en la base de datos. El sync-service interpretaba "no existe" y lo creaba de nuevo en cada ejecución. 18 ejecuciones = 18 duplicados. -**Fix:** El fix del Bug 4 (`state = 1`) resolvió la causa raíz. Los 18 duplicados se eliminaron manualmente desde el panel de administración de PS. +**Fix:** El fix del Bug 4 (`state = 1`) resuelve la causa raíz. Una vez que los productos tienen `state = 1`, el filtro los devuelve correctamente y el sync detecta que ya existen → hace UPDATE en lugar de CREATE. + +Los 18 duplicados se eliminaron manualmente desde el admin de PS. --- -### Bug 6 — Soft-delete en PrestaShop: el mapping local queda obsoleto +### Bug 6 — Soft-delete: el mapping local queda obsoleto -**Contexto:** Al eliminar un producto desde el panel de administración de PS, el sync-service seguía intentando hacer PUT al mismo ID de PS, recibiendo 200 pero el producto ya no existía realmente en la tienda. +**Síntoma:** Al eliminar un producto desde el admin de PS y volver a ejecutar el sync, el producto no se recreaba en PS. -**Causa:** PrestaShop usa **soft-delete**: al eliminar un producto desde el admin, no borra el registro de la tabla `ps_product`, sino que pone `active = 0`. El mapping local del sync-service tenía el antiguo ID de PS almacenado y lo usaba para el UPDATE, pero ese ID ya apuntaba a un producto "borrado". +**Causa:** PrestaShop usa soft-delete. Al eliminar un producto desde el admin, no se borra el registro de `ps_product`; simplemente se pone `active = 0`. El sync-service tenía en `product_mapping` el antiguo `prestashop_id` y llamaba a `PUT /api/products/{id}`, que devolvía 200 pero el producto con `active = 0` seguía sin aparecer en la tienda. -**Fix:** Cambiar la estrategia de sincronización: en lugar de confiar en el mapping local para decidir si crear o actualizar, **siempre se consulta PrestaShop** por SKU como fuente de verdad: -- Si `getProductByReference(sku)` devuelve resultado → UPDATE. -- Si devuelve vacío → CREATE. +**Fix:** Cambiar la estrategia: en lugar de confiar en el mapping local para decidir crear o actualizar, **siempre se consulta PS por SKU**: +- `getProductByReference(sku)` devuelve producto → UPDATE. +- `getProductByReference(sku)` devuelve vacío → CREATE. -Esto hace la sincronización idempotente y resiliente ante cambios manuales en PS. +Esta estrategia es resiliente ante cualquier modificación manual en PS (borrados, cambios de referencia, etc.). **Archivo:** `ProductSyncService.java` — método `pushProduct` --- -### Observación adicional — Endpoint raíz `/api/` da HTTP 500 +### Observación — endpoint raíz `/api/` devuelve HTTP 500 -El endpoint raíz del Webservice de PrestaShop (`/api/`) devuelve HTTP 500 en PS 8.2.5, aunque en versiones anteriores devolvía la lista de recursos disponibles. No es un bug crítico pero hay que evitar llamar a ese endpoint. Siempre se usan recursos específicos: `/api/products`, `/api/orders`, etc. +El endpoint raíz del Webservice (`/api/`) devuelve HTTP 500 en PS 8.2.5, aunque en versiones anteriores devolvía el listado de recursos disponibles. No es un bug crítico, pero hay que usar siempre recursos específicos: `/api/products`, `/api/orders`, etc. --- -## 8. Decisiones técnicas clave +## 9. Decisiones técnicas clave -Esta sección resume las decisiones de diseño más importantes y su justificación. Son los puntos que se deben poder argumentar en la defensa oral. - -### `fixedDelay` vs `fixedRate` en @Scheduled - -`fixedDelay` mide el tiempo desde que **termina** la ejecución anterior. `fixedRate` mide desde que **empieza**. Si una sync tarda más que el intervalo configurado y se usa `fixedRate`, se acumulan ejecuciones pendientes. Con `fixedDelay` eso es imposible: la siguiente siempre espera a que termine la anterior. - -### `ThreadPoolTaskScheduler(poolSize=1)` — pool de un solo hilo - -Aunque hay tres métodos `@Scheduled`, el scheduler tiene un único hilo de ejecución. Esto garantiza que los tres flujos son estrictamente secuenciales. Dos flujos no pueden ejecutarse a la vez aunque el pool tuviera más hilos, porque acceden a los mismos recursos (BD local, mismas APIs). - -### No `@Transactional` en servicios de batch - -En el procesamiento de múltiples ítems, usar `@Transactional` haría que un fallo en el ítem N deshiciera todos los guardados anteriores (rollback). El comportamiento correcto para sincronización es procesamiento parcial: si falla 1 de 100, los 99 que funcionaron se guardan correctamente. Cada `save()` es un autocommit independiente. - -### `try/finally` garantiza escritura del SyncLog - -El bloque `finally` en cada servicio asegura que el `SyncLog` siempre se escribe, incluso si hay una excepción no controlada. Sin esto, si el servicio crashea a mitad de ejecución, no quedaría ningún registro del intento. - -### `DelegatingPasswordEncoder` — mismo campo para dev y prod - -Permite usar `{noop}texto_plano` en desarrollo y `{bcrypt}hash` en producción en el mismo campo de configuración. El código no cambia entre entornos; solo la variable de entorno. - -### Flyway sobre Liquibase - -Flyway usa SQL puro para las migraciones. Liquibase añade una capa de abstracción (XML/YAML) que no aporta valor en este proyecto. Ambas herramientas resuelven el mismo problema; Flyway es más simple. - -### H2 con `MODE=MySQL` para desarrollo - -H2 acepta la sintaxis SQL de MySQL cuando se activa `MODE=MySQL`. Esto permite usar exactamente los mismos ficheros de migración Flyway en dev y prod sin ninguna adaptación. - -### RestClient sobre RestTemplate y WebClient - -`RestTemplate` está marcado como deprecated en Spring 6. `WebClient` es para programación reactiva (non-blocking), que añade complejidad innecesaria en un sistema síncrono. `RestClient` es la API moderna síncrona de Spring 6. - -### PS como fuente de verdad para productos (no el mapping local) - -Siempre se consulta PS antes de decidir si crear o actualizar. El mapping local puede quedar obsoleto si alguien borra un producto desde el admin de PS. Hacer `getProductByReference()` en cada sync cuesta una petición HTTP extra pero garantiza que nunca se actualiza un ID de PS que ya no existe. - -### SKU como identificador común en lugar de IDs internos - -Los IDs internos de Dolibarr y PrestaShop son independientes y no tienen relación entre sí. El SKU (referencia del producto) es el único campo que tiene el mismo valor en los dos sistemas. Es el "pasaporte" del producto. +Esta sección explica las decisiones más importantes en formato pregunta-respuesta, tal como podría plantearlas un tribunal en la defensa oral. --- -## 9. Cómo arrancar el proyecto +**¿Por qué `fixedDelay` y no `fixedRate` en los jobs programados?** + +`fixedRate` dispara el job cada N segundos desde que empezó el anterior, independientemente de si ha terminado. Si una sync tarda más que el intervalo, la siguiente ya debería haber empezado antes de que termine la anterior: se acumulan ejecuciones en cola. + +`fixedDelay` espera N segundos desde que terminó el anterior. Si una sync tarda 20 minutos con `fixedDelay=15m`, la siguiente empezará a los 35 minutos del inicio de la primera. Nunca puede haber solapamiento. + +--- + +**¿Por qué un ThreadPoolTaskScheduler de un solo hilo?** + +Aunque hay tres métodos `@Scheduled`, el pool de un único hilo garantiza que los tres flujos sean estrictamente secuenciales: nunca pueden ejecutarse dos a la vez. Esto es importante porque los tres flujos acceden a las mismas tablas de la base de datos local y a las mismas APIs externas. Con dos hilos, dos flujos podrían modificar `product_mapping` simultáneamente y producir inconsistencias. + +--- + +**¿Por qué no `@Transactional` en los servicios de sincronización?** + +`@Transactional` abre una transacción al inicio del método y hace rollback si lanza una excepción. En un batch de 100 productos, si falla el número 50, los 49 anteriores ya guardados se desharían. El comportamiento correcto para sincronización es procesamiento parcial: si falla 1 de 100, los 99 que funcionaron se persisten igualmente. Cada `save()` es un commit independiente. + +--- + +**¿Por qué `try/finally` en lugar de capturar la excepción?** + +`finally` se ejecuta **siempre**, tanto si el código termina normalmente como si lanza cualquier tipo de excepción (incluyendo `Error` de la JVM). Capturar con `catch (Exception e)` no captura `Error`. Con `finally`, el `SyncLog` siempre se escribe, garantizando que nunca haya una ejecución sin registro. + +--- + +**¿Por qué `DelegatingPasswordEncoder`?** + +Permite usar `{noop}texto_plano` en desarrollo (sin hash, más cómodo para depurar) y `{bcrypt}hash_seguro` en producción, en el mismo campo de configuración. El código de `SecurityConfig.java` no cambia entre entornos. Solo cambia el valor de la variable de entorno `API_SECURITY_PASSWORD`. + +--- + +**¿Por qué Flyway en lugar de `ddl-auto=create-drop` o `update`?** + +`create-drop` borra la base de datos en cada reinicio del servidor: inaceptable en producción. `update` intenta inferir los cambios del esquema a partir de las entidades JPA, pero tiene limitaciones y puede producir resultados inesperados (no elimina columnas, no gestiona índices correctamente). Flyway ejecuta SQL explícito y versionado: sabes exactamente qué SQL se va a ejecutar y en qué orden. + +--- + +**¿Por qué H2 con MODE=MySQL en lugar de una BD de verdad para desarrollo?** + +H2 en memoria arranca en milisegundos y no requiere instalación. `MODE=MySQL` hace que H2 acepte la sintaxis SQL de MySQL, por lo que las migraciones Flyway funcionan sin modificaciones en ambos entornos. Si un desarrollador nuevo clona el repositorio, puede ejecutar el proyecto directamente sin configurar una base de datos. + +--- + +**¿Por qué RestClient y no RestTemplate ni WebClient?** + +RestTemplate está marcado como deprecated en Spring 6 (la versión que usa este proyecto). WebClient es para programación reactiva (non-blocking), que añade complejidad innecesaria en un sistema síncrono. RestClient es la API moderna síncrona de Spring 6, con una API fluida más legible. + +--- + +**¿Por qué PrestaShop como fuente de verdad y no el mapping local?** + +El mapping local puede quedar obsoleto si alguien modifica PS manualmente (borra un producto, cambia su referencia). Si el sync-service confía ciegamente en el mapping local, intentará actualizar un ID de PS que ya no existe o está borrado. Consultar PS directamente por SKU en cada sync añade una petición HTTP pero garantiza que la operación es siempre correcta, independientemente del estado del mapping local. + +--- + +**¿Por qué SKU como identificador común y no los IDs internos?** + +Los IDs internos de Dolibarr y PrestaShop son autoincrementales, independientes y sin ninguna relación entre sí. El mismo producto puede ser ID 15 en uno y ID 42 en el otro. El SKU (referencia) es el único campo que el negocio controla y que tiene el mismo valor en ambos sistemas. Es el "pasaporte" del producto entre sistemas. + +--- + +**¿Por qué arquitectura en tres capas y no conectar las apps directamente con Dolibarr y PrestaShop?** + +Si las apps conectaran directamente, las claves de API de Dolibarr y PrestaShop estarían en los dispositivos del usuario (inseguro). No habría base de datos para el historial. El scheduling no sería posible (las apps pueden estar cerradas). Y la lógica de sincronización habría que implementarla en JavaFX, Android y cualquier cliente futuro. Con el servicio intermediario, toda esa complejidad vive en un solo lugar. + +--- + +## 10. Limitaciones conocidas y trabajo futuro + +### Limitaciones actuales + +**Distribución de la app JavaFX:** +La app JavaFX se ejecuta con `mvn javafx:run`, lo que requiere que el usuario tenga Java 21 y Maven instalados. No existe un instalador `.exe` ni un JAR ejecutable autónomo. Para generar un instalador real habría que usar `jpackage` (incluido en el JDK), que genera instaladores nativos (`.exe` para Windows, `.dmg` para macOS, `.deb/.rpm` para Linux). + +**Sin sincronización en tiempo real:** +Los cambios en Dolibarr tardan hasta 15 minutos en aparecer en PrestaShop (el intervalo del job de productos). Para pedidos, el delay máximo es de 5 minutos. Para un negocio con alta rotación de stock esto podría ser insuficiente, aunque los intervalos son configurables y reducibles. + +**Un solo usuario de API:** +La configuración de seguridad actual tiene un único usuario (`admin`). Para un entorno empresarial real, habría que implementar gestión de usuarios con diferentes niveles de acceso. + +**Sin gestión de imágenes de productos:** +La sincronización de productos transfiere nombre, descripción, precio y stock, pero no las imágenes. Las imágenes de PrestaShop hay que subirlas manualmente o implementar un flujo adicional. + +**Sin sincronización de categorías:** +Los productos se asignan a una categoría por defecto en PrestaShop (`defaultCategoryId` en la configuración). No hay sincronización de la jerarquía de categorías de Dolibarr a PrestaShop. + +**Limitaciones de la API de Dolibarr:** +El cliente `DolibarrClient` obtiene todos los productos en cada sync (sin filtro incremental por fecha), porque la API de Dolibarr no ofrece un endpoint de "modificados desde fecha X". Esto significa que con un catálogo muy grande (miles de productos), cada sync de productos puede tardar bastante. + +### Trabajo futuro + +- **`jpackage`**: generar instaladores nativos para Windows y macOS de la app JavaFX. +- **Filtro incremental en Dolibarr**: implementar detección de cambios por timestamp para evitar procesar todos los productos en cada sync. +- **Sincronización de imágenes**: transferir las imágenes de los productos de Dolibarr a PrestaShop. +- **Webhooks**: en lugar de polling periódico, PrestaShop puede enviar notificaciones cuando ocurre un pedido. Esto eliminaría el delay y la carga de las consultas periódicas. +- **Gestión de usuarios**: múltiples usuarios con roles en la API. +- **Panel web ligero**: interfaz web adicional para acceso desde cualquier dispositivo sin instalación. +- **Tests de integración**: actualmente los tests son unitarios con mocks. Tests de integración con WireMock simularían las APIs de Dolibarr y PrestaShop para verificar el comportamiento end-to-end. + +--- + +## 11. Conclusión + +Este proyecto ha desarrollado un sistema completo de integración entre dos plataformas comerciales que no se comunican nativamente: el ERP Dolibarr y la tienda PrestaShop. El sistema cumple los dos requisitos funcionales del enunciado: sincronización de productos y stock del ERP hacia la tienda, e importación de pedidos de la tienda hacia el ERP. + +El desarrollo se estructuró en seis fases progresivas, comenzando por la infraestructura base y terminando con las apps cliente. Esta progresión permitió validar cada capa antes de construir la siguiente: los clientes HTTP se probaron antes de implementar los servicios de sincronización; la API REST se verificó en Swagger antes de desarrollar las apps cliente. + +La parte más compleja del proyecto no fue la arquitectura ni el código, sino la **integración con PrestaShop 8.2.5**. Los seis bugs no documentados encontrados durante las pruebas en entorno real evidencian que las pruebas en entorno controlado (con mocks o datos ficticios) no pueden sustituir las pruebas en el sistema real. Cada bug requirió análisis directo de la base de datos de PrestaShop para entender la causa raíz, lo que fue una experiencia práctica valiosa en diagnóstico y depuración de sistemas. + +Desde el punto de vista arquitectónico, el diseño en tres capas con un servicio intermediario centralizado ha demostrado ser la elección correcta: permite mantener la lógica de integración en un único lugar, protege las credenciales de las APIs externas, posibilita el scheduling automático y es extensible (añadir una tercera app cliente no requiere cambios en el backend). + +Las decisiones técnicas adoptadas (RestClient, Flyway, `fixedDelay`, `try/finally`, `DelegatingPasswordEncoder`, H2 en modo MySQL) no son arbitrarias: cada una responde a un requisito concreto o a una limitación del entorno. Poder argumentar estas decisiones es parte del objetivo del TFG. + +El sistema está desplegado y en funcionamiento en Railway, conectado con los sistemas reales de Dolibarr y PrestaShop en el hosting del proyecto. + +--- + +## 12. Cómo arrancar el proyecto ### sync-service en local (desarrollo) **Requisitos:** JDK 21, Maven 3.8+ ```bash -# 1. Crear fichero de configuración dev +# 1. Crear el fichero de configuración cp sync-service/src/main/resources/application-dev.yml.example \ sync-service/src/main/resources/application-dev.yml -# 2. Editar application-dev.yml y rellenar las claves reales -# integration.dolibarr.api-key: TU_CLAVE -# integration.prestashop.api-key: TU_CLAVE +# 2. Editar application-dev.yml y rellenar las claves reales: +# integration.dolibarr.api-key: TU_CLAVE +# integration.prestashop.api-key: TU_CLAVE # 3. Arrancar cd sync-service ./mvnw spring-boot:run -Dspring-boot.run.profiles=dev ``` -- Servidor: `http://localhost:8080` -- Swagger UI: `http://localhost:8080/swagger-ui.html` -- Consola H2: `http://localhost:8080/h2-console` (JDBC URL: `jdbc:h2:mem:syncdb`) -- Credenciales API: `admin` / `admin123` +| Recurso | URL | +|---|---| +| API | `http://localhost:8080` | +| Swagger UI | `http://localhost:8080/swagger-ui.html` | +| Consola H2 | `http://localhost:8080/h2-console` | +| JDBC URL H2 | `jdbc:h2:mem:syncdb` | +| Credenciales API | `admin` / `admin123` | + +> `application-dev.yml` está en `.gitignore` — nunca se sube al repositorio. ### sync-service en producción (Railway) -El despliegue es automático: cada push a la rama `main` del repositorio GitHub dispara un nuevo deploy en Railway. +El despliegue es automático: un push a `main` en GitHub dispara el deploy. -- URL: `https://proyectointermodular-production-a9c3.up.railway.app` -- Swagger UI: `https://proyectointermodular-production-a9c3.up.railway.app/swagger-ui.html` -- Health: `https://proyectointermodular-production-a9c3.up.railway.app/actuator/health` +| Recurso | URL | +|---|---| +| API | `https://proyectointermodular-production-a9c3.up.railway.app` | +| Swagger UI | `https://proyectointermodular-production-a9c3.up.railway.app/swagger-ui.html` | +| Health | `https://proyectointermodular-production-a9c3.up.railway.app/actuator/health` | ### javafx-client -**Requisitos:** JDK 21, Maven 3.8+ (JavaFX se descarga automáticamente) +**Requisitos:** JDK 21, Maven 3.8+ (JavaFX se descarga automáticamente de Maven Central) ```bash cd javafx-client mvn javafx:run ``` -La primera ejecución descarga las dependencias (~1-2 minutos). Las siguientes arrancan directamente. +La primera ejecución tarda ~1-2 minutos descargando dependencias. Las siguientes arrancan directamente. -Los ajustes de conexión (URL, usuario, contraseña) se guardan automáticamente al hacer login y se pueden cambiar desde la pestaña Ajustes de la aplicación. +Al iniciar, la app se conecta al servidor Railway por defecto. El checkbox "Usar servidor local" del formulario de login cambia a `localhost:8080`. ### android-app -Abrir el directorio `android-app/` con Android Studio y ejecutar en emulador o dispositivo físico. +Abrir el directorio `android-app/` con Android Studio. Seleccionar emulador o dispositivo físico y pulsar Run. --- -## 10. Glosario +## 13. Glosario | Término | Definición | |---|---| -| **ERP** | Enterprise Resource Planning. Software de gestión empresarial que integra procesos de negocio (contabilidad, stock, ventas...). En este proyecto: Dolibarr. | -| **Webservice** | Interfaz de programación de PrestaShop para integración con sistemas externos. Usa XML para escrituras y puede devolver JSON para lecturas. | -| **SKU** | Stock Keeping Unit. Código único que identifica un producto. En Dolibarr se llama `ref`; en PrestaShop `reference`. | -| **Mapping** | Registro que relaciona el mismo objeto en dos sistemas distintos. Por ejemplo, el producto con SKU `CAMISETA-001` es ID 15 en Dolibarr e ID 42 en PrestaShop. | -| **Soft-delete** | Técnica de "borrado lógico": en lugar de eliminar el registro de la base de datos, se marca con un flag (ej. `active=0`). PrestaShop usa soft-delete para productos. | -| **Idempotente** | Una operación que produce el mismo resultado independientemente de cuántas veces se ejecute. Las operaciones de sync son idempotentes: ejecutar PRODUCT_PUSH dos veces no crea productos duplicados. | +| **ERP** | Enterprise Resource Planning. Software de gestión empresarial que integra procesos como contabilidad, stock, ventas y RRHH. En este proyecto: Dolibarr. | +| **Webservice** | Interfaz de programación oficial de PrestaShop para integración con sistemas externos. Usa XML para escrituras y puede devolver JSON en lecturas. | +| **SKU** | Stock Keeping Unit. Código único que identifica un producto. En Dolibarr: `ref`. En PrestaShop: `reference`. El campo es el mismo en ambos sistemas. | +| **Mapping** | Registro que relaciona el mismo objeto en dos sistemas distintos. Ejemplo: el producto `CAMISETA-001` es ID 15 en Dolibarr e ID 42 en PrestaShop. | +| **Soft-delete** | Técnica de borrado lógico: en lugar de eliminar el registro, se marca con un flag (`active=0`). PrestaShop usa soft-delete para productos. | +| **Idempotente** | Una operación que produce el mismo resultado independientemente de cuántas veces se ejecute. Las syncs son idempotentes: ejecutar PRODUCT_PUSH dos veces no crea duplicados. | | **PaaS** | Platform as a Service. Servicio cloud que gestiona la infraestructura del servidor. En este proyecto: Railway. | -| **Basic Auth** | Mecanismo de autenticación HTTP. El cliente envía `usuario:contraseña` en Base64 en el header `Authorization`. | +| **Basic Auth** | Mecanismo de autenticación HTTP. El cliente envía `usuario:contraseña` codificado en Base64 en el header `Authorization`. | | **CSRF** | Cross-Site Request Forgery. Ataque que usa las cookies de sesión de un usuario. No aplica en APIs STATELESS sin cookies. | -| **Flyway** | Herramienta de migración de esquemas de base de datos. Ejecuta ficheros SQL versionados en orden. | +| **Flyway** | Herramienta de migración de esquemas de base de datos. Ejecuta ficheros SQL versionados (`V1__`, `V2__`...) en orden y registra cuáles ya se aplicaron. | | **DTO** | Data Transfer Object. Clase simple que solo transporta datos entre capas, sin lógica de negocio. | -| **RestClient** | Cliente HTTP síncrono de Spring 6. Reemplaza al deprecated RestTemplate. | -| **fixedDelay** | Modo de scheduling donde el temporizador empieza a contar cuando termina la ejecución anterior. | -| **fixedRate** | Modo de scheduling donde el temporizador empieza a contar cuando empieza la ejecución anterior (puede causar solapamientos). | -| **`@ConditionalOnProperty`** | Anotación Spring que activa un bean solo si una propiedad tiene un valor determinado. Permite desactivar el scheduler en el perfil dev. | -| **ISO-8601** | Estándar internacional para representar fechas y duraciones. `PT15M` = 15 minutos, `PT5M` = 5 minutos. | -| **`DelegatingPasswordEncoder`** | Codificador de contraseñas de Spring que selecciona el algoritmo basándose en el prefijo `{id}` de la contraseña almacenada. | +| **RestClient** | Cliente HTTP síncrono de Spring 6. Reemplaza al deprecated `RestTemplate`. | +| **fixedDelay** | Modo de scheduling: el temporizador empieza a contar cuando termina la ejecución anterior. Sin solapamiento posible. | +| **fixedRate** | Modo de scheduling: el temporizador empieza a contar cuando comienza la ejecución anterior. Puede causar solapamientos si el job tarda más que el intervalo. | +| **`@ConditionalOnProperty`** | Anotación Spring que activa un bean solo si una propiedad tiene un valor concreto. Permite desactivar el scheduler en dev. | +| **ISO-8601** | Estándar para fechas y duraciones. `PT15M` = 15 minutos, `PT5M` = 5 minutos, `2025-05-10T14:32:11Z` = fecha y hora UTC. | +| **`DelegatingPasswordEncoder`** | Codificador de Spring que selecciona el algoritmo de hash según el prefijo `{id}` de la contraseña. Permite `{noop}` en dev y `{bcrypt}` en prod. | +| **Upsert** | Operación que crea el registro si no existe y lo actualiza si existe. Combinación de INSERT y UPDATE. | +| **Fat JAR** | JAR que incluye todas las dependencias dentro. No funciona con JavaFX por sus librerías nativas. | +| **Proxy inverso** | Servidor (nginx en este caso) que recibe peticiones HTTP y las reenvía a otro servidor (Apache/PHP). El proxy puede modificar los headers en tránsito. | +| **`@NotBlank`** | Anotación de validación de Jakarta que rechaza strings null, vacíos o con solo espacios. Provoca fallo al arrancar si la propiedad no está configurada. | +| **Tercero (Thirdparty)** | Término de Dolibarr para cualquier entidad externa: cliente, proveedor, etc. Cada pedido importado desde PS se asocia a un tercero en Dolibarr. |