mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-17 04:09:39 +02:00
feat(reseau): page Recherche réseau — chercher dans le catalogue Qlik
La Grille part de NOTRE catalogue : on extrait nos codes centraux puis on demande à Qlik les métriques de ces codes. Un produit vendu par le réseau mais absent de FF Nancy était donc invisible par construction. Cette page inverse le sens : elle interroge Qlik d'abord, par libellé ou par code centrale, et affiche aussi les produits que nous ne référençons pas. - searchNetworkProductsPlaywright() : sélection par SelectValues sur « Article Code » (mode code) ou par SearchListObjectFor + AcceptListObjectSearch sur la dimension « Article » (mode plein texte, avec repli sur des noms de champs si la master dimension échoue). Cube [Article Code, Article, Mois] × 6 mesures, passe N-1 incluse pour obtenir 12 mois glissants contigus. Garde-fou à 300 produits/recherche. - /api/qlik/search : job asynchrone (POST + polling GET) comme la sync, une seule recherche à la fois pour ménager le moteur Qlik. Met les résultats en cache dans qlik_network_metrics et enrichit avec le catalogue local (badge « Chez moi » / « Réseau uniquement », stock). - Page /recherche-reseau : tableau triable, filtres, fiche produit avec la courbe 12 mois, export Excel. - Composants de tendance extraits de heatmap-grid vers features/network pour être partagés (comportement inchangé). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675
This commit is contained in:
10 files changed
+1514
-132
No files matched your search
@@ -157,6 +157,55 @@ export async function pgGetArticlesByFournisseur(codefou: string): Promise<PgArt
|
||||
return (result.rows as unknown as PgArticle[]).filter(r => r.codein);
|
||||
}
|
||||
|
||||
/** Article local (FF Nancy) résolu depuis un code centrale — page Recherche réseau. */
|
||||
export interface PgArticleLocal {
|
||||
codein: string;
|
||||
codeCentrale: string;
|
||||
libelle1?: string;
|
||||
codefou?: string;
|
||||
nomfou?: string;
|
||||
pa?: number;
|
||||
pv_central?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Résout des codes centraux vers NOS articles locaux.
|
||||
*
|
||||
* Sert à distinguer, dans les résultats de recherche Qlik, les produits que nous
|
||||
* référençons (« Chez moi ») de ceux qui n'existent que dans le réseau. Un code
|
||||
* absent de la Map = produit inconnu de FF Nancy.
|
||||
*/
|
||||
export async function pgGetArticlesByCodeCentrale(codes: string[]): Promise<Map<string, PgArticleLocal>> {
|
||||
const unique = [...new Set(codes.map(c => String(c ?? "").trim()).filter(Boolean))];
|
||||
if (unique.length === 0) return new Map();
|
||||
|
||||
const result = await pgNoParallel(sql`
|
||||
SELECT DISTINCT ON (a.artcentrale)
|
||||
TRIM(a.codein::text) AS codein,
|
||||
a.artcentrale AS "codeCentrale",
|
||||
a.libelle1,
|
||||
af.code AS codefou,
|
||||
fi.nom AS nomfou,
|
||||
pa.pa,
|
||||
ai.prix_vente_mini AS pv_central
|
||||
FROM articles a
|
||||
LEFT JOIN artfou1 af ON af.art_no_id = a.no_id
|
||||
LEFT JOIN fouident fi ON fi.code = af.code
|
||||
LEFT JOIN cube_pa pa ON pa.artnoid = a.no_id
|
||||
LEFT JOIN article_infosup ai ON ai.artnoid = a.no_id
|
||||
WHERE a.artcentrale IN (${sql.join(unique.map(c => sql`${c}`), sql`, `)})
|
||||
AND a.codein IS NOT NULL
|
||||
ORDER BY a.artcentrale, af.no_id
|
||||
`);
|
||||
|
||||
const map = new Map<string, PgArticleLocal>();
|
||||
for (const r of result.rows as unknown as PgArticleLocal[]) {
|
||||
if (r.codeCentrale) map.set(String(r.codeCentrale).trim(), r);
|
||||
}
|
||||
console.log(`[pg-ff] pgGetArticlesByCodeCentrale: ${map.size}/${unique.length} codes connus localement`);
|
||||
return map;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Données mensuelles (ventes + stock + réceptions)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -93,6 +93,20 @@ export interface NetworkMetric {
|
||||
qteByMonth?: Record<string, number>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Produit réseau tel que renvoyé par la **recherche** Qlik (page Recherche réseau).
|
||||
*
|
||||
* Différence clé avec `NetworkMetric` : ces produits viennent du catalogue Qlik
|
||||
* du réseau, pas de notre catalogue local. Ils peuvent donc être totalement
|
||||
* inconnus de FF Nancy — d'où le libellé, qui n'est pas récupérable en base.
|
||||
*/
|
||||
export interface NetworkProduct extends NetworkMetric {
|
||||
/** Libellé article tel qu'il apparaît dans Qlik (dimension « Article »). */
|
||||
libelle: string;
|
||||
fournisseur?: string;
|
||||
famille?: string;
|
||||
}
|
||||
|
||||
function makeXrfkey(): string {
|
||||
return randomBytes(12).toString("base64").replace(/[^a-zA-Z0-9]/g, "").slice(0, 16).padEnd(16, "0");
|
||||
}
|
||||
|
||||
+469
-1
@@ -14,7 +14,7 @@
|
||||
|
||||
import "server-only";
|
||||
import { chromium, type Browser } from "playwright-core";
|
||||
import { getQlikConfig, qlikNtlmSession, type QlikConfig, type NetworkMetric } from "@/lib/qlik-client";
|
||||
import { getQlikConfig, qlikNtlmSession, type QlikConfig, type NetworkMetric, type NetworkProduct } from "@/lib/qlik-client";
|
||||
import { buildGridNetworkQlikDateFilter, getMonthRanges, type QlikDateFilter } from "@/lib/qlik-date-range";
|
||||
|
||||
let browserPromise: Promise<Browser> | null = null;
|
||||
@@ -860,3 +860,471 @@ export async function fetchNetworkMetricsPlaywright(
|
||||
await ctx.close();
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────────
|
||||
// Recherche produits dans le CATALOGUE RÉSEAU Qlik
|
||||
// ───────────────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// `fetchNetworkMetricsPlaywright` part de NOS codes centraux : elle ne peut donc
|
||||
// jamais révéler un produit du réseau que FF Nancy ne référence pas. La recherche
|
||||
// ci-dessous fait l'inverse — elle interroge Qlik d'abord :
|
||||
//
|
||||
// • mode "code" → SelectValues sur le champ « Article Code » (un ou plusieurs codes)
|
||||
// • mode "label" → recherche plein texte sur la dimension « Article » via
|
||||
// SearchListObjectFor + AcceptListObjectSearch (équivalent d'une
|
||||
// saisie dans un filtre Qlik : tous les mots doivent matcher).
|
||||
//
|
||||
// Le mode "label" évite d'avoir à deviner le nom exact du champ libellé : on tente
|
||||
// d'abord la master dimension par qLibraryId, puis une liste de noms de champs.
|
||||
|
||||
/** Résultat d'une recherche réseau. */
|
||||
export interface NetworkSearchResult {
|
||||
products: NetworkProduct[];
|
||||
/** Nb de valeurs « Article » trouvées par Qlik (mode label), ou nb de codes demandés. */
|
||||
matchCount: number;
|
||||
/** true si la recherche a été refusée car trop large (matchCount > maxProducts). */
|
||||
tooMany: boolean;
|
||||
mode: "code" | "label";
|
||||
periode?: string;
|
||||
}
|
||||
|
||||
interface InPageSearchResult {
|
||||
ok: boolean;
|
||||
/** [code, libelle, ca, qte, nbMag, caMag, margePct] — passe principale uniquement. */
|
||||
rows?: Array<Array<string | number>>;
|
||||
monthly?: Record<string, Record<string, number>>;
|
||||
meta?: Record<string, { fournisseur?: string; famille?: string }>;
|
||||
matchCount?: number;
|
||||
tooMany?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** Découpe la saisie utilisateur en codes centraux si elle n'en contient QUE. */
|
||||
function parseCodeQuery(query: string): string[] | null {
|
||||
const parts = query.trim().split(/[\s,;]+/).filter(Boolean);
|
||||
if (parts.length === 0) return null;
|
||||
// Un code centrale FF fait 11 chiffres (10000XXXXXX) ; on accepte 5+ chiffres
|
||||
// pour tolérer des saisies partielles côté centrale.
|
||||
if (!parts.every((p) => /^\d{5,}$/.test(p))) return null;
|
||||
return [...new Set(parts)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Cherche des produits dans le catalogue réseau Qlik par libellé ou par code centrale,
|
||||
* et renvoie leurs métriques réseau + les quantités mois par mois (12 mois glissants).
|
||||
*/
|
||||
export async function searchNetworkProductsPlaywright(
|
||||
query: string,
|
||||
opts: { maxProducts?: number } = {},
|
||||
cfg: QlikConfig = getQlikConfig(),
|
||||
dateFilter: QlikDateFilter | null = buildGridNetworkQlikDateFilter(),
|
||||
): Promise<NetworkSearchResult> {
|
||||
if (!cfg.appNetwork) throw new Error("[qlik-pw] QLIK_APP_NETWORK manquant");
|
||||
if (!cfg.user || !cfg.password) throw new Error("[qlik-pw] identifiants Qlik manquants");
|
||||
const trimmed = query.trim();
|
||||
if (!trimmed) throw new Error("[qlik-pw] recherche vide");
|
||||
|
||||
const totalStart = Date.now();
|
||||
const codes = parseCodeQuery(trimmed);
|
||||
const mode: "code" | "label" = codes ? "code" : "label";
|
||||
const maxProducts = Math.min(2000, Math.max(1, opts.maxProducts ?? envNumber("QLIK_SEARCH_MAX_PRODUCTS", 300, 1, 2000)));
|
||||
const qlikSettleMs = envNumber("QLIK_SETTLE_MS", 150, 0, 2000);
|
||||
const qlikCode15MaxAttempts = envNumber("QLIK_CODE15_MAX_ATTEMPTS", 4, 1, 8);
|
||||
const qlikMoisDimId = (process.env.QLIK_MOIS_DIM_ID ?? "pfGAwTs").trim();
|
||||
const qlikMeasQteCompId = (process.env.QLIK_MEAS_QTE_COMP_ID ?? "96862880-76cd-4957-bf25-b96901f2ac5f").trim();
|
||||
const qlikDimArticleLabelId = (process.env.QLIK_DIM_ARTICLE_LABEL_ID ?? "BcFj").trim();
|
||||
// Noms de champs candidats pour le libellé article (repli si la master dimension échoue).
|
||||
const labelFieldCandidates = [
|
||||
...(process.env.QLIK_FIELD_ARTICLE_LABEL ? [process.env.QLIK_FIELD_ARTICLE_LABEL.trim()] : []),
|
||||
"Article", "Libellé Article", "Libelle Article", "Article Libellé", "Désignation",
|
||||
];
|
||||
const yearN = new Date().getFullYear();
|
||||
|
||||
console.log(`[qlik-pw][search] mode=${mode} query="${trimmed}" max=${maxProducts}${dateFilter ? ` fenêtre=${dateFilter.label}` : ""}`);
|
||||
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-pw][search][page] ${msg.text()}`));
|
||||
page.on("pageerror", (e) => console.log(`[qlik-pw][search][pageerror] ${e.message}`));
|
||||
|
||||
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 ouvre son ws ensuite */ });
|
||||
await Promise.race([tokenReady, page.waitForTimeout(15000)]);
|
||||
if (!csrfToken) throw new Error("[qlik-pw] qlik-csrf-token introuvable (client Qlik non chargé ?)");
|
||||
|
||||
const allSerials = dateFilter ? getMonthRanges(dateFilter).flatMap((m) => m.dailySerials) : [];
|
||||
|
||||
const result = (await page.evaluate(
|
||||
({ app, token, dimCode, dimLabelId, labelFields, mca, mqte, mnb, mcamag, mmarge, mqteComp, moisDimId, mode, codes, match, allSerials, maxProducts, settleMs, code15Attempts, yearN }: {
|
||||
app: string; token: string; dimCode: string; dimLabelId: string; labelFields: string[];
|
||||
mca: string; mqte: string; mnb: string; mcamag: string; mmarge: string; mqteComp: string;
|
||||
moisDimId: string; mode: "code" | "label"; codes: string[]; match: string;
|
||||
allSerials: number[]; maxProducts: number; settleMs: number; code15Attempts: number; yearN: 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);
|
||||
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 fail = (error: string) => resolve({ ok: false, error });
|
||||
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);
|
||||
});
|
||||
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
||||
const msgOf = (e: unknown) => String((e as Error)?.message ?? e);
|
||||
const isEngineCode15 = (e: unknown): boolean => /"code"\s*:\s*15\b/.test(msgOf(e));
|
||||
const rpcWithRetry = async (etape: string, method: string, params: unknown, handle: number): Promise<{ [k: string]: unknown }> => {
|
||||
let lastErr: unknown;
|
||||
for (let attempt = 1; attempt <= code15Attempts; attempt++) {
|
||||
try { return await rpc(method, params, handle); }
|
||||
catch (e) {
|
||||
lastErr = e;
|
||||
if (!isEngineCode15(e) || attempt === code15Attempts) throw e;
|
||||
console.log("[search] retry code15 " + etape + ":" + method + " " + attempt + "/" + code15Attempts);
|
||||
await sleep(settleMs * attempt);
|
||||
}
|
||||
}
|
||||
throw lastErr;
|
||||
};
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
await ready;
|
||||
const open = await rpc("OpenDoc", { qDocName: app });
|
||||
const doc = (open.qReturn as { qHandle: number }).qHandle;
|
||||
|
||||
// Une recherche doit porter sur TOUT le catalogue : on repart d'un
|
||||
// état sans sélection (l'app peut en avoir une enregistrée). Non
|
||||
// fatal si l'Engine refuse.
|
||||
try { await rpc("ClearAll", { qLockedAlso: false }, doc); await sleep(settleMs); }
|
||||
catch (e) { console.log("[search] ClearAll ignoré: " + msgOf(e)); }
|
||||
|
||||
// ── Résolution des mesures ET dimensions par TITRE ──────────────
|
||||
const norm = (s: string) => s.replace(/\s+/g, " ").trim().toLowerCase();
|
||||
const listByTitle = async (qType: string, defKey: string, listKey: string): Promise<Map<string, string>> => {
|
||||
const map = new Map<string, string>();
|
||||
try {
|
||||
const o = await rpc("CreateSessionObject", { qProp: { qInfo: { qType }, [defKey]: { qType: qType === "MeasureList" ? "measure" : "dimension", qData: { title: "/qMetaDef/title" } } } }, doc);
|
||||
const l = await rpc("GetLayout", {}, (o.qReturn as { qHandle: number }).qHandle);
|
||||
const layout = l.qLayout as Record<string, { qItems?: Array<{ qInfo: { qId: string }; qMeta?: { title?: string }; qData?: { title?: string } }> }>;
|
||||
for (const it of layout[listKey]?.qItems ?? []) {
|
||||
const t = it.qMeta?.title ?? it.qData?.title ?? "";
|
||||
if (t) map.set(norm(t), it.qInfo.qId);
|
||||
}
|
||||
} catch (e) { console.log("[search] " + qType + " KO: " + msgOf(e)); }
|
||||
return map;
|
||||
};
|
||||
const measByTitle = await listByTitle("MeasureList", "qMeasureListDef", "qMeasureList");
|
||||
const dimByTitle = await listByTitle("DimensionList", "qDimensionListDef", "qDimensionList");
|
||||
const pickMeas = (title: string, fallback: string) => measByTitle.get(norm(title)) ?? fallback;
|
||||
const rCa = pickMeas("CA N", mca);
|
||||
const rQte = pickMeas("Quantité N", mqte);
|
||||
const rNbMag = pickMeas("Magasin Ventes Nb N", mnb);
|
||||
const rCamag = pickMeas("CA par Magasin N", mcamag);
|
||||
const rMarge = pickMeas("Marge % N", mmarge);
|
||||
const dimLabel = dimByTitle.get(norm("Article")) ?? dimLabelId;
|
||||
const dimFou = dimByTitle.get(norm("Fournisseur")) ?? null;
|
||||
const dimFam = dimByTitle.get(norm("Famille")) ?? null;
|
||||
console.log("[search] dims: label=" + dimLabel + " fou=" + dimFou + " fam=" + dimFam);
|
||||
|
||||
// ── Champs Date / Année ─────────────────────────────────────────
|
||||
let dfh = -1, yfh = -1;
|
||||
try { dfh = ((await rpc("GetField", { qFieldName: "Date" }, doc)).qReturn as { qHandle: number }).qHandle; } catch { dfh = -1; }
|
||||
try { yfh = ((await rpc("GetField", { qFieldName: "Année" }, doc)).qReturn as { qHandle: number }).qHandle; } catch { yfh = -1; }
|
||||
|
||||
// ── Sélection des articles recherchés ───────────────────────────
|
||||
let matchCount = -1;
|
||||
if (mode === "code") {
|
||||
const fh = ((await rpc("GetField", { qFieldName: "Article Code" }, doc)).qReturn as { qHandle: number }).qHandle;
|
||||
await rpc("Clear", {}, fh);
|
||||
await sleep(settleMs);
|
||||
const sel = await rpc("SelectValues", { qFieldValues: codes.map((c) => ({ qText: c })), qToggleMode: false, qSoftLock: true }, fh);
|
||||
console.log("[search] SelectValues Article Code (" + codes.length + ") → " + JSON.stringify(sel.qReturn));
|
||||
matchCount = codes.length;
|
||||
await sleep(settleMs);
|
||||
} else {
|
||||
// Liste de recherche sur le libellé : master dimension d'abord,
|
||||
// puis repli sur des noms de champs plausibles.
|
||||
const defs: Array<{ label: string; def: Record<string, unknown> }> = [];
|
||||
// qHeight: 1 — on ne veut que la taille du résultat de recherche,
|
||||
// mais certains Engine rejettent un qHeight nul.
|
||||
const fetchDef = [{ qTop: 0, qLeft: 0, qWidth: 1, qHeight: 1 }];
|
||||
if (dimLabel) defs.push({ label: "libraryId:" + dimLabel, def: { qLibraryId: dimLabel, qInitialDataFetch: fetchDef } });
|
||||
for (const f of labelFields) defs.push({ label: "field:" + f, def: { qDef: { qFieldDefs: [f] }, qInitialDataFetch: fetchDef } });
|
||||
let lo: { handle: number; id: string } | null = null;
|
||||
for (const d of defs) {
|
||||
try {
|
||||
const o = await rpc("CreateSessionObject", { qProp: { qInfo: { qType: "cf-search-lb" }, qListObjectDef: d.def } }, doc);
|
||||
const ret = o.qReturn as { qHandle: number; qGenericId?: string; qId?: string };
|
||||
await rpc("GetLayout", {}, ret.qHandle); // valide que la définition tient
|
||||
lo = { handle: ret.qHandle, id: String(ret.qGenericId ?? ret.qId ?? "") };
|
||||
console.log("[search] listObject OK via " + d.label);
|
||||
break;
|
||||
} catch (e) { console.log("[search] listObject KO via " + d.label + ": " + msgOf(e)); }
|
||||
}
|
||||
if (!lo) { ws.close(); return fail("impossible de construire la recherche sur le libellé article (dimension « Article » introuvable)"); }
|
||||
|
||||
await rpcWithRetry("search", "SearchListObjectFor", { qPath: "/qListObjectDef", qMatch: match }, lo.handle);
|
||||
await sleep(settleMs);
|
||||
try {
|
||||
const lol = await rpcWithRetry("searchLayout", "GetLayout", {}, lo.handle);
|
||||
const cy = (lol.qLayout as { qListObject?: { qSize?: { qcy?: number } } })?.qListObject?.qSize?.qcy;
|
||||
matchCount = typeof cy === "number" ? cy : -1;
|
||||
} catch (e) { console.log("[search] layout recherche KO: " + msgOf(e)); }
|
||||
console.log("[search] SearchListObjectFor \"" + match + "\" → " + matchCount + " valeur(s)");
|
||||
// Garde-fou : une recherche trop large ferait exploser le cube
|
||||
// (et la mémoire du moteur Qlik, déjà sensible).
|
||||
if (matchCount > maxProducts) {
|
||||
try { if (lo.id) await rpc("DestroySessionObject", { qId: lo.id }, doc); } catch { /* noop */ }
|
||||
ws.close();
|
||||
return resolve({ ok: true, tooMany: true, matchCount, rows: [], monthly: {}, meta: {} });
|
||||
}
|
||||
await rpcWithRetry("accept", "AcceptListObjectSearch", { qPath: "/qListObjectDef", qToggleMode: false, qSoftLock: true }, lo.handle);
|
||||
await sleep(settleMs);
|
||||
try { if (lo.id) await rpc("DestroySessionObject", { qId: lo.id }, doc); } catch { /* noop */ }
|
||||
}
|
||||
|
||||
// ── Cube mensuel [Article Code, Article, Mois] × 6 mesures ──────
|
||||
const normMois = (m: string): string => (/^\d{6}$/.test(m) ? m.slice(0, 4) + "-" + m.slice(4) : m);
|
||||
const PAGEM = 1000; // 9 colonnes × 1000 = 9000 cellules < 10000 (limite Qlik)
|
||||
const rows: Array<Array<string | number>> = [];
|
||||
const monthlyByCode: Record<string, Record<string, number>> = {};
|
||||
|
||||
const fetchMonthCube = async (isMainPass: boolean): Promise<number> => {
|
||||
const o = await rpcWithRetry("createCube", "CreateSessionObject", { qProp: { qInfo: { qType: "cf-search-mois" }, qHyperCubeDef: {
|
||||
qDimensions: [{ qLibraryId: dimCode }, { qLibraryId: dimLabel }, { qLibraryId: moisDimId }],
|
||||
qMeasures: [{ qLibraryId: rCa }, { qLibraryId: rQte }, { qLibraryId: rNbMag }, { qLibraryId: rCamag }, { qLibraryId: rMarge }, { qLibraryId: mqteComp }],
|
||||
qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 9, qHeight: PAGEM }],
|
||||
} } }, doc);
|
||||
const ret = o.qReturn as { qHandle: number; qGenericId?: string; qId?: string };
|
||||
const cubeId = String(ret.qGenericId ?? ret.qId ?? "");
|
||||
const onRow = (r: Array<{ qText?: string; qNum?: number }>) => {
|
||||
const code = String(r[0]?.qText ?? "").trim();
|
||||
if (!code || code === "-") return;
|
||||
const lib = String(r[1]?.qText ?? "").trim();
|
||||
const mm = normMois(String(r[2]?.qText ?? ""));
|
||||
const ca = Number(r[3]?.qNum) || 0, qte = Number(r[4]?.qNum) || 0, nbMag = Number(r[5]?.qNum) || 0;
|
||||
const caMag = Number(r[6]?.qNum) || 0, marge = Number(r[7]?.qNum) || 0, qteComp = Number(r[8]?.qNum) || 0;
|
||||
// Les totaux réseau ne viennent QUE de la passe principale
|
||||
// (sinon la passe N-1 doublerait CA/Qté).
|
||||
if (isMainPass) rows.push([code, lib, ca, qte, nbMag, caMag, marge]);
|
||||
const y = parseInt(mm.slice(0, 4), 10);
|
||||
(monthlyByCode[code] ??= {});
|
||||
if (isMainPass && y === yearN) {
|
||||
monthlyByCode[code][mm] = qte;
|
||||
// COMP = même mois de l'année N-1 : comble la 1re moitié des 12 mois.
|
||||
const mmPrev = String(y - 1) + mm.slice(4);
|
||||
if (monthlyByCode[code][mmPrev] === undefined) monthlyByCode[code][mmPrev] = qteComp;
|
||||
} else {
|
||||
// Passe année N-1 : valeur directe, prioritaire sur COMP.
|
||||
monthlyByCode[code][mm] = qte;
|
||||
}
|
||||
};
|
||||
try {
|
||||
const layout = await rpcWithRetry("layout", "GetLayout", {}, ret.qHandle);
|
||||
const hc = (layout.qLayout as { qHyperCube: { qSize: { qcy: number }; qDataPages?: Array<{ qArea?: { qTop?: number }; qMatrix?: Array<Array<{ qText?: string; qNum?: number }>> }> } }).qHyperCube;
|
||||
const size = hc.qSize.qcy;
|
||||
let got = 0, top = 0;
|
||||
const first = hc.qDataPages?.find((p) => (p.qArea?.qTop ?? 0) === 0);
|
||||
if (first?.qMatrix?.length) { for (const r of first.qMatrix) onRow(r); got += first.qMatrix.length; top = PAGEM; }
|
||||
for (; top < size; top += PAGEM) {
|
||||
const d = await rpcWithRetry("getCubeData", "GetHyperCubeData", { qPath: "/qHyperCubeDef", qPages: [{ qTop: top, qLeft: 0, qWidth: 9, qHeight: PAGEM }] }, ret.qHandle);
|
||||
const matrix = (d.qDataPages as Array<{ qMatrix?: Array<Array<{ qText?: string; qNum?: number }>> }>)?.[0]?.qMatrix ?? [];
|
||||
if (!matrix.length) break;
|
||||
for (const r of matrix) onRow(r);
|
||||
got += matrix.length;
|
||||
if (matrix.length < PAGEM) break;
|
||||
}
|
||||
return got;
|
||||
} finally {
|
||||
try { if (cubeId) await rpc("DestroySessionObject", { qId: cubeId }, doc); } catch { /* noop */ }
|
||||
}
|
||||
};
|
||||
|
||||
// Une passe = (Année ?) + fenêtre Date 12 mois + cube mensuel.
|
||||
// La sélection article posée plus haut reste active : on ne touche
|
||||
// qu'aux champs Année / Date.
|
||||
const runPass = async (selectYear: number | null): Promise<number> => {
|
||||
if (yfh !== -1) {
|
||||
await rpc("Clear", {}, yfh);
|
||||
await sleep(settleMs);
|
||||
if (selectYear !== null) {
|
||||
await rpc("SelectValues", { qFieldValues: [{ qNum: selectYear, qText: String(selectYear) }], qToggleMode: false, qSoftLock: true }, yfh);
|
||||
await sleep(settleMs);
|
||||
}
|
||||
}
|
||||
if (dfh !== -1 && allSerials.length) {
|
||||
await rpc("Clear", {}, dfh);
|
||||
await sleep(settleMs);
|
||||
await rpc("SelectValues", { qFieldValues: allSerials.map((s) => ({ qNum: s, qText: String(s) })), qToggleMode: false, qSoftLock: true }, dfh);
|
||||
await sleep(settleMs);
|
||||
}
|
||||
return fetchMonthCube(selectYear === null);
|
||||
};
|
||||
|
||||
const gotMain = await runPass(null);
|
||||
console.log("[search] passe principale — " + gotMain + " lignes, " + Object.keys(monthlyByCode).length + " codes");
|
||||
if (yfh !== -1) {
|
||||
// Passe complémentaire : mois de N-1 hors de portée de COMP
|
||||
// (ex. août→déc), indispensable pour 12 mois contigus.
|
||||
try {
|
||||
const gotPrev = await runPass(yearN - 1);
|
||||
console.log("[search] passe N-1 — " + gotPrev + " lignes");
|
||||
} catch (e) { console.log("[search] passe N-1 ignorée: " + msgOf(e)); }
|
||||
try { await rpc("Clear", {}, yfh); await sleep(settleMs); } catch { /* noop */ }
|
||||
}
|
||||
|
||||
// ── Métadonnées (fournisseur / famille) — best effort ───────────
|
||||
const meta: Record<string, { fournisseur?: string; famille?: string }> = {};
|
||||
if (dimFou || dimFam) {
|
||||
try {
|
||||
const extra: string[] = [];
|
||||
if (dimFou) extra.push(dimFou);
|
||||
if (dimFam) extra.push(dimFam);
|
||||
const width = 1 + extra.length;
|
||||
const o = await rpcWithRetry("metaCube", "CreateSessionObject", { qProp: { qInfo: { qType: "cf-search-meta" }, qHyperCubeDef: {
|
||||
qDimensions: [{ qLibraryId: dimCode }, ...extra.map((q) => ({ qLibraryId: q }))],
|
||||
qMeasures: [],
|
||||
qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: width, qHeight: 1500 }],
|
||||
} } }, doc);
|
||||
const ret = o.qReturn as { qHandle: number; qGenericId?: string; qId?: string };
|
||||
const layout = await rpcWithRetry("metaLayout", "GetLayout", {}, ret.qHandle);
|
||||
const hc = (layout.qLayout as { qHyperCube: { qDataPages?: Array<{ qMatrix?: Array<Array<{ qText?: string }>> }> } }).qHyperCube;
|
||||
for (const r of hc.qDataPages?.[0]?.qMatrix ?? []) {
|
||||
const code = String(r[0]?.qText ?? "").trim();
|
||||
if (!code || code === "-") continue;
|
||||
let i = 1;
|
||||
const entry: { fournisseur?: string; famille?: string } = {};
|
||||
if (dimFou) { const v = String(r[i++]?.qText ?? "").trim(); if (v && v !== "-") entry.fournisseur = v; }
|
||||
if (dimFam) { const v = String(r[i++]?.qText ?? "").trim(); if (v && v !== "-") entry.famille = v; }
|
||||
meta[code] = entry;
|
||||
}
|
||||
try { await rpc("DestroySessionObject", { qId: String(ret.qGenericId ?? ret.qId ?? "") }, doc); } catch { /* noop */ }
|
||||
} catch (e) { console.log("[search] méta fournisseur/famille ignorée: " + msgOf(e)); }
|
||||
}
|
||||
|
||||
ws.close();
|
||||
resolve({ ok: true, rows, monthly: monthlyByCode, meta, matchCount, tooMany: false });
|
||||
} catch (e) { try { ws.close(); } catch { /* noop */ } fail(msgOf(e)); }
|
||||
})();
|
||||
}),
|
||||
{
|
||||
app: cfg.appNetwork,
|
||||
token: csrfToken,
|
||||
dimCode: cfg.dimCodeArticleId,
|
||||
dimLabelId: qlikDimArticleLabelId,
|
||||
labelFields: labelFieldCandidates,
|
||||
mca: cfg.measCaId,
|
||||
mqte: cfg.measQteId,
|
||||
mnb: cfg.measNbMagId,
|
||||
mcamag: cfg.measCaMagId,
|
||||
mmarge: cfg.measMargePctId,
|
||||
mqteComp: qlikMeasQteCompId,
|
||||
moisDimId: qlikMoisDimId,
|
||||
mode,
|
||||
codes: codes ?? [],
|
||||
match: trimmed,
|
||||
allSerials,
|
||||
maxProducts,
|
||||
settleMs: qlikSettleMs,
|
||||
code15Attempts: qlikCode15MaxAttempts,
|
||||
yearN,
|
||||
},
|
||||
)) as InPageSearchResult;
|
||||
|
||||
if (!result.ok) throw new Error(`[qlik-pw][search] ${result.error}`);
|
||||
const matchCount = result.matchCount ?? -1;
|
||||
if (result.tooMany) {
|
||||
console.log(`[qlik-pw][search] refusé — ${matchCount} valeurs > max ${maxProducts}`);
|
||||
return { products: [], matchCount, tooMany: true, mode, periode: dateFilter?.label };
|
||||
}
|
||||
|
||||
// Agrégation par code centrale (même pondération que la sync réseau :
|
||||
// marge % moyennée par le CA).
|
||||
const byCode = new Map<string, NetworkProduct>();
|
||||
const margeWeight = new Map<string, number>();
|
||||
for (const r of result.rows ?? []) {
|
||||
const code = String(r[0]).trim();
|
||||
if (!code || code === "-") continue;
|
||||
const libelle = String(r[1] ?? "").trim();
|
||||
const ca = Number(r[2]) || 0;
|
||||
const qte = Number(r[3]) || 0;
|
||||
const nbMag = Number(r[4]) || 0;
|
||||
const margePct = Number(r[6]) || 0;
|
||||
const prev = byCode.get(code);
|
||||
if (!prev) {
|
||||
byCode.set(code, {
|
||||
codeCentrale: code,
|
||||
libelle,
|
||||
caReseau: ca,
|
||||
qteReseau: qte,
|
||||
nbMagasinsReseau: nbMag,
|
||||
caParMagasinReseau: nbMag > 0 ? ca / nbMag : 0,
|
||||
margePctReseau: margePct,
|
||||
periode: dateFilter?.label,
|
||||
});
|
||||
margeWeight.set(code, ca);
|
||||
continue;
|
||||
}
|
||||
if (!prev.libelle && libelle) prev.libelle = libelle;
|
||||
prev.caReseau += ca;
|
||||
prev.qteReseau += qte;
|
||||
prev.nbMagasinsReseau = Math.max(prev.nbMagasinsReseau, nbMag);
|
||||
const weight = margeWeight.get(code) ?? 0;
|
||||
const nextWeight = weight + ca;
|
||||
prev.margePctReseau = nextWeight > 0 ? ((prev.margePctReseau * weight) + (margePct * ca)) / nextWeight : Math.max(prev.margePctReseau, margePct);
|
||||
prev.caParMagasinReseau = prev.nbMagasinsReseau > 0 ? prev.caReseau / prev.nbMagasinsReseau : 0;
|
||||
margeWeight.set(code, nextWeight);
|
||||
}
|
||||
|
||||
const monthly = result.monthly ?? {};
|
||||
const meta = result.meta ?? {};
|
||||
for (const [code, p] of byCode) {
|
||||
const mm = monthly[code];
|
||||
if (mm && Object.keys(mm).length) p.qteByMonth = mm;
|
||||
const m = meta[code];
|
||||
if (m?.fournisseur) p.fournisseur = m.fournisseur;
|
||||
if (m?.famille) p.famille = m.famille;
|
||||
}
|
||||
|
||||
const products = [...byCode.values()].sort((a, b) => b.caReseau - a.caReseau);
|
||||
console.log(`[qlik-pw][search] ${products.length} produits (match=${matchCount}) en ${Date.now() - totalStart}ms`);
|
||||
return { products, matchCount: matchCount >= 0 ? matchCount : products.length, tooMany: false, mode, periode: dateFilter?.label };
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user