Nettoyage : scripts Node et ressources Next.js obsolètes supprimés

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LG2LLJuswrrTTPKJmunEVf
This commit is contained in:
Claude committed 2026-10-12 19:33:00 +00:00
1 parent 520e6131f8
commit 56bbfaebe0
23 files changed
+5 -1768

No files matched your search

-229
View File
@@ -1,229 +0,0 @@
// <stdin>
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}`)) })
] }) })
] })
] });
}
// <stdin>
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 })
);
-31
View File
@@ -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();
+2 -2
View File
@@ -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],
+3 -3
View File
@@ -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",
-1
View File
@@ -1 +0,0 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 391 B

-1
View File
@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

-1
View File
@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

Before

Width:  |  Height:  |  Size: 128 B

-1
View File
@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

Before

Width:  |  Height:  |  Size: 385 B

-15
View File
@@ -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();
-7
View File
@@ -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();
-239
View File
@@ -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();
-48
View File
@@ -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();
-27
View File
@@ -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();
-232
View File
@@ -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);
});
-190
View File
@@ -1,190 +0,0 @@
/**
* 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);
}
})();
-354
View File
@@ -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='<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);
});
-39
View File
@@ -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);
}
-25
View File
@@ -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();
-194
View File
@@ -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);
-32
View File
@@ -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);
}
-59
View File
@@ -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);
}
-37
View File
@@ -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();