diff --git a/CLAUDE (1).md b/CLAUDE (1).md new file mode 100644 index 0000000..dec17cf --- /dev/null +++ b/CLAUDE (1).md @@ -0,0 +1,226 @@ + # TFG — Integración Dolibarr-PrestaShop + +## Quién soy + +Estudiante DAM (Desarrollo de Aplicaciones Multiplataforma, FP Grado Superior). Este es mi TFG, evaluado por tribunal. Criterios: calidad de código, arquitectura limpia, justificación de decisiones técnicas, documentación, patrones correctos, sistema funcional end-to-end. Tengo que defenderlo oralmente — todo debe ser entendido y argumentable por mí. + +## Objetivo + +Integrar Dolibarr (ERP) con PrestaShop (e-commerce), ambos en hosting compartido bajo `httpdocs`. + +Requisitos del enunciado: +1. Gestionar productos, propiedades y stock del ERP para integrarlos en PrestaShop. +2. Registrar en el ERP cualquier venta realizada a través de PrestaShop. + +## Arquitectura (tres capas) + +**Capa 1 — Apps cliente** (se hará después, no tocar ahora): +- JavaFX (escritorio) +- Kotlin Android (móvil) +- Panel web ligero (opcional) + +**Capa 2 — `sync-service`** (esto es lo que construimos): +- Spring Boot 3 + Java 21 + Maven +- API REST propia que consumen las apps cliente +- Tareas `@Scheduled` de sincronización +- Persistencia local: mapeo de IDs y logs de sincronización + +**Capa 3 — Sistemas externos:** +- **Dolibarr**: API REST nativa. Base: `https://prestashop.loading.net/dolibarr/api/index.php`. Auth: header `DOLAPIKEY: `. Formato: JSON. +- **PrestaShop**: Webservice nativo. Base: `https://prestashop.loading.net/tienda/api`. Auth: `ws_key` como **query param** (NO Basic Auth — nginx del hosting elimina el header `Authorization`). Formato: JSON con `&output_format=JSON`. + - **Bug PS 8.2.5**: endpoint raíz `/api/` devuelve 500. Usar siempre recursos específicos: `/api/products`, `/api/orders`, etc. + +**Identificador común:** SKU/referencia del producto — `ref` en Dolibarr, `reference` en PrestaShop. Nunca los IDs internos. + +## Stack tecnológico + +| Capa | Elección | +|------|----------| +| Lenguaje/Framework | Java 21 + Spring Boot 3 + Maven | +| Persistencia | Spring Data JPA. H2 en `dev`, MySQL/MariaDB en `prod` | +| Cliente HTTP | `RestClient` de Spring (no `RestTemplate`, no `WebClient`) | +| Serialización | Jackson (incluido en Spring Web) | +| Validación | Jakarta Validation | +| Logging | SLF4J + Logback (default Spring Boot) | +| Testing | JUnit 5 + Mockito + Spring Boot Test + WireMock | +| Docs API | SpringDoc OpenAPI (Swagger UI) | +| Scheduling | `@Scheduled` nativo. ShedLock si hace falta lock distribuido | + +## Estado actual + +**Fases 0–5 completadas. `sync-service` funcional end-to-end.** + +- Compila y arranca con `./mvnw spring-boot:run -Dspring-boot.run.profiles=dev`. +- Código en Gitea: `https://projectes.ieslamar.org/mireya.2526/ProyectoIntermodular.git` +- Espejo en GitHub: `https://github.com/luklpz/ProyectoIntermodular.git` (para Railway) +- Despliegue: Railway.app (Loading.es hosting compartido no puede ejecutar procesos Java persistentes) + +**Pendiente:** +- Fase 6 — Apps cliente (proyecto separado) + +## Modelo de dominio (entidades JPA locales) + +- **`ProductMapping`**: `id`, `sku` (único), `dolibarrId`, `prestashopId`, `lastSyncedAt`, `syncStatus` (enum: `PENDING/SYNCED/ERROR`), `errorMessage`. +- **`OrderMapping`**: `id`, `prestashopOrderId`, `dolibarrOrderId`, `dolibarrInvoiceId`, `importedAt`, `status` (enum). +- **`SyncLog`**: `id`, `syncType` (enum: `PRODUCT_PUSH/STOCK_PUSH/ORDER_PULL`), `startedAt`, `finishedAt`, `itemsProcessed`, `itemsFailed`, `errorDetails` (TEXT). + +## Flujos de sincronización + +1. **`pushProductsToPrestashop`** — lee productos Dolibarr modificados desde `lastSyncedAt`, crea/actualiza en PrestaShop por SKU, actualiza `ProductMapping`. +2. **`pushStockToPrestashop`** — lee stock actual en Dolibarr de productos mapeados, empuja a `stock_availables` de PrestaShop. +3. **`pullOrdersFromPrestashop`** — consulta pedidos PrestaShop desde `lastSyncedAt` con `filter[date_add]=...`, crea/localiza cliente en Dolibarr, crea `commande`, opcionalmente factura, guarda `OrderMapping`. + +Cada flujo escribe una entrada en `SyncLog` (éxito o fallo). + +## Roadmap por fases + +- ✅ **Fase 0 — Setup base** + - Estructura de paquetes: `integration.dolibarr`, `integration.prestashop`, `sync`, `mapping`, `api`, `config` + - Perfiles `application-dev.yml` (H2 MODE=MySQL) y `application-prod.yml` (MySQL) — ambos gitignoreados, `.example` en repo + - `@ConfigurationProperties` con `@Validated` para todas las properties sensibles + - SpringDoc OpenAPI en `pom.xml` + +- ✅ **Fase 1 — Clientes HTTP tipados** + - `DolibarrClient`: `RestClient` + header `DOLAPIKEY` global. Métodos: getProducts, getProductByRef, createProduct, updateProduct, updateStock, getOrCreateThirdparty, createOrder, createInvoiceFromOrder + - `PrestashopClient`: `RestClient` + `ws_key` como query param en cada petición. Métodos: getProducts, getProductByReference, createProduct, updateProduct, getStockAvailableForProduct, updateStockAvailable, getOrdersSince, getCustomer + - DTOs separados por API con `@JsonIgnoreProperties(ignoreUnknown=true)` + - `DolibarrApiException`, `PrestashopApiException` con body logueado + +- ✅ **Fase 2 — Modelo y persistencia** + - Entidades JPA: `ProductMapping`, `OrderMapping`, `SyncLog` + - Repositorios Spring Data JPA + - Flyway: `V1__init.sql` con tablas product_mapping, order_mapping, sync_log + +- ✅ **Fase 3 — Servicios de sincronización** + - `ProductSyncService`, `StockSyncService`, `OrderSyncService` + - Cada uno devuelve `SyncResult(processed, failed)` + - `try/finally` garantiza que `SyncLog` se escribe siempre + - NO `@Transactional` en batch: cada `save()` auto-commit independiente + +- ✅ **Fase 4 — Scheduling** + - `SyncScheduler` con `@Scheduled(fixedDelayString=...)` (delay post-finalización, no entre inicios) + - `@ConditionalOnProperty(sync.scheduling.enabled)` — desactivado en dev + - `ThreadPoolTaskScheduler(poolSize=1)` — ejecución secuencial, sin solapamiento + +- ✅ **Fase 5 — API REST propia** + - `SyncController`: POST `/api/sync/{products,stock,orders}` — sync manual + - `MappingController`: GET `/api/mappings/products?status=`, `/api/mappings/orders` + - `SyncLogController`: GET `/api/logs?type=`, `/api/logs/{id}` + - Spring Security: Basic Auth, `DelegatingPasswordEncoder` (`{noop}` dev / `{bcrypt}` prod), STATELESS, CSRF off + - Swagger UI: `/swagger-ui.html` + +- ✅ **Despliegue en Railway** ← completado + - GitHub mirror: `https://github.com/luklpz/ProyectoIntermodular.git` + - URL pública: `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` + - Root directory del servicio Railway: `sync-service` + - MySQL plugin de Railway — red privada (`mysql.railway.internal:3306`) + - Todas las credenciales en variables del servicio ProyectoIntermodular (ver sección Despliegue) + +- 🔲 **Verificación end-to-end con datos reales** ← HACER ANTES DE FASE 6 + - Ver sección "Verificación end-to-end" más abajo + +- **Fase 6 — Apps cliente** — proyecto separado, avisaré cuando toque + +## Verificación end-to-end con datos reales + +Antes de Fase 6 hay que probar que los tres flujos funcionan con datos reales en Dolibarr y PrestaShop. + +**Swagger UI:** `https://proyectointermodular-production-a9c3.up.railway.app/swagger-ui.html` +**Credenciales:** usuario `admin`, contraseña `admin123` + +### Flujo 1 — Productos Dolibarr → PrestaShop + +1. Crear un producto en Dolibarr (`https://prestashop.loading.net/dolibarr`) con referencia/SKU única (ej: `PROD-001`), nombre, precio y stock. +2. En Swagger: click "Authorize" → `admin` / `admin123`. +3. `POST /api/sync/products` → "Try it out" → "Execute". +4. Verificar respuesta: `{"processed": 1, "failed": 0}`. +5. Comprobar que el producto aparece en PrestaShop (`https://prestashop.loading.net/tienda`). +6. `GET /api/mappings/products` → debe listar el producto con `syncStatus: SYNCED`. + +### Flujo 2 — Stock Dolibarr → PrestaShop + +1. Modificar el stock del producto en Dolibarr. +2. `POST /api/sync/stock` → "Try it out" → "Execute". +3. Verificar que el stock actualizado aparece en PrestaShop. + +### Flujo 3 — Pedidos PrestaShop → Dolibarr + +1. Crear un pedido en PrestaShop comprando el producto. +2. `POST /api/sync/orders` → "Try it out" → "Execute". +3. Verificar respuesta: `{"processed": 1, "failed": 0}`. +4. Comprobar que el pedido aparece en Dolibarr como `commande`. +5. `GET /api/mappings/orders` → debe listar el pedido mapeado. + +### Ver logs de cualquier sync + +`GET /api/logs` → lista todas las ejecuciones con duración, items procesados y errores. +`GET /api/logs/{id}` → detalle de una ejecución concreta. + +### Si algo falla + +- `GET /api/logs` → campo `errorDetails` del log tendrá el mensaje de error. +- Los logs del servidor están en Railway → ProyectoIntermodular → Deploy Logs. + +## Despliegue (Railway) + +**Variables en el servicio ProyectoIntermodular (literales, NO Shared Variables):** + +``` +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 +``` + +**Lecciones aprendidas Railway:** +- Las Shared Variables NO inyectan automáticamente en servicios — poner todo en variables del servicio directamente +- `${{MySQL.MYSQLPASSWORD}}` solo resuelve en variables propias del servicio, no en Shared Variables +- MySQL 8 requiere `allowPublicKeyRetrieval=true` en la URL JDBC (usa `caching_sha2_password`) +- `mysql.railway.internal:3306` es la red privada interna — funciona sin configuración adicional + +Flyway ejecuta `V1__init.sql` en primer arranque y crea las tablas automáticamente. + +## Convenciones de código + +- Constructor injection con `@RequiredArgsConstructor` (Lombok). Sin `@Autowired` en campos. +- Lombok: `@Getter`, `@Setter`, `@RequiredArgsConstructor`, `@Builder` cuando aporten. **Nunca `@Data` en entidades JPA** (rompe `equals/hashCode` con relaciones bidireccionales). +- Nombres: `PascalCase` clases, minúsculas paquetes. +- JavaDoc en clases públicas y métodos no triviales (para la memoria del TFG). +- Commits pequeños y atómicos, mensajes en imperativo en inglés. +- Tests: unitarios con Mockito para servicios; integración mínima con `@SpringBootTest` + WireMock. + +## Lo que NO quiero + +- Microservicios, Kafka, Redis, Docker Compose con cinco contenedores. +- Dependencias sin valor real. +- CQRS, event sourcing, hexagonal estricto, DDD táctico complejo. Arquitectura en capas limpia es suficiente. +- Mezclar lógica Dolibarr y PrestaShop en la misma clase. +- Strings mágicos: todo a constantes o properties. +- `catch (Exception e)` genérico. +- Generar 15 archivos de golpe sin explicar qué hace cada uno. + +## Cómo trabajamos + +- Discutir diseño antes de generar código grande. +- Justificar decisiones técnicas (las tengo que defender ante tribunal). +- Avisar de trade-offs explícitamente. +- Una fase del roadmap a la vez. No saltar fases. +- README.md actualizado: cómo arrancar, perfiles, variables de entorno, endpoints. + +## Decisiones técnicas clave (para defensa oral) + +- **`fixedDelay` no `fixedRate`**: espera a que termine antes de contar el siguiente intervalo. Evita solapamiento si una sync tarda más de lo esperado. +- **`ThreadPoolTaskScheduler(poolSize=1)`**: jobs ejecutan secuencial, imposible ejecutar dos a la vez aunque lleguen a la vez. +- **No `@Transactional` en batch**: fallo en item 3 no hace rollback de items 1-2 ya guardados. Procesamiento parcial es mejor que todo-o-nada para sync. +- **`try/finally` en services**: `SyncLog` siempre se escribe aunque explote algo inesperado. +- **`DelegatingPasswordEncoder`**: soporta `{noop}` en dev y `{bcrypt}` en prod con el mismo campo de configuración. +- **Flyway sobre Liquibase**: menos verboso, SQL puro, suficiente para este proyecto. +- **H2 con `MODE=MySQL`**: mismas migraciones Flyway funcionan en dev y prod sin cambios. diff --git a/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/sync/OrderSyncService.java b/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/sync/OrderSyncService.java index bce6f3b..0b3a461 100644 --- a/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/sync/OrderSyncService.java +++ b/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/sync/OrderSyncService.java @@ -205,16 +205,16 @@ public class OrderSyncService { private void syncCustomerContacts(Integer psCustomerId, Integer dolibarrThirdpartyId) { try { List psAddresses = prestashopClient.getCustomerAddresses(psCustomerId); + if (psAddresses.isEmpty()) { + log.debug("No PS addresses found for customer {}", psCustomerId); + return; + } List dolibarrContacts = dolibarrClient.getThirdpartyContacts(dolibarrThirdpartyId); - PrestashopCustomerDto psCustomer = prestashopClient.getCustomer(psCustomerId); - String defaultAddressId = psCustomer != null ? psCustomer.idDefaultAddress() : null; - + // Create or update a Dolibarr contact for every PS address (including default) for (PrestashopAddressDto psAddr : psAddresses) { - // Default address is already on the thirdparty itself (address/zip/city fields) - if (psAddr.id() != null && psAddr.id().toString().equals(defaultAddressId)) continue; - String formattedAddr = formatAddress(psAddr.address1(), psAddr.address2()); + if (formattedAddr == null) continue; DolibarrContactDto existing = dolibarrContacts.stream() .filter(c -> c.address() != null && c.address().equals(formattedAddr) @@ -239,14 +239,14 @@ public class OrderSyncService { } } - // Remove contacts that no longer exist in PrestaShop + // Remove Dolibarr contacts that no longer exist in PrestaShop Set psKeys = psAddresses.stream() - .filter(a -> !(a.id() != null && a.id().toString().equals(defaultAddressId))) + .filter(a -> formatAddress(a.address1(), a.address2()) != null) .map(a -> formatAddress(a.address1(), a.address2()) + "|" + a.city()) .collect(Collectors.toSet()); for (DolibarrContactDto c : dolibarrContacts) { - if (!psKeys.contains(c.address() + "|" + c.town())) { + if (c.address() == null || !psKeys.contains(c.address() + "|" + c.town())) { dolibarrClient.deleteContact(c.id()); log.debug("Deleted orphaned Dolibarr contact {}", c.id()); } @@ -260,25 +260,11 @@ public class OrderSyncService { String addrId = psOrder.idAddressInvoice(); if (addrId == null || addrId.isBlank() || "0".equals(addrId)) return; - boolean isGuest = psOrder.idCustomer() == null - || psOrder.idCustomer().isBlank() - || "0".equals(psOrder.idCustomer()); - - // If billing addr = customer default addr → thirdparty main address, no contact to link - if (!isGuest) { - try { - PrestashopCustomerDto c = prestashopClient.getCustomer(Integer.parseInt(psOrder.idCustomer())); - if (c != null && addrId.equals(c.idDefaultAddress())) return; - } catch (Exception e) { - log.warn("Could not fetch PS customer for billing contact resolution: {}", e.getMessage()); - return; - } - } - try { PrestashopAddressDto billing = prestashopClient.getAddress(Integer.parseInt(addrId)); if (billing == null) return; String formatted = formatAddress(billing.address1(), billing.address2()); + if (formatted == null) return; dolibarrClient.getThirdpartyContacts(dolibarrThirdpartyId).stream() .filter(c -> c.address() != null && c.address().equals(formatted)