Compare commits

...
Author SHA1 Message Date
Claude 585e171708 fix(grid): aligner la fenêtre 12 mois sur les données et tabuler les ventes du modal
Le total « Tot. 12m » est figé côté serveur sur SA fenêtre de 12 mois, tandis
que la Grille et le modal recalculaient les 12 mois depuis l'horloge du
navigateur. Dès que les deux divergeaient (onglet ouvert au changement de
mois, lignes servies depuis le cache), un mois de ventes disparaissait des
colonnes tout en restant compté dans le total : 28 sur la ligne, 20 dans le
modal.

- months.ts : `getMonthsFromRows()` lit la fenêtre dans les clés des séries
  reçues ; `getLast12Months()` ne sert plus que de repli avant chargement.
- heatmap-grid : MONTHS_12 dérivé des lignes chargées.
- product-monthly-modal : le tableau par magasin devient générique et sert
  aussi aux ventes, sous le graphique, avec une colonne de cumul « 12 m »
  qui retombe sur le total de la Grille.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177HLQz6w2hgoXpx6t1Rkn9
2026-09-02 10:43:26 +00:00
Claude 2404b22628 feat(grille): fige les colonnes d'identité à gauche
Sur 2600 px de tableau pour ~1150 px visibles, on voit 8 colonnes sur 18. Dès
qu'on défilait vers les totaux ou les mois, la Désignation sortait de l'écran
et plus rien ne disait quelle ligne on lisait.

Case à cocher, Code interne et Désignation restent désormais collés au bord
gauche pendant le défilement horizontal, en-tête compris.

Deux conséquences de conception :

- La Désignation a été rapprochée du Code interne dans l'ordre des colonnes.
  Le figeage exige des colonnes contiguës depuis la gauche ; en la laissant
  après Référence et GTIN, il aurait fallu geler ces deux-là aussi, soit 636 px
  immobilisés au lieu de 406. L'ordre « code, désignation, puis codes
  secondaires » est de toute façon celui qu'on lit.

- Les cellules figées doivent être OPAQUES, le reste de la ligne glissant
  dessous. Le survol passe donc par une teinte pleine et non par le voile
  translucide des cellules ordinaires, et l'en-tête figé reprend le dégradé du
  thead pour que la bande reste d'un seul tenant. Un filet marque la limite de
  la zone figée.

Les décalages sont calculés par cumul des largeurs réelles et s'arrêtent à la
première colonne non figée : masquer le Code interne réajuste tout seul, et une
colonne figée ne peut jamais se retrouver à flotter au milieu du tableau.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675
2026-08-21 09:45:17 +00:00
Claude 7f5ef93469 fix(grille): défilement horizontal visible et barre du bas dans le flux
Les deux symptômes n'en faisaient qu'un. L'ascenseur horizontal EXISTAIT déjà
(conteneur en overflow-auto, table en min-width ~2600 px), mais il était caché
sous la carte de synthèse.

Celle-ci était en `position: fixed` et ne réservait donc aucune hauteur : seul
un `pb-12` posé sur la page compensait, trop court d'une quarantaine de pixels
— exactement la bande où se trouvent la barre de défilement et la dernière
ligne. Son `left-[264px]` était en prime calé sur une barre latérale dépliée,
alors qu'elle est repliée par défaut : 200 px de vide à gauche, qui écrasaient
ses statistiques en hauteur. Elle est remise dans le flux : la grille lui cède
exactement la place nécessaire, et il n'y a plus de valeur magique à tenir en
phase avec la sidebar.

L'ascenseur devient visible : la règle générale (8 px, rail transparent, pouce
à 12 % d'opacité) convient aux panneaux où le défilement est accessoire, pas à
la Grille où il est l'outil de navigation principal. Classe `.grid-scroll`
dédiée, avec rail dessiné, pouce saisissable, et prise en charge de Firefox que
les sélecteurs -webkit- ignorent.

Corrigé aussi :
- totalWidth lisait `columnDef.size` au lieu de `getSize()`. Le redimensionnement
  de colonnes étant actif et persisté, la largeur plancher restait figée sur
  l'ancienne somme : dernières colonnes rognées, course de défilement trop courte.
- Conteneur non focusable : flèches, Origine et Fin ne défilaient pas la grille.
- Barre de filtres qui passait sur deux lignes sous ~1200 px, volant 48 px au
  tableau : largeurs réduites en dessous de xl.
- Barre d'actions groupées sans flex-wrap, qui s'écrasait sur petit écran.
- Double croix dans la recherche : `type="search"` ajoutait la croix native du
  navigateur par-dessus le bouton maison. Masquage local — la recherche de
  l'en-tête n'a pas de bouton propre et garde donc le sien.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675
2026-08-21 08:52:28 +00:00
LogiFlow 60ce3b8d21 Merge pull request #13 from R0m1k3/claude/grille-modal-ventes-stock-qlxg9i
Claude/grille modal ventes stock qlxg9i
2026-08-14 10:45:43 +02:00
Claude 828a50bfc0 Trous d'assortiment : la référence à côté du code interne
Le code interne identifie l'article chez nous, la référence l'identifie chez
le fournisseur : c'est elle qu'on recopie sur une commande. La fenêtre ne
donnait que le premier, obligeant à revenir à la Grille pour retrouver le
second — l'export, lui, portait déjà les deux.

Référence absente affichée « — » plutôt que laissée vide : une cellule vide
se lit comme une colonne qui n'a pas fini de charger.

Vérifié sur build de production, avec des références longues et absentes :
colonne lisible, aucun débordement horizontal de la fenêtre.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYGgoQCG1VCe42HzbzhAXR
2026-08-14 08:42:29 +00:00
Claude f75d51f6f4 Trous d'assortiment : extraction Excel de la liste affichée
Un bouton « Export Excel » dans la fenêtre des produits non travaillés. Il
reprend EXACTEMENT ce qui est à l'écran — même magasin, même profondeur,
même classement — plutôt que de recalculer une liste : un fichier qui ne
correspond pas à la fenêtre d'où il sort ne se vérifie plus.

Deux feuilles. Les données seules d'abord : en-têtes en ligne 1, filtre
automatique, volet figé, et surtout des valeurs NUMÉRIQUES — le tableur doit
pouvoir trier et sommer, ce qu'une chaîne « 89 863 € » lui interdit. La mise
en forme des nombres suit le critère de tri (euros, unités, pourcentage). Le
contexte ensuite, sur sa propre feuille : fournisseur, magasin examiné,
classement retenu, définition du « non travaillé », date. Mêler les deux
dans une feuille unique aurait condamné filtre et tri.

`exceljs` est chargé à la demande plutôt qu'importé en tête : il pèse lourd,
et la Grille n'a pas à le transporter pour tous ceux qui n'exportent jamais.
Un échec de génération s'affiche dans la fenêtre — un export qui échoue en
silence laisse croire au téléchargement.

Le nom du fournisseur descend jusqu'à la fenêtre pour nommer et documenter
le fichier.

Vérifié sur build de production, en téléchargeant le fichier puis en le
relisant : « Non_travailles_Fournisseur_Test_Houdemont_Top200_2026-08-14 »,
deux feuilles, 15 lignes conformes au décompte affiché, filtre automatique
sur A1:J1, valeurs lues comme des nombres et format « #,##0 "€" » sur la
colonne du critère.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYGgoQCG1VCe42HzbzhAXR
2026-08-14 08:36:20 +00:00
Claude beefb07ba0 Grille : les produits qu'un magasin ne travaille pas
Un bouton « Non travaillés » ouvre, sur le haut du classement AFFICHÉ, la
liste des produits qu'un magasin ne travaille pas. Le classement de
référence est celui du tableau à l'instant où l'on ouvre : le tri en cours —
CA réseau, quantité, marge, peu importe — fixe l'ordre, et la valeur qui a
servi à classer est relue sur la colonne triée pour être affichée en regard.
Recalculer un ordre maison aurait répondu à une autre question que celle
qu'on vient de poser à l'écran.

« Non travaillé » = aucune vente sur 12 mois glissants ET aucun stock au
dernier mois connu. Les deux conditions comptent : un produit sans vente
mais en stock est détenu par le magasin — c'est un invendu, pas un trou
d'assortiment, et le mélanger aux vrais trous ferait commander ce qui dort
déjà en rayon.

Chaque ligne porte son rang, la valeur du critère de tri, et ce que les
AUTRES magasins en font : sans les ventes d'à côté, un trou ne se distingue
pas d'un produit que personne ne vend. Une dernière colonne sépare « jamais
détenu » de « déjà détenu, sans vente » — le premier est une piste, le
second une tentative déjà faite.

Profondeur réglable (100 / 200 / 500), magasin au choix parmi ceux
réellement présents dans les données. Le classement n'est extrait que
fenêtre ouverte, et tronqué à la profondeur maximale : sans quoi on
copierait 130 000 lignes pour en regarder 500.

Vérifié sur build de production, quatre profils de produits : à Houdemont
seuls remontent les jamais détenus, à Frouard seuls les déjà détenus sans
vente, et le profil « stock sans vente » n'apparaît dans aucun des deux. Les
rangs affichés correspondent aux positions dans le classement trié par CA
réseau.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYGgoQCG1VCe42HzbzhAXR
2026-08-14 07:04:54 +00:00
LogiFlow dc7f6bbf9f Merge pull request #12 from R0m1k3/claude/grille-modal-ventes-stock-qlxg9i
Claude/grille modal ventes stock qlxg9i
2026-08-14 08:16:49 +02:00
Claude 06f0bd0513 Prix de vente : tenir compte d'un prix non ventilé par magasin
`cube_pv` a une clé `(artnoid, site)`, mais la base n'en porte qu'une ligne
par article — 428 530 lignes pour 427 857 articles, là où deux magasins
tarifés en donneraient le double. Le prix n'est donc pas ventilé : la
colonne serait restée vide dès qu'on consulte le magasin qui n'a pas la
ligne, alors même que l'article a un prix.

La cellule couvre maintenant les quatre cas : le prix du magasin consulté
quand il existe ; le prix unique quand il n'y a qu'un tarif, avec la mention
« prix unique, non ventilé par magasin » ; rien quand plusieurs prix
coexistent sans concerner ce magasin — celui du voisin n'est pas le sien ;
et, en « tous magasins », le prix commun ou le plus élevé assorti du « ≠ ».

Vérifié sur build de production, quatre articles couvrant les quatre cas,
dans les trois modes de magasin. Le cas décisif : prix porté par le seul
site 292, consulté depuis Houdemont — 12,90 € et la mention, au lieu du
« - » d'avant.

La route de diagnostic est retirée : `cube_pv` est confirmé alimenté et
synchronisé, elle a fait son office.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYGgoQCG1VCe42HzbzhAXR
2026-08-14 06:12:25 +00:00
Claude 8158eda11b Prix de vente : élaguer le code magasin, qui sert de clé
Le code magasin de `cube_pv` sert de clé de recherche côté Grille
(`prixVenteByStore["292"]`). Or la synchronisation Apiflow l'écrit BRUT
(`site: r.Site`), là où elle fait passer celui de `cube_stock` par `safeStr`
— et aucune des deux ne l'élague. Une colonne MSSQL de largeur fixe remonte
complétée d'espaces : la recherche échouerait alors sans rien signaler, et
de la pire façon qui soit, le prix restant visible en « tous magasins » et
vide magasin par magasin.

`TRIM` des deux côtés, plus l'élagage du repli lu depuis le cube de stock.
L'opération ne coûte rien quand il n'y a pas de remplissage, et le doute
n'avait pas de raison de subsister.

La route de diagnostic affiche désormais les valeurs de `site` telles
qu'elles sont stockées, avec leur longueur : de quoi vérifier d'un coup
d'œil que les clés sont bien « 292 » et « 579 », sur trois caractères.

Commentaires SQL déplacés hors du littéral gabarit, comme partout ailleurs
dans le fichier : un accent grave dans un commentaire refermait la chaîne.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYGgoQCG1VCe42HzbzhAXR
2026-08-14 06:12:25 +00:00
Claude c9e1934aae Grille : le prix de vente vient de cube_pv, pas d'article_infosup
La colonne PV s'affichait vide sur toutes les lignes. Le schéma du SaaS
Postgres (dépôt Apiflow, postgres/init.sql) explique pourquoi : la seule
source lue jusqu'ici, `article_infosup.prix_vente_mini`, est une borne de
PARAMÉTRAGE — un prix plancher, à côté de `prix_vente_maxi` et
`pv_conseille` — et non le prix pratiqué.

Le prix de vente vit dans `cube_pv (artnoid, site, pv)`, pendant exact du
`cube_pa` déjà utilisé pour l'achat, rafraîchi chaque nuit depuis le cube
MSSQL `Cube_PV`. `cube_stock` porte le même PV et sert de filet : il ne
couvre que les articles ayant une ligne de stock, mais il est déjà interrogé
par la Grille, une colonne de plus dans le SELECT suffit.

Le prix est propre à chaque MAGASIN. La colonne suit donc le magasin
consulté ; en « tous magasins » elle montre le prix commun, et s'ils
divergent le plus élevé assorti d'un « ≠ » et de l'infobulle qui donne les
deux. Une moyenne afficherait un prix qu'aucune caisse ne pratique, et
retenir silencieusement l'un des deux ferait passer le prix d'un magasin
pour celui des deux.

L'en-tête devient « PV / Magasin » : « PV central » décrivait la fiche
article, ce n'est plus la source.

Vérifié sur build de production, prix identiques, divergents et absents :
en « tous magasins » 14,90 € ≠ avec l'infobulle « Frouard (Nancy) : 13,90 €
· Houdemont : 14,90 € » ; sur Frouard 13,90 €, sur Houdemont 14,90 €, sans
marqueur ; « - » quand aucune source n'a de prix.

La route de diagnostic compte désormais les trois sources côte à côte, de
quoi confirmer sur la base réelle laquelle est renseignée.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYGgoQCG1VCe42HzbzhAXR
2026-08-14 06:12:25 +00:00
Claude 9729b00dc7 Diagnostic : d'où vient (ou ne vient pas) le prix de vente
La colonne « PV central » s'affiche vide sur toutes les lignes. Le chemin du
code est pourtant intact : `getProductRows` renseigne `prixVente` depuis
`article_infosup.prix_vente_mini` (phase 3), aucune phase suivante ne
l'efface, et la route de streaming diffuse la ligne entière sans filtrer ses
champs. La valeur est donc absente à la source.

Deux causes possibles, que seule la base peut départager : la colonne existe
mais n'est pas renseignée, ou le prix de vente vit ailleurs sous un autre
nom. `article_infosup.prix_vente_mini` est la seule source de prix de vente
câblée dans l'application — la Grille et la fiche produit la partagent, si
bien qu'une fiche produit affichant « — » sur PV central confirmerait la
première hypothèse.

Cette route de diagnostic répond aux deux questions : remplissage réel de la
colonne actuelle (absente / à zéro / renseignée), globalement puis pour un
fournisseur donné, et énumération des colonnes candidates du schéma AVEC
leur remplissage — une colonne bien nommée mais vide ne servirait à rien.

À supprimer une fois la source établie. Elle est derrière l'authentification
et ne renvoie que des comptages et quelques exemples.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYGgoQCG1VCe42HzbzhAXR
2026-08-14 06:12:25 +00:00
LogiFlow 1e7c1ba4cd Merge pull request #11 from R0m1k3/claude/grille-modal-ventes-stock-qlxg9i
Grille : colonnes de prix, et menu « Vues » pour les blocs de colonnes
2026-08-13 22:25:43 +02:00
Claude 52b663579f Grille : colonnes de prix, et menu « Vues » pour les blocs de colonnes
Deux colonnes s'ajoutent à la suite de la tendance réseau :

  · PV moyen (Réseau) — calculé par l'application, CA réseau ÷ quantité
    vendue, exactement la définition déjà retenue par la fiche produit.
    Sans quantité sur la période, la colonne affiche « - » : le rapport
    n'existe pas, et un prix à 0 € se lirait comme un article donné.
  · PV central (Article) — le prix de vente de la fiche article, lu en base
    (article_infosup.prix_vente_mini), sans retraitement.

Les deux restent deux colonnes. En dériver un écart chiffré supposerait une
assiette commune — HT/TTC, remises — que les deux sources ne garantissent
pas ; le rapprochement se fait à l'œil, en connaissance de cause.

Le bouton de repli du bloc mensuel devient un menu « Vues » à deux entrées,
ventes mensuelles et prix, chacune repliable. Il agit par BLOCS, là où le
menu « Colonnes » voisin agit colonne par colonne : douze cellules
mensuelles ou deux prix ne se masquent pas une par une. Les deux états sont
persistés.

Au passage, le tri des colonnes réseau était faux. La sentinelle ±Infinity
choisie d'après le sens de tri courant ne pouvait pas fonctionner : TanStack
mémorise le résultat de l'accesseur dans `row._valuesCache` et ne le
réévalue jamais, si bien que la valeur calculée au premier tri restait figée
et remontait les lignes sans donnée en tête dès qu'on inversait le sens.
`sortUndefined: "last"`, traité avant l'inversion, les garde en bas dans les
deux sens. Les cinq colonnes réseau existantes en bénéficient, et `sorting`
quitte les dépendances des colonnes : plus de reconstruction complète à
chaque clic d'en-tête.

Vérifié sur build de production : les deux colonnes se placent bien après la
tendance, « - » sur quantité réseau nulle comme sur PV central absent, et le
tri place les absents en bas au premier clic (décroissant) comme au deuxième
(croissant). Menu « Vues » : 31 colonnes avec les deux blocs, 29 sans les
prix, 17 sans rien, 19 avec les prix seuls — chaque fois sur toutes les
lignes montées, sans rechargement, et l'état survit au rechargement.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYGgoQCG1VCe42HzbzhAXR
2026-08-13 20:16:36 +00:00
LogiFlow 89d573634a Merge pull request #10 from R0m1k3/claude/grille-modal-ventes-stock-qlxg9i
Claude/grille modal ventes stock qlxg9i
2026-08-13 21:18:31 +02:00
Claude 0a2bc8318b Grille : remonter le corps du tableau au changement de colonnes
Le repli du bloc mensuel ne repeignait toujours pas les lignes déjà
montées : l'en-tête perdait ses douze colonnes, le corps les gardait, et il
fallait recharger la page pour retrouver une grille cohérente.

Le signal passé aux lignes (`columnsKey`) reposait sur la comparaison de
props de `React.memo`. La clé du `<tbody>` porte désormais cette même
signature : un changement de colonnes remonte le corps, sans plus dépendre
d'une comparaison qu'une prop oubliée suffit à mettre en défaut. Le
mécanisme existait déjà pour la densité d'affichage.

Le coût est celui des seules lignes virtualisées (une trentaine), et le
redimensionnement d'une colonne ne déclenche rien : les identifiants de
colonnes, eux, ne changent pas.

Vérifié cette fois sur un BUILD DE PRODUCTION, 2 000 lignes : au repli,
17 en-têtes et 17 cellules pour chaque ligne montée ; toujours 17 après
défilement vers des lignes virtualisées ensuite ; 29 partout au retour, et
17 au repli suivant — sans rechargement.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYGgoQCG1VCe42HzbzhAXR
2026-08-13 19:15:15 +00:00
Claude c532386385 Grille : repeindre les lignes montées quand le jeu de colonnes change
Replier le bloc mensuel ne vidait que l'en-tête. Les lignes déjà montées
gardaient leurs douze cellules, seules celles virtualisées ensuite
adoptaient le nouveau jeu — une grille à deux vitesses, en-tête d'un côté,
corps de l'autre.

TanStack met ses objets `Row` en cache sur la seule identité des DONNÉES :
changer les colonnes ne leur donne aucune référence neuve, et `React.memo`
concluait qu'il n'y avait rien à repeindre. Le contournement existait déjà
pour `columnVisibility` et `columnSizing`, passés en props sans jamais être
lus ; le drapeau du bloc mensuel, lui, n'y figurait pas.

`columnVisibility` cède la place à `columnsKey`, la signature des colonnes
réellement visibles : elle change pour TOUT changement de colonnes, case
décochée dans le menu comme bloc replié, là où l'ancien objet ne voyait que
le menu. Calculée une fois par rendu de grille, pas une fois par ligne.

Vérifié en rendant la Grille sur données de démonstration : 29 cellules par
ligne et 29 en-têtes, 17 et 17 une fois le bloc replié, 29 et 29 au retour.
Avec le signal figé, l'en-tête tombe à 17 quand les lignes restent à 29 —
le défaut signalé.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYGgoQCG1VCe42HzbzhAXR
2026-08-13 11:13:39 +00:00
LogiFlow 1f4571bd7a Merge pull request #9 from R0m1k3/claude/grille-modal-ventes-stock-qlxg9i
Grille : détail mensuel au clic sur le total, bloc mensuel repliable
2026-08-13 13:00:24 +02:00
Claude 2ae523a804 Grille : détail mensuel au clic sur le total, bloc mensuel repliable
La colonne « Tot. 12m » dit combien, jamais comment. Un clic sur la case
ouvre désormais le détail des 12 mois glissants, empilé dans l'ordre où on
le lit : les ventes mensuelles, puis les entrées en stock, puis le stock de
fin de mois magasin par magasin — un stock de 40 réparti sur deux sites et
un stock de 40 bloqué sur un seul ne se pilotent pas pareil.

Les deux premières séries suivent le magasin actif de la Grille, par
cohérence avec la case cliquée ; la ventilation par magasin reste entière,
c'est son objet. Les barres se dessinent de part et d'autre d'un zéro
explicite : une quantité mensuelle peut être négative (retours supérieurs
aux ventes) et sortirait sinon du cadre par le bas.

Le bloc des douze colonnes mensuelles se replie d'un bouton. L'état est un
drapeau à part, et non douze entrées de `columnVisibility` : masquer douze
colonnes une par une n'est pas une manipulation, et les identifiants
`month_YYYYMM` changent à chaque nouveau mois alors que le drapeau, lui,
survit à la fenêtre glissante. Les colonnes sont retirées du modèle plutôt
que cachées — inutile de faire calculer douze cellules par ligne à TanStack
pour ne rien peindre.

`TuileStat` sort de heatmap-grid.tsx vers un module partagé, la modale de
tendance réseau et la nouvelle modale s'en servant toutes deux.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYGgoQCG1VCe42HzbzhAXR
2026-08-13 10:54:24 +00:00
Claude 92769535dc style(grille): agrandit la carte de tendance, illisible en l'état
Les textes du graphique tournaient entre 8,5 et 10 px : titres de séries,
« max N », valeurs annotées, mois de l'axe, infobulle, ainsi que les légendes
des trois tuiles. Difficile à lire.

Typographie relevée d'un cran partout : titres de séries 9,5 → 13, maximum
9 → 11,5, valeurs sur les points 9 → 12,5, mois 8,5 → 11, infobulle 9 → 11,5,
tuiles 10 → 12 avec la valeur à 26 px, tableau 11 → 13.

Le dessin suit la typographie plutôt que de la subir : bandeau de facette
15 → 21, axe 18 → 24, écart 12 → 14, infobulle élargie à 196 avec un interligne
de 17 — sans quoi titres et courbes se seraient chevauchés. La carte passe à
max-w-2xl pour absorber le tout sans comprimer le graphique.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675
2026-08-07 11:43:18 +00:00
Claude a4e3c53dcc feat(grille): détail par magasin dans la carte, en mode tous magasins
En « tous magasins », les trois tuiles de la carte sont des cumuls : savoir que
le stock est de 40 ne dit pas s'il est réparti entre Frouard et Houdemont ou
entièrement sur un seul site — or c'est précisément ce qu'on vient vérifier.

La carte affiche désormais, sous les cumuls, une ligne par magasin avec ses
ventes, ses entrées et son stock de fin de mois. Uniquement en mode tous
magasins : sur un magasin unique, les tuiles sont déjà ce détail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675
2026-08-07 09:05:47 +00:00
Claude 6e59458403 fix(grille): grille vide au changement de magasin, et stock TOTAL incomplet
Trois défauts distincts, dont un que je venais d'introduire.

1. RÉGRESSION — grille vide. Le store zustand est persisté dans le navigateur
   sans version ni migration. Le passage de filters.code3 à `string[] | null`
   laissait donc une CHAÎNE dans le localStorage des utilisateurs :
   `new Set("320211")` produit un ensemble de caractères, plus aucune ligne ne
   correspond, et la Grille apparaît vide sans le moindre message. Ajout de
   `version: 1` + `migrate`, plus une garde dans le filtre pour ne jamais
   redevenir muet sur un état inattendu.

2. STOCK « tous magasins » incomplet. Le stock est un NIVEAU, pas un flux : un
   magasin sans mouvement dans le mois détient toujours sa marchandise. Or le
   TOTAL était sommé depuis les lignes mensuelles brutes, donc n'incluait que
   les sites ayant bougé ce mois-là — sur un produit à faible rotation, il
   n'affichait que Frouard. Il est désormais recalculé depuis les séries par
   site, qui sont reportées d'un mois sur l'autre.

3. CHANGEMENT DE MAGASIN qui se fige. Hors « tous magasins », un rattrapage
   interroge l'API FF à raison d'UNE requête HTTP par article : sur un gros
   fournisseur, cela fait des milliers d'appels. Borné à 300 (réglable via
   GRID_STORE_RECONCILE_MAX), avec un avertissement explicite sur ce qui n'a
   pas été rattrapé — pas de troncature silencieuse.

Enfin, getProductRows ne renvoie plus [] en cas d'erreur : une liste vide est
indiscernable d'un fournisseur sans article. La Grille affichait une page
blanche sans explication, et la synchro nocturne prenait la panne pour un
fournisseur vide — qu'elle désactivait automatiquement. L'erreur remonte
désormais jusqu'au bandeau rouge et au statut « echec ».

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675
2026-08-07 09:04:09 +00:00
Claude 720cf3c7a1 feat(grille): filtre nomenclature en multi-sélection
Le filtre était un <select> à valeur unique : impossible de retenir plusieurs
nomenclatures, ni de partir de tout pour retirer les postes non voulus — ce qui
est la manière naturelle de travailler quand un fournisseur en compte des
dizaines.

Remplacé par une liste déroulante à cases, avec recherche, « Tout cocher »,
« Tout décocher », « Ne garder que ces N » sur le résultat d'une recherche, et
une croix pour tout réafficher. Décocher depuis l'état « tout » matérialise la
liste complète puis en retire le poste, sinon le premier décochage n'aurait
rien fait.

filters.code3 passe de `string | null` à `string[] | null` : `null` = aucun
filtre, `[]` = rien de coché. Distinguer les deux évite la valeur sentinelle
qu'imposerait un simple tableau, et rend « Tout décocher » sans ambiguïté.
L'URL accepte une liste séparée par des virgules.

Au passage : la Grille rechargeait tout depuis le serveur à chaque changement
de nomenclature, alors que getProductRows ignore ce filtre et que le tri se
fait en local. Le paramètre n'est plus transmis ni dans les dépendances de
l'effet — le filtrage est désormais instantané, ce qui compte d'autant plus
avec plusieurs cases à cocher.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675
2026-08-07 08:46:39 +00:00
Claude 0bd7ec9798 feat(admin): synchronisation nocturne des fournisseurs, paramétrable
Page /admin/synchronisation : choisir quels fournisseurs sont cadencés, pour
quelle source, et suivre l'avancement.

ORDRE DE PASSAGE. La file est « le plus anciennement synchronisé d'abord », en
ignorant ceux déjà traités depuis l'ouverture de la fenêtre. Il en découle,
sans curseur ni compteur de tour : la nuit 2 reprend là où la nuit 1 s'est
arrêtée, le tour recommence quand tout le monde est passé, et un fournisseur
en échec garde une date ancienne donc repasse en tête.

DEUX FILES. SQL (~43 s sur le plus gros) et Qlik (jusqu'à 8 min, sur un serveur
qui abandonne quand on le sollicite trop) ont leur propre activation et leur
propre rythme. Une extraction Qlik est intercalée tous les N fournisseurs SQL,
sinon elle ne démarrerait jamais avant que la file SQL soit vide. Un plafond
par nuit et un âge minimal évitent de refaire chaque nuit des agrégats
mensuels.

Détails qui comptent :
- La fenêtre signifie « ne plus DÉMARRER après l'heure de fin » : une
  extraction en cours n'est jamais coupée. Le passage par minuit est géré.
- L'horodatage est écrit même en cas d'échec, sans quoi le fournisseur fautif
  bloquerait la file derrière lui.
- Garde anti-boucle : l'avancement repose sur cette écriture, volontairement
  non bloquante ; si elle échoue, le round s'arrête au lieu de reprendre le
  même fournisseur indéfiniment.
- Un fournisseur sans article est désactivé automatiquement, motif affiché,
  réactivable — plutôt que réessayé chaque nuit.
- Désactivé par défaut : un déploiement ne doit pas se mettre à solliciter
  Qlik la nuit suivante sans décision explicite.

Déclenchement par minuteur interne (instrumentation.ts), battement d'une
minute. Aucune configuration côté hôte, se réarme au démarrage ; en
contrepartie rien ne tourne si l'application est arrêtée.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675
2026-08-07 07:28:11 +00:00
Claude be8d576af5 chore: supprime les derniers restes de l'IA dans les Paramètres
La section « IA Copilot — OpenRouter » annonçait « pour les analyses de
gammes », mais aucun code ne consommait cette clé : elle était décorative.
Supprimée, ainsi que les routes devenues orphelines /api/admin/ai-config,
/api/google-ai/models et /api/openrouter/models.

Nettoyage de l'état correspondant dans la page : clé, liste de modèles,
fournisseur d'IA et modèle Google ne faisaient plus qu'un aller-retour entre le
chargement et l'enregistrement, sans aucune interface pour les modifier.
handleSave n'enregistre donc plus que l'URL de la base.

La règle set-state-in-effect s'est mise à signaler le `setIsMounted` du montage,
inchangé mais devenu analysable une fois le code IA retiré : dérogation locale,
comme dans api-connection-info.tsx, puisque poser l'indicateur au montage est
précisément ce qui évite l'écart d'hydratation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675
2026-08-07 07:06:23 +00:00
Claude fff4cd9641 chore: retire la section Score des Paramètres et supprime la page Chat IA
- Section « Score Produit » retirée : elle ne faisait que décrire une formule,
  sans rien paramétrer.
- Page /admin/ai-chat et sa route /api/admin/ai-chat supprimées, ainsi que
  l'entrée de menu et la section « Admin AI Chat — Fournisseur » des
  Paramètres, qui n'existait que pour la configurer.
- Nettoyage de l'état devenu mort dans la page Paramètres : la liste des
  modèles Google et son chargement n'avaient plus d'interface.

Effet de bord notable : tsc passe de 23 à 4 erreurs préexistantes — la page de
chat en portait 19 à elle seule (flags de regex exigeant une cible es2018).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675
2026-08-07 06:56:24 +00:00
Claude 310165151d fix(qlik): reprise globale quand le moteur abandonne l'extraction (code 15)
Deux exécutions du même fournisseur D005, même code, même jour :
  04:17 → OpenDoc en 78 s,  succès en 479 s
  06:13 → OpenDoc en 233 s, « code 15 / Request aborted » après 333 s

Le moteur Qlik est trois fois plus lent à 6 h qu'à 4 h et finit par abandonner
la requête. Les reprises par requête de rpcWithRetry ne servent à rien ici :
c'est toute l'extraction qui tombe, et la sync repartait à zéro sans réessayer.

L'extraction est désormais relancée entièrement sur erreur transitoire (code 15
ou « Request aborted »), une fois par défaut, après une pause de 60 s — pas
immédiatement : relancer aussitôt ne ferait qu'ajouter de la charge au serveur
qui vient de renoncer. Réglable via QLIK_EXTRACTION_RETRIES et
QLIK_EXTRACTION_RETRY_PAUSE_MS.

Le reste du comportement est préservé : le refus de publier une fenêtre 12 mois
incomplète, et la reprise sur point de contrôle hors fenêtre datée, s'appliquent
comme avant une fois les reprises épuisées. Le checkpoint est vidé entre deux
tentatives pour ne pas mélanger deux extractions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675
2026-08-07 06:24:09 +00:00
Claude 0a57a9a707 fix(api): nomenclatures dérivées du préfixe de code3, pas de code1/code2
Les codes de nomenclature FF font 6 chiffres et sont hiérarchiques par préfixe
(31xxxx à 40xxxx) : « 32 » univers, « 3202 » famille, « 320211 » sous-famille.

L'endpoint groupait sur les colonnes code1/code2, qui ne sont renseignées que
si pgGetNomenclatureByFournisseur a su remonter la hiérarchie — ce qui dépend
d'une détection heuristique de la colonne parent de la table `nomenclature`.
Quand elle échoue, seul code3 est rempli et l'endpoint aurait renvoyé une liste
vide aux niveaux 1 et 2, sans rien signaler.

Les niveaux sont désormais dérivés de left(code3, 2|4|6), toujours disponible.

En conséquence, /grid gagne un filtre `nomenclature` par préfixe, qui fonctionne
aux trois niveaux et ne dépend pas non plus de code1/code2 : c'est lui que
meta.filtreGrid désigne. Les filtres code1..code3 exacts restent inchangés.

Les libellés viennent toujours du payload et peuvent être null aux niveaux 1 et
2 pour la même raison ; codes et compteurs, eux, sont exacts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675
2026-08-07 06:08:02 +00:00
Claude 60aa2ce120 feat(api): inventaire des nomenclatures, pour découper un gros fournisseur
Les filtres code1/code2/code3 existaient déjà sur /grid, mais un appelant ne
pouvait pas savoir quelles valeurs existent chez un fournisseur : il aurait dû
tout télécharger pour les découvrir, ce qui annulait l'intérêt du découpage.

GET /api/v1/nomenclatures?fournisseur=…&niveau=1|2|3&parent=… renvoie les
postes avec leur nombre d'articles et leur CA, agrégés en SQL — quelques
dizaines de lignes, même pour un fournisseur de 130 000 articles. `parent`
permet de descendre l'arborescence sans tout charger, et meta.filtreGrid
indique le paramètre de /grid correspondant au niveau demandé.

Le parcours devient : lister les postes, puis /grid?fournisseur=…&code1=… poste
par poste. Bien plus praticable pour une IA que 132 000 lignes d'un bloc, et
plus pertinent métier que le découpage par gamme.

Les libellés sont lus dans le payload jsonb (ils n'ont pas de colonne dédiée).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675
2026-08-07 06:00:54 +00:00
Claude 8fe294924b fix(api): tient les très gros fournisseurs (132k articles)
Le journal du fournisseur D005 montre 132 388 lignes persistées pour un seul
fournisseur. À cette échelle, /api/v1/grid sans limit ne tenait pas.

Deux plantages francs, indépendants de la taille de la réponse :
- getNetworkMetricsByCodeCentrale : inArray avec 132 000 valeurs, soit autant
  de paramètres liés, alors que le protocole PostgreSQL en accepte 65535.
- pgGetGammesByCodeins : même problème sur son IN (…).
Les deux lectures sont désormais découpées par lots de 2000. Le bug existait
pour tout appel dépassant ~65 000 codes, indépendamment de l'API.

Volume : charger 132k lignes complètes puis les sérialiser d'un bloc représente
plusieurs centaines de Mo en mémoire. Sans `limit`, la réponse est maintenant
diffusée en flux, par lots internes de 1000, à mémoire constante. La forme
{ data, pagination, meta } est inchangée — un client existant ne voit que des
octets arrivant progressivement.

Une erreur survenant après les premiers octets ne peut plus changer le code
HTTP : le document est alors fermé avec meta.erreur et meta.complet=false, pour
qu'une réponse tronquée ne passe pas pour complète.

`fields` reste le levier décisif à cette échelle : une ligne complète porte les
séries mensuelles et les ventilations par magasin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675
2026-08-07 05:45:49 +00:00
Claude 33b30dc8bd fix(api): supprime le plafond de 500 lignes, qui tronquait en silence
Le passage à limit=5000 du commit précédent était sans effet : queryGridRows
re-plafonnait à 500 en dur (Math.min(500, …)). Pire, la pagination était
calculée sur le limit *demandé*, donc hasMore et meta.complet annonçaient une
réponse complète alors qu'elle était tronquée — exactement la troncature
silencieuse que ces champs devaient éliminer.

- Plus aucun plafond. Sur /grid, omettre `limit` renvoie toutes les lignes du
  fournisseur : plus de pagination à dérouler, ce que les agents font mal.
- La pagination reflète le mode sans limite (une page couvrant le total), donc
  hasMore et meta.complet disent la vérité.
- Le défaut reste 100 sur les endpoints de recherche, non bornés par un
  fournisseur — mais sans maximum imposé.
- meta.avertissement explique désormais que la troncature vient du `limit`
  fourni, et qu'il suffit de l'omettre.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675
2026-08-06 11:46:32 +00:00
Claude 73accd6d60 feat(api): un seul appel suffit pour tout un fournisseur
Le plafond de pagination était de 500 lignes. Un lot fournisseur dépasse
souvent ce seuil, si bien qu'une app ou un agent ne récupérait qu'une partie
des données sans forcément s'en rendre compte — et les agents paginent mal,
concluant volontiers qu'ils ont tout lu.

- /grid accepte désormais limit jusqu'à 5000. La borne reste à 500 sur la
  recherche transversale, qui n'est pas bornée par un fournisseur.
- pagination.hasMore, et meta.complet sur /grid : l'appelant sait s'il a tout,
  sans avoir à comparer page et totalPages.
- Réponse partielle : meta.avertissement indique le nombre de lignes obtenues
  sur le total et comment obtenir le reste. Pas de troncature silencieuse.
- Doc des Paramètres et openapi.json mis à jour, avec un exemple curl qui
  récupère un fournisseur entier.

`fields` reste vivement conseillé : une ligne complète porte les séries
mensuelles et les ventilations par magasin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675
2026-08-06 11:30:25 +00:00
Claude 3fa92e891b merge: intègre main — main prioritaire, abandon de la page en doublon
main a beaucoup avancé (53 commits) pendant que cette branche construisait
l'API. La fusion a révélé que /produits, sur main, fait la même chose que la
page Recherche réseau d'ici, en plus abouti : fiche produit, opportunités,
URL partageables, et sa propre chaîne Qlik (lib/qlik-search, pgSearchProduits).

Résolution, main prioritaire :
- sidebar, heatmap-grid, qlik-playwright : version de main telle quelle. Son
  travail sur la tendance et Qlik est postérieur et plus complet.
- get-product-rows : import NB_MAGASINS_RESEAU de main, plus l'appel
  upsertGridRows d'ici — sans lui /api/v1 n'aurait aucune donnée à servir.
- settings : les deux côtés (ServerLogs de main + les sections API d'ici).

Suppression du doublon : page recherche-reseau, /api/qlik/search, ses types et
composants. searchNetworkProductsPlaywright disparaît avec la version main de
qlik-playwright ; rien d'autre ne l'utilisait.

api-enrich se branche désormais sur le module de tendance de main
(features/grid/lib/network-trend) plutôt que sur le mien, supprimé : sa mesure
est meilleure — variation sur la quantité par magasin vendeur, ce qui distingue
« vend mieux » de « est distribué plus largement » — et l'API renvoie ainsi
exactement ce qu'affiche la Grille. Le champ trend gagne surQteParMagasin et
nouveau.

Apport conservé de la branche : l'API /api/v1, absente de main.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675
2026-08-06 11:13:44 +00:00
Claude 94e82aa404 fix(ff): le panneau API FF Nancy affichait une coquille vide + URL configurable
Deux problèmes distincts, dont un mal diagnostiqué au départ.

1. FORMAT DE RÉPONSE — cause réelle du « test ne donne rien ». Le serveur
   fonctionne (vérifié : HTTP 200, synchro du jour, 33 tables), mais il renvoie
   { sync: [{ table_name, last_sync, rows_synced, status, error_msg }] } alors
   que l'application lisait { lastSync, tables: [{ nom, derniereSync, nbLignes }] }.
   Les champs n'existaient pas : le panneau s'affichait vide sans erreur, la
   requête HTTP ayant réussi. normalizeSyncStatus() traduit désormais les deux
   formes, et le tableau expose l'état par table (une table en erreur est
   justement l'information qu'on vient chercher).

2. URL NON CONFIGURABLE. FF_API_BASE était figée au chargement du module depuis
   process.env, sans champ dans l'interface : impossible de corriger l'adresse
   sans redéployer. Elle est maintenant résolue à l'appel — réglage enregistré,
   puis variable d'environnement, puis défaut — avec un champ, un bouton
   Enregistrer et un test dans Paramètres.

Le test distingue désormais DNS, connexion refusée, délai dépassé et code HTTP,
et affiche l'adresse réellement appelée : un « HTTP 503 » nu ne permettait pas
de séparer une mauvaise URL d'une panne. /api/ff-status renvoie le même détail.

Ajout au passage du paramètre `compute` dans la doc API des Paramètres.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675
2026-08-06 10:56:29 +00:00
Claude 18edda7167 feat(api): expose la tendance réseau calculée dans /api/v1
La colonne « Tendance » de la Grille était la seule information visible que
l'API ne fournissait pas : elle est calculée dans le navigateur par
computeNetworkTrend(), qui vivait dans un composant "use client" donc
inaccessible côté serveur. L'API livrait la série mensuelle brute et laissait
chaque appelant refaire la régression — au risque de diverger de l'affichage.

- Le calcul part dans src/lib/network-trend.ts (module pur, sans React) ;
  le composant le réexporte, aucun import existant ne change.
- enrichRows ajoute `trend` : { direction, pct, label, monthsUsed }, calculé
  sur la même série que la colonne de l'application.
- openapi.json : schéma Trend documenté, et il est dit explicitement que la
  liste des propriétés de Product n'est pas exhaustive — la réponse porte la
  ligne de grille entière.

Vérifié au passage que rien n'est perdu en chemin : upsertGridRows stocke le
ProductRow complet dans la colonne jsonb `payload` (les colonnes scalaires ne
servent qu'aux index), ne filtre que les lignes sans codein, et pickFields
renvoie tout tant que `fields` n'est pas précisé.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675
2026-08-06 10:36:35 +00:00
Claude 3a825866d2 feat(api): /api/v1/grid calcule le fournisseur à la demande
L'API lisait uniquement la table grid_rows, écrite en effet de bord quand un
humain ouvre la page Grille. Un fournisseur jamais ouvert renvoyait donc
202 not_ready : une app ou un agent externe ne pouvait consulter que ce qui
avait déjà été parcouru dans l'interface, ce qui vide l'API de son intérêt.

/api/v1/grid déclenche désormais getProductRows() quand l'instantané manque.
Le calcul est déjà protégé en amont (cache 10 min + verrou anti-concurrence)
et persiste l'instantané, donc seul le premier appel paie le coût ; les
suivants repassent par le chemin SQL rapide.

- compute=1 (défaut) : calcul à la demande, meta.computedOnDemand signale
  quand il a eu lieu. compute=0 : comportement strict d'avant.
- Fournisseur inconnu ou sans article : 404 explicite, distinct d'une panne
  (500) et de « pas encore calculé » (202).
- /api/v1/products/{codein} fait de même lorsque `fournisseur` est fourni,
  le calcul se faisant par lot fournisseur.
- openapi.json mis à jour : c'est ce document que lit un agent externe, il
  annonçait « aucun endpoint ne déclenche de recalcul ».

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675
2026-08-06 10:29:26 +00:00
Claude 54d51e8eab fix(reseau): cast texte sur artcentrale pour la jointure code centrale
Le type réel de articles.artcentrale varie selon l'installation FF Nancy
(varchar ou numérique). La comparaison avec des codes centraux (chaînes)
passe désormais par TRIM(...::text), comme ailleurs dans ce fichier.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675
2026-08-05 11:25:42 +00:00
Claude c565672259 feat(reseau): page Recherche réseau — chercher dans le catalogue Qlik
La Grille part de NOTRE catalogue : on extrait nos codes centraux puis on
demande à Qlik les métriques de ces codes. Un produit vendu par le réseau
mais absent de FF Nancy était donc invisible par construction.

Cette page inverse le sens : elle interroge Qlik d'abord, par libellé ou par
code centrale, et affiche aussi les produits que nous ne référençons pas.

- searchNetworkProductsPlaywright() : sélection par SelectValues sur
  « Article Code » (mode code) ou par SearchListObjectFor +
  AcceptListObjectSearch sur la dimension « Article » (mode plein texte,
  avec repli sur des noms de champs si la master dimension échoue).
  Cube [Article Code, Article, Mois] × 6 mesures, passe N-1 incluse pour
  obtenir 12 mois glissants contigus. Garde-fou à 300 produits/recherche.
- /api/qlik/search : job asynchrone (POST + polling GET) comme la sync,
  une seule recherche à la fois pour ménager le moteur Qlik. Met les
  résultats en cache dans qlik_network_metrics et enrichit avec le
  catalogue local (badge « Chez moi » / « Réseau uniquement », stock).
- Page /recherche-reseau : tableau triable, filtres, fiche produit avec la
  courbe 12 mois, export Excel.
- Composants de tendance extraits de heatmap-grid vers features/network
  pour être partagés (comportement inchangé).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675
2026-08-05 11:22:53 +00:00
Claude 4b229acc9b feat(api): API prête pour une IA externe type ChatGPT
Adapte l'API CollectFlow aux exigences des Actions ChatGPT et garantit que
les données soient réellement disponibles pour un consommateur externe.

Schéma OpenAPI :
- openapi.json devient PUBLIC (sans clé). Il ne décrit que la structure, sans
  aucune donnée, or ChatGPT importe le schéma par URL avant que la clé ne soit
  configurée : l'exiger rendait l'import impossible.
- servers[0].url est désormais ABSOLUE (une URL relative est rejetée à
  l'import), construite depuis l'hôte appelant ou COLLECTFLOW_PUBLIC_URL.
- operationId sur chaque opération (requis par les Actions), schémas de
  réponse typés, et descriptions rédigées pour le modèle : quelle gamme
  utiliser pour raisonner, pourquoi préférer caParMagasinReseau au CA brut,
  que faire d'un 202 not_ready.

Disponibilité des données :
- Nouveau préchauffage /api/admin/grid-warmup + bouton dans Paramètres.
  Sans lui, l'API ne sert que les fournisseurs déjà ouverts à la main dans la
  Grille — une IA externe n'aurait presque rien vu. Le job calcule tous les
  fournisseurs séquentiellement (paralléliser saturerait PostgreSQL), saute
  ceux à jour depuis moins de 24 h et suit son avancement.

Documentation :
- Paramètres → marche à suivre pas à pas pour brancher un GPT (import du
  schéma, auth par clé personnalisée X-API-Key), et mention de
  COLLECTFLOW_PUBLIC_URL quand le domaine public diffère.

L'assistant interne et son API api.ffnancy.fr ne sont pas touchés.

Vérifié sur PostgreSQL local : schéma servi sans clé en 200, URL absolue,
5 operationId, auth apiKey/X-API-Key, surcharge COLLECTFLOW_PUBLIC_URL
effective, 5 endpoints en 200 avec la clé, 401 JSON sans clé, et recherche
transversale renvoyant bien plusieurs fournisseurs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675
2026-08-05 10:21:31 +00:00
Claude a45e09777b feat(api): métriques Qlik + gamme serveur à jour, et doc de connexion dans Paramètres
L'instantané grid_rows fige la ligne au moment du calcul, mais deux données
évoluent indépendamment et doivent refléter l'état courant :

- Métriques réseau Qlik : qlik_network_metrics est alimentée par les syncs et
  les recherches réseau, hors du calcul de grille. Relues à chaque appel et
  exposées dans `network` (null quand le produit n'en a pas). Les colonnes
  caReseau/qteReseau/... de la ligne sont réalignées dessus.
- Gamme serveur : codeGamme peut être surchargée par un snapshot de session.
  L'API expose `codeGammeServeur`, la gamme NON modifiée telle qu'elle est en
  base PostgreSQL, relue à chaque appel. codeGammeInit est gardé aligné.

Ajout de pgGetGammesByCodeins() : variante sans jointure artfou1, nécessaire
car la recherche de l'API est transversale (pas de fournisseur connu).

Ce n'est pas un recalcul : deux lectures indexées bornées à la page courante
(500 lignes max). Mesuré à ~20 ms, soit le même coût que sans enrichissement.
Paramètre enrich=0 pour servir l'instantané brut.

Paramètres → nouvelle section « API CollectFlow — Connexion » : URL de base
déduite de l'origine, en-têtes d'authentification, liste des endpoints et des
paramètres, exemples curl copiables, lien vers openapi.json.

Vérifié sur PostgreSQL local avec un instantané volontairement périmé :
gamme snapshot A → serveur C, caReseau 111 → 990000, produit sans données
réseau → network null, produit sans gamme → codeGammeServeur null, enrich=0
redonnant bien les valeurs figées, et toujours zéro recalcul.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675
2026-08-05 10:14:21 +00:00
Claude e79d7e51da feat(api): API CollectFlow /api/v1 — lecture de la grille et recherche
Les données de la grille (ventes 12 mois, stock, marges, gammes, métriques
réseau Qlik) n'étaient accessibles par aucun moyen programmatique, et
getProductRows() exige un fournisseur : chercher un produit sans le connaître
était impossible.

Contrainte de conception : l'API ne recalcule jamais rien. La grille était
reconstruite en direct et gardée seulement 10 min en mémoire ; une API qui
appellerait getProductRows() serait lente et imprévisible. On persiste donc
le résultat d'un calcul qui a déjà lieu, et on le sert.

- Table grid_rows : colonnes scalaires (filtre/tri/recherche en SQL) +
  payload jsonb du ProductRow complet. Remplie en effet de bord NON bloquant
  par getProductRows(), purge des articles disparus via computed_at. Survit
  aux redémarrages, contrairement au cache mémoire.
- Endpoints /api/v1 : fournisseurs, grid, products/search (transversale, tous
  fournisseurs), products/:codein, network/:codeCentrale, openapi.json.
  Pagination, tri sur liste blanche, projection de champs, validation zod.
  202 not_ready si un fournisseur n'a pas encore d'instantané.
- Authentification double : clé d'API (X-API-Key ou Bearer, SHA-256 en base,
  révocable) ou session existante. Le middleware exempte /api/v1 — sans quoi
  un script recevait une redirection 302 vers /login au lieu d'un 401 JSON.
- Gestion des clés dans /settings (server actions, clé affichée une seule fois).

Vérifié contre une base PostgreSQL locale : 401 JSON sans clé, 401 sur clé
révoquée, recherche renvoyant plusieurs fournisseurs, upsert + purge, et
24 appels /api/v1 sans déclencher un seul recalcul (l'ancienne route
/api/grid/rows en déclenche un à chaque appel).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675
2026-08-05 10:14:21 +00:00
Claude 150baeef9a Carte tendance : hauteur figee, bascule Graphique/Tableau, cellules copiables
1. LA CARTE CHANGEAIT DE TAILLE PENDANT LE SURVOL.
   La lecture du mois vivait sous le graphique, en HTML : selon la longueur
   des valeurs elle passait sur deux lignes et la carte sautait. Elle est
   desormais dessinee DANS le SVG, dont la geometrie est figee par le
   viewBox — infobulle ancree au reticule, qui bascule a gauche quand elle
   deborderait a droite.

   Deux causes residuelles trouvees a la mesure, pas a la lecture :
   - le SVG etait dimensionne en pourcentage, donc sa hauteur dependait de
     la largeur : il porte maintenant une hauteur en pixels ;
   - un SVG en ligne traine l'espace sous la ligne de base (6 px mesures),
     ce qui decalait la vue tableau : `display:block`.

   Verifie en pilotant le composant reellement monte : 382,5 px au repos,
   sur quatre positions de survol et dans les deux vues, en clair comme en
   sombre.

2. LES VALEURS MOIS PAR MOIS SANS AGRANDIR LA CARTE.
   Le deplaint aurait fait grandir la carte a l'ouverture — exactement ce
   qu'on venait de corriger. Le tableau occupe donc la place du graphique
   dans un panneau de MEME HAUTEUR et defile a l'interieur.

3. REFERENCE ET EAN COPIABLES, comme le code interne.
   La copie etait ecrite en ligne dans la colonne « Code interne », avec un
   `eslint-disable react-hooks/rules-of-hooks` : un useState dans le corps
   d'un cell() de TanStack n'est pas rendu au meme endroit d'un rendu a
   l'autre. Extraite en composant, la derogation disparait et les trois
   colonnes partagent le meme comportement.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DfqUihgixw4K1AmJhizWiu
2026-08-01 10:40:37 +00:00
Claude 5ad222431d Carte tendance : refonte du graphique selon les regles data-viz
Petits multiples empiles sur un axe des mois commun, palette validee par
script, etiquetage selectif, survol et vue tableau.

FORME. Trois mesures d'ordres de grandeur differents (qte/magasin,
volumes, magasins) ne peuvent pas partager un cadre : il faudrait deux ou
trois echelles verticales dont l'alignement serait arbitraire, ce qui
inventerait des correlations absentes des donnees. Trois facettes, une
echelle chacune, le meme axe horizontal. La facette PRINCIPALE est la
qte/magasin.

COULEUR. Slots 1-3 de la palette categorielle de reference, en variables
CSS, avec un jeu de pas CHOISI pour le fond sombre (pas un eclaircissement
automatique). Palette passee au validateur dans les deux modes, toutes
paires : bande de clarte, plancher de chroma, separation daltonienne
(pire paire ΔE 9,2 clair / 9,4 sombre) et contraste. En mode clair l'aqua
tombe a 2,82:1 — la regle de relief s'applique, d'ou etiquettes visibles
ET vue tableau, toutes deux presentes.

La teinte de tendance (vert/rouge/gris) ne vit plus que dans la tuile de
verdict, avec fleche et libelle : un etat ne se signale jamais par la
couleur seule, et la meme couleur ne veut pas dire deux choses.

LISIBILITE. Etiquetage selectif — l'extreme et le dernier mois, pas les 36
valeurs. Le texte ne porte jamais la couleur d'une serie (illisible en
teinte claire) : l'identite vient du repere pose a cote. Filets de base
pleins et discrets, jamais pointilles. Reticule au survol reliant les
trois facettes au meme mois, et vue tableau depliable : aucune valeur
n'est accessible au seul survol.

En-tete refait en tuiles : verdict, qte/magasin du dernier mois, magasins
vendeurs. Chiffres proportionnels sur les valeurs mises en avant,
tabulaires seulement dans le tableau ou les colonnes s'alignent.

Rendu verifie en image dans les deux themes avant livraison : la version
precedente faisait passer l'etiquette du pic par-dessus le titre de la
facette — elle bascule desormais sous le point quand elle toucherait le
bandeau.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DfqUihgixw4K1AmJhizWiu
2026-08-01 09:58:31 +00:00
Claude eaac945345 Tendance : la calculer sur la QTE PAR MAGASIN, pas sur le volume brut
C'est le bon indicateur, et le volume brut ne pouvait pas le remplacer :
il confond la performance du produit et sa diffusion. Un produit monte en
volume des qu'on le reference ailleurs, sans mieux se vendre nulle part ;
un produit retire de 60 magasins s'effondre en volume tout en performant
mieux la ou il reste. Seule la qte/magasin repond a « ce produit est-il
tendance ».

- computeNetworkTrend prend le nombre de magasins par mois et calcule
  l'evolution sur qte/magasins vendeurs. Sans cette serie, repli sur le
  volume brut et `surQteParMagasin` a false, pour que l'UI le dise.
- La serie remonte jusqu'a tous les appelants : grille, fiche produit et
  recherche produits (nbMagByMonth ajoute au resultat de recherche).
- La carte affiche desormais trois bandes empilees sur le meme axe :
  qte/magasin en PRINCIPALE (couleur de la tendance, aire pleine),
  quantites vendues et magasins vendeurs en contexte. La teinte
  « tendance » ne designe plus qu'une seule chose dans le graphique ; les
  bandes de contexte ont des couleurs fixes.
- Le pourcentage de la carte precise « (qte/magasin) » : un chiffre de
  tendance sans son assiette se prete a tous les malentendus.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DfqUihgixw4K1AmJhizWiu
2026-08-01 09:50:41 +00:00
Claude e6e79dfe06 Carte tendance : desencombrer le graphique, il etait illisible
La densite etait le vrai probleme. La version precedente imprimait les 24
valeurs (12 quantites + 12 magasins) et titrait chaque bande DANS le SVG.
Resultat sur ecran : les titres passaient par-dessus les courbes, les
nombres des extremites etaient coupes par le bord du cadre, et les
etiquettes des magasins se collaient au trait pointille.

- Les titres sortent du dessin : legende HTML au-dessus, plus aucun
  recouvrement possible quelle que soit la forme des courbes.
- La bande des magasins ne porte plus que sa DERNIERE valeur ; son niveau
  se lit a la forme et au maximum rappele dans la legende.
- Les etiquettes des bords sont ancrees vers l'interieur (start / end au
  lieu de middle) : plus rien n'est tronque.
- Chaque mois expose une infobulle native avec les valeurs exactes, ce qui
  rend inutile d'imprimer tous les nombres.
- Cadre elargi (520 de large, marge haute pour les etiquettes du sommet) et
  modale passee en max-w-xl.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DfqUihgixw4K1AmJhizWiu
2026-08-01 09:46:15 +00:00
Claude bf6fef6a14 Carte tendance : deux bandes separees, indicateur lisible, courbe magasins reparee
Trois defauts signales, trois causes distinctes.

1. LA COURBE DES MAGASINS N'APPARAISSAIT PRESQUE JAMAIS.
   L'extracteur ecrivait `{ qte: 0 }` sur les mois sans vente, sans
   `nbMag`. La serie exigeant les 12 mois, elle disparaissait des qu'un
   produit avait un mois creux — c'est-a-dire presque toujours. Un mois
   PRESENT dans le detail est un mois extrait : s'il n'a pas de nbMag,
   c'est que personne n'a vendu, donc zero. Corrige a la source ET a la
   lecture, pour que les caches deja ecrits repartent sans re-sync.

2. LES DEUX COURBES ETAIENT ILLISIBLES SUPERPOSEES.
   Deux echelles independantes placaient la courbe des magasins AU-DESSUS
   de celle des quantites : cela se lit spontanement comme « il y a plus
   de magasins que de ventes », un contresens. Les series sont desormais
   tracees dans DEUX BANDES distinctes alignees sur le meme axe des mois.
   On compare la forme de l'une avec la forme de l'autre, sans jamais
   suggerer un rapport de grandeur.

3. LES POURCENTAGES N'AVAIENT PAS DE SENS.
   La pente d'une regression rapportee a la moyenne donnait « -124 % »
   (une baisse ne peut pas depasser -100 %), « +1 062 % » pour 2 unites
   vendues une fois dans l'annee, et surtout « +508 % » sur des dizaines
   de lignes d'affilee — la signature arithmetique d'une serie nulle
   partout sauf le dernier mois, ou le rapport ne depend meme plus des
   quantites. On compare maintenant deux moyennes de 4 mois : variation
   qui se lit comme telle, plancher a -100 %, insensible au bruit d'un
   mois isole. Sans base de comparaison, aucun pourcentage n'est
   invente — le produit est marque « Nouveau », et le tri le remonte en
   tete au lieu de l'enterrer.

Le graphique accepte aussi des quantites negatives (retours superieurs
aux ventes sur un mois) : l'echelle part du minimum reel, le point ne
sort plus du cadre.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DfqUihgixw4K1AmJhizWiu
2026-08-01 09:41:25 +00:00
Claude 28fe78a646 Carte tendance : garantir que la courbe magasins se distingue des quantites
La courbe des quantites prend la couleur de la TENDANCE : vert, rouge, ou
gris-bleu (#94a3b8) quand elle est stable. L'indigo des magasins se
detache du vert et du rouge, mais il est trop proche du gris-bleu — or
« stable » est le cas le plus frequent.

storesColorFor() bascule sur l'ambre (#f59e0b) dans ce cas precis : gris
froid contre orange chaud, impossible a confondre.

Les deux series se distinguent desormais par TROIS canaux, pas seulement
la couleur — qui ne suffit ni en impression noir et blanc, ni pour un
daltonien :
- teinte garantie differente,
- trait pointille contre trait plein,
- points evides contre points pleins.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DfqUihgixw4K1AmJhizWiu
2026-08-01 09:10:31 +00:00
Claude abc704a363 Carte tendance : deuxieme courbe du nombre de magasins vendeurs
Une quantite qui monte parce que le produit est diffuse dans plus de
magasins ne raconte pas la meme histoire qu'une quantite qui monte a
diffusion constante. La courbe des quantites seule ne permettait pas de
distinguer un succes produit d'un simple elargissement.

- computeStoresSeries() : serie « magasins vendeurs » sur la MEME fenetre
  12 mois que la tendance, avec la meme regle du tout ou rien — les 12
  cles doivent etre presentes, sinon null. Un mois non extrait ne doit
  pas se lire comme « zero magasin », ce qui simulerait un arret de
  diffusion.
- nbMagReseauByMonth remonte du cache (metricsByMonth, deja stocke par la
  sync) jusqu'a la ligne de grille.
- NetworkLineChart accepte une serie `stores` optionnelle.

DEUX ECHELLES INDEPENDANTES : les quantites se comptent en milliers, les
magasins plafonnent a ~270. Sur une echelle commune la courbe des
magasins serait ecrasee sur l'axe et ne montrerait rien. Chaque serie est
normalisee sur son propre maximum, rappele dans la legende — c'est la
FORME des deux courbes qu'on compare, pas leurs hauteurs. La courbe des
magasins est pointillee et indigo, pour ne jamais entrer en collision
avec le vert/rouge/gris de la tendance.

Branchee sur la carte de la Grille et sur celle de la fiche produit. La
sparkline de la colonne reste a une seule courbe : 46x18 px, deux traces
y seraient illisibles.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DfqUihgixw4K1AmJhizWiu
2026-08-01 09:07:39 +00:00
Claude b6225f1920 Qlik: la sync 12 mois passe — sonde fournisseur bornee, commentaires rectifies
Premier run reussi : 12 mois couverts, 40 411 produits ecrits dans le
cache, statut final=success.

Ce que la sonde paginee montre enfin :

    Periode « (aucune) » → master exposee : 2024-01…2026-07 (31 mois)

Sans aucune Periode selectionnee, « Quantite N » couvre 31 mois, et une
seule passe (Annee=2026) sert les 12 mois de la fenetre. L'etat libre
etait donc le bon depuis le debut ; ce sont deux defauts de l'outil de
MESURE qui le cachaient — la sonde tronquee a 60 lignes, puis le Clear
appele sur le list object. Le commentaire de choisirPeriode affirmait
l'inverse (« sans Periode la master est morte ») : il est rectifie, avec
la mesure a l'appui.

Sonde fournisseur : elle lisait 1 207 205 codes en 240 pages avant de
conclure, trois fois de suite — 87 s pour un rejet joue d'avance. La
lecture s'arrete desormais des le plafond depasse.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DfqUihgixw4K1AmJhizWiu
2026-08-01 07:24:56 +00:00
Claude fee267004f Qlik: Clear/SelectValues sur le CHAMP Periode, pas sur un objet de session
Le journal complet (enfin lisible) donne la cause, et elle est unique :

    choix de « Periode » impossible :
      {"code":-32601,"parameter":"Clear","message":"Method not found"}
    extraction par date brute interrompue :
      {"code":-32601,"parameter":"Clear","message":"Method not found"}

Clear et SelectValues sont des methodes de CHAMP. Je les appelais sur le
list object « Periode ». Consequence : la cartographie des periodes ET le
chemin d'extraction par date brute n'ont jamais tourne — d'ou la
couverture master annoncee a 0/12 alors que la master vit (total mesure :
11 591 263).

- Handle du champ « Periode » obtenu comme ceux d'Annee et de Date ; le
  list object ne sert plus qu'a ENUMERER les valeurs.
- appliquerPeriode selectionne par texte et ne propage plus d'erreur :
  une periode qu'on n'arrive pas a poser doit degrader le resultat, pas
  interrompre l'extraction.
- Chaque candidat de la cartographie est isole : une valeur en echec est
  notee et ignoree, elle n'emporte plus toute la carte.

Sonde fournisseur : « J009 » laissait 1 207 205 articles visibles, soit
tout le catalogue — la selection ne restreignait rien, et la
« couverture » de 97 % ne faisait que constater que le catalogue contient
nos codes. Une selection qui laisse plus de 3x les codes demandes est
desormais rejetee immediatement (elle coutait 3 x 24 s de lecture).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DfqUihgixw4K1AmJhizWiu
2026-08-01 07:00:55 +00:00
Claude d89d2b3232 Parametres : telechargement des journaux serveur complets
Une extraction Qlik produit plusieurs milliers de lignes de diagnostic.
Un terminal les tronque et n'en laisse que la fin, alors que
l'information decisive — carte du modele Qlik, champs date retenus,
choix des periodes — se trouve en TETE. Diagnostiquer sur la fin du log
revient a travailler a l'aveugle.

- src/lib/log-capture.ts : instrumente console.log/warn/error une fois,
  recopie chaque ligne horodatee dans les captures ouvertes, ecrit au fil
  de l'eau dans le repertoire temporaire (un job qui fait tomber le
  process laisse quand meme son journal). Bornes : 200 000 lignes,
  8 000 caracteres par ligne, 20 fichiers conserves.
- GET /api/logs : liste JSON ; GET /api/logs?id=<jobId> : le fichier
  complet en piece jointe. Admin uniquement.
- La sync Qlik ouvre la capture AVANT son premier log et la ferme dans un
  finally, avec le statut final — les deux modes (fournisseur et produit).
- Parametres : section « Journaux serveur », un bouton Telecharger par
  job, le plus recent en premier, badge « en cours » pour un job vivant.

Aucun secret ne transite dans ces journaux : l'extracteur ne journalise
que le NOM du cookie de session, jamais sa valeur ni le mot de passe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DfqUihgixw4K1AmJhizWiu
2026-08-01 06:45:14 +00:00
Claude 50f883ec98 Qlik: extraction mois par mois sur le champ date brut, sans pont de periodes
Le log livre le fait qui manquait :

    dimension « Mois » → 35602068 (100% du total sans filtre)

100 %, donc TOUTE la table de faits tient dans les 12 mois. Le test de
champ date « il doit faire baisser le total » etait donc structurellement
faux : n'importe quel champ correct donne 100 % quand la fenetre couvre
tout. Il rejetait les bons champs, ce qui condamnait le seul chemin sain
et renvoyait vers la dimension Mois — celle qui demultiplie les faits.

Nouveau chemin moisParDateBrute(), essaye AVANT l'agregation par
dimension :

- Type_Cal / Periode / Annee effaces : on veut les faits nus, pas un
  contexte de calendrier. Plus aucun mois « inatteignable », puisqu'un
  mois y est un filtre de dates.
- detection du champ date sur UN SEUL MOIS : valide si le total est non
  nul ET une fraction du total (ni 0, ni 100 %).
- cube [Article Code] x 4 expressions, SANS dimension Mois : c'est elle
  qui passe par le pont et rattache un fait a plusieurs contextes
  (mesure : x5,3).
- 12 selections de dates, 12 cubes pagines, un point de controle par
  mois.

Controle d'integrite : chaque mois doit etre servi, et la somme des 12
mois ne doit pas depasser le total sans filtre de date — une
demultiplication ferait exploser ce rapport. Sinon rien n'est ecrit.

Corrige aussi le meme test defectueux dans moisParExpressions, et permet
d'effacer Periode meme si son list object n'a pas pu etre cree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DfqUihgixw4K1AmJhizWiu
2026-08-01 06:29:54 +00:00
Claude b948aca2f8 Qlik: une Periode PAR ANNEE, et sonde de couverture paginee
Deux defauts, l'un cachant l'autre.

1. La sonde de couverture ne lisait que les 60 premieres lignes du cube
   [Mois] x Quantite N. La dimension Mois porte tout l'historique de
   l'app : les mois recents tombaient hors de la premiere page et la
   sonde annoncait 0/12 pour TOUTES les valeurs de Periode — alors que
   la master vivait (mesure dans le meme log : total 9 721 153). C'est
   ce faux zero qui faisait effacer Periode et condamnait l'extraction.
   Le cube est desormais pagine integralement, et la sonde journalise
   aussi ce que la master expose hors fenetre.

2. La bonne valeur de Periode DEPEND DE L'ANNEE. « Annee a date »
   convient a 2026 (janvier -> mois courant) mais pas a 2025, dont on
   veut les 12 mois. Le choix global ne pouvait donc satisfaire que
   l'une des deux passes — ce que montrait le log : 2026 servait
   2026-01..06, la passe 2025 ne servait rien.

   choisirPeriode cartographie maintenant, pour chaque annee de la
   fenetre, la valeur qui sert le plus de mois attendus de cette
   annee-la (l'etat sans Periode etant un candidat comme un autre), et
   chaque passe annuelle pose SA valeur avant de selectionner l'annee.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DfqUihgixw4K1AmJhizWiu
2026-07-31 20:35:11 +00:00
Claude 5fee5bc058 Qlik: ne plus effacer Periode — c'est elle qui donne son sens a Type_Cal
Le log de production tranche : sans selection de Periode, les master
measures sont MUETTES. Les passes annuelles renvoyaient bien 270 056
lignes, mais toutes mesures nulles — d'ou « 0 point mensuel ajoute »
puis le refus des 12 mois.

choisirPeriode() jugeait chaque valeur de Periode SANS annee
selectionnee. « Annee a date » y parait couvrir 6/12 mois, donc etait
rejetee, donc Periode etait effacee — et tout mourait. Or avec
Annee=2025 selectionnee, la meme valeur donne l'annee 2025 COMPLETE
(l'app affiche « Du 02/01/2025 au 31/12/2025 »), et avec Annee=2026 elle
s'arrete au mois courant. L'union des deux passes fait bien 12 mois.

- La couverture d'une Periode est desormais mesuree comme l'UNION des
  passes annuelles, exactement comme monthDimPath les executera.
- La meilleure valeur est CONSERVEE meme partielle ; seule une
  couverture nulle justifie d'effacer.
- Annees traitees la plus recente d'abord (usage de l'app), arret des
  que la fenetre est complete.
- Diagnostics : mois gagnes / encore manquants apres chaque passe, et
  liste des mois manquants dans les messages d'echec.
- Une passe ne peut plus ecraser un mois servi par un zero.

Deux defauts prouves par le meme log, cote agregation directe :
- la master « Quantite N » morte vidait TOUT l'hypercube (4 expressions
  valides a 6 mois, cube complet a 0 ligne) : elle est desormais sondee
  et omise si morte ;
- ventilee par Mois, Sum(quantite) totalise 179 574 616 contre
  33 945 285 sans dimension (x5,3) : la dimension Mois passe par le pont
  de periodes et demultiplie les faits. Ce chemin est refuse plutot que
  d'ecrire des ventes reseau fausses.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DfqUihgixw4K1AmJhizWiu
2026-07-31 18:56:31 +00:00
Claude fa1b64cfa6 Qlik: selection par code fournisseur au lieu de 41 569 codes articles
Le code fournisseur FF de la base SQL/API existe aussi dans Qlik : une
seule valeur y designe le meme perimetre que les dizaines de milliers de
codes articles. Comme SelectValues sur « Article Code » coute 8 a 100 s
par appel et doit etre refait a chaque passe annuelle, la bascule
supprime la partie la plus couteuse de la sync.

- ETAPE 0 : selectionnerParFournisseur() avant choisirPeriode().
- Essaie QLIK_FIELD_CODE_FOURNISSEUR puis code_fournisseur, Fournisseur,
  fournisseur_code_centrale.
- Verifie la couverture reelle (Article Code visibles vs codes demandes)
  et n'accepte la bascule qu'au-dela de QLIK_SUPPLIER_MIN_COVERAGE
  (0.99 par defaut) ; sinon annule la selection et revient aux codes.
- Quand la bascule est retenue, plus aucun SelectValues sur Article Code
  (passes annuelles et agregation par expressions).
- La sync fournisseur transmet job.fournisseur ; la sync produit (un seul
  code centrale) reste inchangee.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DfqUihgixw4K1AmJhizWiu
2026-07-31 15:44:22 +00:00
Claude e7a387b7de fix(qlik): effacer Période puis extraire année par année — la méthode de l'app
Deux remarques de l'utilisateur ont débloqué le sujet : « sur ce tableau j'ai
2024, 2025, 2026 et je sors mois par mois », et « tu mets 2026 et tu as la
comparaison N-1 ».

Type_Cal='N' ne désigne donc pas « l'année civile en cours » mais L'ANNÉE
SÉLECTIONNÉE. Il suffit d'enchaîner les années couvertes par la fenêtre (2025
puis 2026) pour reconstituer 12 mois glissants — chaque passe livrant les 5
mesures, et non la seule quantité comme « Quantité COMP ».

Ce mécanisme existait déjà (monthDimPath(yearN - 1)) mais restait sans effet :
  [qlik-pw] (mois) passe N-1 terminée : 299905 → 299905 points mensuels
La sélection « Période », héritée de l'ouverture de l'app et jamais choisie par
nous (observé « Période:1/4 » dès le premier diagnostic de la session),
épinglait le contexte sur l'année en cours et annulait la sélection d'année.

- choisirPeriode() teste désormais EN PREMIER l'état sans aucune sélection de
  Période — l'état dans lequel un utilisateur voit 2024/2025/2026 — et efface la
  sélection dès que la couverture est incomplète.
- L'orchestration boucle sur les années de la fenêtre au lieu d'une passe
  principale plus une passe N-1 dérivée de COMP.
- 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. « Quantité COMP » n'est plus utilisée.
- L'agrégation directe des faits reste en secours, et le garde-fou d'intégrité
  reste actif : sans les 12 mois, le cache n'est pas écrit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DfqUihgixw4K1AmJhizWiu
2026-07-31 15:22:19 +00:00
Claude ccd60b119f fix(qlik): ne pas amputer les faits avec une Période partielle + valider chaque expression
Deux constats de la dernière sync.

1. AUCUNE valeur de « Période » ne porte 12 mois glissants — c'est mesuré :
     « juin 2026 » 1/12, « Année à date » 6/12, « Mois à date » 0/12,
     « Semaine 2026/30 » 0/12
   Ce sont 4 contextes relatifs à aujourd'hui, pas des types de période. Les
   master measures ne pourront donc jamais couvrir la fenêtre, et l'agrégation
   directe des faits devient obligatoire.

   D'où un bug du commit précédent : garder la meilleure Période (6/12) RESTREINT
   les faits à 2026-01→06 et ampute l'agrégation directe, qui sait pourtant lire
   tout l'historique. choisirPeriode() efface désormais la sélection quand la
   couverture est incomplète, et ne la garde que si elle couvre les 12 mois.

2. Le cube à 5 mesures calculait 26 s puis rendait 0 ligne, alors que
   Sum(quantite) seul donnait 35 424 324 : signature d'une mesure invalide qui
   invalide tout l'hypercube, sans qu'aucun message ne dise laquelle.

   validerExpression() teste maintenant chaque expression isolément sur un petit
   cube [Mois] avant de construire le gros. 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.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DfqUihgixw4K1AmJhizWiu
2026-07-31 15:03:56 +00:00
Claude 5f3760f03e merge: piloter Période + garde-fou d'intégrité + sélection par dimension Mois
Fusion de deux lignes de travail parallèles sur main, complémentaires et non
concurrentes :

  cd2e77e  selectionnerFenetreViaDimensionMois() — sélectionne les 12 mois par
           qElemNumber sur la dimension maître Mois. C'est ce qui marche : les
           champs Date exposés sont dissociés des faits (100 % du total avant
           comme après), la dimension maître porte les vraies valeurs.
  4ef0e9f  garde-fou d'intégrité — refuse d'écrire le cache si la fenêtre n'est
           pas entièrement couverte. C'est pourquoi le cache est resté inchangé
           au lieu d'être corrompu.
  726fb84  choisirPeriode() — le maillon manquant.

Le dump des expressions donne la cause de fond :

  « Quantité N »    = Sum({<Type_Cal={'N'}>} quantite)
  « Quantité COMP » = Sum({<Type_Cal={'$(vPeriod_comp)'}…>} quantite)

Un pont de périodes duplique les lignes de faits ; Type_Cal marque la période
analysée (N) ou de comparaison (COMP), et le champ Période (4 valeurs) décide
de quelle période il s'agit. Sélectionner correctement les mois ne suffit donc
pas : sous la période par défaut, « Quantité N » rend 0 sur tout mois hors
période courante, même parfaitement sélectionné.

Orchestration fusionnée :
  1. choisirPeriode() essaie chaque valeur de Période et MESURE, sur un cube
     [Mois] × Quantité N, combien de mois de la fenêtre elle rend disponibles.
     Aucun libellé n'est deviné.
  2. Si une période couvre les 12 mois → master measures (exactes sur toute la
     fenêtre). Sinon → agrégation directe des faits, en secours.
  3. Dans les deux cas, la couverture des 12 mois est revérifiée avant
     persistance ; sinon l'extraction est refusée et le cache laissé intact.

La tendance conserve la règle stricte de 4ef0e9f (les 12 clés explicitement
présentes, ou pas de tendance), qui écarte aussi le « +4100 % » calculé sur deux
points pour un article vendu depuis mai.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DfqUihgixw4K1AmJhizWiu
2026-07-31 11:27:51 +00:00
Claude 726fb8418d fix(qlik): piloter le champ Période du modèle au lieu de lutter contre lui
Le dump des expressions donne enfin la cause de fond :

  « Quantité N »    = Sum({<Type_Cal={'N'}>} quantite)
  « Quantité COMP » = Sum({<Type_Cal={'$(vPeriod_comp)'}…>} quantite)
  « Magasin Ventes Nb N » = Count({<Type_Cal={'N'}>} Distinct ventes_code_site)

L'app n'est pas un modèle « faits + calendrier ». 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 le champ Période (4 valeurs, une
sélectionnée) décide de quelle période il s'agit. Tout en découle :

  - sélectionner Date ne change rien (mesuré : 100 % du total avant comme après),
    le périmètre venant du pont de périodes et non du champ date ;
  - « Quantité N » ne couvre que la période courante, d'où août→décembre vides ;
  - « Quantité COMP » ne couvre que les mois ayant un comparable, d'où
    janvier→juillet de l'année précédente ;
  - Sum(quantite) brut additionne les lignes N ET COMP : il double compte, il ne
    pouvait pas servir de substitut.

choisirPeriode() essaie donc chaque valeur de Période, mesure sur un cube
[Mois] × Quantité N combien de mois de la fenêtre elle rend réellement
disponibles, et garde la meilleure. Aucun libellé n'est deviné : c'est la
couverture mesurée qui décide, et elle est tracée. Si aucune valeur ne couvre
les 12 mois, les mois manquants restent absents du cache.

Aussi :
- Les mois réellement couverts reçoivent un zéro EXPLICITE quand l'article n'a
  pas vendu ; la tendance ne retient que les mois présents comme clés. Un article
  vendu depuis mai voyait sa tendance calculée sur deux points (« +4100 % »).
- Les diagnostics du chemin d'extraction sont répétés en fin de sync
  ([qlik-pw][diag]) : ils étaient au début d'un log de plusieurs milliers de
  lignes.
- QLIK_USE_EXPR passe à désactivé par défaut.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DfqUihgixw4K1AmJhizWiu
2026-07-31 11:22:56 +00:00
Michael cd2e77e909 fix Qlik month dimension selection 2026-07-31 12:09:57 +02:00
Michael 4ef0e9fdeb fix Qlik rolling 12-month integrity 2026-07-31 10:28:29 +02:00
Claude db886cd8d9 fix(qlik): supprime la passe de rattrapage et vérifie que la sélection Date filtre
Le log de la dernière sync montre batches=3 et des lignes « (rattrapage) » : le
chemin par expressions a bien tourné mais a été rejeté (quantité totale nulle),
et c'est le rattrapage qui a de nouveau recopié le total de période dans les mois
manquants — d'où les plateaux à 87 648 d'août à décembre et les « -162 % ».

- Passe de rattrapage 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. Un mois qu'on ne sait pas extraire
  doit rester absent, jamais rempli d'une valeur plausible.

- Le chemin par expressions vérifie maintenant qu'un champ date filtre RÉELLEMENT
  les faits, ce que personne n'avait pu contrôler tant que seules des mesures
  insensibles à la sélection étaient utilisées. Un total de contrôle
  (Sum(quantite) sans dimension) est comparé avec et sans filtre ; un champ n'est
  retenu que si le total est non nul ET strictement inférieur au total global.
  Candidats : QLIK_DATE_FIELD, Date, Date calendrier, Date_Key (ce dernier
  sélectionné au format AAAAMMJJ). Si aucun ne filtre, l'extraction le dit
  clairement au lieu de produire une fenêtre fausse.

- Diagnostic : échantillon BRUT du cube loggué avant tout filtrage (mois brut,
  mois normalisé, appartenance à la fenêtre, valeurs des cinq mesures), et
  compteur de lignes écartées comme hors fenêtre. Ces deux traces distinguent une
  expression invalide d'un format de dimension Mois inattendu.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DfqUihgixw4K1AmJhizWiu
2026-07-31 07:01:27 +00:00
Claude f0e36308f2 feat(scripts): qlik-validate.mjs — valider les expressions et la clé de jointure
Le serveur Qlik est filtré par IP : injoignable depuis l'extérieur du réseau FF
(la passerelle d'egress tombe en connection timeout sur 443 comme sur 80, alors
qu'elle atteint le reste d'Internet). Impossible donc de valider à distance les
expressions par défaut ni la clé de jointure.

Ce script répond aux deux questions en une exécution, à lancer depuis le
conteneur de l'app :
  - expressions réelles des master measures (CA N, Quantité N, COMP…) ;
  - tableau mois par mois comparant Sum(quantite) / Sum(ca_ht) / Sum(ca_ttc) /
    Count(DISTINCT [Magasin Code]) aux mesures « N », sur l'année en cours (où
    elles doivent coïncider) et sur l'année précédente (où les « N » sont
    censées être à 0) ;
  - Article Code, article_no_centrale et article_codein côte à côte, pour
    trancher la jointure avec articles.artcentrale.

Lecture seule : sélections en soft lock, objets de session détruits. Le mot de
passe vient de l'environnement, rien n'est écrit ni affiché.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DfqUihgixw4K1AmJhizWiu
2026-07-31 06:43:25 +00:00
Claude 4a56584a27 fix(qlik): les master measures ignorent la sélection Date — agrégation directe des faits
Preuve arithmétique dans les logs : pour l'article 10000397784, qteReseau=164 et
caReseau=2220,75 € sont EXACTEMENT la somme de janvier à juillet 2026
(12+19+24+27+22+31+29 = 164). Et la passe de rattrapage, avec août 2025 seul
sélectionné, a renvoyé ces mêmes 164 / 2220,75 — puis les a recopiés dans les
cinq mois manquants.

« CA N » et « Quantité N » sont donc des mesures « cumul année en cours »,
insensibles au champ Date. Aucune sélection ne peut leur faire produire une
fenêtre 12 mois glissants. D'où, simultanément :
  - des totaux réseau qui é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 » vides sur
    tous les articles ;
  - un rattrapage qui y recopiait le total de période.

Le chemin nominal agrège désormais directement les champs de faits, qui eux
respectent les sélections : Sum(quantite), Sum(ca_ht),
Count(DISTINCT [Magasin Code]), Sum(marge) — tous surchargeables par
QLIK_EXPR_QTE / _CA / _NBMAG / _MARGE. Un seul cube [Article Code, Mois] couvre
les 12 mois : ni passe N-1 ni rattrapage, c'est ce qu'ils compensaient. Les
totaux deviennent la somme des 12 mois de la fenêtre.

Garde-fous : « Quantité N » reste 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ù elle est juste) ;
quantité totale nulle ou erreur Engine → repli automatique sur l'ancien chemin ;
QLIK_USE_EXPR=0 le force.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DfqUihgixw4K1AmJhizWiu
2026-07-31 06:27:33 +00:00
Claude b2a426287f perf(qlik): sync 24 sélections → 1, et résultat partiel au lieu de tout perdre
Les timings de production sont sans appel : 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 » — en perdant
la totalité de l'extraction.

- Le chemin mensuel fait désormais UNE seule sélection pour tous les codes et un
  seul cube paginé : la dimension Mois livre déjà tous les mois d'un coup, rien
  n'obligeait à découper. La lecture des pages coûte ~50 ms. Repli automatique
  sur les lots si l'Engine refuse (les lignes déjà lues sont retirées pour ne
  pas doubler les totaux).
- rattraperMoisVides() adopte le même principe : une sélection de codes, puis
  seule la fenêtre de dates change d'un mois à l'autre.
- Sonde de diagnostic de fin de sync retirée : elle refaisait une sélection
  complète des codes et des 365 jours pour rien.
- Point de contrôle (cfCheckpoint) poussé après chaque passe. Si la session
  meurt, l'extraction repart de là : les codes déjà traités ont des données
  complètes, seul le reliquat manque. Le log signale « résultat PARTIEL ».
- Le script in-page remonte aussi ses lignes en cas d'erreur interne, et
  « rows=[object Object] » dans les logs est corrigé.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DfqUihgixw4K1AmJhizWiu
2026-07-31 06:07:17 +00:00
Claude 6b1caa8ee0 fix(qlik): mois vides sur toute la fenêtre glissante (août→déc pour tous les articles)
Cinq mois consécutifs à 0 sur TOUS les articles n'est pas un résultat métier.
Les mesures « N » de l'app Qlik sont bornées à une année civile ; le chemin
« dimension Mois » sélectionne les 365 jours d'un coup, donc la mesure ne se
résout que sur une année et 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ù le trou d'août à décembre 2025 observé en juillet 2026.

- La passe principale n'écrit plus de 0 pour un mois hors année N, et COMP
  n'écrit rien quand elle est vide : un mois absent peut être rattrapé, un mois
  à 0 se lit comme « pas de vente » et masque le trou définitivement.
- Nouvelle passe rattraperMoisVides() : tout mois resté vide pour tous les
  articles est ré-extrait en ne sélectionnant QUE ses dates. La mesure se résout
  alors sur la bonne année quelle que soit l'écriture de son set analysis. Elle
  ne touche que le mensuel, jamais les totaux de période.
- Les expressions des master measures sont dumpées au log : sans elles,
  impossible de savoir comment la mesure est bornée dans le temps.

Contournement sans redéploiement : QLIK_MONTH_DIM=0 (itération mois par mois,
plus lente mais sans trou). Les données déjà en cache gardent leurs zéros — il
faut relancer la sync pour les corriger.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DfqUihgixw4K1AmJhizWiu
2026-07-31 05:28:22 +00:00
Claude 78b3d41896 docs(qlik): une seule recherche simultanée, pas deux
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DfqUihgixw4K1AmJhizWiu
2026-07-30 05:53:56 +00:00
Claude b3a82cf1e8 fix(produits): une seule session Qlik pour la recherche — c'était la cause des code 15
La sync de la Grille marche parce qu'elle est SEULE sur le serveur Qlik. La
recherche, elle, ouvrait une seconde session concurrente pour les mesures
pendant que la première tenait une énorme sélection : l'Engine coupait les
requêtes (code 15 « Request aborted »).

- Les mesures réseau (CA, quantité, magasins, CA/magasin, marge %) sortent
  désormais du MÊME cube que l'identité, dans la même session : la fenêtre 12
  mois glissants est sélectionnée sur le champ Date, les master measures sont
  résolues par titre comme dans l'extraction de la Grille, et le cube est trié
  par quantité réseau décroissante. Plus d'appel à
  fetchNetworkMetricsPlaywright pendant une recherche.
- Sélection plafonnée à 300 libellés au lieu de 1 000 : sélectionner les 13 446
  libellés contenant « tapis » prenait 81 s puis 486 s. Le cube étant trié, 300
  suffisent pour remonter les meilleures ventes.
- Une seule recherche simultanée (deux jobs en parallèle aggravaient la
  saturation).
- Le détail mensuel vient du cache : le cube de recherche est agrégé. L'upsert
  passe les colonnes mensuelles en COALESCE pour qu'une recherche n'efface pas
  un mensuel déjà extrait par une sync.

Jointure catalogue : les 40 codes ne trouvaient aucune correspondance dans
articles.artcentrale (formats différents — « Article Code » renvoie 8 chiffres,
artcentrale en a 11). Le champ article_no_centrale est maintenant remonté comme
seconde clé candidate, la jointure essaie les deux, et un échantillon des deux
clés est loggué quand aucune ne joint.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DfqUihgixw4K1AmJhizWiu
2026-07-30 05:53:40 +00:00
Claude c9ecb47cb1 fix(produits): recherche asynchrone — la requête était coupée par le reverse proxy
« Unexpected token '<', "<html> <h"... is not valid JSON » : ce n'était pas du
JSON parce que ce n'était pas l'application qui répondait. La recherche enchaîne
deux allers-retours Qlik (dont une extraction mensuelle) et dépassait le délai du
proxy, qui renvoyait sa page d'erreur HTML.

- L'API passe en asynchrone, sur le modèle déjà éprouvé de POST /api/qlik/sync :
  POST démarre un job et rend la main immédiatement, GET renvoie l'avancement
  puis le résultat. Le client interroge toutes les 2 s et affiche l'étape en
  cours (recherche des articles / extraction des ventes / rapprochement
  catalogue). Deux recherches simultanées au maximum.
- Le client ne présume plus que la réponse est du JSON : les statuts 504, 502,
  503, 401 et 403 sont traduits en message actionnable au lieu d'une erreur de
  parsing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DfqUihgixw4K1AmJhizWiu
2026-07-30 04:57:17 +00:00
Claude 466cb53da2 fix(qlik-search): OU implicite entre les mots, cube abandonné, mauvais champs
Les logs de production ont tranché les trois causes.

1. La recherche de list object Qlik applique un OU entre les mots : « tapis
   anti » ramenait 17 696 libellés sur 1 070 645 — presque tous ne contenant
   que « anti ». On cherche désormais mot par mot pour retenir le plus sélectif,
   on filtre en ET côté client (tous les mots présents, insensible
   casse/accents), et on ne sélectionne que les valeurs retenues.

2. Sélectionner ces 17 694 valeurs faisait abandonner le cube suivant
   (code 15 « Request aborted »), d'où le repli systématique sur le catalogue
   local. La sélection est maintenant plafonnée à 1 000 valeurs, faite par
   numéro d'élément (SelectListObjectValues) plutôt que par texte, et les
   appels Engine retentent trois fois sur code 15.

3. Les champs détectés étaient les mauvais : article_libelle_ticket (libellé
   ticket tronqué) au lieu d'Article, et code_fournisseur (le code, d'où les
   « F005 » affichés) au lieu de Fournisseur. Une liste de préférences précède
   désormais l'heuristique, avec repli automatique sur le candidat suivant si
   un champ ne ramène rien.

Aussi :
- Un repli catalogue n'est plus mis en cache 10 min : une panne Qlik passagère
  se corrigeait uniquement avec « Relancer ».
- Le code 15 est traduit en message actionnable, et un terme trop large pour le
  moteur est signalé au lieu d'un « aucun résultat » trompeur.
- Les logs de la page ne relaient plus le bruit du client Qlik (thèmes,
  extensions).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DfqUihgixw4K1AmJhizWiu
2026-07-29 13:41:53 +00:00
Claude b2149ad0a6 fix(ui): message clair quand l'onglet est périmé après un déploiement
« Server Action "…" was not found on the server » n'est pas une panne du
snapshot : Next.js identifie chaque Server Action par un hash calculé à la
compilation, et un onglet ouvert avant un nouveau build continue d'envoyer les
anciens identifiants. La seule issue est de recharger la page.

- src/lib/stale-action.ts : détection de cette erreur + message utilisateur.
- Grille et Snapshots : au lieu d'afficher l'erreur technique brute, on propose
  « Recharger la page ». Les brouillons de la Grille sont persistés
  (localStorage), recharger ne perd rien.
- SuccessModal accepte `variant="error"` : icône d'alerte rouge au lieu de la
  coche verte, et surtout plus de fermeture automatique au bout de 3 s — un
  message d'échec doit rester lisible. Les erreurs s'affichaient jusqu'ici sous
  une coche de succès.
- SuccessModal accepte une action secondaire optionnelle, et joue son animation
  d'entrée en CSS au lieu d'un setState synchrone dans un effet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DfqUihgixw4K1AmJhizWiu
2026-07-29 12:40:16 +00:00
Claude a1a95fa290 fix(qlik-search): la sélection ne s'appliquait pas, les résultats ne correspondaient pas au terme
Symptôme : « tapis anti » renvoyait 40 articles sans rapport (mugs, miroirs,
couvertures) avec des codes centraux contigus — c'est-à-dire le début du
catalogue Qlik, pas un résultat de recherche.

Cause : SearchResults + SelectValues. On ré-injectait les qText renvoyés par la
recherche globale dans un SelectValues sur le champ, sans vérifier le retour et
sans laisser à l'Engine le temps d'évaluer la sélection. Quand la sélection
n'est pas appliquée, le cube renvoie simplement les premières lignes de la
dimension « Article Code » — soit exactement les 40 lignes demandées.

Correctifs :
- Sélection via list object : SearchListObjectFor + AcceptListObjectSearch,
  c'est-à-dire le mécanisme d'un volet de filtre Qlik. Qlik applique sa propre
  sémantique de recherche et sélectionne lui-même les valeurs, sans réinjection
  de valeurs texte. Libellé d'abord, code centrale en repli.
- Délai d'évaluation (QLIK_SETTLE_MS, défaut 250 ms) après ClearAll, après la
  recherche et après l'acceptation, comme le fait déjà l'extraction.
- Garde-fou côté client : chaque ligne du cube est revérifiée contre le terme
  (tous les mots présents dans le libellé, insensible casse/accents, ou code
  correspondant). Une liste sans rapport avec la recherche est pire que zéro
  résultat.
- Dédoublonnage par code centrale : un article référencé chez plusieurs
  fournisseurs produisait autant de lignes (vu sur « Couverture de Pique-nique »,
  code 10636135, listé deux fois).
- Diagnostic : inventaire des champs, nombre de valeurs trouvées, sélections
  actives et compteur de lignes écartées sont tracés dans les logs ; si tout est
  écarté, l'UI dit que le filtre n'a pas été appliqué au lieu d'afficher un
  « aucun résultat » trompeur.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DfqUihgixw4K1AmJhizWiu
2026-07-29 09:14:56 +00:00
Claude 62375d3310 feat(qlik-search): surcharge des champs libellé/fournisseur + inventaire en trace
Les intitulés des champs varient d'une app Qlik à l'autre. La détection reste
heuristique, mais l'inventaire complet des champs est désormais loggué et deux
variables d'environnement permettent de corriger le tir sans redéploiement de
code : QLIK_FIELD_ARTICLE_LIBELLE et QLIK_FIELD_FOURNISSEUR.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DfqUihgixw4K1AmJhizWiu
2026-07-29 05:05:36 +00:00
Claude 203c96f6c9 feat(produits): recherche Qlik d'abord + fiche réseau complète, tendance 12 mois glissants
Recherche produit
- La page /produits interroge désormais Qlik Sense EN PREMIER
  (src/lib/qlik-search.ts) : recherche globale Engine sur les champs article
  (code + libellé détectés dynamiquement via FieldList), sélection des valeurs
  trouvées, puis liste des Article Code correspondants.
- Les métriques réseau des articles trouvés sont extraites sur 12 mois
  glissants et mises en cache (qlik_network_metrics), puis rapprochées du
  catalogue FF Nancy par code centrale (pgGetProduitsByCodeCentrale).
- Repli propre sur la recherche catalogue local si Qlik est injoignable, signalé
  dans l'UI. Résultats mis en cache mémoire 10 min, requête dédupliquée.
- Nouvelle route GET /api/produits/search (runtime nodejs, 300 s) ; la liste est
  chargée côté client avec état de chargement, l'extraction Qlik prenant
  plusieurs secondes.

Informations affichées
- Résultats : magasins vendeurs (+ % du réseau), quantité réseau, quantité par
  magasin, prix moyen réseau, CA par magasin, marge %, sparkline de tendance,
  fournisseur, et présence ou non au catalogue Nancy.
- Fiche : carte « Performance réseau · 12 mois glissants » en tête (8 indicateurs
  + détail mensuel qté / magasins / qté par magasin / CA / prix moyen / marge %),
  puis carte « Fournisseur » dédiée, puis identité, stock, nos ventes 12 mois.
- La fiche s'ouvre aussi par code centrale (?cc=) pour les produits que le réseau
  travaille et que Nancy ne référence pas — le libellé et le fournisseur Qlik
  sont persistés dans le cache réseau pour ce cas.

Tendance réseau
- computeNetworkTrend reconstruit sa fenêtre depuis la date du jour : 12 mois
  complets, mois en cours EXCLU (il est partiel et écrasait la pente). Les mois
  absents du cache valent 0 ; ceux antérieurs à la première extraction réelle
  sont écartés au lieu d'être inventés à 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DfqUihgixw4K1AmJhizWiu
2026-07-29 05:04:34 +00:00
Claude abc482c370 fix(produits): recherche insensible aux accents + hiérarchie nomenclature par préfixe
Deux régressions constatées en production sur la page /produits.

1. La recherche par libellé ne remontait rien. `ILIKE '%tapis anti-poussière%'`
   exige l'accent ET le tiret exacts, alors que les libellés sont stockés en
   majuscules sans accent ni ponctuation ("TAPIS ANTI POUSSIERE"). La recherche
   est désormais normalisée des deux côtés (minuscules, accents retirés,
   ponctuation ramenée à un espace) et fonctionne mot à mot : tous les mots
   doivent être présents, dans n'importe quel ordre. « tapis poussiere » retrouve
   donc « TAPIS ANTI-POUSSIÈRE », et inversement.

   Postgres n'a pas unaccent() sans extension et on ne crée rien sur la base FF :
   la normalisation SQL passe par translate() + regexp_replace().

2. Secteur et Famille restaient vides sur la fiche. La table `nomenclature`
   n'expose en production que no_id / code / libelle / niveau / chemin_pere :
   aucune FK entière vers le parent, et chemin_pere est un chemin texte, donc la
   découverte automatique renvoyait null. La hiérarchie est maintenant
   reconstruite par préfixe de code quand aucune colonne parent n'existe
   (secteur = 2 chiffres, famille = 4, sous-famille = 6, ex "330702" → "3307" → "33").

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XR8cMbsdjXWChX8seL9NCP
2026-07-27 15:31:08 +00:00
Claude ec35985c50 Merge: recherche produit + fiche 360° local/réseau 2026-07-27 14:30:42 +00:00
Claude eeaae09282 feat(produits): page de recherche produit + fiche 360° local/réseau
Nouvelle page /produits : recherche par libellé ou par code centrale, puis
fiche complète d'un produit — identité, prix, stock par magasin, ventes sur
12 mois glissants, et l'intégralité des métriques réseau Qlik.

Jusqu'ici les données produit n'étaient accessibles que par fournisseur
(la Grille) ou par classement (Hit Parade) : aucune route n'acceptait un
codein. Les données réseau Qlik n'étaient visibles que dans six colonnes
de la grille d'arbitrage.

Deux analyses ajoutées :
- comparatif local vs réseau, ramené au magasin moyen (indice de
  performance sur les quantités, seule base commune fiable — le CA local
  est TTC et la base du CA Qlik n'est pas documentée, donc indicatif) ;
- opportunités de la même famille : références que le réseau vend bien et
  que nous vendons peu, écart calculé par magasin.

Détail mensuel réseau enrichi : le cube Qlik renvoyait déjà CA, nb de
magasins, CA/magasin et marge % par (article, mois) mais seule la quantité
était conservée. Ces mesures sont désormais stockées dans une nouvelle
colonne metrics_by_month, sans requête Qlik supplémentaire. qte_by_month
reste inchangée — la colonne « Tendance / Réseau » de la Grille en dépend.

L'extraction Qlik peut désormais cibler un seul code centrale
(/api/qlik/sync?codeCentrale=…), ouverte à tout utilisateur connecté et
plafonnée à 3 extractions simultanées ; le mode fournisseur reste
réservé aux admins.

Refactor sans changement de comportement : les helpers de mois, le calcul
de tendance et les graphes SVG quittent heatmap-grid.tsx pour des modules
partagés, et le polling des jobs Qlik devient le hook useQlikSyncJob,
réutilisé par la Grille et par la fiche.

Les calculs de ventes reprennent le SQL canonique de la Grille (genremvt=3
avec quantités niées pour déduire les retours, stock de fin de mois pris
sur le dernier mouvement, report sur les mois sans mouvement), afin que
les chiffres des deux pages soient identiques par construction.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XR8cMbsdjXWChX8seL9NCP
2026-07-27 14:26:08 +00:00
Claude 86cdc86963 Merge: mesures par titre + relevé étiqueté 2026-07-26 04:41:37 +00:00
Claude 03b6ddb8c9 fix(qlik): mesures résolues par titre + relevé étiqueté (vérifier qté vs nb magasins)
CA / Quantité / Nb magasins étaient pris depuis des qId d'environnement
(GUID), sans garantie qu'ils pointent la bonne mesure — les deux autres
mesures étaient déjà résolues par titre pour cette raison. Toutes le sont
désormais ('CA N', 'Quantité N', 'Magasin Ventes Nb N'), avec repli sur
l'env.

Ajout de deux logs de vérification :
- les identifiants de mesure réellement utilisés ;
- un échantillon de ligne mensuelle avec chaque valeur ÉTIQUETÉE, pour
  comparer directement avec Qlik et confirmer que la courbe utilise bien
  la quantité vendue et non le nombre de magasins.
2026-07-26 04:41:36 +00:00
Claude a6cfb75caa Merge: 12 mois glissants contigus (passe N-1) 2026-07-26 04:34:57 +00:00
Claude 59efd70886 feat(qlik): 12 mois glissants contigus via passe complémentaire année N-1
Le graphe sautait de 07/25 à 01/26 : l'astuce COMP ne fournit un mois N-1
que s'il existe un mois N correspondant. L'année N s'arrêtant au mois
courant (juillet), les mois août→décembre N-1 étaient introuvables.

Ajout d'une passe complémentaire : on sélectionne le champ 'Année' sur
N-1, ce qui fait porter les mesures 'N' sur cette année → ses 12 mois
sont récupérés directement (prioritaires sur les valeurs COMP dérivées).
Les totaux réseau (CA/Qté) ne sont alimentés que par la passe principale
pour éviter tout double comptage. Passe ignorée proprement si le champ
'Année' est absent ou si elle échoue.

Résultat attendu : mois contigus 2025-08 → 2026-07 dans le modal (l'UI
garde les 12 derniers mois triés).
2026-07-26 04:34:55 +00:00
Claude b10a5a027e Merge: vrais 12 mois via COMP dérivé 2026-07-25 21:31:17 +00:00
Claude 99097b6eca fix(qlik): dérive les mois N-1 depuis Quantité COMP (vrais 12 mois)
Le cube [Article Code, Mois] ne renvoie que les mois de l'année N (les
mesures 'N' sont nulles ailleurs → lignes supprimées). Le code cherchait
des lignes d'année N-1 qui n'existent pas, donc 'Quantité COMP' n'était
jamais exploitée → seulement ~7 mois affichés.

'Quantité COMP' étant le comparable N-1, à la ligne Mois=YYYYMM elle donne
la quantité du MÊME mois l'année précédente. On dérive donc deux points
par ligne : YYYY-MM (Quantité N) et (YYYY-1)-MM (Quantité COMP) → 12 mois
sans ligne supplémentaire ni requête en plus.

UI : l'en-tête du modal affiche le nombre RÉEL de mois disponibles au lieu
de '12 derniers mois' en dur.
2026-07-25 21:31:15 +00:00
Claude 164bda1fe9 Merge: message OOM Qlik explicite 2026-07-24 08:12:00 +00:00
Claude 1633d5e407 fix(qlik-sync): message explicite quand le serveur Qlik est en OOM
Quand l'app Qlik ne peut pas se charger faute de RAM serveur (code 6
'Not enough memory to load file' / code 3002 'File corrupted' / 'Out of
memory'), le job affiche un message clair ('Serveur Qlik saturé…') au
lieu de l'erreur technique. Ce n'est pas un bug CollectFlow : le sync
échoue à l'ouverture de l'app, avant toute requête.
2026-07-24 08:11:58 +00:00
Claude b5144ed0cf Merge: tooltip tendance + lots Qlik plus petits 2026-07-24 07:41:42 +00:00
Claude 2af7c42b2b fix(grille): tooltip tendance (régression 12 mois) + lots Qlik plus petits (OOM)
- Corrige le tooltip erroné 'X% (4 derniers mois vs 4 précédents)' →
  'Tendance X% sur 12 mois (régression)'. Le calcul est déjà une
  régression linéaire sur 12 mois ; seul le texte était resté l'ancien.
- Réduit la taille des lots du cube [Article Code, Mois] (800→300, replis
  150/75) pour alléger la mémoire moteur Qlik et limiter les 'Out of
  memory / File corrupted' (3002) observés sur le sync.
2026-07-24 07:41:41 +00:00
Claude 147a05c090 Merge into main: modal courbe 12 mois glissants 2026-07-24 06:29:38 +00:00
Claude ac1c404ce5 feat(grille): modal courbe sur 12 mois glissants (réintègre Quantité COMP)
Le modal doit couvrir 12 mois glissants (pas seulement depuis janvier).
Comme 'Quantité N' ne couvre que l'année en cours, on réintègre la mesure
'Quantité COMP' (N-1) pour les mois de l'année précédente : mois année N
→ Quantité N, mois année N-1 → Quantité COMP. La courbe du modal affiche
donc les 12 derniers mois.
2026-07-24 06:29:37 +00:00
Claude 24ed93f833 Merge into main: modal courbe unique + retrait COMP 2026-07-23 20:40:26 +00:00
Claude 5b5e69eed1 feat(grille): modal en courbe unique + retrait Quantité COMP (2025)
- Modal : les 12 barres remplacées par UNE seule courbe SVG des ventes
  réseau mensuelles (points + quantités affichées + axe des mois).
- Sync : retrait de la mesure 'Quantité COMP' (année N-1) — trop lent à
  récupérer. Retour au cube [Article Code, Mois] à 5 mesures (Quantité N).
  Le détail mensuel couvre donc les mois de l'année en cours ; la tendance
  (régression) se calcule sur ces mois.
2026-07-23 20:40:24 +00:00
Claude dec99ce3c1 Merge into main: modal Tendance + régression 12 mois + Quantité COMP 2026-07-23 19:53:22 +00:00
Claude 6d349e33b4 feat(grille): modal sur colonne Tendance + tendance par régression 12 mois + Quantité COMP
UI:
- La colonne Tendance (renommée 'Réseau · 12 m') ouvre le modal au clic
  (au lieu de la colonne Nb magasins, remise en simple affichage).
- Tendance calculée par RÉGRESSION LINÉAIRE (moindres carrés) sur les 12
  derniers mois : robuste au bruit, % = pente×durée/moyenne. Libellé
  Forte hausse/Hausse/Stable/Baisse/Forte baisse (seuils ±8% / ±25%).

Sync:
- Ajout de la mesure 'Quantité COMP' (année N-1) au cube [Article Code,
  Mois] → 12 mois glissants : mois de l'année N via Quantité N, mois de
  l'année N-1 via Quantité COMP. Cube 8 colonnes, PAGEM 1200 (<10000
  cellules). Total réseau reste en année N (non gonflé).
  Configurable : QLIK_MEAS_QTE_COMP_ID.
2026-07-23 19:53:20 +00:00
Claude 76e908e8b2 Merge into main: découpage mensuel réseau via dimension Mois 2026-07-23 13:15:44 +00:00
Claude 8a3f18f23b feat(qlik): découpage mensuel réel via dimension Mois (tendance + total corrigé)
La sonde a prouvé que les mesures 'Quantité N' se découpent correctement
par la dimension 'Mois' (id pfGAwTs, format 'YYYYMM'). L'itération par
sélection de Date, elle, renvoyait la valeur annuelle identique chaque
mois (tendance plate + total sommé 12×).

Nouvelle extraction réseau (activée par défaut, QLIK_MONTH_DIM=0 pour
revenir à l'ancienne) : un cube [Article Code, Mois] × 5 mesures par lot
de codes (avec repli code 15 800>400>200), sélection de la fenêtre de
dates une seule fois. Chaque ligne (code, mois, ...) alimente qteByMonth
en 'YYYY-MM' et le total par somme RÉELLE des mois.

Résultat attendu : la colonne Tendance affiche enfin de vraies variations,
le modal montre les ventes réseau mois par mois, et le total réseau n'est
plus gonflé 12×. Nécessite un nouveau Sync pour re-remplir le cache.

Note : 'Quantité N' = année N, donc les mois disponibles sont ceux de
l'année en cours (pas un glissant 12 mois strict) — suffisant pour la
tendance ; on pourra ajouter 'Quantité COMP' pour compléter N-1 si besoin.
2026-07-23 13:15:41 +00:00
Claude e8507490f7 Merge: sonde Mois en fin de sync 2026-07-23 12:10:51 +00:00
Claude adb8455353 diag(qlik): déplace la sonde Mois à la fin du sync (visible dans la queue du log) 2026-07-23 12:10:49 +00:00
Claude f0a553eb92 Merge: sonde Mois scopée 2026-07-23 12:05:23 +00:00
Claude 4cb2c7d1c5 diag(qlik): sonde Mois scopée (50 codes + fenêtre) pour éviter code 15 2026-07-23 12:05:21 +00:00
Claude 0511a3a7ea Merge into main: sonde cube [Article Code, Mois] 2026-07-23 11:52:54 +00:00
Claude 332b21c1ae diag(qlik): sonde cube [Article Code, Mois] x Quantite N
Le dump révèle une dimension 'Mois' (id pfGAwTs) et 'Période' (tKTPVU).
La sonde crée un mini-cube [Article Code, Mois] × Quantité N et logue le
format des valeurs Mois + si la quantité varie par mois — pour concevoir
le vrai découpage mensuel (dimension Mois) qui règlera tendance + total 12x.
2026-07-23 11:52:52 +00:00
Claude 21758573c2 Merge into main: chemin rapide Qlik par défaut + dump mesures/dimensions 2026-07-23 11:38:38 +00:00
Claude 473ed1e467 perf+diag(qlik): chemin rapide activé par défaut + dump mesures/dimensions
- Chemin rapide select-all activé par DÉFAUT (fiable, aucun code 15 en
  prod) — désactivable seulement via QLIK_SELECT_ALL_CODES=0. Plus besoin
  de la variable d'env (elle sautait à chaque mise à jour de la stack).
- Dump diagnostic des mesures + dimensions Qlik disponibles (titre+id)
  pour identifier une mesure Quantité non-annuelle / une dimension Mois,
  car les mesures 'CA N'/'Quantité N' renvoient la valeur annuelle figée
  (mois identiques → tendance impossible + total réseau sommé 12×).
2026-07-23 11:38:36 +00:00
Claude b9a5bd5e22 Merge into main: diag qteByMonth distribution (variant vs identique) 2026-07-23 11:35:29 +00:00
Claude 74b183abb3 diag(qlik): distribution codes variants vs identiques (mois) + exemples 2026-07-23 11:35:27 +00:00
Claude 5aeb1ca496 Merge branch 'claude/exciting-goldberg-wILkj' into main
diag(qlik): log échantillon qteByMonth pour diagnostiquer la tendance
réseau (indique si les valeurs mensuelles varient ou sont identiques).
2026-07-23 11:16:12 +00:00
Claude 5b74133ecd diag(qlik): log échantillon qteByMonth (vérifie si les mois varient) 2026-07-23 11:14:58 +00:00
Claude 5b874f3581 Merge branch 'claude/exciting-goldberg-wILkj' into main
feat(grille): tendance réseau (sparkline 12 mois) + modal ventes réseau
par mois. Nécessite un nouveau Sync Qlik pour peupler le détail mensuel.
2026-07-23 09:41:03 +00:00
Claude 71563500a0 feat(grille): tendance réseau (sparkline) + modal ventes réseau par mois
Le sync Qlik calculait déjà les ventes réseau mois par mois mais les
sommait en un total. On conserve désormais le détail mensuel.

- Sync : accumulation additive qteByMonth { code: { 'YYYY-MM': qté } }
  in-page (chemins rapide + par lots), rattachée au métrique réseau.
  Reset propre en cas de repli code 15. Total inchangé.
- Stockage : colonne jsonb qte_by_month sur qlik_network_metrics
  (schema + db-init ALTER), upsert + lecture.
- ProductRow.qteReseauByMonth propagé jusqu'à la grille.
- UI grille :
  · colonne '% Présence réseau' remplacée par 'Tendance réseau' :
    sparkline 12 mois teintée + flèche + variation % (moyenne 4 derniers
    mois vs 4 précédents), triable ;
  · cellule 'Nb magasins' rendue cliquable → modal des ventes réseau
    mois par mois (barres, 12 derniers mois).

Nécessite un nouveau Sync Qlik pour peupler le détail mensuel (les
données déjà en cache n'ont que le total → modal 'pas encore de détail').
2026-07-22 14:17:57 +00:00
Claude ff264dcdff Merge branch 'claude/exciting-goldberg-wILkj' into main
perf(qlik): chemin rapide select-all pour la sync Qlik derrière le flag
QLIK_SELECT_ALL_CODES (repli auto sur le chemin par lots en cas de code 15).
2026-07-22 11:34:32 +00:00
Claude 6c5e28b05e perf(qlik): chemin rapide select-all (sélection Article Code unique) derrière flag
La sync Qlik itérait mois × lots de 150 articles, en re-sélectionnant les
mêmes Article Code à chaque lot. La SelectValues répétée (~22 s × ~120)
représentait ~72% du temps total (~60 min pour 12 mois).

Ajoute un chemin rapide (flag env QLIK_SELECT_ALL_CODES=1) qui sélectionne
TOUS les Article Code une seule fois, puis n'itère que la sélection Date
par mois avec un seul cube paginé (Article Code en dimension). Passe de
~120 SelectValues Article Code à 1, et de ~120 cubes à 12 → gain estimé
~60 min → ~5-8 min.

Repli automatique et sûr : en cas d'erreur moteur code 15, les résultats
partiels et les sélections sont réinitialisés et l'ancien chemin par lots
reprend le relais. Flag désactivé par défaut : comportement inchangé tant
qu'on n'active pas la variable. À valider sur le Qlik réel.
2026-07-22 09:47:31 +00:00
Claude 6adcb0d4c8 Merge branch 'claude/exciting-goldberg-wILkj' into main
Grille : bouton "Rafraîchir" pour forcer le rechargement serveur (bypass
du cache 10 min, met à jour la colonne INIT et toutes les données).
2026-07-22 06:14:59 +00:00
Claude 2e25bbc5b3 feat(grille): bouton "Rafraîchir" pour forcer le rechargement serveur
Ajoute un bouton dans l'en-tête de la Grille qui force le rechargement
des données depuis le serveur en ignorant le cache mémoire de 10 min
(via le paramètre URL _refresh → refresh=1 → forceRefresh). Utile pour
remonter immédiatement l'état serveur courant, notamment la colonne INIT.
Spinner pendant le chargement, désactivé si un chargement est en cours.
2026-07-21 21:11:28 +00:00
Claude d0f92e46a3 Merge branch 'claude/exciting-goldberg-wILkj' into main
Corrections Hit Parade / Gestion de stock (ventes nettes par magasin,
tri stock négatif) et Grille (colonne INIT des gammes rechargée à chaque
chargement, y compris sur cache; colonne Gamme conserve le snapshot).
2026-07-21 15:15:34 +00:00
Claude f0fdab465c fix(grille): colonne INIT rechargée à chaque chargement (même sur cache)
La colonne Gamme conserve la valeur du snapshot (comportement voulu). Mais
la colonne INIT ne se mettait pas à jour : getProductRows sert les lignes
depuis un cache mémoire de 10 min, et le client n'envoie 'refresh=1' que
sur le bouton de rafraîchissement manuel. Sur une navigation normale dans
les 10 min, codeGammeInit restait donc figé.

Correctif : sur un hit de cache, on re-requête la gamme serveur courante
(pgGetGammesByFournisseur, 1 requête légère) et on met à jour uniquement
codeGammeInit sur les lignes cachées. codeGamme (snapshot) n'est pas
touché ; les données lourdes (ventes/stock/réseau) restent cachées.

Résultat : la colonne INIT reflète toujours l'état serveur à chaque
chargement, et le marqueur 'modifié' (Gamme ≠ INIT) se résorbe dès que
le serveur rattrape la modification. La révision précédente (reconcilier
codeGamme) est annulée : la colonne Gamme reste le snapshot tel quel.
2026-07-21 15:05:02 +00:00
Claude e9ac5bc0a5 fix(grille): la grille reflète l'état serveur courant des gammes (INIT rechargé)
La grille rechargeait bien le gamme INIT depuis l'état live du serveur à
chaque chargement (Phase 6), mais la Phase 9 réappliquait ensuite
AVEUGLÉMENT le 'after' du dernier snapshot sur codeGamme — même quand la
modification avait déjà été appliquée sur le serveur (ou que la gamme
serveur avait évolué depuis). Résultat : la grille affichait l'ancienne
cible du snapshot au lieu de l'état serveur réel, et une modif 'en
attente' fantôme.

Désormais on ne réapplique une modif du snapshot QUE si elle est encore
réellement en attente côté serveur (change.before === gamme live). Sinon
on garde l'état serveur courant. La grille montre donc toujours où en
sont les gammes sur le serveur, et une modif disparaît du 'en attente'
dès qu'elle est appliquée côté serveur — le suivi reste juste.

Note : les brouillons locaux (localStorage) restent gérés côté client
comme du travail non sauvegardé.
2026-07-21 15:00:40 +00:00
Claude eba4cb0774 fix(hit-parade,stock): retire le filtre montant (théorie invalidée) + tri stock négatif
Analyse de cohérence de stock sur le cas 487673 site 579 : 24 réceptions,
31 ventes brutes, 7 retours clients → 24 ventes nettes, stock final 3.
Le « 38 » affiché = 31 + 7 : signature de l'ancienne formule SUM(ABS)
qui additionnait les retours. La formule nette SUM(-qtemvt) déjà en place
donne 24 — le filtre mntmvtttc <> 0 (hypothèse de lignes fantômes 0 €)
était inutile : aucune ligne de ce type dans les données réelles. Retiré
pour rester strictement aligné sur le calcul de la Grille.

Gestion de stock : ORDER BY cs.qte (colonne affichée) au lieu de
cs.stockdispo (colonne différente) dans pgGetStockNegatif.
2026-07-07 06:46:45 +00:00
LogiFlow cda149ccaa Merge pull request #6 from R0m1k3/claude/exciting-goldberg-wILkj
Claude/exciting goldberg w i lkj
2026-07-06 23:19:39 +02:00
Claude ea19c5771e fix(hit-parade): exclut les lignes de quantité sans montant (corrections)
Cas réel : codein 487673 site 579 affichait 38 vendus au lieu de 24,
avec un CA pourtant exact (215,76 €) — mvtart contient des lignes
genremvt=3 portant une quantité mais aucun montant TTC (corrections /
régularisations), que les endpoints officiels de l'API excluent.

Ajout du filtre mntmvtttc IS NOT NULL AND <> 0 dans l'agrégation :
une vente réelle a toujours un montant TTC non nul. Validé sur
PostgreSQL local en reproduisant le cas : sans filtre 38/215,76 €
(= symptôme), avec filtre 24/215,76 € (= réalité). Le CA et les
requêtes Analytics ne changent pas (ces lignes valent 0 €).
2026-07-06 21:15:44 +00:00
Claude 159508132c fix(hit-parade): agrégation isolée sur mvtart×articles — corrige les chiffres faux des 2 magasins
La version précédente agrégeait les ventes avec les jointures fournisseur
(LATERAL par ligne de mouvement + fouident) dans la même passe : si
fouident contient des codes dupliqués, chaque mouvement était compté
plusieurs fois → chiffres faux sur les deux magasins, et le LATERAL
s'exécutait par mouvement (très lent).

Restructuration, validée sur 12 000 mouvements réels de l'API chargés
dans un PostgreSQL 16 local :
- CTE ventes : mvtart JOIN articles uniquement (1:1), SUM(-qtemvt) /
  SUM(-mntmvtttc) / SUM(margemvt) par (codein, site) — le calcul
  canonique de la Grille (pgGetMensuelByFournisseur), signes vérifiés
  sur les mouvements réels (ventes négatives, genremvt=3).
- CTE attrs : libellé/fournisseur/nomenclature joints APRÈS agrégation,
  dédupliqués par DISTINCT ON — aucune jointure ne peut plus fausser
  les sommes (totaux invariants aux doublons artfou1/fouident injectés).
- Même principe pour pgGetCaByFournisseur, pgGetStockNegatif,
  pgGetSansVente6Mois : sous-requête artfou1 DISTINCT ON au lieu du
  LATERAL par ligne.
2026-07-06 20:49:12 +00:00
Claude b7de347a99 fix(grid,hit-parade): convention de signe nette + lint React Compiler
- Grille (fallback API mensuel) : Math.abs remplacé par la négation sur
  qte_vendue/ca_ht (négatifs côté API) — les retours clients restent
  déduits au lieu d'être inversés en positif, même convention que le SQL.
- Hit Parade (client) : ColHeader extrait hors du composant (erreur
  'Cannot create components during render'), useMemo déplacés avant
  exportToExcel pour préserver la mémoïsation React Compiler, variable
  idx inutilisée supprimée. ESLint 0 erreur, build OK.
2026-07-06 20:28:03 +00:00
Claude d733755659 fix(hit-parade): ventes nettes par magasin — corrige les chiffres faussés (579)
La requête Hit Parade divergeait du calcul canonique de la Grille :
- SUM(ABS(qtemvt)) additionnait les retours clients comme des ventes au
  lieu de les déduire → quantités et CA gonflés sur le magasin ayant des
  retours/avoirs sur la période (visible sur 579).
- ABS(SUM(mntmvtttc)) inversait le signe d'un CA net négatif.
- GROUP BY no_id + jointure artfou1 preference=1 non dédupliquée pouvait
  produire plusieurs lignes par (codein, site), écrasées par le pivot.

Corrections :
- Hit Parade : ventes nettes SUM(-qtemvt) / SUM(-mntmvtttc), agrégation
  par (codein, site), fournisseur préféré via LATERAL ... LIMIT 1,
  stock agrégé par codein ; pivot en accumulation (+=) par sécurité.
- Analytics (même classe de bug) : pgGetCaByFournisseur et
  pgGetCaByNomenclature passent en CA net ; jointure fournisseur
  dédupliquée (LATERAL LIMIT 1) pour éviter le double comptage.
- Gestion de stock : pgGetStockNegatif et pgGetSansVente6Mois dédupliquent
  le fournisseur préféré (LATERAL LIMIT 1) pour éviter les lignes en double.

Audit complet du mapping par magasin (292/579) côté client : aucun champ
inversé (hit-parade, analytics, dashboard, grid, heatmap). Build OK.
2026-07-06 15:55:43 +00:00
R0m1k3 32f9a27563 Fix Qlik network column sorting 2026-06-22 12:12:48 +00:00
R0m1k3 abac5c5ee5 perf(qlik): optimize network sync extraction 2026-06-22 09:58:49 +00:00
R0m1k3 1cb6c2496d fix(qlik): retry engine aborts after selections 2026-06-22 06:13:05 +00:00
R0m1k3 f005c08ad6 fix(qlik): recreate hypercube per batch 2026-06-22 05:44:37 +00:00
R0m1k3 850852912f fix(qlik): track async sync job status 2026-06-21 19:08:04 +00:00
R0m1k3 e7bcba169c fix(qlik): extract by month and code batches 2026-06-21 18:24:53 +00:00
R0m1k3 c4eeab11d2 fix(qlik): select all articles per month 2026-06-21 16:43:36 +00:00
R0m1k3 649a1df267 fix(qlik): aggregate multi-month extraction correctly 2026-06-21 14:32:51 +00:00
R0m1k3 6036ea5600 fix(qlik): avoid abort on batched network extraction 2026-06-21 12:08:13 +00:00
R0m1k3 6d27c7935d fix: align qlik grid extraction date range 2026-06-21 11:26:49 +00:00
MichaelandClaude Opus 4.8 de8a435c14 fix(qlik): batch SelectValues to avoid Engine abort + drop couv stock metric
Large supplier (4816 codes) triggered Qlik Engine "Request aborted"
(code 15): a single SelectValues over all codes forced the hypercube to
calculate too many rows at once. Now select in 1200-code batches and
accumulate, keeping each calc small. 1417 codes worked before, 4816 did
not - chunking scales to any supplier.

Also remove the "Couverture de stock" network column end-to-end (grid,
types, Qlik clients, cache, schema, db-init), shifting the marge measure
index from 6 to 5 and the hypercube width from 7 to 6.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 10:55:03 +02:00
MichaelandClaude Opus 4.8 8cc95cb387 fix(grid): align Sync Qlik button with summary-bar actions
The button was a two-line flex-col (button + date below), making it
taller than the single-line Export button next to it, so its button sat
visibly higher in the items-center row. Lay it out inline instead: the
"MAJ Qlik" date sits to the left (hidden under lg, kept in the tooltip),
the button keeps the 36px btn-action height and now aligns with siblings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 09:41:28 +02:00
MichaelandClaude Opus 4.8 5f364f7d52 feat(grid): refine grid page chrome (refined dense-pro, theme kept)
Redesign the grid page shell while preserving the Slate/teal Apple theme:
- header reworked into a branded bar: teal accent marker + uppercase
  eyebrow + supplier name as the hero, with "Magasin" and "Références"
  metric pills
- loading/error state shown as a teal (or error-red) accent chip
- "Valider" save button restyled with the brand gradient + soft shadow
- grid container radius/shadow aligned to the apple-card system

No change to table cell logic or the network columns.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 09:38:14 +02:00
MichaelandClaude Opus 4.8 524faf4a7a feat(qlik): drop network rupture% metric and the "Sans vente 6 mois" grid tab
Stock Rupture % N saturated at 100% for high-presence products (degenerate
at the article x network grain), so remove it end to end: config id,
NetworkMetric, hypercube (width 8->7), cache, schema, enrich and grid
column. db-init drops the column on existing DBs.

Also remove the "Sans vente 6 mois" tab from the grid page: the grid now
renders directly without the tab switcher; delete the NoSalesTab component.
(The separate Stock negatif page keeps its own sans-vente tabs.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 09:32:03 +02:00
MichaelandClaude Opus 4.8 af93e362e0 chore(qlik): read measure expressions via GetMeasure/GetProperties
The qData expr path returned empty. Fetch the real master-measure
definition through GetMeasure -> GetProperties (qMeasure.qDef.qDef) and
log rupture, couverture and CA/mag expressions to understand why
Stock Rupture % saturates at 100% for high-presence products.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 09:20:43 +02:00
MichaelandClaude Opus 4.8 ae1deedd19 chore(qlik): log measure expressions to diagnose rupture%=100%
3 of 4 new measures now resolve to engine qIds and return varied values
(CA/mag, couverture, marge%). Stock Rupture % N resolves but returns a
constant 1.0 (100%) per article — a measure-semantics issue. Log the
resolved rupture & couverture expressions to understand the definition,
and make the enrich sample pick a non-zero match (the previous sample hit
a no-sales code and looked all-zero).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 09:01:51 +02:00
MichaelandClaude Opus 4.8 da73693ea0 fix(qlik): resolve 4 new measures by title, not QRS GUID
The new measures returned 0 because their qLibraryId was set to the QRS
object GUID, but the engine references master measures by qInfo.qId
(short codes like JhqJ) — an unknown GUID is silently ignored and yields
null. Resolve CA par Magasin / Couverture / Marge % / Rupture % by title
via the in-page MeasureList (whitespace+case normalized), falling back to
the configured id, and log the resolved ids.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 08:50:43 +02:00
MichaelandClaude Opus 4.8 8bd45518f2 chore(qlik): log raw sample of 4 new metrics to isolate empty columns
Adds a sample log of the first extracted rows (all 8 hypercube columns)
and a sample of the network metrics matched from cache in getProductRows,
to pinpoint whether the new measures return null from Qlik, fail to
persist, or fail to render.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 08:39:35 +02:00
MichaelandClaude Opus 4.8 bd9bc78f4c feat(qlik): extract 4 decision metrics (CA/mag, couverture, marge%, rupture%)
Adds CA par Magasin, Couverture stock Qte, Marge % and Stock Rupture %
network measures to the Qlik hypercube extraction (in-page Playwright ws
+ legacy server ws), persists them in qlik_network_metrics, joins them in
getProductRows and renders 4 new grid columns with buy-signal coloring
(low coverage / high margin = green; high rupture = rose).

- qlik-client: config IDs (env-overridable, master GUIDs of app 9872ee6e),
  NetworkMetric fields, hypercube width 4->8, page height 2500->1250
- qlik-playwright: same 4 measures in the in-page hypercube + row mapping
- schema + db-init: 4 numeric columns (ALTER ADD COLUMN IF NOT EXISTS for
  existing DBs)
- cache + grid types + enrich: plumb the new fields end to end

Note: percent measures stored as raw Qlik ratio; UI renders x100 when |v|<=1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 08:34:54 +02:00
MichaelandClaude Opus 4.8 0b9846f55d fix(qlik): auto-refresh grid after sync without manual reload
Restore the _refresh searchParam bump so grid-client refetches
/api/grid/rows with refresh=1 (forceRefresh, bypassing the 10-min
getProductRows cache) and re-renders the network columns from the
store. Also call router.refresh() to update the server-rendered
MAJ Qlik timestamp on the button.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 08:23:34 +02:00
MichaelandClaude Opus 4.8 d54c3ae520 feat(qlik): add step-by-step logging to sync extraction
Logs each stage (PG codes -> NTLM session -> chromium -> csrf token
-> in-page ws -> OpenDoc -> SelectValues -> cube size -> rows) so a
hanging sync reveals where it stalls. Forwards in-page browser console
to the node logger.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 08:14:12 +02:00
MichaelandClaude Opus 4.8 6cdb845a1f fix(qlik): use qlik-csrf-token + real engine qIds — extraction works
Validated end-to-end. The recent Qlik client authorizes the engine websocket
via ?reloadUri=...&qlik-csrf-token=<token> (not Xrfkey). Capture that token
from the client's own ws, then open ours in-page with it. Use the real engine
master-item qIds (dim Article Code=yesCP, CA N=JhqJ, Quantité N=41516861...,
Magasin Ventes Nb N=yNBLjc) — the QRS object IDs returned error 7001. Select
the supplier's codes on field "Article Code" (the app holds ~1.2M codes).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 00:37:04 +02:00
MichaelandClaude Opus 4.8 b755a69258 fix(qlik): in-page engine ws with authenticated browser session
The recent Qlik client uses qmfe/single-spa (no js/qlik Capability API). Go
back to a raw engine websocket but opened in-page from the authenticated
browser (injected X-Qlik-Session cookie), which the proxy accepts. Use the
session xrfkey and capture ws close code/reason for diagnostics.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 00:19:28 +02:00
MichaelandClaude Opus 4.8 d319107076 fix(qlik): extract via Qlik Capability API (reuse page session)
Raw and in-page websockets with a self-generated xrfkey are rejected (403).
Switch to the Qlik Capability API (require(['js/qlik']) → openApp → createCube),
which reuses the authenticated page session and manages the engine ws + xrfkey
internally. Auth via injected X-Qlik-Session cookie (Node ticket flow) +
httpCredentials. Logs rich page diagnostics on failure.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 00:13:45 +02:00
LogiFlow c64d32a219 Merge pull request #5 from R0m1k3/feat/qlik-network
build: sync package-lock.json with httpntlm/ws deps
2026-06-21 00:00:39 +02:00
MichaelandClaude Opus 4.8 694b780160 feat(qlik): extract network data via Playwright (in-page ws)
The Qlik proxy refuses a raw server-side websocket (403, even internally), so
extraction now runs through headless Chromium (NTLM via httpCredentials, like
the hermes agent) and opens the Engine websocket in-page — same origin, which
the proxy accepts. Efficient: selects the supplier's article codes on the
"Article Code" field so the hypercube returns only those rows.

- qlik-playwright.ts: browser singleton, in-page hypercube extraction (paginated)
- /api/qlik/sync: use the Playwright extractor
- Dockerfile: install chromium + headless deps, PLAYWRIGHT_CHROMIUM_PATH, copy playwright-core
- deps: playwright-core (lockfiles synced)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 23:54:03 +02:00
MichaelandClaude Opus 4.8 1281b0e21f fix(qlik): send Origin/User-Agent on engine ws + verbose 403 diagnostics
Qlik proxy validates the WebSocket Origin header (whitelist) — a browser
always sends it. Add Origin + User-Agent to the engine ws. Also surface the
403 response (server/www-authenticate/body) for diagnosis.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 23:35:09 +02:00
MichaelandClaude Opus 4.8 abed369bfb feat(settings): configure Qlik credentials in the UI
Add a "Qlik Sense — Données réseau" section in Paramètres (host/user/password
+ Save & Test buttons), stored in data/.db-config.json. qlik-client reads this
file first (env fallback), so no env vars needed for the sync.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 23:28:55 +02:00
MichaelandClaude Opus 4.8 2ffa7dd1c0 build: sync package-lock.json with httpntlm/ws deps
Docker build uses `npm ci` which requires package-lock.json in sync with
package.json. Add httpntlm, ws, @types/ws to the npm lockfile (were only in
pnpm-lock.yaml), fixing the failed deploy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 23:19:27 +02:00
LogiFlow 5a4ce70695 Merge pull request #4 from R0m1k3/feat/qlik-network
feat(grid): replace ranking/AI/score with Qlik network data
2026-06-20 22:54:04 +02:00
96 changed files with 17018 additions and 2136 deletions

No files matched your search

+229
View File
@@ -0,0 +1,229 @@
// <stdin>
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
// src/features/grid/components/network-charts.tsx
import { useState } from "react";
// src/features/grid/lib/months.ts
function fmtMonthShort(lab) {
const [y, m] = lab.split("-");
return `${m ?? lab}/${(y ?? "").slice(2)}`;
}
// src/features/grid/components/network-charts.tsx
import { jsx, jsxs } from "react/jsx-runtime";
var SERIE_COULEURS = ["var(--viz-1)", "var(--viz-2)", "var(--viz-3)"];
function NetworkLineChart({
labels: labels2,
values: values2,
stores: stores2,
perStore: perStore2
}) {
const [survol, setSurvol] = useState(null);
const n = values2.length;
const memeTaille = (s) => Array.isArray(s) && s.length === n && n > 0;
const avecParMag = memeTaille(perStore2);
const avecMagasins = memeTaille(stores2);
const fmt = (v, d = 0) => v.toLocaleString("fr-FR", { minimumFractionDigits: d, maximumFractionDigits: d });
const facettes = [];
if (avecParMag) {
facettes.push({
titre: "Qt\xE9 / magasin",
valeurs: perStore2,
couleur: SERIE_COULEURS[0],
hauteur: 92,
aire: true,
decimales: perStore2.some((v) => v > 0 && v < 10) ? 1 : 0
});
facettes.push({ titre: "Quantit\xE9s vendues", valeurs: values2, couleur: SERIE_COULEURS[1], hauteur: 58 });
} else {
facettes.push({ titre: "Quantit\xE9s vendues", valeurs: values2, couleur: SERIE_COULEURS[0], hauteur: 110, aire: true });
}
if (avecMagasins) {
facettes.push({ titre: "Magasins vendeurs", valeurs: stores2, couleur: SERIE_COULEURS[2], hauteur: 46 });
}
const W = 520;
const padL = 16, padR = 16, axeH = 18, enteteH = 15, ecart = 12;
const plotW = W - padL - padR;
const H = facettes.reduce((t, f) => t + enteteH + f.hauteur + ecart, 0) - ecart + axeH;
const x = (i) => n > 1 ? padL + i / (n - 1) * plotW : padL + plotW / 2;
const ancrage = (i) => i === 0 ? "start" : i === n - 1 ? "end" : "middle";
let curseur = 0;
const disposees = facettes.map((f) => {
const hautEntete = curseur;
const haut = curseur + enteteH;
curseur = haut + f.hauteur + ecart;
const min = Math.min(0, ...f.valeurs);
const max = Math.max(...f.valeurs, min + 1);
const etendue = max - min || 1;
const y = (v) => haut + f.hauteur - (v - min) / etendue * f.hauteur;
const iMax = f.valeurs.indexOf(Math.max(...f.valeurs));
return { ...f, hautEntete, haut, base: y(min), max, y, iMax };
});
const basPlots = curseur - ecart;
return /* @__PURE__ */ jsxs("div", { className: "mt-3 w-full overflow-x-auto", children: [
/* @__PURE__ */ jsxs(
"svg",
{
viewBox: `0 0 ${W} ${H}`,
className: "w-full",
style: { minWidth: 420 },
role: "img",
"aria-label": "Ventes r\xE9seau mensuelles : " + disposees.map((f) => f.titre).join(", "),
onMouseLeave: () => setSurvol(null),
children: [
disposees.map((f) => {
const pts = f.valeurs.map((v, i) => `${x(i).toFixed(1)},${f.y(v).toFixed(1)}`).join(" ");
const aEtiqueter = [.../* @__PURE__ */ new Set([f.iMax, n - 1])];
return /* @__PURE__ */ jsxs("g", { children: [
/* @__PURE__ */ jsx("rect", { x: padL, y: f.hautEntete + 3, width: 12, height: 3, rx: 1.5, fill: f.couleur }),
/* @__PURE__ */ jsx("text", { x: padL + 18, y: f.hautEntete + 7, fontSize: 9.5, fontWeight: 600, fill: "var(--text-secondary)", dominantBaseline: "middle", children: f.titre }),
/* @__PURE__ */ jsxs("text", { x: padL + plotW, y: f.hautEntete + 7, textAnchor: "end", fontSize: 9, fill: "var(--text-muted)", dominantBaseline: "middle", children: [
"max ",
fmt(f.max, f.decimales ?? 0),
f.unite ?? ""
] }),
/* @__PURE__ */ jsx("line", { x1: padL, y1: f.base, x2: padL + plotW, y2: f.base, stroke: "var(--border)", strokeWidth: 1 }),
f.aire && n > 1 && /* @__PURE__ */ jsx(
"polygon",
{
points: `${padL},${f.base.toFixed(1)} ${pts} ${(padL + plotW).toFixed(1)},${f.base.toFixed(1)}`,
fill: f.couleur,
opacity: 0.1
}
),
n > 1 && /* @__PURE__ */ jsx("polyline", { points: pts, fill: "none", stroke: f.couleur, strokeWidth: 2, strokeLinejoin: "round", strokeLinecap: "round" }),
aEtiqueter.map((i) => /* @__PURE__ */ jsx(
"circle",
{
cx: x(i),
cy: f.y(f.valeurs[i]),
r: 4,
fill: f.couleur,
stroke: "var(--bg-surface)",
strokeWidth: 2
},
`pt-${f.titre}-${i}`
)),
aEtiqueter.map((i) => {
const yPoint = f.y(f.valeurs[i]);
const dessus = yPoint - 8 >= f.haut + 8;
return /* @__PURE__ */ jsx(
"text",
{
x: x(i),
y: dessus ? yPoint - 8 : yPoint + 13,
textAnchor: ancrage(i),
fontSize: 9,
fontWeight: 700,
fill: "var(--text-primary)",
children: fmt(f.valeurs[i], f.decimales ?? 0)
},
`lab-${f.titre}-${i}`
);
}),
survol != null && /* @__PURE__ */ jsx(
"circle",
{
cx: x(survol),
cy: f.y(f.valeurs[survol]),
r: 3.5,
fill: f.couleur,
stroke: "var(--bg-surface)",
strokeWidth: 2
}
)
] }, f.titre);
}),
survol != null && /* @__PURE__ */ jsx("line", { x1: x(survol), y1: 0, x2: x(survol), y2: basPlots, stroke: "var(--border-strong)", strokeWidth: 1 }),
labels2.map((lab, i) => /* @__PURE__ */ jsx(
"text",
{
x: x(i),
y: H - 5,
textAnchor: ancrage(i),
fontSize: 8.5,
fill: survol === i ? "var(--text-primary)" : "var(--text-muted)",
fontWeight: survol === i ? 700 : 400,
children: fmtMonthShort(lab)
},
`mois-${lab}`
)),
survol != null && (() => {
const largeur = 158;
const hauteur = 16 + disposees.length * 13 + 8;
const gauche = x(survol) + 10 + largeur > W - 4;
const bx = gauche ? x(survol) - 10 - largeur : x(survol) + 10;
return /* @__PURE__ */ jsxs("g", { pointerEvents: "none", children: [
/* @__PURE__ */ jsx(
"rect",
{
x: bx,
y: 4,
width: largeur,
height: hauteur,
rx: 8,
fill: "var(--bg-elevated)",
stroke: "var(--border)",
strokeWidth: 1
}
),
/* @__PURE__ */ jsx("text", { x: bx + 9, y: 17, fontSize: 9.5, fontWeight: 700, fill: "var(--text-primary)", children: fmtMonthShort(labels2[survol]) }),
disposees.map((f, k) => /* @__PURE__ */ jsxs("g", { children: [
/* @__PURE__ */ jsx("rect", { x: bx + 9, y: 26 + k * 13, width: 8, height: 3, rx: 1.5, fill: f.couleur }),
/* @__PURE__ */ jsx("text", { x: bx + 22, y: 30 + k * 13, fontSize: 9, fill: "var(--text-secondary)", children: f.titre }),
/* @__PURE__ */ jsx(
"text",
{
x: bx + largeur - 9,
y: 30 + k * 13,
textAnchor: "end",
fontSize: 9,
fontWeight: 700,
fill: "var(--text-primary)",
children: fmt(f.valeurs[survol], f.decimales ?? 0)
}
)
] }, `bulle-${f.titre}`))
] });
})(),
labels2.map((lab, i) => /* @__PURE__ */ jsx(
"rect",
{
x: x(i) - plotW / (2 * Math.max(1, n - 1)),
y: 0,
width: plotW / Math.max(1, n - 1),
height: H,
fill: "transparent",
onMouseEnter: () => setSurvol(i)
},
`survol-${lab}`
))
]
}
),
/* @__PURE__ */ jsxs("details", { className: "mt-2", children: [
/* @__PURE__ */ jsx("summary", { className: "text-[11px] cursor-pointer select-none", style: { color: "var(--text-muted)" }, children: "Voir les valeurs mois par mois" }),
/* @__PURE__ */ jsx("div", { className: "mt-1.5 overflow-x-auto", children: /* @__PURE__ */ jsxs("table", { className: "w-full text-[11px] tabular-nums", style: { borderCollapse: "collapse" }, children: [
/* @__PURE__ */ jsx("thead", { children: /* @__PURE__ */ jsxs("tr", { style: { color: "var(--text-muted)" }, children: [
/* @__PURE__ */ jsx("th", { className: "text-left font-medium py-1 pr-2", children: "Mois" }),
disposees.map((f) => /* @__PURE__ */ jsx("th", { className: "text-right font-medium py-1 pl-2", children: f.titre }, `th-${f.titre}`))
] }) }),
/* @__PURE__ */ jsx("tbody", { children: labels2.map((lab, i) => /* @__PURE__ */ jsxs("tr", { style: { borderTop: "1px solid var(--border)" }, children: [
/* @__PURE__ */ jsx("td", { className: "py-1 pr-2", style: { color: "var(--text-secondary)" }, children: fmtMonthShort(lab) }),
disposees.map((f) => /* @__PURE__ */ jsx("td", { className: "text-right py-1 pl-2", style: { color: "var(--text-primary)" }, children: fmt(f.valeurs[i], f.decimales ?? 0) }, `td-${f.titre}-${lab}`))
] }, `tr-${lab}`)) })
] }) })
] })
] });
}
// <stdin>
var labels = ["2025-08", "2025-09", "2025-10", "2025-11", "2025-12", "2026-01", "2026-02", "2026-03", "2026-04", "2026-05", "2026-06", "2026-07"];
var values = [9953, 13258, 6353, 5429, 3741, 6522, 8211, 4437, 4035, 4066, 2936, 4470];
var stores = [208, 228, 196, 177, 175, 193, 206, 190, 179, 179, 167, 170];
var perStore = values.map((v, i) => stores[i] > 0 ? v / stores[i] : 0);
globalThis.__HTML__ = renderToStaticMarkup(
React.createElement(NetworkLineChart, { labels, values, stores, perStore })
);
+7
View File
@@ -32,6 +32,10 @@ ENV NODE_ENV=production
# Next.js telemetry is disabled
ENV NEXT_TELEMETRY_DISABLED=1
# Chromium pour l'extraction Qlik via Playwright (ws in-page) + dépendances headless
RUN apk add --no-cache chromium nss freetype harfbuzz ca-certificates ttf-freefont
ENV PLAYWRIGHT_CHROMIUM_PATH=/usr/bin/chromium-browser
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
@@ -46,6 +50,9 @@ RUN chown nextjs:nodejs .next data
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
# playwright-core n'est pas toujours tracé par le standalone — on le copie explicitement
COPY --from=deps --chown=nextjs:nodejs /app/node_modules/playwright-core ./node_modules/playwright-core
# Grant write access to /app for settings persistence
RUN chown -R nextjs:nodejs /app
+364
View File
@@ -14,6 +14,367 @@ La Foir'Fouille : **CA réseau, Qté vendue réseau, Nb magasins** par produit.
| Bouton UI | `src/features/grid/components/sync-qlik-button.tsx` |
| Jointure grille | `enrichWithNetworkMetrics()` dans `src/features/grid/api/get-product-rows.ts` (Phase 8) |
| Colonnes grille | CA réseau / Qté réseau / Magasins (/270) / % présence (`heatmap-grid.tsx`) |
| Recherche produit (Qlik d'abord) | `src/lib/qlik-search.ts` → `src/features/produits/api/search-produits.ts` → `GET /api/produits/search` |
## Recherche produit — Qlik d'abord
La page `/produits` ne cherche **plus** dans le catalogue FF Nancy en premier :
le réseau référence bien plus de produits que Nancy, et ce sont précisément
ceux-là qu'on veut voir.
1. `searchQlikArticles()` — recherche de list object sur le champ libellé
article. `SearchListObjectFor` applique un **OU** entre les mots (« tapis
anti » ramenait 17 696 libellés sur l'app FF, et sélectionner ces 17 696
valeurs faisait abandonner le cube en `code 15 — Request aborted`). On
cherche donc mot par mot pour retenir le plus sélectif, on filtre en **ET**
côté client, puis on sélectionne les valeurs retenues par numéro d'élément
(`SelectListObjectValues`). La sélection restreint la dimension « Article
Code » aux articles correspondants.
2. Les **mesures réseau sortent du même cube**, dans la **même session Qlik** :
fenêtre 12 mois glissants sélectionnée sur le champ `Date`, master measures
résolues par titre, cube trié par quantité réseau décroissante (les meilleures
ventes en tête, donc pas besoin de lire tout l'ensemble sélectionné).
Elles sont ensuite persistées par `upsertNetworkMetrics()`.
⚠️ Ne **jamais** relancer `fetchNetworkMetricsPlaywright()` pendant une
recherche : deux sessions concurrentes sur la même app, dont une avec une
grosse sélection, et l'Engine coupe les requêtes (`code 15`). C'est ce qui
faisait échouer la recherche alors que la sync de la Grille — seule sur le
serveur — fonctionnait. Le détail mensuel vient donc du cache, et la fiche
produit garde son bouton « Actualiser depuis Qlik » pour l'extraire à la
demande sur un seul code.
3. `pgGetProduitsByCodeCentrale()` — rapprochement avec le catalogue Nancy, sur
**deux clés candidates** : la dimension « Article Code » et le champ
`article_no_centrale`, qui n'ont pas le même format sur l'app FF. Un code
absent = produit réseau que nous ne référençons pas (`?cc=` sur la fiche).
Plafonds mesurés en production : sélectionner les 13 446 libellés contenant
« tapis » prenait 81 s puis 486 s avant d'abandonner. La sélection est donc
plafonnée à **300 valeurs** (`MAX_VALEURS_SELECTION`) et une seule recherche
tourne à la fois.
L'API est **asynchrone** : `POST /api/produits/search?q=…` démarre un job et rend
la main tout de suite, `GET` renvoie l'avancement puis le résultat (polling client
toutes les 2 s). Une requête HTTP maintenue pendant toute l'extraction se faisait
couper par le reverse proxy, qui répond une page HTML — le client échouait sur
« Unexpected token '<' … is not valid JSON ». Même schéma que `POST /api/qlik/sync`.
Repli : si Qlik est injoignable, la recherche retombe sur `pgSearchProduits()` et
la réponse le signale (`source: "db"`). Un résultat Qlik exploitable est mis en
cache mémoire 10 min ; un repli ne l'est pas, sinon une panne passagère resterait
figée.
Champs de l'app FF (« Magasins Vision Consolidée ») : le code est `Article Code`,
le libellé `Article` (repli `article_libelle_ticket`, qui est le libellé ticket
tronqué), le fournisseur `Fournisseur` (repli `code_fournisseur`, qui ne porte
que le code). Ces préférences sont dans `CHAMPS_LIBELLE_PREFERES` /
`CHAMPS_FOURNISSEUR_PREFERES`, complétées par une heuristique pour les autres
apps. Si la détection tombe à côté, forcer :
```
QLIK_FIELD_ARTICLE_LIBELLE=<nom exact du champ libellé>
QLIK_FIELD_FOURNISSEUR=<nom exact du champ fournisseur>
```
Sans champ libellé exploitable, la recherche fonctionne encore par code centrale.
## Le modèle est piloté par `Période` / `Type_Cal` — pas par `Date`
**C'est l'explication de fond**, obtenue en dumpant les expressions réelles :
```
« Quantité N » = Sum({<Type_Cal={'N'}>} quantite)
« CA N » = Sum({<Type_Cal={'N'}>} $(vCA_vat))
« Quantité COMP » = Sum({<Type_Cal={'$(vPeriod_comp)'}$(vConstantCOMP)>} quantite)
« Magasin Ventes Nb N » = Count({<Type_Cal={'N'}>} Distinct ventes_code_site)
```
L'app n'est pas un modèle « faits + calendrier » classique. Un **pont de périodes**
duplique les lignes de faits : `Type_Cal='N'` marque celles de la période
analysée, `'COMP'` celles de la période de comparaison. Et c'est le champ
**`Période`** (4 valeurs, une sélectionnée) qui décide de quelle période il s'agit.
Tout ce qu'on observait en découle :
| Symptôme | Cause |
|---|---|
| Sélectionner `Date` ne change rien (100 % du total avant/après) | Le périmètre vient du pont de périodes, pas du champ date |
| Août→décembre vides sur tous les articles | « Quantité N » ne couvre que la période courante (janvier→juillet) |
| Janvier→juillet de l'année précédente renseignés | Ce sont les mois que « Quantité COMP » sait atteindre |
| `Sum(quantite)` brut inexploitable | Il additionne les lignes N **et** COMP : il double compte |
### La bonne méthode : effacer `Période`, puis extraire ANNÉE par ANNÉE
C'est ce que fait un utilisateur dans l'app : il sélectionne **2026** et obtient
l'année plus sa comparaison N-1 ; il sélectionne 2025 et obtient 2025. Les
données de 2024, 2025 et 2026 sont toutes accessibles mois par mois. `Type_Cal='N'`
désigne simplement **l'année sélectionnée**.
Il suffit donc d'enchaîner les années couvertes par la fenêtre (2025 puis 2026)
pour reconstituer les 12 mois glissants — chaque passe donnant les 5 mesures, et
non la seule quantité comme « Quantité COMP ».
Ce mécanisme existait déjà dans le code (« passe N-1 ») mais restait **sans
effet** :
```
[qlik-pw] (mois) passe N-1 terminée : 299905 → 299905 points mensuels
^^^^^^^^^^^^^^ aucun gain
```
La sélection `Période` **héritée de l'ouverture de l'app** (observé
« Période:1/4 » alors que rien n'avait été sélectionné) épinglait le contexte sur
l'année en cours et annulait la sélection d'année. `choisirPeriode()` teste donc
en **premier** l'état sans aucune sélection de `Période`, et l'efface dès que la
couverture est incomplète.
Chaque passe n'écrit que les mois **de la fenêtre**, et les totaux s'additionnent
sur ces mois : deux années ne peuvent pas se doubler puisqu'elles ne partagent
aucun mois.
### Mesuré : aucune valeur de `Période` ne porte à elle seule 12 mois
`choisirPeriode()` essaie chaque valeur et mesure la couverture réelle sur un
cube `[Mois] × Quantité N`. Résultat en production :
```
valeurs de « Période » : ["juin 2026","Année à date","Mois à date","Semaine 2026/30"]
« juin 2026 » → 1/12 ["2026-06"]
« Année à date » → 6/12 ["2026-01".."2026-06"]
« Mois à date » → 0/12
« Semaine 2026/30 » → 0/12
```
Ce ne sont pas des types de période mais **4 contextes relatifs à aujourd'hui**.
Aucun ne porte 12 mois glissants : les master measures ne pourront **jamais**
couvrir la fenêtre. Ce n'est plus une hypothèse, c'est une mesure.
Conséquence directe : **l'agrégation directe des faits est obligatoire**, et une
`Période` partielle est pire que pas de sélection — « Année à date » restreint les
faits à 2026-01→06 et amputerait d'autant l'agrégation, qui sait lire tout
l'historique. `choisirPeriode()` **efface** donc la sélection quand la couverture
est incomplète, et ne la garde que si elle couvre les 12 mois.
### Chaque expression est validée séparément
Une seule mesure invalide suffit à faire renvoyer **zéro ligne** à tout un
hypercube, sans qu'aucun message ne dise laquelle : observé en production, le
cube à 5 mesures calculait 26 s puis rendait 0 ligne, alors que `Sum(quantite)`
seul donnait 35 424 324.
`validerExpression()` teste donc chaque expression isolément sur un petit cube
`[Mois]` avant de construire le gros :
```
[qlik-pw][diag] validation des expressions, une par une :
[qlik-pw][diag] mesure « quantité » = Sum(quantite) → 12 mois, total=33945285
[qlik-pw][diag] mesure « magasins » = Count(DISTINCT [Magasin Code]) → INVALIDE (cube vide)
```
Une expression invalide est remplacée par la colonne neutre `0` : le cube reste
exploitable et le log nomme la coupable. Seule la quantité est bloquante.
## ⚠️ Les master measures « N » ignorent la sélection Date
**Constat de production, vérifié arithmétiquement.** Avec août 2025 seul
sélectionné, le cube renvoyait `qte=164` / `ca=2 220,75 €` — soit exactement la
somme de janvier à juillet 2026. « CA N », « Quantité N » et consorts sont des
mesures **cumul année en cours**, insensibles au champ `Date`.
Trois conséquences, toutes observées :
- les totaux réseau de la Grille étaient un **cumul année en cours** (mois
courant partiel inclus), pas 12 mois glissants ;
- les mois de l'année précédente non couverts par « Quantité COMP » (août à
décembre, en juillet) restaient **vides sur tous les articles** ;
- la passe de rattrapage y recopiait le **total de période**, d'où cinq mois
identiques à 164 dans `qteByMonth`.
Aucune sélection ne corrige cela. Le chemin nominal agrège donc **directement les
champs de faits**, qui respectent les sélections :
| Rôle | Expression (surchargeable) |
|------|----------------------------|
| Quantité | `QLIK_EXPR_QTE` — défaut `Sum(quantite)` |
| CA | `QLIK_EXPR_CA` — défaut `Sum(ca_ht)` |
| Magasins | `QLIK_EXPR_NBMAG` — défaut `Count(DISTINCT [Magasin Code])` |
| Marge | `QLIK_EXPR_MARGE` — défaut `Sum(marge)` |
Un seul cube `[Article Code, Mois]` couvre les 12 mois : ni passe N-1 ni
rattrapage — c'est exactement ce qu'ils compensaient. Les totaux deviennent la
**somme des 12 mois de la fenêtre**.
Sur l'app de production, les champs `Date`, `Date calendrier` et `Date_Key`
existent mais leur sélection ne réduit pas `Sum(quantite)` : ils restent à
100 % du total historique. L'extracteur essaie d'abord ces champs puis utilise
la dimension maître `Mois` comme chemin fiable. Il crée un list object, retrouve
les 12 valeurs `YYYY-MM`, les sélectionne par `qElemNumber`, puis exécute le
cube mensuel. Cette méthode ne dépend ni du champ sous-jacent ni de son format
dual et conserve le chemin rapide (une sélection articles + un cube paginé).
Garde-fous : « Quantité N » est incluse dans le cube pour **calibrage**, et le
log compare les deux sur les mois de l'année en cours, seul périmètre où la
master measure est juste :
```
[qlik-pw][expr] 485210 lignes, quantité totale=…, calibrage année 2026 : 4812/4830 mois conformes à « Quantité N »
```
Un calibrage faible signale une expression à ajuster (filtre `flag_type_mvt`,
`ca_ttc` plutôt que `ca_ht`…). Il n'existe plus de repli automatique vers les
master measures annuelles : quantité totale nulle, champ Date invalide, erreur
Engine ou somme incohérente font échouer la synchronisation et laissent le cache
précédent intact.
Après sélection de la fenêtre, l'extracteur vérifie deux invariants avant tout
upsert :
1. la somme de toutes les lignes `[Article Code, Mois]` est égale au total Qlik
sans dimension calculé avec les mêmes sélections ;
2. pour chaque article, la quantité réseau totale est exactement la somme de ses
12 mois. Les mois sans ligne de faits sont alors seulement matérialisés à `0`.
Une extraction interrompue ou partielle n'est jamais publiée.
### Valider les expressions sur le vrai serveur
`scripts/qlik-validate.mjs` répond aux deux questions ouvertes en une exécution,
**depuis un réseau qui atteint Qlik** (le serveur est filtré par IP : injoignable
depuis l'extérieur, la passerelle d'egress tombe en `connection timeout`) :
```
docker exec -e QLIK_PWD='<mot de passe>' -it <conteneur> node scripts/qlik-validate.mjs
```
Il affiche les expressions réelles des master measures, puis un tableau mois par
mois comparant `Sum(quantite)` / `Sum(ca_ht)` / `Sum(ca_ttc)` /
`Count(DISTINCT [Magasin Code])` aux mesures « N » — sur les mois de l'année en
cours (où elles doivent coïncider) comme sur ceux de l'année précédente (où les
mesures « N » sont censées être à 0). Il affiche enfin `Article Code`,
`article_no_centrale` et `article_codein` côte à côte, pour trancher la clé de
jointure avec `articles.artcentrale`.
Lecture seule : sélections en soft lock, objets de session détruits.
### La sélection Date filtre-t-elle seulement quelque chose ?
Question restée sans réponse tant que seules des mesures **insensibles à la
sélection** étaient utilisées : rien ne prouvait que `Date` bornait quoi que ce
soit. Le chemin par expressions le vérifie désormais avant d'extraire, avec un
total de contrôle (`Sum(quantite)` sans dimension) :
```
[qlik-pw][expr] contrôle Sum(quantite) sans filtre date = 12345678
[qlik-pw][expr] champ « Date » → 0 (0% du total sans filtre)
[qlik-pw][expr] champ « Date calendrier » → 3456789 (28% du total sans filtre)
[qlik-pw][expr] champ date retenu : « Date calendrier »
```
Un champ n'est retenu que s'il donne un total **non nul et strictement inférieur**
au total sans filtre — 0 % signifie que la sélection ne matche rien, 100 % qu'elle
n'a aucun effet. Candidats essayés dans l'ordre : `QLIK_DATE_FIELD` (si défini),
`Date`, `Date calendrier`, `Date_Key` (celui-ci sélectionné au format `AAAAMMJJ`).
Si aucun ne filtre, l'extraction échoue explicitement plutôt que de produire une
fenêtre fausse. Chaque sélection d'un candidat rejeté est effacée avant l'essai
suivant ; autrement un premier candidat à zéro contaminait tous les contrôles
suivants. Avant cet échec, la dimension maître `Mois` est testée elle aussi.
## La passe de rattrapage a été supprimée
Elle ré-extrayait un mois vide en ne sélectionnant que ses dates. Les master
measures ignorant la sélection Date, le cube lui renvoyait le **total de période**
qu'elle recopiait dans chaque mois manquant : d'où les plateaux identiques d'août
à décembre (87 648 douze mois de suite sur un article) et les tendances
« −162 % » entièrement fabriquées.
Un mois qu'on ne sait pas extraire doit rester **absent**, jamais rempli d'une
valeur plausible.
## Mois vides de la fenêtre glissante
Les mesures « N » de l'app sont bornées à une année civile. Quand la fenêtre
12 mois glissants chevauche deux années (le cas 11 mois sur 12), le chemin
« dimension Mois » sélectionne les 365 jours d'un coup et la mesure ne se résout
que sur une seule année : les mois de l'année précédente ressortent vides.
« Quantité COMP » ne rattrape que les mois ayant un comparable dans l'année en
cours — d'où, en juillet 2026, un trou observé d'août à décembre 2025 sur
**tous** les articles.
Le chemin daté repose uniquement sur les expressions de faits et un cube unique
`[Article Code, Mois]`. Après validation de la fenêtre et du total, chaque
article reçoit exactement les 12 clés attendues. Une clé absente du cube signifie
alors réellement « aucun fait sur ce mois » et vaut `0`; avant cette validation,
aucun zéro n'est inventé.
`QLIK_MONTH_DIM=0` ou `QLIK_USE_EXPR=0` désactive désormais un prérequis et fait
échouer la synchronisation datée : ces options ne peuvent plus réactiver un
chemin connu comme incorrect.
⚠️ Les données déjà en cache gardent leurs zéros : il faut relancer la sync Qlik
pour les corriger.
## Coût de `SelectValues` sur « Article Code »
Mesuré en production : **8 à 100 s par appel**, quasi indépendamment du nombre de
valeurs — le moteur balaie un symbole de plus d'un million d'entrées. Avec 7 200
codes en lots de 300, cela faisait 24 appels, ~15 min de sync, et la session Qlik
mourait avant la fin (`Socket closed`, puis `Execution context was destroyed`).
La dimension Mois livrant déjà tous les mois d'un coup, rien n'oblige à découper
les codes : le chemin mensuel fait donc **une seule sélection pour tous les codes**
et **un seul cube paginé** (la lecture des pages coûte ~50 ms). Repli automatique
sur les lots si l'Engine refuse. Même principe pour `rattraperMoisVides()` : une
sélection de codes, puis seule la fenêtre de dates change d'un mois à l'autre.
La sonde de diagnostic de fin de sync a été retirée : elle refaisait une sélection
complète des codes et des 365 jours pour rien.
### Sélection PAR FOURNISSEUR (ÉTAPE 0)
Le code fournisseur FF présent en base SQL / API (ex. `J009`) existe aussi dans
Qlik. Une valeur y désigne le même périmètre que les dizaines de milliers de codes
articles — pour un fournisseur de 41 569 références, c'est **un** `SelectValues`
trivial au lieu d'un appel de 8 à 100 s **répété à chaque passe annuelle**.
`selectionnerParFournisseur()` (in-page, avant `choisirPeriode()`) :
1. essaie chaque champ candidat — `QLIK_FIELD_CODE_FOURNISSEUR`, puis
`code_fournisseur`, `Fournisseur`, `fournisseur_code_centrale` ;
2. `SelectValues` le code fournisseur (soft lock) ; une valeur inconnue passe au
champ suivant ;
3. **vérifie la couverture** : lit les Article Code visibles sous cette sélection
(cube paginé) et compare aux codes demandés ;
4. ne retient la bascule que si la couverture atteint `QLIK_SUPPLIER_MIN_COVERAGE`
(défaut **0.99**, borné à [0.5, 1]). Sinon la sélection est annulée et on
revient à la sélection par codes.
Ce contrôle est indispensable : un article rattaché à plusieurs fournisseurs, ou
un référencement Qlik différent du référencement FF, doit faire **revenir aux
codes** plutôt que perdre silencieusement des articles.
Quand la bascule est retenue (`parFournisseur = true`), plus **aucun**
`SelectValues` sur « Article Code » n'est émis — ni par les passes annuelles, ni
par l'agrégation directe par expressions. Le périmètre fournisseur survit aux
sélections `Année` et `Date`, qui portent sur d'autres champs. Le repli par lots
de codes reste disponible si l'Engine refuse un cube.
En mode produit (sync d'un seul code centrale), aucun fournisseur n'est transmis :
la sélection reste par code.
### Aucun résultat partiel dans le cache
Le script in-page conserve des points de contrôle à des fins de diagnostic, mais
une synchronisation datée interrompue est refusée intégralement. Le cache garde
sa dernière version complète jusqu'à la réussite d'une nouvelle extraction.
## Tendance réseau = 12 mois glissants stricts
`computeNetworkTrend()` reconstruit sa fenêtre à partir de la date du jour :
12 mois complets, **mois en cours exclu** (partiel, il tirait la pente vers le bas).
La tendance n'est affichée que si les 12 clés sont explicitement présentes dans
le cache. Les anciennes séries partielles de deux ou trois mois sont donc
refusées au lieu d'être présentées comme une tendance.
Retirés : ranking (champs/query/colonnes), analyse IA (routes `/api/ai/*`, `bulk-ai-analyzer`,
dossier `ai-copilot`), score (`score-engine.ts`, colonne score).
@@ -67,6 +428,9 @@ QLIK_DIM_CODE_ARTICLE_ID=fcd239e5-288b-4830-a047-0e3d7665d971
QLIK_MEAS_CA_ID=43a76088-86fa-402e-a80e-0efd7701b3e1
QLIK_MEAS_QTE_ID=7b40caf1-be4b-4811-8d45-50acde33e715
QLIK_MEAS_NBMAG_ID=8b63fae5-db2f-4e4c-8618-f3e9d60b6b3b
# Sélection par fournisseur (facultatif — auto-détection sinon) :
QLIK_FIELD_CODE_FOURNISSEUR= # champ Qlik portant le code fournisseur FF
QLIK_SUPPLIER_MIN_COVERAGE=0.99 # couverture minimale exigée pour basculer
```
## RESTE À FAIRE
+106
View File
@@ -0,0 +1,106 @@
# Qlik — Performance & tuning de la sync réseau
Ce document rassemble les variables d'environnement et leviers qui agissent
sur la **vitesse** et la **robustesse** de l'extraction Qlik réseau
(`POST /api/qlik/sync`, routeur `src/app/api/qlik/sync/route.ts`,
extracteur `src/lib/qlik-playwright.ts`, helper de fenêtre
`src/lib/qlik-date-range.ts`).
Toutes ces variables sont **optionnelles** : valeur par défaut sûre.
> ⚠️ Aucune concurrence / parallélisme Qlik n'a été ajouté — chaque sync
> reste **mono-flux** (un seul websocket Engine, une seule fenêtre
> temporelle). Les optimisations ci-dessous n'augmentent pas la charge
> sur le moteur Qlik ; elles **réduisent** la quantité de travail qu'on
> lui demande.
---
## Variables d'environnement
| Nom | Type | Défaut | Bornes | Effet |
| --------------------------- | ---- | ------ | ------------------- | -------------------------------------------------------------------------------------- |
| `QLIK_SYNC_MONTHS_BACK` | int | `12` | `1`..`12` | Profondeur de la fenêtre temporelle (mois glissants, mois courant exclu). Plus petit = moins de mois scannés = extraction plus rapide. |
| `QLIK_SETTLE_MS` | int | `150` | `0`..`2000` | Latence (ms) après chaque mutation de sélection (Date / Article Code) avant l'appel RPC suivant. Protège l'Engine contre les collisions qui provoquent `code:15 Request aborted`. |
| `QLIK_CODE15_MAX_ATTEMPTS` | int | `4` | `1`..`8` | Nombre max de retries sur Engine `code:15` par appel RPC, avec backoff `QLIK_SETTLE_MS * attempt`. |
| `QLIK_ARTICLE_BATCH_SIZE` | int | `150` | `1`..`500` | Taille du premier lot d'`Article Code` sélectionné par appel `SelectValues`. |
| `QLIK_ARTICLE_BATCH_FALLBACKS` | CSV | `75,50` | chaque valeur `1`..`500` | Tailles de repli quand un lot déclenche un `code:15`. Valeurs ordonnées croissant appliquées successivement. |
> **Format CSV pour `QLIK_ARTICLE_BATCH_FALLBACKS`** : valeurs séparées par
> des virgules, ex. `QLIK_ARTICLE_BATCH_FALLBACKS=100,75,50,25`. Les doublons
> et valeurs hors bornes sont dédupliqués / clampés automatiquement, puis appliqués dans l'ordre fourni.
---
## Optimisations déjà en place (pattern durable)
Le pipeline reste **mono-connexion** pour ne jamais surcharger le moteur Qlik :
1. **Fenêtre temporelle bornée** : on ne demande jamais à Qlik de scanner
l'historique complet ; uniquement les `QLIK_SYNC_MONTHS_BACK` derniers
mois.
2. **Découpe mensuelle** : `getMonthRanges()` produit 1 sous-fenêtre par mois
calendaire. Chaque cube reste ainsi borné à ~28–31 jours × N codes.
3. **Hypercube session object JETABLE** : `fetchCubeForSelection()` crée un
`CreateSessionObject` neuf par lot, lit ses pages, puis appelle
`DestroySessionObject`. C'est ce qui a éliminé les `code:15 Request aborted`
à grande échelle (la réutilisation du même objet sur des centaines de
recalculs forçait l'Engine à recalculer en boucle).
4. **Fallbacks code 15** : si un lot trop large déclenche un `code:15`, on
retente automatiquement avec une taille plus petite (cf.
`QLIK_ARTICLE_BATCH_FALLBACKS`) sans tout annuler.
## Nouveautés (cette itération)
1. **Fenêtre paramétrable `QLIK_SYNC_MONTHS_BACK`** (1..12, défaut 12). Permet
de scoper un sync test à 6 mois au lieu de 12, ou de n'extraire que les 3
derniers mois pour un check rapide. Le helper `envMonthsBack()` (dans
`src/lib/qlik-date-range.ts`) clamp la valeur et applique le défaut si
l'env est mal configurée.
2. **Filtre strict des codes centraux** dans `route.ts` (regex `[A-Z0-9][A-Z0-9_-]{1,29}`,
longueur 2..30, exclusion de `""`, `"-"`, `"_"`). Réduit les erreurs Engine
sur des valeurs parasites et le gaspillage réseau. Le log expose le **nombre**
de codes rejetés mais pas les valeurs (hygiène logs).
3. **Résumé timing final** : ligne unique `[qlik-pw][summary] rows=… months=…
batches=… code15Retries=… fallbackCount=… totalMs=…` émise en fin de sync
pour audit/debug sans avoir à parser les logs détaillés par lot.
## Exemple de `.env.local`
```env
# Sync Qlik : fenêtre par défaut 12 mois ; on peut descendre à 6 pour un
# test rapide ou un check hebdo sans recharger tout l'historique.
QLIK_SYNC_MONTHS_BACK=12
# Batch sizes / fallbacks (laisser par défaut sauf contre-indication).
QLIK_ARTICLE_BATCH_SIZE=150
QLIK_ARTICLE_BATCH_FALLBACKS=75,50
QLIK_SETTLE_MS=150
QLIK_CODE15_MAX_ATTEMPTS=4
```
## Tests
```bash
# Tests purs Node (aucune dépendance Next.js).
npx tsx scripts/test-qlik-date-range.ts
```
Couvre :
- repères Qlik (sérial ↔ ISO),
- fenêtre 12 mois (référence),
- `getMonthRanges()` : 12 mois / fenêtre partielle / année bissextile,
- option `monthsBack` : 6 mois + clamp 0 / -5 / 24 / NaN / undefined,
- helper `envMonthsBack()` : env absent / vide / valide / hors bornes /
non numérique / décimal / espaces.
## Référence
- `src/lib/qlik-date-range.ts` — fenêtre, `envMonthsBack`, `getMonthRanges`.
- `src/lib/qlik-playwright.ts` — extracteur Playwright, hypercube jetable,
fallbacks code 15, `[qlik-pw][summary]` final.
- `src/app/api/qlik/sync/route.ts` — route admin, `filterCentralCodes`,
applique `QLIK_SYNC_MONTHS_BACK` via `envMonthsBack`.
- `scripts/test-qlik-date-range.ts` — tests CLI.
- `docs/qlik-integration.md` — doc d'intégration générale (auth NTLM,
discovery, hypercube, etc.).
+1 -1
View File
@@ -1,7 +1,7 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: "standalone",
serverExternalPackages: ["pg", "ws", "httpntlm"],
serverExternalPackages: ["pg", "ws", "httpntlm", "playwright-core"],
experimental: {
serverActions: {
bodySizeLimit: "50mb",
+13
View File
@@ -26,6 +26,7 @@
"next-auth": "^5.0.0-beta.30",
"next-themes": "^0.4.6",
"pg": "^8.18.0",
"playwright-core": "^1.61.0",
"radix-ui": "^1.4.3",
"react": "19.2.3",
"react-dom": "19.2.3",
@@ -13285,6 +13286,18 @@
"node": ">=16.20.0"
}
},
"node_modules/playwright-core": {
"version": "1.61.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.0.tgz",
"integrity": "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/possible-typed-array-names": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
+1
View File
@@ -30,6 +30,7 @@
"next-auth": "^5.0.0-beta.30",
"next-themes": "^0.4.6",
"pg": "^8.18.0",
"playwright-core": "^1.61.0",
"radix-ui": "^1.4.3",
"react": "19.2.3",
"react-dom": "19.2.3",
+10
View File
@@ -62,6 +62,9 @@ importers:
pg:
specifier: ^8.18.0
version: 8.20.0
playwright-core:
specifier: ^1.61.0
version: 1.61.0
radix-ui:
specifier: ^1.4.3
version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
@@ -4503,6 +4506,11 @@ packages:
resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==}
engines: {node: '>=16.20.0'}
playwright-core@1.61.0:
resolution: {integrity: sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==}
engines: {node: '>=18'}
hasBin: true
possible-typed-array-names@1.1.0:
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
engines: {node: '>= 0.4'}
@@ -9931,6 +9939,8 @@ snapshots:
pkce-challenge@5.0.1: {}
playwright-core@1.61.0: {}
possible-typed-array-names@1.1.0: {}
postcss-selector-parser@7.1.1:
+95
View File
@@ -102,12 +102,107 @@ async function main() {
"ca_reseau" numeric(16, 2),
"qte_reseau" numeric(14, 2),
"nb_magasins_reseau" integer,
"ca_par_magasin_reseau" numeric(16, 2),
"marge_pct_reseau" numeric(8, 4),
"periode" varchar(20),
"fetched_at" timestamp DEFAULT now()
);
`);
// Colonnes ajoutées après coup (DB existante) — CREATE IF NOT EXISTS ne les pose pas.
await tempPool.query(`
ALTER TABLE "qlik_network_metrics"
ADD COLUMN IF NOT EXISTS "ca_par_magasin_reseau" numeric(16, 2),
ADD COLUMN IF NOT EXISTS "marge_pct_reseau" numeric(8, 4),
ADD COLUMN IF NOT EXISTS "qte_by_month" jsonb,
ADD COLUMN IF NOT EXISTS "metrics_by_month" jsonb,
ADD COLUMN IF NOT EXISTS "libelle_reseau" varchar(255),
ADD COLUMN IF NOT EXISTS "fournisseur_reseau" varchar(255);
ALTER TABLE "qlik_network_metrics" DROP COLUMN IF EXISTS "rupture_pct_reseau";
ALTER TABLE "qlik_network_metrics" DROP COLUMN IF EXISTS "couverture_stock_reseau";
`);
console.log("[DB Init] Table qlik_network_metrics is verified/created.");
// Instantané persisté de la grille : lu par /api/v1 pour ne jamais
// déclencher de recalcul. Rempli en effet de bord par getProductRows().
await tempPool.query(`
CREATE TABLE IF NOT EXISTS "grid_rows" (
"code_fournisseur" varchar(20) NOT NULL,
"codein" varchar(20) NOT NULL,
"nom_fournisseur" varchar(255),
"libelle1" varchar(500),
"gtin" varchar(30),
"reference" varchar(100),
"code_centrale" varchar(20),
"code1" varchar(20),
"code2" varchar(20),
"code3" varchar(20),
"code_gamme" varchar(20),
"code_gamme_init" varchar(20),
"total_ca" numeric(16, 2),
"total_quantite" numeric(14, 2),
"total_marge" numeric(16, 2),
"taux_marge" numeric(8, 4),
"stock_actuel" numeric(14, 2),
"ca_reseau" numeric(16, 2),
"qte_reseau" numeric(14, 2),
"nb_magasins_reseau" integer,
"ca_par_magasin_reseau" numeric(16, 2),
"marge_pct_reseau" numeric(8, 4),
"payload" jsonb NOT NULL,
"computed_at" timestamp DEFAULT now() NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS "uq_grid_rows_fou_codein"
ON "grid_rows" ("code_fournisseur", "codein");
CREATE INDEX IF NOT EXISTS "idx_grid_rows_fournisseur" ON "grid_rows" ("code_fournisseur");
CREATE INDEX IF NOT EXISTS "idx_grid_rows_codein" ON "grid_rows" ("codein");
CREATE INDEX IF NOT EXISTS "idx_grid_rows_gamme" ON "grid_rows" ("code_gamme");
CREATE INDEX IF NOT EXISTS "idx_grid_rows_code_centrale" ON "grid_rows" ("code_centrale");
CREATE INDEX IF NOT EXISTS "idx_grid_rows_total_ca" ON "grid_rows" ("total_ca");
CREATE INDEX IF NOT EXISTS "idx_grid_rows_libelle" ON "grid_rows" ("libelle1");
`);
console.log("[DB Init] Table grid_rows is verified/created.");
// Clés d'API : seul le hachage SHA-256 est stocké.
await tempPool.query(`
CREATE TABLE IF NOT EXISTS "api_keys" (
"id" serial PRIMARY KEY NOT NULL,
"name" varchar(100) NOT NULL,
"key_prefix" varchar(20) NOT NULL,
"key_hash" text NOT NULL UNIQUE,
"role" varchar(20) DEFAULT 'user' NOT NULL,
"created_by" varchar(50),
"created_at" timestamp DEFAULT now(),
"last_used_at" timestamp,
"revoked_at" timestamp
);
`);
console.log("[DB Init] Table api_keys is verified/created.");
// Paramétrage et suivi de la synchronisation nocturne, par fournisseur.
await tempPool.query(`
CREATE TABLE IF NOT EXISTS "sync_fournisseurs" (
"code_fournisseur" varchar(20) PRIMARY KEY NOT NULL,
"nom_fournisseur" varchar(255),
"actif_sql" boolean NOT NULL DEFAULT true,
"actif_qlik" boolean NOT NULL DEFAULT true,
"desactive_motif" text,
"dernier_sql_at" timestamp,
"dernier_sql_statut" varchar(20),
"dernier_sql_lignes" integer,
"dernier_sql_erreur" text,
"dernier_sql_ms" integer,
"dernier_qlik_at" timestamp,
"dernier_qlik_statut" varchar(20),
"dernier_qlik_codes" integer,
"dernier_qlik_erreur" text,
"dernier_qlik_ms" integer,
"updated_at" timestamp DEFAULT now()
);
CREATE INDEX IF NOT EXISTS "idx_sync_fou_sql" ON "sync_fournisseurs" ("actif_sql", "dernier_sql_at");
CREATE INDEX IF NOT EXISTS "idx_sync_fou_qlik" ON "sync_fournisseurs" ("actif_qlik", "dernier_qlik_at");
`);
console.log("[DB Init] Table sync_fournisseurs is verified/created.");
await tempPool.end();
console.log("[DB Init] Initialization successful. Exiting.");
process.exit(0);
+354
View File
@@ -0,0 +1,354 @@
/**
* CollectFlow — Validation des expressions Qlik et de la clé de jointure.
*
* À lancer DEPUIS le réseau qui atteint Qlik (typiquement le conteneur de l'app) :
*
* docker exec -e QLIK_PWD='<mot de passe>' -it <conteneur> node scripts/qlik-validate.mjs
*
* Répond aux deux questions encore ouvertes :
*
* 1. Les master measures « CA N » / « Quantité N » ignorent-elles la sélection
* Date, et les agrégations directes (`Sum(quantite)`, `Sum(ca_ht)`…) sont-elles
* un substitut fidèle ? Le script affiche, pour quelques articles réellement
* vendus, un tableau mois par mois comparant les deux — dont les mois d'août à
* décembre qui ressortaient vides.
*
* 2. Quelle valeur joint avec `articles.artcentrale` : la dimension « Article
* Code » ou le champ `article_no_centrale` ? Le script affiche les deux
* côte à côte.
*
* Aucun secret n'est écrit ni affiché : le mot de passe vient de l'environnement.
* Le script est en LECTURE SEULE (sélections en soft lock, objets de session
* détruits) — il ne modifie rien dans Qlik.
*/
import httpntlm from "httpntlm";
import { randomBytes } from "node:crypto";
import { chromium } from "playwright-core";
process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0"; // certificat interne
const HOST = process.env.QLIK_HOST || "reporting-magasins.lafoirfouille.fr";
const USER = process.env.QLIK_USER || "FFSCH";
const PWD = process.env.QLIK_PWD || "";
const DOMAIN = process.env.QLIK_DOMAIN ?? "";
const APP = process.env.QLIK_APP_NETWORK || "9872ee6e-d64a-4b43-984a-076bf1f7f647";
const DIM_ARTICLE = process.env.QLIK_DIM_CODE_ARTICLE_ID || "yesCP";
const DIM_MOIS = process.env.QLIK_MOIS_DIM_ID || "pfGAwTs";
const NB_ARTICLES = Number(process.env.QLIK_VALIDATE_ARTICLES || "3");
if (!PWD) {
console.error("ERREUR : définir QLIK_PWD dans l'environnement.");
console.error(" docker exec -e QLIK_PWD='…' -it <conteneur> node scripts/qlik-validate.mjs");
process.exit(1);
}
const xrfkey = randomBytes(12).toString("base64").replace(/[^a-zA-Z0-9]/g, "").slice(0, 16).padEnd(16, "0");
// ─── Fenêtre 12 mois glissants, mois courant exclu (même règle que l'app) ────
const QLIK_EPOCH_MS = Date.UTC(1899, 11, 30);
const serialDe = (y, m, d) => Math.round((Date.UTC(y, m - 1, d) - QLIK_EPOCH_MS) / 86_400_000);
function fenetre12Mois(now = new Date()) {
const debut = new Date(now.getFullYear(), now.getMonth() - 12, 1);
const fin = new Date(now.getFullYear(), now.getMonth(), 0);
const serials = [];
const s0 = serialDe(debut.getFullYear(), debut.getMonth() + 1, debut.getDate());
const s1 = serialDe(fin.getFullYear(), fin.getMonth() + 1, fin.getDate());
for (let s = s0; s <= s1; s++) serials.push(s);
const labels = [];
for (let i = 12; i >= 1; i--) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
labels.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`);
}
return { serials, labels, debut: debut.toISOString().slice(0, 10), fin: fin.toISOString().slice(0, 10) };
}
// ─── Auth NTLM → cookie de session ──────────────────────────────────────────
function ntlmGet(path) {
return new Promise((resolve, reject) => {
httpntlm.get(
{
url: `https://${HOST}${path}`,
username: USER,
password: PWD,
domain: DOMAIN,
workstation: "",
rejectUnauthorized: false,
headers: { "User-Agent": "Mozilla/5.0 CollectFlow", "X-Qlik-Xrfkey": xrfkey },
},
(err, res) => (err ? reject(err) : resolve(res)),
);
});
}
function cookieDepuis(setCookie) {
const arr = Array.isArray(setCookie) ? setCookie : setCookie ? [String(setCookie)] : [];
return arr.map((c) => String(c).split(";")[0]).find((c) => /X-Qlik-Session/i.test(c)) ?? null;
}
async function session() {
const hub = await fetch(`https://${HOST}/hub/`, { redirect: "manual" });
const targetId = ((hub.headers.get("location") ?? "").match(/targetId=([0-9a-f-]+)/i) ?? [])[1] ?? "";
const chemin = targetId
? `/internal_windows_authentication/?targetId=${targetId}&xrfkey=${xrfkey}`
: `/hub/?xrfkey=${xrfkey}`;
const res = await ntlmGet(chemin);
const direct = cookieDepuis(res.headers["set-cookie"]);
if (direct) return direct;
const loc = String(res.headers["location"] ?? "");
if (!/qlikTicket=/.test(loc)) throw new Error(`auth NTLM sans ticket (HTTP ${res.statusCode})`);
const ech = await fetch(loc, { redirect: "manual" });
const sc = typeof ech.headers.getSetCookie === "function" ? ech.headers.getSetCookie() : [];
const cookie = sc.map((c) => c.split(";")[0]).filter((c) => /X-Qlik-Session/i.test(c)).join("; ");
if (!cookie) throw new Error("ticket non échangé contre un cookie de session");
return cookie;
}
// ─── Script exécuté dans la page (websocket Engine accepté par le proxy) ────
function scriptInPage({ app, token, dimArticle, dimMois, serials, labels, nbArticles }) {
return new Promise((resolve) => {
const url = `wss://${location.host}/app/${app}?reloadUri=${encodeURIComponent(location.href)}&qlik-csrf-token=${token}`;
const ws = new WebSocket(url);
const sortie = [];
const log = (l) => { sortie.push(l); console.log("[cf] " + l); };
let id = 0;
const pend = new Map();
const rpc = (method, params, handle = -1) => new Promise((res, rej) => {
const i = ++id; pend.set(i, { res, rej });
ws.send(JSON.stringify({ jsonrpc: "2.0", id: i, handle, method, params }));
});
const pret = new Promise((res, rej) => {
ws.onmessage = (ev) => {
const m = JSON.parse(ev.data);
if (m.method === "OnConnected") return res();
if (m.id && pend.has(m.id)) {
const p = pend.get(m.id); pend.delete(m.id);
if (m.error) p.rej(new Error(JSON.stringify(m.error)));
else p.res(m.result);
}
};
ws.onerror = () => rej(new Error("websocket refusé (403 ?)"));
setTimeout(() => rej(new Error("timeout websocket")), 30000);
});
const dormir = (ms) => new Promise((r) => setTimeout(r, ms));
(async () => {
try {
await pret;
const doc = (await rpc("OpenDoc", { qDocName: app })).qReturn.qHandle;
// ─── 1. Expressions des master measures ──────────────────────
const ml = await rpc("CreateSessionObject", {
qProp: {
qInfo: { qType: "MeasureList" },
qMeasureListDef: { qType: "measure", qData: { title: "/qMetaDef/title", expr: "/qMeasure/qDef" } },
},
}, doc);
const items = (await rpc("GetLayout", {}, ml.qReturn.qHandle)).qLayout.qMeasureList?.qItems ?? [];
const parTitre = new Map();
log("=== EXPRESSIONS DES MASTER MEASURES ===");
for (const it of items) {
const t = (it.qMeta?.title ?? it.qData?.title ?? "").trim();
if (!t) continue;
parTitre.set(t.toLowerCase(), it.qInfo.qId);
if (/^(CA N|Quantité N|Magasin Ventes Nb N|CA par Magasin N|Marge % N|Quantité COMP)$/i.test(t)) {
log(` « ${t} » = ${it.qData?.expr ?? "?"}`);
}
}
const mesure = (t) => parTitre.get(t.toLowerCase()) ?? null;
// ─── 2. Sélection de la fenêtre 12 mois ──────────────────────
const champDate = await rpc("GetField", { qFieldName: "Date" }, doc);
const dh = champDate.qReturn.qHandle;
await rpc("Clear", {}, dh);
await dormir(300);
for (let i = 0; i < serials.length; i += 500) {
await rpc("SelectValues", {
qFieldValues: serials.slice(i, i + 500).map((s) => ({ qNum: s, qText: String(s) })),
qToggleMode: i > 0,
qSoftLock: true,
}, dh);
}
await dormir(500);
log("");
log(`=== FENÊTRE SÉLECTIONNÉE : ${labels[0]} → ${labels[labels.length - 1]} (${serials.length} jours) ===`);
// ─── 3. Articles les plus vendus (par agrégation directe) ────
const cubeTop = await rpc("CreateSessionObject", {
qProp: {
qInfo: { qType: "cf-val-top" },
qHyperCubeDef: {
qDimensions: [{ qLibraryId: dimArticle }],
qMeasures: [{ qDef: { qDef: "Sum(quantite)" }, qSortBy: { qSortByNumeric: -1 } }],
qInterColumnSortOrder: [1, 0],
qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 2, qHeight: nbArticles }],
},
},
}, doc);
const topMatrix = (await rpc("GetLayout", {}, cubeTop.qReturn.qHandle))
.qLayout.qHyperCube.qDataPages?.[0]?.qMatrix ?? [];
const codes = topMatrix.map((r) => String(r[0]?.qText ?? "")).filter(Boolean);
log(`articles testés (top ventes) : ${JSON.stringify(codes)}`);
if (codes.length === 0) { ws.close(); return resolve({ ok: false, error: "aucun article vendu sur la fenêtre", sortie }); }
const champArticle = await rpc("GetField", { qFieldName: "Article Code" }, doc);
const ah = champArticle.qReturn.qHandle;
await rpc("Clear", {}, ah);
await dormir(300);
await rpc("SelectValues", { qFieldValues: codes.map((c) => ({ qText: c })), qToggleMode: false, qSoftLock: true }, ah);
await dormir(500);
// ─── 4. Comparaison mois par mois ────────────────────────────
const mesures = [
{ qDef: { qDef: "Sum(quantite)" } },
{ qDef: { qDef: "Sum(ca_ht)" } },
{ qDef: { qDef: "Sum(ca_ttc)" } },
{ qDef: { qDef: "Count(DISTINCT [Magasin Code])" } },
{ qDef: { qDef: "Sum(marge)" } },
];
const idQte = mesure("Quantité N");
const idCa = mesure("CA N");
const idNbMag = mesure("Magasin Ventes Nb N");
if (idQte) mesures.push({ qLibraryId: idQte });
if (idCa) mesures.push({ qLibraryId: idCa });
if (idNbMag) mesures.push({ qLibraryId: idNbMag });
const largeur = 2 + mesures.length;
const cube = await rpc("CreateSessionObject", {
qProp: {
qInfo: { qType: "cf-val-mois" },
qHyperCubeDef: {
qDimensions: [{ qLibraryId: dimArticle }, { qLibraryId: dimMois }],
qMeasures: mesures,
qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: largeur, qHeight: 400 }],
},
},
}, doc);
const hc = (await rpc("GetLayout", {}, cube.qReturn.qHandle)).qLayout.qHyperCube;
const lignes = hc.qDataPages?.[0]?.qMatrix ?? [];
log("");
log("=== COMPARAISON MOIS PAR MOIS (agrégation directe vs master measure) ===");
log("article mois Sum(quantite) Quantité N | Sum(ca_ht) Sum(ca_ttc) CA N | Count(mag) Mag Nb N");
const pad = (v, n) => String(v).padStart(n);
for (const r of lignes) {
const code = String(r[0]?.qText ?? "");
const moisBrut = String(r[1]?.qText ?? "");
const mois = /^\d{6}$/.test(moisBrut) ? moisBrut.slice(0, 4) + "-" + moisBrut.slice(4) : moisBrut;
const n = (i) => Number(r[2 + i]?.qNum ?? 0) || 0;
log(
code.padEnd(13) + mois.padEnd(9) +
pad(Math.round(n(0)), 13) + pad(Math.round(n(5)), 12) + " |" +
pad(Math.round(n(1)), 11) + pad(Math.round(n(2)), 13) + pad(Math.round(n(6)), 9) + " |" +
pad(Math.round(n(3)), 11) + pad(Math.round(n(7)), 10),
);
}
log("");
log("LECTURE : si « Quantité N » vaut 0 sur les mois de l'année précédente alors que");
log(" Sum(quantite) est renseigné, la master measure est bien inutilisable");
log(" pour une fenêtre glissante — et l'agrégation directe la remplace.");
log(" Si Sum(quantite) == Quantité N sur les mois de l'année en cours,");
log(" l'expression par défaut de CollectFlow est validée.");
log(" Comparer aussi Sum(ca_ht) et Sum(ca_ttc) à « CA N » pour choisir");
log(" QLIK_EXPR_CA.");
// ─── 5. Clé de jointure : Article Code vs article_no_centrale ─
const cubeCle = await rpc("CreateSessionObject", {
qProp: {
qInfo: { qType: "cf-val-cle" },
qHyperCubeDef: {
qDimensions: [
{ qLibraryId: dimArticle },
{ qDef: { qFieldDefs: ["article_no_centrale"] } },
{ qDef: { qFieldDefs: ["article_codein"] } },
],
qMeasures: [],
qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 3, qHeight: 20 }],
},
},
}, doc);
const cles = (await rpc("GetLayout", {}, cubeCle.qReturn.qHandle))
.qLayout.qHyperCube.qDataPages?.[0]?.qMatrix ?? [];
log("");
log("=== CLÉ DE JOINTURE ===");
log("Article Code article_no_centrale article_codein");
for (const r of cles) {
log(
String(r[0]?.qText ?? "").padEnd(20) +
String(r[1]?.qText ?? "").padEnd(22) +
String(r[2]?.qText ?? ""),
);
}
log("");
log("LECTURE : la bonne clé est celle qui a le format de `articles.artcentrale`");
log(" côté FF Nancy (ex. 10000167303 — préfixe 10000 + 6 chiffres).");
ws.close();
resolve({ ok: true, sortie });
} catch (e) {
try { ws.close(); } catch { /* noop */ }
resolve({ ok: false, error: String(e?.message || e), sortie });
}
})();
});
}
// ─── Orchestration ──────────────────────────────────────────────────────────
(async () => {
const f = fenetre12Mois();
console.log(`Qlik ${HOST} — app ${APP}`);
console.log(`Fenêtre : ${f.debut} → ${f.fin}\n`);
const cookie = await session();
console.log("✓ authentifié (NTLM + ticket)\n");
const [nom, ...reste] = cookie.split("=");
const navigateur = await chromium.launch({
headless: true,
executablePath: process.env.PLAYWRIGHT_CHROMIUM_PATH || undefined,
args: ["--no-sandbox", "--disable-dev-shm-usage", "--ignore-certificate-errors"],
});
const ctx = await navigateur.newContext({
httpCredentials: { username: USER, password: PWD, origin: `https://${HOST}` },
ignoreHTTPSErrors: true,
});
await ctx.addCookies([{ name: nom, value: reste.join("="), domain: HOST, path: "/", secure: true }]);
try {
const page = await ctx.newPage();
let token = null;
let pret;
const attente = new Promise((r) => { pret = r; });
page.on("websocket", (ws) => {
const m = ws.url().match(/qlik-csrf-token=([^&]+)/);
if (m && !token) { token = decodeURIComponent(m[1]); pret(); }
});
await page.goto(`https://${HOST}/sense/app/${APP}`, { waitUntil: "domcontentloaded", timeout: 60000 }).catch(() => {});
await Promise.race([attente, page.waitForTimeout(20000)]);
if (!token) throw new Error("qlik-csrf-token introuvable (client Qlik non chargé ?)");
const res = await page.evaluate(scriptInPage, {
app: APP, token, dimArticle: DIM_ARTICLE, dimMois: DIM_MOIS,
serials: f.serials, labels: f.labels, nbArticles: NB_ARTICLES,
});
console.log(res.sortie.join("\n"));
if (!res.ok) {
console.error("\nÉCHEC : " + res.error);
process.exitCode = 1;
}
} finally {
await ctx.close();
await navigateur.close();
}
})().catch((e) => {
console.error("ERREUR :", e?.message || e);
process.exit(1);
});
+194
View File
@@ -0,0 +1,194 @@
/**
* Test CLI pour `src/lib/qlik-date-range.ts` (aucune dépendance Next.js).
* Usage : npx tsx scripts/test-qlik-date-range.ts
*
* Vérifie :
* - les repères Qlik (2025-01-01→45658, etc.)
* - la fenêtre 12 mois complets pour `now=2026-06-21` (doit donner
* dateDebut=2025-06-01, dateFin=2026-05-31, qStart=45809, qEnd=46173,
* label=2025-06_2026-05, 365 serials)
* - exemples supplémentaires (1er du mois, fin d'année, année bissextile)
* - erreurs attendues sur entrée mal formée
* - option `monthsBack` : 6 mois, bornes (clamp 1..12)
* - helper `envMonthsBack` : valeur absente, valide, hors bornes, non numérique
*
* Aucun framework : assertions manuelles, exit code 0/1.
*/
import {
isoDateToQlikSerial,
qlikSerialToIsoDate,
buildGridNetworkQlikDateFilter,
chunkSerials,
getMonthRanges,
envMonthsBack,
QLIK_DATE_ANCHORS,
QLIK_MONTHS_BACK_MIN,
QLIK_MONTHS_BACK_MAX,
QLIK_MONTHS_BACK_DEFAULT,
} from "../src/lib/qlik-date-range";
let pass = 0;
let fail = 0;
function eq(label: string, got: unknown, want: unknown): void {
const ok = JSON.stringify(got) === JSON.stringify(want);
if (ok) {
pass++;
console.log(` \u2713 ${label}`);
} else {
fail++;
console.log(` \u2717 ${label}\n got: ${JSON.stringify(got)}\n want: ${JSON.stringify(want)}`);
}
}
function truthy(label: string, got: unknown): void {
if (got) {
pass++;
console.log(` \u2713 ${label}`);
} else {
fail++;
console.log(` \u2717 ${label} \u2014 falsy: ${JSON.stringify(got)}`);
}
}
function throws(label: string, fn: () => unknown, re?: RegExp): void {
try {
fn();
fail++;
console.log(` \u2717 ${label} \u2014 n'a pas lanc\u00e9 d'exception`);
} catch (e) {
const msg = String((e as Error)?.message ?? e);
if (!re || re.test(msg)) {
pass++;
console.log(` \u2713 ${label}`);
} else {
fail++;
console.log(` \u2717 ${label} \u2014 exception inattendue: ${msg}`);
}
}
}
console.log("\n[1] Rep\u00e8res Qlik (skill qlik)");
for (const a of QLIK_DATE_ANCHORS) {
eq(`isoDateToQlikSerial(${a.iso}) = ${a.serial}`, isoDateToQlikSerial(a.iso), a.serial);
}
console.log("\n[2] Round-trip s\u00e9rial \u2194 ISO");
for (const a of QLIK_DATE_ANCHORS) {
eq(`qlikSerialToIsoDate(${a.serial}) = ${a.iso}`, qlikSerialToIsoDate(a.serial), a.iso);
}
console.log("\n[3] Fen\u00eatre grille (now=2026-06-21) \u2192 2025-06-01 .. 2026-05-31");
const f = buildGridNetworkQlikDateFilter(new Date("2026-06-21T12:00:00Z"));
eq("dateDebut", f.dateDebut, "2025-06-01");
eq("dateFin", f.dateFin, "2026-05-31");
eq("qStart", f.qStart, 45809);
eq("qEnd", f.qEnd, 46173);
eq("label", f.label, "2025-06_2026-05");
eq("setAnalysis", f.setAnalysis, `Date={">=45809<=46173"}`);
eq("dailySerials.length = 365", f.dailySerials.length, 365);
eq("dailySerials[0]", f.dailySerials[0], 45809);
eq("dailySerials[364]", f.dailySerials[364], 46173);
console.log("\n[4] Fen\u00eatre grille (now=2026-03-15) \u2192 2025-03-01 .. 2026-02-28");
const f2 = buildGridNetworkQlikDateFilter(new Date("2026-03-15T08:00:00Z"));
eq("dateDebut", f2.dateDebut, "2025-03-01");
eq("dateFin", f2.dateFin, "2026-02-28");
eq("qStart", f2.qStart, 45717);
eq("qEnd", f2.qEnd, 46081);
console.log("\n[5] Fen\u00eatre grille (now=2025-01-05) \u2192 2024-01-01 .. 2024-12-31 (ann\u00e9e bissextile, 366 j)");
const f3 = buildGridNetworkQlikDateFilter(new Date("2025-01-05T08:00:00Z"));
eq("dateDebut", f3.dateDebut, "2024-01-01");
eq("dateFin", f3.dateFin, "2024-12-31");
eq("dailySerials.length = 366", f3.dailySerials.length, 366);
console.log("\n[6] chunkSerials");
const chunks = chunkSerials(f.dailySerials, 100);
eq("chunks.length pour 365/100", chunks.length, 4);
eq("chunks[3].length = 65", chunks[3].length, 65);
eq("chunks[0][0]", chunks[0][0], 45809);
eq("chunks[3][64]", chunks[3][64], 46173);
console.log("\n[7] Robustesse");
throws("format invalide '2025/01/01'", () => isoDateToQlikSerial("2025/01/01"), /YYYY-MM-DD/);
throws("format vide", () => isoDateToQlikSerial(""), /YYYY-MM-DD/);
truthy("label non vide pour fenêtre valide", f.label && f.label.length > 0);
truthy("setAnalysis commence par 'Date='", f.setAnalysis.startsWith("Date="));
console.log("\n[8] getMonthRanges — fenêtre 12 mois (now=2026-06-21)");
const months = getMonthRanges(f);
eq("12 mois exactement", months.length, 12);
eq("premier mois label", months[0].label, "2025-06");
eq("premier mois dateDebut", months[0].dateDebut, "2025-06-01");
eq("premier mois dateFin", months[0].dateFin, "2025-06-30");
eq("premier mois serials.length = 30", months[0].dailySerials.length, 30);
eq("premier mois qStart", months[0].qStart, 45809);
eq("premier mois qEnd", months[0].qEnd, 45838);
eq("dernier mois label", months[11].label, "2026-05");
eq("dernier mois dateDebut", months[11].dateDebut, "2026-05-01");
eq("dernier mois dateFin", months[11].dateFin, "2026-05-31");
eq("dernier mois serials.length = 31", months[11].dailySerials.length, 31);
// Couvre février d'une année non bissextile (2025) — window now=2025-06-15 → 2024-06..2025-05
const fFeb25 = buildGridNetworkQlikDateFilter(new Date("2025-06-15T08:00:00Z"));
truthy("contient février 2025 (28j)", getMonthRanges(fFeb25).some((m) => m.label === "2025-02" && m.dailySerials.length === 28));
// Couvre février d'une année bissextile (2024)
const fBiss = buildGridNetworkQlikDateFilter(new Date("2025-01-05T08:00:00Z"));
const monthsBiss = getMonthRanges(fBiss);
truthy("contient février 2024 (29j)", monthsBiss.some((m) => m.label === "2024-02" && m.dailySerials.length === 29));
// Total jours = 365 (somme des serials)
const totalDays = months.reduce((acc, m) => acc + m.dailySerials.length, 0);
eq("somme des jours = 365", totalDays, 365);
console.log("\n[9] getMonthRanges — fenêtre courte partielle (troncature dernier mois)");
const fShort = { ...f, dateDebut: "2025-06-01", dateFin: "2025-08-15", qStart: 45809, qEnd: 45884, dailySerials: [], setAnalysis: "" };
const monthsShort = getMonthRanges(fShort);
eq("3 mois (juin, juillet, août)", monthsShort.length, 3);
eq("août tronqué à 15j", monthsShort[2].dailySerials.length, 15);
eq("août dateFin = 2025-08-15", monthsShort[2].dateFin, "2025-08-15");
console.log("\n[10] monthsBack=6 (now=2026-06-21) → 2025-12-01 .. 2026-05-31");
const f6 = buildGridNetworkQlikDateFilter(new Date("2026-06-21T12:00:00Z"), 6);
eq("dateDebut", f6.dateDebut, "2025-12-01");
eq("dateFin", f6.dateFin, "2026-05-31");
eq("qStart", f6.qStart, 45992);
eq("qEnd", f6.qEnd, 46173);
eq("label", f6.label, "2025-12_2026-05");
eq("dailySerials.length = 182 (déc 2025:31 + jan:31 + fév:28 + mar:31 + avr:30 + mai:31)", f6.dailySerials.length, 182);
const months6 = getMonthRanges(f6);
eq("getMonthRanges: 6 mois exactement", months6.length, 6);
eq("premier mois label", months6[0].label, "2025-12");
eq("dernier mois label", months6[5].label, "2026-05");
const totalDays6 = months6.reduce((acc, m) => acc + m.dailySerials.length, 0);
eq("somme des jours = 182", totalDays6, 182);
console.log("\n[11] monthsBack — bornes et clamp");
eq("monthsBack=1 (now=2026-06-21) → 2026-05-01 .. 2026-05-31 (1 mois complet)",
(() => { const x = buildGridNetworkQlikDateFilter(new Date("2026-06-21T12:00:00Z"), 1); return x.dateDebut === "2026-05-01" && x.dateFin === "2026-05-31"; })(),
true);
eq("monthsBack=0 clampé à 1", (() => { const x = buildGridNetworkQlikDateFilter(new Date("2026-06-21T12:00:00Z"), 0); return x.dateDebut === "2026-05-01"; })(), true);
eq("monthsBack=24 clampé à 12 (max)", (() => { const x = buildGridNetworkQlikDateFilter(new Date("2026-06-21T12:00:00Z"), 24); return x.dateDebut === "2025-06-01" && x.dateFin === "2026-05-31"; })(), true);
eq("monthsBack=-5 clampé à 1", (() => { const x = buildGridNetworkQlikDateFilter(new Date("2026-06-21T12:00:00Z"), -5); return x.dateDebut === "2026-05-01"; })(), true);
eq("monthsBack=NaN ramené au défaut 12", (() => { const x = buildGridNetworkQlikDateFilter(new Date("2026-06-21T12:00:00Z"), Number.NaN); return x.dateDebut === "2025-06-01" && x.dateFin === "2026-05-31"; })(), true);
eq("monthsBack=undefined (par défaut) = défaut 12", (() => { const x = buildGridNetworkQlikDateFilter(new Date("2026-06-21T12:00:00Z")); return x.dateDebut === "2025-06-01" && x.dateFin === "2026-05-31"; })(), true);
eq("bornes exportées MIN=1", QLIK_MONTHS_BACK_MIN, 1);
eq("bornes exportées MAX=12", QLIK_MONTHS_BACK_MAX, 12);
eq("défaut exporté = 12", QLIK_MONTHS_BACK_DEFAULT, 12);
console.log("\n[12] envMonthsBack — lecture env");
const PREV = process.env.QLIK_TEST_MONTHS_BACK;
delete process.env.QLIK_TEST_MONTHS_BACK;
eq("env absent → défaut", envMonthsBack("QLIK_TEST_MONTHS_BACK"), QLIK_MONTHS_BACK_DEFAULT);
eq("env vide → défaut", (() => { process.env.QLIK_TEST_MONTHS_BACK = ""; return envMonthsBack("QLIK_TEST_MONTHS_BACK"); })(), QLIK_MONTHS_BACK_DEFAULT);
eq("env '6' → 6", (() => { process.env.QLIK_TEST_MONTHS_BACK = "6"; return envMonthsBack("QLIK_TEST_MONTHS_BACK"); })(), 6);
eq("env 'abc' → défaut", (() => { process.env.QLIK_TEST_MONTHS_BACK = "abc"; return envMonthsBack("QLIK_TEST_MONTHS_BACK"); })(), QLIK_MONTHS_BACK_DEFAULT);
eq("env '24' clampé à MAX=12", (() => { process.env.QLIK_TEST_MONTHS_BACK = "24"; return envMonthsBack("QLIK_TEST_MONTHS_BACK"); })(), QLIK_MONTHS_BACK_MAX);
eq("env '-3' clampé à MIN=1", (() => { process.env.QLIK_TEST_MONTHS_BACK = "-3"; return envMonthsBack("QLIK_TEST_MONTHS_BACK"); })(), QLIK_MONTHS_BACK_MIN);
eq("env '0' clampé à MIN=1", (() => { process.env.QLIK_TEST_MONTHS_BACK = "0"; return envMonthsBack("QLIK_TEST_MONTHS_BACK"); })(), QLIK_MONTHS_BACK_MIN);
eq("env '3.7' troncé à 3", (() => { process.env.QLIK_TEST_MONTHS_BACK = "3.7"; return envMonthsBack("QLIK_TEST_MONTHS_BACK"); })(), 3);
eq("env ' 8 ' trimé puis parsé → 8", (() => { process.env.QLIK_TEST_MONTHS_BACK = " 8 "; return envMonthsBack("QLIK_TEST_MONTHS_BACK"); })(), 8);
if (PREV === undefined) delete process.env.QLIK_TEST_MONTHS_BACK; else process.env.QLIK_TEST_MONTHS_BACK = PREV;
console.log(`\n=== ${pass} passés, ${fail} échoués ===`);
if (fail > 0) process.exit(1);
-528
View File
@@ -1,528 +0,0 @@
"use client";
import { useState, useRef, useEffect, useCallback } from "react";
import { Send, Bot, User, Database, Loader2, AlertCircle, ChevronDown, Trash2, DollarSign } from "lucide-react";
import { cn } from "@/lib/utils";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
// Strip HTML tags and convert common HTML constructs to Markdown equivalents
function htmlToMarkdown(text: string): string {
return text
// Block-level → markdown
.replace(/<h([1-6])[^>]*>(.*?)<\/h\1>/gis, (_, level, content) => `${"#".repeat(Number(level))} ${content.replace(/<[^>]+>/g, "")}\n`)
.replace(/<strong[^>]*>(.*?)<\/strong>/gis, "**$1**")
.replace(/<b[^>]*>(.*?)<\/b>/gis, "**$1**")
.replace(/<em[^>]*>(.*?)<\/em>/gis, "*$1*")
.replace(/<i[^>]*>(.*?)<\/i>/gis, "*$1*")
.replace(/<code[^>]*>(.*?)<\/code>/gis, "`$1`")
.replace(/<pre[^>]*>(.*?)<\/pre>/gis, (_, c) => "```\n" + c.replace(/<[^>]+>/g, "") + "\n```")
.replace(/<li[^>]*>(.*?)<\/li>/gis, "- $1")
.replace(/<ul[^>]*>|<\/ul>/gis, "")
.replace(/<ol[^>]*>|<\/ol>/gis, "")
.replace(/<tr[^>]*>/gis, "")
.replace(/<\/tr>/gis, "\n")
.replace(/<th[^>]*>(.*?)<\/th>/gis, "| **$1** ")
.replace(/<td[^>]*>(.*?)<\/td>/gis, "| $1 ")
.replace(/<table[^>]*>|<\/table>|<thead[^>]*>|<\/thead>|<tbody[^>]*>|<\/tbody>/gis, "")
.replace(/<br\s*\/?>/gis, "\n")
.replace(/<p[^>]*>(.*?)<\/p>/gis, "$1\n\n")
.replace(/<div[^>]*>(.*?)<\/div>/gis, "$1\n")
.replace(/<a[^>]*href="([^"]*)"[^>]*>(.*?)<\/a>/gis, "[$2]($1)")
// Strip any remaining tags
.replace(/<[^>]+>/g, "")
// Decode HTML entities
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&nbsp;/g, " ")
// Clean up excessive blank lines
.replace(/\n{3,}/g, "\n\n")
.trim();
}
interface ModelInfo {
id: string;
name: string;
free: boolean;
promptPrice: number; // $ per token
completionPrice: number;
}
interface ToolEvent {
name: string;
args: Record<string, unknown>;
result?: string;
}
interface Message {
role: "user" | "assistant";
content: string;
toolEvents?: ToolEvent[];
}
interface SessionUsage {
promptTokens: number;
completionTokens: number;
costUsd: number;
}
const DEFAULT_MODEL = "google/gemini-2.0-flash-001";
// Compute cost from token counts and model pricing
function computeCost(promptTokens: number, completionTokens: number, model: ModelInfo | null): number {
if (!model) return 0;
return promptTokens * model.promptPrice + completionTokens * model.completionPrice;
}
function formatCost(usd: number): string {
if (usd === 0) return "Gratuit";
if (usd < 0.001) return `$${(usd * 1000).toFixed(4)}m`;
return `$${usd.toFixed(6)}`;
}
function ToolCallBlock({ event }: { event: ToolEvent }) {
const [open, setOpen] = useState(false);
const label = event.name === "execute_sql"
? `SQL: ${String(event.args.query ?? "").substring(0, 60)}${String(event.args.query ?? "").length > 60 ? "…" : ""}`
: event.name === "get_db_schema"
? "Schéma DB"
: event.name;
return (
<div className="my-1 rounded-lg border border-[var(--border)] bg-[var(--bg-elevated)] overflow-hidden text-xs">
<button
onClick={() => setOpen(!open)}
className="w-full flex items-center gap-2 px-3 py-2 text-left hover:bg-[var(--bg-base)] transition-colors"
>
<Database className="w-3.5 h-3.5 text-[var(--accent)] shrink-0" />
<span className="flex-1 text-[var(--text-secondary)] font-mono truncate">{label}</span>
<ChevronDown className={cn("w-3.5 h-3.5 text-[var(--text-muted)] transition-transform", open && "rotate-180")} />
</button>
{open && event.result && (
<pre className="px-3 pb-3 pt-1 text-[10px] text-[var(--text-secondary)] overflow-x-auto whitespace-pre-wrap break-words max-h-48 overflow-y-auto">
{event.result}
</pre>
)}
</div>
);
}
function MessageBubble({ msg }: { msg: Message }) {
const isUser = msg.role === "user";
return (
<div className={cn("flex gap-3 mb-4", isUser && "flex-row-reverse")}>
{/* Avatar */}
<div className={cn(
"w-8 h-8 rounded-full flex items-center justify-center shrink-0 mt-0.5",
isUser ? "bg-[var(--accent)] text-white" : "bg-[var(--bg-elevated)] border border-[var(--border)]"
)}>
{isUser
? <User className="w-4 h-4" />
: <Bot className="w-4 h-4 text-[var(--accent)]" />
}
</div>
{/* Content */}
<div className={cn("flex-1 max-w-[85%]", isUser && "flex flex-col items-end")}>
{/* Tool events (assistant only) */}
{msg.toolEvents && msg.toolEvents.length > 0 && (
<div className="w-full mb-2">
{msg.toolEvents.map((ev, i) => (
<ToolCallBlock key={i} event={ev} />
))}
</div>
)}
{/* Text bubble */}
{msg.content && (
<div className={cn(
"rounded-2xl px-4 py-3 text-sm leading-relaxed break-words",
isUser
? "bg-[var(--accent)] text-white rounded-tr-md"
: "bg-[var(--bg-elevated)] border border-[var(--border)] text-[var(--text-primary)] rounded-tl-md"
)}>
{isUser ? msg.content : (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
p: ({ children }) => <p className="mb-2 last:mb-0">{children}</p>,
h1: ({ children }) => <h1 className="text-base font-bold mb-2 mt-3 first:mt-0">{children}</h1>,
h2: ({ children }) => <h2 className="text-sm font-bold mb-2 mt-3 first:mt-0">{children}</h2>,
h3: ({ children }) => <h3 className="text-sm font-semibold mb-1 mt-2 first:mt-0">{children}</h3>,
ul: ({ children }) => <ul className="list-disc list-inside mb-2 space-y-0.5">{children}</ul>,
ol: ({ children }) => <ol className="list-decimal list-inside mb-2 space-y-0.5">{children}</ol>,
li: ({ children }) => <li className="ml-2">{children}</li>,
code: ({ inline, children }: { inline?: boolean; children?: React.ReactNode }) =>
inline
? <code className="px-1 py-0.5 rounded bg-[var(--bg-base)] border border-[var(--border)] font-mono text-xs text-[var(--accent)]">{children}</code>
: <pre className="my-2 p-3 rounded-lg bg-[var(--bg-base)] border border-[var(--border)] overflow-x-auto"><code className="font-mono text-xs text-[var(--text-primary)] whitespace-pre">{children}</code></pre>,
strong: ({ children }) => <strong className="font-semibold">{children}</strong>,
em: ({ children }) => <em className="italic">{children}</em>,
blockquote: ({ children }) => <blockquote className="border-l-2 border-[var(--accent)] pl-3 my-2 text-[var(--text-secondary)] italic">{children}</blockquote>,
table: ({ children }) => <div className="overflow-x-auto my-2"><table className="w-full text-xs border-collapse">{children}</table></div>,
thead: ({ children }) => <thead className="bg-[var(--bg-base)]">{children}</thead>,
th: ({ children }) => <th className="border border-[var(--border)] px-2 py-1 text-left font-semibold">{children}</th>,
td: ({ children }) => <td className="border border-[var(--border)] px-2 py-1">{children}</td>,
hr: () => <hr className="border-[var(--border)] my-3" />,
a: ({ href, children }) => <a href={href} target="_blank" rel="noopener noreferrer" className="text-[var(--accent)] underline hover:opacity-80">{children}</a>,
}}
>
{htmlToMarkdown(msg.content)}
</ReactMarkdown>
)}
</div>
)}
</div>
</div>
);
}
export default function AdminAiChatPage() {
const [models, setModels] = useState<ModelInfo[]>([]);
const [selectedModel, setSelectedModel] = useState<string>(DEFAULT_MODEL);
const [aiProvider, setAiProviderState] = useState<"openrouter" | "google">("openrouter");
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [loadingModels, setLoadingModels] = useState(true);
const [sessionUsage, setSessionUsage] = useState<SessionUsage>({ promptTokens: 0, completionTokens: 0, costUsd: 0 });
const messagesEndRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const abortRef = useRef<AbortController | null>(null);
const selectedModelInfo = models.find(m => m.id === selectedModel) ?? null;
// Fetch config + models
useEffect(() => {
setLoadingModels(true);
fetch("/api/admin/ai-config")
.then(r => r.json())
.then(async d => {
const provider: "openrouter" | "google" = d.provider ?? "openrouter";
setAiProviderState(provider);
if (provider === "google") {
const gRes = await fetch("/api/google-ai/models").then(r => r.json()).catch(() => ({ models: [] }));
setModels((gRes.models ?? []).map((m: { id: string; name: string }) => ({
id: m.id, name: m.name, free: false, promptPrice: 0, completionPrice: 0,
})));
setSelectedModel(d.googleAiModel ?? "gemini-2.0-flash");
} else {
const od = await fetch("/api/openrouter/models").then(r => r.json()).catch(() => ({ models: [] }));
if (od.models) setModels(od.models);
if (d.openRouterModel) setSelectedModel(d.openRouterModel);
}
})
.catch(async () => {
const od = await fetch("/api/openrouter/models").then(r => r.json()).catch(() => ({ models: [] }));
if (od.models) setModels(od.models);
})
.finally(() => setLoadingModels(false));
}, []);
// Auto-scroll
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages, loading]);
const sendMessage = useCallback(async () => {
if (!input.trim() || loading) return;
const userMsg: Message = { role: "user", content: input.trim() };
setInput("");
setError(null);
setMessages(prev => [...prev, userMsg]);
setLoading(true);
// Build messages to send (excluding tool events, which are local display only)
const apiMessages = [...messages, userMsg].map(m => ({
role: m.role,
content: m.content,
}));
const assistantMsg: Message = { role: "assistant", content: "", toolEvents: [] };
setMessages(prev => [...prev, assistantMsg]);
abortRef.current = new AbortController();
try {
const res = await fetch("/api/admin/ai-chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages: apiMessages, model: selectedModel }),
signal: abortRef.current.signal,
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || "Erreur serveur");
}
const reader = res.body!.getReader();
const decoder = new TextDecoder();
let buffer = "";
const pendingToolEvents: ToolEvent[] = [];
let lastToolStart: ToolEvent | null = null;
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
const raw = line.slice(6).trim();
try {
const event = JSON.parse(raw);
if (event.type === "text") {
setMessages(prev => {
const copy = [...prev];
const last = { ...copy[copy.length - 1] };
last.content += event.content;
copy[copy.length - 1] = last;
return copy;
});
} else if (event.type === "tool_start") {
lastToolStart = { name: event.name, args: event.args };
} else if (event.type === "tool_result") {
if (lastToolStart) {
const ev: ToolEvent = { ...lastToolStart, result: event.result };
pendingToolEvents.push(ev);
lastToolStart = null;
setMessages(prev => {
const copy = [...prev];
const last = { ...copy[copy.length - 1] };
last.toolEvents = [...pendingToolEvents];
copy[copy.length - 1] = last;
return copy;
});
}
} else if (event.type === "usage") {
const pt = event.prompt_tokens ?? 0;
const ct = event.completion_tokens ?? 0;
const cost = computeCost(pt, ct, selectedModelInfo);
setSessionUsage(prev => ({
promptTokens: prev.promptTokens + pt,
completionTokens: prev.completionTokens + ct,
costUsd: prev.costUsd + cost,
}));
} else if (event.type === "error") {
setError(event.message);
}
} catch { /* ignore */ }
}
}
} catch (err) {
if ((err as Error).name !== "AbortError") {
setError(err instanceof Error ? err.message : "Erreur inconnue");
}
} finally {
setLoading(false);
}
}, [input, loading, messages, selectedModel, selectedModelInfo]);
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
};
const clearConversation = () => {
setMessages([]);
setSessionUsage({ promptTokens: 0, completionTokens: 0, costUsd: 0 });
setError(null);
if (loading && abortRef.current) {
abortRef.current.abort();
setLoading(false);
}
};
return (
<div className="flex flex-col h-full max-h-full">
{/* Header */}
<div className="flex items-center justify-between mb-4 shrink-0">
<div>
<h1 className="text-xl font-semibold text-[var(--text-primary)] flex items-center gap-2">
<Bot className="w-5 h-5 text-[var(--accent)]" />
Admin AI Chat
</h1>
<p className="text-xs text-[var(--text-muted)] mt-0.5">
Accès direct à la base de données · Admin uniquement
</p>
</div>
{/* Usage / Cost */}
<div className="flex items-center gap-3">
<div className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-[var(--bg-elevated)] border border-[var(--border)] text-xs text-[var(--text-secondary)]">
<DollarSign className="w-3.5 h-3.5 text-[var(--accent)]" />
<span className="font-mono font-semibold">{formatCost(sessionUsage.costUsd)}</span>
<span className="text-[var(--text-muted)]">session</span>
{sessionUsage.promptTokens > 0 && (
<span className="text-[var(--text-muted)] ml-1">
({sessionUsage.promptTokens + sessionUsage.completionTokens} tok)
</span>
)}
</div>
<button
onClick={clearConversation}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-[var(--border)] text-xs text-[var(--text-secondary)] hover:bg-[var(--bg-elevated)] transition-colors"
>
<Trash2 className="w-3.5 h-3.5" />
Effacer
</button>
</div>
</div>
{/* Model selector */}
<div className="mb-4 shrink-0">
<div className="flex items-center gap-3 p-3 rounded-xl bg-[var(--bg-elevated)] border border-[var(--border)]">
<span className={cn(
"text-[10px] font-semibold px-1.5 py-0.5 rounded shrink-0",
aiProvider === "google"
? "bg-blue-500/10 text-blue-500"
: "bg-[var(--accent-bg)] text-[var(--accent)]"
)}>
{aiProvider === "google" ? "Google AI" : "OpenRouter"}
</span>
{loadingModels ? (
<div className="flex items-center gap-2 text-xs text-[var(--text-muted)]">
<Loader2 className="w-3.5 h-3.5 animate-spin" />
Chargement des modèles…
</div>
) : (
<div className="flex items-center gap-3 flex-1 min-w-0">
<select
value={selectedModel}
onChange={e => setSelectedModel(e.target.value)}
className="flex-1 min-w-0 text-sm bg-transparent text-[var(--text-primary)] border-none outline-none cursor-pointer"
>
{models.map(m => (
<option key={m.id} value={m.id}>
{m.name} {m.free ? "(Gratuit)" : ""}
</option>
))}
</select>
{selectedModelInfo && !selectedModelInfo.free && (
<div className="flex items-center gap-2 text-[10px] text-[var(--text-muted)] shrink-0 font-mono">
<span>in: ${(selectedModelInfo.promptPrice * 1_000_000).toFixed(2)}/M</span>
<span>·</span>
<span>out: ${(selectedModelInfo.completionPrice * 1_000_000).toFixed(2)}/M</span>
</div>
)}
{selectedModelInfo?.free && (
<span className="text-[10px] text-emerald-500 font-semibold shrink-0">GRATUIT</span>
)}
</div>
)}
</div>
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto min-h-0 rounded-xl bg-[var(--bg-base)] border border-[var(--border)] p-4">
{messages.length === 0 && (
<div className="h-full flex flex-col items-center justify-center text-center gap-4">
<div className="w-16 h-16 rounded-2xl bg-[var(--accent-bg)] border border-[var(--accent-border)] flex items-center justify-center">
<Bot className="w-8 h-8 text-[var(--accent)]" />
</div>
<div>
<p className="text-[var(--text-primary)] font-medium mb-1">Assistant Admin CollectFlow</p>
<p className="text-sm text-[var(--text-muted)] max-w-md">
Posez des questions sur vos données, demandez des analyses, ou explorez la base de données.
</p>
</div>
<div className="grid grid-cols-2 gap-2 max-w-lg mt-2">
{[
"Combien de produits par fournisseur ?",
"Quels sont les 10 produits avec le plus de CA ?",
"Montre-moi les snapshots récents",
"Répartition des gammes A/B/C/D/Z",
].map(suggestion => (
<button
key={suggestion}
onClick={() => { setInput(suggestion); textareaRef.current?.focus(); }}
className="text-xs text-left px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--bg-elevated)] hover:bg-[var(--bg-base)] text-[var(--text-secondary)] transition-colors"
>
{suggestion}
</button>
))}
</div>
</div>
)}
{messages.map((msg, i) => (
<MessageBubble key={i} msg={msg} />
))}
{loading && messages[messages.length - 1]?.role !== "assistant" && (
<div className="flex gap-3 mb-4">
<div className="w-8 h-8 rounded-full flex items-center justify-center bg-[var(--bg-elevated)] border border-[var(--border)]">
<Bot className="w-4 h-4 text-[var(--accent)]" />
</div>
<div className="flex items-center gap-1.5 px-4 py-3 rounded-2xl rounded-tl-md bg-[var(--bg-elevated)] border border-[var(--border)]">
<span className="w-1.5 h-1.5 bg-[var(--accent)] rounded-full animate-bounce [animation-delay:0ms]" />
<span className="w-1.5 h-1.5 bg-[var(--accent)] rounded-full animate-bounce [animation-delay:150ms]" />
<span className="w-1.5 h-1.5 bg-[var(--accent)] rounded-full animate-bounce [animation-delay:300ms]" />
</div>
</div>
)}
{error && (
<div className="flex items-start gap-2 p-3 rounded-xl bg-[var(--accent-error-bg)] border border-[var(--accent-error-border,var(--border))] text-sm text-[var(--accent-error)] mb-4">
<AlertCircle className="w-4 h-4 shrink-0 mt-0.5" />
<span>{error}</span>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Input */}
<div className="mt-3 shrink-0">
<div className="flex items-end gap-2 p-2 rounded-xl bg-[var(--bg-elevated)] border border-[var(--border)] focus-within:border-[var(--accent)] transition-colors">
<textarea
ref={textareaRef}
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Posez une question… (Entrée pour envoyer, Shift+Entrée pour nouvelle ligne)"
rows={1}
className="flex-1 resize-none bg-transparent text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] outline-none py-2 px-2 max-h-32 overflow-y-auto"
style={{ minHeight: "40px" }}
onInput={(e) => {
const el = e.currentTarget;
el.style.height = "auto";
el.style.height = Math.min(el.scrollHeight, 128) + "px";
}}
disabled={loading}
/>
<button
onClick={sendMessage}
disabled={!input.trim() || loading}
className="w-9 h-9 rounded-lg bg-[var(--accent)] text-white flex items-center justify-center hover:opacity-90 disabled:opacity-40 disabled:cursor-not-allowed transition-opacity shrink-0"
>
{loading
? <Loader2 className="w-4 h-4 animate-spin" />
: <Send className="w-4 h-4" />
}
</button>
</div>
<p className="text-[10px] text-[var(--text-muted)] mt-1.5 text-center">
L&apos;IA peut exécuter des requêtes SELECT sur la base de données · Les modifications ne sont pas autorisées
</p>
</div>
</div>
);
}
@@ -0,0 +1,414 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
CalendarClock, Play, Square, RefreshCw, Loader2, AlertTriangle,
CheckCircle, Database, Network, Search,
} from "lucide-react";
import { cn } from "@/lib/utils";
interface SyncSettings {
actif: boolean;
heureDebut: number;
heureFin: number;
qlikParNuit: number;
sqlParQlik: number;
qlikMinJours: number;
}
interface SyncState {
enCours: boolean;
origine: "auto" | "manuel" | null;
debutAt: string | null;
finAt: string | null;
fournisseurCourant: string | null;
etape: "sql" | "qlik" | null;
sqlFaits: number;
sqlEchecs: number;
qlikFaits: number;
qlikEchecs: number;
tourTermine: boolean;
derniereErreur: string | null;
}
interface FournisseurRow {
codeFournisseur: string;
nomFournisseur: string | null;
actifSql: boolean;
actifQlik: boolean;
desactiveMotif: string | null;
dernierSqlAt: string | null;
dernierSqlStatut: string | null;
dernierSqlLignes: number | null;
dernierSqlMs: number | null;
dernierQlikAt: string | null;
dernierQlikStatut: string | null;
dernierQlikCodes: number | null;
}
interface Resume {
total: number; actifsSql: number; actifsQlik: number;
desactivesAuto: number; enEchecSql: number; enEchecQlik: number; jamaisSql: number;
}
const fmtDate = (iso: string | null) =>
iso ? new Date(iso).toLocaleString("fr-FR", { dateStyle: "short", timeStyle: "short" }) : "jamais";
const fmtDuree = (ms: number | null) =>
ms == null ? "" : ms < 1000 ? `${ms} ms` : `${Math.round(ms / 1000)} s`;
function Pastille({ statut }: { statut: string | null }) {
if (!statut) return <span className="text-[11px]" style={{ color: "var(--text-muted)" }}>—</span>;
const couleurs: Record<string, string> = {
succes: "text-emerald-500", echec: "text-red-500", vide: "text-amber-500",
};
const libelles: Record<string, string> = { succes: "ok", echec: "échec", vide: "vide" };
return <span className={cn("text-[11px] font-semibold", couleurs[statut] ?? "")}>{libelles[statut] ?? statut}</span>;
}
export function SynchronisationClient() {
const [settings, setSettings] = useState<SyncSettings | null>(null);
const [state, setState] = useState<SyncState | null>(null);
const [dansLaFenetre, setDansLaFenetre] = useState(false);
const [rows, setRows] = useState<FournisseurRow[]>([]);
const [resume, setResume] = useState<Resume | null>(null);
const [erreur, setErreur] = useState<string | null>(null);
const [chargement, setChargement] = useState(true);
const [recherche, setRecherche] = useState("");
const [filtre, setFiltre] = useState<"tous" | "actifs" | "inactifs" | "echecs">("tous");
// ── Lectures pures : aucun setState, pour rester appelables depuis un effet ──
const lireEtat = useCallback(async () => {
const res = await fetch("/api/admin/sync");
if (!res.ok) throw new Error((await res.json()).error ?? `Erreur ${res.status}`);
return res.json();
}, []);
const lireFournisseurs = useCallback(async () => {
const res = await fetch("/api/admin/sync/fournisseurs");
if (!res.ok) throw new Error((await res.json()).error ?? `Erreur ${res.status}`);
return res.json();
}, []);
const rafraichirEtat = useCallback(() => {
lireEtat()
.then((d) => { setSettings(d.settings); setState(d.state); setDansLaFenetre(d.dansLaFenetre); setErreur(null); })
.catch((e) => setErreur(e instanceof Error ? e.message : String(e)));
}, [lireEtat]);
const rafraichirTout = useCallback(() => {
Promise.all([lireEtat(), lireFournisseurs()])
.then(([e, f]) => {
setSettings(e.settings); setState(e.state); setDansLaFenetre(e.dansLaFenetre);
setRows(f.fournisseurs ?? []); setResume(f.resume ?? null); setErreur(null);
})
.catch((e) => setErreur(e instanceof Error ? e.message : String(e)))
.finally(() => setChargement(false));
}, [lireEtat, lireFournisseurs]);
useEffect(() => { rafraichirTout(); }, [rafraichirTout]);
// Suivi rapproché pendant un round : l'état change à chaque fournisseur.
useEffect(() => {
if (!state?.enCours) return;
const id = setInterval(rafraichirEtat, 3000);
return () => clearInterval(id);
}, [state?.enCours, rafraichirEtat]);
const majSettings = async (patch: Partial<SyncSettings>) => {
setErreur(null);
try {
const res = await fetch("/api/admin/sync", {
method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(patch),
});
const d = await res.json();
if (!res.ok) throw new Error(d.error ?? `Erreur ${res.status}`);
setSettings(d.settings);
} catch (e) { setErreur(e instanceof Error ? e.message : String(e)); }
};
const action = async (act: "start" | "stop" | "seed", forcer = false) => {
setErreur(null);
try {
const res = await fetch("/api/admin/sync", {
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: act, forcer }),
});
const d = await res.json();
if (!res.ok) throw new Error(d.error ?? `Erreur ${res.status}`);
rafraichirTout();
} catch (e) { setErreur(e instanceof Error ? e.message : String(e)); }
};
const basculer = async (codes: string[], champ: "actifSql" | "actifQlik", valeur: boolean) => {
setErreur(null);
// Mise à jour optimiste : la liste peut compter des centaines de lignes,
// un rechargement complet à chaque case cochée serait pénible.
setRows((prev) => prev.map((r) => (codes.includes(r.codeFournisseur)
? { ...r, [champ]: valeur, ...(valeur ? { desactiveMotif: null } : {}) }
: r)));
try {
const res = await fetch("/api/admin/sync/fournisseurs", {
method: "PATCH", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ codes, [champ]: valeur }),
});
if (!res.ok) throw new Error((await res.json()).error ?? `Erreur ${res.status}`);
} catch (e) {
setErreur(e instanceof Error ? e.message : String(e));
rafraichirTout(); // l'optimisme était infondé : on resynchronise
}
};
const visibles = useMemo(() => {
const q = recherche.trim().toLowerCase();
return rows.filter((r) => {
if (q && !`${r.codeFournisseur} ${r.nomFournisseur ?? ""}`.toLowerCase().includes(q)) return false;
if (filtre === "actifs") return r.actifSql || r.actifQlik;
if (filtre === "inactifs") return !r.actifSql && !r.actifQlik;
if (filtre === "echecs") return r.dernierSqlStatut === "echec" || r.dernierQlikStatut === "echec";
return true;
});
}, [rows, recherche, filtre]);
const codesVisibles = useMemo(() => visibles.map((r) => r.codeFournisseur), [visibles]);
return (
<div className="min-h-screen p-6" style={{ background: "var(--bg-base)" }}>
<div className="mx-auto max-w-screen-xl">
<h1 className="text-2xl font-bold flex items-center gap-2" style={{ color: "var(--text-primary)" }}>
<CalendarClock className="w-6 h-6" style={{ color: "var(--accent)" }} />
Synchronisation nocturne
</h1>
<p className="text-[13px] mt-1 mb-5" style={{ color: "var(--text-muted)" }}>
Les fournisseurs sont traités du plus anciennement synchronisé au plus récent. Si la fenêtre
se referme avant la fin, la nuit suivante reprend là où on s&apos;est arrêté ; quand tout le
monde est passé, un nouveau tour recommence.
</p>
{erreur && (
<div className="rounded-xl p-3.5 mb-4 flex items-start gap-2.5 text-[13px]"
style={{ background: "var(--accent-error-bg)", border: "1px solid var(--accent-error)", color: "var(--accent-error)" }}>
<AlertTriangle className="w-4 h-4 shrink-0 mt-px" />
<span>{erreur}</span>
</div>
)}
{/* ── Réglages ── */}
{settings && (
<div className="rounded-2xl p-4 mb-4" style={{ background: "var(--bg-surface)", border: "1px solid var(--border)" }}>
<div className="flex flex-wrap items-end gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={settings.actif}
onChange={(e) => majSettings({ actif: e.target.checked })} className="w-4 h-4" />
<span className="text-[13px] font-semibold" style={{ color: "var(--text-primary)" }}>
Synchronisation nocturne active
</span>
</label>
<div className="flex items-end gap-2">
<div>
<label className="block text-[11px] mb-1" style={{ color: "var(--text-muted)" }}>De</label>
<input type="number" min={0} max={23} value={settings.heureDebut}
onChange={(e) => majSettings({ heureDebut: Number(e.target.value) })}
className="w-16 rounded-lg px-2 py-1.5 text-[13px]"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-primary)" }} />
</div>
<span className="pb-2 text-[13px]" style={{ color: "var(--text-muted)" }}>h à</span>
<div>
<label className="block text-[11px] mb-1" style={{ color: "var(--text-muted)" }}>À</label>
<input type="number" min={0} max={23} value={settings.heureFin}
onChange={(e) => majSettings({ heureFin: Number(e.target.value) })}
className="w-16 rounded-lg px-2 py-1.5 text-[13px]"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-primary)" }} />
</div>
<span className="pb-2 text-[13px]" style={{ color: "var(--text-muted)" }}>h</span>
</div>
<div>
<label className="block text-[11px] mb-1" style={{ color: "var(--text-muted)" }}>Qlik / nuit</label>
<input type="number" min={0} max={200} value={settings.qlikParNuit}
onChange={(e) => majSettings({ qlikParNuit: Number(e.target.value) })}
className="w-20 rounded-lg px-2 py-1.5 text-[13px]"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-primary)" }} />
</div>
<div>
<label className="block text-[11px] mb-1" style={{ color: "var(--text-muted)" }}>Qlik si + vieux que</label>
<input type="number" min={0} max={365} value={settings.qlikMinJours}
onChange={(e) => majSettings({ qlikMinJours: Number(e.target.value) })}
className="w-20 rounded-lg px-2 py-1.5 text-[13px]"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-primary)" }} />
</div>
<div className="flex-1" />
{state?.enCours ? (
<button onClick={() => action("stop")} className="rounded-xl px-4 py-2 text-[13px] font-semibold flex items-center gap-2"
style={{ background: "var(--accent-error-bg)", color: "var(--accent-error)", border: "1px solid var(--accent-error)" }}>
<Square className="w-3.5 h-3.5" /> Arrêter
</button>
) : (
<button onClick={() => action("start", !dansLaFenetre)} className="rounded-xl px-4 py-2 text-[13px] font-semibold text-white flex items-center gap-2"
style={{ background: "var(--accent)" }}>
<Play className="w-3.5 h-3.5" /> Lancer maintenant
</button>
)}
</div>
<p className="text-[11px] mt-3" style={{ color: "var(--text-muted)" }}>
{dansLaFenetre
? "Nous sommes actuellement dans la fenêtre."
: "Hors fenêtre — « Lancer maintenant » forcera un round immédiat."}
{" "}Une extraction Qlik peut durer 8 minutes : le plafond par nuit évite qu&apos;elle
monopolise la fenêtre. Un fournisseur déjà démarré n&apos;est jamais interrompu à l&apos;heure de fin.
</p>
</div>
)}
{/* ── Round en cours ── */}
{state && (state.enCours || state.finAt) && (
<div className="rounded-2xl p-4 mb-4 text-[13px]"
style={{ background: state.enCours ? "var(--accent-bg)" : "var(--bg-surface)", border: `1px solid ${state.enCours ? "var(--accent-border)" : "var(--border)"}` }}>
<div className="flex items-center gap-2 flex-wrap">
{state.enCours
? <Loader2 className="w-4 h-4 animate-spin" style={{ color: "var(--accent)" }} />
: <CheckCircle className="w-4 h-4 text-emerald-500" />}
<span className="font-semibold" style={{ color: "var(--text-primary)" }}>
{state.enCours ? `Round ${state.origine} en cours` : "Dernier round terminé"}
</span>
{state.enCours && state.fournisseurCourant && (
<span style={{ color: "var(--text-secondary)" }}>
— {state.etape === "qlik" ? "Qlik" : "SQL"} : {state.fournisseurCourant}
</span>
)}
</div>
<div className="mt-2 flex flex-wrap gap-x-5 gap-y-1 text-[12px]" style={{ color: "var(--text-secondary)" }}>
<span><Database className="w-3 h-3 inline mr-1" />SQL : {state.sqlFaits} ok{state.sqlEchecs > 0 && `, ${state.sqlEchecs} en échec`}</span>
<span><Network className="w-3 h-3 inline mr-1" />Qlik : {state.qlikFaits} ok{state.qlikEchecs > 0 && `, ${state.qlikEchecs} en échec`}</span>
{state.debutAt && <span>Début {fmtDate(state.debutAt)}</span>}
{state.tourTermine && <span className="text-emerald-500 font-semibold">Tour bouclé — tout le monde est à jour</span>}
</div>
{state.derniereErreur && (
<p className="mt-2 text-[11px] text-red-400">Dernière erreur : {state.derniereErreur}</p>
)}
</div>
)}
{/* ── Résumé ── */}
{resume && (
<div className="flex flex-wrap gap-2 mb-3 text-[12px]">
{[
{ l: "Fournisseurs", v: resume.total },
{ l: "Actifs SQL", v: resume.actifsSql },
{ l: "Actifs Qlik", v: resume.actifsQlik },
{ l: "Jamais synchronisés", v: resume.jamaisSql },
{ l: "Désactivés auto", v: resume.desactivesAuto },
{ l: "En échec", v: resume.enEchecSql + resume.enEchecQlik },
].map((s) => (
<span key={s.l} className="rounded-lg px-2.5 py-1"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}>
{s.l} <strong style={{ color: "var(--text-primary)" }}>{s.v}</strong>
</span>
))}
</div>
)}
{/* ── Filtres et actions de masse ── */}
<div className="flex flex-wrap items-center gap-2 mb-3">
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5" style={{ color: "var(--text-muted)" }} />
<input value={recherche} onChange={(e) => setRecherche(e.target.value)} placeholder="Code ou nom"
className="rounded-lg pl-8 pr-3 py-1.5 text-[12px] w-56"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-primary)" }} />
</div>
{(["tous", "actifs", "inactifs", "echecs"] as const).map((f) => (
<button key={f} onClick={() => setFiltre(f)}
className="rounded-full px-3 py-1 text-[12px] font-semibold"
style={{
background: filtre === f ? "var(--accent)" : "var(--bg-elevated)",
color: filtre === f ? "#fff" : "var(--text-secondary)",
border: "1px solid var(--border)",
}}>
{f === "echecs" ? "en échec" : f}
</button>
))}
<div className="flex-1" />
<span className="text-[11px]" style={{ color: "var(--text-muted)" }}>{visibles.length} affichés :</span>
<button onClick={() => basculer(codesVisibles, "actifSql", true)} className="rounded-lg px-2.5 py-1 text-[11px]"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}>
Activer SQL
</button>
<button onClick={() => basculer(codesVisibles, "actifSql", false)} className="rounded-lg px-2.5 py-1 text-[11px]"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}>
Désactiver SQL
</button>
<button onClick={() => basculer(codesVisibles, "actifQlik", false)} className="rounded-lg px-2.5 py-1 text-[11px]"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}>
Désactiver Qlik
</button>
<button onClick={() => action("seed")} title="Récupérer les nouveaux fournisseurs du référentiel"
className="rounded-lg px-2.5 py-1 text-[11px] flex items-center gap-1"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}>
<RefreshCw className="w-3 h-3" /> Actualiser la liste
</button>
</div>
{/* ── Tableau ── */}
<div className="rounded-2xl overflow-hidden" style={{ background: "var(--bg-surface)", border: "1px solid var(--border)" }}>
{chargement ? (
<div className="p-6 text-center text-[13px]" style={{ color: "var(--text-muted)" }}>
<Loader2 className="w-4 h-4 animate-spin inline mr-2" /> Chargement…
</div>
) : visibles.length === 0 ? (
<div className="p-6 text-center text-[13px]" style={{ color: "var(--text-muted)" }}>
Aucun fournisseur. Utilisez « Actualiser la liste » pour importer le référentiel.
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-[12px] border-collapse">
<thead>
<tr style={{ background: "var(--bg-elevated)" }}>
{["Fournisseur", "SQL", "Dernier SQL", "Qlik", "Dernier Qlik", ""].map((h) => (
<th key={h} className="px-3 py-2.5 text-left font-semibold whitespace-nowrap"
style={{ color: "var(--text-secondary)", borderBottom: "1px solid var(--border)" }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{visibles.map((r) => (
<tr key={r.codeFournisseur} style={{ borderBottom: "1px solid var(--border)" }}>
<td className="px-3 py-2">
<div className="font-medium" style={{ color: "var(--text-primary)" }}>
{r.nomFournisseur ?? r.codeFournisseur}
</div>
<div className="text-[10px]" style={{ color: "var(--text-muted)" }}>{r.codeFournisseur}</div>
</td>
<td className="px-3 py-2">
<input type="checkbox" checked={r.actifSql} className="w-4 h-4"
onChange={(e) => basculer([r.codeFournisseur], "actifSql", e.target.checked)} />
</td>
<td className="px-3 py-2 whitespace-nowrap" style={{ color: "var(--text-secondary)" }}>
<Pastille statut={r.dernierSqlStatut} />{" "}
{fmtDate(r.dernierSqlAt)}
{r.dernierSqlLignes != null && <span style={{ color: "var(--text-muted)" }}> · {r.dernierSqlLignes.toLocaleString("fr-FR")} lignes {fmtDuree(r.dernierSqlMs)}</span>}
</td>
<td className="px-3 py-2">
<input type="checkbox" checked={r.actifQlik} className="w-4 h-4"
onChange={(e) => basculer([r.codeFournisseur], "actifQlik", e.target.checked)} />
</td>
<td className="px-3 py-2 whitespace-nowrap" style={{ color: "var(--text-secondary)" }}>
<Pastille statut={r.dernierQlikStatut} />{" "}
{fmtDate(r.dernierQlikAt)}
{r.dernierQlikCodes != null && <span style={{ color: "var(--text-muted)" }}> · {r.dernierQlikCodes.toLocaleString("fr-FR")} codes</span>}
</td>
<td className="px-3 py-2 text-[11px]" style={{ color: "var(--text-muted)" }}>
{r.desactiveMotif}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,15 @@
import { SynchronisationClient } from "./client";
export const metadata = {
title: "Synchronisation nocturne — CollectFlow",
};
/**
* Paramétrage de la synchronisation nocturne des fournisseurs.
*
* L'accès admin est déjà imposé par le middleware (préfixe /admin) ; les routes
* /api/admin/sync revérifient le rôle côté serveur.
*/
export default function SynchronisationPage() {
return <SynchronisationClient />;
}
+2 -1
View File
@@ -19,7 +19,8 @@ export default async function GridPage({ searchParams }: GridPageProps) {
const filters = {
code1: params.code1 || null,
code2: params.code2 || null,
code3: params.code3 || null,
// Liste séparée par des virgules : le filtre accepte plusieurs nomenclatures.
code3: params.code3 ? String(params.code3).split(",").filter(Boolean) : null,
};
// 1. Fetch available suppliers & stores
+73 -60
View File
@@ -28,6 +28,43 @@ interface Props {
pivotted: HitParadePivotRow[];
}
function ColHeader({
label,
sortable,
group,
sortKey,
sortDir,
onSort,
}: {
label: string;
sortable?: SortKey;
group?: "292" | "579" | "total";
sortKey: SortKey;
sortDir: SortDir;
onSort: (key: SortKey) => void;
}) {
const isActive = sortable && sortKey === sortable;
const groupBg = group === "292" ? "bg-blue-50" : group === "579" ? "bg-violet-50" : group === "total" ? "bg-emerald-50" : "";
const activeBg = group === "292" ? "bg-blue-100 text-blue-700" : group === "579" ? "bg-violet-100 text-violet-700" : group === "total" ? "bg-emerald-100 text-emerald-700" : "bg-blue-50 text-blue-700";
return (
<th
onClick={sortable ? () => onSort(sortable) : undefined}
className={[
"px-2 py-2 text-center text-xs font-semibold whitespace-nowrap select-none",
sortable ? "cursor-pointer" : "",
isActive ? activeBg : `${groupBg} text-gray-600`,
].join(" ")}
>
{label}
{sortable && (
<span className="ml-1 inline-block w-3 text-center">
{isActive ? (sortDir === "desc" ? "↓" : "↑") : <span className="text-gray-300">↕</span>}
</span>
)}
</th>
);
}
export function HitParadeClient({ dateDebut, dateFin, pivotted }: Props) {
const router = useRouter();
const [sortKey, setSortKey] = useState<SortKey>(DEFAULT_SORT);
@@ -73,6 +110,32 @@ export function HitParadeClient({ dateDebut, dateFin, pivotted }: Props) {
const isSorted = sortKey !== DEFAULT_SORT || sortDir !== DEFAULT_DIR;
const filtered = useMemo(() =>
pivotted.filter(r =>
(filterFournisseur === "Tous" || r.fournisseur === filterFournisseur) &&
(filterNomenclature === "Tous" || r.nomenclature_code === filterNomenclature)
),
[pivotted, filterFournisseur, filterNomenclature]
);
const sorted = useMemo(() =>
[...filtered].sort((a, b) => {
const diff = a[sortKey] - b[sortKey];
return sortDir === "desc" ? -diff : diff;
}),
[filtered, sortKey, sortDir]
);
const totals = useMemo(() => sorted.reduce(
(acc, r) => ({
qte292: acc.qte292 + r.qte292, ca292: acc.ca292 + r.ca292, marge292: acc.marge292 + r.marge292,
qte579: acc.qte579 + r.qte579, ca579: acc.ca579 + r.ca579, marge579: acc.marge579 + r.marge579,
qteTotal: acc.qteTotal + r.qteTotal, caTotal: acc.caTotal + r.caTotal, margeTotal: acc.margeTotal + r.margeTotal,
stock292: acc.stock292 + r.stock292, stock579: acc.stock579 + r.stock579, stockTotal: acc.stockTotal + r.stockTotal,
}),
{ qte292: 0, ca292: 0, marge292: 0, qte579: 0, ca579: 0, marge579: 0, qteTotal: 0, caTotal: 0, margeTotal: 0, stock292: 0, stock579: 0, stockTotal: 0 }
), [sorted]);
function exportToExcel() {
const headers = [
"Code", "Désignation", "Fournisseur", "Nomenclature",
@@ -115,56 +178,6 @@ export function HitParadeClient({ dateDebut, dateFin, pivotted }: Props) {
XLSX.writeFile(wb, `hit-parade_${dateDebut}_${dateFin}.xlsx`);
}
const filtered = useMemo(() =>
pivotted.filter(r =>
(filterFournisseur === "Tous" || r.fournisseur === filterFournisseur) &&
(filterNomenclature === "Tous" || r.nomenclature_code === filterNomenclature)
),
[pivotted, filterFournisseur, filterNomenclature]
);
const sorted = useMemo(() =>
[...filtered].sort((a, b) => {
const diff = a[sortKey] - b[sortKey];
return sortDir === "desc" ? -diff : diff;
}),
[filtered, sortKey, sortDir]
);
const totals = useMemo(() => sorted.reduce(
(acc, r) => ({
qte292: acc.qte292 + r.qte292, ca292: acc.ca292 + r.ca292, marge292: acc.marge292 + r.marge292,
qte579: acc.qte579 + r.qte579, ca579: acc.ca579 + r.ca579, marge579: acc.marge579 + r.marge579,
qteTotal: acc.qteTotal + r.qteTotal, caTotal: acc.caTotal + r.caTotal, margeTotal: acc.margeTotal + r.margeTotal,
stock292: acc.stock292 + r.stock292, stock579: acc.stock579 + r.stock579, stockTotal: acc.stockTotal + r.stockTotal,
}),
{ qte292: 0, ca292: 0, marge292: 0, qte579: 0, ca579: 0, marge579: 0, qteTotal: 0, caTotal: 0, margeTotal: 0, stock292: 0, stock579: 0, stockTotal: 0 }
), [sorted]);
function ColHeader({ label, sortable, k, group }: { label: string; sortable?: SortKey; k: string; group?: "292" | "579" | "total" }) {
const isActive = sortable && sortKey === sortable;
const groupBg = group === "292" ? "bg-blue-50" : group === "579" ? "bg-violet-50" : group === "total" ? "bg-emerald-50" : "";
const activeBg = group === "292" ? "bg-blue-100 text-blue-700" : group === "579" ? "bg-violet-100 text-violet-700" : group === "total" ? "bg-emerald-100 text-emerald-700" : "bg-blue-50 text-blue-700";
return (
<th
key={k}
onClick={sortable ? () => handleSort(sortable) : undefined}
className={[
"px-2 py-2 text-center text-xs font-semibold whitespace-nowrap select-none",
sortable ? "cursor-pointer" : "",
isActive ? activeBg : `${groupBg} text-gray-600`,
].join(" ")}
>
{label}
{sortable && (
<span className="ml-1 inline-block w-3 text-center">
{isActive ? (sortDir === "desc" ? "↓" : "↑") : <span className="text-gray-300">↕</span>}
</span>
)}
</th>
);
}
return (
<div className="space-y-4">
{/* Barre de contrôles */}
@@ -277,22 +290,22 @@ export function HitParadeClient({ dateDebut, dateFin, pivotted }: Props) {
</th>
</tr>
<tr className="border-b-2 border-gray-200">
<ColHeader label="Qté" sortable="qte292" k="qte292" group="292" />
<ColHeader label="CA TTC" sortable="ca292" k="ca292" group="292" />
<ColHeader label="Qté" sortable="qte292" group="292" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} />
<ColHeader label="CA TTC" sortable="ca292" group="292" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} />
<th className="bg-blue-50 px-2 py-2 text-center text-xs font-semibold text-blue-500">% Marge</th>
<ColHeader label="Stock" sortable="stock292" k="stock292" group="292" />
<ColHeader label="Qté" sortable="qte579" k="qte579" group="579" />
<ColHeader label="CA TTC" sortable="ca579" k="ca579" group="579" />
<ColHeader label="Stock" sortable="stock292" group="292" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} />
<ColHeader label="Qté" sortable="qte579" group="579" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} />
<ColHeader label="CA TTC" sortable="ca579" group="579" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} />
<th className="bg-violet-50 px-2 py-2 text-center text-xs font-semibold text-violet-500">% Marge</th>
<ColHeader label="Stock" sortable="stock579" k="stock579" group="579" />
<ColHeader label="Qté" sortable="qteTotal" k="qteTotal" group="total" />
<ColHeader label="CA TTC" sortable="caTotal" k="caTotal" group="total" />
<ColHeader label="Stock" sortable="stock579" group="579" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} />
<ColHeader label="Qté" sortable="qteTotal" group="total" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} />
<ColHeader label="CA TTC" sortable="caTotal" group="total" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} />
<th className="bg-emerald-50 px-2 py-2 text-center text-xs font-semibold text-emerald-600">% Marge</th>
<ColHeader label="Stock" sortable="stockTotal" k="stockTotal" group="total" />
<ColHeader label="Stock" sortable="stockTotal" group="total" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} />
</tr>
</thead>
<tbody>
{sorted.map((row, idx) => (
{sorted.map((row) => (
<tr key={row.codein} className={`border-b border-gray-100 hover:brightness-95 transition-colors`}>
<td className="bg-white px-2 py-2.5 font-mono text-xs text-gray-400 text-center">{row.codein}</td>
<td className="bg-white px-2 py-2.5 text-gray-900 font-medium min-w-[200px]">{row.libelle.trim()}</td>
+11 -8
View File
@@ -42,14 +42,17 @@ function pivotHitParade(rows: HitParadeRow[]): HitParadePivotRow[] {
});
}
const entry = map.get(row.codein)!;
if (row.site === "292") {
entry.qte292 = row.qte_vendue;
entry.ca292 = row.ca_ttc;
entry.marge292 = row.marge;
} else if (row.site === "579") {
entry.qte579 = row.qte_vendue;
entry.ca579 = row.ca_ttc;
entry.marge579 = row.marge;
// Accumulation (+=) et non affectation : si la requête renvoyait plusieurs
// lignes pour un même (codein, site), aucune valeur ne serait écrasée.
const site = String(row.site).trim();
if (site === "292") {
entry.qte292 += row.qte_vendue;
entry.ca292 += row.ca_ttc;
entry.marge292 += row.marge;
} else if (site === "579") {
entry.qte579 += row.qte_vendue;
entry.ca579 += row.ca_ttc;
entry.marge579 += row.marge;
}
}
+661
View File
@@ -0,0 +1,661 @@
"use client";
import { useMemo, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import {
ArrowLeft, Package, Warehouse, ShoppingCart, Globe, Layers, Truck,
RefreshCw, Loader2, CheckCircle, AlertCircle, WifiOff, LayoutGrid, Info,
} from "lucide-react";
import { HeatmapCell } from "@/features/grid/components/heatmap-cell";
import { NetworkLineChart, DualLineChart, TrendSparkline } from "@/features/grid/components/network-charts";
import { computeNetworkTrend, computeStoresSeries, trendLabel, TREND_COLOR, NB_MAGASINS_RESEAU } from "@/features/grid/lib/network-trend";
import { formatMonthLabel, formatDate, SITE_LABELS, ffMonthToQlik } from "@/features/grid/lib/months";
import { useQlikSyncJob } from "@/features/qlik-sync/use-qlik-sync-job";
import { computeComparatif, indiceVerdict, normalizeMargePct } from "@/features/produits/lib/compare-reseau";
import type { ProduitFiche } from "@/features/produits/types";
import { OpportunitesFamille } from "./opportunites";
// ─── Formatteurs partagés ────────────────────────────────────────────────────
const fmtEur = (v: number) =>
new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 }).format(v);
const fmtEur2 = (v: number) =>
new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 2 }).format(v);
const fmtQte = (v: number) => new Intl.NumberFormat("fr-FR", { maximumFractionDigits: 0 }).format(v);
const fmtDec = (v: number, d = 1) => new Intl.NumberFormat("fr-FR", { minimumFractionDigits: d, maximumFractionDigits: d }).format(v);
// ─── Briques de présentation ─────────────────────────────────────────────────
function Card({ title, icon: Icon, children, action }: {
title: string;
icon: React.ComponentType<{ className?: string; style?: React.CSSProperties }>;
children: React.ReactNode;
action?: React.ReactNode;
}) {
return (
<section className="apple-card">
<div className="apple-card-header flex items-center justify-between gap-3">
<h2 className="flex items-center gap-2 text-[13px] font-semibold" style={{ color: "var(--text-primary)" }}>
<Icon className="w-4 h-4" style={{ color: "var(--accent)" }} />
{title}
</h2>
{action}
</div>
<div className="apple-card-content">{children}</div>
</section>
);
}
function Field({ label, value, mono }: { label: string; value: React.ReactNode; mono?: boolean }) {
return (
<div className="min-w-0">
<div className="text-[10px] font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>
{label}
</div>
<div className={`text-[13px] truncate ${mono ? "font-mono" : ""}`} style={{ color: "var(--text-primary)" }}>
{value ?? "—"}
</div>
</div>
);
}
function Stat({ label, value, hint, color }: { label: string; value: string; hint?: string; color?: string }) {
return (
<div className="rounded-xl p-3" style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}>
<div className="text-[10px] font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>
{label}
</div>
<div className="text-[19px] font-black tabular-nums leading-tight mt-0.5" style={{ color: color ?? "var(--text-primary)" }}>
{value}
</div>
{hint && <div className="text-[10px] mt-0.5" style={{ color: "var(--text-muted)" }}>{hint}</div>}
</div>
);
}
// ─── Détail mensuel réseau ───────────────────────────────────────────────────
/** Les mesures Qlik d'un mois, telles que stockées dans `metricsByMonth`. */
type MoisReseau = { qte: number; ca?: number; nbMag?: number; caMag?: number; margePct?: number };
/**
* Lignes du tableau « Détail mensuel réseau ». Deux d'entre elles sont dérivées
* (qté/magasin, prix moyen) : le cube Qlik ne les renvoie pas, mais ce sont les
* chiffres qu'on lit réellement pour arbitrer.
*/
const LIGNES_MENSUELLES_RESEAU: Array<{
label: string;
get: (v: MoisReseau) => number | undefined;
fmt: (n: number) => string;
}> = [
{ label: "Quantité", get: (v) => v.qte, fmt: (n) => fmtQte(n) },
{ label: "Magasins", get: (v) => v.nbMag, fmt: (n) => fmtQte(n) },
{ label: "Qté / mag.", get: (v) => (v.nbMag && v.nbMag > 0 ? v.qte / v.nbMag : undefined), fmt: (n) => fmtDec(n) },
{ label: "CA", get: (v) => v.ca, fmt: (n) => Math.round(n).toLocaleString("fr-FR") },
{ label: "Prix moyen", get: (v) => (v.ca != null && v.qte > 0 ? v.ca / v.qte : undefined), fmt: (n) => fmtEur2(n) },
{ label: "Marge %", get: (v) => normalizeMargePct(v.margePct) ?? undefined, fmt: (n) => `${fmtDec(n)} %` },
];
// ─── Bouton d'extraction Qlik pour un seul code centrale ─────────────────────
function SyncProduitButton({ codeCentrale }: { codeCentrale: string }) {
const router = useRouter();
const { status, message, start } = useQlikSyncJob({
target: { mode: "produit", codeCentrale },
onSuccess: () => router.refresh(),
});
return (
<div className="flex items-center gap-2">
{message && (
<span
className="text-[10px] leading-tight text-right max-w-[280px]"
style={{ color: status === "error" ? "var(--accent-error)" : "var(--text-muted)" }}
title={message}
>
{message}
</span>
)}
<button
onClick={start}
disabled={status === "running"}
className="btn-action btn-action-secondary flex items-center gap-1.5 disabled:opacity-60"
title="Ré-extraire les données réseau Qlik pour ce seul produit"
>
{status === "running" ? <Loader2 className="w-3.5 h-3.5 animate-spin" />
: status === "success" ? <CheckCircle className="w-3.5 h-3.5 text-emerald-500" />
: status === "error" ? <AlertCircle className="w-3.5 h-3.5 text-rose-500" />
: <RefreshCw className="w-3.5 h-3.5" />}
{status === "running" ? "Extraction…" : "Actualiser depuis Qlik"}
</button>
</div>
);
}
// ─── Fiche ───────────────────────────────────────────────────────────────────
export function ProduitFicheView({ fiche, backQuery }: { fiche: ProduitFiche; backQuery: string }) {
const { detail, codeCentrale, libelleReseau, fournisseurReseau, fournisseurs, stock, commandesEnCours, gammes, months, reseau } = fiche;
const [magasin, setMagasin] = useState<string>("TOTAL");
const sitesDisponibles = useMemo(() => {
const sites = new Set<string>([...Object.keys(fiche.mensuelParSite), ...stock.map((s) => s.site)]);
return [...sites].filter(Boolean).sort();
}, [fiche.mensuelParSite, stock]);
// Série affichée selon le magasin sélectionné.
const serie = magasin === "TOTAL" ? fiche.mensuelTotal : (fiche.mensuelParSite[magasin] ?? {});
const totauxAffiches = magasin === "TOTAL" ? fiche.totaux : (fiche.totauxParSite[magasin] ?? { qte: 0, ca: 0, marge: 0, tauxMarge: 0 });
/** Magasins vendeurs par mois — dénominateur de la tendance ET 3e bande du graphique. */
const nbMagByMonth = useMemo(() => {
const detailMensuel = reseau?.metricsByMonth;
if (!detailMensuel) return null;
const parMois: Record<string, number> = {};
for (const [mois, mesures] of Object.entries(detailMensuel)) {
// Mois présent sans `nbMag` = mois extrait sans vente = zéro magasin
// (cf. get-product-rows.ts) : sinon la série est amputée et la courbe
// disparaît sur les anciens caches.
const nb = Number(mesures?.nbMag);
parMois[mois] = Number.isFinite(nb) ? nb : 0;
}
return parMois;
}, [reseau]);
const trend = useMemo(
() => computeNetworkTrend(reseau?.qteByMonth ?? null, nbMagByMonth),
[reseau, nbMagByMonth],
);
const magasinsParMois = useMemo(() => computeStoresSeries(nbMagByMonth), [nbMagByMonth]);
const comparatif = useMemo(() => computeComparatif(fiche), [fiche]);
const verdict = indiceVerdict(comparatif.indiceQte);
const fournisseurPrincipal = fournisseurs.find((f) => f.principal) ?? fournisseurs[0];
/** Nom de fournisseur à afficher : catalogue Nancy d'abord, sinon champ Qlik. */
const fournisseurAffiche = fournisseurPrincipal?.nomfou || fournisseurReseau || null;
const titre = detail?.libelle1 || libelleReseau || codeCentrale || "Sans libellé";
// Indicateurs réseau dérivés (le cache ne stocke que les mesures brutes Qlik).
const qteParMagasinReseau = reseau && reseau.nbMagasinsReseau > 0 ? reseau.qteReseau / reseau.nbMagasinsReseau : null;
const prixMoyenReseau = reseau && reseau.qteReseau > 0 ? reseau.caReseau / reseau.qteReseau : null;
const margeReseau = normalizeMargePct(reseau?.margePctReseau);
return (
<div className="space-y-5">
{/* En-tête */}
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<Link
href={backQuery ? `/produits?q=${encodeURIComponent(backQuery)}` : "/produits"}
className="inline-flex items-center gap-1.5 text-[12px] mb-1.5 hover:underline"
style={{ color: "var(--text-muted)" }}
>
<ArrowLeft className="w-3.5 h-3.5" />
Retour aux résultats
</Link>
<h2 className="text-[20px] font-semibold tracking-[-0.3px]" style={{ color: "var(--text-primary)" }}>
{titre}
</h2>
<p className="text-[12px] mt-0.5" style={{ color: "var(--text-muted)" }}>
{detail && <><span className="font-mono">{detail.codein}</span>{" · "}</>}
{codeCentrale && <>centrale <span className="font-mono">{codeCentrale}</span></>}
{fournisseurAffiche && <> · <span style={{ color: "var(--text-secondary)" }}>{fournisseurAffiche}</span></>}
</p>
</div>
{fournisseurPrincipal?.codefou && (
<Link
href={`/grid?fournisseur=${encodeURIComponent(fournisseurPrincipal.codefou)}&magasin=TOTAL`}
className="btn-action btn-action-secondary flex items-center gap-1.5"
title="Ouvrir la grille d'arbitrage de ce fournisseur"
>
<LayoutGrid className="w-3.5 h-3.5" />
Voir dans la Grille
</Link>
)}
</div>
{!detail && (
<div
className="rounded-xl p-4 flex items-start gap-3 text-[13px]"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}
>
<Info className="w-4 h-4 mt-0.5 shrink-0" style={{ color: "var(--accent)" }} />
<div>
Ce produit est travaillé par le réseau mais <strong>n&apos;est pas référencé dans notre catalogue</strong>.
<div className="text-[12px] mt-0.5" style={{ color: "var(--text-muted)" }}>
Seules les données réseau Qlik sont disponibles : ni stock, ni ventes, ni gamme chez nous.
</div>
</div>
</div>
)}
{/* ─── Réseau Qlik — la donnée qui motive la recherche ─────────── */}
<Card
title="Performance réseau · 12 mois glissants"
icon={Globe}
action={codeCentrale ? <SyncProduitButton codeCentrale={codeCentrale} /> : undefined}
>
{!codeCentrale ? (
<p className="text-[13px]" style={{ color: "var(--text-muted)" }}>
Ce produit n&apos;a pas de code centrale : il n&apos;est pas référencé centralement et n&apos;existe donc pas dans Qlik.
</p>
) : !reseau ? (
<div className="flex items-start gap-3 rounded-xl p-4" style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}>
<WifiOff className="w-4 h-4 mt-0.5 shrink-0" style={{ color: "var(--text-muted)" }} />
<div className="text-[13px]" style={{ color: "var(--text-secondary)" }}>
Aucune donnée réseau en cache pour ce produit.
<div className="text-[12px] mt-0.5" style={{ color: "var(--text-muted)" }}>
Lancez l&apos;extraction ci-dessus : elle ne porte que sur ce code centrale et prend quelques secondes.
</div>
</div>
</div>
) : (
<>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Stat
label="Magasins vendeurs"
value={fmtQte(reseau.nbMagasinsReseau)}
hint={`sur ${NB_MAGASINS_RESEAU} · ${fmtDec((reseau.nbMagasinsReseau / NB_MAGASINS_RESEAU) * 100, 0)} % du réseau`}
/>
<Stat label="Quantité réseau" value={fmtQte(reseau.qteReseau)} hint="12 mois glissants" />
<Stat
label="Qté / magasin"
value={qteParMagasinReseau != null ? fmtDec(qteParMagasinReseau) : "—"}
hint="par magasin qui le travaille"
/>
<Stat
label="Prix moyen réseau"
value={prixMoyenReseau != null ? fmtEur2(prixMoyenReseau) : "—"}
hint="CA réseau / quantité"
/>
<Stat label="CA réseau" value={fmtEur(reseau.caReseau)} />
<Stat label="CA / magasin" value={fmtEur(reseau.caParMagasinReseau)} />
<Stat
label="Marge % réseau"
value={margeReseau != null ? `${fmtDec(margeReseau)} %` : "—"}
color={margeReseau != null && margeReseau >= 40 ? "#22c55e" : margeReseau != null && margeReseau >= 25 ? "#f59e0b" : undefined}
/>
<Stat
label="Tendance 12 m"
value={trend.pct != null ? `${trend.pct >= 0 ? "+" : ""}${Math.round(trend.pct * 100)} %` : "—"}
hint={trendLabel(trend.pct, trend.nouveau)}
color={TREND_COLOR[trend.direction]}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 mt-3">
<Field label="Libellé réseau" value={libelleReseau || "—"} />
<Field label="Fournisseur réseau" value={fournisseurReseau || "—"} />
</div>
<p className="text-[11px] mt-3" style={{ color: "var(--text-muted)" }}>
Période {reseau.periode ?? "12 mois"} · extrait le {formatDate(reseau.fetchedAt)} ·
{" "}mois en cours exclu (incomplet).
</p>
{trend.hasData && (
<div className="mt-3">
<div className="text-[10px] font-semibold uppercase tracking-wide mb-1" style={{ color: "var(--text-muted)" }}>
Tendance réseau — la qté/magasin d&apos;abord, les volumes en contexte
</div>
<NetworkLineChart
labels={trend.labels}
values={trend.values}
color={TREND_COLOR[trend.direction]}
stores={magasinsParMois?.values}
perStore={trend.perStore}
/>
</div>
)}
{/* Détail mensuel complet (CA, marge, magasins) si disponible */}
{reseau.metricsByMonth && Object.keys(reseau.metricsByMonth).length > 0 && (
<div className="mt-4 overflow-x-auto">
<div className="text-[10px] font-semibold uppercase tracking-wide mb-1.5" style={{ color: "var(--text-muted)" }}>
Détail mensuel réseau
</div>
<table className="w-full border-collapse text-[11px]">
<thead>
<tr>
<th className="px-2 py-1.5 text-left font-semibold" style={{ color: "var(--text-muted)" }}>Mesure</th>
{months.map((m) => (
<th key={m} className="px-1 py-1.5 text-center font-semibold whitespace-nowrap" style={{ color: "var(--text-muted)" }}>
{formatMonthLabel(m)}
</th>
))}
</tr>
</thead>
<tbody>
{LIGNES_MENSUELLES_RESEAU.map((row) => (
<tr key={row.label}>
<td className="px-2 py-1 font-medium whitespace-nowrap" style={{ color: "var(--text-secondary)" }}>
{row.label}
</td>
{months.map((m) => {
const cell = reseau.metricsByMonth?.[ffMonthToQlik(m)];
const v = cell ? row.get(cell) : undefined;
return (
<td key={m} className="px-1 py-1 text-center tabular-nums" style={{ color: "var(--text-muted)" }}>
{v != null ? row.fmt(v) : "—"}
</td>
);
})}
</tr>
))}
</tbody>
</table>
<p className="text-[10px] mt-1.5" style={{ color: "var(--text-muted)" }}>
Les mois sans CA ni marge proviennent de la mesure « Quantité COMP », qui ne porte que la quantité.
</p>
</div>
)}
</>
)}
</Card>
{/* ─── Fournisseur ─────────────────────────────────────────────── */}
<Card title="Fournisseur" icon={Truck}>
{fournisseurs.length === 0 ? (
<p className="text-[13px]" style={{ color: "var(--text-muted)" }}>
{fournisseurReseau
? <>Aucun fournisseur référencé chez nous. Côté réseau, Qlik indique <strong>{fournisseurReseau}</strong>.</>
: "Aucun fournisseur référencé pour ce produit."}
</p>
) : (
<div className="space-y-2">
{fournisseurs.map((f) => (
<div
key={f.codefou}
className="rounded-xl p-3 flex flex-wrap items-center justify-between gap-3"
style={{
background: f.principal ? "var(--accent-bg)" : "var(--bg-elevated)",
border: `1px solid ${f.principal ? "var(--accent-border)" : "var(--border)"}`,
}}
>
<div className="min-w-0">
<div className="text-[14px] font-semibold" style={{ color: f.principal ? "var(--accent)" : "var(--text-primary)" }}>
{f.nomfou}
{f.principal && <span className="ml-2 text-[11px] font-medium">· principal</span>}
</div>
<div className="text-[11px] font-mono" style={{ color: "var(--text-muted)" }}>{f.codefou}</div>
</div>
<div className="flex items-center gap-5 text-[12px]" style={{ color: "var(--text-secondary)" }}>
<span>Réf. <span className="font-mono">{f.reference || "—"}</span></span>
<span>PCB <span className="tabular-nums">{f.pcb != null ? fmtQte(f.pcb) : "—"}</span></span>
<Link
href={`/grid?fournisseur=${encodeURIComponent(f.codefou)}&magasin=TOTAL`}
className="hover:underline"
style={{ color: "var(--accent)" }}
>
Grille
</Link>
</div>
</div>
))}
{fournisseurReseau && (
<p className="text-[11px]" style={{ color: "var(--text-muted)" }}>
Fournisseur côté Qlik : {fournisseurReseau}
</p>
)}
</div>
)}
</Card>
{/* ─── Identité catalogue (produits référencés uniquement) ──────── */}
{detail && (
<Card title="Identité" icon={Package}>
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
<Field label="Code article" value={detail.codein} mono />
<Field label="Code centrale" value={detail.code_centrale || "—"} mono />
<Field label="Fournisseur" value={fournisseurAffiche ?? "—"} />
<Field label="GTIN" value={detail.gtin || "—"} mono />
<Field label="Référence fou." value={detail.reference || "—"} mono />
<Field label="PCB" value={detail.pcb != null ? fmtQte(detail.pcb) : "—"} />
<Field label="Gamme actuelle" value={gammes[0]?.gamme_code ?? "—"} />
<Field label="Secteur" value={detail.libelle1nom ?? "—"} />
<Field label="Famille" value={detail.libelle2 ?? "—"} />
<Field label="Sous-famille" value={detail.libelle3 ?? "—"} />
<Field label="Prix d'achat" value={detail.pa != null ? fmtEur2(detail.pa) : "—"} />
<Field label="PV central" value={detail.pv_central != null ? fmtEur2(detail.pv_central) : "—"} />
<Field label="En commande" value={commandesEnCours > 0 ? fmtQte(commandesEnCours) : "—"} />
</div>
{gammes.length > 1 && (
<div className="mt-3 pt-3" style={{ borderTop: "1px solid var(--border)" }}>
<div className="text-[10px] font-semibold uppercase tracking-wide mb-1.5" style={{ color: "var(--text-muted)" }}>
Historique de gamme
</div>
<div className="flex flex-wrap gap-1.5">
{gammes.slice(0, 10).map((g, i) => (
<span
key={`${g.saison_no_id}-${i}`}
className="rounded-lg px-2 py-1 text-[11px]"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}
>
{g.saison_libelle || g.saison_code || `Saison ${g.saison_no_id}`}
<span className="ml-1.5 font-bold" style={{ color: "var(--text-primary)" }}>{g.gamme_code}</span>
</span>
))}
</div>
</div>
)}
</Card>
)}
{/* ─── Stock ───────────────────────────────────────────────────── */}
{detail && (
<Card title="Stock temps réel" icon={Warehouse}>
{stock.length === 0 ? (
<p className="text-[13px]" style={{ color: "var(--text-muted)" }}>Aucune ligne de stock pour ce produit.</p>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{stock.map((s) => (
<div key={s.site} className="rounded-xl p-3" style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}>
<div className="flex items-center justify-between mb-2">
<span className="text-[12px] font-semibold" style={{ color: "var(--text-primary)" }}>
{SITE_LABELS[s.site]?.nom ?? s.site}
</span>
<span
className="text-[16px] font-black tabular-nums"
style={{ color: s.qte < 0 ? "var(--accent-error)" : "var(--text-primary)" }}
>
{fmtQte(s.qte)}
</span>
</div>
<div className="grid grid-cols-2 gap-2 text-[11px]" style={{ color: "var(--text-secondary)" }}>
<div>Dispo : <span className="tabular-nums">{fmtQte(s.stockdispo)}</span></div>
<div>Valeur : <span className="tabular-nums">{fmtEur(s.valstock)}</span></div>
<div>PRMP : <span className="tabular-nums">{s.prmp > 0 ? fmtEur2(s.prmp) : "—"}</span></div>
<div>Dern. vente : {formatDate(s.dernierevente)}</div>
<div className="col-span-2">Dern. réception : {formatDate(s.dernierereception)}</div>
</div>
</div>
))}
</div>
)}
</Card>
)}
{/* ─── Nos ventes, 12 mois glissants ───────────────────────────── */}
{detail && (
<Card
title="Nos ventes · 12 mois glissants"
icon={ShoppingCart}
action={
<div className="segment-control">
{["TOTAL", ...sitesDisponibles].map((code) => (
<button
key={code}
onClick={() => setMagasin(code)}
className={`segment-btn ${magasin === code ? "active" : ""}`}
>
{code === "TOTAL" ? "Total" : (SITE_LABELS[code]?.label ?? code)}
</button>
))}
</div>
}
>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-4">
<Stat label="Quantité vendue" value={fmtQte(totauxAffiches.qte)} />
<Stat label="CA (TTC)" value={fmtEur(totauxAffiches.ca)} />
<Stat label="Marge" value={fmtEur(totauxAffiches.marge)} />
<Stat
label="Taux de marge"
value={`${fmtDec(totauxAffiches.tauxMarge)} %`}
color={totauxAffiches.tauxMarge >= 40 ? "#22c55e" : totauxAffiches.tauxMarge >= 25 ? "#f59e0b" : undefined}
/>
</div>
<div className="overflow-x-auto">
<table className="w-full border-collapse text-[12px]">
<thead>
<tr>
<th className="px-2 py-1.5 text-left text-[10px] font-semibold uppercase" style={{ color: "var(--text-muted)" }}>
Mois
</th>
{months.map((m) => (
<th key={m} className="px-1 py-1.5 text-center text-[10px] font-semibold whitespace-nowrap" style={{ color: "var(--text-muted)" }}>
{formatMonthLabel(m)}
</th>
))}
</tr>
</thead>
<tbody>
<tr>
<td className="px-2 py-1 text-[11px] font-medium whitespace-nowrap" style={{ color: "var(--text-secondary)" }}>
Ventes
</td>
{months.map((m) => (
<td key={m} className="px-0.5 py-1 min-w-[46px]">
<HeatmapCell
value={serie[m]?.qte ?? 0}
tooltipStock={serie[m]?.stockFinMois ?? null}
tooltipReceptions={serie[m]?.qteRecue ?? null}
/>
</td>
))}
</tr>
<tr>
<td className="px-2 py-1 text-[11px] font-medium whitespace-nowrap" style={{ color: "var(--text-secondary)" }}>
CA (TTC)
</td>
{months.map((m) => (
<td key={m} className="px-0.5 py-1 text-center tabular-nums text-[11px]" style={{ color: "var(--text-muted)" }}>
{serie[m]?.ca ? Math.round(serie[m].ca).toLocaleString("fr-FR") : "—"}
</td>
))}
</tr>
<tr>
<td className="px-2 py-1 text-[11px] font-medium whitespace-nowrap" style={{ color: "var(--text-secondary)" }}>
Stock fin de mois
</td>
{months.map((m) => (
<td key={m} className="px-0.5 py-1 text-center tabular-nums text-[11px]" style={{ color: "var(--text-muted)" }}>
{serie[m]?.stockFinMois ? Math.round(serie[m].stockFinMois).toLocaleString("fr-FR") : "—"}
</td>
))}
</tr>
</tbody>
</table>
</div>
<NetworkLineChart
labels={months.map(ffMonthToQlik)}
values={months.map((m) => serie[m]?.qte ?? 0)}
color="#0ea5e9"
/>
</Card>
)}
{/* ─── Comparatif local vs réseau ──────────────────────────────── */}
{detail && comparatif.hasReseau && (
<Card title="Nous vs. un magasin moyen du réseau" icon={Globe}>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Stat
label="Indice de performance"
value={comparatif.indiceQte != null ? `${Math.round(comparatif.indiceQte)} %` : "—"}
hint={verdict.label}
color={verdict.color}
/>
<Stat
label="Nos ventes / magasin"
value={fmtQte(comparatif.qteLocaleParMagasin)}
hint={`${comparatif.nbMagasinsLocaux} magasin${comparatif.nbMagasinsLocaux > 1 ? "s" : ""} · 12 mois`}
/>
<Stat
label="Réseau / magasin"
value={fmtQte(comparatif.qteReseauParMagasin)}
hint={`${comparatif.nbMagasinsReseau} magasins · 12 mois`}
/>
<Stat
label="Présence réseau"
value={comparatif.tauxPresence != null ? `${fmtDec(comparatif.tauxPresence * 100, 0)} %` : "—"}
hint={`${comparatif.nbMagasinsReseau} / ${NB_MAGASINS_RESEAU} magasins`}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 mt-3">
<div className="rounded-xl p-3 text-[12px]" style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}>
<div className="text-[10px] font-semibold uppercase tracking-wide mb-1" style={{ color: "var(--text-muted)" }}>
CA par magasin · indicatif
</div>
<div className="flex items-center justify-between" style={{ color: "var(--text-secondary)" }}>
<span>Nous : <span className="font-semibold tabular-nums" style={{ color: "var(--text-primary)" }}>{fmtEur(comparatif.caLocalParMagasin)}</span></span>
<span>Réseau : <span className="font-semibold tabular-nums" style={{ color: "var(--text-primary)" }}>{comparatif.caReseauParMagasin != null ? fmtEur(comparatif.caReseauParMagasin) : "—"}</span></span>
</div>
</div>
<div className="rounded-xl p-3 text-[12px]" style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}>
<div className="text-[10px] font-semibold uppercase tracking-wide mb-1" style={{ color: "var(--text-muted)" }}>
Taux de marge · indicatif
</div>
<div className="flex items-center justify-between" style={{ color: "var(--text-secondary)" }}>
<span>Nous : <span className="font-semibold tabular-nums" style={{ color: "var(--text-primary)" }}>{fmtDec(comparatif.tauxMargeLocal)} %</span></span>
<span>Réseau : <span className="font-semibold tabular-nums" style={{ color: "var(--text-primary)" }}>{comparatif.tauxMargeReseau != null ? `${fmtDec(comparatif.tauxMargeReseau)} %` : "—"}</span></span>
</div>
</div>
</div>
<p className="text-[11px] mt-2" style={{ color: "var(--text-muted)" }}>
L&apos;indice compare les <strong>quantités</strong> par magasin, seule base commune fiable.
Notre CA est TTC (issu des mouvements de caisse) ; la base du CA Qlik n&apos;est pas
documentée — montants et marges sont donc donnés à titre indicatif.
</p>
{comparatif.hasMonthly && (
<div className="mt-4">
<div className="text-[10px] font-semibold uppercase tracking-wide mb-1" style={{ color: "var(--text-muted)" }}>
Quantité par magasin, mois par mois
</div>
<DualLineChart
labels={comparatif.monthly.map((m) => ffMonthToQlik(m.mois))}
series={[
{ name: "Nous / magasin", values: comparatif.monthly.map((m) => m.local), color: "#0ea5e9" },
{ name: "Réseau / magasin", values: comparatif.monthly.map((m) => m.reseau), color: "#f59e0b" },
]}
/>
</div>
)}
</Card>
)}
{/* ─── Opportunités de la même famille ─────────────────────────── */}
{detail?.nom_no_id != null && (
<Card title="Opportunités · même famille" icon={Layers}>
<OpportunitesFamille
nomNoId={detail.nom_no_id}
currentCodein={detail.codein}
familleLabel={detail.libelle3 ?? detail.libelle2 ?? ""}
/>
</Card>
)}
{/* Sparkline discrète en pied de fiche, cohérente avec la Grille */}
{trend.hasData && (
<div className="flex items-center justify-end gap-2 text-[11px]" style={{ color: "var(--text-muted)" }}>
<span>Tendance réseau :</span>
<TrendSparkline trend={trend} />
</div>
)}
</div>
);
}
+5
View File
@@ -0,0 +1,5 @@
import LoadingModal from "@/components/shared/loading-modal";
export default function ProduitsLoading() {
return <LoadingModal message="Chargement des données" subMessage="Recherche du produit..." />;
}
@@ -0,0 +1,136 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { Loader2, TrendingUp } from "lucide-react";
import type { PgOpportuniteRow } from "@/lib/pg-ff-client";
const fmtQte = (v: number) => new Intl.NumberFormat("fr-FR", { maximumFractionDigits: 0 }).format(v);
const fmtDec = (v: number) => new Intl.NumberFormat("fr-FR", { minimumFractionDigits: 1, maximumFractionDigits: 1 }).format(v);
/**
* Produits de la même famille que le produit affiché, classés par écart de
* performance réseau vs locale (quantité **par magasin**, pour neutraliser
* l'écart d'échelle entre ~270 magasins et nos 2 sites).
*
* Chargement à la demande : la requête agrège 12 mois de mouvements pour toute
* la famille, elle ne doit pas ralentir l'ouverture de la fiche.
*/
export function OpportunitesFamille({
nomNoId,
currentCodein,
familleLabel,
}: {
nomNoId: number;
currentCodein: string;
familleLabel: string;
}) {
const [rows, setRows] = useState<PgOpportuniteRow[] | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
async function load() {
setLoading(true);
setError(null);
try {
const res = await fetch(`/api/produits/opportunites?nomNoId=${nomNoId}`, { cache: "no-store" });
const data = await res.json();
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
setRows((data.rows ?? []) as PgOpportuniteRow[]);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setLoading(false);
}
}
if (rows === null) {
return (
<div className="space-y-2">
<p className="text-[13px]" style={{ color: "var(--text-secondary)" }}>
Comparer ce produit aux autres références{familleLabel ? ` de « ${familleLabel} »` : " de la même famille"} :
lesquelles le réseau vend bien et que nous vendons peu ?
</p>
<button onClick={load} disabled={loading} className="btn-action btn-action-secondary flex items-center gap-1.5 disabled:opacity-60">
{loading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <TrendingUp className="w-3.5 h-3.5" />}
{loading ? "Analyse…" : "Analyser la famille"}
</button>
{error && <p className="text-[12px]" style={{ color: "var(--accent-error)" }}>{error}</p>}
</div>
);
}
// On retire le produit courant : il est déjà détaillé au-dessus.
const others = rows.filter((r) => r.codein !== currentCodein);
if (others.length === 0) {
return (
<p className="text-[13px]" style={{ color: "var(--text-muted)" }}>
Aucune autre référence de cette famille n&apos;a de données réseau en cache.
Synchronisez d&apos;autres fournisseurs pour enrichir la comparaison.
</p>
);
}
return (
<div className="space-y-2">
<div className="overflow-x-auto rounded-xl" style={{ border: "1px solid var(--border)" }}>
<table className="w-full border-collapse text-[12px]">
<thead>
<tr style={{ background: "var(--bg-elevated)" }}>
<th className="px-3 py-2 text-left text-[10px] font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Produit</th>
<th className="px-3 py-2 text-left text-[10px] font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Fournisseur</th>
<th className="px-3 py-2 text-right text-[10px] font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Réseau / mag.</th>
<th className="px-3 py-2 text-right text-[10px] font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Nous / mag.</th>
<th className="px-3 py-2 text-right text-[10px] font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Écart</th>
<th className="px-3 py-2 text-right text-[10px] font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Magasins</th>
</tr>
</thead>
<tbody>
{others.map((r) => {
const opportunite = r.ecart_par_magasin > 0;
return (
<tr key={r.codein} className="transition-colors hover:bg-[var(--bg-elevated)]" style={{ borderTop: "1px solid var(--border)" }}>
<td className="px-3 py-2">
<Link
href={`/produits?codein=${encodeURIComponent(r.codein)}`}
className="font-medium hover:underline"
style={{ color: "var(--accent)" }}
>
{r.libelle1 || r.codein}
</Link>
</td>
<td className="px-3 py-2" style={{ color: "var(--text-secondary)" }}>{r.fournisseur}</td>
<td className="px-3 py-2 text-right tabular-nums" style={{ color: "var(--text-secondary)" }}>
{fmtDec(r.qte_reseau_par_magasin)}
</td>
<td className="px-3 py-2 text-right tabular-nums" style={{ color: "var(--text-secondary)" }}>
{fmtDec(r.qte_locale_par_magasin)}
</td>
<td
className="px-3 py-2 text-right tabular-nums font-semibold"
style={{ color: opportunite ? "#f59e0b" : "#22c55e" }}
title={opportunite
? "Le réseau vend plus que nous sur ce produit"
: "Nous vendons autant ou plus que le réseau"}
>
{r.ecart_par_magasin > 0 ? "+" : ""}{fmtDec(r.ecart_par_magasin)}
</td>
<td className="px-3 py-2 text-right tabular-nums text-[11px]" style={{ color: "var(--text-muted)" }}>
{fmtQte(r.nb_magasins_reseau)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<p className="text-[11px]" style={{ color: "var(--text-muted)" }}>
Écart = quantité réseau par magasin − notre quantité par magasin, sur 12 mois.
Un écart positif signale une référence sous-exploitée chez nous.
Périmètre : les articles <strong>de notre catalogue</strong> ayant déjà des données réseau —
Qlik est interrogé par code article, un produit que Nancy ne référence pas ne peut pas apparaître ici.
</p>
</div>
);
}
+69
View File
@@ -0,0 +1,69 @@
import { getProduitFiche, getProduitFicheByCodeCentrale } from "@/features/produits/api/get-produit-fiche";
import { ProduitSearchBar } from "./search-bar";
import { ProduitResults } from "./results";
import { ProduitFicheView } from "./fiche";
export const dynamic = "force-dynamic";
/**
* Recherche produit — **Qlik d'abord**, catalogue FF Nancy ensuite.
*
* - `?q=…` → liste de résultats (chargée côté client : l'extraction Qlik
* prend plusieurs secondes, on ne bloque pas le rendu serveur)
* - `?codein=…` → fiche d'un produit de notre catalogue (URL partageable)
* - `?cc=…` → fiche d'un produit du réseau par son code centrale, qu'il
* soit référencé chez nous ou non
*/
export default async function ProduitsPage(props: {
searchParams: Promise<{ q?: string; codein?: string; cc?: string }>;
}) {
const searchParams = await props.searchParams;
const q = (searchParams.q ?? "").trim();
const codein = (searchParams.codein ?? "").trim();
const codeCentrale = (searchParams.cc ?? "").trim();
const fiche = codein
? await getProduitFiche(codein)
: codeCentrale
? await getProduitFicheByCodeCentrale(codeCentrale)
: null;
const ficheDemandee = Boolean(codein || codeCentrale);
return (
<div className="w-full max-w-screen-2xl mx-auto space-y-6">
<header className="space-y-1">
<h1 className="text-2xl font-semibold tracking-[-0.4px]" style={{ color: "var(--text-primary)" }}>
Recherche produit
</h1>
<p className="text-[13px]" style={{ color: "var(--text-muted)" }}>
Recherche dans Qlik Sense (réseau) puis rapprochement avec notre catalogue —
performance réseau et ventes locales sur 12 mois glissants.
</p>
</header>
{/* key : remonte le champ quand l'URL change (retour arrière, lien partagé) */}
<ProduitSearchBar key={q} initialQuery={q} />
{ficheDemandee && !fiche && (
<div
className="rounded-xl p-6 text-center text-[13px]"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-muted)" }}
>
Aucun produit trouvé pour{" "}
{codein
? <>le code article <span className="font-mono">{codein}</span></>
: <>le code centrale <span className="font-mono">{codeCentrale}</span></>}.
{codeCentrale && !codein && (
<div className="mt-1">
Ce code n&apos;est ni dans notre catalogue, ni dans le cache réseau — relancez la recherche.
</div>
)}
</div>
)}
{fiche && <ProduitFicheView fiche={fiche} backQuery={q} />}
{!ficheDemandee && <ProduitResults query={q} />}
</div>
);
}
+399
View File
@@ -0,0 +1,399 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { PackageSearch, Loader2, RefreshCw, AlertTriangle, Database, Globe } from "lucide-react";
import { TrendSparkline } from "@/features/grid/components/network-charts";
import { computeNetworkTrend, NB_MAGASINS_RESEAU } from "@/features/grid/lib/network-trend";
import type { ProduitRechercheResultat, ProduitRechercheRow } from "@/features/produits/types";
const fmtQte = (v: number) => new Intl.NumberFormat("fr-FR", { maximumFractionDigits: 0 }).format(v);
const fmtDec = (v: number, d = 1) =>
new Intl.NumberFormat("fr-FR", { minimumFractionDigits: d, maximumFractionDigits: d }).format(v);
const fmtEur = (v: number) =>
new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 }).format(v);
const fmtEur2 = (v: number) =>
new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 2 }).format(v);
/** Intervalle d'interrogation de l'état du job de recherche. */
const POLL_MS = 2000;
/** Réponse du job de recherche, telle que la renvoie l'API. */
interface JobRecherche {
status: "idle" | "running" | "success" | "error";
etape?: string;
error?: string;
result?: ProduitRechercheResultat;
}
/**
* Lit une réponse HTTP en **ne présumant pas** qu'elle est du JSON.
*
* Un reverse proxy qui coupe la requête répond une page HTML : `res.json()`
* échouait alors sur « Unexpected token '<' … is not valid JSON », message
* inexploitable pour l'utilisateur. On traduit ici le statut HTTP.
*/
async function lireJson(res: Response): Promise<Record<string, unknown>> {
const texte = await res.text();
let data: Record<string, unknown> | null = null;
try {
data = texte ? (JSON.parse(texte) as Record<string, unknown>) : null;
} catch {
data = null;
}
if (data == null) {
if (res.status === 504 || res.status === 408) {
throw new Error("Le serveur a mis trop de temps à répondre (délai dépassé côté proxy). Réessayez.");
}
if (res.status === 502 || res.status === 503) {
throw new Error("Application indisponible ou en cours de redémarrage. Réessayez dans un instant.");
}
if (res.status === 401 || res.status === 403) {
throw new Error("Session expirée. Rechargez la page pour vous reconnecter.");
}
throw new Error(`Réponse inattendue du serveur (HTTP ${res.status}).`);
}
if (!res.ok) throw new Error(String(data.error ?? `HTTP ${res.status}`));
return data;
}
/**
* Résultats de recherche produit.
*
* La recherche interroge **Qlik d'abord** et peut dépasser la minute : elle est
* lancée par un `POST` qui rend la main tout de suite, puis on interroge son
* avancement toutes les 2 s. Une requête HTTP maintenue pendant toute
* l'extraction se faisait couper par le reverse proxy.
*/
export function ProduitResults({ query }: { query: string }) {
// `relance` mémorise la dernière demande explicite de rafraîchissement, pour
// ne forcer le contournement du cache serveur que sur CE terme-là.
const [relance, setRelance] = useState<{ n: number; q: string }>({ n: 0, q: "" });
const [resultat, setResultat] = useState<{
cle: string;
data: ProduitRechercheResultat | null;
error: string | null;
etape: string | null;
} | null>(null);
const force = relance.n > 0 && relance.q === query;
const cle = `${query}|${force ? relance.n : 0}`;
const tropCourt = query.trim().length < 3;
// L'état de chargement est **dérivé** (résultat pas encore aligné sur la
// requête courante) plutôt que posé en début d'effet : appeler setState
// synchronement dans un effet provoque un rendu en cascade.
const enAttente = resultat?.cle !== cle || (!resultat?.data && !resultat?.error);
const loading = !tropCourt && enAttente;
useEffect(() => {
if (tropCourt) return;
let annule = false;
let timer: ReturnType<typeof setTimeout> | undefined;
const url = (methode: "POST" | "GET") =>
`/api/produits/search?q=${encodeURIComponent(query)}${methode === "POST" && force ? "&force=1" : ""}`;
const appliquer = (job: JobRecherche): boolean => {
if (annule) return true;
if (job.status === "success" && job.result) {
setResultat({ cle, data: job.result, error: null, etape: null });
return true;
}
if (job.status === "error") {
setResultat({ cle, data: null, error: job.error ?? "La recherche a échoué.", etape: null });
return true;
}
// `idle` = le process a redémarré et a perdu le job : on relance.
setResultat({ cle, data: null, error: null, etape: job.etape ?? null });
return false;
};
const poll = async () => {
try {
const job = (await lireJson(await fetch(url("GET"), { cache: "no-store" }))) as unknown as JobRecherche;
if (job.status === "idle") {
await demarrer();
return;
}
if (!appliquer(job) && !annule) timer = setTimeout(() => void poll(), POLL_MS);
} catch (e) {
if (!annule) setResultat({ cle, data: null, error: e instanceof Error ? e.message : String(e), etape: null });
}
};
const demarrer = async () => {
try {
const job = (await lireJson(
await fetch(url("POST"), { method: "POST", cache: "no-store" }),
)) as unknown as JobRecherche;
if (!appliquer(job) && !annule) timer = setTimeout(() => void poll(), POLL_MS);
} catch (e) {
if (!annule) setResultat({ cle, data: null, error: e instanceof Error ? e.message : String(e), etape: null });
}
};
void demarrer();
return () => { annule = true; if (timer) clearTimeout(timer); };
}, [cle, query, force, tropCourt]);
const relancer = () => setRelance((r) => ({ n: r.n + 1, q: query }));
const data = resultat?.cle === cle ? resultat.data : null;
const error = resultat?.cle === cle ? resultat.error : null;
const etape = resultat?.cle === cle ? resultat.etape : null;
if (!query) {
return (
<div
className="rounded-xl p-10 text-center"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}
>
<PackageSearch className="w-8 h-8 mx-auto mb-3" style={{ color: "var(--text-muted)" }} strokeWidth={1.5} />
<p className="text-[14px] font-medium" style={{ color: "var(--text-secondary)" }}>
Recherchez un produit dans le réseau
</p>
<p className="text-[12px] mt-1 max-w-lg mx-auto" style={{ color: "var(--text-muted)" }}>
La recherche interroge <strong>Qlik Sense</strong> (les ~{NB_MAGASINS_RESEAU} magasins du réseau),
puis rapproche chaque produit trouvé de notre catalogue.
Saisissez un libellé (ex. « poêle 28 ») ou un code centrale (ex. « 10000167303 »).
</p>
</div>
);
}
if (loading) {
return (
<div
className="rounded-xl p-10 text-center"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}
>
<Loader2 className="w-6 h-6 mx-auto mb-3 animate-spin" style={{ color: "var(--accent)" }} />
<p className="text-[13px] font-medium" style={{ color: "var(--text-secondary)" }}>
{etape ?? "Interrogation de Qlik Sense…"}
</p>
<p className="text-[12px] mt-1" style={{ color: "var(--text-muted)" }}>
Recherche des articles du réseau puis extraction de leurs ventes sur 12 mois glissants.
Cela peut prendre plus d&apos;une minute — vous pouvez laisser la page ouverte.
</p>
</div>
);
}
if (error) {
return (
<div className="rounded-xl p-6" style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}>
<div className="flex items-start gap-3">
<AlertTriangle className="w-4 h-4 mt-0.5 shrink-0" style={{ color: "var(--accent-error)" }} />
<div className="min-w-0">
<p className="text-[13px] font-medium" style={{ color: "var(--text-primary)" }}>
La recherche a échoué
</p>
<p className="text-[12px] mt-0.5 break-words" style={{ color: "var(--text-muted)" }}>{error}</p>
<button onClick={relancer} className="btn-action btn-action-secondary mt-3 flex items-center gap-1.5">
<RefreshCw className="w-3.5 h-3.5" />
Réessayer
</button>
</div>
</div>
</div>
);
}
if (!data) return null;
const { rows, source, qlikError, tronque, locauxHorsReseau, dureeMs } = data;
return (
<div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-[12px] flex items-center gap-1.5" style={{ color: "var(--text-muted)" }}>
{source === "qlik" ? (
<Globe className="w-3.5 h-3.5" style={{ color: "var(--accent)" }} />
) : (
<Database className="w-3.5 h-3.5" />
)}
{rows.length} produit{rows.length > 1 ? "s" : ""}
{source === "qlik" ? " trouvé(s) dans Qlik" : " trouvé(s) dans le catalogue local"}
{tronque && " (liste tronquée — affinez la recherche)"}
{" · "}{(dureeMs / 1000).toFixed(1)} s
</p>
<button onClick={relancer} className="btn-action btn-action-secondary flex items-center gap-1.5">
<RefreshCw className="w-3.5 h-3.5" />
Relancer
</button>
</div>
{source === "db" && (
<div
className="rounded-xl p-3 text-[12px] flex items-start gap-2"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}
>
<AlertTriangle className="w-3.5 h-3.5 mt-0.5 shrink-0" style={{ color: "var(--accent-error)" }} />
<span>
Qlik n&apos;a pas pu être interrogé, la recherche s&apos;est repliée sur le catalogue local
(les chiffres réseau affichés viennent du dernier cache).
{qlikError && <span className="block mt-0.5" style={{ color: "var(--text-muted)" }}>{qlikError}</span>}
</span>
</div>
)}
{source === "qlik" && qlikError && (
<div
className="rounded-xl p-3 text-[12px] flex items-start gap-2"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}
>
<AlertTriangle className="w-3.5 h-3.5 mt-0.5 shrink-0" style={{ color: "var(--accent-error)" }} />
<span>
{rows.length === 0
? qlikError
: `Articles trouvés, mais l'extraction des mesures réseau a échoué : ${qlikError}`}
</span>
</div>
)}
{rows.length === 0 ? (
<div
className="rounded-xl p-8 text-center text-[13px]"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-muted)" }}
>
Aucun article du réseau ne correspond à « {query} ».
</div>
) : (
<ResultTable rows={rows} query={query} />
)}
{locauxHorsReseau.length > 0 && (
<details className="rounded-xl" style={{ border: "1px solid var(--border)" }}>
<summary className="cursor-pointer px-3 py-2.5 text-[12px]" style={{ color: "var(--text-secondary)" }}>
{locauxHorsReseau.length} produit{locauxHorsReseau.length > 1 ? "s" : ""} de notre catalogue
sans correspondance réseau (pas de code centrale, ou non vendu par le réseau)
</summary>
<div className="px-3 pb-3">
<ul className="space-y-1">
{locauxHorsReseau.slice(0, 30).map((l) => (
<li key={l.codein} className="text-[12px]">
<Link
href={`/produits?codein=${encodeURIComponent(l.codein)}&q=${encodeURIComponent(query)}`}
className="hover:underline"
style={{ color: "var(--accent)" }}
>
{l.libelle1 || l.codein}
</Link>
<span style={{ color: "var(--text-muted)" }}>
{" · "}{l.fournisseur}{" · "}<span className="font-mono">{l.codein}</span>
</span>
</li>
))}
</ul>
</div>
</details>
)}
</div>
);
}
function ResultTable({ rows, query }: { rows: ProduitRechercheRow[]; query: string }) {
return (
<div className="overflow-x-auto rounded-xl" style={{ border: "1px solid var(--border)" }}>
<table className="w-full text-[13px] border-collapse">
<thead>
<tr style={{ background: "var(--bg-elevated)" }}>
<Th>Libellé</Th>
<Th>Fournisseur</Th>
<Th>Code centrale</Th>
<Th align="right">Magasins</Th>
<Th align="right">Qté réseau</Th>
<Th align="right">Qté / mag.</Th>
<Th align="right">Prix moyen</Th>
<Th align="right">CA / mag.</Th>
<Th align="right">Marge %</Th>
<Th align="center">Tendance</Th>
<Th align="right">Nous</Th>
</tr>
</thead>
<tbody>
{rows.map((r) => {
const trend = computeNetworkTrend(r.qteByMonth, r.nbMagByMonth);
const href = r.codein
? `/produits?codein=${encodeURIComponent(r.codein)}&q=${encodeURIComponent(query)}`
: `/produits?cc=${encodeURIComponent(r.codeCentrale)}&q=${encodeURIComponent(query)}`;
return (
<tr
key={r.codeCentrale}
className="transition-colors hover:bg-[var(--bg-elevated)]"
style={{ borderTop: "1px solid var(--border)" }}
>
<td className="px-3 py-2 max-w-[280px]">
<Link href={href} className="font-medium hover:underline" style={{ color: "var(--accent)" }}>
{r.libelle || "—"}
</Link>
{r.nomenclature && (
<div className="text-[11px] truncate" style={{ color: "var(--text-muted)" }}>{r.nomenclature}</div>
)}
</td>
<td className="px-3 py-2 text-[12px]" style={{ color: "var(--text-secondary)" }}>
{r.fournisseur || "—"}
</td>
<td className="px-3 py-2 font-mono text-[12px]" style={{ color: "var(--text-muted)" }}>
{r.codeCentrale}
</td>
<td className="px-3 py-2 text-right tabular-nums" style={{ color: "var(--text-secondary)" }}>
{fmtQte(r.nbMagasinsReseau)}
<span className="text-[11px] ml-1" style={{ color: "var(--text-muted)" }}>
{fmtDec(r.tauxPresence * 100, 0)} %
</span>
</td>
<td className="px-3 py-2 text-right tabular-nums font-semibold" style={{ color: "var(--text-primary)" }}>
{fmtQte(r.qteReseau)}
</td>
<td className="px-3 py-2 text-right tabular-nums" style={{ color: "var(--text-secondary)" }}>
{fmtDec(r.qteParMagasinReseau)}
</td>
<td className="px-3 py-2 text-right tabular-nums" style={{ color: "var(--text-secondary)" }}>
{r.prixMoyenReseau != null ? fmtEur2(r.prixMoyenReseau) : "—"}
</td>
<td className="px-3 py-2 text-right tabular-nums" style={{ color: "var(--text-secondary)" }}>
{r.caParMagasinReseau > 0 ? fmtEur(r.caParMagasinReseau) : "—"}
</td>
<td className="px-3 py-2 text-right tabular-nums" style={{ color: "var(--text-secondary)" }}>
{r.margePctReseau != null ? `${fmtDec(r.margePctReseau)} %` : "—"}
</td>
<td className="px-2 py-2 text-center">
{trend.hasData ? <TrendSparkline trend={trend} /> : <span style={{ color: "var(--text-muted)" }}>—</span>}
</td>
<td className="px-3 py-2 text-right text-[12px]">
{r.codein ? (
<span style={{ color: "var(--text-secondary)" }} title={`Code article ${r.codein}`}>
stock {fmtQte(r.stockLocal ?? 0)}
</span>
) : (
<span
className="rounded-md px-1.5 py-0.5 text-[11px]"
style={{ background: "var(--bg-elevated)", color: "var(--text-muted)", border: "1px solid var(--border)" }}
title="Le réseau travaille ce produit, notre catalogue ne le référence pas"
>
non référencé
</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
function Th({ children, align = "left" }: { children: React.ReactNode; align?: "left" | "right" | "center" }) {
// Classes littérales : Tailwind ne génère pas les classes construites dynamiquement.
const alignClass = align === "right" ? "text-right" : align === "center" ? "text-center" : "text-left";
return (
<th
className={`px-3 py-2.5 ${alignClass} text-[11px] font-semibold uppercase tracking-wide whitespace-nowrap`}
style={{ color: "var(--text-muted)" }}
>
{children}
</th>
);
}
@@ -0,0 +1,80 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Search, X } from "lucide-react";
/**
* Champ de recherche produit.
*
* Soumission **explicite** (Entrée ou bouton) et non à la frappe : chaque
* recherche déclenche deux allers-retours Qlik Sense (recherche des articles
* puis extraction de leurs mesures sur 12 mois), soit plusieurs secondes.
* Minimum 3 caractères.
*
* L'appelant passe `key={q}` pour que le champ se resynchronise sur l'URL
* (retour arrière, lien partagé) par remontage, plutôt que par un effet qui
* appellerait setState en cascade.
*/
export function ProduitSearchBar({ initialQuery }: { initialQuery: string }) {
const router = useRouter();
const [term, setTerm] = useState(initialQuery);
const tooShort = term.trim().length > 0 && term.trim().length < 3;
function submit() {
const cleaned = term.trim();
if (cleaned.length < 3) return;
router.push(`/produits?q=${encodeURIComponent(cleaned)}`);
}
function clear() {
setTerm("");
router.push("/produits");
}
return (
<div className="space-y-1.5">
<div className="flex items-center gap-2">
<div className="relative flex-1 max-w-xl">
<Search
className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 pointer-events-none"
style={{ color: "var(--text-muted)" }}
/>
<input
type="text"
value={term}
onChange={(e) => setTerm(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") submit(); }}
placeholder="Libellé produit ou code centrale…"
aria-label="Rechercher un produit par libellé ou code centrale"
className="apple-input h-10 text-[14px]"
style={{ paddingLeft: "38px", paddingRight: term ? "36px" : undefined }}
/>
{term && (
<button
onClick={clear}
aria-label="Effacer la recherche"
className="absolute right-2.5 top-1/2 -translate-y-1/2 rounded-md p-1 transition-colors hover:bg-[var(--bg-elevated)]"
style={{ color: "var(--text-muted)" }}
>
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
<button
onClick={submit}
disabled={term.trim().length < 3}
className="btn-action btn-action-primary disabled:opacity-50"
>
Rechercher
</button>
</div>
{tooShort && (
<p className="text-[11px]" style={{ color: "var(--text-muted)" }}>
Saisissez au moins 3 caractères.
</p>
)}
</div>
);
}
+193 -234
View File
@@ -5,11 +5,13 @@ import { Save, Eye, EyeOff, CheckCircle, AlertCircle, Loader2, RefreshCw, Sun, M
import { useTheme } from "next-themes";
import { useGridStore } from "@/features/grid/store/use-grid-store";
import { useDbSettingsStore } from "@/features/settings/store/use-db-settings-store";
import { testDatabaseConnection, saveDatabaseSettings, getSavedDatabaseConfig } from "@/features/settings/actions";
import { testDatabaseConnection, saveDatabaseSettings, getSavedDatabaseConfig, saveQlikSettings, testQlikConnection, saveFfApiSettings, testFfApiConnection } from "@/features/settings/actions";
import { useEffect } from "react";
import { UserManagement } from "@/features/admin/components/user-management";
interface OpenRouterModel { id: string; name: string; free: boolean; }
import { ApiKeyManagement } from "@/features/admin/components/api-key-management";
import { ApiConnectionInfo } from "@/features/admin/components/api-connection-info";
import { GridWarmup } from "@/features/admin/components/grid-warmup";
import { ServerLogs } from "@/features/settings/components/server-logs";
function Section({ title, subtitle, children }: { title: string; subtitle?: string; children: React.ReactNode }) {
return (
@@ -35,40 +37,92 @@ function Field({ label, hint, children }: { label: string; hint?: string; childr
);
}
interface FfSyncTable { nom: string; derniereSync: string; nbLignes?: number; }
interface FfSyncTable { nom: string; derniereSync: string; nbLignes?: number; statut?: string; erreur?: string | null; }
interface FfSyncStatus { lastSync: string; tables: FfSyncTable[]; }
function FfApiStatusSection() {
const [status, setStatus] = useState<FfSyncStatus | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [url, setUrl] = useState("");
const [savedUrl, setSavedUrl] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [testedUrl, setTestedUrl] = useState<string | null>(null);
const fetchStatus = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await fetch("/api/ff-status");
if (!res.ok) throw new Error(`HTTP ${res.status}`);
setStatus(await res.json());
} catch (e) {
setError(e instanceof Error ? e.message : "Erreur inconnue");
} finally {
setLoading(false);
}
// Charge l'URL enregistrée pour la préremplir (vide = valeur par défaut).
useEffect(() => {
getSavedDatabaseConfig()
.then((cfg) => {
setSavedUrl(cfg?.ffApiBaseUrl ?? null);
if (cfg?.ffApiBaseUrl) setUrl(cfg.ffApiBaseUrl);
})
.catch(() => { /* réglage optionnel : on laisse le champ vide */ });
}, []);
useEffect(() => { fetchStatus(); }, [fetchStatus]);
const runTest = useCallback((candidate?: string) => {
setLoading(true);
setError(null);
setStatus(null);
testFfApiConnection(candidate)
.then((res) => {
setTestedUrl(res.url);
if (res.success) setStatus(res.status as FfSyncStatus);
else setError(res.error ?? "Échec inconnu");
})
.catch((e) => setError(e instanceof Error ? e.message : "Erreur inconnue"))
.finally(() => setLoading(false));
}, []);
useEffect(() => { runTest(); }, [runTest]);
const save = async () => {
setSaving(true);
setError(null);
try {
const res = await saveFfApiSettings(url);
if (!res.success) { setError(res.error ?? "Enregistrement impossible"); return; }
setSavedUrl(url.trim() || null);
runTest(url);
} finally {
setSaving(false);
}
};
return (
<Section title="API FF Nancy" subtitle="Source des données produits — synchronisation nuit depuis SQL Server (données J-1)">
<div className="flex items-center gap-3">
<button onClick={fetchStatus} disabled={loading} className="apple-btn-secondary h-9 px-4">
{loading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <RefreshCw className="w-3.5 h-3.5" />}
Vérifier le statut
<Field label="URL de l'API" hint="Laisser vide pour utiliser la valeur par défaut (https://api.ffnancy.fr)">
<input
type="text"
placeholder="https://api.ffnancy.fr"
value={url}
onChange={(e) => setUrl(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") void save(); }}
className="apple-input w-full"
/>
</Field>
<div className="flex items-center gap-3 flex-wrap">
<button onClick={save} disabled={saving} className="apple-btn h-9 px-4">
{saving ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Save className="w-3.5 h-3.5" />}
Enregistrer
</button>
{error && <span className="text-[12px] text-red-400">{error}</span>}
<button onClick={() => runTest(url)} disabled={loading} className="apple-btn-secondary h-9 px-4">
{loading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <RefreshCw className="w-3.5 h-3.5" />}
Tester
</button>
{status && <span className="text-[12px] text-emerald-400 flex items-center gap-1"><CheckCircle className="w-3.5 h-3.5" /> Connexion OK</span>}
{error && <span className="text-[12px] text-red-400 flex items-center gap-1"><AlertCircle className="w-3.5 h-3.5" /> {error}</span>}
</div>
{/* L'URL réellement appelée : évite de croire qu'on teste celle du champ
alors que le réglage enregistré ou la variable d'environnement prime. */}
{testedUrl && (
<p className="text-[11px] text-[var(--text-muted)]">
Adresse testée : <span className="font-mono">{testedUrl}</span>
{!savedUrl && <> — valeur par défaut, aucun réglage enregistré</>}
</p>
)}
{status && (
<div className="space-y-2 mt-2">
<p className="text-[12px] text-[var(--text-secondary)]">
@@ -82,6 +136,7 @@ function FfApiStatusSection() {
<th className="text-left px-3 py-2 font-medium text-[var(--text-secondary)]">Table</th>
<th className="text-right px-3 py-2 font-medium text-[var(--text-secondary)]">Sync</th>
<th className="text-right px-3 py-2 font-medium text-[var(--text-secondary)]">Lignes</th>
<th className="text-right px-3 py-2 font-medium text-[var(--text-secondary)]">État</th>
</tr>
</thead>
<tbody>
@@ -94,6 +149,12 @@ function FfApiStatusSection() {
<td className="px-3 py-2 text-right text-[var(--text-muted)]">
{t.nbLignes?.toLocaleString("fr-FR") ?? "—"}
</td>
{/* Une table en erreur est précisément ce qu'on vient chercher ici. */}
<td className="px-3 py-2 text-right">
{t.statut === "ok"
? <span className="text-emerald-400">ok</span>
: <span className="text-red-400" title={t.erreur ?? undefined}>{t.statut ?? "—"}</span>}
</td>
</tr>
))}
</tbody>
@@ -106,28 +167,91 @@ function FfApiStatusSection() {
);
}
function QlikSettingsSection() {
const [host, setHost] = useState("");
const [user, setUser] = useState("");
const [password, setPassword] = useState("");
const [showPwd, setShowPwd] = useState(false);
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const [status, setStatus] = useState<{ type: "ok" | "err"; msg: string } | null>(null);
useEffect(() => {
getSavedDatabaseConfig().then((c) => {
if (!c) return;
setHost(c.qlikHost ?? "");
setUser(c.qlikUser ?? "");
setPassword(c.qlikPassword ?? "");
});
}, []);
const handleSave = async () => {
setSaving(true);
setStatus(null);
const res = await saveQlikSettings(host.trim(), user.trim(), password);
setSaving(false);
setStatus(res.success ? { type: "ok", msg: "Réglages Qlik enregistrés" } : { type: "err", msg: res.error || "Erreur" });
};
const handleTest = async () => {
setTesting(true);
setStatus(null);
const res = await testQlikConnection(host.trim(), user.trim(), password);
setTesting(false);
setStatus(res.success ? { type: "ok", msg: "Connexion Qlik OK" } : { type: "err", msg: res.error || "Échec connexion" });
};
return (
<Section title="Qlik Sense — Données réseau" subtitle="Identifiants pour la synchro des données réseau (~270 magasins). Auth NTLM ; laisser l'hôte vide pour le défaut.">
<Field label="Hôte" hint="Défaut : reporting-magasins.lafoirfouille.fr (laisser vide pour ce défaut)">
<input type="text" placeholder="reporting-magasins.lafoirfouille.fr" value={host}
onChange={(e) => setHost(e.target.value)} className="apple-input font-mono" />
</Field>
<Field label="Utilisateur" hint="Ex : FFSCH (sans domaine)">
<input type="text" placeholder="FFSCH" value={user}
onChange={(e) => setUser(e.target.value)} className="apple-input font-mono" />
</Field>
<Field label="Mot de passe">
<div className="relative">
<input type={showPwd ? "text" : "password"} placeholder="••••••••" value={password}
onChange={(e) => setPassword(e.target.value)} className="apple-input font-mono pr-10" />
<button type="button" onClick={() => setShowPwd((v) => !v)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-[var(--text-muted)]">
{showPwd ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</Field>
{status && (
<div className={`flex items-center gap-1.5 text-[12px] ${status.type === "ok" ? "text-emerald-600" : "text-rose-600"}`}>
{status.type === "ok" ? <CheckCircle className="w-4 h-4" /> : <AlertCircle className="w-4 h-4" />}
{status.msg}
</div>
)}
<div className="flex gap-2">
<button onClick={handleSave} disabled={saving}
className="btn-action btn-action-primary flex items-center gap-1.5 disabled:opacity-60">
{saving ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Save className="w-3.5 h-3.5" />}
Enregistrer
</button>
<button onClick={handleTest} disabled={testing || !user || !password}
className="btn-action btn-action-secondary flex items-center gap-1.5 disabled:opacity-60">
{testing ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <RefreshCw className="w-3.5 h-3.5" />}
Tester
</button>
</div>
</Section>
);
}
export default function SettingsPage() {
const { resolvedTheme, setTheme } = useTheme();
const isDark = resolvedTheme === "dark";
const displayDensity = useGridStore((s) => s.displayDensity);
const setDisplayDensity = useGridStore((s) => s.setDisplayDensity);
const [showKey, setShowKey] = useState(false);
const [apiKey, setApiKey] = useState("");
const [dbStatus, setDbStatus] = useState<"idle" | "testing" | "ok" | "error">("idle");
const [dbError, setDbError] = useState<string | null>(null);
const [saveStatus, setSaveStatus] = useState<"idle" | "saving" | "saved">("idle");
const [models, setModels] = useState<OpenRouterModel[]>([]);
const [selectedModel, setSelectedModel] = useState("google/gemini-2.0-flash-001");
const [modelsStatus, setModelsStatus] = useState<"idle" | "loading" | "ok" | "error">("idle");
// AI Provider
const [aiProvider, setAiProvider] = useState<"openrouter" | "google">("openrouter");
const [googleAiKey, setGoogleAiKey] = useState("");
const [showGoogleKey, setShowGoogleKey] = useState(false);
const [googleAiModel, setGoogleAiModel] = useState("");
const [googleModels, setGoogleModels] = useState<{ id: string; name: string }[]>([]);
const [googleModelsStatus, setGoogleModelsStatus] = useState<"idle" | "loading" | "ok" | "error">("idle");
const [isMounted, setIsMounted] = useState(false);
@@ -141,34 +265,6 @@ export default function SettingsPage() {
getDatabaseUrl
} = useDbSettingsStore();
const fetchGoogleModels = useCallback(async (key: string) => {
if (!key.trim()) return;
setGoogleModelsStatus("loading");
try {
const res = await fetch("/api/google-ai/models", { headers: { "x-google-ai-key": key } });
if (!res.ok) throw new Error();
const data = await res.json();
setGoogleModels(data.models ?? []);
setGoogleModelsStatus("ok");
} catch {
setGoogleModelsStatus("error");
}
}, []);
const fetchModels = useCallback(async (key: string) => {
if (!key.trim()) return;
setModelsStatus("loading");
try {
const res = await fetch("/api/openrouter/models", { headers: { "x-openrouter-key": key } });
if (!res.ok) throw new Error();
const data = await res.json();
setModels(data.models ?? []);
setModelsStatus("ok");
} catch {
setModelsStatus("error");
}
}, []);
const reloadFromServer = useCallback(async () => {
const config = await getSavedDatabaseConfig();
if (config) {
@@ -186,23 +282,13 @@ export default function SettingsPage() {
console.error("Failed to parse saved URL", e);
}
}
if (config.openRouterKey) {
setApiKey(config.openRouterKey);
fetchModels(config.openRouterKey);
}
if (config.openRouterModel) {
setSelectedModel(config.openRouterModel);
}
if (config.aiProvider) setAiProvider(config.aiProvider);
if (config.googleAiKey) {
setGoogleAiKey(config.googleAiKey);
fetchGoogleModels(config.googleAiKey);
}
if (config.googleAiModel) setGoogleAiModel(config.googleAiModel);
}
}, [fetchModels, fetchGoogleModels, setApiKey, setDatabase, setHost, setPassword, setPort, setSelectedModel, setSsl, setUser]);
}, [setDatabase, setHost, setPassword, setPort, setSsl, setUser]);
useEffect(() => {
// `isMounted` sert à éviter un écart d'hydratation : il doit donc être posé
// au montage, ce que la règle déconseille en général mais qui est ici le but.
// eslint-disable-next-line react-hooks/set-state-in-effect
setIsMounted(true);
reloadFromServer();
}, [reloadFromServer]);
@@ -223,7 +309,7 @@ export default function SettingsPage() {
const handleSave = async () => {
setSaveStatus("saving");
const url = getDatabaseUrl();
const res = await saveDatabaseSettings(url, apiKey, selectedModel, aiProvider, googleAiKey, googleAiModel);
const res = await saveDatabaseSettings(url);
if (res.success) {
setSaveStatus("saved");
setTimeout(() => setSaveStatus("idle"), 2500);
@@ -336,143 +422,7 @@ export default function SettingsPage() {
{/* API FF Nancy */}
<FfApiStatusSection />
{/* AI Provider selector */}
<Section title="Admin AI Chat — Fournisseur" subtitle="Choisissez le fournisseur IA utilisé dans l'onglet Admin AI Chat">
<div className="flex gap-2">
<button
onClick={() => setAiProvider("openrouter")}
className={`flex-1 py-2.5 px-4 rounded-lg border text-[13px] font-medium transition-colors ${aiProvider === "openrouter" ? "border-[var(--accent)] bg-[var(--accent-bg)] text-[var(--accent)]" : "border-[var(--border)] text-[var(--text-secondary)] hover:bg-[var(--bg-elevated)]"}`}
>
OpenRouter
</button>
<button
onClick={() => setAiProvider("google")}
className={`flex-1 py-2.5 px-4 rounded-lg border text-[13px] font-medium transition-colors ${aiProvider === "google" ? "border-[var(--accent)] bg-[var(--accent-bg)] text-[var(--accent)]" : "border-[var(--border)] text-[var(--text-secondary)] hover:bg-[var(--bg-elevated)]"}`}
>
Google AI Studio
</button>
</div>
{aiProvider === "google" && (
<>
<Field label="Clé API Google AI Studio" hint="Disponible sur aistudio.google.com/apikey — commence par AIza...">
<div className="flex gap-2">
<div className="relative flex-1">
<input
type={showGoogleKey ? "text" : "password"}
placeholder="AIzaSy..."
value={googleAiKey}
onChange={(e) => setGoogleAiKey(e.target.value)}
className="apple-input font-mono pr-10"
/>
<button
onClick={() => setShowGoogleKey((v) => !v)}
className="absolute right-3 top-1/2 -translate-y-1/2 opacity-50 hover:opacity-100 transition-opacity"
>
{showGoogleKey ? <EyeOff className="w-4 h-4" style={{ color: "var(--text-primary)" }} /> : <Eye className="w-4 h-4" style={{ color: "var(--text-primary)" }} />}
</button>
</div>
<button
onClick={() => fetchGoogleModels(googleAiKey)}
disabled={!googleAiKey || googleModelsStatus === "loading"}
className="apple-btn-secondary h-9 px-4 whitespace-nowrap"
>
{googleModelsStatus === "loading" ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <RefreshCw className="w-3.5 h-3.5" />}
Charger les modèles
</button>
</div>
</Field>
<Field
label={`Modèle Google${googleModels.length > 0 ? ` (${googleModels.length} disponibles)` : ""}`}
hint={googleModelsStatus === "error" ? "⚠ Erreur — vérifiez votre clé puis rechargez" : googleModelsStatus === "idle" ? "Entrez votre clé et cliquez « Charger les modèles »" : undefined}
>
{googleModels.length > 0 ? (
<select
value={googleAiModel}
onChange={(e) => setGoogleAiModel(e.target.value)}
className="apple-input"
>
{googleModels.map((m) => (
<option key={m.id} value={m.id}>{m.name}</option>
))}
</select>
) : (
<input
type="text"
value={googleAiModel}
onChange={(e) => setGoogleAiModel(e.target.value)}
placeholder="ex: gemini-2.0-flash"
className="apple-input"
/>
)}
</Field>
</>
)}
{aiProvider === "openrouter" && (
<p className="text-[12px] text-[var(--text-muted)]">
Utilise la clé OpenRouter configurée ci-dessous et le modèle sélectionné.
</p>
)}
</Section>
{/* OpenRouter */}
<Section title="IA Copilot — OpenRouter" subtitle="Clé API pour les analyses de gammes par intelligence artificielle">
<Field label="Clé API" hint="Disponible sur openrouter.ai/keys — commence par sk-or-...">
<div className="flex gap-2">
<div className="relative flex-1">
<input
type={showKey ? "text" : "password"}
placeholder="sk-or-v1-..."
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
className="apple-input font-mono pr-10"
/>
<button
onClick={() => setShowKey((v) => !v)}
className="absolute right-3 top-1/2 -translate-y-1/2 transition-opacity hover:opacity-100 opacity-50"
>
{showKey ? <EyeOff className="w-4 h-4" style={{ color: "var(--text-primary)" }} /> : <Eye className="w-4 h-4" style={{ color: "var(--text-primary)" }} />}
</button>
</div>
<button
onClick={() => fetchModels(apiKey)}
disabled={!apiKey || modelsStatus === "loading"}
className="apple-btn-secondary h-9 px-4 whitespace-nowrap"
>
{modelsStatus === "loading" ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <RefreshCw className="w-3.5 h-3.5" />}
Charger les modèles
</button>
</div>
</Field>
<Field
label={`Modèle IA${models.length > 0 ? ` (${models.length} disponibles)` : ""}`}
hint={modelsStatus === "error" ? "⚠ Erreur — vérifiez votre clé puis rechargez" : modelsStatus === "idle" ? "Entrez votre clé et cliquez « Charger les modèles »" : undefined}
>
{models.length > 0 ? (
<select
value={selectedModel}
onChange={(e) => setSelectedModel(e.target.value)}
className="apple-input"
>
{models.map((m) => (
<option key={m.id} value={m.id}>
{m.free ? "🆓 " : ""}{m.name}
</option>
))}
</select>
) : (
<input
type="text"
value={selectedModel}
onChange={(e) => setSelectedModel(e.target.value)}
placeholder="ex: google/gemini-2.0-flash-001"
className="apple-input"
/>
)}
</Field>
</Section>
<QlikSettingsSection />
{/* Apparence */}
<Section title="Apparence">
@@ -509,25 +459,12 @@ export default function SettingsPage() {
</div>
</Section>
{/* Score Produit */}
<Section title="Score Produit" subtitle="Formule hybride absolue + relative (v4)">
<div className="bg-[var(--surface-tertiary)] rounded-lg p-3 space-y-2">
<p className="text-[11px] text-[var(--text-secondary)]">
<strong>Formule :</strong> Volume (50pts) + CA (30pts) + Marge (20pts) + Bonus relatif (±10pts)
</p>
<p className="text-[11px] text-[var(--text-muted)]">
Le score est basé sur des paliers absolus sensibles au prix unitaire, avec un ajustement relatif selon la position dans le lot fournisseur.
</p>
<div className="border-t border-[var(--border-subtle)] pt-2 mt-2 space-y-1">
<p className="text-[11px] text-[var(--text-secondary)]"><strong>Volume</strong> (0-50 pts) — unités / magasin / mois</p>
<p className="text-[11px] text-[var(--text-secondary)]"><strong>CA</strong> (0-30 pts) — € / magasin / an</p>
<p className="text-[11px] text-[var(--text-secondary)]"><strong>Marge</strong> (0-20 pts) — taux de marge %</p>
<p className="text-[11px] text-[var(--text-secondary)]"><strong>Bonus relatif</strong> (±10 pts) — percentile composite dans le lot</p>
</div>
<p className="text-[11px] text-[var(--text-muted)] mt-2">
Pénalité de régularité appliquée si inactivité prolongée. Score final borné 0-100.
</p>
</div>
{/* Journaux serveur — diagnostic des extractions Qlik */}
<Section
title="Journaux serveur"
subtitle="Journal complet des synchronisations Qlik, à télécharger pour diagnostic"
>
<ServerLogs />
</Section>
{/* Gestion des Utilisateurs */}
@@ -538,6 +475,28 @@ export default function SettingsPage() {
<UserManagement />
</Section>
{/* API publique /api/v1 — connexion puis gestion des clés */}
<Section
title="API CollectFlow — Connexion"
subtitle="Tout ce qu'il faut pour appeler l'API depuis un script ou un outil externe"
>
<ApiConnectionInfo />
</Section>
<Section
title="Clés d'API"
subtitle="Créer et révoquer les clés d'accès à /api/v1"
>
<ApiKeyManagement />
</Section>
<Section
title="Données exposées à l'API"
subtitle="Calculer l'instantané de tous les fournisseurs pour qu'une IA externe y ait accès"
>
<GridWarmup />
</Section>
{/* Save Button — Floating/Sticky style at bottom */}
<div className="sticky bottom-6 flex justify-end pt-4 pb-2">
<button
-491
View File
@@ -1,491 +0,0 @@
import { NextRequest } from "next/server";
import { auth } from "@/lib/auth";
import fs from "fs/promises";
import path from "path";
import { GoogleGenAI, Type } from "@google/genai";
interface AiConfig {
provider: "openrouter" | "google";
openRouterKey?: string;
googleAiKey?: string;
googleAiModel?: string;
}
async function getAiConfig(): Promise<AiConfig> {
try {
const configFile = path.join(process.cwd(), "data", ".db-config.json");
const data = await fs.readFile(configFile, "utf-8");
const config = JSON.parse(data);
return {
provider: config.aiProvider ?? "openrouter",
openRouterKey: config.openRouterKey ?? process.env.OPENROUTER_API_KEY,
googleAiKey: config.googleAiKey ?? process.env.GOOGLE_AI_KEY,
googleAiModel: config.googleAiModel ?? "gemini-2.0-flash",
};
} catch {
return {
provider: "openrouter",
openRouterKey: process.env.OPENROUTER_API_KEY,
};
}
}
const OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions";
const FF_API_BASE = process.env.FF_API_BASE_URL ?? "https://api.ffnancy.fr";
// ─── Tool definitions ────────────────────────────────────────────────────────
const TOOLS_OPENROUTER = [
{
type: "function",
function: {
name: "call_api",
description: "Appelle un endpoint de l'API FF Nancy pour récupérer des données (articles, stock, ventes, fournisseurs, commandes, etc.).",
parameters: {
type: "object",
properties: {
endpoint: {
type: "string",
description: "Chemin de l'endpoint, ex: /api/articles?codefou=FOU001&limit=50 ou /api/stock/article/12345",
},
},
required: ["endpoint"],
},
},
},
];
const TOOLS_GOOGLE = [{
functionDeclarations: [
{
name: "call_api",
description: "Appelle un endpoint de l'API FF Nancy pour récupérer des données (articles, stock, ventes, fournisseurs, commandes, etc.).",
parameters: {
type: Type.OBJECT,
properties: {
endpoint: {
type: Type.STRING,
description: "Chemin de l'endpoint, ex: /api/articles?codefou=FOU001&limit=50 ou /api/stock/article/12345",
},
},
required: ["endpoint"],
},
},
],
}];
// ─── API caller ───────────────────────────────────────────────────────────────
async function callFfApi(endpoint: string): Promise<string> {
// Sécurité : uniquement GET, uniquement les endpoints documentés
const allowedPrefixes = [
"/api/health", "/api/sync", "/api/schema",
"/api/articles", "/api/stock", "/api/fournisseurs",
"/api/commandes", "/api/mouvements", "/api/performance",
"/api/publicites", "/api/commandes-auto",
];
const isAllowed = allowedPrefixes.some(p => endpoint.startsWith(p));
if (!isAllowed) {
return `Erreur: endpoint non autorisé — "${endpoint}". Utilise uniquement les endpoints documentés.`;
}
try {
const url = `${FF_API_BASE}${endpoint}`;
const res = await fetch(url, {
headers: { "Content-Type": "application/json" },
cache: "no-store",
});
if (!res.ok) {
return `Erreur API ${res.status}: ${await res.text()}`;
}
const data = await res.json();
const text = JSON.stringify(data);
// Tronquer si trop long (max 6000 chars)
if (text.length > 6000) {
return text.substring(0, 6000) + `\n...(tronqué, ${text.length} caractères total)`;
}
return text;
} catch (err) {
const msg = err instanceof Error ? err.message : "Erreur inconnue";
return `Erreur réseau: ${msg}`;
}
}
// ─── System prompt ────────────────────────────────────────────────────────────
const SYSTEM_PROMPT = `Tu es un assistant IA administrateur expert pour l'application CollectFlow, un outil de gestion de gammes produits et d'analyse des ventes pour des magasins de distribution alimentaire (Foirfouille Nancy).
## Outil disponible
- \`call_api\` : appelle un endpoint GET de l'API FF Nancy et retourne les données JSON
## API FF Nancy — Documentation complète
Base URL : ${FF_API_BASE}
Format des dates : YYYY-MM-DD
Identifiants articles : toujours \`no_id\` (entier interne)
Codes site : ex 000, 001, 002…
### Santé / Infra
- GET /api/health — vérifie que l'API et PostgreSQL répondent
- GET /api/sync/status — état de la synchronisation en cours
- GET /api/schema/tables?search=nom — liste les tables PG
- GET /api/schema/tables/:nom — colonnes d'une table
- GET /api/schema/top?limit=10 — tables les plus peuplées
### Articles
- GET /api/articles?search=&codein=&ean=&codefou=&actif=1&page=1&limit=50 — liste avec PA, GTIN, fournisseur principal (max 500/page)
- GET /api/articles/:id — détail complet d'un article (no_id)
- GET /api/articles/:id/referentiel — fournisseurs, tarifs, codes EAN
- GET /api/articles/:id/mouvements — historique des mouvements
- GET /api/articles/:id/mensuel — CA mensuel de l'article
Le champ **prix d'achat (PA)** est retourné directement dans GET /api/articles (champ \`pa\` ou \`prix_achat\`).
Pour un article précis : GET /api/articles/:id/referentiel pour voir tous les tarifs fournisseurs.
### Stock
- GET /api/stock?site=001&page=1&limit=100 — stock global tous articles (max 1000/page)
- GET /api/stock/article/:id — stock par site pour un article (no_id) → contient aussi \`prmp\` (Prix de Revient Moyen Pondéré)
- GET /api/stock/site/:site — tous les articles d'un site donné
- GET /api/stock/article/:id/historique — historique stock de l'article
- GET /api/stock/article/:id/periode?dateDebut=&dateFin= — stock sur période
- GET /api/stock/valorisation — valeur totale du stock par site
Le **PRMP** (prix réel moyen pondéré) est dans GET /api/stock/article/:id, champ \`prmp\` par site.
### Fournisseurs
- GET /api/fournisseurs?search=nom — liste des fournisseurs (champs : code, nom)
- GET /api/fournisseurs/:code/articles — articles du fournisseur
- GET /api/fournisseurs/:code/commandes — commandes du fournisseur
### Commandes
- GET /api/commandes?dateDebut=&dateFin=&codefou= — liste des commandes fournisseurs
- GET /api/commandes/:noCommande — détail d'une commande + ses lignes
- GET /api/commandes/receptions/liste — liste des réceptions
### Commandes automatiques
- GET /api/commandes-auto?site=&codefou= — résumé des propositions par fournisseur/site avec comparaison franco
Champs : franco_ht, montant_propo_ht, franco_atteint (OUI/NON/N/A), ecart_franco
- GET /api/commandes-auto/:codefou?site= — détail d'un fournisseur : résumé + liste des articles proposés avec stock et montant
### Mouvements
- GET /api/mouvements/types — types de mouvements disponibles
- GET /api/mouvements/articles?dateDebut=&dateFin=&site= — mouvements par article
- GET /api/mouvements/entrees?dateDebut=&dateFin= — entrées marchandises
- GET /api/mouvements/regularisations?dateDebut=&dateFin= — régularisations inventaire
- GET /api/mouvements/reglements?dateDebut=&dateFin= — règlements
- GET /api/mouvements/synthese?dateDebut=&dateFin=&site= — synthèse des mouvements
### Performance / CA
- GET /api/performance/ca?dateDebut=&dateFin=&site=&groupBy=mois — CA global (groupBy: jour ou mois)
- GET /api/performance/hitparade?dateDebut=&dateFin=&site=&limit=20&groupBy=ca — top articles (groupBy: qte, ca ou marge)
- GET /api/performance/ca/nomenclature?dateDebut=&dateFin=&site=&niveau= — CA par nomenclature
- GET /api/performance/ca/fournisseur?dateDebut=&dateFin=&site= — CA par fournisseur
- GET /api/performance/ca/gamme?dateDebut=&dateFin=&site= — CA par gamme (A/B/C/D/Z)
- GET /api/performance/dashboard — indicateurs résumés pour dashboard
### Publicités
- GET /api/publicites?search=&site=&dateDebut=&dateFin=&page=1&limit=50 — liste des publicités
- GET /api/publicites/:code?site= — détail d'une publicité
## Contexte métier CollectFlow
- Application de gestion de gammes pour magasins Foirfouille Nancy
- Gammes A/B/C/D/Z : A=pilier, B=bonne rotation, C=performance, D=saisonnier/niche, Z=sortie
- Les ventes sont suivies par magasin (site) et par mois
- Les utilisateurs arbitrent les classifications gamme par fournisseur
## Stratégie d'analyse
1. Si tu ne connais pas un code fournisseur ou un no_id, commence par chercher avec /api/fournisseurs?search= ou /api/articles?search=
2. Enchaîne plusieurs appels \`call_api\` si nécessaire pour compléter l'analyse
3. Pour le prix d'achat : utilise /api/articles?codefou=CODE (champ pa) ou /api/articles/:id/referentiel
4. Pour le PRMP (prix réel) : utilise /api/stock/article/:id (champ prmp par site)
5. Ajoute toujours \`limit\` pour éviter des réponses trop volumineuses (limit=20 à 100 selon le contexte)
## Format de réponse — IMPORTANT
- Réponds TOUJOURS en **Markdown** : titres ##, **gras**, tableaux \`| col | col |\`, listes \`-\`, blocs de code
- N'utilise JAMAIS de HTML (<div>, <table>, <b>, <br>, etc.)
- Réponds toujours en français
- Sois précis, structuré et complet — ne tronque jamais ta réponse
- Si les données sont nombreuses, résume et mets en avant les points clés`;
// ─── OpenRouter handler ───────────────────────────────────────────────────────
async function callOpenRouter(
messages: object[],
model: string,
apiKey: string,
useTools: boolean,
stream: boolean
): Promise<Response> {
const body: Record<string, unknown> = {
model,
messages,
temperature: 0.3,
max_tokens: 8192,
};
if (useTools) {
body.tools = TOOLS_OPENROUTER;
body.tool_choice = "auto";
}
if (stream) {
body.stream = true;
body.stream_options = { include_usage: true };
}
return fetch(OPENROUTER_URL, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"HTTP-Referer": "https://collectflow.app",
"X-Title": "CollectFlow Admin Chat",
},
body: JSON.stringify(body),
});
}
// ─── Google AI handler ────────────────────────────────────────────────────────
async function handleGoogleAi(messages: Array<{ role: string; content: string }>, config: AiConfig): Promise<Response> {
if (!config.googleAiKey) {
return new Response(JSON.stringify({ error: "No Google AI key configured" }), { status: 500 });
}
const model = config.googleAiModel ?? "gemini-2.0-flash";
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
function send(event: object) {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
}
try {
const ai = new GoogleGenAI({ apiKey: config.googleAiKey! });
const contents: Array<{ role: string; parts: any[] }> = [
...messages.map((m) => ({
role: m.role === "assistant" ? "model" : "user",
parts: [{ text: m.content }],
})),
];
for (let round = 0; round < 10; round++) {
const streamResponse = await ai.models.generateContentStream({
model,
contents: contents as any,
config: {
systemInstruction: SYSTEM_PROMPT,
tools: TOOLS_GOOGLE as any,
maxOutputTokens: 8192,
},
});
let collectedText = "";
const collectedParts: any[] = [];
for await (const chunk of streamResponse) {
const text = chunk.text;
if (text) {
collectedText += text;
send({ type: "text", content: text });
}
const parts = chunk.candidates?.[0]?.content?.parts;
if (parts) collectedParts.push(...parts);
}
const fnCallParts = collectedParts.filter((p: any) => p.functionCall);
if (fnCallParts.length === 0) break;
contents.push({ role: "model", parts: collectedText ? collectedParts : fnCallParts });
const toolResultParts: Array<{ functionResponse: { name: string; response: { output: string } } }> = [];
for (const part of fnCallParts) {
const fn = part.functionCall as { name: string; args: Record<string, unknown> };
const args = fn.args ?? {};
send({ type: "tool_start", name: fn.name, args });
let result = "";
if (fn.name === "call_api") {
result = await callFfApi(args.endpoint as string);
} else {
result = `Outil inconnu: ${fn.name}`;
}
send({ type: "tool_result", name: fn.name, result: result.substring(0, 500) + (result.length > 500 ? "..." : "") });
toolResultParts.push({ functionResponse: { name: fn.name, response: { output: result } } });
}
contents.push({ role: "user", parts: toolResultParts as any });
}
send({ type: "usage", prompt_tokens: 0, completion_tokens: 0 });
send({ type: "done" });
controller.close();
} catch (err) {
const msg = err instanceof Error ? err.message : "Erreur inconnue";
send({ type: "error", message: msg });
controller.close();
}
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
}
// ─── POST handler ─────────────────────────────────────────────────────────────
export async function POST(req: NextRequest) {
const session = await auth();
if (!session || (session.user as any)?.role !== "admin") {
return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 403 });
}
const { messages, model } = await req.json();
const aiConfig = await getAiConfig();
if (aiConfig.provider === "google") {
return handleGoogleAi(messages, aiConfig);
}
const apiKey = aiConfig.openRouterKey;
if (!apiKey) {
return new Response(JSON.stringify({ error: "No OpenRouter API key configured" }), { status: 500 });
}
const fullMessages: object[] = [
{ role: "system", content: SYSTEM_PROMPT },
...messages,
];
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
function send(event: object) {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
}
try {
let currentMessages = [...fullMessages];
let totalPromptTokens = 0;
let totalCompletionTokens = 0;
let directAnswer: string | null = null;
for (let round = 0; round < 10; round++) {
const res = await callOpenRouter(currentMessages, model, apiKey, true, false);
if (!res.ok) {
send({ type: "error", message: `OpenRouter error: ${await res.text()}` });
controller.close();
return;
}
const data = await res.json();
if (data.usage) {
totalPromptTokens += data.usage.prompt_tokens ?? 0;
totalCompletionTokens += data.usage.completion_tokens ?? 0;
}
const assistantMessage = data.choices?.[0]?.message;
if (!assistantMessage) break;
currentMessages.push(assistantMessage);
const toolCalls = assistantMessage.tool_calls;
if (!toolCalls || toolCalls.length === 0) {
directAnswer = assistantMessage.content ?? "";
break;
}
const toolResults: object[] = [];
for (const tc of toolCalls) {
const fnName = tc.function?.name;
let args: Record<string, unknown> = {};
try { args = JSON.parse(tc.function?.arguments ?? "{}"); } catch { /* ignore */ }
send({ type: "tool_start", name: fnName, args });
let result = "";
if (fnName === "call_api") {
result = await callFfApi(args.endpoint as string);
} else {
result = `Outil inconnu: ${fnName}`;
}
send({ type: "tool_result", name: fnName, result: result.substring(0, 500) + (result.length > 500 ? "..." : "") });
toolResults.push({ role: "tool", tool_call_id: tc.id, content: result });
}
currentMessages.push(...toolResults);
}
if (directAnswer !== null) {
send({ type: "text", content: directAnswer });
send({ type: "usage", prompt_tokens: totalPromptTokens, completion_tokens: totalCompletionTokens });
send({ type: "done" });
controller.close();
return;
}
// Final streaming call after tool use
const finalRes = await callOpenRouter(currentMessages, model, apiKey, false, true);
if (!finalRes.ok) {
send({ type: "error", message: `OpenRouter error: ${await finalRes.text()}` });
controller.close();
return;
}
const reader = finalRes.body?.getReader();
if (!reader) { controller.close(); return; }
const textDecoder = new TextDecoder();
let buffer = "";
let finalUsage: { prompt_tokens?: number; completion_tokens?: number } | null = null;
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += textDecoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
const raw = line.slice(6).trim();
if (raw === "[DONE]") continue;
try {
const chunk = JSON.parse(raw);
const delta = chunk.choices?.[0]?.delta?.content;
if (delta) send({ type: "text", content: delta });
if (chunk.usage) finalUsage = chunk.usage;
} catch { /* ignore */ }
}
}
if (finalUsage) {
totalPromptTokens += finalUsage.prompt_tokens ?? 0;
totalCompletionTokens += finalUsage.completion_tokens ?? 0;
}
send({ type: "usage", prompt_tokens: totalPromptTokens, completion_tokens: totalCompletionTokens });
send({ type: "done" });
controller.close();
} catch (err) {
const msg = err instanceof Error ? err.message : "Erreur inconnue";
send({ type: "error", message: msg });
controller.close();
}
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
}
-24
View File
@@ -1,24 +0,0 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import fs from "fs/promises";
import path from "path";
export async function GET() {
const session = await auth();
if (!session || (session.user as any)?.role !== "admin") {
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
}
try {
const configFile = path.join(process.cwd(), "data", ".db-config.json");
const data = await fs.readFile(configFile, "utf-8");
const config = JSON.parse(data);
return NextResponse.json({
provider: config.aiProvider ?? "openrouter",
openRouterModel: config.openRouterModel,
googleAiModel: config.googleAiModel,
});
} catch {
return NextResponse.json({ provider: "openrouter" });
}
}
+144
View File
@@ -0,0 +1,144 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { pgGetFournisseurs } from "@/lib/pg-ff-client";
import { getProductRows } from "@/features/grid/api/get-product-rows";
import { listGridSuppliers } from "@/lib/grid-store";
// Le préchauffage parcourt tous les fournisseurs : c'est long par nature. On rend la
// main immédiatement et le client interroge GET pour suivre l'avancement.
export const maxDuration = 300;
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* Préchauffage de l'instantané de grille (`grid_rows`).
*
* L'API `/api/v1` ne calcule jamais : elle lit un instantané écrit quand la Grille est
* ouverte dans l'application. Sans préchauffage, un consommateur externe (ChatGPT) ne
* verrait donc que les fournisseurs déjà consultés à la main.
*
* Ce job calcule chaque fournisseur une fois, séquentiellement — la persistance se fait
* en effet de bord de `getProductRows()`. Séquentiel volontairement : le calcul est
* lourd en SQL, le paralléliser saturerait PostgreSQL.
*/
type WarmupStatus = "idle" | "running" | "success" | "error";
interface WarmupJob {
status: WarmupStatus;
total: number;
done: number;
skipped: number;
failed: number;
currentFournisseur?: string;
startedAt: string;
finishedAt?: string;
error?: string;
lastErrors: string[];
}
let job: WarmupJob | null = null;
function publicJob(j: WarmupJob | null) {
if (!j) {
return { status: "idle" as const, total: 0, done: 0, skipped: 0, failed: 0, lastErrors: [] };
}
return j;
}
async function requireAdmin(): Promise<NextResponse | null> {
const session = await auth();
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if ((session.user as { role?: string } | undefined)?.role !== "admin") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
return null;
}
async function runWarmup(j: WarmupJob, staleHours: number): Promise<void> {
try {
const fournisseurs = await pgGetFournisseurs();
// Les fournisseurs déjà calculés récemment sont sautés : un second passage
// coûte alors quasiment rien, ce qui rend le bouton rejouable sans crainte.
const existing = await listGridSuppliers();
const freshLimit = Date.now() - staleHours * 3600_000;
const fresh = new Set(
existing
.filter((s) => s.computedAt && new Date(s.computedAt).getTime() > freshLimit)
.map((s) => s.codeFournisseur),
);
j.total = fournisseurs.length;
console.log(`[grid-warmup] ${fournisseurs.length} fournisseurs, ${fresh.size} déjà à jour (< ${staleHours}h)`);
for (const f of fournisseurs) {
if (fresh.has(f.code)) {
j.skipped++;
continue;
}
j.currentFournisseur = `${f.code} — ${f.nom}`;
try {
await getProductRows({ codeFournisseur: f.code, magasin: "TOTAL", forceRefresh: true });
j.done++;
} catch (e) {
j.failed++;
const msg = `${f.code}: ${e instanceof Error ? e.message.slice(0, 120) : String(e)}`;
// On garde une trace des derniers échecs sans faire exploser la mémoire.
if (j.lastErrors.length < 10) j.lastErrors.push(msg);
console.error("[grid-warmup]", msg);
}
}
j.currentFournisseur = undefined;
j.status = "success";
j.finishedAt = new Date().toISOString();
console.log(`[grid-warmup] terminé — ${j.done} calculés, ${j.skipped} à jour, ${j.failed} en échec`);
} catch (e) {
j.status = "error";
j.error = e instanceof Error ? e.message : String(e);
j.finishedAt = new Date().toISOString();
console.error("[grid-warmup] échec global:", j.error);
}
}
/** GET /api/admin/grid-warmup — avancement du préchauffage. */
export async function GET() {
const denied = await requireAdmin();
if (denied) return denied;
return NextResponse.json({ success: true, ...publicJob(job) });
}
/**
* POST /api/admin/grid-warmup?staleHours=24
* Démarre le préchauffage en arrière-plan et répond immédiatement.
*/
export async function POST(req: NextRequest) {
const denied = await requireAdmin();
if (denied) return denied;
if (job?.status === "running") {
return NextResponse.json({ success: true, ...publicJob(job) });
}
const raw = Number(req.nextUrl.searchParams.get("staleHours"));
const staleHours = Number.isFinite(raw) ? Math.min(720, Math.max(0, raw)) : 24;
job = {
status: "running",
total: 0,
done: 0,
skipped: 0,
failed: 0,
startedAt: new Date().toISOString(),
lastErrors: [],
};
const current = job;
console.log(`[grid-warmup] démarré (staleHours=${staleHours})`);
void runWarmup(current, staleHours).catch((e) => {
current.status = "error";
current.error = e instanceof Error ? e.message : String(e);
current.finishedAt = new Date().toISOString();
});
return NextResponse.json({ success: true, ...publicJob(job) });
}
@@ -0,0 +1,100 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { asc, eq, inArray, sql } from "drizzle-orm";
import { auth } from "@/lib/auth";
import { db } from "@/db";
import { syncFournisseurs } from "@/db/schema";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
async function requireAdmin(): Promise<NextResponse | null> {
const session = await auth();
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if ((session.user as { role?: string } | undefined)?.role !== "admin") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
return null;
}
/**
* GET — la liste complète des fournisseurs paramétrés, avec l'état de leur
* dernière synchronisation.
*
* Pas de pagination : quelques centaines de lignes tout au plus, et l'admin a
* besoin de la vue d'ensemble pour décider qui cadencer.
*/
export async function GET() {
const denied = await requireAdmin();
if (denied) return denied;
const rows = await db
.select()
.from(syncFournisseurs)
.orderBy(asc(sql`coalesce(${syncFournisseurs.nomFournisseur}, ${syncFournisseurs.codeFournisseur})`));
return NextResponse.json({
success: true,
fournisseurs: rows,
resume: {
total: rows.length,
actifsSql: rows.filter((r) => r.actifSql).length,
actifsQlik: rows.filter((r) => r.actifQlik).length,
desactivesAuto: rows.filter((r) => r.desactiveMotif).length,
enEchecSql: rows.filter((r) => r.dernierSqlStatut === "echec").length,
enEchecQlik: rows.filter((r) => r.dernierQlikStatut === "echec").length,
jamaisSql: rows.filter((r) => !r.dernierSqlAt).length,
},
});
}
const patchSchema = z.object({
/** Codes visés. Permet d'activer ou désactiver en masse depuis la page. */
codes: z.array(z.string().min(1)).min(1).max(2000),
actifSql: z.boolean().optional(),
actifQlik: z.boolean().optional(),
}).refine((v) => v.actifSql !== undefined || v.actifQlik !== undefined, {
message: "Préciser au moins actifSql ou actifQlik",
});
/**
* PATCH — active ou désactive des fournisseurs, pour l'une ou l'autre source.
*
* Réactiver lève le motif de désactivation automatique : sans cela, un
* fournisseur remis en service par l'admin garderait un « aucun article »
* trompeur, alors même qu'il vient d'être rouvert volontairement.
*/
export async function PATCH(req: NextRequest) {
const denied = await requireAdmin();
if (denied) return denied;
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Corps JSON invalide" }, { status: 400 });
}
const parsed = patchSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: "Paramètres invalides", details: parsed.error.issues.map((i) => i.message) },
{ status: 400 },
);
}
const { codes, actifSql, actifQlik } = parsed.data;
const reactivation = actifSql === true || actifQlik === true;
const rows = await db
.update(syncFournisseurs)
.set({
...(actifSql !== undefined ? { actifSql } : {}),
...(actifQlik !== undefined ? { actifQlik } : {}),
...(reactivation ? { desactiveMotif: null } : {}),
updatedAt: new Date(),
})
.where(codes.length === 1 ? eq(syncFournisseurs.codeFournisseur, codes[0]) : inArray(syncFournisseurs.codeFournisseur, codes))
.returning({ code: syncFournisseurs.codeFournisseur });
console.log(`[api/admin/sync] ${rows.length} fournisseur(s) mis à jour (sql=${actifSql} qlik=${actifQlik})`);
return NextResponse.json({ success: true, misAJour: rows.length });
}
+125
View File
@@ -0,0 +1,125 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/lib/auth";
import { readSyncSettings, saveSyncSettings } from "@/features/admin/api/sync-settings";
import {
getSyncSchedulerState,
lancerRound,
demanderArret,
debutFenetre,
seedSyncFournisseurs,
} from "@/features/admin/api/sync-scheduler";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
// Un lancement manuel peut enchaîner plusieurs fournisseurs.
export const maxDuration = 300;
async function requireAdmin(): Promise<NextResponse | null> {
const session = await auth();
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if ((session.user as { role?: string } | undefined)?.role !== "admin") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
return null;
}
/** GET — réglages, état courant, et prochaine ouverture de fenêtre. */
export async function GET() {
const denied = await requireAdmin();
if (denied) return denied;
const settings = await readSyncSettings();
const maintenant = new Date();
const debut = debutFenetre(settings, maintenant);
return NextResponse.json({
success: true,
settings,
state: getSyncSchedulerState(),
/** true = on est actuellement dans la fenêtre nocturne. */
dansLaFenetre: debut !== null,
fenetreDebutAt: debut?.toISOString() ?? null,
maintenant: maintenant.toISOString(),
});
}
const settingsSchema = z.object({
actif: z.boolean().optional(),
heureDebut: z.coerce.number().int().min(0).max(23).optional(),
heureFin: z.coerce.number().int().min(0).max(23).optional(),
qlikParNuit: z.coerce.number().int().min(0).max(200).optional(),
sqlParQlik: z.coerce.number().int().min(1).max(500).optional(),
qlikMinJours: z.coerce.number().int().min(0).max(365).optional(),
});
/** PATCH — met à jour les réglages. */
export async function PATCH(req: NextRequest) {
const denied = await requireAdmin();
if (denied) return denied;
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Corps JSON invalide" }, { status: 400 });
}
const parsed = settingsSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: "Paramètres invalides", details: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`) },
{ status: 400 },
);
}
const settings = await saveSyncSettings(parsed.data);
return NextResponse.json({ success: true, settings });
}
const actionSchema = z.object({
action: z.enum(["start", "stop", "seed"]),
/**
* Lancement manuel hors de la fenêtre nocturne. Réservé à l'admin qui sait
* ce qu'il fait : en pleine journée, la charge est visible par les magasins.
*/
forcer: z.boolean().optional(),
});
/** POST — démarre un round manuellement, l'arrête, ou resynchronise la liste. */
export async function POST(req: NextRequest) {
const denied = await requireAdmin();
if (denied) return denied;
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Corps JSON invalide" }, { status: 400 });
}
const parsed = actionSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Action inconnue (start, stop ou seed)" }, { status: 400 });
}
if (parsed.data.action === "seed") {
const n = await seedSyncFournisseurs();
return NextResponse.json({ success: true, fournisseurs: n });
}
if (parsed.data.action === "stop") {
demanderArret();
return NextResponse.json({ success: true, state: getSyncSchedulerState() });
}
const etat = getSyncSchedulerState();
if (etat.enCours) {
return NextResponse.json({ error: "Un round est déjà en cours.", state: etat }, { status: 409 });
}
// Fire-and-forget : le round dure des minutes, le client suit via GET.
void lancerRound("manuel", parsed.data.forcer === true).catch((e) => {
console.error("[api/admin/sync] round manuel en échec :", e instanceof Error ? e.message : String(e));
});
return NextResponse.json({ success: true, state: getSyncSchedulerState() });
}
+19 -5
View File
@@ -1,10 +1,24 @@
import { NextResponse } from "next/server";
import { getSyncStatus } from "@/lib/api-ff-client";
import { testFfApiConnection } from "@/features/settings/actions";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* GET /api/ff-status — état de l'API REST FF Nancy.
*
* S'appuie sur le même diagnostic que le bouton « Tester » des Paramètres, afin
* qu'un échec dise *pourquoi* (DNS, connexion refusée, délai dépassé, code HTTP)
* et *quelle adresse* a été appelée. L'ancienne version renvoyait un 503 sans
* détail, impossible à distinguer d'une simple erreur d'URL.
*/
export async function GET() {
const status = await getSyncStatus();
if (!status) {
return NextResponse.json({ error: "API FF Nancy non disponible" }, { status: 503 });
const res = await testFfApiConnection();
if (!res.success) {
return NextResponse.json(
{ error: "API FF Nancy non disponible", url: res.url, detail: res.error },
{ status: 503 },
);
}
return NextResponse.json(status);
return NextResponse.json(res.status);
}
-61
View File
@@ -1,61 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import fs from "fs/promises";
import path from "path";
async function getSavedGoogleKey(): Promise<string | null> {
try {
const configFile = path.join(process.cwd(), "data", ".db-config.json");
const data = await fs.readFile(configFile, "utf-8");
const config = JSON.parse(data);
return config.googleAiKey ?? null;
} catch {
return null;
}
}
export async function GET(req: NextRequest) {
const apiKey = req.headers.get("x-google-ai-key")
|| process.env.GOOGLE_AI_KEY
|| await getSavedGoogleKey();
if (!apiKey) {
return NextResponse.json({ error: "No Google AI key provided" }, { status: 401 });
}
try {
const res = await fetch(
`https://generativelanguage.googleapis.com/v1beta/models?key=${apiKey}&pageSize=100`,
{ cache: "no-store" }
);
if (!res.ok) {
const err = await res.text();
return NextResponse.json({ error: err }, { status: res.status });
}
const data = await res.json();
// Keep only generative models that support generateContent
const models = (data.models as Array<{
name: string;
displayName: string;
supportedGenerationMethods?: string[];
description?: string;
}>)
.filter(m =>
m.supportedGenerationMethods?.includes("generateContent") &&
!m.name.includes("embedding") &&
!m.name.includes("aqa")
)
.map(m => ({
id: m.name.replace("models/", ""),
name: m.displayName,
}))
.sort((a, b) => a.name.localeCompare(b.name, "fr"));
return NextResponse.json({ models });
} catch (err) {
const msg = err instanceof Error ? err.message : "Unknown error";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
+2 -1
View File
@@ -18,10 +18,11 @@ export async function GET(request: NextRequest) {
const magasin = searchParams.get("magasin") ?? "TOTAL";
const chunkSize = Math.max(50, Math.min(500, Number(searchParams.get("chunkSize") ?? 150)));
// `code3` n'est volontairement pas lu : la Grille applique ce filtre en local
// (getProductRows ne s'en sert pas) et il accepte désormais plusieurs valeurs.
const filters: Partial<GridFilters> = {
code1: searchParams.get("code1"),
code2: searchParams.get("code2"),
code3: searchParams.get("code3"),
};
const forceRefresh = searchParams.get("refresh") === "1";
+54
View File
@@ -0,0 +1,54 @@
/**
* CollectFlow — Journaux serveur des jobs (liste + téléchargement).
*
* `GET /api/logs` → liste des journaux disponibles (JSON)
* `GET /api/logs?id=<jobId>` → le journal complet en pièce jointe (text/plain)
*
* Réservé aux admins : un journal d'extraction contient les diagnostics internes
* du modèle Qlik et les codes articles manipulés. Aucun secret n'y transite —
* l'extracteur ne journalise que le NOM du cookie de session, jamais sa valeur,
* et jamais le mot de passe Qlik.
*/
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { listCaptures, readCapture } from "@/lib/log-capture";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
async function requireAdmin(): Promise<NextResponse | null> {
const session = await auth();
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if ((session.user as { role?: string } | undefined)?.role !== "admin") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
return null;
}
export async function GET(req: NextRequest) {
const refus = await requireAdmin();
if (refus) return refus;
const id = req.nextUrl.searchParams.get("id");
if (!id) {
return NextResponse.json({ success: true, logs: await listCaptures() });
}
const contenu = await readCapture(id);
if (contenu == null) {
return NextResponse.json({ error: "Journal introuvable ou expiré" }, { status: 404 });
}
// Nom de fichier assaini : l'id vient d'un job interne, mais il finit dans
// un en-tête HTTP.
const nom = `${id.replace(/[^A-Za-z0-9_.-]/g, "_")}.log`;
return new NextResponse(contenu, {
status: 200,
headers: {
"Content-Type": "text/plain; charset=utf-8",
"Content-Disposition": `attachment; filename="${nom}"`,
"Cache-Control": "no-store",
},
});
}
-61
View File
@@ -1,61 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import fs from "fs/promises";
import path from "path";
async function getSavedOpenRouterKey(): Promise<string | null> {
try {
const configFile = path.join(process.cwd(), "data", ".db-config.json");
const data = await fs.readFile(configFile, "utf-8");
const config = JSON.parse(data);
return config.openRouterKey ?? null;
} catch {
return null;
}
}
export async function GET(req: NextRequest) {
const apiKey = req.headers.get("x-openrouter-key")
|| process.env.OPENROUTER_API_KEY
|| await getSavedOpenRouterKey();
if (!apiKey) {
return NextResponse.json({ error: "No API key provided" }, { status: 401 });
}
try {
const res = await fetch("https://openrouter.ai/api/v1/models", {
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
cache: "no-store",
});
if (!res.ok) {
return NextResponse.json({ error: "OpenRouter error", status: res.status }, { status: res.status });
}
const data = await res.json();
// Return simplified list sorted by name
const models = (data.data as Array<{ id: string; name: string; pricing?: { prompt: string; completion: string } }>)
.map((m) => ({
id: m.id,
name: m.name,
promptPrice: parseFloat(m.pricing?.prompt ?? "0"),
completionPrice: parseFloat(m.pricing?.completion ?? "0"),
free: parseFloat(m.pricing?.prompt ?? "1") === 0,
}))
.sort((a, b) => {
// Free models first, then alphabetical
if (a.free && !b.free) return -1;
if (!a.free && b.free) return 1;
return a.name.localeCompare(b.name, "fr");
});
return NextResponse.json({ models });
} catch (err) {
const msg = err instanceof Error ? err.message : "Unknown error";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
@@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { pgGetOpportunitesFamille } from "@/lib/pg-ff-client";
import { buildLast12MonthsRange } from "@/lib/api-ff-client";
export const dynamic = "force-dynamic";
/**
* GET /api/produits/opportunites?nomNoId=123
*
* Produits de la même nomenclature, classés par écart entre performance réseau
* et performance locale (quantités par magasin). Chargé à la demande depuis la
* fiche produit : la requête agrège `mvtart` sur 12 mois pour toute la famille,
* on ne veut pas la payer au chargement de la page.
*
* Le middleware Next ne protège pas `/api/*` : contrôle de session explicite.
*/
export async function GET(req: NextRequest) {
const session = await auth();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const raw = req.nextUrl.searchParams.get("nomNoId");
const nomNoId = Number(raw);
if (!raw || !Number.isInteger(nomNoId) || nomNoId <= 0) {
return NextResponse.json({ error: "Param 'nomNoId' invalide" }, { status: 400 });
}
try {
const { dateDebut, dateFin } = buildLast12MonthsRange();
const rows = await pgGetOpportunitesFamille(nomNoId, dateDebut, dateFin);
return NextResponse.json({ success: true, rows });
} catch (e) {
console.error("[api/produits/opportunites]", (e as Error).message?.slice(0, 250));
return NextResponse.json({ error: "Erreur serveur" }, { status: 500 });
}
}
+70
View File
@@ -0,0 +1,70 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { demarrerRecherche, etatRecherche } from "@/features/produits/api/search-produits";
// Les réponses sont immédiates (le travail Qlik tourne en tâche de fond), mais on
// garde le runtime Node.js : Playwright/Chromium ne tourne pas en edge, et l'état
// des jobs vit dans la mémoire du process.
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* Recherche produit **Qlik d'abord** — API asynchrone.
*
* `POST /api/produits/search?q=<terme>[&force=1]` démarre la recherche et rend la
* main tout de suite. `GET /api/produits/search?q=<terme>` renvoie l'avancement,
* puis le résultat.
*
* Pourquoi asynchrone : une recherche enchaîne deux allers-retours Qlik (dont une
* extraction mensuelle) et peut dépasser la minute. Une requête HTTP maintenue
* aussi longtemps se fait couper par le reverse proxy, qui répond une page
* d'erreur **HTML** — le client échouait alors sur « Unexpected token '<' … is
* not valid JSON ». Même schéma que `POST /api/qlik/sync`.
*
* Le middleware Next ne protège pas `/api/*` : contrôle de session explicite.
*/
async function requireSession(): Promise<NextResponse | null> {
const session = await auth();
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
return null;
}
function lireTerme(req: NextRequest): string | null {
const q = (req.nextUrl.searchParams.get("q") ?? "").trim();
return q.length >= 3 ? q : null;
}
export async function POST(req: NextRequest) {
const denied = await requireSession();
if (denied) return denied;
const q = lireTerme(req);
if (!q) return NextResponse.json({ error: "Saisissez au moins 3 caractères" }, { status: 400 });
const force = req.nextUrl.searchParams.get("force") === "1";
try {
const job = demarrerRecherche(q, { force });
return NextResponse.json({ success: true, ...job });
} catch (e) {
const message = (e as Error).message ?? String(e);
console.error("[api/produits/search] POST", message.slice(0, 300));
return NextResponse.json({ error: message.slice(0, 300) }, { status: 500 });
}
}
export async function GET(req: NextRequest) {
const denied = await requireSession();
if (denied) return denied;
const q = lireTerme(req);
if (!q) return NextResponse.json({ error: "Saisissez au moins 3 caractères" }, { status: 400 });
try {
return NextResponse.json({ success: true, ...etatRecherche(q) });
} catch (e) {
const message = (e as Error).message ?? String(e);
console.error("[api/produits/search] GET", message.slice(0, 300));
return NextResponse.json({ error: message.slice(0, 300) }, { status: 500 });
}
}
+437 -42
View File
@@ -1,62 +1,457 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { fetchNetworkMetrics } from "@/lib/qlik-client";
import { fetchNetworkMetricsPlaywright } from "@/lib/qlik-playwright";
import { upsertNetworkMetrics } from "@/lib/qlik-network-cache";
import { pgGetArticlesByFournisseur } from "@/lib/pg-ff-client";
import {
buildGridNetworkQlikDateFilter,
QLIK_MONTHS_BACK_DEFAULT,
} from "@/lib/qlik-date-range";
import { startCapture, stopCapture } from "@/lib/log-capture";
// Synchro potentiellement longue (extraction hypercube paginee).
// Tâche d'extraction Qlik potentiellement très longue (hypercube paginé).
// On accepte quand même 5 min côté plateforme Next.js, mais on rend la main au
// client tout de suite via le state job ci-dessous.
export const maxDuration = 300;
// Forcer le runtime Node.js : Playwright/Chromium ne tourne pas en edge et on a
// besoin d'un process persistant pour conserver l'état de job en mémoire.
export const runtime = "nodejs";
// Toujours exécuter dynamiquement (sinon Next peut essayer de cacher des POST).
export const dynamic = "force-dynamic";
/** État d'un job de sync Qlik pour un fournisseur donné. */
type QlikSyncStatus = "idle" | "running" | "success" | "error";
interface QlikSyncJob {
jobId: string;
/** Code fournisseur (mode fournisseur) — chaîne vide en mode produit. */
fournisseur: string;
/** Code centrale (mode produit) — absent en mode fournisseur. */
codeCentrale?: string;
status: QlikSyncStatus;
requested: number;
fetched: number;
upserted: number;
periode?: string;
dateDebut?: string;
dateFin?: string;
startedAt: string;
finishedAt?: string;
error?: string;
message?: string;
}
/**
* Store en mémoire au niveau module. Une Map par fournisseur car on ne lance
* qu'une extraction à la fois par fournisseur. C'est volontairement simple :
* si le process redémarre, les jobs en cours sont perdus → GET renverra idle
* et l'utilisateur pourra relancer. Suffisant pour corriger le bug UX.
*/
const jobs = new Map<string, QlikSyncJob>();
/**
* Clé de job. Le mode fournisseur garde sa clé historique (le code fournisseur
* brut) ; le mode produit est préfixé pour ne jamais entrer en collision.
*/
function jobKeyForProduct(codeCentrale: string): string {
return `cc:${codeCentrale.toUpperCase()}`;
}
/**
* Nombre maximum d'extractions produit simultanées.
*
* Le mode produit est ouvert à tous les utilisateurs connectés (contrairement au
* mode fournisseur, réservé aux admins). Une extraction sur 1 seul code est très
* légère, mais le serveur Qlik est sujet aux saturations mémoire : on plafonne
* la concurrence plutôt que de lui laisser prendre N requêtes en parallèle.
*/
const MAX_CONCURRENT_PRODUCT_JOBS = 3;
/** Compte les extractions produit actuellement en cours. */
function runningProductJobs(): number {
let n = 0;
for (const [key, job] of jobs) {
if (key.startsWith("cc:") && job.status === "running") n++;
}
return n;
}
/** Construit un jobId court et lisible. */
function newJobId(): string {
return `qlik_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
}
/** Sérialise un job pour la réponse HTTP. */
function publicJob(job: QlikSyncJob) {
return {
jobId: job.jobId,
fournisseur: job.fournisseur,
codeCentrale: job.codeCentrale,
status: job.status,
requested: job.requested,
fetched: job.fetched,
upserted: job.upserted,
periode: job.periode,
dateDebut: job.dateDebut,
dateFin: job.dateFin,
startedAt: job.startedAt,
finishedAt: job.finishedAt,
error: job.error,
message: job.message,
};
}
/** État "idle" sérialisé (pas de job connu pour cette cible). */
function idleJob(fournisseur: string, codeCentrale?: string) {
return {
jobId: null,
fournisseur,
codeCentrale,
status: "idle" as const,
requested: 0,
fetched: 0,
upserted: 0,
periode: undefined,
dateDebut: undefined,
dateFin: undefined,
startedAt: undefined,
finishedAt: undefined,
error: undefined,
message: undefined,
};
}
/**
* Vérifie l'auth admin. Renvoie une réponse 403/401 si refusée, sinon null.
*/
async function requireAdmin(): Promise<NextResponse | null> {
const session = await auth();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if ((session.user as { role?: string } | undefined)?.role !== "admin") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
return null;
}
/**
* Vérifie qu'une session existe (sans exiger le rôle admin).
*
* Utilisé par le mode produit : le middleware Next ne couvre PAS les routes
* `/api/*`, le contrôle doit donc être fait ici explicitement.
*/
async function requireSession(): Promise<NextResponse | null> {
const session = await auth();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
return null;
}
/**
* Regex de validation d'un code centrale "raisonnable".
*
* Format attendu d'après l'API FF : préfixe `10000` + 6 chiffres (ex `10000167303`),
* donc 11 caractères alphanumériques. Pour rester large et ne pas casser
* d'éventuels codes alphanumériques fournisseur (refs internes), on autorise
* - longueur 2..30
* - jeu `[A-Z0-9_-]`
* - premier caractère alphanumérique (pas `_` ni `-`)
*
* But : éviter d'envoyer à Qlik des chaînes vides, des `-`, des espaces
* parasites, ou des entrées manifestement invalides qui déclenchent des
* codes d'erreur Engine ou du gaspillage réseau. **Volontairement simple** :
* on ne touche pas au mapping ; on filtre seulement ce qui ne ressemble à
* aucun code plausible.
*/
const CENTRAL_CODE_REGEX = /^[A-Z0-9][A-Z0-9_-]{1,29}$/i;
/**
* Filtre strict des codes centraux avant d'envoyer à Qlik.
* - trim + drop vides
* - drop "-" (placeholder déjà exclu côté agrégation mais on double-check)
* - drop doublons (préserve l'ordre, comparaison insensible à la casse)
* - drop codes ne matchant pas CENTRAL_CODE_REGEX (longueur / charset)
*
* Renvoie `{ accepted, rejected, deduped }`. Les logs n'exposent que les
* compteurs, jamais les valeurs des codes.
*/
function filterCentralCodes(rawCodes: Array<string | null | undefined>): {
accepted: string[];
rejected: number;
deduped: number;
} {
const seen = new Set<string>();
const accepted: string[] = [];
let rejected = 0;
let deduped = 0;
for (const raw of rawCodes) {
if (raw == null) {
rejected++;
continue;
}
const code = String(raw).trim();
const key = code.toUpperCase();
if (!code || code === "-" || code === "_") {
rejected++;
continue;
}
if (seen.has(key)) {
deduped++;
continue;
}
if (!CENTRAL_CODE_REGEX.test(code)) {
rejected++;
continue;
}
seen.add(key);
accepted.push(code);
}
return { accepted, rejected, deduped };
}
/**
* Exécute réellement l'extraction + l'upsert, en mettant à jour le job
* au fur et à mesure (compteur fetched, puis upserted, puis finishedAt).
*/
async function runJob(job: QlikSyncJob): Promise<void> {
// Journal téléchargeable depuis Paramètres. Ouvert AVANT le premier log du
// job pour ne pas perdre l'entête (choix des champs, carte du modèle Qlik) —
// c'est précisément la partie qu'un terminal tronque.
startCapture(job.jobId, `sync fournisseur=${job.fournisseur} démarrée le ${job.startedAt}`);
try {
const articles = await pgGetArticlesByFournisseur(job.fournisseur);
// Filtre strict des codes (trim, dedupe, exclusion vides/`-`/codes trop suspects).
const { accepted: codes, rejected: codesRejected, deduped: codesDeduped } = filterCentralCodes(
articles.map((a) => a.codeCentrale),
);
job.requested = codes.length;
console.log(
`[api/qlik/sync] job=${job.jobId} fournisseur=${job.fournisseur} — ${articles.length} articles, ${codes.length} codes centraux retenus, ${codesRejected} rejetés (vide/-/anormaux), ${codesDeduped} doublons supprimés`,
);
if (codes.length === 0) {
job.status = "success";
job.message = "Aucun code centrale valide pour ce fournisseur";
job.finishedAt = new Date().toISOString();
return;
}
// Contrat métier strict : toujours 12 mois complets glissants.
// Une variable d'environnement ne doit pas pouvoir raccourcir la série.
const dateFilter = buildGridNetworkQlikDateFilter(new Date(), QLIK_MONTHS_BACK_DEFAULT);
job.periode = dateFilter.label;
job.dateDebut = dateFilter.dateDebut;
job.dateFin = dateFilter.dateFin;
console.log(
`[api/qlik/sync] job=${job.jobId} → extraction Qlik pour ${codes.length} codes — 12 mois complets ${dateFilter.label} (${dateFilter.dateDebut} → ${dateFilter.dateFin})…`,
);
// Le code fournisseur est transmis en plus des codes : s'il désigne dans
// Qlik le même périmètre (couverture vérifiée côté extracteur), une seule
// sélection remplace les dizaines de milliers de `SelectValues` par code.
// Les codes restent le repli et le contrôle de couverture.
const metrics = await fetchNetworkMetricsPlaywright(codes, undefined, dateFilter, job.fournisseur);
job.fetched = metrics.size;
console.log(`[api/qlik/sync] job=${job.jobId} ← Qlik a renvoyé ${metrics.size} produits réseau`);
const count = await upsertNetworkMetrics([...metrics.values()]);
job.upserted = count;
console.log(`[api/qlik/sync] job=${job.jobId} ${count} lignes upsert dans le cache`);
job.status = "success";
job.finishedAt = new Date().toISOString();
} catch (e) {
applyJobError(job, e);
} finally {
await stopCapture(job.jobId, `statut final=${job.status}${job.error ? ` — ${job.error}` : ""}`);
}
}
/**
* Inscrit une erreur dans le job, en traduisant la saturation mémoire du
* serveur Qlik en message actionnable (ce n'est PAS un bug CollectFlow :
* l'app Qlik ne se charge même pas).
*/
function applyJobError(job: QlikSyncJob, e: unknown): void {
const msg = e instanceof Error ? e.message : String(e);
console.error(`[api/qlik/sync] job=${job.jobId}`, msg);
job.status = "error";
const lower = msg.toLowerCase();
if (lower.includes("out of memory") || lower.includes("not enough memory") || lower.includes("file corrupted") || lower.includes('"code":6') || lower.includes('"code":3002')) {
job.error = "Serveur Qlik saturé : mémoire insuffisante pour charger l'application. Réessayez plus tard (ou faites libérer / augmenter la RAM du serveur Qlik).";
} else {
job.error = msg;
}
job.finishedAt = new Date().toISOString();
}
/**
* Extraction Qlik pour UN seul code centrale (fiche produit).
*
* Beaucoup plus légère que la sync fournisseur : un seul code au lieu de
* plusieurs centaines, sur la même fenêtre 12 mois glissants. Réutilise
* exactement le même extracteur, donc le même détail mensuel.
*/
async function runProductJob(job: QlikSyncJob): Promise<void> {
startCapture(job.jobId, `sync produit=${job.codeCentrale} démarrée le ${job.startedAt}`);
try {
const code = job.codeCentrale!;
job.requested = 1;
const dateFilter = buildGridNetworkQlikDateFilter(new Date(), QLIK_MONTHS_BACK_DEFAULT);
job.periode = dateFilter.label;
job.dateDebut = dateFilter.dateDebut;
job.dateFin = dateFilter.dateFin;
console.log(
`[api/qlik/sync] job=${job.jobId} → extraction Qlik produit (1 code) — fenêtre ${dateFilter.label} (${dateFilter.dateDebut} → ${dateFilter.dateFin})…`,
);
const metrics = await fetchNetworkMetricsPlaywright([code], undefined, dateFilter);
job.fetched = metrics.size;
if (metrics.size === 0) {
job.status = "success";
job.message = "Ce code centrale est inconnu de Qlik (aucune vente réseau sur la période)";
job.finishedAt = new Date().toISOString();
return;
}
const count = await upsertNetworkMetrics([...metrics.values()]);
job.upserted = count;
job.status = "success";
job.message = "Données réseau récupérées";
job.finishedAt = new Date().toISOString();
console.log(`[api/qlik/sync] job=${job.jobId} produit ${code} — ${count} ligne(s) upsert`);
} catch (e) {
applyJobError(job, e);
} finally {
await stopCapture(job.jobId, `statut final=${job.status}${job.error ? ` — ${job.error}` : ""}`);
}
}
/**
* GET /api/qlik/sync?fournisseur=XXX ou ?codeCentrale=YYY
* Retourne l'état courant du job pour cette cible. Si aucun job n'est
* connu, renvoie un état "idle" (avec un message explicite). Permet au
* frontend de reprendre l'état après un reload et de poller en arrière-plan.
*/
export async function GET(req: NextRequest) {
const codeCentrale = req.nextUrl.searchParams.get("codeCentrale");
const fournisseur = req.nextUrl.searchParams.get("fournisseur");
// Mode produit : ouvert à tout utilisateur connecté.
if (codeCentrale) {
const denied = await requireSession();
if (denied) return denied;
const job = jobs.get(jobKeyForProduct(codeCentrale));
if (!job) {
return NextResponse.json({ success: true, ...idleJob("", codeCentrale) });
}
return NextResponse.json({ success: true, ...publicJob(job) });
}
// Mode fournisseur : admin uniquement (inchangé).
const denied = await requireAdmin();
if (denied) return denied;
if (!fournisseur) {
return NextResponse.json({ error: "Param 'fournisseur' ou 'codeCentrale' requis" }, { status: 400 });
}
const job = jobs.get(fournisseur);
if (!job) {
return NextResponse.json({ success: true, ...idleJob(fournisseur) });
}
return NextResponse.json({ success: true, ...publicJob(job) });
}
/**
* POST /api/qlik/sync?fournisseur=XXX
* Tire de Qlik les metriques reseau des articles du fournisseur (par code centrale)
* et met a jour le cache local. Admin uniquement.
* Démarre (ou réutilise si déjà running) un job d'extraction en arrière-plan
* et retourne *immédiatement* { success, status, jobId, ... }. Le client doit
* ensuite poller GET pour suivre l'avancement et être notifié de la fin.
*/
export async function POST(req: NextRequest) {
const session = await auth();
if (!session || (session.user as { role?: string } | undefined)?.role !== "admin") {
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
const codeCentraleParam = req.nextUrl.searchParams.get("codeCentrale");
// ─── Mode produit : 1 code centrale, ouvert à tout utilisateur connecté ───
if (codeCentraleParam) {
const denied = await requireSession();
if (denied) return denied;
// Même validation que la sync fournisseur (trim, rejet des vides / "-" /
// codes hors charset) pour ne jamais envoyer de saleté à l'Engine Qlik.
const { accepted } = filterCentralCodes([codeCentraleParam]);
if (accepted.length === 0) {
return NextResponse.json({ error: "Code centrale invalide" }, { status: 400 });
}
const code = accepted[0];
const key = jobKeyForProduct(code);
const existingProduct = jobs.get(key);
if (existingProduct && existingProduct.status === "running") {
return NextResponse.json({ success: true, ...publicJob(existingProduct) });
}
if (runningProductJobs() >= MAX_CONCURRENT_PRODUCT_JOBS) {
return NextResponse.json(
{ error: "Trop d'extractions Qlik en cours. Réessayez dans quelques instants." },
{ status: 429 },
);
}
const productJob: QlikSyncJob = {
jobId: newJobId(),
fournisseur: "",
codeCentrale: code,
status: "running",
requested: 0,
fetched: 0,
upserted: 0,
startedAt: new Date().toISOString(),
};
jobs.set(key, productJob);
console.log(`[api/qlik/sync] job=${productJob.jobId} démarré pour codeCentrale=${code}`);
void runProductJob(productJob).catch((e) => applyJobError(productJob, e));
return NextResponse.json({ success: true, ...publicJob(productJob) });
}
// ─── Mode fournisseur : admin uniquement (inchangé) ──────────────────────
const denied = await requireAdmin();
if (denied) return denied;
const fournisseur = req.nextUrl.searchParams.get("fournisseur");
if (!fournisseur) {
return NextResponse.json({ error: "Param 'fournisseur' requis" }, { status: 400 });
return NextResponse.json({ error: "Param 'fournisseur' ou 'codeCentrale' requis" }, { status: 400 });
}
try {
// Codes centraux des articles de ce fournisseur
const articles = await pgGetArticlesByFournisseur(fournisseur);
const codes = [
...new Set(
articles
.map((a) => (a.codeCentrale ? String(a.codeCentrale).trim() : ""))
.filter(Boolean),
),
];
if (codes.length === 0) {
return NextResponse.json({
success: true,
fournisseur,
fetched: 0,
upserted: 0,
message: "Aucun article avec code centrale pour ce fournisseur",
fetchedAt: new Date().toISOString(),
});
}
const existing = jobs.get(fournisseur);
if (existing && existing.status === "running") {
// Un job tourne déjà pour ce fournisseur : on le réutilise.
return NextResponse.json({ success: true, ...publicJob(existing) });
}
const metrics = await fetchNetworkMetrics(codes);
const count = await upsertNetworkMetrics([...metrics.values()]);
return NextResponse.json({
success: true,
fournisseur,
requested: codes.length,
fetched: metrics.size,
upserted: count,
fetchedAt: new Date().toISOString(),
});
} catch (e) {
const job: QlikSyncJob = {
jobId: newJobId(),
fournisseur,
status: "running",
requested: 0,
fetched: 0,
upserted: 0,
startedAt: new Date().toISOString(),
};
jobs.set(fournisseur, job);
console.log(`[api/qlik/sync] job=${job.jobId} démarré pour fournisseur=${fournisseur}`);
// Lancement fire-and-forget. On ne `await` PAS l'extraction ici : on
// renvoie tout de suite au client et il polle GET. Les erreurs sont
// capturées dans runJob et inscrites dans le job.
void runJob(job).catch((e) => {
// Filet de sécurité (runJob ne devrait pas throw, mais on ne veut
// jamais qu'une exception non capturée crashe le process).
const msg = e instanceof Error ? e.message : String(e);
console.error("[api/qlik/sync]", msg);
return NextResponse.json({ success: false, error: msg }, { status: 500 });
}
console.error(`[api/qlik/sync] job=${job.jobId} crash non géré`, msg);
job.status = "error";
job.error = msg;
job.finishedAt = new Date().toISOString();
});
return NextResponse.json({ success: true, ...publicJob(job) });
}
+62
View File
@@ -0,0 +1,62 @@
import { NextRequest } from "next/server";
import { requireApiAuth } from "@/lib/api-auth";
import { ok, fail } from "@/lib/api-response";
import { fournisseursQuerySchema } from "@/lib/api-schemas";
import { listGridSuppliers } from "@/lib/grid-store";
import { pgGetFournisseurs } from "@/lib/pg-ff-client";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* GET /api/v1/fournisseurs?search=…&withData=1
*
* Liste des fournisseurs. Par défaut, tout le référentiel (`fouident`), enrichi de la
* fraîcheur de l'instantané de grille quand il existe — ce qui permet à l'appelant de
* savoir d'avance quels fournisseurs `/api/v1/grid` peut servir immédiatement.
*
* `withData=1` restreint aux seuls fournisseurs déjà présents dans l'instantané.
*/
export async function GET(req: NextRequest) {
const authCtx = await requireApiAuth(req);
if (authCtx instanceof Response) return authCtx;
const parsed = fournisseursQuerySchema.safeParse(Object.fromEntries(req.nextUrl.searchParams));
if (!parsed.success) {
return fail("bad_request", "Paramètres invalides.", parsed.error.issues);
}
const { search, withData } = parsed.data;
const snapshot = await listGridSuppliers();
const byCode = new Map(snapshot.map((s) => [s.codeFournisseur, s]));
if (withData === "1") {
const term = search?.toLowerCase();
const data = snapshot
.filter((s) => !term
|| s.codeFournisseur.toLowerCase().includes(term)
|| (s.nomFournisseur ?? "").toLowerCase().includes(term))
.map((s) => ({
code: s.codeFournisseur,
nom: s.nomFournisseur,
hasSnapshot: true,
rowCount: s.rowCount,
computedAt: s.computedAt,
}));
return ok(data, { meta: { total: data.length, scope: "instantané uniquement" } });
}
const fournisseurs = await pgGetFournisseurs(search);
const data = fournisseurs.map((f) => {
const snap = byCode.get(f.code);
return {
code: f.code,
nom: f.nom,
hasSnapshot: Boolean(snap),
rowCount: snap?.rowCount ?? 0,
computedAt: snap?.computedAt ?? null,
};
});
return ok(data, { meta: { total: data.length, withSnapshot: byCode.size } });
}
+223
View File
@@ -0,0 +1,223 @@
import { NextRequest } from "next/server";
import { requireApiAuth } from "@/lib/api-auth";
import { ok, fail, buildPagination } from "@/lib/api-response";
import { gridQuerySchema, pickFields, toSortKey } from "@/lib/api-schemas";
import { queryGridRows, getGridFreshness } from "@/lib/grid-store";
import { enrichRows } from "@/lib/api-enrich";
import { getProductRows } from "@/features/grid/api/get-product-rows";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
// Le calcul à la demande (premier appel sur un fournisseur inconnu) enchaîne
// plusieurs requêtes SQL sur la base miroir FF : on laisse de la marge.
export const maxDuration = 300;
/**
* GET /api/v1/grid?fournisseur=…&gamme=&code1..3=&search=&sort=&order=&page=&limit=&fields=&compute=
*
* Lignes de grille d'un fournisseur, filtrées / triées / paginées **en SQL** depuis
* l'instantané persisté (`grid_rows`).
*
* Si le fournisseur n'a pas encore d'instantané, l'endpoint le **calcule à la demande**
* (`compute=1`, défaut) : sans cela, un appelant externe ne pourrait consulter que les
* fournisseurs déjà ouverts dans la Grille par un humain. Le calcul est protégé par le
* cache et le verrou anti-concurrence de `getProductRows()`, et il persiste l'instantané
* au passage — les appels suivants repassent donc par le chemin rapide.
*
* `compute=0` restaure le comportement strict : échec immédiat en `202 not_ready`.
*/
export async function GET(req: NextRequest) {
const authCtx = await requireApiAuth(req);
if (authCtx instanceof Response) return authCtx;
const parsed = gridQuerySchema.safeParse(Object.fromEntries(req.nextUrl.searchParams));
if (!parsed.success) {
return fail("bad_request", "Paramètres invalides.", parsed.error.issues);
}
const q = parsed.data;
// Distingue « fournisseur jamais calculé » de « filtres sans résultat » (200 vide).
let freshness = await getGridFreshness(q.fournisseur);
let computedOnDemand = false;
if (!freshness) {
if (q.compute === "0") {
return fail(
"not_ready",
`Aucun instantané pour le fournisseur « ${q.fournisseur} ». Relancez sans « compute=0 » pour le calculer à la demande.`,
{ fournisseur: q.fournisseur },
);
}
console.log(`[api/v1/grid] instantané absent pour ${q.fournisseur} — calcul à la demande (via ${authCtx.via})`);
try {
// magasin TOTAL : c'est la seule variante persistée par getProductRows,
// et ProductRow embarque déjà les ventilations par magasin.
await getProductRows({ codeFournisseur: q.fournisseur, magasin: "TOTAL" });
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
console.error(`[api/v1/grid] calcul à la demande KO pour ${q.fournisseur}:`, msg);
return fail(
"internal_error",
`Le calcul de la grille a échoué pour le fournisseur « ${q.fournisseur} ».`,
{ fournisseur: q.fournisseur },
);
}
freshness = await getGridFreshness(q.fournisseur);
if (!freshness) {
// Calcul réussi mais aucune ligne : le code fournisseur n'existe pas, ou
// il n'a aucun article. À distinguer d'une panne.
return fail(
"not_found",
`Aucun article pour le fournisseur « ${q.fournisseur} ». Vérifiez le code auprès de /api/v1/fournisseurs.`,
{ fournisseur: q.fournisseur },
);
}
computedOnDemand = true;
}
const filtres = {
codeFournisseur: q.fournisseur,
gamme: q.gamme,
code1: q.code1,
code2: q.code2,
code3: q.code3,
nomenclature: q.nomenclature,
search: q.search,
sort: toSortKey(q.sort),
order: q.order,
};
// Sans `limit`, la réponse peut porter tout un fournisseur — et certains en
// comptent plus de 130 000. Tout charger puis tout sérialiser d'un bloc ferait
// exploser la mémoire du processus : on diffuse par lots, à mémoire constante.
if (q.limit == null) {
return streamAllRows(req, filtres, q, freshness, computedOnDemand);
}
const result = await queryGridRows({ ...filtres, page: q.page, limit: q.limit });
// Métriques Qlik et gamme serveur relues au moment de l'appel (voir api-enrich).
const rows = q.enrich === "1" ? await enrichRows(result.rows) : result.rows;
const pagination = buildPagination(q.page, q.limit, result.total);
return ok(
rows.map((r) => pickFields(r, q.fields)),
{
pagination,
meta: {
fournisseur: q.fournisseur,
computedAt: result.computedAt,
snapshotComputedAt: freshness.computedAt,
snapshotRowCount: freshness.rowCount,
enrichi: q.enrich === "1",
/** true = l'instantané n'existait pas et vient d'être calculé par cet appel. */
computedOnDemand,
/**
* true = cette réponse contient **toutes** les lignes du fournisseur.
* Dit explicitement à un appelant — en particulier un agent — s'il
* peut s'arrêter là, au lieu de le laisser déduire d'une pagination
* qu'il risque d'ignorer.
*/
complet: !pagination.hasMore,
...(pagination.hasMore
? {
avertissement:
`Réponse partielle : ${rows.length} lignes sur ${result.total}, parce que « limit » a été précisé. `
+ `Retirez « limit » pour obtenir tout le fournisseur en un appel, ou passez à page=${q.page + 1}.`,
}
: {}),
},
},
);
}
/** Lots internes : compromis entre nombre d'allers-retours SQL et mémoire retenue. */
const STREAM_BATCH = 1000;
type Filtres = Parameters<typeof queryGridRows>[0];
/**
* Diffuse **toutes** les lignes du fournisseur en une seule réponse JSON, sans
* jamais la construire entièrement en mémoire.
*
* La forme reste celle des autres réponses (`{ data, pagination, meta }`) : un
* client existant ne voit aucune différence, il reçoit simplement les octets au
* fil de l'eau. `pagination` et `meta` sont écrits en dernier, une fois le total
* connu — l'ordre des clés n'a aucune importance en JSON.
*
* Une erreur survenant après les premiers octets ne peut plus changer le code
* HTTP : on ferme alors le document avec `meta.erreur`, pour que l'appelant
* détecte une réponse tronquée au lieu de la croire complète.
*/
function streamAllRows(
req: NextRequest,
filtres: Filtres,
q: { enrich: string; fields?: string; fournisseur: string },
freshness: { rowCount: number; computedAt: string | null },
computedOnDemand: boolean,
): Response {
const encoder = new TextEncoder();
const stream = new ReadableStream<Uint8Array>({
async start(controller) {
const write = (s: string) => controller.enqueue(encoder.encode(s));
let envoyees = 0;
let total = 0;
let computedAt: string | null = null;
let erreur: string | null = null;
write('{"data":[');
try {
for (let page = 1; ; page++) {
const lot = await queryGridRows({ ...filtres, page, limit: STREAM_BATCH });
if (page === 1) {
total = lot.total;
computedAt = lot.computedAt;
}
if (lot.rows.length === 0) break;
const enrichies = q.enrich === "1" ? await enrichRows(lot.rows) : lot.rows;
for (const r of enrichies) {
write((envoyees === 0 ? "" : ",") + JSON.stringify(pickFields(r, q.fields)));
envoyees++;
}
if (lot.rows.length < STREAM_BATCH) break;
}
} catch (e) {
erreur = e instanceof Error ? e.message : String(e);
console.error(`[api/v1/grid] diffusion interrompue pour ${q.fournisseur} après ${envoyees} lignes:`, erreur);
}
const complet = erreur === null && envoyees === total;
write("]," + JSON.stringify({
pagination: { page: 1, limit: envoyees, total, totalPages: 1, hasMore: !complet },
meta: {
fournisseur: q.fournisseur,
computedAt,
snapshotComputedAt: freshness.computedAt,
snapshotRowCount: freshness.rowCount,
enrichi: q.enrich === "1",
computedOnDemand,
diffuse: true,
complet,
...(erreur
? { erreur: `Diffusion interrompue après ${envoyees} lignes sur ${total} : ${erreur}` }
: {}),
},
}).slice(1));
controller.close();
},
});
return new Response(stream, {
headers: {
"Content-Type": "application/json; charset=utf-8",
"Cache-Control": "no-store",
// Empêche un proxy de tamponner la réponse entière avant de la relayer.
"X-Accel-Buffering": "no",
},
});
}
@@ -0,0 +1,40 @@
import { NextRequest } from "next/server";
import { requireApiAuth } from "@/lib/api-auth";
import { ok, fail } from "@/lib/api-response";
import { getNetworkMetricsByCodeCentrale } from "@/lib/qlik-network-cache";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* GET /api/v1/network/:codeCentrale
*
* Métriques réseau Qlik d'un produit (CA, quantité, nombre de magasins, CA/magasin,
* marge %) et sa courbe de quantités mensuelles sur 12 mois glissants.
*
* Lecture **exclusive** de la table `qlik_network_metrics` : aucun appel à Qlik n'est
* déclenché. Pour interroger Qlik en direct (y compris sur des produits que nous ne
* référençons pas), c'est la page Produits — bien plus lente, et volontairement
* séparé de cette API.
*/
export async function GET(
req: NextRequest,
ctx: { params: Promise<{ codeCentrale: string }> },
) {
const authCtx = await requireApiAuth(req);
if (authCtx instanceof Response) return authCtx;
const { codeCentrale } = await ctx.params;
if (!codeCentrale) return fail("bad_request", "Paramètre 'codeCentrale' requis.");
const metrics = await getNetworkMetricsByCodeCentrale([codeCentrale]);
const found = metrics.get(codeCentrale);
if (!found) {
return fail(
"not_found",
`Aucune donnée réseau en cache pour le code centrale « ${codeCentrale} ». Lancez une synchronisation Qlik ou une recherche réseau.`,
);
}
return ok({ codeCentrale, ...found }, { meta: { fetchedAt: found.fetchedAt, source: "cache" } });
}
+65
View File
@@ -0,0 +1,65 @@
import { NextRequest } from "next/server";
import { requireApiAuth } from "@/lib/api-auth";
import { ok, fail } from "@/lib/api-response";
import { nomenclaturesQuerySchema } from "@/lib/api-schemas";
import { listGridNomenclatures, getGridFreshness } from "@/lib/grid-store";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* GET /api/v1/nomenclatures?fournisseur=…&niveau=1|2|3&parent=…
*
* Répartition des articles d'un fournisseur par poste de nomenclature, avec le
* nombre d'articles et le CA de chacun.
*
* C'est la porte d'entrée pour traiter un gros fournisseur : certains dépassent
* 130 000 articles, et tout récupérer d'un coup n'a de sens ni pour une IA ni
* pour une application. On liste d'abord les postes — quelques dizaines de
* lignes — puis on interroge `/grid?fournisseur=…&nomenclature=…` poste par poste.
*
* Les codes sont hiérarchiques par préfixe (6 chiffres, de 31xxxx à 40xxxx) :
* niveau 1 = « 32 », niveau 2 = « 3202 », niveau 3 = « 320211 ». `parent` accepte
* un préfixe pour descendre l'arborescence sans tout charger.
*/
export async function GET(req: NextRequest) {
const authCtx = await requireApiAuth(req);
if (authCtx instanceof Response) return authCtx;
const parsed = nomenclaturesQuerySchema.safeParse(Object.fromEntries(req.nextUrl.searchParams));
if (!parsed.success) {
return fail("bad_request", "Paramètres invalides.", parsed.error.issues);
}
const { fournisseur, niveau, parent } = parsed.data;
// Même distinction que /grid : « pas encore calculé » n'est pas « vide ».
const freshness = await getGridFreshness(fournisseur);
if (!freshness) {
return fail(
"not_ready",
`Aucun instantané pour le fournisseur « ${fournisseur} ». Appelez /api/v1/grid?fournisseur=${fournisseur} une fois pour le calculer.`,
{ fournisseur },
);
}
const postes = await listGridNomenclatures(fournisseur, niveau, parent);
const nbArticles = postes.reduce((s, p) => s + p.nbArticles, 0);
return ok(postes, {
meta: {
fournisseur,
niveau,
parent: parent ?? null,
nbPostes: postes.length,
/** Total couvert par les postes listés — à rapprocher de snapshotRowCount. */
nbArticles,
snapshotRowCount: freshness.rowCount,
snapshotComputedAt: freshness.computedAt,
/**
* Le paramètre de /grid à utiliser avec les `code` renvoyés ci-dessus.
* `nomenclature` filtre par préfixe et fonctionne aux trois niveaux.
*/
filtreGrid: "nomenclature",
},
});
}
+399
View File
@@ -0,0 +1,399 @@
import { NextRequest } from "next/server";
import { GRID_SORT_KEYS } from "@/lib/grid-store";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* GET /api/v1/openapi.json — spécification OpenAPI 3.1 de l'API CollectFlow.
*
* **Volontairement accessible sans authentification.** Le document ne décrit que la
* structure de l'API (chemins, paramètres, schémas) et ne contient aucune donnée
* métier ; or ChatGPT et les autres plateformes d'agents importent le schéma par URL,
* avant même que la clé ne soit configurée. Exiger une clé ici rendrait l'import
* impossible.
*
* `servers` est construit depuis l'hôte appelant (ou COLLECTFLOW_PUBLIC_URL) : les
* Actions ChatGPT exigent une URL **absolue**, une URL relative étant rejetée.
*/
export async function GET(req: NextRequest) {
const configured = process.env.COLLECTFLOW_PUBLIC_URL?.trim().replace(/\/+$/, "");
const origin = configured || req.nextUrl.origin;
const paginationParams = [
{ name: "page", in: "query", schema: { type: "integer", minimum: 1, default: 1 }, description: "Numéro de page." },
{ name: "limit", in: "query", schema: { type: "integer", minimum: 1 }, description: "Lignes par page, sans plafond. Sur /grid, OMETTRE ce paramètre renvoie toutes les lignes du fournisseur en un seul appel — c'est le mode recommandé. Sur les autres endpoints, le défaut est 100." },
];
const sortParams = [
{ name: "sort", in: "query", schema: { type: "string", enum: GRID_SORT_KEYS }, description: "Colonne de tri." },
{ name: "order", in: "query", schema: { type: "string", enum: ["asc", "desc"], default: "desc" }, description: "Sens du tri." },
{
name: "fields",
in: "query",
schema: { type: "string" },
description:
"Colonnes à retourner, séparées par des virgules (ex. « codein,libelle1,totalCa »). "
+ "À utiliser systématiquement : une ligne complète contient les séries mensuelles et les "
+ "ventilations par magasin, ce qui est très volumineux. `codein` est toujours inclus.",
},
{
name: "enrich",
in: "query",
schema: { type: "string", enum: ["0", "1"], default: "1" },
description:
"1 (défaut) : relit les métriques réseau Qlik (`network`) et la gamme serveur "
+ "(`codeGammeServeur`) au moment de l'appel. 0 : sert l'instantané brut.",
},
];
const spec = {
openapi: "3.1.0",
info: {
title: "CollectFlow API",
version: "1.0.0",
description:
"Lecture des données de la grille CollectFlow : ventes sur 12 mois par magasin, stock, marges, "
+ "gammes et métriques du réseau Qlik (~270 magasins Foir'Fouille), plus la recherche de produits.\n\n"
+ "**N'importe quel fournisseur peut être interrogé directement.** Les réponses sont servies "
+ "depuis un instantané persisté ; si le fournisseur demandé n'en a pas encore, `/grid` le calcule "
+ "à la demande. Ce premier appel prend alors plusieurs secondes, les suivants sont immédiats. "
+ "Passez `compute=0` pour refuser ce calcul et obtenir un `202 not_ready` immédiat.\n\n"
+ "**Aucun endpoint ne contacte Qlik en direct** : les métriques réseau viennent d'un cache "
+ "alimenté par une synchronisation séparée.\n\n"
+ "Deux informations sont relues à chaque appel car elles évoluent indépendamment : les métriques "
+ "réseau Qlik (`network`, `null` s'il n'y en a pas) et la gamme serveur non modifiée "
+ "(`codeGammeServeur`).",
},
servers: [{ url: `${origin}/api/v1`, description: "API CollectFlow" }],
security: [{ ApiKeyAuth: [] }],
components: {
securitySchemes: {
ApiKeyAuth: { type: "apiKey", in: "header", name: "X-API-Key" },
},
schemas: {
Error: {
type: "object",
properties: {
error: {
type: "object",
properties: {
code: {
type: "string",
enum: ["unauthorized", "forbidden", "bad_request", "not_found", "not_ready", "internal_error"],
},
message: { type: "string" },
},
},
},
},
Pagination: {
type: "object",
properties: {
page: { type: "integer" },
limit: { type: "integer" },
total: { type: "integer" },
totalPages: { type: "integer" },
hasMore: { type: "boolean", description: "true = il reste des pages. Sur /grid, meta.complet dit la même chose en positif." },
},
},
Fournisseur: {
type: "object",
properties: {
code: { type: "string", description: "Code fournisseur, à passer au paramètre `fournisseur`." },
nom: { type: ["string", "null"] },
hasSnapshot: { type: "boolean", description: "false = données pas encore disponibles pour ce fournisseur." },
rowCount: { type: "integer" },
computedAt: { type: ["string", "null"], format: "date-time" },
},
},
NetworkMetrics: {
type: ["object", "null"],
description: "Métriques du réseau Qlik. `null` quand le produit n'a pas de données réseau.",
properties: {
caReseau: { type: "number" },
qteReseau: { type: "number" },
nbMagasinsReseau: { type: "integer", description: "Nombre de magasins du réseau vendant le produit (sur ~270)." },
caParMagasinReseau: { type: "number", description: "CA moyen par magasin : normalise la présence, préférable au CA brut pour comparer." },
margePctReseau: { type: "number", description: "Taux de marge, ratio brut (0.32 = 32 %)." },
qteByMonth: { type: ["object", "null"], description: "Quantités vendues par mois, clés « YYYY-MM »." },
fetchedAt: { type: ["string", "null"], format: "date-time" },
},
},
Product: {
type: "object",
description:
"Ligne de grille : un article chez un fournisseur. La liste de propriétés ci-dessous "
+ "n'est pas exhaustive — la réponse contient l'intégralité de la ligne (une soixantaine "
+ "de champs). Utilisez `fields` pour n'en garder qu'une partie.",
properties: {
codein: { type: "string", description: "Identifiant interne de l'article." },
codeFournisseur: { type: "string" },
nomFournisseur: { type: "string" },
libelle1: { type: "string", description: "Libellé de l'article." },
gtin: { type: "string" },
reference: { type: "string" },
codeCentrale: { type: "string", description: "Code centrale — clé de jointure avec le réseau Qlik." },
codeGammeServeur: {
type: ["string", "null"],
description:
"Gamme **non modifiée**, telle qu'elle existe en base. C'est celle à utiliser pour "
+ "raisonner. `null` = aucune gamme. Valeurs : A (pilier), B (bonne rotation), "
+ "C (performance), D (saisonnier/niche), Z (sortie).",
},
codeGamme: { type: ["string", "null"], description: "Gamme courante, éventuellement surchargée par une modification locale non enregistrée." },
totalCa: { type: "number", description: "CA sur 12 mois, nos magasins." },
totalQuantite: { type: "number" },
totalMarge: { type: "number" },
tauxMarge: { type: "number" },
stockActuel: { type: "number" },
pcb: { type: "number", description: "Conditionnement (nombre d'unités par colis)." },
prixAchat: { type: "number" },
prixVente: { type: "number" },
derniereVente: { type: "string", format: "date" },
nbJoursDerniereVente: { type: "integer" },
sales12m: { type: "object", description: "Quantités vendues par mois, clés « YYYYMM »." },
stock12m: { type: "object", description: "Stock de fin de mois, clés « YYYYMM »." },
receptions12m: { type: "object", description: "Quantités reçues par mois, clés « YYYYMM »." },
sales12mByStore: { type: "object", description: "Ventes par magasin (292, 579) puis par mois." },
stock12mByStore: { type: "object", description: "Stock de fin de mois par magasin puis par mois." },
receptions12mByStore: { type: "object", description: "Réceptions par magasin puis par mois." },
qteReseauByMonth: { type: ["object", "null"], description: "Quantités vendues par le réseau, clés « YYYY-MM » — série derrière la tendance." },
network: { $ref: "#/components/schemas/NetworkMetrics" },
trend: { $ref: "#/components/schemas/Trend" },
},
},
Trend: {
type: ["object", "null"],
description:
"Tendance réseau sur 12 mois, par régression linéaire — la même que la colonne "
+ "« Tendance » de l'application. `null` si aucune série mensuelle n'est disponible.",
properties: {
direction: { type: "string", enum: ["up", "down", "flat"], description: "Seuil à ±8 % de variation modélisée." },
pct: { type: ["number", "null"], description: "Variation modélisée sur la période (0.23 = +23 %)." },
label: { type: "string", description: "Libellé prêt à afficher : « Forte hausse », « Stable »…" },
monthsUsed: { type: "integer", description: "Nombre de mois réellement utilisés (12 au maximum)." },
},
},
},
},
paths: {
"/fournisseurs": {
get: {
operationId: "listerFournisseurs",
summary: "Lister les fournisseurs",
description:
"Renvoie les fournisseurs et indique, via `hasSnapshot`, lesquels ont des données "
+ "immédiatement disponibles. À appeler en premier quand le code fournisseur est inconnu.",
parameters: [
{ name: "search", in: "query", schema: { type: "string" }, description: "Filtre sur le nom ou le code." },
{ name: "withData", in: "query", schema: { type: "string", enum: ["0", "1"] }, description: "1 = uniquement ceux dont les données sont disponibles." },
],
responses: {
"200": {
description: "Liste des fournisseurs",
content: {
"application/json": {
schema: {
type: "object",
properties: {
data: { type: "array", items: { $ref: "#/components/schemas/Fournisseur" } },
meta: { type: "object" },
},
},
},
},
},
"401": { description: "Clé d'API absente, invalide ou révoquée", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
},
},
},
"/nomenclatures": {
get: {
operationId: "listerNomenclaturesFournisseur",
summary: "Postes de nomenclature d'un fournisseur",
description:
"Répartition des articles d'un fournisseur par poste de nomenclature, avec le nombre "
+ "d'articles et le CA de chacun. **À utiliser avant /grid sur un gros fournisseur** : "
+ "certains dépassent 130 000 articles. On liste les postes (quelques dizaines de lignes), "
+ "puis on interroge /grid poste par poste avec le filtre indiqué par `meta.filtreGrid` "
+ "(code1, code2 ou code3).",
parameters: [
{ name: "fournisseur", in: "query", required: true, schema: { type: "string" } },
{ name: "niveau", in: "query", schema: { type: "integer", enum: [1, 2, 3], default: 1 }, description: "1 = univers, 2 = famille, 3 = sous-famille." },
{ name: "parent", in: "query", schema: { type: "string" }, description: "Préfixe du poste parent, ex. « 32 » pour lister ses familles." },
],
responses: {
"200": {
description: "Postes de nomenclature",
content: {
"application/json": {
schema: {
type: "object",
properties: {
data: {
type: "array",
items: {
type: "object",
properties: {
code: { type: "string", description: "À passer au filtre code1/code2/code3 de /grid." },
libelle: { type: ["string", "null"] },
nbArticles: { type: "integer" },
totalCa: { type: "number" },
totalQuantite: { type: "number" },
},
},
},
meta: { type: "object" },
},
},
},
},
},
"202": { description: "Fournisseur pas encore calculé", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
"400": { description: "Paramètres invalides", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
},
},
},
"/grid": {
get: {
operationId: "listerProduitsFournisseur",
summary: "Lister les produits d'un fournisseur",
description:
"Lignes de grille d'un fournisseur : ventes 12 mois, stock, marges, gammes et métriques "
+ "réseau. Fonctionne pour **n'importe quel** fournisseur : si ses données n'ont jamais été "
+ "calculées, l'API les calcule à la demande (premier appel de quelques secondes, "
+ "`meta.computedOnDemand = true`). Un `404` signifie que le code fournisseur n'existe pas "
+ "ou n'a aucun article — le signaler plutôt que de conclure à une panne.",
parameters: [
{ name: "fournisseur", in: "query", required: true, schema: { type: "string" }, description: "Code fournisseur (voir listerFournisseurs)." },
{ name: "gamme", in: "query", schema: { type: "string" }, description: "Filtre sur la gamme (A, B, C, D, Z)." },
{ name: "code1", in: "query", schema: { type: "string" }, description: "Filtre nomenclature niveau 1." },
{ name: "code2", in: "query", schema: { type: "string" }, description: "Filtre nomenclature niveau 2." },
{ name: "code3", in: "query", schema: { type: "string" }, description: "Filtre nomenclature niveau 3 (code exact)." },
{ name: "nomenclature", in: "query", schema: { type: "string" }, description: "Préfixe de nomenclature renvoyé par listerNomenclaturesFournisseur : 32, 3202 ou 320211. À préférer à code1/code2, qui peuvent être vides." },
{ name: "search", in: "query", schema: { type: "string" }, description: "Filtre texte : libellé, codein, GTIN, référence ou code centrale." },
{
name: "compute",
in: "query",
schema: { type: "string", enum: ["0", "1"], default: "1" },
description:
"1 (défaut) : calcule les données du fournisseur si elles n'existent pas encore. "
+ "0 : refuse le calcul et répond immédiatement 202 not_ready.",
},
...sortParams,
...paginationParams,
],
responses: {
"200": {
description: "Produits du fournisseur",
content: {
"application/json": {
schema: {
type: "object",
properties: {
data: { type: "array", items: { $ref: "#/components/schemas/Product" } },
pagination: { $ref: "#/components/schemas/Pagination" },
meta: { type: "object" },
},
},
},
},
},
"202": { description: "Données pas encore calculées (uniquement avec compute=0)", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
"400": { description: "Paramètres invalides", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
"401": { description: "Clé d'API absente, invalide ou révoquée", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
"404": { description: "Fournisseur inconnu ou sans article", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
},
},
},
"/products/search": {
get: {
operationId: "rechercherProduits",
summary: "Rechercher des produits, tous fournisseurs confondus",
description:
"Recherche sur le libellé, le codein, le GTIN, la référence et le code centrale, sans "
+ "avoir à connaître le fournisseur. À utiliser dès qu'un produit est désigné par son nom.",
parameters: [
{ name: "q", in: "query", required: true, schema: { type: "string", maxLength: 120 }, description: "Termes recherchés." },
{ name: "fournisseur", in: "query", schema: { type: "string" }, description: "Restreint à un fournisseur." },
{ name: "gamme", in: "query", schema: { type: "string" }, description: "Filtre sur la gamme." },
...sortParams,
...paginationParams,
],
responses: {
"200": {
description: "Produits correspondants",
content: {
"application/json": {
schema: {
type: "object",
properties: {
data: { type: "array", items: { $ref: "#/components/schemas/Product" } },
pagination: { $ref: "#/components/schemas/Pagination" },
meta: { type: "object" },
},
},
},
},
},
"401": { description: "Clé d'API absente, invalide ou révoquée", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
},
},
},
"/products/{codein}": {
get: {
operationId: "obtenirProduit",
summary: "Fiche complète d'un produit",
description: "Toutes les données d'un article : séries mensuelles, ventilation par magasin, stock, marges, gammes et métriques réseau.",
parameters: [
{ name: "codein", in: "path", required: true, schema: { type: "string" }, description: "Identifiant interne de l'article." },
{ name: "fournisseur", in: "query", schema: { type: "string" }, description: "Lève l'ambiguïté d'un article référencé chez plusieurs fournisseurs." },
],
responses: {
"200": {
description: "Fiche produit",
content: {
"application/json": {
schema: {
type: "object",
properties: { data: { $ref: "#/components/schemas/Product" }, meta: { type: "object" } },
},
},
},
},
"404": { description: "Produit absent de l'instantané", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
"401": { description: "Clé d'API absente, invalide ou révoquée", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
},
},
},
"/network/{codeCentrale}": {
get: {
operationId: "obtenirMetriquesReseau",
summary: "Métriques du réseau Qlik pour un produit",
description: "Performances du produit sur l'ensemble du réseau Foir'Fouille (~270 magasins) et courbe des quantités sur 12 mois glissants.",
parameters: [
{ name: "codeCentrale", in: "path", required: true, schema: { type: "string" }, description: "Code centrale du produit (format 10000XXXXXX)." },
],
responses: {
"200": {
description: "Métriques réseau",
content: {
"application/json": {
schema: {
type: "object",
properties: { data: { $ref: "#/components/schemas/NetworkMetrics" }, meta: { type: "object" } },
},
},
},
},
"404": { description: "Aucune donnée réseau pour ce code centrale", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
"401": { description: "Clé d'API absente, invalide ou révoquée", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
},
},
},
},
};
// CORS ouvert en lecture : le schéma est public et peut être chargé par un outil tiers.
return Response.json(spec, { headers: { "Access-Control-Allow-Origin": "*" } });
}
+73
View File
@@ -0,0 +1,73 @@
import { NextRequest } from "next/server";
import { requireApiAuth } from "@/lib/api-auth";
import { ok, fail } from "@/lib/api-response";
import { productDetailSchema } from "@/lib/api-schemas";
import { getGridRowByCodein } from "@/lib/grid-store";
import { enrichRows } from "@/lib/api-enrich";
import { getProductRows } from "@/features/grid/api/get-product-rows";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
// Idem /api/v1/grid : le calcul à la demande peut prendre plusieurs secondes.
export const maxDuration = 300;
/**
* GET /api/v1/products/:codein?fournisseur=…
*
* Fiche complète d'un produit : le `ProductRow` intégral (séries mensuelles,
* ventilations par magasin, stock, marges, gammes) enrichi des métriques réseau Qlik
* lues **en cache** (`qlik_network_metrics`) — aucun appel à Qlik.
*
* `fournisseur` lève l'ambiguïté d'un article référencé chez plusieurs fournisseurs ;
* sans lui, l'instantané le plus récent est renvoyé.
*/
export async function GET(
req: NextRequest,
ctx: { params: Promise<{ codein: string }> },
) {
const authCtx = await requireApiAuth(req);
if (authCtx instanceof Response) return authCtx;
const { codein } = await ctx.params;
if (!codein) return fail("bad_request", "Paramètre 'codein' requis.");
const parsed = productDetailSchema.safeParse(Object.fromEntries(req.nextUrl.searchParams));
if (!parsed.success) {
return fail("bad_request", "Paramètres invalides.", parsed.error.issues);
}
let found = await getGridRowByCodein(codein, parsed.data.fournisseur);
// Absent de l'instantané : on peut le calculer, mais seulement si l'appelant a
// précisé le fournisseur — le calcul se fait par lot fournisseur, pas par article.
if (!found && parsed.data.compute === "1" && parsed.data.fournisseur) {
console.log(`[api/v1/products] ${codein} absent — calcul du lot ${parsed.data.fournisseur} à la demande`);
try {
await getProductRows({ codeFournisseur: parsed.data.fournisseur, magasin: "TOTAL" });
found = await getGridRowByCodein(codein, parsed.data.fournisseur);
} catch (e) {
console.error(`[api/v1/products] calcul KO pour ${parsed.data.fournisseur}:`, e instanceof Error ? e.message : String(e));
}
}
if (!found) {
return fail(
"not_found",
parsed.data.fournisseur
? `Produit « ${codein} » introuvable chez le fournisseur « ${parsed.data.fournisseur} ».`
: `Aucun instantané pour le produit « ${codein} ». Ajoutez « fournisseur=… » pour que l'API calcule le lot à la demande.`,
);
}
// Métriques réseau + gamme serveur relues maintenant : le payload date du dernier
// calcul de la grille, alors que ces deux données évoluent indépendamment.
const [enriched] = await enrichRows([found.row]);
return ok(enriched, {
meta: {
computedAt: found.computedAt,
networkFetchedAt: enriched.network?.fetchedAt ?? null,
hasNetwork: enriched.network !== null,
},
});
}
+57
View File
@@ -0,0 +1,57 @@
import { NextRequest } from "next/server";
import { requireApiAuth } from "@/lib/api-auth";
import { ok, fail, buildPagination } from "@/lib/api-response";
import { productSearchSchema, pickFields, toSortKey } from "@/lib/api-schemas";
import { queryGridRows } from "@/lib/grid-store";
import { enrichRows } from "@/lib/api-enrich";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* GET /api/v1/products/search?q=…&fournisseur=&gamme=&sort=&order=&page=&limit=&fields=
*
* **Recherche transversale** sur libellé, codein, GTIN, référence et code centrale —
* tous fournisseurs confondus. C'est ce qu'aucune route existante ne sait faire :
* `getProductRows()` exige un fournisseur, donc chercher un produit sans le connaître
* était impossible.
*
* Lit uniquement l'instantané `grid_rows` : un fournisseur jamais ouvert dans la
* Grille n'apparaît pas encore dans les résultats.
*/
export async function GET(req: NextRequest) {
const authCtx = await requireApiAuth(req);
if (authCtx instanceof Response) return authCtx;
const parsed = productSearchSchema.safeParse(Object.fromEntries(req.nextUrl.searchParams));
if (!parsed.success) {
return fail("bad_request", "Paramètres invalides.", parsed.error.issues);
}
const q = parsed.data;
const result = await queryGridRows({
codeFournisseur: q.fournisseur,
gamme: q.gamme,
search: q.q,
sort: toSortKey(q.sort),
order: q.order,
page: q.page,
limit: q.limit,
});
// Métriques Qlik et gamme serveur relues au moment de l'appel (voir api-enrich).
const rows = q.enrich === "1" ? await enrichRows(result.rows) : result.rows;
return ok(
rows.map((r) => pickFields(r, q.fields)),
{
pagination: buildPagination(q.page, q.limit, result.total),
meta: {
query: q.q,
computedAt: result.computedAt,
scope: q.fournisseur ? `fournisseur:${q.fournisseur}` : "tous fournisseurs",
enrichi: q.enrich === "1",
},
},
);
}
+58
View File
@@ -25,6 +25,16 @@
/* Slate 900 with opacity */
--border: #334155;
/* Slate 700 */
/* ── Palette data-viz (3 séries) ──
Slots 1-3 de la palette catégorielle de référence, pas-de-teinte propre au
fond sombre. Validée sur ce fond : bande de clarté, plancher de chroma,
séparation daltonienne (pire paire ΔE 9,4) et contraste ≥ 3:1 — toutes
paires, pas seulement les adjacentes. Ne pas retoucher une valeur isolément :
c'est l'ENSEMBLE qui passe les contrôles. */
--viz-1: #3987e5;
--viz-2: #d95926;
--viz-3: #199e70;
--border-strong: #475569;
/* Slate 600 */
@@ -70,6 +80,13 @@
--border: rgba(0, 0, 0, 0.07);
--border-strong: rgba(0, 0, 0, 0.12);
/* Mêmes teintes, pas choisis pour le fond clair (et non un simple éclaircissement
des valeurs sombres). L'aqua passe sous 3:1 sur blanc : la « règle de relief »
s'applique — étiquettes directes visibles + vue tableau, toutes deux présentes. */
--viz-1: #2a78d6;
--viz-2: #eb6834;
--viz-3: #1baf7a;
--text-primary: #1d1d1f;
/* Apple dark */
--text-secondary: #515154;
@@ -221,6 +238,47 @@
background: rgba(0, 0, 0, 0.18);
}
/* ── Défilement de la Grille ──
La règle ci-dessus (8 px, rail transparent, pouce très pâle) convient aux
panneaux où l'ascenseur est accessoire. Dans la Grille, le tableau fait
~2600 px de large : l'ascenseur HORIZONTAL est l'outil de navigation
principal, et un rail invisible ne dit pas qu'il reste des colonnes à
droite. On lui donne donc un rail dessiné et un pouce saisissable.
`scrollbar-*` couvre Firefox, que les sélecteurs -webkit- ignorent. */
.grid-scroll {
scrollbar-width: auto;
scrollbar-color: var(--text-muted) var(--bg-elevated);
}
.grid-scroll::-webkit-scrollbar {
width: 10px;
height: 14px;
}
.grid-scroll::-webkit-scrollbar-track:horizontal {
background: var(--bg-elevated);
border-top: 1px solid var(--border);
}
.grid-scroll::-webkit-scrollbar-thumb {
background: var(--text-muted);
border-radius: 99px;
/* Bordure transparente + background-clip : le pouce garde de l'air dans son
rail sans qu'on ait à jouer sur des marges, impossibles ici. */
border: 3px solid transparent;
background-clip: padding-box;
}
.grid-scroll::-webkit-scrollbar-thumb:hover {
background: var(--text-secondary);
background-clip: padding-box;
}
/* Le coin entre les deux ascenseurs, sinon laissé en blanc par le navigateur. */
.grid-scroll::-webkit-scrollbar-corner {
background: var(--bg-elevated);
}
/* ── Heatmap cells ── */
.heatmap-zero {
background: var(--bg-elevated);
+3 -2
View File
@@ -1,6 +1,6 @@
"use client";
import { LayoutGrid, Camera, FileDown, Settings, Package, BarChart2, TrendingUp, Trophy, Megaphone, PackageMinus, ShoppingCart, LogOut, User as UserIcon, Loader2, ChevronRight, ChevronLeft, MessageSquareCode } from "lucide-react";
import { LayoutGrid, Camera, FileDown, Settings, Package, PackageSearch, BarChart2, TrendingUp, Trophy, Megaphone, PackageMinus, ShoppingCart, LogOut, User as UserIcon, Loader2, ChevronRight, ChevronLeft, CalendarClock } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
@@ -11,6 +11,7 @@ import { useSession, signOut } from "next-auth/react";
const NAV_ITEMS = [
{ icon: BarChart2, label: "Dashboard", href: "/dashboard" },
{ icon: LayoutGrid, label: "Grille", href: "/grid" },
{ icon: PackageSearch, label: "Produits", href: "/produits" },
{ icon: TrendingUp, label: "Analytics", href: "/analytics" },
{ icon: Trophy, label: "Hit Parade", href: "/hit-parade" },
{ icon: Megaphone, label: "Publicités", href: "/publicites" },
@@ -18,7 +19,7 @@ const NAV_ITEMS = [
{ icon: ShoppingCart, label: "Commandes auto", href: "/commandes-auto" },
{ icon: Camera, label: "Snapshots", href: "/snapshots" },
{ icon: FileDown, label: "Exports", href: "/exports", adminOnly: true },
{ icon: MessageSquareCode, label: "AI Chat Admin", href: "/admin/ai-chat", adminOnly: true },
{ icon: CalendarClock, label: "Synchronisation", href: "/admin/synchronisation", adminOnly: true },
{ icon: Settings, label: "Paramètres", href: "/settings", adminOnly: true },
];
+64 -22
View File
@@ -1,7 +1,7 @@
"use client";
import React, { useEffect, useState } from "react";
import { CheckCircle2, X } from "lucide-react";
import React, { useCallback, useEffect, useState } from "react";
import { AlertCircle, CheckCircle2, X } from "lucide-react";
interface SuccessModalProps {
isOpen: boolean;
@@ -9,35 +9,57 @@ interface SuccessModalProps {
title: string;
message: string;
duration?: number;
/**
* `error` change l'icône, l'accent et **désactive la fermeture automatique** :
* un message d'échec doit rester à l'écran le temps d'être lu, et il ne doit
* pas s'afficher sous une coche verte de succès.
*/
variant?: "success" | "error";
/** Action secondaire optionnelle (ex. « Recharger la page »). */
action?: { label: string; onClick: () => void };
}
/** Durée de l'animation de sortie, alignée sur les classes `animate-out`. */
const EXIT_MS = 200;
export function SuccessModal({
isOpen,
onClose,
title,
message,
duration = 3000
duration = 3000,
variant = "success",
action,
}: SuccessModalProps) {
const [isVisible, setIsVisible] = useState(false);
// Seul l'état de SORTIE est porté par un state : l'entrée est jouée par CSS
// au montage (`animate-in`), ce qui évite un setState synchrone dans l'effet.
const [closing, setClosing] = useState(false);
const isError = variant === "error";
const handleClose = useCallback(() => {
setClosing(true);
setTimeout(() => {
setClosing(false);
onClose();
}, EXIT_MS);
}, [onClose]);
useEffect(() => {
if (isOpen) {
setIsVisible(true);
const timer = setTimeout(() => handleClose(), duration);
return () => clearTimeout(timer);
}
}, [isOpen, duration]);
// Une erreur ne disparaît pas toute seule : l'utilisateur la ferme.
if (!isOpen || isError) return;
const timer = setTimeout(() => handleClose(), duration);
return () => clearTimeout(timer);
}, [isOpen, duration, isError, handleClose]);
const handleClose = () => {
setIsVisible(false);
setTimeout(onClose, 250);
};
if (!isOpen) return null;
if (!isOpen && !isVisible) return null;
const state = closing ? "closed" : "open";
const Icon = isError ? AlertCircle : CheckCircle2;
return (
<div
className={`fixed inset-0 z-[100] flex items-center justify-center p-4 transition-all duration-250 ${isVisible ? "opacity-100 backdrop-blur-sm" : "opacity-0"}`}
data-state={state}
className="fixed inset-0 z-[100] flex items-center justify-center p-4 backdrop-blur-sm duration-200 data-[state=open]:animate-in data-[state=open]:fade-in data-[state=closed]:animate-out data-[state=closed]:fade-out"
>
{/* Backdrop */}
<div
@@ -48,11 +70,19 @@ export function SuccessModal({
{/* Panel */}
<div
className={`relative w-full max-w-sm rounded-2xl overflow-hidden shadow-2xl transition-all duration-250 ${isVisible ? "scale-100 translate-y-0" : "scale-95 translate-y-4"}`}
data-state={state}
className="relative w-full max-w-sm rounded-2xl overflow-hidden shadow-2xl duration-200 data-[state=open]:animate-in data-[state=open]:zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:zoom-out-95"
style={{ background: "var(--bg-surface)", border: "1px solid var(--border)" }}
>
{/* Accent bar */}
<div className="absolute top-0 left-0 w-full h-0.5" style={{ background: "linear-gradient(to right, var(--accent), var(--accent-success))" }} />
<div
className="absolute top-0 left-0 w-full h-0.5"
style={{
background: isError
? "var(--accent-error)"
: "linear-gradient(to right, var(--accent), var(--accent-success))",
}}
/>
{/* Close button */}
<button
@@ -64,8 +94,15 @@ export function SuccessModal({
<div className="p-8 flex flex-col items-center text-center">
{/* Icon */}
<div className="w-16 h-16 rounded-2xl bg-[var(--accent-bg)] border border-[var(--accent-border)] flex items-center justify-center mb-5">
<CheckCircle2 className="w-8 h-8 text-[var(--accent)]" />
<div
className="w-16 h-16 rounded-2xl flex items-center justify-center mb-5"
style={
isError
? { background: "var(--accent-error-bg)", border: "1px solid var(--accent-error)" }
: { background: "var(--accent-bg)", border: "1px solid var(--accent-border)" }
}
>
<Icon className="w-8 h-8" style={{ color: isError ? "var(--accent-error)" : "var(--accent)" }} />
</div>
<h3 className="text-lg font-bold text-[var(--text-primary)] mb-2 tracking-tight">
@@ -75,11 +112,16 @@ export function SuccessModal({
{message}
</p>
{action && (
<button onClick={action.onClick} className="apple-btn-primary mt-7 w-full justify-center">
{action.label}
</button>
)}
<button
onClick={handleClose}
className="apple-btn-primary mt-7 w-full justify-center"
className={`${action ? "apple-btn-secondary mt-2" : "apple-btn-primary mt-7"} w-full justify-center`}
>
Continuer
{action ? "Plus tard" : "Continuer"}
</button>
</div>
</div>
+147
View File
@@ -117,12 +117,159 @@ export const qlikNetworkMetrics = pgTable("qlik_network_metrics", {
qteReseau: numeric("qte_reseau", { precision: 14, scale: 2 }),
/** Nombre de magasins travaillant le produit (sur ~270) */
nbMagasinsReseau: integer("nb_magasins_reseau"),
/** CA moyen par magasin (productivité normalisée présence) */
caParMagasinReseau: numeric("ca_par_magasin_reseau", { precision: 16, scale: 2 }),
/** Taux de marge réseau (ratio brut Qlik) */
margePctReseau: numeric("marge_pct_reseau", { precision: 8, scale: 4 }),
/** Période couverte (libre, ex "12m" ou "2025") */
periode: varchar("periode", { length: 20 }),
/** Quantité vendue réseau par mois : { "YYYY-MM": qté } (12 derniers mois). */
qteByMonth: jsonb("qte_by_month"),
/**
* Détail mensuel complet : { "YYYY-MM": { qte, ca, nbMag, caMag, margePct } }.
* Superset de `qteByMonth`, qui reste maintenu tel quel pour la colonne
* « Tendance / Réseau » de la Grille.
*/
metricsByMonth: jsonb("metrics_by_month"),
/**
* Libellé de l'article tel que Qlik le renvoie. Renseigné par la recherche
* produit (qui interroge la dimension libellé) ; la sync fournisseur ne
* l'écrase pas. Permet d'afficher une fiche pour un produit réseau que le
* catalogue Nancy ne référence pas.
*/
libelleReseau: varchar("libelle_reseau", { length: 255 }),
/** Fournisseur de l'article côté Qlik, même origine que `libelleReseau`. */
fournisseurReseau: varchar("fournisseur_reseau", { length: 255 }),
/** Dernière synchro depuis Qlik */
fetchedAt: timestamp("fetched_at").defaultNow(),
});
/**
* Instantané persisté des lignes de grille (une ligne par fournisseur × article).
*
* Raison d'être : `getProductRows()` reconstruit la grille en direct et ne la garde
* qu'en mémoire 10 minutes — perdu à chaque redémarrage. L'API `/api/v1` ne doit
* jamais déclencher ce calcul, elle lit donc cette table, remplie en effet de bord
* quand quelqu'un ouvre la Grille. Aucun calcul supplémentaire n'est introduit : on
* arrête simplement de jeter le résultat.
*
* Seul `magasin = TOTAL` est persisté : `payload` embarque déjà les ventilations par
* magasin (sales12mByStore, caByStore…).
*/
export const gridRows = pgTable("grid_rows", {
codeFournisseur: varchar("code_fournisseur", { length: 20 }).notNull(),
codein: varchar("codein", { length: 20 }).notNull(),
// Colonnes scalaires : servent aux filtres, au tri et à la recherche en SQL.
nomFournisseur: varchar("nom_fournisseur", { length: 255 }),
libelle1: varchar("libelle1", { length: 500 }),
gtin: varchar("gtin", { length: 30 }),
reference: varchar("reference", { length: 100 }),
codeCentrale: varchar("code_centrale", { length: 20 }),
code1: varchar("code1", { length: 20 }),
code2: varchar("code2", { length: 20 }),
code3: varchar("code3", { length: 20 }),
codeGamme: varchar("code_gamme", { length: 20 }),
codeGammeInit: varchar("code_gamme_init", { length: 20 }),
totalCa: numeric("total_ca", { precision: 16, scale: 2 }),
totalQuantite: numeric("total_quantite", { precision: 14, scale: 2 }),
totalMarge: numeric("total_marge", { precision: 16, scale: 2 }),
tauxMarge: numeric("taux_marge", { precision: 8, scale: 4 }),
stockActuel: numeric("stock_actuel", { precision: 14, scale: 2 }),
caReseau: numeric("ca_reseau", { precision: 16, scale: 2 }),
qteReseau: numeric("qte_reseau", { precision: 14, scale: 2 }),
nbMagasinsReseau: integer("nb_magasins_reseau"),
caParMagasinReseau: numeric("ca_par_magasin_reseau", { precision: 16, scale: 2 }),
margePctReseau: numeric("marge_pct_reseau", { precision: 8, scale: 4 }),
/** ProductRow complet (séries mensuelles, ventilations par magasin, dates…). */
payload: jsonb("payload").notNull(),
/** Date du calcul ayant produit cette ligne — expose la fraîcheur au consommateur. */
computedAt: timestamp("computed_at").defaultNow().notNull(),
}, (table) => {
return [
uniqueIndex("uq_grid_rows_fou_codein").on(table.codeFournisseur, table.codein),
index("idx_grid_rows_fournisseur").on(table.codeFournisseur),
index("idx_grid_rows_codein").on(table.codein),
index("idx_grid_rows_gamme").on(table.codeGamme),
index("idx_grid_rows_code_centrale").on(table.codeCentrale),
index("idx_grid_rows_total_ca").on(table.totalCa),
index("idx_grid_rows_libelle").on(table.libelle1),
];
});
/**
* Clés d'API pour les appelants non-navigateur (scripts, outils externes).
*
* Seul le hachage SHA-256 est stocké : la clé en clair n'est affichée qu'une fois,
* à la création. Une clé révoquée conserve sa ligne (traçabilité) via `revokedAt`.
*/
export const apiKeys = pgTable("api_keys", {
id: serial("id").primaryKey(),
/** Nom lisible choisi à la création (ex "Export Excel comptabilité"). */
name: varchar("name", { length: 100 }).notNull(),
/** Préfixe en clair (ex "cf_a1b2c3") — sert à identifier la clé dans l'UI. */
keyPrefix: varchar("key_prefix", { length: 20 }).notNull(),
/** SHA-256 hexadécimal de la clé complète. */
keyHash: text("key_hash").notNull().unique(),
/** 'admin' ou 'user' — même sémantique que users.role. */
role: varchar("role", { length: 20 }).default("user").notNull(),
createdBy: varchar("created_by", { length: 50 }),
createdAt: timestamp("created_at").defaultNow(),
lastUsedAt: timestamp("last_used_at"),
revokedAt: timestamp("revoked_at"),
});
/**
* Paramétrage et suivi de la synchronisation nocturne, un enregistrement par
* fournisseur.
*
* Deux sources aux coûts très différents, donc deux cadences distinctes :
* l'instantané SQL prend une minute au pire, l'extraction Qlik jusqu'à huit —
* et le serveur Qlik abandonne quand on le sollicite trop. D'où `actifSql` et
* `actifQlik` séparés, et deux dates de dernier passage.
*
* L'ordre de passage se déduit de `dernier*At` (le plus ancien d'abord) : la
* nuit suivante reprend donc naturellement là où la précédente s'est arrêtée,
* et repart d'elle-même pour un nouveau tour quand tout a été traité. Aucun
* curseur ni compteur de tour à maintenir.
*/
export const syncFournisseurs = pgTable("sync_fournisseurs", {
codeFournisseur: varchar("code_fournisseur", { length: 20 }).primaryKey(),
nomFournisseur: varchar("nom_fournisseur", { length: 255 }),
/** Inclus dans la rotation de l'instantané SQL. */
actifSql: boolean("actif_sql").default(true).notNull(),
/** Inclus dans la rotation Qlik (beaucoup plus coûteuse). */
actifQlik: boolean("actif_qlik").default(true).notNull(),
/**
* Renseigné quand le planificateur a désactivé le fournisseur de lui-même —
* typiquement « aucun article ». Évite de réessayer chaque nuit un
* fournisseur vide, tout en gardant la raison visible pour le réactiver.
*/
desactiveMotif: text("desactive_motif"),
dernierSqlAt: timestamp("dernier_sql_at"),
/** 'succes' | 'echec' | 'vide' */
dernierSqlStatut: varchar("dernier_sql_statut", { length: 20 }),
dernierSqlLignes: integer("dernier_sql_lignes"),
dernierSqlErreur: text("dernier_sql_erreur"),
dernierSqlMs: integer("dernier_sql_ms"),
dernierQlikAt: timestamp("dernier_qlik_at"),
dernierQlikStatut: varchar("dernier_qlik_statut", { length: 20 }),
dernierQlikCodes: integer("dernier_qlik_codes"),
dernierQlikErreur: text("dernier_qlik_erreur"),
dernierQlikMs: integer("dernier_qlik_ms"),
updatedAt: timestamp("updated_at").defaultNow(),
}, (table) => {
return [
// Index de file d'attente : « le plus ancien d'abord », par source.
index("idx_sync_fou_sql").on(table.actifSql, table.dernierSqlAt),
index("idx_sync_fou_qlik").on(table.actifQlik, table.dernierQlikAt),
];
});
/** AI Context rules per supplier (Epic: AI Context) */
export const aiSupplierContext = pgTable("ai_supplier_context", {
/** Supplier code serving as the primary key */
+117
View File
@@ -0,0 +1,117 @@
"use server";
/**
* CollectFlow — Gestion des clés de l'API `/api/v1` (administrateurs).
*
* La clé en clair n'existe qu'une fois : elle est renvoyée par `createApiKey()` et
* jamais restituée ensuite (seul son hachage SHA-256 est stocké). Une clé révoquée
* conserve sa ligne, pour garder la trace de son usage passé.
*/
import { db } from "@/db";
import { apiKeys } from "@/db/schema";
import { desc, eq } from "drizzle-orm";
import { auth } from "@/lib/auth";
import { generateApiKey } from "@/lib/api-auth";
import { revalidatePath } from "next/cache";
async function ensureAdmin() {
const session = await auth();
if ((session?.user as { role?: string } | undefined)?.role !== "admin") {
throw new Error("Accès refusé : Droits administrateur requis.");
}
return session;
}
export interface ApiKeyRow {
id: number;
name: string;
keyPrefix: string;
role: string;
createdBy: string | null;
createdAt: string | null;
lastUsedAt: string | null;
revokedAt: string | null;
}
/** Liste les clés. Ne renvoie jamais de secret — seulement le préfixe lisible. */
export async function getApiKeys(): Promise<ApiKeyRow[]> {
await ensureAdmin();
const rows = await db
.select({
id: apiKeys.id,
name: apiKeys.name,
keyPrefix: apiKeys.keyPrefix,
role: apiKeys.role,
createdBy: apiKeys.createdBy,
createdAt: apiKeys.createdAt,
lastUsedAt: apiKeys.lastUsedAt,
revokedAt: apiKeys.revokedAt,
})
.from(apiKeys)
.orderBy(desc(apiKeys.createdAt));
return rows.map((r) => ({
...r,
createdAt: r.createdAt ? r.createdAt.toISOString() : null,
lastUsedAt: r.lastUsedAt ? r.lastUsedAt.toISOString() : null,
revokedAt: r.revokedAt ? r.revokedAt.toISOString() : null,
}));
}
/**
* Crée une clé et renvoie sa valeur **en clair, une seule et unique fois**.
* L'appelant doit la présenter immédiatement à l'utilisateur : elle est irrécupérable.
*/
export async function createApiKey(
name: string,
role: "admin" | "user" = "user",
): Promise<{ success: true; key: string; keyPrefix: string } | { success: false; error: string }> {
const session = await ensureAdmin();
const trimmed = name.trim();
if (!trimmed) return { success: false, error: "Le nom de la clé est obligatoire." };
if (trimmed.length > 100) return { success: false, error: "Nom trop long (100 caractères max)." };
try {
const { key, keyHash, keyPrefix } = generateApiKey();
await db.insert(apiKeys).values({
name: trimmed,
keyPrefix,
keyHash,
role,
createdBy: (session?.user as { name?: string | null } | undefined)?.name ?? null,
});
revalidatePath("/settings");
return { success: true, key, keyPrefix };
} catch (err) {
console.error("[api-keys] création KO:", err);
return { success: false, error: "Erreur technique lors de la création de la clé." };
}
}
/** Révoque une clé : elle est refusée dès l'appel suivant, mais reste listée. */
export async function revokeApiKey(id: number): Promise<{ success: boolean; error?: string }> {
await ensureAdmin();
try {
await db.update(apiKeys).set({ revokedAt: new Date() }).where(eq(apiKeys.id, id));
revalidatePath("/settings");
return { success: true };
} catch (err) {
console.error("[api-keys] révocation KO:", err);
return { success: false, error: "Erreur lors de la révocation." };
}
}
/** Supprime définitivement une clé révoquée (nettoyage de la liste). */
export async function deleteApiKey(id: number): Promise<{ success: boolean; error?: string }> {
await ensureAdmin();
try {
await db.delete(apiKeys).where(eq(apiKeys.id, id));
revalidatePath("/settings");
return { success: true };
} catch (err) {
console.error("[api-keys] suppression KO:", err);
return { success: false, error: "Erreur lors de la suppression." };
}
}
+366
View File
@@ -0,0 +1,366 @@
/**
* CollectFlow — Synchronisation nocturne des fournisseurs.
*
* Deux sources aux coûts très différents, donc deux files distinctes :
* • **SQL** — l'instantané de grille (`getProductRows`), ~43 s sur le plus gros
* fournisseur du catalogue. Alimente la Grille et `/api/v1`.
* • **Qlik** — les métriques réseau, jusqu'à 8 min par fournisseur, sur un
* serveur externe qui abandonne quand on le sollicite trop. Ce sont des
* agrégats mensuels : les rafraîchir chaque nuit n'apporterait rien.
*
* ORDRE DE PASSAGE — le point de conception central. La file est simplement
* « le plus ancien d'abord », en ignorant ceux déjà traités depuis l'ouverture
* de la fenêtre courante. Il en découle, sans curseur ni compteur de tour :
* • la nuit 2 reprend là où la nuit 1 s'est arrêtée (les non-traités sont les
* plus anciens) ;
* • quand plus personne n'est en attente, le tour est fini ; la nuit suivante
* tout le monde redevient éligible et le tour recommence ;
* • un fournisseur en échec garde une date ancienne, donc il repasse en tête —
* le rattrapage est automatique.
*
* La fenêtre ne peut pas interrompre un fournisseur en cours : elle signifie
* « ne plus en **démarrer** après l'heure de fin ».
*/
import "server-only";
import { and, asc, eq, isNull, lt, or, sql, type SQL } from "drizzle-orm";
import { db } from "@/db";
import { syncFournisseurs } from "@/db/schema";
import { getProductRows } from "@/features/grid/api/get-product-rows";
import { pgGetFournisseurs, pgGetArticlesByFournisseur } from "@/lib/pg-ff-client";
import { fetchNetworkMetricsPlaywright } from "@/lib/qlik-playwright";
import { upsertNetworkMetrics } from "@/lib/qlik-network-cache";
import { buildGridNetworkQlikDateFilter, envMonthsBack, QLIK_MONTHS_BACK_DEFAULT } from "@/lib/qlik-date-range";
import { readSyncSettings, type SyncSettings } from "@/features/admin/api/sync-settings";
export type { SyncSettings };
// ─── État courant, en mémoire ────────────────────────────────────────────────
export interface SyncSchedulerState {
enCours: boolean;
/** "auto" = déclenché par la fenêtre nocturne, "manuel" = lancé depuis l'admin. */
origine: "auto" | "manuel" | null;
debutAt: string | null;
finAt: string | null;
fournisseurCourant: string | null;
etape: "sql" | "qlik" | null;
sqlFaits: number;
sqlEchecs: number;
qlikFaits: number;
qlikEchecs: number;
/** Renseigné quand la file s'est vidée : le tour est bouclé. */
tourTermine: boolean;
arretDemande: boolean;
derniereErreur: string | null;
}
const etat: SyncSchedulerState = {
enCours: false, origine: null, debutAt: null, finAt: null,
fournisseurCourant: null, etape: null,
sqlFaits: 0, sqlEchecs: 0, qlikFaits: 0, qlikEchecs: 0,
tourTermine: false, arretDemande: false, derniereErreur: null,
};
export function getSyncSchedulerState(): SyncSchedulerState {
return { ...etat };
}
export function demanderArret(): void {
if (etat.enCours) etat.arretDemande = true;
}
// ─── Fenêtre horaire ─────────────────────────────────────────────────────────
/**
* Début de la fenêtre en cours, ou `null` si l'heure courante est en dehors.
*
* Gère le passage par minuit (ex. 22h → 5h) : dans ce cas, entre 0h et l'heure
* de fin, la fenêtre a commencé **la veille**.
*/
export function debutFenetre(s: SyncSettings, maintenant = new Date()): Date | null {
const h = maintenant.getHours();
const debutDuJour = new Date(maintenant);
debutDuJour.setHours(s.heureDebut, 0, 0, 0);
if (s.heureDebut < s.heureFin) {
return h >= s.heureDebut && h < s.heureFin ? debutDuJour : null;
}
// Fenêtre à cheval sur minuit.
if (h >= s.heureDebut) return debutDuJour;
if (h < s.heureFin) {
const veille = new Date(debutDuJour);
veille.setDate(veille.getDate() - 1);
return veille;
}
return null;
}
// ─── Référentiel des fournisseurs ────────────────────────────────────────────
/**
* Aligne la table de paramétrage sur le référentiel FF : ajoute les nouveaux
* fournisseurs (actifs par défaut), rafraîchit les noms. Ne supprime rien —
* l'historique de synchronisation d'un fournisseur disparu reste consultable.
*/
export async function seedSyncFournisseurs(): Promise<number> {
const fournisseurs = await pgGetFournisseurs();
if (fournisseurs.length === 0) return 0;
const CHUNK = 500;
for (let i = 0; i < fournisseurs.length; i += CHUNK) {
const lot = fournisseurs.slice(i, i + CHUNK);
await db
.insert(syncFournisseurs)
.values(lot.map((f) => ({ codeFournisseur: f.code, nomFournisseur: f.nom })))
.onConflictDoUpdate({
target: syncFournisseurs.codeFournisseur,
// Seul le nom est réaligné : les choix d'activation appartiennent à l'admin.
set: { nomFournisseur: sql`excluded.nom_fournisseur` },
});
}
return fournisseurs.length;
}
// ─── Files d'attente ─────────────────────────────────────────────────────────
/** Le plus ancien fournisseur SQL pas encore traité depuis l'ouverture de la fenêtre. */
async function prochainSql(depuis: Date) {
const [row] = await db
.select()
.from(syncFournisseurs)
.where(and(
eq(syncFournisseurs.actifSql, true),
or(isNull(syncFournisseurs.dernierSqlAt), lt(syncFournisseurs.dernierSqlAt, depuis)) as SQL,
))
.orderBy(asc(sql`${syncFournisseurs.dernierSqlAt} nulls first`))
.limit(1);
return row ?? null;
}
/**
* Le plus ancien fournisseur Qlik dont les données dépassent `qlikMinJours`.
* Le seuil en jours, plutôt que la fenêtre courante, évite de relancer chaque
* nuit une extraction de plusieurs minutes pour des agrégats mensuels.
*/
async function prochainQlik(qlikMinJours: number, maintenant: Date) {
const seuil = new Date(maintenant.getTime() - qlikMinJours * 24 * 3600 * 1000);
const [row] = await db
.select()
.from(syncFournisseurs)
.where(and(
eq(syncFournisseurs.actifQlik, true),
or(isNull(syncFournisseurs.dernierQlikAt), lt(syncFournisseurs.dernierQlikAt, seuil)) as SQL,
))
.orderBy(asc(sql`${syncFournisseurs.dernierQlikAt} nulls first`))
.limit(1);
return row ?? null;
}
// ─── Traitement d'un fournisseur ─────────────────────────────────────────────
/**
* Instantané SQL. `dernierSqlAt` est renseigné **même en cas d'échec** : sans
* cela, un fournisseur qui échoue resterait éternellement le plus ancien et
* bloquerait la file derrière lui.
*/
async function traiterSql(code: string, nom: string | null): Promise<void> {
const t0 = Date.now();
etat.fournisseurCourant = `${code}${nom ? ` — ${nom}` : ""}`;
etat.etape = "sql";
try {
const rows = await getProductRows({ codeFournisseur: code, magasin: "TOTAL", forceRefresh: true });
const vide = rows.length === 0;
await db.update(syncFournisseurs).set({
dernierSqlAt: new Date(),
dernierSqlStatut: vide ? "vide" : "succes",
dernierSqlLignes: rows.length,
dernierSqlErreur: null,
dernierSqlMs: Date.now() - t0,
// Un fournisseur sans article n'a rien à donner : on le sort des deux
// files plutôt que de le réessayer chaque nuit. L'admin voit le motif
// et peut le réactiver.
...(vide ? { actifSql: false, actifQlik: false, desactiveMotif: "Aucun article — désactivé automatiquement" } : {}),
updatedAt: new Date(),
}).where(eq(syncFournisseurs.codeFournisseur, code));
if (vide) console.log(`[sync-nuit] ${code} sans article — désactivé automatiquement`);
else etat.sqlFaits++;
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
etat.sqlEchecs++;
etat.derniereErreur = `${code} (SQL) : ${msg}`;
console.error(`[sync-nuit] SQL ${code} en échec :`, msg);
await db.update(syncFournisseurs).set({
dernierSqlAt: new Date(),
dernierSqlStatut: "echec",
dernierSqlErreur: msg.slice(0, 500),
dernierSqlMs: Date.now() - t0,
updatedAt: new Date(),
}).where(eq(syncFournisseurs.codeFournisseur, code)).catch(() => { /* le suivi ne doit pas casser la boucle */ });
}
}
/** Format d'un code centrale plausible — même filtre que la synchro manuelle. */
const CODE_CENTRALE = /^[A-Z0-9][A-Z0-9_-]{1,29}$/i;
/** Extraction Qlik. Même politique que le SQL sur l'horodatage en cas d'échec. */
async function traiterQlik(code: string, nom: string | null): Promise<void> {
const t0 = Date.now();
etat.fournisseurCourant = `${code}${nom ? ` — ${nom}` : ""}`;
etat.etape = "qlik";
try {
const articles = await pgGetArticlesByFournisseur(code);
const codes = [...new Set(
articles
.map((a) => String(a.codeCentrale ?? "").trim())
.filter((c) => c && c !== "-" && CODE_CENTRALE.test(c)),
)];
if (codes.length === 0) {
await db.update(syncFournisseurs).set({
dernierQlikAt: new Date(), dernierQlikStatut: "vide", dernierQlikCodes: 0,
dernierQlikErreur: null, dernierQlikMs: Date.now() - t0, updatedAt: new Date(),
}).where(eq(syncFournisseurs.codeFournisseur, code));
return;
}
const dateFilter = buildGridNetworkQlikDateFilter(new Date(), envMonthsBack("QLIK_SYNC_MONTHS_BACK", QLIK_MONTHS_BACK_DEFAULT));
const metrics = await fetchNetworkMetricsPlaywright(codes, undefined, dateFilter, code);
const n = await upsertNetworkMetrics([...metrics.values()]);
await db.update(syncFournisseurs).set({
dernierQlikAt: new Date(), dernierQlikStatut: "succes", dernierQlikCodes: n,
dernierQlikErreur: null, dernierQlikMs: Date.now() - t0, updatedAt: new Date(),
}).where(eq(syncFournisseurs.codeFournisseur, code));
etat.qlikFaits++;
console.log(`[sync-nuit] Qlik ${code} — ${n} codes en ${Math.round((Date.now() - t0) / 1000)} s`);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
etat.qlikEchecs++;
etat.derniereErreur = `${code} (Qlik) : ${msg}`;
console.error(`[sync-nuit] Qlik ${code} en échec :`, msg);
await db.update(syncFournisseurs).set({
dernierQlikAt: new Date(), dernierQlikStatut: "echec",
dernierQlikErreur: msg.slice(0, 500), dernierQlikMs: Date.now() - t0, updatedAt: new Date(),
}).where(eq(syncFournisseurs.codeFournisseur, code)).catch(() => { /* idem */ });
}
}
// ─── Boucle de travail ───────────────────────────────────────────────────────
/**
* Traite les files jusqu'à épuisement, sortie de fenêtre, ou demande d'arrêt.
*
* Les deux files avancent en parallèle : une extraction Qlik est intercalée
* tous les `sqlParQlik` fournisseurs SQL. Sans cet entrelacement, Qlik ne
* démarrerait jamais tant que la file SQL n'est pas vide.
*/
export async function lancerRound(origine: "auto" | "manuel", forcerHorsFenetre = false): Promise<void> {
if (etat.enCours) return;
const s = await readSyncSettings();
const maintenant = new Date();
const debut = debutFenetre(s, maintenant);
if (!forcerHorsFenetre && !debut) return;
// Un lancement manuel hors fenêtre borne la file sur « depuis ce matin ».
const depuis = debut ?? new Date(new Date(maintenant).setHours(0, 0, 0, 0));
Object.assign(etat, {
enCours: true, origine, debutAt: maintenant.toISOString(), finAt: null,
fournisseurCourant: null, etape: null,
sqlFaits: 0, sqlEchecs: 0, qlikFaits: 0, qlikEchecs: 0,
tourTermine: false, arretDemande: false, derniereErreur: null,
});
console.log(`[sync-nuit] round ${origine} démarré (fenêtre ${s.heureDebut}h-${s.heureFin}h, Qlik ≤ ${s.qlikParNuit})`);
try {
await seedSyncFournisseurs();
// Garde anti-boucle. L'avancement de la file repose sur l'écriture de
// `dernier*At`, et cette écriture est volontairement non bloquante en cas
// d'erreur. Si elle échoue, le fournisseur resterait le plus ancien et
// serait repris sans fin : on retient donc ce qui a déjà été tenté.
const tentes = new Set<string>();
let depuisDernierQlik = 0;
for (;;) {
if (etat.arretDemande) break;
if (!forcerHorsFenetre && !debutFenetre(s, new Date())) {
console.log("[sync-nuit] fin de fenêtre — arrêt après le fournisseur en cours");
break;
}
const qlikPossible = etat.qlikFaits + etat.qlikEchecs < s.qlikParNuit;
// Qlik prioritaire quand le quota d'entrelacement est atteint.
if (qlikPossible && depuisDernierQlik >= s.sqlParQlik) {
const cible = await prochainQlik(s.qlikMinJours, new Date());
if (cible && !tentes.has(`qlik:${cible.codeFournisseur}`)) {
tentes.add(`qlik:${cible.codeFournisseur}`);
await traiterQlik(cible.codeFournisseur, cible.nomFournisseur);
depuisDernierQlik = 0;
continue;
}
}
const cibleSql = await prochainSql(depuis);
if (cibleSql) {
if (tentes.has(`sql:${cibleSql.codeFournisseur}`)) {
console.error(
`[sync-nuit] ${cibleSql.codeFournisseur} ressort de la file après avoir été traité :`
+ " le suivi n'a pas pu être écrit. Arrêt du round pour ne pas boucler.",
);
break;
}
tentes.add(`sql:${cibleSql.codeFournisseur}`);
await traiterSql(cibleSql.codeFournisseur, cibleSql.nomFournisseur);
depuisDernierQlik++;
continue;
}
// File SQL vide : on consacre le temps restant à Qlik.
if (qlikPossible) {
const cible = await prochainQlik(s.qlikMinJours, new Date());
if (cible && !tentes.has(`qlik:${cible.codeFournisseur}`)) {
tentes.add(`qlik:${cible.codeFournisseur}`);
await traiterQlik(cible.codeFournisseur, cible.nomFournisseur);
depuisDernierQlik = 0;
continue;
}
}
etat.tourTermine = true;
console.log("[sync-nuit] plus rien en attente — tour terminé");
break;
}
} catch (e) {
etat.derniereErreur = e instanceof Error ? e.message : String(e);
console.error("[sync-nuit] round interrompu :", etat.derniereErreur);
} finally {
etat.enCours = false;
etat.fournisseurCourant = null;
etat.etape = null;
etat.finAt = new Date().toISOString();
console.log(
`[sync-nuit] round terminé — SQL ${etat.sqlFaits} ok / ${etat.sqlEchecs} en échec,`
+ ` Qlik ${etat.qlikFaits} ok / ${etat.qlikEchecs} en échec, tour bouclé=${etat.tourTermine}`,
);
}
}
/**
* Battement du minuteur : ne fait quelque chose que dans la fenêtre, et jamais
* deux rounds à la fois. Volontairement silencieux hors fenêtre — il est appelé
* chaque minute.
*/
export async function tickScheduler(): Promise<void> {
try {
const s = await readSyncSettings();
if (!s.actif || etat.enCours) return;
if (!debutFenetre(s)) return;
await lancerRound("auto");
} catch (e) {
console.error("[sync-nuit] tick en erreur :", e instanceof Error ? e.message : String(e));
}
}
+104
View File
@@ -0,0 +1,104 @@
/**
* CollectFlow — Réglages de la synchronisation nocturne.
*
* Rangés dans `data/.db-config.json`, comme les autres réglages de
* l'application (base, Qlik, API FF), pour qu'il n'y ait qu'un seul endroit à
* sauvegarder et à monter dans le conteneur.
*
* Module **serveur**, pas fichier d'actions : il exporte aussi des valeurs
* (les défauts, le type), ce que `"use server"` interdit.
*/
import "server-only";
import fs from "fs/promises";
import path from "path";
const DATA_DIR = path.join(process.cwd(), "data");
const CONFIG_FILE = path.join(DATA_DIR, ".db-config.json");
export interface SyncSettings {
/**
* Désactivé par défaut : un déploiement ne doit pas se mettre à solliciter
* Qlik et la base la nuit suivante sans que personne l'ait décidé.
*/
actif: boolean;
/** Heure d'ouverture de la fenêtre (0-23). */
heureDebut: number;
/** Heure de fermeture. Aucun fournisseur n'est **démarré** après. */
heureFin: number;
/**
* Nombre maximum d'extractions Qlik par nuit. Chacune peut prendre 8 minutes
* et le serveur abandonne quand il est trop sollicité : ce plafond est la
* principale protection contre une nuit entièrement passée sur Qlik.
*/
qlikParNuit: number;
/** Une extraction Qlik est intercalée tous les N fournisseurs SQL. */
sqlParQlik: number;
/**
* Âge minimal des données Qlik avant de les refaire. Ce sont des agrégats
* mensuels : les rafraîchir toutes les nuits coûterait cher pour rien.
*/
qlikMinJours: number;
}
export const SYNC_SETTINGS_DEFAUT: SyncSettings = {
actif: false,
heureDebut: 0,
heureFin: 5,
qlikParNuit: 6,
sqlParQlik: 20,
qlikMinJours: 7,
};
function borne(v: unknown, min: number, max: number, defaut: number): number {
const n = Number(v);
if (!Number.isFinite(n)) return defaut;
return Math.min(max, Math.max(min, Math.trunc(n)));
}
/** Normalise ce qui vient du fichier : il est éditable à la main. */
function normaliser(brut: Partial<SyncSettings> | undefined): SyncSettings {
const d = SYNC_SETTINGS_DEFAUT;
const s: SyncSettings = {
actif: Boolean(brut?.actif),
heureDebut: borne(brut?.heureDebut, 0, 23, d.heureDebut),
heureFin: borne(brut?.heureFin, 0, 23, d.heureFin),
qlikParNuit: borne(brut?.qlikParNuit, 0, 200, d.qlikParNuit),
sqlParQlik: borne(brut?.sqlParQlik, 1, 500, d.sqlParQlik),
qlikMinJours: borne(brut?.qlikMinJours, 0, 365, d.qlikMinJours),
};
// Une fenêtre de durée nulle ne s'ouvrirait jamais : on revient au défaut
// plutôt que de laisser le planificateur muet sans explication.
if (s.heureDebut === s.heureFin) {
s.heureDebut = d.heureDebut;
s.heureFin = d.heureFin;
}
return s;
}
export async function readSyncSettings(): Promise<SyncSettings> {
try {
const raw = await fs.readFile(CONFIG_FILE, "utf-8");
const cfg = JSON.parse(raw) as { syncNuit?: Partial<SyncSettings> };
return normaliser(cfg?.syncNuit);
} catch {
return { ...SYNC_SETTINGS_DEFAUT };
}
}
export async function saveSyncSettings(patch: Partial<SyncSettings>): Promise<SyncSettings> {
let existant: Record<string, unknown> = {};
try {
existant = JSON.parse(await fs.readFile(CONFIG_FILE, "utf-8"));
} catch {
// Pas encore de fichier : on le crée avec les seuls réglages de synchro.
}
const courant = normaliser((existant.syncNuit as Partial<SyncSettings>) ?? undefined);
const suivant = normaliser({ ...courant, ...patch });
await fs.mkdir(DATA_DIR, { recursive: true });
await fs.writeFile(CONFIG_FILE, JSON.stringify({ ...existant, syncNuit: suivant }, null, 2));
console.log(`[sync-nuit] réglages enregistrés : ${JSON.stringify(suivant)}`);
return suivant;
}
+41
View File
@@ -0,0 +1,41 @@
/**
* CollectFlow — Minuteur de la synchronisation nocturne.
*
* Démarré une fois au boot par `src/instrumentation.ts`. Il bat chaque minute
* et laisse `tickScheduler()` décider s'il y a quelque chose à faire : hors
* fenêtre, ou si un round tourne déjà, le battement ne coûte rien.
*
* Conséquence assumée du choix « minuteur interne » plutôt que cron système :
* rien ne tourne si l'application est arrêtée. En contrepartie, aucune
* configuration côté hôte, et le minuteur se réarme tout seul au redémarrage.
*/
import "server-only";
import { tickScheduler } from "@/features/admin/api/sync-scheduler";
const INTERVALLE_MS = 60_000;
/**
* Garde au niveau du module : en développement, le rechargement à chaud peut
* réévaluer `register()` plusieurs fois, et on se retrouverait avec autant de
* minuteurs empilés.
*/
let minuteur: ReturnType<typeof setInterval> | null = null;
export function startSyncTimer(): void {
if (minuteur) return;
console.log(`[sync-nuit] minuteur démarré (battement ${INTERVALLE_MS / 1000} s)`);
minuteur = setInterval(() => {
// `tickScheduler` capture déjà ses propres erreurs ; ce `catch` ne couvre
// qu'un rejet inattendu, pour qu'il ne devienne jamais une exception non
// gérée capable d'abattre le processus.
void tickScheduler().catch((e) => {
console.error("[sync-nuit] battement en erreur :", e instanceof Error ? e.message : String(e));
});
}, INTERVALLE_MS);
// Ne pas maintenir le processus en vie uniquement pour ce minuteur.
minuteur.unref?.();
}
@@ -0,0 +1,197 @@
"use client";
/**
* CollectFlow — Informations de connexion à l'API `/api/v1`.
*
* Tout ce qu'il faut pour appeler l'API depuis un script : URL de base, en-tête
* d'authentification, liste des endpoints et exemples copiables. L'URL de base est
* déduite de l'origine courante, pour rester juste quel que soit le déploiement.
*/
import { useEffect, useState } from "react";
import { Copy, Check, ExternalLink, Bot } from "lucide-react";
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
return (
<button
type="button"
onClick={() => { navigator.clipboard.writeText(text); setCopied(true); setTimeout(() => setCopied(false), 1500); }}
className="shrink-0 rounded-lg px-2 py-1.5"
style={{ background: "var(--bg-surface)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}
title="Copier"
>
{copied ? <Check className="w-3.5 h-3.5 text-emerald-500" /> : <Copy className="w-3.5 h-3.5" />}
</button>
);
}
function CodeLine({ children, copy }: { children: string; copy?: string }) {
return (
<div className="flex items-start gap-2">
<code
className="flex-1 rounded-lg px-2.5 py-1.5 text-[11px] font-mono break-all whitespace-pre-wrap"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-primary)" }}
>
{children}
</code>
<CopyButton text={copy ?? children} />
</div>
);
}
const ENDPOINTS: Array<{ method: string; path: string; desc: string }> = [
{ method: "GET", path: "/fournisseurs", desc: "Fournisseurs, avec la fraîcheur de leur instantané" },
{ method: "GET", path: "/grid?fournisseur=CODE", desc: "Lignes de grille d'un fournisseur" },
{ method: "GET", path: "/nomenclatures?fournisseur=CODE", desc: "Postes de nomenclature d'un fournisseur, avec nb d'articles et CA" },
{ method: "GET", path: "/products/search?q=terme", desc: "Recherche transversale, tous fournisseurs" },
{ method: "GET", path: "/products/{codein}", desc: "Fiche complète d'un produit" },
{ method: "GET", path: "/network/{codeCentrale}", desc: "Métriques réseau Qlik + courbe 12 mois" },
{ method: "GET", path: "/openapi.json", desc: "Spécification lisible par machine" },
];
const PARAMS: Array<{ name: string; desc: string }> = [
{ name: "page, limit", desc: "Pagination, sans plafond. Sur /grid, omettre limit renvoie TOUT le fournisseur en un appel ; ailleurs le défaut est 100" },
{ name: "sort, order", desc: "Tri, ex. sort=totalCa&order=desc" },
{ name: "search", desc: "Libellé, codein, GTIN, référence ou code centrale" },
{ name: "nomenclature", desc: "Préfixe de nomenclature : 32 (univers), 3202 (famille), 320211 (sous-famille)" },
{ name: "gamme, code1..code3", desc: "Filtres sur la gamme et les codes exacts de nomenclature" },
{ name: "fields", desc: "Champs à conserver, séparés par des virgules — allège fortement la réponse" },
{ name: "enrich", desc: "1 par défaut : métriques Qlik + gamme serveur relues à l'appel. 0 pour s'en dispenser" },
{ name: "compute", desc: "1 par défaut : calcule le fournisseur s'il n'a jamais été ouvert (premier appel plus lent). 0 pour échouer vite" },
];
export function ApiConnectionInfo() {
const [origin, setOrigin] = useState("");
// `window` n'existe pas au rendu serveur : lire l'origine après montage est le
// seul moyen d'afficher l'URL réelle sans provoquer d'écart d'hydratation.
// eslint-disable-next-line react-hooks/set-state-in-effect
useEffect(() => { setOrigin(window.location.origin); }, []);
const base = `${origin || "https://votre-domaine"}/api/v1`;
return (
<div className="space-y-5">
{/* Connexion */}
<div className="space-y-2">
<p className="text-[12px] font-semibold" style={{ color: "var(--text-primary)" }}>URL de base</p>
<CodeLine>{base}</CodeLine>
<p className="text-[12px] font-semibold pt-1" style={{ color: "var(--text-primary)" }}>Authentification</p>
<p className="text-[11px]" style={{ color: "var(--text-muted)" }}>
Une clé créée ci-dessous, dans l&apos;un ou l&apos;autre de ces en-têtes. Depuis un navigateur
connecté, la session suffit — aucune clé n&apos;est nécessaire.
</p>
<CodeLine>X-API-Key: VOTRE_CLE</CodeLine>
<CodeLine>Authorization: Bearer VOTRE_CLE</CodeLine>
</div>
{/* Endpoints */}
<div className="space-y-2">
<p className="text-[12px] font-semibold" style={{ color: "var(--text-primary)" }}>Endpoints</p>
<div className="rounded-xl overflow-hidden" style={{ border: "1px solid var(--border)" }}>
<table className="w-full text-[11px]">
<tbody>
{ENDPOINTS.map((e, i) => (
<tr key={e.path} style={{ borderTop: i === 0 ? undefined : "1px solid var(--border)" }}>
<td className="px-2.5 py-1.5 font-mono font-bold align-top" style={{ color: "var(--accent)", width: 40 }}>{e.method}</td>
<td className="px-2.5 py-1.5 font-mono align-top" style={{ color: "var(--text-primary)" }}>{e.path}</td>
<td className="px-2.5 py-1.5 align-top" style={{ color: "var(--text-muted)" }}>{e.desc}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Paramètres */}
<div className="space-y-2">
<p className="text-[12px] font-semibold" style={{ color: "var(--text-primary)" }}>Paramètres communs</p>
<div className="rounded-xl overflow-hidden" style={{ border: "1px solid var(--border)" }}>
<table className="w-full text-[11px]">
<tbody>
{PARAMS.map((p, i) => (
<tr key={p.name} style={{ borderTop: i === 0 ? undefined : "1px solid var(--border)" }}>
<td className="px-2.5 py-1.5 font-mono align-top whitespace-nowrap" style={{ color: "var(--text-primary)" }}>{p.name}</td>
<td className="px-2.5 py-1.5 align-top" style={{ color: "var(--text-muted)" }}>{p.desc}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Exemples */}
<div className="space-y-2">
<p className="text-[12px] font-semibold" style={{ color: "var(--text-primary)" }}>Exemples</p>
<CodeLine>{`curl -H "X-API-Key: VOTRE_CLE" \\\n "${base}/products/search?q=tapis&limit=20"`}</CodeLine>
<CodeLine>{`curl -H "X-API-Key: VOTRE_CLE" \\\n "${base}/grid?fournisseur=FOU001&fields=codein,libelle1,totalCa,codeGammeServeur"`}</CodeLine>
<p className="text-[11px] pt-1" style={{ color: "var(--text-muted)" }}>
Tout un fournisseur en un seul appel — il suffit d&apos;omettre <code className="font-mono">limit</code>,
et <code className="font-mono">meta.complet</code> confirme qu&apos;il ne reste rien à lire :
</p>
<CodeLine>{`curl -H "X-API-Key: VOTRE_CLE" \\\n "${base}/grid?fournisseur=FOU001"`}</CodeLine>
<p className="text-[11px] pt-1" style={{ color: "var(--text-muted)" }}>
Gros fournisseur (plusieurs dizaines de milliers d&apos;articles) : lister d&apos;abord les
postes de nomenclature, puis les traiter un par un.
</p>
<CodeLine>{`curl -H "X-API-Key: VOTRE_CLE" \\\n "${base}/nomenclatures?fournisseur=D005&niveau=1"`}</CodeLine>
<CodeLine>{`curl -H "X-API-Key: VOTRE_CLE" \\\n "${base}/grid?fournisseur=D005&nomenclature=32"`}</CodeLine>
</div>
{/* Branchement d'une IA externe (ChatGPT) */}
<div className="space-y-2">
<p className="text-[12px] font-semibold flex items-center gap-1.5" style={{ color: "var(--text-primary)" }}>
<Bot className="w-4 h-4" style={{ color: "var(--accent)" }} />
Connecter une IA externe (ChatGPT)
</p>
<ol className="text-[11px] space-y-1.5 list-decimal pl-4" style={{ color: "var(--text-secondary)" }}>
<li>Créez une clé d&apos;API dans la section ci-dessous et copiez-la.</li>
<li>Dans ChatGPT : <strong>Créer un GPT</strong> → onglet <strong>Configurer</strong> → <strong>Créer une action</strong>.</li>
<li>
Cliquez sur <strong>Importer depuis une URL</strong> et collez l&apos;adresse du schéma :
<div className="mt-1"><CodeLine>{`${base}/openapi.json`}</CodeLine></div>
<span style={{ color: "var(--text-muted)" }}>
Ce schéma est public (il ne contient aucune donnée) pour que ChatGPT puisse l&apos;importer.
Votre application doit être joignable depuis Internet.
</span>
</li>
<li>
Dans <strong>Authentification</strong>, choisissez <strong>Clé d&apos;API</strong>, type{" "}
<strong>Personnalisé</strong>, nom d&apos;en-tête <code className="font-mono">X-API-Key</code>,
et collez votre clé.
</li>
<li>Testez avec une question du type « cherche les produits tapis » — le GPT appellera <code className="font-mono">rechercherProduits</code>.</li>
</ol>
<p className="text-[11px]" style={{ color: "var(--text-muted)" }}>
Si votre domaine public diffère de celui affiché ici, renseignez la variable
d&apos;environnement <code className="font-mono">COLLECTFLOW_PUBLIC_URL</code> : elle fixe l&apos;URL
déclarée dans le schéma.
</p>
</div>
{/* Comportement à connaître */}
<div className="rounded-xl p-3 text-[11px] space-y-1.5"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}>
<p><strong>Aucun recalcul.</strong> L&apos;API lit l&apos;instantané de la grille, écrit quand la Grille
est ouverte dans l&apos;application. Un fournisseur jamais consulté renvoie <code className="font-mono">202</code>{" "}
<code className="font-mono">not_ready</code> plutôt que de faire attendre.</p>
<p><strong>Métriques Qlik</strong> (<code className="font-mono">network</code>) et{" "}
<strong>gamme serveur non modifiée</strong> (<code className="font-mono">codeGammeServeur</code>) sont
relues à chaque appel. <code className="font-mono">network</code> vaut{" "}
<code className="font-mono">null</code> quand le produit n&apos;a pas de données réseau.</p>
<p>Erreurs : <code className="font-mono">{`{ "error": { "code", "message" } }`}</code> avec{" "}
<code className="font-mono">401</code> (clé absente/invalide/révoquée),{" "}
<code className="font-mono">400</code>, <code className="font-mono">404</code>.</p>
</div>
<a
href="/api/v1/openapi.json"
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1.5 text-[12px] font-medium"
style={{ color: "var(--accent)" }}
>
Ouvrir la spécification OpenAPI <ExternalLink className="w-3.5 h-3.5" />
</a>
</div>
);
}
@@ -0,0 +1,217 @@
"use client";
/**
* CollectFlow — Gestion des clés de l'API `/api/v1`.
*
* La clé générée n'est affichable qu'une fois : elle est mise en évidence après
* création, puis irrécupérable (seul son hachage est stocké côté base).
*/
import { useState, useEffect, useCallback } from "react";
import { KeyRound, Plus, Loader2, Copy, Check, Ban, Trash2, AlertTriangle } from "lucide-react";
import { getApiKeys, createApiKey, revokeApiKey, deleteApiKey, type ApiKeyRow } from "../api/api-key-actions";
function fmtDate(iso: string | null): string {
if (!iso) return "—";
return new Date(iso).toLocaleString("fr-FR", { dateStyle: "short", timeStyle: "short" });
}
export function ApiKeyManagement() {
const [keys, setKeys] = useState<ApiKeyRow[]>([]);
const [loading, setLoading] = useState(true);
const [creating, setCreating] = useState(false);
const [name, setName] = useState("");
const [role, setRole] = useState<"admin" | "user">("user");
const [freshKey, setFreshKey] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const [error, setError] = useState<string | null>(null);
// Pas de setState synchrone ici : `loading` démarre déjà à true, et le premier
// statement est un await. Cela évite une cascade de rendus au montage.
const refresh = useCallback(async () => {
try {
setKeys(await getApiKeys());
setError(null);
} catch (e) {
setError(e instanceof Error ? e.message : "Erreur de chargement");
} finally {
setLoading(false);
}
}, []);
// Chargement initial depuis la base — la donnée vient d'un système externe, pas
// d'un état dérivé. `refresh` n'appelle setState qu'après un await.
// eslint-disable-next-line react-hooks/set-state-in-effect
useEffect(() => { refresh(); }, [refresh]);
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim() || creating) return;
setCreating(true);
setError(null);
const res = await createApiKey(name.trim(), role);
if (res.success) {
setFreshKey(res.key);
setName("");
setCopied(false);
await refresh();
} else {
setError(res.error);
}
setCreating(false);
};
const handleRevoke = async (id: number) => {
await revokeApiKey(id);
await refresh();
};
const handleDelete = async (id: number) => {
await deleteApiKey(id);
await refresh();
};
return (
<div className="space-y-4">
<p className="text-[12px] text-[var(--text-secondary)]">
Les clés donnent accès à l&apos;API de lecture <code className="font-mono">/api/v1</code> (grille,
recherche, métriques réseau) depuis un script ou un outil externe, via l&apos;en-tête{" "}
<code className="font-mono">X-API-Key</code>. Depuis un navigateur connecté, la session suffit.
</p>
{/* Clé fraîchement créée — visible une seule fois */}
{freshKey && (
<div className="rounded-xl p-3.5 space-y-2"
style={{ background: "var(--accent-bg)", border: "1px solid var(--accent-border)" }}>
<div className="flex items-start gap-2 text-[12px] font-semibold" style={{ color: "var(--text-primary)" }}>
<AlertTriangle className="w-4 h-4 shrink-0 mt-px" style={{ color: "var(--accent)" }} />
Copiez cette clé maintenant — elle ne sera plus jamais affichée.
</div>
<div className="flex items-center gap-2">
<code className="flex-1 rounded-lg px-2.5 py-2 text-[12px] font-mono break-all"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-primary)" }}>
{freshKey}
</code>
<button
type="button"
onClick={() => { navigator.clipboard.writeText(freshKey); setCopied(true); }}
className="rounded-lg px-2.5 py-2 shrink-0"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}
title="Copier"
>
{copied ? <Check className="w-4 h-4 text-emerald-500" /> : <Copy className="w-4 h-4" />}
</button>
</div>
<button
type="button"
onClick={() => setFreshKey(null)}
className="text-[11px] underline"
style={{ color: "var(--text-muted)" }}
>
J&apos;ai copié la clé, masquer
</button>
</div>
)}
{/* Création */}
<form onSubmit={handleCreate} className="flex flex-col sm:flex-row gap-2">
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Nom de la clé (ex. Export comptabilité)"
className="flex-1 rounded-lg px-3 py-2 text-[13px] outline-none"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-primary)" }}
/>
<select
value={role}
onChange={(e) => setRole(e.target.value as "admin" | "user")}
className="rounded-lg px-3 py-2 text-[13px] outline-none"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-primary)" }}
>
<option value="user">Rôle : utilisateur</option>
<option value="admin">Rôle : administrateur</option>
</select>
<button
type="submit"
disabled={creating || !name.trim()}
className="rounded-lg px-4 py-2 text-[13px] font-semibold text-white disabled:opacity-50 flex items-center justify-center gap-1.5"
style={{ background: "var(--accent)" }}
>
{creating ? <Loader2 className="w-4 h-4 animate-spin" /> : <Plus className="w-4 h-4" />}
Créer
</button>
</form>
{error && (
<p className="text-[12px]" style={{ color: "var(--accent-error)" }}>{error}</p>
)}
{/* Liste */}
{loading ? (
<div className="flex items-center gap-2 text-[12px]" style={{ color: "var(--text-muted)" }}>
<Loader2 className="w-3.5 h-3.5 animate-spin" /> Chargement…
</div>
) : keys.length === 0 ? (
<p className="text-[12px]" style={{ color: "var(--text-muted)" }}>Aucune clé pour le moment.</p>
) : (
<div className="rounded-xl overflow-hidden" style={{ border: "1px solid var(--border)" }}>
<table className="w-full text-[12px]">
<thead>
<tr style={{ background: "var(--bg-elevated)" }}>
<th className="text-left px-3 py-2 font-semibold" style={{ color: "var(--text-secondary)" }}>Nom</th>
<th className="text-left px-3 py-2 font-semibold" style={{ color: "var(--text-secondary)" }}>Préfixe</th>
<th className="text-left px-3 py-2 font-semibold" style={{ color: "var(--text-secondary)" }}>Rôle</th>
<th className="text-left px-3 py-2 font-semibold" style={{ color: "var(--text-secondary)" }}>Créée</th>
<th className="text-left px-3 py-2 font-semibold" style={{ color: "var(--text-secondary)" }}>Dernier usage</th>
<th className="px-3 py-2"></th>
</tr>
</thead>
<tbody>
{keys.map((k) => (
<tr key={k.id} style={{ borderTop: "1px solid var(--border)", opacity: k.revokedAt ? 0.55 : 1 }}>
<td className="px-3 py-2 font-medium" style={{ color: "var(--text-primary)" }}>
<span className="inline-flex items-center gap-1.5">
<KeyRound className="w-3.5 h-3.5" style={{ color: "var(--text-muted)" }} />
{k.name}
</span>
{k.revokedAt && (
<span className="ml-2 rounded px-1.5 py-px text-[9px] font-bold uppercase"
style={{ background: "var(--accent-error-bg)", color: "var(--accent-error)" }}>
Révoquée
</span>
)}
</td>
<td className="px-3 py-2 font-mono" style={{ color: "var(--text-secondary)" }}>{k.keyPrefix}…</td>
<td className="px-3 py-2" style={{ color: "var(--text-secondary)" }}>{k.role}</td>
<td className="px-3 py-2" style={{ color: "var(--text-muted)" }}>{fmtDate(k.createdAt)}</td>
<td className="px-3 py-2" style={{ color: "var(--text-muted)" }}>{fmtDate(k.lastUsedAt)}</td>
<td className="px-3 py-2 text-right whitespace-nowrap">
{!k.revokedAt ? (
<button
onClick={() => handleRevoke(k.id)}
className="inline-flex items-center gap-1 rounded-lg px-2 py-1"
style={{ color: "var(--accent-error)" }}
title="Révoquer"
>
<Ban className="w-3.5 h-3.5" /> Révoquer
</button>
) : (
<button
onClick={() => handleDelete(k.id)}
className="inline-flex items-center gap-1 rounded-lg px-2 py-1"
style={{ color: "var(--text-muted)" }}
title="Supprimer définitivement"
>
<Trash2 className="w-3.5 h-3.5" /> Supprimer
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
@@ -0,0 +1,148 @@
"use client";
/**
* CollectFlow — Préchauffage des données exposées par l'API.
*
* L'API `/api/v1` ne calcule jamais : elle lit un instantané écrit quand la Grille est
* ouverte dans l'application. Sans ce préchauffage, une IA externe ne verrait que les
* fournisseurs déjà consultés à la main — d'où ce bouton, qui calcule tout le catalogue
* une bonne fois.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { Loader2, Play, CheckCircle, AlertTriangle, Database } from "lucide-react";
interface WarmupState {
status: "idle" | "running" | "success" | "error";
total: number;
done: number;
skipped: number;
failed: number;
currentFournisseur?: string;
finishedAt?: string;
error?: string;
lastErrors: string[];
}
export function GridWarmup() {
const [state, setState] = useState<WarmupState | null>(null);
const [error, setError] = useState<string | null>(null);
const pollRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const running = state?.status === "running";
const fetchState = useCallback(async () => {
try {
const res = await fetch("/api/admin/grid-warmup");
if (res.ok) setState(await res.json());
} catch {
// Réseau instable : on retentera au tick suivant.
}
}, []);
// Récupère l'état au montage : un préchauffage peut déjà tourner.
// eslint-disable-next-line react-hooks/set-state-in-effect
useEffect(() => { fetchState(); }, [fetchState]);
// Suivi de l'avancement tant que le job tourne.
useEffect(() => {
if (!running) return;
let cancelled = false;
const tick = async () => {
await fetchState();
if (!cancelled) pollRef.current = setTimeout(tick, 2000);
};
pollRef.current = setTimeout(tick, 2000);
return () => { cancelled = true; if (pollRef.current) clearTimeout(pollRef.current); };
}, [running, fetchState]);
const start = async () => {
setError(null);
try {
const res = await fetch("/api/admin/grid-warmup?staleHours=24", { method: "POST" });
const data = await res.json();
if (!res.ok) { setError(data.error ?? `Erreur ${res.status}`); return; }
setState(data);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
}
};
const traites = (state?.done ?? 0) + (state?.skipped ?? 0) + (state?.failed ?? 0);
const pct = state && state.total > 0 ? Math.round((traites / state.total) * 100) : 0;
return (
<div className="space-y-3">
<p className="text-[12px]" style={{ color: "var(--text-secondary)" }}>
L&apos;API ne recalcule jamais : elle lit un instantané écrit quand la Grille est ouverte dans
l&apos;application. Sans préchauffage, une IA externe ne verrait que les fournisseurs déjà consultés.
Ce bouton calcule <strong>tous les fournisseurs</strong> ; ceux déjà à jour depuis moins de 24 h sont
sautés, il est donc sans risque de le relancer.
</p>
<div className="flex items-center gap-2.5">
<button
onClick={start}
disabled={running}
className="rounded-lg px-4 py-2 text-[13px] font-semibold text-white disabled:opacity-50 flex items-center gap-1.5"
style={{ background: "var(--accent)" }}
>
{running ? <Loader2 className="w-4 h-4 animate-spin" /> : <Play className="w-4 h-4" />}
{running ? "Préparation en cours…" : "Préparer les données pour l'API"}
</button>
{state && state.total > 0 && (
<span className="text-[12px] tabular-nums" style={{ color: "var(--text-secondary)" }}>
{traites} / {state.total} ({pct} %)
</span>
)}
</div>
{running && state && (
<div className="space-y-1.5">
<div className="h-1.5 rounded-full overflow-hidden" style={{ background: "var(--bg-elevated)" }}>
<div className="h-full transition-all" style={{ width: `${pct}%`, background: "var(--accent)" }} />
</div>
{state.currentFournisseur && (
<p className="text-[11px] truncate" style={{ color: "var(--text-muted)" }}>
En cours : {state.currentFournisseur}
</p>
)}
</div>
)}
{state?.status === "success" && (
<div className="flex items-start gap-2 text-[12px]" style={{ color: "var(--text-secondary)" }}>
<CheckCircle className="w-4 h-4 shrink-0 mt-px text-emerald-500" />
<span>
Terminé : <strong>{state.done}</strong> fournisseur(s) calculé(s),{" "}
<strong>{state.skipped}</strong> déjà à jour
{state.failed > 0 && <>, <strong style={{ color: "var(--accent-error)" }}>{state.failed} en échec</strong></>}.
</span>
</div>
)}
{state && state.failed > 0 && state.lastErrors.length > 0 && (
<details className="text-[11px]" style={{ color: "var(--text-muted)" }}>
<summary className="cursor-pointer">Voir les échecs ({state.lastErrors.length} premiers)</summary>
<ul className="mt-1 pl-4 list-disc space-y-0.5">
{state.lastErrors.map((e, i) => <li key={i} className="break-all">{e}</li>)}
</ul>
</details>
)}
{(error || state?.error) && (
<div className="flex items-start gap-2 text-[12px]" style={{ color: "var(--accent-error)" }}>
<AlertTriangle className="w-4 h-4 shrink-0 mt-px" />
<span>{error ?? state?.error}</span>
</div>
)}
{state?.status === "idle" && (
<p className="text-[11px] flex items-center gap-1.5" style={{ color: "var(--text-muted)" }}>
<Database className="w-3.5 h-3.5" />
Aucun préchauffage lancé depuis le démarrage de l&apos;application.
</p>
)}
</div>
);
}
+159 -9
View File
@@ -9,14 +9,16 @@ import {
pgGetNomenclatureByFournisseur,
pgGetStockByFournisseur,
pgGetCommandesByFournisseur,
pgGetPrixVenteByFournisseur,
type PgStockRow,
} from "@/lib/pg-ff-client";
import { db } from "@/db";
import { sessionSnapshots } from "@/db/schema";
import { eq, desc } from "drizzle-orm";
import { getNetworkMetricsByCodeCentrale } from "@/lib/qlik-network-cache";
const NB_MAGASINS_RESEAU = 270;
import { NB_MAGASINS_RESEAU } from "@/features/grid/lib/network-trend";
// Persiste l'instantané lu par /api/v1 : sans lui, l'API n'aurait aucune donnée.
import { upsertGridRows } from "@/lib/grid-store";
interface GetProductRowsInput {
codeFournisseur: string;
@@ -47,10 +49,50 @@ export function invalidateGridRowsCache(codeFournisseur?: string) {
gridRowsPending.clear();
}
/**
* Rafraîchit UNIQUEMENT la colonne INIT (codeGammeInit = gamme serveur courante)
* sur des lignes servies depuis le cache. La colonne Gamme (codeGamme) n'est pas
* touchée : elle conserve la valeur issue du snapshot. Ainsi l'état serveur (INIT)
* est toujours à jour à chaque chargement, même sur un hit du cache 10 min — et le
* marqueur "modifié" (Gamme ≠ INIT) se résorbe dès que le serveur rattrape la modif.
*/
async function refreshGammeInit(rows: ProductRow[], codeFournisseur: string): Promise<void> {
try {
const gammeMap = await pgGetGammesByFournisseur(codeFournisseur);
if (gammeMap.size === 0) return;
for (const row of rows) {
const live = gammeMap.get(row.codein);
if (live !== undefined) row.codeGammeInit = live as GammeCode;
}
} catch (e) {
console.error("[getProductRows] refreshGammeInit error:", (e as Error).message?.slice(0, 200));
}
}
/**
* Écrit l'instantané de grille lu par /api/v1 (table `grid_rows`).
*
* Seul `magasin = TOTAL` est persisté : `ProductRow` embarque déjà les ventilations
* par magasin (sales12mByStore, caByStore…), inutile de stocker trois variantes.
* Toute erreur est avalée : la persistance ne doit jamais casser l'affichage.
*/
async function persistGridSnapshot(input: GetProductRowsInput, rows: ProductRow[]): Promise<void> {
if ((input.magasin ?? "TOTAL") !== "TOTAL") return;
try {
const n = await upsertGridRows(input.codeFournisseur, rows);
console.log(`[getProductRows] instantané persisté: ${n} lignes pour ${input.codeFournisseur}`);
} catch (e) {
console.error("[getProductRows] persistance grid_rows KO:", (e as Error).message?.slice(0, 200));
}
}
export async function getProductRows(input: GetProductRowsInput): Promise<ProductRow[]> {
const cacheKey = gridRowsCacheKey(input);
const cached = gridRowsCache.get(cacheKey);
if (!input.forceRefresh && cached && Date.now() - cached.createdAt < GRID_ROWS_CACHE_TTL_MS) {
// La colonne INIT doit refléter l'état serveur à CHAQUE chargement,
// y compris sur un hit de cache (les données lourdes restent, elles, cachées).
await refreshGammeInit(cached.rows, input.codeFournisseur);
return cached.rows;
}
@@ -62,6 +104,10 @@ export async function getProductRows(input: GetProductRowsInput): Promise<Produc
const promise = buildProductRows(input).then((rows) => {
gridRowsCache.set(cacheKey, { rows, createdAt: Date.now() });
gridRowsPending.delete(cacheKey);
// Persiste l'instantané pour /api/v1 : le calcul vient d'avoir lieu, on
// arrête simplement de jeter le résultat. Volontairement NON bloquant —
// l'affichage de la Grille ne doit pas attendre l'écriture.
void persistGridSnapshot(input, rows);
return rows;
}).catch((error) => {
gridRowsPending.delete(cacheKey);
@@ -79,7 +125,7 @@ async function buildProductRows(input: GetProductRowsInput): Promise<ProductRow[
try {
const { dateDebut, dateFin } = buildLast12MonthsRange();
// ─── Phase 1 : 6 requêtes SQL en parallèle ────────────────────────────
// ─── Phase 1 : 7 requêtes SQL en parallèle ────────────────────────────
// Remplace des centaines/milliers d'appels HTTP per-article.
const [
articles,
@@ -88,6 +134,7 @@ async function buildProductRows(input: GetProductRowsInput): Promise<ProductRow[
nomMap,
stockMap,
commandesMap,
prixVenteMap,
] = await Promise.all([
pgGetArticlesByFournisseur(codeFournisseur).catch(e => { console.error("[getProductRows] pgGetArticlesByFournisseur ERROR:", e); return []; }),
pgGetMensuelByFournisseur(codeFournisseur, dateDebut, dateFin).catch(e => { console.error("[getProductRows] pgGetMensuelByFournisseur ERROR:", e); return []; }),
@@ -95,6 +142,7 @@ async function buildProductRows(input: GetProductRowsInput): Promise<ProductRow[
pgGetNomenclatureByFournisseur(codeFournisseur).catch(e => { console.error("[getProductRows] pgGetNomenclatureByFournisseur ERROR:", e); return new Map(); }),
pgGetStockByFournisseur(codeFournisseur).catch(e => { console.error("[getProductRows] pgGetStockByFournisseur ERROR:", e); return new Map<string, PgStockRow[]>(); }),
pgGetCommandesByFournisseur(codeFournisseur).catch(e => { console.error("[getProductRows] pgGetCommandesByFournisseur ERROR:", e); return new Map<string, number>(); }),
pgGetPrixVenteByFournisseur(codeFournisseur).catch(e => { console.error("[getProductRows] pgGetPrixVenteByFournisseur ERROR:", e); return new Map<string, Record<string, number>>(); }),
]);
console.log(`[getProductRows] ${articles.length} articles, ${mensuelRows.length} mensuel rows, ${gammeMap.size} gammes`);
@@ -243,6 +291,25 @@ async function buildProductRows(input: GetProductRowsInput): Promise<ProductRow[
product.caByStore[site] = sCa;
product.margeByStore[site] = sMarge;
}
// Le stock TOTAL se recalcule ici, à partir des séries par site.
//
// Il était sommé plus haut depuis les lignes mensuelles brutes, ce
// qui perdait tout magasin SANS MOUVEMENT dans le mois : le stock
// est un niveau, pas un flux — un magasin qui n'a rien vendu ni reçu
// détient toujours sa marchandise. Sur un produit à faible rotation,
// « tous magasins » n'affichait donc que le site actif, en pratique
// Frouard. Les séries par site, elles, sont reportées d'un mois sur
// l'autre : leur somme est la bonne.
const sites = Object.keys(product.stock12mByStore);
if (sites.length > 0) {
for (const periode of sortedPeriods) {
product.stock12m[periode] = sites.reduce(
(total, site) => total + (product.stock12mByStore![site][periode] ?? 0),
0,
);
}
}
}
// workingStores : sites avec au moins 1 vente sur les 12 derniers mois
@@ -310,10 +377,42 @@ async function buildProductRows(input: GetProductRowsInput): Promise<ProductRow[
}
}
// ─── Phase 7b : Prix de vente (cube_pv, sinon le PV du cube de stock) ─
//
// `article_infosup.prix_vente_mini`, renseigné en Phase 3, n'est qu'une
// borne de paramétrage : elle est vide en base et laissait la colonne PV
// entièrement creuse. Le vrai prix vit dans `cube_pv`, par article ET par
// site. On garde le détail par magasin — deux magasins peuvent ne pas
// pratiquer le même prix, et l'afficher vaut mieux que le moyenner.
for (const product of productMap.values()) {
const parSite = prixVenteMap.get(product.codein);
if (parSite && Object.keys(parSite).length > 0) {
product.prixVenteByStore = parSite;
} else {
// Filet de secours : le cube de stock porte le même PV, mais
// seulement pour les articles qui ont une ligne de stock.
const depuisStock: Record<string, number> = {};
for (const s of stockMap.get(product.codein) ?? []) {
const pv = Number(s.pv);
const site = String(s.site ?? "").trim();
if (site && Number.isFinite(pv) && pv > 0) depuisStock[site] = pv;
}
if (Object.keys(depuisStock).length > 0) product.prixVenteByStore = depuisStock;
}
const prix = Object.values(product.prixVenteByStore ?? {});
// Le plus élevé quand les magasins divergent : une moyenne inventerait
// un prix qu'aucune caisse ne pratique. L'écart est signalé à l'écran.
if (prix.length > 0) product.prixVente = Math.max(...prix);
}
// ─── Phase 8 : Données réseau Qlik (CA / Qté / nb magasins par code centrale) ──
await enrichWithNetworkMetrics(productMap);
// ─── Phase 9 : Restaurer gammes depuis dernier snapshot ──────────────
// La colonne Gamme (codeGamme) conserve la valeur du snapshot telle quelle.
// La colonne INIT (codeGammeInit) reste, elle, l'état LIVE du serveur
// rechargé en Phase 6 — jamais écrasée ici.
try {
const snaps = await db
.select()
@@ -327,7 +426,7 @@ async function buildProductRows(input: GetProductRowsInput): Promise<ProductRow[
for (const [codein, change] of Object.entries(changes)) {
const product = productMap.get(codein);
if (product && change.after) {
// codeGammeInit reste figé — seulement codeGamme est overridé
// codeGammeInit reste figé (état serveur) — seul codeGamme est overridé
product.codeGamme = change.after as GammeCode;
}
}
@@ -348,7 +447,14 @@ async function buildProductRows(input: GetProductRowsInput): Promise<ProductRow[
} catch (error) {
console.error(`[getProductRows] Error for ${codeFournisseur}:`, error);
return [];
// Ne PAS renvoyer [] : une liste vide est indiscernable d'un fournisseur
// sans article. La Grille affichait une page blanche sans explication, et
// la synchro nocturne prenait la panne pour un fournisseur vide — qu'elle
// désactivait alors automatiquement. La remonter laisse chaque appelant la
// traiter comme une erreur (bandeau rouge, statut « echec »).
throw error instanceof Error
? error
: new Error(`Calcul de la grille impossible pour ${codeFournisseur} : ${String(error)}`);
}
}
@@ -369,9 +475,22 @@ async function reconcileSelectedStoreFromMensuelApi(
if (candidates.length === 0) return;
// Ce rattrapage fait UNE requête HTTP par article : sur un gros fournisseur,
// les candidats se comptent par milliers et le changement de magasin se fige
// plusieurs minutes. On le borne, et on dit ce qui a été laissé de côté
// plutôt que de tronquer en silence.
const PLAFOND = Number(process.env.GRID_STORE_RECONCILE_MAX ?? 300);
const retenus = candidates.slice(0, PLAFOND);
if (candidates.length > retenus.length) {
console.warn(
`[getProductRows] rattrapage magasin ${magasin} borné à ${retenus.length} articles`
+ ` sur ${candidates.length} : les autres gardent la valeur SQL.`,
);
}
try {
const mensuelMap = await getMensuelByArticles(
candidates.map((row) => ({
retenus.map((row) => ({
codein: row.codein,
libelle1: row.libelle1,
codefou: row.codeFournisseur,
@@ -383,7 +502,7 @@ async function reconcileSelectedStoreFromMensuelApi(
);
let fixedRows = 0;
for (const row of candidates) {
for (const row of retenus) {
const entries = (mensuelMap.get(row.codein) ?? []).filter((entry) => entry.site === magasin);
if (entries.length === 0) continue;
@@ -391,8 +510,10 @@ async function reconcileSelectedStoreFromMensuelApi(
for (const entry of entries) {
const period = entry.mois.replace("-", "");
if (!sortedPeriods.includes(period)) continue;
const qty = Math.abs(Number(entry.ventes?.qte_vendue ?? 0) || 0);
const ca = Math.abs(Number(entry.ventes?.ca_ht ?? 0) || 0);
// qte_vendue / ca_ht sont NÉGATIFS côté API (ventes nettes) : on
// les nie au lieu de Math.abs pour que les retours restent déduits.
const qty = -(Number(entry.ventes?.qte_vendue ?? 0) || 0);
const ca = -(Number(entry.ventes?.ca_ht ?? 0) || 0);
const marge = Number(entry.ventes?.marge ?? 0) || 0;
const stock = Number(entry.stock_fin_mois ?? 0) || 0;
const receptions = Number(entry.receptions?.qte_recue ?? 0) || 0;
@@ -457,6 +578,29 @@ async function reconcileSelectedStoreFromMensuelApi(
}
}
/**
* Extrait la série « nombre de magasins vendeurs » du détail mensuel complet.
*
* `metricsByMonth` porte les cinq mesures Qlik du mois ; seule `nbMag` sert à la
* deuxième courbe de la carte tendance. Renvoie `null` plutôt qu'un objet vide
* pour que l'UI distingue « pas de donnée » de « zéro magasin ».
*/
function nbMagParMois(
metricsByMonth: Record<string, { nbMag?: number }> | null | undefined,
): Record<string, number> | null {
if (!metricsByMonth) return null;
const parMois: Record<string, number> = {};
for (const [mois] of Object.entries(metricsByMonth)) {
// Un mois PRÉSENT dans le détail est un mois extrait : s'il n'a pas de
// `nbMag`, c'est qu'aucun magasin n'a vendu, donc zéro. Le traiter comme
// « manquant » amputait la série et faisait disparaître toute la courbe
// (les anciens caches n'écrivaient `nbMag` que sur les mois avec vente).
const nb = Number(metricsByMonth[mois]?.nbMag);
parMois[mois] = Number.isFinite(nb) ? nb : 0;
}
return Object.keys(parMois).length > 0 ? parMois : null;
}
/**
* Enrichit les produits avec les metriques reseau Qlik (cache qlik_network_metrics),
* jointes par code centrale. Degradation propre si la sync Qlik n'a jamais tourne
@@ -474,6 +618,8 @@ async function enrichWithNetworkMetrics(productMap: Map<string, ProductRow>): Pr
if (byCodeCentrale.size === 0) return;
const metrics = await getNetworkMetricsByCodeCentrale([...byCodeCentrale.keys()]);
const sampleKey = [...byCodeCentrale.keys()].find((cc) => (metrics.get(cc)?.caReseau ?? 0) > 0) ?? [...byCodeCentrale.keys()].find((cc) => metrics.has(cc));
console.log(`[getProductRows] réseau: ${byCodeCentrale.size} codes centraux → ${metrics.size} matchés en cache. Sample:`, sampleKey ? JSON.stringify(metrics.get(sampleKey)) : "aucun match");
for (const [cc, products] of byCodeCentrale.entries()) {
const m = metrics.get(cc);
if (!m) continue;
@@ -481,7 +627,11 @@ async function enrichWithNetworkMetrics(productMap: Map<string, ProductRow>): Pr
product.caReseau = m.caReseau;
product.qteReseau = m.qteReseau;
product.nbMagasinsReseau = m.nbMagasinsReseau;
product.caParMagasinReseau = m.caParMagasinReseau;
product.margePctReseau = m.margePctReseau;
product.tauxPresenceReseau = m.nbMagasinsReseau / NB_MAGASINS_RESEAU;
product.qteReseauByMonth = m.qteByMonth ?? null;
product.nbMagReseauByMonth = nbMagParMois(m.metricsByMonth);
product.networkFetchedAt = m.fetchedAt ?? undefined;
}
}
@@ -0,0 +1,458 @@
"use client";
/**
* CollectFlow — Trous d'assortiment : ce qu'un magasin ne travaille pas.
*
* On part du CLASSEMENT AFFICHÉ, pas d'un classement maison : le tri en cours
* dans la Grille — CA réseau, quantité, marge, peu importe — fixe l'ordre et
* donc ce que « le haut du classement » veut dire. Recalculer un ordre ici
* répondrait à une autre question que celle qu'on vient de poser à l'écran.
*
* « Non travaillé » = aucune vente sur 12 mois ET aucun stock au dernier mois
* connu. Les deux conditions comptent : un article sans vente mais en stock est
* détenu par le magasin, ce n'est pas un trou d'assortiment mais un invendu.
*/
import React, { useMemo, useState } from "react";
import { PackageX, Store, FileSpreadsheet, Loader2 } from "lucide-react";
import { DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { TuileStat } from "@/features/grid/components/stat-tile";
import { SITE_LABELS } from "@/features/grid/lib/months";
import type { ProductRow } from "@/types/grid";
/** Ligne du classement affiché : le produit et la valeur qui l'a classé. */
export interface LigneClassee {
row: ProductRow;
valeurCritere: unknown;
}
const PROFONDEURS = [100, 200, 500] as const;
export const PROFONDEUR_MAX = PROFONDEURS[PROFONDEURS.length - 1];
const nb = (v: number) => Math.round(v).toLocaleString("fr-FR");
const eur0 = (v: number) => `${Math.round(v).toLocaleString("fr-FR")} €`;
const eur2 = (v: number) => v.toLocaleString("fr-FR", { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + " €";
/**
* Libellé et mise en forme de la colonne de tri.
*
* Le tri porte sur des grandeurs sans commune mesure — des euros, des unités,
* des pourcentages. Afficher la valeur brute rendrait la colonne illisible, et
* ne rien afficher priverait le classement de sa justification.
*/
interface Critere {
label: string;
format: (v: number) => string;
/** Format de nombre Excel — la valeur part BRUTE dans le tableur, qui doit
* pouvoir la trier et la sommer ; seule sa présentation est reprise ici. */
xlsx: string;
/** Normalisation avant export, quand la donnée stockée n'est pas l'unité affichée. */
brut?: (v: number) => number;
}
const CRITERES: Record<string, Critere> = {
caReseau: { label: "CA réseau", format: eur0, xlsx: '#,##0 "€"' },
qteReseau: { label: "Qté réseau", format: nb, xlsx: "#,##0" },
nbMagasinsReseau: { label: "Magasins réseau", format: nb, xlsx: "#,##0" },
tendanceReseau: { label: "Tendance réseau", format: (v) => `${v >= 0 ? "+" : ""}${Math.round(v * 100)} %`, xlsx: "0 %" },
prixMoyenReseau: { label: "PV moyen réseau", format: eur2, xlsx: '#,##0.00 "€"' },
prixVente: { label: "PV magasin", format: eur2, xlsx: '#,##0.00 "€"' },
caParMagasinReseau: { label: "CA / magasin réseau", format: eur0, xlsx: '#,##0 "€"' },
margePctReseau: { label: "Marge % réseau", format: (v) => `${(Math.abs(v) <= 1 ? v * 100 : v).toFixed(1)} %`,
xlsx: '0.0 "%"', brut: (v) => (Math.abs(v) <= 1 ? v * 100 : v) },
totalQuantite: { label: "Qté 12 m", format: nb, xlsx: "#,##0" },
totalCa: { label: "CA 12 m", format: eur0, xlsx: '#,##0 "€"' },
totalMarge: { label: "Marge 12 m", format: eur0, xlsx: '#,##0 "€"' },
};
function libelleCritere(id: string | null): string {
if (!id) return "ordre d'affichage";
return CRITERES[id]?.label ?? id;
}
function formatCritere(id: string | null, valeur: unknown): string {
if (valeur == null) return "—";
if (typeof valeur !== "number") return String(valeur);
// Les sentinelles de tri (valeurs manquantes, produit « nouveau ») ne sont
// pas des mesures : les écrire donnerait un nombre qui n'existe pas.
if (!Number.isFinite(valeur)) return "—";
return (id && CRITERES[id]?.format(valeur)) ?? nb(valeur);
}
/** Ce que le magasin fait de ce produit, et ce que les autres en font. */
interface Constat {
ventesMagasin: number;
stockMagasin: number;
/** Le magasin a déjà détenu ce produit sur la période (stock ou réception). */
dejaDetenu: boolean;
ventesAilleurs: number;
stockAilleurs: number;
}
function constater(row: ProductRow, site: string, mois: string[]): Constat {
const ventesParSite = row.sales12mByStore ?? {};
const stockParSite = row.stock12mByStore ?? {};
const receptionsParSite = row.receptions12mByStore ?? {};
const somme = (serie?: Record<string, number>) =>
mois.reduce((total, m) => total + (serie?.[m] ?? 0), 0);
const dernier = (serie?: Record<string, number>) => serie?.[mois[mois.length - 1]] ?? 0;
const ventesMagasin = row.quantiteByStore?.[site] ?? somme(ventesParSite[site]);
const stockMagasin = dernier(stockParSite[site]);
const maxStock = mois.reduce((max, m) => Math.max(max, stockParSite[site]?.[m] ?? 0), 0);
let ventesAilleurs = 0;
let stockAilleurs = 0;
for (const autre of new Set([...Object.keys(ventesParSite), ...Object.keys(stockParSite)])) {
if (autre === site) continue;
ventesAilleurs += row.quantiteByStore?.[autre] ?? somme(ventesParSite[autre]);
stockAilleurs += dernier(stockParSite[autre]);
}
return {
ventesMagasin,
stockMagasin,
dejaDetenu: maxStock > 0 || somme(receptionsParSite[site]) > 0,
ventesAilleurs,
stockAilleurs,
};
}
/** Ligne prête pour le tableur : le rang, le produit, le constat. */
interface TrouClasse {
row: ProductRow;
valeurCritere: unknown;
rang: number;
constat: Constat;
}
/**
* Classeur Excel de la liste affichée.
*
* Deux feuilles : les données seules d'un côté — en-têtes ligne 1, filtre
* automatique, valeurs NUMÉRIQUES pour que le tableur puisse trier et sommer —
* et le contexte de l'extraction de l'autre. Mêler les deux dans une même
* feuille condamnerait le filtre et le tri, alors que ce fichier a vocation à
* être retravaillé.
*
* `exceljs` est chargé à la demande : il pèse lourd, et la Grille n'a pas à le
* transporter pour tous ceux qui n'exportent jamais.
*/
async function construireClasseur(
trous: TrouClasse[],
contexte: { fournisseur?: string; magasin: string; critere: string; profondeur: number; examines: number },
critereId: string | null,
): Promise<Blob> {
const { Workbook } = await import("exceljs");
const classeur = new Workbook();
classeur.creator = "CollectFlow";
classeur.created = new Date();
const critere = critereId ? CRITERES[critereId] : undefined;
const feuille = classeur.addWorksheet("Non travaillés", { views: [{ state: "frozen", ySplit: 1 }] });
feuille.columns = [
{ header: "Rang", key: "rang", width: 8 },
{ header: "Code interne", key: "codein", width: 14 },
{ header: "Référence", key: "reference", width: 18 },
{ header: "EAN / GTIN", key: "gtin", width: 16 },
{ header: "Désignation", key: "libelle", width: 45 },
{ header: "Famille", key: "famille", width: 30 },
{ header: contexte.critere, key: "critere", width: 18 },
{ header: "Ventes ailleurs 12 m", key: "ventesAilleurs", width: 20 },
{ header: "Stock ailleurs", key: "stockAilleurs", width: 15 },
{ header: "Constat", key: "constat", width: 24 },
];
feuille.getRow(1).font = { bold: true, color: { argb: "FFFFFFFF" } };
feuille.getRow(1).fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FF0F172A" } };
feuille.getRow(1).height = 20;
for (const { row, valeurCritere, rang, constat } of trous) {
feuille.addRow({
rang,
codein: row.codein,
reference: row.reference || "",
gtin: row.gtin || "",
libelle: row.libelle1 || "",
famille: [row.code3, row.libelle3].filter(Boolean).join(" — "),
critere: typeof valeurCritere === "number" && Number.isFinite(valeurCritere)
? (critere?.brut?.(valeurCritere) ?? valeurCritere)
: (valeurCritere == null ? "" : String(valeurCritere)),
ventesAilleurs: constat.ventesAilleurs,
stockAilleurs: constat.stockAilleurs,
constat: constat.dejaDetenu ? "déjà détenu, sans vente" : "jamais détenu",
});
}
if (critere) feuille.getColumn("critere").numFmt = critere.xlsx;
feuille.getColumn("ventesAilleurs").numFmt = "#,##0";
feuille.getColumn("stockAilleurs").numFmt = "#,##0";
feuille.autoFilter = { from: { row: 1, column: 1 }, to: { row: 1, column: feuille.columns.length } };
const contexteFeuille = classeur.addWorksheet("Contexte");
contexteFeuille.columns = [{ width: 26 }, { width: 60 }];
for (const [cle, valeur] of [
["Extraction", "Produits non travaillés par un magasin"],
["Fournisseur", contexte.fournisseur ?? "—"],
["Magasin examiné", contexte.magasin],
["Classement", `${contexte.critere} — ${contexte.profondeur} premiers (${contexte.examines} examinés)`],
["Critère retenu", "aucune vente sur 12 mois glissants ET aucun stock au dernier mois connu"],
["Non travaillés", String(trous.length)],
["Généré le", new Date().toLocaleString("fr-FR")],
]) {
const ligne = contexteFeuille.addRow([cle, valeur]);
ligne.getCell(1).font = { bold: true };
}
const buffer = await classeur.xlsx.writeBuffer();
return new Blob([buffer], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" });
}
export function AssortmentGapsModal({ classement, critereId, mois, magasinInitial, nomFournisseur, onClose }: {
/** Le classement AFFICHÉ, dans son ordre — déjà tronqué à PROFONDEUR_MAX. */
classement: LigneClassee[];
critereId: string | null;
/** Les 12 clés de mois, du plus ancien au plus récent. */
mois: string[];
magasinInitial: string;
/** Sert à nommer le fichier exporté et à le documenter. */
nomFournisseur?: string;
onClose: () => void;
}) {
// Magasins réellement présents dans les données, à défaut les deux sites connus.
const sites = useMemo(() => {
const trouves = new Set<string>();
for (const { row } of classement) {
for (const s of Object.keys(row.sales12mByStore ?? {})) trouves.add(s);
for (const s of Object.keys(row.stock12mByStore ?? {})) trouves.add(s);
}
return trouves.size > 0 ? [...trouves].sort() : Object.keys(SITE_LABELS);
}, [classement]);
const [site, setSite] = useState(() => (sites.includes(magasinInitial) ? magasinInitial : sites[0]));
const [profondeur, setProfondeur] = useState<number>(200);
const examine = useMemo(() => classement.slice(0, profondeur), [classement, profondeur]);
const trous = useMemo(() => examine
.map((ligne, index) => ({ ...ligne, rang: index + 1, constat: constater(ligne.row, site, mois) }))
.filter(({ constat }) => constat.ventesMagasin <= 0 && constat.stockMagasin <= 0),
[examine, site, mois]);
const [exportEnCours, setExportEnCours] = useState(false);
const [exportErreur, setExportErreur] = useState<string | null>(null);
const vendusAilleurs = trous.filter((t) => t.constat.ventesAilleurs > 0).length;
const dejaTentes = trous.filter((t) => t.constat.dejaDetenu).length;
const nomMagasin = SITE_LABELS[site]?.nom ?? site;
const autresMagasins = sites.filter((s) => s !== site).map((s) => SITE_LABELS[s]?.nom ?? s).join(", ") || "les autres";
const exporter = async () => {
if (trous.length === 0 || exportEnCours) return;
setExportEnCours(true);
setExportErreur(null);
try {
const blob = await construireClasseur(
trous,
{
fournisseur: nomFournisseur,
magasin: nomMagasin,
critere: libelleCritere(critereId),
profondeur,
examines: examine.length,
},
critereId,
);
const url = URL.createObjectURL(blob);
const lien = document.createElement("a");
lien.href = url;
lien.download = [
"Non_travailles",
nomFournisseur?.replace(/[^\w]+/g, "_"),
nomMagasin.replace(/[^\w]+/g, "_"),
`Top${profondeur}`,
new Date().toISOString().slice(0, 10),
].filter(Boolean).join("_") + ".xlsx";
lien.click();
URL.revokeObjectURL(url);
} catch (e) {
// Un export qui échoue en silence laisse croire au téléchargement.
console.error("[trous d'assortiment] export Excel :", e);
setExportErreur("L'export a échoué. Réessayez, ou signalez-le si cela persiste.");
} finally {
setExportEnCours(false);
}
};
return (
<DialogContent
className="max-w-[calc(100%-2rem)] sm:max-w-5xl grid-cols-1 max-h-[88vh] overflow-y-auto"
style={{ background: "var(--bg-surface)", border: "1px solid var(--border)" }}
onInteractOutside={onClose}
>
<DialogHeader>
<DialogTitle className="text-lg leading-snug pr-6 flex items-center gap-2" style={{ color: "var(--text-primary)" }}>
<PackageX className="w-5 h-5 shrink-0" style={{ color: "var(--accent-warning)" }} />
Produits non travaillés par un magasin
</DialogTitle>
<p className="text-[13px] mt-1" style={{ color: "var(--text-muted)" }}>
Sur le classement affiché — trié par{" "}
<span className="font-semibold" style={{ color: "var(--text-secondary)" }}>{libelleCritere(critereId)}</span>
{" — ni vente ni stock sur 12 mois."}
</p>
</DialogHeader>
{/* Réglages : quel magasin, et jusqu'où dans le classement */}
<div className="flex flex-wrap items-center gap-x-5 gap-y-2">
<div className="flex items-center gap-2">
<span className="text-[12px] font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Magasin</span>
<div className="inline-flex rounded-lg p-0.5" style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}>
{sites.map((s) => (
<button
key={s}
type="button"
onClick={() => setSite(s)}
aria-pressed={site === s}
className="px-3 py-1 text-[12px] font-semibold rounded-md transition-colors flex items-center gap-1.5"
style={site === s
? { background: "var(--bg-surface)", color: "var(--text-primary)" }
: { color: "var(--text-muted)" }}
>
<Store className="w-3 h-3" />
{SITE_LABELS[s]?.nom ?? s}
</button>
))}
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-[12px] font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Profondeur</span>
<div className="inline-flex rounded-lg p-0.5" style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}>
{PROFONDEURS.map((p) => (
<button
key={p}
type="button"
onClick={() => setProfondeur(p)}
aria-pressed={profondeur === p}
className="px-3 py-1 text-[12px] font-semibold rounded-md transition-colors tabular-nums"
style={profondeur === p
? { background: "var(--bg-surface)", color: "var(--text-primary)" }
: { color: "var(--text-muted)" }}
>
Top {p}
</button>
))}
</div>
{classement.length < profondeur && (
<span className="text-[11.5px]" style={{ color: "var(--text-muted)" }}>
({classement.length} lignes affichées seulement)
</span>
)}
</div>
{/* L'export reprend exactement la liste affichée : même magasin,
même profondeur, même classement. */}
<button
type="button"
onClick={exporter}
disabled={trous.length === 0 || exportEnCours}
title={trous.length === 0
? "Rien à exporter : aucun trou dans ce périmètre"
: `Exporter les ${trous.length} produits non travaillés au format Excel`}
className="ml-auto flex items-center gap-2 px-3 py-1.5 rounded-md text-[12px] font-semibold border transition-all disabled:opacity-50"
style={{ background: "var(--bg-elevated)", borderColor: "var(--border-strong)", color: "var(--text-secondary)" }}
>
{exportEnCours ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <FileSpreadsheet className="w-3.5 h-3.5" />}
{exportEnCours ? "Génération…" : "Export Excel"}
</button>
</div>
{exportErreur && (
<div className="rounded-lg px-3 py-2 text-[12px]" style={{ background: "var(--accent-error-bg)", border: "1px solid var(--accent-error)", color: "var(--accent-error)" }}>
{exportErreur}
</div>
)}
<div className="flex flex-wrap gap-2">
<TuileStat
label="Non travaillés"
valeur={`${trous.length} / ${examine.length}`}
indice={`à ${nomMagasin}`}
couleur={trous.length > 0 ? "var(--accent-warning)" : undefined}
icone={PackageX}
/>
<TuileStat
label="Vendus ailleurs"
valeur={nb(vendusAilleurs)}
indice={`déjà vendus chez ${autresMagasins} — le gisement le plus sûr`}
/>
<TuileStat
label="Déjà détenus"
valeur={nb(dejaTentes)}
indice="stock ou réception sur la période, sans vente : essayés, pas oubliés"
/>
</div>
{trous.length === 0 ? (
<div className="rounded-xl p-4 text-center text-[13px]" style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-muted)" }}>
Aucun trou dans les {examine.length} premiers : {nomMagasin} travaille tout le haut de ce classement.
</div>
) : (
<div className="rounded-xl overflow-x-auto min-w-0" style={{ border: "1px solid var(--border)" }}>
<table className="w-full text-[12px]" style={{ minWidth: 820 }}>
<thead>
<tr style={{ background: "var(--bg-elevated)" }}>
<th className="text-right px-2.5 py-1.5 font-semibold" style={{ color: "var(--text-secondary)" }}>#</th>
<th className="text-left px-2.5 py-1.5 font-semibold" style={{ color: "var(--text-secondary)" }}>Code</th>
<th className="text-left px-2.5 py-1.5 font-semibold" style={{ color: "var(--text-secondary)" }}>Référence</th>
<th className="text-left px-2.5 py-1.5 font-semibold" style={{ color: "var(--text-secondary)" }}>Désignation</th>
<th className="text-right px-2.5 py-1.5 font-semibold whitespace-nowrap" style={{ color: "var(--text-secondary)" }}>
{libelleCritere(critereId)}
</th>
<th className="text-right px-2.5 py-1.5 font-semibold whitespace-nowrap" style={{ color: "var(--text-secondary)" }}>Ventes ailleurs</th>
<th className="text-right px-2.5 py-1.5 font-semibold whitespace-nowrap" style={{ color: "var(--text-secondary)" }}>Stock ailleurs</th>
<th className="text-left px-2.5 py-1.5 font-semibold" style={{ color: "var(--text-secondary)" }}>Constat</th>
</tr>
</thead>
<tbody>
{trous.map(({ row, valeurCritere, rang, constat }) => (
<tr key={row.codein} style={{ borderTop: "1px solid var(--border)" }}>
<td className="px-2.5 py-1.5 text-right tabular-nums" style={{ color: "var(--text-muted)" }}>{rang}</td>
<td className="px-2.5 py-1.5 tabular-nums font-semibold whitespace-nowrap" style={{ color: "var(--text-secondary)" }}>{row.codein}</td>
<td className="px-2.5 py-1.5 font-mono text-[11.5px] whitespace-nowrap" style={{ color: "var(--text-secondary)" }}>
{row.reference || "—"}
</td>
<td className="px-2.5 py-1.5" style={{ color: "var(--text-primary)" }} title={row.libelle1}>
<span className="font-semibold">{row.libelle1}</span>
{row.libelle3 && (
<span className="ml-1.5 text-[10.5px]" style={{ color: "var(--text-muted)" }}>{row.libelle3}</span>
)}
</td>
<td className="px-2.5 py-1.5 text-right tabular-nums font-semibold whitespace-nowrap" style={{ color: "var(--text-primary)" }}>
{formatCritere(critereId, valeurCritere)}
</td>
<td className="px-2.5 py-1.5 text-right tabular-nums whitespace-nowrap"
style={{ color: constat.ventesAilleurs > 0 ? "var(--accent-success)" : "var(--text-muted)" }}>
{constat.ventesAilleurs > 0 ? nb(constat.ventesAilleurs) : "—"}
</td>
<td className="px-2.5 py-1.5 text-right tabular-nums whitespace-nowrap" style={{ color: "var(--text-secondary)" }}>
{constat.stockAilleurs > 0 ? nb(constat.stockAilleurs) : "—"}
</td>
<td className="px-2.5 py-1.5 whitespace-nowrap" style={{ color: "var(--text-muted)" }}>
{constat.dejaDetenu ? "déjà détenu, sans vente" : "jamais détenu"}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<p className="text-[11.5px] leading-snug" style={{ color: "var(--text-muted)" }}>
Non travaillé = aucune vente sur les 12 mois glissants ET aucun stock au dernier mois connu, à {nomMagasin}.
Un produit sans vente mais en stock n&apos;apparaît pas ici : le magasin le détient, c&apos;est un invendu, pas un trou d&apos;assortiment.
</p>
</DialogContent>
);
}
@@ -32,7 +32,7 @@ export function BulkActionToolbar({ selectedCodeins, onClearSelection }: BulkAct
};
return (
<div className="flex items-center gap-3 px-4 py-2 bg-slate-900 border border-slate-700/50 rounded-xl backdrop-blur-md shadow-2xl animate-in fade-in slide-in-from-top-2 duration-200">
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 px-4 py-2 bg-slate-900 border border-slate-700/50 rounded-xl backdrop-blur-md shadow-2xl animate-in fade-in slide-in-from-top-2 duration-200">
<span className="text-[11px] font-black uppercase tracking-wider text-slate-200">
{selectedCodeins.length} sélectionné{selectedCodeins.length > 1 ? "s" : ""}
</span>
@@ -7,6 +7,7 @@ import { Loader2, CheckCircle, AlertCircle, RotateCcw, Camera, ChevronDown } fro
import { useMemo, useState, useTransition } from "react";
import { saveSnapshot } from "@/features/snapshots/api/save-snapshot";
import { SuccessModal } from "@/components/shared/success-modal";
import { isStaleServerActionError, STALE_ACTION_MESSAGE } from "@/lib/stale-action";
import {
DropdownMenu,
DropdownMenuContent,
@@ -18,9 +19,12 @@ function Stat({ label, value, sub }: { label: string; value: string; sub?: strin
// Determine color based on label to match the prototype
const valColor = label.includes("CA") || label.includes("Marge") ? "text-emerald-600 dark:text-emerald-400" : "text-slate-900 dark:text-white";
return (
<div className="flex flex-col items-center">
<span className="text-slate-500 text-[10px] uppercase font-black mb-1 tracking-tighter">{label}</span>
<span className={`font-mono-nums font-black text-xl ${valColor}`}>{value}</span>
// Barre maintenant dans le flux : chaque pixel de hauteur est pris au
// tableau. Ces chiffres sont déjà repris en gros dans l'en-tête de page,
// ils n'ont pas besoin d'y être une seconde fois en 20 px.
<div className="flex flex-col items-center leading-tight">
<span className="text-slate-500 text-[10px] uppercase font-black tracking-tighter">{label}</span>
<span className={`font-mono-nums font-black text-base ${valColor}`}>{value}</span>
{sub && <span className="text-emerald-600 dark:text-emerald-500 text-[11px] font-bold">{sub}</span>}
</div>
);
@@ -37,7 +41,13 @@ export function FloatingSummaryBar() {
const [isPending, startTransition] = useTransition();
const [isSavingSnapshot, setIsSavingSnapshot] = useState(false);
const [saveStatus, setSaveStatus] = useState<"idle" | "success" | "error">("idle");
const [modal, setModal] = useState<{ isOpen: boolean, title: string, message: string }>({
const [modal, setModal] = useState<{
isOpen: boolean;
title: string;
message: string;
variant?: "success" | "error";
action?: { label: string; onClick: () => void };
}>({
isOpen: false,
title: "",
message: ""
@@ -118,12 +128,26 @@ export function FloatingSummaryBar() {
} catch (err) {
console.error(err);
if (!labelOverride) {
const msg = err instanceof Error ? err.message : String(err);
setModal({
isOpen: true,
title: "Erreur Snapshot",
message: `Impossible de créer le snapshot : ${msg}`
});
// Onglet resté ouvert pendant un déploiement : les identifiants de
// Server Action ont changé côté serveur. Rien à réparer côté
// métier, il faut recharger le bundle client.
if (isStaleServerActionError(err)) {
setModal({
isOpen: true,
title: "Page à recharger",
message: STALE_ACTION_MESSAGE,
variant: "error",
action: { label: "Recharger la page", onClick: () => window.location.reload() },
});
} else {
const msg = err instanceof Error ? err.message : String(err);
setModal({
isOpen: true,
title: "Erreur Snapshot",
message: `Impossible de créer le snapshot : ${msg}`,
variant: "error",
});
}
}
} finally {
setIsSavingSnapshot(false);
@@ -132,7 +156,20 @@ export function FloatingSummaryBar() {
return (
<div
className="fixed bottom-6 left-[264px] right-6 p-4 flex justify-between items-center shadow-lg border rounded-xl z-50 transition-colors bg-slate-100 dark:bg-slate-800"
/*
* Dans le flux, plus en `fixed`.
*
* En `fixed` elle ne réservait aucune hauteur : seul un `pb-12` posé sur
* la page compensait, et il était trop court d'une quarantaine de pixels.
* Elle recouvrait donc la dernière ligne du tableau ET la barre de
* défilement horizontale — d'où l'impression qu'aucun ascenseur n'existait.
*
* Le `left-[264px]` était en prime calé sur une barre latérale dépliée,
* alors qu'elle est repliée par défaut (64 px) : 200 px de vide à gauche.
* En flux, la grille cède exactement la hauteur nécessaire et il n'y a
* plus aucune valeur magique à tenir en phase.
*/
className="shrink-0 px-4 py-2.5 flex flex-wrap justify-between items-center gap-x-4 gap-y-2 shadow-sm border rounded-xl transition-colors bg-slate-100 dark:bg-slate-800"
style={{
borderColor: hasDrafts ? "rgba(234, 179, 8, 0.5)" : "var(--border-strong)",
}}
@@ -327,6 +364,8 @@ export function FloatingSummaryBar() {
onClose={() => setModal(prev => ({ ...prev, isOpen: false }))}
title={modal.title}
message={modal.message}
variant={modal.variant}
action={modal.action}
/>
</div>
);
+104 -85
View File
@@ -6,14 +6,13 @@ import { FloatingSummaryBar } from "@/features/grid/components/floating-summary-
import { BulkActionToolbar } from "@/features/grid/components/bulk-action-toolbar";
import { GridFilterBar } from "@/features/grid/components/grid-filter-bar";
import { ExportDropdown } from "@/features/grid/components/export-dropdown";
import { NoSalesTab, countNoSalesIn6Months } from "@/features/grid/components/no-sales-tab";
import { useGridStore } from "@/features/grid/store/use-grid-store";
import { useSaveDrafts } from "@/features/grid/hooks/use-save-drafts";
import type { ProductRow } from "@/types/grid";
import { CheckCircle, AlertCircle, Loader2 } from "lucide-react";
import { CheckCircle, AlertCircle, Loader2, RefreshCw } from "lucide-react";
import { useSession } from "next-auth/react";
import { useSearchParams } from "next/navigation";
import { useSearchParams, useRouter, usePathname } from "next/navigation";
import type { GridFilters } from "@/types/grid";
interface GridClientProps {
@@ -31,6 +30,27 @@ type GridRowsStreamMessage =
| { type: "done"; loaded: number; total: number }
| { type: "error"; error: string };
/** Petite pastille métrique (libellé + valeur) — densité pro, thème Slate/teal. */
function MetricPill({ label, value, accent = false }: { label: string; value: string; accent?: boolean }) {
return (
<div
className="flex flex-col items-start px-2.5 py-1 rounded-lg shrink-0"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}
>
<span className="text-[8.5px] font-semibold uppercase tracking-wider leading-none mb-1" style={{ color: "var(--text-muted)" }}>
{label}
</span>
<span
className="text-[12px] font-bold leading-none tabular-nums max-w-[160px] truncate"
style={{ color: accent ? "var(--accent)" : "var(--text-primary)" }}
title={value}
>
{value}
</span>
</div>
);
}
export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, magasins, magasin, filters }: GridClientProps) {
const { data: session } = useSession();
const isAdmin = (session?.user as { role?: string } | undefined)?.role === "admin";
@@ -38,21 +58,21 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
const rows = useGridStore((s) => s.rows);
const setActiveGridQuery = useGridStore((s) => s.setActiveGridQuery);
const setFilter = useGridStore((s) => s.setFilter);
const setCode3Filter = useGridStore((s) => s.setCode3Filter);
const setActiveMagasin = useGridStore((s) => s.setActiveMagasin);
const searchParams = useSearchParams();
const router = useRouter();
const pathname = usePathname();
const [selectedCodeins, setSelectedCodeins] = useState<string[]>([]);
const [isPending, startTransition] = useTransition();
const [saveStatus, setSaveStatus] = useState<"idle" | "success" | "error">("idle");
const [activeTab, setActiveTab] = useState<"grid" | "no-sales">("grid");
const [isLoadingRows, setIsLoadingRows] = useState(false);
const [rowsLoaded, setRowsLoaded] = useState(0);
const [totalRows, setTotalRows] = useState<number | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const [showStartOverlay, setShowStartOverlay] = useState(false);
const noSalesCount = useMemo(() => countNoSalesIn6Months(rows), [rows]);
const visibleCodeins = useMemo(() => rows.map(r => r.codein), [rows]);
const { save, hasDrafts, count } = useSaveDrafts(magasin, visibleCodeins);
@@ -76,10 +96,10 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
if (!isMounted) return;
if (prevFournisseurRef.current !== codeFournisseur) {
setFilter("codeGamme", null);
setFilter("code3", null);
setCode3Filter(null);
prevFournisseurRef.current = codeFournisseur;
}
}, [codeFournisseur, setFilter, isMounted]);
}, [codeFournisseur, setFilter, setCode3Filter, isMounted]);
const refreshToken = searchParams.get("_refresh");
useEffect(() => {
@@ -89,9 +109,11 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
const params = new URLSearchParams();
params.set("fournisseur", codeFournisseur);
params.set("magasin", magasin || "TOTAL");
// `code3` n'est volontairement pas transmis : le serveur ignore ce filtre
// (getProductRows ne s'en sert pas) et la Grille l'applique en local. Le
// faire voyager relançait un chargement complet à chaque case cochée.
if (filters.code1) params.set("code1", filters.code1);
if (filters.code2) params.set("code2", filters.code2);
if (filters.code3) params.set("code3", filters.code3);
if (refreshToken) params.set("refresh", "1");
let accumulatedRows: ProductRow[] = [];
@@ -154,7 +176,7 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
loadRows();
return () => controller.abort();
}, [codeFournisseur, magasin, filters.code1, filters.code2, filters.code3, refreshToken, setRows, isMounted]);
}, [codeFournisseur, magasin, filters.code1, filters.code2, refreshToken, setRows, isMounted]);
// Synchroniser le magasin actif depuis la prop URL (changement de magasin sans rechargement)
useEffect(() => {
@@ -170,6 +192,16 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
});
};
// Force le rechargement des données depuis le serveur en ignorant le cache
// 10 min : on change le paramètre URL `_refresh`, ce qui relance le fetch
// avec `refresh=1` (→ forceRefresh) et remonte l'état serveur à jour (INIT).
const handleForceRefresh = () => {
if (isLoadingRows) return;
const params = new URLSearchParams(searchParams.toString());
params.set("_refresh", String(Date.now()));
router.replace(`${pathname}?${params.toString()}`);
};
if (!isMounted) {
return <div className="p-8 text-center animate-pulse text-muted italic">Initialisation de la grille...</div>;
}
@@ -177,38 +209,66 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
const activeStoreNom = magasins.find(m => m.code === magasin)?.nom || "National (Total)";
return (
<div className="flex flex-col h-full space-y-3 min-h-0 pb-12">
{/* Header */}
<div className="flex items-end justify-between shrink-0">
<div>
<h1 className="text-2xl font-bold tracking-tight">Révision d&apos;Assortiment</h1>
<p className="text-[13px] mt-0.5" style={{ color: "var(--text-secondary)" }}>
<span className="font-semibold" style={{ color: "var(--text-primary)" }}>{nomFournisseur}</span>
{" "}• <span className="font-medium" style={{ color: "var(--text-muted)" }}>{activeStoreNom}</span>
{" "}• {totalRows?.toLocaleString("fr-FR") ?? rows.length.toLocaleString("fr-FR")} références
</p>
<div className="flex flex-col h-full space-y-3 min-h-0">
{/* Header — chrome raffiné (eyebrow teal + fournisseur héro + pastilles métriques) */}
<header className="shrink-0 flex items-center justify-between gap-4 pb-2.5" style={{ borderBottom: "1px solid var(--border)" }}>
<div className="flex items-center gap-3.5 min-w-0">
<div
className="h-10 w-[3px] rounded-full shrink-0"
style={{ background: "linear-gradient(to bottom, var(--accent), var(--accent-success))" }}
/>
<div className="min-w-0">
<div className="text-[10px] font-bold uppercase tracking-[0.18em] leading-none mb-1.5" style={{ color: "var(--accent)" }}>
Révision d&apos;assortiment
</div>
<h1 className="text-[19px] font-bold tracking-tight truncate leading-none" style={{ color: "var(--text-primary)" }}>
{nomFournisseur}
</h1>
</div>
<div className="hidden md:flex items-center gap-2 ml-1 pl-3 shrink-0" style={{ borderLeft: "1px solid var(--border)" }}>
<MetricPill label="Magasin" value={activeStoreNom} />
<MetricPill label="Références" value={(totalRows ?? rows.length).toLocaleString("fr-FR")} accent />
</div>
</div>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2.5 shrink-0">
{(isLoadingRows || loadError) && (
<div
className="flex items-center gap-2 px-3 py-1.5 rounded-md text-xs font-semibold"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: loadError ? "var(--accent-error)" : "var(--text-secondary)" }}
className="flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-semibold tabular-nums"
style={{
background: loadError ? "var(--accent-error-bg)" : "var(--accent-bg)",
border: `1px solid ${loadError ? "var(--accent-error)" : "var(--accent-border)"}`,
color: loadError ? "var(--accent-error)" : "var(--accent)",
}}
>
{isLoadingRows && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
{loadError
? loadError
: totalRows
? `${rowsLoaded.toLocaleString("fr-FR")} / ${totalRows.toLocaleString("fr-FR")}`
: "Chargement produits..."}
: "Chargement…"}
</div>
)}
<button
onClick={handleForceRefresh}
disabled={isLoadingRows}
title="Forcer le rafraîchissement — recharge les données depuis le serveur en ignorant le cache (met à jour la colonne INIT)"
className="flex items-center gap-2 px-3 h-9 text-[13px] font-semibold rounded-xl disabled:opacity-50 transition-all hover:brightness-110 active:scale-[0.97]"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}
>
<RefreshCw className={`w-4 h-4 ${isLoadingRows ? "animate-spin" : ""}`} />
<span className="hidden sm:inline">Rafraîchir</span>
</button>
<div id="grid-toolbar-actions"></div>
{isAdmin && <ExportDropdown nomFournisseur={nomFournisseur} />}
{isAdmin && hasDrafts && (
<button
onClick={handleSave}
disabled={isPending}
className="flex items-center gap-2 px-4 py-2 bg-emerald-700 hover:bg-emerald-600 text-white text-sm font-semibold rounded-lg disabled:opacity-60 transition-colors"
className="flex items-center gap-2 px-4 h-9 text-white text-[13px] font-semibold rounded-xl disabled:opacity-60 transition-all hover:brightness-110 active:scale-[0.97]"
style={{
background: "linear-gradient(180deg, var(--brand-solid), var(--brand-hover))",
boxShadow: "0 4px 12px rgba(16,185,129,0.25), inset 0 1px 1px rgba(255,255,255,0.2)",
}}
>
{isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
@@ -218,7 +278,7 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
<AlertCircle className="w-4 h-4" />
) : null}
{isPending
? "Sauvegarde..."
? "Sauvegarde…"
: saveStatus === "success"
? "Sauvegardé !"
: saveStatus === "error"
@@ -227,75 +287,34 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
</button>
)}
</div>
</div>
</header>
{/* Filters */}
<div className="shrink-0 print:hidden">
<GridFilterBar fournisseurs={fournisseurs} magasins={magasins} />
</div>
{/* Tab switcher */}
<div className="shrink-0 flex gap-0 print:hidden" style={{ borderBottom: "1px solid var(--border)" }}>
{(["grid", "no-sales"] as const).map(tab => {
const isActive = activeTab === tab;
return (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className="flex items-center gap-1.5 px-4 py-2 text-[13px] font-medium transition-colors"
style={{
color: isActive ? "var(--text-primary)" : "var(--text-muted)",
borderBottom: isActive ? "2px solid var(--accent)" : "2px solid transparent",
marginBottom: "-1px",
}}
>
{tab === "grid" ? "Assortiment" : "Sans vente 6 mois"}
{tab === "no-sales" && noSalesCount > 0 && (
<span className="inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full text-[10px] font-bold"
style={{
background: isActive ? "var(--accent)" : "var(--bg-elevated)",
color: isActive ? "white" : "var(--text-secondary)",
border: "1px solid var(--border)",
}}
>
{noSalesCount}
</span>
)}
</button>
);
})}
{/* Bulk toolbar (contextual) */}
<div className="shrink-0 print:hidden">
<BulkActionToolbar
selectedCodeins={selectedCodeins}
onClearSelection={() => setSelectedCodeins([])}
/>
</div>
{/* Bulk toolbar (contextual) — uniquement sur l'onglet grille */}
{activeTab === "grid" && (
<div className="shrink-0 print:hidden">
<BulkActionToolbar
selectedCodeins={selectedCodeins}
onClearSelection={() => setSelectedCodeins([])}
/>
</div>
)}
{/* Main content */}
{activeTab === "grid" ? (
<div className="flex-1 min-h-0 min-w-0">
<HeatmapGrid
onSelectionChange={setSelectedCodeins}
isAdmin={isAdmin}
/>
</div>
) : (
<div className="flex-1 min-h-0 min-w-0 flex flex-col">
<NoSalesTab />
</div>
)}
<div className="flex-1 min-h-0 min-w-0">
<HeatmapGrid
onSelectionChange={setSelectedCodeins}
isAdmin={isAdmin}
nomFournisseur={nomFournisseur}
/>
</div>
{/* Summary bar — uniquement sur l'onglet grille */}
{activeTab === "grid" && (
<div className="print:hidden">
<FloatingSummaryBar />
</div>
)}
{/* Summary bar */}
<div className="print:hidden">
<FloatingSummaryBar />
</div>
{/* Overlay de démarrage premium (Glassmorphism semi-bloquant) */}
{showStartOverlay && isLoadingRows && (
@@ -134,7 +134,7 @@ export function GridFilterBar({ fournisseurs, magasins }: GridFilterBarProps) {
fournisseurs={fournisseurs}
selectedCode={searchParams.get("fournisseur")}
onSelect={handleSupplierSelect}
className="w-[280px]"
className="w-[200px] xl:w-[280px]"
/>
{/* Store Selector */}
@@ -142,7 +142,7 @@ export function GridFilterBar({ fournisseurs, magasins }: GridFilterBarProps) {
magasins={magasins}
selectedCode={activeMagasin === "TOTAL" ? null : activeMagasin}
onSelect={handleStoreSelect}
className="w-[220px]"
className="w-[170px] xl:w-[220px]"
/>
{/* Search */}
@@ -156,12 +156,19 @@ export function GridFilterBar({ fournisseurs, magasins }: GridFilterBarProps) {
placeholder="Rechercher (code, désignation...)"
value={searchValue}
onChange={(e) => setSearchValue(e.target.value)}
className="apple-input w-72 pr-8"
// La croix native de `type="search"` (WebKit/Blink) est masquée :
// ce champ a déjà son propre bouton d'effacement juste à droite,
// et les deux apparaissaient côte à côte. On garde `type="search"`
// pour la sémantique ; le masquage est local, la recherche de
// l'en-tête n'ayant pas de bouton maison, elle garde le sien.
className="apple-input w-52 xl:w-72 pr-8 [&::-webkit-search-cancel-button]:appearance-none [&::-webkit-search-cancel-button]:hidden"
style={{ paddingLeft: "32px" }}
/>
{searchValue && (
<button
type="button"
onClick={() => setSearchValue("")}
aria-label="Effacer la recherche"
className="absolute right-2 top-1/2 -translate-y-1/2"
>
<X className="h-3.5 w-3.5 hover:opacity-100 opacity-60 transition-opacity" style={{ color: "var(--text-secondary)" }} />
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,442 @@
"use client";
/**
* CollectFlow — Graphiques réseau (SVG fait main, aucune librairie de charts).
*
* Extraits de `heatmap-grid.tsx` pour être partagés entre la Grille et la fiche
* produit (`/produits`). Aucun changement de comportement.
*/
import { useState } from "react";
import { TrendingUp, TrendingDown, Minus } from "lucide-react";
import { fmtMonthShort } from "@/features/grid/lib/months";
import { TREND_COLOR, type NetworkTrend } from "@/features/grid/lib/network-trend";
/** Sparkline compacte 12 mois + flèche, teintée selon la tendance. */
export function TrendSparkline({ trend }: { trend: NetworkTrend }) {
if (!trend.hasData) return <div className="text-center text-[12px]" style={{ color: "var(--text-secondary)" }}>-</div>;
const { values, direction, pct, nouveau } = trend;
const color = TREND_COLOR[direction];
const W = 46, H = 18, n = values.length;
const max = Math.max(...values, 1), min = Math.min(...values, 0);
const range = max - min || 1;
const pts = values.map((v, i) => {
const x = n > 1 ? (i / (n - 1)) * (W - 2) + 1 : W / 2;
const y = H - 1 - ((v - min) / range) * (H - 2);
return `${x.toFixed(1)},${y.toFixed(1)}`;
}).join(" ");
const Arrow = direction === "up" ? TrendingUp : direction === "down" ? TrendingDown : Minus;
return (
<div className="flex flex-col items-center justify-center gap-0.5" title={pct != null
? `Tendance ${pct >= 0 ? "+" : ""}${(pct * 100).toFixed(0)}% : 4 derniers mois vs 4 premiers — cliquer pour le détail`
: nouveau
? "Nouveau : aucune vente sur les 4 premiers mois de la fenêtre — cliquer pour le détail"
: "Tendance réseau — cliquer pour le détail"}>
<div className="flex items-center gap-1">
<svg width={W} height={H} viewBox={`0 0 ${W} ${H}`} className="shrink-0" aria-hidden>
{n > 1 && <polyline points={pts} fill="none" stroke={color} strokeWidth={1.5} strokeLinejoin="round" strokeLinecap="round" />}
{n === 1 && <circle cx={W / 2} cy={H / 2} r={2} fill={color} />}
</svg>
<Arrow className="w-3.5 h-3.5 shrink-0" style={{ color }} strokeWidth={2.5} />
</div>
{pct != null && (
<span className="text-[9px] font-bold tabular-nums leading-none" style={{ color }}>
{pct >= 0 ? "+" : ""}{Math.round(pct * 100)}%
</span>
)}
{pct == null && nouveau && (
<span className="text-[9px] font-bold leading-none" style={{ color }}>Nouveau</span>
)}
</div>
);
}
/**
* Couleurs des séries : slots 1-3 de la palette catégorielle validée, exposés en
* variables CSS pour que le mode sombre soit un jeu de pas CHOISI et vérifié, et
* non un éclaircissement automatique (cf. `globals.css`).
*/
const SERIE_COULEURS = ["var(--viz-1)", "var(--viz-2)", "var(--viz-3)"] as const;
/** Une facette du graphique : une série, son échelle, son titre. */
interface Facette {
titre: string;
valeurs: number[];
couleur: string;
hauteur: number;
/** Lavis sous la courbe : réservé à la facette principale. */
aire?: boolean;
decimales?: number;
unite?: string;
}
/**
* Ventes réseau mensuelles — petits multiples empilés sur un axe des mois commun.
*
* ⚠️ PAS DE DOUBLE AXE. Superposer qté/magasin, volumes et magasins sur un même
* cadre demanderait deux ou trois échelles verticales : l'alignement entre elles
* serait arbitraire et inventerait des corrélations absentes des données. C'est
* l'erreur de graphique la plus commune. Trois mesures d'ordres de grandeur
* différents ⇒ trois facettes, une échelle chacune, le même axe horizontal.
*
* ⚠️ LA FACETTE PRINCIPALE EST LA QUANTITÉ PAR MAGASIN. La quantité brute confond
* la performance du produit et sa diffusion : elle monte dès qu'on référence le
* produit ailleurs, sans qu'il se vende mieux nulle part. Les deux autres
* facettes expliquent les mouvements de la première, elles ne les jugent pas.
*
* ⚠️ ÉTIQUETAGE SÉLECTIF. Une valeur à côté de chaque point, c'est 36 nombres :
* illisible, et personne ne les lit. Seuls l'extrême et le dernier mois sont
* écrits ; le survol donne le mois exact et la vue tableau porte tout le reste,
* de sorte qu'aucune valeur n'est accessible uniquement au survol.
*
* Le texte ne porte jamais la couleur d'une série (une teinte claire est
* illisible en texte) : l'identité vient du repère coloré posé à côté.
*/
export function NetworkLineChart({
labels,
values,
stores,
perStore,
}: {
labels: string[];
values: number[];
/** Couleur héritée de la tendance : conservée pour l'API, plus utilisée ici —
* la teinte « tendance » reste au badge d'en-tête, les séries ont la leur. */
color?: string;
stores?: number[] | null;
perStore?: number[] | null;
}) {
const [survol, setSurvol] = useState<number | null>(null);
const [vue, setVue] = useState<"graphique" | "tableau">("graphique");
const n = values.length;
const memeTaille = (s?: number[] | null) => Array.isArray(s) && s.length === n && n > 0;
const avecParMag = memeTaille(perStore);
const avecMagasins = memeTaille(stores);
const fmt = (v: number, d = 0) =>
v.toLocaleString("fr-FR", { minimumFractionDigits: d, maximumFractionDigits: d });
const facettes: Facette[] = [];
if (avecParMag) {
facettes.push({
titre: "Qté / magasin",
valeurs: perStore!,
couleur: SERIE_COULEURS[0],
hauteur: 92,
aire: true,
decimales: perStore!.some((v) => v > 0 && v < 10) ? 1 : 0,
});
facettes.push({ titre: "Quantités vendues", valeurs: values, couleur: SERIE_COULEURS[1], hauteur: 58 });
} else {
facettes.push({ titre: "Quantités vendues", valeurs: values, couleur: SERIE_COULEURS[0], hauteur: 110, aire: true });
}
if (avecMagasins) {
facettes.push({ titre: "Magasins vendeurs", valeurs: stores!, couleur: SERIE_COULEURS[2], hauteur: 46 });
}
const W = 520;
// Bandeau et axe dimensionnés pour la typographie ci-dessous : agrandir les
// polices sans élargir ces bandes ferait chevaucher titres et courbes.
const padL = 16, padR = 16, axeH = 24, enteteH = 21, ecart = 14;
const plotW = W - padL - padR;
const H = facettes.reduce((t, f) => t + enteteH + f.hauteur + ecart, 0) - ecart + axeH;
const x = (i: number) => (n > 1 ? padL + (i / (n - 1)) * plotW : padL + plotW / 2);
/** Aux extrémités, l'étiquette est ancrée vers l'intérieur : sinon elle sort du cadre. */
const ancrage = (i: number): "start" | "middle" | "end" =>
i === 0 ? "start" : i === n - 1 ? "end" : "middle";
let curseur = 0;
const disposees = facettes.map((f) => {
const hautEntete = curseur;
const haut = curseur + enteteH;
curseur = haut + f.hauteur + ecart;
// Les quantités Qlik peuvent être NÉGATIVES (retours > ventes sur un mois) :
// l'échelle part du minimum réel, sinon le point sort du cadre par le bas.
const min = Math.min(0, ...f.valeurs);
const max = Math.max(...f.valeurs, min + 1);
const etendue = max - min || 1;
const y = (v: number) => haut + f.hauteur - ((v - min) / etendue) * f.hauteur;
const iMax = f.valeurs.indexOf(Math.max(...f.valeurs));
return { ...f, hautEntete, haut, base: y(min), max, y, iMax };
});
const basPlots = curseur - ecart;
return (
<div className="mt-3 w-full">
{/* Bascule : la même surface sert au dessin ou aux chiffres. */}
<div className="flex justify-end mb-1.5">
<div className="inline-flex rounded-lg p-0.5" style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}>
{(["graphique", "tableau"] as const).map((v) => (
<button
key={v}
type="button"
onClick={() => setVue(v)}
className="px-3 py-1 text-[12px] font-semibold rounded-md transition-colors capitalize"
style={vue === v
? { background: "var(--bg-surface)", color: "var(--text-primary)" }
: { color: "var(--text-muted)" }}
aria-pressed={vue === v}
>
{v}
</button>
))}
</div>
</div>
{vue === "graphique" ? (
<div className="overflow-x-auto">
<svg
viewBox={`0 0 ${W} ${H}`}
width="100%"
height={H}
preserveAspectRatio="xMidYMid meet"
// `display:block` : un SVG en ligne traîne l'espace sous la ligne de
// base (6 px mesurés), et la carte changeait de hauteur en basculant
// vers le tableau.
style={{ minWidth: 460, display: "block" }}
role="img"
aria-label={"Ventes réseau mensuelles : " + disposees.map((f) => f.titre).join(", ")}
onMouseLeave={() => setSurvol(null)}
>
{disposees.map((f) => {
const pts = f.valeurs.map((v, i) => `${x(i).toFixed(1)},${f.y(v).toFixed(1)}`).join(" ");
// L'extrême et le dernier mois seulement : deux repères qui
// situent la courbe, sans noyer le dessin sous les nombres.
const aEtiqueter = [...new Set([f.iMax, n - 1])];
return (
<g key={f.titre}>
{/* Bandeau de facette : repère coloré + titre + maximum, en encre neutre */}
<rect x={padL} y={f.hautEntete + 6} width={14} height={4} rx={2} fill={f.couleur} />
<text x={padL + 20} y={f.hautEntete + 9} fontSize={13} fontWeight={700} fill="var(--text-secondary)" dominantBaseline="middle">
{f.titre}
</text>
<text x={padL + plotW} y={f.hautEntete + 9} textAnchor="end" fontSize={11.5} fill="var(--text-muted)" dominantBaseline="middle">
max {fmt(f.max, f.decimales ?? 0)}{f.unite ?? ""}
</text>
{/* Ligne de base : filet plein, une nuance au-dessus du fond */}
<line x1={padL} y1={f.base} x2={padL + plotW} y2={f.base} stroke="var(--border)" strokeWidth={1} />
{f.aire && n > 1 && (
<polygon
points={`${padL},${f.base.toFixed(1)} ${pts} ${(padL + plotW).toFixed(1)},${f.base.toFixed(1)}`}
fill={f.couleur}
opacity={0.1}
/>
)}
{n > 1 && (
<polyline points={pts} fill="none" stroke={f.couleur} strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" />
)}
{/* Points repères : extrême et dernier mois, cerclés de la couleur du fond */}
{aEtiqueter.map((i) => (
<circle
key={`pt-${f.titre}-${i}`}
cx={x(i)}
cy={f.y(f.valeurs[i])}
r={4}
fill={f.couleur}
stroke="var(--bg-surface)"
strokeWidth={2}
/>
))}
{aEtiqueter.map((i) => {
// Un point proche du sommet verrait son étiquette empiéter sur le
// bandeau de la facette (constaté au rendu : « 58 » par-dessus
// « Qté / magasin »). Dans ce cas elle bascule SOUS le point.
const yPoint = f.y(f.valeurs[i]);
const dessus = yPoint - 11 >= f.haut + 8;
return (
<text
key={`lab-${f.titre}-${i}`}
x={x(i)}
y={dessus ? yPoint - 10 : yPoint + 16}
textAnchor={ancrage(i)}
fontSize={12.5}
fontWeight={700}
fill="var(--text-primary)"
>
{fmt(f.valeurs[i], f.decimales ?? 0)}
</text>
);
})}
{/* Point mis en avant au survol */}
{survol != null && (
<circle
cx={x(survol)}
cy={f.y(f.valeurs[survol])}
r={3.5}
fill={f.couleur}
stroke="var(--bg-surface)"
strokeWidth={2}
/>
)}
</g>
);
})}
{/* Réticule : un seul trait vertical relie les trois facettes au même mois */}
{survol != null && (
<line x1={x(survol)} y1={0} x2={x(survol)} y2={basPlots} stroke="var(--border-strong)" strokeWidth={1} />
)}
{/* Axe des mois, commun aux facettes */}
{labels.map((lab, i) => (
<text
key={`mois-${lab}`}
x={x(i)}
y={H - 7}
textAnchor={ancrage(i)}
fontSize={11}
fill={survol === i ? "var(--text-primary)" : "var(--text-muted)"}
fontWeight={survol === i ? 700 : 400}
>
{fmtMonthShort(lab)}
</text>
))}
{/* Infobulle DANS le dessin.
Elle vivait sous le SVG, en HTML : selon la longueur des valeurs elle
passait sur deux lignes et la carte changeait de hauteur en cours de
survol. Ici la géométrie est figée par le viewBox — la carte ne bouge
plus jamais. `pointerEvents=none` laisse passer le survol vers les
cibles en dessous. */}
{survol != null && (() => {
const largeur = 196;
const hauteur = 22 + disposees.length * 17 + 8;
// La bulle bascule à gauche du réticule quand elle déborderait à droite.
const gauche = x(survol) + 10 + largeur > W - 4;
const bx = gauche ? x(survol) - 10 - largeur : x(survol) + 10;
return (
<g pointerEvents="none">
<rect
x={bx} y={4} width={largeur} height={hauteur} rx={8}
fill="var(--bg-elevated)" stroke="var(--border)" strokeWidth={1}
/>
<text x={bx + 11} y={21} fontSize={12.5} fontWeight={700} fill="var(--text-primary)">
{fmtMonthShort(labels[survol])}
</text>
{disposees.map((f, k) => (
<g key={`bulle-${f.titre}`}>
<rect x={bx + 11} y={33 + k * 17} width={10} height={4} rx={2} fill={f.couleur} />
<text x={bx + 26} y={38 + k * 17} fontSize={11.5} fill="var(--text-secondary)">
{f.titre}
</text>
<text
x={bx + largeur - 11} y={38 + k * 17}
textAnchor="end" fontSize={11.5} fontWeight={700} fill="var(--text-primary)"
>
{fmt(f.valeurs[survol], f.decimales ?? 0)}
</text>
</g>
))}
</g>
);
})()}
{/* Zones de survol : larges cibles, une par mois */}
{labels.map((lab, i) => (
<rect
key={`survol-${lab}`}
x={x(i) - plotW / (2 * Math.max(1, n - 1))}
y={0}
width={plotW / Math.max(1, n - 1)}
height={H}
fill="transparent"
onMouseEnter={() => setSurvol(i)}
/>
))}
</svg>
</div>
) : (
/* Vue tableau : même panneau, MÊME HAUTEUR.
Un dépliant aurait fait grandir la carte à l'ouverture — exactement ce
qu'on venait de corriger sur le survol. Le tableau occupe donc la place
du graphique et défile à l'intérieur : la carte garde sa taille, et
aucune valeur n'est réservée au survol. */
<div style={{ height: H }} className="overflow-y-auto overflow-x-auto">
<table className="w-full text-[13px] tabular-nums" style={{ borderCollapse: "collapse" }}>
<thead className="sticky top-0" style={{ background: "var(--bg-surface)" }}>
<tr style={{ color: "var(--text-muted)" }}>
<th className="text-left font-medium py-1 pr-2">Mois</th>
{disposees.map((f) => (
<th key={`th-${f.titre}`} className="text-right font-medium py-1 pl-2 whitespace-nowrap">
{f.titre}
</th>
))}
</tr>
</thead>
<tbody>
{labels.map((lab, i) => (
<tr key={`tr-${lab}`} style={{ borderTop: "1px solid var(--border)" }}>
<td className="py-1 pr-2 whitespace-nowrap" style={{ color: "var(--text-secondary)" }}>
{fmtMonthShort(lab)}
</td>
{disposees.map((f) => (
<td key={`td-${f.titre}-${lab}`} className="text-right py-1 pl-2" style={{ color: "var(--text-primary)" }}>
{fmt(f.valeurs[i], f.decimales ?? 0)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
/**
* Deux courbes superposées sur le même axe (comparatif local vs réseau).
* Les valeurs sont normalisées ensemble pour rester comparables visuellement.
*/
export function DualLineChart({
labels,
series,
}: {
labels: string[];
series: Array<{ name: string; values: number[]; color: string }>;
}) {
const W = 620, H = 220;
const padL = 10, padR = 10, padT = 26, padB = 30;
const plotW = W - padL - padR, plotH = H - padT - padB;
const n = labels.length;
const maxV = Math.max(1, ...series.flatMap((s) => s.values));
const x = (i: number) => (n > 1 ? padL + (i / (n - 1)) * plotW : padL + plotW / 2);
const y = (v: number) => padT + plotH - (v / maxV) * plotH;
return (
<div className="w-full overflow-x-auto">
<div className="flex items-center gap-4 mb-1">
{series.map((s) => (
<span key={s.name} className="flex items-center gap-1.5 text-[12px]" style={{ color: "var(--text-secondary)" }}>
<span className="inline-block w-3 h-[3px] rounded-full" style={{ background: s.color }} />
{s.name}
</span>
))}
</div>
<svg viewBox={`0 0 ${W} ${H}`} className="w-full" style={{ minWidth: 460 }} role="img" aria-label="Comparatif mensuel local vs réseau">
<line x1={padL} y1={padT + plotH} x2={padL + plotW} y2={padT + plotH} stroke="var(--border)" strokeWidth={1} />
{series.map((s) => {
const pts = s.values.map((v, i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`).join(" ");
return (
<g key={s.name}>
{n > 1 && (
<polyline points={pts} fill="none" stroke={s.color} strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" />
)}
{s.values.map((v, i) => (
<circle key={`${s.name}-${labels[i]}`} cx={x(i)} cy={y(v)} r={2.4} fill={s.color} />
))}
</g>
);
})}
{labels.map((lab, i) => (
<text key={lab} x={x(i)} y={H - 10} textAnchor="middle" fontSize={8} fill="var(--text-muted)">
{fmtMonthShort(lab)}
</text>
))}
</svg>
</div>
);
}
@@ -1,178 +0,0 @@
"use client";
import { useMemo, useRef } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import { useGridStore } from "@/features/grid/store/use-grid-store";
import type { ProductRow } from "@/types/grid";
import { PackageX } from "lucide-react";
function getNoSalesIn6Months(rows: ProductRow[]): ProductRow[] {
const sixMonthsAgo = new Date();
sixMonthsAgo.setMonth(sixMonthsAgo.getMonth() - 6);
return rows.filter(row => {
if (row.derniereVente) {
return new Date(row.derniereVente) < sixMonthsAgo;
}
// Fallback : calcul depuis sales12m
const salesMonths = Object.entries(row.sales12m || {})
.filter(([, qty]) => qty > 0)
.map(([m]) => m)
.sort();
if (salesMonths.length === 0) return true;
const last = salesMonths[salesMonths.length - 1];
const lastDate = new Date(parseInt(last.slice(0, 4)), parseInt(last.slice(4, 6)) - 1, 1);
return lastDate < sixMonthsAgo;
});
}
function formatDate(iso?: string): string {
if (!iso) return "—";
return new Date(iso).toLocaleDateString("fr-FR", { day: "2-digit", month: "2-digit", year: "numeric" });
}
function daysSince(iso?: string): number | null {
if (!iso) return null;
return Math.floor((Date.now() - new Date(iso).getTime()) / 86_400_000);
}
function formatCa(ca: number): string {
return ca.toLocaleString("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 });
}
const GAMME_COLORS: Record<string, string> = {
A: "bg-emerald-500/15 text-emerald-700",
B: "bg-blue-500/15 text-blue-700",
C: "bg-amber-500/15 text-amber-700",
Y: "bg-purple-500/15 text-purple-700",
Z: "bg-red-500/15 text-red-700",
};
export function NoSalesTab() {
const rows = useGridStore(s => s.rows);
const scrollRef = useRef<HTMLDivElement>(null);
const filtered = useMemo(() => {
const result = getNoSalesIn6Months(rows);
// Tri : produits les plus anciens en premier, puis par stock décroissant
return result.sort((a, b) => {
const da = a.derniereVente ? new Date(a.derniereVente).getTime() : 0;
const db = b.derniereVente ? new Date(b.derniereVente).getTime() : 0;
if (da !== db) return da - db;
return (b.stockActuel ?? 0) - (a.stockActuel ?? 0);
});
}, [rows]);
// eslint-disable-next-line react-hooks/incompatible-library
const rowVirtualizer = useVirtualizer({
count: filtered.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => 36,
overscan: 12,
});
if (rows.length === 0) {
return (
<div className="flex-1 flex items-center justify-center text-sm italic" style={{ color: "var(--text-muted)" }}>
Chargement des données en cours…
</div>
);
}
if (filtered.length === 0) {
return (
<div className="flex-1 flex flex-col items-center justify-center gap-3" style={{ color: "var(--text-muted)" }}>
<PackageX className="w-10 h-10 opacity-30" />
<p className="text-sm italic">Aucun produit sans vente depuis 6 mois.</p>
</div>
);
}
return (
<div className="flex flex-col flex-1 min-h-0 gap-3">
{/* Résumé */}
<div className="shrink-0 px-3 py-2 rounded-lg text-sm font-medium flex items-center gap-2"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}
>
<PackageX className="w-4 h-4 shrink-0" style={{ color: "var(--accent)" }} />
<span>
<span className="font-bold" style={{ color: "var(--text-primary)" }}>
{filtered.length.toLocaleString("fr-FR")}
</span>
{" "}produit{filtered.length > 1 ? "s" : ""} sans vente depuis plus de 6 mois
{" "}({rows.length.toLocaleString("fr-FR")} références au total)
</span>
</div>
{/* Tableau */}
<div ref={scrollRef} className="flex-1 min-h-0 overflow-auto rounded-xl" style={{ border: "1px solid var(--border)" }}>
<div className="min-w-[900px] text-[12px]">
<div className="sticky top-0 z-10 grid grid-cols-[90px_minmax(220px,1fr)_120px_76px_130px_70px_90px_110px_70px]"
style={{ background: "var(--bg-elevated)", borderBottom: "1px solid var(--border)" }}
>
{["Code", "Libellé", "Réf.", "Gamme", "Dernière vente", "Jours", "Stock", "CA 12m", "Mag."].map(col => (
<div key={col}
className="px-3 py-2 text-left font-semibold whitespace-nowrap"
style={{ color: "var(--text-muted)" }}
>
{col}
</div>
))}
</div>
<div style={{ height: rowVirtualizer.getTotalSize(), position: "relative" }}>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const row = filtered[virtualRow.index];
const i = virtualRow.index;
const days = daysSince(row.derniereVente);
const gamme = row.codeGamme?.trim().toUpperCase() ?? "—";
const gammeClass = GAMME_COLORS[gamme] ?? "bg-zinc-500/15 text-zinc-600";
return (
<div
key={row.codein}
className="absolute left-0 right-0 grid grid-cols-[90px_minmax(220px,1fr)_120px_76px_130px_70px_90px_110px_70px] items-center transition-colors hover:bg-white/5"
style={{
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
borderBottom: i < filtered.length - 1 ? "1px solid var(--border)" : undefined,
background: i % 2 === 0 ? "transparent" : "var(--bg-subtle, rgba(0,0,0,0.02))",
}}
>
<div className="px-3 py-1.5 font-mono" style={{ color: "var(--text-muted)" }}>{row.codein}</div>
<div className="px-3 py-1.5 truncate" style={{ color: "var(--text-primary)" }} title={row.libelle1}>{row.libelle1}</div>
<div className="px-3 py-1.5 font-mono text-[11px]" style={{ color: "var(--text-muted)" }}>{row.reference || "—"}</div>
<div className="px-3 py-1.5">
<span className={`inline-block px-1.5 py-0.5 rounded text-[11px] font-bold ${gammeClass}`}>
{gamme}
</span>
</div>
<div className="px-3 py-1.5 whitespace-nowrap" style={{ color: row.derniereVente ? "var(--text-secondary)" : "var(--text-muted)" }}>
{formatDate(row.derniereVente)}
</div>
<div className="px-3 py-1.5 text-right font-medium tabular-nums"
style={{ color: days && days > 365 ? "var(--color-red, #ef4444)" : "var(--text-secondary)" }}>
{days != null ? days.toLocaleString("fr-FR") : "—"}
</div>
<div className="px-3 py-1.5 text-right tabular-nums" style={{ color: "var(--text-secondary)" }}>
{row.stockActuel != null ? row.stockActuel.toLocaleString("fr-FR") : "—"}
</div>
<div className="px-3 py-1.5 text-right tabular-nums" style={{ color: "var(--text-secondary)" }}>
{row.totalCa > 0 ? formatCa(row.totalCa) : "—"}
</div>
<div className="px-3 py-1.5 text-right tabular-nums font-semibold"
style={{ color: "var(--text-secondary)" }}>
{row.nbMagasinsReseau != null ? row.nbMagasinsReseau : "—"}
</div>
</div>
);
})}
</div>
</div>
</div>
</div>
);
}
/** Compte les produits sans vente depuis 6 mois — utilisé pour le badge dans le switcher */
export function countNoSalesIn6Months(rows: ProductRow[]): number {
return getNoSalesIn6Months(rows).length;
}
@@ -2,7 +2,13 @@
import * as React from "react";
import { cn } from "@/lib/utils";
import { ChevronDown, Filter } from "lucide-react";
import { ChevronDown, Filter, Search, X } from "lucide-react";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useGridStore } from "@/features/grid/store/use-grid-store";
interface NomenclatureFilterProps {
@@ -10,33 +16,143 @@ interface NomenclatureFilterProps {
className?: string;
}
/**
* Filtre multi-nomenclatures de la Grille.
*
* Une liste déroulante à cases plutôt qu'un `<select>` simple : on veut pouvoir
* en retenir plusieurs, et surtout partir de « tout » pour retirer les postes
* dont on ne veut pas — ce qui est la manière naturelle de travailler quand un
* fournisseur en compte des dizaines.
*
* Convention : `null` = aucun filtre (tout s'affiche), `[]` = rien de coché
* (aucune ligne). Les distinguer évite la valeur sentinelle qu'imposerait un
* simple tableau, et rend « Tout décocher » possible sans ambiguïté.
*/
export function NomenclatureFilter({ options, className }: NomenclatureFilterProps) {
const code3 = useGridStore((s) => s.filters.code3);
const setFilter = useGridStore((s) => s.setFilter);
const selection = useGridStore((s) => s.filters.code3);
const setCode3Filter = useGridStore((s) => s.setCode3Filter);
const [recherche, setRecherche] = React.useState("");
/** `null` = tout retenu : les cases sont alors toutes cochées. */
const tout = selection === null;
const selectionnes = React.useMemo(() => new Set(selection ?? []), [selection]);
const visibles = React.useMemo(() => {
const q = recherche.trim().toLowerCase();
if (!q) return options;
return options.filter((o) => `${o.code} ${o.label}`.toLowerCase().includes(q));
}, [options, recherche]);
const basculer = (code: string, coche: boolean) => {
// Décocher depuis l'état « tout » doit retirer ce seul poste : on matérialise
// donc la liste complète, sans quoi le premier décochage ne ferait rien.
const base = tout ? options.map((o) => o.code) : (selection ?? []);
const suivant = coche ? [...base, code] : base.filter((c) => c !== code);
// Tout re-cocher revient à ne plus filtrer : on repasse à `null` pour que
// l'état reste lisible (et l'URL propre).
setCode3Filter(suivant.length === options.length ? null : suivant);
};
const libelle = tout
? "Toute la nomenclature"
: selectionnes.size === 0
? "Aucune nomenclature"
: selectionnes.size === 1
? (options.find((o) => o.code === selection![0])?.label ?? selection![0])
: `${selectionnes.size} / ${options.length} nomenclatures`;
return (
<div className={cn("relative group", className)}>
<div className="absolute left-3 top-1/2 -translate-y-1/2 flex items-center gap-2 pointer-events-none">
<Filter className="h-3.5 w-3.5 opacity-40 group-focus-within:opacity-100 transition-opacity" style={{ color: "var(--text-primary)" }} />
</div>
<div className={cn("relative", className)}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="apple-input w-full min-w-[240px] flex items-center gap-2 text-left"
style={{ fontSize: "12px", height: "36px", paddingLeft: "12px", paddingRight: "12px" }}
>
<Filter className="h-3.5 w-3.5 opacity-40 shrink-0" />
<span className="flex-1 truncate" style={{ color: tout ? undefined : "var(--text-primary)" }}>
{libelle}
</span>
{!tout && (
<span
role="button"
tabIndex={0}
title="Tout réafficher"
onClick={(e) => { e.preventDefault(); e.stopPropagation(); setCode3Filter(null); }}
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); e.stopPropagation(); setCode3Filter(null); } }}
className="shrink-0 rounded p-0.5 hover:bg-[var(--bg-elevated)]"
>
<X className="h-3.5 w-3.5 opacity-60" />
</span>
)}
<ChevronDown className="h-4 w-4 opacity-40 shrink-0" />
</button>
</DropdownMenuTrigger>
<select
value={code3 ?? ""}
onChange={(e) => setFilter("code3", e.target.value || null)}
className="apple-input pr-12 appearance-none w-full min-w-[240px]"
style={{ fontSize: "12px", height: "36px", paddingLeft: "36px" }}
>
<option value="">Toute la nomenclature</option>
{options.map((opt) => (
<option key={opt.code} value={opt.code}>
{opt.code} — {opt.label}
</option>
))}
</select>
<DropdownMenuContent align="start" className="w-[320px] max-h-[420px] overflow-y-auto p-0">
<div className="sticky top-0 z-10 p-2 space-y-2" style={{ background: "var(--bg-surface)", borderBottom: "1px solid var(--border)" }}>
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 opacity-40" />
<input
value={recherche}
onChange={(e) => setRecherche(e.target.value)}
onKeyDown={(e) => e.stopPropagation()}
placeholder="Filtrer les nomenclatures"
className="w-full rounded-lg pl-7 pr-2 py-1.5 text-[12px] outline-none"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-primary)" }}
/>
</div>
<div className="flex items-center gap-2 text-[11px]">
<button
type="button"
onClick={() => setCode3Filter(null)}
className="rounded px-2 py-1 hover:bg-[var(--bg-elevated)]"
style={{ color: "var(--text-secondary)" }}
>
Tout cocher
</button>
<button
type="button"
// Un code vide ne correspond à aucune ligne : c'est ainsi qu'on
// exprime « rien de coché » sans confondre avec « pas de filtre ».
onClick={() => setCode3Filter([])}
className="rounded px-2 py-1 hover:bg-[var(--bg-elevated)]"
style={{ color: "var(--text-secondary)" }}
>
Tout décocher
</button>
{recherche.trim() && visibles.length > 0 && (
<button
type="button"
onClick={() => setCode3Filter(visibles.map((o) => o.code))}
className="rounded px-2 py-1 hover:bg-[var(--bg-elevated)] ml-auto"
style={{ color: "var(--accent)" }}
>
Ne garder que ces {visibles.length}
</button>
)}
</div>
</div>
<div className="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none opacity-40">
<ChevronDown className="h-4 w-4" />
</div>
{visibles.length === 0 ? (
<p className="p-3 text-[12px]" style={{ color: "var(--text-muted)" }}>Aucune nomenclature ne correspond.</p>
) : (
visibles.map((opt) => (
<DropdownMenuCheckboxItem
key={opt.code}
className="text-xs cursor-pointer"
checked={tout || selectionnes.has(opt.code)}
onCheckedChange={(v) => basculer(opt.code, !!v)}
onSelect={(e: Event) => e.preventDefault()}
>
<span className="font-mono opacity-60 mr-1.5">{opt.code}</span>
{opt.label}
</DropdownMenuCheckboxItem>
))
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
@@ -0,0 +1,403 @@
"use client";
/**
* CollectFlow — Détail mensuel d'un produit, ouvert depuis la case « Tot. 12m ».
*
* La colonne de total dit COMBIEN, jamais COMMENT. On empile donc les trois
* séries qui expliquent le total, dans l'ordre où on les lit : ce qui est sorti
* (ventes), ce qui est entré (réceptions), puis ce qui reste — magasin par
* magasin, parce qu'un stock de 40 réparti sur deux sites et un stock de 40
* bloqué sur un seul ne se pilotent pas pareil.
*
* SVG fait main comme le reste des graphiques du dossier : aucune librairie.
*/
import React from "react";
import { ShoppingCart, PackageOpen, Warehouse } from "lucide-react";
import { DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { TuileStat } from "@/features/grid/components/stat-tile";
import { formatMonthLabel, formatDate, SITE_LABELS } from "@/features/grid/lib/months";
import type { ProductRow } from "@/types/grid";
/** Couleurs de séries : slots de la palette catégorielle validée (cf. `globals.css`). */
const COULEUR_VENTES = "var(--viz-1)";
const COULEUR_ENTREES = "var(--viz-3)";
const fmt = (v: number) => Math.round(v).toLocaleString("fr-FR");
/**
* Barres mensuelles sur une ligne de zéro explicite.
*
* Les quantités peuvent être NÉGATIVES (retours supérieurs aux ventes sur un
* mois) : l'échelle part donc du minimum réel et les barres se dessinent de part
* et d'autre du zéro, sinon un mois de retours sortirait du cadre par le bas.
*
* Chaque barre porte sa valeur écrite : douze nombres restent lisibles à cette
* largeur, et aucune valeur n'est ainsi accessible au seul survol.
*/
function BarresMensuelles({ mois, valeurs, couleur, aria }: {
mois: string[];
valeurs: number[];
couleur: string;
aria: string;
}) {
const n = valeurs.length;
const min = Math.min(0, ...valeurs);
const max = Math.max(0, ...valeurs);
// Une barre négative écrit sa valeur SOUS elle : sans marge basse
// supplémentaire, ce nombre se poserait sur les libellés de mois.
const W = 660, hautPlot = 104, padH = 18;
const padB = min < 0 ? 42 : 24;
const H = padH + hautPlot + padB;
const etendue = max - min || 1;
const y = (v: number) => padH + hautPlot - ((v - min) / etendue) * hautPlot;
const yZero = y(0);
const pas = W / n;
const largeur = Math.min(pas * 0.6, 34);
return (
<div className="overflow-x-auto min-w-0">
<svg
viewBox={`0 0 ${W} ${H}`}
width="100%"
height={H}
preserveAspectRatio="xMidYMid meet"
// `display:block` : un SVG en ligne traîne l'espace sous la ligne de
// base et décale la section suivante.
style={{ minWidth: 520, display: "block" }}
role="img"
aria-label={aria}
>
<line x1={0} y1={yZero} x2={W} y2={yZero} stroke="var(--border)" strokeWidth={1} />
{valeurs.map((v, i) => {
const cx = pas * i + pas / 2;
const yVal = y(v);
const haut = Math.min(yVal, yZero);
const hauteur = Math.max(Math.abs(yVal - yZero), 1.5);
return (
<g key={mois[i]}>
{v !== 0 && (
<rect
x={cx - largeur / 2}
y={haut}
width={largeur}
height={hauteur}
rx={2.5}
fill={couleur}
opacity={0.9}
/>
)}
<text
x={cx}
y={v < 0 ? yZero + hauteur + 12 : haut - 5}
textAnchor="middle"
fontSize={11.5}
fontWeight={700}
fill={v === 0 ? "var(--text-muted)" : "var(--text-secondary)"}
>
{fmt(v)}
</text>
<text x={cx} y={H - 8} textAnchor="middle" fontSize={11} fill="var(--text-muted)">
{formatMonthLabel(mois[i])}
</text>
</g>
);
})}
</svg>
</div>
);
}
/** En-tête de section : repère coloré + titre + cumul de la série. */
function EnteteSection({ icone: Icone, titre, detail, couleur }: {
icone: React.ComponentType<{ className?: string; style?: React.CSSProperties }>;
titre: string;
detail: string;
couleur: string;
}) {
return (
<div className="flex items-center gap-2 flex-wrap">
<span className="rounded-sm shrink-0" style={{ width: 14, height: 4, background: couleur }} />
<Icone className="w-4 h-4 shrink-0" style={{ color: "var(--text-muted)" }} />
<h3 className="text-[13px] font-bold" style={{ color: "var(--text-primary)" }}>{titre}</h3>
<span className="text-[12px]" style={{ color: "var(--text-muted)" }}>{detail}</span>
</div>
);
}
/**
* Une série mensuelle ventilée par magasin, un magasin par ligne.
*
* Sert aux ventes (flux : une colonne « 12 m » cumule la ligne) comme au stock
* (niveau : additionner douze fins de mois n'aurait aucun sens, pas de cumul).
*
* La ligne de total est recalculée depuis les lignes affichées : un total qui ne
* correspond pas à la somme visible fait douter du tableau entier.
*/
function TableauParMagasin({ row, mois, activeMagasin, parSite, agrege, sousLigne, cumul, aria }: {
row: ProductRow;
mois: string[];
activeMagasin: string;
/** Série par magasin (site → mois → valeur). */
parSite: Record<string, Record<string, number>> | undefined;
/** Série agrégée réseau, affichée seule quand aucune ventilation n'existe. */
agrege: Record<string, number>;
/** Texte secondaire sous le nom du magasin (`null` pour la ligne réseau). */
sousLigne: (site: string | null, valeurs: number[]) => string;
/** Ajoute une colonne de cumul sur 12 mois (flux uniquement). */
cumul?: boolean;
aria: string;
}) {
const sites = [...new Set([
...Object.keys(row.stock12mByStore ?? {}),
...Object.keys(row.sales12mByStore ?? {}),
])].sort();
// Sans ventilation par site (produit jamais mouvementé, données partielles),
// on montre au moins la série agrégée plutôt qu'un tableau vide.
const lignes = sites.length > 0
? sites.map((site) => {
const valeurs = mois.map((m) => parSite?.[site]?.[m] ?? 0);
return { cle: site, nom: SITE_LABELS[site]?.nom ?? site, actif: activeMagasin === site, valeurs, sous: sousLigne(site, valeurs) };
})
: [(() => {
const valeurs = mois.map((m) => agrege[m] ?? 0);
return { cle: "TOTAL", nom: "Total réseau", actif: true, valeurs, sous: sousLigne(null, valeurs) };
})()];
const totaux = mois.map((_, i) => lignes.reduce((t, l) => t + l.valeurs[i], 0));
const somme = (v: number[]) => v.reduce((t, x) => t + x, 0);
const cellule = (v: number, key: string, gras = false) => (
<td
key={key}
className={`px-1.5 py-1.5 text-right tabular-nums${gras ? " font-bold" : ""}`}
style={{ color: v === 0 && !gras ? "var(--text-muted)" : "var(--text-primary)" }}
>
{fmt(v)}
</td>
);
return (
<div className="rounded-xl overflow-x-auto min-w-0" style={{ border: "1px solid var(--border)" }}>
<table className="w-full text-[11.5px]" style={{ minWidth: 600 }} aria-label={aria}>
<thead>
<tr style={{ background: "var(--bg-elevated)" }}>
<th className="text-left px-2.5 py-1.5 font-semibold" style={{ color: "var(--text-secondary)" }}>
Magasin
</th>
{mois.map((m) => (
<th key={m} className="text-right px-1.5 py-1.5 font-semibold whitespace-nowrap" style={{ color: "var(--text-secondary)" }}>
{formatMonthLabel(m)}
</th>
))}
{cumul && (
<th className="text-right px-2 py-1.5 font-bold whitespace-nowrap" style={{ color: "var(--text-primary)", borderLeft: "1px solid var(--border)" }}>
12 m
</th>
)}
</tr>
</thead>
<tbody>
{lignes.map((l) => {
const surligne = l.actif && activeMagasin !== "TOTAL";
return (
<tr key={l.cle} style={{ borderTop: "1px solid var(--border)", background: surligne ? "var(--accent-bg)" : undefined }}>
<td className="px-2.5 py-1.5 whitespace-nowrap" style={{ color: "var(--text-primary)" }}>
<div className="font-semibold">{l.nom}</div>
{/* L'information secondaire (dernière entrée, mois avec
vente) est écrite, pas réservée au survol. */}
<div className="text-[10px]" style={{ color: "var(--text-muted)" }}>{l.sous}</div>
</td>
{l.valeurs.map((v, i) => cellule(v, mois[i]))}
{cumul && (
<td className="px-2 py-1.5 text-right tabular-nums font-bold" style={{ color: "var(--text-primary)", borderLeft: "1px solid var(--border)" }}>
{fmt(somme(l.valeurs))}
</td>
)}
</tr>
);
})}
{lignes.length > 1 && (
<tr style={{ borderTop: "1px solid var(--border-strong)", background: "var(--bg-elevated)" }}>
<td className="px-2.5 py-1.5 font-bold" style={{ color: "var(--text-primary)" }}>
Total
</td>
{totaux.map((v, i) => cellule(v, mois[i], true))}
{cumul && (
<td className="px-2 py-1.5 text-right tabular-nums font-bold" style={{ color: "var(--text-primary)", borderLeft: "1px solid var(--border)" }}>
{fmt(somme(totaux))}
</td>
)}
</tr>
)}
</tbody>
</table>
</div>
);
}
/**
* Détail 12 mois d'un produit : ventes, entrées en stock, stock par magasin.
*
* Les graphiques suivent le magasin actif de la Grille (cohérence avec la case
* cliquée) ; les tableaux de ventes et de stock restent toujours ventilés par
* magasin, c'est leur objet.
*
* `mois` est la fenêtre du serveur (lue dans les données, cf. `months.ts`) :
* c'est ce qui garantit que la tuile « Ventes 12 m » retombe sur le total de la
* Grille.
*/
export function ProductMonthlyModal({ row, mois, activeMagasin, onClose }: {
row: ProductRow;
mois: string[];
activeMagasin: string;
onClose: () => void;
}) {
const surTotal = activeMagasin === "TOTAL";
const libelleMagasin = surTotal ? "Tous magasins" : (SITE_LABELS[activeMagasin]?.nom ?? activeMagasin);
const ventes = mois.map((m) => (surTotal
? row.sales12m[m]
: row.sales12mByStore?.[activeMagasin]?.[m]) ?? 0);
const entrees = mois.map((m) => (surTotal
? row.receptions12m[m]
: row.receptions12mByStore?.[activeMagasin]?.[m]) ?? 0);
const stocks = mois.map((m) => (surTotal
? row.stock12m[m]
: row.stock12mByStore?.[activeMagasin]?.[m]) ?? 0);
// Sur un magasin donné, la dernière entrée du réseau n'est pas la sienne.
const derniereEntree = surTotal
? row.derniereLivraison
: row.derniereLivraisonByStore?.[activeMagasin];
const totalVentes = ventes.reduce((t, v) => t + v, 0);
const totalEntrees = entrees.reduce((t, v) => t + v, 0);
const stockFin = stocks[stocks.length - 1] ?? 0;
const moisAvecVente = ventes.filter((v) => v !== 0).length;
// Couverture : combien de mois le stock actuel tient-il au rythme des 12
// derniers mois ? Sans vente, le ratio n'existe pas — on ne l'invente pas.
const rythme = totalVentes / mois.length;
const couverture = rythme > 0 ? stockFin / rythme : null;
return (
<DialogContent
// `grid-cols-1` : sans colonne explicite, la grille de la modale se
// dimensionne sur son contenu le plus large (le tableau des magasins)
// et déborde par la droite au lieu de le laisser défiler.
className="max-w-[calc(100%-2rem)] sm:max-w-4xl grid-cols-1 max-h-[88vh] overflow-y-auto"
style={{ background: "var(--bg-surface)", border: "1px solid var(--border)" }}
onInteractOutside={onClose}
>
<DialogHeader>
<DialogTitle className="text-lg leading-snug pr-6" style={{ color: "var(--text-primary)" }}>
{row.libelle1}
</DialogTitle>
<p className="text-[13px] mt-1" style={{ color: "var(--text-muted)" }}>
{row.codein}
{row.reference ? ` · ${row.reference}` : ""}
{" · "}
<span className="font-semibold" style={{ color: "var(--text-secondary)" }}>{libelleMagasin}</span>
{" · 12 mois glissants, mois en cours exclu"}
</p>
</DialogHeader>
<div className="flex flex-wrap gap-2">
<TuileStat
label="Ventes 12 m"
valeur={fmt(totalVentes)}
indice={`${moisAvecVente} mois avec vente · ${rythme.toLocaleString("fr-FR", { maximumFractionDigits: 1 })} / mois`}
icone={ShoppingCart}
/>
<TuileStat
label="Entrées 12 m"
valeur={totalEntrees > 0 ? `+${fmt(totalEntrees)}` : "—"}
indice={derniereEntree ? `dernière entrée ${formatDate(derniereEntree)}` : "aucune entrée enregistrée"}
couleur={totalEntrees > 0 ? COULEUR_ENTREES : undefined}
icone={PackageOpen}
/>
<TuileStat
label="Stock fin de période"
valeur={fmt(stockFin)}
indice={couverture != null
? `≈ ${couverture.toLocaleString("fr-FR", { maximumFractionDigits: 1 })} mois de couverture`
: "pas de vente sur la période"}
icone={Warehouse}
/>
</div>
<section className="space-y-1.5">
<EnteteSection
icone={ShoppingCart}
titre="Ventes mensuelles"
detail={`${fmt(totalVentes)} unités sur 12 mois`}
couleur={COULEUR_VENTES}
/>
<BarresMensuelles
mois={mois}
valeurs={ventes}
couleur={COULEUR_VENTES}
aria={`Ventes mensuelles de ${row.libelle1} sur 12 mois`}
/>
{/* Le graphique donne la forme, le tableau donne les chiffres par
magasin — et son cumul « 12 m » est le même total que la tuile
et que la case cliquée dans la Grille. */}
<TableauParMagasin
row={row}
mois={mois}
activeMagasin={activeMagasin}
parSite={row.sales12mByStore}
agrege={row.sales12m}
sousLigne={(_site, valeurs) => {
const n = valeurs.filter((v) => v !== 0).length;
return n === 0 ? "aucune vente" : `${n} mois avec vente`;
}}
cumul
aria={`Ventes mensuelles de ${row.libelle1} par magasin`}
/>
</section>
<section className="space-y-1">
<EnteteSection
icone={PackageOpen}
titre="Entrées en stock"
detail={totalEntrees > 0 ? `+${fmt(totalEntrees)} unités reçues sur 12 mois` : "aucune réception sur la période"}
couleur={COULEUR_ENTREES}
/>
<BarresMensuelles
mois={mois}
valeurs={entrees}
couleur={COULEUR_ENTREES}
aria={`Entrées en stock de ${row.libelle1} sur 12 mois`}
/>
</section>
<section className="space-y-1.5">
<EnteteSection
icone={Warehouse}
titre="Stock par magasin"
detail="stock de fin de mois"
couleur="var(--border-strong)"
/>
<TableauParMagasin
row={row}
mois={mois}
activeMagasin={activeMagasin}
parSite={row.stock12mByStore}
agrege={row.stock12m}
sousLigne={(site) => {
// La date de dernière entrée explique un stock qui ne bouge plus.
const d = site ? row.derniereLivraisonByStore?.[site] : row.derniereLivraison;
return d ? `dern. entrée ${formatDate(d)}` : "aucune entrée";
}}
aria={`Stock de fin de mois de ${row.libelle1} par magasin`}
/>
</section>
</DialogContent>
);
}
@@ -0,0 +1,36 @@
"use client";
import React from "react";
/**
* Tuile de statistique : intitulé en phrase, valeur en chiffres proportionnels.
*
* Pas de `tabular-nums` ici : à grande taille, des chiffres de largeur égale font
* paraître un nombre court anormalement lâche. Les chiffres tabulaires sont
* réservés aux colonnes qui s'alignent verticalement (tableau, axes).
*
* Extraite de `heatmap-grid.tsx` pour être partagée entre la modale de tendance
* réseau et la modale de détail mensuel produit. Aucun changement de rendu.
*/
export function TuileStat({ label, valeur, indice, couleur, icone: Icone }: {
label: string;
valeur: string;
indice?: string;
couleur?: string;
icone?: React.ComponentType<{ className?: string; style?: React.CSSProperties }>;
}) {
return (
<div className="rounded-xl px-3.5 py-3 flex-1 min-w-[160px]" style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}>
<div className="text-[12px] font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>
{label}
</div>
<div className="flex items-center gap-1.5 mt-1">
{Icone && <Icone className="w-5 h-5 shrink-0" style={{ color: couleur ?? "var(--text-primary)" }} />}
<span className="text-[26px] font-bold leading-tight" style={{ color: couleur ?? "var(--text-primary)" }}>
{valeur}
</span>
</div>
{indice && <div className="text-[12px] mt-1 leading-snug" style={{ color: "var(--text-muted)" }}>{indice}</div>}
</div>
);
}
@@ -1,8 +1,9 @@
"use client";
import { useState } from "react";
import { useCallback } from "react";
import { useRouter, usePathname, useSearchParams } from "next/navigation";
import { Loader2, RefreshCw, CheckCircle, AlertCircle } from "lucide-react";
import { useQlikSyncJob } from "@/features/qlik-sync/use-qlik-sync-job";
interface SyncQlikButtonProps {
/** Code fournisseur affiché (pour sync ciblée) */
@@ -11,7 +12,8 @@ interface SyncQlikButtonProps {
lastUpdate?: string | null;
}
function formatDate(iso?: string | null): string {
/** Date + heure de dernière synchro, ou "jamais". */
function formatLastUpdate(iso?: string | null): string {
if (!iso) return "jamais";
const d = new Date(iso);
if (isNaN(d.getTime())) return "jamais";
@@ -21,49 +23,48 @@ function formatDate(iso?: string | null): string {
/**
* Bouton admin : synchronise depuis Qlik les métriques réseau du fournisseur affiché
* (POST /api/qlik/sync?fournisseur=…) puis recharge la grille. Affiche la dernière maj.
*
* Toute la mécanique de job (POST puis polling GET, reprise après reload) vit
* dans `useQlikSyncJob`, partagée avec la fiche produit.
*/
export function SyncQlikButton({ codeFournisseur, lastUpdate }: SyncQlikButtonProps) {
const [status, setStatus] = useState<"idle" | "loading" | "success" | "error">("idle");
const [message, setMessage] = useState<string>("");
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const handleSync = async () => {
if (!codeFournisseur) {
setStatus("error");
setMessage("Aucun fournisseur sélectionné");
return;
}
setStatus("loading");
setMessage("");
try {
const res = await fetch(`/api/qlik/sync?fournisseur=${encodeURIComponent(codeFournisseur)}`, { method: "POST" });
const data = await res.json();
if (!res.ok || !data.success) throw new Error(data.error || `HTTP ${res.status}`);
setStatus("success");
setMessage(`${data.upserted} produits réseau synchronisés`);
// Recharge la grille pour afficher les nouvelles données réseau
const params = new URLSearchParams(searchParams.toString());
params.set("_refresh", String(Date.now()));
router.replace(`${pathname}?${params.toString()}`);
setTimeout(() => setStatus("idle"), 4000);
} catch (e) {
setStatus("error");
setMessage(e instanceof Error ? e.message : String(e));
setTimeout(() => setStatus("idle"), 6000);
}
};
/** Force le refresh de la grille (avec cache-buster) et de la barre de résumé. */
const refreshGrid = useCallback(() => {
const params = new URLSearchParams(searchParams.toString());
params.set("_refresh", String(Date.now()));
router.replace(`${pathname}?${params.toString()}`);
router.refresh();
}, [pathname, router, searchParams]);
const { status, message, start } = useQlikSyncJob({
target: { mode: "fournisseur", fournisseur: codeFournisseur },
onSuccess: refreshGrid,
});
return (
<div className="flex flex-col items-end">
<button
onClick={handleSync}
disabled={status === "loading"}
className="btn-action btn-action-secondary flex items-center gap-1.5 disabled:opacity-60"
title={message || "Synchroniser les données réseau Qlik pour ce fournisseur"}
<div className="flex items-center gap-2">
<span
className="text-[10px] leading-tight text-right whitespace-nowrap hidden lg:block"
style={{ color: status === "error" ? "var(--accent-error)" : "var(--text-muted)" }}
title={message || undefined}
>
{status === "loading" ? (
{status === "error"
? message
: status === "running"
? (message || `Extraction Qlik…`)
: `MAJ Qlik · ${formatLastUpdate(lastUpdate)}`}
</span>
<button
onClick={start}
disabled={status === "running"}
className="btn-action btn-action-secondary flex items-center gap-1.5 disabled:opacity-60"
title={message || `Synchroniser les données réseau Qlik · MAJ ${formatLastUpdate(lastUpdate)}`}
>
{status === "running" ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : status === "success" ? (
<CheckCircle className="w-3.5 h-3.5 text-emerald-500" />
@@ -72,11 +73,8 @@ export function SyncQlikButton({ codeFournisseur, lastUpdate }: SyncQlikButtonPr
) : (
<RefreshCw className="w-3.5 h-3.5" />
)}
Sync Qlik
{status === "running" ? "Sync en cours…" : "Sync Qlik"}
</button>
<span className="text-[10px] text-slate-500 mt-0.5">
{status === "error" ? message : `MAJ Qlik : ${formatDate(lastUpdate)}`}
</span>
</div>
);
}
+122
View File
@@ -0,0 +1,122 @@
/**
* CollectFlow — Helpers de fenêtre mensuelle et de libellés magasin.
*
* Extraits de `heatmap-grid.tsx` pour être partagés entre la Grille et la
* fiche produit (`/produits`). Aucun changement de comportement.
*
* ⚠️ Deux conventions de clé de mois cohabitent dans l'application :
* - séries FF Nancy (ventes / stock / réceptions) : `"YYYYMM"` (sans tiret)
* - séries réseau Qlik (`qteByMonth`, `metricsByMonth`) : `"YYYY-MM"` (avec tiret)
* Utiliser `ffMonthToQlik` / `qlikMonthToFf` pour passer de l'une à l'autre.
*/
/**
* Les 12 mois complets glissants, du plus ancien au plus récent, au format
* `"YYYYMM"`. Le mois en cours est **exclu** (on part de i=12 jusqu'à i=1).
*
* ⚠️ À n'appeler qu'à l'intérieur d'un `useMemo` / d'un rendu, jamais au niveau
* module : `new Date()` évalué à l'import provoque un mismatch d'hydratation
* entre le rendu serveur et le rendu client.
*/
export function getLast12Months(): string[] {
const months: string[] = [];
const now = new Date();
// On commence à i=12 (il y a 12 mois) et on finit à i=1 (le mois dernier)
// pour exclure le mois en cours (i=0)
for (let i = 12; i >= 1; i--) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
months.push(`${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, "0")}`);
}
return months;
}
/**
* Fenêtre de 12 mois **telle que le serveur l'a calculée**, lue dans les clés des
* séries mensuelles des lignes reçues.
*
* `getLast12Months()` recalcule la fenêtre depuis l'horloge du navigateur ; or
* les totaux (`totalQuantite`, `quantiteByStore`…) sont figés côté serveur, sur
* SA fenêtre. Dès que les deux divergent — onglet resté ouvert au changement de
* mois, lignes servies depuis le cache, horloges décalées — un mois de ventes
* disparaît des colonnes tout en restant compté dans le total : la case
* « Tot. 12m » affiche 28 quand les douze cases visibles ne font que 20.
* Les clés des données sont la seule référence commune : on les prend.
*
* Renvoie `null` si aucune ligne ne porte de série (fournisseur sans vente,
* chargement en cours) — l'appelant retombe alors sur `getLast12Months()`.
*/
export function getMonthsFromRows(rows: ReadonlyArray<{ sales12m?: Record<string, number> }>): string[] | null {
for (const r of rows) {
const keys = Object.keys(r.sales12m ?? {}).filter((k) => /^\d{6}$/.test(k));
if (keys.length < 12) continue;
// Toutes les lignes d'un même calcul partagent la même fenêtre : la
// première ligne complète suffit.
return keys.sort().slice(-12);
}
return null;
}
/** `"202601"` → `"Jan 26"`. */
export function formatMonthLabel(key: string): string {
const m = parseInt(key.slice(4, 6), 10);
const names = ["Jan", "Fév", "Mar", "Avr", "Mai", "Jun", "Jul", "Aoû", "Sep", "Oct", "Nov", "Déc"];
return `${names[m - 1]} ${key.slice(2, 4)}`;
}
/** `"2026-01"` → `"01/26"` — axe compact des graphiques. */
export function fmtMonthShort(lab: string): string {
const [y, m] = lab.split("-");
return `${m ?? lab}/${(y ?? "").slice(2)}`;
}
/** Date ISO → `jj/mm/aaaa`, `"—"` si absente ou invalide. */
export function formatDate(iso?: string | null): string {
if (!iso) return "—";
const d = new Date(iso);
if (isNaN(d.getTime())) return "—";
return d.toLocaleDateString("fr-FR", { day: "2-digit", month: "2-digit", year: "numeric" });
}
/** Clé de mois FF `"202601"` → clé Qlik `"2026-01"`. */
export function ffMonthToQlik(key: string): string {
return `${key.slice(0, 4)}-${key.slice(4, 6)}`;
}
/** Clé de mois Qlik `"2026-01"` → clé FF `"202601"`. */
export function qlikMonthToFf(key: string): string {
return key.replace("-", "");
}
/** Les deux magasins FF Nancy suivis par l'application. */
export const SITE_LABELS: Record<string, { label: string; nom: string }> = {
"292": { label: "F", nom: "Frouard (Nancy)" },
"579": { label: "H", nom: "Houdemont" },
};
/**
* Style de badge stable pour un magasin : la couleur est dérivée d'un hash du
* nom, donc identique quel que soit l'ordre d'affichage.
*/
export function getStoreConfig(name: string) {
const known = SITE_LABELS[name];
const words = (known?.nom ?? name).trim().split(/\s+/);
let label = known?.label ?? words[0].charAt(0).toUpperCase();
if (!known && words.length > 1) {
label += words[1].charAt(0).toUpperCase();
}
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash);
}
const colorInt = Math.abs(hash) % 5;
switch (colorInt) {
case 0: return { bg: "rgba(99, 102, 241, 0.1)", text: "#818cf8", border: "rgba(99, 102, 241, 0.2)", label }; // Indigo
case 1: return { bg: "rgba(245, 158, 11, 0.1)", text: "#fbbf24", border: "rgba(245, 158, 11, 0.2)", label }; // Amber
case 2: return { bg: "rgba(16, 185, 129, 0.1)", text: "#10b981", border: "rgba(16, 185, 129, 0.2)", label }; // Emerald
case 3: return { bg: "rgba(236, 72, 153, 0.1)", text: "#ec4899", border: "rgba(236, 72, 153, 0.2)", label }; // Pink
case 4: return { bg: "rgba(14, 165, 233, 0.1)", text: "#0ea5e9", border: "rgba(14, 165, 233, 0.2)", label }; // Sky
default: return { bg: "var(--bg-elevated)", text: "var(--text-muted)", border: "var(--border)", label };
}
}
+215
View File
@@ -0,0 +1,215 @@
/**
* CollectFlow — Tendance des ventes réseau Qlik sur 12 mois.
*
* Extrait de `heatmap-grid.tsx` pour être partagé entre la Grille (sparkline de
* la colonne « Tendance / Réseau ») et la fiche produit (`/produits`).
* Aucun changement de comportement.
*/
/**
* Nombre de magasins du réseau La Foir'Fouille servant de dénominateur au taux
* de présence. Constante métier : source unique pour la Grille et la fiche produit.
*/
export const NB_MAGASINS_RESEAU = 270;
export type NetworkTrend = {
values: number[]; // quantités mensuelles, ordre chronologique
labels: string[]; // "YYYY-MM"
/**
* Quantité moyenne par magasin vendeur, mois par mois. `null` si le nombre
* de magasins n'est pas connu sur les 12 mois.
*/
perStore: number[] | null;
/**
* `true` si la tendance porte sur la qté/magasin, `false` si elle a dû se
* rabattre sur les quantités brutes faute de nombre de magasins.
*/
surQteParMagasin: boolean;
direction: "up" | "down" | "flat";
/** Variation des 4 derniers mois vs les 4 premiers. `null` si aucune base. */
pct: number | null;
/** Rien sur les 4 premiers mois, des ventes sur les 4 derniers. */
nouveau: boolean;
hasData: boolean;
};
/**
* Nombre de mois comparés à chaque bout de la fenêtre.
*
* Quatre, et pas un : comparer le dernier mois au premier ferait dépendre toute
* la tendance de deux points, donc du hasard d'un réassort ou d'une opération.
*/
export const TREND_WINDOW = 4;
/** Seuil de "forte" variation (±25%) pour distinguer hausse/forte hausse dans l'UI. */
export const TREND_STRONG = 0.25;
/**
* Les 12 mois complets glissants au format Qlik `"YYYY-MM"`, du plus ancien au
* plus récent. **Le mois en cours est exclu** : il est partiel, l'intégrer
* écraserait systématiquement la tendance vers le bas.
*
* Même fenêtre que `getLast12Months()` (clés FF `"YYYYMM"`) et que
* `buildGridNetworkQlikDateFilter()` (extraction Qlik) — les trois doivent
* rester alignés.
*/
export function buildRolling12QlikMonths(now: Date = new Date()): string[] {
const months: string[] = [];
for (let i = 12; i >= 1; i--) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
months.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`);
}
return months;
}
/** Seuil au-delà duquel on ne considère plus la tendance comme stable (±8%). */
export const TREND_FLAT = 0.08;
export const TREND_COLOR: Record<NetworkTrend["direction"], string> = {
up: "#22c55e",
down: "#ef4444",
flat: "#94a3b8",
};
/**
* Tendance réseau : variation entre les **4 premiers** et les **4 derniers**
* mois de la fenêtre de 12 mois glissants, mois en cours exclu.
*
* La fenêtre est reconstruite à partir de la date du jour (et non des clés
* présentes dans `qteByMonth`) : c'est le seul moyen de garantir une tendance
* réellement glissante. Un mois en cours (partiel), ou plus vieux que 12 mois,
* présent dans le cache est donc **ignoré** — sinon la pente est faussée par un
* mois tronqué.
*
* Une série n'est affichée que si les **12 clés sont explicitement présentes**.
* L'extracteur Qlik écrit lui-même les mois sans faits à 0 après avoir validé
* la fenêtre et l'égalité avec le total Qlik. Une ancienne extraction partielle
* ne peut donc plus être présentée comme une tendance réelle.
*
* Cette exigence du « tout ou rien » évite deux écueils observés en production :
* une tendance calculée sur deux points (« +4 100 % · Forte hausse » pour un
* article vendu depuis mai), et des mois non extraits comptés comme des mois
* sans vente.
*
* ⚠️ CHANGEMENT D'INDICATEUR. La version précédente rapportait la pente d'une
* régression linéaire à la moyenne de la série. Mathématiquement défendable,
* mais illisible en pratique — constaté en production :
*
* - « −124 % », alors qu'une baisse ne peut pas dépasser −100 % ;
* - « +508 % » sur des dizaines de lignes d'affilée, toujours la même valeur :
* c'est la signature arithmétique d'une série nulle partout sauf le dernier
* mois, où le rapport pente/moyenne ne dépend même plus des quantités ;
* - « +1 062 % » pour 2 unités vendues une seule fois dans l'année.
*
* On compare donc désormais deux moyennes de 4 mois, ce qui donne une variation
* qui se lit comme telle, plancher à −100 %, et reste insensible au bruit d'un
* mois isolé. Sans base de comparaison (rien vendu sur les 4 premiers mois),
* aucun pourcentage n'est inventé : le produit est signalé « nouveau ».
*
* ⚠️ LA TENDANCE PORTE SUR LA QUANTITÉ PAR MAGASIN, PAS SUR LA QUANTITÉ BRUTE.
*
* La quantité brute confond deux phénomènes distincts : un produit peut vendre
* davantage simplement parce qu'il est référencé dans plus de magasins, sans
* mieux se vendre nulle part. Inversement, un produit retiré de 60 magasins peut
* s'effondrer en volume tout en performant mieux là où il reste.
*
* C'est donc `quantité / magasins vendeurs` qui dit si un produit marche — et
* c'est sur elle que l'évolution est calculée. La courbe des quantités reste
* affichée, mais comme contexte, pas comme indicateur.
*
* Repli : sans nombre de magasins sur les 12 mois, on retombe sur les quantités
* brutes et `surQteParMagasin` vaut `false`, pour que l'UI puisse le dire.
*
* Direction : forte hausse >+25%, hausse >+8%, stable, baisse <−8%, forte baisse <−25%.
*/
export function computeNetworkTrend(
qteByMonth?: Record<string, number> | null,
nbMagByMonth?: Record<string, number> | null,
now: Date = new Date(),
): NetworkTrend {
const empty: NetworkTrend = {
values: [], labels: [], perStore: null, surQteParMagasin: false,
direction: "flat", pct: null, nouveau: false, hasData: false,
};
if (!qteByMonth) return empty;
const labels = buildRolling12QlikMonths(now);
const complet = labels.every((label) =>
Object.prototype.hasOwnProperty.call(qteByMonth, label) &&
Number.isFinite(Number(qteByMonth[label])),
);
if (!complet) return empty;
const values = labels.map((l) => Number(qteByMonth[l]));
const n = values.length;
// Quantité par magasin vendeur : l'indicateur qui isole la performance du
// produit de sa diffusion. Un mois sans magasin vendeur vaut 0 — c'est le
// seul choix cohérent avec une quantité elle-même nulle.
const magasins = computeStoresSeries(nbMagByMonth, now);
const perStore = magasins
? values.map((v, i) => (magasins.values[i] > 0 ? v / magasins.values[i] : 0))
: null;
const serie = perStore ?? values;
const taille = Math.min(TREND_WINDOW, Math.floor(n / 2));
const moyenne = (xs: number[]) => (xs.length ? xs.reduce((s, v) => s + v, 0) / xs.length : 0);
const debut = moyenne(serie.slice(0, taille));
const fin = moyenne(serie.slice(n - taille));
let pct: number | null = null;
let nouveau = false;
let direction: NetworkTrend["direction"] = "flat";
if (debut > 0) {
// Plancher naturel à −100 % : `fin` ne peut pas descendre sous zéro sans
// retours massifs, et la division par une base positive garde le sens.
pct = (fin - debut) / debut;
direction = pct > TREND_FLAT ? "up" : pct < -TREND_FLAT ? "down" : "flat";
} else if (fin > 0) {
// Rien au départ, des ventes à l'arrivée : la variation relative n'existe
// pas (division par zéro). On le dit, au lieu d'afficher un nombre inventé.
nouveau = true;
direction = "up";
}
return { values, labels, perStore, surQteParMagasin: perStore != null, direction, pct, nouveau, hasData: true };
}
/**
* Série « nombre de magasins vendeurs » sur la MÊME fenêtre que
* `computeNetworkTrend`, pour la superposer à la courbe des quantités.
*
* Pourquoi cette deuxième courbe : une quantité qui monte parce que le produit
* est diffusé dans plus de magasins ne raconte pas la même histoire qu'une
* quantité qui monte à diffusion constante. Sans elle, la tendance seule ne
* permet pas de distinguer un succès produit d'un simple élargissement.
*
* Même règle du tout ou rien que la courbe des quantités : les 12 clés doivent
* être explicitement présentes, sinon `null` — un mois non extrait ne doit pas
* se lire comme « zéro magasin », ce qui simulerait un arrêt de diffusion.
*/
export function computeStoresSeries(
nbMagByMonth?: Record<string, number> | null,
now: Date = new Date(),
): { values: number[]; labels: string[] } | null {
if (!nbMagByMonth) return null;
const labels = buildRolling12QlikMonths(now);
const complet = labels.every((label) =>
Object.prototype.hasOwnProperty.call(nbMagByMonth, label) &&
Number.isFinite(Number(nbMagByMonth[label])),
);
if (!complet) return null;
return { values: labels.map((l) => Number(nbMagByMonth[l])), labels };
}
/** Libellé français de la tendance : « Forte hausse », « Baisse », « Stable »… */
export function trendLabel(pct: number | null, nouveau = false): string {
// Un produit sans base de comparaison n'est pas « stable » : il est nouveau.
if (nouveau) return "Nouveau";
if (pct == null) return "Stable";
if (pct > TREND_STRONG) return "Forte hausse";
if (pct > TREND_FLAT) return "Hausse";
if (pct < -TREND_STRONG) return "Forte baisse";
if (pct < -TREND_FLAT) return "Baisse";
return "Stable";
}
+54 -2
View File
@@ -20,6 +20,19 @@ interface GridState {
columnVisibility: Record<string, boolean>;
/** Persisted column sizing state */
columnSizing: Record<string, number>;
/**
* Bloc des 12 colonnes mensuelles affiché ou non dans la Grille.
*
* Séparé de `columnVisibility` : masquer douze colonnes une par une n'est pas
* une manipulation, et l'état « bloc replié » doit survivre au renouvellement
* de la fenêtre glissante — les identifiants `month_YYYYMM` changent chaque
* mois, un drapeau ne change pas.
*/
showMonthlySales: boolean;
setShowMonthlySales: (visible: boolean) => void;
/** Bloc des deux colonnes de prix (PV moyen réseau, PV central) affiché ou non. */
showPrices: boolean;
setShowPrices: (visible: boolean) => void;
/** Currently displayed store: "TOTAL" | "292" | "579" */
activeMagasin: string;
@@ -33,7 +46,9 @@ interface GridState {
clearDrafts: (codeins: string[]) => void;
/** Apply saved drafts onto row.codeGamme so change indicators persist after save */
applyDraftsToRows: (draftsToApply: Record<string, GammeCode>) => void;
setFilter: (key: keyof GridFilters, value: string | null) => void;
setFilter: (key: Exclude<keyof GridFilters, "code3">, value: string | null) => void;
/** Nomenclatures retenues. `null` = aucun filtre, `[]` = rien de coché. */
setCode3Filter: (codes: string[] | null) => void;
setDisplayDensity: (density: "compact" | "normal" | "comfortable") => void;
setActiveGridQuery: (query: string) => void;
restoreSnapshot: (changes: Record<string, GammeCode>) => void;
@@ -70,6 +85,12 @@ function computeSummary(rows: ProductRow[], drafts: Record<string, GammeCode>, m
};
}
/** Sous-ensemble réellement écrit dans le navigateur (cf. `partialize`). */
type GridPersisted = Pick<
GridState,
"filters" | "displayDensity" | "draftChanges" | "activeGridQuery" | "columnVisibility" | "columnSizing" | "showMonthlySales" | "showPrices"
>;
export const useGridStore = create<GridState>()(
persist(
(set, get) => ({
@@ -98,6 +119,11 @@ export const useGridStore = create<GridState>()(
activeGridQuery: "",
columnVisibility: {},
columnSizing: {},
showMonthlySales: true,
showPrices: true,
setShowMonthlySales: (visible) => set({ showMonthlySales: visible }),
setShowPrices: (visible) => set({ showPrices: visible }),
setActiveMagasin: (code) => {
set({ activeMagasin: code });
@@ -172,6 +198,9 @@ export const useGridStore = create<GridState>()(
setFilter: (key, value) => {
set((state) => ({ ...state, filters: { ...state.filters, [key]: value } }));
},
setCode3Filter: (codes) => {
set((state) => ({ ...state, filters: { ...state.filters, code3: codes } }));
},
setDisplayDensity: (density) => set({ displayDensity: density }),
setActiveGridQuery: (query) => set({ activeGridQuery: query }),
restoreSnapshot: (changes) => {
@@ -214,14 +243,37 @@ export const useGridStore = create<GridState>()(
}),
{
name: "collectflow-grid-storage",
/**
* v1 — `filters.code3` est passé d'une chaîne unique à `string[] | null`
* (filtre multi-nomenclatures). Sans migration, la valeur déjà présente
* dans le navigateur restait une chaîne : `new Set("320211")` produit un
* ensemble de caractères, plus aucune ligne ne correspond, et la Grille
* apparaît vide. Le numéro de version force la reprise de l'existant.
*/
version: 1,
migrate: (persisted: unknown, version: number): GridPersisted => {
const etat = (persisted ?? {}) as GridPersisted & { filters?: Record<string, unknown> };
if (version < 1 && etat.filters) {
const c = etat.filters.code3;
etat.filters.code3 = typeof c === "string" && c !== "" ? [c] : Array.isArray(c) ? c : null;
}
// Drapeaux ajoutés après coup : un état persisté antérieur n'en a
// pas, et une valeur `undefined` écraserait la valeur par défaut
// à la fusion (le bloc disparaîtrait sans raison).
if (typeof etat.showMonthlySales !== "boolean") etat.showMonthlySales = true;
if (typeof etat.showPrices !== "boolean") etat.showPrices = true;
return etat;
},
// Only persist filters, display density, drafts, active grid query, and column visibility/sizing
partialize: (state) => ({
partialize: (state): GridPersisted => ({
filters: state.filters,
displayDensity: state.displayDensity,
draftChanges: state.draftChanges,
activeGridQuery: state.activeGridQuery,
columnVisibility: state.columnVisibility,
columnSizing: state.columnSizing,
showMonthlySales: state.showMonthlySales,
showPrices: state.showPrices,
}),
}
)
@@ -0,0 +1,207 @@
import "server-only";
import {
pgGetProduitDetail,
pgGetCodeinByCodeCentrale,
pgGetFournisseursByCodein,
pgGetStockByCodein,
pgGetCommandesByCodein,
pgGetGammeHistoryByCodein,
pgGetMensuelByCodein,
} from "@/lib/pg-ff-client";
import { buildLast12MonthsRange } from "@/lib/api-ff-client";
import { getNetworkMetricsByCodeCentrale } from "@/lib/qlik-network-cache";
import type { ProduitFiche, ProduitMois, ProduitTotaux } from "@/features/produits/types";
/** Les 12 clés de mois "YYYYMM" glissantes, mois courant exclu. */
function buildMonthKeys(): string[] {
const months: string[] = [];
const now = new Date();
for (let i = 12; i >= 1; i--) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
months.push(`${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, "0")}`);
}
return months;
}
function emptyMois(mois: string): ProduitMois {
return { mois, qte: 0, ca: 0, marge: 0, stockFinMois: 0, qteRecue: 0 };
}
function totauxFrom(rows: ProduitMois[]): ProduitTotaux {
const qte = rows.reduce((s, r) => s + r.qte, 0);
const ca = rows.reduce((s, r) => s + r.ca, 0);
const marge = rows.reduce((s, r) => s + r.marge, 0);
return { qte, ca, marge, tauxMarge: ca > 0 ? (marge / ca) * 100 : 0 };
}
/**
* Assemble la fiche d'un produit **à partir de son code centrale** : c'est la
* clé du réseau Qlik, seule identité disponible pour un produit que le réseau
* travaille mais que Nancy ne référence pas.
*
* Si le catalogue Nancy connaît ce code centrale, on rattache toute la partie
* locale (identité, fournisseurs, stock, gammes, 12 mois glissants de ventes).
* Sinon la fiche reste purement réseau, ce qui est un cas parfaitement normal
* depuis la recherche Qlik.
*
* Renvoie `null` uniquement si ni Qlik ni le catalogue ne connaissent le code.
*/
export async function getProduitFicheByCodeCentrale(codeCentrale: string): Promise<ProduitFiche | null> {
const cleaned = codeCentrale.trim();
if (!cleaned) return null;
const codein = await pgGetCodeinByCodeCentrale(cleaned);
if (codein) return getProduitFiche(codein);
// Produit réseau pur : aucune donnée locale à assembler.
const map = await getNetworkMetricsByCodeCentrale([cleaned]);
const reseau = map.get(cleaned) ?? null;
if (!reseau) return null;
const { dateDebut, dateFin } = buildLast12MonthsRange();
const months = buildMonthKeys();
const mensuelTotal: Record<string, ProduitMois> = {};
for (const m of months) mensuelTotal[m] = emptyMois(m);
return {
detail: null,
codeCentrale: cleaned,
libelleReseau: reseau.libelleReseau ?? "",
fournisseurReseau: reseau.fournisseurReseau ?? "",
fournisseurs: [],
stock: [],
commandesEnCours: 0,
gammes: [],
months,
mensuelTotal,
mensuelParSite: {},
totaux: totauxFrom(months.map((m) => mensuelTotal[m])),
totauxParSite: {},
sitesActifs: [],
reseau,
periode: { dateDebut, dateFin },
};
}
/**
* Assemble la fiche complète d'un produit : identité, fournisseurs, stock,
* gammes, 12 mois glissants de ventes (total et par magasin) et métriques
* réseau Qlik.
*
* Renvoie `null` si le codein est inconnu du catalogue FF.
*/
export async function getProduitFiche(codein: string): Promise<ProduitFiche | null> {
const cleaned = codein.trim();
if (!cleaned) return null;
const detail = await pgGetProduitDetail(cleaned);
if (!detail) return null;
const { dateDebut, dateFin } = buildLast12MonthsRange();
const [fournisseurs, stock, commandesEnCours, gammes, mensuelRows] = await Promise.all([
pgGetFournisseursByCodein(cleaned),
pgGetStockByCodein(cleaned),
pgGetCommandesByCodein(cleaned),
pgGetGammeHistoryByCodein(cleaned),
pgGetMensuelByCodein(cleaned, dateDebut, dateFin),
]);
const months = buildMonthKeys();
const allowed = new Set(months);
const mensuelTotal: Record<string, ProduitMois> = {};
const mensuelParSite: Record<string, Record<string, ProduitMois>> = {};
for (const m of months) mensuelTotal[m] = emptyMois(m);
// Mois réellement présents dans les mouvements. Indispensable pour distinguer
// « pas de mouvement ce mois-là » (→ report du stock précédent) de « stock
// effectivement nul » — c'est la convention de la Grille.
const moisAvecDonnees = new Set<string>();
const moisAvecDonneesParSite = new Map<string, Set<string>>();
for (const row of mensuelRows) {
// SQL renvoie "YYYY-MM", la timeline de l'app utilise "YYYYMM".
const mois = row.mois.replace("-", "");
if (!allowed.has(mois)) continue;
const site = String(row.site).trim();
const qte = Number(row.qte_vendue) || 0;
const ca = Number(row.ca_ht) || 0;
const marge = Number(row.marge) || 0;
const stockFinMois = Number(row.stock_fin_mois) || 0;
const qteRecue = Number(row.qte_recue) || 0;
moisAvecDonnees.add(mois);
const tot = mensuelTotal[mois];
tot.qte += qte;
tot.ca += ca;
tot.marge += marge;
tot.stockFinMois += stockFinMois;
tot.qteRecue += qteRecue;
mensuelParSite[site] ??= Object.fromEntries(months.map((m) => [m, emptyMois(m)]));
if (!moisAvecDonneesParSite.has(site)) moisAvecDonneesParSite.set(site, new Set());
moisAvecDonneesParSite.get(site)!.add(mois);
const cell = mensuelParSite[site][mois];
cell.qte += qte;
cell.ca += ca;
cell.marge += marge;
// Le SQL donne déjà le stock du dernier mouvement du mois pour ce site.
cell.stockFinMois = stockFinMois;
cell.qteRecue += qteRecue;
}
// Report du stock de fin de mois sur les mois SANS mouvement (carry-forward),
// même convention que la Grille : un mois sans mouvement conserve le stock du
// mois précédent, alors qu'un mois avec mouvements et un stock nul reste à 0.
const carryForward = (serie: Record<string, ProduitMois>, avecDonnees: Set<string>) => {
let last = 0;
for (const m of months) {
if (avecDonnees.has(m)) last = serie[m].stockFinMois;
else serie[m].stockFinMois = last;
}
};
carryForward(mensuelTotal, moisAvecDonnees);
for (const [site, bySite] of Object.entries(mensuelParSite)) {
carryForward(bySite, moisAvecDonneesParSite.get(site) ?? new Set());
}
const totaux = totauxFrom(months.map((m) => mensuelTotal[m]));
const totauxParSite: Record<string, ProduitTotaux> = {};
for (const [site, bySite] of Object.entries(mensuelParSite)) {
totauxParSite[site] = totauxFrom(months.map((m) => bySite[m]));
}
const sitesActifs = Object.entries(totauxParSite)
.filter(([, t]) => t.qte > 0)
.map(([site]) => site)
.sort();
// Métriques réseau Qlik (jointure par code centrale).
let reseau = null;
if (detail.code_centrale) {
const map = await getNetworkMetricsByCodeCentrale([detail.code_centrale]);
reseau = map.get(detail.code_centrale) ?? null;
}
return {
detail,
codeCentrale: detail.code_centrale ?? "",
libelleReseau: reseau?.libelleReseau ?? "",
fournisseurReseau: reseau?.fournisseurReseau ?? "",
fournisseurs,
stock,
commandesEnCours,
gammes,
months,
mensuelTotal,
mensuelParSite,
totaux,
totauxParSite,
sitesActifs,
reseau,
periode: { dateDebut, dateFin },
};
}
@@ -0,0 +1,445 @@
import "server-only";
import { searchQlikArticles, QLIK_SEARCH_MAX_RESULTS } from "@/lib/qlik-search";
import type { NetworkMetric } from "@/lib/qlik-client";
import {
getNetworkMetricsByCodeCentrale,
upsertNetworkMetrics,
type NetworkMetricCached,
} from "@/lib/qlik-network-cache";
import { pgGetProduitsByCodeCentrale, pgSearchProduits, type PgProduitSearchRow } from "@/lib/pg-ff-client";
import { buildRolling12QlikMonths, NB_MAGASINS_RESEAU } from "@/features/grid/lib/network-trend";
import { normalizeMargePct } from "@/features/produits/lib/compare-reseau";
import type { ProduitRechercheResultat, ProduitRechercheRow } from "@/features/produits/types";
/**
* CollectFlow — Recherche produit **Qlik d'abord**, base FF Nancy ensuite.
*
* Déroulé :
* 1. Qlik Sense, **une seule session** : les articles correspondant au terme ET
* leurs mesures réseau sur 12 mois glissants (`searchQlikArticles`).
* 2. Base FF Nancy : lesquels référençons-nous ? (`pgGetProduitsByCodeCentrale`)
* 3. Cache `qlik_network_metrics` : détail mensuel quand une sync l'a déjà
* extrait (le cube de recherche est agrégé, il ne le fournit pas).
*
* Une seconde session Qlik pour les mesures faisait abandonner le moteur
* (`code 15 — Request aborted`) : deux sessions concurrentes sur la même app,
* dont une avec une grosse sélection, et l'Engine coupe les requêtes.
*
* Si Qlik est injoignable, on se rabat sur la recherche catalogue local pour ne
* pas laisser l'utilisateur sans résultat — la réponse le signale explicitement
* (`source: "db"`).
*/
/** Durée de validité d'un résultat de recherche en cache mémoire. */
const CACHE_TTL_MS = 10 * 60 * 1000;
/** Durée de conservation d'un job terminé (le client a le temps de le relire). */
const JOB_TTL_MS = 5 * 60 * 1000;
/**
* Série « magasins vendeurs » extraite du détail mensuel du cache.
*
* Un mois PRÉSENT dans le détail est un mois extrait : sans `nbMag`, c'est
* qu'aucun magasin n'a vendu, donc zéro. Le traiter comme manquant amputerait la
* série et priverait la tendance de son dénominateur.
*/
function nbMagParMois(
metricsByMonth: Record<string, { nbMag?: number }> | null | undefined,
): Record<string, number> | null {
if (!metricsByMonth) return null;
const parMois: Record<string, number> = {};
for (const mois of Object.keys(metricsByMonth)) {
const nb = Number(metricsByMonth[mois]?.nbMag);
parMois[mois] = Number.isFinite(nb) ? nb : 0;
}
return Object.keys(parMois).length > 0 ? parMois : null;
}
/** Un cache réseau n'est fiable que s'il porte explicitement les 12 mois attendus. */
function cacheReseauComplet(
cache: NetworkMetricCached | undefined,
periode: string | null,
now: Date = new Date(),
): cache is NetworkMetricCached {
const parMois = cache?.qteByMonth;
if (!parMois || !periode || cache.periode !== periode) return false;
return buildRolling12QlikMonths(now).every((mois) =>
Object.prototype.hasOwnProperty.call(parMois, mois) &&
Number.isFinite(Number(parMois[mois])),
);
}
/**
* Nombre maximum de recherches Qlik simultanées.
*
* Le serveur Qlik sature vite (« Out of memory », « Request aborted ») : on
* plafonne plutôt que de lui envoyer N extractions en parallèle.
*/
const MAX_RECHERCHES_SIMULTANEES = 1;
/**
* Cache mémoire des recherches. Une recherche coûte deux allers-retours Qlik
* (plusieurs secondes chacun) : sans ce cache, un retour arrière du navigateur
* ou un rafraîchissement relancerait toute l'extraction.
*/
const cache = new Map<string, { at: number; result: ProduitRechercheResultat }>();
/**
* Jobs de recherche, indexés par terme normalisé.
*
* Une recherche dure de quelques secondes à plusieurs minutes (deux allers-retours
* Qlik dont une extraction mensuelle). Une requête HTTP maintenue aussi longtemps
* se fait couper par le reverse proxy, qui répond une page d'erreur HTML — le
* client recevait alors « Unexpected token '<' … is not valid JSON ». Le travail
* tourne donc en tâche de fond et le client interroge l'état.
*/
const jobs = new Map<string, RechercheJob>();
export interface RechercheJob {
jobId: string;
terme: string;
status: "running" | "success" | "error";
/** Étape en cours, affichée pendant l'attente. */
etape: string;
startedAt: string;
finishedAt?: string;
error?: string;
result?: ProduitRechercheResultat;
}
/** État renvoyé au client : le job, ou `idle` si aucune recherche connue. */
export type RechercheEtat = RechercheJob | { status: "idle"; terme: string };
function cacheKey(term: string): string {
return term.trim().toLowerCase().replace(/\s+/g, " ");
}
/** Purge les entrées expirées (le volume reste faible, un balayage suffit). */
function purge(): void {
const now = Date.now();
for (const [k, v] of cache) {
if (now - v.at > CACHE_TTL_MS) cache.delete(k);
}
for (const [k, j] of jobs) {
if (j.status !== "running" && j.finishedAt && now - Date.parse(j.finishedAt) > JOB_TTL_MS) jobs.delete(k);
}
}
function jobsEnCours(): number {
let n = 0;
for (const j of jobs.values()) if (j.status === "running") n++;
return n;
}
function jobTermine(terme: string, result: ProduitRechercheResultat): RechercheJob {
return {
jobId: `cache_${cacheKey(terme)}`,
terme,
status: "success",
etape: "Terminé",
startedAt: new Date().toISOString(),
finishedAt: new Date().toISOString(),
result,
};
}
/**
* Démarre (ou réutilise) une recherche et rend la main **immédiatement**.
*
* - résultat déjà en cache et pas de `force` → job `success` directement ;
* - recherche déjà en cours sur le même terme → on renvoie ce job ;
* - sinon on lance le travail en tâche de fond.
*/
export function demarrerRecherche(
term: string,
options: { force?: boolean } = {},
): RechercheJob {
const cleaned = term.trim();
const key = cacheKey(cleaned);
purge();
if (!options.force) {
const hit = cache.get(key);
if (hit) {
console.log(`[produits/search] "${cleaned}" — servi depuis le cache mémoire`);
return jobTermine(cleaned, hit.result);
}
}
const existant = jobs.get(key);
if (existant && existant.status === "running") return existant;
if (existant && !options.force && existant.status === "success" && existant.result) return existant;
if (jobsEnCours() >= MAX_RECHERCHES_SIMULTANEES) {
return {
jobId: `refus_${Date.now().toString(36)}`,
terme: cleaned,
status: "error",
etape: "Refusé",
startedAt: new Date().toISOString(),
finishedAt: new Date().toISOString(),
error: "Trop de recherches Qlik en cours. Réessayez dans quelques instants.",
};
}
const job: RechercheJob = {
jobId: `rech_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
terme: cleaned,
status: "running",
etape: "Recherche des articles dans Qlik…",
startedAt: new Date().toISOString(),
};
jobs.set(key, job);
console.log(`[produits/search] job=${job.jobId} démarré pour "${cleaned}"`);
// Fire-and-forget : le client suivra l'avancement via `etatRecherche`.
void executerRecherche(cleaned, (etape) => { job.etape = etape; })
.then((result) => {
job.result = result;
job.status = "success";
job.etape = "Terminé";
job.finishedAt = new Date().toISOString();
// On ne met en cache qu'un résultat Qlik exploitable : mémoriser un
// repli catalogue (panne Qlik passagère) le figerait 10 minutes
// alors qu'un simple « Relancer » aurait suffi.
if (result.source === "qlik" && !result.qlikError) {
cache.set(key, { at: Date.now(), result });
}
console.log(`[produits/search] job=${job.jobId} terminé — ${result.rows.length} produit(s) en ${result.dureeMs} ms`);
})
.catch((e) => {
const message = e instanceof Error ? e.message : String(e);
job.status = "error";
job.etape = "Échec";
job.error = messageQlikLisible(message);
job.finishedAt = new Date().toISOString();
console.error(`[produits/search] job=${job.jobId} échec :`, message.slice(0, 300));
});
return job;
}
/** État courant de la recherche pour ce terme (pour le polling client). */
export function etatRecherche(term: string): RechercheEtat {
const cleaned = term.trim();
const key = cacheKey(cleaned);
purge();
const job = jobs.get(key);
if (job) return job;
const hit = cache.get(key);
if (hit) return jobTermine(cleaned, hit.result);
return { status: "idle", terme: cleaned };
}
async function executerRecherche(
term: string,
onEtape: (etape: string) => void = () => {},
): Promise<ProduitRechercheResultat> {
const started = Date.now();
// La recherche catalogue local tourne en parallèle : elle sert à la fois de
// repli si Qlik échoue, et de complément (produits Nancy sans code centrale,
// donc invisibles côté réseau).
const localPromise = pgSearchProduits(term).catch((e) => {
console.error("[produits/search] recherche locale échouée:", (e as Error).message);
return [] as PgProduitSearchRow[];
});
let qlik;
try {
onEtape("Recherche des articles dans Qlik…");
qlik = await searchQlikArticles(term, QLIK_SEARCH_MAX_RESULTS);
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
console.error("[produits/search] Qlik indisponible, repli catalogue local:", message);
const locaux = await localPromise;
return {
rows: locaux.map(rowDepuisCatalogueSeul),
source: "db",
qlikError: messageQlikLisible(message),
tronque: locaux.length >= 50,
champUtilise: null,
locauxHorsReseau: [],
dureeMs: Date.now() - started,
};
}
const codes = qlik.matches.map((m) => m.codeCentrale);
if (codes.length === 0) {
const locaux = await localPromise;
return {
rows: [],
source: "qlik",
// `avertissement` n'est renseigné que si Qlik a répondu sans appliquer
// le filtre : l'utilisateur doit savoir que ce n'est pas « pas de
// produit » mais « recherche non appliquée ».
qlikError: qlik.avertissement,
tronque: false,
champUtilise: qlik.champUtilise,
locauxHorsReseau: locaux,
dureeMs: Date.now() - started,
};
}
// Les mesures réseau sortent DÉJÀ du cube de recherche (même session Qlik).
// Les extraire dans une seconde session concurrente faisait abandonner le
// moteur (`code 15 — Request aborted`) : c'est ce qui faisait « planter » la
// recherche alors que la sync de la Grille, elle seule sur le serveur,
// fonctionnait.
//
// Reste à récupérer le détail mensuel — indisponible dans ce cube. On le lit
// dans le cache alimenté par les syncs fournisseur ; s'il manque, la fiche
// produit propose son bouton « Actualiser depuis Qlik ».
onEtape("Rapprochement avec notre catalogue…");
const [cacheReseau, catalogue, locaux] = await Promise.all([
getNetworkMetricsByCodeCentrale(codes).catch((e) => {
console.error("[produits/search] lecture du cache réseau échouée:", (e as Error).message);
return new Map<string, NetworkMetricCached>();
}),
// Deux clés candidates : la dimension « Article Code » et le champ
// `article_no_centrale`. Sur l'app FF elles n'ont pas le même format et
// une seule joint avec `articles.artcentrale`.
pgGetProduitsByCodeCentrale([...codes, ...qlik.matches.map((m) => m.codeCentraleAlt).filter(Boolean)]),
localPromise,
]);
// Le cube de recherche utilise encore les master measures annuelles pour
// classer rapidement les résultats. Il ne doit jamais écraser une extraction
// 12 mois déjà vérifiée : dans ce cas on republie les valeurs du cache avec
// leur mensuel. Sans cache complet, les valeurs de découverte restent
// temporaires jusqu'au bouton « Actualiser depuis Qlik ».
try {
const aPersister: NetworkMetric[] = qlik.matches.map((m) => {
const cache = cacheReseau.get(m.codeCentrale);
const fiable = cacheReseauComplet(cache, qlik.periode);
return {
codeCentrale: m.codeCentrale,
caReseau: fiable ? cache.caReseau : m.caReseau,
qteReseau: fiable ? cache.qteReseau : m.qteReseau,
nbMagasinsReseau: fiable ? cache.nbMagasinsReseau : m.nbMagasinsReseau,
caParMagasinReseau: fiable ? cache.caParMagasinReseau : m.caParMagasinReseau,
margePctReseau: fiable ? cache.margePctReseau : m.margePctReseau,
periode: fiable ? cache.periode ?? undefined : qlik.periode ?? undefined,
qteByMonth: fiable ? cache.qteByMonth ?? undefined : undefined,
metricsByMonth: fiable ? cache.metricsByMonth ?? undefined : undefined,
libelleReseau: m.libelle || undefined,
fournisseurReseau: m.fournisseur || undefined,
};
});
const n = await upsertNetworkMetrics(aPersister);
console.log(`[produits/search] ${n} ligne(s) réseau mises en cache`);
} catch (e) {
console.error("[produits/search] mise en cache des mesures échouée:", (e as Error).message);
}
const rows: ProduitRechercheRow[] = qlik.matches.map((m) => {
const local = catalogue.get(m.codeCentrale) ?? (m.codeCentraleAlt ? catalogue.get(m.codeCentraleAlt) : undefined);
const cache = cacheReseau.get(m.codeCentrale);
const fiable = cacheReseauComplet(cache, qlik.periode);
const qteReseau = fiable ? cache.qteReseau : m.qteReseau;
const caReseau = fiable ? cache.caReseau : m.caReseau;
const nbMag = fiable ? cache.nbMagasinsReseau : m.nbMagasinsReseau;
const caParMagasin = fiable ? cache.caParMagasinReseau : m.caParMagasinReseau;
const margePct = fiable ? cache.margePctReseau : m.margePctReseau;
return {
codeCentrale: m.codeCentrale,
libelle: local?.libelle1 || m.libelle || m.codeCentrale,
libelleReseau: m.libelle,
fournisseur: local?.fournisseur || m.fournisseur || "",
codein: local?.codein ?? null,
nomenclature: local?.nomenclature ?? "",
stockLocal: local ? Number(local.stock_total) || 0 : null,
qteReseau,
caReseau,
nbMagasinsReseau: nbMag,
tauxPresence: nbMag / NB_MAGASINS_RESEAU,
qteParMagasinReseau: nbMag > 0 ? qteReseau / nbMag : 0,
caParMagasinReseau: caParMagasin,
prixMoyenReseau: qteReseau > 0 ? caReseau / qteReseau : null,
margePctReseau: normalizeMargePct(margePct),
// Le mensuel ne vient que du cache : le cube de recherche est agrégé.
qteByMonth: fiable ? cache.qteByMonth : null,
nbMagByMonth: fiable ? nbMagParMois(cache.metricsByMonth) : null,
periode: qlik.periode,
};
});
// Les plus vendus du réseau d'abord : c'est l'information utile pour
// arbitrer, un produit que le réseau ne vend pas n'intéresse personne.
rows.sort((a, b) => b.qteReseau - a.qteReseau || a.libelle.localeCompare(b.libelle, "fr"));
// Les produits Nancy déjà couverts par un résultat réseau ne doivent pas
// réapparaître dans la liste secondaire. On compare sur les deux clés
// candidates, la jointure pouvant se faire par l'une ou par l'autre.
const codesTrouves = new Set<string>(codes);
for (const m of qlik.matches) if (m.codeCentraleAlt) codesTrouves.add(m.codeCentraleAlt);
const locauxHorsReseau = locaux.filter((l) => !l.code_centrale || !codesTrouves.has(l.code_centrale));
// Diagnostic de la jointure : 0 rapprochement sur 40 codes signale un format
// de clé différent entre Qlik et `articles.artcentrale`.
if (catalogue.size === 0 && codes.length > 0) {
console.warn(
`[produits/search] aucun des ${codes.length} codes Qlik ne joint avec articles.artcentrale — ` +
`échantillon Article Code=${JSON.stringify(codes.slice(0, 3))} ` +
`article_no_centrale=${JSON.stringify(qlik.matches.slice(0, 3).map((m) => m.codeCentraleAlt))}`,
);
}
return {
rows,
source: "qlik",
qlikError: qlik.avertissement,
tronque: qlik.tronque,
champUtilise: qlik.champUtilise,
locauxHorsReseau,
dureeMs: Date.now() - started,
};
}
/** Ligne de repli : catalogue Nancy seul, sans données réseau fiables. */
function rowDepuisCatalogueSeul(r: PgProduitSearchRow): ProduitRechercheRow {
const qteReseau = Number(r.qte_reseau) || 0;
const nbMag = Number(r.nb_magasins_reseau) || 0;
return {
codeCentrale: r.code_centrale,
libelle: r.libelle1 || r.codein,
libelleReseau: "",
fournisseur: r.fournisseur,
codein: r.codein,
nomenclature: r.nomenclature,
stockLocal: Number(r.stock_total) || 0,
qteReseau,
caReseau: 0,
nbMagasinsReseau: nbMag,
tauxPresence: nbMag / NB_MAGASINS_RESEAU,
qteParMagasinReseau: nbMag > 0 ? qteReseau / nbMag : 0,
caParMagasinReseau: 0,
prixMoyenReseau: null,
margePctReseau: null,
qteByMonth: null,
nbMagByMonth: null,
periode: null,
};
}
/**
* Traduit les pannes Qlik récurrentes en message actionnable. Les autres
* erreurs sont renvoyées telles quelles (tronquées) pour rester diagnosticables.
*/
function messageQlikLisible(message: string): string {
const lower = message.toLowerCase();
if (lower.includes("out of memory") || lower.includes("not enough memory") || lower.includes('"code":6') || lower.includes('"code":3002')) {
return "Serveur Qlik saturé : mémoire insuffisante pour charger l'application. Réessayez plus tard.";
}
if (/"code"\s*:\s*15\b/.test(message) || lower.includes("request aborted")) {
return "Qlik a interrompu la requête (recherche trop large pour le moteur). Ajoutez un mot plus précis, puis relancez.";
}
if (lower.includes("identifiants qlik manquants") || lower.includes("qlik_user")) {
return "Identifiants Qlik non configurés (Paramètres → Qlik).";
}
if (lower.includes("timeout") || lower.includes("econnrefused") || lower.includes("enotfound")) {
return "Serveur Qlik injoignable depuis CollectFlow (réseau ou VPN).";
}
return message.slice(0, 300);
}
+147
View File
@@ -0,0 +1,147 @@
/**
* CollectFlow — Comparatif local vs réseau pour un produit.
*
* Principe : rapporter les deux périmètres à un **magasin moyen**, sinon on
* compare 2 magasins Nancy à ~270 magasins réseau et le chiffre ne veut rien dire.
*
* ⚠️ La comparaison de référence porte sur les **quantités**. Le CA local vient
* de `mvtart.mntmvtttc` (TTC) alors que la base de la mesure Qlik « CA N » n'est
* pas documentée : les montants sont donc affichés côte à côte à titre indicatif,
* sans indice calculé.
*/
import { NB_MAGASINS_RESEAU } from "@/features/grid/lib/network-trend";
import { ffMonthToQlik } from "@/features/grid/lib/months";
import type { ProduitFiche } from "@/features/produits/types";
export interface ComparatifMois {
/** Clé de mois FF "YYYYMM". */
mois: string;
/** Quantité vendue par magasin, chez nous. */
local: number;
/** Quantité vendue par magasin, au réseau. */
reseau: number;
}
export interface ComparatifReseau {
/** false si le produit n'a pas encore de données réseau en cache. */
hasReseau: boolean;
/** Nombre de nos magasins ayant vendu le produit (min. 1 pour éviter /0). */
nbMagasinsLocaux: number;
nbMagasinsReseau: number;
/** Taux de présence réseau (0..1), ou null sans données. */
tauxPresence: number | null;
/** Quantité 12 mois, par magasin. */
qteLocaleParMagasin: number;
qteReseauParMagasin: number;
/**
* Indice de performance en % : 100 = on vend autant qu'un magasin moyen du
* réseau, 150 = 50 % de mieux. `null` si le réseau ne vend rien.
*/
indiceQte: number | null;
/** CA 12 mois par magasin — indicatif (bases de calcul non alignées). */
caLocalParMagasin: number;
caReseauParMagasin: number | null;
/** Taux de marge en %, local et réseau (indicatif). */
tauxMargeLocal: number;
tauxMargeReseau: number | null;
/** Série mensuelle par magasin, alignée sur les 12 mois de la fiche. */
monthly: ComparatifMois[];
/** true si au moins un mois réseau est disponible. */
hasMonthly: boolean;
}
/**
* Le taux de marge Qlik est un ratio brut (0.32 = 32 %) mais certaines
* configurations renvoient déjà des points de pourcentage. Même normalisation
* que la colonne « Marge % / Réseau » de la Grille.
*/
export function normalizeMargePct(raw: number | null | undefined): number | null {
if (raw == null) return null;
const v = Number(raw);
if (!Number.isFinite(v) || v === 0) return null;
return Math.abs(v) <= 1 ? v * 100 : v;
}
/** Calcule le comparatif local vs réseau à partir d'une fiche produit. */
export function computeComparatif(fiche: ProduitFiche): ComparatifReseau {
const { reseau, totaux, sitesActifs, months, mensuelTotal } = fiche;
// Nos magasins actifs sur la période. Si aucun n'a vendu, on garde 1 pour
// que la division reste définie — l'indice vaudra alors 0, ce qui est le
// message voulu : « le réseau vend, nous non ».
const nbMagasinsLocaux = Math.max(sitesActifs.length, 1);
const qteLocaleParMagasin = totaux.qte / nbMagasinsLocaux;
const caLocalParMagasin = totaux.ca / nbMagasinsLocaux;
if (!reseau || !reseau.nbMagasinsReseau) {
return {
hasReseau: false,
nbMagasinsLocaux,
nbMagasinsReseau: 0,
tauxPresence: null,
qteLocaleParMagasin,
qteReseauParMagasin: 0,
indiceQte: null,
caLocalParMagasin,
caReseauParMagasin: null,
tauxMargeLocal: totaux.tauxMarge,
tauxMargeReseau: null,
monthly: [],
hasMonthly: false,
};
}
const nbMagasinsReseau = reseau.nbMagasinsReseau;
const qteReseauParMagasin = reseau.qteReseau / nbMagasinsReseau;
const caReseauParMagasin = reseau.caReseau / nbMagasinsReseau;
// Série mensuelle : quantité par magasin de part et d'autre.
// Le nb de magasins réseau du mois est utilisé quand il est disponible
// (`metricsByMonth`), sinon on retombe sur le total de la période.
const monthly: ComparatifMois[] = [];
let hasMonthly = false;
for (const mois of months) {
const qlikKey = ffMonthToQlik(mois);
const mensuel = reseau.metricsByMonth?.[qlikKey];
const qteReseauMois = mensuel?.qte ?? reseau.qteByMonth?.[qlikKey];
const nbMagMois = mensuel?.nbMag && mensuel.nbMag > 0 ? mensuel.nbMag : nbMagasinsReseau;
if (qteReseauMois != null) hasMonthly = true;
monthly.push({
mois,
local: (mensuelTotal[mois]?.qte ?? 0) / nbMagasinsLocaux,
reseau: qteReseauMois != null ? qteReseauMois / nbMagMois : 0,
});
}
return {
hasReseau: true,
nbMagasinsLocaux,
nbMagasinsReseau,
tauxPresence: nbMagasinsReseau / NB_MAGASINS_RESEAU,
qteLocaleParMagasin,
qteReseauParMagasin,
indiceQte: qteReseauParMagasin > 0 ? (qteLocaleParMagasin / qteReseauParMagasin) * 100 : null,
caLocalParMagasin,
caReseauParMagasin,
tauxMargeLocal: totaux.tauxMarge,
tauxMargeReseau: normalizeMargePct(reseau.margePctReseau),
monthly,
hasMonthly,
};
}
/** Lecture métier de l'indice de performance. */
export function indiceVerdict(indice: number | null): { label: string; color: string } {
if (indice == null) return { label: "Non comparable", color: "var(--text-muted)" };
if (indice >= 150) return { label: "Forte surperformance", color: "#22c55e" };
if (indice >= 110) return { label: "Surperformance", color: "#22c55e" };
if (indice >= 90) return { label: "Dans la moyenne réseau", color: "var(--text-secondary)" };
if (indice >= 50) return { label: "Sous-performance", color: "#f59e0b" };
return { label: "Forte sous-performance", color: "#ef4444" };
}
+128
View File
@@ -0,0 +1,128 @@
/**
* CollectFlow — Types de la fiche produit (page /produits).
*/
import type {
PgProduitDetailRow,
PgProduitFournisseurRow,
PgProduitSearchRow,
PgProduitStockRow,
PgGammeHistoryRow,
} from "@/lib/pg-ff-client";
import type { NetworkMetricCached } from "@/lib/qlik-network-cache";
/**
* Une ligne de résultat de recherche produit.
*
* La recherche part de **Qlik** : chaque ligne est d'abord un produit du réseau
* (identifié par son code centrale), éventuellement rapproché du catalogue FF
* Nancy. `codein === null` = le réseau travaille ce produit, nous ne le
* référençons pas.
*/
export interface ProduitRechercheRow {
/** Dimension Qlik « Article Code » = `articles.artcentrale`. */
codeCentrale: string;
/** Libellé affiché : catalogue Nancy si référencé, sinon libellé réseau. */
libelle: string;
/** Libellé tel que Qlik le renvoie (vide si l'app n'expose pas de champ libellé). */
libelleReseau: string;
/** Fournisseur : catalogue Nancy prioritaire (nom réel), sinon champ Qlik. */
fournisseur: string;
/** `null` si le produit n'est pas au catalogue Nancy. */
codein: string | null;
nomenclature: string;
/** Stock Nancy tous sites, `null` si produit non référencé. */
stockLocal: number | null;
// ─── Réseau Qlik, sur 12 mois glissants ─────────────────────────────────
qteReseau: number;
caReseau: number;
nbMagasinsReseau: number;
/** Part des magasins du réseau travaillant le produit (0..1). */
tauxPresence: number;
/** Quantité vendue par magasin qui travaille le produit. */
qteParMagasinReseau: number;
caParMagasinReseau: number;
/** CA réseau / quantité réseau — panier moyen unitaire, `null` si pas de vente. */
prixMoyenReseau: number | null;
/** Taux de marge réseau en points de % (déjà normalisé), `null` si absent. */
margePctReseau: number | null;
/** Quantité réseau par mois `"YYYY-MM"` — alimente la sparkline de tendance. */
qteByMonth: Record<string, number> | null;
/**
* Magasins vendeurs par mois `"YYYY-MM"`. La tendance porte sur la
* qté/magasin : sans cette série, elle se rabat sur les quantités brutes.
*/
nbMagByMonth: Record<string, number> | null;
periode: string | null;
}
export interface ProduitRechercheResultat {
rows: ProduitRechercheRow[];
/** `qlik` = recherche nominale ; `db` = repli catalogue local (Qlik indisponible). */
source: "qlik" | "db";
/** Message d'erreur Qlik quand on a dû se rabattre sur la base locale. */
qlikError: string | null;
/** true si Qlik a renvoyé plus d'articles que la limite affichée. */
tronque: boolean;
/** Champ Qlik réellement interrogé (diagnostic). */
champUtilise: string | null;
/**
* Produits du catalogue Nancy qui matchent le terme mais qu'aucun article
* Qlik ne couvre (pas de code centrale, ou non vendus par le réseau).
*/
locauxHorsReseau: PgProduitSearchRow[];
dureeMs: number;
}
/** Les métriques d'un mois, pour un périmètre donné (total ou un site). */
export interface ProduitMois {
/** Clé de mois FF, format "YYYYMM". */
mois: string;
qte: number;
/** Issu de mntmvtttc — TTC malgré le nom historique `ca_ht`. */
ca: number;
marge: number;
stockFinMois: number;
qteRecue: number;
}
export interface ProduitTotaux {
qte: number;
ca: number;
marge: number;
/** Taux de marge en % (marge / CA × 100). */
tauxMarge: number;
}
export interface ProduitFiche {
/**
* Identité catalogue FF Nancy — `null` pour un produit que le réseau
* travaille mais que Nancy ne référence pas (fiche 100 % réseau).
*/
detail: PgProduitDetailRow | null;
/** Code centrale = clé Qlik. Toujours renseigné pour une fiche ouverte depuis la recherche. */
codeCentrale: string;
/** Libellé de l'article côté Qlik (peut être vide selon l'app Qlik). */
libelleReseau: string;
/** Fournisseur de l'article côté Qlik (peut être vide selon l'app Qlik). */
fournisseurReseau: string;
fournisseurs: PgProduitFournisseurRow[];
stock: PgProduitStockRow[];
commandesEnCours: number;
gammes: PgGammeHistoryRow[];
/** Les 12 clés de mois "YYYYMM", du plus ancien au plus récent. */
months: string[];
/** Cumul tous sites, par mois. */
mensuelTotal: Record<string, ProduitMois>;
/** site → mois → métriques. */
mensuelParSite: Record<string, Record<string, ProduitMois>>;
totaux: ProduitTotaux;
totauxParSite: Record<string, ProduitTotaux>;
/** Sites ayant vendu au moins une unité sur les 12 mois. */
sitesActifs: string[];
/** Métriques réseau Qlik, `null` si le produit n'est pas encore en cache. */
reseau: NetworkMetricCached | null;
/** Fenêtre analysée, pour l'affichage. */
periode: { dateDebut: string; dateFin: string };
}
+223
View File
@@ -0,0 +1,223 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
/** État serveur renvoyé par GET/POST /api/qlik/sync. */
export interface QlikSyncJobState {
jobId: string | null;
fournisseur: string;
codeCentrale?: string;
status: "idle" | "running" | "success" | "error";
requested: number;
fetched: number;
upserted: number;
periode?: string;
dateDebut?: string;
dateFin?: string;
startedAt?: string;
finishedAt?: string;
error?: string;
message?: string;
}
export type QlikSyncStatus = QlikSyncJobState["status"];
/**
* Cible de la synchro : un fournisseur entier (réservé aux admins) ou un seul
* code centrale (ouvert à tout utilisateur connecté).
*/
export type QlikSyncTarget =
| { mode: "fournisseur"; fournisseur: string | undefined }
| { mode: "produit"; codeCentrale: string | undefined };
interface UseQlikSyncJobOptions {
target: QlikSyncTarget;
/** Appelé une fois quand un job se termine avec succès. */
onSuccess?: () => void;
/** Intervalle de polling en ms (défaut 3 s — une extraction peut durer plusieurs minutes). */
pollIntervalMs?: number;
}
/** Construit la query string de la route selon la cible. */
function targetQuery(target: QlikSyncTarget): string | null {
if (target.mode === "fournisseur") {
return target.fournisseur ? `fournisseur=${encodeURIComponent(target.fournisseur)}` : null;
}
return target.codeCentrale ? `codeCentrale=${encodeURIComponent(target.codeCentrale)}` : null;
}
/**
* Pilote un job de synchronisation Qlik : démarrage (POST), suivi (polling GET),
* reprise d'un job déjà en cours au montage, et nettoyage des timers.
*
* Extrait de `SyncQlikButton` pour être partagé avec la fiche produit. Le
* protocole est inchangé : le POST démarre un job serveur en arrière-plan et
* revient immédiatement, le client poll ensuite GET jusqu'à l'état terminal.
*/
export function useQlikSyncJob({ target, onSuccess, pollIntervalMs = 3000 }: UseQlikSyncJobOptions) {
const [status, setStatus] = useState<QlikSyncStatus>("idle");
const [message, setMessage] = useState<string>("");
const query = targetQuery(target);
const pollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
/** true tant qu'on doit suivre le job courant (false après succès/erreur traité). */
const trackingRef = useRef<boolean>(false);
// Gardé dans une ref : le callback peut changer d'identité à chaque rendu
// sans qu'on veuille relancer le polling pour autant.
const onSuccessRef = useRef(onSuccess);
useEffect(() => { onSuccessRef.current = onSuccess; }, [onSuccess]);
const clearPollTimer = useCallback(() => {
if (pollTimerRef.current) {
clearTimeout(pollTimerRef.current);
pollTimerRef.current = null;
}
}, []);
/** Met à jour le libellé visible selon l'état courant. */
const applyJobToUi = useCallback((j: QlikSyncJobState | null) => {
if (!j) return;
if (j.status === "running") {
setStatus("running");
const requestedTxt = j.requested > 0 ? ` (${j.requested} code${j.requested > 1 ? "s" : ""})` : "";
setMessage(`Extraction Qlik en cours${requestedTxt}…`);
} else if (j.status === "success") {
setStatus("success");
if (j.message) setMessage(j.message);
else setMessage(`${j.upserted} produits réseau synchronisés`);
} else if (j.status === "error") {
setStatus("error");
setMessage(j.error || "Erreur sync Qlik");
} else {
setStatus("idle");
}
}, []);
/**
* Un tour de polling. Renvoie true s'il faut continuer.
*/
const pollOnce = useCallback(async (): Promise<boolean> => {
if (!query || !trackingRef.current) return false;
try {
const res = await fetch(`/api/qlik/sync?${query}`, { method: "GET", cache: "no-store" });
if (!res.ok) {
// Erreur HTTP transitoire : on retente au tour suivant.
return true;
}
const data: QlikSyncJobState = await res.json();
applyJobToUi(data);
if (data.status === "running") return true;
if (data.status === "success" || data.status === "error") {
trackingRef.current = false;
if (data.status === "success") onSuccessRef.current?.();
// Petit délai pour laisser l'utilisateur lire le message vert/rouge.
setTimeout(() => {
setStatus((cur) => (cur === "success" || cur === "error" ? "idle" : cur));
}, 4000);
return false;
}
trackingRef.current = false;
return false;
} catch {
// Erreur réseau transitoire : on laisse le polling réessayer.
return true;
}
}, [applyJobToUi, query]);
/** Boucle de polling auto-réarmée tant que pollOnce le demande. */
const startPolling = useCallback(() => {
clearPollTimer();
const tick = async () => {
const keepGoing = await pollOnce();
if (keepGoing && trackingRef.current) {
pollTimerRef.current = setTimeout(tick, pollIntervalMs);
} else {
pollTimerRef.current = null;
}
};
// Premier appel quasi immédiat.
pollTimerRef.current = setTimeout(tick, 200);
}, [clearPollTimer, pollOnce, pollIntervalMs]);
/**
* Au montage (et quand la cible change) : reprend l'état d'un éventuel job
* déjà en cours, par exemple après un reload pendant l'extraction.
*/
useEffect(() => {
if (!query) return;
let cancelled = false;
trackingRef.current = false;
clearPollTimer();
(async () => {
try {
const res = await fetch(`/api/qlik/sync?${query}`, { method: "GET", cache: "no-store" });
if (cancelled || !res.ok) return;
const data: QlikSyncJobState = await res.json();
if (cancelled) return;
if (data.status === "running" && data.jobId) {
trackingRef.current = true;
applyJobToUi(data);
startPolling();
} else if (data.status === "success" || data.status === "error") {
// Job terminé avant le montage : on l'affiche brièvement.
applyJobToUi(data);
setTimeout(() => {
setStatus((cur) => (cur === "success" || cur === "error" ? "idle" : cur));
}, 4000);
}
} catch {
/* ignore : on n'empêche pas l'UI de s'afficher */
}
})();
return () => {
cancelled = true;
trackingRef.current = false;
clearPollTimer();
};
// On veut interroger une seule fois au montage (et quand la cible change).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [query, clearPollTimer]);
/** Cleanup : annule un timer de polling éventuel au démontage. */
useEffect(() => clearPollTimer, [clearPollTimer]);
/** Démarre l'extraction. */
const start = useCallback(async () => {
if (!query) {
setStatus("error");
setMessage(target.mode === "fournisseur" ? "Aucun fournisseur sélectionné" : "Aucun code centrale");
setTimeout(() => setStatus("idle"), 6000);
return;
}
// Empêche un double-clic pendant qu'un job tourne déjà.
if (status === "running") return;
setStatus("running");
setMessage("Démarrage de l'extraction Qlik…");
try {
const res = await fetch(`/api/qlik/sync?${query}`, { method: "POST" });
const data: QlikSyncJobState & { success?: boolean } = await res.json();
if (!res.ok || data.success === false) {
throw new Error(data.error || `HTTP ${res.status}`);
}
if (data.status === "running" && data.jobId) {
trackingRef.current = true;
applyJobToUi(data);
startPolling();
return;
}
// Statut terminal direct (cas rare : 0 code → success immédiat).
applyJobToUi(data);
if (data.status === "success") onSuccessRef.current?.();
setTimeout(() => setStatus("idle"), 4000);
} catch (e) {
setStatus("error");
setMessage(e instanceof Error ? e.message : String(e));
setTimeout(() => setStatus("idle"), 6000);
}
}, [applyJobToUi, query, startPolling, status, target.mode]);
return { status, message, start };
}
+128 -1
View File
@@ -14,6 +14,27 @@ export interface DbConfig {
aiProvider?: "openrouter" | "google";
googleAiKey?: string;
googleAiModel?: string;
/** Qlik Sense — données réseau */
qlikHost?: string;
qlikUser?: string;
qlikPassword?: string;
/**
* API REST FF Nancy (ex. https://api.ffnancy.fr). Sert au panneau de statut et
* au rattrapage des ventes par magasin dans la Grille. Configurable ici pour
* ne pas dépendre d'une variable d'environnement qu'on ne peut pas changer
* sans redéployer.
*/
ffApiBaseUrl?: string;
}
/** Lit la config existante (ou {} si absente). */
async function readConfig(): Promise<Partial<DbConfig>> {
try {
const data = await fs.readFile(CONFIG_FILE, "utf-8");
return JSON.parse(data);
} catch {
return {};
}
}
export async function testDatabaseConnection(url: string) {
@@ -46,7 +67,9 @@ export async function saveDatabaseSettings(
googleAiModel?: string,
) {
try {
const config: DbConfig = { url, openRouterKey, openRouterModel, aiProvider, googleAiKey, googleAiModel };
// Merge avec l'existant pour préserver les autres réglages (ex: Qlik)
const existing = await readConfig();
const config: DbConfig = { ...existing, url, openRouterKey, openRouterModel, aiProvider, googleAiKey, googleAiModel };
// S'assurer que le dossier data existe
await fs.mkdir(DATA_DIR, { recursive: true });
@@ -67,6 +90,110 @@ export async function saveDatabaseSettings(
}
}
export async function saveQlikSettings(qlikHost: string, qlikUser: string, qlikPassword: string) {
try {
const existing = await readConfig();
const config = { ...existing, qlikHost, qlikUser, qlikPassword } as DbConfig;
await fs.mkdir(DATA_DIR, { recursive: true });
await fs.writeFile(CONFIG_FILE, JSON.stringify(config, null, 2));
console.log("[Settings] Qlik configuration saved.");
return { success: true };
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : String(error);
console.error("[Settings] Failed to save Qlik config:", msg);
return { success: false, error: msg };
}
}
export async function testQlikConnection(qlikHost: string, qlikUser: string, qlikPassword: string) {
try {
const { getQlikConfig, qlikNtlmSession } = await import("@/lib/qlik-client");
const cfg = { ...getQlikConfig(), user: qlikUser, password: qlikPassword };
if (qlikHost) cfg.host = qlikHost;
await qlikNtlmSession(cfg);
return { success: true };
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : String(error);
return { success: false, error: msg };
}
}
/** Enregistre l'URL de l'API FF Nancy. Chaîne vide = revenir au défaut. */
export async function saveFfApiSettings(ffApiBaseUrl: string) {
try {
const cleaned = ffApiBaseUrl.trim().replace(/\/+$/, "");
if (cleaned && !/^https?:\/\//i.test(cleaned)) {
return { success: false, error: "L'URL doit commencer par http:// ou https://" };
}
const existing = await readConfig();
const config = { ...existing, ffApiBaseUrl: cleaned || undefined } as DbConfig;
await fs.mkdir(DATA_DIR, { recursive: true });
await fs.writeFile(CONFIG_FILE, JSON.stringify(config, null, 2));
// Sans cela, l'ancienne URL resterait servie jusqu'à 30 s après la sauvegarde.
const { resetFfApiBaseCache } = await import("@/lib/api-ff-client");
resetFfApiBaseCache();
console.log(`[Settings] FF API base URL saved: ${cleaned || "(défaut)"}`);
return { success: true };
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : String(error);
console.error("[Settings] Failed to save FF API config:", msg);
return { success: false, error: msg };
}
}
/**
* Teste l'API FF Nancy et renvoie un diagnostic **exploitable**.
*
* L'ancien panneau se contentait d'un « HTTP 503 » opaque : impossible de savoir
* si l'hôte était injoignable, l'URL erronée ou le service en panne. On distingue
* donc ici l'échec réseau (DNS, refus de connexion, délai dépassé) du code HTTP.
*/
export async function testFfApiConnection(ffApiBaseUrl?: string) {
const base = (ffApiBaseUrl?.trim() || (await readConfig()).ffApiBaseUrl || process.env.FF_API_BASE_URL || "https://api.ffnancy.fr").replace(/\/+$/, "");
const url = `${base}/api/sync/status`;
const started = Date.now();
try {
const res = await fetch(url, {
cache: "no-store",
signal: AbortSignal.timeout(8000),
});
const ms = Date.now() - started;
if (!res.ok) {
return { success: false, url, error: `Le serveur a répondu HTTP ${res.status} (${res.statusText || "sans message"}) en ${ms} ms.` };
}
const body = await res.json().catch(() => null);
if (!body) {
return { success: false, url, error: `Réponse HTTP 200 mais corps illisible (JSON attendu).` };
}
// La réponse brute de l'API n'a pas la forme attendue par l'interface :
// sans cette normalisation, le panneau s'affiche vide malgré un HTTP 200.
const { normalizeSyncStatus } = await import("@/lib/api-ff-client");
const status = normalizeSyncStatus(body);
if (!status) {
return { success: false, url, error: `Réponse HTTP 200 mais format inattendu (ni « sync » ni « tables »).` };
}
return { success: true, url, ms, status };
} catch (error: unknown) {
const ms = Date.now() - started;
const raw = error instanceof Error ? error.message : String(error);
const name = error instanceof Error ? error.name : "";
// fetch masque la cause réelle derrière « fetch failed » : on la déplie.
const cause = (error as { cause?: { code?: string; message?: string } })?.cause;
let hint = raw;
if (name === "TimeoutError" || name === "AbortError") {
hint = `Aucune réponse en ${ms} ms — serveur injoignable ou trop lent.`;
} else if (cause?.code === "ENOTFOUND") {
hint = `Nom d'hôte introuvable (DNS) — vérifiez l'URL.`;
} else if (cause?.code === "ECONNREFUSED") {
hint = `Connexion refusée — le service n'écoute pas sur cette adresse.`;
} else if (cause?.code) {
hint = `${cause.code}${cause.message ? ` — ${cause.message}` : ""}`;
}
console.error(`[Settings] FF API test KO (${url}):`, raw);
return { success: false, url, error: hint };
}
}
export async function getSavedDatabaseConfig(): Promise<DbConfig | null> {
try {
if (!(await fs.stat(CONFIG_FILE).catch(() => null))) {
@@ -0,0 +1,148 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { Download, Loader2, RefreshCw, FileText, AlertCircle } from "lucide-react";
interface LogEntry {
id: string;
octets: number;
modifieLe: string;
enCours: boolean;
}
function formatTaille(octets: number): string {
if (octets < 1024) return `${octets} o`;
if (octets < 1024 * 1024) return `${Math.round(octets / 1024)} Ko`;
return `${(octets / (1024 * 1024)).toFixed(1)} Mo`;
}
function formatDate(iso: string): string {
const d = new Date(iso);
if (isNaN(d.getTime())) return iso;
return d.toLocaleString("fr-FR", {
day: "2-digit", month: "2-digit", year: "numeric",
hour: "2-digit", minute: "2-digit", second: "2-digit",
});
}
/** Appelle l'API. Volontairement sans état React : réutilisable tel quel. */
async function recupererLogs(): Promise<LogEntry[]> {
const res = await fetch("/api/logs", { cache: "no-store" });
if (!res.ok) {
throw new Error(res.status === 403 ? "Réservé aux administrateurs" : `HTTP ${res.status}`);
}
const data = await res.json();
return Array.isArray(data.logs) ? data.logs : [];
}
/**
* Journaux serveur des extractions Qlik — consultation et téléchargement.
*
* Une extraction produit plusieurs milliers de lignes de diagnostic. Un
* terminal les tronque et n'en laisse que la fin, alors que l'information
* décisive (carte du modèle Qlik, champs retenus, choix des périodes) se trouve
* en tête. Le journal complet est donc capturé côté serveur et récupérable ici
* en un fichier.
*/
export function ServerLogs() {
const [logs, setLogs] = useState<LogEntry[]>([]);
// « chargement » dès le départ : le premier appel part au montage, et aucun
// setState ne doit être fait de façon synchrone dans un effet.
const [etat, setEtat] = useState<"idle" | "chargement" | "erreur">("chargement");
const [erreur, setErreur] = useState<string>("");
/** Applique un résultat (succès ou échec) à l'état local. */
const appliquer = useCallback((entrees: LogEntry[] | null, message: string) => {
if (entrees) {
setLogs(entrees);
setErreur("");
setEtat("idle");
} else {
setErreur(message);
setEtat("erreur");
}
}, []);
// Chargement initial. L'état n'est touché que depuis les callbacks de la
// promesse, jamais dans le corps de l'effet.
useEffect(() => {
recupererLogs()
.then((entrees) => appliquer(entrees, ""))
.catch((e: unknown) => appliquer(null, e instanceof Error ? e.message : String(e)));
}, [appliquer]);
/** Rechargement explicite (bouton) : là, l'état « chargement » est immédiat. */
const recharger = useCallback(() => {
setEtat("chargement");
recupererLogs()
.then((entrees) => appliquer(entrees, ""))
.catch((e: unknown) => appliquer(null, e instanceof Error ? e.message : String(e)));
}, [appliquer]);
return (
<div className="space-y-3">
<div className="flex items-center justify-between gap-3">
<p className="text-[12px] text-[var(--text-secondary)]">
Journal complet de chaque extraction Qlik (le plus récent en premier).
Conservés le temps de la vie du serveur, 20 au maximum.
</p>
<button
onClick={recharger}
disabled={etat === "chargement"}
className="btn-action btn-action-secondary flex items-center gap-1.5 shrink-0 disabled:opacity-60"
>
{etat === "chargement"
? <Loader2 className="w-3.5 h-3.5 animate-spin" />
: <RefreshCw className="w-3.5 h-3.5" />}
Actualiser
</button>
</div>
{etat === "erreur" && (
<div className="flex items-center gap-2 text-[12px] text-[var(--accent-error)]">
<AlertCircle className="w-4 h-4 shrink-0" />
<span>{erreur}</span>
</div>
)}
{etat !== "erreur" && logs.length === 0 && (
<p className="text-[12px] text-[var(--text-muted)]">
Aucun journal pour l&apos;instant — lancez une synchronisation Qlik depuis la Grille.
</p>
)}
{logs.length > 0 && (
<ul className="divide-y divide-[var(--border-subtle)] rounded-lg border border-[var(--border-subtle)]">
{logs.map((log) => (
<li key={log.id} className="flex items-center gap-3 px-3 py-2">
<FileText className="w-4 h-4 shrink-0 text-[var(--text-muted)]" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-[12px] font-medium text-[var(--text-primary)] truncate">
{log.id}
</span>
{log.enCours && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-[var(--brand-solid)] text-white shrink-0">
en cours
</span>
)}
</div>
<span className="text-[11px] text-[var(--text-muted)]">
{formatDate(log.modifieLe)} · {formatTaille(log.octets)}
</span>
</div>
<a
href={`/api/logs?id=${encodeURIComponent(log.id)}`}
download
className="btn-action btn-action-secondary flex items-center gap-1.5 shrink-0"
>
<Download className="w-3.5 h-3.5" />
Télécharger
</a>
</li>
))}
</ul>
)}
</div>
);
}
@@ -15,6 +15,7 @@ import {
Loader2,
} from "lucide-react";
import { SuccessModal } from "@/components/shared/success-modal";
import { isStaleServerActionError, STALE_ACTION_MESSAGE } from "@/lib/stale-action";
import { ConfirmModal } from "@/components/shared/confirm-modal";
const formatDate = (date: Date) => {
@@ -36,12 +37,34 @@ export function SnapshotList({ type }: SnapshotListProps) {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isDeleting, setIsDeleting] = useState<number | null>(null);
const [modal, setModal] = useState<{ isOpen: boolean, title: string, message: string }>({
const [modal, setModal] = useState<{
isOpen: boolean;
title: string;
message: string;
variant?: "success" | "error";
action?: { label: string; onClick: () => void };
}>({
isOpen: false,
title: "",
message: ""
});
/**
* Onglet resté ouvert pendant un déploiement : les identifiants de Server
* Action ont changé côté serveur. On propose le rechargement au lieu
* d'afficher une erreur technique incompréhensible.
*/
const modalErreur = (err: unknown, titre: string, message: string) =>
isStaleServerActionError(err)
? {
isOpen: true,
title: "Page à recharger",
message: STALE_ACTION_MESSAGE,
variant: "error" as const,
action: { label: "Recharger la page", onClick: () => window.location.reload() },
}
: { isOpen: true, title: titre, message, variant: "error" as const };
const [confirmModal, setConfirmModal] = useState<{
isOpen: boolean;
title: string;
@@ -59,7 +82,7 @@ export function SnapshotList({ type }: SnapshotListProps) {
const data = await getSnapshots(type);
setSnapshots(data);
} catch (err) {
setError("Impossible de charger les données.");
setError(isStaleServerActionError(err) ? STALE_ACTION_MESSAGE : "Impossible de charger les données.");
} finally {
setLoading(false);
}
@@ -83,10 +106,10 @@ export function SnapshotList({ type }: SnapshotListProps) {
setSnapshots(prev => prev.filter(s => s.id !== id));
setModal({ isOpen: true, title: "Action Réussie", message: "L'élément a été supprimé de votre historique." });
} else {
setModal({ isOpen: true, title: "Erreur", message: "Impossible de supprimer l'élément." });
setModal({ isOpen: true, title: "Erreur", message: "Impossible de supprimer l'élément.", variant: "error" });
}
} catch (err) {
setModal({ isOpen: true, title: "Erreur Technique", message: "Une erreur est survenue lors de la suppression." });
setModal(modalErreur(err, "Erreur Technique", "Une erreur est survenue lors de la suppression."));
} finally {
setIsDeleting(null);
}
@@ -215,6 +238,8 @@ export function SnapshotList({ type }: SnapshotListProps) {
onClose={() => setModal(prev => ({ ...prev, isOpen: false }))}
title={modal.title}
message={modal.message}
variant={modal.variant}
action={modal.action}
/>
<ConfirmModal
+20
View File
@@ -0,0 +1,20 @@
/**
* CollectFlow — Point d'entrée exécuté une fois au démarrage du serveur.
*
* Sert à armer le minuteur de la synchronisation nocturne. L'import est
* dynamique et conditionné au runtime Node : `register()` est aussi évalué
* dans le runtime Edge, où ni `setInterval` persistant ni l'accès à la base
* n'ont de sens.
*/
export async function register() {
if (process.env.NEXT_RUNTIME !== "nodejs") return;
try {
const { startSyncTimer } = await import("@/features/admin/api/sync-timer");
startSyncTimer();
} catch (e) {
// Un démarrage réussi prime sur le planificateur : s'il ne s'arme pas,
// l'application doit rester utilisable et la synchro manuelle disponible.
console.error("[instrumentation] minuteur de synchro non démarré :", e instanceof Error ? e.message : String(e));
}
}
+122
View File
@@ -0,0 +1,122 @@
/**
* CollectFlow — Authentification de l'API publique `/api/v1`.
*
* Deux voies d'entrée :
* 1. **Clé d'API** (`X-API-Key` ou `Authorization: Bearer …`) — pour les appelants
* non-navigateur : scripts, outils externes, assistant IA.
* 2. **Session NextAuth** — le front de CollectFlow utilise donc les mêmes routes.
*
* En cas d'échec on renvoie un **401 JSON**, jamais une redirection : `src/middleware.ts`
* exempte `/api/v1/*` précisément pour que ce module puisse répondre proprement.
*
* Seul le hachage SHA-256 des clés est stocké (table `api_keys`) ; la clé en clair
* n'existe qu'au moment de sa création.
*/
import "server-only";
import { createHash, randomBytes, timingSafeEqual } from "crypto";
import type { NextRequest } from "next/server";
import { db } from "@/db";
import { apiKeys } from "@/db/schema";
import { eq } from "drizzle-orm";
import { auth } from "@/lib/auth";
import { fail } from "@/lib/api-response";
export interface AuthContext {
/** Origine de l'authentification — utile pour les logs et l'audit. */
via: "api_key" | "session";
role: string;
/** Nom de la clé, ou identifiant de l'utilisateur connecté. */
subject: string;
}
/** Préfixe des clés CollectFlow, pour les reconnaître d'un coup d'œil. */
const KEY_PREFIX = "cf_";
export function hashApiKey(key: string): string {
return createHash("sha256").update(key, "utf8").digest("hex");
}
/**
* Génère une clé d'API. Renvoie la clé **en clair** (à afficher une seule fois),
* son hachage à stocker, et un préfixe lisible pour l'identifier dans l'UI.
*/
export function generateApiKey(): { key: string; keyHash: string; keyPrefix: string } {
const secret = randomBytes(24).toString("base64url");
const key = `${KEY_PREFIX}${secret}`;
return { key, keyHash: hashApiKey(key), keyPrefix: key.slice(0, 11) };
}
/** Comparaison à temps constant de deux hachages hexadécimaux. */
function hashesEqual(a: string, b: string): boolean {
const ba = Buffer.from(a, "hex");
const bb = Buffer.from(b, "hex");
if (ba.length !== bb.length || ba.length === 0) return false;
return timingSafeEqual(ba, bb);
}
/** Extrait la clé d'un en-tête `X-API-Key` ou `Authorization: Bearer …`. */
function readKeyFromRequest(req: NextRequest): string | null {
const direct = req.headers.get("x-api-key");
if (direct?.trim()) return direct.trim();
const authz = req.headers.get("authorization");
if (authz) {
const m = authz.match(/^Bearer\s+(.+)$/i);
if (m) return m[1].trim();
}
return null;
}
/**
* Authentifie une requête `/api/v1`.
*
* Renvoie un `AuthContext` en cas de succès, ou une réponse d'erreur JSON prête à
* être retournée par le handler. À appeler en première ligne de chaque endpoint :
*
* const authCtx = await requireApiAuth(req);
* if (authCtx instanceof NextResponse) return authCtx;
*/
export async function requireApiAuth(req: NextRequest): Promise<AuthContext | Response> {
const presented = readKeyFromRequest(req);
if (presented) {
const presentedHash = hashApiKey(presented);
// La colonne key_hash est unique : la recherche par égalité suffit, et la
// comparaison à temps constant ci-dessous couvre le reste.
const [row] = await db.select().from(apiKeys).where(eq(apiKeys.keyHash, presentedHash)).limit(1);
if (!row || !hashesEqual(row.keyHash, presentedHash)) {
return fail("unauthorized", "Clé d'API invalide.");
}
if (row.revokedAt) {
return fail("unauthorized", "Clé d'API révoquée.");
}
// Trace d'usage — jamais bloquante pour la requête en cours.
void db.update(apiKeys).set({ lastUsedAt: new Date() }).where(eq(apiKeys.id, row.id))
.catch((e) => console.error("[api-auth] lastUsedAt KO:", (e as Error).message?.slice(0, 120)));
return { via: "api_key", role: row.role, subject: row.name };
}
// Repli sur la session du navigateur (le front utilise les mêmes routes).
const session = await auth();
if (session?.user) {
const user = session.user as { name?: string | null; role?: string };
return { via: "session", role: user.role ?? "user", subject: user.name ?? "session" };
}
return fail(
"unauthorized",
"Authentification requise : fournissez un en-tête X-API-Key (ou Authorization: Bearer), ou connectez-vous.",
);
}
/** Restreint un endpoint aux appelants administrateurs. */
export function requireAdminRole(ctx: AuthContext): Response | null {
if (ctx.role !== "admin") {
return fail("forbidden", "Rôle administrateur requis pour cette opération.");
}
return null;
}
+146
View File
@@ -0,0 +1,146 @@
/**
* CollectFlow — Enrichissement des lignes servies par `/api/v1`.
*
* L'instantané `grid_rows` fige la ligne au moment du calcul. Deux informations
* doivent pourtant refléter l'état **courant** au moment de l'appel :
*
* 1. **Métriques réseau Qlik** — la table `qlik_network_metrics` est mise à jour par
* les synchronisations et les recherches réseau, indépendamment du calcul de la
* grille. On les relit donc à la lecture, et on les expose telles quelles quand
* elles existent (`network: null` sinon).
*
* 2. **Gamme serveur** — `codeGamme` peut être surchargée par un snapshot de session
* (modification locale non enregistrée côté serveur). L'API expose la gamme **non
* modifiée**, celle réellement présente en base PostgreSQL, relue à chaque appel.
*
* Ce n'est pas un recalcul : deux lectures indexées bornées au nombre de lignes de la
* page (500 au maximum), de l'ordre de quelques millisecondes.
*/
import "server-only";
import type { ProductRow } from "@/types/grid";
import { getNetworkMetricsByCodeCentrale, type NetworkMetricCached } from "@/lib/qlik-network-cache";
import { pgGetGammesByCodeins } from "@/lib/pg-ff-client";
// Module de la Grille : l'API doit renvoyer exactement la tendance affichée dans
// l'application, pas une seconde implémentation qui divergerait.
import { computeNetworkTrend, trendLabel } from "@/features/grid/lib/network-trend";
/** Tendance réseau 12 mois, telle qu'affichée dans la colonne « Tendance » de la Grille. */
export interface ProductTrend {
direction: "up" | "down" | "flat";
/** Variation des 4 derniers mois vs les 4 premiers (0.23 = +23 %). `null` si aucune base. */
pct: number | null;
/** Libellé prêt à afficher : « Forte hausse », « Nouveau », « Stable »… */
label: string;
/**
* `true` = tendance calculée sur la quantité **par magasin vendeur**, ce qui
* neutralise l'effet d'un simple élargissement de la distribution. `false` =
* repli sur les quantités brutes, faute de nombre de magasins sur 12 mois.
*/
surQteParMagasin: boolean;
/** Aucune vente sur les 4 premiers mois, des ventes sur les 4 derniers. */
nouveau: boolean;
/** Nombre de mois de la série (12 quand elle est complète). */
monthsUsed: number;
}
export interface EnrichedProductRow extends ProductRow {
/**
* Gamme telle qu'elle existe sur le serveur PostgreSQL — non modifiée.
* `null` si l'article n'a aucune gamme en base.
*/
codeGammeServeur: string | null;
/** Métriques réseau Qlik en cache, ou `null` si le produit n'en a pas. */
network: NetworkMetricCached | null;
/**
* Tendance réseau calculée par régression linéaire sur les 12 derniers mois.
* `null` quand il n'y a aucune série mensuelle. Fournie pour que l'appelant —
* en particulier un agent — n'ait pas à réimplémenter le calcul et à diverger
* de ce que montre l'application.
*/
trend: ProductTrend | null;
}
/**
* Enrichit un lot de lignes. Les deux lectures sont indépendantes et lancées en
* parallèle ; un échec de l'une n'empêche pas l'autre (l'API reste servie, avec
* la valeur de l'instantané en repli).
*/
export async function enrichRows(rows: ProductRow[]): Promise<EnrichedProductRow[]> {
if (rows.length === 0) return [];
const codesCentraux = [...new Set(rows.map((r) => r.codeCentrale).filter((c): c is string => Boolean(c)))];
const codeins = [...new Set(rows.map((r) => r.codein).filter(Boolean))];
const [network, gammes] = await Promise.all([
codesCentraux.length
? getNetworkMetricsByCodeCentrale(codesCentraux).catch((e) => {
console.error("[api-enrich] métriques réseau KO:", (e as Error).message?.slice(0, 160));
return new Map<string, NetworkMetricCached>();
})
: Promise.resolve(new Map<string, NetworkMetricCached>()),
codeins.length
? pgGetGammesByCodeins(codeins).catch((e) => {
console.error("[api-enrich] gammes serveur KO:", (e as Error).message?.slice(0, 160));
return new Map<string, string>();
})
: Promise.resolve(new Map<string, string>()),
]);
return rows.map((row) => {
const metrics = row.codeCentrale ? network.get(row.codeCentrale) ?? null : null;
// Gamme absente de la base = pas de gamme, et non « inconnue » : on distingue
// explicitement par `null` plutôt que de retomber sur la valeur du snapshot.
const gammeServeur = gammes.get(row.codein) ?? null;
const enriched: EnrichedProductRow = {
...row,
codeGammeServeur: gammeServeur,
network: metrics,
trend: null, // renseignée plus bas, une fois la série mensuelle connue
};
// `codeGammeInit` porte déjà la sémantique « état serveur » dans la Grille :
// on la garde alignée pour que les deux champs ne divergent jamais.
if (gammes.size > 0) {
enriched.codeGammeInit = gammeServeur as ProductRow["codeGammeInit"];
}
// Les colonnes réseau de la ligne datent du calcul : on les réaligne sur le
// cache quand il a quelque chose à dire.
if (metrics) {
enriched.caReseau = metrics.caReseau;
enriched.qteReseau = metrics.qteReseau;
enriched.nbMagasinsReseau = metrics.nbMagasinsReseau;
enriched.caParMagasinReseau = metrics.caParMagasinReseau;
enriched.margePctReseau = metrics.margePctReseau;
enriched.qteReseauByMonth = metrics.qteByMonth;
enriched.networkFetchedAt = metrics.fetchedAt ?? undefined;
}
// Tendance : mêmes entrées que la colonne « Tendance » de la Grille, à
// savoir la série des quantités **et** celle du nombre de magasins vendeurs
// — sans la seconde, le calcul se rabat sur les quantités brutes et
// confond « vend mieux » avec « est distribué plus largement ».
const serie = enriched.qteReseauByMonth;
const nbMagByMonth = metrics?.metricsByMonth
? Object.fromEntries(
Object.entries(metrics.metricsByMonth).map(([mois, m]) => [mois, Number(m?.nbMag) || 0]),
)
: null;
const t = computeNetworkTrend(serie, nbMagByMonth);
if (t.hasData) {
enriched.trend = {
direction: t.direction,
pct: t.pct,
label: trendLabel(t.pct, t.nouveau),
surQteParMagasin: t.surQteParMagasin,
nouveau: t.nouveau,
monthsUsed: t.values.length,
};
}
return enriched;
});
}
+103 -3
View File
@@ -9,7 +9,46 @@
* - Tous types → lire qtestock pour reconstruire stock12m
*/
const FF_API_BASE = process.env.FF_API_BASE_URL ?? "https://api.ffnancy.fr";
import fs from "fs/promises";
import path from "path";
const CONFIG_FILE = path.join(process.cwd(), "data", ".db-config.json");
const FF_API_BASE_DEFAULT = "https://api.ffnancy.fr";
const FF_API_BASE_TTL_MS = 30_000;
let ffApiBaseCache: { value: string; at: number } | null = null;
/**
* URL de l'API FF Nancy, résolue **au moment de l'appel**.
*
* Ordre de priorité : réglage enregistré dans Paramètres → variable
* d'environnement `FF_API_BASE_URL` → valeur par défaut. Auparavant l'URL était
* figée au chargement du module depuis l'environnement seul : impossible de la
* corriger depuis l'application quand le serveur changeait d'adresse.
*
* Le résultat est mémorisé 30 s pour ne pas relire le fichier à chaque requête.
*/
async function getFfApiBase(): Promise<string> {
if (ffApiBaseCache && Date.now() - ffApiBaseCache.at < FF_API_BASE_TTL_MS) {
return ffApiBaseCache.value;
}
let configured: string | undefined;
try {
const raw = await fs.readFile(CONFIG_FILE, "utf-8");
const parsed = JSON.parse(raw) as { ffApiBaseUrl?: string };
configured = parsed?.ffApiBaseUrl?.trim() || undefined;
} catch {
// Fichier absent ou illisible : on retombe sur l'environnement.
}
const value = (configured || process.env.FF_API_BASE_URL || FF_API_BASE_DEFAULT).replace(/\/+$/, "");
ffApiBaseCache = { value, at: Date.now() };
return value;
}
/** Vide le cache — appelé après un enregistrement dans Paramètres. */
export function resetFfApiBaseCache(): void {
ffApiBaseCache = null;
}
// ---------------------------------------------------------------------------
// Types API (shapes des réponses brutes)
@@ -65,9 +104,62 @@ export interface FfCommande {
qtecde: number;
}
export interface FfSyncTable {
nom: string;
derniereSync: string;
nbLignes?: number;
/** "ok" ou état d'erreur renvoyé par la synchronisation. */
statut?: string;
erreur?: string | null;
}
export interface FfSyncStatus {
lastSync: string;
tables: { nom: string; derniereSync: string; nbLignes?: number }[];
tables: FfSyncTable[];
}
/**
* Normalise la réponse de `/api/sync/status`.
*
* L'API renvoie `{ sync: [{ table_name, last_sync, rows_synced, status, error_msg }] }`,
* alors que l'application attendait `{ lastSync, tables: [{ nom, derniereSync, nbLignes }] }`.
* Les champs lus n'existaient donc pas : le panneau des Paramètres affichait une
* coquille vide sans la moindre erreur, puisque la requête HTTP réussissait.
*
* Les deux formes sont acceptées ici, pour rester compatible si l'API évolue.
*/
export function normalizeSyncStatus(raw: unknown): FfSyncStatus | null {
if (!raw || typeof raw !== "object") return null;
const d = raw as Record<string, unknown>;
// Forme déjà attendue par l'application.
if (Array.isArray(d.tables)) {
return {
lastSync: String(d.lastSync ?? ""),
tables: d.tables as FfSyncTable[],
};
}
// Forme réelle de l'API FF Nancy.
const sync = Array.isArray(d.sync) ? (d.sync as Array<Record<string, unknown>>) : null;
if (!sync) return null;
const tables: FfSyncTable[] = sync.map((t) => ({
nom: String(t.table_name ?? t.nom ?? "—"),
derniereSync: String(t.last_sync ?? t.derniereSync ?? ""),
nbLignes: Number(t.rows_synced ?? t.nbLignes ?? 0) || 0,
statut: t.status != null ? String(t.status) : undefined,
erreur: t.error_msg != null ? String(t.error_msg) : null,
}));
// Pas de date globale dans la réponse : on prend la plus récente des tables.
const lastSync = tables
.map((t) => t.derniereSync)
.filter(Boolean)
.sort()
.at(-1) ?? "";
return { lastSync, tables };
}
/** Stock agrégé sur tous les sites pour un codein */
@@ -212,6 +304,7 @@ function extractFouNom(f: any): string {
export async function getFournisseursFromApi(
search?: string
): Promise<{ code: string; nom: string }[]> {
const FF_API_BASE = await getFfApiBase();
try {
const url = search
? `${FF_API_BASE}/api/fournisseurs?search=${encodeURIComponent(search)}&limit=500`
@@ -242,6 +335,7 @@ export async function getFournisseursFromApi(
export async function getArticlesByFournisseur(
codefou: string
): Promise<FfArticle[]> {
const FF_API_BASE = await getFfApiBase();
const raw = await fetchAllPages<unknown>(
(page) => `${FF_API_BASE}/api/articles?codefou=${encodeURIComponent(codefou)}&page=${page}&limit=500`,
extractList,
@@ -272,6 +366,7 @@ export async function getMouvementsByFournisseur(
dateDebut: string,
dateFin: string
): Promise<FfMouvement[]> {
const FF_API_BASE = await getFfApiBase();
const raw = await fetchAllPages<unknown>(
(page) =>
`${FF_API_BASE}/api/mouvements/articles?codefou=${encodeURIComponent(codefou)}&dateDebut=${dateDebut}&dateFin=${dateFin}&page=${page}&limit=1000`,
@@ -301,6 +396,7 @@ export async function getMouvementsForDate(
dateDebut: string,
dateFin: string
): Promise<FfMouvement[]> {
const FF_API_BASE = await getFfApiBase();
const raw = await fetchAllPages<unknown>(
(page) =>
`${FF_API_BASE}/api/mouvements/articles?dateDebut=${dateDebut}&dateFin=${dateFin}&page=${page}&limit=1000`,
@@ -362,6 +458,7 @@ export async function getMensuelByArticles(
batchSize = 50
): Promise<Map<string, FfMensuelEntry[]>> {
const result = new Map<string, FfMensuelEntry[]>();
const FF_API_BASE = await getFfApiBase();
for (let i = 0; i < articles.length; i += batchSize) {
const batch = articles.slice(i, i + batchSize);
@@ -448,6 +545,7 @@ export async function getReferentielByArticles(
batchSize = 50
): Promise<Map<string, FfReferentiel>> {
const result = new Map<string, FfReferentiel>();
const FF_API_BASE = await getFfApiBase();
for (let i = 0; i < articles.length; i += batchSize) {
const batch = articles.slice(i, i + batchSize);
@@ -479,6 +577,7 @@ export async function getCommandesByFournisseur(
codefou: string
): Promise<Map<string, number>> {
const result = new Map<string, number>();
const FF_API_BASE = await getFfApiBase();
try {
const res = await fetch(
`${FF_API_BASE}/api/commandes/articles?codefou=${encodeURIComponent(codefou)}`,
@@ -502,9 +601,10 @@ export async function getCommandesByFournisseur(
export async function getSyncStatus(): Promise<FfSyncStatus | null> {
try {
const FF_API_BASE = await getFfApiBase();
const res = await fetch(`${FF_API_BASE}/api/sync/status`, { cache: "no-store" });
if (!res.ok) return null;
return await res.json();
return normalizeSyncStatus(await res.json());
} catch (err) {
console.error("[api-ff] getSyncStatus error:", err);
return null;
+72
View File
@@ -0,0 +1,72 @@
/**
* CollectFlow — Enveloppe de réponse de l'API publique `/api/v1`.
*
* Une forme unique pour tout : `{ data, pagination?, meta }` en succès,
* `{ error: { code, message } }` en échec, avec de vrais statuts HTTP.
* Les consommateurs (scripts, IA, outils externes) peuvent ainsi traiter
* toutes les réponses de la même manière.
*/
import { NextResponse } from "next/server";
export type ApiErrorCode =
| "unauthorized"
| "forbidden"
| "bad_request"
| "not_found"
| "not_ready"
| "internal_error";
const STATUS_BY_CODE: Record<ApiErrorCode, number> = {
unauthorized: 401,
forbidden: 403,
bad_request: 400,
not_found: 404,
not_ready: 202,
internal_error: 500,
};
export interface Pagination {
page: number;
limit: number;
total: number;
totalPages: number;
/** `true` s'il reste des pages à lire — évite d'avoir à comparer page et totalPages. */
hasMore: boolean;
}
export function buildPagination(page: number, limit: number, total: number): Pagination {
const totalPages = Math.max(1, Math.ceil(total / limit));
// `hasMore` explicite : un agent qui doit comparer page et totalPages oublie
// souvent de le faire et conclut à tort qu'il a tout récupéré.
return { page, limit, total, totalPages, hasMore: page < totalPages };
}
/** Réponse de succès. `meta` porte la fraîcheur de la donnée (`computedAt`). */
export function ok<T>(
data: T,
opts: { pagination?: Pagination; meta?: Record<string, unknown> } = {},
): NextResponse {
return NextResponse.json({
data,
...(opts.pagination ? { pagination: opts.pagination } : {}),
meta: { ...(opts.meta ?? {}) },
});
}
/**
* Réponse d'erreur. `details` sert notamment aux erreurs de validation zod.
*
* Note : `not_ready` renvoie un 202 — ce n'est pas un échec mais une donnée pas
* encore calculée (voir /api/v1/grid sur un fournisseur jamais ouvert).
*/
export function fail(
code: ApiErrorCode,
message: string,
details?: unknown,
): NextResponse {
return NextResponse.json(
{ error: { code, message, ...(details !== undefined ? { details } : {}) } },
{ status: STATUS_BY_CODE[code] },
);
}
+143
View File
@@ -0,0 +1,143 @@
/**
* CollectFlow — Validation des paramètres de l'API `/api/v1` (zod).
*
* Toute entrée utilisateur passe par ici : bornes de pagination, listes blanches
* de tri, projection de champs. Les erreurs remontent en `400 bad_request` avec
* le détail zod, pour que l'appelant sache quoi corriger.
*/
import { z } from "zod";
import { GRID_SORT_KEYS, type GridSortKey } from "@/lib/grid-store";
const SORT_KEYS = GRID_SORT_KEYS as readonly string[];
export const paginationShape = {
page: z.coerce.number().int().min(1).default(1),
/**
* Aucun plafond : l'appelant décide de la taille qu'il veut recevoir.
* Le défaut reste modeste (100) pour que les endpoints de recherche, non
* bornés par un fournisseur, ne renvoient pas tout le catalogue par accident.
* `/grid` redéfinit ce paramètre : sans `limit`, il renvoie tout.
*/
limit: z.coerce.number().int().min(1).default(100),
};
const sortShape = {
sort: z
.string()
.refine((v) => SORT_KEYS.includes(v), {
message: `Tri inconnu. Valeurs acceptées : ${SORT_KEYS.join(", ")}`,
})
.optional(),
order: z.enum(["asc", "desc"]).default("desc"),
/** Liste de champs de ProductRow à conserver, séparés par des virgules. */
fields: z.string().optional(),
/**
* `1` (défaut) : relit les métriques réseau Qlik et la gamme serveur au moment
* de l'appel. `0` : sert l'instantané brut, un peu plus rapide.
*/
enrich: z.enum(["0", "1"]).default("1"),
};
/** `/api/v1/grid` — le fournisseur est obligatoire (recherche transversale : /products/search). */
export const gridQuerySchema = z.object({
fournisseur: z.string().min(1, "Paramètre 'fournisseur' requis"),
gamme: z.string().min(1).optional(),
code1: z.string().min(1).optional(),
code2: z.string().min(1).optional(),
code3: z.string().min(1).optional(),
/**
* Préfixe de nomenclature (« 32 », « 3202 », « 320211 »), tel que renvoyé par
* /api/v1/nomenclatures. À préférer à code1/code2, qui peuvent être vides
* quand la hiérarchie n'a pas pu être remontée depuis la base FF.
*/
nomenclature: z.string().min(1).max(20).regex(/^[0-9]+$/, "La nomenclature doit être un préfixe numérique (ex. 32, 3202, 320211)").optional(),
search: z.string().min(1).optional(),
/**
* `1` (défaut) : si le fournisseur n'a pas encore d'instantané, l'API le calcule
* à la demande au lieu de répondre `not_ready`. C'est ce qui permet à une app ou
* à un agent externe d'interroger n'importe quel fournisseur sans qu'un humain
* ait ouvert la Grille avant. Le premier appel est alors plus lent (plusieurs
* secondes) ; les suivants sont servis depuis l'instantané.
* `0` : comportement strict, échoue vite en `not_ready`.
*/
compute: z.enum(["0", "1"]).default("1"),
...sortShape,
...paginationShape,
/**
* **Aucun plafond, et aucune valeur par défaut** : sans `limit`, `/grid`
* renvoie *toutes* les lignes du fournisseur.
*
* C'est l'objectif de cet endpoint — qu'une app ou un agent récupère un
* fournisseur entier en un appel. Paginer par défaut condamnait l'appelant à
* boucler, ce que les agents font mal : ils s'arrêtent à la première page et
* raisonnent sur un catalogue tronqué.
*
* Réponse volumineuse : une ligne complète porte les séries mensuelles et les
* ventilations par magasin. Combiner avec `fields` est vivement conseillé.
*/
limit: z.coerce.number().int().min(1).optional(),
});
/** `/api/v1/products/search` — `fournisseur` facultatif : c'est la recherche transversale. */
export const productSearchSchema = z.object({
q: z.string().min(1, "Paramètre 'q' requis").max(120),
fournisseur: z.string().min(1).optional(),
gamme: z.string().min(1).optional(),
...sortShape,
...paginationShape,
});
export const fournisseursQuerySchema = z.object({
search: z.string().min(1).optional(),
/** true = uniquement les fournisseurs déjà présents dans l'instantané de grille. */
withData: z.enum(["0", "1"]).optional(),
});
/** `/api/v1/nomenclatures` — inventaire des postes d'un fournisseur. */
export const nomenclaturesQuerySchema = z.object({
fournisseur: z.string().min(1, "Paramètre 'fournisseur' requis"),
/**
* 1 = univers, 2 = famille, 3 = sous-famille (selon la nomenclature FF).
* Le `transform` ne fait que redonner le type littéral : les bornes ont déjà
* été validées juste avant, la valeur ne peut être que 1, 2 ou 3.
*/
niveau: z.coerce.number().int().min(1).max(3).default(1).transform((n) => n as 1 | 2 | 3),
/** Restreint à un poste parent : un code1 pour niveau=2, un code2 pour niveau=3. */
parent: z.string().min(1).optional(),
});
export const productDetailSchema = z.object({
fournisseur: z.string().min(1).optional(),
/**
* `1` (défaut) : calcule l'instantané à la demande si le produit n'y figure pas
* encore. Nécessite `fournisseur`, le calcul se faisant par lot fournisseur.
*/
compute: z.enum(["0", "1"]).default("1"),
});
/**
* Projection de champs : `?fields=codein,libelle1,totalCa`.
*
* Sert à alléger la réponse — un `ProductRow` complet porte les séries mensuelles
* et les ventilations par magasin, ce qui est volumineux sur plusieurs centaines de
* lignes. `codein` est toujours conservé pour que la ligne reste identifiable.
*/
export function pickFields<T extends object>(row: T, fields?: string): Partial<T> {
if (!fields) return row;
const wanted = new Set(
fields.split(",").map((f) => f.trim()).filter(Boolean),
);
if (wanted.size === 0) return row;
wanted.add("codein");
const out: Record<string, unknown> = {};
for (const key of wanted) {
if (key in row) out[key] = (row as unknown as Record<string, unknown>)[key];
}
return out as Partial<T>;
}
/** Normalise le tri validé vers la clé attendue par grid-store. */
export function toSortKey(sort?: string): GridSortKey | undefined {
return sort as GridSortKey | undefined;
}
+361
View File
@@ -0,0 +1,361 @@
/**
* CollectFlow — Instantané persisté de la grille (table `grid_rows`).
*
* `getProductRows()` reconstruit la grille en direct (6 requêtes SQL sur la base
* miroir FF + jointure Qlik) et ne la garde qu'en mémoire 10 minutes. L'API `/api/v1`
* ne doit **jamais** déclencher ce calcul : elle lit cette table, remplie en effet de
* bord quand quelqu'un ouvre la Grille.
*
* On n'introduit donc aucun calcul supplémentaire — on arrête de jeter un résultat
* qui existe déjà, et il survit désormais aux redémarrages.
*/
import "server-only";
import { db } from "@/db";
import { gridRows } from "@/db/schema";
import { and, asc, desc, eq, ilike, or, sql, lt, type SQL } from "drizzle-orm";
import type { ProductRow } from "@/types/grid";
/** Colonnes autorisées au tri — liste blanche, jamais l'entrée utilisateur brute. */
const SORTABLE = {
libelle1: gridRows.libelle1,
codein: gridRows.codein,
codeFournisseur: gridRows.codeFournisseur,
codeGamme: gridRows.codeGamme,
totalCa: gridRows.totalCa,
totalQuantite: gridRows.totalQuantite,
totalMarge: gridRows.totalMarge,
tauxMarge: gridRows.tauxMarge,
stockActuel: gridRows.stockActuel,
caReseau: gridRows.caReseau,
qteReseau: gridRows.qteReseau,
nbMagasinsReseau: gridRows.nbMagasinsReseau,
caParMagasinReseau: gridRows.caParMagasinReseau,
computedAt: gridRows.computedAt,
} as const;
export type GridSortKey = keyof typeof SORTABLE;
export const GRID_SORT_KEYS = Object.keys(SORTABLE) as GridSortKey[];
export interface GridQuery {
codeFournisseur?: string;
gamme?: string;
code1?: string;
code2?: string;
code3?: string;
/** Préfixe de nomenclature (« 32 », « 3202 », « 320211 ») — filtre sur code3. */
nomenclature?: string;
/** Recherche libre : libellé, codein, GTIN, référence, code centrale. */
search?: string;
sort?: GridSortKey;
order?: "asc" | "desc";
page?: number;
limit?: number;
}
export interface GridQueryResult {
rows: ProductRow[];
total: number;
/** Calcul le plus ancien parmi les lignes renvoyées — fraîcheur de la donnée. */
computedAt: string | null;
}
/**
* Persiste les lignes calculées pour un fournisseur.
*
* Stratégie : toutes les lignes du lot portent le même `computedAt`, puis on supprime
* celles du fournisseur restées sur un `computedAt` antérieur. Cela purge les articles
* disparus du catalogue sans avoir à passer des centaines de codein en paramètres.
*/
export async function upsertGridRows(codeFournisseur: string, rows: ProductRow[]): Promise<number> {
if (!codeFournisseur || rows.length === 0) return 0;
const computedAt = new Date();
const values = rows
.filter((r) => r.codein)
.map((r) => ({
codeFournisseur,
codein: r.codein,
nomFournisseur: r.nomFournisseur ?? null,
libelle1: r.libelle1 ?? null,
gtin: r.gtin ?? null,
reference: r.reference ?? null,
codeCentrale: r.codeCentrale ?? null,
code1: r.code1 ?? null,
code2: r.code2 ?? null,
code3: r.code3 ?? null,
codeGamme: r.codeGamme ?? null,
codeGammeInit: r.codeGammeInit ?? null,
totalCa: r.totalCa != null ? String(r.totalCa) : null,
totalQuantite: r.totalQuantite != null ? String(r.totalQuantite) : null,
totalMarge: r.totalMarge != null ? String(r.totalMarge) : null,
tauxMarge: r.tauxMarge != null ? String(r.tauxMarge) : null,
stockActuel: r.stockActuel != null ? String(r.stockActuel) : null,
caReseau: r.caReseau != null ? String(r.caReseau) : null,
qteReseau: r.qteReseau != null ? String(r.qteReseau) : null,
nbMagasinsReseau: r.nbMagasinsReseau ?? null,
caParMagasinReseau: r.caParMagasinReseau != null ? String(r.caParMagasinReseau) : null,
margePctReseau: r.margePctReseau != null ? String(r.margePctReseau) : null,
payload: r,
computedAt,
}));
if (values.length === 0) return 0;
const CHUNK = 200; // payload jsonb volumineux : lots plus petits que pour des scalaires
let written = 0;
for (let i = 0; i < values.length; i += CHUNK) {
const batch = values.slice(i, i + CHUNK);
await db
.insert(gridRows)
.values(batch)
.onConflictDoUpdate({
target: [gridRows.codeFournisseur, gridRows.codein],
set: {
nomFournisseur: sql`excluded.nom_fournisseur`,
libelle1: sql`excluded.libelle1`,
gtin: sql`excluded.gtin`,
reference: sql`excluded.reference`,
codeCentrale: sql`excluded.code_centrale`,
code1: sql`excluded.code1`,
code2: sql`excluded.code2`,
code3: sql`excluded.code3`,
codeGamme: sql`excluded.code_gamme`,
codeGammeInit: sql`excluded.code_gamme_init`,
totalCa: sql`excluded.total_ca`,
totalQuantite: sql`excluded.total_quantite`,
totalMarge: sql`excluded.total_marge`,
tauxMarge: sql`excluded.taux_marge`,
stockActuel: sql`excluded.stock_actuel`,
caReseau: sql`excluded.ca_reseau`,
qteReseau: sql`excluded.qte_reseau`,
nbMagasinsReseau: sql`excluded.nb_magasins_reseau`,
caParMagasinReseau: sql`excluded.ca_par_magasin_reseau`,
margePctReseau: sql`excluded.marge_pct_reseau`,
payload: sql`excluded.payload`,
computedAt: sql`excluded.computed_at`,
},
});
written += batch.length;
}
// Purge des articles qui ne font plus partie du catalogue du fournisseur.
await db.delete(gridRows).where(
and(eq(gridRows.codeFournisseur, codeFournisseur), lt(gridRows.computedAt, computedAt)),
);
return written;
}
/** Construit la clause WHERE commune à la grille et à la recherche transversale. */
function buildWhere(q: GridQuery): SQL | undefined {
const clauses: SQL[] = [];
if (q.codeFournisseur) clauses.push(eq(gridRows.codeFournisseur, q.codeFournisseur));
if (q.gamme) clauses.push(eq(gridRows.codeGamme, q.gamme));
if (q.code1) clauses.push(eq(gridRows.code1, q.code1));
if (q.code2) clauses.push(eq(gridRows.code2, q.code2));
if (q.code3) clauses.push(eq(gridRows.code3, q.code3));
// Filtre par **préfixe** de nomenclature : « 32 », « 3202 » ou « 320211 ».
// C'est celui à utiliser avec /api/v1/nomenclatures, car il ne dépend pas de
// code1/code2 — parfois vides quand la hiérarchie n'a pas pu être remontée.
if (q.nomenclature) clauses.push(sql`${gridRows.code3} like ${q.nomenclature + "%"}`);
const search = q.search?.trim();
if (search) {
const pattern = `%${search}%`;
const alt = or(
ilike(gridRows.libelle1, pattern),
ilike(gridRows.codein, pattern),
ilike(gridRows.gtin, pattern),
ilike(gridRows.reference, pattern),
ilike(gridRows.codeCentrale, pattern),
);
if (alt) clauses.push(alt);
}
return clauses.length ? and(...clauses) : undefined;
}
/**
* Lit les lignes de grille persistées : filtre, tri et pagination faits en SQL.
* Ne déclenche aucun recalcul et ne contacte jamais Qlik.
*/
export async function queryGridRows(q: GridQuery): Promise<GridQueryResult> {
const page = Math.max(1, q.page ?? 1);
// `limit` absent = aucune limite : on renvoie toutes les lignes du filtre.
// Il y avait ici un Math.min(500, …) qui re-plafonnait en silence, quelle que
// soit la valeur demandée — la réponse était tronquée sans que rien ne le dise.
const limit = q.limit != null ? Math.max(1, q.limit) : null;
const where = buildWhere(q);
const sortCol = SORTABLE[q.sort ?? "totalCa"] ?? gridRows.totalCa;
// NULLS LAST dans les deux sens : un produit sans CA ne doit jamais occuper la tête.
const orderBy = q.order === "asc" ? asc(sortCol) : desc(sortCol);
const [countRow] = await db
.select({ total: sql<number>`count(*)::int` })
.from(gridRows)
.where(where);
const selection = db
.select({ payload: gridRows.payload, computedAt: gridRows.computedAt })
.from(gridRows)
.where(where)
.orderBy(orderBy, asc(gridRows.codein));
const found = limit != null
? await selection.limit(limit).offset((page - 1) * limit)
: await selection;
let oldest: Date | null = null;
for (const r of found) {
if (r.computedAt && (!oldest || r.computedAt < oldest)) oldest = r.computedAt;
}
return {
rows: found.map((r) => r.payload as ProductRow),
total: countRow?.total ?? 0,
computedAt: oldest ? oldest.toISOString() : null,
};
}
/** Fiche complète d'un produit. `codeFournisseur` lève l'ambiguïté d'un article multi-fournisseurs. */
export async function getGridRowByCodein(
codein: string,
codeFournisseur?: string,
): Promise<{ row: ProductRow; computedAt: string | null } | null> {
const clauses: SQL[] = [eq(gridRows.codein, codein)];
if (codeFournisseur) clauses.push(eq(gridRows.codeFournisseur, codeFournisseur));
const [found] = await db
.select({ payload: gridRows.payload, computedAt: gridRows.computedAt })
.from(gridRows)
.where(and(...clauses))
.orderBy(desc(gridRows.computedAt))
.limit(1);
if (!found) return null;
return {
row: found.payload as ProductRow,
computedAt: found.computedAt ? found.computedAt.toISOString() : null,
};
}
/**
* Indique si un fournisseur a déjà été calculé au moins une fois.
* Sert à répondre `202 not_ready` plutôt que de déclencher un calcul long.
*/
export async function getGridFreshness(
codeFournisseur: string,
): Promise<{ rowCount: number; computedAt: string | null } | null> {
const [found] = await db
.select({
rowCount: sql<number>`count(*)::int`,
computedAt: sql<Date | null>`max(${gridRows.computedAt})`,
})
.from(gridRows)
.where(eq(gridRows.codeFournisseur, codeFournisseur));
if (!found || found.rowCount === 0) return null;
return {
rowCount: found.rowCount,
computedAt: found.computedAt ? new Date(found.computedAt).toISOString() : null,
};
}
/** Fournisseurs présents dans l'instantané, avec leur fraîcheur. */
export async function listGridSuppliers(): Promise<
Array<{ codeFournisseur: string; nomFournisseur: string | null; rowCount: number; computedAt: string | null }>
> {
const found = await db
.select({
codeFournisseur: gridRows.codeFournisseur,
nomFournisseur: sql<string | null>`max(${gridRows.nomFournisseur})`,
rowCount: sql<number>`count(*)::int`,
computedAt: sql<Date | null>`max(${gridRows.computedAt})`,
})
.from(gridRows)
.groupBy(gridRows.codeFournisseur)
.orderBy(asc(gridRows.codeFournisseur));
return found.map((r) => ({
codeFournisseur: r.codeFournisseur,
nomFournisseur: r.nomFournisseur,
rowCount: r.rowCount,
computedAt: r.computedAt ? new Date(r.computedAt).toISOString() : null,
}));
}
// ---------------------------------------------------------------------------
// Inventaire des nomenclatures d'un fournisseur
// ---------------------------------------------------------------------------
/** Un poste de nomenclature, avec son poids — de quoi décider par où découper. */
export interface GridNomenclature {
code: string;
/** Libellé du poste, lu dans le payload (il n'a pas de colonne dédiée). */
libelle: string | null;
nbArticles: number;
totalCa: number;
totalQuantite: number;
}
/** Longueur du code pour chaque niveau : « 32 » → « 3202 » → « 320211 ». */
export const NOMENCLATURE_LONGUEURS: Record<1 | 2 | 3, number> = { 1: 2, 2: 4, 3: 6 };
/**
* Répartition des articles d'un fournisseur par niveau de nomenclature.
*
* Sans cela, un appelant qui veut découper un gros fournisseur par nomenclature
* est coincé : le filtre existe sur `/grid`, mais rien ne lui dit **quelles**
* valeurs existent — il devrait tout télécharger pour les découvrir, ce qui
* annule le bénéfice du découpage.
*
* Les niveaux sont dérivés par **préfixe de `code3`** (codes hiérarchiques à 6
* chiffres, de 31xxxx à 40xxxx), et non des colonnes `code1`/`code2`. Celles-ci
* ne sont renseignées que si `pgGetNomenclatureByFournisseur` a su remonter la
* hiérarchie, ce qui dépend d'une détection heuristique de la colonne parent de
* la table `nomenclature` ; quand elle échoue, seul `code3` est rempli. Le
* préfixe, lui, est toujours disponible.
*
* Tout est agrégé en SQL : la réponse fait quelques dizaines de lignes, même pour
* un fournisseur de 130 000 articles.
*/
export async function listGridNomenclatures(
codeFournisseur: string,
niveau: 1 | 2 | 3,
parent?: string,
): Promise<GridNomenclature[]> {
const longueur = NOMENCLATURE_LONGUEURS[niveau];
const codeExpr = sql<string>`left(${gridRows.code3}, ${longueur})`;
// Les libellés n'ont pas de colonne dédiée : on les lit dans le payload.
// Aux niveaux 1 et 2 ils peuvent être absents (même cause que ci-dessus) ;
// le code et les compteurs, eux, restent justes.
const libelleKey = niveau === 1 ? "libelleNiveau1" : niveau === 2 ? "libelleNiveau2" : "libelle3";
const clauses: SQL[] = [
eq(gridRows.codeFournisseur, codeFournisseur),
sql`${gridRows.code3} is not null and length(${gridRows.code3}) >= ${longueur}`,
];
// `parent` est lui aussi un préfixe : « 32 » pour descendre au niveau 2.
if (parent) clauses.push(sql`${gridRows.code3} like ${parent + "%"}`);
const rows = await db
.select({
code: codeExpr,
libelle: sql<string | null>`max(${gridRows.payload} ->> ${libelleKey})`,
nbArticles: sql<number>`count(*)::int`,
totalCa: sql<number>`coalesce(sum(${gridRows.totalCa}), 0)::float`,
totalQuantite: sql<number>`coalesce(sum(${gridRows.totalQuantite}), 0)::float`,
})
.from(gridRows)
.where(and(...clauses))
.groupBy(codeExpr)
.orderBy(desc(sql`coalesce(sum(${gridRows.totalCa}), 0)`));
return rows.map((r) => ({
code: String(r.code ?? ""),
libelle: r.libelle ?? null,
nbArticles: Number(r.nbArticles) || 0,
totalCa: Number(r.totalCa) || 0,
totalQuantite: Number(r.totalQuantite) || 0,
}));
}
+229
View File
@@ -0,0 +1,229 @@
/**
* CollectFlow — Capture des logs serveur d'un job, pour téléchargement.
*
* POURQUOI. Les extractions Qlik produisent plusieurs milliers de lignes
* (`[qlik-pw][page] …`, timings, diagnostics du modèle). Or ces lignes ne
* vivent que dans la sortie standard du conteneur : un terminal les tronque,
* et un copier-coller ne rapporte que la fin — c'est-à-dire jamais l'entête,
* là où se trouvent la carte du modèle Qlik et le choix des champs. Sans elle,
* un diagnostic se fait à l'aveugle.
*
* COMMENT. `console.log/warn/error` est instrumenté UNE fois au chargement du
* module. Tant qu'au moins une capture est ouverte, chaque ligne est recopiée
* dans les captures ouvertes, puis transmise telle quelle à la console
* d'origine (rien n'est retenu, rien ne change dans les logs du conteneur).
*
* ⚠️ Deux jobs simultanés partagent la sortie standard du process : leurs
* lignes ne portent pas d'identifiant permettant de les démêler. Chaque
* capture ouverte reçoit donc TOUTES les lignes de la période — mieux vaut un
* peu de bruit qu'une ligne manquante dans un journal de diagnostic.
*
* Les captures sont écrites sur disque (répertoire temporaire) au fil de l'eau :
* un job qui fait tomber le process laisse quand même son journal.
*/
import { mkdir, readFile, readdir, stat, unlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
/** Répertoire des journaux. Volontairement hors du projet : ce sont des temporaires. */
const LOG_DIR = join(tmpdir(), "collectflow-logs");
/** Au-delà, on arrête d'accumuler : un job en boucle ne doit pas manger la RAM. */
const MAX_LIGNES = 200_000;
/** Une ligne pathologique (dump d'hypercube) est tronquée plutôt que gardée entière. */
const MAX_LONGUEUR_LIGNE = 8_000;
/** Nombre de journaux conservés sur disque. */
const MAX_FICHIERS = 20;
/** Écriture disque au plus toutes les N ms (le journal complet est réécrit). */
const FLUSH_MS = 2_000;
interface Capture {
id: string;
lignes: string[];
tronque: boolean;
debut: number;
flushEnCours: boolean;
flushDemande: boolean;
dernierFlush: number;
}
const captures = new Map<string, Capture>();
/** Nom de fichier sûr : l'id vient d'un job interne, on le verrouille quand même. */
function fichierPour(id: string): string {
return join(LOG_DIR, `${id.replace(/[^A-Za-z0-9_.-]/g, "_")}.log`);
}
function horodatage(): string {
return new Date().toISOString();
}
/** Rend une ligne à partir des arguments variadiques de `console.*`. */
function formater(args: unknown[]): string {
return args
.map((a) => {
if (typeof a === "string") return a;
if (a instanceof Error) return `${a.message}\n${a.stack ?? ""}`;
try {
return JSON.stringify(a);
} catch {
return String(a);
}
})
.join(" ");
}
async function flush(capture: Capture): Promise<void> {
if (capture.flushEnCours) {
capture.flushDemande = true;
return;
}
capture.flushEnCours = true;
try {
await mkdir(LOG_DIR, { recursive: true });
await writeFile(fichierPour(capture.id), capture.lignes.join("\n") + "\n", "utf8");
capture.dernierFlush = Date.now();
} catch {
// Un journal de diagnostic ne doit jamais faire échouer le job qu'il observe.
} finally {
capture.flushEnCours = false;
if (capture.flushDemande) {
capture.flushDemande = false;
void flush(capture);
}
}
}
function ajouter(ligne: string): void {
if (captures.size === 0) return;
const texte = ligne.length > MAX_LONGUEUR_LIGNE
? ligne.slice(0, MAX_LONGUEUR_LIGNE) + " …[ligne tronquée]"
: ligne;
const prefixe = `[${horodatage()}] `;
for (const capture of captures.values()) {
if (capture.lignes.length >= MAX_LIGNES) {
if (!capture.tronque) {
capture.tronque = true;
capture.lignes.push(`${prefixe}[log-capture] limite de ${MAX_LIGNES} lignes atteinte — suite non enregistrée`);
}
continue;
}
capture.lignes.push(prefixe + texte);
if (Date.now() - capture.dernierFlush >= FLUSH_MS) void flush(capture);
}
}
/**
* Instrumente `console` une seule fois, même en développement où le module peut
* être réévalué à chaud (le drapeau est porté par `globalThis`).
*/
function instrumenterConsole(): void {
const g = globalThis as typeof globalThis & { __cfLogCaptureInstalle?: boolean };
if (g.__cfLogCaptureInstalle) return;
g.__cfLogCaptureInstalle = true;
const methodes = ["log", "warn", "error", "info"] as const;
for (const methode of methodes) {
const origine = console[methode].bind(console);
console[methode] = (...args: unknown[]) => {
try {
ajouter(formater(args));
} catch {
// idem : jamais au détriment du log réel
}
origine(...args);
};
}
}
instrumenterConsole();
/** Supprime les journaux les plus anciens au-delà de `MAX_FICHIERS`. */
async function purger(): Promise<void> {
try {
const noms = (await readdir(LOG_DIR)).filter((n) => n.endsWith(".log"));
if (noms.length <= MAX_FICHIERS) return;
const avecDate = await Promise.all(
noms.map(async (nom) => {
const s = await stat(join(LOG_DIR, nom)).catch(() => null);
return { nom, mtime: s?.mtimeMs ?? 0 };
}),
);
avecDate.sort((a, b) => b.mtime - a.mtime);
for (const { nom } of avecDate.slice(MAX_FICHIERS)) {
await unlink(join(LOG_DIR, nom)).catch(() => undefined);
}
} catch {
// purge best-effort
}
}
/**
* Ouvre une capture. Toutes les lignes écrites sur la console à partir de
* maintenant (par n'importe quel code du process, cf. avertissement en tête)
* y sont recopiées jusqu'à `stopCapture`.
*/
export function startCapture(id: string, entete?: string): void {
const capture: Capture = {
id,
lignes: [],
tronque: false,
debut: Date.now(),
flushEnCours: false,
flushDemande: false,
dernierFlush: 0,
};
captures.set(id, capture);
capture.lignes.push(`[${horodatage()}] [log-capture] début de capture ${id}`);
if (entete) capture.lignes.push(`[${horodatage()}] [log-capture] ${entete}`);
void flush(capture);
void purger();
}
/** Ferme la capture et écrit son état final sur disque. */
export async function stopCapture(id: string, pied?: string): Promise<void> {
const capture = captures.get(id);
if (!capture) return;
const duree = Math.round((Date.now() - capture.debut) / 1000);
capture.lignes.push(`[${horodatage()}] [log-capture] fin de capture ${id} — ${capture.lignes.length} lignes, ${duree} s`);
if (pied) capture.lignes.push(`[${horodatage()}] [log-capture] ${pied}`);
captures.delete(id);
await flush(capture);
}
/**
* Contenu d'un journal : la capture en cours si elle est ouverte, sinon le
* fichier laissé sur disque. `null` si aucun des deux n'existe.
*/
export async function readCapture(id: string): Promise<string | null> {
const capture = captures.get(id);
if (capture) return capture.lignes.join("\n") + "\n";
try {
return await readFile(fichierPour(id), "utf8");
} catch {
return null;
}
}
/** Journaux disponibles, du plus récent au plus ancien. */
export async function listCaptures(): Promise<Array<{ id: string; octets: number; modifieLe: string; enCours: boolean }>> {
const resultats: Array<{ id: string; octets: number; modifieLe: string; enCours: boolean }> = [];
try {
for (const nom of await readdir(LOG_DIR)) {
if (!nom.endsWith(".log")) continue;
const s = await stat(join(LOG_DIR, nom)).catch(() => null);
if (!s) continue;
const id = nom.slice(0, -4);
resultats.push({
id,
octets: s.size,
modifieLe: new Date(s.mtimeMs).toISOString(),
enCours: captures.has(id),
});
}
} catch {
// répertoire pas encore créé
}
return resultats.sort((a, b) => b.modifieLe.localeCompare(a.modifieLe));
}
+893 -48
View File
File diff suppressed because it is too large. Load diff
+136 -12
View File
@@ -19,6 +19,20 @@
import httpntlm from "httpntlm";
import { WebSocket } from "ws";
import { randomBytes } from "node:crypto";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { buildGridNetworkQlikDateFilter, chunkSerials, type QlikDateFilter } from "@/lib/qlik-date-range";
/** Lit les réglages Qlik depuis data/.db-config.json (configurés dans Paramètres). */
function readQlikFileConfig(): { qlikHost?: string; qlikUser?: string; qlikPassword?: string } {
try {
const raw = readFileSync(join(process.cwd(), "data", ".db-config.json"), "utf-8");
const c = JSON.parse(raw);
return { qlikHost: c.qlikHost, qlikUser: c.qlikUser, qlikPassword: c.qlikPassword };
} catch {
return {};
}
}
// ---------------------------------------------------------------------------
// Config (env) — defauts issus de la discovery
@@ -35,22 +49,31 @@ export interface QlikConfig {
measCaId: string; // master measure "CA N"
measQteId: string; // master measure "Quantite N"
measNbMagId: string; // master measure "Magasin Ventes Nb N"
measCaMagId: string; // master measure "CA par Magasin N"
measMargePctId: string; // master measure "Marge % N"
tlsInsecure: boolean;
timeoutMs: number;
}
export function getQlikConfig(): QlikConfig {
// Priorité : réglages UI (data/.db-config.json) > env > défaut
const f = readQlikFileConfig();
return {
host: process.env.QLIK_HOST ?? "reporting-magasins.lafoirfouille.fr",
user: process.env.QLIK_USER ?? "FFSCH",
password: process.env.QLIK_PWD ?? "",
host: f.qlikHost || process.env.QLIK_HOST || "reporting-magasins.lafoirfouille.fr",
user: f.qlikUser || process.env.QLIK_USER || "FFSCH",
password: f.qlikPassword || process.env.QLIK_PWD || "",
domain: process.env.QLIK_DOMAIN ?? "",
workstation: process.env.QLIK_WORKSTATION ?? "",
appNetwork: process.env.QLIK_APP_NETWORK ?? "9872ee6e-d64a-4b43-984a-076bf1f7f647",
dimCodeArticleId: process.env.QLIK_DIM_CODE_ARTICLE_ID ?? "fcd239e5-288b-4830-a047-0e3d7665d971",
measCaId: process.env.QLIK_MEAS_CA_ID ?? "43a76088-86fa-402e-a80e-0efd7701b3e1",
measQteId: process.env.QLIK_MEAS_QTE_ID ?? "7b40caf1-be4b-4811-8d45-50acde33e715",
measNbMagId: process.env.QLIK_MEAS_NBMAG_ID ?? "8b63fae5-db2f-4e4c-8618-f3e9d60b6b3b",
// qIds engine (master items de l'app "Magasins Vision Consolidée")
dimCodeArticleId: process.env.QLIK_DIM_CODE_ARTICLE_ID ?? "yesCP",
measCaId: process.env.QLIK_MEAS_CA_ID ?? "JhqJ",
measQteId: process.env.QLIK_MEAS_QTE_ID ?? "41516861-5997-4635-8187-3643a2bde422",
measNbMagId: process.env.QLIK_MEAS_NBMAG_ID ?? "yNBLjc",
// Master GUIDs de l'app "Magasins Vision Consolidée" (9872ee6e). Si l'engine
// les refuse (qId court attendu), override via env après discovery.
measCaMagId: process.env.QLIK_MEAS_CAMAG_ID ?? "16778c00-a4e0-41c7-ab1c-179810d78172",
measMargePctId: process.env.QLIK_MEAS_MARGEPCT_ID ?? "ea1e8feb-49ba-4eb3-8db6-72dcfb4c1222",
tlsInsecure: (process.env.QLIK_TLS_INSECURE ?? "true") === "true",
timeoutMs: Number(process.env.QLIK_TIMEOUT_MS ?? "60000"),
};
@@ -61,7 +84,57 @@ export interface NetworkMetric {
caReseau: number;
qteReseau: number;
nbMagasinsReseau: number;
/** CA moyen par magasin (productivité normalisée présence) */
caParMagasinReseau: number;
/** Taux de marge réseau (ratio brut Qlik, ex 0.32 = 32%) */
margePctReseau: number;
periode?: string;
/** Quantité vendue réseau par mois : { "YYYY-MM": qté }. Rempli si sync avec filtre Date. */
qteByMonth?: Record<string, number>;
/**
* Détail mensuel complet : { "YYYY-MM": { qte, ca, nbMag, caMag, margePct } }.
*
* Le cube mensuel Qlik renvoie déjà ces 5 mesures par (Article Code, Mois).
* Lors d'une synchronisation datée validée, les 12 mois complets sont
* toujours présents ; un mois sans fait est explicitement représenté à 0.
*/
metricsByMonth?: Record<string, QlikMonthMetrics>;
/**
* Libellé de l'article côté Qlik. Renseigné uniquement par la recherche
* produit (`qlik-search.ts`), qui interroge la dimension libellé ; la sync
* fournisseur ne le connaît pas et le laisse `undefined`.
*/
libelleReseau?: string;
/** Fournisseur de l'article côté Qlik, même origine que `libelleReseau`. */
fournisseurReseau?: string;
}
/** Les mesures réseau Qlik pour un produit sur un mois donné. */
export interface QlikMonthMetrics {
/** Quantité vendue réseau — toujours présente. */
qte: number;
/** CA réseau du mois. */
ca?: number;
/** Nombre de magasins ayant vendu le produit ce mois-là. */
nbMag?: number;
/** CA moyen par magasin sur le mois (mesure Qlik brute). */
caMag?: number;
/** Taux de marge réseau du mois (ratio brut Qlik, ex 0.32 = 32%). */
margePct?: number;
}
/**
* Produit réseau tel que renvoyé par la **recherche** Qlik (page Recherche réseau).
*
* Différence clé avec `NetworkMetric` : ces produits viennent du catalogue Qlik
* du réseau, pas de notre catalogue local. Ils peuvent donc être totalement
* inconnus de FF Nancy — d'où le libellé, qui n'est pas récupérable en base.
*/
export interface NetworkProduct extends NetworkMetric {
/** Libellé article tel qu'il apparaît dans Qlik (dimension « Article »). */
libelle: string;
fournisseur?: string;
famille?: string;
}
function makeXrfkey(): string {
@@ -153,7 +226,13 @@ function openEngine(appGuid: string, sess: QlikSession, cfg: QlikConfig): Promis
const url = `wss://${cfg.host}/app/${encodeURIComponent(appGuid)}?Xrfkey=${sess.xrfkey}`;
return new Promise((resolve, reject) => {
const ws = new WebSocket(url, {
headers: { Cookie: sess.cookie, "X-Qlik-Xrfkey": sess.xrfkey },
headers: {
Cookie: sess.cookie,
"X-Qlik-Xrfkey": sess.xrfkey,
// Le proxy Qlik valide l'Origin des websockets (whitelist) — un navigateur l'envoie toujours.
Origin: `https://${cfg.host}`,
"User-Agent": "Mozilla/5.0",
},
rejectUnauthorized: !cfg.tlsInsecure,
handshakeTimeout: cfg.timeoutMs,
});
@@ -173,7 +252,14 @@ function openEngine(appGuid: string, sess: QlikSession, cfg: QlikConfig): Promis
else p.resolve(msg.result as Record<string, unknown>);
}
});
ws.on("unexpected-response", (_req, res) => { clearTimeout(timer); reject(new Error(`[qlik] ws HTTP ${res.statusCode}`)); });
ws.on("unexpected-response", (_req, res) => {
clearTimeout(timer);
let body = "";
res.on("data", (d: Buffer) => (body += d.toString()));
res.on("end", () => reject(new Error(
`[qlik] ws HTTP ${res.statusCode} server=${res.headers["server"]} wwwauth=${res.headers["www-authenticate"]} body=${body.slice(0, 160)}`,
)));
});
ws.on("error", (e: Error) => { clearTimeout(timer); reject(e); });
ws.on("close", () => { for (const p of pending.values()) p.reject(new Error("[qlik] ws ferme")); pending.clear(); });
@@ -200,8 +286,10 @@ function hyperCubeDef(cfg: QlikConfig, height: number) {
{ qLibraryId: cfg.measCaId },
{ qLibraryId: cfg.measQteId },
{ qLibraryId: cfg.measNbMagId },
{ qLibraryId: cfg.measCaMagId },
{ qLibraryId: cfg.measMargePctId },
],
qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 4, qHeight: height }],
qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 6, qHeight: height }],
qSuppressZero: false,
qSuppressMissing: true,
},
@@ -213,6 +301,7 @@ type Matrix = Array<Array<{ qText?: string; qNum?: number }>>;
export async function fetchNetworkMetrics(
codeCentraux?: string[],
cfg = getQlikConfig(),
dateFilter: QlikDateFilter | null = buildGridNetworkQlikDateFilter(),
): Promise<Map<string, NetworkMetric>> {
if (!cfg.appNetwork) throw new Error("[qlik] QLIK_APP_NETWORK manquant");
const wanted = codeCentraux ? new Set(codeCentraux) : null;
@@ -224,7 +313,39 @@ export async function fetchNetworkMetrics(
const openRes = await conn.rpc("OpenDoc", { qDocName: cfg.appNetwork });
const docHandle = (((openRes.qReturn as Record<string, unknown>) ?? {}).qHandle as number) ?? 1;
const pageHeight = 2500; // 4 colonnes * 2500 = 10000 cellules max
// Sélection du champ `Date` sur la fenêtre 12 mois complets glissants
// (alignement avec la timeline de la grille). Équivalent à un set
// analysis `{<Date={">=…<=…"}>}` appliqué aux master measures.
let dateFieldHandle: number | null = null;
if (dateFilter) {
const gf = await conn.rpc("GetField", { qFieldName: "Date" }, docHandle);
dateFieldHandle = (((gf.qReturn as Record<string, unknown>) ?? {}).qHandle as number);
if (typeof dateFieldHandle !== "number") {
console.warn(`[qlik] champ Date introuvable, extraction sans filtre date`);
dateFilter = null;
dateFieldHandle = null;
} else {
const batches = chunkSerials(dateFilter.dailySerials, 500);
for (let i = 0; i < batches.length; i++) {
const batch = batches[i];
const sel = await conn.rpc(
"SelectValues",
{
qFieldValues: batch.map((s) => ({ qNum: s, qText: String(s) })),
qToggleMode: false,
qSoftLock: true,
},
dateFieldHandle,
);
console.log(`[qlik] date filter lot ${i + 1}/${batches.length} (${batch.length} serials) — ${dateFilter.label}`);
// Si SelectValues renvoie une erreur qError, on continue quand même :
// l'absence de champ Date typé numérique lèverait une erreur Engine explicite.
void sel;
}
}
}
const pageHeight = 1400; // 6 colonnes * 1400 = 8400 cellules max
const createRes = await conn.rpc("CreateSessionObject", { qProp: hyperCubeDef(cfg, pageHeight) }, docHandle);
const objHandle = (((createRes.qReturn as Record<string, unknown>) ?? {}).qHandle as number);
@@ -232,7 +353,7 @@ export async function fetchNetworkMetrics(
for (;;) {
const dataRes = await conn.rpc(
"GetHyperCubeData",
{ qPath: "/qHyperCubeDef", qPages: [{ qTop: top, qLeft: 0, qWidth: 4, qHeight: pageHeight }] },
{ qPath: "/qHyperCubeDef", qPages: [{ qTop: top, qLeft: 0, qWidth: 6, qHeight: pageHeight }] },
objHandle,
);
const pages = (dataRes.qDataPages as Array<{ qMatrix?: Matrix }>) ?? [];
@@ -248,6 +369,9 @@ export async function fetchNetworkMetrics(
caReseau: Number(row[1]?.qNum ?? 0) || 0,
qteReseau: Number(row[2]?.qNum ?? 0) || 0,
nbMagasinsReseau: Number(row[3]?.qNum ?? 0) || 0,
caParMagasinReseau: Number(row[4]?.qNum ?? 0) || 0,
margePctReseau: Number(row[5]?.qNum ?? 0) || 0,
periode: dateFilter?.label,
});
}
if (matrix.length < pageHeight) break;
+280
View File
@@ -0,0 +1,280 @@
/**
* CollectFlow — Helper de fenêtre temporelle pour Qlik réseau.
*
* Aligne la timeline d'extraction Qlik réseau sur la timeline de la grille :
* - `monthsBack` mois complets glissants (défaut : 12)
* - exclut systématiquement le mois courant
*
* Référence utilisée : `buildLast12MonthsRange()` de `api-ff-client.ts`
* (même source que la grille). On duplique la logique ici (sans dépendance
* Next.js) pour rester testable en CLI pur Node.
*
* Qlik référence les dates par un sérial numérique (jours écoulés depuis
* l'epoch Qlik 1899-12-30). Repères validés :
* 2025-01-01 → 45658
* 2025-06-19 → 45827
* 2026-01-01 → 46023
* 2026-06-19 → 46192
*
* Le filtre côté Qlik s'applique via une sélection de champ `Date` (skill qlik) :
* - valeurs numériques = serials Qlik
* - la sélection par défaut (`{<Date={...}>}`) est héritée par toutes
* les master measures qui référencent le champ `Date` (cas de l'app
* "Magasins Vision Consolidée").
*
* Pourquoi `SelectValues` sur le champ plutôt que set analysis dans `qDef` ?
* - les master items sont référencés par `qLibraryId` (réutilisation sans
* devoir dupliquer l'expression)
* - `qLibraryId` + `qDef` ensemble n'est pas supporté par le moteur QIX
* - sélectionner toutes les dates de la fenêtre via `SelectValues` revient
* fonctionnellement à `{<Date={">=…<=…"}>}` pour les mesures qui touchent
* ce champ, sans dépendre de l'expression interne des master items
*
* Pas de secrets ici. Pure fonction utilitaire testable en CLI Node.
*/
/**
* Bornes acceptées pour `monthsBack`. On reste sur 1..12 pour ne pas casser
* la sémantique "12 mois glissants alignés sur la grille" : une valeur plus
* grande n'apporterait pas de signal réseau supplémentaire (la grille
* n'expose elle-même que ~12 mois). En-deçà de 1 mois, la fenêtre serait
* vide (mois courant exclu) ou incohérente.
*/
export const QLIK_MONTHS_BACK_MIN = 1;
export const QLIK_MONTHS_BACK_MAX = 12;
export const QLIK_MONTHS_BACK_DEFAULT = 12;
/**
* Lit `process.env[name]` et renvoie un entier borné entre `min` et `max`.
* Renvoie `defaultValue` si la variable est absente / vide / non numérique.
*
* Volontairement exportée pour être réutilisée par `qlik-playwright.ts` et
* `sync/route.ts` (cohérence avec `envNumber` côté Playwright, mais en pur
* Node sans dépendance Next.js).
*/
export function envMonthsBack(name: string, defaultValue: number = QLIK_MONTHS_BACK_DEFAULT): number {
const raw = process.env[name];
if (raw == null || raw.trim() === "") return defaultValue;
const parsed = Number(raw);
if (!Number.isFinite(parsed)) return defaultValue;
const clamped = Math.min(QLIK_MONTHS_BACK_MAX, Math.max(QLIK_MONTHS_BACK_MIN, Math.trunc(parsed)));
return clamped;
}
/** Epoch Qlik = 1899-12-30 (équivalent Excel/Lotus 1-2-3). */
const QLIK_EPOCH_MS = Date.UTC(1899, 11, 30); // 1899-12-30T00:00:00Z
/** Repères de validation (cf. skill qlik). */
export const QLIK_DATE_ANCHORS: ReadonlyArray<{ iso: string; serial: number }> = [
{ iso: "2025-01-01", serial: 45658 },
{ iso: "2025-06-19", serial: 45827 },
{ iso: "2026-01-01", serial: 46023 },
{ iso: "2026-06-19", serial: 46192 },
];
/**
* Convertit une date ISO `YYYY-MM-DD` en sérial Qlik numérique.
* Inverse documenté : Qlik epoch = 1899-12-30 (jour 0).
*
* @throws si l'entrée n'est pas au format `YYYY-MM-DD`.
*/
export function isoDateToQlikSerial(isoDate: string): number {
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(isoDate);
if (!m) throw new Error(`isoDateToQlikSerial: format attendu YYYY-MM-DD, reçu "${isoDate}"`);
const y = Number(m[1]);
const mo = Number(m[2]);
const d = Number(m[3]);
const ms = Date.UTC(y, mo - 1, d);
const serial = Math.round((ms - QLIK_EPOCH_MS) / 86_400_000);
return serial;
}
/**
* Convertit un sérial Qlik en date ISO `YYYY-MM-DD` (utile pour tests/diagnostics).
*/
export function qlikSerialToIsoDate(serial: number): string {
const ms = QLIK_EPOCH_MS + serial * 86_400_000;
const d = new Date(ms);
const y = d.getUTCFullYear();
const mo = String(d.getUTCMonth() + 1).padStart(2, "0");
const da = String(d.getUTCDate()).padStart(2, "0");
return `${y}-${mo}-${da}`;
}
export interface QlikDateFilter {
/** Date ISO du 1er jour de la fenêtre (inclus). */
dateDebut: string;
/** Date ISO du dernier jour de la fenêtre (inclus). */
dateFin: string;
/** Sérial Qlik du 1er jour (= `Date >= …`). */
qStart: number;
/** Sérial Qlik du dernier jour (= `Date <= …`). */
qEnd: number;
/** Libellé humain `YYYY-MM_YYYY-MM` pour logs / cache. */
label: string;
/** Liste exhaustive des serials journaliers `[qStart..qEnd]` pour SelectValues. */
dailySerials: number[];
/** Expression set analysis prête à intégrer dans un `qDef` de mesure. */
setAnalysis: string;
}
/**
* Calcule la fenêtre `monthsBack` mois complets (excluant mois courant).
* Par défaut, utilise `now = new Date()` et `monthsBack = 12`.
*
* Logique alignée sur `buildLast12MonthsRange()` de `src/lib/api-ff-client.ts`
* (défaut = 12 mois). Si la source officielle change, mettre à jour ici aussi
* (garder en sync).
*
* Bornes : `monthsBack` est clampé dans `[QLIK_MONTHS_BACK_MIN, QLIK_MONTHS_BACK_MAX]`
* (1..12). Une valeur invalide (NaN, ≤ 0, > 12) est ramenée aux bornes sans
* exception — on veut que la sync reste possible même si l'env est mal
* configuré.
*/
export function buildGridNetworkQlikDateFilter(
now: Date = new Date(),
monthsBack: number = QLIK_MONTHS_BACK_DEFAULT,
): QlikDateFilter {
const months = Math.min(
QLIK_MONTHS_BACK_MAX,
Math.max(QLIK_MONTHS_BACK_MIN, Math.trunc(Number.isFinite(monthsBack) ? monthsBack : QLIK_MONTHS_BACK_DEFAULT)),
);
// Fin : dernier jour du mois précédent le mois courant
const endMonth = new Date(now.getFullYear(), now.getMonth(), 0);
// Début : `months` mois avant le mois courant (1er du mois)
const startMonth = new Date(now.getFullYear(), now.getMonth() - months, 1);
// Ne jamais utiliser `toISOString()` ici : minuit local en Europe/Paris
// devient la veille en UTC et transformait par exemple 2025-07-01 en
// 2025-06-30, soit une extraction de 13 mois. Les bornes sont des dates
// calendaires Qlik, on les formate donc avec les composantes locales.
const localIso = (d: Date): string =>
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
const dateDebut = localIso(startMonth);
const dateFin = localIso(endMonth);
const qStart = isoDateToQlikSerial(dateDebut);
const qEnd = isoDateToQlikSerial(dateFin);
if (qEnd < qStart) {
throw new Error(
`buildGridNetworkQlikDateFilter: fenêtre inversée qEnd=${qEnd} < qStart=${qStart} (dateDebut=${dateDebut} dateFin=${dateFin})`,
);
}
const dailySerials: number[] = [];
for (let s = qStart; s <= qEnd; s++) dailySerials.push(s);
const label = `${dateDebut.slice(0, 7)}_${dateFin.slice(0, 7)}`;
const setAnalysis = `Date={">=${qStart}<=${qEnd}"}`;
return { dateDebut, dateFin, qStart, qEnd, label, dailySerials, setAnalysis };
}
/**
* Découpe les serials en lots pour appels `SelectValues` paginés.
* Par défaut 500 valeurs/appel (largement < limite Qlik ~10k par requête).
*/
export function chunkSerials(serials: number[], batchSize = 500): number[][] {
const out: number[][] = [];
for (let i = 0; i < serials.length; i += batchSize) {
out.push(serials.slice(i, i + batchSize));
}
return out;
}
/**
* Découpe une fenêtre temporelle en sous-fenêtres mensuelles calendaires
* **inclusives et continues**.
*
* But : éviter qu'un seul hypercube Qlik ne doive charger 365 jours × N
* codes d'un coup (provoque `code:15 Request aborted`). Avec cette découpe,
* chaque mois ≈ 28–31 jours × N codes → beaucoup plus léger pour l'Engine.
*
* Règles :
* - Le 1er mois commence à `filter.dateDebut` (peut être un jour quelconque,
* pas forcément le 1er calendaire).
* - Chaque mois intermédiaire est un mois calendaire **complet** (du 1er
* au dernier jour du mois).
* - Le dernier mois se termine à `filter.dateFin` (tronqué si pas fin de mois).
* - Les mois sont contigus : fin d'un mois + 1 = début du suivant.
* - Si `filter.dateDebut` et `filter.dateFin` tombent dans le même mois,
* on renvoie un seul mois (identique à `filter` mais re-calculé).
*
* Chaque mois retourné est un `QlikDateFilter` autonome : `qStart`/`qEnd`,
* `dailySerials` recomptés, `setAnalysis` et `label` au format `YYYY-MM`.
*
* Pré-condition : `dateDebut <= dateFin` au sens calendaire (pas vérifié ici,
* la validation est faite par `buildGridNetworkQlikDateFilter`).
*/
export function getMonthRanges(filter: QlikDateFilter): QlikDateFilter[] {
const start = filter.dateDebut; // YYYY-MM-DD
const end = filter.dateFin; // YYYY-MM-DD
// [yyyy, mm, dd] depuis YYYY-MM-DD (déjà validé par isoDateToQlikSerial).
const sy = Number(start.slice(0, 4));
const sm = Number(start.slice(5, 7));
// const sd = Number(start.slice(8, 10)); // non utilisé directement
const ey = Number(end.slice(0, 4));
const em = Number(end.slice(5, 7));
const ed = Number(end.slice(8, 10));
// Dernier jour du mois (0-ième du mois suivant en UTC local Date).
const lastDayOfMonth = (y: number, m: number): number => {
// m: 1..12 ; on prend le jour 0 du mois suivant, qui donne le dernier jour du mois courant.
return new Date(y, m, 0).getDate();
};
const pad = (n: number): string => String(n).padStart(2, "0");
const out: QlikDateFilter[] = [];
// Itère mois par mois du mois de début au mois de fin (inclus).
let curY = sy;
let curM = sm;
// Sécurité anti-boucle infinie (au cas où).
const MAX_MONTHS = 1200; // 100 ans, large.
let safety = 0;
while (safety++ < MAX_MONTHS) {
const isFirst = curY === sy && curM === sm;
const isLast = curY === ey && curM === em;
// Début du mois courant : si c'est le premier mois, on respecte `dateDebut` ;
// sinon on prend le 1er calendaire.
const monthStartDay = isFirst ? Number(start.slice(8, 10)) : 1;
const monthStartDate = `${curY}-${pad(curM)}-${pad(monthStartDay)}`;
// Fin du mois courant : si c'est le dernier mois, on respecte `dateFin` ;
// sinon on prend le dernier jour calendaire du mois.
const monthEndDay = isLast ? ed : lastDayOfMonth(curY, curM);
const monthEndDate = `${curY}-${pad(curM)}-${pad(monthEndDay)}`;
const qStart = isoDateToQlikSerial(monthStartDate);
const qEnd = isoDateToQlikSerial(monthEndDate);
const dailySerials: number[] = [];
for (let s = qStart; s <= qEnd; s++) dailySerials.push(s);
const label = `${curY}-${pad(curM)}`;
const setAnalysis = `Date={">=${qStart}<=${qEnd}"}`;
out.push({ dateDebut: monthStartDate, dateFin: monthEndDate, qStart, qEnd, label, dailySerials, setAnalysis });
if (isLast) break;
// Avance d'un mois calendaire (gère le passage décembre → janvier).
curM += 1;
if (curM > 12) { curM = 1; curY += 1; }
}
if (out.length === 0) {
// Fenêtre vide : on retourne un seul mois vide aligné sur dateDebut.
const qStart = filter.qStart;
const qEnd = filter.qEnd;
const label = start.slice(0, 7);
const setAnalysis = `Date={">=${qStart}<=${qEnd}"}`;
out.push({ dateDebut: start, dateFin: end, qStart, qEnd, label, dailySerials: [], setAnalysis });
}
return out;
}
+54 -5
View File
@@ -9,12 +9,28 @@ import "server-only";
import { db } from "@/db";
import { qlikNetworkMetrics } from "@/db/schema";
import { inArray, sql } from "drizzle-orm";
import type { NetworkMetric } from "@/lib/qlik-client";
import type { NetworkMetric, QlikMonthMetrics } from "@/lib/qlik-client";
export interface NetworkMetricCached {
caReseau: number;
qteReseau: number;
nbMagasinsReseau: number;
caParMagasinReseau: number;
margePctReseau: number;
/** Quantité réseau par mois : { "YYYY-MM": qté } — null si non synchronisé avec dates. */
qteByMonth: Record<string, number> | null;
/**
* Détail mensuel complet : { "YYYY-MM": { qte, ca, nbMag, caMag, margePct } }.
* null pour les lignes synchronisées avant l'ajout de la colonne — l'UI
* retombe alors sur `qteByMonth`.
*/
metricsByMonth: Record<string, QlikMonthMetrics> | null;
/** Période couverte par l'extraction (ex "2025-03_2026-02"). */
periode: string | null;
/** Libellé de l'article côté Qlik — `null` tant qu'aucune recherche produit ne l'a renseigné. */
libelleReseau: string | null;
/** Fournisseur de l'article côté Qlik, même origine que `libelleReseau`. */
fournisseurReseau: string | null;
fetchedAt: string | null;
}
@@ -26,16 +42,32 @@ export async function getNetworkMetricsByCodeCentrale(
const unique = [...new Set(codes.filter(Boolean))];
if (unique.length === 0) return out;
const rows = await db
.select()
.from(qlikNetworkMetrics)
.where(inArray(qlikNetworkMetrics.codeCentrale, unique));
// Découpage obligatoire : `inArray` produit un paramètre lié par valeur, or le
// protocole PostgreSQL en accepte 65535 au maximum. Un gros fournisseur peut
// dépasser 130 000 codes — la requête échouait alors purement et simplement.
const CHUNK = 2000;
const rows: Array<typeof qlikNetworkMetrics.$inferSelect> = [];
for (let i = 0; i < unique.length; i += CHUNK) {
const batch = unique.slice(i, i + CHUNK);
const part = await db
.select()
.from(qlikNetworkMetrics)
.where(inArray(qlikNetworkMetrics.codeCentrale, batch));
rows.push(...part);
}
for (const r of rows) {
out.set(r.codeCentrale, {
caReseau: Number(r.caReseau ?? 0) || 0,
qteReseau: Number(r.qteReseau ?? 0) || 0,
nbMagasinsReseau: Number(r.nbMagasinsReseau ?? 0) || 0,
caParMagasinReseau: Number(r.caParMagasinReseau ?? 0) || 0,
margePctReseau: Number(r.margePctReseau ?? 0) || 0,
qteByMonth: (r.qteByMonth as Record<string, number> | null) ?? null,
metricsByMonth: (r.metricsByMonth as Record<string, QlikMonthMetrics> | null) ?? null,
periode: r.periode ?? null,
libelleReseau: r.libelleReseau ?? null,
fournisseurReseau: r.fournisseurReseau ?? null,
fetchedAt: r.fetchedAt ? new Date(r.fetchedAt).toISOString() : null,
});
}
@@ -51,7 +83,13 @@ export async function upsertNetworkMetrics(metrics: NetworkMetric[]): Promise<nu
caReseau: String(m.caReseau),
qteReseau: String(m.qteReseau),
nbMagasinsReseau: m.nbMagasinsReseau,
caParMagasinReseau: String(m.caParMagasinReseau),
margePctReseau: String(m.margePctReseau),
periode: m.periode ?? null,
qteByMonth: m.qteByMonth ?? null,
metricsByMonth: m.metricsByMonth ?? null,
libelleReseau: m.libelleReseau ?? null,
fournisseurReseau: m.fournisseurReseau ?? null,
fetchedAt: now,
}));
@@ -69,7 +107,18 @@ export async function upsertNetworkMetrics(metrics: NetworkMetric[]): Promise<nu
caReseau: sql`excluded.ca_reseau`,
qteReseau: sql`excluded.qte_reseau`,
nbMagasinsReseau: sql`excluded.nb_magasins_reseau`,
caParMagasinReseau: sql`excluded.ca_par_magasin_reseau`,
margePctReseau: sql`excluded.marge_pct_reseau`,
periode: sql`excluded.periode`,
// Le détail mensuel n'est produit que par l'extraction complète
// (dimension Mois). La recherche produit, elle, n'a que les
// agrégats : elle ne doit pas effacer un mensuel déjà extrait.
qteByMonth: sql`COALESCE(excluded.qte_by_month, ${qlikNetworkMetrics.qteByMonth})`,
metricsByMonth: sql`COALESCE(excluded.metrics_by_month, ${qlikNetworkMetrics.metricsByMonth})`,
// Seule la recherche produit connaît le libellé / le fournisseur
// réseau : une sync fournisseur ne doit pas les effacer.
libelleReseau: sql`COALESCE(excluded.libelle_reseau, ${qlikNetworkMetrics.libelleReseau})`,
fournisseurReseau: sql`COALESCE(excluded.fournisseur_reseau, ${qlikNetworkMetrics.fournisseurReseau})`,
fetchedAt: sql`excluded.fetched_at`,
},
});
File diff suppressed because it is too large. Load diff
+664
View File
@@ -0,0 +1,664 @@
/**
* CollectFlow — Recherche produit **dans Qlik Sense** (source primaire).
*
* La recherche produit part du réseau, pas du catalogue Nancy : on interroge
* d'abord l'app Qlik « Magasins Vision Consolidée » pour trouver les articles
* qui correspondent au terme saisi (libellé ou code centrale), puis l'appelant
* rapproche les codes centraux trouvés de la base FF Nancy.
*
* Pourquoi Qlik d'abord : le réseau (~270 magasins) référence beaucoup plus de
* produits que Nancy. Chercher dans la base locale d'abord masquait tous les
* produits que le réseau travaille et que nous ne référençons pas — c'est
* précisément ce qu'on veut voir.
*
* Technique : même plomberie que `qlik-playwright.ts` (Chromium headless +
* websocket Engine in-page avec le qlik-csrf-token), car le proxy Qlik refuse
* un websocket « raw » côté serveur.
*
* ## Comment la sélection est construite
*
* La recherche d'un list object Qlik (`SearchListObjectFor`) applique un **OU**
* entre les mots : « tapis anti » a ramené 17 696 libellés sur l'app FF, dont
* l'immense majorité ne contient que « anti ». Sélectionner ces 17 696 valeurs
* faisait ensuite exploser le cube (`code 15 — Request aborted`).
*
* On procède donc en trois temps :
* 1. une recherche par mot, pour retenir le **plus sélectif** (celui qui
* ramène le moins de valeurs) ;
* 2. lecture de ces valeurs et filtrage **ET** côté client (tous les mots
* présents, insensible casse/accents) ;
* 3. sélection des seules valeurs retenues, **par numéro d'élément**
* (`SelectListObjectValues`) — exact, sans ré-appariement de texte.
*
* Le résultat final est revérifié une dernière fois avant d'être renvoyé : une
* liste sans rapport avec la recherche est pire que pas de résultat du tout.
*/
import "server-only";
import { chromium, type Browser } from "playwright-core";
import { getQlikConfig, qlikNtlmSession, type QlikConfig } from "@/lib/qlik-client";
import { buildGridNetworkQlikDateFilter, QLIK_MONTHS_BACK_DEFAULT } from "@/lib/qlik-date-range";
/**
* Un article trouvé côté Qlik, **avec ses mesures réseau sur 12 mois glissants**.
*
* Les mesures sortent du même cube que l'identité : une seule session Qlik pour
* toute la recherche. Les extraire dans une seconde session concurrente faisait
* abandonner le moteur (`code 15 — Request aborted`).
*/
export interface QlikArticleMatch {
/** Valeur de la dimension « Article Code » — clé de jointure de la Grille. */
codeCentrale: string;
/**
* Champ `article_no_centrale` quand l'app l'expose : second candidat pour la
* jointure avec `articles.artcentrale`, les deux ne portant pas toujours le
* même format.
*/
codeCentraleAlt: string;
/** Libellé réseau, si l'app expose un champ libellé article. */
libelle: string;
/** Fournisseur réseau, si l'app expose un champ fournisseur. */
fournisseur: string;
/** CA réseau sur la fenêtre 12 mois glissants. */
caReseau: number;
qteReseau: number;
nbMagasinsReseau: number;
caParMagasinReseau: number;
/** Taux de marge réseau, ratio brut Qlik (0.32 = 32 %). */
margePctReseau: number;
}
export interface QlikSearchOutcome {
matches: QlikArticleMatch[];
/** Champ Qlik effectivement interrogé (diagnostic UI). */
champUtilise: string | null;
/** true si Qlik a renvoyé plus de résultats que la limite demandée. */
tronque: boolean;
/**
* Message de diagnostic quand la recherche aboutit à zéro article alors que
* Qlik a bien répondu (filtre non appliqué, terme trop large…). `null` en
* fonctionnement nominal.
*/
avertissement: string | null;
/** Fenêtre couverte par les mesures (`YYYY-MM_YYYY-MM`). */
periode: string | null;
}
/**
* Nombre maximum d'articles renvoyés par une recherche.
*
* Chaque code trouvé est ensuite ré-extrait avec son détail mensuel : au-delà
* de quelques dizaines, la seconde passe devient longue et le résultat
* illisible. L'utilisateur affine son terme.
*/
export const QLIK_SEARCH_MAX_RESULTS = 40;
/**
* Champs candidats pour le libellé article, par ordre de préférence.
*
* Sur l'app FF, `Article` est le libellé métier et `article_libelle_ticket` le
* libellé (tronqué) imprimé sur le ticket. On essaie le premier qui donne des
* résultats : si un candidat ne matche rien, on passe au suivant.
*/
const CHAMPS_LIBELLE_PREFERES = ["Article", "article_libelle_ticket"];
/** Champs candidats pour le fournisseur, par ordre de préférence (nom avant code). */
const CHAMPS_FOURNISSEUR_PREFERES = ["Fournisseur", "code_fournisseur"];
/**
* Champ portant le code centrale « brut » de l'article, quand il existe.
* Second candidat de jointure avec `articles.artcentrale` : sur l'app FF, la
* dimension « Article Code » ne renvoie pas le même format.
*/
const CHAMP_CODE_CENTRALE_ALT = "article_no_centrale";
let browserPromise: Promise<Browser> | null = null;
async function getBrowser(): Promise<Browser> {
if (!browserPromise) {
const execPath = process.env.PLAYWRIGHT_CHROMIUM_PATH;
browserPromise = chromium.launch({
headless: true,
executablePath: execPath || undefined,
args: ["--no-sandbox", "--disable-dev-shm-usage", "--ignore-certificate-errors"],
}).catch((e) => { browserPromise = null; throw e; });
}
return browserPromise;
}
interface InPageSearchResult {
ok: boolean;
error?: string;
/** [code, codeAlt, libellé, fournisseur, ca, qte, nbMag, caMag, margePct] */
rows?: Array<[string, string, string, string, number, number, number, number, number]>;
champUtilise?: string | null;
/** Nombre d'articles uniques correspondant au terme (avant plafonnement). */
total?: number;
/** Lignes brutes lues dans le cube (avant garde-fou). */
lues?: number;
/** Lignes écartées parce qu'elles ne correspondaient pas au terme. */
rejetees?: number;
/** true si le terme ramenait trop de valeurs pour être traité intégralement. */
tropLarge?: boolean;
/** Journal compact des étapes, remonté dans les logs serveur. */
trace?: string[];
}
/**
* Cherche dans Qlik les articles correspondant à `term`.
*
* Ne fait **aucune** mesure : on veut juste la liste des codes centraux (plus
* libellé / fournisseur si l'app les expose). Les métriques réseau sont
* ensuite extraites par `fetchNetworkMetricsPlaywright`, qui sait déjà produire
* le détail mensuel sur 12 mois glissants.
*/
export async function searchQlikArticles(
term: string,
limit: number = QLIK_SEARCH_MAX_RESULTS,
cfg: QlikConfig = getQlikConfig(),
): Promise<QlikSearchOutcome> {
const cleaned = term.trim();
if (cleaned.length < 3) return { matches: [], champUtilise: null, tronque: false, avertissement: null, periode: null };
if (!cfg.appNetwork) throw new Error("[qlik-search] QLIK_APP_NETWORK manquant");
if (!cfg.user || !cfg.password) throw new Error("[qlik-search] identifiants Qlik manquants");
const started = Date.now();
// Latence laissée à l'Engine après chaque mutation de sélection. Même réglage
// que l'extraction (`QLIK_SETTLE_MS`) : sans elle, l'objet créé juste après
// est calculé sur l'état de sélection précédent.
const settleMs = Math.min(2000, Math.max(0, Number(process.env.QLIK_SETTLE_MS ?? "250") || 250));
// Même fenêtre que la Grille : 12 mois complets glissants, mois courant exclu.
const dateFilter = buildGridNetworkQlikDateFilter(
new Date(),
QLIK_MONTHS_BACK_DEFAULT,
);
const sess = await qlikNtlmSession(cfg);
const [cookieName, ...rest] = sess.cookie.split("=");
const cookieValue = rest.join("=");
const browser = await getBrowser();
const ctx = await browser.newContext({
httpCredentials: { username: cfg.user, password: cfg.password, origin: `https://${cfg.host}` },
ignoreHTTPSErrors: true,
});
await ctx.addCookies([{ name: cookieName, value: cookieValue, domain: cfg.host, path: "/", secure: true }]);
try {
const page = await ctx.newPage();
page.on("console", (msg) => {
// Le client Qlik est très bavard au chargement (thèmes, extensions) :
// on ne relaie que ce qui vient de notre script.
const t = msg.text();
if (t.startsWith("[cf]")) console.log(`[qlik-search][page] ${t}`);
});
let csrfToken: string | null = null;
let resolveToken: (() => void) | null = null;
const tokenReady = new Promise<void>((r) => { resolveToken = r; });
page.on("websocket", (ws) => {
const m = ws.url().match(/qlik-csrf-token=([^&]+)/);
if (m && !csrfToken) { csrfToken = decodeURIComponent(m[1]); resolveToken?.(); }
});
await page.goto(`https://${cfg.host}/sense/app/${cfg.appNetwork}`, { waitUntil: "domcontentloaded", timeout: cfg.timeoutMs })
.catch(() => { /* le client Qlik ouvre son ws juste après */ });
await Promise.race([tokenReady, page.waitForTimeout(15000)]);
if (!csrfToken) throw new Error("[qlik-search] qlik-csrf-token introuvable (client Qlik non chargé ?)");
const result = (await page.evaluate(
({ app, dim, token, terme, limite, settleMs, serialsDate, mCa, mQte, mNbMag, mCaMag, mMarge,
champCodeAlt, champsLibellePreferes, champsFournisseurPreferes, champLibelleForce, champFournisseurForce }: {
app: string; dim: string; token: string; terme: string; limite: number; settleMs: number;
serialsDate: number[];
mCa: string; mQte: string; mNbMag: string; mCaMag: string; mMarge: string;
champCodeAlt: string;
champsLibellePreferes: string[]; champsFournisseurPreferes: string[];
champLibelleForce: string; champFournisseurForce: string;
}) =>
new Promise<InPageSearchResult>((resolve) => {
/** Valeurs lues au maximum dans le champ recherché (2 pages Engine). */
const MAX_VALEURS_LUES = 10000;
/**
* Valeurs sélectionnées au maximum.
*
* Mesuré en production : sélectionner les 13 446 libellés contenant
* « tapis » a mis 81 s puis 486 s, et faisait abandonner le cube
* (code 15). Le cube étant trié par quantité réseau décroissante et
* l'affichage plafonné à quelques dizaines de lignes, 300 libellés
* suffisent largement.
*/
const MAX_VALEURS_SELECTION = 300;
const PAGE_VALEURS = 5000;
const loc = (window as unknown as { location: Location }).location;
const url = `wss://${loc.host}/app/${app}?reloadUri=${encodeURIComponent(loc.href)}&qlik-csrf-token=${token}`;
const ws = new WebSocket(url);
const trace: string[] = [];
let id = 0;
const pend = new Map<number, { res: (v: { [k: string]: unknown }) => void; rej: (e: unknown) => void }>();
const rpc = (method: string, params: unknown, handle = -1) => new Promise<{ [k: string]: unknown }>((res, rej) => {
const i = ++id; pend.set(i, { res, rej });
ws.send(JSON.stringify({ jsonrpc: "2.0", id: i, handle, method, params }));
});
const ready = new Promise<void>((res, rej) => {
ws.onmessage = (ev: MessageEvent) => {
const m = JSON.parse(ev.data as string);
if (m.method === "OnConnected") return res();
if (m.id && pend.has(m.id)) {
const x = pend.get(m.id)!;
pend.delete(m.id);
if (m.error) x.rej(new Error(JSON.stringify(m.error)));
else x.res(m.result);
}
};
ws.onerror = () => rej(new Error("ws error (403 ?)"));
setTimeout(() => rej(new Error("ws timeout")), 30000);
});
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
/** Qlik Engine code 15 = « Request aborted » : requête entrée en collision avec une évaluation. */
const estCode15 = (e: unknown) => /"code"\s*:\s*15\b/.test(String((e as Error)?.message ?? e));
const rpcRetry = async (method: string, params: unknown, handle: number, tentatives = 3) => {
let derniere: unknown;
for (let i = 1; i <= tentatives; i++) {
try {
return await rpc(method, params, handle);
} catch (e) {
derniere = e;
if (!estCode15(e) || i === tentatives) throw e;
trace.push("code15 sur " + method + ", tentative " + i + "/" + tentatives);
await sleep(settleMs * (i + 1));
}
}
throw derniere;
};
const normaliser = (s: string) => s
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-z0-9]+/g, " ")
.trim();
const mots = normaliser(terme).split(" ").filter(Boolean);
(async () => {
try {
await ready;
const open = await rpc("OpenDoc", { qDocName: app });
const doc = (open.qReturn as { qHandle: number }).qHandle;
// ─── 1. Inventaire des champs ────────────────────────────
const fl = await rpc("CreateSessionObject", {
qProp: {
qInfo: { qType: "FieldList" },
qFieldListDef: { qShowSystem: false, qShowHidden: false, qShowSemantic: true, qShowSrcTables: true },
},
}, doc);
const fll = await rpc("GetLayout", {}, (fl.qReturn as { qHandle: number }).qHandle);
const champs = ((((fll.qLayout as { qFieldList?: { qItems?: Array<{ qName: string }> } }).qFieldList?.qItems) ?? [])
.map((f) => f.qName));
const has = (nom: string, re: RegExp) => re.test(nom);
const champCode = champs.find((c) => c === "Article Code")
?? champs.find((c) => has(c, /article/i) && has(c, /code/i))
?? "Article Code";
// Candidats libellé : surcharge d'env, puis noms connus de
// l'app FF, puis heuristique. On essaiera chacun dans l'ordre
// jusqu'à en trouver un qui ramène des valeurs.
const candidatsLibelle = [
champLibelleForce,
...champsLibellePreferes,
...champs.filter((c) => has(c, /article/i) && has(c, /(libell|designation|désignation|denomination|dénomination)/i)),
...champs.filter((c) => has(c, /(libell|designation|désignation)/i) && !has(c, /(magasin|region|région|famille|rayon|fournisseur)/i)),
].filter((c, i, arr) => c && champs.includes(c) && arr.indexOf(c) === i);
const champFournisseur = [
champFournisseurForce,
...champsFournisseurPreferes,
...champs.filter((c) => has(c, /fournisseur/i)),
...champs.filter((c) => has(c, /(fabricant|supplier)/i)),
].find((c) => c && champs.includes(c)) ?? null;
trace.push("code=" + champCode + " libellé candidats=" + JSON.stringify(candidatsLibelle) + " fournisseur=" + champFournisseur);
// ─── 2. Recherche + sélection sur le champ le plus adapté ─
//
// `SearchListObjectFor` applique un OU entre les mots. On
// cherche donc mot par mot pour retenir le plus sélectif,
// puis on filtre en ET côté client avant de sélectionner.
const chercherEtSelectionner = async (champ: string): Promise<{ retenues: number; brut: number; tropLarge: boolean }> => {
const lo = await rpcRetry("CreateSessionObject", {
qProp: {
qInfo: { qType: "cf-search-lb" },
qListObjectDef: {
qDef: { qFieldDefs: [champ] },
qInitialDataFetch: [],
},
},
}, doc);
const ret = lo.qReturn as { qHandle: number; qGenericId?: string; qId?: string };
const loId = String(ret.qGenericId ?? ret.qId ?? "");
const lh = ret.qHandle;
try {
// 2a. Mot le plus sélectif (une recherche par mot, on ne lit que le compteur).
let meilleurMot = terme;
let meilleurNb = Number.POSITIVE_INFINITY;
for (const mot of (mots.length > 1 ? mots : [terme])) {
await rpcRetry("SearchListObjectFor", { qPath: "/qListObjectDef", qMatch: mot }, lh);
const lay = await rpcRetry("GetLayout", {}, lh);
const nb = ((lay.qLayout as { qListObject?: { qSize?: { qcy?: number } } }).qListObject?.qSize?.qcy) ?? 0;
trace.push(" « " + mot + " » dans " + champ + " → " + nb + " valeur(s)");
if (nb > 0 && nb < meilleurNb) { meilleurNb = nb; meilleurMot = mot; }
}
if (!Number.isFinite(meilleurNb) || meilleurNb === 0) return { retenues: 0, brut: 0, tropLarge: false };
// 2b. Relecture des valeurs du mot retenu. On relance la
// recherche systématiquement : la boucle ci-dessus a pu
// laisser le list object sur un autre mot.
await rpcRetry("SearchListObjectFor", { qPath: "/qListObjectDef", qMatch: meilleurMot }, lh);
const aLire = Math.min(meilleurNb, MAX_VALEURS_LUES);
const elems: number[] = [];
let lues = 0;
for (let top = 0; top < aLire; top += PAGE_VALEURS) {
const d = await rpcRetry("GetListObjectData", {
qPath: "/qListObjectDef",
qPages: [{ qTop: top, qLeft: 0, qWidth: 1, qHeight: Math.min(PAGE_VALEURS, aLire - top) }],
}, lh);
const matrix = ((d.qDataPages as Array<{ qMatrix?: Array<Array<{ qText?: string; qElemNumber?: number }>> }>)?.[0]?.qMatrix) ?? [];
if (matrix.length === 0) break;
lues += matrix.length;
for (const ligne of matrix) {
const cell = ligne[0];
const texte = String(cell?.qText ?? "");
const elem = cell?.qElemNumber;
if (elem == null || elem < 0) continue;
// Filtre ET : tous les mots du terme présents.
const n = normaliser(texte);
if (!mots.every((m) => n.includes(m))) continue;
elems.push(elem);
if (elems.length >= MAX_VALEURS_SELECTION) break;
}
if (elems.length >= MAX_VALEURS_SELECTION) break;
}
trace.push(" mot retenu « " + meilleurMot + " » : " + lues + " valeur(s) lues → " + elems.length + " après filtre ET");
if (elems.length === 0) {
return { retenues: 0, brut: meilleurNb, tropLarge: meilleurNb > MAX_VALEURS_LUES };
}
// 2c. Sélection par numéro d'élément : exact, aucun
// ré-appariement de texte possible.
await rpcRetry("SelectListObjectValues", {
qPath: "/qListObjectDef",
qValues: elems,
qToggleMode: false,
qSoftLock: true,
}, lh);
await sleep(settleMs);
return { retenues: elems.length, brut: meilleurNb, tropLarge: meilleurNb > MAX_VALEURS_LUES };
} finally {
if (loId) { try { await rpc("DestroySessionObject", { qId: loId }, doc); } catch { /* noop */ } }
}
};
await rpc("ClearAll", { qLockedAlso: false }, doc);
await sleep(settleMs);
let champChoisi: string | null = null;
let tropLarge = false;
for (const candidat of candidatsLibelle) {
const r = await chercherEtSelectionner(candidat);
tropLarge = tropLarge || r.tropLarge;
if (r.retenues > 0) { champChoisi = candidat; break; }
await rpc("ClearAll", { qLockedAlso: false }, doc);
await sleep(settleMs);
}
if (!champChoisi) {
// Repli code centrale (terme saisi = un code).
const r = await chercherEtSelectionner(champCode);
if (r.retenues > 0) champChoisi = champCode;
tropLarge = tropLarge || r.tropLarge;
}
if (!champChoisi) {
ws.close();
return resolve({ ok: true, rows: [], champUtilise: null, total: 0, lues: 0, rejetees: 0, tropLarge, trace });
}
trace.push("champ retenu = " + champChoisi);
// Trace des sélections réellement actives : si elle est vide,
// la sélection n'a pas pris côté Engine.
try {
const cs = await rpc("CreateSessionObject", {
qProp: { qInfo: { qType: "cf-search-sel" }, qSelectionObjectDef: {} },
}, doc);
const csl = await rpc("GetLayout", {}, (cs.qReturn as { qHandle: number }).qHandle);
const sel = (((csl.qLayout as { qSelectionObject?: { qSelections?: Array<{ qField?: string; qSelectedCount?: number; qTotal?: number }> } })
.qSelectionObject?.qSelections) ?? [])
.map((s) => `${s.qField}:${s.qSelectedCount}/${s.qTotal}`);
trace.push("sélections actives=" + JSON.stringify(sel));
} catch (e) {
trace.push("sélections actives: lecture impossible (" + String((e as Error)?.message || e) + ")");
}
// ─── 3. Fenêtre 12 mois glissants ────────────────────────
// Sélectionnée APRÈS les libellés : restreindre les dates avant
// la recherche masquerait les articles non vendus sur la période.
if (serialsDate.length > 0) {
try {
const gd = await rpc("GetField", { qFieldName: "Date" }, doc);
const dh = (gd.qReturn as { qHandle: number }).qHandle;
for (let i = 0; i < serialsDate.length; i += 500) {
const lot = serialsDate.slice(i, i + 500);
await rpcRetry("SelectValues", {
qFieldValues: lot.map((v) => ({ qNum: v, qText: String(v) })),
qToggleMode: i > 0,
qSoftLock: true,
}, dh);
}
await sleep(settleMs);
trace.push("fenêtre Date sélectionnée (" + serialsDate.length + " jours)");
} catch (e) {
trace.push("sélection Date impossible, mesures sur toute la période : " + String((e as Error)?.message || e));
}
}
// ─── 4. Identité + mesures réseau, dans UN SEUL cube ─────
//
// Les mesures sont dans le même cube que l'identité, et donc
// dans la même session Qlik : une seconde session concurrente
// pour les extraire faisait abandonner le moteur (code 15).
//
// Les master measures sont résolues par TITRE : le qLibraryId
// fiable est `qInfo.qId`, qui n'est pas le GUID du QRS — même
// approche que l'extraction de la Grille.
const norm = (x: string) => x.replace(/\s+/g, " ").trim().toLowerCase();
const ml = await rpcRetry("CreateSessionObject", {
qProp: { qInfo: { qType: "MeasureList" }, qMeasureListDef: { qType: "measure", qData: { title: "/qMetaDef/title" } } },
}, doc);
const mll = await rpcRetry("GetLayout", {}, (ml.qReturn as { qHandle: number }).qHandle);
const items = (((mll.qLayout as { qMeasureList?: { qItems?: Array<{ qInfo: { qId: string }; qMeta?: { title?: string }; qData?: { title?: string } }> } }).qMeasureList?.qItems) ?? []);
const parTitre = new Map<string, string>();
for (const it of items) {
const t = it.qMeta?.title ?? it.qData?.title ?? "";
if (t) parTitre.set(norm(t), it.qInfo.qId);
}
const mesure = (titre: string, repli: string) => parTitre.get(norm(titre)) ?? repli;
const idCa = mesure("CA N", mCa);
const idQte = mesure("Quantité N", mQte);
const idNbMag = mesure("Magasin Ventes Nb N", mNbMag);
const idCaMag = mesure("CA par Magasin N", mCaMag);
const idMarge = mesure("Marge % N", mMarge);
const champLibelleAffiche = champChoisi === champCode ? candidatsLibelle[0] ?? null : champChoisi;
const champAlt = champs.includes(champCodeAlt) ? champCodeAlt : null;
const dims: Array<{ qLibraryId?: string; qDef?: { qFieldDefs: string[] } }> = [{ qLibraryId: dim }];
const idxAlt = champAlt ? dims.push({ qDef: { qFieldDefs: [champAlt] } }) - 1 : -1;
const idxLibelle = champLibelleAffiche ? dims.push({ qDef: { qFieldDefs: [champLibelleAffiche] } }) - 1 : -1;
const idxFournisseur = champFournisseur ? dims.push({ qDef: { qFieldDefs: [champFournisseur] } }) - 1 : -1;
const nbDims = dims.length;
// Colonnes mesures, dans l'ordre : CA, Qté, NbMag, CA/mag, Marge %.
const idxQteCol = nbDims + 1;
const largeur = nbDims + 5;
// Cube trié par quantité réseau décroissante : la première page
// contient donc directement les meilleures ventes du réseau, et
// on n'a pas besoin de lire tout l'ensemble sélectionné.
const hauteur = Math.max(50, Math.min(400, Math.floor(9000 / largeur)));
const defCube = (trie: boolean) => ({
qProp: {
qInfo: { qType: "cf-search" },
qHyperCubeDef: {
qDimensions: dims.map((d) => ({ ...d, qNullSuppression: true })),
qMeasures: [
{ qLibraryId: idCa },
trie ? { qLibraryId: idQte, qSortBy: { qSortByNumeric: -1 } } : { qLibraryId: idQte },
{ qLibraryId: idNbMag },
{ qLibraryId: idCaMag },
{ qLibraryId: idMarge },
],
...(trie ? { qInterColumnSortOrder: [idxQteCol, 0] } : {}),
qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: largeur, qHeight: hauteur }],
qSuppressZero: false,
qSuppressMissing: true,
},
},
});
let cube: { [k: string]: unknown };
try {
cube = await rpcRetry("CreateSessionObject", defCube(true), doc);
} catch (e) {
// Le tri par mesure est un confort (meilleures ventes en
// tête) : si l'Engine refuse cette forme, on repart sans.
if (estCode15(e)) throw e;
trace.push("tri par quantité refusé, cube non trié : " + String((e as Error)?.message || e));
cube = await rpcRetry("CreateSessionObject", defCube(false), doc);
}
const ch = (cube.qReturn as { qHandle: number }).qHandle;
const layout = await rpcRetry("GetLayout", {}, ch);
const hc = (layout.qLayout as {
qHyperCube: {
qSize: { qcy: number };
qDataPages?: Array<{ qMatrix?: Array<Array<{ qText?: string; qNum?: number }>> }>;
};
}).qHyperCube;
const matrix = hc.qDataPages?.[0]?.qMatrix ?? [];
// ─── 4. Garde-fou : le résultat DOIT correspondre au terme ──
const termeCode = terme.trim().toLowerCase();
const correspond = (code: string, libelle: string): boolean => {
if (mots.length === 0) return true;
const c = code.toLowerCase();
if (c === termeCode || c.startsWith(termeCode)) return true;
if (!libelle) return champChoisi === champCode;
const l = normaliser(libelle);
return mots.every((m) => l.includes(m));
};
type Ligne = [string, string, string, string, number, number, number, number, number];
const texte = (r: Array<{ qText?: string }>, i: number) => {
if (i < 0) return "";
const v = String(r[i]?.qText ?? "").trim();
return v === "-" ? "" : v;
};
const parCode = new Map<string, Ligne>();
let rejetees = 0;
for (const r of matrix) {
const code = String(r[0]?.qText ?? "").trim();
if (!code || code === "-") continue;
const codeAlt = texte(r, idxAlt);
const libelle = texte(r, idxLibelle);
const fournisseur = texte(r, idxFournisseur);
if (!correspond(code, libelle)) { rejetees++; continue; }
const nombre = (i: number) => Number(r[nbDims + i]?.qNum) || 0;
const ligne: Ligne = [
code, codeAlt, libelle, fournisseur,
nombre(0), nombre(1), nombre(2), nombre(3), nombre(4),
];
const existant = parCode.get(code);
if (!existant) { parCode.set(code, ligne); continue; }
// Même article chez plusieurs fournisseurs : une seule ligne.
// Les mesures sont identiques (elles ne dépendent pas de la
// dimension fournisseur), on complète juste les libellés.
if (!existant[1] && codeAlt) existant[1] = codeAlt;
if (!existant[2] && libelle) existant[2] = libelle;
if (!existant[3] && fournisseur) existant[3] = fournisseur;
}
const uniques = [...parCode.values()];
const rows = uniques.slice(0, limite);
trace.push(
"cube=" + hc.qSize.qcy + " lignes lues=" + matrix.length +
" rejetées(hors terme)=" + rejetees +
" articles uniques=" + uniques.length + " retenus=" + rows.length,
);
if (rows.length > 0) {
trace.push("échantillon=" + JSON.stringify(rows.slice(0, 3)));
}
ws.close();
resolve({
ok: true, rows, champUtilise: champChoisi, total: uniques.length,
lues: matrix.length, rejetees, tropLarge, trace,
});
} catch (e) {
try { ws.close(); } catch { /* noop */ }
resolve({ ok: false, error: String((e as Error)?.message || e), trace });
}
})();
}),
{
app: cfg.appNetwork,
dim: cfg.dimCodeArticleId,
token: csrfToken,
terme: cleaned,
limite: limit,
settleMs,
serialsDate: dateFilter.dailySerials,
mCa: cfg.measCaId,
mQte: cfg.measQteId,
mNbMag: cfg.measNbMagId,
mCaMag: cfg.measCaMagId,
mMarge: cfg.measMargePctId,
champCodeAlt: CHAMP_CODE_CENTRALE_ALT,
champsLibellePreferes: CHAMPS_LIBELLE_PREFERES,
champsFournisseurPreferes: CHAMPS_FOURNISSEUR_PREFERES,
champLibelleForce: (process.env.QLIK_FIELD_ARTICLE_LIBELLE ?? "").trim(),
champFournisseurForce: (process.env.QLIK_FIELD_FOURNISSEUR ?? "").trim(),
},
)) as InPageSearchResult;
for (const t of result.trace ?? []) console.log(`[qlik-search] ${t}`);
if (!result.ok) throw new Error(`[qlik-search] ${result.error}`);
const matches: QlikArticleMatch[] = (result.rows ?? []).map(
([codeCentrale, codeCentraleAlt, libelle, fournisseur, caReseau, qteReseau, nbMagasinsReseau, caParMagasinReseau, margePctReseau]) => ({
codeCentrale, codeCentraleAlt, libelle, fournisseur,
caReseau, qteReseau, nbMagasinsReseau, caParMagasinReseau, margePctReseau,
}),
);
console.log(
`[qlik-search] "${cleaned}" → ${matches.length} article(s) (total ${result.total ?? 0}, champ=${result.champUtilise}) en ${Date.now() - started} ms`,
);
// Zéro article alors que Qlik a répondu : on explique pourquoi plutôt que
// d'afficher un « aucun résultat » qui laisserait croire à une absence
// réelle dans le réseau.
let avertissement: string | null = null;
if (matches.length === 0 && (result.rejetees ?? 0) > 0) {
avertissement = "Qlik a répondu sans appliquer le filtre de recherche : les articles renvoyés ne correspondaient pas au terme et ont été écartés. Voir les logs [qlik-search].";
} else if (matches.length === 0 && result.tropLarge) {
avertissement = "Le terme est trop large pour Qlik (plus de 10 000 libellés correspondants). Ajoutez un mot plus précis.";
}
if (avertissement) console.warn(`[qlik-search] ${avertissement}`);
return {
matches,
champUtilise: result.champUtilise ?? null,
tronque: (result.total ?? 0) > matches.length,
avertissement,
periode: dateFilter.label,
};
} finally {
await ctx.close();
}
}
+26
View File
@@ -0,0 +1,26 @@
/**
* CollectFlow — Détection des Server Actions périmées après un déploiement.
*
* Next.js identifie chaque Server Action par un hash calculé **à la compilation**.
* Après un nouveau build (déploiement, redémarrage du conteneur), les hashs
* changent : un onglet resté ouvert continue d'envoyer les anciens identifiants,
* et le serveur répond
*
* Server Action "4010b9…" was not found on the server.
*
* Ce n'est pas une panne applicative : c'est le client qui est périmé. La seule
* issue est de recharger la page pour récupérer le bundle courant.
*
* Les brouillons de la Grille sont persistés dans le store zustand
* (localStorage) : recharger ne perd pas le travail en cours.
*/
/** Message affiché à l'utilisateur quand son onglet est périmé. */
export const STALE_ACTION_MESSAGE =
"L'application a été mise à jour depuis l'ouverture de cet onglet. Rechargez la page pour continuer — vos modifications en cours sont conservées.";
/** true si l'erreur vient d'une Server Action absente du serveur (bundle périmé). */
export function isStaleServerActionError(err: unknown): boolean {
const message = err instanceof Error ? err.message : String(err ?? "");
return /server action/i.test(message) && /not\s+be?\s*found|was not found|failed-to-find-server-action/i.test(message);
}
+7
View File
@@ -10,10 +10,17 @@ export default auth((req) => {
const isApiAuthRoute = nextUrl.pathname.startsWith("/api/auth");
const isPublicRoute = nextUrl.pathname === "/login" || nextUrl.pathname.startsWith("/public/");
// L'API publique s'authentifie elle-même (clé d'API ou session) et rend ses
// propres erreurs JSON. Sans cette exemption, un script non authentifié
// recevrait une redirection 302 + la page HTML de login au lieu d'un 401.
const isPublicApiRoute = nextUrl.pathname.startsWith("/api/v1");
// 1. Laisser passer les requêtes d'auth API
if (isApiAuthRoute) return NextResponse.next();
// 1 bis. Laisser passer /api/v1 — l'authentification est faite dans les handlers
if (isPublicApiRoute) return NextResponse.next();
// 2. Rediriger vers /login si non connecté et route non publique
if (!isLoggedIn && !isPublicRoute) {
return NextResponse.redirect(new URL("/login", nextUrl));
+36 -2
View File
@@ -89,7 +89,21 @@ export interface ProductRow {
/** Données stock & approvisionnement (API FF Nancy) */
pcb?: number; // Conditionnement (pack size)
prixAchat?: number; // PA / PRMP
prixVente?: number; // PV central
/**
* Prix de vente pratiqué, en euros.
*
* Source par ordre de préférence : `cube_pv` (prix de vente par article et
* par site, pendant de `cube_pa` pour l'achat), puis le PV porté par
* `cube_stock`, puis `article_infosup.prix_vente_mini` — cette dernière
* n'est qu'une borne de paramétrage, vide en pratique.
*
* Quand les magasins ne pratiquent pas le même prix, cette valeur est la
* plus élevée : `prixVenteByStore` porte le détail, et l'affichage signale
* la divergence plutôt que d'inventer une moyenne que personne n'encaisse.
*/
prixVente?: number;
/** Prix de vente par magasin (site → PV) — `cube_pv`. */
prixVenteByStore?: Record<string, number>;
stockActuel?: number; // Stock dispo agrégé tous sites
stockTotal?: number; // Qte en stock agrégé
stockValeur?: number; // Valeur stock agrégée
@@ -113,8 +127,22 @@ export interface ProductRow {
caReseau?: number;
qteReseau?: number;
nbMagasinsReseau?: number;
/** CA moyen par magasin réseau (productivité normalisée présence) */
caParMagasinReseau?: number;
/** Taux de marge réseau (ratio brut Qlik, ex 0.32 = 32%) */
margePctReseau?: number;
/** Taux de présence réseau = nbMagasinsReseau / 270 */
tauxPresenceReseau?: number;
/** Quantité vendue réseau par mois : { "YYYY-MM": qté } (tendance / modal). */
qteReseauByMonth?: Record<string, number> | null;
/**
* Nombre de magasins vendeurs par mois : { "YYYY-MM": nb }.
*
* Deuxième courbe de la carte tendance : une quantité qui monte parce que le
* produit est diffusé dans plus de magasins ne raconte pas la même histoire
* qu'une quantité qui monte à diffusion constante.
*/
nbMagReseauByMonth?: Record<string, number> | null;
/** Fraîcheur des données réseau (ISO) */
networkFetchedAt?: string;
}
@@ -135,7 +163,13 @@ export interface GridFilters {
codeFournisseur: string | null;
code1: string | null;
code2: string | null;
code3: string | null;
/**
* Nomenclatures retenues (codes `code3`).
* `null` = aucun filtre (tout s'affiche) ; `[]` = rien de coché (aucune
* ligne). Distinguer les deux évite la valeur sentinelle qu'imposerait un
* simple tableau.
*/
code3: string[] | null;
codeGamme: GammeCode | null;
search: string;
}