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
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
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
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
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
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
`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
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
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
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
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
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
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
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
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
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
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
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
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
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
- 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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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