diff --git a/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/api/controller/MappingController.java b/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/api/controller/MappingController.java index 56ba84b..7816716 100644 --- a/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/api/controller/MappingController.java +++ b/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/api/controller/MappingController.java @@ -1,10 +1,13 @@ package com.teterialosjuanjos.tfg.sync_service.api.controller; +import com.teterialosjuanjos.tfg.sync_service.api.dto.CategoryMappingResponse; import com.teterialosjuanjos.tfg.sync_service.api.dto.OrderMappingResponse; import com.teterialosjuanjos.tfg.sync_service.api.dto.ProductMappingResponse; import com.teterialosjuanjos.tfg.sync_service.integration.prestashop.PrestashopClient; import com.teterialosjuanjos.tfg.sync_service.integration.prestashop.exception.PrestashopApiException; import com.teterialosjuanjos.tfg.sync_service.mapping.*; +import jakarta.validation.Valid; +import org.springframework.web.bind.annotation.RequestBody; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; @@ -27,6 +30,7 @@ public class MappingController { private final ProductMappingRepository productMappingRepository; private final OrderMappingRepository orderMappingRepository; + private final CategoryMappingRepository categoryMappingRepository; private final PrestashopClient prestashopClient; @Operation(summary = "List product mappings, optionally filtered by sync status") @@ -72,6 +76,41 @@ public class MappingController { ); } + // ── Category mappings ───────────────────────────────────────────────── + + @Operation(summary = "List all Dolibarr ↔ PrestaShop category mappings") + @GetMapping("/categories") + public List getCategoryMappings() { + return categoryMappingRepository.findAll().stream() + .map(MappingController::toCategoryResponse).toList(); + } + + @Operation(summary = "Create a category mapping (dolibarrId ↔ prestashopId)") + @PostMapping("/categories") + @ResponseStatus(HttpStatus.CREATED) + public CategoryMappingResponse createCategoryMapping(@RequestBody CategoryMappingResponse request) { + CategoryMapping mapping = CategoryMapping.builder() + .dolibarrId(request.dolibarrId()) + .prestashopId(request.prestashopId()) + .label(request.label()) + .build(); + return toCategoryResponse(categoryMappingRepository.save(mapping)); + } + + @Operation(summary = "Delete a category mapping by id") + @DeleteMapping("/categories/{id}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void deleteCategoryMapping(@PathVariable Long id) { + if (!categoryMappingRepository.existsById(id)) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Category mapping not found: " + id); + } + categoryMappingRepository.deleteById(id); + } + + private static CategoryMappingResponse toCategoryResponse(CategoryMapping m) { + return new CategoryMappingResponse(m.getId(), m.getDolibarrId(), m.getPrestashopId(), m.getLabel()); + } + private static OrderMappingResponse toOrderResponse(OrderMapping m) { return new OrderMappingResponse( m.getId(), m.getPrestashopOrderId(), m.getDolibarrOrderId(), diff --git a/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/api/dto/CategoryMappingResponse.java b/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/api/dto/CategoryMappingResponse.java new file mode 100644 index 0000000..eee0236 --- /dev/null +++ b/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/api/dto/CategoryMappingResponse.java @@ -0,0 +1,8 @@ +package com.teterialosjuanjos.tfg.sync_service.api.dto; + +public record CategoryMappingResponse( + Long id, + Integer dolibarrId, + Integer prestashopId, + String label +) {} diff --git a/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/integration/dolibarr/DolibarrClient.java b/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/integration/dolibarr/DolibarrClient.java index 2f286cb..8fcbeea 100644 --- a/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/integration/dolibarr/DolibarrClient.java +++ b/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/integration/dolibarr/DolibarrClient.java @@ -115,6 +115,23 @@ public class DolibarrClient { .body(DolibarrProductDto.class); } + /** + * Returns the categories linked to a product. + * Returns an empty list if the product has no categories or the endpoint returns 404. + */ + public List getProductCategories(Integer productId) { + try { + List cats = restClient.get() + .uri("/products/{id}/categories", productId) + .retrieve() + .body(new ParameterizedTypeReference>() {}); + return cats != null ? cats : List.of(); + } catch (DolibarrApiException e) { + if (e.getStatusCode().value() == 404) return List.of(); + throw e; + } + } + // ── Thirdparties (customers) ────────────────────────────────────────── /** diff --git a/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/integration/dolibarr/dto/DolibarrCategoryDto.java b/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/integration/dolibarr/dto/DolibarrCategoryDto.java new file mode 100644 index 0000000..d4feaf9 --- /dev/null +++ b/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/integration/dolibarr/dto/DolibarrCategoryDto.java @@ -0,0 +1,6 @@ +package com.teterialosjuanjos.tfg.sync_service.integration.dolibarr.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record DolibarrCategoryDto(Integer id, String label) {} diff --git a/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/integration/dolibarr/dto/DolibarrProductDto.java b/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/integration/dolibarr/dto/DolibarrProductDto.java index 5b64b73..29a9cf5 100644 --- a/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/integration/dolibarr/dto/DolibarrProductDto.java +++ b/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/integration/dolibarr/dto/DolibarrProductDto.java @@ -17,6 +17,8 @@ public record DolibarrProductDto( String ref, String label, String description, + /** Public-facing short note; mapped to PS {@code description_short} */ + @JsonProperty("note_public") String notePublic, String price, /** Current stock level across all warehouses */ @JsonProperty("stock_reel") Double stockReel, diff --git a/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/integration/prestashop/PrestashopClient.java b/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/integration/prestashop/PrestashopClient.java index b77e709..ec97ea5 100644 --- a/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/integration/prestashop/PrestashopClient.java +++ b/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/integration/prestashop/PrestashopClient.java @@ -316,10 +316,15 @@ public class PrestashopClient { sb.append("").append(escapeXml(name)).append(""); sb.append("").append(escapeXml(description)).append(""); sb.append("").append(escapeXml(descShort)).append(""); - if (dto.idCategoryDefault() != null) { + List cats = (dto.categoryIds() != null && !dto.categoryIds().isEmpty()) + ? dto.categoryIds() + : (dto.idCategoryDefault() != null ? List.of(dto.idCategoryDefault()) : List.of()); + if (!cats.isEmpty()) { sb.append(""); sb.append(""); - sb.append("").append(escapeXml(dto.idCategoryDefault())).append(""); + for (String catId : cats) { + sb.append("").append(escapeXml(catId)).append(""); + } sb.append(""); sb.append(""); } diff --git a/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/integration/prestashop/dto/PrestashopProductDto.java b/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/integration/prestashop/dto/PrestashopProductDto.java index 542856b..923c694 100644 --- a/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/integration/prestashop/dto/PrestashopProductDto.java +++ b/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/integration/prestashop/dto/PrestashopProductDto.java @@ -1,5 +1,6 @@ package com.teterialosjuanjos.tfg.sync_service.integration.prestashop.dto; +import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; @@ -31,7 +32,9 @@ public record PrestashopProductDto( @JsonDeserialize(using = MultilangStringDeserializer.class) String name, @JsonDeserialize(using = MultilangStringDeserializer.class) String description, @JsonProperty("description_short") - @JsonDeserialize(using = MultilangStringDeserializer.class) String descriptionShort + @JsonDeserialize(using = MultilangStringDeserializer.class) String descriptionShort, + /** PS category IDs to set on write; not present in PS JSON responses (ignored on read). */ + @JsonIgnore List categoryIds ) { /** Envelope for {@code GET /api/products?display=full} */ diff --git a/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/mapping/CategoryMapping.java b/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/mapping/CategoryMapping.java new file mode 100644 index 0000000..c91bad4 --- /dev/null +++ b/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/mapping/CategoryMapping.java @@ -0,0 +1,31 @@ +package com.teterialosjuanjos.tfg.sync_service.mapping; + +import jakarta.persistence.*; +import lombok.*; + +/** + * Maps a Dolibarr product category ID to its PrestaShop counterpart. + * Must be populated manually before category sync will work. + */ +@Entity +@Table(name = "category_mapping") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class CategoryMapping { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "dolibarr_id", nullable = false, unique = true) + private Integer dolibarrId; + + @Column(name = "prestashop_id", nullable = false) + private Integer prestashopId; + + @Column(length = 255) + private String label; +} diff --git a/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/mapping/CategoryMappingRepository.java b/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/mapping/CategoryMappingRepository.java new file mode 100644 index 0000000..3be15e9 --- /dev/null +++ b/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/mapping/CategoryMappingRepository.java @@ -0,0 +1,13 @@ +package com.teterialosjuanjos.tfg.sync_service.mapping; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface CategoryMappingRepository extends JpaRepository { + + Optional findByDolibarrId(Integer dolibarrId); + + List findByDolibarrIdIn(List dolibarrIds); +} diff --git a/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/sync/ProductSyncService.java b/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/sync/ProductSyncService.java index d927078..e6ef6c9 100644 --- a/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/sync/ProductSyncService.java +++ b/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/sync/ProductSyncService.java @@ -8,6 +8,7 @@ import com.teterialosjuanjos.tfg.sync_service.integration.prestashop.PrestashopC import com.teterialosjuanjos.tfg.sync_service.integration.prestashop.dto.PrestashopProductDto; import com.teterialosjuanjos.tfg.sync_service.integration.prestashop.dto.PrestashopStockAvailableDto; import com.teterialosjuanjos.tfg.sync_service.integration.prestashop.exception.PrestashopApiException; +import com.teterialosjuanjos.tfg.sync_service.integration.dolibarr.dto.DolibarrCategoryDto; import com.teterialosjuanjos.tfg.sync_service.mapping.*; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -39,6 +40,7 @@ public class ProductSyncService { private final DolibarrClient dolibarrClient; private final PrestashopClient prestashopClient; private final ProductMappingRepository productMappingRepository; + private final CategoryMappingRepository categoryMappingRepository; private final SyncLogRepository syncLogRepository; private final IntegrationProperties integrationProperties; @@ -128,17 +130,18 @@ public class ProductSyncService { private void pushProduct(DolibarrProductDto dolProduct) { String sku = dolProduct.ref(); + List psCategoryIds = resolveCategories(dolProduct.id()); // PS is source of truth: local mapping may be stale if PS products were deleted Optional existingPs = prestashopClient.getProductByReference(sku); if (existingPs.isPresent()) { Integer psId = existingPs.get().id(); - prestashopClient.updateProduct(psId, toPrestashopDto(dolProduct, psId)); + prestashopClient.updateProduct(psId, toPrestashopDto(dolProduct, psId, psCategoryIds)); upsertMapping(sku, dolProduct.id(), psId); log.debug("ProductSync: updated PS product reference={} id={}", sku, psId); } else { - PrestashopProductDto created = prestashopClient.createProduct(toPrestashopDto(dolProduct, null)); + PrestashopProductDto created = prestashopClient.createProduct(toPrestashopDto(dolProduct, null, psCategoryIds)); if (created == null) { throw new PrestashopApiException( "Product created in PS but not found via GET (reference=" + sku + ")"); @@ -149,6 +152,20 @@ public class ProductSyncService { } } + private List resolveCategories(Integer dolibarrProductId) { + try { + List dolibarrCats = dolibarrClient.getProductCategories(dolibarrProductId); + if (dolibarrCats.isEmpty()) return List.of(); + List dolibarrIds = dolibarrCats.stream().map(DolibarrCategoryDto::id).toList(); + return categoryMappingRepository.findByDolibarrIdIn(dolibarrIds).stream() + .map(m -> String.valueOf(m.getPrestashopId())) + .toList(); + } catch (Exception e) { + log.warn("ProductSync: could not resolve categories for Dolibarr product id={}: {}", dolibarrProductId, e.getMessage()); + return List.of(); + } + } + /** * Pushes Dolibarr stock to the PS stock_available record immediately after product creation. * Skipped when stockReel is null or zero (PS default is already 0). @@ -200,10 +217,16 @@ public class ProductSyncService { * * @param prestashopId null for create, actual PS ID for update */ - private PrestashopProductDto toPrestashopDto(DolibarrProductDto src, Integer prestashopId) { - String categoryId = String.valueOf(integrationProperties.prestashop().defaultCategoryId()); + private PrestashopProductDto toPrestashopDto(DolibarrProductDto src, Integer prestashopId, List psCategoryIds) { + String defaultCategoryId = String.valueOf(integrationProperties.prestashop().defaultCategoryId()); + String effectiveCategoryId = (psCategoryIds != null && !psCategoryIds.isEmpty()) + ? psCategoryIds.get(0) : defaultCategoryId; + List allCategoryIds = (psCategoryIds != null && !psCategoryIds.isEmpty()) + ? psCategoryIds : List.of(defaultCategoryId); + String label = src.label() != null ? src.label() : ""; String description = src.description() != null ? src.description() : ""; + String descriptionShort = src.notePublic() != null ? src.notePublic() : ""; String active = (src.toSell() != null && src.toSell() == 0) ? "0" : "1"; return new PrestashopProductDto( @@ -211,10 +234,11 @@ public class ProductSyncService { src.ref(), src.price(), active, - categoryId, + effectiveCategoryId, label, description, - "" + descriptionShort, + allCategoryIds ); } } diff --git a/sync-service/src/main/resources/db/migration/V2__category_mapping.sql b/sync-service/src/main/resources/db/migration/V2__category_mapping.sql new file mode 100644 index 0000000..9d4b75d --- /dev/null +++ b/sync-service/src/main/resources/db/migration/V2__category_mapping.sql @@ -0,0 +1,6 @@ +CREATE TABLE category_mapping ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + dolibarr_id INT NOT NULL UNIQUE, + prestashop_id INT NOT NULL, + label VARCHAR(255) +);