From 56bbfaebe080182065c859e491c34f367d455cbb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 19:33:00 +0000 Subject: [PATCH] =?UTF-8?q?Nettoyage=20:=20scripts=20Node=20et=20ressource?= =?UTF-8?q?s=20Next.js=20obsol=C3=A8tes=20supprim=C3=A9s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LG2LLJuswrrTTPKJmunEVf --- .viz-bundle.mjs | 229 --------------------- check_counts.js | 31 --- eslint.config.mjs | 4 +- package.json | 6 +- public/file.svg | 1 - public/globe.svg | 1 - public/next.svg | 1 - public/vercel.svg | 1 - public/window.svg | 1 - scripts/check-api-articles.js | 15 -- scripts/check-dashboard-api.js | 7 - scripts/db-init.js | 239 --------------------- scripts/diagnose-db.js | 48 ----- scripts/diagnose-db.ts | 27 --- scripts/diagnostic-ff.js | 232 --------------------- scripts/qlik-discover.mjs | 190 ----------------- scripts/qlik-validate.mjs | 354 -------------------------------- scripts/repro-500.js | 39 ---- scripts/test-db-cmd.js | 25 --- scripts/test-qlik-date-range.ts | 194 ----------------- verify-logic.js | 32 --- verify-parser.js | 59 ------ verify_limit.js | 37 ---- 23 files changed, 5 insertions(+), 1768 deletions(-) delete mode 100644 .viz-bundle.mjs delete mode 100644 check_counts.js delete mode 100644 public/file.svg delete mode 100644 public/globe.svg delete mode 100644 public/next.svg delete mode 100644 public/vercel.svg delete mode 100644 public/window.svg delete mode 100644 scripts/check-api-articles.js delete mode 100644 scripts/check-dashboard-api.js delete mode 100644 scripts/db-init.js delete mode 100644 scripts/diagnose-db.js delete mode 100644 scripts/diagnose-db.ts delete mode 100644 scripts/diagnostic-ff.js delete mode 100644 scripts/qlik-discover.mjs delete mode 100644 scripts/qlik-validate.mjs delete mode 100644 scripts/repro-500.js delete mode 100644 scripts/test-db-cmd.js delete mode 100644 scripts/test-qlik-date-range.ts delete mode 100644 verify-logic.js delete mode 100644 verify-parser.js delete mode 100644 verify_limit.js diff --git a/.viz-bundle.mjs b/.viz-bundle.mjs deleted file mode 100644 index be35014..0000000 --- a/.viz-bundle.mjs +++ /dev/null @@ -1,229 +0,0 @@ -// -import React from "react"; -import { renderToStaticMarkup } from "react-dom/server"; - -// src/features/grid/components/network-charts.tsx -import { useState } from "react"; - -// src/features/grid/lib/months.ts -function fmtMonthShort(lab) { - const [y, m] = lab.split("-"); - return `${m ?? lab}/${(y ?? "").slice(2)}`; -} - -// src/features/grid/components/network-charts.tsx -import { jsx, jsxs } from "react/jsx-runtime"; -var SERIE_COULEURS = ["var(--viz-1)", "var(--viz-2)", "var(--viz-3)"]; -function NetworkLineChart({ - labels: labels2, - values: values2, - stores: stores2, - perStore: perStore2 -}) { - const [survol, setSurvol] = useState(null); - const n = values2.length; - const memeTaille = (s) => Array.isArray(s) && s.length === n && n > 0; - const avecParMag = memeTaille(perStore2); - const avecMagasins = memeTaille(stores2); - const fmt = (v, d = 0) => v.toLocaleString("fr-FR", { minimumFractionDigits: d, maximumFractionDigits: d }); - const facettes = []; - if (avecParMag) { - facettes.push({ - titre: "Qt\xE9 / magasin", - valeurs: perStore2, - couleur: SERIE_COULEURS[0], - hauteur: 92, - aire: true, - decimales: perStore2.some((v) => v > 0 && v < 10) ? 1 : 0 - }); - facettes.push({ titre: "Quantit\xE9s vendues", valeurs: values2, couleur: SERIE_COULEURS[1], hauteur: 58 }); - } else { - facettes.push({ titre: "Quantit\xE9s vendues", valeurs: values2, couleur: SERIE_COULEURS[0], hauteur: 110, aire: true }); - } - if (avecMagasins) { - facettes.push({ titre: "Magasins vendeurs", valeurs: stores2, couleur: SERIE_COULEURS[2], hauteur: 46 }); - } - const W = 520; - const padL = 16, padR = 16, axeH = 18, enteteH = 15, ecart = 12; - const plotW = W - padL - padR; - const H = facettes.reduce((t, f) => t + enteteH + f.hauteur + ecart, 0) - ecart + axeH; - const x = (i) => n > 1 ? padL + i / (n - 1) * plotW : padL + plotW / 2; - const ancrage = (i) => i === 0 ? "start" : i === n - 1 ? "end" : "middle"; - let curseur = 0; - const disposees = facettes.map((f) => { - const hautEntete = curseur; - const haut = curseur + enteteH; - curseur = haut + f.hauteur + ecart; - const min = Math.min(0, ...f.valeurs); - const max = Math.max(...f.valeurs, min + 1); - const etendue = max - min || 1; - const y = (v) => haut + f.hauteur - (v - min) / etendue * f.hauteur; - const iMax = f.valeurs.indexOf(Math.max(...f.valeurs)); - return { ...f, hautEntete, haut, base: y(min), max, y, iMax }; - }); - const basPlots = curseur - ecart; - return /* @__PURE__ */ jsxs("div", { className: "mt-3 w-full overflow-x-auto", children: [ - /* @__PURE__ */ jsxs( - "svg", - { - viewBox: `0 0 ${W} ${H}`, - className: "w-full", - style: { minWidth: 420 }, - role: "img", - "aria-label": "Ventes r\xE9seau mensuelles : " + disposees.map((f) => f.titre).join(", "), - onMouseLeave: () => setSurvol(null), - children: [ - disposees.map((f) => { - const pts = f.valeurs.map((v, i) => `${x(i).toFixed(1)},${f.y(v).toFixed(1)}`).join(" "); - const aEtiqueter = [.../* @__PURE__ */ new Set([f.iMax, n - 1])]; - return /* @__PURE__ */ jsxs("g", { children: [ - /* @__PURE__ */ jsx("rect", { x: padL, y: f.hautEntete + 3, width: 12, height: 3, rx: 1.5, fill: f.couleur }), - /* @__PURE__ */ jsx("text", { x: padL + 18, y: f.hautEntete + 7, fontSize: 9.5, fontWeight: 600, fill: "var(--text-secondary)", dominantBaseline: "middle", children: f.titre }), - /* @__PURE__ */ jsxs("text", { x: padL + plotW, y: f.hautEntete + 7, textAnchor: "end", fontSize: 9, fill: "var(--text-muted)", dominantBaseline: "middle", children: [ - "max ", - fmt(f.max, f.decimales ?? 0), - f.unite ?? "" - ] }), - /* @__PURE__ */ jsx("line", { x1: padL, y1: f.base, x2: padL + plotW, y2: f.base, stroke: "var(--border)", strokeWidth: 1 }), - f.aire && n > 1 && /* @__PURE__ */ jsx( - "polygon", - { - points: `${padL},${f.base.toFixed(1)} ${pts} ${(padL + plotW).toFixed(1)},${f.base.toFixed(1)}`, - fill: f.couleur, - opacity: 0.1 - } - ), - n > 1 && /* @__PURE__ */ jsx("polyline", { points: pts, fill: "none", stroke: f.couleur, strokeWidth: 2, strokeLinejoin: "round", strokeLinecap: "round" }), - aEtiqueter.map((i) => /* @__PURE__ */ jsx( - "circle", - { - cx: x(i), - cy: f.y(f.valeurs[i]), - r: 4, - fill: f.couleur, - stroke: "var(--bg-surface)", - strokeWidth: 2 - }, - `pt-${f.titre}-${i}` - )), - aEtiqueter.map((i) => { - const yPoint = f.y(f.valeurs[i]); - const dessus = yPoint - 8 >= f.haut + 8; - return /* @__PURE__ */ jsx( - "text", - { - x: x(i), - y: dessus ? yPoint - 8 : yPoint + 13, - textAnchor: ancrage(i), - fontSize: 9, - fontWeight: 700, - fill: "var(--text-primary)", - children: fmt(f.valeurs[i], f.decimales ?? 0) - }, - `lab-${f.titre}-${i}` - ); - }), - survol != null && /* @__PURE__ */ jsx( - "circle", - { - cx: x(survol), - cy: f.y(f.valeurs[survol]), - r: 3.5, - fill: f.couleur, - stroke: "var(--bg-surface)", - strokeWidth: 2 - } - ) - ] }, f.titre); - }), - survol != null && /* @__PURE__ */ jsx("line", { x1: x(survol), y1: 0, x2: x(survol), y2: basPlots, stroke: "var(--border-strong)", strokeWidth: 1 }), - labels2.map((lab, i) => /* @__PURE__ */ jsx( - "text", - { - x: x(i), - y: H - 5, - textAnchor: ancrage(i), - fontSize: 8.5, - fill: survol === i ? "var(--text-primary)" : "var(--text-muted)", - fontWeight: survol === i ? 700 : 400, - children: fmtMonthShort(lab) - }, - `mois-${lab}` - )), - survol != null && (() => { - const largeur = 158; - const hauteur = 16 + disposees.length * 13 + 8; - const gauche = x(survol) + 10 + largeur > W - 4; - const bx = gauche ? x(survol) - 10 - largeur : x(survol) + 10; - return /* @__PURE__ */ jsxs("g", { pointerEvents: "none", children: [ - /* @__PURE__ */ jsx( - "rect", - { - x: bx, - y: 4, - width: largeur, - height: hauteur, - rx: 8, - fill: "var(--bg-elevated)", - stroke: "var(--border)", - strokeWidth: 1 - } - ), - /* @__PURE__ */ jsx("text", { x: bx + 9, y: 17, fontSize: 9.5, fontWeight: 700, fill: "var(--text-primary)", children: fmtMonthShort(labels2[survol]) }), - disposees.map((f, k) => /* @__PURE__ */ jsxs("g", { children: [ - /* @__PURE__ */ jsx("rect", { x: bx + 9, y: 26 + k * 13, width: 8, height: 3, rx: 1.5, fill: f.couleur }), - /* @__PURE__ */ jsx("text", { x: bx + 22, y: 30 + k * 13, fontSize: 9, fill: "var(--text-secondary)", children: f.titre }), - /* @__PURE__ */ jsx( - "text", - { - x: bx + largeur - 9, - y: 30 + k * 13, - textAnchor: "end", - fontSize: 9, - fontWeight: 700, - fill: "var(--text-primary)", - children: fmt(f.valeurs[survol], f.decimales ?? 0) - } - ) - ] }, `bulle-${f.titre}`)) - ] }); - })(), - labels2.map((lab, i) => /* @__PURE__ */ jsx( - "rect", - { - x: x(i) - plotW / (2 * Math.max(1, n - 1)), - y: 0, - width: plotW / Math.max(1, n - 1), - height: H, - fill: "transparent", - onMouseEnter: () => setSurvol(i) - }, - `survol-${lab}` - )) - ] - } - ), - /* @__PURE__ */ jsxs("details", { className: "mt-2", children: [ - /* @__PURE__ */ jsx("summary", { className: "text-[11px] cursor-pointer select-none", style: { color: "var(--text-muted)" }, children: "Voir les valeurs mois par mois" }), - /* @__PURE__ */ jsx("div", { className: "mt-1.5 overflow-x-auto", children: /* @__PURE__ */ jsxs("table", { className: "w-full text-[11px] tabular-nums", style: { borderCollapse: "collapse" }, children: [ - /* @__PURE__ */ jsx("thead", { children: /* @__PURE__ */ jsxs("tr", { style: { color: "var(--text-muted)" }, children: [ - /* @__PURE__ */ jsx("th", { className: "text-left font-medium py-1 pr-2", children: "Mois" }), - disposees.map((f) => /* @__PURE__ */ jsx("th", { className: "text-right font-medium py-1 pl-2", children: f.titre }, `th-${f.titre}`)) - ] }) }), - /* @__PURE__ */ jsx("tbody", { children: labels2.map((lab, i) => /* @__PURE__ */ jsxs("tr", { style: { borderTop: "1px solid var(--border)" }, children: [ - /* @__PURE__ */ jsx("td", { className: "py-1 pr-2", style: { color: "var(--text-secondary)" }, children: fmtMonthShort(lab) }), - disposees.map((f) => /* @__PURE__ */ jsx("td", { className: "text-right py-1 pl-2", style: { color: "var(--text-primary)" }, children: fmt(f.valeurs[i], f.decimales ?? 0) }, `td-${f.titre}-${lab}`)) - ] }, `tr-${lab}`)) }) - ] }) }) - ] }) - ] }); -} - -// -var labels = ["2025-08", "2025-09", "2025-10", "2025-11", "2025-12", "2026-01", "2026-02", "2026-03", "2026-04", "2026-05", "2026-06", "2026-07"]; -var values = [9953, 13258, 6353, 5429, 3741, 6522, 8211, 4437, 4035, 4066, 2936, 4470]; -var stores = [208, 228, 196, 177, 175, 193, 206, 190, 179, 179, 167, 170]; -var perStore = values.map((v, i) => stores[i] > 0 ? v / stores[i] : 0); -globalThis.__HTML__ = renderToStaticMarkup( - React.createElement(NetworkLineChart, { labels, values, stores, perStore }) -); diff --git a/check_counts.js b/check_counts.js deleted file mode 100644 index a8daf4e..0000000 --- a/check_counts.js +++ /dev/null @@ -1,31 +0,0 @@ - -const { Pool } = require('pg'); -const fs = require('fs'); -const path = require('path'); - -const CONFIG_PATH = path.join(process.cwd(), "data", ".db-config.json"); -let url = ""; -if (fs.existsSync(CONFIG_PATH)) { - url = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8")).url; -} - -const pool = new Pool({ connectionString: url || process.env.DATABASE_URL }); - -async function check() { - try { - const resSuppliers = await pool.query(\`SELECT DISTINCT code_fournisseur FROM ventes_produits LIMIT 5\`); - console.log("Suppliers in DB:", resSuppliers.rows.map(r => r.code_fournisseur)); - - for (const row of resSuppliers.rows) { - const supplier = row.code_fournisseur; - const countRes = await pool.query(\`SELECT COUNT(*) FROM ventes_produits WHERE code_fournisseur = $1\`, [supplier]); - const distinctCodein = await pool.query(\`SELECT COUNT(DISTINCT codein) FROM ventes_produits WHERE code_fournisseur = $1\`, [supplier]); - console.log(\`Supplier \${supplier}: Total Rows = \${countRes.rows[0].count}, Distinct Products = \${distinctCodein.rows[0].count}\`); - } - } catch (e) { - console.error("Error:", e); - } finally { - await pool.end(); - } -} -check(); diff --git a/eslint.config.mjs b/eslint.config.mjs index 8108e2e..294df75 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -7,10 +7,10 @@ import reactRefresh from "eslint-plugin-react-refresh"; /** * Lint de l'interface (SPA Vite). Seul `src/` est vérifié : `server/` est en - * Rust, `scripts/` et les fichiers .js de la racine sont des utilitaires Node. + * Rust. */ const eslintConfig = defineConfig([ - globalIgnores(["dist/**", "server/**", "scripts/**", "_bmad/**", "_bmad-output/**", "docs/**", "*.js", "*.mjs"]), + globalIgnores(["dist/**", "server/**", ".claude/**", "_bmad/**", "_bmad-output/**", "docs/**", "*.js", "*.mjs"]), { files: ["src/**/*.{ts,tsx}", "vite.config.mts"], extends: [js.configs.recommended, tseslint.configs.recommended, reactHooks.configs.flat.recommended], diff --git a/package.json b/package.json index 9571b2a..516d039 100644 --- a/package.json +++ b/package.json @@ -8,9 +8,9 @@ "preview": "vite preview", "lint": "eslint", "typecheck": "tsc --noEmit", - "docker:up": "docker-compose up -d", - "docker:down": "docker-compose down", - "docker:build": "docker-compose up -d --build" + "docker:up": "docker compose up -d", + "docker:down": "docker compose down", + "docker:build": "docker compose build" }, "dependencies": { "@tanstack/react-table": "^8.21.3", diff --git a/public/file.svg b/public/file.svg deleted file mode 100644 index 004145c..0000000 --- a/public/file.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/globe.svg b/public/globe.svg deleted file mode 100644 index 567f17b..0000000 --- a/public/globe.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/next.svg b/public/next.svg deleted file mode 100644 index 5174b28..0000000 --- a/public/next.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/vercel.svg b/public/vercel.svg deleted file mode 100644 index 7705396..0000000 --- a/public/vercel.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/window.svg b/public/window.svg deleted file mode 100644 index b2b2a44..0000000 --- a/public/window.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/scripts/check-api-articles.js b/scripts/check-api-articles.js deleted file mode 100644 index adf7de4..0000000 --- a/scripts/check-api-articles.js +++ /dev/null @@ -1,15 +0,0 @@ -const fs = require('fs'); -async function test() { - try { - const res = await fetch("https://api.ffnancy.fr/api/articles?search=334152"); - const data = await res.json(); - console.log("ARTICLES SEARCH:", JSON.stringify(data, null, 2)); - - const res2 = await fetch("https://api.ffnancy.fr/api/articles/334152/referentiel"); - const data2 = await res2.json(); - console.log("ARTICLES REFERENTIEL BY CODEIN:", JSON.stringify(data2, null, 2)); - } catch(e) { - console.log(e.message); - } -} -test(); diff --git a/scripts/check-dashboard-api.js b/scripts/check-dashboard-api.js deleted file mode 100644 index a3614ac..0000000 --- a/scripts/check-dashboard-api.js +++ /dev/null @@ -1,7 +0,0 @@ -const fs = require('fs'); -async function test() { - const res = await fetch("https://api.ffnancy.fr/api/performance/dashboard?date=2024-04-20"); - const data = await res.json(); - console.log(JSON.stringify(data.top10_ca?.[0] || {}, null, 2)); -} -test(); diff --git a/scripts/db-init.js b/scripts/db-init.js deleted file mode 100644 index c4290e2..0000000 --- a/scripts/db-init.js +++ /dev/null @@ -1,239 +0,0 @@ -const { Pool } = require('pg'); -const fs = require('fs'); -const path = require('path'); - -const cwd = path.resolve(__dirname, '..'); // get app root when inside /scripts - -async function main() { - console.log("[DB Init] Starting database migration & initialization..."); - let connectionString = ""; - - try { - const CONFIG_PATH = path.join(cwd, "data", ".db-config.json"); - if (fs.existsSync(CONFIG_PATH)) { - const config = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8")); - if (config.url) { - connectionString = config.url; - console.log("[DB Init] Found connection string in data/.db-config.json"); - } - } - } catch (e) { - console.error("[DB Init] Error reading .db-config.json", e); - } - - // Use environment variable as default IF NOT already loaded from config - if (!connectionString && process.env.DATABASE_URL) { - connectionString = process.env.DATABASE_URL; - console.log("[DB Init] Using environment variable DATABASE_URL as fallback."); - } - - if (!connectionString) { - console.warn("[DB Init] No DATABASE_URL found in env or config. Skipping push."); - process.exit(0); - } - - try { - const tempPool = new Pool({ connectionString, max: 1 }); - - console.log("[DB Init] Connection established. Running migrations via raw SQL..."); - - await tempPool.query(` - CREATE TABLE IF NOT EXISTS "ai_supplier_context" ( - "code_fournisseur" varchar(20) PRIMARY KEY NOT NULL, - "context" text NOT NULL, - "updated_at" timestamp DEFAULT now() - ); - `); - console.log("[DB Init] Table ai_supplier_context is verified/created."); - - - await tempPool.query(` - CREATE TABLE IF NOT EXISTS "session_snapshots" ( - "id" serial PRIMARY KEY NOT NULL, - "user_id" integer, - "code_fournisseur" varchar(20) NOT NULL, - "nom_fournisseur" varchar(255), - "magasin" varchar(20) NOT NULL, - "changes" jsonb NOT NULL, - "summary_json" jsonb, - "label" text, - "type" varchar(20) DEFAULT 'snapshot', - "created_at" timestamp DEFAULT now() - ); - `); - // Index : la Grille relit le dernier snapshot du fournisseur à chaque calcul - // et à chaque enregistrement, et l'historique liste ceux de l'utilisateur. - // Sans eux, chaque lecture parcourait toute la table (qui ne fait que grossir). - await tempPool.query(` - CREATE INDEX IF NOT EXISTS "idx_snapshots_fournisseur_date" - ON "session_snapshots" ("code_fournisseur", "created_at" DESC); - CREATE INDEX IF NOT EXISTS "idx_snapshots_user_type_date" - ON "session_snapshots" ("user_id", "type", "created_at" DESC); - `); - console.log("[DB Init] Table session_snapshots is verified/created."); - - await tempPool.query(` - CREATE TABLE IF NOT EXISTS "users" ( - "id" serial PRIMARY KEY NOT NULL, - "username" varchar(50) NOT NULL UNIQUE, - "password_hash" text NOT NULL, - "role" varchar(20) NOT NULL DEFAULT 'user', - "created_at" timestamp DEFAULT now() - ); - `); - console.log("[DB Init] Table users is verified/created."); - - await tempPool.query(` - CREATE TABLE IF NOT EXISTS "commande_cadences" ( - "id" serial PRIMARY KEY NOT NULL, - "code_fournisseur" varchar(20) NOT NULL, - "nom_fournisseur" varchar(255), - "site" varchar(20) NOT NULL, - "intervalle_semaines" integer NOT NULL, - "actif" boolean NOT NULL DEFAULT true, - "created_at" timestamp DEFAULT now(), - "updated_at" timestamp DEFAULT now() - ); - `); - await tempPool.query(` - CREATE UNIQUE INDEX IF NOT EXISTS "uq_cadence_fou_site" - ON "commande_cadences" ("code_fournisseur", "site"); - `); - await tempPool.query(` - CREATE INDEX IF NOT EXISTS "idx_cadence_site" - ON "commande_cadences" ("site"); - `); - 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, - "ca_par_magasin_reseau" numeric(16, 2), - "marge_pct_reseau" numeric(8, 4), - "periode" varchar(20), - "fetched_at" timestamp DEFAULT now() - ); - `); - // Colonnes ajoutées après coup (DB existante) — CREATE IF NOT EXISTS ne les pose pas. - await tempPool.query(` - 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 "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"; - `); - console.log("[DB Init] Table qlik_network_metrics is verified/created."); - - // Instantané persisté de la grille : lu par /api/v1 pour ne jamais - // déclencher de recalcul. Rempli en effet de bord par getProductRows(). - await tempPool.query(` - CREATE TABLE IF NOT EXISTS "grid_rows" ( - "code_fournisseur" varchar(20) NOT NULL, - "codein" varchar(20) NOT NULL, - "nom_fournisseur" varchar(255), - "libelle1" varchar(500), - "gtin" varchar(30), - "reference" varchar(100), - "code_centrale" varchar(20), - "code1" varchar(20), - "code2" varchar(20), - "code3" varchar(20), - "code_gamme" varchar(20), - "code_gamme_init" varchar(20), - "total_ca" numeric(16, 2), - "total_quantite" numeric(14, 2), - "total_marge" numeric(16, 2), - "taux_marge" numeric(8, 4), - "stock_actuel" numeric(14, 2), - "ca_reseau" numeric(16, 2), - "qte_reseau" numeric(14, 2), - "nb_magasins_reseau" integer, - "ca_par_magasin_reseau" numeric(16, 2), - "marge_pct_reseau" numeric(8, 4), - "payload" jsonb NOT NULL, - "computed_at" timestamp DEFAULT now() NOT NULL - ); - CREATE UNIQUE INDEX IF NOT EXISTS "uq_grid_rows_fou_codein" - ON "grid_rows" ("code_fournisseur", "codein"); - CREATE INDEX IF NOT EXISTS "idx_grid_rows_fournisseur" ON "grid_rows" ("code_fournisseur"); - CREATE INDEX IF NOT EXISTS "idx_grid_rows_codein" ON "grid_rows" ("codein"); - CREATE INDEX IF NOT EXISTS "idx_grid_rows_gamme" ON "grid_rows" ("code_gamme"); - CREATE INDEX IF NOT EXISTS "idx_grid_rows_code_centrale" ON "grid_rows" ("code_centrale"); - CREATE INDEX IF NOT EXISTS "idx_grid_rows_total_ca" ON "grid_rows" ("total_ca"); - CREATE INDEX IF NOT EXISTS "idx_grid_rows_libelle" ON "grid_rows" ("libelle1"); - `); - console.log("[DB Init] Table grid_rows is verified/created."); - - // Clés d'API : seul le hachage SHA-256 est stocké. - await tempPool.query(` - CREATE TABLE IF NOT EXISTS "api_keys" ( - "id" serial PRIMARY KEY NOT NULL, - "name" varchar(100) NOT NULL, - "key_prefix" varchar(20) NOT NULL, - "key_hash" text NOT NULL UNIQUE, - "role" varchar(20) DEFAULT 'user' NOT NULL, - "created_by" varchar(50), - "created_at" timestamp DEFAULT now(), - "last_used_at" timestamp, - "revoked_at" timestamp - ); - `); - console.log("[DB Init] Table api_keys is verified/created."); - - // Paramétrage et suivi de la synchronisation nocturne, par fournisseur. - await tempPool.query(` - CREATE TABLE IF NOT EXISTS "sync_fournisseurs" ( - "code_fournisseur" varchar(20) PRIMARY KEY NOT NULL, - "nom_fournisseur" varchar(255), - "actif_sql" boolean NOT NULL DEFAULT true, - "actif_qlik" boolean NOT NULL DEFAULT true, - "desactive_motif" text, - "dernier_sql_at" timestamp, - "dernier_sql_statut" varchar(20), - "dernier_sql_lignes" integer, - "dernier_sql_erreur" text, - "dernier_sql_ms" integer, - "dernier_qlik_at" timestamp, - "dernier_qlik_statut" varchar(20), - "dernier_qlik_codes" integer, - "dernier_qlik_erreur" text, - "dernier_qlik_ms" integer, - "updated_at" timestamp DEFAULT now() - ); - CREATE INDEX IF NOT EXISTS "idx_sync_fou_sql" ON "sync_fournisseurs" ("actif_sql", "dernier_sql_at"); - CREATE INDEX IF NOT EXISTS "idx_sync_fou_qlik" ON "sync_fournisseurs" ("actif_qlik", "dernier_qlik_at"); - `); - console.log("[DB Init] Table sync_fournisseurs is verified/created."); - - // Gammes proposées par l'API /api/v1 : en attente de validation dans la Grille. - await tempPool.query(` - CREATE TABLE IF NOT EXISTS "gammes_a_valider" ( - "code_fournisseur" varchar(20) NOT NULL, - "codein" varchar(20) NOT NULL, - "gamme" varchar(20) NOT NULL, - "gamme_precedente" varchar(20), - "auteur" varchar(100), - "created_at" timestamp DEFAULT now() NOT NULL, - PRIMARY KEY ("code_fournisseur", "codein") - ); - `); - console.log("[DB Init] Table gammes_a_valider is verified/created."); - - await tempPool.end(); - console.log("[DB Init] Initialization successful. Exiting."); - process.exit(0); - } catch (error) { - console.error("[DB Init] Initialization failed:", error); - // Do not crash the app, just let it run (it uses fallback JSON anyway) - process.exit(0); - } -} - -main(); diff --git a/scripts/diagnose-db.js b/scripts/diagnose-db.js deleted file mode 100644 index ad30ec5..0000000 --- a/scripts/diagnose-db.js +++ /dev/null @@ -1,48 +0,0 @@ - -const { Pool } = require('pg'); -const fs = require('fs'); -const path = require('path'); - -async function main() { - console.log("--- DB DIAGNOSIS Start (Node) ---"); - - let connectionString = process.env.DATABASE_URL; - - try { - const CONFIG_PATH = path.join(process.cwd(), "data", ".db-config.json"); - if (fs.existsSync(CONFIG_PATH)) { - const config = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8")); - if (config.url) { - connectionString = config.url; - } - } - } catch (err) {} - - if (!connectionString) { - console.error("No connection string found."); - return; - } - - const pool = new Pool({ connectionString }); - - try { - const res = await pool.query(` - SELECT - code_fournisseur, - nom_fournisseur, - COUNT(*) as total_rows, - COUNT(DISTINCT codein) as distinct_products - FROM ventes_produits - GROUP BY code_fournisseur, nom_fournisseur - ORDER BY total_rows DESC - `); - - console.table(res.rows); - } catch (error) { - console.error("Diagnosis failed:", error); - } finally { - await pool.end(); - } -} - -main(); diff --git a/scripts/diagnose-db.ts b/scripts/diagnose-db.ts deleted file mode 100644 index 197e27b..0000000 --- a/scripts/diagnose-db.ts +++ /dev/null @@ -1,27 +0,0 @@ - -import { db } from "@/db"; -import { ventesProduits } from "@/db/schema"; -import { sql } from "drizzle-orm"; - -async function main() { - console.log("--- DB DIAGNOSIS Start ---"); - try { - const results = await db.execute(sql` - SELECT - code_fournisseur, - nom_fournisseur, - COUNT(*) as total_rows, - COUNT(DISTINCT codein) as distinct_products - FROM ventes_produits - GROUP BY code_fournisseur, nom_fournisseur - ORDER BY total_rows DESC - `); - - console.table(results.rows); - } catch (error) { - console.error("Diagnosis failed:", error); - } - console.log("--- DB DIAGNOSIS End ---"); -} - -main(); diff --git a/scripts/diagnostic-ff.js b/scripts/diagnostic-ff.js deleted file mode 100644 index 9a672c7..0000000 --- a/scripts/diagnostic-ff.js +++ /dev/null @@ -1,232 +0,0 @@ -/** - * CollectFlow — Diagnostic des requêtes sur la base FF (lecture seule). - * - * Sert à préparer l'optimisation des requêtes lentes (Meilleures ventes, fiche - * produit, stocks, commandes) : le schéma et les index des tables FF ne sont pas - * dans le dépôt, il faut donc les relever sur la vraie base avant de réécrire - * quoi que ce soit. - * - * Usage (sur le serveur, ou dans le conteneur Docker de l'application) : - * - * node scripts/diagnostic-ff.js > diagnostic-ff.txt - * node scripts/diagnostic-ff.js --analyze > diagnostic-ff.txt - * - * Sans option, rien n'est exécuté : seuls les plans prévus (EXPLAIN) sont lus. - * `--analyze` exécute réellement les requêtes analysées pour mesurer leur durée - * (EXPLAIN ANALYZE) : à lancer de préférence en dehors des heures d'ouverture. - * La session est en lecture seule et chaque requête est coupée au-delà de 2 min. - * - * Connexion : `data/.db-config.json` (réglée dans Paramètres), sinon DATABASE_URL. - */ -const { Pool } = require("pg"); -const fs = require("fs"); -const path = require("path"); - -const ANALYZE = process.argv.includes("--analyze"); -const TABLES = ["articles", "mvtart", "cube_stock", "artfou1", "fouident", "nomenclature", "grid_rows", "session_snapshots"]; -const COLONNES = { - articles: ["no_id", "codein", "artcentrale", "nom_no_id", "libelle1"], - mvtart: ["no_id", "artnoid", "site", "datmvt", "genremvt"], - cube_stock: ["artnoid", "site", "qte"], - artfou1: ["no_id", "art_no_id", "code", "preference"], - fouident: ["code"], -}; - -function connexion() { - try { - const fichier = path.join(path.resolve(__dirname, ".."), "data", ".db-config.json"); - if (fs.existsSync(fichier)) { - const config = JSON.parse(fs.readFileSync(fichier, "utf-8")); - if (config.url) return config.url; - } - } catch { - /* configuration illisible : on tente la variable d'environnement */ - } - return process.env.DATABASE_URL || ""; -} - -function titre(texte) { - console.log(`\n${"=".repeat(78)}\n${texte}\n${"=".repeat(78)}`); -} - -async function section(nom, fn) { - titre(nom); - const debut = Date.now(); - try { - await fn(); - } catch (e) { - console.log(` (indisponible : ${e.message})`); - } - console.log(` [${Date.now() - debut} ms]`); -} - -async function plan(client, nom, requete, params = []) { - await section(`PLAN — ${nom}`, async () => { - const options = ANALYZE ? "ANALYZE, BUFFERS, FORMAT TEXT" : "FORMAT TEXT"; - const { rows } = await client.query(`EXPLAIN (${options}) ${requete}`, params); - for (const r of rows) console.log(` ${r["QUERY PLAN"]}`); - }); -} - -// Requêtes reprises de src/lib/pg-ff-client.ts (même forme, paramètres d'exemple). -const HIT_PARADE = ` -WITH ventes AS ( - SELECT TRIM(a.codein::text) AS codein, m.site, - SUM(-m.qtemvt)::float AS qte_vendue, SUM(-m.mntmvtttc)::float AS ca_ttc, SUM(m.margemvt)::float AS marge - FROM mvtart m JOIN articles a ON a.no_id = m.artnoid - WHERE m.datmvt BETWEEN $1::date AND $2::date AND m.site IN ('292', '579') AND m.genremvt = 3 AND a.codein IS NOT NULL - GROUP BY TRIM(a.codein::text), m.site -), -attrs AS ( - SELECT DISTINCT ON (TRIM(a.codein::text)) TRIM(a.codein::text) AS codein, a.libelle1::text AS libelle, - COALESCE(fi.nom, af.code, 'Sans fournisseur')::text AS fournisseur - FROM articles a - LEFT JOIN LATERAL (SELECT af1.code FROM artfou1 af1 WHERE af1.art_no_id = a.no_id AND af1.preference = 1 ORDER BY af1.code LIMIT 1) af ON TRUE - LEFT JOIN fouident fi ON fi.code = af.code - WHERE TRIM(a.codein::text) IN (SELECT codein FROM ventes) - ORDER BY TRIM(a.codein::text), a.no_id DESC -), -stock_agg AS ( - SELECT TRIM(a2.codein::text) AS codein, SUM(cs.qte)::float AS stock_total - FROM cube_stock cs JOIN articles a2 ON a2.no_id = cs.artnoid - WHERE TRIM(a2.codein::text) IN (SELECT codein FROM ventes) - GROUP BY TRIM(a2.codein::text) -) -SELECT v.*, ar.libelle, ar.fournisseur, s.stock_total -FROM ventes v LEFT JOIN attrs ar ON ar.codein = v.codein LEFT JOIN stock_agg s ON s.codein = v.codein -ORDER BY v.ca_ttc DESC`; - -const DERNIERE_RECEPTION = ` -SELECT af.code AS codefou, m.site, MAX(m.datmvt) AS derniere -FROM mvtart m -JOIN articles a ON a.no_id = m.artnoid -JOIN artfou1 af ON af.art_no_id = a.no_id AND af.preference = 1 -WHERE m.genremvt IN (1, 2) AND m.site IN ('292', '579') AND m.datmvt IS NOT NULL -GROUP BY af.code, m.site`; - -const MENSUEL_FOURNISSEUR = ` -SELECT a.codein, m.site, TO_CHAR(m.datmvt, 'YYYY-MM') AS mois, SUM(m.qtemvt) -FROM mvtart m -JOIN articles a ON a.no_id = m.artnoid -JOIN (SELECT DISTINCT art_no_id FROM artfou1 WHERE code = $1) af ON af.art_no_id = a.no_id -WHERE m.datmvt BETWEEN $2::date AND $3::date AND m.site IN ('292', '579') -GROUP BY a.codein, m.site, TO_CHAR(m.datmvt, 'YYYY-MM')`; - -const STOCK_SANS_VENTE = ` -SELECT a.codein, m.site, MAX(m.datmvt) -FROM mvtart m -JOIN articles a ON a.no_id = m.artnoid -LEFT JOIN cube_stock cs ON cs.artnoid = a.no_id AND cs.site = m.site -WHERE m.genremvt IN (1, 2) AND m.site IN ('292', '579') AND m.datmvt < date_trunc('month', CURRENT_DATE) - AND NOT EXISTS (SELECT 1 FROM mvtart mv2 WHERE mv2.artnoid = m.artnoid AND mv2.site = m.site AND mv2.genremvt = 3) -GROUP BY a.codein, m.site, cs.qte -HAVING COALESCE(cs.qte::float, 0) <> 0`; - -async function main() { - const url = connexion(); - if (!url) { - console.error("Aucune connexion : ni data/.db-config.json ni DATABASE_URL."); - process.exit(1); - } - const pool = new Pool({ connectionString: url, max: 1, application_name: "collectflow-diagnostic" }); - const client = await pool.connect(); - try { - await client.query("SET default_transaction_read_only = on"); - await client.query("SET statement_timeout = '120s'"); - await client.query("SET max_parallel_workers_per_gather = 0"); // comme l'application - - console.log(`Diagnostic FF — ${new Date().toISOString()} — mode ${ANALYZE ? "EXPLAIN ANALYZE (requêtes exécutées)" : "EXPLAIN (plans seuls)"}`); - - await section("Serveur PostgreSQL et réglages", async () => { - const { rows } = await client.query(` - SELECT name, setting, unit FROM pg_settings - WHERE name IN ('server_version', 'work_mem', 'shared_buffers', 'effective_cache_size', - 'random_page_cost', 'max_parallel_workers_per_gather', 'default_statistics_target') - ORDER BY name`); - for (const r of rows) console.log(` ${r.name.padEnd(32)} ${r.setting}${r.unit ? ` ${r.unit}` : ""}`); - }); - - await section("Taille des tables (lignes estimées, taille totale avec index)", async () => { - const { rows } = await client.query(` - SELECT c.relname, c.reltuples::bigint AS lignes, pg_size_pretty(pg_total_relation_size(c.oid)) AS taille, - s.last_analyze, s.last_autoanalyze - FROM pg_class c LEFT JOIN pg_stat_user_tables s ON s.relid = c.oid - WHERE c.relkind IN ('r', 'p', 'm', 'v') AND c.relname = ANY($1) - ORDER BY c.relname`, [TABLES]); - const trouvees = new Set(rows.map((r) => r.relname)); - for (const r of rows) { - const analyse = r.last_analyze || r.last_autoanalyze; - console.log(` ${r.relname.padEnd(20)} ${String(r.lignes).padStart(12)} lignes ${String(r.taille).padStart(10)} dernière analyse : ${analyse ? new Date(analyse).toISOString() : "jamais"}`); - } - for (const t of TABLES) if (!trouvees.has(t)) console.log(` ${t.padEnd(20)} (absente)`); - }); - - await section("Types des colonnes utilisées dans les jointures et filtres", async () => { - for (const [table, colonnes] of Object.entries(COLONNES)) { - const { rows } = await client.query(` - SELECT column_name, data_type, character_maximum_length, table_schema - FROM information_schema.columns WHERE table_name = $1 AND column_name = ANY($2) - ORDER BY column_name`, [table, colonnes]); - for (const r of rows) { - console.log(` ${`${r.table_schema}.${table}.${r.column_name}`.padEnd(36)} ${r.data_type}${r.character_maximum_length ? `(${r.character_maximum_length})` : ""}`); - } - } - }); - - await section("Index existants", async () => { - const { rows } = await client.query(` - SELECT tablename, indexname, indexdef FROM pg_indexes - WHERE tablename = ANY($1) ORDER BY tablename, indexname`, [TABLES]); - for (const r of rows) console.log(` ${r.tablename.padEnd(18)} ${r.indexdef}`); - if (rows.length === 0) console.log(" (aucun index trouvé sur ces tables)"); - }); - - await section("Codes article avec espaces (rend TRIM() nécessaire ou non)", async () => { - const { rows } = await client.query(` - SELECT count(*) AS total, - count(*) FILTER (WHERE codein::text <> TRIM(codein::text)) AS avec_espaces, - count(DISTINCT TRIM(codein::text)) AS distincts - FROM articles`); - const r = rows[0]; - console.log(` codein : ${r.total} articles — ${r.avec_espaces} avec espaces — ${r.distincts} codes distincts`); - try { - const { rows: c } = await client.query(` - SELECT count(*) FILTER (WHERE artcentrale::text <> TRIM(artcentrale::text)) AS avec_espaces - FROM articles WHERE artcentrale IS NOT NULL`); - console.log(` artcentrale : ${c[0].avec_espaces} avec espaces`); - } catch (e) { - console.log(` artcentrale : (indisponible : ${e.message})`); - } - }); - - // Paramètres d'exemple tirés de la base elle-même. - let codein = null; - let codefou = null; - try { - codein = (await client.query("SELECT TRIM(codein::text) AS c FROM articles WHERE codein IS NOT NULL LIMIT 1")).rows[0]?.c ?? null; - codefou = (await client.query("SELECT code FROM artfou1 WHERE preference = 1 GROUP BY code ORDER BY count(*) DESC LIMIT 1")).rows[0]?.code ?? null; - } catch { - /* tables absentes : les plans concernés le signaleront */ - } - const hier = new Date(Date.now() - 86_400_000).toISOString().slice(0, 10); - const ilYa7Jours = new Date(Date.now() - 7 * 86_400_000).toISOString().slice(0, 10); - const ilYa1An = new Date(Date.now() - 365 * 86_400_000).toISOString().slice(0, 10); - - await plan(client, `article par code, avec TRIM (codein ${codein})`, - "SELECT a.no_id FROM articles a WHERE TRIM(a.codein::text) = $1", [codein ?? ""]); - await plan(client, `article par code, sans TRIM (codein ${codein})`, - "SELECT a.no_id FROM articles a WHERE a.codein::text = $1", [codein ?? ""]); - await plan(client, `Meilleures ventes, 7 derniers jours (${ilYa7Jours} → ${hier})`, HIT_PARADE, [ilYa7Jours, hier]); - await plan(client, "Dernière réception par fournisseur (Commandes)", DERNIERE_RECEPTION); - await plan(client, `Ventes mensuelles d'un fournisseur sur 12 mois (fournisseur ${codefou})`, MENSUEL_FOURNISSEUR, [codefou ?? "", ilYa1An, hier]); - await plan(client, "Stock sans vente (forme simplifiée)", STOCK_SANS_VENTE); - } finally { - client.release(); - await pool.end(); - } -} - -main().catch((e) => { - console.error("Diagnostic interrompu :", e.message); - process.exit(1); -}); diff --git a/scripts/qlik-discover.mjs b/scripts/qlik-discover.mjs deleted file mode 100644 index 8dcfca9..0000000 --- a/scripts/qlik-discover.mjs +++ /dev/null @@ -1,190 +0,0 @@ -/** - * Discovery Qlik Sense — autonome, à lancer à la main : - * - * QLIK_PWD='' node scripts/qlik-discover.mjs # liste les apps - * QLIK_PWD='' node scripts/qlik-discover.mjs # + 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= - * - * 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 `); - } - } catch (e) { - console.error("ERREUR:", e.message); - process.exit(1); - } -})(); diff --git a/scripts/qlik-validate.mjs b/scripts/qlik-validate.mjs deleted file mode 100644 index 86e3a46..0000000 --- a/scripts/qlik-validate.mjs +++ /dev/null @@ -1,354 +0,0 @@ -/** - * 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='' -it 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 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); -}); diff --git a/scripts/repro-500.js b/scripts/repro-500.js deleted file mode 100644 index 59b38df..0000000 --- a/scripts/repro-500.js +++ /dev/null @@ -1,39 +0,0 @@ -const { computeProductScores } = require('./src/lib/score-engine'); - -// Simulate 50,000 rows -const largeDataset = Array.from({ length: 50000 }, (_, i) => ({ - codein: `item-${i}`, - codeFournisseur: 'A032', - nomFournisseur: 'ACCESS DECO', - libelle1: `Product ${i}`, - gtin: '123456789', - reference: 'REF', - code1: '20', - libelleNiveau1: 'LIQUIDES', - code2: '2001', - libelleNiveau2: 'EAUX', - code3: '200101', - libelle3: 'EAU SOURCE', - codeGamme: 'A', - codeGammeInit: 'A', - codeGammeDraft: null, - sales12m: {}, - totalQuantite: Math.random() * 1000, - totalCa: Math.random() * 5000, - totalMarge: Math.random() * 1000, - tauxMarge: 20, - score: 0, - workingStores: ['MAG1', 'MAG2'] -})); - -console.log(`Starting test with ${largeDataset.length} rows...`); - -try { - const start = Date.now(); - const result = computeProductScores(largeDataset); - const end = Date.now(); - console.log(`Success! Processed ${result.length} rows in ${end - start}ms.`); -} catch (error) { - console.error('Failed!', error); - process.exit(1); -} diff --git a/scripts/test-db-cmd.js b/scripts/test-db-cmd.js deleted file mode 100644 index 98cc6ec..0000000 --- a/scripts/test-db-cmd.js +++ /dev/null @@ -1,25 +0,0 @@ -require('dotenv').config({ path: 'c:/GIT/CollectFlow/.env.local' }); -const { Pool } = require('pg'); - -async function test() { - const pool = new Pool({ - connectionString: process.env.DATABASE_URL - }); - - try { - const res = await pool.query("SELECT * FROM cube_stock WHERE cs.site = '292' LIMIT 5"); - console.log("CUBE_STOCK:", res.rows); - } catch(e) { - console.log("ERROR cube_stock:", e.message); - } - - try { - const res2 = await pool.query("SELECT a.codein, a.no_id FROM articles a LIMIT 5"); - console.log("ARTICLES:", res2.rows); - } catch(e) { - console.log("ERROR articles:", e.message); - } - - pool.end(); -} -test(); diff --git a/scripts/test-qlik-date-range.ts b/scripts/test-qlik-date-range.ts deleted file mode 100644 index 43876e3..0000000 --- a/scripts/test-qlik-date-range.ts +++ /dev/null @@ -1,194 +0,0 @@ -/** - * Test CLI pour `src/lib/qlik-date-range.ts` (aucune dépendance Next.js). - * Usage : npx tsx scripts/test-qlik-date-range.ts - * - * Vérifie : - * - les repères Qlik (2025-01-01→45658, etc.) - * - la fenêtre 12 mois complets pour `now=2026-06-21` (doit donner - * dateDebut=2025-06-01, dateFin=2026-05-31, qStart=45809, qEnd=46173, - * label=2025-06_2026-05, 365 serials) - * - exemples supplémentaires (1er du mois, fin d'année, année bissextile) - * - erreurs attendues sur entrée mal formée - * - option `monthsBack` : 6 mois, bornes (clamp 1..12) - * - helper `envMonthsBack` : valeur absente, valide, hors bornes, non numérique - * - * Aucun framework : assertions manuelles, exit code 0/1. - */ - -import { - isoDateToQlikSerial, - qlikSerialToIsoDate, - buildGridNetworkQlikDateFilter, - chunkSerials, - getMonthRanges, - envMonthsBack, - QLIK_DATE_ANCHORS, - QLIK_MONTHS_BACK_MIN, - QLIK_MONTHS_BACK_MAX, - QLIK_MONTHS_BACK_DEFAULT, -} from "../src/lib/qlik-date-range"; - -let pass = 0; -let fail = 0; - -function eq(label: string, got: unknown, want: unknown): void { - const ok = JSON.stringify(got) === JSON.stringify(want); - if (ok) { - pass++; - console.log(` \u2713 ${label}`); - } else { - fail++; - console.log(` \u2717 ${label}\n got: ${JSON.stringify(got)}\n want: ${JSON.stringify(want)}`); - } -} - -function truthy(label: string, got: unknown): void { - if (got) { - pass++; - console.log(` \u2713 ${label}`); - } else { - fail++; - console.log(` \u2717 ${label} \u2014 falsy: ${JSON.stringify(got)}`); - } -} - -function throws(label: string, fn: () => unknown, re?: RegExp): void { - try { - fn(); - fail++; - console.log(` \u2717 ${label} \u2014 n'a pas lanc\u00e9 d'exception`); - } catch (e) { - const msg = String((e as Error)?.message ?? e); - if (!re || re.test(msg)) { - pass++; - console.log(` \u2713 ${label}`); - } else { - fail++; - console.log(` \u2717 ${label} \u2014 exception inattendue: ${msg}`); - } - } -} - -console.log("\n[1] Rep\u00e8res Qlik (skill qlik)"); -for (const a of QLIK_DATE_ANCHORS) { - eq(`isoDateToQlikSerial(${a.iso}) = ${a.serial}`, isoDateToQlikSerial(a.iso), a.serial); -} - -console.log("\n[2] Round-trip s\u00e9rial \u2194 ISO"); -for (const a of QLIK_DATE_ANCHORS) { - eq(`qlikSerialToIsoDate(${a.serial}) = ${a.iso}`, qlikSerialToIsoDate(a.serial), a.iso); -} - -console.log("\n[3] Fen\u00eatre grille (now=2026-06-21) \u2192 2025-06-01 .. 2026-05-31"); -const f = buildGridNetworkQlikDateFilter(new Date("2026-06-21T12:00:00Z")); -eq("dateDebut", f.dateDebut, "2025-06-01"); -eq("dateFin", f.dateFin, "2026-05-31"); -eq("qStart", f.qStart, 45809); -eq("qEnd", f.qEnd, 46173); -eq("label", f.label, "2025-06_2026-05"); -eq("setAnalysis", f.setAnalysis, `Date={">=45809<=46173"}`); -eq("dailySerials.length = 365", f.dailySerials.length, 365); -eq("dailySerials[0]", f.dailySerials[0], 45809); -eq("dailySerials[364]", f.dailySerials[364], 46173); - -console.log("\n[4] Fen\u00eatre grille (now=2026-03-15) \u2192 2025-03-01 .. 2026-02-28"); -const f2 = buildGridNetworkQlikDateFilter(new Date("2026-03-15T08:00:00Z")); -eq("dateDebut", f2.dateDebut, "2025-03-01"); -eq("dateFin", f2.dateFin, "2026-02-28"); -eq("qStart", f2.qStart, 45717); -eq("qEnd", f2.qEnd, 46081); - -console.log("\n[5] Fen\u00eatre grille (now=2025-01-05) \u2192 2024-01-01 .. 2024-12-31 (ann\u00e9e bissextile, 366 j)"); -const f3 = buildGridNetworkQlikDateFilter(new Date("2025-01-05T08:00:00Z")); -eq("dateDebut", f3.dateDebut, "2024-01-01"); -eq("dateFin", f3.dateFin, "2024-12-31"); -eq("dailySerials.length = 366", f3.dailySerials.length, 366); - -console.log("\n[6] chunkSerials"); -const chunks = chunkSerials(f.dailySerials, 100); -eq("chunks.length pour 365/100", chunks.length, 4); -eq("chunks[3].length = 65", chunks[3].length, 65); -eq("chunks[0][0]", chunks[0][0], 45809); -eq("chunks[3][64]", chunks[3][64], 46173); - -console.log("\n[7] Robustesse"); -throws("format invalide '2025/01/01'", () => isoDateToQlikSerial("2025/01/01"), /YYYY-MM-DD/); -throws("format vide", () => isoDateToQlikSerial(""), /YYYY-MM-DD/); -truthy("label non vide pour fenêtre valide", f.label && f.label.length > 0); -truthy("setAnalysis commence par 'Date='", f.setAnalysis.startsWith("Date=")); - -console.log("\n[8] getMonthRanges — fenêtre 12 mois (now=2026-06-21)"); -const months = getMonthRanges(f); -eq("12 mois exactement", months.length, 12); -eq("premier mois label", months[0].label, "2025-06"); -eq("premier mois dateDebut", months[0].dateDebut, "2025-06-01"); -eq("premier mois dateFin", months[0].dateFin, "2025-06-30"); -eq("premier mois serials.length = 30", months[0].dailySerials.length, 30); -eq("premier mois qStart", months[0].qStart, 45809); -eq("premier mois qEnd", months[0].qEnd, 45838); -eq("dernier mois label", months[11].label, "2026-05"); -eq("dernier mois dateDebut", months[11].dateDebut, "2026-05-01"); -eq("dernier mois dateFin", months[11].dateFin, "2026-05-31"); -eq("dernier mois serials.length = 31", months[11].dailySerials.length, 31); -// Couvre février d'une année non bissextile (2025) — window now=2025-06-15 → 2024-06..2025-05 -const fFeb25 = buildGridNetworkQlikDateFilter(new Date("2025-06-15T08:00:00Z")); -truthy("contient février 2025 (28j)", getMonthRanges(fFeb25).some((m) => m.label === "2025-02" && m.dailySerials.length === 28)); -// Couvre février d'une année bissextile (2024) -const fBiss = buildGridNetworkQlikDateFilter(new Date("2025-01-05T08:00:00Z")); -const monthsBiss = getMonthRanges(fBiss); -truthy("contient février 2024 (29j)", monthsBiss.some((m) => m.label === "2024-02" && m.dailySerials.length === 29)); -// Total jours = 365 (somme des serials) -const totalDays = months.reduce((acc, m) => acc + m.dailySerials.length, 0); -eq("somme des jours = 365", totalDays, 365); - -console.log("\n[9] getMonthRanges — fenêtre courte partielle (troncature dernier mois)"); -const fShort = { ...f, dateDebut: "2025-06-01", dateFin: "2025-08-15", qStart: 45809, qEnd: 45884, dailySerials: [], setAnalysis: "" }; -const monthsShort = getMonthRanges(fShort); -eq("3 mois (juin, juillet, août)", monthsShort.length, 3); -eq("août tronqué à 15j", monthsShort[2].dailySerials.length, 15); -eq("août dateFin = 2025-08-15", monthsShort[2].dateFin, "2025-08-15"); - -console.log("\n[10] monthsBack=6 (now=2026-06-21) → 2025-12-01 .. 2026-05-31"); -const f6 = buildGridNetworkQlikDateFilter(new Date("2026-06-21T12:00:00Z"), 6); -eq("dateDebut", f6.dateDebut, "2025-12-01"); -eq("dateFin", f6.dateFin, "2026-05-31"); -eq("qStart", f6.qStart, 45992); -eq("qEnd", f6.qEnd, 46173); -eq("label", f6.label, "2025-12_2026-05"); -eq("dailySerials.length = 182 (déc 2025:31 + jan:31 + fév:28 + mar:31 + avr:30 + mai:31)", f6.dailySerials.length, 182); -const months6 = getMonthRanges(f6); -eq("getMonthRanges: 6 mois exactement", months6.length, 6); -eq("premier mois label", months6[0].label, "2025-12"); -eq("dernier mois label", months6[5].label, "2026-05"); -const totalDays6 = months6.reduce((acc, m) => acc + m.dailySerials.length, 0); -eq("somme des jours = 182", totalDays6, 182); - -console.log("\n[11] monthsBack — bornes et clamp"); -eq("monthsBack=1 (now=2026-06-21) → 2026-05-01 .. 2026-05-31 (1 mois complet)", - (() => { const x = buildGridNetworkQlikDateFilter(new Date("2026-06-21T12:00:00Z"), 1); return x.dateDebut === "2026-05-01" && x.dateFin === "2026-05-31"; })(), - true); -eq("monthsBack=0 clampé à 1", (() => { const x = buildGridNetworkQlikDateFilter(new Date("2026-06-21T12:00:00Z"), 0); return x.dateDebut === "2026-05-01"; })(), true); -eq("monthsBack=24 clampé à 12 (max)", (() => { const x = buildGridNetworkQlikDateFilter(new Date("2026-06-21T12:00:00Z"), 24); return x.dateDebut === "2025-06-01" && x.dateFin === "2026-05-31"; })(), true); -eq("monthsBack=-5 clampé à 1", (() => { const x = buildGridNetworkQlikDateFilter(new Date("2026-06-21T12:00:00Z"), -5); return x.dateDebut === "2026-05-01"; })(), true); -eq("monthsBack=NaN ramené au défaut 12", (() => { const x = buildGridNetworkQlikDateFilter(new Date("2026-06-21T12:00:00Z"), Number.NaN); return x.dateDebut === "2025-06-01" && x.dateFin === "2026-05-31"; })(), true); -eq("monthsBack=undefined (par défaut) = défaut 12", (() => { const x = buildGridNetworkQlikDateFilter(new Date("2026-06-21T12:00:00Z")); return x.dateDebut === "2025-06-01" && x.dateFin === "2026-05-31"; })(), true); -eq("bornes exportées MIN=1", QLIK_MONTHS_BACK_MIN, 1); -eq("bornes exportées MAX=12", QLIK_MONTHS_BACK_MAX, 12); -eq("défaut exporté = 12", QLIK_MONTHS_BACK_DEFAULT, 12); - -console.log("\n[12] envMonthsBack — lecture env"); -const PREV = process.env.QLIK_TEST_MONTHS_BACK; -delete process.env.QLIK_TEST_MONTHS_BACK; -eq("env absent → défaut", envMonthsBack("QLIK_TEST_MONTHS_BACK"), QLIK_MONTHS_BACK_DEFAULT); -eq("env vide → défaut", (() => { process.env.QLIK_TEST_MONTHS_BACK = ""; return envMonthsBack("QLIK_TEST_MONTHS_BACK"); })(), QLIK_MONTHS_BACK_DEFAULT); -eq("env '6' → 6", (() => { process.env.QLIK_TEST_MONTHS_BACK = "6"; return envMonthsBack("QLIK_TEST_MONTHS_BACK"); })(), 6); -eq("env 'abc' → défaut", (() => { process.env.QLIK_TEST_MONTHS_BACK = "abc"; return envMonthsBack("QLIK_TEST_MONTHS_BACK"); })(), QLIK_MONTHS_BACK_DEFAULT); -eq("env '24' clampé à MAX=12", (() => { process.env.QLIK_TEST_MONTHS_BACK = "24"; return envMonthsBack("QLIK_TEST_MONTHS_BACK"); })(), QLIK_MONTHS_BACK_MAX); -eq("env '-3' clampé à MIN=1", (() => { process.env.QLIK_TEST_MONTHS_BACK = "-3"; return envMonthsBack("QLIK_TEST_MONTHS_BACK"); })(), QLIK_MONTHS_BACK_MIN); -eq("env '0' clampé à MIN=1", (() => { process.env.QLIK_TEST_MONTHS_BACK = "0"; return envMonthsBack("QLIK_TEST_MONTHS_BACK"); })(), QLIK_MONTHS_BACK_MIN); -eq("env '3.7' troncé à 3", (() => { process.env.QLIK_TEST_MONTHS_BACK = "3.7"; return envMonthsBack("QLIK_TEST_MONTHS_BACK"); })(), 3); -eq("env ' 8 ' trimé puis parsé → 8", (() => { process.env.QLIK_TEST_MONTHS_BACK = " 8 "; return envMonthsBack("QLIK_TEST_MONTHS_BACK"); })(), 8); -if (PREV === undefined) delete process.env.QLIK_TEST_MONTHS_BACK; else process.env.QLIK_TEST_MONTHS_BACK = PREV; - -console.log(`\n=== ${pass} passés, ${fail} échoués ===`); -if (fail > 0) process.exit(1); diff --git a/verify-logic.js b/verify-logic.js deleted file mode 100644 index 72ae854..0000000 --- a/verify-logic.js +++ /dev/null @@ -1,32 +0,0 @@ - -// Mocking the simplified logic for validation without imports hitting environment issues -function isRecentLogic(regularityScore, inactivityMonths) { - return (regularityScore || 0) < 3 && (inactivityMonths || 0) <= 1; -} - -const testCases = [ - { name: "Nouveau Récent", regularity: 1, inactivity: 0, expected: true }, - { name: "Nouveau Limite", regularity: 2, inactivity: 1, expected: true }, - { name: "Nouveau Ancien (6m)", regularity: 1, inactivity: 6, expected: false }, - { name: "Nouveau Hors Limite (2m)", regularity: 2, inactivity: 2, expected: false }, - { name: "Ancien Actif", regularity: 12, inactivity: 0, expected: false }, - { name: "VRAIMENT Nouveau (0 activité)", regularity: 0, inactivity: 0, expected: true } -]; - -console.log("--- Testing isRecent Logic (Simplified) ---"); -let allPassed = true; - -testCases.forEach(tc => { - const actual = isRecentLogic(tc.regularity, tc.inactivity); - const passed = actual === tc.expected; - console.log(`${passed ? "✅" : "❌"} [${tc.name}]: expected ${tc.expected}, got ${actual}`); - if (!passed) allPassed = false; -}); - -if (allPassed) { - console.log("\nALL LOGIC TESTS PASSED"); - process.exit(0); -} else { - console.log("\nSOME TESTS FAILED"); - process.exit(1); -} diff --git a/verify-parser.js b/verify-parser.js deleted file mode 100644 index 58d88df..0000000 --- a/verify-parser.js +++ /dev/null @@ -1,59 +0,0 @@ -const fs = require('fs'); - -function extractRecommendation(content) { - const match = content.match(/\b([ABCDZ])\b/i); - if (match) return match[1].toUpperCase(); - return null; -} - -function simulateParse(content) { - let reco = null; - let ruleApplies = false; - - try { - const jsonMatch = content.match(/\{[\s\S]*\}/); - const jsonText = jsonMatch ? jsonMatch[0] : content; - const parsed = JSON.parse(jsonText); - - reco = parsed.recommendation; - ruleApplies = parsed.rule_applies === true; - - if (!ruleApplies && (reco === "B" || reco === "C" || reco === "D")) reco = "A"; - - if (!reco || !["A", "B", "C", "D", "Z"].includes(reco)) { - reco = extractRecommendation(parsed.justification || "") || "A"; - if (!ruleApplies && (reco === "B" || reco === "C" || reco === "D")) reco = "A"; - } - } catch (e) { - reco = extractRecommendation(content) || "A"; - if (!ruleApplies && (reco === "B" || reco === "C" || reco === "D")) reco = "A"; - } - return reco; -} - -const tests = [ - { name: "Valid Rule B", json: '{"rule_applies": true, "recommendation": "B", "justification": "blabla"}', expected: "B" }, - { name: "Valid Rule C", json: '{"rule_applies": true, "recommendation": "C", "justification": "blabla"}', expected: "C" }, - { name: "Valid Rule D", json: '{"rule_applies": true, "recommendation": "D", "justification": "blabla"}', expected: "D" }, - { name: "Invalid Rule B (hallucinated)", json: '{"rule_applies": false, "recommendation": "B", "justification": "blabla"}', expected: "A" }, - { name: "Invalid Rule D (hallucinated)", json: '{"rule_applies": false, "recommendation": "D", "justification": "blabla"}', expected: "A" }, - { name: "Valid Rule A", json: '{"rule_applies": true, "recommendation": "A", "justification": "blabla"}', expected: "A" }, - { name: "Valid Extraction D", json: '{"rule_applies": true, "recommendation": "INVALID", "justification": "Je choisis D."}', expected: "D" }, - { name: "Invalid Extraction D", json: '{"rule_applies": false, "recommendation": "INVALID", "justification": "Je choisis D."}', expected: "A" } -]; - -let allPassed = true; -tests.forEach(t => { - const res = simulateParse(t.json); - const pass = res === t.expected; - console.log(`${pass ? "✅" : "❌"} ${t.name}: expected ${t.expected}, got ${res}`); - if (!pass) allPassed = false; -}); - -if (allPassed) { - console.log("All parsing tests passed."); - process.exit(0); -} else { - console.log("Some tests failed."); - process.exit(1); -} diff --git a/verify_limit.js b/verify_limit.js deleted file mode 100644 index 00ea176..0000000 --- a/verify_limit.js +++ /dev/null @@ -1,37 +0,0 @@ - -const { Pool } = require('pg'); -const fs = require('fs'); -const path = require('path'); - -const CONFIG_PATH = path.join(process.cwd(), "data", ".db-config.json"); -let url = ""; -if (fs.existsSync(CONFIG_PATH)) { - url = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8")).url; -} -if (!url) url = process.env.DATABASE_URL; - -const pool = new Pool({ connectionString: url }); - -async function check() { - try { - console.log("Checking DB URL:", url.replace(/:([^@]+)@/, ":****@")); - - const resTotal = await pool.query('SELECT COUNT(*) FROM ventes_produits'); - console.log("Total rows in table:", resTotal.rows[0].count); - - const resSuppliers = await pool.query('SELECT code_fournisseur, COUNT(*) as count FROM ventes_produits GROUP BY code_fournisseur ORDER BY count DESC LIMIT 10'); - console.log("Top suppliers by row count:", resSuppliers.rows); - - // Check for specific supplier in user screenshot (if any, but I'll check all) - for (const s of resSuppliers.rows) { - const resDistinct = await pool.query('SELECT COUNT(DISTINCT codein) FROM ventes_produits WHERE code_fournisseur = $1', [s.code_fournisseur]); - console.log(`Supplier ${s.code_fournisseur}: Distinct Products = ${resDistinct.rows[0].count}`); - } - - } catch (e) { - console.error("DB Error:", e); - } finally { - await pool.end(); - } -} -check();