1110 lines
38 KiB
TypeScript
1110 lines
38 KiB
TypeScript
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('&', '&')
|
||
.replaceAll('<', '<')
|
||
.replaceAll('>', '>')
|
||
.replaceAll('"', '"')
|
||
.replaceAll("'", ''');
|
||
}
|
||
|
||
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 d’extraire <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 l’API) */
|
||
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 l’id 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 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 =
|
||
`<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)
|
||
);
|
||
}
|
||
}
|