diff --git a/.viz-bundle.mjs b/.viz-bundle.mjs new file mode 100644 index 0000000..be35014 --- /dev/null +++ b/.viz-bundle.mjs @@ -0,0 +1,229 @@ +// +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}`)) }) + ] }) }) + ] }) + ] }); +} + +// +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 }) +); diff --git a/docs/qlik-integration.md b/docs/qlik-integration.md index aafdb8e..f05a8a4 100644 --- a/docs/qlik-integration.md +++ b/docs/qlik-integration.md @@ -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= +QLIK_FIELD_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({} quantite) +« CA N » = Sum({} $(vCA_vat)) +« Quantité COMP » = Sum({} quantite) +« Magasin Ventes Nb N » = Count({} 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='' -it 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 diff --git a/scripts/db-init.js b/scripts/db-init.js index 0249758..410c602 100644 --- a/scripts/db-init.js +++ b/scripts/db-init.js @@ -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"; `); diff --git a/scripts/qlik-validate.mjs b/scripts/qlik-validate.mjs new file mode 100644 index 0000000..86e3a46 --- /dev/null +++ b/scripts/qlik-validate.mjs @@ -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='' -it 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 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); +}); diff --git a/src/app/(dashboard)/produits/fiche.tsx b/src/app/(dashboard)/produits/fiche.tsx new file mode 100644 index 0000000..b011ce0 --- /dev/null +++ b/src/app/(dashboard)/produits/fiche.tsx @@ -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 ( +
+
+

+ + {title} +

+ {action} +
+
{children}
+
+ ); +} + +function Field({ label, value, mono }: { label: string; value: React.ReactNode; mono?: boolean }) { + return ( +
+
+ {label} +
+
+ {value ?? "—"} +
+
+ ); +} + +function Stat({ label, value, hint, color }: { label: string; value: string; hint?: string; color?: string }) { + return ( +
+
+ {label} +
+
+ {value} +
+ {hint &&
{hint}
} +
+ ); +} + +// ─── 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 ( +
+ {message && ( + + {message} + + )} + +
+ ); +} + +// ─── 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("TOTAL"); + + const sitesDisponibles = useMemo(() => { + const sites = new Set([...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 = {}; + 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 ( +
+ {/* En-tête */} +
+
+ + + Retour aux résultats + +

+ {titre} +

+

+ {detail && <>{detail.codein}{" · "}} + {codeCentrale && <>centrale {codeCentrale}} + {fournisseurAffiche && <> · {fournisseurAffiche}} +

+
+ {fournisseurPrincipal?.codefou && ( + + + Voir dans la Grille + + )} +
+ + {!detail && ( +
+ +
+ Ce produit est travaillé par le réseau mais n'est pas référencé dans notre catalogue. +
+ Seules les données réseau Qlik sont disponibles : ni stock, ni ventes, ni gamme chez nous. +
+
+
+ )} + + {/* ─── Réseau Qlik — la donnée qui motive la recherche ─────────── */} + : undefined} + > + {!codeCentrale ? ( +

+ Ce produit n'a pas de code centrale : il n'est pas référencé centralement et n'existe donc pas dans Qlik. +

+ ) : !reseau ? ( +
+ +
+ Aucune donnée réseau en cache pour ce produit. +
+ Lancez l'extraction ci-dessus : elle ne porte que sur ce code centrale et prend quelques secondes. +
+
+
+ ) : ( + <> +
+ + + + + + + = 40 ? "#22c55e" : margeReseau != null && margeReseau >= 25 ? "#f59e0b" : undefined} + /> + = 0 ? "+" : ""}${Math.round(trend.pct * 100)} %` : "—"} + hint={trendLabel(trend.pct, trend.nouveau)} + color={TREND_COLOR[trend.direction]} + /> +
+ +
+ + +
+ +

+ Période {reseau.periode ?? "12 mois"} · extrait le {formatDate(reseau.fetchedAt)} · + {" "}mois en cours exclu (incomplet). +

+ + {trend.hasData && ( +
+
+ Tendance réseau — la qté/magasin d'abord, les volumes en contexte +
+ +
+ )} + + {/* Détail mensuel complet (CA, marge, magasins) si disponible */} + {reseau.metricsByMonth && Object.keys(reseau.metricsByMonth).length > 0 && ( +
+
+ Détail mensuel réseau +
+ + + + + {months.map((m) => ( + + ))} + + + + {LIGNES_MENSUELLES_RESEAU.map((row) => ( + + + {months.map((m) => { + const cell = reseau.metricsByMonth?.[ffMonthToQlik(m)]; + const v = cell ? row.get(cell) : undefined; + return ( + + ); + })} + + ))} + +
Mesure + {formatMonthLabel(m)} +
+ {row.label} + + {v != null ? row.fmt(v) : "—"} +
+

+ Les mois sans CA ni marge proviennent de la mesure « Quantité COMP », qui ne porte que la quantité. +

+
+ )} + + )} +
+ + {/* ─── Fournisseur ─────────────────────────────────────────────── */} + + {fournisseurs.length === 0 ? ( +

+ {fournisseurReseau + ? <>Aucun fournisseur référencé chez nous. Côté réseau, Qlik indique {fournisseurReseau}. + : "Aucun fournisseur référencé pour ce produit."} +

+ ) : ( +
+ {fournisseurs.map((f) => ( +
+
+
+ {f.nomfou} + {f.principal && · principal} +
+
{f.codefou}
+
+
+ Réf. {f.reference || "—"} + PCB {f.pcb != null ? fmtQte(f.pcb) : "—"} + + Grille + +
+
+ ))} + {fournisseurReseau && ( +

+ Fournisseur côté Qlik : {fournisseurReseau} +

+ )} +
+ )} +
+ + {/* ─── Identité catalogue (produits référencés uniquement) ──────── */} + {detail && ( + +
+ + + + + + + + + + + + + 0 ? fmtQte(commandesEnCours) : "—"} /> +
+ + {gammes.length > 1 && ( +
+
+ Historique de gamme +
+
+ {gammes.slice(0, 10).map((g, i) => ( + + {g.saison_libelle || g.saison_code || `Saison ${g.saison_no_id}`} + {g.gamme_code} + + ))} +
+
+ )} +
+ )} + + {/* ─── Stock ───────────────────────────────────────────────────── */} + {detail && ( + + {stock.length === 0 ? ( +

Aucune ligne de stock pour ce produit.

+ ) : ( +
+ {stock.map((s) => ( +
+
+ + {SITE_LABELS[s.site]?.nom ?? s.site} + + + {fmtQte(s.qte)} + +
+
+
Dispo : {fmtQte(s.stockdispo)}
+
Valeur : {fmtEur(s.valstock)}
+
PRMP : {s.prmp > 0 ? fmtEur2(s.prmp) : "—"}
+
Dern. vente : {formatDate(s.dernierevente)}
+
Dern. réception : {formatDate(s.dernierereception)}
+
+
+ ))} +
+ )} +
+ )} + + {/* ─── Nos ventes, 12 mois glissants ───────────────────────────── */} + {detail && ( + + {["TOTAL", ...sitesDisponibles].map((code) => ( + + ))} +
+ } + > +
+ + + + = 40 ? "#22c55e" : totauxAffiches.tauxMarge >= 25 ? "#f59e0b" : undefined} + /> +
+ +
+ + + + + {months.map((m) => ( + + ))} + + + + + + {months.map((m) => ( + + ))} + + + + {months.map((m) => ( + + ))} + + + + {months.map((m) => ( + + ))} + + +
+ Mois + + {formatMonthLabel(m)} +
+ Ventes + + +
+ CA (TTC) + + {serie[m]?.ca ? Math.round(serie[m].ca).toLocaleString("fr-FR") : "—"} +
+ Stock fin de mois + + {serie[m]?.stockFinMois ? Math.round(serie[m].stockFinMois).toLocaleString("fr-FR") : "—"} +
+
+ + serie[m]?.qte ?? 0)} + color="#0ea5e9" + /> + + )} + + {/* ─── Comparatif local vs réseau ──────────────────────────────── */} + {detail && comparatif.hasReseau && ( + +
+ + 1 ? "s" : ""} · 12 mois`} + /> + + +
+ +
+
+
+ CA par magasin · indicatif +
+
+ Nous : {fmtEur(comparatif.caLocalParMagasin)} + Réseau : {comparatif.caReseauParMagasin != null ? fmtEur(comparatif.caReseauParMagasin) : "—"} +
+
+
+
+ Taux de marge · indicatif +
+
+ Nous : {fmtDec(comparatif.tauxMargeLocal)} % + Réseau : {comparatif.tauxMargeReseau != null ? `${fmtDec(comparatif.tauxMargeReseau)} %` : "—"} +
+
+
+ +

+ L'indice compare les quantités par magasin, seule base commune fiable. + Notre CA est TTC (issu des mouvements de caisse) ; la base du CA Qlik n'est pas + documentée — montants et marges sont donc donnés à titre indicatif. +

+ + {comparatif.hasMonthly && ( +
+
+ Quantité par magasin, mois par mois +
+ 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" }, + ]} + /> +
+ )} +
+ )} + + {/* ─── Opportunités de la même famille ─────────────────────────── */} + {detail?.nom_no_id != null && ( + + + + )} + + {/* Sparkline discrète en pied de fiche, cohérente avec la Grille */} + {trend.hasData && ( +
+ Tendance réseau : + +
+ )} + + ); +} diff --git a/src/app/(dashboard)/produits/loading.tsx b/src/app/(dashboard)/produits/loading.tsx new file mode 100644 index 0000000..64ecef1 --- /dev/null +++ b/src/app/(dashboard)/produits/loading.tsx @@ -0,0 +1,5 @@ +import LoadingModal from "@/components/shared/loading-modal"; + +export default function ProduitsLoading() { + return ; +} diff --git a/src/app/(dashboard)/produits/opportunites.tsx b/src/app/(dashboard)/produits/opportunites.tsx new file mode 100644 index 0000000..f1ef7e3 --- /dev/null +++ b/src/app/(dashboard)/produits/opportunites.tsx @@ -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(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(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 ( +
+

+ 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 ? +

+ + {error &&

{error}

} +
+ ); + } + + // 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 ( +

+ Aucune autre référence de cette famille n'a de données réseau en cache. + Synchronisez d'autres fournisseurs pour enrichir la comparaison. +

+ ); + } + + return ( +
+
+ + + + + + + + + + + + + {others.map((r) => { + const opportunite = r.ecart_par_magasin > 0; + return ( + + + + + + + + + ); + })} + +
ProduitFournisseurRéseau / mag.Nous / mag.ÉcartMagasins
+ + {r.libelle1 || r.codein} + + {r.fournisseur} + {fmtDec(r.qte_reseau_par_magasin)} + + {fmtDec(r.qte_locale_par_magasin)} + + {r.ecart_par_magasin > 0 ? "+" : ""}{fmtDec(r.ecart_par_magasin)} + + {fmtQte(r.nb_magasins_reseau)} +
+
+

+ É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 de notre catalogue 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. +

+
+ ); +} diff --git a/src/app/(dashboard)/produits/page.tsx b/src/app/(dashboard)/produits/page.tsx new file mode 100644 index 0000000..59594d5 --- /dev/null +++ b/src/app/(dashboard)/produits/page.tsx @@ -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 ( +
+
+

+ Recherche produit +

+

+ Recherche dans Qlik Sense (réseau) puis rapprochement avec notre catalogue — + performance réseau et ventes locales sur 12 mois glissants. +

+
+ + {/* key : remonte le champ quand l'URL change (retour arrière, lien partagé) */} + + + {ficheDemandee && !fiche && ( +
+ Aucun produit trouvé pour{" "} + {codein + ? <>le code article {codein} + : <>le code centrale {codeCentrale}}. + {codeCentrale && !codein && ( +
+ Ce code n'est ni dans notre catalogue, ni dans le cache réseau — relancez la recherche. +
+ )} +
+ )} + + {fiche && } + + {!ficheDemandee && } +
+ ); +} diff --git a/src/app/(dashboard)/produits/results.tsx b/src/app/(dashboard)/produits/results.tsx new file mode 100644 index 0000000..4c1e823 --- /dev/null +++ b/src/app/(dashboard)/produits/results.tsx @@ -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> { + const texte = await res.text(); + let data: Record | null = null; + try { + data = texte ? (JSON.parse(texte) as Record) : 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 | 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 ( +
+ +

+ Recherchez un produit dans le réseau +

+

+ La recherche interroge Qlik Sense (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 »). +

+
+ ); + } + + if (loading) { + return ( +
+ +

+ {etape ?? "Interrogation de Qlik Sense…"} +

+

+ Recherche des articles du réseau puis extraction de leurs ventes sur 12 mois glissants. + Cela peut prendre plus d'une minute — vous pouvez laisser la page ouverte. +

+
+ ); + } + + if (error) { + return ( +
+
+ +
+

+ La recherche a échoué +

+

{error}

+ +
+
+
+ ); + } + + if (!data) return null; + + const { rows, source, qlikError, tronque, locauxHorsReseau, dureeMs } = data; + + return ( +
+
+

+ {source === "qlik" ? ( + + ) : ( + + )} + {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 +

+ +
+ + {source === "db" && ( +
+ + + Qlik n'a pas pu être interrogé, la recherche s'est repliée sur le catalogue local + (les chiffres réseau affichés viennent du dernier cache). + {qlikError && {qlikError}} + +
+ )} + + {source === "qlik" && qlikError && ( +
+ + + {rows.length === 0 + ? qlikError + : `Articles trouvés, mais l'extraction des mesures réseau a échoué : ${qlikError}`} + +
+ )} + + {rows.length === 0 ? ( +
+ Aucun article du réseau ne correspond à « {query} ». +
+ ) : ( + + )} + + {locauxHorsReseau.length > 0 && ( +
+ + {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) + +
+
    + {locauxHorsReseau.slice(0, 30).map((l) => ( +
  • + + {l.libelle1 || l.codein} + + + {" · "}{l.fournisseur}{" · "}{l.codein} + +
  • + ))} +
+
+
+ )} +
+ ); +} + +function ResultTable({ rows, query }: { rows: ProduitRechercheRow[]; query: string }) { + return ( +
+ + + + + + + + + + + + + + + + + + {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 ( + + + + + + + + + + + + + + ); + })} + +
LibelléFournisseurCode centraleMagasinsQté réseauQté / mag.Prix moyenCA / mag.Marge %TendanceNous
+ + {r.libelle || "—"} + + {r.nomenclature && ( +
{r.nomenclature}
+ )} +
+ {r.fournisseur || "—"} + + {r.codeCentrale} + + {fmtQte(r.nbMagasinsReseau)} + + {fmtDec(r.tauxPresence * 100, 0)} % + + + {fmtQte(r.qteReseau)} + + {fmtDec(r.qteParMagasinReseau)} + + {r.prixMoyenReseau != null ? fmtEur2(r.prixMoyenReseau) : "—"} + + {r.caParMagasinReseau > 0 ? fmtEur(r.caParMagasinReseau) : "—"} + + {r.margePctReseau != null ? `${fmtDec(r.margePctReseau)} %` : "—"} + + {trend.hasData ? : —} + + {r.codein ? ( + + stock {fmtQte(r.stockLocal ?? 0)} + + ) : ( + + non référencé + + )} +
+
+ ); +} + +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 ( + + {children} + + ); +} diff --git a/src/app/(dashboard)/produits/search-bar.tsx b/src/app/(dashboard)/produits/search-bar.tsx new file mode 100644 index 0000000..7d2aecd --- /dev/null +++ b/src/app/(dashboard)/produits/search-bar.tsx @@ -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 ( +
+
+
+ + 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 && ( + + )} +
+ +
+ {tooShort && ( +

+ Saisissez au moins 3 caractères. +

+ )} +
+ ); +} diff --git a/src/app/(dashboard)/recherche-reseau/client.tsx b/src/app/(dashboard)/recherche-reseau/client.tsx deleted file mode 100644 index 8f56abc..0000000 --- a/src/app/(dashboard)/recherche-reseau/client.tsx +++ /dev/null @@ -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(null); - const [error, setError] = useState(null); - const [elapsed, setElapsed] = useState(0); - const [filter, setFilter] = useState("all"); - const [sortKey, setSortKey] = useState("caReseau"); - const [sortDir, setSortDir] = useState("desc"); - const [modalRow, setModalRow] = useState(null); - const [exporting, setExporting] = useState(false); - const pollRef = useRef | 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 = {}; - 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 ( -
-
- {/* ── En-tête ── */} -
-

- - Recherche réseau -

-

- Cherche dans le catalogue Qlik du réseau — y compris les produits que nous ne - référençons pas. Ventes mois par mois sur 12 mois glissants. -

-
- - {/* ── Barre de recherche ── */} -
-
-
- - 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)" }} - /> -
- -
-

- - L'interrogation de Qlik prend en général 20 à 40 secondes. Plusieurs codes centraux peuvent être - collés d'un coup (séparés par des espaces ou des virgules). -

-
- - {/* ── Erreurs ── */} - {error && ( -
- - {error} -
- )} - {job?.status === "error" && job.error && ( -
- - {job.error} -
- )} - {job?.status === "success" && job.tooMany && ( -
- - - Recherche trop large : {job.matchCount} produits correspondent - {job.maxProducts ? <> (maximum {job.maxProducts}) : null}. Précisez les termes pour - protéger le moteur Qlik. - -
- )} - {job?.status === "success" && !job.tooMany && results.length === 0 && ( -
- Aucun produit trouvé dans Qlik pour « {job.query} ». -
- )} - - {/* ── Résultats ── */} - {results.length > 0 && ( - <> - {/* Filtres + résumé + export */} -
- {([ - { 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 }) => ( - - ))} -
- {job?.periode && ( - - Période Qlik : {job.periode} - - )} - -
- - {/* Bandeau opportunités : produits forts au réseau que nous n'avons pas */} - {counts.network > 0 && filter !== "mine" && ( -
- - - {counts.network} produit{counts.network > 1 ? "s" : ""} vendu - {counts.network > 1 ? "s" : ""} par le réseau que nous ne référençons pas — trier par - « CA / Mag » ou « Nb mag » fait remonter les meilleures opportunités d'assortiment. - -
- )} - - - - )} -
- - { if (!open) setModalRow(null); }}> - {modalRow !== null && } - -
- ); -} - -// ─── 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 ( -
-
- - - - {COLUMNS.map((c) => { - const active = sortKey === c.key; - const Icon = !active ? ChevronsUpDown : sortDir === "asc" ? ChevronUp : ChevronDown; - return ( - - ); - })} - - - - {rows.map((r) => { - const trend = computeNetworkTrend(r.qteByMonth); - const margePct = toPct(r.margePctReseau); - return ( - onRowClick(r)} - className="cursor-pointer hover:bg-[var(--bg-elevated)] transition-colors" - style={{ borderBottom: "1px solid var(--border)" }} - > - - - - - - - - - - - - ); - })} - -
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 }} - > - - - - {c.label} - {c.sub && <>
{c.sub}} -
-
-
-
- {r.libelle} -
- - {r.chezMoi ? `Chez moi${r.codein ? ` · ${r.codein}` : ""}` : "Réseau uniquement"} - -
{r.codeCentrale} - {r.fournisseur ?? r.nomfou ?? "-"} - {fmtEur(r.caReseau)}{fmtNum(r.qteReseau)}{fmtNum(r.nbMagasinsReseau)}{fmtEur(r.caParMagasinReseau)}= 30 ? "text-emerald-500" : margePct >= 15 ? "text-amber-500" : "text-rose-500")}> - {margePct.toLocaleString("fr-FR", { maximumFractionDigits: 1 })}% - - {r.chezMoi ? fmtNum(r.stockTotal ?? 0) : "-"} -
-
-
- ); -} - -// ─── 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 ( - - - - {row.libelle} - -

- - {row.chezMoi ? "Chez moi" : "Réseau uniquement"} - - Ventes réseau · {labels.length > 0 ? `${labels.length} derniers mois` : "12 derniers mois"} - {pct != null && ( - - · {pct >= 0 ? "+" : ""}{Math.round(pct * 100)}% · {trendLabel(pct)} - - )} -

-
- - {!trend.hasData ? ( -
- Pas de détail mensuel renvoyé par Qlik pour ce produit. -
- ) : ( - - )} - -
- {infos.map((i) => ( -
- {i.label} - {i.value} -
- ))} -
- - {!row.chezMoi && ( -
- - - Ce produit est vendu dans {fmtNum(row.nbMagasinsReseau)} magasin - {row.nbMagasinsReseau > 1 ? "s" : ""} du réseau et génère {fmtEur(row.caParMagasinReseau)} de - CA par magasin, alors que nous ne le référençons pas. - -
- )} -
- ); -} diff --git a/src/app/(dashboard)/recherche-reseau/page.tsx b/src/app/(dashboard)/recherche-reseau/page.tsx deleted file mode 100644 index 648134a..0000000 --- a/src/app/(dashboard)/recherche-reseau/page.tsx +++ /dev/null @@ -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 ; -} diff --git a/src/app/(dashboard)/settings/page.tsx b/src/app/(dashboard)/settings/page.tsx index e13e57f..0a98c5c 100644 --- a/src/app/(dashboard)/settings/page.tsx +++ b/src/app/(dashboard)/settings/page.tsx @@ -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() {
+ {/* Journaux serveur — diagnostic des extractions Qlik */} +
+ +
+ {/* Gestion des Utilisateurs */}
` → 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 { + 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", + }, + }); +} diff --git a/src/app/api/produits/opportunites/route.ts b/src/app/api/produits/opportunites/route.ts new file mode 100644 index 0000000..e01ab12 --- /dev/null +++ b/src/app/api/produits/opportunites/route.ts @@ -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 }); + } +} diff --git a/src/app/api/produits/search/route.ts b/src/app/api/produits/search/route.ts new file mode 100644 index 0000000..dae34e0 --- /dev/null +++ b/src/app/api/produits/search/route.ts @@ -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=[&force=1]` démarre la recherche et rend la + * main tout de suite. `GET /api/produits/search?q=` 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 { + 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 }); + } +} diff --git a/src/app/api/qlik/search/route.ts b/src/app/api/qlik/search/route.ts deleted file mode 100644 index b0afa30..0000000 --- a/src/app/api/qlik/search/route.ts +++ /dev/null @@ -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(); -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 { - 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 { - 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 = codeins.length - ? await pgGetStockForCodeins(codeins) - : new Map(); - - 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 }); -} diff --git a/src/app/api/qlik/sync/route.ts b/src/app/api/qlik/sync/route.ts index 152a927..53cf5a0 100644 --- a/src/app/api/qlik/sync/route.ts +++ b/src/app/api/qlik/sync/route.ts @@ -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(); +/** + * 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 { 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 { + 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): { * au fur et à mesure (compteur fetched, puis upserted, puis finishedAt). */ async function runJob(job: QlikSyncJob): Promise { + // 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 { 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 { 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 { + 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); diff --git a/src/app/api/v1/network/[codeCentrale]/route.ts b/src/app/api/v1/network/[codeCentrale]/route.ts index c0ded53..ae715b1 100644 --- a/src/app/api/v1/network/[codeCentrale]/route.ts +++ b/src/app/api/v1/network/[codeCentrale]/route.ts @@ -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( diff --git a/src/app/globals.css b/src/app/globals.css index f9c6aed..c93cee5 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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; diff --git a/src/components/shared/sidebar.tsx b/src/components/shared/sidebar.tsx index a2ae629..eb6cc96 100644 --- a/src/components/shared/sidebar.tsx +++ b/src/components/shared/sidebar.tsx @@ -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" }, diff --git a/src/components/shared/success-modal.tsx b/src/components/shared/success-modal.tsx index c30ef8b..2a07db2 100644 --- a/src/components/shared/success-modal.tsx +++ b/src/components/shared/success-modal.tsx @@ -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 (
{/* Backdrop */}
{/* Accent bar */} -
+
{/* Close button */} + )}
diff --git a/src/db/schema.ts b/src/db/schema.ts index a1b7ec4..73d133b 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -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(), }); diff --git a/src/features/grid/api/get-product-rows.ts b/src/features/grid/api/get-product-rows.ts index cf7dc78..66c5287 100644 --- a/src/features/grid/api/get-product-rows.ts +++ b/src/features/grid/api/get-product-rows.ts @@ -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 | null | undefined, +): Record | null { + if (!metricsByMonth) return null; + const parMois: Record = {}; + 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): 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; } } diff --git a/src/features/grid/components/floating-summary-bar.tsx b/src/features/grid/components/floating-summary-bar.tsx index 07df5a8..dbf5b7d 100644 --- a/src/features/grid/components/floating-summary-bar.tsx +++ b/src/features/grid/components/floating-summary-bar.tsx @@ -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} />
); diff --git a/src/features/grid/components/heatmap-grid.tsx b/src/features/grid/components/heatmap-grid.tsx index 85c0fe6..09ca1a3 100644 --- a/src/features/grid/components/heatmap-grid.tsx +++ b/src/features/grid/components/heatmap-grid.tsx @@ -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 = { - "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 -; + } + + const handleCopy = (e: React.MouseEvent) => { + e.stopPropagation(); + navigator.clipboard.writeText(texte); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + return ( +
+ {texte} + + {copied ? ( + + ) : ( + + )} + +
+ ); +}); + 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 ( +
+
+ {label} +
+
+ {Icone && } + + {valeur} + +
+ {indice &&
{indice}
} +
+ ); +} + +/** + * 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 ( - + {row.libelle1}

- Ventes réseau · {labels.length > 0 ? `${labels.length} derniers mois` : "12 derniers mois"} - {row.nbMagasinsReseau != null && <> · {row.nbMagasinsReseau} magasins} - {pct != null && ( - <> · - {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"} - - )} + Réseau · 12 mois glissants, mois en cours exclu

+ {!trend.hasData ? (
Pas encore de détail mensuel pour ce produit.
Relancez un Sync Qlik pour le remplir.
) : ( - + <> +
+ + {trend.perStore && ( + t + v, 0) / trend.perStore.length, 1)} en moyenne`} + /> + )} + +
+ + )}
); @@ -443,63 +511,40 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) { accessorKey: "codein", header: "Code interne", size: 90, - cell: ({ getValue }) => { - const value = getValue(); - // 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 ( -
- - {value} - -
- {copied ? ( - - ) : ( - - )} -
-
- ); - }, + cell: ({ getValue }) => ( + ()} + 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(); - return ( - - {val || "-"} - - ); - }, + cell: ({ getValue }) => ( + ()} + 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(); - return ( - - {val || "-"} - - ); - }, + cell: ({ getValue }) => ( + ()} + 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: () =>
Tendance
Réseau · 12 m
, size: 78, cell: ({ row }) => { - const trend = computeNetworkTrend(row.original.qteReseauByMonth); + const trend = computeNetworkTrend(row.original.qteReseauByMonth, row.original.nbMagReseauByMonth); if (!trend.hasData) return
-
; // Cliquable → modal des ventes réseau mois par mois (12 derniers mois). return ( diff --git a/src/features/grid/components/network-charts.tsx b/src/features/grid/components/network-charts.tsx new file mode 100644 index 0000000..4ae2fce --- /dev/null +++ b/src/features/grid/components/network-charts.tsx @@ -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
-
; + 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 ( +
= 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"}> +
+ + {n > 1 && } + {n === 1 && } + + +
+ {pct != null && ( + + {pct >= 0 ? "+" : ""}{Math.round(pct * 100)}% + + )} + {pct == null && nouveau && ( + Nouveau + )} +
+ ); +} + +/** + * 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(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 ( +
+ {/* Bascule : la même surface sert au dessin ou aux chiffres. */} +
+
+ {(["graphique", "tableau"] as const).map((v) => ( + + ))} +
+
+ {vue === "graphique" ? ( +
+ 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 ( + + {/* Bandeau de facette : repère coloré + titre + maximum, en encre neutre */} + + + {f.titre} + + + max {fmt(f.max, f.decimales ?? 0)}{f.unite ?? ""} + + + {/* Ligne de base : filet plein, une nuance au-dessus du fond */} + + {f.aire && n > 1 && ( + + )} + {n > 1 && ( + + )} + + {/* Points repères : extrême et dernier mois, cerclés de la couleur du fond */} + {aEtiqueter.map((i) => ( + + ))} + {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 ( + + {fmt(f.valeurs[i], f.decimales ?? 0)} + + ); + })} + + {/* Point mis en avant au survol */} + {survol != null && ( + + )} + + ); + })} + + {/* Réticule : un seul trait vertical relie les trois facettes au même mois */} + {survol != null && ( + + )} + + {/* Axe des mois, commun aux facettes */} + {labels.map((lab, i) => ( + + {fmtMonthShort(lab)} + + ))} + + {/* 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 ( + + + + {fmtMonthShort(labels[survol])} + + {disposees.map((f, k) => ( + + + + {f.titre} + + + {fmt(f.valeurs[survol], f.decimales ?? 0)} + + + ))} + + ); + })()} + + {/* Zones de survol : larges cibles, une par mois */} + {labels.map((lab, i) => ( + setSurvol(i)} + /> + ))} + +
+ ) : ( + /* 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. */ +
+ + + + + {disposees.map((f) => ( + + ))} + + + + {labels.map((lab, i) => ( + + + {disposees.map((f) => ( + + ))} + + ))} + +
Mois + {f.titre} +
+ {fmtMonthShort(lab)} + + {fmt(f.valeurs[i], f.decimales ?? 0)} +
+
+ )} +
+ ); +} + +/** + * 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 ( +
+
+ {series.map((s) => ( + + + {s.name} + + ))} +
+ + + {series.map((s) => { + const pts = s.values.map((v, i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`).join(" "); + return ( + + {n > 1 && ( + + )} + {s.values.map((v, i) => ( + + ))} + + ); + })} + {labels.map((lab, i) => ( + + {fmtMonthShort(lab)} + + ))} + +
+ ); +} diff --git a/src/features/grid/components/sync-qlik-button.tsx b/src/features/grid/components/sync-qlik-button.tsx index 3b1172a..59aef41 100644 --- a/src/features/grid/components/sync-qlik-button.tsx +++ b/src/features/grid/components/sync-qlik-button.tsx @@ -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(""); const router = useRouter(); const pathname = usePathname(); const searchParams = useSearchParams(); - const pollTimerRef = useRef | null>(null); - /** true tant qu'on doit suivre le job courant (false après succès/erreur traité). */ - const trackingRef = useRef(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 => { - 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 (
@@ -264,13 +56,13 @@ export function SyncQlikButton({ codeFournisseur, lastUpdate }: SyncQlikButtonPr ? message : status === "running" ? (message || `Extraction Qlik…`) - : `MAJ Qlik · ${formatDate(lastUpdate)}`} + : `MAJ Qlik · ${formatLastUpdate(lastUpdate)}`} +
+ + {etat === "erreur" && ( +
+ + {erreur} +
+ )} + + {etat !== "erreur" && logs.length === 0 && ( +

+ Aucun journal pour l'instant — lancez une synchronisation Qlik depuis la Grille. +

+ )} + + {logs.length > 0 && ( +
    + {logs.map((log) => ( +
  • + +
    +
    + + {log.id} + + {log.enCours && ( + + en cours + + )} +
    + + {formatDate(log.modifieLe)} · {formatTaille(log.octets)} + +
    + + + Télécharger + +
  • + ))} +
+ )} +
+ ); +} diff --git a/src/features/snapshots/components/snapshot-list.tsx b/src/features/snapshots/components/snapshot-list.tsx index 8281ee3..5f072bd 100644 --- a/src/features/snapshots/components/snapshot-list.tsx +++ b/src/features/snapshots/components/snapshot-list.tsx @@ -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(null); const [isDeleting, setIsDeleting] = useState(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} /> [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, }; } diff --git a/src/lib/log-capture.ts b/src/lib/log-capture.ts new file mode 100644 index 0000000..364245e --- /dev/null +++ b/src/lib/log-capture.ts @@ -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(); + +/** 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 { + 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 { + 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 { + 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 { + 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> { + 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)); +} diff --git a/src/lib/network-trend.ts b/src/lib/network-trend.ts deleted file mode 100644 index ea54136..0000000 --- a/src/lib/network-trend.ts +++ /dev/null @@ -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 | 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"; -} diff --git a/src/lib/pg-ff-client.ts b/src/lib/pg-ff-client.ts index 5760449..617a4f6 100644 --- a/src/lib/pg-ff-client.ts +++ b/src/lib/pg-ff-client.ts @@ -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 { + 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> { - // 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 { + 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> { + const out = new Map(); + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 []; + } +} diff --git a/src/lib/qlik-client.ts b/src/lib/qlik-client.ts index 282aab6..aef5300 100644 --- a/src/lib/qlik-client.ts +++ b/src/lib/qlik-client.ts @@ -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; + /** + * 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; + /** + * 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; } /** diff --git a/src/lib/qlik-date-range.ts b/src/lib/qlik-date-range.ts index e1eff67..1de772c 100644 --- a/src/lib/qlik-date-range.ts +++ b/src/lib/qlik-date-range.ts @@ -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); diff --git a/src/lib/qlik-network-cache.ts b/src/lib/qlik-network-cache.ts index 3127d98..2c3109e 100644 --- a/src/lib/qlik-network-cache.ts +++ b/src/lib/qlik-network-cache.ts @@ -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 | 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 | 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 | null) ?? null, + metricsByMonth: (r.metricsByMonth as Record | 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 | null = null; @@ -34,16 +34,27 @@ async function getBrowser(): Promise { interface InPageResult { ok: boolean; rows?: Array>; - /** Quantité réseau par code central et par mois : { code: { "YYYY-MM": qté } }. */ - monthly?: Record>; + /** + * Métriques réseau par code central et par mois : { code: { "YYYY-MM": {…} } }. + * + * Le cube mensuel renvoie déjà les 6 mesures par (Article Code, Mois) ; on les + * conserve toutes. Seule `qte` est systématiquement présente : les mois N-1 + * dérivés de « Quantité COMP » n'ont que la quantité (COMP ne porte pas les + * autres mesures), et la passe complémentaire N-1 les complète quand le champ + * `Année` existe. + */ + monthly?: Record>; size?: number; error?: string; - diag?: Record; // Compteurs agrégés pour le résumé timing final côté Node (cf. qlik-playwright). months?: number; batches?: number; code15Retries?: number; fallbackCount?: number; + /** true si le résultat vient d'un point de contrôle (extraction interrompue). */ + partiel?: boolean; + /** Diagnostics clés du chemin par expressions, répétés en fin de sync. */ + diagExpr?: string[]; } function envNumber(name: string, defaultValue: number, min: number, max: number): number { @@ -79,6 +90,19 @@ export async function fetchNetworkMetricsPlaywright( codeCentraux?: string[], cfg: QlikConfig = getQlikConfig(), dateFilter: QlikDateFilter | null = buildGridNetworkQlikDateFilter(), + /** + * Code fournisseur FF (ex. `J009`), tel qu'il figure dans la base SQL / l'API. + * + * Quand il est fourni, on sélectionne LE fournisseur côté Qlik au lieu des + * dizaines de milliers de codes articles : `SelectValues` sur « Article Code » + * coûte 8 à 100 s par appel quel que soit le nombre de valeurs (le moteur + * balaie un symbole de plus d'un million d'entrées), et il faut le refaire à + * chaque passe annuelle. + * + * La bascule n'est retenue que si la sélection fournisseur **couvre** les + * codes demandés — sinon on revient à la sélection par codes. + */ + fournisseur?: string, ): Promise> { if (!cfg.appNetwork) throw new Error("[qlik-pw] QLIK_APP_NETWORK manquant"); if (!cfg.user || !cfg.password) throw new Error("[qlik-pw] identifiants Qlik manquants"); @@ -111,8 +135,43 @@ export async function fetchNetworkMetricsPlaywright( // Mesure "Quantité COMP" (année N-1) pour compléter les mois de l'année précédente // → 12 mois glissants (Quantité N ne couvre que l'année en cours). const qlikMeasQteCompId = (process.env.QLIK_MEAS_QTE_COMP_ID ?? "96862880-76cd-4957-bf25-b96901f2ac5f").trim(); + // Agrégations directes des champs de faits. Les master measures « N » ignorent + // la sélection Date (mesures « cumul année en cours ») : elles ne peuvent pas + // produire une fenêtre 12 mois glissants. Surchargeables si le modèle change. + // Agrégation directe des faits : chemin de SECOURS, utilisé quand aucune valeur + // de `Période` ne couvre les 12 mois. Activé par défaut, mais second choix : + // `Sum(quantite)` ignore `Type_Cal` et additionne les lignes de la période + // courante ET de la période de comparaison, alors que les master measures + // portent le bon filtre dès que `Période` est bien calé (cf. `choisirPeriode`). + const qlikUseExpr = !["0", "false", "no", "off"].includes((process.env.QLIK_USE_EXPR ?? "").trim().toLowerCase()); + const qlikExprQte = (process.env.QLIK_EXPR_QTE ?? "Sum(quantite)").trim(); + const qlikExprCa = (process.env.QLIK_EXPR_CA ?? "Sum(ca_ht)").trim(); + const qlikExprNbMag = (process.env.QLIK_EXPR_NBMAG ?? "Count(DISTINCT [Magasin Code])").trim(); + const qlikExprMarge = (process.env.QLIK_EXPR_MARGE ?? "Sum(marge)").trim(); + // Champs date candidats, essayés dans l'ordre. Un champ n'est retenu que s'il + // FILTRE réellement les faits (total non nul et strictement inférieur au total + // sans filtre) : personne n'avait pu le vérifier tant que seules des mesures + // insensibles à la sélection étaient utilisées. + // Champs Qlik susceptibles de porter le CODE fournisseur (et non son libellé) : + // c'est `code_fournisseur` qui remontait des valeurs de la forme « F005 », + // « A0491 » — même format que les codes de la base FF. + const qlikSupplierFields = [ + (process.env.QLIK_FIELD_CODE_FOURNISSEUR ?? "").trim(), + "code_fournisseur", "Fournisseur", "fournisseur_code_centrale", + ].filter((c, i, arr) => c && arr.indexOf(c) === i); + /** Part des codes demandés que la sélection fournisseur doit couvrir pour être retenue. */ + const qlikSupplierMinCoverage = Math.min(1, Math.max(0.5, Number(process.env.QLIK_SUPPLIER_MIN_COVERAGE ?? "0.99") || 0.99)); + + const qlikDateFields = [ + (process.env.QLIK_DATE_FIELD ?? "").trim(), + "Date", "Date calendrier", "Date_Key", + ].filter((c, i, arr) => c && arr.indexOf(c) === i); const yearN = new Date().getFullYear(); - console.log(`[qlik-pw] tuning settle=${qlikSettleMs}ms code15Attempts=${qlikCode15MaxAttempts} batchFallbacks=${qlikArticleBatchFallbacks.join(">")} selectAllCodes=${qlikSelectAllCodes} monthDim=${qlikMonthDim}`); + console.log(`[qlik-pw] tuning settle=${qlikSettleMs}ms code15Attempts=${qlikCode15MaxAttempts} batchFallbacks=${qlikArticleBatchFallbacks.join(">")} selectAllCodes=${qlikSelectAllCodes} monthDim=${qlikMonthDim} useExpr=${qlikUseExpr}`); + if (qlikUseExpr) { + console.log(`[qlik-pw] expressions mensuelles: qte="${qlikExprQte}" ca="${qlikExprCa}" nbMag="${qlikExprNbMag}" marge="${qlikExprMarge}"`); + console.log(`[qlik-pw] champs date candidats: ${JSON.stringify(qlikDateFields)}`); + } const browser = await getBrowser(); console.log(`[qlik-pw] chromium lancé`); @@ -141,6 +200,7 @@ export async function fetchNetworkMetricsPlaywright( console.log(`[qlik-pw] page chargée, attente du qlik-csrf-token…`); await Promise.race([tokenReady, page.waitForTimeout(15000)]); if (!csrfToken) throw new Error("[qlik-pw] qlik-csrf-token introuvable (client Qlik non chargé ?)"); + const jeton: string = csrfToken; console.log(`[qlik-pw] csrf-token capturé, ouverture du websocket Engine in-page…`); // Pré-calcul des mois côté Node (pour pouvoir logger X/Y et propager au script in-page). @@ -150,13 +210,31 @@ export async function fetchNetworkMetricsPlaywright( dateDebut: m.dateDebut, dateFin: m.dateFin, serials: m.dailySerials, // ~30 valeurs → SelectValues en une fois + // Même fenêtre au format entier AAAAMMJJ : certains modèles Qlik + // n'exposent la date que par une clé numérique (`Date_Key`), pas par + // un champ date sérialisé. + ymd: m.dailySerials.map((serial) => { + const d = new Date(Date.UTC(1899, 11, 30) + serial * 86_400_000); + return d.getUTCFullYear() * 10000 + (d.getUTCMonth() + 1) * 100 + d.getUTCDate(); + }), })); - const result = (await page.evaluate( - ({ app, dim, mca, mqte, mnb, mcamag, mmarge, codes, token, monthlyPayload, noDateMode, qlikSettleMs, qlikCode15MaxAttempts, qlikArticleBatchFallbacks, selectAllCodes, monthDim, moisDimId, mqteComp, yearN }: { + // Point de contrôle : le script in-page pousse ses résultats intermédiaires + // à chaque passe. Si la session Qlik meurt en cours de route (« Socket + // closed », « Execution context was destroyed »), on repart de là au lieu + // de tout perdre — les codes déjà traités ont des données complètes, la + // dimension Mois livrant tous leurs mois d'un coup. + let checkpoint: { rows?: Array>; monthly?: InPageResult["monthly"] } | null = null; + await page.exposeFunction("cfCheckpoint", (payload: { rows?: Array>; monthly?: InPageResult["monthly"] }) => { + checkpoint = payload; + return true; + }); + + const evaluer = () => page.evaluate( + ({ app, dim, mca, mqte, mnb, mcamag, mmarge, codes, token, monthlyPayload, noDateMode, qlikSettleMs, qlikCode15MaxAttempts, qlikArticleBatchFallbacks, selectAllCodes, monthDim, moisDimId, mqteComp, yearN, useExpr, exprQte, exprCa, exprNbMag, exprMarge, champsDateCandidats, fournisseurCode, champsFournisseurCandidats, couvertureFournisseurMin }: { app: string; dim: string; mca: string; mqte: string; mnb: string; mcamag: string; mmarge: string; codes: string[]; token: string; - monthlyPayload: Array<{ label: string; dateDebut: string; dateFin: string; serials: number[] }>; + monthlyPayload: Array<{ label: string; dateDebut: string; dateFin: string; serials: number[]; ymd: number[] }>; noDateMode: boolean; qlikSettleMs: number; qlikCode15MaxAttempts: number; @@ -166,6 +244,15 @@ export async function fetchNetworkMetricsPlaywright( moisDimId: string; mqteComp: string; yearN: number; + useExpr: boolean; + exprQte: string; + exprCa: string; + exprNbMag: string; + exprMarge: string; + champsDateCandidats: string[]; + fournisseurCode: string; + champsFournisseurCandidats: string[]; + couvertureFournisseurMin: number; }) => new Promise((resolve) => { const loc = (window as unknown as { location: Location }).location; @@ -177,7 +264,6 @@ export async function fetchNetworkMetricsPlaywright( const i = ++id; pend.set(i, { res, rej }); ws.send(JSON.stringify({ jsonrpc: "2.0", id: i, handle, method, params })); }); - const fail = (error: string) => resolve({ ok: false, error }); const ready = new Promise((res, rej) => { ws.onmessage = (ev: MessageEvent) => { const m = JSON.parse(ev.data as string); @@ -235,6 +321,18 @@ export async function fetchNetworkMetricsPlaywright( throw lastErr; }; + // Accumulateurs déclarés au niveau de l'IIFE (et non dans le `try`) : + // le `catch` doit pouvoir remonter ce qui a déjà été extrait. + const out: Array> = []; + // Quantité réseau par code central et par mois (label "YYYY-MM"). + // Accumulée en parallèle du total, sans toucher l'agrégation existante. + const monthlyByCode: Record> = {}; + // Diagnostics clés, RÉPÉTÉS en fin de sync : le log d'une sync fait + // des milliers de lignes de timings, et ce qui explique un échec se + // trouve tout au début — là où personne ne va le chercher. + const diagnostics: string[] = []; + const diag = (msg: string) => { diagnostics.push(msg); console.log("[qlik-pw][expr] " + msg); }; + (async () => { try { await ready; @@ -247,12 +345,27 @@ export async function fetchNetworkMetricsPlaywright( let dfh = -1; let fh = -1; let yfh = -1; + /** + * Handle du CHAMP « Période ». + * + * ⚠️ `Clear` et `SelectValues` sont des méthodes de **champ**. Les + * appeler sur un objet de session renvoie + * `{"code":-32601,"parameter":"Clear","message":"Method not found"}`, + * ce qui a fait avorter en production la cartographie des périodes ET + * le chemin d'extraction par date brute. Le list object ne sert donc + * plus qu'à ÉNUMÉRER les valeurs ; les sélections passent par ici. + */ + let pfh = -1; const df = await rpc("GetField", { qFieldName: "Date" }, doc); dfh = (df.qReturn as { qHandle: number }).qHandle; try { const yf = await rpc("GetField", { qFieldName: "Année" }, doc); yfh = (yf.qReturn as { qHandle: number }).qHandle; } catch { yfh = -1; } + try { + const pf = await rpc("GetField", { qFieldName: "Période" }, doc); + pfh = (pf.qReturn as { qHandle: number }).qHandle; + } catch { pfh = -1; } if (codes.length) { const gf = await rpc("GetField", { qFieldName: "Article Code" }, doc); fh = (gf.qReturn as { qHandle: number }).qHandle; @@ -260,9 +373,9 @@ export async function fetchNetworkMetricsPlaywright( // Résoudre les master measures par TITRE (le qLibraryId fiable = qInfo.qId, ≠ GUID QRS). const norm = (s: string) => s.replace(/\s+/g, " ").trim().toLowerCase(); - const ml = await rpc("CreateSessionObject", { qProp: { qInfo: { qType: "MeasureList" }, qMeasureListDef: { qType: "measure", qData: { title: "/qMetaDef/title" } } } }, doc); + const ml = await rpc("CreateSessionObject", { qProp: { qInfo: { qType: "MeasureList" }, qMeasureListDef: { qType: "measure", qData: { title: "/qMetaDef/title", expr: "/qMeasure/qDef" } } } }, doc); const mll = await rpc("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 items = (((mll.qLayout as { qMeasureList?: { qItems?: Array<{ qInfo: { qId: string }; qMeta?: { title?: string }; qData?: { title?: string; expr?: string } }> } }).qMeasureList?.qItems) ?? []); const byTitle = new Map(); for (const it of items) { const t = it.qMeta?.title ?? it.qData?.title ?? ""; if (t) byTitle.set(norm(t), it.qInfo.qId); } const pickMeasure = (title: string, fallback: string) => byTitle.get(norm(title)) ?? fallback; @@ -282,7 +395,16 @@ export async function fetchNetworkMetricsPlaywright( // DUMP diagnostic : inventaire des mesures + dimensions (titre + id) pour // identifier une mesure "Quantité" NON annuelle et/ou une dimension "Mois/Période" // permettant un vrai découpage mensuel du réseau. + // L'EXPRESSION est indispensable au diagnostic : c'est elle qui dit + // si la mesure est bornée à une année (et comment), donc pourquoi des + // mois de la fenêtre glissante peuvent ressortir vides. console.log("[qlik-pw][dump] MESURES (" + items.length + "): " + JSON.stringify(items.map((it) => ({ t: it.qMeta?.title ?? it.qData?.title ?? "", id: it.qInfo.qId })))); + for (const it of items) { + const titre = it.qMeta?.title ?? it.qData?.title ?? ""; + if (/^(CA N|Quantité N|Magasin Ventes Nb N|CA par Magasin N|Marge % N|Quantité COMP)$/i.test(titre.trim())) { + console.log("[qlik-pw][dump] expression « " + titre + " » = " + (it.qData?.expr ?? "?")); + } + } try { const dl = await rpc("CreateSessionObject", { qProp: { qInfo: { qType: "DimensionList" }, qDimensionListDef: { qType: "dimension", qData: { title: "/qMetaDef/title" } } } }, doc); const dll = await rpc("GetLayout", {}, (dl.qReturn as { qHandle: number }).qHandle); @@ -293,17 +415,22 @@ export async function fetchNetworkMetricsPlaywright( // (un objet par lot) puis DestroySessionObject — corrige "Request aborted" (code 15) // causé par la réutilisation du même objet sur des centaines de recalculs. - const out: Array> = []; - // Quantité réseau par code central et par mois (label "YYYY-MM"). - // Accumulée en parallèle du total, sans toucher l'agrégation existante. - const monthlyByCode: Record> = {}; + // (déclaré au niveau de l'IIFE — voir plus haut) + // (monthlyByCode est déclaré au niveau de l'IIFE — voir plus haut) const accMonthly = (batchRows: Array>, monthLabel: string) => { for (const r of batchRows) { const c = String(r[0] ?? "").trim(); if (!c || c === "-") continue; const q = Number(r[2]) || 0; (monthlyByCode[c] ??= {}); - monthlyByCode[c][monthLabel] = (monthlyByCode[c][monthLabel] ?? 0) + q; + const cur = monthlyByCode[c][monthLabel]; + monthlyByCode[c][monthLabel] = { + qte: (cur?.qte ?? 0) + q, + ca: (cur?.ca ?? 0) + (Number(r[1]) || 0), + nbMag: Math.max(cur?.nbMag ?? 0, Number(r[3]) || 0), + caMag: Number(r[4]) || 0, + margePct: Number(r[5]) || 0, + }; } }; // Compteurs agrégés pour le résumé timing final (loggué côté Node après la sync). @@ -426,6 +553,1208 @@ export async function fetchNetworkMetricsPlaywright( } }; + // ─── Cube mensuel par EXPRESSIONS (respecte la sélection Date) ───── + // + // Constat de production : les master measures « CA N » / « Quantité N » + // **ignorent la sélection Date**. Vérifié arithmétiquement : avec août + // 2025 seul sélectionné, le cube renvoyait 164 unités / 2 220,75 € — + // soit exactement la somme de janvier à juillet 2026. Ce sont des + // mesures « cumul année en cours », pas des mesures de période. + // + // Conséquences : les totaux réseau étaient un cumul année en cours + // (mois courant partiel inclus) et non 12 mois glissants, les mois de + // l'année précédente non couverts par « Quantité COMP » restaient + // vides, et la passe de rattrapage y recopiait le total de période. + // + // Aucune sélection ne peut corriger cela : on agrège donc directement + // les champs de faits, qui eux respectent les sélections. + const PAGEX = 1200; // 7 colonnes × 1200 = 8400 cellules < 10000 + const fetchMonthCubeExpr = async ( + eCa: string, + eQte: string, + eNbMag: string, + eMarge: string, + onRow: (code: string, mois: string, ca: number, qte: number, nbMag: number, marge: number, qteMaster: number) => void, + timings?: Record, + onCode15Retry?: () => void, + /** + * Inclure la master « Quantité N » (colonne de calibrage) ? + * + * ⚠️ Elle ne coûte rien quand elle vit, mais **annule tout le cube** + * quand elle est morte : mesuré en production, 4 expressions valides + * à 6 mois chacune et le cube complet à **0 ligne** après 25 s de + * calcul. L'appelant ne l'ajoute donc qu'après l'avoir sondée. + */ + avecMaster = true, + ): Promise => { + const largeur = avecMaster ? 7 : 6; + const { value: obj } = await timed("createCubeExpr", "CreateSessionObject", () => rpcWithRetry("createCubeExpr", "CreateSessionObject", { qProp: { qInfo: { qType: "cf-net-mois-expr" }, qHyperCubeDef: { + qDimensions: [{ qLibraryId: dim }, { qLibraryId: moisDimId }], + qMeasures: [ + { qDef: { qDef: eCa } }, + { qDef: { qDef: eQte } }, + { qDef: { qDef: eNbMag } }, + { qDef: { qDef: eMarge } }, + // Master « Quantité N », uniquement pour le calibrage : sur les + // mois de l'année en cours les deux doivent coïncider. + ...(avecMaster ? [{ qLibraryId: rQte }] : []), + ], + qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: largeur, qHeight: PAGEX }], + } } }, doc, onCode15Retry), timings); + const qReturn = obj.qReturn as { qHandle: number; qGenericId?: string; qId?: string }; + const cube = { handle: qReturn.qHandle, id: String(qReturn.qGenericId ?? qReturn.qId ?? "") }; + const oh = cube.handle; + const process = (matrix: Array>): number => { + for (const r of matrix) { + onRow( + String(r[0]?.qText ?? ""), String(r[1]?.qText ?? ""), + Number(r[2]?.qNum) || 0, Number(r[3]?.qNum) || 0, + Number(r[4]?.qNum) || 0, Number(r[5]?.qNum) || 0, + avecMaster ? Number(r[6]?.qNum) || 0 : 0, + ); + } + return matrix.length; + }; + try { + const { value: layout } = await timed("layout", "GetLayout", () => rpcWithRetry("layout", "GetLayout", {}, oh, onCode15Retry), timings); + const hc = (layout.qLayout as { qHyperCube: { qSize: { qcy: number }; qDataPages?: Array<{ qArea?: { qTop?: number }; qMatrix?: Array> }> } }).qHyperCube; + const size = hc.qSize.qcy; + let got = 0, top = 0; + const firstPage = hc.qDataPages?.find((pg) => (pg.qArea?.qTop ?? 0) === 0); + if (firstPage?.qMatrix?.length) { got += process(firstPage.qMatrix); top = PAGEX; } + for (; top < size; top += PAGEX) { + const { value: d } = await timed("getCubeData", "GetHyperCubeData", () => rpcWithRetry("getCubeData", "GetHyperCubeData", { qPath: "/qHyperCubeDef", qPages: [{ qTop: top, qLeft: 0, qWidth: largeur, qHeight: PAGEX }] }, oh, onCode15Retry), timings); + const matrix = (d.qDataPages as Array<{ qMatrix?: Array> }>)?.[0]?.qMatrix ?? []; + if (!matrix.length) break; + got += process(matrix); + if (matrix.length < PAGEX) break; + } + return got; + } finally { + await destroyCube(cube, timings); + } + }; + + /** + * Passe unique par expressions : sélectionne la fenêtre 12 mois et tous + * les codes, puis lit un seul cube [Article Code, Mois]. + * + * Renvoie false si le résultat est inexploitable (expression invalide, + * champ absent) — l'appelant repart alors sur les master measures. + */ + /** + * Total de contrôle : un cube 1 cellule, sans dimension, sur + * l'expression quantité. Sert à savoir si une sélection de dates + * FILTRE réellement les faits — ce que les master measures, qui + * l'ignorent, ne permettaient pas de vérifier. + */ + const totalDeControle = async (): Promise => { + const o = await rpc("CreateSessionObject", { qProp: { + qInfo: { qType: "cf-ctrl" }, + qHyperCubeDef: { + qDimensions: [], + qMeasures: [{ qDef: { qDef: exprQte } }], + qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 1, qHeight: 1 }], + }, + } }, doc); + const ret = o.qReturn as { qHandle: number; qGenericId?: string; qId?: string }; + try { + const lay = await rpc("GetLayout", {}, ret.qHandle); + const cell = (lay.qLayout as { qHyperCube?: { qDataPages?: Array<{ qMatrix?: Array> }> } }) + .qHyperCube?.qDataPages?.[0]?.qMatrix?.[0]?.[0]; + return Number(cell?.qNum) || 0; + } finally { + const id = String(ret.qGenericId ?? ret.qId ?? ""); + if (id) { try { await rpc("DestroySessionObject", { qId: id }, doc); } catch { /* noop */ } } + } + }; + + /** + * Sélectionne la fenêtre sur un champ date candidat. + * + * Renvoie le handle pour que l'appelant puisse impérativement + * effacer une sélection rejetée avant d'essayer le candidat + * suivant. Sans ce Clear, un premier champ qui ne matche aucune + * date maintient l'ensemble courant à zéro et condamne tous les + * essais suivants à renvoyer zéro eux aussi. + */ + const selectionnerFenetreSur = async (champ: string, valeurs: number[]): Promise => { + let h: number; + try { + const gf = await rpc("GetField", { qFieldName: champ }, doc); + h = (gf.qReturn as { qHandle: number }).qHandle; + if (typeof h !== "number" || h < 0) return null; + } catch { return null; } + await rpc("Clear", {}, h); + await sleep(qlikSettleMs); + for (let i = 0; i < valeurs.length; i += 500) { + await rpc("SelectValues", { + qFieldValues: valeurs.slice(i, i + 500).map((v) => ({ qNum: v, qText: String(v) })), + qToggleMode: i > 0, + qSoftLock: true, + }, h); + } + await sleep(qlikSettleMs); + return h; + }; + + /** + * Sélectionne les 12 mois via la dimension maître « Mois ». + * + * Dans l'app Magasins Vision Consolidée, les champs Date exposés + * existent mais sont dissociés des faits de vente : leur sélection + * laisse Sum(quantite) à 100 %. La dimension maître utilisée par le + * cube, elle, porte les vraies valeurs associées. Un list object + * permet de sélectionner ses qElemNumber sans connaître le nom du + * champ sous-jacent ni le format dual exact. + */ + const selectionnerFenetreViaDimensionMois = async ( + moisAttendus: Set, + ): Promise<{ total: number; trouves: string[] } | null> => { + let objet: { handle: number; id: string } | null = null; + try { + const cree = await rpcWithRetry("createMonthList", "CreateSessionObject", { + qProp: { + qInfo: { qType: "cf-month-list" }, + qListObjectDef: { + qLibraryId: moisDimId, + qShowAlternatives: true, + qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 1, qHeight: 100 }], + }, + }, + }, doc); + const ret = cree.qReturn as { qHandle: number; qGenericId?: string; qId?: string }; + objet = { + handle: ret.qHandle, + id: String(ret.qGenericId ?? ret.qId ?? ""), + }; + + const layout = await rpcWithRetry("layoutMonthList", "GetLayout", {}, objet.handle); + const lo = (layout.qLayout as { + qListObject?: { + qSize?: { qcy?: number }; + qDataPages?: Array<{ + qArea?: { qTop?: number }; + qMatrix?: Array>; + }>; + }; + }).qListObject; + const size = Number(lo?.qSize?.qcy) || 0; + const cellules: Array<{ qText?: string; qElemNumber?: number }> = []; + for (const page of lo?.qDataPages ?? []) { + for (const row of page.qMatrix ?? []) if (row[0]) cellules.push(row[0]); + } + for (let top = cellules.length; top < size; top += 500) { + const data = await rpcWithRetry("monthListData", "GetListObjectData", { + qPath: "/qListObjectDef", + qPages: [{ qTop: top, qLeft: 0, qWidth: 1, qHeight: Math.min(500, size - top) }], + }, objet.handle); + const matrix = (data.qDataPages as Array<{ + qMatrix?: Array>; + }>)?.[0]?.qMatrix ?? []; + for (const row of matrix) if (row[0]) cellules.push(row[0]); + if (matrix.length === 0) break; + } + + const parMois = new Map(); + for (const cellule of cellules) { + const mois = normMois(String(cellule.qText ?? "").trim()); + const elem = Number(cellule.qElemNumber); + if (moisAttendus.has(mois) && Number.isInteger(elem) && elem >= 0) { + parMois.set(mois, elem); + } + } + const trouves = [...parMois.keys()].sort(); + console.log( + "[qlik-pw][expr] dimension Mois : " + trouves.length + "/" + moisAttendus.size + + " valeurs trouvées " + JSON.stringify(trouves), + ); + if (trouves.length !== moisAttendus.size) return null; + + const selection = await rpcWithRetry("selectMonthList", "SelectListObjectValues", { + qPath: "/qListObjectDef", + qValues: [...parMois.values()], + qToggleMode: false, + qSoftLock: true, + }, objet.handle); + console.log("[qlik-pw][expr] sélection dimension Mois → " + JSON.stringify(selection.qReturn)); + await sleep(qlikSettleMs); + const total = await totalDeControle(); + return { total, trouves }; + } catch (e) { + console.log("[qlik-pw][expr] sélection dimension Mois impossible : " + String((e as Error)?.message || e)); + return null; + } finally { + if (objet?.id) { + try { + await rpc("DestroySessionObject", { qId: objet.id }, doc); + } catch { /* objet limité à la session */ } + } + } + }; + + /** + * Cale le champ `Période` du modèle sur la fenêtre 12 mois glissants. + * + * L'app n'est pas un modèle « faits + calendrier » classique : TOUTES + * ses mesures sont bornées par un type de calendrier — + * + * « Quantité N » = Sum({} quantite) + * « Quantité COMP » = Sum({} quantite) + * + * `Type_Cal` marque les lignes de la période analysée (`N`) et celles + * de la période de comparaison (`COMP`), et c'est le champ `Période` + * (4 valeurs, une sélectionnée) qui décide de quelle période il s'agit. + * + * Cela explique tout ce qu'on observait : + * - sélectionner `Date` ne change RIEN (le périmètre vient du pont de + * périodes, pas du champ date) — mesuré : 100 % du total avant comme + * après ; + * - « Quantité N » ne renvoie que les mois de la période courante + * (janvier→juillet avec « année en cours »), d'où août→décembre + * vides ; + * - « Quantité COMP » ne couvre que les mois ayant un comparable dans + * cette période, d'où janvier→juillet de l'année précédente ; + * - `Sum(quantite)` brut mélange les lignes N et COMP : il double + * compte, et ne peut pas se substituer aux mesures. + * + * La solution n'est donc pas de contourner le modèle mais de le + * piloter : on essaie chaque valeur de `Période`, on mesure combien de + * mois de la fenêtre elle rend réellement disponibles, et on garde la + * meilleure. Aucun libellé n'est deviné — c'est la couverture mesurée + * qui décide. + */ + /** + * Vrai quand le périmètre articles est posé par une sélection + * fournisseur : dans ce cas, plus aucun `SelectValues` sur + * « Article Code » n'est émis (ni par passe annuelle, ni par + * l'agrégation directe) — c'est tout l'intérêt de la bascule. + */ + let parFournisseur = false; + + /** + * Sélectionne LE fournisseur plutôt que ses dizaines de milliers de + * codes articles. + * + * `SelectValues` sur « Article Code » coûte 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 — et il faut le refaire à + * chaque passe annuelle. Une valeur de fournisseur produit le même + * périmètre en un appel trivial. + * + * La bascule n'est retenue QUE si la sélection couvre les codes + * demandés : un article rattaché à plusieurs fournisseurs, ou un + * référencement Qlik différent du référencement FF, doit faire + * revenir à la sélection par codes plutôt que perdre des articles. + */ + const selectionnerParFournisseur = async (): Promise => { + if (!fournisseurCode || codes.length === 0) return false; + const attendus = new Set(codes.map((c) => c.trim())); + /** + * Au-delà de ce nombre de codes visibles, la sélection fournisseur + * ne restreint rien (elle laisse tout le catalogue) : inutile de + * finir la lecture, la réponse est déjà connue. + */ + const plafond = attendus.size * 3; + + /** Codes articles visibles sous la sélection courante. */ + const lireCodesVisibles = async (): Promise> => { + const vus = new Set(); + const o = await rpc("CreateSessionObject", { qProp: { + qInfo: { qType: "cf-codes-fou" }, + qHyperCubeDef: { + qDimensions: [{ qLibraryId: dim, qNullSuppression: true }], + qMeasures: [], + qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 1, qHeight: 5000 }], + }, + } }, doc); + const ret = o.qReturn as { qHandle: number; qGenericId?: string; qId?: string }; + try { + const lay = await rpc("GetLayout", {}, ret.qHandle); + const hc = (lay.qLayout as { qHyperCube?: { qSize?: { qcy?: number }; qDataPages?: Array<{ qMatrix?: Array> }> } }).qHyperCube; + const total = Number(hc?.qSize?.qcy) || 0; + for (const r of hc?.qDataPages?.[0]?.qMatrix ?? []) { + const c = String(r[0]?.qText ?? "").trim(); + if (c && c !== "-") vus.add(c); + } + for (let top = vus.size ? 5000 : 0; top < total; top += 5000) { + const d = await rpc("GetHyperCubeData", { + qPath: "/qHyperCubeDef", + qPages: [{ qTop: top, qLeft: 0, qWidth: 1, qHeight: Math.min(5000, total - top) }], + }, ret.qHandle); + const matrix = (d.qDataPages as Array<{ qMatrix?: Array> }>)?.[0]?.qMatrix ?? []; + if (matrix.length === 0) break; + for (const r of matrix) { + const c = String(r[0]?.qText ?? "").trim(); + if (c && c !== "-") vus.add(c); + } + // Inutile de lire tout le catalogue pour conclure : au-delà + // du plafond, la sélection ne restreint manifestement rien. + // Mesuré : 1 207 205 codes lus en 240 pages, ×3 champs = + // 87 s perdues avant un rejet joué d'avance. + if (vus.size > plafond) break; + } + return vus; + } finally { + const id = String(ret.qGenericId ?? ret.qId ?? ""); + if (id) { try { await rpc("DestroySessionObject", { qId: id }, doc); } catch { /* noop */ } } + } + }; + + for (const champ of champsFournisseurCandidats) { + let h = -1; + try { + const gf = await rpc("GetField", { qFieldName: champ }, doc); + h = (gf.qReturn as { qHandle: number }).qHandle; + if (typeof h !== "number" || h < 0) continue; + } catch { continue; } + + await rpc("Clear", {}, h); + await sleep(qlikSettleMs); + const sel = await rpc("SelectValues", { + qFieldValues: [{ qText: fournisseurCode }], + qToggleMode: false, + qSoftLock: true, + }, h); + await sleep(qlikSettleMs); + if (sel.qReturn === false) { + diag(" champ « " + champ + " » : valeur « " + fournisseurCode + " » inconnue"); + await rpc("Clear", {}, h); + continue; + } + + const visibles = await lireCodesVisibles(); + // Un vrai périmètre fournisseur est de l'ordre de grandeur des + // codes demandés. Mesuré en production : « J009 » laissait + // 1 207 205 articles visibles, soit tout le catalogue — la + // sélection n'avait rien restreint, et la « couverture » de 97 % + // ne faisait que constater que le catalogue contient nos codes. + if (visibles.size > plafond) { + diag( + " champ « " + champ + " » = « " + fournisseurCode + " » → plus de " + plafond + + " articles visibles pour " + attendus.size + " demandés : la sélection ne restreint rien, ignorée", + ); + await rpc("Clear", {}, h); + await sleep(qlikSettleMs); + continue; + } + let couverts = 0; + for (const c of attendus) if (visibles.has(c)) couverts++; + const couverture = attendus.size > 0 ? couverts / attendus.size : 0; + diag( + " champ « " + champ + " » = « " + fournisseurCode + " » → " + visibles.size + + " articles Qlik, couvre " + couverts + "/" + attendus.size + + " codes demandés (" + Math.round(couverture * 100) + "%)", + ); + if (couverture >= couvertureFournisseurMin) { + diag("sélection PAR FOURNISSEUR retenue sur « " + champ + " » — " + attendus.size + " SelectValues économisés par passe"); + return true; + } + await rpc("Clear", {}, h); + await sleep(qlikSettleMs); + } + diag("aucun champ fournisseur ne couvre les codes demandés → sélection par codes articles"); + return false; + }; + + /** Valeur de `Période` à poser avant la passe d'une année donnée. */ + const periodeParAnnee = new Map(); + + /** + * Pose (ou efface, si `texte` est nul) la sélection `Période`. + * + * Passe par le HANDLE DU CHAMP, comme pour `Année` et `Date` : c'est la + * seule API qui expose `Clear` et `SelectValues`. Ne propage jamais + * d'erreur — une `Période` qu'on n'arrive pas à poser doit dégrader le + * résultat, pas interrompre l'extraction (leçon de production : un + * `Clear` en échec a fait avorter deux chemins entiers). + */ + const appliquerPeriode = async (texte: string | null): Promise => { + if (pfh === -1) return texte == null; + try { + await rpc("Clear", {}, pfh); + await sleep(qlikSettleMs); + if (texte == null) return true; + const sel = await rpc("SelectValues", { + qFieldValues: [{ qText: texte }], + qToggleMode: false, + qSoftLock: true, + }, pfh); + await sleep(qlikSettleMs); + return sel.qReturn !== false; + } catch (e) { + diag(" sélection Période « " + (texte ?? "(aucune)") + " » impossible : " + String((e as Error)?.message || e)); + return false; + } + }; + + /** + * Choisit la valeur de `Période` sous laquelle l'extraction aura lieu. + * + * CE QUE DIT LA MESURE (production, 2026-08-01, sonde paginée) : + * + * Période « (aucune) » → master exposée : 2024-01…2026-07 (31 mois) + * + * Sans aucune `Période` sélectionnée, « Quantité N » couvre **31 mois**, + * et une seule passe (`Année=2026`) sert les 12 mois de la fenêtre. + * L'état libre est donc le bon — encore fallait-il pouvoir le voir. + * + * Deux erreurs successives l'avaient masqué, toutes deux dans l'outil + * de mesure et non dans le modèle Qlik : + * 1. la sonde ne lisait que les 60 premières lignes du cube `[Mois]`, + * donc jamais les mois récents → « 0/12 » pour toute valeur ; + * 2. `Clear` était appelée sur le list object au lieu du champ, ce qui + * faisait avorter la cartographie entière avant son premier essai. + * + * D'où le principe retenu ici : **ne rien présumer, tout mesurer**. La + * couverture est évaluée par année et dans le contexte exact où elle + * sera exploitée (`monthDimPath` pose la même valeur), l'état sans + * `Période` étant un candidat comme un autre — et, en l'occurrence, le + * gagnant. + */ + const choisirPeriode = async (annees: number[]): Promise => { + const fenetre = new Set(monthlyPayload.map((m) => m.label)); + let lo: { qHandle: number; qGenericId?: string; qId?: string } | null = null; + try { + const obj = await rpc("CreateSessionObject", { qProp: { + qInfo: { qType: "cf-periodes" }, + qListObjectDef: { + qDef: { qFieldDefs: ["Période"] }, + qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 1, qHeight: 50 }], + }, + } }, doc); + lo = obj.qReturn as { qHandle: number; qGenericId?: string; qId?: string }; + const lay = await rpc("GetLayout", {}, lo.qHandle); + const matrix = ((lay.qLayout as { qListObject?: { qDataPages?: Array<{ qMatrix?: Array> }> } }) + .qListObject?.qDataPages?.[0]?.qMatrix) ?? []; + const valeurs = matrix + .map((r) => ({ texte: String(r[0]?.qText ?? ""), elem: r[0]?.qElemNumber ?? -1 })) + .filter((v) => v.texte && v.elem >= 0); + diag("valeurs de « Période » : " + JSON.stringify(valeurs.map((v) => v.texte))); + if (valeurs.length === 0) return 0; + + /** + * Couverture réelle : quels mois « Quantité N » rend-elle vivants ? + * + * ⚠️ Le cube est PAGINÉ intégralement. La version précédente ne + * lisait que les 60 premières lignes : la dimension Mois porte + * tout l'historique de l'app, les mois récents tombaient hors + * de la première page, et la sonde annonçait 0/12 quelle que + * soit la `Période` — alors que la master vivait (mesuré : + * total 9 721 153). C'est ce faux zéro qui faisait effacer + * `Période` et condamnait toute l'extraction. + * + * Renvoie les mois vivants DE LA FENÊTRE, et journalise aussi + * ce que la dimension expose hors fenêtre : sans cela on ne + * distingue pas « le modèle ne donne rien » de « le modèle + * donne autre chose que ce qu'on attend ». + */ + const mesurerCouverture = async (): Promise<{ fenetre: string[]; tous: string[] }> => { + const sonde = await rpc("CreateSessionObject", { qProp: { + qInfo: { qType: "cf-periode-sonde" }, + qHyperCubeDef: { + qDimensions: [{ qLibraryId: moisDimId }], + qMeasures: [{ qLibraryId: rQte }], + qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 2, qHeight: 500 }], + }, + } }, doc); + const sRet = sonde.qReturn as { qHandle: number; qGenericId?: string; qId?: string }; + try { + const sl = await rpc("GetLayout", {}, sRet.qHandle); + const hc = (sl.qLayout as { qHyperCube?: { qSize?: { qcy?: number }; qDataPages?: Array<{ qMatrix?: Array> }> } }).qHyperCube; + const lignes: Array> = [...(hc?.qDataPages?.[0]?.qMatrix ?? [])]; + const total = Number(hc?.qSize?.qcy) || 0; + for (let top = lignes.length; top < total; top += 500) { + const d = await rpc("GetHyperCubeData", { + qPath: "/qHyperCubeDef", + qPages: [{ qTop: top, qLeft: 0, qWidth: 2, qHeight: Math.min(500, total - top) }], + }, sRet.qHandle); + const m = (d.qDataPages as Array<{ qMatrix?: Array> }>)?.[0]?.qMatrix ?? []; + if (m.length === 0) break; + lignes.push(...m); + } + const vivants = lignes + .filter((r) => (Number(r[1]?.qNum) || 0) > 0) + .map((r) => normMois(String(r[0]?.qText ?? ""))) + .sort(); + return { fenetre: vivants.filter((m) => fenetre.has(m)), tous: vivants }; + } finally { + const sid = String(sRet.qGenericId ?? sRet.qId ?? ""); + if (sid) { try { await rpc("DestroySessionObject", { qId: sid }, doc); } catch { /* noop */ } } + } + }; + + /** Résumé compact d'une liste de mois : « 2025-01…2025-12 (12) ». */ + const resume = (mois: string[]) => + mois.length === 0 ? "aucun" : mois[0] + "…" + mois[mois.length - 1] + " (" + mois.length + ")"; + + /** + * Couverture d'une `Période` telle qu'elle sera RÉELLEMENT + * exploitée : une passe par année, comme `monthDimPath`, puis + * union. C'est le seul chiffre qui a un sens ici — mesurer + * sans année revient à juger « Année à date » sur la seule + * année en cours (6/12) et à la rejeter à tort. + */ + // ─── Une `Période` PAR ANNÉE ───────────────────────────── + // + // C'est le point que la version globale manquait : la bonne + // valeur dépend de l'année analysée. « Année à date » convient + // à 2026 (janvier → mois courant) mais pas à 2025, dont on veut + // les 12 mois. On cartographie donc, pour chaque année de la + // fenêtre, la valeur qui sert le PLUS de mois attendus de cette + // année-là — l'état sans `Période` étant un candidat comme un + // autre. + const couvertsGlobal = new Set(); + for (const annee of annees) { + const attendusAnnee = [...fenetre].filter((m) => m.startsWith(String(annee) + "-")); + diag(" année " + annee + " — mois attendus " + JSON.stringify(attendusAnnee.sort())); + + if (yfh !== -1) { + await rpc("Clear", {}, yfh); + await sleep(qlikSettleMs); + await rpc("SelectValues", { + qFieldValues: [{ qNum: annee, qText: String(annee) }], + qToggleMode: false, + qSoftLock: true, + }, yfh); + await sleep(qlikSettleMs); + } + + // `null` = état sans `Période`, candidat comme un autre. + const candidats: Array = [null, ...valeurs.map((v) => v.texte)]; + let meilleure: { texte: string | null; servis: string[] } | null = null; + for (const c of candidats) { + const libelle = c ?? "(aucune)"; + // Un candidat qui échoue ne doit pas emporter toute la + // cartographie : on le note et on passe au suivant. + let servis: string[] = []; + try { + const pose = await appliquerPeriode(c); + if (!pose && c !== null) { + diag(" Période « " + libelle + " » → non sélectionnable, ignorée"); + continue; + } + const mois = await mesurerCouverture(); + servis = mois.fenetre.filter((m) => attendusAnnee.includes(m)); + diag( + " Période « " + libelle + " » → sert " + servis.length + "/" + attendusAnnee.length + + " mois attendus " + JSON.stringify(servis) + " ; master exposée : " + resume(mois.tous), + ); + } catch (e) { + diag(" Période « " + libelle + " » → erreur " + String((e as Error)?.message || e)); + continue; + } + if (!meilleure || servis.length > meilleure.servis.length) { + meilleure = { texte: c, servis }; + } + if (servis.length >= attendusAnnee.length) break; // année complète + } + + if (meilleure) { + periodeParAnnee.set(annee, { texte: meilleure.texte }); + for (const m of meilleure.servis) couvertsGlobal.add(m); + diag( + " → année " + annee + " sera extraite sous Période « " + (meilleure.texte ?? "(aucune)") + " » (" + + meilleure.servis.length + "/" + attendusAnnee.length + " mois)", + ); + } + } + + if (yfh !== -1) { + // Chaque passe pose sa propre année. + await rpc("Clear", {}, yfh); + await sleep(qlikSettleMs); + } + return couvertsGlobal.size; + } catch (e) { + diag("choix de « Période » impossible : " + String((e as Error)?.message || e)); + return 0; + } finally { + // Le list object est conservé jusqu'au bout : le détruire + // annulerait la sélection qu'il porte. + void lo; + } + }; + + /** + * Teste UNE expression isolément sur un petit cube `[Mois]`. + * + * Une seule mesure invalide suffit à faire renvoyer **zéro ligne** à + * tout un hypercube — observé en production : le cube à 5 mesures + * calculait 26 s puis rendait 0 ligne, alors que `Sum(quantite)` seul + * donnait 35 424 324. Impossible de savoir laquelle sans les isoler. + * + * Renvoie l'expression si elle produit des lignes, sinon `"0"` — une + * colonne neutre qui préserve les indices sans casser le cube. + */ + const validerExpression = async (nom: string, expr: string): Promise => { + let ret: { qHandle: number; qGenericId?: string; qId?: string } | null = null; + try { + const o = await rpc("CreateSessionObject", { qProp: { + qInfo: { qType: "cf-test-expr" }, + qHyperCubeDef: { + qDimensions: [{ qLibraryId: moisDimId }], + qMeasures: [{ qDef: { qDef: expr } }], + qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 2, qHeight: 30 }], + }, + } }, doc); + ret = o.qReturn as { qHandle: number; qGenericId?: string; qId?: string }; + const lay = await rpc("GetLayout", {}, ret.qHandle); + const hc = (lay.qLayout as { qHyperCube?: { qSize?: { qcy?: number }; qError?: unknown; qDataPages?: Array<{ qMatrix?: Array> }> } }).qHyperCube; + const matrix = hc?.qDataPages?.[0]?.qMatrix ?? []; + const lignes = Number(hc?.qSize?.qcy) || 0; + let total = 0; + let texteErreur = ""; + for (const r of matrix) { + total += Number(r[1]?.qNum) || 0; + const t = String(r[1]?.qText ?? ""); + if (!texteErreur && /error|erreur|garbage|^-$/i.test(t)) texteErreur = t; + } + if (hc?.qError) texteErreur = texteErreur || JSON.stringify(hc.qError); + if (lignes === 0 || texteErreur) { + diag(" mesure « " + nom + " » = " + expr + " → INVALIDE (" + (texteErreur || "cube vide") + ")"); + return "0"; + } + diag(" mesure « " + nom + " » = " + expr + " → " + lignes + " mois, total=" + Math.round(total)); + return expr; + } catch (e) { + diag(" mesure « " + nom + " » = " + expr + " → ERREUR " + String((e as Error)?.message || e)); + return "0"; + } finally { + const id = String(ret?.qGenericId ?? ret?.qId ?? ""); + if (id) { try { await rpc("DestroySessionObject", { qId: id }, doc); } catch { /* noop */ } } + } + }; + + /** + * La master « Quantité N » produit-elle la moindre ligne sur `[Mois]` + * dans l'état de sélection courant ? Même sonde que + * `validerExpression`, mais par `qLibraryId`. + */ + const masterQteVivante = async (): Promise => { + let ret: { qHandle: number; qGenericId?: string; qId?: string } | null = null; + try { + const o = await rpc("CreateSessionObject", { qProp: { + qInfo: { qType: "cf-test-master" }, + qHyperCubeDef: { + qDimensions: [{ qLibraryId: moisDimId }], + qMeasures: [{ qLibraryId: rQte }], + qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 2, qHeight: 30 }], + }, + } }, doc); + ret = o.qReturn as { qHandle: number; qGenericId?: string; qId?: string }; + const lay = await rpc("GetLayout", {}, ret.qHandle); + const hc = (lay.qLayout as { qHyperCube?: { qDataPages?: Array<{ qMatrix?: Array> }> } }).qHyperCube; + let total = 0; + for (const r of hc?.qDataPages?.[0]?.qMatrix ?? []) total += Number(r[1]?.qNum) || 0; + diag(" master « Quantité N » (calibrage) → total=" + Math.round(total)); + return total > 0; + } catch (e) { + diag(" master « Quantité N » → ERREUR " + String((e as Error)?.message || e)); + return false; + } finally { + const id = String(ret?.qGenericId ?? ret?.qId ?? ""); + if (id) { try { await rpc("DestroySessionObject", { qId: id }, doc); } catch { /* noop */ } } + } + }; + + /** + * Somme de `expr` VENTILÉE par la dimension Mois. + * + * Comparée au total sans dimension, elle dit si la dimension Mois + * démultiplie les faits à travers le pont de périodes — auquel cas + * l'agrégation directe est inexploitable. + */ + const totalVentileParMois = async (expr: string): Promise => { + let ret: { qHandle: number; qGenericId?: string; qId?: string } | null = null; + try { + const o = await rpc("CreateSessionObject", { qProp: { + qInfo: { qType: "cf-ventil-mois" }, + qHyperCubeDef: { + qDimensions: [{ qLibraryId: moisDimId }], + qMeasures: [{ qDef: { qDef: expr } }], + qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 2, qHeight: 200 }], + }, + } }, doc); + ret = o.qReturn as { qHandle: number; qGenericId?: string; qId?: string }; + const lay = await rpc("GetLayout", {}, ret.qHandle); + const hc = (lay.qLayout as { qHyperCube?: { qDataPages?: Array<{ qMatrix?: Array> }> } }).qHyperCube; + let total = 0; + for (const r of hc?.qDataPages?.[0]?.qMatrix ?? []) total += Number(r[1]?.qNum) || 0; + return total; + } catch { + return 0; + } finally { + const id = String(ret?.qGenericId ?? ret?.qId ?? ""); + if (id) { try { await rpc("DestroySessionObject", { qId: id }, doc); } catch { /* noop */ } } + } + }; + + /** + * Cube `[Article Code] × 4 expressions`, SANS dimension Mois. + * + * C'est tout l'intérêt : la dimension Mois passe par le pont de + * périodes et rattache une même ligne de fait à plusieurs contextes + * (mesuré : ×5,3). Sans elle, `Sum(quantite)` porte une fois et une + * seule sur les faits visibles — donc sur le mois sélectionné. + */ + const fetchCodeCubeExpr = async ( + eCa: string, eQte: string, eNbMag: string, eMarge: string, + onRow: (code: string, ca: number, qte: number, nbMag: number, marge: number) => void, + timings?: Record, + ): Promise => { + const LARGEUR = 5; + const PAGE = 1800; // 5 × 1800 = 9000 cellules < 10000 + const { value: obj } = await timed("createCubeCode", "CreateSessionObject", () => rpcWithRetry("createCubeCode", "CreateSessionObject", { qProp: { qInfo: { qType: "cf-net-code-expr" }, qHyperCubeDef: { + qDimensions: [{ qLibraryId: dim }], + qMeasures: [ + { qDef: { qDef: eCa } }, + { qDef: { qDef: eQte } }, + { qDef: { qDef: eNbMag } }, + { qDef: { qDef: eMarge } }, + ], + qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: LARGEUR, qHeight: PAGE }], + } } }, doc), timings); + const qReturn = obj.qReturn as { qHandle: number; qGenericId?: string; qId?: string }; + const cube = { handle: qReturn.qHandle, id: String(qReturn.qGenericId ?? qReturn.qId ?? "") }; + const traiter = (matrix: Array>): number => { + for (const r of matrix) { + onRow( + String(r[0]?.qText ?? ""), + Number(r[1]?.qNum) || 0, Number(r[2]?.qNum) || 0, + Number(r[3]?.qNum) || 0, Number(r[4]?.qNum) || 0, + ); + } + return matrix.length; + }; + try { + const { value: layout } = await timed("layout", "GetLayout", () => rpcWithRetry("layout", "GetLayout", {}, cube.handle), timings); + const hc = (layout.qLayout as { qHyperCube: { qSize: { qcy: number }; qDataPages?: Array<{ qArea?: { qTop?: number }; qMatrix?: Array> }> } }).qHyperCube; + const size = hc.qSize.qcy; + let got = 0, top = 0; + const p0 = hc.qDataPages?.find((p) => (p.qArea?.qTop ?? 0) === 0); + if (p0?.qMatrix?.length) { got += traiter(p0.qMatrix); top = PAGE; } + for (; top < size; top += PAGE) { + const { value: d } = await timed("getCubeData", "GetHyperCubeData", () => rpcWithRetry("getCubeData", "GetHyperCubeData", { qPath: "/qHyperCubeDef", qPages: [{ qTop: top, qLeft: 0, qWidth: LARGEUR, qHeight: PAGE }] }, cube.handle), timings); + const matrix = (d.qDataPages as Array<{ qMatrix?: Array> }>)?.[0]?.qMatrix ?? []; + if (!matrix.length) break; + got += traiter(matrix); + if (matrix.length < PAGE) break; + } + return got; + } finally { + await destroyCube(cube, timings); + } + }; + + /** + * EXTRACTION MOIS PAR MOIS SUR LE CHAMP DATE BRUT. + * + * Le chemin que rien n'avait encore essayé, et le seul qui n'emprunte + * ni le pont de périodes ni les master measures : + * + * - on sélectionne les jours d'UN mois sur le champ date ; + * - on lit un cube `[Article Code]` sans dimension Mois, donc sans + * démultiplication ; + * - `Type_Cal`, `Période` et `Année` sont effacés : les expressions + * brutes n'ont pas besoin du pont, elles agrègent les faits visibles. + * + * Il n'y a donc plus de « mois inatteignable » : chaque mois est un + * filtre de dates, pas un contexte de calendrier. + * + * CONTRÔLE D'INTÉGRITÉ. La somme des 12 mois ne doit pas dépasser le + * total sans filtre de date : si la dimension ou le champ démultipliait + * les faits, elle exploserait (le pont donne ×5,3). Et chaque mois doit + * être servi. Sinon rien n'est écrit. + */ + const moisParDateBrute = async (): Promise => { + const timings: Record = {}; + + // Aucun contexte de calendrier : on veut les faits nus. + if (yfh !== -1) { await rpc("Clear", {}, yfh); await sleep(qlikSettleMs); } + await appliquerPeriode(null); + if (dfh !== -1) { await rpc("Clear", {}, dfh); await sleep(qlikSettleMs); } + + if (codes.length && fh !== -1 && !parFournisseur) { + await timed("clearCode", "Clear", () => rpc("Clear", {}, fh), timings); + await sleep(qlikSettleMs); + await timed("selectCodeAll", "SelectValues", () => rpcWithRetry("selectCodeAll", "SelectValues", { + qFieldValues: codes.map((c) => ({ qText: c })), + qToggleMode: false, + qSoftLock: true, + }, fh, () => { aggCode15Retries++; }), timings); + await sleep(qlikSettleMs); + } + + const sansFiltre = await totalDeControle(); + diag("[date brute] contrôle « " + exprQte + " » sans filtre date = " + Math.round(sansFiltre)); + if (sansFiltre <= 0) { + diag("[date brute] ÉCHEC : l'expression quantité ne renvoie rien (corriger QLIK_EXPR_QTE)"); + return false; + } + + // ─── Quel champ date filtre les faits ? ────────────────── + // + // ⚠️ On teste avec UN SEUL MOIS, pas avec la fenêtre entière. + // Toute la table de faits tient dans les 12 mois (mesuré : 100 % + // du total) : sélectionner la fenêtre ne réduit donc RIEN, et + // l'ancien test « le total doit baisser » rejetait tous les + // champs, y compris les bons. + const moisTest = monthlyPayload[Math.floor(monthlyPayload.length / 2)]; + let champDate: string | null = null; + let handleDate = -1; + for (const cand of champsDateCandidats) { + const valeurs = /key/i.test(cand) ? moisTest.ymd : moisTest.serials; + const h = await selectionnerFenetreSur(cand, valeurs); + if (h == null) { diag("[date brute] champ « " + cand + " » : inexistant"); continue; } + const t = await totalDeControle(); + const part = sansFiltre > 0 ? t / sansFiltre : 0; + diag( + "[date brute] champ « " + cand + " » sur le seul mois " + moisTest.label + " → " + + Math.round(t) + " (" + (part * 100).toFixed(1) + "% du total)", + ); + // Un mois isolé doit peser une fraction du total : ni zéro + // (le champ ne touche pas les faits) ni la totalité (il ne + // filtre pas). + if (t > 0 && part < 0.5) { champDate = cand; handleDate = h; break; } + await rpc("Clear", {}, h); + await sleep(qlikSettleMs); + } + if (!champDate || handleDate === -1) { + diag( + "[date brute] ÉCHEC : aucun champ date ne filtre les faits mois par mois " + + "(essayés : " + JSON.stringify(champsDateCandidats) + ")", + ); + return false; + } + diag("[date brute] champ date retenu : « " + champDate + " »"); + + // À partir d'ici la série sera reconstruite entièrement : les + // passes annuelles (master measures) et la série brute n'ont pas + // la même sémantique, on ne les mélange pas. + out.length = 0; + for (const k of Object.keys(monthlyByCode)) delete monthlyByCode[k]; + + const totauxMois: Record = {}; + let sommeMois = 0; + for (let i = 0; i < monthlyPayload.length; i++) { + const m = monthlyPayload[i]; + const valeurs = /key/i.test(champDate) ? m.ymd : m.serials; + await rpc("Clear", {}, handleDate); + await sleep(qlikSettleMs); + await rpcWithRetry("selectDate", "SelectValues", { + qFieldValues: valeurs.map((v) => ({ qNum: v, qText: String(v) })), + qToggleMode: false, + qSoftLock: true, + }, handleDate, () => { aggCode15Retries++; }); + await sleep(qlikSettleMs); + + let totalDuMois = 0; + const lignes = await fetchCodeCubeExpr(exprCa, exprQte, exprNbMag, exprMarge, (code, ca, qte, nbMag, marge) => { + if (!code || code === "-") return; + if (qte === 0 && ca === 0) return; + totalDuMois += qte; + out.push([code, ca, qte, nbMag, nbMag > 0 ? ca / nbMag : 0, ca > 0 ? marge / ca : 0]); + (monthlyByCode[code] ??= {}); + monthlyByCode[code][m.label] = { + qte, ca, nbMag, + caMag: nbMag > 0 ? ca / nbMag : 0, + margePct: ca > 0 ? marge / ca : 0, + }; + }, timings); + totauxMois[m.label] = totalDuMois; + sommeMois += totalDuMois; + aggBatches++; + console.log( + "[qlik-pw][date brute] mois " + (i + 1) + "/" + monthlyPayload.length + " " + m.label + + " → " + lignes + " articles, quantité=" + Math.round(totalDuMois), + ); + await pousserCheckpoint("date brute " + m.label); + } + aggMonths = monthlyPayload.length; + + diag("[date brute] quantités par mois : " + JSON.stringify( + Object.fromEntries(Object.entries(totauxMois).map(([k, v]) => [k, Math.round(v)])), + )); + + // ─── Intégrité ─────────────────────────────────────────── + const vides = Object.entries(totauxMois).filter(([, v]) => v <= 0).map(([k]) => k); + if (vides.length) { + diag("[date brute] ÉCHEC : mois sans aucune vente " + JSON.stringify(vides)); + return false; + } + if (sommeMois > sansFiltre * 1.02) { + diag( + "[date brute] ÉCHEC : la somme des 12 mois (" + Math.round(sommeMois) + + ") dépasse le total sans filtre (" + Math.round(sansFiltre) + ") — les faits sont " + + "démultipliés, les ventes réseau seraient fausses", + ); + return false; + } + diag( + "[date brute] OK — somme des 12 mois = " + Math.round(sommeMois) + " pour un total sans filtre de " + + Math.round(sansFiltre) + " (" + Math.round((sommeMois / sansFiltre) * 100) + "%), " + + Object.keys(monthlyByCode).length + " articles", + ); + console.log("[qlik-pw][date brute] timings=" + timingSummary(timings)); + return true; + }; + + const moisParExpressions = async (): Promise => { + const timings: Record = {}; + const marqueOut = out.length; + try { + if (yfh !== -1) { await rpc("Clear", {}, yfh); await sleep(qlikSettleMs); } + + // Les codes d'abord : les totaux de contrôle portent alors sur + // le même périmètre que l'extraction. Sauf si le fournisseur + // pose déjà ce périmètre — le re-sélectionner par codes ne + // changerait rien au résultat et coûterait une minute. + if (codes.length && fh !== -1 && !parFournisseur) { + await timed("clearCode", "Clear", () => rpc("Clear", {}, fh), timings); + await sleep(qlikSettleMs); + await timed("selectCodeAll", "SelectValues", () => rpcWithRetry("selectCodeAll", "SelectValues", { + qFieldValues: codes.map((c) => ({ qText: c })), + qToggleMode: false, + qSoftLock: true, + }, fh, () => { aggCode15Retries++; }), timings); + await sleep(qlikSettleMs); + } + + // ─── Quel champ date filtre RÉELLEMENT les faits ? ─────── + // + // Les master measures ignorant la sélection Date, personne + // n'avait jamais pu vérifier que « Date » filtrait quoi que + // ce soit. Un champ valide doit donner un total non nul ET + // strictement inférieur au total sans filtre. + const sansFiltre = await totalDeControle(); + diag("contrôle « " + exprQte + " » sans filtre date = " + Math.round(sansFiltre)); + if (sansFiltre <= 0) { + diag("ÉCHEC : « " + exprQte + " » renvoie 0 même sans filtre → expression ou champ invalide (corriger QLIK_EXPR_QTE)"); + return false; + } + + const allSerials = monthlyPayload.flatMap((m) => m.serials); + const allYmd = monthlyPayload.flatMap((m) => m.ymd); + const moisFenetre = new Set(monthlyPayload.map((m) => m.label)); + let champDate: string | null = null; + let totalFenetre = 0; + for (const cand of champsDateCandidats) { + const valeurs = /key/i.test(cand) ? allYmd : allSerials; + const handleCand = await selectionnerFenetreSur(cand, valeurs); + if (handleCand == null) { + diag(" champ « " + cand + " » : inexistant"); + continue; + } + const filtre = await totalDeControle(); + const ratio = sansFiltre > 0 ? filtre / sansFiltre : 0; + diag(" champ « " + cand + " » → " + Math.round(filtre) + " (" + Math.round(ratio * 100) + "% du total sans filtre)"); + // ⚠️ 100 % ne veut PAS dire « ce champ ne filtre rien » : toute + // la table de faits tient dans les 12 mois (mesuré), donc + // sélectionner la fenêtre entière ne retire rien. Exiger une + // baisse rejetait donc les champs valides. Ici la sélection + // ne sert qu'à borner la fenêtre : un total non nul suffit, + // et on préfère simplement un champ qui filtre s'il existe. + if (filtre > 0) { + champDate = cand; + totalFenetre = filtre; + if (ratio < 0.995) break; + diag(" (la fenêtre couvre 100 % des faits — normal si l'app ne porte que ces 12 mois)"); + break; + } + // Un candidat rejeté ne doit jamais polluer l'essai + // suivant (cas observé : Date=0 puis tous les autres=0). + await rpc("Clear", {}, handleCand); + await sleep(qlikSettleMs); + } + if (!champDate) { + // Les champs date exposés sont dissociés des faits : la + // dimension maître « Mois », elle, porte les vraies valeurs. + const selectionMois = await selectionnerFenetreViaDimensionMois(moisFenetre); + const ratio = selectionMois && sansFiltre > 0 ? selectionMois.total / sansFiltre : 0; + if (selectionMois) { + diag(" dimension « Mois » → " + Math.round(selectionMois.total) + " (" + Math.round(ratio * 100) + "% du total sans filtre)"); + } + if (selectionMois && selectionMois.total > 0 && ratio < 0.995) { + champDate = "dimension Mois"; + totalFenetre = selectionMois.total; + } + } + if (!champDate) { + diag( + "ÉCHEC : ni les champs date ni la dimension Mois ne filtrent les faits " + + "(champs essayés : " + JSON.stringify(champsDateCandidats) + ") → impossible de borner la fenêtre 12 mois.", + ); + return false; + } + diag("champ date retenu : « " + champDate + " »"); + + // Chaque expression est validée SÉPARÉMENT : une seule mesure + // invalide fait rendre zéro ligne à tout l'hypercube, sans + // qu'aucun message ne dise laquelle. + diag("validation des expressions, une par une :"); + const vQte = await validerExpression("quantité", exprQte); + if (vQte === "0") { + diag("ÉCHEC : l'expression quantité est inutilisable, rien à extraire"); + return false; + } + const vCa = await validerExpression("CA", exprCa); + const vNbMag = await validerExpression("magasins", exprNbMag); + const vMarge = await validerExpression("marge", exprMarge); + + // La master « Quantité N » n'est là que pour le calibrage, mais + // une master morte suffit à vider tout l'hypercube. On la sonde + // donc comme les autres, et on l'omet plutôt que de perdre + // l'extraction entière pour une colonne de contrôle. + const avecMaster = await masterQteVivante(); + if (!avecMaster) { + diag(" master « Quantité N » morte sous cette sélection → colonne de calibrage retirée du cube"); + } + + // ─── La dimension Mois démultiplie-t-elle les faits ? ──── + // + // Le pont de périodes rattache une même ligne de fait à + // plusieurs contextes. Ventilé par `Mois`, `Sum(quantite)` peut + // donc dépasser le total sans dimension — mesuré : 179 574 616 + // contre 33 945 285, soit ×5,3. Écrire cela dans le cache + // donnerait des ventes réseau cinq fois trop hautes : on refuse. + const totalVentile = await totalVentileParMois(vQte); + if (totalVentile > totalFenetre * 1.05) { + diag( + "ÉCHEC : ventilée par Mois, « " + vQte + " » totalise " + Math.round(totalVentile) + + " contre " + Math.round(totalFenetre) + " sans dimension (×" + + (totalFenetre > 0 ? (totalVentile / totalFenetre).toFixed(1) : "?") + + "). La dimension Mois passe par le pont de périodes et démultiplie les faits — " + + "l'agrégation directe donnerait des ventes réseau fausses, elle est refusée.", + ); + return false; + } + + // Seuls les mois de la fenêtre nous intéressent ; la dimension + // Mois peut en exposer d'autres si une sélection déborde. + let totalQte = 0; + let calibrees = 0, calibrOk = 0; + let horsFenetre = 0; + let echantillon = false; + + const got = await timed("cube", "expr", () => fetchMonthCubeExpr(vCa, vQte, vNbMag, vMarge, (code, mois, ca, qte, nbMag, marge, qteMaster) => { + if (!code || code === "-") return; + const mm = normMois(mois); + if (!echantillon) { + echantillon = true; + // Échantillon AVANT filtrage : si le mois ne tombe pas dans + // la fenêtre, c'est le format de la dimension Mois qui est + // en cause, pas les mesures. + diag("échantillon brut: " + JSON.stringify({ + code, moisBrut: mois, moisNormalise: mm, dansLaFenetre: moisFenetre.has(mm), + ca, qte, nbMag, marge, "Quantité N (master)": qteMaster, + })); + } + if (!moisFenetre.has(mm)) { horsFenetre++; return; } + // Calibrage sur l'année en cours, seul périmètre où la master + // measure est censée être juste. + if (avecMaster && parseInt(mm.slice(0, 4), 10) === yearN && (qte > 0 || qteMaster > 0)) { + calibrees++; + if (Math.abs(qte - qteMaster) <= Math.max(1, qteMaster * 0.02)) calibrOk++; + } + totalQte += qte; + // Totaux = somme des mois de la fenêtre (et non un cumul année). + out.push([code, ca, qte, nbMag, nbMag > 0 ? ca / nbMag : 0, ca > 0 ? marge / ca : 0]); + (monthlyByCode[code] ??= {}); + // Agrège au lieu d'écraser : protège contre deux valeurs + // brutes de la dimension Mois qui se normaliseraient vers + // la même clé YYYY-MM. + const precedent = monthlyByCode[code][mm]; + const qteCumulee = (precedent?.qte ?? 0) + qte; + const caCumule = (precedent?.ca ?? 0) + ca; + const margeMontant = ((precedent?.margePct ?? 0) * (precedent?.ca ?? 0)) + marge; + monthlyByCode[code][mm] = { + qte: qteCumulee, + ca: caCumule, + nbMag: Math.max(precedent?.nbMag ?? 0, nbMag), + caMag: nbMag > 0 ? ca / nbMag : 0, + margePct: caCumule > 0 ? margeMontant / caCumule : 0, + }; + }, timings, undefined, avecMaster), timings); + + aggMonths = monthlyPayload.length; + aggBatches++; + diag( + got.value + " lignes (" + horsFenetre + " hors fenêtre), quantité totale=" + Math.round(totalQte) + + ", calibrage année " + yearN + " : " + calibrOk + "/" + calibrees + " mois conformes à « Quantité N »", + ); + console.log("[qlik-pw][expr] timings=" + timingSummary(timings)); + + if (totalQte <= 0) { + diag( + "ÉCHEC : quantité totale nulle sur la fenêtre alors que le contrôle global valait " + + Math.round(sansFiltre) + " — " + horsFenetre + " ligne(s) écartées comme hors fenêtre. " + + "Si ce nombre est élevé, c'est le format de la dimension Mois qui ne correspond pas aux libellés attendus.", + ); + out.length = marqueOut; + for (const k of Object.keys(monthlyByCode)) delete monthlyByCode[k]; + return false; + } + // Contrôle global indépendant : le cube sans dimension, + // calculé sur exactement les mêmes sélections, doit être + // égal à la somme de toutes les lignes mensuelles paginées. + const ecartGlobal = Math.abs(totalQte - totalFenetre); + const toleranceGlobale = Math.max(0.001, Math.abs(totalFenetre) * 1e-9); + if (ecartGlobal > toleranceGlobale) { + console.error( + "[qlik-pw][expr] INTÉGRITÉ REFUSÉE : somme mensuelle=" + totalQte + + " mais total Qlik fenêtre=" + totalFenetre + " (écart=" + ecartGlobal + ")", + ); + out.length = marqueOut; + for (const k of Object.keys(monthlyByCode)) delete monthlyByCode[k]; + return false; + } + + // Contrôle et normalisation PAR ARTICLE. Chaque article + // retourné possède ensuite exactement les 12 clés de la + // fenêtre ; une clé absente du cube Qlik signifie qu'aucun + // fait n'existe pour ce mois, donc une vraie valeur 0. + const totauxParCode: Record = {}; + for (let i = marqueOut; i < out.length; i++) { + const ligne = out[i]; + const code = String(ligne[0] ?? "").trim(); + if (code) totauxParCode[code] = (totauxParCode[code] ?? 0) + (Number(ligne[2]) || 0); + } + for (const [code, mois] of Object.entries(monthlyByCode)) { + for (const label of moisFenetre) { + mois[label] ??= { qte: 0, ca: 0, nbMag: 0, caMag: 0, margePct: 0 }; + } + // Ne conserve aucune clé hors de la fenêtre glissante. + for (const label of Object.keys(mois)) { + if (!moisFenetre.has(label)) delete mois[label]; + } + const somme12 = Array.from(moisFenetre).reduce((s, label) => s + (Number(mois[label]?.qte) || 0), 0); + const totalArticle = totauxParCode[code] ?? 0; + if (Math.abs(somme12 - totalArticle) > Math.max(0.001, Math.abs(totalArticle) * 1e-9)) { + console.error( + "[qlik-pw][expr] INTÉGRITÉ ARTICLE REFUSÉE code=" + code + + " somme12=" + somme12 + " total=" + totalArticle, + ); + out.length = marqueOut; + for (const k of Object.keys(monthlyByCode)) delete monthlyByCode[k]; + return false; + } + } + if (calibrees > 20 && calibrOk / calibrees < 0.5) { + console.warn( + "[qlik-pw][expr] ⚠ calibrage faible (" + calibrOk + "/" + calibrees + ") : " + exprQte + + " diverge de « Quantité N ». Vérifier l'expression (QLIK_EXPR_QTE).", + ); + } + return true; + } catch (e) { + diag("ÉCHEC : " + String((e as Error)?.message || e) + " → repli sur les master measures"); + out.length = marqueOut; + for (const k of Object.keys(monthlyByCode)) delete monthlyByCode[k]; + return false; + } + }; + // Sélectionne toute la fenêtre de dates une fois, puis parcourt les codes par // lots ; un seul cube [Article Code, Mois] par lot donne TOUS les mois d'un coup. // selectYear = null → passe principale (année N + N-1 dérivée via COMP). @@ -433,17 +1762,64 @@ export async function fetchNetworkMetricsPlaywright( // récupérer directement ses mois (seul moyen d'obtenir août→déc N-1, que // COMP ne peut pas fournir puisque l'année N ne va pas jusque-là). let loggedSampleRow = false; - const monthDimPath = async (selectYear: number | null): Promise => { - const isMainPass = selectYear === null; - if (yfh !== -1) { - await rpc("Clear", {}, yfh); - await sleep(qlikSettleMs); - if (selectYear !== null) { - const ysel = await rpc("SelectValues", { qFieldValues: [{ qNum: selectYear, qText: String(selectYear) }], qToggleMode: false, qSoftLock: true }, yfh); - console.log("[qlik-pw] (mois) sélection Année=" + selectYear + " → " + JSON.stringify(ysel.qReturn)); - await sleep(qlikSettleMs); + const monthDimPath = async (selectYear: number): Promise => { + const moisFenetre = new Set(monthlyPayload.map((m) => m.label)); + + /** + * Traitement d'une ligne du cube [Article Code, Mois]. + * + * Une passe = UNE année explicitement sélectionnée, exactement ce + * que fait un utilisateur dans l'app (« je sélectionne 2026 et + * j'obtiens N + la comparaison N-1 »). Chaque passe n'écrit que + * les mois DE LA FENÊTRE, et les totaux s'additionnent sur ces + * mois-là : deux années différentes ne peuvent donc pas se + * doubler, puisqu'elles ne partagent aucun mois. + */ + const onRowMois = (code: string, mois: string, ca: number, qte: number, nbMag: number, caMag: number, marge: number, qteComp: number) => { + if (!code || code === "-") return; + const mm = normMois(mois); + if (!moisFenetre.has(mm)) return; + out.push([code, ca, qte, nbMag, caMag, marge]); + const y = parseInt(mm.slice(0, 4), 10); + if (!loggedSampleRow) { + loggedSampleRow = true; + console.log("[qlik-pw][mesures] échantillon ligne mensuelle: " + JSON.stringify({ + code, mois: mm, "CA N": ca, "Quantité N (utilisée pour la courbe)": qte, + "Magasin Ventes Nb N": nbMag, "CA par Magasin N": caMag, "Marge % N": marge, "Quantité COMP": qteComp, + })); } + (monthlyByCode[code] ??= {}); + // Les 5 mesures du mois courant de la ligne. Le cube les renvoie + // déjà toutes : les conserver ne coûte aucune requête Qlik en plus. + const full = { qte, ca, nbMag, caMag, margePct: marge }; + // « Quantité COMP » n'est plus nécessaire : chaque année de la + // fenêtre est extraite directement par sa propre passe, ce qui + // donne les 5 mesures et non la seule quantité. + void y; void qteComp; + // Une passe ultérieure ne doit JAMAIS écraser un mois servi par + // un zéro : hors de son année, la passe voit la combinaison + // (article, mois) mais toutes ses mesures sont nulles. + const precedent = monthlyByCode[code][mm]; + if (precedent && (precedent.qte ?? 0) > 0 && qte <= 0) return; + monthlyByCode[code][mm] = full; + }; + + if (yfh === -1) throw new Error("champ « Année » introuvable : impossible d'extraire année par année"); + + // La `Période` qui sert cette année-là — pas la même pour toutes : + // « Année à date » convient à l'année en cours, une autre valeur à + // une année révolue (cf. choisirPeriode). + const periodeAnnee = periodeParAnnee.get(selectYear); + if (periodeAnnee) { + console.log("[qlik-pw] (mois) Période « " + (periodeAnnee.texte ?? "(aucune)") + " » pour l'année " + selectYear); + await appliquerPeriode(periodeAnnee.texte); } + + await rpc("Clear", {}, yfh); + await sleep(qlikSettleMs); + const ysel = await rpc("SelectValues", { qFieldValues: [{ qNum: selectYear, qText: String(selectYear) }], qToggleMode: false, qSoftLock: true }, yfh); + console.log("[qlik-pw] (mois) sélection Année=" + selectYear + " → " + JSON.stringify(ysel.qReturn)); + await sleep(qlikSettleMs); const allSerials = monthlyPayload.flatMap((m) => m.serials); if (dfh !== -1 && allSerials.length) { await rpc("Clear", {}, dfh); @@ -452,6 +1828,57 @@ export async function fetchNetworkMetricsPlaywright( await sleep(qlikSettleMs); } aggMonths = monthlyPayload.length; + + // ─── Chemin rapide : UNE seule sélection Article Code ──── + // + // Mesuré en production : `SelectValues` sur « Article Code » + // coûte 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 donne déjà tous les mois d'un coup : rien + // n'oblige à découper les codes. On tente donc un seul appel + // pour tous les codes et un seul cube paginé — la lecture des + // pages, elle, coûte ~50 ms. Repli sur les lots si l'Engine + // refuse (cube trop gros, code 15, saturation mémoire). + const marqueOut = out.length; + if ((codes.length || parFournisseur) && fh !== -1) { + const timingsAll: Record = {}; + try { + // Le périmètre fournisseur est posé une fois pour toutes + // avant la première passe : il survit aux sélections + // Année/Date, qui portent sur d'autres champs. + if (!parFournisseur) { + await timed("clearCodeAll", "Clear", () => rpc("Clear", {}, fh), timingsAll); + await sleep(qlikSettleMs); + await timed("selectCodeAll", "SelectValues", () => rpc("SelectValues", { + qFieldValues: codes.map((c) => ({ qText: c })), + qToggleMode: false, + qSoftLock: true, + }, fh), timingsAll); + await sleep(qlikSettleMs); + } + const got = await timed("cube", "all", () => fetchMonthCube(onRowMois, timingsAll), timingsAll); + aggBatches++; + console.log( + "[qlik-pw][timing] (mois) chemin rapide — " + + (parFournisseur ? "périmètre fournisseur (0 sélection code)" : codes.length + " codes en 1 sélection") + + ", rows=" + got.value + + " cumul=" + out.length + " timings=" + timingSummary(timingsAll), + ); + return; + } catch (e) { + // Les lignes déjà lues seraient recomptées par le repli : + // on les retire (les écritures mensuelles, elles, sont + // des affectations idempotentes). + out.length = marqueOut; + console.log("[qlik-pw] (mois) chemin rapide échoué → repli par lots : " + String((e as Error)?.message || e)); + } + } + // Lots plus petits = cubes plus légers (moins de pression mémoire côté // moteur Qlik, qui a tendance à saturer "Out of memory" sur les gros cubes). const fallbackSizes = [300, 150, 75]; @@ -470,34 +1897,10 @@ export async function fetchNetworkMetricsPlaywright( await timed("selectCode", "SelectValues", () => rpc("SelectValues", { qFieldValues: batch.map((c) => ({ qText: c })), qToggleMode: false, qSoftLock: true }, fh), timings); await sleep(qlikSettleMs); } - const got = await timed("cube", "batch", () => fetchMonthCube((code, mois, ca, qte, nbMag, caMag, marge, qteComp) => { - if (!code || code === "-") return; - // Les totaux réseau ne viennent QUE de la passe principale - // (sinon on doublerait CA/Qté avec la passe N-1). - if (isMainPass) out.push([code, ca, qte, nbMag, caMag, marge]); - const mm = normMois(mois); - const y = parseInt(mm.slice(0, 4), 10); - if (!loggedSampleRow) { - loggedSampleRow = true; - console.log("[qlik-pw][mesures] échantillon ligne mensuelle: " + JSON.stringify({ - code, mois: mm, "CA N": ca, "Quantité N (utilisée pour la courbe)": qte, - "Magasin Ventes Nb N": nbMag, "CA par Magasin N": caMag, "Marge % N": marge, "Quantité COMP": qteComp, - })); - } - (monthlyByCode[code] ??= {}); - if (isMainPass && y === yearN) { - // Mois de l'année N + son comparable N-1 (COMP = même mois, N-1). - monthlyByCode[code][mm] = qte; - const mmPrev = String(y - 1) + mm.slice(4); // "2026-03" → "2025-03" - if (monthlyByCode[code][mmPrev] === undefined) monthlyByCode[code][mmPrev] = qteComp; - } else { - // Passe année sélectionnée : valeur directe, prioritaire sur COMP. - monthlyByCode[code][mm] = qte; - } - }, timings, () => { code15Retries++; }), timings); + const got = await timed("cube", "batch", () => fetchMonthCube(onRowMois, timings, () => { code15Retries++; }), timings); aggBatches++; aggCode15Retries += code15Retries; - console.log("[qlik-pw][timing] " + bp + " — rows=" + got + " cumul=" + out.length + " retries15=" + code15Retries + " timings=" + timingSummary(timings)); + console.log("[qlik-pw][timing] " + bp + " — rows=" + got.value + " cumul=" + out.length + " retries15=" + code15Retries + " timings=" + timingSummary(timings)); if (!codes.length) break; codeIndex += batch.length; fallbackIndex = 0; @@ -581,25 +1984,152 @@ export async function fetchNetworkMetricsPlaywright( } }; - if (!noDateMode && monthDim) { - // Découpage mensuel réel (dimension Mois) — remplace l'itération par mois. - console.log("[qlik-pw] extraction via dimension Mois (" + monthlyPayload.length + " mois attendus, moisDim=" + moisDimId + ")"); - await monthDimPath(null); - // Passe complémentaire : mois de l'année N-1 absents de l'année N - // (ex. août→déc), impossibles à obtenir via COMP. On sélectionne - // l'année N-1 pour que les mesures "N" portent sur cette année. - if (yfh !== -1) { - const before = Object.values(monthlyByCode).reduce((s, m) => s + Object.keys(m).length, 0); - try { - await monthDimPath(yearN - 1); - const after = Object.values(monthlyByCode).reduce((s, m) => s + Object.keys(m).length, 0); - console.log("[qlik-pw] (mois) passe N-1 terminée : " + before + " → " + after + " points mensuels"); - } catch (e2) { - console.log("[qlik-pw] (mois) passe N-1 ignorée: " + String((e2 as Error)?.message || e2)); + // La passe de « rattrapage des mois vides » a été SUPPRIMÉE. + // + // Elle ré-extrayait un mois vide en ne sélectionnant que ses dates. + // Or les master measures ignorent la sélection Date : le cube lui + // renvoyait le total de période, qu'elle recopiait dans chacun des + // mois manquants. D'où les plateaux identiques d'août à décembre + // (87 648 sur douze mois pour un article, etc.) et les tendances + // « -162 % » qu'ils fabriquaient. + // + // Un mois qu'on ne sait pas extraire doit rester ABSENT. + + /** Pousse l'état courant vers Node (voir `cfCheckpoint`). */ + const pousserCheckpoint = async (etape: string) => { + try { + const fn = (window as unknown as { cfCheckpoint?: (p: unknown) => Promise }).cfCheckpoint; + if (!fn) return; + await fn({ rows: out, monthly: monthlyByCode }); + console.log("[qlik-pw] point de contrôle « " + etape + " » : " + out.length + " lignes"); + } catch (e) { + console.log("[qlik-pw] point de contrôle « " + etape + " » ignoré : " + String((e as Error)?.message || e)); + } + }; + + if (!noDateMode) { + // ÉTAPE 0 — poser le périmètre articles, une fois pour toutes. + // + // Le code fournisseur FF (base SQL / API) existe aussi dans Qlik : + // une seule valeur y désigne le même périmètre que les dizaines de + // milliers de codes articles qu'on envoyait jusqu'ici. La bascule + // n'est retenue que si la couverture mesurée le justifie, sinon on + // retombe sur la sélection par codes (cf. selectionnerParFournisseur). + parFournisseur = await selectionnerParFournisseur(); + + const attendus = monthlyPayload.map((m) => m.label); + // Années de la fenêtre, la PLUS RÉCENTE D'ABORD — c'est l'usage de + // l'app (« je sélectionne 2026 »), et cela met les mois les plus + // utiles en tête des points de contrôle. + const annees = [...new Set(attendus.map((m) => Number(m.slice(0, 4))))].sort((a, b) => b - a); + + // ÉTAPE 1 — caler le champ `Période` du modèle sur la fenêtre. + // + // Toutes les mesures de l'app sont bornées par `Type_Cal` + // (`Sum({} quantite)`), lui-même piloté par + // `Période`. C'est cette sélection — et elle seule — qui détermine + // quels mois sont atteignables. Sous la période par défaut, + // « Quantité N » ne couvre que l'année en cours : d'où les mois + // d'août à décembre vides quelle que soit la sélection de dates. + const couvertureMaster = await choisirPeriode(annees); + + // ÉTAPE 2 — extraire, puis REFUSER tout résultat incomplet. + // + // Un cache partiel est pire que pas de cache : il se lit comme des + // ventes nulles. Les deux chemins sont donc validés sur le même + // critère — les 12 mois de la fenêtre, ou rien. + const moisManquants = () => { + const vus = new Set(); + for (const parMois of Object.values(monthlyByCode)) { + for (const [mois, v] of Object.entries(parMois)) { + if ((v?.qte ?? 0) > 0) vus.add(mois); + } } - try { await rpc("Clear", {}, yfh); } catch { /* noop */ } - } else { - console.log("[qlik-pw] (mois) champ Année introuvable → pas de passe N-1"); + return attendus.filter((m) => !vus.has(m)); + }; + const couvre12 = () => moisManquants().length === 0; + + // ÉTAPE 2 — extraire ANNÉE PAR ANNÉE, comme dans l'app. + // + // Dans l'interface, on sélectionne « 2026 » et on obtient l'année + // plus sa comparaison N-1. C'est le mécanisme natif du modèle : + // `Type_Cal='N'` désigne l'année sélectionnée. Il suffit donc + // d'enchaîner les années couvertes par la fenêtre — ici 2025 puis + // 2026 — pour reconstituer les 12 mois glissants. + // + // Chaque passe est faite SOUS la `Période` retenue : c'est elle qui + // donne son sens à `Type_Cal='N'`. Avec `Année=2025`, « Année à + // date » vaut l'année 2025 entière (elle est passée) ; avec + // `Année=2026`, elle s'arrête au mois courant. L'union des deux + // passes reconstitue donc bien les 12 mois glissants. + diag("extraction année par année : " + JSON.stringify(annees)); + + let extraitParAnnee = false; + if (monthDim) { + try { + for (const annee of annees) { + const manquantsAvant = moisManquants(); + await monthDimPath(annee); + const manquantsApres = moisManquants(); + const gagnes = manquantsAvant.filter((m) => !manquantsApres.includes(m)); + diag( + " année " + annee + " → mois de la fenêtre désormais servis : " + JSON.stringify(gagnes) + + " ; encore manquants : " + JSON.stringify(manquantsApres), + ); + await pousserCheckpoint("année " + annee); + if (manquantsApres.length === 0) break; // fenêtre complète, inutile de continuer + } + extraitParAnnee = couvre12(); + if (!extraitParAnnee) { + diag( + "l'extraction année par année ne couvre pas les 12 mois (manquants : " + + JSON.stringify(moisManquants()) + ") — essai de l'agrégation directe", + ); + } + } catch (e) { + diag("extraction année par année interrompue : " + String((e as Error)?.message || e)); + } + } + + // REPLI 1 — mois par mois sur le champ date brut. + // + // Passe en premier car c'est le seul chemin sans pont de périodes : + // aucun mois n'y est « inatteignable », puisqu'un mois y est un + // filtre de dates et non un contexte de calendrier. + if (!extraitParAnnee && useExpr) { + try { + if (await moisParDateBrute()) { + extraitParAnnee = couvre12(); + if (extraitParAnnee) { + diag("extraction par date brute retenue — les " + attendus.length + " mois sont servis"); + await pousserCheckpoint("date brute complète"); + } else { + diag("extraction par date brute incomplète (manquants : " + JSON.stringify(moisManquants()) + ")"); + } + } + } catch (e) { + diag("extraction par date brute interrompue : " + String((e as Error)?.message || e)); + } + } + + if (!extraitParAnnee) { + if (!(monthDim && useExpr && (await moisParExpressions()))) { + throw new Error( + "extraction 12 mois refusée : ni l'extraction année par année " + + "(couverture master annoncée = " + couvertureMaster + "/" + attendus.length + ") " + + "ni l'agrégation directe des faits n'ont couvert la fenêtre — mois manquants : " + + JSON.stringify(moisManquants()), + ); + } + await pousserCheckpoint("expressions vérifiées"); + } + + if (!couvre12()) { + throw new Error( + "extraction 12 mois refusée : la fenêtre " + JSON.stringify(attendus) + + " n'est pas entièrement couverte (manquants : " + JSON.stringify(moisManquants()) + + ") — cache laissé inchangé", + ); } } else if (noDateMode) { // Aucun filtre Date : extraction en un seul appel (tous les codes d'un coup). @@ -719,42 +2249,19 @@ export async function fetchNetworkMetricsPlaywright( } } - // SONDE (à la FIN pour apparaître dans la queue du log) : sélectionne ~50 - // codes + la fenêtre 12 mois, puis crée un petit cube [Article Code, Mois] - // × Quantité N. Montre le FORMAT des valeurs "Mois" et si la quantité VARIE - // par mois, pour concevoir le vrai découpage mensuel. - try { - const moisDimId = "pfGAwTs"; - const probeCodes = (codes ?? []).slice(0, 50); - if (fh !== -1 && probeCodes.length) { - await rpc("Clear", {}, fh); - await rpc("SelectValues", { qFieldValues: probeCodes.map((c) => ({ qText: c })), qToggleMode: false, qSoftLock: true }, fh); - } - const allSerials = monthlyPayload.flatMap((m) => m.serials); - if (dfh !== -1 && allSerials.length) { - await rpc("SelectValues", { qFieldValues: allSerials.map((s) => ({ qNum: s, qText: String(s) })), qToggleMode: false, qSoftLock: true }, dfh); - } - await sleep(qlikSettleMs); - const probe = await rpc("CreateSessionObject", { qProp: { qInfo: { qType: "cf-probe" }, qHyperCubeDef: { - qDimensions: [{ qLibraryId: dim }, { qLibraryId: moisDimId }], - qMeasures: [{ qLibraryId: mqte }], - qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 3, qHeight: 40 }], - } } }, doc); - const pRet = probe.qReturn as { qHandle: number; qGenericId?: string; qId?: string }; - const pl = await rpc("GetLayout", {}, pRet.qHandle); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const hc = (pl.qLayout as any)?.qHyperCube; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const matrix: any[] = hc?.qDataPages?.[0]?.qMatrix ?? []; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const sample = matrix.slice(0, 40).map((row: any[]) => [row[0]?.qText, row[1]?.qText, row[2]?.qNum]); - console.log("[qlik-pw][probe] cube [Article Code, Mois] x Quantite N — size=" + (hc?.qSize?.qcy ?? "?") + " echantillon: " + JSON.stringify(sample)); - try { await rpc("DestroySessionObject", { qId: String(pRet.qGenericId ?? pRet.qId ?? "") }, doc); } catch { /* noop */ } - } catch (e) { console.log("[qlik-pw][probe] error: " + String((e as Error)?.message || e)); } + // La sonde de diagnostic du cube [Article Code, Mois] a été retirée : + // elle refaisait une sélection complète des codes et des 365 jours en + // fin de sync (des dizaines de secondes) alors que le découpage + // mensuel est validé et en production depuis. ws.close(); - resolve({ ok: true, rows: out, monthly: monthlyByCode, size: out.length, months: aggMonths, batches: aggBatches, code15Retries: aggCode15Retries, fallbackCount: aggFallbackCount }); - } catch (e) { try { ws.close(); } catch { /* noop */ } fail(String((e as Error)?.message || e)); } + resolve({ ok: true, rows: out, monthly: monthlyByCode, size: out.length, months: aggMonths, batches: aggBatches, code15Retries: aggCode15Retries, fallbackCount: aggFallbackCount, diagExpr: diagnostics }); + } catch (e) { + try { ws.close(); } catch { /* noop */ } + // On remonte ce qui a été extrait : les codes déjà traités ont + // toutes leurs données, seul le reliquat manque. + resolve({ ok: false, error: String((e as Error)?.message || e), rows: out, monthly: monthlyByCode, size: out.length, diagExpr: diagnostics }); + } })(); }), { @@ -766,7 +2273,7 @@ export async function fetchNetworkMetricsPlaywright( mcamag: cfg.measCaMagId, mmarge: cfg.measMargePctId, codes: codeCentraux ?? [], - token: csrfToken, + token: jeton, monthlyPayload, noDateMode: !dateFilter, qlikSettleMs, @@ -777,15 +2284,65 @@ export async function fetchNetworkMetricsPlaywright( moisDimId: qlikMoisDimId, mqteComp: qlikMeasQteCompId, yearN, + useExpr: qlikUseExpr, + exprQte: qlikExprQte, + exprCa: qlikExprCa, + exprNbMag: qlikExprNbMag, + exprMarge: qlikExprMarge, + champsDateCandidats: qlikDateFields, + fournisseurCode: (fournisseur ?? "").trim(), + champsFournisseurCandidats: qlikSupplierFields, + couvertureFournisseurMin: qlikSupplierMinCoverage, }, - )) as InPageResult; + ); - if (!result.ok) throw new Error(`[qlik-pw] ${result.error}`); + let result: InPageResult; + try { + result = (await evaluer()) as InPageResult; + } catch (e) { + const msg = String((e as Error)?.message || e); + // Une fenêtre 12 mois n'est publiable que si l'extraction est + // complète et a passé tous les contrôles. Conserver un checkpoint + // partiel fabriquerait précisément des graphiques à 2 ou 3 mois. + if (dateFilter) { + throw new Error(`[qlik-pw] extraction 12 mois interrompue, cache inchangé : ${msg}`); + } + const secours = checkpoint as { rows?: Array>; monthly?: InPageResult["monthly"] } | null; + if (secours?.rows?.length) { + console.error(`[qlik-pw] extraction interrompue (${msg.slice(0, 200)}) — reprise du dernier point de contrôle : ${secours.rows.length} lignes`); + result = { ok: true, rows: secours.rows, monthly: secours.monthly, size: secours.rows.length, partiel: true }; + } else { + throw e; + } + } + + if (!result.ok) { + if (dateFilter) { + throw new Error(`[qlik-pw] extraction 12 mois refusée, cache inchangé : ${result.error}`); + } + // Même logique qu'un crash : un résultat partiel vaut mieux que rien. + if (result.rows?.length) { + console.error(`[qlik-pw] extraction en échec (${String(result.error).slice(0, 200)}) — ${result.rows.length} lignes déjà extraites conservées`); + result = { ...result, ok: true, partiel: true }; + } else { + throw new Error(`[qlik-pw] ${result.error}`); + } + } + if (result.partiel) { + if (dateFilter) { + throw new Error("[qlik-pw] extraction 12 mois partielle refusée, cache inchangé"); + } + console.warn("[qlik-pw] ⚠ résultat PARTIEL : certains codes n'ont pas été extraits, relancer la sync pour compléter"); + } // Résumé timing final : synthèse compacte de la sync pour audit/debug. // Les logs détaillés par étape restent en place (timings batch/mois) ; // ici on consolide les compteurs agrégés et la durée totale. const totalMs = Date.now() - totalStart; + if (result.diagExpr?.length) { + console.log("[qlik-pw][diag] ─── diagnostic du chemin par expressions ───"); + for (const d of result.diagExpr) console.log("[qlik-pw][diag] " + d); + } console.log( `[qlik-pw][summary] rows=${result.size ?? 0} months=${result.months ?? 0} batches=${result.batches ?? 0} code15Retries=${result.code15Retries ?? 0} fallbackCount=${result.fallbackCount ?? 0} totalMs=${totalMs}`, ); @@ -826,11 +2383,43 @@ export async function fetchNetworkMetricsPlaywright( prev.caParMagasinReseau = prev.nbMagasinsReseau > 0 ? prev.caReseau / prev.nbMagasinsReseau : 0; margeWeight.set(code, nextWeight); } - // Rattache la quantité mensuelle (par code central) au métrique agrégé. + // Rattache le détail mensuel (par code central) au métrique agrégé. + // On produit DEUX représentations : + // - `qteByMonth` : { "YYYY-MM": qté } — format historique, dont dépend la + // colonne « Tendance / Réseau » de la Grille. Inchangé. + // - `metricsByMonth` : toutes les mesures du mois, pour la fiche produit. const monthly = result.monthly ?? {}; + + // Mois RÉELLEMENT couverts par l'extraction = union des mois vus sur + // l'ensemble des codes. Distinguer « couvert, zéro vente » de « non + // extrait » est indispensable : sans cela, un article vendu seulement + // depuis mai voyait sa tendance calculée sur deux points (« +4 100 % »), + // et un mois non extrait passait pour une absence de vente. + const couverts = new Set(); + for (const parMois of Object.values(monthly)) { + for (const mois of Object.keys(parMois)) couverts.add(mois); + } + const moisCouverts = [...couverts].sort(); + console.log(`[qlik-pw] mois couverts par l'extraction (${moisCouverts.length}) : ${JSON.stringify(moisCouverts)}`); + for (const [code, metric] of out) { - const mm = monthly[code]; - if (mm && Object.keys(mm).length) metric.qteByMonth = mm; + const mm = monthly[code] ?? {}; + const qteByMonth: Record = {}; + const metricsByMonth: Record = {}; + for (const mois of moisCouverts) { + const v = mm[mois]; + // Zéro EXPLICITE sur un mois couvert sans vente : c'est une + // information, au même titre qu'une vente. Un mois non couvert + // reste absent et sera ignoré par la tendance. + qteByMonth[mois] = Number(v?.qte) || 0; + // ⚠️ Toutes les mesures à zéro, pas seulement la quantité. Écrire + // `{ qte: 0 }` seul laissait `nbMag` absent sur les mois sans + // vente : la courbe « magasins vendeurs » exigeant les 12 mois, + // elle disparaissait dès qu'un produit avait un mois creux. + metricsByMonth[mois] = v ?? { qte: 0, ca: 0, nbMag: 0, caMag: 0, margePct: 0 }; + } + metric.qteByMonth = qteByMonth; + metric.metricsByMonth = metricsByMonth; } // Diagnostic CONCLUSIF : combien de codes ont des mois qui VARIENT vs identiques. // Si presque tous varient → la mesure respecte le mois (feature OK). @@ -860,471 +2449,3 @@ export async function fetchNetworkMetricsPlaywright( await ctx.close(); } } - -// ─────────────────────────────────────────────────────────────────────────────── -// Recherche produits dans le CATALOGUE RÉSEAU Qlik -// ─────────────────────────────────────────────────────────────────────────────── -// -// `fetchNetworkMetricsPlaywright` part de NOS codes centraux : elle ne peut donc -// jamais révéler un produit du réseau que FF Nancy ne référence pas. La recherche -// ci-dessous fait l'inverse — elle interroge Qlik d'abord : -// -// • mode "code" → SelectValues sur le champ « Article Code » (un ou plusieurs codes) -// • mode "label" → recherche plein texte sur la dimension « Article » via -// SearchListObjectFor + AcceptListObjectSearch (équivalent d'une -// saisie dans un filtre Qlik : tous les mots doivent matcher). -// -// Le mode "label" évite d'avoir à deviner le nom exact du champ libellé : on tente -// d'abord la master dimension par qLibraryId, puis une liste de noms de champs. - -/** Résultat d'une recherche réseau. */ -export interface NetworkSearchResult { - products: NetworkProduct[]; - /** Nb de valeurs « Article » trouvées par Qlik (mode label), ou nb de codes demandés. */ - matchCount: number; - /** true si la recherche a été refusée car trop large (matchCount > maxProducts). */ - tooMany: boolean; - mode: "code" | "label"; - periode?: string; -} - -interface InPageSearchResult { - ok: boolean; - /** [code, libelle, ca, qte, nbMag, caMag, margePct] — passe principale uniquement. */ - rows?: Array>; - monthly?: Record>; - meta?: Record; - matchCount?: number; - tooMany?: boolean; - error?: string; -} - -/** Découpe la saisie utilisateur en codes centraux si elle n'en contient QUE. */ -function parseCodeQuery(query: string): string[] | null { - const parts = query.trim().split(/[\s,;]+/).filter(Boolean); - if (parts.length === 0) return null; - // Un code centrale FF fait 11 chiffres (10000XXXXXX) ; on accepte 5+ chiffres - // pour tolérer des saisies partielles côté centrale. - if (!parts.every((p) => /^\d{5,}$/.test(p))) return null; - return [...new Set(parts)]; -} - -/** - * Cherche des produits dans le catalogue réseau Qlik par libellé ou par code centrale, - * et renvoie leurs métriques réseau + les quantités mois par mois (12 mois glissants). - */ -export async function searchNetworkProductsPlaywright( - query: string, - opts: { maxProducts?: number } = {}, - cfg: QlikConfig = getQlikConfig(), - dateFilter: QlikDateFilter | null = buildGridNetworkQlikDateFilter(), -): Promise { - if (!cfg.appNetwork) throw new Error("[qlik-pw] QLIK_APP_NETWORK manquant"); - if (!cfg.user || !cfg.password) throw new Error("[qlik-pw] identifiants Qlik manquants"); - const trimmed = query.trim(); - if (!trimmed) throw new Error("[qlik-pw] recherche vide"); - - const totalStart = Date.now(); - const codes = parseCodeQuery(trimmed); - const mode: "code" | "label" = codes ? "code" : "label"; - const maxProducts = Math.min(2000, Math.max(1, opts.maxProducts ?? envNumber("QLIK_SEARCH_MAX_PRODUCTS", 300, 1, 2000))); - const qlikSettleMs = envNumber("QLIK_SETTLE_MS", 150, 0, 2000); - const qlikCode15MaxAttempts = envNumber("QLIK_CODE15_MAX_ATTEMPTS", 4, 1, 8); - const qlikMoisDimId = (process.env.QLIK_MOIS_DIM_ID ?? "pfGAwTs").trim(); - const qlikMeasQteCompId = (process.env.QLIK_MEAS_QTE_COMP_ID ?? "96862880-76cd-4957-bf25-b96901f2ac5f").trim(); - const qlikDimArticleLabelId = (process.env.QLIK_DIM_ARTICLE_LABEL_ID ?? "BcFj").trim(); - // Noms de champs candidats pour le libellé article (repli si la master dimension échoue). - const labelFieldCandidates = [ - ...(process.env.QLIK_FIELD_ARTICLE_LABEL ? [process.env.QLIK_FIELD_ARTICLE_LABEL.trim()] : []), - "Article", "Libellé Article", "Libelle Article", "Article Libellé", "Désignation", - ]; - const yearN = new Date().getFullYear(); - - console.log(`[qlik-pw][search] mode=${mode} query="${trimmed}" max=${maxProducts}${dateFilter ? ` fenêtre=${dateFilter.label}` : ""}`); - 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) => console.log(`[qlik-pw][search][page] ${msg.text()}`)); - page.on("pageerror", (e) => console.log(`[qlik-pw][search][pageerror] ${e.message}`)); - - let csrfToken: string | null = null; - let resolveToken: (() => void) | null = null; - const tokenReady = new Promise((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 ouvre son ws ensuite */ }); - await Promise.race([tokenReady, page.waitForTimeout(15000)]); - if (!csrfToken) throw new Error("[qlik-pw] qlik-csrf-token introuvable (client Qlik non chargé ?)"); - - const allSerials = dateFilter ? getMonthRanges(dateFilter).flatMap((m) => m.dailySerials) : []; - - const result = (await page.evaluate( - ({ app, token, dimCode, dimLabelId, labelFields, mca, mqte, mnb, mcamag, mmarge, mqteComp, moisDimId, mode, codes, match, allSerials, maxProducts, settleMs, code15Attempts, yearN }: { - app: string; token: string; dimCode: string; dimLabelId: string; labelFields: string[]; - mca: string; mqte: string; mnb: string; mcamag: string; mmarge: string; mqteComp: string; - moisDimId: string; mode: "code" | "label"; codes: string[]; match: string; - allSerials: number[]; maxProducts: number; settleMs: number; code15Attempts: number; yearN: number; - }) => - new Promise((resolve) => { - 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); - let id = 0; - const pend = new Map 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 fail = (error: string) => resolve({ ok: false, error }); - const ready = new Promise((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((r) => setTimeout(r, ms)); - const msgOf = (e: unknown) => String((e as Error)?.message ?? e); - const isEngineCode15 = (e: unknown): boolean => /"code"\s*:\s*15\b/.test(msgOf(e)); - const rpcWithRetry = async (etape: string, method: string, params: unknown, handle: number): Promise<{ [k: string]: unknown }> => { - let lastErr: unknown; - for (let attempt = 1; attempt <= code15Attempts; attempt++) { - try { return await rpc(method, params, handle); } - catch (e) { - lastErr = e; - if (!isEngineCode15(e) || attempt === code15Attempts) throw e; - console.log("[search] retry code15 " + etape + ":" + method + " " + attempt + "/" + code15Attempts); - await sleep(settleMs * attempt); - } - } - throw lastErr; - }; - - (async () => { - try { - await ready; - const open = await rpc("OpenDoc", { qDocName: app }); - const doc = (open.qReturn as { qHandle: number }).qHandle; - - // Une recherche doit porter sur TOUT le catalogue : on repart d'un - // état sans sélection (l'app peut en avoir une enregistrée). Non - // fatal si l'Engine refuse. - try { await rpc("ClearAll", { qLockedAlso: false }, doc); await sleep(settleMs); } - catch (e) { console.log("[search] ClearAll ignoré: " + msgOf(e)); } - - // ── Résolution des mesures ET dimensions par TITRE ────────────── - const norm = (s: string) => s.replace(/\s+/g, " ").trim().toLowerCase(); - const listByTitle = async (qType: string, defKey: string, listKey: string): Promise> => { - const map = new Map(); - try { - const o = await rpc("CreateSessionObject", { qProp: { qInfo: { qType }, [defKey]: { qType: qType === "MeasureList" ? "measure" : "dimension", qData: { title: "/qMetaDef/title" } } } }, doc); - const l = await rpc("GetLayout", {}, (o.qReturn as { qHandle: number }).qHandle); - const layout = l.qLayout as Record }>; - for (const it of layout[listKey]?.qItems ?? []) { - const t = it.qMeta?.title ?? it.qData?.title ?? ""; - if (t) map.set(norm(t), it.qInfo.qId); - } - } catch (e) { console.log("[search] " + qType + " KO: " + msgOf(e)); } - return map; - }; - const measByTitle = await listByTitle("MeasureList", "qMeasureListDef", "qMeasureList"); - const dimByTitle = await listByTitle("DimensionList", "qDimensionListDef", "qDimensionList"); - const pickMeas = (title: string, fallback: string) => measByTitle.get(norm(title)) ?? fallback; - const rCa = pickMeas("CA N", mca); - const rQte = pickMeas("Quantité N", mqte); - const rNbMag = pickMeas("Magasin Ventes Nb N", mnb); - const rCamag = pickMeas("CA par Magasin N", mcamag); - const rMarge = pickMeas("Marge % N", mmarge); - const dimLabel = dimByTitle.get(norm("Article")) ?? dimLabelId; - const dimFou = dimByTitle.get(norm("Fournisseur")) ?? null; - const dimFam = dimByTitle.get(norm("Famille")) ?? null; - console.log("[search] dims: label=" + dimLabel + " fou=" + dimFou + " fam=" + dimFam); - - // ── Champs Date / Année ───────────────────────────────────────── - let dfh = -1, yfh = -1; - try { dfh = ((await rpc("GetField", { qFieldName: "Date" }, doc)).qReturn as { qHandle: number }).qHandle; } catch { dfh = -1; } - try { yfh = ((await rpc("GetField", { qFieldName: "Année" }, doc)).qReturn as { qHandle: number }).qHandle; } catch { yfh = -1; } - - // ── Sélection des articles recherchés ─────────────────────────── - let matchCount = -1; - if (mode === "code") { - const fh = ((await rpc("GetField", { qFieldName: "Article Code" }, doc)).qReturn as { qHandle: number }).qHandle; - await rpc("Clear", {}, fh); - await sleep(settleMs); - const sel = await rpc("SelectValues", { qFieldValues: codes.map((c) => ({ qText: c })), qToggleMode: false, qSoftLock: true }, fh); - console.log("[search] SelectValues Article Code (" + codes.length + ") → " + JSON.stringify(sel.qReturn)); - matchCount = codes.length; - await sleep(settleMs); - } else { - // Liste de recherche sur le libellé : master dimension d'abord, - // puis repli sur des noms de champs plausibles. - const defs: Array<{ label: string; def: Record }> = []; - // qHeight: 1 — on ne veut que la taille du résultat de recherche, - // mais certains Engine rejettent un qHeight nul. - const fetchDef = [{ qTop: 0, qLeft: 0, qWidth: 1, qHeight: 1 }]; - if (dimLabel) defs.push({ label: "libraryId:" + dimLabel, def: { qLibraryId: dimLabel, qInitialDataFetch: fetchDef } }); - for (const f of labelFields) defs.push({ label: "field:" + f, def: { qDef: { qFieldDefs: [f] }, qInitialDataFetch: fetchDef } }); - let lo: { handle: number; id: string } | null = null; - for (const d of defs) { - try { - const o = await rpc("CreateSessionObject", { qProp: { qInfo: { qType: "cf-search-lb" }, qListObjectDef: d.def } }, doc); - const ret = o.qReturn as { qHandle: number; qGenericId?: string; qId?: string }; - await rpc("GetLayout", {}, ret.qHandle); // valide que la définition tient - lo = { handle: ret.qHandle, id: String(ret.qGenericId ?? ret.qId ?? "") }; - console.log("[search] listObject OK via " + d.label); - break; - } catch (e) { console.log("[search] listObject KO via " + d.label + ": " + msgOf(e)); } - } - if (!lo) { ws.close(); return fail("impossible de construire la recherche sur le libellé article (dimension « Article » introuvable)"); } - - await rpcWithRetry("search", "SearchListObjectFor", { qPath: "/qListObjectDef", qMatch: match }, lo.handle); - await sleep(settleMs); - try { - const lol = await rpcWithRetry("searchLayout", "GetLayout", {}, lo.handle); - const cy = (lol.qLayout as { qListObject?: { qSize?: { qcy?: number } } })?.qListObject?.qSize?.qcy; - matchCount = typeof cy === "number" ? cy : -1; - } catch (e) { console.log("[search] layout recherche KO: " + msgOf(e)); } - console.log("[search] SearchListObjectFor \"" + match + "\" → " + matchCount + " valeur(s)"); - // Garde-fou : une recherche trop large ferait exploser le cube - // (et la mémoire du moteur Qlik, déjà sensible). - if (matchCount > maxProducts) { - try { if (lo.id) await rpc("DestroySessionObject", { qId: lo.id }, doc); } catch { /* noop */ } - ws.close(); - return resolve({ ok: true, tooMany: true, matchCount, rows: [], monthly: {}, meta: {} }); - } - await rpcWithRetry("accept", "AcceptListObjectSearch", { qPath: "/qListObjectDef", qToggleMode: false, qSoftLock: true }, lo.handle); - await sleep(settleMs); - try { if (lo.id) await rpc("DestroySessionObject", { qId: lo.id }, doc); } catch { /* noop */ } - } - - // ── Cube mensuel [Article Code, Article, Mois] × 6 mesures ────── - const normMois = (m: string): string => (/^\d{6}$/.test(m) ? m.slice(0, 4) + "-" + m.slice(4) : m); - const PAGEM = 1000; // 9 colonnes × 1000 = 9000 cellules < 10000 (limite Qlik) - const rows: Array> = []; - const monthlyByCode: Record> = {}; - - const fetchMonthCube = async (isMainPass: boolean): Promise => { - const o = await rpcWithRetry("createCube", "CreateSessionObject", { qProp: { qInfo: { qType: "cf-search-mois" }, qHyperCubeDef: { - qDimensions: [{ qLibraryId: dimCode }, { qLibraryId: dimLabel }, { qLibraryId: moisDimId }], - qMeasures: [{ qLibraryId: rCa }, { qLibraryId: rQte }, { qLibraryId: rNbMag }, { qLibraryId: rCamag }, { qLibraryId: rMarge }, { qLibraryId: mqteComp }], - qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 9, qHeight: PAGEM }], - } } }, doc); - const ret = o.qReturn as { qHandle: number; qGenericId?: string; qId?: string }; - const cubeId = String(ret.qGenericId ?? ret.qId ?? ""); - const onRow = (r: Array<{ qText?: string; qNum?: number }>) => { - const code = String(r[0]?.qText ?? "").trim(); - if (!code || code === "-") return; - const lib = String(r[1]?.qText ?? "").trim(); - const mm = normMois(String(r[2]?.qText ?? "")); - const ca = Number(r[3]?.qNum) || 0, qte = Number(r[4]?.qNum) || 0, nbMag = Number(r[5]?.qNum) || 0; - const caMag = Number(r[6]?.qNum) || 0, marge = Number(r[7]?.qNum) || 0, qteComp = Number(r[8]?.qNum) || 0; - // Les totaux réseau ne viennent QUE de la passe principale - // (sinon la passe N-1 doublerait CA/Qté). - if (isMainPass) rows.push([code, lib, ca, qte, nbMag, caMag, marge]); - const y = parseInt(mm.slice(0, 4), 10); - (monthlyByCode[code] ??= {}); - if (isMainPass && y === yearN) { - monthlyByCode[code][mm] = qte; - // COMP = même mois de l'année N-1 : comble la 1re moitié des 12 mois. - const mmPrev = String(y - 1) + mm.slice(4); - if (monthlyByCode[code][mmPrev] === undefined) monthlyByCode[code][mmPrev] = qteComp; - } else { - // Passe année N-1 : valeur directe, prioritaire sur COMP. - monthlyByCode[code][mm] = qte; - } - }; - try { - const layout = await rpcWithRetry("layout", "GetLayout", {}, ret.qHandle); - const hc = (layout.qLayout as { qHyperCube: { qSize: { qcy: number }; qDataPages?: Array<{ qArea?: { qTop?: number }; qMatrix?: Array> }> } }).qHyperCube; - const size = hc.qSize.qcy; - let got = 0, top = 0; - const first = hc.qDataPages?.find((p) => (p.qArea?.qTop ?? 0) === 0); - if (first?.qMatrix?.length) { for (const r of first.qMatrix) onRow(r); got += first.qMatrix.length; top = PAGEM; } - for (; top < size; top += PAGEM) { - const d = await rpcWithRetry("getCubeData", "GetHyperCubeData", { qPath: "/qHyperCubeDef", qPages: [{ qTop: top, qLeft: 0, qWidth: 9, qHeight: PAGEM }] }, ret.qHandle); - const matrix = (d.qDataPages as Array<{ qMatrix?: Array> }>)?.[0]?.qMatrix ?? []; - if (!matrix.length) break; - for (const r of matrix) onRow(r); - got += matrix.length; - if (matrix.length < PAGEM) break; - } - return got; - } finally { - try { if (cubeId) await rpc("DestroySessionObject", { qId: cubeId }, doc); } catch { /* noop */ } - } - }; - - // Une passe = (Année ?) + fenêtre Date 12 mois + cube mensuel. - // La sélection article posée plus haut reste active : on ne touche - // qu'aux champs Année / Date. - const runPass = async (selectYear: number | null): Promise => { - if (yfh !== -1) { - await rpc("Clear", {}, yfh); - await sleep(settleMs); - if (selectYear !== null) { - await rpc("SelectValues", { qFieldValues: [{ qNum: selectYear, qText: String(selectYear) }], qToggleMode: false, qSoftLock: true }, yfh); - await sleep(settleMs); - } - } - if (dfh !== -1 && allSerials.length) { - await rpc("Clear", {}, dfh); - await sleep(settleMs); - await rpc("SelectValues", { qFieldValues: allSerials.map((s) => ({ qNum: s, qText: String(s) })), qToggleMode: false, qSoftLock: true }, dfh); - await sleep(settleMs); - } - return fetchMonthCube(selectYear === null); - }; - - const gotMain = await runPass(null); - console.log("[search] passe principale — " + gotMain + " lignes, " + Object.keys(monthlyByCode).length + " codes"); - if (yfh !== -1) { - // Passe complémentaire : mois de N-1 hors de portée de COMP - // (ex. août→déc), indispensable pour 12 mois contigus. - try { - const gotPrev = await runPass(yearN - 1); - console.log("[search] passe N-1 — " + gotPrev + " lignes"); - } catch (e) { console.log("[search] passe N-1 ignorée: " + msgOf(e)); } - try { await rpc("Clear", {}, yfh); await sleep(settleMs); } catch { /* noop */ } - } - - // ── Métadonnées (fournisseur / famille) — best effort ─────────── - const meta: Record = {}; - if (dimFou || dimFam) { - try { - const extra: string[] = []; - if (dimFou) extra.push(dimFou); - if (dimFam) extra.push(dimFam); - const width = 1 + extra.length; - const o = await rpcWithRetry("metaCube", "CreateSessionObject", { qProp: { qInfo: { qType: "cf-search-meta" }, qHyperCubeDef: { - qDimensions: [{ qLibraryId: dimCode }, ...extra.map((q) => ({ qLibraryId: q }))], - qMeasures: [], - qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: width, qHeight: 1500 }], - } } }, doc); - const ret = o.qReturn as { qHandle: number; qGenericId?: string; qId?: string }; - const layout = await rpcWithRetry("metaLayout", "GetLayout", {}, ret.qHandle); - const hc = (layout.qLayout as { qHyperCube: { qDataPages?: Array<{ qMatrix?: Array> }> } }).qHyperCube; - for (const r of hc.qDataPages?.[0]?.qMatrix ?? []) { - const code = String(r[0]?.qText ?? "").trim(); - if (!code || code === "-") continue; - let i = 1; - const entry: { fournisseur?: string; famille?: string } = {}; - if (dimFou) { const v = String(r[i++]?.qText ?? "").trim(); if (v && v !== "-") entry.fournisseur = v; } - if (dimFam) { const v = String(r[i++]?.qText ?? "").trim(); if (v && v !== "-") entry.famille = v; } - meta[code] = entry; - } - try { await rpc("DestroySessionObject", { qId: String(ret.qGenericId ?? ret.qId ?? "") }, doc); } catch { /* noop */ } - } catch (e) { console.log("[search] méta fournisseur/famille ignorée: " + msgOf(e)); } - } - - ws.close(); - resolve({ ok: true, rows, monthly: monthlyByCode, meta, matchCount, tooMany: false }); - } catch (e) { try { ws.close(); } catch { /* noop */ } fail(msgOf(e)); } - })(); - }), - { - app: cfg.appNetwork, - token: csrfToken, - dimCode: cfg.dimCodeArticleId, - dimLabelId: qlikDimArticleLabelId, - labelFields: labelFieldCandidates, - mca: cfg.measCaId, - mqte: cfg.measQteId, - mnb: cfg.measNbMagId, - mcamag: cfg.measCaMagId, - mmarge: cfg.measMargePctId, - mqteComp: qlikMeasQteCompId, - moisDimId: qlikMoisDimId, - mode, - codes: codes ?? [], - match: trimmed, - allSerials, - maxProducts, - settleMs: qlikSettleMs, - code15Attempts: qlikCode15MaxAttempts, - yearN, - }, - )) as InPageSearchResult; - - if (!result.ok) throw new Error(`[qlik-pw][search] ${result.error}`); - const matchCount = result.matchCount ?? -1; - if (result.tooMany) { - console.log(`[qlik-pw][search] refusé — ${matchCount} valeurs > max ${maxProducts}`); - return { products: [], matchCount, tooMany: true, mode, periode: dateFilter?.label }; - } - - // Agrégation par code centrale (même pondération que la sync réseau : - // marge % moyennée par le CA). - const byCode = new Map(); - const margeWeight = new Map(); - for (const r of result.rows ?? []) { - const code = String(r[0]).trim(); - if (!code || code === "-") continue; - const libelle = String(r[1] ?? "").trim(); - const ca = Number(r[2]) || 0; - const qte = Number(r[3]) || 0; - const nbMag = Number(r[4]) || 0; - const margePct = Number(r[6]) || 0; - const prev = byCode.get(code); - if (!prev) { - byCode.set(code, { - codeCentrale: code, - libelle, - caReseau: ca, - qteReseau: qte, - nbMagasinsReseau: nbMag, - caParMagasinReseau: nbMag > 0 ? ca / nbMag : 0, - margePctReseau: margePct, - periode: dateFilter?.label, - }); - margeWeight.set(code, ca); - continue; - } - if (!prev.libelle && libelle) prev.libelle = libelle; - prev.caReseau += ca; - prev.qteReseau += qte; - prev.nbMagasinsReseau = Math.max(prev.nbMagasinsReseau, nbMag); - const weight = margeWeight.get(code) ?? 0; - const nextWeight = weight + ca; - prev.margePctReseau = nextWeight > 0 ? ((prev.margePctReseau * weight) + (margePct * ca)) / nextWeight : Math.max(prev.margePctReseau, margePct); - prev.caParMagasinReseau = prev.nbMagasinsReseau > 0 ? prev.caReseau / prev.nbMagasinsReseau : 0; - margeWeight.set(code, nextWeight); - } - - const monthly = result.monthly ?? {}; - const meta = result.meta ?? {}; - for (const [code, p] of byCode) { - const mm = monthly[code]; - if (mm && Object.keys(mm).length) p.qteByMonth = mm; - const m = meta[code]; - if (m?.fournisseur) p.fournisseur = m.fournisseur; - if (m?.famille) p.famille = m.famille; - } - - const products = [...byCode.values()].sort((a, b) => b.caReseau - a.caReseau); - console.log(`[qlik-pw][search] ${products.length} produits (match=${matchCount}) en ${Date.now() - totalStart}ms`); - return { products, matchCount: matchCount >= 0 ? matchCount : products.length, tooMany: false, mode, periode: dateFilter?.label }; - } finally { - await ctx.close(); - } -} diff --git a/src/lib/qlik-search.ts b/src/lib/qlik-search.ts new file mode 100644 index 0000000..0161cb5 --- /dev/null +++ b/src/lib/qlik-search.ts @@ -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 | null = null; + +async function getBrowser(): Promise { + 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 { + 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((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((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 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((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((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> }>)?.[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(); + 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> }>; + }; + }).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(); + 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(); + } +} diff --git a/src/lib/stale-action.ts b/src/lib/stale-action.ts new file mode 100644 index 0000000..fe1dde6 --- /dev/null +++ b/src/lib/stale-action.ts @@ -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); +} diff --git a/src/types/grid.ts b/src/types/grid.ts index 6b6336a..43f5cc5 100644 --- a/src/types/grid.ts +++ b/src/types/grid.ts @@ -121,6 +121,14 @@ export interface ProductRow { tauxPresenceReseau?: number; /** Quantité vendue réseau par mois : { "YYYY-MM": qté } (tendance / modal). */ qteReseauByMonth?: Record | 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 | null; /** Fraîcheur des données réseau (ISO) */ networkFetchedAt?: string; } diff --git a/src/types/network-search.ts b/src/types/network-search.ts deleted file mode 100644 index c34f78e..0000000 --- a/src/types/network-search.ts +++ /dev/null @@ -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 | 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; -}