mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-11 17:26:32 +02:00
merge: intègre main — main prioritaire, abandon de la page en doublon
main a beaucoup avancé (53 commits) pendant que cette branche construisait l'API. La fusion a révélé que /produits, sur main, fait la même chose que la page Recherche réseau d'ici, en plus abouti : fiche produit, opportunités, URL partageables, et sa propre chaîne Qlik (lib/qlik-search, pgSearchProduits). Résolution, main prioritaire : - sidebar, heatmap-grid, qlik-playwright : version de main telle quelle. Son travail sur la tendance et Qlik est postérieur et plus complet. - get-product-rows : import NB_MAGASINS_RESEAU de main, plus l'appel upsertGridRows d'ici — sans lui /api/v1 n'aurait aucune donnée à servir. - settings : les deux côtés (ServerLogs de main + les sections API d'ici). Suppression du doublon : page recherche-reseau, /api/qlik/search, ses types et composants. searchNetworkProductsPlaywright disparaît avec la version main de qlik-playwright ; rien d'autre ne l'utilisait. api-enrich se branche désormais sur le module de tendance de main (features/grid/lib/network-trend) plutôt que sur le mien, supprimé : sa mesure est meilleure — variation sur la quantité par magasin vendeur, ce qui distingue « vend mieux » de « est distribué plus largement » — et l'API renvoie ainsi exactement ce qu'affiche la Grille. Le champ trend gagne surQteParMagasin et nouveau. Apport conservé de la branche : l'API /api/v1, absente de main. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675
This commit is contained in:
50 files changed
+8488
-2017
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 })
|
||||
);
|
||||
@@ -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
|
||||
|
||||
+4
-1
@@ -113,7 +113,10 @@ async function main() {
|
||||
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 "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";
|
||||
`);
|
||||
|
||||
@@ -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,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>
|
||||
);
|
||||
}
|
||||
@@ -1,525 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Search, Loader2, AlertTriangle, PackageSearch, FileDown, Info,
|
||||
ChevronUp, ChevronDown, ChevronsUpDown, Store, Sparkles,
|
||||
} from "lucide-react";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import {
|
||||
computeNetworkTrend, TrendSparkline, NetworkLineChart, TREND_COLOR, trendLabel,
|
||||
} from "@/features/network/components/network-trend";
|
||||
import type { NetworkSearchJobState, NetworkSearchRow } from "@/types/network-search";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// ─── Formatage ────────────────────────────────────────────────────────────────
|
||||
const fmtEur = (n?: number | null) =>
|
||||
n == null ? "-" : n.toLocaleString("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 });
|
||||
const fmtNum = (n?: number | null) =>
|
||||
n == null ? "-" : Math.round(n).toLocaleString("fr-FR");
|
||||
/** Qlik renvoie soit un ratio (0.32) soit déjà des points (32) — même règle que la Grille. */
|
||||
const toPct = (v: number) => (Math.abs(v) <= 1 ? v * 100 : v);
|
||||
|
||||
// ─── Tri ──────────────────────────────────────────────────────────────────────
|
||||
type SortKey = "libelle" | "codeCentrale" | "fournisseur" | "caReseau" | "qteReseau"
|
||||
| "nbMagasinsReseau" | "caParMagasinReseau" | "margePctReseau" | "trend" | "stockTotal";
|
||||
type SortDir = "asc" | "desc";
|
||||
|
||||
/** Filtres : tout / seulement nos références / seulement les produits inconnus. */
|
||||
type Filter = "all" | "mine" | "network";
|
||||
|
||||
export function RechercheReseauClient() {
|
||||
const [query, setQuery] = useState("");
|
||||
const [job, setJob] = useState<NetworkSearchJobState | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
const [filter, setFilter] = useState<Filter>("all");
|
||||
const [sortKey, setSortKey] = useState<SortKey>("caReseau");
|
||||
const [sortDir, setSortDir] = useState<SortDir>("desc");
|
||||
const [modalRow, setModalRow] = useState<NetworkSearchRow | null>(null);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const pollRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const isRunning = job?.status === "running";
|
||||
|
||||
// ── Lancement de la recherche ─────────────────────────────────────────────
|
||||
const startSearch = useCallback(async () => {
|
||||
const q = query.trim();
|
||||
if (!q || isRunning) return;
|
||||
setError(null);
|
||||
setElapsed(0);
|
||||
try {
|
||||
const res = await fetch("/api/qlik/search", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ query: q }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setError(data.error ?? `Erreur ${res.status}`);
|
||||
return;
|
||||
}
|
||||
setJob(data as NetworkSearchJobState);
|
||||
setFilter("all");
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}, [query, isRunning]);
|
||||
|
||||
// ── Polling tant que le job tourne ────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!isRunning || !job?.jobId) return;
|
||||
let cancelled = false;
|
||||
const tick = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/qlik/search?jobId=${encodeURIComponent(job.jobId!)}`);
|
||||
const data = await res.json();
|
||||
if (cancelled) return;
|
||||
if (res.ok) setJob(data as NetworkSearchJobState);
|
||||
} catch {
|
||||
// Réseau instable : on retentera au prochain tick.
|
||||
}
|
||||
if (!cancelled) pollRef.current = setTimeout(tick, 2000);
|
||||
};
|
||||
pollRef.current = setTimeout(tick, 1500);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (pollRef.current) clearTimeout(pollRef.current);
|
||||
};
|
||||
}, [isRunning, job?.jobId]);
|
||||
|
||||
// Chronomètre d'attente (l'extraction Qlik prend typiquement 20-40 s).
|
||||
useEffect(() => {
|
||||
if (!isRunning) return;
|
||||
const id = setInterval(() => setElapsed((e) => e + 1), 1000);
|
||||
return () => clearInterval(id);
|
||||
}, [isRunning]);
|
||||
|
||||
const results = useMemo(() => job?.results ?? [], [job?.results]);
|
||||
const counts = useMemo(() => ({
|
||||
all: results.length,
|
||||
mine: results.filter((r) => r.chezMoi).length,
|
||||
network: results.filter((r) => !r.chezMoi).length,
|
||||
}), [results]);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const filtered = results.filter((r) =>
|
||||
filter === "all" ? true : filter === "mine" ? r.chezMoi : !r.chezMoi);
|
||||
const dir = sortDir === "asc" ? 1 : -1;
|
||||
const val = (r: NetworkSearchRow): number | string => {
|
||||
switch (sortKey) {
|
||||
case "libelle": return r.libelle.toLowerCase();
|
||||
case "codeCentrale": return r.codeCentrale;
|
||||
case "fournisseur": return (r.fournisseur ?? r.nomfou ?? "").toLowerCase();
|
||||
case "trend": return computeNetworkTrend(r.qteByMonth).pct ?? -Infinity;
|
||||
case "stockTotal": return r.stockTotal ?? -1;
|
||||
default: return r[sortKey] ?? 0;
|
||||
}
|
||||
};
|
||||
return [...filtered].sort((a, b) => {
|
||||
const va = val(a), vb = val(b);
|
||||
if (typeof va === "string" || typeof vb === "string") {
|
||||
return String(va).localeCompare(String(vb), "fr") * dir;
|
||||
}
|
||||
return (va - vb) * dir;
|
||||
});
|
||||
}, [results, filter, sortKey, sortDir]);
|
||||
|
||||
const toggleSort = (key: SortKey) => {
|
||||
if (sortKey === key) setSortDir((d) => (d === "asc" ? "desc" : "asc"));
|
||||
else { setSortKey(key); setSortDir(key === "libelle" || key === "codeCentrale" || key === "fournisseur" ? "asc" : "desc"); }
|
||||
};
|
||||
|
||||
// ── Export Excel ──────────────────────────────────────────────────────────
|
||||
const exportExcel = useCallback(async () => {
|
||||
if (!rows.length || exporting) return;
|
||||
setExporting(true);
|
||||
try {
|
||||
const ExcelJS = (await import("exceljs")).default;
|
||||
const wb = new ExcelJS.Workbook();
|
||||
const ws = wb.addWorksheet("Recherche réseau");
|
||||
// Colonnes mensuelles = union des mois présents, triée chronologiquement.
|
||||
const months = [...new Set(rows.flatMap((r) => Object.keys(r.qteByMonth ?? {})))].sort().slice(-12);
|
||||
ws.columns = [
|
||||
{ header: "Libellé", key: "libelle", width: 46 },
|
||||
{ header: "Code centrale", key: "code", width: 15 },
|
||||
{ header: "Fournisseur", key: "fou", width: 24 },
|
||||
{ header: "Famille", key: "fam", width: 20 },
|
||||
{ header: "Statut", key: "statut", width: 18 },
|
||||
{ header: "CA réseau", key: "ca", width: 14 },
|
||||
{ header: "Qté réseau", key: "qte", width: 12 },
|
||||
{ header: "Nb magasins", key: "nbmag", width: 12 },
|
||||
{ header: "CA / magasin", key: "camag", width: 14 },
|
||||
{ header: "Marge %", key: "marge", width: 10 },
|
||||
{ header: "Code interne", key: "codein", width: 14 },
|
||||
{ header: "Stock 292", key: "s292", width: 10 },
|
||||
{ header: "Stock 579", key: "s579", width: 10 },
|
||||
...months.map((m) => ({ header: m, key: `m_${m}`, width: 9 })),
|
||||
];
|
||||
ws.getRow(1).font = { bold: true };
|
||||
for (const r of rows) {
|
||||
const monthCells: Record<string, number> = {};
|
||||
for (const m of months) monthCells[`m_${m}`] = r.qteByMonth?.[m] ?? 0;
|
||||
ws.addRow({
|
||||
libelle: r.libelle,
|
||||
code: r.codeCentrale,
|
||||
fou: r.fournisseur ?? r.nomfou ?? "",
|
||||
fam: r.famille ?? "",
|
||||
statut: r.chezMoi ? "Chez moi" : "Réseau uniquement",
|
||||
ca: Math.round(r.caReseau),
|
||||
qte: Math.round(r.qteReseau),
|
||||
nbmag: r.nbMagasinsReseau,
|
||||
camag: Math.round(r.caParMagasinReseau),
|
||||
marge: Number(toPct(r.margePctReseau).toFixed(1)),
|
||||
codein: r.codein ?? "",
|
||||
s292: r.stock292 ?? "",
|
||||
s579: r.stock579 ?? "",
|
||||
...monthCells,
|
||||
});
|
||||
}
|
||||
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 = `recherche-reseau-${(job?.query ?? "").replace(/[^\w-]+/g, "_").slice(0, 40)}.xlsx`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
}, [rows, exporting, job?.query]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen p-6" style={{ background: "var(--bg-base)" }}>
|
||||
<div className="mx-auto max-w-screen-2xl">
|
||||
{/* ── En-tête ── */}
|
||||
<div className="mb-5">
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2" style={{ color: "var(--text-primary)" }}>
|
||||
<PackageSearch className="w-6 h-6" style={{ color: "var(--accent)" }} />
|
||||
Recherche réseau
|
||||
</h1>
|
||||
<p className="text-[13px] mt-1" style={{ color: "var(--text-muted)" }}>
|
||||
Cherche dans le catalogue <strong>Qlik du réseau</strong> — y compris les produits que nous ne
|
||||
référençons pas. Ventes mois par mois sur 12 mois glissants.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ── Barre de recherche ── */}
|
||||
<div className="rounded-2xl p-4 mb-4" style={{ background: "var(--bg-surface)", border: "1px solid var(--border)" }}>
|
||||
<div className="flex flex-col sm:flex-row gap-2.5">
|
||||
<div className="relative flex-1">
|
||||
<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
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") startSearch(); }}
|
||||
placeholder="Nom du produit (ex. tapis anti) ou code centrale (ex. 10000459340)"
|
||||
disabled={isRunning}
|
||||
className="w-full rounded-xl pl-9 pr-3 py-2.5 text-[14px] outline-none disabled:opacity-60"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-primary)" }}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={startSearch}
|
||||
disabled={isRunning || !query.trim()}
|
||||
className="rounded-xl px-5 py-2.5 text-[14px] font-semibold text-white disabled:opacity-50 flex items-center justify-center gap-2 shrink-0"
|
||||
style={{ background: "var(--accent)" }}
|
||||
>
|
||||
{isRunning ? <><Loader2 className="w-4 h-4 animate-spin" /> Recherche… {elapsed}s</> : <><Search className="w-4 h-4" /> Rechercher</>}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[11px] mt-2 flex items-start gap-1.5" style={{ color: "var(--text-muted)" }}>
|
||||
<Info className="w-3.5 h-3.5 shrink-0 mt-px" />
|
||||
L'interrogation de Qlik prend en général 20 à 40 secondes. Plusieurs codes centraux peuvent être
|
||||
collés d'un coup (séparés par des espaces ou des virgules).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ── Erreurs ── */}
|
||||
{error && (
|
||||
<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>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
{job?.status === "error" && job.error && (
|
||||
<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>{job.error}</span>
|
||||
</div>
|
||||
)}
|
||||
{job?.status === "success" && job.tooMany && (
|
||||
<div className="rounded-xl p-3.5 mb-4 flex items-start gap-2.5 text-[13px]"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}>
|
||||
<AlertTriangle className="w-4 h-4 shrink-0 mt-px" />
|
||||
<span>
|
||||
Recherche trop large : <strong>{job.matchCount}</strong> produits correspondent
|
||||
{job.maxProducts ? <> (maximum {job.maxProducts})</> : null}. Précisez les termes pour
|
||||
protéger le moteur Qlik.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{job?.status === "success" && !job.tooMany && results.length === 0 && (
|
||||
<div className="rounded-xl p-6 text-center text-[13px]"
|
||||
style={{ background: "var(--bg-surface)", border: "1px solid var(--border)", color: "var(--text-muted)" }}>
|
||||
Aucun produit trouvé dans Qlik pour « {job.query} ».
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Résultats ── */}
|
||||
{results.length > 0 && (
|
||||
<>
|
||||
{/* Filtres + résumé + export */}
|
||||
<div className="flex flex-wrap items-center gap-2 mb-3">
|
||||
{([
|
||||
{ key: "all" as Filter, label: "Tous", n: counts.all },
|
||||
{ key: "mine" as Filter, label: "Chez moi", n: counts.mine },
|
||||
{ key: "network" as Filter, label: "Réseau uniquement", n: counts.network },
|
||||
]).map(({ key, label, n }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setFilter(key)}
|
||||
className={cn("rounded-full px-3.5 py-1.5 text-[12px] font-semibold transition-colors")}
|
||||
style={{
|
||||
background: filter === key ? "var(--accent)" : "var(--bg-elevated)",
|
||||
color: filter === key ? "#fff" : "var(--text-secondary)",
|
||||
border: "1px solid var(--border)",
|
||||
}}
|
||||
>
|
||||
{label} <span className="opacity-70">({n})</span>
|
||||
</button>
|
||||
))}
|
||||
<div className="flex-1" />
|
||||
{job?.periode && (
|
||||
<span className="text-[11px]" style={{ color: "var(--text-muted)" }}>
|
||||
Période Qlik : {job.periode}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={exportExcel}
|
||||
disabled={exporting}
|
||||
className="rounded-xl px-3 py-1.5 text-[12px] font-semibold flex items-center gap-1.5 disabled:opacity-50"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}
|
||||
>
|
||||
{exporting ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <FileDown className="w-3.5 h-3.5" />}
|
||||
Excel
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Bandeau opportunités : produits forts au réseau que nous n'avons pas */}
|
||||
{counts.network > 0 && filter !== "mine" && (
|
||||
<div className="rounded-xl p-3 mb-3 flex items-start gap-2.5 text-[12px]"
|
||||
style={{ background: "var(--accent-bg)", border: "1px solid var(--accent-border)", color: "var(--text-secondary)" }}>
|
||||
<Sparkles className="w-4 h-4 shrink-0 mt-px" style={{ color: "var(--accent)" }} />
|
||||
<span>
|
||||
<strong>{counts.network}</strong> produit{counts.network > 1 ? "s" : ""} vendu
|
||||
{counts.network > 1 ? "s" : ""} par le réseau que nous ne référençons pas — trier par
|
||||
« CA / Mag » ou « Nb mag » fait remonter les meilleures opportunités d'assortiment.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ResultsTable
|
||||
rows={rows}
|
||||
sortKey={sortKey}
|
||||
sortDir={sortDir}
|
||||
onSort={toggleSort}
|
||||
onRowClick={setModalRow}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog open={modalRow !== null} onOpenChange={(open) => { if (!open) setModalRow(null); }}>
|
||||
{modalRow !== null && <ProductDetailModal row={modalRow} />}
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Tableau des résultats ────────────────────────────────────────────────────
|
||||
|
||||
const COLUMNS: Array<{ key: SortKey; label: string; sub?: string; align: "left" | "center" | "right" }> = [
|
||||
{ key: "libelle", label: "Produit", align: "left" },
|
||||
{ key: "codeCentrale", label: "Code centrale", align: "left" },
|
||||
{ key: "fournisseur", label: "Fournisseur", align: "left" },
|
||||
{ key: "caReseau", label: "CA", sub: "Réseau", align: "right" },
|
||||
{ key: "qteReseau", label: "Qté", sub: "Réseau", align: "right" },
|
||||
{ key: "nbMagasinsReseau", label: "Nb mag", sub: "Réseau", align: "right" },
|
||||
{ key: "caParMagasinReseau", label: "CA / Mag", sub: "Réseau", align: "right" },
|
||||
{ key: "margePctReseau", label: "Marge %", sub: "Réseau", align: "right" },
|
||||
{ key: "trend", label: "Tendance", sub: "12 mois", align: "center" },
|
||||
{ key: "stockTotal", label: "Stock", sub: "292 + 579", align: "right" },
|
||||
];
|
||||
|
||||
function ResultsTable({ rows, sortKey, sortDir, onSort, onRowClick }: {
|
||||
rows: NetworkSearchRow[];
|
||||
sortKey: SortKey;
|
||||
sortDir: SortDir;
|
||||
onSort: (k: SortKey) => void;
|
||||
onRowClick: (r: NetworkSearchRow) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-2xl overflow-hidden" style={{ background: "var(--bg-surface)", border: "1px solid var(--border)" }}>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-[12px] border-collapse">
|
||||
<thead>
|
||||
<tr style={{ background: "var(--bg-elevated)" }}>
|
||||
{COLUMNS.map((c) => {
|
||||
const active = sortKey === c.key;
|
||||
const Icon = !active ? ChevronsUpDown : sortDir === "asc" ? ChevronUp : ChevronDown;
|
||||
return (
|
||||
<th
|
||||
key={c.key}
|
||||
onClick={() => onSort(c.key)}
|
||||
className="px-3 py-2.5 font-semibold cursor-pointer select-none whitespace-nowrap"
|
||||
style={{ color: "var(--text-secondary)", borderBottom: "1px solid var(--border)", textAlign: c.align }}
|
||||
>
|
||||
<span className={cn("inline-flex items-center gap-1", c.align === "right" && "flex-row-reverse")}>
|
||||
<Icon className="w-3 h-3 shrink-0" style={{ opacity: active ? 1 : 0.35 }} />
|
||||
<span>
|
||||
{c.label}
|
||||
{c.sub && <><br /><span className="text-[9px] opacity-60 font-normal">{c.sub}</span></>}
|
||||
</span>
|
||||
</span>
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => {
|
||||
const trend = computeNetworkTrend(r.qteByMonth);
|
||||
const margePct = toPct(r.margePctReseau);
|
||||
return (
|
||||
<tr
|
||||
key={r.codeCentrale}
|
||||
onClick={() => onRowClick(r)}
|
||||
className="cursor-pointer hover:bg-[var(--bg-elevated)] transition-colors"
|
||||
style={{ borderBottom: "1px solid var(--border)" }}
|
||||
>
|
||||
<td className="px-3 py-2 max-w-[380px]">
|
||||
<div className="font-medium truncate" style={{ color: "var(--text-primary)" }} title={r.libelle}>
|
||||
{r.libelle}
|
||||
</div>
|
||||
<span
|
||||
className="inline-block mt-0.5 rounded px-1.5 py-px text-[9px] font-bold uppercase tracking-wide"
|
||||
style={r.chezMoi
|
||||
? { background: "var(--accent-bg)", color: "var(--accent)" }
|
||||
: { background: "rgba(245,158,11,0.15)", color: "#d97706" }}
|
||||
>
|
||||
{r.chezMoi ? `Chez moi${r.codein ? ` · ${r.codein}` : ""}` : "Réseau uniquement"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 tabular-nums" style={{ color: "var(--text-secondary)" }}>{r.codeCentrale}</td>
|
||||
<td className="px-3 py-2 max-w-[180px] truncate" style={{ color: "var(--text-secondary)" }} title={r.fournisseur ?? r.nomfou ?? ""}>
|
||||
{r.fournisseur ?? r.nomfou ?? "-"}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums font-semibold" style={{ color: "var(--text-primary)" }}>{fmtEur(r.caReseau)}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums" style={{ color: "var(--text-secondary)" }}>{fmtNum(r.qteReseau)}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums" style={{ color: "var(--text-secondary)" }}>{fmtNum(r.nbMagasinsReseau)}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums font-semibold" style={{ color: "var(--text-secondary)" }}>{fmtEur(r.caParMagasinReseau)}</td>
|
||||
<td className={cn("px-3 py-2 text-right tabular-nums font-bold",
|
||||
margePct >= 30 ? "text-emerald-500" : margePct >= 15 ? "text-amber-500" : "text-rose-500")}>
|
||||
{margePct.toLocaleString("fr-FR", { maximumFractionDigits: 1 })}%
|
||||
</td>
|
||||
<td className="px-3 py-2"><TrendSparkline trend={trend} /></td>
|
||||
<td className="px-3 py-2 text-right tabular-nums" style={{ color: "var(--text-secondary)" }}>
|
||||
{r.chezMoi ? fmtNum(r.stockTotal ?? 0) : "-"}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Fiche produit ────────────────────────────────────────────────────────────
|
||||
|
||||
/** Détail d'un produit : courbe des ventes réseau 12 mois + informations Qlik / locales. */
|
||||
function ProductDetailModal({ row }: { row: NetworkSearchRow }) {
|
||||
const trend = computeNetworkTrend(row.qteByMonth);
|
||||
const { values, labels, direction, pct } = trend;
|
||||
const color = TREND_COLOR[direction];
|
||||
const margePct = toPct(row.margePctReseau);
|
||||
|
||||
const infos: Array<{ label: string; value: string }> = [
|
||||
{ label: "Code centrale", value: row.codeCentrale },
|
||||
{ label: "Fournisseur", value: row.fournisseur ?? row.nomfou ?? "-" },
|
||||
...(row.famille ? [{ label: "Famille", value: row.famille }] : []),
|
||||
{ label: "CA réseau", value: fmtEur(row.caReseau) },
|
||||
{ label: "Qté réseau", value: fmtNum(row.qteReseau) },
|
||||
{ label: "Nb magasins", value: fmtNum(row.nbMagasinsReseau) },
|
||||
{ label: "CA / magasin", value: fmtEur(row.caParMagasinReseau) },
|
||||
{ label: "Marge % réseau", value: `${margePct.toLocaleString("fr-FR", { maximumFractionDigits: 1 })}%` },
|
||||
];
|
||||
if (row.chezMoi) {
|
||||
infos.push({ label: "Code interne", value: row.codein ?? "-" });
|
||||
if (row.pa != null) infos.push({ label: "PA", value: fmtEur(row.pa) });
|
||||
if (row.pvCentral != null) infos.push({ label: "PV central", value: fmtEur(row.pvCentral) });
|
||||
infos.push({ label: "Stock 292", value: fmtNum(row.stock292 ?? 0) });
|
||||
infos.push({ label: "Stock 579", value: fmtNum(row.stock579 ?? 0) });
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogContent className="max-w-lg" style={{ background: "var(--bg-surface)", border: "1px solid var(--border)" }}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base leading-snug pr-6" style={{ color: "var(--text-primary)" }}>
|
||||
{row.libelle}
|
||||
</DialogTitle>
|
||||
<p className="text-[12px] mt-0.5 flex flex-wrap items-center gap-x-1.5" style={{ color: "var(--text-muted)" }}>
|
||||
<span
|
||||
className="rounded px-1.5 py-px text-[9px] font-bold uppercase tracking-wide"
|
||||
style={row.chezMoi
|
||||
? { background: "var(--accent-bg)", color: "var(--accent)" }
|
||||
: { background: "rgba(245,158,11,0.15)", color: "#d97706" }}
|
||||
>
|
||||
{row.chezMoi ? "Chez moi" : "Réseau uniquement"}
|
||||
</span>
|
||||
<span>Ventes réseau · {labels.length > 0 ? `${labels.length} derniers mois` : "12 derniers mois"}</span>
|
||||
{pct != null && (
|
||||
<span className="font-bold" style={{ color }}>
|
||||
· {pct >= 0 ? "+" : ""}{Math.round(pct * 100)}% · {trendLabel(pct)}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</DialogHeader>
|
||||
|
||||
{!trend.hasData ? (
|
||||
<div className="mt-3 rounded-xl p-4 text-center text-[13px]"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-muted)" }}>
|
||||
Pas de détail mensuel renvoyé par Qlik pour ce produit.
|
||||
</div>
|
||||
) : (
|
||||
<NetworkLineChart labels={labels} values={values} color={color} />
|
||||
)}
|
||||
|
||||
<div className="mt-3 grid grid-cols-2 gap-x-4 gap-y-1.5">
|
||||
{infos.map((i) => (
|
||||
<div key={i.label} className="flex items-baseline justify-between gap-2 text-[12px]">
|
||||
<span style={{ color: "var(--text-muted)" }}>{i.label}</span>
|
||||
<span className="font-semibold tabular-nums text-right" style={{ color: "var(--text-primary)" }}>{i.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!row.chezMoi && (
|
||||
<div className="mt-3 rounded-xl p-3 flex items-start gap-2 text-[12px]"
|
||||
style={{ background: "var(--accent-bg)", border: "1px solid var(--accent-border)", color: "var(--text-secondary)" }}>
|
||||
<Store className="w-4 h-4 shrink-0 mt-px" style={{ color: "var(--accent)" }} />
|
||||
<span>
|
||||
Ce produit est vendu dans <strong>{fmtNum(row.nbMagasinsReseau)}</strong> magasin
|
||||
{row.nbMagasinsReseau > 1 ? "s" : ""} du réseau et génère <strong>{fmtEur(row.caParMagasinReseau)}</strong> de
|
||||
CA par magasin, alors que nous ne le référençons pas.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import { RechercheReseauClient } from "./client";
|
||||
|
||||
export const metadata = {
|
||||
title: "Recherche réseau — CollectFlow",
|
||||
};
|
||||
|
||||
/**
|
||||
* Recherche dans le catalogue réseau Qlik.
|
||||
*
|
||||
* Contrairement à la Grille (qui part de NOTRE catalogue), cette page interroge
|
||||
* Qlik en premier : elle affiche donc aussi les produits vendus par le réseau
|
||||
* que FF Nancy ne référence pas. Tout le travail est fait côté client via
|
||||
* /api/qlik/search (job asynchrone), l'extraction Qlik prenant ~20-40 s.
|
||||
*/
|
||||
export default function RechercheReseauPage() {
|
||||
return <RechercheReseauClient />;
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { UserManagement } from "@/features/admin/components/user-management";
|
||||
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";
|
||||
|
||||
interface OpenRouterModel { id: string; name: string; free: boolean; }
|
||||
|
||||
@@ -670,6 +671,14 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* 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 */}
|
||||
<Section
|
||||
title="Gestion des Utilisateurs"
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -1,219 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { searchNetworkProductsPlaywright } from "@/lib/qlik-playwright";
|
||||
import { upsertNetworkMetrics } from "@/lib/qlik-network-cache";
|
||||
import { pgGetArticlesByCodeCentrale, pgGetStockForCodeins, type StockBySite } from "@/lib/pg-ff-client";
|
||||
import { buildGridNetworkQlikDateFilter, envMonthsBack, QLIK_MONTHS_BACK_DEFAULT } from "@/lib/qlik-date-range";
|
||||
import type { NetworkSearchJobState, NetworkSearchRow } from "@/types/network-search";
|
||||
|
||||
// Comme la sync : l'ouverture de l'app Qlik + les cubes peuvent prendre plusieurs
|
||||
// dizaines de secondes. On rend la main immédiatement et le client polle GET.
|
||||
export const maxDuration = 300;
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
interface SearchJob extends NetworkSearchJobState {
|
||||
jobId: string;
|
||||
status: "running" | "success" | "error";
|
||||
}
|
||||
|
||||
/**
|
||||
* Store en mémoire (même parti pris que /api/qlik/sync : simple, perdu au restart).
|
||||
*
|
||||
* `activeJobId` sérialise les recherches : le moteur Qlik sature facilement, on ne
|
||||
* lance donc **qu'une extraction à la fois** pour toute l'application.
|
||||
*/
|
||||
const jobs = new Map<string, SearchJob>();
|
||||
let activeJobId: string | null = null;
|
||||
|
||||
/** Ne garde que les N derniers jobs pour éviter une fuite mémoire. */
|
||||
function pruneJobs(keep = 20): void {
|
||||
if (jobs.size <= keep) return;
|
||||
const ordered = [...jobs.values()].sort((a, b) => String(a.startedAt).localeCompare(String(b.startedAt)));
|
||||
for (const j of ordered.slice(0, jobs.size - keep)) {
|
||||
if (j.jobId !== activeJobId) jobs.delete(j.jobId);
|
||||
}
|
||||
}
|
||||
|
||||
function newJobId(): string {
|
||||
return `qsearch_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
/** Vérifie qu'une session est ouverte. Renvoie une 401 sinon. */
|
||||
async function requireSession(): Promise<NextResponse | null> {
|
||||
const session = await auth();
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Traduit une erreur Qlik en message actionnable. La saturation mémoire du serveur
|
||||
* Qlik est le cas le plus fréquent et n'est PAS un bug CollectFlow : l'application
|
||||
* Qlik ne se charge même pas.
|
||||
*/
|
||||
function humanizeQlikError(msg: string): string {
|
||||
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')) {
|
||||
return "Serveur Qlik saturé : mémoire insuffisante pour charger l'application. Réessayez plus tard (ou faites libérer / augmenter la RAM du serveur Qlik).";
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
|
||||
/** Exécute la recherche Qlik puis enrichit avec le catalogue local. */
|
||||
async function runJob(job: SearchJob, maxProducts: number): Promise<void> {
|
||||
try {
|
||||
const monthsBack = envMonthsBack("QLIK_SYNC_MONTHS_BACK", QLIK_MONTHS_BACK_DEFAULT);
|
||||
const dateFilter = buildGridNetworkQlikDateFilter(new Date(), monthsBack);
|
||||
console.log(`[api/qlik/search] job=${job.jobId} query="${job.query}" fenêtre=${dateFilter.label}`);
|
||||
|
||||
const res = await searchNetworkProductsPlaywright(job.query, { maxProducts }, undefined, dateFilter);
|
||||
job.mode = res.mode;
|
||||
job.matchCount = res.matchCount;
|
||||
job.periode = res.periode;
|
||||
|
||||
if (res.tooMany) {
|
||||
job.tooMany = true;
|
||||
job.results = [];
|
||||
job.status = "success";
|
||||
job.finishedAt = new Date().toISOString();
|
||||
console.log(`[api/qlik/search] job=${job.jobId} recherche trop large (${res.matchCount} > ${maxProducts})`);
|
||||
return;
|
||||
}
|
||||
|
||||
const products = res.products;
|
||||
console.log(`[api/qlik/search] job=${job.jobId} ← Qlik: ${products.length} produits`);
|
||||
|
||||
// Mise en cache : accélère les recherches suivantes ET enrichit la Grille
|
||||
// pour les codes que nous référençons.
|
||||
if (products.length) {
|
||||
try {
|
||||
const n = await upsertNetworkMetrics(products);
|
||||
console.log(`[api/qlik/search] job=${job.jobId} ${n} lignes mises en cache`);
|
||||
} catch (e) {
|
||||
// Non fatal : la recherche reste exploitable sans cache.
|
||||
console.error(`[api/qlik/search] job=${job.jobId} cache KO:`, e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}
|
||||
|
||||
// Enrichissement local : qui connaît-on déjà, et avec quel stock ?
|
||||
const locaux = await pgGetArticlesByCodeCentrale(products.map((p) => p.codeCentrale));
|
||||
const codeins = [...locaux.values()].map((a) => a.codein).filter(Boolean);
|
||||
const stocks: Map<string, StockBySite> = codeins.length
|
||||
? await pgGetStockForCodeins(codeins)
|
||||
: new Map<string, StockBySite>();
|
||||
|
||||
const rows: NetworkSearchRow[] = products.map((p) => {
|
||||
const local = locaux.get(p.codeCentrale);
|
||||
const stock = local?.codein ? stocks.get(local.codein) : undefined;
|
||||
return {
|
||||
codeCentrale: p.codeCentrale,
|
||||
libelle: p.libelle || local?.libelle1 || p.codeCentrale,
|
||||
fournisseur: p.fournisseur,
|
||||
famille: p.famille,
|
||||
caReseau: p.caReseau,
|
||||
qteReseau: p.qteReseau,
|
||||
nbMagasinsReseau: p.nbMagasinsReseau,
|
||||
caParMagasinReseau: p.caParMagasinReseau,
|
||||
margePctReseau: p.margePctReseau,
|
||||
qteByMonth: p.qteByMonth ?? null,
|
||||
chezMoi: Boolean(local),
|
||||
codein: local?.codein,
|
||||
libelleLocal: local?.libelle1,
|
||||
codefou: local?.codefou,
|
||||
nomfou: local?.nomfou,
|
||||
pa: local?.pa != null ? Number(local.pa) : undefined,
|
||||
pvCentral: local?.pv_central != null ? Number(local.pv_central) : undefined,
|
||||
stock292: stock?.stock292,
|
||||
stock579: stock?.stock579,
|
||||
stockTotal: stock?.stockTotal,
|
||||
};
|
||||
});
|
||||
|
||||
job.results = rows;
|
||||
job.status = "success";
|
||||
job.finishedAt = new Date().toISOString();
|
||||
console.log(`[api/qlik/search] job=${job.jobId} terminé — ${rows.length} lignes (${rows.filter((r) => r.chezMoi).length} déjà référencées)`);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
console.error(`[api/qlik/search] job=${job.jobId}`, msg);
|
||||
job.status = "error";
|
||||
job.error = humanizeQlikError(msg);
|
||||
job.finishedAt = new Date().toISOString();
|
||||
} finally {
|
||||
if (activeJobId === job.jobId) activeJobId = null;
|
||||
pruneJobs();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/qlik/search?jobId=XXX
|
||||
* État + résultats du job. Sans jobId, renvoie le job actif s'il y en a un.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
const denied = await requireSession();
|
||||
if (denied) return denied;
|
||||
|
||||
const jobId = req.nextUrl.searchParams.get("jobId");
|
||||
const job = jobId ? jobs.get(jobId) : (activeJobId ? jobs.get(activeJobId) : undefined);
|
||||
if (!job) {
|
||||
const idle: NetworkSearchJobState = { jobId: null, status: "idle", query: "" };
|
||||
return NextResponse.json({ success: true, ...idle });
|
||||
}
|
||||
return NextResponse.json({ success: true, ...job });
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/qlik/search body: { query: string, maxProducts?: number }
|
||||
* Démarre une recherche en arrière-plan et retourne immédiatement le job.
|
||||
* Refuse (409) si une recherche tourne déjà — le moteur Qlik ne supporte pas
|
||||
* plusieurs extractions concurrentes.
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
const denied = await requireSession();
|
||||
if (denied) return denied;
|
||||
|
||||
let body: { query?: unknown; maxProducts?: unknown };
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Corps JSON invalide" }, { status: 400 });
|
||||
}
|
||||
|
||||
const query = String(body.query ?? "").trim();
|
||||
if (!query) return NextResponse.json({ error: "Param 'query' requis" }, { status: 400 });
|
||||
if (query.length > 120) return NextResponse.json({ error: "Recherche trop longue (120 caractères max)" }, { status: 400 });
|
||||
|
||||
const rawMax = Number(body.maxProducts);
|
||||
const maxProducts = Number.isFinite(rawMax) ? Math.min(2000, Math.max(1, Math.trunc(rawMax))) : 300;
|
||||
|
||||
const active = activeJobId ? jobs.get(activeJobId) : undefined;
|
||||
if (active && active.status === "running") {
|
||||
return NextResponse.json(
|
||||
{ error: `Une recherche est déjà en cours ("${active.query}"). Attendez qu'elle se termine.`, ...active },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
const job: SearchJob = {
|
||||
jobId: newJobId(),
|
||||
status: "running",
|
||||
query,
|
||||
maxProducts,
|
||||
startedAt: new Date().toISOString(),
|
||||
};
|
||||
jobs.set(job.jobId, job);
|
||||
activeJobId = job.jobId;
|
||||
console.log(`[api/qlik/search] job=${job.jobId} démarré — query="${query}" max=${maxProducts}`);
|
||||
|
||||
// Fire-and-forget : le client polle GET. runJob capture ses propres erreurs.
|
||||
void runJob(job, maxProducts).catch((e) => {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
console.error(`[api/qlik/search] job=${job.jobId} crash non géré`, msg);
|
||||
job.status = "error";
|
||||
job.error = msg;
|
||||
job.finishedAt = new Date().toISOString();
|
||||
if (activeJobId === job.jobId) activeJobId = null;
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true, ...job });
|
||||
}
|
||||
+191
-29
@@ -5,11 +5,9 @@ import { upsertNetworkMetrics } from "@/lib/qlik-network-cache";
|
||||
import { pgGetArticlesByFournisseur } from "@/lib/pg-ff-client";
|
||||
import {
|
||||
buildGridNetworkQlikDateFilter,
|
||||
envMonthsBack,
|
||||
QLIK_MONTHS_BACK_DEFAULT,
|
||||
QLIK_MONTHS_BACK_MAX,
|
||||
QLIK_MONTHS_BACK_MIN,
|
||||
} from "@/lib/qlik-date-range";
|
||||
import { startCapture, stopCapture } from "@/lib/log-capture";
|
||||
|
||||
// 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
|
||||
@@ -26,7 +24,10 @@ 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;
|
||||
@@ -48,6 +49,33 @@ interface QlikSyncJob {
|
||||
*/
|
||||
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)}`;
|
||||
@@ -58,6 +86,7 @@ function publicJob(job: QlikSyncJob) {
|
||||
return {
|
||||
jobId: job.jobId,
|
||||
fournisseur: job.fournisseur,
|
||||
codeCentrale: job.codeCentrale,
|
||||
status: job.status,
|
||||
requested: job.requested,
|
||||
fetched: job.fetched,
|
||||
@@ -72,11 +101,12 @@ function publicJob(job: QlikSyncJob) {
|
||||
};
|
||||
}
|
||||
|
||||
/** État "idle" sérialisé (pas de job connu pour ce fournisseur). */
|
||||
function idleJob(fournisseur: string) {
|
||||
/** É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,
|
||||
@@ -105,6 +135,20 @@ async function requireAdmin(): Promise<NextResponse | null> {
|
||||
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".
|
||||
*
|
||||
@@ -172,6 +216,10 @@ function filterCentralCodes(rawCodes: Array<string | null | undefined>): {
|
||||
* 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).
|
||||
@@ -189,18 +237,20 @@ async function runJob(job: QlikSyncJob): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
// Fenêtre temporelle alignée sur la grille, éventuellement raccourcie via
|
||||
// QLIK_SYNC_MONTHS_BACK (1..12, défaut 12). On logue la valeur effective
|
||||
// pour audit.
|
||||
const monthsBack = envMonthsBack("QLIK_SYNC_MONTHS_BACK", QLIK_MONTHS_BACK_DEFAULT);
|
||||
const dateFilter = buildGridNetworkQlikDateFilter(new Date(), monthsBack);
|
||||
// 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 — fenêtre ${dateFilter.label} (${dateFilter.dateDebut} → ${dateFilter.dateFin}, QLIK_SYNC_MONTHS_BACK=${monthsBack}, bornes ${QLIK_MONTHS_BACK_MIN}..${QLIK_MONTHS_BACK_MAX})…`,
|
||||
`[api/qlik/sync] job=${job.jobId} → extraction Qlik pour ${codes.length} codes — 12 mois complets ${dateFilter.label} (${dateFilter.dateDebut} → ${dateFilter.dateFin})…`,
|
||||
);
|
||||
const metrics = await fetchNetworkMetricsPlaywright(codes, undefined, dateFilter);
|
||||
// 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()]);
|
||||
@@ -209,34 +259,99 @@ async function runJob(job: QlikSyncJob): Promise<void> {
|
||||
job.status = "success";
|
||||
job.finishedAt = new Date().toISOString();
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
console.error(`[api/qlik/sync] job=${job.jobId}`, msg);
|
||||
job.status = "error";
|
||||
// Message explicite quand le serveur Qlik est saturé (OOM à l'ouverture de
|
||||
// l'app) : ce n'est PAS un bug CollectFlow, l'app Qlik ne se charge même pas.
|
||||
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();
|
||||
applyJobError(job, e);
|
||||
} finally {
|
||||
await stopCapture(job.jobId, `statut final=${job.status}${job.error ? ` — ${job.error}` : ""}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/qlik/sync?fournisseur=XXX
|
||||
* Retourne l'état courant du job pour ce fournisseur. Si aucun job n'est
|
||||
* 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;
|
||||
|
||||
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 });
|
||||
}
|
||||
const job = jobs.get(fournisseur);
|
||||
if (!job) {
|
||||
@@ -252,12 +367,59 @@ export async function GET(req: NextRequest) {
|
||||
* ensuite poller GET pour suivre l'avancement et être notifié de la fin.
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
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 });
|
||||
}
|
||||
|
||||
const existing = jobs.get(fournisseur);
|
||||
|
||||
@@ -14,7 +14,7 @@ export const dynamic = "force-dynamic";
|
||||
*
|
||||
* 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 `/api/qlik/search` — bien plus lent, et volontairement
|
||||
* référençons pas), c'est la page Produits — bien plus lente, et volontairement
|
||||
* séparé de cette API.
|
||||
*/
|
||||
export async function GET(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { LayoutGrid, Camera, FileDown, Settings, Package, BarChart2, TrendingUp, Trophy, Megaphone, PackageMinus, PackageSearch, 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, MessageSquareCode } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -11,7 +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: "Recherche réseau", href: "/recherche-reseau" },
|
||||
{ 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" },
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -125,6 +125,21 @@ export const qlikNetworkMetrics = pgTable("qlik_network_metrics", {
|
||||
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(),
|
||||
});
|
||||
|
||||
@@ -15,10 +15,10 @@ import { db } from "@/db";
|
||||
import { sessionSnapshots } from "@/db/schema";
|
||||
import { eq, desc } from "drizzle-orm";
|
||||
import { getNetworkMetricsByCodeCentrale } from "@/lib/qlik-network-cache";
|
||||
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";
|
||||
|
||||
const NB_MAGASINS_RESEAU = 270;
|
||||
|
||||
interface GetProductRowsInput {
|
||||
codeFournisseur: string;
|
||||
magasin?: string;
|
||||
@@ -507,6 +507,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
|
||||
@@ -537,6 +560,7 @@ async function enrichWithNetworkMetrics(productMap: Map<string, ProductRow>): Pr
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -37,7 +38,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 +125,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);
|
||||
@@ -327,6 +348,8 @@ export function FloatingSummaryBar() {
|
||||
onClose={() => setModal(prev => ({ ...prev, isOpen: false }))}
|
||||
title={modal.title}
|
||||
message={modal.message}
|
||||
variant={modal.variant}
|
||||
action={modal.action}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
RowSelectionState,
|
||||
} from "@tanstack/react-table";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { ChevronUp, ChevronDown, ChevronsUpDown, Copy, Check, Store, SlidersHorizontal, ShoppingCart, PackageOpen, Warehouse, AlertTriangle } from "lucide-react";
|
||||
import { ChevronUp, ChevronDown, ChevronsUpDown, Copy, Check, Store, SlidersHorizontal, ShoppingCart, PackageOpen, Warehouse, AlertTriangle, TrendingUp, TrendingDown, Minus } from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
@@ -26,71 +26,21 @@ import { GammeSelect } from "@/features/grid/components/gamme-select";
|
||||
import { HeatmapCell } from "@/features/grid/components/heatmap-cell";
|
||||
import type { ProductRow, GammeCode } from "@/types/grid";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
getLast12Months,
|
||||
formatMonthLabel,
|
||||
formatDate,
|
||||
SITE_LABELS,
|
||||
getStoreConfig,
|
||||
} from "@/features/grid/lib/months";
|
||||
import {
|
||||
computeNetworkTrend,
|
||||
TrendSparkline,
|
||||
NetworkLineChart,
|
||||
computeStoresSeries,
|
||||
trendLabel,
|
||||
TREND_COLOR,
|
||||
TREND_STRONG,
|
||||
} from "@/features/network/components/network-trend";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// La constante est supprimée d'ici pour éviter le mismatch d'hydratation (new Date() au runtime module)
|
||||
|
||||
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)}`;
|
||||
}
|
||||
|
||||
function formatDate(iso?: string): string {
|
||||
if (!iso) return "—";
|
||||
return new Date(iso).toLocaleDateString("fr-FR", { day: "2-digit", month: "2-digit", year: "numeric" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns premium styling and color for stores based on the store name's hash.
|
||||
* This guarantees a consistent color for a given store, regardless of its position in the list.
|
||||
*/
|
||||
const SITE_LABELS: Record<string, { label: string; nom: string }> = {
|
||||
"292": { label: "F", nom: "Frouard (Nancy)" },
|
||||
"579": { label: "H", nom: "Houdemont" },
|
||||
};
|
||||
|
||||
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 };
|
||||
}
|
||||
}
|
||||
NB_MAGASINS_RESEAU,
|
||||
} from "@/features/grid/lib/network-trend";
|
||||
import { TrendSparkline, NetworkLineChart } from "@/features/grid/components/network-charts";
|
||||
|
||||
interface HeatmapGridProps {
|
||||
onSelectionChange?: (codeins: string[]) => void;
|
||||
@@ -125,6 +75,56 @@ function getQlikNetworkSortValue(value: number | null | undefined, columnId: str
|
||||
// =========================================================================
|
||||
|
||||
// 1. Composant isolé pour la Cellule Gamme (évite le re-render des colonnes)
|
||||
/**
|
||||
* Cellule dont la valeur se copie d'un clic.
|
||||
*
|
||||
* Extraite de la colonne « Code interne », qui la portait en ligne avec un
|
||||
* `eslint-disable react-hooks/rules-of-hooks` : un `useState` dans le corps d'un
|
||||
* `cell()` de TanStack n'est pas rendu au même endroit d'un rendu à l'autre. Un
|
||||
* vrai composant supprime la dérogation et rend la copie réutilisable — les
|
||||
* références et les gencodes se recopient autant que les codes internes.
|
||||
*
|
||||
* `stopPropagation` est indispensable : sans lui, le clic sélectionne aussi la
|
||||
* ligne.
|
||||
*/
|
||||
const CopiableCell = React.memo(function CopiableCell({ value, titre, className, style }: {
|
||||
value: string | null | undefined;
|
||||
titre: string;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const texte = (value ?? "").trim();
|
||||
|
||||
if (!texte) {
|
||||
return <span className={className} style={style}>-</span>;
|
||||
}
|
||||
|
||||
const handleCopy = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
navigator.clipboard.writeText(texte);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={handleCopy}
|
||||
className="group flex items-center gap-1.5 cursor-pointer hover:text-emerald-500 transition-colors min-w-0"
|
||||
title={copied ? "Copié !" : titre}
|
||||
>
|
||||
<span className={cn("truncate", className)} style={style}>{texte}</span>
|
||||
<span className="shrink-0 transition-all duration-200">
|
||||
{copied ? (
|
||||
<Check className="w-3 h-3 text-emerald-500 animate-in zoom-in-50" />
|
||||
) : (
|
||||
<Copy className="w-3 h-3 opacity-0 group-hover:opacity-40 hover:!opacity-100" />
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const GammeCell = React.memo(({ row, isAdmin }: { row: ProductRow; isAdmin?: boolean }) => {
|
||||
// Abonnement ultra-ciblé : la cellule ne re-render que si SA valeur change
|
||||
const codein = row.codein;
|
||||
@@ -245,34 +245,102 @@ interface CellDetailData {
|
||||
receptions: number | null;
|
||||
}
|
||||
|
||||
/** Modal : quantités vendues réseau sur les 12 derniers mois, en une seule courbe. */
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
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 py-2.5 flex-1 min-w-[140px]" 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="flex items-center gap-1.5 mt-0.5">
|
||||
{Icone && <Icone className="w-4 h-4 shrink-0" style={{ color: couleur ?? "var(--text-primary)" }} />}
|
||||
<span className="text-[20px] font-bold leading-tight" style={{ color: couleur ?? "var(--text-primary)" }}>
|
||||
{valeur}
|
||||
</span>
|
||||
</div>
|
||||
{indice && <div className="text-[10px] mt-0.5" style={{ color: "var(--text-muted)" }}>{indice}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Carte « tendance réseau » : le verdict en tuiles, puis les trois séries
|
||||
* mensuelles en petits multiples.
|
||||
*
|
||||
* La couleur de tendance (vert / rouge / gris) ne vit QUE dans la tuile de
|
||||
* verdict, accompagnée d'une flèche et d'un libellé : un état ne se signale
|
||||
* jamais par la couleur seule. Les courbes, elles, portent des teintes
|
||||
* d'identité — sans quoi la même couleur voudrait dire deux choses.
|
||||
*/
|
||||
function NetworkMonthlyModal({ row, onClose }: { row: ProductRow; onClose: () => void }) {
|
||||
const trend = computeNetworkTrend(row.qteReseauByMonth);
|
||||
const trend = computeNetworkTrend(row.qteReseauByMonth, row.nbMagReseauByMonth);
|
||||
const { values, labels, direction, pct } = trend;
|
||||
const color = TREND_COLOR[direction];
|
||||
const magasins = computeStoresSeries(row.nbMagReseauByMonth);
|
||||
const Fleche = direction === "up" ? TrendingUp : direction === "down" ? TrendingDown : Minus;
|
||||
|
||||
const dernier = values.length - 1;
|
||||
const fmt = (v: number, d = 0) => v.toLocaleString("fr-FR", { minimumFractionDigits: d, maximumFractionDigits: d });
|
||||
const verdict = trend.nouveau
|
||||
? "Nouveau"
|
||||
: pct != null
|
||||
? `${pct >= 0 ? "+" : ""}${Math.round(pct * 100)} %`
|
||||
: "—";
|
||||
|
||||
return (
|
||||
<DialogContent className="max-w-md" style={{ background: "var(--bg-surface)", border: "1px solid var(--border)" }} onInteractOutside={onClose}>
|
||||
<DialogContent className="max-w-xl" style={{ background: "var(--bg-surface)", border: "1px solid var(--border)" }} onInteractOutside={onClose}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base leading-snug pr-6" style={{ color: "var(--text-primary)" }}>
|
||||
{row.libelle1}
|
||||
</DialogTitle>
|
||||
<p className="text-[12px] mt-0.5" style={{ color: "var(--text-muted)" }}>
|
||||
Ventes réseau · {labels.length > 0 ? `${labels.length} derniers mois` : "12 derniers mois"}
|
||||
{row.nbMagasinsReseau != null && <> · <span className="font-semibold" style={{ color: "var(--text-secondary)" }}>{row.nbMagasinsReseau} magasins</span></>}
|
||||
{pct != null && (
|
||||
<> · <span className="font-bold" style={{ color }}>
|
||||
{pct >= 0 ? "+" : ""}{Math.round(pct * 100)}% ·{" "}
|
||||
{pct > TREND_STRONG ? "Forte hausse" : pct > 0.08 ? "Hausse" : pct < -TREND_STRONG ? "Forte baisse" : pct < -0.08 ? "Baisse" : "Stable"}
|
||||
</span></>
|
||||
)}
|
||||
Réseau · 12 mois glissants, mois en cours exclu
|
||||
</p>
|
||||
</DialogHeader>
|
||||
|
||||
{!trend.hasData ? (
|
||||
<div className="mt-3 rounded-xl p-4 text-center text-[13px]" style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-muted)" }}>
|
||||
Pas encore de détail mensuel pour ce produit.<br />Relancez un Sync Qlik pour le remplir.
|
||||
</div>
|
||||
) : (
|
||||
<NetworkLineChart labels={labels} values={values} color={color} />
|
||||
<>
|
||||
<div className="flex flex-wrap gap-2 mt-1">
|
||||
<TuileStat
|
||||
label="Tendance"
|
||||
valeur={verdict}
|
||||
indice={trend.surQteParMagasin
|
||||
? `${trendLabel(pct, trend.nouveau)} · sur la qté/magasin`
|
||||
: `${trendLabel(pct, trend.nouveau)} · sur les volumes (magasins inconnus)`}
|
||||
couleur={color}
|
||||
icone={Fleche}
|
||||
/>
|
||||
{trend.perStore && (
|
||||
<TuileStat
|
||||
label="Qté / magasin"
|
||||
valeur={fmt(trend.perStore[dernier], trend.perStore[dernier] < 10 ? 1 : 0)}
|
||||
indice={`dernier mois · ${fmt(trend.perStore.reduce((t, v) => t + v, 0) / trend.perStore.length, 1)} en moyenne`}
|
||||
/>
|
||||
)}
|
||||
<TuileStat
|
||||
label="Magasins vendeurs"
|
||||
valeur={magasins ? fmt(magasins.values[dernier]) : fmt(row.nbMagasinsReseau ?? 0)}
|
||||
indice={`sur ${NB_MAGASINS_RESEAU} du réseau`}
|
||||
/>
|
||||
</div>
|
||||
<NetworkLineChart labels={labels} values={values} stores={magasins?.values} perStore={trend.perStore} />
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
);
|
||||
@@ -443,63 +511,40 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
|
||||
accessorKey: "codein",
|
||||
header: "Code interne",
|
||||
size: 90,
|
||||
cell: ({ getValue }) => {
|
||||
const value = getValue<string>();
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
navigator.clipboard.writeText(value);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={handleCopy}
|
||||
className="group flex items-center gap-1.5 cursor-pointer hover:text-emerald-500 transition-colors"
|
||||
title="Copier le code interne"
|
||||
>
|
||||
<span className="tabular-nums font-bold text-[12px] tracking-tight opacity-70 group-hover:opacity-100" style={{ color: "var(--text-muted)" }}>
|
||||
{value}
|
||||
</span>
|
||||
<div className="shrink-0 transition-all duration-200">
|
||||
{copied ? (
|
||||
<Check className="w-3 h-3 text-emerald-500 animate-in zoom-in-50" />
|
||||
) : (
|
||||
<Copy className="w-3 h-3 opacity-0 group-hover:opacity-40 hover:!opacity-100" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
cell: ({ getValue }) => (
|
||||
<CopiableCell
|
||||
value={getValue<string>()}
|
||||
titre="Copier le code interne"
|
||||
className="tabular-nums font-bold text-[12px] tracking-tight opacity-70 group-hover:opacity-100"
|
||||
style={{ color: "var(--text-muted)" }}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "reference",
|
||||
header: "Référence",
|
||||
size: 110,
|
||||
cell: ({ getValue }) => {
|
||||
const val = getValue<string>();
|
||||
return (
|
||||
<span className="text-[12px] font-mono opacity-80" style={{ color: "var(--text-secondary)" }}>
|
||||
{val || "-"}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
cell: ({ getValue }) => (
|
||||
<CopiableCell
|
||||
value={getValue<string>()}
|
||||
titre="Copier la référence"
|
||||
className="text-[12px] font-mono opacity-80 group-hover:opacity-100"
|
||||
style={{ color: "var(--text-secondary)" }}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "gtin",
|
||||
header: "EAN / GTIN",
|
||||
size: 120,
|
||||
cell: ({ getValue }) => {
|
||||
const val = getValue<string>();
|
||||
return (
|
||||
<span className="text-[12px] font-mono tracking-tight opacity-70" style={{ color: "var(--text-secondary)" }}>
|
||||
{val || "-"}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
cell: ({ getValue }) => (
|
||||
<CopiableCell
|
||||
value={getValue<string>()}
|
||||
titre="Copier l'EAN / GTIN"
|
||||
className="text-[12px] font-mono tracking-tight opacity-70 group-hover:opacity-100"
|
||||
style={{ color: "var(--text-secondary)" }}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "libelle1",
|
||||
@@ -612,13 +657,18 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
|
||||
{
|
||||
id: "tendanceReseau",
|
||||
accessorFn: (row) => {
|
||||
const t = computeNetworkTrend(row.qteReseauByMonth);
|
||||
return t.hasData && t.pct != null ? t.pct : Number.NEGATIVE_INFINITY;
|
||||
const t = computeNetworkTrend(row.qteReseauByMonth, row.nbMagReseauByMonth);
|
||||
if (!t.hasData) return Number.NEGATIVE_INFINITY;
|
||||
// Un produit « nouveau » n'a pas de pourcentage mais c'est la plus
|
||||
// forte progression possible : il doit remonter en tête du tri, pas
|
||||
// tomber au fond avec les lignes sans données.
|
||||
if (t.nouveau) return Number.POSITIVE_INFINITY;
|
||||
return t.pct ?? Number.NEGATIVE_INFINITY;
|
||||
},
|
||||
header: () => <div className="text-center w-full">Tendance<br/><span className="text-[9px] opacity-60">Réseau · 12 m</span></div>,
|
||||
size: 78,
|
||||
cell: ({ row }) => {
|
||||
const trend = computeNetworkTrend(row.original.qteReseauByMonth);
|
||||
const trend = computeNetworkTrend(row.original.qteReseauByMonth, row.original.nbMagReseauByMonth);
|
||||
if (!trend.hasData) return <div className="text-center text-[12px]" style={{ color: "var(--text-secondary)" }}>-</div>;
|
||||
// Cliquable → modal des ventes réseau mois par mois (12 derniers mois).
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,440 @@
|
||||
"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;
|
||||
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: 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-2.5 py-0.5 text-[10px] 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 + 3} width={12} height={3} rx={1.5} fill={f.couleur} />
|
||||
<text x={padL + 18} y={f.hautEntete + 7} fontSize={9.5} fontWeight={600} fill="var(--text-secondary)" dominantBaseline="middle">
|
||||
{f.titre}
|
||||
</text>
|
||||
<text x={padL + plotW} y={f.hautEntete + 7} textAnchor="end" fontSize={9} 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 - 8 >= f.haut + 8;
|
||||
return (
|
||||
<text
|
||||
key={`lab-${f.titre}-${i}`}
|
||||
x={x(i)}
|
||||
y={dessus ? yPoint - 8 : yPoint + 13}
|
||||
textAnchor={ancrage(i)}
|
||||
fontSize={9}
|
||||
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 - 5}
|
||||
textAnchor={ancrage(i)}
|
||||
fontSize={8.5}
|
||||
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 = 158;
|
||||
const hauteur = 16 + disposees.length * 13 + 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 + 9} y={17} fontSize={9.5} fontWeight={700} fill="var(--text-primary)">
|
||||
{fmtMonthShort(labels[survol])}
|
||||
</text>
|
||||
{disposees.map((f, k) => (
|
||||
<g key={`bulle-${f.titre}`}>
|
||||
<rect x={bx + 9} y={26 + k * 13} width={8} height={3} rx={1.5} fill={f.couleur} />
|
||||
<text x={bx + 22} y={30 + k * 13} fontSize={9} fill="var(--text-secondary)">
|
||||
{f.titre}
|
||||
</text>
|
||||
<text
|
||||
x={bx + largeur - 9} y={30 + k * 13}
|
||||
textAnchor="end" fontSize={9} 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-[11px] 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-[11px]" 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,8 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, 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,24 +12,8 @@ interface SyncQlikButtonProps {
|
||||
lastUpdate?: string | null;
|
||||
}
|
||||
|
||||
/** État serveur renvoyé par GET/POST /api/qlik/sync */
|
||||
interface SyncJobState {
|
||||
jobId: string | null;
|
||||
fournisseur: 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;
|
||||
}
|
||||
|
||||
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";
|
||||
@@ -39,55 +24,15 @@ 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.
|
||||
*
|
||||
* UX : le POST démarre un job serveur en arrière-plan et revient immédiatement.
|
||||
* Le composant poll ensuite GET pour suivre l'avancement. Au mount, on appelle
|
||||
* GET pour reprendre l'état d'un éventuel job en cours (par ex. après reload de
|
||||
* la page) et continuer à afficher le spinner correctement.
|
||||
* 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" | "running" | "success" | "error">("idle");
|
||||
const [message, setMessage] = useState<string>("");
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
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);
|
||||
|
||||
const clearPollTimer = useCallback(() => {
|
||||
if (pollTimerRef.current) {
|
||||
clearTimeout(pollTimerRef.current);
|
||||
pollTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Met à jour le label visible à droite du bouton selon l'état courant.
|
||||
*/
|
||||
const applyJobToUi = useCallback((j: SyncJobState | null) => {
|
||||
if (!j) return;
|
||||
if (j.status === "running") {
|
||||
setStatus("running");
|
||||
// Texte informatif pendant l'extraction
|
||||
const requestedTxt = j.requested > 0 ? ` (${j.requested} codes)` : "";
|
||||
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");
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Force le refresh de la grille (avec cache-buster) et de la barre de résumé.
|
||||
* C'est l'équivalent de ce que faisait l'ancien code après un POST réussi.
|
||||
*/
|
||||
/** 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()));
|
||||
@@ -95,163 +40,10 @@ export function SyncQlikButton({ codeFournisseur, lastUpdate }: SyncQlikButtonPr
|
||||
router.refresh();
|
||||
}, [pathname, router, searchParams]);
|
||||
|
||||
/**
|
||||
* Polling GET pendant qu'un job tourne. S'arrête tout seul quand le job
|
||||
* passe en success/error, ou après un timeout de sécurité.
|
||||
*/
|
||||
const pollOnce = useCallback(async (): Promise<boolean> => {
|
||||
if (!codeFournisseur) return false;
|
||||
if (!trackingRef.current) return false;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/qlik/sync?fournisseur=${encodeURIComponent(codeFournisseur)}`,
|
||||
{ method: "GET", cache: "no-store" },
|
||||
);
|
||||
if (!res.ok) {
|
||||
// En cas d'erreur HTTP transitoire on retente au tour suivant.
|
||||
return true;
|
||||
}
|
||||
const data: SyncJobState = await res.json();
|
||||
applyJobToUi(data);
|
||||
|
||||
if (data.status === "running") {
|
||||
// Continue à poller
|
||||
return true;
|
||||
}
|
||||
if (data.status === "success" || data.status === "error") {
|
||||
// Terminé : on rafraîchit la grille, on coupe le polling.
|
||||
trackingRef.current = false;
|
||||
if (data.status === "success") {
|
||||
refreshGrid();
|
||||
}
|
||||
// Petit délai pour laisser l'utilisateur lire le message vert/rouge
|
||||
setTimeout(() => {
|
||||
setStatus((cur) => (cur === "success" || cur === "error" ? "idle" : cur));
|
||||
}, 4000);
|
||||
return false;
|
||||
}
|
||||
// idle : rien à faire
|
||||
trackingRef.current = false;
|
||||
return false;
|
||||
} catch {
|
||||
// Erreur réseau transitoire, on laisse le polling réessayer.
|
||||
return true;
|
||||
}
|
||||
}, [applyJobToUi, codeFournisseur, refreshGrid]);
|
||||
|
||||
/**
|
||||
* Boucle de polling : s'auto-réarme tant que pollOnce indique qu'il faut
|
||||
* continuer. Délai un peu plus long que d'habitude (3 s) pour ne pas
|
||||
* marteler le serveur pendant une extraction qui dure plusieurs minutes.
|
||||
*/
|
||||
const startPolling = useCallback(() => {
|
||||
clearPollTimer();
|
||||
const tick = async () => {
|
||||
const keepGoing = await pollOnce();
|
||||
if (keepGoing && trackingRef.current) {
|
||||
pollTimerRef.current = setTimeout(tick, 3000);
|
||||
} else {
|
||||
pollTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
// Premier appel quasi immédiat
|
||||
pollTimerRef.current = setTimeout(tick, 200);
|
||||
}, [clearPollTimer, pollOnce]);
|
||||
|
||||
/**
|
||||
* Au mount : si on a un fournisseur, on interroge l'API pour savoir si
|
||||
* un job tourne déjà (ex. reload de la page pendant l'extraction). Si
|
||||
* oui, on reprend l'état et on relance le polling.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!codeFournisseur) return;
|
||||
let cancelled = false;
|
||||
trackingRef.current = false;
|
||||
clearPollTimer();
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/qlik/sync?fournisseur=${encodeURIComponent(codeFournisseur)}`,
|
||||
{ method: "GET", cache: "no-store" },
|
||||
);
|
||||
if (cancelled) return;
|
||||
if (!res.ok) return;
|
||||
const data: SyncJobState = 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é en amont (avant le mount) : 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 d'afficher le bouton */
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
trackingRef.current = false;
|
||||
clearPollTimer();
|
||||
};
|
||||
// On veut interroger une seule fois au mount (et quand le fournisseur change).
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [codeFournisseur, clearPollTimer]);
|
||||
|
||||
/**
|
||||
* Cleanup : annule un timer de polling éventuel au démontage.
|
||||
*/
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearPollTimer();
|
||||
};
|
||||
}, [clearPollTimer]);
|
||||
|
||||
const handleSync = async () => {
|
||||
if (!codeFournisseur) {
|
||||
setStatus("error");
|
||||
setMessage("Aucun fournisseur sélectionné");
|
||||
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?fournisseur=${encodeURIComponent(codeFournisseur)}`,
|
||||
{ method: "POST" },
|
||||
);
|
||||
const data: SyncJobState & { success?: boolean } = await res.json();
|
||||
if (!res.ok || (data as { success?: boolean }).success === false) {
|
||||
throw new Error(data.error || `HTTP ${res.status}`);
|
||||
}
|
||||
// Si le serveur nous dit que c'est déjà en cours (jobId existant),
|
||||
// on reprend juste le suivi.
|
||||
if (data.status === "running" && data.jobId) {
|
||||
trackingRef.current = true;
|
||||
applyJobToUi(data);
|
||||
startPolling();
|
||||
return;
|
||||
}
|
||||
// Status terminal direct (cas rare : 0 codes → success immédiat)
|
||||
applyJobToUi(data);
|
||||
if (data.status === "success") {
|
||||
refreshGrid();
|
||||
}
|
||||
setTimeout(() => setStatus("idle"), 4000);
|
||||
} catch (e) {
|
||||
setStatus("error");
|
||||
setMessage(e instanceof Error ? e.message : String(e));
|
||||
setTimeout(() => setStatus("idle"), 6000);
|
||||
}
|
||||
};
|
||||
const { status, message, start } = useQlikSyncJob({
|
||||
target: { mode: "fournisseur", fournisseur: codeFournisseur },
|
||||
onSuccess: refreshGrid,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -264,13 +56,13 @@ export function SyncQlikButton({ codeFournisseur, lastUpdate }: SyncQlikButtonPr
|
||||
? message
|
||||
: status === "running"
|
||||
? (message || `Extraction Qlik…`)
|
||||
: `MAJ Qlik · ${formatDate(lastUpdate)}`}
|
||||
: `MAJ Qlik · ${formatLastUpdate(lastUpdate)}`}
|
||||
</span>
|
||||
<button
|
||||
onClick={handleSync}
|
||||
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 ${formatDate(lastUpdate)}`}
|
||||
title={message || `Synchroniser les données réseau Qlik · MAJ ${formatLastUpdate(lastUpdate)}`}
|
||||
>
|
||||
{status === "running" ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/** `"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";
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* CollectFlow — Tendance réseau Qlik (composants partagés).
|
||||
*
|
||||
* Extrait de `features/grid/components/heatmap-grid.tsx` pour être réutilisé par
|
||||
* la page Recherche réseau. Le comportement est identique : seule la localisation
|
||||
* du code change.
|
||||
*
|
||||
* Source des données : `qteByMonth` ({ "YYYY-MM": quantité }) produit par la
|
||||
* synchronisation Qlik (mesure « Quantité N », complétée par « Quantité COMP »
|
||||
* et une passe année N-1 pour obtenir 12 mois glissants contigus).
|
||||
*/
|
||||
|
||||
import { TrendingUp, TrendingDown, Minus } from "lucide-react";
|
||||
|
||||
// Le calcul vit dans `@/lib/network-trend` (module pur, sans React) pour que
|
||||
// l'API `/api/v1` expose exactement la même tendance que la Grille. On le
|
||||
// réexporte ici pour ne pas changer les imports existants.
|
||||
import { computeNetworkTrend, trendLabel, TREND_STRONG, type NetworkTrend } from "@/lib/network-trend";
|
||||
|
||||
export { computeNetworkTrend, trendLabel, TREND_STRONG };
|
||||
export type { NetworkTrend };
|
||||
|
||||
export const TREND_COLOR: Record<NetworkTrend["direction"], string> = {
|
||||
up: "#22c55e",
|
||||
down: "#ef4444",
|
||||
flat: "#94a3b8",
|
||||
};
|
||||
|
||||
/** 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 } = 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)}% sur 12 mois (régression) — 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>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** "2026-01" → "01/26" pour un axe compact. */
|
||||
export function fmtMonthShort(lab: string): string {
|
||||
const [y, m] = lab.split("-");
|
||||
return `${m ?? lab}/${(y ?? "").slice(2)}`;
|
||||
}
|
||||
|
||||
/** Courbe unique des ventes réseau mensuelles (SVG). */
|
||||
export function NetworkLineChart({ labels, values, color }: { labels: string[]; values: number[]; color: string }) {
|
||||
const W = 460, H = 200;
|
||||
const padL = 10, padR = 10, padT = 22, padB = 30;
|
||||
const plotW = W - padL - padR, plotH = H - padT - padB;
|
||||
const n = values.length;
|
||||
const maxV = Math.max(...values, 1);
|
||||
const x = (i: number) => (n > 1 ? padL + (i / (n - 1)) * plotW : padL + plotW / 2);
|
||||
const y = (v: number) => padT + plotH - (v / maxV) * plotH;
|
||||
const linePts = values.map((v, i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`).join(" ");
|
||||
const areaPts = `${padL},${padT + plotH} ${linePts} ${(padL + plotW).toFixed(1)},${(padT + plotH).toFixed(1)}`;
|
||||
return (
|
||||
<div className="mt-3 w-full overflow-x-auto">
|
||||
<svg viewBox={`0 0 ${W} ${H}`} className="w-full" style={{ minWidth: 380 }} role="img" aria-label="Ventes réseau mensuelles">
|
||||
{/* ligne de base */}
|
||||
<line x1={padL} y1={padT + plotH} x2={padL + plotW} y2={padT + plotH} stroke="var(--border)" strokeWidth={1} />
|
||||
{/* aire douce sous la courbe */}
|
||||
{n > 1 && <polygon points={areaPts} fill={color} opacity={0.08} />}
|
||||
{/* courbe */}
|
||||
{n > 1 && <polyline points={linePts} fill="none" stroke={color} strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" />}
|
||||
{values.map((v, i) => (
|
||||
<g key={labels[i]}>
|
||||
<circle cx={x(i)} cy={y(v)} r={2.6} fill={color} />
|
||||
{/* quantité au-dessus du point */}
|
||||
<text x={x(i)} y={y(v) - 6} textAnchor="middle" fontSize={9} fontWeight={700} fill="var(--text-primary)">
|
||||
{Math.round(v).toLocaleString("fr-FR")}
|
||||
</text>
|
||||
{/* mois sous l'axe */}
|
||||
<text x={x(i)} y={H - 10} textAnchor="middle" fontSize={8} fill="var(--text-muted)">
|
||||
{fmtMonthShort(labels[i])}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+28
-10
@@ -22,17 +22,26 @@ 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";
|
||||
import { computeNetworkTrend, trendLabel, type TrendDirection } from "@/lib/network-trend";
|
||||
// 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 {
|
||||
/** "up" | "down" | "flat" — seuil à ±8 % de variation modélisée. */
|
||||
direction: TrendDirection;
|
||||
/** Variation modélisée sur la période (0.23 = +23 %). `null` si non calculable. */
|
||||
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 », « Stable »… */
|
||||
/** Libellé prêt à afficher : « Forte hausse », « Nouveau », « Stable »… */
|
||||
label: string;
|
||||
/** Nombre de mois effectivement utilisés (12 au maximum). */
|
||||
/**
|
||||
* `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;
|
||||
}
|
||||
|
||||
@@ -110,15 +119,24 @@ export async function enrichRows(rows: ProductRow[]): Promise<EnrichedProductRow
|
||||
enriched.networkFetchedAt = metrics.fetchedAt ?? undefined;
|
||||
}
|
||||
|
||||
// Tendance calculée sur la série finale (celle du cache si elle existe,
|
||||
// sinon celle de l'instantané) — même entrée que la colonne « Tendance ».
|
||||
// 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 t = computeNetworkTrend(serie);
|
||||
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: t.pct != null ? trendLabel(t.pct) : "Indéterminée",
|
||||
label: trendLabel(t.pct, t.nouveau),
|
||||
surQteParMagasin: t.surQteParMagasin,
|
||||
nouveau: t.nouveau,
|
||||
monthsUsed: t.values.length,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
/**
|
||||
* CollectFlow — Tendance réseau (calcul pur, sans React).
|
||||
*
|
||||
* Isolé du composant d'affichage (`features/network/components/network-trend.tsx`)
|
||||
* pour être utilisable **côté serveur** : l'API `/api/v1` expose ainsi la même
|
||||
* tendance que celle affichée dans la Grille, au lieu de laisser chaque appelant
|
||||
* la recalculer — et diverger.
|
||||
*
|
||||
* Source : `qteByMonth` ({ "YYYY-MM": quantité }) produit par la synchronisation Qlik.
|
||||
*/
|
||||
|
||||
export type TrendDirection = "up" | "down" | "flat";
|
||||
|
||||
export type NetworkTrend = {
|
||||
values: number[]; // quantités mensuelles, ordre chronologique
|
||||
labels: string[]; // "YYYY-MM"
|
||||
direction: TrendDirection;
|
||||
pct: number | null; // variation modélisée (régression linéaire) sur 12 mois
|
||||
hasData: boolean;
|
||||
};
|
||||
|
||||
/** Seuil de "forte" variation (±25%) pour distinguer hausse/forte hausse. */
|
||||
export const TREND_STRONG = 0.25;
|
||||
|
||||
/**
|
||||
* Tendance réseau par RÉGRESSION LINÉAIRE (moindres carrés) sur les 12 derniers mois.
|
||||
* Utilise tous les points (robuste au bruit d'un mois isolé). L'indicateur `pct` est la
|
||||
* variation modélisée sur la période (pente × durée) rapportée à la moyenne.
|
||||
* Direction : hausse >+8%, stable, baisse <−8%.
|
||||
*/
|
||||
export function computeNetworkTrend(qteByMonth?: Record<string, number> | null): NetworkTrend {
|
||||
const empty: NetworkTrend = { values: [], labels: [], direction: "flat", pct: null, hasData: false };
|
||||
if (!qteByMonth) return empty;
|
||||
let labels = Object.keys(qteByMonth).sort(); // "YYYY-MM" trie chronologiquement
|
||||
if (labels.length === 0) return empty;
|
||||
labels = labels.slice(-12); // 12 derniers mois
|
||||
const values = labels.map((l) => Number(qteByMonth[l]) || 0);
|
||||
const n = values.length;
|
||||
let pct: number | null = null;
|
||||
let direction: TrendDirection = "flat";
|
||||
if (n >= 3) {
|
||||
const mx = (n - 1) / 2;
|
||||
const my = values.reduce((s, v) => s + v, 0) / n;
|
||||
let num = 0, den = 0;
|
||||
for (let i = 0; i < n; i++) { num += (i - mx) * (values[i] - my); den += (i - mx) * (i - mx); }
|
||||
const slope = den ? num / den : 0;
|
||||
if (my > 0) {
|
||||
pct = (slope * (n - 1)) / my; // variation modélisée sur toute la période / moyenne
|
||||
direction = pct > 0.08 ? "up" : pct < -0.08 ? "down" : "flat";
|
||||
} else if (slope > 0) {
|
||||
direction = "up";
|
||||
}
|
||||
} else if (n === 2 && values[0] > 0) {
|
||||
pct = (values[1] - values[0]) / values[0];
|
||||
direction = pct > 0.08 ? "up" : pct < -0.08 ? "down" : "flat";
|
||||
}
|
||||
return { values, labels, direction, pct, hasData: true };
|
||||
}
|
||||
|
||||
/** Libellé français de la tendance (« Forte hausse », « Stable »…). */
|
||||
export function trendLabel(pct: number): string {
|
||||
if (pct > TREND_STRONG) return "Forte hausse";
|
||||
if (pct > 0.08) return "Hausse";
|
||||
if (pct < -TREND_STRONG) return "Forte baisse";
|
||||
if (pct < -0.08) return "Baisse";
|
||||
return "Stable";
|
||||
}
|
||||
+683
-19
@@ -355,26 +355,37 @@ export interface PgNomRow {
|
||||
* Colonnes confirmées : nomenclature.code, nomenclature.libelle
|
||||
* Colonne parent : découverte dynamiquement via information_schema
|
||||
*/
|
||||
/**
|
||||
* Découvre (une seule fois par process) le nom de la colonne "parent" de la
|
||||
* table `nomenclature`, qui porte la FK vers le `no_id` du niveau supérieur.
|
||||
*
|
||||
* On ne peut pas la coder en dur : le nom varie selon les installations
|
||||
* ("chemin_pere" existe mais c'est un chemin *texte* matérialisé, pas la FK),
|
||||
* d'où le filtre explicite sur `data_type` entier.
|
||||
*/
|
||||
async function getNomenclatureParentCol(): Promise<string | null> {
|
||||
if (_nomenclatureParentCol !== undefined) return _nomenclatureParentCol;
|
||||
|
||||
const metaResult = await db.execute(sql`
|
||||
SELECT column_name, data_type FROM information_schema.columns
|
||||
WHERE table_name = 'nomenclature' ORDER BY ordinal_position
|
||||
`);
|
||||
const nomCols = (metaResult.rows as { column_name: string; data_type: string }[]);
|
||||
console.log("[pg-ff] Nomenclature colonnes:", nomCols.map(r => `${r.column_name}(${r.data_type})`).join(", "));
|
||||
// Cherche une colonne "parent" qui soit un entier (FK vers no_id du parent)
|
||||
// "chemin_pere" = chemin vers le père en français
|
||||
// On vérifie le data_type : integer/bigint/smallint uniquement (pas text/varchar qui serait un chemin matérialisé)
|
||||
const parentRow = nomCols.find(r =>
|
||||
(/parent|pere|father/i.test(r.column_name) || (r.column_name !== "no_id" && /no_id$/i.test(r.column_name)))
|
||||
&& /int|serial/i.test(r.data_type)
|
||||
);
|
||||
_nomenclatureParentCol = parentRow?.column_name ?? null;
|
||||
console.log("[pg-ff] Nomenclature parentCol:", _nomenclatureParentCol);
|
||||
return _nomenclatureParentCol;
|
||||
}
|
||||
|
||||
export async function pgGetNomenclatureByFournisseur(codefou: string): Promise<Map<string, PgNomRow>> {
|
||||
// Découverte de la colonne parent : mise en cache module-level (1 seule fois par process)
|
||||
if (_nomenclatureParentCol === undefined) {
|
||||
const metaResult = await db.execute(sql`
|
||||
SELECT column_name, data_type FROM information_schema.columns
|
||||
WHERE table_name = 'nomenclature' ORDER BY ordinal_position
|
||||
`);
|
||||
const nomCols = (metaResult.rows as { column_name: string; data_type: string }[]);
|
||||
console.log("[pg-ff] Nomenclature colonnes:", nomCols.map(r => `${r.column_name}(${r.data_type})`).join(", "));
|
||||
// Cherche une colonne "parent" qui soit un entier (FK vers no_id du parent)
|
||||
// "chemin_pere" = chemin vers le père en français
|
||||
// On vérifie le data_type : integer/bigint/smallint uniquement (pas text/varchar qui serait un chemin matérialisé)
|
||||
const parentRow = nomCols.find(r =>
|
||||
(/parent|pere|father/i.test(r.column_name) || (r.column_name !== "no_id" && /no_id$/i.test(r.column_name)))
|
||||
&& /int|serial/i.test(r.data_type)
|
||||
);
|
||||
_nomenclatureParentCol = parentRow?.column_name ?? null;
|
||||
console.log("[pg-ff] Nomenclature parentCol:", _nomenclatureParentCol);
|
||||
}
|
||||
const parentCol = _nomenclatureParentCol;
|
||||
const parentCol = await getNomenclatureParentCol();
|
||||
|
||||
let result;
|
||||
if (parentCol) {
|
||||
@@ -1308,3 +1319,656 @@ export async function pgGetDerniereReceptionParFournisseur(): Promise<Map<string
|
||||
return map;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 13. Recherche & fiche produit (page /produits)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Une ligne de résultat de recherche produit. Volontairement **légère** :
|
||||
* aucune agrégation `mvtart` ici (le seq-scan du ILIKE domine déjà le coût).
|
||||
* Le détail lourd n'est calculé qu'à l'ouverture de la fiche.
|
||||
*/
|
||||
export interface PgProduitSearchRow {
|
||||
no_id: number;
|
||||
codein: string;
|
||||
code_centrale: string;
|
||||
libelle1: string;
|
||||
/** Nom du fournisseur principal (artfou1.preference = 1), sinon son code. */
|
||||
fournisseur: string;
|
||||
codefou: string;
|
||||
nomenclature_code: string;
|
||||
nomenclature: string;
|
||||
stock_total: number;
|
||||
/** true si le produit a déjà des métriques réseau Qlik en cache. */
|
||||
has_reseau: boolean;
|
||||
qte_reseau: number;
|
||||
nb_magasins_reseau: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Caractères accentués et leur équivalent non accentué, pour `translate()`.
|
||||
* Les deux chaînes DOIVENT rester de même longueur, caractère par caractère.
|
||||
* Postgres n'a pas `unaccent()` sans l'extension : on ne peut pas compter dessus
|
||||
* sur la base FF, d'où cette table de correspondance explicite.
|
||||
*/
|
||||
const SQL_ACCENTS_FROM = "àâäãáåéèêëíìîïóòôöõúùûüçñýÿ";
|
||||
const SQL_ACCENTS_TO = "aaaaaaeeeeiiiiooooouuuucnyy";
|
||||
|
||||
/**
|
||||
* Normalise un terme de recherche côté JS, de la même façon que
|
||||
* `normalizedLibelleSql()` le fait côté SQL : minuscules, accents retirés,
|
||||
* toute ponctuation ramenée à un espace.
|
||||
*/
|
||||
function normalizeSearchTerm(term: string): string {
|
||||
return term
|
||||
.toLowerCase()
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "") // retire les diacritiques
|
||||
.replace(/[^a-z0-9]+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
/** Expression SQL renvoyant le libellé normalisé (minuscules, sans accent ni ponctuation). */
|
||||
function normalizedLibelleSql() {
|
||||
return sql`regexp_replace(translate(lower(COALESCE(a.libelle1, '')), ${SQL_ACCENTS_FROM}, ${SQL_ACCENTS_TO}), '[^a-z0-9]+', ' ', 'g')`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recherche un produit par **libellé** ou par **code centrale** (préfixe).
|
||||
* Si le terme est entièrement numérique, on tente aussi une correspondance
|
||||
* exacte sur le `codein`.
|
||||
*
|
||||
* La recherche par libellé est **insensible aux accents et à la ponctuation**,
|
||||
* et fonctionne **mot à mot** : « tapis anti-poussière » retrouve
|
||||
* « TAPIS ANTI POUSSIERE », et « tapis poussiere » aussi (tous les mots doivent
|
||||
* être présents, dans n'importe quel ordre). Sans cette normalisation, un
|
||||
* `ILIKE` brut exigeait l'accent ET le tiret exacts et ne remontait rien.
|
||||
*
|
||||
* ⚠️ Il n'existe aucun index texte sur `articles.libelle1` (base FF resynchronisée
|
||||
* chaque nuit depuis SQL Server — on n'y crée pas d'index). La requête fait donc
|
||||
* un seq-scan avec normalisation par ligne : l'appelant impose un minimum de
|
||||
* 3 caractères et une soumission explicite, jamais une recherche à la frappe.
|
||||
*/
|
||||
export async function pgSearchProduits(term: string, limit = 50): Promise<PgProduitSearchRow[]> {
|
||||
const cleaned = term.trim();
|
||||
if (cleaned.length < 3) return [];
|
||||
|
||||
const prefix = `${cleaned}%`;
|
||||
// Mots de la recherche, normalisés. Tous doivent être présents (AND).
|
||||
const tokens = normalizeSearchTerm(cleaned).split(" ").filter(Boolean);
|
||||
const normLibelle = normalizedLibelleSql();
|
||||
const libelleClause = tokens.length > 0
|
||||
? sql`(${sql.join(tokens.map((t) => sql`${normLibelle} LIKE ${`%${t}%`}`), sql` AND `)})`
|
||||
: sql`FALSE`;
|
||||
|
||||
// Un terme purement numérique peut être un codein : on l'ajoute au filtre
|
||||
// (correspondance exacte, la colonne est space-padded d'où le TRIM).
|
||||
const codeinClause = /^\d+$/.test(cleaned)
|
||||
? sql`OR TRIM(a.codein::text) = ${cleaned}`
|
||||
: sql``;
|
||||
|
||||
try {
|
||||
const result = await pgNoParallel(sql`
|
||||
WITH raw AS (
|
||||
SELECT DISTINCT ON (TRIM(a.codein::text))
|
||||
a.no_id,
|
||||
TRIM(a.codein::text) AS codein,
|
||||
COALESCE(TRIM(a.artcentrale::text), '') AS code_centrale,
|
||||
COALESCE(a.libelle1, '') AS libelle1,
|
||||
a.nom_no_id
|
||||
FROM articles a
|
||||
WHERE a.codein IS NOT NULL
|
||||
AND (
|
||||
${libelleClause}
|
||||
OR TRIM(a.artcentrale::text) ILIKE ${prefix}
|
||||
${codeinClause}
|
||||
)
|
||||
ORDER BY TRIM(a.codein::text), a.no_id DESC
|
||||
),
|
||||
matched AS (
|
||||
SELECT * FROM raw ORDER BY libelle1 LIMIT ${limit}
|
||||
)
|
||||
SELECT
|
||||
m.no_id,
|
||||
m.codein,
|
||||
m.code_centrale,
|
||||
m.libelle1,
|
||||
COALESCE(fi.nom, af.code, 'Sans fournisseur')::text AS fournisseur,
|
||||
COALESCE(af.code, '')::text AS codefou,
|
||||
COALESCE(n.code, '')::text AS nomenclature_code,
|
||||
COALESCE(n.libelle, '')::text AS nomenclature,
|
||||
COALESCE(st.stock_total, 0)::float AS stock_total,
|
||||
(q.code_centrale IS NOT NULL) AS has_reseau,
|
||||
COALESCE(q.qte_reseau, 0)::float AS qte_reseau,
|
||||
COALESCE(q.nb_magasins_reseau, 0) AS nb_magasins_reseau
|
||||
FROM matched m
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT af1.code
|
||||
FROM artfou1 af1
|
||||
WHERE af1.art_no_id = m.no_id AND af1.preference = 1
|
||||
ORDER BY af1.code
|
||||
LIMIT 1
|
||||
) af ON TRUE
|
||||
LEFT JOIN fouident fi ON fi.code = af.code
|
||||
LEFT JOIN nomenclature n ON n.no_id = m.nom_no_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT SUM(cs.qte)::float AS stock_total
|
||||
FROM cube_stock cs
|
||||
WHERE cs.artnoid = m.no_id AND cs.site IN ('292', '579')
|
||||
) st ON TRUE
|
||||
LEFT JOIN qlik_network_metrics q ON q.code_centrale = m.code_centrale
|
||||
ORDER BY m.libelle1
|
||||
`);
|
||||
|
||||
console.log(`[pg-ff] pgSearchProduits("${cleaned}"): ${result.rows.length} résultats`);
|
||||
return result.rows as unknown as PgProduitSearchRow[];
|
||||
} catch (e) {
|
||||
console.error("[pg-ff] pgSearchProduits error:", (e as Error).message?.slice(0, 250));
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rapproche une liste de **codes centraux** (issus de Qlik) du catalogue FF Nancy.
|
||||
*
|
||||
* C'est l'étape 2 de la recherche produit : Qlik dit quels articles le réseau
|
||||
* travaille, cette requête dit lesquels nous référençons — et avec quel
|
||||
* `codein`, quel fournisseur et quel stock. Un code absent du résultat est un
|
||||
* produit réseau que Nancy ne référence pas (cas normal et intéressant).
|
||||
*
|
||||
* Les codeins sont renvoyés indexés par code centrale ; un même code centrale
|
||||
* peut porter plusieurs codeins (ré-créations d'article), on ne garde que le
|
||||
* `no_id` le plus récent.
|
||||
*/
|
||||
export async function pgGetProduitsByCodeCentrale(
|
||||
codesCentraux: string[],
|
||||
): Promise<Map<string, PgProduitSearchRow>> {
|
||||
const out = new Map<string, PgProduitSearchRow>();
|
||||
const codes = [...new Set(codesCentraux.map((c) => c.trim()).filter(Boolean))];
|
||||
if (codes.length === 0) return out;
|
||||
|
||||
try {
|
||||
const result = await pgNoParallel(sql`
|
||||
WITH matched AS (
|
||||
SELECT DISTINCT ON (TRIM(a.artcentrale::text))
|
||||
a.no_id,
|
||||
TRIM(a.codein::text) AS codein,
|
||||
TRIM(a.artcentrale::text) AS code_centrale,
|
||||
COALESCE(a.libelle1, '') AS libelle1,
|
||||
a.nom_no_id
|
||||
FROM articles a
|
||||
WHERE TRIM(a.artcentrale::text) IN (${sql.join(codes.map((c) => sql`${c}`), sql`, `)})
|
||||
AND a.codein IS NOT NULL
|
||||
ORDER BY TRIM(a.artcentrale::text), a.no_id DESC
|
||||
)
|
||||
SELECT
|
||||
m.no_id,
|
||||
m.codein,
|
||||
m.code_centrale,
|
||||
m.libelle1,
|
||||
COALESCE(fi.nom, af.code, 'Sans fournisseur')::text AS fournisseur,
|
||||
COALESCE(af.code, '')::text AS codefou,
|
||||
COALESCE(n.code, '')::text AS nomenclature_code,
|
||||
COALESCE(n.libelle, '')::text AS nomenclature,
|
||||
COALESCE(st.stock_total, 0)::float AS stock_total,
|
||||
TRUE AS has_reseau,
|
||||
0::float AS qte_reseau,
|
||||
0 AS nb_magasins_reseau
|
||||
FROM matched m
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT af1.code
|
||||
FROM artfou1 af1
|
||||
WHERE af1.art_no_id = m.no_id AND af1.preference = 1
|
||||
ORDER BY af1.code
|
||||
LIMIT 1
|
||||
) af ON TRUE
|
||||
LEFT JOIN fouident fi ON fi.code = af.code
|
||||
LEFT JOIN nomenclature n ON n.no_id = m.nom_no_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT SUM(cs.qte)::float AS stock_total
|
||||
FROM cube_stock cs
|
||||
WHERE cs.artnoid = m.no_id AND cs.site IN ('292', '579')
|
||||
) st ON TRUE
|
||||
`);
|
||||
|
||||
for (const row of result.rows as unknown as PgProduitSearchRow[]) {
|
||||
out.set(row.code_centrale, row);
|
||||
}
|
||||
console.log(`[pg-ff] pgGetProduitsByCodeCentrale: ${codes.length} codes Qlik → ${out.size} trouvés dans le catalogue Nancy`);
|
||||
return out;
|
||||
} catch (e) {
|
||||
console.error("[pg-ff] pgGetProduitsByCodeCentrale error:", (e as Error).message?.slice(0, 250));
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `codein` de l'article portant ce code centrale, ou `null` si Nancy ne le
|
||||
* référence pas. Utilisé par la fiche produit ouverte depuis un résultat Qlik
|
||||
* (URL `?cc=…`), qui n'a que le code centrale.
|
||||
*/
|
||||
export async function pgGetCodeinByCodeCentrale(codeCentrale: string): Promise<string | null> {
|
||||
const cleaned = codeCentrale.trim();
|
||||
if (!cleaned) return null;
|
||||
try {
|
||||
const result = await pgNoParallel(sql`
|
||||
SELECT TRIM(a.codein::text) AS codein
|
||||
FROM articles a
|
||||
WHERE TRIM(a.artcentrale::text) = ${cleaned}
|
||||
AND a.codein IS NOT NULL
|
||||
ORDER BY a.no_id DESC
|
||||
LIMIT 1
|
||||
`);
|
||||
const row = (result.rows as unknown as { codein: string }[])[0];
|
||||
return row?.codein ?? null;
|
||||
} catch (e) {
|
||||
console.error("[pg-ff] pgGetCodeinByCodeCentrale error:", (e as Error).message?.slice(0, 250));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Identité, prix et nomenclature d'un produit (une seule ligne). */
|
||||
export interface PgProduitDetailRow {
|
||||
no_id: number;
|
||||
codein: string;
|
||||
code_centrale: string;
|
||||
libelle1: string;
|
||||
nom_no_id: number | null;
|
||||
gtin: string;
|
||||
reference: string;
|
||||
pcb: number | null;
|
||||
/** article_infosup.prix_vente_mini */
|
||||
pv_central: number | null;
|
||||
/** cube_pa.pa */
|
||||
pa: number | null;
|
||||
code3: string | null;
|
||||
libelle3: string | null;
|
||||
code2: string | null;
|
||||
libelle2: string | null;
|
||||
code1: string | null;
|
||||
libelle1nom: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fiche d'identité complète d'un produit à partir de son `codein`.
|
||||
* Renvoie `null` si le codein est inconnu.
|
||||
*/
|
||||
export async function pgGetProduitDetail(codein: string): Promise<PgProduitDetailRow | null> {
|
||||
const parentCol = await getNomenclatureParentCol();
|
||||
// Hiérarchie nomenclature — deux stratégies :
|
||||
//
|
||||
// 1. Colonne parent entière si elle existe (FK vers le no_id du niveau au-dessus).
|
||||
// 2. Sinon, remontée par **préfixe de code** : la nomenclature FF est encodée
|
||||
// hiérarchiquement, secteur = 2 chiffres, famille = 4, sous-famille = 6
|
||||
// (ex "330702" → "3307" → "33"). En production `nomenclature` n'expose que
|
||||
// no_id / code / libelle / niveau / chemin_pere : aucune FK entière, et
|
||||
// `chemin_pere` est un chemin texte. Sans ce repli, Secteur et Famille
|
||||
// restaient vides sur la fiche.
|
||||
const hierarchy = parentCol
|
||||
? sql`
|
||||
LEFT JOIN nomenclature n2 ON n2.no_id = n3.${sql.raw(parentCol)}
|
||||
LEFT JOIN nomenclature n1 ON n1.no_id = n2.${sql.raw(parentCol)}
|
||||
`
|
||||
: sql`
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT nx.code, nx.libelle
|
||||
FROM nomenclature nx
|
||||
WHERE LENGTH(TRIM(n3.code)) >= 4
|
||||
AND TRIM(nx.code) = LEFT(TRIM(n3.code), 4)
|
||||
LIMIT 1
|
||||
) n2 ON TRUE
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT nx.code, nx.libelle
|
||||
FROM nomenclature nx
|
||||
WHERE LENGTH(TRIM(n3.code)) >= 2
|
||||
AND TRIM(nx.code) = LEFT(TRIM(n3.code), 2)
|
||||
LIMIT 1
|
||||
) n1 ON TRUE
|
||||
`;
|
||||
// Les colonnes sont identiques dans les deux stratégies (alias n1 / n2).
|
||||
const hierarchyCols = sql`,
|
||||
n2.code AS code2,
|
||||
n2.libelle AS libelle2,
|
||||
n1.code AS code1,
|
||||
n1.libelle AS libelle1nom`;
|
||||
|
||||
try {
|
||||
const result = await pgNoParallel(sql`
|
||||
SELECT
|
||||
a.no_id,
|
||||
TRIM(a.codein::text) AS codein,
|
||||
COALESCE(TRIM(a.artcentrale::text), '') AS code_centrale,
|
||||
COALESCE(a.libelle1, '') AS libelle1,
|
||||
a.nom_no_id,
|
||||
COALESCE(ag.gtin, af.ean13, '')::text AS gtin,
|
||||
COALESCE(af.reference, '')::text AS reference,
|
||||
af.pcb::float AS pcb,
|
||||
ai.prix_vente_mini::float AS pv_central,
|
||||
pa.pa::float AS pa,
|
||||
n3.code AS code3,
|
||||
n3.libelle AS libelle3
|
||||
${hierarchyCols}
|
||||
FROM articles a
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT af1.code, af1.pcb, af1.reference, af1.ean13
|
||||
FROM artfou1 af1
|
||||
WHERE af1.art_no_id = a.no_id
|
||||
ORDER BY af1.preference DESC NULLS LAST, af1.no_id DESC
|
||||
LIMIT 1
|
||||
) af ON TRUE
|
||||
LEFT JOIN art_gtin ag ON ag.idarticle = a.no_id AND ag.preferentiel = 1
|
||||
LEFT JOIN article_infosup ai ON ai.artnoid = a.no_id
|
||||
LEFT JOIN cube_pa pa ON pa.artnoid = a.no_id
|
||||
LEFT JOIN nomenclature n3 ON n3.no_id = a.nom_no_id
|
||||
${hierarchy}
|
||||
WHERE TRIM(a.codein::text) = ${codein}
|
||||
ORDER BY a.no_id DESC
|
||||
LIMIT 1
|
||||
`);
|
||||
|
||||
const row = (result.rows as unknown as PgProduitDetailRow[])[0] ?? null;
|
||||
console.log(`[pg-ff] pgGetProduitDetail(${codein}): ${row ? "trouvé" : "introuvable"}`);
|
||||
return row;
|
||||
} catch (e) {
|
||||
console.error("[pg-ff] pgGetProduitDetail error:", (e as Error).message?.slice(0, 250));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Un fournisseur référencé pour l'article. */
|
||||
export interface PgProduitFournisseurRow {
|
||||
codefou: string;
|
||||
nomfou: string;
|
||||
reference: string;
|
||||
pcb: number | null;
|
||||
/** true si artfou1.preference = 1 */
|
||||
principal: boolean;
|
||||
}
|
||||
|
||||
/** Tous les fournisseurs référencés pour un article (le principal en premier). */
|
||||
export async function pgGetFournisseursByCodein(codein: string): Promise<PgProduitFournisseurRow[]> {
|
||||
try {
|
||||
const result = await pgNoParallel(sql`
|
||||
SELECT DISTINCT ON (af.code)
|
||||
af.code::text AS codefou,
|
||||
COALESCE(fi.nom, af.code)::text AS nomfou,
|
||||
COALESCE(af.reference, '')::text AS reference,
|
||||
af.pcb::float AS pcb,
|
||||
(af.preference = 1) AS principal
|
||||
FROM articles a
|
||||
JOIN artfou1 af ON af.art_no_id = a.no_id
|
||||
LEFT JOIN fouident fi ON fi.code = af.code
|
||||
WHERE TRIM(a.codein::text) = ${codein}
|
||||
ORDER BY af.code, af.preference DESC NULLS LAST, af.no_id DESC
|
||||
`);
|
||||
const rows = result.rows as unknown as PgProduitFournisseurRow[];
|
||||
// Le principal remonte en tête (le DISTINCT ON impose un tri par code).
|
||||
rows.sort((x, y) => Number(y.principal) - Number(x.principal) || x.nomfou.localeCompare(y.nomfou, "fr"));
|
||||
return rows;
|
||||
} catch (e) {
|
||||
console.error("[pg-ff] pgGetFournisseursByCodein error:", (e as Error).message?.slice(0, 250));
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Stock temps réel par site pour un article. */
|
||||
export interface PgProduitStockRow {
|
||||
site: string;
|
||||
stockdispo: number;
|
||||
qte: number;
|
||||
valstock: number;
|
||||
prmp: number;
|
||||
dernierevente: string | null;
|
||||
dernierereception: string | null;
|
||||
}
|
||||
|
||||
export async function pgGetStockByCodein(codein: string): Promise<PgProduitStockRow[]> {
|
||||
try {
|
||||
const result = await pgNoParallel(sql`
|
||||
SELECT
|
||||
cs.site,
|
||||
COALESCE(cs.stockdispo, 0)::float AS stockdispo,
|
||||
COALESCE(cs.qte, 0)::float AS qte,
|
||||
COALESCE(cs.valstock, 0)::float AS valstock,
|
||||
COALESCE(cs.prmp, 0)::float AS prmp,
|
||||
cs.dernierevente::text AS dernierevente,
|
||||
cs.dernierereception::text AS dernierereception
|
||||
FROM cube_stock cs
|
||||
JOIN articles a ON a.no_id = cs.artnoid
|
||||
WHERE TRIM(a.codein::text) = ${codein}
|
||||
AND cs.site IN ('292', '579')
|
||||
ORDER BY cs.site
|
||||
`);
|
||||
return result.rows as unknown as PgProduitStockRow[];
|
||||
} catch (e) {
|
||||
console.error("[pg-ff] pgGetStockByCodein error:", (e as Error).message?.slice(0, 250));
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Quantité totale en commande fournisseur pour un article. */
|
||||
export async function pgGetCommandesByCodein(codein: string): Promise<number> {
|
||||
try {
|
||||
const result = await pgNoParallel(sql`
|
||||
SELECT COALESCE(SUM(cv.cdefou_ligne_qtecde), 0)::float AS qtecde
|
||||
FROM cdefou_vivant cv
|
||||
WHERE TRIM(cv.articles_codein::text) = ${codein}
|
||||
`);
|
||||
const row = (result.rows as unknown as { qtecde: number }[])[0];
|
||||
return Number(row?.qtecde) || 0;
|
||||
} catch (e) {
|
||||
console.error("[pg-ff] pgGetCommandesByCodein error:", (e as Error).message?.slice(0, 250));
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** Une gamme affectée à l'article pour une saison donnée. */
|
||||
export interface PgGammeHistoryRow {
|
||||
saison_no_id: number;
|
||||
saison_code: string | null;
|
||||
saison_libelle: string | null;
|
||||
gamme_code: string;
|
||||
gamme_libelle: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Historique complet des gammes de l'article, saison par saison (la plus
|
||||
* récente en premier). Le reste de l'application ne lit que la saison active
|
||||
* (`pgGetGammesByFournisseur`) ; ici on veut la trajectoire d'arbitrage.
|
||||
*/
|
||||
export async function pgGetGammeHistoryByCodein(codein: string): Promise<PgGammeHistoryRow[]> {
|
||||
try {
|
||||
const result = await pgNoParallel(sql`
|
||||
SELECT
|
||||
s.no_id AS saison_no_id,
|
||||
s.code::text AS saison_code,
|
||||
s.libelle::text AS saison_libelle,
|
||||
g.code::text AS gamme_code,
|
||||
g.libelle::text AS gamme_libelle
|
||||
FROM art_gamme_saison ags
|
||||
JOIN articles a ON a.no_id = ags.artnoid
|
||||
JOIN gammes g ON g.no_id = ags.idgamme
|
||||
JOIN saisons s ON s.no_id = ags.idsaison
|
||||
WHERE TRIM(a.codein::text) = ${codein}
|
||||
ORDER BY s.no_id DESC
|
||||
`);
|
||||
return result.rows as unknown as PgGammeHistoryRow[];
|
||||
} catch (e) {
|
||||
console.error("[pg-ff] pgGetGammeHistoryByCodein error:", (e as Error).message?.slice(0, 250));
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Données mensuelles (ventes / CA / marge / stock fin de mois / réceptions)
|
||||
* d'UN article, par site et par mois.
|
||||
*
|
||||
* Reprend **exactement** le calcul canonique de `pgGetMensuelByFournisseur`
|
||||
* (la source de vérité de la Grille) : seule la sélection change — filtre sur
|
||||
* le codein au lieu d'une jointure `artfou1`. Les chiffres affichés sur la
|
||||
* fiche produit sont donc identiques à ceux de la Grille, par construction.
|
||||
*/
|
||||
export async function pgGetMensuelByCodein(
|
||||
codein: string,
|
||||
dateDebut: string,
|
||||
dateFin: string
|
||||
): Promise<PgMensuelRow[]> {
|
||||
try {
|
||||
const result = await pgNoParallel(sql`
|
||||
WITH base AS (
|
||||
SELECT
|
||||
TRIM(a.codein::text) AS codein,
|
||||
m.site,
|
||||
TO_CHAR(m.datmvt, 'YYYY-MM') AS mois,
|
||||
m.qtemvt,
|
||||
m.mntmvtttc,
|
||||
m.margemvt,
|
||||
m.genremvt,
|
||||
m.qtestock,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY m.site, TO_CHAR(m.datmvt, 'YYYY-MM')
|
||||
ORDER BY m.datmvt DESC
|
||||
) AS rn_last
|
||||
FROM mvtart m
|
||||
JOIN articles a ON a.no_id = m.artnoid
|
||||
WHERE TRIM(a.codein::text) = ${codein}
|
||||
AND m.datmvt BETWEEN ${dateDebut}::date AND ${dateFin}::date
|
||||
AND m.site IN ('292', '579')
|
||||
)
|
||||
SELECT
|
||||
codein,
|
||||
site,
|
||||
mois,
|
||||
SUM(CASE WHEN genremvt = 3 THEN -qtemvt ELSE 0 END)::float AS qte_vendue,
|
||||
SUM(CASE WHEN genremvt = 3 THEN -mntmvtttc ELSE 0 END)::float AS ca_ht,
|
||||
SUM(CASE WHEN genremvt = 3 THEN margemvt ELSE 0 END)::float AS marge,
|
||||
MAX(CASE WHEN rn_last = 1 THEN qtestock ELSE NULL END)::float AS stock_fin_mois,
|
||||
SUM(CASE WHEN genremvt IN (1, 2) THEN qtemvt ELSE 0 END)::float AS qte_recue
|
||||
FROM base
|
||||
GROUP BY codein, site, mois
|
||||
ORDER BY site, mois
|
||||
`);
|
||||
|
||||
console.log(`[pg-ff] pgGetMensuelByCodein(${codein}): ${result.rows.length} lignes`);
|
||||
return result.rows as unknown as PgMensuelRow[];
|
||||
} catch (e) {
|
||||
console.error("[pg-ff] pgGetMensuelByCodein error:", (e as Error).message?.slice(0, 250));
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Un produit de la même famille, comparé local vs réseau. */
|
||||
export interface PgOpportuniteRow {
|
||||
codein: string;
|
||||
libelle1: string;
|
||||
code_centrale: string;
|
||||
fournisseur: string;
|
||||
qte_reseau: number;
|
||||
nb_magasins_reseau: number;
|
||||
ca_reseau: number;
|
||||
ca_par_magasin_reseau: number;
|
||||
qte_locale: number;
|
||||
nb_sites_locaux: number;
|
||||
qte_reseau_par_magasin: number;
|
||||
qte_locale_par_magasin: number;
|
||||
/** Écart réseau − local, par magasin, sur 12 mois. Positif = opportunité. */
|
||||
ecart_par_magasin: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Produits de la même nomenclature, classés par écart entre la performance
|
||||
* réseau et la performance locale (quantités **par magasin**, pour neutraliser
|
||||
* la différence d'échelle entre ~270 magasins et nos 2 sites).
|
||||
*
|
||||
* Périmètre volontairement borné aux articles **de notre catalogue** ayant déjà
|
||||
* des métriques réseau en cache : la dimension Qlik interrogée est
|
||||
* `Article Code`, on ne peut pas découvrir un produit que Nancy ne référence pas.
|
||||
*/
|
||||
export async function pgGetOpportunitesFamille(
|
||||
nomNoId: number,
|
||||
dateDebut: string,
|
||||
dateFin: string,
|
||||
limit = 30
|
||||
): Promise<PgOpportuniteRow[]> {
|
||||
try {
|
||||
const result = await pgNoParallel(sql`
|
||||
WITH famille AS (
|
||||
SELECT DISTINCT ON (TRIM(a.codein::text))
|
||||
a.no_id,
|
||||
TRIM(a.codein::text) AS codein,
|
||||
TRIM(a.artcentrale::text) AS code_centrale,
|
||||
COALESCE(a.libelle1, '') AS libelle1
|
||||
FROM articles a
|
||||
WHERE a.nom_no_id = ${nomNoId}
|
||||
AND a.codein IS NOT NULL
|
||||
AND a.artcentrale IS NOT NULL
|
||||
AND TRIM(a.artcentrale::text) <> ''
|
||||
ORDER BY TRIM(a.codein::text), a.no_id DESC
|
||||
),
|
||||
avec_reseau AS (
|
||||
SELECT
|
||||
f.no_id,
|
||||
f.codein,
|
||||
f.code_centrale,
|
||||
f.libelle1,
|
||||
q.qte_reseau::float AS qte_reseau,
|
||||
q.nb_magasins_reseau AS nb_magasins_reseau,
|
||||
COALESCE(q.ca_reseau, 0)::float AS ca_reseau,
|
||||
COALESCE(q.ca_par_magasin_reseau, 0)::float AS ca_par_magasin_reseau
|
||||
FROM famille f
|
||||
JOIN qlik_network_metrics q ON q.code_centrale = f.code_centrale
|
||||
WHERE q.nb_magasins_reseau > 0
|
||||
AND q.qte_reseau > 0
|
||||
),
|
||||
ventes AS (
|
||||
SELECT
|
||||
TRIM(a.codein::text) AS codein,
|
||||
SUM(-m.qtemvt)::float AS qte_locale,
|
||||
COUNT(DISTINCT m.site) AS nb_sites_locaux
|
||||
FROM mvtart m
|
||||
JOIN articles a ON a.no_id = m.artnoid
|
||||
WHERE m.genremvt = 3
|
||||
AND m.site IN ('292', '579')
|
||||
AND m.datmvt BETWEEN ${dateDebut}::date AND ${dateFin}::date
|
||||
AND TRIM(a.codein::text) IN (SELECT codein FROM avec_reseau)
|
||||
GROUP BY TRIM(a.codein::text)
|
||||
)
|
||||
SELECT
|
||||
r.codein,
|
||||
r.libelle1,
|
||||
r.code_centrale,
|
||||
COALESCE(fi.nom, af.code, 'Sans fournisseur')::text AS fournisseur,
|
||||
r.qte_reseau,
|
||||
r.nb_magasins_reseau,
|
||||
r.ca_reseau,
|
||||
r.ca_par_magasin_reseau,
|
||||
COALESCE(v.qte_locale, 0)::float AS qte_locale,
|
||||
COALESCE(v.nb_sites_locaux, 0) AS nb_sites_locaux,
|
||||
(r.qte_reseau / r.nb_magasins_reseau)::float AS qte_reseau_par_magasin,
|
||||
(COALESCE(v.qte_locale, 0) / GREATEST(COALESCE(v.nb_sites_locaux, 0), 1))::float AS qte_locale_par_magasin,
|
||||
(
|
||||
(r.qte_reseau / r.nb_magasins_reseau)
|
||||
- (COALESCE(v.qte_locale, 0) / GREATEST(COALESCE(v.nb_sites_locaux, 0), 1))
|
||||
)::float AS ecart_par_magasin
|
||||
FROM avec_reseau r
|
||||
LEFT JOIN ventes v ON v.codein = r.codein
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT af1.code
|
||||
FROM artfou1 af1
|
||||
WHERE af1.art_no_id = r.no_id AND af1.preference = 1
|
||||
ORDER BY af1.code
|
||||
LIMIT 1
|
||||
) af ON TRUE
|
||||
LEFT JOIN fouident fi ON fi.code = af.code
|
||||
ORDER BY ecart_par_magasin DESC
|
||||
LIMIT ${limit}
|
||||
`);
|
||||
|
||||
console.log(`[pg-ff] pgGetOpportunitesFamille(nom=${nomNoId}): ${result.rows.length} lignes`);
|
||||
return result.rows as unknown as PgOpportuniteRow[];
|
||||
} catch (e) {
|
||||
console.error("[pg-ff] pgGetOpportunitesFamille error:", (e as Error).message?.slice(0, 250));
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -91,6 +91,36 @@ export interface NetworkMetric {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -144,8 +144,14 @@ export function buildGridNetworkQlikDateFilter(
|
||||
// Début : `months` mois avant le mois courant (1er du mois)
|
||||
const startMonth = new Date(now.getFullYear(), now.getMonth() - months, 1);
|
||||
|
||||
const dateDebut = startMonth.toISOString().slice(0, 10);
|
||||
const dateFin = endMonth.toISOString().slice(0, 10);
|
||||
// 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);
|
||||
|
||||
@@ -9,7 +9,7 @@ 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;
|
||||
@@ -19,6 +19,18 @@ export interface NetworkMetricCached {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -43,6 +55,10 @@ export async function getNetworkMetricsByCodeCentrale(
|
||||
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,
|
||||
});
|
||||
}
|
||||
@@ -62,6 +78,9 @@ export async function upsertNetworkMetrics(metrics: NetworkMetric[]): Promise<nu
|
||||
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,
|
||||
}));
|
||||
|
||||
@@ -82,7 +101,15 @@ export async function upsertNetworkMetrics(metrics: NetworkMetric[]): Promise<nu
|
||||
caParMagasinReseau: sql`excluded.ca_par_magasin_reseau`,
|
||||
margePctReseau: sql`excluded.marge_pct_reseau`,
|
||||
periode: sql`excluded.periode`,
|
||||
qteByMonth: sql`excluded.qte_by_month`,
|
||||
// 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`,
|
||||
},
|
||||
});
|
||||
|
||||
+1698
-577
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);
|
||||
}
|
||||
@@ -121,6 +121,14 @@ export interface ProductRow {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
/**
|
||||
* CollectFlow — Types de la recherche réseau Qlik.
|
||||
*
|
||||
* Partagés entre la route `/api/qlik/search` et la page `/recherche-reseau`.
|
||||
*/
|
||||
|
||||
/** Une ligne de résultat : métriques Qlik du réseau + enrichissement local FF Nancy. */
|
||||
export interface NetworkSearchRow {
|
||||
codeCentrale: string;
|
||||
/** Libellé tel qu'il apparaît dans Qlik (seule source pour un produit non référencé). */
|
||||
libelle: string;
|
||||
fournisseur?: string;
|
||||
famille?: string;
|
||||
|
||||
// ── Métriques réseau (Qlik, 12 mois glissants) ──
|
||||
caReseau: number;
|
||||
qteReseau: number;
|
||||
nbMagasinsReseau: number;
|
||||
caParMagasinReseau: number;
|
||||
/** Ratio brut Qlik (0.32 = 32 %). */
|
||||
margePctReseau: number;
|
||||
/** Quantité vendue réseau par mois : { "YYYY-MM": qté }. */
|
||||
qteByMonth: Record<string, number> | null;
|
||||
|
||||
// ── Enrichissement catalogue local ──
|
||||
/** false = produit vendu par le réseau mais absent de notre catalogue. */
|
||||
chezMoi: boolean;
|
||||
codein?: string;
|
||||
libelleLocal?: string;
|
||||
codefou?: string;
|
||||
nomfou?: string;
|
||||
pa?: number;
|
||||
pvCentral?: number;
|
||||
stock292?: number;
|
||||
stock579?: number;
|
||||
stockTotal?: number;
|
||||
}
|
||||
|
||||
export type NetworkSearchStatus = "idle" | "running" | "success" | "error";
|
||||
|
||||
/** État d'un job de recherche, tel que renvoyé par GET/POST /api/qlik/search. */
|
||||
export interface NetworkSearchJobState {
|
||||
jobId: string | null;
|
||||
status: NetworkSearchStatus;
|
||||
query: string;
|
||||
/** "code" = recherche par code centrale, "label" = recherche plein texte. */
|
||||
mode?: "code" | "label";
|
||||
/** Nb de valeurs « Article » trouvées par Qlik avant construction du cube. */
|
||||
matchCount?: number;
|
||||
/** true = recherche trop large, refusée pour protéger le moteur Qlik. */
|
||||
tooMany?: boolean;
|
||||
/** Nb max de produits accepté par recherche (affiché dans le message « trop large »). */
|
||||
maxProducts?: number;
|
||||
periode?: string;
|
||||
results?: NetworkSearchRow[];
|
||||
startedAt?: string;
|
||||
finishedAt?: string;
|
||||
error?: string;
|
||||
}
|
||||
Reference in new issue
Block a user