feat(produits): recherche Qlik d'abord + fiche réseau complète, tendance 12 mois glissants

Recherche produit
- La page /produits interroge désormais Qlik Sense EN PREMIER
  (src/lib/qlik-search.ts) : recherche globale Engine sur les champs article
  (code + libellé détectés dynamiquement via FieldList), sélection des valeurs
  trouvées, puis liste des Article Code correspondants.
- Les métriques réseau des articles trouvés sont extraites sur 12 mois
  glissants et mises en cache (qlik_network_metrics), puis rapprochées du
  catalogue FF Nancy par code centrale (pgGetProduitsByCodeCentrale).
- Repli propre sur la recherche catalogue local si Qlik est injoignable, signalé
  dans l'UI. Résultats mis en cache mémoire 10 min, requête dédupliquée.
- Nouvelle route GET /api/produits/search (runtime nodejs, 300 s) ; la liste est
  chargée côté client avec état de chargement, l'extraction Qlik prenant
  plusieurs secondes.

Informations affichées
- Résultats : magasins vendeurs (+ % du réseau), quantité réseau, quantité par
  magasin, prix moyen réseau, CA par magasin, marge %, sparkline de tendance,
  fournisseur, et présence ou non au catalogue Nancy.
- Fiche : carte « Performance réseau · 12 mois glissants » en tête (8 indicateurs
  + détail mensuel qté / magasins / qté par magasin / CA / prix moyen / marge %),
  puis carte « Fournisseur » dédiée, puis identité, stock, nos ventes 12 mois.
- La fiche s'ouvre aussi par code centrale (?cc=) pour les produits que le réseau
  travaille et que Nancy ne référence pas — le libellé et le fournisseur Qlik
  sont persistés dans le cache réseau pour ce cas.

Tendance réseau
- computeNetworkTrend reconstruit sa fenêtre depuis la date du jour : 12 mois
  complets, mois en cours EXCLU (il est partiel et écrasait la pente). Les mois
  absents du cache valent 0 ; ceux antérieurs à la première extraction réelle
  sont écartés au lieu d'être inventés à 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DfqUihgixw4K1AmJhizWiu
This commit is contained in:
Claude committed 2026-07-29 05:04:34 +00:00
1 parent abc482c370
commit 203c96f6c9
16 files changed
+1550 -329

No files matched your search

+99
View File
@@ -1387,6 +1387,105 @@ export async function pgSearchProduits(term: string, limit = 50): Promise<PgProd
}
}
/**
* Rapproche une liste de **codes centraux** (issus de Qlik) du catalogue FF Nancy.
*
* C'est l'étape 2 de la recherche produit : Qlik dit quels articles le réseau
* travaille, cette requête dit lesquels nous référençons — et avec quel
* `codein`, quel fournisseur et quel stock. Un code absent du résultat est un
* produit réseau que Nancy ne référence pas (cas normal et intéressant).
*
* Les codeins sont renvoyés indexés par code centrale ; un même code centrale
* peut porter plusieurs codeins (ré-créations d'article), on ne garde que le
* `no_id` le plus récent.
*/
export async function pgGetProduitsByCodeCentrale(
codesCentraux: string[],
): Promise<Map<string, PgProduitSearchRow>> {
const out = new Map<string, PgProduitSearchRow>();
const codes = [...new Set(codesCentraux.map((c) => c.trim()).filter(Boolean))];
if (codes.length === 0) return out;
try {
const result = await pgNoParallel(sql`
WITH matched AS (
SELECT DISTINCT ON (TRIM(a.artcentrale::text))
a.no_id,
TRIM(a.codein::text) AS codein,
TRIM(a.artcentrale::text) AS code_centrale,
COALESCE(a.libelle1, '') AS libelle1,
a.nom_no_id
FROM articles a
WHERE TRIM(a.artcentrale::text) IN (${sql.join(codes.map((c) => sql`${c}`), sql`, `)})
AND a.codein IS NOT NULL
ORDER BY TRIM(a.artcentrale::text), a.no_id DESC
)
SELECT
m.no_id,
m.codein,
m.code_centrale,
m.libelle1,
COALESCE(fi.nom, af.code, 'Sans fournisseur')::text AS fournisseur,
COALESCE(af.code, '')::text AS codefou,
COALESCE(n.code, '')::text AS nomenclature_code,
COALESCE(n.libelle, '')::text AS nomenclature,
COALESCE(st.stock_total, 0)::float AS stock_total,
TRUE AS has_reseau,
0::float AS qte_reseau,
0 AS nb_magasins_reseau
FROM matched m
LEFT JOIN LATERAL (
SELECT af1.code
FROM artfou1 af1
WHERE af1.art_no_id = m.no_id AND af1.preference = 1
ORDER BY af1.code
LIMIT 1
) af ON TRUE
LEFT JOIN fouident fi ON fi.code = af.code
LEFT JOIN nomenclature n ON n.no_id = m.nom_no_id
LEFT JOIN LATERAL (
SELECT SUM(cs.qte)::float AS stock_total
FROM cube_stock cs
WHERE cs.artnoid = m.no_id AND cs.site IN ('292', '579')
) st ON TRUE
`);
for (const row of result.rows as unknown as PgProduitSearchRow[]) {
out.set(row.code_centrale, row);
}
console.log(`[pg-ff] pgGetProduitsByCodeCentrale: ${codes.length} codes Qlik → ${out.size} trouvés dans le catalogue Nancy`);
return out;
} catch (e) {
console.error("[pg-ff] pgGetProduitsByCodeCentrale error:", (e as Error).message?.slice(0, 250));
return out;
}
}
/**
* `codein` de l'article portant ce code centrale, ou `null` si Nancy ne le
* référence pas. Utilisé par la fiche produit ouverte depuis un résultat Qlik
* (URL `?cc=…`), qui n'a que le code centrale.
*/
export async function pgGetCodeinByCodeCentrale(codeCentrale: string): Promise<string | null> {
const cleaned = codeCentrale.trim();
if (!cleaned) return null;
try {
const result = await pgNoParallel(sql`
SELECT TRIM(a.codein::text) AS codein
FROM articles a
WHERE TRIM(a.artcentrale::text) = ${cleaned}
AND a.codein IS NOT NULL
ORDER BY a.no_id DESC
LIMIT 1
`);
const row = (result.rows as unknown as { codein: string }[])[0];
return row?.codein ?? null;
} catch (e) {
console.error("[pg-ff] pgGetCodeinByCodeCentrale error:", (e as Error).message?.slice(0, 250));
return null;
}
}
/** Identité, prix et nomenclature d'un produit (une seule ligne). */
export interface PgProduitDetailRow {
no_id: number;
+8
View File
@@ -101,6 +101,14 @@ export interface NetworkMetric {
* sautée si le champ Qlik `Année` est absent).
*/
metricsByMonth?: Record<string, QlikMonthMetrics>;
/**
* Libellé de l'article côté Qlik. Renseigné uniquement par la recherche
* produit (`qlik-search.ts`), qui interroge la dimension libellé ; la sync
* fournisseur ne le connaît pas et le laisse `undefined`.
*/
libelleReseau?: string;
/** Fournisseur de l'article côté Qlik, même origine que `libelleReseau`. */
fournisseurReseau?: string;
}
/** Les mesures réseau Qlik pour un produit sur un mois donné. */
+12
View File
@@ -27,6 +27,10 @@ export interface NetworkMetricCached {
metricsByMonth: Record<string, QlikMonthMetrics> | null;
/** Période couverte par l'extraction (ex "2025-03_2026-02"). */
periode: string | null;
/** Libellé de l'article côté Qlik — `null` tant qu'aucune recherche produit ne l'a renseigné. */
libelleReseau: string | null;
/** Fournisseur de l'article côté Qlik, même origine que `libelleReseau`. */
fournisseurReseau: string | null;
fetchedAt: string | null;
}
@@ -53,6 +57,8 @@ export async function getNetworkMetricsByCodeCentrale(
qteByMonth: (r.qteByMonth as Record<string, number> | null) ?? null,
metricsByMonth: (r.metricsByMonth as Record<string, QlikMonthMetrics> | null) ?? null,
periode: r.periode ?? null,
libelleReseau: r.libelleReseau ?? null,
fournisseurReseau: r.fournisseurReseau ?? null,
fetchedAt: r.fetchedAt ? new Date(r.fetchedAt).toISOString() : null,
});
}
@@ -73,6 +79,8 @@ export async function upsertNetworkMetrics(metrics: NetworkMetric[]): Promise<nu
periode: m.periode ?? null,
qteByMonth: m.qteByMonth ?? null,
metricsByMonth: m.metricsByMonth ?? null,
libelleReseau: m.libelleReseau ?? null,
fournisseurReseau: m.fournisseurReseau ?? null,
fetchedAt: now,
}));
@@ -95,6 +103,10 @@ export async function upsertNetworkMetrics(metrics: NetworkMetric[]): Promise<nu
periode: sql`excluded.periode`,
qteByMonth: sql`excluded.qte_by_month`,
metricsByMonth: sql`excluded.metrics_by_month`,
// Seule la recherche produit connaît le libellé / le fournisseur
// réseau : une sync fournisseur ne doit pas les effacer.
libelleReseau: sql`COALESCE(excluded.libelle_reseau, ${qlikNetworkMetrics.libelleReseau})`,
fournisseurReseau: sql`COALESCE(excluded.fournisseur_reseau, ${qlikNetworkMetrics.fournisseurReseau})`,
fetchedAt: sql`excluded.fetched_at`,
},
});
+318
View File
@@ -0,0 +1,318 @@
/**
* CollectFlow — Recherche produit **dans Qlik Sense** (source primaire).
*
* La recherche produit part du réseau, pas du catalogue Nancy : on interroge
* d'abord l'app Qlik « Magasins Vision Consolidée » pour trouver les articles
* qui correspondent au terme saisi (libellé ou code centrale), puis l'appelant
* rapproche les codes centraux trouvés de la base FF Nancy.
*
* Pourquoi Qlik d'abord : le réseau (~270 magasins) référence beaucoup plus de
* produits que Nancy. Chercher dans la base locale d'abord masquait tous les
* produits que le réseau travaille et que nous ne référençons pas — c'est
* précisément ce qu'on veut voir.
*
* Technique : même plomberie que `qlik-playwright.ts` (Chromium headless +
* websocket Engine in-page avec le qlik-csrf-token), car le proxy Qlik refuse
* un websocket « raw » côté serveur.
*
* L'API Engine utilisée est la recherche globale (`SearchResults`) : elle
* renvoie, pour chaque champ, les valeurs qui matchent le terme. On sélectionne
* ensuite ces valeurs dans leur champ, ce qui restreint la dimension
* « Article Code » aux articles correspondants — sans avoir à connaître à
* l'avance le nom exact du champ libellé, qui varie selon les apps.
*/
import "server-only";
import { chromium, type Browser } from "playwright-core";
import { getQlikConfig, qlikNtlmSession, type QlikConfig } from "@/lib/qlik-client";
/** Un article trouvé côté Qlik. Seul `codeCentrale` est garanti. */
export interface QlikArticleMatch {
/** Valeur de la dimension « Article Code » = code centrale FF (`articles.artcentrale`). */
codeCentrale: string;
/** Libellé réseau, si l'app expose un champ libellé article. */
libelle: string;
/** Fournisseur réseau, si l'app expose un champ fournisseur. */
fournisseur: string;
}
export interface QlikSearchOutcome {
matches: QlikArticleMatch[];
/** Champ Qlik effectivement interrogé (diagnostic UI). */
champUtilise: string | null;
/** true si Qlik a renvoyé plus de résultats que la limite demandée. */
tronque: boolean;
}
/**
* Nombre maximum d'articles renvoyés par une recherche.
*
* Chaque code trouvé est ensuite ré-extrait avec son détail mensuel : au-delà
* de quelques dizaines, la seconde passe devient longue et le résultat
* illisible. L'utilisateur affine son terme.
*/
export const QLIK_SEARCH_MAX_RESULTS = 40;
/** Nombre maximum de valeurs de champ sélectionnées suite à la recherche. */
const MAX_FIELD_MATCHES = 400;
let browserPromise: Promise<Browser> | null = null;
async function getBrowser(): Promise<Browser> {
if (!browserPromise) {
const execPath = process.env.PLAYWRIGHT_CHROMIUM_PATH;
browserPromise = chromium.launch({
headless: true,
executablePath: execPath || undefined,
args: ["--no-sandbox", "--disable-dev-shm-usage", "--ignore-certificate-errors"],
}).catch((e) => { browserPromise = null; throw e; });
}
return browserPromise;
}
interface InPageSearchResult {
ok: boolean;
error?: string;
rows?: Array<[string, string, string]>;
champUtilise?: string | null;
total?: number;
/** Journal compact des étapes, remonté dans les logs serveur. */
trace?: string[];
}
/**
* Cherche dans Qlik les articles correspondant à `term`.
*
* Ne fait **aucune** mesure : on veut juste la liste des codes centraux (plus
* libellé / fournisseur si l'app les expose). Les métriques réseau sont
* ensuite extraites par `fetchNetworkMetricsPlaywright`, qui sait déjà produire
* le détail mensuel sur 12 mois glissants.
*/
export async function searchQlikArticles(
term: string,
limit: number = QLIK_SEARCH_MAX_RESULTS,
cfg: QlikConfig = getQlikConfig(),
): Promise<QlikSearchOutcome> {
const cleaned = term.trim();
if (cleaned.length < 3) return { matches: [], champUtilise: null, tronque: false };
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");
const started = Date.now();
const sess = await qlikNtlmSession(cfg);
const [cookieName, ...rest] = sess.cookie.split("=");
const cookieValue = rest.join("=");
const browser = await getBrowser();
const ctx = await browser.newContext({
httpCredentials: { username: cfg.user, password: cfg.password, origin: `https://${cfg.host}` },
ignoreHTTPSErrors: true,
});
await ctx.addCookies([{ name: cookieName, value: cookieValue, domain: cfg.host, path: "/", secure: true }]);
try {
const page = await ctx.newPage();
page.on("console", (msg) => console.log(`[qlik-search][page] ${msg.text()}`));
let csrfToken: string | null = null;
let resolveToken: (() => void) | null = null;
const tokenReady = new Promise<void>((r) => { resolveToken = r; });
page.on("websocket", (ws) => {
const m = ws.url().match(/qlik-csrf-token=([^&]+)/);
if (m && !csrfToken) { csrfToken = decodeURIComponent(m[1]); resolveToken?.(); }
});
await page.goto(`https://${cfg.host}/sense/app/${cfg.appNetwork}`, { waitUntil: "domcontentloaded", timeout: cfg.timeoutMs })
.catch(() => { /* le client Qlik ouvre son ws juste après */ });
await Promise.race([tokenReady, page.waitForTimeout(15000)]);
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, maxFieldMatches }: {
app: string; dim: string; token: string; terme: string; limite: number; maxFieldMatches: number;
}) =>
new Promise<InPageSearchResult>((resolve) => {
const loc = (window as unknown as { location: Location }).location;
const url = `wss://${loc.host}/app/${app}?reloadUri=${encodeURIComponent(loc.href)}&qlik-csrf-token=${token}`;
const ws = new WebSocket(url);
const trace: string[] = [];
let id = 0;
const pend = new Map<number, { res: (v: { [k: string]: unknown }) => void; rej: (e: unknown) => void }>();
const rpc = (method: string, params: unknown, handle = -1) => new Promise<{ [k: string]: unknown }>((res, rej) => {
const i = ++id; pend.set(i, { res, rej });
ws.send(JSON.stringify({ jsonrpc: "2.0", id: i, handle, method, params }));
});
const ready = new Promise<void>((res, rej) => {
ws.onmessage = (ev: MessageEvent) => {
const m = JSON.parse(ev.data as string);
if (m.method === "OnConnected") return res();
if (m.id && pend.has(m.id)) {
const x = pend.get(m.id)!;
pend.delete(m.id);
if (m.error) x.rej(new Error(JSON.stringify(m.error)));
else x.res(m.result);
}
};
ws.onerror = () => rej(new Error("ws error (403 ?)"));
setTimeout(() => rej(new Error("ws timeout")), 30000);
});
(async () => {
try {
await ready;
const open = await rpc("OpenDoc", { qDocName: app });
const doc = (open.qReturn as { qHandle: number }).qHandle;
trace.push("OpenDoc ok");
// ─── 1. Inventaire des champs ────────────────────────────
// Sert à repérer, sans connaître l'app par cœur, le champ
// libellé article et le champ fournisseur.
const fl = await rpc("CreateSessionObject", {
qProp: {
qInfo: { qType: "FieldList" },
qFieldListDef: { qShowSystem: false, qShowHidden: false, qShowSemantic: true, qShowSrcTables: true },
},
}, doc);
const fll = await rpc("GetLayout", {}, (fl.qReturn as { qHandle: number }).qHandle);
const champs = ((((fll.qLayout as { qFieldList?: { qItems?: Array<{ qName: string }> } }).qFieldList?.qItems) ?? [])
.map((f) => f.qName));
trace.push("champs=" + champs.length);
const has = (nom: string, re: RegExp) => re.test(nom);
const champCode = champs.find((c) => c === "Article Code")
?? champs.find((c) => has(c, /article/i) && has(c, /code/i))
?? "Article Code";
const champLibelle = champs.find((c) => has(c, /article/i) && has(c, /(libell|designation|désignation|denomination|dénomination)/i))
?? champs.find((c) => has(c, /(libell|designation|désignation)/i) && !has(c, /(magasin|region|région|famille|rayon|fournisseur)/i))
?? null;
const champFournisseur = champs.find((c) => has(c, /fournisseur/i))
?? champs.find((c) => has(c, /(fabricant|supplier)/i))
?? null;
trace.push("code=" + champCode + " libelle=" + champLibelle + " fournisseur=" + champFournisseur);
// ─── 2. Recherche globale Qlik ───────────────────────────
// On restreint aux champs article : sans restriction, un terme
// courant ("noir") matcherait des dizaines de champs sans
// rapport (couleur magasin, région…) et la sélection deviendrait
// absurde.
const champsRecherche = [champCode, ...(champLibelle ? [champLibelle] : [])];
const sr = await rpc("SearchResults", {
qOptions: { qSearchFields: champsRecherche, qContext: "Cleared" },
qTerms: [terme],
qPage: { qOffset: 0, qCount: champsRecherche.length, qMaxNbrFieldMatches: maxFieldMatches },
}, doc);
type Match = { qText?: string };
type Item = { qIdentifier?: string; qItemMatches?: Match[]; qTotalNumberOfMatches?: number };
type Group = { qItems?: Item[] };
const groupes = (((sr.qResult as { qSearchGroupArray?: Group[] })?.qSearchGroupArray) ?? []);
const parChamp = new Map<string, string[]>();
for (const g of groupes) {
for (const it of g.qItems ?? []) {
const champ = it.qIdentifier ?? "";
if (!champ) continue;
const vals = (it.qItemMatches ?? []).map((m) => String(m.qText ?? "")).filter(Boolean);
if (vals.length === 0) continue;
parChamp.set(champ, [...(parChamp.get(champ) ?? []), ...vals]);
}
}
trace.push("groupes=" + JSON.stringify([...parChamp].map(([c, v]) => c + ":" + v.length)));
// Priorité au champ libellé (une recherche texte), sinon le code.
const champChoisi = (champLibelle && parChamp.has(champLibelle)) ? champLibelle
: parChamp.has(champCode) ? champCode
: [...parChamp.keys()][0] ?? null;
if (!champChoisi) {
ws.close();
return resolve({ ok: true, rows: [], champUtilise: null, total: 0, trace });
}
const valeurs = [...new Set(parChamp.get(champChoisi) ?? [])].slice(0, maxFieldMatches);
// ─── 3. Sélection des valeurs trouvées ───────────────────
const gf = await rpc("GetField", { qFieldName: champChoisi }, doc);
const fh = (gf.qReturn as { qHandle: number }).qHandle;
await rpc("Clear", {}, fh);
await rpc("SelectValues", {
qFieldValues: valeurs.map((v) => ({ qText: v })),
qToggleMode: false,
qSoftLock: true,
}, fh);
trace.push("selection " + champChoisi + " = " + valeurs.length + " valeurs");
// ─── 4. 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 dims: Array<{ qLibraryId?: string; qDef?: { qFieldDefs: string[] } }> = [{ qLibraryId: dim }];
if (champLibelle) dims.push({ qDef: { qFieldDefs: [champLibelle] } });
if (champFournisseur) dims.push({ qDef: { qFieldDefs: [champFournisseur] } });
const largeur = dims.length;
const hauteur = Math.min(limite, Math.floor(9000 / Math.max(largeur, 1)));
const cube = await rpc("CreateSessionObject", {
qProp: {
qInfo: { qType: "cf-search" },
qHyperCubeDef: {
qDimensions: dims.map((d) => ({ ...d, qNullSuppression: true })),
qMeasures: [],
qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: largeur, qHeight: hauteur }],
qSuppressMissing: true,
},
},
}, doc);
const ch = (cube.qReturn as { qHandle: number }).qHandle;
const layout = await rpc("GetLayout", {}, ch);
const hc = (layout.qLayout as {
qHyperCube: {
qSize: { qcy: number };
qDataPages?: Array<{ qMatrix?: Array<Array<{ qText?: string }>> }>;
};
}).qHyperCube;
const matrix = hc.qDataPages?.[0]?.qMatrix ?? [];
const rows: Array<[string, string, string]> = [];
for (const r of matrix) {
const code = String(r[0]?.qText ?? "").trim();
if (!code || code === "-") continue;
const libelle = champLibelle ? String(r[1]?.qText ?? "").trim() : "";
const idxFou = champLibelle ? 2 : 1;
const fournisseur = champFournisseur ? String(r[idxFou]?.qText ?? "").trim() : "";
rows.push([code, libelle === "-" ? "" : libelle, fournisseur === "-" ? "" : fournisseur]);
}
trace.push("articles=" + rows.length + "/" + hc.qSize.qcy);
ws.close();
resolve({ ok: true, rows, champUtilise: champChoisi, total: hc.qSize.qcy, trace });
} catch (e) {
try { ws.close(); } catch { /* noop */ }
resolve({ ok: false, error: String((e as Error)?.message || e), trace });
}
})();
}),
{
app: cfg.appNetwork,
dim: cfg.dimCodeArticleId,
token: csrfToken,
terme: cleaned,
limite: limit,
maxFieldMatches: MAX_FIELD_MATCHES,
},
)) as InPageSearchResult;
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,
}));
console.log(
`[qlik-search] "${cleaned}" → ${matches.length} article(s) (total Qlik ${result.total ?? 0}, champ=${result.champUtilise}) en ${Date.now() - started} ms`,
);
return {
matches,
champUtilise: result.champUtilise ?? null,
tronque: (result.total ?? 0) > matches.length,
};
} finally {
await ctx.close();
}
}