Files
gameovergne-app/client/src/app/services/prestashop.serivce.ts

1110 lines
38 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<Resource, {
root: 'category' | 'manufacturer' | 'supplier';
needsDefaultLang?: boolean;
keepFields?: string[];
nameIsMultilang?: boolean;
}> = {
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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&apos;');
}
private extractIdFromXml(xml: string): number | null {
const s = String(xml);
// 1) cas le plus sûr : <product ...><id><![CDATA[123]]></id> ... (avant <associations>)
const mProduct = /<product[\s\S]*?<id[^>]*>\s*(?:<!\[CDATA\[)?(\d+)(?:]]>)?\s*<\/id>[\s\S]*?(?:<associations>|<\/product>)/i.exec(s);
if (mProduct) return +mProduct[1];
// 2) racine quelconque
const mRoot = /<prestashop[\s\S]*?<([a-z_]+)[^>]*>[\s\S]*?<id[^>]*>\s*(?:<!\[CDATA\[)?(\d+)(?:]]>)?\s*<\/id>/i.exec(s);
if (mRoot) return +mRoot[2];
// 3) fallback
const mAny = /<id[^>]*>\s*(?:<!\[CDATA\[)?(\d+)(?:]]>)?\s*<\/id>/i.exec(s);
if (mAny) return +mAny[1];
console.warn('[Presta] Impossible dextraire <id> 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 => `<language id="${e.id}">${this.escapeXml(e.value)}</language>`)
.join('');
return `<${tag}>${inner}</${tag}>`;
}
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<any>(`${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<any>(`${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<any>(`${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<any>(`${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<any>(`${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<any>(`${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<any>(`${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<any>(`${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<any>(`${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 =
`<prestashop xmlns:xlink="http://www.w3.org/1999/xlink">
<category>
<id_parent>2</id_parent>
<active>1</active>
${this.toLangBlock('name', langIds.map((id: any) => ({id, value: safeName})))}
${this.toLangBlock('link_rewrite', langIds.map((id: any) => ({id, value: this.slug(name)})))}
</category>
</prestashop>`;
return this.http.post(`${this.base}/categories`, xml, {
headers: this.headersXml,
responseType: 'text'
});
}),
map(res => this.extractIdFromXml(res))
);
}
const xml =
resource === 'manufacturers'
? `<prestashop xmlns:xlink="http://www.w3.org/1999/xlink"><manufacturer><active>1</active><name>${safeName}</name></manufacturer></prestashop>`
: `<prestashop xmlns:xlink="http://www.w3.org/1999/xlink"><supplier><active>1</active><name>${safeName}</name></supplier></prestashop>`;
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 = `<id_parent>${+obj.id_parent}</id_parent>`;
}
}
const nameXml = cfg.nameIsMultilang
? `<name><language id="${idLang}">${safeName}</language></name>`
: `<name>${safeName}</name>`;
const body =
`<prestashop xmlns:xlink="http://www.w3.org/1999/xlink">
<${root}>
<id>${id}</id>
<active>${active}</active>
${idParentXml}
${nameXml}
${linkRewriteXml}
</${root}>
</prestashop>`;
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<any>(`${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<any>(`${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<number, number>();
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<any>(`${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 = /<description>[\s\S]*?<language[^>]*>([\s\S]*?)<\/language>[\s\S]*?<\/description>/i.exec(xml);
let descXml = m ? m[1] : '';
if (descXml.startsWith('<![CDATA[') && descXml.endsWith(']]>')) {
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 lAPI) */
getProductImageUrls(productId: number) {
const params = new HttpParams()
.set('output_format', 'JSON')
.set('display', 'full');
return this.http.get<any>(`${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 lid SA via les associations du produit
return this.http.get(`${this.base}/products/${productId}`, {responseType: 'text'}).pipe(
switchMap(xml => {
const m = /<stock_availables[\s\S]*?<stock_available[^>]*>[\s\S]*?<id[^>]*>\s*(?:<!\[CDATA\[)?(\d+)(?:]]>)?\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<any>(`${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<any>(`${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<any>(`${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 ? '' : `<id_shop>${idShop}</id_shop>`) +
(idShopGroup == null ? '' : `<id_shop_group>${idShopGroup}</id_shop_group>`);
const xml = `<prestashop xmlns:xlink="http://www.w3.org/1999/xlink">
<stock_available>
<id>${saId}</id>
<id_product>${productId}</id_product>
<id_product_attribute>0</id_product_attribute>
${extraShop}
<quantity>${q}</quantity>
<depends_on_stock>0</depends_on_stock>
<out_of_stock>0</out_of_stock>
</stock_available>
</prestashop>`;
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 = /<stock_availables[\s\S]*?<stock_available[^>]*>[\s\S]*?<id[^>]*>\s*(?:<!\[CDATA\[)?(\d+)(?:]]>)?\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<any>(`${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<any>(`${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<any>(`${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<any>(`${this.base}/products/${productId}`, { params: prodParams }),
features: this.http.get<any>(`${this.base}/product_features`, { params: featParams }),
values: this.http.get<any>(`${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<number, string>();
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<number, { featureId: number; value: string }>();
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<any>(`${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<any>(`${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 <product_features>…</product_features> pour Complet/Notice/Etat */
private buildFeaturesXml(dto: PsProduct): Observable<string> {
const ops: Array<Observable<{ featureId: number; valueId: number } | null>> = [
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 =>
`<product_feature><id>${f.featureId}</id><id_feature_value>${f.valueId}</id_feature_value></product_feature>`
).join('');
return `<product_features>${inner}</product_features>`;
})
);
}
// -------- 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<any>(`${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<string[]> {
if (this.conditionValuesCache) {
return of(this.conditionValuesCache);
}
const paramsFeat = new HttpParams()
.set('display', '[id,name]')
.set('output_format', 'JSON');
return this.http.get<any>(`${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<string[]>([]);
}
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<any>(`${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 lURL publique dune 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 =
`<prestashop xmlns:xlink="http://www.w3.org/1999/xlink">
<product>
<id_manufacturer>${dto.manufacturerId}</id_manufacturer>
<id_supplier>${dto.supplierId}</id_supplier>
<id_category_default>${dto.categoryId}</id_category_default>
<id_shop_default>${shop.idShop}</id_shop_default>
<id_tax_rules_group>${idTaxGroup}</id_tax_rules_group>
<type>standard</type>
<product_type>standard</product_type>
<state>1</state>
<minimal_quantity>1</minimal_quantity>
<price>${priceHt}</price>
<active>1</active>
<visibility>both</visibility>
<available_for_order>1</available_for_order>
<show_price>1</show_price>
<indexed>1</indexed>
<name><language id="${idLang}">${this.escapeXml(dto.name)}</language></name>
<link_rewrite><language id="${idLang}">${this.escapeXml(this.slug(dto.name))}</language></link_rewrite>
<description><language id="${idLang}">${this.escapeXml(desc)}</language></description>
<associations>
<categories>
<category><id>${rootCat}</id></category>
<category><id>${homeCat}</id></category>
<category><id>${dto.categoryId}</id></category>
</categories>
${featuresXml}
</associations>
</product>
</prestashop>`;
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<Observable<unknown>> = [];
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 =
`<link_rewrite>${
lr.map((e: { id: any; value: string }) =>
`<language id="${e.id}">${this.escapeXml(e.value)}</language>`
).join('')
}</link_rewrite>`;
const desc = this.buildAugmentedDescription(dto);
const xml =
`<prestashop xmlns:xlink="http://www.w3.org/1999/xlink">
<product>
<id>${id}</id>
<active>${active}</active>
<id_manufacturer>${dto.manufacturerId}</id_manufacturer>
<id_supplier>${dto.supplierId}</id_supplier>
<id_category_default>${dto.categoryId}</id_category_default>
<id_shop_default>${shop.idShop}</id_shop_default>
<id_tax_rules_group>${idTaxGroup}</id_tax_rules_group>
<type>standard</type>
<product_type>standard</product_type>
<state>1</state>
<minimal_quantity>1</minimal_quantity>
<price>${priceHt}</price>
<visibility>both</visibility>
<available_for_order>1</available_for_order>
<show_price>1</show_price>
<indexed>1</indexed>
<name><language id="${idLang}">${this.escapeXml(dto.name)}</language></name>
${lrXml}
<description><language id="${idLang}">${this.escapeXml(desc)}</language></description>
<associations>
<categories>
<category><id>${rootCat}</id></category>
<category><id>${homeCat}</id></category>
<category><id>${dto.categoryId}</id></category>
</categories>
${featuresXml}
</associations>
</product>
</prestashop>`;
return this.http.put(`${this.base}/products/${id}`, xml, {
headers: this.headersXml,
responseType: 'text'
});
}),
switchMap(() => {
const ops: Array<Observable<unknown>> = [];
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)
);
}
}