mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-18 20:56:43 +02:00
fix(produits): une seule session Qlik pour la recherche — c'était la cause des code 15
La sync de la Grille marche parce qu'elle est SEULE sur le serveur Qlik. La recherche, elle, ouvrait une seconde session concurrente pour les mesures pendant que la première tenait une énorme sélection : l'Engine coupait les requêtes (code 15 « Request aborted »). - Les mesures réseau (CA, quantité, magasins, CA/magasin, marge %) sortent désormais du MÊME cube que l'identité, dans la même session : la fenêtre 12 mois glissants est sélectionnée sur le champ Date, les master measures sont résolues par titre comme dans l'extraction de la Grille, et le cube est trié par quantité réseau décroissante. Plus d'appel à fetchNetworkMetricsPlaywright pendant une recherche. - Sélection plafonnée à 300 libellés au lieu de 1 000 : sélectionner les 13 446 libellés contenant « tapis » prenait 81 s puis 486 s. Le cube étant trié, 300 suffisent pour remonter les meilleures ventes. - Une seule recherche simultanée (deux jobs en parallèle aggravaient la saturation). - Le détail mensuel vient du cache : le cube de recherche est agrégé. L'upsert passe les colonnes mensuelles en COALESCE pour qu'une recherche n'efface pas un mensuel déjà extrait par une sync. Jointure catalogue : les 40 codes ne trouvaient aucune correspondance dans articles.artcentrale (formats différents — « Article Code » renvoie 8 chiffres, artcentrale en a 11). Le champ article_no_centrale est maintenant remonté comme seconde clé candidate, la jointure essaie les deux, et un échantillon des deux clés est loggué quand aucune ne joint. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DfqUihgixw4K1AmJhizWiu
This commit is contained in:
4 files changed
+288
-99
No files matched your search
@@ -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
|
côté client, puis on sélectionne les valeurs retenues par numéro d'élément
|
||||||
(`SelectListObjectValues`). La sélection restreint la dimension « Article
|
(`SelectListObjectValues`). La sélection restreint la dimension « Article
|
||||||
Code » aux articles correspondants.
|
Code » aux articles correspondants.
|
||||||
2. `fetchNetworkMetricsPlaywright()` — mesures réseau + détail mensuel sur la
|
2. Les **mesures réseau sortent du même cube**, dans la **même session Qlik** :
|
||||||
fenêtre 12 mois glissants (mois en cours exclu), puis `upsertNetworkMetrics()`
|
fenêtre 12 mois glissants sélectionnée sur le champ `Date`, master measures
|
||||||
pour que la fiche produit soit immédiatement servie depuis le cache.
|
résolues par titre, cube trié par quantité réseau décroissante (les meilleures
|
||||||
3. `pgGetProduitsByCodeCentrale()` — rapprochement avec le catalogue Nancy.
|
ventes en tête, donc pas besoin de lire tout l'ensemble sélectionné).
|
||||||
Un code absent = produit réseau que nous ne référençons pas (`?cc=` sur la fiche).
|
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
|
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
|
la main tout de suite, `GET` renvoie l'avancement puis le résultat (polling client
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import "server-only";
|
import "server-only";
|
||||||
|
|
||||||
import { searchQlikArticles, QLIK_SEARCH_MAX_RESULTS } from "@/lib/qlik-search";
|
import { searchQlikArticles, QLIK_SEARCH_MAX_RESULTS } from "@/lib/qlik-search";
|
||||||
import { fetchNetworkMetricsPlaywright } from "@/lib/qlik-playwright";
|
|
||||||
import type { NetworkMetric } from "@/lib/qlik-client";
|
import type { NetworkMetric } from "@/lib/qlik-client";
|
||||||
import { upsertNetworkMetrics } from "@/lib/qlik-network-cache";
|
import {
|
||||||
import { buildGridNetworkQlikDateFilter, envMonthsBack, QLIK_MONTHS_BACK_DEFAULT } from "@/lib/qlik-date-range";
|
getNetworkMetricsByCodeCentrale,
|
||||||
|
upsertNetworkMetrics,
|
||||||
|
type NetworkMetricCached,
|
||||||
|
} from "@/lib/qlik-network-cache";
|
||||||
import { pgGetProduitsByCodeCentrale, pgSearchProduits, type PgProduitSearchRow } from "@/lib/pg-ff-client";
|
import { pgGetProduitsByCodeCentrale, pgSearchProduits, type PgProduitSearchRow } from "@/lib/pg-ff-client";
|
||||||
import { NB_MAGASINS_RESEAU } from "@/features/grid/lib/network-trend";
|
import { NB_MAGASINS_RESEAU } from "@/features/grid/lib/network-trend";
|
||||||
import { normalizeMargePct } from "@/features/produits/lib/compare-reseau";
|
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.
|
* CollectFlow — Recherche produit **Qlik d'abord**, base FF Nancy ensuite.
|
||||||
*
|
*
|
||||||
* Déroulé :
|
* Déroulé :
|
||||||
* 1. Qlik Sense : quels articles du réseau correspondent au terme ?
|
* 1. Qlik Sense, **une seule session** : les articles correspondant au terme ET
|
||||||
* (`searchQlikArticles` — libellé ou code centrale)
|
* leurs mesures réseau sur 12 mois glissants (`searchQlikArticles`).
|
||||||
* 2. Qlik Sense : métriques réseau 12 mois glissants de ces articles
|
* 2. Base FF Nancy : lesquels référençons-nous ? (`pgGetProduitsByCodeCentrale`)
|
||||||
* (`fetchNetworkMetricsPlaywright` — CA, qté, nb magasins, marge, mensuel)
|
* 3. Cache `qlik_network_metrics` : détail mensuel quand une sync l'a déjà
|
||||||
* 3. Base FF Nancy : lesquels référençons-nous ? (`pgGetProduitsByCodeCentrale`)
|
* 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
|
* 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
|
* 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
|
* Le serveur Qlik sature vite (« Out of memory », « Request aborted ») : on
|
||||||
* plafonne plutôt que de lui envoyer N extractions en parallèle.
|
* 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
|
* 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
|
// Les mesures réseau sortent DÉJÀ du cube de recherche (même session Qlik).
|
||||||
// courant exclu) — même extracteur que la sync fournisseur, donc mêmes
|
// Les extraire dans une seconde session concurrente faisait abandonner le
|
||||||
// chiffres que la Grille.
|
// moteur (`code 15 — Request aborted`) : c'est ce qui faisait « planter » la
|
||||||
const monthsBack = envMonthsBack("QLIK_SYNC_MONTHS_BACK", QLIK_MONTHS_BACK_DEFAULT);
|
// recherche alors que la sync de la Grille, elle seule sur le serveur,
|
||||||
const dateFilter = buildGridNetworkQlikDateFilter(new Date(), monthsBack);
|
// fonctionnait.
|
||||||
let metrics = new Map<string, NetworkMetric>();
|
//
|
||||||
let metricsError: string | null = null;
|
// 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<string, NetworkMetricCached>();
|
||||||
|
}),
|
||||||
|
// 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 {
|
try {
|
||||||
onEtape(`Extraction des ventes réseau de ${codes.length} article${codes.length > 1 ? "s" : ""} sur 12 mois…`);
|
const aPersister: NetworkMetric[] = qlik.matches.map((m) => ({
|
||||||
metrics = await fetchNetworkMetricsPlaywright(codes, undefined, dateFilter);
|
codeCentrale: m.codeCentrale,
|
||||||
// Le libellé / fournisseur ne sortent que de la recherche : on les
|
caReseau: m.caReseau,
|
||||||
// attache ici pour qu'ils soient persistés avec les mesures.
|
qteReseau: m.qteReseau,
|
||||||
for (const m of qlik.matches) {
|
nbMagasinsReseau: m.nbMagasinsReseau,
|
||||||
const metric = metrics.get(m.codeCentrale);
|
caParMagasinReseau: m.caParMagasinReseau,
|
||||||
if (!metric) continue;
|
margePctReseau: m.margePctReseau,
|
||||||
if (m.libelle) metric.libelleReseau = m.libelle;
|
periode: qlik.periode ?? undefined,
|
||||||
if (m.fournisseur) metric.fournisseurReseau = m.fournisseur;
|
libelleReseau: m.libelle || undefined,
|
||||||
}
|
fournisseurReseau: m.fournisseur || undefined,
|
||||||
// On alimente le cache `qlik_network_metrics` au passage : la fiche
|
}));
|
||||||
// produit ouverte depuis un résultat affichera les données réseau tout
|
const n = await upsertNetworkMetrics(aPersister);
|
||||||
// de suite, sans re-extraction.
|
console.log(`[produits/search] ${n} ligne(s) réseau mises en cache`);
|
||||||
if (metrics.size > 0) {
|
|
||||||
const n = await upsertNetworkMetrics([...metrics.values()]);
|
|
||||||
console.log(`[produits/search] ${n} ligne(s) réseau mises en cache`);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Les identités trouvées restent affichables sans les mesures : mieux
|
console.error("[produits/search] mise en cache des mesures échouée:", (e as Error).message);
|
||||||
// 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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onEtape("Rapprochement avec notre catalogue…");
|
|
||||||
const catalogue = await pgGetProduitsByCodeCentrale(codes);
|
|
||||||
const locaux = await localPromise;
|
|
||||||
|
|
||||||
const rows: ProduitRechercheRow[] = qlik.matches.map((m) => {
|
const rows: ProduitRechercheRow[] = qlik.matches.map((m) => {
|
||||||
const net = metrics.get(m.codeCentrale);
|
const local = catalogue.get(m.codeCentrale) ?? (m.codeCentraleAlt ? catalogue.get(m.codeCentraleAlt) : undefined);
|
||||||
const local = catalogue.get(m.codeCentrale);
|
const cache = cacheReseau.get(m.codeCentrale);
|
||||||
const qteReseau = net?.qteReseau ?? 0;
|
const nbMag = m.nbMagasinsReseau;
|
||||||
const caReseau = net?.caReseau ?? 0;
|
|
||||||
const nbMag = net?.nbMagasinsReseau ?? 0;
|
|
||||||
return {
|
return {
|
||||||
codeCentrale: m.codeCentrale,
|
codeCentrale: m.codeCentrale,
|
||||||
libelle: local?.libelle1 || m.libelle || m.codeCentrale,
|
libelle: local?.libelle1 || m.libelle || m.codeCentrale,
|
||||||
@@ -293,16 +304,17 @@ async function executerRecherche(
|
|||||||
codein: local?.codein ?? null,
|
codein: local?.codein ?? null,
|
||||||
nomenclature: local?.nomenclature ?? "",
|
nomenclature: local?.nomenclature ?? "",
|
||||||
stockLocal: local ? Number(local.stock_total) || 0 : null,
|
stockLocal: local ? Number(local.stock_total) || 0 : null,
|
||||||
qteReseau,
|
qteReseau: m.qteReseau,
|
||||||
caReseau,
|
caReseau: m.caReseau,
|
||||||
nbMagasinsReseau: nbMag,
|
nbMagasinsReseau: nbMag,
|
||||||
tauxPresence: nbMag / NB_MAGASINS_RESEAU,
|
tauxPresence: nbMag / NB_MAGASINS_RESEAU,
|
||||||
qteParMagasinReseau: nbMag > 0 ? qteReseau / nbMag : 0,
|
qteParMagasinReseau: nbMag > 0 ? m.qteReseau / nbMag : 0,
|
||||||
caParMagasinReseau: net?.caParMagasinReseau ?? 0,
|
caParMagasinReseau: m.caParMagasinReseau,
|
||||||
prixMoyenReseau: qteReseau > 0 ? caReseau / qteReseau : null,
|
prixMoyenReseau: m.qteReseau > 0 ? m.caReseau / m.qteReseau : null,
|
||||||
margePctReseau: normalizeMargePct(net?.margePctReseau),
|
margePctReseau: normalizeMargePct(m.margePctReseau),
|
||||||
qteByMonth: net?.qteByMonth ?? null,
|
// Le mensuel ne vient que du cache : le cube de recherche est agrégé.
|
||||||
periode: net?.periode ?? dateFilter.label,
|
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.
|
// 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"));
|
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<string>(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));
|
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 {
|
return {
|
||||||
rows,
|
rows,
|
||||||
source: "qlik",
|
source: "qlik",
|
||||||
qlikError: metricsError ? messageQlikLisible(metricsError) : null,
|
qlikError: qlik.avertissement,
|
||||||
tronque: qlik.tronque,
|
tronque: qlik.tronque,
|
||||||
champUtilise: qlik.champUtilise,
|
champUtilise: qlik.champUtilise,
|
||||||
locauxHorsReseau,
|
locauxHorsReseau,
|
||||||
|
|||||||
@@ -101,8 +101,11 @@ export async function upsertNetworkMetrics(metrics: NetworkMetric[]): Promise<nu
|
|||||||
caParMagasinReseau: sql`excluded.ca_par_magasin_reseau`,
|
caParMagasinReseau: sql`excluded.ca_par_magasin_reseau`,
|
||||||
margePctReseau: sql`excluded.marge_pct_reseau`,
|
margePctReseau: sql`excluded.marge_pct_reseau`,
|
||||||
periode: sql`excluded.periode`,
|
periode: sql`excluded.periode`,
|
||||||
qteByMonth: sql`excluded.qte_by_month`,
|
// Le détail mensuel n'est produit que par l'extraction complète
|
||||||
metricsByMonth: sql`excluded.metrics_by_month`,
|
// (dimension Mois). La recherche produit, elle, n'a que les
|
||||||
|
// agrégats : elle ne doit pas effacer un mensuel déjà extrait.
|
||||||
|
qteByMonth: sql`COALESCE(excluded.qte_by_month, ${qlikNetworkMetrics.qteByMonth})`,
|
||||||
|
metricsByMonth: sql`COALESCE(excluded.metrics_by_month, ${qlikNetworkMetrics.metricsByMonth})`,
|
||||||
// Seule la recherche produit connaît le libellé / le fournisseur
|
// Seule la recherche produit connaît le libellé / le fournisseur
|
||||||
// réseau : une sync fournisseur ne doit pas les effacer.
|
// réseau : une sync fournisseur ne doit pas les effacer.
|
||||||
libelleReseau: sql`COALESCE(excluded.libelle_reseau, ${qlikNetworkMetrics.libelleReseau})`,
|
libelleReseau: sql`COALESCE(excluded.libelle_reseau, ${qlikNetworkMetrics.libelleReseau})`,
|
||||||
|
|||||||
+179
-36
@@ -37,15 +37,35 @@
|
|||||||
import "server-only";
|
import "server-only";
|
||||||
import { chromium, type Browser } from "playwright-core";
|
import { chromium, type Browser } from "playwright-core";
|
||||||
import { getQlikConfig, qlikNtlmSession, type QlikConfig } from "@/lib/qlik-client";
|
import { getQlikConfig, qlikNtlmSession, type QlikConfig } from "@/lib/qlik-client";
|
||||||
|
import { buildGridNetworkQlikDateFilter, envMonthsBack, QLIK_MONTHS_BACK_DEFAULT } from "@/lib/qlik-date-range";
|
||||||
|
|
||||||
/** Un article trouvé côté Qlik. Seul `codeCentrale` est garanti. */
|
/**
|
||||||
|
* Un article trouvé côté Qlik, **avec ses mesures réseau sur 12 mois glissants**.
|
||||||
|
*
|
||||||
|
* Les mesures sortent du même cube que l'identité : une seule session Qlik pour
|
||||||
|
* toute la recherche. Les extraire dans une seconde session concurrente faisait
|
||||||
|
* abandonner le moteur (`code 15 — Request aborted`).
|
||||||
|
*/
|
||||||
export interface QlikArticleMatch {
|
export interface QlikArticleMatch {
|
||||||
/** Valeur de la dimension « Article Code » = code centrale FF (`articles.artcentrale`). */
|
/** Valeur de la dimension « Article Code » — clé de jointure de la Grille. */
|
||||||
codeCentrale: string;
|
codeCentrale: string;
|
||||||
|
/**
|
||||||
|
* Champ `article_no_centrale` quand l'app l'expose : second candidat pour la
|
||||||
|
* jointure avec `articles.artcentrale`, les deux ne portant pas toujours le
|
||||||
|
* même format.
|
||||||
|
*/
|
||||||
|
codeCentraleAlt: string;
|
||||||
/** Libellé réseau, si l'app expose un champ libellé article. */
|
/** Libellé réseau, si l'app expose un champ libellé article. */
|
||||||
libelle: string;
|
libelle: string;
|
||||||
/** Fournisseur réseau, si l'app expose un champ fournisseur. */
|
/** Fournisseur réseau, si l'app expose un champ fournisseur. */
|
||||||
fournisseur: string;
|
fournisseur: string;
|
||||||
|
/** CA réseau sur la fenêtre 12 mois glissants. */
|
||||||
|
caReseau: number;
|
||||||
|
qteReseau: number;
|
||||||
|
nbMagasinsReseau: number;
|
||||||
|
caParMagasinReseau: number;
|
||||||
|
/** Taux de marge réseau, ratio brut Qlik (0.32 = 32 %). */
|
||||||
|
margePctReseau: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface QlikSearchOutcome {
|
export interface QlikSearchOutcome {
|
||||||
@@ -60,6 +80,8 @@ export interface QlikSearchOutcome {
|
|||||||
* fonctionnement nominal.
|
* fonctionnement nominal.
|
||||||
*/
|
*/
|
||||||
avertissement: string | null;
|
avertissement: string | null;
|
||||||
|
/** Fenêtre couverte par les mesures (`YYYY-MM_YYYY-MM`). */
|
||||||
|
periode: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -83,6 +105,13 @@ const CHAMPS_LIBELLE_PREFERES = ["Article", "article_libelle_ticket"];
|
|||||||
/** Champs candidats pour le fournisseur, par ordre de préférence (nom avant code). */
|
/** Champs candidats pour le fournisseur, par ordre de préférence (nom avant code). */
|
||||||
const CHAMPS_FOURNISSEUR_PREFERES = ["Fournisseur", "code_fournisseur"];
|
const CHAMPS_FOURNISSEUR_PREFERES = ["Fournisseur", "code_fournisseur"];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Champ portant le code centrale « brut » de l'article, quand il existe.
|
||||||
|
* Second candidat de jointure avec `articles.artcentrale` : sur l'app FF, la
|
||||||
|
* dimension « Article Code » ne renvoie pas le même format.
|
||||||
|
*/
|
||||||
|
const CHAMP_CODE_CENTRALE_ALT = "article_no_centrale";
|
||||||
|
|
||||||
let browserPromise: Promise<Browser> | null = null;
|
let browserPromise: Promise<Browser> | null = null;
|
||||||
|
|
||||||
async function getBrowser(): Promise<Browser> {
|
async function getBrowser(): Promise<Browser> {
|
||||||
@@ -100,7 +129,8 @@ async function getBrowser(): Promise<Browser> {
|
|||||||
interface InPageSearchResult {
|
interface InPageSearchResult {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
error?: string;
|
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;
|
champUtilise?: string | null;
|
||||||
/** Nombre d'articles uniques correspondant au terme (avant plafonnement). */
|
/** Nombre d'articles uniques correspondant au terme (avant plafonnement). */
|
||||||
total?: number;
|
total?: number;
|
||||||
@@ -128,7 +158,7 @@ export async function searchQlikArticles(
|
|||||||
cfg: QlikConfig = getQlikConfig(),
|
cfg: QlikConfig = getQlikConfig(),
|
||||||
): Promise<QlikSearchOutcome> {
|
): Promise<QlikSearchOutcome> {
|
||||||
const cleaned = term.trim();
|
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.appNetwork) throw new Error("[qlik-search] QLIK_APP_NETWORK manquant");
|
||||||
if (!cfg.user || !cfg.password) throw new Error("[qlik-search] identifiants Qlik manquants");
|
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
|
// 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.
|
// 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));
|
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 sess = await qlikNtlmSession(cfg);
|
||||||
const [cookieName, ...rest] = sess.cookie.split("=");
|
const [cookieName, ...rest] = sess.cookie.split("=");
|
||||||
const cookieValue = rest.join("=");
|
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é ?)");
|
if (!csrfToken) throw new Error("[qlik-search] qlik-csrf-token introuvable (client Qlik non chargé ?)");
|
||||||
|
|
||||||
const result = (await page.evaluate(
|
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;
|
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[];
|
champsLibellePreferes: string[]; champsFournisseurPreferes: string[];
|
||||||
champLibelleForce: string; champFournisseurForce: string;
|
champLibelleForce: string; champFournisseurForce: string;
|
||||||
}) =>
|
}) =>
|
||||||
new Promise<InPageSearchResult>((resolve) => {
|
new Promise<InPageSearchResult>((resolve) => {
|
||||||
/** Valeurs lues au maximum dans le champ recherché (2 pages Engine). */
|
/** Valeurs lues au maximum dans le champ recherché (2 pages Engine). */
|
||||||
const MAX_VALEURS_LUES = 10000;
|
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 PAGE_VALEURS = 5000;
|
||||||
|
|
||||||
const loc = (window as unknown as { location: Location }).location;
|
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) + ")");
|
trace.push("sélections actives: lecture impossible (" + String((e as Error)?.message || e) + ")");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 3. Liste des articles correspondants ────────────────
|
// ─── 3. Fenêtre 12 mois glissants ────────────────────────
|
||||||
// Cube sans mesure : uniquement l'identité de l'article. Les
|
// Sélectionnée APRÈS les libellés : restreindre les dates avant
|
||||||
// métriques réseau sont extraites juste après par
|
// la recherche masquerait les articles non vendus sur la période.
|
||||||
// fetchNetworkMetricsPlaywright (qui produit aussi le mensuel).
|
if (serialsDate.length > 0) {
|
||||||
const champLibelleAffiche = champChoisi === champCode ? candidatsLibelle[0] ?? null : champChoisi;
|
try {
|
||||||
const dims: Array<{ qLibraryId?: string; qDef?: { qFieldDefs: string[] } }> = [{ qLibraryId: dim }];
|
const gd = await rpc("GetField", { qFieldName: "Date" }, doc);
|
||||||
if (champLibelleAffiche) dims.push({ qDef: { qFieldDefs: [champLibelleAffiche] } });
|
const dh = (gd.qReturn as { qHandle: number }).qHandle;
|
||||||
if (champFournisseur) dims.push({ qDef: { qFieldDefs: [champFournisseur] } });
|
for (let i = 0; i < serialsDate.length; i += 500) {
|
||||||
const largeur = dims.length;
|
const lot = serialsDate.slice(i, i + 500);
|
||||||
// On lit large puis on dédoublonne : un article référencé chez
|
await rpcRetry("SelectValues", {
|
||||||
// plusieurs fournisseurs produit autant de lignes que de
|
qFieldValues: lot.map((v) => ({ qNum: v, qText: String(v) })),
|
||||||
// fournisseurs, il ne doit pas consommer plusieurs places.
|
qToggleMode: i > 0,
|
||||||
const hauteur = Math.min(1000, Math.floor(9000 / Math.max(largeur, 1)));
|
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<string, string>();
|
||||||
|
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: {
|
qProp: {
|
||||||
qInfo: { qType: "cf-search" },
|
qInfo: { qType: "cf-search" },
|
||||||
qHyperCubeDef: {
|
qHyperCubeDef: {
|
||||||
qDimensions: dims.map((d) => ({ ...d, qNullSuppression: true })),
|
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 }],
|
qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: largeur, qHeight: hauteur }],
|
||||||
|
qSuppressZero: false,
|
||||||
qSuppressMissing: true,
|
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 ch = (cube.qReturn as { qHandle: number }).qHandle;
|
||||||
const layout = await rpcRetry("GetLayout", {}, ch);
|
const layout = await rpcRetry("GetLayout", {}, ch);
|
||||||
const hc = (layout.qLayout as {
|
const hc = (layout.qLayout as {
|
||||||
qHyperCube: {
|
qHyperCube: {
|
||||||
qSize: { qcy: number };
|
qSize: { qcy: number };
|
||||||
qDataPages?: Array<{ qMatrix?: Array<Array<{ qText?: string }>> }>;
|
qDataPages?: Array<{ qMatrix?: Array<Array<{ qText?: string; qNum?: number }>> }>;
|
||||||
};
|
};
|
||||||
}).qHyperCube;
|
}).qHyperCube;
|
||||||
const matrix = hc.qDataPages?.[0]?.qMatrix ?? [];
|
const matrix = hc.qDataPages?.[0]?.qMatrix ?? [];
|
||||||
@@ -439,21 +555,34 @@ export async function searchQlikArticles(
|
|||||||
return mots.every((m) => l.includes(m));
|
return mots.every((m) => l.includes(m));
|
||||||
};
|
};
|
||||||
|
|
||||||
const parCode = new Map<string, [string, string, string]>();
|
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<string, Ligne>();
|
||||||
let rejetees = 0;
|
let rejetees = 0;
|
||||||
for (const r of matrix) {
|
for (const r of matrix) {
|
||||||
const code = String(r[0]?.qText ?? "").trim();
|
const code = String(r[0]?.qText ?? "").trim();
|
||||||
if (!code || code === "-") continue;
|
if (!code || code === "-") continue;
|
||||||
const libelleBrut = champLibelleAffiche ? String(r[1]?.qText ?? "").trim() : "";
|
const codeAlt = texte(r, idxAlt);
|
||||||
const idxFou = champLibelleAffiche ? 2 : 1;
|
const libelle = texte(r, idxLibelle);
|
||||||
const fournisseurBrut = champFournisseur ? String(r[idxFou]?.qText ?? "").trim() : "";
|
const fournisseur = texte(r, idxFournisseur);
|
||||||
const libelle = libelleBrut === "-" ? "" : libelleBrut;
|
|
||||||
const fournisseur = fournisseurBrut === "-" ? "" : fournisseurBrut;
|
|
||||||
if (!correspond(code, libelle)) { rejetees++; continue; }
|
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);
|
const existant = parCode.get(code);
|
||||||
if (!existant) { parCode.set(code, [code, libelle, fournisseur]); continue; }
|
if (!existant) { parCode.set(code, ligne); continue; }
|
||||||
if (!existant[1] && libelle) existant[1] = libelle;
|
// Même article chez plusieurs fournisseurs : une seule ligne.
|
||||||
if (!existant[2] && fournisseur) existant[2] = fournisseur;
|
// 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 uniques = [...parCode.values()];
|
||||||
const rows = uniques.slice(0, limite);
|
const rows = uniques.slice(0, limite);
|
||||||
@@ -462,6 +591,9 @@ export async function searchQlikArticles(
|
|||||||
" rejetées(hors terme)=" + rejetees +
|
" rejetées(hors terme)=" + rejetees +
|
||||||
" articles uniques=" + uniques.length + " retenus=" + rows.length,
|
" articles uniques=" + uniques.length + " retenus=" + rows.length,
|
||||||
);
|
);
|
||||||
|
if (rows.length > 0) {
|
||||||
|
trace.push("échantillon=" + JSON.stringify(rows.slice(0, 3)));
|
||||||
|
}
|
||||||
|
|
||||||
ws.close();
|
ws.close();
|
||||||
resolve({
|
resolve({
|
||||||
@@ -481,6 +613,13 @@ export async function searchQlikArticles(
|
|||||||
terme: cleaned,
|
terme: cleaned,
|
||||||
limite: limit,
|
limite: limit,
|
||||||
settleMs,
|
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,
|
champsLibellePreferes: CHAMPS_LIBELLE_PREFERES,
|
||||||
champsFournisseurPreferes: CHAMPS_FOURNISSEUR_PREFERES,
|
champsFournisseurPreferes: CHAMPS_FOURNISSEUR_PREFERES,
|
||||||
champLibelleForce: (process.env.QLIK_FIELD_ARTICLE_LIBELLE ?? "").trim(),
|
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}`);
|
for (const t of result.trace ?? []) console.log(`[qlik-search] ${t}`);
|
||||||
if (!result.ok) throw new Error(`[qlik-search] ${result.error}`);
|
if (!result.ok) throw new Error(`[qlik-search] ${result.error}`);
|
||||||
|
|
||||||
const matches: QlikArticleMatch[] = (result.rows ?? []).map(([codeCentrale, libelle, fournisseur]) => ({
|
const matches: QlikArticleMatch[] = (result.rows ?? []).map(
|
||||||
codeCentrale, libelle, fournisseur,
|
([codeCentrale, codeCentraleAlt, libelle, fournisseur, caReseau, qteReseau, nbMagasinsReseau, caParMagasinReseau, margePctReseau]) => ({
|
||||||
}));
|
codeCentrale, codeCentraleAlt, libelle, fournisseur,
|
||||||
|
caReseau, qteReseau, nbMagasinsReseau, caParMagasinReseau, margePctReseau,
|
||||||
|
}),
|
||||||
|
);
|
||||||
console.log(
|
console.log(
|
||||||
`[qlik-search] "${cleaned}" → ${matches.length} article(s) (total ${result.total ?? 0}, champ=${result.champUtilise}) en ${Date.now() - started} ms`,
|
`[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,
|
champUtilise: result.champUtilise ?? null,
|
||||||
tronque: (result.total ?? 0) > matches.length,
|
tronque: (result.total ?? 0) > matches.length,
|
||||||
avertissement,
|
avertissement,
|
||||||
|
periode: dateFilter.label,
|
||||||
};
|
};
|
||||||
} finally {
|
} finally {
|
||||||
await ctx.close();
|
await ctx.close();
|
||||||
|
|||||||
Reference in new issue
Block a user