mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-13 18:27:40 +02:00
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:
23 files changed
+5
-1768
No files matched your search
-229
@@ -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 })
|
||||
);
|
||||
@@ -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
@@ -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
@@ -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 +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 +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 +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 +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 +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 |
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
})();
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
Reference in new issue
Block a user