From e30fb830436aa876f3902ef90911d2d826c20443 Mon Sep 17 00:00:00 2001 From: Vincent Guillet Date: Sat, 29 Nov 2025 10:36:09 +0100 Subject: [PATCH] Add PrestashopAdminController and PrestashopAdminService for managing admin resources --- .../controller/PrestashopAdminController.java | 78 ++ .../api/dto/ps/ProductFlagsDto.java | 12 + .../api/dto/ps/ProductListItemDto.java | 16 + .../fr/gameovergne/api/dto/ps/PsItemDto.java | 12 + .../gameovergne/api/dto/ps/PsProductDto.java | 26 + .../api/model/ps/SimpleResource.java | 19 + .../api/service/PrestashopAdminService.java | 1048 +++++++++++++++++ .../api/service/PrestashopClient.java | 126 +- 8 files changed, 1283 insertions(+), 54 deletions(-) create mode 100644 api/src/main/java/fr/gameovergne/api/controller/PrestashopAdminController.java create mode 100644 api/src/main/java/fr/gameovergne/api/dto/ps/ProductFlagsDto.java create mode 100644 api/src/main/java/fr/gameovergne/api/dto/ps/ProductListItemDto.java create mode 100644 api/src/main/java/fr/gameovergne/api/dto/ps/PsItemDto.java create mode 100644 api/src/main/java/fr/gameovergne/api/dto/ps/PsProductDto.java create mode 100644 api/src/main/java/fr/gameovergne/api/model/ps/SimpleResource.java create mode 100644 api/src/main/java/fr/gameovergne/api/service/PrestashopAdminService.java diff --git a/api/src/main/java/fr/gameovergne/api/controller/PrestashopAdminController.java b/api/src/main/java/fr/gameovergne/api/controller/PrestashopAdminController.java new file mode 100644 index 0000000..d64a97f --- /dev/null +++ b/api/src/main/java/fr/gameovergne/api/controller/PrestashopAdminController.java @@ -0,0 +1,78 @@ +package fr.gameovergne.api.controller; + +import fr.gameovergne.api.dto.ps.*; +import fr.gameovergne.api.model.ps.SimpleResource; +import fr.gameovergne.api.service.PrestashopAdminService; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/ps-admin") +@RequiredArgsConstructor +public class PrestashopAdminController { + + private final PrestashopAdminService service; + + // --- Simple resources --- + + @GetMapping("/{resource}") + public List listSimple(@PathVariable SimpleResource resource) { + return service.listSimple(resource); + } + + @PostMapping("/{resource}") + public long createSimple(@PathVariable SimpleResource resource, + @RequestParam String name) { + return service.createSimple(resource, name); + } + + @PutMapping("/{resource}/{id}") + public void updateSimple(@PathVariable SimpleResource resource, + @PathVariable long id, + @RequestParam String name) { + service.updateSimple(resource, id, name); + } + + @DeleteMapping("/{resource}/{id}") + public void deleteSimple(@PathVariable SimpleResource resource, + @PathVariable long id) { + service.deleteSimple(resource, id); + } + + // --- Produits liste + flags + condition values --- + + @GetMapping("/products") + public List listProducts(@RequestParam(required = false) String q) { + return service.listProducts(q); + } + + @GetMapping("/products/{id}/flags") + public ProductFlagsDto getProductFlags(@PathVariable long id) { + return service.getProductFlags(id); + } + + @GetMapping("/meta/condition-values") + public List getConditionValues() { + return service.getConditionValues(); + } + + // --- Create / update / delete produit --- + + @PostMapping("/products") + public long createProduct(@RequestBody PsProductDto dto) { + return service.createProduct(dto); + } + + @PutMapping("/products/{id}") + public void updateProduct(@PathVariable long id, + @RequestBody PsProductDto dto) { + service.updateProduct(id, dto); + } + + @DeleteMapping("/products/{id}") + public void deleteProduct(@PathVariable long id) { + service.deleteProduct(id); + } +} \ No newline at end of file diff --git a/api/src/main/java/fr/gameovergne/api/dto/ps/ProductFlagsDto.java b/api/src/main/java/fr/gameovergne/api/dto/ps/ProductFlagsDto.java new file mode 100644 index 0000000..e6b93f1 --- /dev/null +++ b/api/src/main/java/fr/gameovergne/api/dto/ps/ProductFlagsDto.java @@ -0,0 +1,12 @@ +package fr.gameovergne.api.dto.ps; + +import lombok.Builder; +import lombok.Value; + +@Value +@Builder +public class ProductFlagsDto { + boolean complete; + boolean hasManual; + String conditionLabel; +} \ No newline at end of file diff --git a/api/src/main/java/fr/gameovergne/api/dto/ps/ProductListItemDto.java b/api/src/main/java/fr/gameovergne/api/dto/ps/ProductListItemDto.java new file mode 100644 index 0000000..715f708 --- /dev/null +++ b/api/src/main/java/fr/gameovergne/api/dto/ps/ProductListItemDto.java @@ -0,0 +1,16 @@ +package fr.gameovergne.api.dto.ps; + +import lombok.Builder; +import lombok.Value; + +@Value +@Builder +public class ProductListItemDto { + Long id; + String name; + Long manufacturerId; + Long supplierId; + Long categoryId; + Double priceHt; + Integer quantity; +} \ No newline at end of file diff --git a/api/src/main/java/fr/gameovergne/api/dto/ps/PsItemDto.java b/api/src/main/java/fr/gameovergne/api/dto/ps/PsItemDto.java new file mode 100644 index 0000000..4bf3769 --- /dev/null +++ b/api/src/main/java/fr/gameovergne/api/dto/ps/PsItemDto.java @@ -0,0 +1,12 @@ +package fr.gameovergne.api.dto.ps; + +import lombok.Builder; +import lombok.Value; + +@Value +@Builder +public class PsItemDto { + Long id; + String name; + Boolean active; +} \ No newline at end of file diff --git a/api/src/main/java/fr/gameovergne/api/dto/ps/PsProductDto.java b/api/src/main/java/fr/gameovergne/api/dto/ps/PsProductDto.java new file mode 100644 index 0000000..608cf38 --- /dev/null +++ b/api/src/main/java/fr/gameovergne/api/dto/ps/PsProductDto.java @@ -0,0 +1,26 @@ +package fr.gameovergne.api.dto.ps; + +import lombok.Builder; +import lombok.Value; + +@Value +@Builder +public class PsProductDto { + Long id; // optionnel pour update + String name; + + Long manufacturerId; + Long supplierId; + Long categoryId; + + Double priceTtc; + Double vatRate; + + Integer quantity; + + Boolean complete; // Complet: Oui/Non + Boolean hasManual; // Notice: Avec/Sans + String conditionLabel; // État: libellé + + String description; // description libre +} \ No newline at end of file diff --git a/api/src/main/java/fr/gameovergne/api/model/ps/SimpleResource.java b/api/src/main/java/fr/gameovergne/api/model/ps/SimpleResource.java new file mode 100644 index 0000000..6d1dc3f --- /dev/null +++ b/api/src/main/java/fr/gameovergne/api/model/ps/SimpleResource.java @@ -0,0 +1,19 @@ +package fr.gameovergne.api.model.ps; + +public enum SimpleResource { + categories("categories", "category", true, true), + manufacturers("manufacturers", "manufacturer", false, false), + suppliers("suppliers", "supplier", false, false); + + public final String path; + public final String root; + public final boolean needsDefaultLang; + public final boolean nameIsMultilang; + + SimpleResource(String path, String root, boolean needsDefaultLang, boolean nameIsMultilang) { + this.path = path; + this.root = root; + this.needsDefaultLang = needsDefaultLang; + this.nameIsMultilang = nameIsMultilang; + } +} \ No newline at end of file diff --git a/api/src/main/java/fr/gameovergne/api/service/PrestashopAdminService.java b/api/src/main/java/fr/gameovergne/api/service/PrestashopAdminService.java new file mode 100644 index 0000000..a6507e5 --- /dev/null +++ b/api/src/main/java/fr/gameovergne/api/service/PrestashopAdminService.java @@ -0,0 +1,1048 @@ +package fr.gameovergne.api.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import fr.gameovergne.api.dto.ps.*; +import fr.gameovergne.api.model.ps.SimpleResource; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; + +import java.text.Normalizer; +import java.util.*; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +@Service +@RequiredArgsConstructor +@Slf4j +public class PrestashopAdminService { + + private final PrestashopClient presta; + private final ObjectMapper objectMapper; + + private List conditionValuesCache; + + // ---------- Helpers string / XML ---------- + + private String normalizeLabel(String s) { + if (s == null) return ""; + String nfd = Normalizer.normalize(s, Normalizer.Form.NFD); + return nfd.replaceAll("\\p{M}", "") + .toLowerCase(Locale.FRENCH) + .trim(); + } + + private String escapeXml(String v) { + if (v == null) return ""; + return v + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) + .replace("'", "'"); + } + + private String slug(String str) { + if (str == null) return ""; + String nfd = Normalizer.normalize(str, Normalizer.Form.NFD); + String noAccents = nfd.replaceAll("\\p{M}", ""); + String lower = noAccents.toLowerCase(Locale.FRENCH); + String dashed = lower.replaceAll("[^a-z0-9]+", "-"); + return dashed.replaceAll("(^-|-$)", ""); + } + + private int extractIdFromXml(String xml) { + if (xml == null) return 0; + // mêmes patterns que ton TS + Pattern p1 = Pattern.compile( + "]*>\\s*(?:)?\\s*[\\s\\S]*?(?:|)", + Pattern.CASE_INSENSITIVE + ); + Matcher m1 = p1.matcher(xml); + if (m1.find()) return Integer.parseInt(m1.group(1)); + + Pattern p2 = Pattern.compile( + "]*>[\\s\\S]*?]*>\\s*(?:)?\\s*", + Pattern.CASE_INSENSITIVE + ); + Matcher m2 = p2.matcher(xml); + if (m2.find()) return Integer.parseInt(m2.group(2)); + + Pattern p3 = Pattern.compile( + "]*>\\s*(?:)?\\s*", + Pattern.CASE_INSENSITIVE + ); + Matcher m3 = p3.matcher(xml); + if (m3.find()) return Integer.parseInt(m3.group(1)); + + log.warn("[Presta] Impossible d’extraire depuis la réponse XML"); + return 0; + } + + private String toLangBlock(String tag, List entries) { + StringBuilder sb = new StringBuilder(); + sb.append("<").append(tag).append(">"); + for (LangEntry e : entries) { + sb.append("") + .append(escapeXml(e.value())) + .append(""); + } + sb.append(""); + return sb.toString(); + } + + private double ttcToHt(double priceTtc, double rate) { + double raw = priceTtc / (1.0 + rate); + return Math.round(raw * 1_000_000d) / 1_000_000d; + } + + private record LangEntry(int id, String value) {} + + // ---------- Contexte Presta & configs ---------- + + public int getDefaultLangId() { + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("display", "[value]"); + params.add("filter[name]", "PS_LANG_DEFAULT"); + params.add("output_format", "JSON"); + + try { + String json = presta.getJson("/configurations", params); + JsonNode root = objectMapper.readTree(json); + JsonNode arr = root.path("configurations"); + if (arr.isArray() && arr.size() > 0) { + return arr.get(0).path("value").asInt(1); + } + } catch (Exception e) { + log.warn("getDefaultLangId error", e); + } + return 1; + } + + private int getDefaultTaxRulesGroupId() { + // même logique que ton TS: d’abord la config, sinon premier tax_rule_group actif + try { + MultiValueMap cfgParams = new LinkedMultiValueMap<>(); + cfgParams.add("display", "[value]"); + cfgParams.add("filter[name]", "PS_TAX_DEFAULT_RULES_GROUP"); + cfgParams.add("output_format", "JSON"); + + String jsonCfg = presta.getJson("/configurations", cfgParams); + JsonNode cfgRoot = objectMapper.readTree(jsonCfg); + JsonNode arr = cfgRoot.path("configurations"); + if (arr.isArray() && arr.size() > 0) { + int id = arr.get(0).path("value").asInt(0); + if (id > 0) return id; + } + + MultiValueMap trgParams = new LinkedMultiValueMap<>(); + trgParams.add("display", "[id,name,active]"); + trgParams.add("filter[active]", "1"); + trgParams.add("output_format", "JSON"); + + String jsonTrg = presta.getJson("/tax_rule_groups", trgParams); + JsonNode trgRoot = objectMapper.readTree(jsonTrg); + JsonNode groups = trgRoot.path("tax_rule_groups"); + if (groups.isMissingNode() || groups.isNull()) { + groups = trgRoot.path("tax_rule_group"); + } + if (groups.isArray() && groups.size() > 0) { + return groups.get(0).path("id").asInt(0); + } + } catch (Exception e) { + log.warn("getDefaultTaxRulesGroupId error", e); + } + return 0; + } + + private record ShopContext(int idShop, int idShopGroup) {} + + private ShopContext getDefaultShopContext() { + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("display", "[id,id_shop_group,active]"); + params.add("filter[active]", "1"); + params.add("output_format", "JSON"); + + try { + String json = presta.getJson("/shops", params); + JsonNode root = objectMapper.readTree(json); + JsonNode shops = root.path("shops"); + if (shops.isArray() && shops.size() > 0) { + JsonNode s = shops.get(0); + return new ShopContext( + s.path("id").asInt(1), + s.path("id_shop_group").asInt(1) + ); + } + } catch (Exception e) { + log.warn("getDefaultShopContext error", e); + } + return new ShopContext(1, 1); + } + + private int getConfigInt(String name, int defaultValue) { + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("display", "[value]"); + params.add("filter[name]", name); + params.add("output_format", "JSON"); + try { + String json = presta.getJson("/configurations", params); + JsonNode root = objectMapper.readTree(json); + JsonNode arr = root.path("configurations"); + if (arr.isArray() && arr.size() > 0) { + return arr.get(0).path("value").asInt(defaultValue); + } + } catch (Exception e) { + log.warn("getConfigInt {} error", name, e); + } + return defaultValue; + } + + private int getHomeCategoryId() { + return getConfigInt("PS_HOME_CATEGORY", 2); + } + + private int getRootCategoryId() { + return getConfigInt("PS_ROOT_CATEGORY", 1); + } + + // ---------- CRUD simple: categories / manufacturers / suppliers ---------- + + public List listSimple(SimpleResource resource) { + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("display", "[id,name,active]"); + params.add("output_format", "JSON"); + + try { + String json = presta.getJson("/" + resource.path, params); + JsonNode root = objectMapper.readTree(json); + JsonNode arr = root.path(resource.path); + if (!arr.isArray()) { + return Collections.emptyList(); + } + + List result = new ArrayList<>(); + for (JsonNode n : arr) { + long id = n.path("id").asLong(); + JsonNode nameNode = n.path("name"); + String name; + if (nameNode.isArray() && nameNode.size() > 0) { + name = nameNode.get(0).path("value").asText(""); + } else { + name = nameNode.asText(""); + } + Boolean active = n.has("active") ? (n.path("active").asInt(1) == 1) : null; + result.add(PsItemDto.builder() + .id(id) + .name(name) + .active(active) + .build()); + } + result.sort(Comparator.comparing(PsItemDto::getName, String.CASE_INSENSITIVE_ORDER)); + return result; + } catch (Exception e) { + log.error("listSimple {} error", resource, e); + return Collections.emptyList(); + } + } + + public long createSimple(SimpleResource resource, String name) { + String safeName = escapeXml(name); + + if (resource == SimpleResource.categories) { + // comme ton TS: multilang + link_rewrite + List langIds = getActiveLangIds(); + List names = new ArrayList<>(); + List rewrites = new ArrayList<>(); + for (int id : langIds) { + names.add(new LangEntry(id, safeName)); + rewrites.add(new LangEntry(id, slug(name))); + } + String xml = + "\n" + + " \n" + + " 2\n" + + " 1\n" + + " " + toLangBlock("name", names) + "\n" + + " " + toLangBlock("link_rewrite", rewrites) + "\n" + + " \n" + + ""; + + String resp = presta.postXml("/categories", null, xml); + return extractIdFromXml(resp); + } + + // manufacturers / suppliers + String rootTag = resource == SimpleResource.manufacturers ? "manufacturer" : "supplier"; + String xml = + "" + + "<" + rootTag + ">1" + safeName + "" + + ""; + + String resp = presta.postXml("/" + resource.path, null, xml); + return extractIdFromXml(resp); + } + + public void updateSimple(SimpleResource resource, long id, String newName) { + int idLang = resource.needsDefaultLang ? getDefaultLangId() : 1; + + // on fait simple: on reconstruit depuis les données actuelles si besoin + String path = "/" + resource.path + "/" + id; + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("output_format", "JSON"); + params.add("display", "full"); + + try { + String json = presta.getJson(path, params); + JsonNode root = objectMapper.readTree(json); + JsonNode obj = root.path(resource.root); + if (obj.isMissingNode()) { + // parfois Presta renvoie {category: {...}} ou {categories:[...]} + JsonNode arr = root.path(resource.path); + if (arr.isArray() && arr.size() > 0) { + obj = arr.get(0); + } + } + + int active = obj.path("active").asInt(1); + String nameXml; + String safeName = escapeXml(newName); + + if (resource.nameIsMultilang) { + nameXml = "" + safeName + ""; + } else { + nameXml = "" + safeName + ""; + } + + String idParentXml = ""; + String linkRewriteXml = ""; + + if (resource == SimpleResource.categories) { + int idParent = obj.path("id_parent").asInt(2); + idParentXml = "" + idParent + ""; + + // simplifié: on recalcule slug + linkRewriteXml = "" + + escapeXml(slug(newName)) + ""; + } + + String xml = "\n" + + " <" + resource.root + ">\n" + + " " + id + "\n" + + " " + active + "\n" + + " " + idParentXml + "\n" + + " " + nameXml + "\n" + + " " + linkRewriteXml + "\n" + + " \n" + + ""; + + presta.putXml(path, null, xml); + } catch (Exception e) { + log.error("updateSimple {} id={} error", resource, id, e); + throw new RuntimeException(e); + } + } + + public void deleteSimple(SimpleResource resource, long id) { + presta.delete("/" + resource.path + "/" + id, null); + } + + public List getActiveLangIds() { + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("display", "[id,active]"); + params.add("filter[active]", "1"); + params.add("output_format", "JSON"); + try { + String json = presta.getJson("/languages", params); + JsonNode root = objectMapper.readTree(json); + JsonNode langs = root.path("languages"); + List ids = new ArrayList<>(); + if (langs.isArray()) { + for (JsonNode l : langs) { + ids.add(l.path("id").asInt()); + } + } + return ids; + } catch (Exception e) { + log.warn("getActiveLangIds error", e); + return List.of(1); + } + } + + // ---------- Liste produits (avec quantités) ---------- + + public List listProducts(String query) { + try { + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("display", "[id,name,id_manufacturer,id_supplier,id_category_default,price]"); + params.add("output_format", "JSON"); + if (query != null && !query.isBlank()) { + params.add("filter[name]", "%[" + query.trim() + "]%"); + } + + String json = presta.getJson("/products", params); + JsonNode root = objectMapper.readTree(json); + JsonNode arr = root.path("products"); + if (!arr.isArray() || arr.isEmpty()) { + return Collections.emptyList(); + } + + List products = new ArrayList<>(); + List ids = new ArrayList<>(); + for (JsonNode p : arr) { + long id = p.path("id").asLong(); + ids.add(id); + + JsonNode nameNode = p.path("name"); + String name; + if (nameNode.isArray() && nameNode.size() > 0) { + name = nameNode.get(0).path("value").asText(""); + } else { + name = nameNode.asText(""); + } + + products.add(ProductListItemDto.builder() + .id(id) + .name(name) + .manufacturerId(p.path("id_manufacturer").asLong(0) == 0 ? null : p.path("id_manufacturer").asLong()) + .supplierId(p.path("id_supplier").asLong(0) == 0 ? null : p.path("id_supplier").asLong()) + .categoryId(p.path("id_category_default").asLong(0) == 0 ? null : p.path("id_category_default").asLong()) + .priceHt(p.path("price").isMissingNode() ? null : p.path("price").asDouble()) + .quantity(0) // rempli juste après + .build()); + } + + // Quantités via stock_availables + MultiValueMap stockParams = new LinkedMultiValueMap<>(); + stockParams.add("display", "[id_product,quantity]"); + stockParams.add("filter[id_product]", "[" + joinIds(ids) + "]"); + stockParams.add("filter[id_product_attribute]", "0"); + stockParams.add("output_format", "JSON"); + + String jsonStock = presta.getJson("/stock_availables", stockParams); + JsonNode stockRoot = objectMapper.readTree(jsonStock); + JsonNode stockArr = stockRoot.path("stock_availables"); + Map qtyMap = new HashMap<>(); + if (stockArr.isArray()) { + for (JsonNode sa : stockArr) { + long pid = sa.path("id_product").asLong(); + int q = sa.path("quantity").asInt(0); + qtyMap.put(pid, q); + } + } + + List result = new ArrayList<>(); + for (ProductListItemDto p : products) { + Integer q = qtyMap.getOrDefault(p.getId(), 0); + result.add(ProductListItemDto.builder() + .id(p.getId()) + .name(p.getName()) + .manufacturerId(p.getManufacturerId()) + .supplierId(p.getSupplierId()) + .categoryId(p.getCategoryId()) + .priceHt(p.getPriceHt()) + .quantity(q) + .build()); + } + + return result; + } catch (Exception e) { + log.error("listProducts error", e); + return Collections.emptyList(); + } + } + + private String joinIds(List ids) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < ids.size(); i++) { + if (i > 0) sb.append("|"); + sb.append(ids.get(i)); + } + return sb.toString(); + } + + // ---------- Caractéristiques Complet / Notice / État (flags) ---------- + + public ProductFlagsDto getProductFlags(long productId) { + try { + // produit full JSON + MultiValueMap prodParams = new LinkedMultiValueMap<>(); + prodParams.add("display", "full"); + prodParams.add("output_format", "JSON"); + String prodJson = presta.getJson("/products/" + productId, prodParams); + + // features + MultiValueMap featParams = new LinkedMultiValueMap<>(); + featParams.add("display", "[id,name]"); + featParams.add("output_format", "JSON"); + String featJson = presta.getJson("/product_features", featParams); + + // values + MultiValueMap valParams = new LinkedMultiValueMap<>(); + valParams.add("display", "[id,id_feature,value]"); + valParams.add("output_format", "JSON"); + String valJson = presta.getJson("/product_feature_values", valParams); + + JsonNode prodRoot = objectMapper.readTree(prodJson); + JsonNode p = prodRoot.path("product"); + if (p.isMissingNode()) { + JsonNode arr = prodRoot.path("products"); + if (arr.isArray() && arr.size() > 0) { + p = arr.get(0); + } + } + + JsonNode rawPf = p.path("associations").path("product_features"); + List pfArr = asArray(rawPf); + + // features + JsonNode featRoot = objectMapper.readTree(featJson); + JsonNode rawFeat = featRoot.path("product_features"); + if (rawFeat.isMissingNode()) { + rawFeat = featRoot.path("product_feature"); + } + List featArr = asArray(rawFeat); + Map featById = new HashMap<>(); + for (JsonNode f : featArr) { + int id = f.path("id").asInt(); + JsonNode n = f.path("name"); + String label = n.isArray() && n.size() > 0 ? n.get(0).path("value").asText("") : n.asText(""); + featById.put(id, label); + } + + // values + JsonNode valRoot = objectMapper.readTree(valJson); + JsonNode rawVals = valRoot.path("product_feature_values"); + if (rawVals.isMissingNode()) { + rawVals = valRoot.path("product_feature_value"); + } + List valArr = asArray(rawVals); + Map valById = new HashMap<>(); + for (JsonNode v : valArr) { + int id = v.path("id").asInt(); + int fId = v.path("id_feature").asInt(); + JsonNode valNode = v.path("value"); + String label = valNode.isArray() && valNode.size() > 0 ? valNode.get(0).path("value").asText("") : valNode.asText(""); + valById.put(id, new ValueInfo(fId, label)); + } + + boolean complete = false; + boolean hasManual = false; + String conditionLabel = null; + + for (JsonNode pf : pfArr) { + int valueId = pf.has("id_feature_value") + ? pf.path("id_feature_value").asInt() + : pf.path("id_value").asInt(0); + if (valueId == 0) continue; + ValueInfo info = valById.get(valueId); + if (info == null) continue; + + String featName = featById.getOrDefault(info.featureId(), ""); + String featNorm = normalizeLabel(featName); + String valNorm = normalizeLabel(info.value()); + + if (featNorm.equals("complet")) { + complete = valNorm.equals("oui"); + } else if (featNorm.equals("notice")) { + hasManual = valNorm.equals("avec"); + } else if (featNorm.equals("etat")) { + conditionLabel = info.value(); + } + } + + return ProductFlagsDto.builder() + .complete(complete) + .hasManual(hasManual) + .conditionLabel(conditionLabel) + .build(); + + } catch (Exception e) { + log.error("getProductFlags error", e); + return ProductFlagsDto.builder().complete(false).hasManual(false).conditionLabel(null).build(); + } + } + + private record ValueInfo(int featureId, String value) {} + + private List asArray(JsonNode node) { + if (node == null || node.isMissingNode() || node.isNull()) return List.of(); + if (node.isArray()) { + List list = new ArrayList<>(); + node.forEach(list::add); + return list; + } else { + return List.of(node); + } + } + + // ---------- Valeurs possibles de "État" (avec cache) ---------- + + public List getConditionValues() { + if (conditionValuesCache != null) { + return conditionValuesCache; + } + try { + MultiValueMap paramsFeat = new LinkedMultiValueMap<>(); + paramsFeat.add("display", "[id,name]"); + paramsFeat.add("output_format", "JSON"); + String featJson = presta.getJson("/product_features", paramsFeat); + + JsonNode featRoot = objectMapper.readTree(featJson); + JsonNode rawFeat = featRoot.path("product_features"); + if (rawFeat.isMissingNode()) { + rawFeat = featRoot.path("product_feature"); + } + List featArr = asArray(rawFeat); + + String targetNorm = normalizeLabel("État"); + JsonNode feat = null; + for (JsonNode f : featArr) { + JsonNode n = f.path("name"); + String label = n.isArray() && n.size() > 0 ? n.get(0).path("value").asText("") : n.asText(""); + if (normalizeLabel(label).equals(targetNorm)) { + feat = f; + break; + } + } + if (feat == null) { + log.warn("[Presta] Caractéristique 'État' introuvable"); + conditionValuesCache = List.of(); + return conditionValuesCache; + } + + int idFeature = feat.path("id").asInt(); + MultiValueMap paramsVal = new LinkedMultiValueMap<>(); + paramsVal.add("display", "[id,id_feature,value]"); + paramsVal.add("filter[id_feature]", String.valueOf(idFeature)); + paramsVal.add("output_format", "JSON"); + + String valJson = presta.getJson("/product_feature_values", paramsVal); + JsonNode valRoot = objectMapper.readTree(valJson); + JsonNode rawVals = valRoot.path("product_feature_values"); + if (rawVals.isMissingNode()) { + rawVals = valRoot.path("product_feature_value"); + } + List valArr = asArray(rawVals); + List list = new ArrayList<>(); + for (JsonNode v : valArr) { + JsonNode n = v.path("value"); + String label = n.isArray() && n.size() > 0 ? n.get(0).path("value").asText("") : n.asText(""); + if (!label.isBlank()) list.add(label); + } + conditionValuesCache = list; + return list; + } catch (Exception e) { + log.error("getConditionValues error", e); + conditionValuesCache = List.of(); + return conditionValuesCache; + } + } + + // ---------- Create / Update produit (XML complet) ---------- + + public long createProduct(PsProductDto dto) { + int idLang = getDefaultLangId(); + int idTaxGroup = getDefaultTaxRulesGroupId(); + ShopContext shop = getDefaultShopContext(); + int homeCat = getHomeCategoryId(); + int rootCat = getRootCategoryId(); + + String featuresXml = buildFeaturesXml(dto); + + double priceHt = ttcToHt(dto.getPriceTtc(), dto.getVatRate()); + String desc = (dto.getDescription() == null || dto.getDescription().isBlank()) + ? "" + : dto.getDescription().trim(); + + String xml = """ + + + %d + %d + %d + %d + %d + + standard + standard + 1 + 1 + + %s + 1 + both + 1 + 1 + 1 + + %s + %s + %s + + + + %d + %d + %d + + %s + + + + """.formatted( + dto.getManufacturerId(), + dto.getSupplierId(), + dto.getCategoryId(), + shop.idShop(), + idTaxGroup, + priceHt, + idLang, escapeXml(dto.getName()), + idLang, escapeXml(slug(dto.getName())), + idLang, escapeXml(desc), + rootCat, + homeCat, + dto.getCategoryId(), + featuresXml + ); + + String resp = presta.postXml("/products", null, xml); + int productId = extractIdFromXml(resp); + if (productId <= 0) { + throw new RuntimeException("Impossible d’extraire l’ID produit créé"); + } + + // Stock + setProductQuantity(productId, dto.getQuantity() == null ? 0 : dto.getQuantity()); + + // Les images seront gérées dans un endpoint séparé (multipart) plus tard. + return productId; + } + + public void updateProduct(long id, PsProductDto dto) { + int idLang = getDefaultLangId(); + int idTaxGroup = getDefaultTaxRulesGroupId(); + ShopContext shop = getDefaultShopContext(); + int homeCat = getHomeCategoryId(); + int rootCat = getRootCategoryId(); + + String featuresXml = buildFeaturesXml(dto); + double priceHt = ttcToHt(dto.getPriceTtc(), dto.getVatRate()); + String desc = (dto.getDescription() == null || dto.getDescription().isBlank()) + ? "" + : dto.getDescription().trim(); + + // récupérer produit existant (pour active, link_rewrite, etc.) + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("output_format", "JSON"); + params.add("display", "full"); + String json = presta.getJson("/products/" + id, params); + JsonNode root; + JsonNode prod; + try { + root = objectMapper.readTree(json); + prod = root.path("product"); + if (prod.isMissingNode()) { + JsonNode arr = root.path("products"); + if (arr.isArray() && arr.size() > 0) { + prod = arr.get(0); + } + } + } catch (Exception e) { + log.error("updateProduct get existing error", e); + throw new RuntimeException(e); + } + + int active = prod.path("active").asInt(1); + + // link_rewrite existant + String lrXml; + JsonNode lrNode = prod.path("link_rewrite"); + if (lrNode.isArray() && lrNode.size() > 0) { + StringBuilder sb = new StringBuilder(); + sb.append(""); + for (JsonNode n : lrNode) { + int lid = n.path("id").asInt(idLang); + String val = n.path("value").asText(slug(dto.getName())); + sb.append("") + .append(escapeXml(val)) + .append(""); + } + sb.append(""); + lrXml = sb.toString(); + } else { + lrXml = "" + + escapeXml(slug(dto.getName())) + + ""; + } + + String xml = """ + + + %d + %d + + %d + %d + %d + %d + %d + + standard + standard + 1 + 1 + + %s + both + 1 + 1 + 1 + + %s + %s + %s + + + + %d + %d + %d + + %s + + + + """.formatted( + id, + active, + dto.getManufacturerId(), + dto.getSupplierId(), + dto.getCategoryId(), + shop.idShop(), + idTaxGroup, + priceHt, + idLang, escapeXml(dto.getName()), + lrXml, + idLang, escapeXml(desc), + rootCat, + homeCat, + dto.getCategoryId(), + featuresXml + ); + + presta.putXml("/products/" + id, null, xml); + + // Stock + setProductQuantity((int) id, dto.getQuantity() == null ? 0 : dto.getQuantity()); + } + + private String buildFeaturesXml(PsProductDto dto) { + List pairs = new ArrayList<>(); + // Complet + if (dto.getComplete() != null) { + pairs.add(resolveFeatureValue("Complet", dto.getComplete() ? "Oui" : "Non")); + } + // Notice + if (dto.getHasManual() != null) { + pairs.add(resolveFeatureValue("Notice", dto.getHasManual() ? "Avec" : "Sans")); + } + // État + if (dto.getConditionLabel() != null && !dto.getConditionLabel().isBlank()) { + pairs.add(resolveFeatureValue("État", dto.getConditionLabel())); + } + + StringBuilder sb = new StringBuilder(); + sb.append(""); + for (ValuePair v : pairs) { + if (v == null) continue; + sb.append("") + .append("").append(v.featureId()).append("") + .append("").append(v.valueId()).append("") + .append(""); + } + sb.append(""); + return sb.toString(); + } + + private record ValuePair(int featureId, int valueId) {} + + private ValuePair resolveFeatureValue(String featureName, String valueLabel) { + if (valueLabel == null || valueLabel.isBlank()) return null; + try { + // 1) feature + MultiValueMap paramsFeat = new LinkedMultiValueMap<>(); + paramsFeat.add("display", "[id,name]"); + paramsFeat.add("output_format", "JSON"); + String featJson = presta.getJson("/product_features", paramsFeat); + JsonNode featRoot = objectMapper.readTree(featJson); + JsonNode rawFeat = featRoot.path("product_features"); + if (rawFeat.isMissingNode()) { + rawFeat = featRoot.path("product_feature"); + } + List featArr = asArray(rawFeat); + + String targetNorm = normalizeLabel(featureName); + JsonNode feat = null; + for (JsonNode f : featArr) { + JsonNode n = f.path("name"); + String label = n.isArray() && n.size() > 0 ? n.get(0).path("value").asText("") : n.asText(""); + if (normalizeLabel(label).equals(targetNorm)) { + feat = f; + break; + } + } + if (feat == null) { + log.warn("[Presta] Caractéristique introuvable {}", featureName); + return null; + } + int idFeature = feat.path("id").asInt(); + + // 2) values + MultiValueMap paramsVal = new LinkedMultiValueMap<>(); + paramsVal.add("display", "[id,id_feature,value]"); + paramsVal.add("filter[id_feature]", String.valueOf(idFeature)); + paramsVal.add("output_format", "JSON"); + String valJson = presta.getJson("/product_feature_values", paramsVal); + JsonNode valRoot = objectMapper.readTree(valJson); + JsonNode rawVals = valRoot.path("product_feature_values"); + if (rawVals.isMissingNode()) { + rawVals = valRoot.path("product_feature_value"); + } + List valArr = asArray(rawVals); + String targetValNorm = normalizeLabel(valueLabel); + JsonNode valNode = null; + for (JsonNode v : valArr) { + JsonNode n = v.path("value"); + String label = n.isArray() && n.size() > 0 ? n.get(0).path("value").asText("") : n.asText(""); + if (normalizeLabel(label).equals(targetValNorm)) { + valNode = v; + break; + } + } + if (valNode == null) { + log.warn("[Presta] Valeur de caractéristique introuvable {} / {}", featureName, valueLabel); + return null; + } + int valueId = valNode.path("id").asInt(); + return new ValuePair(idFeature, valueId); + } catch (Exception e) { + log.error("resolveFeatureValue error {} / {}", featureName, valueLabel, e); + return null; + } + } + + // ---------- Stock : setProductQuantity (comme ton TS) ---------- + + private void setProductQuantity(int productId, int quantity) { + int q = Math.max(0, quantity); + + // lecture produit pour voir si un stock_available est associé + String prodXml = presta.getXml("/products/" + productId, null); + Pattern p = Pattern.compile( + "]*>[\\s\\S]*?]*>\\s*(?:)?\\s*[\\s\\S]*?", + Pattern.CASE_INSENSITIVE + ); + Matcher m = p.matcher(prodXml); + Integer saId = m.find() ? Integer.parseInt(m.group(1)) : null; + + if (saId != null) { + // on récupère la ligne stock_available complète + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("display", "[id,id_product,id_product_attribute,id_shop,id_shop_group,quantity]"); + params.add("filter[id]", String.valueOf(saId)); + params.add("output_format", "JSON"); + + try { + String json = presta.getJson("/stock_availables", params); + JsonNode root = objectMapper.readTree(json); + JsonNode arr = root.path("stock_availables"); + if (arr.isArray() && arr.size() > 0) { + JsonNode row = arr.get(0); + putStockRow(productId, q, row); + return; + } + } catch (Exception e) { + log.error("setProductQuantity by saId error", e); + } + } + + // fallback : recherche par id_product + id_product_attribute=0 + (shop) + try { + MultiValueMap p1 = new LinkedMultiValueMap<>(); + p1.add("display", "[id,id_product,id_product_attribute,id_shop,id_shop_group,quantity]"); + p1.add("filter[id_product]", String.valueOf(productId)); + p1.add("filter[id_product_attribute]", "0"); + p1.add("output_format", "JSON"); + String json1 = presta.getJson("/stock_availables", p1); + JsonNode root1 = objectMapper.readTree(json1); + JsonNode arr1 = root1.path("stock_availables"); + if (arr1.isArray() && arr1.size() > 0) { + JsonNode row = arr1.get(0); + putStockRow(productId, q, row); + return; + } + + ShopContext shop = getDefaultShopContext(); + MultiValueMap p2 = new LinkedMultiValueMap<>(); + p2.add("display", "[id,id_product,id_product_attribute,id_shop,id_shop_group,quantity]"); + p2.add("filter[id_product]", String.valueOf(productId)); + p2.add("filter[id_product_attribute]", "0"); + p2.add("filter[id_shop]", String.valueOf(shop.idShop())); + p2.add("filter[id_shop_group]", String.valueOf(shop.idShopGroup())); + p2.add("output_format", "JSON"); + String json2 = presta.getJson("/stock_availables", p2); + JsonNode root2 = objectMapper.readTree(json2); + JsonNode arr2 = root2.path("stock_availables"); + if (arr2.isArray() && arr2.size() > 0) { + JsonNode row = arr2.get(0); + putStockRow(productId, q, row); + return; + } + + log.warn("[Presta] Aucune ligne stock_available PUTtable trouvée pour product {}", productId); + + } catch (Exception e) { + log.error("setProductQuantity fallback error", e); + } + } + + private void putStockRow(int productId, int quantity, JsonNode row) { + int saId = row.path("id").asInt(); + Integer idShop = row.has("id_shop") ? row.path("id_shop").asInt() : null; + Integer idShopGroup = row.has("id_shop_group") ? row.path("id_shop_group").asInt() : null; + + StringBuilder extraShop = new StringBuilder(); + if (idShop != null) { + extraShop.append("").append(idShop).append(""); + } + if (idShopGroup != null) { + extraShop.append("").append(idShopGroup).append(""); + } + + String xml = """ + + + %d + %d + 0 + %s + %d + 0 + 0 + + + """.formatted(saId, productId, extraShop, quantity); + + presta.putXml("/stock_availables/" + saId, null, xml); + } + + public void deleteProduct(long id) { + presta.delete("/products/" + id, null); + } +} \ No newline at end of file diff --git a/api/src/main/java/fr/gameovergne/api/service/PrestashopClient.java b/api/src/main/java/fr/gameovergne/api/service/PrestashopClient.java index 7305779..9ac7c62 100644 --- a/api/src/main/java/fr/gameovergne/api/service/PrestashopClient.java +++ b/api/src/main/java/fr/gameovergne/api/service/PrestashopClient.java @@ -1,79 +1,97 @@ package fr.gameovergne.api.service; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.*; import org.springframework.stereotype.Service; -import org.springframework.web.client.RestClientException; -import org.springframework.web.client.RestTemplate; -import org.springframework.web.server.ResponseStatusException; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestClient; +import org.springframework.web.util.UriComponentsBuilder; -import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.Base64; @Service +@Slf4j public class PrestashopClient { - private static final Logger log = LoggerFactory.getLogger(PrestashopClient.class); - + private final RestClient client; private final String baseUrl; - private final String apiKey; - private final RestTemplate restTemplate = new RestTemplate(); public PrestashopClient( - @Value("${prestashop.api.base-url}") String baseUrl, - @Value("${prestashop.api.key}") String apiKey + @Value("${prestashop.base-url}") String baseUrl, + @Value("${prestashop.api-key}") String apiKey ) { - this.baseUrl = baseUrl; // ex: https://shop.gameovergne.fr/api - this.apiKey = apiKey; + this.baseUrl = baseUrl; + + String basicAuth = Base64.getEncoder() + .encodeToString((apiKey + ":").getBytes(StandardCharsets.UTF_8)); + + this.client = RestClient.builder() + .defaultHeader(HttpHeaders.AUTHORIZATION, "Basic " + basicAuth) + .build(); + log.info("[PrestaShop] Base URL = {}", baseUrl); - log.info("[PrestaShop] API key length = {}", apiKey != null ? apiKey.length() : 0); + log.info("[PrestaShop] API key length = {}", apiKey.length()); } - public ResponseEntity getWithRawQuery(String relativePath, String rawQuery) { - // Normalisation du path - String path = (relativePath == null) ? "" : relativePath; - if (!path.startsWith("/")) { - path = "/" + path; + private String buildUri(String path, MultiValueMap params) { + UriComponentsBuilder builder = UriComponentsBuilder + .fromHttpUrl(baseUrl + path); + if (params != null && !params.isEmpty()) { + builder.queryParams(params); } + return builder.build(true).toUriString(); + } - // Construction manuelle de l’URL - StringBuilder urlBuilder = new StringBuilder(); - urlBuilder.append(baseUrl); - urlBuilder.append(path); + public String getJson(String path, MultiValueMap params) { + String uri = buildUri(path, params); + log.info("[PrestaShop] GET JSON {}", uri); + return client.get() + .uri(uri) + .accept(MediaType.APPLICATION_JSON) + .retrieve() + .body(String.class); + } - if (rawQuery != null && !rawQuery.isBlank()) { - urlBuilder.append('?').append(rawQuery); - } + public String getXml(String path, MultiValueMap params) { + String uri = buildUri(path, params); + log.info("[PrestaShop] GET XML {}", uri); + return client.get() + .uri(uri) + .accept(MediaType.APPLICATION_XML) + .retrieve() + .body(String.class); + } - String urlString = urlBuilder.toString(); - log.info("[PrestaShop] GET {}", urlString); + public String postXml(String path, MultiValueMap params, String xmlBody) { + String uri = buildUri(path, params); + log.info("[PrestaShop] POST XML {}", uri); + return client.post() + .uri(uri) + .contentType(MediaType.APPLICATION_XML) + .body(xmlBody) + .retrieve() + .body(String.class); + } - URI uri = URI.create(urlString); + public String putXml(String path, MultiValueMap params, String xmlBody) { + String uri = buildUri(path, params); + log.info("[PrestaShop] PUT XML {}", uri); + return client.put() + .uri(uri) + .contentType(MediaType.APPLICATION_XML) + .body(xmlBody) + .retrieve() + .body(String.class); + } - HttpHeaders headers = new HttpHeaders(); - headers.set(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE); - // Presta: Basic Auth avec apiKey comme user et mot de passe vide - headers.setBasicAuth(apiKey, ""); - - HttpEntity entity = new HttpEntity<>(headers); - - try { - ResponseEntity response = - restTemplate.exchange(uri, HttpMethod.GET, entity, String.class); - - log.info("[PrestaShop] Response {} {}", response.getStatusCode().value(), - response.getBody()); - - return response; - } catch (RestClientException ex) { - log.error("[PrestaShop] Error calling {} : {}", urlString, ex.toString(), ex); - // On renvoie quelque chose de propre au client Angular - throw new ResponseStatusException( - HttpStatus.BAD_GATEWAY, - "Error while calling PrestaShop API", - ex - ); - } + public void delete(String path, MultiValueMap params) { + String uri = buildUri(path, params); + log.info("[PrestaShop] DELETE {}", uri); + client.delete() + .uri(uri) + .retrieve() + .toBodilessEntity(); } } \ No newline at end of file