merge: intègre main — main prioritaire, abandon de la page en doublon

main a beaucoup avancé (53 commits) pendant que cette branche construisait
l'API. La fusion a révélé que /produits, sur main, fait la même chose que la
page Recherche réseau d'ici, en plus abouti : fiche produit, opportunités,
URL partageables, et sa propre chaîne Qlik (lib/qlik-search, pgSearchProduits).

Résolution, main prioritaire :
- sidebar, heatmap-grid, qlik-playwright : version de main telle quelle. Son
  travail sur la tendance et Qlik est postérieur et plus complet.
- get-product-rows : import NB_MAGASINS_RESEAU de main, plus l'appel
  upsertGridRows d'ici — sans lui /api/v1 n'aurait aucune donnée à servir.
- settings : les deux côtés (ServerLogs de main + les sections API d'ici).

Suppression du doublon : page recherche-reseau, /api/qlik/search, ses types et
composants. searchNetworkProductsPlaywright disparaît avec la version main de
qlik-playwright ; rien d'autre ne l'utilisait.

api-enrich se branche désormais sur le module de tendance de main
(features/grid/lib/network-trend) plutôt que sur le mien, supprimé : sa mesure
est meilleure — variation sur la quantité par magasin vendeur, ce qui distingue
« vend mieux » de « est distribué plus largement » — et l'API renvoie ainsi
exactement ce qu'affiche la Grille. Le champ trend gagne surQteParMagasin et
nouveau.

Apport conservé de la branche : l'API /api/v1, absente de main.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675
This commit is contained in:
Claude committed 2026-08-06 11:13:44 +00:00
commit 3fa92e891b
50 files changed
+8488 -2017

No files matched your search

+4 -1
View File
@@ -113,7 +113,10 @@ async function main() {
ALTER TABLE "qlik_network_metrics"
ADD COLUMN IF NOT EXISTS "ca_par_magasin_reseau" numeric(16, 2),
ADD COLUMN IF NOT EXISTS "marge_pct_reseau" numeric(8, 4),
ADD COLUMN IF NOT EXISTS "qte_by_month" jsonb;
ADD COLUMN IF NOT EXISTS "qte_by_month" jsonb,
ADD COLUMN IF NOT EXISTS "metrics_by_month" jsonb,
ADD COLUMN IF NOT EXISTS "libelle_reseau" varchar(255),
ADD COLUMN IF NOT EXISTS "fournisseur_reseau" varchar(255);
ALTER TABLE "qlik_network_metrics" DROP COLUMN IF EXISTS "rupture_pct_reseau";
ALTER TABLE "qlik_network_metrics" DROP COLUMN IF EXISTS "couverture_stock_reseau";
`);
+354
View File
@@ -0,0 +1,354 @@
/**
* CollectFlow — Validation des expressions Qlik et de la clé de jointure.
*
* À lancer DEPUIS le réseau qui atteint Qlik (typiquement le conteneur de l'app) :
*
* docker exec -e QLIK_PWD='<mot de passe>' -it <conteneur> node scripts/qlik-validate.mjs
*
* Répond aux deux questions encore ouvertes :
*
* 1. Les master measures « CA N » / « Quantité N » ignorent-elles la sélection
* Date, et les agrégations directes (`Sum(quantite)`, `Sum(ca_ht)`…) sont-elles
* un substitut fidèle ? Le script affiche, pour quelques articles réellement
* vendus, un tableau mois par mois comparant les deux — dont les mois d'août à
* décembre qui ressortaient vides.
*
* 2. Quelle valeur joint avec `articles.artcentrale` : la dimension « Article
* Code » ou le champ `article_no_centrale` ? Le script affiche les deux
* côte à côte.
*
* Aucun secret n'est écrit ni affiché : le mot de passe vient de l'environnement.
* Le script est en LECTURE SEULE (sélections en soft lock, objets de session
* détruits) — il ne modifie rien dans Qlik.
*/
import httpntlm from "httpntlm";
import { randomBytes } from "node:crypto";
import { chromium } from "playwright-core";
process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0"; // certificat interne
const HOST = process.env.QLIK_HOST || "reporting-magasins.lafoirfouille.fr";
const USER = process.env.QLIK_USER || "FFSCH";
const PWD = process.env.QLIK_PWD || "";
const DOMAIN = process.env.QLIK_DOMAIN ?? "";
const APP = process.env.QLIK_APP_NETWORK || "9872ee6e-d64a-4b43-984a-076bf1f7f647";
const DIM_ARTICLE = process.env.QLIK_DIM_CODE_ARTICLE_ID || "yesCP";
const DIM_MOIS = process.env.QLIK_MOIS_DIM_ID || "pfGAwTs";
const NB_ARTICLES = Number(process.env.QLIK_VALIDATE_ARTICLES || "3");
if (!PWD) {
console.error("ERREUR : définir QLIK_PWD dans l'environnement.");
console.error(" docker exec -e QLIK_PWD='…' -it <conteneur> node scripts/qlik-validate.mjs");
process.exit(1);
}
const xrfkey = randomBytes(12).toString("base64").replace(/[^a-zA-Z0-9]/g, "").slice(0, 16).padEnd(16, "0");
// ─── Fenêtre 12 mois glissants, mois courant exclu (même règle que l'app) ────
const QLIK_EPOCH_MS = Date.UTC(1899, 11, 30);
const serialDe = (y, m, d) => Math.round((Date.UTC(y, m - 1, d) - QLIK_EPOCH_MS) / 86_400_000);
function fenetre12Mois(now = new Date()) {
const debut = new Date(now.getFullYear(), now.getMonth() - 12, 1);
const fin = new Date(now.getFullYear(), now.getMonth(), 0);
const serials = [];
const s0 = serialDe(debut.getFullYear(), debut.getMonth() + 1, debut.getDate());
const s1 = serialDe(fin.getFullYear(), fin.getMonth() + 1, fin.getDate());
for (let s = s0; s <= s1; s++) serials.push(s);
const labels = [];
for (let i = 12; i >= 1; i--) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
labels.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`);
}
return { serials, labels, debut: debut.toISOString().slice(0, 10), fin: fin.toISOString().slice(0, 10) };
}
// ─── Auth NTLM → cookie de session ──────────────────────────────────────────
function ntlmGet(path) {
return new Promise((resolve, reject) => {
httpntlm.get(
{
url: `https://${HOST}${path}`,
username: USER,
password: PWD,
domain: DOMAIN,
workstation: "",
rejectUnauthorized: false,
headers: { "User-Agent": "Mozilla/5.0 CollectFlow", "X-Qlik-Xrfkey": xrfkey },
},
(err, res) => (err ? reject(err) : resolve(res)),
);
});
}
function cookieDepuis(setCookie) {
const arr = Array.isArray(setCookie) ? setCookie : setCookie ? [String(setCookie)] : [];
return arr.map((c) => String(c).split(";")[0]).find((c) => /X-Qlik-Session/i.test(c)) ?? null;
}
async function session() {
const hub = await fetch(`https://${HOST}/hub/`, { redirect: "manual" });
const targetId = ((hub.headers.get("location") ?? "").match(/targetId=([0-9a-f-]+)/i) ?? [])[1] ?? "";
const chemin = targetId
? `/internal_windows_authentication/?targetId=${targetId}&xrfkey=${xrfkey}`
: `/hub/?xrfkey=${xrfkey}`;
const res = await ntlmGet(chemin);
const direct = cookieDepuis(res.headers["set-cookie"]);
if (direct) return direct;
const loc = String(res.headers["location"] ?? "");
if (!/qlikTicket=/.test(loc)) throw new Error(`auth NTLM sans ticket (HTTP ${res.statusCode})`);
const ech = await fetch(loc, { redirect: "manual" });
const sc = typeof ech.headers.getSetCookie === "function" ? ech.headers.getSetCookie() : [];
const cookie = sc.map((c) => c.split(";")[0]).filter((c) => /X-Qlik-Session/i.test(c)).join("; ");
if (!cookie) throw new Error("ticket non échangé contre un cookie de session");
return cookie;
}
// ─── Script exécuté dans la page (websocket Engine accepté par le proxy) ────
function scriptInPage({ app, token, dimArticle, dimMois, serials, labels, nbArticles }) {
return new Promise((resolve) => {
const url = `wss://${location.host}/app/${app}?reloadUri=${encodeURIComponent(location.href)}&qlik-csrf-token=${token}`;
const ws = new WebSocket(url);
const sortie = [];
const log = (l) => { sortie.push(l); console.log("[cf] " + l); };
let id = 0;
const pend = new Map();
const rpc = (method, params, handle = -1) => new Promise((res, rej) => {
const i = ++id; pend.set(i, { res, rej });
ws.send(JSON.stringify({ jsonrpc: "2.0", id: i, handle, method, params }));
});
const pret = new Promise((res, rej) => {
ws.onmessage = (ev) => {
const m = JSON.parse(ev.data);
if (m.method === "OnConnected") return res();
if (m.id && pend.has(m.id)) {
const p = pend.get(m.id); pend.delete(m.id);
if (m.error) p.rej(new Error(JSON.stringify(m.error)));
else p.res(m.result);
}
};
ws.onerror = () => rej(new Error("websocket refusé (403 ?)"));
setTimeout(() => rej(new Error("timeout websocket")), 30000);
});
const dormir = (ms) => new Promise((r) => setTimeout(r, ms));
(async () => {
try {
await pret;
const doc = (await rpc("OpenDoc", { qDocName: app })).qReturn.qHandle;
// ─── 1. Expressions des master measures ──────────────────────
const ml = await rpc("CreateSessionObject", {
qProp: {
qInfo: { qType: "MeasureList" },
qMeasureListDef: { qType: "measure", qData: { title: "/qMetaDef/title", expr: "/qMeasure/qDef" } },
},
}, doc);
const items = (await rpc("GetLayout", {}, ml.qReturn.qHandle)).qLayout.qMeasureList?.qItems ?? [];
const parTitre = new Map();
log("=== EXPRESSIONS DES MASTER MEASURES ===");
for (const it of items) {
const t = (it.qMeta?.title ?? it.qData?.title ?? "").trim();
if (!t) continue;
parTitre.set(t.toLowerCase(), it.qInfo.qId);
if (/^(CA N|Quantité N|Magasin Ventes Nb N|CA par Magasin N|Marge % N|Quantité COMP)$/i.test(t)) {
log(` « ${t} » = ${it.qData?.expr ?? "?"}`);
}
}
const mesure = (t) => parTitre.get(t.toLowerCase()) ?? null;
// ─── 2. Sélection de la fenêtre 12 mois ──────────────────────
const champDate = await rpc("GetField", { qFieldName: "Date" }, doc);
const dh = champDate.qReturn.qHandle;
await rpc("Clear", {}, dh);
await dormir(300);
for (let i = 0; i < serials.length; i += 500) {
await rpc("SelectValues", {
qFieldValues: serials.slice(i, i + 500).map((s) => ({ qNum: s, qText: String(s) })),
qToggleMode: i > 0,
qSoftLock: true,
}, dh);
}
await dormir(500);
log("");
log(`=== FENÊTRE SÉLECTIONNÉE : ${labels[0]} → ${labels[labels.length - 1]} (${serials.length} jours) ===`);
// ─── 3. Articles les plus vendus (par agrégation directe) ────
const cubeTop = await rpc("CreateSessionObject", {
qProp: {
qInfo: { qType: "cf-val-top" },
qHyperCubeDef: {
qDimensions: [{ qLibraryId: dimArticle }],
qMeasures: [{ qDef: { qDef: "Sum(quantite)" }, qSortBy: { qSortByNumeric: -1 } }],
qInterColumnSortOrder: [1, 0],
qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 2, qHeight: nbArticles }],
},
},
}, doc);
const topMatrix = (await rpc("GetLayout", {}, cubeTop.qReturn.qHandle))
.qLayout.qHyperCube.qDataPages?.[0]?.qMatrix ?? [];
const codes = topMatrix.map((r) => String(r[0]?.qText ?? "")).filter(Boolean);
log(`articles testés (top ventes) : ${JSON.stringify(codes)}`);
if (codes.length === 0) { ws.close(); return resolve({ ok: false, error: "aucun article vendu sur la fenêtre", sortie }); }
const champArticle = await rpc("GetField", { qFieldName: "Article Code" }, doc);
const ah = champArticle.qReturn.qHandle;
await rpc("Clear", {}, ah);
await dormir(300);
await rpc("SelectValues", { qFieldValues: codes.map((c) => ({ qText: c })), qToggleMode: false, qSoftLock: true }, ah);
await dormir(500);
// ─── 4. Comparaison mois par mois ────────────────────────────
const mesures = [
{ qDef: { qDef: "Sum(quantite)" } },
{ qDef: { qDef: "Sum(ca_ht)" } },
{ qDef: { qDef: "Sum(ca_ttc)" } },
{ qDef: { qDef: "Count(DISTINCT [Magasin Code])" } },
{ qDef: { qDef: "Sum(marge)" } },
];
const idQte = mesure("Quantité N");
const idCa = mesure("CA N");
const idNbMag = mesure("Magasin Ventes Nb N");
if (idQte) mesures.push({ qLibraryId: idQte });
if (idCa) mesures.push({ qLibraryId: idCa });
if (idNbMag) mesures.push({ qLibraryId: idNbMag });
const largeur = 2 + mesures.length;
const cube = await rpc("CreateSessionObject", {
qProp: {
qInfo: { qType: "cf-val-mois" },
qHyperCubeDef: {
qDimensions: [{ qLibraryId: dimArticle }, { qLibraryId: dimMois }],
qMeasures: mesures,
qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: largeur, qHeight: 400 }],
},
},
}, doc);
const hc = (await rpc("GetLayout", {}, cube.qReturn.qHandle)).qLayout.qHyperCube;
const lignes = hc.qDataPages?.[0]?.qMatrix ?? [];
log("");
log("=== COMPARAISON MOIS PAR MOIS (agrégation directe vs master measure) ===");
log("article mois Sum(quantite) Quantité N | Sum(ca_ht) Sum(ca_ttc) CA N | Count(mag) Mag Nb N");
const pad = (v, n) => String(v).padStart(n);
for (const r of lignes) {
const code = String(r[0]?.qText ?? "");
const moisBrut = String(r[1]?.qText ?? "");
const mois = /^\d{6}$/.test(moisBrut) ? moisBrut.slice(0, 4) + "-" + moisBrut.slice(4) : moisBrut;
const n = (i) => Number(r[2 + i]?.qNum ?? 0) || 0;
log(
code.padEnd(13) + mois.padEnd(9) +
pad(Math.round(n(0)), 13) + pad(Math.round(n(5)), 12) + " |" +
pad(Math.round(n(1)), 11) + pad(Math.round(n(2)), 13) + pad(Math.round(n(6)), 9) + " |" +
pad(Math.round(n(3)), 11) + pad(Math.round(n(7)), 10),
);
}
log("");
log("LECTURE : si « Quantité N » vaut 0 sur les mois de l'année précédente alors que");
log(" Sum(quantite) est renseigné, la master measure est bien inutilisable");
log(" pour une fenêtre glissante — et l'agrégation directe la remplace.");
log(" Si Sum(quantite) == Quantité N sur les mois de l'année en cours,");
log(" l'expression par défaut de CollectFlow est validée.");
log(" Comparer aussi Sum(ca_ht) et Sum(ca_ttc) à « CA N » pour choisir");
log(" QLIK_EXPR_CA.");
// ─── 5. Clé de jointure : Article Code vs article_no_centrale ─
const cubeCle = await rpc("CreateSessionObject", {
qProp: {
qInfo: { qType: "cf-val-cle" },
qHyperCubeDef: {
qDimensions: [
{ qLibraryId: dimArticle },
{ qDef: { qFieldDefs: ["article_no_centrale"] } },
{ qDef: { qFieldDefs: ["article_codein"] } },
],
qMeasures: [],
qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 3, qHeight: 20 }],
},
},
}, doc);
const cles = (await rpc("GetLayout", {}, cubeCle.qReturn.qHandle))
.qLayout.qHyperCube.qDataPages?.[0]?.qMatrix ?? [];
log("");
log("=== CLÉ DE JOINTURE ===");
log("Article Code article_no_centrale article_codein");
for (const r of cles) {
log(
String(r[0]?.qText ?? "").padEnd(20) +
String(r[1]?.qText ?? "").padEnd(22) +
String(r[2]?.qText ?? ""),
);
}
log("");
log("LECTURE : la bonne clé est celle qui a le format de `articles.artcentrale`");
log(" côté FF Nancy (ex. 10000167303 — préfixe 10000 + 6 chiffres).");
ws.close();
resolve({ ok: true, sortie });
} catch (e) {
try { ws.close(); } catch { /* noop */ }
resolve({ ok: false, error: String(e?.message || e), sortie });
}
})();
});
}
// ─── Orchestration ──────────────────────────────────────────────────────────
(async () => {
const f = fenetre12Mois();
console.log(`Qlik ${HOST} — app ${APP}`);
console.log(`Fenêtre : ${f.debut} → ${f.fin}\n`);
const cookie = await session();
console.log("✓ authentifié (NTLM + ticket)\n");
const [nom, ...reste] = cookie.split("=");
const navigateur = await chromium.launch({
headless: true,
executablePath: process.env.PLAYWRIGHT_CHROMIUM_PATH || undefined,
args: ["--no-sandbox", "--disable-dev-shm-usage", "--ignore-certificate-errors"],
});
const ctx = await navigateur.newContext({
httpCredentials: { username: USER, password: PWD, origin: `https://${HOST}` },
ignoreHTTPSErrors: true,
});
await ctx.addCookies([{ name: nom, value: reste.join("="), domain: HOST, path: "/", secure: true }]);
try {
const page = await ctx.newPage();
let token = null;
let pret;
const attente = new Promise((r) => { pret = r; });
page.on("websocket", (ws) => {
const m = ws.url().match(/qlik-csrf-token=([^&]+)/);
if (m && !token) { token = decodeURIComponent(m[1]); pret(); }
});
await page.goto(`https://${HOST}/sense/app/${APP}`, { waitUntil: "domcontentloaded", timeout: 60000 }).catch(() => {});
await Promise.race([attente, page.waitForTimeout(20000)]);
if (!token) throw new Error("qlik-csrf-token introuvable (client Qlik non chargé ?)");
const res = await page.evaluate(scriptInPage, {
app: APP, token, dimArticle: DIM_ARTICLE, dimMois: DIM_MOIS,
serials: f.serials, labels: f.labels, nbArticles: NB_ARTICLES,
});
console.log(res.sortie.join("\n"));
if (!res.ok) {
console.error("\nÉCHEC : " + res.error);
process.exitCode = 1;
}
} finally {
await ctx.close();
await navigateur.close();
}
})().catch((e) => {
console.error("ERREUR :", e?.message || e);
process.exit(1);
});