Commit Graph
562 Commits
Author SHA1 Message Date
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