From aead87b1bc13eb7cddc11e0c2907e4d3d7c260ca Mon Sep 17 00:00:00 2001 From: Vincent Guillet Date: Sat, 29 Nov 2025 10:43:57 +0100 Subject: [PATCH] Refactor PrestashopService to improve API integration and enhance product management functionality --- client/src/app/services/prestashop.serivce.ts | 1184 ++--------------- 1 file changed, 131 insertions(+), 1053 deletions(-) diff --git a/client/src/app/services/prestashop.serivce.ts b/client/src/app/services/prestashop.serivce.ts index 7e6f41a..6d539ca 100644 --- a/client/src/app/services/prestashop.serivce.ts +++ b/client/src/app/services/prestashop.serivce.ts @@ -1,1087 +1,165 @@ -import {inject, Injectable} from '@angular/core'; -import {HttpClient, HttpHeaders, HttpParams} from '@angular/common/http'; -import {forkJoin, map, of, switchMap, Observable, catchError} from 'rxjs'; -import {PsItem} from '../interfaces/ps-item'; -import {PsProduct} from '../interfaces/ps-product'; -import {ProductListItem} from '../interfaces/product-list-item'; -import {environment} from '../../environments/environment'; +import { inject, Injectable } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Observable, map } from 'rxjs'; -type Resource = 'categories' | 'manufacturers' | 'suppliers'; +import { PsItem } from '../interfaces/ps-item'; +import { PsProduct } from '../interfaces/ps-product'; +import { ProductListItem } from '../interfaces/product-list-item'; +import { environment } from '../../environments/environment'; -const UPDATE_CFG: Record = { - categories: { - root: 'category', - needsDefaultLang: true, - keepFields: ['active', 'id_parent', 'link_rewrite'], - nameIsMultilang: true - }, - manufacturers: { - root: 'manufacturer', - needsDefaultLang: false, - keepFields: ['active'], - nameIsMultilang: false - }, - suppliers: { - root: 'supplier', - needsDefaultLang: false, - keepFields: ['active'], - nameIsMultilang: false - }, -}; +export type Resource = 'categories' | 'manufacturers' | 'suppliers'; -@Injectable({providedIn: 'root'}) +export interface ProductFlags { + complete: boolean; + hasManual: boolean; + conditionLabel?: string | null; +} + +@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' - }); + /** + * Base de l'API Spring. + * Exemple dans environment: + * apiUrl: '/gameovergne-api/api' + */ + private readonly base = `${environment.apiUrl}/ps-admin`; - private conditionValuesCache: string[] | null = null; + // --------------------------------------------------------------------------- + // RÉFÉRENTIELS SIMPLES : categories / manufacturers / suppliers + // --------------------------------------------------------------------------- - private normalizeLabel(s: string): string { - return String(s ?? '') - .normalize('NFD') - .replaceAll(/[\u0300-\u036f]/g, '') - .toLowerCase() - .trim(); + list(resource: Resource): Observable { + return this.http.get(`${this.base}/${resource}`); } - private escapeXml(v: string) { - return String(v) - .replaceAll('&', '&') - .replaceAll('<', '<') - .replaceAll('>', '>') - .replaceAll('"', '"') - .replaceAll("'", '''); + create(resource: Resource, name: string): Observable { + const params = new HttpParams().set('name', name); + return this.http.post(`${this.base}/${resource}`, null, { params }); } - private extractIdFromXml(xml: string): number | null { - const s = String(xml); - - // 1) cas le plus sûr : ... (avant ) - const mProduct = /]*>\s*(?:)?\s*<\/id>[\s\S]*?(?:|<\/product>)/i.exec(s); - if (mProduct) return +mProduct[1]; - - // 2) racine quelconque - const mRoot = /]*>[\s\S]*?]*>\s*(?:)?\s*<\/id>/i.exec(s); - if (mRoot) return +mRoot[2]; - - // 3) fallback - const mAny = /]*>\s*(?:)?\s*<\/id>/i.exec(s); - if (mAny) return +mAny[1]; - - console.warn('[Presta] Impossible d’extraire depuis la réponse:', xml); - return null; + update(resource: Resource, id: number, newName: string): Observable { + const params = new HttpParams().set('name', newName); + return this.http.put(`${this.base}/${resource}/${id}`, null, { params }); } - private slug(str: string) { - return String(str).toLowerCase() - .normalize('NFD') - .replaceAll(/[\u0300-\u036f]/g, '') - .replaceAll(/[^a-z0-9]+/g, '-') - .replaceAll(/(^-|-$)/g, ''); + delete(resource: Resource, id: number): Observable { + return this.http.delete(`${this.base}/${resource}/${id}`); } - private toLangBlock(tag: string, entries: Array<{ id: number; value: string }>) { - const inner = entries - .map(e => `${this.escapeXml(e.value)}`) - .join(''); - return `<${tag}>${inner}`; - } - - private ensureArrayLang(v: any): Array<{ id: number; value: string }> { - if (Array.isArray(v)) { - return v.map(x => ({id: +x.id, value: String(x.value ?? '')})); - } - return []; - } - - private ttcToHt(priceTtc: number, rate = 0.2) { - const raw = priceTtc / (1 + rate); - return Number(raw.toFixed(6)); - } - - // -------- Contexte & langues - - /** Langue par défaut (PS_LANG_DEFAULT) */ - getDefaultLangId() { - const params = new HttpParams() - .set('display', '[value]') - .set('filter[name]', 'PS_LANG_DEFAULT') - .set('output_format', 'JSON'); - - return this.http.get(`${this.base}/configurations`, {params}).pipe( - map(r => +r?.configurations?.[0]?.value || 1) - ); - } - - /** Groupe de taxes par défaut (PS_TAX_DEFAULT_RULES_GROUP avec fallback sur le 1er groupe actif) */ - private getDefaultTaxRulesGroupId() { - const cfgParams = new HttpParams() - .set('display', '[value]') - .set('filter[name]', 'PS_TAX_DEFAULT_RULES_GROUP') - .set('output_format', 'JSON'); - - return this.http.get(`${this.base}/configurations`, { params: cfgParams }).pipe( - switchMap(r => { - const raw = r?.configurations?.[0]?.value; - const cfgId = Number(raw); - - if (Number.isFinite(cfgId) && cfgId > 0) { - // Cas nominal : la conf est bien renseignée - return of(cfgId); - } - - // Fallback : on prend le 1er tax_rule_group actif - const trgParams = new HttpParams() - .set('display', '[id,name,active]') - .set('filter[active]', '1') - .set('output_format', 'JSON'); - - return this.http.get(`${this.base}/tax_rule_groups`, { params: trgParams }).pipe( - map(tr => { - const rawGroups = tr?.tax_rule_groups ?? tr?.tax_rule_group ?? []; - const groups: any[] = Array.isArray(rawGroups) ? rawGroups : (rawGroups ? [rawGroups] : []); - const g = groups[0]; - const id = g ? Number(g.id) : 0; - if (!id) { - console.warn('[Presta] Aucun tax_rule_group actif trouvé, id_tax_rules_group sera 0 (pas de TVA).'); - } - return id; - }) - ); - }) - ); - } - - /** IDs des langues actives */ - getActiveLangIds() { - const params = new HttpParams() - .set('display', '[id,active]') - .set('filter[active]', '1') - .set('output_format', 'JSON'); - - return this.http.get(`${this.base}/languages`, {params}).pipe( - map(r => (r?.languages ?? []).map((l: any) => +l.id)) - ); - } - - /** Contexte boutique actif */ - private getDefaultShopContext() { - const params = new HttpParams() - .set('display', '[id,id_shop_group,active]') - .set('filter[active]', '1') - .set('output_format', 'JSON'); - - return this.http.get(`${this.base}/shops`, {params}).pipe( - map(r => { - const s = (r?.shops ?? [])[0]; - return { - idShop: s ? +s.id : 1, - idShopGroup: s ? +s.id_shop_group : 1 - }; - }) - ); - } - - /** Id catégorie Home (PS_HOME_CATEGORY) */ - private getHomeCategoryId() { - const params = new HttpParams() - .set('display', '[value]') - .set('filter[name]', 'PS_HOME_CATEGORY') - .set('output_format', 'JSON'); - - return this.http.get(`${this.base}/configurations`, {params}).pipe( - map(r => +r?.configurations?.[0]?.value || 2) - ); - } - - /** Id catégorie root (PS_ROOT_CATEGORY) */ - private getRootCategoryId() { - const params = new HttpParams() - .set('display', '[value]') - .set('filter[name]', 'PS_ROOT_CATEGORY') - .set('output_format', 'JSON'); - - return this.http.get(`${this.base}/configurations`, {params}).pipe( - map(r => +r?.configurations?.[0]?.value || 1) - ); - } - - /** Objet complet (JSON) pour update sûr */ - private getOne(resource: Resource, id: number) { - const params = new HttpParams() - .set('output_format', 'JSON') - .set('display', 'full'); - - return this.http.get(`${this.base}/${resource}/${id}`, {params}).pipe( - map(r => r?.category ?? r?.manufacturer ?? r?.supplier ?? r) - ); - } - - // -------- CRUD générique (categories/manufacturers/suppliers) - - list(resource: Resource) { - const params = new HttpParams() - .set('display', '[id,name,active]') - .set('output_format', 'JSON'); - - return this.http.get(`${this.base}/${resource}`, { params }).pipe( - map(r => { - const arr = r?.[resource] ?? []; - const items: PsItem[] = arr.map((x: any) => ({ - id: +x.id, - name: Array.isArray(x.name) ? (x.name[0]?.value ?? '') : (x.name ?? ''), - active: x.active === undefined ? undefined : !!+x.active - }) as PsItem); - - // tri global A→Z par nom - return items.sort((a, b) => - a.name.localeCompare(b.name, 'fr', { sensitivity: 'base' }) - ); - }) - ); - } - - create(resource: Resource, name: string) { - const safeName = this.escapeXml(name); - - if (resource === 'categories') { - return this.getActiveLangIds().pipe( - switchMap(langIds => { - const xml = - ` - - 2 - 1 - ${this.toLangBlock('name', langIds.map((id: any) => ({id, value: safeName})))} - ${this.toLangBlock('link_rewrite', langIds.map((id: any) => ({id, value: this.slug(name)})))} - -`; - - return this.http.post(`${this.base}/categories`, xml, { - headers: this.headersXml, - responseType: 'text' - }); - }), - map(res => this.extractIdFromXml(res)) - ); - } - - const xml = - resource === 'manufacturers' - ? `1${safeName}` - : `1${safeName}`; - - return this.http.post(`${this.base}/${resource}`, xml, { - headers: this.headersXml, - responseType: 'text' - }).pipe( - map((res: string) => this.extractIdFromXml(res)) - ); - } - - update(resource: Resource, id: number, newName: string) { - const cfg = UPDATE_CFG[resource]; - const safeName = this.escapeXml(newName); - - const defaultLangOr1$ = - cfg.needsDefaultLang ? this.getDefaultLangId() : this.getDefaultLangId().pipe(map(() => 1)); - - return defaultLangOr1$.pipe( - switchMap((idLang: number) => - this.getOne(resource, id).pipe( - switchMap(obj => { - const root = cfg.root; - const active = obj?.active === undefined ? 1 : +obj.active; - - let linkRewriteXml = ''; - let idParentXml = ''; - - if (resource === 'categories') { - const lr = this.ensureArrayLang(obj?.link_rewrite); - linkRewriteXml = lr.length - ? this.toLangBlock('link_rewrite', lr) - : this.toLangBlock('link_rewrite', [{id: idLang, value: this.slug(newName)}]); - - if (obj?.id_parent) { - idParentXml = `${+obj.id_parent}`; - } - } - - const nameXml = cfg.nameIsMultilang - ? `${safeName}` - : `${safeName}`; - - const body = - ` - <${root}> - ${id} - ${active} - ${idParentXml} - ${nameXml} - ${linkRewriteXml} - -`; - - return this.http.put(`${this.base}/${resource}/${id}`, body, { - headers: this.headersXml, - responseType: 'text' - }); - }) - ) - ), - map(() => true) - ); - } - - delete(resource: Resource, id: number) { - return this.http.delete(`${this.base}/${resource}/${id}`, { - responseType: 'text' - }).pipe(map(() => true)); - } - - getXml(resource: Resource, id: number) { - return this.http.get(`${this.base}/${resource}/${id}`, { - responseType: 'text' - }); - } - - // -------- Produits (liste / détails) - - listProducts(query?: string) { - let params = new HttpParams() - .set('display', '[id,name,id_manufacturer,id_supplier,id_category_default,price]') - .set('output_format', 'JSON'); + // --------------------------------------------------------------------------- + // PRODUITS : liste, flags, création / mise à jour / suppression + // --------------------------------------------------------------------------- + /** + * Liste des produits avec quantité, basée sur l’API Spring. + * + * Le backend renvoie un DTO en camelCase : + * { + * id: number; + * name: string; + * manufacturerId?: number; + * supplierId?: number; + * categoryId?: number; + * priceHt?: number; + * quantity?: number; + * } + * + * On le remappe sur ton interface existante ProductListItem + * (id_manufacturer, id_supplier, id_category_default, etc.). + */ + listProducts(query?: string): Observable { + let params = new HttpParams(); if (query?.trim()) { - params = params.set('filter[name]', `%[${query.trim()}]%`); + params = params.set('q', query.trim()); } - // 1) On récupère la liste des produits - return this.http.get(`${this.base}/products`, {params}).pipe( - switchMap(r => { - const products: (ProductListItem & { priceHt?: number })[] = - (r?.products ?? []).map((p: any) => ({ - id: +p.id, - name: Array.isArray(p.name) ? (p.name[0]?.value ?? '') : (p.name ?? ''), - id_manufacturer: p?.id_manufacturer ? +p.id_manufacturer : undefined, - id_supplier: p?.id_supplier ? +p.id_supplier : undefined, - id_category_default: p?.id_category_default ? +p.id_category_default : undefined, - priceHt: p?.price ? +p.price : 0 - })); - - if (!products.length) { - return of([] as (ProductListItem & { priceHt?: number; quantity?: number })[]); - } - - const ids = products.map(p => p.id).join('|'); - - // 2) Une seule requête pour toutes les quantités - const stockParams = new HttpParams() - .set('display', '[id_product,quantity]') - .set('filter[id_product]', `[${ids}]`) - .set('filter[id_product_attribute]', '0') - .set('output_format', 'JSON'); - - return this.http.get(`${this.base}/stock_availables`, {params: stockParams}).pipe( - map(sr => { - const saArr: any[] = Array.isArray(sr?.stock_availables) - ? sr.stock_availables - : (sr?.stock_availables ? [sr.stock_availables] : []); - - const qtyMap = new Map(); - for (const sa of saArr) { - const pid = +sa.id_product; - const q = sa.quantity != null ? +sa.quantity : 0; - qtyMap.set(pid, q); - } - - return products.map(p => ({ - ...p, - quantity: qtyMap.get(p.id) ?? 0 - })); - }) - ); - }) - ); + return this.http + .get< + Array<{ + id: number; + name: string; + manufacturerId?: number; + supplierId?: number; + categoryId?: number; + priceHt?: number; + quantity?: number; + }> + >(`${this.base}/products`, { params }) + .pipe( + map((dtoList) => + dtoList.map( + (dto) => + ({ + id: dto.id, + name: dto.name, + id_manufacturer: dto.manufacturerId, + id_supplier: dto.supplierId, + id_category_default: dto.categoryId, + priceHt: dto.priceHt, + quantity: dto.quantity ?? 0, + } as ProductListItem), + ), + ), + ); } - /** Détails produit (JSON full) + fallback XML si besoin */ - getProductDetails(id: number) { - const params = new HttpParams() - .set('output_format', 'JSON') - .set('display', 'full'); - - return this.http.get(`${this.base}/products/${id}`, {params}).pipe( - map(r => r?.products?.[0] ?? r?.product ?? r), - switchMap(p => { - let description = p?.description ?? ''; - - if (description && typeof description === 'string') { - return of({ - id: +p.id, - name: p.name, - description, - id_manufacturer: p?.id_manufacturer ? +p.id_manufacturer : undefined, - id_supplier: p?.id_supplier ? +p.id_supplier : undefined, - id_category_default: p?.id_category_default ? +p.id_category_default : undefined, - priceHt: p?.price ? +p.price : 0 - }); - } - - // Fallback XML - return this.http.get(`${this.base}/products/${id}`, {responseType: 'text'}).pipe( - map((xml: string) => { - const m = /[\s\S]*?]*>([\s\S]*?)<\/language>[\s\S]*?<\/description>/i.exec(xml); - let descXml = m ? m[1] : ''; - - if (descXml.startsWith('')) { - descXml = descXml.slice(9, -3); - } - - return { - id: +p.id, - name: p.name, - description: descXml, - id_manufacturer: p?.id_manufacturer ? +p.id_manufacturer : undefined, - id_supplier: p?.id_supplier ? +p.id_supplier : undefined, - id_category_default: p?.id_category_default ? +p.id_category_default : undefined, - priceHt: p?.price ? +p.price : 0 - }; - }) - ); - }) - ); + /** + * Flags Complet / Notice / État pour un produit. + * Le backend renvoie déjà ce format : + * { complete: boolean; hasManual: boolean; conditionLabel?: string } + */ + getProductFlags(productId: number): Observable { + return this.http.get(`${this.base}/products/${productId}/flags`); } - // -------- Images - - /** Retourne les URLs publiques des images du produit (FO Presta, pas l’API) */ - getProductImageUrls(productId: number) { - const params = new HttpParams() - .set('output_format', 'JSON') - .set('display', 'full'); - - return this.http.get(`${this.base}/products/${productId}`, { params }).pipe( - map(r => { - // même logique que pour les autres méthodes : format 1 ou 2 - const p = r?.product ?? (Array.isArray(r?.products) ? r.products[0] : r); - const rawAssoc = p?.associations ?? {}; - const rawImages = rawAssoc?.images?.image ?? rawAssoc?.images ?? []; - const arr: any[] = Array.isArray(rawImages) ? rawImages : (rawImages ? [rawImages] : []); - - const ids = arr - .map(img => +img.id) - .filter(id => Number.isFinite(id) && id > 0); - - return ids.map(id => this.buildFrontImageUrl(id)); - }) - ); - } - - /** Retourne la première URL d'image (miniature) du produit, ou null s'il n'y en a pas */ - getProductThumbnailUrl(productId: number) { - return this.getProductImageUrls(productId).pipe( - map(urls => urls.length ? urls[0] : null), - catchError(() => of(null)) - ); - } - - uploadProductImage(productId: number, file: File) { - const fd = new FormData(); - fd.append('image', file); - return this.http.post(`${this.base}/images/products/${productId}`, fd); - } - - // -------- Stock (quantité) — gestion fine via stock_availables - - getProductQuantity(productId: number) { - // 1) essayer de récupérer l’id SA via les associations du produit - return this.http.get(`${this.base}/products/${productId}`, {responseType: 'text'}).pipe( - switchMap(xml => { - const m = /]*>[\s\S]*?]*>\s*(?:)?\s*<\/id>[\s\S]*?<\/stock_availables>/i.exec(xml); - const saId = m ? +m[1] : null; - - if (saId) { - const params = new HttpParams() - .set('display', '[id,quantity]') - .set('filter[id]', `${saId}`) - .set('output_format', 'JSON'); - - return this.http.get(`${this.base}/stock_availables`, {params}).pipe( - map(r => { - const sa = (r?.stock_availables ?? [])[0]; - return sa?.quantity == null ? 0 : +sa.quantity; - }) - ); - } - - // 2) fallback : recherche par id_product_attribute=0 - let p1 = new HttpParams() - .set('display', '[id,quantity,id_product_attribute]') - .set('filter[id_product]', `${productId}`) - .set('filter[id_product_attribute]', '0') - .set('output_format', 'JSON'); - - return this.http.get(`${this.base}/stock_availables`, {params: p1}).pipe( - switchMap(r => { - const sa = (r?.stock_availables ?? [])[0]; - if (sa?.quantity != null) return of(+sa.quantity); - - return this.getDefaultShopContext().pipe( - switchMap(({idShop, idShopGroup}) => { - const p2 = new HttpParams() - .set('display', '[id,quantity,id_product_attribute]') - .set('filter[id_product]', `${productId}`) - .set('filter[id_product_attribute]', '0') - .set('filter[id_shop]', `${idShop}`) - .set('filter[id_shop_group]', `${idShopGroup}`) - .set('output_format', 'JSON'); - - return this.http.get(`${this.base}/stock_availables`, {params: p2}).pipe( - map(r2 => { - const sa2 = (r2?.stock_availables ?? [])[0]; - return sa2?.quantity == null ? 0 : +sa2.quantity; - }) - ); - }) - ); - }) - ); - }) - ); - } - - private setProductQuantity(productId: number, quantity: number) { - const q = Math.max(0, Math.trunc(quantity)); - - const putFromRow = (row: any) => { - const saId = +row.id; - const idShop = row.id_shop ? +row.id_shop : undefined; - const idShopGroup = row.id_shop_group ? +row.id_shop_group : undefined; - - const extraShop = - (idShop == null ? '' : `${idShop}`) + - (idShopGroup == null ? '' : `${idShopGroup}`); - - const xml = ` - - ${saId} - ${productId} - 0 - ${extraShop} - ${q} - 0 - 0 - -`; - - return this.http.put(`${this.base}/stock_availables/${saId}`, xml, { - headers: this.headersXml, - responseType: 'text' - }).pipe(map(() => true)); - }; - - return this.http.get(`${this.base}/products/${productId}`, {responseType: 'text'}).pipe( - switchMap(xml => { - const m = /]*>[\s\S]*?]*>\s*(?:)?\s*<\/id>[\s\S]*?<\/stock_availables>/i.exec(xml); - const saId = m ? +m[1] : null; - - if (saId) { - const params = new HttpParams() - .set('display', '[id,id_product,id_product_attribute,id_shop,id_shop_group,quantity]') - .set('filter[id]', `${saId}`) - .set('output_format', 'JSON'); - - return this.http.get(`${this.base}/stock_availables`, {params}).pipe( - switchMap(r => { - const row = (r?.stock_availables ?? [])[0]; - if (row?.id) return putFromRow(row); - return of(true); - }) - ); - } - - // fallback - let p1 = new HttpParams() - .set('display', '[id,id_product,id_product_attribute,id_shop,id_shop_group,quantity]') - .set('filter[id_product]', `${productId}`) - .set('filter[id_product_attribute]', '0') - .set('output_format', 'JSON'); - - return this.http.get(`${this.base}/stock_availables`, {params: p1}).pipe( - switchMap(r => { - const row = (r?.stock_availables ?? [])[0]; - if (row?.id) return putFromRow(row); - - return this.getDefaultShopContext().pipe( - switchMap(({idShop, idShopGroup}) => { - const p2 = new HttpParams() - .set('display', '[id,id_product,id_product_attribute,id_shop,id_shop_group,quantity]') - .set('filter[id_product]', `${productId}`) - .set('filter[id_product_attribute]', '0') - .set('filter[id_shop]', `${idShop}`) - .set('filter[id_shop_group]', `${idShopGroup}`) - .set('output_format', 'JSON'); - - return this.http.get(`${this.base}/stock_availables`, {params: p2}).pipe( - switchMap(r2 => { - const row2 = (r2?.stock_availables ?? [])[0]; - if (row2?.id) return putFromRow(row2); - - console.warn('[Presta] Aucune ligne stock_available PUTtable trouvée pour product:', productId); - return of(true); - }) - ); - }) - ); - }) - ); - }) - ); - } - - // -------- Caractéristiques (Complet / Notice / Etat) - - /** Lit les caractéristiques Complet / Notice / État pour un produit donné. */ - getProductFlags(productId: number): Observable<{ complete: boolean; hasManual: boolean; conditionLabel?: string }> { - const prodParams = new HttpParams() - .set('output_format', 'JSON') - .set('display', 'full'); - - const featParams = new HttpParams() - .set('display', '[id,name]') - .set('output_format', 'JSON'); - - const valParams = new HttpParams() - .set('display', '[id,id_feature,value]') - .set('output_format', 'JSON'); - - return forkJoin({ - prod: this.http.get(`${this.base}/products/${productId}`, { params: prodParams }), - features: this.http.get(`${this.base}/product_features`, { params: featParams }), - values: this.http.get(`${this.base}/product_feature_values`, { params: valParams }), - }).pipe( - map(({ prod, features, values }) => { - // ----- Produit - const p = prod?.product ?? (prod?.products?.[0] ?? prod); - const rawPf = p?.associations?.product_features ?? []; - const pfArr: any[] = Array.isArray(rawPf) ? rawPf : (rawPf ? [rawPf] : []); - - // ----- Caractéristiques (id -> nom) - const rawFeat = features?.product_features ?? features?.product_feature ?? []; - const featArr: any[] = Array.isArray(rawFeat) ? rawFeat : (rawFeat ? [rawFeat] : []); - - const featById = new Map(); - for (const f of featArr) { - const rawName = f.name; - const label = Array.isArray(rawName) ? (rawName[0]?.value ?? '') : (rawName ?? ''); - featById.set(+f.id, String(label)); - } - - // ----- Valeurs (id -> { featureId, value }) - const rawVals = values?.product_feature_values ?? values?.product_feature_value ?? []; - const valArr: any[] = Array.isArray(rawVals) ? rawVals : (rawVals ? [rawVals] : []); - - const valById = new Map(); - for (const v of valArr) { - const rawVal = v.value; - const label = Array.isArray(rawVal) ? (rawVal[0]?.value ?? '') : (rawVal ?? ''); - valById.set(+v.id, { - featureId: +v.id_feature, - value: String(label), - }); - } - - // ----- Parcours des features du produit - let complete = false; - let hasManual = false; - let conditionLabel: string | undefined; - - for (const pf of pfArr) { - // format classique Presta: { id: id_feature, id_feature_value: id valeur } - const valueId = pf.id_feature_value == null ? (pf.id_value != null ? +pf.id_value : NaN) : +pf.id_feature_value; - if (!Number.isFinite(valueId)) continue; - - const vInfo = valById.get(valueId); - if (!vInfo) continue; - - const featName = featById.get(vInfo.featureId) ?? ''; - const featNorm = this.normalizeLabel(featName); - const valNorm = this.normalizeLabel(vInfo.value); - - if (featNorm === 'complet') { - // Complet: Oui / Non - if (valNorm === 'oui') complete = true; - if (valNorm === 'non') complete = false; - } else if (featNorm === 'notice') { - // Notice: Avec / Sans - if (valNorm === 'avec') hasManual = true; - if (valNorm === 'sans') hasManual = false; - } else if (featNorm === 'etat') { - // État: on garde juste le libellé brut - conditionLabel = vInfo.value; - } - } - - return { complete, hasManual, conditionLabel }; - }) - ); - } - - /** Resolve un couple (featureId, valueId) à partir du nom de caractéristique et de la valeur */ - private resolveFeatureValue(featureName: string, valueLabel: string): Observable<{ - featureId: number; - valueId: number - } | null> { - if (!valueLabel) return of(null); - - // 1) récupérer toutes les caractéristiques, trouver celle dont le nom normalisé correspond - const paramsFeat = new HttpParams() - .set('display', '[id,name]') - .set('output_format', 'JSON'); - - return this.http.get(`${this.base}/product_features`, {params: paramsFeat}).pipe( - switchMap(r => { - const rawFeat = r?.product_features ?? r?.product_feature ?? []; - const featArr: any[] = Array.isArray(rawFeat) ? rawFeat : (rawFeat ? [rawFeat] : []); - - const targetNorm = this.normalizeLabel(featureName); - const feat = featArr.find((f: any) => { - const raw = f.name; - const name = Array.isArray(raw) ? (raw[0]?.value ?? '') : (raw ?? ''); - return this.normalizeLabel(name) === targetNorm; - }); - - if (!feat?.id) { - console.warn('[Presta] Caractéristique introuvable:', featureName); - return of(null); - } - - const idFeature = +feat.id; - - // 2) récupérer les valeurs associées à cette caractéristique, trouver la bonne par libellé - const paramsVal = new HttpParams() - .set('display', '[id,id_feature,value]') - .set('filter[id_feature]', `${idFeature}`) - .set('output_format', 'JSON'); - - return this.http.get(`${this.base}/product_feature_values`, {params: paramsVal}).pipe( - map(rv => { - const rawVals = rv?.product_feature_values ?? rv?.product_feature_value ?? []; - const valArr: any[] = Array.isArray(rawVals) ? rawVals : (rawVals ? [rawVals] : []); - - const targetValNorm = this.normalizeLabel(valueLabel); - const val = valArr.find((v: any) => { - const raw = v.value; - const label = Array.isArray(raw) ? (raw[0]?.value ?? '') : (raw ?? ''); - return this.normalizeLabel(label) === targetValNorm; - }); - - if (!val?.id) { - console.warn('[Presta] Valeur de caractéristique introuvable:', featureName, valueLabel); - return null; - } - - return {featureId: idFeature, valueId: +val.id}; - }) - ); - }) - ); - } - - /** Construit le bloc pour Complet/Notice/Etat */ - private buildFeaturesXml(dto: PsProduct): Observable { - const ops: Array> = [ - this.resolveFeatureValue('Complet', dto.complete ? 'Oui' : 'Non'), - this.resolveFeatureValue('Notice', dto.hasManual ? 'Avec' : 'Sans') - ]; - - if (dto.conditionLabel) { - ops.push(this.resolveFeatureValue('Etat', dto.conditionLabel)); - } - - return forkJoin(ops).pipe( - map(results => { - const feats = results.filter((x): x is { featureId: number; valueId: number } => !!x); - if (!feats.length) return ''; - - const inner = feats.map(f => - `${f.featureId}${f.valueId}` - ).join(''); - - return `${inner}`; - }) - ); - } - -// -------- Helpers internes (produit complet pour update) - private getProductForUpdate(id: number) { - const params = new HttpParams().set('output_format', 'JSON').set('display', 'full'); - - return this.http.get(`${this.base}/products/${id}`, {params}).pipe( - map(r => { - // format 1 : { product: { ... } } - if (r?.product) { - return r.product; - } - // format 2 : { products: [ { ... } ] } - if (Array.isArray(r?.products) && r.products.length) { - return r.products[0]; - } - // fallback : on renvoie brut - return r; - }) - ); - } - - /** Liste les valeurs possibles de la caractéristique "Etat" (libellés) */ - /** Liste les valeurs possibles de la caractéristique "État" (libellés) */ - /** Liste les valeurs possibles de la caractéristique "État" (avec cache mémoire) */ + /** + * Valeurs possibles de la caractéristique "État". + * Renvoie une simple liste de libellés. + */ getConditionValues(): Observable { - if (this.conditionValuesCache) { - return of(this.conditionValuesCache); - } - - const paramsFeat = new HttpParams() - .set('display', '[id,name]') - .set('output_format', 'JSON'); - - return this.http.get(`${this.base}/product_features`, {params: paramsFeat}).pipe( - switchMap(r => { - const rawFeat = r?.product_features ?? r?.product_feature ?? []; - const featArr: any[] = Array.isArray(rawFeat) ? rawFeat : (rawFeat ? [rawFeat] : []); - - const targetNorm = this.normalizeLabel('État'); // "État", "etat", etc. - const feat = featArr.find((f: any) => { - const raw = f.name; - const name = Array.isArray(raw) ? (raw[0]?.value ?? '') : (raw ?? ''); - return this.normalizeLabel(name) === targetNorm; - }); - - if (!feat?.id) { - console.warn('[Presta] Caractéristique "État" introuvable'); - this.conditionValuesCache = []; - return of([]); - } - - const idFeature = +feat.id; - - const paramsVal = new HttpParams() - .set('display', '[id,id_feature,value]') - .set('filter[id_feature]', `${idFeature}`) - .set('output_format', 'JSON'); - - return this.http.get(`${this.base}/product_feature_values`, {params: paramsVal}).pipe( - map(rv => { - const rawVals = rv?.product_feature_values ?? rv?.product_feature_value ?? []; - const vals: any[] = Array.isArray(rawVals) ? rawVals : (rawVals ? [rawVals] : []); - - const list = vals - .map(v => { - const raw = v.value; - if (Array.isArray(raw)) return raw[0]?.value ?? ''; - return String(raw ?? ''); - }) - .filter((x: string) => !!x); - - this.conditionValuesCache = list; - return list; - }) - ); - }) - ); + return this.http.get(`${this.base}/meta/condition-values`); } - /** 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`; + /** + * Création produit : on envoie ton PsProduct tel quel. + * Toute la logique XML / TVA / caractéristiques / stock est dans Spring. + * + * NB: Si ton interface PsProduct contient `images?: File[]`, + * Jackson côté Spring ignorera cette propriété (unknown property), + * et tu pourras gérer l'upload dans un endpoint dédié plus tard. + */ + createProduct(dto: PsProduct): Observable { + return this.http.post(`${this.base}/products`, dto); } - // -------- Description (désormais: uniquement la description libre) - private buildAugmentedDescription(dto: PsProduct): string { - return dto.description?.trim() || ''; + /** + * Mise à jour produit. + */ + updateProduct(id: number, dto: PsProduct): Observable { + return this.http.put(`${this.base}/products/${id}`, dto); } - // -------- Create & Update produit - - createProduct(dto: PsProduct) { - const priceHt = this.ttcToHt(dto.priceTtc, dto.vatRate); - - return forkJoin({ - idLang: this.getDefaultLangId(), - idTaxGroup: this.getDefaultTaxRulesGroupId(), - shop: this.getDefaultShopContext(), - homeCat: this.getHomeCategoryId(), - rootCat: this.getRootCategoryId(), - featuresXml: this.buildFeaturesXml(dto), - }).pipe( - switchMap(({idLang, idTaxGroup, shop, homeCat, rootCat, featuresXml}) => { - const desc = this.buildAugmentedDescription(dto); - - const xml = - ` - - ${dto.manufacturerId} - ${dto.supplierId} - ${dto.categoryId} - ${shop.idShop} - ${idTaxGroup} - - standard - standard - 1 - 1 - - ${priceHt} - 1 - both - 1 - 1 - 1 - - ${this.escapeXml(dto.name)} - ${this.escapeXml(this.slug(dto.name))} - ${this.escapeXml(desc)} - - - - ${rootCat} - ${homeCat} - ${dto.categoryId} - - ${featuresXml} - - -`; - - return this.http.post(`${this.base}/products`, xml, { - headers: this.headersXml, - responseType: 'text' - }).pipe( - map((res: string) => this.extractIdFromXml(res)) - ); - }), - switchMap((productId) => { - if (!productId) return of(null); - - const ops: Array> = []; - - if (dto.images?.length) { - ops.push(forkJoin(dto.images.map(f => this.uploadProductImage(productId, f)))); - } - - ops.push(this.setProductQuantity(productId, dto.quantity)); - - return ops.length ? forkJoin(ops).pipe(map(() => productId)) : of(productId); - }) - ); + /** + * Suppression produit. + */ + deleteProduct(id: number): Observable { + return this.http.delete(`${this.base}/products/${id}`); } - updateProduct(id: number, dto: PsProduct) { - const priceHt = this.ttcToHt(dto.priceTtc, dto.vatRate); - - return forkJoin({ - idLang: this.getDefaultLangId(), - prod: this.getProductForUpdate(id), - idTaxGroup: this.getDefaultTaxRulesGroupId(), - shop: this.getDefaultShopContext(), - homeCat: this.getHomeCategoryId(), - rootCat: this.getRootCategoryId(), - featuresXml: this.buildFeaturesXml(dto), - }).pipe( - switchMap(({idLang, prod, idTaxGroup, shop, homeCat, rootCat, featuresXml}) => { - const active = +prod?.active || 1; - - const lr = Array.isArray(prod?.link_rewrite) - ? prod.link_rewrite.map((l: any) => ({id: +l.id, value: String(l.value ?? '')})) - : [{id: idLang, value: this.slug(dto.name)}]; - - const lrXml = - `${ - lr.map((e: { id: any; value: string }) => - `${this.escapeXml(e.value)}` - ).join('') - }`; - - const desc = this.buildAugmentedDescription(dto); - - const xml = - ` - - ${id} - ${active} - - ${dto.manufacturerId} - ${dto.supplierId} - ${dto.categoryId} - ${shop.idShop} - ${idTaxGroup} - - standard - standard - 1 - 1 - - ${priceHt} - both - 1 - 1 - 1 - - ${this.escapeXml(dto.name)} - ${lrXml} - ${this.escapeXml(desc)} - - - - ${rootCat} - ${homeCat} - ${dto.categoryId} - - ${featuresXml} - - -`; - - return this.http.put(`${this.base}/products/${id}`, xml, { - headers: this.headersXml, - responseType: 'text' - }); - }), - switchMap(() => { - const ops: Array> = []; - - if (dto.images?.length) { - ops.push(forkJoin(dto.images.map(f => this.uploadProductImage(id, f)))); - } - - ops.push(this.setProductQuantity(id, dto.quantity)); - - return ops.length ? forkJoin(ops) : of(true); - }), - map(() => true) - ); - } - - deleteProduct(id: number) { - return this.http.delete(`${this.base}/products/${id}`, { - responseType: 'text' - }).pipe( - map(() => true) - ); - } + // --------------------------------------------------------------------------- + // TODO ultérieur : gestion des images produits via Spring + // + // Exemple de signature à garder côté front (sans impl pour l’instant) : + // + // uploadProductImage(productId: number, file: File): Observable { + // const formData = new FormData(); + // formData.append('image', file); + // return this.http.post(`${this.base}/products/${productId}/images`, formData); + // } + // + // Il faudra ajouter le contrôleur + service côté Spring pour + // relayer vers /images/products/{id} de Presta. + // --------------------------------------------------------------------------- }