mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-11 17:26:32 +02:00
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
454 lines
24 KiB
Markdown
454 lines
24 KiB
Markdown
# Intégration Qlik Sense — données réseau (~270 magasins)
|
||
|
||
Remplace les heuristiques (ranking / IA / score) par les vraies données réseau
|
||
La Foir'Fouille : **CA réseau, Qté vendue réseau, Nb magasins** par produit.
|
||
|
||
## Architecture livrée
|
||
|
||
| Élément | Fichier |
|
||
|---------|---------|
|
||
| Connecteur Qlik (NTLM + QIX websocket) | `src/lib/qlik-client.ts` |
|
||
| Cache lecture/upsert | `src/lib/qlik-network-cache.ts` |
|
||
| Table cache | `qlik_network_metrics` (`src/db/schema.ts`, créée par `scripts/db-init.js`) |
|
||
| Route de sync (admin) | `POST /api/qlik/sync` (`src/app/api/qlik/sync/route.ts`) |
|
||
| Bouton UI | `src/features/grid/components/sync-qlik-button.tsx` |
|
||
| Jointure grille | `enrichWithNetworkMetrics()` dans `src/features/grid/api/get-product-rows.ts` (Phase 8) |
|
||
| Colonnes grille | CA réseau / Qté réseau / Magasins (/270) / % présence (`heatmap-grid.tsx`) |
|
||
| Recherche produit (Qlik d'abord) | `src/lib/qlik-search.ts` → `src/features/produits/api/search-produits.ts` → `GET /api/produits/search` |
|
||
|
||
## Recherche produit — Qlik d'abord
|
||
|
||
La page `/produits` ne cherche **plus** dans le catalogue FF Nancy en premier :
|
||
le réseau référence bien plus de produits que Nancy, et ce sont précisément
|
||
ceux-là qu'on veut voir.
|
||
|
||
1. `searchQlikArticles()` — recherche de list object sur le champ libellé
|
||
article. `SearchListObjectFor` applique un **OU** entre les mots (« tapis
|
||
anti » ramenait 17 696 libellés sur l'app FF, et sélectionner ces 17 696
|
||
valeurs faisait abandonner le cube en `code 15 — Request aborted`). On
|
||
cherche donc mot par mot pour retenir le plus sélectif, on filtre en **ET**
|
||
côté client, puis on sélectionne les valeurs retenues par numéro d'élément
|
||
(`SelectListObjectValues`). La sélection restreint la dimension « Article
|
||
Code » aux articles correspondants.
|
||
2. Les **mesures réseau sortent du même cube**, dans la **même session Qlik** :
|
||
fenêtre 12 mois glissants sélectionnée sur le champ `Date`, master measures
|
||
résolues par titre, cube trié par quantité réseau décroissante (les meilleures
|
||
ventes en tête, donc pas besoin de lire tout l'ensemble sélectionné).
|
||
Elles sont ensuite persistées par `upsertNetworkMetrics()`.
|
||
|
||
⚠️ Ne **jamais** relancer `fetchNetworkMetricsPlaywright()` pendant une
|
||
recherche : deux sessions concurrentes sur la même app, dont une avec une
|
||
grosse sélection, et l'Engine coupe les requêtes (`code 15`). C'est ce qui
|
||
faisait échouer la recherche alors que la sync de la Grille — seule sur le
|
||
serveur — fonctionnait. Le détail mensuel vient donc du cache, et la fiche
|
||
produit garde son bouton « Actualiser depuis Qlik » pour l'extraire à la
|
||
demande sur un seul code.
|
||
3. `pgGetProduitsByCodeCentrale()` — rapprochement avec le catalogue Nancy, sur
|
||
**deux clés candidates** : la dimension « Article Code » et le champ
|
||
`article_no_centrale`, qui n'ont pas le même format sur l'app FF. Un code
|
||
absent = produit réseau que nous ne référençons pas (`?cc=` sur la fiche).
|
||
|
||
Plafonds mesurés en production : sélectionner les 13 446 libellés contenant
|
||
« tapis » prenait 81 s puis 486 s avant d'abandonner. La sélection est donc
|
||
plafonnée à **300 valeurs** (`MAX_VALEURS_SELECTION`) et une seule recherche
|
||
tourne à la fois.
|
||
|
||
L'API est **asynchrone** : `POST /api/produits/search?q=…` démarre un job et rend
|
||
la main tout de suite, `GET` renvoie l'avancement puis le résultat (polling client
|
||
toutes les 2 s). Une requête HTTP maintenue pendant toute l'extraction se faisait
|
||
couper par le reverse proxy, qui répond une page HTML — le client échouait sur
|
||
« Unexpected token '<' … is not valid JSON ». Même schéma que `POST /api/qlik/sync`.
|
||
|
||
Repli : si Qlik est injoignable, la recherche retombe sur `pgSearchProduits()` et
|
||
la réponse le signale (`source: "db"`). Un résultat Qlik exploitable est mis en
|
||
cache mémoire 10 min ; un repli ne l'est pas, sinon une panne passagère resterait
|
||
figée.
|
||
|
||
Champs de l'app FF (« Magasins Vision Consolidée ») : le code est `Article Code`,
|
||
le libellé `Article` (repli `article_libelle_ticket`, qui est le libellé ticket
|
||
tronqué), le fournisseur `Fournisseur` (repli `code_fournisseur`, qui ne porte
|
||
que le code). Ces préférences sont dans `CHAMPS_LIBELLE_PREFERES` /
|
||
`CHAMPS_FOURNISSEUR_PREFERES`, complétées par une heuristique pour les autres
|
||
apps. Si la détection tombe à côté, forcer :
|
||
|
||
```
|
||
QLIK_FIELD_ARTICLE_LIBELLE=<nom exact du champ libellé>
|
||
QLIK_FIELD_FOURNISSEUR=<nom exact du champ fournisseur>
|
||
```
|
||
|
||
Sans champ libellé exploitable, la recherche fonctionne encore par code centrale.
|
||
|
||
## Le modèle est piloté par `Période` / `Type_Cal` — pas par `Date`
|
||
|
||
**C'est l'explication de fond**, obtenue en dumpant les expressions réelles :
|
||
|
||
```
|
||
« Quantité N » = Sum({<Type_Cal={'N'}>} quantite)
|
||
« CA N » = Sum({<Type_Cal={'N'}>} $(vCA_vat))
|
||
« Quantité COMP » = Sum({<Type_Cal={'$(vPeriod_comp)'}$(vConstantCOMP)>} quantite)
|
||
« Magasin Ventes Nb N » = Count({<Type_Cal={'N'}>} Distinct ventes_code_site)
|
||
```
|
||
|
||
L'app n'est pas un modèle « faits + calendrier » classique. Un **pont de périodes**
|
||
duplique les lignes de faits : `Type_Cal='N'` marque celles de la période
|
||
analysée, `'COMP'` celles de la période de comparaison. Et c'est le champ
|
||
**`Période`** (4 valeurs, une sélectionnée) qui décide de quelle période il s'agit.
|
||
|
||
Tout ce qu'on observait en découle :
|
||
|
||
| Symptôme | Cause |
|
||
|---|---|
|
||
| Sélectionner `Date` ne change rien (100 % du total avant/après) | Le périmètre vient du pont de périodes, pas du champ date |
|
||
| Août→décembre vides sur tous les articles | « Quantité N » ne couvre que la période courante (janvier→juillet) |
|
||
| Janvier→juillet de l'année précédente renseignés | Ce sont les mois que « Quantité COMP » sait atteindre |
|
||
| `Sum(quantite)` brut inexploitable | Il additionne les lignes N **et** COMP : il double compte |
|
||
|
||
### La bonne méthode : effacer `Période`, puis extraire ANNÉE par ANNÉE
|
||
|
||
C'est ce que fait un utilisateur dans l'app : il sélectionne **2026** et obtient
|
||
l'année plus sa comparaison N-1 ; il sélectionne 2025 et obtient 2025. Les
|
||
données de 2024, 2025 et 2026 sont toutes accessibles mois par mois. `Type_Cal='N'`
|
||
désigne simplement **l'année sélectionnée**.
|
||
|
||
Il suffit donc d'enchaîner les années couvertes par la fenêtre (2025 puis 2026)
|
||
pour reconstituer les 12 mois glissants — chaque passe donnant les 5 mesures, et
|
||
non la seule quantité comme « Quantité COMP ».
|
||
|
||
Ce mécanisme existait déjà dans le code (« passe N-1 ») mais restait **sans
|
||
effet** :
|
||
|
||
```
|
||
[qlik-pw] (mois) passe N-1 terminée : 299905 → 299905 points mensuels
|
||
^^^^^^^^^^^^^^ aucun gain
|
||
```
|
||
|
||
La sélection `Période` **héritée de l'ouverture de l'app** (observé
|
||
« Période:1/4 » alors que rien n'avait été sélectionné) épinglait le contexte sur
|
||
l'année en cours et annulait la sélection d'année. `choisirPeriode()` teste donc
|
||
en **premier** l'état sans aucune sélection de `Période`, et l'efface dès que la
|
||
couverture est incomplète.
|
||
|
||
Chaque passe n'écrit que les mois **de la fenêtre**, et les totaux s'additionnent
|
||
sur ces mois : deux années ne peuvent pas se doubler puisqu'elles ne partagent
|
||
aucun mois.
|
||
|
||
### Mesuré : aucune valeur de `Période` ne porte à elle seule 12 mois
|
||
|
||
`choisirPeriode()` essaie chaque valeur et mesure la couverture réelle sur un
|
||
cube `[Mois] × Quantité N`. Résultat en production :
|
||
|
||
```
|
||
valeurs de « Période » : ["juin 2026","Année à date","Mois à date","Semaine 2026/30"]
|
||
« juin 2026 » → 1/12 ["2026-06"]
|
||
« Année à date » → 6/12 ["2026-01".."2026-06"]
|
||
« Mois à date » → 0/12
|
||
« Semaine 2026/30 » → 0/12
|
||
```
|
||
|
||
Ce ne sont pas des types de période mais **4 contextes relatifs à aujourd'hui**.
|
||
Aucun ne porte 12 mois glissants : les master measures ne pourront **jamais**
|
||
couvrir la fenêtre. Ce n'est plus une hypothèse, c'est une mesure.
|
||
|
||
Conséquence directe : **l'agrégation directe des faits est obligatoire**, et une
|
||
`Période` partielle est pire que pas de sélection — « Année à date » restreint les
|
||
faits à 2026-01→06 et amputerait d'autant l'agrégation, qui sait lire tout
|
||
l'historique. `choisirPeriode()` **efface** donc la sélection quand la couverture
|
||
est incomplète, et ne la garde que si elle couvre les 12 mois.
|
||
|
||
### Chaque expression est validée séparément
|
||
|
||
Une seule mesure invalide suffit à faire renvoyer **zéro ligne** à tout un
|
||
hypercube, sans qu'aucun message ne dise laquelle : observé en production, le
|
||
cube à 5 mesures calculait 26 s puis rendait 0 ligne, alors que `Sum(quantite)`
|
||
seul donnait 35 424 324.
|
||
|
||
`validerExpression()` teste donc chaque expression isolément sur un petit cube
|
||
`[Mois]` avant de construire le gros :
|
||
|
||
```
|
||
[qlik-pw][diag] validation des expressions, une par une :
|
||
[qlik-pw][diag] mesure « quantité » = Sum(quantite) → 12 mois, total=33945285
|
||
[qlik-pw][diag] mesure « magasins » = Count(DISTINCT [Magasin Code]) → INVALIDE (cube vide)
|
||
```
|
||
|
||
Une expression invalide est remplacée par la colonne neutre `0` : le cube reste
|
||
exploitable et le log nomme la coupable. Seule la quantité est bloquante.
|
||
|
||
## ⚠️ Les master measures « N » ignorent la sélection Date
|
||
|
||
**Constat de production, vérifié arithmétiquement.** Avec août 2025 seul
|
||
sélectionné, le cube renvoyait `qte=164` / `ca=2 220,75 €` — soit exactement la
|
||
somme de janvier à juillet 2026. « CA N », « Quantité N » et consorts sont des
|
||
mesures **cumul année en cours**, insensibles au champ `Date`.
|
||
|
||
Trois conséquences, toutes observées :
|
||
|
||
- les totaux réseau de la Grille étaient un **cumul année en cours** (mois
|
||
courant partiel inclus), pas 12 mois glissants ;
|
||
- les mois de l'année précédente non couverts par « Quantité COMP » (août à
|
||
décembre, en juillet) restaient **vides sur tous les articles** ;
|
||
- la passe de rattrapage y recopiait le **total de période**, d'où cinq mois
|
||
identiques à 164 dans `qteByMonth`.
|
||
|
||
Aucune sélection ne corrige cela. Le chemin nominal agrège donc **directement les
|
||
champs de faits**, qui respectent les sélections :
|
||
|
||
| Rôle | Expression (surchargeable) |
|
||
|------|----------------------------|
|
||
| Quantité | `QLIK_EXPR_QTE` — défaut `Sum(quantite)` |
|
||
| CA | `QLIK_EXPR_CA` — défaut `Sum(ca_ht)` |
|
||
| Magasins | `QLIK_EXPR_NBMAG` — défaut `Count(DISTINCT [Magasin Code])` |
|
||
| Marge | `QLIK_EXPR_MARGE` — défaut `Sum(marge)` |
|
||
|
||
Un seul cube `[Article Code, Mois]` couvre les 12 mois : ni passe N-1 ni
|
||
rattrapage — c'est exactement ce qu'ils compensaient. Les totaux deviennent la
|
||
**somme des 12 mois de la fenêtre**.
|
||
|
||
Sur l'app de production, les champs `Date`, `Date calendrier` et `Date_Key`
|
||
existent mais leur sélection ne réduit pas `Sum(quantite)` : ils restent à
|
||
100 % du total historique. L'extracteur essaie d'abord ces champs puis utilise
|
||
la dimension maître `Mois` comme chemin fiable. Il crée un list object, retrouve
|
||
les 12 valeurs `YYYY-MM`, les sélectionne par `qElemNumber`, puis exécute le
|
||
cube mensuel. Cette méthode ne dépend ni du champ sous-jacent ni de son format
|
||
dual et conserve le chemin rapide (une sélection articles + un cube paginé).
|
||
|
||
Garde-fous : « Quantité N » est incluse dans le cube pour **calibrage**, et le
|
||
log compare les deux sur les mois de l'année en cours, seul périmètre où la
|
||
master measure est juste :
|
||
|
||
```
|
||
[qlik-pw][expr] 485210 lignes, quantité totale=…, calibrage année 2026 : 4812/4830 mois conformes à « Quantité N »
|
||
```
|
||
|
||
Un calibrage faible signale une expression à ajuster (filtre `flag_type_mvt`,
|
||
`ca_ttc` plutôt que `ca_ht`…). Il n'existe plus de repli automatique vers les
|
||
master measures annuelles : quantité totale nulle, champ Date invalide, erreur
|
||
Engine ou somme incohérente font échouer la synchronisation et laissent le cache
|
||
précédent intact.
|
||
|
||
Après sélection de la fenêtre, l'extracteur vérifie deux invariants avant tout
|
||
upsert :
|
||
|
||
1. la somme de toutes les lignes `[Article Code, Mois]` est égale au total Qlik
|
||
sans dimension calculé avec les mêmes sélections ;
|
||
2. pour chaque article, la quantité réseau totale est exactement la somme de ses
|
||
12 mois. Les mois sans ligne de faits sont alors seulement matérialisés à `0`.
|
||
|
||
Une extraction interrompue ou partielle n'est jamais publiée.
|
||
|
||
### Valider les expressions sur le vrai serveur
|
||
|
||
`scripts/qlik-validate.mjs` répond aux deux questions ouvertes en une exécution,
|
||
**depuis un réseau qui atteint Qlik** (le serveur est filtré par IP : injoignable
|
||
depuis l'extérieur, la passerelle d'egress tombe en `connection timeout`) :
|
||
|
||
```
|
||
docker exec -e QLIK_PWD='<mot de passe>' -it <conteneur> node scripts/qlik-validate.mjs
|
||
```
|
||
|
||
Il affiche les expressions réelles des master measures, puis un tableau mois par
|
||
mois comparant `Sum(quantite)` / `Sum(ca_ht)` / `Sum(ca_ttc)` /
|
||
`Count(DISTINCT [Magasin Code])` aux mesures « N » — sur les mois de l'année en
|
||
cours (où elles doivent coïncider) comme sur ceux de l'année précédente (où les
|
||
mesures « N » sont censées être à 0). Il affiche enfin `Article Code`,
|
||
`article_no_centrale` et `article_codein` côte à côte, pour trancher la clé de
|
||
jointure avec `articles.artcentrale`.
|
||
|
||
Lecture seule : sélections en soft lock, objets de session détruits.
|
||
|
||
### La sélection Date filtre-t-elle seulement quelque chose ?
|
||
|
||
Question restée sans réponse tant que seules des mesures **insensibles à la
|
||
sélection** étaient utilisées : rien ne prouvait que `Date` bornait quoi que ce
|
||
soit. Le chemin par expressions le vérifie désormais avant d'extraire, avec un
|
||
total de contrôle (`Sum(quantite)` sans dimension) :
|
||
|
||
```
|
||
[qlik-pw][expr] contrôle Sum(quantite) sans filtre date = 12345678
|
||
[qlik-pw][expr] champ « Date » → 0 (0% du total sans filtre)
|
||
[qlik-pw][expr] champ « Date calendrier » → 3456789 (28% du total sans filtre)
|
||
[qlik-pw][expr] champ date retenu : « Date calendrier »
|
||
```
|
||
|
||
Un champ n'est retenu que s'il donne un total **non nul et strictement inférieur**
|
||
au total sans filtre — 0 % signifie que la sélection ne matche rien, 100 % qu'elle
|
||
n'a aucun effet. Candidats essayés dans l'ordre : `QLIK_DATE_FIELD` (si défini),
|
||
`Date`, `Date calendrier`, `Date_Key` (celui-ci sélectionné au format `AAAAMMJJ`).
|
||
|
||
Si aucun ne filtre, l'extraction échoue explicitement plutôt que de produire une
|
||
fenêtre fausse. Chaque sélection d'un candidat rejeté est effacée avant l'essai
|
||
suivant ; autrement un premier candidat à zéro contaminait tous les contrôles
|
||
suivants. Avant cet échec, la dimension maître `Mois` est testée elle aussi.
|
||
|
||
## La passe de rattrapage a été supprimée
|
||
|
||
Elle ré-extrayait un mois vide en ne sélectionnant que ses dates. Les master
|
||
measures ignorant la sélection Date, le cube lui renvoyait le **total de période**
|
||
qu'elle recopiait dans chaque mois manquant : d'où les plateaux identiques d'août
|
||
à décembre (87 648 douze mois de suite sur un article) et les tendances
|
||
« −162 % » entièrement fabriquées.
|
||
|
||
Un mois qu'on ne sait pas extraire doit rester **absent**, jamais rempli d'une
|
||
valeur plausible.
|
||
|
||
## Mois vides de la fenêtre glissante
|
||
|
||
Les mesures « N » de l'app sont bornées à une année civile. Quand la fenêtre
|
||
12 mois glissants chevauche deux années (le cas 11 mois sur 12), le chemin
|
||
« dimension Mois » sélectionne les 365 jours d'un coup et la mesure ne se résout
|
||
que sur une seule année : les mois de l'année précédente ressortent vides.
|
||
« Quantité COMP » ne rattrape que les mois ayant un comparable dans l'année en
|
||
cours — d'où, en juillet 2026, un trou observé d'août à décembre 2025 sur
|
||
**tous** les articles.
|
||
|
||
Le chemin daté repose uniquement sur les expressions de faits et un cube unique
|
||
`[Article Code, Mois]`. Après validation de la fenêtre et du total, chaque
|
||
article reçoit exactement les 12 clés attendues. Une clé absente du cube signifie
|
||
alors réellement « aucun fait sur ce mois » et vaut `0`; avant cette validation,
|
||
aucun zéro n'est inventé.
|
||
|
||
`QLIK_MONTH_DIM=0` ou `QLIK_USE_EXPR=0` désactive désormais un prérequis et fait
|
||
échouer la synchronisation datée : ces options ne peuvent plus réactiver un
|
||
chemin connu comme incorrect.
|
||
|
||
⚠️ Les données déjà en cache gardent leurs zéros : il faut relancer la sync Qlik
|
||
pour les corriger.
|
||
|
||
## Coût de `SelectValues` sur « Article Code »
|
||
|
||
Mesuré en production : **8 à 100 s par appel**, quasi indépendamment du nombre de
|
||
valeurs — le moteur balaie un symbole de plus d'un million d'entrées. Avec 7 200
|
||
codes en lots de 300, cela faisait 24 appels, ~15 min de sync, et la session Qlik
|
||
mourait avant la fin (`Socket closed`, puis `Execution context was destroyed`).
|
||
|
||
La dimension Mois livrant déjà tous les mois d'un coup, rien n'oblige à découper
|
||
les codes : le chemin mensuel fait donc **une seule sélection pour tous les codes**
|
||
et **un seul cube paginé** (la lecture des pages coûte ~50 ms). Repli automatique
|
||
sur les lots si l'Engine refuse. Même principe pour `rattraperMoisVides()` : une
|
||
sélection de codes, puis seule la fenêtre de dates change d'un mois à l'autre.
|
||
|
||
La sonde de diagnostic de fin de sync a été retirée : elle refaisait une sélection
|
||
complète des codes et des 365 jours pour rien.
|
||
|
||
### Sélection PAR FOURNISSEUR (ÉTAPE 0)
|
||
|
||
Le code fournisseur FF présent en base SQL / API (ex. `J009`) existe aussi dans
|
||
Qlik. Une valeur y désigne le même périmètre que les dizaines de milliers de codes
|
||
articles — pour un fournisseur de 41 569 références, c'est **un** `SelectValues`
|
||
trivial au lieu d'un appel de 8 à 100 s **répété à chaque passe annuelle**.
|
||
|
||
`selectionnerParFournisseur()` (in-page, avant `choisirPeriode()`) :
|
||
|
||
1. essaie chaque champ candidat — `QLIK_FIELD_CODE_FOURNISSEUR`, puis
|
||
`code_fournisseur`, `Fournisseur`, `fournisseur_code_centrale` ;
|
||
2. `SelectValues` le code fournisseur (soft lock) ; une valeur inconnue passe au
|
||
champ suivant ;
|
||
3. **vérifie la couverture** : lit les Article Code visibles sous cette sélection
|
||
(cube paginé) et compare aux codes demandés ;
|
||
4. ne retient la bascule que si la couverture atteint `QLIK_SUPPLIER_MIN_COVERAGE`
|
||
(défaut **0.99**, borné à [0.5, 1]). Sinon la sélection est annulée et on
|
||
revient à la sélection par codes.
|
||
|
||
Ce contrôle est indispensable : un article rattaché à plusieurs fournisseurs, ou
|
||
un référencement Qlik différent du référencement FF, doit faire **revenir aux
|
||
codes** plutôt que perdre silencieusement des articles.
|
||
|
||
Quand la bascule est retenue (`parFournisseur = true`), plus **aucun**
|
||
`SelectValues` sur « Article Code » n'est émis — ni par les passes annuelles, ni
|
||
par l'agrégation directe par expressions. Le périmètre fournisseur survit aux
|
||
sélections `Année` et `Date`, qui portent sur d'autres champs. Le repli par lots
|
||
de codes reste disponible si l'Engine refuse un cube.
|
||
|
||
En mode produit (sync d'un seul code centrale), aucun fournisseur n'est transmis :
|
||
la sélection reste par code.
|
||
|
||
### Aucun résultat partiel dans le cache
|
||
|
||
Le script in-page conserve des points de contrôle à des fins de diagnostic, mais
|
||
une synchronisation datée interrompue est refusée intégralement. Le cache garde
|
||
sa dernière version complète jusqu'à la réussite d'une nouvelle extraction.
|
||
|
||
## Tendance réseau = 12 mois glissants stricts
|
||
|
||
`computeNetworkTrend()` reconstruit sa fenêtre à partir de la date du jour :
|
||
12 mois complets, **mois en cours exclu** (partiel, il tirait la pente vers le bas).
|
||
La tendance n'est affichée que si les 12 clés sont explicitement présentes dans
|
||
le cache. Les anciennes séries partielles de deux ou trois mois sont donc
|
||
refusées au lieu d'être présentées comme une tendance.
|
||
|
||
Retirés : ranking (champs/query/colonnes), analyse IA (routes `/api/ai/*`, `bulk-ai-analyzer`,
|
||
dossier `ai-copilot`), score (`score-engine.ts`, colonne score).
|
||
|
||
## Auth — NTLM + ticket SSO (✅ TESTÉ, marche)
|
||
|
||
Flux implémenté dans `qlik-client.ts` / `scripts/qlik-discover.mjs` :
|
||
1. `GET /hub/` (non authentifié) → 302, on extrait le `targetId`.
|
||
2. **NTLM** (login `FFSCH`, **domaine vide**, pas `FOIRFOUILLE`) sur
|
||
`/internal_windows_authentication/?targetId=…` → 302 vers `/hub/?qlikTicket=XXX`.
|
||
3. `GET /hub/?qlikTicket=XXX` → Qlik pose le cookie **`X-Qlik-Session`**.
|
||
4. Cookie + Xrfkey → QRS REST (✅) et Engine websocket.
|
||
|
||
⚠️ Domaine NTLM **vide** (comme `requests_ntlm("FFSCH", pwd)`). Forcer `FOIRFOUILLE` → 401.
|
||
Testé depuis l'extérieur : auth + QRS OK, **147 apps** côté admin (2 visibles pour FFSCH).
|
||
|
||
## Discovery (✅ FAITE)
|
||
|
||
**App réseau article-niveau = "Magasins Vision Consolidée"** (stream Magasin Big Data) :
|
||
- `QLIK_APP_NETWORK = 9872ee6e-d64a-4b43-984a-076bf1f7f647`
|
||
- dim `QLIK_DIM_CODE_ARTICLE_ID = fcd239e5-288b-4830-a047-0e3d7665d971` (**Article Code**)
|
||
- CA `QLIK_MEAS_CA_ID = 43a76088-86fa-402e-a80e-0efd7701b3e1` (**CA N**)
|
||
- Qté `QLIK_MEAS_QTE_ID = 7b40caf1-be4b-4811-8d45-50acde33e715` (**Quantité N**)
|
||
- nb mag `QLIK_MEAS_NBMAG_ID = 8b63fae5-db2f-4e4c-8618-f3e9d60b6b3b` (**Magasin Ventes Nb N**)
|
||
|
||
Ces GUID sont déjà les **défauts** dans `qlik-client.ts` (hypercube par `qLibraryId`).
|
||
(App "CA Foirfouille" stream Réseau = `65b3ad21-…` : pas de dimension article → écartée.)
|
||
|
||
⚠️ **À confirmer par un échantillon de hypercube** (5 lignes) : que `Article Code` = code
|
||
centrale `10000XXXXXX`, et que `Magasin Ventes Nb N` = nb magasins vendeurs. L'agent hermes
|
||
(Playwright) peut le faire.
|
||
|
||
### Limite extraction depuis l'extérieur
|
||
Le **websocket Engine renvoie 403** depuis un environnement externe (le proxy Qlik refuse
|
||
l'upgrade ws hors contexte navigateur ; l'extraction hermes qui marche réutilise une session
|
||
Playwright). Sur le **réseau corporate** (où tournera CollectFlow / où `/hub/` renvoie 401
|
||
NTLM direct), le ws raw devrait passer. À valider en déployant `POST /api/qlik/sync` sur leur
|
||
infra interne.
|
||
|
||
## Variables d'environnement (`.env.local`)
|
||
|
||
```
|
||
QLIK_HOST=reporting-magasins.lafoirfouille.fr
|
||
QLIK_USER=FFSCH
|
||
QLIK_PWD=<mot de passe courant>
|
||
QLIK_DOMAIN= # VIDE (ne pas mettre FOIRFOUILLE — NTLM sans domaine)
|
||
QLIK_TLS_INSECURE=true
|
||
# Défauts déjà en dur dans qlik-client.ts (discovery 2026-06-20) :
|
||
QLIK_APP_NETWORK=9872ee6e-d64a-4b43-984a-076bf1f7f647
|
||
QLIK_DIM_CODE_ARTICLE_ID=fcd239e5-288b-4830-a047-0e3d7665d971
|
||
QLIK_MEAS_CA_ID=43a76088-86fa-402e-a80e-0efd7701b3e1
|
||
QLIK_MEAS_QTE_ID=7b40caf1-be4b-4811-8d45-50acde33e715
|
||
QLIK_MEAS_NBMAG_ID=8b63fae5-db2f-4e4c-8618-f3e9d60b6b3b
|
||
# Sélection par fournisseur (facultatif — auto-détection sinon) :
|
||
QLIK_FIELD_CODE_FOURNISSEUR= # champ Qlik portant le code fournisseur FF
|
||
QLIK_SUPPLIER_MIN_COVERAGE=0.99 # couverture minimale exigée pour basculer
|
||
```
|
||
|
||
## RESTE À FAIRE
|
||
|
||
1. **Confirmer la sémantique des champs** (échantillon hypercube, via l'agent hermes/Playwright
|
||
ou en déployant `/api/qlik/sync` en interne) : `Article Code` = code centrale `10000XXXXXX` ?
|
||
`Magasin Ventes Nb N` = nb magasins vendeurs ? App bien réseau (270 magasins) ?
|
||
|
||
2. **Exécuter le sync depuis le réseau interne** : le websocket Engine renvoie 403 depuis
|
||
l'extérieur. Déployer `POST /api/qlik/sync` sur l'infra interne FF pour valider l'extraction.
|
||
|
||
3. **Code centrale = `articles.artcentrale`** ✅ RÉSOLU. Confirmé via l'API FF
|
||
`GET /api/articles/{no_id}` → champ `artcentrale` (ex `"10000167303"` = `10000`+6 chiffres).
|
||
Ajouté au SELECT de `pgGetArticlesByFournisseur` (`a.artcentrale AS "codeCentrale"`) → propagé
|
||
dans `ProductRow.codeCentrale` → jointure `enrichWithNetworkMetrics`. ⚠️ Vide pour la plupart
|
||
des articles (seuls les référencés centralement en ont un). À valider que la valeur Qlik
|
||
"Article Code" est bien au même format sur le premier vrai sync.
|
||
|
||
Tant que le sync interne n'est pas exécuté, la grille fonctionne en **dégradation propre** :
|
||
colonnes réseau vides, aucune erreur.
|