product descriptions and etc.

This commit is contained in:
Mireya Serrano 2026-05-26 16:43:52 +02:00
parent a719e83ce7
commit 7aa6e7f767
11 changed files with 163 additions and 9 deletions

View File

@ -1,10 +1,13 @@
package com.teterialosjuanjos.tfg.sync_service.api.controller; 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.OrderMappingResponse;
import com.teterialosjuanjos.tfg.sync_service.api.dto.ProductMappingResponse; 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.PrestashopClient;
import com.teterialosjuanjos.tfg.sync_service.integration.prestashop.exception.PrestashopApiException; import com.teterialosjuanjos.tfg.sync_service.integration.prestashop.exception.PrestashopApiException;
import com.teterialosjuanjos.tfg.sync_service.mapping.*; 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.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@ -27,6 +30,7 @@ public class MappingController {
private final ProductMappingRepository productMappingRepository; private final ProductMappingRepository productMappingRepository;
private final OrderMappingRepository orderMappingRepository; private final OrderMappingRepository orderMappingRepository;
private final CategoryMappingRepository categoryMappingRepository;
private final PrestashopClient prestashopClient; private final PrestashopClient prestashopClient;
@Operation(summary = "List product mappings, optionally filtered by sync status") @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<CategoryMappingResponse> 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) { private static OrderMappingResponse toOrderResponse(OrderMapping m) {
return new OrderMappingResponse( return new OrderMappingResponse(
m.getId(), m.getPrestashopOrderId(), m.getDolibarrOrderId(), m.getId(), m.getPrestashopOrderId(), m.getDolibarrOrderId(),

View File

@ -0,0 +1,8 @@
package com.teterialosjuanjos.tfg.sync_service.api.dto;
public record CategoryMappingResponse(
Long id,
Integer dolibarrId,
Integer prestashopId,
String label
) {}

View File

@ -115,6 +115,23 @@ public class DolibarrClient {
.body(DolibarrProductDto.class); .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<DolibarrCategoryDto> getProductCategories(Integer productId) {
try {
List<DolibarrCategoryDto> cats = restClient.get()
.uri("/products/{id}/categories", productId)
.retrieve()
.body(new ParameterizedTypeReference<List<DolibarrCategoryDto>>() {});
return cats != null ? cats : List.of();
} catch (DolibarrApiException e) {
if (e.getStatusCode().value() == 404) return List.of();
throw e;
}
}
// ── Thirdparties (customers) ────────────────────────────────────────── // ── Thirdparties (customers) ──────────────────────────────────────────
/** /**

View File

@ -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) {}

View File

@ -17,6 +17,8 @@ public record DolibarrProductDto(
String ref, String ref,
String label, String label,
String description, String description,
/** Public-facing short note; mapped to PS {@code description_short} */
@JsonProperty("note_public") String notePublic,
String price, String price,
/** Current stock level across all warehouses */ /** Current stock level across all warehouses */
@JsonProperty("stock_reel") Double stockReel, @JsonProperty("stock_reel") Double stockReel,

View File

@ -316,10 +316,15 @@ public class PrestashopClient {
sb.append("<name><language id=\"1\">").append(escapeXml(name)).append("</language></name>"); sb.append("<name><language id=\"1\">").append(escapeXml(name)).append("</language></name>");
sb.append("<description><language id=\"1\">").append(escapeXml(description)).append("</language></description>"); sb.append("<description><language id=\"1\">").append(escapeXml(description)).append("</language></description>");
sb.append("<description_short><language id=\"1\">").append(escapeXml(descShort)).append("</language></description_short>"); sb.append("<description_short><language id=\"1\">").append(escapeXml(descShort)).append("</language></description_short>");
if (dto.idCategoryDefault() != null) { List<String> cats = (dto.categoryIds() != null && !dto.categoryIds().isEmpty())
? dto.categoryIds()
: (dto.idCategoryDefault() != null ? List.of(dto.idCategoryDefault()) : List.of());
if (!cats.isEmpty()) {
sb.append("<associations>"); sb.append("<associations>");
sb.append("<categories nodeType=\"category\" api=\"categories\">"); sb.append("<categories nodeType=\"category\" api=\"categories\">");
sb.append("<category><id>").append(escapeXml(dto.idCategoryDefault())).append("</id></category>"); for (String catId : cats) {
sb.append("<category><id>").append(escapeXml(catId)).append("</id></category>");
}
sb.append("</categories>"); sb.append("</categories>");
sb.append("</associations>"); sb.append("</associations>");
} }

View File

@ -1,5 +1,6 @@
package com.teterialosjuanjos.tfg.sync_service.integration.prestashop.dto; 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.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonProperty;
@ -31,7 +32,9 @@ public record PrestashopProductDto(
@JsonDeserialize(using = MultilangStringDeserializer.class) String name, @JsonDeserialize(using = MultilangStringDeserializer.class) String name,
@JsonDeserialize(using = MultilangStringDeserializer.class) String description, @JsonDeserialize(using = MultilangStringDeserializer.class) String description,
@JsonProperty("description_short") @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<String> categoryIds
) { ) {
/** Envelope for {@code GET /api/products?display=full} */ /** Envelope for {@code GET /api/products?display=full} */

View File

@ -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;
}

View File

@ -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<CategoryMapping, Long> {
Optional<CategoryMapping> findByDolibarrId(Integer dolibarrId);
List<CategoryMapping> findByDolibarrIdIn(List<Integer> dolibarrIds);
}

View File

@ -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.PrestashopProductDto;
import com.teterialosjuanjos.tfg.sync_service.integration.prestashop.dto.PrestashopStockAvailableDto; 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.prestashop.exception.PrestashopApiException;
import com.teterialosjuanjos.tfg.sync_service.integration.dolibarr.dto.DolibarrCategoryDto;
import com.teterialosjuanjos.tfg.sync_service.mapping.*; import com.teterialosjuanjos.tfg.sync_service.mapping.*;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@ -39,6 +40,7 @@ public class ProductSyncService {
private final DolibarrClient dolibarrClient; private final DolibarrClient dolibarrClient;
private final PrestashopClient prestashopClient; private final PrestashopClient prestashopClient;
private final ProductMappingRepository productMappingRepository; private final ProductMappingRepository productMappingRepository;
private final CategoryMappingRepository categoryMappingRepository;
private final SyncLogRepository syncLogRepository; private final SyncLogRepository syncLogRepository;
private final IntegrationProperties integrationProperties; private final IntegrationProperties integrationProperties;
@ -128,17 +130,18 @@ public class ProductSyncService {
private void pushProduct(DolibarrProductDto dolProduct) { private void pushProduct(DolibarrProductDto dolProduct) {
String sku = dolProduct.ref(); String sku = dolProduct.ref();
List<String> psCategoryIds = resolveCategories(dolProduct.id());
// PS is source of truth: local mapping may be stale if PS products were deleted // PS is source of truth: local mapping may be stale if PS products were deleted
Optional<PrestashopProductDto> existingPs = prestashopClient.getProductByReference(sku); Optional<PrestashopProductDto> existingPs = prestashopClient.getProductByReference(sku);
if (existingPs.isPresent()) { if (existingPs.isPresent()) {
Integer psId = existingPs.get().id(); Integer psId = existingPs.get().id();
prestashopClient.updateProduct(psId, toPrestashopDto(dolProduct, psId)); prestashopClient.updateProduct(psId, toPrestashopDto(dolProduct, psId, psCategoryIds));
upsertMapping(sku, dolProduct.id(), psId); upsertMapping(sku, dolProduct.id(), psId);
log.debug("ProductSync: updated PS product reference={} id={}", sku, psId); log.debug("ProductSync: updated PS product reference={} id={}", sku, psId);
} else { } else {
PrestashopProductDto created = prestashopClient.createProduct(toPrestashopDto(dolProduct, null)); PrestashopProductDto created = prestashopClient.createProduct(toPrestashopDto(dolProduct, null, psCategoryIds));
if (created == null) { if (created == null) {
throw new PrestashopApiException( throw new PrestashopApiException(
"Product created in PS but not found via GET (reference=" + sku + ")"); "Product created in PS but not found via GET (reference=" + sku + ")");
@ -149,6 +152,20 @@ public class ProductSyncService {
} }
} }
private List<String> resolveCategories(Integer dolibarrProductId) {
try {
List<DolibarrCategoryDto> dolibarrCats = dolibarrClient.getProductCategories(dolibarrProductId);
if (dolibarrCats.isEmpty()) return List.of();
List<Integer> 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. * 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). * 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 * @param prestashopId null for create, actual PS ID for update
*/ */
private PrestashopProductDto toPrestashopDto(DolibarrProductDto src, Integer prestashopId) { private PrestashopProductDto toPrestashopDto(DolibarrProductDto src, Integer prestashopId, List<String> psCategoryIds) {
String categoryId = String.valueOf(integrationProperties.prestashop().defaultCategoryId()); String defaultCategoryId = String.valueOf(integrationProperties.prestashop().defaultCategoryId());
String effectiveCategoryId = (psCategoryIds != null && !psCategoryIds.isEmpty())
? psCategoryIds.get(0) : defaultCategoryId;
List<String> allCategoryIds = (psCategoryIds != null && !psCategoryIds.isEmpty())
? psCategoryIds : List.of(defaultCategoryId);
String label = src.label() != null ? src.label() : ""; String label = src.label() != null ? src.label() : "";
String description = src.description() != null ? src.description() : ""; String description = src.description() != null ? src.description() : "";
String descriptionShort = src.notePublic() != null ? src.notePublic() : "";
String active = (src.toSell() != null && src.toSell() == 0) ? "0" : "1"; String active = (src.toSell() != null && src.toSell() == 0) ? "0" : "1";
return new PrestashopProductDto( return new PrestashopProductDto(
@ -211,10 +234,11 @@ public class ProductSyncService {
src.ref(), src.ref(),
src.price(), src.price(),
active, active,
categoryId, effectiveCategoryId,
label, label,
description, description,
"" descriptionShort,
allCategoryIds
); );
} }
} }

View File

@ -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)
);