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:
Claude committed 2026-08-06 11:13:44 +00:00
commit 3fa92e891b
50 files changed
+8488 -2017

No files matched your search

+229
View File
@@ -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 })
);
+364
View File
@@ -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
View File
@@ -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";
`);
+354
View File
@@ -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);
});
+661
View File
@@ -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&apos;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&apos;a pas de code centrale : il n&apos;est pas référencé centralement et n&apos;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&apos;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&apos;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&apos;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&apos;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>
);
}
+5
View File
@@ -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&apos;a de données réseau en cache.
Synchronisez d&apos;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>
);
}
+69
View File
@@ -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&apos;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>
);
}
+399
View File
@@ -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&apos;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&apos;a pas pu être interrogé, la recherche s&apos;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&apos;interrogation de Qlik prend en général 20 à 40 secondes. Plusieurs codes centraux peuvent être
collés d&apos;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&apos;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 />;
}
+9
View File
@@ -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"
+54
View File
@@ -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 });
}
}
+70
View File
@@ -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 });
}
}
-219
View File
@@ -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
View File
@@ -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(
+17
View File
@@ -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;
+2 -2
View File
@@ -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" },
+64 -22
View File
@@ -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>
+15
View File
@@ -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(),
});
+26 -2
View File
@@ -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>
);
+175 -125
View File
@@ -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>
);
}
+14 -222
View File
@@ -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" />
+96
View File
@@ -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 };
}
}
+215
View File
@@ -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);
}
+147
View File
@@ -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" };
}
+128
View File
@@ -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 };
}
+223
View File
@@ -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&apos;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
View File
@@ -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,
};
}
+229
View File
@@ -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));
}
-67
View File
@@ -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
View File
@@ -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 [];
}
}
+30
View File
@@ -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;
}
/**
+8 -2
View File
@@ -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);
+29 -2
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+664
View File
@@ -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();
}
}
+26
View File
@@ -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);
}
+8
View File
@@ -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;
}
-59
View File
@@ -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;
}