Compare commits

...
Author SHA1 Message Date
Claude eeaae09282 feat(produits): page de recherche produit + fiche 360° local/réseau
Nouvelle page /produits : recherche par libellé ou par code centrale, puis
fiche complète d'un produit — identité, prix, stock par magasin, ventes sur
12 mois glissants, et l'intégralité des métriques réseau Qlik.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

No change to table cell logic or the network columns.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

No files matched your search

+7
View File
@@ -32,6 +32,10 @@ ENV NODE_ENV=production
# Next.js telemetry is disabled
ENV NEXT_TELEMETRY_DISABLED=1
# Chromium pour l'extraction Qlik via Playwright (ws in-page) + dépendances headless
RUN apk add --no-cache chromium nss freetype harfbuzz ca-certificates ttf-freefont
ENV PLAYWRIGHT_CHROMIUM_PATH=/usr/bin/chromium-browser
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
@@ -46,6 +50,9 @@ RUN chown nextjs:nodejs .next data
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
# playwright-core n'est pas toujours tracé par le standalone — on le copie explicitement
COPY --from=deps --chown=nextjs:nodejs /app/node_modules/playwright-core ./node_modules/playwright-core
# Grant write access to /app for settings persistence
RUN chown -R nextjs:nodejs /app
+106
View File
@@ -0,0 +1,106 @@
# Qlik — Performance & tuning de la sync réseau
Ce document rassemble les variables d'environnement et leviers qui agissent
sur la **vitesse** et la **robustesse** de l'extraction Qlik réseau
(`POST /api/qlik/sync`, routeur `src/app/api/qlik/sync/route.ts`,
extracteur `src/lib/qlik-playwright.ts`, helper de fenêtre
`src/lib/qlik-date-range.ts`).
Toutes ces variables sont **optionnelles** : valeur par défaut sûre.
> ⚠️ Aucune concurrence / parallélisme Qlik n'a été ajouté — chaque sync
> reste **mono-flux** (un seul websocket Engine, une seule fenêtre
> temporelle). Les optimisations ci-dessous n'augmentent pas la charge
> sur le moteur Qlik ; elles **réduisent** la quantité de travail qu'on
> lui demande.
---
## Variables d'environnement
| Nom | Type | Défaut | Bornes | Effet |
| --------------------------- | ---- | ------ | ------------------- | -------------------------------------------------------------------------------------- |
| `QLIK_SYNC_MONTHS_BACK` | int | `12` | `1`..`12` | Profondeur de la fenêtre temporelle (mois glissants, mois courant exclu). Plus petit = moins de mois scannés = extraction plus rapide. |
| `QLIK_SETTLE_MS` | int | `150` | `0`..`2000` | Latence (ms) après chaque mutation de sélection (Date / Article Code) avant l'appel RPC suivant. Protège l'Engine contre les collisions qui provoquent `code:15 Request aborted`. |
| `QLIK_CODE15_MAX_ATTEMPTS` | int | `4` | `1`..`8` | Nombre max de retries sur Engine `code:15` par appel RPC, avec backoff `QLIK_SETTLE_MS * attempt`. |
| `QLIK_ARTICLE_BATCH_SIZE` | int | `150` | `1`..`500` | Taille du premier lot d'`Article Code` sélectionné par appel `SelectValues`. |
| `QLIK_ARTICLE_BATCH_FALLBACKS` | CSV | `75,50` | chaque valeur `1`..`500` | Tailles de repli quand un lot déclenche un `code:15`. Valeurs ordonnées croissant appliquées successivement. |
> **Format CSV pour `QLIK_ARTICLE_BATCH_FALLBACKS`** : valeurs séparées par
> des virgules, ex. `QLIK_ARTICLE_BATCH_FALLBACKS=100,75,50,25`. Les doublons
> et valeurs hors bornes sont dédupliqués / clampés automatiquement, puis appliqués dans l'ordre fourni.
---
## Optimisations déjà en place (pattern durable)
Le pipeline reste **mono-connexion** pour ne jamais surcharger le moteur Qlik :
1. **Fenêtre temporelle bornée** : on ne demande jamais à Qlik de scanner
l'historique complet ; uniquement les `QLIK_SYNC_MONTHS_BACK` derniers
mois.
2. **Découpe mensuelle** : `getMonthRanges()` produit 1 sous-fenêtre par mois
calendaire. Chaque cube reste ainsi borné à ~28–31 jours × N codes.
3. **Hypercube session object JETABLE** : `fetchCubeForSelection()` crée un
`CreateSessionObject` neuf par lot, lit ses pages, puis appelle
`DestroySessionObject`. C'est ce qui a éliminé les `code:15 Request aborted`
à grande échelle (la réutilisation du même objet sur des centaines de
recalculs forçait l'Engine à recalculer en boucle).
4. **Fallbacks code 15** : si un lot trop large déclenche un `code:15`, on
retente automatiquement avec une taille plus petite (cf.
`QLIK_ARTICLE_BATCH_FALLBACKS`) sans tout annuler.
## Nouveautés (cette itération)
1. **Fenêtre paramétrable `QLIK_SYNC_MONTHS_BACK`** (1..12, défaut 12). Permet
de scoper un sync test à 6 mois au lieu de 12, ou de n'extraire que les 3
derniers mois pour un check rapide. Le helper `envMonthsBack()` (dans
`src/lib/qlik-date-range.ts`) clamp la valeur et applique le défaut si
l'env est mal configurée.
2. **Filtre strict des codes centraux** dans `route.ts` (regex `[A-Z0-9][A-Z0-9_-]{1,29}`,
longueur 2..30, exclusion de `""`, `"-"`, `"_"`). Réduit les erreurs Engine
sur des valeurs parasites et le gaspillage réseau. Le log expose le **nombre**
de codes rejetés mais pas les valeurs (hygiène logs).
3. **Résumé timing final** : ligne unique `[qlik-pw][summary] rows=… months=…
batches=… code15Retries=… fallbackCount=… totalMs=…` émise en fin de sync
pour audit/debug sans avoir à parser les logs détaillés par lot.
## Exemple de `.env.local`
```env
# Sync Qlik : fenêtre par défaut 12 mois ; on peut descendre à 6 pour un
# test rapide ou un check hebdo sans recharger tout l'historique.
QLIK_SYNC_MONTHS_BACK=12
# Batch sizes / fallbacks (laisser par défaut sauf contre-indication).
QLIK_ARTICLE_BATCH_SIZE=150
QLIK_ARTICLE_BATCH_FALLBACKS=75,50
QLIK_SETTLE_MS=150
QLIK_CODE15_MAX_ATTEMPTS=4
```
## Tests
```bash
# Tests purs Node (aucune dépendance Next.js).
npx tsx scripts/test-qlik-date-range.ts
```
Couvre :
- repères Qlik (sérial ↔ ISO),
- fenêtre 12 mois (référence),
- `getMonthRanges()` : 12 mois / fenêtre partielle / année bissextile,
- option `monthsBack` : 6 mois + clamp 0 / -5 / 24 / NaN / undefined,
- helper `envMonthsBack()` : env absent / vide / valide / hors bornes /
non numérique / décimal / espaces.
## Référence
- `src/lib/qlik-date-range.ts` — fenêtre, `envMonthsBack`, `getMonthRanges`.
- `src/lib/qlik-playwright.ts` — extracteur Playwright, hypercube jetable,
fallbacks code 15, `[qlik-pw][summary]` final.
- `src/app/api/qlik/sync/route.ts` — route admin, `filterCentralCodes`,
applique `QLIK_SYNC_MONTHS_BACK` via `envMonthsBack`.
- `scripts/test-qlik-date-range.ts` — tests CLI.
- `docs/qlik-integration.md` — doc d'intégration générale (auth NTLM,
discovery, hypercube, etc.).
+1 -1
View File
@@ -1,7 +1,7 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: "standalone",
serverExternalPackages: ["pg", "ws", "httpntlm"],
serverExternalPackages: ["pg", "ws", "httpntlm", "playwright-core"],
experimental: {
serverActions: {
bodySizeLimit: "50mb",
+13
View File
@@ -26,6 +26,7 @@
"next-auth": "^5.0.0-beta.30",
"next-themes": "^0.4.6",
"pg": "^8.18.0",
"playwright-core": "^1.61.0",
"radix-ui": "^1.4.3",
"react": "19.2.3",
"react-dom": "19.2.3",
@@ -13285,6 +13286,18 @@
"node": ">=16.20.0"
}
},
"node_modules/playwright-core": {
"version": "1.61.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.0.tgz",
"integrity": "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/possible-typed-array-names": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
+1
View File
@@ -30,6 +30,7 @@
"next-auth": "^5.0.0-beta.30",
"next-themes": "^0.4.6",
"pg": "^8.18.0",
"playwright-core": "^1.61.0",
"radix-ui": "^1.4.3",
"react": "19.2.3",
"react-dom": "19.2.3",
+10
View File
@@ -62,6 +62,9 @@ importers:
pg:
specifier: ^8.18.0
version: 8.20.0
playwright-core:
specifier: ^1.61.0
version: 1.61.0
radix-ui:
specifier: ^1.4.3
version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
@@ -4503,6 +4506,11 @@ packages:
resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==}
engines: {node: '>=16.20.0'}
playwright-core@1.61.0:
resolution: {integrity: sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==}
engines: {node: '>=18'}
hasBin: true
possible-typed-array-names@1.1.0:
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
engines: {node: '>= 0.4'}
@@ -9931,6 +9939,8 @@ snapshots:
pkce-challenge@5.0.1: {}
playwright-core@1.61.0: {}
possible-typed-array-names@1.1.0: {}
postcss-selector-parser@7.1.1:
+12
View File
@@ -102,10 +102,22 @@ async function main() {
"ca_reseau" numeric(16, 2),
"qte_reseau" numeric(14, 2),
"nb_magasins_reseau" integer,
"ca_par_magasin_reseau" numeric(16, 2),
"marge_pct_reseau" numeric(8, 4),
"periode" varchar(20),
"fetched_at" timestamp DEFAULT now()
);
`);
// Colonnes ajoutées après coup (DB existante) — CREATE IF NOT EXISTS ne les pose pas.
await tempPool.query(`
ALTER TABLE "qlik_network_metrics"
ADD COLUMN IF NOT EXISTS "ca_par_magasin_reseau" numeric(16, 2),
ADD COLUMN IF NOT EXISTS "marge_pct_reseau" numeric(8, 4),
ADD COLUMN IF NOT EXISTS "qte_by_month" jsonb,
ADD COLUMN IF NOT EXISTS "metrics_by_month" jsonb;
ALTER TABLE "qlik_network_metrics" DROP COLUMN IF EXISTS "rupture_pct_reseau";
ALTER TABLE "qlik_network_metrics" DROP COLUMN IF EXISTS "couverture_stock_reseau";
`);
console.log("[DB Init] Table qlik_network_metrics is verified/created.");
await tempPool.end();
+194
View File
@@ -0,0 +1,194 @@
/**
* Test CLI pour `src/lib/qlik-date-range.ts` (aucune dépendance Next.js).
* Usage : npx tsx scripts/test-qlik-date-range.ts
*
* Vérifie :
* - les repères Qlik (2025-01-01→45658, etc.)
* - la fenêtre 12 mois complets pour `now=2026-06-21` (doit donner
* dateDebut=2025-06-01, dateFin=2026-05-31, qStart=45809, qEnd=46173,
* label=2025-06_2026-05, 365 serials)
* - exemples supplémentaires (1er du mois, fin d'année, année bissextile)
* - erreurs attendues sur entrée mal formée
* - option `monthsBack` : 6 mois, bornes (clamp 1..12)
* - helper `envMonthsBack` : valeur absente, valide, hors bornes, non numérique
*
* Aucun framework : assertions manuelles, exit code 0/1.
*/
import {
isoDateToQlikSerial,
qlikSerialToIsoDate,
buildGridNetworkQlikDateFilter,
chunkSerials,
getMonthRanges,
envMonthsBack,
QLIK_DATE_ANCHORS,
QLIK_MONTHS_BACK_MIN,
QLIK_MONTHS_BACK_MAX,
QLIK_MONTHS_BACK_DEFAULT,
} from "../src/lib/qlik-date-range";
let pass = 0;
let fail = 0;
function eq(label: string, got: unknown, want: unknown): void {
const ok = JSON.stringify(got) === JSON.stringify(want);
if (ok) {
pass++;
console.log(` \u2713 ${label}`);
} else {
fail++;
console.log(` \u2717 ${label}\n got: ${JSON.stringify(got)}\n want: ${JSON.stringify(want)}`);
}
}
function truthy(label: string, got: unknown): void {
if (got) {
pass++;
console.log(` \u2713 ${label}`);
} else {
fail++;
console.log(` \u2717 ${label} \u2014 falsy: ${JSON.stringify(got)}`);
}
}
function throws(label: string, fn: () => unknown, re?: RegExp): void {
try {
fn();
fail++;
console.log(` \u2717 ${label} \u2014 n'a pas lanc\u00e9 d'exception`);
} catch (e) {
const msg = String((e as Error)?.message ?? e);
if (!re || re.test(msg)) {
pass++;
console.log(` \u2713 ${label}`);
} else {
fail++;
console.log(` \u2717 ${label} \u2014 exception inattendue: ${msg}`);
}
}
}
console.log("\n[1] Rep\u00e8res Qlik (skill qlik)");
for (const a of QLIK_DATE_ANCHORS) {
eq(`isoDateToQlikSerial(${a.iso}) = ${a.serial}`, isoDateToQlikSerial(a.iso), a.serial);
}
console.log("\n[2] Round-trip s\u00e9rial \u2194 ISO");
for (const a of QLIK_DATE_ANCHORS) {
eq(`qlikSerialToIsoDate(${a.serial}) = ${a.iso}`, qlikSerialToIsoDate(a.serial), a.iso);
}
console.log("\n[3] Fen\u00eatre grille (now=2026-06-21) \u2192 2025-06-01 .. 2026-05-31");
const f = buildGridNetworkQlikDateFilter(new Date("2026-06-21T12:00:00Z"));
eq("dateDebut", f.dateDebut, "2025-06-01");
eq("dateFin", f.dateFin, "2026-05-31");
eq("qStart", f.qStart, 45809);
eq("qEnd", f.qEnd, 46173);
eq("label", f.label, "2025-06_2026-05");
eq("setAnalysis", f.setAnalysis, `Date={">=45809<=46173"}`);
eq("dailySerials.length = 365", f.dailySerials.length, 365);
eq("dailySerials[0]", f.dailySerials[0], 45809);
eq("dailySerials[364]", f.dailySerials[364], 46173);
console.log("\n[4] Fen\u00eatre grille (now=2026-03-15) \u2192 2025-03-01 .. 2026-02-28");
const f2 = buildGridNetworkQlikDateFilter(new Date("2026-03-15T08:00:00Z"));
eq("dateDebut", f2.dateDebut, "2025-03-01");
eq("dateFin", f2.dateFin, "2026-02-28");
eq("qStart", f2.qStart, 45717);
eq("qEnd", f2.qEnd, 46081);
console.log("\n[5] Fen\u00eatre grille (now=2025-01-05) \u2192 2024-01-01 .. 2024-12-31 (ann\u00e9e bissextile, 366 j)");
const f3 = buildGridNetworkQlikDateFilter(new Date("2025-01-05T08:00:00Z"));
eq("dateDebut", f3.dateDebut, "2024-01-01");
eq("dateFin", f3.dateFin, "2024-12-31");
eq("dailySerials.length = 366", f3.dailySerials.length, 366);
console.log("\n[6] chunkSerials");
const chunks = chunkSerials(f.dailySerials, 100);
eq("chunks.length pour 365/100", chunks.length, 4);
eq("chunks[3].length = 65", chunks[3].length, 65);
eq("chunks[0][0]", chunks[0][0], 45809);
eq("chunks[3][64]", chunks[3][64], 46173);
console.log("\n[7] Robustesse");
throws("format invalide '2025/01/01'", () => isoDateToQlikSerial("2025/01/01"), /YYYY-MM-DD/);
throws("format vide", () => isoDateToQlikSerial(""), /YYYY-MM-DD/);
truthy("label non vide pour fenêtre valide", f.label && f.label.length > 0);
truthy("setAnalysis commence par 'Date='", f.setAnalysis.startsWith("Date="));
console.log("\n[8] getMonthRanges — fenêtre 12 mois (now=2026-06-21)");
const months = getMonthRanges(f);
eq("12 mois exactement", months.length, 12);
eq("premier mois label", months[0].label, "2025-06");
eq("premier mois dateDebut", months[0].dateDebut, "2025-06-01");
eq("premier mois dateFin", months[0].dateFin, "2025-06-30");
eq("premier mois serials.length = 30", months[0].dailySerials.length, 30);
eq("premier mois qStart", months[0].qStart, 45809);
eq("premier mois qEnd", months[0].qEnd, 45838);
eq("dernier mois label", months[11].label, "2026-05");
eq("dernier mois dateDebut", months[11].dateDebut, "2026-05-01");
eq("dernier mois dateFin", months[11].dateFin, "2026-05-31");
eq("dernier mois serials.length = 31", months[11].dailySerials.length, 31);
// Couvre février d'une année non bissextile (2025) — window now=2025-06-15 → 2024-06..2025-05
const fFeb25 = buildGridNetworkQlikDateFilter(new Date("2025-06-15T08:00:00Z"));
truthy("contient février 2025 (28j)", getMonthRanges(fFeb25).some((m) => m.label === "2025-02" && m.dailySerials.length === 28));
// Couvre février d'une année bissextile (2024)
const fBiss = buildGridNetworkQlikDateFilter(new Date("2025-01-05T08:00:00Z"));
const monthsBiss = getMonthRanges(fBiss);
truthy("contient février 2024 (29j)", monthsBiss.some((m) => m.label === "2024-02" && m.dailySerials.length === 29));
// Total jours = 365 (somme des serials)
const totalDays = months.reduce((acc, m) => acc + m.dailySerials.length, 0);
eq("somme des jours = 365", totalDays, 365);
console.log("\n[9] getMonthRanges — fenêtre courte partielle (troncature dernier mois)");
const fShort = { ...f, dateDebut: "2025-06-01", dateFin: "2025-08-15", qStart: 45809, qEnd: 45884, dailySerials: [], setAnalysis: "" };
const monthsShort = getMonthRanges(fShort);
eq("3 mois (juin, juillet, août)", monthsShort.length, 3);
eq("août tronqué à 15j", monthsShort[2].dailySerials.length, 15);
eq("août dateFin = 2025-08-15", monthsShort[2].dateFin, "2025-08-15");
console.log("\n[10] monthsBack=6 (now=2026-06-21) → 2025-12-01 .. 2026-05-31");
const f6 = buildGridNetworkQlikDateFilter(new Date("2026-06-21T12:00:00Z"), 6);
eq("dateDebut", f6.dateDebut, "2025-12-01");
eq("dateFin", f6.dateFin, "2026-05-31");
eq("qStart", f6.qStart, 45992);
eq("qEnd", f6.qEnd, 46173);
eq("label", f6.label, "2025-12_2026-05");
eq("dailySerials.length = 182 (déc 2025:31 + jan:31 + fév:28 + mar:31 + avr:30 + mai:31)", f6.dailySerials.length, 182);
const months6 = getMonthRanges(f6);
eq("getMonthRanges: 6 mois exactement", months6.length, 6);
eq("premier mois label", months6[0].label, "2025-12");
eq("dernier mois label", months6[5].label, "2026-05");
const totalDays6 = months6.reduce((acc, m) => acc + m.dailySerials.length, 0);
eq("somme des jours = 182", totalDays6, 182);
console.log("\n[11] monthsBack — bornes et clamp");
eq("monthsBack=1 (now=2026-06-21) → 2026-05-01 .. 2026-05-31 (1 mois complet)",
(() => { const x = buildGridNetworkQlikDateFilter(new Date("2026-06-21T12:00:00Z"), 1); return x.dateDebut === "2026-05-01" && x.dateFin === "2026-05-31"; })(),
true);
eq("monthsBack=0 clampé à 1", (() => { const x = buildGridNetworkQlikDateFilter(new Date("2026-06-21T12:00:00Z"), 0); return x.dateDebut === "2026-05-01"; })(), true);
eq("monthsBack=24 clampé à 12 (max)", (() => { const x = buildGridNetworkQlikDateFilter(new Date("2026-06-21T12:00:00Z"), 24); return x.dateDebut === "2025-06-01" && x.dateFin === "2026-05-31"; })(), true);
eq("monthsBack=-5 clampé à 1", (() => { const x = buildGridNetworkQlikDateFilter(new Date("2026-06-21T12:00:00Z"), -5); return x.dateDebut === "2026-05-01"; })(), true);
eq("monthsBack=NaN ramené au défaut 12", (() => { const x = buildGridNetworkQlikDateFilter(new Date("2026-06-21T12:00:00Z"), Number.NaN); return x.dateDebut === "2025-06-01" && x.dateFin === "2026-05-31"; })(), true);
eq("monthsBack=undefined (par défaut) = défaut 12", (() => { const x = buildGridNetworkQlikDateFilter(new Date("2026-06-21T12:00:00Z")); return x.dateDebut === "2025-06-01" && x.dateFin === "2026-05-31"; })(), true);
eq("bornes exportées MIN=1", QLIK_MONTHS_BACK_MIN, 1);
eq("bornes exportées MAX=12", QLIK_MONTHS_BACK_MAX, 12);
eq("défaut exporté = 12", QLIK_MONTHS_BACK_DEFAULT, 12);
console.log("\n[12] envMonthsBack — lecture env");
const PREV = process.env.QLIK_TEST_MONTHS_BACK;
delete process.env.QLIK_TEST_MONTHS_BACK;
eq("env absent → défaut", envMonthsBack("QLIK_TEST_MONTHS_BACK"), QLIK_MONTHS_BACK_DEFAULT);
eq("env vide → défaut", (() => { process.env.QLIK_TEST_MONTHS_BACK = ""; return envMonthsBack("QLIK_TEST_MONTHS_BACK"); })(), QLIK_MONTHS_BACK_DEFAULT);
eq("env '6' → 6", (() => { process.env.QLIK_TEST_MONTHS_BACK = "6"; return envMonthsBack("QLIK_TEST_MONTHS_BACK"); })(), 6);
eq("env 'abc' → défaut", (() => { process.env.QLIK_TEST_MONTHS_BACK = "abc"; return envMonthsBack("QLIK_TEST_MONTHS_BACK"); })(), QLIK_MONTHS_BACK_DEFAULT);
eq("env '24' clampé à MAX=12", (() => { process.env.QLIK_TEST_MONTHS_BACK = "24"; return envMonthsBack("QLIK_TEST_MONTHS_BACK"); })(), QLIK_MONTHS_BACK_MAX);
eq("env '-3' clampé à MIN=1", (() => { process.env.QLIK_TEST_MONTHS_BACK = "-3"; return envMonthsBack("QLIK_TEST_MONTHS_BACK"); })(), QLIK_MONTHS_BACK_MIN);
eq("env '0' clampé à MIN=1", (() => { process.env.QLIK_TEST_MONTHS_BACK = "0"; return envMonthsBack("QLIK_TEST_MONTHS_BACK"); })(), QLIK_MONTHS_BACK_MIN);
eq("env '3.7' troncé à 3", (() => { process.env.QLIK_TEST_MONTHS_BACK = "3.7"; return envMonthsBack("QLIK_TEST_MONTHS_BACK"); })(), 3);
eq("env ' 8 ' trimé puis parsé → 8", (() => { process.env.QLIK_TEST_MONTHS_BACK = " 8 "; return envMonthsBack("QLIK_TEST_MONTHS_BACK"); })(), 8);
if (PREV === undefined) delete process.env.QLIK_TEST_MONTHS_BACK; else process.env.QLIK_TEST_MONTHS_BACK = PREV;
console.log(`\n=== ${pass} passés, ${fail} échoués ===`);
if (fail > 0) process.exit(1);
+73 -60
View File
@@ -28,6 +28,43 @@ interface Props {
pivotted: HitParadePivotRow[];
}
function ColHeader({
label,
sortable,
group,
sortKey,
sortDir,
onSort,
}: {
label: string;
sortable?: SortKey;
group?: "292" | "579" | "total";
sortKey: SortKey;
sortDir: SortDir;
onSort: (key: SortKey) => void;
}) {
const isActive = sortable && sortKey === sortable;
const groupBg = group === "292" ? "bg-blue-50" : group === "579" ? "bg-violet-50" : group === "total" ? "bg-emerald-50" : "";
const activeBg = group === "292" ? "bg-blue-100 text-blue-700" : group === "579" ? "bg-violet-100 text-violet-700" : group === "total" ? "bg-emerald-100 text-emerald-700" : "bg-blue-50 text-blue-700";
return (
<th
onClick={sortable ? () => onSort(sortable) : undefined}
className={[
"px-2 py-2 text-center text-xs font-semibold whitespace-nowrap select-none",
sortable ? "cursor-pointer" : "",
isActive ? activeBg : `${groupBg} text-gray-600`,
].join(" ")}
>
{label}
{sortable && (
<span className="ml-1 inline-block w-3 text-center">
{isActive ? (sortDir === "desc" ? "↓" : "↑") : <span className="text-gray-300">↕</span>}
</span>
)}
</th>
);
}
export function HitParadeClient({ dateDebut, dateFin, pivotted }: Props) {
const router = useRouter();
const [sortKey, setSortKey] = useState<SortKey>(DEFAULT_SORT);
@@ -73,6 +110,32 @@ export function HitParadeClient({ dateDebut, dateFin, pivotted }: Props) {
const isSorted = sortKey !== DEFAULT_SORT || sortDir !== DEFAULT_DIR;
const filtered = useMemo(() =>
pivotted.filter(r =>
(filterFournisseur === "Tous" || r.fournisseur === filterFournisseur) &&
(filterNomenclature === "Tous" || r.nomenclature_code === filterNomenclature)
),
[pivotted, filterFournisseur, filterNomenclature]
);
const sorted = useMemo(() =>
[...filtered].sort((a, b) => {
const diff = a[sortKey] - b[sortKey];
return sortDir === "desc" ? -diff : diff;
}),
[filtered, sortKey, sortDir]
);
const totals = useMemo(() => sorted.reduce(
(acc, r) => ({
qte292: acc.qte292 + r.qte292, ca292: acc.ca292 + r.ca292, marge292: acc.marge292 + r.marge292,
qte579: acc.qte579 + r.qte579, ca579: acc.ca579 + r.ca579, marge579: acc.marge579 + r.marge579,
qteTotal: acc.qteTotal + r.qteTotal, caTotal: acc.caTotal + r.caTotal, margeTotal: acc.margeTotal + r.margeTotal,
stock292: acc.stock292 + r.stock292, stock579: acc.stock579 + r.stock579, stockTotal: acc.stockTotal + r.stockTotal,
}),
{ qte292: 0, ca292: 0, marge292: 0, qte579: 0, ca579: 0, marge579: 0, qteTotal: 0, caTotal: 0, margeTotal: 0, stock292: 0, stock579: 0, stockTotal: 0 }
), [sorted]);
function exportToExcel() {
const headers = [
"Code", "Désignation", "Fournisseur", "Nomenclature",
@@ -115,56 +178,6 @@ export function HitParadeClient({ dateDebut, dateFin, pivotted }: Props) {
XLSX.writeFile(wb, `hit-parade_${dateDebut}_${dateFin}.xlsx`);
}
const filtered = useMemo(() =>
pivotted.filter(r =>
(filterFournisseur === "Tous" || r.fournisseur === filterFournisseur) &&
(filterNomenclature === "Tous" || r.nomenclature_code === filterNomenclature)
),
[pivotted, filterFournisseur, filterNomenclature]
);
const sorted = useMemo(() =>
[...filtered].sort((a, b) => {
const diff = a[sortKey] - b[sortKey];
return sortDir === "desc" ? -diff : diff;
}),
[filtered, sortKey, sortDir]
);
const totals = useMemo(() => sorted.reduce(
(acc, r) => ({
qte292: acc.qte292 + r.qte292, ca292: acc.ca292 + r.ca292, marge292: acc.marge292 + r.marge292,
qte579: acc.qte579 + r.qte579, ca579: acc.ca579 + r.ca579, marge579: acc.marge579 + r.marge579,
qteTotal: acc.qteTotal + r.qteTotal, caTotal: acc.caTotal + r.caTotal, margeTotal: acc.margeTotal + r.margeTotal,
stock292: acc.stock292 + r.stock292, stock579: acc.stock579 + r.stock579, stockTotal: acc.stockTotal + r.stockTotal,
}),
{ qte292: 0, ca292: 0, marge292: 0, qte579: 0, ca579: 0, marge579: 0, qteTotal: 0, caTotal: 0, margeTotal: 0, stock292: 0, stock579: 0, stockTotal: 0 }
), [sorted]);
function ColHeader({ label, sortable, k, group }: { label: string; sortable?: SortKey; k: string; group?: "292" | "579" | "total" }) {
const isActive = sortable && sortKey === sortable;
const groupBg = group === "292" ? "bg-blue-50" : group === "579" ? "bg-violet-50" : group === "total" ? "bg-emerald-50" : "";
const activeBg = group === "292" ? "bg-blue-100 text-blue-700" : group === "579" ? "bg-violet-100 text-violet-700" : group === "total" ? "bg-emerald-100 text-emerald-700" : "bg-blue-50 text-blue-700";
return (
<th
key={k}
onClick={sortable ? () => handleSort(sortable) : undefined}
className={[
"px-2 py-2 text-center text-xs font-semibold whitespace-nowrap select-none",
sortable ? "cursor-pointer" : "",
isActive ? activeBg : `${groupBg} text-gray-600`,
].join(" ")}
>
{label}
{sortable && (
<span className="ml-1 inline-block w-3 text-center">
{isActive ? (sortDir === "desc" ? "↓" : "↑") : <span className="text-gray-300">↕</span>}
</span>
)}
</th>
);
}
return (
<div className="space-y-4">
{/* Barre de contrôles */}
@@ -277,22 +290,22 @@ export function HitParadeClient({ dateDebut, dateFin, pivotted }: Props) {
</th>
</tr>
<tr className="border-b-2 border-gray-200">
<ColHeader label="Qté" sortable="qte292" k="qte292" group="292" />
<ColHeader label="CA TTC" sortable="ca292" k="ca292" group="292" />
<ColHeader label="Qté" sortable="qte292" group="292" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} />
<ColHeader label="CA TTC" sortable="ca292" group="292" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} />
<th className="bg-blue-50 px-2 py-2 text-center text-xs font-semibold text-blue-500">% Marge</th>
<ColHeader label="Stock" sortable="stock292" k="stock292" group="292" />
<ColHeader label="Qté" sortable="qte579" k="qte579" group="579" />
<ColHeader label="CA TTC" sortable="ca579" k="ca579" group="579" />
<ColHeader label="Stock" sortable="stock292" group="292" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} />
<ColHeader label="Qté" sortable="qte579" group="579" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} />
<ColHeader label="CA TTC" sortable="ca579" group="579" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} />
<th className="bg-violet-50 px-2 py-2 text-center text-xs font-semibold text-violet-500">% Marge</th>
<ColHeader label="Stock" sortable="stock579" k="stock579" group="579" />
<ColHeader label="Qté" sortable="qteTotal" k="qteTotal" group="total" />
<ColHeader label="CA TTC" sortable="caTotal" k="caTotal" group="total" />
<ColHeader label="Stock" sortable="stock579" group="579" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} />
<ColHeader label="Qté" sortable="qteTotal" group="total" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} />
<ColHeader label="CA TTC" sortable="caTotal" group="total" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} />
<th className="bg-emerald-50 px-2 py-2 text-center text-xs font-semibold text-emerald-600">% Marge</th>
<ColHeader label="Stock" sortable="stockTotal" k="stockTotal" group="total" />
<ColHeader label="Stock" sortable="stockTotal" group="total" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} />
</tr>
</thead>
<tbody>
{sorted.map((row, idx) => (
{sorted.map((row) => (
<tr key={row.codein} className={`border-b border-gray-100 hover:brightness-95 transition-colors`}>
<td className="bg-white px-2 py-2.5 font-mono text-xs text-gray-400 text-center">{row.codein}</td>
<td className="bg-white px-2 py-2.5 text-gray-900 font-medium min-w-[200px]">{row.libelle.trim()}</td>
+11 -8
View File
@@ -42,14 +42,17 @@ function pivotHitParade(rows: HitParadeRow[]): HitParadePivotRow[] {
});
}
const entry = map.get(row.codein)!;
if (row.site === "292") {
entry.qte292 = row.qte_vendue;
entry.ca292 = row.ca_ttc;
entry.marge292 = row.marge;
} else if (row.site === "579") {
entry.qte579 = row.qte_vendue;
entry.ca579 = row.ca_ttc;
entry.marge579 = row.marge;
// Accumulation (+=) et non affectation : si la requête renvoyait plusieurs
// lignes pour un même (codein, site), aucune valeur ne serait écrasée.
const site = String(row.site).trim();
if (site === "292") {
entry.qte292 += row.qte_vendue;
entry.ca292 += row.ca_ttc;
entry.marge292 += row.marge;
} else if (site === "579") {
entry.qte579 += row.qte_vendue;
entry.ca579 += row.ca_ttc;
entry.marge579 += row.marge;
}
}
+546
View File
@@ -0,0 +1,546 @@
"use client";
import { useMemo, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import {
ArrowLeft, Package, Warehouse, ShoppingCart, Globe, Layers,
RefreshCw, Loader2, CheckCircle, AlertCircle, WifiOff, LayoutGrid,
} from "lucide-react";
import { HeatmapCell } from "@/features/grid/components/heatmap-cell";
import { NetworkLineChart, DualLineChart, TrendSparkline } from "@/features/grid/components/network-charts";
import { computeNetworkTrend, trendLabel, TREND_COLOR, NB_MAGASINS_RESEAU } from "@/features/grid/lib/network-trend";
import { formatMonthLabel, formatDate, SITE_LABELS, ffMonthToQlik } from "@/features/grid/lib/months";
import { useQlikSyncJob } from "@/features/qlik-sync/use-qlik-sync-job";
import { computeComparatif, indiceVerdict } from "@/features/produits/lib/compare-reseau";
import type { ProduitFiche } from "@/features/produits/types";
import { OpportunitesFamille } from "./opportunites";
// ─── Formatteurs partagés ────────────────────────────────────────────────────
const fmtEur = (v: number) =>
new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 }).format(v);
const fmtQte = (v: number) => new Intl.NumberFormat("fr-FR", { maximumFractionDigits: 0 }).format(v);
const fmtDec = (v: number, d = 1) => new Intl.NumberFormat("fr-FR", { minimumFractionDigits: d, maximumFractionDigits: d }).format(v);
// ─── Briques de présentation ─────────────────────────────────────────────────
function Card({ title, icon: Icon, children, action }: {
title: string;
icon: React.ComponentType<{ className?: string; style?: React.CSSProperties }>;
children: React.ReactNode;
action?: React.ReactNode;
}) {
return (
<section className="apple-card">
<div className="apple-card-header flex items-center justify-between gap-3">
<h2 className="flex items-center gap-2 text-[13px] font-semibold" style={{ color: "var(--text-primary)" }}>
<Icon className="w-4 h-4" style={{ color: "var(--accent)" }} />
{title}
</h2>
{action}
</div>
<div className="apple-card-content">{children}</div>
</section>
);
}
function Field({ label, value, mono }: { label: string; value: React.ReactNode; mono?: boolean }) {
return (
<div className="min-w-0">
<div className="text-[10px] font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>
{label}
</div>
<div className={`text-[13px] truncate ${mono ? "font-mono" : ""}`} style={{ color: "var(--text-primary)" }}>
{value ?? "—"}
</div>
</div>
);
}
function Stat({ label, value, hint, color }: { label: string; value: string; hint?: string; color?: string }) {
return (
<div className="rounded-xl p-3" style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}>
<div className="text-[10px] font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>
{label}
</div>
<div className="text-[19px] font-black tabular-nums leading-tight mt-0.5" style={{ color: color ?? "var(--text-primary)" }}>
{value}
</div>
{hint && <div className="text-[10px] mt-0.5" style={{ color: "var(--text-muted)" }}>{hint}</div>}
</div>
);
}
// ─── Bouton d'extraction Qlik pour un seul code centrale ─────────────────────
function SyncProduitButton({ codeCentrale }: { codeCentrale: string }) {
const router = useRouter();
const { status, message, start } = useQlikSyncJob({
target: { mode: "produit", codeCentrale },
onSuccess: () => router.refresh(),
});
return (
<div className="flex items-center gap-2">
{message && (
<span
className="text-[10px] leading-tight text-right max-w-[280px]"
style={{ color: status === "error" ? "var(--accent-error)" : "var(--text-muted)" }}
title={message}
>
{message}
</span>
)}
<button
onClick={start}
disabled={status === "running"}
className="btn-action btn-action-secondary flex items-center gap-1.5 disabled:opacity-60"
title="Extraire les données réseau Qlik pour ce seul produit"
>
{status === "running" ? <Loader2 className="w-3.5 h-3.5 animate-spin" />
: status === "success" ? <CheckCircle className="w-3.5 h-3.5 text-emerald-500" />
: status === "error" ? <AlertCircle className="w-3.5 h-3.5 text-rose-500" />
: <RefreshCw className="w-3.5 h-3.5" />}
{status === "running" ? "Extraction…" : "Charger les données réseau"}
</button>
</div>
);
}
// ─── Fiche ───────────────────────────────────────────────────────────────────
export function ProduitFicheView({ fiche, backQuery }: { fiche: ProduitFiche; backQuery: string }) {
const { detail, fournisseurs, stock, commandesEnCours, gammes, months, reseau } = fiche;
const [magasin, setMagasin] = useState<string>("TOTAL");
const sitesDisponibles = useMemo(() => {
const sites = new Set<string>([...Object.keys(fiche.mensuelParSite), ...stock.map((s) => s.site)]);
return [...sites].filter(Boolean).sort();
}, [fiche.mensuelParSite, stock]);
// Série affichée selon le magasin sélectionné.
const serie = magasin === "TOTAL" ? fiche.mensuelTotal : (fiche.mensuelParSite[magasin] ?? {});
const totauxAffiches = magasin === "TOTAL" ? fiche.totaux : (fiche.totauxParSite[magasin] ?? { qte: 0, ca: 0, marge: 0, tauxMarge: 0 });
const trend = useMemo(() => computeNetworkTrend(reseau?.qteByMonth ?? null), [reseau]);
const comparatif = useMemo(() => computeComparatif(fiche), [fiche]);
const verdict = indiceVerdict(comparatif.indiceQte);
const fournisseurPrincipal = fournisseurs.find((f) => f.principal) ?? fournisseurs[0];
return (
<div className="space-y-5">
{/* En-tête */}
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<Link
href={backQuery ? `/produits?q=${encodeURIComponent(backQuery)}` : "/produits"}
className="inline-flex items-center gap-1.5 text-[12px] mb-1.5 hover:underline"
style={{ color: "var(--text-muted)" }}
>
<ArrowLeft className="w-3.5 h-3.5" />
Retour aux résultats
</Link>
<h2 className="text-[20px] font-semibold tracking-[-0.3px]" style={{ color: "var(--text-primary)" }}>
{detail.libelle1 || "Sans libellé"}
</h2>
<p className="text-[12px] mt-0.5" style={{ color: "var(--text-muted)" }}>
<span className="font-mono">{detail.codein}</span>
{detail.code_centrale && <> · centrale <span className="font-mono">{detail.code_centrale}</span></>}
{fournisseurPrincipal && <> · {fournisseurPrincipal.nomfou}</>}
</p>
</div>
{fournisseurPrincipal?.codefou && (
<Link
href={`/grid?fournisseur=${encodeURIComponent(fournisseurPrincipal.codefou)}&magasin=TOTAL`}
className="btn-action btn-action-secondary flex items-center gap-1.5"
title="Ouvrir la grille d'arbitrage de ce fournisseur"
>
<LayoutGrid className="w-3.5 h-3.5" />
Voir dans la Grille
</Link>
)}
</div>
{/* Identité */}
<Card title="Identité" icon={Package}>
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
<Field label="Code article" value={detail.codein} mono />
<Field label="Code centrale" value={detail.code_centrale || "—"} mono />
<Field label="GTIN" value={detail.gtin || "—"} mono />
<Field label="Référence fou." value={detail.reference || "—"} mono />
<Field label="PCB" value={detail.pcb != null ? fmtQte(detail.pcb) : "—"} />
<Field label="Gamme actuelle" value={gammes[0]?.gamme_code ?? "—"} />
<Field label="Secteur" value={detail.libelle1nom ?? "—"} />
<Field label="Famille" value={detail.libelle2 ?? "—"} />
<Field label="Sous-famille" value={detail.libelle3 ?? "—"} />
<Field label="Prix d'achat" value={detail.pa != null ? fmtEur(detail.pa) : "—"} />
<Field label="PV central" value={detail.pv_central != null ? fmtEur(detail.pv_central) : "—"} />
<Field label="En commande" value={commandesEnCours > 0 ? fmtQte(commandesEnCours) : "—"} />
</div>
{fournisseurs.length > 0 && (
<div className="mt-4 pt-3" style={{ borderTop: "1px solid var(--border)" }}>
<div className="text-[10px] font-semibold uppercase tracking-wide mb-1.5" style={{ color: "var(--text-muted)" }}>
Fournisseurs référencés
</div>
<div className="flex flex-wrap gap-1.5">
{fournisseurs.map((f) => (
<span
key={f.codefou}
className="rounded-lg px-2 py-1 text-[11px]"
style={{
background: f.principal ? "var(--accent-bg)" : "var(--bg-elevated)",
border: `1px solid ${f.principal ? "var(--accent-border)" : "var(--border)"}`,
color: f.principal ? "var(--accent)" : "var(--text-secondary)",
}}
title={f.reference ? `Réf. ${f.reference}` : undefined}
>
{f.nomfou}
{f.principal && <span className="ml-1 font-semibold">· principal</span>}
</span>
))}
</div>
</div>
)}
{gammes.length > 1 && (
<div className="mt-3 pt-3" style={{ borderTop: "1px solid var(--border)" }}>
<div className="text-[10px] font-semibold uppercase tracking-wide mb-1.5" style={{ color: "var(--text-muted)" }}>
Historique de gamme
</div>
<div className="flex flex-wrap gap-1.5">
{gammes.slice(0, 10).map((g, i) => (
<span
key={`${g.saison_no_id}-${i}`}
className="rounded-lg px-2 py-1 text-[11px]"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}
>
{g.saison_libelle || g.saison_code || `Saison ${g.saison_no_id}`}
<span className="ml-1.5 font-bold" style={{ color: "var(--text-primary)" }}>{g.gamme_code}</span>
</span>
))}
</div>
</div>
)}
</Card>
{/* Stock */}
<Card title="Stock temps réel" icon={Warehouse}>
{stock.length === 0 ? (
<p className="text-[13px]" style={{ color: "var(--text-muted)" }}>Aucune ligne de stock pour ce produit.</p>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{stock.map((s) => (
<div key={s.site} className="rounded-xl p-3" style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}>
<div className="flex items-center justify-between mb-2">
<span className="text-[12px] font-semibold" style={{ color: "var(--text-primary)" }}>
{SITE_LABELS[s.site]?.nom ?? s.site}
</span>
<span
className="text-[16px] font-black tabular-nums"
style={{ color: s.qte < 0 ? "var(--accent-error)" : "var(--text-primary)" }}
>
{fmtQte(s.qte)}
</span>
</div>
<div className="grid grid-cols-2 gap-2 text-[11px]" style={{ color: "var(--text-secondary)" }}>
<div>Dispo : <span className="tabular-nums">{fmtQte(s.stockdispo)}</span></div>
<div>Valeur : <span className="tabular-nums">{fmtEur(s.valstock)}</span></div>
<div>PRMP : <span className="tabular-nums">{s.prmp > 0 ? fmtEur(s.prmp) : "—"}</span></div>
<div>Dern. vente : {formatDate(s.dernierevente)}</div>
<div className="col-span-2">Dern. réception : {formatDate(s.dernierereception)}</div>
</div>
</div>
))}
</div>
)}
</Card>
{/* Ventes 12 mois glissants */}
<Card
title="Ventes · 12 mois glissants"
icon={ShoppingCart}
action={
<div className="segment-control">
{["TOTAL", ...sitesDisponibles].map((code) => (
<button
key={code}
onClick={() => setMagasin(code)}
className={`segment-btn ${magasin === code ? "active" : ""}`}
>
{code === "TOTAL" ? "Total" : (SITE_LABELS[code]?.label ?? code)}
</button>
))}
</div>
}
>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-4">
<Stat label="Quantité vendue" value={fmtQte(totauxAffiches.qte)} />
<Stat label="CA (TTC)" value={fmtEur(totauxAffiches.ca)} />
<Stat label="Marge" value={fmtEur(totauxAffiches.marge)} />
<Stat
label="Taux de marge"
value={`${fmtDec(totauxAffiches.tauxMarge)} %`}
color={totauxAffiches.tauxMarge >= 40 ? "#22c55e" : totauxAffiches.tauxMarge >= 25 ? "#f59e0b" : undefined}
/>
</div>
<div className="overflow-x-auto">
<table className="w-full border-collapse text-[12px]">
<thead>
<tr>
<th className="px-2 py-1.5 text-left text-[10px] font-semibold uppercase" style={{ color: "var(--text-muted)" }}>
Mois
</th>
{months.map((m) => (
<th key={m} className="px-1 py-1.5 text-center text-[10px] font-semibold whitespace-nowrap" style={{ color: "var(--text-muted)" }}>
{formatMonthLabel(m)}
</th>
))}
</tr>
</thead>
<tbody>
<tr>
<td className="px-2 py-1 text-[11px] font-medium whitespace-nowrap" style={{ color: "var(--text-secondary)" }}>
Ventes
</td>
{months.map((m) => (
<td key={m} className="px-0.5 py-1 min-w-[46px]">
<HeatmapCell
value={serie[m]?.qte ?? 0}
tooltipStock={serie[m]?.stockFinMois ?? null}
tooltipReceptions={serie[m]?.qteRecue ?? null}
/>
</td>
))}
</tr>
<tr>
<td className="px-2 py-1 text-[11px] font-medium whitespace-nowrap" style={{ color: "var(--text-secondary)" }}>
CA (TTC)
</td>
{months.map((m) => (
<td key={m} className="px-0.5 py-1 text-center tabular-nums text-[11px]" style={{ color: "var(--text-muted)" }}>
{serie[m]?.ca ? Math.round(serie[m].ca).toLocaleString("fr-FR") : "—"}
</td>
))}
</tr>
<tr>
<td className="px-2 py-1 text-[11px] font-medium whitespace-nowrap" style={{ color: "var(--text-secondary)" }}>
Stock fin de mois
</td>
{months.map((m) => (
<td key={m} className="px-0.5 py-1 text-center tabular-nums text-[11px]" style={{ color: "var(--text-muted)" }}>
{serie[m]?.stockFinMois ? Math.round(serie[m].stockFinMois).toLocaleString("fr-FR") : "—"}
</td>
))}
</tr>
</tbody>
</table>
</div>
<NetworkLineChart
labels={months.map(ffMonthToQlik)}
values={months.map((m) => serie[m]?.qte ?? 0)}
color="#0ea5e9"
/>
</Card>
{/* Réseau Qlik */}
<Card
title="Réseau Qlik"
icon={Globe}
action={detail.code_centrale ? <SyncProduitButton codeCentrale={detail.code_centrale} /> : undefined}
>
{!detail.code_centrale ? (
<p className="text-[13px]" style={{ color: "var(--text-muted)" }}>
Ce produit n&apos;a pas de code centrale : il n&apos;est pas référencé centralement et n&apos;existe donc pas dans Qlik.
</p>
) : !reseau ? (
<div className="flex items-start gap-3 rounded-xl p-4" style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}>
<WifiOff className="w-4 h-4 mt-0.5 shrink-0" style={{ color: "var(--text-muted)" }} />
<div className="text-[13px]" style={{ color: "var(--text-secondary)" }}>
Aucune donnée réseau en cache pour ce produit.
<div className="text-[12px] mt-0.5" style={{ color: "var(--text-muted)" }}>
Lancez l&apos;extraction ci-dessus : elle ne porte que sur ce code centrale et prend quelques secondes.
</div>
</div>
</div>
) : (
<>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">
<Stat label="CA réseau" value={fmtEur(reseau.caReseau)} />
<Stat label="Qté réseau" value={fmtQte(reseau.qteReseau)} />
<Stat
label="Magasins"
value={`${reseau.nbMagasinsReseau}`}
hint={`sur ${NB_MAGASINS_RESEAU} · ${fmtDec((reseau.nbMagasinsReseau / NB_MAGASINS_RESEAU) * 100, 0)} %`}
/>
<Stat label="CA / magasin" value={fmtEur(reseau.caParMagasinReseau)} />
<Stat
label="Marge % réseau"
value={comparatif.tauxMargeReseau != null ? `${fmtDec(comparatif.tauxMargeReseau)} %` : "—"}
/>
<Stat
label="Tendance 12 m"
value={trend.pct != null ? `${trend.pct >= 0 ? "+" : ""}${Math.round(trend.pct * 100)} %` : "—"}
hint={trendLabel(trend.pct)}
color={TREND_COLOR[trend.direction]}
/>
</div>
<p className="text-[11px] mt-3" style={{ color: "var(--text-muted)" }}>
Période {reseau.periode ?? "12 mois"} · extrait le {formatDate(reseau.fetchedAt)}
</p>
{trend.hasData && (
<div className="mt-3">
<div className="text-[10px] font-semibold uppercase tracking-wide mb-1" style={{ color: "var(--text-muted)" }}>
Quantités vendues réseau, mois par mois
</div>
<NetworkLineChart labels={trend.labels} values={trend.values} color={TREND_COLOR[trend.direction]} />
</div>
)}
{/* Détail mensuel complet (CA, marge, magasins) si disponible */}
{reseau.metricsByMonth && Object.keys(reseau.metricsByMonth).length > 0 && (
<div className="mt-4 overflow-x-auto">
<div className="text-[10px] font-semibold uppercase tracking-wide mb-1.5" style={{ color: "var(--text-muted)" }}>
Détail mensuel réseau
</div>
<table className="w-full border-collapse text-[11px]">
<thead>
<tr>
<th className="px-2 py-1.5 text-left font-semibold" style={{ color: "var(--text-muted)" }}>Mesure</th>
{months.map((m) => (
<th key={m} className="px-1 py-1.5 text-center font-semibold whitespace-nowrap" style={{ color: "var(--text-muted)" }}>
{formatMonthLabel(m)}
</th>
))}
</tr>
</thead>
<tbody>
{([
{ key: "qte", label: "Quantité", fmt: (v: number) => fmtQte(v) },
{ key: "ca", label: "CA", fmt: (v: number) => Math.round(v).toLocaleString("fr-FR") },
{ key: "nbMag", label: "Magasins", fmt: (v: number) => fmtQte(v) },
{ key: "margePct", label: "Marge %", fmt: (v: number) => `${fmtDec(Math.abs(v) <= 1 ? v * 100 : v)} %` },
] as const).map((row) => (
<tr key={row.key}>
<td className="px-2 py-1 font-medium whitespace-nowrap" style={{ color: "var(--text-secondary)" }}>
{row.label}
</td>
{months.map((m) => {
const v = reseau.metricsByMonth?.[ffMonthToQlik(m)]?.[row.key];
return (
<td key={m} className="px-1 py-1 text-center tabular-nums" style={{ color: "var(--text-muted)" }}>
{v != null ? row.fmt(v) : "—"}
</td>
);
})}
</tr>
))}
</tbody>
</table>
<p className="text-[10px] mt-1.5" style={{ color: "var(--text-muted)" }}>
Les mois sans CA ni marge proviennent de la mesure « Quantité COMP », qui ne porte que la quantité.
</p>
</div>
)}
</>
)}
</Card>
{/* Comparatif local vs réseau */}
{comparatif.hasReseau && (
<Card title="Nous vs. un magasin moyen du réseau" icon={Globe}>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Stat
label="Indice de performance"
value={comparatif.indiceQte != null ? `${Math.round(comparatif.indiceQte)} %` : "—"}
hint={verdict.label}
color={verdict.color}
/>
<Stat
label="Nos ventes / magasin"
value={fmtQte(comparatif.qteLocaleParMagasin)}
hint={`${comparatif.nbMagasinsLocaux} magasin${comparatif.nbMagasinsLocaux > 1 ? "s" : ""} · 12 mois`}
/>
<Stat
label="Réseau / magasin"
value={fmtQte(comparatif.qteReseauParMagasin)}
hint={`${comparatif.nbMagasinsReseau} magasins · 12 mois`}
/>
<Stat
label="Présence réseau"
value={comparatif.tauxPresence != null ? `${fmtDec(comparatif.tauxPresence * 100, 0)} %` : "—"}
hint={`${comparatif.nbMagasinsReseau} / ${NB_MAGASINS_RESEAU} magasins`}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 mt-3">
<div className="rounded-xl p-3 text-[12px]" style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}>
<div className="text-[10px] font-semibold uppercase tracking-wide mb-1" style={{ color: "var(--text-muted)" }}>
CA par magasin · indicatif
</div>
<div className="flex items-center justify-between" style={{ color: "var(--text-secondary)" }}>
<span>Nous : <span className="font-semibold tabular-nums" style={{ color: "var(--text-primary)" }}>{fmtEur(comparatif.caLocalParMagasin)}</span></span>
<span>Réseau : <span className="font-semibold tabular-nums" style={{ color: "var(--text-primary)" }}>{comparatif.caReseauParMagasin != null ? fmtEur(comparatif.caReseauParMagasin) : "—"}</span></span>
</div>
</div>
<div className="rounded-xl p-3 text-[12px]" style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}>
<div className="text-[10px] font-semibold uppercase tracking-wide mb-1" style={{ color: "var(--text-muted)" }}>
Taux de marge · indicatif
</div>
<div className="flex items-center justify-between" style={{ color: "var(--text-secondary)" }}>
<span>Nous : <span className="font-semibold tabular-nums" style={{ color: "var(--text-primary)" }}>{fmtDec(comparatif.tauxMargeLocal)} %</span></span>
<span>Réseau : <span className="font-semibold tabular-nums" style={{ color: "var(--text-primary)" }}>{comparatif.tauxMargeReseau != null ? `${fmtDec(comparatif.tauxMargeReseau)} %` : "—"}</span></span>
</div>
</div>
</div>
<p className="text-[11px] mt-2" style={{ color: "var(--text-muted)" }}>
L&apos;indice compare les <strong>quantités</strong> par magasin, seule base commune fiable.
Notre CA est TTC (issu des mouvements de caisse) ; la base du CA Qlik n&apos;est pas
documentée — montants et marges sont donc donnés à titre indicatif.
</p>
{comparatif.hasMonthly && (
<div className="mt-4">
<div className="text-[10px] font-semibold uppercase tracking-wide mb-1" style={{ color: "var(--text-muted)" }}>
Quantité par magasin, mois par mois
</div>
<DualLineChart
labels={comparatif.monthly.map((m) => ffMonthToQlik(m.mois))}
series={[
{ name: "Nous / magasin", values: comparatif.monthly.map((m) => m.local), color: "#0ea5e9" },
{ name: "Réseau / magasin", values: comparatif.monthly.map((m) => m.reseau), color: "#f59e0b" },
]}
/>
</div>
)}
</Card>
)}
{/* Opportunités de la même famille */}
{detail.nom_no_id != null && (
<Card title="Opportunités · même famille" icon={Layers}>
<OpportunitesFamille
nomNoId={detail.nom_no_id}
currentCodein={detail.codein}
familleLabel={detail.libelle3 ?? detail.libelle2 ?? ""}
/>
</Card>
)}
{/* Sparkline discrète en pied de fiche, cohérente avec la Grille */}
{trend.hasData && (
<div className="flex items-center justify-end gap-2 text-[11px]" style={{ color: "var(--text-muted)" }}>
<span>Tendance réseau :</span>
<TrendSparkline trend={trend} />
</div>
)}
</div>
);
}
+5
View File
@@ -0,0 +1,5 @@
import LoadingModal from "@/components/shared/loading-modal";
export default function ProduitsLoading() {
return <LoadingModal message="Chargement des données" subMessage="Recherche du produit..." />;
}
@@ -0,0 +1,136 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { Loader2, TrendingUp } from "lucide-react";
import type { PgOpportuniteRow } from "@/lib/pg-ff-client";
const fmtQte = (v: number) => new Intl.NumberFormat("fr-FR", { maximumFractionDigits: 0 }).format(v);
const fmtDec = (v: number) => new Intl.NumberFormat("fr-FR", { minimumFractionDigits: 1, maximumFractionDigits: 1 }).format(v);
/**
* Produits de la même famille que le produit affiché, classés par écart de
* performance réseau vs locale (quantité **par magasin**, pour neutraliser
* l'écart d'échelle entre ~270 magasins et nos 2 sites).
*
* Chargement à la demande : la requête agrège 12 mois de mouvements pour toute
* la famille, elle ne doit pas ralentir l'ouverture de la fiche.
*/
export function OpportunitesFamille({
nomNoId,
currentCodein,
familleLabel,
}: {
nomNoId: number;
currentCodein: string;
familleLabel: string;
}) {
const [rows, setRows] = useState<PgOpportuniteRow[] | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
async function load() {
setLoading(true);
setError(null);
try {
const res = await fetch(`/api/produits/opportunites?nomNoId=${nomNoId}`, { cache: "no-store" });
const data = await res.json();
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
setRows((data.rows ?? []) as PgOpportuniteRow[]);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setLoading(false);
}
}
if (rows === null) {
return (
<div className="space-y-2">
<p className="text-[13px]" style={{ color: "var(--text-secondary)" }}>
Comparer ce produit aux autres références{familleLabel ? ` de « ${familleLabel} »` : " de la même famille"} :
lesquelles le réseau vend bien et que nous vendons peu ?
</p>
<button onClick={load} disabled={loading} className="btn-action btn-action-secondary flex items-center gap-1.5 disabled:opacity-60">
{loading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <TrendingUp className="w-3.5 h-3.5" />}
{loading ? "Analyse…" : "Analyser la famille"}
</button>
{error && <p className="text-[12px]" style={{ color: "var(--accent-error)" }}>{error}</p>}
</div>
);
}
// On retire le produit courant : il est déjà détaillé au-dessus.
const others = rows.filter((r) => r.codein !== currentCodein);
if (others.length === 0) {
return (
<p className="text-[13px]" style={{ color: "var(--text-muted)" }}>
Aucune autre référence de cette famille n&apos;a de données réseau en cache.
Synchronisez d&apos;autres fournisseurs pour enrichir la comparaison.
</p>
);
}
return (
<div className="space-y-2">
<div className="overflow-x-auto rounded-xl" style={{ border: "1px solid var(--border)" }}>
<table className="w-full border-collapse text-[12px]">
<thead>
<tr style={{ background: "var(--bg-elevated)" }}>
<th className="px-3 py-2 text-left text-[10px] font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Produit</th>
<th className="px-3 py-2 text-left text-[10px] font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Fournisseur</th>
<th className="px-3 py-2 text-right text-[10px] font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Réseau / mag.</th>
<th className="px-3 py-2 text-right text-[10px] font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Nous / mag.</th>
<th className="px-3 py-2 text-right text-[10px] font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Écart</th>
<th className="px-3 py-2 text-right text-[10px] font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Magasins</th>
</tr>
</thead>
<tbody>
{others.map((r) => {
const opportunite = r.ecart_par_magasin > 0;
return (
<tr key={r.codein} className="transition-colors hover:bg-[var(--bg-elevated)]" style={{ borderTop: "1px solid var(--border)" }}>
<td className="px-3 py-2">
<Link
href={`/produits?codein=${encodeURIComponent(r.codein)}`}
className="font-medium hover:underline"
style={{ color: "var(--accent)" }}
>
{r.libelle1 || r.codein}
</Link>
</td>
<td className="px-3 py-2" style={{ color: "var(--text-secondary)" }}>{r.fournisseur}</td>
<td className="px-3 py-2 text-right tabular-nums" style={{ color: "var(--text-secondary)" }}>
{fmtDec(r.qte_reseau_par_magasin)}
</td>
<td className="px-3 py-2 text-right tabular-nums" style={{ color: "var(--text-secondary)" }}>
{fmtDec(r.qte_locale_par_magasin)}
</td>
<td
className="px-3 py-2 text-right tabular-nums font-semibold"
style={{ color: opportunite ? "#f59e0b" : "#22c55e" }}
title={opportunite
? "Le réseau vend plus que nous sur ce produit"
: "Nous vendons autant ou plus que le réseau"}
>
{r.ecart_par_magasin > 0 ? "+" : ""}{fmtDec(r.ecart_par_magasin)}
</td>
<td className="px-3 py-2 text-right tabular-nums text-[11px]" style={{ color: "var(--text-muted)" }}>
{fmtQte(r.nb_magasins_reseau)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<p className="text-[11px]" style={{ color: "var(--text-muted)" }}>
Écart = quantité réseau par magasin − notre quantité par magasin, sur 12 mois.
Un écart positif signale une référence sous-exploitée chez nous.
Périmètre : les articles <strong>de notre catalogue</strong> ayant déjà des données réseau —
Qlik est interrogé par code article, un produit que Nancy ne référence pas ne peut pas apparaître ici.
</p>
</div>
);
}
+57
View File
@@ -0,0 +1,57 @@
import { pgSearchProduits } from "@/lib/pg-ff-client";
import { getProduitFiche } from "@/features/produits/api/get-produit-fiche";
import { ProduitSearchBar } from "./search-bar";
import { ProduitResults } from "./results";
import { ProduitFicheView } from "./fiche";
export const dynamic = "force-dynamic";
/**
* Recherche produit : par libellé ou par code centrale.
*
* - `?q=…` → liste de résultats
* - `?codein=…` → fiche complète (URL partageable)
*/
export default async function ProduitsPage(props: {
searchParams: Promise<{ q?: string; codein?: string }>;
}) {
const searchParams = await props.searchParams;
const q = (searchParams.q ?? "").trim();
const codein = (searchParams.codein ?? "").trim();
const fiche = codein ? await getProduitFiche(codein) : null;
// On ne relance pas la recherche quand une fiche est ouverte : la liste
// reste accessible via le lien retour, qui conserve `q`.
const results = !codein && q.length >= 3 ? await pgSearchProduits(q) : [];
return (
<div className="w-full max-w-screen-2xl mx-auto space-y-6">
<header className="space-y-1">
<h1 className="text-2xl font-semibold tracking-[-0.4px]" style={{ color: "var(--text-primary)" }}>
Recherche produit
</h1>
<p className="text-[13px]" style={{ color: "var(--text-muted)" }}>
Par libellé ou par code centrale — ventes sur 12 mois glissants et données réseau Qlik.
</p>
</header>
{/* key : remonte le champ quand l'URL change (retour arrière, lien partagé) */}
<ProduitSearchBar key={q} initialQuery={q} />
{codein && !fiche && (
<div
className="rounded-xl p-6 text-center text-[13px]"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-muted)" }}
>
Aucun produit trouvé pour le code article <span className="font-mono">{codein}</span>.
</div>
)}
{fiche && <ProduitFicheView fiche={fiche} backQuery={q} />}
{!codein && (
<ProduitResults rows={results} query={q} />
)}
</div>
);
}
+129
View File
@@ -0,0 +1,129 @@
import Link from "next/link";
import { PackageSearch, Wifi, WifiOff } from "lucide-react";
import type { PgProduitSearchRow } from "@/lib/pg-ff-client";
function fmtQte(v: number): string {
return new Intl.NumberFormat("fr-FR", { maximumFractionDigits: 0 }).format(v);
}
/** Liste des résultats de recherche. */
export function ProduitResults({ rows, query }: { rows: PgProduitSearchRow[]; query: string }) {
if (!query) {
return (
<div
className="rounded-xl p-10 text-center"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}
>
<PackageSearch className="w-8 h-8 mx-auto mb-3" style={{ color: "var(--text-muted)" }} strokeWidth={1.5} />
<p className="text-[14px] font-medium" style={{ color: "var(--text-secondary)" }}>
Recherchez un produit
</p>
<p className="text-[12px] mt-1" style={{ color: "var(--text-muted)" }}>
Saisissez un libellé (ex. « poêle 28 ») ou un code centrale (ex. « 10000167303 »).
</p>
</div>
);
}
if (rows.length === 0) {
return (
<div
className="rounded-xl p-8 text-center text-[13px]"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-muted)" }}
>
Aucun produit ne correspond à « {query} ».
</div>
);
}
return (
<div className="space-y-2">
<p className="text-[12px]" style={{ color: "var(--text-muted)" }}>
{rows.length} produit{rows.length > 1 ? "s" : ""} trouvé{rows.length > 1 ? "s" : ""}
{rows.length >= 50 && " (50 premiers — affinez la recherche)"}
</p>
<div className="overflow-x-auto rounded-xl" style={{ border: "1px solid var(--border)" }}>
<table className="w-full text-[13px] border-collapse">
<thead>
<tr style={{ background: "var(--bg-elevated)" }}>
<Th>Libellé</Th>
<Th>Fournisseur</Th>
<Th>Code article</Th>
<Th>Code centrale</Th>
<Th>Famille</Th>
<Th align="right">Stock</Th>
<Th align="right">Qté réseau</Th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr
key={r.codein}
className="transition-colors hover:bg-[var(--bg-elevated)]"
style={{ borderTop: "1px solid var(--border)" }}
>
<td className="px-3 py-2">
<Link
href={`/produits?codein=${encodeURIComponent(r.codein)}&q=${encodeURIComponent(query)}`}
className="font-medium hover:underline"
style={{ color: "var(--accent)" }}
>
{r.libelle1 || "—"}
</Link>
</td>
<td className="px-3 py-2" style={{ color: "var(--text-secondary)" }}>
{r.fournisseur || "—"}
</td>
<td className="px-3 py-2 font-mono text-[12px]" style={{ color: "var(--text-muted)" }}>
{r.codein}
</td>
<td className="px-3 py-2 font-mono text-[12px]" style={{ color: "var(--text-muted)" }}>
{r.code_centrale || "—"}
</td>
<td className="px-3 py-2 text-[12px]" style={{ color: "var(--text-secondary)" }}>
{r.nomenclature || "—"}
</td>
<td className="px-3 py-2 text-right tabular-nums" style={{ color: "var(--text-secondary)" }}>
{fmtQte(r.stock_total)}
</td>
<td className="px-3 py-2 text-right tabular-nums">
{r.has_reseau ? (
<span className="inline-flex items-center gap-1.5" style={{ color: "var(--text-secondary)" }}>
<Wifi className="w-3 h-3" style={{ color: "var(--accent-success)" }} />
{fmtQte(r.qte_reseau)}
<span className="text-[11px]" style={{ color: "var(--text-muted)" }}>
/ {r.nb_magasins_reseau} mag.
</span>
</span>
) : (
<span
className="inline-flex items-center gap-1.5 text-[11px]"
style={{ color: "var(--text-muted)" }}
title="Données réseau Qlik non synchronisées pour ce produit"
>
<WifiOff className="w-3 h-3" />
non synchro
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
function Th({ children, align = "left" }: { children: React.ReactNode; align?: "left" | "right" }) {
return (
<th
// Classes littérales : Tailwind ne génère pas les classes construites dynamiquement.
className={`px-3 py-2.5 ${align === "right" ? "text-right" : "text-left"} text-[11px] font-semibold uppercase tracking-wide whitespace-nowrap`}
style={{ color: "var(--text-muted)" }}
>
{children}
</th>
);
}
@@ -0,0 +1,79 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Search, X } from "lucide-react";
/**
* Champ de recherche produit.
*
* Soumission **explicite** (Entrée ou bouton) et non à la frappe : la requête
* fait un ILIKE sans index sur `articles.libelle1`, on ne veut pas la déclencher
* à chaque caractère. Minimum 3 caractères, aligné sur `pgSearchProduits`.
*
* L'appelant passe `key={q}` pour que le champ se resynchronise sur l'URL
* (retour arrière, lien partagé) par remontage, plutôt que par un effet qui
* appellerait setState en cascade.
*/
export function ProduitSearchBar({ initialQuery }: { initialQuery: string }) {
const router = useRouter();
const [term, setTerm] = useState(initialQuery);
const tooShort = term.trim().length > 0 && term.trim().length < 3;
function submit() {
const cleaned = term.trim();
if (cleaned.length < 3) return;
router.push(`/produits?q=${encodeURIComponent(cleaned)}`);
}
function clear() {
setTerm("");
router.push("/produits");
}
return (
<div className="space-y-1.5">
<div className="flex items-center gap-2">
<div className="relative flex-1 max-w-xl">
<Search
className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 pointer-events-none"
style={{ color: "var(--text-muted)" }}
/>
<input
type="text"
value={term}
onChange={(e) => setTerm(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") submit(); }}
placeholder="Libellé produit ou code centrale…"
aria-label="Rechercher un produit par libellé ou code centrale"
className="apple-input h-10 text-[14px]"
style={{ paddingLeft: "38px", paddingRight: term ? "36px" : undefined }}
/>
{term && (
<button
onClick={clear}
aria-label="Effacer la recherche"
className="absolute right-2.5 top-1/2 -translate-y-1/2 rounded-md p-1 transition-colors hover:bg-[var(--bg-elevated)]"
style={{ color: "var(--text-muted)" }}
>
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
<button
onClick={submit}
disabled={term.trim().length < 3}
className="btn-action btn-action-primary disabled:opacity-50"
>
Rechercher
</button>
</div>
{tooShort && (
<p className="text-[11px]" style={{ color: "var(--text-muted)" }}>
Saisissez au moins 3 caractères.
</p>
)}
</div>
);
}
+79 -1
View File
@@ -5,7 +5,7 @@ import { Save, Eye, EyeOff, CheckCircle, AlertCircle, Loader2, RefreshCw, Sun, M
import { useTheme } from "next-themes";
import { useGridStore } from "@/features/grid/store/use-grid-store";
import { useDbSettingsStore } from "@/features/settings/store/use-db-settings-store";
import { testDatabaseConnection, saveDatabaseSettings, getSavedDatabaseConfig } from "@/features/settings/actions";
import { testDatabaseConnection, saveDatabaseSettings, getSavedDatabaseConfig, saveQlikSettings, testQlikConnection } from "@/features/settings/actions";
import { useEffect } from "react";
import { UserManagement } from "@/features/admin/components/user-management";
@@ -106,6 +106,82 @@ function FfApiStatusSection() {
);
}
function QlikSettingsSection() {
const [host, setHost] = useState("");
const [user, setUser] = useState("");
const [password, setPassword] = useState("");
const [showPwd, setShowPwd] = useState(false);
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const [status, setStatus] = useState<{ type: "ok" | "err"; msg: string } | null>(null);
useEffect(() => {
getSavedDatabaseConfig().then((c) => {
if (!c) return;
setHost(c.qlikHost ?? "");
setUser(c.qlikUser ?? "");
setPassword(c.qlikPassword ?? "");
});
}, []);
const handleSave = async () => {
setSaving(true);
setStatus(null);
const res = await saveQlikSettings(host.trim(), user.trim(), password);
setSaving(false);
setStatus(res.success ? { type: "ok", msg: "Réglages Qlik enregistrés" } : { type: "err", msg: res.error || "Erreur" });
};
const handleTest = async () => {
setTesting(true);
setStatus(null);
const res = await testQlikConnection(host.trim(), user.trim(), password);
setTesting(false);
setStatus(res.success ? { type: "ok", msg: "Connexion Qlik OK" } : { type: "err", msg: res.error || "Échec connexion" });
};
return (
<Section title="Qlik Sense — Données réseau" subtitle="Identifiants pour la synchro des données réseau (~270 magasins). Auth NTLM ; laisser l'hôte vide pour le défaut.">
<Field label="Hôte" hint="Défaut : reporting-magasins.lafoirfouille.fr (laisser vide pour ce défaut)">
<input type="text" placeholder="reporting-magasins.lafoirfouille.fr" value={host}
onChange={(e) => setHost(e.target.value)} className="apple-input font-mono" />
</Field>
<Field label="Utilisateur" hint="Ex : FFSCH (sans domaine)">
<input type="text" placeholder="FFSCH" value={user}
onChange={(e) => setUser(e.target.value)} className="apple-input font-mono" />
</Field>
<Field label="Mot de passe">
<div className="relative">
<input type={showPwd ? "text" : "password"} placeholder="••••••••" value={password}
onChange={(e) => setPassword(e.target.value)} className="apple-input font-mono pr-10" />
<button type="button" onClick={() => setShowPwd((v) => !v)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-[var(--text-muted)]">
{showPwd ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</Field>
{status && (
<div className={`flex items-center gap-1.5 text-[12px] ${status.type === "ok" ? "text-emerald-600" : "text-rose-600"}`}>
{status.type === "ok" ? <CheckCircle className="w-4 h-4" /> : <AlertCircle className="w-4 h-4" />}
{status.msg}
</div>
)}
<div className="flex gap-2">
<button onClick={handleSave} disabled={saving}
className="btn-action btn-action-primary flex items-center gap-1.5 disabled:opacity-60">
{saving ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Save className="w-3.5 h-3.5" />}
Enregistrer
</button>
<button onClick={handleTest} disabled={testing || !user || !password}
className="btn-action btn-action-secondary flex items-center gap-1.5 disabled:opacity-60">
{testing ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <RefreshCw className="w-3.5 h-3.5" />}
Tester
</button>
</div>
</Section>
);
}
export default function SettingsPage() {
const { resolvedTheme, setTheme } = useTheme();
const isDark = resolvedTheme === "dark";
@@ -336,6 +412,8 @@ export default function SettingsPage() {
{/* API FF Nancy */}
<FfApiStatusSection />
<QlikSettingsSection />
{/* AI Provider selector */}
<Section title="Admin AI Chat — Fournisseur" subtitle="Choisissez le fournisseur IA utilisé dans l'onglet Admin AI Chat">
<div className="flex gap-2">
@@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { pgGetOpportunitesFamille } from "@/lib/pg-ff-client";
import { buildLast12MonthsRange } from "@/lib/api-ff-client";
export const dynamic = "force-dynamic";
/**
* GET /api/produits/opportunites?nomNoId=123
*
* Produits de la même nomenclature, classés par écart entre performance réseau
* et performance locale (quantités par magasin). Chargé à la demande depuis la
* fiche produit : la requête agrège `mvtart` sur 12 mois pour toute la famille,
* on ne veut pas la payer au chargement de la page.
*
* Le middleware Next ne protège pas `/api/*` : contrôle de session explicite.
*/
export async function GET(req: NextRequest) {
const session = await auth();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const raw = req.nextUrl.searchParams.get("nomNoId");
const nomNoId = Number(raw);
if (!raw || !Number.isInteger(nomNoId) || nomNoId <= 0) {
return NextResponse.json({ error: "Param 'nomNoId' invalide" }, { status: 400 });
}
try {
const { dateDebut, dateFin } = buildLast12MonthsRange();
const rows = await pgGetOpportunitesFamille(nomNoId, dateDebut, dateFin);
return NextResponse.json({ success: true, rows });
} catch (e) {
console.error("[api/produits/opportunites]", (e as Error).message?.slice(0, 250));
return NextResponse.json({ error: "Erreur serveur" }, { status: 500 });
}
}
+429 -42
View File
@@ -1,62 +1,449 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { fetchNetworkMetrics } from "@/lib/qlik-client";
import { fetchNetworkMetricsPlaywright } from "@/lib/qlik-playwright";
import { upsertNetworkMetrics } from "@/lib/qlik-network-cache";
import { pgGetArticlesByFournisseur } from "@/lib/pg-ff-client";
import {
buildGridNetworkQlikDateFilter,
envMonthsBack,
QLIK_MONTHS_BACK_DEFAULT,
QLIK_MONTHS_BACK_MAX,
QLIK_MONTHS_BACK_MIN,
} from "@/lib/qlik-date-range";
// Synchro potentiellement longue (extraction hypercube paginee).
// Tâche d'extraction Qlik potentiellement très longue (hypercube paginé).
// On accepte quand même 5 min côté plateforme Next.js, mais on rend la main au
// client tout de suite via le state job ci-dessous.
export const maxDuration = 300;
// Forcer le runtime Node.js : Playwright/Chromium ne tourne pas en edge et on a
// besoin d'un process persistant pour conserver l'état de job en mémoire.
export const runtime = "nodejs";
// Toujours exécuter dynamiquement (sinon Next peut essayer de cacher des POST).
export const dynamic = "force-dynamic";
/** État d'un job de sync Qlik pour un fournisseur donné. */
type QlikSyncStatus = "idle" | "running" | "success" | "error";
interface QlikSyncJob {
jobId: string;
/** Code fournisseur (mode fournisseur) — chaîne vide en mode produit. */
fournisseur: string;
/** Code centrale (mode produit) — absent en mode fournisseur. */
codeCentrale?: string;
status: QlikSyncStatus;
requested: number;
fetched: number;
upserted: number;
periode?: string;
dateDebut?: string;
dateFin?: string;
startedAt: string;
finishedAt?: string;
error?: string;
message?: string;
}
/**
* Store en mémoire au niveau module. Une Map par fournisseur car on ne lance
* qu'une extraction à la fois par fournisseur. C'est volontairement simple :
* si le process redémarre, les jobs en cours sont perdus → GET renverra idle
* et l'utilisateur pourra relancer. Suffisant pour corriger le bug UX.
*/
const jobs = new Map<string, QlikSyncJob>();
/**
* Clé de job. Le mode fournisseur garde sa clé historique (le code fournisseur
* brut) ; le mode produit est préfixé pour ne jamais entrer en collision.
*/
function jobKeyForProduct(codeCentrale: string): string {
return `cc:${codeCentrale.toUpperCase()}`;
}
/**
* Nombre maximum d'extractions produit simultanées.
*
* Le mode produit est ouvert à tous les utilisateurs connectés (contrairement au
* mode fournisseur, réservé aux admins). Une extraction sur 1 seul code est très
* légère, mais le serveur Qlik est sujet aux saturations mémoire : on plafonne
* la concurrence plutôt que de lui laisser prendre N requêtes en parallèle.
*/
const MAX_CONCURRENT_PRODUCT_JOBS = 3;
/** Compte les extractions produit actuellement en cours. */
function runningProductJobs(): number {
let n = 0;
for (const [key, job] of jobs) {
if (key.startsWith("cc:") && job.status === "running") n++;
}
return n;
}
/** Construit un jobId court et lisible. */
function newJobId(): string {
return `qlik_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
}
/** Sérialise un job pour la réponse HTTP. */
function publicJob(job: QlikSyncJob) {
return {
jobId: job.jobId,
fournisseur: job.fournisseur,
codeCentrale: job.codeCentrale,
status: job.status,
requested: job.requested,
fetched: job.fetched,
upserted: job.upserted,
periode: job.periode,
dateDebut: job.dateDebut,
dateFin: job.dateFin,
startedAt: job.startedAt,
finishedAt: job.finishedAt,
error: job.error,
message: job.message,
};
}
/** État "idle" sérialisé (pas de job connu pour cette cible). */
function idleJob(fournisseur: string, codeCentrale?: string) {
return {
jobId: null,
fournisseur,
codeCentrale,
status: "idle" as const,
requested: 0,
fetched: 0,
upserted: 0,
periode: undefined,
dateDebut: undefined,
dateFin: undefined,
startedAt: undefined,
finishedAt: undefined,
error: undefined,
message: undefined,
};
}
/**
* Vérifie l'auth admin. Renvoie une réponse 403/401 si refusée, sinon null.
*/
async function requireAdmin(): Promise<NextResponse | null> {
const session = await auth();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if ((session.user as { role?: string } | undefined)?.role !== "admin") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
return null;
}
/**
* Vérifie qu'une session existe (sans exiger le rôle admin).
*
* Utilisé par le mode produit : le middleware Next ne couvre PAS les routes
* `/api/*`, le contrôle doit donc être fait ici explicitement.
*/
async function requireSession(): Promise<NextResponse | null> {
const session = await auth();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
return null;
}
/**
* Regex de validation d'un code centrale "raisonnable".
*
* Format attendu d'après l'API FF : préfixe `10000` + 6 chiffres (ex `10000167303`),
* donc 11 caractères alphanumériques. Pour rester large et ne pas casser
* d'éventuels codes alphanumériques fournisseur (refs internes), on autorise
* - longueur 2..30
* - jeu `[A-Z0-9_-]`
* - premier caractère alphanumérique (pas `_` ni `-`)
*
* But : éviter d'envoyer à Qlik des chaînes vides, des `-`, des espaces
* parasites, ou des entrées manifestement invalides qui déclenchent des
* codes d'erreur Engine ou du gaspillage réseau. **Volontairement simple** :
* on ne touche pas au mapping ; on filtre seulement ce qui ne ressemble à
* aucun code plausible.
*/
const CENTRAL_CODE_REGEX = /^[A-Z0-9][A-Z0-9_-]{1,29}$/i;
/**
* Filtre strict des codes centraux avant d'envoyer à Qlik.
* - trim + drop vides
* - drop "-" (placeholder déjà exclu côté agrégation mais on double-check)
* - drop doublons (préserve l'ordre, comparaison insensible à la casse)
* - drop codes ne matchant pas CENTRAL_CODE_REGEX (longueur / charset)
*
* Renvoie `{ accepted, rejected, deduped }`. Les logs n'exposent que les
* compteurs, jamais les valeurs des codes.
*/
function filterCentralCodes(rawCodes: Array<string | null | undefined>): {
accepted: string[];
rejected: number;
deduped: number;
} {
const seen = new Set<string>();
const accepted: string[] = [];
let rejected = 0;
let deduped = 0;
for (const raw of rawCodes) {
if (raw == null) {
rejected++;
continue;
}
const code = String(raw).trim();
const key = code.toUpperCase();
if (!code || code === "-" || code === "_") {
rejected++;
continue;
}
if (seen.has(key)) {
deduped++;
continue;
}
if (!CENTRAL_CODE_REGEX.test(code)) {
rejected++;
continue;
}
seen.add(key);
accepted.push(code);
}
return { accepted, rejected, deduped };
}
/**
* Exécute réellement l'extraction + l'upsert, en mettant à jour le job
* au fur et à mesure (compteur fetched, puis upserted, puis finishedAt).
*/
async function runJob(job: QlikSyncJob): Promise<void> {
try {
const articles = await pgGetArticlesByFournisseur(job.fournisseur);
// Filtre strict des codes (trim, dedupe, exclusion vides/`-`/codes trop suspects).
const { accepted: codes, rejected: codesRejected, deduped: codesDeduped } = filterCentralCodes(
articles.map((a) => a.codeCentrale),
);
job.requested = codes.length;
console.log(
`[api/qlik/sync] job=${job.jobId} fournisseur=${job.fournisseur} — ${articles.length} articles, ${codes.length} codes centraux retenus, ${codesRejected} rejetés (vide/-/anormaux), ${codesDeduped} doublons supprimés`,
);
if (codes.length === 0) {
job.status = "success";
job.message = "Aucun code centrale valide pour ce fournisseur";
job.finishedAt = new Date().toISOString();
return;
}
// Fenêtre temporelle alignée sur la grille, éventuellement raccourcie via
// QLIK_SYNC_MONTHS_BACK (1..12, défaut 12). On logue la valeur effective
// pour audit.
const monthsBack = envMonthsBack("QLIK_SYNC_MONTHS_BACK", QLIK_MONTHS_BACK_DEFAULT);
const dateFilter = buildGridNetworkQlikDateFilter(new Date(), monthsBack);
job.periode = dateFilter.label;
job.dateDebut = dateFilter.dateDebut;
job.dateFin = dateFilter.dateFin;
console.log(
`[api/qlik/sync] job=${job.jobId} → extraction Qlik pour ${codes.length} codes — fenêtre ${dateFilter.label} (${dateFilter.dateDebut} → ${dateFilter.dateFin}, QLIK_SYNC_MONTHS_BACK=${monthsBack}, bornes ${QLIK_MONTHS_BACK_MIN}..${QLIK_MONTHS_BACK_MAX})…`,
);
const metrics = await fetchNetworkMetricsPlaywright(codes, undefined, dateFilter);
job.fetched = metrics.size;
console.log(`[api/qlik/sync] job=${job.jobId} ← Qlik a renvoyé ${metrics.size} produits réseau`);
const count = await upsertNetworkMetrics([...metrics.values()]);
job.upserted = count;
console.log(`[api/qlik/sync] job=${job.jobId} ${count} lignes upsert dans le cache`);
job.status = "success";
job.finishedAt = new Date().toISOString();
} catch (e) {
applyJobError(job, e);
}
}
/**
* Inscrit une erreur dans le job, en traduisant la saturation mémoire du
* serveur Qlik en message actionnable (ce n'est PAS un bug CollectFlow :
* l'app Qlik ne se charge même pas).
*/
function applyJobError(job: QlikSyncJob, e: unknown): void {
const msg = e instanceof Error ? e.message : String(e);
console.error(`[api/qlik/sync] job=${job.jobId}`, msg);
job.status = "error";
const lower = msg.toLowerCase();
if (lower.includes("out of memory") || lower.includes("not enough memory") || lower.includes("file corrupted") || lower.includes('"code":6') || lower.includes('"code":3002')) {
job.error = "Serveur Qlik saturé : mémoire insuffisante pour charger l'application. Réessayez plus tard (ou faites libérer / augmenter la RAM du serveur Qlik).";
} else {
job.error = msg;
}
job.finishedAt = new Date().toISOString();
}
/**
* Extraction Qlik pour UN seul code centrale (fiche produit).
*
* Beaucoup plus légère que la sync fournisseur : un seul code au lieu de
* plusieurs centaines, sur la même fenêtre 12 mois glissants. Réutilise
* exactement le même extracteur, donc le même détail mensuel.
*/
async function runProductJob(job: QlikSyncJob): Promise<void> {
try {
const code = job.codeCentrale!;
job.requested = 1;
const monthsBack = envMonthsBack("QLIK_SYNC_MONTHS_BACK", QLIK_MONTHS_BACK_DEFAULT);
const dateFilter = buildGridNetworkQlikDateFilter(new Date(), monthsBack);
job.periode = dateFilter.label;
job.dateDebut = dateFilter.dateDebut;
job.dateFin = dateFilter.dateFin;
console.log(
`[api/qlik/sync] job=${job.jobId} → extraction Qlik produit (1 code) — fenêtre ${dateFilter.label} (${dateFilter.dateDebut} → ${dateFilter.dateFin})…`,
);
const metrics = await fetchNetworkMetricsPlaywright([code], undefined, dateFilter);
job.fetched = metrics.size;
if (metrics.size === 0) {
job.status = "success";
job.message = "Ce code centrale est inconnu de Qlik (aucune vente réseau sur la période)";
job.finishedAt = new Date().toISOString();
return;
}
const count = await upsertNetworkMetrics([...metrics.values()]);
job.upserted = count;
job.status = "success";
job.message = "Données réseau récupérées";
job.finishedAt = new Date().toISOString();
console.log(`[api/qlik/sync] job=${job.jobId} produit ${code} — ${count} ligne(s) upsert`);
} catch (e) {
applyJobError(job, e);
}
}
/**
* GET /api/qlik/sync?fournisseur=XXX ou ?codeCentrale=YYY
* Retourne l'état courant du job pour cette cible. Si aucun job n'est
* connu, renvoie un état "idle" (avec un message explicite). Permet au
* frontend de reprendre l'état après un reload et de poller en arrière-plan.
*/
export async function GET(req: NextRequest) {
const codeCentrale = req.nextUrl.searchParams.get("codeCentrale");
const fournisseur = req.nextUrl.searchParams.get("fournisseur");
// Mode produit : ouvert à tout utilisateur connecté.
if (codeCentrale) {
const denied = await requireSession();
if (denied) return denied;
const job = jobs.get(jobKeyForProduct(codeCentrale));
if (!job) {
return NextResponse.json({ success: true, ...idleJob("", codeCentrale) });
}
return NextResponse.json({ success: true, ...publicJob(job) });
}
// Mode fournisseur : admin uniquement (inchangé).
const denied = await requireAdmin();
if (denied) return denied;
if (!fournisseur) {
return NextResponse.json({ error: "Param 'fournisseur' ou 'codeCentrale' requis" }, { status: 400 });
}
const job = jobs.get(fournisseur);
if (!job) {
return NextResponse.json({ success: true, ...idleJob(fournisseur) });
}
return NextResponse.json({ success: true, ...publicJob(job) });
}
/**
* POST /api/qlik/sync?fournisseur=XXX
* Tire de Qlik les metriques reseau des articles du fournisseur (par code centrale)
* et met a jour le cache local. Admin uniquement.
* Démarre (ou réutilise si déjà running) un job d'extraction en arrière-plan
* et retourne *immédiatement* { success, status, jobId, ... }. Le client doit
* ensuite poller GET pour suivre l'avancement et être notifié de la fin.
*/
export async function POST(req: NextRequest) {
const session = await auth();
if (!session || (session.user as { role?: string } | undefined)?.role !== "admin") {
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
const codeCentraleParam = req.nextUrl.searchParams.get("codeCentrale");
// ─── Mode produit : 1 code centrale, ouvert à tout utilisateur connecté ───
if (codeCentraleParam) {
const denied = await requireSession();
if (denied) return denied;
// Même validation que la sync fournisseur (trim, rejet des vides / "-" /
// codes hors charset) pour ne jamais envoyer de saleté à l'Engine Qlik.
const { accepted } = filterCentralCodes([codeCentraleParam]);
if (accepted.length === 0) {
return NextResponse.json({ error: "Code centrale invalide" }, { status: 400 });
}
const code = accepted[0];
const key = jobKeyForProduct(code);
const existingProduct = jobs.get(key);
if (existingProduct && existingProduct.status === "running") {
return NextResponse.json({ success: true, ...publicJob(existingProduct) });
}
if (runningProductJobs() >= MAX_CONCURRENT_PRODUCT_JOBS) {
return NextResponse.json(
{ error: "Trop d'extractions Qlik en cours. Réessayez dans quelques instants." },
{ status: 429 },
);
}
const productJob: QlikSyncJob = {
jobId: newJobId(),
fournisseur: "",
codeCentrale: code,
status: "running",
requested: 0,
fetched: 0,
upserted: 0,
startedAt: new Date().toISOString(),
};
jobs.set(key, productJob);
console.log(`[api/qlik/sync] job=${productJob.jobId} démarré pour codeCentrale=${code}`);
void runProductJob(productJob).catch((e) => applyJobError(productJob, e));
return NextResponse.json({ success: true, ...publicJob(productJob) });
}
// ─── Mode fournisseur : admin uniquement (inchangé) ──────────────────────
const denied = await requireAdmin();
if (denied) return denied;
const fournisseur = req.nextUrl.searchParams.get("fournisseur");
if (!fournisseur) {
return NextResponse.json({ error: "Param 'fournisseur' requis" }, { status: 400 });
return NextResponse.json({ error: "Param 'fournisseur' ou 'codeCentrale' requis" }, { status: 400 });
}
try {
// Codes centraux des articles de ce fournisseur
const articles = await pgGetArticlesByFournisseur(fournisseur);
const codes = [
...new Set(
articles
.map((a) => (a.codeCentrale ? String(a.codeCentrale).trim() : ""))
.filter(Boolean),
),
];
if (codes.length === 0) {
return NextResponse.json({
success: true,
fournisseur,
fetched: 0,
upserted: 0,
message: "Aucun article avec code centrale pour ce fournisseur",
fetchedAt: new Date().toISOString(),
});
}
const existing = jobs.get(fournisseur);
if (existing && existing.status === "running") {
// Un job tourne déjà pour ce fournisseur : on le réutilise.
return NextResponse.json({ success: true, ...publicJob(existing) });
}
const metrics = await fetchNetworkMetrics(codes);
const count = await upsertNetworkMetrics([...metrics.values()]);
return NextResponse.json({
success: true,
fournisseur,
requested: codes.length,
fetched: metrics.size,
upserted: count,
fetchedAt: new Date().toISOString(),
});
} catch (e) {
const job: QlikSyncJob = {
jobId: newJobId(),
fournisseur,
status: "running",
requested: 0,
fetched: 0,
upserted: 0,
startedAt: new Date().toISOString(),
};
jobs.set(fournisseur, job);
console.log(`[api/qlik/sync] job=${job.jobId} démarré pour fournisseur=${fournisseur}`);
// Lancement fire-and-forget. On ne `await` PAS l'extraction ici : on
// renvoie tout de suite au client et il polle GET. Les erreurs sont
// capturées dans runJob et inscrites dans le job.
void runJob(job).catch((e) => {
// Filet de sécurité (runJob ne devrait pas throw, mais on ne veut
// jamais qu'une exception non capturée crashe le process).
const msg = e instanceof Error ? e.message : String(e);
console.error("[api/qlik/sync]", msg);
return NextResponse.json({ success: false, error: msg }, { status: 500 });
}
console.error(`[api/qlik/sync] job=${job.jobId} crash non géré`, msg);
job.status = "error";
job.error = msg;
job.finishedAt = new Date().toISOString();
});
return NextResponse.json({ success: true, ...publicJob(job) });
}
+2 -1
View File
@@ -1,6 +1,6 @@
"use client";
import { LayoutGrid, Camera, FileDown, Settings, Package, BarChart2, TrendingUp, Trophy, Megaphone, PackageMinus, ShoppingCart, LogOut, User as UserIcon, Loader2, ChevronRight, ChevronLeft, MessageSquareCode } from "lucide-react";
import { LayoutGrid, Camera, FileDown, Settings, Package, PackageSearch, BarChart2, TrendingUp, Trophy, Megaphone, PackageMinus, ShoppingCart, LogOut, User as UserIcon, Loader2, ChevronRight, ChevronLeft, MessageSquareCode } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
@@ -11,6 +11,7 @@ import { useSession, signOut } from "next-auth/react";
const NAV_ITEMS = [
{ icon: BarChart2, label: "Dashboard", href: "/dashboard" },
{ icon: LayoutGrid, label: "Grille", href: "/grid" },
{ icon: PackageSearch, label: "Produits", href: "/produits" },
{ icon: TrendingUp, label: "Analytics", href: "/analytics" },
{ icon: Trophy, label: "Hit Parade", href: "/hit-parade" },
{ icon: Megaphone, label: "Publicités", href: "/publicites" },
+12
View File
@@ -117,8 +117,20 @@ export const qlikNetworkMetrics = pgTable("qlik_network_metrics", {
qteReseau: numeric("qte_reseau", { precision: 14, scale: 2 }),
/** Nombre de magasins travaillant le produit (sur ~270) */
nbMagasinsReseau: integer("nb_magasins_reseau"),
/** CA moyen par magasin (productivité normalisée présence) */
caParMagasinReseau: numeric("ca_par_magasin_reseau", { precision: 16, scale: 2 }),
/** Taux de marge réseau (ratio brut Qlik) */
margePctReseau: numeric("marge_pct_reseau", { precision: 8, scale: 4 }),
/** Période couverte (libre, ex "12m" ou "2025") */
periode: varchar("periode", { length: 20 }),
/** Quantité vendue réseau par mois : { "YYYY-MM": qté } (12 derniers mois). */
qteByMonth: jsonb("qte_by_month"),
/**
* Détail mensuel complet : { "YYYY-MM": { qte, ca, nbMag, caMag, margePct } }.
* Superset de `qteByMonth`, qui reste maintenu tel quel pour la colonne
* « Tendance / Réseau » de la Grille.
*/
metricsByMonth: jsonb("metrics_by_month"),
/** Dernière synchro depuis Qlik */
fetchedAt: timestamp("fetched_at").defaultNow(),
});
+37 -5
View File
@@ -15,8 +15,7 @@ import { db } from "@/db";
import { sessionSnapshots } from "@/db/schema";
import { eq, desc } from "drizzle-orm";
import { getNetworkMetricsByCodeCentrale } from "@/lib/qlik-network-cache";
const NB_MAGASINS_RESEAU = 270;
import { NB_MAGASINS_RESEAU } from "@/features/grid/lib/network-trend";
interface GetProductRowsInput {
codeFournisseur: string;
@@ -47,10 +46,33 @@ export function invalidateGridRowsCache(codeFournisseur?: string) {
gridRowsPending.clear();
}
/**
* Rafraîchit UNIQUEMENT la colonne INIT (codeGammeInit = gamme serveur courante)
* sur des lignes servies depuis le cache. La colonne Gamme (codeGamme) n'est pas
* touchée : elle conserve la valeur issue du snapshot. Ainsi l'état serveur (INIT)
* est toujours à jour à chaque chargement, même sur un hit du cache 10 min — et le
* marqueur "modifié" (Gamme ≠ INIT) se résorbe dès que le serveur rattrape la modif.
*/
async function refreshGammeInit(rows: ProductRow[], codeFournisseur: string): Promise<void> {
try {
const gammeMap = await pgGetGammesByFournisseur(codeFournisseur);
if (gammeMap.size === 0) return;
for (const row of rows) {
const live = gammeMap.get(row.codein);
if (live !== undefined) row.codeGammeInit = live as GammeCode;
}
} catch (e) {
console.error("[getProductRows] refreshGammeInit error:", (e as Error).message?.slice(0, 200));
}
}
export async function getProductRows(input: GetProductRowsInput): Promise<ProductRow[]> {
const cacheKey = gridRowsCacheKey(input);
const cached = gridRowsCache.get(cacheKey);
if (!input.forceRefresh && cached && Date.now() - cached.createdAt < GRID_ROWS_CACHE_TTL_MS) {
// La colonne INIT doit refléter l'état serveur à CHAQUE chargement,
// y compris sur un hit de cache (les données lourdes restent, elles, cachées).
await refreshGammeInit(cached.rows, input.codeFournisseur);
return cached.rows;
}
@@ -314,6 +336,9 @@ async function buildProductRows(input: GetProductRowsInput): Promise<ProductRow[
await enrichWithNetworkMetrics(productMap);
// ─── Phase 9 : Restaurer gammes depuis dernier snapshot ──────────────
// La colonne Gamme (codeGamme) conserve la valeur du snapshot telle quelle.
// La colonne INIT (codeGammeInit) reste, elle, l'état LIVE du serveur
// rechargé en Phase 6 — jamais écrasée ici.
try {
const snaps = await db
.select()
@@ -327,7 +352,7 @@ async function buildProductRows(input: GetProductRowsInput): Promise<ProductRow[
for (const [codein, change] of Object.entries(changes)) {
const product = productMap.get(codein);
if (product && change.after) {
// codeGammeInit reste figé — seulement codeGamme est overridé
// codeGammeInit reste figé (état serveur) — seul codeGamme est overridé
product.codeGamme = change.after as GammeCode;
}
}
@@ -391,8 +416,10 @@ async function reconcileSelectedStoreFromMensuelApi(
for (const entry of entries) {
const period = entry.mois.replace("-", "");
if (!sortedPeriods.includes(period)) continue;
const qty = Math.abs(Number(entry.ventes?.qte_vendue ?? 0) || 0);
const ca = Math.abs(Number(entry.ventes?.ca_ht ?? 0) || 0);
// qte_vendue / ca_ht sont NÉGATIFS côté API (ventes nettes) : on
// les nie au lieu de Math.abs pour que les retours restent déduits.
const qty = -(Number(entry.ventes?.qte_vendue ?? 0) || 0);
const ca = -(Number(entry.ventes?.ca_ht ?? 0) || 0);
const marge = Number(entry.ventes?.marge ?? 0) || 0;
const stock = Number(entry.stock_fin_mois ?? 0) || 0;
const receptions = Number(entry.receptions?.qte_recue ?? 0) || 0;
@@ -474,6 +501,8 @@ async function enrichWithNetworkMetrics(productMap: Map<string, ProductRow>): Pr
if (byCodeCentrale.size === 0) return;
const metrics = await getNetworkMetricsByCodeCentrale([...byCodeCentrale.keys()]);
const sampleKey = [...byCodeCentrale.keys()].find((cc) => (metrics.get(cc)?.caReseau ?? 0) > 0) ?? [...byCodeCentrale.keys()].find((cc) => metrics.has(cc));
console.log(`[getProductRows] réseau: ${byCodeCentrale.size} codes centraux → ${metrics.size} matchés en cache. Sample:`, sampleKey ? JSON.stringify(metrics.get(sampleKey)) : "aucun match");
for (const [cc, products] of byCodeCentrale.entries()) {
const m = metrics.get(cc);
if (!m) continue;
@@ -481,7 +510,10 @@ async function enrichWithNetworkMetrics(productMap: Map<string, ProductRow>): Pr
product.caReseau = m.caReseau;
product.qteReseau = m.qteReseau;
product.nbMagasinsReseau = m.nbMagasinsReseau;
product.caParMagasinReseau = m.caParMagasinReseau;
product.margePctReseau = m.margePctReseau;
product.tauxPresenceReseau = m.nbMagasinsReseau / NB_MAGASINS_RESEAU;
product.qteReseauByMonth = m.qteByMonth ?? null;
product.networkFetchedAt = m.fetchedAt ?? undefined;
}
}
+95 -80
View File
@@ -6,14 +6,13 @@ import { FloatingSummaryBar } from "@/features/grid/components/floating-summary-
import { BulkActionToolbar } from "@/features/grid/components/bulk-action-toolbar";
import { GridFilterBar } from "@/features/grid/components/grid-filter-bar";
import { ExportDropdown } from "@/features/grid/components/export-dropdown";
import { NoSalesTab, countNoSalesIn6Months } from "@/features/grid/components/no-sales-tab";
import { useGridStore } from "@/features/grid/store/use-grid-store";
import { useSaveDrafts } from "@/features/grid/hooks/use-save-drafts";
import type { ProductRow } from "@/types/grid";
import { CheckCircle, AlertCircle, Loader2 } from "lucide-react";
import { CheckCircle, AlertCircle, Loader2, RefreshCw } from "lucide-react";
import { useSession } from "next-auth/react";
import { useSearchParams } from "next/navigation";
import { useSearchParams, useRouter, usePathname } from "next/navigation";
import type { GridFilters } from "@/types/grid";
interface GridClientProps {
@@ -31,6 +30,27 @@ type GridRowsStreamMessage =
| { type: "done"; loaded: number; total: number }
| { type: "error"; error: string };
/** Petite pastille métrique (libellé + valeur) — densité pro, thème Slate/teal. */
function MetricPill({ label, value, accent = false }: { label: string; value: string; accent?: boolean }) {
return (
<div
className="flex flex-col items-start px-2.5 py-1 rounded-lg shrink-0"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}
>
<span className="text-[8.5px] font-semibold uppercase tracking-wider leading-none mb-1" style={{ color: "var(--text-muted)" }}>
{label}
</span>
<span
className="text-[12px] font-bold leading-none tabular-nums max-w-[160px] truncate"
style={{ color: accent ? "var(--accent)" : "var(--text-primary)" }}
title={value}
>
{value}
</span>
</div>
);
}
export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, magasins, magasin, filters }: GridClientProps) {
const { data: session } = useSession();
const isAdmin = (session?.user as { role?: string } | undefined)?.role === "admin";
@@ -40,19 +60,18 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
const setFilter = useGridStore((s) => s.setFilter);
const setActiveMagasin = useGridStore((s) => s.setActiveMagasin);
const searchParams = useSearchParams();
const router = useRouter();
const pathname = usePathname();
const [selectedCodeins, setSelectedCodeins] = useState<string[]>([]);
const [isPending, startTransition] = useTransition();
const [saveStatus, setSaveStatus] = useState<"idle" | "success" | "error">("idle");
const [activeTab, setActiveTab] = useState<"grid" | "no-sales">("grid");
const [isLoadingRows, setIsLoadingRows] = useState(false);
const [rowsLoaded, setRowsLoaded] = useState(0);
const [totalRows, setTotalRows] = useState<number | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const [showStartOverlay, setShowStartOverlay] = useState(false);
const noSalesCount = useMemo(() => countNoSalesIn6Months(rows), [rows]);
const visibleCodeins = useMemo(() => rows.map(r => r.codein), [rows]);
const { save, hasDrafts, count } = useSaveDrafts(magasin, visibleCodeins);
@@ -170,6 +189,16 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
});
};
// Force le rechargement des données depuis le serveur en ignorant le cache
// 10 min : on change le paramètre URL `_refresh`, ce qui relance le fetch
// avec `refresh=1` (→ forceRefresh) et remonte l'état serveur à jour (INIT).
const handleForceRefresh = () => {
if (isLoadingRows) return;
const params = new URLSearchParams(searchParams.toString());
params.set("_refresh", String(Date.now()));
router.replace(`${pathname}?${params.toString()}`);
};
if (!isMounted) {
return <div className="p-8 text-center animate-pulse text-muted italic">Initialisation de la grille...</div>;
}
@@ -178,37 +207,65 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
return (
<div className="flex flex-col h-full space-y-3 min-h-0 pb-12">
{/* Header */}
<div className="flex items-end justify-between shrink-0">
<div>
<h1 className="text-2xl font-bold tracking-tight">Révision d&apos;Assortiment</h1>
<p className="text-[13px] mt-0.5" style={{ color: "var(--text-secondary)" }}>
<span className="font-semibold" style={{ color: "var(--text-primary)" }}>{nomFournisseur}</span>
{" "}• <span className="font-medium" style={{ color: "var(--text-muted)" }}>{activeStoreNom}</span>
{" "}• {totalRows?.toLocaleString("fr-FR") ?? rows.length.toLocaleString("fr-FR")} références
</p>
{/* Header — chrome raffiné (eyebrow teal + fournisseur héro + pastilles métriques) */}
<header className="shrink-0 flex items-center justify-between gap-4 pb-2.5" style={{ borderBottom: "1px solid var(--border)" }}>
<div className="flex items-center gap-3.5 min-w-0">
<div
className="h-10 w-[3px] rounded-full shrink-0"
style={{ background: "linear-gradient(to bottom, var(--accent), var(--accent-success))" }}
/>
<div className="min-w-0">
<div className="text-[10px] font-bold uppercase tracking-[0.18em] leading-none mb-1.5" style={{ color: "var(--accent)" }}>
Révision d&apos;assortiment
</div>
<h1 className="text-[19px] font-bold tracking-tight truncate leading-none" style={{ color: "var(--text-primary)" }}>
{nomFournisseur}
</h1>
</div>
<div className="hidden md:flex items-center gap-2 ml-1 pl-3 shrink-0" style={{ borderLeft: "1px solid var(--border)" }}>
<MetricPill label="Magasin" value={activeStoreNom} />
<MetricPill label="Références" value={(totalRows ?? rows.length).toLocaleString("fr-FR")} accent />
</div>
</div>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2.5 shrink-0">
{(isLoadingRows || loadError) && (
<div
className="flex items-center gap-2 px-3 py-1.5 rounded-md text-xs font-semibold"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: loadError ? "var(--accent-error)" : "var(--text-secondary)" }}
className="flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-semibold tabular-nums"
style={{
background: loadError ? "var(--accent-error-bg)" : "var(--accent-bg)",
border: `1px solid ${loadError ? "var(--accent-error)" : "var(--accent-border)"}`,
color: loadError ? "var(--accent-error)" : "var(--accent)",
}}
>
{isLoadingRows && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
{loadError
? loadError
: totalRows
? `${rowsLoaded.toLocaleString("fr-FR")} / ${totalRows.toLocaleString("fr-FR")}`
: "Chargement produits..."}
: "Chargement…"}
</div>
)}
<button
onClick={handleForceRefresh}
disabled={isLoadingRows}
title="Forcer le rafraîchissement — recharge les données depuis le serveur en ignorant le cache (met à jour la colonne INIT)"
className="flex items-center gap-2 px-3 h-9 text-[13px] font-semibold rounded-xl disabled:opacity-50 transition-all hover:brightness-110 active:scale-[0.97]"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}
>
<RefreshCw className={`w-4 h-4 ${isLoadingRows ? "animate-spin" : ""}`} />
<span className="hidden sm:inline">Rafraîchir</span>
</button>
<div id="grid-toolbar-actions"></div>
{isAdmin && <ExportDropdown nomFournisseur={nomFournisseur} />}
{isAdmin && hasDrafts && (
<button
onClick={handleSave}
disabled={isPending}
className="flex items-center gap-2 px-4 py-2 bg-emerald-700 hover:bg-emerald-600 text-white text-sm font-semibold rounded-lg disabled:opacity-60 transition-colors"
className="flex items-center gap-2 px-4 h-9 text-white text-[13px] font-semibold rounded-xl disabled:opacity-60 transition-all hover:brightness-110 active:scale-[0.97]"
style={{
background: "linear-gradient(180deg, var(--brand-solid), var(--brand-hover))",
boxShadow: "0 4px 12px rgba(16,185,129,0.25), inset 0 1px 1px rgba(255,255,255,0.2)",
}}
>
{isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
@@ -218,7 +275,7 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
<AlertCircle className="w-4 h-4" />
) : null}
{isPending
? "Sauvegarde..."
? "Sauvegarde…"
: saveStatus === "success"
? "Sauvegardé !"
: saveStatus === "error"
@@ -227,75 +284,33 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
</button>
)}
</div>
</div>
</header>
{/* Filters */}
<div className="shrink-0 print:hidden">
<GridFilterBar fournisseurs={fournisseurs} magasins={magasins} />
</div>
{/* Tab switcher */}
<div className="shrink-0 flex gap-0 print:hidden" style={{ borderBottom: "1px solid var(--border)" }}>
{(["grid", "no-sales"] as const).map(tab => {
const isActive = activeTab === tab;
return (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className="flex items-center gap-1.5 px-4 py-2 text-[13px] font-medium transition-colors"
style={{
color: isActive ? "var(--text-primary)" : "var(--text-muted)",
borderBottom: isActive ? "2px solid var(--accent)" : "2px solid transparent",
marginBottom: "-1px",
}}
>
{tab === "grid" ? "Assortiment" : "Sans vente 6 mois"}
{tab === "no-sales" && noSalesCount > 0 && (
<span className="inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full text-[10px] font-bold"
style={{
background: isActive ? "var(--accent)" : "var(--bg-elevated)",
color: isActive ? "white" : "var(--text-secondary)",
border: "1px solid var(--border)",
}}
>
{noSalesCount}
</span>
)}
</button>
);
})}
{/* Bulk toolbar (contextual) */}
<div className="shrink-0 print:hidden">
<BulkActionToolbar
selectedCodeins={selectedCodeins}
onClearSelection={() => setSelectedCodeins([])}
/>
</div>
{/* Bulk toolbar (contextual) — uniquement sur l'onglet grille */}
{activeTab === "grid" && (
<div className="shrink-0 print:hidden">
<BulkActionToolbar
selectedCodeins={selectedCodeins}
onClearSelection={() => setSelectedCodeins([])}
/>
</div>
)}
{/* Main content */}
{activeTab === "grid" ? (
<div className="flex-1 min-h-0 min-w-0">
<HeatmapGrid
onSelectionChange={setSelectedCodeins}
isAdmin={isAdmin}
/>
</div>
) : (
<div className="flex-1 min-h-0 min-w-0 flex flex-col">
<NoSalesTab />
</div>
)}
<div className="flex-1 min-h-0 min-w-0">
<HeatmapGrid
onSelectionChange={setSelectedCodeins}
isAdmin={isAdmin}
/>
</div>
{/* Summary bar — uniquement sur l'onglet grille */}
{activeTab === "grid" && (
<div className="print:hidden">
<FloatingSummaryBar />
</div>
)}
{/* Summary bar */}
<div className="print:hidden">
<FloatingSummaryBar />
</div>
{/* Overlay de démarrage premium (Glassmorphism semi-bloquant) */}
{showStartOverlay && isLoadingRows && (
+136 -82
View File
@@ -26,64 +26,19 @@ import { GammeSelect } from "@/features/grid/components/gamme-select";
import { HeatmapCell } from "@/features/grid/components/heatmap-cell";
import type { ProductRow, GammeCode } from "@/types/grid";
import { cn } from "@/lib/utils";
function getLast12Months(): string[] {
const months: string[] = [];
const now = new Date();
// On commence à i=12 (il y a 12 mois) et on finit à i=1 (le mois dernier)
// pour exclure le mois en cours (i=0)
for (let i = 12; i >= 1; i--) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
months.push(`${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, "0")}`);
}
return months;
}
// La constante est supprimée d'ici pour éviter le mismatch d'hydratation (new Date() au runtime module)
function formatMonthLabel(key: string): string {
const m = parseInt(key.slice(4, 6), 10);
const names = ["Jan", "Fév", "Mar", "Avr", "Mai", "Jun", "Jul", "Aoû", "Sep", "Oct", "Nov", "Déc"];
return `${names[m - 1]} ${key.slice(2, 4)}`;
}
function formatDate(iso?: string): string {
if (!iso) return "—";
return new Date(iso).toLocaleDateString("fr-FR", { day: "2-digit", month: "2-digit", year: "numeric" });
}
/**
* Returns premium styling and color for stores based on the store name's hash.
* This guarantees a consistent color for a given store, regardless of its position in the list.
*/
const SITE_LABELS: Record<string, { label: string; nom: string }> = {
"292": { label: "F", nom: "Frouard (Nancy)" },
"579": { label: "H", nom: "Houdemont" },
};
function getStoreConfig(name: string) {
const known = SITE_LABELS[name];
const words = (known?.nom ?? name).trim().split(/\s+/);
let label = known?.label ?? words[0].charAt(0).toUpperCase();
if (!known && words.length > 1) {
label += words[1].charAt(0).toUpperCase();
}
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash);
}
const colorInt = Math.abs(hash) % 5;
switch (colorInt) {
case 0: return { bg: "rgba(99, 102, 241, 0.1)", text: "#818cf8", border: "rgba(99, 102, 241, 0.2)", label }; // Indigo
case 1: return { bg: "rgba(245, 158, 11, 0.1)", text: "#fbbf24", border: "rgba(245, 158, 11, 0.2)", label }; // Amber
case 2: return { bg: "rgba(16, 185, 129, 0.1)", text: "#10b981", border: "rgba(16, 185, 129, 0.2)", label }; // Emerald
case 3: return { bg: "rgba(236, 72, 153, 0.1)", text: "#ec4899", border: "rgba(236, 72, 153, 0.2)", label }; // Pink
case 4: return { bg: "rgba(14, 165, 233, 0.1)", text: "#0ea5e9", border: "rgba(14, 165, 233, 0.2)", label }; // Sky
default: return { bg: "var(--bg-elevated)", text: "var(--text-muted)", border: "var(--border)", label };
}
}
import {
getLast12Months,
formatMonthLabel,
formatDate,
SITE_LABELS,
getStoreConfig,
} from "@/features/grid/lib/months";
import {
computeNetworkTrend,
trendLabel,
TREND_COLOR,
} from "@/features/grid/lib/network-trend";
import { TrendSparkline, NetworkLineChart } from "@/features/grid/components/network-charts";
interface HeatmapGridProps {
onSelectionChange?: (codeins: string[]) => void;
@@ -92,6 +47,27 @@ interface HeatmapGridProps {
const EMPTY_DRAFT_CHANGES: Record<string, GammeCode> = {};
const QLIK_NETWORK_COLUMN_IDS = new Set<string>([
"caReseau",
"qteReseau",
"nbMagasinsReseau",
"tendanceReseau",
"caParMagasinReseau",
"margePctReseau",
]);
function getQlikNetworkSortValue(value: number | null | undefined, columnId: string, sorting: SortingState): number | undefined {
if (value != null) return value;
const activeSort = sorting.find((sort) => sort.id === columnId);
if (!activeSort) return undefined;
// TanStack inverse le résultat en tri descendant. On utilise donc une
// sentinelle dépendante du sens de tri pour garder les valeurs absentes
// toujours en bas, en ascendant comme en descendant.
return activeSort.desc ? Number.NEGATIVE_INFINITY : Number.POSITIVE_INFINITY;
}
// =========================================================================
// OPTIMISATION PERFORMANCES (React.memo + Zustand Selectors granulaires)
// =========================================================================
@@ -178,7 +154,7 @@ const GridRow = React.memo(({ virtualRow, row, rowHeight, isSelected, columnVisi
>
{row.getVisibleCells().map((cell: Cell<ProductRow, unknown>) => {
const isFlexible = cell.column.id === "libelle1" || cell.column.id === "libelle3";
const isCenter = cell.column.id === "totalQuantite" || cell.column.id === "totalCa" || cell.column.id === "totalMarge" || cell.column.id.startsWith("month_") || cell.column.id === "gammeInitial" || cell.column.id === "caReseau" || cell.column.id === "qteReseau" || cell.column.id === "nbMagasinsReseau" || cell.column.id === "tauxPresenceReseau" || cell.column.id === "gamme";
const isCenter = cell.column.id === "totalQuantite" || cell.column.id === "totalCa" || cell.column.id === "totalMarge" || cell.column.id.startsWith("month_") || cell.column.id === "gammeInitial" || QLIK_NETWORK_COLUMN_IDS.has(cell.column.id) || cell.column.id === "gamme";
const size = cell.column.getSize();
return (
<td
@@ -217,6 +193,38 @@ interface CellDetailData {
receptions: number | null;
}
/** Modal : quantités vendues réseau sur les 12 derniers mois, en une seule courbe. */
function NetworkMonthlyModal({ row, onClose }: { row: ProductRow; onClose: () => void }) {
const trend = computeNetworkTrend(row.qteReseauByMonth);
const { values, labels, direction, pct } = trend;
const color = TREND_COLOR[direction];
return (
<DialogContent className="max-w-md" style={{ background: "var(--bg-surface)", border: "1px solid var(--border)" }} onInteractOutside={onClose}>
<DialogHeader>
<DialogTitle className="text-base leading-snug pr-6" style={{ color: "var(--text-primary)" }}>
{row.libelle1}
</DialogTitle>
<p className="text-[12px] mt-0.5" style={{ color: "var(--text-muted)" }}>
Ventes réseau · {labels.length > 0 ? `${labels.length} derniers mois` : "12 derniers mois"}
{row.nbMagasinsReseau != null && <> · <span className="font-semibold" style={{ color: "var(--text-secondary)" }}>{row.nbMagasinsReseau} magasins</span></>}
{pct != null && (
<> · <span className="font-bold" style={{ color }}>
{pct >= 0 ? "+" : ""}{Math.round(pct * 100)}% · {trendLabel(pct)}
</span></>
)}
</p>
</DialogHeader>
{!trend.hasData ? (
<div className="mt-3 rounded-xl p-4 text-center text-[13px]" style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-muted)" }}>
Pas encore de détail mensuel pour ce produit.<br />Relancez un Sync Qlik pour le remplir.
</div>
) : (
<NetworkLineChart labels={labels} values={values} color={color} />
)}
</DialogContent>
);
}
function CellDetailModal({ d, activeMagasin, onClose }: { d: CellDetailData; activeMagasin: string; onClose: () => void }) {
const hasReceptions = (d.receptions ?? 0) > 0;
const storeLabel = activeMagasin === "TOTAL" ? "Total réseau" : (SITE_LABELS[activeMagasin]?.nom ?? activeMagasin);
@@ -307,6 +315,7 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
// État pour le détail d'une cellule mensuelle (modal)
const [cellDetail, setCellDetail] = useState<CellDetailData | null>(null);
const [networkModal, setNetworkModal] = useState<ProductRow | null>(null);
const tableContainerRef = useRef<HTMLDivElement>(null);
// Calculer les mois dynamiquement pour éviter le mismatch entre serveur et client
@@ -506,11 +515,12 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
},
},
{
accessorKey: "caReseau",
id: "caReseau",
accessorFn: (row) => getQlikNetworkSortValue(row.caReseau, "caReseau", sorting),
header: () => <div className="text-center w-full">CA<br/><span className="text-[9px] opacity-60">Réseau</span></div>,
size: 90,
cell: ({ getValue }) => {
const val = getValue<number | undefined>();
cell: ({ row }) => {
const val = row.original.caReseau;
return (
<div className="text-center tabular-nums text-[12px] font-bold text-emerald-600 dark:text-emerald-400">
{val != null ? val.toLocaleString("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 }) : "-"}
@@ -519,11 +529,12 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
},
},
{
accessorKey: "qteReseau",
id: "qteReseau",
accessorFn: (row) => getQlikNetworkSortValue(row.qteReseau, "qteReseau", sorting),
header: () => <div className="text-center w-full">Qté<br/><span className="text-[9px] opacity-60">Réseau</span></div>,
size: 80,
cell: ({ getValue }) => {
const val = getValue<number | undefined>();
cell: ({ row }) => {
const val = row.original.qteReseau;
return (
<div className="text-center tabular-nums text-[12px] font-bold" style={{ color: "var(--text-secondary)" }}>
{val != null ? Math.round(val).toLocaleString("fr-FR") : "-"}
@@ -532,11 +543,12 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
},
},
{
accessorKey: "nbMagasinsReseau",
id: "nbMagasinsReseau",
accessorFn: (row) => getQlikNetworkSortValue(row.nbMagasinsReseau, "nbMagasinsReseau", sorting),
header: () => <div className="text-center w-full">Magasins<br/><span className="text-[9px] opacity-60">/ 270</span></div>,
size: 80,
cell: ({ getValue }) => {
const val = getValue<number | undefined>();
cell: ({ row }) => {
const val = row.original.nbMagasinsReseau;
return (
<div className="text-center tabular-nums text-[12px] font-bold" style={{ color: "var(--text-secondary)" }}>
{val != null ? val : "-"}
@@ -545,17 +557,56 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
},
},
{
accessorKey: "tauxPresenceReseau",
header: () => <div className="text-center w-full">% Prés.<br/><span className="text-[9px] opacity-60">Réseau</span></div>,
size: 70,
cell: ({ getValue }) => {
const val = getValue<number | undefined>();
if (val == null) return <div className="text-center text-[12px]" style={{ color: "var(--text-secondary)" }}>-</div>;
const pct = Math.round(val * 100);
const color = pct >= 66 ? "text-emerald-500" : pct >= 33 ? "text-amber-500" : "text-rose-500";
id: "tendanceReseau",
accessorFn: (row) => {
const t = computeNetworkTrend(row.qteReseauByMonth);
return t.hasData && t.pct != null ? t.pct : Number.NEGATIVE_INFINITY;
},
header: () => <div className="text-center w-full">Tendance<br/><span className="text-[9px] opacity-60">Réseau · 12 m</span></div>,
size: 78,
cell: ({ row }) => {
const trend = computeNetworkTrend(row.original.qteReseauByMonth);
if (!trend.hasData) return <div className="text-center text-[12px]" style={{ color: "var(--text-secondary)" }}>-</div>;
// Cliquable → modal des ventes réseau mois par mois (12 derniers mois).
return (
<div className={cn("text-center font-black text-[13px] tabular-nums", color)}>
{pct}%
<button
type="button"
onClick={() => setNetworkModal(row.original)}
title="Voir les ventes réseau mois par mois (12 derniers mois)"
className="w-full cursor-pointer rounded hover:bg-[var(--bg-elevated)] transition-colors py-0.5"
>
<TrendSparkline trend={trend} />
</button>
);
},
},
{
id: "caParMagasinReseau",
accessorFn: (row) => getQlikNetworkSortValue(row.caParMagasinReseau, "caParMagasinReseau", sorting),
header: () => <div className="text-center w-full">CA / Mag<br/><span className="text-[9px] opacity-60">Réseau</span></div>,
size: 85,
cell: ({ row }) => {
const val = row.original.caParMagasinReseau;
return (
<div className="text-center tabular-nums text-[12px] font-bold" style={{ color: "var(--text-secondary)" }}>
{val != null ? val.toLocaleString("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 }) : "-"}
</div>
);
},
},
{
id: "margePctReseau",
accessorFn: (row) => getQlikNetworkSortValue(row.margePctReseau, "margePctReseau", sorting),
header: () => <div className="text-center w-full">Marge %<br/><span className="text-[9px] opacity-60">Réseau</span></div>,
size: 75,
cell: ({ row }) => {
const val = row.original.margePctReseau;
if (val == null) return <div className="text-center text-[12px]" style={{ color: "var(--text-secondary)" }}>-</div>;
const pct = Math.abs(val) <= 1 ? val * 100 : val;
const color = pct >= 30 ? "text-emerald-500" : pct >= 15 ? "text-amber-500" : "text-rose-500";
return (
<div className={cn("text-center font-bold text-[12px] tabular-nums", color)}>
{pct.toLocaleString("fr-FR", { maximumFractionDigits: 1 })}%
</div>
);
},
@@ -667,7 +718,7 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
size: 110,
cell: ({ row }) => <GammeCell row={row.original} isAdmin={isAdmin} />,
},
], [MONTHS_12, activeMagasin, isAdmin]); // activeMagasin déclenche re-render des cellules mensuelles et totaux
], [MONTHS_12, activeMagasin, isAdmin, sorting]); // activeMagasin déclenche re-render des cellules mensuelles et totaux
const table = useReactTable({
data: filteredData,
@@ -760,11 +811,11 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
<div
ref={tableContainerRef}
className="h-full w-full overflow-auto rounded-[12px] relative"
className="h-full w-full overflow-auto rounded-[14px] relative"
style={{
background: "var(--bg-surface)",
border: "1px solid var(--border)",
boxShadow: "var(--shadow-sm)"
boxShadow: "var(--shadow-md)"
}}
>
<table className="text-sm block" style={{ width: "100%", minWidth: totalWidth }}>
@@ -778,7 +829,7 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
<tr key={headerGroup.id} className="flex w-full">
{headerGroup.headers.map((header) => {
const isFlexible = header.column.id === "libelle1" || header.column.id === "libelle3";
const isCenter = header.column.id === "totalQuantite" || header.column.id === "totalCa" || header.column.id === "totalMarge" || header.column.id.startsWith("month_") || header.column.id === "gammeInitial" || header.column.id === "caReseau" || header.column.id === "qteReseau" || header.column.id === "nbMagasinsReseau" || header.column.id === "tauxPresenceReseau" || header.column.id === "gamme";
const isCenter = header.column.id === "totalQuantite" || header.column.id === "totalCa" || header.column.id === "totalMarge" || header.column.id.startsWith("month_") || header.column.id === "gammeInitial" || QLIK_NETWORK_COLUMN_IDS.has(header.column.id) || header.column.id === "gamme";
const size = header.getSize();
return (
<th
@@ -847,6 +898,9 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
<Dialog open={cellDetail !== null} onOpenChange={(open) => { if (!open) setCellDetail(null); }}>
{cellDetail !== null && <CellDetailModal d={cellDetail!} activeMagasin={activeMagasin} onClose={() => setCellDetail(null)} />}
</Dialog>
<Dialog open={networkModal !== null} onOpenChange={(open) => { if (!open) setNetworkModal(null); }}>
{networkModal !== null && <NetworkMonthlyModal row={networkModal} onClose={() => setNetworkModal(null)} />}
</Dialog>
</>
);
}
@@ -0,0 +1,136 @@
"use client";
/**
* CollectFlow — Graphiques réseau (SVG fait main, aucune librairie de charts).
*
* Extraits de `heatmap-grid.tsx` pour être partagés entre la Grille et la fiche
* produit (`/produits`). Aucun changement de comportement.
*/
import { TrendingUp, TrendingDown, Minus } from "lucide-react";
import { fmtMonthShort } from "@/features/grid/lib/months";
import { TREND_COLOR, type NetworkTrend } from "@/features/grid/lib/network-trend";
/** Sparkline compacte 12 mois + flèche, teintée selon la tendance. */
export function TrendSparkline({ trend }: { trend: NetworkTrend }) {
if (!trend.hasData) return <div className="text-center text-[12px]" style={{ color: "var(--text-secondary)" }}>-</div>;
const { values, direction, pct } = trend;
const color = TREND_COLOR[direction];
const W = 46, H = 18, n = values.length;
const max = Math.max(...values, 1), min = Math.min(...values, 0);
const range = max - min || 1;
const pts = values.map((v, i) => {
const x = n > 1 ? (i / (n - 1)) * (W - 2) + 1 : W / 2;
const y = H - 1 - ((v - min) / range) * (H - 2);
return `${x.toFixed(1)},${y.toFixed(1)}`;
}).join(" ");
const Arrow = direction === "up" ? TrendingUp : direction === "down" ? TrendingDown : Minus;
return (
<div className="flex flex-col items-center justify-center gap-0.5" title={pct != null ? `Tendance ${pct >= 0 ? "+" : ""}${(pct * 100).toFixed(0)}% sur 12 mois (régression) — cliquer pour le détail` : "Tendance réseau — cliquer pour le détail"}>
<div className="flex items-center gap-1">
<svg width={W} height={H} viewBox={`0 0 ${W} ${H}`} className="shrink-0" aria-hidden>
{n > 1 && <polyline points={pts} fill="none" stroke={color} strokeWidth={1.5} strokeLinejoin="round" strokeLinecap="round" />}
{n === 1 && <circle cx={W / 2} cy={H / 2} r={2} fill={color} />}
</svg>
<Arrow className="w-3.5 h-3.5 shrink-0" style={{ color }} strokeWidth={2.5} />
</div>
{pct != null && (
<span className="text-[9px] font-bold tabular-nums leading-none" style={{ color }}>
{pct >= 0 ? "+" : ""}{Math.round(pct * 100)}%
</span>
)}
</div>
);
}
/** Courbe unique des ventes réseau mensuelles (SVG). */
export function NetworkLineChart({ labels, values, color }: { labels: string[]; values: number[]; color: string }) {
const W = 460, H = 200;
const padL = 10, padR = 10, padT = 22, padB = 30;
const plotW = W - padL - padR, plotH = H - padT - padB;
const n = values.length;
const maxV = Math.max(...values, 1);
const x = (i: number) => (n > 1 ? padL + (i / (n - 1)) * plotW : padL + plotW / 2);
const y = (v: number) => padT + plotH - (v / maxV) * plotH;
const linePts = values.map((v, i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`).join(" ");
const areaPts = `${padL},${padT + plotH} ${linePts} ${(padL + plotW).toFixed(1)},${(padT + plotH).toFixed(1)}`;
return (
<div className="mt-3 w-full overflow-x-auto">
<svg viewBox={`0 0 ${W} ${H}`} className="w-full" style={{ minWidth: 380 }} role="img" aria-label="Ventes réseau mensuelles">
{/* ligne de base */}
<line x1={padL} y1={padT + plotH} x2={padL + plotW} y2={padT + plotH} stroke="var(--border)" strokeWidth={1} />
{/* aire douce sous la courbe */}
{n > 1 && <polygon points={areaPts} fill={color} opacity={0.08} />}
{/* courbe */}
{n > 1 && <polyline points={linePts} fill="none" stroke={color} strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" />}
{values.map((v, i) => (
<g key={labels[i]}>
<circle cx={x(i)} cy={y(v)} r={2.6} fill={color} />
{/* quantité au-dessus du point */}
<text x={x(i)} y={y(v) - 6} textAnchor="middle" fontSize={9} fontWeight={700} fill="var(--text-primary)">
{Math.round(v).toLocaleString("fr-FR")}
</text>
{/* mois sous l'axe */}
<text x={x(i)} y={H - 10} textAnchor="middle" fontSize={8} fill="var(--text-muted)">
{fmtMonthShort(labels[i])}
</text>
</g>
))}
</svg>
</div>
);
}
/**
* Deux courbes superposées sur le même axe (comparatif local vs réseau).
* Les valeurs sont normalisées ensemble pour rester comparables visuellement.
*/
export function DualLineChart({
labels,
series,
}: {
labels: string[];
series: Array<{ name: string; values: number[]; color: string }>;
}) {
const W = 620, H = 220;
const padL = 10, padR = 10, padT = 26, padB = 30;
const plotW = W - padL - padR, plotH = H - padT - padB;
const n = labels.length;
const maxV = Math.max(1, ...series.flatMap((s) => s.values));
const x = (i: number) => (n > 1 ? padL + (i / (n - 1)) * plotW : padL + plotW / 2);
const y = (v: number) => padT + plotH - (v / maxV) * plotH;
return (
<div className="w-full overflow-x-auto">
<div className="flex items-center gap-4 mb-1">
{series.map((s) => (
<span key={s.name} className="flex items-center gap-1.5 text-[11px]" style={{ color: "var(--text-secondary)" }}>
<span className="inline-block w-3 h-[3px] rounded-full" style={{ background: s.color }} />
{s.name}
</span>
))}
</div>
<svg viewBox={`0 0 ${W} ${H}`} className="w-full" style={{ minWidth: 460 }} role="img" aria-label="Comparatif mensuel local vs réseau">
<line x1={padL} y1={padT + plotH} x2={padL + plotW} y2={padT + plotH} stroke="var(--border)" strokeWidth={1} />
{series.map((s) => {
const pts = s.values.map((v, i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`).join(" ");
return (
<g key={s.name}>
{n > 1 && (
<polyline points={pts} fill="none" stroke={s.color} strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" />
)}
{s.values.map((v, i) => (
<circle key={`${s.name}-${labels[i]}`} cx={x(i)} cy={y(v)} r={2.4} fill={s.color} />
))}
</g>
);
})}
{labels.map((lab, i) => (
<text key={lab} x={x(i)} y={H - 10} textAnchor="middle" fontSize={8} fill="var(--text-muted)">
{fmtMonthShort(lab)}
</text>
))}
</svg>
</div>
);
}
@@ -1,178 +0,0 @@
"use client";
import { useMemo, useRef } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import { useGridStore } from "@/features/grid/store/use-grid-store";
import type { ProductRow } from "@/types/grid";
import { PackageX } from "lucide-react";
function getNoSalesIn6Months(rows: ProductRow[]): ProductRow[] {
const sixMonthsAgo = new Date();
sixMonthsAgo.setMonth(sixMonthsAgo.getMonth() - 6);
return rows.filter(row => {
if (row.derniereVente) {
return new Date(row.derniereVente) < sixMonthsAgo;
}
// Fallback : calcul depuis sales12m
const salesMonths = Object.entries(row.sales12m || {})
.filter(([, qty]) => qty > 0)
.map(([m]) => m)
.sort();
if (salesMonths.length === 0) return true;
const last = salesMonths[salesMonths.length - 1];
const lastDate = new Date(parseInt(last.slice(0, 4)), parseInt(last.slice(4, 6)) - 1, 1);
return lastDate < sixMonthsAgo;
});
}
function formatDate(iso?: string): string {
if (!iso) return "—";
return new Date(iso).toLocaleDateString("fr-FR", { day: "2-digit", month: "2-digit", year: "numeric" });
}
function daysSince(iso?: string): number | null {
if (!iso) return null;
return Math.floor((Date.now() - new Date(iso).getTime()) / 86_400_000);
}
function formatCa(ca: number): string {
return ca.toLocaleString("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 });
}
const GAMME_COLORS: Record<string, string> = {
A: "bg-emerald-500/15 text-emerald-700",
B: "bg-blue-500/15 text-blue-700",
C: "bg-amber-500/15 text-amber-700",
Y: "bg-purple-500/15 text-purple-700",
Z: "bg-red-500/15 text-red-700",
};
export function NoSalesTab() {
const rows = useGridStore(s => s.rows);
const scrollRef = useRef<HTMLDivElement>(null);
const filtered = useMemo(() => {
const result = getNoSalesIn6Months(rows);
// Tri : produits les plus anciens en premier, puis par stock décroissant
return result.sort((a, b) => {
const da = a.derniereVente ? new Date(a.derniereVente).getTime() : 0;
const db = b.derniereVente ? new Date(b.derniereVente).getTime() : 0;
if (da !== db) return da - db;
return (b.stockActuel ?? 0) - (a.stockActuel ?? 0);
});
}, [rows]);
// eslint-disable-next-line react-hooks/incompatible-library
const rowVirtualizer = useVirtualizer({
count: filtered.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => 36,
overscan: 12,
});
if (rows.length === 0) {
return (
<div className="flex-1 flex items-center justify-center text-sm italic" style={{ color: "var(--text-muted)" }}>
Chargement des données en cours…
</div>
);
}
if (filtered.length === 0) {
return (
<div className="flex-1 flex flex-col items-center justify-center gap-3" style={{ color: "var(--text-muted)" }}>
<PackageX className="w-10 h-10 opacity-30" />
<p className="text-sm italic">Aucun produit sans vente depuis 6 mois.</p>
</div>
);
}
return (
<div className="flex flex-col flex-1 min-h-0 gap-3">
{/* Résumé */}
<div className="shrink-0 px-3 py-2 rounded-lg text-sm font-medium flex items-center gap-2"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}
>
<PackageX className="w-4 h-4 shrink-0" style={{ color: "var(--accent)" }} />
<span>
<span className="font-bold" style={{ color: "var(--text-primary)" }}>
{filtered.length.toLocaleString("fr-FR")}
</span>
{" "}produit{filtered.length > 1 ? "s" : ""} sans vente depuis plus de 6 mois
{" "}({rows.length.toLocaleString("fr-FR")} références au total)
</span>
</div>
{/* Tableau */}
<div ref={scrollRef} className="flex-1 min-h-0 overflow-auto rounded-xl" style={{ border: "1px solid var(--border)" }}>
<div className="min-w-[900px] text-[12px]">
<div className="sticky top-0 z-10 grid grid-cols-[90px_minmax(220px,1fr)_120px_76px_130px_70px_90px_110px_70px]"
style={{ background: "var(--bg-elevated)", borderBottom: "1px solid var(--border)" }}
>
{["Code", "Libellé", "Réf.", "Gamme", "Dernière vente", "Jours", "Stock", "CA 12m", "Mag."].map(col => (
<div key={col}
className="px-3 py-2 text-left font-semibold whitespace-nowrap"
style={{ color: "var(--text-muted)" }}
>
{col}
</div>
))}
</div>
<div style={{ height: rowVirtualizer.getTotalSize(), position: "relative" }}>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const row = filtered[virtualRow.index];
const i = virtualRow.index;
const days = daysSince(row.derniereVente);
const gamme = row.codeGamme?.trim().toUpperCase() ?? "—";
const gammeClass = GAMME_COLORS[gamme] ?? "bg-zinc-500/15 text-zinc-600";
return (
<div
key={row.codein}
className="absolute left-0 right-0 grid grid-cols-[90px_minmax(220px,1fr)_120px_76px_130px_70px_90px_110px_70px] items-center transition-colors hover:bg-white/5"
style={{
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
borderBottom: i < filtered.length - 1 ? "1px solid var(--border)" : undefined,
background: i % 2 === 0 ? "transparent" : "var(--bg-subtle, rgba(0,0,0,0.02))",
}}
>
<div className="px-3 py-1.5 font-mono" style={{ color: "var(--text-muted)" }}>{row.codein}</div>
<div className="px-3 py-1.5 truncate" style={{ color: "var(--text-primary)" }} title={row.libelle1}>{row.libelle1}</div>
<div className="px-3 py-1.5 font-mono text-[11px]" style={{ color: "var(--text-muted)" }}>{row.reference || "—"}</div>
<div className="px-3 py-1.5">
<span className={`inline-block px-1.5 py-0.5 rounded text-[11px] font-bold ${gammeClass}`}>
{gamme}
</span>
</div>
<div className="px-3 py-1.5 whitespace-nowrap" style={{ color: row.derniereVente ? "var(--text-secondary)" : "var(--text-muted)" }}>
{formatDate(row.derniereVente)}
</div>
<div className="px-3 py-1.5 text-right font-medium tabular-nums"
style={{ color: days && days > 365 ? "var(--color-red, #ef4444)" : "var(--text-secondary)" }}>
{days != null ? days.toLocaleString("fr-FR") : "—"}
</div>
<div className="px-3 py-1.5 text-right tabular-nums" style={{ color: "var(--text-secondary)" }}>
{row.stockActuel != null ? row.stockActuel.toLocaleString("fr-FR") : "—"}
</div>
<div className="px-3 py-1.5 text-right tabular-nums" style={{ color: "var(--text-secondary)" }}>
{row.totalCa > 0 ? formatCa(row.totalCa) : "—"}
</div>
<div className="px-3 py-1.5 text-right tabular-nums font-semibold"
style={{ color: "var(--text-secondary)" }}>
{row.nbMagasinsReseau != null ? row.nbMagasinsReseau : "—"}
</div>
</div>
);
})}
</div>
</div>
</div>
</div>
);
}
/** Compte les produits sans vente depuis 6 mois — utilisé pour le badge dans le switcher */
export function countNoSalesIn6Months(rows: ProductRow[]): number {
return getNoSalesIn6Months(rows).length;
}
@@ -1,8 +1,9 @@
"use client";
import { useState } from "react";
import { useCallback } from "react";
import { useRouter, usePathname, useSearchParams } from "next/navigation";
import { Loader2, RefreshCw, CheckCircle, AlertCircle } from "lucide-react";
import { useQlikSyncJob } from "@/features/qlik-sync/use-qlik-sync-job";
interface SyncQlikButtonProps {
/** Code fournisseur affiché (pour sync ciblée) */
@@ -11,7 +12,8 @@ interface SyncQlikButtonProps {
lastUpdate?: string | null;
}
function formatDate(iso?: string | null): string {
/** Date + heure de dernière synchro, ou "jamais". */
function formatLastUpdate(iso?: string | null): string {
if (!iso) return "jamais";
const d = new Date(iso);
if (isNaN(d.getTime())) return "jamais";
@@ -21,49 +23,48 @@ function formatDate(iso?: string | null): string {
/**
* Bouton admin : synchronise depuis Qlik les métriques réseau du fournisseur affiché
* (POST /api/qlik/sync?fournisseur=…) puis recharge la grille. Affiche la dernière maj.
*
* Toute la mécanique de job (POST puis polling GET, reprise après reload) vit
* dans `useQlikSyncJob`, partagée avec la fiche produit.
*/
export function SyncQlikButton({ codeFournisseur, lastUpdate }: SyncQlikButtonProps) {
const [status, setStatus] = useState<"idle" | "loading" | "success" | "error">("idle");
const [message, setMessage] = useState<string>("");
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const handleSync = async () => {
if (!codeFournisseur) {
setStatus("error");
setMessage("Aucun fournisseur sélectionné");
return;
}
setStatus("loading");
setMessage("");
try {
const res = await fetch(`/api/qlik/sync?fournisseur=${encodeURIComponent(codeFournisseur)}`, { method: "POST" });
const data = await res.json();
if (!res.ok || !data.success) throw new Error(data.error || `HTTP ${res.status}`);
setStatus("success");
setMessage(`${data.upserted} produits réseau synchronisés`);
// Recharge la grille pour afficher les nouvelles données réseau
const params = new URLSearchParams(searchParams.toString());
params.set("_refresh", String(Date.now()));
router.replace(`${pathname}?${params.toString()}`);
setTimeout(() => setStatus("idle"), 4000);
} catch (e) {
setStatus("error");
setMessage(e instanceof Error ? e.message : String(e));
setTimeout(() => setStatus("idle"), 6000);
}
};
/** Force le refresh de la grille (avec cache-buster) et de la barre de résumé. */
const refreshGrid = useCallback(() => {
const params = new URLSearchParams(searchParams.toString());
params.set("_refresh", String(Date.now()));
router.replace(`${pathname}?${params.toString()}`);
router.refresh();
}, [pathname, router, searchParams]);
const { status, message, start } = useQlikSyncJob({
target: { mode: "fournisseur", fournisseur: codeFournisseur },
onSuccess: refreshGrid,
});
return (
<div className="flex flex-col items-end">
<button
onClick={handleSync}
disabled={status === "loading"}
className="btn-action btn-action-secondary flex items-center gap-1.5 disabled:opacity-60"
title={message || "Synchroniser les données réseau Qlik pour ce fournisseur"}
<div className="flex items-center gap-2">
<span
className="text-[10px] leading-tight text-right whitespace-nowrap hidden lg:block"
style={{ color: status === "error" ? "var(--accent-error)" : "var(--text-muted)" }}
title={message || undefined}
>
{status === "loading" ? (
{status === "error"
? message
: status === "running"
? (message || `Extraction Qlik…`)
: `MAJ Qlik · ${formatLastUpdate(lastUpdate)}`}
</span>
<button
onClick={start}
disabled={status === "running"}
className="btn-action btn-action-secondary flex items-center gap-1.5 disabled:opacity-60"
title={message || `Synchroniser les données réseau Qlik · MAJ ${formatLastUpdate(lastUpdate)}`}
>
{status === "running" ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : status === "success" ? (
<CheckCircle className="w-3.5 h-3.5 text-emerald-500" />
@@ -72,11 +73,8 @@ export function SyncQlikButton({ codeFournisseur, lastUpdate }: SyncQlikButtonPr
) : (
<RefreshCw className="w-3.5 h-3.5" />
)}
Sync Qlik
{status === "running" ? "Sync en cours…" : "Sync Qlik"}
</button>
<span className="text-[10px] text-slate-500 mt-0.5">
{status === "error" ? message : `MAJ Qlik : ${formatDate(lastUpdate)}`}
</span>
</div>
);
}
+96
View File
@@ -0,0 +1,96 @@
/**
* CollectFlow — Helpers de fenêtre mensuelle et de libellés magasin.
*
* Extraits de `heatmap-grid.tsx` pour être partagés entre la Grille et la
* fiche produit (`/produits`). Aucun changement de comportement.
*
* ⚠️ Deux conventions de clé de mois cohabitent dans l'application :
* - séries FF Nancy (ventes / stock / réceptions) : `"YYYYMM"` (sans tiret)
* - séries réseau Qlik (`qteByMonth`, `metricsByMonth`) : `"YYYY-MM"` (avec tiret)
* Utiliser `ffMonthToQlik` / `qlikMonthToFf` pour passer de l'une à l'autre.
*/
/**
* Les 12 mois complets glissants, du plus ancien au plus récent, au format
* `"YYYYMM"`. Le mois en cours est **exclu** (on part de i=12 jusqu'à i=1).
*
* ⚠️ À n'appeler qu'à l'intérieur d'un `useMemo` / d'un rendu, jamais au niveau
* module : `new Date()` évalué à l'import provoque un mismatch d'hydratation
* entre le rendu serveur et le rendu client.
*/
export function getLast12Months(): string[] {
const months: string[] = [];
const now = new Date();
// On commence à i=12 (il y a 12 mois) et on finit à i=1 (le mois dernier)
// pour exclure le mois en cours (i=0)
for (let i = 12; i >= 1; i--) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
months.push(`${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, "0")}`);
}
return months;
}
/** `"202601"` → `"Jan 26"`. */
export function formatMonthLabel(key: string): string {
const m = parseInt(key.slice(4, 6), 10);
const names = ["Jan", "Fév", "Mar", "Avr", "Mai", "Jun", "Jul", "Aoû", "Sep", "Oct", "Nov", "Déc"];
return `${names[m - 1]} ${key.slice(2, 4)}`;
}
/** `"2026-01"` → `"01/26"` — axe compact des graphiques. */
export function fmtMonthShort(lab: string): string {
const [y, m] = lab.split("-");
return `${m ?? lab}/${(y ?? "").slice(2)}`;
}
/** Date ISO → `jj/mm/aaaa`, `"—"` si absente ou invalide. */
export function formatDate(iso?: string | null): string {
if (!iso) return "—";
const d = new Date(iso);
if (isNaN(d.getTime())) return "—";
return d.toLocaleDateString("fr-FR", { day: "2-digit", month: "2-digit", year: "numeric" });
}
/** Clé de mois FF `"202601"` → clé Qlik `"2026-01"`. */
export function ffMonthToQlik(key: string): string {
return `${key.slice(0, 4)}-${key.slice(4, 6)}`;
}
/** Clé de mois Qlik `"2026-01"` → clé FF `"202601"`. */
export function qlikMonthToFf(key: string): string {
return key.replace("-", "");
}
/** Les deux magasins FF Nancy suivis par l'application. */
export const SITE_LABELS: Record<string, { label: string; nom: string }> = {
"292": { label: "F", nom: "Frouard (Nancy)" },
"579": { label: "H", nom: "Houdemont" },
};
/**
* Style de badge stable pour un magasin : la couleur est dérivée d'un hash du
* nom, donc identique quel que soit l'ordre d'affichage.
*/
export function getStoreConfig(name: string) {
const known = SITE_LABELS[name];
const words = (known?.nom ?? name).trim().split(/\s+/);
let label = known?.label ?? words[0].charAt(0).toUpperCase();
if (!known && words.length > 1) {
label += words[1].charAt(0).toUpperCase();
}
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash);
}
const colorInt = Math.abs(hash) % 5;
switch (colorInt) {
case 0: return { bg: "rgba(99, 102, 241, 0.1)", text: "#818cf8", border: "rgba(99, 102, 241, 0.2)", label }; // Indigo
case 1: return { bg: "rgba(245, 158, 11, 0.1)", text: "#fbbf24", border: "rgba(245, 158, 11, 0.2)", label }; // Amber
case 2: return { bg: "rgba(16, 185, 129, 0.1)", text: "#10b981", border: "rgba(16, 185, 129, 0.2)", label }; // Emerald
case 3: return { bg: "rgba(236, 72, 153, 0.1)", text: "#ec4899", border: "rgba(236, 72, 153, 0.2)", label }; // Pink
case 4: return { bg: "rgba(14, 165, 233, 0.1)", text: "#0ea5e9", border: "rgba(14, 165, 233, 0.2)", label }; // Sky
default: return { bg: "var(--bg-elevated)", text: "var(--text-muted)", border: "var(--border)", label };
}
}
+78
View File
@@ -0,0 +1,78 @@
/**
* CollectFlow — Tendance des ventes réseau Qlik sur 12 mois.
*
* Extrait de `heatmap-grid.tsx` pour être partagé entre la Grille (sparkline de
* la colonne « Tendance / Réseau ») et la fiche produit (`/produits`).
* Aucun changement de comportement.
*/
/**
* Nombre de magasins du réseau La Foir'Fouille servant de dénominateur au taux
* de présence. Constante métier : source unique pour la Grille et la fiche produit.
*/
export const NB_MAGASINS_RESEAU = 270;
export type NetworkTrend = {
values: number[]; // quantités mensuelles, ordre chronologique
labels: string[]; // "YYYY-MM"
direction: "up" | "down" | "flat";
pct: number | null; // variation modélisée (régression linéaire) sur 12 mois
hasData: boolean;
};
/** Seuil de "forte" variation (±25%) pour distinguer hausse/forte hausse dans l'UI. */
export const TREND_STRONG = 0.25;
/** Seuil au-delà duquel on ne considère plus la tendance comme stable (±8%). */
export const TREND_FLAT = 0.08;
export const TREND_COLOR: Record<NetworkTrend["direction"], string> = {
up: "#22c55e",
down: "#ef4444",
flat: "#94a3b8",
};
/**
* Tendance réseau par RÉGRESSION LINÉAIRE (moindres carrés) sur les 12 derniers mois.
* Utilise tous les points (robuste au bruit d'un mois isolé). L'indicateur `pct` est la
* variation modélisée sur la période (pente × durée) rapportée à la moyenne.
* Direction : forte hausse >+25%, hausse >+8%, stable, baisse <−8%, forte baisse <−25%.
*/
export function computeNetworkTrend(qteByMonth?: Record<string, number> | null): NetworkTrend {
const empty: NetworkTrend = { values: [], labels: [], direction: "flat", pct: null, hasData: false };
if (!qteByMonth) return empty;
let labels = Object.keys(qteByMonth).sort(); // "YYYY-MM" trie chronologiquement
if (labels.length === 0) return empty;
labels = labels.slice(-12); // 12 derniers mois
const values = labels.map((l) => Number(qteByMonth[l]) || 0);
const n = values.length;
let pct: number | null = null;
let direction: NetworkTrend["direction"] = "flat";
if (n >= 3) {
const mx = (n - 1) / 2;
const my = values.reduce((s, v) => s + v, 0) / n;
let num = 0, den = 0;
for (let i = 0; i < n; i++) { num += (i - mx) * (values[i] - my); den += (i - mx) * (i - mx); }
const slope = den ? num / den : 0;
if (my > 0) {
pct = (slope * (n - 1)) / my; // variation modélisée sur toute la période / moyenne
direction = pct > TREND_FLAT ? "up" : pct < -TREND_FLAT ? "down" : "flat";
} else if (slope > 0) {
direction = "up";
}
} else if (n === 2 && values[0] > 0) {
pct = (values[1] - values[0]) / values[0];
direction = pct > TREND_FLAT ? "up" : pct < -TREND_FLAT ? "down" : "flat";
}
return { values, labels, direction, pct, hasData: true };
}
/** Libellé français de la tendance : « Forte hausse », « Baisse », « Stable »… */
export function trendLabel(pct: number | null): string {
if (pct == null) return "Stable";
if (pct > TREND_STRONG) return "Forte hausse";
if (pct > TREND_FLAT) return "Hausse";
if (pct < -TREND_STRONG) return "Forte baisse";
if (pct < -TREND_FLAT) return "Baisse";
return "Stable";
}
@@ -0,0 +1,154 @@
import "server-only";
import {
pgGetProduitDetail,
pgGetFournisseursByCodein,
pgGetStockByCodein,
pgGetCommandesByCodein,
pgGetGammeHistoryByCodein,
pgGetMensuelByCodein,
} from "@/lib/pg-ff-client";
import { buildLast12MonthsRange } from "@/lib/api-ff-client";
import { getNetworkMetricsByCodeCentrale } from "@/lib/qlik-network-cache";
import type { ProduitFiche, ProduitMois, ProduitTotaux } from "@/features/produits/types";
/** Les 12 clés de mois "YYYYMM" glissantes, mois courant exclu. */
function buildMonthKeys(): string[] {
const months: string[] = [];
const now = new Date();
for (let i = 12; i >= 1; i--) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
months.push(`${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, "0")}`);
}
return months;
}
function emptyMois(mois: string): ProduitMois {
return { mois, qte: 0, ca: 0, marge: 0, stockFinMois: 0, qteRecue: 0 };
}
function totauxFrom(rows: ProduitMois[]): ProduitTotaux {
const qte = rows.reduce((s, r) => s + r.qte, 0);
const ca = rows.reduce((s, r) => s + r.ca, 0);
const marge = rows.reduce((s, r) => s + r.marge, 0);
return { qte, ca, marge, tauxMarge: ca > 0 ? (marge / ca) * 100 : 0 };
}
/**
* Assemble la fiche complète d'un produit : identité, fournisseurs, stock,
* gammes, 12 mois glissants de ventes (total et par magasin) et métriques
* réseau Qlik.
*
* Renvoie `null` si le codein est inconnu du catalogue FF.
*/
export async function getProduitFiche(codein: string): Promise<ProduitFiche | null> {
const cleaned = codein.trim();
if (!cleaned) return null;
const detail = await pgGetProduitDetail(cleaned);
if (!detail) return null;
const { dateDebut, dateFin } = buildLast12MonthsRange();
const [fournisseurs, stock, commandesEnCours, gammes, mensuelRows] = await Promise.all([
pgGetFournisseursByCodein(cleaned),
pgGetStockByCodein(cleaned),
pgGetCommandesByCodein(cleaned),
pgGetGammeHistoryByCodein(cleaned),
pgGetMensuelByCodein(cleaned, dateDebut, dateFin),
]);
const months = buildMonthKeys();
const allowed = new Set(months);
const mensuelTotal: Record<string, ProduitMois> = {};
const mensuelParSite: Record<string, Record<string, ProduitMois>> = {};
for (const m of months) mensuelTotal[m] = emptyMois(m);
// Mois réellement présents dans les mouvements. Indispensable pour distinguer
// « pas de mouvement ce mois-là » (→ report du stock précédent) de « stock
// effectivement nul » — c'est la convention de la Grille.
const moisAvecDonnees = new Set<string>();
const moisAvecDonneesParSite = new Map<string, Set<string>>();
for (const row of mensuelRows) {
// SQL renvoie "YYYY-MM", la timeline de l'app utilise "YYYYMM".
const mois = row.mois.replace("-", "");
if (!allowed.has(mois)) continue;
const site = String(row.site).trim();
const qte = Number(row.qte_vendue) || 0;
const ca = Number(row.ca_ht) || 0;
const marge = Number(row.marge) || 0;
const stockFinMois = Number(row.stock_fin_mois) || 0;
const qteRecue = Number(row.qte_recue) || 0;
moisAvecDonnees.add(mois);
const tot = mensuelTotal[mois];
tot.qte += qte;
tot.ca += ca;
tot.marge += marge;
tot.stockFinMois += stockFinMois;
tot.qteRecue += qteRecue;
mensuelParSite[site] ??= Object.fromEntries(months.map((m) => [m, emptyMois(m)]));
if (!moisAvecDonneesParSite.has(site)) moisAvecDonneesParSite.set(site, new Set());
moisAvecDonneesParSite.get(site)!.add(mois);
const cell = mensuelParSite[site][mois];
cell.qte += qte;
cell.ca += ca;
cell.marge += marge;
// Le SQL donne déjà le stock du dernier mouvement du mois pour ce site.
cell.stockFinMois = stockFinMois;
cell.qteRecue += qteRecue;
}
// Report du stock de fin de mois sur les mois SANS mouvement (carry-forward),
// même convention que la Grille : un mois sans mouvement conserve le stock du
// mois précédent, alors qu'un mois avec mouvements et un stock nul reste à 0.
const carryForward = (serie: Record<string, ProduitMois>, avecDonnees: Set<string>) => {
let last = 0;
for (const m of months) {
if (avecDonnees.has(m)) last = serie[m].stockFinMois;
else serie[m].stockFinMois = last;
}
};
carryForward(mensuelTotal, moisAvecDonnees);
for (const [site, bySite] of Object.entries(mensuelParSite)) {
carryForward(bySite, moisAvecDonneesParSite.get(site) ?? new Set());
}
const totaux = totauxFrom(months.map((m) => mensuelTotal[m]));
const totauxParSite: Record<string, ProduitTotaux> = {};
for (const [site, bySite] of Object.entries(mensuelParSite)) {
totauxParSite[site] = totauxFrom(months.map((m) => bySite[m]));
}
const sitesActifs = Object.entries(totauxParSite)
.filter(([, t]) => t.qte > 0)
.map(([site]) => site)
.sort();
// Métriques réseau Qlik (jointure par code centrale).
let reseau = null;
if (detail.code_centrale) {
const map = await getNetworkMetricsByCodeCentrale([detail.code_centrale]);
reseau = map.get(detail.code_centrale) ?? null;
}
return {
detail,
fournisseurs,
stock,
commandesEnCours,
gammes,
months,
mensuelTotal,
mensuelParSite,
totaux,
totauxParSite,
sitesActifs,
reseau,
periode: { dateDebut, dateFin },
};
}
+147
View File
@@ -0,0 +1,147 @@
/**
* CollectFlow — Comparatif local vs réseau pour un produit.
*
* Principe : rapporter les deux périmètres à un **magasin moyen**, sinon on
* compare 2 magasins Nancy à ~270 magasins réseau et le chiffre ne veut rien dire.
*
* ⚠️ La comparaison de référence porte sur les **quantités**. Le CA local vient
* de `mvtart.mntmvtttc` (TTC) alors que la base de la mesure Qlik « CA N » n'est
* pas documentée : les montants sont donc affichés côte à côte à titre indicatif,
* sans indice calculé.
*/
import { NB_MAGASINS_RESEAU } from "@/features/grid/lib/network-trend";
import { ffMonthToQlik } from "@/features/grid/lib/months";
import type { ProduitFiche } from "@/features/produits/types";
export interface ComparatifMois {
/** Clé de mois FF "YYYYMM". */
mois: string;
/** Quantité vendue par magasin, chez nous. */
local: number;
/** Quantité vendue par magasin, au réseau. */
reseau: number;
}
export interface ComparatifReseau {
/** false si le produit n'a pas encore de données réseau en cache. */
hasReseau: boolean;
/** Nombre de nos magasins ayant vendu le produit (min. 1 pour éviter /0). */
nbMagasinsLocaux: number;
nbMagasinsReseau: number;
/** Taux de présence réseau (0..1), ou null sans données. */
tauxPresence: number | null;
/** Quantité 12 mois, par magasin. */
qteLocaleParMagasin: number;
qteReseauParMagasin: number;
/**
* Indice de performance en % : 100 = on vend autant qu'un magasin moyen du
* réseau, 150 = 50 % de mieux. `null` si le réseau ne vend rien.
*/
indiceQte: number | null;
/** CA 12 mois par magasin — indicatif (bases de calcul non alignées). */
caLocalParMagasin: number;
caReseauParMagasin: number | null;
/** Taux de marge en %, local et réseau (indicatif). */
tauxMargeLocal: number;
tauxMargeReseau: number | null;
/** Série mensuelle par magasin, alignée sur les 12 mois de la fiche. */
monthly: ComparatifMois[];
/** true si au moins un mois réseau est disponible. */
hasMonthly: boolean;
}
/**
* Le taux de marge Qlik est un ratio brut (0.32 = 32 %) mais certaines
* configurations renvoient déjà des points de pourcentage. Même normalisation
* que la colonne « Marge % / Réseau » de la Grille.
*/
export function normalizeMargePct(raw: number | null | undefined): number | null {
if (raw == null) return null;
const v = Number(raw);
if (!Number.isFinite(v) || v === 0) return null;
return Math.abs(v) <= 1 ? v * 100 : v;
}
/** Calcule le comparatif local vs réseau à partir d'une fiche produit. */
export function computeComparatif(fiche: ProduitFiche): ComparatifReseau {
const { reseau, totaux, sitesActifs, months, mensuelTotal } = fiche;
// Nos magasins actifs sur la période. Si aucun n'a vendu, on garde 1 pour
// que la division reste définie — l'indice vaudra alors 0, ce qui est le
// message voulu : « le réseau vend, nous non ».
const nbMagasinsLocaux = Math.max(sitesActifs.length, 1);
const qteLocaleParMagasin = totaux.qte / nbMagasinsLocaux;
const caLocalParMagasin = totaux.ca / nbMagasinsLocaux;
if (!reseau || !reseau.nbMagasinsReseau) {
return {
hasReseau: false,
nbMagasinsLocaux,
nbMagasinsReseau: 0,
tauxPresence: null,
qteLocaleParMagasin,
qteReseauParMagasin: 0,
indiceQte: null,
caLocalParMagasin,
caReseauParMagasin: null,
tauxMargeLocal: totaux.tauxMarge,
tauxMargeReseau: null,
monthly: [],
hasMonthly: false,
};
}
const nbMagasinsReseau = reseau.nbMagasinsReseau;
const qteReseauParMagasin = reseau.qteReseau / nbMagasinsReseau;
const caReseauParMagasin = reseau.caReseau / nbMagasinsReseau;
// Série mensuelle : quantité par magasin de part et d'autre.
// Le nb de magasins réseau du mois est utilisé quand il est disponible
// (`metricsByMonth`), sinon on retombe sur le total de la période.
const monthly: ComparatifMois[] = [];
let hasMonthly = false;
for (const mois of months) {
const qlikKey = ffMonthToQlik(mois);
const mensuel = reseau.metricsByMonth?.[qlikKey];
const qteReseauMois = mensuel?.qte ?? reseau.qteByMonth?.[qlikKey];
const nbMagMois = mensuel?.nbMag && mensuel.nbMag > 0 ? mensuel.nbMag : nbMagasinsReseau;
if (qteReseauMois != null) hasMonthly = true;
monthly.push({
mois,
local: (mensuelTotal[mois]?.qte ?? 0) / nbMagasinsLocaux,
reseau: qteReseauMois != null ? qteReseauMois / nbMagMois : 0,
});
}
return {
hasReseau: true,
nbMagasinsLocaux,
nbMagasinsReseau,
tauxPresence: nbMagasinsReseau / NB_MAGASINS_RESEAU,
qteLocaleParMagasin,
qteReseauParMagasin,
indiceQte: qteReseauParMagasin > 0 ? (qteLocaleParMagasin / qteReseauParMagasin) * 100 : null,
caLocalParMagasin,
caReseauParMagasin,
tauxMargeLocal: totaux.tauxMarge,
tauxMargeReseau: normalizeMargePct(reseau.margePctReseau),
monthly,
hasMonthly,
};
}
/** Lecture métier de l'indice de performance. */
export function indiceVerdict(indice: number | null): { label: string; color: string } {
if (indice == null) return { label: "Non comparable", color: "var(--text-muted)" };
if (indice >= 150) return { label: "Forte surperformance", color: "#22c55e" };
if (indice >= 110) return { label: "Surperformance", color: "#22c55e" };
if (indice >= 90) return { label: "Dans la moyenne réseau", color: "var(--text-secondary)" };
if (indice >= 50) return { label: "Sous-performance", color: "#f59e0b" };
return { label: "Forte sous-performance", color: "#ef4444" };
}
+53
View File
@@ -0,0 +1,53 @@
/**
* CollectFlow — Types de la fiche produit (page /produits).
*/
import type {
PgProduitDetailRow,
PgProduitFournisseurRow,
PgProduitStockRow,
PgGammeHistoryRow,
} from "@/lib/pg-ff-client";
import type { NetworkMetricCached } from "@/lib/qlik-network-cache";
/** Les métriques d'un mois, pour un périmètre donné (total ou un site). */
export interface ProduitMois {
/** Clé de mois FF, format "YYYYMM". */
mois: string;
qte: number;
/** Issu de mntmvtttc — TTC malgré le nom historique `ca_ht`. */
ca: number;
marge: number;
stockFinMois: number;
qteRecue: number;
}
export interface ProduitTotaux {
qte: number;
ca: number;
marge: number;
/** Taux de marge en % (marge / CA × 100). */
tauxMarge: number;
}
export interface ProduitFiche {
detail: PgProduitDetailRow;
fournisseurs: PgProduitFournisseurRow[];
stock: PgProduitStockRow[];
commandesEnCours: number;
gammes: PgGammeHistoryRow[];
/** Les 12 clés de mois "YYYYMM", du plus ancien au plus récent. */
months: string[];
/** Cumul tous sites, par mois. */
mensuelTotal: Record<string, ProduitMois>;
/** site → mois → métriques. */
mensuelParSite: Record<string, Record<string, ProduitMois>>;
totaux: ProduitTotaux;
totauxParSite: Record<string, ProduitTotaux>;
/** Sites ayant vendu au moins une unité sur les 12 mois. */
sitesActifs: string[];
/** Métriques réseau Qlik, `null` si le produit n'est pas encore en cache. */
reseau: NetworkMetricCached | null;
/** Fenêtre analysée, pour l'affichage. */
periode: { dateDebut: string; dateFin: string };
}
+223
View File
@@ -0,0 +1,223 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
/** État serveur renvoyé par GET/POST /api/qlik/sync. */
export interface QlikSyncJobState {
jobId: string | null;
fournisseur: string;
codeCentrale?: string;
status: "idle" | "running" | "success" | "error";
requested: number;
fetched: number;
upserted: number;
periode?: string;
dateDebut?: string;
dateFin?: string;
startedAt?: string;
finishedAt?: string;
error?: string;
message?: string;
}
export type QlikSyncStatus = QlikSyncJobState["status"];
/**
* Cible de la synchro : un fournisseur entier (réservé aux admins) ou un seul
* code centrale (ouvert à tout utilisateur connecté).
*/
export type QlikSyncTarget =
| { mode: "fournisseur"; fournisseur: string | undefined }
| { mode: "produit"; codeCentrale: string | undefined };
interface UseQlikSyncJobOptions {
target: QlikSyncTarget;
/** Appelé une fois quand un job se termine avec succès. */
onSuccess?: () => void;
/** Intervalle de polling en ms (défaut 3 s — une extraction peut durer plusieurs minutes). */
pollIntervalMs?: number;
}
/** Construit la query string de la route selon la cible. */
function targetQuery(target: QlikSyncTarget): string | null {
if (target.mode === "fournisseur") {
return target.fournisseur ? `fournisseur=${encodeURIComponent(target.fournisseur)}` : null;
}
return target.codeCentrale ? `codeCentrale=${encodeURIComponent(target.codeCentrale)}` : null;
}
/**
* Pilote un job de synchronisation Qlik : démarrage (POST), suivi (polling GET),
* reprise d'un job déjà en cours au montage, et nettoyage des timers.
*
* Extrait de `SyncQlikButton` pour être partagé avec la fiche produit. Le
* protocole est inchangé : le POST démarre un job serveur en arrière-plan et
* revient immédiatement, le client poll ensuite GET jusqu'à l'état terminal.
*/
export function useQlikSyncJob({ target, onSuccess, pollIntervalMs = 3000 }: UseQlikSyncJobOptions) {
const [status, setStatus] = useState<QlikSyncStatus>("idle");
const [message, setMessage] = useState<string>("");
const query = targetQuery(target);
const pollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
/** true tant qu'on doit suivre le job courant (false après succès/erreur traité). */
const trackingRef = useRef<boolean>(false);
// Gardé dans une ref : le callback peut changer d'identité à chaque rendu
// sans qu'on veuille relancer le polling pour autant.
const onSuccessRef = useRef(onSuccess);
useEffect(() => { onSuccessRef.current = onSuccess; }, [onSuccess]);
const clearPollTimer = useCallback(() => {
if (pollTimerRef.current) {
clearTimeout(pollTimerRef.current);
pollTimerRef.current = null;
}
}, []);
/** Met à jour le libellé visible selon l'état courant. */
const applyJobToUi = useCallback((j: QlikSyncJobState | null) => {
if (!j) return;
if (j.status === "running") {
setStatus("running");
const requestedTxt = j.requested > 0 ? ` (${j.requested} code${j.requested > 1 ? "s" : ""})` : "";
setMessage(`Extraction Qlik en cours${requestedTxt}…`);
} else if (j.status === "success") {
setStatus("success");
if (j.message) setMessage(j.message);
else setMessage(`${j.upserted} produits réseau synchronisés`);
} else if (j.status === "error") {
setStatus("error");
setMessage(j.error || "Erreur sync Qlik");
} else {
setStatus("idle");
}
}, []);
/**
* Un tour de polling. Renvoie true s'il faut continuer.
*/
const pollOnce = useCallback(async (): Promise<boolean> => {
if (!query || !trackingRef.current) return false;
try {
const res = await fetch(`/api/qlik/sync?${query}`, { method: "GET", cache: "no-store" });
if (!res.ok) {
// Erreur HTTP transitoire : on retente au tour suivant.
return true;
}
const data: QlikSyncJobState = await res.json();
applyJobToUi(data);
if (data.status === "running") return true;
if (data.status === "success" || data.status === "error") {
trackingRef.current = false;
if (data.status === "success") onSuccessRef.current?.();
// Petit délai pour laisser l'utilisateur lire le message vert/rouge.
setTimeout(() => {
setStatus((cur) => (cur === "success" || cur === "error" ? "idle" : cur));
}, 4000);
return false;
}
trackingRef.current = false;
return false;
} catch {
// Erreur réseau transitoire : on laisse le polling réessayer.
return true;
}
}, [applyJobToUi, query]);
/** Boucle de polling auto-réarmée tant que pollOnce le demande. */
const startPolling = useCallback(() => {
clearPollTimer();
const tick = async () => {
const keepGoing = await pollOnce();
if (keepGoing && trackingRef.current) {
pollTimerRef.current = setTimeout(tick, pollIntervalMs);
} else {
pollTimerRef.current = null;
}
};
// Premier appel quasi immédiat.
pollTimerRef.current = setTimeout(tick, 200);
}, [clearPollTimer, pollOnce, pollIntervalMs]);
/**
* Au montage (et quand la cible change) : reprend l'état d'un éventuel job
* déjà en cours, par exemple après un reload pendant l'extraction.
*/
useEffect(() => {
if (!query) return;
let cancelled = false;
trackingRef.current = false;
clearPollTimer();
(async () => {
try {
const res = await fetch(`/api/qlik/sync?${query}`, { method: "GET", cache: "no-store" });
if (cancelled || !res.ok) return;
const data: QlikSyncJobState = await res.json();
if (cancelled) return;
if (data.status === "running" && data.jobId) {
trackingRef.current = true;
applyJobToUi(data);
startPolling();
} else if (data.status === "success" || data.status === "error") {
// Job terminé avant le montage : on l'affiche brièvement.
applyJobToUi(data);
setTimeout(() => {
setStatus((cur) => (cur === "success" || cur === "error" ? "idle" : cur));
}, 4000);
}
} catch {
/* ignore : on n'empêche pas l'UI de s'afficher */
}
})();
return () => {
cancelled = true;
trackingRef.current = false;
clearPollTimer();
};
// On veut interroger une seule fois au montage (et quand la cible change).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [query, clearPollTimer]);
/** Cleanup : annule un timer de polling éventuel au démontage. */
useEffect(() => clearPollTimer, [clearPollTimer]);
/** Démarre l'extraction. */
const start = useCallback(async () => {
if (!query) {
setStatus("error");
setMessage(target.mode === "fournisseur" ? "Aucun fournisseur sélectionné" : "Aucun code centrale");
setTimeout(() => setStatus("idle"), 6000);
return;
}
// Empêche un double-clic pendant qu'un job tourne déjà.
if (status === "running") return;
setStatus("running");
setMessage("Démarrage de l'extraction Qlik…");
try {
const res = await fetch(`/api/qlik/sync?${query}`, { method: "POST" });
const data: QlikSyncJobState & { success?: boolean } = await res.json();
if (!res.ok || data.success === false) {
throw new Error(data.error || `HTTP ${res.status}`);
}
if (data.status === "running" && data.jobId) {
trackingRef.current = true;
applyJobToUi(data);
startPolling();
return;
}
// Statut terminal direct (cas rare : 0 code → success immédiat).
applyJobToUi(data);
if (data.status === "success") onSuccessRef.current?.();
setTimeout(() => setStatus("idle"), 4000);
} catch (e) {
setStatus("error");
setMessage(e instanceof Error ? e.message : String(e));
setTimeout(() => setStatus("idle"), 6000);
}
}, [applyJobToUi, query, startPolling, status, target.mode]);
return { status, message, start };
}
+45 -1
View File
@@ -14,6 +14,20 @@ export interface DbConfig {
aiProvider?: "openrouter" | "google";
googleAiKey?: string;
googleAiModel?: string;
/** Qlik Sense — données réseau */
qlikHost?: string;
qlikUser?: string;
qlikPassword?: string;
}
/** Lit la config existante (ou {} si absente). */
async function readConfig(): Promise<Partial<DbConfig>> {
try {
const data = await fs.readFile(CONFIG_FILE, "utf-8");
return JSON.parse(data);
} catch {
return {};
}
}
export async function testDatabaseConnection(url: string) {
@@ -46,7 +60,9 @@ export async function saveDatabaseSettings(
googleAiModel?: string,
) {
try {
const config: DbConfig = { url, openRouterKey, openRouterModel, aiProvider, googleAiKey, googleAiModel };
// Merge avec l'existant pour préserver les autres réglages (ex: Qlik)
const existing = await readConfig();
const config: DbConfig = { ...existing, url, openRouterKey, openRouterModel, aiProvider, googleAiKey, googleAiModel };
// S'assurer que le dossier data existe
await fs.mkdir(DATA_DIR, { recursive: true });
@@ -67,6 +83,34 @@ export async function saveDatabaseSettings(
}
}
export async function saveQlikSettings(qlikHost: string, qlikUser: string, qlikPassword: string) {
try {
const existing = await readConfig();
const config = { ...existing, qlikHost, qlikUser, qlikPassword } as DbConfig;
await fs.mkdir(DATA_DIR, { recursive: true });
await fs.writeFile(CONFIG_FILE, JSON.stringify(config, null, 2));
console.log("[Settings] Qlik configuration saved.");
return { success: true };
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : String(error);
console.error("[Settings] Failed to save Qlik config:", msg);
return { success: false, error: msg };
}
}
export async function testQlikConnection(qlikHost: string, qlikUser: string, qlikPassword: string) {
try {
const { getQlikConfig, qlikNtlmSession } = await import("@/lib/qlik-client");
const cfg = { ...getQlikConfig(), user: qlikUser, password: qlikPassword };
if (qlikHost) cfg.host = qlikHost;
await qlikNtlmSession(cfg);
return { success: true };
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : String(error);
return { success: false, error: msg };
}
}
export async function getSavedDatabaseConfig(): Promise<DbConfig | null> {
try {
if (!(await fs.stat(CONFIG_FILE).catch(() => null))) {
+602 -47
View File
@@ -273,26 +273,37 @@ export interface PgNomRow {
* Colonnes confirmées : nomenclature.code, nomenclature.libelle
* Colonne parent : découverte dynamiquement via information_schema
*/
/**
* Découvre (une seule fois par process) le nom de la colonne "parent" de la
* table `nomenclature`, qui porte la FK vers le `no_id` du niveau supérieur.
*
* On ne peut pas la coder en dur : le nom varie selon les installations
* ("chemin_pere" existe mais c'est un chemin *texte* matérialisé, pas la FK),
* d'où le filtre explicite sur `data_type` entier.
*/
async function getNomenclatureParentCol(): Promise<string | null> {
if (_nomenclatureParentCol !== undefined) return _nomenclatureParentCol;
const metaResult = await db.execute(sql`
SELECT column_name, data_type FROM information_schema.columns
WHERE table_name = 'nomenclature' ORDER BY ordinal_position
`);
const nomCols = (metaResult.rows as { column_name: string; data_type: string }[]);
console.log("[pg-ff] Nomenclature colonnes:", nomCols.map(r => `${r.column_name}(${r.data_type})`).join(", "));
// Cherche une colonne "parent" qui soit un entier (FK vers no_id du parent)
// "chemin_pere" = chemin vers le père en français
// On vérifie le data_type : integer/bigint/smallint uniquement (pas text/varchar qui serait un chemin matérialisé)
const parentRow = nomCols.find(r =>
(/parent|pere|father/i.test(r.column_name) || (r.column_name !== "no_id" && /no_id$/i.test(r.column_name)))
&& /int|serial/i.test(r.data_type)
);
_nomenclatureParentCol = parentRow?.column_name ?? null;
console.log("[pg-ff] Nomenclature parentCol:", _nomenclatureParentCol);
return _nomenclatureParentCol;
}
export async function pgGetNomenclatureByFournisseur(codefou: string): Promise<Map<string, PgNomRow>> {
// Découverte de la colonne parent : mise en cache module-level (1 seule fois par process)
if (_nomenclatureParentCol === undefined) {
const metaResult = await db.execute(sql`
SELECT column_name, data_type FROM information_schema.columns
WHERE table_name = 'nomenclature' ORDER BY ordinal_position
`);
const nomCols = (metaResult.rows as { column_name: string; data_type: string }[]);
console.log("[pg-ff] Nomenclature colonnes:", nomCols.map(r => `${r.column_name}(${r.data_type})`).join(", "));
// Cherche une colonne "parent" qui soit un entier (FK vers no_id du parent)
// "chemin_pere" = chemin vers le père en français
// On vérifie le data_type : integer/bigint/smallint uniquement (pas text/varchar qui serait un chemin matérialisé)
const parentRow = nomCols.find(r =>
(/parent|pere|father/i.test(r.column_name) || (r.column_name !== "no_id" && /no_id$/i.test(r.column_name)))
&& /int|serial/i.test(r.data_type)
);
_nomenclatureParentCol = parentRow?.column_name ?? null;
console.log("[pg-ff] Nomenclature parentCol:", _nomenclatureParentCol);
}
const parentCol = _nomenclatureParentCol;
const parentCol = await getNomenclatureParentCol();
let result;
if (parentCol) {
@@ -674,6 +685,10 @@ export interface CaByNomenclatureRow {
/**
* Retourne le CA TTC par fournisseur et site pour deux mois donnés (mois et mois N-1).
* Attribution via artfou1.preference = true (fournisseur principal de l'article).
* CA NET : SUM(-mntmvtttc) — les retours clients sont déduits (même convention
* que pgGetMensuelByFournisseur / pgGetHitParade). Fournisseur préféré résolu
* via une sous-requête artfou1 dédupliquée (DISTINCT ON) : un seul code par
* article, aucune duplication possible des mouvements dans la somme.
*/
export async function pgGetCaByFournisseur(
mois: string,
@@ -685,10 +700,15 @@ export async function pgGetCaByFournisseur(
COALESCE(fi.nom, af.code, 'Sans fournisseur')::text AS nom,
m.site,
TO_CHAR(m.datmvt, 'YYYY-MM') AS mois,
ABS(SUM(m.mntmvtttc))::float AS ca_ttc
SUM(-m.mntmvtttc)::float AS ca_ttc
FROM mvtart m
JOIN articles a ON a.no_id = m.artnoid
LEFT JOIN artfou1 af ON af.art_no_id = a.no_id AND af.preference = 1
LEFT JOIN (
SELECT DISTINCT ON (art_no_id) art_no_id, code
FROM artfou1
WHERE preference = 1
ORDER BY art_no_id, code
) af ON af.art_no_id = a.no_id
LEFT JOIN fouident fi ON fi.code = af.code
WHERE (TO_CHAR(m.datmvt, 'YYYY-MM') = ${mois} OR TO_CHAR(m.datmvt, 'YYYY-MM') = ${moisN1})
AND m.site IN ('292', '579')
@@ -703,6 +723,7 @@ export async function pgGetCaByFournisseur(
/**
* Retourne le CA TTC par nomenclature et site pour deux mois donnés.
* CA NET : SUM(-mntmvtttc) — retours clients déduits (convention commune).
*/
export async function pgGetCaByNomenclature(
mois: string,
@@ -714,7 +735,7 @@ export async function pgGetCaByNomenclature(
COALESCE(n.libelle, 'Sans nomenclature')::text AS libelle,
m.site,
TO_CHAR(m.datmvt, 'YYYY-MM') AS mois,
ABS(SUM(m.mntmvtttc))::float AS ca_ttc
SUM(-m.mntmvtttc)::float AS ca_ttc
FROM mvtart m
JOIN articles a ON a.no_id = m.artnoid
LEFT JOIN nomenclature n ON n.no_id = a.nom_no_id
@@ -750,48 +771,74 @@ export interface HitParadeRow {
/**
* Retourne les ventes produit sur une période, avec fournisseur, qté, CA TTC, marge et stock par site.
* Le stock est intégré via un LEFT JOIN — pas de requête séparée, pas de tableau de paramètres.
*
* L'agrégation des ventes se fait UNIQUEMENT sur mvtart × articles (jointure 1:1),
* exactement comme pgGetMensuelByFournisseur (le calcul canonique de la Grille) :
* qte = SUM(-qtemvt), ca = SUM(-mntmvtttc), marge = SUM(margemvt) pour genremvt = 3
* (ventes stockées en négatif — vérifié sur les mouvements réels de l'API).
* Les attributs (libellé, fournisseur préféré, nomenclature) et le stock sont
* joints APRÈS l'agrégation, par codein : aucune jointure ne peut dupliquer les
* mouvements et fausser les sommes, et le LATERAL fournisseur ne s'exécute que
* par article vendu (et non par ligne de mouvement).
*
* Cas vérifié (codein 487673, site 579, jan→juil 2026) : 31 ventes brutes,
* 7 retours clients → 24 net. L'ancien SUM(ABS(qtemvt)) affichait 38 (retours
* additionnés) ; SUM(-qtemvt) donne 24, identique à l'API mensuel officielle.
*/
export async function pgGetHitParade(dateDebut: string, dateFin: string): Promise<HitParadeRow[]> {
const result = await pgNoParallel(sql`
WITH ventes AS (
SELECT
a.no_id AS art_no_id,
a.codein::text AS codein,
a.libelle1::text AS libelle,
COALESCE(fi.nom, af.code, 'Sans fournisseur')::text AS fournisseur,
COALESCE(n.code, '')::text AS nomenclature_code,
COALESCE(n.libelle, 'Sans nomenclature')::text AS nomenclature,
TRIM(a.codein::text) AS codein,
m.site,
SUM(ABS(m.qtemvt))::float AS qte_vendue,
ABS(SUM(m.mntmvtttc))::float AS ca_ttc,
SUM(-m.qtemvt)::float AS qte_vendue,
SUM(-m.mntmvtttc)::float AS ca_ttc,
SUM(m.margemvt)::float AS marge
FROM mvtart m
JOIN articles a ON a.no_id = m.artnoid
LEFT JOIN artfou1 af ON af.art_no_id = a.no_id AND af.preference = 1
LEFT JOIN fouident fi ON fi.code = af.code
LEFT JOIN nomenclature n ON n.no_id = a.nom_no_id
JOIN articles a ON a.no_id = m.artnoid
WHERE m.datmvt BETWEEN ${dateDebut}::date AND ${dateFin}::date
AND m.site IN ('292', '579')
AND m.genremvt = 3
GROUP BY a.no_id, a.codein, a.libelle1, fi.nom, af.code, n.code, n.libelle, m.site
AND a.codein IS NOT NULL
GROUP BY TRIM(a.codein::text), m.site
),
attrs AS (
SELECT DISTINCT ON (TRIM(a.codein::text))
TRIM(a.codein::text) AS codein,
a.libelle1::text AS libelle,
COALESCE(fi.nom, af.code, 'Sans fournisseur')::text AS fournisseur,
COALESCE(n.code, '')::text AS nomenclature_code,
COALESCE(n.libelle, 'Sans nomenclature')::text AS nomenclature
FROM articles a
LEFT JOIN LATERAL (
SELECT af1.code
FROM artfou1 af1
WHERE af1.art_no_id = a.no_id AND af1.preference = 1
ORDER BY af1.code
LIMIT 1
) af ON TRUE
LEFT JOIN fouident fi ON fi.code = af.code
LEFT JOIN nomenclature n ON n.no_id = a.nom_no_id
WHERE TRIM(a.codein::text) IN (SELECT codein FROM ventes)
ORDER BY TRIM(a.codein::text), a.no_id DESC
),
stock_agg AS (
SELECT
cs.artnoid,
TRIM(a2.codein::text) AS codein,
SUM(CASE WHEN cs.site = '292' THEN cs.qte ELSE 0 END)::float AS stock292,
SUM(CASE WHEN cs.site = '579' THEN cs.qte ELSE 0 END)::float AS stock579,
SUM(cs.qte)::float AS stockTotal
FROM cube_stock cs
WHERE cs.artnoid IN (SELECT DISTINCT art_no_id FROM ventes)
GROUP BY cs.artnoid
JOIN articles a2 ON a2.no_id = cs.artnoid
WHERE TRIM(a2.codein::text) IN (SELECT codein FROM ventes)
GROUP BY TRIM(a2.codein::text)
)
SELECT
v.codein,
v.libelle,
v.fournisseur,
v.nomenclature_code,
v.nomenclature,
COALESCE(ar.libelle, '') AS libelle,
COALESCE(ar.fournisseur, 'Sans fournisseur') AS fournisseur,
COALESCE(ar.nomenclature_code, '') AS nomenclature_code,
COALESCE(ar.nomenclature, 'Sans nomenclature') AS nomenclature,
v.site,
v.qte_vendue,
v.ca_ttc,
@@ -800,7 +847,8 @@ export async function pgGetHitParade(dateDebut: string, dateFin: string): Promis
COALESCE(s.stock579, 0) AS stock579,
COALESCE(s.stockTotal, 0) AS "stockTotal"
FROM ventes v
LEFT JOIN stock_agg s ON s.artnoid = v.art_no_id
LEFT JOIN attrs ar ON ar.codein = v.codein
LEFT JOIN stock_agg s ON s.codein = v.codein
ORDER BY v.ca_ttc DESC
`);
@@ -929,12 +977,17 @@ export async function pgGetStockNegatif(site?: string): Promise<PgStockNegatifRo
) AS derniereentree
FROM cube_stock cs
JOIN articles a ON a.no_id = cs.artnoid
JOIN artfou1 af ON af.art_no_id = a.no_id AND af.preference = 1
JOIN (
SELECT DISTINCT ON (art_no_id) art_no_id, code
FROM artfou1
WHERE preference = 1
ORDER BY art_no_id, code
) af ON af.art_no_id = a.no_id
JOIN fouident f ON f.code = af.code
WHERE cs.qte < 0
AND cs.site IN ('292', '579')
${siteFilter}
ORDER BY cs.stockdispo ASC
ORDER BY cs.qte ASC
`);
return result.rows as PgStockNegatifRow[];
}
@@ -982,7 +1035,12 @@ export async function pgGetSansVente6Mois(site?: string): Promise<PgSansVente6Mo
) AS derniere_entree
FROM cube_stock cs
JOIN articles a ON a.no_id = cs.artnoid
JOIN artfou1 af ON af.art_no_id = a.no_id AND af.preference = 1
JOIN (
SELECT DISTINCT ON (art_no_id) art_no_id, code
FROM artfou1
WHERE preference = 1
ORDER BY art_no_id, code
) af ON af.art_no_id = a.no_id
JOIN fouident f ON f.code = af.code
WHERE cs.site IN ('292', '579')
AND cs.qte > 0
@@ -1179,3 +1237,500 @@ export async function pgGetDerniereReceptionParFournisseur(): Promise<Map<string
return map;
}
}
// ---------------------------------------------------------------------------
// 13. Recherche & fiche produit (page /produits)
// ---------------------------------------------------------------------------
/**
* Une ligne de résultat de recherche produit. Volontairement **légère** :
* aucune agrégation `mvtart` ici (le seq-scan du ILIKE domine déjà le coût).
* Le détail lourd n'est calculé qu'à l'ouverture de la fiche.
*/
export interface PgProduitSearchRow {
no_id: number;
codein: string;
code_centrale: string;
libelle1: string;
/** Nom du fournisseur principal (artfou1.preference = 1), sinon son code. */
fournisseur: string;
codefou: string;
nomenclature_code: string;
nomenclature: string;
stock_total: number;
/** true si le produit a déjà des métriques réseau Qlik en cache. */
has_reseau: boolean;
qte_reseau: number;
nb_magasins_reseau: number;
}
/**
* Recherche un produit par **libellé** (sous-chaîne) ou par **code centrale**
* (préfixe). Si le terme est entièrement numérique, on tente aussi une
* correspondance exacte sur le `codein`.
*
* ⚠️ Il n'existe aucun index texte sur `articles.libelle1` (base FF resynchronisée
* chaque nuit depuis SQL Server — on n'y crée pas d'index). La requête fait donc
* un seq-scan : l'appelant impose un minimum de 3 caractères et une soumission
* explicite, jamais une recherche à la frappe.
*/
export async function pgSearchProduits(term: string, limit = 50): Promise<PgProduitSearchRow[]> {
const cleaned = term.trim();
if (cleaned.length < 3) return [];
const like = `%${cleaned}%`;
const prefix = `${cleaned}%`;
// Un terme purement numérique peut être un codein : on l'ajoute au filtre
// (correspondance exacte, la colonne est space-padded d'où le TRIM).
const codeinClause = /^\d+$/.test(cleaned)
? sql`OR TRIM(a.codein::text) = ${cleaned}`
: sql``;
try {
const result = await pgNoParallel(sql`
WITH raw AS (
SELECT DISTINCT ON (TRIM(a.codein::text))
a.no_id,
TRIM(a.codein::text) AS codein,
COALESCE(TRIM(a.artcentrale::text), '') AS code_centrale,
COALESCE(a.libelle1, '') AS libelle1,
a.nom_no_id
FROM articles a
WHERE a.codein IS NOT NULL
AND (
a.libelle1 ILIKE ${like}
OR TRIM(a.artcentrale::text) ILIKE ${prefix}
${codeinClause}
)
ORDER BY TRIM(a.codein::text), a.no_id DESC
),
matched AS (
SELECT * FROM raw ORDER BY libelle1 LIMIT ${limit}
)
SELECT
m.no_id,
m.codein,
m.code_centrale,
m.libelle1,
COALESCE(fi.nom, af.code, 'Sans fournisseur')::text AS fournisseur,
COALESCE(af.code, '')::text AS codefou,
COALESCE(n.code, '')::text AS nomenclature_code,
COALESCE(n.libelle, '')::text AS nomenclature,
COALESCE(st.stock_total, 0)::float AS stock_total,
(q.code_centrale IS NOT NULL) AS has_reseau,
COALESCE(q.qte_reseau, 0)::float AS qte_reseau,
COALESCE(q.nb_magasins_reseau, 0) AS nb_magasins_reseau
FROM matched m
LEFT JOIN LATERAL (
SELECT af1.code
FROM artfou1 af1
WHERE af1.art_no_id = m.no_id AND af1.preference = 1
ORDER BY af1.code
LIMIT 1
) af ON TRUE
LEFT JOIN fouident fi ON fi.code = af.code
LEFT JOIN nomenclature n ON n.no_id = m.nom_no_id
LEFT JOIN LATERAL (
SELECT SUM(cs.qte)::float AS stock_total
FROM cube_stock cs
WHERE cs.artnoid = m.no_id AND cs.site IN ('292', '579')
) st ON TRUE
LEFT JOIN qlik_network_metrics q ON q.code_centrale = m.code_centrale
ORDER BY m.libelle1
`);
console.log(`[pg-ff] pgSearchProduits("${cleaned}"): ${result.rows.length} résultats`);
return result.rows as unknown as PgProduitSearchRow[];
} catch (e) {
console.error("[pg-ff] pgSearchProduits error:", (e as Error).message?.slice(0, 250));
return [];
}
}
/** Identité, prix et nomenclature d'un produit (une seule ligne). */
export interface PgProduitDetailRow {
no_id: number;
codein: string;
code_centrale: string;
libelle1: string;
nom_no_id: number | null;
gtin: string;
reference: string;
pcb: number | null;
/** article_infosup.prix_vente_mini */
pv_central: number | null;
/** cube_pa.pa */
pa: number | null;
code3: string | null;
libelle3: string | null;
code2: string | null;
libelle2: string | null;
code1: string | null;
libelle1nom: string | null;
}
/**
* Fiche d'identité complète d'un produit à partir de son `codein`.
* Renvoie `null` si le codein est inconnu.
*/
export async function pgGetProduitDetail(codein: string): Promise<PgProduitDetailRow | null> {
const parentCol = await getNomenclatureParentCol();
// Hiérarchie nomenclature : seulement si la colonne parent a été trouvée,
// sinon on se limite au niveau feuille (même dégradation que pgGetNomenclatureByFournisseur).
const hierarchy = parentCol
? sql`
LEFT JOIN nomenclature n2 ON n2.no_id = n3.${sql.raw(parentCol)}
LEFT JOIN nomenclature n1 ON n1.no_id = n2.${sql.raw(parentCol)}
`
: sql``;
const hierarchyCols = parentCol
? sql`,
n2.code AS code2,
n2.libelle AS libelle2,
n1.code AS code1,
n1.libelle AS libelle1nom`
: sql`,
NULL::text AS code2,
NULL::text AS libelle2,
NULL::text AS code1,
NULL::text AS libelle1nom`;
try {
const result = await pgNoParallel(sql`
SELECT
a.no_id,
TRIM(a.codein::text) AS codein,
COALESCE(TRIM(a.artcentrale::text), '') AS code_centrale,
COALESCE(a.libelle1, '') AS libelle1,
a.nom_no_id,
COALESCE(ag.gtin, af.ean13, '')::text AS gtin,
COALESCE(af.reference, '')::text AS reference,
af.pcb::float AS pcb,
ai.prix_vente_mini::float AS pv_central,
pa.pa::float AS pa,
n3.code AS code3,
n3.libelle AS libelle3
${hierarchyCols}
FROM articles a
LEFT JOIN LATERAL (
SELECT af1.code, af1.pcb, af1.reference, af1.ean13
FROM artfou1 af1
WHERE af1.art_no_id = a.no_id
ORDER BY af1.preference DESC NULLS LAST, af1.no_id DESC
LIMIT 1
) af ON TRUE
LEFT JOIN art_gtin ag ON ag.idarticle = a.no_id AND ag.preferentiel = 1
LEFT JOIN article_infosup ai ON ai.artnoid = a.no_id
LEFT JOIN cube_pa pa ON pa.artnoid = a.no_id
LEFT JOIN nomenclature n3 ON n3.no_id = a.nom_no_id
${hierarchy}
WHERE TRIM(a.codein::text) = ${codein}
ORDER BY a.no_id DESC
LIMIT 1
`);
const row = (result.rows as unknown as PgProduitDetailRow[])[0] ?? null;
console.log(`[pg-ff] pgGetProduitDetail(${codein}): ${row ? "trouvé" : "introuvable"}`);
return row;
} catch (e) {
console.error("[pg-ff] pgGetProduitDetail error:", (e as Error).message?.slice(0, 250));
return null;
}
}
/** Un fournisseur référencé pour l'article. */
export interface PgProduitFournisseurRow {
codefou: string;
nomfou: string;
reference: string;
pcb: number | null;
/** true si artfou1.preference = 1 */
principal: boolean;
}
/** Tous les fournisseurs référencés pour un article (le principal en premier). */
export async function pgGetFournisseursByCodein(codein: string): Promise<PgProduitFournisseurRow[]> {
try {
const result = await pgNoParallel(sql`
SELECT DISTINCT ON (af.code)
af.code::text AS codefou,
COALESCE(fi.nom, af.code)::text AS nomfou,
COALESCE(af.reference, '')::text AS reference,
af.pcb::float AS pcb,
(af.preference = 1) AS principal
FROM articles a
JOIN artfou1 af ON af.art_no_id = a.no_id
LEFT JOIN fouident fi ON fi.code = af.code
WHERE TRIM(a.codein::text) = ${codein}
ORDER BY af.code, af.preference DESC NULLS LAST, af.no_id DESC
`);
const rows = result.rows as unknown as PgProduitFournisseurRow[];
// Le principal remonte en tête (le DISTINCT ON impose un tri par code).
rows.sort((x, y) => Number(y.principal) - Number(x.principal) || x.nomfou.localeCompare(y.nomfou, "fr"));
return rows;
} catch (e) {
console.error("[pg-ff] pgGetFournisseursByCodein error:", (e as Error).message?.slice(0, 250));
return [];
}
}
/** Stock temps réel par site pour un article. */
export interface PgProduitStockRow {
site: string;
stockdispo: number;
qte: number;
valstock: number;
prmp: number;
dernierevente: string | null;
dernierereception: string | null;
}
export async function pgGetStockByCodein(codein: string): Promise<PgProduitStockRow[]> {
try {
const result = await pgNoParallel(sql`
SELECT
cs.site,
COALESCE(cs.stockdispo, 0)::float AS stockdispo,
COALESCE(cs.qte, 0)::float AS qte,
COALESCE(cs.valstock, 0)::float AS valstock,
COALESCE(cs.prmp, 0)::float AS prmp,
cs.dernierevente::text AS dernierevente,
cs.dernierereception::text AS dernierereception
FROM cube_stock cs
JOIN articles a ON a.no_id = cs.artnoid
WHERE TRIM(a.codein::text) = ${codein}
AND cs.site IN ('292', '579')
ORDER BY cs.site
`);
return result.rows as unknown as PgProduitStockRow[];
} catch (e) {
console.error("[pg-ff] pgGetStockByCodein error:", (e as Error).message?.slice(0, 250));
return [];
}
}
/** Quantité totale en commande fournisseur pour un article. */
export async function pgGetCommandesByCodein(codein: string): Promise<number> {
try {
const result = await pgNoParallel(sql`
SELECT COALESCE(SUM(cv.cdefou_ligne_qtecde), 0)::float AS qtecde
FROM cdefou_vivant cv
WHERE TRIM(cv.articles_codein::text) = ${codein}
`);
const row = (result.rows as unknown as { qtecde: number }[])[0];
return Number(row?.qtecde) || 0;
} catch (e) {
console.error("[pg-ff] pgGetCommandesByCodein error:", (e as Error).message?.slice(0, 250));
return 0;
}
}
/** Une gamme affectée à l'article pour une saison donnée. */
export interface PgGammeHistoryRow {
saison_no_id: number;
saison_code: string | null;
saison_libelle: string | null;
gamme_code: string;
gamme_libelle: string | null;
}
/**
* Historique complet des gammes de l'article, saison par saison (la plus
* récente en premier). Le reste de l'application ne lit que la saison active
* (`pgGetGammesByFournisseur`) ; ici on veut la trajectoire d'arbitrage.
*/
export async function pgGetGammeHistoryByCodein(codein: string): Promise<PgGammeHistoryRow[]> {
try {
const result = await pgNoParallel(sql`
SELECT
s.no_id AS saison_no_id,
s.code::text AS saison_code,
s.libelle::text AS saison_libelle,
g.code::text AS gamme_code,
g.libelle::text AS gamme_libelle
FROM art_gamme_saison ags
JOIN articles a ON a.no_id = ags.artnoid
JOIN gammes g ON g.no_id = ags.idgamme
JOIN saisons s ON s.no_id = ags.idsaison
WHERE TRIM(a.codein::text) = ${codein}
ORDER BY s.no_id DESC
`);
return result.rows as unknown as PgGammeHistoryRow[];
} catch (e) {
console.error("[pg-ff] pgGetGammeHistoryByCodein error:", (e as Error).message?.slice(0, 250));
return [];
}
}
/**
* Données mensuelles (ventes / CA / marge / stock fin de mois / réceptions)
* d'UN article, par site et par mois.
*
* Reprend **exactement** le calcul canonique de `pgGetMensuelByFournisseur`
* (la source de vérité de la Grille) : seule la sélection change — filtre sur
* le codein au lieu d'une jointure `artfou1`. Les chiffres affichés sur la
* fiche produit sont donc identiques à ceux de la Grille, par construction.
*/
export async function pgGetMensuelByCodein(
codein: string,
dateDebut: string,
dateFin: string
): Promise<PgMensuelRow[]> {
try {
const result = await pgNoParallel(sql`
WITH base AS (
SELECT
TRIM(a.codein::text) AS codein,
m.site,
TO_CHAR(m.datmvt, 'YYYY-MM') AS mois,
m.qtemvt,
m.mntmvtttc,
m.margemvt,
m.genremvt,
m.qtestock,
ROW_NUMBER() OVER (
PARTITION BY m.site, TO_CHAR(m.datmvt, 'YYYY-MM')
ORDER BY m.datmvt DESC
) AS rn_last
FROM mvtart m
JOIN articles a ON a.no_id = m.artnoid
WHERE TRIM(a.codein::text) = ${codein}
AND m.datmvt BETWEEN ${dateDebut}::date AND ${dateFin}::date
AND m.site IN ('292', '579')
)
SELECT
codein,
site,
mois,
SUM(CASE WHEN genremvt = 3 THEN -qtemvt ELSE 0 END)::float AS qte_vendue,
SUM(CASE WHEN genremvt = 3 THEN -mntmvtttc ELSE 0 END)::float AS ca_ht,
SUM(CASE WHEN genremvt = 3 THEN margemvt ELSE 0 END)::float AS marge,
MAX(CASE WHEN rn_last = 1 THEN qtestock ELSE NULL END)::float AS stock_fin_mois,
SUM(CASE WHEN genremvt IN (1, 2) THEN qtemvt ELSE 0 END)::float AS qte_recue
FROM base
GROUP BY codein, site, mois
ORDER BY site, mois
`);
console.log(`[pg-ff] pgGetMensuelByCodein(${codein}): ${result.rows.length} lignes`);
return result.rows as unknown as PgMensuelRow[];
} catch (e) {
console.error("[pg-ff] pgGetMensuelByCodein error:", (e as Error).message?.slice(0, 250));
return [];
}
}
/** Un produit de la même famille, comparé local vs réseau. */
export interface PgOpportuniteRow {
codein: string;
libelle1: string;
code_centrale: string;
fournisseur: string;
qte_reseau: number;
nb_magasins_reseau: number;
ca_reseau: number;
ca_par_magasin_reseau: number;
qte_locale: number;
nb_sites_locaux: number;
qte_reseau_par_magasin: number;
qte_locale_par_magasin: number;
/** Écart réseau − local, par magasin, sur 12 mois. Positif = opportunité. */
ecart_par_magasin: number;
}
/**
* Produits de la même nomenclature, classés par écart entre la performance
* réseau et la performance locale (quantités **par magasin**, pour neutraliser
* la différence d'échelle entre ~270 magasins et nos 2 sites).
*
* Périmètre volontairement borné aux articles **de notre catalogue** ayant déjà
* des métriques réseau en cache : la dimension Qlik interrogée est
* `Article Code`, on ne peut pas découvrir un produit que Nancy ne référence pas.
*/
export async function pgGetOpportunitesFamille(
nomNoId: number,
dateDebut: string,
dateFin: string,
limit = 30
): Promise<PgOpportuniteRow[]> {
try {
const result = await pgNoParallel(sql`
WITH famille AS (
SELECT DISTINCT ON (TRIM(a.codein::text))
a.no_id,
TRIM(a.codein::text) AS codein,
TRIM(a.artcentrale::text) AS code_centrale,
COALESCE(a.libelle1, '') AS libelle1
FROM articles a
WHERE a.nom_no_id = ${nomNoId}
AND a.codein IS NOT NULL
AND a.artcentrale IS NOT NULL
AND TRIM(a.artcentrale::text) <> ''
ORDER BY TRIM(a.codein::text), a.no_id DESC
),
avec_reseau AS (
SELECT
f.no_id,
f.codein,
f.code_centrale,
f.libelle1,
q.qte_reseau::float AS qte_reseau,
q.nb_magasins_reseau AS nb_magasins_reseau,
COALESCE(q.ca_reseau, 0)::float AS ca_reseau,
COALESCE(q.ca_par_magasin_reseau, 0)::float AS ca_par_magasin_reseau
FROM famille f
JOIN qlik_network_metrics q ON q.code_centrale = f.code_centrale
WHERE q.nb_magasins_reseau > 0
AND q.qte_reseau > 0
),
ventes AS (
SELECT
TRIM(a.codein::text) AS codein,
SUM(-m.qtemvt)::float AS qte_locale,
COUNT(DISTINCT m.site) AS nb_sites_locaux
FROM mvtart m
JOIN articles a ON a.no_id = m.artnoid
WHERE m.genremvt = 3
AND m.site IN ('292', '579')
AND m.datmvt BETWEEN ${dateDebut}::date AND ${dateFin}::date
AND TRIM(a.codein::text) IN (SELECT codein FROM avec_reseau)
GROUP BY TRIM(a.codein::text)
)
SELECT
r.codein,
r.libelle1,
r.code_centrale,
COALESCE(fi.nom, af.code, 'Sans fournisseur')::text AS fournisseur,
r.qte_reseau,
r.nb_magasins_reseau,
r.ca_reseau,
r.ca_par_magasin_reseau,
COALESCE(v.qte_locale, 0)::float AS qte_locale,
COALESCE(v.nb_sites_locaux, 0) AS nb_sites_locaux,
(r.qte_reseau / r.nb_magasins_reseau)::float AS qte_reseau_par_magasin,
(COALESCE(v.qte_locale, 0) / GREATEST(COALESCE(v.nb_sites_locaux, 0), 1))::float AS qte_locale_par_magasin,
(
(r.qte_reseau / r.nb_magasins_reseau)
- (COALESCE(v.qte_locale, 0) / GREATEST(COALESCE(v.nb_sites_locaux, 0), 1))
)::float AS ecart_par_magasin
FROM avec_reseau r
LEFT JOIN ventes v ON v.codein = r.codein
LEFT JOIN LATERAL (
SELECT af1.code
FROM artfou1 af1
WHERE af1.art_no_id = r.no_id AND af1.preference = 1
ORDER BY af1.code
LIMIT 1
) af ON TRUE
LEFT JOIN fouident fi ON fi.code = af.code
ORDER BY ecart_par_magasin DESC
LIMIT ${limit}
`);
console.log(`[pg-ff] pgGetOpportunitesFamille(nom=${nomNoId}): ${result.rows.length} lignes`);
return result.rows as unknown as PgOpportuniteRow[];
} catch (e) {
console.error("[pg-ff] pgGetOpportunitesFamille error:", (e as Error).message?.slice(0, 250));
return [];
}
}
+116 -12
View File
@@ -19,6 +19,20 @@
import httpntlm from "httpntlm";
import { WebSocket } from "ws";
import { randomBytes } from "node:crypto";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { buildGridNetworkQlikDateFilter, chunkSerials, type QlikDateFilter } from "@/lib/qlik-date-range";
/** Lit les réglages Qlik depuis data/.db-config.json (configurés dans Paramètres). */
function readQlikFileConfig(): { qlikHost?: string; qlikUser?: string; qlikPassword?: string } {
try {
const raw = readFileSync(join(process.cwd(), "data", ".db-config.json"), "utf-8");
const c = JSON.parse(raw);
return { qlikHost: c.qlikHost, qlikUser: c.qlikUser, qlikPassword: c.qlikPassword };
} catch {
return {};
}
}
// ---------------------------------------------------------------------------
// Config (env) — defauts issus de la discovery
@@ -35,22 +49,31 @@ export interface QlikConfig {
measCaId: string; // master measure "CA N"
measQteId: string; // master measure "Quantite N"
measNbMagId: string; // master measure "Magasin Ventes Nb N"
measCaMagId: string; // master measure "CA par Magasin N"
measMargePctId: string; // master measure "Marge % N"
tlsInsecure: boolean;
timeoutMs: number;
}
export function getQlikConfig(): QlikConfig {
// Priorité : réglages UI (data/.db-config.json) > env > défaut
const f = readQlikFileConfig();
return {
host: process.env.QLIK_HOST ?? "reporting-magasins.lafoirfouille.fr",
user: process.env.QLIK_USER ?? "FFSCH",
password: process.env.QLIK_PWD ?? "",
host: f.qlikHost || process.env.QLIK_HOST || "reporting-magasins.lafoirfouille.fr",
user: f.qlikUser || process.env.QLIK_USER || "FFSCH",
password: f.qlikPassword || process.env.QLIK_PWD || "",
domain: process.env.QLIK_DOMAIN ?? "",
workstation: process.env.QLIK_WORKSTATION ?? "",
appNetwork: process.env.QLIK_APP_NETWORK ?? "9872ee6e-d64a-4b43-984a-076bf1f7f647",
dimCodeArticleId: process.env.QLIK_DIM_CODE_ARTICLE_ID ?? "fcd239e5-288b-4830-a047-0e3d7665d971",
measCaId: process.env.QLIK_MEAS_CA_ID ?? "43a76088-86fa-402e-a80e-0efd7701b3e1",
measQteId: process.env.QLIK_MEAS_QTE_ID ?? "7b40caf1-be4b-4811-8d45-50acde33e715",
measNbMagId: process.env.QLIK_MEAS_NBMAG_ID ?? "8b63fae5-db2f-4e4c-8618-f3e9d60b6b3b",
// qIds engine (master items de l'app "Magasins Vision Consolidée")
dimCodeArticleId: process.env.QLIK_DIM_CODE_ARTICLE_ID ?? "yesCP",
measCaId: process.env.QLIK_MEAS_CA_ID ?? "JhqJ",
measQteId: process.env.QLIK_MEAS_QTE_ID ?? "41516861-5997-4635-8187-3643a2bde422",
measNbMagId: process.env.QLIK_MEAS_NBMAG_ID ?? "yNBLjc",
// Master GUIDs de l'app "Magasins Vision Consolidée" (9872ee6e). Si l'engine
// les refuse (qId court attendu), override via env après discovery.
measCaMagId: process.env.QLIK_MEAS_CAMAG_ID ?? "16778c00-a4e0-41c7-ab1c-179810d78172",
measMargePctId: process.env.QLIK_MEAS_MARGEPCT_ID ?? "ea1e8feb-49ba-4eb3-8db6-72dcfb4c1222",
tlsInsecure: (process.env.QLIK_TLS_INSECURE ?? "true") === "true",
timeoutMs: Number(process.env.QLIK_TIMEOUT_MS ?? "60000"),
};
@@ -61,7 +84,37 @@ export interface NetworkMetric {
caReseau: number;
qteReseau: number;
nbMagasinsReseau: number;
/** CA moyen par magasin (productivité normalisée présence) */
caParMagasinReseau: number;
/** Taux de marge réseau (ratio brut Qlik, ex 0.32 = 32%) */
margePctReseau: number;
periode?: string;
/** Quantité vendue réseau par mois : { "YYYY-MM": qté }. Rempli si sync avec filtre Date. */
qteByMonth?: Record<string, number>;
/**
* Détail mensuel complet : { "YYYY-MM": { qte, ca, nbMag, caMag, margePct } }.
*
* Le cube mensuel Qlik renvoie déjà ces 5 mesures par (Article Code, Mois) —
* les conserver ne coûte aucune requête supplémentaire. Seule `qte` est
* garantie : les mois N-1 dérivés de « Quantité COMP » n'ont que la quantité
* tant que la passe complémentaire N-1 ne les a pas complétés (elle est
* sautée si le champ Qlik `Année` est absent).
*/
metricsByMonth?: Record<string, QlikMonthMetrics>;
}
/** Les mesures réseau Qlik pour un produit sur un mois donné. */
export interface QlikMonthMetrics {
/** Quantité vendue réseau — toujours présente. */
qte: number;
/** CA réseau du mois. */
ca?: number;
/** Nombre de magasins ayant vendu le produit ce mois-là. */
nbMag?: number;
/** CA moyen par magasin sur le mois (mesure Qlik brute). */
caMag?: number;
/** Taux de marge réseau du mois (ratio brut Qlik, ex 0.32 = 32%). */
margePct?: number;
}
function makeXrfkey(): string {
@@ -153,7 +206,13 @@ function openEngine(appGuid: string, sess: QlikSession, cfg: QlikConfig): Promis
const url = `wss://${cfg.host}/app/${encodeURIComponent(appGuid)}?Xrfkey=${sess.xrfkey}`;
return new Promise((resolve, reject) => {
const ws = new WebSocket(url, {
headers: { Cookie: sess.cookie, "X-Qlik-Xrfkey": sess.xrfkey },
headers: {
Cookie: sess.cookie,
"X-Qlik-Xrfkey": sess.xrfkey,
// Le proxy Qlik valide l'Origin des websockets (whitelist) — un navigateur l'envoie toujours.
Origin: `https://${cfg.host}`,
"User-Agent": "Mozilla/5.0",
},
rejectUnauthorized: !cfg.tlsInsecure,
handshakeTimeout: cfg.timeoutMs,
});
@@ -173,7 +232,14 @@ function openEngine(appGuid: string, sess: QlikSession, cfg: QlikConfig): Promis
else p.resolve(msg.result as Record<string, unknown>);
}
});
ws.on("unexpected-response", (_req, res) => { clearTimeout(timer); reject(new Error(`[qlik] ws HTTP ${res.statusCode}`)); });
ws.on("unexpected-response", (_req, res) => {
clearTimeout(timer);
let body = "";
res.on("data", (d: Buffer) => (body += d.toString()));
res.on("end", () => reject(new Error(
`[qlik] ws HTTP ${res.statusCode} server=${res.headers["server"]} wwwauth=${res.headers["www-authenticate"]} body=${body.slice(0, 160)}`,
)));
});
ws.on("error", (e: Error) => { clearTimeout(timer); reject(e); });
ws.on("close", () => { for (const p of pending.values()) p.reject(new Error("[qlik] ws ferme")); pending.clear(); });
@@ -200,8 +266,10 @@ function hyperCubeDef(cfg: QlikConfig, height: number) {
{ qLibraryId: cfg.measCaId },
{ qLibraryId: cfg.measQteId },
{ qLibraryId: cfg.measNbMagId },
{ qLibraryId: cfg.measCaMagId },
{ qLibraryId: cfg.measMargePctId },
],
qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 4, qHeight: height }],
qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 6, qHeight: height }],
qSuppressZero: false,
qSuppressMissing: true,
},
@@ -213,6 +281,7 @@ type Matrix = Array<Array<{ qText?: string; qNum?: number }>>;
export async function fetchNetworkMetrics(
codeCentraux?: string[],
cfg = getQlikConfig(),
dateFilter: QlikDateFilter | null = buildGridNetworkQlikDateFilter(),
): Promise<Map<string, NetworkMetric>> {
if (!cfg.appNetwork) throw new Error("[qlik] QLIK_APP_NETWORK manquant");
const wanted = codeCentraux ? new Set(codeCentraux) : null;
@@ -224,7 +293,39 @@ export async function fetchNetworkMetrics(
const openRes = await conn.rpc("OpenDoc", { qDocName: cfg.appNetwork });
const docHandle = (((openRes.qReturn as Record<string, unknown>) ?? {}).qHandle as number) ?? 1;
const pageHeight = 2500; // 4 colonnes * 2500 = 10000 cellules max
// Sélection du champ `Date` sur la fenêtre 12 mois complets glissants
// (alignement avec la timeline de la grille). Équivalent à un set
// analysis `{<Date={">=…<=…"}>}` appliqué aux master measures.
let dateFieldHandle: number | null = null;
if (dateFilter) {
const gf = await conn.rpc("GetField", { qFieldName: "Date" }, docHandle);
dateFieldHandle = (((gf.qReturn as Record<string, unknown>) ?? {}).qHandle as number);
if (typeof dateFieldHandle !== "number") {
console.warn(`[qlik] champ Date introuvable, extraction sans filtre date`);
dateFilter = null;
dateFieldHandle = null;
} else {
const batches = chunkSerials(dateFilter.dailySerials, 500);
for (let i = 0; i < batches.length; i++) {
const batch = batches[i];
const sel = await conn.rpc(
"SelectValues",
{
qFieldValues: batch.map((s) => ({ qNum: s, qText: String(s) })),
qToggleMode: false,
qSoftLock: true,
},
dateFieldHandle,
);
console.log(`[qlik] date filter lot ${i + 1}/${batches.length} (${batch.length} serials) — ${dateFilter.label}`);
// Si SelectValues renvoie une erreur qError, on continue quand même :
// l'absence de champ Date typé numérique lèverait une erreur Engine explicite.
void sel;
}
}
}
const pageHeight = 1400; // 6 colonnes * 1400 = 8400 cellules max
const createRes = await conn.rpc("CreateSessionObject", { qProp: hyperCubeDef(cfg, pageHeight) }, docHandle);
const objHandle = (((createRes.qReturn as Record<string, unknown>) ?? {}).qHandle as number);
@@ -232,7 +333,7 @@ export async function fetchNetworkMetrics(
for (;;) {
const dataRes = await conn.rpc(
"GetHyperCubeData",
{ qPath: "/qHyperCubeDef", qPages: [{ qTop: top, qLeft: 0, qWidth: 4, qHeight: pageHeight }] },
{ qPath: "/qHyperCubeDef", qPages: [{ qTop: top, qLeft: 0, qWidth: 6, qHeight: pageHeight }] },
objHandle,
);
const pages = (dataRes.qDataPages as Array<{ qMatrix?: Matrix }>) ?? [];
@@ -248,6 +349,9 @@ export async function fetchNetworkMetrics(
caReseau: Number(row[1]?.qNum ?? 0) || 0,
qteReseau: Number(row[2]?.qNum ?? 0) || 0,
nbMagasinsReseau: Number(row[3]?.qNum ?? 0) || 0,
caParMagasinReseau: Number(row[4]?.qNum ?? 0) || 0,
margePctReseau: Number(row[5]?.qNum ?? 0) || 0,
periode: dateFilter?.label,
});
}
if (matrix.length < pageHeight) break;
+274
View File
@@ -0,0 +1,274 @@
/**
* CollectFlow — Helper de fenêtre temporelle pour Qlik réseau.
*
* Aligne la timeline d'extraction Qlik réseau sur la timeline de la grille :
* - `monthsBack` mois complets glissants (défaut : 12)
* - exclut systématiquement le mois courant
*
* Référence utilisée : `buildLast12MonthsRange()` de `api-ff-client.ts`
* (même source que la grille). On duplique la logique ici (sans dépendance
* Next.js) pour rester testable en CLI pur Node.
*
* Qlik référence les dates par un sérial numérique (jours écoulés depuis
* l'epoch Qlik 1899-12-30). Repères validés :
* 2025-01-01 → 45658
* 2025-06-19 → 45827
* 2026-01-01 → 46023
* 2026-06-19 → 46192
*
* Le filtre côté Qlik s'applique via une sélection de champ `Date` (skill qlik) :
* - valeurs numériques = serials Qlik
* - la sélection par défaut (`{<Date={...}>}`) est héritée par toutes
* les master measures qui référencent le champ `Date` (cas de l'app
* "Magasins Vision Consolidée").
*
* Pourquoi `SelectValues` sur le champ plutôt que set analysis dans `qDef` ?
* - les master items sont référencés par `qLibraryId` (réutilisation sans
* devoir dupliquer l'expression)
* - `qLibraryId` + `qDef` ensemble n'est pas supporté par le moteur QIX
* - sélectionner toutes les dates de la fenêtre via `SelectValues` revient
* fonctionnellement à `{<Date={">=…<=…"}>}` pour les mesures qui touchent
* ce champ, sans dépendre de l'expression interne des master items
*
* Pas de secrets ici. Pure fonction utilitaire testable en CLI Node.
*/
/**
* Bornes acceptées pour `monthsBack`. On reste sur 1..12 pour ne pas casser
* la sémantique "12 mois glissants alignés sur la grille" : une valeur plus
* grande n'apporterait pas de signal réseau supplémentaire (la grille
* n'expose elle-même que ~12 mois). En-deçà de 1 mois, la fenêtre serait
* vide (mois courant exclu) ou incohérente.
*/
export const QLIK_MONTHS_BACK_MIN = 1;
export const QLIK_MONTHS_BACK_MAX = 12;
export const QLIK_MONTHS_BACK_DEFAULT = 12;
/**
* Lit `process.env[name]` et renvoie un entier borné entre `min` et `max`.
* Renvoie `defaultValue` si la variable est absente / vide / non numérique.
*
* Volontairement exportée pour être réutilisée par `qlik-playwright.ts` et
* `sync/route.ts` (cohérence avec `envNumber` côté Playwright, mais en pur
* Node sans dépendance Next.js).
*/
export function envMonthsBack(name: string, defaultValue: number = QLIK_MONTHS_BACK_DEFAULT): number {
const raw = process.env[name];
if (raw == null || raw.trim() === "") return defaultValue;
const parsed = Number(raw);
if (!Number.isFinite(parsed)) return defaultValue;
const clamped = Math.min(QLIK_MONTHS_BACK_MAX, Math.max(QLIK_MONTHS_BACK_MIN, Math.trunc(parsed)));
return clamped;
}
/** Epoch Qlik = 1899-12-30 (équivalent Excel/Lotus 1-2-3). */
const QLIK_EPOCH_MS = Date.UTC(1899, 11, 30); // 1899-12-30T00:00:00Z
/** Repères de validation (cf. skill qlik). */
export const QLIK_DATE_ANCHORS: ReadonlyArray<{ iso: string; serial: number }> = [
{ iso: "2025-01-01", serial: 45658 },
{ iso: "2025-06-19", serial: 45827 },
{ iso: "2026-01-01", serial: 46023 },
{ iso: "2026-06-19", serial: 46192 },
];
/**
* Convertit une date ISO `YYYY-MM-DD` en sérial Qlik numérique.
* Inverse documenté : Qlik epoch = 1899-12-30 (jour 0).
*
* @throws si l'entrée n'est pas au format `YYYY-MM-DD`.
*/
export function isoDateToQlikSerial(isoDate: string): number {
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(isoDate);
if (!m) throw new Error(`isoDateToQlikSerial: format attendu YYYY-MM-DD, reçu "${isoDate}"`);
const y = Number(m[1]);
const mo = Number(m[2]);
const d = Number(m[3]);
const ms = Date.UTC(y, mo - 1, d);
const serial = Math.round((ms - QLIK_EPOCH_MS) / 86_400_000);
return serial;
}
/**
* Convertit un sérial Qlik en date ISO `YYYY-MM-DD` (utile pour tests/diagnostics).
*/
export function qlikSerialToIsoDate(serial: number): string {
const ms = QLIK_EPOCH_MS + serial * 86_400_000;
const d = new Date(ms);
const y = d.getUTCFullYear();
const mo = String(d.getUTCMonth() + 1).padStart(2, "0");
const da = String(d.getUTCDate()).padStart(2, "0");
return `${y}-${mo}-${da}`;
}
export interface QlikDateFilter {
/** Date ISO du 1er jour de la fenêtre (inclus). */
dateDebut: string;
/** Date ISO du dernier jour de la fenêtre (inclus). */
dateFin: string;
/** Sérial Qlik du 1er jour (= `Date >= …`). */
qStart: number;
/** Sérial Qlik du dernier jour (= `Date <= …`). */
qEnd: number;
/** Libellé humain `YYYY-MM_YYYY-MM` pour logs / cache. */
label: string;
/** Liste exhaustive des serials journaliers `[qStart..qEnd]` pour SelectValues. */
dailySerials: number[];
/** Expression set analysis prête à intégrer dans un `qDef` de mesure. */
setAnalysis: string;
}
/**
* Calcule la fenêtre `monthsBack` mois complets (excluant mois courant).
* Par défaut, utilise `now = new Date()` et `monthsBack = 12`.
*
* Logique alignée sur `buildLast12MonthsRange()` de `src/lib/api-ff-client.ts`
* (défaut = 12 mois). Si la source officielle change, mettre à jour ici aussi
* (garder en sync).
*
* Bornes : `monthsBack` est clampé dans `[QLIK_MONTHS_BACK_MIN, QLIK_MONTHS_BACK_MAX]`
* (1..12). Une valeur invalide (NaN, ≤ 0, > 12) est ramenée aux bornes sans
* exception — on veut que la sync reste possible même si l'env est mal
* configuré.
*/
export function buildGridNetworkQlikDateFilter(
now: Date = new Date(),
monthsBack: number = QLIK_MONTHS_BACK_DEFAULT,
): QlikDateFilter {
const months = Math.min(
QLIK_MONTHS_BACK_MAX,
Math.max(QLIK_MONTHS_BACK_MIN, Math.trunc(Number.isFinite(monthsBack) ? monthsBack : QLIK_MONTHS_BACK_DEFAULT)),
);
// Fin : dernier jour du mois précédent le mois courant
const endMonth = new Date(now.getFullYear(), now.getMonth(), 0);
// Début : `months` mois avant le mois courant (1er du mois)
const startMonth = new Date(now.getFullYear(), now.getMonth() - months, 1);
const dateDebut = startMonth.toISOString().slice(0, 10);
const dateFin = endMonth.toISOString().slice(0, 10);
const qStart = isoDateToQlikSerial(dateDebut);
const qEnd = isoDateToQlikSerial(dateFin);
if (qEnd < qStart) {
throw new Error(
`buildGridNetworkQlikDateFilter: fenêtre inversée qEnd=${qEnd} < qStart=${qStart} (dateDebut=${dateDebut} dateFin=${dateFin})`,
);
}
const dailySerials: number[] = [];
for (let s = qStart; s <= qEnd; s++) dailySerials.push(s);
const label = `${dateDebut.slice(0, 7)}_${dateFin.slice(0, 7)}`;
const setAnalysis = `Date={">=${qStart}<=${qEnd}"}`;
return { dateDebut, dateFin, qStart, qEnd, label, dailySerials, setAnalysis };
}
/**
* Découpe les serials en lots pour appels `SelectValues` paginés.
* Par défaut 500 valeurs/appel (largement < limite Qlik ~10k par requête).
*/
export function chunkSerials(serials: number[], batchSize = 500): number[][] {
const out: number[][] = [];
for (let i = 0; i < serials.length; i += batchSize) {
out.push(serials.slice(i, i + batchSize));
}
return out;
}
/**
* Découpe une fenêtre temporelle en sous-fenêtres mensuelles calendaires
* **inclusives et continues**.
*
* But : éviter qu'un seul hypercube Qlik ne doive charger 365 jours × N
* codes d'un coup (provoque `code:15 Request aborted`). Avec cette découpe,
* chaque mois ≈ 28–31 jours × N codes → beaucoup plus léger pour l'Engine.
*
* Règles :
* - Le 1er mois commence à `filter.dateDebut` (peut être un jour quelconque,
* pas forcément le 1er calendaire).
* - Chaque mois intermédiaire est un mois calendaire **complet** (du 1er
* au dernier jour du mois).
* - Le dernier mois se termine à `filter.dateFin` (tronqué si pas fin de mois).
* - Les mois sont contigus : fin d'un mois + 1 = début du suivant.
* - Si `filter.dateDebut` et `filter.dateFin` tombent dans le même mois,
* on renvoie un seul mois (identique à `filter` mais re-calculé).
*
* Chaque mois retourné est un `QlikDateFilter` autonome : `qStart`/`qEnd`,
* `dailySerials` recomptés, `setAnalysis` et `label` au format `YYYY-MM`.
*
* Pré-condition : `dateDebut <= dateFin` au sens calendaire (pas vérifié ici,
* la validation est faite par `buildGridNetworkQlikDateFilter`).
*/
export function getMonthRanges(filter: QlikDateFilter): QlikDateFilter[] {
const start = filter.dateDebut; // YYYY-MM-DD
const end = filter.dateFin; // YYYY-MM-DD
// [yyyy, mm, dd] depuis YYYY-MM-DD (déjà validé par isoDateToQlikSerial).
const sy = Number(start.slice(0, 4));
const sm = Number(start.slice(5, 7));
// const sd = Number(start.slice(8, 10)); // non utilisé directement
const ey = Number(end.slice(0, 4));
const em = Number(end.slice(5, 7));
const ed = Number(end.slice(8, 10));
// Dernier jour du mois (0-ième du mois suivant en UTC local Date).
const lastDayOfMonth = (y: number, m: number): number => {
// m: 1..12 ; on prend le jour 0 du mois suivant, qui donne le dernier jour du mois courant.
return new Date(y, m, 0).getDate();
};
const pad = (n: number): string => String(n).padStart(2, "0");
const out: QlikDateFilter[] = [];
// Itère mois par mois du mois de début au mois de fin (inclus).
let curY = sy;
let curM = sm;
// Sécurité anti-boucle infinie (au cas où).
const MAX_MONTHS = 1200; // 100 ans, large.
let safety = 0;
while (safety++ < MAX_MONTHS) {
const isFirst = curY === sy && curM === sm;
const isLast = curY === ey && curM === em;
// Début du mois courant : si c'est le premier mois, on respecte `dateDebut` ;
// sinon on prend le 1er calendaire.
const monthStartDay = isFirst ? Number(start.slice(8, 10)) : 1;
const monthStartDate = `${curY}-${pad(curM)}-${pad(monthStartDay)}`;
// Fin du mois courant : si c'est le dernier mois, on respecte `dateFin` ;
// sinon on prend le dernier jour calendaire du mois.
const monthEndDay = isLast ? ed : lastDayOfMonth(curY, curM);
const monthEndDate = `${curY}-${pad(curM)}-${pad(monthEndDay)}`;
const qStart = isoDateToQlikSerial(monthStartDate);
const qEnd = isoDateToQlikSerial(monthEndDate);
const dailySerials: number[] = [];
for (let s = qStart; s <= qEnd; s++) dailySerials.push(s);
const label = `${curY}-${pad(curM)}`;
const setAnalysis = `Date={">=${qStart}<=${qEnd}"}`;
out.push({ dateDebut: monthStartDate, dateFin: monthEndDate, qStart, qEnd, label, dailySerials, setAnalysis });
if (isLast) break;
// Avance d'un mois calendaire (gère le passage décembre → janvier).
curM += 1;
if (curM > 12) { curM = 1; curY += 1; }
}
if (out.length === 0) {
// Fenêtre vide : on retourne un seul mois vide aligné sur dateDebut.
const qStart = filter.qStart;
const qEnd = filter.qEnd;
const label = start.slice(0, 7);
const setAnalysis = `Date={">=${qStart}<=${qEnd}"}`;
out.push({ dateDebut: start, dateFin: end, qStart, qEnd, label, dailySerials: [], setAnalysis });
}
return out;
}
+26 -1
View File
@@ -9,12 +9,24 @@ import "server-only";
import { db } from "@/db";
import { qlikNetworkMetrics } from "@/db/schema";
import { inArray, sql } from "drizzle-orm";
import type { NetworkMetric } from "@/lib/qlik-client";
import type { NetworkMetric, QlikMonthMetrics } from "@/lib/qlik-client";
export interface NetworkMetricCached {
caReseau: number;
qteReseau: number;
nbMagasinsReseau: number;
caParMagasinReseau: number;
margePctReseau: number;
/** Quantité réseau par mois : { "YYYY-MM": qté } — null si non synchronisé avec dates. */
qteByMonth: Record<string, number> | null;
/**
* Détail mensuel complet : { "YYYY-MM": { qte, ca, nbMag, caMag, margePct } }.
* null pour les lignes synchronisées avant l'ajout de la colonne — l'UI
* retombe alors sur `qteByMonth`.
*/
metricsByMonth: Record<string, QlikMonthMetrics> | null;
/** Période couverte par l'extraction (ex "2025-03_2026-02"). */
periode: string | null;
fetchedAt: string | null;
}
@@ -36,6 +48,11 @@ export async function getNetworkMetricsByCodeCentrale(
caReseau: Number(r.caReseau ?? 0) || 0,
qteReseau: Number(r.qteReseau ?? 0) || 0,
nbMagasinsReseau: Number(r.nbMagasinsReseau ?? 0) || 0,
caParMagasinReseau: Number(r.caParMagasinReseau ?? 0) || 0,
margePctReseau: Number(r.margePctReseau ?? 0) || 0,
qteByMonth: (r.qteByMonth as Record<string, number> | null) ?? null,
metricsByMonth: (r.metricsByMonth as Record<string, QlikMonthMetrics> | null) ?? null,
periode: r.periode ?? null,
fetchedAt: r.fetchedAt ? new Date(r.fetchedAt).toISOString() : null,
});
}
@@ -51,7 +68,11 @@ export async function upsertNetworkMetrics(metrics: NetworkMetric[]): Promise<nu
caReseau: String(m.caReseau),
qteReseau: String(m.qteReseau),
nbMagasinsReseau: m.nbMagasinsReseau,
caParMagasinReseau: String(m.caParMagasinReseau),
margePctReseau: String(m.margePctReseau),
periode: m.periode ?? null,
qteByMonth: m.qteByMonth ?? null,
metricsByMonth: m.metricsByMonth ?? null,
fetchedAt: now,
}));
@@ -69,7 +90,11 @@ export async function upsertNetworkMetrics(metrics: NetworkMetric[]): Promise<nu
caReseau: sql`excluded.ca_reseau`,
qteReseau: sql`excluded.qte_reseau`,
nbMagasinsReseau: sql`excluded.nb_magasins_reseau`,
caParMagasinReseau: sql`excluded.ca_par_magasin_reseau`,
margePctReseau: sql`excluded.marge_pct_reseau`,
periode: sql`excluded.periode`,
qteByMonth: sql`excluded.qte_by_month`,
metricsByMonth: sql`excluded.metrics_by_month`,
fetchedAt: sql`excluded.fetched_at`,
},
});
+892
View File
@@ -0,0 +1,892 @@
/**
* CollectFlow — Extraction Qlik via Playwright (Chromium headless).
*
* Le proxy Qlik refuse un websocket "raw" côté serveur (403). On ouvre donc
* l'app dans Chromium avec une session authentifiée (cookie X-Qlik-Session
* injecté via le flux ticket NTLM), on récupère le **qlik-csrf-token** que le
* client Qlik utilise pour SON websocket, puis on ouvre notre propre websocket
* Engine **in-page** avec ce token (le proxy l'accepte).
*
* Efficacité : on filtre d'abord Date par mois (~30 jours), puis on sélectionne
* les Article Code demandés par petits lots. Chaque cube reste borné à
* ~30 jours × batch d'articles, sans lire tout le catalogue Qlik.
*/
import "server-only";
import { chromium, type Browser } from "playwright-core";
import { getQlikConfig, qlikNtlmSession, type QlikConfig, type NetworkMetric } from "@/lib/qlik-client";
import { buildGridNetworkQlikDateFilter, getMonthRanges, type QlikDateFilter } from "@/lib/qlik-date-range";
let browserPromise: Promise<Browser> | null = null;
async function getBrowser(): Promise<Browser> {
if (!browserPromise) {
const execPath = process.env.PLAYWRIGHT_CHROMIUM_PATH;
browserPromise = chromium.launch({
headless: true,
executablePath: execPath || undefined,
args: ["--no-sandbox", "--disable-dev-shm-usage", "--ignore-certificate-errors"],
}).catch((e) => { browserPromise = null; throw e; });
}
return browserPromise;
}
interface InPageResult {
ok: boolean;
rows?: Array<Array<string | number>>;
/**
* Métriques réseau par code central et par mois : { code: { "YYYY-MM": {…} } }.
*
* Le cube mensuel renvoie déjà les 6 mesures par (Article Code, Mois) ; on les
* conserve toutes. Seule `qte` est systématiquement présente : les mois N-1
* dérivés de « Quantité COMP » n'ont que la quantité (COMP ne porte pas les
* autres mesures), et la passe complémentaire N-1 les complète quand le champ
* `Année` existe.
*/
monthly?: Record<string, Record<string, { qte: number; ca?: number; nbMag?: number; caMag?: number; margePct?: number }>>;
size?: number;
error?: string;
diag?: Record<string, unknown>;
// Compteurs agrégés pour le résumé timing final côté Node (cf. qlik-playwright).
months?: number;
batches?: number;
code15Retries?: number;
fallbackCount?: number;
}
function envNumber(name: string, defaultValue: number, min: number, max: number): number {
const raw = process.env[name];
if (raw == null || raw.trim() === "") return defaultValue;
const parsed = Number(raw);
if (!Number.isFinite(parsed)) return defaultValue;
return Math.min(max, Math.max(min, Math.trunc(parsed)));
}
function envNumberCsv(name: string, min: number, max: number): number[] {
const raw = process.env[name];
if (!raw) return [];
return raw
.split(",")
.map((part) => Number(part.trim()))
.filter((value) => Number.isFinite(value))
.map((value) => Math.min(max, Math.max(min, Math.trunc(value))));
}
function uniqueOrdered(values: number[]): number[] {
const seen = new Set<number>();
const out: number[] = [];
for (const value of values) {
if (seen.has(value)) continue;
seen.add(value);
out.push(value);
}
return out;
}
export async function fetchNetworkMetricsPlaywright(
codeCentraux?: string[],
cfg: QlikConfig = getQlikConfig(),
dateFilter: QlikDateFilter | null = buildGridNetworkQlikDateFilter(),
): Promise<Map<string, NetworkMetric>> {
if (!cfg.appNetwork) throw new Error("[qlik-pw] QLIK_APP_NETWORK manquant");
if (!cfg.user || !cfg.password) throw new Error("[qlik-pw] identifiants Qlik manquants");
// Démarre l'horloge pour le résumé timing final (cf. bloc [qlik-pw][summary]).
const totalStart = Date.now();
console.log(`[qlik-pw] host=${cfg.host} app=${cfg.appNetwork} — ${(codeCentraux ?? []).length} codes à sélectionner`);
if (dateFilter) {
console.log(`[qlik-pw] filtre Date=${dateFilter.label} (${dateFilter.dateDebut} → ${dateFilter.dateFin}, ${dateFilter.setAnalysis})`);
}
const sess = await qlikNtlmSession(cfg);
console.log(`[qlik-pw] session NTLM OK, cookie=${sess.cookie.split("=")[0]}=…`);
const [cookieName, ...rest] = sess.cookie.split("=");
const cookieValue = rest.join("=");
const qlikSettleMs = envNumber("QLIK_SETTLE_MS", 150, 0, 2000);
const qlikCode15MaxAttempts = envNumber("QLIK_CODE15_MAX_ATTEMPTS", 4, 1, 8);
const qlikArticleBatchSize = envNumber("QLIK_ARTICLE_BATCH_SIZE", 150, 1, 500);
const csvFallbacks = envNumberCsv("QLIK_ARTICLE_BATCH_FALLBACKS", 1, 500);
const qlikArticleBatchFallbacks = uniqueOrdered([qlikArticleBatchSize, ...(csvFallbacks.length ? csvFallbacks : [75, 50]), 75, 50]);
// Chemin rapide : sélectionner TOUS les Article Code une seule fois, puis ne
// changer que la Date par mois (au lieu de re-sélectionner par lots de 150 chaque
// mois). Repli automatique sur le chemin par lots en cas de code 15.
// ACTIVÉ PAR DÉFAUT (validé fiable) — désactivable seulement avec QLIK_SELECT_ALL_CODES=0.
const qlikSelectAllCodes = !["0", "false", "no", "off"].includes((process.env.QLIK_SELECT_ALL_CODES ?? "").trim().toLowerCase());
// Découpage mensuel réel via la dimension "Mois" (cube [Article Code, Mois]) : donne
// les vraies quantités par mois ET le vrai total (au lieu de 12× la valeur annuelle).
// Activé par défaut ; désactivable via QLIK_MONTH_DIM=0 (revient à l'itération par mois).
const qlikMonthDim = !["0", "false", "no", "off"].includes((process.env.QLIK_MONTH_DIM ?? "").trim().toLowerCase());
const qlikMoisDimId = (process.env.QLIK_MOIS_DIM_ID ?? "pfGAwTs").trim();
// Mesure "Quantité COMP" (année N-1) pour compléter les mois de l'année précédente
// → 12 mois glissants (Quantité N ne couvre que l'année en cours).
const qlikMeasQteCompId = (process.env.QLIK_MEAS_QTE_COMP_ID ?? "96862880-76cd-4957-bf25-b96901f2ac5f").trim();
const yearN = new Date().getFullYear();
console.log(`[qlik-pw] tuning settle=${qlikSettleMs}ms code15Attempts=${qlikCode15MaxAttempts} batchFallbacks=${qlikArticleBatchFallbacks.join(">")} selectAllCodes=${qlikSelectAllCodes} monthDim=${qlikMonthDim}`);
const browser = await getBrowser();
console.log(`[qlik-pw] chromium lancé`);
const ctx = await browser.newContext({
httpCredentials: { username: cfg.user, password: cfg.password, origin: `https://${cfg.host}` },
ignoreHTTPSErrors: true,
});
await ctx.addCookies([{ name: cookieName, value: cookieValue, domain: cfg.host, path: "/", secure: true }]);
try {
const page = await ctx.newPage();
page.on("console", (msg) => console.log(`[qlik-pw][page] ${msg.text()}`));
page.on("pageerror", (e) => console.log(`[qlik-pw][pageerror] ${e.message}`));
// Capture le qlik-csrf-token depuis le websocket que le client Qlik ouvre
let csrfToken: string | null = null;
let resolveToken: (() => void) | null = null;
const tokenReady = new Promise<void>((r) => { resolveToken = r; });
page.on("websocket", (ws) => {
const m = ws.url().match(/qlik-csrf-token=([^&]+)/);
if (m && !csrfToken) { csrfToken = decodeURIComponent(m[1]); resolveToken?.(); }
});
await page.goto(`https://${cfg.host}/sense/app/${cfg.appNetwork}`, { waitUntil: "domcontentloaded", timeout: cfg.timeoutMs })
.catch(() => { /* le client ouvre son ws ensuite */ });
console.log(`[qlik-pw] page chargée, attente du qlik-csrf-token…`);
await Promise.race([tokenReady, page.waitForTimeout(15000)]);
if (!csrfToken) throw new Error("[qlik-pw] qlik-csrf-token introuvable (client Qlik non chargé ?)");
console.log(`[qlik-pw] csrf-token capturé, ouverture du websocket Engine in-page…`);
// Pré-calcul des mois côté Node (pour pouvoir logger X/Y et propager au script in-page).
const monthRanges: QlikDateFilter[] = dateFilter ? getMonthRanges(dateFilter) : [];
const monthlyPayload = monthRanges.map((m) => ({
label: m.label,
dateDebut: m.dateDebut,
dateFin: m.dateFin,
serials: m.dailySerials, // ~30 valeurs → SelectValues en une fois
}));
const result = (await page.evaluate(
({ app, dim, mca, mqte, mnb, mcamag, mmarge, codes, token, monthlyPayload, noDateMode, qlikSettleMs, qlikCode15MaxAttempts, qlikArticleBatchFallbacks, selectAllCodes, monthDim, moisDimId, mqteComp, yearN }: {
app: string; dim: string; mca: string; mqte: string; mnb: string; mcamag: string; mmarge: string;
codes: string[]; token: string;
monthlyPayload: Array<{ label: string; dateDebut: string; dateFin: string; serials: number[] }>;
noDateMode: boolean;
qlikSettleMs: number;
qlikCode15MaxAttempts: number;
qlikArticleBatchFallbacks: number[];
selectAllCodes: boolean;
monthDim: boolean;
moisDimId: string;
mqteComp: string;
yearN: number;
}) =>
new Promise<InPageResult>((resolve) => {
const loc = (window as unknown as { location: Location }).location;
const url = `wss://${loc.host}/app/${app}?reloadUri=${encodeURIComponent(loc.href)}&qlik-csrf-token=${token}`;
const ws = new WebSocket(url);
let id = 0;
const pend = new Map<number, { res: (v: { [k: string]: unknown }) => void; rej: (e: unknown) => void }>();
const rpc = (method: string, params: unknown, handle = -1) => new Promise<{ [k: string]: unknown }>((res, rej) => {
const i = ++id; pend.set(i, { res, rej });
ws.send(JSON.stringify({ jsonrpc: "2.0", id: i, handle, method, params }));
});
const fail = (error: string) => resolve({ ok: false, error });
const ready = new Promise<void>((res, rej) => {
ws.onmessage = (ev: MessageEvent) => {
const m = JSON.parse(ev.data as string);
if (m.method === "OnConnected") return res();
if (m.id && pend.has(m.id)) {
const x = pend.get(m.id)!;
pend.delete(m.id);
if (m.error) x.rej(new Error(JSON.stringify(m.error)));
else x.res(m.result);
}
};
ws.onerror = () => rej(new Error("ws error (403 ?)"));
setTimeout(() => rej(new Error("ws timeout")), 30000);
});
// Qlik Engine code 15 = "Request aborted" : déclenché quand une requête
// (GetLayout / GetHyperCubeData / CreateSessionObject) entre en collision
// avec une évaluation de sélection encore en cours côté Engine. On retente
// quelques fois avec un court backoff et un log explicite de l'étape.
const isEngineCode15 = (e: unknown): boolean => {
const msg = String((e as Error)?.message ?? e);
return /"code"\s*:\s*15\b/.test(msg);
};
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
const nowMs = () => (typeof performance !== "undefined" ? performance.now() : Date.now());
const timed = async <T>(etape: string, method: string, fn: () => Promise<T>, timings?: Record<string, number>): Promise<{ value: T; ms: number }> => {
const start = nowMs();
const value = await fn();
const ms = Math.round(nowMs() - start);
const label = `${etape}:${method}`;
if (timings) timings[label] = (timings[label] ?? 0) + ms;
console.log(`[qlik-pw][timing] ${label} ms=${ms}`);
return { value, ms };
};
const timingSummary = (timings: Record<string, number>) => JSON.stringify(timings);
const rpcWithRetry = async (
etape: string,
method: string,
params: unknown,
handle: number,
onCode15Retry?: () => void,
): Promise<{ [k: string]: unknown }> => {
let lastErr: unknown;
for (let attempt = 1; attempt <= qlikCode15MaxAttempts; attempt++) {
try {
return await rpc(method, params, handle);
} catch (e) {
lastErr = e;
if (!isEngineCode15(e) || attempt === qlikCode15MaxAttempts) throw e;
onCode15Retry?.();
const backoff = qlikSettleMs * attempt;
console.log(`[qlik-pw] retry code15 étape=${etape} méthode=${method} tentative=${attempt}/${qlikCode15MaxAttempts} backoff=${backoff}ms`);
await sleep(backoff);
}
}
throw lastErr;
};
(async () => {
try {
await ready;
console.log("ws connecté (OnConnected)");
const open = await rpc("OpenDoc", { qDocName: app });
const doc = (open.qReturn as { qHandle: number }).qHandle;
console.log("OpenDoc OK handle=" + doc);
// Handles des champs Date / Article Code.
let dfh = -1;
let fh = -1;
let yfh = -1;
const df = await rpc("GetField", { qFieldName: "Date" }, doc);
dfh = (df.qReturn as { qHandle: number }).qHandle;
try {
const yf = await rpc("GetField", { qFieldName: "Année" }, doc);
yfh = (yf.qReturn as { qHandle: number }).qHandle;
} catch { yfh = -1; }
if (codes.length) {
const gf = await rpc("GetField", { qFieldName: "Article Code" }, doc);
fh = (gf.qReturn as { qHandle: number }).qHandle;
}
// Résoudre les master measures par TITRE (le qLibraryId fiable = qInfo.qId, ≠ GUID QRS).
const norm = (s: string) => s.replace(/\s+/g, " ").trim().toLowerCase();
const ml = await rpc("CreateSessionObject", { qProp: { qInfo: { qType: "MeasureList" }, qMeasureListDef: { qType: "measure", qData: { title: "/qMetaDef/title" } } } }, doc);
const mll = await rpc("GetLayout", {}, (ml.qReturn as { qHandle: number }).qHandle);
const items = (((mll.qLayout as { qMeasureList?: { qItems?: Array<{ qInfo: { qId: string }; qMeta?: { title?: string }; qData?: { title?: string } }> } }).qMeasureList?.qItems) ?? []);
const byTitle = new Map<string, string>();
for (const it of items) { const t = it.qMeta?.title ?? it.qData?.title ?? ""; if (t) byTitle.set(norm(t), it.qInfo.qId); }
const pickMeasure = (title: string, fallback: string) => byTitle.get(norm(title)) ?? fallback;
const rCamag = pickMeasure("CA par Magasin N", mcamag);
const rMarge = pickMeasure("Marge % N", mmarge);
// Résolution par TITRE aussi pour CA / Quantité / Nb magasins : évite tout
// décalage si un qId d'env ne pointe pas sur la mesure attendue.
const rCa = pickMeasure("CA N", mca);
const rQte = pickMeasure("Quantité N", mqte);
const rNbMag = pickMeasure("Magasin Ventes Nb N", mnb);
console.log("[qlik-pw] mesures utilisées: " + JSON.stringify({
"CA N": rCa, "Quantité N": rQte, "Magasin Ventes Nb N": rNbMag,
"CA par Magasin N": rCamag, "Marge % N": rMarge,
env: { mca, mqte, mnb },
}));
console.log("measures résolues par titre: " + JSON.stringify({ rCamag, rMarge }) + " (sur " + items.length + " mesures)");
// DUMP diagnostic : inventaire des mesures + dimensions (titre + id) pour
// identifier une mesure "Quantité" NON annuelle et/ou une dimension "Mois/Période"
// permettant un vrai découpage mensuel du réseau.
console.log("[qlik-pw][dump] MESURES (" + items.length + "): " + JSON.stringify(items.map((it) => ({ t: it.qMeta?.title ?? it.qData?.title ?? "", id: it.qInfo.qId }))));
try {
const dl = await rpc("CreateSessionObject", { qProp: { qInfo: { qType: "DimensionList" }, qDimensionListDef: { qType: "dimension", qData: { title: "/qMetaDef/title" } } } }, doc);
const dll = await rpc("GetLayout", {}, (dl.qReturn as { qHandle: number }).qHandle);
const ditems = (((dll.qLayout as { qDimensionList?: { qItems?: Array<{ qInfo: { qId: string }; qMeta?: { title?: string }; qData?: { title?: string } }> } }).qDimensionList?.qItems) ?? []);
console.log("[qlik-pw][dump] DIMENSIONS (" + ditems.length + "): " + JSON.stringify(ditems.map((it) => ({ t: it.qMeta?.title ?? it.qData?.title ?? "", id: it.qInfo.qId }))));
} catch (e) { console.log("[qlik-pw][dump] DimensionList error: " + String((e as Error)?.message || e)); }
// L'hypercube session object est désormais créé JETABLE dans fetchCubeForSelection
// (un objet par lot) puis DestroySessionObject — corrige "Request aborted" (code 15)
// causé par la réutilisation du même objet sur des centaines de recalculs.
const out: Array<Array<string | number>> = [];
// Quantité réseau par code central et par mois (label "YYYY-MM").
// Accumulée en parallèle du total, sans toucher l'agrégation existante.
const monthlyByCode: Record<string, Record<string, { qte: number; ca?: number; nbMag?: number; caMag?: number; margePct?: number }>> = {};
const accMonthly = (batchRows: Array<Array<string | number>>, monthLabel: string) => {
for (const r of batchRows) {
const c = String(r[0] ?? "").trim();
if (!c || c === "-") continue;
const q = Number(r[2]) || 0;
(monthlyByCode[c] ??= {});
const cur = monthlyByCode[c][monthLabel];
monthlyByCode[c][monthLabel] = {
qte: (cur?.qte ?? 0) + q,
ca: (cur?.ca ?? 0) + (Number(r[1]) || 0),
nbMag: Math.max(cur?.nbMag ?? 0, Number(r[3]) || 0),
caMag: Number(r[4]) || 0,
margePct: Number(r[5]) || 0,
};
}
};
// Compteurs agrégés pour le résumé timing final (loggué côté Node après la sync).
let aggMonths = 0;
let aggBatches = 0;
let aggCode15Retries = 0;
let aggFallbackCount = 0;
// Sélectionne Date + Article Code puis pagine le cube. Renvoie le nb de lignes
// brutes lues (avant dédoublonnage côté Node).
//
// IMPORTANT : crée un hypercube session object JETABLE par lot (Date + Article Code
// déjà sélectionnés) puis le détruit. Réutiliser le même objet sur des centaines de
// lots force Qlik à recalculer en boucle et finit par "Request aborted" (code 15).
const PAGE = 1400;
// Petite latence après chaque mutation de sélection : laisser l'Engine
// finaliser l'évaluation de la sélection avant la prochaine requête layout/cube.
const createCubeForBatch = async (timings?: Record<string, number>, onCode15Retry?: () => void): Promise<{ handle: number; id: string }> => {
const { value: obj } = await timed("createCube", "CreateSessionObject", () => rpcWithRetry("createCube", "CreateSessionObject", { qProp: { qInfo: { qType: "cf-net" }, qHyperCubeDef: {
qDimensions: [{ qLibraryId: dim }],
qMeasures: [{ qLibraryId: rCa }, { qLibraryId: rQte }, { qLibraryId: rNbMag }, { qLibraryId: rCamag }, { qLibraryId: rMarge }],
qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 6, qHeight: PAGE }],
} } }, doc, onCode15Retry), timings);
const qReturn = obj.qReturn as { qHandle: number; qGenericId?: string; qId?: string };
return { handle: qReturn.qHandle, id: String(qReturn.qGenericId ?? qReturn.qId ?? "") };
};
const destroyCube = async (cube: { handle: number; id: string }, timings?: Record<string, number>) => {
if (!cube.id) return;
try {
await timed("destroyCube", "DestroySessionObject", () => rpcWithRetry("destroyCube", "DestroySessionObject", { qId: cube.id }, doc), timings);
} catch (e) {
// Non fatal : l'objet est de toute façon scopé à la session websocket.
// Un code 15 ici signifie que l'Engine l'a déjà libéré après un abort.
console.log("[qlik-pw] DestroySessionObject ignoré: " + String((e as Error)?.message || e));
}
};
const appendMatrix = (matrix: Array<Array<{ qText?: string; qNum?: number }>>, sink: Array<Array<string | number>>): number => {
for (const r of matrix) sink.push([
String(r[0]?.qText ?? ""),
Number(r[1]?.qNum) || 0,
Number(r[2]?.qNum) || 0,
Number(r[3]?.qNum) || 0,
Number(r[4]?.qNum) || 0,
Number(r[5]?.qNum) || 0,
]);
return matrix.length;
};
const fetchCubeForSelection = async (sink: Array<Array<string | number>>, timings?: Record<string, number>, onCode15Retry?: () => void): Promise<number> => {
const cube = await createCubeForBatch(timings, onCode15Retry);
const oh = cube.handle;
try {
const { value: layout } = await timed("layout", "GetLayout", () => rpcWithRetry("layout", "GetLayout", {}, oh, onCode15Retry), timings);
const hyperCube = (layout.qLayout as { qHyperCube: { qSize: { qcy: number }; qDataPages?: Array<{ qArea?: { qTop?: number }; qMatrix?: Array<Array<{ qText?: string; qNum?: number }>> }> } }).qHyperCube;
const size = hyperCube.qSize.qcy;
const firstPage = hyperCube.qDataPages?.find((page) => (page.qArea?.qTop ?? 0) === 0);
let got = 0;
let top = 0;
if (firstPage?.qMatrix?.length) {
got += appendMatrix(firstPage.qMatrix, sink);
top = PAGE;
}
for (; top < size; top += PAGE) {
const { value: d } = await timed("getCubeData", "GetHyperCubeData", () => rpcWithRetry("getCubeData", "GetHyperCubeData", { qPath: "/qHyperCubeDef", qPages: [{ qTop: top, qLeft: 0, qWidth: 6, qHeight: PAGE }] }, oh, onCode15Retry), timings);
const matrix = (d.qDataPages as Array<{ qMatrix?: Array<Array<{ qText?: string; qNum?: number }>> }>)?.[0]?.qMatrix ?? [];
if (!matrix.length) break;
got += appendMatrix(matrix, sink);
if (matrix.length < PAGE) break;
}
return got;
} finally {
await destroyCube(cube, timings);
}
};
// ─── Découpage mensuel réel via dimension "Mois" ────────────────────
// Cube [Article Code, Mois] × 5 mesures. Chaque ligne = (code, mois, ca,
// qte, nbMag, caMag, marge). Le "Mois" est au format "YYYYMM" → "YYYY-MM".
const normMois = (m: string): string => (/^\d{6}$/.test(m) ? m.slice(0, 4) + "-" + m.slice(4) : m);
const PAGEM = 1200; // 8 colonnes × 1200 = 9600 cellules < 10000 (limite Qlik)
const fetchMonthCube = async (
onRow: (code: string, mois: string, ca: number, qte: number, nbMag: number, caMag: number, marge: number, qteComp: number) => void,
timings?: Record<string, number>,
onCode15Retry?: () => void,
): Promise<number> => {
const { value: obj } = await timed("createCube", "CreateSessionObject", () => rpcWithRetry("createCube", "CreateSessionObject", { qProp: { qInfo: { qType: "cf-net-mois" }, qHyperCubeDef: {
qDimensions: [{ qLibraryId: dim }, { qLibraryId: moisDimId }],
qMeasures: [{ qLibraryId: rCa }, { qLibraryId: rQte }, { qLibraryId: rNbMag }, { qLibraryId: rCamag }, { qLibraryId: rMarge }, { qLibraryId: mqteComp }],
qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 8, qHeight: PAGEM }],
} } }, doc, onCode15Retry), timings);
const qReturn = obj.qReturn as { qHandle: number; qGenericId?: string; qId?: string };
const cube = { handle: qReturn.qHandle, id: String(qReturn.qGenericId ?? qReturn.qId ?? "") };
const oh = cube.handle;
const process = (matrix: Array<Array<{ qText?: string; qNum?: number }>>): number => {
for (const r of matrix) {
onRow(
String(r[0]?.qText ?? ""), String(r[1]?.qText ?? ""),
Number(r[2]?.qNum) || 0, Number(r[3]?.qNum) || 0, Number(r[4]?.qNum) || 0,
Number(r[5]?.qNum) || 0, Number(r[6]?.qNum) || 0, Number(r[7]?.qNum) || 0,
);
}
return matrix.length;
};
try {
const { value: layout } = await timed("layout", "GetLayout", () => rpcWithRetry("layout", "GetLayout", {}, oh, onCode15Retry), timings);
const hc = (layout.qLayout as { qHyperCube: { qSize: { qcy: number }; qDataPages?: Array<{ qArea?: { qTop?: number }; qMatrix?: Array<Array<{ qText?: string; qNum?: number }>> }> } }).qHyperCube;
const size = hc.qSize.qcy;
let got = 0, top = 0;
const firstPage = hc.qDataPages?.find((p) => (p.qArea?.qTop ?? 0) === 0);
if (firstPage?.qMatrix?.length) { got += process(firstPage.qMatrix); top = PAGEM; }
for (; top < size; top += PAGEM) {
const { value: d } = await timed("getCubeData", "GetHyperCubeData", () => rpcWithRetry("getCubeData", "GetHyperCubeData", { qPath: "/qHyperCubeDef", qPages: [{ qTop: top, qLeft: 0, qWidth: 8, qHeight: PAGEM }] }, oh, onCode15Retry), timings);
const matrix = (d.qDataPages as Array<{ qMatrix?: Array<Array<{ qText?: string; qNum?: number }>> }>)?.[0]?.qMatrix ?? [];
if (!matrix.length) break;
got += process(matrix);
if (matrix.length < PAGEM) break;
}
return got;
} finally {
await destroyCube(cube, timings);
}
};
// Sélectionne toute la fenêtre de dates une fois, puis parcourt les codes par
// lots ; un seul cube [Article Code, Mois] par lot donne TOUS les mois d'un coup.
// selectYear = null → passe principale (année N + N-1 dérivée via COMP).
// selectYear = 2025 → passe complémentaire : on sélectionne l'année pour
// récupérer directement ses mois (seul moyen d'obtenir août→déc N-1, que
// COMP ne peut pas fournir puisque l'année N ne va pas jusque-là).
let loggedSampleRow = false;
const monthDimPath = async (selectYear: number | null): Promise<void> => {
const isMainPass = selectYear === null;
if (yfh !== -1) {
await rpc("Clear", {}, yfh);
await sleep(qlikSettleMs);
if (selectYear !== null) {
const ysel = await rpc("SelectValues", { qFieldValues: [{ qNum: selectYear, qText: String(selectYear) }], qToggleMode: false, qSoftLock: true }, yfh);
console.log("[qlik-pw] (mois) sélection Année=" + selectYear + " → " + JSON.stringify(ysel.qReturn));
await sleep(qlikSettleMs);
}
}
const allSerials = monthlyPayload.flatMap((m) => m.serials);
if (dfh !== -1 && allSerials.length) {
await rpc("Clear", {}, dfh);
await sleep(qlikSettleMs);
await rpc("SelectValues", { qFieldValues: allSerials.map((s) => ({ qNum: s, qText: String(s) })), qToggleMode: false, qSoftLock: true }, dfh);
await sleep(qlikSettleMs);
}
aggMonths = monthlyPayload.length;
// Lots plus petits = cubes plus légers (moins de pression mémoire côté
// moteur Qlik, qui a tendance à saturer "Out of memory" sur les gros cubes).
const fallbackSizes = [300, 150, 75];
const totalCodes = codes.length;
let codeIndex = 0, batchNumber = 0, fallbackIndex = 0;
while (codes.length ? codeIndex < totalCodes : batchNumber === 0) {
const batch = codes.length ? codes.slice(codeIndex, Math.min(codeIndex + fallbackSizes[fallbackIndex], totalCodes)) : [];
const timings: Record<string, number> = {};
let code15Retries = 0;
batchNumber++;
const bp = "[qlik-pw] (mois) lot " + batchNumber + " [" + codeIndex + "," + (codeIndex + batch.length) + ") size=" + batch.length;
try {
if (batch.length && fh !== -1) {
await timed("clearCode", "Clear", () => rpc("Clear", {}, fh), timings);
await sleep(qlikSettleMs);
await timed("selectCode", "SelectValues", () => rpc("SelectValues", { qFieldValues: batch.map((c) => ({ qText: c })), qToggleMode: false, qSoftLock: true }, fh), timings);
await sleep(qlikSettleMs);
}
const got = await timed("cube", "batch", () => fetchMonthCube((code, mois, ca, qte, nbMag, caMag, marge, qteComp) => {
if (!code || code === "-") return;
// Les totaux réseau ne viennent QUE de la passe principale
// (sinon on doublerait CA/Qté avec la passe N-1).
if (isMainPass) out.push([code, ca, qte, nbMag, caMag, marge]);
const mm = normMois(mois);
const y = parseInt(mm.slice(0, 4), 10);
if (!loggedSampleRow) {
loggedSampleRow = true;
console.log("[qlik-pw][mesures] échantillon ligne mensuelle: " + JSON.stringify({
code, mois: mm, "CA N": ca, "Quantité N (utilisée pour la courbe)": qte,
"Magasin Ventes Nb N": nbMag, "CA par Magasin N": caMag, "Marge % N": marge, "Quantité COMP": qteComp,
}));
}
(monthlyByCode[code] ??= {});
// Les 5 mesures du mois courant de la ligne. Le cube les renvoie
// déjà toutes : les conserver ne coûte aucune requête Qlik en plus.
const full = { qte, ca, nbMag, caMag, margePct: marge };
if (isMainPass && y === yearN) {
// Mois de l'année N + son comparable N-1 (COMP = même mois, N-1).
monthlyByCode[code][mm] = full;
const mmPrev = String(y - 1) + mm.slice(4); // "2026-03" → "2025-03"
// COMP ne porte QUE la quantité : les autres mesures restent
// absentes tant que la passe N-1 ne les a pas complétées.
if (monthlyByCode[code][mmPrev] === undefined) monthlyByCode[code][mmPrev] = { qte: qteComp };
} else {
// Passe année sélectionnée : valeurs directes, prioritaires sur COMP.
monthlyByCode[code][mm] = full;
}
}, timings, () => { code15Retries++; }), timings);
aggBatches++;
aggCode15Retries += code15Retries;
console.log("[qlik-pw][timing] " + bp + " — rows=" + got + " cumul=" + out.length + " retries15=" + code15Retries + " timings=" + timingSummary(timings));
if (!codes.length) break;
codeIndex += batch.length;
fallbackIndex = 0;
} catch (batchErr) {
const nextSize = fallbackSizes[fallbackIndex + 1];
if (codes.length && isEngineCode15(batchErr) && nextSize && batch.length > nextSize) {
console.log("[qlik-pw] " + bp + " — fallback code15 " + batch.length + "→" + nextSize);
fallbackIndex += 1;
aggFallbackCount++;
continue;
}
throw new Error(bp + " — " + String((batchErr as Error)?.message || batchErr));
}
}
};
// Chemin rapide (flag QLIK_SELECT_ALL_CODES) : sélectionne TOUS les
// Article Code une seule fois, puis n'itère que la sélection Date par
// mois avec UN cube paginé (au lieu de re-sélectionner les codes par lots
// de 150 à chaque mois — la SelectValues répétée coûte ~22 s × 120).
// Renvoie true si le mois complet a réussi ; false si repli code 15 (les
// résultats partiels et sélections sont réinitialisés avant le chemin par lots).
const trySelectAllCodesPath = async (): Promise<boolean> => {
const savedLen = out.length;
try {
if (codes.length && fh !== -1) {
await timed("clearCodeAll", "Clear", () => rpc("Clear", {}, fh), {});
await sleep(qlikSettleMs);
const { value: sel } = await timed("selectCodeAll", "SelectValues", () => rpc("SelectValues", {
qFieldValues: codes.map((c) => ({ qText: c })),
qToggleMode: false,
qSoftLock: true,
}, fh), {});
console.log("[qlik-pw] select-all — SelectValues Article Code (" + codes.length + " codes) → " + JSON.stringify(sel.qReturn));
await sleep(qlikSettleMs);
}
const total = monthlyPayload.length;
console.log("[qlik-pw] chemin rapide select-all — " + total + " mois, 1 sélection Article Code globale");
for (let mi = 0; mi < total; mi++) {
const m = monthlyPayload[mi];
const prefix = "[qlik-pw] (rapide) mois " + (mi + 1) + "/" + total + " " + m.label + " (" + m.dateDebut + "→" + m.dateFin + ", " + m.serials.length + "j)";
const monthStart = nowMs();
const monthTimings: Record<string, number> = {};
aggMonths = mi + 1;
let code15Retries = 0;
// Sélection Date du mois uniquement (la sélection Article Code reste active).
const { value: dsel } = await timed("selectDate", "SelectValues", () => rpc("SelectValues", {
qFieldValues: m.serials.map((s) => ({ qNum: s, qText: String(s) })),
qToggleMode: false,
qSoftLock: true,
}, dfh), monthTimings);
console.log(prefix + " — SelectValues Date " + m.serials.length + " serials → " + JSON.stringify(dsel.qReturn));
await sleep(qlikSettleMs);
// Un seul cube paginé pour tous les articles du mois.
const batchRows: Array<Array<string | number>> = [];
const { value: got, ms } = await timed("cube", "batch", () => fetchCubeForSelection(batchRows, monthTimings, () => { code15Retries++; }), monthTimings);
out.push(...batchRows);
accMonthly(batchRows, m.label);
aggBatches++;
aggCode15Retries += code15Retries;
console.log("[qlik-pw][timing] " + prefix + " — rows=" + got + " cumul=" + out.length + " ms=" + ms + " retries15=" + code15Retries + " timings=" + timingSummary(monthTimings));
// Clear Date uniquement — on garde la sélection Article Code pour le mois suivant.
if (mi < total - 1) {
try { await timed("clearDate", "Clear", () => rpc("Clear", {}, dfh), monthTimings); }
catch (e) { console.log(prefix + " — Clear Date ignoré: " + String((e as Error)?.message || e)); }
}
console.log("[qlik-pw][timing] " + prefix + " — done ms=" + Math.round(nowMs() - monthStart) + " timings=" + timingSummary(monthTimings));
}
return true;
} catch (e) {
if (isEngineCode15(e)) {
console.log("[qlik-pw] chemin rapide échoué (code 15) → repli sur le chemin par lots");
out.length = savedLen;
for (const k of Object.keys(monthlyByCode)) delete monthlyByCode[k];
aggMonths = 0; aggBatches = 0; aggCode15Retries = 0;
try { await rpc("Clear", {}, dfh); if (fh !== -1) await rpc("Clear", {}, fh); } catch { /* noop */ }
await sleep(qlikSettleMs);
return false;
}
throw e;
}
};
if (!noDateMode && monthDim) {
// Découpage mensuel réel (dimension Mois) — remplace l'itération par mois.
console.log("[qlik-pw] extraction via dimension Mois (" + monthlyPayload.length + " mois attendus, moisDim=" + moisDimId + ")");
await monthDimPath(null);
// Passe complémentaire : mois de l'année N-1 absents de l'année N
// (ex. août→déc), impossibles à obtenir via COMP. On sélectionne
// l'année N-1 pour que les mesures "N" portent sur cette année.
if (yfh !== -1) {
const before = Object.values(monthlyByCode).reduce((s, m) => s + Object.keys(m).length, 0);
try {
await monthDimPath(yearN - 1);
const after = Object.values(monthlyByCode).reduce((s, m) => s + Object.keys(m).length, 0);
console.log("[qlik-pw] (mois) passe N-1 terminée : " + before + " → " + after + " points mensuels");
} catch (e2) {
console.log("[qlik-pw] (mois) passe N-1 ignorée: " + String((e2 as Error)?.message || e2));
}
try { await rpc("Clear", {}, yfh); } catch { /* noop */ }
} else {
console.log("[qlik-pw] (mois) champ Année introuvable → pas de passe N-1");
}
} else if (noDateMode) {
// Aucun filtre Date : extraction en un seul appel (tous les codes d'un coup).
// On sélectionne uniquement Article Code ; Date reste libre.
const timings: Record<string, number> = {};
let code15Retries = 0;
console.log("mode sans dateFilter — sélection de " + codes.length + " codes en une fois");
if (codes.length && fh !== -1) {
const { value: sel } = await timed("selectCode", "SelectValues", () => rpc("SelectValues", { qFieldValues: codes.map((c) => ({ qText: c })), qToggleMode: false, qSoftLock: true }, fh), timings);
console.log("SelectValues Article Code → " + JSON.stringify(sel.qReturn));
}
const batchRows: Array<Array<string | number>> = [];
const { value: got, ms } = await timed("cube", "batch", () => fetchCubeForSelection(batchRows, timings, () => { code15Retries++; }), timings);
out.push(...batchRows);
aggMonths = 1;
aggBatches = 1;
aggCode15Retries = code15Retries;
console.log("[qlik-pw][timing] noDate rows=" + got + " cumul=" + out.length + " ms=" + ms + " retries15=" + code15Retries + " timings=" + timingSummary(timings));
} else if (selectAllCodes && (await trySelectAllCodesPath())) {
// Chemin rapide terminé avec succès (voir trySelectAllCodesPath).
// Si false (repli code 15), on tombe dans le chemin par lots ci-dessous.
} else {
// Itération par mois × lots de codes : pour chaque mois, SelectValues Date
// (~30 serials), puis SelectValues Article Code par petits lots. Le cube
// reste borné à ~30 jours × batch contrôlé, sans lire tout le catalogue Qlik.
const total = monthlyPayload.length;
const fallbackSizes = qlikArticleBatchFallbacks.length ? qlikArticleBatchFallbacks : [150, 75, 50];
const fallbackLabel = fallbackSizes.join(">");
const estimatedBatches = codes.length ? Math.ceil(codes.length / fallbackSizes[0]) : 1;
console.log("itération par mois — " + total + " mois × ~" + estimatedBatches + " lot(s), batchFallbacks=" + fallbackLabel);
for (let mi = 0; mi < total; mi++) {
const m = monthlyPayload[mi];
const prefix = "[qlik-pw] mois " + (mi + 1) + "/" + total + " " + m.label + " (" + m.dateDebut + "→" + m.dateFin + ", " + m.serials.length + "j)";
const monthStart = nowMs();
const monthTimings: Record<string, number> = {};
aggMonths = mi + 1;
try {
// 1) Sélection Date pour le mois (un seul SelectValues, ~30 valeurs).
const { value: dsel } = await timed("selectDate", "SelectValues", () => rpc("SelectValues", {
qFieldValues: m.serials.map((s) => ({ qNum: s, qText: String(s) })),
qToggleMode: false,
qSoftLock: true,
}, dfh), monthTimings);
console.log(prefix + " — SelectValues Date " + m.serials.length + " serials → " + JSON.stringify(dsel.qReturn));
// Laisser l'Engine finaliser l'évaluation de la sélection Date
// avant la première SelectValues Article Code du 1er lot.
await sleep(qlikSettleMs);
// 2) Sélection Article Code par lots contrôlés.
let codeIndex = 0;
let batchNumber = 0;
let fallbackIndex = 0;
const totalCodes = codes.length || 0;
while (codes.length ? codeIndex < totalCodes : batchNumber === 0) {
const batchSize = codes.length ? fallbackSizes[fallbackIndex] : 0;
const batch = codes.length ? codes.slice(codeIndex, Math.min(codeIndex + batchSize, totalCodes)) : [];
const batchStart = codeIndex;
const batchEnd = codeIndex + batch.length;
const timings: Record<string, number> = {};
let code15Retries = 0;
batchNumber++;
const batchPrefix = prefix + " — lot " + batchNumber + " [" + batchStart + "," + batchEnd + ") size=" + batch.length;
try {
if (fh !== -1) {
await timed("clearCode", "Clear", () => rpc("Clear", {}, fh), timings);
// Laisser l'Engine appliquer le Clear avant la nouvelle sélection.
await sleep(qlikSettleMs);
const { value: csel } = await timed("selectCode", "SelectValues", () => rpc("SelectValues", {
qFieldValues: batch.map((c) => ({ qText: c })),
qToggleMode: false,
qSoftLock: true,
}, fh), timings);
console.log(batchPrefix + " — SelectValues Article Code → " + JSON.stringify(csel.qReturn));
// Laisser l'Engine finaliser l'évaluation de la sélection
// avant de créer / interroger le cube (évite code 15 sur le 1er lot).
await sleep(qlikSettleMs);
}
const batchRows: Array<Array<string | number>> = [];
const { value: got, ms } = await timed("cube", "batch", () => fetchCubeForSelection(batchRows, timings, () => { code15Retries++; }), timings);
out.push(...batchRows);
accMonthly(batchRows, m.label);
aggBatches++;
aggCode15Retries += code15Retries;
console.log("[qlik-pw][timing] " + batchPrefix + " — rows=" + got + " cumul=" + out.length + " ms=" + ms + " retries15=" + code15Retries + " timings=" + timingSummary(timings));
if (!codes.length) break;
codeIndex = batchEnd;
fallbackIndex = 0;
} catch (batchErr) {
const msg = String((batchErr as Error)?.message || batchErr);
const nextFallbackIndex = fallbackIndex + 1;
const nextSize = fallbackSizes[nextFallbackIndex];
if (codes.length && isEngineCode15(batchErr) && nextSize && batch.length > nextSize) {
console.log("[qlik-pw][timing] " + batchPrefix + " — fallback code15 " + batch.length + "→" + nextSize + " retries15=" + code15Retries + " timings=" + timingSummary(timings));
fallbackIndex = nextFallbackIndex;
aggFallbackCount++;
continue;
}
console.error(batchPrefix + " — ABORT — " + msg);
throw new Error(batchPrefix + " — " + msg);
}
}
} catch (monthErr) {
const msg = String((monthErr as Error)?.message || monthErr);
console.error(prefix + " — ABORT — " + msg);
throw new Error(prefix + " — " + msg);
}
// 3) Clear Date + Article Code avant le mois suivant (soulage l'Engine).
if (mi < total - 1) {
try {
await timed("clearDate", "Clear", () => rpc("Clear", {}, dfh), monthTimings);
if (fh !== -1) await timed("clearCodeMonth", "Clear", () => rpc("Clear", {}, fh), monthTimings);
} catch (clearErr) {
console.log(prefix + " — Clear ignoré: " + String((clearErr as Error)?.message || clearErr));
}
}
console.log("[qlik-pw][timing] " + prefix + " — done ms=" + Math.round(nowMs() - monthStart) + " cumul=" + out.length + " timings=" + timingSummary(monthTimings));
}
}
// SONDE (à la FIN pour apparaître dans la queue du log) : sélectionne ~50
// codes + la fenêtre 12 mois, puis crée un petit cube [Article Code, Mois]
// × Quantité N. Montre le FORMAT des valeurs "Mois" et si la quantité VARIE
// par mois, pour concevoir le vrai découpage mensuel.
try {
const moisDimId = "pfGAwTs";
const probeCodes = (codes ?? []).slice(0, 50);
if (fh !== -1 && probeCodes.length) {
await rpc("Clear", {}, fh);
await rpc("SelectValues", { qFieldValues: probeCodes.map((c) => ({ qText: c })), qToggleMode: false, qSoftLock: true }, fh);
}
const allSerials = monthlyPayload.flatMap((m) => m.serials);
if (dfh !== -1 && allSerials.length) {
await rpc("SelectValues", { qFieldValues: allSerials.map((s) => ({ qNum: s, qText: String(s) })), qToggleMode: false, qSoftLock: true }, dfh);
}
await sleep(qlikSettleMs);
const probe = await rpc("CreateSessionObject", { qProp: { qInfo: { qType: "cf-probe" }, qHyperCubeDef: {
qDimensions: [{ qLibraryId: dim }, { qLibraryId: moisDimId }],
qMeasures: [{ qLibraryId: mqte }],
qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 3, qHeight: 40 }],
} } }, doc);
const pRet = probe.qReturn as { qHandle: number; qGenericId?: string; qId?: string };
const pl = await rpc("GetLayout", {}, pRet.qHandle);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const hc = (pl.qLayout as any)?.qHyperCube;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const matrix: any[] = hc?.qDataPages?.[0]?.qMatrix ?? [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const sample = matrix.slice(0, 40).map((row: any[]) => [row[0]?.qText, row[1]?.qText, row[2]?.qNum]);
console.log("[qlik-pw][probe] cube [Article Code, Mois] x Quantite N — size=" + (hc?.qSize?.qcy ?? "?") + " echantillon: " + JSON.stringify(sample));
try { await rpc("DestroySessionObject", { qId: String(pRet.qGenericId ?? pRet.qId ?? "") }, doc); } catch { /* noop */ }
} catch (e) { console.log("[qlik-pw][probe] error: " + String((e as Error)?.message || e)); }
ws.close();
resolve({ ok: true, rows: out, monthly: monthlyByCode, size: out.length, months: aggMonths, batches: aggBatches, code15Retries: aggCode15Retries, fallbackCount: aggFallbackCount });
} catch (e) { try { ws.close(); } catch { /* noop */ } fail(String((e as Error)?.message || e)); }
})();
}),
{
app: cfg.appNetwork,
dim: cfg.dimCodeArticleId,
mca: cfg.measCaId,
mqte: cfg.measQteId,
mnb: cfg.measNbMagId,
mcamag: cfg.measCaMagId,
mmarge: cfg.measMargePctId,
codes: codeCentraux ?? [],
token: csrfToken,
monthlyPayload,
noDateMode: !dateFilter,
qlikSettleMs,
qlikCode15MaxAttempts,
qlikArticleBatchFallbacks,
selectAllCodes: qlikSelectAllCodes,
monthDim: qlikMonthDim,
moisDimId: qlikMoisDimId,
mqteComp: qlikMeasQteCompId,
yearN,
},
)) as InPageResult;
if (!result.ok) throw new Error(`[qlik-pw] ${result.error}`);
// Résumé timing final : synthèse compacte de la sync pour audit/debug.
// Les logs détaillés par étape restent en place (timings batch/mois) ;
// ici on consolide les compteurs agrégés et la durée totale.
const totalMs = Date.now() - totalStart;
console.log(
`[qlik-pw][summary] rows=${result.size ?? 0} months=${result.months ?? 0} batches=${result.batches ?? 0} code15Retries=${result.code15Retries ?? 0} fallbackCount=${result.fallbackCount ?? 0} totalMs=${totalMs}`,
);
const wanted = codeCentraux ? new Set(codeCentraux) : null;
const out = new Map<string, NetworkMetric>();
const margeWeight = new Map<string, number>();
for (const r of result.rows ?? []) {
const code = String(r[0]).trim();
if (!code || code === "-") continue;
if (wanted && !wanted.has(code)) continue;
const ca = Number(r[1]) || 0;
const qte = Number(r[2]) || 0;
const nbMag = Number(r[3]) || 0;
const margePct = Number(r[5]) || 0;
const prev = out.get(code);
if (!prev) {
out.set(code, {
codeCentrale: code,
caReseau: ca,
qteReseau: qte,
nbMagasinsReseau: nbMag,
caParMagasinReseau: nbMag > 0 ? ca / nbMag : 0,
margePctReseau: margePct,
periode: dateFilter?.label,
});
margeWeight.set(code, ca);
continue;
}
prev.caReseau += ca;
prev.qteReseau += qte;
prev.nbMagasinsReseau = Math.max(prev.nbMagasinsReseau, nbMag);
const weight = margeWeight.get(code) ?? 0;
const nextWeight = weight + ca;
prev.margePctReseau = nextWeight > 0 ? ((prev.margePctReseau * weight) + (margePct * ca)) / nextWeight : Math.max(prev.margePctReseau, margePct);
prev.caParMagasinReseau = prev.nbMagasinsReseau > 0 ? prev.caReseau / prev.nbMagasinsReseau : 0;
margeWeight.set(code, nextWeight);
}
// Rattache le détail mensuel (par code central) au métrique agrégé.
// On produit DEUX représentations :
// - `qteByMonth` : { "YYYY-MM": qté } — format historique, dont dépend la
// colonne « Tendance / Réseau » de la Grille. Inchangé.
// - `metricsByMonth` : toutes les mesures du mois, pour la fiche produit.
const monthly = result.monthly ?? {};
for (const [code, metric] of out) {
const mm = monthly[code];
if (!mm || Object.keys(mm).length === 0) continue;
const qteByMonth: Record<string, number> = {};
for (const [mois, v] of Object.entries(mm)) {
qteByMonth[mois] = Number(v?.qte) || 0;
}
metric.qteByMonth = qteByMonth;
metric.metricsByMonth = mm;
}
// Diagnostic CONCLUSIF : combien de codes ont des mois qui VARIENT vs identiques.
// Si presque tous varient → la mesure respecte le mois (feature OK).
// Si tous identiques → la mesure Qlik ignore la sélection Date.
let nVar = 0, nFlat = 0;
let exVar: NetworkMetric | null = null, exFlat: NetworkMetric | null = null;
for (const m of out.values()) {
const bm = m.qteByMonth;
if (!bm) continue;
const vals = Object.values(bm);
if (vals.length < 2) continue;
const allEq = vals.every((v) => v === vals[0]);
if (allEq) { nFlat++; if (!exFlat) exFlat = m; }
else { nVar++; if (!exVar) exVar = m; }
}
console.log(`[qlik-pw] qteByMonth distribution: ${nVar} codes VARIENT / ${nFlat} identiques (total ${nVar + nFlat})`);
if (exVar) console.log(`[qlik-pw] exemple VARIE ${exVar.codeCentrale}:`, JSON.stringify(exVar.qteByMonth));
if (exFlat) console.log(`[qlik-pw] exemple IDENTIQUE ${exFlat.codeCentrale}:`, JSON.stringify(exFlat.qteByMonth));
console.log(`[qlik-pw] ${out.size} produits réseau (cube ${result.size})`);
// Échantillon brut pour vérifier que les 4 nouvelles mesures (cols 4-7) renvoient des valeurs
const sample = (result.rows ?? []).slice(0, 3).map((r) => ({
code: r[0], ca: r[1], qte: r[2], nbMag: r[3], caMag: r[4], margePct: r[5],
}));
console.log(`[qlik-pw] échantillon lignes:`, JSON.stringify(sample));
return out;
} finally {
await ctx.close();
}
}
+6
View File
@@ -113,8 +113,14 @@ export interface ProductRow {
caReseau?: number;
qteReseau?: number;
nbMagasinsReseau?: number;
/** CA moyen par magasin réseau (productivité normalisée présence) */
caParMagasinReseau?: number;
/** Taux de marge réseau (ratio brut Qlik, ex 0.32 = 32%) */
margePctReseau?: number;
/** Taux de présence réseau = nbMagasinsReseau / 270 */
tauxPresenceReseau?: number;
/** Quantité vendue réseau par mois : { "YYYY-MM": qté } (tendance / modal). */
qteReseauByMonth?: Record<string, number> | null;
/** Fraîcheur des données réseau (ISO) */
networkFetchedAt?: string;
}