diff --git a/api/src/main/java/fr/gameovergne/api/controller/PrestashopAdminController.java b/api/src/main/java/fr/gameovergne/api/controller/PrestashopAdminController.java deleted file mode 100644 index d64a97f..0000000 --- a/api/src/main/java/fr/gameovergne/api/controller/PrestashopAdminController.java +++ /dev/null @@ -1,78 +0,0 @@ -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/controller/PrestashopProxyController.java b/api/src/main/java/fr/gameovergne/api/controller/PrestashopProxyController.java index 423ded5..a4a634a 100644 --- a/api/src/main/java/fr/gameovergne/api/controller/PrestashopProxyController.java +++ b/api/src/main/java/fr/gameovergne/api/controller/PrestashopProxyController.java @@ -2,8 +2,13 @@ package fr.gameovergne.api.controller; import fr.gameovergne.api.service.PrestashopClient; import jakarta.servlet.http.HttpServletRequest; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; +import org.springframework.util.AntPathMatcher; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.HandlerMapping; @RestController @RequestMapping("/api/ps") @@ -15,13 +20,24 @@ public class PrestashopProxyController { this.prestashopClient = prestashopClient; } - @GetMapping("/{resource}") - public ResponseEntity proxyGet( - @PathVariable String resource, - HttpServletRequest request - ) { - String rawQuery = request.getQueryString(); // ex: "display=%5Bid,name,active%5D&output_format=JSON" - String body = prestashopClient.getWithRawQuery(resource, rawQuery); - return ResponseEntity.ok(body); + @GetMapping("/**") + public ResponseEntity proxyGet(HttpServletRequest request) { + String fullPath = (String) request.getAttribute( + HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE); + String bestMatchPattern = (String) request.getAttribute( + HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE); + + String relativePath = new AntPathMatcher() + .extractPathWithinPattern(bestMatchPattern, fullPath); + + String rawQuery = request.getQueryString(); + + ResponseEntity prestaResponse = + prestashopClient.getWithRawQuery("/" + relativePath, rawQuery); + + return ResponseEntity + .status(prestaResponse.getStatusCode()) + .contentType(MediaType.APPLICATION_JSON) + .body(prestaResponse.getBody()); } } \ 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 deleted file mode 100644 index e6b93f1..0000000 --- a/api/src/main/java/fr/gameovergne/api/dto/ps/ProductFlagsDto.java +++ /dev/null @@ -1,12 +0,0 @@ -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 deleted file mode 100644 index 715f708..0000000 --- a/api/src/main/java/fr/gameovergne/api/dto/ps/ProductListItemDto.java +++ /dev/null @@ -1,16 +0,0 @@ -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 deleted file mode 100644 index 4bf3769..0000000 --- a/api/src/main/java/fr/gameovergne/api/dto/ps/PsItemDto.java +++ /dev/null @@ -1,12 +0,0 @@ -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 deleted file mode 100644 index 608cf38..0000000 --- a/api/src/main/java/fr/gameovergne/api/dto/ps/PsProductDto.java +++ /dev/null @@ -1,26 +0,0 @@ -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 deleted file mode 100644 index 6d1dc3f..0000000 --- a/api/src/main/java/fr/gameovergne/api/model/ps/SimpleResource.java +++ /dev/null @@ -1,19 +0,0 @@ -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 deleted file mode 100644 index a6507e5..0000000 --- a/api/src/main/java/fr/gameovergne/api/service/PrestashopAdminService.java +++ /dev/null @@ -1,1048 +0,0 @@ -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 42e5534..88c2187 100644 --- a/api/src/main/java/fr/gameovergne/api/service/PrestashopClient.java +++ b/api/src/main/java/fr/gameovergne/api/service/PrestashopClient.java @@ -1,19 +1,17 @@ -// package à adapter si besoin package fr.gameovergne.api.service; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; import org.springframework.util.MultiValueMap; import org.springframework.web.client.RestClient; +import org.springframework.web.util.UriComponentsBuilder; -import java.net.URI; -import java.net.URLEncoder; import java.nio.charset.StandardCharsets; -import java.util.List; -import java.util.Map; +import java.util.Base64; @Service @Slf4j @@ -21,176 +19,104 @@ public class PrestashopClient { private final RestClient client; private final String baseUrl; - private final String apiKey; 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.length()); - - this.client = RestClient.builder() - .defaultHeaders(headers -> { - headers.set(HttpHeaders.USER_AGENT, "curl/8.10.1"); - headers.setAccept(List.of(MediaType.APPLICATION_JSON, MediaType.ALL)); - }) - .build(); } - // ------------------------------------------------------------------------ - // Outil interne : construit l’URL complète baseUrl + path + ?ws_key=...¶ms... - // ------------------------------------------------------------------------ - private String buildUrl(String path, MultiValueMap params) { - StringBuilder full = new StringBuilder(); - - // baseUrl - full.append(baseUrl); - - // path - if (path != null && !path.isBlank()) { - boolean baseEndsWithSlash = baseUrl.endsWith("/"); - boolean pathStartsWithSlash = path.startsWith("/"); - - if (baseEndsWithSlash && pathStartsWithSlash) { - full.append(path.substring(1)); - } else if (!baseEndsWithSlash && !pathStartsWithSlash) { - full.append("/").append(path); - } else { - full.append(path); - } - } - - // ws_key en premier param - full.append("?ws_key=").append(URLEncoder.encode(apiKey, StandardCharsets.UTF_8)); - + private String buildUri(String path, MultiValueMap params) { + UriComponentsBuilder builder = UriComponentsBuilder + .fromHttpUrl(baseUrl + path); if (params != null && !params.isEmpty()) { - for (Map.Entry> entry : params.entrySet()) { - String key = entry.getKey(); - for (String value : entry.getValue()) { - full.append("&") - .append(URLEncoder.encode(key, StandardCharsets.UTF_8)) - .append("=") - .append(URLEncoder.encode(value, StandardCharsets.UTF_8)); - } - } + builder.queryParams(params); } - - String url = full.toString(); - log.debug("[PrestaShop] Built URL = {}", url); - return url; + return builder.build(true).toUriString(); } - // ------------------------------------------------------------------------ - // GET JSON (utilisé partout dans PrestashopAdminService) - // ------------------------------------------------------------------------ - public String getJson(String path, MultiValueMap params) { - String url = buildUrl(path, params); - log.info("[PrestaShop] GET JSON {}", url); + // -------- Méthodes "typed" JSON / XML utilisées par ps-admin -------- + public String getJson(String path, MultiValueMap params) { + String uri = buildUri(path, params); + log.info("[PrestaShop] GET JSON {}", uri); return client.get() - .uri(URI.create(url)) + .uri(uri) .accept(MediaType.APPLICATION_JSON) .retrieve() .body(String.class); } - // ------------------------------------------------------------------------ - // GET XML (utilisé pour certains appels de stock, produit, etc.) - // ------------------------------------------------------------------------ public String getXml(String path, MultiValueMap params) { - String url = buildUrl(path, params); - log.info("[PrestaShop] GET XML {}", url); - + String uri = buildUri(path, params); + log.info("[PrestaShop] GET XML {}", uri); return client.get() - .uri(URI.create(url)) + .uri(uri) .accept(MediaType.APPLICATION_XML) .retrieve() .body(String.class); } - // ------------------------------------------------------------------------ - // POST XML (création catégorie/produit/etc.) - // ------------------------------------------------------------------------ public String postXml(String path, MultiValueMap params, String xmlBody) { - String url = buildUrl(path, params); - log.info("[PrestaShop] POST XML {} (body length={})", url, xmlBody != null ? xmlBody.length() : 0); - + String uri = buildUri(path, params); + log.info("[PrestaShop] POST XML {}", uri); return client.post() - .uri(URI.create(url)) + .uri(uri) .contentType(MediaType.APPLICATION_XML) - .accept(MediaType.APPLICATION_XML) - .body(xmlBody != null ? xmlBody : "") + .body(xmlBody) .retrieve() .body(String.class); } - // ------------------------------------------------------------------------ - // PUT XML (update catégorie/produit/stock/etc.) - // ------------------------------------------------------------------------ public String putXml(String path, MultiValueMap params, String xmlBody) { - String url = buildUrl(path, params); - log.info("[PrestaShop] PUT XML {} (body length={})", url, xmlBody != null ? xmlBody.length() : 0); - + String uri = buildUri(path, params); + log.info("[PrestaShop] PUT XML {}", uri); return client.put() - .uri(URI.create(url)) + .uri(uri) .contentType(MediaType.APPLICATION_XML) - .accept(MediaType.APPLICATION_XML) - .body(xmlBody != null ? xmlBody : "") + .body(xmlBody) .retrieve() .body(String.class); } - // ------------------------------------------------------------------------ - // DELETE (suppression ressource) - // ------------------------------------------------------------------------ public void delete(String path, MultiValueMap params) { - String url = buildUrl(path, params); - log.info("[PrestaShop] DELETE {}", url); - + String uri = buildUri(path, params); + log.info("[PrestaShop] DELETE {}", uri); client.delete() - .uri(URI.create(url)) + .uri(uri) .retrieve() .toBodilessEntity(); } - // ------------------------------------------------------------------------ - // Spécial proxy GET brut : on garde la query telle quelle (déjà encodée) - // ------------------------------------------------------------------------ - public String getWithRawQuery(String resource, String rawQuery) { - try { - StringBuilder fullUrl = new StringBuilder(); - fullUrl.append(baseUrl); - // baseUrl : https://shop.gameovergne.fr/api - if (!baseUrl.endsWith("/")) { - fullUrl.append("/"); - } - // resource : "categories", "products", ... - fullUrl.append(resource); + // -------- Méthode générique utilisée par le proxy /api/ps/** -------- - // ws_key - String encodedKey = URLEncoder.encode(apiKey, StandardCharsets.UTF_8); - fullUrl.append("?ws_key=").append(encodedKey); - - // rawQuery = "display=%5Bid,name,active%5D&output_format=JSON" - if (rawQuery != null && !rawQuery.isBlank()) { - fullUrl.append("&").append(rawQuery); // surtout ne pas réencoder - } - - String urlString = fullUrl.toString(); - log.info("[PrestaShop] RAW GET via ws_key = {}", urlString); - - return client.get() - .uri(URI.create(urlString)) - .retrieve() - .body(String.class); - - } catch (Exception e) { - log.error("[PrestaShop] getWithRawQuery error for resource={} rawQuery={}", resource, rawQuery, e); - throw e; + /** + * Proxy brut : on lui donne le path Presta (ex: "/categories") et la query string déjà encodée. + * On récupère un ResponseEntity pour pouvoir propager le status code. + */ + public ResponseEntity getWithRawQuery(String path, String rawQuery) { + String uri = baseUrl + path; + if (rawQuery != null && !rawQuery.isBlank()) { + uri = uri + "?" + rawQuery; } + + log.info("[PrestaShop] GET (proxy) {}", uri); + + return client.get() + .uri(uri) + .accept(MediaType.APPLICATION_JSON) + .retrieve() + .toEntity(String.class); } } \ No newline at end of file diff --git a/client/src/app/components/ps-product-dialog/ps-product-dialog.component.ts b/client/src/app/components/ps-product-dialog/ps-product-dialog.component.ts index 187c717..15f7610 100644 --- a/client/src/app/components/ps-product-dialog/ps-product-dialog.component.ts +++ b/client/src/app/components/ps-product-dialog/ps-product-dialog.component.ts @@ -1,5 +1,4 @@ -// File: src/app/components/ps-product-dialog/ps-product-dialog.component.ts -import { Component, Inject, OnInit, inject, OnDestroy } from '@angular/core'; +import {Component, Inject, OnInit, inject, OnDestroy} from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; import { MatFormField, MatLabel } from '@angular/material/form-field'; @@ -21,7 +20,6 @@ import { catchError, forkJoin, of, Observable } from 'rxjs'; import { PsItem } from '../../interfaces/ps-item'; import { ProductListItem } from '../../interfaces/product-list-item'; import { PrestashopService } from '../../services/prestashop.serivce'; -import { PsProduct } from '../../interfaces/ps-product'; // si tu as une interface dédiée export type ProductDialogData = { mode: 'create' | 'edit'; @@ -89,7 +87,6 @@ export class PsProductDialogComponent implements OnInit, OnDestroy { // ---------- Helpers locaux ---------- - // utilisé seulement pour pré-afficher un TTC approximatif à partir du HT de la liste private toTtc(ht: number) { return Math.round(((ht * 1.2) + Number.EPSILON) * 100) / 100; } @@ -151,7 +148,6 @@ export class PsProductDialogComponent implements OnInit, OnDestroy { if (this.mode === 'edit' && this.productRow) { const r = this.productRow; - // pré-remplissage rapide avec la ligne de liste (HT -> TTC approximatif) const immediateTtc = r.priceHt == null ? 0 : this.toTtc(r.priceHt); this.form.patchValue({ name: r.name, @@ -162,7 +158,11 @@ export class PsProductDialogComponent implements OnInit, OnDestroy { }); const details$ = this.ps.getProductDetails(r.id).pipe( - catchError(() => of(null)) + catchError(() => of({ + id: r.id, name: r.name, description: '', + id_manufacturer: r.id_manufacturer, id_supplier: r.id_supplier, + id_category_default: r.id_category_default, priceHt: r.priceHt ?? 0 + })) ); const qty$ = this.ps.getProductQuantity(r.id).pipe(catchError(() => of(0))); const imgs$ = this.ps.getProductImageUrls(r.id).pipe(catchError(() => of([]))); @@ -172,23 +172,20 @@ export class PsProductDialogComponent implements OnInit, OnDestroy { forkJoin({ details: details$, qty: qty$, imgs: imgs$, flags: flags$ }) .subscribe(({ details, qty, imgs, flags }) => { - const baseDesc = this.cleanForTextarea(details?.description ?? ''); + const ttc = this.toTtc(details.priceHt ?? 0); + const baseDesc = this.cleanForTextarea(details.description ?? ''); this.lastLoadedDescription = baseDesc; this.form.patchValue({ - // description depuis le backend (déjà propre / nettoyée normalement) description: baseDesc, - // flags complete: flags.complete, hasManual: flags.hasManual, conditionLabel: flags.conditionLabel || '', - // TTC "réel" calculé par le backend (si dispo) sinon on garde la valeur actuelle - priceTtc: (details?.priceTtc ?? this.form.value.priceTtc ?? 0), + priceTtc: (ttc || this.form.value.priceTtc || 0), quantity: qty, - // ids de ref issus du DTO backend - categoryId: (details?.categoryId ?? this.form.value.categoryId) ?? null, - manufacturerId: (details?.manufacturerId ?? this.form.value.manufacturerId) ?? null, - supplierId: (details?.supplierId ?? this.form.value.supplierId) ?? null + categoryId: (details.id_category_default ?? this.form.value.categoryId) ?? null, + manufacturerId: (details.id_manufacturer ?? this.form.value.manufacturerId) ?? null, + supplierId: (details.id_supplier ?? this.form.value.supplierId) ?? null }); this.existingImageUrls = imgs; @@ -206,7 +203,7 @@ export class PsProductDialogComponent implements OnInit, OnDestroy { const fl = (ev.target as HTMLInputElement).files; // Nettoyage des anciens objectURL - for (let url of this.previewUrls) { + for(let url of this.previewUrls) { URL.revokeObjectURL(url); } this.previewUrls = []; @@ -264,7 +261,7 @@ export class PsProductDialogComponent implements OnInit, OnDestroy { } } - // -------- Save / close -------- + // -------- Save / close inchangés (à part dto.images) -------- save() { if (this.form.invalid) return; @@ -272,7 +269,7 @@ export class PsProductDialogComponent implements OnInit, OnDestroy { const v = this.form.getRawValue(); const effectiveDescription = (v.description ?? '').trim() || this.lastLoadedDescription; - const dto: PsProduct = { + const dto = { name: v.name!, description: effectiveDescription, categoryId: +v.categoryId!, diff --git a/client/src/app/services/prestashop.serivce.ts b/client/src/app/services/prestashop.serivce.ts index a36b6da..7e6f41a 100644 --- a/client/src/app/services/prestashop.serivce.ts +++ b/client/src/app/services/prestashop.serivce.ts @@ -1,301 +1,1087 @@ -// File: src/app/services/prestashop.service.ts -import { inject, Injectable } from '@angular/core'; -import { HttpClient, HttpParams } from '@angular/common/http'; -import { Observable, map, of } from 'rxjs'; - -import { PsItem } from '../interfaces/ps-item'; -import { PsProduct } from '../interfaces/ps-product'; -import { ProductListItem } from '../interfaces/product-list-item'; -import { environment } from '../../environments/environment'; +import {inject, Injectable} from '@angular/core'; +import {HttpClient, HttpHeaders, HttpParams} from '@angular/common/http'; +import {forkJoin, map, of, switchMap, Observable, catchError} from 'rxjs'; +import {PsItem} from '../interfaces/ps-item'; +import {PsProduct} from '../interfaces/ps-product'; +import {ProductListItem} from '../interfaces/product-list-item'; +import {environment} from '../../environments/environment'; type Resource = 'categories' | 'manufacturers' | 'suppliers'; -@Injectable({ providedIn: 'root' }) +const UPDATE_CFG: Record = { + categories: { + root: 'category', + needsDefaultLang: true, + keepFields: ['active', 'id_parent', 'link_rewrite'], + nameIsMultilang: true + }, + manufacturers: { + root: 'manufacturer', + needsDefaultLang: false, + keepFields: ['active'], + nameIsMultilang: false + }, + suppliers: { + root: 'supplier', + needsDefaultLang: false, + keepFields: ['active'], + nameIsMultilang: false + }, +}; + +@Injectable({providedIn: 'root'}) export class PrestashopService { private readonly http = inject(HttpClient); + private readonly base = environment.psUrl; + private readonly frontBase = 'https://shop.gameovergne.fr' - /** - * Base de ton API Spring. - * Exemple : https://dev.vincent-guillet.fr/gameovergne-api - * - * Assure-toi d'avoir dans environment : - * apiUrl: 'https://dev.vincent-guillet.fr/gameovergne-api' - */ - private readonly apiBase = environment.apiUrl; + // -------- Utils + private readonly headersXml = new HttpHeaders({ + 'Content-Type': 'application/xml', + 'Accept': 'application/xml' + }); - /** Endpoints backend "intelligents" (logique métier Presta) */ - private readonly adminBase = `${this.apiBase}/ps-admin`; + private conditionValuesCache: string[] | null = null; - /** Endpoints proxy bruts vers Presta (si tu en as encore besoin) */ - private readonly proxyBase = `${this.apiBase}/ps`; + private normalizeLabel(s: string): string { + return String(s ?? '') + .normalize('NFD') + .replaceAll(/[\u0300-\u036f]/g, '') + .toLowerCase() + .trim(); + } - // =========================================================================== - // 1) CRUD générique (categories / manufacturers / suppliers) - // =========================================================================== + private escapeXml(v: string) { + return String(v) + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); + } - /** - * Liste les catégories / fabricants / fournisseurs. - * Signature conservée pour les composants existants. - */ - list(resource: Resource): Observable { - return this.http - .get(`${this.adminBase}/${resource}`) - .pipe( - // Par sécurité, on trie par nom ici (comme avant) - map(items => - [...items].sort((a, b) => - a.name.localeCompare(b.name, 'fr', { sensitivity: 'base' }) - ) - ) + private extractIdFromXml(xml: string): number | null { + const s = String(xml); + + // 1) cas le plus sûr : ... (avant ) + const mProduct = /]*>\s*(?:)?\s*<\/id>[\s\S]*?(?:|<\/product>)/i.exec(s); + if (mProduct) return +mProduct[1]; + + // 2) racine quelconque + const mRoot = /]*>[\s\S]*?]*>\s*(?:)?\s*<\/id>/i.exec(s); + if (mRoot) return +mRoot[2]; + + // 3) fallback + const mAny = /]*>\s*(?:)?\s*<\/id>/i.exec(s); + if (mAny) return +mAny[1]; + + console.warn('[Presta] Impossible d’extraire depuis la réponse:', xml); + return null; + } + + private slug(str: string) { + return String(str).toLowerCase() + .normalize('NFD') + .replaceAll(/[\u0300-\u036f]/g, '') + .replaceAll(/[^a-z0-9]+/g, '-') + .replaceAll(/(^-|-$)/g, ''); + } + + private toLangBlock(tag: string, entries: Array<{ id: number; value: string }>) { + const inner = entries + .map(e => `${this.escapeXml(e.value)}`) + .join(''); + return `<${tag}>${inner}`; + } + + private ensureArrayLang(v: any): Array<{ id: number; value: string }> { + if (Array.isArray(v)) { + return v.map(x => ({id: +x.id, value: String(x.value ?? '')})); + } + return []; + } + + private ttcToHt(priceTtc: number, rate = 0.2) { + const raw = priceTtc / (1 + rate); + return Number(raw.toFixed(6)); + } + + // -------- Contexte & langues + + /** Langue par défaut (PS_LANG_DEFAULT) */ + getDefaultLangId() { + const params = new HttpParams() + .set('display', '[value]') + .set('filter[name]', 'PS_LANG_DEFAULT') + .set('output_format', 'JSON'); + + return this.http.get(`${this.base}/configurations`, {params}).pipe( + map(r => +r?.configurations?.[0]?.value || 1) + ); + } + + /** Groupe de taxes par défaut (PS_TAX_DEFAULT_RULES_GROUP avec fallback sur le 1er groupe actif) */ + private getDefaultTaxRulesGroupId() { + const cfgParams = new HttpParams() + .set('display', '[value]') + .set('filter[name]', 'PS_TAX_DEFAULT_RULES_GROUP') + .set('output_format', 'JSON'); + + return this.http.get(`${this.base}/configurations`, { params: cfgParams }).pipe( + switchMap(r => { + const raw = r?.configurations?.[0]?.value; + const cfgId = Number(raw); + + if (Number.isFinite(cfgId) && cfgId > 0) { + // Cas nominal : la conf est bien renseignée + return of(cfgId); + } + + // Fallback : on prend le 1er tax_rule_group actif + const trgParams = new HttpParams() + .set('display', '[id,name,active]') + .set('filter[active]', '1') + .set('output_format', 'JSON'); + + return this.http.get(`${this.base}/tax_rule_groups`, { params: trgParams }).pipe( + map(tr => { + const rawGroups = tr?.tax_rule_groups ?? tr?.tax_rule_group ?? []; + const groups: any[] = Array.isArray(rawGroups) ? rawGroups : (rawGroups ? [rawGroups] : []); + const g = groups[0]; + const id = g ? Number(g.id) : 0; + if (!id) { + console.warn('[Presta] Aucun tax_rule_group actif trouvé, id_tax_rules_group sera 0 (pas de TVA).'); + } + return id; + }) + ); + }) + ); + } + + /** IDs des langues actives */ + getActiveLangIds() { + const params = new HttpParams() + .set('display', '[id,active]') + .set('filter[active]', '1') + .set('output_format', 'JSON'); + + return this.http.get(`${this.base}/languages`, {params}).pipe( + map(r => (r?.languages ?? []).map((l: any) => +l.id)) + ); + } + + /** Contexte boutique actif */ + private getDefaultShopContext() { + const params = new HttpParams() + .set('display', '[id,id_shop_group,active]') + .set('filter[active]', '1') + .set('output_format', 'JSON'); + + return this.http.get(`${this.base}/shops`, {params}).pipe( + map(r => { + const s = (r?.shops ?? [])[0]; + return { + idShop: s ? +s.id : 1, + idShopGroup: s ? +s.id_shop_group : 1 + }; + }) + ); + } + + /** Id catégorie Home (PS_HOME_CATEGORY) */ + private getHomeCategoryId() { + const params = new HttpParams() + .set('display', '[value]') + .set('filter[name]', 'PS_HOME_CATEGORY') + .set('output_format', 'JSON'); + + return this.http.get(`${this.base}/configurations`, {params}).pipe( + map(r => +r?.configurations?.[0]?.value || 2) + ); + } + + /** Id catégorie root (PS_ROOT_CATEGORY) */ + private getRootCategoryId() { + const params = new HttpParams() + .set('display', '[value]') + .set('filter[name]', 'PS_ROOT_CATEGORY') + .set('output_format', 'JSON'); + + return this.http.get(`${this.base}/configurations`, {params}).pipe( + map(r => +r?.configurations?.[0]?.value || 1) + ); + } + + /** Objet complet (JSON) pour update sûr */ + private getOne(resource: Resource, id: number) { + const params = new HttpParams() + .set('output_format', 'JSON') + .set('display', 'full'); + + return this.http.get(`${this.base}/${resource}/${id}`, {params}).pipe( + map(r => r?.category ?? r?.manufacturer ?? r?.supplier ?? r) + ); + } + + // -------- CRUD générique (categories/manufacturers/suppliers) + + list(resource: Resource) { + const params = new HttpParams() + .set('display', '[id,name,active]') + .set('output_format', 'JSON'); + + return this.http.get(`${this.base}/${resource}`, { params }).pipe( + map(r => { + const arr = r?.[resource] ?? []; + const items: PsItem[] = arr.map((x: any) => ({ + id: +x.id, + name: Array.isArray(x.name) ? (x.name[0]?.value ?? '') : (x.name ?? ''), + active: x.active === undefined ? undefined : !!+x.active + }) as PsItem); + + // tri global A→Z par nom + return items.sort((a, b) => + a.name.localeCompare(b.name, 'fr', { sensitivity: 'base' }) + ); + }) + ); + } + + create(resource: Resource, name: string) { + const safeName = this.escapeXml(name); + + if (resource === 'categories') { + return this.getActiveLangIds().pipe( + switchMap(langIds => { + const xml = + ` + + 2 + 1 + ${this.toLangBlock('name', langIds.map((id: any) => ({id, value: safeName})))} + ${this.toLangBlock('link_rewrite', langIds.map((id: any) => ({id, value: this.slug(name)})))} + +`; + + return this.http.post(`${this.base}/categories`, xml, { + headers: this.headersXml, + responseType: 'text' + }); + }), + map(res => this.extractIdFromXml(res)) ); - } - - /** - * Crée une catégorie / manufacturer / supplier. - * Retourne l'id créé (ou null si souci). - */ - create(resource: Resource, name: string): Observable { - return this.http - .post<{ id: number }>( - `${this.adminBase}/${resource}`, - { name } - ) - .pipe(map(res => (res && true ? res.id : null))); - } - - /** - * Met à jour le nom d'une catégorie / manufacturer / supplier. - */ - update(resource: Resource, id: number, newName: string): Observable { - return this.http - .put( - `${this.adminBase}/${resource}/${id}`, - { name: newName } - ) - .pipe(map(() => true)); - } - - /** - * Supprime une catégorie / manufacturer / supplier. - */ - delete(resource: Resource, id: number): Observable { - return this.http - .delete(`${this.adminBase}/${resource}/${id}`) - .pipe(map(() => true)); - } - - /** - * Récupère le XML brut de l'objet Presta (pour debug / vérif). - * Signature conservée, mais on passe désormais par le backend. - */ - getXml(resource: Resource, id: number): Observable { - return this.http.get( - `${this.adminBase}/${resource}/${id}/raw-xml`, - { - responseType: 'text', - } - ); - } - - // =========================================================================== - // 2) Produits (liste / détails) - // =========================================================================== - - /** - * Liste les produits pour l'écran de recherche / listing. - * Anciennement : multi-requêtes directes sur Presta. - * Maintenant : le backend te renvoie déjà les infos nécessaires. - */ - listProducts( - query?: string - ): Observable<(ProductListItem & { priceHt?: number; quantity?: number })[]> { - let params = new HttpParams(); - if (query?.trim()) { - params = params.set('q', query.trim()); } - return this.http.get< - (ProductListItem & { priceHt?: number; quantity?: number })[] - >(`${this.adminBase}/products`, { params }); - } + const xml = + resource === 'manufacturers' + ? `1${safeName}` + : `1${safeName}`; - /** - * Détails complets d'un produit. - * Le backend renvoie un PsProduct (ou équivalent). - */ - getProductDetails(id: number): Observable { - return this.http.get(`${this.adminBase}/products/${id}`); - } - - // =========================================================================== - // 3) Images produits - // =========================================================================== - - getProductImageUrls(productId: number) { - return this.http.get( - `${this.adminBase}/products/${productId}/images` + return this.http.post(`${this.base}/${resource}`, xml, { + headers: this.headersXml, + responseType: 'text' + }).pipe( + map((res: string) => this.extractIdFromXml(res)) ); } - /** - * URL de la miniature d'un produit (ou null). - * On délègue au backend (qui sait comment construire l'URL FO). - * - * Ancienne signature conservée : Observable. - */ - getProductThumbnailUrl(productId: number): Observable { - return this.http - .get<{ url: string | null }>( - `${this.adminBase}/products/${productId}/thumbnail` - ) - .pipe(map(res => res?.url ?? null)); - } + update(resource: Resource, id: number, newName: string) { + const cfg = UPDATE_CFG[resource]; + const safeName = this.escapeXml(newName); - /** - * Si tu as encore un usage direct de l'upload dans certains composants, - * tu peux garder cette méthode mais la faire pointer sur ton endpoint Spring - * "upload image only". - * - * Si tu n'en as pas besoin, tu peux la laisser mais non utilisée. - */ - uploadProductImage( - productId: number, - file: File - ): Observable { - const fd = new FormData(); - fd.append('image', file); + const defaultLangOr1$ = + cfg.needsDefaultLang ? this.getDefaultLangId() : this.getDefaultLangId().pipe(map(() => 1)); - return this.http.post( - `${this.adminBase}/products/${productId}/images`, - fd + return defaultLangOr1$.pipe( + switchMap((idLang: number) => + this.getOne(resource, id).pipe( + switchMap(obj => { + const root = cfg.root; + const active = obj?.active === undefined ? 1 : +obj.active; + + let linkRewriteXml = ''; + let idParentXml = ''; + + if (resource === 'categories') { + const lr = this.ensureArrayLang(obj?.link_rewrite); + linkRewriteXml = lr.length + ? this.toLangBlock('link_rewrite', lr) + : this.toLangBlock('link_rewrite', [{id: idLang, value: this.slug(newName)}]); + + if (obj?.id_parent) { + idParentXml = `${+obj.id_parent}`; + } + } + + const nameXml = cfg.nameIsMultilang + ? `${safeName}` + : `${safeName}`; + + const body = + ` + <${root}> + ${id} + ${active} + ${idParentXml} + ${nameXml} + ${linkRewriteXml} + +`; + + return this.http.put(`${this.base}/${resource}/${id}`, body, { + headers: this.headersXml, + responseType: 'text' + }); + }) + ) + ), + map(() => true) ); } - // =========================================================================== - // 4) Stock (quantité) - // =========================================================================== - - /** - * Récupère la quantité du produit dans Presta. - */ - getProductQuantity(productId: number): Observable { - return this.http - .get<{ quantity: number }>( - `${this.adminBase}/products/${productId}/quantity` - ) - .pipe(map(res => (res?.quantity ?? 0))); + delete(resource: Resource, id: number) { + return this.http.delete(`${this.base}/${resource}/${id}`, { + responseType: 'text' + }).pipe(map(() => true)); } - // =========================================================================== - // 5) Caractéristiques Complet / Notice / Etat - // =========================================================================== - - /** - * Flags Complet / Notice / Etat d'un produit. - * Signature conservée. - */ - getProductFlags( - productId: number - ): Observable<{ complete: boolean; hasManual: boolean; conditionLabel?: string }> { - return this.http.get<{ - complete: boolean; - hasManual: boolean; - conditionLabel?: string; - }>(`${this.adminBase}/products/${productId}/flags`); - } - - /** - * Liste des valeurs possibles pour la caractéristique "État". - * Signature conservée (Observable). - */ - getConditionValues(): Observable { - return this.http.get( - `${this.adminBase}/products/conditions` - ); - } - - // =========================================================================== - // 6) Création / mise à jour / suppression d'un produit - // =========================================================================== - - /** - * Construit un FormData pour envoyer : - * - le DTO produit sans les images en JSON ("meta"), - * - les fichiers images ("images"). - * - * C'est aligné avec un endpoint Spring @PostMapping(consumes = MULTIPART_FORM_DATA) - * qui prend : - * @RequestPart("meta") PsProductDto - * @RequestPart(name = "images", required = false) List - */ - private buildProductFormData(dto: PsProduct): FormData { - const fd = new FormData(); - - // On clone le DTO sans les images pour l'envoyer en JSON - const { images, ...meta } = dto as PsProduct & { images?: File[] }; - - fd.append( - 'meta', - new Blob([JSON.stringify(meta)], { type: 'application/json' }) - ); - - if (images && images.length) { - for (const img of images) { - fd.append('images', img); - } - } - - return fd; - } - - /** - * Création d'un produit Presta via ton backend. - * Signature conservée : Observable (id du produit Presta). - */ - createProduct(dto: PsProduct): Observable { - const fd = this.buildProductFormData(dto); - - return this.http - .post<{ id: number }>(`${this.adminBase}/products`, fd) - .pipe(map(res => (res && typeof res.id === 'number' ? res.id : null))); - } - - /** - * Mise à jour d'un produit Presta via ton backend. - * Signature conservée : Observable. - */ - updateProduct(id: number, dto: PsProduct): Observable { - const fd = this.buildProductFormData(dto); - - return this.http - .put(`${this.adminBase}/products/${id}`, fd) - .pipe(map(() => true)); - } - - /** - * Suppression d'un produit Presta via ton backend. - * Signature conservée : Observable. - */ - deleteProduct(id: number): Observable { - return this.http - .delete(`${this.adminBase}/products/${id}`) - .pipe(map(() => true)); - } - - // =========================================================================== - // 7) (Optionnel) Accès direct proxy /api/ps/** si tu en as encore besoin - // =========================================================================== - - /** - * Si certains composants utilisent encore directement l'ancien endpoint - * "base Presta proxy" (ex: this.base + '/categories?display=...'), - * tu peux garder ce helper pour les migrations progressives. - */ - rawGet(path: string, params?: HttpParams): Observable { - const url = `${this.proxyBase}/${path.replace(/^\/+/, '')}`; - return this.http.get(url, { - params, - responseType: 'text', + getXml(resource: Resource, id: number) { + return this.http.get(`${this.base}/${resource}/${id}`, { + responseType: 'text' }); } + + // -------- Produits (liste / détails) + + listProducts(query?: string) { + let params = new HttpParams() + .set('display', '[id,name,id_manufacturer,id_supplier,id_category_default,price]') + .set('output_format', 'JSON'); + + if (query?.trim()) { + params = params.set('filter[name]', `%[${query.trim()}]%`); + } + + // 1) On récupère la liste des produits + return this.http.get(`${this.base}/products`, {params}).pipe( + switchMap(r => { + const products: (ProductListItem & { priceHt?: number })[] = + (r?.products ?? []).map((p: any) => ({ + id: +p.id, + name: Array.isArray(p.name) ? (p.name[0]?.value ?? '') : (p.name ?? ''), + id_manufacturer: p?.id_manufacturer ? +p.id_manufacturer : undefined, + id_supplier: p?.id_supplier ? +p.id_supplier : undefined, + id_category_default: p?.id_category_default ? +p.id_category_default : undefined, + priceHt: p?.price ? +p.price : 0 + })); + + if (!products.length) { + return of([] as (ProductListItem & { priceHt?: number; quantity?: number })[]); + } + + const ids = products.map(p => p.id).join('|'); + + // 2) Une seule requête pour toutes les quantités + const stockParams = new HttpParams() + .set('display', '[id_product,quantity]') + .set('filter[id_product]', `[${ids}]`) + .set('filter[id_product_attribute]', '0') + .set('output_format', 'JSON'); + + return this.http.get(`${this.base}/stock_availables`, {params: stockParams}).pipe( + map(sr => { + const saArr: any[] = Array.isArray(sr?.stock_availables) + ? sr.stock_availables + : (sr?.stock_availables ? [sr.stock_availables] : []); + + const qtyMap = new Map(); + for (const sa of saArr) { + const pid = +sa.id_product; + const q = sa.quantity != null ? +sa.quantity : 0; + qtyMap.set(pid, q); + } + + return products.map(p => ({ + ...p, + quantity: qtyMap.get(p.id) ?? 0 + })); + }) + ); + }) + ); + } + + /** Détails produit (JSON full) + fallback XML si besoin */ + getProductDetails(id: number) { + const params = new HttpParams() + .set('output_format', 'JSON') + .set('display', 'full'); + + return this.http.get(`${this.base}/products/${id}`, {params}).pipe( + map(r => r?.products?.[0] ?? r?.product ?? r), + switchMap(p => { + let description = p?.description ?? ''; + + if (description && typeof description === 'string') { + return of({ + id: +p.id, + name: p.name, + description, + id_manufacturer: p?.id_manufacturer ? +p.id_manufacturer : undefined, + id_supplier: p?.id_supplier ? +p.id_supplier : undefined, + id_category_default: p?.id_category_default ? +p.id_category_default : undefined, + priceHt: p?.price ? +p.price : 0 + }); + } + + // Fallback XML + return this.http.get(`${this.base}/products/${id}`, {responseType: 'text'}).pipe( + map((xml: string) => { + const m = /[\s\S]*?]*>([\s\S]*?)<\/language>[\s\S]*?<\/description>/i.exec(xml); + let descXml = m ? m[1] : ''; + + if (descXml.startsWith('')) { + descXml = descXml.slice(9, -3); + } + + return { + id: +p.id, + name: p.name, + description: descXml, + id_manufacturer: p?.id_manufacturer ? +p.id_manufacturer : undefined, + id_supplier: p?.id_supplier ? +p.id_supplier : undefined, + id_category_default: p?.id_category_default ? +p.id_category_default : undefined, + priceHt: p?.price ? +p.price : 0 + }; + }) + ); + }) + ); + } + + // -------- Images + + /** Retourne les URLs publiques des images du produit (FO Presta, pas l’API) */ + getProductImageUrls(productId: number) { + const params = new HttpParams() + .set('output_format', 'JSON') + .set('display', 'full'); + + return this.http.get(`${this.base}/products/${productId}`, { params }).pipe( + map(r => { + // même logique que pour les autres méthodes : format 1 ou 2 + const p = r?.product ?? (Array.isArray(r?.products) ? r.products[0] : r); + const rawAssoc = p?.associations ?? {}; + const rawImages = rawAssoc?.images?.image ?? rawAssoc?.images ?? []; + const arr: any[] = Array.isArray(rawImages) ? rawImages : (rawImages ? [rawImages] : []); + + const ids = arr + .map(img => +img.id) + .filter(id => Number.isFinite(id) && id > 0); + + return ids.map(id => this.buildFrontImageUrl(id)); + }) + ); + } + + /** Retourne la première URL d'image (miniature) du produit, ou null s'il n'y en a pas */ + getProductThumbnailUrl(productId: number) { + return this.getProductImageUrls(productId).pipe( + map(urls => urls.length ? urls[0] : null), + catchError(() => of(null)) + ); + } + + uploadProductImage(productId: number, file: File) { + const fd = new FormData(); + fd.append('image', file); + return this.http.post(`${this.base}/images/products/${productId}`, fd); + } + + // -------- Stock (quantité) — gestion fine via stock_availables + + getProductQuantity(productId: number) { + // 1) essayer de récupérer l’id SA via les associations du produit + return this.http.get(`${this.base}/products/${productId}`, {responseType: 'text'}).pipe( + switchMap(xml => { + const m = /]*>[\s\S]*?]*>\s*(?:)?\s*<\/id>[\s\S]*?<\/stock_availables>/i.exec(xml); + const saId = m ? +m[1] : null; + + if (saId) { + const params = new HttpParams() + .set('display', '[id,quantity]') + .set('filter[id]', `${saId}`) + .set('output_format', 'JSON'); + + return this.http.get(`${this.base}/stock_availables`, {params}).pipe( + map(r => { + const sa = (r?.stock_availables ?? [])[0]; + return sa?.quantity == null ? 0 : +sa.quantity; + }) + ); + } + + // 2) fallback : recherche par id_product_attribute=0 + let p1 = new HttpParams() + .set('display', '[id,quantity,id_product_attribute]') + .set('filter[id_product]', `${productId}`) + .set('filter[id_product_attribute]', '0') + .set('output_format', 'JSON'); + + return this.http.get(`${this.base}/stock_availables`, {params: p1}).pipe( + switchMap(r => { + const sa = (r?.stock_availables ?? [])[0]; + if (sa?.quantity != null) return of(+sa.quantity); + + return this.getDefaultShopContext().pipe( + switchMap(({idShop, idShopGroup}) => { + const p2 = new HttpParams() + .set('display', '[id,quantity,id_product_attribute]') + .set('filter[id_product]', `${productId}`) + .set('filter[id_product_attribute]', '0') + .set('filter[id_shop]', `${idShop}`) + .set('filter[id_shop_group]', `${idShopGroup}`) + .set('output_format', 'JSON'); + + return this.http.get(`${this.base}/stock_availables`, {params: p2}).pipe( + map(r2 => { + const sa2 = (r2?.stock_availables ?? [])[0]; + return sa2?.quantity == null ? 0 : +sa2.quantity; + }) + ); + }) + ); + }) + ); + }) + ); + } + + private setProductQuantity(productId: number, quantity: number) { + const q = Math.max(0, Math.trunc(quantity)); + + const putFromRow = (row: any) => { + const saId = +row.id; + const idShop = row.id_shop ? +row.id_shop : undefined; + const idShopGroup = row.id_shop_group ? +row.id_shop_group : undefined; + + const extraShop = + (idShop == null ? '' : `${idShop}`) + + (idShopGroup == null ? '' : `${idShopGroup}`); + + const xml = ` + + ${saId} + ${productId} + 0 + ${extraShop} + ${q} + 0 + 0 + +`; + + return this.http.put(`${this.base}/stock_availables/${saId}`, xml, { + headers: this.headersXml, + responseType: 'text' + }).pipe(map(() => true)); + }; + + return this.http.get(`${this.base}/products/${productId}`, {responseType: 'text'}).pipe( + switchMap(xml => { + const m = /]*>[\s\S]*?]*>\s*(?:)?\s*<\/id>[\s\S]*?<\/stock_availables>/i.exec(xml); + const saId = m ? +m[1] : null; + + if (saId) { + const params = new HttpParams() + .set('display', '[id,id_product,id_product_attribute,id_shop,id_shop_group,quantity]') + .set('filter[id]', `${saId}`) + .set('output_format', 'JSON'); + + return this.http.get(`${this.base}/stock_availables`, {params}).pipe( + switchMap(r => { + const row = (r?.stock_availables ?? [])[0]; + if (row?.id) return putFromRow(row); + return of(true); + }) + ); + } + + // fallback + let p1 = new HttpParams() + .set('display', '[id,id_product,id_product_attribute,id_shop,id_shop_group,quantity]') + .set('filter[id_product]', `${productId}`) + .set('filter[id_product_attribute]', '0') + .set('output_format', 'JSON'); + + return this.http.get(`${this.base}/stock_availables`, {params: p1}).pipe( + switchMap(r => { + const row = (r?.stock_availables ?? [])[0]; + if (row?.id) return putFromRow(row); + + return this.getDefaultShopContext().pipe( + switchMap(({idShop, idShopGroup}) => { + const p2 = new HttpParams() + .set('display', '[id,id_product,id_product_attribute,id_shop,id_shop_group,quantity]') + .set('filter[id_product]', `${productId}`) + .set('filter[id_product_attribute]', '0') + .set('filter[id_shop]', `${idShop}`) + .set('filter[id_shop_group]', `${idShopGroup}`) + .set('output_format', 'JSON'); + + return this.http.get(`${this.base}/stock_availables`, {params: p2}).pipe( + switchMap(r2 => { + const row2 = (r2?.stock_availables ?? [])[0]; + if (row2?.id) return putFromRow(row2); + + console.warn('[Presta] Aucune ligne stock_available PUTtable trouvée pour product:', productId); + return of(true); + }) + ); + }) + ); + }) + ); + }) + ); + } + + // -------- Caractéristiques (Complet / Notice / Etat) + + /** Lit les caractéristiques Complet / Notice / État pour un produit donné. */ + getProductFlags(productId: number): Observable<{ complete: boolean; hasManual: boolean; conditionLabel?: string }> { + const prodParams = new HttpParams() + .set('output_format', 'JSON') + .set('display', 'full'); + + const featParams = new HttpParams() + .set('display', '[id,name]') + .set('output_format', 'JSON'); + + const valParams = new HttpParams() + .set('display', '[id,id_feature,value]') + .set('output_format', 'JSON'); + + return forkJoin({ + prod: this.http.get(`${this.base}/products/${productId}`, { params: prodParams }), + features: this.http.get(`${this.base}/product_features`, { params: featParams }), + values: this.http.get(`${this.base}/product_feature_values`, { params: valParams }), + }).pipe( + map(({ prod, features, values }) => { + // ----- Produit + const p = prod?.product ?? (prod?.products?.[0] ?? prod); + const rawPf = p?.associations?.product_features ?? []; + const pfArr: any[] = Array.isArray(rawPf) ? rawPf : (rawPf ? [rawPf] : []); + + // ----- Caractéristiques (id -> nom) + const rawFeat = features?.product_features ?? features?.product_feature ?? []; + const featArr: any[] = Array.isArray(rawFeat) ? rawFeat : (rawFeat ? [rawFeat] : []); + + const featById = new Map(); + for (const f of featArr) { + const rawName = f.name; + const label = Array.isArray(rawName) ? (rawName[0]?.value ?? '') : (rawName ?? ''); + featById.set(+f.id, String(label)); + } + + // ----- Valeurs (id -> { featureId, value }) + const rawVals = values?.product_feature_values ?? values?.product_feature_value ?? []; + const valArr: any[] = Array.isArray(rawVals) ? rawVals : (rawVals ? [rawVals] : []); + + const valById = new Map(); + for (const v of valArr) { + const rawVal = v.value; + const label = Array.isArray(rawVal) ? (rawVal[0]?.value ?? '') : (rawVal ?? ''); + valById.set(+v.id, { + featureId: +v.id_feature, + value: String(label), + }); + } + + // ----- Parcours des features du produit + let complete = false; + let hasManual = false; + let conditionLabel: string | undefined; + + for (const pf of pfArr) { + // format classique Presta: { id: id_feature, id_feature_value: id valeur } + const valueId = pf.id_feature_value == null ? (pf.id_value != null ? +pf.id_value : NaN) : +pf.id_feature_value; + if (!Number.isFinite(valueId)) continue; + + const vInfo = valById.get(valueId); + if (!vInfo) continue; + + const featName = featById.get(vInfo.featureId) ?? ''; + const featNorm = this.normalizeLabel(featName); + const valNorm = this.normalizeLabel(vInfo.value); + + if (featNorm === 'complet') { + // Complet: Oui / Non + if (valNorm === 'oui') complete = true; + if (valNorm === 'non') complete = false; + } else if (featNorm === 'notice') { + // Notice: Avec / Sans + if (valNorm === 'avec') hasManual = true; + if (valNorm === 'sans') hasManual = false; + } else if (featNorm === 'etat') { + // État: on garde juste le libellé brut + conditionLabel = vInfo.value; + } + } + + return { complete, hasManual, conditionLabel }; + }) + ); + } + + /** Resolve un couple (featureId, valueId) à partir du nom de caractéristique et de la valeur */ + private resolveFeatureValue(featureName: string, valueLabel: string): Observable<{ + featureId: number; + valueId: number + } | null> { + if (!valueLabel) return of(null); + + // 1) récupérer toutes les caractéristiques, trouver celle dont le nom normalisé correspond + const paramsFeat = new HttpParams() + .set('display', '[id,name]') + .set('output_format', 'JSON'); + + return this.http.get(`${this.base}/product_features`, {params: paramsFeat}).pipe( + switchMap(r => { + const rawFeat = r?.product_features ?? r?.product_feature ?? []; + const featArr: any[] = Array.isArray(rawFeat) ? rawFeat : (rawFeat ? [rawFeat] : []); + + const targetNorm = this.normalizeLabel(featureName); + const feat = featArr.find((f: any) => { + const raw = f.name; + const name = Array.isArray(raw) ? (raw[0]?.value ?? '') : (raw ?? ''); + return this.normalizeLabel(name) === targetNorm; + }); + + if (!feat?.id) { + console.warn('[Presta] Caractéristique introuvable:', featureName); + return of(null); + } + + const idFeature = +feat.id; + + // 2) récupérer les valeurs associées à cette caractéristique, trouver la bonne par libellé + const paramsVal = new HttpParams() + .set('display', '[id,id_feature,value]') + .set('filter[id_feature]', `${idFeature}`) + .set('output_format', 'JSON'); + + return this.http.get(`${this.base}/product_feature_values`, {params: paramsVal}).pipe( + map(rv => { + const rawVals = rv?.product_feature_values ?? rv?.product_feature_value ?? []; + const valArr: any[] = Array.isArray(rawVals) ? rawVals : (rawVals ? [rawVals] : []); + + const targetValNorm = this.normalizeLabel(valueLabel); + const val = valArr.find((v: any) => { + const raw = v.value; + const label = Array.isArray(raw) ? (raw[0]?.value ?? '') : (raw ?? ''); + return this.normalizeLabel(label) === targetValNorm; + }); + + if (!val?.id) { + console.warn('[Presta] Valeur de caractéristique introuvable:', featureName, valueLabel); + return null; + } + + return {featureId: idFeature, valueId: +val.id}; + }) + ); + }) + ); + } + + /** Construit le bloc pour Complet/Notice/Etat */ + private buildFeaturesXml(dto: PsProduct): Observable { + const ops: Array> = [ + this.resolveFeatureValue('Complet', dto.complete ? 'Oui' : 'Non'), + this.resolveFeatureValue('Notice', dto.hasManual ? 'Avec' : 'Sans') + ]; + + if (dto.conditionLabel) { + ops.push(this.resolveFeatureValue('Etat', dto.conditionLabel)); + } + + return forkJoin(ops).pipe( + map(results => { + const feats = results.filter((x): x is { featureId: number; valueId: number } => !!x); + if (!feats.length) return ''; + + const inner = feats.map(f => + `${f.featureId}${f.valueId}` + ).join(''); + + return `${inner}`; + }) + ); + } + +// -------- Helpers internes (produit complet pour update) + private getProductForUpdate(id: number) { + const params = new HttpParams().set('output_format', 'JSON').set('display', 'full'); + + return this.http.get(`${this.base}/products/${id}`, {params}).pipe( + map(r => { + // format 1 : { product: { ... } } + if (r?.product) { + return r.product; + } + // format 2 : { products: [ { ... } ] } + if (Array.isArray(r?.products) && r.products.length) { + return r.products[0]; + } + // fallback : on renvoie brut + return r; + }) + ); + } + + /** Liste les valeurs possibles de la caractéristique "Etat" (libellés) */ + /** Liste les valeurs possibles de la caractéristique "État" (libellés) */ + /** Liste les valeurs possibles de la caractéristique "État" (avec cache mémoire) */ + getConditionValues(): Observable { + if (this.conditionValuesCache) { + return of(this.conditionValuesCache); + } + + const paramsFeat = new HttpParams() + .set('display', '[id,name]') + .set('output_format', 'JSON'); + + return this.http.get(`${this.base}/product_features`, {params: paramsFeat}).pipe( + switchMap(r => { + const rawFeat = r?.product_features ?? r?.product_feature ?? []; + const featArr: any[] = Array.isArray(rawFeat) ? rawFeat : (rawFeat ? [rawFeat] : []); + + const targetNorm = this.normalizeLabel('État'); // "État", "etat", etc. + const feat = featArr.find((f: any) => { + const raw = f.name; + const name = Array.isArray(raw) ? (raw[0]?.value ?? '') : (raw ?? ''); + return this.normalizeLabel(name) === targetNorm; + }); + + if (!feat?.id) { + console.warn('[Presta] Caractéristique "État" introuvable'); + this.conditionValuesCache = []; + return of([]); + } + + const idFeature = +feat.id; + + const paramsVal = new HttpParams() + .set('display', '[id,id_feature,value]') + .set('filter[id_feature]', `${idFeature}`) + .set('output_format', 'JSON'); + + return this.http.get(`${this.base}/product_feature_values`, {params: paramsVal}).pipe( + map(rv => { + const rawVals = rv?.product_feature_values ?? rv?.product_feature_value ?? []; + const vals: any[] = Array.isArray(rawVals) ? rawVals : (rawVals ? [rawVals] : []); + + const list = vals + .map(v => { + const raw = v.value; + if (Array.isArray(raw)) return raw[0]?.value ?? ''; + return String(raw ?? ''); + }) + .filter((x: string) => !!x); + + this.conditionValuesCache = list; + return list; + }) + ); + }) + ); + } + + /** Construit l’URL publique d’une image produit Presta à partir de son id_image */ + private buildFrontImageUrl(imageId: number): string { + const idStr = String(imageId); + const path = idStr.split('').join('/'); // "123" -> "1/2/3" + return `${this.frontBase}/img/p/${path}/${idStr}.jpg`; + } + + // -------- Description (désormais: uniquement la description libre) + private buildAugmentedDescription(dto: PsProduct): string { + return dto.description?.trim() || ''; + } + + // -------- Create & Update produit + + createProduct(dto: PsProduct) { + const priceHt = this.ttcToHt(dto.priceTtc, dto.vatRate); + + return forkJoin({ + idLang: this.getDefaultLangId(), + idTaxGroup: this.getDefaultTaxRulesGroupId(), + shop: this.getDefaultShopContext(), + homeCat: this.getHomeCategoryId(), + rootCat: this.getRootCategoryId(), + featuresXml: this.buildFeaturesXml(dto), + }).pipe( + switchMap(({idLang, idTaxGroup, shop, homeCat, rootCat, featuresXml}) => { + const desc = this.buildAugmentedDescription(dto); + + const xml = + ` + + ${dto.manufacturerId} + ${dto.supplierId} + ${dto.categoryId} + ${shop.idShop} + ${idTaxGroup} + + standard + standard + 1 + 1 + + ${priceHt} + 1 + both + 1 + 1 + 1 + + ${this.escapeXml(dto.name)} + ${this.escapeXml(this.slug(dto.name))} + ${this.escapeXml(desc)} + + + + ${rootCat} + ${homeCat} + ${dto.categoryId} + + ${featuresXml} + + +`; + + return this.http.post(`${this.base}/products`, xml, { + headers: this.headersXml, + responseType: 'text' + }).pipe( + map((res: string) => this.extractIdFromXml(res)) + ); + }), + switchMap((productId) => { + if (!productId) return of(null); + + const ops: Array> = []; + + if (dto.images?.length) { + ops.push(forkJoin(dto.images.map(f => this.uploadProductImage(productId, f)))); + } + + ops.push(this.setProductQuantity(productId, dto.quantity)); + + return ops.length ? forkJoin(ops).pipe(map(() => productId)) : of(productId); + }) + ); + } + + updateProduct(id: number, dto: PsProduct) { + const priceHt = this.ttcToHt(dto.priceTtc, dto.vatRate); + + return forkJoin({ + idLang: this.getDefaultLangId(), + prod: this.getProductForUpdate(id), + idTaxGroup: this.getDefaultTaxRulesGroupId(), + shop: this.getDefaultShopContext(), + homeCat: this.getHomeCategoryId(), + rootCat: this.getRootCategoryId(), + featuresXml: this.buildFeaturesXml(dto), + }).pipe( + switchMap(({idLang, prod, idTaxGroup, shop, homeCat, rootCat, featuresXml}) => { + const active = +prod?.active || 1; + + const lr = Array.isArray(prod?.link_rewrite) + ? prod.link_rewrite.map((l: any) => ({id: +l.id, value: String(l.value ?? '')})) + : [{id: idLang, value: this.slug(dto.name)}]; + + const lrXml = + `${ + lr.map((e: { id: any; value: string }) => + `${this.escapeXml(e.value)}` + ).join('') + }`; + + const desc = this.buildAugmentedDescription(dto); + + const xml = + ` + + ${id} + ${active} + + ${dto.manufacturerId} + ${dto.supplierId} + ${dto.categoryId} + ${shop.idShop} + ${idTaxGroup} + + standard + standard + 1 + 1 + + ${priceHt} + both + 1 + 1 + 1 + + ${this.escapeXml(dto.name)} + ${lrXml} + ${this.escapeXml(desc)} + + + + ${rootCat} + ${homeCat} + ${dto.categoryId} + + ${featuresXml} + + +`; + + return this.http.put(`${this.base}/products/${id}`, xml, { + headers: this.headersXml, + responseType: 'text' + }); + }), + switchMap(() => { + const ops: Array> = []; + + if (dto.images?.length) { + ops.push(forkJoin(dto.images.map(f => this.uploadProductImage(id, f)))); + } + + ops.push(this.setProductQuantity(id, dto.quantity)); + + return ops.length ? forkJoin(ops) : of(true); + }), + map(() => true) + ); + } + + deleteProduct(id: number) { + return this.http.delete(`${this.base}/products/${id}`, { + responseType: 'text' + }).pipe( + map(() => true) + ); + } }