mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-19 05:06:56 +02:00
Compare commits
153
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c84cfda53 | ||
|
|
585e171708 | ||
|
|
2404b22628 | ||
|
|
7f5ef93469 | ||
|
|
60ce3b8d21 | ||
|
|
828a50bfc0 | ||
|
|
f75d51f6f4 | ||
|
|
beefb07ba0 | ||
|
|
dc7f6bbf9f | ||
|
|
06f0bd0513 | ||
|
|
8158eda11b | ||
|
|
c9e1934aae | ||
|
|
9729b00dc7 | ||
|
|
1e7c1ba4cd | ||
|
|
52b663579f | ||
|
|
89d573634a | ||
|
|
0a2bc8318b | ||
|
|
c532386385 | ||
|
|
1f4571bd7a | ||
|
|
2ae523a804 | ||
|
|
92769535dc | ||
|
|
a4e3c53dcc | ||
|
|
6e59458403 | ||
|
|
720cf3c7a1 | ||
|
|
0bd7ec9798 | ||
|
|
be8d576af5 | ||
|
|
fff4cd9641 | ||
|
|
310165151d | ||
|
|
0a57a9a707 | ||
|
|
60aa2ce120 | ||
|
|
8fe294924b | ||
|
|
33b30dc8bd | ||
|
|
73accd6d60 | ||
|
|
3fa92e891b | ||
|
|
94e82aa404 | ||
|
|
18edda7167 | ||
|
|
3a825866d2 | ||
|
|
54d51e8eab | ||
|
|
c565672259 | ||
|
|
4b229acc9b | ||
|
|
a45e09777b | ||
|
|
e79d7e51da | ||
|
|
150baeef9a | ||
|
|
5ad222431d | ||
|
|
eaac945345 | ||
|
|
e6e79dfe06 | ||
|
|
bf6fef6a14 | ||
|
|
28fe78a646 | ||
|
|
abc704a363 | ||
|
|
b6225f1920 | ||
|
|
fee267004f | ||
|
|
d89d2b3232 | ||
|
|
50f883ec98 | ||
|
|
b948aca2f8 | ||
|
|
5fee5bc058 | ||
|
|
fa1b64cfa6 | ||
|
|
e7a387b7de | ||
|
|
ccd60b119f | ||
|
|
5f3760f03e | ||
|
|
726fb8418d | ||
|
|
cd2e77e909 | ||
|
|
4ef0e9fdeb | ||
|
|
db886cd8d9 | ||
|
|
f0e36308f2 | ||
|
|
4a56584a27 | ||
|
|
b2a426287f | ||
|
|
6b1caa8ee0 | ||
|
|
78b3d41896 | ||
|
|
b3a82cf1e8 | ||
|
|
c9ecb47cb1 | ||
|
|
466cb53da2 | ||
|
|
b2149ad0a6 | ||
|
|
a1a95fa290 | ||
|
|
62375d3310 | ||
|
|
203c96f6c9 | ||
|
|
abc482c370 | ||
|
|
ec35985c50 | ||
|
|
eeaae09282 | ||
|
|
86cdc86963 | ||
|
|
03b6ddb8c9 | ||
|
|
a6cfb75caa | ||
|
|
59efd70886 | ||
|
|
b10a5a027e | ||
|
|
99097b6eca | ||
|
|
164bda1fe9 | ||
|
|
1633d5e407 | ||
|
|
b5144ed0cf | ||
|
|
2af7c42b2b | ||
|
|
147a05c090 | ||
|
|
ac1c404ce5 | ||
|
|
24ed93f833 | ||
|
|
5b5e69eed1 | ||
|
|
dec99ce3c1 | ||
|
|
6d349e33b4 | ||
|
|
76e908e8b2 | ||
|
|
8a3f18f23b | ||
|
|
e8507490f7 | ||
|
|
adb8455353 | ||
|
|
f0a553eb92 | ||
|
|
4cb2c7d1c5 | ||
|
|
0511a3a7ea | ||
|
|
332b21c1ae | ||
|
|
21758573c2 | ||
|
|
473ed1e467 | ||
|
|
b9a5bd5e22 | ||
|
|
74b183abb3 | ||
|
|
5aeb1ca496 | ||
|
|
5b74133ecd | ||
|
|
5b874f3581 | ||
|
|
71563500a0 | ||
|
|
ff264dcdff | ||
|
|
6c5e28b05e | ||
|
|
6adcb0d4c8 | ||
|
|
2e25bbc5b3 | ||
|
|
d0f92e46a3 | ||
|
|
f0fdab465c | ||
|
|
e9ac5bc0a5 | ||
|
|
eba4cb0774 | ||
|
|
cda149ccaa | ||
|
|
ea19c5771e | ||
|
|
159508132c | ||
|
|
b7de347a99 | ||
|
|
d733755659 | ||
|
|
32f9a27563 | ||
|
|
abac5c5ee5 | ||
|
|
1cb6c2496d | ||
|
|
f005c08ad6 | ||
|
|
850852912f | ||
|
|
e7bcba169c | ||
|
|
c4eeab11d2 | ||
|
|
649a1df267 | ||
|
|
6036ea5600 | ||
|
|
6d27c7935d | ||
|
|
de8a435c14 | ||
|
|
8cc95cb387 | ||
|
|
5f364f7d52 | ||
|
|
524faf4a7a | ||
|
|
af93e362e0 | ||
|
|
ae1deedd19 | ||
|
|
da73693ea0 | ||
|
|
8bd45518f2 | ||
|
|
bd9bc78f4c | ||
|
|
0b9846f55d | ||
|
|
d54c3ae520 | ||
|
|
6cdb845a1f | ||
|
|
b755a69258 | ||
|
|
d319107076 | ||
|
|
c64d32a219 | ||
|
|
694b780160 | ||
|
|
1281b0e21f | ||
|
|
abed369bfb | ||
|
|
2ffa7dd1c0 | ||
|
|
5a4ce70695 |
No files matched your search
+229
@@ -0,0 +1,229 @@
|
||||
// <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 })
|
||||
);
|
||||
@@ -32,6 +32,10 @@ ENV NODE_ENV=production
|
||||
# Next.js telemetry is disabled
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
# Chromium pour l'extraction Qlik via Playwright (ws in-page) + dépendances headless
|
||||
RUN apk add --no-cache chromium nss freetype harfbuzz ca-certificates ttf-freefont
|
||||
ENV PLAYWRIGHT_CHROMIUM_PATH=/usr/bin/chromium-browser
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
@@ -46,6 +50,9 @@ RUN chown nextjs:nodejs .next data
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
# playwright-core n'est pas toujours tracé par le standalone — on le copie explicitement
|
||||
COPY --from=deps --chown=nextjs:nodejs /app/node_modules/playwright-core ./node_modules/playwright-core
|
||||
|
||||
# Grant write access to /app for settings persistence
|
||||
RUN chown -R nextjs:nodejs /app
|
||||
|
||||
|
||||
@@ -14,6 +14,367 @@ La Foir'Fouille : **CA réseau, Qté vendue réseau, Nb magasins** par produit.
|
||||
| Bouton UI | `src/features/grid/components/sync-qlik-button.tsx` |
|
||||
| Jointure grille | `enrichWithNetworkMetrics()` dans `src/features/grid/api/get-product-rows.ts` (Phase 8) |
|
||||
| Colonnes grille | CA réseau / Qté réseau / Magasins (/270) / % présence (`heatmap-grid.tsx`) |
|
||||
| Recherche produit (Qlik d'abord) | `src/lib/qlik-search.ts` → `src/features/produits/api/search-produits.ts` → `GET /api/produits/search` |
|
||||
|
||||
## Recherche produit — Qlik d'abord
|
||||
|
||||
La page `/produits` ne cherche **plus** dans le catalogue FF Nancy en premier :
|
||||
le réseau référence bien plus de produits que Nancy, et ce sont précisément
|
||||
ceux-là qu'on veut voir.
|
||||
|
||||
1. `searchQlikArticles()` — recherche de list object sur le champ libellé
|
||||
article. `SearchListObjectFor` applique un **OU** entre les mots (« tapis
|
||||
anti » ramenait 17 696 libellés sur l'app FF, et sélectionner ces 17 696
|
||||
valeurs faisait abandonner le cube en `code 15 — Request aborted`). On
|
||||
cherche donc mot par mot pour retenir le plus sélectif, on filtre en **ET**
|
||||
côté client, puis on sélectionne les valeurs retenues par numéro d'élément
|
||||
(`SelectListObjectValues`). La sélection restreint la dimension « Article
|
||||
Code » aux articles correspondants.
|
||||
2. Les **mesures réseau sortent du même cube**, dans la **même session Qlik** :
|
||||
fenêtre 12 mois glissants sélectionnée sur le champ `Date`, master measures
|
||||
résolues par titre, cube trié par quantité réseau décroissante (les meilleures
|
||||
ventes en tête, donc pas besoin de lire tout l'ensemble sélectionné).
|
||||
Elles sont ensuite persistées par `upsertNetworkMetrics()`.
|
||||
|
||||
⚠️ Ne **jamais** relancer `fetchNetworkMetricsPlaywright()` pendant une
|
||||
recherche : deux sessions concurrentes sur la même app, dont une avec une
|
||||
grosse sélection, et l'Engine coupe les requêtes (`code 15`). C'est ce qui
|
||||
faisait échouer la recherche alors que la sync de la Grille — seule sur le
|
||||
serveur — fonctionnait. Le détail mensuel vient donc du cache, et la fiche
|
||||
produit garde son bouton « Actualiser depuis Qlik » pour l'extraire à la
|
||||
demande sur un seul code.
|
||||
3. `pgGetProduitsByCodeCentrale()` — rapprochement avec le catalogue Nancy, sur
|
||||
**deux clés candidates** : la dimension « Article Code » et le champ
|
||||
`article_no_centrale`, qui n'ont pas le même format sur l'app FF. Un code
|
||||
absent = produit réseau que nous ne référençons pas (`?cc=` sur la fiche).
|
||||
|
||||
Plafonds mesurés en production : sélectionner les 13 446 libellés contenant
|
||||
« tapis » prenait 81 s puis 486 s avant d'abandonner. La sélection est donc
|
||||
plafonnée à **300 valeurs** (`MAX_VALEURS_SELECTION`) et une seule recherche
|
||||
tourne à la fois.
|
||||
|
||||
L'API est **asynchrone** : `POST /api/produits/search?q=…` démarre un job et rend
|
||||
la main tout de suite, `GET` renvoie l'avancement puis le résultat (polling client
|
||||
toutes les 2 s). Une requête HTTP maintenue pendant toute l'extraction se faisait
|
||||
couper par le reverse proxy, qui répond une page HTML — le client échouait sur
|
||||
« Unexpected token '<' … is not valid JSON ». Même schéma que `POST /api/qlik/sync`.
|
||||
|
||||
Repli : si Qlik est injoignable, la recherche retombe sur `pgSearchProduits()` et
|
||||
la réponse le signale (`source: "db"`). Un résultat Qlik exploitable est mis en
|
||||
cache mémoire 10 min ; un repli ne l'est pas, sinon une panne passagère resterait
|
||||
figée.
|
||||
|
||||
Champs de l'app FF (« Magasins Vision Consolidée ») : le code est `Article Code`,
|
||||
le libellé `Article` (repli `article_libelle_ticket`, qui est le libellé ticket
|
||||
tronqué), le fournisseur `Fournisseur` (repli `code_fournisseur`, qui ne porte
|
||||
que le code). Ces préférences sont dans `CHAMPS_LIBELLE_PREFERES` /
|
||||
`CHAMPS_FOURNISSEUR_PREFERES`, complétées par une heuristique pour les autres
|
||||
apps. Si la détection tombe à côté, forcer :
|
||||
|
||||
```
|
||||
QLIK_FIELD_ARTICLE_LIBELLE=<nom exact du champ libellé>
|
||||
QLIK_FIELD_FOURNISSEUR=<nom exact du champ fournisseur>
|
||||
```
|
||||
|
||||
Sans champ libellé exploitable, la recherche fonctionne encore par code centrale.
|
||||
|
||||
## Le modèle est piloté par `Période` / `Type_Cal` — pas par `Date`
|
||||
|
||||
**C'est l'explication de fond**, obtenue en dumpant les expressions réelles :
|
||||
|
||||
```
|
||||
« Quantité N » = Sum({<Type_Cal={'N'}>} quantite)
|
||||
« CA N » = Sum({<Type_Cal={'N'}>} $(vCA_vat))
|
||||
« Quantité COMP » = Sum({<Type_Cal={'$(vPeriod_comp)'}$(vConstantCOMP)>} quantite)
|
||||
« Magasin Ventes Nb N » = Count({<Type_Cal={'N'}>} Distinct ventes_code_site)
|
||||
```
|
||||
|
||||
L'app n'est pas un modèle « faits + calendrier » classique. Un **pont de périodes**
|
||||
duplique les lignes de faits : `Type_Cal='N'` marque celles de la période
|
||||
analysée, `'COMP'` celles de la période de comparaison. Et c'est le champ
|
||||
**`Période`** (4 valeurs, une sélectionnée) qui décide de quelle période il s'agit.
|
||||
|
||||
Tout ce qu'on observait en découle :
|
||||
|
||||
| Symptôme | Cause |
|
||||
|---|---|
|
||||
| Sélectionner `Date` ne change rien (100 % du total avant/après) | Le périmètre vient du pont de périodes, pas du champ date |
|
||||
| Août→décembre vides sur tous les articles | « Quantité N » ne couvre que la période courante (janvier→juillet) |
|
||||
| Janvier→juillet de l'année précédente renseignés | Ce sont les mois que « Quantité COMP » sait atteindre |
|
||||
| `Sum(quantite)` brut inexploitable | Il additionne les lignes N **et** COMP : il double compte |
|
||||
|
||||
### La bonne méthode : effacer `Période`, puis extraire ANNÉE par ANNÉE
|
||||
|
||||
C'est ce que fait un utilisateur dans l'app : il sélectionne **2026** et obtient
|
||||
l'année plus sa comparaison N-1 ; il sélectionne 2025 et obtient 2025. Les
|
||||
données de 2024, 2025 et 2026 sont toutes accessibles mois par mois. `Type_Cal='N'`
|
||||
désigne simplement **l'année sélectionnée**.
|
||||
|
||||
Il suffit donc d'enchaîner les années couvertes par la fenêtre (2025 puis 2026)
|
||||
pour reconstituer les 12 mois glissants — chaque passe donnant les 5 mesures, et
|
||||
non la seule quantité comme « Quantité COMP ».
|
||||
|
||||
Ce mécanisme existait déjà dans le code (« passe N-1 ») mais restait **sans
|
||||
effet** :
|
||||
|
||||
```
|
||||
[qlik-pw] (mois) passe N-1 terminée : 299905 → 299905 points mensuels
|
||||
^^^^^^^^^^^^^^ aucun gain
|
||||
```
|
||||
|
||||
La sélection `Période` **héritée de l'ouverture de l'app** (observé
|
||||
« Période:1/4 » alors que rien n'avait été sélectionné) épinglait le contexte sur
|
||||
l'année en cours et annulait la sélection d'année. `choisirPeriode()` teste donc
|
||||
en **premier** l'état sans aucune sélection de `Période`, et l'efface dès que la
|
||||
couverture est incomplète.
|
||||
|
||||
Chaque passe n'écrit que les mois **de la fenêtre**, et les totaux s'additionnent
|
||||
sur ces mois : deux années ne peuvent pas se doubler puisqu'elles ne partagent
|
||||
aucun mois.
|
||||
|
||||
### Mesuré : aucune valeur de `Période` ne porte à elle seule 12 mois
|
||||
|
||||
`choisirPeriode()` essaie chaque valeur et mesure la couverture réelle sur un
|
||||
cube `[Mois] × Quantité N`. Résultat en production :
|
||||
|
||||
```
|
||||
valeurs de « Période » : ["juin 2026","Année à date","Mois à date","Semaine 2026/30"]
|
||||
« juin 2026 » → 1/12 ["2026-06"]
|
||||
« Année à date » → 6/12 ["2026-01".."2026-06"]
|
||||
« Mois à date » → 0/12
|
||||
« Semaine 2026/30 » → 0/12
|
||||
```
|
||||
|
||||
Ce ne sont pas des types de période mais **4 contextes relatifs à aujourd'hui**.
|
||||
Aucun ne porte 12 mois glissants : les master measures ne pourront **jamais**
|
||||
couvrir la fenêtre. Ce n'est plus une hypothèse, c'est une mesure.
|
||||
|
||||
Conséquence directe : **l'agrégation directe des faits est obligatoire**, et une
|
||||
`Période` partielle est pire que pas de sélection — « Année à date » restreint les
|
||||
faits à 2026-01→06 et amputerait d'autant l'agrégation, qui sait lire tout
|
||||
l'historique. `choisirPeriode()` **efface** donc la sélection quand la couverture
|
||||
est incomplète, et ne la garde que si elle couvre les 12 mois.
|
||||
|
||||
### Chaque expression est validée séparément
|
||||
|
||||
Une seule mesure invalide suffit à faire renvoyer **zéro ligne** à tout un
|
||||
hypercube, sans qu'aucun message ne dise laquelle : observé en production, le
|
||||
cube à 5 mesures calculait 26 s puis rendait 0 ligne, alors que `Sum(quantite)`
|
||||
seul donnait 35 424 324.
|
||||
|
||||
`validerExpression()` teste donc chaque expression isolément sur un petit cube
|
||||
`[Mois]` avant de construire le gros :
|
||||
|
||||
```
|
||||
[qlik-pw][diag] validation des expressions, une par une :
|
||||
[qlik-pw][diag] mesure « quantité » = Sum(quantite) → 12 mois, total=33945285
|
||||
[qlik-pw][diag] mesure « magasins » = Count(DISTINCT [Magasin Code]) → INVALIDE (cube vide)
|
||||
```
|
||||
|
||||
Une expression invalide est remplacée par la colonne neutre `0` : le cube reste
|
||||
exploitable et le log nomme la coupable. Seule la quantité est bloquante.
|
||||
|
||||
## ⚠️ Les master measures « N » ignorent la sélection Date
|
||||
|
||||
**Constat de production, vérifié arithmétiquement.** Avec août 2025 seul
|
||||
sélectionné, le cube renvoyait `qte=164` / `ca=2 220,75 €` — soit exactement la
|
||||
somme de janvier à juillet 2026. « CA N », « Quantité N » et consorts sont des
|
||||
mesures **cumul année en cours**, insensibles au champ `Date`.
|
||||
|
||||
Trois conséquences, toutes observées :
|
||||
|
||||
- les totaux réseau de la Grille étaient un **cumul année en cours** (mois
|
||||
courant partiel inclus), pas 12 mois glissants ;
|
||||
- les mois de l'année précédente non couverts par « Quantité COMP » (août à
|
||||
décembre, en juillet) restaient **vides sur tous les articles** ;
|
||||
- la passe de rattrapage y recopiait le **total de période**, d'où cinq mois
|
||||
identiques à 164 dans `qteByMonth`.
|
||||
|
||||
Aucune sélection ne corrige cela. Le chemin nominal agrège donc **directement les
|
||||
champs de faits**, qui respectent les sélections :
|
||||
|
||||
| Rôle | Expression (surchargeable) |
|
||||
|------|----------------------------|
|
||||
| Quantité | `QLIK_EXPR_QTE` — défaut `Sum(quantite)` |
|
||||
| CA | `QLIK_EXPR_CA` — défaut `Sum(ca_ht)` |
|
||||
| Magasins | `QLIK_EXPR_NBMAG` — défaut `Count(DISTINCT [Magasin Code])` |
|
||||
| Marge | `QLIK_EXPR_MARGE` — défaut `Sum(marge)` |
|
||||
|
||||
Un seul cube `[Article Code, Mois]` couvre les 12 mois : ni passe N-1 ni
|
||||
rattrapage — c'est exactement ce qu'ils compensaient. Les totaux deviennent la
|
||||
**somme des 12 mois de la fenêtre**.
|
||||
|
||||
Sur l'app de production, les champs `Date`, `Date calendrier` et `Date_Key`
|
||||
existent mais leur sélection ne réduit pas `Sum(quantite)` : ils restent à
|
||||
100 % du total historique. L'extracteur essaie d'abord ces champs puis utilise
|
||||
la dimension maître `Mois` comme chemin fiable. Il crée un list object, retrouve
|
||||
les 12 valeurs `YYYY-MM`, les sélectionne par `qElemNumber`, puis exécute le
|
||||
cube mensuel. Cette méthode ne dépend ni du champ sous-jacent ni de son format
|
||||
dual et conserve le chemin rapide (une sélection articles + un cube paginé).
|
||||
|
||||
Garde-fous : « Quantité N » est incluse dans le cube pour **calibrage**, et le
|
||||
log compare les deux sur les mois de l'année en cours, seul périmètre où la
|
||||
master measure est juste :
|
||||
|
||||
```
|
||||
[qlik-pw][expr] 485210 lignes, quantité totale=…, calibrage année 2026 : 4812/4830 mois conformes à « Quantité N »
|
||||
```
|
||||
|
||||
Un calibrage faible signale une expression à ajuster (filtre `flag_type_mvt`,
|
||||
`ca_ttc` plutôt que `ca_ht`…). Il n'existe plus de repli automatique vers les
|
||||
master measures annuelles : quantité totale nulle, champ Date invalide, erreur
|
||||
Engine ou somme incohérente font échouer la synchronisation et laissent le cache
|
||||
précédent intact.
|
||||
|
||||
Après sélection de la fenêtre, l'extracteur vérifie deux invariants avant tout
|
||||
upsert :
|
||||
|
||||
1. la somme de toutes les lignes `[Article Code, Mois]` est égale au total Qlik
|
||||
sans dimension calculé avec les mêmes sélections ;
|
||||
2. pour chaque article, la quantité réseau totale est exactement la somme de ses
|
||||
12 mois. Les mois sans ligne de faits sont alors seulement matérialisés à `0`.
|
||||
|
||||
Une extraction interrompue ou partielle n'est jamais publiée.
|
||||
|
||||
### Valider les expressions sur le vrai serveur
|
||||
|
||||
`scripts/qlik-validate.mjs` répond aux deux questions ouvertes en une exécution,
|
||||
**depuis un réseau qui atteint Qlik** (le serveur est filtré par IP : injoignable
|
||||
depuis l'extérieur, la passerelle d'egress tombe en `connection timeout`) :
|
||||
|
||||
```
|
||||
docker exec -e QLIK_PWD='<mot de passe>' -it <conteneur> node scripts/qlik-validate.mjs
|
||||
```
|
||||
|
||||
Il affiche les expressions réelles des master measures, puis un tableau mois par
|
||||
mois comparant `Sum(quantite)` / `Sum(ca_ht)` / `Sum(ca_ttc)` /
|
||||
`Count(DISTINCT [Magasin Code])` aux mesures « N » — sur les mois de l'année en
|
||||
cours (où elles doivent coïncider) comme sur ceux de l'année précédente (où les
|
||||
mesures « N » sont censées être à 0). Il affiche enfin `Article Code`,
|
||||
`article_no_centrale` et `article_codein` côte à côte, pour trancher la clé de
|
||||
jointure avec `articles.artcentrale`.
|
||||
|
||||
Lecture seule : sélections en soft lock, objets de session détruits.
|
||||
|
||||
### La sélection Date filtre-t-elle seulement quelque chose ?
|
||||
|
||||
Question restée sans réponse tant que seules des mesures **insensibles à la
|
||||
sélection** étaient utilisées : rien ne prouvait que `Date` bornait quoi que ce
|
||||
soit. Le chemin par expressions le vérifie désormais avant d'extraire, avec un
|
||||
total de contrôle (`Sum(quantite)` sans dimension) :
|
||||
|
||||
```
|
||||
[qlik-pw][expr] contrôle Sum(quantite) sans filtre date = 12345678
|
||||
[qlik-pw][expr] champ « Date » → 0 (0% du total sans filtre)
|
||||
[qlik-pw][expr] champ « Date calendrier » → 3456789 (28% du total sans filtre)
|
||||
[qlik-pw][expr] champ date retenu : « Date calendrier »
|
||||
```
|
||||
|
||||
Un champ n'est retenu que s'il donne un total **non nul et strictement inférieur**
|
||||
au total sans filtre — 0 % signifie que la sélection ne matche rien, 100 % qu'elle
|
||||
n'a aucun effet. Candidats essayés dans l'ordre : `QLIK_DATE_FIELD` (si défini),
|
||||
`Date`, `Date calendrier`, `Date_Key` (celui-ci sélectionné au format `AAAAMMJJ`).
|
||||
|
||||
Si aucun ne filtre, l'extraction échoue explicitement plutôt que de produire une
|
||||
fenêtre fausse. Chaque sélection d'un candidat rejeté est effacée avant l'essai
|
||||
suivant ; autrement un premier candidat à zéro contaminait tous les contrôles
|
||||
suivants. Avant cet échec, la dimension maître `Mois` est testée elle aussi.
|
||||
|
||||
## La passe de rattrapage a été supprimée
|
||||
|
||||
Elle ré-extrayait un mois vide en ne sélectionnant que ses dates. Les master
|
||||
measures ignorant la sélection Date, le cube lui renvoyait le **total de période**
|
||||
qu'elle recopiait dans chaque mois manquant : d'où les plateaux identiques d'août
|
||||
à décembre (87 648 douze mois de suite sur un article) et les tendances
|
||||
« −162 % » entièrement fabriquées.
|
||||
|
||||
Un mois qu'on ne sait pas extraire doit rester **absent**, jamais rempli d'une
|
||||
valeur plausible.
|
||||
|
||||
## Mois vides de la fenêtre glissante
|
||||
|
||||
Les mesures « N » de l'app sont bornées à une année civile. Quand la fenêtre
|
||||
12 mois glissants chevauche deux années (le cas 11 mois sur 12), le chemin
|
||||
« dimension Mois » sélectionne les 365 jours d'un coup et la mesure ne se résout
|
||||
que sur une seule année : les mois de l'année précédente ressortent vides.
|
||||
« Quantité COMP » ne rattrape que les mois ayant un comparable dans l'année en
|
||||
cours — d'où, en juillet 2026, un trou observé d'août à décembre 2025 sur
|
||||
**tous** les articles.
|
||||
|
||||
Le chemin daté repose uniquement sur les expressions de faits et un cube unique
|
||||
`[Article Code, Mois]`. Après validation de la fenêtre et du total, chaque
|
||||
article reçoit exactement les 12 clés attendues. Une clé absente du cube signifie
|
||||
alors réellement « aucun fait sur ce mois » et vaut `0`; avant cette validation,
|
||||
aucun zéro n'est inventé.
|
||||
|
||||
`QLIK_MONTH_DIM=0` ou `QLIK_USE_EXPR=0` désactive désormais un prérequis et fait
|
||||
échouer la synchronisation datée : ces options ne peuvent plus réactiver un
|
||||
chemin connu comme incorrect.
|
||||
|
||||
⚠️ Les données déjà en cache gardent leurs zéros : il faut relancer la sync Qlik
|
||||
pour les corriger.
|
||||
|
||||
## Coût de `SelectValues` sur « Article Code »
|
||||
|
||||
Mesuré en production : **8 à 100 s par appel**, quasi indépendamment du nombre de
|
||||
valeurs — le moteur balaie un symbole de plus d'un million d'entrées. Avec 7 200
|
||||
codes en lots de 300, cela faisait 24 appels, ~15 min de sync, et la session Qlik
|
||||
mourait avant la fin (`Socket closed`, puis `Execution context was destroyed`).
|
||||
|
||||
La dimension Mois livrant déjà tous les mois d'un coup, rien n'oblige à découper
|
||||
les codes : le chemin mensuel fait donc **une seule sélection pour tous les codes**
|
||||
et **un seul cube paginé** (la lecture des pages coûte ~50 ms). Repli automatique
|
||||
sur les lots si l'Engine refuse. Même principe pour `rattraperMoisVides()` : une
|
||||
sélection de codes, puis seule la fenêtre de dates change d'un mois à l'autre.
|
||||
|
||||
La sonde de diagnostic de fin de sync a été retirée : elle refaisait une sélection
|
||||
complète des codes et des 365 jours pour rien.
|
||||
|
||||
### Sélection PAR FOURNISSEUR (ÉTAPE 0)
|
||||
|
||||
Le code fournisseur FF présent en base SQL / API (ex. `J009`) existe aussi dans
|
||||
Qlik. Une valeur y désigne le même périmètre que les dizaines de milliers de codes
|
||||
articles — pour un fournisseur de 41 569 références, c'est **un** `SelectValues`
|
||||
trivial au lieu d'un appel de 8 à 100 s **répété à chaque passe annuelle**.
|
||||
|
||||
`selectionnerParFournisseur()` (in-page, avant `choisirPeriode()`) :
|
||||
|
||||
1. essaie chaque champ candidat — `QLIK_FIELD_CODE_FOURNISSEUR`, puis
|
||||
`code_fournisseur`, `Fournisseur`, `fournisseur_code_centrale` ;
|
||||
2. `SelectValues` le code fournisseur (soft lock) ; une valeur inconnue passe au
|
||||
champ suivant ;
|
||||
3. **vérifie la couverture** : lit les Article Code visibles sous cette sélection
|
||||
(cube paginé) et compare aux codes demandés ;
|
||||
4. ne retient la bascule que si la couverture atteint `QLIK_SUPPLIER_MIN_COVERAGE`
|
||||
(défaut **0.99**, borné à [0.5, 1]). Sinon la sélection est annulée et on
|
||||
revient à la sélection par codes.
|
||||
|
||||
Ce contrôle est indispensable : un article rattaché à plusieurs fournisseurs, ou
|
||||
un référencement Qlik différent du référencement FF, doit faire **revenir aux
|
||||
codes** plutôt que perdre silencieusement des articles.
|
||||
|
||||
Quand la bascule est retenue (`parFournisseur = true`), plus **aucun**
|
||||
`SelectValues` sur « Article Code » n'est émis — ni par les passes annuelles, ni
|
||||
par l'agrégation directe par expressions. Le périmètre fournisseur survit aux
|
||||
sélections `Année` et `Date`, qui portent sur d'autres champs. Le repli par lots
|
||||
de codes reste disponible si l'Engine refuse un cube.
|
||||
|
||||
En mode produit (sync d'un seul code centrale), aucun fournisseur n'est transmis :
|
||||
la sélection reste par code.
|
||||
|
||||
### Aucun résultat partiel dans le cache
|
||||
|
||||
Le script in-page conserve des points de contrôle à des fins de diagnostic, mais
|
||||
une synchronisation datée interrompue est refusée intégralement. Le cache garde
|
||||
sa dernière version complète jusqu'à la réussite d'une nouvelle extraction.
|
||||
|
||||
## Tendance réseau = 12 mois glissants stricts
|
||||
|
||||
`computeNetworkTrend()` reconstruit sa fenêtre à partir de la date du jour :
|
||||
12 mois complets, **mois en cours exclu** (partiel, il tirait la pente vers le bas).
|
||||
La tendance n'est affichée que si les 12 clés sont explicitement présentes dans
|
||||
le cache. Les anciennes séries partielles de deux ou trois mois sont donc
|
||||
refusées au lieu d'être présentées comme une tendance.
|
||||
|
||||
Retirés : ranking (champs/query/colonnes), analyse IA (routes `/api/ai/*`, `bulk-ai-analyzer`,
|
||||
dossier `ai-copilot`), score (`score-engine.ts`, colonne score).
|
||||
@@ -67,6 +428,9 @@ QLIK_DIM_CODE_ARTICLE_ID=fcd239e5-288b-4830-a047-0e3d7665d971
|
||||
QLIK_MEAS_CA_ID=43a76088-86fa-402e-a80e-0efd7701b3e1
|
||||
QLIK_MEAS_QTE_ID=7b40caf1-be4b-4811-8d45-50acde33e715
|
||||
QLIK_MEAS_NBMAG_ID=8b63fae5-db2f-4e4c-8618-f3e9d60b6b3b
|
||||
# Sélection par fournisseur (facultatif — auto-détection sinon) :
|
||||
QLIK_FIELD_CODE_FOURNISSEUR= # champ Qlik portant le code fournisseur FF
|
||||
QLIK_SUPPLIER_MIN_COVERAGE=0.99 # couverture minimale exigée pour basculer
|
||||
```
|
||||
|
||||
## RESTE À FAIRE
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
# Qlik — Performance & tuning de la sync réseau
|
||||
|
||||
Ce document rassemble les variables d'environnement et leviers qui agissent
|
||||
sur la **vitesse** et la **robustesse** de l'extraction Qlik réseau
|
||||
(`POST /api/qlik/sync`, routeur `src/app/api/qlik/sync/route.ts`,
|
||||
extracteur `src/lib/qlik-playwright.ts`, helper de fenêtre
|
||||
`src/lib/qlik-date-range.ts`).
|
||||
|
||||
Toutes ces variables sont **optionnelles** : valeur par défaut sûre.
|
||||
|
||||
> ⚠️ Aucune concurrence / parallélisme Qlik n'a été ajouté — chaque sync
|
||||
> reste **mono-flux** (un seul websocket Engine, une seule fenêtre
|
||||
> temporelle). Les optimisations ci-dessous n'augmentent pas la charge
|
||||
> sur le moteur Qlik ; elles **réduisent** la quantité de travail qu'on
|
||||
> lui demande.
|
||||
|
||||
---
|
||||
|
||||
## Variables d'environnement
|
||||
|
||||
| Nom | Type | Défaut | Bornes | Effet |
|
||||
| --------------------------- | ---- | ------ | ------------------- | -------------------------------------------------------------------------------------- |
|
||||
| `QLIK_SYNC_MONTHS_BACK` | int | `12` | `1`..`12` | Profondeur de la fenêtre temporelle (mois glissants, mois courant exclu). Plus petit = moins de mois scannés = extraction plus rapide. |
|
||||
| `QLIK_SETTLE_MS` | int | `150` | `0`..`2000` | Latence (ms) après chaque mutation de sélection (Date / Article Code) avant l'appel RPC suivant. Protège l'Engine contre les collisions qui provoquent `code:15 Request aborted`. |
|
||||
| `QLIK_CODE15_MAX_ATTEMPTS` | int | `4` | `1`..`8` | Nombre max de retries sur Engine `code:15` par appel RPC, avec backoff `QLIK_SETTLE_MS * attempt`. |
|
||||
| `QLIK_ARTICLE_BATCH_SIZE` | int | `150` | `1`..`500` | Taille du premier lot d'`Article Code` sélectionné par appel `SelectValues`. |
|
||||
| `QLIK_ARTICLE_BATCH_FALLBACKS` | CSV | `75,50` | chaque valeur `1`..`500` | Tailles de repli quand un lot déclenche un `code:15`. Valeurs ordonnées croissant appliquées successivement. |
|
||||
|
||||
> **Format CSV pour `QLIK_ARTICLE_BATCH_FALLBACKS`** : valeurs séparées par
|
||||
> des virgules, ex. `QLIK_ARTICLE_BATCH_FALLBACKS=100,75,50,25`. Les doublons
|
||||
> et valeurs hors bornes sont dédupliqués / clampés automatiquement, puis appliqués dans l'ordre fourni.
|
||||
|
||||
---
|
||||
|
||||
## Optimisations déjà en place (pattern durable)
|
||||
|
||||
Le pipeline reste **mono-connexion** pour ne jamais surcharger le moteur Qlik :
|
||||
|
||||
1. **Fenêtre temporelle bornée** : on ne demande jamais à Qlik de scanner
|
||||
l'historique complet ; uniquement les `QLIK_SYNC_MONTHS_BACK` derniers
|
||||
mois.
|
||||
2. **Découpe mensuelle** : `getMonthRanges()` produit 1 sous-fenêtre par mois
|
||||
calendaire. Chaque cube reste ainsi borné à ~28–31 jours × N codes.
|
||||
3. **Hypercube session object JETABLE** : `fetchCubeForSelection()` crée un
|
||||
`CreateSessionObject` neuf par lot, lit ses pages, puis appelle
|
||||
`DestroySessionObject`. C'est ce qui a éliminé les `code:15 Request aborted`
|
||||
à grande échelle (la réutilisation du même objet sur des centaines de
|
||||
recalculs forçait l'Engine à recalculer en boucle).
|
||||
4. **Fallbacks code 15** : si un lot trop large déclenche un `code:15`, on
|
||||
retente automatiquement avec une taille plus petite (cf.
|
||||
`QLIK_ARTICLE_BATCH_FALLBACKS`) sans tout annuler.
|
||||
|
||||
## Nouveautés (cette itération)
|
||||
|
||||
1. **Fenêtre paramétrable `QLIK_SYNC_MONTHS_BACK`** (1..12, défaut 12). Permet
|
||||
de scoper un sync test à 6 mois au lieu de 12, ou de n'extraire que les 3
|
||||
derniers mois pour un check rapide. Le helper `envMonthsBack()` (dans
|
||||
`src/lib/qlik-date-range.ts`) clamp la valeur et applique le défaut si
|
||||
l'env est mal configurée.
|
||||
2. **Filtre strict des codes centraux** dans `route.ts` (regex `[A-Z0-9][A-Z0-9_-]{1,29}`,
|
||||
longueur 2..30, exclusion de `""`, `"-"`, `"_"`). Réduit les erreurs Engine
|
||||
sur des valeurs parasites et le gaspillage réseau. Le log expose le **nombre**
|
||||
de codes rejetés mais pas les valeurs (hygiène logs).
|
||||
3. **Résumé timing final** : ligne unique `[qlik-pw][summary] rows=… months=…
|
||||
batches=… code15Retries=… fallbackCount=… totalMs=…` émise en fin de sync
|
||||
pour audit/debug sans avoir à parser les logs détaillés par lot.
|
||||
|
||||
## Exemple de `.env.local`
|
||||
|
||||
```env
|
||||
# Sync Qlik : fenêtre par défaut 12 mois ; on peut descendre à 6 pour un
|
||||
# test rapide ou un check hebdo sans recharger tout l'historique.
|
||||
QLIK_SYNC_MONTHS_BACK=12
|
||||
|
||||
# Batch sizes / fallbacks (laisser par défaut sauf contre-indication).
|
||||
QLIK_ARTICLE_BATCH_SIZE=150
|
||||
QLIK_ARTICLE_BATCH_FALLBACKS=75,50
|
||||
QLIK_SETTLE_MS=150
|
||||
QLIK_CODE15_MAX_ATTEMPTS=4
|
||||
```
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
# Tests purs Node (aucune dépendance Next.js).
|
||||
npx tsx scripts/test-qlik-date-range.ts
|
||||
```
|
||||
|
||||
Couvre :
|
||||
- repères Qlik (sérial ↔ ISO),
|
||||
- fenêtre 12 mois (référence),
|
||||
- `getMonthRanges()` : 12 mois / fenêtre partielle / année bissextile,
|
||||
- option `monthsBack` : 6 mois + clamp 0 / -5 / 24 / NaN / undefined,
|
||||
- helper `envMonthsBack()` : env absent / vide / valide / hors bornes /
|
||||
non numérique / décimal / espaces.
|
||||
|
||||
## Référence
|
||||
|
||||
- `src/lib/qlik-date-range.ts` — fenêtre, `envMonthsBack`, `getMonthRanges`.
|
||||
- `src/lib/qlik-playwright.ts` — extracteur Playwright, hypercube jetable,
|
||||
fallbacks code 15, `[qlik-pw][summary]` final.
|
||||
- `src/app/api/qlik/sync/route.ts` — route admin, `filterCentralCodes`,
|
||||
applique `QLIK_SYNC_MONTHS_BACK` via `envMonthsBack`.
|
||||
- `scripts/test-qlik-date-range.ts` — tests CLI.
|
||||
- `docs/qlik-integration.md` — doc d'intégration générale (auth NTLM,
|
||||
discovery, hypercube, etc.).
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: "standalone",
|
||||
serverExternalPackages: ["pg", "ws", "httpntlm"],
|
||||
serverExternalPackages: ["pg", "ws", "httpntlm", "playwright-core"],
|
||||
experimental: {
|
||||
serverActions: {
|
||||
bodySizeLimit: "50mb",
|
||||
|
||||
Generated
+13
@@ -26,6 +26,7 @@
|
||||
"next-auth": "^5.0.0-beta.30",
|
||||
"next-themes": "^0.4.6",
|
||||
"pg": "^8.18.0",
|
||||
"playwright-core": "^1.61.0",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
@@ -13285,6 +13286,18 @@
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.61.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.0.tgz",
|
||||
"integrity": "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/possible-typed-array-names": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
"next-auth": "^5.0.0-beta.30",
|
||||
"next-themes": "^0.4.6",
|
||||
"pg": "^8.18.0",
|
||||
"playwright-core": "^1.61.0",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
|
||||
Generated
+10
@@ -62,6 +62,9 @@ importers:
|
||||
pg:
|
||||
specifier: ^8.18.0
|
||||
version: 8.20.0
|
||||
playwright-core:
|
||||
specifier: ^1.61.0
|
||||
version: 1.61.0
|
||||
radix-ui:
|
||||
specifier: ^1.4.3
|
||||
version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
@@ -4503,6 +4506,11 @@ packages:
|
||||
resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
|
||||
playwright-core@1.61.0:
|
||||
resolution: {integrity: sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
possible-typed-array-names@1.1.0:
|
||||
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -9931,6 +9939,8 @@ snapshots:
|
||||
|
||||
pkce-challenge@5.0.1: {}
|
||||
|
||||
playwright-core@1.61.0: {}
|
||||
|
||||
possible-typed-array-names@1.1.0: {}
|
||||
|
||||
postcss-selector-parser@7.1.1:
|
||||
|
||||
@@ -102,12 +102,107 @@ async function main() {
|
||||
"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.");
|
||||
|
||||
await tempPool.end();
|
||||
console.log("[DB Init] Initialization successful. Exiting.");
|
||||
process.exit(0);
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
/**
|
||||
* CollectFlow — Validation des expressions Qlik et de la clé de jointure.
|
||||
*
|
||||
* À lancer DEPUIS le réseau qui atteint Qlik (typiquement le conteneur de l'app) :
|
||||
*
|
||||
* docker exec -e QLIK_PWD='<mot de passe>' -it <conteneur> node scripts/qlik-validate.mjs
|
||||
*
|
||||
* Répond aux deux questions encore ouvertes :
|
||||
*
|
||||
* 1. Les master measures « CA N » / « Quantité N » ignorent-elles la sélection
|
||||
* Date, et les agrégations directes (`Sum(quantite)`, `Sum(ca_ht)`…) sont-elles
|
||||
* un substitut fidèle ? Le script affiche, pour quelques articles réellement
|
||||
* vendus, un tableau mois par mois comparant les deux — dont les mois d'août à
|
||||
* décembre qui ressortaient vides.
|
||||
*
|
||||
* 2. Quelle valeur joint avec `articles.artcentrale` : la dimension « Article
|
||||
* Code » ou le champ `article_no_centrale` ? Le script affiche les deux
|
||||
* côte à côte.
|
||||
*
|
||||
* Aucun secret n'est écrit ni affiché : le mot de passe vient de l'environnement.
|
||||
* Le script est en LECTURE SEULE (sélections en soft lock, objets de session
|
||||
* détruits) — il ne modifie rien dans Qlik.
|
||||
*/
|
||||
|
||||
import httpntlm from "httpntlm";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { chromium } from "playwright-core";
|
||||
|
||||
process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0"; // certificat interne
|
||||
|
||||
const HOST = process.env.QLIK_HOST || "reporting-magasins.lafoirfouille.fr";
|
||||
const USER = process.env.QLIK_USER || "FFSCH";
|
||||
const PWD = process.env.QLIK_PWD || "";
|
||||
const DOMAIN = process.env.QLIK_DOMAIN ?? "";
|
||||
const APP = process.env.QLIK_APP_NETWORK || "9872ee6e-d64a-4b43-984a-076bf1f7f647";
|
||||
const DIM_ARTICLE = process.env.QLIK_DIM_CODE_ARTICLE_ID || "yesCP";
|
||||
const DIM_MOIS = process.env.QLIK_MOIS_DIM_ID || "pfGAwTs";
|
||||
const NB_ARTICLES = Number(process.env.QLIK_VALIDATE_ARTICLES || "3");
|
||||
|
||||
if (!PWD) {
|
||||
console.error("ERREUR : définir QLIK_PWD dans l'environnement.");
|
||||
console.error(" docker exec -e QLIK_PWD='…' -it <conteneur> node scripts/qlik-validate.mjs");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const xrfkey = randomBytes(12).toString("base64").replace(/[^a-zA-Z0-9]/g, "").slice(0, 16).padEnd(16, "0");
|
||||
|
||||
// ─── Fenêtre 12 mois glissants, mois courant exclu (même règle que l'app) ────
|
||||
|
||||
const QLIK_EPOCH_MS = Date.UTC(1899, 11, 30);
|
||||
const serialDe = (y, m, d) => Math.round((Date.UTC(y, m - 1, d) - QLIK_EPOCH_MS) / 86_400_000);
|
||||
|
||||
function fenetre12Mois(now = new Date()) {
|
||||
const debut = new Date(now.getFullYear(), now.getMonth() - 12, 1);
|
||||
const fin = new Date(now.getFullYear(), now.getMonth(), 0);
|
||||
const serials = [];
|
||||
const s0 = serialDe(debut.getFullYear(), debut.getMonth() + 1, debut.getDate());
|
||||
const s1 = serialDe(fin.getFullYear(), fin.getMonth() + 1, fin.getDate());
|
||||
for (let s = s0; s <= s1; s++) serials.push(s);
|
||||
const labels = [];
|
||||
for (let i = 12; i >= 1; i--) {
|
||||
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||||
labels.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`);
|
||||
}
|
||||
return { serials, labels, debut: debut.toISOString().slice(0, 10), fin: fin.toISOString().slice(0, 10) };
|
||||
}
|
||||
|
||||
// ─── Auth NTLM → cookie de session ──────────────────────────────────────────
|
||||
|
||||
function ntlmGet(path) {
|
||||
return new Promise((resolve, reject) => {
|
||||
httpntlm.get(
|
||||
{
|
||||
url: `https://${HOST}${path}`,
|
||||
username: USER,
|
||||
password: PWD,
|
||||
domain: DOMAIN,
|
||||
workstation: "",
|
||||
rejectUnauthorized: false,
|
||||
headers: { "User-Agent": "Mozilla/5.0 CollectFlow", "X-Qlik-Xrfkey": xrfkey },
|
||||
},
|
||||
(err, res) => (err ? reject(err) : resolve(res)),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function cookieDepuis(setCookie) {
|
||||
const arr = Array.isArray(setCookie) ? setCookie : setCookie ? [String(setCookie)] : [];
|
||||
return arr.map((c) => String(c).split(";")[0]).find((c) => /X-Qlik-Session/i.test(c)) ?? null;
|
||||
}
|
||||
|
||||
async function session() {
|
||||
const hub = await fetch(`https://${HOST}/hub/`, { redirect: "manual" });
|
||||
const targetId = ((hub.headers.get("location") ?? "").match(/targetId=([0-9a-f-]+)/i) ?? [])[1] ?? "";
|
||||
const chemin = targetId
|
||||
? `/internal_windows_authentication/?targetId=${targetId}&xrfkey=${xrfkey}`
|
||||
: `/hub/?xrfkey=${xrfkey}`;
|
||||
const res = await ntlmGet(chemin);
|
||||
|
||||
const direct = cookieDepuis(res.headers["set-cookie"]);
|
||||
if (direct) return direct;
|
||||
|
||||
const loc = String(res.headers["location"] ?? "");
|
||||
if (!/qlikTicket=/.test(loc)) throw new Error(`auth NTLM sans ticket (HTTP ${res.statusCode})`);
|
||||
const ech = await fetch(loc, { redirect: "manual" });
|
||||
const sc = typeof ech.headers.getSetCookie === "function" ? ech.headers.getSetCookie() : [];
|
||||
const cookie = sc.map((c) => c.split(";")[0]).filter((c) => /X-Qlik-Session/i.test(c)).join("; ");
|
||||
if (!cookie) throw new Error("ticket non échangé contre un cookie de session");
|
||||
return cookie;
|
||||
}
|
||||
|
||||
// ─── Script exécuté dans la page (websocket Engine accepté par le proxy) ────
|
||||
|
||||
function scriptInPage({ app, token, dimArticle, dimMois, serials, labels, nbArticles }) {
|
||||
return new Promise((resolve) => {
|
||||
const url = `wss://${location.host}/app/${app}?reloadUri=${encodeURIComponent(location.href)}&qlik-csrf-token=${token}`;
|
||||
const ws = new WebSocket(url);
|
||||
const sortie = [];
|
||||
const log = (l) => { sortie.push(l); console.log("[cf] " + l); };
|
||||
let id = 0;
|
||||
const pend = new Map();
|
||||
const rpc = (method, params, handle = -1) => new Promise((res, rej) => {
|
||||
const i = ++id; pend.set(i, { res, rej });
|
||||
ws.send(JSON.stringify({ jsonrpc: "2.0", id: i, handle, method, params }));
|
||||
});
|
||||
const pret = new Promise((res, rej) => {
|
||||
ws.onmessage = (ev) => {
|
||||
const m = JSON.parse(ev.data);
|
||||
if (m.method === "OnConnected") return res();
|
||||
if (m.id && pend.has(m.id)) {
|
||||
const p = pend.get(m.id); pend.delete(m.id);
|
||||
if (m.error) p.rej(new Error(JSON.stringify(m.error)));
|
||||
else p.res(m.result);
|
||||
}
|
||||
};
|
||||
ws.onerror = () => rej(new Error("websocket refusé (403 ?)"));
|
||||
setTimeout(() => rej(new Error("timeout websocket")), 30000);
|
||||
});
|
||||
const dormir = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
await pret;
|
||||
const doc = (await rpc("OpenDoc", { qDocName: app })).qReturn.qHandle;
|
||||
|
||||
// ─── 1. Expressions des master measures ──────────────────────
|
||||
const ml = await rpc("CreateSessionObject", {
|
||||
qProp: {
|
||||
qInfo: { qType: "MeasureList" },
|
||||
qMeasureListDef: { qType: "measure", qData: { title: "/qMetaDef/title", expr: "/qMeasure/qDef" } },
|
||||
},
|
||||
}, doc);
|
||||
const items = (await rpc("GetLayout", {}, ml.qReturn.qHandle)).qLayout.qMeasureList?.qItems ?? [];
|
||||
const parTitre = new Map();
|
||||
log("=== EXPRESSIONS DES MASTER MEASURES ===");
|
||||
for (const it of items) {
|
||||
const t = (it.qMeta?.title ?? it.qData?.title ?? "").trim();
|
||||
if (!t) continue;
|
||||
parTitre.set(t.toLowerCase(), it.qInfo.qId);
|
||||
if (/^(CA N|Quantité N|Magasin Ventes Nb N|CA par Magasin N|Marge % N|Quantité COMP)$/i.test(t)) {
|
||||
log(` « ${t} » = ${it.qData?.expr ?? "?"}`);
|
||||
}
|
||||
}
|
||||
const mesure = (t) => parTitre.get(t.toLowerCase()) ?? null;
|
||||
|
||||
// ─── 2. Sélection de la fenêtre 12 mois ──────────────────────
|
||||
const champDate = await rpc("GetField", { qFieldName: "Date" }, doc);
|
||||
const dh = champDate.qReturn.qHandle;
|
||||
await rpc("Clear", {}, dh);
|
||||
await dormir(300);
|
||||
for (let i = 0; i < serials.length; i += 500) {
|
||||
await rpc("SelectValues", {
|
||||
qFieldValues: serials.slice(i, i + 500).map((s) => ({ qNum: s, qText: String(s) })),
|
||||
qToggleMode: i > 0,
|
||||
qSoftLock: true,
|
||||
}, dh);
|
||||
}
|
||||
await dormir(500);
|
||||
log("");
|
||||
log(`=== FENÊTRE SÉLECTIONNÉE : ${labels[0]} → ${labels[labels.length - 1]} (${serials.length} jours) ===`);
|
||||
|
||||
// ─── 3. Articles les plus vendus (par agrégation directe) ────
|
||||
const cubeTop = await rpc("CreateSessionObject", {
|
||||
qProp: {
|
||||
qInfo: { qType: "cf-val-top" },
|
||||
qHyperCubeDef: {
|
||||
qDimensions: [{ qLibraryId: dimArticle }],
|
||||
qMeasures: [{ qDef: { qDef: "Sum(quantite)" }, qSortBy: { qSortByNumeric: -1 } }],
|
||||
qInterColumnSortOrder: [1, 0],
|
||||
qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 2, qHeight: nbArticles }],
|
||||
},
|
||||
},
|
||||
}, doc);
|
||||
const topMatrix = (await rpc("GetLayout", {}, cubeTop.qReturn.qHandle))
|
||||
.qLayout.qHyperCube.qDataPages?.[0]?.qMatrix ?? [];
|
||||
const codes = topMatrix.map((r) => String(r[0]?.qText ?? "")).filter(Boolean);
|
||||
log(`articles testés (top ventes) : ${JSON.stringify(codes)}`);
|
||||
if (codes.length === 0) { ws.close(); return resolve({ ok: false, error: "aucun article vendu sur la fenêtre", sortie }); }
|
||||
|
||||
const champArticle = await rpc("GetField", { qFieldName: "Article Code" }, doc);
|
||||
const ah = champArticle.qReturn.qHandle;
|
||||
await rpc("Clear", {}, ah);
|
||||
await dormir(300);
|
||||
await rpc("SelectValues", { qFieldValues: codes.map((c) => ({ qText: c })), qToggleMode: false, qSoftLock: true }, ah);
|
||||
await dormir(500);
|
||||
|
||||
// ─── 4. Comparaison mois par mois ────────────────────────────
|
||||
const mesures = [
|
||||
{ qDef: { qDef: "Sum(quantite)" } },
|
||||
{ qDef: { qDef: "Sum(ca_ht)" } },
|
||||
{ qDef: { qDef: "Sum(ca_ttc)" } },
|
||||
{ qDef: { qDef: "Count(DISTINCT [Magasin Code])" } },
|
||||
{ qDef: { qDef: "Sum(marge)" } },
|
||||
];
|
||||
const idQte = mesure("Quantité N");
|
||||
const idCa = mesure("CA N");
|
||||
const idNbMag = mesure("Magasin Ventes Nb N");
|
||||
if (idQte) mesures.push({ qLibraryId: idQte });
|
||||
if (idCa) mesures.push({ qLibraryId: idCa });
|
||||
if (idNbMag) mesures.push({ qLibraryId: idNbMag });
|
||||
const largeur = 2 + mesures.length;
|
||||
|
||||
const cube = await rpc("CreateSessionObject", {
|
||||
qProp: {
|
||||
qInfo: { qType: "cf-val-mois" },
|
||||
qHyperCubeDef: {
|
||||
qDimensions: [{ qLibraryId: dimArticle }, { qLibraryId: dimMois }],
|
||||
qMeasures: mesures,
|
||||
qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: largeur, qHeight: 400 }],
|
||||
},
|
||||
},
|
||||
}, doc);
|
||||
const hc = (await rpc("GetLayout", {}, cube.qReturn.qHandle)).qLayout.qHyperCube;
|
||||
const lignes = hc.qDataPages?.[0]?.qMatrix ?? [];
|
||||
|
||||
log("");
|
||||
log("=== COMPARAISON MOIS PAR MOIS (agrégation directe vs master measure) ===");
|
||||
log("article mois Sum(quantite) Quantité N | Sum(ca_ht) Sum(ca_ttc) CA N | Count(mag) Mag Nb N");
|
||||
const pad = (v, n) => String(v).padStart(n);
|
||||
for (const r of lignes) {
|
||||
const code = String(r[0]?.qText ?? "");
|
||||
const moisBrut = String(r[1]?.qText ?? "");
|
||||
const mois = /^\d{6}$/.test(moisBrut) ? moisBrut.slice(0, 4) + "-" + moisBrut.slice(4) : moisBrut;
|
||||
const n = (i) => Number(r[2 + i]?.qNum ?? 0) || 0;
|
||||
log(
|
||||
code.padEnd(13) + mois.padEnd(9) +
|
||||
pad(Math.round(n(0)), 13) + pad(Math.round(n(5)), 12) + " |" +
|
||||
pad(Math.round(n(1)), 11) + pad(Math.round(n(2)), 13) + pad(Math.round(n(6)), 9) + " |" +
|
||||
pad(Math.round(n(3)), 11) + pad(Math.round(n(7)), 10),
|
||||
);
|
||||
}
|
||||
log("");
|
||||
log("LECTURE : si « Quantité N » vaut 0 sur les mois de l'année précédente alors que");
|
||||
log(" Sum(quantite) est renseigné, la master measure est bien inutilisable");
|
||||
log(" pour une fenêtre glissante — et l'agrégation directe la remplace.");
|
||||
log(" Si Sum(quantite) == Quantité N sur les mois de l'année en cours,");
|
||||
log(" l'expression par défaut de CollectFlow est validée.");
|
||||
log(" Comparer aussi Sum(ca_ht) et Sum(ca_ttc) à « CA N » pour choisir");
|
||||
log(" QLIK_EXPR_CA.");
|
||||
|
||||
// ─── 5. Clé de jointure : Article Code vs article_no_centrale ─
|
||||
const cubeCle = await rpc("CreateSessionObject", {
|
||||
qProp: {
|
||||
qInfo: { qType: "cf-val-cle" },
|
||||
qHyperCubeDef: {
|
||||
qDimensions: [
|
||||
{ qLibraryId: dimArticle },
|
||||
{ qDef: { qFieldDefs: ["article_no_centrale"] } },
|
||||
{ qDef: { qFieldDefs: ["article_codein"] } },
|
||||
],
|
||||
qMeasures: [],
|
||||
qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 3, qHeight: 20 }],
|
||||
},
|
||||
},
|
||||
}, doc);
|
||||
const cles = (await rpc("GetLayout", {}, cubeCle.qReturn.qHandle))
|
||||
.qLayout.qHyperCube.qDataPages?.[0]?.qMatrix ?? [];
|
||||
log("");
|
||||
log("=== CLÉ DE JOINTURE ===");
|
||||
log("Article Code article_no_centrale article_codein");
|
||||
for (const r of cles) {
|
||||
log(
|
||||
String(r[0]?.qText ?? "").padEnd(20) +
|
||||
String(r[1]?.qText ?? "").padEnd(22) +
|
||||
String(r[2]?.qText ?? ""),
|
||||
);
|
||||
}
|
||||
log("");
|
||||
log("LECTURE : la bonne clé est celle qui a le format de `articles.artcentrale`");
|
||||
log(" côté FF Nancy (ex. 10000167303 — préfixe 10000 + 6 chiffres).");
|
||||
|
||||
ws.close();
|
||||
resolve({ ok: true, sortie });
|
||||
} catch (e) {
|
||||
try { ws.close(); } catch { /* noop */ }
|
||||
resolve({ ok: false, error: String(e?.message || e), sortie });
|
||||
}
|
||||
})();
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Orchestration ──────────────────────────────────────────────────────────
|
||||
|
||||
(async () => {
|
||||
const f = fenetre12Mois();
|
||||
console.log(`Qlik ${HOST} — app ${APP}`);
|
||||
console.log(`Fenêtre : ${f.debut} → ${f.fin}\n`);
|
||||
|
||||
const cookie = await session();
|
||||
console.log("✓ authentifié (NTLM + ticket)\n");
|
||||
const [nom, ...reste] = cookie.split("=");
|
||||
|
||||
const navigateur = await chromium.launch({
|
||||
headless: true,
|
||||
executablePath: process.env.PLAYWRIGHT_CHROMIUM_PATH || undefined,
|
||||
args: ["--no-sandbox", "--disable-dev-shm-usage", "--ignore-certificate-errors"],
|
||||
});
|
||||
const ctx = await navigateur.newContext({
|
||||
httpCredentials: { username: USER, password: PWD, origin: `https://${HOST}` },
|
||||
ignoreHTTPSErrors: true,
|
||||
});
|
||||
await ctx.addCookies([{ name: nom, value: reste.join("="), domain: HOST, path: "/", secure: true }]);
|
||||
|
||||
try {
|
||||
const page = await ctx.newPage();
|
||||
let token = null;
|
||||
let pret;
|
||||
const attente = new Promise((r) => { pret = r; });
|
||||
page.on("websocket", (ws) => {
|
||||
const m = ws.url().match(/qlik-csrf-token=([^&]+)/);
|
||||
if (m && !token) { token = decodeURIComponent(m[1]); pret(); }
|
||||
});
|
||||
await page.goto(`https://${HOST}/sense/app/${APP}`, { waitUntil: "domcontentloaded", timeout: 60000 }).catch(() => {});
|
||||
await Promise.race([attente, page.waitForTimeout(20000)]);
|
||||
if (!token) throw new Error("qlik-csrf-token introuvable (client Qlik non chargé ?)");
|
||||
|
||||
const res = await page.evaluate(scriptInPage, {
|
||||
app: APP, token, dimArticle: DIM_ARTICLE, dimMois: DIM_MOIS,
|
||||
serials: f.serials, labels: f.labels, nbArticles: NB_ARTICLES,
|
||||
});
|
||||
|
||||
console.log(res.sortie.join("\n"));
|
||||
if (!res.ok) {
|
||||
console.error("\nÉCHEC : " + res.error);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
} finally {
|
||||
await ctx.close();
|
||||
await navigateur.close();
|
||||
}
|
||||
})().catch((e) => {
|
||||
console.error("ERREUR :", e?.message || e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* 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,528 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { Send, Bot, User, Database, Loader2, AlertCircle, ChevronDown, Trash2, DollarSign } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
|
||||
// Strip HTML tags and convert common HTML constructs to Markdown equivalents
|
||||
function htmlToMarkdown(text: string): string {
|
||||
return text
|
||||
// Block-level → markdown
|
||||
.replace(/<h([1-6])[^>]*>(.*?)<\/h\1>/gis, (_, level, content) => `${"#".repeat(Number(level))} ${content.replace(/<[^>]+>/g, "")}\n`)
|
||||
.replace(/<strong[^>]*>(.*?)<\/strong>/gis, "**$1**")
|
||||
.replace(/<b[^>]*>(.*?)<\/b>/gis, "**$1**")
|
||||
.replace(/<em[^>]*>(.*?)<\/em>/gis, "*$1*")
|
||||
.replace(/<i[^>]*>(.*?)<\/i>/gis, "*$1*")
|
||||
.replace(/<code[^>]*>(.*?)<\/code>/gis, "`$1`")
|
||||
.replace(/<pre[^>]*>(.*?)<\/pre>/gis, (_, c) => "```\n" + c.replace(/<[^>]+>/g, "") + "\n```")
|
||||
.replace(/<li[^>]*>(.*?)<\/li>/gis, "- $1")
|
||||
.replace(/<ul[^>]*>|<\/ul>/gis, "")
|
||||
.replace(/<ol[^>]*>|<\/ol>/gis, "")
|
||||
.replace(/<tr[^>]*>/gis, "")
|
||||
.replace(/<\/tr>/gis, "\n")
|
||||
.replace(/<th[^>]*>(.*?)<\/th>/gis, "| **$1** ")
|
||||
.replace(/<td[^>]*>(.*?)<\/td>/gis, "| $1 ")
|
||||
.replace(/<table[^>]*>|<\/table>|<thead[^>]*>|<\/thead>|<tbody[^>]*>|<\/tbody>/gis, "")
|
||||
.replace(/<br\s*\/?>/gis, "\n")
|
||||
.replace(/<p[^>]*>(.*?)<\/p>/gis, "$1\n\n")
|
||||
.replace(/<div[^>]*>(.*?)<\/div>/gis, "$1\n")
|
||||
.replace(/<a[^>]*href="([^"]*)"[^>]*>(.*?)<\/a>/gis, "[$2]($1)")
|
||||
// Strip any remaining tags
|
||||
.replace(/<[^>]+>/g, "")
|
||||
// Decode HTML entities
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/ /g, " ")
|
||||
// Clean up excessive blank lines
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
interface ModelInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
free: boolean;
|
||||
promptPrice: number; // $ per token
|
||||
completionPrice: number;
|
||||
}
|
||||
|
||||
interface ToolEvent {
|
||||
name: string;
|
||||
args: Record<string, unknown>;
|
||||
result?: string;
|
||||
}
|
||||
|
||||
interface Message {
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
toolEvents?: ToolEvent[];
|
||||
}
|
||||
|
||||
interface SessionUsage {
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
costUsd: number;
|
||||
}
|
||||
|
||||
const DEFAULT_MODEL = "google/gemini-2.0-flash-001";
|
||||
|
||||
// Compute cost from token counts and model pricing
|
||||
function computeCost(promptTokens: number, completionTokens: number, model: ModelInfo | null): number {
|
||||
if (!model) return 0;
|
||||
return promptTokens * model.promptPrice + completionTokens * model.completionPrice;
|
||||
}
|
||||
|
||||
function formatCost(usd: number): string {
|
||||
if (usd === 0) return "Gratuit";
|
||||
if (usd < 0.001) return `$${(usd * 1000).toFixed(4)}m`;
|
||||
return `$${usd.toFixed(6)}`;
|
||||
}
|
||||
|
||||
function ToolCallBlock({ event }: { event: ToolEvent }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const label = event.name === "execute_sql"
|
||||
? `SQL: ${String(event.args.query ?? "").substring(0, 60)}${String(event.args.query ?? "").length > 60 ? "…" : ""}`
|
||||
: event.name === "get_db_schema"
|
||||
? "Schéma DB"
|
||||
: event.name;
|
||||
|
||||
return (
|
||||
<div className="my-1 rounded-lg border border-[var(--border)] bg-[var(--bg-elevated)] overflow-hidden text-xs">
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-left hover:bg-[var(--bg-base)] transition-colors"
|
||||
>
|
||||
<Database className="w-3.5 h-3.5 text-[var(--accent)] shrink-0" />
|
||||
<span className="flex-1 text-[var(--text-secondary)] font-mono truncate">{label}</span>
|
||||
<ChevronDown className={cn("w-3.5 h-3.5 text-[var(--text-muted)] transition-transform", open && "rotate-180")} />
|
||||
</button>
|
||||
{open && event.result && (
|
||||
<pre className="px-3 pb-3 pt-1 text-[10px] text-[var(--text-secondary)] overflow-x-auto whitespace-pre-wrap break-words max-h-48 overflow-y-auto">
|
||||
{event.result}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageBubble({ msg }: { msg: Message }) {
|
||||
const isUser = msg.role === "user";
|
||||
|
||||
return (
|
||||
<div className={cn("flex gap-3 mb-4", isUser && "flex-row-reverse")}>
|
||||
{/* Avatar */}
|
||||
<div className={cn(
|
||||
"w-8 h-8 rounded-full flex items-center justify-center shrink-0 mt-0.5",
|
||||
isUser ? "bg-[var(--accent)] text-white" : "bg-[var(--bg-elevated)] border border-[var(--border)]"
|
||||
)}>
|
||||
{isUser
|
||||
? <User className="w-4 h-4" />
|
||||
: <Bot className="w-4 h-4 text-[var(--accent)]" />
|
||||
}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className={cn("flex-1 max-w-[85%]", isUser && "flex flex-col items-end")}>
|
||||
{/* Tool events (assistant only) */}
|
||||
{msg.toolEvents && msg.toolEvents.length > 0 && (
|
||||
<div className="w-full mb-2">
|
||||
{msg.toolEvents.map((ev, i) => (
|
||||
<ToolCallBlock key={i} event={ev} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Text bubble */}
|
||||
{msg.content && (
|
||||
<div className={cn(
|
||||
"rounded-2xl px-4 py-3 text-sm leading-relaxed break-words",
|
||||
isUser
|
||||
? "bg-[var(--accent)] text-white rounded-tr-md"
|
||||
: "bg-[var(--bg-elevated)] border border-[var(--border)] text-[var(--text-primary)] rounded-tl-md"
|
||||
)}>
|
||||
{isUser ? msg.content : (
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
p: ({ children }) => <p className="mb-2 last:mb-0">{children}</p>,
|
||||
h1: ({ children }) => <h1 className="text-base font-bold mb-2 mt-3 first:mt-0">{children}</h1>,
|
||||
h2: ({ children }) => <h2 className="text-sm font-bold mb-2 mt-3 first:mt-0">{children}</h2>,
|
||||
h3: ({ children }) => <h3 className="text-sm font-semibold mb-1 mt-2 first:mt-0">{children}</h3>,
|
||||
ul: ({ children }) => <ul className="list-disc list-inside mb-2 space-y-0.5">{children}</ul>,
|
||||
ol: ({ children }) => <ol className="list-decimal list-inside mb-2 space-y-0.5">{children}</ol>,
|
||||
li: ({ children }) => <li className="ml-2">{children}</li>,
|
||||
code: ({ inline, children }: { inline?: boolean; children?: React.ReactNode }) =>
|
||||
inline
|
||||
? <code className="px-1 py-0.5 rounded bg-[var(--bg-base)] border border-[var(--border)] font-mono text-xs text-[var(--accent)]">{children}</code>
|
||||
: <pre className="my-2 p-3 rounded-lg bg-[var(--bg-base)] border border-[var(--border)] overflow-x-auto"><code className="font-mono text-xs text-[var(--text-primary)] whitespace-pre">{children}</code></pre>,
|
||||
strong: ({ children }) => <strong className="font-semibold">{children}</strong>,
|
||||
em: ({ children }) => <em className="italic">{children}</em>,
|
||||
blockquote: ({ children }) => <blockquote className="border-l-2 border-[var(--accent)] pl-3 my-2 text-[var(--text-secondary)] italic">{children}</blockquote>,
|
||||
table: ({ children }) => <div className="overflow-x-auto my-2"><table className="w-full text-xs border-collapse">{children}</table></div>,
|
||||
thead: ({ children }) => <thead className="bg-[var(--bg-base)]">{children}</thead>,
|
||||
th: ({ children }) => <th className="border border-[var(--border)] px-2 py-1 text-left font-semibold">{children}</th>,
|
||||
td: ({ children }) => <td className="border border-[var(--border)] px-2 py-1">{children}</td>,
|
||||
hr: () => <hr className="border-[var(--border)] my-3" />,
|
||||
a: ({ href, children }) => <a href={href} target="_blank" rel="noopener noreferrer" className="text-[var(--accent)] underline hover:opacity-80">{children}</a>,
|
||||
}}
|
||||
>
|
||||
{htmlToMarkdown(msg.content)}
|
||||
</ReactMarkdown>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export default function AdminAiChatPage() {
|
||||
const [models, setModels] = useState<ModelInfo[]>([]);
|
||||
const [selectedModel, setSelectedModel] = useState<string>(DEFAULT_MODEL);
|
||||
const [aiProvider, setAiProviderState] = useState<"openrouter" | "google">("openrouter");
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [input, setInput] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loadingModels, setLoadingModels] = useState(true);
|
||||
const [sessionUsage, setSessionUsage] = useState<SessionUsage>({ promptTokens: 0, completionTokens: 0, costUsd: 0 });
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const selectedModelInfo = models.find(m => m.id === selectedModel) ?? null;
|
||||
|
||||
// Fetch config + models
|
||||
useEffect(() => {
|
||||
setLoadingModels(true);
|
||||
fetch("/api/admin/ai-config")
|
||||
.then(r => r.json())
|
||||
.then(async d => {
|
||||
const provider: "openrouter" | "google" = d.provider ?? "openrouter";
|
||||
setAiProviderState(provider);
|
||||
if (provider === "google") {
|
||||
const gRes = await fetch("/api/google-ai/models").then(r => r.json()).catch(() => ({ models: [] }));
|
||||
setModels((gRes.models ?? []).map((m: { id: string; name: string }) => ({
|
||||
id: m.id, name: m.name, free: false, promptPrice: 0, completionPrice: 0,
|
||||
})));
|
||||
setSelectedModel(d.googleAiModel ?? "gemini-2.0-flash");
|
||||
} else {
|
||||
const od = await fetch("/api/openrouter/models").then(r => r.json()).catch(() => ({ models: [] }));
|
||||
if (od.models) setModels(od.models);
|
||||
if (d.openRouterModel) setSelectedModel(d.openRouterModel);
|
||||
}
|
||||
})
|
||||
.catch(async () => {
|
||||
const od = await fetch("/api/openrouter/models").then(r => r.json()).catch(() => ({ models: [] }));
|
||||
if (od.models) setModels(od.models);
|
||||
})
|
||||
.finally(() => setLoadingModels(false));
|
||||
}, []);
|
||||
|
||||
// Auto-scroll
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [messages, loading]);
|
||||
|
||||
const sendMessage = useCallback(async () => {
|
||||
if (!input.trim() || loading) return;
|
||||
|
||||
const userMsg: Message = { role: "user", content: input.trim() };
|
||||
setInput("");
|
||||
setError(null);
|
||||
setMessages(prev => [...prev, userMsg]);
|
||||
setLoading(true);
|
||||
|
||||
// Build messages to send (excluding tool events, which are local display only)
|
||||
const apiMessages = [...messages, userMsg].map(m => ({
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
}));
|
||||
|
||||
const assistantMsg: Message = { role: "assistant", content: "", toolEvents: [] };
|
||||
setMessages(prev => [...prev, assistantMsg]);
|
||||
|
||||
abortRef.current = new AbortController();
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/admin/ai-chat", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ messages: apiMessages, model: selectedModel }),
|
||||
signal: abortRef.current.signal,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
throw new Error(err.error || "Erreur serveur");
|
||||
}
|
||||
|
||||
const reader = res.body!.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
const pendingToolEvents: ToolEvent[] = [];
|
||||
let lastToolStart: ToolEvent | null = null;
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() ?? "";
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith("data: ")) continue;
|
||||
const raw = line.slice(6).trim();
|
||||
try {
|
||||
const event = JSON.parse(raw);
|
||||
|
||||
if (event.type === "text") {
|
||||
setMessages(prev => {
|
||||
const copy = [...prev];
|
||||
const last = { ...copy[copy.length - 1] };
|
||||
last.content += event.content;
|
||||
copy[copy.length - 1] = last;
|
||||
return copy;
|
||||
});
|
||||
} else if (event.type === "tool_start") {
|
||||
lastToolStart = { name: event.name, args: event.args };
|
||||
} else if (event.type === "tool_result") {
|
||||
if (lastToolStart) {
|
||||
const ev: ToolEvent = { ...lastToolStart, result: event.result };
|
||||
pendingToolEvents.push(ev);
|
||||
lastToolStart = null;
|
||||
setMessages(prev => {
|
||||
const copy = [...prev];
|
||||
const last = { ...copy[copy.length - 1] };
|
||||
last.toolEvents = [...pendingToolEvents];
|
||||
copy[copy.length - 1] = last;
|
||||
return copy;
|
||||
});
|
||||
}
|
||||
} else if (event.type === "usage") {
|
||||
const pt = event.prompt_tokens ?? 0;
|
||||
const ct = event.completion_tokens ?? 0;
|
||||
const cost = computeCost(pt, ct, selectedModelInfo);
|
||||
setSessionUsage(prev => ({
|
||||
promptTokens: prev.promptTokens + pt,
|
||||
completionTokens: prev.completionTokens + ct,
|
||||
costUsd: prev.costUsd + cost,
|
||||
}));
|
||||
} else if (event.type === "error") {
|
||||
setError(event.message);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if ((err as Error).name !== "AbortError") {
|
||||
setError(err instanceof Error ? err.message : "Erreur inconnue");
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [input, loading, messages, selectedModel, selectedModelInfo]);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
sendMessage();
|
||||
}
|
||||
};
|
||||
|
||||
const clearConversation = () => {
|
||||
setMessages([]);
|
||||
setSessionUsage({ promptTokens: 0, completionTokens: 0, costUsd: 0 });
|
||||
setError(null);
|
||||
if (loading && abortRef.current) {
|
||||
abortRef.current.abort();
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full max-h-full">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4 shrink-0">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-[var(--text-primary)] flex items-center gap-2">
|
||||
<Bot className="w-5 h-5 text-[var(--accent)]" />
|
||||
Admin AI Chat
|
||||
</h1>
|
||||
<p className="text-xs text-[var(--text-muted)] mt-0.5">
|
||||
Accès direct à la base de données · Admin uniquement
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Usage / Cost */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-[var(--bg-elevated)] border border-[var(--border)] text-xs text-[var(--text-secondary)]">
|
||||
<DollarSign className="w-3.5 h-3.5 text-[var(--accent)]" />
|
||||
<span className="font-mono font-semibold">{formatCost(sessionUsage.costUsd)}</span>
|
||||
<span className="text-[var(--text-muted)]">session</span>
|
||||
{sessionUsage.promptTokens > 0 && (
|
||||
<span className="text-[var(--text-muted)] ml-1">
|
||||
({sessionUsage.promptTokens + sessionUsage.completionTokens} tok)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={clearConversation}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-[var(--border)] text-xs text-[var(--text-secondary)] hover:bg-[var(--bg-elevated)] transition-colors"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
Effacer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Model selector */}
|
||||
<div className="mb-4 shrink-0">
|
||||
<div className="flex items-center gap-3 p-3 rounded-xl bg-[var(--bg-elevated)] border border-[var(--border)]">
|
||||
<span className={cn(
|
||||
"text-[10px] font-semibold px-1.5 py-0.5 rounded shrink-0",
|
||||
aiProvider === "google"
|
||||
? "bg-blue-500/10 text-blue-500"
|
||||
: "bg-[var(--accent-bg)] text-[var(--accent)]"
|
||||
)}>
|
||||
{aiProvider === "google" ? "Google AI" : "OpenRouter"}
|
||||
</span>
|
||||
{loadingModels ? (
|
||||
<div className="flex items-center gap-2 text-xs text-[var(--text-muted)]">
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
Chargement des modèles…
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<select
|
||||
value={selectedModel}
|
||||
onChange={e => setSelectedModel(e.target.value)}
|
||||
className="flex-1 min-w-0 text-sm bg-transparent text-[var(--text-primary)] border-none outline-none cursor-pointer"
|
||||
>
|
||||
{models.map(m => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.name} {m.free ? "(Gratuit)" : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{selectedModelInfo && !selectedModelInfo.free && (
|
||||
<div className="flex items-center gap-2 text-[10px] text-[var(--text-muted)] shrink-0 font-mono">
|
||||
<span>in: ${(selectedModelInfo.promptPrice * 1_000_000).toFixed(2)}/M</span>
|
||||
<span>·</span>
|
||||
<span>out: ${(selectedModelInfo.completionPrice * 1_000_000).toFixed(2)}/M</span>
|
||||
</div>
|
||||
)}
|
||||
{selectedModelInfo?.free && (
|
||||
<span className="text-[10px] text-emerald-500 font-semibold shrink-0">GRATUIT</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div className="flex-1 overflow-y-auto min-h-0 rounded-xl bg-[var(--bg-base)] border border-[var(--border)] p-4">
|
||||
{messages.length === 0 && (
|
||||
<div className="h-full flex flex-col items-center justify-center text-center gap-4">
|
||||
<div className="w-16 h-16 rounded-2xl bg-[var(--accent-bg)] border border-[var(--accent-border)] flex items-center justify-center">
|
||||
<Bot className="w-8 h-8 text-[var(--accent)]" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[var(--text-primary)] font-medium mb-1">Assistant Admin CollectFlow</p>
|
||||
<p className="text-sm text-[var(--text-muted)] max-w-md">
|
||||
Posez des questions sur vos données, demandez des analyses, ou explorez la base de données.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 max-w-lg mt-2">
|
||||
{[
|
||||
"Combien de produits par fournisseur ?",
|
||||
"Quels sont les 10 produits avec le plus de CA ?",
|
||||
"Montre-moi les snapshots récents",
|
||||
"Répartition des gammes A/B/C/D/Z",
|
||||
].map(suggestion => (
|
||||
<button
|
||||
key={suggestion}
|
||||
onClick={() => { setInput(suggestion); textareaRef.current?.focus(); }}
|
||||
className="text-xs text-left px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--bg-elevated)] hover:bg-[var(--bg-base)] text-[var(--text-secondary)] transition-colors"
|
||||
>
|
||||
{suggestion}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.map((msg, i) => (
|
||||
<MessageBubble key={i} msg={msg} />
|
||||
))}
|
||||
|
||||
{loading && messages[messages.length - 1]?.role !== "assistant" && (
|
||||
<div className="flex gap-3 mb-4">
|
||||
<div className="w-8 h-8 rounded-full flex items-center justify-center bg-[var(--bg-elevated)] border border-[var(--border)]">
|
||||
<Bot className="w-4 h-4 text-[var(--accent)]" />
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 px-4 py-3 rounded-2xl rounded-tl-md bg-[var(--bg-elevated)] border border-[var(--border)]">
|
||||
<span className="w-1.5 h-1.5 bg-[var(--accent)] rounded-full animate-bounce [animation-delay:0ms]" />
|
||||
<span className="w-1.5 h-1.5 bg-[var(--accent)] rounded-full animate-bounce [animation-delay:150ms]" />
|
||||
<span className="w-1.5 h-1.5 bg-[var(--accent)] rounded-full animate-bounce [animation-delay:300ms]" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 p-3 rounded-xl bg-[var(--accent-error-bg)] border border-[var(--accent-error-border,var(--border))] text-sm text-[var(--accent-error)] mb-4">
|
||||
<AlertCircle className="w-4 h-4 shrink-0 mt-0.5" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div className="mt-3 shrink-0">
|
||||
<div className="flex items-end gap-2 p-2 rounded-xl bg-[var(--bg-elevated)] border border-[var(--border)] focus-within:border-[var(--accent)] transition-colors">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Posez une question… (Entrée pour envoyer, Shift+Entrée pour nouvelle ligne)"
|
||||
rows={1}
|
||||
className="flex-1 resize-none bg-transparent text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] outline-none py-2 px-2 max-h-32 overflow-y-auto"
|
||||
style={{ minHeight: "40px" }}
|
||||
onInput={(e) => {
|
||||
const el = e.currentTarget;
|
||||
el.style.height = "auto";
|
||||
el.style.height = Math.min(el.scrollHeight, 128) + "px";
|
||||
}}
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
onClick={sendMessage}
|
||||
disabled={!input.trim() || loading}
|
||||
className="w-9 h-9 rounded-lg bg-[var(--accent)] text-white flex items-center justify-center hover:opacity-90 disabled:opacity-40 disabled:cursor-not-allowed transition-opacity shrink-0"
|
||||
>
|
||||
{loading
|
||||
? <Loader2 className="w-4 h-4 animate-spin" />
|
||||
: <Send className="w-4 h-4" />
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[10px] text-[var(--text-muted)] mt-1.5 text-center">
|
||||
L'IA peut exécuter des requêtes SELECT sur la base de données · Les modifications ne sont pas autorisées
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
CalendarClock, Play, Square, RefreshCw, Loader2, AlertTriangle,
|
||||
CheckCircle, Database, Network, Search,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface SyncSettings {
|
||||
actif: boolean;
|
||||
heureDebut: number;
|
||||
heureFin: number;
|
||||
qlikParNuit: number;
|
||||
sqlParQlik: number;
|
||||
qlikMinJours: number;
|
||||
}
|
||||
|
||||
interface SyncState {
|
||||
enCours: boolean;
|
||||
origine: "auto" | "manuel" | null;
|
||||
debutAt: string | null;
|
||||
finAt: string | null;
|
||||
fournisseurCourant: string | null;
|
||||
etape: "sql" | "qlik" | null;
|
||||
sqlFaits: number;
|
||||
sqlEchecs: number;
|
||||
qlikFaits: number;
|
||||
qlikEchecs: number;
|
||||
tourTermine: boolean;
|
||||
derniereErreur: string | null;
|
||||
}
|
||||
|
||||
interface FournisseurRow {
|
||||
codeFournisseur: string;
|
||||
nomFournisseur: string | null;
|
||||
actifSql: boolean;
|
||||
actifQlik: boolean;
|
||||
desactiveMotif: string | null;
|
||||
dernierSqlAt: string | null;
|
||||
dernierSqlStatut: string | null;
|
||||
dernierSqlLignes: number | null;
|
||||
dernierSqlMs: number | null;
|
||||
dernierQlikAt: string | null;
|
||||
dernierQlikStatut: string | null;
|
||||
dernierQlikCodes: number | null;
|
||||
}
|
||||
|
||||
interface Resume {
|
||||
total: number; actifsSql: number; actifsQlik: number;
|
||||
desactivesAuto: number; enEchecSql: number; enEchecQlik: number; jamaisSql: number;
|
||||
}
|
||||
|
||||
const fmtDate = (iso: string | null) =>
|
||||
iso ? new Date(iso).toLocaleString("fr-FR", { dateStyle: "short", timeStyle: "short" }) : "jamais";
|
||||
const fmtDuree = (ms: number | null) =>
|
||||
ms == null ? "" : ms < 1000 ? `${ms} ms` : `${Math.round(ms / 1000)} s`;
|
||||
|
||||
function Pastille({ statut }: { statut: string | null }) {
|
||||
if (!statut) return <span className="text-[11px]" style={{ color: "var(--text-muted)" }}>—</span>;
|
||||
const couleurs: Record<string, string> = {
|
||||
succes: "text-emerald-500", echec: "text-red-500", vide: "text-amber-500",
|
||||
};
|
||||
const libelles: Record<string, string> = { succes: "ok", echec: "échec", vide: "vide" };
|
||||
return <span className={cn("text-[11px] font-semibold", couleurs[statut] ?? "")}>{libelles[statut] ?? statut}</span>;
|
||||
}
|
||||
|
||||
export function SynchronisationClient() {
|
||||
const [settings, setSettings] = useState<SyncSettings | null>(null);
|
||||
const [state, setState] = useState<SyncState | null>(null);
|
||||
const [dansLaFenetre, setDansLaFenetre] = useState(false);
|
||||
const [rows, setRows] = useState<FournisseurRow[]>([]);
|
||||
const [resume, setResume] = useState<Resume | null>(null);
|
||||
const [erreur, setErreur] = useState<string | null>(null);
|
||||
const [chargement, setChargement] = useState(true);
|
||||
const [recherche, setRecherche] = useState("");
|
||||
const [filtre, setFiltre] = useState<"tous" | "actifs" | "inactifs" | "echecs">("tous");
|
||||
|
||||
// ── Lectures pures : aucun setState, pour rester appelables depuis un effet ──
|
||||
const lireEtat = useCallback(async () => {
|
||||
const res = await fetch("/api/admin/sync");
|
||||
if (!res.ok) throw new Error((await res.json()).error ?? `Erreur ${res.status}`);
|
||||
return res.json();
|
||||
}, []);
|
||||
|
||||
const lireFournisseurs = useCallback(async () => {
|
||||
const res = await fetch("/api/admin/sync/fournisseurs");
|
||||
if (!res.ok) throw new Error((await res.json()).error ?? `Erreur ${res.status}`);
|
||||
return res.json();
|
||||
}, []);
|
||||
|
||||
const rafraichirEtat = useCallback(() => {
|
||||
lireEtat()
|
||||
.then((d) => { setSettings(d.settings); setState(d.state); setDansLaFenetre(d.dansLaFenetre); setErreur(null); })
|
||||
.catch((e) => setErreur(e instanceof Error ? e.message : String(e)));
|
||||
}, [lireEtat]);
|
||||
|
||||
const rafraichirTout = useCallback(() => {
|
||||
Promise.all([lireEtat(), lireFournisseurs()])
|
||||
.then(([e, f]) => {
|
||||
setSettings(e.settings); setState(e.state); setDansLaFenetre(e.dansLaFenetre);
|
||||
setRows(f.fournisseurs ?? []); setResume(f.resume ?? null); setErreur(null);
|
||||
})
|
||||
.catch((e) => setErreur(e instanceof Error ? e.message : String(e)))
|
||||
.finally(() => setChargement(false));
|
||||
}, [lireEtat, lireFournisseurs]);
|
||||
|
||||
useEffect(() => { rafraichirTout(); }, [rafraichirTout]);
|
||||
|
||||
// Suivi rapproché pendant un round : l'état change à chaque fournisseur.
|
||||
useEffect(() => {
|
||||
if (!state?.enCours) return;
|
||||
const id = setInterval(rafraichirEtat, 3000);
|
||||
return () => clearInterval(id);
|
||||
}, [state?.enCours, rafraichirEtat]);
|
||||
|
||||
const majSettings = async (patch: Partial<SyncSettings>) => {
|
||||
setErreur(null);
|
||||
try {
|
||||
const res = await fetch("/api/admin/sync", {
|
||||
method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(patch),
|
||||
});
|
||||
const d = await res.json();
|
||||
if (!res.ok) throw new Error(d.error ?? `Erreur ${res.status}`);
|
||||
setSettings(d.settings);
|
||||
} catch (e) { setErreur(e instanceof Error ? e.message : String(e)); }
|
||||
};
|
||||
|
||||
const action = async (act: "start" | "stop" | "seed", forcer = false) => {
|
||||
setErreur(null);
|
||||
try {
|
||||
const res = await fetch("/api/admin/sync", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: act, forcer }),
|
||||
});
|
||||
const d = await res.json();
|
||||
if (!res.ok) throw new Error(d.error ?? `Erreur ${res.status}`);
|
||||
rafraichirTout();
|
||||
} catch (e) { setErreur(e instanceof Error ? e.message : String(e)); }
|
||||
};
|
||||
|
||||
const basculer = async (codes: string[], champ: "actifSql" | "actifQlik", valeur: boolean) => {
|
||||
setErreur(null);
|
||||
// Mise à jour optimiste : la liste peut compter des centaines de lignes,
|
||||
// un rechargement complet à chaque case cochée serait pénible.
|
||||
setRows((prev) => prev.map((r) => (codes.includes(r.codeFournisseur)
|
||||
? { ...r, [champ]: valeur, ...(valeur ? { desactiveMotif: null } : {}) }
|
||||
: r)));
|
||||
try {
|
||||
const res = await fetch("/api/admin/sync/fournisseurs", {
|
||||
method: "PATCH", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ codes, [champ]: valeur }),
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.json()).error ?? `Erreur ${res.status}`);
|
||||
} catch (e) {
|
||||
setErreur(e instanceof Error ? e.message : String(e));
|
||||
rafraichirTout(); // l'optimisme était infondé : on resynchronise
|
||||
}
|
||||
};
|
||||
|
||||
const visibles = useMemo(() => {
|
||||
const q = recherche.trim().toLowerCase();
|
||||
return rows.filter((r) => {
|
||||
if (q && !`${r.codeFournisseur} ${r.nomFournisseur ?? ""}`.toLowerCase().includes(q)) return false;
|
||||
if (filtre === "actifs") return r.actifSql || r.actifQlik;
|
||||
if (filtre === "inactifs") return !r.actifSql && !r.actifQlik;
|
||||
if (filtre === "echecs") return r.dernierSqlStatut === "echec" || r.dernierQlikStatut === "echec";
|
||||
return true;
|
||||
});
|
||||
}, [rows, recherche, filtre]);
|
||||
|
||||
const codesVisibles = useMemo(() => visibles.map((r) => r.codeFournisseur), [visibles]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen p-6" style={{ background: "var(--bg-base)" }}>
|
||||
<div className="mx-auto max-w-screen-xl">
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2" style={{ color: "var(--text-primary)" }}>
|
||||
<CalendarClock className="w-6 h-6" style={{ color: "var(--accent)" }} />
|
||||
Synchronisation nocturne
|
||||
</h1>
|
||||
<p className="text-[13px] mt-1 mb-5" style={{ color: "var(--text-muted)" }}>
|
||||
Les fournisseurs sont traités du plus anciennement synchronisé au plus récent. Si la fenêtre
|
||||
se referme avant la fin, la nuit suivante reprend là où on s'est arrêté ; quand tout le
|
||||
monde est passé, un nouveau tour recommence.
|
||||
</p>
|
||||
|
||||
{erreur && (
|
||||
<div className="rounded-xl p-3.5 mb-4 flex items-start gap-2.5 text-[13px]"
|
||||
style={{ background: "var(--accent-error-bg)", border: "1px solid var(--accent-error)", color: "var(--accent-error)" }}>
|
||||
<AlertTriangle className="w-4 h-4 shrink-0 mt-px" />
|
||||
<span>{erreur}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Réglages ── */}
|
||||
{settings && (
|
||||
<div className="rounded-2xl p-4 mb-4" style={{ background: "var(--bg-surface)", border: "1px solid var(--border)" }}>
|
||||
<div className="flex flex-wrap items-end gap-4">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" checked={settings.actif}
|
||||
onChange={(e) => majSettings({ actif: e.target.checked })} className="w-4 h-4" />
|
||||
<span className="text-[13px] font-semibold" style={{ color: "var(--text-primary)" }}>
|
||||
Synchronisation nocturne active
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div className="flex items-end gap-2">
|
||||
<div>
|
||||
<label className="block text-[11px] mb-1" style={{ color: "var(--text-muted)" }}>De</label>
|
||||
<input type="number" min={0} max={23} value={settings.heureDebut}
|
||||
onChange={(e) => majSettings({ heureDebut: Number(e.target.value) })}
|
||||
className="w-16 rounded-lg px-2 py-1.5 text-[13px]"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-primary)" }} />
|
||||
</div>
|
||||
<span className="pb-2 text-[13px]" style={{ color: "var(--text-muted)" }}>h à</span>
|
||||
<div>
|
||||
<label className="block text-[11px] mb-1" style={{ color: "var(--text-muted)" }}>À</label>
|
||||
<input type="number" min={0} max={23} value={settings.heureFin}
|
||||
onChange={(e) => majSettings({ heureFin: Number(e.target.value) })}
|
||||
className="w-16 rounded-lg px-2 py-1.5 text-[13px]"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-primary)" }} />
|
||||
</div>
|
||||
<span className="pb-2 text-[13px]" style={{ color: "var(--text-muted)" }}>h</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-[11px] mb-1" style={{ color: "var(--text-muted)" }}>Qlik / nuit</label>
|
||||
<input type="number" min={0} max={200} value={settings.qlikParNuit}
|
||||
onChange={(e) => majSettings({ qlikParNuit: Number(e.target.value) })}
|
||||
className="w-20 rounded-lg px-2 py-1.5 text-[13px]"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-primary)" }} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[11px] mb-1" style={{ color: "var(--text-muted)" }}>Qlik si + vieux que</label>
|
||||
<input type="number" min={0} max={365} value={settings.qlikMinJours}
|
||||
onChange={(e) => majSettings({ qlikMinJours: Number(e.target.value) })}
|
||||
className="w-20 rounded-lg px-2 py-1.5 text-[13px]"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-primary)" }} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{state?.enCours ? (
|
||||
<button onClick={() => action("stop")} className="rounded-xl px-4 py-2 text-[13px] font-semibold flex items-center gap-2"
|
||||
style={{ background: "var(--accent-error-bg)", color: "var(--accent-error)", border: "1px solid var(--accent-error)" }}>
|
||||
<Square className="w-3.5 h-3.5" /> Arrêter
|
||||
</button>
|
||||
) : (
|
||||
<button onClick={() => action("start", !dansLaFenetre)} className="rounded-xl px-4 py-2 text-[13px] font-semibold text-white flex items-center gap-2"
|
||||
style={{ background: "var(--accent)" }}>
|
||||
<Play className="w-3.5 h-3.5" /> Lancer maintenant
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] mt-3" style={{ color: "var(--text-muted)" }}>
|
||||
{dansLaFenetre
|
||||
? "Nous sommes actuellement dans la fenêtre."
|
||||
: "Hors fenêtre — « Lancer maintenant » forcera un round immédiat."}
|
||||
{" "}Une extraction Qlik peut durer 8 minutes : le plafond par nuit évite qu'elle
|
||||
monopolise la fenêtre. Un fournisseur déjà démarré n'est jamais interrompu à l'heure de fin.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Round en cours ── */}
|
||||
{state && (state.enCours || state.finAt) && (
|
||||
<div className="rounded-2xl p-4 mb-4 text-[13px]"
|
||||
style={{ background: state.enCours ? "var(--accent-bg)" : "var(--bg-surface)", border: `1px solid ${state.enCours ? "var(--accent-border)" : "var(--border)"}` }}>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{state.enCours
|
||||
? <Loader2 className="w-4 h-4 animate-spin" style={{ color: "var(--accent)" }} />
|
||||
: <CheckCircle className="w-4 h-4 text-emerald-500" />}
|
||||
<span className="font-semibold" style={{ color: "var(--text-primary)" }}>
|
||||
{state.enCours ? `Round ${state.origine} en cours` : "Dernier round terminé"}
|
||||
</span>
|
||||
{state.enCours && state.fournisseurCourant && (
|
||||
<span style={{ color: "var(--text-secondary)" }}>
|
||||
— {state.etape === "qlik" ? "Qlik" : "SQL"} : {state.fournisseurCourant}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-x-5 gap-y-1 text-[12px]" style={{ color: "var(--text-secondary)" }}>
|
||||
<span><Database className="w-3 h-3 inline mr-1" />SQL : {state.sqlFaits} ok{state.sqlEchecs > 0 && `, ${state.sqlEchecs} en échec`}</span>
|
||||
<span><Network className="w-3 h-3 inline mr-1" />Qlik : {state.qlikFaits} ok{state.qlikEchecs > 0 && `, ${state.qlikEchecs} en échec`}</span>
|
||||
{state.debutAt && <span>Début {fmtDate(state.debutAt)}</span>}
|
||||
{state.tourTermine && <span className="text-emerald-500 font-semibold">Tour bouclé — tout le monde est à jour</span>}
|
||||
</div>
|
||||
{state.derniereErreur && (
|
||||
<p className="mt-2 text-[11px] text-red-400">Dernière erreur : {state.derniereErreur}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Résumé ── */}
|
||||
{resume && (
|
||||
<div className="flex flex-wrap gap-2 mb-3 text-[12px]">
|
||||
{[
|
||||
{ l: "Fournisseurs", v: resume.total },
|
||||
{ l: "Actifs SQL", v: resume.actifsSql },
|
||||
{ l: "Actifs Qlik", v: resume.actifsQlik },
|
||||
{ l: "Jamais synchronisés", v: resume.jamaisSql },
|
||||
{ l: "Désactivés auto", v: resume.desactivesAuto },
|
||||
{ l: "En échec", v: resume.enEchecSql + resume.enEchecQlik },
|
||||
].map((s) => (
|
||||
<span key={s.l} className="rounded-lg px-2.5 py-1"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}>
|
||||
{s.l} <strong style={{ color: "var(--text-primary)" }}>{s.v}</strong>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Filtres et actions de masse ── */}
|
||||
<div className="flex flex-wrap items-center gap-2 mb-3">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5" style={{ color: "var(--text-muted)" }} />
|
||||
<input value={recherche} onChange={(e) => setRecherche(e.target.value)} placeholder="Code ou nom"
|
||||
className="rounded-lg pl-8 pr-3 py-1.5 text-[12px] w-56"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-primary)" }} />
|
||||
</div>
|
||||
{(["tous", "actifs", "inactifs", "echecs"] as const).map((f) => (
|
||||
<button key={f} onClick={() => setFiltre(f)}
|
||||
className="rounded-full px-3 py-1 text-[12px] font-semibold"
|
||||
style={{
|
||||
background: filtre === f ? "var(--accent)" : "var(--bg-elevated)",
|
||||
color: filtre === f ? "#fff" : "var(--text-secondary)",
|
||||
border: "1px solid var(--border)",
|
||||
}}>
|
||||
{f === "echecs" ? "en échec" : f}
|
||||
</button>
|
||||
))}
|
||||
<div className="flex-1" />
|
||||
<span className="text-[11px]" style={{ color: "var(--text-muted)" }}>{visibles.length} affichés :</span>
|
||||
<button onClick={() => basculer(codesVisibles, "actifSql", true)} className="rounded-lg px-2.5 py-1 text-[11px]"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}>
|
||||
Activer SQL
|
||||
</button>
|
||||
<button onClick={() => basculer(codesVisibles, "actifSql", false)} className="rounded-lg px-2.5 py-1 text-[11px]"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}>
|
||||
Désactiver SQL
|
||||
</button>
|
||||
<button onClick={() => basculer(codesVisibles, "actifQlik", false)} className="rounded-lg px-2.5 py-1 text-[11px]"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}>
|
||||
Désactiver Qlik
|
||||
</button>
|
||||
<button onClick={() => action("seed")} title="Récupérer les nouveaux fournisseurs du référentiel"
|
||||
className="rounded-lg px-2.5 py-1 text-[11px] flex items-center gap-1"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}>
|
||||
<RefreshCw className="w-3 h-3" /> Actualiser la liste
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── Tableau ── */}
|
||||
<div className="rounded-2xl overflow-hidden" style={{ background: "var(--bg-surface)", border: "1px solid var(--border)" }}>
|
||||
{chargement ? (
|
||||
<div className="p-6 text-center text-[13px]" style={{ color: "var(--text-muted)" }}>
|
||||
<Loader2 className="w-4 h-4 animate-spin inline mr-2" /> Chargement…
|
||||
</div>
|
||||
) : visibles.length === 0 ? (
|
||||
<div className="p-6 text-center text-[13px]" style={{ color: "var(--text-muted)" }}>
|
||||
Aucun fournisseur. Utilisez « Actualiser la liste » pour importer le référentiel.
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-[12px] border-collapse">
|
||||
<thead>
|
||||
<tr style={{ background: "var(--bg-elevated)" }}>
|
||||
{["Fournisseur", "SQL", "Dernier SQL", "Qlik", "Dernier Qlik", ""].map((h) => (
|
||||
<th key={h} className="px-3 py-2.5 text-left font-semibold whitespace-nowrap"
|
||||
style={{ color: "var(--text-secondary)", borderBottom: "1px solid var(--border)" }}>{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{visibles.map((r) => (
|
||||
<tr key={r.codeFournisseur} style={{ borderBottom: "1px solid var(--border)" }}>
|
||||
<td className="px-3 py-2">
|
||||
<div className="font-medium" style={{ color: "var(--text-primary)" }}>
|
||||
{r.nomFournisseur ?? r.codeFournisseur}
|
||||
</div>
|
||||
<div className="text-[10px]" style={{ color: "var(--text-muted)" }}>{r.codeFournisseur}</div>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<input type="checkbox" checked={r.actifSql} className="w-4 h-4"
|
||||
onChange={(e) => basculer([r.codeFournisseur], "actifSql", e.target.checked)} />
|
||||
</td>
|
||||
<td className="px-3 py-2 whitespace-nowrap" style={{ color: "var(--text-secondary)" }}>
|
||||
<Pastille statut={r.dernierSqlStatut} />{" "}
|
||||
{fmtDate(r.dernierSqlAt)}
|
||||
{r.dernierSqlLignes != null && <span style={{ color: "var(--text-muted)" }}> · {r.dernierSqlLignes.toLocaleString("fr-FR")} lignes {fmtDuree(r.dernierSqlMs)}</span>}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<input type="checkbox" checked={r.actifQlik} className="w-4 h-4"
|
||||
onChange={(e) => basculer([r.codeFournisseur], "actifQlik", e.target.checked)} />
|
||||
</td>
|
||||
<td className="px-3 py-2 whitespace-nowrap" style={{ color: "var(--text-secondary)" }}>
|
||||
<Pastille statut={r.dernierQlikStatut} />{" "}
|
||||
{fmtDate(r.dernierQlikAt)}
|
||||
{r.dernierQlikCodes != null && <span style={{ color: "var(--text-muted)" }}> · {r.dernierQlikCodes.toLocaleString("fr-FR")} codes</span>}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-[11px]" style={{ color: "var(--text-muted)" }}>
|
||||
{r.desactiveMotif}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { SynchronisationClient } from "./client";
|
||||
|
||||
export const metadata = {
|
||||
title: "Synchronisation nocturne — CollectFlow",
|
||||
};
|
||||
|
||||
/**
|
||||
* Paramétrage de la synchronisation nocturne des fournisseurs.
|
||||
*
|
||||
* L'accès admin est déjà imposé par le middleware (préfixe /admin) ; les routes
|
||||
* /api/admin/sync revérifient le rôle côté serveur.
|
||||
*/
|
||||
export default function SynchronisationPage() {
|
||||
return <SynchronisationClient />;
|
||||
}
|
||||
@@ -19,7 +19,8 @@ export default async function GridPage({ searchParams }: GridPageProps) {
|
||||
const filters = {
|
||||
code1: params.code1 || null,
|
||||
code2: params.code2 || null,
|
||||
code3: params.code3 || null,
|
||||
// Liste séparée par des virgules : le filtre accepte plusieurs nomenclatures.
|
||||
code3: params.code3 ? String(params.code3).split(",").filter(Boolean) : null,
|
||||
};
|
||||
|
||||
// 1. Fetch available suppliers & stores
|
||||
|
||||
@@ -28,6 +28,43 @@ interface Props {
|
||||
pivotted: HitParadePivotRow[];
|
||||
}
|
||||
|
||||
function ColHeader({
|
||||
label,
|
||||
sortable,
|
||||
group,
|
||||
sortKey,
|
||||
sortDir,
|
||||
onSort,
|
||||
}: {
|
||||
label: string;
|
||||
sortable?: SortKey;
|
||||
group?: "292" | "579" | "total";
|
||||
sortKey: SortKey;
|
||||
sortDir: SortDir;
|
||||
onSort: (key: SortKey) => void;
|
||||
}) {
|
||||
const isActive = sortable && sortKey === sortable;
|
||||
const groupBg = group === "292" ? "bg-blue-50" : group === "579" ? "bg-violet-50" : group === "total" ? "bg-emerald-50" : "";
|
||||
const activeBg = group === "292" ? "bg-blue-100 text-blue-700" : group === "579" ? "bg-violet-100 text-violet-700" : group === "total" ? "bg-emerald-100 text-emerald-700" : "bg-blue-50 text-blue-700";
|
||||
return (
|
||||
<th
|
||||
onClick={sortable ? () => onSort(sortable) : undefined}
|
||||
className={[
|
||||
"px-2 py-2 text-center text-xs font-semibold whitespace-nowrap select-none",
|
||||
sortable ? "cursor-pointer" : "",
|
||||
isActive ? activeBg : `${groupBg} text-gray-600`,
|
||||
].join(" ")}
|
||||
>
|
||||
{label}
|
||||
{sortable && (
|
||||
<span className="ml-1 inline-block w-3 text-center">
|
||||
{isActive ? (sortDir === "desc" ? "↓" : "↑") : <span className="text-gray-300">↕</span>}
|
||||
</span>
|
||||
)}
|
||||
</th>
|
||||
);
|
||||
}
|
||||
|
||||
export function HitParadeClient({ dateDebut, dateFin, pivotted }: Props) {
|
||||
const router = useRouter();
|
||||
const [sortKey, setSortKey] = useState<SortKey>(DEFAULT_SORT);
|
||||
@@ -73,6 +110,32 @@ export function HitParadeClient({ dateDebut, dateFin, pivotted }: Props) {
|
||||
|
||||
const isSorted = sortKey !== DEFAULT_SORT || sortDir !== DEFAULT_DIR;
|
||||
|
||||
const filtered = useMemo(() =>
|
||||
pivotted.filter(r =>
|
||||
(filterFournisseur === "Tous" || r.fournisseur === filterFournisseur) &&
|
||||
(filterNomenclature === "Tous" || r.nomenclature_code === filterNomenclature)
|
||||
),
|
||||
[pivotted, filterFournisseur, filterNomenclature]
|
||||
);
|
||||
|
||||
const sorted = useMemo(() =>
|
||||
[...filtered].sort((a, b) => {
|
||||
const diff = a[sortKey] - b[sortKey];
|
||||
return sortDir === "desc" ? -diff : diff;
|
||||
}),
|
||||
[filtered, sortKey, sortDir]
|
||||
);
|
||||
|
||||
const totals = useMemo(() => sorted.reduce(
|
||||
(acc, r) => ({
|
||||
qte292: acc.qte292 + r.qte292, ca292: acc.ca292 + r.ca292, marge292: acc.marge292 + r.marge292,
|
||||
qte579: acc.qte579 + r.qte579, ca579: acc.ca579 + r.ca579, marge579: acc.marge579 + r.marge579,
|
||||
qteTotal: acc.qteTotal + r.qteTotal, caTotal: acc.caTotal + r.caTotal, margeTotal: acc.margeTotal + r.margeTotal,
|
||||
stock292: acc.stock292 + r.stock292, stock579: acc.stock579 + r.stock579, stockTotal: acc.stockTotal + r.stockTotal,
|
||||
}),
|
||||
{ qte292: 0, ca292: 0, marge292: 0, qte579: 0, ca579: 0, marge579: 0, qteTotal: 0, caTotal: 0, margeTotal: 0, stock292: 0, stock579: 0, stockTotal: 0 }
|
||||
), [sorted]);
|
||||
|
||||
function exportToExcel() {
|
||||
const headers = [
|
||||
"Code", "Désignation", "Fournisseur", "Nomenclature",
|
||||
@@ -115,56 +178,6 @@ export function HitParadeClient({ dateDebut, dateFin, pivotted }: Props) {
|
||||
XLSX.writeFile(wb, `hit-parade_${dateDebut}_${dateFin}.xlsx`);
|
||||
}
|
||||
|
||||
const filtered = useMemo(() =>
|
||||
pivotted.filter(r =>
|
||||
(filterFournisseur === "Tous" || r.fournisseur === filterFournisseur) &&
|
||||
(filterNomenclature === "Tous" || r.nomenclature_code === filterNomenclature)
|
||||
),
|
||||
[pivotted, filterFournisseur, filterNomenclature]
|
||||
);
|
||||
|
||||
const sorted = useMemo(() =>
|
||||
[...filtered].sort((a, b) => {
|
||||
const diff = a[sortKey] - b[sortKey];
|
||||
return sortDir === "desc" ? -diff : diff;
|
||||
}),
|
||||
[filtered, sortKey, sortDir]
|
||||
);
|
||||
|
||||
const totals = useMemo(() => sorted.reduce(
|
||||
(acc, r) => ({
|
||||
qte292: acc.qte292 + r.qte292, ca292: acc.ca292 + r.ca292, marge292: acc.marge292 + r.marge292,
|
||||
qte579: acc.qte579 + r.qte579, ca579: acc.ca579 + r.ca579, marge579: acc.marge579 + r.marge579,
|
||||
qteTotal: acc.qteTotal + r.qteTotal, caTotal: acc.caTotal + r.caTotal, margeTotal: acc.margeTotal + r.margeTotal,
|
||||
stock292: acc.stock292 + r.stock292, stock579: acc.stock579 + r.stock579, stockTotal: acc.stockTotal + r.stockTotal,
|
||||
}),
|
||||
{ qte292: 0, ca292: 0, marge292: 0, qte579: 0, ca579: 0, marge579: 0, qteTotal: 0, caTotal: 0, margeTotal: 0, stock292: 0, stock579: 0, stockTotal: 0 }
|
||||
), [sorted]);
|
||||
|
||||
function ColHeader({ label, sortable, k, group }: { label: string; sortable?: SortKey; k: string; group?: "292" | "579" | "total" }) {
|
||||
const isActive = sortable && sortKey === sortable;
|
||||
const groupBg = group === "292" ? "bg-blue-50" : group === "579" ? "bg-violet-50" : group === "total" ? "bg-emerald-50" : "";
|
||||
const activeBg = group === "292" ? "bg-blue-100 text-blue-700" : group === "579" ? "bg-violet-100 text-violet-700" : group === "total" ? "bg-emerald-100 text-emerald-700" : "bg-blue-50 text-blue-700";
|
||||
return (
|
||||
<th
|
||||
key={k}
|
||||
onClick={sortable ? () => handleSort(sortable) : undefined}
|
||||
className={[
|
||||
"px-2 py-2 text-center text-xs font-semibold whitespace-nowrap select-none",
|
||||
sortable ? "cursor-pointer" : "",
|
||||
isActive ? activeBg : `${groupBg} text-gray-600`,
|
||||
].join(" ")}
|
||||
>
|
||||
{label}
|
||||
{sortable && (
|
||||
<span className="ml-1 inline-block w-3 text-center">
|
||||
{isActive ? (sortDir === "desc" ? "↓" : "↑") : <span className="text-gray-300">↕</span>}
|
||||
</span>
|
||||
)}
|
||||
</th>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Barre de contrôles */}
|
||||
@@ -277,22 +290,22 @@ export function HitParadeClient({ dateDebut, dateFin, pivotted }: Props) {
|
||||
</th>
|
||||
</tr>
|
||||
<tr className="border-b-2 border-gray-200">
|
||||
<ColHeader label="Qté" sortable="qte292" k="qte292" group="292" />
|
||||
<ColHeader label="CA TTC" sortable="ca292" k="ca292" group="292" />
|
||||
<ColHeader label="Qté" sortable="qte292" group="292" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} />
|
||||
<ColHeader label="CA TTC" sortable="ca292" group="292" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} />
|
||||
<th className="bg-blue-50 px-2 py-2 text-center text-xs font-semibold text-blue-500">% Marge</th>
|
||||
<ColHeader label="Stock" sortable="stock292" k="stock292" group="292" />
|
||||
<ColHeader label="Qté" sortable="qte579" k="qte579" group="579" />
|
||||
<ColHeader label="CA TTC" sortable="ca579" k="ca579" group="579" />
|
||||
<ColHeader label="Stock" sortable="stock292" group="292" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} />
|
||||
<ColHeader label="Qté" sortable="qte579" group="579" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} />
|
||||
<ColHeader label="CA TTC" sortable="ca579" group="579" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} />
|
||||
<th className="bg-violet-50 px-2 py-2 text-center text-xs font-semibold text-violet-500">% Marge</th>
|
||||
<ColHeader label="Stock" sortable="stock579" k="stock579" group="579" />
|
||||
<ColHeader label="Qté" sortable="qteTotal" k="qteTotal" group="total" />
|
||||
<ColHeader label="CA TTC" sortable="caTotal" k="caTotal" group="total" />
|
||||
<ColHeader label="Stock" sortable="stock579" group="579" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} />
|
||||
<ColHeader label="Qté" sortable="qteTotal" group="total" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} />
|
||||
<ColHeader label="CA TTC" sortable="caTotal" group="total" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} />
|
||||
<th className="bg-emerald-50 px-2 py-2 text-center text-xs font-semibold text-emerald-600">% Marge</th>
|
||||
<ColHeader label="Stock" sortable="stockTotal" k="stockTotal" group="total" />
|
||||
<ColHeader label="Stock" sortable="stockTotal" group="total" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sorted.map((row, idx) => (
|
||||
{sorted.map((row) => (
|
||||
<tr key={row.codein} className={`border-b border-gray-100 hover:brightness-95 transition-colors`}>
|
||||
<td className="bg-white px-2 py-2.5 font-mono text-xs text-gray-400 text-center">{row.codein}</td>
|
||||
<td className="bg-white px-2 py-2.5 text-gray-900 font-medium min-w-[200px]">{row.libelle.trim()}</td>
|
||||
|
||||
@@ -42,14 +42,17 @@ function pivotHitParade(rows: HitParadeRow[]): HitParadePivotRow[] {
|
||||
});
|
||||
}
|
||||
const entry = map.get(row.codein)!;
|
||||
if (row.site === "292") {
|
||||
entry.qte292 = row.qte_vendue;
|
||||
entry.ca292 = row.ca_ttc;
|
||||
entry.marge292 = row.marge;
|
||||
} else if (row.site === "579") {
|
||||
entry.qte579 = row.qte_vendue;
|
||||
entry.ca579 = row.ca_ttc;
|
||||
entry.marge579 = row.marge;
|
||||
// Accumulation (+=) et non affectation : si la requête renvoyait plusieurs
|
||||
// lignes pour un même (codein, site), aucune valeur ne serait écrasée.
|
||||
const site = String(row.site).trim();
|
||||
if (site === "292") {
|
||||
entry.qte292 += row.qte_vendue;
|
||||
entry.ca292 += row.ca_ttc;
|
||||
entry.marge292 += row.marge;
|
||||
} else if (site === "579") {
|
||||
entry.qte579 += row.qte_vendue;
|
||||
entry.ca579 += row.ca_ttc;
|
||||
entry.marge579 += row.marge;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,661 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
ArrowLeft, Package, Warehouse, ShoppingCart, Globe, Layers, Truck,
|
||||
RefreshCw, Loader2, CheckCircle, AlertCircle, WifiOff, LayoutGrid, Info,
|
||||
} from "lucide-react";
|
||||
import { HeatmapCell } from "@/features/grid/components/heatmap-cell";
|
||||
import { NetworkLineChart, DualLineChart, TrendSparkline } from "@/features/grid/components/network-charts";
|
||||
import { computeNetworkTrend, computeStoresSeries, trendLabel, TREND_COLOR, NB_MAGASINS_RESEAU } from "@/features/grid/lib/network-trend";
|
||||
import { formatMonthLabel, formatDate, SITE_LABELS, ffMonthToQlik } from "@/features/grid/lib/months";
|
||||
import { useQlikSyncJob } from "@/features/qlik-sync/use-qlik-sync-job";
|
||||
import { computeComparatif, indiceVerdict, normalizeMargePct } from "@/features/produits/lib/compare-reseau";
|
||||
import type { ProduitFiche } from "@/features/produits/types";
|
||||
import { OpportunitesFamille } from "./opportunites";
|
||||
|
||||
// ─── Formatteurs partagés ────────────────────────────────────────────────────
|
||||
|
||||
const fmtEur = (v: number) =>
|
||||
new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 }).format(v);
|
||||
const fmtEur2 = (v: number) =>
|
||||
new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 2 }).format(v);
|
||||
const fmtQte = (v: number) => new Intl.NumberFormat("fr-FR", { maximumFractionDigits: 0 }).format(v);
|
||||
const fmtDec = (v: number, d = 1) => new Intl.NumberFormat("fr-FR", { minimumFractionDigits: d, maximumFractionDigits: d }).format(v);
|
||||
|
||||
// ─── Briques de présentation ─────────────────────────────────────────────────
|
||||
|
||||
function Card({ title, icon: Icon, children, action }: {
|
||||
title: string;
|
||||
icon: React.ComponentType<{ className?: string; style?: React.CSSProperties }>;
|
||||
children: React.ReactNode;
|
||||
action?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="apple-card">
|
||||
<div className="apple-card-header flex items-center justify-between gap-3">
|
||||
<h2 className="flex items-center gap-2 text-[13px] font-semibold" style={{ color: "var(--text-primary)" }}>
|
||||
<Icon className="w-4 h-4" style={{ color: "var(--accent)" }} />
|
||||
{title}
|
||||
</h2>
|
||||
{action}
|
||||
</div>
|
||||
<div className="apple-card-content">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, value, mono }: { label: string; value: React.ReactNode; mono?: boolean }) {
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<div className="text-[10px] font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>
|
||||
{label}
|
||||
</div>
|
||||
<div className={`text-[13px] truncate ${mono ? "font-mono" : ""}`} style={{ color: "var(--text-primary)" }}>
|
||||
{value ?? "—"}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value, hint, color }: { label: string; value: string; hint?: string; color?: string }) {
|
||||
return (
|
||||
<div className="rounded-xl p-3" style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}>
|
||||
<div className="text-[10px] font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>
|
||||
{label}
|
||||
</div>
|
||||
<div className="text-[19px] font-black tabular-nums leading-tight mt-0.5" style={{ color: color ?? "var(--text-primary)" }}>
|
||||
{value}
|
||||
</div>
|
||||
{hint && <div className="text-[10px] mt-0.5" style={{ color: "var(--text-muted)" }}>{hint}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Détail mensuel réseau ───────────────────────────────────────────────────
|
||||
|
||||
/** Les mesures Qlik d'un mois, telles que stockées dans `metricsByMonth`. */
|
||||
type MoisReseau = { qte: number; ca?: number; nbMag?: number; caMag?: number; margePct?: number };
|
||||
|
||||
/**
|
||||
* Lignes du tableau « Détail mensuel réseau ». Deux d'entre elles sont dérivées
|
||||
* (qté/magasin, prix moyen) : le cube Qlik ne les renvoie pas, mais ce sont les
|
||||
* chiffres qu'on lit réellement pour arbitrer.
|
||||
*/
|
||||
const LIGNES_MENSUELLES_RESEAU: Array<{
|
||||
label: string;
|
||||
get: (v: MoisReseau) => number | undefined;
|
||||
fmt: (n: number) => string;
|
||||
}> = [
|
||||
{ label: "Quantité", get: (v) => v.qte, fmt: (n) => fmtQte(n) },
|
||||
{ label: "Magasins", get: (v) => v.nbMag, fmt: (n) => fmtQte(n) },
|
||||
{ label: "Qté / mag.", get: (v) => (v.nbMag && v.nbMag > 0 ? v.qte / v.nbMag : undefined), fmt: (n) => fmtDec(n) },
|
||||
{ label: "CA", get: (v) => v.ca, fmt: (n) => Math.round(n).toLocaleString("fr-FR") },
|
||||
{ label: "Prix moyen", get: (v) => (v.ca != null && v.qte > 0 ? v.ca / v.qte : undefined), fmt: (n) => fmtEur2(n) },
|
||||
{ label: "Marge %", get: (v) => normalizeMargePct(v.margePct) ?? undefined, fmt: (n) => `${fmtDec(n)} %` },
|
||||
];
|
||||
|
||||
// ─── Bouton d'extraction Qlik pour un seul code centrale ─────────────────────
|
||||
|
||||
function SyncProduitButton({ codeCentrale }: { codeCentrale: string }) {
|
||||
const router = useRouter();
|
||||
const { status, message, start } = useQlikSyncJob({
|
||||
target: { mode: "produit", codeCentrale },
|
||||
onSuccess: () => router.refresh(),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{message && (
|
||||
<span
|
||||
className="text-[10px] leading-tight text-right max-w-[280px]"
|
||||
style={{ color: status === "error" ? "var(--accent-error)" : "var(--text-muted)" }}
|
||||
title={message}
|
||||
>
|
||||
{message}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={start}
|
||||
disabled={status === "running"}
|
||||
className="btn-action btn-action-secondary flex items-center gap-1.5 disabled:opacity-60"
|
||||
title="Ré-extraire les données réseau Qlik pour ce seul produit"
|
||||
>
|
||||
{status === "running" ? <Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
: status === "success" ? <CheckCircle className="w-3.5 h-3.5 text-emerald-500" />
|
||||
: status === "error" ? <AlertCircle className="w-3.5 h-3.5 text-rose-500" />
|
||||
: <RefreshCw className="w-3.5 h-3.5" />}
|
||||
{status === "running" ? "Extraction…" : "Actualiser depuis Qlik"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Fiche ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export function ProduitFicheView({ fiche, backQuery }: { fiche: ProduitFiche; backQuery: string }) {
|
||||
const { detail, codeCentrale, libelleReseau, fournisseurReseau, fournisseurs, stock, commandesEnCours, gammes, months, reseau } = fiche;
|
||||
const [magasin, setMagasin] = useState<string>("TOTAL");
|
||||
|
||||
const sitesDisponibles = useMemo(() => {
|
||||
const sites = new Set<string>([...Object.keys(fiche.mensuelParSite), ...stock.map((s) => s.site)]);
|
||||
return [...sites].filter(Boolean).sort();
|
||||
}, [fiche.mensuelParSite, stock]);
|
||||
|
||||
// Série affichée selon le magasin sélectionné.
|
||||
const serie = magasin === "TOTAL" ? fiche.mensuelTotal : (fiche.mensuelParSite[magasin] ?? {});
|
||||
const totauxAffiches = magasin === "TOTAL" ? fiche.totaux : (fiche.totauxParSite[magasin] ?? { qte: 0, ca: 0, marge: 0, tauxMarge: 0 });
|
||||
|
||||
/** Magasins vendeurs par mois — dénominateur de la tendance ET 3e bande du graphique. */
|
||||
const nbMagByMonth = useMemo(() => {
|
||||
const detailMensuel = reseau?.metricsByMonth;
|
||||
if (!detailMensuel) return null;
|
||||
const parMois: Record<string, number> = {};
|
||||
for (const [mois, mesures] of Object.entries(detailMensuel)) {
|
||||
// Mois présent sans `nbMag` = mois extrait sans vente = zéro magasin
|
||||
// (cf. get-product-rows.ts) : sinon la série est amputée et la courbe
|
||||
// disparaît sur les anciens caches.
|
||||
const nb = Number(mesures?.nbMag);
|
||||
parMois[mois] = Number.isFinite(nb) ? nb : 0;
|
||||
}
|
||||
return parMois;
|
||||
}, [reseau]);
|
||||
const trend = useMemo(
|
||||
() => computeNetworkTrend(reseau?.qteByMonth ?? null, nbMagByMonth),
|
||||
[reseau, nbMagByMonth],
|
||||
);
|
||||
const magasinsParMois = useMemo(() => computeStoresSeries(nbMagByMonth), [nbMagByMonth]);
|
||||
const comparatif = useMemo(() => computeComparatif(fiche), [fiche]);
|
||||
const verdict = indiceVerdict(comparatif.indiceQte);
|
||||
|
||||
const fournisseurPrincipal = fournisseurs.find((f) => f.principal) ?? fournisseurs[0];
|
||||
/** Nom de fournisseur à afficher : catalogue Nancy d'abord, sinon champ Qlik. */
|
||||
const fournisseurAffiche = fournisseurPrincipal?.nomfou || fournisseurReseau || null;
|
||||
const titre = detail?.libelle1 || libelleReseau || codeCentrale || "Sans libellé";
|
||||
|
||||
// Indicateurs réseau dérivés (le cache ne stocke que les mesures brutes Qlik).
|
||||
const qteParMagasinReseau = reseau && reseau.nbMagasinsReseau > 0 ? reseau.qteReseau / reseau.nbMagasinsReseau : null;
|
||||
const prixMoyenReseau = reseau && reseau.qteReseau > 0 ? reseau.caReseau / reseau.qteReseau : null;
|
||||
const margeReseau = normalizeMargePct(reseau?.margePctReseau);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* En-tête */}
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<Link
|
||||
href={backQuery ? `/produits?q=${encodeURIComponent(backQuery)}` : "/produits"}
|
||||
className="inline-flex items-center gap-1.5 text-[12px] mb-1.5 hover:underline"
|
||||
style={{ color: "var(--text-muted)" }}
|
||||
>
|
||||
<ArrowLeft className="w-3.5 h-3.5" />
|
||||
Retour aux résultats
|
||||
</Link>
|
||||
<h2 className="text-[20px] font-semibold tracking-[-0.3px]" style={{ color: "var(--text-primary)" }}>
|
||||
{titre}
|
||||
</h2>
|
||||
<p className="text-[12px] mt-0.5" style={{ color: "var(--text-muted)" }}>
|
||||
{detail && <><span className="font-mono">{detail.codein}</span>{" · "}</>}
|
||||
{codeCentrale && <>centrale <span className="font-mono">{codeCentrale}</span></>}
|
||||
{fournisseurAffiche && <> · <span style={{ color: "var(--text-secondary)" }}>{fournisseurAffiche}</span></>}
|
||||
</p>
|
||||
</div>
|
||||
{fournisseurPrincipal?.codefou && (
|
||||
<Link
|
||||
href={`/grid?fournisseur=${encodeURIComponent(fournisseurPrincipal.codefou)}&magasin=TOTAL`}
|
||||
className="btn-action btn-action-secondary flex items-center gap-1.5"
|
||||
title="Ouvrir la grille d'arbitrage de ce fournisseur"
|
||||
>
|
||||
<LayoutGrid className="w-3.5 h-3.5" />
|
||||
Voir dans la Grille
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!detail && (
|
||||
<div
|
||||
className="rounded-xl p-4 flex items-start gap-3 text-[13px]"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}
|
||||
>
|
||||
<Info className="w-4 h-4 mt-0.5 shrink-0" style={{ color: "var(--accent)" }} />
|
||||
<div>
|
||||
Ce produit est travaillé par le réseau mais <strong>n'est pas référencé dans notre catalogue</strong>.
|
||||
<div className="text-[12px] mt-0.5" style={{ color: "var(--text-muted)" }}>
|
||||
Seules les données réseau Qlik sont disponibles : ni stock, ni ventes, ni gamme chez nous.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ─── Réseau Qlik — la donnée qui motive la recherche ─────────── */}
|
||||
<Card
|
||||
title="Performance réseau · 12 mois glissants"
|
||||
icon={Globe}
|
||||
action={codeCentrale ? <SyncProduitButton codeCentrale={codeCentrale} /> : undefined}
|
||||
>
|
||||
{!codeCentrale ? (
|
||||
<p className="text-[13px]" style={{ color: "var(--text-muted)" }}>
|
||||
Ce produit n'a pas de code centrale : il n'est pas référencé centralement et n'existe donc pas dans Qlik.
|
||||
</p>
|
||||
) : !reseau ? (
|
||||
<div className="flex items-start gap-3 rounded-xl p-4" style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}>
|
||||
<WifiOff className="w-4 h-4 mt-0.5 shrink-0" style={{ color: "var(--text-muted)" }} />
|
||||
<div className="text-[13px]" style={{ color: "var(--text-secondary)" }}>
|
||||
Aucune donnée réseau en cache pour ce produit.
|
||||
<div className="text-[12px] mt-0.5" style={{ color: "var(--text-muted)" }}>
|
||||
Lancez l'extraction ci-dessus : elle ne porte que sur ce code centrale et prend quelques secondes.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<Stat
|
||||
label="Magasins vendeurs"
|
||||
value={fmtQte(reseau.nbMagasinsReseau)}
|
||||
hint={`sur ${NB_MAGASINS_RESEAU} · ${fmtDec((reseau.nbMagasinsReseau / NB_MAGASINS_RESEAU) * 100, 0)} % du réseau`}
|
||||
/>
|
||||
<Stat label="Quantité réseau" value={fmtQte(reseau.qteReseau)} hint="12 mois glissants" />
|
||||
<Stat
|
||||
label="Qté / magasin"
|
||||
value={qteParMagasinReseau != null ? fmtDec(qteParMagasinReseau) : "—"}
|
||||
hint="par magasin qui le travaille"
|
||||
/>
|
||||
<Stat
|
||||
label="Prix moyen réseau"
|
||||
value={prixMoyenReseau != null ? fmtEur2(prixMoyenReseau) : "—"}
|
||||
hint="CA réseau / quantité"
|
||||
/>
|
||||
<Stat label="CA réseau" value={fmtEur(reseau.caReseau)} />
|
||||
<Stat label="CA / magasin" value={fmtEur(reseau.caParMagasinReseau)} />
|
||||
<Stat
|
||||
label="Marge % réseau"
|
||||
value={margeReseau != null ? `${fmtDec(margeReseau)} %` : "—"}
|
||||
color={margeReseau != null && margeReseau >= 40 ? "#22c55e" : margeReseau != null && margeReseau >= 25 ? "#f59e0b" : undefined}
|
||||
/>
|
||||
<Stat
|
||||
label="Tendance 12 m"
|
||||
value={trend.pct != null ? `${trend.pct >= 0 ? "+" : ""}${Math.round(trend.pct * 100)} %` : "—"}
|
||||
hint={trendLabel(trend.pct, trend.nouveau)}
|
||||
color={TREND_COLOR[trend.direction]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 mt-3">
|
||||
<Field label="Libellé réseau" value={libelleReseau || "—"} />
|
||||
<Field label="Fournisseur réseau" value={fournisseurReseau || "—"} />
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] mt-3" style={{ color: "var(--text-muted)" }}>
|
||||
Période {reseau.periode ?? "12 mois"} · extrait le {formatDate(reseau.fetchedAt)} ·
|
||||
{" "}mois en cours exclu (incomplet).
|
||||
</p>
|
||||
|
||||
{trend.hasData && (
|
||||
<div className="mt-3">
|
||||
<div className="text-[10px] font-semibold uppercase tracking-wide mb-1" style={{ color: "var(--text-muted)" }}>
|
||||
Tendance réseau — la qté/magasin d'abord, les volumes en contexte
|
||||
</div>
|
||||
<NetworkLineChart
|
||||
labels={trend.labels}
|
||||
values={trend.values}
|
||||
color={TREND_COLOR[trend.direction]}
|
||||
stores={magasinsParMois?.values}
|
||||
perStore={trend.perStore}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Détail mensuel complet (CA, marge, magasins) si disponible */}
|
||||
{reseau.metricsByMonth && Object.keys(reseau.metricsByMonth).length > 0 && (
|
||||
<div className="mt-4 overflow-x-auto">
|
||||
<div className="text-[10px] font-semibold uppercase tracking-wide mb-1.5" style={{ color: "var(--text-muted)" }}>
|
||||
Détail mensuel réseau
|
||||
</div>
|
||||
<table className="w-full border-collapse text-[11px]">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="px-2 py-1.5 text-left font-semibold" style={{ color: "var(--text-muted)" }}>Mesure</th>
|
||||
{months.map((m) => (
|
||||
<th key={m} className="px-1 py-1.5 text-center font-semibold whitespace-nowrap" style={{ color: "var(--text-muted)" }}>
|
||||
{formatMonthLabel(m)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{LIGNES_MENSUELLES_RESEAU.map((row) => (
|
||||
<tr key={row.label}>
|
||||
<td className="px-2 py-1 font-medium whitespace-nowrap" style={{ color: "var(--text-secondary)" }}>
|
||||
{row.label}
|
||||
</td>
|
||||
{months.map((m) => {
|
||||
const cell = reseau.metricsByMonth?.[ffMonthToQlik(m)];
|
||||
const v = cell ? row.get(cell) : undefined;
|
||||
return (
|
||||
<td key={m} className="px-1 py-1 text-center tabular-nums" style={{ color: "var(--text-muted)" }}>
|
||||
{v != null ? row.fmt(v) : "—"}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<p className="text-[10px] mt-1.5" style={{ color: "var(--text-muted)" }}>
|
||||
Les mois sans CA ni marge proviennent de la mesure « Quantité COMP », qui ne porte que la quantité.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* ─── Fournisseur ─────────────────────────────────────────────── */}
|
||||
<Card title="Fournisseur" icon={Truck}>
|
||||
{fournisseurs.length === 0 ? (
|
||||
<p className="text-[13px]" style={{ color: "var(--text-muted)" }}>
|
||||
{fournisseurReseau
|
||||
? <>Aucun fournisseur référencé chez nous. Côté réseau, Qlik indique <strong>{fournisseurReseau}</strong>.</>
|
||||
: "Aucun fournisseur référencé pour ce produit."}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{fournisseurs.map((f) => (
|
||||
<div
|
||||
key={f.codefou}
|
||||
className="rounded-xl p-3 flex flex-wrap items-center justify-between gap-3"
|
||||
style={{
|
||||
background: f.principal ? "var(--accent-bg)" : "var(--bg-elevated)",
|
||||
border: `1px solid ${f.principal ? "var(--accent-border)" : "var(--border)"}`,
|
||||
}}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="text-[14px] font-semibold" style={{ color: f.principal ? "var(--accent)" : "var(--text-primary)" }}>
|
||||
{f.nomfou}
|
||||
{f.principal && <span className="ml-2 text-[11px] font-medium">· principal</span>}
|
||||
</div>
|
||||
<div className="text-[11px] font-mono" style={{ color: "var(--text-muted)" }}>{f.codefou}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-5 text-[12px]" style={{ color: "var(--text-secondary)" }}>
|
||||
<span>Réf. <span className="font-mono">{f.reference || "—"}</span></span>
|
||||
<span>PCB <span className="tabular-nums">{f.pcb != null ? fmtQte(f.pcb) : "—"}</span></span>
|
||||
<Link
|
||||
href={`/grid?fournisseur=${encodeURIComponent(f.codefou)}&magasin=TOTAL`}
|
||||
className="hover:underline"
|
||||
style={{ color: "var(--accent)" }}
|
||||
>
|
||||
Grille
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{fournisseurReseau && (
|
||||
<p className="text-[11px]" style={{ color: "var(--text-muted)" }}>
|
||||
Fournisseur côté Qlik : {fournisseurReseau}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* ─── Identité catalogue (produits référencés uniquement) ──────── */}
|
||||
{detail && (
|
||||
<Card title="Identité" icon={Package}>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||
<Field label="Code article" value={detail.codein} mono />
|
||||
<Field label="Code centrale" value={detail.code_centrale || "—"} mono />
|
||||
<Field label="Fournisseur" value={fournisseurAffiche ?? "—"} />
|
||||
<Field label="GTIN" value={detail.gtin || "—"} mono />
|
||||
<Field label="Référence fou." value={detail.reference || "—"} mono />
|
||||
<Field label="PCB" value={detail.pcb != null ? fmtQte(detail.pcb) : "—"} />
|
||||
<Field label="Gamme actuelle" value={gammes[0]?.gamme_code ?? "—"} />
|
||||
<Field label="Secteur" value={detail.libelle1nom ?? "—"} />
|
||||
<Field label="Famille" value={detail.libelle2 ?? "—"} />
|
||||
<Field label="Sous-famille" value={detail.libelle3 ?? "—"} />
|
||||
<Field label="Prix d'achat" value={detail.pa != null ? fmtEur2(detail.pa) : "—"} />
|
||||
<Field label="PV central" value={detail.pv_central != null ? fmtEur2(detail.pv_central) : "—"} />
|
||||
<Field label="En commande" value={commandesEnCours > 0 ? fmtQte(commandesEnCours) : "—"} />
|
||||
</div>
|
||||
|
||||
{gammes.length > 1 && (
|
||||
<div className="mt-3 pt-3" style={{ borderTop: "1px solid var(--border)" }}>
|
||||
<div className="text-[10px] font-semibold uppercase tracking-wide mb-1.5" style={{ color: "var(--text-muted)" }}>
|
||||
Historique de gamme
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{gammes.slice(0, 10).map((g, i) => (
|
||||
<span
|
||||
key={`${g.saison_no_id}-${i}`}
|
||||
className="rounded-lg px-2 py-1 text-[11px]"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}
|
||||
>
|
||||
{g.saison_libelle || g.saison_code || `Saison ${g.saison_no_id}`}
|
||||
<span className="ml-1.5 font-bold" style={{ color: "var(--text-primary)" }}>{g.gamme_code}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ─── Stock ───────────────────────────────────────────────────── */}
|
||||
{detail && (
|
||||
<Card title="Stock temps réel" icon={Warehouse}>
|
||||
{stock.length === 0 ? (
|
||||
<p className="text-[13px]" style={{ color: "var(--text-muted)" }}>Aucune ligne de stock pour ce produit.</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{stock.map((s) => (
|
||||
<div key={s.site} className="rounded-xl p-3" style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[12px] font-semibold" style={{ color: "var(--text-primary)" }}>
|
||||
{SITE_LABELS[s.site]?.nom ?? s.site}
|
||||
</span>
|
||||
<span
|
||||
className="text-[16px] font-black tabular-nums"
|
||||
style={{ color: s.qte < 0 ? "var(--accent-error)" : "var(--text-primary)" }}
|
||||
>
|
||||
{fmtQte(s.qte)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 text-[11px]" style={{ color: "var(--text-secondary)" }}>
|
||||
<div>Dispo : <span className="tabular-nums">{fmtQte(s.stockdispo)}</span></div>
|
||||
<div>Valeur : <span className="tabular-nums">{fmtEur(s.valstock)}</span></div>
|
||||
<div>PRMP : <span className="tabular-nums">{s.prmp > 0 ? fmtEur2(s.prmp) : "—"}</span></div>
|
||||
<div>Dern. vente : {formatDate(s.dernierevente)}</div>
|
||||
<div className="col-span-2">Dern. réception : {formatDate(s.dernierereception)}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ─── Nos ventes, 12 mois glissants ───────────────────────────── */}
|
||||
{detail && (
|
||||
<Card
|
||||
title="Nos ventes · 12 mois glissants"
|
||||
icon={ShoppingCart}
|
||||
action={
|
||||
<div className="segment-control">
|
||||
{["TOTAL", ...sitesDisponibles].map((code) => (
|
||||
<button
|
||||
key={code}
|
||||
onClick={() => setMagasin(code)}
|
||||
className={`segment-btn ${magasin === code ? "active" : ""}`}
|
||||
>
|
||||
{code === "TOTAL" ? "Total" : (SITE_LABELS[code]?.label ?? code)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-4">
|
||||
<Stat label="Quantité vendue" value={fmtQte(totauxAffiches.qte)} />
|
||||
<Stat label="CA (TTC)" value={fmtEur(totauxAffiches.ca)} />
|
||||
<Stat label="Marge" value={fmtEur(totauxAffiches.marge)} />
|
||||
<Stat
|
||||
label="Taux de marge"
|
||||
value={`${fmtDec(totauxAffiches.tauxMarge)} %`}
|
||||
color={totauxAffiches.tauxMarge >= 40 ? "#22c55e" : totauxAffiches.tauxMarge >= 25 ? "#f59e0b" : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse text-[12px]">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="px-2 py-1.5 text-left text-[10px] font-semibold uppercase" style={{ color: "var(--text-muted)" }}>
|
||||
Mois
|
||||
</th>
|
||||
{months.map((m) => (
|
||||
<th key={m} className="px-1 py-1.5 text-center text-[10px] font-semibold whitespace-nowrap" style={{ color: "var(--text-muted)" }}>
|
||||
{formatMonthLabel(m)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="px-2 py-1 text-[11px] font-medium whitespace-nowrap" style={{ color: "var(--text-secondary)" }}>
|
||||
Ventes
|
||||
</td>
|
||||
{months.map((m) => (
|
||||
<td key={m} className="px-0.5 py-1 min-w-[46px]">
|
||||
<HeatmapCell
|
||||
value={serie[m]?.qte ?? 0}
|
||||
tooltipStock={serie[m]?.stockFinMois ?? null}
|
||||
tooltipReceptions={serie[m]?.qteRecue ?? null}
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-2 py-1 text-[11px] font-medium whitespace-nowrap" style={{ color: "var(--text-secondary)" }}>
|
||||
CA (TTC)
|
||||
</td>
|
||||
{months.map((m) => (
|
||||
<td key={m} className="px-0.5 py-1 text-center tabular-nums text-[11px]" style={{ color: "var(--text-muted)" }}>
|
||||
{serie[m]?.ca ? Math.round(serie[m].ca).toLocaleString("fr-FR") : "—"}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-2 py-1 text-[11px] font-medium whitespace-nowrap" style={{ color: "var(--text-secondary)" }}>
|
||||
Stock fin de mois
|
||||
</td>
|
||||
{months.map((m) => (
|
||||
<td key={m} className="px-0.5 py-1 text-center tabular-nums text-[11px]" style={{ color: "var(--text-muted)" }}>
|
||||
{serie[m]?.stockFinMois ? Math.round(serie[m].stockFinMois).toLocaleString("fr-FR") : "—"}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<NetworkLineChart
|
||||
labels={months.map(ffMonthToQlik)}
|
||||
values={months.map((m) => serie[m]?.qte ?? 0)}
|
||||
color="#0ea5e9"
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ─── Comparatif local vs réseau ──────────────────────────────── */}
|
||||
{detail && comparatif.hasReseau && (
|
||||
<Card title="Nous vs. un magasin moyen du réseau" icon={Globe}>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<Stat
|
||||
label="Indice de performance"
|
||||
value={comparatif.indiceQte != null ? `${Math.round(comparatif.indiceQte)} %` : "—"}
|
||||
hint={verdict.label}
|
||||
color={verdict.color}
|
||||
/>
|
||||
<Stat
|
||||
label="Nos ventes / magasin"
|
||||
value={fmtQte(comparatif.qteLocaleParMagasin)}
|
||||
hint={`${comparatif.nbMagasinsLocaux} magasin${comparatif.nbMagasinsLocaux > 1 ? "s" : ""} · 12 mois`}
|
||||
/>
|
||||
<Stat
|
||||
label="Réseau / magasin"
|
||||
value={fmtQte(comparatif.qteReseauParMagasin)}
|
||||
hint={`${comparatif.nbMagasinsReseau} magasins · 12 mois`}
|
||||
/>
|
||||
<Stat
|
||||
label="Présence réseau"
|
||||
value={comparatif.tauxPresence != null ? `${fmtDec(comparatif.tauxPresence * 100, 0)} %` : "—"}
|
||||
hint={`${comparatif.nbMagasinsReseau} / ${NB_MAGASINS_RESEAU} magasins`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 mt-3">
|
||||
<div className="rounded-xl p-3 text-[12px]" style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}>
|
||||
<div className="text-[10px] font-semibold uppercase tracking-wide mb-1" style={{ color: "var(--text-muted)" }}>
|
||||
CA par magasin · indicatif
|
||||
</div>
|
||||
<div className="flex items-center justify-between" style={{ color: "var(--text-secondary)" }}>
|
||||
<span>Nous : <span className="font-semibold tabular-nums" style={{ color: "var(--text-primary)" }}>{fmtEur(comparatif.caLocalParMagasin)}</span></span>
|
||||
<span>Réseau : <span className="font-semibold tabular-nums" style={{ color: "var(--text-primary)" }}>{comparatif.caReseauParMagasin != null ? fmtEur(comparatif.caReseauParMagasin) : "—"}</span></span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl p-3 text-[12px]" style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}>
|
||||
<div className="text-[10px] font-semibold uppercase tracking-wide mb-1" style={{ color: "var(--text-muted)" }}>
|
||||
Taux de marge · indicatif
|
||||
</div>
|
||||
<div className="flex items-center justify-between" style={{ color: "var(--text-secondary)" }}>
|
||||
<span>Nous : <span className="font-semibold tabular-nums" style={{ color: "var(--text-primary)" }}>{fmtDec(comparatif.tauxMargeLocal)} %</span></span>
|
||||
<span>Réseau : <span className="font-semibold tabular-nums" style={{ color: "var(--text-primary)" }}>{comparatif.tauxMargeReseau != null ? `${fmtDec(comparatif.tauxMargeReseau)} %` : "—"}</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] mt-2" style={{ color: "var(--text-muted)" }}>
|
||||
L'indice compare les <strong>quantités</strong> par magasin, seule base commune fiable.
|
||||
Notre CA est TTC (issu des mouvements de caisse) ; la base du CA Qlik n'est pas
|
||||
documentée — montants et marges sont donc donnés à titre indicatif.
|
||||
</p>
|
||||
|
||||
{comparatif.hasMonthly && (
|
||||
<div className="mt-4">
|
||||
<div className="text-[10px] font-semibold uppercase tracking-wide mb-1" style={{ color: "var(--text-muted)" }}>
|
||||
Quantité par magasin, mois par mois
|
||||
</div>
|
||||
<DualLineChart
|
||||
labels={comparatif.monthly.map((m) => ffMonthToQlik(m.mois))}
|
||||
series={[
|
||||
{ name: "Nous / magasin", values: comparatif.monthly.map((m) => m.local), color: "#0ea5e9" },
|
||||
{ name: "Réseau / magasin", values: comparatif.monthly.map((m) => m.reseau), color: "#f59e0b" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ─── Opportunités de la même famille ─────────────────────────── */}
|
||||
{detail?.nom_no_id != null && (
|
||||
<Card title="Opportunités · même famille" icon={Layers}>
|
||||
<OpportunitesFamille
|
||||
nomNoId={detail.nom_no_id}
|
||||
currentCodein={detail.codein}
|
||||
familleLabel={detail.libelle3 ?? detail.libelle2 ?? ""}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Sparkline discrète en pied de fiche, cohérente avec la Grille */}
|
||||
{trend.hasData && (
|
||||
<div className="flex items-center justify-end gap-2 text-[11px]" style={{ color: "var(--text-muted)" }}>
|
||||
<span>Tendance réseau :</span>
|
||||
<TrendSparkline trend={trend} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import LoadingModal from "@/components/shared/loading-modal";
|
||||
|
||||
export default function ProduitsLoading() {
|
||||
return <LoadingModal message="Chargement des données" subMessage="Recherche du produit..." />;
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Loader2, TrendingUp } from "lucide-react";
|
||||
import type { PgOpportuniteRow } from "@/lib/pg-ff-client";
|
||||
|
||||
const fmtQte = (v: number) => new Intl.NumberFormat("fr-FR", { maximumFractionDigits: 0 }).format(v);
|
||||
const fmtDec = (v: number) => new Intl.NumberFormat("fr-FR", { minimumFractionDigits: 1, maximumFractionDigits: 1 }).format(v);
|
||||
|
||||
/**
|
||||
* Produits de la même famille que le produit affiché, classés par écart de
|
||||
* performance réseau vs locale (quantité **par magasin**, pour neutraliser
|
||||
* l'écart d'échelle entre ~270 magasins et nos 2 sites).
|
||||
*
|
||||
* Chargement à la demande : la requête agrège 12 mois de mouvements pour toute
|
||||
* la famille, elle ne doit pas ralentir l'ouverture de la fiche.
|
||||
*/
|
||||
export function OpportunitesFamille({
|
||||
nomNoId,
|
||||
currentCodein,
|
||||
familleLabel,
|
||||
}: {
|
||||
nomNoId: number;
|
||||
currentCodein: string;
|
||||
familleLabel: string;
|
||||
}) {
|
||||
const [rows, setRows] = useState<PgOpportuniteRow[] | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/produits/opportunites?nomNoId=${nomNoId}`, { cache: "no-store" });
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
|
||||
setRows((data.rows ?? []) as PgOpportuniteRow[]);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (rows === null) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<p className="text-[13px]" style={{ color: "var(--text-secondary)" }}>
|
||||
Comparer ce produit aux autres références{familleLabel ? ` de « ${familleLabel} »` : " de la même famille"} :
|
||||
lesquelles le réseau vend bien et que nous vendons peu ?
|
||||
</p>
|
||||
<button onClick={load} disabled={loading} className="btn-action btn-action-secondary flex items-center gap-1.5 disabled:opacity-60">
|
||||
{loading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <TrendingUp className="w-3.5 h-3.5" />}
|
||||
{loading ? "Analyse…" : "Analyser la famille"}
|
||||
</button>
|
||||
{error && <p className="text-[12px]" style={{ color: "var(--accent-error)" }}>{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// On retire le produit courant : il est déjà détaillé au-dessus.
|
||||
const others = rows.filter((r) => r.codein !== currentCodein);
|
||||
|
||||
if (others.length === 0) {
|
||||
return (
|
||||
<p className="text-[13px]" style={{ color: "var(--text-muted)" }}>
|
||||
Aucune autre référence de cette famille n'a de données réseau en cache.
|
||||
Synchronisez d'autres fournisseurs pour enrichir la comparaison.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="overflow-x-auto rounded-xl" style={{ border: "1px solid var(--border)" }}>
|
||||
<table className="w-full border-collapse text-[12px]">
|
||||
<thead>
|
||||
<tr style={{ background: "var(--bg-elevated)" }}>
|
||||
<th className="px-3 py-2 text-left text-[10px] font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Produit</th>
|
||||
<th className="px-3 py-2 text-left text-[10px] font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Fournisseur</th>
|
||||
<th className="px-3 py-2 text-right text-[10px] font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Réseau / mag.</th>
|
||||
<th className="px-3 py-2 text-right text-[10px] font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Nous / mag.</th>
|
||||
<th className="px-3 py-2 text-right text-[10px] font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Écart</th>
|
||||
<th className="px-3 py-2 text-right text-[10px] font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Magasins</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{others.map((r) => {
|
||||
const opportunite = r.ecart_par_magasin > 0;
|
||||
return (
|
||||
<tr key={r.codein} className="transition-colors hover:bg-[var(--bg-elevated)]" style={{ borderTop: "1px solid var(--border)" }}>
|
||||
<td className="px-3 py-2">
|
||||
<Link
|
||||
href={`/produits?codein=${encodeURIComponent(r.codein)}`}
|
||||
className="font-medium hover:underline"
|
||||
style={{ color: "var(--accent)" }}
|
||||
>
|
||||
{r.libelle1 || r.codein}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-3 py-2" style={{ color: "var(--text-secondary)" }}>{r.fournisseur}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums" style={{ color: "var(--text-secondary)" }}>
|
||||
{fmtDec(r.qte_reseau_par_magasin)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums" style={{ color: "var(--text-secondary)" }}>
|
||||
{fmtDec(r.qte_locale_par_magasin)}
|
||||
</td>
|
||||
<td
|
||||
className="px-3 py-2 text-right tabular-nums font-semibold"
|
||||
style={{ color: opportunite ? "#f59e0b" : "#22c55e" }}
|
||||
title={opportunite
|
||||
? "Le réseau vend plus que nous sur ce produit"
|
||||
: "Nous vendons autant ou plus que le réseau"}
|
||||
>
|
||||
{r.ecart_par_magasin > 0 ? "+" : ""}{fmtDec(r.ecart_par_magasin)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-[11px]" style={{ color: "var(--text-muted)" }}>
|
||||
{fmtQte(r.nb_magasins_reseau)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="text-[11px]" style={{ color: "var(--text-muted)" }}>
|
||||
Écart = quantité réseau par magasin − notre quantité par magasin, sur 12 mois.
|
||||
Un écart positif signale une référence sous-exploitée chez nous.
|
||||
Périmètre : les articles <strong>de notre catalogue</strong> ayant déjà des données réseau —
|
||||
Qlik est interrogé par code article, un produit que Nancy ne référence pas ne peut pas apparaître ici.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { getProduitFiche, getProduitFicheByCodeCentrale } from "@/features/produits/api/get-produit-fiche";
|
||||
import { ProduitSearchBar } from "./search-bar";
|
||||
import { ProduitResults } from "./results";
|
||||
import { ProduitFicheView } from "./fiche";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Recherche produit — **Qlik d'abord**, catalogue FF Nancy ensuite.
|
||||
*
|
||||
* - `?q=…` → liste de résultats (chargée côté client : l'extraction Qlik
|
||||
* prend plusieurs secondes, on ne bloque pas le rendu serveur)
|
||||
* - `?codein=…` → fiche d'un produit de notre catalogue (URL partageable)
|
||||
* - `?cc=…` → fiche d'un produit du réseau par son code centrale, qu'il
|
||||
* soit référencé chez nous ou non
|
||||
*/
|
||||
export default async function ProduitsPage(props: {
|
||||
searchParams: Promise<{ q?: string; codein?: string; cc?: string }>;
|
||||
}) {
|
||||
const searchParams = await props.searchParams;
|
||||
const q = (searchParams.q ?? "").trim();
|
||||
const codein = (searchParams.codein ?? "").trim();
|
||||
const codeCentrale = (searchParams.cc ?? "").trim();
|
||||
|
||||
const fiche = codein
|
||||
? await getProduitFiche(codein)
|
||||
: codeCentrale
|
||||
? await getProduitFicheByCodeCentrale(codeCentrale)
|
||||
: null;
|
||||
const ficheDemandee = Boolean(codein || codeCentrale);
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-screen-2xl mx-auto space-y-6">
|
||||
<header className="space-y-1">
|
||||
<h1 className="text-2xl font-semibold tracking-[-0.4px]" style={{ color: "var(--text-primary)" }}>
|
||||
Recherche produit
|
||||
</h1>
|
||||
<p className="text-[13px]" style={{ color: "var(--text-muted)" }}>
|
||||
Recherche dans Qlik Sense (réseau) puis rapprochement avec notre catalogue —
|
||||
performance réseau et ventes locales sur 12 mois glissants.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* key : remonte le champ quand l'URL change (retour arrière, lien partagé) */}
|
||||
<ProduitSearchBar key={q} initialQuery={q} />
|
||||
|
||||
{ficheDemandee && !fiche && (
|
||||
<div
|
||||
className="rounded-xl p-6 text-center text-[13px]"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-muted)" }}
|
||||
>
|
||||
Aucun produit trouvé pour{" "}
|
||||
{codein
|
||||
? <>le code article <span className="font-mono">{codein}</span></>
|
||||
: <>le code centrale <span className="font-mono">{codeCentrale}</span></>}.
|
||||
{codeCentrale && !codein && (
|
||||
<div className="mt-1">
|
||||
Ce code n'est ni dans notre catalogue, ni dans le cache réseau — relancez la recherche.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fiche && <ProduitFicheView fiche={fiche} backQuery={q} />}
|
||||
|
||||
{!ficheDemandee && <ProduitResults query={q} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { PackageSearch, Loader2, RefreshCw, AlertTriangle, Database, Globe } from "lucide-react";
|
||||
import { TrendSparkline } from "@/features/grid/components/network-charts";
|
||||
import { computeNetworkTrend, NB_MAGASINS_RESEAU } from "@/features/grid/lib/network-trend";
|
||||
import type { ProduitRechercheResultat, ProduitRechercheRow } from "@/features/produits/types";
|
||||
|
||||
const fmtQte = (v: number) => new Intl.NumberFormat("fr-FR", { maximumFractionDigits: 0 }).format(v);
|
||||
const fmtDec = (v: number, d = 1) =>
|
||||
new Intl.NumberFormat("fr-FR", { minimumFractionDigits: d, maximumFractionDigits: d }).format(v);
|
||||
const fmtEur = (v: number) =>
|
||||
new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 }).format(v);
|
||||
const fmtEur2 = (v: number) =>
|
||||
new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 2 }).format(v);
|
||||
|
||||
/** Intervalle d'interrogation de l'état du job de recherche. */
|
||||
const POLL_MS = 2000;
|
||||
|
||||
/** Réponse du job de recherche, telle que la renvoie l'API. */
|
||||
interface JobRecherche {
|
||||
status: "idle" | "running" | "success" | "error";
|
||||
etape?: string;
|
||||
error?: string;
|
||||
result?: ProduitRechercheResultat;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lit une réponse HTTP en **ne présumant pas** qu'elle est du JSON.
|
||||
*
|
||||
* Un reverse proxy qui coupe la requête répond une page HTML : `res.json()`
|
||||
* échouait alors sur « Unexpected token '<' … is not valid JSON », message
|
||||
* inexploitable pour l'utilisateur. On traduit ici le statut HTTP.
|
||||
*/
|
||||
async function lireJson(res: Response): Promise<Record<string, unknown>> {
|
||||
const texte = await res.text();
|
||||
let data: Record<string, unknown> | null = null;
|
||||
try {
|
||||
data = texte ? (JSON.parse(texte) as Record<string, unknown>) : null;
|
||||
} catch {
|
||||
data = null;
|
||||
}
|
||||
if (data == null) {
|
||||
if (res.status === 504 || res.status === 408) {
|
||||
throw new Error("Le serveur a mis trop de temps à répondre (délai dépassé côté proxy). Réessayez.");
|
||||
}
|
||||
if (res.status === 502 || res.status === 503) {
|
||||
throw new Error("Application indisponible ou en cours de redémarrage. Réessayez dans un instant.");
|
||||
}
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
throw new Error("Session expirée. Rechargez la page pour vous reconnecter.");
|
||||
}
|
||||
throw new Error(`Réponse inattendue du serveur (HTTP ${res.status}).`);
|
||||
}
|
||||
if (!res.ok) throw new Error(String(data.error ?? `HTTP ${res.status}`));
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Résultats de recherche produit.
|
||||
*
|
||||
* La recherche interroge **Qlik d'abord** et peut dépasser la minute : elle est
|
||||
* lancée par un `POST` qui rend la main tout de suite, puis on interroge son
|
||||
* avancement toutes les 2 s. Une requête HTTP maintenue pendant toute
|
||||
* l'extraction se faisait couper par le reverse proxy.
|
||||
*/
|
||||
export function ProduitResults({ query }: { query: string }) {
|
||||
// `relance` mémorise la dernière demande explicite de rafraîchissement, pour
|
||||
// ne forcer le contournement du cache serveur que sur CE terme-là.
|
||||
const [relance, setRelance] = useState<{ n: number; q: string }>({ n: 0, q: "" });
|
||||
const [resultat, setResultat] = useState<{
|
||||
cle: string;
|
||||
data: ProduitRechercheResultat | null;
|
||||
error: string | null;
|
||||
etape: string | null;
|
||||
} | null>(null);
|
||||
|
||||
const force = relance.n > 0 && relance.q === query;
|
||||
const cle = `${query}|${force ? relance.n : 0}`;
|
||||
const tropCourt = query.trim().length < 3;
|
||||
|
||||
// L'état de chargement est **dérivé** (résultat pas encore aligné sur la
|
||||
// requête courante) plutôt que posé en début d'effet : appeler setState
|
||||
// synchronement dans un effet provoque un rendu en cascade.
|
||||
const enAttente = resultat?.cle !== cle || (!resultat?.data && !resultat?.error);
|
||||
const loading = !tropCourt && enAttente;
|
||||
|
||||
useEffect(() => {
|
||||
if (tropCourt) return;
|
||||
let annule = false;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const url = (methode: "POST" | "GET") =>
|
||||
`/api/produits/search?q=${encodeURIComponent(query)}${methode === "POST" && force ? "&force=1" : ""}`;
|
||||
|
||||
const appliquer = (job: JobRecherche): boolean => {
|
||||
if (annule) return true;
|
||||
if (job.status === "success" && job.result) {
|
||||
setResultat({ cle, data: job.result, error: null, etape: null });
|
||||
return true;
|
||||
}
|
||||
if (job.status === "error") {
|
||||
setResultat({ cle, data: null, error: job.error ?? "La recherche a échoué.", etape: null });
|
||||
return true;
|
||||
}
|
||||
// `idle` = le process a redémarré et a perdu le job : on relance.
|
||||
setResultat({ cle, data: null, error: null, etape: job.etape ?? null });
|
||||
return false;
|
||||
};
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
const job = (await lireJson(await fetch(url("GET"), { cache: "no-store" }))) as unknown as JobRecherche;
|
||||
if (job.status === "idle") {
|
||||
await demarrer();
|
||||
return;
|
||||
}
|
||||
if (!appliquer(job) && !annule) timer = setTimeout(() => void poll(), POLL_MS);
|
||||
} catch (e) {
|
||||
if (!annule) setResultat({ cle, data: null, error: e instanceof Error ? e.message : String(e), etape: null });
|
||||
}
|
||||
};
|
||||
|
||||
const demarrer = async () => {
|
||||
try {
|
||||
const job = (await lireJson(
|
||||
await fetch(url("POST"), { method: "POST", cache: "no-store" }),
|
||||
)) as unknown as JobRecherche;
|
||||
if (!appliquer(job) && !annule) timer = setTimeout(() => void poll(), POLL_MS);
|
||||
} catch (e) {
|
||||
if (!annule) setResultat({ cle, data: null, error: e instanceof Error ? e.message : String(e), etape: null });
|
||||
}
|
||||
};
|
||||
|
||||
void demarrer();
|
||||
return () => { annule = true; if (timer) clearTimeout(timer); };
|
||||
}, [cle, query, force, tropCourt]);
|
||||
|
||||
const relancer = () => setRelance((r) => ({ n: r.n + 1, q: query }));
|
||||
const data = resultat?.cle === cle ? resultat.data : null;
|
||||
const error = resultat?.cle === cle ? resultat.error : null;
|
||||
const etape = resultat?.cle === cle ? resultat.etape : null;
|
||||
|
||||
if (!query) {
|
||||
return (
|
||||
<div
|
||||
className="rounded-xl p-10 text-center"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}
|
||||
>
|
||||
<PackageSearch className="w-8 h-8 mx-auto mb-3" style={{ color: "var(--text-muted)" }} strokeWidth={1.5} />
|
||||
<p className="text-[14px] font-medium" style={{ color: "var(--text-secondary)" }}>
|
||||
Recherchez un produit dans le réseau
|
||||
</p>
|
||||
<p className="text-[12px] mt-1 max-w-lg mx-auto" style={{ color: "var(--text-muted)" }}>
|
||||
La recherche interroge <strong>Qlik Sense</strong> (les ~{NB_MAGASINS_RESEAU} magasins du réseau),
|
||||
puis rapproche chaque produit trouvé de notre catalogue.
|
||||
Saisissez un libellé (ex. « poêle 28 ») ou un code centrale (ex. « 10000167303 »).
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div
|
||||
className="rounded-xl p-10 text-center"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}
|
||||
>
|
||||
<Loader2 className="w-6 h-6 mx-auto mb-3 animate-spin" style={{ color: "var(--accent)" }} />
|
||||
<p className="text-[13px] font-medium" style={{ color: "var(--text-secondary)" }}>
|
||||
{etape ?? "Interrogation de Qlik Sense…"}
|
||||
</p>
|
||||
<p className="text-[12px] mt-1" style={{ color: "var(--text-muted)" }}>
|
||||
Recherche des articles du réseau puis extraction de leurs ventes sur 12 mois glissants.
|
||||
Cela peut prendre plus d'une minute — vous pouvez laisser la page ouverte.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="rounded-xl p-6" style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}>
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="w-4 h-4 mt-0.5 shrink-0" style={{ color: "var(--accent-error)" }} />
|
||||
<div className="min-w-0">
|
||||
<p className="text-[13px] font-medium" style={{ color: "var(--text-primary)" }}>
|
||||
La recherche a échoué
|
||||
</p>
|
||||
<p className="text-[12px] mt-0.5 break-words" style={{ color: "var(--text-muted)" }}>{error}</p>
|
||||
<button onClick={relancer} className="btn-action btn-action-secondary mt-3 flex items-center gap-1.5">
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
Réessayer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) return null;
|
||||
|
||||
const { rows, source, qlikError, tronque, locauxHorsReseau, dureeMs } = data;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="text-[12px] flex items-center gap-1.5" style={{ color: "var(--text-muted)" }}>
|
||||
{source === "qlik" ? (
|
||||
<Globe className="w-3.5 h-3.5" style={{ color: "var(--accent)" }} />
|
||||
) : (
|
||||
<Database className="w-3.5 h-3.5" />
|
||||
)}
|
||||
{rows.length} produit{rows.length > 1 ? "s" : ""}
|
||||
{source === "qlik" ? " trouvé(s) dans Qlik" : " trouvé(s) dans le catalogue local"}
|
||||
{tronque && " (liste tronquée — affinez la recherche)"}
|
||||
{" · "}{(dureeMs / 1000).toFixed(1)} s
|
||||
</p>
|
||||
<button onClick={relancer} className="btn-action btn-action-secondary flex items-center gap-1.5">
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
Relancer
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{source === "db" && (
|
||||
<div
|
||||
className="rounded-xl p-3 text-[12px] flex items-start gap-2"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}
|
||||
>
|
||||
<AlertTriangle className="w-3.5 h-3.5 mt-0.5 shrink-0" style={{ color: "var(--accent-error)" }} />
|
||||
<span>
|
||||
Qlik n'a pas pu être interrogé, la recherche s'est repliée sur le catalogue local
|
||||
(les chiffres réseau affichés viennent du dernier cache).
|
||||
{qlikError && <span className="block mt-0.5" style={{ color: "var(--text-muted)" }}>{qlikError}</span>}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{source === "qlik" && qlikError && (
|
||||
<div
|
||||
className="rounded-xl p-3 text-[12px] flex items-start gap-2"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}
|
||||
>
|
||||
<AlertTriangle className="w-3.5 h-3.5 mt-0.5 shrink-0" style={{ color: "var(--accent-error)" }} />
|
||||
<span>
|
||||
{rows.length === 0
|
||||
? qlikError
|
||||
: `Articles trouvés, mais l'extraction des mesures réseau a échoué : ${qlikError}`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rows.length === 0 ? (
|
||||
<div
|
||||
className="rounded-xl p-8 text-center text-[13px]"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-muted)" }}
|
||||
>
|
||||
Aucun article du réseau ne correspond à « {query} ».
|
||||
</div>
|
||||
) : (
|
||||
<ResultTable rows={rows} query={query} />
|
||||
)}
|
||||
|
||||
{locauxHorsReseau.length > 0 && (
|
||||
<details className="rounded-xl" style={{ border: "1px solid var(--border)" }}>
|
||||
<summary className="cursor-pointer px-3 py-2.5 text-[12px]" style={{ color: "var(--text-secondary)" }}>
|
||||
{locauxHorsReseau.length} produit{locauxHorsReseau.length > 1 ? "s" : ""} de notre catalogue
|
||||
sans correspondance réseau (pas de code centrale, ou non vendu par le réseau)
|
||||
</summary>
|
||||
<div className="px-3 pb-3">
|
||||
<ul className="space-y-1">
|
||||
{locauxHorsReseau.slice(0, 30).map((l) => (
|
||||
<li key={l.codein} className="text-[12px]">
|
||||
<Link
|
||||
href={`/produits?codein=${encodeURIComponent(l.codein)}&q=${encodeURIComponent(query)}`}
|
||||
className="hover:underline"
|
||||
style={{ color: "var(--accent)" }}
|
||||
>
|
||||
{l.libelle1 || l.codein}
|
||||
</Link>
|
||||
<span style={{ color: "var(--text-muted)" }}>
|
||||
{" · "}{l.fournisseur}{" · "}<span className="font-mono">{l.codein}</span>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ResultTable({ rows, query }: { rows: ProduitRechercheRow[]; query: string }) {
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-xl" style={{ border: "1px solid var(--border)" }}>
|
||||
<table className="w-full text-[13px] border-collapse">
|
||||
<thead>
|
||||
<tr style={{ background: "var(--bg-elevated)" }}>
|
||||
<Th>Libellé</Th>
|
||||
<Th>Fournisseur</Th>
|
||||
<Th>Code centrale</Th>
|
||||
<Th align="right">Magasins</Th>
|
||||
<Th align="right">Qté réseau</Th>
|
||||
<Th align="right">Qté / mag.</Th>
|
||||
<Th align="right">Prix moyen</Th>
|
||||
<Th align="right">CA / mag.</Th>
|
||||
<Th align="right">Marge %</Th>
|
||||
<Th align="center">Tendance</Th>
|
||||
<Th align="right">Nous</Th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => {
|
||||
const trend = computeNetworkTrend(r.qteByMonth, r.nbMagByMonth);
|
||||
const href = r.codein
|
||||
? `/produits?codein=${encodeURIComponent(r.codein)}&q=${encodeURIComponent(query)}`
|
||||
: `/produits?cc=${encodeURIComponent(r.codeCentrale)}&q=${encodeURIComponent(query)}`;
|
||||
return (
|
||||
<tr
|
||||
key={r.codeCentrale}
|
||||
className="transition-colors hover:bg-[var(--bg-elevated)]"
|
||||
style={{ borderTop: "1px solid var(--border)" }}
|
||||
>
|
||||
<td className="px-3 py-2 max-w-[280px]">
|
||||
<Link href={href} className="font-medium hover:underline" style={{ color: "var(--accent)" }}>
|
||||
{r.libelle || "—"}
|
||||
</Link>
|
||||
{r.nomenclature && (
|
||||
<div className="text-[11px] truncate" style={{ color: "var(--text-muted)" }}>{r.nomenclature}</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-[12px]" style={{ color: "var(--text-secondary)" }}>
|
||||
{r.fournisseur || "—"}
|
||||
</td>
|
||||
<td className="px-3 py-2 font-mono text-[12px]" style={{ color: "var(--text-muted)" }}>
|
||||
{r.codeCentrale}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums" style={{ color: "var(--text-secondary)" }}>
|
||||
{fmtQte(r.nbMagasinsReseau)}
|
||||
<span className="text-[11px] ml-1" style={{ color: "var(--text-muted)" }}>
|
||||
{fmtDec(r.tauxPresence * 100, 0)} %
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums font-semibold" style={{ color: "var(--text-primary)" }}>
|
||||
{fmtQte(r.qteReseau)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums" style={{ color: "var(--text-secondary)" }}>
|
||||
{fmtDec(r.qteParMagasinReseau)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums" style={{ color: "var(--text-secondary)" }}>
|
||||
{r.prixMoyenReseau != null ? fmtEur2(r.prixMoyenReseau) : "—"}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums" style={{ color: "var(--text-secondary)" }}>
|
||||
{r.caParMagasinReseau > 0 ? fmtEur(r.caParMagasinReseau) : "—"}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums" style={{ color: "var(--text-secondary)" }}>
|
||||
{r.margePctReseau != null ? `${fmtDec(r.margePctReseau)} %` : "—"}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-center">
|
||||
{trend.hasData ? <TrendSparkline trend={trend} /> : <span style={{ color: "var(--text-muted)" }}>—</span>}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right text-[12px]">
|
||||
{r.codein ? (
|
||||
<span style={{ color: "var(--text-secondary)" }} title={`Code article ${r.codein}`}>
|
||||
stock {fmtQte(r.stockLocal ?? 0)}
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
className="rounded-md px-1.5 py-0.5 text-[11px]"
|
||||
style={{ background: "var(--bg-elevated)", color: "var(--text-muted)", border: "1px solid var(--border)" }}
|
||||
title="Le réseau travaille ce produit, notre catalogue ne le référence pas"
|
||||
>
|
||||
non référencé
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Th({ children, align = "left" }: { children: React.ReactNode; align?: "left" | "right" | "center" }) {
|
||||
// Classes littérales : Tailwind ne génère pas les classes construites dynamiquement.
|
||||
const alignClass = align === "right" ? "text-right" : align === "center" ? "text-center" : "text-left";
|
||||
return (
|
||||
<th
|
||||
className={`px-3 py-2.5 ${alignClass} text-[11px] font-semibold uppercase tracking-wide whitespace-nowrap`}
|
||||
style={{ color: "var(--text-muted)" }}
|
||||
>
|
||||
{children}
|
||||
</th>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Search, X } from "lucide-react";
|
||||
|
||||
/**
|
||||
* Champ de recherche produit.
|
||||
*
|
||||
* Soumission **explicite** (Entrée ou bouton) et non à la frappe : chaque
|
||||
* recherche déclenche deux allers-retours Qlik Sense (recherche des articles
|
||||
* puis extraction de leurs mesures sur 12 mois), soit plusieurs secondes.
|
||||
* Minimum 3 caractères.
|
||||
*
|
||||
* L'appelant passe `key={q}` pour que le champ se resynchronise sur l'URL
|
||||
* (retour arrière, lien partagé) par remontage, plutôt que par un effet qui
|
||||
* appellerait setState en cascade.
|
||||
*/
|
||||
export function ProduitSearchBar({ initialQuery }: { initialQuery: string }) {
|
||||
const router = useRouter();
|
||||
const [term, setTerm] = useState(initialQuery);
|
||||
|
||||
const tooShort = term.trim().length > 0 && term.trim().length < 3;
|
||||
|
||||
function submit() {
|
||||
const cleaned = term.trim();
|
||||
if (cleaned.length < 3) return;
|
||||
router.push(`/produits?q=${encodeURIComponent(cleaned)}`);
|
||||
}
|
||||
|
||||
function clear() {
|
||||
setTerm("");
|
||||
router.push("/produits");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1 max-w-xl">
|
||||
<Search
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 pointer-events-none"
|
||||
style={{ color: "var(--text-muted)" }}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={term}
|
||||
onChange={(e) => setTerm(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") submit(); }}
|
||||
placeholder="Libellé produit ou code centrale…"
|
||||
aria-label="Rechercher un produit par libellé ou code centrale"
|
||||
className="apple-input h-10 text-[14px]"
|
||||
style={{ paddingLeft: "38px", paddingRight: term ? "36px" : undefined }}
|
||||
/>
|
||||
{term && (
|
||||
<button
|
||||
onClick={clear}
|
||||
aria-label="Effacer la recherche"
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 rounded-md p-1 transition-colors hover:bg-[var(--bg-elevated)]"
|
||||
style={{ color: "var(--text-muted)" }}
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={submit}
|
||||
disabled={term.trim().length < 3}
|
||||
className="btn-action btn-action-primary disabled:opacity-50"
|
||||
>
|
||||
Rechercher
|
||||
</button>
|
||||
</div>
|
||||
{tooShort && (
|
||||
<p className="text-[11px]" style={{ color: "var(--text-muted)" }}>
|
||||
Saisissez au moins 3 caractères.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,11 +5,13 @@ import { Save, Eye, EyeOff, CheckCircle, AlertCircle, Loader2, RefreshCw, Sun, M
|
||||
import { useTheme } from "next-themes";
|
||||
import { useGridStore } from "@/features/grid/store/use-grid-store";
|
||||
import { useDbSettingsStore } from "@/features/settings/store/use-db-settings-store";
|
||||
import { testDatabaseConnection, saveDatabaseSettings, getSavedDatabaseConfig } from "@/features/settings/actions";
|
||||
import { testDatabaseConnection, saveDatabaseSettings, getSavedDatabaseConfig, saveQlikSettings, testQlikConnection, saveFfApiSettings, testFfApiConnection } from "@/features/settings/actions";
|
||||
import { useEffect } from "react";
|
||||
import { UserManagement } from "@/features/admin/components/user-management";
|
||||
|
||||
interface OpenRouterModel { id: string; name: string; free: boolean; }
|
||||
import { ApiKeyManagement } from "@/features/admin/components/api-key-management";
|
||||
import { ApiConnectionInfo } from "@/features/admin/components/api-connection-info";
|
||||
import { GridWarmup } from "@/features/admin/components/grid-warmup";
|
||||
import { ServerLogs } from "@/features/settings/components/server-logs";
|
||||
|
||||
function Section({ title, subtitle, children }: { title: string; subtitle?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
@@ -35,40 +37,92 @@ function Field({ label, hint, children }: { label: string; hint?: string; childr
|
||||
);
|
||||
}
|
||||
|
||||
interface FfSyncTable { nom: string; derniereSync: string; nbLignes?: number; }
|
||||
interface FfSyncTable { nom: string; derniereSync: string; nbLignes?: number; statut?: string; erreur?: string | null; }
|
||||
interface FfSyncStatus { lastSync: string; tables: FfSyncTable[]; }
|
||||
|
||||
function FfApiStatusSection() {
|
||||
const [status, setStatus] = useState<FfSyncStatus | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [url, setUrl] = useState("");
|
||||
const [savedUrl, setSavedUrl] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testedUrl, setTestedUrl] = useState<string | null>(null);
|
||||
|
||||
const fetchStatus = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch("/api/ff-status");
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
setStatus(await res.json());
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Erreur inconnue");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
// Charge l'URL enregistrée pour la préremplir (vide = valeur par défaut).
|
||||
useEffect(() => {
|
||||
getSavedDatabaseConfig()
|
||||
.then((cfg) => {
|
||||
setSavedUrl(cfg?.ffApiBaseUrl ?? null);
|
||||
if (cfg?.ffApiBaseUrl) setUrl(cfg.ffApiBaseUrl);
|
||||
})
|
||||
.catch(() => { /* réglage optionnel : on laisse le champ vide */ });
|
||||
}, []);
|
||||
|
||||
useEffect(() => { fetchStatus(); }, [fetchStatus]);
|
||||
const runTest = useCallback((candidate?: string) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setStatus(null);
|
||||
testFfApiConnection(candidate)
|
||||
.then((res) => {
|
||||
setTestedUrl(res.url);
|
||||
if (res.success) setStatus(res.status as FfSyncStatus);
|
||||
else setError(res.error ?? "Échec inconnu");
|
||||
})
|
||||
.catch((e) => setError(e instanceof Error ? e.message : "Erreur inconnue"))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => { runTest(); }, [runTest]);
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await saveFfApiSettings(url);
|
||||
if (!res.success) { setError(res.error ?? "Enregistrement impossible"); return; }
|
||||
setSavedUrl(url.trim() || null);
|
||||
runTest(url);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Section title="API FF Nancy" subtitle="Source des données produits — synchronisation nuit depuis SQL Server (données J-1)">
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={fetchStatus} disabled={loading} className="apple-btn-secondary h-9 px-4">
|
||||
{loading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <RefreshCw className="w-3.5 h-3.5" />}
|
||||
Vérifier le statut
|
||||
<Field label="URL de l'API" hint="Laisser vide pour utiliser la valeur par défaut (https://api.ffnancy.fr)">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="https://api.ffnancy.fr"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") void save(); }}
|
||||
className="apple-input w-full"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<button onClick={save} disabled={saving} className="apple-btn h-9 px-4">
|
||||
{saving ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Save className="w-3.5 h-3.5" />}
|
||||
Enregistrer
|
||||
</button>
|
||||
{error && <span className="text-[12px] text-red-400">{error}</span>}
|
||||
<button onClick={() => runTest(url)} disabled={loading} className="apple-btn-secondary h-9 px-4">
|
||||
{loading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <RefreshCw className="w-3.5 h-3.5" />}
|
||||
Tester
|
||||
</button>
|
||||
{status && <span className="text-[12px] text-emerald-400 flex items-center gap-1"><CheckCircle className="w-3.5 h-3.5" /> Connexion OK</span>}
|
||||
{error && <span className="text-[12px] text-red-400 flex items-center gap-1"><AlertCircle className="w-3.5 h-3.5" /> {error}</span>}
|
||||
</div>
|
||||
|
||||
{/* L'URL réellement appelée : évite de croire qu'on teste celle du champ
|
||||
alors que le réglage enregistré ou la variable d'environnement prime. */}
|
||||
{testedUrl && (
|
||||
<p className="text-[11px] text-[var(--text-muted)]">
|
||||
Adresse testée : <span className="font-mono">{testedUrl}</span>
|
||||
{!savedUrl && <> — valeur par défaut, aucun réglage enregistré</>}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{status && (
|
||||
<div className="space-y-2 mt-2">
|
||||
<p className="text-[12px] text-[var(--text-secondary)]">
|
||||
@@ -82,6 +136,7 @@ function FfApiStatusSection() {
|
||||
<th className="text-left px-3 py-2 font-medium text-[var(--text-secondary)]">Table</th>
|
||||
<th className="text-right px-3 py-2 font-medium text-[var(--text-secondary)]">Sync</th>
|
||||
<th className="text-right px-3 py-2 font-medium text-[var(--text-secondary)]">Lignes</th>
|
||||
<th className="text-right px-3 py-2 font-medium text-[var(--text-secondary)]">État</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -94,6 +149,12 @@ function FfApiStatusSection() {
|
||||
<td className="px-3 py-2 text-right text-[var(--text-muted)]">
|
||||
{t.nbLignes?.toLocaleString("fr-FR") ?? "—"}
|
||||
</td>
|
||||
{/* Une table en erreur est précisément ce qu'on vient chercher ici. */}
|
||||
<td className="px-3 py-2 text-right">
|
||||
{t.statut === "ok"
|
||||
? <span className="text-emerald-400">ok</span>
|
||||
: <span className="text-red-400" title={t.erreur ?? undefined}>{t.statut ?? "—"}</span>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -106,28 +167,91 @@ function FfApiStatusSection() {
|
||||
);
|
||||
}
|
||||
|
||||
function QlikSettingsSection() {
|
||||
const [host, setHost] = useState("");
|
||||
const [user, setUser] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [showPwd, setShowPwd] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [status, setStatus] = useState<{ type: "ok" | "err"; msg: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getSavedDatabaseConfig().then((c) => {
|
||||
if (!c) return;
|
||||
setHost(c.qlikHost ?? "");
|
||||
setUser(c.qlikUser ?? "");
|
||||
setPassword(c.qlikPassword ?? "");
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
setStatus(null);
|
||||
const res = await saveQlikSettings(host.trim(), user.trim(), password);
|
||||
setSaving(false);
|
||||
setStatus(res.success ? { type: "ok", msg: "Réglages Qlik enregistrés" } : { type: "err", msg: res.error || "Erreur" });
|
||||
};
|
||||
|
||||
const handleTest = async () => {
|
||||
setTesting(true);
|
||||
setStatus(null);
|
||||
const res = await testQlikConnection(host.trim(), user.trim(), password);
|
||||
setTesting(false);
|
||||
setStatus(res.success ? { type: "ok", msg: "Connexion Qlik OK" } : { type: "err", msg: res.error || "Échec connexion" });
|
||||
};
|
||||
|
||||
return (
|
||||
<Section title="Qlik Sense — Données réseau" subtitle="Identifiants pour la synchro des données réseau (~270 magasins). Auth NTLM ; laisser l'hôte vide pour le défaut.">
|
||||
<Field label="Hôte" hint="Défaut : reporting-magasins.lafoirfouille.fr (laisser vide pour ce défaut)">
|
||||
<input type="text" placeholder="reporting-magasins.lafoirfouille.fr" value={host}
|
||||
onChange={(e) => setHost(e.target.value)} className="apple-input font-mono" />
|
||||
</Field>
|
||||
<Field label="Utilisateur" hint="Ex : FFSCH (sans domaine)">
|
||||
<input type="text" placeholder="FFSCH" value={user}
|
||||
onChange={(e) => setUser(e.target.value)} className="apple-input font-mono" />
|
||||
</Field>
|
||||
<Field label="Mot de passe">
|
||||
<div className="relative">
|
||||
<input type={showPwd ? "text" : "password"} placeholder="••••••••" value={password}
|
||||
onChange={(e) => setPassword(e.target.value)} className="apple-input font-mono pr-10" />
|
||||
<button type="button" onClick={() => setShowPwd((v) => !v)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-[var(--text-muted)]">
|
||||
{showPwd ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</Field>
|
||||
{status && (
|
||||
<div className={`flex items-center gap-1.5 text-[12px] ${status.type === "ok" ? "text-emerald-600" : "text-rose-600"}`}>
|
||||
{status.type === "ok" ? <CheckCircle className="w-4 h-4" /> : <AlertCircle className="w-4 h-4" />}
|
||||
{status.msg}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<button onClick={handleSave} disabled={saving}
|
||||
className="btn-action btn-action-primary flex items-center gap-1.5 disabled:opacity-60">
|
||||
{saving ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Save className="w-3.5 h-3.5" />}
|
||||
Enregistrer
|
||||
</button>
|
||||
<button onClick={handleTest} disabled={testing || !user || !password}
|
||||
className="btn-action btn-action-secondary flex items-center gap-1.5 disabled:opacity-60">
|
||||
{testing ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <RefreshCw className="w-3.5 h-3.5" />}
|
||||
Tester
|
||||
</button>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { resolvedTheme, setTheme } = useTheme();
|
||||
const isDark = resolvedTheme === "dark";
|
||||
const displayDensity = useGridStore((s) => s.displayDensity);
|
||||
const setDisplayDensity = useGridStore((s) => s.setDisplayDensity);
|
||||
|
||||
const [showKey, setShowKey] = useState(false);
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [dbStatus, setDbStatus] = useState<"idle" | "testing" | "ok" | "error">("idle");
|
||||
const [dbError, setDbError] = useState<string | null>(null);
|
||||
const [saveStatus, setSaveStatus] = useState<"idle" | "saving" | "saved">("idle");
|
||||
const [models, setModels] = useState<OpenRouterModel[]>([]);
|
||||
const [selectedModel, setSelectedModel] = useState("google/gemini-2.0-flash-001");
|
||||
const [modelsStatus, setModelsStatus] = useState<"idle" | "loading" | "ok" | "error">("idle");
|
||||
|
||||
// AI Provider
|
||||
const [aiProvider, setAiProvider] = useState<"openrouter" | "google">("openrouter");
|
||||
const [googleAiKey, setGoogleAiKey] = useState("");
|
||||
const [showGoogleKey, setShowGoogleKey] = useState(false);
|
||||
const [googleAiModel, setGoogleAiModel] = useState("");
|
||||
const [googleModels, setGoogleModels] = useState<{ id: string; name: string }[]>([]);
|
||||
const [googleModelsStatus, setGoogleModelsStatus] = useState<"idle" | "loading" | "ok" | "error">("idle");
|
||||
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
|
||||
@@ -141,34 +265,6 @@ export default function SettingsPage() {
|
||||
getDatabaseUrl
|
||||
} = useDbSettingsStore();
|
||||
|
||||
const fetchGoogleModels = useCallback(async (key: string) => {
|
||||
if (!key.trim()) return;
|
||||
setGoogleModelsStatus("loading");
|
||||
try {
|
||||
const res = await fetch("/api/google-ai/models", { headers: { "x-google-ai-key": key } });
|
||||
if (!res.ok) throw new Error();
|
||||
const data = await res.json();
|
||||
setGoogleModels(data.models ?? []);
|
||||
setGoogleModelsStatus("ok");
|
||||
} catch {
|
||||
setGoogleModelsStatus("error");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchModels = useCallback(async (key: string) => {
|
||||
if (!key.trim()) return;
|
||||
setModelsStatus("loading");
|
||||
try {
|
||||
const res = await fetch("/api/openrouter/models", { headers: { "x-openrouter-key": key } });
|
||||
if (!res.ok) throw new Error();
|
||||
const data = await res.json();
|
||||
setModels(data.models ?? []);
|
||||
setModelsStatus("ok");
|
||||
} catch {
|
||||
setModelsStatus("error");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const reloadFromServer = useCallback(async () => {
|
||||
const config = await getSavedDatabaseConfig();
|
||||
if (config) {
|
||||
@@ -186,23 +282,13 @@ export default function SettingsPage() {
|
||||
console.error("Failed to parse saved URL", e);
|
||||
}
|
||||
}
|
||||
if (config.openRouterKey) {
|
||||
setApiKey(config.openRouterKey);
|
||||
fetchModels(config.openRouterKey);
|
||||
}
|
||||
if (config.openRouterModel) {
|
||||
setSelectedModel(config.openRouterModel);
|
||||
}
|
||||
if (config.aiProvider) setAiProvider(config.aiProvider);
|
||||
if (config.googleAiKey) {
|
||||
setGoogleAiKey(config.googleAiKey);
|
||||
fetchGoogleModels(config.googleAiKey);
|
||||
}
|
||||
if (config.googleAiModel) setGoogleAiModel(config.googleAiModel);
|
||||
}
|
||||
}, [fetchModels, fetchGoogleModels, setApiKey, setDatabase, setHost, setPassword, setPort, setSelectedModel, setSsl, setUser]);
|
||||
}, [setDatabase, setHost, setPassword, setPort, setSsl, setUser]);
|
||||
|
||||
useEffect(() => {
|
||||
// `isMounted` sert à éviter un écart d'hydratation : il doit donc être posé
|
||||
// au montage, ce que la règle déconseille en général mais qui est ici le but.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setIsMounted(true);
|
||||
reloadFromServer();
|
||||
}, [reloadFromServer]);
|
||||
@@ -223,7 +309,7 @@ export default function SettingsPage() {
|
||||
const handleSave = async () => {
|
||||
setSaveStatus("saving");
|
||||
const url = getDatabaseUrl();
|
||||
const res = await saveDatabaseSettings(url, apiKey, selectedModel, aiProvider, googleAiKey, googleAiModel);
|
||||
const res = await saveDatabaseSettings(url);
|
||||
if (res.success) {
|
||||
setSaveStatus("saved");
|
||||
setTimeout(() => setSaveStatus("idle"), 2500);
|
||||
@@ -336,143 +422,7 @@ export default function SettingsPage() {
|
||||
{/* API FF Nancy */}
|
||||
<FfApiStatusSection />
|
||||
|
||||
{/* AI Provider selector */}
|
||||
<Section title="Admin AI Chat — Fournisseur" subtitle="Choisissez le fournisseur IA utilisé dans l'onglet Admin AI Chat">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setAiProvider("openrouter")}
|
||||
className={`flex-1 py-2.5 px-4 rounded-lg border text-[13px] font-medium transition-colors ${aiProvider === "openrouter" ? "border-[var(--accent)] bg-[var(--accent-bg)] text-[var(--accent)]" : "border-[var(--border)] text-[var(--text-secondary)] hover:bg-[var(--bg-elevated)]"}`}
|
||||
>
|
||||
OpenRouter
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setAiProvider("google")}
|
||||
className={`flex-1 py-2.5 px-4 rounded-lg border text-[13px] font-medium transition-colors ${aiProvider === "google" ? "border-[var(--accent)] bg-[var(--accent-bg)] text-[var(--accent)]" : "border-[var(--border)] text-[var(--text-secondary)] hover:bg-[var(--bg-elevated)]"}`}
|
||||
>
|
||||
Google AI Studio
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{aiProvider === "google" && (
|
||||
<>
|
||||
<Field label="Clé API Google AI Studio" hint="Disponible sur aistudio.google.com/apikey — commence par AIza...">
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<input
|
||||
type={showGoogleKey ? "text" : "password"}
|
||||
placeholder="AIzaSy..."
|
||||
value={googleAiKey}
|
||||
onChange={(e) => setGoogleAiKey(e.target.value)}
|
||||
className="apple-input font-mono pr-10"
|
||||
/>
|
||||
<button
|
||||
onClick={() => setShowGoogleKey((v) => !v)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 opacity-50 hover:opacity-100 transition-opacity"
|
||||
>
|
||||
{showGoogleKey ? <EyeOff className="w-4 h-4" style={{ color: "var(--text-primary)" }} /> : <Eye className="w-4 h-4" style={{ color: "var(--text-primary)" }} />}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => fetchGoogleModels(googleAiKey)}
|
||||
disabled={!googleAiKey || googleModelsStatus === "loading"}
|
||||
className="apple-btn-secondary h-9 px-4 whitespace-nowrap"
|
||||
>
|
||||
{googleModelsStatus === "loading" ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <RefreshCw className="w-3.5 h-3.5" />}
|
||||
Charger les modèles
|
||||
</button>
|
||||
</div>
|
||||
</Field>
|
||||
<Field
|
||||
label={`Modèle Google${googleModels.length > 0 ? ` (${googleModels.length} disponibles)` : ""}`}
|
||||
hint={googleModelsStatus === "error" ? "⚠ Erreur — vérifiez votre clé puis rechargez" : googleModelsStatus === "idle" ? "Entrez votre clé et cliquez « Charger les modèles »" : undefined}
|
||||
>
|
||||
{googleModels.length > 0 ? (
|
||||
<select
|
||||
value={googleAiModel}
|
||||
onChange={(e) => setGoogleAiModel(e.target.value)}
|
||||
className="apple-input"
|
||||
>
|
||||
{googleModels.map((m) => (
|
||||
<option key={m.id} value={m.id}>{m.name}</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
value={googleAiModel}
|
||||
onChange={(e) => setGoogleAiModel(e.target.value)}
|
||||
placeholder="ex: gemini-2.0-flash"
|
||||
className="apple-input"
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
|
||||
{aiProvider === "openrouter" && (
|
||||
<p className="text-[12px] text-[var(--text-muted)]">
|
||||
Utilise la clé OpenRouter configurée ci-dessous et le modèle sélectionné.
|
||||
</p>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* OpenRouter */}
|
||||
<Section title="IA Copilot — OpenRouter" subtitle="Clé API pour les analyses de gammes par intelligence artificielle">
|
||||
<Field label="Clé API" hint="Disponible sur openrouter.ai/keys — commence par sk-or-...">
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<input
|
||||
type={showKey ? "text" : "password"}
|
||||
placeholder="sk-or-v1-..."
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
className="apple-input font-mono pr-10"
|
||||
/>
|
||||
<button
|
||||
onClick={() => setShowKey((v) => !v)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 transition-opacity hover:opacity-100 opacity-50"
|
||||
>
|
||||
{showKey ? <EyeOff className="w-4 h-4" style={{ color: "var(--text-primary)" }} /> : <Eye className="w-4 h-4" style={{ color: "var(--text-primary)" }} />}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => fetchModels(apiKey)}
|
||||
disabled={!apiKey || modelsStatus === "loading"}
|
||||
className="apple-btn-secondary h-9 px-4 whitespace-nowrap"
|
||||
>
|
||||
{modelsStatus === "loading" ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <RefreshCw className="w-3.5 h-3.5" />}
|
||||
Charger les modèles
|
||||
</button>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={`Modèle IA${models.length > 0 ? ` (${models.length} disponibles)` : ""}`}
|
||||
hint={modelsStatus === "error" ? "⚠ Erreur — vérifiez votre clé puis rechargez" : modelsStatus === "idle" ? "Entrez votre clé et cliquez « Charger les modèles »" : undefined}
|
||||
>
|
||||
{models.length > 0 ? (
|
||||
<select
|
||||
value={selectedModel}
|
||||
onChange={(e) => setSelectedModel(e.target.value)}
|
||||
className="apple-input"
|
||||
>
|
||||
{models.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.free ? "🆓 " : ""}{m.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
value={selectedModel}
|
||||
onChange={(e) => setSelectedModel(e.target.value)}
|
||||
placeholder="ex: google/gemini-2.0-flash-001"
|
||||
className="apple-input"
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</Section>
|
||||
<QlikSettingsSection />
|
||||
|
||||
{/* Apparence */}
|
||||
<Section title="Apparence">
|
||||
@@ -509,25 +459,12 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Score Produit */}
|
||||
<Section title="Score Produit" subtitle="Formule hybride absolue + relative (v4)">
|
||||
<div className="bg-[var(--surface-tertiary)] rounded-lg p-3 space-y-2">
|
||||
<p className="text-[11px] text-[var(--text-secondary)]">
|
||||
<strong>Formule :</strong> Volume (50pts) + CA (30pts) + Marge (20pts) + Bonus relatif (±10pts)
|
||||
</p>
|
||||
<p className="text-[11px] text-[var(--text-muted)]">
|
||||
Le score est basé sur des paliers absolus sensibles au prix unitaire, avec un ajustement relatif selon la position dans le lot fournisseur.
|
||||
</p>
|
||||
<div className="border-t border-[var(--border-subtle)] pt-2 mt-2 space-y-1">
|
||||
<p className="text-[11px] text-[var(--text-secondary)]"><strong>Volume</strong> (0-50 pts) — unités / magasin / mois</p>
|
||||
<p className="text-[11px] text-[var(--text-secondary)]"><strong>CA</strong> (0-30 pts) — € / magasin / an</p>
|
||||
<p className="text-[11px] text-[var(--text-secondary)]"><strong>Marge</strong> (0-20 pts) — taux de marge %</p>
|
||||
<p className="text-[11px] text-[var(--text-secondary)]"><strong>Bonus relatif</strong> (±10 pts) — percentile composite dans le lot</p>
|
||||
</div>
|
||||
<p className="text-[11px] text-[var(--text-muted)] mt-2">
|
||||
Pénalité de régularité appliquée si inactivité prolongée. Score final borné 0-100.
|
||||
</p>
|
||||
</div>
|
||||
{/* Journaux serveur — diagnostic des extractions Qlik */}
|
||||
<Section
|
||||
title="Journaux serveur"
|
||||
subtitle="Journal complet des synchronisations Qlik, à télécharger pour diagnostic"
|
||||
>
|
||||
<ServerLogs />
|
||||
</Section>
|
||||
|
||||
{/* Gestion des Utilisateurs */}
|
||||
@@ -538,6 +475,28 @@ export default function SettingsPage() {
|
||||
<UserManagement />
|
||||
</Section>
|
||||
|
||||
{/* API publique /api/v1 — connexion puis gestion des clés */}
|
||||
<Section
|
||||
title="API CollectFlow — Connexion"
|
||||
subtitle="Tout ce qu'il faut pour appeler l'API depuis un script ou un outil externe"
|
||||
>
|
||||
<ApiConnectionInfo />
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title="Clés d'API"
|
||||
subtitle="Créer et révoquer les clés d'accès à /api/v1"
|
||||
>
|
||||
<ApiKeyManagement />
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title="Données exposées à l'API"
|
||||
subtitle="Calculer l'instantané de tous les fournisseurs pour qu'une IA externe y ait accès"
|
||||
>
|
||||
<GridWarmup />
|
||||
</Section>
|
||||
|
||||
{/* Save Button — Floating/Sticky style at bottom */}
|
||||
<div className="sticky bottom-6 flex justify-end pt-4 pb-2">
|
||||
<button
|
||||
|
||||
@@ -1,12 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useMemo } from "react";
|
||||
import { ChevronUp, ChevronDown, ChevronsUpDown, Download, Search } from "lucide-react";
|
||||
import { useState, useMemo, useEffect, useTransition, type ReactNode } from "react";
|
||||
import {
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
ChevronsUpDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronsLeft,
|
||||
ChevronsRight,
|
||||
Download,
|
||||
Search,
|
||||
} from "lucide-react";
|
||||
import LoadingModal from "@/components/shared/loading-modal";
|
||||
import type { PgStockNegatifRow, PgStockSansVenteRow, PgSansVente6MoisRow } from "./page";
|
||||
|
||||
type SortDir = "asc" | "desc";
|
||||
|
||||
/** Nombre de lignes rendues simultanément : au-delà, le navigateur fige. */
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
/** Un seul collateur réutilisé : `localeCompare` en crée un par appel (très coûteux sur 17 000 lignes). */
|
||||
const collator = new Intl.Collator("fr", { numeric: true, sensitivity: "base" });
|
||||
|
||||
function fmtDate(raw: string | null) {
|
||||
if (!raw) return "—";
|
||||
const d = new Date(raw);
|
||||
@@ -21,9 +38,66 @@ function getYear(raw: string | null): string {
|
||||
return String(d.getFullYear());
|
||||
}
|
||||
|
||||
function SortIcon<T extends string>({ col, sortKey, sortDir }: { col: T; sortKey: T; sortDir: SortDir }) {
|
||||
if (col !== sortKey) return <ChevronsUpDown className="inline w-3 h-3 ml-1 opacity-30" />;
|
||||
return sortDir === "asc"
|
||||
/**
|
||||
* Passe à `true` seulement si `active` reste vrai plus de `delay` ms.
|
||||
* Évite le clignotement du modal sur les transitions instantanées.
|
||||
*/
|
||||
function useDelayedFlag(active: boolean, delay = 120) {
|
||||
const [elapsed, setElapsed] = useState(false);
|
||||
const [wasActive, setWasActive] = useState(active);
|
||||
|
||||
// Réinitialisation pendant le rendu (et non dans un effet) : dès que
|
||||
// l'attente cesse, le prochain délai repart de zéro.
|
||||
if (wasActive !== active) {
|
||||
setWasActive(active);
|
||||
if (!active) setElapsed(false);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
const id = setTimeout(() => setElapsed(true), delay);
|
||||
return () => clearTimeout(id);
|
||||
}, [active, delay]);
|
||||
|
||||
return active && elapsed;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tableau générique (les 3 onglets partagent filtres, tri, pagination, export)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface Column<T> {
|
||||
key: Extract<keyof T, string>;
|
||||
label: string;
|
||||
/** Tri numérique au lieu du tri alphabétique. */
|
||||
numeric?: boolean;
|
||||
render?: (row: T) => ReactNode;
|
||||
}
|
||||
|
||||
interface ExcelSpec<T> {
|
||||
sheet: string;
|
||||
filePrefix: string;
|
||||
header: string[];
|
||||
widths: number[];
|
||||
row: (r: T) => (string | number)[];
|
||||
}
|
||||
|
||||
interface StockTableProps<T> {
|
||||
rows: T[];
|
||||
magasin: string;
|
||||
columns: Column<T>[];
|
||||
fournisseurOf: (r: T) => string;
|
||||
/** Source du filtre « Année » — omis, le filtre n'est pas affiché. */
|
||||
anneeOf?: (r: T) => string;
|
||||
anneeLabel?: string;
|
||||
searchIn: (r: T) => (string | null | undefined)[];
|
||||
emptyLabel: string;
|
||||
excel: ExcelSpec<T>;
|
||||
}
|
||||
|
||||
function SortIcon({ active, dir }: { active: boolean; dir: SortDir }) {
|
||||
if (!active) return <ChevronsUpDown className="inline w-3 h-3 ml-1 opacity-30" />;
|
||||
return dir === "asc"
|
||||
? <ChevronUp className="inline w-3 h-3 ml-1" />
|
||||
: <ChevronDown className="inline w-3 h-3 ml-1" />;
|
||||
}
|
||||
@@ -49,95 +123,174 @@ function FilterSelect({ label, value, onChange, options }: {
|
||||
);
|
||||
}
|
||||
|
||||
function useSortedRows<T extends Record<string, unknown>>(rows: T[], numericKeys: string[]) {
|
||||
const [sortKey, setSortKey] = useState<keyof T>(Object.keys(rows[0] ?? { codein: "" })[0] as keyof T);
|
||||
const [sortDir, setSortDir] = useState<SortDir>("asc");
|
||||
function Pagination({ page, totalPages, total, from, to, onChange }: {
|
||||
page: number;
|
||||
totalPages: number;
|
||||
total: number;
|
||||
from: number;
|
||||
to: number;
|
||||
onChange: (p: number) => void;
|
||||
}) {
|
||||
if (total === 0) return null;
|
||||
|
||||
function handleSort(key: keyof T) {
|
||||
if (sortKey === key) setSortDir(d => d === "asc" ? "desc" : "asc");
|
||||
const btn = "rounded-lg border border-gray-200 bg-white p-1.5 text-gray-600 shadow-sm transition-colors hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-40";
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 px-1">
|
||||
<span className="text-xs text-gray-500">
|
||||
Lignes <span className="font-medium text-gray-700">{from.toLocaleString("fr-FR")}</span>
|
||||
{" à "}
|
||||
<span className="font-medium text-gray-700">{to.toLocaleString("fr-FR")}</span>
|
||||
{" sur "}
|
||||
<span className="font-medium text-gray-700">{total.toLocaleString("fr-FR")}</span>
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button className={btn} onClick={() => onChange(1)} disabled={page <= 1} aria-label="Première page">
|
||||
<ChevronsLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<button className={btn} onClick={() => onChange(page - 1)} disabled={page <= 1} aria-label="Page précédente">
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<span className="px-2 text-xs text-gray-600 tabular-nums">
|
||||
Page {page.toLocaleString("fr-FR")} / {totalPages.toLocaleString("fr-FR")}
|
||||
</span>
|
||||
<button className={btn} onClick={() => onChange(page + 1)} disabled={page >= totalPages} aria-label="Page suivante">
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
<button className={btn} onClick={() => onChange(totalPages)} disabled={page >= totalPages} aria-label="Dernière page">
|
||||
<ChevronsRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StockTable<T extends object>({
|
||||
rows,
|
||||
magasin,
|
||||
columns,
|
||||
fournisseurOf,
|
||||
anneeOf,
|
||||
anneeLabel = "Année entrée",
|
||||
searchIn,
|
||||
emptyLabel,
|
||||
excel,
|
||||
}: StockTableProps<T>) {
|
||||
const [filterFournisseur, setFilterFournisseur] = useState("");
|
||||
const [filterAnnee, setFilterAnnee] = useState("");
|
||||
const [search, setSearch] = useState("");
|
||||
// `null` = ordre renvoyé par la base (le plus pertinent : stock le plus négatif,
|
||||
// entrée la plus récente…). Aucun tri client tant que l'utilisateur n'en demande pas.
|
||||
const [sortKey, setSortKey] = useState<Extract<keyof T, string> | null>(null);
|
||||
const [sortDir, setSortDir] = useState<SortDir>("asc");
|
||||
const [page, setPage] = useState(1);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
|
||||
const fournisseurs = useMemo(
|
||||
() => [...new Set(rows.map(fournisseurOf).filter(Boolean))].sort((a, b) => collator.compare(a, b)),
|
||||
[rows, fournisseurOf]
|
||||
);
|
||||
const annees = useMemo(
|
||||
() => anneeOf ? [...new Set(rows.map(anneeOf).filter(Boolean))].sort((a, b) => b.localeCompare(a)) : [],
|
||||
[rows, anneeOf]
|
||||
);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!filterFournisseur && !filterAnnee && !q) return rows;
|
||||
return rows.filter(r =>
|
||||
(!filterFournisseur || fournisseurOf(r) === filterFournisseur) &&
|
||||
(!filterAnnee || (anneeOf ? anneeOf(r) === filterAnnee : true)) &&
|
||||
(!q || searchIn(r).some(v => v?.toLowerCase().includes(q)))
|
||||
);
|
||||
}, [rows, filterFournisseur, filterAnnee, search, fournisseurOf, anneeOf, searchIn]);
|
||||
|
||||
const numericKeys = useMemo(
|
||||
() => new Set(columns.filter(c => c.numeric).map(c => c.key as string)),
|
||||
[columns]
|
||||
);
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
if (!sortKey) return filtered;
|
||||
const numeric = numericKeys.has(sortKey);
|
||||
const dir = sortDir === "asc" ? 1 : -1;
|
||||
return [...filtered].sort((a, b) => {
|
||||
const av = (a as Record<string, unknown>)[sortKey];
|
||||
const bv = (b as Record<string, unknown>)[sortKey];
|
||||
if (numeric) {
|
||||
const an = Number(av);
|
||||
const bn = Number(bv);
|
||||
if (Number.isNaN(an) && Number.isNaN(bn)) return 0;
|
||||
if (Number.isNaN(an)) return 1;
|
||||
if (Number.isNaN(bn)) return -1;
|
||||
return (an - bn) * dir;
|
||||
}
|
||||
return collator.compare(String(av ?? ""), String(bv ?? "")) * dir;
|
||||
});
|
||||
}, [filtered, sortKey, sortDir, numericKeys]);
|
||||
|
||||
// Toute modification des filtres / du tri ramène à la première page.
|
||||
// Ajustement pendant le rendu : pas d'effet, donc pas de rendu en cascade.
|
||||
const [pagedSet, setPagedSet] = useState(sorted);
|
||||
if (pagedSet !== sorted) {
|
||||
setPagedSet(sorted);
|
||||
setPage(1);
|
||||
}
|
||||
|
||||
const total = sorted.length;
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
const currentPage = Math.min(page, totalPages);
|
||||
const pageRows = useMemo(
|
||||
() => sorted.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE),
|
||||
[sorted, currentPage]
|
||||
);
|
||||
|
||||
function handleSort(key: Extract<keyof T, string>) {
|
||||
if (sortKey === key) setSortDir(d => (d === "asc" ? "desc" : "asc"));
|
||||
else { setSortKey(key); setSortDir("asc"); }
|
||||
}
|
||||
|
||||
const sorted = useMemo(() => [...rows].sort((a, b) => {
|
||||
const av = a[sortKey];
|
||||
const bv = b[sortKey];
|
||||
if (numericKeys.includes(sortKey as string)) {
|
||||
return sortDir === "asc" ? Number(av) - Number(bv) : Number(bv) - Number(av);
|
||||
const hasFilters = Boolean(filterFournisseur || filterAnnee || search);
|
||||
|
||||
async function handleExport() {
|
||||
if (exporting) return;
|
||||
setExporting(true);
|
||||
try {
|
||||
const ExcelJS = (await import("exceljs")).default;
|
||||
const wb = new ExcelJS.Workbook();
|
||||
const ws = wb.addWorksheet(excel.sheet);
|
||||
ws.addRow(excel.header);
|
||||
ws.getRow(1).eachCell(cell => {
|
||||
cell.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FF1E293B" } };
|
||||
cell.font = { color: { argb: "FFFFFFFF" }, bold: true };
|
||||
cell.alignment = { vertical: "middle", horizontal: "center" };
|
||||
});
|
||||
ws.getRow(1).height = 22;
|
||||
// L'export reprend l'intégralité des lignes filtrées, pas seulement la page affichée.
|
||||
for (const r of sorted) ws.addRow(excel.row(r));
|
||||
ws.columns = excel.widths.map(width => ({ width }));
|
||||
const buffer = await wb.xlsx.writeBuffer();
|
||||
const blob = new Blob([buffer], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `${excel.filePrefix}_${magasin || "Tous"}_${new Date().toISOString().slice(0, 10)}.xlsx`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
const as = String(av ?? "").toLowerCase();
|
||||
const bs = String(bv ?? "").toLowerCase();
|
||||
return sortDir === "asc" ? as.localeCompare(bs) : bs.localeCompare(as);
|
||||
}), [rows, sortKey, sortDir]);
|
||||
|
||||
return { sorted, sortKey, sortDir, handleSort };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Onglet 1 — Stock Négatif
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function TabStockNegatif({ rows, magasin }: { rows: PgStockNegatifRow[]; magasin: string }) {
|
||||
type SK = keyof PgStockNegatifRow;
|
||||
|
||||
const [filterFournisseur, setFilterFournisseur] = useState("");
|
||||
const [filterAnnee, setFilterAnnee] = useState("");
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const fournisseurs = useMemo(() =>
|
||||
[...new Set(rows.map(r => r.fournisseur).filter(Boolean))].sort((a, b) => a.localeCompare(b, "fr")),
|
||||
[rows]
|
||||
);
|
||||
const annees = useMemo(() =>
|
||||
[...new Set(rows.map(r => getYear(r.derniereentree)).filter(Boolean))].sort((a, b) => b.localeCompare(a)),
|
||||
[rows]
|
||||
);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
return rows.filter(r =>
|
||||
(!filterFournisseur || r.fournisseur === filterFournisseur) &&
|
||||
(!filterAnnee || getYear(r.derniereentree) === filterAnnee) &&
|
||||
(!q || r.codein?.toLowerCase().includes(q) || r.libelle1?.toLowerCase().includes(q))
|
||||
);
|
||||
}, [rows, filterFournisseur, filterAnnee, search]);
|
||||
|
||||
const { sorted, sortKey, sortDir, handleSort } = useSortedRows<PgStockNegatifRow>(
|
||||
filtered,
|
||||
["stockdispo"]
|
||||
);
|
||||
|
||||
const th = (key: SK, label: string) => (
|
||||
<th className="cursor-pointer select-none whitespace-nowrap px-3 py-2.5 text-left text-xs font-semibold text-gray-600 uppercase tracking-wide hover:text-gray-900"
|
||||
onClick={() => handleSort(key)}>
|
||||
{label}<SortIcon col={key as string} sortKey={sortKey as string} sortDir={sortDir} />
|
||||
</th>
|
||||
);
|
||||
|
||||
async function handleExport() {
|
||||
const ExcelJS = (await import("exceljs")).default;
|
||||
const wb = new ExcelJS.Workbook();
|
||||
const ws = wb.addWorksheet("Stock Négatif");
|
||||
ws.addRow(["CODEIN", "GENCODE", "QTE", "CODMV", "Dernière vente", "Dernière entrée"]);
|
||||
ws.getRow(1).eachCell(cell => {
|
||||
cell.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FF1E293B" } };
|
||||
cell.font = { color: { argb: "FFFFFFFF" }, bold: true };
|
||||
cell.alignment = { vertical: "middle", horizontal: "center" };
|
||||
});
|
||||
ws.getRow(1).height = 22;
|
||||
for (const r of sorted) ws.addRow([r.codein, "", Math.abs(r.stockdispo), "503", fmtDate(r.dernierevente), fmtDate(r.derniereentree)]);
|
||||
ws.columns = [{ width: 16 }, { width: 16 }, { width: 10 }, { width: 10 }, { width: 16 }, { width: 22 }];
|
||||
const buffer = await wb.xlsx.writeBuffer();
|
||||
const blob = new Blob([buffer], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `Stock_Negatif_${magasin || "Tous"}_${new Date().toISOString().slice(0, 10)}.xlsx`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{exporting && (
|
||||
<LoadingModal
|
||||
message="Génération du fichier Excel"
|
||||
subMessage={`${total.toLocaleString("fr-FR")} ligne${total !== 1 ? "s" : ""} en cours d'écriture…`}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="relative">
|
||||
@@ -151,8 +304,10 @@ function TabStockNegatif({ rows, magasin }: { rows: PgStockNegatifRow[]; magasin
|
||||
/>
|
||||
</div>
|
||||
<FilterSelect label="Fournisseur" value={filterFournisseur} onChange={setFilterFournisseur} options={fournisseurs} />
|
||||
<FilterSelect label="Année entrée" value={filterAnnee} onChange={setFilterAnnee} options={annees} />
|
||||
{(filterFournisseur || filterAnnee || search) && (
|
||||
{anneeOf && (
|
||||
<FilterSelect label={anneeLabel} value={filterAnnee} onChange={setFilterAnnee} options={annees} />
|
||||
)}
|
||||
{hasFilters && (
|
||||
<button
|
||||
onClick={() => { setFilterFournisseur(""); setFilterAnnee(""); setSearch(""); }}
|
||||
className="rounded-lg border border-orange-200 bg-orange-50 px-3 py-1.5 text-xs font-medium text-orange-700 hover:bg-orange-100 transition-colors"
|
||||
@@ -162,312 +317,170 @@ function TabStockNegatif({ rows, magasin }: { rows: PgStockNegatifRow[]; magasin
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-gray-500">{sorted.length} article{sorted.length !== 1 ? "s" : ""}</span>
|
||||
<button onClick={handleExport} className="flex items-center gap-2 rounded-lg bg-emerald-600 px-4 py-1.5 text-sm font-medium text-white shadow-sm hover:bg-emerald-700 transition-colors">
|
||||
<span className="text-sm text-gray-500">
|
||||
{total.toLocaleString("fr-FR")} article{total !== 1 ? "s" : ""}
|
||||
{hasFilters && total !== rows.length && (
|
||||
<span className="text-gray-400"> / {rows.length.toLocaleString("fr-FR")}</span>
|
||||
)}
|
||||
</span>
|
||||
<button
|
||||
onClick={handleExport}
|
||||
disabled={exporting || total === 0}
|
||||
className="flex items-center gap-2 rounded-lg bg-emerald-600 px-4 py-1.5 text-sm font-medium text-white shadow-sm transition-colors hover:bg-emerald-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<Download className="w-4 h-4" />Exporter Excel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto rounded-xl border border-gray-200 bg-white shadow-sm">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
{th("codein", "Code article")}
|
||||
{th("libelle1", "Libellé")}
|
||||
{th("fournisseur", "Fournisseur")}
|
||||
{th("site", "Magasin")}
|
||||
{th("stockdispo", "Stock")}
|
||||
{th("dernierevente", "Dernière vente")}
|
||||
{th("derniereentree", "Dernière entrée")}
|
||||
{columns.map(c => (
|
||||
<th
|
||||
key={c.key}
|
||||
onClick={() => handleSort(c.key)}
|
||||
className="cursor-pointer select-none whitespace-nowrap px-3 py-2.5 text-left text-xs font-semibold text-gray-600 uppercase tracking-wide hover:text-gray-900"
|
||||
>
|
||||
{c.label}
|
||||
<SortIcon active={sortKey === c.key} dir={sortDir} />
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{sorted.length === 0 && (
|
||||
<tr><td colSpan={7} className="px-3 py-8 text-center text-gray-400">Aucun article en stock négatif</td></tr>
|
||||
{pageRows.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={columns.length} className="px-3 py-8 text-center text-gray-400">{emptyLabel}</td>
|
||||
</tr>
|
||||
)}
|
||||
{sorted.map((row, i) => (
|
||||
<tr key={`${row.codein}-${row.site}-${i}`} className="hover:bg-gray-50 transition-colors">
|
||||
<td className="px-3 py-2 font-mono text-xs text-gray-700">{row.codein}</td>
|
||||
<td className="px-3 py-2 text-gray-900">{row.libelle1}</td>
|
||||
<td className="px-3 py-2 text-gray-700">{row.fournisseur}</td>
|
||||
<td className="px-3 py-2">
|
||||
<span className="rounded-full bg-blue-50 px-2 py-0.5 text-xs font-medium text-blue-700">{row.site}</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 font-semibold text-red-600">{row.stockdispo}</td>
|
||||
<td className="px-3 py-2 text-gray-600 text-xs">{fmtDate(row.dernierevente)}</td>
|
||||
<td className="px-3 py-2 text-gray-600 text-xs">{fmtDate(row.derniereentree)}</td>
|
||||
{pageRows.map((row, i) => (
|
||||
<tr key={`${String((row as Record<string, unknown>).codein)}-${String((row as Record<string, unknown>).site)}-${i}`}
|
||||
className="hover:bg-gray-50 transition-colors">
|
||||
{columns.map(c => (
|
||||
<td key={c.key} className="px-3 py-2">
|
||||
{c.render
|
||||
? c.render(row)
|
||||
: String((row as Record<string, unknown>)[c.key] ?? "")}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Pagination
|
||||
page={currentPage}
|
||||
totalPages={totalPages}
|
||||
total={total}
|
||||
from={total === 0 ? 0 : (currentPage - 1) * PAGE_SIZE + 1}
|
||||
to={Math.min(currentPage * PAGE_SIZE, total)}
|
||||
onChange={p => setPage(Math.min(Math.max(1, p), totalPages))}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Onglet 2 — Entrées sans vente
|
||||
// Cellules partagées
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function TabEntreesSansVente({ rows, magasin }: { rows: PgStockSansVenteRow[]; magasin: string }) {
|
||||
type SK = keyof PgStockSansVenteRow;
|
||||
|
||||
const [filterFournisseur, setFilterFournisseur] = useState("");
|
||||
const [filterAnnee, setFilterAnnee] = useState("");
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const baseRows = useMemo(() => rows.filter(r => r.stock_actuel !== 0), [rows]);
|
||||
|
||||
const fournisseurs = useMemo(() =>
|
||||
[...new Set(baseRows.map(r => r.fournisseur).filter(Boolean))].sort((a, b) => a.localeCompare(b, "fr")),
|
||||
[baseRows]
|
||||
);
|
||||
const annees = useMemo(() =>
|
||||
[...new Set(baseRows.map(r => getYear(r.derniere_entree)).filter(Boolean))].sort((a, b) => b.localeCompare(a)),
|
||||
[baseRows]
|
||||
);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
return baseRows.filter(r =>
|
||||
(!filterFournisseur || r.fournisseur === filterFournisseur) &&
|
||||
(!filterAnnee || getYear(r.derniere_entree) === filterAnnee) &&
|
||||
(!q || r.codein?.toLowerCase().includes(q) || r.libelle1?.toLowerCase().includes(q))
|
||||
);
|
||||
}, [baseRows, filterFournisseur, filterAnnee, search]);
|
||||
|
||||
const { sorted, sortKey, sortDir, handleSort } = useSortedRows<PgStockSansVenteRow>(
|
||||
filtered,
|
||||
["stock_actuel"]
|
||||
);
|
||||
|
||||
const th = (key: SK, label: string) => (
|
||||
<th className="cursor-pointer select-none whitespace-nowrap px-3 py-2.5 text-left text-xs font-semibold text-gray-600 uppercase tracking-wide hover:text-gray-900"
|
||||
onClick={() => handleSort(key)}>
|
||||
{label}<SortIcon col={key as string} sortKey={sortKey as string} sortDir={sortDir} />
|
||||
</th>
|
||||
);
|
||||
|
||||
async function handleExport() {
|
||||
const ExcelJS = (await import("exceljs")).default;
|
||||
const wb = new ExcelJS.Workbook();
|
||||
const ws = wb.addWorksheet("Entrées sans vente");
|
||||
ws.addRow(["CODEIN", "GENCODE", "QTE", "CODMV", "Dernière entrée"]);
|
||||
ws.getRow(1).eachCell(cell => {
|
||||
cell.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FF1E293B" } };
|
||||
cell.font = { color: { argb: "FFFFFFFF" }, bold: true };
|
||||
cell.alignment = { vertical: "middle", horizontal: "center" };
|
||||
});
|
||||
ws.getRow(1).height = 22;
|
||||
for (const r of sorted) ws.addRow([r.codein, "", r.stock_actuel, "412", fmtDate(r.derniere_entree)]);
|
||||
ws.columns = [{ width: 16 }, { width: 16 }, { width: 10 }, { width: 10 }, { width: 22 }];
|
||||
const buffer = await wb.xlsx.writeBuffer();
|
||||
const blob = new Blob([buffer], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `Entrees_Sans_Vente_${magasin || "Tous"}_${new Date().toISOString().slice(0, 10)}.xlsx`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-gray-400 pointer-events-none" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder="Code article ou libellé…"
|
||||
className="rounded-lg border border-gray-200 bg-white pl-8 pr-3 py-1.5 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 min-w-[220px]"
|
||||
/>
|
||||
</div>
|
||||
<FilterSelect label="Fournisseur" value={filterFournisseur} onChange={setFilterFournisseur} options={fournisseurs} />
|
||||
<FilterSelect label="Année entrée" value={filterAnnee} onChange={setFilterAnnee} options={annees} />
|
||||
{(filterFournisseur || filterAnnee || search) && (
|
||||
<button
|
||||
onClick={() => { setFilterFournisseur(""); setFilterAnnee(""); setSearch(""); }}
|
||||
className="rounded-lg border border-orange-200 bg-orange-50 px-3 py-1.5 text-xs font-medium text-orange-700 hover:bg-orange-100 transition-colors"
|
||||
>
|
||||
✕ Réinitialiser
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-gray-500">{sorted.length} article{sorted.length !== 1 ? "s" : ""}</span>
|
||||
<button onClick={handleExport} className="flex items-center gap-2 rounded-lg bg-emerald-600 px-4 py-1.5 text-sm font-medium text-white shadow-sm hover:bg-emerald-700 transition-colors">
|
||||
<Download className="w-4 h-4" />Exporter Excel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-x-auto rounded-xl border border-gray-200 bg-white shadow-sm">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
{th("codein", "Code article")}
|
||||
{th("libelle1", "Libellé")}
|
||||
{th("fournisseur", "Fournisseur")}
|
||||
{th("site", "Magasin")}
|
||||
{th("stock_actuel", "Stock actuel")}
|
||||
{th("derniere_entree", "Dernière entrée")}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{sorted.length === 0 && (
|
||||
<tr><td colSpan={6} className="px-3 py-8 text-center text-gray-400">Aucun article trouvé</td></tr>
|
||||
)}
|
||||
{sorted.map((row, i) => (
|
||||
<tr key={`${row.codein}-${row.site}-${i}`} className="hover:bg-gray-50 transition-colors">
|
||||
<td className="px-3 py-2 font-mono text-xs text-gray-700">{row.codein}</td>
|
||||
<td className="px-3 py-2 text-gray-900">{row.libelle1}</td>
|
||||
<td className="px-3 py-2 text-gray-700">{row.fournisseur}</td>
|
||||
<td className="px-3 py-2">
|
||||
<span className="rounded-full bg-blue-50 px-2 py-0.5 text-xs font-medium text-blue-700">{row.site}</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 font-semibold text-amber-600">{row.stock_actuel}</td>
|
||||
<td className="px-3 py-2 text-gray-600 text-xs">{fmtDate(row.derniere_entree)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const cellCode = (v: string) => <span className="font-mono text-xs text-gray-700">{v}</span>;
|
||||
const cellSite = (v: string) => (
|
||||
<span className="rounded-full bg-blue-50 px-2 py-0.5 text-xs font-medium text-blue-700">{v}</span>
|
||||
);
|
||||
const cellDate = (v: string | null) => <span className="text-gray-600 text-xs">{fmtDate(v)}</span>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Onglet 3 — Sans vente depuis 6 mois
|
||||
// Configuration des 3 onglets
|
||||
//
|
||||
// Définies au niveau module : leur identité doit rester stable d'un rendu à
|
||||
// l'autre, sinon les `useMemo` de StockTable se recalculent en boucle.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function TabSansVente6Mois({ rows, magasin }: { rows: PgSansVente6MoisRow[]; magasin: string }) {
|
||||
type SK = keyof PgSansVente6MoisRow;
|
||||
type TabConfig<T> = Omit<StockTableProps<T>, "rows" | "magasin">;
|
||||
|
||||
const [filterFournisseur, setFilterFournisseur] = useState("");
|
||||
const [search, setSearch] = useState("");
|
||||
const NEGATIF_CONFIG: TabConfig<PgStockNegatifRow> = {
|
||||
fournisseurOf: r => r.fournisseur,
|
||||
anneeOf: r => getYear(r.derniereentree),
|
||||
searchIn: r => [r.codein, r.libelle1],
|
||||
emptyLabel: "Aucun article en stock négatif",
|
||||
columns: [
|
||||
{ key: "codein", label: "Code article", render: r => cellCode(r.codein) },
|
||||
{ key: "libelle1", label: "Libellé", render: r => <span className="text-gray-900">{r.libelle1}</span> },
|
||||
{ key: "fournisseur", label: "Fournisseur", render: r => <span className="text-gray-700">{r.fournisseur}</span> },
|
||||
{ key: "site", label: "Magasin", render: r => cellSite(r.site) },
|
||||
{ key: "stockdispo", label: "Stock", numeric: true, render: r => <span className="font-semibold text-red-600">{r.stockdispo}</span> },
|
||||
{ key: "dernierevente", label: "Dernière vente", render: r => cellDate(r.dernierevente) },
|
||||
{ key: "derniereentree", label: "Dernière entrée", render: r => cellDate(r.derniereentree) },
|
||||
],
|
||||
excel: {
|
||||
sheet: "Stock Négatif",
|
||||
filePrefix: "Stock_Negatif",
|
||||
header: ["CODEIN", "GENCODE", "QTE", "CODMV", "Dernière vente", "Dernière entrée"],
|
||||
widths: [16, 16, 10, 10, 16, 22],
|
||||
row: r => [r.codein, "", Math.abs(r.stockdispo), "503", fmtDate(r.dernierevente), fmtDate(r.derniereentree)],
|
||||
},
|
||||
};
|
||||
|
||||
const fournisseurs = useMemo(() =>
|
||||
[...new Set(rows.map(r => r.fournisseur).filter(Boolean))].sort((a, b) => a.localeCompare(b, "fr")),
|
||||
[rows]
|
||||
);
|
||||
const SANS_VENTE_CONFIG: TabConfig<PgStockSansVenteRow> = {
|
||||
fournisseurOf: r => r.fournisseur,
|
||||
anneeOf: r => getYear(r.derniere_entree),
|
||||
searchIn: r => [r.codein, r.libelle1],
|
||||
emptyLabel: "Aucun article trouvé",
|
||||
columns: [
|
||||
{ key: "codein", label: "Code article", render: r => cellCode(r.codein) },
|
||||
{ key: "libelle1", label: "Libellé", render: r => <span className="text-gray-900">{r.libelle1}</span> },
|
||||
{ key: "fournisseur", label: "Fournisseur", render: r => <span className="text-gray-700">{r.fournisseur}</span> },
|
||||
{ key: "site", label: "Magasin", render: r => cellSite(r.site) },
|
||||
{ key: "stock_actuel", label: "Stock actuel", numeric: true, render: r => <span className="font-semibold text-amber-600">{r.stock_actuel}</span> },
|
||||
{ key: "derniere_entree", label: "Dernière entrée", render: r => cellDate(r.derniere_entree) },
|
||||
],
|
||||
excel: {
|
||||
sheet: "Entrées sans vente",
|
||||
filePrefix: "Entrees_Sans_Vente",
|
||||
header: ["CODEIN", "GENCODE", "QTE", "CODMV", "Dernière entrée"],
|
||||
widths: [16, 16, 10, 10, 22],
|
||||
row: r => [r.codein, "", r.stock_actuel, "412", fmtDate(r.derniere_entree)],
|
||||
},
|
||||
};
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
return rows.filter(r =>
|
||||
(!filterFournisseur || r.fournisseur === filterFournisseur) &&
|
||||
(!q || r.codein?.toLowerCase().includes(q) || r.libelle1?.toLowerCase().includes(q))
|
||||
);
|
||||
}, [rows, filterFournisseur, search]);
|
||||
|
||||
const { sorted, sortKey, sortDir, handleSort } = useSortedRows<PgSansVente6MoisRow & Record<string, unknown>>(
|
||||
filtered as (PgSansVente6MoisRow & Record<string, unknown>)[],
|
||||
["stock_actuel", "jours_sans_vente"]
|
||||
);
|
||||
|
||||
const th = (key: SK, label: string) => (
|
||||
<th className="cursor-pointer select-none whitespace-nowrap px-3 py-2.5 text-left text-xs font-semibold text-gray-600 uppercase tracking-wide hover:text-gray-900"
|
||||
onClick={() => handleSort(key as string & keyof (PgSansVente6MoisRow & Record<string, unknown>))}>
|
||||
{label}<SortIcon col={key as string} sortKey={sortKey as string} sortDir={sortDir} />
|
||||
</th>
|
||||
);
|
||||
|
||||
async function handleExport() {
|
||||
const ExcelJS = (await import("exceljs")).default;
|
||||
const wb = new ExcelJS.Workbook();
|
||||
const ws = wb.addWorksheet("Sans vente 6 mois");
|
||||
ws.addRow(["CODEIN", "GENCODE", "QTE", "CODMV", "Dernière vente"]);
|
||||
ws.getRow(1).eachCell(cell => {
|
||||
cell.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FF1E293B" } };
|
||||
cell.font = { color: { argb: "FFFFFFFF" }, bold: true };
|
||||
cell.alignment = { vertical: "middle", horizontal: "center" };
|
||||
});
|
||||
ws.getRow(1).height = 22;
|
||||
for (const r of sorted) ws.addRow([r.codein, "", r.stock_actuel, "412", fmtDate(r.derniere_vente)]);
|
||||
ws.columns = [{ width: 16 }, { width: 16 }, { width: 10 }, { width: 10 }, { width: 22 }];
|
||||
const buffer = await wb.xlsx.writeBuffer();
|
||||
const blob = new Blob([buffer], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `Sans_Vente_6Mois_${magasin || "Tous"}_${new Date().toISOString().slice(0, 10)}.xlsx`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-gray-400 pointer-events-none" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder="Code article ou libellé…"
|
||||
className="rounded-lg border border-gray-200 bg-white pl-8 pr-3 py-1.5 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 min-w-[220px]"
|
||||
/>
|
||||
</div>
|
||||
<FilterSelect label="Fournisseur" value={filterFournisseur} onChange={setFilterFournisseur} options={fournisseurs} />
|
||||
{(filterFournisseur || search) && (
|
||||
<button
|
||||
onClick={() => { setFilterFournisseur(""); setSearch(""); }}
|
||||
className="rounded-lg border border-orange-200 bg-orange-50 px-3 py-1.5 text-xs font-medium text-orange-700 hover:bg-orange-100 transition-colors"
|
||||
>
|
||||
✕ Réinitialiser
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-gray-500">{sorted.length} article{sorted.length !== 1 ? "s" : ""}</span>
|
||||
<button onClick={handleExport} className="flex items-center gap-2 rounded-lg bg-emerald-600 px-4 py-1.5 text-sm font-medium text-white shadow-sm hover:bg-emerald-700 transition-colors">
|
||||
<Download className="w-4 h-4" />Exporter Excel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-x-auto rounded-xl border border-gray-200 bg-white shadow-sm">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
{th("codein", "Code article")}
|
||||
{th("libelle1", "Libellé")}
|
||||
{th("fournisseur", "Fournisseur")}
|
||||
{th("site", "Magasin")}
|
||||
{th("stock_actuel", "Stock actuel")}
|
||||
{th("derniere_vente", "Dernière vente")}
|
||||
{th("jours_sans_vente", "Jours sans vente")}
|
||||
{th("derniere_entree", "Dernière entrée")}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{sorted.length === 0 && (
|
||||
<tr><td colSpan={8} className="px-3 py-8 text-center text-gray-400">Aucun article trouvé</td></tr>
|
||||
)}
|
||||
{sorted.map((row, i) => (
|
||||
<tr key={`${row.codein}-${row.site}-${i}`} className="hover:bg-gray-50 transition-colors">
|
||||
<td className="px-3 py-2 font-mono text-xs text-gray-700">{row.codein}</td>
|
||||
<td className="px-3 py-2 text-gray-900">{row.libelle1}</td>
|
||||
<td className="px-3 py-2 text-gray-700">{row.fournisseur}</td>
|
||||
<td className="px-3 py-2">
|
||||
<span className="rounded-full bg-blue-50 px-2 py-0.5 text-xs font-medium text-blue-700">{row.site}</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 font-semibold text-amber-600">{row.stock_actuel}</td>
|
||||
<td className="px-3 py-2 text-gray-600 text-xs">{fmtDate(row.derniere_vente)}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums font-medium"
|
||||
style={{ color: row.jours_sans_vente && row.jours_sans_vente > 365 ? "#ef4444" : "#6b7280" }}>
|
||||
{row.jours_sans_vente != null ? row.jours_sans_vente.toLocaleString("fr-FR") : "—"}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-gray-600 text-xs">{fmtDate(row.derniere_entree)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const SANS_VENTE_6MOIS_CONFIG: TabConfig<PgSansVente6MoisRow> = {
|
||||
fournisseurOf: r => r.fournisseur,
|
||||
searchIn: r => [r.codein, r.libelle1],
|
||||
emptyLabel: "Aucun article trouvé",
|
||||
columns: [
|
||||
{ key: "codein", label: "Code article", render: r => cellCode(r.codein) },
|
||||
{ key: "libelle1", label: "Libellé", render: r => <span className="text-gray-900">{r.libelle1}</span> },
|
||||
{ key: "fournisseur", label: "Fournisseur", render: r => <span className="text-gray-700">{r.fournisseur}</span> },
|
||||
{ key: "site", label: "Magasin", render: r => cellSite(r.site) },
|
||||
{ key: "stock_actuel", label: "Stock actuel", numeric: true, render: r => <span className="font-semibold text-amber-600">{r.stock_actuel}</span> },
|
||||
{ key: "derniere_vente", label: "Dernière vente", render: r => cellDate(r.derniere_vente) },
|
||||
{
|
||||
key: "jours_sans_vente",
|
||||
label: "Jours sans vente",
|
||||
numeric: true,
|
||||
render: r => (
|
||||
<span
|
||||
className="tabular-nums font-medium"
|
||||
style={{ color: r.jours_sans_vente && r.jours_sans_vente > 365 ? "#ef4444" : "#6b7280" }}
|
||||
>
|
||||
{r.jours_sans_vente != null ? r.jours_sans_vente.toLocaleString("fr-FR") : "—"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{ key: "derniere_entree", label: "Dernière entrée", render: r => cellDate(r.derniere_entree) },
|
||||
],
|
||||
excel: {
|
||||
sheet: "Sans vente 6 mois",
|
||||
filePrefix: "Sans_Vente_6Mois",
|
||||
header: ["CODEIN", "GENCODE", "QTE", "CODMV", "Dernière vente"],
|
||||
widths: [16, 16, 10, 10, 22],
|
||||
row: r => [r.codein, "", r.stock_actuel, "412", fmtDate(r.derniere_vente)],
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Composant principal avec onglets
|
||||
@@ -481,6 +494,10 @@ const TABS = [
|
||||
|
||||
type TabKey = typeof TABS[number]["key"];
|
||||
|
||||
function isTabKey(v: string): v is TabKey {
|
||||
return TABS.some(t => t.key === v);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
rowsNegatif: PgStockNegatifRow[];
|
||||
rowsSansVente: PgStockSansVenteRow[];
|
||||
@@ -492,25 +509,59 @@ interface Props {
|
||||
|
||||
export function GestionStockClient({ rowsNegatif, rowsSansVente, rowsSansVente6Mois, magasin, tab, sites }: Props) {
|
||||
const router = useRouter();
|
||||
const activeTab: TabKey = (TABS.some(t => t.key === tab) ? tab : "negatif") as TabKey;
|
||||
const initialTab: TabKey = isTabKey(tab) ? tab : "negatif";
|
||||
|
||||
const [activeTab, setActiveTab] = useState<TabKey>(initialTab);
|
||||
// Le changement d'onglet est purement client : les 3 jeux de données sont déjà chargés.
|
||||
const [switching, startSwitch] = useTransition();
|
||||
// Le changement de magasin, lui, relance les requêtes serveur.
|
||||
const [navigating, startNavigation] = useTransition();
|
||||
|
||||
// Resynchronise l'onglet si l'URL change côté serveur (retour arrière, lien direct).
|
||||
const [urlTab, setUrlTab] = useState(initialTab);
|
||||
if (urlTab !== initialTab) {
|
||||
setUrlTab(initialTab);
|
||||
setActiveTab(initialTab);
|
||||
}
|
||||
|
||||
function buildUrl(nextTab: TabKey, nextMagasin: string) {
|
||||
const params = new URLSearchParams();
|
||||
if (nextMagasin) params.set("magasin", nextMagasin);
|
||||
params.set("tab", nextTab);
|
||||
return `/stock-negatif?${params.toString()}`;
|
||||
}
|
||||
|
||||
function handleMagasinChange(e: React.ChangeEvent<HTMLSelectElement>) {
|
||||
const val = e.target.value;
|
||||
const params = new URLSearchParams();
|
||||
if (val) params.set("magasin", val);
|
||||
params.set("tab", activeTab);
|
||||
router.push(`/stock-negatif?${params.toString()}`);
|
||||
startNavigation(() => {
|
||||
router.push(buildUrl(activeTab, val));
|
||||
});
|
||||
}
|
||||
|
||||
function handleTabChange(key: TabKey) {
|
||||
const params = new URLSearchParams();
|
||||
if (magasin) params.set("magasin", magasin);
|
||||
params.set("tab", key);
|
||||
router.push(`/stock-negatif?${params.toString()}`);
|
||||
if (key === activeTab || switching) return;
|
||||
// Mise à jour de l'URL sans navigation serveur : aucune requête SQL n'est rejouée.
|
||||
window.history.replaceState(null, "", buildUrl(key, magasin));
|
||||
startSwitch(() => setActiveTab(key));
|
||||
}
|
||||
|
||||
const counts: Record<TabKey, number> = useMemo(() => ({
|
||||
"negatif": rowsNegatif.length,
|
||||
"sans-vente": rowsSansVente.length,
|
||||
"sans-vente-6mois": rowsSansVente6Mois.length,
|
||||
}), [rowsNegatif, rowsSansVente, rowsSansVente6Mois]);
|
||||
|
||||
const busy = switching || navigating;
|
||||
const showLoader = useDelayedFlag(busy);
|
||||
const loaderMessage = navigating ? "Chargement des données" : "Préparation de l'onglet";
|
||||
const loaderSubMessage = navigating
|
||||
? "Interrogation du stock pour le magasin sélectionné…"
|
||||
: "Application des filtres et du tri…";
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-4" aria-busy={busy}>
|
||||
{showLoader && <LoadingModal message={loaderMessage} subMessage={loaderSubMessage} />}
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -518,7 +569,8 @@ export function GestionStockClient({ rowsNegatif, rowsSansVente, rowsSansVente6M
|
||||
<select
|
||||
value={magasin}
|
||||
onChange={handleMagasinChange}
|
||||
className="rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
disabled={navigating}
|
||||
className="rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:cursor-wait disabled:opacity-60"
|
||||
>
|
||||
<option value="">Tous les magasins</option>
|
||||
{sites.map(s => (
|
||||
@@ -531,37 +583,45 @@ export function GestionStockClient({ rowsNegatif, rowsSansVente, rowsSansVente6M
|
||||
{/* Tabs */}
|
||||
<div className="border-b border-gray-200">
|
||||
<nav className="-mb-px flex gap-1">
|
||||
{TABS.map(t => (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => handleTabChange(t.key)}
|
||||
className={[
|
||||
"px-4 py-2.5 text-sm font-medium border-b-2 transition-colors whitespace-nowrap",
|
||||
activeTab === t.key
|
||||
? "border-blue-600 text-blue-600"
|
||||
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300",
|
||||
].join(" ")}
|
||||
>
|
||||
{t.label}
|
||||
<span className={[
|
||||
"ml-2 rounded-full px-2 py-0.5 text-xs font-semibold",
|
||||
activeTab === t.key ? "bg-blue-100 text-blue-700" : "bg-gray-100 text-gray-500",
|
||||
].join(" ")}>
|
||||
{t.key === "negatif"
|
||||
? rowsNegatif.length
|
||||
: t.key === "sans-vente"
|
||||
? rowsSansVente.filter(r => r.stock_actuel !== 0).length
|
||||
: rowsSansVente6Mois.length}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
{TABS.map(t => {
|
||||
const isActive = activeTab === t.key;
|
||||
return (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => handleTabChange(t.key)}
|
||||
disabled={busy}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
className={[
|
||||
"px-4 py-2.5 text-sm font-medium border-b-2 transition-colors whitespace-nowrap",
|
||||
isActive
|
||||
? "border-blue-600 text-blue-600"
|
||||
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300",
|
||||
busy ? "cursor-wait" : "",
|
||||
].join(" ")}
|
||||
>
|
||||
{t.label}
|
||||
<span className={[
|
||||
"ml-2 rounded-full px-2 py-0.5 text-xs font-semibold",
|
||||
isActive ? "bg-blue-100 text-blue-700" : "bg-gray-100 text-gray-500",
|
||||
].join(" ")}>
|
||||
{counts[t.key].toLocaleString("fr-FR")}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
{activeTab === "negatif" && <TabStockNegatif rows={rowsNegatif} magasin={magasin} />}
|
||||
{activeTab === "sans-vente" && <TabEntreesSansVente rows={rowsSansVente} magasin={magasin} />}
|
||||
{activeTab === "sans-vente-6mois" && <TabSansVente6Mois rows={rowsSansVente6Mois} magasin={magasin} />}
|
||||
{activeTab === "negatif" && (
|
||||
<StockTable<PgStockNegatifRow> rows={rowsNegatif} magasin={magasin} {...NEGATIF_CONFIG} />
|
||||
)}
|
||||
{activeTab === "sans-vente" && (
|
||||
<StockTable<PgStockSansVenteRow> rows={rowsSansVente} magasin={magasin} {...SANS_VENTE_CONFIG} />
|
||||
)}
|
||||
{activeTab === "sans-vente-6mois" && (
|
||||
<StockTable<PgSansVente6MoisRow> rows={rowsSansVente6Mois} magasin={magasin} {...SANS_VENTE_6MOIS_CONFIG} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,491 +0,0 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import { GoogleGenAI, Type } from "@google/genai";
|
||||
|
||||
interface AiConfig {
|
||||
provider: "openrouter" | "google";
|
||||
openRouterKey?: string;
|
||||
googleAiKey?: string;
|
||||
googleAiModel?: string;
|
||||
}
|
||||
|
||||
async function getAiConfig(): Promise<AiConfig> {
|
||||
try {
|
||||
const configFile = path.join(process.cwd(), "data", ".db-config.json");
|
||||
const data = await fs.readFile(configFile, "utf-8");
|
||||
const config = JSON.parse(data);
|
||||
return {
|
||||
provider: config.aiProvider ?? "openrouter",
|
||||
openRouterKey: config.openRouterKey ?? process.env.OPENROUTER_API_KEY,
|
||||
googleAiKey: config.googleAiKey ?? process.env.GOOGLE_AI_KEY,
|
||||
googleAiModel: config.googleAiModel ?? "gemini-2.0-flash",
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
provider: "openrouter",
|
||||
openRouterKey: process.env.OPENROUTER_API_KEY,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions";
|
||||
const FF_API_BASE = process.env.FF_API_BASE_URL ?? "https://api.ffnancy.fr";
|
||||
|
||||
// ─── Tool definitions ────────────────────────────────────────────────────────
|
||||
|
||||
const TOOLS_OPENROUTER = [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "call_api",
|
||||
description: "Appelle un endpoint de l'API FF Nancy pour récupérer des données (articles, stock, ventes, fournisseurs, commandes, etc.).",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
endpoint: {
|
||||
type: "string",
|
||||
description: "Chemin de l'endpoint, ex: /api/articles?codefou=FOU001&limit=50 ou /api/stock/article/12345",
|
||||
},
|
||||
},
|
||||
required: ["endpoint"],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const TOOLS_GOOGLE = [{
|
||||
functionDeclarations: [
|
||||
{
|
||||
name: "call_api",
|
||||
description: "Appelle un endpoint de l'API FF Nancy pour récupérer des données (articles, stock, ventes, fournisseurs, commandes, etc.).",
|
||||
parameters: {
|
||||
type: Type.OBJECT,
|
||||
properties: {
|
||||
endpoint: {
|
||||
type: Type.STRING,
|
||||
description: "Chemin de l'endpoint, ex: /api/articles?codefou=FOU001&limit=50 ou /api/stock/article/12345",
|
||||
},
|
||||
},
|
||||
required: ["endpoint"],
|
||||
},
|
||||
},
|
||||
],
|
||||
}];
|
||||
|
||||
// ─── API caller ───────────────────────────────────────────────────────────────
|
||||
|
||||
async function callFfApi(endpoint: string): Promise<string> {
|
||||
// Sécurité : uniquement GET, uniquement les endpoints documentés
|
||||
const allowedPrefixes = [
|
||||
"/api/health", "/api/sync", "/api/schema",
|
||||
"/api/articles", "/api/stock", "/api/fournisseurs",
|
||||
"/api/commandes", "/api/mouvements", "/api/performance",
|
||||
"/api/publicites", "/api/commandes-auto",
|
||||
];
|
||||
const isAllowed = allowedPrefixes.some(p => endpoint.startsWith(p));
|
||||
if (!isAllowed) {
|
||||
return `Erreur: endpoint non autorisé — "${endpoint}". Utilise uniquement les endpoints documentés.`;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = `${FF_API_BASE}${endpoint}`;
|
||||
const res = await fetch(url, {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return `Erreur API ${res.status}: ${await res.text()}`;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const text = JSON.stringify(data);
|
||||
|
||||
// Tronquer si trop long (max 6000 chars)
|
||||
if (text.length > 6000) {
|
||||
return text.substring(0, 6000) + `\n...(tronqué, ${text.length} caractères total)`;
|
||||
}
|
||||
return text;
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Erreur inconnue";
|
||||
return `Erreur réseau: ${msg}`;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── System prompt ────────────────────────────────────────────────────────────
|
||||
|
||||
const SYSTEM_PROMPT = `Tu es un assistant IA administrateur expert pour l'application CollectFlow, un outil de gestion de gammes produits et d'analyse des ventes pour des magasins de distribution alimentaire (Foirfouille Nancy).
|
||||
|
||||
## Outil disponible
|
||||
- \`call_api\` : appelle un endpoint GET de l'API FF Nancy et retourne les données JSON
|
||||
|
||||
## API FF Nancy — Documentation complète
|
||||
Base URL : ${FF_API_BASE}
|
||||
Format des dates : YYYY-MM-DD
|
||||
Identifiants articles : toujours \`no_id\` (entier interne)
|
||||
Codes site : ex 000, 001, 002…
|
||||
|
||||
### Santé / Infra
|
||||
- GET /api/health — vérifie que l'API et PostgreSQL répondent
|
||||
- GET /api/sync/status — état de la synchronisation en cours
|
||||
- GET /api/schema/tables?search=nom — liste les tables PG
|
||||
- GET /api/schema/tables/:nom — colonnes d'une table
|
||||
- GET /api/schema/top?limit=10 — tables les plus peuplées
|
||||
|
||||
### Articles
|
||||
- GET /api/articles?search=&codein=&ean=&codefou=&actif=1&page=1&limit=50 — liste avec PA, GTIN, fournisseur principal (max 500/page)
|
||||
- GET /api/articles/:id — détail complet d'un article (no_id)
|
||||
- GET /api/articles/:id/referentiel — fournisseurs, tarifs, codes EAN
|
||||
- GET /api/articles/:id/mouvements — historique des mouvements
|
||||
- GET /api/articles/:id/mensuel — CA mensuel de l'article
|
||||
|
||||
Le champ **prix d'achat (PA)** est retourné directement dans GET /api/articles (champ \`pa\` ou \`prix_achat\`).
|
||||
Pour un article précis : GET /api/articles/:id/referentiel pour voir tous les tarifs fournisseurs.
|
||||
|
||||
### Stock
|
||||
- GET /api/stock?site=001&page=1&limit=100 — stock global tous articles (max 1000/page)
|
||||
- GET /api/stock/article/:id — stock par site pour un article (no_id) → contient aussi \`prmp\` (Prix de Revient Moyen Pondéré)
|
||||
- GET /api/stock/site/:site — tous les articles d'un site donné
|
||||
- GET /api/stock/article/:id/historique — historique stock de l'article
|
||||
- GET /api/stock/article/:id/periode?dateDebut=&dateFin= — stock sur période
|
||||
- GET /api/stock/valorisation — valeur totale du stock par site
|
||||
|
||||
Le **PRMP** (prix réel moyen pondéré) est dans GET /api/stock/article/:id, champ \`prmp\` par site.
|
||||
|
||||
### Fournisseurs
|
||||
- GET /api/fournisseurs?search=nom — liste des fournisseurs (champs : code, nom)
|
||||
- GET /api/fournisseurs/:code/articles — articles du fournisseur
|
||||
- GET /api/fournisseurs/:code/commandes — commandes du fournisseur
|
||||
|
||||
### Commandes
|
||||
- GET /api/commandes?dateDebut=&dateFin=&codefou= — liste des commandes fournisseurs
|
||||
- GET /api/commandes/:noCommande — détail d'une commande + ses lignes
|
||||
- GET /api/commandes/receptions/liste — liste des réceptions
|
||||
|
||||
### Commandes automatiques
|
||||
- GET /api/commandes-auto?site=&codefou= — résumé des propositions par fournisseur/site avec comparaison franco
|
||||
Champs : franco_ht, montant_propo_ht, franco_atteint (OUI/NON/N/A), ecart_franco
|
||||
- GET /api/commandes-auto/:codefou?site= — détail d'un fournisseur : résumé + liste des articles proposés avec stock et montant
|
||||
|
||||
### Mouvements
|
||||
- GET /api/mouvements/types — types de mouvements disponibles
|
||||
- GET /api/mouvements/articles?dateDebut=&dateFin=&site= — mouvements par article
|
||||
- GET /api/mouvements/entrees?dateDebut=&dateFin= — entrées marchandises
|
||||
- GET /api/mouvements/regularisations?dateDebut=&dateFin= — régularisations inventaire
|
||||
- GET /api/mouvements/reglements?dateDebut=&dateFin= — règlements
|
||||
- GET /api/mouvements/synthese?dateDebut=&dateFin=&site= — synthèse des mouvements
|
||||
|
||||
### Performance / CA
|
||||
- GET /api/performance/ca?dateDebut=&dateFin=&site=&groupBy=mois — CA global (groupBy: jour ou mois)
|
||||
- GET /api/performance/hitparade?dateDebut=&dateFin=&site=&limit=20&groupBy=ca — top articles (groupBy: qte, ca ou marge)
|
||||
- GET /api/performance/ca/nomenclature?dateDebut=&dateFin=&site=&niveau= — CA par nomenclature
|
||||
- GET /api/performance/ca/fournisseur?dateDebut=&dateFin=&site= — CA par fournisseur
|
||||
- GET /api/performance/ca/gamme?dateDebut=&dateFin=&site= — CA par gamme (A/B/C/D/Z)
|
||||
- GET /api/performance/dashboard — indicateurs résumés pour dashboard
|
||||
|
||||
### Publicités
|
||||
- GET /api/publicites?search=&site=&dateDebut=&dateFin=&page=1&limit=50 — liste des publicités
|
||||
- GET /api/publicites/:code?site= — détail d'une publicité
|
||||
|
||||
## Contexte métier CollectFlow
|
||||
- Application de gestion de gammes pour magasins Foirfouille Nancy
|
||||
- Gammes A/B/C/D/Z : A=pilier, B=bonne rotation, C=performance, D=saisonnier/niche, Z=sortie
|
||||
- Les ventes sont suivies par magasin (site) et par mois
|
||||
- Les utilisateurs arbitrent les classifications gamme par fournisseur
|
||||
|
||||
## Stratégie d'analyse
|
||||
1. Si tu ne connais pas un code fournisseur ou un no_id, commence par chercher avec /api/fournisseurs?search= ou /api/articles?search=
|
||||
2. Enchaîne plusieurs appels \`call_api\` si nécessaire pour compléter l'analyse
|
||||
3. Pour le prix d'achat : utilise /api/articles?codefou=CODE (champ pa) ou /api/articles/:id/referentiel
|
||||
4. Pour le PRMP (prix réel) : utilise /api/stock/article/:id (champ prmp par site)
|
||||
5. Ajoute toujours \`limit\` pour éviter des réponses trop volumineuses (limit=20 à 100 selon le contexte)
|
||||
|
||||
## Format de réponse — IMPORTANT
|
||||
- Réponds TOUJOURS en **Markdown** : titres ##, **gras**, tableaux \`| col | col |\`, listes \`-\`, blocs de code
|
||||
- N'utilise JAMAIS de HTML (<div>, <table>, <b>, <br>, etc.)
|
||||
- Réponds toujours en français
|
||||
- Sois précis, structuré et complet — ne tronque jamais ta réponse
|
||||
- Si les données sont nombreuses, résume et mets en avant les points clés`;
|
||||
|
||||
// ─── OpenRouter handler ───────────────────────────────────────────────────────
|
||||
|
||||
async function callOpenRouter(
|
||||
messages: object[],
|
||||
model: string,
|
||||
apiKey: string,
|
||||
useTools: boolean,
|
||||
stream: boolean
|
||||
): Promise<Response> {
|
||||
const body: Record<string, unknown> = {
|
||||
model,
|
||||
messages,
|
||||
temperature: 0.3,
|
||||
max_tokens: 8192,
|
||||
};
|
||||
if (useTools) {
|
||||
body.tools = TOOLS_OPENROUTER;
|
||||
body.tool_choice = "auto";
|
||||
}
|
||||
if (stream) {
|
||||
body.stream = true;
|
||||
body.stream_options = { include_usage: true };
|
||||
}
|
||||
return fetch(OPENROUTER_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
"HTTP-Referer": "https://collectflow.app",
|
||||
"X-Title": "CollectFlow Admin Chat",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Google AI handler ────────────────────────────────────────────────────────
|
||||
|
||||
async function handleGoogleAi(messages: Array<{ role: string; content: string }>, config: AiConfig): Promise<Response> {
|
||||
if (!config.googleAiKey) {
|
||||
return new Response(JSON.stringify({ error: "No Google AI key configured" }), { status: 500 });
|
||||
}
|
||||
|
||||
const model = config.googleAiModel ?? "gemini-2.0-flash";
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
function send(event: object) {
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
|
||||
}
|
||||
|
||||
try {
|
||||
const ai = new GoogleGenAI({ apiKey: config.googleAiKey! });
|
||||
|
||||
const contents: Array<{ role: string; parts: any[] }> = [
|
||||
...messages.map((m) => ({
|
||||
role: m.role === "assistant" ? "model" : "user",
|
||||
parts: [{ text: m.content }],
|
||||
})),
|
||||
];
|
||||
|
||||
for (let round = 0; round < 10; round++) {
|
||||
const streamResponse = await ai.models.generateContentStream({
|
||||
model,
|
||||
contents: contents as any,
|
||||
config: {
|
||||
systemInstruction: SYSTEM_PROMPT,
|
||||
tools: TOOLS_GOOGLE as any,
|
||||
maxOutputTokens: 8192,
|
||||
},
|
||||
});
|
||||
|
||||
let collectedText = "";
|
||||
const collectedParts: any[] = [];
|
||||
|
||||
for await (const chunk of streamResponse) {
|
||||
const text = chunk.text;
|
||||
if (text) {
|
||||
collectedText += text;
|
||||
send({ type: "text", content: text });
|
||||
}
|
||||
const parts = chunk.candidates?.[0]?.content?.parts;
|
||||
if (parts) collectedParts.push(...parts);
|
||||
}
|
||||
|
||||
const fnCallParts = collectedParts.filter((p: any) => p.functionCall);
|
||||
|
||||
if (fnCallParts.length === 0) break;
|
||||
|
||||
contents.push({ role: "model", parts: collectedText ? collectedParts : fnCallParts });
|
||||
|
||||
const toolResultParts: Array<{ functionResponse: { name: string; response: { output: string } } }> = [];
|
||||
for (const part of fnCallParts) {
|
||||
const fn = part.functionCall as { name: string; args: Record<string, unknown> };
|
||||
const args = fn.args ?? {};
|
||||
send({ type: "tool_start", name: fn.name, args });
|
||||
|
||||
let result = "";
|
||||
if (fn.name === "call_api") {
|
||||
result = await callFfApi(args.endpoint as string);
|
||||
} else {
|
||||
result = `Outil inconnu: ${fn.name}`;
|
||||
}
|
||||
|
||||
send({ type: "tool_result", name: fn.name, result: result.substring(0, 500) + (result.length > 500 ? "..." : "") });
|
||||
toolResultParts.push({ functionResponse: { name: fn.name, response: { output: result } } });
|
||||
}
|
||||
|
||||
contents.push({ role: "user", parts: toolResultParts as any });
|
||||
}
|
||||
|
||||
send({ type: "usage", prompt_tokens: 0, completion_tokens: 0 });
|
||||
send({ type: "done" });
|
||||
controller.close();
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Erreur inconnue";
|
||||
send({ type: "error", message: msg });
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ─── POST handler ─────────────────────────────────────────────────────────────
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session || (session.user as any)?.role !== "admin") {
|
||||
return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 403 });
|
||||
}
|
||||
|
||||
const { messages, model } = await req.json();
|
||||
const aiConfig = await getAiConfig();
|
||||
|
||||
if (aiConfig.provider === "google") {
|
||||
return handleGoogleAi(messages, aiConfig);
|
||||
}
|
||||
|
||||
const apiKey = aiConfig.openRouterKey;
|
||||
if (!apiKey) {
|
||||
return new Response(JSON.stringify({ error: "No OpenRouter API key configured" }), { status: 500 });
|
||||
}
|
||||
|
||||
const fullMessages: object[] = [
|
||||
{ role: "system", content: SYSTEM_PROMPT },
|
||||
...messages,
|
||||
];
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
function send(event: object) {
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
|
||||
}
|
||||
|
||||
try {
|
||||
let currentMessages = [...fullMessages];
|
||||
let totalPromptTokens = 0;
|
||||
let totalCompletionTokens = 0;
|
||||
let directAnswer: string | null = null;
|
||||
|
||||
for (let round = 0; round < 10; round++) {
|
||||
const res = await callOpenRouter(currentMessages, model, apiKey, true, false);
|
||||
if (!res.ok) {
|
||||
send({ type: "error", message: `OpenRouter error: ${await res.text()}` });
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
if (data.usage) {
|
||||
totalPromptTokens += data.usage.prompt_tokens ?? 0;
|
||||
totalCompletionTokens += data.usage.completion_tokens ?? 0;
|
||||
}
|
||||
|
||||
const assistantMessage = data.choices?.[0]?.message;
|
||||
if (!assistantMessage) break;
|
||||
currentMessages.push(assistantMessage);
|
||||
|
||||
const toolCalls = assistantMessage.tool_calls;
|
||||
if (!toolCalls || toolCalls.length === 0) {
|
||||
directAnswer = assistantMessage.content ?? "";
|
||||
break;
|
||||
}
|
||||
|
||||
const toolResults: object[] = [];
|
||||
for (const tc of toolCalls) {
|
||||
const fnName = tc.function?.name;
|
||||
let args: Record<string, unknown> = {};
|
||||
try { args = JSON.parse(tc.function?.arguments ?? "{}"); } catch { /* ignore */ }
|
||||
|
||||
send({ type: "tool_start", name: fnName, args });
|
||||
|
||||
let result = "";
|
||||
if (fnName === "call_api") {
|
||||
result = await callFfApi(args.endpoint as string);
|
||||
} else {
|
||||
result = `Outil inconnu: ${fnName}`;
|
||||
}
|
||||
|
||||
send({ type: "tool_result", name: fnName, result: result.substring(0, 500) + (result.length > 500 ? "..." : "") });
|
||||
toolResults.push({ role: "tool", tool_call_id: tc.id, content: result });
|
||||
}
|
||||
currentMessages.push(...toolResults);
|
||||
}
|
||||
|
||||
if (directAnswer !== null) {
|
||||
send({ type: "text", content: directAnswer });
|
||||
send({ type: "usage", prompt_tokens: totalPromptTokens, completion_tokens: totalCompletionTokens });
|
||||
send({ type: "done" });
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
|
||||
// Final streaming call after tool use
|
||||
const finalRes = await callOpenRouter(currentMessages, model, apiKey, false, true);
|
||||
if (!finalRes.ok) {
|
||||
send({ type: "error", message: `OpenRouter error: ${await finalRes.text()}` });
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = finalRes.body?.getReader();
|
||||
if (!reader) { controller.close(); return; }
|
||||
|
||||
const textDecoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
let finalUsage: { prompt_tokens?: number; completion_tokens?: number } | null = null;
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += textDecoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() ?? "";
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith("data: ")) continue;
|
||||
const raw = line.slice(6).trim();
|
||||
if (raw === "[DONE]") continue;
|
||||
try {
|
||||
const chunk = JSON.parse(raw);
|
||||
const delta = chunk.choices?.[0]?.delta?.content;
|
||||
if (delta) send({ type: "text", content: delta });
|
||||
if (chunk.usage) finalUsage = chunk.usage;
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
if (finalUsage) {
|
||||
totalPromptTokens += finalUsage.prompt_tokens ?? 0;
|
||||
totalCompletionTokens += finalUsage.completion_tokens ?? 0;
|
||||
}
|
||||
|
||||
send({ type: "usage", prompt_tokens: totalPromptTokens, completion_tokens: totalCompletionTokens });
|
||||
send({ type: "done" });
|
||||
controller.close();
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Erreur inconnue";
|
||||
send({ type: "error", message: msg });
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
|
||||
export async function GET() {
|
||||
const session = await auth();
|
||||
if (!session || (session.user as any)?.role !== "admin") {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
const configFile = path.join(process.cwd(), "data", ".db-config.json");
|
||||
const data = await fs.readFile(configFile, "utf-8");
|
||||
const config = JSON.parse(data);
|
||||
return NextResponse.json({
|
||||
provider: config.aiProvider ?? "openrouter",
|
||||
openRouterModel: config.openRouterModel,
|
||||
googleAiModel: config.googleAiModel,
|
||||
});
|
||||
} catch {
|
||||
return NextResponse.json({ provider: "openrouter" });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { pgGetFournisseurs } from "@/lib/pg-ff-client";
|
||||
import { getProductRows } from "@/features/grid/api/get-product-rows";
|
||||
import { listGridSuppliers } from "@/lib/grid-store";
|
||||
|
||||
// Le préchauffage parcourt tous les fournisseurs : c'est long par nature. On rend la
|
||||
// main immédiatement et le client interroge GET pour suivre l'avancement.
|
||||
export const maxDuration = 300;
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Préchauffage de l'instantané de grille (`grid_rows`).
|
||||
*
|
||||
* L'API `/api/v1` ne calcule jamais : elle lit un instantané écrit quand la Grille est
|
||||
* ouverte dans l'application. Sans préchauffage, un consommateur externe (ChatGPT) ne
|
||||
* verrait donc que les fournisseurs déjà consultés à la main.
|
||||
*
|
||||
* Ce job calcule chaque fournisseur une fois, séquentiellement — la persistance se fait
|
||||
* en effet de bord de `getProductRows()`. Séquentiel volontairement : le calcul est
|
||||
* lourd en SQL, le paralléliser saturerait PostgreSQL.
|
||||
*/
|
||||
type WarmupStatus = "idle" | "running" | "success" | "error";
|
||||
|
||||
interface WarmupJob {
|
||||
status: WarmupStatus;
|
||||
total: number;
|
||||
done: number;
|
||||
skipped: number;
|
||||
failed: number;
|
||||
currentFournisseur?: string;
|
||||
startedAt: string;
|
||||
finishedAt?: string;
|
||||
error?: string;
|
||||
lastErrors: string[];
|
||||
}
|
||||
|
||||
let job: WarmupJob | null = null;
|
||||
|
||||
function publicJob(j: WarmupJob | null) {
|
||||
if (!j) {
|
||||
return { status: "idle" as const, total: 0, done: 0, skipped: 0, failed: 0, lastErrors: [] };
|
||||
}
|
||||
return j;
|
||||
}
|
||||
|
||||
async function requireAdmin(): Promise<NextResponse | null> {
|
||||
const session = await auth();
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
if ((session.user as { role?: string } | undefined)?.role !== "admin") {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function runWarmup(j: WarmupJob, staleHours: number): Promise<void> {
|
||||
try {
|
||||
const fournisseurs = await pgGetFournisseurs();
|
||||
// Les fournisseurs déjà calculés récemment sont sautés : un second passage
|
||||
// coûte alors quasiment rien, ce qui rend le bouton rejouable sans crainte.
|
||||
const existing = await listGridSuppliers();
|
||||
const freshLimit = Date.now() - staleHours * 3600_000;
|
||||
const fresh = new Set(
|
||||
existing
|
||||
.filter((s) => s.computedAt && new Date(s.computedAt).getTime() > freshLimit)
|
||||
.map((s) => s.codeFournisseur),
|
||||
);
|
||||
|
||||
j.total = fournisseurs.length;
|
||||
console.log(`[grid-warmup] ${fournisseurs.length} fournisseurs, ${fresh.size} déjà à jour (< ${staleHours}h)`);
|
||||
|
||||
for (const f of fournisseurs) {
|
||||
if (fresh.has(f.code)) {
|
||||
j.skipped++;
|
||||
continue;
|
||||
}
|
||||
j.currentFournisseur = `${f.code} — ${f.nom}`;
|
||||
try {
|
||||
await getProductRows({ codeFournisseur: f.code, magasin: "TOTAL", forceRefresh: true });
|
||||
j.done++;
|
||||
} catch (e) {
|
||||
j.failed++;
|
||||
const msg = `${f.code}: ${e instanceof Error ? e.message.slice(0, 120) : String(e)}`;
|
||||
// On garde une trace des derniers échecs sans faire exploser la mémoire.
|
||||
if (j.lastErrors.length < 10) j.lastErrors.push(msg);
|
||||
console.error("[grid-warmup]", msg);
|
||||
}
|
||||
}
|
||||
|
||||
j.currentFournisseur = undefined;
|
||||
j.status = "success";
|
||||
j.finishedAt = new Date().toISOString();
|
||||
console.log(`[grid-warmup] terminé — ${j.done} calculés, ${j.skipped} à jour, ${j.failed} en échec`);
|
||||
} catch (e) {
|
||||
j.status = "error";
|
||||
j.error = e instanceof Error ? e.message : String(e);
|
||||
j.finishedAt = new Date().toISOString();
|
||||
console.error("[grid-warmup] échec global:", j.error);
|
||||
}
|
||||
}
|
||||
|
||||
/** GET /api/admin/grid-warmup — avancement du préchauffage. */
|
||||
export async function GET() {
|
||||
const denied = await requireAdmin();
|
||||
if (denied) return denied;
|
||||
return NextResponse.json({ success: true, ...publicJob(job) });
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/admin/grid-warmup?staleHours=24
|
||||
* Démarre le préchauffage en arrière-plan et répond immédiatement.
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
const denied = await requireAdmin();
|
||||
if (denied) return denied;
|
||||
|
||||
if (job?.status === "running") {
|
||||
return NextResponse.json({ success: true, ...publicJob(job) });
|
||||
}
|
||||
|
||||
const raw = Number(req.nextUrl.searchParams.get("staleHours"));
|
||||
const staleHours = Number.isFinite(raw) ? Math.min(720, Math.max(0, raw)) : 24;
|
||||
|
||||
job = {
|
||||
status: "running",
|
||||
total: 0,
|
||||
done: 0,
|
||||
skipped: 0,
|
||||
failed: 0,
|
||||
startedAt: new Date().toISOString(),
|
||||
lastErrors: [],
|
||||
};
|
||||
const current = job;
|
||||
console.log(`[grid-warmup] démarré (staleHours=${staleHours})`);
|
||||
|
||||
void runWarmup(current, staleHours).catch((e) => {
|
||||
current.status = "error";
|
||||
current.error = e instanceof Error ? e.message : String(e);
|
||||
current.finishedAt = new Date().toISOString();
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true, ...publicJob(job) });
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { asc, eq, inArray, sql } from "drizzle-orm";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { db } from "@/db";
|
||||
import { syncFournisseurs } from "@/db/schema";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
async function requireAdmin(): Promise<NextResponse | null> {
|
||||
const session = await auth();
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
if ((session.user as { role?: string } | undefined)?.role !== "admin") {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET — la liste complète des fournisseurs paramétrés, avec l'état de leur
|
||||
* dernière synchronisation.
|
||||
*
|
||||
* Pas de pagination : quelques centaines de lignes tout au plus, et l'admin a
|
||||
* besoin de la vue d'ensemble pour décider qui cadencer.
|
||||
*/
|
||||
export async function GET() {
|
||||
const denied = await requireAdmin();
|
||||
if (denied) return denied;
|
||||
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(syncFournisseurs)
|
||||
.orderBy(asc(sql`coalesce(${syncFournisseurs.nomFournisseur}, ${syncFournisseurs.codeFournisseur})`));
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
fournisseurs: rows,
|
||||
resume: {
|
||||
total: rows.length,
|
||||
actifsSql: rows.filter((r) => r.actifSql).length,
|
||||
actifsQlik: rows.filter((r) => r.actifQlik).length,
|
||||
desactivesAuto: rows.filter((r) => r.desactiveMotif).length,
|
||||
enEchecSql: rows.filter((r) => r.dernierSqlStatut === "echec").length,
|
||||
enEchecQlik: rows.filter((r) => r.dernierQlikStatut === "echec").length,
|
||||
jamaisSql: rows.filter((r) => !r.dernierSqlAt).length,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const patchSchema = z.object({
|
||||
/** Codes visés. Permet d'activer ou désactiver en masse depuis la page. */
|
||||
codes: z.array(z.string().min(1)).min(1).max(2000),
|
||||
actifSql: z.boolean().optional(),
|
||||
actifQlik: z.boolean().optional(),
|
||||
}).refine((v) => v.actifSql !== undefined || v.actifQlik !== undefined, {
|
||||
message: "Préciser au moins actifSql ou actifQlik",
|
||||
});
|
||||
|
||||
/**
|
||||
* PATCH — active ou désactive des fournisseurs, pour l'une ou l'autre source.
|
||||
*
|
||||
* Réactiver lève le motif de désactivation automatique : sans cela, un
|
||||
* fournisseur remis en service par l'admin garderait un « aucun article »
|
||||
* trompeur, alors même qu'il vient d'être rouvert volontairement.
|
||||
*/
|
||||
export async function PATCH(req: NextRequest) {
|
||||
const denied = await requireAdmin();
|
||||
if (denied) return denied;
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Corps JSON invalide" }, { status: 400 });
|
||||
}
|
||||
const parsed = patchSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Paramètres invalides", details: parsed.error.issues.map((i) => i.message) },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const { codes, actifSql, actifQlik } = parsed.data;
|
||||
|
||||
const reactivation = actifSql === true || actifQlik === true;
|
||||
const rows = await db
|
||||
.update(syncFournisseurs)
|
||||
.set({
|
||||
...(actifSql !== undefined ? { actifSql } : {}),
|
||||
...(actifQlik !== undefined ? { actifQlik } : {}),
|
||||
...(reactivation ? { desactiveMotif: null } : {}),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(codes.length === 1 ? eq(syncFournisseurs.codeFournisseur, codes[0]) : inArray(syncFournisseurs.codeFournisseur, codes))
|
||||
.returning({ code: syncFournisseurs.codeFournisseur });
|
||||
|
||||
console.log(`[api/admin/sync] ${rows.length} fournisseur(s) mis à jour (sql=${actifSql} qlik=${actifQlik})`);
|
||||
return NextResponse.json({ success: true, misAJour: rows.length });
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { readSyncSettings, saveSyncSettings } from "@/features/admin/api/sync-settings";
|
||||
import {
|
||||
getSyncSchedulerState,
|
||||
lancerRound,
|
||||
demanderArret,
|
||||
debutFenetre,
|
||||
seedSyncFournisseurs,
|
||||
} from "@/features/admin/api/sync-scheduler";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
// Un lancement manuel peut enchaîner plusieurs fournisseurs.
|
||||
export const maxDuration = 300;
|
||||
|
||||
async function requireAdmin(): Promise<NextResponse | null> {
|
||||
const session = await auth();
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
if ((session.user as { role?: string } | undefined)?.role !== "admin") {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** GET — réglages, état courant, et prochaine ouverture de fenêtre. */
|
||||
export async function GET() {
|
||||
const denied = await requireAdmin();
|
||||
if (denied) return denied;
|
||||
|
||||
const settings = await readSyncSettings();
|
||||
const maintenant = new Date();
|
||||
const debut = debutFenetre(settings, maintenant);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
settings,
|
||||
state: getSyncSchedulerState(),
|
||||
/** true = on est actuellement dans la fenêtre nocturne. */
|
||||
dansLaFenetre: debut !== null,
|
||||
fenetreDebutAt: debut?.toISOString() ?? null,
|
||||
maintenant: maintenant.toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
const settingsSchema = z.object({
|
||||
actif: z.boolean().optional(),
|
||||
heureDebut: z.coerce.number().int().min(0).max(23).optional(),
|
||||
heureFin: z.coerce.number().int().min(0).max(23).optional(),
|
||||
qlikParNuit: z.coerce.number().int().min(0).max(200).optional(),
|
||||
sqlParQlik: z.coerce.number().int().min(1).max(500).optional(),
|
||||
qlikMinJours: z.coerce.number().int().min(0).max(365).optional(),
|
||||
});
|
||||
|
||||
/** PATCH — met à jour les réglages. */
|
||||
export async function PATCH(req: NextRequest) {
|
||||
const denied = await requireAdmin();
|
||||
if (denied) return denied;
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Corps JSON invalide" }, { status: 400 });
|
||||
}
|
||||
const parsed = settingsSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Paramètres invalides", details: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`) },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const settings = await saveSyncSettings(parsed.data);
|
||||
return NextResponse.json({ success: true, settings });
|
||||
}
|
||||
|
||||
const actionSchema = z.object({
|
||||
action: z.enum(["start", "stop", "seed"]),
|
||||
/**
|
||||
* Lancement manuel hors de la fenêtre nocturne. Réservé à l'admin qui sait
|
||||
* ce qu'il fait : en pleine journée, la charge est visible par les magasins.
|
||||
*/
|
||||
forcer: z.boolean().optional(),
|
||||
});
|
||||
|
||||
/** POST — démarre un round manuellement, l'arrête, ou resynchronise la liste. */
|
||||
export async function POST(req: NextRequest) {
|
||||
const denied = await requireAdmin();
|
||||
if (denied) return denied;
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Corps JSON invalide" }, { status: 400 });
|
||||
}
|
||||
const parsed = actionSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Action inconnue (start, stop ou seed)" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (parsed.data.action === "seed") {
|
||||
const n = await seedSyncFournisseurs();
|
||||
return NextResponse.json({ success: true, fournisseurs: n });
|
||||
}
|
||||
|
||||
if (parsed.data.action === "stop") {
|
||||
demanderArret();
|
||||
return NextResponse.json({ success: true, state: getSyncSchedulerState() });
|
||||
}
|
||||
|
||||
const etat = getSyncSchedulerState();
|
||||
if (etat.enCours) {
|
||||
return NextResponse.json({ error: "Un round est déjà en cours.", state: etat }, { status: 409 });
|
||||
}
|
||||
|
||||
// Fire-and-forget : le round dure des minutes, le client suit via GET.
|
||||
void lancerRound("manuel", parsed.data.forcer === true).catch((e) => {
|
||||
console.error("[api/admin/sync] round manuel en échec :", e instanceof Error ? e.message : String(e));
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true, state: getSyncSchedulerState() });
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { pgDiagFournisseurDerniereEntree } from "@/lib/pg-ff-client";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Diagnostic de l'attribution « fournisseur de la dernière entrée » utilisée par
|
||||
* la page Gestion de Stock.
|
||||
*
|
||||
* `colonneDetectee: null` signifie que `mvtart` ne porte aucune des colonnes
|
||||
* fournisseur connues : la page retombe alors sur `artfou1.preference = 1`.
|
||||
* `colonnesMvtart` liste ce qui existe réellement, pour compléter la liste
|
||||
* blanche `MVTART_FOU_CANDIDATES` si le nom diffère.
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
return NextResponse.json(await pgDiagFournisseurDerniereEntree());
|
||||
} catch (e) {
|
||||
return NextResponse.json({ error: String(e) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,24 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSyncStatus } from "@/lib/api-ff-client";
|
||||
import { testFfApiConnection } from "@/features/settings/actions";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* GET /api/ff-status — état de l'API REST FF Nancy.
|
||||
*
|
||||
* S'appuie sur le même diagnostic que le bouton « Tester » des Paramètres, afin
|
||||
* qu'un échec dise *pourquoi* (DNS, connexion refusée, délai dépassé, code HTTP)
|
||||
* et *quelle adresse* a été appelée. L'ancienne version renvoyait un 503 sans
|
||||
* détail, impossible à distinguer d'une simple erreur d'URL.
|
||||
*/
|
||||
export async function GET() {
|
||||
const status = await getSyncStatus();
|
||||
if (!status) {
|
||||
return NextResponse.json({ error: "API FF Nancy non disponible" }, { status: 503 });
|
||||
const res = await testFfApiConnection();
|
||||
if (!res.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "API FF Nancy non disponible", url: res.url, detail: res.error },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
return NextResponse.json(status);
|
||||
return NextResponse.json(res.status);
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
|
||||
async function getSavedGoogleKey(): Promise<string | null> {
|
||||
try {
|
||||
const configFile = path.join(process.cwd(), "data", ".db-config.json");
|
||||
const data = await fs.readFile(configFile, "utf-8");
|
||||
const config = JSON.parse(data);
|
||||
return config.googleAiKey ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const apiKey = req.headers.get("x-google-ai-key")
|
||||
|| process.env.GOOGLE_AI_KEY
|
||||
|| await getSavedGoogleKey();
|
||||
|
||||
if (!apiKey) {
|
||||
return NextResponse.json({ error: "No Google AI key provided" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
`https://generativelanguage.googleapis.com/v1beta/models?key=${apiKey}&pageSize=100`,
|
||||
{ cache: "no-store" }
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
return NextResponse.json({ error: err }, { status: res.status });
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
// Keep only generative models that support generateContent
|
||||
const models = (data.models as Array<{
|
||||
name: string;
|
||||
displayName: string;
|
||||
supportedGenerationMethods?: string[];
|
||||
description?: string;
|
||||
}>)
|
||||
.filter(m =>
|
||||
m.supportedGenerationMethods?.includes("generateContent") &&
|
||||
!m.name.includes("embedding") &&
|
||||
!m.name.includes("aqa")
|
||||
)
|
||||
.map(m => ({
|
||||
id: m.name.replace("models/", ""),
|
||||
name: m.displayName,
|
||||
}))
|
||||
.sort((a, b) => a.name.localeCompare(b.name, "fr"));
|
||||
|
||||
return NextResponse.json({ models });
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Unknown error";
|
||||
return NextResponse.json({ error: msg }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -18,10 +18,11 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
const magasin = searchParams.get("magasin") ?? "TOTAL";
|
||||
const chunkSize = Math.max(50, Math.min(500, Number(searchParams.get("chunkSize") ?? 150)));
|
||||
// `code3` n'est volontairement pas lu : la Grille applique ce filtre en local
|
||||
// (getProductRows ne s'en sert pas) et il accepte désormais plusieurs valeurs.
|
||||
const filters: Partial<GridFilters> = {
|
||||
code1: searchParams.get("code1"),
|
||||
code2: searchParams.get("code2"),
|
||||
code3: searchParams.get("code3"),
|
||||
};
|
||||
const forceRefresh = searchParams.get("refresh") === "1";
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* CollectFlow — Journaux serveur des jobs (liste + téléchargement).
|
||||
*
|
||||
* `GET /api/logs` → liste des journaux disponibles (JSON)
|
||||
* `GET /api/logs?id=<jobId>` → le journal complet en pièce jointe (text/plain)
|
||||
*
|
||||
* Réservé aux admins : un journal d'extraction contient les diagnostics internes
|
||||
* du modèle Qlik et les codes articles manipulés. Aucun secret n'y transite —
|
||||
* l'extracteur ne journalise que le NOM du cookie de session, jamais sa valeur,
|
||||
* et jamais le mot de passe Qlik.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { listCaptures, readCapture } from "@/lib/log-capture";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
async function requireAdmin(): Promise<NextResponse | null> {
|
||||
const session = await auth();
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
if ((session.user as { role?: string } | undefined)?.role !== "admin") {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const refus = await requireAdmin();
|
||||
if (refus) return refus;
|
||||
|
||||
const id = req.nextUrl.searchParams.get("id");
|
||||
if (!id) {
|
||||
return NextResponse.json({ success: true, logs: await listCaptures() });
|
||||
}
|
||||
|
||||
const contenu = await readCapture(id);
|
||||
if (contenu == null) {
|
||||
return NextResponse.json({ error: "Journal introuvable ou expiré" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Nom de fichier assaini : l'id vient d'un job interne, mais il finit dans
|
||||
// un en-tête HTTP.
|
||||
const nom = `${id.replace(/[^A-Za-z0-9_.-]/g, "_")}.log`;
|
||||
return new NextResponse(contenu, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "text/plain; charset=utf-8",
|
||||
"Content-Disposition": `attachment; filename="${nom}"`,
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
|
||||
async function getSavedOpenRouterKey(): Promise<string | null> {
|
||||
try {
|
||||
const configFile = path.join(process.cwd(), "data", ".db-config.json");
|
||||
const data = await fs.readFile(configFile, "utf-8");
|
||||
const config = JSON.parse(data);
|
||||
return config.openRouterKey ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const apiKey = req.headers.get("x-openrouter-key")
|
||||
|| process.env.OPENROUTER_API_KEY
|
||||
|| await getSavedOpenRouterKey();
|
||||
|
||||
if (!apiKey) {
|
||||
return NextResponse.json({ error: "No API key provided" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch("https://openrouter.ai/api/v1/models", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json({ error: "OpenRouter error", status: res.status }, { status: res.status });
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
// Return simplified list sorted by name
|
||||
const models = (data.data as Array<{ id: string; name: string; pricing?: { prompt: string; completion: string } }>)
|
||||
.map((m) => ({
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
promptPrice: parseFloat(m.pricing?.prompt ?? "0"),
|
||||
completionPrice: parseFloat(m.pricing?.completion ?? "0"),
|
||||
free: parseFloat(m.pricing?.prompt ?? "1") === 0,
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
// Free models first, then alphabetical
|
||||
if (a.free && !b.free) return -1;
|
||||
if (!a.free && b.free) return 1;
|
||||
return a.name.localeCompare(b.name, "fr");
|
||||
});
|
||||
|
||||
return NextResponse.json({ models });
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Unknown error";
|
||||
return NextResponse.json({ error: msg }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { pgGetOpportunitesFamille } from "@/lib/pg-ff-client";
|
||||
import { buildLast12MonthsRange } from "@/lib/api-ff-client";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* GET /api/produits/opportunites?nomNoId=123
|
||||
*
|
||||
* Produits de la même nomenclature, classés par écart entre performance réseau
|
||||
* et performance locale (quantités par magasin). Chargé à la demande depuis la
|
||||
* fiche produit : la requête agrège `mvtart` sur 12 mois pour toute la famille,
|
||||
* on ne veut pas la payer au chargement de la page.
|
||||
*
|
||||
* Le middleware Next ne protège pas `/api/*` : contrôle de session explicite.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const raw = req.nextUrl.searchParams.get("nomNoId");
|
||||
const nomNoId = Number(raw);
|
||||
if (!raw || !Number.isInteger(nomNoId) || nomNoId <= 0) {
|
||||
return NextResponse.json({ error: "Param 'nomNoId' invalide" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { dateDebut, dateFin } = buildLast12MonthsRange();
|
||||
const rows = await pgGetOpportunitesFamille(nomNoId, dateDebut, dateFin);
|
||||
return NextResponse.json({ success: true, rows });
|
||||
} catch (e) {
|
||||
console.error("[api/produits/opportunites]", (e as Error).message?.slice(0, 250));
|
||||
return NextResponse.json({ error: "Erreur serveur" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { demarrerRecherche, etatRecherche } from "@/features/produits/api/search-produits";
|
||||
|
||||
// Les réponses sont immédiates (le travail Qlik tourne en tâche de fond), mais on
|
||||
// garde le runtime Node.js : Playwright/Chromium ne tourne pas en edge, et l'état
|
||||
// des jobs vit dans la mémoire du process.
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Recherche produit **Qlik d'abord** — API asynchrone.
|
||||
*
|
||||
* `POST /api/produits/search?q=<terme>[&force=1]` démarre la recherche et rend la
|
||||
* main tout de suite. `GET /api/produits/search?q=<terme>` renvoie l'avancement,
|
||||
* puis le résultat.
|
||||
*
|
||||
* Pourquoi asynchrone : une recherche enchaîne deux allers-retours Qlik (dont une
|
||||
* extraction mensuelle) et peut dépasser la minute. Une requête HTTP maintenue
|
||||
* aussi longtemps se fait couper par le reverse proxy, qui répond une page
|
||||
* d'erreur **HTML** — le client échouait alors sur « Unexpected token '<' … is
|
||||
* not valid JSON ». Même schéma que `POST /api/qlik/sync`.
|
||||
*
|
||||
* Le middleware Next ne protège pas `/api/*` : contrôle de session explicite.
|
||||
*/
|
||||
|
||||
async function requireSession(): Promise<NextResponse | null> {
|
||||
const session = await auth();
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
return null;
|
||||
}
|
||||
|
||||
function lireTerme(req: NextRequest): string | null {
|
||||
const q = (req.nextUrl.searchParams.get("q") ?? "").trim();
|
||||
return q.length >= 3 ? q : null;
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const denied = await requireSession();
|
||||
if (denied) return denied;
|
||||
|
||||
const q = lireTerme(req);
|
||||
if (!q) return NextResponse.json({ error: "Saisissez au moins 3 caractères" }, { status: 400 });
|
||||
|
||||
const force = req.nextUrl.searchParams.get("force") === "1";
|
||||
try {
|
||||
const job = demarrerRecherche(q, { force });
|
||||
return NextResponse.json({ success: true, ...job });
|
||||
} catch (e) {
|
||||
const message = (e as Error).message ?? String(e);
|
||||
console.error("[api/produits/search] POST", message.slice(0, 300));
|
||||
return NextResponse.json({ error: message.slice(0, 300) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const denied = await requireSession();
|
||||
if (denied) return denied;
|
||||
|
||||
const q = lireTerme(req);
|
||||
if (!q) return NextResponse.json({ error: "Saisissez au moins 3 caractères" }, { status: 400 });
|
||||
|
||||
try {
|
||||
return NextResponse.json({ success: true, ...etatRecherche(q) });
|
||||
} catch (e) {
|
||||
const message = (e as Error).message ?? String(e);
|
||||
console.error("[api/produits/search] GET", message.slice(0, 300));
|
||||
return NextResponse.json({ error: message.slice(0, 300) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
+437
-42
@@ -1,62 +1,457 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { fetchNetworkMetrics } from "@/lib/qlik-client";
|
||||
import { fetchNetworkMetricsPlaywright } from "@/lib/qlik-playwright";
|
||||
import { upsertNetworkMetrics } from "@/lib/qlik-network-cache";
|
||||
import { pgGetArticlesByFournisseur } from "@/lib/pg-ff-client";
|
||||
import {
|
||||
buildGridNetworkQlikDateFilter,
|
||||
QLIK_MONTHS_BACK_DEFAULT,
|
||||
} from "@/lib/qlik-date-range";
|
||||
import { startCapture, stopCapture } from "@/lib/log-capture";
|
||||
|
||||
// Synchro potentiellement longue (extraction hypercube paginee).
|
||||
// Tâche d'extraction Qlik potentiellement très longue (hypercube paginé).
|
||||
// On accepte quand même 5 min côté plateforme Next.js, mais on rend la main au
|
||||
// client tout de suite via le state job ci-dessous.
|
||||
export const maxDuration = 300;
|
||||
// Forcer le runtime Node.js : Playwright/Chromium ne tourne pas en edge et on a
|
||||
// besoin d'un process persistant pour conserver l'état de job en mémoire.
|
||||
export const runtime = "nodejs";
|
||||
// Toujours exécuter dynamiquement (sinon Next peut essayer de cacher des POST).
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/** État d'un job de sync Qlik pour un fournisseur donné. */
|
||||
type QlikSyncStatus = "idle" | "running" | "success" | "error";
|
||||
|
||||
interface QlikSyncJob {
|
||||
jobId: string;
|
||||
/** Code fournisseur (mode fournisseur) — chaîne vide en mode produit. */
|
||||
fournisseur: string;
|
||||
/** Code centrale (mode produit) — absent en mode fournisseur. */
|
||||
codeCentrale?: string;
|
||||
status: QlikSyncStatus;
|
||||
requested: number;
|
||||
fetched: number;
|
||||
upserted: number;
|
||||
periode?: string;
|
||||
dateDebut?: string;
|
||||
dateFin?: string;
|
||||
startedAt: string;
|
||||
finishedAt?: string;
|
||||
error?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store en mémoire au niveau module. Une Map par fournisseur car on ne lance
|
||||
* qu'une extraction à la fois par fournisseur. C'est volontairement simple :
|
||||
* si le process redémarre, les jobs en cours sont perdus → GET renverra idle
|
||||
* et l'utilisateur pourra relancer. Suffisant pour corriger le bug UX.
|
||||
*/
|
||||
const jobs = new Map<string, QlikSyncJob>();
|
||||
|
||||
/**
|
||||
* Clé de job. Le mode fournisseur garde sa clé historique (le code fournisseur
|
||||
* brut) ; le mode produit est préfixé pour ne jamais entrer en collision.
|
||||
*/
|
||||
function jobKeyForProduct(codeCentrale: string): string {
|
||||
return `cc:${codeCentrale.toUpperCase()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Nombre maximum d'extractions produit simultanées.
|
||||
*
|
||||
* Le mode produit est ouvert à tous les utilisateurs connectés (contrairement au
|
||||
* mode fournisseur, réservé aux admins). Une extraction sur 1 seul code est très
|
||||
* légère, mais le serveur Qlik est sujet aux saturations mémoire : on plafonne
|
||||
* la concurrence plutôt que de lui laisser prendre N requêtes en parallèle.
|
||||
*/
|
||||
const MAX_CONCURRENT_PRODUCT_JOBS = 3;
|
||||
|
||||
/** Compte les extractions produit actuellement en cours. */
|
||||
function runningProductJobs(): number {
|
||||
let n = 0;
|
||||
for (const [key, job] of jobs) {
|
||||
if (key.startsWith("cc:") && job.status === "running") n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/** Construit un jobId court et lisible. */
|
||||
function newJobId(): string {
|
||||
return `qlik_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
/** Sérialise un job pour la réponse HTTP. */
|
||||
function publicJob(job: QlikSyncJob) {
|
||||
return {
|
||||
jobId: job.jobId,
|
||||
fournisseur: job.fournisseur,
|
||||
codeCentrale: job.codeCentrale,
|
||||
status: job.status,
|
||||
requested: job.requested,
|
||||
fetched: job.fetched,
|
||||
upserted: job.upserted,
|
||||
periode: job.periode,
|
||||
dateDebut: job.dateDebut,
|
||||
dateFin: job.dateFin,
|
||||
startedAt: job.startedAt,
|
||||
finishedAt: job.finishedAt,
|
||||
error: job.error,
|
||||
message: job.message,
|
||||
};
|
||||
}
|
||||
|
||||
/** État "idle" sérialisé (pas de job connu pour cette cible). */
|
||||
function idleJob(fournisseur: string, codeCentrale?: string) {
|
||||
return {
|
||||
jobId: null,
|
||||
fournisseur,
|
||||
codeCentrale,
|
||||
status: "idle" as const,
|
||||
requested: 0,
|
||||
fetched: 0,
|
||||
upserted: 0,
|
||||
periode: undefined,
|
||||
dateDebut: undefined,
|
||||
dateFin: undefined,
|
||||
startedAt: undefined,
|
||||
finishedAt: undefined,
|
||||
error: undefined,
|
||||
message: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie l'auth admin. Renvoie une réponse 403/401 si refusée, sinon null.
|
||||
*/
|
||||
async function requireAdmin(): Promise<NextResponse | null> {
|
||||
const session = await auth();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
if ((session.user as { role?: string } | undefined)?.role !== "admin") {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie qu'une session existe (sans exiger le rôle admin).
|
||||
*
|
||||
* Utilisé par le mode produit : le middleware Next ne couvre PAS les routes
|
||||
* `/api/*`, le contrôle doit donc être fait ici explicitement.
|
||||
*/
|
||||
async function requireSession(): Promise<NextResponse | null> {
|
||||
const session = await auth();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Regex de validation d'un code centrale "raisonnable".
|
||||
*
|
||||
* Format attendu d'après l'API FF : préfixe `10000` + 6 chiffres (ex `10000167303`),
|
||||
* donc 11 caractères alphanumériques. Pour rester large et ne pas casser
|
||||
* d'éventuels codes alphanumériques fournisseur (refs internes), on autorise
|
||||
* - longueur 2..30
|
||||
* - jeu `[A-Z0-9_-]`
|
||||
* - premier caractère alphanumérique (pas `_` ni `-`)
|
||||
*
|
||||
* But : éviter d'envoyer à Qlik des chaînes vides, des `-`, des espaces
|
||||
* parasites, ou des entrées manifestement invalides qui déclenchent des
|
||||
* codes d'erreur Engine ou du gaspillage réseau. **Volontairement simple** :
|
||||
* on ne touche pas au mapping ; on filtre seulement ce qui ne ressemble à
|
||||
* aucun code plausible.
|
||||
*/
|
||||
const CENTRAL_CODE_REGEX = /^[A-Z0-9][A-Z0-9_-]{1,29}$/i;
|
||||
|
||||
/**
|
||||
* Filtre strict des codes centraux avant d'envoyer à Qlik.
|
||||
* - trim + drop vides
|
||||
* - drop "-" (placeholder déjà exclu côté agrégation mais on double-check)
|
||||
* - drop doublons (préserve l'ordre, comparaison insensible à la casse)
|
||||
* - drop codes ne matchant pas CENTRAL_CODE_REGEX (longueur / charset)
|
||||
*
|
||||
* Renvoie `{ accepted, rejected, deduped }`. Les logs n'exposent que les
|
||||
* compteurs, jamais les valeurs des codes.
|
||||
*/
|
||||
function filterCentralCodes(rawCodes: Array<string | null | undefined>): {
|
||||
accepted: string[];
|
||||
rejected: number;
|
||||
deduped: number;
|
||||
} {
|
||||
const seen = new Set<string>();
|
||||
const accepted: string[] = [];
|
||||
let rejected = 0;
|
||||
let deduped = 0;
|
||||
for (const raw of rawCodes) {
|
||||
if (raw == null) {
|
||||
rejected++;
|
||||
continue;
|
||||
}
|
||||
const code = String(raw).trim();
|
||||
const key = code.toUpperCase();
|
||||
if (!code || code === "-" || code === "_") {
|
||||
rejected++;
|
||||
continue;
|
||||
}
|
||||
if (seen.has(key)) {
|
||||
deduped++;
|
||||
continue;
|
||||
}
|
||||
if (!CENTRAL_CODE_REGEX.test(code)) {
|
||||
rejected++;
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
accepted.push(code);
|
||||
}
|
||||
return { accepted, rejected, deduped };
|
||||
}
|
||||
|
||||
/**
|
||||
* Exécute réellement l'extraction + l'upsert, en mettant à jour le job
|
||||
* au fur et à mesure (compteur fetched, puis upserted, puis finishedAt).
|
||||
*/
|
||||
async function runJob(job: QlikSyncJob): Promise<void> {
|
||||
// Journal téléchargeable depuis Paramètres. Ouvert AVANT le premier log du
|
||||
// job pour ne pas perdre l'entête (choix des champs, carte du modèle Qlik) —
|
||||
// c'est précisément la partie qu'un terminal tronque.
|
||||
startCapture(job.jobId, `sync fournisseur=${job.fournisseur} démarrée le ${job.startedAt}`);
|
||||
try {
|
||||
const articles = await pgGetArticlesByFournisseur(job.fournisseur);
|
||||
// Filtre strict des codes (trim, dedupe, exclusion vides/`-`/codes trop suspects).
|
||||
const { accepted: codes, rejected: codesRejected, deduped: codesDeduped } = filterCentralCodes(
|
||||
articles.map((a) => a.codeCentrale),
|
||||
);
|
||||
job.requested = codes.length;
|
||||
console.log(
|
||||
`[api/qlik/sync] job=${job.jobId} fournisseur=${job.fournisseur} — ${articles.length} articles, ${codes.length} codes centraux retenus, ${codesRejected} rejetés (vide/-/anormaux), ${codesDeduped} doublons supprimés`,
|
||||
);
|
||||
if (codes.length === 0) {
|
||||
job.status = "success";
|
||||
job.message = "Aucun code centrale valide pour ce fournisseur";
|
||||
job.finishedAt = new Date().toISOString();
|
||||
return;
|
||||
}
|
||||
|
||||
// Contrat métier strict : toujours 12 mois complets glissants.
|
||||
// Une variable d'environnement ne doit pas pouvoir raccourcir la série.
|
||||
const dateFilter = buildGridNetworkQlikDateFilter(new Date(), QLIK_MONTHS_BACK_DEFAULT);
|
||||
job.periode = dateFilter.label;
|
||||
job.dateDebut = dateFilter.dateDebut;
|
||||
job.dateFin = dateFilter.dateFin;
|
||||
console.log(
|
||||
`[api/qlik/sync] job=${job.jobId} → extraction Qlik pour ${codes.length} codes — 12 mois complets ${dateFilter.label} (${dateFilter.dateDebut} → ${dateFilter.dateFin})…`,
|
||||
);
|
||||
// Le code fournisseur est transmis en plus des codes : s'il désigne dans
|
||||
// Qlik le même périmètre (couverture vérifiée côté extracteur), une seule
|
||||
// sélection remplace les dizaines de milliers de `SelectValues` par code.
|
||||
// Les codes restent le repli et le contrôle de couverture.
|
||||
const metrics = await fetchNetworkMetricsPlaywright(codes, undefined, dateFilter, job.fournisseur);
|
||||
job.fetched = metrics.size;
|
||||
console.log(`[api/qlik/sync] job=${job.jobId} ← Qlik a renvoyé ${metrics.size} produits réseau`);
|
||||
const count = await upsertNetworkMetrics([...metrics.values()]);
|
||||
job.upserted = count;
|
||||
console.log(`[api/qlik/sync] job=${job.jobId} ${count} lignes upsert dans le cache`);
|
||||
job.status = "success";
|
||||
job.finishedAt = new Date().toISOString();
|
||||
} catch (e) {
|
||||
applyJobError(job, e);
|
||||
} finally {
|
||||
await stopCapture(job.jobId, `statut final=${job.status}${job.error ? ` — ${job.error}` : ""}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inscrit une erreur dans le job, en traduisant la saturation mémoire du
|
||||
* serveur Qlik en message actionnable (ce n'est PAS un bug CollectFlow :
|
||||
* l'app Qlik ne se charge même pas).
|
||||
*/
|
||||
function applyJobError(job: QlikSyncJob, e: unknown): void {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
console.error(`[api/qlik/sync] job=${job.jobId}`, msg);
|
||||
job.status = "error";
|
||||
const lower = msg.toLowerCase();
|
||||
if (lower.includes("out of memory") || lower.includes("not enough memory") || lower.includes("file corrupted") || lower.includes('"code":6') || lower.includes('"code":3002')) {
|
||||
job.error = "Serveur Qlik saturé : mémoire insuffisante pour charger l'application. Réessayez plus tard (ou faites libérer / augmenter la RAM du serveur Qlik).";
|
||||
} else {
|
||||
job.error = msg;
|
||||
}
|
||||
job.finishedAt = new Date().toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Extraction Qlik pour UN seul code centrale (fiche produit).
|
||||
*
|
||||
* Beaucoup plus légère que la sync fournisseur : un seul code au lieu de
|
||||
* plusieurs centaines, sur la même fenêtre 12 mois glissants. Réutilise
|
||||
* exactement le même extracteur, donc le même détail mensuel.
|
||||
*/
|
||||
async function runProductJob(job: QlikSyncJob): Promise<void> {
|
||||
startCapture(job.jobId, `sync produit=${job.codeCentrale} démarrée le ${job.startedAt}`);
|
||||
try {
|
||||
const code = job.codeCentrale!;
|
||||
job.requested = 1;
|
||||
|
||||
const dateFilter = buildGridNetworkQlikDateFilter(new Date(), QLIK_MONTHS_BACK_DEFAULT);
|
||||
job.periode = dateFilter.label;
|
||||
job.dateDebut = dateFilter.dateDebut;
|
||||
job.dateFin = dateFilter.dateFin;
|
||||
console.log(
|
||||
`[api/qlik/sync] job=${job.jobId} → extraction Qlik produit (1 code) — fenêtre ${dateFilter.label} (${dateFilter.dateDebut} → ${dateFilter.dateFin})…`,
|
||||
);
|
||||
|
||||
const metrics = await fetchNetworkMetricsPlaywright([code], undefined, dateFilter);
|
||||
job.fetched = metrics.size;
|
||||
if (metrics.size === 0) {
|
||||
job.status = "success";
|
||||
job.message = "Ce code centrale est inconnu de Qlik (aucune vente réseau sur la période)";
|
||||
job.finishedAt = new Date().toISOString();
|
||||
return;
|
||||
}
|
||||
const count = await upsertNetworkMetrics([...metrics.values()]);
|
||||
job.upserted = count;
|
||||
job.status = "success";
|
||||
job.message = "Données réseau récupérées";
|
||||
job.finishedAt = new Date().toISOString();
|
||||
console.log(`[api/qlik/sync] job=${job.jobId} produit ${code} — ${count} ligne(s) upsert`);
|
||||
} catch (e) {
|
||||
applyJobError(job, e);
|
||||
} finally {
|
||||
await stopCapture(job.jobId, `statut final=${job.status}${job.error ? ` — ${job.error}` : ""}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/qlik/sync?fournisseur=XXX ou ?codeCentrale=YYY
|
||||
* Retourne l'état courant du job pour cette cible. Si aucun job n'est
|
||||
* connu, renvoie un état "idle" (avec un message explicite). Permet au
|
||||
* frontend de reprendre l'état après un reload et de poller en arrière-plan.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
const codeCentrale = req.nextUrl.searchParams.get("codeCentrale");
|
||||
const fournisseur = req.nextUrl.searchParams.get("fournisseur");
|
||||
|
||||
// Mode produit : ouvert à tout utilisateur connecté.
|
||||
if (codeCentrale) {
|
||||
const denied = await requireSession();
|
||||
if (denied) return denied;
|
||||
const job = jobs.get(jobKeyForProduct(codeCentrale));
|
||||
if (!job) {
|
||||
return NextResponse.json({ success: true, ...idleJob("", codeCentrale) });
|
||||
}
|
||||
return NextResponse.json({ success: true, ...publicJob(job) });
|
||||
}
|
||||
|
||||
// Mode fournisseur : admin uniquement (inchangé).
|
||||
const denied = await requireAdmin();
|
||||
if (denied) return denied;
|
||||
|
||||
if (!fournisseur) {
|
||||
return NextResponse.json({ error: "Param 'fournisseur' ou 'codeCentrale' requis" }, { status: 400 });
|
||||
}
|
||||
const job = jobs.get(fournisseur);
|
||||
if (!job) {
|
||||
return NextResponse.json({ success: true, ...idleJob(fournisseur) });
|
||||
}
|
||||
return NextResponse.json({ success: true, ...publicJob(job) });
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/qlik/sync?fournisseur=XXX
|
||||
* Tire de Qlik les metriques reseau des articles du fournisseur (par code centrale)
|
||||
* et met a jour le cache local. Admin uniquement.
|
||||
* Démarre (ou réutilise si déjà running) un job d'extraction en arrière-plan
|
||||
* et retourne *immédiatement* { success, status, jobId, ... }. Le client doit
|
||||
* ensuite poller GET pour suivre l'avancement et être notifié de la fin.
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session || (session.user as { role?: string } | undefined)?.role !== "admin") {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||
const codeCentraleParam = req.nextUrl.searchParams.get("codeCentrale");
|
||||
|
||||
// ─── Mode produit : 1 code centrale, ouvert à tout utilisateur connecté ───
|
||||
if (codeCentraleParam) {
|
||||
const denied = await requireSession();
|
||||
if (denied) return denied;
|
||||
|
||||
// Même validation que la sync fournisseur (trim, rejet des vides / "-" /
|
||||
// codes hors charset) pour ne jamais envoyer de saleté à l'Engine Qlik.
|
||||
const { accepted } = filterCentralCodes([codeCentraleParam]);
|
||||
if (accepted.length === 0) {
|
||||
return NextResponse.json({ error: "Code centrale invalide" }, { status: 400 });
|
||||
}
|
||||
const code = accepted[0];
|
||||
const key = jobKeyForProduct(code);
|
||||
|
||||
const existingProduct = jobs.get(key);
|
||||
if (existingProduct && existingProduct.status === "running") {
|
||||
return NextResponse.json({ success: true, ...publicJob(existingProduct) });
|
||||
}
|
||||
|
||||
if (runningProductJobs() >= MAX_CONCURRENT_PRODUCT_JOBS) {
|
||||
return NextResponse.json(
|
||||
{ error: "Trop d'extractions Qlik en cours. Réessayez dans quelques instants." },
|
||||
{ status: 429 },
|
||||
);
|
||||
}
|
||||
|
||||
const productJob: QlikSyncJob = {
|
||||
jobId: newJobId(),
|
||||
fournisseur: "",
|
||||
codeCentrale: code,
|
||||
status: "running",
|
||||
requested: 0,
|
||||
fetched: 0,
|
||||
upserted: 0,
|
||||
startedAt: new Date().toISOString(),
|
||||
};
|
||||
jobs.set(key, productJob);
|
||||
console.log(`[api/qlik/sync] job=${productJob.jobId} démarré pour codeCentrale=${code}`);
|
||||
|
||||
void runProductJob(productJob).catch((e) => applyJobError(productJob, e));
|
||||
|
||||
return NextResponse.json({ success: true, ...publicJob(productJob) });
|
||||
}
|
||||
|
||||
// ─── Mode fournisseur : admin uniquement (inchangé) ──────────────────────
|
||||
const denied = await requireAdmin();
|
||||
if (denied) return denied;
|
||||
|
||||
const fournisseur = req.nextUrl.searchParams.get("fournisseur");
|
||||
if (!fournisseur) {
|
||||
return NextResponse.json({ error: "Param 'fournisseur' requis" }, { status: 400 });
|
||||
return NextResponse.json({ error: "Param 'fournisseur' ou 'codeCentrale' requis" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
// Codes centraux des articles de ce fournisseur
|
||||
const articles = await pgGetArticlesByFournisseur(fournisseur);
|
||||
const codes = [
|
||||
...new Set(
|
||||
articles
|
||||
.map((a) => (a.codeCentrale ? String(a.codeCentrale).trim() : ""))
|
||||
.filter(Boolean),
|
||||
),
|
||||
];
|
||||
if (codes.length === 0) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
fournisseur,
|
||||
fetched: 0,
|
||||
upserted: 0,
|
||||
message: "Aucun article avec code centrale pour ce fournisseur",
|
||||
fetchedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
const existing = jobs.get(fournisseur);
|
||||
if (existing && existing.status === "running") {
|
||||
// Un job tourne déjà pour ce fournisseur : on le réutilise.
|
||||
return NextResponse.json({ success: true, ...publicJob(existing) });
|
||||
}
|
||||
|
||||
const metrics = await fetchNetworkMetrics(codes);
|
||||
const count = await upsertNetworkMetrics([...metrics.values()]);
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
fournisseur,
|
||||
requested: codes.length,
|
||||
fetched: metrics.size,
|
||||
upserted: count,
|
||||
fetchedAt: new Date().toISOString(),
|
||||
});
|
||||
} catch (e) {
|
||||
const job: QlikSyncJob = {
|
||||
jobId: newJobId(),
|
||||
fournisseur,
|
||||
status: "running",
|
||||
requested: 0,
|
||||
fetched: 0,
|
||||
upserted: 0,
|
||||
startedAt: new Date().toISOString(),
|
||||
};
|
||||
jobs.set(fournisseur, job);
|
||||
console.log(`[api/qlik/sync] job=${job.jobId} démarré pour fournisseur=${fournisseur}`);
|
||||
|
||||
// Lancement fire-and-forget. On ne `await` PAS l'extraction ici : on
|
||||
// renvoie tout de suite au client et il polle GET. Les erreurs sont
|
||||
// capturées dans runJob et inscrites dans le job.
|
||||
void runJob(job).catch((e) => {
|
||||
// Filet de sécurité (runJob ne devrait pas throw, mais on ne veut
|
||||
// jamais qu'une exception non capturée crashe le process).
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
console.error("[api/qlik/sync]", msg);
|
||||
return NextResponse.json({ success: false, error: msg }, { status: 500 });
|
||||
}
|
||||
console.error(`[api/qlik/sync] job=${job.jobId} crash non géré`, msg);
|
||||
job.status = "error";
|
||||
job.error = msg;
|
||||
job.finishedAt = new Date().toISOString();
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true, ...publicJob(job) });
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { requireApiAuth } from "@/lib/api-auth";
|
||||
import { ok, fail } from "@/lib/api-response";
|
||||
import { fournisseursQuerySchema } from "@/lib/api-schemas";
|
||||
import { listGridSuppliers } from "@/lib/grid-store";
|
||||
import { pgGetFournisseurs } from "@/lib/pg-ff-client";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* GET /api/v1/fournisseurs?search=…&withData=1
|
||||
*
|
||||
* Liste des fournisseurs. Par défaut, tout le référentiel (`fouident`), enrichi de la
|
||||
* fraîcheur de l'instantané de grille quand il existe — ce qui permet à l'appelant de
|
||||
* savoir d'avance quels fournisseurs `/api/v1/grid` peut servir immédiatement.
|
||||
*
|
||||
* `withData=1` restreint aux seuls fournisseurs déjà présents dans l'instantané.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
const authCtx = await requireApiAuth(req);
|
||||
if (authCtx instanceof Response) return authCtx;
|
||||
|
||||
const parsed = fournisseursQuerySchema.safeParse(Object.fromEntries(req.nextUrl.searchParams));
|
||||
if (!parsed.success) {
|
||||
return fail("bad_request", "Paramètres invalides.", parsed.error.issues);
|
||||
}
|
||||
const { search, withData } = parsed.data;
|
||||
|
||||
const snapshot = await listGridSuppliers();
|
||||
const byCode = new Map(snapshot.map((s) => [s.codeFournisseur, s]));
|
||||
|
||||
if (withData === "1") {
|
||||
const term = search?.toLowerCase();
|
||||
const data = snapshot
|
||||
.filter((s) => !term
|
||||
|| s.codeFournisseur.toLowerCase().includes(term)
|
||||
|| (s.nomFournisseur ?? "").toLowerCase().includes(term))
|
||||
.map((s) => ({
|
||||
code: s.codeFournisseur,
|
||||
nom: s.nomFournisseur,
|
||||
hasSnapshot: true,
|
||||
rowCount: s.rowCount,
|
||||
computedAt: s.computedAt,
|
||||
}));
|
||||
return ok(data, { meta: { total: data.length, scope: "instantané uniquement" } });
|
||||
}
|
||||
|
||||
const fournisseurs = await pgGetFournisseurs(search);
|
||||
const data = fournisseurs.map((f) => {
|
||||
const snap = byCode.get(f.code);
|
||||
return {
|
||||
code: f.code,
|
||||
nom: f.nom,
|
||||
hasSnapshot: Boolean(snap),
|
||||
rowCount: snap?.rowCount ?? 0,
|
||||
computedAt: snap?.computedAt ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
return ok(data, { meta: { total: data.length, withSnapshot: byCode.size } });
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { requireApiAuth } from "@/lib/api-auth";
|
||||
import { ok, fail, buildPagination } from "@/lib/api-response";
|
||||
import { gridQuerySchema, pickFields, toSortKey } from "@/lib/api-schemas";
|
||||
import { queryGridRows, getGridFreshness } from "@/lib/grid-store";
|
||||
import { enrichRows } from "@/lib/api-enrich";
|
||||
import { getProductRows } from "@/features/grid/api/get-product-rows";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
// Le calcul à la demande (premier appel sur un fournisseur inconnu) enchaîne
|
||||
// plusieurs requêtes SQL sur la base miroir FF : on laisse de la marge.
|
||||
export const maxDuration = 300;
|
||||
|
||||
/**
|
||||
* GET /api/v1/grid?fournisseur=…&gamme=&code1..3=&search=&sort=&order=&page=&limit=&fields=&compute=
|
||||
*
|
||||
* Lignes de grille d'un fournisseur, filtrées / triées / paginées **en SQL** depuis
|
||||
* l'instantané persisté (`grid_rows`).
|
||||
*
|
||||
* Si le fournisseur n'a pas encore d'instantané, l'endpoint le **calcule à la demande**
|
||||
* (`compute=1`, défaut) : sans cela, un appelant externe ne pourrait consulter que les
|
||||
* fournisseurs déjà ouverts dans la Grille par un humain. Le calcul est protégé par le
|
||||
* cache et le verrou anti-concurrence de `getProductRows()`, et il persiste l'instantané
|
||||
* au passage — les appels suivants repassent donc par le chemin rapide.
|
||||
*
|
||||
* `compute=0` restaure le comportement strict : échec immédiat en `202 not_ready`.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
const authCtx = await requireApiAuth(req);
|
||||
if (authCtx instanceof Response) return authCtx;
|
||||
|
||||
const parsed = gridQuerySchema.safeParse(Object.fromEntries(req.nextUrl.searchParams));
|
||||
if (!parsed.success) {
|
||||
return fail("bad_request", "Paramètres invalides.", parsed.error.issues);
|
||||
}
|
||||
const q = parsed.data;
|
||||
|
||||
// Distingue « fournisseur jamais calculé » de « filtres sans résultat » (200 vide).
|
||||
let freshness = await getGridFreshness(q.fournisseur);
|
||||
let computedOnDemand = false;
|
||||
|
||||
if (!freshness) {
|
||||
if (q.compute === "0") {
|
||||
return fail(
|
||||
"not_ready",
|
||||
`Aucun instantané pour le fournisseur « ${q.fournisseur} ». Relancez sans « compute=0 » pour le calculer à la demande.`,
|
||||
{ fournisseur: q.fournisseur },
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`[api/v1/grid] instantané absent pour ${q.fournisseur} — calcul à la demande (via ${authCtx.via})`);
|
||||
try {
|
||||
// magasin TOTAL : c'est la seule variante persistée par getProductRows,
|
||||
// et ProductRow embarque déjà les ventilations par magasin.
|
||||
await getProductRows({ codeFournisseur: q.fournisseur, magasin: "TOTAL" });
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
console.error(`[api/v1/grid] calcul à la demande KO pour ${q.fournisseur}:`, msg);
|
||||
return fail(
|
||||
"internal_error",
|
||||
`Le calcul de la grille a échoué pour le fournisseur « ${q.fournisseur} ».`,
|
||||
{ fournisseur: q.fournisseur },
|
||||
);
|
||||
}
|
||||
|
||||
freshness = await getGridFreshness(q.fournisseur);
|
||||
if (!freshness) {
|
||||
// Calcul réussi mais aucune ligne : le code fournisseur n'existe pas, ou
|
||||
// il n'a aucun article. À distinguer d'une panne.
|
||||
return fail(
|
||||
"not_found",
|
||||
`Aucun article pour le fournisseur « ${q.fournisseur} ». Vérifiez le code auprès de /api/v1/fournisseurs.`,
|
||||
{ fournisseur: q.fournisseur },
|
||||
);
|
||||
}
|
||||
computedOnDemand = true;
|
||||
}
|
||||
|
||||
const filtres = {
|
||||
codeFournisseur: q.fournisseur,
|
||||
gamme: q.gamme,
|
||||
code1: q.code1,
|
||||
code2: q.code2,
|
||||
code3: q.code3,
|
||||
nomenclature: q.nomenclature,
|
||||
search: q.search,
|
||||
sort: toSortKey(q.sort),
|
||||
order: q.order,
|
||||
};
|
||||
|
||||
// Sans `limit`, la réponse peut porter tout un fournisseur — et certains en
|
||||
// comptent plus de 130 000. Tout charger puis tout sérialiser d'un bloc ferait
|
||||
// exploser la mémoire du processus : on diffuse par lots, à mémoire constante.
|
||||
if (q.limit == null) {
|
||||
return streamAllRows(req, filtres, q, freshness, computedOnDemand);
|
||||
}
|
||||
|
||||
const result = await queryGridRows({ ...filtres, page: q.page, limit: q.limit });
|
||||
|
||||
// Métriques Qlik et gamme serveur relues au moment de l'appel (voir api-enrich).
|
||||
const rows = q.enrich === "1" ? await enrichRows(result.rows) : result.rows;
|
||||
|
||||
const pagination = buildPagination(q.page, q.limit, result.total);
|
||||
|
||||
return ok(
|
||||
rows.map((r) => pickFields(r, q.fields)),
|
||||
{
|
||||
pagination,
|
||||
meta: {
|
||||
fournisseur: q.fournisseur,
|
||||
computedAt: result.computedAt,
|
||||
snapshotComputedAt: freshness.computedAt,
|
||||
snapshotRowCount: freshness.rowCount,
|
||||
enrichi: q.enrich === "1",
|
||||
/** true = l'instantané n'existait pas et vient d'être calculé par cet appel. */
|
||||
computedOnDemand,
|
||||
/**
|
||||
* true = cette réponse contient **toutes** les lignes du fournisseur.
|
||||
* Dit explicitement à un appelant — en particulier un agent — s'il
|
||||
* peut s'arrêter là, au lieu de le laisser déduire d'une pagination
|
||||
* qu'il risque d'ignorer.
|
||||
*/
|
||||
complet: !pagination.hasMore,
|
||||
...(pagination.hasMore
|
||||
? {
|
||||
avertissement:
|
||||
`Réponse partielle : ${rows.length} lignes sur ${result.total}, parce que « limit » a été précisé. `
|
||||
+ `Retirez « limit » pour obtenir tout le fournisseur en un appel, ou passez à page=${q.page + 1}.`,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** Lots internes : compromis entre nombre d'allers-retours SQL et mémoire retenue. */
|
||||
const STREAM_BATCH = 1000;
|
||||
|
||||
type Filtres = Parameters<typeof queryGridRows>[0];
|
||||
|
||||
/**
|
||||
* Diffuse **toutes** les lignes du fournisseur en une seule réponse JSON, sans
|
||||
* jamais la construire entièrement en mémoire.
|
||||
*
|
||||
* La forme reste celle des autres réponses (`{ data, pagination, meta }`) : un
|
||||
* client existant ne voit aucune différence, il reçoit simplement les octets au
|
||||
* fil de l'eau. `pagination` et `meta` sont écrits en dernier, une fois le total
|
||||
* connu — l'ordre des clés n'a aucune importance en JSON.
|
||||
*
|
||||
* Une erreur survenant après les premiers octets ne peut plus changer le code
|
||||
* HTTP : on ferme alors le document avec `meta.erreur`, pour que l'appelant
|
||||
* détecte une réponse tronquée au lieu de la croire complète.
|
||||
*/
|
||||
function streamAllRows(
|
||||
req: NextRequest,
|
||||
filtres: Filtres,
|
||||
q: { enrich: string; fields?: string; fournisseur: string },
|
||||
freshness: { rowCount: number; computedAt: string | null },
|
||||
computedOnDemand: boolean,
|
||||
): Response {
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
async start(controller) {
|
||||
const write = (s: string) => controller.enqueue(encoder.encode(s));
|
||||
let envoyees = 0;
|
||||
let total = 0;
|
||||
let computedAt: string | null = null;
|
||||
let erreur: string | null = null;
|
||||
|
||||
write('{"data":[');
|
||||
try {
|
||||
for (let page = 1; ; page++) {
|
||||
const lot = await queryGridRows({ ...filtres, page, limit: STREAM_BATCH });
|
||||
if (page === 1) {
|
||||
total = lot.total;
|
||||
computedAt = lot.computedAt;
|
||||
}
|
||||
if (lot.rows.length === 0) break;
|
||||
|
||||
const enrichies = q.enrich === "1" ? await enrichRows(lot.rows) : lot.rows;
|
||||
for (const r of enrichies) {
|
||||
write((envoyees === 0 ? "" : ",") + JSON.stringify(pickFields(r, q.fields)));
|
||||
envoyees++;
|
||||
}
|
||||
if (lot.rows.length < STREAM_BATCH) break;
|
||||
}
|
||||
} catch (e) {
|
||||
erreur = e instanceof Error ? e.message : String(e);
|
||||
console.error(`[api/v1/grid] diffusion interrompue pour ${q.fournisseur} après ${envoyees} lignes:`, erreur);
|
||||
}
|
||||
|
||||
const complet = erreur === null && envoyees === total;
|
||||
write("]," + JSON.stringify({
|
||||
pagination: { page: 1, limit: envoyees, total, totalPages: 1, hasMore: !complet },
|
||||
meta: {
|
||||
fournisseur: q.fournisseur,
|
||||
computedAt,
|
||||
snapshotComputedAt: freshness.computedAt,
|
||||
snapshotRowCount: freshness.rowCount,
|
||||
enrichi: q.enrich === "1",
|
||||
computedOnDemand,
|
||||
diffuse: true,
|
||||
complet,
|
||||
...(erreur
|
||||
? { erreur: `Diffusion interrompue après ${envoyees} lignes sur ${total} : ${erreur}` }
|
||||
: {}),
|
||||
},
|
||||
}).slice(1));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"Cache-Control": "no-store",
|
||||
// Empêche un proxy de tamponner la réponse entière avant de la relayer.
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { requireApiAuth } from "@/lib/api-auth";
|
||||
import { ok, fail } from "@/lib/api-response";
|
||||
import { getNetworkMetricsByCodeCentrale } from "@/lib/qlik-network-cache";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* GET /api/v1/network/:codeCentrale
|
||||
*
|
||||
* Métriques réseau Qlik d'un produit (CA, quantité, nombre de magasins, CA/magasin,
|
||||
* marge %) et sa courbe de quantités mensuelles sur 12 mois glissants.
|
||||
*
|
||||
* Lecture **exclusive** de la table `qlik_network_metrics` : aucun appel à Qlik n'est
|
||||
* déclenché. Pour interroger Qlik en direct (y compris sur des produits que nous ne
|
||||
* référençons pas), c'est la page Produits — bien plus lente, et volontairement
|
||||
* séparé de cette API.
|
||||
*/
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
ctx: { params: Promise<{ codeCentrale: string }> },
|
||||
) {
|
||||
const authCtx = await requireApiAuth(req);
|
||||
if (authCtx instanceof Response) return authCtx;
|
||||
|
||||
const { codeCentrale } = await ctx.params;
|
||||
if (!codeCentrale) return fail("bad_request", "Paramètre 'codeCentrale' requis.");
|
||||
|
||||
const metrics = await getNetworkMetricsByCodeCentrale([codeCentrale]);
|
||||
const found = metrics.get(codeCentrale);
|
||||
if (!found) {
|
||||
return fail(
|
||||
"not_found",
|
||||
`Aucune donnée réseau en cache pour le code centrale « ${codeCentrale} ». Lancez une synchronisation Qlik ou une recherche réseau.`,
|
||||
);
|
||||
}
|
||||
|
||||
return ok({ codeCentrale, ...found }, { meta: { fetchedAt: found.fetchedAt, source: "cache" } });
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { requireApiAuth } from "@/lib/api-auth";
|
||||
import { ok, fail } from "@/lib/api-response";
|
||||
import { nomenclaturesQuerySchema } from "@/lib/api-schemas";
|
||||
import { listGridNomenclatures, getGridFreshness } from "@/lib/grid-store";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* GET /api/v1/nomenclatures?fournisseur=…&niveau=1|2|3&parent=…
|
||||
*
|
||||
* Répartition des articles d'un fournisseur par poste de nomenclature, avec le
|
||||
* nombre d'articles et le CA de chacun.
|
||||
*
|
||||
* C'est la porte d'entrée pour traiter un gros fournisseur : certains dépassent
|
||||
* 130 000 articles, et tout récupérer d'un coup n'a de sens ni pour une IA ni
|
||||
* pour une application. On liste d'abord les postes — quelques dizaines de
|
||||
* lignes — puis on interroge `/grid?fournisseur=…&nomenclature=…` poste par poste.
|
||||
*
|
||||
* Les codes sont hiérarchiques par préfixe (6 chiffres, de 31xxxx à 40xxxx) :
|
||||
* niveau 1 = « 32 », niveau 2 = « 3202 », niveau 3 = « 320211 ». `parent` accepte
|
||||
* un préfixe pour descendre l'arborescence sans tout charger.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
const authCtx = await requireApiAuth(req);
|
||||
if (authCtx instanceof Response) return authCtx;
|
||||
|
||||
const parsed = nomenclaturesQuerySchema.safeParse(Object.fromEntries(req.nextUrl.searchParams));
|
||||
if (!parsed.success) {
|
||||
return fail("bad_request", "Paramètres invalides.", parsed.error.issues);
|
||||
}
|
||||
const { fournisseur, niveau, parent } = parsed.data;
|
||||
|
||||
// Même distinction que /grid : « pas encore calculé » n'est pas « vide ».
|
||||
const freshness = await getGridFreshness(fournisseur);
|
||||
if (!freshness) {
|
||||
return fail(
|
||||
"not_ready",
|
||||
`Aucun instantané pour le fournisseur « ${fournisseur} ». Appelez /api/v1/grid?fournisseur=${fournisseur} une fois pour le calculer.`,
|
||||
{ fournisseur },
|
||||
);
|
||||
}
|
||||
|
||||
const postes = await listGridNomenclatures(fournisseur, niveau, parent);
|
||||
const nbArticles = postes.reduce((s, p) => s + p.nbArticles, 0);
|
||||
|
||||
return ok(postes, {
|
||||
meta: {
|
||||
fournisseur,
|
||||
niveau,
|
||||
parent: parent ?? null,
|
||||
nbPostes: postes.length,
|
||||
/** Total couvert par les postes listés — à rapprocher de snapshotRowCount. */
|
||||
nbArticles,
|
||||
snapshotRowCount: freshness.rowCount,
|
||||
snapshotComputedAt: freshness.computedAt,
|
||||
/**
|
||||
* Le paramètre de /grid à utiliser avec les `code` renvoyés ci-dessus.
|
||||
* `nomenclature` filtre par préfixe et fonctionne aux trois niveaux.
|
||||
*/
|
||||
filtreGrid: "nomenclature",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { GRID_SORT_KEYS } from "@/lib/grid-store";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* GET /api/v1/openapi.json — spécification OpenAPI 3.1 de l'API CollectFlow.
|
||||
*
|
||||
* **Volontairement accessible sans authentification.** Le document ne décrit que la
|
||||
* structure de l'API (chemins, paramètres, schémas) et ne contient aucune donnée
|
||||
* métier ; or ChatGPT et les autres plateformes d'agents importent le schéma par URL,
|
||||
* avant même que la clé ne soit configurée. Exiger une clé ici rendrait l'import
|
||||
* impossible.
|
||||
*
|
||||
* `servers` est construit depuis l'hôte appelant (ou COLLECTFLOW_PUBLIC_URL) : les
|
||||
* Actions ChatGPT exigent une URL **absolue**, une URL relative étant rejetée.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
const configured = process.env.COLLECTFLOW_PUBLIC_URL?.trim().replace(/\/+$/, "");
|
||||
const origin = configured || req.nextUrl.origin;
|
||||
|
||||
const paginationParams = [
|
||||
{ name: "page", in: "query", schema: { type: "integer", minimum: 1, default: 1 }, description: "Numéro de page." },
|
||||
{ name: "limit", in: "query", schema: { type: "integer", minimum: 1 }, description: "Lignes par page, sans plafond. Sur /grid, OMETTRE ce paramètre renvoie toutes les lignes du fournisseur en un seul appel — c'est le mode recommandé. Sur les autres endpoints, le défaut est 100." },
|
||||
];
|
||||
const sortParams = [
|
||||
{ name: "sort", in: "query", schema: { type: "string", enum: GRID_SORT_KEYS }, description: "Colonne de tri." },
|
||||
{ name: "order", in: "query", schema: { type: "string", enum: ["asc", "desc"], default: "desc" }, description: "Sens du tri." },
|
||||
{
|
||||
name: "fields",
|
||||
in: "query",
|
||||
schema: { type: "string" },
|
||||
description:
|
||||
"Colonnes à retourner, séparées par des virgules (ex. « codein,libelle1,totalCa »). "
|
||||
+ "À utiliser systématiquement : une ligne complète contient les séries mensuelles et les "
|
||||
+ "ventilations par magasin, ce qui est très volumineux. `codein` est toujours inclus.",
|
||||
},
|
||||
{
|
||||
name: "enrich",
|
||||
in: "query",
|
||||
schema: { type: "string", enum: ["0", "1"], default: "1" },
|
||||
description:
|
||||
"1 (défaut) : relit les métriques réseau Qlik (`network`) et la gamme serveur "
|
||||
+ "(`codeGammeServeur`) au moment de l'appel. 0 : sert l'instantané brut.",
|
||||
},
|
||||
];
|
||||
|
||||
const spec = {
|
||||
openapi: "3.1.0",
|
||||
info: {
|
||||
title: "CollectFlow API",
|
||||
version: "1.0.0",
|
||||
description:
|
||||
"Lecture des données de la grille CollectFlow : ventes sur 12 mois par magasin, stock, marges, "
|
||||
+ "gammes et métriques du réseau Qlik (~270 magasins Foir'Fouille), plus la recherche de produits.\n\n"
|
||||
+ "**N'importe quel fournisseur peut être interrogé directement.** Les réponses sont servies "
|
||||
+ "depuis un instantané persisté ; si le fournisseur demandé n'en a pas encore, `/grid` le calcule "
|
||||
+ "à la demande. Ce premier appel prend alors plusieurs secondes, les suivants sont immédiats. "
|
||||
+ "Passez `compute=0` pour refuser ce calcul et obtenir un `202 not_ready` immédiat.\n\n"
|
||||
+ "**Aucun endpoint ne contacte Qlik en direct** : les métriques réseau viennent d'un cache "
|
||||
+ "alimenté par une synchronisation séparée.\n\n"
|
||||
+ "Deux informations sont relues à chaque appel car elles évoluent indépendamment : les métriques "
|
||||
+ "réseau Qlik (`network`, `null` s'il n'y en a pas) et la gamme serveur non modifiée "
|
||||
+ "(`codeGammeServeur`).",
|
||||
},
|
||||
servers: [{ url: `${origin}/api/v1`, description: "API CollectFlow" }],
|
||||
security: [{ ApiKeyAuth: [] }],
|
||||
components: {
|
||||
securitySchemes: {
|
||||
ApiKeyAuth: { type: "apiKey", in: "header", name: "X-API-Key" },
|
||||
},
|
||||
schemas: {
|
||||
Error: {
|
||||
type: "object",
|
||||
properties: {
|
||||
error: {
|
||||
type: "object",
|
||||
properties: {
|
||||
code: {
|
||||
type: "string",
|
||||
enum: ["unauthorized", "forbidden", "bad_request", "not_found", "not_ready", "internal_error"],
|
||||
},
|
||||
message: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Pagination: {
|
||||
type: "object",
|
||||
properties: {
|
||||
page: { type: "integer" },
|
||||
limit: { type: "integer" },
|
||||
total: { type: "integer" },
|
||||
totalPages: { type: "integer" },
|
||||
hasMore: { type: "boolean", description: "true = il reste des pages. Sur /grid, meta.complet dit la même chose en positif." },
|
||||
},
|
||||
},
|
||||
Fournisseur: {
|
||||
type: "object",
|
||||
properties: {
|
||||
code: { type: "string", description: "Code fournisseur, à passer au paramètre `fournisseur`." },
|
||||
nom: { type: ["string", "null"] },
|
||||
hasSnapshot: { type: "boolean", description: "false = données pas encore disponibles pour ce fournisseur." },
|
||||
rowCount: { type: "integer" },
|
||||
computedAt: { type: ["string", "null"], format: "date-time" },
|
||||
},
|
||||
},
|
||||
NetworkMetrics: {
|
||||
type: ["object", "null"],
|
||||
description: "Métriques du réseau Qlik. `null` quand le produit n'a pas de données réseau.",
|
||||
properties: {
|
||||
caReseau: { type: "number" },
|
||||
qteReseau: { type: "number" },
|
||||
nbMagasinsReseau: { type: "integer", description: "Nombre de magasins du réseau vendant le produit (sur ~270)." },
|
||||
caParMagasinReseau: { type: "number", description: "CA moyen par magasin : normalise la présence, préférable au CA brut pour comparer." },
|
||||
margePctReseau: { type: "number", description: "Taux de marge, ratio brut (0.32 = 32 %)." },
|
||||
qteByMonth: { type: ["object", "null"], description: "Quantités vendues par mois, clés « YYYY-MM »." },
|
||||
fetchedAt: { type: ["string", "null"], format: "date-time" },
|
||||
},
|
||||
},
|
||||
Product: {
|
||||
type: "object",
|
||||
description:
|
||||
"Ligne de grille : un article chez un fournisseur. La liste de propriétés ci-dessous "
|
||||
+ "n'est pas exhaustive — la réponse contient l'intégralité de la ligne (une soixantaine "
|
||||
+ "de champs). Utilisez `fields` pour n'en garder qu'une partie.",
|
||||
properties: {
|
||||
codein: { type: "string", description: "Identifiant interne de l'article." },
|
||||
codeFournisseur: { type: "string" },
|
||||
nomFournisseur: { type: "string" },
|
||||
libelle1: { type: "string", description: "Libellé de l'article." },
|
||||
gtin: { type: "string" },
|
||||
reference: { type: "string" },
|
||||
codeCentrale: { type: "string", description: "Code centrale — clé de jointure avec le réseau Qlik." },
|
||||
codeGammeServeur: {
|
||||
type: ["string", "null"],
|
||||
description:
|
||||
"Gamme **non modifiée**, telle qu'elle existe en base. C'est celle à utiliser pour "
|
||||
+ "raisonner. `null` = aucune gamme. Valeurs : A (pilier), B (bonne rotation), "
|
||||
+ "C (performance), D (saisonnier/niche), Z (sortie).",
|
||||
},
|
||||
codeGamme: { type: ["string", "null"], description: "Gamme courante, éventuellement surchargée par une modification locale non enregistrée." },
|
||||
totalCa: { type: "number", description: "CA sur 12 mois, nos magasins." },
|
||||
totalQuantite: { type: "number" },
|
||||
totalMarge: { type: "number" },
|
||||
tauxMarge: { type: "number" },
|
||||
stockActuel: { type: "number" },
|
||||
pcb: { type: "number", description: "Conditionnement (nombre d'unités par colis)." },
|
||||
prixAchat: { type: "number" },
|
||||
prixVente: { type: "number" },
|
||||
derniereVente: { type: "string", format: "date" },
|
||||
nbJoursDerniereVente: { type: "integer" },
|
||||
sales12m: { type: "object", description: "Quantités vendues par mois, clés « YYYYMM »." },
|
||||
stock12m: { type: "object", description: "Stock de fin de mois, clés « YYYYMM »." },
|
||||
receptions12m: { type: "object", description: "Quantités reçues par mois, clés « YYYYMM »." },
|
||||
sales12mByStore: { type: "object", description: "Ventes par magasin (292, 579) puis par mois." },
|
||||
stock12mByStore: { type: "object", description: "Stock de fin de mois par magasin puis par mois." },
|
||||
receptions12mByStore: { type: "object", description: "Réceptions par magasin puis par mois." },
|
||||
qteReseauByMonth: { type: ["object", "null"], description: "Quantités vendues par le réseau, clés « YYYY-MM » — série derrière la tendance." },
|
||||
network: { $ref: "#/components/schemas/NetworkMetrics" },
|
||||
trend: { $ref: "#/components/schemas/Trend" },
|
||||
},
|
||||
},
|
||||
Trend: {
|
||||
type: ["object", "null"],
|
||||
description:
|
||||
"Tendance réseau sur 12 mois, par régression linéaire — la même que la colonne "
|
||||
+ "« Tendance » de l'application. `null` si aucune série mensuelle n'est disponible.",
|
||||
properties: {
|
||||
direction: { type: "string", enum: ["up", "down", "flat"], description: "Seuil à ±8 % de variation modélisée." },
|
||||
pct: { type: ["number", "null"], description: "Variation modélisée sur la période (0.23 = +23 %)." },
|
||||
label: { type: "string", description: "Libellé prêt à afficher : « Forte hausse », « Stable »…" },
|
||||
monthsUsed: { type: "integer", description: "Nombre de mois réellement utilisés (12 au maximum)." },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
paths: {
|
||||
"/fournisseurs": {
|
||||
get: {
|
||||
operationId: "listerFournisseurs",
|
||||
summary: "Lister les fournisseurs",
|
||||
description:
|
||||
"Renvoie les fournisseurs et indique, via `hasSnapshot`, lesquels ont des données "
|
||||
+ "immédiatement disponibles. À appeler en premier quand le code fournisseur est inconnu.",
|
||||
parameters: [
|
||||
{ name: "search", in: "query", schema: { type: "string" }, description: "Filtre sur le nom ou le code." },
|
||||
{ name: "withData", in: "query", schema: { type: "string", enum: ["0", "1"] }, description: "1 = uniquement ceux dont les données sont disponibles." },
|
||||
],
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Liste des fournisseurs",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
data: { type: "array", items: { $ref: "#/components/schemas/Fournisseur" } },
|
||||
meta: { type: "object" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"401": { description: "Clé d'API absente, invalide ou révoquée", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
"/nomenclatures": {
|
||||
get: {
|
||||
operationId: "listerNomenclaturesFournisseur",
|
||||
summary: "Postes de nomenclature d'un fournisseur",
|
||||
description:
|
||||
"Répartition des articles d'un fournisseur par poste de nomenclature, avec le nombre "
|
||||
+ "d'articles et le CA de chacun. **À utiliser avant /grid sur un gros fournisseur** : "
|
||||
+ "certains dépassent 130 000 articles. On liste les postes (quelques dizaines de lignes), "
|
||||
+ "puis on interroge /grid poste par poste avec le filtre indiqué par `meta.filtreGrid` "
|
||||
+ "(code1, code2 ou code3).",
|
||||
parameters: [
|
||||
{ name: "fournisseur", in: "query", required: true, schema: { type: "string" } },
|
||||
{ name: "niveau", in: "query", schema: { type: "integer", enum: [1, 2, 3], default: 1 }, description: "1 = univers, 2 = famille, 3 = sous-famille." },
|
||||
{ name: "parent", in: "query", schema: { type: "string" }, description: "Préfixe du poste parent, ex. « 32 » pour lister ses familles." },
|
||||
],
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Postes de nomenclature",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
data: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
code: { type: "string", description: "À passer au filtre code1/code2/code3 de /grid." },
|
||||
libelle: { type: ["string", "null"] },
|
||||
nbArticles: { type: "integer" },
|
||||
totalCa: { type: "number" },
|
||||
totalQuantite: { type: "number" },
|
||||
},
|
||||
},
|
||||
},
|
||||
meta: { type: "object" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"202": { description: "Fournisseur pas encore calculé", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
|
||||
"400": { description: "Paramètres invalides", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
"/grid": {
|
||||
get: {
|
||||
operationId: "listerProduitsFournisseur",
|
||||
summary: "Lister les produits d'un fournisseur",
|
||||
description:
|
||||
"Lignes de grille d'un fournisseur : ventes 12 mois, stock, marges, gammes et métriques "
|
||||
+ "réseau. Fonctionne pour **n'importe quel** fournisseur : si ses données n'ont jamais été "
|
||||
+ "calculées, l'API les calcule à la demande (premier appel de quelques secondes, "
|
||||
+ "`meta.computedOnDemand = true`). Un `404` signifie que le code fournisseur n'existe pas "
|
||||
+ "ou n'a aucun article — le signaler plutôt que de conclure à une panne.",
|
||||
parameters: [
|
||||
{ name: "fournisseur", in: "query", required: true, schema: { type: "string" }, description: "Code fournisseur (voir listerFournisseurs)." },
|
||||
{ name: "gamme", in: "query", schema: { type: "string" }, description: "Filtre sur la gamme (A, B, C, D, Z)." },
|
||||
{ name: "code1", in: "query", schema: { type: "string" }, description: "Filtre nomenclature niveau 1." },
|
||||
{ name: "code2", in: "query", schema: { type: "string" }, description: "Filtre nomenclature niveau 2." },
|
||||
{ name: "code3", in: "query", schema: { type: "string" }, description: "Filtre nomenclature niveau 3 (code exact)." },
|
||||
{ name: "nomenclature", in: "query", schema: { type: "string" }, description: "Préfixe de nomenclature renvoyé par listerNomenclaturesFournisseur : 32, 3202 ou 320211. À préférer à code1/code2, qui peuvent être vides." },
|
||||
{ name: "search", in: "query", schema: { type: "string" }, description: "Filtre texte : libellé, codein, GTIN, référence ou code centrale." },
|
||||
{
|
||||
name: "compute",
|
||||
in: "query",
|
||||
schema: { type: "string", enum: ["0", "1"], default: "1" },
|
||||
description:
|
||||
"1 (défaut) : calcule les données du fournisseur si elles n'existent pas encore. "
|
||||
+ "0 : refuse le calcul et répond immédiatement 202 not_ready.",
|
||||
},
|
||||
...sortParams,
|
||||
...paginationParams,
|
||||
],
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Produits du fournisseur",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
data: { type: "array", items: { $ref: "#/components/schemas/Product" } },
|
||||
pagination: { $ref: "#/components/schemas/Pagination" },
|
||||
meta: { type: "object" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"202": { description: "Données pas encore calculées (uniquement avec compute=0)", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
|
||||
"400": { description: "Paramètres invalides", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
|
||||
"401": { description: "Clé d'API absente, invalide ou révoquée", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
|
||||
"404": { description: "Fournisseur inconnu ou sans article", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
"/products/search": {
|
||||
get: {
|
||||
operationId: "rechercherProduits",
|
||||
summary: "Rechercher des produits, tous fournisseurs confondus",
|
||||
description:
|
||||
"Recherche sur le libellé, le codein, le GTIN, la référence et le code centrale, sans "
|
||||
+ "avoir à connaître le fournisseur. À utiliser dès qu'un produit est désigné par son nom.",
|
||||
parameters: [
|
||||
{ name: "q", in: "query", required: true, schema: { type: "string", maxLength: 120 }, description: "Termes recherchés." },
|
||||
{ name: "fournisseur", in: "query", schema: { type: "string" }, description: "Restreint à un fournisseur." },
|
||||
{ name: "gamme", in: "query", schema: { type: "string" }, description: "Filtre sur la gamme." },
|
||||
...sortParams,
|
||||
...paginationParams,
|
||||
],
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Produits correspondants",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
data: { type: "array", items: { $ref: "#/components/schemas/Product" } },
|
||||
pagination: { $ref: "#/components/schemas/Pagination" },
|
||||
meta: { type: "object" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"401": { description: "Clé d'API absente, invalide ou révoquée", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
"/products/{codein}": {
|
||||
get: {
|
||||
operationId: "obtenirProduit",
|
||||
summary: "Fiche complète d'un produit",
|
||||
description: "Toutes les données d'un article : séries mensuelles, ventilation par magasin, stock, marges, gammes et métriques réseau.",
|
||||
parameters: [
|
||||
{ name: "codein", in: "path", required: true, schema: { type: "string" }, description: "Identifiant interne de l'article." },
|
||||
{ name: "fournisseur", in: "query", schema: { type: "string" }, description: "Lève l'ambiguïté d'un article référencé chez plusieurs fournisseurs." },
|
||||
],
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Fiche produit",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: { data: { $ref: "#/components/schemas/Product" }, meta: { type: "object" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"404": { description: "Produit absent de l'instantané", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
|
||||
"401": { description: "Clé d'API absente, invalide ou révoquée", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
"/network/{codeCentrale}": {
|
||||
get: {
|
||||
operationId: "obtenirMetriquesReseau",
|
||||
summary: "Métriques du réseau Qlik pour un produit",
|
||||
description: "Performances du produit sur l'ensemble du réseau Foir'Fouille (~270 magasins) et courbe des quantités sur 12 mois glissants.",
|
||||
parameters: [
|
||||
{ name: "codeCentrale", in: "path", required: true, schema: { type: "string" }, description: "Code centrale du produit (format 10000XXXXXX)." },
|
||||
],
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Métriques réseau",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: { data: { $ref: "#/components/schemas/NetworkMetrics" }, meta: { type: "object" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"404": { description: "Aucune donnée réseau pour ce code centrale", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
|
||||
"401": { description: "Clé d'API absente, invalide ou révoquée", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// CORS ouvert en lecture : le schéma est public et peut être chargé par un outil tiers.
|
||||
return Response.json(spec, { headers: { "Access-Control-Allow-Origin": "*" } });
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { requireApiAuth } from "@/lib/api-auth";
|
||||
import { ok, fail } from "@/lib/api-response";
|
||||
import { productDetailSchema } from "@/lib/api-schemas";
|
||||
import { getGridRowByCodein } from "@/lib/grid-store";
|
||||
import { enrichRows } from "@/lib/api-enrich";
|
||||
import { getProductRows } from "@/features/grid/api/get-product-rows";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
// Idem /api/v1/grid : le calcul à la demande peut prendre plusieurs secondes.
|
||||
export const maxDuration = 300;
|
||||
|
||||
/**
|
||||
* GET /api/v1/products/:codein?fournisseur=…
|
||||
*
|
||||
* Fiche complète d'un produit : le `ProductRow` intégral (séries mensuelles,
|
||||
* ventilations par magasin, stock, marges, gammes) enrichi des métriques réseau Qlik
|
||||
* lues **en cache** (`qlik_network_metrics`) — aucun appel à Qlik.
|
||||
*
|
||||
* `fournisseur` lève l'ambiguïté d'un article référencé chez plusieurs fournisseurs ;
|
||||
* sans lui, l'instantané le plus récent est renvoyé.
|
||||
*/
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
ctx: { params: Promise<{ codein: string }> },
|
||||
) {
|
||||
const authCtx = await requireApiAuth(req);
|
||||
if (authCtx instanceof Response) return authCtx;
|
||||
|
||||
const { codein } = await ctx.params;
|
||||
if (!codein) return fail("bad_request", "Paramètre 'codein' requis.");
|
||||
|
||||
const parsed = productDetailSchema.safeParse(Object.fromEntries(req.nextUrl.searchParams));
|
||||
if (!parsed.success) {
|
||||
return fail("bad_request", "Paramètres invalides.", parsed.error.issues);
|
||||
}
|
||||
|
||||
let found = await getGridRowByCodein(codein, parsed.data.fournisseur);
|
||||
|
||||
// Absent de l'instantané : on peut le calculer, mais seulement si l'appelant a
|
||||
// précisé le fournisseur — le calcul se fait par lot fournisseur, pas par article.
|
||||
if (!found && parsed.data.compute === "1" && parsed.data.fournisseur) {
|
||||
console.log(`[api/v1/products] ${codein} absent — calcul du lot ${parsed.data.fournisseur} à la demande`);
|
||||
try {
|
||||
await getProductRows({ codeFournisseur: parsed.data.fournisseur, magasin: "TOTAL" });
|
||||
found = await getGridRowByCodein(codein, parsed.data.fournisseur);
|
||||
} catch (e) {
|
||||
console.error(`[api/v1/products] calcul KO pour ${parsed.data.fournisseur}:`, e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}
|
||||
|
||||
if (!found) {
|
||||
return fail(
|
||||
"not_found",
|
||||
parsed.data.fournisseur
|
||||
? `Produit « ${codein} » introuvable chez le fournisseur « ${parsed.data.fournisseur} ».`
|
||||
: `Aucun instantané pour le produit « ${codein} ». Ajoutez « fournisseur=… » pour que l'API calcule le lot à la demande.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Métriques réseau + gamme serveur relues maintenant : le payload date du dernier
|
||||
// calcul de la grille, alors que ces deux données évoluent indépendamment.
|
||||
const [enriched] = await enrichRows([found.row]);
|
||||
|
||||
return ok(enriched, {
|
||||
meta: {
|
||||
computedAt: found.computedAt,
|
||||
networkFetchedAt: enriched.network?.fetchedAt ?? null,
|
||||
hasNetwork: enriched.network !== null,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { requireApiAuth } from "@/lib/api-auth";
|
||||
import { ok, fail, buildPagination } from "@/lib/api-response";
|
||||
import { productSearchSchema, pickFields, toSortKey } from "@/lib/api-schemas";
|
||||
import { queryGridRows } from "@/lib/grid-store";
|
||||
import { enrichRows } from "@/lib/api-enrich";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* GET /api/v1/products/search?q=…&fournisseur=&gamme=&sort=&order=&page=&limit=&fields=
|
||||
*
|
||||
* **Recherche transversale** sur libellé, codein, GTIN, référence et code centrale —
|
||||
* tous fournisseurs confondus. C'est ce qu'aucune route existante ne sait faire :
|
||||
* `getProductRows()` exige un fournisseur, donc chercher un produit sans le connaître
|
||||
* était impossible.
|
||||
*
|
||||
* Lit uniquement l'instantané `grid_rows` : un fournisseur jamais ouvert dans la
|
||||
* Grille n'apparaît pas encore dans les résultats.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
const authCtx = await requireApiAuth(req);
|
||||
if (authCtx instanceof Response) return authCtx;
|
||||
|
||||
const parsed = productSearchSchema.safeParse(Object.fromEntries(req.nextUrl.searchParams));
|
||||
if (!parsed.success) {
|
||||
return fail("bad_request", "Paramètres invalides.", parsed.error.issues);
|
||||
}
|
||||
const q = parsed.data;
|
||||
|
||||
const result = await queryGridRows({
|
||||
codeFournisseur: q.fournisseur,
|
||||
gamme: q.gamme,
|
||||
search: q.q,
|
||||
sort: toSortKey(q.sort),
|
||||
order: q.order,
|
||||
page: q.page,
|
||||
limit: q.limit,
|
||||
});
|
||||
|
||||
// Métriques Qlik et gamme serveur relues au moment de l'appel (voir api-enrich).
|
||||
const rows = q.enrich === "1" ? await enrichRows(result.rows) : result.rows;
|
||||
|
||||
return ok(
|
||||
rows.map((r) => pickFields(r, q.fields)),
|
||||
{
|
||||
pagination: buildPagination(q.page, q.limit, result.total),
|
||||
meta: {
|
||||
query: q.q,
|
||||
computedAt: result.computedAt,
|
||||
scope: q.fournisseur ? `fournisseur:${q.fournisseur}` : "tous fournisseurs",
|
||||
enrichi: q.enrich === "1",
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -25,6 +25,16 @@
|
||||
/* Slate 900 with opacity */
|
||||
--border: #334155;
|
||||
/* Slate 700 */
|
||||
|
||||
/* ── Palette data-viz (3 séries) ──
|
||||
Slots 1-3 de la palette catégorielle de référence, pas-de-teinte propre au
|
||||
fond sombre. Validée sur ce fond : bande de clarté, plancher de chroma,
|
||||
séparation daltonienne (pire paire ΔE 9,4) et contraste ≥ 3:1 — toutes
|
||||
paires, pas seulement les adjacentes. Ne pas retoucher une valeur isolément :
|
||||
c'est l'ENSEMBLE qui passe les contrôles. */
|
||||
--viz-1: #3987e5;
|
||||
--viz-2: #d95926;
|
||||
--viz-3: #199e70;
|
||||
--border-strong: #475569;
|
||||
/* Slate 600 */
|
||||
|
||||
@@ -70,6 +80,13 @@
|
||||
--border: rgba(0, 0, 0, 0.07);
|
||||
--border-strong: rgba(0, 0, 0, 0.12);
|
||||
|
||||
/* Mêmes teintes, pas choisis pour le fond clair (et non un simple éclaircissement
|
||||
des valeurs sombres). L'aqua passe sous 3:1 sur blanc : la « règle de relief »
|
||||
s'applique — étiquettes directes visibles + vue tableau, toutes deux présentes. */
|
||||
--viz-1: #2a78d6;
|
||||
--viz-2: #eb6834;
|
||||
--viz-3: #1baf7a;
|
||||
|
||||
--text-primary: #1d1d1f;
|
||||
/* Apple dark */
|
||||
--text-secondary: #515154;
|
||||
@@ -221,6 +238,47 @@
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
/* ── Défilement de la Grille ──
|
||||
La règle ci-dessus (8 px, rail transparent, pouce très pâle) convient aux
|
||||
panneaux où l'ascenseur est accessoire. Dans la Grille, le tableau fait
|
||||
~2600 px de large : l'ascenseur HORIZONTAL est l'outil de navigation
|
||||
principal, et un rail invisible ne dit pas qu'il reste des colonnes à
|
||||
droite. On lui donne donc un rail dessiné et un pouce saisissable.
|
||||
`scrollbar-*` couvre Firefox, que les sélecteurs -webkit- ignorent. */
|
||||
.grid-scroll {
|
||||
scrollbar-width: auto;
|
||||
scrollbar-color: var(--text-muted) var(--bg-elevated);
|
||||
}
|
||||
|
||||
.grid-scroll::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.grid-scroll::-webkit-scrollbar-track:horizontal {
|
||||
background: var(--bg-elevated);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.grid-scroll::-webkit-scrollbar-thumb {
|
||||
background: var(--text-muted);
|
||||
border-radius: 99px;
|
||||
/* Bordure transparente + background-clip : le pouce garde de l'air dans son
|
||||
rail sans qu'on ait à jouer sur des marges, impossibles ici. */
|
||||
border: 3px solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
.grid-scroll::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-secondary);
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
/* Le coin entre les deux ascenseurs, sinon laissé en blanc par le navigateur. */
|
||||
.grid-scroll::-webkit-scrollbar-corner {
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
|
||||
/* ── Heatmap cells ── */
|
||||
.heatmap-zero {
|
||||
background: var(--bg-elevated);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { LayoutGrid, Camera, FileDown, Settings, Package, BarChart2, TrendingUp, Trophy, Megaphone, PackageMinus, ShoppingCart, LogOut, User as UserIcon, Loader2, ChevronRight, ChevronLeft, MessageSquareCode } from "lucide-react";
|
||||
import { LayoutGrid, Camera, FileDown, Settings, Package, PackageSearch, BarChart2, TrendingUp, Trophy, Megaphone, PackageMinus, ShoppingCart, LogOut, User as UserIcon, Loader2, ChevronRight, ChevronLeft, CalendarClock } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -11,6 +11,7 @@ import { useSession, signOut } from "next-auth/react";
|
||||
const NAV_ITEMS = [
|
||||
{ icon: BarChart2, label: "Dashboard", href: "/dashboard" },
|
||||
{ icon: LayoutGrid, label: "Grille", href: "/grid" },
|
||||
{ icon: PackageSearch, label: "Produits", href: "/produits" },
|
||||
{ icon: TrendingUp, label: "Analytics", href: "/analytics" },
|
||||
{ icon: Trophy, label: "Hit Parade", href: "/hit-parade" },
|
||||
{ icon: Megaphone, label: "Publicités", href: "/publicites" },
|
||||
@@ -18,7 +19,7 @@ const NAV_ITEMS = [
|
||||
{ icon: ShoppingCart, label: "Commandes auto", href: "/commandes-auto" },
|
||||
{ icon: Camera, label: "Snapshots", href: "/snapshots" },
|
||||
{ icon: FileDown, label: "Exports", href: "/exports", adminOnly: true },
|
||||
{ icon: MessageSquareCode, label: "AI Chat Admin", href: "/admin/ai-chat", adminOnly: true },
|
||||
{ icon: CalendarClock, label: "Synchronisation", href: "/admin/synchronisation", adminOnly: true },
|
||||
{ icon: Settings, label: "Paramètres", href: "/settings", adminOnly: true },
|
||||
];
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { CheckCircle2, X } from "lucide-react";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { AlertCircle, CheckCircle2, X } from "lucide-react";
|
||||
|
||||
interface SuccessModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -9,35 +9,57 @@ interface SuccessModalProps {
|
||||
title: string;
|
||||
message: string;
|
||||
duration?: number;
|
||||
/**
|
||||
* `error` change l'icône, l'accent et **désactive la fermeture automatique** :
|
||||
* un message d'échec doit rester à l'écran le temps d'être lu, et il ne doit
|
||||
* pas s'afficher sous une coche verte de succès.
|
||||
*/
|
||||
variant?: "success" | "error";
|
||||
/** Action secondaire optionnelle (ex. « Recharger la page »). */
|
||||
action?: { label: string; onClick: () => void };
|
||||
}
|
||||
|
||||
/** Durée de l'animation de sortie, alignée sur les classes `animate-out`. */
|
||||
const EXIT_MS = 200;
|
||||
|
||||
export function SuccessModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
title,
|
||||
message,
|
||||
duration = 3000
|
||||
duration = 3000,
|
||||
variant = "success",
|
||||
action,
|
||||
}: SuccessModalProps) {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
// Seul l'état de SORTIE est porté par un state : l'entrée est jouée par CSS
|
||||
// au montage (`animate-in`), ce qui évite un setState synchrone dans l'effet.
|
||||
const [closing, setClosing] = useState(false);
|
||||
const isError = variant === "error";
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setClosing(true);
|
||||
setTimeout(() => {
|
||||
setClosing(false);
|
||||
onClose();
|
||||
}, EXIT_MS);
|
||||
}, [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setIsVisible(true);
|
||||
const timer = setTimeout(() => handleClose(), duration);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [isOpen, duration]);
|
||||
// Une erreur ne disparaît pas toute seule : l'utilisateur la ferme.
|
||||
if (!isOpen || isError) return;
|
||||
const timer = setTimeout(() => handleClose(), duration);
|
||||
return () => clearTimeout(timer);
|
||||
}, [isOpen, duration, isError, handleClose]);
|
||||
|
||||
const handleClose = () => {
|
||||
setIsVisible(false);
|
||||
setTimeout(onClose, 250);
|
||||
};
|
||||
if (!isOpen) return null;
|
||||
|
||||
if (!isOpen && !isVisible) return null;
|
||||
const state = closing ? "closed" : "open";
|
||||
const Icon = isError ? AlertCircle : CheckCircle2;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`fixed inset-0 z-[100] flex items-center justify-center p-4 transition-all duration-250 ${isVisible ? "opacity-100 backdrop-blur-sm" : "opacity-0"}`}
|
||||
data-state={state}
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center p-4 backdrop-blur-sm duration-200 data-[state=open]:animate-in data-[state=open]:fade-in data-[state=closed]:animate-out data-[state=closed]:fade-out"
|
||||
>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
@@ -48,11 +70,19 @@ export function SuccessModal({
|
||||
|
||||
{/* Panel */}
|
||||
<div
|
||||
className={`relative w-full max-w-sm rounded-2xl overflow-hidden shadow-2xl transition-all duration-250 ${isVisible ? "scale-100 translate-y-0" : "scale-95 translate-y-4"}`}
|
||||
data-state={state}
|
||||
className="relative w-full max-w-sm rounded-2xl overflow-hidden shadow-2xl duration-200 data-[state=open]:animate-in data-[state=open]:zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:zoom-out-95"
|
||||
style={{ background: "var(--bg-surface)", border: "1px solid var(--border)" }}
|
||||
>
|
||||
{/* Accent bar */}
|
||||
<div className="absolute top-0 left-0 w-full h-0.5" style={{ background: "linear-gradient(to right, var(--accent), var(--accent-success))" }} />
|
||||
<div
|
||||
className="absolute top-0 left-0 w-full h-0.5"
|
||||
style={{
|
||||
background: isError
|
||||
? "var(--accent-error)"
|
||||
: "linear-gradient(to right, var(--accent), var(--accent-success))",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Close button */}
|
||||
<button
|
||||
@@ -64,8 +94,15 @@ export function SuccessModal({
|
||||
|
||||
<div className="p-8 flex flex-col items-center text-center">
|
||||
{/* Icon */}
|
||||
<div className="w-16 h-16 rounded-2xl bg-[var(--accent-bg)] border border-[var(--accent-border)] flex items-center justify-center mb-5">
|
||||
<CheckCircle2 className="w-8 h-8 text-[var(--accent)]" />
|
||||
<div
|
||||
className="w-16 h-16 rounded-2xl flex items-center justify-center mb-5"
|
||||
style={
|
||||
isError
|
||||
? { background: "var(--accent-error-bg)", border: "1px solid var(--accent-error)" }
|
||||
: { background: "var(--accent-bg)", border: "1px solid var(--accent-border)" }
|
||||
}
|
||||
>
|
||||
<Icon className="w-8 h-8" style={{ color: isError ? "var(--accent-error)" : "var(--accent)" }} />
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-bold text-[var(--text-primary)] mb-2 tracking-tight">
|
||||
@@ -75,11 +112,16 @@ export function SuccessModal({
|
||||
{message}
|
||||
</p>
|
||||
|
||||
{action && (
|
||||
<button onClick={action.onClick} className="apple-btn-primary mt-7 w-full justify-center">
|
||||
{action.label}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="apple-btn-primary mt-7 w-full justify-center"
|
||||
className={`${action ? "apple-btn-secondary mt-2" : "apple-btn-primary mt-7"} w-full justify-center`}
|
||||
>
|
||||
Continuer
|
||||
{action ? "Plus tard" : "Continuer"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -117,12 +117,159 @@ export const qlikNetworkMetrics = pgTable("qlik_network_metrics", {
|
||||
qteReseau: numeric("qte_reseau", { precision: 14, scale: 2 }),
|
||||
/** Nombre de magasins travaillant le produit (sur ~270) */
|
||||
nbMagasinsReseau: integer("nb_magasins_reseau"),
|
||||
/** CA moyen par magasin (productivité normalisée présence) */
|
||||
caParMagasinReseau: numeric("ca_par_magasin_reseau", { precision: 16, scale: 2 }),
|
||||
/** Taux de marge réseau (ratio brut Qlik) */
|
||||
margePctReseau: numeric("marge_pct_reseau", { precision: 8, scale: 4 }),
|
||||
/** Période couverte (libre, ex "12m" ou "2025") */
|
||||
periode: varchar("periode", { length: 20 }),
|
||||
/** Quantité vendue réseau par mois : { "YYYY-MM": qté } (12 derniers mois). */
|
||||
qteByMonth: jsonb("qte_by_month"),
|
||||
/**
|
||||
* Détail mensuel complet : { "YYYY-MM": { qte, ca, nbMag, caMag, margePct } }.
|
||||
* Superset de `qteByMonth`, qui reste maintenu tel quel pour la colonne
|
||||
* « Tendance / Réseau » de la Grille.
|
||||
*/
|
||||
metricsByMonth: jsonb("metrics_by_month"),
|
||||
/**
|
||||
* Libellé de l'article tel que Qlik le renvoie. Renseigné par la recherche
|
||||
* produit (qui interroge la dimension libellé) ; la sync fournisseur ne
|
||||
* l'écrase pas. Permet d'afficher une fiche pour un produit réseau que le
|
||||
* catalogue Nancy ne référence pas.
|
||||
*/
|
||||
libelleReseau: varchar("libelle_reseau", { length: 255 }),
|
||||
/** Fournisseur de l'article côté Qlik, même origine que `libelleReseau`. */
|
||||
fournisseurReseau: varchar("fournisseur_reseau", { length: 255 }),
|
||||
/** Dernière synchro depuis Qlik */
|
||||
fetchedAt: timestamp("fetched_at").defaultNow(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Instantané persisté des lignes de grille (une ligne par fournisseur × article).
|
||||
*
|
||||
* Raison d'être : `getProductRows()` reconstruit la grille en direct et ne la garde
|
||||
* qu'en mémoire 10 minutes — perdu à chaque redémarrage. L'API `/api/v1` ne doit
|
||||
* jamais déclencher ce calcul, elle lit donc cette table, remplie en effet de bord
|
||||
* quand quelqu'un ouvre la Grille. Aucun calcul supplémentaire n'est introduit : on
|
||||
* arrête simplement de jeter le résultat.
|
||||
*
|
||||
* Seul `magasin = TOTAL` est persisté : `payload` embarque déjà les ventilations par
|
||||
* magasin (sales12mByStore, caByStore…).
|
||||
*/
|
||||
export const gridRows = pgTable("grid_rows", {
|
||||
codeFournisseur: varchar("code_fournisseur", { length: 20 }).notNull(),
|
||||
codein: varchar("codein", { length: 20 }).notNull(),
|
||||
|
||||
// Colonnes scalaires : servent aux filtres, au tri et à la recherche en SQL.
|
||||
nomFournisseur: varchar("nom_fournisseur", { length: 255 }),
|
||||
libelle1: varchar("libelle1", { length: 500 }),
|
||||
gtin: varchar("gtin", { length: 30 }),
|
||||
reference: varchar("reference", { length: 100 }),
|
||||
codeCentrale: varchar("code_centrale", { length: 20 }),
|
||||
code1: varchar("code1", { length: 20 }),
|
||||
code2: varchar("code2", { length: 20 }),
|
||||
code3: varchar("code3", { length: 20 }),
|
||||
codeGamme: varchar("code_gamme", { length: 20 }),
|
||||
codeGammeInit: varchar("code_gamme_init", { length: 20 }),
|
||||
totalCa: numeric("total_ca", { precision: 16, scale: 2 }),
|
||||
totalQuantite: numeric("total_quantite", { precision: 14, scale: 2 }),
|
||||
totalMarge: numeric("total_marge", { precision: 16, scale: 2 }),
|
||||
tauxMarge: numeric("taux_marge", { precision: 8, scale: 4 }),
|
||||
stockActuel: numeric("stock_actuel", { precision: 14, scale: 2 }),
|
||||
caReseau: numeric("ca_reseau", { precision: 16, scale: 2 }),
|
||||
qteReseau: numeric("qte_reseau", { precision: 14, scale: 2 }),
|
||||
nbMagasinsReseau: integer("nb_magasins_reseau"),
|
||||
caParMagasinReseau: numeric("ca_par_magasin_reseau", { precision: 16, scale: 2 }),
|
||||
margePctReseau: numeric("marge_pct_reseau", { precision: 8, scale: 4 }),
|
||||
|
||||
/** ProductRow complet (séries mensuelles, ventilations par magasin, dates…). */
|
||||
payload: jsonb("payload").notNull(),
|
||||
/** Date du calcul ayant produit cette ligne — expose la fraîcheur au consommateur. */
|
||||
computedAt: timestamp("computed_at").defaultNow().notNull(),
|
||||
}, (table) => {
|
||||
return [
|
||||
uniqueIndex("uq_grid_rows_fou_codein").on(table.codeFournisseur, table.codein),
|
||||
index("idx_grid_rows_fournisseur").on(table.codeFournisseur),
|
||||
index("idx_grid_rows_codein").on(table.codein),
|
||||
index("idx_grid_rows_gamme").on(table.codeGamme),
|
||||
index("idx_grid_rows_code_centrale").on(table.codeCentrale),
|
||||
index("idx_grid_rows_total_ca").on(table.totalCa),
|
||||
index("idx_grid_rows_libelle").on(table.libelle1),
|
||||
];
|
||||
});
|
||||
|
||||
/**
|
||||
* Clés d'API pour les appelants non-navigateur (scripts, outils externes).
|
||||
*
|
||||
* Seul le hachage SHA-256 est stocké : la clé en clair n'est affichée qu'une fois,
|
||||
* à la création. Une clé révoquée conserve sa ligne (traçabilité) via `revokedAt`.
|
||||
*/
|
||||
export const apiKeys = pgTable("api_keys", {
|
||||
id: serial("id").primaryKey(),
|
||||
/** Nom lisible choisi à la création (ex "Export Excel comptabilité"). */
|
||||
name: varchar("name", { length: 100 }).notNull(),
|
||||
/** Préfixe en clair (ex "cf_a1b2c3") — sert à identifier la clé dans l'UI. */
|
||||
keyPrefix: varchar("key_prefix", { length: 20 }).notNull(),
|
||||
/** SHA-256 hexadécimal de la clé complète. */
|
||||
keyHash: text("key_hash").notNull().unique(),
|
||||
/** 'admin' ou 'user' — même sémantique que users.role. */
|
||||
role: varchar("role", { length: 20 }).default("user").notNull(),
|
||||
createdBy: varchar("created_by", { length: 50 }),
|
||||
createdAt: timestamp("created_at").defaultNow(),
|
||||
lastUsedAt: timestamp("last_used_at"),
|
||||
revokedAt: timestamp("revoked_at"),
|
||||
});
|
||||
|
||||
/**
|
||||
* Paramétrage et suivi de la synchronisation nocturne, un enregistrement par
|
||||
* fournisseur.
|
||||
*
|
||||
* Deux sources aux coûts très différents, donc deux cadences distinctes :
|
||||
* l'instantané SQL prend une minute au pire, l'extraction Qlik jusqu'à huit —
|
||||
* et le serveur Qlik abandonne quand on le sollicite trop. D'où `actifSql` et
|
||||
* `actifQlik` séparés, et deux dates de dernier passage.
|
||||
*
|
||||
* L'ordre de passage se déduit de `dernier*At` (le plus ancien d'abord) : la
|
||||
* nuit suivante reprend donc naturellement là où la précédente s'est arrêtée,
|
||||
* et repart d'elle-même pour un nouveau tour quand tout a été traité. Aucun
|
||||
* curseur ni compteur de tour à maintenir.
|
||||
*/
|
||||
export const syncFournisseurs = pgTable("sync_fournisseurs", {
|
||||
codeFournisseur: varchar("code_fournisseur", { length: 20 }).primaryKey(),
|
||||
nomFournisseur: varchar("nom_fournisseur", { length: 255 }),
|
||||
/** Inclus dans la rotation de l'instantané SQL. */
|
||||
actifSql: boolean("actif_sql").default(true).notNull(),
|
||||
/** Inclus dans la rotation Qlik (beaucoup plus coûteuse). */
|
||||
actifQlik: boolean("actif_qlik").default(true).notNull(),
|
||||
/**
|
||||
* Renseigné quand le planificateur a désactivé le fournisseur de lui-même —
|
||||
* typiquement « aucun article ». Évite de réessayer chaque nuit un
|
||||
* fournisseur vide, tout en gardant la raison visible pour le réactiver.
|
||||
*/
|
||||
desactiveMotif: text("desactive_motif"),
|
||||
|
||||
dernierSqlAt: timestamp("dernier_sql_at"),
|
||||
/** 'succes' | 'echec' | 'vide' */
|
||||
dernierSqlStatut: varchar("dernier_sql_statut", { length: 20 }),
|
||||
dernierSqlLignes: integer("dernier_sql_lignes"),
|
||||
dernierSqlErreur: text("dernier_sql_erreur"),
|
||||
dernierSqlMs: integer("dernier_sql_ms"),
|
||||
|
||||
dernierQlikAt: timestamp("dernier_qlik_at"),
|
||||
dernierQlikStatut: varchar("dernier_qlik_statut", { length: 20 }),
|
||||
dernierQlikCodes: integer("dernier_qlik_codes"),
|
||||
dernierQlikErreur: text("dernier_qlik_erreur"),
|
||||
dernierQlikMs: integer("dernier_qlik_ms"),
|
||||
|
||||
updatedAt: timestamp("updated_at").defaultNow(),
|
||||
}, (table) => {
|
||||
return [
|
||||
// Index de file d'attente : « le plus ancien d'abord », par source.
|
||||
index("idx_sync_fou_sql").on(table.actifSql, table.dernierSqlAt),
|
||||
index("idx_sync_fou_qlik").on(table.actifQlik, table.dernierQlikAt),
|
||||
];
|
||||
});
|
||||
|
||||
/** AI Context rules per supplier (Epic: AI Context) */
|
||||
export const aiSupplierContext = pgTable("ai_supplier_context", {
|
||||
/** Supplier code serving as the primary key */
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
"use server";
|
||||
|
||||
/**
|
||||
* CollectFlow — Gestion des clés de l'API `/api/v1` (administrateurs).
|
||||
*
|
||||
* La clé en clair n'existe qu'une fois : elle est renvoyée par `createApiKey()` et
|
||||
* jamais restituée ensuite (seul son hachage SHA-256 est stocké). Une clé révoquée
|
||||
* conserve sa ligne, pour garder la trace de son usage passé.
|
||||
*/
|
||||
|
||||
import { db } from "@/db";
|
||||
import { apiKeys } from "@/db/schema";
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { generateApiKey } from "@/lib/api-auth";
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
async function ensureAdmin() {
|
||||
const session = await auth();
|
||||
if ((session?.user as { role?: string } | undefined)?.role !== "admin") {
|
||||
throw new Error("Accès refusé : Droits administrateur requis.");
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
export interface ApiKeyRow {
|
||||
id: number;
|
||||
name: string;
|
||||
keyPrefix: string;
|
||||
role: string;
|
||||
createdBy: string | null;
|
||||
createdAt: string | null;
|
||||
lastUsedAt: string | null;
|
||||
revokedAt: string | null;
|
||||
}
|
||||
|
||||
/** Liste les clés. Ne renvoie jamais de secret — seulement le préfixe lisible. */
|
||||
export async function getApiKeys(): Promise<ApiKeyRow[]> {
|
||||
await ensureAdmin();
|
||||
const rows = await db
|
||||
.select({
|
||||
id: apiKeys.id,
|
||||
name: apiKeys.name,
|
||||
keyPrefix: apiKeys.keyPrefix,
|
||||
role: apiKeys.role,
|
||||
createdBy: apiKeys.createdBy,
|
||||
createdAt: apiKeys.createdAt,
|
||||
lastUsedAt: apiKeys.lastUsedAt,
|
||||
revokedAt: apiKeys.revokedAt,
|
||||
})
|
||||
.from(apiKeys)
|
||||
.orderBy(desc(apiKeys.createdAt));
|
||||
|
||||
return rows.map((r) => ({
|
||||
...r,
|
||||
createdAt: r.createdAt ? r.createdAt.toISOString() : null,
|
||||
lastUsedAt: r.lastUsedAt ? r.lastUsedAt.toISOString() : null,
|
||||
revokedAt: r.revokedAt ? r.revokedAt.toISOString() : null,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée une clé et renvoie sa valeur **en clair, une seule et unique fois**.
|
||||
* L'appelant doit la présenter immédiatement à l'utilisateur : elle est irrécupérable.
|
||||
*/
|
||||
export async function createApiKey(
|
||||
name: string,
|
||||
role: "admin" | "user" = "user",
|
||||
): Promise<{ success: true; key: string; keyPrefix: string } | { success: false; error: string }> {
|
||||
const session = await ensureAdmin();
|
||||
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return { success: false, error: "Le nom de la clé est obligatoire." };
|
||||
if (trimmed.length > 100) return { success: false, error: "Nom trop long (100 caractères max)." };
|
||||
|
||||
try {
|
||||
const { key, keyHash, keyPrefix } = generateApiKey();
|
||||
await db.insert(apiKeys).values({
|
||||
name: trimmed,
|
||||
keyPrefix,
|
||||
keyHash,
|
||||
role,
|
||||
createdBy: (session?.user as { name?: string | null } | undefined)?.name ?? null,
|
||||
});
|
||||
revalidatePath("/settings");
|
||||
return { success: true, key, keyPrefix };
|
||||
} catch (err) {
|
||||
console.error("[api-keys] création KO:", err);
|
||||
return { success: false, error: "Erreur technique lors de la création de la clé." };
|
||||
}
|
||||
}
|
||||
|
||||
/** Révoque une clé : elle est refusée dès l'appel suivant, mais reste listée. */
|
||||
export async function revokeApiKey(id: number): Promise<{ success: boolean; error?: string }> {
|
||||
await ensureAdmin();
|
||||
try {
|
||||
await db.update(apiKeys).set({ revokedAt: new Date() }).where(eq(apiKeys.id, id));
|
||||
revalidatePath("/settings");
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
console.error("[api-keys] révocation KO:", err);
|
||||
return { success: false, error: "Erreur lors de la révocation." };
|
||||
}
|
||||
}
|
||||
|
||||
/** Supprime définitivement une clé révoquée (nettoyage de la liste). */
|
||||
export async function deleteApiKey(id: number): Promise<{ success: boolean; error?: string }> {
|
||||
await ensureAdmin();
|
||||
try {
|
||||
await db.delete(apiKeys).where(eq(apiKeys.id, id));
|
||||
revalidatePath("/settings");
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
console.error("[api-keys] suppression KO:", err);
|
||||
return { success: false, error: "Erreur lors de la suppression." };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
/**
|
||||
* CollectFlow — Synchronisation nocturne des fournisseurs.
|
||||
*
|
||||
* Deux sources aux coûts très différents, donc deux files distinctes :
|
||||
* • **SQL** — l'instantané de grille (`getProductRows`), ~43 s sur le plus gros
|
||||
* fournisseur du catalogue. Alimente la Grille et `/api/v1`.
|
||||
* • **Qlik** — les métriques réseau, jusqu'à 8 min par fournisseur, sur un
|
||||
* serveur externe qui abandonne quand on le sollicite trop. Ce sont des
|
||||
* agrégats mensuels : les rafraîchir chaque nuit n'apporterait rien.
|
||||
*
|
||||
* ORDRE DE PASSAGE — le point de conception central. La file est simplement
|
||||
* « le plus ancien d'abord », en ignorant ceux déjà traités depuis l'ouverture
|
||||
* de la fenêtre courante. Il en découle, sans curseur ni compteur de tour :
|
||||
* • la nuit 2 reprend là où la nuit 1 s'est arrêtée (les non-traités sont les
|
||||
* plus anciens) ;
|
||||
* • quand plus personne n'est en attente, le tour est fini ; la nuit suivante
|
||||
* tout le monde redevient éligible et le tour recommence ;
|
||||
* • un fournisseur en échec garde une date ancienne, donc il repasse en tête —
|
||||
* le rattrapage est automatique.
|
||||
*
|
||||
* La fenêtre ne peut pas interrompre un fournisseur en cours : elle signifie
|
||||
* « ne plus en **démarrer** après l'heure de fin ».
|
||||
*/
|
||||
|
||||
import "server-only";
|
||||
|
||||
import { and, asc, eq, isNull, lt, or, sql, type SQL } from "drizzle-orm";
|
||||
import { db } from "@/db";
|
||||
import { syncFournisseurs } from "@/db/schema";
|
||||
import { getProductRows } from "@/features/grid/api/get-product-rows";
|
||||
import { pgGetFournisseurs, pgGetArticlesByFournisseur } from "@/lib/pg-ff-client";
|
||||
import { fetchNetworkMetricsPlaywright } from "@/lib/qlik-playwright";
|
||||
import { upsertNetworkMetrics } from "@/lib/qlik-network-cache";
|
||||
import { buildGridNetworkQlikDateFilter, envMonthsBack, QLIK_MONTHS_BACK_DEFAULT } from "@/lib/qlik-date-range";
|
||||
import { readSyncSettings, type SyncSettings } from "@/features/admin/api/sync-settings";
|
||||
|
||||
export type { SyncSettings };
|
||||
|
||||
// ─── État courant, en mémoire ────────────────────────────────────────────────
|
||||
|
||||
export interface SyncSchedulerState {
|
||||
enCours: boolean;
|
||||
/** "auto" = déclenché par la fenêtre nocturne, "manuel" = lancé depuis l'admin. */
|
||||
origine: "auto" | "manuel" | null;
|
||||
debutAt: string | null;
|
||||
finAt: string | null;
|
||||
fournisseurCourant: string | null;
|
||||
etape: "sql" | "qlik" | null;
|
||||
sqlFaits: number;
|
||||
sqlEchecs: number;
|
||||
qlikFaits: number;
|
||||
qlikEchecs: number;
|
||||
/** Renseigné quand la file s'est vidée : le tour est bouclé. */
|
||||
tourTermine: boolean;
|
||||
arretDemande: boolean;
|
||||
derniereErreur: string | null;
|
||||
}
|
||||
|
||||
const etat: SyncSchedulerState = {
|
||||
enCours: false, origine: null, debutAt: null, finAt: null,
|
||||
fournisseurCourant: null, etape: null,
|
||||
sqlFaits: 0, sqlEchecs: 0, qlikFaits: 0, qlikEchecs: 0,
|
||||
tourTermine: false, arretDemande: false, derniereErreur: null,
|
||||
};
|
||||
|
||||
export function getSyncSchedulerState(): SyncSchedulerState {
|
||||
return { ...etat };
|
||||
}
|
||||
|
||||
export function demanderArret(): void {
|
||||
if (etat.enCours) etat.arretDemande = true;
|
||||
}
|
||||
|
||||
// ─── Fenêtre horaire ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Début de la fenêtre en cours, ou `null` si l'heure courante est en dehors.
|
||||
*
|
||||
* Gère le passage par minuit (ex. 22h → 5h) : dans ce cas, entre 0h et l'heure
|
||||
* de fin, la fenêtre a commencé **la veille**.
|
||||
*/
|
||||
export function debutFenetre(s: SyncSettings, maintenant = new Date()): Date | null {
|
||||
const h = maintenant.getHours();
|
||||
const debutDuJour = new Date(maintenant);
|
||||
debutDuJour.setHours(s.heureDebut, 0, 0, 0);
|
||||
|
||||
if (s.heureDebut < s.heureFin) {
|
||||
return h >= s.heureDebut && h < s.heureFin ? debutDuJour : null;
|
||||
}
|
||||
// Fenêtre à cheval sur minuit.
|
||||
if (h >= s.heureDebut) return debutDuJour;
|
||||
if (h < s.heureFin) {
|
||||
const veille = new Date(debutDuJour);
|
||||
veille.setDate(veille.getDate() - 1);
|
||||
return veille;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── Référentiel des fournisseurs ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Aligne la table de paramétrage sur le référentiel FF : ajoute les nouveaux
|
||||
* fournisseurs (actifs par défaut), rafraîchit les noms. Ne supprime rien —
|
||||
* l'historique de synchronisation d'un fournisseur disparu reste consultable.
|
||||
*/
|
||||
export async function seedSyncFournisseurs(): Promise<number> {
|
||||
const fournisseurs = await pgGetFournisseurs();
|
||||
if (fournisseurs.length === 0) return 0;
|
||||
|
||||
const CHUNK = 500;
|
||||
for (let i = 0; i < fournisseurs.length; i += CHUNK) {
|
||||
const lot = fournisseurs.slice(i, i + CHUNK);
|
||||
await db
|
||||
.insert(syncFournisseurs)
|
||||
.values(lot.map((f) => ({ codeFournisseur: f.code, nomFournisseur: f.nom })))
|
||||
.onConflictDoUpdate({
|
||||
target: syncFournisseurs.codeFournisseur,
|
||||
// Seul le nom est réaligné : les choix d'activation appartiennent à l'admin.
|
||||
set: { nomFournisseur: sql`excluded.nom_fournisseur` },
|
||||
});
|
||||
}
|
||||
return fournisseurs.length;
|
||||
}
|
||||
|
||||
// ─── Files d'attente ─────────────────────────────────────────────────────────
|
||||
|
||||
/** Le plus ancien fournisseur SQL pas encore traité depuis l'ouverture de la fenêtre. */
|
||||
async function prochainSql(depuis: Date) {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(syncFournisseurs)
|
||||
.where(and(
|
||||
eq(syncFournisseurs.actifSql, true),
|
||||
or(isNull(syncFournisseurs.dernierSqlAt), lt(syncFournisseurs.dernierSqlAt, depuis)) as SQL,
|
||||
))
|
||||
.orderBy(asc(sql`${syncFournisseurs.dernierSqlAt} nulls first`))
|
||||
.limit(1);
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Le plus ancien fournisseur Qlik dont les données dépassent `qlikMinJours`.
|
||||
* Le seuil en jours, plutôt que la fenêtre courante, évite de relancer chaque
|
||||
* nuit une extraction de plusieurs minutes pour des agrégats mensuels.
|
||||
*/
|
||||
async function prochainQlik(qlikMinJours: number, maintenant: Date) {
|
||||
const seuil = new Date(maintenant.getTime() - qlikMinJours * 24 * 3600 * 1000);
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(syncFournisseurs)
|
||||
.where(and(
|
||||
eq(syncFournisseurs.actifQlik, true),
|
||||
or(isNull(syncFournisseurs.dernierQlikAt), lt(syncFournisseurs.dernierQlikAt, seuil)) as SQL,
|
||||
))
|
||||
.orderBy(asc(sql`${syncFournisseurs.dernierQlikAt} nulls first`))
|
||||
.limit(1);
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
// ─── Traitement d'un fournisseur ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Instantané SQL. `dernierSqlAt` est renseigné **même en cas d'échec** : sans
|
||||
* cela, un fournisseur qui échoue resterait éternellement le plus ancien et
|
||||
* bloquerait la file derrière lui.
|
||||
*/
|
||||
async function traiterSql(code: string, nom: string | null): Promise<void> {
|
||||
const t0 = Date.now();
|
||||
etat.fournisseurCourant = `${code}${nom ? ` — ${nom}` : ""}`;
|
||||
etat.etape = "sql";
|
||||
try {
|
||||
const rows = await getProductRows({ codeFournisseur: code, magasin: "TOTAL", forceRefresh: true });
|
||||
const vide = rows.length === 0;
|
||||
await db.update(syncFournisseurs).set({
|
||||
dernierSqlAt: new Date(),
|
||||
dernierSqlStatut: vide ? "vide" : "succes",
|
||||
dernierSqlLignes: rows.length,
|
||||
dernierSqlErreur: null,
|
||||
dernierSqlMs: Date.now() - t0,
|
||||
// Un fournisseur sans article n'a rien à donner : on le sort des deux
|
||||
// files plutôt que de le réessayer chaque nuit. L'admin voit le motif
|
||||
// et peut le réactiver.
|
||||
...(vide ? { actifSql: false, actifQlik: false, desactiveMotif: "Aucun article — désactivé automatiquement" } : {}),
|
||||
updatedAt: new Date(),
|
||||
}).where(eq(syncFournisseurs.codeFournisseur, code));
|
||||
if (vide) console.log(`[sync-nuit] ${code} sans article — désactivé automatiquement`);
|
||||
else etat.sqlFaits++;
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
etat.sqlEchecs++;
|
||||
etat.derniereErreur = `${code} (SQL) : ${msg}`;
|
||||
console.error(`[sync-nuit] SQL ${code} en échec :`, msg);
|
||||
await db.update(syncFournisseurs).set({
|
||||
dernierSqlAt: new Date(),
|
||||
dernierSqlStatut: "echec",
|
||||
dernierSqlErreur: msg.slice(0, 500),
|
||||
dernierSqlMs: Date.now() - t0,
|
||||
updatedAt: new Date(),
|
||||
}).where(eq(syncFournisseurs.codeFournisseur, code)).catch(() => { /* le suivi ne doit pas casser la boucle */ });
|
||||
}
|
||||
}
|
||||
|
||||
/** Format d'un code centrale plausible — même filtre que la synchro manuelle. */
|
||||
const CODE_CENTRALE = /^[A-Z0-9][A-Z0-9_-]{1,29}$/i;
|
||||
|
||||
/** Extraction Qlik. Même politique que le SQL sur l'horodatage en cas d'échec. */
|
||||
async function traiterQlik(code: string, nom: string | null): Promise<void> {
|
||||
const t0 = Date.now();
|
||||
etat.fournisseurCourant = `${code}${nom ? ` — ${nom}` : ""}`;
|
||||
etat.etape = "qlik";
|
||||
try {
|
||||
const articles = await pgGetArticlesByFournisseur(code);
|
||||
const codes = [...new Set(
|
||||
articles
|
||||
.map((a) => String(a.codeCentrale ?? "").trim())
|
||||
.filter((c) => c && c !== "-" && CODE_CENTRALE.test(c)),
|
||||
)];
|
||||
|
||||
if (codes.length === 0) {
|
||||
await db.update(syncFournisseurs).set({
|
||||
dernierQlikAt: new Date(), dernierQlikStatut: "vide", dernierQlikCodes: 0,
|
||||
dernierQlikErreur: null, dernierQlikMs: Date.now() - t0, updatedAt: new Date(),
|
||||
}).where(eq(syncFournisseurs.codeFournisseur, code));
|
||||
return;
|
||||
}
|
||||
|
||||
const dateFilter = buildGridNetworkQlikDateFilter(new Date(), envMonthsBack("QLIK_SYNC_MONTHS_BACK", QLIK_MONTHS_BACK_DEFAULT));
|
||||
const metrics = await fetchNetworkMetricsPlaywright(codes, undefined, dateFilter, code);
|
||||
const n = await upsertNetworkMetrics([...metrics.values()]);
|
||||
|
||||
await db.update(syncFournisseurs).set({
|
||||
dernierQlikAt: new Date(), dernierQlikStatut: "succes", dernierQlikCodes: n,
|
||||
dernierQlikErreur: null, dernierQlikMs: Date.now() - t0, updatedAt: new Date(),
|
||||
}).where(eq(syncFournisseurs.codeFournisseur, code));
|
||||
etat.qlikFaits++;
|
||||
console.log(`[sync-nuit] Qlik ${code} — ${n} codes en ${Math.round((Date.now() - t0) / 1000)} s`);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
etat.qlikEchecs++;
|
||||
etat.derniereErreur = `${code} (Qlik) : ${msg}`;
|
||||
console.error(`[sync-nuit] Qlik ${code} en échec :`, msg);
|
||||
await db.update(syncFournisseurs).set({
|
||||
dernierQlikAt: new Date(), dernierQlikStatut: "echec",
|
||||
dernierQlikErreur: msg.slice(0, 500), dernierQlikMs: Date.now() - t0, updatedAt: new Date(),
|
||||
}).where(eq(syncFournisseurs.codeFournisseur, code)).catch(() => { /* idem */ });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Boucle de travail ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Traite les files jusqu'à épuisement, sortie de fenêtre, ou demande d'arrêt.
|
||||
*
|
||||
* Les deux files avancent en parallèle : une extraction Qlik est intercalée
|
||||
* tous les `sqlParQlik` fournisseurs SQL. Sans cet entrelacement, Qlik ne
|
||||
* démarrerait jamais tant que la file SQL n'est pas vide.
|
||||
*/
|
||||
export async function lancerRound(origine: "auto" | "manuel", forcerHorsFenetre = false): Promise<void> {
|
||||
if (etat.enCours) return;
|
||||
|
||||
const s = await readSyncSettings();
|
||||
const maintenant = new Date();
|
||||
const debut = debutFenetre(s, maintenant);
|
||||
if (!forcerHorsFenetre && !debut) return;
|
||||
// Un lancement manuel hors fenêtre borne la file sur « depuis ce matin ».
|
||||
const depuis = debut ?? new Date(new Date(maintenant).setHours(0, 0, 0, 0));
|
||||
|
||||
Object.assign(etat, {
|
||||
enCours: true, origine, debutAt: maintenant.toISOString(), finAt: null,
|
||||
fournisseurCourant: null, etape: null,
|
||||
sqlFaits: 0, sqlEchecs: 0, qlikFaits: 0, qlikEchecs: 0,
|
||||
tourTermine: false, arretDemande: false, derniereErreur: null,
|
||||
});
|
||||
console.log(`[sync-nuit] round ${origine} démarré (fenêtre ${s.heureDebut}h-${s.heureFin}h, Qlik ≤ ${s.qlikParNuit})`);
|
||||
|
||||
try {
|
||||
await seedSyncFournisseurs();
|
||||
|
||||
// Garde anti-boucle. L'avancement de la file repose sur l'écriture de
|
||||
// `dernier*At`, et cette écriture est volontairement non bloquante en cas
|
||||
// d'erreur. Si elle échoue, le fournisseur resterait le plus ancien et
|
||||
// serait repris sans fin : on retient donc ce qui a déjà été tenté.
|
||||
const tentes = new Set<string>();
|
||||
|
||||
let depuisDernierQlik = 0;
|
||||
for (;;) {
|
||||
if (etat.arretDemande) break;
|
||||
if (!forcerHorsFenetre && !debutFenetre(s, new Date())) {
|
||||
console.log("[sync-nuit] fin de fenêtre — arrêt après le fournisseur en cours");
|
||||
break;
|
||||
}
|
||||
|
||||
const qlikPossible = etat.qlikFaits + etat.qlikEchecs < s.qlikParNuit;
|
||||
|
||||
// Qlik prioritaire quand le quota d'entrelacement est atteint.
|
||||
if (qlikPossible && depuisDernierQlik >= s.sqlParQlik) {
|
||||
const cible = await prochainQlik(s.qlikMinJours, new Date());
|
||||
if (cible && !tentes.has(`qlik:${cible.codeFournisseur}`)) {
|
||||
tentes.add(`qlik:${cible.codeFournisseur}`);
|
||||
await traiterQlik(cible.codeFournisseur, cible.nomFournisseur);
|
||||
depuisDernierQlik = 0;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const cibleSql = await prochainSql(depuis);
|
||||
if (cibleSql) {
|
||||
if (tentes.has(`sql:${cibleSql.codeFournisseur}`)) {
|
||||
console.error(
|
||||
`[sync-nuit] ${cibleSql.codeFournisseur} ressort de la file après avoir été traité :`
|
||||
+ " le suivi n'a pas pu être écrit. Arrêt du round pour ne pas boucler.",
|
||||
);
|
||||
break;
|
||||
}
|
||||
tentes.add(`sql:${cibleSql.codeFournisseur}`);
|
||||
await traiterSql(cibleSql.codeFournisseur, cibleSql.nomFournisseur);
|
||||
depuisDernierQlik++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// File SQL vide : on consacre le temps restant à Qlik.
|
||||
if (qlikPossible) {
|
||||
const cible = await prochainQlik(s.qlikMinJours, new Date());
|
||||
if (cible && !tentes.has(`qlik:${cible.codeFournisseur}`)) {
|
||||
tentes.add(`qlik:${cible.codeFournisseur}`);
|
||||
await traiterQlik(cible.codeFournisseur, cible.nomFournisseur);
|
||||
depuisDernierQlik = 0;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
etat.tourTermine = true;
|
||||
console.log("[sync-nuit] plus rien en attente — tour terminé");
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
etat.derniereErreur = e instanceof Error ? e.message : String(e);
|
||||
console.error("[sync-nuit] round interrompu :", etat.derniereErreur);
|
||||
} finally {
|
||||
etat.enCours = false;
|
||||
etat.fournisseurCourant = null;
|
||||
etat.etape = null;
|
||||
etat.finAt = new Date().toISOString();
|
||||
console.log(
|
||||
`[sync-nuit] round terminé — SQL ${etat.sqlFaits} ok / ${etat.sqlEchecs} en échec,`
|
||||
+ ` Qlik ${etat.qlikFaits} ok / ${etat.qlikEchecs} en échec, tour bouclé=${etat.tourTermine}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Battement du minuteur : ne fait quelque chose que dans la fenêtre, et jamais
|
||||
* deux rounds à la fois. Volontairement silencieux hors fenêtre — il est appelé
|
||||
* chaque minute.
|
||||
*/
|
||||
export async function tickScheduler(): Promise<void> {
|
||||
try {
|
||||
const s = await readSyncSettings();
|
||||
if (!s.actif || etat.enCours) return;
|
||||
if (!debutFenetre(s)) return;
|
||||
await lancerRound("auto");
|
||||
} catch (e) {
|
||||
console.error("[sync-nuit] tick en erreur :", e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* CollectFlow — Réglages de la synchronisation nocturne.
|
||||
*
|
||||
* Rangés dans `data/.db-config.json`, comme les autres réglages de
|
||||
* l'application (base, Qlik, API FF), pour qu'il n'y ait qu'un seul endroit à
|
||||
* sauvegarder et à monter dans le conteneur.
|
||||
*
|
||||
* Module **serveur**, pas fichier d'actions : il exporte aussi des valeurs
|
||||
* (les défauts, le type), ce que `"use server"` interdit.
|
||||
*/
|
||||
|
||||
import "server-only";
|
||||
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
|
||||
const DATA_DIR = path.join(process.cwd(), "data");
|
||||
const CONFIG_FILE = path.join(DATA_DIR, ".db-config.json");
|
||||
|
||||
export interface SyncSettings {
|
||||
/**
|
||||
* Désactivé par défaut : un déploiement ne doit pas se mettre à solliciter
|
||||
* Qlik et la base la nuit suivante sans que personne l'ait décidé.
|
||||
*/
|
||||
actif: boolean;
|
||||
/** Heure d'ouverture de la fenêtre (0-23). */
|
||||
heureDebut: number;
|
||||
/** Heure de fermeture. Aucun fournisseur n'est **démarré** après. */
|
||||
heureFin: number;
|
||||
/**
|
||||
* Nombre maximum d'extractions Qlik par nuit. Chacune peut prendre 8 minutes
|
||||
* et le serveur abandonne quand il est trop sollicité : ce plafond est la
|
||||
* principale protection contre une nuit entièrement passée sur Qlik.
|
||||
*/
|
||||
qlikParNuit: number;
|
||||
/** Une extraction Qlik est intercalée tous les N fournisseurs SQL. */
|
||||
sqlParQlik: number;
|
||||
/**
|
||||
* Âge minimal des données Qlik avant de les refaire. Ce sont des agrégats
|
||||
* mensuels : les rafraîchir toutes les nuits coûterait cher pour rien.
|
||||
*/
|
||||
qlikMinJours: number;
|
||||
}
|
||||
|
||||
export const SYNC_SETTINGS_DEFAUT: SyncSettings = {
|
||||
actif: false,
|
||||
heureDebut: 0,
|
||||
heureFin: 5,
|
||||
qlikParNuit: 6,
|
||||
sqlParQlik: 20,
|
||||
qlikMinJours: 7,
|
||||
};
|
||||
|
||||
function borne(v: unknown, min: number, max: number, defaut: number): number {
|
||||
const n = Number(v);
|
||||
if (!Number.isFinite(n)) return defaut;
|
||||
return Math.min(max, Math.max(min, Math.trunc(n)));
|
||||
}
|
||||
|
||||
/** Normalise ce qui vient du fichier : il est éditable à la main. */
|
||||
function normaliser(brut: Partial<SyncSettings> | undefined): SyncSettings {
|
||||
const d = SYNC_SETTINGS_DEFAUT;
|
||||
const s: SyncSettings = {
|
||||
actif: Boolean(brut?.actif),
|
||||
heureDebut: borne(brut?.heureDebut, 0, 23, d.heureDebut),
|
||||
heureFin: borne(brut?.heureFin, 0, 23, d.heureFin),
|
||||
qlikParNuit: borne(brut?.qlikParNuit, 0, 200, d.qlikParNuit),
|
||||
sqlParQlik: borne(brut?.sqlParQlik, 1, 500, d.sqlParQlik),
|
||||
qlikMinJours: borne(brut?.qlikMinJours, 0, 365, d.qlikMinJours),
|
||||
};
|
||||
// Une fenêtre de durée nulle ne s'ouvrirait jamais : on revient au défaut
|
||||
// plutôt que de laisser le planificateur muet sans explication.
|
||||
if (s.heureDebut === s.heureFin) {
|
||||
s.heureDebut = d.heureDebut;
|
||||
s.heureFin = d.heureFin;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
export async function readSyncSettings(): Promise<SyncSettings> {
|
||||
try {
|
||||
const raw = await fs.readFile(CONFIG_FILE, "utf-8");
|
||||
const cfg = JSON.parse(raw) as { syncNuit?: Partial<SyncSettings> };
|
||||
return normaliser(cfg?.syncNuit);
|
||||
} catch {
|
||||
return { ...SYNC_SETTINGS_DEFAUT };
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveSyncSettings(patch: Partial<SyncSettings>): Promise<SyncSettings> {
|
||||
let existant: Record<string, unknown> = {};
|
||||
try {
|
||||
existant = JSON.parse(await fs.readFile(CONFIG_FILE, "utf-8"));
|
||||
} catch {
|
||||
// Pas encore de fichier : on le crée avec les seuls réglages de synchro.
|
||||
}
|
||||
const courant = normaliser((existant.syncNuit as Partial<SyncSettings>) ?? undefined);
|
||||
const suivant = normaliser({ ...courant, ...patch });
|
||||
|
||||
await fs.mkdir(DATA_DIR, { recursive: true });
|
||||
await fs.writeFile(CONFIG_FILE, JSON.stringify({ ...existant, syncNuit: suivant }, null, 2));
|
||||
console.log(`[sync-nuit] réglages enregistrés : ${JSON.stringify(suivant)}`);
|
||||
return suivant;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* CollectFlow — Minuteur de la synchronisation nocturne.
|
||||
*
|
||||
* Démarré une fois au boot par `src/instrumentation.ts`. Il bat chaque minute
|
||||
* et laisse `tickScheduler()` décider s'il y a quelque chose à faire : hors
|
||||
* fenêtre, ou si un round tourne déjà, le battement ne coûte rien.
|
||||
*
|
||||
* Conséquence assumée du choix « minuteur interne » plutôt que cron système :
|
||||
* rien ne tourne si l'application est arrêtée. En contrepartie, aucune
|
||||
* configuration côté hôte, et le minuteur se réarme tout seul au redémarrage.
|
||||
*/
|
||||
|
||||
import "server-only";
|
||||
|
||||
import { tickScheduler } from "@/features/admin/api/sync-scheduler";
|
||||
|
||||
const INTERVALLE_MS = 60_000;
|
||||
|
||||
/**
|
||||
* Garde au niveau du module : en développement, le rechargement à chaud peut
|
||||
* réévaluer `register()` plusieurs fois, et on se retrouverait avec autant de
|
||||
* minuteurs empilés.
|
||||
*/
|
||||
let minuteur: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
export function startSyncTimer(): void {
|
||||
if (minuteur) return;
|
||||
|
||||
console.log(`[sync-nuit] minuteur démarré (battement ${INTERVALLE_MS / 1000} s)`);
|
||||
minuteur = setInterval(() => {
|
||||
// `tickScheduler` capture déjà ses propres erreurs ; ce `catch` ne couvre
|
||||
// qu'un rejet inattendu, pour qu'il ne devienne jamais une exception non
|
||||
// gérée capable d'abattre le processus.
|
||||
void tickScheduler().catch((e) => {
|
||||
console.error("[sync-nuit] battement en erreur :", e instanceof Error ? e.message : String(e));
|
||||
});
|
||||
}, INTERVALLE_MS);
|
||||
|
||||
// Ne pas maintenir le processus en vie uniquement pour ce minuteur.
|
||||
minuteur.unref?.();
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* CollectFlow — Informations de connexion à l'API `/api/v1`.
|
||||
*
|
||||
* Tout ce qu'il faut pour appeler l'API depuis un script : URL de base, en-tête
|
||||
* d'authentification, liste des endpoints et exemples copiables. L'URL de base est
|
||||
* déduite de l'origine courante, pour rester juste quel que soit le déploiement.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Copy, Check, ExternalLink, Bot } from "lucide-react";
|
||||
|
||||
function CopyButton({ text }: { text: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { navigator.clipboard.writeText(text); setCopied(true); setTimeout(() => setCopied(false), 1500); }}
|
||||
className="shrink-0 rounded-lg px-2 py-1.5"
|
||||
style={{ background: "var(--bg-surface)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}
|
||||
title="Copier"
|
||||
>
|
||||
{copied ? <Check className="w-3.5 h-3.5 text-emerald-500" /> : <Copy className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function CodeLine({ children, copy }: { children: string; copy?: string }) {
|
||||
return (
|
||||
<div className="flex items-start gap-2">
|
||||
<code
|
||||
className="flex-1 rounded-lg px-2.5 py-1.5 text-[11px] font-mono break-all whitespace-pre-wrap"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-primary)" }}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
<CopyButton text={copy ?? children} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ENDPOINTS: Array<{ method: string; path: string; desc: string }> = [
|
||||
{ method: "GET", path: "/fournisseurs", desc: "Fournisseurs, avec la fraîcheur de leur instantané" },
|
||||
{ method: "GET", path: "/grid?fournisseur=CODE", desc: "Lignes de grille d'un fournisseur" },
|
||||
{ method: "GET", path: "/nomenclatures?fournisseur=CODE", desc: "Postes de nomenclature d'un fournisseur, avec nb d'articles et CA" },
|
||||
{ method: "GET", path: "/products/search?q=terme", desc: "Recherche transversale, tous fournisseurs" },
|
||||
{ method: "GET", path: "/products/{codein}", desc: "Fiche complète d'un produit" },
|
||||
{ method: "GET", path: "/network/{codeCentrale}", desc: "Métriques réseau Qlik + courbe 12 mois" },
|
||||
{ method: "GET", path: "/openapi.json", desc: "Spécification lisible par machine" },
|
||||
];
|
||||
|
||||
const PARAMS: Array<{ name: string; desc: string }> = [
|
||||
{ name: "page, limit", desc: "Pagination, sans plafond. Sur /grid, omettre limit renvoie TOUT le fournisseur en un appel ; ailleurs le défaut est 100" },
|
||||
{ name: "sort, order", desc: "Tri, ex. sort=totalCa&order=desc" },
|
||||
{ name: "search", desc: "Libellé, codein, GTIN, référence ou code centrale" },
|
||||
{ name: "nomenclature", desc: "Préfixe de nomenclature : 32 (univers), 3202 (famille), 320211 (sous-famille)" },
|
||||
{ name: "gamme, code1..code3", desc: "Filtres sur la gamme et les codes exacts de nomenclature" },
|
||||
{ name: "fields", desc: "Champs à conserver, séparés par des virgules — allège fortement la réponse" },
|
||||
{ name: "enrich", desc: "1 par défaut : métriques Qlik + gamme serveur relues à l'appel. 0 pour s'en dispenser" },
|
||||
{ name: "compute", desc: "1 par défaut : calcule le fournisseur s'il n'a jamais été ouvert (premier appel plus lent). 0 pour échouer vite" },
|
||||
];
|
||||
|
||||
export function ApiConnectionInfo() {
|
||||
const [origin, setOrigin] = useState("");
|
||||
// `window` n'existe pas au rendu serveur : lire l'origine après montage est le
|
||||
// seul moyen d'afficher l'URL réelle sans provoquer d'écart d'hydratation.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
useEffect(() => { setOrigin(window.location.origin); }, []);
|
||||
const base = `${origin || "https://votre-domaine"}/api/v1`;
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Connexion */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-[12px] font-semibold" style={{ color: "var(--text-primary)" }}>URL de base</p>
|
||||
<CodeLine>{base}</CodeLine>
|
||||
<p className="text-[12px] font-semibold pt-1" style={{ color: "var(--text-primary)" }}>Authentification</p>
|
||||
<p className="text-[11px]" style={{ color: "var(--text-muted)" }}>
|
||||
Une clé créée ci-dessous, dans l'un ou l'autre de ces en-têtes. Depuis un navigateur
|
||||
connecté, la session suffit — aucune clé n'est nécessaire.
|
||||
</p>
|
||||
<CodeLine>X-API-Key: VOTRE_CLE</CodeLine>
|
||||
<CodeLine>Authorization: Bearer VOTRE_CLE</CodeLine>
|
||||
</div>
|
||||
|
||||
{/* Endpoints */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-[12px] font-semibold" style={{ color: "var(--text-primary)" }}>Endpoints</p>
|
||||
<div className="rounded-xl overflow-hidden" style={{ border: "1px solid var(--border)" }}>
|
||||
<table className="w-full text-[11px]">
|
||||
<tbody>
|
||||
{ENDPOINTS.map((e, i) => (
|
||||
<tr key={e.path} style={{ borderTop: i === 0 ? undefined : "1px solid var(--border)" }}>
|
||||
<td className="px-2.5 py-1.5 font-mono font-bold align-top" style={{ color: "var(--accent)", width: 40 }}>{e.method}</td>
|
||||
<td className="px-2.5 py-1.5 font-mono align-top" style={{ color: "var(--text-primary)" }}>{e.path}</td>
|
||||
<td className="px-2.5 py-1.5 align-top" style={{ color: "var(--text-muted)" }}>{e.desc}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Paramètres */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-[12px] font-semibold" style={{ color: "var(--text-primary)" }}>Paramètres communs</p>
|
||||
<div className="rounded-xl overflow-hidden" style={{ border: "1px solid var(--border)" }}>
|
||||
<table className="w-full text-[11px]">
|
||||
<tbody>
|
||||
{PARAMS.map((p, i) => (
|
||||
<tr key={p.name} style={{ borderTop: i === 0 ? undefined : "1px solid var(--border)" }}>
|
||||
<td className="px-2.5 py-1.5 font-mono align-top whitespace-nowrap" style={{ color: "var(--text-primary)" }}>{p.name}</td>
|
||||
<td className="px-2.5 py-1.5 align-top" style={{ color: "var(--text-muted)" }}>{p.desc}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Exemples */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-[12px] font-semibold" style={{ color: "var(--text-primary)" }}>Exemples</p>
|
||||
<CodeLine>{`curl -H "X-API-Key: VOTRE_CLE" \\\n "${base}/products/search?q=tapis&limit=20"`}</CodeLine>
|
||||
<CodeLine>{`curl -H "X-API-Key: VOTRE_CLE" \\\n "${base}/grid?fournisseur=FOU001&fields=codein,libelle1,totalCa,codeGammeServeur"`}</CodeLine>
|
||||
<p className="text-[11px] pt-1" style={{ color: "var(--text-muted)" }}>
|
||||
Tout un fournisseur en un seul appel — il suffit d'omettre <code className="font-mono">limit</code>,
|
||||
et <code className="font-mono">meta.complet</code> confirme qu'il ne reste rien à lire :
|
||||
</p>
|
||||
<CodeLine>{`curl -H "X-API-Key: VOTRE_CLE" \\\n "${base}/grid?fournisseur=FOU001"`}</CodeLine>
|
||||
<p className="text-[11px] pt-1" style={{ color: "var(--text-muted)" }}>
|
||||
Gros fournisseur (plusieurs dizaines de milliers d'articles) : lister d'abord les
|
||||
postes de nomenclature, puis les traiter un par un.
|
||||
</p>
|
||||
<CodeLine>{`curl -H "X-API-Key: VOTRE_CLE" \\\n "${base}/nomenclatures?fournisseur=D005&niveau=1"`}</CodeLine>
|
||||
<CodeLine>{`curl -H "X-API-Key: VOTRE_CLE" \\\n "${base}/grid?fournisseur=D005&nomenclature=32"`}</CodeLine>
|
||||
</div>
|
||||
|
||||
{/* Branchement d'une IA externe (ChatGPT) */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-[12px] font-semibold flex items-center gap-1.5" style={{ color: "var(--text-primary)" }}>
|
||||
<Bot className="w-4 h-4" style={{ color: "var(--accent)" }} />
|
||||
Connecter une IA externe (ChatGPT)
|
||||
</p>
|
||||
<ol className="text-[11px] space-y-1.5 list-decimal pl-4" style={{ color: "var(--text-secondary)" }}>
|
||||
<li>Créez une clé d'API dans la section ci-dessous et copiez-la.</li>
|
||||
<li>Dans ChatGPT : <strong>Créer un GPT</strong> → onglet <strong>Configurer</strong> → <strong>Créer une action</strong>.</li>
|
||||
<li>
|
||||
Cliquez sur <strong>Importer depuis une URL</strong> et collez l'adresse du schéma :
|
||||
<div className="mt-1"><CodeLine>{`${base}/openapi.json`}</CodeLine></div>
|
||||
<span style={{ color: "var(--text-muted)" }}>
|
||||
Ce schéma est public (il ne contient aucune donnée) pour que ChatGPT puisse l'importer.
|
||||
Votre application doit être joignable depuis Internet.
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
Dans <strong>Authentification</strong>, choisissez <strong>Clé d'API</strong>, type{" "}
|
||||
<strong>Personnalisé</strong>, nom d'en-tête <code className="font-mono">X-API-Key</code>,
|
||||
et collez votre clé.
|
||||
</li>
|
||||
<li>Testez avec une question du type « cherche les produits tapis » — le GPT appellera <code className="font-mono">rechercherProduits</code>.</li>
|
||||
</ol>
|
||||
<p className="text-[11px]" style={{ color: "var(--text-muted)" }}>
|
||||
Si votre domaine public diffère de celui affiché ici, renseignez la variable
|
||||
d'environnement <code className="font-mono">COLLECTFLOW_PUBLIC_URL</code> : elle fixe l'URL
|
||||
déclarée dans le schéma.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Comportement à connaître */}
|
||||
<div className="rounded-xl p-3 text-[11px] space-y-1.5"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}>
|
||||
<p><strong>Aucun recalcul.</strong> L'API lit l'instantané de la grille, écrit quand la Grille
|
||||
est ouverte dans l'application. Un fournisseur jamais consulté renvoie <code className="font-mono">202</code>{" "}
|
||||
<code className="font-mono">not_ready</code> plutôt que de faire attendre.</p>
|
||||
<p><strong>Métriques Qlik</strong> (<code className="font-mono">network</code>) et{" "}
|
||||
<strong>gamme serveur non modifiée</strong> (<code className="font-mono">codeGammeServeur</code>) sont
|
||||
relues à chaque appel. <code className="font-mono">network</code> vaut{" "}
|
||||
<code className="font-mono">null</code> quand le produit n'a pas de données réseau.</p>
|
||||
<p>Erreurs : <code className="font-mono">{`{ "error": { "code", "message" } }`}</code> avec{" "}
|
||||
<code className="font-mono">401</code> (clé absente/invalide/révoquée),{" "}
|
||||
<code className="font-mono">400</code>, <code className="font-mono">404</code>.</p>
|
||||
</div>
|
||||
|
||||
<a
|
||||
href="/api/v1/openapi.json"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1.5 text-[12px] font-medium"
|
||||
style={{ color: "var(--accent)" }}
|
||||
>
|
||||
Ouvrir la spécification OpenAPI <ExternalLink className="w-3.5 h-3.5" />
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* CollectFlow — Gestion des clés de l'API `/api/v1`.
|
||||
*
|
||||
* La clé générée n'est affichable qu'une fois : elle est mise en évidence après
|
||||
* création, puis irrécupérable (seul son hachage est stocké côté base).
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { KeyRound, Plus, Loader2, Copy, Check, Ban, Trash2, AlertTriangle } from "lucide-react";
|
||||
import { getApiKeys, createApiKey, revokeApiKey, deleteApiKey, type ApiKeyRow } from "../api/api-key-actions";
|
||||
|
||||
function fmtDate(iso: string | null): string {
|
||||
if (!iso) return "—";
|
||||
return new Date(iso).toLocaleString("fr-FR", { dateStyle: "short", timeStyle: "short" });
|
||||
}
|
||||
|
||||
export function ApiKeyManagement() {
|
||||
const [keys, setKeys] = useState<ApiKeyRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [name, setName] = useState("");
|
||||
const [role, setRole] = useState<"admin" | "user">("user");
|
||||
const [freshKey, setFreshKey] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Pas de setState synchrone ici : `loading` démarre déjà à true, et le premier
|
||||
// statement est un await. Cela évite une cascade de rendus au montage.
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
setKeys(await getApiKeys());
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Erreur de chargement");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Chargement initial depuis la base — la donnée vient d'un système externe, pas
|
||||
// d'un état dérivé. `refresh` n'appelle setState qu'après un await.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
useEffect(() => { refresh(); }, [refresh]);
|
||||
|
||||
const handleCreate = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!name.trim() || creating) return;
|
||||
setCreating(true);
|
||||
setError(null);
|
||||
const res = await createApiKey(name.trim(), role);
|
||||
if (res.success) {
|
||||
setFreshKey(res.key);
|
||||
setName("");
|
||||
setCopied(false);
|
||||
await refresh();
|
||||
} else {
|
||||
setError(res.error);
|
||||
}
|
||||
setCreating(false);
|
||||
};
|
||||
|
||||
const handleRevoke = async (id: number) => {
|
||||
await revokeApiKey(id);
|
||||
await refresh();
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
await deleteApiKey(id);
|
||||
await refresh();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-[12px] text-[var(--text-secondary)]">
|
||||
Les clés donnent accès à l'API de lecture <code className="font-mono">/api/v1</code> (grille,
|
||||
recherche, métriques réseau) depuis un script ou un outil externe, via l'en-tête{" "}
|
||||
<code className="font-mono">X-API-Key</code>. Depuis un navigateur connecté, la session suffit.
|
||||
</p>
|
||||
|
||||
{/* Clé fraîchement créée — visible une seule fois */}
|
||||
{freshKey && (
|
||||
<div className="rounded-xl p-3.5 space-y-2"
|
||||
style={{ background: "var(--accent-bg)", border: "1px solid var(--accent-border)" }}>
|
||||
<div className="flex items-start gap-2 text-[12px] font-semibold" style={{ color: "var(--text-primary)" }}>
|
||||
<AlertTriangle className="w-4 h-4 shrink-0 mt-px" style={{ color: "var(--accent)" }} />
|
||||
Copiez cette clé maintenant — elle ne sera plus jamais affichée.
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 rounded-lg px-2.5 py-2 text-[12px] font-mono break-all"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-primary)" }}>
|
||||
{freshKey}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { navigator.clipboard.writeText(freshKey); setCopied(true); }}
|
||||
className="rounded-lg px-2.5 py-2 shrink-0"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}
|
||||
title="Copier"
|
||||
>
|
||||
{copied ? <Check className="w-4 h-4 text-emerald-500" /> : <Copy className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFreshKey(null)}
|
||||
className="text-[11px] underline"
|
||||
style={{ color: "var(--text-muted)" }}
|
||||
>
|
||||
J'ai copié la clé, masquer
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Création */}
|
||||
<form onSubmit={handleCreate} className="flex flex-col sm:flex-row gap-2">
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Nom de la clé (ex. Export comptabilité)"
|
||||
className="flex-1 rounded-lg px-3 py-2 text-[13px] outline-none"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-primary)" }}
|
||||
/>
|
||||
<select
|
||||
value={role}
|
||||
onChange={(e) => setRole(e.target.value as "admin" | "user")}
|
||||
className="rounded-lg px-3 py-2 text-[13px] outline-none"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-primary)" }}
|
||||
>
|
||||
<option value="user">Rôle : utilisateur</option>
|
||||
<option value="admin">Rôle : administrateur</option>
|
||||
</select>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={creating || !name.trim()}
|
||||
className="rounded-lg px-4 py-2 text-[13px] font-semibold text-white disabled:opacity-50 flex items-center justify-center gap-1.5"
|
||||
style={{ background: "var(--accent)" }}
|
||||
>
|
||||
{creating ? <Loader2 className="w-4 h-4 animate-spin" /> : <Plus className="w-4 h-4" />}
|
||||
Créer
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{error && (
|
||||
<p className="text-[12px]" style={{ color: "var(--accent-error)" }}>{error}</p>
|
||||
)}
|
||||
|
||||
{/* Liste */}
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 text-[12px]" style={{ color: "var(--text-muted)" }}>
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" /> Chargement…
|
||||
</div>
|
||||
) : keys.length === 0 ? (
|
||||
<p className="text-[12px]" style={{ color: "var(--text-muted)" }}>Aucune clé pour le moment.</p>
|
||||
) : (
|
||||
<div className="rounded-xl overflow-hidden" style={{ border: "1px solid var(--border)" }}>
|
||||
<table className="w-full text-[12px]">
|
||||
<thead>
|
||||
<tr style={{ background: "var(--bg-elevated)" }}>
|
||||
<th className="text-left px-3 py-2 font-semibold" style={{ color: "var(--text-secondary)" }}>Nom</th>
|
||||
<th className="text-left px-3 py-2 font-semibold" style={{ color: "var(--text-secondary)" }}>Préfixe</th>
|
||||
<th className="text-left px-3 py-2 font-semibold" style={{ color: "var(--text-secondary)" }}>Rôle</th>
|
||||
<th className="text-left px-3 py-2 font-semibold" style={{ color: "var(--text-secondary)" }}>Créée</th>
|
||||
<th className="text-left px-3 py-2 font-semibold" style={{ color: "var(--text-secondary)" }}>Dernier usage</th>
|
||||
<th className="px-3 py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{keys.map((k) => (
|
||||
<tr key={k.id} style={{ borderTop: "1px solid var(--border)", opacity: k.revokedAt ? 0.55 : 1 }}>
|
||||
<td className="px-3 py-2 font-medium" style={{ color: "var(--text-primary)" }}>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<KeyRound className="w-3.5 h-3.5" style={{ color: "var(--text-muted)" }} />
|
||||
{k.name}
|
||||
</span>
|
||||
{k.revokedAt && (
|
||||
<span className="ml-2 rounded px-1.5 py-px text-[9px] font-bold uppercase"
|
||||
style={{ background: "var(--accent-error-bg)", color: "var(--accent-error)" }}>
|
||||
Révoquée
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 font-mono" style={{ color: "var(--text-secondary)" }}>{k.keyPrefix}…</td>
|
||||
<td className="px-3 py-2" style={{ color: "var(--text-secondary)" }}>{k.role}</td>
|
||||
<td className="px-3 py-2" style={{ color: "var(--text-muted)" }}>{fmtDate(k.createdAt)}</td>
|
||||
<td className="px-3 py-2" style={{ color: "var(--text-muted)" }}>{fmtDate(k.lastUsedAt)}</td>
|
||||
<td className="px-3 py-2 text-right whitespace-nowrap">
|
||||
{!k.revokedAt ? (
|
||||
<button
|
||||
onClick={() => handleRevoke(k.id)}
|
||||
className="inline-flex items-center gap-1 rounded-lg px-2 py-1"
|
||||
style={{ color: "var(--accent-error)" }}
|
||||
title="Révoquer"
|
||||
>
|
||||
<Ban className="w-3.5 h-3.5" /> Révoquer
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleDelete(k.id)}
|
||||
className="inline-flex items-center gap-1 rounded-lg px-2 py-1"
|
||||
style={{ color: "var(--text-muted)" }}
|
||||
title="Supprimer définitivement"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" /> Supprimer
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* CollectFlow — Préchauffage des données exposées par l'API.
|
||||
*
|
||||
* L'API `/api/v1` ne calcule jamais : elle lit un instantané écrit quand la Grille est
|
||||
* ouverte dans l'application. Sans ce préchauffage, une IA externe ne verrait que les
|
||||
* fournisseurs déjà consultés à la main — d'où ce bouton, qui calcule tout le catalogue
|
||||
* une bonne fois.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Loader2, Play, CheckCircle, AlertTriangle, Database } from "lucide-react";
|
||||
|
||||
interface WarmupState {
|
||||
status: "idle" | "running" | "success" | "error";
|
||||
total: number;
|
||||
done: number;
|
||||
skipped: number;
|
||||
failed: number;
|
||||
currentFournisseur?: string;
|
||||
finishedAt?: string;
|
||||
error?: string;
|
||||
lastErrors: string[];
|
||||
}
|
||||
|
||||
export function GridWarmup() {
|
||||
const [state, setState] = useState<WarmupState | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const pollRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const running = state?.status === "running";
|
||||
|
||||
const fetchState = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/admin/grid-warmup");
|
||||
if (res.ok) setState(await res.json());
|
||||
} catch {
|
||||
// Réseau instable : on retentera au tick suivant.
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Récupère l'état au montage : un préchauffage peut déjà tourner.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
useEffect(() => { fetchState(); }, [fetchState]);
|
||||
|
||||
// Suivi de l'avancement tant que le job tourne.
|
||||
useEffect(() => {
|
||||
if (!running) return;
|
||||
let cancelled = false;
|
||||
const tick = async () => {
|
||||
await fetchState();
|
||||
if (!cancelled) pollRef.current = setTimeout(tick, 2000);
|
||||
};
|
||||
pollRef.current = setTimeout(tick, 2000);
|
||||
return () => { cancelled = true; if (pollRef.current) clearTimeout(pollRef.current); };
|
||||
}, [running, fetchState]);
|
||||
|
||||
const start = async () => {
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch("/api/admin/grid-warmup?staleHours=24", { method: "POST" });
|
||||
const data = await res.json();
|
||||
if (!res.ok) { setError(data.error ?? `Erreur ${res.status}`); return; }
|
||||
setState(data);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
};
|
||||
|
||||
const traites = (state?.done ?? 0) + (state?.skipped ?? 0) + (state?.failed ?? 0);
|
||||
const pct = state && state.total > 0 ? Math.round((traites / state.total) * 100) : 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<p className="text-[12px]" style={{ color: "var(--text-secondary)" }}>
|
||||
L'API ne recalcule jamais : elle lit un instantané écrit quand la Grille est ouverte dans
|
||||
l'application. Sans préchauffage, une IA externe ne verrait que les fournisseurs déjà consultés.
|
||||
Ce bouton calcule <strong>tous les fournisseurs</strong> ; ceux déjà à jour depuis moins de 24 h sont
|
||||
sautés, il est donc sans risque de le relancer.
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-2.5">
|
||||
<button
|
||||
onClick={start}
|
||||
disabled={running}
|
||||
className="rounded-lg px-4 py-2 text-[13px] font-semibold text-white disabled:opacity-50 flex items-center gap-1.5"
|
||||
style={{ background: "var(--accent)" }}
|
||||
>
|
||||
{running ? <Loader2 className="w-4 h-4 animate-spin" /> : <Play className="w-4 h-4" />}
|
||||
{running ? "Préparation en cours…" : "Préparer les données pour l'API"}
|
||||
</button>
|
||||
{state && state.total > 0 && (
|
||||
<span className="text-[12px] tabular-nums" style={{ color: "var(--text-secondary)" }}>
|
||||
{traites} / {state.total} ({pct} %)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{running && state && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="h-1.5 rounded-full overflow-hidden" style={{ background: "var(--bg-elevated)" }}>
|
||||
<div className="h-full transition-all" style={{ width: `${pct}%`, background: "var(--accent)" }} />
|
||||
</div>
|
||||
{state.currentFournisseur && (
|
||||
<p className="text-[11px] truncate" style={{ color: "var(--text-muted)" }}>
|
||||
En cours : {state.currentFournisseur}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state?.status === "success" && (
|
||||
<div className="flex items-start gap-2 text-[12px]" style={{ color: "var(--text-secondary)" }}>
|
||||
<CheckCircle className="w-4 h-4 shrink-0 mt-px text-emerald-500" />
|
||||
<span>
|
||||
Terminé : <strong>{state.done}</strong> fournisseur(s) calculé(s),{" "}
|
||||
<strong>{state.skipped}</strong> déjà à jour
|
||||
{state.failed > 0 && <>, <strong style={{ color: "var(--accent-error)" }}>{state.failed} en échec</strong></>}.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state && state.failed > 0 && state.lastErrors.length > 0 && (
|
||||
<details className="text-[11px]" style={{ color: "var(--text-muted)" }}>
|
||||
<summary className="cursor-pointer">Voir les échecs ({state.lastErrors.length} premiers)</summary>
|
||||
<ul className="mt-1 pl-4 list-disc space-y-0.5">
|
||||
{state.lastErrors.map((e, i) => <li key={i} className="break-all">{e}</li>)}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{(error || state?.error) && (
|
||||
<div className="flex items-start gap-2 text-[12px]" style={{ color: "var(--accent-error)" }}>
|
||||
<AlertTriangle className="w-4 h-4 shrink-0 mt-px" />
|
||||
<span>{error ?? state?.error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state?.status === "idle" && (
|
||||
<p className="text-[11px] flex items-center gap-1.5" style={{ color: "var(--text-muted)" }}>
|
||||
<Database className="w-3.5 h-3.5" />
|
||||
Aucun préchauffage lancé depuis le démarrage de l'application.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,14 +9,16 @@ import {
|
||||
pgGetNomenclatureByFournisseur,
|
||||
pgGetStockByFournisseur,
|
||||
pgGetCommandesByFournisseur,
|
||||
pgGetPrixVenteByFournisseur,
|
||||
type PgStockRow,
|
||||
} from "@/lib/pg-ff-client";
|
||||
import { db } from "@/db";
|
||||
import { sessionSnapshots } from "@/db/schema";
|
||||
import { eq, desc } from "drizzle-orm";
|
||||
import { getNetworkMetricsByCodeCentrale } from "@/lib/qlik-network-cache";
|
||||
|
||||
const NB_MAGASINS_RESEAU = 270;
|
||||
import { NB_MAGASINS_RESEAU } from "@/features/grid/lib/network-trend";
|
||||
// Persiste l'instantané lu par /api/v1 : sans lui, l'API n'aurait aucune donnée.
|
||||
import { upsertGridRows } from "@/lib/grid-store";
|
||||
|
||||
interface GetProductRowsInput {
|
||||
codeFournisseur: string;
|
||||
@@ -47,10 +49,50 @@ export function invalidateGridRowsCache(codeFournisseur?: string) {
|
||||
gridRowsPending.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Rafraîchit UNIQUEMENT la colonne INIT (codeGammeInit = gamme serveur courante)
|
||||
* sur des lignes servies depuis le cache. La colonne Gamme (codeGamme) n'est pas
|
||||
* touchée : elle conserve la valeur issue du snapshot. Ainsi l'état serveur (INIT)
|
||||
* est toujours à jour à chaque chargement, même sur un hit du cache 10 min — et le
|
||||
* marqueur "modifié" (Gamme ≠ INIT) se résorbe dès que le serveur rattrape la modif.
|
||||
*/
|
||||
async function refreshGammeInit(rows: ProductRow[], codeFournisseur: string): Promise<void> {
|
||||
try {
|
||||
const gammeMap = await pgGetGammesByFournisseur(codeFournisseur);
|
||||
if (gammeMap.size === 0) return;
|
||||
for (const row of rows) {
|
||||
const live = gammeMap.get(row.codein);
|
||||
if (live !== undefined) row.codeGammeInit = live as GammeCode;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[getProductRows] refreshGammeInit error:", (e as Error).message?.slice(0, 200));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Écrit l'instantané de grille lu par /api/v1 (table `grid_rows`).
|
||||
*
|
||||
* Seul `magasin = TOTAL` est persisté : `ProductRow` embarque déjà les ventilations
|
||||
* par magasin (sales12mByStore, caByStore…), inutile de stocker trois variantes.
|
||||
* Toute erreur est avalée : la persistance ne doit jamais casser l'affichage.
|
||||
*/
|
||||
async function persistGridSnapshot(input: GetProductRowsInput, rows: ProductRow[]): Promise<void> {
|
||||
if ((input.magasin ?? "TOTAL") !== "TOTAL") return;
|
||||
try {
|
||||
const n = await upsertGridRows(input.codeFournisseur, rows);
|
||||
console.log(`[getProductRows] instantané persisté: ${n} lignes pour ${input.codeFournisseur}`);
|
||||
} catch (e) {
|
||||
console.error("[getProductRows] persistance grid_rows KO:", (e as Error).message?.slice(0, 200));
|
||||
}
|
||||
}
|
||||
|
||||
export async function getProductRows(input: GetProductRowsInput): Promise<ProductRow[]> {
|
||||
const cacheKey = gridRowsCacheKey(input);
|
||||
const cached = gridRowsCache.get(cacheKey);
|
||||
if (!input.forceRefresh && cached && Date.now() - cached.createdAt < GRID_ROWS_CACHE_TTL_MS) {
|
||||
// La colonne INIT doit refléter l'état serveur à CHAQUE chargement,
|
||||
// y compris sur un hit de cache (les données lourdes restent, elles, cachées).
|
||||
await refreshGammeInit(cached.rows, input.codeFournisseur);
|
||||
return cached.rows;
|
||||
}
|
||||
|
||||
@@ -62,6 +104,10 @@ export async function getProductRows(input: GetProductRowsInput): Promise<Produc
|
||||
const promise = buildProductRows(input).then((rows) => {
|
||||
gridRowsCache.set(cacheKey, { rows, createdAt: Date.now() });
|
||||
gridRowsPending.delete(cacheKey);
|
||||
// Persiste l'instantané pour /api/v1 : le calcul vient d'avoir lieu, on
|
||||
// arrête simplement de jeter le résultat. Volontairement NON bloquant —
|
||||
// l'affichage de la Grille ne doit pas attendre l'écriture.
|
||||
void persistGridSnapshot(input, rows);
|
||||
return rows;
|
||||
}).catch((error) => {
|
||||
gridRowsPending.delete(cacheKey);
|
||||
@@ -79,7 +125,7 @@ async function buildProductRows(input: GetProductRowsInput): Promise<ProductRow[
|
||||
try {
|
||||
const { dateDebut, dateFin } = buildLast12MonthsRange();
|
||||
|
||||
// ─── Phase 1 : 6 requêtes SQL en parallèle ────────────────────────────
|
||||
// ─── Phase 1 : 7 requêtes SQL en parallèle ────────────────────────────
|
||||
// Remplace des centaines/milliers d'appels HTTP per-article.
|
||||
const [
|
||||
articles,
|
||||
@@ -88,6 +134,7 @@ async function buildProductRows(input: GetProductRowsInput): Promise<ProductRow[
|
||||
nomMap,
|
||||
stockMap,
|
||||
commandesMap,
|
||||
prixVenteMap,
|
||||
] = await Promise.all([
|
||||
pgGetArticlesByFournisseur(codeFournisseur).catch(e => { console.error("[getProductRows] pgGetArticlesByFournisseur ERROR:", e); return []; }),
|
||||
pgGetMensuelByFournisseur(codeFournisseur, dateDebut, dateFin).catch(e => { console.error("[getProductRows] pgGetMensuelByFournisseur ERROR:", e); return []; }),
|
||||
@@ -95,6 +142,7 @@ async function buildProductRows(input: GetProductRowsInput): Promise<ProductRow[
|
||||
pgGetNomenclatureByFournisseur(codeFournisseur).catch(e => { console.error("[getProductRows] pgGetNomenclatureByFournisseur ERROR:", e); return new Map(); }),
|
||||
pgGetStockByFournisseur(codeFournisseur).catch(e => { console.error("[getProductRows] pgGetStockByFournisseur ERROR:", e); return new Map<string, PgStockRow[]>(); }),
|
||||
pgGetCommandesByFournisseur(codeFournisseur).catch(e => { console.error("[getProductRows] pgGetCommandesByFournisseur ERROR:", e); return new Map<string, number>(); }),
|
||||
pgGetPrixVenteByFournisseur(codeFournisseur).catch(e => { console.error("[getProductRows] pgGetPrixVenteByFournisseur ERROR:", e); return new Map<string, Record<string, number>>(); }),
|
||||
]);
|
||||
|
||||
console.log(`[getProductRows] ${articles.length} articles, ${mensuelRows.length} mensuel rows, ${gammeMap.size} gammes`);
|
||||
@@ -243,6 +291,25 @@ async function buildProductRows(input: GetProductRowsInput): Promise<ProductRow[
|
||||
product.caByStore[site] = sCa;
|
||||
product.margeByStore[site] = sMarge;
|
||||
}
|
||||
|
||||
// Le stock TOTAL se recalcule ici, à partir des séries par site.
|
||||
//
|
||||
// Il était sommé plus haut depuis les lignes mensuelles brutes, ce
|
||||
// qui perdait tout magasin SANS MOUVEMENT dans le mois : le stock
|
||||
// est un niveau, pas un flux — un magasin qui n'a rien vendu ni reçu
|
||||
// détient toujours sa marchandise. Sur un produit à faible rotation,
|
||||
// « tous magasins » n'affichait donc que le site actif, en pratique
|
||||
// Frouard. Les séries par site, elles, sont reportées d'un mois sur
|
||||
// l'autre : leur somme est la bonne.
|
||||
const sites = Object.keys(product.stock12mByStore);
|
||||
if (sites.length > 0) {
|
||||
for (const periode of sortedPeriods) {
|
||||
product.stock12m[periode] = sites.reduce(
|
||||
(total, site) => total + (product.stock12mByStore![site][periode] ?? 0),
|
||||
0,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// workingStores : sites avec au moins 1 vente sur les 12 derniers mois
|
||||
@@ -310,10 +377,42 @@ async function buildProductRows(input: GetProductRowsInput): Promise<ProductRow[
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Phase 7b : Prix de vente (cube_pv, sinon le PV du cube de stock) ─
|
||||
//
|
||||
// `article_infosup.prix_vente_mini`, renseigné en Phase 3, n'est qu'une
|
||||
// borne de paramétrage : elle est vide en base et laissait la colonne PV
|
||||
// entièrement creuse. Le vrai prix vit dans `cube_pv`, par article ET par
|
||||
// site. On garde le détail par magasin — deux magasins peuvent ne pas
|
||||
// pratiquer le même prix, et l'afficher vaut mieux que le moyenner.
|
||||
for (const product of productMap.values()) {
|
||||
const parSite = prixVenteMap.get(product.codein);
|
||||
if (parSite && Object.keys(parSite).length > 0) {
|
||||
product.prixVenteByStore = parSite;
|
||||
} else {
|
||||
// Filet de secours : le cube de stock porte le même PV, mais
|
||||
// seulement pour les articles qui ont une ligne de stock.
|
||||
const depuisStock: Record<string, number> = {};
|
||||
for (const s of stockMap.get(product.codein) ?? []) {
|
||||
const pv = Number(s.pv);
|
||||
const site = String(s.site ?? "").trim();
|
||||
if (site && Number.isFinite(pv) && pv > 0) depuisStock[site] = pv;
|
||||
}
|
||||
if (Object.keys(depuisStock).length > 0) product.prixVenteByStore = depuisStock;
|
||||
}
|
||||
|
||||
const prix = Object.values(product.prixVenteByStore ?? {});
|
||||
// Le plus élevé quand les magasins divergent : une moyenne inventerait
|
||||
// un prix qu'aucune caisse ne pratique. L'écart est signalé à l'écran.
|
||||
if (prix.length > 0) product.prixVente = Math.max(...prix);
|
||||
}
|
||||
|
||||
// ─── Phase 8 : Données réseau Qlik (CA / Qté / nb magasins par code centrale) ──
|
||||
await enrichWithNetworkMetrics(productMap);
|
||||
|
||||
// ─── Phase 9 : Restaurer gammes depuis dernier snapshot ──────────────
|
||||
// La colonne Gamme (codeGamme) conserve la valeur du snapshot telle quelle.
|
||||
// La colonne INIT (codeGammeInit) reste, elle, l'état LIVE du serveur
|
||||
// rechargé en Phase 6 — jamais écrasée ici.
|
||||
try {
|
||||
const snaps = await db
|
||||
.select()
|
||||
@@ -327,7 +426,7 @@ async function buildProductRows(input: GetProductRowsInput): Promise<ProductRow[
|
||||
for (const [codein, change] of Object.entries(changes)) {
|
||||
const product = productMap.get(codein);
|
||||
if (product && change.after) {
|
||||
// codeGammeInit reste figé — seulement codeGamme est overridé
|
||||
// codeGammeInit reste figé (état serveur) — seul codeGamme est overridé
|
||||
product.codeGamme = change.after as GammeCode;
|
||||
}
|
||||
}
|
||||
@@ -348,7 +447,14 @@ async function buildProductRows(input: GetProductRowsInput): Promise<ProductRow[
|
||||
|
||||
} catch (error) {
|
||||
console.error(`[getProductRows] Error for ${codeFournisseur}:`, error);
|
||||
return [];
|
||||
// Ne PAS renvoyer [] : une liste vide est indiscernable d'un fournisseur
|
||||
// sans article. La Grille affichait une page blanche sans explication, et
|
||||
// la synchro nocturne prenait la panne pour un fournisseur vide — qu'elle
|
||||
// désactivait alors automatiquement. La remonter laisse chaque appelant la
|
||||
// traiter comme une erreur (bandeau rouge, statut « echec »).
|
||||
throw error instanceof Error
|
||||
? error
|
||||
: new Error(`Calcul de la grille impossible pour ${codeFournisseur} : ${String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -369,9 +475,22 @@ async function reconcileSelectedStoreFromMensuelApi(
|
||||
|
||||
if (candidates.length === 0) return;
|
||||
|
||||
// Ce rattrapage fait UNE requête HTTP par article : sur un gros fournisseur,
|
||||
// les candidats se comptent par milliers et le changement de magasin se fige
|
||||
// plusieurs minutes. On le borne, et on dit ce qui a été laissé de côté
|
||||
// plutôt que de tronquer en silence.
|
||||
const PLAFOND = Number(process.env.GRID_STORE_RECONCILE_MAX ?? 300);
|
||||
const retenus = candidates.slice(0, PLAFOND);
|
||||
if (candidates.length > retenus.length) {
|
||||
console.warn(
|
||||
`[getProductRows] rattrapage magasin ${magasin} borné à ${retenus.length} articles`
|
||||
+ ` sur ${candidates.length} : les autres gardent la valeur SQL.`,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const mensuelMap = await getMensuelByArticles(
|
||||
candidates.map((row) => ({
|
||||
retenus.map((row) => ({
|
||||
codein: row.codein,
|
||||
libelle1: row.libelle1,
|
||||
codefou: row.codeFournisseur,
|
||||
@@ -383,7 +502,7 @@ async function reconcileSelectedStoreFromMensuelApi(
|
||||
);
|
||||
|
||||
let fixedRows = 0;
|
||||
for (const row of candidates) {
|
||||
for (const row of retenus) {
|
||||
const entries = (mensuelMap.get(row.codein) ?? []).filter((entry) => entry.site === magasin);
|
||||
if (entries.length === 0) continue;
|
||||
|
||||
@@ -391,8 +510,10 @@ async function reconcileSelectedStoreFromMensuelApi(
|
||||
for (const entry of entries) {
|
||||
const period = entry.mois.replace("-", "");
|
||||
if (!sortedPeriods.includes(period)) continue;
|
||||
const qty = Math.abs(Number(entry.ventes?.qte_vendue ?? 0) || 0);
|
||||
const ca = Math.abs(Number(entry.ventes?.ca_ht ?? 0) || 0);
|
||||
// qte_vendue / ca_ht sont NÉGATIFS côté API (ventes nettes) : on
|
||||
// les nie au lieu de Math.abs pour que les retours restent déduits.
|
||||
const qty = -(Number(entry.ventes?.qte_vendue ?? 0) || 0);
|
||||
const ca = -(Number(entry.ventes?.ca_ht ?? 0) || 0);
|
||||
const marge = Number(entry.ventes?.marge ?? 0) || 0;
|
||||
const stock = Number(entry.stock_fin_mois ?? 0) || 0;
|
||||
const receptions = Number(entry.receptions?.qte_recue ?? 0) || 0;
|
||||
@@ -457,6 +578,29 @@ async function reconcileSelectedStoreFromMensuelApi(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrait la série « nombre de magasins vendeurs » du détail mensuel complet.
|
||||
*
|
||||
* `metricsByMonth` porte les cinq mesures Qlik du mois ; seule `nbMag` sert à la
|
||||
* deuxième courbe de la carte tendance. Renvoie `null` plutôt qu'un objet vide
|
||||
* pour que l'UI distingue « pas de donnée » de « zéro magasin ».
|
||||
*/
|
||||
function nbMagParMois(
|
||||
metricsByMonth: Record<string, { nbMag?: number }> | null | undefined,
|
||||
): Record<string, number> | null {
|
||||
if (!metricsByMonth) return null;
|
||||
const parMois: Record<string, number> = {};
|
||||
for (const [mois] of Object.entries(metricsByMonth)) {
|
||||
// Un mois PRÉSENT dans le détail est un mois extrait : s'il n'a pas de
|
||||
// `nbMag`, c'est qu'aucun magasin n'a vendu, donc zéro. Le traiter comme
|
||||
// « manquant » amputait la série et faisait disparaître toute la courbe
|
||||
// (les anciens caches n'écrivaient `nbMag` que sur les mois avec vente).
|
||||
const nb = Number(metricsByMonth[mois]?.nbMag);
|
||||
parMois[mois] = Number.isFinite(nb) ? nb : 0;
|
||||
}
|
||||
return Object.keys(parMois).length > 0 ? parMois : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enrichit les produits avec les metriques reseau Qlik (cache qlik_network_metrics),
|
||||
* jointes par code centrale. Degradation propre si la sync Qlik n'a jamais tourne
|
||||
@@ -474,6 +618,8 @@ async function enrichWithNetworkMetrics(productMap: Map<string, ProductRow>): Pr
|
||||
if (byCodeCentrale.size === 0) return;
|
||||
|
||||
const metrics = await getNetworkMetricsByCodeCentrale([...byCodeCentrale.keys()]);
|
||||
const sampleKey = [...byCodeCentrale.keys()].find((cc) => (metrics.get(cc)?.caReseau ?? 0) > 0) ?? [...byCodeCentrale.keys()].find((cc) => metrics.has(cc));
|
||||
console.log(`[getProductRows] réseau: ${byCodeCentrale.size} codes centraux → ${metrics.size} matchés en cache. Sample:`, sampleKey ? JSON.stringify(metrics.get(sampleKey)) : "aucun match");
|
||||
for (const [cc, products] of byCodeCentrale.entries()) {
|
||||
const m = metrics.get(cc);
|
||||
if (!m) continue;
|
||||
@@ -481,7 +627,11 @@ async function enrichWithNetworkMetrics(productMap: Map<string, ProductRow>): Pr
|
||||
product.caReseau = m.caReseau;
|
||||
product.qteReseau = m.qteReseau;
|
||||
product.nbMagasinsReseau = m.nbMagasinsReseau;
|
||||
product.caParMagasinReseau = m.caParMagasinReseau;
|
||||
product.margePctReseau = m.margePctReseau;
|
||||
product.tauxPresenceReseau = m.nbMagasinsReseau / NB_MAGASINS_RESEAU;
|
||||
product.qteReseauByMonth = m.qteByMonth ?? null;
|
||||
product.nbMagReseauByMonth = nbMagParMois(m.metricsByMonth);
|
||||
product.networkFetchedAt = m.fetchedAt ?? undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,458 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* CollectFlow — Trous d'assortiment : ce qu'un magasin ne travaille pas.
|
||||
*
|
||||
* On part du CLASSEMENT AFFICHÉ, pas d'un classement maison : le tri en cours
|
||||
* dans la Grille — CA réseau, quantité, marge, peu importe — fixe l'ordre et
|
||||
* donc ce que « le haut du classement » veut dire. Recalculer un ordre ici
|
||||
* répondrait à une autre question que celle qu'on vient de poser à l'écran.
|
||||
*
|
||||
* « Non travaillé » = aucune vente sur 12 mois ET aucun stock au dernier mois
|
||||
* connu. Les deux conditions comptent : un article sans vente mais en stock est
|
||||
* détenu par le magasin, ce n'est pas un trou d'assortiment mais un invendu.
|
||||
*/
|
||||
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { PackageX, Store, FileSpreadsheet, Loader2 } from "lucide-react";
|
||||
import { DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { TuileStat } from "@/features/grid/components/stat-tile";
|
||||
import { SITE_LABELS } from "@/features/grid/lib/months";
|
||||
import type { ProductRow } from "@/types/grid";
|
||||
|
||||
/** Ligne du classement affiché : le produit et la valeur qui l'a classé. */
|
||||
export interface LigneClassee {
|
||||
row: ProductRow;
|
||||
valeurCritere: unknown;
|
||||
}
|
||||
|
||||
const PROFONDEURS = [100, 200, 500] as const;
|
||||
export const PROFONDEUR_MAX = PROFONDEURS[PROFONDEURS.length - 1];
|
||||
|
||||
const nb = (v: number) => Math.round(v).toLocaleString("fr-FR");
|
||||
const eur0 = (v: number) => `${Math.round(v).toLocaleString("fr-FR")} €`;
|
||||
const eur2 = (v: number) => v.toLocaleString("fr-FR", { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + " €";
|
||||
|
||||
/**
|
||||
* Libellé et mise en forme de la colonne de tri.
|
||||
*
|
||||
* Le tri porte sur des grandeurs sans commune mesure — des euros, des unités,
|
||||
* des pourcentages. Afficher la valeur brute rendrait la colonne illisible, et
|
||||
* ne rien afficher priverait le classement de sa justification.
|
||||
*/
|
||||
interface Critere {
|
||||
label: string;
|
||||
format: (v: number) => string;
|
||||
/** Format de nombre Excel — la valeur part BRUTE dans le tableur, qui doit
|
||||
* pouvoir la trier et la sommer ; seule sa présentation est reprise ici. */
|
||||
xlsx: string;
|
||||
/** Normalisation avant export, quand la donnée stockée n'est pas l'unité affichée. */
|
||||
brut?: (v: number) => number;
|
||||
}
|
||||
|
||||
const CRITERES: Record<string, Critere> = {
|
||||
caReseau: { label: "CA réseau", format: eur0, xlsx: '#,##0 "€"' },
|
||||
qteReseau: { label: "Qté réseau", format: nb, xlsx: "#,##0" },
|
||||
nbMagasinsReseau: { label: "Magasins réseau", format: nb, xlsx: "#,##0" },
|
||||
tendanceReseau: { label: "Tendance réseau", format: (v) => `${v >= 0 ? "+" : ""}${Math.round(v * 100)} %`, xlsx: "0 %" },
|
||||
prixMoyenReseau: { label: "PV moyen réseau", format: eur2, xlsx: '#,##0.00 "€"' },
|
||||
prixVente: { label: "PV magasin", format: eur2, xlsx: '#,##0.00 "€"' },
|
||||
caParMagasinReseau: { label: "CA / magasin réseau", format: eur0, xlsx: '#,##0 "€"' },
|
||||
margePctReseau: { label: "Marge % réseau", format: (v) => `${(Math.abs(v) <= 1 ? v * 100 : v).toFixed(1)} %`,
|
||||
xlsx: '0.0 "%"', brut: (v) => (Math.abs(v) <= 1 ? v * 100 : v) },
|
||||
totalQuantite: { label: "Qté 12 m", format: nb, xlsx: "#,##0" },
|
||||
totalCa: { label: "CA 12 m", format: eur0, xlsx: '#,##0 "€"' },
|
||||
totalMarge: { label: "Marge 12 m", format: eur0, xlsx: '#,##0 "€"' },
|
||||
};
|
||||
|
||||
function libelleCritere(id: string | null): string {
|
||||
if (!id) return "ordre d'affichage";
|
||||
return CRITERES[id]?.label ?? id;
|
||||
}
|
||||
|
||||
function formatCritere(id: string | null, valeur: unknown): string {
|
||||
if (valeur == null) return "—";
|
||||
if (typeof valeur !== "number") return String(valeur);
|
||||
// Les sentinelles de tri (valeurs manquantes, produit « nouveau ») ne sont
|
||||
// pas des mesures : les écrire donnerait un nombre qui n'existe pas.
|
||||
if (!Number.isFinite(valeur)) return "—";
|
||||
return (id && CRITERES[id]?.format(valeur)) ?? nb(valeur);
|
||||
}
|
||||
|
||||
/** Ce que le magasin fait de ce produit, et ce que les autres en font. */
|
||||
interface Constat {
|
||||
ventesMagasin: number;
|
||||
stockMagasin: number;
|
||||
/** Le magasin a déjà détenu ce produit sur la période (stock ou réception). */
|
||||
dejaDetenu: boolean;
|
||||
ventesAilleurs: number;
|
||||
stockAilleurs: number;
|
||||
}
|
||||
|
||||
function constater(row: ProductRow, site: string, mois: string[]): Constat {
|
||||
const ventesParSite = row.sales12mByStore ?? {};
|
||||
const stockParSite = row.stock12mByStore ?? {};
|
||||
const receptionsParSite = row.receptions12mByStore ?? {};
|
||||
|
||||
const somme = (serie?: Record<string, number>) =>
|
||||
mois.reduce((total, m) => total + (serie?.[m] ?? 0), 0);
|
||||
const dernier = (serie?: Record<string, number>) => serie?.[mois[mois.length - 1]] ?? 0;
|
||||
|
||||
const ventesMagasin = row.quantiteByStore?.[site] ?? somme(ventesParSite[site]);
|
||||
const stockMagasin = dernier(stockParSite[site]);
|
||||
const maxStock = mois.reduce((max, m) => Math.max(max, stockParSite[site]?.[m] ?? 0), 0);
|
||||
|
||||
let ventesAilleurs = 0;
|
||||
let stockAilleurs = 0;
|
||||
for (const autre of new Set([...Object.keys(ventesParSite), ...Object.keys(stockParSite)])) {
|
||||
if (autre === site) continue;
|
||||
ventesAilleurs += row.quantiteByStore?.[autre] ?? somme(ventesParSite[autre]);
|
||||
stockAilleurs += dernier(stockParSite[autre]);
|
||||
}
|
||||
|
||||
return {
|
||||
ventesMagasin,
|
||||
stockMagasin,
|
||||
dejaDetenu: maxStock > 0 || somme(receptionsParSite[site]) > 0,
|
||||
ventesAilleurs,
|
||||
stockAilleurs,
|
||||
};
|
||||
}
|
||||
|
||||
/** Ligne prête pour le tableur : le rang, le produit, le constat. */
|
||||
interface TrouClasse {
|
||||
row: ProductRow;
|
||||
valeurCritere: unknown;
|
||||
rang: number;
|
||||
constat: Constat;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classeur Excel de la liste affichée.
|
||||
*
|
||||
* Deux feuilles : les données seules d'un côté — en-têtes ligne 1, filtre
|
||||
* automatique, valeurs NUMÉRIQUES pour que le tableur puisse trier et sommer —
|
||||
* et le contexte de l'extraction de l'autre. Mêler les deux dans une même
|
||||
* feuille condamnerait le filtre et le tri, alors que ce fichier a vocation à
|
||||
* être retravaillé.
|
||||
*
|
||||
* `exceljs` est chargé à la demande : il pèse lourd, et la Grille n'a pas à le
|
||||
* transporter pour tous ceux qui n'exportent jamais.
|
||||
*/
|
||||
async function construireClasseur(
|
||||
trous: TrouClasse[],
|
||||
contexte: { fournisseur?: string; magasin: string; critere: string; profondeur: number; examines: number },
|
||||
critereId: string | null,
|
||||
): Promise<Blob> {
|
||||
const { Workbook } = await import("exceljs");
|
||||
const classeur = new Workbook();
|
||||
classeur.creator = "CollectFlow";
|
||||
classeur.created = new Date();
|
||||
|
||||
const critere = critereId ? CRITERES[critereId] : undefined;
|
||||
const feuille = classeur.addWorksheet("Non travaillés", { views: [{ state: "frozen", ySplit: 1 }] });
|
||||
|
||||
feuille.columns = [
|
||||
{ header: "Rang", key: "rang", width: 8 },
|
||||
{ header: "Code interne", key: "codein", width: 14 },
|
||||
{ header: "Référence", key: "reference", width: 18 },
|
||||
{ header: "EAN / GTIN", key: "gtin", width: 16 },
|
||||
{ header: "Désignation", key: "libelle", width: 45 },
|
||||
{ header: "Famille", key: "famille", width: 30 },
|
||||
{ header: contexte.critere, key: "critere", width: 18 },
|
||||
{ header: "Ventes ailleurs 12 m", key: "ventesAilleurs", width: 20 },
|
||||
{ header: "Stock ailleurs", key: "stockAilleurs", width: 15 },
|
||||
{ header: "Constat", key: "constat", width: 24 },
|
||||
];
|
||||
|
||||
feuille.getRow(1).font = { bold: true, color: { argb: "FFFFFFFF" } };
|
||||
feuille.getRow(1).fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FF0F172A" } };
|
||||
feuille.getRow(1).height = 20;
|
||||
|
||||
for (const { row, valeurCritere, rang, constat } of trous) {
|
||||
feuille.addRow({
|
||||
rang,
|
||||
codein: row.codein,
|
||||
reference: row.reference || "",
|
||||
gtin: row.gtin || "",
|
||||
libelle: row.libelle1 || "",
|
||||
famille: [row.code3, row.libelle3].filter(Boolean).join(" — "),
|
||||
critere: typeof valeurCritere === "number" && Number.isFinite(valeurCritere)
|
||||
? (critere?.brut?.(valeurCritere) ?? valeurCritere)
|
||||
: (valeurCritere == null ? "" : String(valeurCritere)),
|
||||
ventesAilleurs: constat.ventesAilleurs,
|
||||
stockAilleurs: constat.stockAilleurs,
|
||||
constat: constat.dejaDetenu ? "déjà détenu, sans vente" : "jamais détenu",
|
||||
});
|
||||
}
|
||||
|
||||
if (critere) feuille.getColumn("critere").numFmt = critere.xlsx;
|
||||
feuille.getColumn("ventesAilleurs").numFmt = "#,##0";
|
||||
feuille.getColumn("stockAilleurs").numFmt = "#,##0";
|
||||
feuille.autoFilter = { from: { row: 1, column: 1 }, to: { row: 1, column: feuille.columns.length } };
|
||||
|
||||
const contexteFeuille = classeur.addWorksheet("Contexte");
|
||||
contexteFeuille.columns = [{ width: 26 }, { width: 60 }];
|
||||
for (const [cle, valeur] of [
|
||||
["Extraction", "Produits non travaillés par un magasin"],
|
||||
["Fournisseur", contexte.fournisseur ?? "—"],
|
||||
["Magasin examiné", contexte.magasin],
|
||||
["Classement", `${contexte.critere} — ${contexte.profondeur} premiers (${contexte.examines} examinés)`],
|
||||
["Critère retenu", "aucune vente sur 12 mois glissants ET aucun stock au dernier mois connu"],
|
||||
["Non travaillés", String(trous.length)],
|
||||
["Généré le", new Date().toLocaleString("fr-FR")],
|
||||
]) {
|
||||
const ligne = contexteFeuille.addRow([cle, valeur]);
|
||||
ligne.getCell(1).font = { bold: true };
|
||||
}
|
||||
|
||||
const buffer = await classeur.xlsx.writeBuffer();
|
||||
return new Blob([buffer], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" });
|
||||
}
|
||||
|
||||
export function AssortmentGapsModal({ classement, critereId, mois, magasinInitial, nomFournisseur, onClose }: {
|
||||
/** Le classement AFFICHÉ, dans son ordre — déjà tronqué à PROFONDEUR_MAX. */
|
||||
classement: LigneClassee[];
|
||||
critereId: string | null;
|
||||
/** Les 12 clés de mois, du plus ancien au plus récent. */
|
||||
mois: string[];
|
||||
magasinInitial: string;
|
||||
/** Sert à nommer le fichier exporté et à le documenter. */
|
||||
nomFournisseur?: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
// Magasins réellement présents dans les données, à défaut les deux sites connus.
|
||||
const sites = useMemo(() => {
|
||||
const trouves = new Set<string>();
|
||||
for (const { row } of classement) {
|
||||
for (const s of Object.keys(row.sales12mByStore ?? {})) trouves.add(s);
|
||||
for (const s of Object.keys(row.stock12mByStore ?? {})) trouves.add(s);
|
||||
}
|
||||
return trouves.size > 0 ? [...trouves].sort() : Object.keys(SITE_LABELS);
|
||||
}, [classement]);
|
||||
|
||||
const [site, setSite] = useState(() => (sites.includes(magasinInitial) ? magasinInitial : sites[0]));
|
||||
const [profondeur, setProfondeur] = useState<number>(200);
|
||||
|
||||
const examine = useMemo(() => classement.slice(0, profondeur), [classement, profondeur]);
|
||||
|
||||
const trous = useMemo(() => examine
|
||||
.map((ligne, index) => ({ ...ligne, rang: index + 1, constat: constater(ligne.row, site, mois) }))
|
||||
.filter(({ constat }) => constat.ventesMagasin <= 0 && constat.stockMagasin <= 0),
|
||||
[examine, site, mois]);
|
||||
|
||||
const [exportEnCours, setExportEnCours] = useState(false);
|
||||
const [exportErreur, setExportErreur] = useState<string | null>(null);
|
||||
|
||||
const vendusAilleurs = trous.filter((t) => t.constat.ventesAilleurs > 0).length;
|
||||
const dejaTentes = trous.filter((t) => t.constat.dejaDetenu).length;
|
||||
const nomMagasin = SITE_LABELS[site]?.nom ?? site;
|
||||
const autresMagasins = sites.filter((s) => s !== site).map((s) => SITE_LABELS[s]?.nom ?? s).join(", ") || "les autres";
|
||||
|
||||
const exporter = async () => {
|
||||
if (trous.length === 0 || exportEnCours) return;
|
||||
setExportEnCours(true);
|
||||
setExportErreur(null);
|
||||
try {
|
||||
const blob = await construireClasseur(
|
||||
trous,
|
||||
{
|
||||
fournisseur: nomFournisseur,
|
||||
magasin: nomMagasin,
|
||||
critere: libelleCritere(critereId),
|
||||
profondeur,
|
||||
examines: examine.length,
|
||||
},
|
||||
critereId,
|
||||
);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const lien = document.createElement("a");
|
||||
lien.href = url;
|
||||
lien.download = [
|
||||
"Non_travailles",
|
||||
nomFournisseur?.replace(/[^\w]+/g, "_"),
|
||||
nomMagasin.replace(/[^\w]+/g, "_"),
|
||||
`Top${profondeur}`,
|
||||
new Date().toISOString().slice(0, 10),
|
||||
].filter(Boolean).join("_") + ".xlsx";
|
||||
lien.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (e) {
|
||||
// Un export qui échoue en silence laisse croire au téléchargement.
|
||||
console.error("[trous d'assortiment] export Excel :", e);
|
||||
setExportErreur("L'export a échoué. Réessayez, ou signalez-le si cela persiste.");
|
||||
} finally {
|
||||
setExportEnCours(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DialogContent
|
||||
className="max-w-[calc(100%-2rem)] sm:max-w-5xl grid-cols-1 max-h-[88vh] overflow-y-auto"
|
||||
style={{ background: "var(--bg-surface)", border: "1px solid var(--border)" }}
|
||||
onInteractOutside={onClose}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-lg leading-snug pr-6 flex items-center gap-2" style={{ color: "var(--text-primary)" }}>
|
||||
<PackageX className="w-5 h-5 shrink-0" style={{ color: "var(--accent-warning)" }} />
|
||||
Produits non travaillés par un magasin
|
||||
</DialogTitle>
|
||||
<p className="text-[13px] mt-1" style={{ color: "var(--text-muted)" }}>
|
||||
Sur le classement affiché — trié par{" "}
|
||||
<span className="font-semibold" style={{ color: "var(--text-secondary)" }}>{libelleCritere(critereId)}</span>
|
||||
{" — ni vente ni stock sur 12 mois."}
|
||||
</p>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Réglages : quel magasin, et jusqu'où dans le classement */}
|
||||
<div className="flex flex-wrap items-center gap-x-5 gap-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[12px] font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Magasin</span>
|
||||
<div className="inline-flex rounded-lg p-0.5" style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}>
|
||||
{sites.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
onClick={() => setSite(s)}
|
||||
aria-pressed={site === s}
|
||||
className="px-3 py-1 text-[12px] font-semibold rounded-md transition-colors flex items-center gap-1.5"
|
||||
style={site === s
|
||||
? { background: "var(--bg-surface)", color: "var(--text-primary)" }
|
||||
: { color: "var(--text-muted)" }}
|
||||
>
|
||||
<Store className="w-3 h-3" />
|
||||
{SITE_LABELS[s]?.nom ?? s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[12px] font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Profondeur</span>
|
||||
<div className="inline-flex rounded-lg p-0.5" style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}>
|
||||
{PROFONDEURS.map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
type="button"
|
||||
onClick={() => setProfondeur(p)}
|
||||
aria-pressed={profondeur === p}
|
||||
className="px-3 py-1 text-[12px] font-semibold rounded-md transition-colors tabular-nums"
|
||||
style={profondeur === p
|
||||
? { background: "var(--bg-surface)", color: "var(--text-primary)" }
|
||||
: { color: "var(--text-muted)" }}
|
||||
>
|
||||
Top {p}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{classement.length < profondeur && (
|
||||
<span className="text-[11.5px]" style={{ color: "var(--text-muted)" }}>
|
||||
({classement.length} lignes affichées seulement)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* L'export reprend exactement la liste affichée : même magasin,
|
||||
même profondeur, même classement. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={exporter}
|
||||
disabled={trous.length === 0 || exportEnCours}
|
||||
title={trous.length === 0
|
||||
? "Rien à exporter : aucun trou dans ce périmètre"
|
||||
: `Exporter les ${trous.length} produits non travaillés au format Excel`}
|
||||
className="ml-auto flex items-center gap-2 px-3 py-1.5 rounded-md text-[12px] font-semibold border transition-all disabled:opacity-50"
|
||||
style={{ background: "var(--bg-elevated)", borderColor: "var(--border-strong)", color: "var(--text-secondary)" }}
|
||||
>
|
||||
{exportEnCours ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <FileSpreadsheet className="w-3.5 h-3.5" />}
|
||||
{exportEnCours ? "Génération…" : "Export Excel"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{exportErreur && (
|
||||
<div className="rounded-lg px-3 py-2 text-[12px]" style={{ background: "var(--accent-error-bg)", border: "1px solid var(--accent-error)", color: "var(--accent-error)" }}>
|
||||
{exportErreur}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<TuileStat
|
||||
label="Non travaillés"
|
||||
valeur={`${trous.length} / ${examine.length}`}
|
||||
indice={`à ${nomMagasin}`}
|
||||
couleur={trous.length > 0 ? "var(--accent-warning)" : undefined}
|
||||
icone={PackageX}
|
||||
/>
|
||||
<TuileStat
|
||||
label="Vendus ailleurs"
|
||||
valeur={nb(vendusAilleurs)}
|
||||
indice={`déjà vendus chez ${autresMagasins} — le gisement le plus sûr`}
|
||||
/>
|
||||
<TuileStat
|
||||
label="Déjà détenus"
|
||||
valeur={nb(dejaTentes)}
|
||||
indice="stock ou réception sur la période, sans vente : essayés, pas oubliés"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{trous.length === 0 ? (
|
||||
<div className="rounded-xl p-4 text-center text-[13px]" style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-muted)" }}>
|
||||
Aucun trou dans les {examine.length} premiers : {nomMagasin} travaille tout le haut de ce classement.
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl overflow-x-auto min-w-0" style={{ border: "1px solid var(--border)" }}>
|
||||
<table className="w-full text-[12px]" style={{ minWidth: 820 }}>
|
||||
<thead>
|
||||
<tr style={{ background: "var(--bg-elevated)" }}>
|
||||
<th className="text-right px-2.5 py-1.5 font-semibold" style={{ color: "var(--text-secondary)" }}>#</th>
|
||||
<th className="text-left px-2.5 py-1.5 font-semibold" style={{ color: "var(--text-secondary)" }}>Code</th>
|
||||
<th className="text-left px-2.5 py-1.5 font-semibold" style={{ color: "var(--text-secondary)" }}>Référence</th>
|
||||
<th className="text-left px-2.5 py-1.5 font-semibold" style={{ color: "var(--text-secondary)" }}>Désignation</th>
|
||||
<th className="text-right px-2.5 py-1.5 font-semibold whitespace-nowrap" style={{ color: "var(--text-secondary)" }}>
|
||||
{libelleCritere(critereId)}
|
||||
</th>
|
||||
<th className="text-right px-2.5 py-1.5 font-semibold whitespace-nowrap" style={{ color: "var(--text-secondary)" }}>Ventes ailleurs</th>
|
||||
<th className="text-right px-2.5 py-1.5 font-semibold whitespace-nowrap" style={{ color: "var(--text-secondary)" }}>Stock ailleurs</th>
|
||||
<th className="text-left px-2.5 py-1.5 font-semibold" style={{ color: "var(--text-secondary)" }}>Constat</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{trous.map(({ row, valeurCritere, rang, constat }) => (
|
||||
<tr key={row.codein} style={{ borderTop: "1px solid var(--border)" }}>
|
||||
<td className="px-2.5 py-1.5 text-right tabular-nums" style={{ color: "var(--text-muted)" }}>{rang}</td>
|
||||
<td className="px-2.5 py-1.5 tabular-nums font-semibold whitespace-nowrap" style={{ color: "var(--text-secondary)" }}>{row.codein}</td>
|
||||
<td className="px-2.5 py-1.5 font-mono text-[11.5px] whitespace-nowrap" style={{ color: "var(--text-secondary)" }}>
|
||||
{row.reference || "—"}
|
||||
</td>
|
||||
<td className="px-2.5 py-1.5" style={{ color: "var(--text-primary)" }} title={row.libelle1}>
|
||||
<span className="font-semibold">{row.libelle1}</span>
|
||||
{row.libelle3 && (
|
||||
<span className="ml-1.5 text-[10.5px]" style={{ color: "var(--text-muted)" }}>{row.libelle3}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-2.5 py-1.5 text-right tabular-nums font-semibold whitespace-nowrap" style={{ color: "var(--text-primary)" }}>
|
||||
{formatCritere(critereId, valeurCritere)}
|
||||
</td>
|
||||
<td className="px-2.5 py-1.5 text-right tabular-nums whitespace-nowrap"
|
||||
style={{ color: constat.ventesAilleurs > 0 ? "var(--accent-success)" : "var(--text-muted)" }}>
|
||||
{constat.ventesAilleurs > 0 ? nb(constat.ventesAilleurs) : "—"}
|
||||
</td>
|
||||
<td className="px-2.5 py-1.5 text-right tabular-nums whitespace-nowrap" style={{ color: "var(--text-secondary)" }}>
|
||||
{constat.stockAilleurs > 0 ? nb(constat.stockAilleurs) : "—"}
|
||||
</td>
|
||||
<td className="px-2.5 py-1.5 whitespace-nowrap" style={{ color: "var(--text-muted)" }}>
|
||||
{constat.dejaDetenu ? "déjà détenu, sans vente" : "jamais détenu"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-[11.5px] leading-snug" style={{ color: "var(--text-muted)" }}>
|
||||
Non travaillé = aucune vente sur les 12 mois glissants ET aucun stock au dernier mois connu, à {nomMagasin}.
|
||||
Un produit sans vente mais en stock n'apparaît pas ici : le magasin le détient, c'est un invendu, pas un trou d'assortiment.
|
||||
</p>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
@@ -32,7 +32,7 @@ export function BulkActionToolbar({ selectedCodeins, onClearSelection }: BulkAct
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 px-4 py-2 bg-slate-900 border border-slate-700/50 rounded-xl backdrop-blur-md shadow-2xl animate-in fade-in slide-in-from-top-2 duration-200">
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 px-4 py-2 bg-slate-900 border border-slate-700/50 rounded-xl backdrop-blur-md shadow-2xl animate-in fade-in slide-in-from-top-2 duration-200">
|
||||
<span className="text-[11px] font-black uppercase tracking-wider text-slate-200">
|
||||
{selectedCodeins.length} sélectionné{selectedCodeins.length > 1 ? "s" : ""}
|
||||
</span>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Loader2, CheckCircle, AlertCircle, RotateCcw, Camera, ChevronDown } fro
|
||||
import { useMemo, useState, useTransition } from "react";
|
||||
import { saveSnapshot } from "@/features/snapshots/api/save-snapshot";
|
||||
import { SuccessModal } from "@/components/shared/success-modal";
|
||||
import { isStaleServerActionError, STALE_ACTION_MESSAGE } from "@/lib/stale-action";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -18,9 +19,12 @@ function Stat({ label, value, sub }: { label: string; value: string; sub?: strin
|
||||
// Determine color based on label to match the prototype
|
||||
const valColor = label.includes("CA") || label.includes("Marge") ? "text-emerald-600 dark:text-emerald-400" : "text-slate-900 dark:text-white";
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="text-slate-500 text-[10px] uppercase font-black mb-1 tracking-tighter">{label}</span>
|
||||
<span className={`font-mono-nums font-black text-xl ${valColor}`}>{value}</span>
|
||||
// Barre maintenant dans le flux : chaque pixel de hauteur est pris au
|
||||
// tableau. Ces chiffres sont déjà repris en gros dans l'en-tête de page,
|
||||
// ils n'ont pas besoin d'y être une seconde fois en 20 px.
|
||||
<div className="flex flex-col items-center leading-tight">
|
||||
<span className="text-slate-500 text-[10px] uppercase font-black tracking-tighter">{label}</span>
|
||||
<span className={`font-mono-nums font-black text-base ${valColor}`}>{value}</span>
|
||||
{sub && <span className="text-emerald-600 dark:text-emerald-500 text-[11px] font-bold">{sub}</span>}
|
||||
</div>
|
||||
);
|
||||
@@ -37,7 +41,13 @@ export function FloatingSummaryBar() {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [isSavingSnapshot, setIsSavingSnapshot] = useState(false);
|
||||
const [saveStatus, setSaveStatus] = useState<"idle" | "success" | "error">("idle");
|
||||
const [modal, setModal] = useState<{ isOpen: boolean, title: string, message: string }>({
|
||||
const [modal, setModal] = useState<{
|
||||
isOpen: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
variant?: "success" | "error";
|
||||
action?: { label: string; onClick: () => void };
|
||||
}>({
|
||||
isOpen: false,
|
||||
title: "",
|
||||
message: ""
|
||||
@@ -118,12 +128,26 @@ export function FloatingSummaryBar() {
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
if (!labelOverride) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
setModal({
|
||||
isOpen: true,
|
||||
title: "Erreur Snapshot",
|
||||
message: `Impossible de créer le snapshot : ${msg}`
|
||||
});
|
||||
// Onglet resté ouvert pendant un déploiement : les identifiants de
|
||||
// Server Action ont changé côté serveur. Rien à réparer côté
|
||||
// métier, il faut recharger le bundle client.
|
||||
if (isStaleServerActionError(err)) {
|
||||
setModal({
|
||||
isOpen: true,
|
||||
title: "Page à recharger",
|
||||
message: STALE_ACTION_MESSAGE,
|
||||
variant: "error",
|
||||
action: { label: "Recharger la page", onClick: () => window.location.reload() },
|
||||
});
|
||||
} else {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
setModal({
|
||||
isOpen: true,
|
||||
title: "Erreur Snapshot",
|
||||
message: `Impossible de créer le snapshot : ${msg}`,
|
||||
variant: "error",
|
||||
});
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setIsSavingSnapshot(false);
|
||||
@@ -132,7 +156,20 @@ export function FloatingSummaryBar() {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed bottom-6 left-[264px] right-6 p-4 flex justify-between items-center shadow-lg border rounded-xl z-50 transition-colors bg-slate-100 dark:bg-slate-800"
|
||||
/*
|
||||
* Dans le flux, plus en `fixed`.
|
||||
*
|
||||
* En `fixed` elle ne réservait aucune hauteur : seul un `pb-12` posé sur
|
||||
* la page compensait, et il était trop court d'une quarantaine de pixels.
|
||||
* Elle recouvrait donc la dernière ligne du tableau ET la barre de
|
||||
* défilement horizontale — d'où l'impression qu'aucun ascenseur n'existait.
|
||||
*
|
||||
* Le `left-[264px]` était en prime calé sur une barre latérale dépliée,
|
||||
* alors qu'elle est repliée par défaut (64 px) : 200 px de vide à gauche.
|
||||
* En flux, la grille cède exactement la hauteur nécessaire et il n'y a
|
||||
* plus aucune valeur magique à tenir en phase.
|
||||
*/
|
||||
className="shrink-0 px-4 py-2.5 flex flex-wrap justify-between items-center gap-x-4 gap-y-2 shadow-sm border rounded-xl transition-colors bg-slate-100 dark:bg-slate-800"
|
||||
style={{
|
||||
borderColor: hasDrafts ? "rgba(234, 179, 8, 0.5)" : "var(--border-strong)",
|
||||
}}
|
||||
@@ -327,6 +364,8 @@ export function FloatingSummaryBar() {
|
||||
onClose={() => setModal(prev => ({ ...prev, isOpen: false }))}
|
||||
title={modal.title}
|
||||
message={modal.message}
|
||||
variant={modal.variant}
|
||||
action={modal.action}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -6,14 +6,13 @@ import { FloatingSummaryBar } from "@/features/grid/components/floating-summary-
|
||||
import { BulkActionToolbar } from "@/features/grid/components/bulk-action-toolbar";
|
||||
import { GridFilterBar } from "@/features/grid/components/grid-filter-bar";
|
||||
import { ExportDropdown } from "@/features/grid/components/export-dropdown";
|
||||
import { NoSalesTab, countNoSalesIn6Months } from "@/features/grid/components/no-sales-tab";
|
||||
import { useGridStore } from "@/features/grid/store/use-grid-store";
|
||||
import { useSaveDrafts } from "@/features/grid/hooks/use-save-drafts";
|
||||
import type { ProductRow } from "@/types/grid";
|
||||
import { CheckCircle, AlertCircle, Loader2 } from "lucide-react";
|
||||
import { CheckCircle, AlertCircle, Loader2, RefreshCw } from "lucide-react";
|
||||
import { useSession } from "next-auth/react";
|
||||
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useSearchParams, useRouter, usePathname } from "next/navigation";
|
||||
import type { GridFilters } from "@/types/grid";
|
||||
|
||||
interface GridClientProps {
|
||||
@@ -31,6 +30,27 @@ type GridRowsStreamMessage =
|
||||
| { type: "done"; loaded: number; total: number }
|
||||
| { type: "error"; error: string };
|
||||
|
||||
/** Petite pastille métrique (libellé + valeur) — densité pro, thème Slate/teal. */
|
||||
function MetricPill({ label, value, accent = false }: { label: string; value: string; accent?: boolean }) {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col items-start px-2.5 py-1 rounded-lg shrink-0"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}
|
||||
>
|
||||
<span className="text-[8.5px] font-semibold uppercase tracking-wider leading-none mb-1" style={{ color: "var(--text-muted)" }}>
|
||||
{label}
|
||||
</span>
|
||||
<span
|
||||
className="text-[12px] font-bold leading-none tabular-nums max-w-[160px] truncate"
|
||||
style={{ color: accent ? "var(--accent)" : "var(--text-primary)" }}
|
||||
title={value}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, magasins, magasin, filters }: GridClientProps) {
|
||||
const { data: session } = useSession();
|
||||
const isAdmin = (session?.user as { role?: string } | undefined)?.role === "admin";
|
||||
@@ -38,21 +58,21 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
|
||||
const rows = useGridStore((s) => s.rows);
|
||||
const setActiveGridQuery = useGridStore((s) => s.setActiveGridQuery);
|
||||
const setFilter = useGridStore((s) => s.setFilter);
|
||||
const setCode3Filter = useGridStore((s) => s.setCode3Filter);
|
||||
const setActiveMagasin = useGridStore((s) => s.setActiveMagasin);
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
const [selectedCodeins, setSelectedCodeins] = useState<string[]>([]);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [saveStatus, setSaveStatus] = useState<"idle" | "success" | "error">("idle");
|
||||
const [activeTab, setActiveTab] = useState<"grid" | "no-sales">("grid");
|
||||
const [isLoadingRows, setIsLoadingRows] = useState(false);
|
||||
const [rowsLoaded, setRowsLoaded] = useState(0);
|
||||
const [totalRows, setTotalRows] = useState<number | null>(null);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [showStartOverlay, setShowStartOverlay] = useState(false);
|
||||
|
||||
const noSalesCount = useMemo(() => countNoSalesIn6Months(rows), [rows]);
|
||||
|
||||
const visibleCodeins = useMemo(() => rows.map(r => r.codein), [rows]);
|
||||
const { save, hasDrafts, count } = useSaveDrafts(magasin, visibleCodeins);
|
||||
|
||||
@@ -76,10 +96,10 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
|
||||
if (!isMounted) return;
|
||||
if (prevFournisseurRef.current !== codeFournisseur) {
|
||||
setFilter("codeGamme", null);
|
||||
setFilter("code3", null);
|
||||
setCode3Filter(null);
|
||||
prevFournisseurRef.current = codeFournisseur;
|
||||
}
|
||||
}, [codeFournisseur, setFilter, isMounted]);
|
||||
}, [codeFournisseur, setFilter, setCode3Filter, isMounted]);
|
||||
|
||||
const refreshToken = searchParams.get("_refresh");
|
||||
useEffect(() => {
|
||||
@@ -89,9 +109,11 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
|
||||
const params = new URLSearchParams();
|
||||
params.set("fournisseur", codeFournisseur);
|
||||
params.set("magasin", magasin || "TOTAL");
|
||||
// `code3` n'est volontairement pas transmis : le serveur ignore ce filtre
|
||||
// (getProductRows ne s'en sert pas) et la Grille l'applique en local. Le
|
||||
// faire voyager relançait un chargement complet à chaque case cochée.
|
||||
if (filters.code1) params.set("code1", filters.code1);
|
||||
if (filters.code2) params.set("code2", filters.code2);
|
||||
if (filters.code3) params.set("code3", filters.code3);
|
||||
if (refreshToken) params.set("refresh", "1");
|
||||
|
||||
let accumulatedRows: ProductRow[] = [];
|
||||
@@ -154,7 +176,7 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
|
||||
loadRows();
|
||||
|
||||
return () => controller.abort();
|
||||
}, [codeFournisseur, magasin, filters.code1, filters.code2, filters.code3, refreshToken, setRows, isMounted]);
|
||||
}, [codeFournisseur, magasin, filters.code1, filters.code2, refreshToken, setRows, isMounted]);
|
||||
|
||||
// Synchroniser le magasin actif depuis la prop URL (changement de magasin sans rechargement)
|
||||
useEffect(() => {
|
||||
@@ -170,6 +192,16 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
|
||||
});
|
||||
};
|
||||
|
||||
// Force le rechargement des données depuis le serveur en ignorant le cache
|
||||
// 10 min : on change le paramètre URL `_refresh`, ce qui relance le fetch
|
||||
// avec `refresh=1` (→ forceRefresh) et remonte l'état serveur à jour (INIT).
|
||||
const handleForceRefresh = () => {
|
||||
if (isLoadingRows) return;
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set("_refresh", String(Date.now()));
|
||||
router.replace(`${pathname}?${params.toString()}`);
|
||||
};
|
||||
|
||||
if (!isMounted) {
|
||||
return <div className="p-8 text-center animate-pulse text-muted italic">Initialisation de la grille...</div>;
|
||||
}
|
||||
@@ -177,38 +209,66 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
|
||||
const activeStoreNom = magasins.find(m => m.code === magasin)?.nom || "National (Total)";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full space-y-3 min-h-0 pb-12">
|
||||
{/* Header */}
|
||||
<div className="flex items-end justify-between shrink-0">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Révision d'Assortiment</h1>
|
||||
<p className="text-[13px] mt-0.5" style={{ color: "var(--text-secondary)" }}>
|
||||
<span className="font-semibold" style={{ color: "var(--text-primary)" }}>{nomFournisseur}</span>
|
||||
{" "}• <span className="font-medium" style={{ color: "var(--text-muted)" }}>{activeStoreNom}</span>
|
||||
{" "}• {totalRows?.toLocaleString("fr-FR") ?? rows.length.toLocaleString("fr-FR")} références
|
||||
</p>
|
||||
<div className="flex flex-col h-full space-y-3 min-h-0">
|
||||
{/* Header — chrome raffiné (eyebrow teal + fournisseur héro + pastilles métriques) */}
|
||||
<header className="shrink-0 flex items-center justify-between gap-4 pb-2.5" style={{ borderBottom: "1px solid var(--border)" }}>
|
||||
<div className="flex items-center gap-3.5 min-w-0">
|
||||
<div
|
||||
className="h-10 w-[3px] rounded-full shrink-0"
|
||||
style={{ background: "linear-gradient(to bottom, var(--accent), var(--accent-success))" }}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<div className="text-[10px] font-bold uppercase tracking-[0.18em] leading-none mb-1.5" style={{ color: "var(--accent)" }}>
|
||||
Révision d'assortiment
|
||||
</div>
|
||||
<h1 className="text-[19px] font-bold tracking-tight truncate leading-none" style={{ color: "var(--text-primary)" }}>
|
||||
{nomFournisseur}
|
||||
</h1>
|
||||
</div>
|
||||
<div className="hidden md:flex items-center gap-2 ml-1 pl-3 shrink-0" style={{ borderLeft: "1px solid var(--border)" }}>
|
||||
<MetricPill label="Magasin" value={activeStoreNom} />
|
||||
<MetricPill label="Références" value={(totalRows ?? rows.length).toLocaleString("fr-FR")} accent />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2.5 shrink-0">
|
||||
{(isLoadingRows || loadError) && (
|
||||
<div
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded-md text-xs font-semibold"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: loadError ? "var(--accent-error)" : "var(--text-secondary)" }}
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-semibold tabular-nums"
|
||||
style={{
|
||||
background: loadError ? "var(--accent-error-bg)" : "var(--accent-bg)",
|
||||
border: `1px solid ${loadError ? "var(--accent-error)" : "var(--accent-border)"}`,
|
||||
color: loadError ? "var(--accent-error)" : "var(--accent)",
|
||||
}}
|
||||
>
|
||||
{isLoadingRows && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
|
||||
{loadError
|
||||
? loadError
|
||||
: totalRows
|
||||
? `${rowsLoaded.toLocaleString("fr-FR")} / ${totalRows.toLocaleString("fr-FR")}`
|
||||
: "Chargement produits..."}
|
||||
: "Chargement…"}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={handleForceRefresh}
|
||||
disabled={isLoadingRows}
|
||||
title="Forcer le rafraîchissement — recharge les données depuis le serveur en ignorant le cache (met à jour la colonne INIT)"
|
||||
className="flex items-center gap-2 px-3 h-9 text-[13px] font-semibold rounded-xl disabled:opacity-50 transition-all hover:brightness-110 active:scale-[0.97]"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${isLoadingRows ? "animate-spin" : ""}`} />
|
||||
<span className="hidden sm:inline">Rafraîchir</span>
|
||||
</button>
|
||||
<div id="grid-toolbar-actions"></div>
|
||||
{isAdmin && <ExportDropdown nomFournisseur={nomFournisseur} />}
|
||||
{isAdmin && hasDrafts && (
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={isPending}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-emerald-700 hover:bg-emerald-600 text-white text-sm font-semibold rounded-lg disabled:opacity-60 transition-colors"
|
||||
className="flex items-center gap-2 px-4 h-9 text-white text-[13px] font-semibold rounded-xl disabled:opacity-60 transition-all hover:brightness-110 active:scale-[0.97]"
|
||||
style={{
|
||||
background: "linear-gradient(180deg, var(--brand-solid), var(--brand-hover))",
|
||||
boxShadow: "0 4px 12px rgba(16,185,129,0.25), inset 0 1px 1px rgba(255,255,255,0.2)",
|
||||
}}
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
@@ -218,7 +278,7 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
) : null}
|
||||
{isPending
|
||||
? "Sauvegarde..."
|
||||
? "Sauvegarde…"
|
||||
: saveStatus === "success"
|
||||
? "Sauvegardé !"
|
||||
: saveStatus === "error"
|
||||
@@ -227,75 +287,34 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="shrink-0 print:hidden">
|
||||
<GridFilterBar fournisseurs={fournisseurs} magasins={magasins} />
|
||||
</div>
|
||||
|
||||
{/* Tab switcher */}
|
||||
<div className="shrink-0 flex gap-0 print:hidden" style={{ borderBottom: "1px solid var(--border)" }}>
|
||||
{(["grid", "no-sales"] as const).map(tab => {
|
||||
const isActive = activeTab === tab;
|
||||
return (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className="flex items-center gap-1.5 px-4 py-2 text-[13px] font-medium transition-colors"
|
||||
style={{
|
||||
color: isActive ? "var(--text-primary)" : "var(--text-muted)",
|
||||
borderBottom: isActive ? "2px solid var(--accent)" : "2px solid transparent",
|
||||
marginBottom: "-1px",
|
||||
}}
|
||||
>
|
||||
{tab === "grid" ? "Assortiment" : "Sans vente 6 mois"}
|
||||
{tab === "no-sales" && noSalesCount > 0 && (
|
||||
<span className="inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full text-[10px] font-bold"
|
||||
style={{
|
||||
background: isActive ? "var(--accent)" : "var(--bg-elevated)",
|
||||
color: isActive ? "white" : "var(--text-secondary)",
|
||||
border: "1px solid var(--border)",
|
||||
}}
|
||||
>
|
||||
{noSalesCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{/* Bulk toolbar (contextual) */}
|
||||
<div className="shrink-0 print:hidden">
|
||||
<BulkActionToolbar
|
||||
selectedCodeins={selectedCodeins}
|
||||
onClearSelection={() => setSelectedCodeins([])}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Bulk toolbar (contextual) — uniquement sur l'onglet grille */}
|
||||
{activeTab === "grid" && (
|
||||
<div className="shrink-0 print:hidden">
|
||||
<BulkActionToolbar
|
||||
selectedCodeins={selectedCodeins}
|
||||
onClearSelection={() => setSelectedCodeins([])}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main content */}
|
||||
{activeTab === "grid" ? (
|
||||
<div className="flex-1 min-h-0 min-w-0">
|
||||
<HeatmapGrid
|
||||
onSelectionChange={setSelectedCodeins}
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 min-h-0 min-w-0 flex flex-col">
|
||||
<NoSalesTab />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-h-0 min-w-0">
|
||||
<HeatmapGrid
|
||||
onSelectionChange={setSelectedCodeins}
|
||||
isAdmin={isAdmin}
|
||||
nomFournisseur={nomFournisseur}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Summary bar — uniquement sur l'onglet grille */}
|
||||
{activeTab === "grid" && (
|
||||
<div className="print:hidden">
|
||||
<FloatingSummaryBar />
|
||||
</div>
|
||||
)}
|
||||
{/* Summary bar */}
|
||||
<div className="print:hidden">
|
||||
<FloatingSummaryBar />
|
||||
</div>
|
||||
|
||||
{/* Overlay de démarrage premium (Glassmorphism semi-bloquant) */}
|
||||
{showStartOverlay && isLoadingRows && (
|
||||
|
||||
@@ -134,7 +134,7 @@ export function GridFilterBar({ fournisseurs, magasins }: GridFilterBarProps) {
|
||||
fournisseurs={fournisseurs}
|
||||
selectedCode={searchParams.get("fournisseur")}
|
||||
onSelect={handleSupplierSelect}
|
||||
className="w-[280px]"
|
||||
className="w-[200px] xl:w-[280px]"
|
||||
/>
|
||||
|
||||
{/* Store Selector */}
|
||||
@@ -142,7 +142,7 @@ export function GridFilterBar({ fournisseurs, magasins }: GridFilterBarProps) {
|
||||
magasins={magasins}
|
||||
selectedCode={activeMagasin === "TOTAL" ? null : activeMagasin}
|
||||
onSelect={handleStoreSelect}
|
||||
className="w-[220px]"
|
||||
className="w-[170px] xl:w-[220px]"
|
||||
/>
|
||||
|
||||
{/* Search */}
|
||||
@@ -156,12 +156,19 @@ export function GridFilterBar({ fournisseurs, magasins }: GridFilterBarProps) {
|
||||
placeholder="Rechercher (code, désignation...)"
|
||||
value={searchValue}
|
||||
onChange={(e) => setSearchValue(e.target.value)}
|
||||
className="apple-input w-72 pr-8"
|
||||
// La croix native de `type="search"` (WebKit/Blink) est masquée :
|
||||
// ce champ a déjà son propre bouton d'effacement juste à droite,
|
||||
// et les deux apparaissaient côte à côte. On garde `type="search"`
|
||||
// pour la sémantique ; le masquage est local, la recherche de
|
||||
// l'en-tête n'ayant pas de bouton maison, elle garde le sien.
|
||||
className="apple-input w-52 xl:w-72 pr-8 [&::-webkit-search-cancel-button]:appearance-none [&::-webkit-search-cancel-button]:hidden"
|
||||
style={{ paddingLeft: "32px" }}
|
||||
/>
|
||||
{searchValue && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearchValue("")}
|
||||
aria-label="Effacer la recherche"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2"
|
||||
>
|
||||
<X className="h-3.5 w-3.5 hover:opacity-100 opacity-60 transition-opacity" style={{ color: "var(--text-secondary)" }} />
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,442 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* CollectFlow — Graphiques réseau (SVG fait main, aucune librairie de charts).
|
||||
*
|
||||
* Extraits de `heatmap-grid.tsx` pour être partagés entre la Grille et la fiche
|
||||
* produit (`/produits`). Aucun changement de comportement.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { TrendingUp, TrendingDown, Minus } from "lucide-react";
|
||||
import { fmtMonthShort } from "@/features/grid/lib/months";
|
||||
import { TREND_COLOR, type NetworkTrend } from "@/features/grid/lib/network-trend";
|
||||
|
||||
/** Sparkline compacte 12 mois + flèche, teintée selon la tendance. */
|
||||
export function TrendSparkline({ trend }: { trend: NetworkTrend }) {
|
||||
if (!trend.hasData) return <div className="text-center text-[12px]" style={{ color: "var(--text-secondary)" }}>-</div>;
|
||||
const { values, direction, pct, nouveau } = trend;
|
||||
const color = TREND_COLOR[direction];
|
||||
const W = 46, H = 18, n = values.length;
|
||||
const max = Math.max(...values, 1), min = Math.min(...values, 0);
|
||||
const range = max - min || 1;
|
||||
const pts = values.map((v, i) => {
|
||||
const x = n > 1 ? (i / (n - 1)) * (W - 2) + 1 : W / 2;
|
||||
const y = H - 1 - ((v - min) / range) * (H - 2);
|
||||
return `${x.toFixed(1)},${y.toFixed(1)}`;
|
||||
}).join(" ");
|
||||
const Arrow = direction === "up" ? TrendingUp : direction === "down" ? TrendingDown : Minus;
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-0.5" title={pct != null
|
||||
? `Tendance ${pct >= 0 ? "+" : ""}${(pct * 100).toFixed(0)}% : 4 derniers mois vs 4 premiers — cliquer pour le détail`
|
||||
: nouveau
|
||||
? "Nouveau : aucune vente sur les 4 premiers mois de la fenêtre — cliquer pour le détail"
|
||||
: "Tendance réseau — cliquer pour le détail"}>
|
||||
<div className="flex items-center gap-1">
|
||||
<svg width={W} height={H} viewBox={`0 0 ${W} ${H}`} className="shrink-0" aria-hidden>
|
||||
{n > 1 && <polyline points={pts} fill="none" stroke={color} strokeWidth={1.5} strokeLinejoin="round" strokeLinecap="round" />}
|
||||
{n === 1 && <circle cx={W / 2} cy={H / 2} r={2} fill={color} />}
|
||||
</svg>
|
||||
<Arrow className="w-3.5 h-3.5 shrink-0" style={{ color }} strokeWidth={2.5} />
|
||||
</div>
|
||||
{pct != null && (
|
||||
<span className="text-[9px] font-bold tabular-nums leading-none" style={{ color }}>
|
||||
{pct >= 0 ? "+" : ""}{Math.round(pct * 100)}%
|
||||
</span>
|
||||
)}
|
||||
{pct == null && nouveau && (
|
||||
<span className="text-[9px] font-bold leading-none" style={{ color }}>Nouveau</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Couleurs des séries : slots 1-3 de la palette catégorielle validée, exposés en
|
||||
* variables CSS pour que le mode sombre soit un jeu de pas CHOISI et vérifié, et
|
||||
* non un éclaircissement automatique (cf. `globals.css`).
|
||||
*/
|
||||
const SERIE_COULEURS = ["var(--viz-1)", "var(--viz-2)", "var(--viz-3)"] as const;
|
||||
|
||||
/** Une facette du graphique : une série, son échelle, son titre. */
|
||||
interface Facette {
|
||||
titre: string;
|
||||
valeurs: number[];
|
||||
couleur: string;
|
||||
hauteur: number;
|
||||
/** Lavis sous la courbe : réservé à la facette principale. */
|
||||
aire?: boolean;
|
||||
decimales?: number;
|
||||
unite?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ventes réseau mensuelles — petits multiples empilés sur un axe des mois commun.
|
||||
*
|
||||
* ⚠️ PAS DE DOUBLE AXE. Superposer qté/magasin, volumes et magasins sur un même
|
||||
* cadre demanderait deux ou trois échelles verticales : l'alignement entre elles
|
||||
* serait arbitraire et inventerait des corrélations absentes des données. C'est
|
||||
* l'erreur de graphique la plus commune. Trois mesures d'ordres de grandeur
|
||||
* différents ⇒ trois facettes, une échelle chacune, le même axe horizontal.
|
||||
*
|
||||
* ⚠️ LA FACETTE PRINCIPALE EST LA QUANTITÉ PAR MAGASIN. La quantité brute confond
|
||||
* la performance du produit et sa diffusion : elle monte dès qu'on référence le
|
||||
* produit ailleurs, sans qu'il se vende mieux nulle part. Les deux autres
|
||||
* facettes expliquent les mouvements de la première, elles ne les jugent pas.
|
||||
*
|
||||
* ⚠️ ÉTIQUETAGE SÉLECTIF. Une valeur à côté de chaque point, c'est 36 nombres :
|
||||
* illisible, et personne ne les lit. Seuls l'extrême et le dernier mois sont
|
||||
* écrits ; le survol donne le mois exact et la vue tableau porte tout le reste,
|
||||
* de sorte qu'aucune valeur n'est accessible uniquement au survol.
|
||||
*
|
||||
* Le texte ne porte jamais la couleur d'une série (une teinte claire est
|
||||
* illisible en texte) : l'identité vient du repère coloré posé à côté.
|
||||
*/
|
||||
export function NetworkLineChart({
|
||||
labels,
|
||||
values,
|
||||
stores,
|
||||
perStore,
|
||||
}: {
|
||||
labels: string[];
|
||||
values: number[];
|
||||
/** Couleur héritée de la tendance : conservée pour l'API, plus utilisée ici —
|
||||
* la teinte « tendance » reste au badge d'en-tête, les séries ont la leur. */
|
||||
color?: string;
|
||||
stores?: number[] | null;
|
||||
perStore?: number[] | null;
|
||||
}) {
|
||||
const [survol, setSurvol] = useState<number | null>(null);
|
||||
const [vue, setVue] = useState<"graphique" | "tableau">("graphique");
|
||||
const n = values.length;
|
||||
const memeTaille = (s?: number[] | null) => Array.isArray(s) && s.length === n && n > 0;
|
||||
const avecParMag = memeTaille(perStore);
|
||||
const avecMagasins = memeTaille(stores);
|
||||
|
||||
const fmt = (v: number, d = 0) =>
|
||||
v.toLocaleString("fr-FR", { minimumFractionDigits: d, maximumFractionDigits: d });
|
||||
|
||||
const facettes: Facette[] = [];
|
||||
if (avecParMag) {
|
||||
facettes.push({
|
||||
titre: "Qté / magasin",
|
||||
valeurs: perStore!,
|
||||
couleur: SERIE_COULEURS[0],
|
||||
hauteur: 92,
|
||||
aire: true,
|
||||
decimales: perStore!.some((v) => v > 0 && v < 10) ? 1 : 0,
|
||||
});
|
||||
facettes.push({ titre: "Quantités vendues", valeurs: values, couleur: SERIE_COULEURS[1], hauteur: 58 });
|
||||
} else {
|
||||
facettes.push({ titre: "Quantités vendues", valeurs: values, couleur: SERIE_COULEURS[0], hauteur: 110, aire: true });
|
||||
}
|
||||
if (avecMagasins) {
|
||||
facettes.push({ titre: "Magasins vendeurs", valeurs: stores!, couleur: SERIE_COULEURS[2], hauteur: 46 });
|
||||
}
|
||||
|
||||
const W = 520;
|
||||
// Bandeau et axe dimensionnés pour la typographie ci-dessous : agrandir les
|
||||
// polices sans élargir ces bandes ferait chevaucher titres et courbes.
|
||||
const padL = 16, padR = 16, axeH = 24, enteteH = 21, ecart = 14;
|
||||
const plotW = W - padL - padR;
|
||||
const H = facettes.reduce((t, f) => t + enteteH + f.hauteur + ecart, 0) - ecart + axeH;
|
||||
|
||||
const x = (i: number) => (n > 1 ? padL + (i / (n - 1)) * plotW : padL + plotW / 2);
|
||||
/** Aux extrémités, l'étiquette est ancrée vers l'intérieur : sinon elle sort du cadre. */
|
||||
const ancrage = (i: number): "start" | "middle" | "end" =>
|
||||
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;
|
||||
// Les quantités Qlik peuvent être NÉGATIVES (retours > ventes sur un mois) :
|
||||
// l'échelle part du minimum réel, sinon le point sort du cadre par le bas.
|
||||
const min = Math.min(0, ...f.valeurs);
|
||||
const max = Math.max(...f.valeurs, min + 1);
|
||||
const etendue = max - min || 1;
|
||||
const y = (v: number) => 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 (
|
||||
<div className="mt-3 w-full">
|
||||
{/* Bascule : la même surface sert au dessin ou aux chiffres. */}
|
||||
<div className="flex justify-end mb-1.5">
|
||||
<div className="inline-flex rounded-lg p-0.5" style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}>
|
||||
{(["graphique", "tableau"] as const).map((v) => (
|
||||
<button
|
||||
key={v}
|
||||
type="button"
|
||||
onClick={() => setVue(v)}
|
||||
className="px-3 py-1 text-[12px] font-semibold rounded-md transition-colors capitalize"
|
||||
style={vue === v
|
||||
? { background: "var(--bg-surface)", color: "var(--text-primary)" }
|
||||
: { color: "var(--text-muted)" }}
|
||||
aria-pressed={vue === v}
|
||||
>
|
||||
{v}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{vue === "graphique" ? (
|
||||
<div className="overflow-x-auto">
|
||||
<svg
|
||||
viewBox={`0 0 ${W} ${H}`}
|
||||
width="100%"
|
||||
height={H}
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
// `display:block` : un SVG en ligne traîne l'espace sous la ligne de
|
||||
// base (6 px mesurés), et la carte changeait de hauteur en basculant
|
||||
// vers le tableau.
|
||||
style={{ minWidth: 460, display: "block" }}
|
||||
role="img"
|
||||
aria-label={"Ventes réseau mensuelles : " + disposees.map((f) => f.titre).join(", ")}
|
||||
onMouseLeave={() => setSurvol(null)}
|
||||
>
|
||||
{disposees.map((f) => {
|
||||
const pts = f.valeurs.map((v, i) => `${x(i).toFixed(1)},${f.y(v).toFixed(1)}`).join(" ");
|
||||
// L'extrême et le dernier mois seulement : deux repères qui
|
||||
// situent la courbe, sans noyer le dessin sous les nombres.
|
||||
const aEtiqueter = [...new Set([f.iMax, n - 1])];
|
||||
return (
|
||||
<g key={f.titre}>
|
||||
{/* Bandeau de facette : repère coloré + titre + maximum, en encre neutre */}
|
||||
<rect x={padL} y={f.hautEntete + 6} width={14} height={4} rx={2} fill={f.couleur} />
|
||||
<text x={padL + 20} y={f.hautEntete + 9} fontSize={13} fontWeight={700} fill="var(--text-secondary)" dominantBaseline="middle">
|
||||
{f.titre}
|
||||
</text>
|
||||
<text x={padL + plotW} y={f.hautEntete + 9} textAnchor="end" fontSize={11.5} fill="var(--text-muted)" dominantBaseline="middle">
|
||||
max {fmt(f.max, f.decimales ?? 0)}{f.unite ?? ""}
|
||||
</text>
|
||||
|
||||
{/* Ligne de base : filet plein, une nuance au-dessus du fond */}
|
||||
<line x1={padL} y1={f.base} x2={padL + plotW} y2={f.base} stroke="var(--border)" strokeWidth={1} />
|
||||
{f.aire && n > 1 && (
|
||||
<polygon
|
||||
points={`${padL},${f.base.toFixed(1)} ${pts} ${(padL + plotW).toFixed(1)},${f.base.toFixed(1)}`}
|
||||
fill={f.couleur}
|
||||
opacity={0.1}
|
||||
/>
|
||||
)}
|
||||
{n > 1 && (
|
||||
<polyline points={pts} fill="none" stroke={f.couleur} strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" />
|
||||
)}
|
||||
|
||||
{/* Points repères : extrême et dernier mois, cerclés de la couleur du fond */}
|
||||
{aEtiqueter.map((i) => (
|
||||
<circle
|
||||
key={`pt-${f.titre}-${i}`}
|
||||
cx={x(i)}
|
||||
cy={f.y(f.valeurs[i])}
|
||||
r={4}
|
||||
fill={f.couleur}
|
||||
stroke="var(--bg-surface)"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
))}
|
||||
{aEtiqueter.map((i) => {
|
||||
// Un point proche du sommet verrait son étiquette empiéter sur le
|
||||
// bandeau de la facette (constaté au rendu : « 58 » par-dessus
|
||||
// « Qté / magasin »). Dans ce cas elle bascule SOUS le point.
|
||||
const yPoint = f.y(f.valeurs[i]);
|
||||
const dessus = yPoint - 11 >= f.haut + 8;
|
||||
return (
|
||||
<text
|
||||
key={`lab-${f.titre}-${i}`}
|
||||
x={x(i)}
|
||||
y={dessus ? yPoint - 10 : yPoint + 16}
|
||||
textAnchor={ancrage(i)}
|
||||
fontSize={12.5}
|
||||
fontWeight={700}
|
||||
fill="var(--text-primary)"
|
||||
>
|
||||
{fmt(f.valeurs[i], f.decimales ?? 0)}
|
||||
</text>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Point mis en avant au survol */}
|
||||
{survol != null && (
|
||||
<circle
|
||||
cx={x(survol)}
|
||||
cy={f.y(f.valeurs[survol])}
|
||||
r={3.5}
|
||||
fill={f.couleur}
|
||||
stroke="var(--bg-surface)"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Réticule : un seul trait vertical relie les trois facettes au même mois */}
|
||||
{survol != null && (
|
||||
<line x1={x(survol)} y1={0} x2={x(survol)} y2={basPlots} stroke="var(--border-strong)" strokeWidth={1} />
|
||||
)}
|
||||
|
||||
{/* Axe des mois, commun aux facettes */}
|
||||
{labels.map((lab, i) => (
|
||||
<text
|
||||
key={`mois-${lab}`}
|
||||
x={x(i)}
|
||||
y={H - 7}
|
||||
textAnchor={ancrage(i)}
|
||||
fontSize={11}
|
||||
fill={survol === i ? "var(--text-primary)" : "var(--text-muted)"}
|
||||
fontWeight={survol === i ? 700 : 400}
|
||||
>
|
||||
{fmtMonthShort(lab)}
|
||||
</text>
|
||||
))}
|
||||
|
||||
{/* Infobulle DANS le dessin.
|
||||
Elle vivait sous le SVG, en HTML : selon la longueur des valeurs elle
|
||||
passait sur deux lignes et la carte changeait de hauteur en cours de
|
||||
survol. Ici la géométrie est figée par le viewBox — la carte ne bouge
|
||||
plus jamais. `pointerEvents=none` laisse passer le survol vers les
|
||||
cibles en dessous. */}
|
||||
{survol != null && (() => {
|
||||
const largeur = 196;
|
||||
const hauteur = 22 + disposees.length * 17 + 8;
|
||||
// La bulle bascule à gauche du réticule quand elle déborderait à droite.
|
||||
const gauche = x(survol) + 10 + largeur > W - 4;
|
||||
const bx = gauche ? x(survol) - 10 - largeur : x(survol) + 10;
|
||||
return (
|
||||
<g pointerEvents="none">
|
||||
<rect
|
||||
x={bx} y={4} width={largeur} height={hauteur} rx={8}
|
||||
fill="var(--bg-elevated)" stroke="var(--border)" strokeWidth={1}
|
||||
/>
|
||||
<text x={bx + 11} y={21} fontSize={12.5} fontWeight={700} fill="var(--text-primary)">
|
||||
{fmtMonthShort(labels[survol])}
|
||||
</text>
|
||||
{disposees.map((f, k) => (
|
||||
<g key={`bulle-${f.titre}`}>
|
||||
<rect x={bx + 11} y={33 + k * 17} width={10} height={4} rx={2} fill={f.couleur} />
|
||||
<text x={bx + 26} y={38 + k * 17} fontSize={11.5} fill="var(--text-secondary)">
|
||||
{f.titre}
|
||||
</text>
|
||||
<text
|
||||
x={bx + largeur - 11} y={38 + k * 17}
|
||||
textAnchor="end" fontSize={11.5} fontWeight={700} fill="var(--text-primary)"
|
||||
>
|
||||
{fmt(f.valeurs[survol], f.decimales ?? 0)}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
</g>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Zones de survol : larges cibles, une par mois */}
|
||||
{labels.map((lab, i) => (
|
||||
<rect
|
||||
key={`survol-${lab}`}
|
||||
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)}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
) : (
|
||||
/* Vue tableau : même panneau, MÊME HAUTEUR.
|
||||
Un dépliant aurait fait grandir la carte à l'ouverture — exactement ce
|
||||
qu'on venait de corriger sur le survol. Le tableau occupe donc la place
|
||||
du graphique et défile à l'intérieur : la carte garde sa taille, et
|
||||
aucune valeur n'est réservée au survol. */
|
||||
<div style={{ height: H }} className="overflow-y-auto overflow-x-auto">
|
||||
<table className="w-full text-[13px] tabular-nums" style={{ borderCollapse: "collapse" }}>
|
||||
<thead className="sticky top-0" style={{ background: "var(--bg-surface)" }}>
|
||||
<tr style={{ color: "var(--text-muted)" }}>
|
||||
<th className="text-left font-medium py-1 pr-2">Mois</th>
|
||||
{disposees.map((f) => (
|
||||
<th key={`th-${f.titre}`} className="text-right font-medium py-1 pl-2 whitespace-nowrap">
|
||||
{f.titre}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{labels.map((lab, i) => (
|
||||
<tr key={`tr-${lab}`} style={{ borderTop: "1px solid var(--border)" }}>
|
||||
<td className="py-1 pr-2 whitespace-nowrap" style={{ color: "var(--text-secondary)" }}>
|
||||
{fmtMonthShort(lab)}
|
||||
</td>
|
||||
{disposees.map((f) => (
|
||||
<td key={`td-${f.titre}-${lab}`} className="text-right py-1 pl-2" style={{ color: "var(--text-primary)" }}>
|
||||
{fmt(f.valeurs[i], f.decimales ?? 0)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deux courbes superposées sur le même axe (comparatif local vs réseau).
|
||||
* Les valeurs sont normalisées ensemble pour rester comparables visuellement.
|
||||
*/
|
||||
export function DualLineChart({
|
||||
labels,
|
||||
series,
|
||||
}: {
|
||||
labels: string[];
|
||||
series: Array<{ name: string; values: number[]; color: string }>;
|
||||
}) {
|
||||
const W = 620, H = 220;
|
||||
const padL = 10, padR = 10, padT = 26, padB = 30;
|
||||
const plotW = W - padL - padR, plotH = H - padT - padB;
|
||||
const n = labels.length;
|
||||
const maxV = Math.max(1, ...series.flatMap((s) => s.values));
|
||||
const x = (i: number) => (n > 1 ? padL + (i / (n - 1)) * plotW : padL + plotW / 2);
|
||||
const y = (v: number) => padT + plotH - (v / maxV) * plotH;
|
||||
|
||||
return (
|
||||
<div className="w-full overflow-x-auto">
|
||||
<div className="flex items-center gap-4 mb-1">
|
||||
{series.map((s) => (
|
||||
<span key={s.name} className="flex items-center gap-1.5 text-[12px]" style={{ color: "var(--text-secondary)" }}>
|
||||
<span className="inline-block w-3 h-[3px] rounded-full" style={{ background: s.color }} />
|
||||
{s.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<svg viewBox={`0 0 ${W} ${H}`} className="w-full" style={{ minWidth: 460 }} role="img" aria-label="Comparatif mensuel local vs réseau">
|
||||
<line x1={padL} y1={padT + plotH} x2={padL + plotW} y2={padT + plotH} stroke="var(--border)" strokeWidth={1} />
|
||||
{series.map((s) => {
|
||||
const pts = s.values.map((v, i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`).join(" ");
|
||||
return (
|
||||
<g key={s.name}>
|
||||
{n > 1 && (
|
||||
<polyline points={pts} fill="none" stroke={s.color} strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" />
|
||||
)}
|
||||
{s.values.map((v, i) => (
|
||||
<circle key={`${s.name}-${labels[i]}`} cx={x(i)} cy={y(v)} r={2.4} fill={s.color} />
|
||||
))}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
{labels.map((lab, i) => (
|
||||
<text key={lab} x={x(i)} y={H - 10} textAnchor="middle" fontSize={8} fill="var(--text-muted)">
|
||||
{fmtMonthShort(lab)}
|
||||
</text>
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useRef } from "react";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { useGridStore } from "@/features/grid/store/use-grid-store";
|
||||
import type { ProductRow } from "@/types/grid";
|
||||
import { PackageX } from "lucide-react";
|
||||
|
||||
function getNoSalesIn6Months(rows: ProductRow[]): ProductRow[] {
|
||||
const sixMonthsAgo = new Date();
|
||||
sixMonthsAgo.setMonth(sixMonthsAgo.getMonth() - 6);
|
||||
|
||||
return rows.filter(row => {
|
||||
if (row.derniereVente) {
|
||||
return new Date(row.derniereVente) < sixMonthsAgo;
|
||||
}
|
||||
// Fallback : calcul depuis sales12m
|
||||
const salesMonths = Object.entries(row.sales12m || {})
|
||||
.filter(([, qty]) => qty > 0)
|
||||
.map(([m]) => m)
|
||||
.sort();
|
||||
if (salesMonths.length === 0) return true;
|
||||
const last = salesMonths[salesMonths.length - 1];
|
||||
const lastDate = new Date(parseInt(last.slice(0, 4)), parseInt(last.slice(4, 6)) - 1, 1);
|
||||
return lastDate < sixMonthsAgo;
|
||||
});
|
||||
}
|
||||
|
||||
function formatDate(iso?: string): string {
|
||||
if (!iso) return "—";
|
||||
return new Date(iso).toLocaleDateString("fr-FR", { day: "2-digit", month: "2-digit", year: "numeric" });
|
||||
}
|
||||
|
||||
function daysSince(iso?: string): number | null {
|
||||
if (!iso) return null;
|
||||
return Math.floor((Date.now() - new Date(iso).getTime()) / 86_400_000);
|
||||
}
|
||||
|
||||
function formatCa(ca: number): string {
|
||||
return ca.toLocaleString("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 });
|
||||
}
|
||||
|
||||
const GAMME_COLORS: Record<string, string> = {
|
||||
A: "bg-emerald-500/15 text-emerald-700",
|
||||
B: "bg-blue-500/15 text-blue-700",
|
||||
C: "bg-amber-500/15 text-amber-700",
|
||||
Y: "bg-purple-500/15 text-purple-700",
|
||||
Z: "bg-red-500/15 text-red-700",
|
||||
};
|
||||
|
||||
export function NoSalesTab() {
|
||||
const rows = useGridStore(s => s.rows);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const result = getNoSalesIn6Months(rows);
|
||||
// Tri : produits les plus anciens en premier, puis par stock décroissant
|
||||
return result.sort((a, b) => {
|
||||
const da = a.derniereVente ? new Date(a.derniereVente).getTime() : 0;
|
||||
const db = b.derniereVente ? new Date(b.derniereVente).getTime() : 0;
|
||||
if (da !== db) return da - db;
|
||||
return (b.stockActuel ?? 0) - (a.stockActuel ?? 0);
|
||||
});
|
||||
}, [rows]);
|
||||
|
||||
// eslint-disable-next-line react-hooks/incompatible-library
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: filtered.length,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: () => 36,
|
||||
overscan: 12,
|
||||
});
|
||||
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center text-sm italic" style={{ color: "var(--text-muted)" }}>
|
||||
Chargement des données en cours…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (filtered.length === 0) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-3" style={{ color: "var(--text-muted)" }}>
|
||||
<PackageX className="w-10 h-10 opacity-30" />
|
||||
<p className="text-sm italic">Aucun produit sans vente depuis 6 mois.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0 gap-3">
|
||||
{/* Résumé */}
|
||||
<div className="shrink-0 px-3 py-2 rounded-lg text-sm font-medium flex items-center gap-2"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}
|
||||
>
|
||||
<PackageX className="w-4 h-4 shrink-0" style={{ color: "var(--accent)" }} />
|
||||
<span>
|
||||
<span className="font-bold" style={{ color: "var(--text-primary)" }}>
|
||||
{filtered.length.toLocaleString("fr-FR")}
|
||||
</span>
|
||||
{" "}produit{filtered.length > 1 ? "s" : ""} sans vente depuis plus de 6 mois
|
||||
{" "}({rows.length.toLocaleString("fr-FR")} références au total)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Tableau */}
|
||||
<div ref={scrollRef} className="flex-1 min-h-0 overflow-auto rounded-xl" style={{ border: "1px solid var(--border)" }}>
|
||||
<div className="min-w-[900px] text-[12px]">
|
||||
<div className="sticky top-0 z-10 grid grid-cols-[90px_minmax(220px,1fr)_120px_76px_130px_70px_90px_110px_70px]"
|
||||
style={{ background: "var(--bg-elevated)", borderBottom: "1px solid var(--border)" }}
|
||||
>
|
||||
{["Code", "Libellé", "Réf.", "Gamme", "Dernière vente", "Jours", "Stock", "CA 12m", "Mag."].map(col => (
|
||||
<div key={col}
|
||||
className="px-3 py-2 text-left font-semibold whitespace-nowrap"
|
||||
style={{ color: "var(--text-muted)" }}
|
||||
>
|
||||
{col}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ height: rowVirtualizer.getTotalSize(), position: "relative" }}>
|
||||
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
||||
const row = filtered[virtualRow.index];
|
||||
const i = virtualRow.index;
|
||||
const days = daysSince(row.derniereVente);
|
||||
const gamme = row.codeGamme?.trim().toUpperCase() ?? "—";
|
||||
const gammeClass = GAMME_COLORS[gamme] ?? "bg-zinc-500/15 text-zinc-600";
|
||||
return (
|
||||
<div
|
||||
key={row.codein}
|
||||
className="absolute left-0 right-0 grid grid-cols-[90px_minmax(220px,1fr)_120px_76px_130px_70px_90px_110px_70px] items-center transition-colors hover:bg-white/5"
|
||||
style={{
|
||||
height: `${virtualRow.size}px`,
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
borderBottom: i < filtered.length - 1 ? "1px solid var(--border)" : undefined,
|
||||
background: i % 2 === 0 ? "transparent" : "var(--bg-subtle, rgba(0,0,0,0.02))",
|
||||
}}
|
||||
>
|
||||
<div className="px-3 py-1.5 font-mono" style={{ color: "var(--text-muted)" }}>{row.codein}</div>
|
||||
<div className="px-3 py-1.5 truncate" style={{ color: "var(--text-primary)" }} title={row.libelle1}>{row.libelle1}</div>
|
||||
<div className="px-3 py-1.5 font-mono text-[11px]" style={{ color: "var(--text-muted)" }}>{row.reference || "—"}</div>
|
||||
<div className="px-3 py-1.5">
|
||||
<span className={`inline-block px-1.5 py-0.5 rounded text-[11px] font-bold ${gammeClass}`}>
|
||||
{gamme}
|
||||
</span>
|
||||
</div>
|
||||
<div className="px-3 py-1.5 whitespace-nowrap" style={{ color: row.derniereVente ? "var(--text-secondary)" : "var(--text-muted)" }}>
|
||||
{formatDate(row.derniereVente)}
|
||||
</div>
|
||||
<div className="px-3 py-1.5 text-right font-medium tabular-nums"
|
||||
style={{ color: days && days > 365 ? "var(--color-red, #ef4444)" : "var(--text-secondary)" }}>
|
||||
{days != null ? days.toLocaleString("fr-FR") : "—"}
|
||||
</div>
|
||||
<div className="px-3 py-1.5 text-right tabular-nums" style={{ color: "var(--text-secondary)" }}>
|
||||
{row.stockActuel != null ? row.stockActuel.toLocaleString("fr-FR") : "—"}
|
||||
</div>
|
||||
<div className="px-3 py-1.5 text-right tabular-nums" style={{ color: "var(--text-secondary)" }}>
|
||||
{row.totalCa > 0 ? formatCa(row.totalCa) : "—"}
|
||||
</div>
|
||||
<div className="px-3 py-1.5 text-right tabular-nums font-semibold"
|
||||
style={{ color: "var(--text-secondary)" }}>
|
||||
{row.nbMagasinsReseau != null ? row.nbMagasinsReseau : "—"}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Compte les produits sans vente depuis 6 mois — utilisé pour le badge dans le switcher */
|
||||
export function countNoSalesIn6Months(rows: ProductRow[]): number {
|
||||
return getNoSalesIn6Months(rows).length;
|
||||
}
|
||||
@@ -2,7 +2,13 @@
|
||||
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChevronDown, Filter } from "lucide-react";
|
||||
import { ChevronDown, Filter, Search, X } from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useGridStore } from "@/features/grid/store/use-grid-store";
|
||||
|
||||
interface NomenclatureFilterProps {
|
||||
@@ -10,33 +16,143 @@ interface NomenclatureFilterProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filtre multi-nomenclatures de la Grille.
|
||||
*
|
||||
* Une liste déroulante à cases plutôt qu'un `<select>` simple : on veut pouvoir
|
||||
* en retenir plusieurs, et surtout partir de « tout » pour retirer les postes
|
||||
* dont on ne veut pas — ce qui est la manière naturelle de travailler quand un
|
||||
* fournisseur en compte des dizaines.
|
||||
*
|
||||
* Convention : `null` = aucun filtre (tout s'affiche), `[]` = rien de coché
|
||||
* (aucune ligne). Les distinguer évite la valeur sentinelle qu'imposerait un
|
||||
* simple tableau, et rend « Tout décocher » possible sans ambiguïté.
|
||||
*/
|
||||
export function NomenclatureFilter({ options, className }: NomenclatureFilterProps) {
|
||||
const code3 = useGridStore((s) => s.filters.code3);
|
||||
const setFilter = useGridStore((s) => s.setFilter);
|
||||
const selection = useGridStore((s) => s.filters.code3);
|
||||
const setCode3Filter = useGridStore((s) => s.setCode3Filter);
|
||||
const [recherche, setRecherche] = React.useState("");
|
||||
|
||||
/** `null` = tout retenu : les cases sont alors toutes cochées. */
|
||||
const tout = selection === null;
|
||||
const selectionnes = React.useMemo(() => new Set(selection ?? []), [selection]);
|
||||
|
||||
const visibles = React.useMemo(() => {
|
||||
const q = recherche.trim().toLowerCase();
|
||||
if (!q) return options;
|
||||
return options.filter((o) => `${o.code} ${o.label}`.toLowerCase().includes(q));
|
||||
}, [options, recherche]);
|
||||
|
||||
const basculer = (code: string, coche: boolean) => {
|
||||
// Décocher depuis l'état « tout » doit retirer ce seul poste : on matérialise
|
||||
// donc la liste complète, sans quoi le premier décochage ne ferait rien.
|
||||
const base = tout ? options.map((o) => o.code) : (selection ?? []);
|
||||
const suivant = coche ? [...base, code] : base.filter((c) => c !== code);
|
||||
// Tout re-cocher revient à ne plus filtrer : on repasse à `null` pour que
|
||||
// l'état reste lisible (et l'URL propre).
|
||||
setCode3Filter(suivant.length === options.length ? null : suivant);
|
||||
};
|
||||
|
||||
const libelle = tout
|
||||
? "Toute la nomenclature"
|
||||
: selectionnes.size === 0
|
||||
? "Aucune nomenclature"
|
||||
: selectionnes.size === 1
|
||||
? (options.find((o) => o.code === selection![0])?.label ?? selection![0])
|
||||
: `${selectionnes.size} / ${options.length} nomenclatures`;
|
||||
|
||||
return (
|
||||
<div className={cn("relative group", className)}>
|
||||
<div className="absolute left-3 top-1/2 -translate-y-1/2 flex items-center gap-2 pointer-events-none">
|
||||
<Filter className="h-3.5 w-3.5 opacity-40 group-focus-within:opacity-100 transition-opacity" style={{ color: "var(--text-primary)" }} />
|
||||
</div>
|
||||
<div className={cn("relative", className)}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="apple-input w-full min-w-[240px] flex items-center gap-2 text-left"
|
||||
style={{ fontSize: "12px", height: "36px", paddingLeft: "12px", paddingRight: "12px" }}
|
||||
>
|
||||
<Filter className="h-3.5 w-3.5 opacity-40 shrink-0" />
|
||||
<span className="flex-1 truncate" style={{ color: tout ? undefined : "var(--text-primary)" }}>
|
||||
{libelle}
|
||||
</span>
|
||||
{!tout && (
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
title="Tout réafficher"
|
||||
onClick={(e) => { e.preventDefault(); e.stopPropagation(); setCode3Filter(null); }}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); e.stopPropagation(); setCode3Filter(null); } }}
|
||||
className="shrink-0 rounded p-0.5 hover:bg-[var(--bg-elevated)]"
|
||||
>
|
||||
<X className="h-3.5 w-3.5 opacity-60" />
|
||||
</span>
|
||||
)}
|
||||
<ChevronDown className="h-4 w-4 opacity-40 shrink-0" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<select
|
||||
value={code3 ?? ""}
|
||||
onChange={(e) => setFilter("code3", e.target.value || null)}
|
||||
className="apple-input pr-12 appearance-none w-full min-w-[240px]"
|
||||
style={{ fontSize: "12px", height: "36px", paddingLeft: "36px" }}
|
||||
>
|
||||
<option value="">Toute la nomenclature</option>
|
||||
{options.map((opt) => (
|
||||
<option key={opt.code} value={opt.code}>
|
||||
{opt.code} — {opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<DropdownMenuContent align="start" className="w-[320px] max-h-[420px] overflow-y-auto p-0">
|
||||
<div className="sticky top-0 z-10 p-2 space-y-2" style={{ background: "var(--bg-surface)", borderBottom: "1px solid var(--border)" }}>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 opacity-40" />
|
||||
<input
|
||||
value={recherche}
|
||||
onChange={(e) => setRecherche(e.target.value)}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
placeholder="Filtrer les nomenclatures"
|
||||
className="w-full rounded-lg pl-7 pr-2 py-1.5 text-[12px] outline-none"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-primary)" }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-[11px]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCode3Filter(null)}
|
||||
className="rounded px-2 py-1 hover:bg-[var(--bg-elevated)]"
|
||||
style={{ color: "var(--text-secondary)" }}
|
||||
>
|
||||
Tout cocher
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
// Un code vide ne correspond à aucune ligne : c'est ainsi qu'on
|
||||
// exprime « rien de coché » sans confondre avec « pas de filtre ».
|
||||
onClick={() => setCode3Filter([])}
|
||||
className="rounded px-2 py-1 hover:bg-[var(--bg-elevated)]"
|
||||
style={{ color: "var(--text-secondary)" }}
|
||||
>
|
||||
Tout décocher
|
||||
</button>
|
||||
{recherche.trim() && visibles.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCode3Filter(visibles.map((o) => o.code))}
|
||||
className="rounded px-2 py-1 hover:bg-[var(--bg-elevated)] ml-auto"
|
||||
style={{ color: "var(--accent)" }}
|
||||
>
|
||||
Ne garder que ces {visibles.length}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none opacity-40">
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</div>
|
||||
{visibles.length === 0 ? (
|
||||
<p className="p-3 text-[12px]" style={{ color: "var(--text-muted)" }}>Aucune nomenclature ne correspond.</p>
|
||||
) : (
|
||||
visibles.map((opt) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={opt.code}
|
||||
className="text-xs cursor-pointer"
|
||||
checked={tout || selectionnes.has(opt.code)}
|
||||
onCheckedChange={(v) => basculer(opt.code, !!v)}
|
||||
onSelect={(e: Event) => e.preventDefault()}
|
||||
>
|
||||
<span className="font-mono opacity-60 mr-1.5">{opt.code}</span>
|
||||
{opt.label}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* CollectFlow — Détail mensuel d'un produit, ouvert depuis la case « Tot. 12m ».
|
||||
*
|
||||
* La colonne de total dit COMBIEN, jamais COMMENT. On empile donc les trois
|
||||
* séries qui expliquent le total, dans l'ordre où on les lit : ce qui est sorti
|
||||
* (ventes), ce qui est entré (réceptions), puis ce qui reste — magasin par
|
||||
* magasin, parce qu'un stock de 40 réparti sur deux sites et un stock de 40
|
||||
* bloqué sur un seul ne se pilotent pas pareil.
|
||||
*
|
||||
* SVG fait main comme le reste des graphiques du dossier : aucune librairie.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { ShoppingCart, PackageOpen, Warehouse } from "lucide-react";
|
||||
import { DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { TuileStat } from "@/features/grid/components/stat-tile";
|
||||
import { formatMonthLabel, formatDate, SITE_LABELS } from "@/features/grid/lib/months";
|
||||
import type { ProductRow } from "@/types/grid";
|
||||
|
||||
/** Couleurs de séries : slots de la palette catégorielle validée (cf. `globals.css`). */
|
||||
const COULEUR_VENTES = "var(--viz-1)";
|
||||
const COULEUR_ENTREES = "var(--viz-3)";
|
||||
|
||||
const fmt = (v: number) => Math.round(v).toLocaleString("fr-FR");
|
||||
|
||||
/**
|
||||
* Barres mensuelles sur une ligne de zéro explicite.
|
||||
*
|
||||
* Les quantités peuvent être NÉGATIVES (retours supérieurs aux ventes sur un
|
||||
* mois) : l'échelle part donc du minimum réel et les barres se dessinent de part
|
||||
* et d'autre du zéro, sinon un mois de retours sortirait du cadre par le bas.
|
||||
*
|
||||
* Chaque barre porte sa valeur écrite : douze nombres restent lisibles à cette
|
||||
* largeur, et aucune valeur n'est ainsi accessible au seul survol.
|
||||
*/
|
||||
function BarresMensuelles({ mois, valeurs, couleur, aria }: {
|
||||
mois: string[];
|
||||
valeurs: number[];
|
||||
couleur: string;
|
||||
aria: string;
|
||||
}) {
|
||||
const n = valeurs.length;
|
||||
const min = Math.min(0, ...valeurs);
|
||||
const max = Math.max(0, ...valeurs);
|
||||
|
||||
// Une barre négative écrit sa valeur SOUS elle : sans marge basse
|
||||
// supplémentaire, ce nombre se poserait sur les libellés de mois.
|
||||
const W = 660, hautPlot = 104, padH = 18;
|
||||
const padB = min < 0 ? 42 : 24;
|
||||
const H = padH + hautPlot + padB;
|
||||
|
||||
const etendue = max - min || 1;
|
||||
const y = (v: number) => padH + hautPlot - ((v - min) / etendue) * hautPlot;
|
||||
const yZero = y(0);
|
||||
|
||||
const pas = W / n;
|
||||
const largeur = Math.min(pas * 0.6, 34);
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto min-w-0">
|
||||
<svg
|
||||
viewBox={`0 0 ${W} ${H}`}
|
||||
width="100%"
|
||||
height={H}
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
// `display:block` : un SVG en ligne traîne l'espace sous la ligne de
|
||||
// base et décale la section suivante.
|
||||
style={{ minWidth: 520, display: "block" }}
|
||||
role="img"
|
||||
aria-label={aria}
|
||||
>
|
||||
<line x1={0} y1={yZero} x2={W} y2={yZero} stroke="var(--border)" strokeWidth={1} />
|
||||
{valeurs.map((v, i) => {
|
||||
const cx = pas * i + pas / 2;
|
||||
const yVal = y(v);
|
||||
const haut = Math.min(yVal, yZero);
|
||||
const hauteur = Math.max(Math.abs(yVal - yZero), 1.5);
|
||||
return (
|
||||
<g key={mois[i]}>
|
||||
{v !== 0 && (
|
||||
<rect
|
||||
x={cx - largeur / 2}
|
||||
y={haut}
|
||||
width={largeur}
|
||||
height={hauteur}
|
||||
rx={2.5}
|
||||
fill={couleur}
|
||||
opacity={0.9}
|
||||
/>
|
||||
)}
|
||||
<text
|
||||
x={cx}
|
||||
y={v < 0 ? yZero + hauteur + 12 : haut - 5}
|
||||
textAnchor="middle"
|
||||
fontSize={11.5}
|
||||
fontWeight={700}
|
||||
fill={v === 0 ? "var(--text-muted)" : "var(--text-secondary)"}
|
||||
>
|
||||
{fmt(v)}
|
||||
</text>
|
||||
<text x={cx} y={H - 8} textAnchor="middle" fontSize={11} fill="var(--text-muted)">
|
||||
{formatMonthLabel(mois[i])}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** En-tête de section : repère coloré + titre + cumul de la série. */
|
||||
function EnteteSection({ icone: Icone, titre, detail, couleur }: {
|
||||
icone: React.ComponentType<{ className?: string; style?: React.CSSProperties }>;
|
||||
titre: string;
|
||||
detail: string;
|
||||
couleur: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="rounded-sm shrink-0" style={{ width: 14, height: 4, background: couleur }} />
|
||||
<Icone className="w-4 h-4 shrink-0" style={{ color: "var(--text-muted)" }} />
|
||||
<h3 className="text-[13px] font-bold" style={{ color: "var(--text-primary)" }}>{titre}</h3>
|
||||
<span className="text-[12px]" style={{ color: "var(--text-muted)" }}>{detail}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Une série mensuelle ventilée par magasin, un magasin par ligne.
|
||||
*
|
||||
* Sert aux ventes (flux : une colonne « 12 m » cumule la ligne) comme au stock
|
||||
* (niveau : additionner douze fins de mois n'aurait aucun sens, pas de cumul).
|
||||
*
|
||||
* La ligne de total est recalculée depuis les lignes affichées : un total qui ne
|
||||
* correspond pas à la somme visible fait douter du tableau entier.
|
||||
*/
|
||||
function TableauParMagasin({ row, mois, activeMagasin, parSite, agrege, sousLigne, cumul, aria }: {
|
||||
row: ProductRow;
|
||||
mois: string[];
|
||||
activeMagasin: string;
|
||||
/** Série par magasin (site → mois → valeur). */
|
||||
parSite: Record<string, Record<string, number>> | undefined;
|
||||
/** Série agrégée réseau, affichée seule quand aucune ventilation n'existe. */
|
||||
agrege: Record<string, number>;
|
||||
/** Texte secondaire sous le nom du magasin (`null` pour la ligne réseau). */
|
||||
sousLigne: (site: string | null, valeurs: number[]) => string;
|
||||
/** Ajoute une colonne de cumul sur 12 mois (flux uniquement). */
|
||||
cumul?: boolean;
|
||||
aria: string;
|
||||
}) {
|
||||
const sites = [...new Set([
|
||||
...Object.keys(row.stock12mByStore ?? {}),
|
||||
...Object.keys(row.sales12mByStore ?? {}),
|
||||
])].sort();
|
||||
|
||||
// Sans ventilation par site (produit jamais mouvementé, données partielles),
|
||||
// on montre au moins la série agrégée plutôt qu'un tableau vide.
|
||||
const lignes = sites.length > 0
|
||||
? sites.map((site) => {
|
||||
const valeurs = mois.map((m) => parSite?.[site]?.[m] ?? 0);
|
||||
return { cle: site, nom: SITE_LABELS[site]?.nom ?? site, actif: activeMagasin === site, valeurs, sous: sousLigne(site, valeurs) };
|
||||
})
|
||||
: [(() => {
|
||||
const valeurs = mois.map((m) => agrege[m] ?? 0);
|
||||
return { cle: "TOTAL", nom: "Total réseau", actif: true, valeurs, sous: sousLigne(null, valeurs) };
|
||||
})()];
|
||||
|
||||
const totaux = mois.map((_, i) => lignes.reduce((t, l) => t + l.valeurs[i], 0));
|
||||
const somme = (v: number[]) => v.reduce((t, x) => t + x, 0);
|
||||
|
||||
const cellule = (v: number, key: string, gras = false) => (
|
||||
<td
|
||||
key={key}
|
||||
className={`px-1.5 py-1.5 text-right tabular-nums${gras ? " font-bold" : ""}`}
|
||||
style={{ color: v === 0 && !gras ? "var(--text-muted)" : "var(--text-primary)" }}
|
||||
>
|
||||
{fmt(v)}
|
||||
</td>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="rounded-xl overflow-x-auto min-w-0" style={{ border: "1px solid var(--border)" }}>
|
||||
<table className="w-full text-[11.5px]" style={{ minWidth: 600 }} aria-label={aria}>
|
||||
<thead>
|
||||
<tr style={{ background: "var(--bg-elevated)" }}>
|
||||
<th className="text-left px-2.5 py-1.5 font-semibold" style={{ color: "var(--text-secondary)" }}>
|
||||
Magasin
|
||||
</th>
|
||||
{mois.map((m) => (
|
||||
<th key={m} className="text-right px-1.5 py-1.5 font-semibold whitespace-nowrap" style={{ color: "var(--text-secondary)" }}>
|
||||
{formatMonthLabel(m)}
|
||||
</th>
|
||||
))}
|
||||
{cumul && (
|
||||
<th className="text-right px-2 py-1.5 font-bold whitespace-nowrap" style={{ color: "var(--text-primary)", borderLeft: "1px solid var(--border)" }}>
|
||||
12 m
|
||||
</th>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{lignes.map((l) => {
|
||||
const surligne = l.actif && activeMagasin !== "TOTAL";
|
||||
return (
|
||||
<tr key={l.cle} style={{ borderTop: "1px solid var(--border)", background: surligne ? "var(--accent-bg)" : undefined }}>
|
||||
<td className="px-2.5 py-1.5 whitespace-nowrap" style={{ color: "var(--text-primary)" }}>
|
||||
<div className="font-semibold">{l.nom}</div>
|
||||
{/* L'information secondaire (dernière entrée, mois avec
|
||||
vente) est écrite, pas réservée au survol. */}
|
||||
<div className="text-[10px]" style={{ color: "var(--text-muted)" }}>{l.sous}</div>
|
||||
</td>
|
||||
{l.valeurs.map((v, i) => cellule(v, mois[i]))}
|
||||
{cumul && (
|
||||
<td className="px-2 py-1.5 text-right tabular-nums font-bold" style={{ color: "var(--text-primary)", borderLeft: "1px solid var(--border)" }}>
|
||||
{fmt(somme(l.valeurs))}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{lignes.length > 1 && (
|
||||
<tr style={{ borderTop: "1px solid var(--border-strong)", background: "var(--bg-elevated)" }}>
|
||||
<td className="px-2.5 py-1.5 font-bold" style={{ color: "var(--text-primary)" }}>
|
||||
Total
|
||||
</td>
|
||||
{totaux.map((v, i) => cellule(v, mois[i], true))}
|
||||
{cumul && (
|
||||
<td className="px-2 py-1.5 text-right tabular-nums font-bold" style={{ color: "var(--text-primary)", borderLeft: "1px solid var(--border)" }}>
|
||||
{fmt(somme(totaux))}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Détail 12 mois d'un produit : ventes, entrées en stock, stock par magasin.
|
||||
*
|
||||
* Les graphiques suivent le magasin actif de la Grille (cohérence avec la case
|
||||
* cliquée) ; les tableaux de ventes et de stock restent toujours ventilés par
|
||||
* magasin, c'est leur objet.
|
||||
*
|
||||
* `mois` est la fenêtre du serveur (lue dans les données, cf. `months.ts`) :
|
||||
* c'est ce qui garantit que la tuile « Ventes 12 m » retombe sur le total de la
|
||||
* Grille.
|
||||
*/
|
||||
export function ProductMonthlyModal({ row, mois, activeMagasin, onClose }: {
|
||||
row: ProductRow;
|
||||
mois: string[];
|
||||
activeMagasin: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const surTotal = activeMagasin === "TOTAL";
|
||||
const libelleMagasin = surTotal ? "Tous magasins" : (SITE_LABELS[activeMagasin]?.nom ?? activeMagasin);
|
||||
|
||||
const ventes = mois.map((m) => (surTotal
|
||||
? row.sales12m[m]
|
||||
: row.sales12mByStore?.[activeMagasin]?.[m]) ?? 0);
|
||||
const entrees = mois.map((m) => (surTotal
|
||||
? row.receptions12m[m]
|
||||
: row.receptions12mByStore?.[activeMagasin]?.[m]) ?? 0);
|
||||
const stocks = mois.map((m) => (surTotal
|
||||
? row.stock12m[m]
|
||||
: row.stock12mByStore?.[activeMagasin]?.[m]) ?? 0);
|
||||
|
||||
// Sur un magasin donné, la dernière entrée du réseau n'est pas la sienne.
|
||||
const derniereEntree = surTotal
|
||||
? row.derniereLivraison
|
||||
: row.derniereLivraisonByStore?.[activeMagasin];
|
||||
|
||||
const totalVentes = ventes.reduce((t, v) => t + v, 0);
|
||||
const totalEntrees = entrees.reduce((t, v) => t + v, 0);
|
||||
const stockFin = stocks[stocks.length - 1] ?? 0;
|
||||
const moisAvecVente = ventes.filter((v) => v !== 0).length;
|
||||
|
||||
// Couverture : combien de mois le stock actuel tient-il au rythme des 12
|
||||
// derniers mois ? Sans vente, le ratio n'existe pas — on ne l'invente pas.
|
||||
const rythme = totalVentes / mois.length;
|
||||
const couverture = rythme > 0 ? stockFin / rythme : null;
|
||||
|
||||
return (
|
||||
<DialogContent
|
||||
// `grid-cols-1` : sans colonne explicite, la grille de la modale se
|
||||
// dimensionne sur son contenu le plus large (le tableau des magasins)
|
||||
// et déborde par la droite au lieu de le laisser défiler.
|
||||
className="max-w-[calc(100%-2rem)] sm:max-w-4xl grid-cols-1 max-h-[88vh] overflow-y-auto"
|
||||
style={{ background: "var(--bg-surface)", border: "1px solid var(--border)" }}
|
||||
onInteractOutside={onClose}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-lg leading-snug pr-6" style={{ color: "var(--text-primary)" }}>
|
||||
{row.libelle1}
|
||||
</DialogTitle>
|
||||
<p className="text-[13px] mt-1" style={{ color: "var(--text-muted)" }}>
|
||||
{row.codein}
|
||||
{row.reference ? ` · ${row.reference}` : ""}
|
||||
{" · "}
|
||||
<span className="font-semibold" style={{ color: "var(--text-secondary)" }}>{libelleMagasin}</span>
|
||||
{" · 12 mois glissants, mois en cours exclu"}
|
||||
</p>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<TuileStat
|
||||
label="Ventes 12 m"
|
||||
valeur={fmt(totalVentes)}
|
||||
indice={`${moisAvecVente} mois avec vente · ${rythme.toLocaleString("fr-FR", { maximumFractionDigits: 1 })} / mois`}
|
||||
icone={ShoppingCart}
|
||||
/>
|
||||
<TuileStat
|
||||
label="Entrées 12 m"
|
||||
valeur={totalEntrees > 0 ? `+${fmt(totalEntrees)}` : "—"}
|
||||
indice={derniereEntree ? `dernière entrée ${formatDate(derniereEntree)}` : "aucune entrée enregistrée"}
|
||||
couleur={totalEntrees > 0 ? COULEUR_ENTREES : undefined}
|
||||
icone={PackageOpen}
|
||||
/>
|
||||
<TuileStat
|
||||
label="Stock fin de période"
|
||||
valeur={fmt(stockFin)}
|
||||
indice={couverture != null
|
||||
? `≈ ${couverture.toLocaleString("fr-FR", { maximumFractionDigits: 1 })} mois de couverture`
|
||||
: "pas de vente sur la période"}
|
||||
icone={Warehouse}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section className="space-y-1.5">
|
||||
<EnteteSection
|
||||
icone={ShoppingCart}
|
||||
titre="Ventes mensuelles"
|
||||
detail={`${fmt(totalVentes)} unités sur 12 mois`}
|
||||
couleur={COULEUR_VENTES}
|
||||
/>
|
||||
<BarresMensuelles
|
||||
mois={mois}
|
||||
valeurs={ventes}
|
||||
couleur={COULEUR_VENTES}
|
||||
aria={`Ventes mensuelles de ${row.libelle1} sur 12 mois`}
|
||||
/>
|
||||
{/* Le graphique donne la forme, le tableau donne les chiffres par
|
||||
magasin — et son cumul « 12 m » est le même total que la tuile
|
||||
et que la case cliquée dans la Grille. */}
|
||||
<TableauParMagasin
|
||||
row={row}
|
||||
mois={mois}
|
||||
activeMagasin={activeMagasin}
|
||||
parSite={row.sales12mByStore}
|
||||
agrege={row.sales12m}
|
||||
sousLigne={(_site, valeurs) => {
|
||||
const n = valeurs.filter((v) => v !== 0).length;
|
||||
return n === 0 ? "aucune vente" : `${n} mois avec vente`;
|
||||
}}
|
||||
cumul
|
||||
aria={`Ventes mensuelles de ${row.libelle1} par magasin`}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="space-y-1">
|
||||
<EnteteSection
|
||||
icone={PackageOpen}
|
||||
titre="Entrées en stock"
|
||||
detail={totalEntrees > 0 ? `+${fmt(totalEntrees)} unités reçues sur 12 mois` : "aucune réception sur la période"}
|
||||
couleur={COULEUR_ENTREES}
|
||||
/>
|
||||
<BarresMensuelles
|
||||
mois={mois}
|
||||
valeurs={entrees}
|
||||
couleur={COULEUR_ENTREES}
|
||||
aria={`Entrées en stock de ${row.libelle1} sur 12 mois`}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="space-y-1.5">
|
||||
<EnteteSection
|
||||
icone={Warehouse}
|
||||
titre="Stock par magasin"
|
||||
detail="stock de fin de mois"
|
||||
couleur="var(--border-strong)"
|
||||
/>
|
||||
<TableauParMagasin
|
||||
row={row}
|
||||
mois={mois}
|
||||
activeMagasin={activeMagasin}
|
||||
parSite={row.stock12mByStore}
|
||||
agrege={row.stock12m}
|
||||
sousLigne={(site) => {
|
||||
// La date de dernière entrée explique un stock qui ne bouge plus.
|
||||
const d = site ? row.derniereLivraisonByStore?.[site] : row.derniereLivraison;
|
||||
return d ? `dern. entrée ${formatDate(d)}` : "aucune entrée";
|
||||
}}
|
||||
aria={`Stock de fin de mois de ${row.libelle1} par magasin`}
|
||||
/>
|
||||
</section>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
|
||||
/**
|
||||
* Tuile de statistique : intitulé en phrase, valeur en chiffres proportionnels.
|
||||
*
|
||||
* Pas de `tabular-nums` ici : à grande taille, des chiffres de largeur égale font
|
||||
* paraître un nombre court anormalement lâche. Les chiffres tabulaires sont
|
||||
* réservés aux colonnes qui s'alignent verticalement (tableau, axes).
|
||||
*
|
||||
* Extraite de `heatmap-grid.tsx` pour être partagée entre la modale de tendance
|
||||
* réseau et la modale de détail mensuel produit. Aucun changement de rendu.
|
||||
*/
|
||||
export function TuileStat({ label, valeur, indice, couleur, icone: Icone }: {
|
||||
label: string;
|
||||
valeur: string;
|
||||
indice?: string;
|
||||
couleur?: string;
|
||||
icone?: React.ComponentType<{ className?: string; style?: React.CSSProperties }>;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-xl px-3.5 py-3 flex-1 min-w-[160px]" style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}>
|
||||
<div className="text-[12px] font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>
|
||||
{label}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mt-1">
|
||||
{Icone && <Icone className="w-5 h-5 shrink-0" style={{ color: couleur ?? "var(--text-primary)" }} />}
|
||||
<span className="text-[26px] font-bold leading-tight" style={{ color: couleur ?? "var(--text-primary)" }}>
|
||||
{valeur}
|
||||
</span>
|
||||
</div>
|
||||
{indice && <div className="text-[12px] mt-1 leading-snug" style={{ color: "var(--text-muted)" }}>{indice}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useCallback } from "react";
|
||||
import { useRouter, usePathname, useSearchParams } from "next/navigation";
|
||||
import { Loader2, RefreshCw, CheckCircle, AlertCircle } from "lucide-react";
|
||||
import { useQlikSyncJob } from "@/features/qlik-sync/use-qlik-sync-job";
|
||||
|
||||
interface SyncQlikButtonProps {
|
||||
/** Code fournisseur affiché (pour sync ciblée) */
|
||||
@@ -11,7 +12,8 @@ interface SyncQlikButtonProps {
|
||||
lastUpdate?: string | null;
|
||||
}
|
||||
|
||||
function formatDate(iso?: string | null): string {
|
||||
/** Date + heure de dernière synchro, ou "jamais". */
|
||||
function formatLastUpdate(iso?: string | null): string {
|
||||
if (!iso) return "jamais";
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return "jamais";
|
||||
@@ -21,49 +23,48 @@ function formatDate(iso?: string | null): string {
|
||||
/**
|
||||
* Bouton admin : synchronise depuis Qlik les métriques réseau du fournisseur affiché
|
||||
* (POST /api/qlik/sync?fournisseur=…) puis recharge la grille. Affiche la dernière maj.
|
||||
*
|
||||
* Toute la mécanique de job (POST puis polling GET, reprise après reload) vit
|
||||
* dans `useQlikSyncJob`, partagée avec la fiche produit.
|
||||
*/
|
||||
export function SyncQlikButton({ codeFournisseur, lastUpdate }: SyncQlikButtonProps) {
|
||||
const [status, setStatus] = useState<"idle" | "loading" | "success" | "error">("idle");
|
||||
const [message, setMessage] = useState<string>("");
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const handleSync = async () => {
|
||||
if (!codeFournisseur) {
|
||||
setStatus("error");
|
||||
setMessage("Aucun fournisseur sélectionné");
|
||||
return;
|
||||
}
|
||||
setStatus("loading");
|
||||
setMessage("");
|
||||
try {
|
||||
const res = await fetch(`/api/qlik/sync?fournisseur=${encodeURIComponent(codeFournisseur)}`, { method: "POST" });
|
||||
const data = await res.json();
|
||||
if (!res.ok || !data.success) throw new Error(data.error || `HTTP ${res.status}`);
|
||||
setStatus("success");
|
||||
setMessage(`${data.upserted} produits réseau synchronisés`);
|
||||
// Recharge la grille pour afficher les nouvelles données réseau
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set("_refresh", String(Date.now()));
|
||||
router.replace(`${pathname}?${params.toString()}`);
|
||||
setTimeout(() => setStatus("idle"), 4000);
|
||||
} catch (e) {
|
||||
setStatus("error");
|
||||
setMessage(e instanceof Error ? e.message : String(e));
|
||||
setTimeout(() => setStatus("idle"), 6000);
|
||||
}
|
||||
};
|
||||
/** Force le refresh de la grille (avec cache-buster) et de la barre de résumé. */
|
||||
const refreshGrid = useCallback(() => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set("_refresh", String(Date.now()));
|
||||
router.replace(`${pathname}?${params.toString()}`);
|
||||
router.refresh();
|
||||
}, [pathname, router, searchParams]);
|
||||
|
||||
const { status, message, start } = useQlikSyncJob({
|
||||
target: { mode: "fournisseur", fournisseur: codeFournisseur },
|
||||
onSuccess: refreshGrid,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-end">
|
||||
<button
|
||||
onClick={handleSync}
|
||||
disabled={status === "loading"}
|
||||
className="btn-action btn-action-secondary flex items-center gap-1.5 disabled:opacity-60"
|
||||
title={message || "Synchroniser les données réseau Qlik pour ce fournisseur"}
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="text-[10px] leading-tight text-right whitespace-nowrap hidden lg:block"
|
||||
style={{ color: status === "error" ? "var(--accent-error)" : "var(--text-muted)" }}
|
||||
title={message || undefined}
|
||||
>
|
||||
{status === "loading" ? (
|
||||
{status === "error"
|
||||
? message
|
||||
: status === "running"
|
||||
? (message || `Extraction Qlik…`)
|
||||
: `MAJ Qlik · ${formatLastUpdate(lastUpdate)}`}
|
||||
</span>
|
||||
<button
|
||||
onClick={start}
|
||||
disabled={status === "running"}
|
||||
className="btn-action btn-action-secondary flex items-center gap-1.5 disabled:opacity-60"
|
||||
title={message || `Synchroniser les données réseau Qlik · MAJ ${formatLastUpdate(lastUpdate)}`}
|
||||
>
|
||||
{status === "running" ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
) : status === "success" ? (
|
||||
<CheckCircle className="w-3.5 h-3.5 text-emerald-500" />
|
||||
@@ -72,11 +73,8 @@ export function SyncQlikButton({ codeFournisseur, lastUpdate }: SyncQlikButtonPr
|
||||
) : (
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
)}
|
||||
Sync Qlik
|
||||
{status === "running" ? "Sync en cours…" : "Sync Qlik"}
|
||||
</button>
|
||||
<span className="text-[10px] text-slate-500 mt-0.5">
|
||||
{status === "error" ? message : `MAJ Qlik : ${formatDate(lastUpdate)}`}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* CollectFlow — Helpers de fenêtre mensuelle et de libellés magasin.
|
||||
*
|
||||
* Extraits de `heatmap-grid.tsx` pour être partagés entre la Grille et la
|
||||
* fiche produit (`/produits`). Aucun changement de comportement.
|
||||
*
|
||||
* ⚠️ Deux conventions de clé de mois cohabitent dans l'application :
|
||||
* - séries FF Nancy (ventes / stock / réceptions) : `"YYYYMM"` (sans tiret)
|
||||
* - séries réseau Qlik (`qteByMonth`, `metricsByMonth`) : `"YYYY-MM"` (avec tiret)
|
||||
* Utiliser `ffMonthToQlik` / `qlikMonthToFf` pour passer de l'une à l'autre.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Les 12 mois complets glissants, du plus ancien au plus récent, au format
|
||||
* `"YYYYMM"`. Le mois en cours est **exclu** (on part de i=12 jusqu'à i=1).
|
||||
*
|
||||
* ⚠️ À n'appeler qu'à l'intérieur d'un `useMemo` / d'un rendu, jamais au niveau
|
||||
* module : `new Date()` évalué à l'import provoque un mismatch d'hydratation
|
||||
* entre le rendu serveur et le rendu client.
|
||||
*/
|
||||
export function getLast12Months(): string[] {
|
||||
const months: string[] = [];
|
||||
const now = new Date();
|
||||
// On commence à i=12 (il y a 12 mois) et on finit à i=1 (le mois dernier)
|
||||
// pour exclure le mois en cours (i=0)
|
||||
for (let i = 12; i >= 1; i--) {
|
||||
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||||
months.push(`${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, "0")}`);
|
||||
}
|
||||
return months;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fenêtre de 12 mois **telle que le serveur l'a calculée**, lue dans les clés des
|
||||
* séries mensuelles des lignes reçues.
|
||||
*
|
||||
* `getLast12Months()` recalcule la fenêtre depuis l'horloge du navigateur ; or
|
||||
* les totaux (`totalQuantite`, `quantiteByStore`…) sont figés côté serveur, sur
|
||||
* SA fenêtre. Dès que les deux divergent — onglet resté ouvert au changement de
|
||||
* mois, lignes servies depuis le cache, horloges décalées — un mois de ventes
|
||||
* disparaît des colonnes tout en restant compté dans le total : la case
|
||||
* « Tot. 12m » affiche 28 quand les douze cases visibles ne font que 20.
|
||||
* Les clés des données sont la seule référence commune : on les prend.
|
||||
*
|
||||
* Renvoie `null` si aucune ligne ne porte de série (fournisseur sans vente,
|
||||
* chargement en cours) — l'appelant retombe alors sur `getLast12Months()`.
|
||||
*/
|
||||
export function getMonthsFromRows(rows: ReadonlyArray<{ sales12m?: Record<string, number> }>): string[] | null {
|
||||
for (const r of rows) {
|
||||
const keys = Object.keys(r.sales12m ?? {}).filter((k) => /^\d{6}$/.test(k));
|
||||
if (keys.length < 12) continue;
|
||||
// Toutes les lignes d'un même calcul partagent la même fenêtre : la
|
||||
// première ligne complète suffit.
|
||||
return keys.sort().slice(-12);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** `"202601"` → `"Jan 26"`. */
|
||||
export function formatMonthLabel(key: string): string {
|
||||
const m = parseInt(key.slice(4, 6), 10);
|
||||
const names = ["Jan", "Fév", "Mar", "Avr", "Mai", "Jun", "Jul", "Aoû", "Sep", "Oct", "Nov", "Déc"];
|
||||
return `${names[m - 1]} ${key.slice(2, 4)}`;
|
||||
}
|
||||
|
||||
/** `"2026-01"` → `"01/26"` — axe compact des graphiques. */
|
||||
export function fmtMonthShort(lab: string): string {
|
||||
const [y, m] = lab.split("-");
|
||||
return `${m ?? lab}/${(y ?? "").slice(2)}`;
|
||||
}
|
||||
|
||||
/** Date ISO → `jj/mm/aaaa`, `"—"` si absente ou invalide. */
|
||||
export function formatDate(iso?: string | null): string {
|
||||
if (!iso) return "—";
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return "—";
|
||||
return d.toLocaleDateString("fr-FR", { day: "2-digit", month: "2-digit", year: "numeric" });
|
||||
}
|
||||
|
||||
/** Clé de mois FF `"202601"` → clé Qlik `"2026-01"`. */
|
||||
export function ffMonthToQlik(key: string): string {
|
||||
return `${key.slice(0, 4)}-${key.slice(4, 6)}`;
|
||||
}
|
||||
|
||||
/** Clé de mois Qlik `"2026-01"` → clé FF `"202601"`. */
|
||||
export function qlikMonthToFf(key: string): string {
|
||||
return key.replace("-", "");
|
||||
}
|
||||
|
||||
/** Les deux magasins FF Nancy suivis par l'application. */
|
||||
export const SITE_LABELS: Record<string, { label: string; nom: string }> = {
|
||||
"292": { label: "F", nom: "Frouard (Nancy)" },
|
||||
"579": { label: "H", nom: "Houdemont" },
|
||||
};
|
||||
|
||||
/**
|
||||
* Style de badge stable pour un magasin : la couleur est dérivée d'un hash du
|
||||
* nom, donc identique quel que soit l'ordre d'affichage.
|
||||
*/
|
||||
export function getStoreConfig(name: string) {
|
||||
const known = SITE_LABELS[name];
|
||||
const words = (known?.nom ?? name).trim().split(/\s+/);
|
||||
let label = known?.label ?? words[0].charAt(0).toUpperCase();
|
||||
if (!known && words.length > 1) {
|
||||
label += words[1].charAt(0).toUpperCase();
|
||||
}
|
||||
|
||||
let hash = 0;
|
||||
for (let i = 0; i < name.length; i++) {
|
||||
hash = name.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
const colorInt = Math.abs(hash) % 5;
|
||||
|
||||
switch (colorInt) {
|
||||
case 0: return { bg: "rgba(99, 102, 241, 0.1)", text: "#818cf8", border: "rgba(99, 102, 241, 0.2)", label }; // Indigo
|
||||
case 1: return { bg: "rgba(245, 158, 11, 0.1)", text: "#fbbf24", border: "rgba(245, 158, 11, 0.2)", label }; // Amber
|
||||
case 2: return { bg: "rgba(16, 185, 129, 0.1)", text: "#10b981", border: "rgba(16, 185, 129, 0.2)", label }; // Emerald
|
||||
case 3: return { bg: "rgba(236, 72, 153, 0.1)", text: "#ec4899", border: "rgba(236, 72, 153, 0.2)", label }; // Pink
|
||||
case 4: return { bg: "rgba(14, 165, 233, 0.1)", text: "#0ea5e9", border: "rgba(14, 165, 233, 0.2)", label }; // Sky
|
||||
default: return { bg: "var(--bg-elevated)", text: "var(--text-muted)", border: "var(--border)", label };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* CollectFlow — Tendance des ventes réseau Qlik sur 12 mois.
|
||||
*
|
||||
* Extrait de `heatmap-grid.tsx` pour être partagé entre la Grille (sparkline de
|
||||
* la colonne « Tendance / Réseau ») et la fiche produit (`/produits`).
|
||||
* Aucun changement de comportement.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Nombre de magasins du réseau La Foir'Fouille servant de dénominateur au taux
|
||||
* de présence. Constante métier : source unique pour la Grille et la fiche produit.
|
||||
*/
|
||||
export const NB_MAGASINS_RESEAU = 270;
|
||||
|
||||
export type NetworkTrend = {
|
||||
values: number[]; // quantités mensuelles, ordre chronologique
|
||||
labels: string[]; // "YYYY-MM"
|
||||
/**
|
||||
* Quantité moyenne par magasin vendeur, mois par mois. `null` si le nombre
|
||||
* de magasins n'est pas connu sur les 12 mois.
|
||||
*/
|
||||
perStore: number[] | null;
|
||||
/**
|
||||
* `true` si la tendance porte sur la qté/magasin, `false` si elle a dû se
|
||||
* rabattre sur les quantités brutes faute de nombre de magasins.
|
||||
*/
|
||||
surQteParMagasin: boolean;
|
||||
direction: "up" | "down" | "flat";
|
||||
/** Variation des 4 derniers mois vs les 4 premiers. `null` si aucune base. */
|
||||
pct: number | null;
|
||||
/** Rien sur les 4 premiers mois, des ventes sur les 4 derniers. */
|
||||
nouveau: boolean;
|
||||
hasData: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Nombre de mois comparés à chaque bout de la fenêtre.
|
||||
*
|
||||
* Quatre, et pas un : comparer le dernier mois au premier ferait dépendre toute
|
||||
* la tendance de deux points, donc du hasard d'un réassort ou d'une opération.
|
||||
*/
|
||||
export const TREND_WINDOW = 4;
|
||||
|
||||
/** Seuil de "forte" variation (±25%) pour distinguer hausse/forte hausse dans l'UI. */
|
||||
export const TREND_STRONG = 0.25;
|
||||
|
||||
/**
|
||||
* Les 12 mois complets glissants au format Qlik `"YYYY-MM"`, du plus ancien au
|
||||
* plus récent. **Le mois en cours est exclu** : il est partiel, l'intégrer
|
||||
* écraserait systématiquement la tendance vers le bas.
|
||||
*
|
||||
* Même fenêtre que `getLast12Months()` (clés FF `"YYYYMM"`) et que
|
||||
* `buildGridNetworkQlikDateFilter()` (extraction Qlik) — les trois doivent
|
||||
* rester alignés.
|
||||
*/
|
||||
export function buildRolling12QlikMonths(now: Date = new Date()): string[] {
|
||||
const months: string[] = [];
|
||||
for (let i = 12; i >= 1; i--) {
|
||||
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||||
months.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`);
|
||||
}
|
||||
return months;
|
||||
}
|
||||
|
||||
/** Seuil au-delà duquel on ne considère plus la tendance comme stable (±8%). */
|
||||
export const TREND_FLAT = 0.08;
|
||||
|
||||
export const TREND_COLOR: Record<NetworkTrend["direction"], string> = {
|
||||
up: "#22c55e",
|
||||
down: "#ef4444",
|
||||
flat: "#94a3b8",
|
||||
};
|
||||
|
||||
/**
|
||||
* Tendance réseau : variation entre les **4 premiers** et les **4 derniers**
|
||||
* mois de la fenêtre de 12 mois glissants, mois en cours exclu.
|
||||
*
|
||||
* La fenêtre est reconstruite à partir de la date du jour (et non des clés
|
||||
* présentes dans `qteByMonth`) : c'est le seul moyen de garantir une tendance
|
||||
* réellement glissante. Un mois en cours (partiel), ou plus vieux que 12 mois,
|
||||
* présent dans le cache est donc **ignoré** — sinon la pente est faussée par un
|
||||
* mois tronqué.
|
||||
*
|
||||
* Une série n'est affichée que si les **12 clés sont explicitement présentes**.
|
||||
* L'extracteur Qlik écrit lui-même les mois sans faits à 0 après avoir validé
|
||||
* la fenêtre et l'égalité avec le total Qlik. Une ancienne extraction partielle
|
||||
* ne peut donc plus être présentée comme une tendance réelle.
|
||||
*
|
||||
* Cette exigence du « tout ou rien » évite deux écueils observés en production :
|
||||
* une tendance calculée sur deux points (« +4 100 % · Forte hausse » pour un
|
||||
* article vendu depuis mai), et des mois non extraits comptés comme des mois
|
||||
* sans vente.
|
||||
*
|
||||
* ⚠️ CHANGEMENT D'INDICATEUR. La version précédente rapportait la pente d'une
|
||||
* régression linéaire à la moyenne de la série. Mathématiquement défendable,
|
||||
* mais illisible en pratique — constaté en production :
|
||||
*
|
||||
* - « −124 % », alors qu'une baisse ne peut pas dépasser −100 % ;
|
||||
* - « +508 % » sur des dizaines de lignes d'affilée, toujours la même valeur :
|
||||
* c'est la signature arithmétique d'une série nulle partout sauf le dernier
|
||||
* mois, où le rapport pente/moyenne ne dépend même plus des quantités ;
|
||||
* - « +1 062 % » pour 2 unités vendues une seule fois dans l'année.
|
||||
*
|
||||
* On compare donc désormais deux moyennes de 4 mois, ce qui donne une variation
|
||||
* qui se lit comme telle, plancher à −100 %, et reste insensible au bruit d'un
|
||||
* mois isolé. Sans base de comparaison (rien vendu sur les 4 premiers mois),
|
||||
* aucun pourcentage n'est inventé : le produit est signalé « nouveau ».
|
||||
*
|
||||
* ⚠️ LA TENDANCE PORTE SUR LA QUANTITÉ PAR MAGASIN, PAS SUR LA QUANTITÉ BRUTE.
|
||||
*
|
||||
* La quantité brute confond deux phénomènes distincts : un produit peut vendre
|
||||
* davantage simplement parce qu'il est référencé dans plus de magasins, sans
|
||||
* mieux se vendre nulle part. Inversement, un produit retiré de 60 magasins peut
|
||||
* s'effondrer en volume tout en performant mieux là où il reste.
|
||||
*
|
||||
* C'est donc `quantité / magasins vendeurs` qui dit si un produit marche — et
|
||||
* c'est sur elle que l'évolution est calculée. La courbe des quantités reste
|
||||
* affichée, mais comme contexte, pas comme indicateur.
|
||||
*
|
||||
* Repli : sans nombre de magasins sur les 12 mois, on retombe sur les quantités
|
||||
* brutes et `surQteParMagasin` vaut `false`, pour que l'UI puisse le dire.
|
||||
*
|
||||
* Direction : forte hausse >+25%, hausse >+8%, stable, baisse <−8%, forte baisse <−25%.
|
||||
*/
|
||||
export function computeNetworkTrend(
|
||||
qteByMonth?: Record<string, number> | null,
|
||||
nbMagByMonth?: Record<string, number> | null,
|
||||
now: Date = new Date(),
|
||||
): NetworkTrend {
|
||||
const empty: NetworkTrend = {
|
||||
values: [], labels: [], perStore: null, surQteParMagasin: false,
|
||||
direction: "flat", pct: null, nouveau: false, hasData: false,
|
||||
};
|
||||
if (!qteByMonth) return empty;
|
||||
const labels = buildRolling12QlikMonths(now);
|
||||
const complet = labels.every((label) =>
|
||||
Object.prototype.hasOwnProperty.call(qteByMonth, label) &&
|
||||
Number.isFinite(Number(qteByMonth[label])),
|
||||
);
|
||||
if (!complet) return empty;
|
||||
|
||||
const values = labels.map((l) => Number(qteByMonth[l]));
|
||||
const n = values.length;
|
||||
|
||||
// Quantité par magasin vendeur : l'indicateur qui isole la performance du
|
||||
// produit de sa diffusion. Un mois sans magasin vendeur vaut 0 — c'est le
|
||||
// seul choix cohérent avec une quantité elle-même nulle.
|
||||
const magasins = computeStoresSeries(nbMagByMonth, now);
|
||||
const perStore = magasins
|
||||
? values.map((v, i) => (magasins.values[i] > 0 ? v / magasins.values[i] : 0))
|
||||
: null;
|
||||
const serie = perStore ?? values;
|
||||
|
||||
const taille = Math.min(TREND_WINDOW, Math.floor(n / 2));
|
||||
const moyenne = (xs: number[]) => (xs.length ? xs.reduce((s, v) => s + v, 0) / xs.length : 0);
|
||||
const debut = moyenne(serie.slice(0, taille));
|
||||
const fin = moyenne(serie.slice(n - taille));
|
||||
|
||||
let pct: number | null = null;
|
||||
let nouveau = false;
|
||||
let direction: NetworkTrend["direction"] = "flat";
|
||||
|
||||
if (debut > 0) {
|
||||
// Plancher naturel à −100 % : `fin` ne peut pas descendre sous zéro sans
|
||||
// retours massifs, et la division par une base positive garde le sens.
|
||||
pct = (fin - debut) / debut;
|
||||
direction = pct > TREND_FLAT ? "up" : pct < -TREND_FLAT ? "down" : "flat";
|
||||
} else if (fin > 0) {
|
||||
// Rien au départ, des ventes à l'arrivée : la variation relative n'existe
|
||||
// pas (division par zéro). On le dit, au lieu d'afficher un nombre inventé.
|
||||
nouveau = true;
|
||||
direction = "up";
|
||||
}
|
||||
|
||||
return { values, labels, perStore, surQteParMagasin: perStore != null, direction, pct, nouveau, hasData: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Série « nombre de magasins vendeurs » sur la MÊME fenêtre que
|
||||
* `computeNetworkTrend`, pour la superposer à la courbe des quantités.
|
||||
*
|
||||
* Pourquoi cette deuxième courbe : une quantité qui monte parce que le produit
|
||||
* est diffusé dans plus de magasins ne raconte pas la même histoire qu'une
|
||||
* quantité qui monte à diffusion constante. Sans elle, la tendance seule ne
|
||||
* permet pas de distinguer un succès produit d'un simple élargissement.
|
||||
*
|
||||
* Même règle du tout ou rien que la courbe des quantités : les 12 clés doivent
|
||||
* être explicitement présentes, sinon `null` — un mois non extrait ne doit pas
|
||||
* se lire comme « zéro magasin », ce qui simulerait un arrêt de diffusion.
|
||||
*/
|
||||
export function computeStoresSeries(
|
||||
nbMagByMonth?: Record<string, number> | null,
|
||||
now: Date = new Date(),
|
||||
): { values: number[]; labels: string[] } | null {
|
||||
if (!nbMagByMonth) return null;
|
||||
const labels = buildRolling12QlikMonths(now);
|
||||
const complet = labels.every((label) =>
|
||||
Object.prototype.hasOwnProperty.call(nbMagByMonth, label) &&
|
||||
Number.isFinite(Number(nbMagByMonth[label])),
|
||||
);
|
||||
if (!complet) return null;
|
||||
return { values: labels.map((l) => Number(nbMagByMonth[l])), labels };
|
||||
}
|
||||
|
||||
/** Libellé français de la tendance : « Forte hausse », « Baisse », « Stable »… */
|
||||
export function trendLabel(pct: number | null, nouveau = false): string {
|
||||
// Un produit sans base de comparaison n'est pas « stable » : il est nouveau.
|
||||
if (nouveau) return "Nouveau";
|
||||
if (pct == null) return "Stable";
|
||||
if (pct > TREND_STRONG) return "Forte hausse";
|
||||
if (pct > TREND_FLAT) return "Hausse";
|
||||
if (pct < -TREND_STRONG) return "Forte baisse";
|
||||
if (pct < -TREND_FLAT) return "Baisse";
|
||||
return "Stable";
|
||||
}
|
||||
@@ -20,6 +20,19 @@ interface GridState {
|
||||
columnVisibility: Record<string, boolean>;
|
||||
/** Persisted column sizing state */
|
||||
columnSizing: Record<string, number>;
|
||||
/**
|
||||
* Bloc des 12 colonnes mensuelles affiché ou non dans la Grille.
|
||||
*
|
||||
* Séparé de `columnVisibility` : masquer douze colonnes une par une n'est pas
|
||||
* une manipulation, et l'état « bloc replié » doit survivre au renouvellement
|
||||
* de la fenêtre glissante — les identifiants `month_YYYYMM` changent chaque
|
||||
* mois, un drapeau ne change pas.
|
||||
*/
|
||||
showMonthlySales: boolean;
|
||||
setShowMonthlySales: (visible: boolean) => void;
|
||||
/** Bloc des deux colonnes de prix (PV moyen réseau, PV central) affiché ou non. */
|
||||
showPrices: boolean;
|
||||
setShowPrices: (visible: boolean) => void;
|
||||
|
||||
/** Currently displayed store: "TOTAL" | "292" | "579" */
|
||||
activeMagasin: string;
|
||||
@@ -33,7 +46,9 @@ interface GridState {
|
||||
clearDrafts: (codeins: string[]) => void;
|
||||
/** Apply saved drafts onto row.codeGamme so change indicators persist after save */
|
||||
applyDraftsToRows: (draftsToApply: Record<string, GammeCode>) => void;
|
||||
setFilter: (key: keyof GridFilters, value: string | null) => void;
|
||||
setFilter: (key: Exclude<keyof GridFilters, "code3">, value: string | null) => void;
|
||||
/** Nomenclatures retenues. `null` = aucun filtre, `[]` = rien de coché. */
|
||||
setCode3Filter: (codes: string[] | null) => void;
|
||||
setDisplayDensity: (density: "compact" | "normal" | "comfortable") => void;
|
||||
setActiveGridQuery: (query: string) => void;
|
||||
restoreSnapshot: (changes: Record<string, GammeCode>) => void;
|
||||
@@ -70,6 +85,12 @@ function computeSummary(rows: ProductRow[], drafts: Record<string, GammeCode>, m
|
||||
};
|
||||
}
|
||||
|
||||
/** Sous-ensemble réellement écrit dans le navigateur (cf. `partialize`). */
|
||||
type GridPersisted = Pick<
|
||||
GridState,
|
||||
"filters" | "displayDensity" | "draftChanges" | "activeGridQuery" | "columnVisibility" | "columnSizing" | "showMonthlySales" | "showPrices"
|
||||
>;
|
||||
|
||||
export const useGridStore = create<GridState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
@@ -98,6 +119,11 @@ export const useGridStore = create<GridState>()(
|
||||
activeGridQuery: "",
|
||||
columnVisibility: {},
|
||||
columnSizing: {},
|
||||
showMonthlySales: true,
|
||||
showPrices: true,
|
||||
|
||||
setShowMonthlySales: (visible) => set({ showMonthlySales: visible }),
|
||||
setShowPrices: (visible) => set({ showPrices: visible }),
|
||||
|
||||
setActiveMagasin: (code) => {
|
||||
set({ activeMagasin: code });
|
||||
@@ -172,6 +198,9 @@ export const useGridStore = create<GridState>()(
|
||||
setFilter: (key, value) => {
|
||||
set((state) => ({ ...state, filters: { ...state.filters, [key]: value } }));
|
||||
},
|
||||
setCode3Filter: (codes) => {
|
||||
set((state) => ({ ...state, filters: { ...state.filters, code3: codes } }));
|
||||
},
|
||||
setDisplayDensity: (density) => set({ displayDensity: density }),
|
||||
setActiveGridQuery: (query) => set({ activeGridQuery: query }),
|
||||
restoreSnapshot: (changes) => {
|
||||
@@ -214,14 +243,37 @@ export const useGridStore = create<GridState>()(
|
||||
}),
|
||||
{
|
||||
name: "collectflow-grid-storage",
|
||||
/**
|
||||
* v1 — `filters.code3` est passé d'une chaîne unique à `string[] | null`
|
||||
* (filtre multi-nomenclatures). Sans migration, la valeur déjà présente
|
||||
* dans le navigateur restait une chaîne : `new Set("320211")` produit un
|
||||
* ensemble de caractères, plus aucune ligne ne correspond, et la Grille
|
||||
* apparaît vide. Le numéro de version force la reprise de l'existant.
|
||||
*/
|
||||
version: 1,
|
||||
migrate: (persisted: unknown, version: number): GridPersisted => {
|
||||
const etat = (persisted ?? {}) as GridPersisted & { filters?: Record<string, unknown> };
|
||||
if (version < 1 && etat.filters) {
|
||||
const c = etat.filters.code3;
|
||||
etat.filters.code3 = typeof c === "string" && c !== "" ? [c] : Array.isArray(c) ? c : null;
|
||||
}
|
||||
// Drapeaux ajoutés après coup : un état persisté antérieur n'en a
|
||||
// pas, et une valeur `undefined` écraserait la valeur par défaut
|
||||
// à la fusion (le bloc disparaîtrait sans raison).
|
||||
if (typeof etat.showMonthlySales !== "boolean") etat.showMonthlySales = true;
|
||||
if (typeof etat.showPrices !== "boolean") etat.showPrices = true;
|
||||
return etat;
|
||||
},
|
||||
// Only persist filters, display density, drafts, active grid query, and column visibility/sizing
|
||||
partialize: (state) => ({
|
||||
partialize: (state): GridPersisted => ({
|
||||
filters: state.filters,
|
||||
displayDensity: state.displayDensity,
|
||||
draftChanges: state.draftChanges,
|
||||
activeGridQuery: state.activeGridQuery,
|
||||
columnVisibility: state.columnVisibility,
|
||||
columnSizing: state.columnSizing,
|
||||
showMonthlySales: state.showMonthlySales,
|
||||
showPrices: state.showPrices,
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
import "server-only";
|
||||
|
||||
import {
|
||||
pgGetProduitDetail,
|
||||
pgGetCodeinByCodeCentrale,
|
||||
pgGetFournisseursByCodein,
|
||||
pgGetStockByCodein,
|
||||
pgGetCommandesByCodein,
|
||||
pgGetGammeHistoryByCodein,
|
||||
pgGetMensuelByCodein,
|
||||
} from "@/lib/pg-ff-client";
|
||||
import { buildLast12MonthsRange } from "@/lib/api-ff-client";
|
||||
import { getNetworkMetricsByCodeCentrale } from "@/lib/qlik-network-cache";
|
||||
import type { ProduitFiche, ProduitMois, ProduitTotaux } from "@/features/produits/types";
|
||||
|
||||
/** Les 12 clés de mois "YYYYMM" glissantes, mois courant exclu. */
|
||||
function buildMonthKeys(): string[] {
|
||||
const months: string[] = [];
|
||||
const now = new Date();
|
||||
for (let i = 12; i >= 1; i--) {
|
||||
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||||
months.push(`${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, "0")}`);
|
||||
}
|
||||
return months;
|
||||
}
|
||||
|
||||
function emptyMois(mois: string): ProduitMois {
|
||||
return { mois, qte: 0, ca: 0, marge: 0, stockFinMois: 0, qteRecue: 0 };
|
||||
}
|
||||
|
||||
function totauxFrom(rows: ProduitMois[]): ProduitTotaux {
|
||||
const qte = rows.reduce((s, r) => s + r.qte, 0);
|
||||
const ca = rows.reduce((s, r) => s + r.ca, 0);
|
||||
const marge = rows.reduce((s, r) => s + r.marge, 0);
|
||||
return { qte, ca, marge, tauxMarge: ca > 0 ? (marge / ca) * 100 : 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble la fiche d'un produit **à partir de son code centrale** : c'est la
|
||||
* clé du réseau Qlik, seule identité disponible pour un produit que le réseau
|
||||
* travaille mais que Nancy ne référence pas.
|
||||
*
|
||||
* Si le catalogue Nancy connaît ce code centrale, on rattache toute la partie
|
||||
* locale (identité, fournisseurs, stock, gammes, 12 mois glissants de ventes).
|
||||
* Sinon la fiche reste purement réseau, ce qui est un cas parfaitement normal
|
||||
* depuis la recherche Qlik.
|
||||
*
|
||||
* Renvoie `null` uniquement si ni Qlik ni le catalogue ne connaissent le code.
|
||||
*/
|
||||
export async function getProduitFicheByCodeCentrale(codeCentrale: string): Promise<ProduitFiche | null> {
|
||||
const cleaned = codeCentrale.trim();
|
||||
if (!cleaned) return null;
|
||||
|
||||
const codein = await pgGetCodeinByCodeCentrale(cleaned);
|
||||
if (codein) return getProduitFiche(codein);
|
||||
|
||||
// Produit réseau pur : aucune donnée locale à assembler.
|
||||
const map = await getNetworkMetricsByCodeCentrale([cleaned]);
|
||||
const reseau = map.get(cleaned) ?? null;
|
||||
if (!reseau) return null;
|
||||
|
||||
const { dateDebut, dateFin } = buildLast12MonthsRange();
|
||||
const months = buildMonthKeys();
|
||||
const mensuelTotal: Record<string, ProduitMois> = {};
|
||||
for (const m of months) mensuelTotal[m] = emptyMois(m);
|
||||
|
||||
return {
|
||||
detail: null,
|
||||
codeCentrale: cleaned,
|
||||
libelleReseau: reseau.libelleReseau ?? "",
|
||||
fournisseurReseau: reseau.fournisseurReseau ?? "",
|
||||
fournisseurs: [],
|
||||
stock: [],
|
||||
commandesEnCours: 0,
|
||||
gammes: [],
|
||||
months,
|
||||
mensuelTotal,
|
||||
mensuelParSite: {},
|
||||
totaux: totauxFrom(months.map((m) => mensuelTotal[m])),
|
||||
totauxParSite: {},
|
||||
sitesActifs: [],
|
||||
reseau,
|
||||
periode: { dateDebut, dateFin },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble la fiche complète d'un produit : identité, fournisseurs, stock,
|
||||
* gammes, 12 mois glissants de ventes (total et par magasin) et métriques
|
||||
* réseau Qlik.
|
||||
*
|
||||
* Renvoie `null` si le codein est inconnu du catalogue FF.
|
||||
*/
|
||||
export async function getProduitFiche(codein: string): Promise<ProduitFiche | null> {
|
||||
const cleaned = codein.trim();
|
||||
if (!cleaned) return null;
|
||||
|
||||
const detail = await pgGetProduitDetail(cleaned);
|
||||
if (!detail) return null;
|
||||
|
||||
const { dateDebut, dateFin } = buildLast12MonthsRange();
|
||||
|
||||
const [fournisseurs, stock, commandesEnCours, gammes, mensuelRows] = await Promise.all([
|
||||
pgGetFournisseursByCodein(cleaned),
|
||||
pgGetStockByCodein(cleaned),
|
||||
pgGetCommandesByCodein(cleaned),
|
||||
pgGetGammeHistoryByCodein(cleaned),
|
||||
pgGetMensuelByCodein(cleaned, dateDebut, dateFin),
|
||||
]);
|
||||
|
||||
const months = buildMonthKeys();
|
||||
const allowed = new Set(months);
|
||||
|
||||
const mensuelTotal: Record<string, ProduitMois> = {};
|
||||
const mensuelParSite: Record<string, Record<string, ProduitMois>> = {};
|
||||
for (const m of months) mensuelTotal[m] = emptyMois(m);
|
||||
|
||||
// Mois réellement présents dans les mouvements. Indispensable pour distinguer
|
||||
// « pas de mouvement ce mois-là » (→ report du stock précédent) de « stock
|
||||
// effectivement nul » — c'est la convention de la Grille.
|
||||
const moisAvecDonnees = new Set<string>();
|
||||
const moisAvecDonneesParSite = new Map<string, Set<string>>();
|
||||
|
||||
for (const row of mensuelRows) {
|
||||
// SQL renvoie "YYYY-MM", la timeline de l'app utilise "YYYYMM".
|
||||
const mois = row.mois.replace("-", "");
|
||||
if (!allowed.has(mois)) continue;
|
||||
const site = String(row.site).trim();
|
||||
|
||||
const qte = Number(row.qte_vendue) || 0;
|
||||
const ca = Number(row.ca_ht) || 0;
|
||||
const marge = Number(row.marge) || 0;
|
||||
const stockFinMois = Number(row.stock_fin_mois) || 0;
|
||||
const qteRecue = Number(row.qte_recue) || 0;
|
||||
|
||||
moisAvecDonnees.add(mois);
|
||||
const tot = mensuelTotal[mois];
|
||||
tot.qte += qte;
|
||||
tot.ca += ca;
|
||||
tot.marge += marge;
|
||||
tot.stockFinMois += stockFinMois;
|
||||
tot.qteRecue += qteRecue;
|
||||
|
||||
mensuelParSite[site] ??= Object.fromEntries(months.map((m) => [m, emptyMois(m)]));
|
||||
if (!moisAvecDonneesParSite.has(site)) moisAvecDonneesParSite.set(site, new Set());
|
||||
moisAvecDonneesParSite.get(site)!.add(mois);
|
||||
const cell = mensuelParSite[site][mois];
|
||||
cell.qte += qte;
|
||||
cell.ca += ca;
|
||||
cell.marge += marge;
|
||||
// Le SQL donne déjà le stock du dernier mouvement du mois pour ce site.
|
||||
cell.stockFinMois = stockFinMois;
|
||||
cell.qteRecue += qteRecue;
|
||||
}
|
||||
|
||||
// Report du stock de fin de mois sur les mois SANS mouvement (carry-forward),
|
||||
// même convention que la Grille : un mois sans mouvement conserve le stock du
|
||||
// mois précédent, alors qu'un mois avec mouvements et un stock nul reste à 0.
|
||||
const carryForward = (serie: Record<string, ProduitMois>, avecDonnees: Set<string>) => {
|
||||
let last = 0;
|
||||
for (const m of months) {
|
||||
if (avecDonnees.has(m)) last = serie[m].stockFinMois;
|
||||
else serie[m].stockFinMois = last;
|
||||
}
|
||||
};
|
||||
carryForward(mensuelTotal, moisAvecDonnees);
|
||||
for (const [site, bySite] of Object.entries(mensuelParSite)) {
|
||||
carryForward(bySite, moisAvecDonneesParSite.get(site) ?? new Set());
|
||||
}
|
||||
|
||||
const totaux = totauxFrom(months.map((m) => mensuelTotal[m]));
|
||||
const totauxParSite: Record<string, ProduitTotaux> = {};
|
||||
for (const [site, bySite] of Object.entries(mensuelParSite)) {
|
||||
totauxParSite[site] = totauxFrom(months.map((m) => bySite[m]));
|
||||
}
|
||||
|
||||
const sitesActifs = Object.entries(totauxParSite)
|
||||
.filter(([, t]) => t.qte > 0)
|
||||
.map(([site]) => site)
|
||||
.sort();
|
||||
|
||||
// Métriques réseau Qlik (jointure par code centrale).
|
||||
let reseau = null;
|
||||
if (detail.code_centrale) {
|
||||
const map = await getNetworkMetricsByCodeCentrale([detail.code_centrale]);
|
||||
reseau = map.get(detail.code_centrale) ?? null;
|
||||
}
|
||||
|
||||
return {
|
||||
detail,
|
||||
codeCentrale: detail.code_centrale ?? "",
|
||||
libelleReseau: reseau?.libelleReseau ?? "",
|
||||
fournisseurReseau: reseau?.fournisseurReseau ?? "",
|
||||
fournisseurs,
|
||||
stock,
|
||||
commandesEnCours,
|
||||
gammes,
|
||||
months,
|
||||
mensuelTotal,
|
||||
mensuelParSite,
|
||||
totaux,
|
||||
totauxParSite,
|
||||
sitesActifs,
|
||||
reseau,
|
||||
periode: { dateDebut, dateFin },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,445 @@
|
||||
import "server-only";
|
||||
|
||||
import { searchQlikArticles, QLIK_SEARCH_MAX_RESULTS } from "@/lib/qlik-search";
|
||||
import type { NetworkMetric } from "@/lib/qlik-client";
|
||||
import {
|
||||
getNetworkMetricsByCodeCentrale,
|
||||
upsertNetworkMetrics,
|
||||
type NetworkMetricCached,
|
||||
} from "@/lib/qlik-network-cache";
|
||||
import { pgGetProduitsByCodeCentrale, pgSearchProduits, type PgProduitSearchRow } from "@/lib/pg-ff-client";
|
||||
import { buildRolling12QlikMonths, NB_MAGASINS_RESEAU } from "@/features/grid/lib/network-trend";
|
||||
import { normalizeMargePct } from "@/features/produits/lib/compare-reseau";
|
||||
import type { ProduitRechercheResultat, ProduitRechercheRow } from "@/features/produits/types";
|
||||
|
||||
/**
|
||||
* CollectFlow — Recherche produit **Qlik d'abord**, base FF Nancy ensuite.
|
||||
*
|
||||
* Déroulé :
|
||||
* 1. Qlik Sense, **une seule session** : les articles correspondant au terme ET
|
||||
* leurs mesures réseau sur 12 mois glissants (`searchQlikArticles`).
|
||||
* 2. Base FF Nancy : lesquels référençons-nous ? (`pgGetProduitsByCodeCentrale`)
|
||||
* 3. Cache `qlik_network_metrics` : détail mensuel quand une sync l'a déjà
|
||||
* extrait (le cube de recherche est agrégé, il ne le fournit pas).
|
||||
*
|
||||
* Une seconde session Qlik pour les mesures faisait abandonner le moteur
|
||||
* (`code 15 — Request aborted`) : deux sessions concurrentes sur la même app,
|
||||
* dont une avec une grosse sélection, et l'Engine coupe les requêtes.
|
||||
*
|
||||
* Si Qlik est injoignable, on se rabat sur la recherche catalogue local pour ne
|
||||
* pas laisser l'utilisateur sans résultat — la réponse le signale explicitement
|
||||
* (`source: "db"`).
|
||||
*/
|
||||
|
||||
/** Durée de validité d'un résultat de recherche en cache mémoire. */
|
||||
const CACHE_TTL_MS = 10 * 60 * 1000;
|
||||
|
||||
/** Durée de conservation d'un job terminé (le client a le temps de le relire). */
|
||||
const JOB_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Série « magasins vendeurs » extraite du détail mensuel du cache.
|
||||
*
|
||||
* Un mois PRÉSENT dans le détail est un mois extrait : sans `nbMag`, c'est
|
||||
* qu'aucun magasin n'a vendu, donc zéro. Le traiter comme manquant amputerait la
|
||||
* série et priverait la tendance de son dénominateur.
|
||||
*/
|
||||
function nbMagParMois(
|
||||
metricsByMonth: Record<string, { nbMag?: number }> | null | undefined,
|
||||
): Record<string, number> | null {
|
||||
if (!metricsByMonth) return null;
|
||||
const parMois: Record<string, number> = {};
|
||||
for (const mois of Object.keys(metricsByMonth)) {
|
||||
const nb = Number(metricsByMonth[mois]?.nbMag);
|
||||
parMois[mois] = Number.isFinite(nb) ? nb : 0;
|
||||
}
|
||||
return Object.keys(parMois).length > 0 ? parMois : null;
|
||||
}
|
||||
|
||||
/** Un cache réseau n'est fiable que s'il porte explicitement les 12 mois attendus. */
|
||||
function cacheReseauComplet(
|
||||
cache: NetworkMetricCached | undefined,
|
||||
periode: string | null,
|
||||
now: Date = new Date(),
|
||||
): cache is NetworkMetricCached {
|
||||
const parMois = cache?.qteByMonth;
|
||||
if (!parMois || !periode || cache.periode !== periode) return false;
|
||||
return buildRolling12QlikMonths(now).every((mois) =>
|
||||
Object.prototype.hasOwnProperty.call(parMois, mois) &&
|
||||
Number.isFinite(Number(parMois[mois])),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Nombre maximum de recherches Qlik simultanées.
|
||||
*
|
||||
* Le serveur Qlik sature vite (« Out of memory », « Request aborted ») : on
|
||||
* plafonne plutôt que de lui envoyer N extractions en parallèle.
|
||||
*/
|
||||
const MAX_RECHERCHES_SIMULTANEES = 1;
|
||||
|
||||
/**
|
||||
* Cache mémoire des recherches. Une recherche coûte deux allers-retours Qlik
|
||||
* (plusieurs secondes chacun) : sans ce cache, un retour arrière du navigateur
|
||||
* ou un rafraîchissement relancerait toute l'extraction.
|
||||
*/
|
||||
const cache = new Map<string, { at: number; result: ProduitRechercheResultat }>();
|
||||
|
||||
/**
|
||||
* Jobs de recherche, indexés par terme normalisé.
|
||||
*
|
||||
* Une recherche dure de quelques secondes à plusieurs minutes (deux allers-retours
|
||||
* Qlik dont une extraction mensuelle). Une requête HTTP maintenue aussi longtemps
|
||||
* se fait couper par le reverse proxy, qui répond une page d'erreur HTML — le
|
||||
* client recevait alors « Unexpected token '<' … is not valid JSON ». Le travail
|
||||
* tourne donc en tâche de fond et le client interroge l'état.
|
||||
*/
|
||||
const jobs = new Map<string, RechercheJob>();
|
||||
|
||||
export interface RechercheJob {
|
||||
jobId: string;
|
||||
terme: string;
|
||||
status: "running" | "success" | "error";
|
||||
/** Étape en cours, affichée pendant l'attente. */
|
||||
etape: string;
|
||||
startedAt: string;
|
||||
finishedAt?: string;
|
||||
error?: string;
|
||||
result?: ProduitRechercheResultat;
|
||||
}
|
||||
|
||||
/** État renvoyé au client : le job, ou `idle` si aucune recherche connue. */
|
||||
export type RechercheEtat = RechercheJob | { status: "idle"; terme: string };
|
||||
|
||||
function cacheKey(term: string): string {
|
||||
return term.trim().toLowerCase().replace(/\s+/g, " ");
|
||||
}
|
||||
|
||||
/** Purge les entrées expirées (le volume reste faible, un balayage suffit). */
|
||||
function purge(): void {
|
||||
const now = Date.now();
|
||||
for (const [k, v] of cache) {
|
||||
if (now - v.at > CACHE_TTL_MS) cache.delete(k);
|
||||
}
|
||||
for (const [k, j] of jobs) {
|
||||
if (j.status !== "running" && j.finishedAt && now - Date.parse(j.finishedAt) > JOB_TTL_MS) jobs.delete(k);
|
||||
}
|
||||
}
|
||||
|
||||
function jobsEnCours(): number {
|
||||
let n = 0;
|
||||
for (const j of jobs.values()) if (j.status === "running") n++;
|
||||
return n;
|
||||
}
|
||||
|
||||
function jobTermine(terme: string, result: ProduitRechercheResultat): RechercheJob {
|
||||
return {
|
||||
jobId: `cache_${cacheKey(terme)}`,
|
||||
terme,
|
||||
status: "success",
|
||||
etape: "Terminé",
|
||||
startedAt: new Date().toISOString(),
|
||||
finishedAt: new Date().toISOString(),
|
||||
result,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Démarre (ou réutilise) une recherche et rend la main **immédiatement**.
|
||||
*
|
||||
* - résultat déjà en cache et pas de `force` → job `success` directement ;
|
||||
* - recherche déjà en cours sur le même terme → on renvoie ce job ;
|
||||
* - sinon on lance le travail en tâche de fond.
|
||||
*/
|
||||
export function demarrerRecherche(
|
||||
term: string,
|
||||
options: { force?: boolean } = {},
|
||||
): RechercheJob {
|
||||
const cleaned = term.trim();
|
||||
const key = cacheKey(cleaned);
|
||||
purge();
|
||||
|
||||
if (!options.force) {
|
||||
const hit = cache.get(key);
|
||||
if (hit) {
|
||||
console.log(`[produits/search] "${cleaned}" — servi depuis le cache mémoire`);
|
||||
return jobTermine(cleaned, hit.result);
|
||||
}
|
||||
}
|
||||
|
||||
const existant = jobs.get(key);
|
||||
if (existant && existant.status === "running") return existant;
|
||||
if (existant && !options.force && existant.status === "success" && existant.result) return existant;
|
||||
|
||||
if (jobsEnCours() >= MAX_RECHERCHES_SIMULTANEES) {
|
||||
return {
|
||||
jobId: `refus_${Date.now().toString(36)}`,
|
||||
terme: cleaned,
|
||||
status: "error",
|
||||
etape: "Refusé",
|
||||
startedAt: new Date().toISOString(),
|
||||
finishedAt: new Date().toISOString(),
|
||||
error: "Trop de recherches Qlik en cours. Réessayez dans quelques instants.",
|
||||
};
|
||||
}
|
||||
|
||||
const job: RechercheJob = {
|
||||
jobId: `rech_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
|
||||
terme: cleaned,
|
||||
status: "running",
|
||||
etape: "Recherche des articles dans Qlik…",
|
||||
startedAt: new Date().toISOString(),
|
||||
};
|
||||
jobs.set(key, job);
|
||||
console.log(`[produits/search] job=${job.jobId} démarré pour "${cleaned}"`);
|
||||
|
||||
// Fire-and-forget : le client suivra l'avancement via `etatRecherche`.
|
||||
void executerRecherche(cleaned, (etape) => { job.etape = etape; })
|
||||
.then((result) => {
|
||||
job.result = result;
|
||||
job.status = "success";
|
||||
job.etape = "Terminé";
|
||||
job.finishedAt = new Date().toISOString();
|
||||
// On ne met en cache qu'un résultat Qlik exploitable : mémoriser un
|
||||
// repli catalogue (panne Qlik passagère) le figerait 10 minutes
|
||||
// alors qu'un simple « Relancer » aurait suffi.
|
||||
if (result.source === "qlik" && !result.qlikError) {
|
||||
cache.set(key, { at: Date.now(), result });
|
||||
}
|
||||
console.log(`[produits/search] job=${job.jobId} terminé — ${result.rows.length} produit(s) en ${result.dureeMs} ms`);
|
||||
})
|
||||
.catch((e) => {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
job.status = "error";
|
||||
job.etape = "Échec";
|
||||
job.error = messageQlikLisible(message);
|
||||
job.finishedAt = new Date().toISOString();
|
||||
console.error(`[produits/search] job=${job.jobId} échec :`, message.slice(0, 300));
|
||||
});
|
||||
|
||||
return job;
|
||||
}
|
||||
|
||||
/** État courant de la recherche pour ce terme (pour le polling client). */
|
||||
export function etatRecherche(term: string): RechercheEtat {
|
||||
const cleaned = term.trim();
|
||||
const key = cacheKey(cleaned);
|
||||
purge();
|
||||
const job = jobs.get(key);
|
||||
if (job) return job;
|
||||
const hit = cache.get(key);
|
||||
if (hit) return jobTermine(cleaned, hit.result);
|
||||
return { status: "idle", terme: cleaned };
|
||||
}
|
||||
|
||||
async function executerRecherche(
|
||||
term: string,
|
||||
onEtape: (etape: string) => void = () => {},
|
||||
): Promise<ProduitRechercheResultat> {
|
||||
const started = Date.now();
|
||||
|
||||
// La recherche catalogue local tourne en parallèle : elle sert à la fois de
|
||||
// repli si Qlik échoue, et de complément (produits Nancy sans code centrale,
|
||||
// donc invisibles côté réseau).
|
||||
const localPromise = pgSearchProduits(term).catch((e) => {
|
||||
console.error("[produits/search] recherche locale échouée:", (e as Error).message);
|
||||
return [] as PgProduitSearchRow[];
|
||||
});
|
||||
|
||||
let qlik;
|
||||
try {
|
||||
onEtape("Recherche des articles dans Qlik…");
|
||||
qlik = await searchQlikArticles(term, QLIK_SEARCH_MAX_RESULTS);
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
console.error("[produits/search] Qlik indisponible, repli catalogue local:", message);
|
||||
const locaux = await localPromise;
|
||||
return {
|
||||
rows: locaux.map(rowDepuisCatalogueSeul),
|
||||
source: "db",
|
||||
qlikError: messageQlikLisible(message),
|
||||
tronque: locaux.length >= 50,
|
||||
champUtilise: null,
|
||||
locauxHorsReseau: [],
|
||||
dureeMs: Date.now() - started,
|
||||
};
|
||||
}
|
||||
|
||||
const codes = qlik.matches.map((m) => m.codeCentrale);
|
||||
if (codes.length === 0) {
|
||||
const locaux = await localPromise;
|
||||
return {
|
||||
rows: [],
|
||||
source: "qlik",
|
||||
// `avertissement` n'est renseigné que si Qlik a répondu sans appliquer
|
||||
// le filtre : l'utilisateur doit savoir que ce n'est pas « pas de
|
||||
// produit » mais « recherche non appliquée ».
|
||||
qlikError: qlik.avertissement,
|
||||
tronque: false,
|
||||
champUtilise: qlik.champUtilise,
|
||||
locauxHorsReseau: locaux,
|
||||
dureeMs: Date.now() - started,
|
||||
};
|
||||
}
|
||||
|
||||
// Les mesures réseau sortent DÉJÀ du cube de recherche (même session Qlik).
|
||||
// Les extraire dans une seconde session concurrente faisait abandonner le
|
||||
// moteur (`code 15 — Request aborted`) : c'est ce qui faisait « planter » la
|
||||
// recherche alors que la sync de la Grille, elle seule sur le serveur,
|
||||
// fonctionnait.
|
||||
//
|
||||
// Reste à récupérer le détail mensuel — indisponible dans ce cube. On le lit
|
||||
// dans le cache alimenté par les syncs fournisseur ; s'il manque, la fiche
|
||||
// produit propose son bouton « Actualiser depuis Qlik ».
|
||||
onEtape("Rapprochement avec notre catalogue…");
|
||||
const [cacheReseau, catalogue, locaux] = await Promise.all([
|
||||
getNetworkMetricsByCodeCentrale(codes).catch((e) => {
|
||||
console.error("[produits/search] lecture du cache réseau échouée:", (e as Error).message);
|
||||
return new Map<string, NetworkMetricCached>();
|
||||
}),
|
||||
// Deux clés candidates : la dimension « Article Code » et le champ
|
||||
// `article_no_centrale`. Sur l'app FF elles n'ont pas le même format et
|
||||
// une seule joint avec `articles.artcentrale`.
|
||||
pgGetProduitsByCodeCentrale([...codes, ...qlik.matches.map((m) => m.codeCentraleAlt).filter(Boolean)]),
|
||||
localPromise,
|
||||
]);
|
||||
|
||||
// Le cube de recherche utilise encore les master measures annuelles pour
|
||||
// classer rapidement les résultats. Il ne doit jamais écraser une extraction
|
||||
// 12 mois déjà vérifiée : dans ce cas on republie les valeurs du cache avec
|
||||
// leur mensuel. Sans cache complet, les valeurs de découverte restent
|
||||
// temporaires jusqu'au bouton « Actualiser depuis Qlik ».
|
||||
try {
|
||||
const aPersister: NetworkMetric[] = qlik.matches.map((m) => {
|
||||
const cache = cacheReseau.get(m.codeCentrale);
|
||||
const fiable = cacheReseauComplet(cache, qlik.periode);
|
||||
return {
|
||||
codeCentrale: m.codeCentrale,
|
||||
caReseau: fiable ? cache.caReseau : m.caReseau,
|
||||
qteReseau: fiable ? cache.qteReseau : m.qteReseau,
|
||||
nbMagasinsReseau: fiable ? cache.nbMagasinsReseau : m.nbMagasinsReseau,
|
||||
caParMagasinReseau: fiable ? cache.caParMagasinReseau : m.caParMagasinReseau,
|
||||
margePctReseau: fiable ? cache.margePctReseau : m.margePctReseau,
|
||||
periode: fiable ? cache.periode ?? undefined : qlik.periode ?? undefined,
|
||||
qteByMonth: fiable ? cache.qteByMonth ?? undefined : undefined,
|
||||
metricsByMonth: fiable ? cache.metricsByMonth ?? undefined : undefined,
|
||||
libelleReseau: m.libelle || undefined,
|
||||
fournisseurReseau: m.fournisseur || undefined,
|
||||
};
|
||||
});
|
||||
const n = await upsertNetworkMetrics(aPersister);
|
||||
console.log(`[produits/search] ${n} ligne(s) réseau mises en cache`);
|
||||
} catch (e) {
|
||||
console.error("[produits/search] mise en cache des mesures échouée:", (e as Error).message);
|
||||
}
|
||||
|
||||
const rows: ProduitRechercheRow[] = qlik.matches.map((m) => {
|
||||
const local = catalogue.get(m.codeCentrale) ?? (m.codeCentraleAlt ? catalogue.get(m.codeCentraleAlt) : undefined);
|
||||
const cache = cacheReseau.get(m.codeCentrale);
|
||||
const fiable = cacheReseauComplet(cache, qlik.periode);
|
||||
const qteReseau = fiable ? cache.qteReseau : m.qteReseau;
|
||||
const caReseau = fiable ? cache.caReseau : m.caReseau;
|
||||
const nbMag = fiable ? cache.nbMagasinsReseau : m.nbMagasinsReseau;
|
||||
const caParMagasin = fiable ? cache.caParMagasinReseau : m.caParMagasinReseau;
|
||||
const margePct = fiable ? cache.margePctReseau : m.margePctReseau;
|
||||
return {
|
||||
codeCentrale: m.codeCentrale,
|
||||
libelle: local?.libelle1 || m.libelle || m.codeCentrale,
|
||||
libelleReseau: m.libelle,
|
||||
fournisseur: local?.fournisseur || m.fournisseur || "",
|
||||
codein: local?.codein ?? null,
|
||||
nomenclature: local?.nomenclature ?? "",
|
||||
stockLocal: local ? Number(local.stock_total) || 0 : null,
|
||||
qteReseau,
|
||||
caReseau,
|
||||
nbMagasinsReseau: nbMag,
|
||||
tauxPresence: nbMag / NB_MAGASINS_RESEAU,
|
||||
qteParMagasinReseau: nbMag > 0 ? qteReseau / nbMag : 0,
|
||||
caParMagasinReseau: caParMagasin,
|
||||
prixMoyenReseau: qteReseau > 0 ? caReseau / qteReseau : null,
|
||||
margePctReseau: normalizeMargePct(margePct),
|
||||
// Le mensuel ne vient que du cache : le cube de recherche est agrégé.
|
||||
qteByMonth: fiable ? cache.qteByMonth : null,
|
||||
nbMagByMonth: fiable ? nbMagParMois(cache.metricsByMonth) : null,
|
||||
periode: qlik.periode,
|
||||
};
|
||||
});
|
||||
|
||||
// Les plus vendus du réseau d'abord : c'est l'information utile pour
|
||||
// arbitrer, un produit que le réseau ne vend pas n'intéresse personne.
|
||||
rows.sort((a, b) => b.qteReseau - a.qteReseau || a.libelle.localeCompare(b.libelle, "fr"));
|
||||
|
||||
// Les produits Nancy déjà couverts par un résultat réseau ne doivent pas
|
||||
// réapparaître dans la liste secondaire. On compare sur les deux clés
|
||||
// candidates, la jointure pouvant se faire par l'une ou par l'autre.
|
||||
const codesTrouves = new Set<string>(codes);
|
||||
for (const m of qlik.matches) if (m.codeCentraleAlt) codesTrouves.add(m.codeCentraleAlt);
|
||||
const locauxHorsReseau = locaux.filter((l) => !l.code_centrale || !codesTrouves.has(l.code_centrale));
|
||||
|
||||
// Diagnostic de la jointure : 0 rapprochement sur 40 codes signale un format
|
||||
// de clé différent entre Qlik et `articles.artcentrale`.
|
||||
if (catalogue.size === 0 && codes.length > 0) {
|
||||
console.warn(
|
||||
`[produits/search] aucun des ${codes.length} codes Qlik ne joint avec articles.artcentrale — ` +
|
||||
`échantillon Article Code=${JSON.stringify(codes.slice(0, 3))} ` +
|
||||
`article_no_centrale=${JSON.stringify(qlik.matches.slice(0, 3).map((m) => m.codeCentraleAlt))}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
rows,
|
||||
source: "qlik",
|
||||
qlikError: qlik.avertissement,
|
||||
tronque: qlik.tronque,
|
||||
champUtilise: qlik.champUtilise,
|
||||
locauxHorsReseau,
|
||||
dureeMs: Date.now() - started,
|
||||
};
|
||||
}
|
||||
|
||||
/** Ligne de repli : catalogue Nancy seul, sans données réseau fiables. */
|
||||
function rowDepuisCatalogueSeul(r: PgProduitSearchRow): ProduitRechercheRow {
|
||||
const qteReseau = Number(r.qte_reseau) || 0;
|
||||
const nbMag = Number(r.nb_magasins_reseau) || 0;
|
||||
return {
|
||||
codeCentrale: r.code_centrale,
|
||||
libelle: r.libelle1 || r.codein,
|
||||
libelleReseau: "",
|
||||
fournisseur: r.fournisseur,
|
||||
codein: r.codein,
|
||||
nomenclature: r.nomenclature,
|
||||
stockLocal: Number(r.stock_total) || 0,
|
||||
qteReseau,
|
||||
caReseau: 0,
|
||||
nbMagasinsReseau: nbMag,
|
||||
tauxPresence: nbMag / NB_MAGASINS_RESEAU,
|
||||
qteParMagasinReseau: nbMag > 0 ? qteReseau / nbMag : 0,
|
||||
caParMagasinReseau: 0,
|
||||
prixMoyenReseau: null,
|
||||
margePctReseau: null,
|
||||
qteByMonth: null,
|
||||
nbMagByMonth: null,
|
||||
periode: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Traduit les pannes Qlik récurrentes en message actionnable. Les autres
|
||||
* erreurs sont renvoyées telles quelles (tronquées) pour rester diagnosticables.
|
||||
*/
|
||||
function messageQlikLisible(message: string): string {
|
||||
const lower = message.toLowerCase();
|
||||
if (lower.includes("out of memory") || lower.includes("not enough memory") || lower.includes('"code":6') || lower.includes('"code":3002')) {
|
||||
return "Serveur Qlik saturé : mémoire insuffisante pour charger l'application. Réessayez plus tard.";
|
||||
}
|
||||
if (/"code"\s*:\s*15\b/.test(message) || lower.includes("request aborted")) {
|
||||
return "Qlik a interrompu la requête (recherche trop large pour le moteur). Ajoutez un mot plus précis, puis relancez.";
|
||||
}
|
||||
if (lower.includes("identifiants qlik manquants") || lower.includes("qlik_user")) {
|
||||
return "Identifiants Qlik non configurés (Paramètres → Qlik).";
|
||||
}
|
||||
if (lower.includes("timeout") || lower.includes("econnrefused") || lower.includes("enotfound")) {
|
||||
return "Serveur Qlik injoignable depuis CollectFlow (réseau ou VPN).";
|
||||
}
|
||||
return message.slice(0, 300);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* CollectFlow — Comparatif local vs réseau pour un produit.
|
||||
*
|
||||
* Principe : rapporter les deux périmètres à un **magasin moyen**, sinon on
|
||||
* compare 2 magasins Nancy à ~270 magasins réseau et le chiffre ne veut rien dire.
|
||||
*
|
||||
* ⚠️ La comparaison de référence porte sur les **quantités**. Le CA local vient
|
||||
* de `mvtart.mntmvtttc` (TTC) alors que la base de la mesure Qlik « CA N » n'est
|
||||
* pas documentée : les montants sont donc affichés côte à côte à titre indicatif,
|
||||
* sans indice calculé.
|
||||
*/
|
||||
|
||||
import { NB_MAGASINS_RESEAU } from "@/features/grid/lib/network-trend";
|
||||
import { ffMonthToQlik } from "@/features/grid/lib/months";
|
||||
import type { ProduitFiche } from "@/features/produits/types";
|
||||
|
||||
export interface ComparatifMois {
|
||||
/** Clé de mois FF "YYYYMM". */
|
||||
mois: string;
|
||||
/** Quantité vendue par magasin, chez nous. */
|
||||
local: number;
|
||||
/** Quantité vendue par magasin, au réseau. */
|
||||
reseau: number;
|
||||
}
|
||||
|
||||
export interface ComparatifReseau {
|
||||
/** false si le produit n'a pas encore de données réseau en cache. */
|
||||
hasReseau: boolean;
|
||||
/** Nombre de nos magasins ayant vendu le produit (min. 1 pour éviter /0). */
|
||||
nbMagasinsLocaux: number;
|
||||
nbMagasinsReseau: number;
|
||||
/** Taux de présence réseau (0..1), ou null sans données. */
|
||||
tauxPresence: number | null;
|
||||
|
||||
/** Quantité 12 mois, par magasin. */
|
||||
qteLocaleParMagasin: number;
|
||||
qteReseauParMagasin: number;
|
||||
/**
|
||||
* Indice de performance en % : 100 = on vend autant qu'un magasin moyen du
|
||||
* réseau, 150 = 50 % de mieux. `null` si le réseau ne vend rien.
|
||||
*/
|
||||
indiceQte: number | null;
|
||||
|
||||
/** CA 12 mois par magasin — indicatif (bases de calcul non alignées). */
|
||||
caLocalParMagasin: number;
|
||||
caReseauParMagasin: number | null;
|
||||
|
||||
/** Taux de marge en %, local et réseau (indicatif). */
|
||||
tauxMargeLocal: number;
|
||||
tauxMargeReseau: number | null;
|
||||
|
||||
/** Série mensuelle par magasin, alignée sur les 12 mois de la fiche. */
|
||||
monthly: ComparatifMois[];
|
||||
/** true si au moins un mois réseau est disponible. */
|
||||
hasMonthly: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Le taux de marge Qlik est un ratio brut (0.32 = 32 %) mais certaines
|
||||
* configurations renvoient déjà des points de pourcentage. Même normalisation
|
||||
* que la colonne « Marge % / Réseau » de la Grille.
|
||||
*/
|
||||
export function normalizeMargePct(raw: number | null | undefined): number | null {
|
||||
if (raw == null) return null;
|
||||
const v = Number(raw);
|
||||
if (!Number.isFinite(v) || v === 0) return null;
|
||||
return Math.abs(v) <= 1 ? v * 100 : v;
|
||||
}
|
||||
|
||||
/** Calcule le comparatif local vs réseau à partir d'une fiche produit. */
|
||||
export function computeComparatif(fiche: ProduitFiche): ComparatifReseau {
|
||||
const { reseau, totaux, sitesActifs, months, mensuelTotal } = fiche;
|
||||
|
||||
// Nos magasins actifs sur la période. Si aucun n'a vendu, on garde 1 pour
|
||||
// que la division reste définie — l'indice vaudra alors 0, ce qui est le
|
||||
// message voulu : « le réseau vend, nous non ».
|
||||
const nbMagasinsLocaux = Math.max(sitesActifs.length, 1);
|
||||
const qteLocaleParMagasin = totaux.qte / nbMagasinsLocaux;
|
||||
const caLocalParMagasin = totaux.ca / nbMagasinsLocaux;
|
||||
|
||||
if (!reseau || !reseau.nbMagasinsReseau) {
|
||||
return {
|
||||
hasReseau: false,
|
||||
nbMagasinsLocaux,
|
||||
nbMagasinsReseau: 0,
|
||||
tauxPresence: null,
|
||||
qteLocaleParMagasin,
|
||||
qteReseauParMagasin: 0,
|
||||
indiceQte: null,
|
||||
caLocalParMagasin,
|
||||
caReseauParMagasin: null,
|
||||
tauxMargeLocal: totaux.tauxMarge,
|
||||
tauxMargeReseau: null,
|
||||
monthly: [],
|
||||
hasMonthly: false,
|
||||
};
|
||||
}
|
||||
|
||||
const nbMagasinsReseau = reseau.nbMagasinsReseau;
|
||||
const qteReseauParMagasin = reseau.qteReseau / nbMagasinsReseau;
|
||||
const caReseauParMagasin = reseau.caReseau / nbMagasinsReseau;
|
||||
|
||||
// Série mensuelle : quantité par magasin de part et d'autre.
|
||||
// Le nb de magasins réseau du mois est utilisé quand il est disponible
|
||||
// (`metricsByMonth`), sinon on retombe sur le total de la période.
|
||||
const monthly: ComparatifMois[] = [];
|
||||
let hasMonthly = false;
|
||||
for (const mois of months) {
|
||||
const qlikKey = ffMonthToQlik(mois);
|
||||
const mensuel = reseau.metricsByMonth?.[qlikKey];
|
||||
const qteReseauMois = mensuel?.qte ?? reseau.qteByMonth?.[qlikKey];
|
||||
const nbMagMois = mensuel?.nbMag && mensuel.nbMag > 0 ? mensuel.nbMag : nbMagasinsReseau;
|
||||
|
||||
if (qteReseauMois != null) hasMonthly = true;
|
||||
monthly.push({
|
||||
mois,
|
||||
local: (mensuelTotal[mois]?.qte ?? 0) / nbMagasinsLocaux,
|
||||
reseau: qteReseauMois != null ? qteReseauMois / nbMagMois : 0,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
hasReseau: true,
|
||||
nbMagasinsLocaux,
|
||||
nbMagasinsReseau,
|
||||
tauxPresence: nbMagasinsReseau / NB_MAGASINS_RESEAU,
|
||||
qteLocaleParMagasin,
|
||||
qteReseauParMagasin,
|
||||
indiceQte: qteReseauParMagasin > 0 ? (qteLocaleParMagasin / qteReseauParMagasin) * 100 : null,
|
||||
caLocalParMagasin,
|
||||
caReseauParMagasin,
|
||||
tauxMargeLocal: totaux.tauxMarge,
|
||||
tauxMargeReseau: normalizeMargePct(reseau.margePctReseau),
|
||||
monthly,
|
||||
hasMonthly,
|
||||
};
|
||||
}
|
||||
|
||||
/** Lecture métier de l'indice de performance. */
|
||||
export function indiceVerdict(indice: number | null): { label: string; color: string } {
|
||||
if (indice == null) return { label: "Non comparable", color: "var(--text-muted)" };
|
||||
if (indice >= 150) return { label: "Forte surperformance", color: "#22c55e" };
|
||||
if (indice >= 110) return { label: "Surperformance", color: "#22c55e" };
|
||||
if (indice >= 90) return { label: "Dans la moyenne réseau", color: "var(--text-secondary)" };
|
||||
if (indice >= 50) return { label: "Sous-performance", color: "#f59e0b" };
|
||||
return { label: "Forte sous-performance", color: "#ef4444" };
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* CollectFlow — Types de la fiche produit (page /produits).
|
||||
*/
|
||||
|
||||
import type {
|
||||
PgProduitDetailRow,
|
||||
PgProduitFournisseurRow,
|
||||
PgProduitSearchRow,
|
||||
PgProduitStockRow,
|
||||
PgGammeHistoryRow,
|
||||
} from "@/lib/pg-ff-client";
|
||||
import type { NetworkMetricCached } from "@/lib/qlik-network-cache";
|
||||
|
||||
/**
|
||||
* Une ligne de résultat de recherche produit.
|
||||
*
|
||||
* La recherche part de **Qlik** : chaque ligne est d'abord un produit du réseau
|
||||
* (identifié par son code centrale), éventuellement rapproché du catalogue FF
|
||||
* Nancy. `codein === null` = le réseau travaille ce produit, nous ne le
|
||||
* référençons pas.
|
||||
*/
|
||||
export interface ProduitRechercheRow {
|
||||
/** Dimension Qlik « Article Code » = `articles.artcentrale`. */
|
||||
codeCentrale: string;
|
||||
/** Libellé affiché : catalogue Nancy si référencé, sinon libellé réseau. */
|
||||
libelle: string;
|
||||
/** Libellé tel que Qlik le renvoie (vide si l'app n'expose pas de champ libellé). */
|
||||
libelleReseau: string;
|
||||
/** Fournisseur : catalogue Nancy prioritaire (nom réel), sinon champ Qlik. */
|
||||
fournisseur: string;
|
||||
/** `null` si le produit n'est pas au catalogue Nancy. */
|
||||
codein: string | null;
|
||||
nomenclature: string;
|
||||
/** Stock Nancy tous sites, `null` si produit non référencé. */
|
||||
stockLocal: number | null;
|
||||
|
||||
// ─── Réseau Qlik, sur 12 mois glissants ─────────────────────────────────
|
||||
qteReseau: number;
|
||||
caReseau: number;
|
||||
nbMagasinsReseau: number;
|
||||
/** Part des magasins du réseau travaillant le produit (0..1). */
|
||||
tauxPresence: number;
|
||||
/** Quantité vendue par magasin qui travaille le produit. */
|
||||
qteParMagasinReseau: number;
|
||||
caParMagasinReseau: number;
|
||||
/** CA réseau / quantité réseau — panier moyen unitaire, `null` si pas de vente. */
|
||||
prixMoyenReseau: number | null;
|
||||
/** Taux de marge réseau en points de % (déjà normalisé), `null` si absent. */
|
||||
margePctReseau: number | null;
|
||||
/** Quantité réseau par mois `"YYYY-MM"` — alimente la sparkline de tendance. */
|
||||
qteByMonth: Record<string, number> | null;
|
||||
/**
|
||||
* Magasins vendeurs par mois `"YYYY-MM"`. La tendance porte sur la
|
||||
* qté/magasin : sans cette série, elle se rabat sur les quantités brutes.
|
||||
*/
|
||||
nbMagByMonth: Record<string, number> | null;
|
||||
periode: string | null;
|
||||
}
|
||||
|
||||
export interface ProduitRechercheResultat {
|
||||
rows: ProduitRechercheRow[];
|
||||
/** `qlik` = recherche nominale ; `db` = repli catalogue local (Qlik indisponible). */
|
||||
source: "qlik" | "db";
|
||||
/** Message d'erreur Qlik quand on a dû se rabattre sur la base locale. */
|
||||
qlikError: string | null;
|
||||
/** true si Qlik a renvoyé plus d'articles que la limite affichée. */
|
||||
tronque: boolean;
|
||||
/** Champ Qlik réellement interrogé (diagnostic). */
|
||||
champUtilise: string | null;
|
||||
/**
|
||||
* Produits du catalogue Nancy qui matchent le terme mais qu'aucun article
|
||||
* Qlik ne couvre (pas de code centrale, ou non vendus par le réseau).
|
||||
*/
|
||||
locauxHorsReseau: PgProduitSearchRow[];
|
||||
dureeMs: number;
|
||||
}
|
||||
|
||||
/** Les métriques d'un mois, pour un périmètre donné (total ou un site). */
|
||||
export interface ProduitMois {
|
||||
/** Clé de mois FF, format "YYYYMM". */
|
||||
mois: string;
|
||||
qte: number;
|
||||
/** Issu de mntmvtttc — TTC malgré le nom historique `ca_ht`. */
|
||||
ca: number;
|
||||
marge: number;
|
||||
stockFinMois: number;
|
||||
qteRecue: number;
|
||||
}
|
||||
|
||||
export interface ProduitTotaux {
|
||||
qte: number;
|
||||
ca: number;
|
||||
marge: number;
|
||||
/** Taux de marge en % (marge / CA × 100). */
|
||||
tauxMarge: number;
|
||||
}
|
||||
|
||||
export interface ProduitFiche {
|
||||
/**
|
||||
* Identité catalogue FF Nancy — `null` pour un produit que le réseau
|
||||
* travaille mais que Nancy ne référence pas (fiche 100 % réseau).
|
||||
*/
|
||||
detail: PgProduitDetailRow | null;
|
||||
/** Code centrale = clé Qlik. Toujours renseigné pour une fiche ouverte depuis la recherche. */
|
||||
codeCentrale: string;
|
||||
/** Libellé de l'article côté Qlik (peut être vide selon l'app Qlik). */
|
||||
libelleReseau: string;
|
||||
/** Fournisseur de l'article côté Qlik (peut être vide selon l'app Qlik). */
|
||||
fournisseurReseau: string;
|
||||
fournisseurs: PgProduitFournisseurRow[];
|
||||
stock: PgProduitStockRow[];
|
||||
commandesEnCours: number;
|
||||
gammes: PgGammeHistoryRow[];
|
||||
/** Les 12 clés de mois "YYYYMM", du plus ancien au plus récent. */
|
||||
months: string[];
|
||||
/** Cumul tous sites, par mois. */
|
||||
mensuelTotal: Record<string, ProduitMois>;
|
||||
/** site → mois → métriques. */
|
||||
mensuelParSite: Record<string, Record<string, ProduitMois>>;
|
||||
totaux: ProduitTotaux;
|
||||
totauxParSite: Record<string, ProduitTotaux>;
|
||||
/** Sites ayant vendu au moins une unité sur les 12 mois. */
|
||||
sitesActifs: string[];
|
||||
/** Métriques réseau Qlik, `null` si le produit n'est pas encore en cache. */
|
||||
reseau: NetworkMetricCached | null;
|
||||
/** Fenêtre analysée, pour l'affichage. */
|
||||
periode: { dateDebut: string; dateFin: string };
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
/** État serveur renvoyé par GET/POST /api/qlik/sync. */
|
||||
export interface QlikSyncJobState {
|
||||
jobId: string | null;
|
||||
fournisseur: string;
|
||||
codeCentrale?: string;
|
||||
status: "idle" | "running" | "success" | "error";
|
||||
requested: number;
|
||||
fetched: number;
|
||||
upserted: number;
|
||||
periode?: string;
|
||||
dateDebut?: string;
|
||||
dateFin?: string;
|
||||
startedAt?: string;
|
||||
finishedAt?: string;
|
||||
error?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export type QlikSyncStatus = QlikSyncJobState["status"];
|
||||
|
||||
/**
|
||||
* Cible de la synchro : un fournisseur entier (réservé aux admins) ou un seul
|
||||
* code centrale (ouvert à tout utilisateur connecté).
|
||||
*/
|
||||
export type QlikSyncTarget =
|
||||
| { mode: "fournisseur"; fournisseur: string | undefined }
|
||||
| { mode: "produit"; codeCentrale: string | undefined };
|
||||
|
||||
interface UseQlikSyncJobOptions {
|
||||
target: QlikSyncTarget;
|
||||
/** Appelé une fois quand un job se termine avec succès. */
|
||||
onSuccess?: () => void;
|
||||
/** Intervalle de polling en ms (défaut 3 s — une extraction peut durer plusieurs minutes). */
|
||||
pollIntervalMs?: number;
|
||||
}
|
||||
|
||||
/** Construit la query string de la route selon la cible. */
|
||||
function targetQuery(target: QlikSyncTarget): string | null {
|
||||
if (target.mode === "fournisseur") {
|
||||
return target.fournisseur ? `fournisseur=${encodeURIComponent(target.fournisseur)}` : null;
|
||||
}
|
||||
return target.codeCentrale ? `codeCentrale=${encodeURIComponent(target.codeCentrale)}` : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pilote un job de synchronisation Qlik : démarrage (POST), suivi (polling GET),
|
||||
* reprise d'un job déjà en cours au montage, et nettoyage des timers.
|
||||
*
|
||||
* Extrait de `SyncQlikButton` pour être partagé avec la fiche produit. Le
|
||||
* protocole est inchangé : le POST démarre un job serveur en arrière-plan et
|
||||
* revient immédiatement, le client poll ensuite GET jusqu'à l'état terminal.
|
||||
*/
|
||||
export function useQlikSyncJob({ target, onSuccess, pollIntervalMs = 3000 }: UseQlikSyncJobOptions) {
|
||||
const [status, setStatus] = useState<QlikSyncStatus>("idle");
|
||||
const [message, setMessage] = useState<string>("");
|
||||
|
||||
const query = targetQuery(target);
|
||||
const pollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
/** true tant qu'on doit suivre le job courant (false après succès/erreur traité). */
|
||||
const trackingRef = useRef<boolean>(false);
|
||||
// Gardé dans une ref : le callback peut changer d'identité à chaque rendu
|
||||
// sans qu'on veuille relancer le polling pour autant.
|
||||
const onSuccessRef = useRef(onSuccess);
|
||||
useEffect(() => { onSuccessRef.current = onSuccess; }, [onSuccess]);
|
||||
|
||||
const clearPollTimer = useCallback(() => {
|
||||
if (pollTimerRef.current) {
|
||||
clearTimeout(pollTimerRef.current);
|
||||
pollTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
/** Met à jour le libellé visible selon l'état courant. */
|
||||
const applyJobToUi = useCallback((j: QlikSyncJobState | null) => {
|
||||
if (!j) return;
|
||||
if (j.status === "running") {
|
||||
setStatus("running");
|
||||
const requestedTxt = j.requested > 0 ? ` (${j.requested} code${j.requested > 1 ? "s" : ""})` : "";
|
||||
setMessage(`Extraction Qlik en cours${requestedTxt}…`);
|
||||
} else if (j.status === "success") {
|
||||
setStatus("success");
|
||||
if (j.message) setMessage(j.message);
|
||||
else setMessage(`${j.upserted} produits réseau synchronisés`);
|
||||
} else if (j.status === "error") {
|
||||
setStatus("error");
|
||||
setMessage(j.error || "Erreur sync Qlik");
|
||||
} else {
|
||||
setStatus("idle");
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Un tour de polling. Renvoie true s'il faut continuer.
|
||||
*/
|
||||
const pollOnce = useCallback(async (): Promise<boolean> => {
|
||||
if (!query || !trackingRef.current) return false;
|
||||
try {
|
||||
const res = await fetch(`/api/qlik/sync?${query}`, { method: "GET", cache: "no-store" });
|
||||
if (!res.ok) {
|
||||
// Erreur HTTP transitoire : on retente au tour suivant.
|
||||
return true;
|
||||
}
|
||||
const data: QlikSyncJobState = await res.json();
|
||||
applyJobToUi(data);
|
||||
|
||||
if (data.status === "running") return true;
|
||||
|
||||
if (data.status === "success" || data.status === "error") {
|
||||
trackingRef.current = false;
|
||||
if (data.status === "success") onSuccessRef.current?.();
|
||||
// Petit délai pour laisser l'utilisateur lire le message vert/rouge.
|
||||
setTimeout(() => {
|
||||
setStatus((cur) => (cur === "success" || cur === "error" ? "idle" : cur));
|
||||
}, 4000);
|
||||
return false;
|
||||
}
|
||||
trackingRef.current = false;
|
||||
return false;
|
||||
} catch {
|
||||
// Erreur réseau transitoire : on laisse le polling réessayer.
|
||||
return true;
|
||||
}
|
||||
}, [applyJobToUi, query]);
|
||||
|
||||
/** Boucle de polling auto-réarmée tant que pollOnce le demande. */
|
||||
const startPolling = useCallback(() => {
|
||||
clearPollTimer();
|
||||
const tick = async () => {
|
||||
const keepGoing = await pollOnce();
|
||||
if (keepGoing && trackingRef.current) {
|
||||
pollTimerRef.current = setTimeout(tick, pollIntervalMs);
|
||||
} else {
|
||||
pollTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
// Premier appel quasi immédiat.
|
||||
pollTimerRef.current = setTimeout(tick, 200);
|
||||
}, [clearPollTimer, pollOnce, pollIntervalMs]);
|
||||
|
||||
/**
|
||||
* Au montage (et quand la cible change) : reprend l'état d'un éventuel job
|
||||
* déjà en cours, par exemple après un reload pendant l'extraction.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!query) return;
|
||||
let cancelled = false;
|
||||
trackingRef.current = false;
|
||||
clearPollTimer();
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/qlik/sync?${query}`, { method: "GET", cache: "no-store" });
|
||||
if (cancelled || !res.ok) return;
|
||||
const data: QlikSyncJobState = await res.json();
|
||||
if (cancelled) return;
|
||||
if (data.status === "running" && data.jobId) {
|
||||
trackingRef.current = true;
|
||||
applyJobToUi(data);
|
||||
startPolling();
|
||||
} else if (data.status === "success" || data.status === "error") {
|
||||
// Job terminé avant le montage : on l'affiche brièvement.
|
||||
applyJobToUi(data);
|
||||
setTimeout(() => {
|
||||
setStatus((cur) => (cur === "success" || cur === "error" ? "idle" : cur));
|
||||
}, 4000);
|
||||
}
|
||||
} catch {
|
||||
/* ignore : on n'empêche pas l'UI de s'afficher */
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
trackingRef.current = false;
|
||||
clearPollTimer();
|
||||
};
|
||||
// On veut interroger une seule fois au montage (et quand la cible change).
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [query, clearPollTimer]);
|
||||
|
||||
/** Cleanup : annule un timer de polling éventuel au démontage. */
|
||||
useEffect(() => clearPollTimer, [clearPollTimer]);
|
||||
|
||||
/** Démarre l'extraction. */
|
||||
const start = useCallback(async () => {
|
||||
if (!query) {
|
||||
setStatus("error");
|
||||
setMessage(target.mode === "fournisseur" ? "Aucun fournisseur sélectionné" : "Aucun code centrale");
|
||||
setTimeout(() => setStatus("idle"), 6000);
|
||||
return;
|
||||
}
|
||||
// Empêche un double-clic pendant qu'un job tourne déjà.
|
||||
if (status === "running") return;
|
||||
|
||||
setStatus("running");
|
||||
setMessage("Démarrage de l'extraction Qlik…");
|
||||
try {
|
||||
const res = await fetch(`/api/qlik/sync?${query}`, { method: "POST" });
|
||||
const data: QlikSyncJobState & { success?: boolean } = await res.json();
|
||||
if (!res.ok || data.success === false) {
|
||||
throw new Error(data.error || `HTTP ${res.status}`);
|
||||
}
|
||||
if (data.status === "running" && data.jobId) {
|
||||
trackingRef.current = true;
|
||||
applyJobToUi(data);
|
||||
startPolling();
|
||||
return;
|
||||
}
|
||||
// Statut terminal direct (cas rare : 0 code → success immédiat).
|
||||
applyJobToUi(data);
|
||||
if (data.status === "success") onSuccessRef.current?.();
|
||||
setTimeout(() => setStatus("idle"), 4000);
|
||||
} catch (e) {
|
||||
setStatus("error");
|
||||
setMessage(e instanceof Error ? e.message : String(e));
|
||||
setTimeout(() => setStatus("idle"), 6000);
|
||||
}
|
||||
}, [applyJobToUi, query, startPolling, status, target.mode]);
|
||||
|
||||
return { status, message, start };
|
||||
}
|
||||
@@ -14,6 +14,27 @@ export interface DbConfig {
|
||||
aiProvider?: "openrouter" | "google";
|
||||
googleAiKey?: string;
|
||||
googleAiModel?: string;
|
||||
/** Qlik Sense — données réseau */
|
||||
qlikHost?: string;
|
||||
qlikUser?: string;
|
||||
qlikPassword?: string;
|
||||
/**
|
||||
* API REST FF Nancy (ex. https://api.ffnancy.fr). Sert au panneau de statut et
|
||||
* au rattrapage des ventes par magasin dans la Grille. Configurable ici pour
|
||||
* ne pas dépendre d'une variable d'environnement qu'on ne peut pas changer
|
||||
* sans redéployer.
|
||||
*/
|
||||
ffApiBaseUrl?: string;
|
||||
}
|
||||
|
||||
/** Lit la config existante (ou {} si absente). */
|
||||
async function readConfig(): Promise<Partial<DbConfig>> {
|
||||
try {
|
||||
const data = await fs.readFile(CONFIG_FILE, "utf-8");
|
||||
return JSON.parse(data);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export async function testDatabaseConnection(url: string) {
|
||||
@@ -46,7 +67,9 @@ export async function saveDatabaseSettings(
|
||||
googleAiModel?: string,
|
||||
) {
|
||||
try {
|
||||
const config: DbConfig = { url, openRouterKey, openRouterModel, aiProvider, googleAiKey, googleAiModel };
|
||||
// Merge avec l'existant pour préserver les autres réglages (ex: Qlik)
|
||||
const existing = await readConfig();
|
||||
const config: DbConfig = { ...existing, url, openRouterKey, openRouterModel, aiProvider, googleAiKey, googleAiModel };
|
||||
|
||||
// S'assurer que le dossier data existe
|
||||
await fs.mkdir(DATA_DIR, { recursive: true });
|
||||
@@ -67,6 +90,110 @@ export async function saveDatabaseSettings(
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveQlikSettings(qlikHost: string, qlikUser: string, qlikPassword: string) {
|
||||
try {
|
||||
const existing = await readConfig();
|
||||
const config = { ...existing, qlikHost, qlikUser, qlikPassword } as DbConfig;
|
||||
await fs.mkdir(DATA_DIR, { recursive: true });
|
||||
await fs.writeFile(CONFIG_FILE, JSON.stringify(config, null, 2));
|
||||
console.log("[Settings] Qlik configuration saved.");
|
||||
return { success: true };
|
||||
} catch (error: unknown) {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
console.error("[Settings] Failed to save Qlik config:", msg);
|
||||
return { success: false, error: msg };
|
||||
}
|
||||
}
|
||||
|
||||
export async function testQlikConnection(qlikHost: string, qlikUser: string, qlikPassword: string) {
|
||||
try {
|
||||
const { getQlikConfig, qlikNtlmSession } = await import("@/lib/qlik-client");
|
||||
const cfg = { ...getQlikConfig(), user: qlikUser, password: qlikPassword };
|
||||
if (qlikHost) cfg.host = qlikHost;
|
||||
await qlikNtlmSession(cfg);
|
||||
return { success: true };
|
||||
} catch (error: unknown) {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
return { success: false, error: msg };
|
||||
}
|
||||
}
|
||||
|
||||
/** Enregistre l'URL de l'API FF Nancy. Chaîne vide = revenir au défaut. */
|
||||
export async function saveFfApiSettings(ffApiBaseUrl: string) {
|
||||
try {
|
||||
const cleaned = ffApiBaseUrl.trim().replace(/\/+$/, "");
|
||||
if (cleaned && !/^https?:\/\//i.test(cleaned)) {
|
||||
return { success: false, error: "L'URL doit commencer par http:// ou https://" };
|
||||
}
|
||||
const existing = await readConfig();
|
||||
const config = { ...existing, ffApiBaseUrl: cleaned || undefined } as DbConfig;
|
||||
await fs.mkdir(DATA_DIR, { recursive: true });
|
||||
await fs.writeFile(CONFIG_FILE, JSON.stringify(config, null, 2));
|
||||
// Sans cela, l'ancienne URL resterait servie jusqu'à 30 s après la sauvegarde.
|
||||
const { resetFfApiBaseCache } = await import("@/lib/api-ff-client");
|
||||
resetFfApiBaseCache();
|
||||
console.log(`[Settings] FF API base URL saved: ${cleaned || "(défaut)"}`);
|
||||
return { success: true };
|
||||
} catch (error: unknown) {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
console.error("[Settings] Failed to save FF API config:", msg);
|
||||
return { success: false, error: msg };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Teste l'API FF Nancy et renvoie un diagnostic **exploitable**.
|
||||
*
|
||||
* L'ancien panneau se contentait d'un « HTTP 503 » opaque : impossible de savoir
|
||||
* si l'hôte était injoignable, l'URL erronée ou le service en panne. On distingue
|
||||
* donc ici l'échec réseau (DNS, refus de connexion, délai dépassé) du code HTTP.
|
||||
*/
|
||||
export async function testFfApiConnection(ffApiBaseUrl?: string) {
|
||||
const base = (ffApiBaseUrl?.trim() || (await readConfig()).ffApiBaseUrl || process.env.FF_API_BASE_URL || "https://api.ffnancy.fr").replace(/\/+$/, "");
|
||||
const url = `${base}/api/sync/status`;
|
||||
const started = Date.now();
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
cache: "no-store",
|
||||
signal: AbortSignal.timeout(8000),
|
||||
});
|
||||
const ms = Date.now() - started;
|
||||
if (!res.ok) {
|
||||
return { success: false, url, error: `Le serveur a répondu HTTP ${res.status} (${res.statusText || "sans message"}) en ${ms} ms.` };
|
||||
}
|
||||
const body = await res.json().catch(() => null);
|
||||
if (!body) {
|
||||
return { success: false, url, error: `Réponse HTTP 200 mais corps illisible (JSON attendu).` };
|
||||
}
|
||||
// La réponse brute de l'API n'a pas la forme attendue par l'interface :
|
||||
// sans cette normalisation, le panneau s'affiche vide malgré un HTTP 200.
|
||||
const { normalizeSyncStatus } = await import("@/lib/api-ff-client");
|
||||
const status = normalizeSyncStatus(body);
|
||||
if (!status) {
|
||||
return { success: false, url, error: `Réponse HTTP 200 mais format inattendu (ni « sync » ni « tables »).` };
|
||||
}
|
||||
return { success: true, url, ms, status };
|
||||
} catch (error: unknown) {
|
||||
const ms = Date.now() - started;
|
||||
const raw = error instanceof Error ? error.message : String(error);
|
||||
const name = error instanceof Error ? error.name : "";
|
||||
// fetch masque la cause réelle derrière « fetch failed » : on la déplie.
|
||||
const cause = (error as { cause?: { code?: string; message?: string } })?.cause;
|
||||
let hint = raw;
|
||||
if (name === "TimeoutError" || name === "AbortError") {
|
||||
hint = `Aucune réponse en ${ms} ms — serveur injoignable ou trop lent.`;
|
||||
} else if (cause?.code === "ENOTFOUND") {
|
||||
hint = `Nom d'hôte introuvable (DNS) — vérifiez l'URL.`;
|
||||
} else if (cause?.code === "ECONNREFUSED") {
|
||||
hint = `Connexion refusée — le service n'écoute pas sur cette adresse.`;
|
||||
} else if (cause?.code) {
|
||||
hint = `${cause.code}${cause.message ? ` — ${cause.message}` : ""}`;
|
||||
}
|
||||
console.error(`[Settings] FF API test KO (${url}):`, raw);
|
||||
return { success: false, url, error: hint };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSavedDatabaseConfig(): Promise<DbConfig | null> {
|
||||
try {
|
||||
if (!(await fs.stat(CONFIG_FILE).catch(() => null))) {
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Download, Loader2, RefreshCw, FileText, AlertCircle } from "lucide-react";
|
||||
|
||||
interface LogEntry {
|
||||
id: string;
|
||||
octets: number;
|
||||
modifieLe: string;
|
||||
enCours: boolean;
|
||||
}
|
||||
|
||||
function formatTaille(octets: number): string {
|
||||
if (octets < 1024) return `${octets} o`;
|
||||
if (octets < 1024 * 1024) return `${Math.round(octets / 1024)} Ko`;
|
||||
return `${(octets / (1024 * 1024)).toFixed(1)} Mo`;
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return iso;
|
||||
return d.toLocaleString("fr-FR", {
|
||||
day: "2-digit", month: "2-digit", year: "numeric",
|
||||
hour: "2-digit", minute: "2-digit", second: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
/** Appelle l'API. Volontairement sans état React : réutilisable tel quel. */
|
||||
async function recupererLogs(): Promise<LogEntry[]> {
|
||||
const res = await fetch("/api/logs", { cache: "no-store" });
|
||||
if (!res.ok) {
|
||||
throw new Error(res.status === 403 ? "Réservé aux administrateurs" : `HTTP ${res.status}`);
|
||||
}
|
||||
const data = await res.json();
|
||||
return Array.isArray(data.logs) ? data.logs : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Journaux serveur des extractions Qlik — consultation et téléchargement.
|
||||
*
|
||||
* Une extraction produit plusieurs milliers de lignes de diagnostic. Un
|
||||
* terminal les tronque et n'en laisse que la fin, alors que l'information
|
||||
* décisive (carte du modèle Qlik, champs retenus, choix des périodes) se trouve
|
||||
* en tête. Le journal complet est donc capturé côté serveur et récupérable ici
|
||||
* en un fichier.
|
||||
*/
|
||||
export function ServerLogs() {
|
||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||
// « chargement » dès le départ : le premier appel part au montage, et aucun
|
||||
// setState ne doit être fait de façon synchrone dans un effet.
|
||||
const [etat, setEtat] = useState<"idle" | "chargement" | "erreur">("chargement");
|
||||
const [erreur, setErreur] = useState<string>("");
|
||||
|
||||
/** Applique un résultat (succès ou échec) à l'état local. */
|
||||
const appliquer = useCallback((entrees: LogEntry[] | null, message: string) => {
|
||||
if (entrees) {
|
||||
setLogs(entrees);
|
||||
setErreur("");
|
||||
setEtat("idle");
|
||||
} else {
|
||||
setErreur(message);
|
||||
setEtat("erreur");
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Chargement initial. L'état n'est touché que depuis les callbacks de la
|
||||
// promesse, jamais dans le corps de l'effet.
|
||||
useEffect(() => {
|
||||
recupererLogs()
|
||||
.then((entrees) => appliquer(entrees, ""))
|
||||
.catch((e: unknown) => appliquer(null, e instanceof Error ? e.message : String(e)));
|
||||
}, [appliquer]);
|
||||
|
||||
/** Rechargement explicite (bouton) : là, l'état « chargement » est immédiat. */
|
||||
const recharger = useCallback(() => {
|
||||
setEtat("chargement");
|
||||
recupererLogs()
|
||||
.then((entrees) => appliquer(entrees, ""))
|
||||
.catch((e: unknown) => appliquer(null, e instanceof Error ? e.message : String(e)));
|
||||
}, [appliquer]);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-[12px] text-[var(--text-secondary)]">
|
||||
Journal complet de chaque extraction Qlik (le plus récent en premier).
|
||||
Conservés le temps de la vie du serveur, 20 au maximum.
|
||||
</p>
|
||||
<button
|
||||
onClick={recharger}
|
||||
disabled={etat === "chargement"}
|
||||
className="btn-action btn-action-secondary flex items-center gap-1.5 shrink-0 disabled:opacity-60"
|
||||
>
|
||||
{etat === "chargement"
|
||||
? <Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
: <RefreshCw className="w-3.5 h-3.5" />}
|
||||
Actualiser
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{etat === "erreur" && (
|
||||
<div className="flex items-center gap-2 text-[12px] text-[var(--accent-error)]">
|
||||
<AlertCircle className="w-4 h-4 shrink-0" />
|
||||
<span>{erreur}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{etat !== "erreur" && logs.length === 0 && (
|
||||
<p className="text-[12px] text-[var(--text-muted)]">
|
||||
Aucun journal pour l'instant — lancez une synchronisation Qlik depuis la Grille.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{logs.length > 0 && (
|
||||
<ul className="divide-y divide-[var(--border-subtle)] rounded-lg border border-[var(--border-subtle)]">
|
||||
{logs.map((log) => (
|
||||
<li key={log.id} className="flex items-center gap-3 px-3 py-2">
|
||||
<FileText className="w-4 h-4 shrink-0 text-[var(--text-muted)]" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[12px] font-medium text-[var(--text-primary)] truncate">
|
||||
{log.id}
|
||||
</span>
|
||||
{log.enCours && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-[var(--brand-solid)] text-white shrink-0">
|
||||
en cours
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[11px] text-[var(--text-muted)]">
|
||||
{formatDate(log.modifieLe)} · {formatTaille(log.octets)}
|
||||
</span>
|
||||
</div>
|
||||
<a
|
||||
href={`/api/logs?id=${encodeURIComponent(log.id)}`}
|
||||
download
|
||||
className="btn-action btn-action-secondary flex items-center gap-1.5 shrink-0"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
Télécharger
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { SuccessModal } from "@/components/shared/success-modal";
|
||||
import { isStaleServerActionError, STALE_ACTION_MESSAGE } from "@/lib/stale-action";
|
||||
import { ConfirmModal } from "@/components/shared/confirm-modal";
|
||||
|
||||
const formatDate = (date: Date) => {
|
||||
@@ -36,12 +37,34 @@ export function SnapshotList({ type }: SnapshotListProps) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isDeleting, setIsDeleting] = useState<number | null>(null);
|
||||
const [modal, setModal] = useState<{ isOpen: boolean, title: string, message: string }>({
|
||||
const [modal, setModal] = useState<{
|
||||
isOpen: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
variant?: "success" | "error";
|
||||
action?: { label: string; onClick: () => void };
|
||||
}>({
|
||||
isOpen: false,
|
||||
title: "",
|
||||
message: ""
|
||||
});
|
||||
|
||||
/**
|
||||
* Onglet resté ouvert pendant un déploiement : les identifiants de Server
|
||||
* Action ont changé côté serveur. On propose le rechargement au lieu
|
||||
* d'afficher une erreur technique incompréhensible.
|
||||
*/
|
||||
const modalErreur = (err: unknown, titre: string, message: string) =>
|
||||
isStaleServerActionError(err)
|
||||
? {
|
||||
isOpen: true,
|
||||
title: "Page à recharger",
|
||||
message: STALE_ACTION_MESSAGE,
|
||||
variant: "error" as const,
|
||||
action: { label: "Recharger la page", onClick: () => window.location.reload() },
|
||||
}
|
||||
: { isOpen: true, title: titre, message, variant: "error" as const };
|
||||
|
||||
const [confirmModal, setConfirmModal] = useState<{
|
||||
isOpen: boolean;
|
||||
title: string;
|
||||
@@ -59,7 +82,7 @@ export function SnapshotList({ type }: SnapshotListProps) {
|
||||
const data = await getSnapshots(type);
|
||||
setSnapshots(data);
|
||||
} catch (err) {
|
||||
setError("Impossible de charger les données.");
|
||||
setError(isStaleServerActionError(err) ? STALE_ACTION_MESSAGE : "Impossible de charger les données.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -83,10 +106,10 @@ export function SnapshotList({ type }: SnapshotListProps) {
|
||||
setSnapshots(prev => prev.filter(s => s.id !== id));
|
||||
setModal({ isOpen: true, title: "Action Réussie", message: "L'élément a été supprimé de votre historique." });
|
||||
} else {
|
||||
setModal({ isOpen: true, title: "Erreur", message: "Impossible de supprimer l'élément." });
|
||||
setModal({ isOpen: true, title: "Erreur", message: "Impossible de supprimer l'élément.", variant: "error" });
|
||||
}
|
||||
} catch (err) {
|
||||
setModal({ isOpen: true, title: "Erreur Technique", message: "Une erreur est survenue lors de la suppression." });
|
||||
setModal(modalErreur(err, "Erreur Technique", "Une erreur est survenue lors de la suppression."));
|
||||
} finally {
|
||||
setIsDeleting(null);
|
||||
}
|
||||
@@ -215,6 +238,8 @@ export function SnapshotList({ type }: SnapshotListProps) {
|
||||
onClose={() => setModal(prev => ({ ...prev, isOpen: false }))}
|
||||
title={modal.title}
|
||||
message={modal.message}
|
||||
variant={modal.variant}
|
||||
action={modal.action}
|
||||
/>
|
||||
|
||||
<ConfirmModal
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* CollectFlow — Point d'entrée exécuté une fois au démarrage du serveur.
|
||||
*
|
||||
* Sert à armer le minuteur de la synchronisation nocturne. L'import est
|
||||
* dynamique et conditionné au runtime Node : `register()` est aussi évalué
|
||||
* dans le runtime Edge, où ni `setInterval` persistant ni l'accès à la base
|
||||
* n'ont de sens.
|
||||
*/
|
||||
export async function register() {
|
||||
if (process.env.NEXT_RUNTIME !== "nodejs") return;
|
||||
|
||||
try {
|
||||
const { startSyncTimer } = await import("@/features/admin/api/sync-timer");
|
||||
startSyncTimer();
|
||||
} catch (e) {
|
||||
// Un démarrage réussi prime sur le planificateur : s'il ne s'arme pas,
|
||||
// l'application doit rester utilisable et la synchro manuelle disponible.
|
||||
console.error("[instrumentation] minuteur de synchro non démarré :", e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* CollectFlow — Authentification de l'API publique `/api/v1`.
|
||||
*
|
||||
* Deux voies d'entrée :
|
||||
* 1. **Clé d'API** (`X-API-Key` ou `Authorization: Bearer …`) — pour les appelants
|
||||
* non-navigateur : scripts, outils externes, assistant IA.
|
||||
* 2. **Session NextAuth** — le front de CollectFlow utilise donc les mêmes routes.
|
||||
*
|
||||
* En cas d'échec on renvoie un **401 JSON**, jamais une redirection : `src/middleware.ts`
|
||||
* exempte `/api/v1/*` précisément pour que ce module puisse répondre proprement.
|
||||
*
|
||||
* Seul le hachage SHA-256 des clés est stocké (table `api_keys`) ; la clé en clair
|
||||
* n'existe qu'au moment de sa création.
|
||||
*/
|
||||
|
||||
import "server-only";
|
||||
|
||||
import { createHash, randomBytes, timingSafeEqual } from "crypto";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { db } from "@/db";
|
||||
import { apiKeys } from "@/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { fail } from "@/lib/api-response";
|
||||
|
||||
export interface AuthContext {
|
||||
/** Origine de l'authentification — utile pour les logs et l'audit. */
|
||||
via: "api_key" | "session";
|
||||
role: string;
|
||||
/** Nom de la clé, ou identifiant de l'utilisateur connecté. */
|
||||
subject: string;
|
||||
}
|
||||
|
||||
/** Préfixe des clés CollectFlow, pour les reconnaître d'un coup d'œil. */
|
||||
const KEY_PREFIX = "cf_";
|
||||
|
||||
export function hashApiKey(key: string): string {
|
||||
return createHash("sha256").update(key, "utf8").digest("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère une clé d'API. Renvoie la clé **en clair** (à afficher une seule fois),
|
||||
* son hachage à stocker, et un préfixe lisible pour l'identifier dans l'UI.
|
||||
*/
|
||||
export function generateApiKey(): { key: string; keyHash: string; keyPrefix: string } {
|
||||
const secret = randomBytes(24).toString("base64url");
|
||||
const key = `${KEY_PREFIX}${secret}`;
|
||||
return { key, keyHash: hashApiKey(key), keyPrefix: key.slice(0, 11) };
|
||||
}
|
||||
|
||||
/** Comparaison à temps constant de deux hachages hexadécimaux. */
|
||||
function hashesEqual(a: string, b: string): boolean {
|
||||
const ba = Buffer.from(a, "hex");
|
||||
const bb = Buffer.from(b, "hex");
|
||||
if (ba.length !== bb.length || ba.length === 0) return false;
|
||||
return timingSafeEqual(ba, bb);
|
||||
}
|
||||
|
||||
/** Extrait la clé d'un en-tête `X-API-Key` ou `Authorization: Bearer …`. */
|
||||
function readKeyFromRequest(req: NextRequest): string | null {
|
||||
const direct = req.headers.get("x-api-key");
|
||||
if (direct?.trim()) return direct.trim();
|
||||
const authz = req.headers.get("authorization");
|
||||
if (authz) {
|
||||
const m = authz.match(/^Bearer\s+(.+)$/i);
|
||||
if (m) return m[1].trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentifie une requête `/api/v1`.
|
||||
*
|
||||
* Renvoie un `AuthContext` en cas de succès, ou une réponse d'erreur JSON prête à
|
||||
* être retournée par le handler. À appeler en première ligne de chaque endpoint :
|
||||
*
|
||||
* const authCtx = await requireApiAuth(req);
|
||||
* if (authCtx instanceof NextResponse) return authCtx;
|
||||
*/
|
||||
export async function requireApiAuth(req: NextRequest): Promise<AuthContext | Response> {
|
||||
const presented = readKeyFromRequest(req);
|
||||
|
||||
if (presented) {
|
||||
const presentedHash = hashApiKey(presented);
|
||||
// La colonne key_hash est unique : la recherche par égalité suffit, et la
|
||||
// comparaison à temps constant ci-dessous couvre le reste.
|
||||
const [row] = await db.select().from(apiKeys).where(eq(apiKeys.keyHash, presentedHash)).limit(1);
|
||||
|
||||
if (!row || !hashesEqual(row.keyHash, presentedHash)) {
|
||||
return fail("unauthorized", "Clé d'API invalide.");
|
||||
}
|
||||
if (row.revokedAt) {
|
||||
return fail("unauthorized", "Clé d'API révoquée.");
|
||||
}
|
||||
|
||||
// Trace d'usage — jamais bloquante pour la requête en cours.
|
||||
void db.update(apiKeys).set({ lastUsedAt: new Date() }).where(eq(apiKeys.id, row.id))
|
||||
.catch((e) => console.error("[api-auth] lastUsedAt KO:", (e as Error).message?.slice(0, 120)));
|
||||
|
||||
return { via: "api_key", role: row.role, subject: row.name };
|
||||
}
|
||||
|
||||
// Repli sur la session du navigateur (le front utilise les mêmes routes).
|
||||
const session = await auth();
|
||||
if (session?.user) {
|
||||
const user = session.user as { name?: string | null; role?: string };
|
||||
return { via: "session", role: user.role ?? "user", subject: user.name ?? "session" };
|
||||
}
|
||||
|
||||
return fail(
|
||||
"unauthorized",
|
||||
"Authentification requise : fournissez un en-tête X-API-Key (ou Authorization: Bearer), ou connectez-vous.",
|
||||
);
|
||||
}
|
||||
|
||||
/** Restreint un endpoint aux appelants administrateurs. */
|
||||
export function requireAdminRole(ctx: AuthContext): Response | null {
|
||||
if (ctx.role !== "admin") {
|
||||
return fail("forbidden", "Rôle administrateur requis pour cette opération.");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* CollectFlow — Enrichissement des lignes servies par `/api/v1`.
|
||||
*
|
||||
* L'instantané `grid_rows` fige la ligne au moment du calcul. Deux informations
|
||||
* doivent pourtant refléter l'état **courant** au moment de l'appel :
|
||||
*
|
||||
* 1. **Métriques réseau Qlik** — la table `qlik_network_metrics` est mise à jour par
|
||||
* les synchronisations et les recherches réseau, indépendamment du calcul de la
|
||||
* grille. On les relit donc à la lecture, et on les expose telles quelles quand
|
||||
* elles existent (`network: null` sinon).
|
||||
*
|
||||
* 2. **Gamme serveur** — `codeGamme` peut être surchargée par un snapshot de session
|
||||
* (modification locale non enregistrée côté serveur). L'API expose la gamme **non
|
||||
* modifiée**, celle réellement présente en base PostgreSQL, relue à chaque appel.
|
||||
*
|
||||
* Ce n'est pas un recalcul : deux lectures indexées bornées au nombre de lignes de la
|
||||
* page (500 au maximum), de l'ordre de quelques millisecondes.
|
||||
*/
|
||||
|
||||
import "server-only";
|
||||
|
||||
import type { ProductRow } from "@/types/grid";
|
||||
import { getNetworkMetricsByCodeCentrale, type NetworkMetricCached } from "@/lib/qlik-network-cache";
|
||||
import { pgGetGammesByCodeins } from "@/lib/pg-ff-client";
|
||||
// Module de la Grille : l'API doit renvoyer exactement la tendance affichée dans
|
||||
// l'application, pas une seconde implémentation qui divergerait.
|
||||
import { computeNetworkTrend, trendLabel } from "@/features/grid/lib/network-trend";
|
||||
|
||||
/** Tendance réseau 12 mois, telle qu'affichée dans la colonne « Tendance » de la Grille. */
|
||||
export interface ProductTrend {
|
||||
direction: "up" | "down" | "flat";
|
||||
/** Variation des 4 derniers mois vs les 4 premiers (0.23 = +23 %). `null` si aucune base. */
|
||||
pct: number | null;
|
||||
/** Libellé prêt à afficher : « Forte hausse », « Nouveau », « Stable »… */
|
||||
label: string;
|
||||
/**
|
||||
* `true` = tendance calculée sur la quantité **par magasin vendeur**, ce qui
|
||||
* neutralise l'effet d'un simple élargissement de la distribution. `false` =
|
||||
* repli sur les quantités brutes, faute de nombre de magasins sur 12 mois.
|
||||
*/
|
||||
surQteParMagasin: boolean;
|
||||
/** Aucune vente sur les 4 premiers mois, des ventes sur les 4 derniers. */
|
||||
nouveau: boolean;
|
||||
/** Nombre de mois de la série (12 quand elle est complète). */
|
||||
monthsUsed: number;
|
||||
}
|
||||
|
||||
export interface EnrichedProductRow extends ProductRow {
|
||||
/**
|
||||
* Gamme telle qu'elle existe sur le serveur PostgreSQL — non modifiée.
|
||||
* `null` si l'article n'a aucune gamme en base.
|
||||
*/
|
||||
codeGammeServeur: string | null;
|
||||
/** Métriques réseau Qlik en cache, ou `null` si le produit n'en a pas. */
|
||||
network: NetworkMetricCached | null;
|
||||
/**
|
||||
* Tendance réseau calculée par régression linéaire sur les 12 derniers mois.
|
||||
* `null` quand il n'y a aucune série mensuelle. Fournie pour que l'appelant —
|
||||
* en particulier un agent — n'ait pas à réimplémenter le calcul et à diverger
|
||||
* de ce que montre l'application.
|
||||
*/
|
||||
trend: ProductTrend | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enrichit un lot de lignes. Les deux lectures sont indépendantes et lancées en
|
||||
* parallèle ; un échec de l'une n'empêche pas l'autre (l'API reste servie, avec
|
||||
* la valeur de l'instantané en repli).
|
||||
*/
|
||||
export async function enrichRows(rows: ProductRow[]): Promise<EnrichedProductRow[]> {
|
||||
if (rows.length === 0) return [];
|
||||
|
||||
const codesCentraux = [...new Set(rows.map((r) => r.codeCentrale).filter((c): c is string => Boolean(c)))];
|
||||
const codeins = [...new Set(rows.map((r) => r.codein).filter(Boolean))];
|
||||
|
||||
const [network, gammes] = await Promise.all([
|
||||
codesCentraux.length
|
||||
? getNetworkMetricsByCodeCentrale(codesCentraux).catch((e) => {
|
||||
console.error("[api-enrich] métriques réseau KO:", (e as Error).message?.slice(0, 160));
|
||||
return new Map<string, NetworkMetricCached>();
|
||||
})
|
||||
: Promise.resolve(new Map<string, NetworkMetricCached>()),
|
||||
codeins.length
|
||||
? pgGetGammesByCodeins(codeins).catch((e) => {
|
||||
console.error("[api-enrich] gammes serveur KO:", (e as Error).message?.slice(0, 160));
|
||||
return new Map<string, string>();
|
||||
})
|
||||
: Promise.resolve(new Map<string, string>()),
|
||||
]);
|
||||
|
||||
return rows.map((row) => {
|
||||
const metrics = row.codeCentrale ? network.get(row.codeCentrale) ?? null : null;
|
||||
// Gamme absente de la base = pas de gamme, et non « inconnue » : on distingue
|
||||
// explicitement par `null` plutôt que de retomber sur la valeur du snapshot.
|
||||
const gammeServeur = gammes.get(row.codein) ?? null;
|
||||
|
||||
const enriched: EnrichedProductRow = {
|
||||
...row,
|
||||
codeGammeServeur: gammeServeur,
|
||||
network: metrics,
|
||||
trend: null, // renseignée plus bas, une fois la série mensuelle connue
|
||||
};
|
||||
|
||||
// `codeGammeInit` porte déjà la sémantique « état serveur » dans la Grille :
|
||||
// on la garde alignée pour que les deux champs ne divergent jamais.
|
||||
if (gammes.size > 0) {
|
||||
enriched.codeGammeInit = gammeServeur as ProductRow["codeGammeInit"];
|
||||
}
|
||||
|
||||
// Les colonnes réseau de la ligne datent du calcul : on les réaligne sur le
|
||||
// cache quand il a quelque chose à dire.
|
||||
if (metrics) {
|
||||
enriched.caReseau = metrics.caReseau;
|
||||
enriched.qteReseau = metrics.qteReseau;
|
||||
enriched.nbMagasinsReseau = metrics.nbMagasinsReseau;
|
||||
enriched.caParMagasinReseau = metrics.caParMagasinReseau;
|
||||
enriched.margePctReseau = metrics.margePctReseau;
|
||||
enriched.qteReseauByMonth = metrics.qteByMonth;
|
||||
enriched.networkFetchedAt = metrics.fetchedAt ?? undefined;
|
||||
}
|
||||
|
||||
// Tendance : mêmes entrées que la colonne « Tendance » de la Grille, à
|
||||
// savoir la série des quantités **et** celle du nombre de magasins vendeurs
|
||||
// — sans la seconde, le calcul se rabat sur les quantités brutes et
|
||||
// confond « vend mieux » avec « est distribué plus largement ».
|
||||
const serie = enriched.qteReseauByMonth;
|
||||
const nbMagByMonth = metrics?.metricsByMonth
|
||||
? Object.fromEntries(
|
||||
Object.entries(metrics.metricsByMonth).map(([mois, m]) => [mois, Number(m?.nbMag) || 0]),
|
||||
)
|
||||
: null;
|
||||
const t = computeNetworkTrend(serie, nbMagByMonth);
|
||||
if (t.hasData) {
|
||||
enriched.trend = {
|
||||
direction: t.direction,
|
||||
pct: t.pct,
|
||||
label: trendLabel(t.pct, t.nouveau),
|
||||
surQteParMagasin: t.surQteParMagasin,
|
||||
nouveau: t.nouveau,
|
||||
monthsUsed: t.values.length,
|
||||
};
|
||||
}
|
||||
|
||||
return enriched;
|
||||
});
|
||||
}
|
||||
+103
-3
@@ -9,7 +9,46 @@
|
||||
* - Tous types → lire qtestock pour reconstruire stock12m
|
||||
*/
|
||||
|
||||
const FF_API_BASE = process.env.FF_API_BASE_URL ?? "https://api.ffnancy.fr";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
|
||||
const CONFIG_FILE = path.join(process.cwd(), "data", ".db-config.json");
|
||||
const FF_API_BASE_DEFAULT = "https://api.ffnancy.fr";
|
||||
const FF_API_BASE_TTL_MS = 30_000;
|
||||
|
||||
let ffApiBaseCache: { value: string; at: number } | null = null;
|
||||
|
||||
/**
|
||||
* URL de l'API FF Nancy, résolue **au moment de l'appel**.
|
||||
*
|
||||
* Ordre de priorité : réglage enregistré dans Paramètres → variable
|
||||
* d'environnement `FF_API_BASE_URL` → valeur par défaut. Auparavant l'URL était
|
||||
* figée au chargement du module depuis l'environnement seul : impossible de la
|
||||
* corriger depuis l'application quand le serveur changeait d'adresse.
|
||||
*
|
||||
* Le résultat est mémorisé 30 s pour ne pas relire le fichier à chaque requête.
|
||||
*/
|
||||
async function getFfApiBase(): Promise<string> {
|
||||
if (ffApiBaseCache && Date.now() - ffApiBaseCache.at < FF_API_BASE_TTL_MS) {
|
||||
return ffApiBaseCache.value;
|
||||
}
|
||||
let configured: string | undefined;
|
||||
try {
|
||||
const raw = await fs.readFile(CONFIG_FILE, "utf-8");
|
||||
const parsed = JSON.parse(raw) as { ffApiBaseUrl?: string };
|
||||
configured = parsed?.ffApiBaseUrl?.trim() || undefined;
|
||||
} catch {
|
||||
// Fichier absent ou illisible : on retombe sur l'environnement.
|
||||
}
|
||||
const value = (configured || process.env.FF_API_BASE_URL || FF_API_BASE_DEFAULT).replace(/\/+$/, "");
|
||||
ffApiBaseCache = { value, at: Date.now() };
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Vide le cache — appelé après un enregistrement dans Paramètres. */
|
||||
export function resetFfApiBaseCache(): void {
|
||||
ffApiBaseCache = null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types API (shapes des réponses brutes)
|
||||
@@ -65,9 +104,62 @@ export interface FfCommande {
|
||||
qtecde: number;
|
||||
}
|
||||
|
||||
export interface FfSyncTable {
|
||||
nom: string;
|
||||
derniereSync: string;
|
||||
nbLignes?: number;
|
||||
/** "ok" ou état d'erreur renvoyé par la synchronisation. */
|
||||
statut?: string;
|
||||
erreur?: string | null;
|
||||
}
|
||||
|
||||
export interface FfSyncStatus {
|
||||
lastSync: string;
|
||||
tables: { nom: string; derniereSync: string; nbLignes?: number }[];
|
||||
tables: FfSyncTable[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise la réponse de `/api/sync/status`.
|
||||
*
|
||||
* L'API renvoie `{ sync: [{ table_name, last_sync, rows_synced, status, error_msg }] }`,
|
||||
* alors que l'application attendait `{ lastSync, tables: [{ nom, derniereSync, nbLignes }] }`.
|
||||
* Les champs lus n'existaient donc pas : le panneau des Paramètres affichait une
|
||||
* coquille vide sans la moindre erreur, puisque la requête HTTP réussissait.
|
||||
*
|
||||
* Les deux formes sont acceptées ici, pour rester compatible si l'API évolue.
|
||||
*/
|
||||
export function normalizeSyncStatus(raw: unknown): FfSyncStatus | null {
|
||||
if (!raw || typeof raw !== "object") return null;
|
||||
const d = raw as Record<string, unknown>;
|
||||
|
||||
// Forme déjà attendue par l'application.
|
||||
if (Array.isArray(d.tables)) {
|
||||
return {
|
||||
lastSync: String(d.lastSync ?? ""),
|
||||
tables: d.tables as FfSyncTable[],
|
||||
};
|
||||
}
|
||||
|
||||
// Forme réelle de l'API FF Nancy.
|
||||
const sync = Array.isArray(d.sync) ? (d.sync as Array<Record<string, unknown>>) : null;
|
||||
if (!sync) return null;
|
||||
|
||||
const tables: FfSyncTable[] = sync.map((t) => ({
|
||||
nom: String(t.table_name ?? t.nom ?? "—"),
|
||||
derniereSync: String(t.last_sync ?? t.derniereSync ?? ""),
|
||||
nbLignes: Number(t.rows_synced ?? t.nbLignes ?? 0) || 0,
|
||||
statut: t.status != null ? String(t.status) : undefined,
|
||||
erreur: t.error_msg != null ? String(t.error_msg) : null,
|
||||
}));
|
||||
|
||||
// Pas de date globale dans la réponse : on prend la plus récente des tables.
|
||||
const lastSync = tables
|
||||
.map((t) => t.derniereSync)
|
||||
.filter(Boolean)
|
||||
.sort()
|
||||
.at(-1) ?? "";
|
||||
|
||||
return { lastSync, tables };
|
||||
}
|
||||
|
||||
/** Stock agrégé sur tous les sites pour un codein */
|
||||
@@ -212,6 +304,7 @@ function extractFouNom(f: any): string {
|
||||
export async function getFournisseursFromApi(
|
||||
search?: string
|
||||
): Promise<{ code: string; nom: string }[]> {
|
||||
const FF_API_BASE = await getFfApiBase();
|
||||
try {
|
||||
const url = search
|
||||
? `${FF_API_BASE}/api/fournisseurs?search=${encodeURIComponent(search)}&limit=500`
|
||||
@@ -242,6 +335,7 @@ export async function getFournisseursFromApi(
|
||||
export async function getArticlesByFournisseur(
|
||||
codefou: string
|
||||
): Promise<FfArticle[]> {
|
||||
const FF_API_BASE = await getFfApiBase();
|
||||
const raw = await fetchAllPages<unknown>(
|
||||
(page) => `${FF_API_BASE}/api/articles?codefou=${encodeURIComponent(codefou)}&page=${page}&limit=500`,
|
||||
extractList,
|
||||
@@ -272,6 +366,7 @@ export async function getMouvementsByFournisseur(
|
||||
dateDebut: string,
|
||||
dateFin: string
|
||||
): Promise<FfMouvement[]> {
|
||||
const FF_API_BASE = await getFfApiBase();
|
||||
const raw = await fetchAllPages<unknown>(
|
||||
(page) =>
|
||||
`${FF_API_BASE}/api/mouvements/articles?codefou=${encodeURIComponent(codefou)}&dateDebut=${dateDebut}&dateFin=${dateFin}&page=${page}&limit=1000`,
|
||||
@@ -301,6 +396,7 @@ export async function getMouvementsForDate(
|
||||
dateDebut: string,
|
||||
dateFin: string
|
||||
): Promise<FfMouvement[]> {
|
||||
const FF_API_BASE = await getFfApiBase();
|
||||
const raw = await fetchAllPages<unknown>(
|
||||
(page) =>
|
||||
`${FF_API_BASE}/api/mouvements/articles?dateDebut=${dateDebut}&dateFin=${dateFin}&page=${page}&limit=1000`,
|
||||
@@ -362,6 +458,7 @@ export async function getMensuelByArticles(
|
||||
batchSize = 50
|
||||
): Promise<Map<string, FfMensuelEntry[]>> {
|
||||
const result = new Map<string, FfMensuelEntry[]>();
|
||||
const FF_API_BASE = await getFfApiBase();
|
||||
|
||||
for (let i = 0; i < articles.length; i += batchSize) {
|
||||
const batch = articles.slice(i, i + batchSize);
|
||||
@@ -448,6 +545,7 @@ export async function getReferentielByArticles(
|
||||
batchSize = 50
|
||||
): Promise<Map<string, FfReferentiel>> {
|
||||
const result = new Map<string, FfReferentiel>();
|
||||
const FF_API_BASE = await getFfApiBase();
|
||||
|
||||
for (let i = 0; i < articles.length; i += batchSize) {
|
||||
const batch = articles.slice(i, i + batchSize);
|
||||
@@ -479,6 +577,7 @@ export async function getCommandesByFournisseur(
|
||||
codefou: string
|
||||
): Promise<Map<string, number>> {
|
||||
const result = new Map<string, number>();
|
||||
const FF_API_BASE = await getFfApiBase();
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${FF_API_BASE}/api/commandes/articles?codefou=${encodeURIComponent(codefou)}`,
|
||||
@@ -502,9 +601,10 @@ export async function getCommandesByFournisseur(
|
||||
|
||||
export async function getSyncStatus(): Promise<FfSyncStatus | null> {
|
||||
try {
|
||||
const FF_API_BASE = await getFfApiBase();
|
||||
const res = await fetch(`${FF_API_BASE}/api/sync/status`, { cache: "no-store" });
|
||||
if (!res.ok) return null;
|
||||
return await res.json();
|
||||
return normalizeSyncStatus(await res.json());
|
||||
} catch (err) {
|
||||
console.error("[api-ff] getSyncStatus error:", err);
|
||||
return null;
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* CollectFlow — Enveloppe de réponse de l'API publique `/api/v1`.
|
||||
*
|
||||
* Une forme unique pour tout : `{ data, pagination?, meta }` en succès,
|
||||
* `{ error: { code, message } }` en échec, avec de vrais statuts HTTP.
|
||||
* Les consommateurs (scripts, IA, outils externes) peuvent ainsi traiter
|
||||
* toutes les réponses de la même manière.
|
||||
*/
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export type ApiErrorCode =
|
||||
| "unauthorized"
|
||||
| "forbidden"
|
||||
| "bad_request"
|
||||
| "not_found"
|
||||
| "not_ready"
|
||||
| "internal_error";
|
||||
|
||||
const STATUS_BY_CODE: Record<ApiErrorCode, number> = {
|
||||
unauthorized: 401,
|
||||
forbidden: 403,
|
||||
bad_request: 400,
|
||||
not_found: 404,
|
||||
not_ready: 202,
|
||||
internal_error: 500,
|
||||
};
|
||||
|
||||
export interface Pagination {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
/** `true` s'il reste des pages à lire — évite d'avoir à comparer page et totalPages. */
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
export function buildPagination(page: number, limit: number, total: number): Pagination {
|
||||
const totalPages = Math.max(1, Math.ceil(total / limit));
|
||||
// `hasMore` explicite : un agent qui doit comparer page et totalPages oublie
|
||||
// souvent de le faire et conclut à tort qu'il a tout récupéré.
|
||||
return { page, limit, total, totalPages, hasMore: page < totalPages };
|
||||
}
|
||||
|
||||
/** Réponse de succès. `meta` porte la fraîcheur de la donnée (`computedAt`). */
|
||||
export function ok<T>(
|
||||
data: T,
|
||||
opts: { pagination?: Pagination; meta?: Record<string, unknown> } = {},
|
||||
): NextResponse {
|
||||
return NextResponse.json({
|
||||
data,
|
||||
...(opts.pagination ? { pagination: opts.pagination } : {}),
|
||||
meta: { ...(opts.meta ?? {}) },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Réponse d'erreur. `details` sert notamment aux erreurs de validation zod.
|
||||
*
|
||||
* Note : `not_ready` renvoie un 202 — ce n'est pas un échec mais une donnée pas
|
||||
* encore calculée (voir /api/v1/grid sur un fournisseur jamais ouvert).
|
||||
*/
|
||||
export function fail(
|
||||
code: ApiErrorCode,
|
||||
message: string,
|
||||
details?: unknown,
|
||||
): NextResponse {
|
||||
return NextResponse.json(
|
||||
{ error: { code, message, ...(details !== undefined ? { details } : {}) } },
|
||||
{ status: STATUS_BY_CODE[code] },
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* CollectFlow — Validation des paramètres de l'API `/api/v1` (zod).
|
||||
*
|
||||
* Toute entrée utilisateur passe par ici : bornes de pagination, listes blanches
|
||||
* de tri, projection de champs. Les erreurs remontent en `400 bad_request` avec
|
||||
* le détail zod, pour que l'appelant sache quoi corriger.
|
||||
*/
|
||||
|
||||
import { z } from "zod";
|
||||
import { GRID_SORT_KEYS, type GridSortKey } from "@/lib/grid-store";
|
||||
|
||||
const SORT_KEYS = GRID_SORT_KEYS as readonly string[];
|
||||
|
||||
export const paginationShape = {
|
||||
page: z.coerce.number().int().min(1).default(1),
|
||||
/**
|
||||
* Aucun plafond : l'appelant décide de la taille qu'il veut recevoir.
|
||||
* Le défaut reste modeste (100) pour que les endpoints de recherche, non
|
||||
* bornés par un fournisseur, ne renvoient pas tout le catalogue par accident.
|
||||
* `/grid` redéfinit ce paramètre : sans `limit`, il renvoie tout.
|
||||
*/
|
||||
limit: z.coerce.number().int().min(1).default(100),
|
||||
};
|
||||
|
||||
const sortShape = {
|
||||
sort: z
|
||||
.string()
|
||||
.refine((v) => SORT_KEYS.includes(v), {
|
||||
message: `Tri inconnu. Valeurs acceptées : ${SORT_KEYS.join(", ")}`,
|
||||
})
|
||||
.optional(),
|
||||
order: z.enum(["asc", "desc"]).default("desc"),
|
||||
/** Liste de champs de ProductRow à conserver, séparés par des virgules. */
|
||||
fields: z.string().optional(),
|
||||
/**
|
||||
* `1` (défaut) : relit les métriques réseau Qlik et la gamme serveur au moment
|
||||
* de l'appel. `0` : sert l'instantané brut, un peu plus rapide.
|
||||
*/
|
||||
enrich: z.enum(["0", "1"]).default("1"),
|
||||
};
|
||||
|
||||
/** `/api/v1/grid` — le fournisseur est obligatoire (recherche transversale : /products/search). */
|
||||
export const gridQuerySchema = z.object({
|
||||
fournisseur: z.string().min(1, "Paramètre 'fournisseur' requis"),
|
||||
gamme: z.string().min(1).optional(),
|
||||
code1: z.string().min(1).optional(),
|
||||
code2: z.string().min(1).optional(),
|
||||
code3: z.string().min(1).optional(),
|
||||
/**
|
||||
* Préfixe de nomenclature (« 32 », « 3202 », « 320211 »), tel que renvoyé par
|
||||
* /api/v1/nomenclatures. À préférer à code1/code2, qui peuvent être vides
|
||||
* quand la hiérarchie n'a pas pu être remontée depuis la base FF.
|
||||
*/
|
||||
nomenclature: z.string().min(1).max(20).regex(/^[0-9]+$/, "La nomenclature doit être un préfixe numérique (ex. 32, 3202, 320211)").optional(),
|
||||
search: z.string().min(1).optional(),
|
||||
/**
|
||||
* `1` (défaut) : si le fournisseur n'a pas encore d'instantané, l'API le calcule
|
||||
* à la demande au lieu de répondre `not_ready`. C'est ce qui permet à une app ou
|
||||
* à un agent externe d'interroger n'importe quel fournisseur sans qu'un humain
|
||||
* ait ouvert la Grille avant. Le premier appel est alors plus lent (plusieurs
|
||||
* secondes) ; les suivants sont servis depuis l'instantané.
|
||||
* `0` : comportement strict, échoue vite en `not_ready`.
|
||||
*/
|
||||
compute: z.enum(["0", "1"]).default("1"),
|
||||
...sortShape,
|
||||
...paginationShape,
|
||||
/**
|
||||
* **Aucun plafond, et aucune valeur par défaut** : sans `limit`, `/grid`
|
||||
* renvoie *toutes* les lignes du fournisseur.
|
||||
*
|
||||
* C'est l'objectif de cet endpoint — qu'une app ou un agent récupère un
|
||||
* fournisseur entier en un appel. Paginer par défaut condamnait l'appelant à
|
||||
* boucler, ce que les agents font mal : ils s'arrêtent à la première page et
|
||||
* raisonnent sur un catalogue tronqué.
|
||||
*
|
||||
* Réponse volumineuse : une ligne complète porte les séries mensuelles et les
|
||||
* ventilations par magasin. Combiner avec `fields` est vivement conseillé.
|
||||
*/
|
||||
limit: z.coerce.number().int().min(1).optional(),
|
||||
});
|
||||
|
||||
/** `/api/v1/products/search` — `fournisseur` facultatif : c'est la recherche transversale. */
|
||||
export const productSearchSchema = z.object({
|
||||
q: z.string().min(1, "Paramètre 'q' requis").max(120),
|
||||
fournisseur: z.string().min(1).optional(),
|
||||
gamme: z.string().min(1).optional(),
|
||||
...sortShape,
|
||||
...paginationShape,
|
||||
});
|
||||
|
||||
export const fournisseursQuerySchema = z.object({
|
||||
search: z.string().min(1).optional(),
|
||||
/** true = uniquement les fournisseurs déjà présents dans l'instantané de grille. */
|
||||
withData: z.enum(["0", "1"]).optional(),
|
||||
});
|
||||
|
||||
/** `/api/v1/nomenclatures` — inventaire des postes d'un fournisseur. */
|
||||
export const nomenclaturesQuerySchema = z.object({
|
||||
fournisseur: z.string().min(1, "Paramètre 'fournisseur' requis"),
|
||||
/**
|
||||
* 1 = univers, 2 = famille, 3 = sous-famille (selon la nomenclature FF).
|
||||
* Le `transform` ne fait que redonner le type littéral : les bornes ont déjà
|
||||
* été validées juste avant, la valeur ne peut être que 1, 2 ou 3.
|
||||
*/
|
||||
niveau: z.coerce.number().int().min(1).max(3).default(1).transform((n) => n as 1 | 2 | 3),
|
||||
/** Restreint à un poste parent : un code1 pour niveau=2, un code2 pour niveau=3. */
|
||||
parent: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
export const productDetailSchema = z.object({
|
||||
fournisseur: z.string().min(1).optional(),
|
||||
/**
|
||||
* `1` (défaut) : calcule l'instantané à la demande si le produit n'y figure pas
|
||||
* encore. Nécessite `fournisseur`, le calcul se faisant par lot fournisseur.
|
||||
*/
|
||||
compute: z.enum(["0", "1"]).default("1"),
|
||||
});
|
||||
|
||||
/**
|
||||
* Projection de champs : `?fields=codein,libelle1,totalCa`.
|
||||
*
|
||||
* Sert à alléger la réponse — un `ProductRow` complet porte les séries mensuelles
|
||||
* et les ventilations par magasin, ce qui est volumineux sur plusieurs centaines de
|
||||
* lignes. `codein` est toujours conservé pour que la ligne reste identifiable.
|
||||
*/
|
||||
export function pickFields<T extends object>(row: T, fields?: string): Partial<T> {
|
||||
if (!fields) return row;
|
||||
const wanted = new Set(
|
||||
fields.split(",").map((f) => f.trim()).filter(Boolean),
|
||||
);
|
||||
if (wanted.size === 0) return row;
|
||||
wanted.add("codein");
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const key of wanted) {
|
||||
if (key in row) out[key] = (row as unknown as Record<string, unknown>)[key];
|
||||
}
|
||||
return out as Partial<T>;
|
||||
}
|
||||
|
||||
/** Normalise le tri validé vers la clé attendue par grid-store. */
|
||||
export function toSortKey(sort?: string): GridSortKey | undefined {
|
||||
return sort as GridSortKey | undefined;
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
/**
|
||||
* CollectFlow — Instantané persisté de la grille (table `grid_rows`).
|
||||
*
|
||||
* `getProductRows()` reconstruit la grille en direct (6 requêtes SQL sur la base
|
||||
* miroir FF + jointure Qlik) et ne la garde qu'en mémoire 10 minutes. L'API `/api/v1`
|
||||
* ne doit **jamais** déclencher ce calcul : elle lit cette table, remplie en effet de
|
||||
* bord quand quelqu'un ouvre la Grille.
|
||||
*
|
||||
* On n'introduit donc aucun calcul supplémentaire — on arrête de jeter un résultat
|
||||
* qui existe déjà, et il survit désormais aux redémarrages.
|
||||
*/
|
||||
|
||||
import "server-only";
|
||||
|
||||
import { db } from "@/db";
|
||||
import { gridRows } from "@/db/schema";
|
||||
import { and, asc, desc, eq, ilike, or, sql, lt, type SQL } from "drizzle-orm";
|
||||
import type { ProductRow } from "@/types/grid";
|
||||
|
||||
/** Colonnes autorisées au tri — liste blanche, jamais l'entrée utilisateur brute. */
|
||||
const SORTABLE = {
|
||||
libelle1: gridRows.libelle1,
|
||||
codein: gridRows.codein,
|
||||
codeFournisseur: gridRows.codeFournisseur,
|
||||
codeGamme: gridRows.codeGamme,
|
||||
totalCa: gridRows.totalCa,
|
||||
totalQuantite: gridRows.totalQuantite,
|
||||
totalMarge: gridRows.totalMarge,
|
||||
tauxMarge: gridRows.tauxMarge,
|
||||
stockActuel: gridRows.stockActuel,
|
||||
caReseau: gridRows.caReseau,
|
||||
qteReseau: gridRows.qteReseau,
|
||||
nbMagasinsReseau: gridRows.nbMagasinsReseau,
|
||||
caParMagasinReseau: gridRows.caParMagasinReseau,
|
||||
computedAt: gridRows.computedAt,
|
||||
} as const;
|
||||
|
||||
export type GridSortKey = keyof typeof SORTABLE;
|
||||
export const GRID_SORT_KEYS = Object.keys(SORTABLE) as GridSortKey[];
|
||||
|
||||
export interface GridQuery {
|
||||
codeFournisseur?: string;
|
||||
gamme?: string;
|
||||
code1?: string;
|
||||
code2?: string;
|
||||
code3?: string;
|
||||
/** Préfixe de nomenclature (« 32 », « 3202 », « 320211 ») — filtre sur code3. */
|
||||
nomenclature?: string;
|
||||
/** Recherche libre : libellé, codein, GTIN, référence, code centrale. */
|
||||
search?: string;
|
||||
sort?: GridSortKey;
|
||||
order?: "asc" | "desc";
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface GridQueryResult {
|
||||
rows: ProductRow[];
|
||||
total: number;
|
||||
/** Calcul le plus ancien parmi les lignes renvoyées — fraîcheur de la donnée. */
|
||||
computedAt: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persiste les lignes calculées pour un fournisseur.
|
||||
*
|
||||
* Stratégie : toutes les lignes du lot portent le même `computedAt`, puis on supprime
|
||||
* celles du fournisseur restées sur un `computedAt` antérieur. Cela purge les articles
|
||||
* disparus du catalogue sans avoir à passer des centaines de codein en paramètres.
|
||||
*/
|
||||
export async function upsertGridRows(codeFournisseur: string, rows: ProductRow[]): Promise<number> {
|
||||
if (!codeFournisseur || rows.length === 0) return 0;
|
||||
const computedAt = new Date();
|
||||
|
||||
const values = rows
|
||||
.filter((r) => r.codein)
|
||||
.map((r) => ({
|
||||
codeFournisseur,
|
||||
codein: r.codein,
|
||||
nomFournisseur: r.nomFournisseur ?? null,
|
||||
libelle1: r.libelle1 ?? null,
|
||||
gtin: r.gtin ?? null,
|
||||
reference: r.reference ?? null,
|
||||
codeCentrale: r.codeCentrale ?? null,
|
||||
code1: r.code1 ?? null,
|
||||
code2: r.code2 ?? null,
|
||||
code3: r.code3 ?? null,
|
||||
codeGamme: r.codeGamme ?? null,
|
||||
codeGammeInit: r.codeGammeInit ?? null,
|
||||
totalCa: r.totalCa != null ? String(r.totalCa) : null,
|
||||
totalQuantite: r.totalQuantite != null ? String(r.totalQuantite) : null,
|
||||
totalMarge: r.totalMarge != null ? String(r.totalMarge) : null,
|
||||
tauxMarge: r.tauxMarge != null ? String(r.tauxMarge) : null,
|
||||
stockActuel: r.stockActuel != null ? String(r.stockActuel) : null,
|
||||
caReseau: r.caReseau != null ? String(r.caReseau) : null,
|
||||
qteReseau: r.qteReseau != null ? String(r.qteReseau) : null,
|
||||
nbMagasinsReseau: r.nbMagasinsReseau ?? null,
|
||||
caParMagasinReseau: r.caParMagasinReseau != null ? String(r.caParMagasinReseau) : null,
|
||||
margePctReseau: r.margePctReseau != null ? String(r.margePctReseau) : null,
|
||||
payload: r,
|
||||
computedAt,
|
||||
}));
|
||||
if (values.length === 0) return 0;
|
||||
|
||||
const CHUNK = 200; // payload jsonb volumineux : lots plus petits que pour des scalaires
|
||||
let written = 0;
|
||||
for (let i = 0; i < values.length; i += CHUNK) {
|
||||
const batch = values.slice(i, i + CHUNK);
|
||||
await db
|
||||
.insert(gridRows)
|
||||
.values(batch)
|
||||
.onConflictDoUpdate({
|
||||
target: [gridRows.codeFournisseur, gridRows.codein],
|
||||
set: {
|
||||
nomFournisseur: sql`excluded.nom_fournisseur`,
|
||||
libelle1: sql`excluded.libelle1`,
|
||||
gtin: sql`excluded.gtin`,
|
||||
reference: sql`excluded.reference`,
|
||||
codeCentrale: sql`excluded.code_centrale`,
|
||||
code1: sql`excluded.code1`,
|
||||
code2: sql`excluded.code2`,
|
||||
code3: sql`excluded.code3`,
|
||||
codeGamme: sql`excluded.code_gamme`,
|
||||
codeGammeInit: sql`excluded.code_gamme_init`,
|
||||
totalCa: sql`excluded.total_ca`,
|
||||
totalQuantite: sql`excluded.total_quantite`,
|
||||
totalMarge: sql`excluded.total_marge`,
|
||||
tauxMarge: sql`excluded.taux_marge`,
|
||||
stockActuel: sql`excluded.stock_actuel`,
|
||||
caReseau: sql`excluded.ca_reseau`,
|
||||
qteReseau: sql`excluded.qte_reseau`,
|
||||
nbMagasinsReseau: sql`excluded.nb_magasins_reseau`,
|
||||
caParMagasinReseau: sql`excluded.ca_par_magasin_reseau`,
|
||||
margePctReseau: sql`excluded.marge_pct_reseau`,
|
||||
payload: sql`excluded.payload`,
|
||||
computedAt: sql`excluded.computed_at`,
|
||||
},
|
||||
});
|
||||
written += batch.length;
|
||||
}
|
||||
|
||||
// Purge des articles qui ne font plus partie du catalogue du fournisseur.
|
||||
await db.delete(gridRows).where(
|
||||
and(eq(gridRows.codeFournisseur, codeFournisseur), lt(gridRows.computedAt, computedAt)),
|
||||
);
|
||||
|
||||
return written;
|
||||
}
|
||||
|
||||
/** Construit la clause WHERE commune à la grille et à la recherche transversale. */
|
||||
function buildWhere(q: GridQuery): SQL | undefined {
|
||||
const clauses: SQL[] = [];
|
||||
if (q.codeFournisseur) clauses.push(eq(gridRows.codeFournisseur, q.codeFournisseur));
|
||||
if (q.gamme) clauses.push(eq(gridRows.codeGamme, q.gamme));
|
||||
if (q.code1) clauses.push(eq(gridRows.code1, q.code1));
|
||||
if (q.code2) clauses.push(eq(gridRows.code2, q.code2));
|
||||
if (q.code3) clauses.push(eq(gridRows.code3, q.code3));
|
||||
// Filtre par **préfixe** de nomenclature : « 32 », « 3202 » ou « 320211 ».
|
||||
// C'est celui à utiliser avec /api/v1/nomenclatures, car il ne dépend pas de
|
||||
// code1/code2 — parfois vides quand la hiérarchie n'a pas pu être remontée.
|
||||
if (q.nomenclature) clauses.push(sql`${gridRows.code3} like ${q.nomenclature + "%"}`);
|
||||
const search = q.search?.trim();
|
||||
if (search) {
|
||||
const pattern = `%${search}%`;
|
||||
const alt = or(
|
||||
ilike(gridRows.libelle1, pattern),
|
||||
ilike(gridRows.codein, pattern),
|
||||
ilike(gridRows.gtin, pattern),
|
||||
ilike(gridRows.reference, pattern),
|
||||
ilike(gridRows.codeCentrale, pattern),
|
||||
);
|
||||
if (alt) clauses.push(alt);
|
||||
}
|
||||
return clauses.length ? and(...clauses) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lit les lignes de grille persistées : filtre, tri et pagination faits en SQL.
|
||||
* Ne déclenche aucun recalcul et ne contacte jamais Qlik.
|
||||
*/
|
||||
export async function queryGridRows(q: GridQuery): Promise<GridQueryResult> {
|
||||
const page = Math.max(1, q.page ?? 1);
|
||||
// `limit` absent = aucune limite : on renvoie toutes les lignes du filtre.
|
||||
// Il y avait ici un Math.min(500, …) qui re-plafonnait en silence, quelle que
|
||||
// soit la valeur demandée — la réponse était tronquée sans que rien ne le dise.
|
||||
const limit = q.limit != null ? Math.max(1, q.limit) : null;
|
||||
const where = buildWhere(q);
|
||||
|
||||
const sortCol = SORTABLE[q.sort ?? "totalCa"] ?? gridRows.totalCa;
|
||||
// NULLS LAST dans les deux sens : un produit sans CA ne doit jamais occuper la tête.
|
||||
const orderBy = q.order === "asc" ? asc(sortCol) : desc(sortCol);
|
||||
|
||||
const [countRow] = await db
|
||||
.select({ total: sql<number>`count(*)::int` })
|
||||
.from(gridRows)
|
||||
.where(where);
|
||||
|
||||
const selection = db
|
||||
.select({ payload: gridRows.payload, computedAt: gridRows.computedAt })
|
||||
.from(gridRows)
|
||||
.where(where)
|
||||
.orderBy(orderBy, asc(gridRows.codein));
|
||||
|
||||
const found = limit != null
|
||||
? await selection.limit(limit).offset((page - 1) * limit)
|
||||
: await selection;
|
||||
|
||||
let oldest: Date | null = null;
|
||||
for (const r of found) {
|
||||
if (r.computedAt && (!oldest || r.computedAt < oldest)) oldest = r.computedAt;
|
||||
}
|
||||
|
||||
return {
|
||||
rows: found.map((r) => r.payload as ProductRow),
|
||||
total: countRow?.total ?? 0,
|
||||
computedAt: oldest ? oldest.toISOString() : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Fiche complète d'un produit. `codeFournisseur` lève l'ambiguïté d'un article multi-fournisseurs. */
|
||||
export async function getGridRowByCodein(
|
||||
codein: string,
|
||||
codeFournisseur?: string,
|
||||
): Promise<{ row: ProductRow; computedAt: string | null } | null> {
|
||||
const clauses: SQL[] = [eq(gridRows.codein, codein)];
|
||||
if (codeFournisseur) clauses.push(eq(gridRows.codeFournisseur, codeFournisseur));
|
||||
|
||||
const [found] = await db
|
||||
.select({ payload: gridRows.payload, computedAt: gridRows.computedAt })
|
||||
.from(gridRows)
|
||||
.where(and(...clauses))
|
||||
.orderBy(desc(gridRows.computedAt))
|
||||
.limit(1);
|
||||
|
||||
if (!found) return null;
|
||||
return {
|
||||
row: found.payload as ProductRow,
|
||||
computedAt: found.computedAt ? found.computedAt.toISOString() : null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Indique si un fournisseur a déjà été calculé au moins une fois.
|
||||
* Sert à répondre `202 not_ready` plutôt que de déclencher un calcul long.
|
||||
*/
|
||||
export async function getGridFreshness(
|
||||
codeFournisseur: string,
|
||||
): Promise<{ rowCount: number; computedAt: string | null } | null> {
|
||||
const [found] = await db
|
||||
.select({
|
||||
rowCount: sql<number>`count(*)::int`,
|
||||
computedAt: sql<Date | null>`max(${gridRows.computedAt})`,
|
||||
})
|
||||
.from(gridRows)
|
||||
.where(eq(gridRows.codeFournisseur, codeFournisseur));
|
||||
|
||||
if (!found || found.rowCount === 0) return null;
|
||||
return {
|
||||
rowCount: found.rowCount,
|
||||
computedAt: found.computedAt ? new Date(found.computedAt).toISOString() : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Fournisseurs présents dans l'instantané, avec leur fraîcheur. */
|
||||
export async function listGridSuppliers(): Promise<
|
||||
Array<{ codeFournisseur: string; nomFournisseur: string | null; rowCount: number; computedAt: string | null }>
|
||||
> {
|
||||
const found = await db
|
||||
.select({
|
||||
codeFournisseur: gridRows.codeFournisseur,
|
||||
nomFournisseur: sql<string | null>`max(${gridRows.nomFournisseur})`,
|
||||
rowCount: sql<number>`count(*)::int`,
|
||||
computedAt: sql<Date | null>`max(${gridRows.computedAt})`,
|
||||
})
|
||||
.from(gridRows)
|
||||
.groupBy(gridRows.codeFournisseur)
|
||||
.orderBy(asc(gridRows.codeFournisseur));
|
||||
|
||||
return found.map((r) => ({
|
||||
codeFournisseur: r.codeFournisseur,
|
||||
nomFournisseur: r.nomFournisseur,
|
||||
rowCount: r.rowCount,
|
||||
computedAt: r.computedAt ? new Date(r.computedAt).toISOString() : null,
|
||||
}));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inventaire des nomenclatures d'un fournisseur
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Un poste de nomenclature, avec son poids — de quoi décider par où découper. */
|
||||
export interface GridNomenclature {
|
||||
code: string;
|
||||
/** Libellé du poste, lu dans le payload (il n'a pas de colonne dédiée). */
|
||||
libelle: string | null;
|
||||
nbArticles: number;
|
||||
totalCa: number;
|
||||
totalQuantite: number;
|
||||
}
|
||||
|
||||
/** Longueur du code pour chaque niveau : « 32 » → « 3202 » → « 320211 ». */
|
||||
export const NOMENCLATURE_LONGUEURS: Record<1 | 2 | 3, number> = { 1: 2, 2: 4, 3: 6 };
|
||||
|
||||
/**
|
||||
* Répartition des articles d'un fournisseur par niveau de nomenclature.
|
||||
*
|
||||
* Sans cela, un appelant qui veut découper un gros fournisseur par nomenclature
|
||||
* est coincé : le filtre existe sur `/grid`, mais rien ne lui dit **quelles**
|
||||
* valeurs existent — il devrait tout télécharger pour les découvrir, ce qui
|
||||
* annule le bénéfice du découpage.
|
||||
*
|
||||
* Les niveaux sont dérivés par **préfixe de `code3`** (codes hiérarchiques à 6
|
||||
* chiffres, de 31xxxx à 40xxxx), et non des colonnes `code1`/`code2`. Celles-ci
|
||||
* ne sont renseignées que si `pgGetNomenclatureByFournisseur` a su remonter la
|
||||
* hiérarchie, ce qui dépend d'une détection heuristique de la colonne parent de
|
||||
* la table `nomenclature` ; quand elle échoue, seul `code3` est rempli. Le
|
||||
* préfixe, lui, est toujours disponible.
|
||||
*
|
||||
* Tout est agrégé en SQL : la réponse fait quelques dizaines de lignes, même pour
|
||||
* un fournisseur de 130 000 articles.
|
||||
*/
|
||||
export async function listGridNomenclatures(
|
||||
codeFournisseur: string,
|
||||
niveau: 1 | 2 | 3,
|
||||
parent?: string,
|
||||
): Promise<GridNomenclature[]> {
|
||||
const longueur = NOMENCLATURE_LONGUEURS[niveau];
|
||||
const codeExpr = sql<string>`left(${gridRows.code3}, ${longueur})`;
|
||||
// Les libellés n'ont pas de colonne dédiée : on les lit dans le payload.
|
||||
// Aux niveaux 1 et 2 ils peuvent être absents (même cause que ci-dessus) ;
|
||||
// le code et les compteurs, eux, restent justes.
|
||||
const libelleKey = niveau === 1 ? "libelleNiveau1" : niveau === 2 ? "libelleNiveau2" : "libelle3";
|
||||
|
||||
const clauses: SQL[] = [
|
||||
eq(gridRows.codeFournisseur, codeFournisseur),
|
||||
sql`${gridRows.code3} is not null and length(${gridRows.code3}) >= ${longueur}`,
|
||||
];
|
||||
// `parent` est lui aussi un préfixe : « 32 » pour descendre au niveau 2.
|
||||
if (parent) clauses.push(sql`${gridRows.code3} like ${parent + "%"}`);
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
code: codeExpr,
|
||||
libelle: sql<string | null>`max(${gridRows.payload} ->> ${libelleKey})`,
|
||||
nbArticles: sql<number>`count(*)::int`,
|
||||
totalCa: sql<number>`coalesce(sum(${gridRows.totalCa}), 0)::float`,
|
||||
totalQuantite: sql<number>`coalesce(sum(${gridRows.totalQuantite}), 0)::float`,
|
||||
})
|
||||
.from(gridRows)
|
||||
.where(and(...clauses))
|
||||
.groupBy(codeExpr)
|
||||
.orderBy(desc(sql`coalesce(sum(${gridRows.totalCa}), 0)`));
|
||||
|
||||
return rows.map((r) => ({
|
||||
code: String(r.code ?? ""),
|
||||
libelle: r.libelle ?? null,
|
||||
nbArticles: Number(r.nbArticles) || 0,
|
||||
totalCa: Number(r.totalCa) || 0,
|
||||
totalQuantite: Number(r.totalQuantite) || 0,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* CollectFlow — Capture des logs serveur d'un job, pour téléchargement.
|
||||
*
|
||||
* POURQUOI. Les extractions Qlik produisent plusieurs milliers de lignes
|
||||
* (`[qlik-pw][page] …`, timings, diagnostics du modèle). Or ces lignes ne
|
||||
* vivent que dans la sortie standard du conteneur : un terminal les tronque,
|
||||
* et un copier-coller ne rapporte que la fin — c'est-à-dire jamais l'entête,
|
||||
* là où se trouvent la carte du modèle Qlik et le choix des champs. Sans elle,
|
||||
* un diagnostic se fait à l'aveugle.
|
||||
*
|
||||
* COMMENT. `console.log/warn/error` est instrumenté UNE fois au chargement du
|
||||
* module. Tant qu'au moins une capture est ouverte, chaque ligne est recopiée
|
||||
* dans les captures ouvertes, puis transmise telle quelle à la console
|
||||
* d'origine (rien n'est retenu, rien ne change dans les logs du conteneur).
|
||||
*
|
||||
* ⚠️ Deux jobs simultanés partagent la sortie standard du process : leurs
|
||||
* lignes ne portent pas d'identifiant permettant de les démêler. Chaque
|
||||
* capture ouverte reçoit donc TOUTES les lignes de la période — mieux vaut un
|
||||
* peu de bruit qu'une ligne manquante dans un journal de diagnostic.
|
||||
*
|
||||
* Les captures sont écrites sur disque (répertoire temporaire) au fil de l'eau :
|
||||
* un job qui fait tomber le process laisse quand même son journal.
|
||||
*/
|
||||
|
||||
import { mkdir, readFile, readdir, stat, unlink, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
/** Répertoire des journaux. Volontairement hors du projet : ce sont des temporaires. */
|
||||
const LOG_DIR = join(tmpdir(), "collectflow-logs");
|
||||
|
||||
/** Au-delà, on arrête d'accumuler : un job en boucle ne doit pas manger la RAM. */
|
||||
const MAX_LIGNES = 200_000;
|
||||
/** Une ligne pathologique (dump d'hypercube) est tronquée plutôt que gardée entière. */
|
||||
const MAX_LONGUEUR_LIGNE = 8_000;
|
||||
/** Nombre de journaux conservés sur disque. */
|
||||
const MAX_FICHIERS = 20;
|
||||
/** Écriture disque au plus toutes les N ms (le journal complet est réécrit). */
|
||||
const FLUSH_MS = 2_000;
|
||||
|
||||
interface Capture {
|
||||
id: string;
|
||||
lignes: string[];
|
||||
tronque: boolean;
|
||||
debut: number;
|
||||
flushEnCours: boolean;
|
||||
flushDemande: boolean;
|
||||
dernierFlush: number;
|
||||
}
|
||||
|
||||
const captures = new Map<string, Capture>();
|
||||
|
||||
/** Nom de fichier sûr : l'id vient d'un job interne, on le verrouille quand même. */
|
||||
function fichierPour(id: string): string {
|
||||
return join(LOG_DIR, `${id.replace(/[^A-Za-z0-9_.-]/g, "_")}.log`);
|
||||
}
|
||||
|
||||
function horodatage(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
/** Rend une ligne à partir des arguments variadiques de `console.*`. */
|
||||
function formater(args: unknown[]): string {
|
||||
return args
|
||||
.map((a) => {
|
||||
if (typeof a === "string") return a;
|
||||
if (a instanceof Error) return `${a.message}\n${a.stack ?? ""}`;
|
||||
try {
|
||||
return JSON.stringify(a);
|
||||
} catch {
|
||||
return String(a);
|
||||
}
|
||||
})
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
async function flush(capture: Capture): Promise<void> {
|
||||
if (capture.flushEnCours) {
|
||||
capture.flushDemande = true;
|
||||
return;
|
||||
}
|
||||
capture.flushEnCours = true;
|
||||
try {
|
||||
await mkdir(LOG_DIR, { recursive: true });
|
||||
await writeFile(fichierPour(capture.id), capture.lignes.join("\n") + "\n", "utf8");
|
||||
capture.dernierFlush = Date.now();
|
||||
} catch {
|
||||
// Un journal de diagnostic ne doit jamais faire échouer le job qu'il observe.
|
||||
} finally {
|
||||
capture.flushEnCours = false;
|
||||
if (capture.flushDemande) {
|
||||
capture.flushDemande = false;
|
||||
void flush(capture);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function ajouter(ligne: string): void {
|
||||
if (captures.size === 0) return;
|
||||
const texte = ligne.length > MAX_LONGUEUR_LIGNE
|
||||
? ligne.slice(0, MAX_LONGUEUR_LIGNE) + " …[ligne tronquée]"
|
||||
: ligne;
|
||||
const prefixe = `[${horodatage()}] `;
|
||||
for (const capture of captures.values()) {
|
||||
if (capture.lignes.length >= MAX_LIGNES) {
|
||||
if (!capture.tronque) {
|
||||
capture.tronque = true;
|
||||
capture.lignes.push(`${prefixe}[log-capture] limite de ${MAX_LIGNES} lignes atteinte — suite non enregistrée`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
capture.lignes.push(prefixe + texte);
|
||||
if (Date.now() - capture.dernierFlush >= FLUSH_MS) void flush(capture);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Instrumente `console` une seule fois, même en développement où le module peut
|
||||
* être réévalué à chaud (le drapeau est porté par `globalThis`).
|
||||
*/
|
||||
function instrumenterConsole(): void {
|
||||
const g = globalThis as typeof globalThis & { __cfLogCaptureInstalle?: boolean };
|
||||
if (g.__cfLogCaptureInstalle) return;
|
||||
g.__cfLogCaptureInstalle = true;
|
||||
|
||||
const methodes = ["log", "warn", "error", "info"] as const;
|
||||
for (const methode of methodes) {
|
||||
const origine = console[methode].bind(console);
|
||||
console[methode] = (...args: unknown[]) => {
|
||||
try {
|
||||
ajouter(formater(args));
|
||||
} catch {
|
||||
// idem : jamais au détriment du log réel
|
||||
}
|
||||
origine(...args);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
instrumenterConsole();
|
||||
|
||||
/** Supprime les journaux les plus anciens au-delà de `MAX_FICHIERS`. */
|
||||
async function purger(): Promise<void> {
|
||||
try {
|
||||
const noms = (await readdir(LOG_DIR)).filter((n) => n.endsWith(".log"));
|
||||
if (noms.length <= MAX_FICHIERS) return;
|
||||
const avecDate = await Promise.all(
|
||||
noms.map(async (nom) => {
|
||||
const s = await stat(join(LOG_DIR, nom)).catch(() => null);
|
||||
return { nom, mtime: s?.mtimeMs ?? 0 };
|
||||
}),
|
||||
);
|
||||
avecDate.sort((a, b) => b.mtime - a.mtime);
|
||||
for (const { nom } of avecDate.slice(MAX_FICHIERS)) {
|
||||
await unlink(join(LOG_DIR, nom)).catch(() => undefined);
|
||||
}
|
||||
} catch {
|
||||
// purge best-effort
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ouvre une capture. Toutes les lignes écrites sur la console à partir de
|
||||
* maintenant (par n'importe quel code du process, cf. avertissement en tête)
|
||||
* y sont recopiées jusqu'à `stopCapture`.
|
||||
*/
|
||||
export function startCapture(id: string, entete?: string): void {
|
||||
const capture: Capture = {
|
||||
id,
|
||||
lignes: [],
|
||||
tronque: false,
|
||||
debut: Date.now(),
|
||||
flushEnCours: false,
|
||||
flushDemande: false,
|
||||
dernierFlush: 0,
|
||||
};
|
||||
captures.set(id, capture);
|
||||
capture.lignes.push(`[${horodatage()}] [log-capture] début de capture ${id}`);
|
||||
if (entete) capture.lignes.push(`[${horodatage()}] [log-capture] ${entete}`);
|
||||
void flush(capture);
|
||||
void purger();
|
||||
}
|
||||
|
||||
/** Ferme la capture et écrit son état final sur disque. */
|
||||
export async function stopCapture(id: string, pied?: string): Promise<void> {
|
||||
const capture = captures.get(id);
|
||||
if (!capture) return;
|
||||
const duree = Math.round((Date.now() - capture.debut) / 1000);
|
||||
capture.lignes.push(`[${horodatage()}] [log-capture] fin de capture ${id} — ${capture.lignes.length} lignes, ${duree} s`);
|
||||
if (pied) capture.lignes.push(`[${horodatage()}] [log-capture] ${pied}`);
|
||||
captures.delete(id);
|
||||
await flush(capture);
|
||||
}
|
||||
|
||||
/**
|
||||
* Contenu d'un journal : la capture en cours si elle est ouverte, sinon le
|
||||
* fichier laissé sur disque. `null` si aucun des deux n'existe.
|
||||
*/
|
||||
export async function readCapture(id: string): Promise<string | null> {
|
||||
const capture = captures.get(id);
|
||||
if (capture) return capture.lignes.join("\n") + "\n";
|
||||
try {
|
||||
return await readFile(fichierPour(id), "utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Journaux disponibles, du plus récent au plus ancien. */
|
||||
export async function listCaptures(): Promise<Array<{ id: string; octets: number; modifieLe: string; enCours: boolean }>> {
|
||||
const resultats: Array<{ id: string; octets: number; modifieLe: string; enCours: boolean }> = [];
|
||||
try {
|
||||
for (const nom of await readdir(LOG_DIR)) {
|
||||
if (!nom.endsWith(".log")) continue;
|
||||
const s = await stat(join(LOG_DIR, nom)).catch(() => null);
|
||||
if (!s) continue;
|
||||
const id = nom.slice(0, -4);
|
||||
resultats.push({
|
||||
id,
|
||||
octets: s.size,
|
||||
modifieLe: new Date(s.mtimeMs).toISOString(),
|
||||
enCours: captures.has(id),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// répertoire pas encore créé
|
||||
}
|
||||
return resultats.sort((a, b) => b.modifieLe.localeCompare(a.modifieLe));
|
||||
}
|
||||
+1050
-71
File diff suppressed because it is too large.
Load diff
+136
-12
@@ -19,6 +19,20 @@
|
||||
import httpntlm from "httpntlm";
|
||||
import { WebSocket } from "ws";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { buildGridNetworkQlikDateFilter, chunkSerials, type QlikDateFilter } from "@/lib/qlik-date-range";
|
||||
|
||||
/** Lit les réglages Qlik depuis data/.db-config.json (configurés dans Paramètres). */
|
||||
function readQlikFileConfig(): { qlikHost?: string; qlikUser?: string; qlikPassword?: string } {
|
||||
try {
|
||||
const raw = readFileSync(join(process.cwd(), "data", ".db-config.json"), "utf-8");
|
||||
const c = JSON.parse(raw);
|
||||
return { qlikHost: c.qlikHost, qlikUser: c.qlikUser, qlikPassword: c.qlikPassword };
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Config (env) — defauts issus de la discovery
|
||||
@@ -35,22 +49,31 @@ export interface QlikConfig {
|
||||
measCaId: string; // master measure "CA N"
|
||||
measQteId: string; // master measure "Quantite N"
|
||||
measNbMagId: string; // master measure "Magasin Ventes Nb N"
|
||||
measCaMagId: string; // master measure "CA par Magasin N"
|
||||
measMargePctId: string; // master measure "Marge % N"
|
||||
tlsInsecure: boolean;
|
||||
timeoutMs: number;
|
||||
}
|
||||
|
||||
export function getQlikConfig(): QlikConfig {
|
||||
// Priorité : réglages UI (data/.db-config.json) > env > défaut
|
||||
const f = readQlikFileConfig();
|
||||
return {
|
||||
host: process.env.QLIK_HOST ?? "reporting-magasins.lafoirfouille.fr",
|
||||
user: process.env.QLIK_USER ?? "FFSCH",
|
||||
password: process.env.QLIK_PWD ?? "",
|
||||
host: f.qlikHost || process.env.QLIK_HOST || "reporting-magasins.lafoirfouille.fr",
|
||||
user: f.qlikUser || process.env.QLIK_USER || "FFSCH",
|
||||
password: f.qlikPassword || process.env.QLIK_PWD || "",
|
||||
domain: process.env.QLIK_DOMAIN ?? "",
|
||||
workstation: process.env.QLIK_WORKSTATION ?? "",
|
||||
appNetwork: process.env.QLIK_APP_NETWORK ?? "9872ee6e-d64a-4b43-984a-076bf1f7f647",
|
||||
dimCodeArticleId: process.env.QLIK_DIM_CODE_ARTICLE_ID ?? "fcd239e5-288b-4830-a047-0e3d7665d971",
|
||||
measCaId: process.env.QLIK_MEAS_CA_ID ?? "43a76088-86fa-402e-a80e-0efd7701b3e1",
|
||||
measQteId: process.env.QLIK_MEAS_QTE_ID ?? "7b40caf1-be4b-4811-8d45-50acde33e715",
|
||||
measNbMagId: process.env.QLIK_MEAS_NBMAG_ID ?? "8b63fae5-db2f-4e4c-8618-f3e9d60b6b3b",
|
||||
// qIds engine (master items de l'app "Magasins Vision Consolidée")
|
||||
dimCodeArticleId: process.env.QLIK_DIM_CODE_ARTICLE_ID ?? "yesCP",
|
||||
measCaId: process.env.QLIK_MEAS_CA_ID ?? "JhqJ",
|
||||
measQteId: process.env.QLIK_MEAS_QTE_ID ?? "41516861-5997-4635-8187-3643a2bde422",
|
||||
measNbMagId: process.env.QLIK_MEAS_NBMAG_ID ?? "yNBLjc",
|
||||
// Master GUIDs de l'app "Magasins Vision Consolidée" (9872ee6e). Si l'engine
|
||||
// les refuse (qId court attendu), override via env après discovery.
|
||||
measCaMagId: process.env.QLIK_MEAS_CAMAG_ID ?? "16778c00-a4e0-41c7-ab1c-179810d78172",
|
||||
measMargePctId: process.env.QLIK_MEAS_MARGEPCT_ID ?? "ea1e8feb-49ba-4eb3-8db6-72dcfb4c1222",
|
||||
tlsInsecure: (process.env.QLIK_TLS_INSECURE ?? "true") === "true",
|
||||
timeoutMs: Number(process.env.QLIK_TIMEOUT_MS ?? "60000"),
|
||||
};
|
||||
@@ -61,7 +84,57 @@ export interface NetworkMetric {
|
||||
caReseau: number;
|
||||
qteReseau: number;
|
||||
nbMagasinsReseau: number;
|
||||
/** CA moyen par magasin (productivité normalisée présence) */
|
||||
caParMagasinReseau: number;
|
||||
/** Taux de marge réseau (ratio brut Qlik, ex 0.32 = 32%) */
|
||||
margePctReseau: number;
|
||||
periode?: string;
|
||||
/** Quantité vendue réseau par mois : { "YYYY-MM": qté }. Rempli si sync avec filtre Date. */
|
||||
qteByMonth?: Record<string, number>;
|
||||
/**
|
||||
* Détail mensuel complet : { "YYYY-MM": { qte, ca, nbMag, caMag, margePct } }.
|
||||
*
|
||||
* Le cube mensuel Qlik renvoie déjà ces 5 mesures par (Article Code, Mois).
|
||||
* Lors d'une synchronisation datée validée, les 12 mois complets sont
|
||||
* toujours présents ; un mois sans fait est explicitement représenté à 0.
|
||||
*/
|
||||
metricsByMonth?: Record<string, QlikMonthMetrics>;
|
||||
/**
|
||||
* Libellé de l'article côté Qlik. Renseigné uniquement par la recherche
|
||||
* produit (`qlik-search.ts`), qui interroge la dimension libellé ; la sync
|
||||
* fournisseur ne le connaît pas et le laisse `undefined`.
|
||||
*/
|
||||
libelleReseau?: string;
|
||||
/** Fournisseur de l'article côté Qlik, même origine que `libelleReseau`. */
|
||||
fournisseurReseau?: string;
|
||||
}
|
||||
|
||||
/** Les mesures réseau Qlik pour un produit sur un mois donné. */
|
||||
export interface QlikMonthMetrics {
|
||||
/** Quantité vendue réseau — toujours présente. */
|
||||
qte: number;
|
||||
/** CA réseau du mois. */
|
||||
ca?: number;
|
||||
/** Nombre de magasins ayant vendu le produit ce mois-là. */
|
||||
nbMag?: number;
|
||||
/** CA moyen par magasin sur le mois (mesure Qlik brute). */
|
||||
caMag?: number;
|
||||
/** Taux de marge réseau du mois (ratio brut Qlik, ex 0.32 = 32%). */
|
||||
margePct?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Produit réseau tel que renvoyé par la **recherche** Qlik (page Recherche réseau).
|
||||
*
|
||||
* Différence clé avec `NetworkMetric` : ces produits viennent du catalogue Qlik
|
||||
* du réseau, pas de notre catalogue local. Ils peuvent donc être totalement
|
||||
* inconnus de FF Nancy — d'où le libellé, qui n'est pas récupérable en base.
|
||||
*/
|
||||
export interface NetworkProduct extends NetworkMetric {
|
||||
/** Libellé article tel qu'il apparaît dans Qlik (dimension « Article »). */
|
||||
libelle: string;
|
||||
fournisseur?: string;
|
||||
famille?: string;
|
||||
}
|
||||
|
||||
function makeXrfkey(): string {
|
||||
@@ -153,7 +226,13 @@ function openEngine(appGuid: string, sess: QlikSession, cfg: QlikConfig): Promis
|
||||
const url = `wss://${cfg.host}/app/${encodeURIComponent(appGuid)}?Xrfkey=${sess.xrfkey}`;
|
||||
return new Promise((resolve, reject) => {
|
||||
const ws = new WebSocket(url, {
|
||||
headers: { Cookie: sess.cookie, "X-Qlik-Xrfkey": sess.xrfkey },
|
||||
headers: {
|
||||
Cookie: sess.cookie,
|
||||
"X-Qlik-Xrfkey": sess.xrfkey,
|
||||
// Le proxy Qlik valide l'Origin des websockets (whitelist) — un navigateur l'envoie toujours.
|
||||
Origin: `https://${cfg.host}`,
|
||||
"User-Agent": "Mozilla/5.0",
|
||||
},
|
||||
rejectUnauthorized: !cfg.tlsInsecure,
|
||||
handshakeTimeout: cfg.timeoutMs,
|
||||
});
|
||||
@@ -173,7 +252,14 @@ function openEngine(appGuid: string, sess: QlikSession, cfg: QlikConfig): Promis
|
||||
else p.resolve(msg.result as Record<string, unknown>);
|
||||
}
|
||||
});
|
||||
ws.on("unexpected-response", (_req, res) => { clearTimeout(timer); reject(new Error(`[qlik] ws HTTP ${res.statusCode}`)); });
|
||||
ws.on("unexpected-response", (_req, res) => {
|
||||
clearTimeout(timer);
|
||||
let body = "";
|
||||
res.on("data", (d: Buffer) => (body += d.toString()));
|
||||
res.on("end", () => reject(new Error(
|
||||
`[qlik] ws HTTP ${res.statusCode} server=${res.headers["server"]} wwwauth=${res.headers["www-authenticate"]} body=${body.slice(0, 160)}`,
|
||||
)));
|
||||
});
|
||||
ws.on("error", (e: Error) => { clearTimeout(timer); reject(e); });
|
||||
ws.on("close", () => { for (const p of pending.values()) p.reject(new Error("[qlik] ws ferme")); pending.clear(); });
|
||||
|
||||
@@ -200,8 +286,10 @@ function hyperCubeDef(cfg: QlikConfig, height: number) {
|
||||
{ qLibraryId: cfg.measCaId },
|
||||
{ qLibraryId: cfg.measQteId },
|
||||
{ qLibraryId: cfg.measNbMagId },
|
||||
{ qLibraryId: cfg.measCaMagId },
|
||||
{ qLibraryId: cfg.measMargePctId },
|
||||
],
|
||||
qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 4, qHeight: height }],
|
||||
qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 6, qHeight: height }],
|
||||
qSuppressZero: false,
|
||||
qSuppressMissing: true,
|
||||
},
|
||||
@@ -213,6 +301,7 @@ type Matrix = Array<Array<{ qText?: string; qNum?: number }>>;
|
||||
export async function fetchNetworkMetrics(
|
||||
codeCentraux?: string[],
|
||||
cfg = getQlikConfig(),
|
||||
dateFilter: QlikDateFilter | null = buildGridNetworkQlikDateFilter(),
|
||||
): Promise<Map<string, NetworkMetric>> {
|
||||
if (!cfg.appNetwork) throw new Error("[qlik] QLIK_APP_NETWORK manquant");
|
||||
const wanted = codeCentraux ? new Set(codeCentraux) : null;
|
||||
@@ -224,7 +313,39 @@ export async function fetchNetworkMetrics(
|
||||
const openRes = await conn.rpc("OpenDoc", { qDocName: cfg.appNetwork });
|
||||
const docHandle = (((openRes.qReturn as Record<string, unknown>) ?? {}).qHandle as number) ?? 1;
|
||||
|
||||
const pageHeight = 2500; // 4 colonnes * 2500 = 10000 cellules max
|
||||
// Sélection du champ `Date` sur la fenêtre 12 mois complets glissants
|
||||
// (alignement avec la timeline de la grille). Équivalent à un set
|
||||
// analysis `{<Date={">=…<=…"}>}` appliqué aux master measures.
|
||||
let dateFieldHandle: number | null = null;
|
||||
if (dateFilter) {
|
||||
const gf = await conn.rpc("GetField", { qFieldName: "Date" }, docHandle);
|
||||
dateFieldHandle = (((gf.qReturn as Record<string, unknown>) ?? {}).qHandle as number);
|
||||
if (typeof dateFieldHandle !== "number") {
|
||||
console.warn(`[qlik] champ Date introuvable, extraction sans filtre date`);
|
||||
dateFilter = null;
|
||||
dateFieldHandle = null;
|
||||
} else {
|
||||
const batches = chunkSerials(dateFilter.dailySerials, 500);
|
||||
for (let i = 0; i < batches.length; i++) {
|
||||
const batch = batches[i];
|
||||
const sel = await conn.rpc(
|
||||
"SelectValues",
|
||||
{
|
||||
qFieldValues: batch.map((s) => ({ qNum: s, qText: String(s) })),
|
||||
qToggleMode: false,
|
||||
qSoftLock: true,
|
||||
},
|
||||
dateFieldHandle,
|
||||
);
|
||||
console.log(`[qlik] date filter lot ${i + 1}/${batches.length} (${batch.length} serials) — ${dateFilter.label}`);
|
||||
// Si SelectValues renvoie une erreur qError, on continue quand même :
|
||||
// l'absence de champ Date typé numérique lèverait une erreur Engine explicite.
|
||||
void sel;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const pageHeight = 1400; // 6 colonnes * 1400 = 8400 cellules max
|
||||
const createRes = await conn.rpc("CreateSessionObject", { qProp: hyperCubeDef(cfg, pageHeight) }, docHandle);
|
||||
const objHandle = (((createRes.qReturn as Record<string, unknown>) ?? {}).qHandle as number);
|
||||
|
||||
@@ -232,7 +353,7 @@ export async function fetchNetworkMetrics(
|
||||
for (;;) {
|
||||
const dataRes = await conn.rpc(
|
||||
"GetHyperCubeData",
|
||||
{ qPath: "/qHyperCubeDef", qPages: [{ qTop: top, qLeft: 0, qWidth: 4, qHeight: pageHeight }] },
|
||||
{ qPath: "/qHyperCubeDef", qPages: [{ qTop: top, qLeft: 0, qWidth: 6, qHeight: pageHeight }] },
|
||||
objHandle,
|
||||
);
|
||||
const pages = (dataRes.qDataPages as Array<{ qMatrix?: Matrix }>) ?? [];
|
||||
@@ -248,6 +369,9 @@ export async function fetchNetworkMetrics(
|
||||
caReseau: Number(row[1]?.qNum ?? 0) || 0,
|
||||
qteReseau: Number(row[2]?.qNum ?? 0) || 0,
|
||||
nbMagasinsReseau: Number(row[3]?.qNum ?? 0) || 0,
|
||||
caParMagasinReseau: Number(row[4]?.qNum ?? 0) || 0,
|
||||
margePctReseau: Number(row[5]?.qNum ?? 0) || 0,
|
||||
periode: dateFilter?.label,
|
||||
});
|
||||
}
|
||||
if (matrix.length < pageHeight) break;
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* CollectFlow — Helper de fenêtre temporelle pour Qlik réseau.
|
||||
*
|
||||
* Aligne la timeline d'extraction Qlik réseau sur la timeline de la grille :
|
||||
* - `monthsBack` mois complets glissants (défaut : 12)
|
||||
* - exclut systématiquement le mois courant
|
||||
*
|
||||
* Référence utilisée : `buildLast12MonthsRange()` de `api-ff-client.ts`
|
||||
* (même source que la grille). On duplique la logique ici (sans dépendance
|
||||
* Next.js) pour rester testable en CLI pur Node.
|
||||
*
|
||||
* Qlik référence les dates par un sérial numérique (jours écoulés depuis
|
||||
* l'epoch Qlik 1899-12-30). Repères validés :
|
||||
* 2025-01-01 → 45658
|
||||
* 2025-06-19 → 45827
|
||||
* 2026-01-01 → 46023
|
||||
* 2026-06-19 → 46192
|
||||
*
|
||||
* Le filtre côté Qlik s'applique via une sélection de champ `Date` (skill qlik) :
|
||||
* - valeurs numériques = serials Qlik
|
||||
* - la sélection par défaut (`{<Date={...}>}`) est héritée par toutes
|
||||
* les master measures qui référencent le champ `Date` (cas de l'app
|
||||
* "Magasins Vision Consolidée").
|
||||
*
|
||||
* Pourquoi `SelectValues` sur le champ plutôt que set analysis dans `qDef` ?
|
||||
* - les master items sont référencés par `qLibraryId` (réutilisation sans
|
||||
* devoir dupliquer l'expression)
|
||||
* - `qLibraryId` + `qDef` ensemble n'est pas supporté par le moteur QIX
|
||||
* - sélectionner toutes les dates de la fenêtre via `SelectValues` revient
|
||||
* fonctionnellement à `{<Date={">=…<=…"}>}` pour les mesures qui touchent
|
||||
* ce champ, sans dépendre de l'expression interne des master items
|
||||
*
|
||||
* Pas de secrets ici. Pure fonction utilitaire testable en CLI Node.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Bornes acceptées pour `monthsBack`. On reste sur 1..12 pour ne pas casser
|
||||
* la sémantique "12 mois glissants alignés sur la grille" : une valeur plus
|
||||
* grande n'apporterait pas de signal réseau supplémentaire (la grille
|
||||
* n'expose elle-même que ~12 mois). En-deçà de 1 mois, la fenêtre serait
|
||||
* vide (mois courant exclu) ou incohérente.
|
||||
*/
|
||||
export const QLIK_MONTHS_BACK_MIN = 1;
|
||||
export const QLIK_MONTHS_BACK_MAX = 12;
|
||||
export const QLIK_MONTHS_BACK_DEFAULT = 12;
|
||||
|
||||
/**
|
||||
* Lit `process.env[name]` et renvoie un entier borné entre `min` et `max`.
|
||||
* Renvoie `defaultValue` si la variable est absente / vide / non numérique.
|
||||
*
|
||||
* Volontairement exportée pour être réutilisée par `qlik-playwright.ts` et
|
||||
* `sync/route.ts` (cohérence avec `envNumber` côté Playwright, mais en pur
|
||||
* Node sans dépendance Next.js).
|
||||
*/
|
||||
export function envMonthsBack(name: string, defaultValue: number = QLIK_MONTHS_BACK_DEFAULT): number {
|
||||
const raw = process.env[name];
|
||||
if (raw == null || raw.trim() === "") return defaultValue;
|
||||
const parsed = Number(raw);
|
||||
if (!Number.isFinite(parsed)) return defaultValue;
|
||||
const clamped = Math.min(QLIK_MONTHS_BACK_MAX, Math.max(QLIK_MONTHS_BACK_MIN, Math.trunc(parsed)));
|
||||
return clamped;
|
||||
}
|
||||
|
||||
/** Epoch Qlik = 1899-12-30 (équivalent Excel/Lotus 1-2-3). */
|
||||
const QLIK_EPOCH_MS = Date.UTC(1899, 11, 30); // 1899-12-30T00:00:00Z
|
||||
|
||||
/** Repères de validation (cf. skill qlik). */
|
||||
export const QLIK_DATE_ANCHORS: ReadonlyArray<{ iso: string; serial: number }> = [
|
||||
{ iso: "2025-01-01", serial: 45658 },
|
||||
{ iso: "2025-06-19", serial: 45827 },
|
||||
{ iso: "2026-01-01", serial: 46023 },
|
||||
{ iso: "2026-06-19", serial: 46192 },
|
||||
];
|
||||
|
||||
/**
|
||||
* Convertit une date ISO `YYYY-MM-DD` en sérial Qlik numérique.
|
||||
* Inverse documenté : Qlik epoch = 1899-12-30 (jour 0).
|
||||
*
|
||||
* @throws si l'entrée n'est pas au format `YYYY-MM-DD`.
|
||||
*/
|
||||
export function isoDateToQlikSerial(isoDate: string): number {
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(isoDate);
|
||||
if (!m) throw new Error(`isoDateToQlikSerial: format attendu YYYY-MM-DD, reçu "${isoDate}"`);
|
||||
const y = Number(m[1]);
|
||||
const mo = Number(m[2]);
|
||||
const d = Number(m[3]);
|
||||
const ms = Date.UTC(y, mo - 1, d);
|
||||
const serial = Math.round((ms - QLIK_EPOCH_MS) / 86_400_000);
|
||||
return serial;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convertit un sérial Qlik en date ISO `YYYY-MM-DD` (utile pour tests/diagnostics).
|
||||
*/
|
||||
export function qlikSerialToIsoDate(serial: number): string {
|
||||
const ms = QLIK_EPOCH_MS + serial * 86_400_000;
|
||||
const d = new Date(ms);
|
||||
const y = d.getUTCFullYear();
|
||||
const mo = String(d.getUTCMonth() + 1).padStart(2, "0");
|
||||
const da = String(d.getUTCDate()).padStart(2, "0");
|
||||
return `${y}-${mo}-${da}`;
|
||||
}
|
||||
|
||||
export interface QlikDateFilter {
|
||||
/** Date ISO du 1er jour de la fenêtre (inclus). */
|
||||
dateDebut: string;
|
||||
/** Date ISO du dernier jour de la fenêtre (inclus). */
|
||||
dateFin: string;
|
||||
/** Sérial Qlik du 1er jour (= `Date >= …`). */
|
||||
qStart: number;
|
||||
/** Sérial Qlik du dernier jour (= `Date <= …`). */
|
||||
qEnd: number;
|
||||
/** Libellé humain `YYYY-MM_YYYY-MM` pour logs / cache. */
|
||||
label: string;
|
||||
/** Liste exhaustive des serials journaliers `[qStart..qEnd]` pour SelectValues. */
|
||||
dailySerials: number[];
|
||||
/** Expression set analysis prête à intégrer dans un `qDef` de mesure. */
|
||||
setAnalysis: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcule la fenêtre `monthsBack` mois complets (excluant mois courant).
|
||||
* Par défaut, utilise `now = new Date()` et `monthsBack = 12`.
|
||||
*
|
||||
* Logique alignée sur `buildLast12MonthsRange()` de `src/lib/api-ff-client.ts`
|
||||
* (défaut = 12 mois). Si la source officielle change, mettre à jour ici aussi
|
||||
* (garder en sync).
|
||||
*
|
||||
* Bornes : `monthsBack` est clampé dans `[QLIK_MONTHS_BACK_MIN, QLIK_MONTHS_BACK_MAX]`
|
||||
* (1..12). Une valeur invalide (NaN, ≤ 0, > 12) est ramenée aux bornes sans
|
||||
* exception — on veut que la sync reste possible même si l'env est mal
|
||||
* configuré.
|
||||
*/
|
||||
export function buildGridNetworkQlikDateFilter(
|
||||
now: Date = new Date(),
|
||||
monthsBack: number = QLIK_MONTHS_BACK_DEFAULT,
|
||||
): QlikDateFilter {
|
||||
const months = Math.min(
|
||||
QLIK_MONTHS_BACK_MAX,
|
||||
Math.max(QLIK_MONTHS_BACK_MIN, Math.trunc(Number.isFinite(monthsBack) ? monthsBack : QLIK_MONTHS_BACK_DEFAULT)),
|
||||
);
|
||||
// Fin : dernier jour du mois précédent le mois courant
|
||||
const endMonth = new Date(now.getFullYear(), now.getMonth(), 0);
|
||||
// Début : `months` mois avant le mois courant (1er du mois)
|
||||
const startMonth = new Date(now.getFullYear(), now.getMonth() - months, 1);
|
||||
|
||||
// Ne jamais utiliser `toISOString()` ici : minuit local en Europe/Paris
|
||||
// devient la veille en UTC et transformait par exemple 2025-07-01 en
|
||||
// 2025-06-30, soit une extraction de 13 mois. Les bornes sont des dates
|
||||
// calendaires Qlik, on les formate donc avec les composantes locales.
|
||||
const localIso = (d: Date): string =>
|
||||
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
const dateDebut = localIso(startMonth);
|
||||
const dateFin = localIso(endMonth);
|
||||
|
||||
const qStart = isoDateToQlikSerial(dateDebut);
|
||||
const qEnd = isoDateToQlikSerial(dateFin);
|
||||
|
||||
if (qEnd < qStart) {
|
||||
throw new Error(
|
||||
`buildGridNetworkQlikDateFilter: fenêtre inversée qEnd=${qEnd} < qStart=${qStart} (dateDebut=${dateDebut} dateFin=${dateFin})`,
|
||||
);
|
||||
}
|
||||
|
||||
const dailySerials: number[] = [];
|
||||
for (let s = qStart; s <= qEnd; s++) dailySerials.push(s);
|
||||
|
||||
const label = `${dateDebut.slice(0, 7)}_${dateFin.slice(0, 7)}`;
|
||||
const setAnalysis = `Date={">=${qStart}<=${qEnd}"}`;
|
||||
|
||||
return { dateDebut, dateFin, qStart, qEnd, label, dailySerials, setAnalysis };
|
||||
}
|
||||
|
||||
/**
|
||||
* Découpe les serials en lots pour appels `SelectValues` paginés.
|
||||
* Par défaut 500 valeurs/appel (largement < limite Qlik ~10k par requête).
|
||||
*/
|
||||
export function chunkSerials(serials: number[], batchSize = 500): number[][] {
|
||||
const out: number[][] = [];
|
||||
for (let i = 0; i < serials.length; i += batchSize) {
|
||||
out.push(serials.slice(i, i + batchSize));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Découpe une fenêtre temporelle en sous-fenêtres mensuelles calendaires
|
||||
* **inclusives et continues**.
|
||||
*
|
||||
* But : éviter qu'un seul hypercube Qlik ne doive charger 365 jours × N
|
||||
* codes d'un coup (provoque `code:15 Request aborted`). Avec cette découpe,
|
||||
* chaque mois ≈ 28–31 jours × N codes → beaucoup plus léger pour l'Engine.
|
||||
*
|
||||
* Règles :
|
||||
* - Le 1er mois commence à `filter.dateDebut` (peut être un jour quelconque,
|
||||
* pas forcément le 1er calendaire).
|
||||
* - Chaque mois intermédiaire est un mois calendaire **complet** (du 1er
|
||||
* au dernier jour du mois).
|
||||
* - Le dernier mois se termine à `filter.dateFin` (tronqué si pas fin de mois).
|
||||
* - Les mois sont contigus : fin d'un mois + 1 = début du suivant.
|
||||
* - Si `filter.dateDebut` et `filter.dateFin` tombent dans le même mois,
|
||||
* on renvoie un seul mois (identique à `filter` mais re-calculé).
|
||||
*
|
||||
* Chaque mois retourné est un `QlikDateFilter` autonome : `qStart`/`qEnd`,
|
||||
* `dailySerials` recomptés, `setAnalysis` et `label` au format `YYYY-MM`.
|
||||
*
|
||||
* Pré-condition : `dateDebut <= dateFin` au sens calendaire (pas vérifié ici,
|
||||
* la validation est faite par `buildGridNetworkQlikDateFilter`).
|
||||
*/
|
||||
export function getMonthRanges(filter: QlikDateFilter): QlikDateFilter[] {
|
||||
const start = filter.dateDebut; // YYYY-MM-DD
|
||||
const end = filter.dateFin; // YYYY-MM-DD
|
||||
|
||||
// [yyyy, mm, dd] depuis YYYY-MM-DD (déjà validé par isoDateToQlikSerial).
|
||||
const sy = Number(start.slice(0, 4));
|
||||
const sm = Number(start.slice(5, 7));
|
||||
// const sd = Number(start.slice(8, 10)); // non utilisé directement
|
||||
const ey = Number(end.slice(0, 4));
|
||||
const em = Number(end.slice(5, 7));
|
||||
const ed = Number(end.slice(8, 10));
|
||||
|
||||
// Dernier jour du mois (0-ième du mois suivant en UTC local Date).
|
||||
const lastDayOfMonth = (y: number, m: number): number => {
|
||||
// m: 1..12 ; on prend le jour 0 du mois suivant, qui donne le dernier jour du mois courant.
|
||||
return new Date(y, m, 0).getDate();
|
||||
};
|
||||
|
||||
const pad = (n: number): string => String(n).padStart(2, "0");
|
||||
|
||||
const out: QlikDateFilter[] = [];
|
||||
|
||||
// Itère mois par mois du mois de début au mois de fin (inclus).
|
||||
let curY = sy;
|
||||
let curM = sm;
|
||||
// Sécurité anti-boucle infinie (au cas où).
|
||||
const MAX_MONTHS = 1200; // 100 ans, large.
|
||||
let safety = 0;
|
||||
while (safety++ < MAX_MONTHS) {
|
||||
const isFirst = curY === sy && curM === sm;
|
||||
const isLast = curY === ey && curM === em;
|
||||
|
||||
// Début du mois courant : si c'est le premier mois, on respecte `dateDebut` ;
|
||||
// sinon on prend le 1er calendaire.
|
||||
const monthStartDay = isFirst ? Number(start.slice(8, 10)) : 1;
|
||||
const monthStartDate = `${curY}-${pad(curM)}-${pad(monthStartDay)}`;
|
||||
|
||||
// Fin du mois courant : si c'est le dernier mois, on respecte `dateFin` ;
|
||||
// sinon on prend le dernier jour calendaire du mois.
|
||||
const monthEndDay = isLast ? ed : lastDayOfMonth(curY, curM);
|
||||
const monthEndDate = `${curY}-${pad(curM)}-${pad(monthEndDay)}`;
|
||||
|
||||
const qStart = isoDateToQlikSerial(monthStartDate);
|
||||
const qEnd = isoDateToQlikSerial(monthEndDate);
|
||||
|
||||
const dailySerials: number[] = [];
|
||||
for (let s = qStart; s <= qEnd; s++) dailySerials.push(s);
|
||||
|
||||
const label = `${curY}-${pad(curM)}`;
|
||||
const setAnalysis = `Date={">=${qStart}<=${qEnd}"}`;
|
||||
|
||||
out.push({ dateDebut: monthStartDate, dateFin: monthEndDate, qStart, qEnd, label, dailySerials, setAnalysis });
|
||||
|
||||
if (isLast) break;
|
||||
|
||||
// Avance d'un mois calendaire (gère le passage décembre → janvier).
|
||||
curM += 1;
|
||||
if (curM > 12) { curM = 1; curY += 1; }
|
||||
}
|
||||
|
||||
if (out.length === 0) {
|
||||
// Fenêtre vide : on retourne un seul mois vide aligné sur dateDebut.
|
||||
const qStart = filter.qStart;
|
||||
const qEnd = filter.qEnd;
|
||||
const label = start.slice(0, 7);
|
||||
const setAnalysis = `Date={">=${qStart}<=${qEnd}"}`;
|
||||
out.push({ dateDebut: start, dateFin: end, qStart, qEnd, label, dailySerials: [], setAnalysis });
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
@@ -9,12 +9,28 @@ import "server-only";
|
||||
import { db } from "@/db";
|
||||
import { qlikNetworkMetrics } from "@/db/schema";
|
||||
import { inArray, sql } from "drizzle-orm";
|
||||
import type { NetworkMetric } from "@/lib/qlik-client";
|
||||
import type { NetworkMetric, QlikMonthMetrics } from "@/lib/qlik-client";
|
||||
|
||||
export interface NetworkMetricCached {
|
||||
caReseau: number;
|
||||
qteReseau: number;
|
||||
nbMagasinsReseau: number;
|
||||
caParMagasinReseau: number;
|
||||
margePctReseau: number;
|
||||
/** Quantité réseau par mois : { "YYYY-MM": qté } — null si non synchronisé avec dates. */
|
||||
qteByMonth: Record<string, number> | null;
|
||||
/**
|
||||
* Détail mensuel complet : { "YYYY-MM": { qte, ca, nbMag, caMag, margePct } }.
|
||||
* null pour les lignes synchronisées avant l'ajout de la colonne — l'UI
|
||||
* retombe alors sur `qteByMonth`.
|
||||
*/
|
||||
metricsByMonth: Record<string, QlikMonthMetrics> | null;
|
||||
/** Période couverte par l'extraction (ex "2025-03_2026-02"). */
|
||||
periode: string | null;
|
||||
/** Libellé de l'article côté Qlik — `null` tant qu'aucune recherche produit ne l'a renseigné. */
|
||||
libelleReseau: string | null;
|
||||
/** Fournisseur de l'article côté Qlik, même origine que `libelleReseau`. */
|
||||
fournisseurReseau: string | null;
|
||||
fetchedAt: string | null;
|
||||
}
|
||||
|
||||
@@ -26,16 +42,32 @@ export async function getNetworkMetricsByCodeCentrale(
|
||||
const unique = [...new Set(codes.filter(Boolean))];
|
||||
if (unique.length === 0) return out;
|
||||
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(qlikNetworkMetrics)
|
||||
.where(inArray(qlikNetworkMetrics.codeCentrale, unique));
|
||||
// Découpage obligatoire : `inArray` produit un paramètre lié par valeur, or le
|
||||
// protocole PostgreSQL en accepte 65535 au maximum. Un gros fournisseur peut
|
||||
// dépasser 130 000 codes — la requête échouait alors purement et simplement.
|
||||
const CHUNK = 2000;
|
||||
const rows: Array<typeof qlikNetworkMetrics.$inferSelect> = [];
|
||||
for (let i = 0; i < unique.length; i += CHUNK) {
|
||||
const batch = unique.slice(i, i + CHUNK);
|
||||
const part = await db
|
||||
.select()
|
||||
.from(qlikNetworkMetrics)
|
||||
.where(inArray(qlikNetworkMetrics.codeCentrale, batch));
|
||||
rows.push(...part);
|
||||
}
|
||||
|
||||
for (const r of rows) {
|
||||
out.set(r.codeCentrale, {
|
||||
caReseau: Number(r.caReseau ?? 0) || 0,
|
||||
qteReseau: Number(r.qteReseau ?? 0) || 0,
|
||||
nbMagasinsReseau: Number(r.nbMagasinsReseau ?? 0) || 0,
|
||||
caParMagasinReseau: Number(r.caParMagasinReseau ?? 0) || 0,
|
||||
margePctReseau: Number(r.margePctReseau ?? 0) || 0,
|
||||
qteByMonth: (r.qteByMonth as Record<string, number> | null) ?? null,
|
||||
metricsByMonth: (r.metricsByMonth as Record<string, QlikMonthMetrics> | null) ?? null,
|
||||
periode: r.periode ?? null,
|
||||
libelleReseau: r.libelleReseau ?? null,
|
||||
fournisseurReseau: r.fournisseurReseau ?? null,
|
||||
fetchedAt: r.fetchedAt ? new Date(r.fetchedAt).toISOString() : null,
|
||||
});
|
||||
}
|
||||
@@ -51,7 +83,13 @@ export async function upsertNetworkMetrics(metrics: NetworkMetric[]): Promise<nu
|
||||
caReseau: String(m.caReseau),
|
||||
qteReseau: String(m.qteReseau),
|
||||
nbMagasinsReseau: m.nbMagasinsReseau,
|
||||
caParMagasinReseau: String(m.caParMagasinReseau),
|
||||
margePctReseau: String(m.margePctReseau),
|
||||
periode: m.periode ?? null,
|
||||
qteByMonth: m.qteByMonth ?? null,
|
||||
metricsByMonth: m.metricsByMonth ?? null,
|
||||
libelleReseau: m.libelleReseau ?? null,
|
||||
fournisseurReseau: m.fournisseurReseau ?? null,
|
||||
fetchedAt: now,
|
||||
}));
|
||||
|
||||
@@ -69,7 +107,18 @@ export async function upsertNetworkMetrics(metrics: NetworkMetric[]): Promise<nu
|
||||
caReseau: sql`excluded.ca_reseau`,
|
||||
qteReseau: sql`excluded.qte_reseau`,
|
||||
nbMagasinsReseau: sql`excluded.nb_magasins_reseau`,
|
||||
caParMagasinReseau: sql`excluded.ca_par_magasin_reseau`,
|
||||
margePctReseau: sql`excluded.marge_pct_reseau`,
|
||||
periode: sql`excluded.periode`,
|
||||
// Le détail mensuel n'est produit que par l'extraction complète
|
||||
// (dimension Mois). La recherche produit, elle, n'a que les
|
||||
// agrégats : elle ne doit pas effacer un mensuel déjà extrait.
|
||||
qteByMonth: sql`COALESCE(excluded.qte_by_month, ${qlikNetworkMetrics.qteByMonth})`,
|
||||
metricsByMonth: sql`COALESCE(excluded.metrics_by_month, ${qlikNetworkMetrics.metricsByMonth})`,
|
||||
// Seule la recherche produit connaît le libellé / le fournisseur
|
||||
// réseau : une sync fournisseur ne doit pas les effacer.
|
||||
libelleReseau: sql`COALESCE(excluded.libelle_reseau, ${qlikNetworkMetrics.libelleReseau})`,
|
||||
fournisseurReseau: sql`COALESCE(excluded.fournisseur_reseau, ${qlikNetworkMetrics.fournisseurReseau})`,
|
||||
fetchedAt: sql`excluded.fetched_at`,
|
||||
},
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,664 @@
|
||||
/**
|
||||
* CollectFlow — Recherche produit **dans Qlik Sense** (source primaire).
|
||||
*
|
||||
* La recherche produit part du réseau, pas du catalogue Nancy : on interroge
|
||||
* d'abord l'app Qlik « Magasins Vision Consolidée » pour trouver les articles
|
||||
* qui correspondent au terme saisi (libellé ou code centrale), puis l'appelant
|
||||
* rapproche les codes centraux trouvés de la base FF Nancy.
|
||||
*
|
||||
* Pourquoi Qlik d'abord : le réseau (~270 magasins) référence beaucoup plus de
|
||||
* produits que Nancy. Chercher dans la base locale d'abord masquait tous les
|
||||
* produits que le réseau travaille et que nous ne référençons pas — c'est
|
||||
* précisément ce qu'on veut voir.
|
||||
*
|
||||
* Technique : même plomberie que `qlik-playwright.ts` (Chromium headless +
|
||||
* websocket Engine in-page avec le qlik-csrf-token), car le proxy Qlik refuse
|
||||
* un websocket « raw » côté serveur.
|
||||
*
|
||||
* ## Comment la sélection est construite
|
||||
*
|
||||
* La recherche d'un list object Qlik (`SearchListObjectFor`) applique un **OU**
|
||||
* entre les mots : « tapis anti » a ramené 17 696 libellés sur l'app FF, dont
|
||||
* l'immense majorité ne contient que « anti ». Sélectionner ces 17 696 valeurs
|
||||
* faisait ensuite exploser le cube (`code 15 — Request aborted`).
|
||||
*
|
||||
* On procède donc en trois temps :
|
||||
* 1. une recherche par mot, pour retenir le **plus sélectif** (celui qui
|
||||
* ramène le moins de valeurs) ;
|
||||
* 2. lecture de ces valeurs et filtrage **ET** côté client (tous les mots
|
||||
* présents, insensible casse/accents) ;
|
||||
* 3. sélection des seules valeurs retenues, **par numéro d'élément**
|
||||
* (`SelectListObjectValues`) — exact, sans ré-appariement de texte.
|
||||
*
|
||||
* Le résultat final est revérifié une dernière fois avant d'être renvoyé : une
|
||||
* liste sans rapport avec la recherche est pire que pas de résultat du tout.
|
||||
*/
|
||||
|
||||
import "server-only";
|
||||
import { chromium, type Browser } from "playwright-core";
|
||||
import { getQlikConfig, qlikNtlmSession, type QlikConfig } from "@/lib/qlik-client";
|
||||
import { buildGridNetworkQlikDateFilter, QLIK_MONTHS_BACK_DEFAULT } from "@/lib/qlik-date-range";
|
||||
|
||||
/**
|
||||
* Un article trouvé côté Qlik, **avec ses mesures réseau sur 12 mois glissants**.
|
||||
*
|
||||
* Les mesures sortent du même cube que l'identité : une seule session Qlik pour
|
||||
* toute la recherche. Les extraire dans une seconde session concurrente faisait
|
||||
* abandonner le moteur (`code 15 — Request aborted`).
|
||||
*/
|
||||
export interface QlikArticleMatch {
|
||||
/** Valeur de la dimension « Article Code » — clé de jointure de la Grille. */
|
||||
codeCentrale: string;
|
||||
/**
|
||||
* Champ `article_no_centrale` quand l'app l'expose : second candidat pour la
|
||||
* jointure avec `articles.artcentrale`, les deux ne portant pas toujours le
|
||||
* même format.
|
||||
*/
|
||||
codeCentraleAlt: string;
|
||||
/** Libellé réseau, si l'app expose un champ libellé article. */
|
||||
libelle: string;
|
||||
/** Fournisseur réseau, si l'app expose un champ fournisseur. */
|
||||
fournisseur: string;
|
||||
/** CA réseau sur la fenêtre 12 mois glissants. */
|
||||
caReseau: number;
|
||||
qteReseau: number;
|
||||
nbMagasinsReseau: number;
|
||||
caParMagasinReseau: number;
|
||||
/** Taux de marge réseau, ratio brut Qlik (0.32 = 32 %). */
|
||||
margePctReseau: number;
|
||||
}
|
||||
|
||||
export interface QlikSearchOutcome {
|
||||
matches: QlikArticleMatch[];
|
||||
/** Champ Qlik effectivement interrogé (diagnostic UI). */
|
||||
champUtilise: string | null;
|
||||
/** true si Qlik a renvoyé plus de résultats que la limite demandée. */
|
||||
tronque: boolean;
|
||||
/**
|
||||
* Message de diagnostic quand la recherche aboutit à zéro article alors que
|
||||
* Qlik a bien répondu (filtre non appliqué, terme trop large…). `null` en
|
||||
* fonctionnement nominal.
|
||||
*/
|
||||
avertissement: string | null;
|
||||
/** Fenêtre couverte par les mesures (`YYYY-MM_YYYY-MM`). */
|
||||
periode: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Nombre maximum d'articles renvoyés par une recherche.
|
||||
*
|
||||
* Chaque code trouvé est ensuite ré-extrait avec son détail mensuel : au-delà
|
||||
* de quelques dizaines, la seconde passe devient longue et le résultat
|
||||
* illisible. L'utilisateur affine son terme.
|
||||
*/
|
||||
export const QLIK_SEARCH_MAX_RESULTS = 40;
|
||||
|
||||
/**
|
||||
* Champs candidats pour le libellé article, par ordre de préférence.
|
||||
*
|
||||
* Sur l'app FF, `Article` est le libellé métier et `article_libelle_ticket` le
|
||||
* libellé (tronqué) imprimé sur le ticket. On essaie le premier qui donne des
|
||||
* résultats : si un candidat ne matche rien, on passe au suivant.
|
||||
*/
|
||||
const CHAMPS_LIBELLE_PREFERES = ["Article", "article_libelle_ticket"];
|
||||
|
||||
/** Champs candidats pour le fournisseur, par ordre de préférence (nom avant code). */
|
||||
const CHAMPS_FOURNISSEUR_PREFERES = ["Fournisseur", "code_fournisseur"];
|
||||
|
||||
/**
|
||||
* Champ portant le code centrale « brut » de l'article, quand il existe.
|
||||
* Second candidat de jointure avec `articles.artcentrale` : sur l'app FF, la
|
||||
* dimension « Article Code » ne renvoie pas le même format.
|
||||
*/
|
||||
const CHAMP_CODE_CENTRALE_ALT = "article_no_centrale";
|
||||
|
||||
let browserPromise: Promise<Browser> | null = null;
|
||||
|
||||
async function getBrowser(): Promise<Browser> {
|
||||
if (!browserPromise) {
|
||||
const execPath = process.env.PLAYWRIGHT_CHROMIUM_PATH;
|
||||
browserPromise = chromium.launch({
|
||||
headless: true,
|
||||
executablePath: execPath || undefined,
|
||||
args: ["--no-sandbox", "--disable-dev-shm-usage", "--ignore-certificate-errors"],
|
||||
}).catch((e) => { browserPromise = null; throw e; });
|
||||
}
|
||||
return browserPromise;
|
||||
}
|
||||
|
||||
interface InPageSearchResult {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
/** [code, codeAlt, libellé, fournisseur, ca, qte, nbMag, caMag, margePct] */
|
||||
rows?: Array<[string, string, string, string, number, number, number, number, number]>;
|
||||
champUtilise?: string | null;
|
||||
/** Nombre d'articles uniques correspondant au terme (avant plafonnement). */
|
||||
total?: number;
|
||||
/** Lignes brutes lues dans le cube (avant garde-fou). */
|
||||
lues?: number;
|
||||
/** Lignes écartées parce qu'elles ne correspondaient pas au terme. */
|
||||
rejetees?: number;
|
||||
/** true si le terme ramenait trop de valeurs pour être traité intégralement. */
|
||||
tropLarge?: boolean;
|
||||
/** Journal compact des étapes, remonté dans les logs serveur. */
|
||||
trace?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Cherche dans Qlik les articles correspondant à `term`.
|
||||
*
|
||||
* Ne fait **aucune** mesure : on veut juste la liste des codes centraux (plus
|
||||
* libellé / fournisseur si l'app les expose). Les métriques réseau sont
|
||||
* ensuite extraites par `fetchNetworkMetricsPlaywright`, qui sait déjà produire
|
||||
* le détail mensuel sur 12 mois glissants.
|
||||
*/
|
||||
export async function searchQlikArticles(
|
||||
term: string,
|
||||
limit: number = QLIK_SEARCH_MAX_RESULTS,
|
||||
cfg: QlikConfig = getQlikConfig(),
|
||||
): Promise<QlikSearchOutcome> {
|
||||
const cleaned = term.trim();
|
||||
if (cleaned.length < 3) return { matches: [], champUtilise: null, tronque: false, avertissement: null, periode: null };
|
||||
if (!cfg.appNetwork) throw new Error("[qlik-search] QLIK_APP_NETWORK manquant");
|
||||
if (!cfg.user || !cfg.password) throw new Error("[qlik-search] identifiants Qlik manquants");
|
||||
|
||||
const started = Date.now();
|
||||
// Latence laissée à l'Engine après chaque mutation de sélection. Même réglage
|
||||
// que l'extraction (`QLIK_SETTLE_MS`) : sans elle, l'objet créé juste après
|
||||
// est calculé sur l'état de sélection précédent.
|
||||
const settleMs = Math.min(2000, Math.max(0, Number(process.env.QLIK_SETTLE_MS ?? "250") || 250));
|
||||
// Même fenêtre que la Grille : 12 mois complets glissants, mois courant exclu.
|
||||
const dateFilter = buildGridNetworkQlikDateFilter(
|
||||
new Date(),
|
||||
QLIK_MONTHS_BACK_DEFAULT,
|
||||
);
|
||||
const sess = await qlikNtlmSession(cfg);
|
||||
const [cookieName, ...rest] = sess.cookie.split("=");
|
||||
const cookieValue = rest.join("=");
|
||||
|
||||
const browser = await getBrowser();
|
||||
const ctx = await browser.newContext({
|
||||
httpCredentials: { username: cfg.user, password: cfg.password, origin: `https://${cfg.host}` },
|
||||
ignoreHTTPSErrors: true,
|
||||
});
|
||||
await ctx.addCookies([{ name: cookieName, value: cookieValue, domain: cfg.host, path: "/", secure: true }]);
|
||||
|
||||
try {
|
||||
const page = await ctx.newPage();
|
||||
page.on("console", (msg) => {
|
||||
// Le client Qlik est très bavard au chargement (thèmes, extensions) :
|
||||
// on ne relaie que ce qui vient de notre script.
|
||||
const t = msg.text();
|
||||
if (t.startsWith("[cf]")) console.log(`[qlik-search][page] ${t}`);
|
||||
});
|
||||
|
||||
let csrfToken: string | null = null;
|
||||
let resolveToken: (() => void) | null = null;
|
||||
const tokenReady = new Promise<void>((r) => { resolveToken = r; });
|
||||
page.on("websocket", (ws) => {
|
||||
const m = ws.url().match(/qlik-csrf-token=([^&]+)/);
|
||||
if (m && !csrfToken) { csrfToken = decodeURIComponent(m[1]); resolveToken?.(); }
|
||||
});
|
||||
|
||||
await page.goto(`https://${cfg.host}/sense/app/${cfg.appNetwork}`, { waitUntil: "domcontentloaded", timeout: cfg.timeoutMs })
|
||||
.catch(() => { /* le client Qlik ouvre son ws juste après */ });
|
||||
await Promise.race([tokenReady, page.waitForTimeout(15000)]);
|
||||
if (!csrfToken) throw new Error("[qlik-search] qlik-csrf-token introuvable (client Qlik non chargé ?)");
|
||||
|
||||
const result = (await page.evaluate(
|
||||
({ app, dim, token, terme, limite, settleMs, serialsDate, mCa, mQte, mNbMag, mCaMag, mMarge,
|
||||
champCodeAlt, champsLibellePreferes, champsFournisseurPreferes, champLibelleForce, champFournisseurForce }: {
|
||||
app: string; dim: string; token: string; terme: string; limite: number; settleMs: number;
|
||||
serialsDate: number[];
|
||||
mCa: string; mQte: string; mNbMag: string; mCaMag: string; mMarge: string;
|
||||
champCodeAlt: string;
|
||||
champsLibellePreferes: string[]; champsFournisseurPreferes: string[];
|
||||
champLibelleForce: string; champFournisseurForce: string;
|
||||
}) =>
|
||||
new Promise<InPageSearchResult>((resolve) => {
|
||||
/** Valeurs lues au maximum dans le champ recherché (2 pages Engine). */
|
||||
const MAX_VALEURS_LUES = 10000;
|
||||
/**
|
||||
* Valeurs sélectionnées au maximum.
|
||||
*
|
||||
* Mesuré en production : sélectionner les 13 446 libellés contenant
|
||||
* « tapis » a mis 81 s puis 486 s, et faisait abandonner le cube
|
||||
* (code 15). Le cube étant trié par quantité réseau décroissante et
|
||||
* l'affichage plafonné à quelques dizaines de lignes, 300 libellés
|
||||
* suffisent largement.
|
||||
*/
|
||||
const MAX_VALEURS_SELECTION = 300;
|
||||
const PAGE_VALEURS = 5000;
|
||||
|
||||
const loc = (window as unknown as { location: Location }).location;
|
||||
const url = `wss://${loc.host}/app/${app}?reloadUri=${encodeURIComponent(loc.href)}&qlik-csrf-token=${token}`;
|
||||
const ws = new WebSocket(url);
|
||||
const trace: string[] = [];
|
||||
let id = 0;
|
||||
const pend = new Map<number, { res: (v: { [k: string]: unknown }) => void; rej: (e: unknown) => void }>();
|
||||
const rpc = (method: string, params: unknown, handle = -1) => new Promise<{ [k: string]: unknown }>((res, rej) => {
|
||||
const i = ++id; pend.set(i, { res, rej });
|
||||
ws.send(JSON.stringify({ jsonrpc: "2.0", id: i, handle, method, params }));
|
||||
});
|
||||
const ready = new Promise<void>((res, rej) => {
|
||||
ws.onmessage = (ev: MessageEvent) => {
|
||||
const m = JSON.parse(ev.data as string);
|
||||
if (m.method === "OnConnected") return res();
|
||||
if (m.id && pend.has(m.id)) {
|
||||
const x = pend.get(m.id)!;
|
||||
pend.delete(m.id);
|
||||
if (m.error) x.rej(new Error(JSON.stringify(m.error)));
|
||||
else x.res(m.result);
|
||||
}
|
||||
};
|
||||
ws.onerror = () => rej(new Error("ws error (403 ?)"));
|
||||
setTimeout(() => rej(new Error("ws timeout")), 30000);
|
||||
});
|
||||
|
||||
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
||||
/** Qlik Engine code 15 = « Request aborted » : requête entrée en collision avec une évaluation. */
|
||||
const estCode15 = (e: unknown) => /"code"\s*:\s*15\b/.test(String((e as Error)?.message ?? e));
|
||||
const rpcRetry = async (method: string, params: unknown, handle: number, tentatives = 3) => {
|
||||
let derniere: unknown;
|
||||
for (let i = 1; i <= tentatives; i++) {
|
||||
try {
|
||||
return await rpc(method, params, handle);
|
||||
} catch (e) {
|
||||
derniere = e;
|
||||
if (!estCode15(e) || i === tentatives) throw e;
|
||||
trace.push("code15 sur " + method + ", tentative " + i + "/" + tentatives);
|
||||
await sleep(settleMs * (i + 1));
|
||||
}
|
||||
}
|
||||
throw derniere;
|
||||
};
|
||||
|
||||
const normaliser = (s: string) => s
|
||||
.toLowerCase()
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.replace(/[^a-z0-9]+/g, " ")
|
||||
.trim();
|
||||
const mots = normaliser(terme).split(" ").filter(Boolean);
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
await ready;
|
||||
const open = await rpc("OpenDoc", { qDocName: app });
|
||||
const doc = (open.qReturn as { qHandle: number }).qHandle;
|
||||
|
||||
// ─── 1. Inventaire des champs ────────────────────────────
|
||||
const fl = await rpc("CreateSessionObject", {
|
||||
qProp: {
|
||||
qInfo: { qType: "FieldList" },
|
||||
qFieldListDef: { qShowSystem: false, qShowHidden: false, qShowSemantic: true, qShowSrcTables: true },
|
||||
},
|
||||
}, doc);
|
||||
const fll = await rpc("GetLayout", {}, (fl.qReturn as { qHandle: number }).qHandle);
|
||||
const champs = ((((fll.qLayout as { qFieldList?: { qItems?: Array<{ qName: string }> } }).qFieldList?.qItems) ?? [])
|
||||
.map((f) => f.qName));
|
||||
|
||||
const has = (nom: string, re: RegExp) => re.test(nom);
|
||||
const champCode = champs.find((c) => c === "Article Code")
|
||||
?? champs.find((c) => has(c, /article/i) && has(c, /code/i))
|
||||
?? "Article Code";
|
||||
|
||||
// Candidats libellé : surcharge d'env, puis noms connus de
|
||||
// l'app FF, puis heuristique. On essaiera chacun dans l'ordre
|
||||
// jusqu'à en trouver un qui ramène des valeurs.
|
||||
const candidatsLibelle = [
|
||||
champLibelleForce,
|
||||
...champsLibellePreferes,
|
||||
...champs.filter((c) => has(c, /article/i) && has(c, /(libell|designation|désignation|denomination|dénomination)/i)),
|
||||
...champs.filter((c) => has(c, /(libell|designation|désignation)/i) && !has(c, /(magasin|region|région|famille|rayon|fournisseur)/i)),
|
||||
].filter((c, i, arr) => c && champs.includes(c) && arr.indexOf(c) === i);
|
||||
|
||||
const champFournisseur = [
|
||||
champFournisseurForce,
|
||||
...champsFournisseurPreferes,
|
||||
...champs.filter((c) => has(c, /fournisseur/i)),
|
||||
...champs.filter((c) => has(c, /(fabricant|supplier)/i)),
|
||||
].find((c) => c && champs.includes(c)) ?? null;
|
||||
|
||||
trace.push("code=" + champCode + " libellé candidats=" + JSON.stringify(candidatsLibelle) + " fournisseur=" + champFournisseur);
|
||||
|
||||
// ─── 2. Recherche + sélection sur le champ le plus adapté ─
|
||||
//
|
||||
// `SearchListObjectFor` applique un OU entre les mots. On
|
||||
// cherche donc mot par mot pour retenir le plus sélectif,
|
||||
// puis on filtre en ET côté client avant de sélectionner.
|
||||
const chercherEtSelectionner = async (champ: string): Promise<{ retenues: number; brut: number; tropLarge: boolean }> => {
|
||||
const lo = await rpcRetry("CreateSessionObject", {
|
||||
qProp: {
|
||||
qInfo: { qType: "cf-search-lb" },
|
||||
qListObjectDef: {
|
||||
qDef: { qFieldDefs: [champ] },
|
||||
qInitialDataFetch: [],
|
||||
},
|
||||
},
|
||||
}, doc);
|
||||
const ret = lo.qReturn as { qHandle: number; qGenericId?: string; qId?: string };
|
||||
const loId = String(ret.qGenericId ?? ret.qId ?? "");
|
||||
const lh = ret.qHandle;
|
||||
try {
|
||||
// 2a. Mot le plus sélectif (une recherche par mot, on ne lit que le compteur).
|
||||
let meilleurMot = terme;
|
||||
let meilleurNb = Number.POSITIVE_INFINITY;
|
||||
for (const mot of (mots.length > 1 ? mots : [terme])) {
|
||||
await rpcRetry("SearchListObjectFor", { qPath: "/qListObjectDef", qMatch: mot }, lh);
|
||||
const lay = await rpcRetry("GetLayout", {}, lh);
|
||||
const nb = ((lay.qLayout as { qListObject?: { qSize?: { qcy?: number } } }).qListObject?.qSize?.qcy) ?? 0;
|
||||
trace.push(" « " + mot + " » dans " + champ + " → " + nb + " valeur(s)");
|
||||
if (nb > 0 && nb < meilleurNb) { meilleurNb = nb; meilleurMot = mot; }
|
||||
}
|
||||
if (!Number.isFinite(meilleurNb) || meilleurNb === 0) return { retenues: 0, brut: 0, tropLarge: false };
|
||||
|
||||
// 2b. Relecture des valeurs du mot retenu. On relance la
|
||||
// recherche systématiquement : la boucle ci-dessus a pu
|
||||
// laisser le list object sur un autre mot.
|
||||
await rpcRetry("SearchListObjectFor", { qPath: "/qListObjectDef", qMatch: meilleurMot }, lh);
|
||||
const aLire = Math.min(meilleurNb, MAX_VALEURS_LUES);
|
||||
const elems: number[] = [];
|
||||
let lues = 0;
|
||||
for (let top = 0; top < aLire; top += PAGE_VALEURS) {
|
||||
const d = await rpcRetry("GetListObjectData", {
|
||||
qPath: "/qListObjectDef",
|
||||
qPages: [{ qTop: top, qLeft: 0, qWidth: 1, qHeight: Math.min(PAGE_VALEURS, aLire - top) }],
|
||||
}, lh);
|
||||
const matrix = ((d.qDataPages as Array<{ qMatrix?: Array<Array<{ qText?: string; qElemNumber?: number }>> }>)?.[0]?.qMatrix) ?? [];
|
||||
if (matrix.length === 0) break;
|
||||
lues += matrix.length;
|
||||
for (const ligne of matrix) {
|
||||
const cell = ligne[0];
|
||||
const texte = String(cell?.qText ?? "");
|
||||
const elem = cell?.qElemNumber;
|
||||
if (elem == null || elem < 0) continue;
|
||||
// Filtre ET : tous les mots du terme présents.
|
||||
const n = normaliser(texte);
|
||||
if (!mots.every((m) => n.includes(m))) continue;
|
||||
elems.push(elem);
|
||||
if (elems.length >= MAX_VALEURS_SELECTION) break;
|
||||
}
|
||||
if (elems.length >= MAX_VALEURS_SELECTION) break;
|
||||
}
|
||||
trace.push(" mot retenu « " + meilleurMot + " » : " + lues + " valeur(s) lues → " + elems.length + " après filtre ET");
|
||||
if (elems.length === 0) {
|
||||
return { retenues: 0, brut: meilleurNb, tropLarge: meilleurNb > MAX_VALEURS_LUES };
|
||||
}
|
||||
|
||||
// 2c. Sélection par numéro d'élément : exact, aucun
|
||||
// ré-appariement de texte possible.
|
||||
await rpcRetry("SelectListObjectValues", {
|
||||
qPath: "/qListObjectDef",
|
||||
qValues: elems,
|
||||
qToggleMode: false,
|
||||
qSoftLock: true,
|
||||
}, lh);
|
||||
await sleep(settleMs);
|
||||
return { retenues: elems.length, brut: meilleurNb, tropLarge: meilleurNb > MAX_VALEURS_LUES };
|
||||
} finally {
|
||||
if (loId) { try { await rpc("DestroySessionObject", { qId: loId }, doc); } catch { /* noop */ } }
|
||||
}
|
||||
};
|
||||
|
||||
await rpc("ClearAll", { qLockedAlso: false }, doc);
|
||||
await sleep(settleMs);
|
||||
|
||||
let champChoisi: string | null = null;
|
||||
let tropLarge = false;
|
||||
for (const candidat of candidatsLibelle) {
|
||||
const r = await chercherEtSelectionner(candidat);
|
||||
tropLarge = tropLarge || r.tropLarge;
|
||||
if (r.retenues > 0) { champChoisi = candidat; break; }
|
||||
await rpc("ClearAll", { qLockedAlso: false }, doc);
|
||||
await sleep(settleMs);
|
||||
}
|
||||
if (!champChoisi) {
|
||||
// Repli code centrale (terme saisi = un code).
|
||||
const r = await chercherEtSelectionner(champCode);
|
||||
if (r.retenues > 0) champChoisi = champCode;
|
||||
tropLarge = tropLarge || r.tropLarge;
|
||||
}
|
||||
if (!champChoisi) {
|
||||
ws.close();
|
||||
return resolve({ ok: true, rows: [], champUtilise: null, total: 0, lues: 0, rejetees: 0, tropLarge, trace });
|
||||
}
|
||||
trace.push("champ retenu = " + champChoisi);
|
||||
|
||||
// Trace des sélections réellement actives : si elle est vide,
|
||||
// la sélection n'a pas pris côté Engine.
|
||||
try {
|
||||
const cs = await rpc("CreateSessionObject", {
|
||||
qProp: { qInfo: { qType: "cf-search-sel" }, qSelectionObjectDef: {} },
|
||||
}, doc);
|
||||
const csl = await rpc("GetLayout", {}, (cs.qReturn as { qHandle: number }).qHandle);
|
||||
const sel = (((csl.qLayout as { qSelectionObject?: { qSelections?: Array<{ qField?: string; qSelectedCount?: number; qTotal?: number }> } })
|
||||
.qSelectionObject?.qSelections) ?? [])
|
||||
.map((s) => `${s.qField}:${s.qSelectedCount}/${s.qTotal}`);
|
||||
trace.push("sélections actives=" + JSON.stringify(sel));
|
||||
} catch (e) {
|
||||
trace.push("sélections actives: lecture impossible (" + String((e as Error)?.message || e) + ")");
|
||||
}
|
||||
|
||||
// ─── 3. Fenêtre 12 mois glissants ────────────────────────
|
||||
// Sélectionnée APRÈS les libellés : restreindre les dates avant
|
||||
// la recherche masquerait les articles non vendus sur la période.
|
||||
if (serialsDate.length > 0) {
|
||||
try {
|
||||
const gd = await rpc("GetField", { qFieldName: "Date" }, doc);
|
||||
const dh = (gd.qReturn as { qHandle: number }).qHandle;
|
||||
for (let i = 0; i < serialsDate.length; i += 500) {
|
||||
const lot = serialsDate.slice(i, i + 500);
|
||||
await rpcRetry("SelectValues", {
|
||||
qFieldValues: lot.map((v) => ({ qNum: v, qText: String(v) })),
|
||||
qToggleMode: i > 0,
|
||||
qSoftLock: true,
|
||||
}, dh);
|
||||
}
|
||||
await sleep(settleMs);
|
||||
trace.push("fenêtre Date sélectionnée (" + serialsDate.length + " jours)");
|
||||
} catch (e) {
|
||||
trace.push("sélection Date impossible, mesures sur toute la période : " + String((e as Error)?.message || e));
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 4. Identité + mesures réseau, dans UN SEUL cube ─────
|
||||
//
|
||||
// Les mesures sont dans le même cube que l'identité, et donc
|
||||
// dans la même session Qlik : une seconde session concurrente
|
||||
// pour les extraire faisait abandonner le moteur (code 15).
|
||||
//
|
||||
// Les master measures sont résolues par TITRE : le qLibraryId
|
||||
// fiable est `qInfo.qId`, qui n'est pas le GUID du QRS — même
|
||||
// approche que l'extraction de la Grille.
|
||||
const norm = (x: string) => x.replace(/\s+/g, " ").trim().toLowerCase();
|
||||
const ml = await rpcRetry("CreateSessionObject", {
|
||||
qProp: { qInfo: { qType: "MeasureList" }, qMeasureListDef: { qType: "measure", qData: { title: "/qMetaDef/title" } } },
|
||||
}, doc);
|
||||
const mll = await rpcRetry("GetLayout", {}, (ml.qReturn as { qHandle: number }).qHandle);
|
||||
const items = (((mll.qLayout as { qMeasureList?: { qItems?: Array<{ qInfo: { qId: string }; qMeta?: { title?: string }; qData?: { title?: string } }> } }).qMeasureList?.qItems) ?? []);
|
||||
const parTitre = new Map<string, string>();
|
||||
for (const it of items) {
|
||||
const t = it.qMeta?.title ?? it.qData?.title ?? "";
|
||||
if (t) parTitre.set(norm(t), it.qInfo.qId);
|
||||
}
|
||||
const mesure = (titre: string, repli: string) => parTitre.get(norm(titre)) ?? repli;
|
||||
const idCa = mesure("CA N", mCa);
|
||||
const idQte = mesure("Quantité N", mQte);
|
||||
const idNbMag = mesure("Magasin Ventes Nb N", mNbMag);
|
||||
const idCaMag = mesure("CA par Magasin N", mCaMag);
|
||||
const idMarge = mesure("Marge % N", mMarge);
|
||||
|
||||
const champLibelleAffiche = champChoisi === champCode ? candidatsLibelle[0] ?? null : champChoisi;
|
||||
const champAlt = champs.includes(champCodeAlt) ? champCodeAlt : null;
|
||||
const dims: Array<{ qLibraryId?: string; qDef?: { qFieldDefs: string[] } }> = [{ qLibraryId: dim }];
|
||||
const idxAlt = champAlt ? dims.push({ qDef: { qFieldDefs: [champAlt] } }) - 1 : -1;
|
||||
const idxLibelle = champLibelleAffiche ? dims.push({ qDef: { qFieldDefs: [champLibelleAffiche] } }) - 1 : -1;
|
||||
const idxFournisseur = champFournisseur ? dims.push({ qDef: { qFieldDefs: [champFournisseur] } }) - 1 : -1;
|
||||
|
||||
const nbDims = dims.length;
|
||||
// Colonnes mesures, dans l'ordre : CA, Qté, NbMag, CA/mag, Marge %.
|
||||
const idxQteCol = nbDims + 1;
|
||||
const largeur = nbDims + 5;
|
||||
// Cube trié par quantité réseau décroissante : la première page
|
||||
// contient donc directement les meilleures ventes du réseau, et
|
||||
// on n'a pas besoin de lire tout l'ensemble sélectionné.
|
||||
const hauteur = Math.max(50, Math.min(400, Math.floor(9000 / largeur)));
|
||||
|
||||
const defCube = (trie: boolean) => ({
|
||||
qProp: {
|
||||
qInfo: { qType: "cf-search" },
|
||||
qHyperCubeDef: {
|
||||
qDimensions: dims.map((d) => ({ ...d, qNullSuppression: true })),
|
||||
qMeasures: [
|
||||
{ qLibraryId: idCa },
|
||||
trie ? { qLibraryId: idQte, qSortBy: { qSortByNumeric: -1 } } : { qLibraryId: idQte },
|
||||
{ qLibraryId: idNbMag },
|
||||
{ qLibraryId: idCaMag },
|
||||
{ qLibraryId: idMarge },
|
||||
],
|
||||
...(trie ? { qInterColumnSortOrder: [idxQteCol, 0] } : {}),
|
||||
qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: largeur, qHeight: hauteur }],
|
||||
qSuppressZero: false,
|
||||
qSuppressMissing: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
let cube: { [k: string]: unknown };
|
||||
try {
|
||||
cube = await rpcRetry("CreateSessionObject", defCube(true), doc);
|
||||
} catch (e) {
|
||||
// Le tri par mesure est un confort (meilleures ventes en
|
||||
// tête) : si l'Engine refuse cette forme, on repart sans.
|
||||
if (estCode15(e)) throw e;
|
||||
trace.push("tri par quantité refusé, cube non trié : " + String((e as Error)?.message || e));
|
||||
cube = await rpcRetry("CreateSessionObject", defCube(false), doc);
|
||||
}
|
||||
const ch = (cube.qReturn as { qHandle: number }).qHandle;
|
||||
const layout = await rpcRetry("GetLayout", {}, ch);
|
||||
const hc = (layout.qLayout as {
|
||||
qHyperCube: {
|
||||
qSize: { qcy: number };
|
||||
qDataPages?: Array<{ qMatrix?: Array<Array<{ qText?: string; qNum?: number }>> }>;
|
||||
};
|
||||
}).qHyperCube;
|
||||
const matrix = hc.qDataPages?.[0]?.qMatrix ?? [];
|
||||
|
||||
// ─── 4. Garde-fou : le résultat DOIT correspondre au terme ──
|
||||
const termeCode = terme.trim().toLowerCase();
|
||||
const correspond = (code: string, libelle: string): boolean => {
|
||||
if (mots.length === 0) return true;
|
||||
const c = code.toLowerCase();
|
||||
if (c === termeCode || c.startsWith(termeCode)) return true;
|
||||
if (!libelle) return champChoisi === champCode;
|
||||
const l = normaliser(libelle);
|
||||
return mots.every((m) => l.includes(m));
|
||||
};
|
||||
|
||||
type Ligne = [string, string, string, string, number, number, number, number, number];
|
||||
const texte = (r: Array<{ qText?: string }>, i: number) => {
|
||||
if (i < 0) return "";
|
||||
const v = String(r[i]?.qText ?? "").trim();
|
||||
return v === "-" ? "" : v;
|
||||
};
|
||||
const parCode = new Map<string, Ligne>();
|
||||
let rejetees = 0;
|
||||
for (const r of matrix) {
|
||||
const code = String(r[0]?.qText ?? "").trim();
|
||||
if (!code || code === "-") continue;
|
||||
const codeAlt = texte(r, idxAlt);
|
||||
const libelle = texte(r, idxLibelle);
|
||||
const fournisseur = texte(r, idxFournisseur);
|
||||
if (!correspond(code, libelle)) { rejetees++; continue; }
|
||||
const nombre = (i: number) => Number(r[nbDims + i]?.qNum) || 0;
|
||||
const ligne: Ligne = [
|
||||
code, codeAlt, libelle, fournisseur,
|
||||
nombre(0), nombre(1), nombre(2), nombre(3), nombre(4),
|
||||
];
|
||||
const existant = parCode.get(code);
|
||||
if (!existant) { parCode.set(code, ligne); continue; }
|
||||
// Même article chez plusieurs fournisseurs : une seule ligne.
|
||||
// Les mesures sont identiques (elles ne dépendent pas de la
|
||||
// dimension fournisseur), on complète juste les libellés.
|
||||
if (!existant[1] && codeAlt) existant[1] = codeAlt;
|
||||
if (!existant[2] && libelle) existant[2] = libelle;
|
||||
if (!existant[3] && fournisseur) existant[3] = fournisseur;
|
||||
}
|
||||
const uniques = [...parCode.values()];
|
||||
const rows = uniques.slice(0, limite);
|
||||
trace.push(
|
||||
"cube=" + hc.qSize.qcy + " lignes lues=" + matrix.length +
|
||||
" rejetées(hors terme)=" + rejetees +
|
||||
" articles uniques=" + uniques.length + " retenus=" + rows.length,
|
||||
);
|
||||
if (rows.length > 0) {
|
||||
trace.push("échantillon=" + JSON.stringify(rows.slice(0, 3)));
|
||||
}
|
||||
|
||||
ws.close();
|
||||
resolve({
|
||||
ok: true, rows, champUtilise: champChoisi, total: uniques.length,
|
||||
lues: matrix.length, rejetees, tropLarge, trace,
|
||||
});
|
||||
} catch (e) {
|
||||
try { ws.close(); } catch { /* noop */ }
|
||||
resolve({ ok: false, error: String((e as Error)?.message || e), trace });
|
||||
}
|
||||
})();
|
||||
}),
|
||||
{
|
||||
app: cfg.appNetwork,
|
||||
dim: cfg.dimCodeArticleId,
|
||||
token: csrfToken,
|
||||
terme: cleaned,
|
||||
limite: limit,
|
||||
settleMs,
|
||||
serialsDate: dateFilter.dailySerials,
|
||||
mCa: cfg.measCaId,
|
||||
mQte: cfg.measQteId,
|
||||
mNbMag: cfg.measNbMagId,
|
||||
mCaMag: cfg.measCaMagId,
|
||||
mMarge: cfg.measMargePctId,
|
||||
champCodeAlt: CHAMP_CODE_CENTRALE_ALT,
|
||||
champsLibellePreferes: CHAMPS_LIBELLE_PREFERES,
|
||||
champsFournisseurPreferes: CHAMPS_FOURNISSEUR_PREFERES,
|
||||
champLibelleForce: (process.env.QLIK_FIELD_ARTICLE_LIBELLE ?? "").trim(),
|
||||
champFournisseurForce: (process.env.QLIK_FIELD_FOURNISSEUR ?? "").trim(),
|
||||
},
|
||||
)) as InPageSearchResult;
|
||||
|
||||
for (const t of result.trace ?? []) console.log(`[qlik-search] ${t}`);
|
||||
if (!result.ok) throw new Error(`[qlik-search] ${result.error}`);
|
||||
|
||||
const matches: QlikArticleMatch[] = (result.rows ?? []).map(
|
||||
([codeCentrale, codeCentraleAlt, libelle, fournisseur, caReseau, qteReseau, nbMagasinsReseau, caParMagasinReseau, margePctReseau]) => ({
|
||||
codeCentrale, codeCentraleAlt, libelle, fournisseur,
|
||||
caReseau, qteReseau, nbMagasinsReseau, caParMagasinReseau, margePctReseau,
|
||||
}),
|
||||
);
|
||||
console.log(
|
||||
`[qlik-search] "${cleaned}" → ${matches.length} article(s) (total ${result.total ?? 0}, champ=${result.champUtilise}) en ${Date.now() - started} ms`,
|
||||
);
|
||||
|
||||
// Zéro article alors que Qlik a répondu : on explique pourquoi plutôt que
|
||||
// d'afficher un « aucun résultat » qui laisserait croire à une absence
|
||||
// réelle dans le réseau.
|
||||
let avertissement: string | null = null;
|
||||
if (matches.length === 0 && (result.rejetees ?? 0) > 0) {
|
||||
avertissement = "Qlik a répondu sans appliquer le filtre de recherche : les articles renvoyés ne correspondaient pas au terme et ont été écartés. Voir les logs [qlik-search].";
|
||||
} else if (matches.length === 0 && result.tropLarge) {
|
||||
avertissement = "Le terme est trop large pour Qlik (plus de 10 000 libellés correspondants). Ajoutez un mot plus précis.";
|
||||
}
|
||||
if (avertissement) console.warn(`[qlik-search] ${avertissement}`);
|
||||
|
||||
return {
|
||||
matches,
|
||||
champUtilise: result.champUtilise ?? null,
|
||||
tronque: (result.total ?? 0) > matches.length,
|
||||
avertissement,
|
||||
periode: dateFilter.label,
|
||||
};
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* CollectFlow — Détection des Server Actions périmées après un déploiement.
|
||||
*
|
||||
* Next.js identifie chaque Server Action par un hash calculé **à la compilation**.
|
||||
* Après un nouveau build (déploiement, redémarrage du conteneur), les hashs
|
||||
* changent : un onglet resté ouvert continue d'envoyer les anciens identifiants,
|
||||
* et le serveur répond
|
||||
*
|
||||
* Server Action "4010b9…" was not found on the server.
|
||||
*
|
||||
* Ce n'est pas une panne applicative : c'est le client qui est périmé. La seule
|
||||
* issue est de recharger la page pour récupérer le bundle courant.
|
||||
*
|
||||
* Les brouillons de la Grille sont persistés dans le store zustand
|
||||
* (localStorage) : recharger ne perd pas le travail en cours.
|
||||
*/
|
||||
|
||||
/** Message affiché à l'utilisateur quand son onglet est périmé. */
|
||||
export const STALE_ACTION_MESSAGE =
|
||||
"L'application a été mise à jour depuis l'ouverture de cet onglet. Rechargez la page pour continuer — vos modifications en cours sont conservées.";
|
||||
|
||||
/** true si l'erreur vient d'une Server Action absente du serveur (bundle périmé). */
|
||||
export function isStaleServerActionError(err: unknown): boolean {
|
||||
const message = err instanceof Error ? err.message : String(err ?? "");
|
||||
return /server action/i.test(message) && /not\s+be?\s*found|was not found|failed-to-find-server-action/i.test(message);
|
||||
}
|
||||
@@ -10,10 +10,17 @@ export default auth((req) => {
|
||||
|
||||
const isApiAuthRoute = nextUrl.pathname.startsWith("/api/auth");
|
||||
const isPublicRoute = nextUrl.pathname === "/login" || nextUrl.pathname.startsWith("/public/");
|
||||
// L'API publique s'authentifie elle-même (clé d'API ou session) et rend ses
|
||||
// propres erreurs JSON. Sans cette exemption, un script non authentifié
|
||||
// recevrait une redirection 302 + la page HTML de login au lieu d'un 401.
|
||||
const isPublicApiRoute = nextUrl.pathname.startsWith("/api/v1");
|
||||
|
||||
// 1. Laisser passer les requêtes d'auth API
|
||||
if (isApiAuthRoute) return NextResponse.next();
|
||||
|
||||
// 1 bis. Laisser passer /api/v1 — l'authentification est faite dans les handlers
|
||||
if (isPublicApiRoute) return NextResponse.next();
|
||||
|
||||
// 2. Rediriger vers /login si non connecté et route non publique
|
||||
if (!isLoggedIn && !isPublicRoute) {
|
||||
return NextResponse.redirect(new URL("/login", nextUrl));
|
||||
|
||||
+36
-2
@@ -89,7 +89,21 @@ export interface ProductRow {
|
||||
/** Données stock & approvisionnement (API FF Nancy) */
|
||||
pcb?: number; // Conditionnement (pack size)
|
||||
prixAchat?: number; // PA / PRMP
|
||||
prixVente?: number; // PV central
|
||||
/**
|
||||
* Prix de vente pratiqué, en euros.
|
||||
*
|
||||
* Source par ordre de préférence : `cube_pv` (prix de vente par article et
|
||||
* par site, pendant de `cube_pa` pour l'achat), puis le PV porté par
|
||||
* `cube_stock`, puis `article_infosup.prix_vente_mini` — cette dernière
|
||||
* n'est qu'une borne de paramétrage, vide en pratique.
|
||||
*
|
||||
* Quand les magasins ne pratiquent pas le même prix, cette valeur est la
|
||||
* plus élevée : `prixVenteByStore` porte le détail, et l'affichage signale
|
||||
* la divergence plutôt que d'inventer une moyenne que personne n'encaisse.
|
||||
*/
|
||||
prixVente?: number;
|
||||
/** Prix de vente par magasin (site → PV) — `cube_pv`. */
|
||||
prixVenteByStore?: Record<string, number>;
|
||||
stockActuel?: number; // Stock dispo agrégé tous sites
|
||||
stockTotal?: number; // Qte en stock agrégé
|
||||
stockValeur?: number; // Valeur stock agrégée
|
||||
@@ -113,8 +127,22 @@ export interface ProductRow {
|
||||
caReseau?: number;
|
||||
qteReseau?: number;
|
||||
nbMagasinsReseau?: number;
|
||||
/** CA moyen par magasin réseau (productivité normalisée présence) */
|
||||
caParMagasinReseau?: number;
|
||||
/** Taux de marge réseau (ratio brut Qlik, ex 0.32 = 32%) */
|
||||
margePctReseau?: number;
|
||||
/** Taux de présence réseau = nbMagasinsReseau / 270 */
|
||||
tauxPresenceReseau?: number;
|
||||
/** Quantité vendue réseau par mois : { "YYYY-MM": qté } (tendance / modal). */
|
||||
qteReseauByMonth?: Record<string, number> | null;
|
||||
/**
|
||||
* Nombre de magasins vendeurs par mois : { "YYYY-MM": nb }.
|
||||
*
|
||||
* Deuxième courbe de la carte tendance : une quantité qui monte parce que le
|
||||
* produit est diffusé dans plus de magasins ne raconte pas la même histoire
|
||||
* qu'une quantité qui monte à diffusion constante.
|
||||
*/
|
||||
nbMagReseauByMonth?: Record<string, number> | null;
|
||||
/** Fraîcheur des données réseau (ISO) */
|
||||
networkFetchedAt?: string;
|
||||
}
|
||||
@@ -135,7 +163,13 @@ export interface GridFilters {
|
||||
codeFournisseur: string | null;
|
||||
code1: string | null;
|
||||
code2: string | null;
|
||||
code3: string | null;
|
||||
/**
|
||||
* Nomenclatures retenues (codes `code3`).
|
||||
* `null` = aucun filtre (tout s'affiche) ; `[]` = rien de coché (aucune
|
||||
* ligne). Distinguer les deux évite la valeur sentinelle qu'imposerait un
|
||||
* simple tableau.
|
||||
*/
|
||||
code3: string[] | null;
|
||||
codeGamme: GammeCode | null;
|
||||
search: string;
|
||||
}
|
||||
Reference in new issue
Block a user