import {inject, Injectable} from '@angular/core'; import {HttpClient, HttpHeaders, HttpParams} from '@angular/common/http'; import {forkJoin, map, of, switchMap, Observable, catchError, from} 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 {resizeImage} from '../utils/image-utils'; type Resource = 'categories' | 'manufacturers' | 'suppliers'; 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' // -------- Utils private readonly headersXml = new HttpHeaders({ 'Content-Type': 'application/xml', 'Accept': 'application/xml' }); private conditionValuesCache: string[] | null = null; private normalizeLabel(s: string): string { return String(s ?? '') .normalize('NFD') .replaceAll(/[\u0300-\u036f]/g, '') .toLowerCase() .trim(); } private escapeXml(v: string) { return String(v) .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll("'", '''); } 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)) ); } const xml = resource === 'manufacturers' ? `1${safeName}` : `1${safeName}`; return this.http.post(`${this.base}/${resource}`, xml, { headers: this.headersXml, responseType: 'text' }).pipe( map((res: string) => this.extractIdFromXml(res)) ); } update(resource: Resource, id: number, newName: string) { const cfg = UPDATE_CFG[resource]; const safeName = this.escapeXml(newName); const defaultLangOr1$ = cfg.needsDefaultLang ? this.getDefaultLangId() : this.getDefaultLangId().pipe(map(() => 1)); 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) ); } delete(resource: Resource, id: number) { return this.http.delete(`${this.base}/${resource}/${id}`, { responseType: 'text' }).pipe(map(() => true)); } 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) { // 1) Compression AVANT upload return from(resizeImage(file, 1600, 1600, 0.8)).pipe( switchMap(compressedFile => { const fd = new FormData(); fd.append('image', compressedFile); // ← image compressée // 2) Envoi vers ton backend (identique à avant) return this.http.post( `${this.base}/images/products/${productId}`, fd, { reportProgress: true, observe: 'events' } ); }) ); } deleteProductImage(productId: number, imageId: number) { // Presta : DELETE /images/products/{id_product}/{id_image} return this.http.delete( `${this.base}/images/products/${productId}/${imageId}`, { responseType: 'text' } ).pipe( map(() => true) ); } // -------- 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) ); } }