refactor: reorganize component files and update import paths; add PsItem and PsProduct interfaces

This commit is contained in:
Vincent Guillet
2025-11-12 12:34:58 +01:00
parent f063a245b9
commit bcc71b965b
92 changed files with 1694 additions and 2815 deletions

View File

@@ -0,0 +1,726 @@
import {inject, Injectable} from '@angular/core';
import {HttpClient, HttpHeaders, HttpParams} from '@angular/common/http';
import {forkJoin, map, of, switchMap, Observable} from 'rxjs';
import {PsItem} from '../interfaces/ps-item';
import {PsProduct} from '../interfaces/ps-product';
import {ProductListItem} from '../interfaces/product-list-item';
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 = '/ps'; // proxy Angular -> https://.../api
// -------- Utils
/** Id de la catégorie daccueil (Home) de la boutique: 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) // fallback 2
);
}
/** Id de la catégorie racine (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) // fallback 1
);
}
private readonly headersXml = new HttpHeaders({'Content-Type': 'application/xml', 'Accept': 'application/xml'});
private escapeXml(v: string) {
return String(v)
.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;')
.replaceAll('"', '&quot;').replaceAll("'", '&apos;');
}
/** Extrait un <id> y compris avec CDATA et attributs éventuels */
private extractIdFromXml(xml: string): number | null {
const s = String(xml);
// 1) prioritaire: id du noeud <product> (avant <associations>)
const mProduct = s.match(
/<product[\s\S]*?<id[^>]*>\s*(?:<!\[CDATA\[)?(\d+)(?:\]\]>)?\s*<\/id>[\s\S]*?(?:<associations>|<\/product>)/i
);
if (mProduct) return +mProduct[1];
// 2) racine quelconque (tolère CDATA)
const mRoot = s.match(
/<prestashop[\s\S]*?<([a-z_]+)[^>]*>[\s\S]*?<id[^>]*>\s*(?:<!\[CDATA\[)?(\d+)(?:\]\]>)?\s*<\/id>/i
);
if (mRoot) return +mRoot[2];
// 3) fallback: premier <id> numérique
const mAny = s.match(/<id[^>]*>\s*(?:<!\[CDATA\[)?(\d+)(?:\]\]>)?\s*<\/id>/i);
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 round2(n: number) {
return Math.round((n + Number.EPSILON) * 100) / 100;
}
private ttcToHt(priceTtc: number, rate = 0.2) {
return this.round2(priceTtc / (1 + rate));
}
// -------- Contexte, langues, fiscalité
/** 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)
);
}
/** Premier tax_rule_group actif (fallback TVA) */
private getFirstActiveTaxRulesGroupId() {
const params = new HttpParams()
.set('display', '[id,active]')
.set('filter[active]', '1')
.set('output_format', 'JSON');
return this.http.get<any>(`${this.base}/tax_rule_groups`, {params}).pipe(
map(r => {
const g = (r?.tax_rule_groups ?? [])[0];
return g ? +g.id : 0;
})
);
}
/** Groupe de taxes par défaut (PS_TAX_DEFAULT_RULES_GROUP) avec fallback sur un groupe actif */
private getDefaultTaxRulesGroupId() {
const params = 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}).pipe(
switchMap(r => {
const id = +r?.configurations?.[0]?.value;
if (Number.isFinite(id) && id > 0) return of(id);
return this.getFirstActiveTaxRulesGroupId();
})
);
}
/** Devise par défaut (PS_CURRENCY_DEFAULT) */
private getDefaultCurrencyId() {
const params = new HttpParams()
.set('display', '[value]')
.set('filter[name]', 'PS_CURRENCY_DEFAULT')
.set('output_format', 'JSON');
return this.http.get<any>(`${this.base}/configurations`, {params}).pipe(
map(r => +r?.configurations?.[0]?.value || 1)
);
}
/** IDs des langues actives (pour création catégorie multilangue) */
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 (1ère boutique active) */
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};
})
);
}
/** Objet complet pour update sûr (cat/manu/supplier) */
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 => (r?.[resource] ?? []).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))
);
}
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: number) => ({id, value: safeName})))}
${this.toLangBlock('link_rewrite', langIds.map((id: number) => ({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 = '', 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()}]%`);
return this.http.get<any>(`${this.base}/products`, {params}).pipe(
map(r => (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
}) as ProductListItem & { priceHt?: number }))
);
}
/** Détails produit (JSON full) + fallback XML pour description si vide */
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?.product ?? r),
switchMap(p => {
let description = Array.isArray(p?.description) ? (p.description[0]?.value ?? '') : (p?.description ?? '');
if (description && typeof description === 'string') {
return of({
id: +p.id,
name: Array.isArray(p.name) ? (p.name[0]?.value ?? '') : (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 : extraire <description>…</description> et enlever CDATA si présent
return this.http.get(`${this.base}/products/${id}`, {responseType: 'text'}).pipe(
map((xml: string) => {
const m = xml.match(/<description>[\s\S]*?<language[^>]*>([\s\S]*?)<\/language>[\s\S]*?<\/description>/i);
let descXml = m ? m[1] : '';
if (descXml.startsWith('<![CDATA[') && descXml.endsWith(']]>')) {
descXml = descXml.slice(9, -3);
}
return {
id: +p.id,
name: Array.isArray(p.name) ? (p.name[0]?.value ?? '') : (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
private getProductImageIds(productId: number) {
return this.http.get<any>(`${this.base}/images/products/${productId}`, {responseType: 'json' as any}).pipe(
map(r => {
const arr = (r?.image ?? r?.images ?? []) as Array<any>;
return Array.isArray(arr) ? arr.map(x => +x.id) : [];
})
);
}
getProductImageUrls(productId: number) {
return this.getProductImageIds(productId).pipe(
map(ids => ids.map(idImg => `${this.base}/images/products/${productId}/${idImg}`))
);
}
uploadProductImage(productId: number, file: File) {
const fd = new FormData();
fd.append('image', file);
return this.http.post(`${this.base}/images/products/${productId}`, fd);
}
// --- Stock (quantité) — sans POST, uniquement PUT
/** Lis la quantité (prend exactement la ligne stock_available utilisée par Presta) */
getProductQuantity(productId: number) {
// 1) on essaie dabord de récupérer lid SA via les associations du produit (le plus fiable)
return this.http.get(`${this.base}/products/${productId}`, {responseType: 'text'}).pipe(
switchMap(xml => {
const m = xml.match(
/<stock_availables[\s\S]*?<stock_available[^>]*>[\s\S]*?<id[^>]*>\s*(?:<!\[CDATA\[)?(\d+)(?:\]\]>)?\s*<\/id>[\s\S]*?<\/stock_availables>/i
);
const saId = m ? +m[1] : null;
if (saId) {
// lire la ligne SA par son id → on lit quantity
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 ? +sa.quantity : 0;
})
);
}
// 2) fallback: rechercher la ligne par id_product_attribute=0 (sans filtre shop → puis avec)
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 ? +sa2.quantity : 0;
})
);
})
);
})
);
})
);
}
/** PUT quantité : on cible précisément la ligne SA (avec id_shop/id_shop_group réels) */
private setProductQuantity(productId: number, quantity: number) {
const q = Math.max(0, Math.trunc(quantity));
// Helper: fait un PUT en reprenant tous les champs utiles de la ligne SA existante
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));
};
// 1) récupérer lid SA via les associations du produit (le plus fiable)
return this.http.get(`${this.base}/products/${productId}`, {responseType: 'text'}).pipe(
switchMap(xml => {
const m = xml.match(
/<stock_availables[\s\S]*?<stock_available[^>]*>[\s\S]*?<id[^>]*>\s*(?:<!\[CDATA\[)?(\d+)(?:\]\]>)?\s*<\/id>[\s\S]*?<\/stock_availables>/i
);
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);
// si lid renvoyé nest pas lisible (peu probable), on bascule sur le fallback ci-dessous
return of(null);
})
);
}
// 2) fallback: rechercher la ligne par id_product_attribute=0 (sans filtre shop → puis avec)
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); // on ne bloque pas le flux si la ligne est introuvable
})
);
})
);
})
);
})
);
}
// --- Association fournisseur (product_suppliers)
private findProductSupplierId(productId: number, supplierId: number) {
const params = new HttpParams()
.set('display', '[id,id_product,id_supplier,id_product_attribute]')
.set('filter[id_product]', `${productId}`)
.set('filter[id_supplier]', `${supplierId}`)
.set('filter[id_product_attribute]', '0')
.set('output_format', 'JSON');
return this.http.get<any>(`${this.base}/product_suppliers`, {params}).pipe(
map(r => {
const row = (r?.product_suppliers ?? [])[0];
return row?.id ? +row.id : null;
})
);
}
private upsertProductSupplier(productId: number, supplierId: number) {
if (!supplierId) return of(true);
return forkJoin({
curId: this.getDefaultCurrencyId(),
psId: this.findProductSupplierId(productId, supplierId),
}).pipe(
switchMap(({curId, psId}) => {
const body = (id?: number) => `<prestashop xmlns:xlink="http://www.w3.org/1999/xlink">
<product_supplier>
${id ? `<id>${id}</id>` : ''}
<id_product>${productId}</id_product>
<id_product_attribute>0</id_product_attribute>
<id_supplier>${supplierId}</id_supplier>
<id_currency>${curId}</id_currency>
<product_supplier_reference></product_supplier_reference>
<product_supplier_price_te>0</product_supplier_price_te>
</product_supplier>
</prestashop>`;
if (psId) {
return this.http.put(`${this.base}/product_suppliers/${psId}`, body(psId), {
headers: this.headersXml, responseType: 'text'
}).pipe(map(() => true));
} else {
return this.http.post(`${this.base}/product_suppliers`, body(), {
headers: this.headersXml, responseType: 'text'
}).pipe(map(() => true));
}
})
);
}
// --- Create & Update produit
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 => r?.product ?? r));
}
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(),
}).pipe(
switchMap(({idLang, idTaxGroup, shop, homeCat, rootCat}) => {
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>${dto.categoryId}</id></category>
</categories>
</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));
if (dto.supplierId) ops.push(this.upsertProductSupplier(productId, dto.supplierId));
return forkJoin(ops).pipe(map(() => 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(),
}).pipe(
switchMap(({idLang, prod, idTaxGroup, shop, homeCat, rootCat}) => {
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> <!-- NEW -->
<product_type>standard</product_type><!-- NEW -->
<state>1</state> <!-- NEW -->
<minimal_quantity>1</minimal_quantity><!-- NEW -->
<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>${dto.categoryId}</id></category>
</categories>
</associations>
</product>
</prestashop>`;
return this.http.put(`${this.base}/products/${id}`, xml, {headers: this.headersXml, responseType: 'text'})
.pipe(map(() => id));
}),
switchMap(productId => {
const ops: Array<Observable<unknown>> = [this.setProductQuantity(productId, dto.quantity)];
if (dto.supplierId) ops.push(this.upsertProductSupplier(productId, dto.supplierId));
return forkJoin(ops).pipe(map(() => true));
})
);
}
deleteProduct(id: number) {
return this.http.delete(`${this.base}/products/${id}`, {responseType: 'text'}).pipe(map(() => true));
}
// -------- Description augmentée
private buildAugmentedDescription(dto: PsProduct): string {
const parts: string[] = [];
if (dto.description?.trim()) parts.push(dto.description.trim());
const flags: string[] = [];
flags.push(`Complet: ${dto.complete ? 'Oui' : 'Non'}`);
flags.push(`Notice: ${dto.hasManual ? 'Oui' : 'Non'}`);
if (dto.conditionLabel) flags.push(`État: ${dto.conditionLabel}`);
parts.push('', flags.join(' | '));
return parts.join('\n').trim();
}
}