This commit is contained in:
parent
411ad0a7d3
commit
abe534f4da
|
|
@ -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: <clave>`. 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=<ver MySQL service → MYSQLPASSWORD>
|
||||||
|
SPRING_JPA_DATABASE_PLATFORM=org.hibernate.dialect.MySQLDialect
|
||||||
|
SPRING_JPA_HIBERNATE_DDL_AUTO=validate
|
||||||
|
SYNC_SCHEDULING_ENABLED=true
|
||||||
|
DOLIBARR_API_KEY=<ver application-dev.yml local>
|
||||||
|
PRESTASHOP_API_KEY=<ver application-dev.yml local>
|
||||||
|
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.
|
||||||
|
|
@ -205,16 +205,16 @@ public class OrderSyncService {
|
||||||
private void syncCustomerContacts(Integer psCustomerId, Integer dolibarrThirdpartyId) {
|
private void syncCustomerContacts(Integer psCustomerId, Integer dolibarrThirdpartyId) {
|
||||||
try {
|
try {
|
||||||
List<PrestashopAddressDto> psAddresses = prestashopClient.getCustomerAddresses(psCustomerId);
|
List<PrestashopAddressDto> psAddresses = prestashopClient.getCustomerAddresses(psCustomerId);
|
||||||
|
if (psAddresses.isEmpty()) {
|
||||||
|
log.debug("No PS addresses found for customer {}", psCustomerId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
List<DolibarrContactDto> dolibarrContacts = dolibarrClient.getThirdpartyContacts(dolibarrThirdpartyId);
|
List<DolibarrContactDto> dolibarrContacts = dolibarrClient.getThirdpartyContacts(dolibarrThirdpartyId);
|
||||||
|
|
||||||
PrestashopCustomerDto psCustomer = prestashopClient.getCustomer(psCustomerId);
|
// Create or update a Dolibarr contact for every PS address (including default)
|
||||||
String defaultAddressId = psCustomer != null ? psCustomer.idDefaultAddress() : null;
|
|
||||||
|
|
||||||
for (PrestashopAddressDto psAddr : psAddresses) {
|
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());
|
String formattedAddr = formatAddress(psAddr.address1(), psAddr.address2());
|
||||||
|
if (formattedAddr == null) continue;
|
||||||
|
|
||||||
DolibarrContactDto existing = dolibarrContacts.stream()
|
DolibarrContactDto existing = dolibarrContacts.stream()
|
||||||
.filter(c -> c.address() != null && c.address().equals(formattedAddr)
|
.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<String> psKeys = psAddresses.stream()
|
Set<String> 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())
|
.map(a -> formatAddress(a.address1(), a.address2()) + "|" + a.city())
|
||||||
.collect(Collectors.toSet());
|
.collect(Collectors.toSet());
|
||||||
|
|
||||||
for (DolibarrContactDto c : dolibarrContacts) {
|
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());
|
dolibarrClient.deleteContact(c.id());
|
||||||
log.debug("Deleted orphaned Dolibarr contact {}", c.id());
|
log.debug("Deleted orphaned Dolibarr contact {}", c.id());
|
||||||
}
|
}
|
||||||
|
|
@ -260,25 +260,11 @@ public class OrderSyncService {
|
||||||
String addrId = psOrder.idAddressInvoice();
|
String addrId = psOrder.idAddressInvoice();
|
||||||
if (addrId == null || addrId.isBlank() || "0".equals(addrId)) return;
|
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 {
|
try {
|
||||||
PrestashopAddressDto billing = prestashopClient.getAddress(Integer.parseInt(addrId));
|
PrestashopAddressDto billing = prestashopClient.getAddress(Integer.parseInt(addrId));
|
||||||
if (billing == null) return;
|
if (billing == null) return;
|
||||||
String formatted = formatAddress(billing.address1(), billing.address2());
|
String formatted = formatAddress(billing.address1(), billing.address2());
|
||||||
|
if (formatted == null) return;
|
||||||
|
|
||||||
dolibarrClient.getThirdpartyContacts(dolibarrThirdpartyId).stream()
|
dolibarrClient.getThirdpartyContacts(dolibarrThirdpartyId).stream()
|
||||||
.filter(c -> c.address() != null && c.address().equals(formatted)
|
.filter(c -> c.address() != null && c.address().equals(formatted)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue