mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-11 17:26:32 +02:00
feat(grid): replace ranking/AI/score with Qlik network data
Remove ranking, AI analysis (routes + ai-copilot module), and score-engine from the grid. Add Qlik Sense network metrics (CA, Qte, nb magasins per product) joined by code centrale (articles.artcentrale). - qlik-client.ts: NTLM + ticket SSO auth, QIX hypercube extraction (master items) - qlik-network-cache.ts + qlik_network_metrics table (db-init): cache layer - POST /api/qlik/sync?fournisseur=: per-supplier sync into cache - SyncQlikButton: per-supplier sync, grid reload, last-update date display - get-product-rows Phase 8: enrich rows with cached network metrics - heatmap-grid: CA reseau / Qte reseau / Magasins (/270) / % presence columns - discovery: app 9872ee6e, master items CA N / Quantite N / Magasin Ventes Nb N - deps: httpntlm, ws Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
02a10caa4b
commit
aee968d30f
34 files changed
+1043
-2925
No files matched your search
@@ -96,6 +96,18 @@ async function main() {
|
||||
`);
|
||||
console.log("[DB Init] Table commande_cadences is verified/created.");
|
||||
|
||||
await tempPool.query(`
|
||||
CREATE TABLE IF NOT EXISTS "qlik_network_metrics" (
|
||||
"code_centrale" varchar(20) PRIMARY KEY NOT NULL,
|
||||
"ca_reseau" numeric(16, 2),
|
||||
"qte_reseau" numeric(14, 2),
|
||||
"nb_magasins_reseau" integer,
|
||||
"periode" varchar(20),
|
||||
"fetched_at" timestamp DEFAULT now()
|
||||
);
|
||||
`);
|
||||
console.log("[DB Init] Table qlik_network_metrics is verified/created.");
|
||||
|
||||
await tempPool.end();
|
||||
console.log("[DB Init] Initialization successful. Exiting.");
|
||||
process.exit(0);
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* Discovery Qlik Sense — autonome, à lancer à la main :
|
||||
*
|
||||
* QLIK_PWD='<mot de passe>' node scripts/qlik-discover.mjs # liste les apps
|
||||
* QLIK_PWD='<mot de passe>' node scripts/qlik-discover.mjs <appGUID> # + champs & mesures de l'app
|
||||
*
|
||||
* Env (défauts adaptés à La Foir'Fouille) :
|
||||
* QLIK_HOST=reporting-magasins.lafoirfouille.fr
|
||||
* QLIK_USER=FFSCH
|
||||
* QLIK_DOMAIN=FOIRFOUILLE
|
||||
* QLIK_PWD=<requis>
|
||||
*
|
||||
* But : récupérer le GUID de l'app réseau (~270 magasins) + les noms exacts du champ
|
||||
* "code article" et des mesures (CA / Qté / nb magasins) pour remplir .env.local :
|
||||
* QLIK_APP_NETWORK / QLIK_DIM_CODE_ARTICLE / QLIK_MEAS_CA / QLIK_MEAS_QTE / QLIK_MEAS_NB_MAG
|
||||
*/
|
||||
|
||||
import httpntlm from "httpntlm";
|
||||
import { WebSocket } from "ws";
|
||||
import { randomBytes } from "node:crypto";
|
||||
|
||||
process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0"; // certif Qlik interne
|
||||
|
||||
const HOST = process.env.QLIK_HOST || "reporting-magasins.lafoirfouille.fr";
|
||||
const USER = process.env.QLIK_USER || "FFSCH";
|
||||
// Domaine vide par défaut : requests_ntlm("FFSCH", pwd) marche sans domaine explicite.
|
||||
const DOMAIN = process.env.QLIK_DOMAIN || "";
|
||||
const PWD = process.env.QLIK_PWD || "";
|
||||
const APP = process.argv[2] || process.env.QLIK_APP_NETWORK || "";
|
||||
|
||||
if (!PWD) {
|
||||
console.error("ERREUR: définis QLIK_PWD. Ex: QLIK_PWD='xxx' node scripts/qlik-discover.mjs");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const xrfkey = randomBytes(12).toString("base64").replace(/[^a-zA-Z0-9]/g, "").slice(0, 16).padEnd(16, "0");
|
||||
|
||||
function ntlmGet(path) {
|
||||
return new Promise((resolve, reject) => {
|
||||
httpntlm.get(
|
||||
{
|
||||
url: `https://${HOST}${path}`,
|
||||
username: USER,
|
||||
password: PWD,
|
||||
domain: DOMAIN,
|
||||
workstation: "",
|
||||
rejectUnauthorized: false,
|
||||
headers: { "X-Qlik-Xrfkey": xrfkey, "User-Agent": "Mozilla/5.0 CollectFlow" },
|
||||
},
|
||||
(err, res) => (err ? reject(err) : resolve(res)),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Récupère un targetId en initiant une requête non authentifiée vers /hub/.
|
||||
async function getTargetId() {
|
||||
const res = await fetch(`https://${HOST}/hub/`, { redirect: "manual" });
|
||||
const loc = res.headers.get("location") || "";
|
||||
const m = loc.match(/targetId=([0-9a-f-]+)/i);
|
||||
return m ? m[1] : "";
|
||||
}
|
||||
|
||||
// Flux SSO ticket Qlik :
|
||||
// 1. NTLM sur /internal_windows_authentication/?targetId=... -> 302 /hub/?qlikTicket=XXX
|
||||
// 2. GET /hub/?qlikTicket=XXX -> Qlik valide le ticket et pose le cookie X-Qlik-Session
|
||||
async function authCookie() {
|
||||
const tid = await getTargetId();
|
||||
const res = await ntlmGet(`/internal_windows_authentication/?targetId=${tid}&xrfkey=${xrfkey}`);
|
||||
const loc = res.headers.location || "";
|
||||
const ticket = (loc.match(/qlikTicket=([^&\s]+)/) || [])[1];
|
||||
console.log(` [auth] NTLM HTTP ${res.statusCode}, ticket=${ticket ? "oui" : "non"}`);
|
||||
if (!ticket) throw new Error(`pas de qlikTicket (HTTP ${res.statusCode})`);
|
||||
|
||||
// Échange ticket -> cookies de session (on garde TOUS les cookies posés)
|
||||
const r2 = await fetch(loc, { redirect: "manual" });
|
||||
const sc = typeof r2.headers.getSetCookie === "function"
|
||||
? r2.headers.getSetCookie()
|
||||
: (r2.headers.get("set-cookie") ? [r2.headers.get("set-cookie")] : []);
|
||||
const pairs = sc.map((c) => c.split(";")[0]);
|
||||
const hasSession = pairs.some((c) => /X-Qlik-Session/i.test(c));
|
||||
console.log(` [auth] échange ticket HTTP ${r2.status}, cookies=${JSON.stringify(pairs.map(p=>p.split("=")[0]))}`);
|
||||
if (!hasSession) throw new Error("ticket non échangé contre un cookie de session");
|
||||
return pairs.join("; ");
|
||||
}
|
||||
|
||||
// QRS via cookie de session + Xrfkey.
|
||||
async function qrsGet(path, cookie) {
|
||||
const sep = path.includes("?") ? "&" : "?";
|
||||
const res = await fetch(`https://${HOST}${path}${sep}xrfkey=${xrfkey}`, {
|
||||
headers: { Cookie: cookie, "X-Qlik-Xrfkey": xrfkey, Accept: "application/json" },
|
||||
});
|
||||
if (res.status !== 200) throw new Error(`QRS ${path} HTTP ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function qrsApps(cookie) {
|
||||
const apps = await qrsGet("/qrs/app", cookie);
|
||||
return apps.map((a) => ({ id: a.id, name: a.name, published: a.published, stream: a.stream?.name }));
|
||||
}
|
||||
|
||||
function engine(appGuid, cookie) {
|
||||
const ident = randomBytes(8).toString("hex");
|
||||
const url = `wss://${HOST}/app/${appGuid}/identity/${ident}?Xrfkey=${xrfkey}`;
|
||||
const ws = new WebSocket(url, {
|
||||
headers: { Cookie: cookie, "X-Qlik-Xrfkey": xrfkey, "User-Agent": "Mozilla/5.0" },
|
||||
rejectUnauthorized: false,
|
||||
});
|
||||
let id = 1;
|
||||
const pending = new Map();
|
||||
const ready = new Promise((resolve, reject) => {
|
||||
ws.on("message", (raw) => {
|
||||
const m = JSON.parse(raw.toString());
|
||||
if (m.method === "OnConnected") return resolve();
|
||||
if (m.id != null && pending.has(m.id)) {
|
||||
const p = pending.get(m.id);
|
||||
pending.delete(m.id);
|
||||
m.error ? p.reject(new Error(JSON.stringify(m.error))) : p.resolve(m.result);
|
||||
}
|
||||
});
|
||||
ws.on("unexpected-response", (_req, res) => {
|
||||
let body = "";
|
||||
res.on("data", (d) => (body += d));
|
||||
res.on("end", () => reject(new Error(`ws ${res.statusCode} server=${res.headers.server} wwwauth=${res.headers["www-authenticate"]} body=${body.slice(0, 120)}`)));
|
||||
});
|
||||
ws.on("error", reject);
|
||||
});
|
||||
const rpc = (method, params, handle = -1) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const i = id++;
|
||||
pending.set(i, { resolve, reject });
|
||||
ws.send(JSON.stringify({ jsonrpc: "2.0", id: i, handle, method, params }));
|
||||
});
|
||||
return { ready, rpc, close: () => ws.close() };
|
||||
}
|
||||
|
||||
async function dumpFieldsMeasures(appGuid, cookie) {
|
||||
const e = engine(appGuid, cookie);
|
||||
await e.ready;
|
||||
const open = await e.rpc("OpenDoc", { qDocName: appGuid });
|
||||
const doc = open.qReturn.qHandle;
|
||||
|
||||
// Liste des champs
|
||||
const fl = await e.rpc("CreateSessionObject", {
|
||||
qProp: { qInfo: { qType: "FieldList" }, qFieldListDef: { qShowSystem: false, qShowHidden: false, qShowSemantic: true, qShowSrcTables: true } },
|
||||
}, doc);
|
||||
const flLayout = await e.rpc("GetLayout", {}, fl.qReturn.qHandle);
|
||||
const fields = (flLayout.qLayout.qFieldList.qItems || []).map((f) => f.qName);
|
||||
|
||||
// Liste des mesures (master items)
|
||||
const ml = await e.rpc("CreateSessionObject", {
|
||||
qProp: { qInfo: { qType: "MeasureList" }, qMeasureListDef: { qType: "measure", qData: { title: "/qMetaDef/title", expr: "/qMeasure/qDef" } } },
|
||||
}, doc);
|
||||
const mlLayout = await e.rpc("GetLayout", {}, ml.qReturn.qHandle);
|
||||
const measures = (mlLayout.qLayout.qMeasureList.qItems || []).map((m) => ({
|
||||
title: m.qMeta?.title ?? m.qData?.title,
|
||||
expr: m.qData?.expr,
|
||||
}));
|
||||
|
||||
e.close();
|
||||
return { fields, measures };
|
||||
}
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
console.log(`Auth NTLM ${DOMAIN}\\${USER} @ ${HOST} ...`);
|
||||
const cookie = await authCookie();
|
||||
console.log("✓ Authentifié.\n");
|
||||
|
||||
const apps = await qrsApps(cookie);
|
||||
console.log(`=== ${apps.length} apps ===`);
|
||||
for (const a of apps) console.log(` ${a.id} [${a.stream || "-"}] ${a.name}`);
|
||||
|
||||
if (APP) {
|
||||
console.log(`\n=== Champs & mesures de l'app ${APP} ===`);
|
||||
const { fields, measures } = await dumpFieldsMeasures(APP, cookie);
|
||||
console.log(`\n-- Champs (${fields.length}) --`);
|
||||
for (const f of fields) console.log(` ${f}`);
|
||||
console.log(`\n-- Mesures master (${measures.length}) --`);
|
||||
for (const m of measures) console.log(` ${m.title} = ${m.expr}`);
|
||||
console.log(`\n→ Repère le champ "code article", et les expressions CA / Qté / nb magasins,`);
|
||||
console.log(` puis remplis QLIK_APP_NETWORK / QLIK_DIM_CODE_ARTICLE / QLIK_MEAS_* dans .env.local.`);
|
||||
} else {
|
||||
console.log(`\n→ Relance avec le GUID de l'app réseau pour lister ses champs & mesures :`);
|
||||
console.log(` QLIK_PWD='...' node scripts/qlik-discover.mjs <appGUID>`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("ERREUR:", e.message);
|
||||
process.exit(1);
|
||||
}
|
||||
})();
|
||||
Reference in new issue
Block a user