diff --git a/docs/qlik-integration.md b/docs/qlik-integration.md index 2becb43..782432a 100644 --- a/docs/qlik-integration.md +++ b/docs/qlik-integration.md @@ -30,11 +30,28 @@ ceux-là qu'on veut voir. côté client, puis on sélectionne les valeurs retenues par numéro d'élément (`SelectListObjectValues`). La sélection restreint la dimension « Article Code » aux articles correspondants. -2. `fetchNetworkMetricsPlaywright()` — mesures réseau + détail mensuel sur la - fenêtre 12 mois glissants (mois en cours exclu), puis `upsertNetworkMetrics()` - pour que la fiche produit soit immédiatement servie depuis le cache. -3. `pgGetProduitsByCodeCentrale()` — rapprochement avec le catalogue Nancy. - Un code absent = produit réseau que nous ne référençons pas (`?cc=` sur la fiche). +2. Les **mesures réseau sortent du même cube**, dans la **même session Qlik** : + fenêtre 12 mois glissants sélectionnée sur le champ `Date`, master measures + résolues par titre, cube trié par quantité réseau décroissante (les meilleures + ventes en tête, donc pas besoin de lire tout l'ensemble sélectionné). + Elles sont ensuite persistées par `upsertNetworkMetrics()`. + + ⚠️ Ne **jamais** relancer `fetchNetworkMetricsPlaywright()` pendant une + recherche : deux sessions concurrentes sur la même app, dont une avec une + grosse sélection, et l'Engine coupe les requêtes (`code 15`). C'est ce qui + faisait échouer la recherche alors que la sync de la Grille — seule sur le + serveur — fonctionnait. Le détail mensuel vient donc du cache, et la fiche + produit garde son bouton « Actualiser depuis Qlik » pour l'extraire à la + demande sur un seul code. +3. `pgGetProduitsByCodeCentrale()` — rapprochement avec le catalogue Nancy, sur + **deux clés candidates** : la dimension « Article Code » et le champ + `article_no_centrale`, qui n'ont pas le même format sur l'app FF. Un code + absent = produit réseau que nous ne référençons pas (`?cc=` sur la fiche). + +Plafonds mesurés en production : sélectionner les 13 446 libellés contenant +« tapis » prenait 81 s puis 486 s avant d'abandonner. La sélection est donc +plafonnée à **300 valeurs** (`MAX_VALEURS_SELECTION`) et une seule recherche +tourne à la fois. L'API est **asynchrone** : `POST /api/produits/search?q=…` démarre un job et rend la main tout de suite, `GET` renvoie l'avancement puis le résultat (polling client diff --git a/src/features/produits/api/search-produits.ts b/src/features/produits/api/search-produits.ts index cea0ca7..32c4e70 100644 --- a/src/features/produits/api/search-produits.ts +++ b/src/features/produits/api/search-produits.ts @@ -1,10 +1,12 @@ import "server-only"; import { searchQlikArticles, QLIK_SEARCH_MAX_RESULTS } from "@/lib/qlik-search"; -import { fetchNetworkMetricsPlaywright } from "@/lib/qlik-playwright"; import type { NetworkMetric } from "@/lib/qlik-client"; -import { upsertNetworkMetrics } from "@/lib/qlik-network-cache"; -import { buildGridNetworkQlikDateFilter, envMonthsBack, QLIK_MONTHS_BACK_DEFAULT } from "@/lib/qlik-date-range"; +import { + getNetworkMetricsByCodeCentrale, + upsertNetworkMetrics, + type NetworkMetricCached, +} from "@/lib/qlik-network-cache"; import { pgGetProduitsByCodeCentrale, pgSearchProduits, type PgProduitSearchRow } from "@/lib/pg-ff-client"; import { NB_MAGASINS_RESEAU } from "@/features/grid/lib/network-trend"; import { normalizeMargePct } from "@/features/produits/lib/compare-reseau"; @@ -14,11 +16,15 @@ import type { ProduitRechercheResultat, ProduitRechercheRow } from "@/features/p * CollectFlow — Recherche produit **Qlik d'abord**, base FF Nancy ensuite. * * Déroulé : - * 1. Qlik Sense : quels articles du réseau correspondent au terme ? - * (`searchQlikArticles` — libellé ou code centrale) - * 2. Qlik Sense : métriques réseau 12 mois glissants de ces articles - * (`fetchNetworkMetricsPlaywright` — CA, qté, nb magasins, marge, mensuel) - * 3. Base FF Nancy : lesquels référençons-nous ? (`pgGetProduitsByCodeCentrale`) + * 1. Qlik Sense, **une seule session** : les articles correspondant au terme ET + * leurs mesures réseau sur 12 mois glissants (`searchQlikArticles`). + * 2. Base FF Nancy : lesquels référençons-nous ? (`pgGetProduitsByCodeCentrale`) + * 3. Cache `qlik_network_metrics` : détail mensuel quand une sync l'a déjà + * extrait (le cube de recherche est agrégé, il ne le fournit pas). + * + * Une seconde session Qlik pour les mesures faisait abandonner le moteur + * (`code 15 — Request aborted`) : deux sessions concurrentes sur la même app, + * dont une avec une grosse sélection, et l'Engine coupe les requêtes. * * Si Qlik est injoignable, on se rabat sur la recherche catalogue local pour ne * pas laisser l'utilisateur sans résultat — la réponse le signale explicitement @@ -37,7 +43,7 @@ const JOB_TTL_MS = 5 * 60 * 1000; * Le serveur Qlik sature vite (« Out of memory », « Request aborted ») : on * plafonne plutôt que de lui envoyer N extractions en parallèle. */ -const MAX_RECHERCHES_SIMULTANEES = 2; +const MAX_RECHERCHES_SIMULTANEES = 1; /** * Cache mémoire des recherches. Une recherche coûte deux allers-retours Qlik @@ -243,48 +249,53 @@ async function executerRecherche( }; } - // Métriques réseau + détail mensuel sur la fenêtre 12 mois glissants (mois - // courant exclu) — même extracteur que la sync fournisseur, donc mêmes - // chiffres que la Grille. - const monthsBack = envMonthsBack("QLIK_SYNC_MONTHS_BACK", QLIK_MONTHS_BACK_DEFAULT); - const dateFilter = buildGridNetworkQlikDateFilter(new Date(), monthsBack); - let metrics = new Map(); - let metricsError: string | null = null; + // Les mesures réseau sortent DÉJÀ du cube de recherche (même session Qlik). + // Les extraire dans une seconde session concurrente faisait abandonner le + // moteur (`code 15 — Request aborted`) : c'est ce qui faisait « planter » la + // recherche alors que la sync de la Grille, elle seule sur le serveur, + // fonctionnait. + // + // Reste à récupérer le détail mensuel — indisponible dans ce cube. On le lit + // dans le cache alimenté par les syncs fournisseur ; s'il manque, la fiche + // produit propose son bouton « Actualiser depuis Qlik ». + onEtape("Rapprochement avec notre catalogue…"); + const [cacheReseau, catalogue, locaux] = await Promise.all([ + getNetworkMetricsByCodeCentrale(codes).catch((e) => { + console.error("[produits/search] lecture du cache réseau échouée:", (e as Error).message); + return new Map(); + }), + // Deux clés candidates : la dimension « Article Code » et le champ + // `article_no_centrale`. Sur l'app FF elles n'ont pas le même format et + // une seule joint avec `articles.artcentrale`. + pgGetProduitsByCodeCentrale([...codes, ...qlik.matches.map((m) => m.codeCentraleAlt).filter(Boolean)]), + localPromise, + ]); + + // On persiste les mesures fraîches : la fiche produit ouverte depuis un + // résultat les affiche aussitôt, sans re-extraction. Le détail mensuel n'est + // pas fourni ici et n'est donc pas écrasé (upsert en COALESCE). try { - onEtape(`Extraction des ventes réseau de ${codes.length} article${codes.length > 1 ? "s" : ""} sur 12 mois…`); - metrics = await fetchNetworkMetricsPlaywright(codes, undefined, dateFilter); - // Le libellé / fournisseur ne sortent que de la recherche : on les - // attache ici pour qu'ils soient persistés avec les mesures. - for (const m of qlik.matches) { - const metric = metrics.get(m.codeCentrale); - if (!metric) continue; - if (m.libelle) metric.libelleReseau = m.libelle; - if (m.fournisseur) metric.fournisseurReseau = m.fournisseur; - } - // On alimente le cache `qlik_network_metrics` au passage : la fiche - // produit ouverte depuis un résultat affichera les données réseau tout - // de suite, sans re-extraction. - if (metrics.size > 0) { - const n = await upsertNetworkMetrics([...metrics.values()]); - console.log(`[produits/search] ${n} ligne(s) réseau mises en cache`); - } + const aPersister: NetworkMetric[] = qlik.matches.map((m) => ({ + codeCentrale: m.codeCentrale, + caReseau: m.caReseau, + qteReseau: m.qteReseau, + nbMagasinsReseau: m.nbMagasinsReseau, + caParMagasinReseau: m.caParMagasinReseau, + margePctReseau: m.margePctReseau, + periode: qlik.periode ?? undefined, + libelleReseau: m.libelle || undefined, + fournisseurReseau: m.fournisseur || undefined, + })); + const n = await upsertNetworkMetrics(aPersister); + console.log(`[produits/search] ${n} ligne(s) réseau mises en cache`); } catch (e) { - // Les identités trouvées restent affichables sans les mesures : mieux - // vaut une liste sans chiffres réseau qu'une page d'erreur. - metricsError = e instanceof Error ? e.message : String(e); - console.error("[produits/search] extraction des métriques réseau échouée:", metricsError); + console.error("[produits/search] mise en cache des mesures échouée:", (e as Error).message); } - onEtape("Rapprochement avec notre catalogue…"); - const catalogue = await pgGetProduitsByCodeCentrale(codes); - const locaux = await localPromise; - const rows: ProduitRechercheRow[] = qlik.matches.map((m) => { - const net = metrics.get(m.codeCentrale); - const local = catalogue.get(m.codeCentrale); - const qteReseau = net?.qteReseau ?? 0; - const caReseau = net?.caReseau ?? 0; - const nbMag = net?.nbMagasinsReseau ?? 0; + const local = catalogue.get(m.codeCentrale) ?? (m.codeCentraleAlt ? catalogue.get(m.codeCentraleAlt) : undefined); + const cache = cacheReseau.get(m.codeCentrale); + const nbMag = m.nbMagasinsReseau; return { codeCentrale: m.codeCentrale, libelle: local?.libelle1 || m.libelle || m.codeCentrale, @@ -293,16 +304,17 @@ async function executerRecherche( codein: local?.codein ?? null, nomenclature: local?.nomenclature ?? "", stockLocal: local ? Number(local.stock_total) || 0 : null, - qteReseau, - caReseau, + qteReseau: m.qteReseau, + caReseau: m.caReseau, nbMagasinsReseau: nbMag, tauxPresence: nbMag / NB_MAGASINS_RESEAU, - qteParMagasinReseau: nbMag > 0 ? qteReseau / nbMag : 0, - caParMagasinReseau: net?.caParMagasinReseau ?? 0, - prixMoyenReseau: qteReseau > 0 ? caReseau / qteReseau : null, - margePctReseau: normalizeMargePct(net?.margePctReseau), - qteByMonth: net?.qteByMonth ?? null, - periode: net?.periode ?? dateFilter.label, + qteParMagasinReseau: nbMag > 0 ? m.qteReseau / nbMag : 0, + caParMagasinReseau: m.caParMagasinReseau, + prixMoyenReseau: m.qteReseau > 0 ? m.caReseau / m.qteReseau : null, + margePctReseau: normalizeMargePct(m.margePctReseau), + // Le mensuel ne vient que du cache : le cube de recherche est agrégé. + qteByMonth: cache?.qteByMonth ?? null, + periode: qlik.periode, }; }); @@ -310,13 +322,27 @@ async function executerRecherche( // arbitrer, un produit que le réseau ne vend pas n'intéresse personne. rows.sort((a, b) => b.qteReseau - a.qteReseau || a.libelle.localeCompare(b.libelle, "fr")); - const codesTrouves = new Set(codes); + // Les produits Nancy déjà couverts par un résultat réseau ne doivent pas + // réapparaître dans la liste secondaire. On compare sur les deux clés + // candidates, la jointure pouvant se faire par l'une ou par l'autre. + const codesTrouves = new Set(codes); + for (const m of qlik.matches) if (m.codeCentraleAlt) codesTrouves.add(m.codeCentraleAlt); const locauxHorsReseau = locaux.filter((l) => !l.code_centrale || !codesTrouves.has(l.code_centrale)); + // Diagnostic de la jointure : 0 rapprochement sur 40 codes signale un format + // de clé différent entre Qlik et `articles.artcentrale`. + if (catalogue.size === 0 && codes.length > 0) { + console.warn( + `[produits/search] aucun des ${codes.length} codes Qlik ne joint avec articles.artcentrale — ` + + `échantillon Article Code=${JSON.stringify(codes.slice(0, 3))} ` + + `article_no_centrale=${JSON.stringify(qlik.matches.slice(0, 3).map((m) => m.codeCentraleAlt))}`, + ); + } + return { rows, source: "qlik", - qlikError: metricsError ? messageQlikLisible(metricsError) : null, + qlikError: qlik.avertissement, tronque: qlik.tronque, champUtilise: qlik.champUtilise, locauxHorsReseau, diff --git a/src/lib/qlik-network-cache.ts b/src/lib/qlik-network-cache.ts index 508f5b2..2c3109e 100644 --- a/src/lib/qlik-network-cache.ts +++ b/src/lib/qlik-network-cache.ts @@ -101,8 +101,11 @@ export async function upsertNetworkMetrics(metrics: NetworkMetric[]): Promise | null = null; async function getBrowser(): Promise { @@ -100,7 +129,8 @@ async function getBrowser(): Promise { interface InPageSearchResult { ok: boolean; error?: string; - rows?: Array<[string, string, string]>; + /** [code, codeAlt, libellé, fournisseur, ca, qte, nbMag, caMag, margePct] */ + rows?: Array<[string, string, string, string, number, number, number, number, number]>; champUtilise?: string | null; /** Nombre d'articles uniques correspondant au terme (avant plafonnement). */ total?: number; @@ -128,7 +158,7 @@ export async function searchQlikArticles( cfg: QlikConfig = getQlikConfig(), ): Promise { const cleaned = term.trim(); - if (cleaned.length < 3) return { matches: [], champUtilise: null, tronque: false, avertissement: null }; + if (cleaned.length < 3) return { matches: [], champUtilise: null, tronque: false, avertissement: null, periode: null }; if (!cfg.appNetwork) throw new Error("[qlik-search] QLIK_APP_NETWORK manquant"); if (!cfg.user || !cfg.password) throw new Error("[qlik-search] identifiants Qlik manquants"); @@ -137,6 +167,11 @@ export async function searchQlikArticles( // que l'extraction (`QLIK_SETTLE_MS`) : sans elle, l'objet créé juste après // est calculé sur l'état de sélection précédent. const settleMs = Math.min(2000, Math.max(0, Number(process.env.QLIK_SETTLE_MS ?? "250") || 250)); + // Même fenêtre que la Grille : 12 mois complets glissants, mois courant exclu. + const dateFilter = buildGridNetworkQlikDateFilter( + new Date(), + envMonthsBack("QLIK_SYNC_MONTHS_BACK", QLIK_MONTHS_BACK_DEFAULT), + ); const sess = await qlikNtlmSession(cfg); const [cookieName, ...rest] = sess.cookie.split("="); const cookieValue = rest.join("="); @@ -171,16 +206,28 @@ export async function searchQlikArticles( if (!csrfToken) throw new Error("[qlik-search] qlik-csrf-token introuvable (client Qlik non chargé ?)"); const result = (await page.evaluate( - ({ app, dim, token, terme, limite, settleMs, champsLibellePreferes, champsFournisseurPreferes, champLibelleForce, champFournisseurForce }: { + ({ app, dim, token, terme, limite, settleMs, serialsDate, mCa, mQte, mNbMag, mCaMag, mMarge, + champCodeAlt, champsLibellePreferes, champsFournisseurPreferes, champLibelleForce, champFournisseurForce }: { app: string; dim: string; token: string; terme: string; limite: number; settleMs: number; + serialsDate: number[]; + mCa: string; mQte: string; mNbMag: string; mCaMag: string; mMarge: string; + champCodeAlt: string; champsLibellePreferes: string[]; champsFournisseurPreferes: string[]; champLibelleForce: string; champFournisseurForce: string; }) => new Promise((resolve) => { /** Valeurs lues au maximum dans le champ recherché (2 pages Engine). */ const MAX_VALEURS_LUES = 10000; - /** Valeurs sélectionnées au maximum — au-delà, le cube abandonne (code 15). */ - const MAX_VALEURS_SELECTION = 1000; + /** + * Valeurs sélectionnées au maximum. + * + * Mesuré en production : sélectionner les 13 446 libellés contenant + * « tapis » a mis 81 s puis 486 s, et faisait abandonner le cube + * (code 15). Le cube étant trié par quantité réseau décroissante et + * l'affichage plafonné à quelques dizaines de lignes, 300 libellés + * suffisent largement. + */ + const MAX_VALEURS_SELECTION = 300; const PAGE_VALEURS = 5000; const loc = (window as unknown as { location: Location }).location; @@ -393,37 +440,106 @@ export async function searchQlikArticles( trace.push("sélections actives: lecture impossible (" + String((e as Error)?.message || e) + ")"); } - // ─── 3. Liste des articles correspondants ──────────────── - // Cube sans mesure : uniquement l'identité de l'article. Les - // métriques réseau sont extraites juste après par - // fetchNetworkMetricsPlaywright (qui produit aussi le mensuel). - const champLibelleAffiche = champChoisi === champCode ? candidatsLibelle[0] ?? null : champChoisi; - const dims: Array<{ qLibraryId?: string; qDef?: { qFieldDefs: string[] } }> = [{ qLibraryId: dim }]; - if (champLibelleAffiche) dims.push({ qDef: { qFieldDefs: [champLibelleAffiche] } }); - if (champFournisseur) dims.push({ qDef: { qFieldDefs: [champFournisseur] } }); - const largeur = dims.length; - // On lit large puis on dédoublonne : un article référencé chez - // plusieurs fournisseurs produit autant de lignes que de - // fournisseurs, il ne doit pas consommer plusieurs places. - const hauteur = Math.min(1000, Math.floor(9000 / Math.max(largeur, 1))); + // ─── 3. Fenêtre 12 mois glissants ──────────────────────── + // Sélectionnée APRÈS les libellés : restreindre les dates avant + // la recherche masquerait les articles non vendus sur la période. + if (serialsDate.length > 0) { + try { + const gd = await rpc("GetField", { qFieldName: "Date" }, doc); + const dh = (gd.qReturn as { qHandle: number }).qHandle; + for (let i = 0; i < serialsDate.length; i += 500) { + const lot = serialsDate.slice(i, i + 500); + await rpcRetry("SelectValues", { + qFieldValues: lot.map((v) => ({ qNum: v, qText: String(v) })), + qToggleMode: i > 0, + qSoftLock: true, + }, dh); + } + await sleep(settleMs); + trace.push("fenêtre Date sélectionnée (" + serialsDate.length + " jours)"); + } catch (e) { + trace.push("sélection Date impossible, mesures sur toute la période : " + String((e as Error)?.message || e)); + } + } - const cube = await rpcRetry("CreateSessionObject", { + // ─── 4. Identité + mesures réseau, dans UN SEUL cube ───── + // + // Les mesures sont dans le même cube que l'identité, et donc + // dans la même session Qlik : une seconde session concurrente + // pour les extraire faisait abandonner le moteur (code 15). + // + // Les master measures sont résolues par TITRE : le qLibraryId + // fiable est `qInfo.qId`, qui n'est pas le GUID du QRS — même + // approche que l'extraction de la Grille. + const norm = (x: string) => x.replace(/\s+/g, " ").trim().toLowerCase(); + const ml = await rpcRetry("CreateSessionObject", { + qProp: { qInfo: { qType: "MeasureList" }, qMeasureListDef: { qType: "measure", qData: { title: "/qMetaDef/title" } } }, + }, doc); + const mll = await rpcRetry("GetLayout", {}, (ml.qReturn as { qHandle: number }).qHandle); + const items = (((mll.qLayout as { qMeasureList?: { qItems?: Array<{ qInfo: { qId: string }; qMeta?: { title?: string }; qData?: { title?: string } }> } }).qMeasureList?.qItems) ?? []); + const parTitre = new Map(); + for (const it of items) { + const t = it.qMeta?.title ?? it.qData?.title ?? ""; + if (t) parTitre.set(norm(t), it.qInfo.qId); + } + const mesure = (titre: string, repli: string) => parTitre.get(norm(titre)) ?? repli; + const idCa = mesure("CA N", mCa); + const idQte = mesure("Quantité N", mQte); + const idNbMag = mesure("Magasin Ventes Nb N", mNbMag); + const idCaMag = mesure("CA par Magasin N", mCaMag); + const idMarge = mesure("Marge % N", mMarge); + + const champLibelleAffiche = champChoisi === champCode ? candidatsLibelle[0] ?? null : champChoisi; + const champAlt = champs.includes(champCodeAlt) ? champCodeAlt : null; + const dims: Array<{ qLibraryId?: string; qDef?: { qFieldDefs: string[] } }> = [{ qLibraryId: dim }]; + const idxAlt = champAlt ? dims.push({ qDef: { qFieldDefs: [champAlt] } }) - 1 : -1; + const idxLibelle = champLibelleAffiche ? dims.push({ qDef: { qFieldDefs: [champLibelleAffiche] } }) - 1 : -1; + const idxFournisseur = champFournisseur ? dims.push({ qDef: { qFieldDefs: [champFournisseur] } }) - 1 : -1; + + const nbDims = dims.length; + // Colonnes mesures, dans l'ordre : CA, Qté, NbMag, CA/mag, Marge %. + const idxQteCol = nbDims + 1; + const largeur = nbDims + 5; + // Cube trié par quantité réseau décroissante : la première page + // contient donc directement les meilleures ventes du réseau, et + // on n'a pas besoin de lire tout l'ensemble sélectionné. + const hauteur = Math.max(50, Math.min(400, Math.floor(9000 / largeur))); + + const defCube = (trie: boolean) => ({ qProp: { qInfo: { qType: "cf-search" }, qHyperCubeDef: { qDimensions: dims.map((d) => ({ ...d, qNullSuppression: true })), - qMeasures: [], + qMeasures: [ + { qLibraryId: idCa }, + trie ? { qLibraryId: idQte, qSortBy: { qSortByNumeric: -1 } } : { qLibraryId: idQte }, + { qLibraryId: idNbMag }, + { qLibraryId: idCaMag }, + { qLibraryId: idMarge }, + ], + ...(trie ? { qInterColumnSortOrder: [idxQteCol, 0] } : {}), qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: largeur, qHeight: hauteur }], + qSuppressZero: false, qSuppressMissing: true, }, }, - }, doc); + }); + let cube: { [k: string]: unknown }; + try { + cube = await rpcRetry("CreateSessionObject", defCube(true), doc); + } catch (e) { + // Le tri par mesure est un confort (meilleures ventes en + // tête) : si l'Engine refuse cette forme, on repart sans. + if (estCode15(e)) throw e; + trace.push("tri par quantité refusé, cube non trié : " + String((e as Error)?.message || e)); + cube = await rpcRetry("CreateSessionObject", defCube(false), doc); + } const ch = (cube.qReturn as { qHandle: number }).qHandle; const layout = await rpcRetry("GetLayout", {}, ch); const hc = (layout.qLayout as { qHyperCube: { qSize: { qcy: number }; - qDataPages?: Array<{ qMatrix?: Array> }>; + qDataPages?: Array<{ qMatrix?: Array> }>; }; }).qHyperCube; const matrix = hc.qDataPages?.[0]?.qMatrix ?? []; @@ -439,21 +555,34 @@ export async function searchQlikArticles( return mots.every((m) => l.includes(m)); }; - const parCode = new Map(); + type Ligne = [string, string, string, string, number, number, number, number, number]; + const texte = (r: Array<{ qText?: string }>, i: number) => { + if (i < 0) return ""; + const v = String(r[i]?.qText ?? "").trim(); + return v === "-" ? "" : v; + }; + const parCode = new Map(); let rejetees = 0; for (const r of matrix) { const code = String(r[0]?.qText ?? "").trim(); if (!code || code === "-") continue; - const libelleBrut = champLibelleAffiche ? String(r[1]?.qText ?? "").trim() : ""; - const idxFou = champLibelleAffiche ? 2 : 1; - const fournisseurBrut = champFournisseur ? String(r[idxFou]?.qText ?? "").trim() : ""; - const libelle = libelleBrut === "-" ? "" : libelleBrut; - const fournisseur = fournisseurBrut === "-" ? "" : fournisseurBrut; + const codeAlt = texte(r, idxAlt); + const libelle = texte(r, idxLibelle); + const fournisseur = texte(r, idxFournisseur); if (!correspond(code, libelle)) { rejetees++; continue; } + const nombre = (i: number) => Number(r[nbDims + i]?.qNum) || 0; + const ligne: Ligne = [ + code, codeAlt, libelle, fournisseur, + nombre(0), nombre(1), nombre(2), nombre(3), nombre(4), + ]; const existant = parCode.get(code); - if (!existant) { parCode.set(code, [code, libelle, fournisseur]); continue; } - if (!existant[1] && libelle) existant[1] = libelle; - if (!existant[2] && fournisseur) existant[2] = fournisseur; + if (!existant) { parCode.set(code, ligne); continue; } + // Même article chez plusieurs fournisseurs : une seule ligne. + // Les mesures sont identiques (elles ne dépendent pas de la + // dimension fournisseur), on complète juste les libellés. + if (!existant[1] && codeAlt) existant[1] = codeAlt; + if (!existant[2] && libelle) existant[2] = libelle; + if (!existant[3] && fournisseur) existant[3] = fournisseur; } const uniques = [...parCode.values()]; const rows = uniques.slice(0, limite); @@ -462,6 +591,9 @@ export async function searchQlikArticles( " rejetées(hors terme)=" + rejetees + " articles uniques=" + uniques.length + " retenus=" + rows.length, ); + if (rows.length > 0) { + trace.push("échantillon=" + JSON.stringify(rows.slice(0, 3))); + } ws.close(); resolve({ @@ -481,6 +613,13 @@ export async function searchQlikArticles( terme: cleaned, limite: limit, settleMs, + serialsDate: dateFilter.dailySerials, + mCa: cfg.measCaId, + mQte: cfg.measQteId, + mNbMag: cfg.measNbMagId, + mCaMag: cfg.measCaMagId, + mMarge: cfg.measMargePctId, + champCodeAlt: CHAMP_CODE_CENTRALE_ALT, champsLibellePreferes: CHAMPS_LIBELLE_PREFERES, champsFournisseurPreferes: CHAMPS_FOURNISSEUR_PREFERES, champLibelleForce: (process.env.QLIK_FIELD_ARTICLE_LIBELLE ?? "").trim(), @@ -491,9 +630,12 @@ export async function searchQlikArticles( for (const t of result.trace ?? []) console.log(`[qlik-search] ${t}`); if (!result.ok) throw new Error(`[qlik-search] ${result.error}`); - const matches: QlikArticleMatch[] = (result.rows ?? []).map(([codeCentrale, libelle, fournisseur]) => ({ - codeCentrale, libelle, fournisseur, - })); + const matches: QlikArticleMatch[] = (result.rows ?? []).map( + ([codeCentrale, codeCentraleAlt, libelle, fournisseur, caReseau, qteReseau, nbMagasinsReseau, caParMagasinReseau, margePctReseau]) => ({ + codeCentrale, codeCentraleAlt, libelle, fournisseur, + caReseau, qteReseau, nbMagasinsReseau, caParMagasinReseau, margePctReseau, + }), + ); console.log( `[qlik-search] "${cleaned}" → ${matches.length} article(s) (total ${result.total ?? 0}, champ=${result.champUtilise}) en ${Date.now() - started} ms`, ); @@ -514,6 +656,7 @@ export async function searchQlikArticles( champUtilise: result.champUtilise ?? null, tronque: (result.total ?? 0) > matches.length, avertissement, + periode: dateFilter.label, }; } finally { await ctx.close();