From eeaae092821dc8d57351ee686e2470b529f5dcf1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 14:26:08 +0000 Subject: [PATCH 01/34] =?UTF-8?q?feat(produits):=20page=20de=20recherche?= =?UTF-8?q?=20produit=20+=20fiche=20360=C2=B0=20local/r=C3=A9seau?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01XR8cMbsdjXWChX8seL9NCP --- scripts/db-init.js | 3 +- src/app/(dashboard)/produits/fiche.tsx | 546 ++++++++++++++++++ src/app/(dashboard)/produits/loading.tsx | 5 + src/app/(dashboard)/produits/opportunites.tsx | 136 +++++ src/app/(dashboard)/produits/page.tsx | 57 ++ src/app/(dashboard)/produits/results.tsx | 129 +++++ src/app/(dashboard)/produits/search-bar.tsx | 79 +++ src/app/api/produits/opportunites/route.ts | 38 ++ src/app/api/qlik/sync/route.ts | 192 +++++- src/components/shared/sidebar.tsx | 3 +- src/db/schema.ts | 6 + src/features/grid/api/get-product-rows.ts | 3 +- src/features/grid/components/heatmap-grid.tsx | 205 +------ .../grid/components/network-charts.tsx | 136 +++++ .../grid/components/sync-qlik-button.tsx | 236 +------- src/features/grid/lib/months.ts | 96 +++ src/features/grid/lib/network-trend.ts | 78 +++ .../produits/api/get-produit-fiche.ts | 154 +++++ src/features/produits/lib/compare-reseau.ts | 147 +++++ src/features/produits/types.ts | 53 ++ src/features/qlik-sync/use-qlik-sync-job.ts | 223 +++++++ src/lib/pg-ff-client.ts | 546 +++++++++++++++++- src/lib/qlik-client.ts | 24 + src/lib/qlik-network-cache.ts | 14 +- src/lib/qlik-playwright.ts | 50 +- 25 files changed, 2694 insertions(+), 465 deletions(-) create mode 100644 src/app/(dashboard)/produits/fiche.tsx create mode 100644 src/app/(dashboard)/produits/loading.tsx create mode 100644 src/app/(dashboard)/produits/opportunites.tsx create mode 100644 src/app/(dashboard)/produits/page.tsx create mode 100644 src/app/(dashboard)/produits/results.tsx create mode 100644 src/app/(dashboard)/produits/search-bar.tsx create mode 100644 src/app/api/produits/opportunites/route.ts create mode 100644 src/features/grid/components/network-charts.tsx create mode 100644 src/features/grid/lib/months.ts create mode 100644 src/features/grid/lib/network-trend.ts create mode 100644 src/features/produits/api/get-produit-fiche.ts create mode 100644 src/features/produits/lib/compare-reseau.ts create mode 100644 src/features/produits/types.ts create mode 100644 src/features/qlik-sync/use-qlik-sync-job.ts diff --git a/scripts/db-init.js b/scripts/db-init.js index 9c2cecc..3fa350d 100644 --- a/scripts/db-init.js +++ b/scripts/db-init.js @@ -113,7 +113,8 @@ async function main() { 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 "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"; `); diff --git a/src/app/(dashboard)/produits/fiche.tsx b/src/app/(dashboard)/produits/fiche.tsx new file mode 100644 index 0000000..3a47948 --- /dev/null +++ b/src/app/(dashboard)/produits/fiche.tsx @@ -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 ( +
+
+

+ + {title} +

+ {action} +
+
{children}
+
+ ); +} + +function Field({ label, value, mono }: { label: string; value: React.ReactNode; mono?: boolean }) { + return ( +
+
+ {label} +
+
+ {value ?? "—"} +
+
+ ); +} + +function Stat({ label, value, hint, color }: { label: string; value: string; hint?: string; color?: string }) { + return ( +
+
+ {label} +
+
+ {value} +
+ {hint &&
{hint}
} +
+ ); +} + +// ─── 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 ( +
+ {message && ( + + {message} + + )} + +
+ ); +} + +// ─── Fiche ─────────────────────────────────────────────────────────────────── + +export function ProduitFicheView({ fiche, backQuery }: { fiche: ProduitFiche; backQuery: string }) { + const { detail, fournisseurs, stock, commandesEnCours, gammes, months, reseau } = fiche; + const [magasin, setMagasin] = useState("TOTAL"); + + const sitesDisponibles = useMemo(() => { + const sites = new Set([...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 ( +
+ {/* En-tête */} +
+
+ + + Retour aux résultats + +

+ {detail.libelle1 || "Sans libellé"} +

+

+ {detail.codein} + {detail.code_centrale && <> · centrale {detail.code_centrale}} + {fournisseurPrincipal && <> · {fournisseurPrincipal.nomfou}} +

+
+ {fournisseurPrincipal?.codefou && ( + + + Voir dans la Grille + + )} +
+ + {/* Identité */} + +
+ + + + + + + + + + + + 0 ? fmtQte(commandesEnCours) : "—"} /> +
+ + {fournisseurs.length > 0 && ( +
+
+ Fournisseurs référencés +
+
+ {fournisseurs.map((f) => ( + + {f.nomfou} + {f.principal && · principal} + + ))} +
+
+ )} + + {gammes.length > 1 && ( +
+
+ Historique de gamme +
+
+ {gammes.slice(0, 10).map((g, i) => ( + + {g.saison_libelle || g.saison_code || `Saison ${g.saison_no_id}`} + {g.gamme_code} + + ))} +
+
+ )} +
+ + {/* Stock */} + + {stock.length === 0 ? ( +

Aucune ligne de stock pour ce produit.

+ ) : ( +
+ {stock.map((s) => ( +
+
+ + {SITE_LABELS[s.site]?.nom ?? s.site} + + + {fmtQte(s.qte)} + +
+
+
Dispo : {fmtQte(s.stockdispo)}
+
Valeur : {fmtEur(s.valstock)}
+
PRMP : {s.prmp > 0 ? fmtEur(s.prmp) : "—"}
+
Dern. vente : {formatDate(s.dernierevente)}
+
Dern. réception : {formatDate(s.dernierereception)}
+
+
+ ))} +
+ )} +
+ + {/* Ventes 12 mois glissants */} + + {["TOTAL", ...sitesDisponibles].map((code) => ( + + ))} +
+ } + > +
+ + + + = 40 ? "#22c55e" : totauxAffiches.tauxMarge >= 25 ? "#f59e0b" : undefined} + /> +
+ +
+ + + + + {months.map((m) => ( + + ))} + + + + + + {months.map((m) => ( + + ))} + + + + {months.map((m) => ( + + ))} + + + + {months.map((m) => ( + + ))} + + +
+ Mois + + {formatMonthLabel(m)} +
+ Ventes + + +
+ CA (TTC) + + {serie[m]?.ca ? Math.round(serie[m].ca).toLocaleString("fr-FR") : "—"} +
+ Stock fin de mois + + {serie[m]?.stockFinMois ? Math.round(serie[m].stockFinMois).toLocaleString("fr-FR") : "—"} +
+
+ + serie[m]?.qte ?? 0)} + color="#0ea5e9" + /> + + + {/* Réseau Qlik */} + : undefined} + > + {!detail.code_centrale ? ( +

+ Ce produit n'a pas de code centrale : il n'est pas référencé centralement et n'existe donc pas dans Qlik. +

+ ) : !reseau ? ( +
+ +
+ Aucune donnée réseau en cache pour ce produit. +
+ Lancez l'extraction ci-dessus : elle ne porte que sur ce code centrale et prend quelques secondes. +
+
+
+ ) : ( + <> +
+ + + + + + = 0 ? "+" : ""}${Math.round(trend.pct * 100)} %` : "—"} + hint={trendLabel(trend.pct)} + color={TREND_COLOR[trend.direction]} + /> +
+ +

+ Période {reseau.periode ?? "12 mois"} · extrait le {formatDate(reseau.fetchedAt)} +

+ + {trend.hasData && ( +
+
+ Quantités vendues réseau, mois par mois +
+ +
+ )} + + {/* Détail mensuel complet (CA, marge, magasins) si disponible */} + {reseau.metricsByMonth && Object.keys(reseau.metricsByMonth).length > 0 && ( +
+
+ Détail mensuel réseau +
+ + + + + {months.map((m) => ( + + ))} + + + + {([ + { 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) => ( + + + {months.map((m) => { + const v = reseau.metricsByMonth?.[ffMonthToQlik(m)]?.[row.key]; + return ( + + ); + })} + + ))} + +
Mesure + {formatMonthLabel(m)} +
+ {row.label} + + {v != null ? row.fmt(v) : "—"} +
+

+ Les mois sans CA ni marge proviennent de la mesure « Quantité COMP », qui ne porte que la quantité. +

+
+ )} + + )} +
+ + {/* Comparatif local vs réseau */} + {comparatif.hasReseau && ( + +
+ + 1 ? "s" : ""} · 12 mois`} + /> + + +
+ +
+
+
+ CA par magasin · indicatif +
+
+ Nous : {fmtEur(comparatif.caLocalParMagasin)} + Réseau : {comparatif.caReseauParMagasin != null ? fmtEur(comparatif.caReseauParMagasin) : "—"} +
+
+
+
+ Taux de marge · indicatif +
+
+ Nous : {fmtDec(comparatif.tauxMargeLocal)} % + Réseau : {comparatif.tauxMargeReseau != null ? `${fmtDec(comparatif.tauxMargeReseau)} %` : "—"} +
+
+
+ +

+ L'indice compare les quantités par magasin, seule base commune fiable. + Notre CA est TTC (issu des mouvements de caisse) ; la base du CA Qlik n'est pas + documentée — montants et marges sont donc donnés à titre indicatif. +

+ + {comparatif.hasMonthly && ( +
+
+ Quantité par magasin, mois par mois +
+ 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" }, + ]} + /> +
+ )} +
+ )} + + {/* Opportunités de la même famille */} + {detail.nom_no_id != null && ( + + + + )} + + {/* Sparkline discrète en pied de fiche, cohérente avec la Grille */} + {trend.hasData && ( +
+ Tendance réseau : + +
+ )} + + ); +} diff --git a/src/app/(dashboard)/produits/loading.tsx b/src/app/(dashboard)/produits/loading.tsx new file mode 100644 index 0000000..64ecef1 --- /dev/null +++ b/src/app/(dashboard)/produits/loading.tsx @@ -0,0 +1,5 @@ +import LoadingModal from "@/components/shared/loading-modal"; + +export default function ProduitsLoading() { + return ; +} diff --git a/src/app/(dashboard)/produits/opportunites.tsx b/src/app/(dashboard)/produits/opportunites.tsx new file mode 100644 index 0000000..f1ef7e3 --- /dev/null +++ b/src/app/(dashboard)/produits/opportunites.tsx @@ -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(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(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 ( +
+

+ 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 ? +

+ + {error &&

{error}

} +
+ ); + } + + // 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 ( +

+ Aucune autre référence de cette famille n'a de données réseau en cache. + Synchronisez d'autres fournisseurs pour enrichir la comparaison. +

+ ); + } + + return ( +
+
+ + + + + + + + + + + + + {others.map((r) => { + const opportunite = r.ecart_par_magasin > 0; + return ( + + + + + + + + + ); + })} + +
ProduitFournisseurRéseau / mag.Nous / mag.ÉcartMagasins
+ + {r.libelle1 || r.codein} + + {r.fournisseur} + {fmtDec(r.qte_reseau_par_magasin)} + + {fmtDec(r.qte_locale_par_magasin)} + + {r.ecart_par_magasin > 0 ? "+" : ""}{fmtDec(r.ecart_par_magasin)} + + {fmtQte(r.nb_magasins_reseau)} +
+
+

+ É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 de notre catalogue 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. +

+
+ ); +} diff --git a/src/app/(dashboard)/produits/page.tsx b/src/app/(dashboard)/produits/page.tsx new file mode 100644 index 0000000..4bc1f1c --- /dev/null +++ b/src/app/(dashboard)/produits/page.tsx @@ -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 ( +
+
+

+ Recherche produit +

+

+ Par libellé ou par code centrale — ventes sur 12 mois glissants et données réseau Qlik. +

+
+ + {/* key : remonte le champ quand l'URL change (retour arrière, lien partagé) */} + + + {codein && !fiche && ( +
+ Aucun produit trouvé pour le code article {codein}. +
+ )} + + {fiche && } + + {!codein && ( + + )} +
+ ); +} diff --git a/src/app/(dashboard)/produits/results.tsx b/src/app/(dashboard)/produits/results.tsx new file mode 100644 index 0000000..1d9a889 --- /dev/null +++ b/src/app/(dashboard)/produits/results.tsx @@ -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 ( +
+ +

+ Recherchez un produit +

+

+ Saisissez un libellé (ex. « poêle 28 ») ou un code centrale (ex. « 10000167303 »). +

+
+ ); + } + + if (rows.length === 0) { + return ( +
+ Aucun produit ne correspond à « {query} ». +
+ ); + } + + return ( +
+

+ {rows.length} produit{rows.length > 1 ? "s" : ""} trouvé{rows.length > 1 ? "s" : ""} + {rows.length >= 50 && " (50 premiers — affinez la recherche)"} +

+ +
+ + + + + + + + + + + + + + {rows.map((r) => ( + + + + + + + + + + ))} + +
LibelléFournisseurCode articleCode centraleFamilleStockQté réseau
+ + {r.libelle1 || "—"} + + + {r.fournisseur || "—"} + + {r.codein} + + {r.code_centrale || "—"} + + {r.nomenclature || "—"} + + {fmtQte(r.stock_total)} + + {r.has_reseau ? ( + + + {fmtQte(r.qte_reseau)} + + / {r.nb_magasins_reseau} mag. + + + ) : ( + + + non synchro + + )} +
+
+
+ ); +} + +function Th({ children, align = "left" }: { children: React.ReactNode; align?: "left" | "right" }) { + return ( + + {children} + + ); +} diff --git a/src/app/(dashboard)/produits/search-bar.tsx b/src/app/(dashboard)/produits/search-bar.tsx new file mode 100644 index 0000000..2bd18bb --- /dev/null +++ b/src/app/(dashboard)/produits/search-bar.tsx @@ -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 ( +
+
+
+ + 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 && ( + + )} +
+ +
+ {tooShort && ( +

+ Saisissez au moins 3 caractères. +

+ )} +
+ ); +} diff --git a/src/app/api/produits/opportunites/route.ts b/src/app/api/produits/opportunites/route.ts new file mode 100644 index 0000000..e01ab12 --- /dev/null +++ b/src/app/api/produits/opportunites/route.ts @@ -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 }); + } +} diff --git a/src/app/api/qlik/sync/route.ts b/src/app/api/qlik/sync/route.ts index 152a927..3a075b3 100644 --- a/src/app/api/qlik/sync/route.ts +++ b/src/app/api/qlik/sync/route.ts @@ -26,7 +26,10 @@ 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; @@ -48,6 +51,33 @@ interface QlikSyncJob { */ const jobs = new Map(); +/** + * 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)}`; @@ -58,6 +88,7 @@ function publicJob(job: QlikSyncJob) { return { jobId: job.jobId, fournisseur: job.fournisseur, + codeCentrale: job.codeCentrale, status: job.status, requested: job.requested, fetched: job.fetched, @@ -72,11 +103,12 @@ function publicJob(job: QlikSyncJob) { }; } -/** État "idle" sérialisé (pas de job connu pour ce fournisseur). */ -function idleJob(fournisseur: string) { +/** É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, @@ -105,6 +137,20 @@ async function requireAdmin(): Promise { 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 { + const session = await auth(); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + return null; +} + /** * Regex de validation d'un code centrale "raisonnable". * @@ -209,34 +255,95 @@ async function runJob(job: QlikSyncJob): Promise { job.status = "success"; job.finishedAt = new Date().toISOString(); } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - console.error(`[api/qlik/sync] job=${job.jobId}`, msg); - job.status = "error"; - // Message explicite quand le serveur Qlik est saturé (OOM à l'ouverture de - // l'app) : ce n'est PAS un bug CollectFlow, l'app Qlik ne se charge même pas. - 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(); + applyJobError(job, e); } } /** - * GET /api/qlik/sync?fournisseur=XXX - * Retourne l'état courant du job pour ce fournisseur. Si aucun job n'est + * 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 { + 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; - 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 }); } const job = jobs.get(fournisseur); if (!job) { @@ -252,12 +359,59 @@ export async function GET(req: NextRequest) { * ensuite poller GET pour suivre l'avancement et être notifié de la fin. */ export async function POST(req: NextRequest) { + 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 }); } const existing = jobs.get(fournisseur); diff --git a/src/components/shared/sidebar.tsx b/src/components/shared/sidebar.tsx index d9d9dc0..eb6cc96 100644 --- a/src/components/shared/sidebar.tsx +++ b/src/components/shared/sidebar.tsx @@ -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" }, diff --git a/src/db/schema.ts b/src/db/schema.ts index 745a320..0bac293 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -125,6 +125,12 @@ export const qlikNetworkMetrics = pgTable("qlik_network_metrics", { 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(), }); diff --git a/src/features/grid/api/get-product-rows.ts b/src/features/grid/api/get-product-rows.ts index 1a8b6ca..38456e6 100644 --- a/src/features/grid/api/get-product-rows.ts +++ b/src/features/grid/api/get-product-rows.ts @@ -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; diff --git a/src/features/grid/components/heatmap-grid.tsx b/src/features/grid/components/heatmap-grid.tsx index 4f967a0..4843eff 100644 --- a/src/features/grid/components/heatmap-grid.tsx +++ b/src/features/grid/components/heatmap-grid.tsx @@ -13,7 +13,7 @@ import { RowSelectionState, } from "@tanstack/react-table"; import { useVirtualizer } from "@tanstack/react-virtual"; -import { ChevronUp, ChevronDown, ChevronsUpDown, Copy, Check, Store, SlidersHorizontal, ShoppingCart, PackageOpen, Warehouse, AlertTriangle, TrendingUp, TrendingDown, Minus } from "lucide-react"; +import { ChevronUp, ChevronDown, ChevronsUpDown, Copy, Check, Store, SlidersHorizontal, ShoppingCart, PackageOpen, Warehouse, AlertTriangle } from "lucide-react"; import { DropdownMenu, DropdownMenuCheckboxItem, @@ -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 = { - "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; @@ -238,135 +193,6 @@ interface CellDetailData { receptions: number | null; } -// ─── Tendance réseau (à partir de qteReseauByMonth) ─────────────────────────── -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; -}; - -/** - * 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%. - */ -function computeNetworkTrend(qteByMonth?: Record | 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 > 0.08 ? "up" : pct < -0.08 ? "down" : "flat"; - } else if (slope > 0) { - direction = "up"; - } - } else if (n === 2 && values[0] > 0) { - pct = (values[1] - values[0]) / values[0]; - direction = pct > 0.08 ? "up" : pct < -0.08 ? "down" : "flat"; - } - return { values, labels, direction, pct, hasData: true }; -} - -/** Seuil de "forte" variation (±25%) pour distinguer hausse/forte hausse dans l'UI. */ -const TREND_STRONG = 0.25; - -const TREND_COLOR: Record = { - up: "#22c55e", - down: "#ef4444", - flat: "#94a3b8", -}; - -/** Sparkline compacte 12 mois + flèche, teintée selon la tendance. */ -function TrendSparkline({ trend }: { trend: NetworkTrend }) { - if (!trend.hasData) return
-
; - 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 ( -
= 0 ? "+" : ""}${(pct * 100).toFixed(0)}% sur 12 mois (régression) — cliquer pour le détail` : "Tendance réseau — cliquer pour le détail"}> -
- - {n > 1 && } - {n === 1 && } - - -
- {pct != null && ( - - {pct >= 0 ? "+" : ""}{Math.round(pct * 100)}% - - )} -
- ); -} - -/** "2026-01" → "01/26" pour un axe compact. */ -function fmtMonthShort(lab: string): string { - const [y, m] = lab.split("-"); - return `${m ?? lab}/${(y ?? "").slice(2)}`; -} - -/** Courbe unique des ventes réseau mensuelles (SVG). */ -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 ( -
- - {/* ligne de base */} - - {/* aire douce sous la courbe */} - {n > 1 && } - {/* courbe */} - {n > 1 && } - {values.map((v, i) => ( - - - {/* quantité au-dessus du point */} - - {Math.round(v).toLocaleString("fr-FR")} - - {/* mois sous l'axe */} - - {fmtMonthShort(labels[i])} - - - ))} - -
- ); -} - /** 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); @@ -383,8 +209,7 @@ function NetworkMonthlyModal({ row, onClose }: { row: ProductRow; onClose: () => {row.nbMagasinsReseau != null && <> · {row.nbMagasinsReseau} magasins} {pct != null && ( <> · - {pct >= 0 ? "+" : ""}{Math.round(pct * 100)}% ·{" "} - {pct > TREND_STRONG ? "Forte hausse" : pct > 0.08 ? "Hausse" : pct < -TREND_STRONG ? "Forte baisse" : pct < -0.08 ? "Baisse" : "Stable"} + {pct >= 0 ? "+" : ""}{Math.round(pct * 100)}% · {trendLabel(pct)} )}

diff --git a/src/features/grid/components/network-charts.tsx b/src/features/grid/components/network-charts.tsx new file mode 100644 index 0000000..050061a --- /dev/null +++ b/src/features/grid/components/network-charts.tsx @@ -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
-
; + 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 ( +
= 0 ? "+" : ""}${(pct * 100).toFixed(0)}% sur 12 mois (régression) — cliquer pour le détail` : "Tendance réseau — cliquer pour le détail"}> +
+ + {n > 1 && } + {n === 1 && } + + +
+ {pct != null && ( + + {pct >= 0 ? "+" : ""}{Math.round(pct * 100)}% + + )} +
+ ); +} + +/** 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 ( +
+ + {/* ligne de base */} + + {/* aire douce sous la courbe */} + {n > 1 && } + {/* courbe */} + {n > 1 && } + {values.map((v, i) => ( + + + {/* quantité au-dessus du point */} + + {Math.round(v).toLocaleString("fr-FR")} + + {/* mois sous l'axe */} + + {fmtMonthShort(labels[i])} + + + ))} + +
+ ); +} + +/** + * 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 ( +
+
+ {series.map((s) => ( + + + {s.name} + + ))} +
+ + + {series.map((s) => { + const pts = s.values.map((v, i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`).join(" "); + return ( + + {n > 1 && ( + + )} + {s.values.map((v, i) => ( + + ))} + + ); + })} + {labels.map((lab, i) => ( + + {fmtMonthShort(lab)} + + ))} + +
+ ); +} diff --git a/src/features/grid/components/sync-qlik-button.tsx b/src/features/grid/components/sync-qlik-button.tsx index 3b1172a..59aef41 100644 --- a/src/features/grid/components/sync-qlik-button.tsx +++ b/src/features/grid/components/sync-qlik-button.tsx @@ -1,8 +1,9 @@ "use client"; -import { useCallback, useEffect, useRef, 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,24 +12,8 @@ interface SyncQlikButtonProps { lastUpdate?: string | null; } -/** État serveur renvoyé par GET/POST /api/qlik/sync */ -interface SyncJobState { - jobId: string | null; - fournisseur: 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; -} - -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"; @@ -39,55 +24,15 @@ 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. * - * UX : le POST démarre un job serveur en arrière-plan et revient immédiatement. - * Le composant poll ensuite GET pour suivre l'avancement. Au mount, on appelle - * GET pour reprendre l'état d'un éventuel job en cours (par ex. après reload de - * la page) et continuer à afficher le spinner correctement. + * 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" | "running" | "success" | "error">("idle"); - const [message, setMessage] = useState(""); const router = useRouter(); const pathname = usePathname(); const searchParams = useSearchParams(); - const pollTimerRef = useRef | null>(null); - /** true tant qu'on doit suivre le job courant (false après succès/erreur traité). */ - const trackingRef = useRef(false); - - const clearPollTimer = useCallback(() => { - if (pollTimerRef.current) { - clearTimeout(pollTimerRef.current); - pollTimerRef.current = null; - } - }, []); - - /** - * Met à jour le label visible à droite du bouton selon l'état courant. - */ - const applyJobToUi = useCallback((j: SyncJobState | null) => { - if (!j) return; - if (j.status === "running") { - setStatus("running"); - // Texte informatif pendant l'extraction - const requestedTxt = j.requested > 0 ? ` (${j.requested} codes)` : ""; - 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"); - } - }, []); - - /** - * Force le refresh de la grille (avec cache-buster) et de la barre de résumé. - * C'est l'équivalent de ce que faisait l'ancien code après un POST réussi. - */ + /** 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())); @@ -95,163 +40,10 @@ export function SyncQlikButton({ codeFournisseur, lastUpdate }: SyncQlikButtonPr router.refresh(); }, [pathname, router, searchParams]); - /** - * Polling GET pendant qu'un job tourne. S'arrête tout seul quand le job - * passe en success/error, ou après un timeout de sécurité. - */ - const pollOnce = useCallback(async (): Promise => { - if (!codeFournisseur) return false; - if (!trackingRef.current) return false; - try { - const res = await fetch( - `/api/qlik/sync?fournisseur=${encodeURIComponent(codeFournisseur)}`, - { method: "GET", cache: "no-store" }, - ); - if (!res.ok) { - // En cas d'erreur HTTP transitoire on retente au tour suivant. - return true; - } - const data: SyncJobState = await res.json(); - applyJobToUi(data); - - if (data.status === "running") { - // Continue à poller - return true; - } - if (data.status === "success" || data.status === "error") { - // Terminé : on rafraîchit la grille, on coupe le polling. - trackingRef.current = false; - if (data.status === "success") { - refreshGrid(); - } - // Petit délai pour laisser l'utilisateur lire le message vert/rouge - setTimeout(() => { - setStatus((cur) => (cur === "success" || cur === "error" ? "idle" : cur)); - }, 4000); - return false; - } - // idle : rien à faire - trackingRef.current = false; - return false; - } catch { - // Erreur réseau transitoire, on laisse le polling réessayer. - return true; - } - }, [applyJobToUi, codeFournisseur, refreshGrid]); - - /** - * Boucle de polling : s'auto-réarme tant que pollOnce indique qu'il faut - * continuer. Délai un peu plus long que d'habitude (3 s) pour ne pas - * marteler le serveur pendant une extraction qui dure plusieurs minutes. - */ - const startPolling = useCallback(() => { - clearPollTimer(); - const tick = async () => { - const keepGoing = await pollOnce(); - if (keepGoing && trackingRef.current) { - pollTimerRef.current = setTimeout(tick, 3000); - } else { - pollTimerRef.current = null; - } - }; - // Premier appel quasi immédiat - pollTimerRef.current = setTimeout(tick, 200); - }, [clearPollTimer, pollOnce]); - - /** - * Au mount : si on a un fournisseur, on interroge l'API pour savoir si - * un job tourne déjà (ex. reload de la page pendant l'extraction). Si - * oui, on reprend l'état et on relance le polling. - */ - useEffect(() => { - if (!codeFournisseur) return; - let cancelled = false; - trackingRef.current = false; - clearPollTimer(); - (async () => { - try { - const res = await fetch( - `/api/qlik/sync?fournisseur=${encodeURIComponent(codeFournisseur)}`, - { method: "GET", cache: "no-store" }, - ); - if (cancelled) return; - if (!res.ok) return; - const data: SyncJobState = 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é en amont (avant le mount) : 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 d'afficher le bouton */ - } - })(); - return () => { - cancelled = true; - trackingRef.current = false; - clearPollTimer(); - }; - // On veut interroger une seule fois au mount (et quand le fournisseur change). - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [codeFournisseur, clearPollTimer]); - - /** - * Cleanup : annule un timer de polling éventuel au démontage. - */ - useEffect(() => { - return () => { - clearPollTimer(); - }; - }, [clearPollTimer]); - - const handleSync = async () => { - if (!codeFournisseur) { - setStatus("error"); - setMessage("Aucun fournisseur sélectionné"); - 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?fournisseur=${encodeURIComponent(codeFournisseur)}`, - { method: "POST" }, - ); - const data: SyncJobState & { success?: boolean } = await res.json(); - if (!res.ok || (data as { success?: boolean }).success === false) { - throw new Error(data.error || `HTTP ${res.status}`); - } - // Si le serveur nous dit que c'est déjà en cours (jobId existant), - // on reprend juste le suivi. - if (data.status === "running" && data.jobId) { - trackingRef.current = true; - applyJobToUi(data); - startPolling(); - return; - } - // Status terminal direct (cas rare : 0 codes → success immédiat) - applyJobToUi(data); - if (data.status === "success") { - refreshGrid(); - } - setTimeout(() => setStatus("idle"), 4000); - } catch (e) { - setStatus("error"); - setMessage(e instanceof Error ? e.message : String(e)); - setTimeout(() => setStatus("idle"), 6000); - } - }; + const { status, message, start } = useQlikSyncJob({ + target: { mode: "fournisseur", fournisseur: codeFournisseur }, + onSuccess: refreshGrid, + }); return (
@@ -264,13 +56,13 @@ export function SyncQlikButton({ codeFournisseur, lastUpdate }: SyncQlikButtonPr ? message : status === "running" ? (message || `Extraction Qlik…`) - : `MAJ Qlik · ${formatDate(lastUpdate)}`} + : `MAJ Qlik · ${formatLastUpdate(lastUpdate)}`}
); @@ -111,7 +136,7 @@ function SyncProduitButton({ codeCentrale }: { codeCentrale: string }) { // ─── Fiche ─────────────────────────────────────────────────────────────────── export function ProduitFicheView({ fiche, backQuery }: { fiche: ProduitFiche; backQuery: string }) { - const { detail, fournisseurs, stock, commandesEnCours, gammes, months, reseau } = fiche; + const { detail, codeCentrale, libelleReseau, fournisseurReseau, fournisseurs, stock, commandesEnCours, gammes, months, reseau } = fiche; const [magasin, setMagasin] = useState("TOTAL"); const sitesDisponibles = useMemo(() => { @@ -128,6 +153,14 @@ export function ProduitFicheView({ fiche, backQuery }: { fiche: ProduitFiche; ba const verdict = indiceVerdict(comparatif.indiceQte); const fournisseurPrincipal = fournisseurs.find((f) => f.principal) ?? fournisseurs[0]; + /** Nom de fournisseur à afficher : catalogue Nancy d'abord, sinon champ Qlik. */ + const fournisseurAffiche = fournisseurPrincipal?.nomfou || fournisseurReseau || null; + const titre = detail?.libelle1 || libelleReseau || codeCentrale || "Sans libellé"; + + // Indicateurs réseau dérivés (le cache ne stocke que les mesures brutes Qlik). + const qteParMagasinReseau = reseau && reseau.nbMagasinsReseau > 0 ? reseau.qteReseau / reseau.nbMagasinsReseau : null; + const prixMoyenReseau = reseau && reseau.qteReseau > 0 ? reseau.caReseau / reseau.qteReseau : null; + const margeReseau = normalizeMargePct(reseau?.margePctReseau); return (
@@ -143,12 +176,12 @@ export function ProduitFicheView({ fiche, backQuery }: { fiche: ProduitFiche; ba Retour aux résultats

- {detail.libelle1 || "Sans libellé"} + {titre}

- {detail.codein} - {detail.code_centrale && <> · centrale {detail.code_centrale}} - {fournisseurPrincipal && <> · {fournisseurPrincipal.nomfou}} + {detail && <>{detail.codein}{" · "}} + {codeCentrale && <>centrale {codeCentrale}} + {fournisseurAffiche && <> · {fournisseurAffiche}}

{fournisseurPrincipal?.codefou && ( @@ -163,197 +196,28 @@ export function ProduitFicheView({ fiche, backQuery }: { fiche: ProduitFiche; ba )} - {/* Identité */} - -
- - - - - - - - - - - - 0 ? fmtQte(commandesEnCours) : "—"} /> + {!detail && ( +
+ +
+ Ce produit est travaillé par le réseau mais n'est pas référencé dans notre catalogue. +
+ Seules les données réseau Qlik sont disponibles : ni stock, ni ventes, ni gamme chez nous. +
+
+ )} - {fournisseurs.length > 0 && ( -
-
- Fournisseurs référencés -
-
- {fournisseurs.map((f) => ( - - {f.nomfou} - {f.principal && · principal} - - ))} -
-
- )} - - {gammes.length > 1 && ( -
-
- Historique de gamme -
-
- {gammes.slice(0, 10).map((g, i) => ( - - {g.saison_libelle || g.saison_code || `Saison ${g.saison_no_id}`} - {g.gamme_code} - - ))} -
-
- )} - - - {/* Stock */} - - {stock.length === 0 ? ( -

Aucune ligne de stock pour ce produit.

- ) : ( -
- {stock.map((s) => ( -
-
- - {SITE_LABELS[s.site]?.nom ?? s.site} - - - {fmtQte(s.qte)} - -
-
-
Dispo : {fmtQte(s.stockdispo)}
-
Valeur : {fmtEur(s.valstock)}
-
PRMP : {s.prmp > 0 ? fmtEur(s.prmp) : "—"}
-
Dern. vente : {formatDate(s.dernierevente)}
-
Dern. réception : {formatDate(s.dernierereception)}
-
-
- ))} -
- )} -
- - {/* Ventes 12 mois glissants */} + {/* ─── Réseau Qlik — la donnée qui motive la recherche ─────────── */} - {["TOTAL", ...sitesDisponibles].map((code) => ( - - ))} -
- } - > -
- - - - = 40 ? "#22c55e" : totauxAffiches.tauxMarge >= 25 ? "#f59e0b" : undefined} - /> -
- -
- - - - - {months.map((m) => ( - - ))} - - - - - - {months.map((m) => ( - - ))} - - - - {months.map((m) => ( - - ))} - - - - {months.map((m) => ( - - ))} - - -
- Mois - - {formatMonthLabel(m)} -
- Ventes - - -
- CA (TTC) - - {serie[m]?.ca ? Math.round(serie[m].ca).toLocaleString("fr-FR") : "—"} -
- Stock fin de mois - - {serie[m]?.stockFinMois ? Math.round(serie[m].stockFinMois).toLocaleString("fr-FR") : "—"} -
-
- - serie[m]?.qte ?? 0)} - color="#0ea5e9" - /> -
- - {/* Réseau Qlik */} - : undefined} + action={codeCentrale ? : undefined} > - {!detail.code_centrale ? ( + {!codeCentrale ? (

Ce produit n'a pas de code centrale : il n'est pas référencé centralement et n'existe donc pas dans Qlik.

@@ -369,18 +233,29 @@ export function ProduitFicheView({ fiche, backQuery }: { fiche: ProduitFiche; ba ) : ( <> -
- - +
+ + + + = 40 ? "#22c55e" : margeReseau != null && margeReseau >= 25 ? "#f59e0b" : undefined} />
+
+ + +
+

- Période {reseau.periode ?? "12 mois"} · extrait le {formatDate(reseau.fetchedAt)} + Période {reseau.periode ?? "12 mois"} · extrait le {formatDate(reseau.fetchedAt)} · + {" "}mois en cours exclu (incomplet).

{trend.hasData && ( @@ -421,18 +302,14 @@ export function ProduitFicheView({ fiche, backQuery }: { fiche: ProduitFiche; ba - {([ - { 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) => ( - + {LIGNES_MENSUELLES_RESEAU.map((row) => ( + {row.label} {months.map((m) => { - const v = reseau.metricsByMonth?.[ffMonthToQlik(m)]?.[row.key]; + const cell = reseau.metricsByMonth?.[ffMonthToQlik(m)]; + const v = cell ? row.get(cell) : undefined; return ( {v != null ? row.fmt(v) : "—"} @@ -452,8 +329,222 @@ export function ProduitFicheView({ fiche, backQuery }: { fiche: ProduitFiche; ba )} - {/* Comparatif local vs réseau */} - {comparatif.hasReseau && ( + {/* ─── Fournisseur ─────────────────────────────────────────────── */} + + {fournisseurs.length === 0 ? ( +

+ {fournisseurReseau + ? <>Aucun fournisseur référencé chez nous. Côté réseau, Qlik indique {fournisseurReseau}. + : "Aucun fournisseur référencé pour ce produit."} +

+ ) : ( +
+ {fournisseurs.map((f) => ( +
+
+
+ {f.nomfou} + {f.principal && · principal} +
+
{f.codefou}
+
+
+ Réf. {f.reference || "—"} + PCB {f.pcb != null ? fmtQte(f.pcb) : "—"} + + Grille + +
+
+ ))} + {fournisseurReseau && ( +

+ Fournisseur côté Qlik : {fournisseurReseau} +

+ )} +
+ )} +
+ + {/* ─── Identité catalogue (produits référencés uniquement) ──────── */} + {detail && ( + +
+ + + + + + + + + + + + + 0 ? fmtQte(commandesEnCours) : "—"} /> +
+ + {gammes.length > 1 && ( +
+
+ Historique de gamme +
+
+ {gammes.slice(0, 10).map((g, i) => ( + + {g.saison_libelle || g.saison_code || `Saison ${g.saison_no_id}`} + {g.gamme_code} + + ))} +
+
+ )} +
+ )} + + {/* ─── Stock ───────────────────────────────────────────────────── */} + {detail && ( + + {stock.length === 0 ? ( +

Aucune ligne de stock pour ce produit.

+ ) : ( +
+ {stock.map((s) => ( +
+
+ + {SITE_LABELS[s.site]?.nom ?? s.site} + + + {fmtQte(s.qte)} + +
+
+
Dispo : {fmtQte(s.stockdispo)}
+
Valeur : {fmtEur(s.valstock)}
+
PRMP : {s.prmp > 0 ? fmtEur2(s.prmp) : "—"}
+
Dern. vente : {formatDate(s.dernierevente)}
+
Dern. réception : {formatDate(s.dernierereception)}
+
+
+ ))} +
+ )} +
+ )} + + {/* ─── Nos ventes, 12 mois glissants ───────────────────────────── */} + {detail && ( + + {["TOTAL", ...sitesDisponibles].map((code) => ( + + ))} +
+ } + > +
+ + + + = 40 ? "#22c55e" : totauxAffiches.tauxMarge >= 25 ? "#f59e0b" : undefined} + /> +
+ +
+ + + + + {months.map((m) => ( + + ))} + + + + + + {months.map((m) => ( + + ))} + + + + {months.map((m) => ( + + ))} + + + + {months.map((m) => ( + + ))} + + +
+ Mois + + {formatMonthLabel(m)} +
+ Ventes + + +
+ CA (TTC) + + {serie[m]?.ca ? Math.round(serie[m].ca).toLocaleString("fr-FR") : "—"} +
+ Stock fin de mois + + {serie[m]?.stockFinMois ? Math.round(serie[m].stockFinMois).toLocaleString("fr-FR") : "—"} +
+
+ + serie[m]?.qte ?? 0)} + color="#0ea5e9" + /> +
+ )} + + {/* ─── Comparatif local vs réseau ──────────────────────────────── */} + {detail && comparatif.hasReseau && (
)} - {/* Opportunités de la même famille */} - {detail.nom_no_id != null && ( + {/* ─── Opportunités de la même famille ─────────────────────────── */} + {detail?.nom_no_id != null && ( ; + searchParams: Promise<{ q?: string; codein?: string; cc?: string }>; }) { const searchParams = await props.searchParams; const q = (searchParams.q ?? "").trim(); const codein = (searchParams.codein ?? "").trim(); + const codeCentrale = (searchParams.cc ?? "").trim(); - const fiche = codein ? await getProduitFiche(codein) : 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) : []; + const fiche = codein + ? await getProduitFiche(codein) + : codeCentrale + ? await getProduitFicheByCodeCentrale(codeCentrale) + : null; + const ficheDemandee = Boolean(codein || codeCentrale); return (
@@ -31,27 +36,34 @@ export default async function ProduitsPage(props: { Recherche produit

- Par libellé ou par code centrale — ventes sur 12 mois glissants et données réseau Qlik. + Recherche dans Qlik Sense (réseau) puis rapprochement avec notre catalogue — + performance réseau et ventes locales sur 12 mois glissants.

{/* key : remonte le champ quand l'URL change (retour arrière, lien partagé) */} - {codein && !fiche && ( + {ficheDemandee && !fiche && (
- Aucun produit trouvé pour le code article {codein}. + Aucun produit trouvé pour{" "} + {codein + ? <>le code article {codein} + : <>le code centrale {codeCentrale}}. + {codeCentrale && !codein && ( +
+ Ce code n'est ni dans notre catalogue, ni dans le cache réseau — relancez la recherche. +
+ )}
)} {fiche && } - {!codein && ( - - )} + {!ficheDemandee && }
); } diff --git a/src/app/(dashboard)/produits/results.tsx b/src/app/(dashboard)/produits/results.tsx index 1d9a889..c9b1ec1 100644 --- a/src/app/(dashboard)/produits/results.tsx +++ b/src/app/(dashboard)/produits/results.tsx @@ -1,13 +1,67 @@ +"use client"; + +import { useEffect, useState } from "react"; import Link from "next/link"; -import { PackageSearch, Wifi, WifiOff } from "lucide-react"; -import type { PgProduitSearchRow } from "@/lib/pg-ff-client"; +import { PackageSearch, Loader2, RefreshCw, AlertTriangle, Database, Globe } from "lucide-react"; +import { TrendSparkline } from "@/features/grid/components/network-charts"; +import { computeNetworkTrend, NB_MAGASINS_RESEAU } from "@/features/grid/lib/network-trend"; +import type { ProduitRechercheResultat, ProduitRechercheRow } from "@/features/produits/types"; -function fmtQte(v: number): string { - return new Intl.NumberFormat("fr-FR", { 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); +const fmtEur = (v: number) => + new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 }).format(v); +const fmtEur2 = (v: number) => + new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 2 }).format(v); + +/** + * Résultats de recherche produit. + * + * La recherche interroge **Qlik d'abord** (`/api/produits/search`) : plusieurs + * secondes, parfois plus. On la lance donc côté client avec un état de + * chargement explicite, plutôt que de bloquer le rendu serveur de la page. + */ +export function ProduitResults({ query }: { query: string }) { + // `relance` mémorise la dernière demande explicite de rafraîchissement, pour + // ne forcer le contournement du cache serveur que sur CE terme-là. + const [relance, setRelance] = useState<{ n: number; q: string }>({ n: 0, q: "" }); + const [resultat, setResultat] = useState<{ + cle: string; + data: ProduitRechercheResultat | null; + error: string | null; + } | null>(null); + + const force = relance.n > 0 && relance.q === query; + const cle = `${query}|${force ? relance.n : 0}`; + const tropCourt = query.trim().length < 3; + + // L'état de chargement est **dérivé** (résultat pas encore aligné sur la + // requête courante) plutôt que posé en début d'effet : appeler setState + // synchronement dans un effet provoque un rendu en cascade. + const loading = !tropCourt && resultat?.cle !== cle; + + useEffect(() => { + if (tropCourt) return; + let annule = false; + const url = `/api/produits/search?q=${encodeURIComponent(query)}${force ? "&force=1" : ""}`; + fetch(url, { cache: "no-store" }) + .then(async (res) => { + const json = await res.json(); + if (!res.ok) throw new Error(json.error || `HTTP ${res.status}`); + return json as ProduitRechercheResultat; + }) + .then((data) => { if (!annule) setResultat({ cle, data, error: null }); }) + .catch((e) => { + if (!annule) setResultat({ cle, data: null, error: e instanceof Error ? e.message : String(e) }); + }); + return () => { annule = true; }; + }, [cle, query, force, tropCourt]); + + const relancer = () => setRelance((r) => ({ n: r.n + 1, q: query })); + const data = resultat?.cle === cle ? resultat.data : null; + const error = resultat?.cle === cle ? resultat.error : null; -/** Liste des résultats de recherche. */ -export function ProduitResults({ rows, query }: { rows: PgProduitSearchRow[]; query: string }) { if (!query) { return (

- Recherchez un produit + Recherchez un produit dans le réseau

-

+

+ La recherche interroge Qlik Sense (les ~{NB_MAGASINS_RESEAU} magasins du réseau), + puis rapproche chaque produit trouvé de notre catalogue. Saisissez un libellé (ex. « poêle 28 ») ou un code centrale (ex. « 10000167303 »).

); } - if (rows.length === 0) { + if (loading) { return (
- Aucun produit ne correspond à « {query} ». + +

+ Interrogation de Qlik Sense… +

+

+ Recherche des articles du réseau puis extraction de leurs ventes sur 12 mois glissants. + Cela peut prendre jusqu'à une minute. +

); } - return ( -
-

- {rows.length} produit{rows.length > 1 ? "s" : ""} trouvé{rows.length > 1 ? "s" : ""} - {rows.length >= 50 && " (50 premiers — affinez la recherche)"} -

+ if (error) { + return ( +
+
+ +
+

+ La recherche a échoué +

+

{error}

+ +
+
+
+ ); + } -
- - - - - - - - - - - - - - {rows.map((r) => ( - - - - - - - - - - ))} - -
LibelléFournisseurCode articleCode centraleFamilleStockQté réseau
+ if (!data) return null; + + const { rows, source, qlikError, tronque, locauxHorsReseau, dureeMs } = data; + + return ( +
+
+

+ {source === "qlik" ? ( + + ) : ( + + )} + {rows.length} produit{rows.length > 1 ? "s" : ""} + {source === "qlik" ? " trouvé(s) dans Qlik" : " trouvé(s) dans le catalogue local"} + {tronque && " (liste tronquée — affinez la recherche)"} + {" · "}{(dureeMs / 1000).toFixed(1)} s +

+ +
+ + {source === "db" && ( +
+ + + Qlik n'a pas pu être interrogé, la recherche s'est repliée sur le catalogue local + (les chiffres réseau affichés viennent du dernier cache). + {qlikError && {qlikError}} + +
+ )} + + {source === "qlik" && qlikError && ( +
+ Articles trouvés, mais l'extraction des mesures réseau a échoué : {qlikError} +
+ )} + + {rows.length === 0 ? ( +
+ Aucun article du réseau ne correspond à « {query} ». +
+ ) : ( + + )} + + {locauxHorsReseau.length > 0 && ( +
+ + {locauxHorsReseau.length} produit{locauxHorsReseau.length > 1 ? "s" : ""} de notre catalogue + sans correspondance réseau (pas de code centrale, ou non vendu par le réseau) + +
+
    + {locauxHorsReseau.slice(0, 30).map((l) => ( +
  • - {r.libelle1 || "—"} + {l.libelle1 || l.codein} -
- {r.fournisseur || "—"} - - {r.codein} - - {r.code_centrale || "—"} - - {r.nomenclature || "—"} - - {fmtQte(r.stock_total)} - - {r.has_reseau ? ( - - - {fmtQte(r.qte_reseau)} - - / {r.nb_magasins_reseau} mag. - - - ) : ( - - - non synchro - - )} -
-
+ + {" · "}{l.fournisseur}{" · "}{l.codein} + + + ))} + +
+ + )}
); } -function Th({ children, align = "left" }: { children: React.ReactNode; align?: "left" | "right" }) { +function ResultTable({ rows, query }: { rows: ProduitRechercheRow[]; query: string }) { + return ( +
+ + + + + + + + + + + + + + + + + + {rows.map((r) => { + const trend = computeNetworkTrend(r.qteByMonth); + const href = r.codein + ? `/produits?codein=${encodeURIComponent(r.codein)}&q=${encodeURIComponent(query)}` + : `/produits?cc=${encodeURIComponent(r.codeCentrale)}&q=${encodeURIComponent(query)}`; + return ( + + + + + + + + + + + + + + ); + })} + +
LibelléFournisseurCode centraleMagasinsQté réseauQté / mag.Prix moyenCA / mag.Marge %TendanceNous
+ + {r.libelle || "—"} + + {r.nomenclature && ( +
{r.nomenclature}
+ )} +
+ {r.fournisseur || "—"} + + {r.codeCentrale} + + {fmtQte(r.nbMagasinsReseau)} + + {fmtDec(r.tauxPresence * 100, 0)} % + + + {fmtQte(r.qteReseau)} + + {fmtDec(r.qteParMagasinReseau)} + + {r.prixMoyenReseau != null ? fmtEur2(r.prixMoyenReseau) : "—"} + + {r.caParMagasinReseau > 0 ? fmtEur(r.caParMagasinReseau) : "—"} + + {r.margePctReseau != null ? `${fmtDec(r.margePctReseau)} %` : "—"} + + {trend.hasData ? : —} + + {r.codein ? ( + + stock {fmtQte(r.stockLocal ?? 0)} + + ) : ( + + non référencé + + )} +
+
+ ); +} + +function Th({ children, align = "left" }: { children: React.ReactNode; align?: "left" | "right" | "center" }) { + // Classes littérales : Tailwind ne génère pas les classes construites dynamiquement. + const alignClass = align === "right" ? "text-right" : align === "center" ? "text-center" : "text-left"; return ( {children} diff --git a/src/app/(dashboard)/produits/search-bar.tsx b/src/app/(dashboard)/produits/search-bar.tsx index 2bd18bb..7d2aecd 100644 --- a/src/app/(dashboard)/produits/search-bar.tsx +++ b/src/app/(dashboard)/produits/search-bar.tsx @@ -7,9 +7,10 @@ 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`. + * Soumission **explicite** (Entrée ou bouton) et non à la frappe : chaque + * recherche déclenche deux allers-retours Qlik Sense (recherche des articles + * puis extraction de leurs mesures sur 12 mois), soit plusieurs secondes. + * Minimum 3 caractères. * * L'appelant passe `key={q}` pour que le champ se resynchronise sur l'URL * (retour arrière, lien partagé) par remontage, plutôt que par un effet qui diff --git a/src/app/api/produits/search/route.ts b/src/app/api/produits/search/route.ts new file mode 100644 index 0000000..06c7c45 --- /dev/null +++ b/src/app/api/produits/search/route.ts @@ -0,0 +1,43 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; +import { rechercherProduits } from "@/features/produits/api/search-produits"; + +// L'extraction Qlik (Playwright + Engine) prend plusieurs secondes, parfois +// plus d'une minute quand le serveur Qlik est chargé. +export const maxDuration = 300; +// Playwright/Chromium exige le runtime Node.js (pas edge). +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +/** + * GET /api/produits/search?q=[&force=1] + * + * Recherche produit **Qlik d'abord** : les articles du réseau qui correspondent + * au terme, leurs métriques réseau sur 12 mois glissants, puis le rapprochement + * avec le catalogue FF Nancy. + * + * `force=1` ignore le cache mémoire de 10 min (bouton « relancer »). + * + * 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 q = (req.nextUrl.searchParams.get("q") ?? "").trim(); + if (q.length < 3) { + return NextResponse.json({ error: "Saisissez au moins 3 caractères" }, { status: 400 }); + } + const force = req.nextUrl.searchParams.get("force") === "1"; + + try { + const result = await rechercherProduits(q, { force }); + return NextResponse.json({ success: true, ...result }); + } catch (e) { + const message = (e as Error).message ?? String(e); + console.error("[api/produits/search]", message.slice(0, 300)); + return NextResponse.json({ error: message.slice(0, 300) }, { status: 500 }); + } +} diff --git a/src/db/schema.ts b/src/db/schema.ts index 0bac293..5a60cdb 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -131,6 +131,15 @@ export const qlikNetworkMetrics = pgTable("qlik_network_metrics", { * « Tendance / Réseau » de la Grille. */ metricsByMonth: jsonb("metrics_by_month"), + /** + * Libellé de l'article tel que Qlik le renvoie. Renseigné par la recherche + * produit (qui interroge la dimension libellé) ; la sync fournisseur ne + * l'écrase pas. Permet d'afficher une fiche pour un produit réseau que le + * catalogue Nancy ne référence pas. + */ + libelleReseau: varchar("libelle_reseau", { length: 255 }), + /** Fournisseur de l'article côté Qlik, même origine que `libelleReseau`. */ + fournisseurReseau: varchar("fournisseur_reseau", { length: 255 }), /** Dernière synchro depuis Qlik */ fetchedAt: timestamp("fetched_at").defaultNow(), }); diff --git a/src/features/grid/lib/network-trend.ts b/src/features/grid/lib/network-trend.ts index e57454a..b3d8646 100644 --- a/src/features/grid/lib/network-trend.ts +++ b/src/features/grid/lib/network-trend.ts @@ -23,6 +23,24 @@ export type NetworkTrend = { /** Seuil de "forte" variation (±25%) pour distinguer hausse/forte hausse dans l'UI. */ export const TREND_STRONG = 0.25; +/** + * Les 12 mois complets glissants au format Qlik `"YYYY-MM"`, du plus ancien au + * plus récent. **Le mois en cours est exclu** : il est partiel, l'intégrer + * écraserait systématiquement la tendance vers le bas. + * + * Même fenêtre que `getLast12Months()` (clés FF `"YYYYMM"`) et que + * `buildGridNetworkQlikDateFilter()` (extraction Qlik) — les trois doivent + * rester alignés. + */ +export function buildRolling12QlikMonths(now: Date = new Date()): string[] { + const months: string[] = []; + for (let i = 12; i >= 1; i--) { + const d = new Date(now.getFullYear(), now.getMonth() - i, 1); + months.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`); + } + return months; +} + /** Seuil au-delà duquel on ne considère plus la tendance comme stable (±8%). */ export const TREND_FLAT = 0.08; @@ -33,17 +51,42 @@ export const TREND_COLOR: Record = { }; /** - * Tendance réseau par RÉGRESSION LINÉAIRE (moindres carrés) sur les 12 derniers mois. + * Tendance réseau par RÉGRESSION LINÉAIRE (moindres carrés) sur les **12 mois + * complets glissants, mois en cours exclu**. + * + * La fenêtre est reconstruite à partir de la date du jour (et non des clés + * présentes dans `qteByMonth`) : c'est le seul moyen de garantir une tendance + * réellement glissante. Deux conséquences voulues : + * - un mois en cours (partiel) ou un mois plus vieux que 12 mois présent dans + * le cache est **ignoré** — sinon la pente est faussée par un mois tronqué ; + * - un mois de la fenêtre **absent** du cache vaut 0 : côté Qlik, un produit + * non vendu sur un mois ne produit tout simplement pas de ligne. + * + * Seule exception au remplissage par zéro : les mois **antérieurs au premier + * mois réellement extrait**. Ils traduisent une extraction plus courte que 12 + * mois (`QLIK_SYNC_MONTHS_BACK`), pas une absence de ventes — les inventer à 0 + * simulerait une croissance qui n'existe pas. + * * 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 | null): NetworkTrend { +export function computeNetworkTrend( + qteByMonth?: Record | null, + now: Date = new Date(), +): 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 presents = Object.keys(qteByMonth).sort(); // "YYYY-MM" trie chronologiquement + if (presents.length === 0) return empty; + + const fenetre = buildRolling12QlikMonths(now); + // Aucun mois extrait ne tombe dans la fenêtre glissante (cache périmé) : + // rien de fiable à tracer. + const premierMoisExtrait = presents[0]; + const labels = fenetre.filter((m) => m >= premierMoisExtrait); + if (labels.length === 0 || !labels.some((m) => qteByMonth[m] != null)) return empty; + const values = labels.map((l) => Number(qteByMonth[l]) || 0); const n = values.length; let pct: number | null = null; diff --git a/src/features/produits/api/get-produit-fiche.ts b/src/features/produits/api/get-produit-fiche.ts index b75eaec..b434ebc 100644 --- a/src/features/produits/api/get-produit-fiche.ts +++ b/src/features/produits/api/get-produit-fiche.ts @@ -2,6 +2,7 @@ import "server-only"; import { pgGetProduitDetail, + pgGetCodeinByCodeCentrale, pgGetFournisseursByCodein, pgGetStockByCodein, pgGetCommandesByCodein, @@ -34,6 +35,55 @@ function totauxFrom(rows: ProduitMois[]): ProduitTotaux { return { qte, ca, marge, tauxMarge: ca > 0 ? (marge / ca) * 100 : 0 }; } +/** + * Assemble la fiche d'un produit **à partir de son code centrale** : c'est la + * clé du réseau Qlik, seule identité disponible pour un produit que le réseau + * travaille mais que Nancy ne référence pas. + * + * Si le catalogue Nancy connaît ce code centrale, on rattache toute la partie + * locale (identité, fournisseurs, stock, gammes, 12 mois glissants de ventes). + * Sinon la fiche reste purement réseau, ce qui est un cas parfaitement normal + * depuis la recherche Qlik. + * + * Renvoie `null` uniquement si ni Qlik ni le catalogue ne connaissent le code. + */ +export async function getProduitFicheByCodeCentrale(codeCentrale: string): Promise { + const cleaned = codeCentrale.trim(); + if (!cleaned) return null; + + const codein = await pgGetCodeinByCodeCentrale(cleaned); + if (codein) return getProduitFiche(codein); + + // Produit réseau pur : aucune donnée locale à assembler. + const map = await getNetworkMetricsByCodeCentrale([cleaned]); + const reseau = map.get(cleaned) ?? null; + if (!reseau) return null; + + const { dateDebut, dateFin } = buildLast12MonthsRange(); + const months = buildMonthKeys(); + const mensuelTotal: Record = {}; + for (const m of months) mensuelTotal[m] = emptyMois(m); + + return { + detail: null, + codeCentrale: cleaned, + libelleReseau: reseau.libelleReseau ?? "", + fournisseurReseau: reseau.fournisseurReseau ?? "", + fournisseurs: [], + stock: [], + commandesEnCours: 0, + gammes: [], + months, + mensuelTotal, + mensuelParSite: {}, + totaux: totauxFrom(months.map((m) => mensuelTotal[m])), + totauxParSite: {}, + sitesActifs: [], + reseau, + periode: { dateDebut, dateFin }, + }; +} + /** * Assemble la fiche complète d'un produit : identité, fournisseurs, stock, * gammes, 12 mois glissants de ventes (total et par magasin) et métriques @@ -138,6 +188,9 @@ export async function getProduitFiche(codein: string): Promise(); + +/** Recherches en cours, pour ne pas lancer deux extractions identiques en parallèle. */ +const enCours = new Map>(); + +function cacheKey(term: string): string { + return term.trim().toLowerCase().replace(/\s+/g, " "); +} + +/** Purge les entrées expirées (le volume reste faible, un balayage suffit). */ +function purgeCache(): void { + const now = Date.now(); + for (const [k, v] of cache) { + if (now - v.at > CACHE_TTL_MS) cache.delete(k); + } +} + +export async function rechercherProduits( + term: string, + options: { force?: boolean } = {}, +): Promise { + const cleaned = term.trim(); + if (cleaned.length < 3) { + return { + rows: [], source: "qlik", qlikError: null, tronque: false, + champUtilise: null, locauxHorsReseau: [], dureeMs: 0, + }; + } + + const key = cacheKey(cleaned); + purgeCache(); + if (!options.force) { + const hit = cache.get(key); + if (hit) { + console.log(`[produits/search] "${cleaned}" — servi depuis le cache mémoire`); + return hit.result; + } + const running = enCours.get(key); + if (running) return running; + } + + const p = executerRecherche(cleaned).finally(() => enCours.delete(key)); + enCours.set(key, p); + const result = await p; + cache.set(key, { at: Date.now(), result }); + return result; +} + +async function executerRecherche(term: string): Promise { + const started = Date.now(); + + // La recherche catalogue local tourne en parallèle : elle sert à la fois de + // repli si Qlik échoue, et de complément (produits Nancy sans code centrale, + // donc invisibles côté réseau). + const localPromise = pgSearchProduits(term).catch((e) => { + console.error("[produits/search] recherche locale échouée:", (e as Error).message); + return [] as PgProduitSearchRow[]; + }); + + let qlik; + try { + qlik = await searchQlikArticles(term, QLIK_SEARCH_MAX_RESULTS); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + console.error("[produits/search] Qlik indisponible, repli catalogue local:", message); + const locaux = await localPromise; + return { + rows: locaux.map(rowDepuisCatalogueSeul), + source: "db", + qlikError: messageQlikLisible(message), + tronque: locaux.length >= 50, + champUtilise: null, + locauxHorsReseau: [], + dureeMs: Date.now() - started, + }; + } + + const codes = qlik.matches.map((m) => m.codeCentrale); + if (codes.length === 0) { + const locaux = await localPromise; + return { + rows: [], + source: "qlik", + qlikError: null, + tronque: false, + champUtilise: qlik.champUtilise, + locauxHorsReseau: locaux, + dureeMs: Date.now() - started, + }; + } + + // Métriques réseau + détail mensuel sur la fenêtre 12 mois glissants (mois + // courant exclu) — même extracteur que la sync fournisseur, donc mêmes + // chiffres que la Grille. + const monthsBack = envMonthsBack("QLIK_SYNC_MONTHS_BACK", QLIK_MONTHS_BACK_DEFAULT); + const dateFilter = buildGridNetworkQlikDateFilter(new Date(), monthsBack); + let metrics = new Map(); + let metricsError: string | null = null; + try { + metrics = await fetchNetworkMetricsPlaywright(codes, undefined, dateFilter); + // Le libellé / fournisseur ne sortent que de la recherche : on les + // attache ici pour qu'ils soient persistés avec les mesures. + for (const m of qlik.matches) { + const metric = metrics.get(m.codeCentrale); + if (!metric) continue; + if (m.libelle) metric.libelleReseau = m.libelle; + if (m.fournisseur) metric.fournisseurReseau = m.fournisseur; + } + // On alimente le cache `qlik_network_metrics` au passage : la fiche + // produit ouverte depuis un résultat affichera les données réseau tout + // de suite, sans re-extraction. + if (metrics.size > 0) { + const n = await upsertNetworkMetrics([...metrics.values()]); + console.log(`[produits/search] ${n} ligne(s) réseau mises en cache`); + } + } catch (e) { + // Les identités trouvées restent affichables sans les mesures : mieux + // vaut une liste sans chiffres réseau qu'une page d'erreur. + metricsError = e instanceof Error ? e.message : String(e); + console.error("[produits/search] extraction des métriques réseau échouée:", metricsError); + } + + const catalogue = await pgGetProduitsByCodeCentrale(codes); + const locaux = await localPromise; + + const rows: ProduitRechercheRow[] = qlik.matches.map((m) => { + const net = metrics.get(m.codeCentrale); + const local = catalogue.get(m.codeCentrale); + const qteReseau = net?.qteReseau ?? 0; + const caReseau = net?.caReseau ?? 0; + const nbMag = net?.nbMagasinsReseau ?? 0; + return { + codeCentrale: m.codeCentrale, + libelle: local?.libelle1 || m.libelle || m.codeCentrale, + libelleReseau: m.libelle, + fournisseur: local?.fournisseur || m.fournisseur || "", + codein: local?.codein ?? null, + nomenclature: local?.nomenclature ?? "", + stockLocal: local ? Number(local.stock_total) || 0 : null, + qteReseau, + caReseau, + nbMagasinsReseau: nbMag, + tauxPresence: nbMag / NB_MAGASINS_RESEAU, + qteParMagasinReseau: nbMag > 0 ? qteReseau / nbMag : 0, + caParMagasinReseau: net?.caParMagasinReseau ?? 0, + prixMoyenReseau: qteReseau > 0 ? caReseau / qteReseau : null, + margePctReseau: normalizeMargePct(net?.margePctReseau), + qteByMonth: net?.qteByMonth ?? null, + periode: net?.periode ?? dateFilter.label, + }; + }); + + // Les plus vendus du réseau d'abord : c'est l'information utile pour + // arbitrer, un produit que le réseau ne vend pas n'intéresse personne. + rows.sort((a, b) => b.qteReseau - a.qteReseau || a.libelle.localeCompare(b.libelle, "fr")); + + const codesTrouves = new Set(codes); + const locauxHorsReseau = locaux.filter((l) => !l.code_centrale || !codesTrouves.has(l.code_centrale)); + + return { + rows, + source: "qlik", + qlikError: metricsError ? messageQlikLisible(metricsError) : null, + tronque: qlik.tronque, + champUtilise: qlik.champUtilise, + locauxHorsReseau, + dureeMs: Date.now() - started, + }; +} + +/** Ligne de repli : catalogue Nancy seul, sans données réseau fiables. */ +function rowDepuisCatalogueSeul(r: PgProduitSearchRow): ProduitRechercheRow { + const qteReseau = Number(r.qte_reseau) || 0; + const nbMag = Number(r.nb_magasins_reseau) || 0; + return { + codeCentrale: r.code_centrale, + libelle: r.libelle1 || r.codein, + libelleReseau: "", + fournisseur: r.fournisseur, + codein: r.codein, + nomenclature: r.nomenclature, + stockLocal: Number(r.stock_total) || 0, + qteReseau, + caReseau: 0, + nbMagasinsReseau: nbMag, + tauxPresence: nbMag / NB_MAGASINS_RESEAU, + qteParMagasinReseau: nbMag > 0 ? qteReseau / nbMag : 0, + caParMagasinReseau: 0, + prixMoyenReseau: null, + margePctReseau: null, + qteByMonth: null, + periode: null, + }; +} + +/** + * Traduit les pannes Qlik récurrentes en message actionnable. Les autres + * erreurs sont renvoyées telles quelles (tronquées) pour rester diagnosticables. + */ +function messageQlikLisible(message: string): string { + const lower = message.toLowerCase(); + if (lower.includes("out of memory") || lower.includes("not enough memory") || lower.includes('"code":6') || lower.includes('"code":3002')) { + return "Serveur Qlik saturé : mémoire insuffisante pour charger l'application. Réessayez plus tard."; + } + if (lower.includes("identifiants qlik manquants") || lower.includes("qlik_user")) { + return "Identifiants Qlik non configurés (Paramètres → Qlik)."; + } + if (lower.includes("timeout") || lower.includes("econnrefused") || lower.includes("enotfound")) { + return "Serveur Qlik injoignable depuis CollectFlow (réseau ou VPN)."; + } + return message.slice(0, 300); +} diff --git a/src/features/produits/types.ts b/src/features/produits/types.ts index bf3ac52..03f76c5 100644 --- a/src/features/produits/types.ts +++ b/src/features/produits/types.ts @@ -5,11 +5,71 @@ import type { PgProduitDetailRow, PgProduitFournisseurRow, + PgProduitSearchRow, PgProduitStockRow, PgGammeHistoryRow, } from "@/lib/pg-ff-client"; import type { NetworkMetricCached } from "@/lib/qlik-network-cache"; +/** + * Une ligne de résultat de recherche produit. + * + * La recherche part de **Qlik** : chaque ligne est d'abord un produit du réseau + * (identifié par son code centrale), éventuellement rapproché du catalogue FF + * Nancy. `codein === null` = le réseau travaille ce produit, nous ne le + * référençons pas. + */ +export interface ProduitRechercheRow { + /** Dimension Qlik « Article Code » = `articles.artcentrale`. */ + codeCentrale: string; + /** Libellé affiché : catalogue Nancy si référencé, sinon libellé réseau. */ + libelle: string; + /** Libellé tel que Qlik le renvoie (vide si l'app n'expose pas de champ libellé). */ + libelleReseau: string; + /** Fournisseur : catalogue Nancy prioritaire (nom réel), sinon champ Qlik. */ + fournisseur: string; + /** `null` si le produit n'est pas au catalogue Nancy. */ + codein: string | null; + nomenclature: string; + /** Stock Nancy tous sites, `null` si produit non référencé. */ + stockLocal: number | null; + + // ─── Réseau Qlik, sur 12 mois glissants ───────────────────────────────── + qteReseau: number; + caReseau: number; + nbMagasinsReseau: number; + /** Part des magasins du réseau travaillant le produit (0..1). */ + tauxPresence: number; + /** Quantité vendue par magasin qui travaille le produit. */ + qteParMagasinReseau: number; + caParMagasinReseau: number; + /** CA réseau / quantité réseau — panier moyen unitaire, `null` si pas de vente. */ + prixMoyenReseau: number | null; + /** Taux de marge réseau en points de % (déjà normalisé), `null` si absent. */ + margePctReseau: number | null; + /** Quantité réseau par mois `"YYYY-MM"` — alimente la sparkline de tendance. */ + qteByMonth: Record | null; + periode: string | null; +} + +export interface ProduitRechercheResultat { + rows: ProduitRechercheRow[]; + /** `qlik` = recherche nominale ; `db` = repli catalogue local (Qlik indisponible). */ + source: "qlik" | "db"; + /** Message d'erreur Qlik quand on a dû se rabattre sur la base locale. */ + qlikError: string | null; + /** true si Qlik a renvoyé plus d'articles que la limite affichée. */ + tronque: boolean; + /** Champ Qlik réellement interrogé (diagnostic). */ + champUtilise: string | null; + /** + * Produits du catalogue Nancy qui matchent le terme mais qu'aucun article + * Qlik ne couvre (pas de code centrale, ou non vendus par le réseau). + */ + locauxHorsReseau: PgProduitSearchRow[]; + dureeMs: number; +} + /** Les métriques d'un mois, pour un périmètre donné (total ou un site). */ export interface ProduitMois { /** Clé de mois FF, format "YYYYMM". */ @@ -31,7 +91,17 @@ export interface ProduitTotaux { } export interface ProduitFiche { - detail: PgProduitDetailRow; + /** + * Identité catalogue FF Nancy — `null` pour un produit que le réseau + * travaille mais que Nancy ne référence pas (fiche 100 % réseau). + */ + detail: PgProduitDetailRow | null; + /** Code centrale = clé Qlik. Toujours renseigné pour une fiche ouverte depuis la recherche. */ + codeCentrale: string; + /** Libellé de l'article côté Qlik (peut être vide selon l'app Qlik). */ + libelleReseau: string; + /** Fournisseur de l'article côté Qlik (peut être vide selon l'app Qlik). */ + fournisseurReseau: string; fournisseurs: PgProduitFournisseurRow[]; stock: PgProduitStockRow[]; commandesEnCours: number; diff --git a/src/lib/pg-ff-client.ts b/src/lib/pg-ff-client.ts index 2446054..bb6076c 100644 --- a/src/lib/pg-ff-client.ts +++ b/src/lib/pg-ff-client.ts @@ -1387,6 +1387,105 @@ export async function pgSearchProduits(term: string, limit = 50): Promise> { + const out = new Map(); + const codes = [...new Set(codesCentraux.map((c) => c.trim()).filter(Boolean))]; + if (codes.length === 0) return out; + + try { + const result = await pgNoParallel(sql` + WITH matched AS ( + SELECT DISTINCT ON (TRIM(a.artcentrale::text)) + a.no_id, + TRIM(a.codein::text) AS codein, + TRIM(a.artcentrale::text) AS code_centrale, + COALESCE(a.libelle1, '') AS libelle1, + a.nom_no_id + FROM articles a + WHERE TRIM(a.artcentrale::text) IN (${sql.join(codes.map((c) => sql`${c}`), sql`, `)}) + AND a.codein IS NOT NULL + ORDER BY TRIM(a.artcentrale::text), a.no_id DESC + ) + 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, + TRUE AS has_reseau, + 0::float AS qte_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 + `); + + for (const row of result.rows as unknown as PgProduitSearchRow[]) { + out.set(row.code_centrale, row); + } + console.log(`[pg-ff] pgGetProduitsByCodeCentrale: ${codes.length} codes Qlik → ${out.size} trouvés dans le catalogue Nancy`); + return out; + } catch (e) { + console.error("[pg-ff] pgGetProduitsByCodeCentrale error:", (e as Error).message?.slice(0, 250)); + return out; + } +} + +/** + * `codein` de l'article portant ce code centrale, ou `null` si Nancy ne le + * référence pas. Utilisé par la fiche produit ouverte depuis un résultat Qlik + * (URL `?cc=…`), qui n'a que le code centrale. + */ +export async function pgGetCodeinByCodeCentrale(codeCentrale: string): Promise { + const cleaned = codeCentrale.trim(); + if (!cleaned) return null; + try { + const result = await pgNoParallel(sql` + SELECT TRIM(a.codein::text) AS codein + FROM articles a + WHERE TRIM(a.artcentrale::text) = ${cleaned} + AND a.codein IS NOT NULL + ORDER BY a.no_id DESC + LIMIT 1 + `); + const row = (result.rows as unknown as { codein: string }[])[0]; + return row?.codein ?? null; + } catch (e) { + console.error("[pg-ff] pgGetCodeinByCodeCentrale error:", (e as Error).message?.slice(0, 250)); + return null; + } +} + /** Identité, prix et nomenclature d'un produit (une seule ligne). */ export interface PgProduitDetailRow { no_id: number; diff --git a/src/lib/qlik-client.ts b/src/lib/qlik-client.ts index 872a895..5506448 100644 --- a/src/lib/qlik-client.ts +++ b/src/lib/qlik-client.ts @@ -101,6 +101,14 @@ export interface NetworkMetric { * sautée si le champ Qlik `Année` est absent). */ metricsByMonth?: Record; + /** + * Libellé de l'article côté Qlik. Renseigné uniquement par la recherche + * produit (`qlik-search.ts`), qui interroge la dimension libellé ; la sync + * fournisseur ne le connaît pas et le laisse `undefined`. + */ + libelleReseau?: string; + /** Fournisseur de l'article côté Qlik, même origine que `libelleReseau`. */ + fournisseurReseau?: string; } /** Les mesures réseau Qlik pour un produit sur un mois donné. */ diff --git a/src/lib/qlik-network-cache.ts b/src/lib/qlik-network-cache.ts index 653f99e..508f5b2 100644 --- a/src/lib/qlik-network-cache.ts +++ b/src/lib/qlik-network-cache.ts @@ -27,6 +27,10 @@ export interface NetworkMetricCached { metricsByMonth: Record | null; /** Période couverte par l'extraction (ex "2025-03_2026-02"). */ periode: string | null; + /** Libellé de l'article côté Qlik — `null` tant qu'aucune recherche produit ne l'a renseigné. */ + libelleReseau: string | null; + /** Fournisseur de l'article côté Qlik, même origine que `libelleReseau`. */ + fournisseurReseau: string | null; fetchedAt: string | null; } @@ -53,6 +57,8 @@ export async function getNetworkMetricsByCodeCentrale( qteByMonth: (r.qteByMonth as Record | null) ?? null, metricsByMonth: (r.metricsByMonth as Record | null) ?? null, periode: r.periode ?? null, + libelleReseau: r.libelleReseau ?? null, + fournisseurReseau: r.fournisseurReseau ?? null, fetchedAt: r.fetchedAt ? new Date(r.fetchedAt).toISOString() : null, }); } @@ -73,6 +79,8 @@ export async function upsertNetworkMetrics(metrics: NetworkMetric[]): Promise | null = null; + +async function getBrowser(): Promise { + if (!browserPromise) { + const execPath = process.env.PLAYWRIGHT_CHROMIUM_PATH; + browserPromise = chromium.launch({ + headless: true, + executablePath: execPath || undefined, + args: ["--no-sandbox", "--disable-dev-shm-usage", "--ignore-certificate-errors"], + }).catch((e) => { browserPromise = null; throw e; }); + } + return browserPromise; +} + +interface InPageSearchResult { + ok: boolean; + error?: string; + rows?: Array<[string, string, string]>; + champUtilise?: string | null; + total?: number; + /** Journal compact des étapes, remonté dans les logs serveur. */ + trace?: string[]; +} + +/** + * Cherche dans Qlik les articles correspondant à `term`. + * + * Ne fait **aucune** mesure : on veut juste la liste des codes centraux (plus + * libellé / fournisseur si l'app les expose). Les métriques réseau sont + * ensuite extraites par `fetchNetworkMetricsPlaywright`, qui sait déjà produire + * le détail mensuel sur 12 mois glissants. + */ +export async function searchQlikArticles( + term: string, + limit: number = QLIK_SEARCH_MAX_RESULTS, + cfg: QlikConfig = getQlikConfig(), +): Promise { + const cleaned = term.trim(); + if (cleaned.length < 3) return { matches: [], champUtilise: null, tronque: false }; + if (!cfg.appNetwork) throw new Error("[qlik-search] QLIK_APP_NETWORK manquant"); + if (!cfg.user || !cfg.password) throw new Error("[qlik-search] identifiants Qlik manquants"); + + const started = Date.now(); + const sess = await qlikNtlmSession(cfg); + const [cookieName, ...rest] = sess.cookie.split("="); + const cookieValue = rest.join("="); + + const browser = await getBrowser(); + const ctx = await browser.newContext({ + httpCredentials: { username: cfg.user, password: cfg.password, origin: `https://${cfg.host}` }, + ignoreHTTPSErrors: true, + }); + await ctx.addCookies([{ name: cookieName, value: cookieValue, domain: cfg.host, path: "/", secure: true }]); + + try { + const page = await ctx.newPage(); + page.on("console", (msg) => console.log(`[qlik-search][page] ${msg.text()}`)); + + let csrfToken: string | null = null; + let resolveToken: (() => void) | null = null; + const tokenReady = new Promise((r) => { resolveToken = r; }); + page.on("websocket", (ws) => { + const m = ws.url().match(/qlik-csrf-token=([^&]+)/); + if (m && !csrfToken) { csrfToken = decodeURIComponent(m[1]); resolveToken?.(); } + }); + + await page.goto(`https://${cfg.host}/sense/app/${cfg.appNetwork}`, { waitUntil: "domcontentloaded", timeout: cfg.timeoutMs }) + .catch(() => { /* le client Qlik ouvre son ws juste après */ }); + await Promise.race([tokenReady, page.waitForTimeout(15000)]); + if (!csrfToken) throw new Error("[qlik-search] qlik-csrf-token introuvable (client Qlik non chargé ?)"); + + const result = (await page.evaluate( + ({ app, dim, token, terme, limite, maxFieldMatches }: { + app: string; dim: string; token: string; terme: string; limite: number; maxFieldMatches: number; + }) => + new Promise((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); + const trace: string[] = []; + let id = 0; + const pend = new Map void; rej: (e: unknown) => void }>(); + const rpc = (method: string, params: unknown, handle = -1) => new Promise<{ [k: string]: unknown }>((res, rej) => { + const i = ++id; pend.set(i, { res, rej }); + ws.send(JSON.stringify({ jsonrpc: "2.0", id: i, handle, method, params })); + }); + const ready = new Promise((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); + }); + + (async () => { + try { + await ready; + const open = await rpc("OpenDoc", { qDocName: app }); + const doc = (open.qReturn as { qHandle: number }).qHandle; + trace.push("OpenDoc ok"); + + // ─── 1. Inventaire des champs ──────────────────────────── + // Sert à repérer, sans connaître l'app par cœur, le champ + // libellé article et le champ fournisseur. + const fl = await rpc("CreateSessionObject", { + qProp: { + qInfo: { qType: "FieldList" }, + qFieldListDef: { qShowSystem: false, qShowHidden: false, qShowSemantic: true, qShowSrcTables: true }, + }, + }, doc); + const fll = await rpc("GetLayout", {}, (fl.qReturn as { qHandle: number }).qHandle); + const champs = ((((fll.qLayout as { qFieldList?: { qItems?: Array<{ qName: string }> } }).qFieldList?.qItems) ?? []) + .map((f) => f.qName)); + trace.push("champs=" + champs.length); + + const has = (nom: string, re: RegExp) => re.test(nom); + const champCode = champs.find((c) => c === "Article Code") + ?? champs.find((c) => has(c, /article/i) && has(c, /code/i)) + ?? "Article Code"; + const champLibelle = champs.find((c) => has(c, /article/i) && has(c, /(libell|designation|désignation|denomination|dénomination)/i)) + ?? champs.find((c) => has(c, /(libell|designation|désignation)/i) && !has(c, /(magasin|region|région|famille|rayon|fournisseur)/i)) + ?? null; + const champFournisseur = champs.find((c) => has(c, /fournisseur/i)) + ?? champs.find((c) => has(c, /(fabricant|supplier)/i)) + ?? null; + trace.push("code=" + champCode + " libelle=" + champLibelle + " fournisseur=" + champFournisseur); + + // ─── 2. Recherche globale Qlik ─────────────────────────── + // On restreint aux champs article : sans restriction, un terme + // courant ("noir") matcherait des dizaines de champs sans + // rapport (couleur magasin, région…) et la sélection deviendrait + // absurde. + const champsRecherche = [champCode, ...(champLibelle ? [champLibelle] : [])]; + const sr = await rpc("SearchResults", { + qOptions: { qSearchFields: champsRecherche, qContext: "Cleared" }, + qTerms: [terme], + qPage: { qOffset: 0, qCount: champsRecherche.length, qMaxNbrFieldMatches: maxFieldMatches }, + }, doc); + + type Match = { qText?: string }; + type Item = { qIdentifier?: string; qItemMatches?: Match[]; qTotalNumberOfMatches?: number }; + type Group = { qItems?: Item[] }; + const groupes = (((sr.qResult as { qSearchGroupArray?: Group[] })?.qSearchGroupArray) ?? []); + const parChamp = new Map(); + for (const g of groupes) { + for (const it of g.qItems ?? []) { + const champ = it.qIdentifier ?? ""; + if (!champ) continue; + const vals = (it.qItemMatches ?? []).map((m) => String(m.qText ?? "")).filter(Boolean); + if (vals.length === 0) continue; + parChamp.set(champ, [...(parChamp.get(champ) ?? []), ...vals]); + } + } + trace.push("groupes=" + JSON.stringify([...parChamp].map(([c, v]) => c + ":" + v.length))); + + // Priorité au champ libellé (une recherche texte), sinon le code. + const champChoisi = (champLibelle && parChamp.has(champLibelle)) ? champLibelle + : parChamp.has(champCode) ? champCode + : [...parChamp.keys()][0] ?? null; + if (!champChoisi) { + ws.close(); + return resolve({ ok: true, rows: [], champUtilise: null, total: 0, trace }); + } + const valeurs = [...new Set(parChamp.get(champChoisi) ?? [])].slice(0, maxFieldMatches); + + // ─── 3. Sélection des valeurs trouvées ─────────────────── + const gf = await rpc("GetField", { qFieldName: champChoisi }, doc); + const fh = (gf.qReturn as { qHandle: number }).qHandle; + await rpc("Clear", {}, fh); + await rpc("SelectValues", { + qFieldValues: valeurs.map((v) => ({ qText: v })), + qToggleMode: false, + qSoftLock: true, + }, fh); + trace.push("selection " + champChoisi + " = " + valeurs.length + " valeurs"); + + // ─── 4. Liste des articles correspondants ──────────────── + // Cube sans mesure : uniquement l'identité de l'article. Les + // métriques réseau sont extraites juste après par + // fetchNetworkMetricsPlaywright (qui produit aussi le mensuel). + const dims: Array<{ qLibraryId?: string; qDef?: { qFieldDefs: string[] } }> = [{ qLibraryId: dim }]; + if (champLibelle) dims.push({ qDef: { qFieldDefs: [champLibelle] } }); + if (champFournisseur) dims.push({ qDef: { qFieldDefs: [champFournisseur] } }); + const largeur = dims.length; + const hauteur = Math.min(limite, Math.floor(9000 / Math.max(largeur, 1))); + + const cube = await rpc("CreateSessionObject", { + qProp: { + qInfo: { qType: "cf-search" }, + qHyperCubeDef: { + qDimensions: dims.map((d) => ({ ...d, qNullSuppression: true })), + qMeasures: [], + qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: largeur, qHeight: hauteur }], + qSuppressMissing: true, + }, + }, + }, doc); + const ch = (cube.qReturn as { qHandle: number }).qHandle; + const layout = await rpc("GetLayout", {}, ch); + const hc = (layout.qLayout as { + qHyperCube: { + qSize: { qcy: number }; + qDataPages?: Array<{ qMatrix?: Array> }>; + }; + }).qHyperCube; + const matrix = hc.qDataPages?.[0]?.qMatrix ?? []; + const rows: Array<[string, string, string]> = []; + for (const r of matrix) { + const code = String(r[0]?.qText ?? "").trim(); + if (!code || code === "-") continue; + const libelle = champLibelle ? String(r[1]?.qText ?? "").trim() : ""; + const idxFou = champLibelle ? 2 : 1; + const fournisseur = champFournisseur ? String(r[idxFou]?.qText ?? "").trim() : ""; + rows.push([code, libelle === "-" ? "" : libelle, fournisseur === "-" ? "" : fournisseur]); + } + trace.push("articles=" + rows.length + "/" + hc.qSize.qcy); + + ws.close(); + resolve({ ok: true, rows, champUtilise: champChoisi, total: hc.qSize.qcy, trace }); + } catch (e) { + try { ws.close(); } catch { /* noop */ } + resolve({ ok: false, error: String((e as Error)?.message || e), trace }); + } + })(); + }), + { + app: cfg.appNetwork, + dim: cfg.dimCodeArticleId, + token: csrfToken, + terme: cleaned, + limite: limit, + maxFieldMatches: MAX_FIELD_MATCHES, + }, + )) as InPageSearchResult; + + for (const t of result.trace ?? []) console.log(`[qlik-search] ${t}`); + if (!result.ok) throw new Error(`[qlik-search] ${result.error}`); + + const matches: QlikArticleMatch[] = (result.rows ?? []).map(([codeCentrale, libelle, fournisseur]) => ({ + codeCentrale, libelle, fournisseur, + })); + console.log( + `[qlik-search] "${cleaned}" → ${matches.length} article(s) (total Qlik ${result.total ?? 0}, champ=${result.champUtilise}) en ${Date.now() - started} ms`, + ); + return { + matches, + champUtilise: result.champUtilise ?? null, + tronque: (result.total ?? 0) > matches.length, + }; + } finally { + await ctx.close(); + } +} From 62375d3310d2dbebf811238c120e916adebc0d87 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 05:05:36 +0000 Subject: [PATCH 04/34] =?UTF-8?q?feat(qlik-search):=20surcharge=20des=20ch?= =?UTF-8?q?amps=20libell=C3=A9/fournisseur=20+=20inventaire=20en=20trace?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Les intitulés des champs varient d'une app Qlik à l'autre. La détection reste heuristique, mais l'inventaire complet des champs est désormais loggué et deux variables d'environnement permettent de corriger le tir sans redéploiement de code : QLIK_FIELD_ARTICLE_LIBELLE et QLIK_FIELD_FOURNISSEUR. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DfqUihgixw4K1AmJhizWiu --- docs/qlik-integration.md | 11 +++++++++++ src/lib/qlik-search.ts | 17 ++++++++++++++--- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/docs/qlik-integration.md b/docs/qlik-integration.md index 682900c..c418c25 100644 --- a/docs/qlik-integration.md +++ b/docs/qlik-integration.md @@ -35,6 +35,17 @@ ceux-là qu'on veut voir. Repli : si Qlik est injoignable, la recherche retombe sur `pgSearchProduits()` et la réponse le signale (`source: "db"`). Résultats mis en cache mémoire 10 min. +Les noms des champs libellé / fournisseur varient d'une app Qlik à l'autre : ils +sont détectés par heuristique, et l'inventaire complet des champs est loggué +(`[qlik-search] champs disponibles=…`). Si la détection tombe à côté, forcer : + +``` +QLIK_FIELD_ARTICLE_LIBELLE= +QLIK_FIELD_FOURNISSEUR= +``` + +Sans champ libellé exploitable, la recherche fonctionne encore par code centrale. + ## Tendance réseau = 12 mois glissants stricts `computeNetworkTrend()` reconstruit sa fenêtre à partir de la date du jour : diff --git a/src/lib/qlik-search.ts b/src/lib/qlik-search.ts index 5a11278..f85815d 100644 --- a/src/lib/qlik-search.ts +++ b/src/lib/qlik-search.ts @@ -128,8 +128,9 @@ export async function searchQlikArticles( if (!csrfToken) throw new Error("[qlik-search] qlik-csrf-token introuvable (client Qlik non chargé ?)"); const result = (await page.evaluate( - ({ app, dim, token, terme, limite, maxFieldMatches }: { + ({ app, dim, token, terme, limite, maxFieldMatches, champLibelleForce, champFournisseurForce }: { app: string; dim: string; token: string; terme: string; limite: number; maxFieldMatches: number; + champLibelleForce: string; champFournisseurForce: string; }) => new Promise((resolve) => { const loc = (window as unknown as { location: Location }).location; @@ -182,12 +183,20 @@ export async function searchQlikArticles( const champCode = champs.find((c) => c === "Article Code") ?? champs.find((c) => has(c, /article/i) && has(c, /code/i)) ?? "Article Code"; - const champLibelle = champs.find((c) => has(c, /article/i) && has(c, /(libell|designation|désignation|denomination|dénomination)/i)) + // Les noms exacts varient selon l'app Qlik : détection par + // heuristique, surchargeable par variable d'environnement si + // l'app expose un intitulé inattendu (cf. les logs [qlik-search]). + const champLibelle = (champLibelleForce && champs.includes(champLibelleForce) ? champLibelleForce : null) + ?? champs.find((c) => has(c, /article/i) && has(c, /(libell|designation|désignation|denomination|dénomination)/i)) ?? champs.find((c) => has(c, /(libell|designation|désignation)/i) && !has(c, /(magasin|region|région|famille|rayon|fournisseur)/i)) ?? null; - const champFournisseur = champs.find((c) => has(c, /fournisseur/i)) + const champFournisseur = (champFournisseurForce && champs.includes(champFournisseurForce) ? champFournisseurForce : null) + ?? champs.find((c) => has(c, /fournisseur/i)) ?? champs.find((c) => has(c, /(fabricant|supplier)/i)) ?? null; + // Inventaire complet en trace : sans accès Qlik depuis le poste + // de dev, c'est le seul moyen de régler les surcharges ci-dessus. + trace.push("champs disponibles=" + JSON.stringify(champs)); trace.push("code=" + champCode + " libelle=" + champLibelle + " fournisseur=" + champFournisseur); // ─── 2. Recherche globale Qlik ─────────────────────────── @@ -295,6 +304,8 @@ export async function searchQlikArticles( terme: cleaned, limite: limit, maxFieldMatches: MAX_FIELD_MATCHES, + champLibelleForce: (process.env.QLIK_FIELD_ARTICLE_LIBELLE ?? "").trim(), + champFournisseurForce: (process.env.QLIK_FIELD_FOURNISSEUR ?? "").trim(), }, )) as InPageSearchResult; From a1a95fa2904278d6ba9f3f95f9208a85e70a8b2b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 09:14:56 +0000 Subject: [PATCH 05/34] =?UTF-8?q?fix(qlik-search):=20la=20s=C3=A9lection?= =?UTF-8?q?=20ne=20s'appliquait=20pas,=20les=20r=C3=A9sultats=20ne=20corre?= =?UTF-8?q?spondaient=20pas=20au=20terme?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Symptôme : « tapis anti » renvoyait 40 articles sans rapport (mugs, miroirs, couvertures) avec des codes centraux contigus — c'est-à-dire le début du catalogue Qlik, pas un résultat de recherche. Cause : SearchResults + SelectValues. On ré-injectait les qText renvoyés par la recherche globale dans un SelectValues sur le champ, sans vérifier le retour et sans laisser à l'Engine le temps d'évaluer la sélection. Quand la sélection n'est pas appliquée, le cube renvoie simplement les premières lignes de la dimension « Article Code » — soit exactement les 40 lignes demandées. Correctifs : - Sélection via list object : SearchListObjectFor + AcceptListObjectSearch, c'est-à-dire le mécanisme d'un volet de filtre Qlik. Qlik applique sa propre sémantique de recherche et sélectionne lui-même les valeurs, sans réinjection de valeurs texte. Libellé d'abord, code centrale en repli. - Délai d'évaluation (QLIK_SETTLE_MS, défaut 250 ms) après ClearAll, après la recherche et après l'acceptation, comme le fait déjà l'extraction. - Garde-fou côté client : chaque ligne du cube est revérifiée contre le terme (tous les mots présents dans le libellé, insensible casse/accents, ou code correspondant). Une liste sans rapport avec la recherche est pire que zéro résultat. - Dédoublonnage par code centrale : un article référencé chez plusieurs fournisseurs produisait autant de lignes (vu sur « Couverture de Pique-nique », code 10636135, listé deux fois). - Diagnostic : inventaire des champs, nombre de valeurs trouvées, sélections actives et compteur de lignes écartées sont tracés dans les logs ; si tout est écarté, l'UI dit que le filtre n'a pas été appliqué au lieu d'afficher un « aucun résultat » trompeur. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DfqUihgixw4K1AmJhizWiu --- src/app/(dashboard)/produits/results.tsx | 9 +- src/features/produits/api/search-produits.ts | 5 +- src/lib/qlik-search.ts | 227 ++++++++++++++----- 3 files changed, 177 insertions(+), 64 deletions(-) diff --git a/src/app/(dashboard)/produits/results.tsx b/src/app/(dashboard)/produits/results.tsx index c9b1ec1..686c1e2 100644 --- a/src/app/(dashboard)/produits/results.tsx +++ b/src/app/(dashboard)/produits/results.tsx @@ -159,10 +159,15 @@ export function ProduitResults({ query }: { query: string }) { {source === "qlik" && qlikError && (
- Articles trouvés, mais l'extraction des mesures réseau a échoué : {qlikError} + + + {rows.length === 0 + ? qlikError + : `Articles trouvés, mais l'extraction des mesures réseau a échoué : ${qlikError}`} +
)} diff --git a/src/features/produits/api/search-produits.ts b/src/features/produits/api/search-produits.ts index e81ccae..4d564fb 100644 --- a/src/features/produits/api/search-produits.ts +++ b/src/features/produits/api/search-produits.ts @@ -116,7 +116,10 @@ async function executerRecherche(term: string): Promise | null = null; async function getBrowser(): Promise { @@ -76,6 +84,10 @@ interface InPageSearchResult { rows?: Array<[string, string, string]>; champUtilise?: string | null; total?: number; + /** Lignes brutes lues dans le cube (avant garde-fou). */ + lues?: number; + /** Lignes écartées parce qu'elles ne correspondaient pas au terme. */ + rejetees?: number; /** Journal compact des étapes, remonté dans les logs serveur. */ trace?: string[]; } @@ -94,11 +106,15 @@ export async function searchQlikArticles( cfg: QlikConfig = getQlikConfig(), ): Promise { const cleaned = term.trim(); - if (cleaned.length < 3) return { matches: [], champUtilise: null, tronque: false }; + if (cleaned.length < 3) return { matches: [], champUtilise: null, tronque: false, avertissement: null }; if (!cfg.appNetwork) throw new Error("[qlik-search] QLIK_APP_NETWORK manquant"); if (!cfg.user || !cfg.password) throw new Error("[qlik-search] identifiants Qlik manquants"); const started = Date.now(); + // Latence laissée à l'Engine après chaque mutation de sélection. Même réglage + // que l'extraction (`QLIK_SETTLE_MS`) : sans elle, l'objet créé juste après + // est calculé sur l'état de sélection précédent. + const settleMs = Math.min(2000, Math.max(0, Number(process.env.QLIK_SETTLE_MS ?? "250") || 250)); const sess = await qlikNtlmSession(cfg); const [cookieName, ...rest] = sess.cookie.split("="); const cookieValue = rest.join("="); @@ -128,8 +144,8 @@ export async function searchQlikArticles( if (!csrfToken) throw new Error("[qlik-search] qlik-csrf-token introuvable (client Qlik non chargé ?)"); const result = (await page.evaluate( - ({ app, dim, token, terme, limite, maxFieldMatches, champLibelleForce, champFournisseurForce }: { - app: string; dim: string; token: string; terme: string; limite: number; maxFieldMatches: number; + ({ app, dim, token, terme, limite, settleMs, champLibelleForce, champFournisseurForce }: { + app: string; dim: string; token: string; terme: string; limite: number; settleMs: number; champLibelleForce: string; champFournisseurForce: string; }) => new Promise((resolve) => { @@ -199,56 +215,89 @@ export async function searchQlikArticles( trace.push("champs disponibles=" + JSON.stringify(champs)); trace.push("code=" + champCode + " libelle=" + champLibelle + " fournisseur=" + champFournisseur); - // ─── 2. Recherche globale Qlik ─────────────────────────── - // On restreint aux champs article : sans restriction, un terme - // courant ("noir") matcherait des dizaines de champs sans - // rapport (couleur magasin, région…) et la sélection deviendrait - // absurde. - const champsRecherche = [champCode, ...(champLibelle ? [champLibelle] : [])]; - const sr = await rpc("SearchResults", { - qOptions: { qSearchFields: champsRecherche, qContext: "Cleared" }, - qTerms: [terme], - qPage: { qOffset: 0, qCount: champsRecherche.length, qMaxNbrFieldMatches: maxFieldMatches }, - }, doc); + // ─── 2. Sélection par recherche de champ ───────────────── + // + // On passe par un **list object** + `SearchListObjectFor` / + // `AcceptListObjectSearch` : c'est exactement ce que fait un + // volet de filtre Qlik quand on tape dans sa zone de recherche. + // Qlik applique lui-même sa sémantique (mots multiples = tous + // présents, insensible casse/accents) et sélectionne les valeurs + // trouvées — sans qu'on ait à ré-injecter des `qText` à + // l'identique, ce qui échouait silencieusement et laissait le + // cube renvoyer le début du catalogue. + const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); - type Match = { qText?: string }; - type Item = { qIdentifier?: string; qItemMatches?: Match[]; qTotalNumberOfMatches?: number }; - type Group = { qItems?: Item[] }; - const groupes = (((sr.qResult as { qSearchGroupArray?: Group[] })?.qSearchGroupArray) ?? []); - const parChamp = new Map(); - for (const g of groupes) { - for (const it of g.qItems ?? []) { - const champ = it.qIdentifier ?? ""; - if (!champ) continue; - const vals = (it.qItemMatches ?? []).map((m) => String(m.qText ?? "")).filter(Boolean); - if (vals.length === 0) continue; - parChamp.set(champ, [...(parChamp.get(champ) ?? []), ...vals]); + const chercherEtSelectionner = async (champ: string): Promise => { + const lo = await rpc("CreateSessionObject", { + qProp: { + qInfo: { qType: "cf-search-lb" }, + qListObjectDef: { + qDef: { qFieldDefs: [champ] }, + qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 1, qHeight: 1 }], + }, + }, + }, doc); + const ret = lo.qReturn as { qHandle: number; qGenericId?: string; qId?: string }; + const loId = String(ret.qGenericId ?? ret.qId ?? ""); + try { + await rpc("SearchListObjectFor", { qPath: "/qListObjectDef", qMatch: terme }, ret.qHandle); + await sleep(settleMs); + const lay = await rpc("GetLayout", {}, ret.qHandle); + const nb = ((lay.qLayout as { qListObject?: { qSize?: { qcy?: number } } }).qListObject?.qSize?.qcy) ?? 0; + trace.push("recherche " + champ + " → " + nb + " valeur(s)"); + if (nb > 0) { + await rpc("AcceptListObjectSearch", { + qPath: "/qListObjectDef", + qToggleMode: false, + qSoftLock: true, + }, ret.qHandle); + // L'Engine doit finir d'évaluer la sélection avant que + // le cube suivant soit calculé, sinon il est construit + // sur l'état précédent (= catalogue entier). + await sleep(settleMs); + } + return nb; + } finally { + if (loId) { try { await rpc("DestroySessionObject", { qId: loId }, doc); } catch { /* noop */ } } } - } - trace.push("groupes=" + JSON.stringify([...parChamp].map(([c, v]) => c + ":" + v.length))); + }; - // Priorité au champ libellé (une recherche texte), sinon le code. - const champChoisi = (champLibelle && parChamp.has(champLibelle)) ? champLibelle - : parChamp.has(champCode) ? champCode - : [...parChamp.keys()][0] ?? null; + await rpc("ClearAll", { qLockedAlso: false }, doc); + await sleep(settleMs); + + // Le libellé d'abord (cas courant), le code centrale ensuite. + let champChoisi: string | null = null; + if (champLibelle && (await chercherEtSelectionner(champLibelle)) > 0) { + champChoisi = champLibelle; + } else if ((await chercherEtSelectionner(champCode)) > 0) { + champChoisi = champCode; + } if (!champChoisi) { ws.close(); - return resolve({ ok: true, rows: [], champUtilise: null, total: 0, trace }); + return resolve({ ok: true, rows: [], champUtilise: null, total: 0, lues: 0, rejetees: 0, trace }); } - const valeurs = [...new Set(parChamp.get(champChoisi) ?? [])].slice(0, maxFieldMatches); - // ─── 3. Sélection des valeurs trouvées ─────────────────── - const gf = await rpc("GetField", { qFieldName: champChoisi }, doc); - const fh = (gf.qReturn as { qHandle: number }).qHandle; - await rpc("Clear", {}, fh); - await rpc("SelectValues", { - qFieldValues: valeurs.map((v) => ({ qText: v })), - qToggleMode: false, - qSoftLock: true, - }, fh); - trace.push("selection " + champChoisi + " = " + valeurs.length + " valeurs"); + // Trace des sélections réellement actives : si elle est vide, la + // sélection n'a pas pris côté Engine et le cube ci-dessous + // renverra tout le catalogue (le garde-fou de l'étape 4 le + // rattrape, mais c'est ici qu'on voit pourquoi). + try { + const cs = await rpc("CreateSessionObject", { + qProp: { + qInfo: { qType: "cf-search-sel" }, + qSelectionObjectDef: {}, + }, + }, doc); + const csl = await rpc("GetLayout", {}, (cs.qReturn as { qHandle: number }).qHandle); + const sel = (((csl.qLayout as { qSelectionObject?: { qSelections?: Array<{ qField?: string; qSelectedCount?: number; qTotal?: number }> } }) + .qSelectionObject?.qSelections) ?? []) + .map((s) => `${s.qField}:${s.qSelectedCount}/${s.qTotal}`); + trace.push("sélections actives=" + JSON.stringify(sel)); + } catch (e) { + trace.push("sélections actives: lecture impossible (" + String((e as Error)?.message || e) + ")"); + } - // ─── 4. Liste des articles correspondants ──────────────── + // ─── 3. Liste des articles correspondants ──────────────── // Cube sans mesure : uniquement l'identité de l'article. Les // métriques réseau sont extraites juste après par // fetchNetworkMetricsPlaywright (qui produit aussi le mensuel). @@ -256,7 +305,10 @@ export async function searchQlikArticles( if (champLibelle) dims.push({ qDef: { qFieldDefs: [champLibelle] } }); if (champFournisseur) dims.push({ qDef: { qFieldDefs: [champFournisseur] } }); const largeur = dims.length; - const hauteur = Math.min(limite, Math.floor(9000 / Math.max(largeur, 1))); + // On lit large puis on dédoublonne : un article référencé chez + // plusieurs fournisseurs produit autant de lignes que de + // fournisseurs, il ne doit pas consommer plusieurs places. + const hauteur = Math.min(1000, Math.floor(9000 / Math.max(largeur, 1))); const cube = await rpc("CreateSessionObject", { qProp: { @@ -278,19 +330,60 @@ export async function searchQlikArticles( }; }).qHyperCube; const matrix = hc.qDataPages?.[0]?.qMatrix ?? []; - const rows: Array<[string, string, string]> = []; + + // ─── 4. Garde-fou : le résultat DOIT correspondre au terme ── + // Si la sélection Qlik n'a pas pris (droits, modèle de données, + // évaluation en retard), le cube renvoie le début du catalogue. + // On revérifie donc chaque ligne côté client : mots tous présents + // dans le libellé, ou code correspondant au terme. Mieux vaut + // zéro résultat qu'une liste sans rapport avec la recherche. + const normaliser = (s: string) => s + .toLowerCase() + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") + .replace(/[^a-z0-9]+/g, " ") + .trim(); + const mots = normaliser(terme).split(" ").filter(Boolean); + const termeCode = terme.trim().toLowerCase(); + const correspond = (code: string, libelle: string): boolean => { + if (mots.length === 0) return true; + const c = code.toLowerCase(); + if (c === termeCode || c.startsWith(termeCode)) return true; + if (!libelle) return champChoisi === champCode; + const l = normaliser(libelle); + return mots.every((m) => l.includes(m)); + }; + + const parCode = new Map(); + let rejetees = 0; for (const r of matrix) { const code = String(r[0]?.qText ?? "").trim(); if (!code || code === "-") continue; - const libelle = champLibelle ? String(r[1]?.qText ?? "").trim() : ""; + const libelleBrut = champLibelle ? String(r[1]?.qText ?? "").trim() : ""; const idxFou = champLibelle ? 2 : 1; - const fournisseur = champFournisseur ? String(r[idxFou]?.qText ?? "").trim() : ""; - rows.push([code, libelle === "-" ? "" : libelle, fournisseur === "-" ? "" : fournisseur]); + const fournisseurBrut = champFournisseur ? String(r[idxFou]?.qText ?? "").trim() : ""; + const libelle = libelleBrut === "-" ? "" : libelleBrut; + const fournisseur = fournisseurBrut === "-" ? "" : fournisseurBrut; + if (!correspond(code, libelle)) { rejetees++; continue; } + const existant = parCode.get(code); + if (!existant) { parCode.set(code, [code, libelle, fournisseur]); continue; } + // Complète les champs manquants d'une ligne déjà vue. + if (!existant[1] && libelle) existant[1] = libelle; + if (!existant[2] && fournisseur) existant[2] = fournisseur; } - trace.push("articles=" + rows.length + "/" + hc.qSize.qcy); + const uniques = [...parCode.values()]; + const rows = uniques.slice(0, limite); + trace.push( + "cube=" + hc.qSize.qcy + " lignes lues=" + matrix.length + + " rejetées(hors terme)=" + rejetees + + " articles uniques=" + uniques.length + " retenus=" + rows.length, + ); ws.close(); - resolve({ ok: true, rows, champUtilise: champChoisi, total: hc.qSize.qcy, trace }); + resolve({ + ok: true, rows, champUtilise: champChoisi, total: uniques.length, + lues: matrix.length, rejetees, trace, + }); } catch (e) { try { ws.close(); } catch { /* noop */ } resolve({ ok: false, error: String((e as Error)?.message || e), trace }); @@ -303,7 +396,7 @@ export async function searchQlikArticles( token: csrfToken, terme: cleaned, limite: limit, - maxFieldMatches: MAX_FIELD_MATCHES, + settleMs, champLibelleForce: (process.env.QLIK_FIELD_ARTICLE_LIBELLE ?? "").trim(), champFournisseurForce: (process.env.QLIK_FIELD_FOURNISSEUR ?? "").trim(), }, @@ -318,10 +411,22 @@ export async function searchQlikArticles( console.log( `[qlik-search] "${cleaned}" → ${matches.length} article(s) (total Qlik ${result.total ?? 0}, champ=${result.champUtilise}) en ${Date.now() - started} ms`, ); + // Toutes les lignes écartées = Qlik a bien répondu mais sans appliquer la + // sélection. On le dit au lieu d'afficher un « aucun résultat » trompeur. + const filtreNonApplique = matches.length === 0 && (result.rejetees ?? 0) > 0; + if (filtreNonApplique) { + console.warn( + `[qlik-search] filtre non appliqué : ${result.rejetees} ligne(s) lues sans rapport avec "${cleaned}" — vérifier le champ ${result.champUtilise} et les traces ci-dessus`, + ); + } + return { matches, champUtilise: result.champUtilise ?? null, tronque: (result.total ?? 0) > matches.length, + avertissement: filtreNonApplique + ? "Qlik a répondu sans appliquer le filtre de recherche : les articles renvoyés ne correspondaient pas au terme et ont été écartés. Voir les logs [qlik-search]." + : null, }; } finally { await ctx.close(); From b2149ad0a6c2072b262f0fa4f1e153c410901764 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 12:40:16 +0000 Subject: [PATCH 06/34] =?UTF-8?q?fix(ui):=20message=20clair=20quand=20l'on?= =?UTF-8?q?glet=20est=20p=C3=A9rim=C3=A9=20apr=C3=A8s=20un=20d=C3=A9ploiem?= =?UTF-8?q?ent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit « Server Action "…" was not found on the server » n'est pas une panne du snapshot : Next.js identifie chaque Server Action par un hash calculé à la compilation, et un onglet ouvert avant un nouveau build continue d'envoyer les anciens identifiants. La seule issue est de recharger la page. - src/lib/stale-action.ts : détection de cette erreur + message utilisateur. - Grille et Snapshots : au lieu d'afficher l'erreur technique brute, on propose « Recharger la page ». Les brouillons de la Grille sont persistés (localStorage), recharger ne perd rien. - SuccessModal accepte `variant="error"` : icône d'alerte rouge au lieu de la coche verte, et surtout plus de fermeture automatique au bout de 3 s — un message d'échec doit rester lisible. Les erreurs s'affichaient jusqu'ici sous une coche de succès. - SuccessModal accepte une action secondaire optionnelle, et joue son animation d'entrée en CSS au lieu d'un setState synchrone dans un effet. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DfqUihgixw4K1AmJhizWiu --- src/components/shared/success-modal.tsx | 86 ++++++++++++++----- .../grid/components/floating-summary-bar.tsx | 37 ++++++-- .../snapshots/components/snapshot-list.tsx | 33 ++++++- src/lib/stale-action.ts | 26 ++++++ 4 files changed, 149 insertions(+), 33 deletions(-) create mode 100644 src/lib/stale-action.ts diff --git a/src/components/shared/success-modal.tsx b/src/components/shared/success-modal.tsx index c30ef8b..2a07db2 100644 --- a/src/components/shared/success-modal.tsx +++ b/src/components/shared/success-modal.tsx @@ -1,7 +1,7 @@ "use client"; -import React, { useEffect, useState } from "react"; -import { CheckCircle2, X } from "lucide-react"; +import React, { useCallback, useEffect, useState } from "react"; +import { AlertCircle, CheckCircle2, X } from "lucide-react"; interface SuccessModalProps { isOpen: boolean; @@ -9,35 +9,57 @@ interface SuccessModalProps { title: string; message: string; duration?: number; + /** + * `error` change l'icône, l'accent et **désactive la fermeture automatique** : + * un message d'échec doit rester à l'écran le temps d'être lu, et il ne doit + * pas s'afficher sous une coche verte de succès. + */ + variant?: "success" | "error"; + /** Action secondaire optionnelle (ex. « Recharger la page »). */ + action?: { label: string; onClick: () => void }; } +/** Durée de l'animation de sortie, alignée sur les classes `animate-out`. */ +const EXIT_MS = 200; + export function SuccessModal({ isOpen, onClose, title, message, - duration = 3000 + duration = 3000, + variant = "success", + action, }: SuccessModalProps) { - const [isVisible, setIsVisible] = useState(false); + // Seul l'état de SORTIE est porté par un state : l'entrée est jouée par CSS + // au montage (`animate-in`), ce qui évite un setState synchrone dans l'effet. + const [closing, setClosing] = useState(false); + const isError = variant === "error"; + + const handleClose = useCallback(() => { + setClosing(true); + setTimeout(() => { + setClosing(false); + onClose(); + }, EXIT_MS); + }, [onClose]); useEffect(() => { - if (isOpen) { - setIsVisible(true); - const timer = setTimeout(() => handleClose(), duration); - return () => clearTimeout(timer); - } - }, [isOpen, duration]); + // Une erreur ne disparaît pas toute seule : l'utilisateur la ferme. + if (!isOpen || isError) return; + const timer = setTimeout(() => handleClose(), duration); + return () => clearTimeout(timer); + }, [isOpen, duration, isError, handleClose]); - const handleClose = () => { - setIsVisible(false); - setTimeout(onClose, 250); - }; + if (!isOpen) return null; - if (!isOpen && !isVisible) return null; + const state = closing ? "closed" : "open"; + const Icon = isError ? AlertCircle : CheckCircle2; return (
{/* Backdrop */}
{/* Accent bar */} -
+
{/* Close button */} + )}
diff --git a/src/features/grid/components/floating-summary-bar.tsx b/src/features/grid/components/floating-summary-bar.tsx index 07df5a8..dbf5b7d 100644 --- a/src/features/grid/components/floating-summary-bar.tsx +++ b/src/features/grid/components/floating-summary-bar.tsx @@ -7,6 +7,7 @@ import { Loader2, CheckCircle, AlertCircle, RotateCcw, Camera, ChevronDown } fro import { useMemo, useState, useTransition } from "react"; import { saveSnapshot } from "@/features/snapshots/api/save-snapshot"; import { SuccessModal } from "@/components/shared/success-modal"; +import { isStaleServerActionError, STALE_ACTION_MESSAGE } from "@/lib/stale-action"; import { DropdownMenu, DropdownMenuContent, @@ -37,7 +38,13 @@ export function FloatingSummaryBar() { const [isPending, startTransition] = useTransition(); const [isSavingSnapshot, setIsSavingSnapshot] = useState(false); const [saveStatus, setSaveStatus] = useState<"idle" | "success" | "error">("idle"); - const [modal, setModal] = useState<{ isOpen: boolean, title: string, message: string }>({ + const [modal, setModal] = useState<{ + isOpen: boolean; + title: string; + message: string; + variant?: "success" | "error"; + action?: { label: string; onClick: () => void }; + }>({ isOpen: false, title: "", message: "" @@ -118,12 +125,26 @@ export function FloatingSummaryBar() { } catch (err) { console.error(err); if (!labelOverride) { - const msg = err instanceof Error ? err.message : String(err); - setModal({ - isOpen: true, - title: "Erreur Snapshot", - message: `Impossible de créer le snapshot : ${msg}` - }); + // Onglet resté ouvert pendant un déploiement : les identifiants de + // Server Action ont changé côté serveur. Rien à réparer côté + // métier, il faut recharger le bundle client. + if (isStaleServerActionError(err)) { + setModal({ + isOpen: true, + title: "Page à recharger", + message: STALE_ACTION_MESSAGE, + variant: "error", + action: { label: "Recharger la page", onClick: () => window.location.reload() }, + }); + } else { + const msg = err instanceof Error ? err.message : String(err); + setModal({ + isOpen: true, + title: "Erreur Snapshot", + message: `Impossible de créer le snapshot : ${msg}`, + variant: "error", + }); + } } } finally { setIsSavingSnapshot(false); @@ -327,6 +348,8 @@ export function FloatingSummaryBar() { onClose={() => setModal(prev => ({ ...prev, isOpen: false }))} title={modal.title} message={modal.message} + variant={modal.variant} + action={modal.action} />
); diff --git a/src/features/snapshots/components/snapshot-list.tsx b/src/features/snapshots/components/snapshot-list.tsx index 8281ee3..5f072bd 100644 --- a/src/features/snapshots/components/snapshot-list.tsx +++ b/src/features/snapshots/components/snapshot-list.tsx @@ -15,6 +15,7 @@ import { Loader2, } from "lucide-react"; import { SuccessModal } from "@/components/shared/success-modal"; +import { isStaleServerActionError, STALE_ACTION_MESSAGE } from "@/lib/stale-action"; import { ConfirmModal } from "@/components/shared/confirm-modal"; const formatDate = (date: Date) => { @@ -36,12 +37,34 @@ export function SnapshotList({ type }: SnapshotListProps) { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [isDeleting, setIsDeleting] = useState(null); - const [modal, setModal] = useState<{ isOpen: boolean, title: string, message: string }>({ + const [modal, setModal] = useState<{ + isOpen: boolean; + title: string; + message: string; + variant?: "success" | "error"; + action?: { label: string; onClick: () => void }; + }>({ isOpen: false, title: "", message: "" }); + /** + * Onglet resté ouvert pendant un déploiement : les identifiants de Server + * Action ont changé côté serveur. On propose le rechargement au lieu + * d'afficher une erreur technique incompréhensible. + */ + const modalErreur = (err: unknown, titre: string, message: string) => + isStaleServerActionError(err) + ? { + isOpen: true, + title: "Page à recharger", + message: STALE_ACTION_MESSAGE, + variant: "error" as const, + action: { label: "Recharger la page", onClick: () => window.location.reload() }, + } + : { isOpen: true, title: titre, message, variant: "error" as const }; + const [confirmModal, setConfirmModal] = useState<{ isOpen: boolean; title: string; @@ -59,7 +82,7 @@ export function SnapshotList({ type }: SnapshotListProps) { const data = await getSnapshots(type); setSnapshots(data); } catch (err) { - setError("Impossible de charger les données."); + setError(isStaleServerActionError(err) ? STALE_ACTION_MESSAGE : "Impossible de charger les données."); } finally { setLoading(false); } @@ -83,10 +106,10 @@ export function SnapshotList({ type }: SnapshotListProps) { setSnapshots(prev => prev.filter(s => s.id !== id)); setModal({ isOpen: true, title: "Action Réussie", message: "L'élément a été supprimé de votre historique." }); } else { - setModal({ isOpen: true, title: "Erreur", message: "Impossible de supprimer l'élément." }); + setModal({ isOpen: true, title: "Erreur", message: "Impossible de supprimer l'élément.", variant: "error" }); } } catch (err) { - setModal({ isOpen: true, title: "Erreur Technique", message: "Une erreur est survenue lors de la suppression." }); + setModal(modalErreur(err, "Erreur Technique", "Une erreur est survenue lors de la suppression.")); } finally { setIsDeleting(null); } @@ -215,6 +238,8 @@ export function SnapshotList({ type }: SnapshotListProps) { onClose={() => setModal(prev => ({ ...prev, isOpen: false }))} title={modal.title} message={modal.message} + variant={modal.variant} + action={modal.action} /> Date: Wed, 29 Jul 2026 13:41:53 +0000 Subject: [PATCH 07/34] =?UTF-8?q?fix(qlik-search):=20OU=20implicite=20entr?= =?UTF-8?q?e=20les=20mots,=20cube=20abandonn=C3=A9,=20mauvais=20champs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Les logs de production ont tranché les trois causes. 1. La recherche de list object Qlik applique un OU entre les mots : « tapis anti » ramenait 17 696 libellés sur 1 070 645 — presque tous ne contenant que « anti ». On cherche désormais mot par mot pour retenir le plus sélectif, on filtre en ET côté client (tous les mots présents, insensible casse/accents), et on ne sélectionne que les valeurs retenues. 2. Sélectionner ces 17 694 valeurs faisait abandonner le cube suivant (code 15 « Request aborted »), d'où le repli systématique sur le catalogue local. La sélection est maintenant plafonnée à 1 000 valeurs, faite par numéro d'élément (SelectListObjectValues) plutôt que par texte, et les appels Engine retentent trois fois sur code 15. 3. Les champs détectés étaient les mauvais : article_libelle_ticket (libellé ticket tronqué) au lieu d'Article, et code_fournisseur (le code, d'où les « F005 » affichés) au lieu de Fournisseur. Une liste de préférences précède désormais l'heuristique, avec repli automatique sur le candidat suivant si un champ ne ramène rien. Aussi : - Un repli catalogue n'est plus mis en cache 10 min : une panne Qlik passagère se corrigeait uniquement avec « Relancer ». - Le code 15 est traduit en message actionnable, et un terme trop large pour le moteur est signalé au lieu d'un « aucun résultat » trompeur. - Les logs de la page ne relaient plus le bruit du client Qlik (thèmes, extensions). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DfqUihgixw4K1AmJhizWiu --- docs/qlik-integration.md | 21 +- src/features/produits/api/search-produits.ts | 10 +- src/lib/qlik-search.ts | 299 ++++++++++++------- 3 files changed, 216 insertions(+), 114 deletions(-) diff --git a/docs/qlik-integration.md b/docs/qlik-integration.md index c418c25..2f202e3 100644 --- a/docs/qlik-integration.md +++ b/docs/qlik-integration.md @@ -22,10 +22,14 @@ La page `/produits` ne cherche **plus** dans le catalogue FF Nancy en premier : le réseau référence bien plus de produits que Nancy, et ce sont précisément ceux-là qu'on veut voir. -1. `searchQlikArticles()` — recherche globale Engine (`SearchResults`) restreinte - aux champs article (code + libellé, détectés dynamiquement via `FieldList`). - Les valeurs trouvées sont sélectionnées dans leur champ, ce qui restreint la - dimension « Article Code » aux articles correspondants. +1. `searchQlikArticles()` — recherche de list object sur le champ libellé + article. `SearchListObjectFor` applique un **OU** entre les mots (« tapis + anti » ramenait 17 696 libellés sur l'app FF, et sélectionner ces 17 696 + valeurs faisait abandonner le cube en `code 15 — Request aborted`). On + cherche donc mot par mot pour retenir le plus sélectif, on filtre en **ET** + côté client, puis on sélectionne les valeurs retenues par numéro d'élément + (`SelectListObjectValues`). La sélection restreint la dimension « Article + Code » aux articles correspondants. 2. `fetchNetworkMetricsPlaywright()` — mesures réseau + détail mensuel sur la fenêtre 12 mois glissants (mois en cours exclu), puis `upsertNetworkMetrics()` pour que la fiche produit soit immédiatement servie depuis le cache. @@ -35,9 +39,12 @@ ceux-là qu'on veut voir. Repli : si Qlik est injoignable, la recherche retombe sur `pgSearchProduits()` et la réponse le signale (`source: "db"`). Résultats mis en cache mémoire 10 min. -Les noms des champs libellé / fournisseur varient d'une app Qlik à l'autre : ils -sont détectés par heuristique, et l'inventaire complet des champs est loggué -(`[qlik-search] champs disponibles=…`). Si la détection tombe à côté, forcer : +Champs de l'app FF (« Magasins Vision Consolidée ») : le code est `Article Code`, +le libellé `Article` (repli `article_libelle_ticket`, qui est le libellé ticket +tronqué), le fournisseur `Fournisseur` (repli `code_fournisseur`, qui ne porte +que le code). Ces préférences sont dans `CHAMPS_LIBELLE_PREFERES` / +`CHAMPS_FOURNISSEUR_PREFERES`, complétées par une heuristique pour les autres +apps. Si la détection tombe à côté, forcer : ``` QLIK_FIELD_ARTICLE_LIBELLE= diff --git a/src/features/produits/api/search-produits.ts b/src/features/produits/api/search-produits.ts index 4d564fb..e29f1fb 100644 --- a/src/features/produits/api/search-produits.ts +++ b/src/features/produits/api/search-produits.ts @@ -77,7 +77,12 @@ export async function rechercherProduits( const p = executerRecherche(cleaned).finally(() => enCours.delete(key)); enCours.set(key, p); const result = await p; - cache.set(key, { at: Date.now(), result }); + // On ne met en cache qu'un résultat Qlik exploitable : mémoriser un repli + // catalogue (panne Qlik passagère) le figerait pour 10 minutes alors qu'un + // simple « Relancer » aurait suffi. + if (result.source === "qlik" && !result.qlikError) { + cache.set(key, { at: Date.now(), result }); + } return result; } @@ -240,6 +245,9 @@ function messageQlikLisible(message: string): string { if (lower.includes("out of memory") || lower.includes("not enough memory") || lower.includes('"code":6') || lower.includes('"code":3002')) { return "Serveur Qlik saturé : mémoire insuffisante pour charger l'application. Réessayez plus tard."; } + if (/"code"\s*:\s*15\b/.test(message) || lower.includes("request aborted")) { + return "Qlik a interrompu la requête (recherche trop large pour le moteur). Ajoutez un mot plus précis, puis relancez."; + } if (lower.includes("identifiants qlik manquants") || lower.includes("qlik_user")) { return "Identifiants Qlik non configurés (Paramètres → Qlik)."; } diff --git a/src/lib/qlik-search.ts b/src/lib/qlik-search.ts index ca57b12..7de7dd7 100644 --- a/src/lib/qlik-search.ts +++ b/src/lib/qlik-search.ts @@ -15,16 +15,23 @@ * websocket Engine in-page avec le qlik-csrf-token), car le proxy Qlik refuse * un websocket « raw » côté serveur. * - * L'API Engine utilisée est la recherche de **list object** - * (`SearchListObjectFor` + `AcceptListObjectSearch`), c'est-à-dire exactement ce - * que fait un volet de filtre Qlik quand on tape dans sa zone de recherche : - * Qlik applique sa propre sémantique (tous les mots présents, insensible à la - * casse et aux accents) puis sélectionne les valeurs trouvées. La sélection - * restreint alors la dimension « Article Code » aux articles correspondants. + * ## Comment la sélection est construite * - * Le résultat est **revérifié côté client** avant d'être renvoyé : si la - * sélection ne prend pas, le cube renvoie le début du catalogue, et une liste - * sans rapport avec la recherche est pire que pas de résultat du tout. + * La recherche d'un list object Qlik (`SearchListObjectFor`) applique un **OU** + * entre les mots : « tapis anti » a ramené 17 696 libellés sur l'app FF, dont + * l'immense majorité ne contient que « anti ». Sélectionner ces 17 696 valeurs + * faisait ensuite exploser le cube (`code 15 — Request aborted`). + * + * On procède donc en trois temps : + * 1. une recherche par mot, pour retenir le **plus sélectif** (celui qui + * ramène le moins de valeurs) ; + * 2. lecture de ces valeurs et filtrage **ET** côté client (tous les mots + * présents, insensible casse/accents) ; + * 3. sélection des seules valeurs retenues, **par numéro d'élément** + * (`SelectListObjectValues`) — exact, sans ré-appariement de texte. + * + * Le résultat final est revérifié une dernière fois avant d'être renvoyé : une + * liste sans rapport avec la recherche est pire que pas de résultat du tout. */ import "server-only"; @@ -49,8 +56,8 @@ export interface QlikSearchOutcome { tronque: boolean; /** * Message de diagnostic quand la recherche aboutit à zéro article alors que - * Qlik a bien renvoyé des lignes : le filtre n'a pas été appliqué côté - * Engine et le garde-fou a tout écarté. `null` en fonctionnement nominal. + * Qlik a bien répondu (filtre non appliqué, terme trop large…). `null` en + * fonctionnement nominal. */ avertissement: string | null; } @@ -64,6 +71,18 @@ export interface QlikSearchOutcome { */ export const QLIK_SEARCH_MAX_RESULTS = 40; +/** + * Champs candidats pour le libellé article, par ordre de préférence. + * + * Sur l'app FF, `Article` est le libellé métier et `article_libelle_ticket` le + * libellé (tronqué) imprimé sur le ticket. On essaie le premier qui donne des + * résultats : si un candidat ne matche rien, on passe au suivant. + */ +const CHAMPS_LIBELLE_PREFERES = ["Article", "article_libelle_ticket"]; + +/** Champs candidats pour le fournisseur, par ordre de préférence (nom avant code). */ +const CHAMPS_FOURNISSEUR_PREFERES = ["Fournisseur", "code_fournisseur"]; + let browserPromise: Promise | null = null; async function getBrowser(): Promise { @@ -83,11 +102,14 @@ interface InPageSearchResult { error?: string; rows?: Array<[string, string, string]>; champUtilise?: string | null; + /** Nombre d'articles uniques correspondant au terme (avant plafonnement). */ total?: number; /** Lignes brutes lues dans le cube (avant garde-fou). */ lues?: number; /** Lignes écartées parce qu'elles ne correspondaient pas au terme. */ rejetees?: number; + /** true si le terme ramenait trop de valeurs pour être traité intégralement. */ + tropLarge?: boolean; /** Journal compact des étapes, remonté dans les logs serveur. */ trace?: string[]; } @@ -128,7 +150,12 @@ export async function searchQlikArticles( try { const page = await ctx.newPage(); - page.on("console", (msg) => console.log(`[qlik-search][page] ${msg.text()}`)); + page.on("console", (msg) => { + // Le client Qlik est très bavard au chargement (thèmes, extensions) : + // on ne relaie que ce qui vient de notre script. + const t = msg.text(); + if (t.startsWith("[cf]")) console.log(`[qlik-search][page] ${t}`); + }); let csrfToken: string | null = null; let resolveToken: (() => void) | null = null; @@ -144,11 +171,18 @@ export async function searchQlikArticles( if (!csrfToken) throw new Error("[qlik-search] qlik-csrf-token introuvable (client Qlik non chargé ?)"); const result = (await page.evaluate( - ({ app, dim, token, terme, limite, settleMs, champLibelleForce, champFournisseurForce }: { + ({ app, dim, token, terme, limite, settleMs, champsLibellePreferes, champsFournisseurPreferes, champLibelleForce, champFournisseurForce }: { app: string; dim: string; token: string; terme: string; limite: number; settleMs: number; + champsLibellePreferes: string[]; champsFournisseurPreferes: string[]; champLibelleForce: string; champFournisseurForce: string; }) => new Promise((resolve) => { + /** Valeurs lues au maximum dans le champ recherché (2 pages Engine). */ + const MAX_VALEURS_LUES = 10000; + /** Valeurs sélectionnées au maximum — au-delà, le cube abandonne (code 15). */ + const MAX_VALEURS_SELECTION = 1000; + const PAGE_VALEURS = 5000; + const loc = (window as unknown as { location: Location }).location; const url = `wss://${loc.host}/app/${app}?reloadUri=${encodeURIComponent(loc.href)}&qlik-csrf-token=${token}`; const ws = new WebSocket(url); @@ -174,16 +208,39 @@ export async function searchQlikArticles( setTimeout(() => rej(new Error("ws timeout")), 30000); }); + const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + /** Qlik Engine code 15 = « Request aborted » : requête entrée en collision avec une évaluation. */ + const estCode15 = (e: unknown) => /"code"\s*:\s*15\b/.test(String((e as Error)?.message ?? e)); + const rpcRetry = async (method: string, params: unknown, handle: number, tentatives = 3) => { + let derniere: unknown; + for (let i = 1; i <= tentatives; i++) { + try { + return await rpc(method, params, handle); + } catch (e) { + derniere = e; + if (!estCode15(e) || i === tentatives) throw e; + trace.push("code15 sur " + method + ", tentative " + i + "/" + tentatives); + await sleep(settleMs * (i + 1)); + } + } + throw derniere; + }; + + const normaliser = (s: string) => s + .toLowerCase() + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") + .replace(/[^a-z0-9]+/g, " ") + .trim(); + const mots = normaliser(terme).split(" ").filter(Boolean); + (async () => { try { await ready; const open = await rpc("OpenDoc", { qDocName: app }); const doc = (open.qReturn as { qHandle: number }).qHandle; - trace.push("OpenDoc ok"); // ─── 1. Inventaire des champs ──────────────────────────── - // Sert à repérer, sans connaître l'app par cœur, le champ - // libellé article et le champ fournisseur. const fl = await rpc("CreateSessionObject", { qProp: { qInfo: { qType: "FieldList" }, @@ -193,70 +250,105 @@ export async function searchQlikArticles( const fll = await rpc("GetLayout", {}, (fl.qReturn as { qHandle: number }).qHandle); const champs = ((((fll.qLayout as { qFieldList?: { qItems?: Array<{ qName: string }> } }).qFieldList?.qItems) ?? []) .map((f) => f.qName)); - trace.push("champs=" + champs.length); const has = (nom: string, re: RegExp) => re.test(nom); const champCode = champs.find((c) => c === "Article Code") ?? champs.find((c) => has(c, /article/i) && has(c, /code/i)) ?? "Article Code"; - // Les noms exacts varient selon l'app Qlik : détection par - // heuristique, surchargeable par variable d'environnement si - // l'app expose un intitulé inattendu (cf. les logs [qlik-search]). - const champLibelle = (champLibelleForce && champs.includes(champLibelleForce) ? champLibelleForce : null) - ?? champs.find((c) => has(c, /article/i) && has(c, /(libell|designation|désignation|denomination|dénomination)/i)) - ?? champs.find((c) => has(c, /(libell|designation|désignation)/i) && !has(c, /(magasin|region|région|famille|rayon|fournisseur)/i)) - ?? null; - const champFournisseur = (champFournisseurForce && champs.includes(champFournisseurForce) ? champFournisseurForce : null) - ?? champs.find((c) => has(c, /fournisseur/i)) - ?? champs.find((c) => has(c, /(fabricant|supplier)/i)) - ?? null; - // Inventaire complet en trace : sans accès Qlik depuis le poste - // de dev, c'est le seul moyen de régler les surcharges ci-dessus. - trace.push("champs disponibles=" + JSON.stringify(champs)); - trace.push("code=" + champCode + " libelle=" + champLibelle + " fournisseur=" + champFournisseur); - // ─── 2. Sélection par recherche de champ ───────────────── + // Candidats libellé : surcharge d'env, puis noms connus de + // l'app FF, puis heuristique. On essaiera chacun dans l'ordre + // jusqu'à en trouver un qui ramène des valeurs. + const candidatsLibelle = [ + champLibelleForce, + ...champsLibellePreferes, + ...champs.filter((c) => has(c, /article/i) && has(c, /(libell|designation|désignation|denomination|dénomination)/i)), + ...champs.filter((c) => has(c, /(libell|designation|désignation)/i) && !has(c, /(magasin|region|région|famille|rayon|fournisseur)/i)), + ].filter((c, i, arr) => c && champs.includes(c) && arr.indexOf(c) === i); + + const champFournisseur = [ + champFournisseurForce, + ...champsFournisseurPreferes, + ...champs.filter((c) => has(c, /fournisseur/i)), + ...champs.filter((c) => has(c, /(fabricant|supplier)/i)), + ].find((c) => c && champs.includes(c)) ?? null; + + trace.push("code=" + champCode + " libellé candidats=" + JSON.stringify(candidatsLibelle) + " fournisseur=" + champFournisseur); + + // ─── 2. Recherche + sélection sur le champ le plus adapté ─ // - // On passe par un **list object** + `SearchListObjectFor` / - // `AcceptListObjectSearch` : c'est exactement ce que fait un - // volet de filtre Qlik quand on tape dans sa zone de recherche. - // Qlik applique lui-même sa sémantique (mots multiples = tous - // présents, insensible casse/accents) et sélectionne les valeurs - // trouvées — sans qu'on ait à ré-injecter des `qText` à - // l'identique, ce qui échouait silencieusement et laissait le - // cube renvoyer le début du catalogue. - const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); - - const chercherEtSelectionner = async (champ: string): Promise => { - const lo = await rpc("CreateSessionObject", { + // `SearchListObjectFor` applique un OU entre les mots. On + // cherche donc mot par mot pour retenir le plus sélectif, + // puis on filtre en ET côté client avant de sélectionner. + const chercherEtSelectionner = async (champ: string): Promise<{ retenues: number; brut: number; tropLarge: boolean }> => { + const lo = await rpcRetry("CreateSessionObject", { qProp: { qInfo: { qType: "cf-search-lb" }, qListObjectDef: { qDef: { qFieldDefs: [champ] }, - qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 1, qHeight: 1 }], + qInitialDataFetch: [], }, }, }, doc); const ret = lo.qReturn as { qHandle: number; qGenericId?: string; qId?: string }; const loId = String(ret.qGenericId ?? ret.qId ?? ""); + const lh = ret.qHandle; try { - await rpc("SearchListObjectFor", { qPath: "/qListObjectDef", qMatch: terme }, ret.qHandle); - await sleep(settleMs); - const lay = await rpc("GetLayout", {}, ret.qHandle); - const nb = ((lay.qLayout as { qListObject?: { qSize?: { qcy?: number } } }).qListObject?.qSize?.qcy) ?? 0; - trace.push("recherche " + champ + " → " + nb + " valeur(s)"); - if (nb > 0) { - await rpc("AcceptListObjectSearch", { - qPath: "/qListObjectDef", - qToggleMode: false, - qSoftLock: true, - }, ret.qHandle); - // L'Engine doit finir d'évaluer la sélection avant que - // le cube suivant soit calculé, sinon il est construit - // sur l'état précédent (= catalogue entier). - await sleep(settleMs); + // 2a. Mot le plus sélectif (une recherche par mot, on ne lit que le compteur). + let meilleurMot = terme; + let meilleurNb = Number.POSITIVE_INFINITY; + for (const mot of (mots.length > 1 ? mots : [terme])) { + await rpcRetry("SearchListObjectFor", { qPath: "/qListObjectDef", qMatch: mot }, lh); + const lay = await rpcRetry("GetLayout", {}, lh); + const nb = ((lay.qLayout as { qListObject?: { qSize?: { qcy?: number } } }).qListObject?.qSize?.qcy) ?? 0; + trace.push(" « " + mot + " » dans " + champ + " → " + nb + " valeur(s)"); + if (nb > 0 && nb < meilleurNb) { meilleurNb = nb; meilleurMot = mot; } } - return nb; + if (!Number.isFinite(meilleurNb) || meilleurNb === 0) return { retenues: 0, brut: 0, tropLarge: false }; + + // 2b. Relecture des valeurs du mot retenu. On relance la + // recherche systématiquement : la boucle ci-dessus a pu + // laisser le list object sur un autre mot. + await rpcRetry("SearchListObjectFor", { qPath: "/qListObjectDef", qMatch: meilleurMot }, lh); + const aLire = Math.min(meilleurNb, MAX_VALEURS_LUES); + const elems: number[] = []; + let lues = 0; + for (let top = 0; top < aLire; top += PAGE_VALEURS) { + const d = await rpcRetry("GetListObjectData", { + qPath: "/qListObjectDef", + qPages: [{ qTop: top, qLeft: 0, qWidth: 1, qHeight: Math.min(PAGE_VALEURS, aLire - top) }], + }, lh); + const matrix = ((d.qDataPages as Array<{ qMatrix?: Array> }>)?.[0]?.qMatrix) ?? []; + if (matrix.length === 0) break; + lues += matrix.length; + for (const ligne of matrix) { + const cell = ligne[0]; + const texte = String(cell?.qText ?? ""); + const elem = cell?.qElemNumber; + if (elem == null || elem < 0) continue; + // Filtre ET : tous les mots du terme présents. + const n = normaliser(texte); + if (!mots.every((m) => n.includes(m))) continue; + elems.push(elem); + if (elems.length >= MAX_VALEURS_SELECTION) break; + } + if (elems.length >= MAX_VALEURS_SELECTION) break; + } + trace.push(" mot retenu « " + meilleurMot + " » : " + lues + " valeur(s) lues → " + elems.length + " après filtre ET"); + if (elems.length === 0) { + return { retenues: 0, brut: meilleurNb, tropLarge: meilleurNb > MAX_VALEURS_LUES }; + } + + // 2c. Sélection par numéro d'élément : exact, aucun + // ré-appariement de texte possible. + await rpcRetry("SelectListObjectValues", { + qPath: "/qListObjectDef", + qValues: elems, + qToggleMode: false, + qSoftLock: true, + }, lh); + await sleep(settleMs); + return { retenues: elems.length, brut: meilleurNb, tropLarge: meilleurNb > MAX_VALEURS_LUES }; } finally { if (loId) { try { await rpc("DestroySessionObject", { qId: loId }, doc); } catch { /* noop */ } } } @@ -265,28 +357,32 @@ export async function searchQlikArticles( await rpc("ClearAll", { qLockedAlso: false }, doc); await sleep(settleMs); - // Le libellé d'abord (cas courant), le code centrale ensuite. let champChoisi: string | null = null; - if (champLibelle && (await chercherEtSelectionner(champLibelle)) > 0) { - champChoisi = champLibelle; - } else if ((await chercherEtSelectionner(champCode)) > 0) { - champChoisi = champCode; + let tropLarge = false; + for (const candidat of candidatsLibelle) { + const r = await chercherEtSelectionner(candidat); + tropLarge = tropLarge || r.tropLarge; + if (r.retenues > 0) { champChoisi = candidat; break; } + await rpc("ClearAll", { qLockedAlso: false }, doc); + await sleep(settleMs); + } + if (!champChoisi) { + // Repli code centrale (terme saisi = un code). + const r = await chercherEtSelectionner(champCode); + if (r.retenues > 0) champChoisi = champCode; + tropLarge = tropLarge || r.tropLarge; } if (!champChoisi) { ws.close(); - return resolve({ ok: true, rows: [], champUtilise: null, total: 0, lues: 0, rejetees: 0, trace }); + return resolve({ ok: true, rows: [], champUtilise: null, total: 0, lues: 0, rejetees: 0, tropLarge, trace }); } + trace.push("champ retenu = " + champChoisi); - // Trace des sélections réellement actives : si elle est vide, la - // sélection n'a pas pris côté Engine et le cube ci-dessous - // renverra tout le catalogue (le garde-fou de l'étape 4 le - // rattrape, mais c'est ici qu'on voit pourquoi). + // Trace des sélections réellement actives : si elle est vide, + // la sélection n'a pas pris côté Engine. try { const cs = await rpc("CreateSessionObject", { - qProp: { - qInfo: { qType: "cf-search-sel" }, - qSelectionObjectDef: {}, - }, + qProp: { qInfo: { qType: "cf-search-sel" }, qSelectionObjectDef: {} }, }, doc); const csl = await rpc("GetLayout", {}, (cs.qReturn as { qHandle: number }).qHandle); const sel = (((csl.qLayout as { qSelectionObject?: { qSelections?: Array<{ qField?: string; qSelectedCount?: number; qTotal?: number }> } }) @@ -301,8 +397,9 @@ export async function searchQlikArticles( // Cube sans mesure : uniquement l'identité de l'article. Les // métriques réseau sont extraites juste après par // fetchNetworkMetricsPlaywright (qui produit aussi le mensuel). + const champLibelleAffiche = champChoisi === champCode ? candidatsLibelle[0] ?? null : champChoisi; const dims: Array<{ qLibraryId?: string; qDef?: { qFieldDefs: string[] } }> = [{ qLibraryId: dim }]; - if (champLibelle) dims.push({ qDef: { qFieldDefs: [champLibelle] } }); + if (champLibelleAffiche) dims.push({ qDef: { qFieldDefs: [champLibelleAffiche] } }); if (champFournisseur) dims.push({ qDef: { qFieldDefs: [champFournisseur] } }); const largeur = dims.length; // On lit large puis on dédoublonne : un article référencé chez @@ -310,7 +407,7 @@ export async function searchQlikArticles( // fournisseurs, il ne doit pas consommer plusieurs places. const hauteur = Math.min(1000, Math.floor(9000 / Math.max(largeur, 1))); - const cube = await rpc("CreateSessionObject", { + const cube = await rpcRetry("CreateSessionObject", { qProp: { qInfo: { qType: "cf-search" }, qHyperCubeDef: { @@ -322,7 +419,7 @@ export async function searchQlikArticles( }, }, doc); const ch = (cube.qReturn as { qHandle: number }).qHandle; - const layout = await rpc("GetLayout", {}, ch); + const layout = await rpcRetry("GetLayout", {}, ch); const hc = (layout.qLayout as { qHyperCube: { qSize: { qcy: number }; @@ -332,18 +429,6 @@ export async function searchQlikArticles( const matrix = hc.qDataPages?.[0]?.qMatrix ?? []; // ─── 4. Garde-fou : le résultat DOIT correspondre au terme ── - // Si la sélection Qlik n'a pas pris (droits, modèle de données, - // évaluation en retard), le cube renvoie le début du catalogue. - // On revérifie donc chaque ligne côté client : mots tous présents - // dans le libellé, ou code correspondant au terme. Mieux vaut - // zéro résultat qu'une liste sans rapport avec la recherche. - const normaliser = (s: string) => s - .toLowerCase() - .normalize("NFD") - .replace(/[\u0300-\u036f]/g, "") - .replace(/[^a-z0-9]+/g, " ") - .trim(); - const mots = normaliser(terme).split(" ").filter(Boolean); const termeCode = terme.trim().toLowerCase(); const correspond = (code: string, libelle: string): boolean => { if (mots.length === 0) return true; @@ -359,15 +444,14 @@ export async function searchQlikArticles( for (const r of matrix) { const code = String(r[0]?.qText ?? "").trim(); if (!code || code === "-") continue; - const libelleBrut = champLibelle ? String(r[1]?.qText ?? "").trim() : ""; - const idxFou = champLibelle ? 2 : 1; + const libelleBrut = champLibelleAffiche ? String(r[1]?.qText ?? "").trim() : ""; + const idxFou = champLibelleAffiche ? 2 : 1; const fournisseurBrut = champFournisseur ? String(r[idxFou]?.qText ?? "").trim() : ""; const libelle = libelleBrut === "-" ? "" : libelleBrut; const fournisseur = fournisseurBrut === "-" ? "" : fournisseurBrut; if (!correspond(code, libelle)) { rejetees++; continue; } const existant = parCode.get(code); if (!existant) { parCode.set(code, [code, libelle, fournisseur]); continue; } - // Complète les champs manquants d'une ligne déjà vue. if (!existant[1] && libelle) existant[1] = libelle; if (!existant[2] && fournisseur) existant[2] = fournisseur; } @@ -382,7 +466,7 @@ export async function searchQlikArticles( ws.close(); resolve({ ok: true, rows, champUtilise: champChoisi, total: uniques.length, - lues: matrix.length, rejetees, trace, + lues: matrix.length, rejetees, tropLarge, trace, }); } catch (e) { try { ws.close(); } catch { /* noop */ } @@ -397,6 +481,8 @@ export async function searchQlikArticles( terme: cleaned, limite: limit, settleMs, + champsLibellePreferes: CHAMPS_LIBELLE_PREFERES, + champsFournisseurPreferes: CHAMPS_FOURNISSEUR_PREFERES, champLibelleForce: (process.env.QLIK_FIELD_ARTICLE_LIBELLE ?? "").trim(), champFournisseurForce: (process.env.QLIK_FIELD_FOURNISSEUR ?? "").trim(), }, @@ -409,24 +495,25 @@ export async function searchQlikArticles( codeCentrale, libelle, fournisseur, })); console.log( - `[qlik-search] "${cleaned}" → ${matches.length} article(s) (total Qlik ${result.total ?? 0}, champ=${result.champUtilise}) en ${Date.now() - started} ms`, + `[qlik-search] "${cleaned}" → ${matches.length} article(s) (total ${result.total ?? 0}, champ=${result.champUtilise}) en ${Date.now() - started} ms`, ); - // Toutes les lignes écartées = Qlik a bien répondu mais sans appliquer la - // sélection. On le dit au lieu d'afficher un « aucun résultat » trompeur. - const filtreNonApplique = matches.length === 0 && (result.rejetees ?? 0) > 0; - if (filtreNonApplique) { - console.warn( - `[qlik-search] filtre non appliqué : ${result.rejetees} ligne(s) lues sans rapport avec "${cleaned}" — vérifier le champ ${result.champUtilise} et les traces ci-dessus`, - ); + + // Zéro article alors que Qlik a répondu : on explique pourquoi plutôt que + // d'afficher un « aucun résultat » qui laisserait croire à une absence + // réelle dans le réseau. + let avertissement: string | null = null; + if (matches.length === 0 && (result.rejetees ?? 0) > 0) { + avertissement = "Qlik a répondu sans appliquer le filtre de recherche : les articles renvoyés ne correspondaient pas au terme et ont été écartés. Voir les logs [qlik-search]."; + } else if (matches.length === 0 && result.tropLarge) { + avertissement = "Le terme est trop large pour Qlik (plus de 10 000 libellés correspondants). Ajoutez un mot plus précis."; } + if (avertissement) console.warn(`[qlik-search] ${avertissement}`); return { matches, champUtilise: result.champUtilise ?? null, tronque: (result.total ?? 0) > matches.length, - avertissement: filtreNonApplique - ? "Qlik a répondu sans appliquer le filtre de recherche : les articles renvoyés ne correspondaient pas au terme et ont été écartés. Voir les logs [qlik-search]." - : null, + avertissement, }; } finally { await ctx.close(); From c9ecb47cb11cb291237b68e8e263fbe367b442ae Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 04:57:17 +0000 Subject: [PATCH 08/34] =?UTF-8?q?fix(produits):=20recherche=20asynchrone?= =?UTF-8?q?=20=E2=80=94=20la=20requ=C3=AAte=20=C3=A9tait=20coup=C3=A9e=20p?= =?UTF-8?q?ar=20le=20reverse=20proxy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit « Unexpected token '<', " Claude-Session: https://claude.ai/code/session_01DfqUihgixw4K1AmJhizWiu --- docs/qlik-integration.md | 11 +- src/app/(dashboard)/produits/results.tsx | 116 +++++++++++-- src/app/api/produits/search/route.ts | 69 +++++--- src/features/produits/api/search-produits.ts | 165 ++++++++++++++++--- 4 files changed, 295 insertions(+), 66 deletions(-) diff --git a/docs/qlik-integration.md b/docs/qlik-integration.md index 2f202e3..2becb43 100644 --- a/docs/qlik-integration.md +++ b/docs/qlik-integration.md @@ -36,8 +36,17 @@ ceux-là qu'on veut voir. 3. `pgGetProduitsByCodeCentrale()` — rapprochement avec le catalogue Nancy. Un code absent = produit réseau que nous ne référençons pas (`?cc=` sur la fiche). +L'API est **asynchrone** : `POST /api/produits/search?q=…` démarre un job et rend +la main tout de suite, `GET` renvoie l'avancement puis le résultat (polling client +toutes les 2 s). Une requête HTTP maintenue pendant toute l'extraction se faisait +couper par le reverse proxy, qui répond une page HTML — le client échouait sur +« Unexpected token '<' … is not valid JSON ». Même schéma que `POST /api/qlik/sync`. +Deux recherches simultanées au maximum (le serveur Qlik sature vite). + Repli : si Qlik est injoignable, la recherche retombe sur `pgSearchProduits()` et -la réponse le signale (`source: "db"`). Résultats mis en cache mémoire 10 min. +la réponse le signale (`source: "db"`). Un résultat Qlik exploitable est mis en +cache mémoire 10 min ; un repli ne l'est pas, sinon une panne passagère resterait +figée. Champs de l'app FF (« Magasins Vision Consolidée ») : le code est `Article Code`, le libellé `Article` (repli `article_libelle_ticket`, qui est le libellé ticket diff --git a/src/app/(dashboard)/produits/results.tsx b/src/app/(dashboard)/produits/results.tsx index 686c1e2..1190f3e 100644 --- a/src/app/(dashboard)/produits/results.tsx +++ b/src/app/(dashboard)/produits/results.tsx @@ -15,12 +15,55 @@ const fmtEur = (v: number) => const fmtEur2 = (v: number) => new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 2 }).format(v); +/** Intervalle d'interrogation de l'état du job de recherche. */ +const POLL_MS = 2000; + +/** Réponse du job de recherche, telle que la renvoie l'API. */ +interface JobRecherche { + status: "idle" | "running" | "success" | "error"; + etape?: string; + error?: string; + result?: ProduitRechercheResultat; +} + +/** + * Lit une réponse HTTP en **ne présumant pas** qu'elle est du JSON. + * + * Un reverse proxy qui coupe la requête répond une page HTML : `res.json()` + * échouait alors sur « Unexpected token '<' … is not valid JSON », message + * inexploitable pour l'utilisateur. On traduit ici le statut HTTP. + */ +async function lireJson(res: Response): Promise> { + const texte = await res.text(); + let data: Record | null = null; + try { + data = texte ? (JSON.parse(texte) as Record) : null; + } catch { + data = null; + } + if (data == null) { + if (res.status === 504 || res.status === 408) { + throw new Error("Le serveur a mis trop de temps à répondre (délai dépassé côté proxy). Réessayez."); + } + if (res.status === 502 || res.status === 503) { + throw new Error("Application indisponible ou en cours de redémarrage. Réessayez dans un instant."); + } + if (res.status === 401 || res.status === 403) { + throw new Error("Session expirée. Rechargez la page pour vous reconnecter."); + } + throw new Error(`Réponse inattendue du serveur (HTTP ${res.status}).`); + } + if (!res.ok) throw new Error(String(data.error ?? `HTTP ${res.status}`)); + return data; +} + /** * Résultats de recherche produit. * - * La recherche interroge **Qlik d'abord** (`/api/produits/search`) : plusieurs - * secondes, parfois plus. On la lance donc côté client avec un état de - * chargement explicite, plutôt que de bloquer le rendu serveur de la page. + * La recherche interroge **Qlik d'abord** et peut dépasser la minute : elle est + * lancée par un `POST` qui rend la main tout de suite, puis on interroge son + * avancement toutes les 2 s. Une requête HTTP maintenue pendant toute + * l'extraction se faisait couper par le reverse proxy. */ export function ProduitResults({ query }: { query: string }) { // `relance` mémorise la dernière demande explicite de rafraîchissement, pour @@ -30,6 +73,7 @@ export function ProduitResults({ query }: { query: string }) { cle: string; data: ProduitRechercheResultat | null; error: string | null; + etape: string | null; } | null>(null); const force = relance.n > 0 && relance.q === query; @@ -39,28 +83,64 @@ export function ProduitResults({ query }: { query: string }) { // L'état de chargement est **dérivé** (résultat pas encore aligné sur la // requête courante) plutôt que posé en début d'effet : appeler setState // synchronement dans un effet provoque un rendu en cascade. - const loading = !tropCourt && resultat?.cle !== cle; + const enAttente = resultat?.cle !== cle || (!resultat?.data && !resultat?.error); + const loading = !tropCourt && enAttente; useEffect(() => { if (tropCourt) return; let annule = false; - const url = `/api/produits/search?q=${encodeURIComponent(query)}${force ? "&force=1" : ""}`; - fetch(url, { cache: "no-store" }) - .then(async (res) => { - const json = await res.json(); - if (!res.ok) throw new Error(json.error || `HTTP ${res.status}`); - return json as ProduitRechercheResultat; - }) - .then((data) => { if (!annule) setResultat({ cle, data, error: null }); }) - .catch((e) => { - if (!annule) setResultat({ cle, data: null, error: e instanceof Error ? e.message : String(e) }); - }); - return () => { annule = true; }; + let timer: ReturnType | undefined; + + const url = (methode: "POST" | "GET") => + `/api/produits/search?q=${encodeURIComponent(query)}${methode === "POST" && force ? "&force=1" : ""}`; + + const appliquer = (job: JobRecherche): boolean => { + if (annule) return true; + if (job.status === "success" && job.result) { + setResultat({ cle, data: job.result, error: null, etape: null }); + return true; + } + if (job.status === "error") { + setResultat({ cle, data: null, error: job.error ?? "La recherche a échoué.", etape: null }); + return true; + } + // `idle` = le process a redémarré et a perdu le job : on relance. + setResultat({ cle, data: null, error: null, etape: job.etape ?? null }); + return false; + }; + + const poll = async () => { + try { + const job = (await lireJson(await fetch(url("GET"), { cache: "no-store" }))) as unknown as JobRecherche; + if (job.status === "idle") { + await demarrer(); + return; + } + if (!appliquer(job) && !annule) timer = setTimeout(() => void poll(), POLL_MS); + } catch (e) { + if (!annule) setResultat({ cle, data: null, error: e instanceof Error ? e.message : String(e), etape: null }); + } + }; + + const demarrer = async () => { + try { + const job = (await lireJson( + await fetch(url("POST"), { method: "POST", cache: "no-store" }), + )) as unknown as JobRecherche; + if (!appliquer(job) && !annule) timer = setTimeout(() => void poll(), POLL_MS); + } catch (e) { + if (!annule) setResultat({ cle, data: null, error: e instanceof Error ? e.message : String(e), etape: null }); + } + }; + + void demarrer(); + return () => { annule = true; if (timer) clearTimeout(timer); }; }, [cle, query, force, tropCourt]); const relancer = () => setRelance((r) => ({ n: r.n + 1, q: query })); const data = resultat?.cle === cle ? resultat.data : null; const error = resultat?.cle === cle ? resultat.error : null; + const etape = resultat?.cle === cle ? resultat.etape : null; if (!query) { return ( @@ -89,11 +169,11 @@ export function ProduitResults({ query }: { query: string }) { >

- Interrogation de Qlik Sense… + {etape ?? "Interrogation de Qlik Sense…"}

Recherche des articles du réseau puis extraction de leurs ventes sur 12 mois glissants. - Cela peut prendre jusqu'à une minute. + Cela peut prendre plus d'une minute — vous pouvez laisser la page ouverte.

); diff --git a/src/app/api/produits/search/route.ts b/src/app/api/produits/search/route.ts index 06c7c45..dae34e0 100644 --- a/src/app/api/produits/search/route.ts +++ b/src/app/api/produits/search/route.ts @@ -1,43 +1,70 @@ import { NextRequest, NextResponse } from "next/server"; import { auth } from "@/lib/auth"; -import { rechercherProduits } from "@/features/produits/api/search-produits"; +import { demarrerRecherche, etatRecherche } from "@/features/produits/api/search-produits"; -// L'extraction Qlik (Playwright + Engine) prend plusieurs secondes, parfois -// plus d'une minute quand le serveur Qlik est chargé. -export const maxDuration = 300; -// Playwright/Chromium exige le runtime Node.js (pas edge). +// Les réponses sont immédiates (le travail Qlik tourne en tâche de fond), mais on +// garde le runtime Node.js : Playwright/Chromium ne tourne pas en edge, et l'état +// des jobs vit dans la mémoire du process. export const runtime = "nodejs"; export const dynamic = "force-dynamic"; /** - * GET /api/produits/search?q=[&force=1] + * Recherche produit **Qlik d'abord** — API asynchrone. * - * Recherche produit **Qlik d'abord** : les articles du réseau qui correspondent - * au terme, leurs métriques réseau sur 12 mois glissants, puis le rapprochement - * avec le catalogue FF Nancy. + * `POST /api/produits/search?q=[&force=1]` démarre la recherche et rend la + * main tout de suite. `GET /api/produits/search?q=` renvoie l'avancement, + * puis le résultat. * - * `force=1` ignore le cache mémoire de 10 min (bouton « relancer »). + * Pourquoi asynchrone : une recherche enchaîne deux allers-retours Qlik (dont une + * extraction mensuelle) et peut dépasser la minute. Une requête HTTP maintenue + * aussi longtemps se fait couper par le reverse proxy, qui répond une page + * d'erreur **HTML** — le client échouait alors sur « Unexpected token '<' … is + * not valid JSON ». Même schéma que `POST /api/qlik/sync`. * * Le middleware Next ne protège pas `/api/*` : contrôle de session explicite. */ -export async function GET(req: NextRequest) { + +async function requireSession(): Promise { const session = await auth(); - if (!session) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } + if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + return null; +} +function lireTerme(req: NextRequest): string | null { const q = (req.nextUrl.searchParams.get("q") ?? "").trim(); - if (q.length < 3) { - return NextResponse.json({ error: "Saisissez au moins 3 caractères" }, { status: 400 }); - } - const force = req.nextUrl.searchParams.get("force") === "1"; + return q.length >= 3 ? q : null; +} +export async function POST(req: NextRequest) { + const denied = await requireSession(); + if (denied) return denied; + + const q = lireTerme(req); + if (!q) return NextResponse.json({ error: "Saisissez au moins 3 caractères" }, { status: 400 }); + + const force = req.nextUrl.searchParams.get("force") === "1"; try { - const result = await rechercherProduits(q, { force }); - return NextResponse.json({ success: true, ...result }); + const job = demarrerRecherche(q, { force }); + return NextResponse.json({ success: true, ...job }); } catch (e) { const message = (e as Error).message ?? String(e); - console.error("[api/produits/search]", message.slice(0, 300)); + console.error("[api/produits/search] POST", message.slice(0, 300)); + return NextResponse.json({ error: message.slice(0, 300) }, { status: 500 }); + } +} + +export async function GET(req: NextRequest) { + const denied = await requireSession(); + if (denied) return denied; + + const q = lireTerme(req); + if (!q) return NextResponse.json({ error: "Saisissez au moins 3 caractères" }, { status: 400 }); + + try { + return NextResponse.json({ success: true, ...etatRecherche(q) }); + } catch (e) { + const message = (e as Error).message ?? String(e); + console.error("[api/produits/search] GET", message.slice(0, 300)); return NextResponse.json({ error: message.slice(0, 300) }, { status: 500 }); } } diff --git a/src/features/produits/api/search-produits.ts b/src/features/produits/api/search-produits.ts index e29f1fb..cea0ca7 100644 --- a/src/features/produits/api/search-produits.ts +++ b/src/features/produits/api/search-produits.ts @@ -28,6 +28,17 @@ import type { ProduitRechercheResultat, ProduitRechercheRow } from "@/features/p /** Durée de validité d'un résultat de recherche en cache mémoire. */ const CACHE_TTL_MS = 10 * 60 * 1000; +/** Durée de conservation d'un job terminé (le client a le temps de le relire). */ +const JOB_TTL_MS = 5 * 60 * 1000; + +/** + * Nombre maximum de recherches Qlik simultanées. + * + * Le serveur Qlik sature vite (« Out of memory », « Request aborted ») : on + * plafonne plutôt que de lui envoyer N extractions en parallèle. + */ +const MAX_RECHERCHES_SIMULTANEES = 2; + /** * Cache mémoire des recherches. Une recherche coûte deux allers-retours Qlik * (plusieurs secondes chacun) : sans ce cache, un retour arrière du navigateur @@ -35,58 +46,157 @@ const CACHE_TTL_MS = 10 * 60 * 1000; */ const cache = new Map(); -/** Recherches en cours, pour ne pas lancer deux extractions identiques en parallèle. */ -const enCours = new Map>(); +/** + * Jobs de recherche, indexés par terme normalisé. + * + * Une recherche dure de quelques secondes à plusieurs minutes (deux allers-retours + * Qlik dont une extraction mensuelle). Une requête HTTP maintenue aussi longtemps + * se fait couper par le reverse proxy, qui répond une page d'erreur HTML — le + * client recevait alors « Unexpected token '<' … is not valid JSON ». Le travail + * tourne donc en tâche de fond et le client interroge l'état. + */ +const jobs = new Map(); + +export interface RechercheJob { + jobId: string; + terme: string; + status: "running" | "success" | "error"; + /** Étape en cours, affichée pendant l'attente. */ + etape: string; + startedAt: string; + finishedAt?: string; + error?: string; + result?: ProduitRechercheResultat; +} + +/** État renvoyé au client : le job, ou `idle` si aucune recherche connue. */ +export type RechercheEtat = RechercheJob | { status: "idle"; terme: string }; function cacheKey(term: string): string { return term.trim().toLowerCase().replace(/\s+/g, " "); } /** Purge les entrées expirées (le volume reste faible, un balayage suffit). */ -function purgeCache(): void { +function purge(): void { const now = Date.now(); for (const [k, v] of cache) { if (now - v.at > CACHE_TTL_MS) cache.delete(k); } + for (const [k, j] of jobs) { + if (j.status !== "running" && j.finishedAt && now - Date.parse(j.finishedAt) > JOB_TTL_MS) jobs.delete(k); + } } -export async function rechercherProduits( +function jobsEnCours(): number { + let n = 0; + for (const j of jobs.values()) if (j.status === "running") n++; + return n; +} + +function jobTermine(terme: string, result: ProduitRechercheResultat): RechercheJob { + return { + jobId: `cache_${cacheKey(terme)}`, + terme, + status: "success", + etape: "Terminé", + startedAt: new Date().toISOString(), + finishedAt: new Date().toISOString(), + result, + }; +} + +/** + * Démarre (ou réutilise) une recherche et rend la main **immédiatement**. + * + * - résultat déjà en cache et pas de `force` → job `success` directement ; + * - recherche déjà en cours sur le même terme → on renvoie ce job ; + * - sinon on lance le travail en tâche de fond. + */ +export function demarrerRecherche( term: string, options: { force?: boolean } = {}, -): Promise { +): RechercheJob { const cleaned = term.trim(); - if (cleaned.length < 3) { - return { - rows: [], source: "qlik", qlikError: null, tronque: false, - champUtilise: null, locauxHorsReseau: [], dureeMs: 0, - }; - } - const key = cacheKey(cleaned); - purgeCache(); + purge(); + if (!options.force) { const hit = cache.get(key); if (hit) { console.log(`[produits/search] "${cleaned}" — servi depuis le cache mémoire`); - return hit.result; + return jobTermine(cleaned, hit.result); } - const running = enCours.get(key); - if (running) return running; } - const p = executerRecherche(cleaned).finally(() => enCours.delete(key)); - enCours.set(key, p); - const result = await p; - // On ne met en cache qu'un résultat Qlik exploitable : mémoriser un repli - // catalogue (panne Qlik passagère) le figerait pour 10 minutes alors qu'un - // simple « Relancer » aurait suffi. - if (result.source === "qlik" && !result.qlikError) { - cache.set(key, { at: Date.now(), result }); + const existant = jobs.get(key); + if (existant && existant.status === "running") return existant; + if (existant && !options.force && existant.status === "success" && existant.result) return existant; + + if (jobsEnCours() >= MAX_RECHERCHES_SIMULTANEES) { + return { + jobId: `refus_${Date.now().toString(36)}`, + terme: cleaned, + status: "error", + etape: "Refusé", + startedAt: new Date().toISOString(), + finishedAt: new Date().toISOString(), + error: "Trop de recherches Qlik en cours. Réessayez dans quelques instants.", + }; } - return result; + + const job: RechercheJob = { + jobId: `rech_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`, + terme: cleaned, + status: "running", + etape: "Recherche des articles dans Qlik…", + startedAt: new Date().toISOString(), + }; + jobs.set(key, job); + console.log(`[produits/search] job=${job.jobId} démarré pour "${cleaned}"`); + + // Fire-and-forget : le client suivra l'avancement via `etatRecherche`. + void executerRecherche(cleaned, (etape) => { job.etape = etape; }) + .then((result) => { + job.result = result; + job.status = "success"; + job.etape = "Terminé"; + job.finishedAt = new Date().toISOString(); + // On ne met en cache qu'un résultat Qlik exploitable : mémoriser un + // repli catalogue (panne Qlik passagère) le figerait 10 minutes + // alors qu'un simple « Relancer » aurait suffi. + if (result.source === "qlik" && !result.qlikError) { + cache.set(key, { at: Date.now(), result }); + } + console.log(`[produits/search] job=${job.jobId} terminé — ${result.rows.length} produit(s) en ${result.dureeMs} ms`); + }) + .catch((e) => { + const message = e instanceof Error ? e.message : String(e); + job.status = "error"; + job.etape = "Échec"; + job.error = messageQlikLisible(message); + job.finishedAt = new Date().toISOString(); + console.error(`[produits/search] job=${job.jobId} échec :`, message.slice(0, 300)); + }); + + return job; } -async function executerRecherche(term: string): Promise { +/** État courant de la recherche pour ce terme (pour le polling client). */ +export function etatRecherche(term: string): RechercheEtat { + const cleaned = term.trim(); + const key = cacheKey(cleaned); + purge(); + const job = jobs.get(key); + if (job) return job; + const hit = cache.get(key); + if (hit) return jobTermine(cleaned, hit.result); + return { status: "idle", terme: cleaned }; +} + +async function executerRecherche( + term: string, + onEtape: (etape: string) => void = () => {}, +): Promise { const started = Date.now(); // La recherche catalogue local tourne en parallèle : elle sert à la fois de @@ -99,6 +209,7 @@ async function executerRecherche(term: string): Promise(); let metricsError: string | null = null; try { + onEtape(`Extraction des ventes réseau de ${codes.length} article${codes.length > 1 ? "s" : ""} sur 12 mois…`); metrics = await fetchNetworkMetricsPlaywright(codes, undefined, dateFilter); // Le libellé / fournisseur ne sortent que de la recherche : on les // attache ici pour qu'ils soient persistés avec les mesures. @@ -163,6 +275,7 @@ async function executerRecherche(term: string): Promise Date: Thu, 30 Jul 2026 05:53:40 +0000 Subject: [PATCH 09/34] =?UTF-8?q?fix(produits):=20une=20seule=20session=20?= =?UTF-8?q?Qlik=20pour=20la=20recherche=20=E2=80=94=20c'=C3=A9tait=20la=20?= =?UTF-8?q?cause=20des=20code=2015?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La sync de la Grille marche parce qu'elle est SEULE sur le serveur Qlik. La recherche, elle, ouvrait une seconde session concurrente pour les mesures pendant que la première tenait une énorme sélection : l'Engine coupait les requêtes (code 15 « Request aborted »). - Les mesures réseau (CA, quantité, magasins, CA/magasin, marge %) sortent désormais du MÊME cube que l'identité, dans la même session : la fenêtre 12 mois glissants est sélectionnée sur le champ Date, les master measures sont résolues par titre comme dans l'extraction de la Grille, et le cube est trié par quantité réseau décroissante. Plus d'appel à fetchNetworkMetricsPlaywright pendant une recherche. - Sélection plafonnée à 300 libellés au lieu de 1 000 : sélectionner les 13 446 libellés contenant « tapis » prenait 81 s puis 486 s. Le cube étant trié, 300 suffisent pour remonter les meilleures ventes. - Une seule recherche simultanée (deux jobs en parallèle aggravaient la saturation). - Le détail mensuel vient du cache : le cube de recherche est agrégé. L'upsert passe les colonnes mensuelles en COALESCE pour qu'une recherche n'efface pas un mensuel déjà extrait par une sync. Jointure catalogue : les 40 codes ne trouvaient aucune correspondance dans articles.artcentrale (formats différents — « Article Code » renvoie 8 chiffres, artcentrale en a 11). Le champ article_no_centrale est maintenant remonté comme seconde clé candidate, la jointure essaie les deux, et un échantillon des deux clés est loggué quand aucune ne joint. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DfqUihgixw4K1AmJhizWiu --- docs/qlik-integration.md | 27 ++- src/features/produits/api/search-produits.ts | 138 +++++++----- src/lib/qlik-network-cache.ts | 7 +- src/lib/qlik-search.ts | 215 +++++++++++++++---- 4 files changed, 288 insertions(+), 99 deletions(-) diff --git a/docs/qlik-integration.md b/docs/qlik-integration.md index 2becb43..782432a 100644 --- a/docs/qlik-integration.md +++ b/docs/qlik-integration.md @@ -30,11 +30,28 @@ ceux-là qu'on veut voir. côté client, puis on sélectionne les valeurs retenues par numéro d'élément (`SelectListObjectValues`). La sélection restreint la dimension « Article Code » aux articles correspondants. -2. `fetchNetworkMetricsPlaywright()` — mesures réseau + détail mensuel sur la - fenêtre 12 mois glissants (mois en cours exclu), puis `upsertNetworkMetrics()` - pour que la fiche produit soit immédiatement servie depuis le cache. -3. `pgGetProduitsByCodeCentrale()` — rapprochement avec le catalogue Nancy. - Un code absent = produit réseau que nous ne référençons pas (`?cc=` sur la fiche). +2. Les **mesures réseau sortent du même cube**, dans la **même session Qlik** : + fenêtre 12 mois glissants sélectionnée sur le champ `Date`, master measures + résolues par titre, cube trié par quantité réseau décroissante (les meilleures + ventes en tête, donc pas besoin de lire tout l'ensemble sélectionné). + Elles sont ensuite persistées par `upsertNetworkMetrics()`. + + ⚠️ Ne **jamais** relancer `fetchNetworkMetricsPlaywright()` pendant une + recherche : deux sessions concurrentes sur la même app, dont une avec une + grosse sélection, et l'Engine coupe les requêtes (`code 15`). C'est ce qui + faisait échouer la recherche alors que la sync de la Grille — seule sur le + serveur — fonctionnait. Le détail mensuel vient donc du cache, et la fiche + produit garde son bouton « Actualiser depuis Qlik » pour l'extraire à la + demande sur un seul code. +3. `pgGetProduitsByCodeCentrale()` — rapprochement avec le catalogue Nancy, sur + **deux clés candidates** : la dimension « Article Code » et le champ + `article_no_centrale`, qui n'ont pas le même format sur l'app FF. Un code + absent = produit réseau que nous ne référençons pas (`?cc=` sur la fiche). + +Plafonds mesurés en production : sélectionner les 13 446 libellés contenant +« tapis » prenait 81 s puis 486 s avant d'abandonner. La sélection est donc +plafonnée à **300 valeurs** (`MAX_VALEURS_SELECTION`) et une seule recherche +tourne à la fois. L'API est **asynchrone** : `POST /api/produits/search?q=…` démarre un job et rend la main tout de suite, `GET` renvoie l'avancement puis le résultat (polling client diff --git a/src/features/produits/api/search-produits.ts b/src/features/produits/api/search-produits.ts index cea0ca7..32c4e70 100644 --- a/src/features/produits/api/search-produits.ts +++ b/src/features/produits/api/search-produits.ts @@ -1,10 +1,12 @@ import "server-only"; import { searchQlikArticles, QLIK_SEARCH_MAX_RESULTS } from "@/lib/qlik-search"; -import { fetchNetworkMetricsPlaywright } from "@/lib/qlik-playwright"; import type { NetworkMetric } from "@/lib/qlik-client"; -import { upsertNetworkMetrics } from "@/lib/qlik-network-cache"; -import { buildGridNetworkQlikDateFilter, envMonthsBack, QLIK_MONTHS_BACK_DEFAULT } from "@/lib/qlik-date-range"; +import { + getNetworkMetricsByCodeCentrale, + upsertNetworkMetrics, + type NetworkMetricCached, +} from "@/lib/qlik-network-cache"; import { pgGetProduitsByCodeCentrale, pgSearchProduits, type PgProduitSearchRow } from "@/lib/pg-ff-client"; import { NB_MAGASINS_RESEAU } from "@/features/grid/lib/network-trend"; import { normalizeMargePct } from "@/features/produits/lib/compare-reseau"; @@ -14,11 +16,15 @@ import type { ProduitRechercheResultat, ProduitRechercheRow } from "@/features/p * CollectFlow — Recherche produit **Qlik d'abord**, base FF Nancy ensuite. * * Déroulé : - * 1. Qlik Sense : quels articles du réseau correspondent au terme ? - * (`searchQlikArticles` — libellé ou code centrale) - * 2. Qlik Sense : métriques réseau 12 mois glissants de ces articles - * (`fetchNetworkMetricsPlaywright` — CA, qté, nb magasins, marge, mensuel) - * 3. Base FF Nancy : lesquels référençons-nous ? (`pgGetProduitsByCodeCentrale`) + * 1. Qlik Sense, **une seule session** : les articles correspondant au terme ET + * leurs mesures réseau sur 12 mois glissants (`searchQlikArticles`). + * 2. Base FF Nancy : lesquels référençons-nous ? (`pgGetProduitsByCodeCentrale`) + * 3. Cache `qlik_network_metrics` : détail mensuel quand une sync l'a déjà + * extrait (le cube de recherche est agrégé, il ne le fournit pas). + * + * Une seconde session Qlik pour les mesures faisait abandonner le moteur + * (`code 15 — Request aborted`) : deux sessions concurrentes sur la même app, + * dont une avec une grosse sélection, et l'Engine coupe les requêtes. * * Si Qlik est injoignable, on se rabat sur la recherche catalogue local pour ne * pas laisser l'utilisateur sans résultat — la réponse le signale explicitement @@ -37,7 +43,7 @@ const JOB_TTL_MS = 5 * 60 * 1000; * Le serveur Qlik sature vite (« Out of memory », « Request aborted ») : on * plafonne plutôt que de lui envoyer N extractions en parallèle. */ -const MAX_RECHERCHES_SIMULTANEES = 2; +const MAX_RECHERCHES_SIMULTANEES = 1; /** * Cache mémoire des recherches. Une recherche coûte deux allers-retours Qlik @@ -243,48 +249,53 @@ async function executerRecherche( }; } - // Métriques réseau + détail mensuel sur la fenêtre 12 mois glissants (mois - // courant exclu) — même extracteur que la sync fournisseur, donc mêmes - // chiffres que la Grille. - const monthsBack = envMonthsBack("QLIK_SYNC_MONTHS_BACK", QLIK_MONTHS_BACK_DEFAULT); - const dateFilter = buildGridNetworkQlikDateFilter(new Date(), monthsBack); - let metrics = new Map(); - let metricsError: string | null = null; + // Les mesures réseau sortent DÉJÀ du cube de recherche (même session Qlik). + // Les extraire dans une seconde session concurrente faisait abandonner le + // moteur (`code 15 — Request aborted`) : c'est ce qui faisait « planter » la + // recherche alors que la sync de la Grille, elle seule sur le serveur, + // fonctionnait. + // + // Reste à récupérer le détail mensuel — indisponible dans ce cube. On le lit + // dans le cache alimenté par les syncs fournisseur ; s'il manque, la fiche + // produit propose son bouton « Actualiser depuis Qlik ». + onEtape("Rapprochement avec notre catalogue…"); + const [cacheReseau, catalogue, locaux] = await Promise.all([ + getNetworkMetricsByCodeCentrale(codes).catch((e) => { + console.error("[produits/search] lecture du cache réseau échouée:", (e as Error).message); + return new Map(); + }), + // Deux clés candidates : la dimension « Article Code » et le champ + // `article_no_centrale`. Sur l'app FF elles n'ont pas le même format et + // une seule joint avec `articles.artcentrale`. + pgGetProduitsByCodeCentrale([...codes, ...qlik.matches.map((m) => m.codeCentraleAlt).filter(Boolean)]), + localPromise, + ]); + + // On persiste les mesures fraîches : la fiche produit ouverte depuis un + // résultat les affiche aussitôt, sans re-extraction. Le détail mensuel n'est + // pas fourni ici et n'est donc pas écrasé (upsert en COALESCE). try { - onEtape(`Extraction des ventes réseau de ${codes.length} article${codes.length > 1 ? "s" : ""} sur 12 mois…`); - metrics = await fetchNetworkMetricsPlaywright(codes, undefined, dateFilter); - // Le libellé / fournisseur ne sortent que de la recherche : on les - // attache ici pour qu'ils soient persistés avec les mesures. - for (const m of qlik.matches) { - const metric = metrics.get(m.codeCentrale); - if (!metric) continue; - if (m.libelle) metric.libelleReseau = m.libelle; - if (m.fournisseur) metric.fournisseurReseau = m.fournisseur; - } - // On alimente le cache `qlik_network_metrics` au passage : la fiche - // produit ouverte depuis un résultat affichera les données réseau tout - // de suite, sans re-extraction. - if (metrics.size > 0) { - const n = await upsertNetworkMetrics([...metrics.values()]); - console.log(`[produits/search] ${n} ligne(s) réseau mises en cache`); - } + const aPersister: NetworkMetric[] = qlik.matches.map((m) => ({ + codeCentrale: m.codeCentrale, + caReseau: m.caReseau, + qteReseau: m.qteReseau, + nbMagasinsReseau: m.nbMagasinsReseau, + caParMagasinReseau: m.caParMagasinReseau, + margePctReseau: m.margePctReseau, + periode: qlik.periode ?? undefined, + libelleReseau: m.libelle || undefined, + fournisseurReseau: m.fournisseur || undefined, + })); + const n = await upsertNetworkMetrics(aPersister); + console.log(`[produits/search] ${n} ligne(s) réseau mises en cache`); } catch (e) { - // Les identités trouvées restent affichables sans les mesures : mieux - // vaut une liste sans chiffres réseau qu'une page d'erreur. - metricsError = e instanceof Error ? e.message : String(e); - console.error("[produits/search] extraction des métriques réseau échouée:", metricsError); + console.error("[produits/search] mise en cache des mesures échouée:", (e as Error).message); } - onEtape("Rapprochement avec notre catalogue…"); - const catalogue = await pgGetProduitsByCodeCentrale(codes); - const locaux = await localPromise; - const rows: ProduitRechercheRow[] = qlik.matches.map((m) => { - const net = metrics.get(m.codeCentrale); - const local = catalogue.get(m.codeCentrale); - const qteReseau = net?.qteReseau ?? 0; - const caReseau = net?.caReseau ?? 0; - const nbMag = net?.nbMagasinsReseau ?? 0; + const local = catalogue.get(m.codeCentrale) ?? (m.codeCentraleAlt ? catalogue.get(m.codeCentraleAlt) : undefined); + const cache = cacheReseau.get(m.codeCentrale); + const nbMag = m.nbMagasinsReseau; return { codeCentrale: m.codeCentrale, libelle: local?.libelle1 || m.libelle || m.codeCentrale, @@ -293,16 +304,17 @@ async function executerRecherche( codein: local?.codein ?? null, nomenclature: local?.nomenclature ?? "", stockLocal: local ? Number(local.stock_total) || 0 : null, - qteReseau, - caReseau, + qteReseau: m.qteReseau, + caReseau: m.caReseau, nbMagasinsReseau: nbMag, tauxPresence: nbMag / NB_MAGASINS_RESEAU, - qteParMagasinReseau: nbMag > 0 ? qteReseau / nbMag : 0, - caParMagasinReseau: net?.caParMagasinReseau ?? 0, - prixMoyenReseau: qteReseau > 0 ? caReseau / qteReseau : null, - margePctReseau: normalizeMargePct(net?.margePctReseau), - qteByMonth: net?.qteByMonth ?? null, - periode: net?.periode ?? dateFilter.label, + qteParMagasinReseau: nbMag > 0 ? m.qteReseau / nbMag : 0, + caParMagasinReseau: m.caParMagasinReseau, + prixMoyenReseau: m.qteReseau > 0 ? m.caReseau / m.qteReseau : null, + margePctReseau: normalizeMargePct(m.margePctReseau), + // Le mensuel ne vient que du cache : le cube de recherche est agrégé. + qteByMonth: cache?.qteByMonth ?? null, + periode: qlik.periode, }; }); @@ -310,13 +322,27 @@ async function executerRecherche( // arbitrer, un produit que le réseau ne vend pas n'intéresse personne. rows.sort((a, b) => b.qteReseau - a.qteReseau || a.libelle.localeCompare(b.libelle, "fr")); - const codesTrouves = new Set(codes); + // Les produits Nancy déjà couverts par un résultat réseau ne doivent pas + // réapparaître dans la liste secondaire. On compare sur les deux clés + // candidates, la jointure pouvant se faire par l'une ou par l'autre. + const codesTrouves = new Set(codes); + for (const m of qlik.matches) if (m.codeCentraleAlt) codesTrouves.add(m.codeCentraleAlt); const locauxHorsReseau = locaux.filter((l) => !l.code_centrale || !codesTrouves.has(l.code_centrale)); + // Diagnostic de la jointure : 0 rapprochement sur 40 codes signale un format + // de clé différent entre Qlik et `articles.artcentrale`. + if (catalogue.size === 0 && codes.length > 0) { + console.warn( + `[produits/search] aucun des ${codes.length} codes Qlik ne joint avec articles.artcentrale — ` + + `échantillon Article Code=${JSON.stringify(codes.slice(0, 3))} ` + + `article_no_centrale=${JSON.stringify(qlik.matches.slice(0, 3).map((m) => m.codeCentraleAlt))}`, + ); + } + return { rows, source: "qlik", - qlikError: metricsError ? messageQlikLisible(metricsError) : null, + qlikError: qlik.avertissement, tronque: qlik.tronque, champUtilise: qlik.champUtilise, locauxHorsReseau, diff --git a/src/lib/qlik-network-cache.ts b/src/lib/qlik-network-cache.ts index 508f5b2..2c3109e 100644 --- a/src/lib/qlik-network-cache.ts +++ b/src/lib/qlik-network-cache.ts @@ -101,8 +101,11 @@ export async function upsertNetworkMetrics(metrics: NetworkMetric[]): Promise | null = null; async function getBrowser(): Promise { @@ -100,7 +129,8 @@ async function getBrowser(): Promise { interface InPageSearchResult { ok: boolean; error?: string; - rows?: Array<[string, string, string]>; + /** [code, codeAlt, libellé, fournisseur, ca, qte, nbMag, caMag, margePct] */ + rows?: Array<[string, string, string, string, number, number, number, number, number]>; champUtilise?: string | null; /** Nombre d'articles uniques correspondant au terme (avant plafonnement). */ total?: number; @@ -128,7 +158,7 @@ export async function searchQlikArticles( cfg: QlikConfig = getQlikConfig(), ): Promise { const cleaned = term.trim(); - if (cleaned.length < 3) return { matches: [], champUtilise: null, tronque: false, avertissement: null }; + if (cleaned.length < 3) return { matches: [], champUtilise: null, tronque: false, avertissement: null, periode: null }; if (!cfg.appNetwork) throw new Error("[qlik-search] QLIK_APP_NETWORK manquant"); if (!cfg.user || !cfg.password) throw new Error("[qlik-search] identifiants Qlik manquants"); @@ -137,6 +167,11 @@ export async function searchQlikArticles( // que l'extraction (`QLIK_SETTLE_MS`) : sans elle, l'objet créé juste après // est calculé sur l'état de sélection précédent. const settleMs = Math.min(2000, Math.max(0, Number(process.env.QLIK_SETTLE_MS ?? "250") || 250)); + // Même fenêtre que la Grille : 12 mois complets glissants, mois courant exclu. + const dateFilter = buildGridNetworkQlikDateFilter( + new Date(), + envMonthsBack("QLIK_SYNC_MONTHS_BACK", QLIK_MONTHS_BACK_DEFAULT), + ); const sess = await qlikNtlmSession(cfg); const [cookieName, ...rest] = sess.cookie.split("="); const cookieValue = rest.join("="); @@ -171,16 +206,28 @@ export async function searchQlikArticles( if (!csrfToken) throw new Error("[qlik-search] qlik-csrf-token introuvable (client Qlik non chargé ?)"); const result = (await page.evaluate( - ({ app, dim, token, terme, limite, settleMs, champsLibellePreferes, champsFournisseurPreferes, champLibelleForce, champFournisseurForce }: { + ({ app, dim, token, terme, limite, settleMs, serialsDate, mCa, mQte, mNbMag, mCaMag, mMarge, + champCodeAlt, champsLibellePreferes, champsFournisseurPreferes, champLibelleForce, champFournisseurForce }: { app: string; dim: string; token: string; terme: string; limite: number; settleMs: number; + serialsDate: number[]; + mCa: string; mQte: string; mNbMag: string; mCaMag: string; mMarge: string; + champCodeAlt: string; champsLibellePreferes: string[]; champsFournisseurPreferes: string[]; champLibelleForce: string; champFournisseurForce: string; }) => new Promise((resolve) => { /** Valeurs lues au maximum dans le champ recherché (2 pages Engine). */ const MAX_VALEURS_LUES = 10000; - /** Valeurs sélectionnées au maximum — au-delà, le cube abandonne (code 15). */ - const MAX_VALEURS_SELECTION = 1000; + /** + * Valeurs sélectionnées au maximum. + * + * Mesuré en production : sélectionner les 13 446 libellés contenant + * « tapis » a mis 81 s puis 486 s, et faisait abandonner le cube + * (code 15). Le cube étant trié par quantité réseau décroissante et + * l'affichage plafonné à quelques dizaines de lignes, 300 libellés + * suffisent largement. + */ + const MAX_VALEURS_SELECTION = 300; const PAGE_VALEURS = 5000; const loc = (window as unknown as { location: Location }).location; @@ -393,37 +440,106 @@ export async function searchQlikArticles( trace.push("sélections actives: lecture impossible (" + String((e as Error)?.message || e) + ")"); } - // ─── 3. Liste des articles correspondants ──────────────── - // Cube sans mesure : uniquement l'identité de l'article. Les - // métriques réseau sont extraites juste après par - // fetchNetworkMetricsPlaywright (qui produit aussi le mensuel). - const champLibelleAffiche = champChoisi === champCode ? candidatsLibelle[0] ?? null : champChoisi; - const dims: Array<{ qLibraryId?: string; qDef?: { qFieldDefs: string[] } }> = [{ qLibraryId: dim }]; - if (champLibelleAffiche) dims.push({ qDef: { qFieldDefs: [champLibelleAffiche] } }); - if (champFournisseur) dims.push({ qDef: { qFieldDefs: [champFournisseur] } }); - const largeur = dims.length; - // On lit large puis on dédoublonne : un article référencé chez - // plusieurs fournisseurs produit autant de lignes que de - // fournisseurs, il ne doit pas consommer plusieurs places. - const hauteur = Math.min(1000, Math.floor(9000 / Math.max(largeur, 1))); + // ─── 3. Fenêtre 12 mois glissants ──────────────────────── + // Sélectionnée APRÈS les libellés : restreindre les dates avant + // la recherche masquerait les articles non vendus sur la période. + if (serialsDate.length > 0) { + try { + const gd = await rpc("GetField", { qFieldName: "Date" }, doc); + const dh = (gd.qReturn as { qHandle: number }).qHandle; + for (let i = 0; i < serialsDate.length; i += 500) { + const lot = serialsDate.slice(i, i + 500); + await rpcRetry("SelectValues", { + qFieldValues: lot.map((v) => ({ qNum: v, qText: String(v) })), + qToggleMode: i > 0, + qSoftLock: true, + }, dh); + } + await sleep(settleMs); + trace.push("fenêtre Date sélectionnée (" + serialsDate.length + " jours)"); + } catch (e) { + trace.push("sélection Date impossible, mesures sur toute la période : " + String((e as Error)?.message || e)); + } + } - const cube = await rpcRetry("CreateSessionObject", { + // ─── 4. Identité + mesures réseau, dans UN SEUL cube ───── + // + // Les mesures sont dans le même cube que l'identité, et donc + // dans la même session Qlik : une seconde session concurrente + // pour les extraire faisait abandonner le moteur (code 15). + // + // Les master measures sont résolues par TITRE : le qLibraryId + // fiable est `qInfo.qId`, qui n'est pas le GUID du QRS — même + // approche que l'extraction de la Grille. + const norm = (x: string) => x.replace(/\s+/g, " ").trim().toLowerCase(); + const ml = await rpcRetry("CreateSessionObject", { + qProp: { qInfo: { qType: "MeasureList" }, qMeasureListDef: { qType: "measure", qData: { title: "/qMetaDef/title" } } }, + }, doc); + const mll = await rpcRetry("GetLayout", {}, (ml.qReturn as { qHandle: number }).qHandle); + const items = (((mll.qLayout as { qMeasureList?: { qItems?: Array<{ qInfo: { qId: string }; qMeta?: { title?: string }; qData?: { title?: string } }> } }).qMeasureList?.qItems) ?? []); + const parTitre = new Map(); + for (const it of items) { + const t = it.qMeta?.title ?? it.qData?.title ?? ""; + if (t) parTitre.set(norm(t), it.qInfo.qId); + } + const mesure = (titre: string, repli: string) => parTitre.get(norm(titre)) ?? repli; + const idCa = mesure("CA N", mCa); + const idQte = mesure("Quantité N", mQte); + const idNbMag = mesure("Magasin Ventes Nb N", mNbMag); + const idCaMag = mesure("CA par Magasin N", mCaMag); + const idMarge = mesure("Marge % N", mMarge); + + const champLibelleAffiche = champChoisi === champCode ? candidatsLibelle[0] ?? null : champChoisi; + const champAlt = champs.includes(champCodeAlt) ? champCodeAlt : null; + const dims: Array<{ qLibraryId?: string; qDef?: { qFieldDefs: string[] } }> = [{ qLibraryId: dim }]; + const idxAlt = champAlt ? dims.push({ qDef: { qFieldDefs: [champAlt] } }) - 1 : -1; + const idxLibelle = champLibelleAffiche ? dims.push({ qDef: { qFieldDefs: [champLibelleAffiche] } }) - 1 : -1; + const idxFournisseur = champFournisseur ? dims.push({ qDef: { qFieldDefs: [champFournisseur] } }) - 1 : -1; + + const nbDims = dims.length; + // Colonnes mesures, dans l'ordre : CA, Qté, NbMag, CA/mag, Marge %. + const idxQteCol = nbDims + 1; + const largeur = nbDims + 5; + // Cube trié par quantité réseau décroissante : la première page + // contient donc directement les meilleures ventes du réseau, et + // on n'a pas besoin de lire tout l'ensemble sélectionné. + const hauteur = Math.max(50, Math.min(400, Math.floor(9000 / largeur))); + + const defCube = (trie: boolean) => ({ qProp: { qInfo: { qType: "cf-search" }, qHyperCubeDef: { qDimensions: dims.map((d) => ({ ...d, qNullSuppression: true })), - qMeasures: [], + qMeasures: [ + { qLibraryId: idCa }, + trie ? { qLibraryId: idQte, qSortBy: { qSortByNumeric: -1 } } : { qLibraryId: idQte }, + { qLibraryId: idNbMag }, + { qLibraryId: idCaMag }, + { qLibraryId: idMarge }, + ], + ...(trie ? { qInterColumnSortOrder: [idxQteCol, 0] } : {}), qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: largeur, qHeight: hauteur }], + qSuppressZero: false, qSuppressMissing: true, }, }, - }, doc); + }); + let cube: { [k: string]: unknown }; + try { + cube = await rpcRetry("CreateSessionObject", defCube(true), doc); + } catch (e) { + // Le tri par mesure est un confort (meilleures ventes en + // tête) : si l'Engine refuse cette forme, on repart sans. + if (estCode15(e)) throw e; + trace.push("tri par quantité refusé, cube non trié : " + String((e as Error)?.message || e)); + cube = await rpcRetry("CreateSessionObject", defCube(false), doc); + } const ch = (cube.qReturn as { qHandle: number }).qHandle; const layout = await rpcRetry("GetLayout", {}, ch); const hc = (layout.qLayout as { qHyperCube: { qSize: { qcy: number }; - qDataPages?: Array<{ qMatrix?: Array> }>; + qDataPages?: Array<{ qMatrix?: Array> }>; }; }).qHyperCube; const matrix = hc.qDataPages?.[0]?.qMatrix ?? []; @@ -439,21 +555,34 @@ export async function searchQlikArticles( return mots.every((m) => l.includes(m)); }; - const parCode = new Map(); + type Ligne = [string, string, string, string, number, number, number, number, number]; + const texte = (r: Array<{ qText?: string }>, i: number) => { + if (i < 0) return ""; + const v = String(r[i]?.qText ?? "").trim(); + return v === "-" ? "" : v; + }; + const parCode = new Map(); let rejetees = 0; for (const r of matrix) { const code = String(r[0]?.qText ?? "").trim(); if (!code || code === "-") continue; - const libelleBrut = champLibelleAffiche ? String(r[1]?.qText ?? "").trim() : ""; - const idxFou = champLibelleAffiche ? 2 : 1; - const fournisseurBrut = champFournisseur ? String(r[idxFou]?.qText ?? "").trim() : ""; - const libelle = libelleBrut === "-" ? "" : libelleBrut; - const fournisseur = fournisseurBrut === "-" ? "" : fournisseurBrut; + const codeAlt = texte(r, idxAlt); + const libelle = texte(r, idxLibelle); + const fournisseur = texte(r, idxFournisseur); if (!correspond(code, libelle)) { rejetees++; continue; } + const nombre = (i: number) => Number(r[nbDims + i]?.qNum) || 0; + const ligne: Ligne = [ + code, codeAlt, libelle, fournisseur, + nombre(0), nombre(1), nombre(2), nombre(3), nombre(4), + ]; const existant = parCode.get(code); - if (!existant) { parCode.set(code, [code, libelle, fournisseur]); continue; } - if (!existant[1] && libelle) existant[1] = libelle; - if (!existant[2] && fournisseur) existant[2] = fournisseur; + if (!existant) { parCode.set(code, ligne); continue; } + // Même article chez plusieurs fournisseurs : une seule ligne. + // Les mesures sont identiques (elles ne dépendent pas de la + // dimension fournisseur), on complète juste les libellés. + if (!existant[1] && codeAlt) existant[1] = codeAlt; + if (!existant[2] && libelle) existant[2] = libelle; + if (!existant[3] && fournisseur) existant[3] = fournisseur; } const uniques = [...parCode.values()]; const rows = uniques.slice(0, limite); @@ -462,6 +591,9 @@ export async function searchQlikArticles( " rejetées(hors terme)=" + rejetees + " articles uniques=" + uniques.length + " retenus=" + rows.length, ); + if (rows.length > 0) { + trace.push("échantillon=" + JSON.stringify(rows.slice(0, 3))); + } ws.close(); resolve({ @@ -481,6 +613,13 @@ export async function searchQlikArticles( terme: cleaned, limite: limit, settleMs, + serialsDate: dateFilter.dailySerials, + mCa: cfg.measCaId, + mQte: cfg.measQteId, + mNbMag: cfg.measNbMagId, + mCaMag: cfg.measCaMagId, + mMarge: cfg.measMargePctId, + champCodeAlt: CHAMP_CODE_CENTRALE_ALT, champsLibellePreferes: CHAMPS_LIBELLE_PREFERES, champsFournisseurPreferes: CHAMPS_FOURNISSEUR_PREFERES, champLibelleForce: (process.env.QLIK_FIELD_ARTICLE_LIBELLE ?? "").trim(), @@ -491,9 +630,12 @@ export async function searchQlikArticles( for (const t of result.trace ?? []) console.log(`[qlik-search] ${t}`); if (!result.ok) throw new Error(`[qlik-search] ${result.error}`); - const matches: QlikArticleMatch[] = (result.rows ?? []).map(([codeCentrale, libelle, fournisseur]) => ({ - codeCentrale, libelle, fournisseur, - })); + const matches: QlikArticleMatch[] = (result.rows ?? []).map( + ([codeCentrale, codeCentraleAlt, libelle, fournisseur, caReseau, qteReseau, nbMagasinsReseau, caParMagasinReseau, margePctReseau]) => ({ + codeCentrale, codeCentraleAlt, libelle, fournisseur, + caReseau, qteReseau, nbMagasinsReseau, caParMagasinReseau, margePctReseau, + }), + ); console.log( `[qlik-search] "${cleaned}" → ${matches.length} article(s) (total ${result.total ?? 0}, champ=${result.champUtilise}) en ${Date.now() - started} ms`, ); @@ -514,6 +656,7 @@ export async function searchQlikArticles( champUtilise: result.champUtilise ?? null, tronque: (result.total ?? 0) > matches.length, avertissement, + periode: dateFilter.label, }; } finally { await ctx.close(); From 78b3d41896824dd68775d7ca67af7718b36f4c75 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 05:53:56 +0000 Subject: [PATCH 10/34] =?UTF-8?q?docs(qlik):=20une=20seule=20recherche=20s?= =?UTF-8?q?imultan=C3=A9e,=20pas=20deux?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DfqUihgixw4K1AmJhizWiu --- docs/qlik-integration.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/qlik-integration.md b/docs/qlik-integration.md index 782432a..4f8854c 100644 --- a/docs/qlik-integration.md +++ b/docs/qlik-integration.md @@ -58,7 +58,6 @@ la main tout de suite, `GET` renvoie l'avancement puis le résultat (polling cli toutes les 2 s). Une requête HTTP maintenue pendant toute l'extraction se faisait couper par le reverse proxy, qui répond une page HTML — le client échouait sur « Unexpected token '<' … is not valid JSON ». Même schéma que `POST /api/qlik/sync`. -Deux recherches simultanées au maximum (le serveur Qlik sature vite). Repli : si Qlik est injoignable, la recherche retombe sur `pgSearchProduits()` et la réponse le signale (`source: "db"`). Un résultat Qlik exploitable est mis en From 6b1caa8ee025360a461ef13d43a8ee1d39a7f542 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 05:28:22 +0000 Subject: [PATCH 11/34] =?UTF-8?q?fix(qlik):=20mois=20vides=20sur=20toute?= =?UTF-8?q?=20la=20fen=C3=AAtre=20glissante=20(ao=C3=BBt=E2=86=92d=C3=A9c?= =?UTF-8?q?=20pour=20tous=20les=20articles)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cinq mois consécutifs à 0 sur TOUS les articles n'est pas un résultat métier. Les mesures « N » de l'app Qlik sont bornées à une année civile ; le chemin « dimension Mois » sélectionne les 365 jours d'un coup, donc la mesure ne se résout que sur une année et les mois de l'année précédente ressortent vides. « Quantité COMP » ne rattrape que les mois ayant un comparable dans l'année en cours — d'où le trou d'août à décembre 2025 observé en juillet 2026. - La passe principale n'écrit plus de 0 pour un mois hors année N, et COMP n'écrit rien quand elle est vide : un mois absent peut être rattrapé, un mois à 0 se lit comme « pas de vente » et masque le trou définitivement. - Nouvelle passe rattraperMoisVides() : tout mois resté vide pour tous les articles est ré-extrait en ne sélectionnant QUE ses dates. La mesure se résout alors sur la bonne année quelle que soit l'écriture de son set analysis. Elle ne touche que le mensuel, jamais les totaux de période. - Les expressions des master measures sont dumpées au log : sans elles, impossible de savoir comment la mesure est bornée dans le temps. Contournement sans redéploiement : QLIK_MONTH_DIM=0 (itération mois par mois, plus lente mais sans trou). Les données déjà en cache gardent leurs zéros — il faut relancer la sync pour les corriger. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DfqUihgixw4K1AmJhizWiu --- docs/qlik-integration.md | 30 +++++++++++ src/lib/qlik-playwright.ts | 108 +++++++++++++++++++++++++++++++++++-- 2 files changed, 134 insertions(+), 4 deletions(-) diff --git a/docs/qlik-integration.md b/docs/qlik-integration.md index 4f8854c..4012535 100644 --- a/docs/qlik-integration.md +++ b/docs/qlik-integration.md @@ -78,6 +78,36 @@ QLIK_FIELD_FOURNISSEUR= Sans champ libellé exploitable, la recherche fonctionne encore par code centrale. +## Mois vides de la fenêtre glissante (dimension Mois) + +Les mesures « N » de l'app sont bornées à une année civile. Quand la fenêtre +12 mois glissants chevauche deux années (le cas 11 mois sur 12), le chemin +« dimension Mois » sélectionne les 365 jours d'un coup et la mesure ne se résout +que sur une seule année : les mois de l'année précédente ressortent vides. +« Quantité COMP » ne rattrape que les mois ayant un comparable dans l'année en +cours — d'où, en juillet 2026, un trou observé d'août à décembre 2025 sur +**tous** les articles. + +Trois garde-fous, dans `qlik-playwright.ts` : + +1. La passe principale n'écrit plus de `0` pour un mois hors année N, et + « Quantité COMP » n'écrit rien quand elle est vide. Un mois **absent** peut + être rattrapé ; un mois **à 0** se lit comme « pas de vente » et masque le trou. +2. `rattraperMoisVides()` : après les passes N et N-1, tout mois vide pour + **tous** les articles est ré-extrait en ne sélectionnant **que ses dates** — + la mesure se résout alors sur la bonne année, quelle que soit l'écriture de + son set analysis. +3. Les expressions des master measures sont dumpées au log + (`[qlik-pw][dump] expression « Quantité N » = …`) : c'est la seule façon de + savoir comment la mesure est bornée. + +Contournement immédiat sans redéploiement : `QLIK_MONTH_DIM=0` repasse sur +l'itération mois par mois (une sélection de dates par mois, donc pas de trou), +au prix d'une extraction plus lente. + +⚠️ Les données déjà en cache gardent leurs zéros : il faut relancer la sync Qlik +pour les corriger. + ## Tendance réseau = 12 mois glissants stricts `computeNetworkTrend()` reconstruit sa fenêtre à partir de la date du jour : diff --git a/src/lib/qlik-playwright.ts b/src/lib/qlik-playwright.ts index 427a275..fc8c49c 100644 --- a/src/lib/qlik-playwright.ts +++ b/src/lib/qlik-playwright.ts @@ -268,9 +268,9 @@ export async function fetchNetworkMetricsPlaywright( // 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 ml = await rpc("CreateSessionObject", { qProp: { qInfo: { qType: "MeasureList" }, qMeasureListDef: { qType: "measure", qData: { title: "/qMetaDef/title", expr: "/qMeasure/qDef" } } } }, 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 items = (((mll.qLayout as { qMeasureList?: { qItems?: Array<{ qInfo: { qId: string }; qMeta?: { title?: string }; qData?: { title?: string; expr?: string } }> } }).qMeasureList?.qItems) ?? []); const byTitle = new Map(); 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; @@ -290,7 +290,16 @@ export async function fetchNetworkMetricsPlaywright( // 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. + // L'EXPRESSION est indispensable au diagnostic : c'est elle qui dit + // si la mesure est bornée à une année (et comment), donc pourquoi des + // mois de la fenêtre glissante peuvent ressortir vides. console.log("[qlik-pw][dump] MESURES (" + items.length + "): " + JSON.stringify(items.map((it) => ({ t: it.qMeta?.title ?? it.qData?.title ?? "", id: it.qInfo.qId })))); + for (const it of items) { + const titre = it.qMeta?.title ?? it.qData?.title ?? ""; + if (/^(CA N|Quantité N|Magasin Ventes Nb N|CA par Magasin N|Marge % N|Quantité COMP)$/i.test(titre.trim())) { + console.log("[qlik-pw][dump] expression « " + titre + " » = " + (it.qData?.expr ?? "?")); + } + } 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); @@ -509,10 +518,20 @@ export async function fetchNetworkMetricsPlaywright( 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 { + // On n'écrit rien si COMP est vide : un mois ABSENT peut être + // rattrapé plus tard, un mois à 0 se lit comme « pas de vente » + // et masque définitivement le trou d'extraction. + if (monthlyByCode[code][mmPrev] === undefined && qteComp > 0) { + monthlyByCode[code][mmPrev] = { qte: qteComp }; + } + } else if (!isMainPass) { // Passe année sélectionnée : valeurs directes, prioritaires sur COMP. monthlyByCode[code][mm] = full; + } else if (qte > 0 || ca > 0) { + // Passe principale, mois HORS année N : « Quantité N » n'est pas + // censée le couvrir. Écrire son 0 fabriquerait une absence de + // vente là où il n'y a qu'une mesure hors périmètre. + monthlyByCode[code][mm] = full; } }, timings, () => { code15Retries++; }), timings); aggBatches++; @@ -601,6 +620,86 @@ export async function fetchNetworkMetricsPlaywright( } }; + /** + * Rattrapage des mois RESTÉS VIDES pour tous les articles. + * + * Les mesures « N » de l'app sont bornées à une année : quand la + * fenêtre 12 mois glissants chevauche deux années civiles, les mois + * de l'année précédente non couverts par « Quantité COMP » ressortent + * vides — cinq mois d'affilée à 0 sur TOUS les articles, ce qui n'a + * aucun sens métier. + * + * La parade fiable est de ne sélectionner QUE les dates du mois + * concerné : la mesure se résout alors sur la bonne année, quelle que + * soit la façon dont son set analysis est écrit. On ne le fait que + * pour les mois réellement vides — au pire quelques cubes de plus. + */ + const rattraperMoisVides = async (): Promise => { + const moisRenseignes = new Set(); + for (const parMois of Object.values(monthlyByCode)) { + for (const [mois, v] of Object.entries(parMois)) { + if ((v?.qte ?? 0) > 0) moisRenseignes.add(mois); + } + } + const manquants = monthlyPayload.filter((m) => !moisRenseignes.has(m.label)); + if (manquants.length === 0) return; + console.log( + "[qlik-pw] (mois) " + manquants.length + " mois vide(s) sur tous les articles : " + + JSON.stringify(manquants.map((m) => m.label)) + " → passe de rattrapage mois par mois", + ); + + for (const m of manquants) { + const prefix = "[qlik-pw] (rattrapage) " + m.label; + try { + // Année libre : c'est la sélection de dates qui doit + // déterminer l'année vue par la mesure. + if (yfh !== -1) { await rpc("Clear", {}, yfh); await sleep(qlikSettleMs); } + await rpc("Clear", {}, dfh); + await sleep(qlikSettleMs); + await rpc("SelectValues", { + qFieldValues: m.serials.map((s2) => ({ qNum: s2, qText: String(s2) })), + qToggleMode: false, + qSoftLock: true, + }, dfh); + await sleep(qlikSettleMs); + + const tailles = [300, 150, 75]; + let idx = 0, tailleIdx = 0; + const total = codes.length; + while (codes.length ? idx < total : tailleIdx === 0) { + const lot = codes.length ? codes.slice(idx, Math.min(idx + tailles[tailleIdx], total)) : []; + try { + if (lot.length && fh !== -1) { + await rpc("Clear", {}, fh); + await sleep(qlikSettleMs); + await rpc("SelectValues", { qFieldValues: lot.map((c) => ({ qText: c })), qToggleMode: false, qSoftLock: true }, fh); + await sleep(qlikSettleMs); + } + const batchRows: Array> = []; + await fetchCubeForSelection(batchRows); + // Uniquement le mensuel : les totaux réseau restent ceux + // de la passe principale, sinon on les doublerait. + accMonthly(batchRows, m.label); + if (!codes.length) break; + idx += lot.length; + tailleIdx = 0; + } catch (lotErr) { + const suivante = tailles[tailleIdx + 1]; + if (codes.length && isEngineCode15(lotErr) && suivante && lot.length > suivante) { + tailleIdx += 1; + continue; + } + throw lotErr; + } + } + const remplis = Object.values(monthlyByCode).filter((x) => (x[m.label]?.qte ?? 0) > 0).length; + console.log(prefix + " — " + remplis + " article(s) renseigné(s)"); + } catch (e) { + console.error(prefix + " — échec : " + String((e as Error)?.message || 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 + ")"); @@ -621,6 +720,7 @@ export async function fetchNetworkMetricsPlaywright( } else { console.log("[qlik-pw] (mois) champ Année introuvable → pas de passe N-1"); } + await rattraperMoisVides(); } 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. From b2a426287faa5a335a62387427e7bd4eed52e813 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 06:07:17 +0000 Subject: [PATCH 12/34] =?UTF-8?q?perf(qlik):=20sync=2024=20s=C3=A9lections?= =?UTF-8?q?=20=E2=86=92=201,=20et=20r=C3=A9sultat=20partiel=20au=20lieu=20?= =?UTF-8?q?de=20tout=20perdre?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Les timings de production sont sans appel : SelectValues sur « Article Code » coûte 8 à 100 s PAR APPEL, quasi indépendamment du nombre de valeurs (le moteur balaie un symbole de plus d'un million d'entrées). Avec 7 200 codes en lots de 300, cela faisait 24 appels, ~15 min de sync, et la session Qlik mourait avant la fin — « Socket closed » puis « Execution context was destroyed » — en perdant la totalité de l'extraction. - Le chemin mensuel fait désormais UNE seule sélection pour tous les codes et un seul cube paginé : la dimension Mois livre déjà tous les mois d'un coup, rien n'obligeait à découper. La lecture des pages coûte ~50 ms. Repli automatique sur les lots si l'Engine refuse (les lignes déjà lues sont retirées pour ne pas doubler les totaux). - rattraperMoisVides() adopte le même principe : une sélection de codes, puis seule la fenêtre de dates change d'un mois à l'autre. - Sonde de diagnostic de fin de sync retirée : elle refaisait une sélection complète des codes et des 365 jours pour rien. - Point de contrôle (cfCheckpoint) poussé après chaque passe. Si la session meurt, l'extraction repart de là : les codes déjà traités ont des données complètes, seul le reliquat manque. Le log signale « résultat PARTIEL ». - Le script in-page remonte aussi ses lignes en cas d'erreur interne, et « rows=[object Object] » dans les logs est corrigé. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DfqUihgixw4K1AmJhizWiu --- docs/qlik-integration.md | 24 +++ src/lib/qlik-playwright.ts | 299 +++++++++++++++++++++++-------------- 2 files changed, 208 insertions(+), 115 deletions(-) diff --git a/docs/qlik-integration.md b/docs/qlik-integration.md index 4012535..54461d3 100644 --- a/docs/qlik-integration.md +++ b/docs/qlik-integration.md @@ -108,6 +108,30 @@ au prix d'une extraction plus lente. ⚠️ Les données déjà en cache gardent leurs zéros : il faut relancer la sync Qlik pour les corriger. +## Coût de `SelectValues` sur « Article Code » + +Mesuré en production : **8 à 100 s par appel**, quasi indépendamment du nombre de +valeurs — le moteur balaie un symbole de plus d'un million d'entrées. Avec 7 200 +codes en lots de 300, cela faisait 24 appels, ~15 min de sync, et la session Qlik +mourait avant la fin (`Socket closed`, puis `Execution context was destroyed`). + +La dimension Mois livrant déjà tous les mois d'un coup, rien n'oblige à découper +les codes : le chemin mensuel fait donc **une seule sélection pour tous les codes** +et **un seul cube paginé** (la lecture des pages coûte ~50 ms). Repli automatique +sur les lots si l'Engine refuse. Même principe pour `rattraperMoisVides()` : une +sélection de codes, puis seule la fenêtre de dates change d'un mois à l'autre. + +La sonde de diagnostic de fin de sync a été retirée : elle refaisait une sélection +complète des codes et des 365 jours pour rien. + +### Résultat partiel plutôt que rien + +Le script in-page pousse un point de contrôle (`cfCheckpoint`) après chaque passe. +Si la session meurt en cours de route, l'extraction repart de ce point au lieu de +tout perdre : les codes déjà traités ont des données **complètes** (la dimension +Mois livre tous leurs mois d'un coup), seul le reliquat manque. Le log signale +alors `⚠ résultat PARTIEL` — relancer la sync complète le reste. + ## Tendance réseau = 12 mois glissants stricts `computeNetworkTrend()` reconstruit sa fenêtre à partir de la date du jour : diff --git a/src/lib/qlik-playwright.ts b/src/lib/qlik-playwright.ts index fc8c49c..388bf32 100644 --- a/src/lib/qlik-playwright.ts +++ b/src/lib/qlik-playwright.ts @@ -52,6 +52,8 @@ interface InPageResult { batches?: number; code15Retries?: number; fallbackCount?: number; + /** true si le résultat vient d'un point de contrôle (extraction interrompue). */ + partiel?: boolean; } function envNumber(name: string, defaultValue: number, min: number, max: number): number { @@ -149,6 +151,7 @@ export async function fetchNetworkMetricsPlaywright( 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é ?)"); + const jeton: string = csrfToken; 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). @@ -160,7 +163,18 @@ export async function fetchNetworkMetricsPlaywright( serials: m.dailySerials, // ~30 valeurs → SelectValues en une fois })); - const result = (await page.evaluate( + // Point de contrôle : le script in-page pousse ses résultats intermédiaires + // à chaque passe. Si la session Qlik meurt en cours de route (« Socket + // closed », « Execution context was destroyed »), on repart de là au lieu + // de tout perdre — les codes déjà traités ont des données complètes, la + // dimension Mois livrant tous leurs mois d'un coup. + let checkpoint: { rows?: Array>; monthly?: InPageResult["monthly"] } | null = null; + await page.exposeFunction("cfCheckpoint", (payload: { rows?: Array>; monthly?: InPageResult["monthly"] }) => { + checkpoint = payload; + return true; + }); + + const evaluer = () => 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; @@ -185,7 +199,6 @@ export async function fetchNetworkMetricsPlaywright( 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((res, rej) => { ws.onmessage = (ev: MessageEvent) => { const m = JSON.parse(ev.data as string); @@ -243,6 +256,13 @@ export async function fetchNetworkMetricsPlaywright( throw lastErr; }; + // Accumulateurs déclarés au niveau de l'IIFE (et non dans le `try`) : + // le `catch` doit pouvoir remonter ce qui a déjà été extrait. + const out: Array> = []; + // 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> = {}; + (async () => { try { await ready; @@ -310,10 +330,8 @@ export async function fetchNetworkMetricsPlaywright( // (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> = []; - // 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> = {}; + // (déclaré au niveau de l'IIFE — voir plus haut) + // (monthlyByCode est déclaré au niveau de l'IIFE — voir plus haut) const accMonthly = (batchRows: Array>, monthLabel: string) => { for (const r of batchRows) { const c = String(r[0] ?? "").trim(); @@ -459,6 +477,49 @@ export async function fetchNetworkMetricsPlaywright( let loggedSampleRow = false; const monthDimPath = async (selectYear: number | null): Promise => { const isMainPass = selectYear === null; + + /** Traitement d'une ligne du cube [Article Code, Mois] — partagé par les deux chemins. */ + const onRowMois = (code: string, mois: string, ca: number, qte: number, nbMag: number, caMag: number, marge: number, qteComp: number) => { + 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. + // On n'écrit rien si COMP est vide : un mois ABSENT peut être + // rattrapé plus tard, un mois à 0 se lit comme « pas de vente » + // et masque définitivement le trou d'extraction. + if (monthlyByCode[code][mmPrev] === undefined && qteComp > 0) { + monthlyByCode[code][mmPrev] = { qte: qteComp }; + } + } else if (!isMainPass) { + // Passe année sélectionnée : valeurs directes, prioritaires sur COMP. + monthlyByCode[code][mm] = full; + } else if (qte > 0 || ca > 0) { + // Passe principale, mois HORS année N : « Quantité N » n'est pas + // censée le couvrir. Écrire son 0 fabriquerait une absence de + // vente là où il n'y a qu'une mesure hors périmètre. + monthlyByCode[code][mm] = full; + } + }; + if (yfh !== -1) { await rpc("Clear", {}, yfh); await sleep(qlikSettleMs); @@ -476,6 +537,50 @@ export async function fetchNetworkMetricsPlaywright( await sleep(qlikSettleMs); } aggMonths = monthlyPayload.length; + + // ─── Chemin rapide : UNE seule sélection Article Code ──── + // + // Mesuré en production : `SelectValues` sur « Article Code » + // coûte 8 à 100 s **par appel**, quasi indépendamment du + // nombre de valeurs (le moteur balaie un symbole de plus d'un + // million d'entrées). Avec 7 200 codes en lots de 300, cela + // faisait 24 appels, ~15 min de sync, et la session Qlik + // mourait avant la fin (« Socket closed » puis « Execution + // context was destroyed »). + // + // La dimension Mois donne déjà tous les mois d'un coup : rien + // n'oblige à découper les codes. On tente donc un seul appel + // pour tous les codes et un seul cube paginé — la lecture des + // pages, elle, coûte ~50 ms. Repli sur les lots si l'Engine + // refuse (cube trop gros, code 15, saturation mémoire). + const marqueOut = out.length; + if (codes.length && fh !== -1) { + const timingsAll: Record = {}; + try { + await timed("clearCodeAll", "Clear", () => rpc("Clear", {}, fh), timingsAll); + await sleep(qlikSettleMs); + await timed("selectCodeAll", "SelectValues", () => rpc("SelectValues", { + qFieldValues: codes.map((c) => ({ qText: c })), + qToggleMode: false, + qSoftLock: true, + }, fh), timingsAll); + await sleep(qlikSettleMs); + const got = await timed("cube", "all", () => fetchMonthCube(onRowMois, timingsAll), timingsAll); + aggBatches++; + console.log( + "[qlik-pw][timing] (mois) chemin rapide — " + codes.length + " codes en 1 sélection, rows=" + got.value + + " cumul=" + out.length + " timings=" + timingSummary(timingsAll), + ); + return; + } catch (e) { + // Les lignes déjà lues seraient recomptées par le repli : + // on les retire (les écritures mensuelles, elles, sont + // des affectations idempotentes). + out.length = marqueOut; + console.log("[qlik-pw] (mois) chemin rapide échoué → repli par lots : " + String((e as Error)?.message || e)); + } + } + // 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]; @@ -494,49 +599,10 @@ export async function fetchNetworkMetricsPlaywright( 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. - // On n'écrit rien si COMP est vide : un mois ABSENT peut être - // rattrapé plus tard, un mois à 0 se lit comme « pas de vente » - // et masque définitivement le trou d'extraction. - if (monthlyByCode[code][mmPrev] === undefined && qteComp > 0) { - monthlyByCode[code][mmPrev] = { qte: qteComp }; - } - } else if (!isMainPass) { - // Passe année sélectionnée : valeurs directes, prioritaires sur COMP. - monthlyByCode[code][mm] = full; - } else if (qte > 0 || ca > 0) { - // Passe principale, mois HORS année N : « Quantité N » n'est pas - // censée le couvrir. Écrire son 0 fabriquerait une absence de - // vente là où il n'y a qu'une mesure hors périmètre. - monthlyByCode[code][mm] = full; - } - }, timings, () => { code15Retries++; }), timings); + const got = await timed("cube", "batch", () => fetchMonthCube(onRowMois, timings, () => { code15Retries++; }), timings); aggBatches++; aggCode15Retries += code15Retries; - console.log("[qlik-pw][timing] " + bp + " — rows=" + got + " cumul=" + out.length + " retries15=" + code15Retries + " timings=" + timingSummary(timings)); + console.log("[qlik-pw][timing] " + bp + " — rows=" + got.value + " cumul=" + out.length + " retries15=" + code15Retries + " timings=" + timingSummary(timings)); if (!codes.length) break; codeIndex += batch.length; fallbackIndex = 0; @@ -648,12 +714,22 @@ export async function fetchNetworkMetricsPlaywright( JSON.stringify(manquants.map((m) => m.label)) + " → passe de rattrapage mois par mois", ); + // Une seule sélection Article Code pour tout le rattrapage : + // `SelectValues` sur ce champ coûte des dizaines de secondes + // par appel, on ne le refait pas à chaque mois. + if (yfh !== -1) { try { await rpc("Clear", {}, yfh); await sleep(qlikSettleMs); } catch { /* noop */ } } + if (codes.length && fh !== -1) { + await rpc("Clear", {}, fh); + await sleep(qlikSettleMs); + await rpc("SelectValues", { qFieldValues: codes.map((c) => ({ qText: c })), qToggleMode: false, qSoftLock: true }, fh); + await sleep(qlikSettleMs); + } + for (const m of manquants) { const prefix = "[qlik-pw] (rattrapage) " + m.label; try { - // Année libre : c'est la sélection de dates qui doit - // déterminer l'année vue par la mesure. - if (yfh !== -1) { await rpc("Clear", {}, yfh); await sleep(qlikSettleMs); } + // Seule la fenêtre de dates change d'un mois à l'autre : + // c'est elle qui détermine l'année vue par la mesure. await rpc("Clear", {}, dfh); await sleep(qlikSettleMs); await rpc("SelectValues", { @@ -663,35 +739,11 @@ export async function fetchNetworkMetricsPlaywright( }, dfh); await sleep(qlikSettleMs); - const tailles = [300, 150, 75]; - let idx = 0, tailleIdx = 0; - const total = codes.length; - while (codes.length ? idx < total : tailleIdx === 0) { - const lot = codes.length ? codes.slice(idx, Math.min(idx + tailles[tailleIdx], total)) : []; - try { - if (lot.length && fh !== -1) { - await rpc("Clear", {}, fh); - await sleep(qlikSettleMs); - await rpc("SelectValues", { qFieldValues: lot.map((c) => ({ qText: c })), qToggleMode: false, qSoftLock: true }, fh); - await sleep(qlikSettleMs); - } - const batchRows: Array> = []; - await fetchCubeForSelection(batchRows); - // Uniquement le mensuel : les totaux réseau restent ceux - // de la passe principale, sinon on les doublerait. - accMonthly(batchRows, m.label); - if (!codes.length) break; - idx += lot.length; - tailleIdx = 0; - } catch (lotErr) { - const suivante = tailles[tailleIdx + 1]; - if (codes.length && isEngineCode15(lotErr) && suivante && lot.length > suivante) { - tailleIdx += 1; - continue; - } - throw lotErr; - } - } + const batchRows: Array> = []; + await fetchCubeForSelection(batchRows); + // Uniquement le mensuel : les totaux réseau restent ceux + // de la passe principale, sinon on les doublerait. + accMonthly(batchRows, m.label); const remplis = Object.values(monthlyByCode).filter((x) => (x[m.label]?.qte ?? 0) > 0).length; console.log(prefix + " — " + remplis + " article(s) renseigné(s)"); } catch (e) { @@ -700,10 +752,23 @@ export async function fetchNetworkMetricsPlaywright( } }; + /** Pousse l'état courant vers Node (voir `cfCheckpoint`). */ + const pousserCheckpoint = async (etape: string) => { + try { + const fn = (window as unknown as { cfCheckpoint?: (p: unknown) => Promise }).cfCheckpoint; + if (!fn) return; + await fn({ rows: out, monthly: monthlyByCode }); + console.log("[qlik-pw] point de contrôle « " + etape + " » : " + out.length + " lignes"); + } catch (e) { + console.log("[qlik-pw] point de contrôle « " + etape + " » ignoré : " + String((e as Error)?.message || 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); + await pousserCheckpoint("passe N"); // 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. @@ -717,10 +782,12 @@ export async function fetchNetworkMetricsPlaywright( console.log("[qlik-pw] (mois) passe N-1 ignorée: " + String((e2 as Error)?.message || e2)); } try { await rpc("Clear", {}, yfh); } catch { /* noop */ } + await pousserCheckpoint("passe N-1"); } else { console.log("[qlik-pw] (mois) champ Année introuvable → pas de passe N-1"); } await rattraperMoisVides(); + await pousserCheckpoint("rattrapage"); } 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. @@ -839,42 +906,19 @@ export async function fetchNetworkMetricsPlaywright( } } - // 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)); } + // La sonde de diagnostic du cube [Article Code, Mois] a été retirée : + // elle refaisait une sélection complète des codes et des 365 jours en + // fin de sync (des dizaines de secondes) alors que le découpage + // mensuel est validé et en production depuis. 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)); } + } catch (e) { + try { ws.close(); } catch { /* noop */ } + // On remonte ce qui a été extrait : les codes déjà traités ont + // toutes leurs données, seul le reliquat manque. + resolve({ ok: false, error: String((e as Error)?.message || e), rows: out, monthly: monthlyByCode, size: out.length }); + } })(); }), { @@ -886,7 +930,7 @@ export async function fetchNetworkMetricsPlaywright( mcamag: cfg.measCaMagId, mmarge: cfg.measMargePctId, codes: codeCentraux ?? [], - token: csrfToken, + token: jeton, monthlyPayload, noDateMode: !dateFilter, qlikSettleMs, @@ -898,9 +942,34 @@ export async function fetchNetworkMetricsPlaywright( mqteComp: qlikMeasQteCompId, yearN, }, - )) as InPageResult; + ); - if (!result.ok) throw new Error(`[qlik-pw] ${result.error}`); + let result: InPageResult; + try { + result = (await evaluer()) as InPageResult; + } catch (e) { + const msg = String((e as Error)?.message || e); + const secours = checkpoint as { rows?: Array>; monthly?: InPageResult["monthly"] } | null; + if (secours?.rows?.length) { + console.error(`[qlik-pw] extraction interrompue (${msg.slice(0, 200)}) — reprise du dernier point de contrôle : ${secours.rows.length} lignes`); + result = { ok: true, rows: secours.rows, monthly: secours.monthly, size: secours.rows.length, partiel: true }; + } else { + throw e; + } + } + + if (!result.ok) { + // Même logique qu'un crash : un résultat partiel vaut mieux que rien. + if (result.rows?.length) { + console.error(`[qlik-pw] extraction en échec (${String(result.error).slice(0, 200)}) — ${result.rows.length} lignes déjà extraites conservées`); + result = { ...result, ok: true, partiel: true }; + } else { + throw new Error(`[qlik-pw] ${result.error}`); + } + } + if (result.partiel) { + console.warn("[qlik-pw] ⚠ résultat PARTIEL : certains codes n'ont pas été extraits, relancer la sync pour compléter"); + } // 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) ; From 4a56584a27c2559f5177927e5d5a5b09a401df2d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 06:27:33 +0000 Subject: [PATCH 13/34] =?UTF-8?q?fix(qlik):=20les=20master=20measures=20ig?= =?UTF-8?q?norent=20la=20s=C3=A9lection=20Date=20=E2=80=94=20agr=C3=A9gati?= =?UTF-8?q?on=20directe=20des=20faits?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Preuve arithmétique dans les logs : pour l'article 10000397784, qteReseau=164 et caReseau=2220,75 € sont EXACTEMENT la somme de janvier à juillet 2026 (12+19+24+27+22+31+29 = 164). Et la passe de rattrapage, avec août 2025 seul sélectionné, a renvoyé ces mêmes 164 / 2220,75 — puis les a recopiés dans les cinq mois manquants. « CA N » et « Quantité N » sont donc des mesures « cumul année en cours », insensibles au champ Date. Aucune sélection ne peut leur faire produire une fenêtre 12 mois glissants. D'où, simultanément : - des totaux réseau qui étaient un cumul année en cours, mois courant partiel inclus, et non 12 mois glissants ; - les mois de l'année précédente non couverts par « Quantité COMP » vides sur tous les articles ; - un rattrapage qui y recopiait le total de période. Le chemin nominal agrège désormais directement les champs de faits, qui eux respectent les sélections : Sum(quantite), Sum(ca_ht), Count(DISTINCT [Magasin Code]), Sum(marge) — tous surchargeables par QLIK_EXPR_QTE / _CA / _NBMAG / _MARGE. Un seul cube [Article Code, Mois] couvre les 12 mois : ni passe N-1 ni rattrapage, c'est ce qu'ils compensaient. Les totaux deviennent la somme des 12 mois de la fenêtre. Garde-fous : « Quantité N » reste dans le cube pour calibrage et le log compare les deux sur les mois de l'année en cours (seul périmètre où elle est juste) ; quantité totale nulle ou erreur Engine → repli automatique sur l'ancien chemin ; QLIK_USE_EXPR=0 le force. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DfqUihgixw4K1AmJhizWiu --- docs/qlik-integration.md | 44 ++++++++- src/lib/qlik-playwright.ts | 196 ++++++++++++++++++++++++++++++++++++- 2 files changed, 235 insertions(+), 5 deletions(-) diff --git a/docs/qlik-integration.md b/docs/qlik-integration.md index 54461d3..a8280d6 100644 --- a/docs/qlik-integration.md +++ b/docs/qlik-integration.md @@ -78,7 +78,49 @@ QLIK_FIELD_FOURNISSEUR= Sans champ libellé exploitable, la recherche fonctionne encore par code centrale. -## Mois vides de la fenêtre glissante (dimension Mois) +## ⚠️ Les master measures « N » ignorent la sélection Date + +**Constat de production, vérifié arithmétiquement.** Avec août 2025 seul +sélectionné, le cube renvoyait `qte=164` / `ca=2 220,75 €` — soit exactement la +somme de janvier à juillet 2026. « CA N », « Quantité N » et consorts sont des +mesures **cumul année en cours**, insensibles au champ `Date`. + +Trois conséquences, toutes observées : + +- les totaux réseau de la Grille étaient un **cumul année en cours** (mois + courant partiel inclus), pas 12 mois glissants ; +- les mois de l'année précédente non couverts par « Quantité COMP » (août à + décembre, en juillet) restaient **vides sur tous les articles** ; +- la passe de rattrapage y recopiait le **total de période**, d'où cinq mois + identiques à 164 dans `qteByMonth`. + +Aucune sélection ne corrige cela. Le chemin nominal agrège donc **directement les +champs de faits**, qui respectent les sélections : + +| Rôle | Expression (surchargeable) | +|------|----------------------------| +| Quantité | `QLIK_EXPR_QTE` — défaut `Sum(quantite)` | +| CA | `QLIK_EXPR_CA` — défaut `Sum(ca_ht)` | +| Magasins | `QLIK_EXPR_NBMAG` — défaut `Count(DISTINCT [Magasin Code])` | +| Marge | `QLIK_EXPR_MARGE` — défaut `Sum(marge)` | + +Un seul cube `[Article Code, Mois]` couvre les 12 mois : ni passe N-1 ni +rattrapage — c'est exactement ce qu'ils compensaient. Les totaux deviennent la +**somme des 12 mois de la fenêtre**. + +Garde-fous : « Quantité N » est incluse dans le cube pour **calibrage**, et le +log compare les deux sur les mois de l'année en cours, seul périmètre où la +master measure est juste : + +``` +[qlik-pw][expr] 485210 lignes, quantité totale=…, calibrage année 2026 : 4812/4830 mois conformes à « Quantité N » +``` + +Un calibrage faible signale une expression à ajuster (filtre `flag_type_mvt`, +`ca_ttc` plutôt que `ca_ht`…). Quantité totale nulle ou erreur Engine → repli +automatique sur l'ancien chemin. `QLIK_USE_EXPR=0` le force. + +## Mois vides de la fenêtre glissante (chemin de repli) Les mesures « N » de l'app sont bornées à une année civile. Quand la fenêtre 12 mois glissants chevauche deux années (le cas 11 mois sur 12), le chemin diff --git a/src/lib/qlik-playwright.ts b/src/lib/qlik-playwright.ts index 388bf32..1e33a2c 100644 --- a/src/lib/qlik-playwright.ts +++ b/src/lib/qlik-playwright.ts @@ -121,8 +121,19 @@ export async function fetchNetworkMetricsPlaywright( // 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(); + // Agrégations directes des champs de faits. Les master measures « N » ignorent + // la sélection Date (mesures « cumul année en cours ») : elles ne peuvent pas + // produire une fenêtre 12 mois glissants. Surchargeables si le modèle change. + const qlikUseExpr = !["0", "false", "no", "off"].includes((process.env.QLIK_USE_EXPR ?? "").trim().toLowerCase()); + const qlikExprQte = (process.env.QLIK_EXPR_QTE ?? "Sum(quantite)").trim(); + const qlikExprCa = (process.env.QLIK_EXPR_CA ?? "Sum(ca_ht)").trim(); + const qlikExprNbMag = (process.env.QLIK_EXPR_NBMAG ?? "Count(DISTINCT [Magasin Code])").trim(); + const qlikExprMarge = (process.env.QLIK_EXPR_MARGE ?? "Sum(marge)").trim(); const yearN = new Date().getFullYear(); - console.log(`[qlik-pw] tuning settle=${qlikSettleMs}ms code15Attempts=${qlikCode15MaxAttempts} batchFallbacks=${qlikArticleBatchFallbacks.join(">")} selectAllCodes=${qlikSelectAllCodes} monthDim=${qlikMonthDim}`); + console.log(`[qlik-pw] tuning settle=${qlikSettleMs}ms code15Attempts=${qlikCode15MaxAttempts} batchFallbacks=${qlikArticleBatchFallbacks.join(">")} selectAllCodes=${qlikSelectAllCodes} monthDim=${qlikMonthDim} useExpr=${qlikUseExpr}`); + if (qlikUseExpr) { + console.log(`[qlik-pw] expressions mensuelles: qte="${qlikExprQte}" ca="${qlikExprCa}" nbMag="${qlikExprNbMag}" marge="${qlikExprMarge}"`); + } const browser = await getBrowser(); console.log(`[qlik-pw] chromium lancé`); @@ -175,7 +186,7 @@ export async function fetchNetworkMetricsPlaywright( }); const evaluer = () => page.evaluate( - ({ app, dim, mca, mqte, mnb, mcamag, mmarge, codes, token, monthlyPayload, noDateMode, qlikSettleMs, qlikCode15MaxAttempts, qlikArticleBatchFallbacks, selectAllCodes, monthDim, moisDimId, mqteComp, yearN }: { + ({ app, dim, mca, mqte, mnb, mcamag, mmarge, codes, token, monthlyPayload, noDateMode, qlikSettleMs, qlikCode15MaxAttempts, qlikArticleBatchFallbacks, selectAllCodes, monthDim, moisDimId, mqteComp, yearN, useExpr, exprQte, exprCa, exprNbMag, exprMarge }: { 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[] }>; @@ -188,6 +199,11 @@ export async function fetchNetworkMetricsPlaywright( moisDimId: string; mqteComp: string; yearN: number; + useExpr: boolean; + exprQte: string; + exprCa: string; + exprNbMag: string; + exprMarge: string; }) => new Promise((resolve) => { const loc = (window as unknown as { location: Location }).location; @@ -468,6 +484,168 @@ export async function fetchNetworkMetricsPlaywright( } }; + // ─── Cube mensuel par EXPRESSIONS (respecte la sélection Date) ───── + // + // Constat de production : les master measures « CA N » / « Quantité N » + // **ignorent la sélection Date**. Vérifié arithmétiquement : avec août + // 2025 seul sélectionné, le cube renvoyait 164 unités / 2 220,75 € — + // soit exactement la somme de janvier à juillet 2026. Ce sont des + // mesures « cumul année en cours », pas des mesures de période. + // + // Conséquences : les totaux réseau étaient un cumul année en cours + // (mois courant partiel inclus) et non 12 mois glissants, les mois de + // l'année précédente non couverts par « Quantité COMP » restaient + // vides, et la passe de rattrapage y recopiait le total de période. + // + // Aucune sélection ne peut corriger cela : on agrège donc directement + // les champs de faits, qui eux respectent les sélections. + const PAGEX = 1200; // 7 colonnes × 1200 = 8400 cellules < 10000 + const fetchMonthCubeExpr = async ( + onRow: (code: string, mois: string, ca: number, qte: number, nbMag: number, marge: number, qteMaster: number) => void, + timings?: Record, + onCode15Retry?: () => void, + ): Promise => { + const { value: obj } = await timed("createCubeExpr", "CreateSessionObject", () => rpcWithRetry("createCubeExpr", "CreateSessionObject", { qProp: { qInfo: { qType: "cf-net-mois-expr" }, qHyperCubeDef: { + qDimensions: [{ qLibraryId: dim }, { qLibraryId: moisDimId }], + qMeasures: [ + { qDef: { qDef: exprCa } }, + { qDef: { qDef: exprQte } }, + { qDef: { qDef: exprNbMag } }, + { qDef: { qDef: exprMarge } }, + // Master « Quantité N », uniquement pour le calibrage : sur les + // mois de l'année en cours les deux doivent coïncider. + { qLibraryId: rQte }, + ], + qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 7, qHeight: PAGEX }], + } } }, 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>): 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, + ); + } + 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> }> } }).qHyperCube; + const size = hc.qSize.qcy; + let got = 0, top = 0; + const firstPage = hc.qDataPages?.find((pg) => (pg.qArea?.qTop ?? 0) === 0); + if (firstPage?.qMatrix?.length) { got += process(firstPage.qMatrix); top = PAGEX; } + for (; top < size; top += PAGEX) { + const { value: d } = await timed("getCubeData", "GetHyperCubeData", () => rpcWithRetry("getCubeData", "GetHyperCubeData", { qPath: "/qHyperCubeDef", qPages: [{ qTop: top, qLeft: 0, qWidth: 7, qHeight: PAGEX }] }, oh, onCode15Retry), timings); + const matrix = (d.qDataPages as Array<{ qMatrix?: Array> }>)?.[0]?.qMatrix ?? []; + if (!matrix.length) break; + got += process(matrix); + if (matrix.length < PAGEX) break; + } + return got; + } finally { + await destroyCube(cube, timings); + } + }; + + /** + * Passe unique par expressions : sélectionne la fenêtre 12 mois et tous + * les codes, puis lit un seul cube [Article Code, Mois]. + * + * Renvoie false si le résultat est inexploitable (expression invalide, + * champ absent) — l'appelant repart alors sur les master measures. + */ + const moisParExpressions = async (): Promise => { + const timings: Record = {}; + const marqueOut = out.length; + try { + if (yfh !== -1) { await rpc("Clear", {}, yfh); await sleep(qlikSettleMs); } + const allSerials = monthlyPayload.flatMap((m) => m.serials); + if (dfh !== -1 && allSerials.length) { + await rpc("Clear", {}, dfh); + await sleep(qlikSettleMs); + await timed("selectDate", "SelectValues", () => rpc("SelectValues", { + qFieldValues: allSerials.map((v) => ({ qNum: v, qText: String(v) })), + qToggleMode: false, + qSoftLock: true, + }, dfh), timings); + await sleep(qlikSettleMs); + } + if (codes.length && fh !== -1) { + await timed("clearCode", "Clear", () => rpc("Clear", {}, fh), timings); + await sleep(qlikSettleMs); + await timed("selectCodeAll", "SelectValues", () => rpc("SelectValues", { + qFieldValues: codes.map((c) => ({ qText: c })), + qToggleMode: false, + qSoftLock: true, + }, fh), timings); + await sleep(qlikSettleMs); + } + + // Seuls les mois de la fenêtre nous intéressent ; la dimension + // Mois peut en exposer d'autres si une sélection déborde. + const moisFenetre = new Set(monthlyPayload.map((m) => m.label)); + let totalQte = 0; + let calibrees = 0, calibrOk = 0; + let echantillon = false; + + const got = await timed("cube", "expr", () => fetchMonthCubeExpr((code, mois, ca, qte, nbMag, marge, qteMaster) => { + if (!code || code === "-") return; + const mm = normMois(mois); + if (!moisFenetre.has(mm)) return; + if (!echantillon) { + echantillon = true; + console.log("[qlik-pw][expr] échantillon: " + JSON.stringify({ code, mois: mm, ca, qte, nbMag, marge, "Quantité N (master)": qteMaster })); + } + // Calibrage sur l'année en cours, seul périmètre où la master + // measure est censée être juste. + if (parseInt(mm.slice(0, 4), 10) === yearN && (qte > 0 || qteMaster > 0)) { + calibrees++; + if (Math.abs(qte - qteMaster) <= Math.max(1, qteMaster * 0.02)) calibrOk++; + } + totalQte += qte; + // Totaux = somme des mois de la fenêtre (et non un cumul année). + out.push([code, ca, qte, nbMag, nbMag > 0 ? ca / nbMag : 0, ca > 0 ? marge / ca : 0]); + (monthlyByCode[code] ??= {}); + monthlyByCode[code][mm] = { + qte, ca, nbMag, + caMag: nbMag > 0 ? ca / nbMag : 0, + margePct: ca > 0 ? marge / ca : 0, + }; + }, timings), timings); + + aggMonths = monthlyPayload.length; + aggBatches++; + console.log( + "[qlik-pw][expr] " + got.value + " lignes, quantité totale=" + Math.round(totalQte) + + ", calibrage année " + yearN + " : " + calibrOk + "/" + calibrees + " mois conformes à « Quantité N »" + + " timings=" + timingSummary(timings), + ); + + if (totalQte <= 0) { + console.error("[qlik-pw][expr] quantité totale nulle → expressions inexploitables, repli sur les master measures"); + out.length = marqueOut; + for (const k of Object.keys(monthlyByCode)) delete monthlyByCode[k]; + return false; + } + if (calibrees > 20 && calibrOk / calibrees < 0.5) { + console.warn( + "[qlik-pw][expr] ⚠ calibrage faible (" + calibrOk + "/" + calibrees + ") : " + exprQte + + " diverge de « Quantité N ». Vérifier l'expression (QLIK_EXPR_QTE).", + ); + } + return true; + } catch (e) { + console.error("[qlik-pw][expr] échec : " + String((e as Error)?.message || e) + " → repli sur les master measures"); + out.length = marqueOut; + for (const k of Object.keys(monthlyByCode)) delete monthlyByCode[k]; + return false; + } + }; + // 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). @@ -764,8 +942,13 @@ export async function fetchNetworkMetricsPlaywright( } }; - if (!noDateMode && monthDim) { - // Découpage mensuel réel (dimension Mois) — remplace l'itération par mois. + if (!noDateMode && monthDim && useExpr && (await moisParExpressions())) { + // Chemin nominal : un seul cube, tous les mois de la fenêtre, des + // mesures qui respectent la sélection Date. Ni passe N-1 ni + // rattrapage nécessaires — c'est justement ce qu'ils compensaient. + await pousserCheckpoint("expressions"); + } else if (!noDateMode && monthDim) { + // Repli : master measures « année en cours » + passe N-1 + rattrapage. console.log("[qlik-pw] extraction via dimension Mois (" + monthlyPayload.length + " mois attendus, moisDim=" + moisDimId + ")"); await monthDimPath(null); await pousserCheckpoint("passe N"); @@ -941,6 +1124,11 @@ export async function fetchNetworkMetricsPlaywright( moisDimId: qlikMoisDimId, mqteComp: qlikMeasQteCompId, yearN, + useExpr: qlikUseExpr, + exprQte: qlikExprQte, + exprCa: qlikExprCa, + exprNbMag: qlikExprNbMag, + exprMarge: qlikExprMarge, }, ); From f0e36308f238b960901a52e42a27e390861dfa66 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 06:43:25 +0000 Subject: [PATCH 14/34] =?UTF-8?q?feat(scripts):=20qlik-validate.mjs=20?= =?UTF-8?q?=E2=80=94=20valider=20les=20expressions=20et=20la=20cl=C3=A9=20?= =?UTF-8?q?de=20jointure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le serveur Qlik est filtré par IP : injoignable depuis l'extérieur du réseau FF (la passerelle d'egress tombe en connection timeout sur 443 comme sur 80, alors qu'elle atteint le reste d'Internet). Impossible donc de valider à distance les expressions par défaut ni la clé de jointure. Ce script répond aux deux questions en une exécution, à lancer depuis le conteneur de l'app : - expressions réelles des master measures (CA N, Quantité N, COMP…) ; - tableau mois par mois comparant Sum(quantite) / Sum(ca_ht) / Sum(ca_ttc) / Count(DISTINCT [Magasin Code]) aux mesures « N », sur l'année en cours (où elles doivent coïncider) et sur l'année précédente (où les « N » sont censées être à 0) ; - Article Code, article_no_centrale et article_codein côte à côte, pour trancher la jointure avec articles.artcentrale. Lecture seule : sélections en soft lock, objets de session détruits. Le mot de passe vient de l'environnement, rien n'est écrit ni affiché. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DfqUihgixw4K1AmJhizWiu --- docs/qlik-integration.md | 20 +++ scripts/qlik-validate.mjs | 354 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 374 insertions(+) create mode 100644 scripts/qlik-validate.mjs diff --git a/docs/qlik-integration.md b/docs/qlik-integration.md index a8280d6..0f4c7b0 100644 --- a/docs/qlik-integration.md +++ b/docs/qlik-integration.md @@ -120,6 +120,26 @@ Un calibrage faible signale une expression à ajuster (filtre `flag_type_mvt`, `ca_ttc` plutôt que `ca_ht`…). Quantité totale nulle ou erreur Engine → repli automatique sur l'ancien chemin. `QLIK_USE_EXPR=0` le force. +### Valider les expressions sur le vrai serveur + +`scripts/qlik-validate.mjs` répond aux deux questions ouvertes en une exécution, +**depuis un réseau qui atteint Qlik** (le serveur est filtré par IP : injoignable +depuis l'extérieur, la passerelle d'egress tombe en `connection timeout`) : + +``` +docker exec -e QLIK_PWD='' -it node scripts/qlik-validate.mjs +``` + +Il affiche les expressions réelles des master measures, puis un tableau mois par +mois comparant `Sum(quantite)` / `Sum(ca_ht)` / `Sum(ca_ttc)` / +`Count(DISTINCT [Magasin Code])` aux mesures « N » — sur les mois de l'année en +cours (où elles doivent coïncider) comme sur ceux de l'année précédente (où les +mesures « N » sont censées être à 0). Il affiche enfin `Article Code`, +`article_no_centrale` et `article_codein` côte à côte, pour trancher la clé de +jointure avec `articles.artcentrale`. + +Lecture seule : sélections en soft lock, objets de session détruits. + ## Mois vides de la fenêtre glissante (chemin de repli) Les mesures « N » de l'app sont bornées à une année civile. Quand la fenêtre diff --git a/scripts/qlik-validate.mjs b/scripts/qlik-validate.mjs new file mode 100644 index 0000000..86e3a46 --- /dev/null +++ b/scripts/qlik-validate.mjs @@ -0,0 +1,354 @@ +/** + * CollectFlow — Validation des expressions Qlik et de la clé de jointure. + * + * À lancer DEPUIS le réseau qui atteint Qlik (typiquement le conteneur de l'app) : + * + * docker exec -e QLIK_PWD='' -it node scripts/qlik-validate.mjs + * + * Répond aux deux questions encore ouvertes : + * + * 1. Les master measures « CA N » / « Quantité N » ignorent-elles la sélection + * Date, et les agrégations directes (`Sum(quantite)`, `Sum(ca_ht)`…) sont-elles + * un substitut fidèle ? Le script affiche, pour quelques articles réellement + * vendus, un tableau mois par mois comparant les deux — dont les mois d'août à + * décembre qui ressortaient vides. + * + * 2. Quelle valeur joint avec `articles.artcentrale` : la dimension « Article + * Code » ou le champ `article_no_centrale` ? Le script affiche les deux + * côte à côte. + * + * Aucun secret n'est écrit ni affiché : le mot de passe vient de l'environnement. + * Le script est en LECTURE SEULE (sélections en soft lock, objets de session + * détruits) — il ne modifie rien dans Qlik. + */ + +import httpntlm from "httpntlm"; +import { randomBytes } from "node:crypto"; +import { chromium } from "playwright-core"; + +process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0"; // certificat interne + +const HOST = process.env.QLIK_HOST || "reporting-magasins.lafoirfouille.fr"; +const USER = process.env.QLIK_USER || "FFSCH"; +const PWD = process.env.QLIK_PWD || ""; +const DOMAIN = process.env.QLIK_DOMAIN ?? ""; +const APP = process.env.QLIK_APP_NETWORK || "9872ee6e-d64a-4b43-984a-076bf1f7f647"; +const DIM_ARTICLE = process.env.QLIK_DIM_CODE_ARTICLE_ID || "yesCP"; +const DIM_MOIS = process.env.QLIK_MOIS_DIM_ID || "pfGAwTs"; +const NB_ARTICLES = Number(process.env.QLIK_VALIDATE_ARTICLES || "3"); + +if (!PWD) { + console.error("ERREUR : définir QLIK_PWD dans l'environnement."); + console.error(" docker exec -e QLIK_PWD='…' -it node scripts/qlik-validate.mjs"); + process.exit(1); +} + +const xrfkey = randomBytes(12).toString("base64").replace(/[^a-zA-Z0-9]/g, "").slice(0, 16).padEnd(16, "0"); + +// ─── Fenêtre 12 mois glissants, mois courant exclu (même règle que l'app) ──── + +const QLIK_EPOCH_MS = Date.UTC(1899, 11, 30); +const serialDe = (y, m, d) => Math.round((Date.UTC(y, m - 1, d) - QLIK_EPOCH_MS) / 86_400_000); + +function fenetre12Mois(now = new Date()) { + const debut = new Date(now.getFullYear(), now.getMonth() - 12, 1); + const fin = new Date(now.getFullYear(), now.getMonth(), 0); + const serials = []; + const s0 = serialDe(debut.getFullYear(), debut.getMonth() + 1, debut.getDate()); + const s1 = serialDe(fin.getFullYear(), fin.getMonth() + 1, fin.getDate()); + for (let s = s0; s <= s1; s++) serials.push(s); + const labels = []; + for (let i = 12; i >= 1; i--) { + const d = new Date(now.getFullYear(), now.getMonth() - i, 1); + labels.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`); + } + return { serials, labels, debut: debut.toISOString().slice(0, 10), fin: fin.toISOString().slice(0, 10) }; +} + +// ─── Auth NTLM → cookie de session ────────────────────────────────────────── + +function ntlmGet(path) { + return new Promise((resolve, reject) => { + httpntlm.get( + { + url: `https://${HOST}${path}`, + username: USER, + password: PWD, + domain: DOMAIN, + workstation: "", + rejectUnauthorized: false, + headers: { "User-Agent": "Mozilla/5.0 CollectFlow", "X-Qlik-Xrfkey": xrfkey }, + }, + (err, res) => (err ? reject(err) : resolve(res)), + ); + }); +} + +function cookieDepuis(setCookie) { + const arr = Array.isArray(setCookie) ? setCookie : setCookie ? [String(setCookie)] : []; + return arr.map((c) => String(c).split(";")[0]).find((c) => /X-Qlik-Session/i.test(c)) ?? null; +} + +async function session() { + const hub = await fetch(`https://${HOST}/hub/`, { redirect: "manual" }); + const targetId = ((hub.headers.get("location") ?? "").match(/targetId=([0-9a-f-]+)/i) ?? [])[1] ?? ""; + const chemin = targetId + ? `/internal_windows_authentication/?targetId=${targetId}&xrfkey=${xrfkey}` + : `/hub/?xrfkey=${xrfkey}`; + const res = await ntlmGet(chemin); + + const direct = cookieDepuis(res.headers["set-cookie"]); + if (direct) return direct; + + const loc = String(res.headers["location"] ?? ""); + if (!/qlikTicket=/.test(loc)) throw new Error(`auth NTLM sans ticket (HTTP ${res.statusCode})`); + const ech = await fetch(loc, { redirect: "manual" }); + const sc = typeof ech.headers.getSetCookie === "function" ? ech.headers.getSetCookie() : []; + const cookie = sc.map((c) => c.split(";")[0]).filter((c) => /X-Qlik-Session/i.test(c)).join("; "); + if (!cookie) throw new Error("ticket non échangé contre un cookie de session"); + return cookie; +} + +// ─── Script exécuté dans la page (websocket Engine accepté par le proxy) ──── + +function scriptInPage({ app, token, dimArticle, dimMois, serials, labels, nbArticles }) { + return new Promise((resolve) => { + const url = `wss://${location.host}/app/${app}?reloadUri=${encodeURIComponent(location.href)}&qlik-csrf-token=${token}`; + const ws = new WebSocket(url); + const sortie = []; + const log = (l) => { sortie.push(l); console.log("[cf] " + l); }; + let id = 0; + const pend = new Map(); + const rpc = (method, params, handle = -1) => new Promise((res, rej) => { + const i = ++id; pend.set(i, { res, rej }); + ws.send(JSON.stringify({ jsonrpc: "2.0", id: i, handle, method, params })); + }); + const pret = new Promise((res, rej) => { + ws.onmessage = (ev) => { + const m = JSON.parse(ev.data); + if (m.method === "OnConnected") return res(); + if (m.id && pend.has(m.id)) { + const p = pend.get(m.id); pend.delete(m.id); + if (m.error) p.rej(new Error(JSON.stringify(m.error))); + else p.res(m.result); + } + }; + ws.onerror = () => rej(new Error("websocket refusé (403 ?)")); + setTimeout(() => rej(new Error("timeout websocket")), 30000); + }); + const dormir = (ms) => new Promise((r) => setTimeout(r, ms)); + + (async () => { + try { + await pret; + const doc = (await rpc("OpenDoc", { qDocName: app })).qReturn.qHandle; + + // ─── 1. Expressions des master measures ────────────────────── + const ml = await rpc("CreateSessionObject", { + qProp: { + qInfo: { qType: "MeasureList" }, + qMeasureListDef: { qType: "measure", qData: { title: "/qMetaDef/title", expr: "/qMeasure/qDef" } }, + }, + }, doc); + const items = (await rpc("GetLayout", {}, ml.qReturn.qHandle)).qLayout.qMeasureList?.qItems ?? []; + const parTitre = new Map(); + log("=== EXPRESSIONS DES MASTER MEASURES ==="); + for (const it of items) { + const t = (it.qMeta?.title ?? it.qData?.title ?? "").trim(); + if (!t) continue; + parTitre.set(t.toLowerCase(), it.qInfo.qId); + if (/^(CA N|Quantité N|Magasin Ventes Nb N|CA par Magasin N|Marge % N|Quantité COMP)$/i.test(t)) { + log(` « ${t} » = ${it.qData?.expr ?? "?"}`); + } + } + const mesure = (t) => parTitre.get(t.toLowerCase()) ?? null; + + // ─── 2. Sélection de la fenêtre 12 mois ────────────────────── + const champDate = await rpc("GetField", { qFieldName: "Date" }, doc); + const dh = champDate.qReturn.qHandle; + await rpc("Clear", {}, dh); + await dormir(300); + for (let i = 0; i < serials.length; i += 500) { + await rpc("SelectValues", { + qFieldValues: serials.slice(i, i + 500).map((s) => ({ qNum: s, qText: String(s) })), + qToggleMode: i > 0, + qSoftLock: true, + }, dh); + } + await dormir(500); + log(""); + log(`=== FENÊTRE SÉLECTIONNÉE : ${labels[0]} → ${labels[labels.length - 1]} (${serials.length} jours) ===`); + + // ─── 3. Articles les plus vendus (par agrégation directe) ──── + const cubeTop = await rpc("CreateSessionObject", { + qProp: { + qInfo: { qType: "cf-val-top" }, + qHyperCubeDef: { + qDimensions: [{ qLibraryId: dimArticle }], + qMeasures: [{ qDef: { qDef: "Sum(quantite)" }, qSortBy: { qSortByNumeric: -1 } }], + qInterColumnSortOrder: [1, 0], + qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 2, qHeight: nbArticles }], + }, + }, + }, doc); + const topMatrix = (await rpc("GetLayout", {}, cubeTop.qReturn.qHandle)) + .qLayout.qHyperCube.qDataPages?.[0]?.qMatrix ?? []; + const codes = topMatrix.map((r) => String(r[0]?.qText ?? "")).filter(Boolean); + log(`articles testés (top ventes) : ${JSON.stringify(codes)}`); + if (codes.length === 0) { ws.close(); return resolve({ ok: false, error: "aucun article vendu sur la fenêtre", sortie }); } + + const champArticle = await rpc("GetField", { qFieldName: "Article Code" }, doc); + const ah = champArticle.qReturn.qHandle; + await rpc("Clear", {}, ah); + await dormir(300); + await rpc("SelectValues", { qFieldValues: codes.map((c) => ({ qText: c })), qToggleMode: false, qSoftLock: true }, ah); + await dormir(500); + + // ─── 4. Comparaison mois par mois ──────────────────────────── + const mesures = [ + { qDef: { qDef: "Sum(quantite)" } }, + { qDef: { qDef: "Sum(ca_ht)" } }, + { qDef: { qDef: "Sum(ca_ttc)" } }, + { qDef: { qDef: "Count(DISTINCT [Magasin Code])" } }, + { qDef: { qDef: "Sum(marge)" } }, + ]; + const idQte = mesure("Quantité N"); + const idCa = mesure("CA N"); + const idNbMag = mesure("Magasin Ventes Nb N"); + if (idQte) mesures.push({ qLibraryId: idQte }); + if (idCa) mesures.push({ qLibraryId: idCa }); + if (idNbMag) mesures.push({ qLibraryId: idNbMag }); + const largeur = 2 + mesures.length; + + const cube = await rpc("CreateSessionObject", { + qProp: { + qInfo: { qType: "cf-val-mois" }, + qHyperCubeDef: { + qDimensions: [{ qLibraryId: dimArticle }, { qLibraryId: dimMois }], + qMeasures: mesures, + qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: largeur, qHeight: 400 }], + }, + }, + }, doc); + const hc = (await rpc("GetLayout", {}, cube.qReturn.qHandle)).qLayout.qHyperCube; + const lignes = hc.qDataPages?.[0]?.qMatrix ?? []; + + log(""); + log("=== COMPARAISON MOIS PAR MOIS (agrégation directe vs master measure) ==="); + log("article mois Sum(quantite) Quantité N | Sum(ca_ht) Sum(ca_ttc) CA N | Count(mag) Mag Nb N"); + const pad = (v, n) => String(v).padStart(n); + for (const r of lignes) { + const code = String(r[0]?.qText ?? ""); + const moisBrut = String(r[1]?.qText ?? ""); + const mois = /^\d{6}$/.test(moisBrut) ? moisBrut.slice(0, 4) + "-" + moisBrut.slice(4) : moisBrut; + const n = (i) => Number(r[2 + i]?.qNum ?? 0) || 0; + log( + code.padEnd(13) + mois.padEnd(9) + + pad(Math.round(n(0)), 13) + pad(Math.round(n(5)), 12) + " |" + + pad(Math.round(n(1)), 11) + pad(Math.round(n(2)), 13) + pad(Math.round(n(6)), 9) + " |" + + pad(Math.round(n(3)), 11) + pad(Math.round(n(7)), 10), + ); + } + log(""); + log("LECTURE : si « Quantité N » vaut 0 sur les mois de l'année précédente alors que"); + log(" Sum(quantite) est renseigné, la master measure est bien inutilisable"); + log(" pour une fenêtre glissante — et l'agrégation directe la remplace."); + log(" Si Sum(quantite) == Quantité N sur les mois de l'année en cours,"); + log(" l'expression par défaut de CollectFlow est validée."); + log(" Comparer aussi Sum(ca_ht) et Sum(ca_ttc) à « CA N » pour choisir"); + log(" QLIK_EXPR_CA."); + + // ─── 5. Clé de jointure : Article Code vs article_no_centrale ─ + const cubeCle = await rpc("CreateSessionObject", { + qProp: { + qInfo: { qType: "cf-val-cle" }, + qHyperCubeDef: { + qDimensions: [ + { qLibraryId: dimArticle }, + { qDef: { qFieldDefs: ["article_no_centrale"] } }, + { qDef: { qFieldDefs: ["article_codein"] } }, + ], + qMeasures: [], + qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 3, qHeight: 20 }], + }, + }, + }, doc); + const cles = (await rpc("GetLayout", {}, cubeCle.qReturn.qHandle)) + .qLayout.qHyperCube.qDataPages?.[0]?.qMatrix ?? []; + log(""); + log("=== CLÉ DE JOINTURE ==="); + log("Article Code article_no_centrale article_codein"); + for (const r of cles) { + log( + String(r[0]?.qText ?? "").padEnd(20) + + String(r[1]?.qText ?? "").padEnd(22) + + String(r[2]?.qText ?? ""), + ); + } + log(""); + log("LECTURE : la bonne clé est celle qui a le format de `articles.artcentrale`"); + log(" côté FF Nancy (ex. 10000167303 — préfixe 10000 + 6 chiffres)."); + + ws.close(); + resolve({ ok: true, sortie }); + } catch (e) { + try { ws.close(); } catch { /* noop */ } + resolve({ ok: false, error: String(e?.message || e), sortie }); + } + })(); + }); +} + +// ─── Orchestration ────────────────────────────────────────────────────────── + +(async () => { + const f = fenetre12Mois(); + console.log(`Qlik ${HOST} — app ${APP}`); + console.log(`Fenêtre : ${f.debut} → ${f.fin}\n`); + + const cookie = await session(); + console.log("✓ authentifié (NTLM + ticket)\n"); + const [nom, ...reste] = cookie.split("="); + + const navigateur = await chromium.launch({ + headless: true, + executablePath: process.env.PLAYWRIGHT_CHROMIUM_PATH || undefined, + args: ["--no-sandbox", "--disable-dev-shm-usage", "--ignore-certificate-errors"], + }); + const ctx = await navigateur.newContext({ + httpCredentials: { username: USER, password: PWD, origin: `https://${HOST}` }, + ignoreHTTPSErrors: true, + }); + await ctx.addCookies([{ name: nom, value: reste.join("="), domain: HOST, path: "/", secure: true }]); + + try { + const page = await ctx.newPage(); + let token = null; + let pret; + const attente = new Promise((r) => { pret = r; }); + page.on("websocket", (ws) => { + const m = ws.url().match(/qlik-csrf-token=([^&]+)/); + if (m && !token) { token = decodeURIComponent(m[1]); pret(); } + }); + await page.goto(`https://${HOST}/sense/app/${APP}`, { waitUntil: "domcontentloaded", timeout: 60000 }).catch(() => {}); + await Promise.race([attente, page.waitForTimeout(20000)]); + if (!token) throw new Error("qlik-csrf-token introuvable (client Qlik non chargé ?)"); + + const res = await page.evaluate(scriptInPage, { + app: APP, token, dimArticle: DIM_ARTICLE, dimMois: DIM_MOIS, + serials: f.serials, labels: f.labels, nbArticles: NB_ARTICLES, + }); + + console.log(res.sortie.join("\n")); + if (!res.ok) { + console.error("\nÉCHEC : " + res.error); + process.exitCode = 1; + } + } finally { + await ctx.close(); + await navigateur.close(); + } +})().catch((e) => { + console.error("ERREUR :", e?.message || e); + process.exit(1); +}); From db886cd8d9ba817be33d7dd12fdb9b05b73c1b20 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 07:01:27 +0000 Subject: [PATCH 15/34] =?UTF-8?q?fix(qlik):=20supprime=20la=20passe=20de?= =?UTF-8?q?=20rattrapage=20et=20v=C3=A9rifie=20que=20la=20s=C3=A9lection?= =?UTF-8?q?=20Date=20filtre?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le log de la dernière sync montre batches=3 et des lignes « (rattrapage) » : le chemin par expressions a bien tourné mais a été rejeté (quantité totale nulle), et c'est le rattrapage qui a de nouveau recopié le total de période dans les mois manquants — d'où les plateaux à 87 648 d'août à décembre et les « -162 % ». - Passe de rattrapage SUPPRIMÉE. Elle ré-extrayait un mois vide en ne sélectionnant que ses dates ; les master measures ignorant la sélection Date, le cube lui renvoyait le total de période. Un mois qu'on ne sait pas extraire doit rester absent, jamais rempli d'une valeur plausible. - Le chemin par expressions vérifie maintenant qu'un champ date filtre RÉELLEMENT les faits, ce que personne n'avait pu contrôler tant que seules des mesures insensibles à la sélection étaient utilisées. Un total de contrôle (Sum(quantite) sans dimension) est comparé avec et sans filtre ; un champ n'est retenu que si le total est non nul ET strictement inférieur au total global. Candidats : QLIK_DATE_FIELD, Date, Date calendrier, Date_Key (ce dernier sélectionné au format AAAAMMJJ). Si aucun ne filtre, l'extraction le dit clairement au lieu de produire une fenêtre fausse. - Diagnostic : échantillon BRUT du cube loggué avant tout filtrage (mois brut, mois normalisé, appartenance à la fenêtre, valeurs des cinq mesures), et compteur de lignes écartées comme hors fenêtre. Ces deux traces distinguent une expression invalide d'un format de dimension Mois inattendu. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DfqUihgixw4K1AmJhizWiu --- docs/qlik-integration.md | 33 ++++++ src/lib/qlik-playwright.ts | 221 +++++++++++++++++++++++-------------- 2 files changed, 170 insertions(+), 84 deletions(-) diff --git a/docs/qlik-integration.md b/docs/qlik-integration.md index 0f4c7b0..d632981 100644 --- a/docs/qlik-integration.md +++ b/docs/qlik-integration.md @@ -140,6 +140,39 @@ jointure avec `articles.artcentrale`. Lecture seule : sélections en soft lock, objets de session détruits. +### La sélection Date filtre-t-elle seulement quelque chose ? + +Question restée sans réponse tant que seules des mesures **insensibles à la +sélection** étaient utilisées : rien ne prouvait que `Date` bornait quoi que ce +soit. Le chemin par expressions le vérifie désormais avant d'extraire, avec un +total de contrôle (`Sum(quantite)` sans dimension) : + +``` +[qlik-pw][expr] contrôle Sum(quantite) sans filtre date = 12345678 +[qlik-pw][expr] champ « Date » → 0 (0% du total sans filtre) +[qlik-pw][expr] champ « Date calendrier » → 3456789 (28% du total sans filtre) +[qlik-pw][expr] champ date retenu : « Date calendrier » +``` + +Un champ n'est retenu que s'il donne un total **non nul et strictement inférieur** +au total sans filtre — 0 % signifie que la sélection ne matche rien, 100 % qu'elle +n'a aucun effet. Candidats essayés dans l'ordre : `QLIK_DATE_FIELD` (si défini), +`Date`, `Date calendrier`, `Date_Key` (celui-ci sélectionné au format `AAAAMMJJ`). + +Si aucun ne filtre, l'extraction le dit et repart sur l'ancien chemin plutôt que +de produire une fenêtre fausse. + +## La passe de rattrapage a été supprimée + +Elle ré-extrayait un mois vide en ne sélectionnant que ses dates. Les master +measures ignorant la sélection Date, le cube lui renvoyait le **total de période** +qu'elle recopiait dans chaque mois manquant : d'où les plateaux identiques d'août +à décembre (87 648 douze mois de suite sur un article) et les tendances +« −162 % » entièrement fabriquées. + +Un mois qu'on ne sait pas extraire doit rester **absent**, jamais rempli d'une +valeur plausible. + ## Mois vides de la fenêtre glissante (chemin de repli) Les mesures « N » de l'app sont bornées à une année civile. Quand la fenêtre diff --git a/src/lib/qlik-playwright.ts b/src/lib/qlik-playwright.ts index 1e33a2c..aaa4038 100644 --- a/src/lib/qlik-playwright.ts +++ b/src/lib/qlik-playwright.ts @@ -129,10 +129,19 @@ export async function fetchNetworkMetricsPlaywright( const qlikExprCa = (process.env.QLIK_EXPR_CA ?? "Sum(ca_ht)").trim(); const qlikExprNbMag = (process.env.QLIK_EXPR_NBMAG ?? "Count(DISTINCT [Magasin Code])").trim(); const qlikExprMarge = (process.env.QLIK_EXPR_MARGE ?? "Sum(marge)").trim(); + // Champs date candidats, essayés dans l'ordre. Un champ n'est retenu que s'il + // FILTRE réellement les faits (total non nul et strictement inférieur au total + // sans filtre) : personne n'avait pu le vérifier tant que seules des mesures + // insensibles à la sélection étaient utilisées. + const qlikDateFields = [ + (process.env.QLIK_DATE_FIELD ?? "").trim(), + "Date", "Date calendrier", "Date_Key", + ].filter((c, i, arr) => c && arr.indexOf(c) === i); const yearN = new Date().getFullYear(); console.log(`[qlik-pw] tuning settle=${qlikSettleMs}ms code15Attempts=${qlikCode15MaxAttempts} batchFallbacks=${qlikArticleBatchFallbacks.join(">")} selectAllCodes=${qlikSelectAllCodes} monthDim=${qlikMonthDim} useExpr=${qlikUseExpr}`); if (qlikUseExpr) { console.log(`[qlik-pw] expressions mensuelles: qte="${qlikExprQte}" ca="${qlikExprCa}" nbMag="${qlikExprNbMag}" marge="${qlikExprMarge}"`); + console.log(`[qlik-pw] champs date candidats: ${JSON.stringify(qlikDateFields)}`); } const browser = await getBrowser(); @@ -172,6 +181,13 @@ export async function fetchNetworkMetricsPlaywright( dateDebut: m.dateDebut, dateFin: m.dateFin, serials: m.dailySerials, // ~30 valeurs → SelectValues en une fois + // Même fenêtre au format entier AAAAMMJJ : certains modèles Qlik + // n'exposent la date que par une clé numérique (`Date_Key`), pas par + // un champ date sérialisé. + ymd: m.dailySerials.map((serial) => { + const d = new Date(Date.UTC(1899, 11, 30) + serial * 86_400_000); + return d.getUTCFullYear() * 10000 + (d.getUTCMonth() + 1) * 100 + d.getUTCDate(); + }), })); // Point de contrôle : le script in-page pousse ses résultats intermédiaires @@ -186,10 +202,10 @@ export async function fetchNetworkMetricsPlaywright( }); const evaluer = () => page.evaluate( - ({ app, dim, mca, mqte, mnb, mcamag, mmarge, codes, token, monthlyPayload, noDateMode, qlikSettleMs, qlikCode15MaxAttempts, qlikArticleBatchFallbacks, selectAllCodes, monthDim, moisDimId, mqteComp, yearN, useExpr, exprQte, exprCa, exprNbMag, exprMarge }: { + ({ app, dim, mca, mqte, mnb, mcamag, mmarge, codes, token, monthlyPayload, noDateMode, qlikSettleMs, qlikCode15MaxAttempts, qlikArticleBatchFallbacks, selectAllCodes, monthDim, moisDimId, mqteComp, yearN, useExpr, exprQte, exprCa, exprNbMag, exprMarge, champsDateCandidats }: { 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[] }>; + monthlyPayload: Array<{ label: string; dateDebut: string; dateFin: string; serials: number[]; ymd: number[] }>; noDateMode: boolean; qlikSettleMs: number; qlikCode15MaxAttempts: number; @@ -204,6 +220,7 @@ export async function fetchNetworkMetricsPlaywright( exprCa: string; exprNbMag: string; exprMarge: string; + champsDateCandidats: string[]; }) => new Promise((resolve) => { const loc = (window as unknown as { location: Location }).location; @@ -558,22 +575,62 @@ export async function fetchNetworkMetricsPlaywright( * Renvoie false si le résultat est inexploitable (expression invalide, * champ absent) — l'appelant repart alors sur les master measures. */ + /** + * Total de contrôle : un cube 1 cellule, sans dimension, sur + * l'expression quantité. Sert à savoir si une sélection de dates + * FILTRE réellement les faits — ce que les master measures, qui + * l'ignorent, ne permettaient pas de vérifier. + */ + const totalDeControle = async (): Promise => { + const o = await rpc("CreateSessionObject", { qProp: { + qInfo: { qType: "cf-ctrl" }, + qHyperCubeDef: { + qDimensions: [], + qMeasures: [{ qDef: { qDef: exprQte } }], + qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 1, qHeight: 1 }], + }, + } }, doc); + const ret = o.qReturn as { qHandle: number; qGenericId?: string; qId?: string }; + try { + const lay = await rpc("GetLayout", {}, ret.qHandle); + const cell = (lay.qLayout as { qHyperCube?: { qDataPages?: Array<{ qMatrix?: Array> }> } }) + .qHyperCube?.qDataPages?.[0]?.qMatrix?.[0]?.[0]; + return Number(cell?.qNum) || 0; + } finally { + const id = String(ret.qGenericId ?? ret.qId ?? ""); + if (id) { try { await rpc("DestroySessionObject", { qId: id }, doc); } catch { /* noop */ } } + } + }; + + /** Sélectionne la fenêtre sur un champ date candidat. Renvoie false si le champ n'existe pas. */ + const selectionnerFenetreSur = async (champ: string, valeurs: number[]): Promise => { + let h: number; + try { + const gf = await rpc("GetField", { qFieldName: champ }, doc); + h = (gf.qReturn as { qHandle: number }).qHandle; + if (typeof h !== "number" || h < 0) return false; + } catch { return false; } + await rpc("Clear", {}, h); + await sleep(qlikSettleMs); + for (let i = 0; i < valeurs.length; i += 500) { + await rpc("SelectValues", { + qFieldValues: valeurs.slice(i, i + 500).map((v) => ({ qNum: v, qText: String(v) })), + qToggleMode: i > 0, + qSoftLock: true, + }, h); + } + await sleep(qlikSettleMs); + return true; + }; + const moisParExpressions = async (): Promise => { const timings: Record = {}; const marqueOut = out.length; try { if (yfh !== -1) { await rpc("Clear", {}, yfh); await sleep(qlikSettleMs); } - const allSerials = monthlyPayload.flatMap((m) => m.serials); - if (dfh !== -1 && allSerials.length) { - await rpc("Clear", {}, dfh); - await sleep(qlikSettleMs); - await timed("selectDate", "SelectValues", () => rpc("SelectValues", { - qFieldValues: allSerials.map((v) => ({ qNum: v, qText: String(v) })), - qToggleMode: false, - qSoftLock: true, - }, dfh), timings); - await sleep(qlikSettleMs); - } + + // Les codes d'abord : les totaux de contrôle portent alors sur + // le même périmètre que l'extraction. if (codes.length && fh !== -1) { await timed("clearCode", "Clear", () => rpc("Clear", {}, fh), timings); await sleep(qlikSettleMs); @@ -585,21 +642,68 @@ export async function fetchNetworkMetricsPlaywright( await sleep(qlikSettleMs); } + // ─── Quel champ date filtre RÉELLEMENT les faits ? ─────── + // + // Les master measures ignorant la sélection Date, personne + // n'avait jamais pu vérifier que « Date » filtrait quoi que + // ce soit. Un champ valide doit donner un total non nul ET + // strictement inférieur au total sans filtre. + const sansFiltre = await totalDeControle(); + console.log("[qlik-pw][expr] contrôle " + exprQte + " sans filtre date = " + Math.round(sansFiltre)); + if (sansFiltre <= 0) { + console.error("[qlik-pw][expr] " + exprQte + " renvoie 0 même sans filtre → expression ou champ invalide (QLIK_EXPR_QTE)"); + return false; + } + + const allSerials = monthlyPayload.flatMap((m) => m.serials); + const allYmd = monthlyPayload.flatMap((m) => m.ymd); + let champDate: string | null = null; + for (const cand of champsDateCandidats) { + const valeurs = /key/i.test(cand) ? allYmd : allSerials; + if (!(await selectionnerFenetreSur(cand, valeurs))) { + console.log("[qlik-pw][expr] champ « " + cand + " » : inexistant"); + continue; + } + const filtre = await totalDeControle(); + const ratio = sansFiltre > 0 ? filtre / sansFiltre : 0; + console.log( + "[qlik-pw][expr] champ « " + cand + " » → " + Math.round(filtre) + + " (" + Math.round(ratio * 100) + "% du total sans filtre)", + ); + if (filtre > 0 && ratio < 0.995) { champDate = cand; break; } + } + if (!champDate) { + console.error( + "[qlik-pw][expr] aucun champ date ne filtre les faits (essayés : " + + JSON.stringify(champsDateCandidats) + ") → impossible de borner la fenêtre 12 mois. " + + "Forcer le bon champ avec QLIK_DATE_FIELD.", + ); + return false; + } + console.log("[qlik-pw][expr] champ date retenu : « " + champDate + " »"); + // Seuls les mois de la fenêtre nous intéressent ; la dimension // Mois peut en exposer d'autres si une sélection déborde. const moisFenetre = new Set(monthlyPayload.map((m) => m.label)); let totalQte = 0; let calibrees = 0, calibrOk = 0; + let horsFenetre = 0; let echantillon = false; const got = await timed("cube", "expr", () => fetchMonthCubeExpr((code, mois, ca, qte, nbMag, marge, qteMaster) => { if (!code || code === "-") return; const mm = normMois(mois); - if (!moisFenetre.has(mm)) return; if (!echantillon) { echantillon = true; - console.log("[qlik-pw][expr] échantillon: " + JSON.stringify({ code, mois: mm, ca, qte, nbMag, marge, "Quantité N (master)": qteMaster })); + // Échantillon AVANT filtrage : si le mois ne tombe pas dans + // la fenêtre, c'est le format de la dimension Mois qui est + // en cause, pas les mesures. + console.log("[qlik-pw][expr] échantillon brut: " + JSON.stringify({ + code, moisBrut: mois, moisNormalise: mm, dansLaFenetre: moisFenetre.has(mm), + ca, qte, nbMag, marge, "Quantité N (master)": qteMaster, + })); } + if (!moisFenetre.has(mm)) { horsFenetre++; return; } // Calibrage sur l'année en cours, seul périmètre où la master // measure est censée être juste. if (parseInt(mm.slice(0, 4), 10) === yearN && (qte > 0 || qteMaster > 0)) { @@ -620,13 +724,17 @@ export async function fetchNetworkMetricsPlaywright( aggMonths = monthlyPayload.length; aggBatches++; console.log( - "[qlik-pw][expr] " + got.value + " lignes, quantité totale=" + Math.round(totalQte) + + "[qlik-pw][expr] " + got.value + " lignes (" + horsFenetre + " hors fenêtre), quantité totale=" + Math.round(totalQte) + ", calibrage année " + yearN + " : " + calibrOk + "/" + calibrees + " mois conformes à « Quantité N »" + " timings=" + timingSummary(timings), ); if (totalQte <= 0) { - console.error("[qlik-pw][expr] quantité totale nulle → expressions inexploitables, repli sur les master measures"); + console.error( + "[qlik-pw][expr] quantité totale nulle sur la fenêtre alors que le contrôle global valait " + + Math.round(sansFiltre) + " — " + horsFenetre + " ligne(s) écartées comme hors fenêtre. " + + "Si ce nombre est élevé, c'est le format de la dimension Mois qui ne correspond pas aux libellés attendus.", + ); out.length = marqueOut; for (const k of Object.keys(monthlyByCode)) delete monthlyByCode[k]; return false; @@ -864,71 +972,16 @@ export async function fetchNetworkMetricsPlaywright( } }; - /** - * Rattrapage des mois RESTÉS VIDES pour tous les articles. - * - * Les mesures « N » de l'app sont bornées à une année : quand la - * fenêtre 12 mois glissants chevauche deux années civiles, les mois - * de l'année précédente non couverts par « Quantité COMP » ressortent - * vides — cinq mois d'affilée à 0 sur TOUS les articles, ce qui n'a - * aucun sens métier. - * - * La parade fiable est de ne sélectionner QUE les dates du mois - * concerné : la mesure se résout alors sur la bonne année, quelle que - * soit la façon dont son set analysis est écrit. On ne le fait que - * pour les mois réellement vides — au pire quelques cubes de plus. - */ - const rattraperMoisVides = async (): Promise => { - const moisRenseignes = new Set(); - for (const parMois of Object.values(monthlyByCode)) { - for (const [mois, v] of Object.entries(parMois)) { - if ((v?.qte ?? 0) > 0) moisRenseignes.add(mois); - } - } - const manquants = monthlyPayload.filter((m) => !moisRenseignes.has(m.label)); - if (manquants.length === 0) return; - console.log( - "[qlik-pw] (mois) " + manquants.length + " mois vide(s) sur tous les articles : " + - JSON.stringify(manquants.map((m) => m.label)) + " → passe de rattrapage mois par mois", - ); - - // Une seule sélection Article Code pour tout le rattrapage : - // `SelectValues` sur ce champ coûte des dizaines de secondes - // par appel, on ne le refait pas à chaque mois. - if (yfh !== -1) { try { await rpc("Clear", {}, yfh); await sleep(qlikSettleMs); } catch { /* noop */ } } - if (codes.length && fh !== -1) { - await rpc("Clear", {}, fh); - await sleep(qlikSettleMs); - await rpc("SelectValues", { qFieldValues: codes.map((c) => ({ qText: c })), qToggleMode: false, qSoftLock: true }, fh); - await sleep(qlikSettleMs); - } - - for (const m of manquants) { - const prefix = "[qlik-pw] (rattrapage) " + m.label; - try { - // Seule la fenêtre de dates change d'un mois à l'autre : - // c'est elle qui détermine l'année vue par la mesure. - await rpc("Clear", {}, dfh); - await sleep(qlikSettleMs); - await rpc("SelectValues", { - qFieldValues: m.serials.map((s2) => ({ qNum: s2, qText: String(s2) })), - qToggleMode: false, - qSoftLock: true, - }, dfh); - await sleep(qlikSettleMs); - - const batchRows: Array> = []; - await fetchCubeForSelection(batchRows); - // Uniquement le mensuel : les totaux réseau restent ceux - // de la passe principale, sinon on les doublerait. - accMonthly(batchRows, m.label); - const remplis = Object.values(monthlyByCode).filter((x) => (x[m.label]?.qte ?? 0) > 0).length; - console.log(prefix + " — " + remplis + " article(s) renseigné(s)"); - } catch (e) { - console.error(prefix + " — échec : " + String((e as Error)?.message || e)); - } - } - }; + // La passe de « rattrapage des mois vides » a été SUPPRIMÉE. + // + // Elle ré-extrayait un mois vide en ne sélectionnant que ses dates. + // Or les master measures ignorent la sélection Date : le cube lui + // renvoyait le total de période, qu'elle recopiait dans chacun des + // mois manquants. D'où les plateaux identiques d'août à décembre + // (87 648 sur douze mois pour un article, etc.) et les tendances + // « -162 % » qu'ils fabriquaient. + // + // Un mois qu'on ne sait pas extraire doit rester ABSENT. /** Pousse l'état courant vers Node (voir `cfCheckpoint`). */ const pousserCheckpoint = async (etape: string) => { @@ -969,8 +1022,7 @@ export async function fetchNetworkMetricsPlaywright( } else { console.log("[qlik-pw] (mois) champ Année introuvable → pas de passe N-1"); } - await rattraperMoisVides(); - await pousserCheckpoint("rattrapage"); + await pousserCheckpoint("passes master"); } 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. @@ -1129,6 +1181,7 @@ export async function fetchNetworkMetricsPlaywright( exprCa: qlikExprCa, exprNbMag: qlikExprNbMag, exprMarge: qlikExprMarge, + champsDateCandidats: qlikDateFields, }, ); From 4ef0e9fdebe639f80ff2d10d6a223a8065ece185 Mon Sep 17 00:00:00 2001 From: Michael Date: Fri, 31 Jul 2026 10:28:29 +0200 Subject: [PATCH 16/34] fix Qlik rolling 12-month integrity --- docs/qlik-integration.md | 63 +++++----- src/app/api/qlik/sync/route.ts | 16 +-- src/features/grid/lib/network-trend.ts | 25 ++-- src/features/produits/api/search-produits.ts | 72 +++++++---- src/lib/qlik-client.ts | 8 +- src/lib/qlik-date-range.ts | 10 +- src/lib/qlik-playwright.ts | 123 ++++++++++++++++--- src/lib/qlik-search.ts | 4 +- 8 files changed, 221 insertions(+), 100 deletions(-) diff --git a/docs/qlik-integration.md b/docs/qlik-integration.md index d632981..3b8fe0b 100644 --- a/docs/qlik-integration.md +++ b/docs/qlik-integration.md @@ -117,8 +117,20 @@ master measure est juste : ``` Un calibrage faible signale une expression à ajuster (filtre `flag_type_mvt`, -`ca_ttc` plutôt que `ca_ht`…). Quantité totale nulle ou erreur Engine → repli -automatique sur l'ancien chemin. `QLIK_USE_EXPR=0` le force. +`ca_ttc` plutôt que `ca_ht`…). Il n'existe plus de repli automatique vers les +master measures annuelles : quantité totale nulle, champ Date invalide, erreur +Engine ou somme incohérente font échouer la synchronisation et laissent le cache +précédent intact. + +Après sélection de la fenêtre, l'extracteur vérifie deux invariants avant tout +upsert : + +1. la somme de toutes les lignes `[Article Code, Mois]` est égale au total Qlik + sans dimension calculé avec les mêmes sélections ; +2. pour chaque article, la quantité réseau totale est exactement la somme de ses + 12 mois. Les mois sans ligne de faits sont alors seulement matérialisés à `0`. + +Une extraction interrompue ou partielle n'est jamais publiée. ### Valider les expressions sur le vrai serveur @@ -159,8 +171,10 @@ au total sans filtre — 0 % signifie que la sélection ne matche rien, 100 % qu n'a aucun effet. Candidats essayés dans l'ordre : `QLIK_DATE_FIELD` (si défini), `Date`, `Date calendrier`, `Date_Key` (celui-ci sélectionné au format `AAAAMMJJ`). -Si aucun ne filtre, l'extraction le dit et repart sur l'ancien chemin plutôt que -de produire une fenêtre fausse. +Si aucun ne filtre, l'extraction échoue explicitement plutôt que de produire une +fenêtre fausse. Chaque sélection d'un candidat rejeté est effacée avant l'essai +suivant ; autrement un premier candidat à zéro contaminait tous les contrôles +suivants. ## La passe de rattrapage a été supprimée @@ -173,7 +187,7 @@ qu'elle recopiait dans chaque mois manquant : d'où les plateaux identiques d'ao Un mois qu'on ne sait pas extraire doit rester **absent**, jamais rempli d'une valeur plausible. -## Mois vides de la fenêtre glissante (chemin de repli) +## Mois vides de la fenêtre glissante Les mesures « N » de l'app sont bornées à une année civile. Quand la fenêtre 12 mois glissants chevauche deux années (le cas 11 mois sur 12), le chemin @@ -183,22 +197,15 @@ que sur une seule année : les mois de l'année précédente ressortent vides. cours — d'où, en juillet 2026, un trou observé d'août à décembre 2025 sur **tous** les articles. -Trois garde-fous, dans `qlik-playwright.ts` : +Le chemin daté repose uniquement sur les expressions de faits et un cube unique +`[Article Code, Mois]`. Après validation de la fenêtre et du total, chaque +article reçoit exactement les 12 clés attendues. Une clé absente du cube signifie +alors réellement « aucun fait sur ce mois » et vaut `0`; avant cette validation, +aucun zéro n'est inventé. -1. La passe principale n'écrit plus de `0` pour un mois hors année N, et - « Quantité COMP » n'écrit rien quand elle est vide. Un mois **absent** peut - être rattrapé ; un mois **à 0** se lit comme « pas de vente » et masque le trou. -2. `rattraperMoisVides()` : après les passes N et N-1, tout mois vide pour - **tous** les articles est ré-extrait en ne sélectionnant **que ses dates** — - la mesure se résout alors sur la bonne année, quelle que soit l'écriture de - son set analysis. -3. Les expressions des master measures sont dumpées au log - (`[qlik-pw][dump] expression « Quantité N » = …`) : c'est la seule façon de - savoir comment la mesure est bornée. - -Contournement immédiat sans redéploiement : `QLIK_MONTH_DIM=0` repasse sur -l'itération mois par mois (une sélection de dates par mois, donc pas de trou), -au prix d'une extraction plus lente. +`QLIK_MONTH_DIM=0` ou `QLIK_USE_EXPR=0` désactive désormais un prérequis et fait +échouer la synchronisation datée : ces options ne peuvent plus réactiver un +chemin connu comme incorrect. ⚠️ Les données déjà en cache gardent leurs zéros : il faut relancer la sync Qlik pour les corriger. @@ -219,21 +226,19 @@ sélection de codes, puis seule la fenêtre de dates change d'un mois à l'autre La sonde de diagnostic de fin de sync a été retirée : elle refaisait une sélection complète des codes et des 365 jours pour rien. -### Résultat partiel plutôt que rien +### Aucun résultat partiel dans le cache -Le script in-page pousse un point de contrôle (`cfCheckpoint`) après chaque passe. -Si la session meurt en cours de route, l'extraction repart de ce point au lieu de -tout perdre : les codes déjà traités ont des données **complètes** (la dimension -Mois livre tous leurs mois d'un coup), seul le reliquat manque. Le log signale -alors `⚠ résultat PARTIEL` — relancer la sync complète le reste. +Le script in-page conserve des points de contrôle à des fins de diagnostic, mais +une synchronisation datée interrompue est refusée intégralement. Le cache garde +sa dernière version complète jusqu'à la réussite d'une nouvelle extraction. ## Tendance réseau = 12 mois glissants stricts `computeNetworkTrend()` reconstruit sa fenêtre à partir de la date du jour : 12 mois complets, **mois en cours exclu** (partiel, il tirait la pente vers le bas). -Les mois de la fenêtre absents du cache valent 0 (produit non vendu) ; ceux -antérieurs au premier mois réellement extrait sont écartés au lieu d'être -inventés à 0 (extraction plus courte que 12 mois). +La tendance n'est affichée que si les 12 clés sont explicitement présentes dans +le cache. Les anciennes séries partielles de deux ou trois mois sont donc +refusées au lieu d'être présentées comme une tendance. Retirés : ranking (champs/query/colonnes), analyse IA (routes `/api/ai/*`, `bulk-ai-analyzer`, dossier `ai-copilot`), score (`score-engine.ts`, colonne score). diff --git a/src/app/api/qlik/sync/route.ts b/src/app/api/qlik/sync/route.ts index 3a075b3..33e217e 100644 --- a/src/app/api/qlik/sync/route.ts +++ b/src/app/api/qlik/sync/route.ts @@ -5,10 +5,7 @@ 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"; // Tâche d'extraction Qlik potentiellement très longue (hypercube paginé). @@ -235,16 +232,14 @@ async function runJob(job: QlikSyncJob): Promise { 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); + // Contrat métier strict : toujours 12 mois complets glissants. + // Une variable d'environnement ne doit pas pouvoir raccourcir la série. + const dateFilter = buildGridNetworkQlikDateFilter(new Date(), QLIK_MONTHS_BACK_DEFAULT); job.periode = dateFilter.label; job.dateDebut = dateFilter.dateDebut; job.dateFin = dateFilter.dateFin; console.log( - `[api/qlik/sync] job=${job.jobId} → extraction Qlik pour ${codes.length} codes — fenêtre ${dateFilter.label} (${dateFilter.dateDebut} → ${dateFilter.dateFin}, QLIK_SYNC_MONTHS_BACK=${monthsBack}, bornes ${QLIK_MONTHS_BACK_MIN}..${QLIK_MONTHS_BACK_MAX})…`, + `[api/qlik/sync] job=${job.jobId} → extraction Qlik pour ${codes.length} codes — 12 mois complets ${dateFilter.label} (${dateFilter.dateDebut} → ${dateFilter.dateFin})…`, ); const metrics = await fetchNetworkMetricsPlaywright(codes, undefined, dateFilter); job.fetched = metrics.size; @@ -289,8 +284,7 @@ async function runProductJob(job: QlikSyncJob): Promise { const code = job.codeCentrale!; job.requested = 1; - const monthsBack = envMonthsBack("QLIK_SYNC_MONTHS_BACK", QLIK_MONTHS_BACK_DEFAULT); - const dateFilter = buildGridNetworkQlikDateFilter(new Date(), monthsBack); + const dateFilter = buildGridNetworkQlikDateFilter(new Date(), QLIK_MONTHS_BACK_DEFAULT); job.periode = dateFilter.label; job.dateDebut = dateFilter.dateDebut; job.dateFin = dateFilter.dateFin; diff --git a/src/features/grid/lib/network-trend.ts b/src/features/grid/lib/network-trend.ts index b3d8646..6f06a23 100644 --- a/src/features/grid/lib/network-trend.ts +++ b/src/features/grid/lib/network-trend.ts @@ -62,10 +62,10 @@ export const TREND_COLOR: Record = { * - un mois de la fenêtre **absent** du cache vaut 0 : côté Qlik, un produit * non vendu sur un mois ne produit tout simplement pas de ligne. * - * Seule exception au remplissage par zéro : les mois **antérieurs au premier - * mois réellement extrait**. Ils traduisent une extraction plus courte que 12 - * mois (`QLIK_SYNC_MONTHS_BACK`), pas une absence de ventes — les inventer à 0 - * simulerait une croissance qui n'existe pas. + * Une série n'est affichée que si les **12 clés sont explicitement présentes**. + * L'extracteur Qlik écrit lui-même les mois sans faits à 0 après avoir validé + * la fenêtre et l'égalité avec le total Qlik. Une ancienne extraction partielle + * ne peut donc plus être présentée comme une tendance réelle. * * 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. @@ -77,17 +77,14 @@ export function computeNetworkTrend( ): NetworkTrend { const empty: NetworkTrend = { values: [], labels: [], direction: "flat", pct: null, hasData: false }; if (!qteByMonth) return empty; - const presents = Object.keys(qteByMonth).sort(); // "YYYY-MM" trie chronologiquement - if (presents.length === 0) return empty; + const labels = buildRolling12QlikMonths(now); + const complet = labels.every((label) => + Object.prototype.hasOwnProperty.call(qteByMonth, label) && + Number.isFinite(Number(qteByMonth[label])), + ); + if (!complet) return empty; - const fenetre = buildRolling12QlikMonths(now); - // Aucun mois extrait ne tombe dans la fenêtre glissante (cache périmé) : - // rien de fiable à tracer. - const premierMoisExtrait = presents[0]; - const labels = fenetre.filter((m) => m >= premierMoisExtrait); - if (labels.length === 0 || !labels.some((m) => qteByMonth[m] != null)) return empty; - - const values = labels.map((l) => Number(qteByMonth[l]) || 0); + const values = labels.map((l) => Number(qteByMonth[l])); const n = values.length; let pct: number | null = null; let direction: NetworkTrend["direction"] = "flat"; diff --git a/src/features/produits/api/search-produits.ts b/src/features/produits/api/search-produits.ts index 32c4e70..eebc8de 100644 --- a/src/features/produits/api/search-produits.ts +++ b/src/features/produits/api/search-produits.ts @@ -8,7 +8,7 @@ import { type NetworkMetricCached, } from "@/lib/qlik-network-cache"; import { pgGetProduitsByCodeCentrale, pgSearchProduits, type PgProduitSearchRow } from "@/lib/pg-ff-client"; -import { NB_MAGASINS_RESEAU } from "@/features/grid/lib/network-trend"; +import { buildRolling12QlikMonths, NB_MAGASINS_RESEAU } from "@/features/grid/lib/network-trend"; import { normalizeMargePct } from "@/features/produits/lib/compare-reseau"; import type { ProduitRechercheResultat, ProduitRechercheRow } from "@/features/produits/types"; @@ -37,6 +37,19 @@ const CACHE_TTL_MS = 10 * 60 * 1000; /** Durée de conservation d'un job terminé (le client a le temps de le relire). */ const JOB_TTL_MS = 5 * 60 * 1000; +/** Un cache réseau n'est fiable que s'il porte explicitement les 12 mois attendus. */ +function cacheReseauComplet( + cache: NetworkMetricCached | undefined, + periode: string | null, + now: Date = new Date(), +): cache is NetworkMetricCached { + if (!cache?.qteByMonth || !periode || cache.periode !== periode) return false; + return buildRolling12QlikMonths(now).every((mois) => + Object.prototype.hasOwnProperty.call(cache.qteByMonth, mois) && + Number.isFinite(Number(cache.qteByMonth[mois])), + ); +} + /** * Nombre maximum de recherches Qlik simultanées. * @@ -271,21 +284,29 @@ async function executerRecherche( localPromise, ]); - // On persiste les mesures fraîches : la fiche produit ouverte depuis un - // résultat les affiche aussitôt, sans re-extraction. Le détail mensuel n'est - // pas fourni ici et n'est donc pas écrasé (upsert en COALESCE). + // Le cube de recherche utilise encore les master measures annuelles pour + // classer rapidement les résultats. Il ne doit jamais écraser une extraction + // 12 mois déjà vérifiée : dans ce cas on republie les valeurs du cache avec + // leur mensuel. Sans cache complet, les valeurs de découverte restent + // temporaires jusqu'au bouton « Actualiser depuis Qlik ». try { - const aPersister: NetworkMetric[] = qlik.matches.map((m) => ({ - codeCentrale: m.codeCentrale, - caReseau: m.caReseau, - qteReseau: m.qteReseau, - nbMagasinsReseau: m.nbMagasinsReseau, - caParMagasinReseau: m.caParMagasinReseau, - margePctReseau: m.margePctReseau, - periode: qlik.periode ?? undefined, - libelleReseau: m.libelle || undefined, - fournisseurReseau: m.fournisseur || undefined, - })); + const aPersister: NetworkMetric[] = qlik.matches.map((m) => { + const cache = cacheReseau.get(m.codeCentrale); + const fiable = cacheReseauComplet(cache, qlik.periode); + return { + codeCentrale: m.codeCentrale, + caReseau: fiable ? cache.caReseau : m.caReseau, + qteReseau: fiable ? cache.qteReseau : m.qteReseau, + nbMagasinsReseau: fiable ? cache.nbMagasinsReseau : m.nbMagasinsReseau, + caParMagasinReseau: fiable ? cache.caParMagasinReseau : m.caParMagasinReseau, + margePctReseau: fiable ? cache.margePctReseau : m.margePctReseau, + periode: fiable ? cache.periode ?? undefined : qlik.periode ?? undefined, + qteByMonth: fiable ? cache.qteByMonth ?? undefined : undefined, + metricsByMonth: fiable ? cache.metricsByMonth ?? undefined : undefined, + libelleReseau: m.libelle || undefined, + fournisseurReseau: m.fournisseur || undefined, + }; + }); const n = await upsertNetworkMetrics(aPersister); console.log(`[produits/search] ${n} ligne(s) réseau mises en cache`); } catch (e) { @@ -295,7 +316,12 @@ async function executerRecherche( const rows: ProduitRechercheRow[] = qlik.matches.map((m) => { const local = catalogue.get(m.codeCentrale) ?? (m.codeCentraleAlt ? catalogue.get(m.codeCentraleAlt) : undefined); const cache = cacheReseau.get(m.codeCentrale); - const nbMag = m.nbMagasinsReseau; + const fiable = cacheReseauComplet(cache, qlik.periode); + const qteReseau = fiable ? cache.qteReseau : m.qteReseau; + const caReseau = fiable ? cache.caReseau : m.caReseau; + const nbMag = fiable ? cache.nbMagasinsReseau : m.nbMagasinsReseau; + const caParMagasin = fiable ? cache.caParMagasinReseau : m.caParMagasinReseau; + const margePct = fiable ? cache.margePctReseau : m.margePctReseau; return { codeCentrale: m.codeCentrale, libelle: local?.libelle1 || m.libelle || m.codeCentrale, @@ -304,16 +330,16 @@ async function executerRecherche( codein: local?.codein ?? null, nomenclature: local?.nomenclature ?? "", stockLocal: local ? Number(local.stock_total) || 0 : null, - qteReseau: m.qteReseau, - caReseau: m.caReseau, + qteReseau, + caReseau, nbMagasinsReseau: nbMag, tauxPresence: nbMag / NB_MAGASINS_RESEAU, - qteParMagasinReseau: nbMag > 0 ? m.qteReseau / nbMag : 0, - caParMagasinReseau: m.caParMagasinReseau, - prixMoyenReseau: m.qteReseau > 0 ? m.caReseau / m.qteReseau : null, - margePctReseau: normalizeMargePct(m.margePctReseau), + qteParMagasinReseau: nbMag > 0 ? qteReseau / nbMag : 0, + caParMagasinReseau: caParMagasin, + prixMoyenReseau: qteReseau > 0 ? caReseau / qteReseau : null, + margePctReseau: normalizeMargePct(margePct), // Le mensuel ne vient que du cache : le cube de recherche est agrégé. - qteByMonth: cache?.qteByMonth ?? null, + qteByMonth: fiable ? cache.qteByMonth : null, periode: qlik.periode, }; }); diff --git a/src/lib/qlik-client.ts b/src/lib/qlik-client.ts index 5506448..b6d2ee1 100644 --- a/src/lib/qlik-client.ts +++ b/src/lib/qlik-client.ts @@ -94,11 +94,9 @@ export interface NetworkMetric { /** * 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). + * Le cube mensuel Qlik renvoie déjà ces 5 mesures par (Article Code, Mois). + * Lors d'une synchronisation datée validée, les 12 mois complets sont + * toujours présents ; un mois sans fait est explicitement représenté à 0. */ metricsByMonth?: Record; /** diff --git a/src/lib/qlik-date-range.ts b/src/lib/qlik-date-range.ts index e1eff67..1de772c 100644 --- a/src/lib/qlik-date-range.ts +++ b/src/lib/qlik-date-range.ts @@ -144,8 +144,14 @@ export function buildGridNetworkQlikDateFilter( // 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); + // Ne jamais utiliser `toISOString()` ici : minuit local en Europe/Paris + // devient la veille en UTC et transformait par exemple 2025-07-01 en + // 2025-06-30, soit une extraction de 13 mois. Les bornes sont des dates + // calendaires Qlik, on les formate donc avec les composantes locales. + const localIso = (d: Date): string => + `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; + const dateDebut = localIso(startMonth); + const dateFin = localIso(endMonth); const qStart = isoDateToQlikSerial(dateDebut); const qEnd = isoDateToQlikSerial(dateFin); diff --git a/src/lib/qlik-playwright.ts b/src/lib/qlik-playwright.ts index aaa4038..3a394a0 100644 --- a/src/lib/qlik-playwright.ts +++ b/src/lib/qlik-playwright.ts @@ -602,14 +602,22 @@ export async function fetchNetworkMetricsPlaywright( } }; - /** Sélectionne la fenêtre sur un champ date candidat. Renvoie false si le champ n'existe pas. */ - const selectionnerFenetreSur = async (champ: string, valeurs: number[]): Promise => { + /** + * Sélectionne la fenêtre sur un champ date candidat. + * + * Renvoie le handle pour que l'appelant puisse impérativement + * effacer une sélection rejetée avant d'essayer le candidat + * suivant. Sans ce Clear, un premier champ qui ne matche aucune + * date maintient l'ensemble courant à zéro et condamne tous les + * essais suivants à renvoyer zéro eux aussi. + */ + const selectionnerFenetreSur = async (champ: string, valeurs: number[]): Promise => { let h: number; try { const gf = await rpc("GetField", { qFieldName: champ }, doc); h = (gf.qReturn as { qHandle: number }).qHandle; - if (typeof h !== "number" || h < 0) return false; - } catch { return false; } + if (typeof h !== "number" || h < 0) return null; + } catch { return null; } await rpc("Clear", {}, h); await sleep(qlikSettleMs); for (let i = 0; i < valeurs.length; i += 500) { @@ -620,7 +628,7 @@ export async function fetchNetworkMetricsPlaywright( }, h); } await sleep(qlikSettleMs); - return true; + return h; }; const moisParExpressions = async (): Promise => { @@ -658,9 +666,11 @@ export async function fetchNetworkMetricsPlaywright( const allSerials = monthlyPayload.flatMap((m) => m.serials); const allYmd = monthlyPayload.flatMap((m) => m.ymd); let champDate: string | null = null; + let totalFenetre = 0; for (const cand of champsDateCandidats) { const valeurs = /key/i.test(cand) ? allYmd : allSerials; - if (!(await selectionnerFenetreSur(cand, valeurs))) { + const handleCand = await selectionnerFenetreSur(cand, valeurs); + if (handleCand == null) { console.log("[qlik-pw][expr] champ « " + cand + " » : inexistant"); continue; } @@ -670,7 +680,15 @@ export async function fetchNetworkMetricsPlaywright( "[qlik-pw][expr] champ « " + cand + " » → " + Math.round(filtre) + " (" + Math.round(ratio * 100) + "% du total sans filtre)", ); - if (filtre > 0 && ratio < 0.995) { champDate = cand; break; } + if (filtre > 0 && ratio < 0.995) { + champDate = cand; + totalFenetre = filtre; + break; + } + // Un candidat rejeté ne doit jamais polluer l'essai + // suivant (cas observé : Date=0 puis tous les autres=0). + await rpc("Clear", {}, handleCand); + await sleep(qlikSettleMs); } if (!champDate) { console.error( @@ -714,10 +732,19 @@ export async function fetchNetworkMetricsPlaywright( // Totaux = somme des mois de la fenêtre (et non un cumul année). out.push([code, ca, qte, nbMag, nbMag > 0 ? ca / nbMag : 0, ca > 0 ? marge / ca : 0]); (monthlyByCode[code] ??= {}); + // Agrège au lieu d'écraser : protège contre deux valeurs + // brutes de la dimension Mois qui se normaliseraient vers + // la même clé YYYY-MM. + const precedent = monthlyByCode[code][mm]; + const qteCumulee = (precedent?.qte ?? 0) + qte; + const caCumule = (precedent?.ca ?? 0) + ca; + const margeMontant = ((precedent?.margePct ?? 0) * (precedent?.ca ?? 0)) + marge; monthlyByCode[code][mm] = { - qte, ca, nbMag, + qte: qteCumulee, + ca: caCumule, + nbMag: Math.max(precedent?.nbMag ?? 0, nbMag), caMag: nbMag > 0 ? ca / nbMag : 0, - margePct: ca > 0 ? marge / ca : 0, + margePct: caCumule > 0 ? margeMontant / caCumule : 0, }; }, timings), timings); @@ -739,6 +766,51 @@ export async function fetchNetworkMetricsPlaywright( for (const k of Object.keys(monthlyByCode)) delete monthlyByCode[k]; return false; } + // Contrôle global indépendant : le cube sans dimension, + // calculé sur exactement les mêmes sélections, doit être + // égal à la somme de toutes les lignes mensuelles paginées. + const ecartGlobal = Math.abs(totalQte - totalFenetre); + const toleranceGlobale = Math.max(0.001, Math.abs(totalFenetre) * 1e-9); + if (ecartGlobal > toleranceGlobale) { + console.error( + "[qlik-pw][expr] INTÉGRITÉ REFUSÉE : somme mensuelle=" + totalQte + + " mais total Qlik fenêtre=" + totalFenetre + " (écart=" + ecartGlobal + ")", + ); + out.length = marqueOut; + for (const k of Object.keys(monthlyByCode)) delete monthlyByCode[k]; + return false; + } + + // Contrôle et normalisation PAR ARTICLE. Chaque article + // retourné possède ensuite exactement les 12 clés de la + // fenêtre ; une clé absente du cube Qlik signifie qu'aucun + // fait n'existe pour ce mois, donc une vraie valeur 0. + const totauxParCode: Record = {}; + for (let i = marqueOut; i < out.length; i++) { + const ligne = out[i]; + const code = String(ligne[0] ?? "").trim(); + if (code) totauxParCode[code] = (totauxParCode[code] ?? 0) + (Number(ligne[2]) || 0); + } + for (const [code, mois] of Object.entries(monthlyByCode)) { + for (const label of moisFenetre) { + mois[label] ??= { qte: 0, ca: 0, nbMag: 0, caMag: 0, margePct: 0 }; + } + // Ne conserve aucune clé hors de la fenêtre glissante. + for (const label of Object.keys(mois)) { + if (!moisFenetre.has(label)) delete mois[label]; + } + const somme12 = Array.from(moisFenetre).reduce((s, label) => s + (Number(mois[label]?.qte) || 0), 0); + const totalArticle = totauxParCode[code] ?? 0; + if (Math.abs(somme12 - totalArticle) > Math.max(0.001, Math.abs(totalArticle) * 1e-9)) { + console.error( + "[qlik-pw][expr] INTÉGRITÉ ARTICLE REFUSÉE code=" + code + + " somme12=" + somme12 + " total=" + totalArticle, + ); + out.length = marqueOut; + for (const k of Object.keys(monthlyByCode)) delete monthlyByCode[k]; + return false; + } + } if (calibrees > 20 && calibrOk / calibrees < 0.5) { console.warn( "[qlik-pw][expr] ⚠ calibrage faible (" + calibrOk + "/" + calibrees + ") : " + exprQte + @@ -995,11 +1067,22 @@ export async function fetchNetworkMetricsPlaywright( } }; - if (!noDateMode && monthDim && useExpr && (await moisParExpressions())) { - // Chemin nominal : un seul cube, tous les mois de la fenêtre, des - // mesures qui respectent la sélection Date. Ni passe N-1 ni - // rattrapage nécessaires — c'est justement ce qu'ils compensaient. - await pousserCheckpoint("expressions"); + if (!noDateMode) { + // Une synchronisation datée n'est valide qu'avec les expressions + // de faits, la dimension Mois et les contrôles d'intégrité. + // Les master measures « N » sont annuelles et ne peuvent jamais + // constituer un repli fiable pour 12 mois glissants. + if (!monthDim || !useExpr) { + throw new Error( + "extraction 12 mois impossible : QLIK_MONTH_DIM et QLIK_USE_EXPR doivent être activés", + ); + } + if (!(await moisParExpressions())) { + throw new Error( + "extraction 12 mois refusée : champ Date/expression invalide ou somme mensuelle différente du total Qlik", + ); + } + await pousserCheckpoint("expressions vérifiées"); } else if (!noDateMode && monthDim) { // Repli : master measures « année en cours » + passe N-1 + rattrapage. console.log("[qlik-pw] extraction via dimension Mois (" + monthlyPayload.length + " mois attendus, moisDim=" + moisDimId + ")"); @@ -1190,6 +1273,12 @@ export async function fetchNetworkMetricsPlaywright( result = (await evaluer()) as InPageResult; } catch (e) { const msg = String((e as Error)?.message || e); + // Une fenêtre 12 mois n'est publiable que si l'extraction est + // complète et a passé tous les contrôles. Conserver un checkpoint + // partiel fabriquerait précisément des graphiques à 2 ou 3 mois. + if (dateFilter) { + throw new Error(`[qlik-pw] extraction 12 mois interrompue, cache inchangé : ${msg}`); + } const secours = checkpoint as { rows?: Array>; monthly?: InPageResult["monthly"] } | null; if (secours?.rows?.length) { console.error(`[qlik-pw] extraction interrompue (${msg.slice(0, 200)}) — reprise du dernier point de contrôle : ${secours.rows.length} lignes`); @@ -1200,6 +1289,9 @@ export async function fetchNetworkMetricsPlaywright( } if (!result.ok) { + if (dateFilter) { + throw new Error(`[qlik-pw] extraction 12 mois refusée, cache inchangé : ${result.error}`); + } // Même logique qu'un crash : un résultat partiel vaut mieux que rien. if (result.rows?.length) { console.error(`[qlik-pw] extraction en échec (${String(result.error).slice(0, 200)}) — ${result.rows.length} lignes déjà extraites conservées`); @@ -1209,6 +1301,9 @@ export async function fetchNetworkMetricsPlaywright( } } if (result.partiel) { + if (dateFilter) { + throw new Error("[qlik-pw] extraction 12 mois partielle refusée, cache inchangé"); + } console.warn("[qlik-pw] ⚠ résultat PARTIEL : certains codes n'ont pas été extraits, relancer la sync pour compléter"); } diff --git a/src/lib/qlik-search.ts b/src/lib/qlik-search.ts index a63a4de..0161cb5 100644 --- a/src/lib/qlik-search.ts +++ b/src/lib/qlik-search.ts @@ -37,7 +37,7 @@ import "server-only"; import { chromium, type Browser } from "playwright-core"; import { getQlikConfig, qlikNtlmSession, type QlikConfig } from "@/lib/qlik-client"; -import { buildGridNetworkQlikDateFilter, envMonthsBack, QLIK_MONTHS_BACK_DEFAULT } from "@/lib/qlik-date-range"; +import { buildGridNetworkQlikDateFilter, QLIK_MONTHS_BACK_DEFAULT } from "@/lib/qlik-date-range"; /** * Un article trouvé côté Qlik, **avec ses mesures réseau sur 12 mois glissants**. @@ -170,7 +170,7 @@ export async function searchQlikArticles( // Même fenêtre que la Grille : 12 mois complets glissants, mois courant exclu. const dateFilter = buildGridNetworkQlikDateFilter( new Date(), - envMonthsBack("QLIK_SYNC_MONTHS_BACK", QLIK_MONTHS_BACK_DEFAULT), + QLIK_MONTHS_BACK_DEFAULT, ); const sess = await qlikNtlmSession(cfg); const [cookieName, ...rest] = sess.cookie.split("="); From cd2e77e9093cd55c479bd91c6114349d9c5c583d Mon Sep 17 00:00:00 2001 From: Michael Date: Fri, 31 Jul 2026 12:09:57 +0200 Subject: [PATCH 17/34] fix Qlik month dimension selection --- docs/qlik-integration.md | 10 +++- src/lib/qlik-playwright.ts | 120 +++++++++++++++++++++++++++++++++++-- 2 files changed, 123 insertions(+), 7 deletions(-) diff --git a/docs/qlik-integration.md b/docs/qlik-integration.md index 3b8fe0b..f9c4664 100644 --- a/docs/qlik-integration.md +++ b/docs/qlik-integration.md @@ -108,6 +108,14 @@ Un seul cube `[Article Code, Mois]` couvre les 12 mois : ni passe N-1 ni rattrapage — c'est exactement ce qu'ils compensaient. Les totaux deviennent la **somme des 12 mois de la fenêtre**. +Sur l'app de production, les champs `Date`, `Date calendrier` et `Date_Key` +existent mais leur sélection ne réduit pas `Sum(quantite)` : ils restent à +100 % du total historique. L'extracteur essaie d'abord ces champs puis utilise +la dimension maître `Mois` comme chemin fiable. Il crée un list object, retrouve +les 12 valeurs `YYYY-MM`, les sélectionne par `qElemNumber`, puis exécute le +cube mensuel. Cette méthode ne dépend ni du champ sous-jacent ni de son format +dual et conserve le chemin rapide (une sélection articles + un cube paginé). + Garde-fous : « Quantité N » est incluse dans le cube pour **calibrage**, et le log compare les deux sur les mois de l'année en cours, seul périmètre où la master measure est juste : @@ -174,7 +182,7 @@ n'a aucun effet. Candidats essayés dans l'ordre : `QLIK_DATE_FIELD` (si défini Si aucun ne filtre, l'extraction échoue explicitement plutôt que de produire une fenêtre fausse. Chaque sélection d'un candidat rejeté est effacée avant l'essai suivant ; autrement un premier candidat à zéro contaminait tous les contrôles -suivants. +suivants. Avant cet échec, la dimension maître `Mois` est testée elle aussi. ## La passe de rattrapage a été supprimée diff --git a/src/lib/qlik-playwright.ts b/src/lib/qlik-playwright.ts index 3a394a0..2d6b433 100644 --- a/src/lib/qlik-playwright.ts +++ b/src/lib/qlik-playwright.ts @@ -631,6 +631,101 @@ export async function fetchNetworkMetricsPlaywright( return h; }; + /** + * Sélectionne les 12 mois via la dimension maître « Mois ». + * + * Dans l'app Magasins Vision Consolidée, les champs Date exposés + * existent mais sont dissociés des faits de vente : leur sélection + * laisse Sum(quantite) à 100 %. La dimension maître utilisée par le + * cube, elle, porte les vraies valeurs associées. Un list object + * permet de sélectionner ses qElemNumber sans connaître le nom du + * champ sous-jacent ni le format dual exact. + */ + const selectionnerFenetreViaDimensionMois = async ( + moisAttendus: Set, + ): Promise<{ total: number; trouves: string[] } | null> => { + let objet: { handle: number; id: string } | null = null; + try { + const cree = await rpcWithRetry("createMonthList", "CreateSessionObject", { + qProp: { + qInfo: { qType: "cf-month-list" }, + qListObjectDef: { + qLibraryId: moisDimId, + qShowAlternatives: true, + qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 1, qHeight: 100 }], + }, + }, + }, doc); + const ret = cree.qReturn as { qHandle: number; qGenericId?: string; qId?: string }; + objet = { + handle: ret.qHandle, + id: String(ret.qGenericId ?? ret.qId ?? ""), + }; + + const layout = await rpcWithRetry("layoutMonthList", "GetLayout", {}, objet.handle); + const lo = (layout.qLayout as { + qListObject?: { + qSize?: { qcy?: number }; + qDataPages?: Array<{ + qArea?: { qTop?: number }; + qMatrix?: Array>; + }>; + }; + }).qListObject; + const size = Number(lo?.qSize?.qcy) || 0; + const cellules: Array<{ qText?: string; qElemNumber?: number }> = []; + for (const page of lo?.qDataPages ?? []) { + for (const row of page.qMatrix ?? []) if (row[0]) cellules.push(row[0]); + } + for (let top = cellules.length; top < size; top += 500) { + const data = await rpcWithRetry("monthListData", "GetListObjectData", { + qPath: "/qListObjectDef", + qPages: [{ qTop: top, qLeft: 0, qWidth: 1, qHeight: Math.min(500, size - top) }], + }, objet.handle); + const matrix = (data.qDataPages as Array<{ + qMatrix?: Array>; + }>)?.[0]?.qMatrix ?? []; + for (const row of matrix) if (row[0]) cellules.push(row[0]); + if (matrix.length === 0) break; + } + + const parMois = new Map(); + for (const cellule of cellules) { + const mois = normMois(String(cellule.qText ?? "").trim()); + const elem = Number(cellule.qElemNumber); + if (moisAttendus.has(mois) && Number.isInteger(elem) && elem >= 0) { + parMois.set(mois, elem); + } + } + const trouves = [...parMois.keys()].sort(); + console.log( + "[qlik-pw][expr] dimension Mois : " + trouves.length + "/" + moisAttendus.size + + " valeurs trouvées " + JSON.stringify(trouves), + ); + if (trouves.length !== moisAttendus.size) return null; + + const selection = await rpcWithRetry("selectMonthList", "SelectListObjectValues", { + qPath: "/qListObjectDef", + qValues: [...parMois.values()], + qToggleMode: false, + qSoftLock: true, + }, objet.handle); + console.log("[qlik-pw][expr] sélection dimension Mois → " + JSON.stringify(selection.qReturn)); + await sleep(qlikSettleMs); + const total = await totalDeControle(); + return { total, trouves }; + } catch (e) { + console.log("[qlik-pw][expr] sélection dimension Mois impossible : " + String((e as Error)?.message || e)); + return null; + } finally { + if (objet?.id) { + try { + await rpc("DestroySessionObject", { qId: objet.id }, doc); + } catch { /* objet limité à la session */ } + } + } + }; + const moisParExpressions = async (): Promise => { const timings: Record = {}; const marqueOut = out.length; @@ -642,11 +737,11 @@ export async function fetchNetworkMetricsPlaywright( if (codes.length && fh !== -1) { await timed("clearCode", "Clear", () => rpc("Clear", {}, fh), timings); await sleep(qlikSettleMs); - await timed("selectCodeAll", "SelectValues", () => rpc("SelectValues", { + await timed("selectCodeAll", "SelectValues", () => rpcWithRetry("selectCodeAll", "SelectValues", { qFieldValues: codes.map((c) => ({ qText: c })), qToggleMode: false, qSoftLock: true, - }, fh), timings); + }, fh, () => { aggCode15Retries++; }), timings); await sleep(qlikSettleMs); } @@ -665,6 +760,7 @@ export async function fetchNetworkMetricsPlaywright( const allSerials = monthlyPayload.flatMap((m) => m.serials); const allYmd = monthlyPayload.flatMap((m) => m.ymd); + const moisFenetre = new Set(monthlyPayload.map((m) => m.label)); let champDate: string | null = null; let totalFenetre = 0; for (const cand of champsDateCandidats) { @@ -690,11 +786,24 @@ export async function fetchNetworkMetricsPlaywright( await rpc("Clear", {}, handleCand); await sleep(qlikSettleMs); } + if (!champDate) { + const selectionMois = await selectionnerFenetreViaDimensionMois(moisFenetre); + const ratio = selectionMois && sansFiltre > 0 ? selectionMois.total / sansFiltre : 0; + if (selectionMois) { + console.log( + "[qlik-pw][expr] dimension « Mois » → " + Math.round(selectionMois.total) + + " (" + Math.round(ratio * 100) + "% du total sans filtre)", + ); + } + if (selectionMois && selectionMois.total > 0 && ratio < 0.995) { + champDate = "dimension Mois"; + totalFenetre = selectionMois.total; + } + } if (!champDate) { console.error( - "[qlik-pw][expr] aucun champ date ne filtre les faits (essayés : " + - JSON.stringify(champsDateCandidats) + ") → impossible de borner la fenêtre 12 mois. " + - "Forcer le bon champ avec QLIK_DATE_FIELD.", + "[qlik-pw][expr] ni les champs date ni la dimension Mois ne filtrent les faits " + + "(champs essayés : " + JSON.stringify(champsDateCandidats) + ") → impossible de borner la fenêtre 12 mois.", ); return false; } @@ -702,7 +811,6 @@ export async function fetchNetworkMetricsPlaywright( // Seuls les mois de la fenêtre nous intéressent ; la dimension // Mois peut en exposer d'autres si une sélection déborde. - const moisFenetre = new Set(monthlyPayload.map((m) => m.label)); let totalQte = 0; let calibrees = 0, calibrOk = 0; let horsFenetre = 0; From 726fb8418dcdfe9de941b325b2cb039849762841 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 11:22:56 +0000 Subject: [PATCH 18/34] =?UTF-8?q?fix(qlik):=20piloter=20le=20champ=20P?= =?UTF-8?q?=C3=A9riode=20du=20mod=C3=A8le=20au=20lieu=20de=20lutter=20cont?= =?UTF-8?q?re=20lui?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le dump des expressions donne enfin la cause de fond : « Quantité N » = Sum({} quantite) « Quantité COMP » = Sum({} quantite) « Magasin Ventes Nb N » = Count({} Distinct ventes_code_site) L'app n'est pas un modèle « faits + calendrier ». Un pont de périodes duplique les lignes de faits : Type_Cal='N' marque celles de la période analysée, 'COMP' celles de la période de comparaison, et le champ Période (4 valeurs, une sélectionnée) décide de quelle période il s'agit. Tout en découle : - sélectionner Date ne change rien (mesuré : 100 % du total avant comme après), le périmètre venant du pont de périodes et non du champ date ; - « Quantité N » ne couvre que la période courante, d'où août→décembre vides ; - « Quantité COMP » ne couvre que les mois ayant un comparable, d'où janvier→juillet de l'année précédente ; - Sum(quantite) brut additionne les lignes N ET COMP : il double compte, il ne pouvait pas servir de substitut. choisirPeriode() essaie donc chaque valeur de Période, mesure sur un cube [Mois] × Quantité N combien de mois de la fenêtre elle rend réellement disponibles, et garde la meilleure. Aucun libellé n'est deviné : c'est la couverture mesurée qui décide, et elle est tracée. Si aucune valeur ne couvre les 12 mois, les mois manquants restent absents du cache. Aussi : - Les mois réellement couverts reçoivent un zéro EXPLICITE quand l'article n'a pas vendu ; la tendance ne retient que les mois présents comme clés. Un article vendu depuis mai voyait sa tendance calculée sur deux points (« +4100 % »). - Les diagnostics du chemin d'extraction sont répétés en fin de sync ([qlik-pw][diag]) : ils étaient au début d'un log de plusieurs milliers de lignes. - QLIK_USE_EXPR passe à désactivé par défaut. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DfqUihgixw4K1AmJhizWiu --- docs/qlik-integration.md | 44 +++++ src/features/grid/lib/network-trend.ts | 29 ++-- src/lib/qlik-playwright.ts | 222 +++++++++++++++++++++---- 3 files changed, 249 insertions(+), 46 deletions(-) diff --git a/docs/qlik-integration.md b/docs/qlik-integration.md index d632981..bc12504 100644 --- a/docs/qlik-integration.md +++ b/docs/qlik-integration.md @@ -78,6 +78,50 @@ QLIK_FIELD_FOURNISSEUR= Sans champ libellé exploitable, la recherche fonctionne encore par code centrale. +## Le modèle est piloté par `Période` / `Type_Cal` — pas par `Date` + +**C'est l'explication de fond**, obtenue en dumpant les expressions réelles : + +``` +« Quantité N » = Sum({} quantite) +« CA N » = Sum({} $(vCA_vat)) +« Quantité COMP » = Sum({} quantite) +« Magasin Ventes Nb N » = Count({} Distinct ventes_code_site) +``` + +L'app n'est pas un modèle « faits + calendrier » classique. Un **pont de périodes** +duplique les lignes de faits : `Type_Cal='N'` marque celles de la période +analysée, `'COMP'` celles de la période de comparaison. Et c'est le champ +**`Période`** (4 valeurs, une sélectionnée) qui décide de quelle période il s'agit. + +Tout ce qu'on observait en découle : + +| Symptôme | Cause | +|---|---| +| Sélectionner `Date` ne change rien (100 % du total avant/après) | Le périmètre vient du pont de périodes, pas du champ date | +| Août→décembre vides sur tous les articles | « Quantité N » ne couvre que la période courante (janvier→juillet) | +| Janvier→juillet de l'année précédente renseignés | Ce sont les mois que « Quantité COMP » sait atteindre | +| `Sum(quantite)` brut inexploitable | Il additionne les lignes N **et** COMP : il double compte | + +La solution n'est donc pas de contourner le modèle mais de **le piloter** : +`choisirPeriode()` essaie chaque valeur de `Période`, mesure sur un cube +`[Mois] × Quantité N` combien de mois de la fenêtre elle rend réellement +disponibles, et garde la meilleure. Aucun libellé n'est deviné — c'est la +couverture mesurée qui décide : + +``` +[qlik-pw][diag] valeurs de « Période » : ["Année en cours","12 mois glissants","Mois","Semaine"] +[qlik-pw][diag] Période « Année en cours » → 7/12 mois de la fenêtre [...] +[qlik-pw][diag] Période « 12 mois glissants » → 12/12 mois de la fenêtre [...] +[qlik-pw][diag] Période retenue : « 12 mois glissants » (12/12 mois) +``` + +Si aucune valeur ne couvre les 12 mois, l'extraction le dit et les mois manquants +restent **absents** du cache — jamais remplis d'une valeur plausible. + +`QLIK_USE_EXPR=1` réactive l'ancien essai par agrégation directe des faits ; il +est désactivé par défaut puisque `Sum(quantite)` double compte. + ## ⚠️ Les master measures « N » ignorent la sélection Date **Constat de production, vérifié arithmétiquement.** Avec août 2025 seul diff --git a/src/features/grid/lib/network-trend.ts b/src/features/grid/lib/network-trend.ts index b3d8646..cede299 100644 --- a/src/features/grid/lib/network-trend.ts +++ b/src/features/grid/lib/network-trend.ts @@ -56,16 +56,18 @@ export const TREND_COLOR: Record = { * * La fenêtre est reconstruite à partir de la date du jour (et non des clés * présentes dans `qteByMonth`) : c'est le seul moyen de garantir une tendance - * réellement glissante. Deux conséquences voulues : - * - un mois en cours (partiel) ou un mois plus vieux que 12 mois présent dans - * le cache est **ignoré** — sinon la pente est faussée par un mois tronqué ; - * - un mois de la fenêtre **absent** du cache vaut 0 : côté Qlik, un produit - * non vendu sur un mois ne produit tout simplement pas de ligne. + * réellement glissante. Un mois en cours (partiel), ou plus vieux que 12 mois, + * présent dans le cache est donc **ignoré** — sinon la pente est faussée par un + * mois tronqué. * - * Seule exception au remplissage par zéro : les mois **antérieurs au premier - * mois réellement extrait**. Ils traduisent une extraction plus courte que 12 - * mois (`QLIK_SYNC_MONTHS_BACK`), pas une absence de ventes — les inventer à 0 - * simulerait une croissance qui n'existe pas. + * Un mois **absent** du cache n'est PAS supposé nul : l'extraction ne couvre pas + * toujours les 12 mois (les mesures Qlik « N » ne savent pas remonter au-delà de + * l'année en cours). La sync écrit un zéro **explicite** sur chaque mois qu'elle + * a réellement couvert sans vente ; la tendance ne retient donc que les mois + * présents comme clés, et ignore purement et simplement les autres. + * + * Sans cette distinction, un article vendu seulement depuis mai voyait sa + * tendance calculée sur deux points — « +4 100 % · Forte hausse ». * * 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. @@ -81,11 +83,10 @@ export function computeNetworkTrend( if (presents.length === 0) return empty; const fenetre = buildRolling12QlikMonths(now); - // Aucun mois extrait ne tombe dans la fenêtre glissante (cache périmé) : - // rien de fiable à tracer. - const premierMoisExtrait = presents[0]; - const labels = fenetre.filter((m) => m >= premierMoisExtrait); - if (labels.length === 0 || !labels.some((m) => qteByMonth[m] != null)) return empty; + // Seuls les mois RÉELLEMENT extraits comptent : ceux que la sync n'a pas + // couverts sont absents des clés et sont écartés, plutôt que comptés à zéro. + const labels = fenetre.filter((m) => qteByMonth[m] != null); + if (labels.length === 0) return empty; const values = labels.map((l) => Number(qteByMonth[l]) || 0); const n = values.length; diff --git a/src/lib/qlik-playwright.ts b/src/lib/qlik-playwright.ts index aaa4038..3555e61 100644 --- a/src/lib/qlik-playwright.ts +++ b/src/lib/qlik-playwright.ts @@ -14,7 +14,7 @@ import "server-only"; import { chromium, type Browser } from "playwright-core"; -import { getQlikConfig, qlikNtlmSession, type QlikConfig, type NetworkMetric } from "@/lib/qlik-client"; +import { getQlikConfig, qlikNtlmSession, type QlikConfig, type NetworkMetric, type QlikMonthMetrics } from "@/lib/qlik-client"; import { buildGridNetworkQlikDateFilter, getMonthRanges, type QlikDateFilter } from "@/lib/qlik-date-range"; let browserPromise: Promise | null = null; @@ -46,7 +46,6 @@ interface InPageResult { monthly?: Record>; size?: number; error?: string; - diag?: Record; // Compteurs agrégés pour le résumé timing final côté Node (cf. qlik-playwright). months?: number; batches?: number; @@ -54,6 +53,8 @@ interface InPageResult { fallbackCount?: number; /** true si le résultat vient d'un point de contrôle (extraction interrompue). */ partiel?: boolean; + /** Diagnostics clés du chemin par expressions, répétés en fin de sync. */ + diagExpr?: string[]; } function envNumber(name: string, defaultValue: number, min: number, max: number): number { @@ -124,7 +125,11 @@ export async function fetchNetworkMetricsPlaywright( // Agrégations directes des champs de faits. Les master measures « N » ignorent // la sélection Date (mesures « cumul année en cours ») : elles ne peuvent pas // produire une fenêtre 12 mois glissants. Surchargeables si le modèle change. - const qlikUseExpr = !["0", "false", "no", "off"].includes((process.env.QLIK_USE_EXPR ?? "").trim().toLowerCase()); + // Désactivé par défaut : `Sum(quantite)` ignore `Type_Cal` et additionne donc + // les lignes de la période courante ET celles de la période de comparaison — + // il double compte. Les master measures, elles, portent le bon filtre ; il + // suffit de caler `Période` (cf. `choisirPeriode`). Réactivable pour essai. + const qlikUseExpr = ["1", "true", "yes", "on"].includes((process.env.QLIK_USE_EXPR ?? "").trim().toLowerCase()); const qlikExprQte = (process.env.QLIK_EXPR_QTE ?? "Sum(quantite)").trim(); const qlikExprCa = (process.env.QLIK_EXPR_CA ?? "Sum(ca_ht)").trim(); const qlikExprNbMag = (process.env.QLIK_EXPR_NBMAG ?? "Count(DISTINCT [Magasin Code])").trim(); @@ -295,6 +300,11 @@ export async function fetchNetworkMetricsPlaywright( // 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> = {}; + // Diagnostics clés, RÉPÉTÉS en fin de sync : le log d'une sync fait + // des milliers de lignes de timings, et ce qui explique un échec se + // trouve tout au début — là où personne ne va le chercher. + const diagnostics: string[] = []; + const diag = (msg: string) => { diagnostics.push(msg); console.log("[qlik-pw][expr] " + msg); }; (async () => { try { @@ -623,6 +633,127 @@ export async function fetchNetworkMetricsPlaywright( return true; }; + /** + * Cale le champ `Période` du modèle sur la fenêtre 12 mois glissants. + * + * L'app n'est pas un modèle « faits + calendrier » classique : TOUTES + * ses mesures sont bornées par un type de calendrier — + * + * « Quantité N » = Sum({} quantite) + * « Quantité COMP » = Sum({} quantite) + * + * `Type_Cal` marque les lignes de la période analysée (`N`) et celles + * de la période de comparaison (`COMP`), et c'est le champ `Période` + * (4 valeurs, une sélectionnée) qui décide de quelle période il s'agit. + * + * Cela explique tout ce qu'on observait : + * - sélectionner `Date` ne change RIEN (le périmètre vient du pont de + * périodes, pas du champ date) — mesuré : 100 % du total avant comme + * après ; + * - « Quantité N » ne renvoie que les mois de la période courante + * (janvier→juillet avec « année en cours »), d'où août→décembre + * vides ; + * - « Quantité COMP » ne couvre que les mois ayant un comparable dans + * cette période, d'où janvier→juillet de l'année précédente ; + * - `Sum(quantite)` brut mélange les lignes N et COMP : il double + * compte, et ne peut pas se substituer aux mesures. + * + * La solution n'est donc pas de contourner le modèle mais de le + * piloter : on essaie chaque valeur de `Période`, on mesure combien de + * mois de la fenêtre elle rend réellement disponibles, et on garde la + * meilleure. Aucun libellé n'est deviné — c'est la couverture mesurée + * qui décide. + */ + const choisirPeriode = async (): Promise => { + const fenetre = new Set(monthlyPayload.map((m) => m.label)); + let lo: { qHandle: number; qGenericId?: string; qId?: string } | null = null; + try { + const obj = await rpc("CreateSessionObject", { qProp: { + qInfo: { qType: "cf-periodes" }, + qListObjectDef: { + qDef: { qFieldDefs: ["Période"] }, + qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 1, qHeight: 50 }], + }, + } }, doc); + lo = obj.qReturn as { qHandle: number; qGenericId?: string; qId?: string }; + const lay = await rpc("GetLayout", {}, lo.qHandle); + const matrix = ((lay.qLayout as { qListObject?: { qDataPages?: Array<{ qMatrix?: Array> }> } }) + .qListObject?.qDataPages?.[0]?.qMatrix) ?? []; + const valeurs = matrix + .map((r) => ({ texte: String(r[0]?.qText ?? ""), elem: r[0]?.qElemNumber ?? -1 })) + .filter((v) => v.texte && v.elem >= 0); + diag("valeurs de « Période » : " + JSON.stringify(valeurs.map((v) => v.texte))); + if (valeurs.length === 0) return null; + + let meilleure: { texte: string; elem: number; couverts: string[] } | null = null; + for (const v of valeurs) { + await rpc("SelectListObjectValues", { + qPath: "/qListObjectDef", + qValues: [v.elem], + qToggleMode: false, + qSoftLock: true, + }, lo.qHandle); + await sleep(qlikSettleMs); + + // Cube [Mois] × « Quantité N » : quels mois cette période rend-elle vivants ? + const sonde = await rpc("CreateSessionObject", { qProp: { + qInfo: { qType: "cf-periode-sonde" }, + qHyperCubeDef: { + qDimensions: [{ qLibraryId: moisDimId }], + qMeasures: [{ qLibraryId: rQte }], + qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 2, qHeight: 60 }], + }, + } }, doc); + const sRet = sonde.qReturn as { qHandle: number; qGenericId?: string; qId?: string }; + let couverts: string[] = []; + try { + const sl = await rpc("GetLayout", {}, sRet.qHandle); + const sm = ((sl.qLayout as { qHyperCube?: { qDataPages?: Array<{ qMatrix?: Array> }> } }) + .qHyperCube?.qDataPages?.[0]?.qMatrix) ?? []; + couverts = sm + .filter((r) => (Number(r[1]?.qNum) || 0) > 0) + .map((r) => normMois(String(r[0]?.qText ?? ""))) + .filter((m) => fenetre.has(m)); + } finally { + const sid = String(sRet.qGenericId ?? sRet.qId ?? ""); + if (sid) { try { await rpc("DestroySessionObject", { qId: sid }, doc); } catch { /* noop */ } } + } + diag(" Période « " + v.texte + " » → " + couverts.length + "/" + fenetre.size + " mois de la fenêtre " + JSON.stringify(couverts.sort())); + if (!meilleure || couverts.length > meilleure.couverts.length) { + meilleure = { texte: v.texte, elem: v.elem, couverts }; + } + if (couverts.length >= fenetre.size) break; // couverture complète, inutile de continuer + } + + if (!meilleure || meilleure.couverts.length === 0) { + diag("aucune valeur de « Période » ne couvre la fenêtre — extraction sur la période par défaut"); + return null; + } + await rpc("SelectListObjectValues", { + qPath: "/qListObjectDef", + qValues: [meilleure.elem], + qToggleMode: false, + qSoftLock: true, + }, lo.qHandle); + await sleep(qlikSettleMs); + diag("Période retenue : « " + meilleure.texte + " » (" + meilleure.couverts.length + "/" + fenetre.size + " mois)"); + if (meilleure.couverts.length < fenetre.size) { + diag( + "⚠ couverture incomplète : les mois manquants resteront ABSENTS du cache. " + + "Aucune valeur de « Période » ne porte 12 mois glissants dans cette app.", + ); + } + return meilleure.texte; + } catch (e) { + diag("choix de « Période » impossible : " + String((e as Error)?.message || e)); + return null; + } finally { + // Le list object est conservé jusqu'au bout : le détruire + // annulerait la sélection qu'il porte. + void lo; + } + }; + const moisParExpressions = async (): Promise => { const timings: Record = {}; const marqueOut = out.length; @@ -649,9 +780,9 @@ export async function fetchNetworkMetricsPlaywright( // ce soit. Un champ valide doit donner un total non nul ET // strictement inférieur au total sans filtre. const sansFiltre = await totalDeControle(); - console.log("[qlik-pw][expr] contrôle " + exprQte + " sans filtre date = " + Math.round(sansFiltre)); + diag("contrôle « " + exprQte + " » sans filtre date = " + Math.round(sansFiltre)); if (sansFiltre <= 0) { - console.error("[qlik-pw][expr] " + exprQte + " renvoie 0 même sans filtre → expression ou champ invalide (QLIK_EXPR_QTE)"); + diag("ÉCHEC : « " + exprQte + " » renvoie 0 même sans filtre → expression ou champ invalide (corriger QLIK_EXPR_QTE)"); return false; } @@ -661,26 +792,23 @@ export async function fetchNetworkMetricsPlaywright( for (const cand of champsDateCandidats) { const valeurs = /key/i.test(cand) ? allYmd : allSerials; if (!(await selectionnerFenetreSur(cand, valeurs))) { - console.log("[qlik-pw][expr] champ « " + cand + " » : inexistant"); + diag(" champ « " + cand + " » : inexistant"); continue; } const filtre = await totalDeControle(); const ratio = sansFiltre > 0 ? filtre / sansFiltre : 0; - console.log( - "[qlik-pw][expr] champ « " + cand + " » → " + Math.round(filtre) + - " (" + Math.round(ratio * 100) + "% du total sans filtre)", - ); + diag(" champ « " + cand + " » → " + Math.round(filtre) + " (" + Math.round(ratio * 100) + "% du total sans filtre)"); if (filtre > 0 && ratio < 0.995) { champDate = cand; break; } } if (!champDate) { - console.error( - "[qlik-pw][expr] aucun champ date ne filtre les faits (essayés : " + - JSON.stringify(champsDateCandidats) + ") → impossible de borner la fenêtre 12 mois. " + - "Forcer le bon champ avec QLIK_DATE_FIELD.", + diag( + "ÉCHEC : aucun champ date ne filtre les faits (essayés : " + + JSON.stringify(champsDateCandidats) + "). 0% = la sélection ne matche rien, " + + "100% = elle n'a aucun effet. Forcer le bon champ avec QLIK_DATE_FIELD.", ); return false; } - console.log("[qlik-pw][expr] champ date retenu : « " + champDate + " »"); + diag("champ date retenu : « " + champDate + " »"); // Seuls les mois de la fenêtre nous intéressent ; la dimension // Mois peut en exposer d'autres si une sélection déborde. @@ -698,7 +826,7 @@ export async function fetchNetworkMetricsPlaywright( // Échantillon AVANT filtrage : si le mois ne tombe pas dans // la fenêtre, c'est le format de la dimension Mois qui est // en cause, pas les mesures. - console.log("[qlik-pw][expr] échantillon brut: " + JSON.stringify({ + diag("échantillon brut: " + JSON.stringify({ code, moisBrut: mois, moisNormalise: mm, dansLaFenetre: moisFenetre.has(mm), ca, qte, nbMag, marge, "Quantité N (master)": qteMaster, })); @@ -723,15 +851,15 @@ export async function fetchNetworkMetricsPlaywright( aggMonths = monthlyPayload.length; aggBatches++; - console.log( - "[qlik-pw][expr] " + got.value + " lignes (" + horsFenetre + " hors fenêtre), quantité totale=" + Math.round(totalQte) + - ", calibrage année " + yearN + " : " + calibrOk + "/" + calibrees + " mois conformes à « Quantité N »" + - " timings=" + timingSummary(timings), + diag( + got.value + " lignes (" + horsFenetre + " hors fenêtre), quantité totale=" + Math.round(totalQte) + + ", calibrage année " + yearN + " : " + calibrOk + "/" + calibrees + " mois conformes à « Quantité N »", ); + console.log("[qlik-pw][expr] timings=" + timingSummary(timings)); if (totalQte <= 0) { - console.error( - "[qlik-pw][expr] quantité totale nulle sur la fenêtre alors que le contrôle global valait " + + diag( + "ÉCHEC : quantité totale nulle sur la fenêtre alors que le contrôle global valait " + Math.round(sansFiltre) + " — " + horsFenetre + " ligne(s) écartées comme hors fenêtre. " + "Si ce nombre est élevé, c'est le format de la dimension Mois qui ne correspond pas aux libellés attendus.", ); @@ -747,7 +875,7 @@ export async function fetchNetworkMetricsPlaywright( } return true; } catch (e) { - console.error("[qlik-pw][expr] échec : " + String((e as Error)?.message || e) + " → repli sur les master measures"); + diag("ÉCHEC : " + String((e as Error)?.message || e) + " → repli sur les master measures"); out.length = marqueOut; for (const k of Object.keys(monthlyByCode)) delete monthlyByCode[k]; return false; @@ -995,10 +1123,18 @@ export async function fetchNetworkMetricsPlaywright( } }; + if (!noDateMode && monthDim) { + // ÉTAPE 1, avant toute extraction : caler le champ `Période` du + // modèle sur la fenêtre. Toutes les mesures de l'app étant bornées + // par `Type_Cal` (lui-même piloté par `Période`), c'est cette + // sélection — et elle seule — qui détermine les mois accessibles. + await choisirPeriode(); + } + if (!noDateMode && monthDim && useExpr && (await moisParExpressions())) { - // Chemin nominal : un seul cube, tous les mois de la fenêtre, des - // mesures qui respectent la sélection Date. Ni passe N-1 ni - // rattrapage nécessaires — c'est justement ce qu'ils compensaient. + // Chemin alternatif : agrégation directe des faits. Conservé + // derrière QLIK_USE_EXPR mais désormais secondaire — `Sum(quantite)` + // mélange les lignes N et COMP du pont de périodes et double compte. await pousserCheckpoint("expressions"); } else if (!noDateMode && monthDim) { // Repli : master measures « année en cours » + passe N-1 + rattrapage. @@ -1147,12 +1283,12 @@ export async function fetchNetworkMetricsPlaywright( // mensuel est validé et en production depuis. ws.close(); - resolve({ ok: true, rows: out, monthly: monthlyByCode, size: out.length, months: aggMonths, batches: aggBatches, code15Retries: aggCode15Retries, fallbackCount: aggFallbackCount }); + resolve({ ok: true, rows: out, monthly: monthlyByCode, size: out.length, months: aggMonths, batches: aggBatches, code15Retries: aggCode15Retries, fallbackCount: aggFallbackCount, diagExpr: diagnostics }); } catch (e) { try { ws.close(); } catch { /* noop */ } // On remonte ce qui a été extrait : les codes déjà traités ont // toutes leurs données, seul le reliquat manque. - resolve({ ok: false, error: String((e as Error)?.message || e), rows: out, monthly: monthlyByCode, size: out.length }); + resolve({ ok: false, error: String((e as Error)?.message || e), rows: out, monthly: monthlyByCode, size: out.length, diagExpr: diagnostics }); } })(); }), @@ -1216,6 +1352,10 @@ export async function fetchNetworkMetricsPlaywright( // 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; + if (result.diagExpr?.length) { + console.log("[qlik-pw][diag] ─── diagnostic du chemin par expressions ───"); + for (const d of result.diagExpr) console.log("[qlik-pw][diag] " + d); + } 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}`, ); @@ -1262,15 +1402,33 @@ export async function fetchNetworkMetricsPlaywright( // colonne « Tendance / Réseau » de la Grille. Inchangé. // - `metricsByMonth` : toutes les mesures du mois, pour la fiche produit. const monthly = result.monthly ?? {}; + + // Mois RÉELLEMENT couverts par l'extraction = union des mois vus sur + // l'ensemble des codes. Distinguer « couvert, zéro vente » de « non + // extrait » est indispensable : sans cela, un article vendu seulement + // depuis mai voyait sa tendance calculée sur deux points (« +4 100 % »), + // et un mois non extrait passait pour une absence de vente. + const couverts = new Set(); + for (const parMois of Object.values(monthly)) { + for (const mois of Object.keys(parMois)) couverts.add(mois); + } + const moisCouverts = [...couverts].sort(); + console.log(`[qlik-pw] mois couverts par l'extraction (${moisCouverts.length}) : ${JSON.stringify(moisCouverts)}`); + for (const [code, metric] of out) { - const mm = monthly[code]; - if (!mm || Object.keys(mm).length === 0) continue; + const mm = monthly[code] ?? {}; const qteByMonth: Record = {}; - for (const [mois, v] of Object.entries(mm)) { + const metricsByMonth: Record = {}; + for (const mois of moisCouverts) { + const v = mm[mois]; + // Zéro EXPLICITE sur un mois couvert sans vente : c'est une + // information, au même titre qu'une vente. Un mois non couvert + // reste absent et sera ignoré par la tendance. qteByMonth[mois] = Number(v?.qte) || 0; + metricsByMonth[mois] = v ?? { qte: 0 }; } metric.qteByMonth = qteByMonth; - metric.metricsByMonth = mm; + metric.metricsByMonth = metricsByMonth; } // Diagnostic CONCLUSIF : combien de codes ont des mois qui VARIENT vs identiques. // Si presque tous varient → la mesure respecte le mois (feature OK). From ccd60b119f57f9374e47e1fe872193a14d0376f2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 15:03:56 +0000 Subject: [PATCH 19/34] =?UTF-8?q?fix(qlik):=20ne=20pas=20amputer=20les=20f?= =?UTF-8?q?aits=20avec=20une=20P=C3=A9riode=20partielle=20+=20valider=20ch?= =?UTF-8?q?aque=20expression?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deux constats de la dernière sync. 1. AUCUNE valeur de « Période » ne porte 12 mois glissants — c'est mesuré : « juin 2026 » 1/12, « Année à date » 6/12, « Mois à date » 0/12, « Semaine 2026/30 » 0/12 Ce sont 4 contextes relatifs à aujourd'hui, pas des types de période. Les master measures ne pourront donc jamais couvrir la fenêtre, et l'agrégation directe des faits devient obligatoire. D'où un bug du commit précédent : garder la meilleure Période (6/12) RESTREINT les faits à 2026-01→06 et ampute l'agrégation directe, qui sait pourtant lire tout l'historique. choisirPeriode() efface désormais la sélection quand la couverture est incomplète, et ne la garde que si elle couvre les 12 mois. 2. Le cube à 5 mesures calculait 26 s puis rendait 0 ligne, alors que Sum(quantite) seul donnait 35 424 324 : signature d'une mesure invalide qui invalide tout l'hypercube, sans qu'aucun message ne dise laquelle. validerExpression() teste maintenant chaque expression isolément sur un petit cube [Mois] avant de construire le gros. Une expression invalide est remplacée par la colonne neutre 0 — le cube reste exploitable et le log nomme la coupable. Seule la quantité est bloquante. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DfqUihgixw4K1AmJhizWiu --- docs/qlik-integration.md | 49 ++++++++++++----- src/lib/qlik-playwright.ts | 107 +++++++++++++++++++++++++++++++------ 2 files changed, 127 insertions(+), 29 deletions(-) diff --git a/docs/qlik-integration.md b/docs/qlik-integration.md index 5b9b779..71df648 100644 --- a/docs/qlik-integration.md +++ b/docs/qlik-integration.md @@ -103,24 +103,47 @@ Tout ce qu'on observait en découle : | Janvier→juillet de l'année précédente renseignés | Ce sont les mois que « Quantité COMP » sait atteindre | | `Sum(quantite)` brut inexploitable | Il additionne les lignes N **et** COMP : il double compte | -La solution n'est donc pas de contourner le modèle mais de **le piloter** : -`choisirPeriode()` essaie chaque valeur de `Période`, mesure sur un cube -`[Mois] × Quantité N` combien de mois de la fenêtre elle rend réellement -disponibles, et garde la meilleure. Aucun libellé n'est deviné — c'est la -couverture mesurée qui décide : +### Mesuré : aucune `Période` ne porte 12 mois glissants + +`choisirPeriode()` essaie chaque valeur et mesure la couverture réelle sur un +cube `[Mois] × Quantité N`. Résultat en production : ``` -[qlik-pw][diag] valeurs de « Période » : ["Année en cours","12 mois glissants","Mois","Semaine"] -[qlik-pw][diag] Période « Année en cours » → 7/12 mois de la fenêtre [...] -[qlik-pw][diag] Période « 12 mois glissants » → 12/12 mois de la fenêtre [...] -[qlik-pw][diag] Période retenue : « 12 mois glissants » (12/12 mois) +valeurs de « Période » : ["juin 2026","Année à date","Mois à date","Semaine 2026/30"] + « juin 2026 » → 1/12 ["2026-06"] + « Année à date » → 6/12 ["2026-01".."2026-06"] + « Mois à date » → 0/12 + « Semaine 2026/30 » → 0/12 ``` -Si aucune valeur ne couvre les 12 mois, l'extraction le dit et les mois manquants -restent **absents** du cache — jamais remplis d'une valeur plausible. +Ce ne sont pas des types de période mais **4 contextes relatifs à aujourd'hui**. +Aucun ne porte 12 mois glissants : les master measures ne pourront **jamais** +couvrir la fenêtre. Ce n'est plus une hypothèse, c'est une mesure. -`QLIK_USE_EXPR=1` réactive l'ancien essai par agrégation directe des faits ; il -est désactivé par défaut puisque `Sum(quantite)` double compte. +Conséquence directe : **l'agrégation directe des faits est obligatoire**, et une +`Période` partielle est pire que pas de sélection — « Année à date » restreint les +faits à 2026-01→06 et amputerait d'autant l'agrégation, qui sait lire tout +l'historique. `choisirPeriode()` **efface** donc la sélection quand la couverture +est incomplète, et ne la garde que si elle couvre les 12 mois. + +### Chaque expression est validée séparément + +Une seule mesure invalide suffit à faire renvoyer **zéro ligne** à tout un +hypercube, sans qu'aucun message ne dise laquelle : observé en production, le +cube à 5 mesures calculait 26 s puis rendait 0 ligne, alors que `Sum(quantite)` +seul donnait 35 424 324. + +`validerExpression()` teste donc chaque expression isolément sur un petit cube +`[Mois]` avant de construire le gros : + +``` +[qlik-pw][diag] validation des expressions, une par une : +[qlik-pw][diag] mesure « quantité » = Sum(quantite) → 12 mois, total=33945285 +[qlik-pw][diag] mesure « magasins » = Count(DISTINCT [Magasin Code]) → INVALIDE (cube vide) +``` + +Une expression invalide est remplacée par la colonne neutre `0` : le cube reste +exploitable et le log nomme la coupable. Seule la quantité est bloquante. ## ⚠️ Les master measures « N » ignorent la sélection Date diff --git a/src/lib/qlik-playwright.ts b/src/lib/qlik-playwright.ts index 904d9f0..3dbcffa 100644 --- a/src/lib/qlik-playwright.ts +++ b/src/lib/qlik-playwright.ts @@ -529,6 +529,10 @@ export async function fetchNetworkMetricsPlaywright( // les champs de faits, qui eux respectent les sélections. const PAGEX = 1200; // 7 colonnes × 1200 = 8400 cellules < 10000 const fetchMonthCubeExpr = async ( + eCa: string, + eQte: string, + eNbMag: string, + eMarge: string, onRow: (code: string, mois: string, ca: number, qte: number, nbMag: number, marge: number, qteMaster: number) => void, timings?: Record, onCode15Retry?: () => void, @@ -536,10 +540,10 @@ export async function fetchNetworkMetricsPlaywright( const { value: obj } = await timed("createCubeExpr", "CreateSessionObject", () => rpcWithRetry("createCubeExpr", "CreateSessionObject", { qProp: { qInfo: { qType: "cf-net-mois-expr" }, qHyperCubeDef: { qDimensions: [{ qLibraryId: dim }, { qLibraryId: moisDimId }], qMeasures: [ - { qDef: { qDef: exprCa } }, - { qDef: { qDef: exprQte } }, - { qDef: { qDef: exprNbMag } }, - { qDef: { qDef: exprMarge } }, + { qDef: { qDef: eCa } }, + { qDef: { qDef: eQte } }, + { qDef: { qDef: eNbMag } }, + { qDef: { qDef: eMarge } }, // Master « Quantité N », uniquement pour le calibrage : sur les // mois de l'année en cours les deux doivent coïncider. { qLibraryId: rQte }, @@ -829,10 +833,24 @@ export async function fetchNetworkMetricsPlaywright( if (couverts.length >= fenetre.size) break; // couverture complète, inutile de continuer } - if (!meilleure || meilleure.couverts.length === 0) { - diag("aucune valeur de « Période » ne couvre la fenêtre — extraction sur la période par défaut"); - return 0; + const couverture = meilleure ? meilleure.couverts.length : 0; + + // Une `Période` partielle est PIRE que pas de sélection : elle + // restreint les faits au contexte choisi (« Année à date » ne + // porte que 2026-01→06) et ampute d'autant l'agrégation + // directe, qui elle sait lire tout l'historique. On ne garde + // donc la sélection QUE si elle couvre toute la fenêtre. + if (!meilleure || couverture < fenetre.size) { + await rpc("ClearField", { qFieldName: "Période" }, doc) + .catch(() => rpc("Clear", {}, lo!.qHandle)); + await sleep(qlikSettleMs); + diag( + "aucune valeur de « Période » ne couvre les " + fenetre.size + " mois (meilleure = " + + couverture + ") → sélection Période EFFACÉE pour ne pas amputer les faits", + ); + return couverture; } + await rpc("SelectListObjectValues", { qPath: "/qListObjectDef", qValues: [meilleure.elem], @@ -840,14 +858,8 @@ export async function fetchNetworkMetricsPlaywright( qSoftLock: true, }, lo.qHandle); await sleep(qlikSettleMs); - diag("Période retenue : « " + meilleure.texte + " » (" + meilleure.couverts.length + "/" + fenetre.size + " mois)"); - if (meilleure.couverts.length < fenetre.size) { - diag( - "⚠ couverture incomplète : les mois manquants resteront ABSENTS du cache. " + - "Aucune valeur de « Période » ne porte 12 mois glissants dans cette app.", - ); - } - return meilleure.couverts.length; + diag("Période retenue : « " + meilleure.texte + " » (" + couverture + "/" + fenetre.size + " mois)"); + return couverture; } catch (e) { diag("choix de « Période » impossible : " + String((e as Error)?.message || e)); return 0; @@ -858,6 +870,56 @@ export async function fetchNetworkMetricsPlaywright( } }; + /** + * Teste UNE expression isolément sur un petit cube `[Mois]`. + * + * Une seule mesure invalide suffit à faire renvoyer **zéro ligne** à + * tout un hypercube — observé en production : le cube à 5 mesures + * calculait 26 s puis rendait 0 ligne, alors que `Sum(quantite)` seul + * donnait 35 424 324. Impossible de savoir laquelle sans les isoler. + * + * Renvoie l'expression si elle produit des lignes, sinon `"0"` — une + * colonne neutre qui préserve les indices sans casser le cube. + */ + const validerExpression = async (nom: string, expr: string): Promise => { + let ret: { qHandle: number; qGenericId?: string; qId?: string } | null = null; + try { + const o = await rpc("CreateSessionObject", { qProp: { + qInfo: { qType: "cf-test-expr" }, + qHyperCubeDef: { + qDimensions: [{ qLibraryId: moisDimId }], + qMeasures: [{ qDef: { qDef: expr } }], + qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 2, qHeight: 30 }], + }, + } }, doc); + ret = o.qReturn as { qHandle: number; qGenericId?: string; qId?: string }; + const lay = await rpc("GetLayout", {}, ret.qHandle); + const hc = (lay.qLayout as { qHyperCube?: { qSize?: { qcy?: number }; qError?: unknown; qDataPages?: Array<{ qMatrix?: Array> }> } }).qHyperCube; + const matrix = hc?.qDataPages?.[0]?.qMatrix ?? []; + const lignes = Number(hc?.qSize?.qcy) || 0; + let total = 0; + let texteErreur = ""; + for (const r of matrix) { + total += Number(r[1]?.qNum) || 0; + const t = String(r[1]?.qText ?? ""); + if (!texteErreur && /error|erreur|garbage|^-$/i.test(t)) texteErreur = t; + } + if (hc?.qError) texteErreur = texteErreur || JSON.stringify(hc.qError); + if (lignes === 0 || texteErreur) { + diag(" mesure « " + nom + " » = " + expr + " → INVALIDE (" + (texteErreur || "cube vide") + ")"); + return "0"; + } + diag(" mesure « " + nom + " » = " + expr + " → " + lignes + " mois, total=" + Math.round(total)); + return expr; + } catch (e) { + diag(" mesure « " + nom + " » = " + expr + " → ERREUR " + String((e as Error)?.message || e)); + return "0"; + } finally { + const id = String(ret?.qGenericId ?? ret?.qId ?? ""); + if (id) { try { await rpc("DestroySessionObject", { qId: id }, doc); } catch { /* noop */ } } + } + }; + const moisParExpressions = async (): Promise => { const timings: Record = {}; const marqueOut = out.length; @@ -937,6 +999,19 @@ export async function fetchNetworkMetricsPlaywright( } diag("champ date retenu : « " + champDate + " »"); + // Chaque expression est validée SÉPARÉMENT : une seule mesure + // invalide fait rendre zéro ligne à tout l'hypercube, sans + // qu'aucun message ne dise laquelle. + diag("validation des expressions, une par une :"); + const vQte = await validerExpression("quantité", exprQte); + if (vQte === "0") { + diag("ÉCHEC : l'expression quantité est inutilisable, rien à extraire"); + return false; + } + const vCa = await validerExpression("CA", exprCa); + const vNbMag = await validerExpression("magasins", exprNbMag); + const vMarge = await validerExpression("marge", exprMarge); + // Seuls les mois de la fenêtre nous intéressent ; la dimension // Mois peut en exposer d'autres si une sélection déborde. let totalQte = 0; @@ -944,7 +1019,7 @@ export async function fetchNetworkMetricsPlaywright( let horsFenetre = 0; let echantillon = false; - const got = await timed("cube", "expr", () => fetchMonthCubeExpr((code, mois, ca, qte, nbMag, marge, qteMaster) => { + const got = await timed("cube", "expr", () => fetchMonthCubeExpr(vCa, vQte, vNbMag, vMarge, (code, mois, ca, qte, nbMag, marge, qteMaster) => { if (!code || code === "-") return; const mm = normMois(mois); if (!echantillon) { From e7a387b7de9b640518535aaef3fd4f6191973557 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 15:22:19 +0000 Subject: [PATCH 20/34] =?UTF-8?q?fix(qlik):=20effacer=20P=C3=A9riode=20pui?= =?UTF-8?q?s=20extraire=20ann=C3=A9e=20par=20ann=C3=A9e=20=E2=80=94=20la?= =?UTF-8?q?=20m=C3=A9thode=20de=20l'app?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deux remarques de l'utilisateur ont débloqué le sujet : « sur ce tableau j'ai 2024, 2025, 2026 et je sors mois par mois », et « tu mets 2026 et tu as la comparaison N-1 ». Type_Cal='N' ne désigne donc pas « l'année civile en cours » mais L'ANNÉE SÉLECTIONNÉE. Il suffit d'enchaîner les années couvertes par la fenêtre (2025 puis 2026) pour reconstituer 12 mois glissants — chaque passe livrant les 5 mesures, et non la seule quantité comme « Quantité COMP ». Ce mécanisme existait déjà (monthDimPath(yearN - 1)) mais restait sans effet : [qlik-pw] (mois) passe N-1 terminée : 299905 → 299905 points mensuels La sélection « Période », héritée de l'ouverture de l'app et jamais choisie par nous (observé « Période:1/4 » dès le premier diagnostic de la session), épinglait le contexte sur l'année en cours et annulait la sélection d'année. - choisirPeriode() teste désormais EN PREMIER l'état sans aucune sélection de Période — l'état dans lequel un utilisateur voit 2024/2025/2026 — et efface la sélection dès que la couverture est incomplète. - L'orchestration boucle sur les années de la fenêtre au lieu d'une passe principale plus une passe N-1 dérivée de COMP. - Chaque passe n'écrit que les mois DE LA FENÊTRE et les totaux s'additionnent sur ces mois : deux années ne peuvent pas se doubler puisqu'elles ne partagent aucun mois. « Quantité COMP » n'est plus utilisée. - L'agrégation directe des faits reste en secours, et le garde-fou d'intégrité reste actif : sans les 12 mois, le cache n'est pas écrit. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DfqUihgixw4K1AmJhizWiu --- docs/qlik-integration.md | 31 ++++++- src/lib/qlik-playwright.ts | 186 ++++++++++++++++++++++--------------- 2 files changed, 141 insertions(+), 76 deletions(-) diff --git a/docs/qlik-integration.md b/docs/qlik-integration.md index 71df648..2f7fc6a 100644 --- a/docs/qlik-integration.md +++ b/docs/qlik-integration.md @@ -103,7 +103,36 @@ Tout ce qu'on observait en découle : | Janvier→juillet de l'année précédente renseignés | Ce sont les mois que « Quantité COMP » sait atteindre | | `Sum(quantite)` brut inexploitable | Il additionne les lignes N **et** COMP : il double compte | -### Mesuré : aucune `Période` ne porte 12 mois glissants +### La bonne méthode : effacer `Période`, puis extraire ANNÉE par ANNÉE + +C'est ce que fait un utilisateur dans l'app : il sélectionne **2026** et obtient +l'année plus sa comparaison N-1 ; il sélectionne 2025 et obtient 2025. Les +données de 2024, 2025 et 2026 sont toutes accessibles mois par mois. `Type_Cal='N'` +désigne simplement **l'année sélectionnée**. + +Il suffit donc d'enchaîner les années couvertes par la fenêtre (2025 puis 2026) +pour reconstituer les 12 mois glissants — chaque passe donnant les 5 mesures, et +non la seule quantité comme « Quantité COMP ». + +Ce mécanisme existait déjà dans le code (« passe N-1 ») mais restait **sans +effet** : + +``` +[qlik-pw] (mois) passe N-1 terminée : 299905 → 299905 points mensuels + ^^^^^^^^^^^^^^ aucun gain +``` + +La sélection `Période` **héritée de l'ouverture de l'app** (observé +« Période:1/4 » alors que rien n'avait été sélectionné) épinglait le contexte sur +l'année en cours et annulait la sélection d'année. `choisirPeriode()` teste donc +en **premier** l'état sans aucune sélection de `Période`, et l'efface dès que la +couverture est incomplète. + +Chaque passe n'écrit que les mois **de la fenêtre**, et les totaux s'additionnent +sur ces mois : deux années ne peuvent pas se doubler puisqu'elles ne partagent +aucun mois. + +### Mesuré : aucune valeur de `Période` ne porte à elle seule 12 mois `choisirPeriode()` essaie chaque valeur et mesure la couverture réelle sur un cube `[Mois] × Quantité N`. Résultat en production : diff --git a/src/lib/qlik-playwright.ts b/src/lib/qlik-playwright.ts index 3dbcffa..1d42fa4 100644 --- a/src/lib/qlik-playwright.ts +++ b/src/lib/qlik-playwright.ts @@ -793,6 +793,48 @@ export async function fetchNetworkMetricsPlaywright( diag("valeurs de « Période » : " + JSON.stringify(valeurs.map((v) => v.texte))); if (valeurs.length === 0) return 0; + /** Couverture réelle : quels mois de la fenêtre « Quantité N » rend-elle vivants ? */ + const mesurerCouverture = async (): Promise => { + const sonde = await rpc("CreateSessionObject", { qProp: { + qInfo: { qType: "cf-periode-sonde" }, + qHyperCubeDef: { + qDimensions: [{ qLibraryId: moisDimId }], + qMeasures: [{ qLibraryId: rQte }], + qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 2, qHeight: 60 }], + }, + } }, doc); + const sRet = sonde.qReturn as { qHandle: number; qGenericId?: string; qId?: string }; + try { + const sl = await rpc("GetLayout", {}, sRet.qHandle); + const sm = ((sl.qLayout as { qHyperCube?: { qDataPages?: Array<{ qMatrix?: Array> }> } }) + .qHyperCube?.qDataPages?.[0]?.qMatrix) ?? []; + return sm + .filter((r) => (Number(r[1]?.qNum) || 0) > 0) + .map((r) => normMois(String(r[0]?.qText ?? ""))) + .filter((m) => fenetre.has(m)) + .sort(); + } finally { + const sid = String(sRet.qGenericId ?? sRet.qId ?? ""); + if (sid) { try { await rpc("DestroySessionObject", { qId: sid }, doc); } catch { /* noop */ } } + } + }; + + // CANDIDAT ZÉRO : aucune sélection de « Période ». + // + // L'app pose une valeur par défaut à l'ouverture (observé : + // « Période:1/4 » alors que nous n'avions rien sélectionné). + // C'est cette valeur héritée qui bornait les mesures — et c'est + // sans elle qu'un utilisateur voit 2024, 2025 et 2026 mois par + // mois dans l'app. On teste donc l'état libre EN PREMIER. + await rpc("ClearField", { qFieldName: "Période" }, doc).catch(() => rpc("Clear", {}, lo!.qHandle)); + await sleep(qlikSettleMs); + const sansSelection = await mesurerCouverture(); + diag(" SANS sélection de Période → " + sansSelection.length + "/" + fenetre.size + " mois " + JSON.stringify(sansSelection)); + if (sansSelection.length >= fenetre.size) { + diag("Période laissée LIBRE : les mesures couvrent déjà les " + fenetre.size + " mois"); + return sansSelection.length; + } + let meilleure: { texte: string; elem: number; couverts: string[] } | null = null; for (const v of valeurs) { await rpc("SelectListObjectValues", { @@ -803,30 +845,8 @@ export async function fetchNetworkMetricsPlaywright( }, lo.qHandle); await sleep(qlikSettleMs); - // Cube [Mois] × « Quantité N » : quels mois cette période rend-elle vivants ? - const sonde = await rpc("CreateSessionObject", { qProp: { - qInfo: { qType: "cf-periode-sonde" }, - qHyperCubeDef: { - qDimensions: [{ qLibraryId: moisDimId }], - qMeasures: [{ qLibraryId: rQte }], - qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 2, qHeight: 60 }], - }, - } }, doc); - const sRet = sonde.qReturn as { qHandle: number; qGenericId?: string; qId?: string }; - let couverts: string[] = []; - try { - const sl = await rpc("GetLayout", {}, sRet.qHandle); - const sm = ((sl.qLayout as { qHyperCube?: { qDataPages?: Array<{ qMatrix?: Array> }> } }) - .qHyperCube?.qDataPages?.[0]?.qMatrix) ?? []; - couverts = sm - .filter((r) => (Number(r[1]?.qNum) || 0) > 0) - .map((r) => normMois(String(r[0]?.qText ?? ""))) - .filter((m) => fenetre.has(m)); - } finally { - const sid = String(sRet.qGenericId ?? sRet.qId ?? ""); - if (sid) { try { await rpc("DestroySessionObject", { qId: sid }, doc); } catch { /* noop */ } } - } - diag(" Période « " + v.texte + " » → " + couverts.length + "/" + fenetre.size + " mois de la fenêtre " + JSON.stringify(couverts.sort())); + const couverts = await mesurerCouverture(); + diag(" Période « " + v.texte + " » → " + couverts.length + "/" + fenetre.size + " mois de la fenêtre " + JSON.stringify(couverts)); if (!meilleure || couverts.length > meilleure.couverts.length) { meilleure = { texte: v.texte, elem: v.elem, couverts }; } @@ -1144,16 +1164,24 @@ export async function fetchNetworkMetricsPlaywright( // 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 => { - const isMainPass = selectYear === null; + const monthDimPath = async (selectYear: number): Promise => { + const moisFenetre = new Set(monthlyPayload.map((m) => m.label)); - /** Traitement d'une ligne du cube [Article Code, Mois] — partagé par les deux chemins. */ + /** + * Traitement d'une ligne du cube [Article Code, Mois]. + * + * Une passe = UNE année explicitement sélectionnée, exactement ce + * que fait un utilisateur dans l'app (« je sélectionne 2026 et + * j'obtiens N + la comparaison N-1 »). Chaque passe n'écrit que + * les mois DE LA FENÊTRE, et les totaux s'additionnent sur ces + * mois-là : deux années différentes ne peuvent donc pas se + * doubler, puisqu'elles ne partagent aucun mois. + */ const onRowMois = (code: string, mois: string, ca: number, qte: number, nbMag: number, caMag: number, marge: number, qteComp: number) => { 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); + if (!moisFenetre.has(mm)) return; + out.push([code, ca, qte, nbMag, caMag, marge]); const y = parseInt(mm.slice(0, 4), 10); if (!loggedSampleRow) { loggedSampleRow = true; @@ -1166,38 +1194,19 @@ export async function fetchNetworkMetricsPlaywright( // 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. - // On n'écrit rien si COMP est vide : un mois ABSENT peut être - // rattrapé plus tard, un mois à 0 se lit comme « pas de vente » - // et masque définitivement le trou d'extraction. - if (monthlyByCode[code][mmPrev] === undefined && qteComp > 0) { - monthlyByCode[code][mmPrev] = { qte: qteComp }; - } - } else if (!isMainPass) { - // Passe année sélectionnée : valeurs directes, prioritaires sur COMP. - monthlyByCode[code][mm] = full; - } else if (qte > 0 || ca > 0) { - // Passe principale, mois HORS année N : « Quantité N » n'est pas - // censée le couvrir. Écrire son 0 fabriquerait une absence de - // vente là où il n'y a qu'une mesure hors périmètre. - monthlyByCode[code][mm] = full; - } + // « Quantité COMP » n'est plus nécessaire : chaque année de la + // fenêtre est extraite directement par sa propre passe, ce qui + // donne les 5 mesures et non la seule quantité. + void y; void qteComp; + monthlyByCode[code][mm] = full; }; - 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); - } - } + if (yfh === -1) throw new Error("champ « Année » introuvable : impossible d'extraire année par année"); + await rpc("Clear", {}, yfh); + await sleep(qlikSettleMs); + 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); @@ -1405,22 +1414,49 @@ export async function fetchNetworkMetricsPlaywright( return attendus.every((m) => vus.has(m)); }; - if (couvertureMaster >= attendus.length && monthDim) { - // Une valeur de `Période` couvre les 12 mois : les master - // measures sont alors exactes sur toute la fenêtre. - diag("extraction par master measures (Période couvrant " + couvertureMaster + " mois)"); - await monthDimPath(null); - await pousserCheckpoint("master measures"); - } else if (monthDim && useExpr && (await moisParExpressions())) { - // Aucune période ne couvre 12 mois : on retombe sur - // l'agrégation directe des faits. + // ÉTAPE 2 — extraire ANNÉE PAR ANNÉE, comme dans l'app. + // + // Dans l'interface, on sélectionne « 2026 » et on obtient l'année + // plus sa comparaison N-1. C'est le mécanisme natif du modèle : + // `Type_Cal='N'` désigne l'année sélectionnée. Il suffit donc + // d'enchaîner les années couvertes par la fenêtre — ici 2025 puis + // 2026 — pour reconstituer les 12 mois glissants. + // + // Ce mécanisme existait déjà (« passe N-1 ») mais restait sans + // effet : la sélection `Période` héritée de l'ouverture de l'app + // épinglait le contexte sur l'année en cours et annulait la + // sélection d'année. `choisirPeriode()` l'a effacée juste avant. + const annees = [...new Set(attendus.map((m) => Number(m.slice(0, 4))))].sort(); + diag("extraction année par année : " + JSON.stringify(annees) + " (Période libre)"); + + let extraitParAnnee = false; + if (monthDim) { + try { + for (const annee of annees) { + const avant = Object.values(monthlyByCode).reduce((n, m) => n + Object.keys(m).length, 0); + await monthDimPath(annee); + const apres = Object.values(monthlyByCode).reduce((n, m) => n + Object.keys(m).length, 0); + diag(" année " + annee + " → " + (apres - avant) + " points mensuels ajoutés"); + await pousserCheckpoint("année " + annee); + } + extraitParAnnee = couvre12(); + if (!extraitParAnnee) { + diag("l'extraction année par année ne couvre pas les 12 mois — essai de l'agrégation directe"); + } + } catch (e) { + diag("extraction année par année interrompue : " + String((e as Error)?.message || e)); + } + } + + if (!extraitParAnnee) { + if (!(monthDim && useExpr && (await moisParExpressions()))) { + throw new Error( + "extraction 12 mois refusée : ni l'extraction année par année " + + "(Période libre, couverture master = " + couvertureMaster + "/" + attendus.length + ") " + + "ni l'agrégation directe des faits n'ont couvert la fenêtre", + ); + } await pousserCheckpoint("expressions vérifiées"); - } else { - throw new Error( - "extraction 12 mois refusée : aucune valeur de « Période » ne couvre la fenêtre " + - "(meilleure = " + couvertureMaster + "/" + attendus.length + " mois) et l'agrégation " + - "directe des faits n'a pas validé ses contrôles", - ); } if (!couvre12()) { From fa1b64cfa6b15043486b30930b5a3056bd6cfa70 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 15:44:22 +0000 Subject: [PATCH 21/34] Qlik: selection par code fournisseur au lieu de 41 569 codes articles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le code fournisseur FF de la base SQL/API existe aussi dans Qlik : une seule valeur y designe le meme perimetre que les dizaines de milliers de codes articles. Comme SelectValues sur « Article Code » coute 8 a 100 s par appel et doit etre refait a chaque passe annuelle, la bascule supprime la partie la plus couteuse de la sync. - ETAPE 0 : selectionnerParFournisseur() avant choisirPeriode(). - Essaie QLIK_FIELD_CODE_FOURNISSEUR puis code_fournisseur, Fournisseur, fournisseur_code_centrale. - Verifie la couverture reelle (Article Code visibles vs codes demandes) et n'accepte la bascule qu'au-dela de QLIK_SUPPLIER_MIN_COVERAGE (0.99 par defaut) ; sinon annule la selection et revient aux codes. - Quand la bascule est retenue, plus aucun SelectValues sur Article Code (passes annuelles et agregation par expressions). - La sync fournisseur transmet job.fournisseur ; la sync produit (un seul code centrale) reste inchangee. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DfqUihgixw4K1AmJhizWiu --- docs/qlik-integration.md | 35 +++++++ src/app/api/qlik/sync/route.ts | 6 +- src/lib/qlik-playwright.ts | 181 ++++++++++++++++++++++++++++++--- 3 files changed, 208 insertions(+), 14 deletions(-) diff --git a/docs/qlik-integration.md b/docs/qlik-integration.md index 2f7fc6a..f05a8a4 100644 --- a/docs/qlik-integration.md +++ b/docs/qlik-integration.md @@ -330,6 +330,38 @@ sélection de codes, puis seule la fenêtre de dates change d'un mois à l'autre La sonde de diagnostic de fin de sync a été retirée : elle refaisait une sélection complète des codes et des 365 jours pour rien. +### Sélection PAR FOURNISSEUR (ÉTAPE 0) + +Le code fournisseur FF présent en base SQL / API (ex. `J009`) existe aussi dans +Qlik. Une valeur y désigne le même périmètre que les dizaines de milliers de codes +articles — pour un fournisseur de 41 569 références, c'est **un** `SelectValues` +trivial au lieu d'un appel de 8 à 100 s **répété à chaque passe annuelle**. + +`selectionnerParFournisseur()` (in-page, avant `choisirPeriode()`) : + +1. essaie chaque champ candidat — `QLIK_FIELD_CODE_FOURNISSEUR`, puis + `code_fournisseur`, `Fournisseur`, `fournisseur_code_centrale` ; +2. `SelectValues` le code fournisseur (soft lock) ; une valeur inconnue passe au + champ suivant ; +3. **vérifie la couverture** : lit les Article Code visibles sous cette sélection + (cube paginé) et compare aux codes demandés ; +4. ne retient la bascule que si la couverture atteint `QLIK_SUPPLIER_MIN_COVERAGE` + (défaut **0.99**, borné à [0.5, 1]). Sinon la sélection est annulée et on + revient à la sélection par codes. + +Ce contrôle est indispensable : un article rattaché à plusieurs fournisseurs, ou +un référencement Qlik différent du référencement FF, doit faire **revenir aux +codes** plutôt que perdre silencieusement des articles. + +Quand la bascule est retenue (`parFournisseur = true`), plus **aucun** +`SelectValues` sur « Article Code » n'est émis — ni par les passes annuelles, ni +par l'agrégation directe par expressions. Le périmètre fournisseur survit aux +sélections `Année` et `Date`, qui portent sur d'autres champs. Le repli par lots +de codes reste disponible si l'Engine refuse un cube. + +En mode produit (sync d'un seul code centrale), aucun fournisseur n'est transmis : +la sélection reste par code. + ### Aucun résultat partiel dans le cache Le script in-page conserve des points de contrôle à des fins de diagnostic, mais @@ -396,6 +428,9 @@ QLIK_DIM_CODE_ARTICLE_ID=fcd239e5-288b-4830-a047-0e3d7665d971 QLIK_MEAS_CA_ID=43a76088-86fa-402e-a80e-0efd7701b3e1 QLIK_MEAS_QTE_ID=7b40caf1-be4b-4811-8d45-50acde33e715 QLIK_MEAS_NBMAG_ID=8b63fae5-db2f-4e4c-8618-f3e9d60b6b3b +# Sélection par fournisseur (facultatif — auto-détection sinon) : +QLIK_FIELD_CODE_FOURNISSEUR= # champ Qlik portant le code fournisseur FF +QLIK_SUPPLIER_MIN_COVERAGE=0.99 # couverture minimale exigée pour basculer ``` ## RESTE À FAIRE diff --git a/src/app/api/qlik/sync/route.ts b/src/app/api/qlik/sync/route.ts index 33e217e..5614041 100644 --- a/src/app/api/qlik/sync/route.ts +++ b/src/app/api/qlik/sync/route.ts @@ -241,7 +241,11 @@ async function runJob(job: QlikSyncJob): Promise { console.log( `[api/qlik/sync] job=${job.jobId} → extraction Qlik pour ${codes.length} codes — 12 mois complets ${dateFilter.label} (${dateFilter.dateDebut} → ${dateFilter.dateFin})…`, ); - const metrics = await fetchNetworkMetricsPlaywright(codes, undefined, dateFilter); + // Le code fournisseur est transmis en plus des codes : s'il désigne dans + // Qlik le même périmètre (couverture vérifiée côté extracteur), une seule + // sélection remplace les dizaines de milliers de `SelectValues` par code. + // Les codes restent le repli et le contrôle de couverture. + const metrics = await fetchNetworkMetricsPlaywright(codes, undefined, dateFilter, job.fournisseur); job.fetched = metrics.size; console.log(`[api/qlik/sync] job=${job.jobId} ← Qlik a renvoyé ${metrics.size} produits réseau`); const count = await upsertNetworkMetrics([...metrics.values()]); diff --git a/src/lib/qlik-playwright.ts b/src/lib/qlik-playwright.ts index 1d42fa4..1aa96f6 100644 --- a/src/lib/qlik-playwright.ts +++ b/src/lib/qlik-playwright.ts @@ -90,6 +90,19 @@ export async function fetchNetworkMetricsPlaywright( codeCentraux?: string[], cfg: QlikConfig = getQlikConfig(), dateFilter: QlikDateFilter | null = buildGridNetworkQlikDateFilter(), + /** + * Code fournisseur FF (ex. `J009`), tel qu'il figure dans la base SQL / l'API. + * + * Quand il est fourni, on sélectionne LE fournisseur côté Qlik au lieu des + * dizaines de milliers de codes articles : `SelectValues` sur « Article Code » + * coûte 8 à 100 s par appel quel que soit le nombre de valeurs (le moteur + * balaie un symbole de plus d'un million d'entrées), et il faut le refaire à + * chaque passe annuelle. + * + * La bascule n'est retenue que si la sélection fournisseur **couvre** les + * codes demandés — sinon on revient à la sélection par codes. + */ + fournisseur?: string, ): Promise> { 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"); @@ -139,6 +152,16 @@ export async function fetchNetworkMetricsPlaywright( // FILTRE réellement les faits (total non nul et strictement inférieur au total // sans filtre) : personne n'avait pu le vérifier tant que seules des mesures // insensibles à la sélection étaient utilisées. + // Champs Qlik susceptibles de porter le CODE fournisseur (et non son libellé) : + // c'est `code_fournisseur` qui remontait des valeurs de la forme « F005 », + // « A0491 » — même format que les codes de la base FF. + const qlikSupplierFields = [ + (process.env.QLIK_FIELD_CODE_FOURNISSEUR ?? "").trim(), + "code_fournisseur", "Fournisseur", "fournisseur_code_centrale", + ].filter((c, i, arr) => c && arr.indexOf(c) === i); + /** Part des codes demandés que la sélection fournisseur doit couvrir pour être retenue. */ + const qlikSupplierMinCoverage = Math.min(1, Math.max(0.5, Number(process.env.QLIK_SUPPLIER_MIN_COVERAGE ?? "0.99") || 0.99)); + const qlikDateFields = [ (process.env.QLIK_DATE_FIELD ?? "").trim(), "Date", "Date calendrier", "Date_Key", @@ -208,7 +231,7 @@ export async function fetchNetworkMetricsPlaywright( }); const evaluer = () => page.evaluate( - ({ app, dim, mca, mqte, mnb, mcamag, mmarge, codes, token, monthlyPayload, noDateMode, qlikSettleMs, qlikCode15MaxAttempts, qlikArticleBatchFallbacks, selectAllCodes, monthDim, moisDimId, mqteComp, yearN, useExpr, exprQte, exprCa, exprNbMag, exprMarge, champsDateCandidats }: { + ({ app, dim, mca, mqte, mnb, mcamag, mmarge, codes, token, monthlyPayload, noDateMode, qlikSettleMs, qlikCode15MaxAttempts, qlikArticleBatchFallbacks, selectAllCodes, monthDim, moisDimId, mqteComp, yearN, useExpr, exprQte, exprCa, exprNbMag, exprMarge, champsDateCandidats, fournisseurCode, champsFournisseurCandidats, couvertureFournisseurMin }: { 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[]; ymd: number[] }>; @@ -227,6 +250,9 @@ export async function fetchNetworkMetricsPlaywright( exprNbMag: string; exprMarge: string; champsDateCandidats: string[]; + fournisseurCode: string; + champsFournisseurCandidats: string[]; + couvertureFournisseurMin: number; }) => new Promise((resolve) => { const loc = (window as unknown as { location: Location }).location; @@ -772,6 +798,114 @@ export async function fetchNetworkMetricsPlaywright( * meilleure. Aucun libellé n'est deviné — c'est la couverture mesurée * qui décide. */ + /** + * Vrai quand le périmètre articles est posé par une sélection + * fournisseur : dans ce cas, plus aucun `SelectValues` sur + * « Article Code » n'est émis (ni par passe annuelle, ni par + * l'agrégation directe) — c'est tout l'intérêt de la bascule. + */ + let parFournisseur = false; + + /** + * Sélectionne LE fournisseur plutôt que ses dizaines de milliers de + * codes articles. + * + * `SelectValues` sur « Article Code » coûte 8 à 100 s **par appel**, + * quasi indépendamment du nombre de valeurs — le moteur balaie un + * symbole de plus d'un million d'entrées — et il faut le refaire à + * chaque passe annuelle. Une valeur de fournisseur produit le même + * périmètre en un appel trivial. + * + * La bascule n'est retenue QUE si la sélection couvre les codes + * demandés : un article rattaché à plusieurs fournisseurs, ou un + * référencement Qlik différent du référencement FF, doit faire + * revenir à la sélection par codes plutôt que perdre des articles. + */ + const selectionnerParFournisseur = async (): Promise => { + if (!fournisseurCode || codes.length === 0) return false; + const attendus = new Set(codes.map((c) => c.trim())); + + /** Codes articles visibles sous la sélection courante. */ + const lireCodesVisibles = async (): Promise> => { + const vus = new Set(); + const o = await rpc("CreateSessionObject", { qProp: { + qInfo: { qType: "cf-codes-fou" }, + qHyperCubeDef: { + qDimensions: [{ qLibraryId: dim, qNullSuppression: true }], + qMeasures: [], + qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 1, qHeight: 5000 }], + }, + } }, doc); + const ret = o.qReturn as { qHandle: number; qGenericId?: string; qId?: string }; + try { + const lay = await rpc("GetLayout", {}, ret.qHandle); + const hc = (lay.qLayout as { qHyperCube?: { qSize?: { qcy?: number }; qDataPages?: Array<{ qMatrix?: Array> }> } }).qHyperCube; + const total = Number(hc?.qSize?.qcy) || 0; + for (const r of hc?.qDataPages?.[0]?.qMatrix ?? []) { + const c = String(r[0]?.qText ?? "").trim(); + if (c && c !== "-") vus.add(c); + } + for (let top = vus.size ? 5000 : 0; top < total; top += 5000) { + const d = await rpc("GetHyperCubeData", { + qPath: "/qHyperCubeDef", + qPages: [{ qTop: top, qLeft: 0, qWidth: 1, qHeight: Math.min(5000, total - top) }], + }, ret.qHandle); + const matrix = (d.qDataPages as Array<{ qMatrix?: Array> }>)?.[0]?.qMatrix ?? []; + if (matrix.length === 0) break; + for (const r of matrix) { + const c = String(r[0]?.qText ?? "").trim(); + if (c && c !== "-") vus.add(c); + } + } + return vus; + } finally { + const id = String(ret.qGenericId ?? ret.qId ?? ""); + if (id) { try { await rpc("DestroySessionObject", { qId: id }, doc); } catch { /* noop */ } } + } + }; + + for (const champ of champsFournisseurCandidats) { + let h = -1; + try { + const gf = await rpc("GetField", { qFieldName: champ }, doc); + h = (gf.qReturn as { qHandle: number }).qHandle; + if (typeof h !== "number" || h < 0) continue; + } catch { continue; } + + await rpc("Clear", {}, h); + await sleep(qlikSettleMs); + const sel = await rpc("SelectValues", { + qFieldValues: [{ qText: fournisseurCode }], + qToggleMode: false, + qSoftLock: true, + }, h); + await sleep(qlikSettleMs); + if (sel.qReturn === false) { + diag(" champ « " + champ + " » : valeur « " + fournisseurCode + " » inconnue"); + await rpc("Clear", {}, h); + continue; + } + + const visibles = await lireCodesVisibles(); + let couverts = 0; + for (const c of attendus) if (visibles.has(c)) couverts++; + const couverture = attendus.size > 0 ? couverts / attendus.size : 0; + diag( + " champ « " + champ + " » = « " + fournisseurCode + " » → " + visibles.size + + " articles Qlik, couvre " + couverts + "/" + attendus.size + + " codes demandés (" + Math.round(couverture * 100) + "%)", + ); + if (couverture >= couvertureFournisseurMin) { + diag("sélection PAR FOURNISSEUR retenue sur « " + champ + " » — " + attendus.size + " SelectValues économisés par passe"); + return true; + } + await rpc("Clear", {}, h); + await sleep(qlikSettleMs); + } + diag("aucun champ fournisseur ne couvre les codes demandés → sélection par codes articles"); + return false; + }; + const choisirPeriode = async (): Promise => { const fenetre = new Set(monthlyPayload.map((m) => m.label)); let lo: { qHandle: number; qGenericId?: string; qId?: string } | null = null; @@ -947,8 +1081,10 @@ export async function fetchNetworkMetricsPlaywright( if (yfh !== -1) { await rpc("Clear", {}, yfh); await sleep(qlikSettleMs); } // Les codes d'abord : les totaux de contrôle portent alors sur - // le même périmètre que l'extraction. - if (codes.length && fh !== -1) { + // le même périmètre que l'extraction. Sauf si le fournisseur + // pose déjà ce périmètre — le re-sélectionner par codes ne + // changerait rien au résultat et coûterait une minute. + if (codes.length && fh !== -1 && !parFournisseur) { await timed("clearCode", "Clear", () => rpc("Clear", {}, fh), timings); await sleep(qlikSettleMs); await timed("selectCodeAll", "SelectValues", () => rpcWithRetry("selectCodeAll", "SelectValues", { @@ -1232,21 +1368,28 @@ export async function fetchNetworkMetricsPlaywright( // pages, elle, coûte ~50 ms. Repli sur les lots si l'Engine // refuse (cube trop gros, code 15, saturation mémoire). const marqueOut = out.length; - if (codes.length && fh !== -1) { + if ((codes.length || parFournisseur) && fh !== -1) { const timingsAll: Record = {}; try { - await timed("clearCodeAll", "Clear", () => rpc("Clear", {}, fh), timingsAll); - await sleep(qlikSettleMs); - await timed("selectCodeAll", "SelectValues", () => rpc("SelectValues", { - qFieldValues: codes.map((c) => ({ qText: c })), - qToggleMode: false, - qSoftLock: true, - }, fh), timingsAll); - await sleep(qlikSettleMs); + // Le périmètre fournisseur est posé une fois pour toutes + // avant la première passe : il survit aux sélections + // Année/Date, qui portent sur d'autres champs. + if (!parFournisseur) { + await timed("clearCodeAll", "Clear", () => rpc("Clear", {}, fh), timingsAll); + await sleep(qlikSettleMs); + await timed("selectCodeAll", "SelectValues", () => rpc("SelectValues", { + qFieldValues: codes.map((c) => ({ qText: c })), + qToggleMode: false, + qSoftLock: true, + }, fh), timingsAll); + await sleep(qlikSettleMs); + } const got = await timed("cube", "all", () => fetchMonthCube(onRowMois, timingsAll), timingsAll); aggBatches++; console.log( - "[qlik-pw][timing] (mois) chemin rapide — " + codes.length + " codes en 1 sélection, rows=" + got.value + + "[qlik-pw][timing] (mois) chemin rapide — " + + (parFournisseur ? "périmètre fournisseur (0 sélection code)" : codes.length + " codes en 1 sélection") + + ", rows=" + got.value + " cumul=" + out.length + " timings=" + timingSummary(timingsAll), ); return; @@ -1388,6 +1531,15 @@ export async function fetchNetworkMetricsPlaywright( }; if (!noDateMode) { + // ÉTAPE 0 — poser le périmètre articles, une fois pour toutes. + // + // Le code fournisseur FF (base SQL / API) existe aussi dans Qlik : + // une seule valeur y désigne le même périmètre que les dizaines de + // milliers de codes articles qu'on envoyait jusqu'ici. La bascule + // n'est retenue que si la couverture mesurée le justifie, sinon on + // retombe sur la sélection par codes (cf. selectionnerParFournisseur). + parFournisseur = await selectionnerParFournisseur(); + // ÉTAPE 1 — caler le champ `Période` du modèle sur la fenêtre. // // Toutes les mesures de l'app sont bornées par `Type_Cal` @@ -1624,6 +1776,9 @@ export async function fetchNetworkMetricsPlaywright( exprNbMag: qlikExprNbMag, exprMarge: qlikExprMarge, champsDateCandidats: qlikDateFields, + fournisseurCode: (fournisseur ?? "").trim(), + champsFournisseurCandidats: qlikSupplierFields, + couvertureFournisseurMin: qlikSupplierMinCoverage, }, ); From 5fee5bc058ce25638524fff3b0b4a34f3eea24ad Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 18:56:31 +0000 Subject: [PATCH 22/34] =?UTF-8?q?Qlik:=20ne=20plus=20effacer=20Periode=20?= =?UTF-8?q?=E2=80=94=20c'est=20elle=20qui=20donne=20son=20sens=20a=20Type?= =?UTF-8?q?=5FCal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le log de production tranche : sans selection de Periode, les master measures sont MUETTES. Les passes annuelles renvoyaient bien 270 056 lignes, mais toutes mesures nulles — d'ou « 0 point mensuel ajoute » puis le refus des 12 mois. choisirPeriode() jugeait chaque valeur de Periode SANS annee selectionnee. « Annee a date » y parait couvrir 6/12 mois, donc etait rejetee, donc Periode etait effacee — et tout mourait. Or avec Annee=2025 selectionnee, la meme valeur donne l'annee 2025 COMPLETE (l'app affiche « Du 02/01/2025 au 31/12/2025 »), et avec Annee=2026 elle s'arrete au mois courant. L'union des deux passes fait bien 12 mois. - La couverture d'une Periode est desormais mesuree comme l'UNION des passes annuelles, exactement comme monthDimPath les executera. - La meilleure valeur est CONSERVEE meme partielle ; seule une couverture nulle justifie d'effacer. - Annees traitees la plus recente d'abord (usage de l'app), arret des que la fenetre est complete. - Diagnostics : mois gagnes / encore manquants apres chaque passe, et liste des mois manquants dans les messages d'echec. - Une passe ne peut plus ecraser un mois servi par un zero. Deux defauts prouves par le meme log, cote agregation directe : - la master « Quantite N » morte vidait TOUT l'hypercube (4 expressions valides a 6 mois, cube complet a 0 ligne) : elle est desormais sondee et omise si morte ; - ventilee par Mois, Sum(quantite) totalise 179 574 616 contre 33 945 285 sans dimension (x5,3) : la dimension Mois passe par le pont de periodes et demultiplie les faits. Ce chemin est refuse plutot que d'ecrire des ventes reseau fausses. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DfqUihgixw4K1AmJhizWiu --- src/lib/qlik-playwright.ts | 262 +++++++++++++++++++++++++++++++------ 1 file changed, 220 insertions(+), 42 deletions(-) diff --git a/src/lib/qlik-playwright.ts b/src/lib/qlik-playwright.ts index 1aa96f6..19dad98 100644 --- a/src/lib/qlik-playwright.ts +++ b/src/lib/qlik-playwright.ts @@ -562,7 +562,17 @@ export async function fetchNetworkMetricsPlaywright( onRow: (code: string, mois: string, ca: number, qte: number, nbMag: number, marge: number, qteMaster: number) => void, timings?: Record, onCode15Retry?: () => void, + /** + * Inclure la master « Quantité N » (colonne de calibrage) ? + * + * ⚠️ Elle ne coûte rien quand elle vit, mais **annule tout le cube** + * quand elle est morte : mesuré en production, 4 expressions valides + * à 6 mois chacune et le cube complet à **0 ligne** après 25 s de + * calcul. L'appelant ne l'ajoute donc qu'après l'avoir sondée. + */ + avecMaster = true, ): Promise => { + const largeur = avecMaster ? 7 : 6; const { value: obj } = await timed("createCubeExpr", "CreateSessionObject", () => rpcWithRetry("createCubeExpr", "CreateSessionObject", { qProp: { qInfo: { qType: "cf-net-mois-expr" }, qHyperCubeDef: { qDimensions: [{ qLibraryId: dim }, { qLibraryId: moisDimId }], qMeasures: [ @@ -572,9 +582,9 @@ export async function fetchNetworkMetricsPlaywright( { qDef: { qDef: eMarge } }, // Master « Quantité N », uniquement pour le calibrage : sur les // mois de l'année en cours les deux doivent coïncider. - { qLibraryId: rQte }, + ...(avecMaster ? [{ qLibraryId: rQte }] : []), ], - qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 7, qHeight: PAGEX }], + qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: largeur, qHeight: PAGEX }], } } }, 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 ?? "") }; @@ -584,7 +594,8 @@ export async function fetchNetworkMetricsPlaywright( 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[4]?.qNum) || 0, Number(r[5]?.qNum) || 0, + avecMaster ? Number(r[6]?.qNum) || 0 : 0, ); } return matrix.length; @@ -597,7 +608,7 @@ export async function fetchNetworkMetricsPlaywright( const firstPage = hc.qDataPages?.find((pg) => (pg.qArea?.qTop ?? 0) === 0); if (firstPage?.qMatrix?.length) { got += process(firstPage.qMatrix); top = PAGEX; } for (; top < size; top += PAGEX) { - const { value: d } = await timed("getCubeData", "GetHyperCubeData", () => rpcWithRetry("getCubeData", "GetHyperCubeData", { qPath: "/qHyperCubeDef", qPages: [{ qTop: top, qLeft: 0, qWidth: 7, qHeight: PAGEX }] }, oh, onCode15Retry), timings); + const { value: d } = await timed("getCubeData", "GetHyperCubeData", () => rpcWithRetry("getCubeData", "GetHyperCubeData", { qPath: "/qHyperCubeDef", qPages: [{ qTop: top, qLeft: 0, qWidth: largeur, qHeight: PAGEX }] }, oh, onCode15Retry), timings); const matrix = (d.qDataPages as Array<{ qMatrix?: Array> }>)?.[0]?.qMatrix ?? []; if (!matrix.length) break; got += process(matrix); @@ -906,7 +917,28 @@ export async function fetchNetworkMetricsPlaywright( return false; }; - const choisirPeriode = async (): Promise => { + /** + * Choisit la valeur de `Période` sous laquelle l'extraction aura lieu. + * + * ⚠️ Correction majeure. La version précédente mesurait la couverture + * d'une `Période` **sans sélection d'année**, puis effaçait `Période` + * si aucune valeur ne couvrait les 12 mois. Or : + * + * - sans `Période`, `Type_Cal` ne résout rien : « Quantité N » est + * morte partout (mesuré : couverture 0/12). Les passes annuelles + * renvoyaient bien 270 056 lignes, mais **toutes mesures nulles** — + * d'où « 0 point mensuel ajouté » et le refus final ; + * - « Année à date » ne vaut « janvier → aujourd'hui » que pour + * l'année EN COURS. Avec `Année=2025` sélectionnée, la même valeur + * donne l'année 2025 COMPLÈTE — c'est exactement ce que montre + * l'app : « Période d'analyse : Du 02/01/2025 au 31/12/2025 ». + * + * La couverture d'une `Période` ne veut donc rien dire hors du contexte + * d'année dans lequel elle sera utilisée. On la mesure désormais comme + * l'**union des passes annuelles** — 2025 puis 2026 — c'est-à-dire + * exactement ce que fera `monthDimPath`. + */ + const choisirPeriode = async (annees: number[]): Promise => { const fenetre = new Set(monthlyPayload.map((m) => m.label)); let lo: { qHandle: number; qGenericId?: string; qId?: string } | null = null; try { @@ -953,16 +985,43 @@ export async function fetchNetworkMetricsPlaywright( } }; + /** + * Couverture d'une `Période` telle qu'elle sera RÉELLEMENT + * exploitée : une passe par année, comme `monthDimPath`, puis + * union. C'est le seul chiffre qui a un sens ici — mesurer + * sans année revient à juger « Année à date » sur la seule + * année en cours (6/12) et à la rejeter à tort. + */ + const mesurerCouvertureUnion = async (): Promise => { + if (yfh === -1) return mesurerCouverture(); + const union = new Set(); + for (const annee of annees) { + await rpc("Clear", {}, yfh); + await sleep(qlikSettleMs); + await rpc("SelectValues", { + qFieldValues: [{ qNum: annee, qText: String(annee) }], + qToggleMode: false, + qSoftLock: true, + }, yfh); + await sleep(qlikSettleMs); + const mois = await mesurerCouverture(); + diag(" Année=" + annee + " → " + mois.length + " mois " + JSON.stringify(mois)); + for (const m of mois) union.add(m); + } + // L'année ne doit pas rester sélectionnée : chaque passe + // pose la sienne. + await rpc("Clear", {}, yfh); + await sleep(qlikSettleMs); + return [...union].sort(); + }; + // CANDIDAT ZÉRO : aucune sélection de « Période ». // - // L'app pose une valeur par défaut à l'ouverture (observé : - // « Période:1/4 » alors que nous n'avions rien sélectionné). - // C'est cette valeur héritée qui bornait les mesures — et c'est - // sans elle qu'un utilisateur voit 2024, 2025 et 2026 mois par - // mois dans l'app. On teste donc l'état libre EN PREMIER. + // Conservé pour le diagnostic, mais on sait maintenant qu'il + // renvoie 0/12 : sans `Période`, `Type_Cal` ne résout rien. await rpc("ClearField", { qFieldName: "Période" }, doc).catch(() => rpc("Clear", {}, lo!.qHandle)); await sleep(qlikSettleMs); - const sansSelection = await mesurerCouverture(); + const sansSelection = await mesurerCouvertureUnion(); diag(" SANS sélection de Période → " + sansSelection.length + "/" + fenetre.size + " mois " + JSON.stringify(sansSelection)); if (sansSelection.length >= fenetre.size) { diag("Période laissée LIBRE : les mesures couvrent déjà les " + fenetre.size + " mois"); @@ -979,8 +1038,8 @@ export async function fetchNetworkMetricsPlaywright( }, lo.qHandle); await sleep(qlikSettleMs); - const couverts = await mesurerCouverture(); - diag(" Période « " + v.texte + " » → " + couverts.length + "/" + fenetre.size + " mois de la fenêtre " + JSON.stringify(couverts)); + const couverts = await mesurerCouvertureUnion(); + diag(" Période « " + v.texte + " » → " + couverts.length + "/" + fenetre.size + " mois de la fenêtre (union des années) " + JSON.stringify(couverts)); if (!meilleure || couverts.length > meilleure.couverts.length) { meilleure = { texte: v.texte, elem: v.elem, couverts }; } @@ -989,20 +1048,23 @@ export async function fetchNetworkMetricsPlaywright( const couverture = meilleure ? meilleure.couverts.length : 0; - // Une `Période` partielle est PIRE que pas de sélection : elle - // restreint les faits au contexte choisi (« Année à date » ne - // porte que 2026-01→06) et ampute d'autant l'agrégation - // directe, qui elle sait lire tout l'historique. On ne garde - // donc la sélection QUE si elle couvre toute la fenêtre. - if (!meilleure || couverture < fenetre.size) { + // On garde la MEILLEURE valeur, même partielle. + // + // L'inverse a été essayé et s'est retourné contre nous : + // effacer `Période` ne « libère » pas les faits, elle rend les + // master measures muettes (0/12 mesuré). Une couverture + // partielle reste strictement meilleure que rien ; seul un + // zéro absolu justifie d'effacer et de tenter l'agrégation + // directe. + if (!meilleure || couverture === 0) { await rpc("ClearField", { qFieldName: "Période" }, doc) .catch(() => rpc("Clear", {}, lo!.qHandle)); await sleep(qlikSettleMs); diag( - "aucune valeur de « Période » ne couvre les " + fenetre.size + " mois (meilleure = " + - couverture + ") → sélection Période EFFACÉE pour ne pas amputer les faits", + "aucune valeur de « Période » ne rend le moindre mois vivant → sélection Période " + + "effacée, repli sur l'agrégation directe des faits", ); - return couverture; + return 0; } await rpc("SelectListObjectValues", { @@ -1012,7 +1074,10 @@ export async function fetchNetworkMetricsPlaywright( qSoftLock: true, }, lo.qHandle); await sleep(qlikSettleMs); - diag("Période retenue : « " + meilleure.texte + " » (" + couverture + "/" + fenetre.size + " mois)"); + diag( + "Période retenue : « " + meilleure.texte + " » (" + couverture + "/" + fenetre.size + + " mois en union des années" + (couverture < fenetre.size ? " — meilleure disponible" : "") + ")", + ); return couverture; } catch (e) { diag("choix de « Période » impossible : " + String((e as Error)?.message || e)); @@ -1074,6 +1139,70 @@ export async function fetchNetworkMetricsPlaywright( } }; + /** + * La master « Quantité N » produit-elle la moindre ligne sur `[Mois]` + * dans l'état de sélection courant ? Même sonde que + * `validerExpression`, mais par `qLibraryId`. + */ + const masterQteVivante = async (): Promise => { + let ret: { qHandle: number; qGenericId?: string; qId?: string } | null = null; + try { + const o = await rpc("CreateSessionObject", { qProp: { + qInfo: { qType: "cf-test-master" }, + qHyperCubeDef: { + qDimensions: [{ qLibraryId: moisDimId }], + qMeasures: [{ qLibraryId: rQte }], + qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 2, qHeight: 30 }], + }, + } }, doc); + ret = o.qReturn as { qHandle: number; qGenericId?: string; qId?: string }; + const lay = await rpc("GetLayout", {}, ret.qHandle); + const hc = (lay.qLayout as { qHyperCube?: { qDataPages?: Array<{ qMatrix?: Array> }> } }).qHyperCube; + let total = 0; + for (const r of hc?.qDataPages?.[0]?.qMatrix ?? []) total += Number(r[1]?.qNum) || 0; + diag(" master « Quantité N » (calibrage) → total=" + Math.round(total)); + return total > 0; + } catch (e) { + diag(" master « Quantité N » → ERREUR " + String((e as Error)?.message || e)); + return false; + } finally { + const id = String(ret?.qGenericId ?? ret?.qId ?? ""); + if (id) { try { await rpc("DestroySessionObject", { qId: id }, doc); } catch { /* noop */ } } + } + }; + + /** + * Somme de `expr` VENTILÉE par la dimension Mois. + * + * Comparée au total sans dimension, elle dit si la dimension Mois + * démultiplie les faits à travers le pont de périodes — auquel cas + * l'agrégation directe est inexploitable. + */ + const totalVentileParMois = async (expr: string): Promise => { + let ret: { qHandle: number; qGenericId?: string; qId?: string } | null = null; + try { + const o = await rpc("CreateSessionObject", { qProp: { + qInfo: { qType: "cf-ventil-mois" }, + qHyperCubeDef: { + qDimensions: [{ qLibraryId: moisDimId }], + qMeasures: [{ qDef: { qDef: expr } }], + qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 2, qHeight: 200 }], + }, + } }, doc); + ret = o.qReturn as { qHandle: number; qGenericId?: string; qId?: string }; + const lay = await rpc("GetLayout", {}, ret.qHandle); + const hc = (lay.qLayout as { qHyperCube?: { qDataPages?: Array<{ qMatrix?: Array> }> } }).qHyperCube; + let total = 0; + for (const r of hc?.qDataPages?.[0]?.qMatrix ?? []) total += Number(r[1]?.qNum) || 0; + return total; + } catch { + return 0; + } finally { + const id = String(ret?.qGenericId ?? ret?.qId ?? ""); + if (id) { try { await rpc("DestroySessionObject", { qId: id }, doc); } catch { /* noop */ } } + } + }; + const moisParExpressions = async (): Promise => { const timings: Record = {}; const marqueOut = out.length; @@ -1168,6 +1297,34 @@ export async function fetchNetworkMetricsPlaywright( const vNbMag = await validerExpression("magasins", exprNbMag); const vMarge = await validerExpression("marge", exprMarge); + // La master « Quantité N » n'est là que pour le calibrage, mais + // une master morte suffit à vider tout l'hypercube. On la sonde + // donc comme les autres, et on l'omet plutôt que de perdre + // l'extraction entière pour une colonne de contrôle. + const avecMaster = await masterQteVivante(); + if (!avecMaster) { + diag(" master « Quantité N » morte sous cette sélection → colonne de calibrage retirée du cube"); + } + + // ─── La dimension Mois démultiplie-t-elle les faits ? ──── + // + // Le pont de périodes rattache une même ligne de fait à + // plusieurs contextes. Ventilé par `Mois`, `Sum(quantite)` peut + // donc dépasser le total sans dimension — mesuré : 179 574 616 + // contre 33 945 285, soit ×5,3. Écrire cela dans le cache + // donnerait des ventes réseau cinq fois trop hautes : on refuse. + const totalVentile = await totalVentileParMois(vQte); + if (totalVentile > totalFenetre * 1.05) { + diag( + "ÉCHEC : ventilée par Mois, « " + vQte + " » totalise " + Math.round(totalVentile) + + " contre " + Math.round(totalFenetre) + " sans dimension (×" + + (totalFenetre > 0 ? (totalVentile / totalFenetre).toFixed(1) : "?") + + "). La dimension Mois passe par le pont de périodes et démultiplie les faits — " + + "l'agrégation directe donnerait des ventes réseau fausses, elle est refusée.", + ); + return false; + } + // Seuls les mois de la fenêtre nous intéressent ; la dimension // Mois peut en exposer d'autres si une sélection déborde. let totalQte = 0; @@ -1191,7 +1348,7 @@ export async function fetchNetworkMetricsPlaywright( if (!moisFenetre.has(mm)) { horsFenetre++; return; } // Calibrage sur l'année en cours, seul périmètre où la master // measure est censée être juste. - if (parseInt(mm.slice(0, 4), 10) === yearN && (qte > 0 || qteMaster > 0)) { + if (avecMaster && parseInt(mm.slice(0, 4), 10) === yearN && (qte > 0 || qteMaster > 0)) { calibrees++; if (Math.abs(qte - qteMaster) <= Math.max(1, qteMaster * 0.02)) calibrOk++; } @@ -1213,7 +1370,7 @@ export async function fetchNetworkMetricsPlaywright( caMag: nbMag > 0 ? ca / nbMag : 0, margePct: caCumule > 0 ? margeMontant / caCumule : 0, }; - }, timings), timings); + }, timings, undefined, avecMaster), timings); aggMonths = monthlyPayload.length; aggBatches++; @@ -1334,6 +1491,11 @@ export async function fetchNetworkMetricsPlaywright( // fenêtre est extraite directement par sa propre passe, ce qui // donne les 5 mesures et non la seule quantité. void y; void qteComp; + // Une passe ultérieure ne doit JAMAIS écraser un mois servi par + // un zéro : hors de son année, la passe voit la combinaison + // (article, mois) mais toutes ses mesures sont nulles. + const precedent = monthlyByCode[code][mm]; + if (precedent && (precedent.qte ?? 0) > 0 && qte <= 0) return; monthlyByCode[code][mm] = full; }; @@ -1540,6 +1702,12 @@ export async function fetchNetworkMetricsPlaywright( // retombe sur la sélection par codes (cf. selectionnerParFournisseur). parFournisseur = await selectionnerParFournisseur(); + const attendus = monthlyPayload.map((m) => m.label); + // Années de la fenêtre, la PLUS RÉCENTE D'ABORD — c'est l'usage de + // l'app (« je sélectionne 2026 »), et cela met les mois les plus + // utiles en tête des points de contrôle. + const annees = [...new Set(attendus.map((m) => Number(m.slice(0, 4))))].sort((a, b) => b - a); + // ÉTAPE 1 — caler le champ `Période` du modèle sur la fenêtre. // // Toutes les mesures de l'app sont bornées par `Type_Cal` @@ -1548,23 +1716,23 @@ export async function fetchNetworkMetricsPlaywright( // quels mois sont atteignables. Sous la période par défaut, // « Quantité N » ne couvre que l'année en cours : d'où les mois // d'août à décembre vides quelle que soit la sélection de dates. - const couvertureMaster = await choisirPeriode(); + const couvertureMaster = await choisirPeriode(annees); // ÉTAPE 2 — extraire, puis REFUSER tout résultat incomplet. // // Un cache partiel est pire que pas de cache : il se lit comme des // ventes nulles. Les deux chemins sont donc validés sur le même // critère — les 12 mois de la fenêtre, ou rien. - const attendus = monthlyPayload.map((m) => m.label); - const couvre12 = () => { + const moisManquants = () => { const vus = new Set(); for (const parMois of Object.values(monthlyByCode)) { for (const [mois, v] of Object.entries(parMois)) { if ((v?.qte ?? 0) > 0) vus.add(mois); } } - return attendus.every((m) => vus.has(m)); + return attendus.filter((m) => !vus.has(m)); }; + const couvre12 = () => moisManquants().length === 0; // ÉTAPE 2 — extraire ANNÉE PAR ANNÉE, comme dans l'app. // @@ -1574,26 +1742,34 @@ export async function fetchNetworkMetricsPlaywright( // d'enchaîner les années couvertes par la fenêtre — ici 2025 puis // 2026 — pour reconstituer les 12 mois glissants. // - // Ce mécanisme existait déjà (« passe N-1 ») mais restait sans - // effet : la sélection `Période` héritée de l'ouverture de l'app - // épinglait le contexte sur l'année en cours et annulait la - // sélection d'année. `choisirPeriode()` l'a effacée juste avant. - const annees = [...new Set(attendus.map((m) => Number(m.slice(0, 4))))].sort(); - diag("extraction année par année : " + JSON.stringify(annees) + " (Période libre)"); + // Chaque passe est faite SOUS la `Période` retenue : c'est elle qui + // donne son sens à `Type_Cal='N'`. Avec `Année=2025`, « Année à + // date » vaut l'année 2025 entière (elle est passée) ; avec + // `Année=2026`, elle s'arrête au mois courant. L'union des deux + // passes reconstitue donc bien les 12 mois glissants. + diag("extraction année par année : " + JSON.stringify(annees)); let extraitParAnnee = false; if (monthDim) { try { for (const annee of annees) { - const avant = Object.values(monthlyByCode).reduce((n, m) => n + Object.keys(m).length, 0); + const manquantsAvant = moisManquants(); await monthDimPath(annee); - const apres = Object.values(monthlyByCode).reduce((n, m) => n + Object.keys(m).length, 0); - diag(" année " + annee + " → " + (apres - avant) + " points mensuels ajoutés"); + const manquantsApres = moisManquants(); + const gagnes = manquantsAvant.filter((m) => !manquantsApres.includes(m)); + diag( + " année " + annee + " → mois de la fenêtre désormais servis : " + JSON.stringify(gagnes) + + " ; encore manquants : " + JSON.stringify(manquantsApres), + ); await pousserCheckpoint("année " + annee); + if (manquantsApres.length === 0) break; // fenêtre complète, inutile de continuer } extraitParAnnee = couvre12(); if (!extraitParAnnee) { - diag("l'extraction année par année ne couvre pas les 12 mois — essai de l'agrégation directe"); + diag( + "l'extraction année par année ne couvre pas les 12 mois (manquants : " + + JSON.stringify(moisManquants()) + ") — essai de l'agrégation directe", + ); } } catch (e) { diag("extraction année par année interrompue : " + String((e as Error)?.message || e)); @@ -1604,8 +1780,9 @@ export async function fetchNetworkMetricsPlaywright( if (!(monthDim && useExpr && (await moisParExpressions()))) { throw new Error( "extraction 12 mois refusée : ni l'extraction année par année " + - "(Période libre, couverture master = " + couvertureMaster + "/" + attendus.length + ") " + - "ni l'agrégation directe des faits n'ont couvert la fenêtre", + "(couverture master annoncée = " + couvertureMaster + "/" + attendus.length + ") " + + "ni l'agrégation directe des faits n'ont couvert la fenêtre — mois manquants : " + + JSON.stringify(moisManquants()), ); } await pousserCheckpoint("expressions vérifiées"); @@ -1614,7 +1791,8 @@ export async function fetchNetworkMetricsPlaywright( if (!couvre12()) { throw new Error( "extraction 12 mois refusée : la fenêtre " + JSON.stringify(attendus) + - " n'est pas entièrement couverte — cache laissé inchangé", + " n'est pas entièrement couverte (manquants : " + JSON.stringify(moisManquants()) + + ") — cache laissé inchangé", ); } } else if (noDateMode) { From b948aca2f85a4b9e649a55b338a28663759d7d25 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 20:35:11 +0000 Subject: [PATCH 23/34] Qlik: une Periode PAR ANNEE, et sonde de couverture paginee MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deux defauts, l'un cachant l'autre. 1. La sonde de couverture ne lisait que les 60 premieres lignes du cube [Mois] x Quantite N. La dimension Mois porte tout l'historique de l'app : les mois recents tombaient hors de la premiere page et la sonde annoncait 0/12 pour TOUTES les valeurs de Periode — alors que la master vivait (mesure dans le meme log : total 9 721 153). C'est ce faux zero qui faisait effacer Periode et condamnait l'extraction. Le cube est desormais pagine integralement, et la sonde journalise aussi ce que la master expose hors fenetre. 2. La bonne valeur de Periode DEPEND DE L'ANNEE. « Annee a date » convient a 2026 (janvier -> mois courant) mais pas a 2025, dont on veut les 12 mois. Le choix global ne pouvait donc satisfaire que l'une des deux passes — ce que montrait le log : 2026 servait 2026-01..06, la passe 2025 ne servait rien. choisirPeriode cartographie maintenant, pour chaque annee de la fenetre, la valeur qui sert le plus de mois attendus de cette annee-la (l'etat sans Periode etant un candidat comme un autre), et chaque passe annuelle pose SA valeur avant de selectionner l'annee. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DfqUihgixw4K1AmJhizWiu --- src/lib/qlik-playwright.ts | 206 ++++++++++++++++++++++--------------- 1 file changed, 124 insertions(+), 82 deletions(-) diff --git a/src/lib/qlik-playwright.ts b/src/lib/qlik-playwright.ts index 19dad98..7ec3e9b 100644 --- a/src/lib/qlik-playwright.ts +++ b/src/lib/qlik-playwright.ts @@ -917,6 +917,32 @@ export async function fetchNetworkMetricsPlaywright( return false; }; + /** + * Le list object « Période », gardé vivant : le détruire annulerait la + * sélection qu'il porte. + */ + let periodeLo: { qHandle: number } | null = null; + + /** Valeur de `Période` à poser avant la passe d'une année donnée. */ + const periodeParAnnee = new Map(); + + /** Pose (ou efface, si `elem` est nul) la sélection `Période`. */ + const appliquerPeriode = async (elem: number | null): Promise => { + if (!periodeLo) return; + if (elem == null) { + await rpc("ClearField", { qFieldName: "Période" }, doc) + .catch(() => rpc("Clear", {}, periodeLo!.qHandle)); + } else { + await rpc("SelectListObjectValues", { + qPath: "/qListObjectDef", + qValues: [elem], + qToggleMode: false, + qSoftLock: true, + }, periodeLo.qHandle); + } + await sleep(qlikSettleMs); + }; + /** * Choisit la valeur de `Période` sous laquelle l'extraction aura lieu. * @@ -959,32 +985,61 @@ export async function fetchNetworkMetricsPlaywright( diag("valeurs de « Période » : " + JSON.stringify(valeurs.map((v) => v.texte))); if (valeurs.length === 0) return 0; - /** Couverture réelle : quels mois de la fenêtre « Quantité N » rend-elle vivants ? */ - const mesurerCouverture = async (): Promise => { + /** + * Couverture réelle : quels mois « Quantité N » rend-elle vivants ? + * + * ⚠️ Le cube est PAGINÉ intégralement. La version précédente ne + * lisait que les 60 premières lignes : la dimension Mois porte + * tout l'historique de l'app, les mois récents tombaient hors + * de la première page, et la sonde annonçait 0/12 quelle que + * soit la `Période` — alors que la master vivait (mesuré : + * total 9 721 153). C'est ce faux zéro qui faisait effacer + * `Période` et condamnait toute l'extraction. + * + * Renvoie les mois vivants DE LA FENÊTRE, et journalise aussi + * ce que la dimension expose hors fenêtre : sans cela on ne + * distingue pas « le modèle ne donne rien » de « le modèle + * donne autre chose que ce qu'on attend ». + */ + const mesurerCouverture = async (): Promise<{ fenetre: string[]; tous: string[] }> => { const sonde = await rpc("CreateSessionObject", { qProp: { qInfo: { qType: "cf-periode-sonde" }, qHyperCubeDef: { qDimensions: [{ qLibraryId: moisDimId }], qMeasures: [{ qLibraryId: rQte }], - qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 2, qHeight: 60 }], + qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 2, qHeight: 500 }], }, } }, doc); const sRet = sonde.qReturn as { qHandle: number; qGenericId?: string; qId?: string }; try { const sl = await rpc("GetLayout", {}, sRet.qHandle); - const sm = ((sl.qLayout as { qHyperCube?: { qDataPages?: Array<{ qMatrix?: Array> }> } }) - .qHyperCube?.qDataPages?.[0]?.qMatrix) ?? []; - return sm + const hc = (sl.qLayout as { qHyperCube?: { qSize?: { qcy?: number }; qDataPages?: Array<{ qMatrix?: Array> }> } }).qHyperCube; + const lignes: Array> = [...(hc?.qDataPages?.[0]?.qMatrix ?? [])]; + const total = Number(hc?.qSize?.qcy) || 0; + for (let top = lignes.length; top < total; top += 500) { + const d = await rpc("GetHyperCubeData", { + qPath: "/qHyperCubeDef", + qPages: [{ qTop: top, qLeft: 0, qWidth: 2, qHeight: Math.min(500, total - top) }], + }, sRet.qHandle); + const m = (d.qDataPages as Array<{ qMatrix?: Array> }>)?.[0]?.qMatrix ?? []; + if (m.length === 0) break; + lignes.push(...m); + } + const vivants = lignes .filter((r) => (Number(r[1]?.qNum) || 0) > 0) .map((r) => normMois(String(r[0]?.qText ?? ""))) - .filter((m) => fenetre.has(m)) .sort(); + return { fenetre: vivants.filter((m) => fenetre.has(m)), tous: vivants }; } finally { const sid = String(sRet.qGenericId ?? sRet.qId ?? ""); if (sid) { try { await rpc("DestroySessionObject", { qId: sid }, doc); } catch { /* noop */ } } } }; + /** Résumé compact d'une liste de mois : « 2025-01…2025-12 (12) ». */ + const resume = (mois: string[]) => + mois.length === 0 ? "aucun" : mois[0] + "…" + mois[mois.length - 1] + " (" + mois.length + ")"; + /** * Couverture d'une `Période` telle qu'elle sera RÉELLEMENT * exploitée : une passe par année, comme `monthDimPath`, puis @@ -992,10 +1047,25 @@ export async function fetchNetworkMetricsPlaywright( * sans année revient à juger « Année à date » sur la seule * année en cours (6/12) et à la rejeter à tort. */ - const mesurerCouvertureUnion = async (): Promise => { - if (yfh === -1) return mesurerCouverture(); - const union = new Set(); - for (const annee of annees) { + // Le list object doit survivre : le détruire annulerait la + // sélection qu'il porte. On le garde pour `appliquerPeriode`. + periodeLo = { qHandle: lo.qHandle }; + + // ─── Une `Période` PAR ANNÉE ───────────────────────────── + // + // C'est le point que la version globale manquait : la bonne + // valeur dépend de l'année analysée. « Année à date » convient + // à 2026 (janvier → mois courant) mais pas à 2025, dont on veut + // les 12 mois. On cartographie donc, pour chaque année de la + // fenêtre, la valeur qui sert le PLUS de mois attendus de cette + // année-là — l'état sans `Période` étant un candidat comme un + // autre. + const couvertsGlobal = new Set(); + for (const annee of annees) { + const attendusAnnee = [...fenetre].filter((m) => m.startsWith(String(annee) + "-")); + diag(" année " + annee + " — mois attendus " + JSON.stringify(attendusAnnee.sort())); + + if (yfh !== -1) { await rpc("Clear", {}, yfh); await sleep(qlikSettleMs); await rpc("SelectValues", { @@ -1004,81 +1074,43 @@ export async function fetchNetworkMetricsPlaywright( qSoftLock: true, }, yfh); await sleep(qlikSettleMs); - const mois = await mesurerCouverture(); - diag(" Année=" + annee + " → " + mois.length + " mois " + JSON.stringify(mois)); - for (const m of mois) union.add(m); } - // L'année ne doit pas rester sélectionnée : chaque passe - // pose la sienne. + + const candidats: Array<{ texte: string; elem: number | null }> = [ + { texte: "(aucune)", elem: null }, + ...valeurs.map((v) => ({ texte: v.texte, elem: v.elem as number | null })), + ]; + let meilleure: { texte: string; elem: number | null; servis: string[] } | null = null; + for (const c of candidats) { + await appliquerPeriode(c.elem); + const mois = await mesurerCouverture(); + const servis = mois.fenetre.filter((m) => attendusAnnee.includes(m)); + diag( + " Période « " + c.texte + " » → sert " + servis.length + "/" + attendusAnnee.length + + " mois attendus " + JSON.stringify(servis) + " ; master exposée : " + resume(mois.tous), + ); + if (!meilleure || servis.length > meilleure.servis.length) { + meilleure = { texte: c.texte, elem: c.elem, servis }; + } + if (servis.length >= attendusAnnee.length) break; // année complète + } + + if (meilleure) { + periodeParAnnee.set(annee, { elem: meilleure.elem, texte: meilleure.texte }); + for (const m of meilleure.servis) couvertsGlobal.add(m); + diag( + " → année " + annee + " sera extraite sous Période « " + meilleure.texte + " » (" + + meilleure.servis.length + "/" + attendusAnnee.length + " mois)", + ); + } + } + + if (yfh !== -1) { + // Chaque passe pose sa propre année. await rpc("Clear", {}, yfh); await sleep(qlikSettleMs); - return [...union].sort(); - }; - - // CANDIDAT ZÉRO : aucune sélection de « Période ». - // - // Conservé pour le diagnostic, mais on sait maintenant qu'il - // renvoie 0/12 : sans `Période`, `Type_Cal` ne résout rien. - await rpc("ClearField", { qFieldName: "Période" }, doc).catch(() => rpc("Clear", {}, lo!.qHandle)); - await sleep(qlikSettleMs); - const sansSelection = await mesurerCouvertureUnion(); - diag(" SANS sélection de Période → " + sansSelection.length + "/" + fenetre.size + " mois " + JSON.stringify(sansSelection)); - if (sansSelection.length >= fenetre.size) { - diag("Période laissée LIBRE : les mesures couvrent déjà les " + fenetre.size + " mois"); - return sansSelection.length; } - - let meilleure: { texte: string; elem: number; couverts: string[] } | null = null; - for (const v of valeurs) { - await rpc("SelectListObjectValues", { - qPath: "/qListObjectDef", - qValues: [v.elem], - qToggleMode: false, - qSoftLock: true, - }, lo.qHandle); - await sleep(qlikSettleMs); - - const couverts = await mesurerCouvertureUnion(); - diag(" Période « " + v.texte + " » → " + couverts.length + "/" + fenetre.size + " mois de la fenêtre (union des années) " + JSON.stringify(couverts)); - if (!meilleure || couverts.length > meilleure.couverts.length) { - meilleure = { texte: v.texte, elem: v.elem, couverts }; - } - if (couverts.length >= fenetre.size) break; // couverture complète, inutile de continuer - } - - const couverture = meilleure ? meilleure.couverts.length : 0; - - // On garde la MEILLEURE valeur, même partielle. - // - // L'inverse a été essayé et s'est retourné contre nous : - // effacer `Période` ne « libère » pas les faits, elle rend les - // master measures muettes (0/12 mesuré). Une couverture - // partielle reste strictement meilleure que rien ; seul un - // zéro absolu justifie d'effacer et de tenter l'agrégation - // directe. - if (!meilleure || couverture === 0) { - await rpc("ClearField", { qFieldName: "Période" }, doc) - .catch(() => rpc("Clear", {}, lo!.qHandle)); - await sleep(qlikSettleMs); - diag( - "aucune valeur de « Période » ne rend le moindre mois vivant → sélection Période " + - "effacée, repli sur l'agrégation directe des faits", - ); - return 0; - } - - await rpc("SelectListObjectValues", { - qPath: "/qListObjectDef", - qValues: [meilleure.elem], - qToggleMode: false, - qSoftLock: true, - }, lo.qHandle); - await sleep(qlikSettleMs); - diag( - "Période retenue : « " + meilleure.texte + " » (" + couverture + "/" + fenetre.size + - " mois en union des années" + (couverture < fenetre.size ? " — meilleure disponible" : "") + ")", - ); - return couverture; + return couvertsGlobal.size; } catch (e) { diag("choix de « Période » impossible : " + String((e as Error)?.message || e)); return 0; @@ -1500,6 +1532,16 @@ export async function fetchNetworkMetricsPlaywright( }; if (yfh === -1) throw new Error("champ « Année » introuvable : impossible d'extraire année par année"); + + // La `Période` qui sert cette année-là — pas la même pour toutes : + // « Année à date » convient à l'année en cours, une autre valeur à + // une année révolue (cf. choisirPeriode). + const periodeAnnee = periodeParAnnee.get(selectYear); + if (periodeAnnee) { + console.log("[qlik-pw] (mois) Période « " + periodeAnnee.texte + " » pour l'année " + selectYear); + await appliquerPeriode(periodeAnnee.elem); + } + await rpc("Clear", {}, yfh); await sleep(qlikSettleMs); const ysel = await rpc("SelectValues", { qFieldValues: [{ qNum: selectYear, qText: String(selectYear) }], qToggleMode: false, qSoftLock: true }, yfh); From 50f883ec98d7a9b506f89c1396a09dc47ad525eb Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 06:29:54 +0000 Subject: [PATCH 24/34] Qlik: extraction mois par mois sur le champ date brut, sans pont de periodes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le log livre le fait qui manquait : dimension « Mois » → 35602068 (100% du total sans filtre) 100 %, donc TOUTE la table de faits tient dans les 12 mois. Le test de champ date « il doit faire baisser le total » etait donc structurellement faux : n'importe quel champ correct donne 100 % quand la fenetre couvre tout. Il rejetait les bons champs, ce qui condamnait le seul chemin sain et renvoyait vers la dimension Mois — celle qui demultiplie les faits. Nouveau chemin moisParDateBrute(), essaye AVANT l'agregation par dimension : - Type_Cal / Periode / Annee effaces : on veut les faits nus, pas un contexte de calendrier. Plus aucun mois « inatteignable », puisqu'un mois y est un filtre de dates. - detection du champ date sur UN SEUL MOIS : valide si le total est non nul ET une fraction du total (ni 0, ni 100 %). - cube [Article Code] x 4 expressions, SANS dimension Mois : c'est elle qui passe par le pont et rattache un fait a plusieurs contextes (mesure : x5,3). - 12 selections de dates, 12 cubes pagines, un point de controle par mois. Controle d'integrite : chaque mois doit etre servi, et la somme des 12 mois ne doit pas depasser le total sans filtre de date — une demultiplication ferait exploser ce rapport. Sinon rien n'est ecrit. Corrige aussi le meme test defectueux dans moisParExpressions, et permet d'effacer Periode meme si son list object n'a pas pu etre cree. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DfqUihgixw4K1AmJhizWiu --- src/lib/qlik-playwright.ts | 253 ++++++++++++++++++++++++++++++++++++- 1 file changed, 249 insertions(+), 4 deletions(-) diff --git a/src/lib/qlik-playwright.ts b/src/lib/qlik-playwright.ts index 7ec3e9b..d75b79c 100644 --- a/src/lib/qlik-playwright.ts +++ b/src/lib/qlik-playwright.ts @@ -928,11 +928,17 @@ export async function fetchNetworkMetricsPlaywright( /** Pose (ou efface, si `elem` est nul) la sélection `Période`. */ const appliquerPeriode = async (elem: number | null): Promise => { - if (!periodeLo) return; + // Effacer doit rester possible même si le list object n'a jamais pu + // être créé : sinon la `Période` héritée de l'ouverture de l'app + // resterait posée sans que personne ne l'ait choisie. if (elem == null) { await rpc("ClearField", { qFieldName: "Période" }, doc) - .catch(() => rpc("Clear", {}, periodeLo!.qHandle)); - } else { + .catch(() => (periodeLo ? rpc("Clear", {}, periodeLo.qHandle) : undefined)); + await sleep(qlikSettleMs); + return; + } + if (!periodeLo) return; + { await rpc("SelectListObjectValues", { qPath: "/qListObjectDef", qValues: [elem], @@ -1235,6 +1241,216 @@ export async function fetchNetworkMetricsPlaywright( } }; + /** + * Cube `[Article Code] × 4 expressions`, SANS dimension Mois. + * + * C'est tout l'intérêt : la dimension Mois passe par le pont de + * périodes et rattache une même ligne de fait à plusieurs contextes + * (mesuré : ×5,3). Sans elle, `Sum(quantite)` porte une fois et une + * seule sur les faits visibles — donc sur le mois sélectionné. + */ + const fetchCodeCubeExpr = async ( + eCa: string, eQte: string, eNbMag: string, eMarge: string, + onRow: (code: string, ca: number, qte: number, nbMag: number, marge: number) => void, + timings?: Record, + ): Promise => { + const LARGEUR = 5; + const PAGE = 1800; // 5 × 1800 = 9000 cellules < 10000 + const { value: obj } = await timed("createCubeCode", "CreateSessionObject", () => rpcWithRetry("createCubeCode", "CreateSessionObject", { qProp: { qInfo: { qType: "cf-net-code-expr" }, qHyperCubeDef: { + qDimensions: [{ qLibraryId: dim }], + qMeasures: [ + { qDef: { qDef: eCa } }, + { qDef: { qDef: eQte } }, + { qDef: { qDef: eNbMag } }, + { qDef: { qDef: eMarge } }, + ], + qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: LARGEUR, qHeight: PAGE }], + } } }, doc), timings); + const qReturn = obj.qReturn as { qHandle: number; qGenericId?: string; qId?: string }; + const cube = { handle: qReturn.qHandle, id: String(qReturn.qGenericId ?? qReturn.qId ?? "") }; + const traiter = (matrix: Array>): number => { + for (const r of matrix) { + onRow( + String(r[0]?.qText ?? ""), + Number(r[1]?.qNum) || 0, Number(r[2]?.qNum) || 0, + Number(r[3]?.qNum) || 0, Number(r[4]?.qNum) || 0, + ); + } + return matrix.length; + }; + try { + const { value: layout } = await timed("layout", "GetLayout", () => rpcWithRetry("layout", "GetLayout", {}, cube.handle), timings); + const hc = (layout.qLayout as { qHyperCube: { qSize: { qcy: number }; qDataPages?: Array<{ qArea?: { qTop?: number }; qMatrix?: Array> }> } }).qHyperCube; + const size = hc.qSize.qcy; + let got = 0, top = 0; + const p0 = hc.qDataPages?.find((p) => (p.qArea?.qTop ?? 0) === 0); + if (p0?.qMatrix?.length) { got += traiter(p0.qMatrix); 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: LARGEUR, qHeight: PAGE }] }, cube.handle), timings); + const matrix = (d.qDataPages as Array<{ qMatrix?: Array> }>)?.[0]?.qMatrix ?? []; + if (!matrix.length) break; + got += traiter(matrix); + if (matrix.length < PAGE) break; + } + return got; + } finally { + await destroyCube(cube, timings); + } + }; + + /** + * EXTRACTION MOIS PAR MOIS SUR LE CHAMP DATE BRUT. + * + * Le chemin que rien n'avait encore essayé, et le seul qui n'emprunte + * ni le pont de périodes ni les master measures : + * + * - on sélectionne les jours d'UN mois sur le champ date ; + * - on lit un cube `[Article Code]` sans dimension Mois, donc sans + * démultiplication ; + * - `Type_Cal`, `Période` et `Année` sont effacés : les expressions + * brutes n'ont pas besoin du pont, elles agrègent les faits visibles. + * + * Il n'y a donc plus de « mois inatteignable » : chaque mois est un + * filtre de dates, pas un contexte de calendrier. + * + * CONTRÔLE D'INTÉGRITÉ. La somme des 12 mois ne doit pas dépasser le + * total sans filtre de date : si la dimension ou le champ démultipliait + * les faits, elle exploserait (le pont donne ×5,3). Et chaque mois doit + * être servi. Sinon rien n'est écrit. + */ + const moisParDateBrute = async (): Promise => { + const timings: Record = {}; + + // Aucun contexte de calendrier : on veut les faits nus. + if (yfh !== -1) { await rpc("Clear", {}, yfh); await sleep(qlikSettleMs); } + await appliquerPeriode(null); + if (dfh !== -1) { await rpc("Clear", {}, dfh); await sleep(qlikSettleMs); } + + if (codes.length && fh !== -1 && !parFournisseur) { + await timed("clearCode", "Clear", () => rpc("Clear", {}, fh), timings); + await sleep(qlikSettleMs); + await timed("selectCodeAll", "SelectValues", () => rpcWithRetry("selectCodeAll", "SelectValues", { + qFieldValues: codes.map((c) => ({ qText: c })), + qToggleMode: false, + qSoftLock: true, + }, fh, () => { aggCode15Retries++; }), timings); + await sleep(qlikSettleMs); + } + + const sansFiltre = await totalDeControle(); + diag("[date brute] contrôle « " + exprQte + " » sans filtre date = " + Math.round(sansFiltre)); + if (sansFiltre <= 0) { + diag("[date brute] ÉCHEC : l'expression quantité ne renvoie rien (corriger QLIK_EXPR_QTE)"); + return false; + } + + // ─── Quel champ date filtre les faits ? ────────────────── + // + // ⚠️ On teste avec UN SEUL MOIS, pas avec la fenêtre entière. + // Toute la table de faits tient dans les 12 mois (mesuré : 100 % + // du total) : sélectionner la fenêtre ne réduit donc RIEN, et + // l'ancien test « le total doit baisser » rejetait tous les + // champs, y compris les bons. + const moisTest = monthlyPayload[Math.floor(monthlyPayload.length / 2)]; + let champDate: string | null = null; + let handleDate = -1; + for (const cand of champsDateCandidats) { + const valeurs = /key/i.test(cand) ? moisTest.ymd : moisTest.serials; + const h = await selectionnerFenetreSur(cand, valeurs); + if (h == null) { diag("[date brute] champ « " + cand + " » : inexistant"); continue; } + const t = await totalDeControle(); + const part = sansFiltre > 0 ? t / sansFiltre : 0; + diag( + "[date brute] champ « " + cand + " » sur le seul mois " + moisTest.label + " → " + + Math.round(t) + " (" + (part * 100).toFixed(1) + "% du total)", + ); + // Un mois isolé doit peser une fraction du total : ni zéro + // (le champ ne touche pas les faits) ni la totalité (il ne + // filtre pas). + if (t > 0 && part < 0.5) { champDate = cand; handleDate = h; break; } + await rpc("Clear", {}, h); + await sleep(qlikSettleMs); + } + if (!champDate || handleDate === -1) { + diag( + "[date brute] ÉCHEC : aucun champ date ne filtre les faits mois par mois " + + "(essayés : " + JSON.stringify(champsDateCandidats) + ")", + ); + return false; + } + diag("[date brute] champ date retenu : « " + champDate + " »"); + + // À partir d'ici la série sera reconstruite entièrement : les + // passes annuelles (master measures) et la série brute n'ont pas + // la même sémantique, on ne les mélange pas. + out.length = 0; + for (const k of Object.keys(monthlyByCode)) delete monthlyByCode[k]; + + const totauxMois: Record = {}; + let sommeMois = 0; + for (let i = 0; i < monthlyPayload.length; i++) { + const m = monthlyPayload[i]; + const valeurs = /key/i.test(champDate) ? m.ymd : m.serials; + await rpc("Clear", {}, handleDate); + await sleep(qlikSettleMs); + await rpcWithRetry("selectDate", "SelectValues", { + qFieldValues: valeurs.map((v) => ({ qNum: v, qText: String(v) })), + qToggleMode: false, + qSoftLock: true, + }, handleDate, () => { aggCode15Retries++; }); + await sleep(qlikSettleMs); + + let totalDuMois = 0; + const lignes = await fetchCodeCubeExpr(exprCa, exprQte, exprNbMag, exprMarge, (code, ca, qte, nbMag, marge) => { + if (!code || code === "-") return; + if (qte === 0 && ca === 0) return; + totalDuMois += qte; + out.push([code, ca, qte, nbMag, nbMag > 0 ? ca / nbMag : 0, ca > 0 ? marge / ca : 0]); + (monthlyByCode[code] ??= {}); + monthlyByCode[code][m.label] = { + qte, ca, nbMag, + caMag: nbMag > 0 ? ca / nbMag : 0, + margePct: ca > 0 ? marge / ca : 0, + }; + }, timings); + totauxMois[m.label] = totalDuMois; + sommeMois += totalDuMois; + aggBatches++; + console.log( + "[qlik-pw][date brute] mois " + (i + 1) + "/" + monthlyPayload.length + " " + m.label + + " → " + lignes + " articles, quantité=" + Math.round(totalDuMois), + ); + await pousserCheckpoint("date brute " + m.label); + } + aggMonths = monthlyPayload.length; + + diag("[date brute] quantités par mois : " + JSON.stringify( + Object.fromEntries(Object.entries(totauxMois).map(([k, v]) => [k, Math.round(v)])), + )); + + // ─── Intégrité ─────────────────────────────────────────── + const vides = Object.entries(totauxMois).filter(([, v]) => v <= 0).map(([k]) => k); + if (vides.length) { + diag("[date brute] ÉCHEC : mois sans aucune vente " + JSON.stringify(vides)); + return false; + } + if (sommeMois > sansFiltre * 1.02) { + diag( + "[date brute] ÉCHEC : la somme des 12 mois (" + Math.round(sommeMois) + + ") dépasse le total sans filtre (" + Math.round(sansFiltre) + ") — les faits sont " + + "démultipliés, les ventes réseau seraient fausses", + ); + return false; + } + diag( + "[date brute] OK — somme des 12 mois = " + Math.round(sommeMois) + " pour un total sans filtre de " + + Math.round(sansFiltre) + " (" + Math.round((sommeMois / sansFiltre) * 100) + "%), " + + Object.keys(monthlyByCode).length + " articles", + ); + console.log("[qlik-pw][date brute] timings=" + timingSummary(timings)); + return true; + }; + const moisParExpressions = async (): Promise => { const timings: Record = {}; const marqueOut = out.length; @@ -1284,9 +1500,17 @@ export async function fetchNetworkMetricsPlaywright( const filtre = await totalDeControle(); const ratio = sansFiltre > 0 ? filtre / sansFiltre : 0; diag(" champ « " + cand + " » → " + Math.round(filtre) + " (" + Math.round(ratio * 100) + "% du total sans filtre)"); - if (filtre > 0 && ratio < 0.995) { + // ⚠️ 100 % ne veut PAS dire « ce champ ne filtre rien » : toute + // la table de faits tient dans les 12 mois (mesuré), donc + // sélectionner la fenêtre entière ne retire rien. Exiger une + // baisse rejetait donc les champs valides. Ici la sélection + // ne sert qu'à borner la fenêtre : un total non nul suffit, + // et on préfère simplement un champ qui filtre s'il existe. + if (filtre > 0) { champDate = cand; totalFenetre = filtre; + if (ratio < 0.995) break; + diag(" (la fenêtre couvre 100 % des faits — normal si l'app ne porte que ces 12 mois)"); break; } // Un candidat rejeté ne doit jamais polluer l'essai @@ -1818,6 +2042,27 @@ export async function fetchNetworkMetricsPlaywright( } } + // REPLI 1 — mois par mois sur le champ date brut. + // + // Passe en premier car c'est le seul chemin sans pont de périodes : + // aucun mois n'y est « inatteignable », puisqu'un mois y est un + // filtre de dates et non un contexte de calendrier. + if (!extraitParAnnee && useExpr) { + try { + if (await moisParDateBrute()) { + extraitParAnnee = couvre12(); + if (extraitParAnnee) { + diag("extraction par date brute retenue — les " + attendus.length + " mois sont servis"); + await pousserCheckpoint("date brute complète"); + } else { + diag("extraction par date brute incomplète (manquants : " + JSON.stringify(moisManquants()) + ")"); + } + } + } catch (e) { + diag("extraction par date brute interrompue : " + String((e as Error)?.message || e)); + } + } + if (!extraitParAnnee) { if (!(monthDim && useExpr && (await moisParExpressions()))) { throw new Error( From d89d2b323261521f46ffecdef76fb4cceb41fdd3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 06:45:14 +0000 Subject: [PATCH 25/34] Parametres : telechargement des journaux serveur complets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Une extraction Qlik produit plusieurs milliers de lignes de diagnostic. Un terminal les tronque et n'en laisse que la fin, alors que l'information decisive — carte du modele Qlik, champs date retenus, choix des periodes — se trouve en TETE. Diagnostiquer sur la fin du log revient a travailler a l'aveugle. - src/lib/log-capture.ts : instrumente console.log/warn/error une fois, recopie chaque ligne horodatee dans les captures ouvertes, ecrit au fil de l'eau dans le repertoire temporaire (un job qui fait tomber le process laisse quand meme son journal). Bornes : 200 000 lignes, 8 000 caracteres par ligne, 20 fichiers conserves. - GET /api/logs : liste JSON ; GET /api/logs?id= : le fichier complet en piece jointe. Admin uniquement. - La sync Qlik ouvre la capture AVANT son premier log et la ferme dans un finally, avec le statut final — les deux modes (fournisseur et produit). - Parametres : section « Journaux serveur », un bouton Telecharger par job, le plus recent en premier, badge « en cours » pour un job vivant. Aucun secret ne transite dans ces journaux : l'extracteur ne journalise que le NOM du cookie de session, jamais sa valeur ni le mot de passe. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DfqUihgixw4K1AmJhizWiu --- src/app/(dashboard)/settings/page.tsx | 9 + src/app/api/logs/route.ts | 54 +++++ src/app/api/qlik/sync/route.ts | 10 + .../settings/components/server-logs.tsx | 148 +++++++++++ src/lib/log-capture.ts | 229 ++++++++++++++++++ 5 files changed, 450 insertions(+) create mode 100644 src/app/api/logs/route.ts create mode 100644 src/features/settings/components/server-logs.tsx create mode 100644 src/lib/log-capture.ts diff --git a/src/app/(dashboard)/settings/page.tsx b/src/app/(dashboard)/settings/page.tsx index e288523..bcecf1e 100644 --- a/src/app/(dashboard)/settings/page.tsx +++ b/src/app/(dashboard)/settings/page.tsx @@ -8,6 +8,7 @@ import { useDbSettingsStore } from "@/features/settings/store/use-db-settings-st import { testDatabaseConnection, saveDatabaseSettings, getSavedDatabaseConfig, saveQlikSettings, testQlikConnection } from "@/features/settings/actions"; import { useEffect } from "react"; import { UserManagement } from "@/features/admin/components/user-management"; +import { ServerLogs } from "@/features/settings/components/server-logs"; interface OpenRouterModel { id: string; name: string; free: boolean; } @@ -608,6 +609,14 @@ export default function SettingsPage() { + {/* Journaux serveur — diagnostic des extractions Qlik */} +
+ +
+ {/* Gestion des Utilisateurs */}
` → le journal complet en pièce jointe (text/plain) + * + * Réservé aux admins : un journal d'extraction contient les diagnostics internes + * du modèle Qlik et les codes articles manipulés. Aucun secret n'y transite — + * l'extracteur ne journalise que le NOM du cookie de session, jamais sa valeur, + * et jamais le mot de passe Qlik. + */ + +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; +import { listCaptures, readCapture } from "@/lib/log-capture"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +async function requireAdmin(): Promise { + const session = await auth(); + if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + if ((session.user as { role?: string } | undefined)?.role !== "admin") { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + return null; +} + +export async function GET(req: NextRequest) { + const refus = await requireAdmin(); + if (refus) return refus; + + const id = req.nextUrl.searchParams.get("id"); + if (!id) { + return NextResponse.json({ success: true, logs: await listCaptures() }); + } + + const contenu = await readCapture(id); + if (contenu == null) { + return NextResponse.json({ error: "Journal introuvable ou expiré" }, { status: 404 }); + } + + // Nom de fichier assaini : l'id vient d'un job interne, mais il finit dans + // un en-tête HTTP. + const nom = `${id.replace(/[^A-Za-z0-9_.-]/g, "_")}.log`; + return new NextResponse(contenu, { + status: 200, + headers: { + "Content-Type": "text/plain; charset=utf-8", + "Content-Disposition": `attachment; filename="${nom}"`, + "Cache-Control": "no-store", + }, + }); +} diff --git a/src/app/api/qlik/sync/route.ts b/src/app/api/qlik/sync/route.ts index 5614041..53cf5a0 100644 --- a/src/app/api/qlik/sync/route.ts +++ b/src/app/api/qlik/sync/route.ts @@ -7,6 +7,7 @@ import { buildGridNetworkQlikDateFilter, QLIK_MONTHS_BACK_DEFAULT, } from "@/lib/qlik-date-range"; +import { startCapture, stopCapture } from "@/lib/log-capture"; // 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 @@ -215,6 +216,10 @@ function filterCentralCodes(rawCodes: Array): { * au fur et à mesure (compteur fetched, puis upserted, puis finishedAt). */ async function runJob(job: QlikSyncJob): Promise { + // Journal téléchargeable depuis Paramètres. Ouvert AVANT le premier log du + // job pour ne pas perdre l'entête (choix des champs, carte du modèle Qlik) — + // c'est précisément la partie qu'un terminal tronque. + startCapture(job.jobId, `sync fournisseur=${job.fournisseur} démarrée le ${job.startedAt}`); try { const articles = await pgGetArticlesByFournisseur(job.fournisseur); // Filtre strict des codes (trim, dedupe, exclusion vides/`-`/codes trop suspects). @@ -255,6 +260,8 @@ async function runJob(job: QlikSyncJob): Promise { job.finishedAt = new Date().toISOString(); } catch (e) { applyJobError(job, e); + } finally { + await stopCapture(job.jobId, `statut final=${job.status}${job.error ? ` — ${job.error}` : ""}`); } } @@ -284,6 +291,7 @@ function applyJobError(job: QlikSyncJob, e: unknown): void { * exactement le même extracteur, donc le même détail mensuel. */ async function runProductJob(job: QlikSyncJob): Promise { + startCapture(job.jobId, `sync produit=${job.codeCentrale} démarrée le ${job.startedAt}`); try { const code = job.codeCentrale!; job.requested = 1; @@ -312,6 +320,8 @@ async function runProductJob(job: QlikSyncJob): Promise { console.log(`[api/qlik/sync] job=${job.jobId} produit ${code} — ${count} ligne(s) upsert`); } catch (e) { applyJobError(job, e); + } finally { + await stopCapture(job.jobId, `statut final=${job.status}${job.error ? ` — ${job.error}` : ""}`); } } diff --git a/src/features/settings/components/server-logs.tsx b/src/features/settings/components/server-logs.tsx new file mode 100644 index 0000000..9469d2a --- /dev/null +++ b/src/features/settings/components/server-logs.tsx @@ -0,0 +1,148 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { Download, Loader2, RefreshCw, FileText, AlertCircle } from "lucide-react"; + +interface LogEntry { + id: string; + octets: number; + modifieLe: string; + enCours: boolean; +} + +function formatTaille(octets: number): string { + if (octets < 1024) return `${octets} o`; + if (octets < 1024 * 1024) return `${Math.round(octets / 1024)} Ko`; + return `${(octets / (1024 * 1024)).toFixed(1)} Mo`; +} + +function formatDate(iso: string): string { + const d = new Date(iso); + if (isNaN(d.getTime())) return iso; + return d.toLocaleString("fr-FR", { + day: "2-digit", month: "2-digit", year: "numeric", + hour: "2-digit", minute: "2-digit", second: "2-digit", + }); +} + +/** Appelle l'API. Volontairement sans état React : réutilisable tel quel. */ +async function recupererLogs(): Promise { + const res = await fetch("/api/logs", { cache: "no-store" }); + if (!res.ok) { + throw new Error(res.status === 403 ? "Réservé aux administrateurs" : `HTTP ${res.status}`); + } + const data = await res.json(); + return Array.isArray(data.logs) ? data.logs : []; +} + +/** + * Journaux serveur des extractions Qlik — consultation et téléchargement. + * + * Une extraction produit plusieurs milliers de lignes de diagnostic. Un + * terminal les tronque et n'en laisse que la fin, alors que l'information + * décisive (carte du modèle Qlik, champs retenus, choix des périodes) se trouve + * en tête. Le journal complet est donc capturé côté serveur et récupérable ici + * en un fichier. + */ +export function ServerLogs() { + const [logs, setLogs] = useState([]); + // « chargement » dès le départ : le premier appel part au montage, et aucun + // setState ne doit être fait de façon synchrone dans un effet. + const [etat, setEtat] = useState<"idle" | "chargement" | "erreur">("chargement"); + const [erreur, setErreur] = useState(""); + + /** Applique un résultat (succès ou échec) à l'état local. */ + const appliquer = useCallback((entrees: LogEntry[] | null, message: string) => { + if (entrees) { + setLogs(entrees); + setErreur(""); + setEtat("idle"); + } else { + setErreur(message); + setEtat("erreur"); + } + }, []); + + // Chargement initial. L'état n'est touché que depuis les callbacks de la + // promesse, jamais dans le corps de l'effet. + useEffect(() => { + recupererLogs() + .then((entrees) => appliquer(entrees, "")) + .catch((e: unknown) => appliquer(null, e instanceof Error ? e.message : String(e))); + }, [appliquer]); + + /** Rechargement explicite (bouton) : là, l'état « chargement » est immédiat. */ + const recharger = useCallback(() => { + setEtat("chargement"); + recupererLogs() + .then((entrees) => appliquer(entrees, "")) + .catch((e: unknown) => appliquer(null, e instanceof Error ? e.message : String(e))); + }, [appliquer]); + + return ( +
+
+

+ Journal complet de chaque extraction Qlik (le plus récent en premier). + Conservés le temps de la vie du serveur, 20 au maximum. +

+ +
+ + {etat === "erreur" && ( +
+ + {erreur} +
+ )} + + {etat !== "erreur" && logs.length === 0 && ( +

+ Aucun journal pour l'instant — lancez une synchronisation Qlik depuis la Grille. +

+ )} + + {logs.length > 0 && ( +
    + {logs.map((log) => ( +
  • + +
    +
    + + {log.id} + + {log.enCours && ( + + en cours + + )} +
    + + {formatDate(log.modifieLe)} · {formatTaille(log.octets)} + +
    + + + Télécharger + +
  • + ))} +
+ )} +
+ ); +} diff --git a/src/lib/log-capture.ts b/src/lib/log-capture.ts new file mode 100644 index 0000000..364245e --- /dev/null +++ b/src/lib/log-capture.ts @@ -0,0 +1,229 @@ +/** + * CollectFlow — Capture des logs serveur d'un job, pour téléchargement. + * + * POURQUOI. Les extractions Qlik produisent plusieurs milliers de lignes + * (`[qlik-pw][page] …`, timings, diagnostics du modèle). Or ces lignes ne + * vivent que dans la sortie standard du conteneur : un terminal les tronque, + * et un copier-coller ne rapporte que la fin — c'est-à-dire jamais l'entête, + * là où se trouvent la carte du modèle Qlik et le choix des champs. Sans elle, + * un diagnostic se fait à l'aveugle. + * + * COMMENT. `console.log/warn/error` est instrumenté UNE fois au chargement du + * module. Tant qu'au moins une capture est ouverte, chaque ligne est recopiée + * dans les captures ouvertes, puis transmise telle quelle à la console + * d'origine (rien n'est retenu, rien ne change dans les logs du conteneur). + * + * ⚠️ Deux jobs simultanés partagent la sortie standard du process : leurs + * lignes ne portent pas d'identifiant permettant de les démêler. Chaque + * capture ouverte reçoit donc TOUTES les lignes de la période — mieux vaut un + * peu de bruit qu'une ligne manquante dans un journal de diagnostic. + * + * Les captures sont écrites sur disque (répertoire temporaire) au fil de l'eau : + * un job qui fait tomber le process laisse quand même son journal. + */ + +import { mkdir, readFile, readdir, stat, unlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +/** Répertoire des journaux. Volontairement hors du projet : ce sont des temporaires. */ +const LOG_DIR = join(tmpdir(), "collectflow-logs"); + +/** Au-delà, on arrête d'accumuler : un job en boucle ne doit pas manger la RAM. */ +const MAX_LIGNES = 200_000; +/** Une ligne pathologique (dump d'hypercube) est tronquée plutôt que gardée entière. */ +const MAX_LONGUEUR_LIGNE = 8_000; +/** Nombre de journaux conservés sur disque. */ +const MAX_FICHIERS = 20; +/** Écriture disque au plus toutes les N ms (le journal complet est réécrit). */ +const FLUSH_MS = 2_000; + +interface Capture { + id: string; + lignes: string[]; + tronque: boolean; + debut: number; + flushEnCours: boolean; + flushDemande: boolean; + dernierFlush: number; +} + +const captures = new Map(); + +/** Nom de fichier sûr : l'id vient d'un job interne, on le verrouille quand même. */ +function fichierPour(id: string): string { + return join(LOG_DIR, `${id.replace(/[^A-Za-z0-9_.-]/g, "_")}.log`); +} + +function horodatage(): string { + return new Date().toISOString(); +} + +/** Rend une ligne à partir des arguments variadiques de `console.*`. */ +function formater(args: unknown[]): string { + return args + .map((a) => { + if (typeof a === "string") return a; + if (a instanceof Error) return `${a.message}\n${a.stack ?? ""}`; + try { + return JSON.stringify(a); + } catch { + return String(a); + } + }) + .join(" "); +} + +async function flush(capture: Capture): Promise { + if (capture.flushEnCours) { + capture.flushDemande = true; + return; + } + capture.flushEnCours = true; + try { + await mkdir(LOG_DIR, { recursive: true }); + await writeFile(fichierPour(capture.id), capture.lignes.join("\n") + "\n", "utf8"); + capture.dernierFlush = Date.now(); + } catch { + // Un journal de diagnostic ne doit jamais faire échouer le job qu'il observe. + } finally { + capture.flushEnCours = false; + if (capture.flushDemande) { + capture.flushDemande = false; + void flush(capture); + } + } +} + +function ajouter(ligne: string): void { + if (captures.size === 0) return; + const texte = ligne.length > MAX_LONGUEUR_LIGNE + ? ligne.slice(0, MAX_LONGUEUR_LIGNE) + " …[ligne tronquée]" + : ligne; + const prefixe = `[${horodatage()}] `; + for (const capture of captures.values()) { + if (capture.lignes.length >= MAX_LIGNES) { + if (!capture.tronque) { + capture.tronque = true; + capture.lignes.push(`${prefixe}[log-capture] limite de ${MAX_LIGNES} lignes atteinte — suite non enregistrée`); + } + continue; + } + capture.lignes.push(prefixe + texte); + if (Date.now() - capture.dernierFlush >= FLUSH_MS) void flush(capture); + } +} + +/** + * Instrumente `console` une seule fois, même en développement où le module peut + * être réévalué à chaud (le drapeau est porté par `globalThis`). + */ +function instrumenterConsole(): void { + const g = globalThis as typeof globalThis & { __cfLogCaptureInstalle?: boolean }; + if (g.__cfLogCaptureInstalle) return; + g.__cfLogCaptureInstalle = true; + + const methodes = ["log", "warn", "error", "info"] as const; + for (const methode of methodes) { + const origine = console[methode].bind(console); + console[methode] = (...args: unknown[]) => { + try { + ajouter(formater(args)); + } catch { + // idem : jamais au détriment du log réel + } + origine(...args); + }; + } +} + +instrumenterConsole(); + +/** Supprime les journaux les plus anciens au-delà de `MAX_FICHIERS`. */ +async function purger(): Promise { + try { + const noms = (await readdir(LOG_DIR)).filter((n) => n.endsWith(".log")); + if (noms.length <= MAX_FICHIERS) return; + const avecDate = await Promise.all( + noms.map(async (nom) => { + const s = await stat(join(LOG_DIR, nom)).catch(() => null); + return { nom, mtime: s?.mtimeMs ?? 0 }; + }), + ); + avecDate.sort((a, b) => b.mtime - a.mtime); + for (const { nom } of avecDate.slice(MAX_FICHIERS)) { + await unlink(join(LOG_DIR, nom)).catch(() => undefined); + } + } catch { + // purge best-effort + } +} + +/** + * Ouvre une capture. Toutes les lignes écrites sur la console à partir de + * maintenant (par n'importe quel code du process, cf. avertissement en tête) + * y sont recopiées jusqu'à `stopCapture`. + */ +export function startCapture(id: string, entete?: string): void { + const capture: Capture = { + id, + lignes: [], + tronque: false, + debut: Date.now(), + flushEnCours: false, + flushDemande: false, + dernierFlush: 0, + }; + captures.set(id, capture); + capture.lignes.push(`[${horodatage()}] [log-capture] début de capture ${id}`); + if (entete) capture.lignes.push(`[${horodatage()}] [log-capture] ${entete}`); + void flush(capture); + void purger(); +} + +/** Ferme la capture et écrit son état final sur disque. */ +export async function stopCapture(id: string, pied?: string): Promise { + const capture = captures.get(id); + if (!capture) return; + const duree = Math.round((Date.now() - capture.debut) / 1000); + capture.lignes.push(`[${horodatage()}] [log-capture] fin de capture ${id} — ${capture.lignes.length} lignes, ${duree} s`); + if (pied) capture.lignes.push(`[${horodatage()}] [log-capture] ${pied}`); + captures.delete(id); + await flush(capture); +} + +/** + * Contenu d'un journal : la capture en cours si elle est ouverte, sinon le + * fichier laissé sur disque. `null` si aucun des deux n'existe. + */ +export async function readCapture(id: string): Promise { + const capture = captures.get(id); + if (capture) return capture.lignes.join("\n") + "\n"; + try { + return await readFile(fichierPour(id), "utf8"); + } catch { + return null; + } +} + +/** Journaux disponibles, du plus récent au plus ancien. */ +export async function listCaptures(): Promise> { + const resultats: Array<{ id: string; octets: number; modifieLe: string; enCours: boolean }> = []; + try { + for (const nom of await readdir(LOG_DIR)) { + if (!nom.endsWith(".log")) continue; + const s = await stat(join(LOG_DIR, nom)).catch(() => null); + if (!s) continue; + const id = nom.slice(0, -4); + resultats.push({ + id, + octets: s.size, + modifieLe: new Date(s.mtimeMs).toISOString(), + enCours: captures.has(id), + }); + } + } catch { + // répertoire pas encore créé + } + return resultats.sort((a, b) => b.modifieLe.localeCompare(a.modifieLe)); +} From fee267004f8ffe5d4ea0db6a42873fed93d66068 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 07:00:55 +0000 Subject: [PATCH 26/34] Qlik: Clear/SelectValues sur le CHAMP Periode, pas sur un objet de session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le journal complet (enfin lisible) donne la cause, et elle est unique : choix de « Periode » impossible : {"code":-32601,"parameter":"Clear","message":"Method not found"} extraction par date brute interrompue : {"code":-32601,"parameter":"Clear","message":"Method not found"} Clear et SelectValues sont des methodes de CHAMP. Je les appelais sur le list object « Periode ». Consequence : la cartographie des periodes ET le chemin d'extraction par date brute n'ont jamais tourne — d'ou la couverture master annoncee a 0/12 alors que la master vit (total mesure : 11 591 263). - Handle du champ « Periode » obtenu comme ceux d'Annee et de Date ; le list object ne sert plus qu'a ENUMERER les valeurs. - appliquerPeriode selectionne par texte et ne propage plus d'erreur : une periode qu'on n'arrive pas a poser doit degrader le resultat, pas interrompre l'extraction. - Chaque candidat de la cartographie est isole : une valeur en echec est notee et ignoree, elle n'emporte plus toute la carte. Sonde fournisseur : « J009 » laissait 1 207 205 articles visibles, soit tout le catalogue — la selection ne restreignait rien, et la « couverture » de 97 % ne faisait que constater que le catalogue contient nos codes. Une selection qui laisse plus de 3x les codes demandes est desormais rejetee immediatement (elle coutait 3 x 24 s de lecture). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DfqUihgixw4K1AmJhizWiu --- src/lib/qlik-playwright.ts | 125 ++++++++++++++++++++++++------------- 1 file changed, 80 insertions(+), 45 deletions(-) diff --git a/src/lib/qlik-playwright.ts b/src/lib/qlik-playwright.ts index d75b79c..40fa402 100644 --- a/src/lib/qlik-playwright.ts +++ b/src/lib/qlik-playwright.ts @@ -345,12 +345,27 @@ export async function fetchNetworkMetricsPlaywright( let dfh = -1; let fh = -1; let yfh = -1; + /** + * Handle du CHAMP « Période ». + * + * ⚠️ `Clear` et `SelectValues` sont des méthodes de **champ**. Les + * appeler sur un objet de session renvoie + * `{"code":-32601,"parameter":"Clear","message":"Method not found"}`, + * ce qui a fait avorter en production la cartographie des périodes ET + * le chemin d'extraction par date brute. Le list object ne sert donc + * plus qu'à ÉNUMÉRER les valeurs ; les sélections passent par ici. + */ + let pfh = -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; } + try { + const pf = await rpc("GetField", { qFieldName: "Période" }, doc); + pfh = (pf.qReturn as { qHandle: number }).qHandle; + } catch { pfh = -1; } if (codes.length) { const gf = await rpc("GetField", { qFieldName: "Article Code" }, doc); fh = (gf.qReturn as { qHandle: number }).qHandle; @@ -898,6 +913,20 @@ export async function fetchNetworkMetricsPlaywright( } const visibles = await lireCodesVisibles(); + // Un vrai périmètre fournisseur est de l'ordre de grandeur des + // codes demandés. Mesuré en production : « J009 » laissait + // 1 207 205 articles visibles, soit tout le catalogue — la + // sélection n'avait rien restreint, et la « couverture » de 97 % + // ne faisait que constater que le catalogue contient nos codes. + if (visibles.size > attendus.size * 3) { + diag( + " champ « " + champ + " » = « " + fournisseurCode + " » → " + visibles.size + + " articles visibles pour " + attendus.size + " demandés : la sélection ne restreint rien, ignorée", + ); + await rpc("Clear", {}, h); + await sleep(qlikSettleMs); + continue; + } let couverts = 0; for (const c of attendus) if (visibles.has(c)) couverts++; const couverture = attendus.size > 0 ? couverts / attendus.size : 0; @@ -917,36 +946,35 @@ export async function fetchNetworkMetricsPlaywright( return false; }; - /** - * Le list object « Période », gardé vivant : le détruire annulerait la - * sélection qu'il porte. - */ - let periodeLo: { qHandle: number } | null = null; - /** Valeur de `Période` à poser avant la passe d'une année donnée. */ - const periodeParAnnee = new Map(); + const periodeParAnnee = new Map(); - /** Pose (ou efface, si `elem` est nul) la sélection `Période`. */ - const appliquerPeriode = async (elem: number | null): Promise => { - // Effacer doit rester possible même si le list object n'a jamais pu - // être créé : sinon la `Période` héritée de l'ouverture de l'app - // resterait posée sans que personne ne l'ait choisie. - if (elem == null) { - await rpc("ClearField", { qFieldName: "Période" }, doc) - .catch(() => (periodeLo ? rpc("Clear", {}, periodeLo.qHandle) : undefined)); + /** + * Pose (ou efface, si `texte` est nul) la sélection `Période`. + * + * Passe par le HANDLE DU CHAMP, comme pour `Année` et `Date` : c'est la + * seule API qui expose `Clear` et `SelectValues`. Ne propage jamais + * d'erreur — une `Période` qu'on n'arrive pas à poser doit dégrader le + * résultat, pas interrompre l'extraction (leçon de production : un + * `Clear` en échec a fait avorter deux chemins entiers). + */ + const appliquerPeriode = async (texte: string | null): Promise => { + if (pfh === -1) return texte == null; + try { + await rpc("Clear", {}, pfh); await sleep(qlikSettleMs); - return; - } - if (!periodeLo) return; - { - await rpc("SelectListObjectValues", { - qPath: "/qListObjectDef", - qValues: [elem], + if (texte == null) return true; + const sel = await rpc("SelectValues", { + qFieldValues: [{ qText: texte }], qToggleMode: false, qSoftLock: true, - }, periodeLo.qHandle); + }, pfh); + await sleep(qlikSettleMs); + return sel.qReturn !== false; + } catch (e) { + diag(" sélection Période « " + (texte ?? "(aucune)") + " » impossible : " + String((e as Error)?.message || e)); + return false; } - await sleep(qlikSettleMs); }; /** @@ -1053,10 +1081,6 @@ export async function fetchNetworkMetricsPlaywright( * sans année revient à juger « Année à date » sur la seule * année en cours (6/12) et à la rejeter à tort. */ - // Le list object doit survivre : le détruire annulerait la - // sélection qu'il porte. On le garde pour `appliquerPeriode`. - periodeLo = { qHandle: lo.qHandle }; - // ─── Une `Période` PAR ANNÉE ───────────────────────────── // // C'est le point que la version globale manquait : la bonne @@ -1082,30 +1106,41 @@ export async function fetchNetworkMetricsPlaywright( await sleep(qlikSettleMs); } - const candidats: Array<{ texte: string; elem: number | null }> = [ - { texte: "(aucune)", elem: null }, - ...valeurs.map((v) => ({ texte: v.texte, elem: v.elem as number | null })), - ]; - let meilleure: { texte: string; elem: number | null; servis: string[] } | null = null; + // `null` = état sans `Période`, candidat comme un autre. + const candidats: Array = [null, ...valeurs.map((v) => v.texte)]; + let meilleure: { texte: string | null; servis: string[] } | null = null; for (const c of candidats) { - await appliquerPeriode(c.elem); - const mois = await mesurerCouverture(); - const servis = mois.fenetre.filter((m) => attendusAnnee.includes(m)); - diag( - " Période « " + c.texte + " » → sert " + servis.length + "/" + attendusAnnee.length + - " mois attendus " + JSON.stringify(servis) + " ; master exposée : " + resume(mois.tous), - ); + const libelle = c ?? "(aucune)"; + // Un candidat qui échoue ne doit pas emporter toute la + // cartographie : on le note et on passe au suivant. + let servis: string[] = []; + try { + const pose = await appliquerPeriode(c); + if (!pose && c !== null) { + diag(" Période « " + libelle + " » → non sélectionnable, ignorée"); + continue; + } + const mois = await mesurerCouverture(); + servis = mois.fenetre.filter((m) => attendusAnnee.includes(m)); + diag( + " Période « " + libelle + " » → sert " + servis.length + "/" + attendusAnnee.length + + " mois attendus " + JSON.stringify(servis) + " ; master exposée : " + resume(mois.tous), + ); + } catch (e) { + diag(" Période « " + libelle + " » → erreur " + String((e as Error)?.message || e)); + continue; + } if (!meilleure || servis.length > meilleure.servis.length) { - meilleure = { texte: c.texte, elem: c.elem, servis }; + meilleure = { texte: c, servis }; } if (servis.length >= attendusAnnee.length) break; // année complète } if (meilleure) { - periodeParAnnee.set(annee, { elem: meilleure.elem, texte: meilleure.texte }); + periodeParAnnee.set(annee, { texte: meilleure.texte }); for (const m of meilleure.servis) couvertsGlobal.add(m); diag( - " → année " + annee + " sera extraite sous Période « " + meilleure.texte + " » (" + + " → année " + annee + " sera extraite sous Période « " + (meilleure.texte ?? "(aucune)") + " » (" + meilleure.servis.length + "/" + attendusAnnee.length + " mois)", ); } @@ -1762,8 +1797,8 @@ export async function fetchNetworkMetricsPlaywright( // une année révolue (cf. choisirPeriode). const periodeAnnee = periodeParAnnee.get(selectYear); if (periodeAnnee) { - console.log("[qlik-pw] (mois) Période « " + periodeAnnee.texte + " » pour l'année " + selectYear); - await appliquerPeriode(periodeAnnee.elem); + console.log("[qlik-pw] (mois) Période « " + (periodeAnnee.texte ?? "(aucune)") + " » pour l'année " + selectYear); + await appliquerPeriode(periodeAnnee.texte); } await rpc("Clear", {}, yfh); From b6225f19208de51fcccebc40e52307f44a2765af Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 07:24:56 +0000 Subject: [PATCH 27/34] =?UTF-8?q?Qlik:=20la=20sync=2012=20mois=20passe=20?= =?UTF-8?q?=E2=80=94=20sonde=20fournisseur=20bornee,=20commentaires=20rect?= =?UTF-8?q?ifies?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Premier run reussi : 12 mois couverts, 40 411 produits ecrits dans le cache, statut final=success. Ce que la sonde paginee montre enfin : Periode « (aucune) » → master exposee : 2024-01…2026-07 (31 mois) Sans aucune Periode selectionnee, « Quantite N » couvre 31 mois, et une seule passe (Annee=2026) sert les 12 mois de la fenetre. L'etat libre etait donc le bon depuis le debut ; ce sont deux defauts de l'outil de MESURE qui le cachaient — la sonde tronquee a 60 lignes, puis le Clear appele sur le list object. Le commentaire de choisirPeriode affirmait l'inverse (« sans Periode la master est morte ») : il est rectifie, avec la mesure a l'appui. Sonde fournisseur : elle lisait 1 207 205 codes en 240 pages avant de conclure, trois fois de suite — 87 s pour un rejet joue d'avance. La lecture s'arrete desormais des le plafond depasse. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DfqUihgixw4K1AmJhizWiu --- src/lib/qlik-playwright.ts | 48 ++++++++++++++++++++++++-------------- 1 file changed, 31 insertions(+), 17 deletions(-) diff --git a/src/lib/qlik-playwright.ts b/src/lib/qlik-playwright.ts index 40fa402..eb20117 100644 --- a/src/lib/qlik-playwright.ts +++ b/src/lib/qlik-playwright.ts @@ -850,6 +850,12 @@ export async function fetchNetworkMetricsPlaywright( const selectionnerParFournisseur = async (): Promise => { if (!fournisseurCode || codes.length === 0) return false; const attendus = new Set(codes.map((c) => c.trim())); + /** + * Au-delà de ce nombre de codes visibles, la sélection fournisseur + * ne restreint rien (elle laisse tout le catalogue) : inutile de + * finir la lecture, la réponse est déjà connue. + */ + const plafond = attendus.size * 3; /** Codes articles visibles sous la sélection courante. */ const lireCodesVisibles = async (): Promise> => { @@ -882,6 +888,11 @@ export async function fetchNetworkMetricsPlaywright( const c = String(r[0]?.qText ?? "").trim(); if (c && c !== "-") vus.add(c); } + // Inutile de lire tout le catalogue pour conclure : au-delà + // du plafond, la sélection ne restreint manifestement rien. + // Mesuré : 1 207 205 codes lus en 240 pages, ×3 champs = + // 87 s perdues avant un rejet joué d'avance. + if (vus.size > plafond) break; } return vus; } finally { @@ -918,9 +929,9 @@ export async function fetchNetworkMetricsPlaywright( // 1 207 205 articles visibles, soit tout le catalogue — la // sélection n'avait rien restreint, et la « couverture » de 97 % // ne faisait que constater que le catalogue contient nos codes. - if (visibles.size > attendus.size * 3) { + if (visibles.size > plafond) { diag( - " champ « " + champ + " » = « " + fournisseurCode + " » → " + visibles.size + + " champ « " + champ + " » = « " + fournisseurCode + " » → plus de " + plafond + " articles visibles pour " + attendus.size + " demandés : la sélection ne restreint rien, ignorée", ); await rpc("Clear", {}, h); @@ -980,23 +991,26 @@ export async function fetchNetworkMetricsPlaywright( /** * Choisit la valeur de `Période` sous laquelle l'extraction aura lieu. * - * ⚠️ Correction majeure. La version précédente mesurait la couverture - * d'une `Période` **sans sélection d'année**, puis effaçait `Période` - * si aucune valeur ne couvrait les 12 mois. Or : + * CE QUE DIT LA MESURE (production, 2026-08-01, sonde paginée) : * - * - sans `Période`, `Type_Cal` ne résout rien : « Quantité N » est - * morte partout (mesuré : couverture 0/12). Les passes annuelles - * renvoyaient bien 270 056 lignes, mais **toutes mesures nulles** — - * d'où « 0 point mensuel ajouté » et le refus final ; - * - « Année à date » ne vaut « janvier → aujourd'hui » que pour - * l'année EN COURS. Avec `Année=2025` sélectionnée, la même valeur - * donne l'année 2025 COMPLÈTE — c'est exactement ce que montre - * l'app : « Période d'analyse : Du 02/01/2025 au 31/12/2025 ». + * Période « (aucune) » → master exposée : 2024-01…2026-07 (31 mois) * - * La couverture d'une `Période` ne veut donc rien dire hors du contexte - * d'année dans lequel elle sera utilisée. On la mesure désormais comme - * l'**union des passes annuelles** — 2025 puis 2026 — c'est-à-dire - * exactement ce que fera `monthDimPath`. + * Sans aucune `Période` sélectionnée, « Quantité N » couvre **31 mois**, + * et une seule passe (`Année=2026`) sert les 12 mois de la fenêtre. + * L'état libre est donc le bon — encore fallait-il pouvoir le voir. + * + * Deux erreurs successives l'avaient masqué, toutes deux dans l'outil + * de mesure et non dans le modèle Qlik : + * 1. la sonde ne lisait que les 60 premières lignes du cube `[Mois]`, + * donc jamais les mois récents → « 0/12 » pour toute valeur ; + * 2. `Clear` était appelée sur le list object au lieu du champ, ce qui + * faisait avorter la cartographie entière avant son premier essai. + * + * D'où le principe retenu ici : **ne rien présumer, tout mesurer**. La + * couverture est évaluée par année et dans le contexte exact où elle + * sera exploitée (`monthDimPath` pose la même valeur), l'état sans + * `Période` étant un candidat comme un autre — et, en l'occurrence, le + * gagnant. */ const choisirPeriode = async (annees: number[]): Promise => { const fenetre = new Set(monthlyPayload.map((m) => m.label)); From abc704a363ef1fef3ffd7bed9649f48920881be9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 09:07:39 +0000 Subject: [PATCH 28/34] Carte tendance : deuxieme courbe du nombre de magasins vendeurs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Une quantite qui monte parce que le produit est diffuse dans plus de magasins ne raconte pas la meme histoire qu'une quantite qui monte a diffusion constante. La courbe des quantites seule ne permettait pas de distinguer un succes produit d'un simple elargissement. - computeStoresSeries() : serie « magasins vendeurs » sur la MEME fenetre 12 mois que la tendance, avec la meme regle du tout ou rien — les 12 cles doivent etre presentes, sinon null. Un mois non extrait ne doit pas se lire comme « zero magasin », ce qui simulerait un arret de diffusion. - nbMagReseauByMonth remonte du cache (metricsByMonth, deja stocke par la sync) jusqu'a la ligne de grille. - NetworkLineChart accepte une serie `stores` optionnelle. DEUX ECHELLES INDEPENDANTES : les quantites se comptent en milliers, les magasins plafonnent a ~270. Sur une echelle commune la courbe des magasins serait ecrasee sur l'axe et ne montrerait rien. Chaque serie est normalisee sur son propre maximum, rappele dans la legende — c'est la FORME des deux courbes qu'on compare, pas leurs hauteurs. La courbe des magasins est pointillee et indigo, pour ne jamais entrer en collision avec le vert/rouge/gris de la tendance. Branchee sur la carte de la Grille et sur celle de la fiche produit. La sparkline de la colonne reste a une seule courbe : 46x18 px, deux traces y seraient illisibles. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DfqUihgixw4K1AmJhizWiu --- src/app/(dashboard)/produits/fiche.tsx | 20 ++++- src/features/grid/api/get-product-rows.ts | 20 +++++ src/features/grid/components/heatmap-grid.tsx | 6 +- .../grid/components/network-charts.tsx | 87 +++++++++++++++++-- src/features/grid/lib/network-trend.ts | 27 ++++++ src/types/grid.ts | 8 ++ 6 files changed, 160 insertions(+), 8 deletions(-) diff --git a/src/app/(dashboard)/produits/fiche.tsx b/src/app/(dashboard)/produits/fiche.tsx index 78f4499..ebbb27d 100644 --- a/src/app/(dashboard)/produits/fiche.tsx +++ b/src/app/(dashboard)/produits/fiche.tsx @@ -9,7 +9,7 @@ import { } 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 { computeNetworkTrend, computeStoresSeries, trendLabel, TREND_COLOR, NB_MAGASINS_RESEAU } from "@/features/grid/lib/network-trend"; import { formatMonthLabel, formatDate, SITE_LABELS, ffMonthToQlik } from "@/features/grid/lib/months"; import { useQlikSyncJob } from "@/features/qlik-sync/use-qlik-sync-job"; import { computeComparatif, indiceVerdict, normalizeMargePct } from "@/features/produits/lib/compare-reseau"; @@ -149,6 +149,17 @@ export function ProduitFicheView({ fiche, backQuery }: { fiche: ProduitFiche; ba 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]); + /** Nombre de magasins vendeurs par mois — deuxième courbe de la carte tendance. */ + const magasinsParMois = useMemo(() => { + const detailMensuel = reseau?.metricsByMonth; + if (!detailMensuel) return null; + const parMois: Record = {}; + for (const [mois, mesures] of Object.entries(detailMensuel)) { + const nb = Number(mesures?.nbMag); + if (Number.isFinite(nb)) parMois[mois] = nb; + } + return computeStoresSeries(parMois); + }, [reseau]); const comparatif = useMemo(() => computeComparatif(fiche), [fiche]); const verdict = indiceVerdict(comparatif.indiceQte); @@ -280,7 +291,12 @@ export function ProduitFicheView({ fiche, backQuery }: { fiche: ProduitFiche; ba
Quantités vendues réseau, mois par mois
- + )} diff --git a/src/features/grid/api/get-product-rows.ts b/src/features/grid/api/get-product-rows.ts index 38456e6..1e36831 100644 --- a/src/features/grid/api/get-product-rows.ts +++ b/src/features/grid/api/get-product-rows.ts @@ -484,6 +484,25 @@ async function reconcileSelectedStoreFromMensuelApi( } } +/** + * Extrait la série « nombre de magasins vendeurs » du détail mensuel complet. + * + * `metricsByMonth` porte les cinq mesures Qlik du mois ; seule `nbMag` sert à la + * deuxième courbe de la carte tendance. Renvoie `null` plutôt qu'un objet vide + * pour que l'UI distingue « pas de donnée » de « zéro magasin ». + */ +function nbMagParMois( + metricsByMonth: Record | null | undefined, +): Record | null { + if (!metricsByMonth) return null; + const parMois: Record = {}; + for (const [mois, mesures] of Object.entries(metricsByMonth)) { + const nb = Number(mesures?.nbMag); + if (Number.isFinite(nb)) parMois[mois] = nb; + } + return Object.keys(parMois).length > 0 ? parMois : null; +} + /** * Enrichit les produits avec les metriques reseau Qlik (cache qlik_network_metrics), * jointes par code centrale. Degradation propre si la sync Qlik n'a jamais tourne @@ -514,6 +533,7 @@ async function enrichWithNetworkMetrics(productMap: Map): Pr product.margePctReseau = m.margePctReseau; product.tauxPresenceReseau = m.nbMagasinsReseau / NB_MAGASINS_RESEAU; product.qteReseauByMonth = m.qteByMonth ?? null; + product.nbMagReseauByMonth = nbMagParMois(m.metricsByMonth); product.networkFetchedAt = m.fetchedAt ?? undefined; } } diff --git a/src/features/grid/components/heatmap-grid.tsx b/src/features/grid/components/heatmap-grid.tsx index 4843eff..5873980 100644 --- a/src/features/grid/components/heatmap-grid.tsx +++ b/src/features/grid/components/heatmap-grid.tsx @@ -35,6 +35,7 @@ import { } from "@/features/grid/lib/months"; import { computeNetworkTrend, + computeStoresSeries, trendLabel, TREND_COLOR, } from "@/features/grid/lib/network-trend"; @@ -198,6 +199,9 @@ function NetworkMonthlyModal({ row, onClose }: { row: ProductRow; onClose: () => const trend = computeNetworkTrend(row.qteReseauByMonth); const { values, labels, direction, pct } = trend; const color = TREND_COLOR[direction]; + // Deuxième courbe : le nombre de magasins vendeurs, pour distinguer une + // hausse due à l'élargissement de la diffusion d'une vraie accélération. + const magasins = computeStoresSeries(row.nbMagReseauByMonth); return ( @@ -219,7 +223,7 @@ function NetworkMonthlyModal({ row, onClose }: { row: ProductRow; onClose: () => Pas encore de détail mensuel pour ce produit.
Relancez un Sync Qlik pour le remplir. ) : ( - + )}
); diff --git a/src/features/grid/components/network-charts.tsx b/src/features/grid/components/network-charts.tsx index 050061a..6542155 100644 --- a/src/features/grid/components/network-charts.tsx +++ b/src/features/grid/components/network-charts.tsx @@ -43,24 +43,95 @@ export function TrendSparkline({ trend }: { trend: NetworkTrend }) { ); } -/** 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; +/** + * Couleur de la courbe « magasins vendeurs ». + * + * Indigo choisi pour ne jamais entrer en collision avec les couleurs de + * tendance (vert / rouge / gris) portées par la courbe des quantités, et pour + * rester lisible sur fond clair comme sur fond sombre. + */ +export const STORES_COLOR = "#6366f1"; + +/** + * Ventes réseau mensuelles, avec en option la courbe du nombre de magasins. + * + * ⚠️ DEUX ÉCHELLES INDÉPENDANTES. Les quantités se comptent en milliers, les + * magasins plafonnent à ~270 : sur une échelle commune la courbe des magasins + * serait écrasée sur l'axe et ne montrerait plus rien. Chaque série est donc + * normalisée sur son propre maximum, rappelé en haut du graphique — c'est la + * FORME des deux courbes qu'on compare, pas leurs hauteurs. + * + * Lecture : quantités qui montent avec les magasins = élargissement de la + * diffusion ; quantités qui montent à magasins constants = vraie accélération + * des ventes ; quantités qui baissent alors que les magasins tiennent = essoufflement. + */ +export function NetworkLineChart({ + labels, + values, + color, + stores, +}: { + labels: string[]; + values: number[]; + color: string; + /** Nombre de magasins vendeurs, même longueur et même ordre que `values`. */ + stores?: number[] | null; +}) { + const avecMagasins = Array.isArray(stores) && stores.length === values.length && stores.length > 0; + const W = 460, H = avecMagasins ? 224 : 200; + const padL = 10, padR = 10, padT = avecMagasins ? 40 : 22, padB = 30; const plotW = W - padL - padR, plotH = H - padT - padB; const n = values.length; const maxV = Math.max(...values, 1); + const maxS = avecMagasins ? Math.max(...stores!, 1) : 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 yS = (v: number) => padT + plotH - (v / maxS) * 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)}`; + const storePts = avecMagasins + ? stores!.map((v, i) => `${x(i).toFixed(1)},${yS(v).toFixed(1)}`).join(" ") + : ""; return (
- + {avecMagasins && ( +
+ + + Quantités vendues (max {maxV.toLocaleString("fr-FR")}) + + + + Magasins vendeurs (max {maxS.toLocaleString("fr-FR")}) + +
+ )} + {/* ligne de base */} {/* aire douce sous la courbe */} {n > 1 && } + {/* courbe des magasins, dessinée SOUS celle des quantités (série de contexte) */} + {avecMagasins && n > 1 && ( + + )} + {avecMagasins && stores!.map((v, i) => ( + + ))} {/* courbe */} {n > 1 && } {values.map((v, i) => ( @@ -74,6 +145,12 @@ export function NetworkLineChart({ labels, values, color }: { labels: string[]; {fmtMonthShort(labels[i])} + {/* nb de magasins sous l'axe, juste au-dessus du mois */} + {avecMagasins && ( + + {Math.round(stores![i]).toLocaleString("fr-FR")} + + )} ))} diff --git a/src/features/grid/lib/network-trend.ts b/src/features/grid/lib/network-trend.ts index 1bf3497..34e637b 100644 --- a/src/features/grid/lib/network-trend.ts +++ b/src/features/grid/lib/network-trend.ts @@ -110,6 +110,33 @@ export function computeNetworkTrend( return { values, labels, direction, pct, hasData: true }; } +/** + * Série « nombre de magasins vendeurs » sur la MÊME fenêtre que + * `computeNetworkTrend`, pour la superposer à la courbe des quantités. + * + * Pourquoi cette deuxième courbe : une quantité qui monte parce que le produit + * est diffusé dans plus de magasins ne raconte pas la même histoire qu'une + * quantité qui monte à diffusion constante. Sans elle, la tendance seule ne + * permet pas de distinguer un succès produit d'un simple élargissement. + * + * Même règle du tout ou rien que la courbe des quantités : les 12 clés doivent + * être explicitement présentes, sinon `null` — un mois non extrait ne doit pas + * se lire comme « zéro magasin », ce qui simulerait un arrêt de diffusion. + */ +export function computeStoresSeries( + nbMagByMonth?: Record | null, + now: Date = new Date(), +): { values: number[]; labels: string[] } | null { + if (!nbMagByMonth) return null; + const labels = buildRolling12QlikMonths(now); + const complet = labels.every((label) => + Object.prototype.hasOwnProperty.call(nbMagByMonth, label) && + Number.isFinite(Number(nbMagByMonth[label])), + ); + if (!complet) return null; + return { values: labels.map((l) => Number(nbMagByMonth[l])), labels }; +} + /** Libellé français de la tendance : « Forte hausse », « Baisse », « Stable »… */ export function trendLabel(pct: number | null): string { if (pct == null) return "Stable"; diff --git a/src/types/grid.ts b/src/types/grid.ts index 6b6336a..43f5cc5 100644 --- a/src/types/grid.ts +++ b/src/types/grid.ts @@ -121,6 +121,14 @@ export interface ProductRow { tauxPresenceReseau?: number; /** Quantité vendue réseau par mois : { "YYYY-MM": qté } (tendance / modal). */ qteReseauByMonth?: Record | null; + /** + * Nombre de magasins vendeurs par mois : { "YYYY-MM": nb }. + * + * Deuxième courbe de la carte tendance : une quantité qui monte parce que le + * produit est diffusé dans plus de magasins ne raconte pas la même histoire + * qu'une quantité qui monte à diffusion constante. + */ + nbMagReseauByMonth?: Record | null; /** Fraîcheur des données réseau (ISO) */ networkFetchedAt?: string; } From 28fe78a646eede53eac478e7d858774c2bf36b72 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 09:10:31 +0000 Subject: [PATCH 29/34] Carte tendance : garantir que la courbe magasins se distingue des quantites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La courbe des quantites prend la couleur de la TENDANCE : vert, rouge, ou gris-bleu (#94a3b8) quand elle est stable. L'indigo des magasins se detache du vert et du rouge, mais il est trop proche du gris-bleu — or « stable » est le cas le plus frequent. storesColorFor() bascule sur l'ambre (#f59e0b) dans ce cas precis : gris froid contre orange chaud, impossible a confondre. Les deux series se distinguent desormais par TROIS canaux, pas seulement la couleur — qui ne suffit ni en impression noir et blanc, ni pour un daltonien : - teinte garantie differente, - trait pointille contre trait plein, - points evides contre points pleins. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DfqUihgixw4K1AmJhizWiu --- .../grid/components/network-charts.tsx | 48 ++++++++++++++----- 1 file changed, 37 insertions(+), 11 deletions(-) diff --git a/src/features/grid/components/network-charts.tsx b/src/features/grid/components/network-charts.tsx index 6542155..14c6382 100644 --- a/src/features/grid/components/network-charts.tsx +++ b/src/features/grid/components/network-charts.tsx @@ -43,14 +43,25 @@ export function TrendSparkline({ trend }: { trend: NetworkTrend }) { ); } -/** - * Couleur de la courbe « magasins vendeurs ». - * - * Indigo choisi pour ne jamais entrer en collision avec les couleurs de - * tendance (vert / rouge / gris) portées par la courbe des quantités, et pour - * rester lisible sur fond clair comme sur fond sombre. - */ +/** Couleur par défaut de la courbe « magasins vendeurs » : indigo franc. */ export const STORES_COLOR = "#6366f1"; +/** Repli chaud, quand la courbe des quantités est elle-même dans les tons froids. */ +export const STORES_COLOR_ALT = "#f59e0b"; + +/** + * Couleur de la courbe « magasins » garantie DISTINCTE de celle des quantités. + * + * La courbe des quantités prend la couleur de la tendance : vert (#22c55e), + * rouge (#ef4444) ou gris-bleu (#94a3b8) quand elle est stable. L'indigo se + * détache nettement du vert et du rouge, mais il est trop proche du gris-bleu — + * or « stable » est le cas le plus fréquent. Dans ce cas précis on bascule sur + * l'ambre : gris froid contre orange chaud, impossible à confondre. + */ +export function storesColorFor(couleurQuantites: string): string { + return couleurQuantites.toLowerCase() === TREND_COLOR.flat.toLowerCase() + ? STORES_COLOR_ALT + : STORES_COLOR; +} /** * Ventes réseau mensuelles, avec en option la courbe du nombre de magasins. @@ -61,6 +72,11 @@ export const STORES_COLOR = "#6366f1"; * normalisée sur son propre maximum, rappelé en haut du graphique — c'est la * FORME des deux courbes qu'on compare, pas leurs hauteurs. * + * Les deux séries se distinguent par TROIS canaux, et pas seulement la couleur + * (qui ne suffit ni en impression noir et blanc, ni pour un daltonien) : teinte + * garantie différente (`storesColorFor`), trait pointillé contre trait plein, et + * points évidés contre points pleins. + * * Lecture : quantités qui montent avec les magasins = élargissement de la * diffusion ; quantités qui montent à magasins constants = vraie accélération * des ventes ; quantités qui baissent alors que les magasins tiennent = essoufflement. @@ -89,6 +105,7 @@ export function NetworkLineChart({ const yS = (v: number) => padT + plotH - (v / maxS) * 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)}`; + const storesColor = storesColorFor(color); const storePts = avecMagasins ? stores!.map((v, i) => `${x(i).toFixed(1)},${yS(v).toFixed(1)}`).join(" ") : ""; @@ -101,7 +118,7 @@ export function NetworkLineChart({ Quantités vendues (max {maxV.toLocaleString("fr-FR")}) - + Magasins vendeurs (max {maxS.toLocaleString("fr-FR")})
@@ -122,15 +139,24 @@ export function NetworkLineChart({ )} + {/* points évidés : distincts des points pleins des quantités, même sans couleur */} {avecMagasins && stores!.map((v, i) => ( - + ))} {/* courbe */} {n > 1 && } @@ -147,7 +173,7 @@ export function NetworkLineChart({ {/* nb de magasins sous l'axe, juste au-dessus du mois */} {avecMagasins && ( - + {Math.round(stores![i]).toLocaleString("fr-FR")} )} From bf6fef6a1443464885b70acda08131198f2a611f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 09:41:25 +0000 Subject: [PATCH 30/34] Carte tendance : deux bandes separees, indicateur lisible, courbe magasins reparee MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trois defauts signales, trois causes distinctes. 1. LA COURBE DES MAGASINS N'APPARAISSAIT PRESQUE JAMAIS. L'extracteur ecrivait `{ qte: 0 }` sur les mois sans vente, sans `nbMag`. La serie exigeant les 12 mois, elle disparaissait des qu'un produit avait un mois creux — c'est-a-dire presque toujours. Un mois PRESENT dans le detail est un mois extrait : s'il n'a pas de nbMag, c'est que personne n'a vendu, donc zero. Corrige a la source ET a la lecture, pour que les caches deja ecrits repartent sans re-sync. 2. LES DEUX COURBES ETAIENT ILLISIBLES SUPERPOSEES. Deux echelles independantes placaient la courbe des magasins AU-DESSUS de celle des quantites : cela se lit spontanement comme « il y a plus de magasins que de ventes », un contresens. Les series sont desormais tracees dans DEUX BANDES distinctes alignees sur le meme axe des mois. On compare la forme de l'une avec la forme de l'autre, sans jamais suggerer un rapport de grandeur. 3. LES POURCENTAGES N'AVAIENT PAS DE SENS. La pente d'une regression rapportee a la moyenne donnait « -124 % » (une baisse ne peut pas depasser -100 %), « +1 062 % » pour 2 unites vendues une fois dans l'annee, et surtout « +508 % » sur des dizaines de lignes d'affilee — la signature arithmetique d'une serie nulle partout sauf le dernier mois, ou le rapport ne depend meme plus des quantites. On compare maintenant deux moyennes de 4 mois : variation qui se lit comme telle, plancher a -100 %, insensible au bruit d'un mois isole. Sans base de comparaison, aucun pourcentage n'est invente — le produit est marque « Nouveau », et le tri le remonte en tete au lieu de l'enterrer. Le graphique accepte aussi des quantites negatives (retours superieurs aux ventes sur un mois) : l'echelle part du minimum reel, le point ne sort plus du cadre. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DfqUihgixw4K1AmJhizWiu --- src/app/(dashboard)/produits/fiche.tsx | 7 +- src/features/grid/api/get-product-rows.ts | 10 +- src/features/grid/components/heatmap-grid.tsx | 12 +- .../grid/components/network-charts.tsx | 188 +++++++++++------- src/features/grid/lib/network-trend.ts | 73 +++++-- src/lib/qlik-playwright.ts | 6 +- 6 files changed, 191 insertions(+), 105 deletions(-) diff --git a/src/app/(dashboard)/produits/fiche.tsx b/src/app/(dashboard)/produits/fiche.tsx index ebbb27d..e764601 100644 --- a/src/app/(dashboard)/produits/fiche.tsx +++ b/src/app/(dashboard)/produits/fiche.tsx @@ -155,8 +155,11 @@ export function ProduitFicheView({ fiche, backQuery }: { fiche: ProduitFiche; ba if (!detailMensuel) return null; const parMois: Record = {}; for (const [mois, mesures] of Object.entries(detailMensuel)) { + // Mois présent sans `nbMag` = mois extrait sans vente = zéro magasin + // (cf. get-product-rows.ts) : sinon la série est amputée et la courbe + // disparaît sur les anciens caches. const nb = Number(mesures?.nbMag); - if (Number.isFinite(nb)) parMois[mois] = nb; + parMois[mois] = Number.isFinite(nb) ? nb : 0; } return computeStoresSeries(parMois); }, [reseau]); @@ -271,7 +274,7 @@ export function ProduitFicheView({ fiche, backQuery }: { fiche: ProduitFiche; ba = 0 ? "+" : ""}${Math.round(trend.pct * 100)} %` : "—"} - hint={trendLabel(trend.pct)} + hint={trendLabel(trend.pct, trend.nouveau)} color={TREND_COLOR[trend.direction]} /> diff --git a/src/features/grid/api/get-product-rows.ts b/src/features/grid/api/get-product-rows.ts index 1e36831..b8a280f 100644 --- a/src/features/grid/api/get-product-rows.ts +++ b/src/features/grid/api/get-product-rows.ts @@ -496,9 +496,13 @@ function nbMagParMois( ): Record | null { if (!metricsByMonth) return null; const parMois: Record = {}; - for (const [mois, mesures] of Object.entries(metricsByMonth)) { - const nb = Number(mesures?.nbMag); - if (Number.isFinite(nb)) parMois[mois] = nb; + for (const [mois] of Object.entries(metricsByMonth)) { + // Un mois PRÉSENT dans le détail est un mois extrait : s'il n'a pas de + // `nbMag`, c'est qu'aucun magasin n'a vendu, donc zéro. Le traiter comme + // « manquant » amputait la série et faisait disparaître toute la courbe + // (les anciens caches n'écrivaient `nbMag` que sur les mois avec vente). + const nb = Number(metricsByMonth[mois]?.nbMag); + parMois[mois] = Number.isFinite(nb) ? nb : 0; } return Object.keys(parMois).length > 0 ? parMois : null; } diff --git a/src/features/grid/components/heatmap-grid.tsx b/src/features/grid/components/heatmap-grid.tsx index 5873980..b604cdc 100644 --- a/src/features/grid/components/heatmap-grid.tsx +++ b/src/features/grid/components/heatmap-grid.tsx @@ -211,9 +211,10 @@ function NetworkMonthlyModal({ row, onClose }: { row: ProductRow; onClose: () =>

Ventes réseau · {labels.length > 0 ? `${labels.length} derniers mois` : "12 derniers mois"} {row.nbMagasinsReseau != null && <> · {row.nbMagasinsReseau} magasins} - {pct != null && ( + {(pct != null || trend.nouveau) && ( <> · - {pct >= 0 ? "+" : ""}{Math.round(pct * 100)}% · {trendLabel(pct)} + {pct != null && <>{pct >= 0 ? "+" : ""}{Math.round(pct * 100)}% · } + {trendLabel(pct, trend.nouveau)} )}

@@ -564,7 +565,12 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) { id: "tendanceReseau", accessorFn: (row) => { const t = computeNetworkTrend(row.qteReseauByMonth); - return t.hasData && t.pct != null ? t.pct : Number.NEGATIVE_INFINITY; + if (!t.hasData) return Number.NEGATIVE_INFINITY; + // Un produit « nouveau » n'a pas de pourcentage mais c'est la plus + // forte progression possible : il doit remonter en tête du tri, pas + // tomber au fond avec les lignes sans données. + if (t.nouveau) return Number.POSITIVE_INFINITY; + return t.pct ?? Number.NEGATIVE_INFINITY; }, header: () =>
Tendance
Réseau · 12 m
, size: 78, diff --git a/src/features/grid/components/network-charts.tsx b/src/features/grid/components/network-charts.tsx index 14c6382..5f0b434 100644 --- a/src/features/grid/components/network-charts.tsx +++ b/src/features/grid/components/network-charts.tsx @@ -14,7 +14,7 @@ import { TREND_COLOR, type NetworkTrend } from "@/features/grid/lib/network-tren /** Sparkline compacte 12 mois + flèche, teintée selon la tendance. */ export function TrendSparkline({ trend }: { trend: NetworkTrend }) { if (!trend.hasData) return
-
; - const { values, direction, pct } = trend; + const { values, direction, pct, nouveau } = trend; const color = TREND_COLOR[direction]; const W = 46, H = 18, n = values.length; const max = Math.max(...values, 1), min = Math.min(...values, 0); @@ -26,7 +26,11 @@ export function TrendSparkline({ trend }: { trend: NetworkTrend }) { }).join(" "); const Arrow = direction === "up" ? TrendingUp : direction === "down" ? TrendingDown : Minus; return ( -
= 0 ? "+" : ""}${(pct * 100).toFixed(0)}% sur 12 mois (régression) — cliquer pour le détail` : "Tendance réseau — cliquer pour le détail"}> +
= 0 ? "+" : ""}${(pct * 100).toFixed(0)}% : 4 derniers mois vs 4 premiers — cliquer pour le détail` + : nouveau + ? "Nouveau : aucune vente sur les 4 premiers mois de la fenêtre — cliquer pour le détail" + : "Tendance réseau — cliquer pour le détail"}>
{n > 1 && } @@ -39,6 +43,9 @@ export function TrendSparkline({ trend }: { trend: NetworkTrend }) { {pct >= 0 ? "+" : ""}{Math.round(pct * 100)}% )} + {pct == null && nouveau && ( + Nouveau + )}
); } @@ -64,18 +71,20 @@ export function storesColorFor(couleurQuantites: string): string { } /** - * Ventes réseau mensuelles, avec en option la courbe du nombre de magasins. + * Ventes réseau mensuelles, avec en option le nombre de magasins vendeurs. * - * ⚠️ DEUX ÉCHELLES INDÉPENDANTES. Les quantités se comptent en milliers, les - * magasins plafonnent à ~270 : sur une échelle commune la courbe des magasins - * serait écrasée sur l'axe et ne montrerait plus rien. Chaque série est donc - * normalisée sur son propre maximum, rappelé en haut du graphique — c'est la - * FORME des deux courbes qu'on compare, pas leurs hauteurs. + * ⚠️ DEUX BANDES SÉPARÉES, PAS DEUX COURBES SUPERPOSÉES. * - * Les deux séries se distinguent par TROIS canaux, et pas seulement la couleur - * (qui ne suffit ni en impression noir et blanc, ni pour un daltonien) : teinte - * garantie différente (`storesColorFor`), trait pointillé contre trait plein, et - * points évidés contre points pleins. + * La superposition a été essayée et rejetée à l'usage : les quantités se + * comptent en milliers, les magasins plafonnent à ~270. Sur une échelle commune + * la courbe des magasins s'écrase sur l'axe ; sur deux échelles superposées elle + * passe AU-DESSUS de celle des quantités, ce qui se lit spontanément comme « il + * y a plus de magasins que de ventes » — un contresens. + * + * Les deux séries sont donc tracées dans deux bandes distinctes, alignées sur le + * même axe des mois. On compare ce qui est comparable — la forme de l'une avec + * la forme de l'autre, mois par mois — sans jamais suggérer un rapport de + * grandeur entre elles. * * Lecture : quantités qui montent avec les magasins = élargissement de la * diffusion ; quantités qui montent à magasins constants = vraie accélération @@ -94,35 +103,41 @@ export function NetworkLineChart({ stores?: number[] | null; }) { const avecMagasins = Array.isArray(stores) && stores.length === values.length && stores.length > 0; - const W = 460, H = avecMagasins ? 224 : 200; - const padL = 10, padR = 10, padT = avecMagasins ? 40 : 22, padB = 30; - const plotW = W - padL - padR, plotH = H - padT - padB; const n = values.length; - const maxV = Math.max(...values, 1); - const maxS = avecMagasins ? Math.max(...stores!, 1) : 1; + + const W = 460; + const padL = 10, padR = 10, padB = 22; + const titreH = 12; // hauteur réservée au titre d'une bande + const bandeQ = 120; // bande des quantités + const bandeM = avecMagasins ? 46 : 0; // bande des magasins + const ecart = avecMagasins ? 18 : 0; + const hautQ = titreH + 10; // sommet de la bande des quantités + const hautM = hautQ + bandeQ + ecart + titreH; + const H = hautQ + bandeQ + ecart + (avecMagasins ? titreH + bandeM : 0) + padB; + const plotW = W - padL - padR; + + // Les quantités Qlik peuvent être NÉGATIVES (retours supérieurs aux ventes + // sur un mois) : l'échelle part du minimum réel, sinon le point sortirait + // du cadre par le bas. + const minQ = Math.min(0, ...values); + const maxQ = Math.max(...values, 1); + const etendueQ = maxQ - minQ || 1; + const maxM = avecMagasins ? Math.max(...stores!, 1) : 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 yS = (v: number) => padT + plotH - (v / maxS) * plotH; + const y = (v: number) => hautQ + bandeQ - ((v - minQ) / etendueQ) * bandeQ; + const yM = (v: number) => hautM + bandeM - (v / maxM) * bandeM; + const zero = y(0); + 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)}`; + const areaPts = `${padL},${zero.toFixed(1)} ${linePts} ${(padL + plotW).toFixed(1)},${zero.toFixed(1)}`; const storesColor = storesColorFor(color); const storePts = avecMagasins - ? stores!.map((v, i) => `${x(i).toFixed(1)},${yS(v).toFixed(1)}`).join(" ") + ? stores!.map((v, i) => `${x(i).toFixed(1)},${yM(v).toFixed(1)}`).join(" ") : ""; + return (
- {avecMagasins && ( -
- - - Quantités vendues (max {maxV.toLocaleString("fr-FR")}) - - - - Magasins vendeurs (max {maxS.toLocaleString("fr-FR")}) - -
- )} - {/* ligne de base */} - - {/* aire douce sous la courbe */} + {/* ─── Bande 1 : quantités ─────────────────────────────── */} + + QUANTITÉS VENDUES + + {n > 1 && } - {/* courbe des magasins, dessinée SOUS celle des quantités (série de contexte) */} - {avecMagasins && n > 1 && ( - - )} - {/* points évidés : distincts des points pleins des quantités, même sans couleur */} - {avecMagasins && stores!.map((v, i) => ( - - ))} - {/* courbe */} {n > 1 && } {values.map((v, i) => ( - + - {/* quantité au-dessus du point */} - + = 0 ? y(v) - 6 : y(v) + 12} + textAnchor="middle" + fontSize={9} + fontWeight={700} + fill="var(--text-primary)" + > {Math.round(v).toLocaleString("fr-FR")} - {/* mois sous l'axe */} - - {fmtMonthShort(labels[i])} - - {/* nb de magasins sous l'axe, juste au-dessus du mois */} - {avecMagasins && ( - - {Math.round(stores![i]).toLocaleString("fr-FR")} - - )} ))} + + {/* ─── Bande 2 : magasins vendeurs ─────────────────────── */} + {avecMagasins && ( + <> + + MAGASINS VENDEURS + · {maxM.toLocaleString("fr-FR")} au maximum + + + {n > 1 && ( + + )} + {stores!.map((v, i) => ( + + {/* point évidé : lisible même sans couleur */} + + + {Math.round(v).toLocaleString("fr-FR")} + + + ))} + + )} + + {/* ─── Axe des mois, commun aux deux bandes ────────────── */} + {labels.map((lab, i) => ( + + {fmtMonthShort(lab)} + + ))}
); diff --git a/src/features/grid/lib/network-trend.ts b/src/features/grid/lib/network-trend.ts index 34e637b..8069f42 100644 --- a/src/features/grid/lib/network-trend.ts +++ b/src/features/grid/lib/network-trend.ts @@ -16,10 +16,21 @@ 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 + /** Variation des 4 derniers mois vs les 4 premiers. `null` si aucune base. */ + pct: number | null; + /** Aucune vente sur les 4 premiers mois, des ventes sur les 4 derniers. */ + nouveau: boolean; hasData: boolean; }; +/** + * Nombre de mois comparés à chaque bout de la fenêtre. + * + * Quatre, et pas un : comparer le dernier mois au premier ferait dépendre toute + * la tendance de deux points, donc du hasard d'un réassort ou d'une opération. + */ +export const TREND_WINDOW = 4; + /** Seuil de "forte" variation (±25%) pour distinguer hausse/forte hausse dans l'UI. */ export const TREND_STRONG = 0.25; @@ -51,8 +62,8 @@ export const TREND_COLOR: Record = { }; /** - * Tendance réseau par RÉGRESSION LINÉAIRE (moindres carrés) sur les **12 mois - * complets glissants, mois en cours exclu**. + * Tendance réseau : variation entre les **4 premiers** et les **4 derniers** + * mois de la fenêtre de 12 mois glissants, mois en cours exclu. * * La fenêtre est reconstruite à partir de la date du jour (et non des clés * présentes dans `qteByMonth`) : c'est le seul moyen de garantir une tendance @@ -70,15 +81,28 @@ export const TREND_COLOR: Record = { * article vendu depuis mai), et des mois non extraits comptés comme des mois * sans vente. * - * 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. + * ⚠️ CHANGEMENT D'INDICATEUR. La version précédente rapportait la pente d'une + * régression linéaire à la moyenne de la série. Mathématiquement défendable, + * mais illisible en pratique — constaté en production : + * + * - « −124 % », alors qu'une baisse ne peut pas dépasser −100 % ; + * - « +508 % » sur des dizaines de lignes d'affilée, toujours la même valeur : + * c'est la signature arithmétique d'une série nulle partout sauf le dernier + * mois, où le rapport pente/moyenne ne dépend même plus des quantités ; + * - « +1 062 % » pour 2 unités vendues une seule fois dans l'année. + * + * On compare donc désormais deux moyennes de 4 mois, ce qui donne une variation + * qui se lit comme telle, plancher à −100 %, et reste insensible au bruit d'un + * mois isolé. Sans base de comparaison (rien vendu sur les 4 premiers mois), + * aucun pourcentage n'est inventé : le produit est signalé « nouveau ». + * * Direction : forte hausse >+25%, hausse >+8%, stable, baisse <−8%, forte baisse <−25%. */ export function computeNetworkTrend( qteByMonth?: Record | null, now: Date = new Date(), ): NetworkTrend { - const empty: NetworkTrend = { values: [], labels: [], direction: "flat", pct: null, hasData: false }; + const empty: NetworkTrend = { values: [], labels: [], direction: "flat", pct: null, nouveau: false, hasData: false }; if (!qteByMonth) return empty; const labels = buildRolling12QlikMonths(now); const complet = labels.every((label) => @@ -89,25 +113,28 @@ export function computeNetworkTrend( const values = labels.map((l) => Number(qteByMonth[l])); const n = values.length; + const taille = Math.min(TREND_WINDOW, Math.floor(n / 2)); + const moyenne = (xs: number[]) => (xs.length ? xs.reduce((s, v) => s + v, 0) / xs.length : 0); + const debut = moyenne(values.slice(0, taille)); + const fin = moyenne(values.slice(n - taille)); + let pct: number | null = null; + let nouveau = false; 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]; + + if (debut > 0) { + // Plancher naturel à −100 % : `fin` ne peut pas descendre sous zéro sans + // retours massifs, et la division par une base positive garde le sens. + pct = (fin - debut) / debut; direction = pct > TREND_FLAT ? "up" : pct < -TREND_FLAT ? "down" : "flat"; + } else if (fin > 0) { + // Rien au départ, des ventes à l'arrivée : la variation relative n'existe + // pas (division par zéro). On le dit, au lieu d'afficher un nombre inventé. + nouveau = true; + direction = "up"; } - return { values, labels, direction, pct, hasData: true }; + + return { values, labels, direction, pct, nouveau, hasData: true }; } /** @@ -138,7 +165,9 @@ export function computeStoresSeries( } /** Libellé français de la tendance : « Forte hausse », « Baisse », « Stable »… */ -export function trendLabel(pct: number | null): string { +export function trendLabel(pct: number | null, nouveau = false): string { + // Un produit sans base de comparaison n'est pas « stable » : il est nouveau. + if (nouveau) return "Nouveau"; if (pct == null) return "Stable"; if (pct > TREND_STRONG) return "Forte hausse"; if (pct > TREND_FLAT) return "Hausse"; diff --git a/src/lib/qlik-playwright.ts b/src/lib/qlik-playwright.ts index eb20117..75848d5 100644 --- a/src/lib/qlik-playwright.ts +++ b/src/lib/qlik-playwright.ts @@ -2412,7 +2412,11 @@ export async function fetchNetworkMetricsPlaywright( // information, au même titre qu'une vente. Un mois non couvert // reste absent et sera ignoré par la tendance. qteByMonth[mois] = Number(v?.qte) || 0; - metricsByMonth[mois] = v ?? { qte: 0 }; + // ⚠️ Toutes les mesures à zéro, pas seulement la quantité. Écrire + // `{ qte: 0 }` seul laissait `nbMag` absent sur les mois sans + // vente : la courbe « magasins vendeurs » exigeant les 12 mois, + // elle disparaissait dès qu'un produit avait un mois creux. + metricsByMonth[mois] = v ?? { qte: 0, ca: 0, nbMag: 0, caMag: 0, margePct: 0 }; } metric.qteByMonth = qteByMonth; metric.metricsByMonth = metricsByMonth; From e6e79dfe06249016f05c2f5e680d6f5591fa5f04 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 09:46:15 +0000 Subject: [PATCH 31/34] Carte tendance : desencombrer le graphique, il etait illisible La densite etait le vrai probleme. La version precedente imprimait les 24 valeurs (12 quantites + 12 magasins) et titrait chaque bande DANS le SVG. Resultat sur ecran : les titres passaient par-dessus les courbes, les nombres des extremites etaient coupes par le bord du cadre, et les etiquettes des magasins se collaient au trait pointille. - Les titres sortent du dessin : legende HTML au-dessus, plus aucun recouvrement possible quelle que soit la forme des courbes. - La bande des magasins ne porte plus que sa DERNIERE valeur ; son niveau se lit a la forme et au maximum rappele dans la legende. - Les etiquettes des bords sont ancrees vers l'interieur (start / end au lieu de middle) : plus rien n'est tronque. - Chaque mois expose une infobulle native avec les valeurs exactes, ce qui rend inutile d'imprimer tous les nombres. - Cadre elargi (520 de large, marge haute pour les etiquettes du sommet) et modale passee en max-w-xl. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DfqUihgixw4K1AmJhizWiu --- src/features/grid/components/heatmap-grid.tsx | 2 +- .../grid/components/network-charts.tsx | 127 ++++++++++++------ 2 files changed, 86 insertions(+), 43 deletions(-) diff --git a/src/features/grid/components/heatmap-grid.tsx b/src/features/grid/components/heatmap-grid.tsx index b604cdc..325b11d 100644 --- a/src/features/grid/components/heatmap-grid.tsx +++ b/src/features/grid/components/heatmap-grid.tsx @@ -203,7 +203,7 @@ function NetworkMonthlyModal({ row, onClose }: { row: ProductRow; onClose: () => // hausse due à l'élargissement de la diffusion d'une vraie accélération. const magasins = computeStoresSeries(row.nbMagReseauByMonth); return ( - + {row.libelle1} diff --git a/src/features/grid/components/network-charts.tsx b/src/features/grid/components/network-charts.tsx index 5f0b434..776a1cc 100644 --- a/src/features/grid/components/network-charts.tsx +++ b/src/features/grid/components/network-charts.tsx @@ -81,10 +81,15 @@ export function storesColorFor(couleurQuantites: string): string { * passe AU-DESSUS de celle des quantités, ce qui se lit spontanément comme « il * y a plus de magasins que de ventes » — un contresens. * - * Les deux séries sont donc tracées dans deux bandes distinctes, alignées sur le - * même axe des mois. On compare ce qui est comparable — la forme de l'une avec - * la forme de l'autre, mois par mois — sans jamais suggérer un rapport de - * grandeur entre elles. + * ⚠️ LA DENSITÉ EST LE VRAI ENNEMI. Une première version imprimait les 24 + * valeurs (12 quantités + 12 magasins) et titrait chaque bande dans le SVG : + * titres par-dessus les courbes, nombres des extrémités coupés par le bord, + * étiquettes des magasins collées au trait. Illisible. D'où : + * - les titres sortent du dessin (HTML au-dessus, plus aucun recouvrement) ; + * - la bande des magasins ne porte plus que sa dernière valeur, son niveau se + * lisant à la forme et au maximum rappelé dans la légende ; + * - les étiquettes des bords sont ancrées vers l'intérieur, jamais tronquées ; + * - chaque mois expose une infobulle native avec les valeurs exactes. * * Lecture : quantités qui montent avec les magasins = élargissement de la * diffusion ; quantités qui montent à magasins constants = vraie accélération @@ -105,15 +110,14 @@ export function NetworkLineChart({ const avecMagasins = Array.isArray(stores) && stores.length === values.length && stores.length > 0; const n = values.length; - const W = 460; - const padL = 10, padR = 10, padB = 22; - const titreH = 12; // hauteur réservée au titre d'une bande - const bandeQ = 120; // bande des quantités - const bandeM = avecMagasins ? 46 : 0; // bande des magasins - const ecart = avecMagasins ? 18 : 0; - const hautQ = titreH + 10; // sommet de la bande des quantités - const hautM = hautQ + bandeQ + ecart + titreH; - const H = hautQ + bandeQ + ecart + (avecMagasins ? titreH + bandeM : 0) + padB; + const W = 520; + const padL = 14, padR = 14; + const hautQ = 16; // marge haute : place aux étiquettes du sommet + const bandeQ = 116; + const ecart = avecMagasins ? 12 : 0; + const bandeM = avecMagasins ? 44 : 0; + const moisH = 18; + const H = hautQ + bandeQ + ecart + bandeM + moisH; const plotW = W - padL - padR; // Les quantités Qlik peuvent être NÉGATIVES (retours supérieurs aux ventes @@ -126,29 +130,49 @@ export function NetworkLineChart({ const x = (i: number) => (n > 1 ? padL + (i / (n - 1)) * plotW : padL + plotW / 2); const y = (v: number) => hautQ + bandeQ - ((v - minQ) / etendueQ) * bandeQ; - const yM = (v: number) => hautM + bandeM - (v / maxM) * bandeM; + const yM = (v: number) => hautQ + bandeQ + ecart + bandeM - (v / maxM) * bandeM; const zero = y(0); + /** Aux extrémités, on ancre l'étiquette vers l'intérieur : sinon elle sort du cadre. */ + const ancrage = (i: number): "start" | "middle" | "end" => + i === 0 ? "start" : i === n - 1 ? "end" : "middle"; + const linePts = values.map((v, i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`).join(" "); const areaPts = `${padL},${zero.toFixed(1)} ${linePts} ${(padL + plotW).toFixed(1)},${zero.toFixed(1)}`; const storesColor = storesColorFor(color); const storePts = avecMagasins ? stores!.map((v, i) => `${x(i).toFixed(1)},${yM(v).toFixed(1)}`).join(" ") : ""; + const fmt = (v: number) => Math.round(v).toLocaleString("fr-FR"); return (
+ {/* Titres HORS du dessin : plus rien ne recouvre les courbes. */} +
+ + + Quantités vendues + · max {fmt(maxQ)} + + {avecMagasins && ( + + + Magasins vendeurs + · max {fmt(maxM)} + + )} +
{/* ─── Bande 1 : quantités ─────────────────────────────── */} - - QUANTITÉS VENDUES - {n > 1 && } {n > 1 && } @@ -158,12 +182,12 @@ export function NetworkLineChart({ = 0 ? y(v) - 6 : y(v) + 12} - textAnchor="middle" - fontSize={9} + textAnchor={ancrage(i)} + fontSize={8.5} fontWeight={700} fill="var(--text-primary)" > - {Math.round(v).toLocaleString("fr-FR")} + {fmt(v)} ))} @@ -171,15 +195,11 @@ export function NetworkLineChart({ {/* ─── Bande 2 : magasins vendeurs ─────────────────────── */} {avecMagasins && ( <> - - MAGASINS VENDEURS - · {maxM.toLocaleString("fr-FR")} au maximum - @@ -195,30 +215,53 @@ export function NetworkLineChart({ /> )} {stores!.map((v, i) => ( - - {/* point évidé : lisible même sans couleur */} - - - {Math.round(v).toLocaleString("fr-FR")} - - + // Point évidé : lisible même sans couleur (impression N&B, daltonisme). + ))} + {/* Seule la dernière valeur est écrite : le reste se lit à la forme. */} + + {fmt(stores![n - 1])} mag. + )} {/* ─── Axe des mois, commun aux deux bandes ────────────── */} {labels.map((lab, i) => ( - + {fmtMonthShort(lab)} ))} + + {/* Zones de survol : valeurs exactes du mois, sans encombrer le dessin. */} + {labels.map((lab, i) => ( + + + {`${fmtMonthShort(lab)} · ${fmt(values[i])} vendues${avecMagasins ? ` · ${fmt(stores![i])} magasins` : ""}`} + + + ))}
); From eaac945345525826c05015cdb278e09f3cefb1ce Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 09:50:41 +0000 Subject: [PATCH 32/34] Tendance : la calculer sur la QTE PAR MAGASIN, pas sur le volume brut MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C'est le bon indicateur, et le volume brut ne pouvait pas le remplacer : il confond la performance du produit et sa diffusion. Un produit monte en volume des qu'on le reference ailleurs, sans mieux se vendre nulle part ; un produit retire de 60 magasins s'effondre en volume tout en performant mieux la ou il reste. Seule la qte/magasin repond a « ce produit est-il tendance ». - computeNetworkTrend prend le nombre de magasins par mois et calcule l'evolution sur qte/magasins vendeurs. Sans cette serie, repli sur le volume brut et `surQteParMagasin` a false, pour que l'UI le dise. - La serie remonte jusqu'a tous les appelants : grille, fiche produit et recherche produits (nbMagByMonth ajoute au resultat de recherche). - La carte affiche desormais trois bandes empilees sur le meme axe : qte/magasin en PRINCIPALE (couleur de la tendance, aire pleine), quantites vendues et magasins vendeurs en contexte. La teinte « tendance » ne designe plus qu'une seule chose dans le graphique ; les bandes de contexte ont des couleurs fixes. - Le pourcentage de la carte precise « (qte/magasin) » : un chiffre de tendance sans son assiette se prete a tous les malentendus. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DfqUihgixw4K1AmJhizWiu --- src/app/(dashboard)/produits/fiche.tsx | 15 +- src/app/(dashboard)/produits/results.tsx | 2 +- src/features/grid/components/heatmap-grid.tsx | 11 +- .../grid/components/network-charts.tsx | 295 +++++++++--------- src/features/grid/lib/network-trend.ts | 48 ++- src/features/produits/api/search-produits.ts | 21 ++ src/features/produits/types.ts | 5 + 7 files changed, 234 insertions(+), 163 deletions(-) diff --git a/src/app/(dashboard)/produits/fiche.tsx b/src/app/(dashboard)/produits/fiche.tsx index e764601..b011ce0 100644 --- a/src/app/(dashboard)/produits/fiche.tsx +++ b/src/app/(dashboard)/produits/fiche.tsx @@ -148,9 +148,8 @@ export function ProduitFicheView({ fiche, backQuery }: { fiche: ProduitFiche; ba 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]); - /** Nombre de magasins vendeurs par mois — deuxième courbe de la carte tendance. */ - const magasinsParMois = useMemo(() => { + /** Magasins vendeurs par mois — dénominateur de la tendance ET 3e bande du graphique. */ + const nbMagByMonth = useMemo(() => { const detailMensuel = reseau?.metricsByMonth; if (!detailMensuel) return null; const parMois: Record = {}; @@ -161,8 +160,13 @@ export function ProduitFicheView({ fiche, backQuery }: { fiche: ProduitFiche; ba const nb = Number(mesures?.nbMag); parMois[mois] = Number.isFinite(nb) ? nb : 0; } - return computeStoresSeries(parMois); + return parMois; }, [reseau]); + const trend = useMemo( + () => computeNetworkTrend(reseau?.qteByMonth ?? null, nbMagByMonth), + [reseau, nbMagByMonth], + ); + const magasinsParMois = useMemo(() => computeStoresSeries(nbMagByMonth), [nbMagByMonth]); const comparatif = useMemo(() => computeComparatif(fiche), [fiche]); const verdict = indiceVerdict(comparatif.indiceQte); @@ -292,13 +296,14 @@ export function ProduitFicheView({ fiche, backQuery }: { fiche: ProduitFiche; ba {trend.hasData && (
- Quantités vendues réseau, mois par mois + Tendance réseau — la qté/magasin d'abord, les volumes en contexte
)} diff --git a/src/app/(dashboard)/produits/results.tsx b/src/app/(dashboard)/produits/results.tsx index 1190f3e..4c1e823 100644 --- a/src/app/(dashboard)/produits/results.tsx +++ b/src/app/(dashboard)/produits/results.tsx @@ -313,7 +313,7 @@ function ResultTable({ rows, query }: { rows: ProduitRechercheRow[]; query: stri {rows.map((r) => { - const trend = computeNetworkTrend(r.qteByMonth); + const trend = computeNetworkTrend(r.qteByMonth, r.nbMagByMonth); const href = r.codein ? `/produits?codein=${encodeURIComponent(r.codein)}&q=${encodeURIComponent(query)}` : `/produits?cc=${encodeURIComponent(r.codeCentrale)}&q=${encodeURIComponent(query)}`; diff --git a/src/features/grid/components/heatmap-grid.tsx b/src/features/grid/components/heatmap-grid.tsx index 325b11d..e5c9b40 100644 --- a/src/features/grid/components/heatmap-grid.tsx +++ b/src/features/grid/components/heatmap-grid.tsx @@ -196,7 +196,7 @@ interface CellDetailData { /** 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 trend = computeNetworkTrend(row.qteReseauByMonth, row.nbMagReseauByMonth); const { values, labels, direction, pct } = trend; const color = TREND_COLOR[direction]; // Deuxième courbe : le nombre de magasins vendeurs, pour distinguer une @@ -215,6 +215,9 @@ function NetworkMonthlyModal({ row, onClose }: { row: ProductRow; onClose: () => <> · {pct != null && <>{pct >= 0 ? "+" : ""}{Math.round(pct * 100)}% · } {trendLabel(pct, trend.nouveau)} + {trend.surQteParMagasin && ( + (qté/magasin) + )} )}

@@ -224,7 +227,7 @@ function NetworkMonthlyModal({ row, onClose }: { row: ProductRow; onClose: () => Pas encore de détail mensuel pour ce produit.
Relancez un Sync Qlik pour le remplir.
) : ( - + )} ); @@ -564,7 +567,7 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) { { id: "tendanceReseau", accessorFn: (row) => { - const t = computeNetworkTrend(row.qteReseauByMonth); + const t = computeNetworkTrend(row.qteReseauByMonth, row.nbMagReseauByMonth); if (!t.hasData) return Number.NEGATIVE_INFINITY; // Un produit « nouveau » n'a pas de pourcentage mais c'est la plus // forte progression possible : il doit remonter en tête du tri, pas @@ -575,7 +578,7 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) { header: () =>
Tendance
Réseau · 12 m
, size: 78, cell: ({ row }) => { - const trend = computeNetworkTrend(row.original.qteReseauByMonth); + const trend = computeNetworkTrend(row.original.qteReseauByMonth, row.original.nbMagReseauByMonth); if (!trend.hasData) return
-
; // Cliquable → modal des ventes réseau mois par mois (12 derniers mois). return ( diff --git a/src/features/grid/components/network-charts.tsx b/src/features/grid/components/network-charts.tsx index 776a1cc..5529723 100644 --- a/src/features/grid/components/network-charts.tsx +++ b/src/features/grid/components/network-charts.tsx @@ -50,197 +50,195 @@ export function TrendSparkline({ trend }: { trend: NetworkTrend }) { ); } -/** Couleur par défaut de la courbe « magasins vendeurs » : indigo franc. */ -export const STORES_COLOR = "#6366f1"; -/** Repli chaud, quand la courbe des quantités est elle-même dans les tons froids. */ -export const STORES_COLOR_ALT = "#f59e0b"; +/** Courbe des quantités vendues (contexte) — bleu ciel. */ +export const QTY_COLOR = "#0ea5e9"; +/** Courbe des magasins vendeurs (contexte) — violet. */ +export const STORES_COLOR = "#8b5cf6"; -/** - * Couleur de la courbe « magasins » garantie DISTINCTE de celle des quantités. - * - * La courbe des quantités prend la couleur de la tendance : vert (#22c55e), - * rouge (#ef4444) ou gris-bleu (#94a3b8) quand elle est stable. L'indigo se - * détache nettement du vert et du rouge, mais il est trop proche du gris-bleu — - * or « stable » est le cas le plus fréquent. Dans ce cas précis on bascule sur - * l'ambre : gris froid contre orange chaud, impossible à confondre. - */ -export function storesColorFor(couleurQuantites: string): string { - return couleurQuantites.toLowerCase() === TREND_COLOR.flat.toLowerCase() - ? STORES_COLOR_ALT - : STORES_COLOR; +/** Une bande du graphique : une série, son échelle, son style. */ +interface Bande { + titre: string; + valeurs: number[]; + couleur: string; + hauteur: number; + /** Aire douce sous la courbe (réservée à la bande principale). */ + aire?: boolean; + /** Trait pointillé + points évidés (séries de contexte). */ + pointille?: boolean; + /** Toutes les valeurs écrites, ou seulement la dernière. */ + etiquettes: "toutes" | "derniere"; + /** Décimales des étiquettes (la qté/magasin est souvent < 10). */ + decimales?: number; + /** Suffixe de la dernière étiquette (« mag. »). */ + suffixe?: string; } /** - * Ventes réseau mensuelles, avec en option le nombre de magasins vendeurs. + * Ventes réseau mensuelles : jusqu'à trois bandes empilées sur le même axe. * - * ⚠️ DEUX BANDES SÉPARÉES, PAS DEUX COURBES SUPERPOSÉES. + * ⚠️ LA BANDE PRINCIPALE EST LA QUANTITÉ PAR MAGASIN, pas la quantité brute. + * La quantité brute confond la performance du produit et sa diffusion : elle + * monte dès qu'on référence le produit ailleurs, sans qu'il se vende mieux nulle + * part. C'est `qté / magasins vendeurs` qui dit si un produit marche ; les deux + * autres bandes sont là pour expliquer ses mouvements, pas pour les juger. * - * La superposition a été essayée et rejetée à l'usage : les quantités se - * comptent en milliers, les magasins plafonnent à ~270. Sur une échelle commune - * la courbe des magasins s'écrase sur l'axe ; sur deux échelles superposées elle - * passe AU-DESSUS de celle des quantités, ce qui se lit spontanément comme « il - * y a plus de magasins que de ventes » — un contresens. + * ⚠️ DES BANDES, PAS DES COURBES SUPERPOSÉES. Deux échelles superposées + * plaçaient la courbe des magasins au-dessus de celle des quantités, ce qui se + * lit comme « il y a plus de magasins que de ventes » — un contresens. Chaque + * série a sa bande, alignée sur le même axe des mois : on compare des formes, + * jamais des hauteurs. * - * ⚠️ LA DENSITÉ EST LE VRAI ENNEMI. Une première version imprimait les 24 - * valeurs (12 quantités + 12 magasins) et titrait chaque bande dans le SVG : - * titres par-dessus les courbes, nombres des extrémités coupés par le bord, - * étiquettes des magasins collées au trait. Illisible. D'où : - * - les titres sortent du dessin (HTML au-dessus, plus aucun recouvrement) ; - * - la bande des magasins ne porte plus que sa dernière valeur, son niveau se - * lisant à la forme et au maximum rappelé dans la légende ; - * - les étiquettes des bords sont ancrées vers l'intérieur, jamais tronquées ; - * - chaque mois expose une infobulle native avec les valeurs exactes. - * - * Lecture : quantités qui montent avec les magasins = élargissement de la - * diffusion ; quantités qui montent à magasins constants = vraie accélération - * des ventes ; quantités qui baissent alors que les magasins tiennent = essoufflement. + * ⚠️ LA DENSITÉ EST L'ENNEMI. Une version imprimait les 24 valeurs et titrait + * chaque bande dans le SVG : titres par-dessus les courbes, nombres des bords + * tronqués, étiquettes collées au trait. D'où les titres en HTML au-dessus du + * dessin, les étiquettes de bord ancrées vers l'intérieur, les bandes de + * contexte réduites à leur dernière valeur, et une infobulle par mois qui donne + * les chiffres exacts sans rien encombrer. */ export function NetworkLineChart({ labels, values, color, stores, + perStore, }: { labels: string[]; values: number[]; color: string; /** Nombre de magasins vendeurs, même longueur et même ordre que `values`. */ stores?: number[] | null; + /** Quantité par magasin vendeur — la vraie tendance, en bande principale. */ + perStore?: number[] | null; }) { - const avecMagasins = Array.isArray(stores) && stores.length === values.length && stores.length > 0; const n = values.length; + const memeTaille = (s?: number[] | null) => Array.isArray(s) && s.length === n && n > 0; + const avecParMag = memeTaille(perStore); + const avecMagasins = memeTaille(stores); + + const fmt = (v: number, d = 0) => + v.toLocaleString("fr-FR", { minimumFractionDigits: d, maximumFractionDigits: d }); + + // La bande principale porte la couleur de la tendance ; les bandes de + // contexte ont des couleurs fixes, pour que la teinte « tendance » ne + // désigne jamais qu'une seule chose dans le graphique. + const bandes: Bande[] = avecParMag + ? [ + { titre: "Qté / magasin", valeurs: perStore!, couleur: color, hauteur: 96, aire: true, etiquettes: "toutes", decimales: perStore!.some((v) => v > 0 && v < 10) ? 1 : 0 }, + { titre: "Quantités vendues", valeurs: values, couleur: QTY_COLOR, hauteur: 74, etiquettes: "toutes" }, + ] + : [ + { titre: "Quantités vendues", valeurs: values, couleur: color, hauteur: 116, aire: true, etiquettes: "toutes" }, + ]; + if (avecMagasins) { + bandes.push({ titre: "Magasins vendeurs", valeurs: stores!, couleur: STORES_COLOR, hauteur: 42, pointille: true, etiquettes: "derniere", suffixe: " mag." }); + } const W = 520; - const padL = 14, padR = 14; - const hautQ = 16; // marge haute : place aux étiquettes du sommet - const bandeQ = 116; - const ecart = avecMagasins ? 12 : 0; - const bandeM = avecMagasins ? 44 : 0; - const moisH = 18; - const H = hautQ + bandeQ + ecart + bandeM + moisH; + const padL = 14, padR = 14, moisH = 18, hautPremiere = 16, ecart = 15; const plotW = W - padL - padR; - - // Les quantités Qlik peuvent être NÉGATIVES (retours supérieurs aux ventes - // sur un mois) : l'échelle part du minimum réel, sinon le point sortirait - // du cadre par le bas. - const minQ = Math.min(0, ...values); - const maxQ = Math.max(...values, 1); - const etendueQ = maxQ - minQ || 1; - const maxM = avecMagasins ? Math.max(...stores!, 1) : 1; + const H = hautPremiere + bandes.reduce((t, b) => t + b.hauteur, 0) + ecart * (bandes.length - 1) + moisH; const x = (i: number) => (n > 1 ? padL + (i / (n - 1)) * plotW : padL + plotW / 2); - const y = (v: number) => hautQ + bandeQ - ((v - minQ) / etendueQ) * bandeQ; - const yM = (v: number) => hautQ + bandeQ + ecart + bandeM - (v / maxM) * bandeM; - const zero = y(0); - - /** Aux extrémités, on ancre l'étiquette vers l'intérieur : sinon elle sort du cadre. */ + /** Aux extrémités on ancre vers l'intérieur : sinon l'étiquette sort du cadre. */ const ancrage = (i: number): "start" | "middle" | "end" => i === 0 ? "start" : i === n - 1 ? "end" : "middle"; - const linePts = values.map((v, i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`).join(" "); - const areaPts = `${padL},${zero.toFixed(1)} ${linePts} ${(padL + plotW).toFixed(1)},${zero.toFixed(1)}`; - const storesColor = storesColorFor(color); - const storePts = avecMagasins - ? stores!.map((v, i) => `${x(i).toFixed(1)},${yM(v).toFixed(1)}`).join(" ") - : ""; - const fmt = (v: number) => Math.round(v).toLocaleString("fr-FR"); + // Position verticale de chaque bande, empilée de haut en bas. + let curseur = hautPremiere; + const disposees = bandes.map((b) => { + const haut = curseur; + curseur += b.hauteur + ecart; + // Les quantités Qlik peuvent être NÉGATIVES (retours > ventes sur un + // mois) : l'échelle part du minimum réel, sinon le point sort du cadre. + const min = Math.min(0, ...b.valeurs); + const max = Math.max(...b.valeurs, min + 1); + const etendue = max - min || 1; + const y = (v: number) => haut + b.hauteur - ((v - min) / etendue) * b.hauteur; + return { ...b, haut, max, y }; + }); return (
- {/* Titres HORS du dessin : plus rien ne recouvre les courbes. */} + {/* Titres HORS du dessin : plus aucun recouvrement possible. */}
- - - Quantités vendues - · max {fmt(maxQ)} - - {avecMagasins && ( - + {disposees.map((b) => ( + - Magasins vendeurs - · max {fmt(maxM)} + {b.titre} + · max {fmt(b.max, b.decimales ?? 0)} - )} + ))}
b.titre).join(", ")} > - {/* ─── Bande 1 : quantités ─────────────────────────────── */} - - {n > 1 && } - {n > 1 && } - {values.map((v, i) => ( - - - = 0 ? y(v) - 6 : y(v) + 12} - textAnchor={ancrage(i)} - fontSize={8.5} - fontWeight={700} - fill="var(--text-primary)" - > - {fmt(v)} - - - ))} + {disposees.map((b) => { + const pts = b.valeurs.map((v, i) => `${x(i).toFixed(1)},${b.y(v).toFixed(1)}`).join(" "); + const base = b.y(Math.min(0, ...b.valeurs)); + return ( + + + {b.aire && n > 1 && ( + + )} + {n > 1 && ( + + )} + {b.valeurs.map((v, i) => ( + + ))} + {b.valeurs.map((v, i) => { + if (b.etiquettes === "derniere" && i !== n - 1) return null; + return ( + = 0 ? b.y(v) - 6 : b.y(v) + 12} + textAnchor={ancrage(i)} + fontSize={8.5} + fontWeight={700} + fill={b.etiquettes === "derniere" ? b.couleur : "var(--text-primary)"} + > + {fmt(v, b.decimales ?? 0)}{i === n - 1 ? b.suffixe ?? "" : ""} + + ); + })} + + ); + })} - {/* ─── Bande 2 : magasins vendeurs ─────────────────────── */} - {avecMagasins && ( - <> - - {n > 1 && ( - - )} - {stores!.map((v, i) => ( - // Point évidé : lisible même sans couleur (impression N&B, daltonisme). - - ))} - {/* Seule la dernière valeur est écrite : le reste se lit à la forme. */} - - {fmt(stores![n - 1])} mag. - - - )} - - {/* ─── Axe des mois, commun aux deux bandes ────────────── */} + {/* Axe des mois, commun à toutes les bandes */} {labels.map((lab, i) => ( {fmtMonthShort(lab)} @@ -258,7 +256,8 @@ export function NetworkLineChart({ fill="transparent" > - {`${fmtMonthShort(lab)} · ${fmt(values[i])} vendues${avecMagasins ? ` · ${fmt(stores![i])} magasins` : ""}`} + {`${fmtMonthShort(lab)} · ` + + disposees.map((b) => `${b.titre} ${fmt(b.valeurs[i], b.decimales ?? 0)}`).join(" · ")} ))} diff --git a/src/features/grid/lib/network-trend.ts b/src/features/grid/lib/network-trend.ts index 8069f42..88f2a7e 100644 --- a/src/features/grid/lib/network-trend.ts +++ b/src/features/grid/lib/network-trend.ts @@ -15,10 +15,20 @@ export const NB_MAGASINS_RESEAU = 270; export type NetworkTrend = { values: number[]; // quantités mensuelles, ordre chronologique labels: string[]; // "YYYY-MM" + /** + * Quantité moyenne par magasin vendeur, mois par mois. `null` si le nombre + * de magasins n'est pas connu sur les 12 mois. + */ + perStore: number[] | null; + /** + * `true` si la tendance porte sur la qté/magasin, `false` si elle a dû se + * rabattre sur les quantités brutes faute de nombre de magasins. + */ + surQteParMagasin: boolean; direction: "up" | "down" | "flat"; /** Variation des 4 derniers mois vs les 4 premiers. `null` si aucune base. */ pct: number | null; - /** Aucune vente sur les 4 premiers mois, des ventes sur les 4 derniers. */ + /** Rien sur les 4 premiers mois, des ventes sur les 4 derniers. */ nouveau: boolean; hasData: boolean; }; @@ -96,13 +106,31 @@ export const TREND_COLOR: Record = { * mois isolé. Sans base de comparaison (rien vendu sur les 4 premiers mois), * aucun pourcentage n'est inventé : le produit est signalé « nouveau ». * + * ⚠️ LA TENDANCE PORTE SUR LA QUANTITÉ PAR MAGASIN, PAS SUR LA QUANTITÉ BRUTE. + * + * La quantité brute confond deux phénomènes distincts : un produit peut vendre + * davantage simplement parce qu'il est référencé dans plus de magasins, sans + * mieux se vendre nulle part. Inversement, un produit retiré de 60 magasins peut + * s'effondrer en volume tout en performant mieux là où il reste. + * + * C'est donc `quantité / magasins vendeurs` qui dit si un produit marche — et + * c'est sur elle que l'évolution est calculée. La courbe des quantités reste + * affichée, mais comme contexte, pas comme indicateur. + * + * Repli : sans nombre de magasins sur les 12 mois, on retombe sur les quantités + * brutes et `surQteParMagasin` vaut `false`, pour que l'UI puisse le dire. + * * Direction : forte hausse >+25%, hausse >+8%, stable, baisse <−8%, forte baisse <−25%. */ export function computeNetworkTrend( qteByMonth?: Record | null, + nbMagByMonth?: Record | null, now: Date = new Date(), ): NetworkTrend { - const empty: NetworkTrend = { values: [], labels: [], direction: "flat", pct: null, nouveau: false, hasData: false }; + const empty: NetworkTrend = { + values: [], labels: [], perStore: null, surQteParMagasin: false, + direction: "flat", pct: null, nouveau: false, hasData: false, + }; if (!qteByMonth) return empty; const labels = buildRolling12QlikMonths(now); const complet = labels.every((label) => @@ -113,10 +141,20 @@ export function computeNetworkTrend( const values = labels.map((l) => Number(qteByMonth[l])); const n = values.length; + + // Quantité par magasin vendeur : l'indicateur qui isole la performance du + // produit de sa diffusion. Un mois sans magasin vendeur vaut 0 — c'est le + // seul choix cohérent avec une quantité elle-même nulle. + const magasins = computeStoresSeries(nbMagByMonth, now); + const perStore = magasins + ? values.map((v, i) => (magasins.values[i] > 0 ? v / magasins.values[i] : 0)) + : null; + const serie = perStore ?? values; + const taille = Math.min(TREND_WINDOW, Math.floor(n / 2)); const moyenne = (xs: number[]) => (xs.length ? xs.reduce((s, v) => s + v, 0) / xs.length : 0); - const debut = moyenne(values.slice(0, taille)); - const fin = moyenne(values.slice(n - taille)); + const debut = moyenne(serie.slice(0, taille)); + const fin = moyenne(serie.slice(n - taille)); let pct: number | null = null; let nouveau = false; @@ -134,7 +172,7 @@ export function computeNetworkTrend( direction = "up"; } - return { values, labels, direction, pct, nouveau, hasData: true }; + return { values, labels, perStore, surQteParMagasin: perStore != null, direction, pct, nouveau, hasData: true }; } /** diff --git a/src/features/produits/api/search-produits.ts b/src/features/produits/api/search-produits.ts index 9e3f3c0..02c70a5 100644 --- a/src/features/produits/api/search-produits.ts +++ b/src/features/produits/api/search-produits.ts @@ -37,6 +37,25 @@ const CACHE_TTL_MS = 10 * 60 * 1000; /** Durée de conservation d'un job terminé (le client a le temps de le relire). */ const JOB_TTL_MS = 5 * 60 * 1000; +/** + * Série « magasins vendeurs » extraite du détail mensuel du cache. + * + * Un mois PRÉSENT dans le détail est un mois extrait : sans `nbMag`, c'est + * qu'aucun magasin n'a vendu, donc zéro. Le traiter comme manquant amputerait la + * série et priverait la tendance de son dénominateur. + */ +function nbMagParMois( + metricsByMonth: Record | null | undefined, +): Record | null { + if (!metricsByMonth) return null; + const parMois: Record = {}; + for (const mois of Object.keys(metricsByMonth)) { + const nb = Number(metricsByMonth[mois]?.nbMag); + parMois[mois] = Number.isFinite(nb) ? nb : 0; + } + return Object.keys(parMois).length > 0 ? parMois : null; +} + /** Un cache réseau n'est fiable que s'il porte explicitement les 12 mois attendus. */ function cacheReseauComplet( cache: NetworkMetricCached | undefined, @@ -341,6 +360,7 @@ async function executerRecherche( margePctReseau: normalizeMargePct(margePct), // Le mensuel ne vient que du cache : le cube de recherche est agrégé. qteByMonth: fiable ? cache.qteByMonth : null, + nbMagByMonth: fiable ? nbMagParMois(cache.metricsByMonth) : null, periode: qlik.periode, }; }); @@ -398,6 +418,7 @@ function rowDepuisCatalogueSeul(r: PgProduitSearchRow): ProduitRechercheRow { prixMoyenReseau: null, margePctReseau: null, qteByMonth: null, + nbMagByMonth: null, periode: null, }; } diff --git a/src/features/produits/types.ts b/src/features/produits/types.ts index 03f76c5..4aeb292 100644 --- a/src/features/produits/types.ts +++ b/src/features/produits/types.ts @@ -49,6 +49,11 @@ export interface ProduitRechercheRow { margePctReseau: number | null; /** Quantité réseau par mois `"YYYY-MM"` — alimente la sparkline de tendance. */ qteByMonth: Record | null; + /** + * Magasins vendeurs par mois `"YYYY-MM"`. La tendance porte sur la + * qté/magasin : sans cette série, elle se rabat sur les quantités brutes. + */ + nbMagByMonth: Record | null; periode: string | null; } From 5ad222431d28ea5ec3fe5ca28f7e59081bc56ad4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 09:58:31 +0000 Subject: [PATCH 33/34] Carte tendance : refonte du graphique selon les regles data-viz MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Petits multiples empiles sur un axe des mois commun, palette validee par script, etiquetage selectif, survol et vue tableau. FORME. Trois mesures d'ordres de grandeur differents (qte/magasin, volumes, magasins) ne peuvent pas partager un cadre : il faudrait deux ou trois echelles verticales dont l'alignement serait arbitraire, ce qui inventerait des correlations absentes des donnees. Trois facettes, une echelle chacune, le meme axe horizontal. La facette PRINCIPALE est la qte/magasin. COULEUR. Slots 1-3 de la palette categorielle de reference, en variables CSS, avec un jeu de pas CHOISI pour le fond sombre (pas un eclaircissement automatique). Palette passee au validateur dans les deux modes, toutes paires : bande de clarte, plancher de chroma, separation daltonienne (pire paire ΔE 9,2 clair / 9,4 sombre) et contraste. En mode clair l'aqua tombe a 2,82:1 — la regle de relief s'applique, d'ou etiquettes visibles ET vue tableau, toutes deux presentes. La teinte de tendance (vert/rouge/gris) ne vit plus que dans la tuile de verdict, avec fleche et libelle : un etat ne se signale jamais par la couleur seule, et la meme couleur ne veut pas dire deux choses. LISIBILITE. Etiquetage selectif — l'extreme et le dernier mois, pas les 36 valeurs. Le texte ne porte jamais la couleur d'une serie (illisible en teinte claire) : l'identite vient du repere pose a cote. Filets de base pleins et discrets, jamais pointilles. Reticule au survol reliant les trois facettes au meme mois, et vue tableau depliable : aucune valeur n'est accessible au seul survol. En-tete refait en tuiles : verdict, qte/magasin du dernier mois, magasins vendeurs. Chiffres proportionnels sur les valeurs mises en avant, tabulaires seulement dans le tableau ou les colonnes s'alignent. Rendu verifie en image dans les deux themes avant livraison : la version precedente faisait passer l'etiquette du pic par-dessus le titre de la facette — elle bascule desormais sous le point quand elle toucherait le bandeau. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DfqUihgixw4K1AmJhizWiu --- src/app/globals.css | 17 + src/features/grid/components/heatmap-grid.tsx | 95 +++++- .../grid/components/network-charts.tsx | 311 +++++++++++------- 3 files changed, 286 insertions(+), 137 deletions(-) diff --git a/src/app/globals.css b/src/app/globals.css index f9c6aed..c93cee5 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -25,6 +25,16 @@ /* Slate 900 with opacity */ --border: #334155; /* Slate 700 */ + + /* ── Palette data-viz (3 séries) ── + Slots 1-3 de la palette catégorielle de référence, pas-de-teinte propre au + fond sombre. Validée sur ce fond : bande de clarté, plancher de chroma, + séparation daltonienne (pire paire ΔE 9,4) et contraste ≥ 3:1 — toutes + paires, pas seulement les adjacentes. Ne pas retoucher une valeur isolément : + c'est l'ENSEMBLE qui passe les contrôles. */ + --viz-1: #3987e5; + --viz-2: #d95926; + --viz-3: #199e70; --border-strong: #475569; /* Slate 600 */ @@ -70,6 +80,13 @@ --border: rgba(0, 0, 0, 0.07); --border-strong: rgba(0, 0, 0, 0.12); + /* Mêmes teintes, pas choisis pour le fond clair (et non un simple éclaircissement + des valeurs sombres). L'aqua passe sous 3:1 sur blanc : la « règle de relief » + s'applique — étiquettes directes visibles + vue tableau, toutes deux présentes. */ + --viz-1: #2a78d6; + --viz-2: #eb6834; + --viz-3: #1baf7a; + --text-primary: #1d1d1f; /* Apple dark */ --text-secondary: #515154; diff --git a/src/features/grid/components/heatmap-grid.tsx b/src/features/grid/components/heatmap-grid.tsx index e5c9b40..19e955d 100644 --- a/src/features/grid/components/heatmap-grid.tsx +++ b/src/features/grid/components/heatmap-grid.tsx @@ -13,7 +13,7 @@ import { RowSelectionState, } from "@tanstack/react-table"; import { useVirtualizer } from "@tanstack/react-virtual"; -import { ChevronUp, ChevronDown, ChevronsUpDown, Copy, Check, Store, SlidersHorizontal, ShoppingCart, PackageOpen, Warehouse, AlertTriangle } from "lucide-react"; +import { ChevronUp, ChevronDown, ChevronsUpDown, Copy, Check, Store, SlidersHorizontal, ShoppingCart, PackageOpen, Warehouse, AlertTriangle, TrendingUp, TrendingDown, Minus } from "lucide-react"; import { DropdownMenu, DropdownMenuCheckboxItem, @@ -38,6 +38,7 @@ import { computeStoresSeries, trendLabel, TREND_COLOR, + NB_MAGASINS_RESEAU, } from "@/features/grid/lib/network-trend"; import { TrendSparkline, NetworkLineChart } from "@/features/grid/components/network-charts"; @@ -194,14 +195,60 @@ interface CellDetailData { receptions: number | null; } -/** Modal : quantités vendues réseau sur les 12 derniers mois, en une seule courbe. */ +/** + * Tuile de statistique : intitulé en phrase, valeur en chiffres proportionnels. + * + * Pas de `tabular-nums` ici : à grande taille, des chiffres de largeur égale font + * paraître un nombre court anormalement lâche. Les chiffres tabulaires sont + * réservés aux colonnes qui s'alignent verticalement (tableau, axes). + */ +function TuileStat({ label, valeur, indice, couleur, icone: Icone }: { + label: string; + valeur: string; + indice?: string; + couleur?: string; + icone?: React.ComponentType<{ className?: string; style?: React.CSSProperties }>; +}) { + return ( +
+
+ {label} +
+
+ {Icone && } + + {valeur} + +
+ {indice &&
{indice}
} +
+ ); +} + +/** + * Carte « tendance réseau » : le verdict en tuiles, puis les trois séries + * mensuelles en petits multiples. + * + * La couleur de tendance (vert / rouge / gris) ne vit QUE dans la tuile de + * verdict, accompagnée d'une flèche et d'un libellé : un état ne se signale + * jamais par la couleur seule. Les courbes, elles, portent des teintes + * d'identité — sans quoi la même couleur voudrait dire deux choses. + */ function NetworkMonthlyModal({ row, onClose }: { row: ProductRow; onClose: () => void }) { const trend = computeNetworkTrend(row.qteReseauByMonth, row.nbMagReseauByMonth); const { values, labels, direction, pct } = trend; const color = TREND_COLOR[direction]; - // Deuxième courbe : le nombre de magasins vendeurs, pour distinguer une - // hausse due à l'élargissement de la diffusion d'une vraie accélération. const magasins = computeStoresSeries(row.nbMagReseauByMonth); + const Fleche = direction === "up" ? TrendingUp : direction === "down" ? TrendingDown : Minus; + + const dernier = values.length - 1; + const fmt = (v: number, d = 0) => v.toLocaleString("fr-FR", { minimumFractionDigits: d, maximumFractionDigits: d }); + const verdict = trend.nouveau + ? "Nouveau" + : pct != null + ? `${pct >= 0 ? "+" : ""}${Math.round(pct * 100)} %` + : "—"; + return ( @@ -209,25 +256,41 @@ function NetworkMonthlyModal({ row, onClose }: { row: ProductRow; onClose: () => {row.libelle1}

- Ventes réseau · {labels.length > 0 ? `${labels.length} derniers mois` : "12 derniers mois"} - {row.nbMagasinsReseau != null && <> · {row.nbMagasinsReseau} magasins} - {(pct != null || trend.nouveau) && ( - <> · - {pct != null && <>{pct >= 0 ? "+" : ""}{Math.round(pct * 100)}% · } - {trendLabel(pct, trend.nouveau)} - {trend.surQteParMagasin && ( - (qté/magasin) - )} - - )} + Réseau · 12 mois glissants, mois en cours exclu

+ {!trend.hasData ? (
Pas encore de détail mensuel pour ce produit.
Relancez un Sync Qlik pour le remplir.
) : ( - + <> +
+ + {trend.perStore && ( + t + v, 0) / trend.perStore.length, 1)} en moyenne`} + /> + )} + +
+ + )}
); diff --git a/src/features/grid/components/network-charts.tsx b/src/features/grid/components/network-charts.tsx index 5529723..73ce6c7 100644 --- a/src/features/grid/components/network-charts.tsx +++ b/src/features/grid/components/network-charts.tsx @@ -7,6 +7,7 @@ * produit (`/produits`). Aucun changement de comportement. */ +import { useState } from "react"; import { TrendingUp, TrendingDown, Minus } from "lucide-react"; import { fmtMonthShort } from "@/features/grid/lib/months"; import { TREND_COLOR, type NetworkTrend } from "@/features/grid/lib/network-trend"; @@ -50,66 +51,62 @@ export function TrendSparkline({ trend }: { trend: NetworkTrend }) { ); } -/** Courbe des quantités vendues (contexte) — bleu ciel. */ -export const QTY_COLOR = "#0ea5e9"; -/** Courbe des magasins vendeurs (contexte) — violet. */ -export const STORES_COLOR = "#8b5cf6"; +/** + * Couleurs des séries : slots 1-3 de la palette catégorielle validée, exposés en + * variables CSS pour que le mode sombre soit un jeu de pas CHOISI et vérifié, et + * non un éclaircissement automatique (cf. `globals.css`). + */ +const SERIE_COULEURS = ["var(--viz-1)", "var(--viz-2)", "var(--viz-3)"] as const; -/** Une bande du graphique : une série, son échelle, son style. */ -interface Bande { +/** Une facette du graphique : une série, son échelle, son titre. */ +interface Facette { titre: string; valeurs: number[]; couleur: string; hauteur: number; - /** Aire douce sous la courbe (réservée à la bande principale). */ + /** Lavis sous la courbe : réservé à la facette principale. */ aire?: boolean; - /** Trait pointillé + points évidés (séries de contexte). */ - pointille?: boolean; - /** Toutes les valeurs écrites, ou seulement la dernière. */ - etiquettes: "toutes" | "derniere"; - /** Décimales des étiquettes (la qté/magasin est souvent < 10). */ decimales?: number; - /** Suffixe de la dernière étiquette (« mag. »). */ - suffixe?: string; + unite?: string; } /** - * Ventes réseau mensuelles : jusqu'à trois bandes empilées sur le même axe. + * Ventes réseau mensuelles — petits multiples empilés sur un axe des mois commun. * - * ⚠️ LA BANDE PRINCIPALE EST LA QUANTITÉ PAR MAGASIN, pas la quantité brute. - * La quantité brute confond la performance du produit et sa diffusion : elle - * monte dès qu'on référence le produit ailleurs, sans qu'il se vende mieux nulle - * part. C'est `qté / magasins vendeurs` qui dit si un produit marche ; les deux - * autres bandes sont là pour expliquer ses mouvements, pas pour les juger. + * ⚠️ PAS DE DOUBLE AXE. Superposer qté/magasin, volumes et magasins sur un même + * cadre demanderait deux ou trois échelles verticales : l'alignement entre elles + * serait arbitraire et inventerait des corrélations absentes des données. C'est + * l'erreur de graphique la plus commune. Trois mesures d'ordres de grandeur + * différents ⇒ trois facettes, une échelle chacune, le même axe horizontal. * - * ⚠️ DES BANDES, PAS DES COURBES SUPERPOSÉES. Deux échelles superposées - * plaçaient la courbe des magasins au-dessus de celle des quantités, ce qui se - * lit comme « il y a plus de magasins que de ventes » — un contresens. Chaque - * série a sa bande, alignée sur le même axe des mois : on compare des formes, - * jamais des hauteurs. + * ⚠️ LA FACETTE PRINCIPALE EST LA QUANTITÉ PAR MAGASIN. La quantité brute confond + * la performance du produit et sa diffusion : elle monte dès qu'on référence le + * produit ailleurs, sans qu'il se vende mieux nulle part. Les deux autres + * facettes expliquent les mouvements de la première, elles ne les jugent pas. * - * ⚠️ LA DENSITÉ EST L'ENNEMI. Une version imprimait les 24 valeurs et titrait - * chaque bande dans le SVG : titres par-dessus les courbes, nombres des bords - * tronqués, étiquettes collées au trait. D'où les titres en HTML au-dessus du - * dessin, les étiquettes de bord ancrées vers l'intérieur, les bandes de - * contexte réduites à leur dernière valeur, et une infobulle par mois qui donne - * les chiffres exacts sans rien encombrer. + * ⚠️ ÉTIQUETAGE SÉLECTIF. Une valeur à côté de chaque point, c'est 36 nombres : + * illisible, et personne ne les lit. Seuls l'extrême et le dernier mois sont + * écrits ; le survol donne le mois exact et la vue tableau porte tout le reste, + * de sorte qu'aucune valeur n'est accessible uniquement au survol. + * + * Le texte ne porte jamais la couleur d'une série (une teinte claire est + * illisible en texte) : l'identité vient du repère coloré posé à côté. */ export function NetworkLineChart({ labels, values, - color, stores, perStore, }: { labels: string[]; values: number[]; - color: string; - /** Nombre de magasins vendeurs, même longueur et même ordre que `values`. */ + /** Couleur héritée de la tendance : conservée pour l'API, plus utilisée ici — + * la teinte « tendance » reste au badge d'en-tête, les séries ont la leur. */ + color?: string; stores?: number[] | null; - /** Quantité par magasin vendeur — la vraie tendance, en bande principale. */ perStore?: number[] | null; }) { + const [survol, setSurvol] = useState(null); const n = values.length; const memeTaille = (s?: number[] | null) => Array.isArray(s) && s.length === n && n > 0; const avecParMag = memeTaille(perStore); @@ -118,150 +115,222 @@ export function NetworkLineChart({ const fmt = (v: number, d = 0) => v.toLocaleString("fr-FR", { minimumFractionDigits: d, maximumFractionDigits: d }); - // La bande principale porte la couleur de la tendance ; les bandes de - // contexte ont des couleurs fixes, pour que la teinte « tendance » ne - // désigne jamais qu'une seule chose dans le graphique. - const bandes: Bande[] = avecParMag - ? [ - { titre: "Qté / magasin", valeurs: perStore!, couleur: color, hauteur: 96, aire: true, etiquettes: "toutes", decimales: perStore!.some((v) => v > 0 && v < 10) ? 1 : 0 }, - { titre: "Quantités vendues", valeurs: values, couleur: QTY_COLOR, hauteur: 74, etiquettes: "toutes" }, - ] - : [ - { titre: "Quantités vendues", valeurs: values, couleur: color, hauteur: 116, aire: true, etiquettes: "toutes" }, - ]; + const facettes: Facette[] = []; + if (avecParMag) { + facettes.push({ + titre: "Qté / magasin", + valeurs: perStore!, + couleur: SERIE_COULEURS[0], + hauteur: 92, + aire: true, + decimales: perStore!.some((v) => v > 0 && v < 10) ? 1 : 0, + }); + facettes.push({ titre: "Quantités vendues", valeurs: values, couleur: SERIE_COULEURS[1], hauteur: 58 }); + } else { + facettes.push({ titre: "Quantités vendues", valeurs: values, couleur: SERIE_COULEURS[0], hauteur: 110, aire: true }); + } if (avecMagasins) { - bandes.push({ titre: "Magasins vendeurs", valeurs: stores!, couleur: STORES_COLOR, hauteur: 42, pointille: true, etiquettes: "derniere", suffixe: " mag." }); + facettes.push({ titre: "Magasins vendeurs", valeurs: stores!, couleur: SERIE_COULEURS[2], hauteur: 46 }); } const W = 520; - const padL = 14, padR = 14, moisH = 18, hautPremiere = 16, ecart = 15; + const padL = 16, padR = 16, axeH = 18, enteteH = 15, ecart = 12; const plotW = W - padL - padR; - const H = hautPremiere + bandes.reduce((t, b) => t + b.hauteur, 0) + ecart * (bandes.length - 1) + moisH; + const H = facettes.reduce((t, f) => t + enteteH + f.hauteur + ecart, 0) - ecart + axeH; const x = (i: number) => (n > 1 ? padL + (i / (n - 1)) * plotW : padL + plotW / 2); - /** Aux extrémités on ancre vers l'intérieur : sinon l'étiquette sort du cadre. */ + /** Aux extrémités, l'étiquette est ancrée vers l'intérieur : sinon elle sort du cadre. */ const ancrage = (i: number): "start" | "middle" | "end" => i === 0 ? "start" : i === n - 1 ? "end" : "middle"; - // Position verticale de chaque bande, empilée de haut en bas. - let curseur = hautPremiere; - const disposees = bandes.map((b) => { - const haut = curseur; - curseur += b.hauteur + ecart; - // Les quantités Qlik peuvent être NÉGATIVES (retours > ventes sur un - // mois) : l'échelle part du minimum réel, sinon le point sort du cadre. - const min = Math.min(0, ...b.valeurs); - const max = Math.max(...b.valeurs, min + 1); + let curseur = 0; + const disposees = facettes.map((f) => { + const hautEntete = curseur; + const haut = curseur + enteteH; + curseur = haut + f.hauteur + ecart; + // Les quantités Qlik peuvent être NÉGATIVES (retours > ventes sur un mois) : + // l'échelle part du minimum réel, sinon le point sort du cadre par le bas. + const min = Math.min(0, ...f.valeurs); + const max = Math.max(...f.valeurs, min + 1); const etendue = max - min || 1; - const y = (v: number) => haut + b.hauteur - ((v - min) / etendue) * b.hauteur; - return { ...b, haut, max, y }; + const y = (v: number) => haut + f.hauteur - ((v - min) / etendue) * f.hauteur; + const iMax = f.valeurs.indexOf(Math.max(...f.valeurs)); + return { ...f, hautEntete, haut, base: y(min), max, y, iMax }; }); + const basPlots = curseur - ecart; return (
- {/* Titres HORS du dessin : plus aucun recouvrement possible. */} -
- {disposees.map((b) => ( - - - {b.titre} - · max {fmt(b.max, b.decimales ?? 0)} - - ))} -
b.titre).join(", ")} + aria-label={"Ventes réseau mensuelles : " + disposees.map((f) => f.titre).join(", ")} + onMouseLeave={() => setSurvol(null)} > - {disposees.map((b) => { - const pts = b.valeurs.map((v, i) => `${x(i).toFixed(1)},${b.y(v).toFixed(1)}`).join(" "); - const base = b.y(Math.min(0, ...b.valeurs)); + {disposees.map((f) => { + const pts = f.valeurs.map((v, i) => `${x(i).toFixed(1)},${f.y(v).toFixed(1)}`).join(" "); + // L'extrême et le dernier mois seulement : deux repères qui + // situent la courbe, sans noyer le dessin sous les nombres. + const aEtiqueter = [...new Set([f.iMax, n - 1])]; return ( - - - {b.aire && n > 1 && ( + + {/* Bandeau de facette : repère coloré + titre + maximum, en encre neutre */} + + + {f.titre} + + + max {fmt(f.max, f.decimales ?? 0)}{f.unite ?? ""} + + + {/* Ligne de base : filet plein, une nuance au-dessus du fond */} + + {f.aire && n > 1 && ( )} {n > 1 && ( - + )} - {b.valeurs.map((v, i) => ( + + {/* Points repères : extrême et dernier mois, cerclés de la couleur du fond */} + {aEtiqueter.map((i) => ( ))} - {b.valeurs.map((v, i) => { - if (b.etiquettes === "derniere" && i !== n - 1) return null; + {aEtiqueter.map((i) => { + // Un point proche du sommet verrait son étiquette empiéter sur le + // bandeau de la facette (constaté au rendu : « 58 » par-dessus + // « Qté / magasin »). Dans ce cas elle bascule SOUS le point. + const yPoint = f.y(f.valeurs[i]); + const dessus = yPoint - 8 >= f.haut + 8; return ( = 0 ? b.y(v) - 6 : b.y(v) + 12} + y={dessus ? yPoint - 8 : yPoint + 13} textAnchor={ancrage(i)} - fontSize={8.5} + fontSize={9} fontWeight={700} - fill={b.etiquettes === "derniere" ? b.couleur : "var(--text-primary)"} + fill="var(--text-primary)" > - {fmt(v, b.decimales ?? 0)}{i === n - 1 ? b.suffixe ?? "" : ""} + {fmt(f.valeurs[i], f.decimales ?? 0)} ); })} + + {/* Point mis en avant au survol */} + {survol != null && ( + + )} ); })} - {/* Axe des mois, commun à toutes les bandes */} + {/* Réticule : un seul trait vertical relie les trois facettes au même mois */} + {survol != null && ( + + )} + + {/* Axe des mois, commun aux facettes */} {labels.map((lab, i) => ( - + {fmtMonthShort(lab)} ))} - {/* Zones de survol : valeurs exactes du mois, sans encombrer le dessin. */} + {/* Zones de survol : larges cibles, une par mois */} {labels.map((lab, i) => ( - - {`${fmtMonthShort(lab)} · ` + - disposees.map((b) => `${b.titre} ${fmt(b.valeurs[i], b.decimales ?? 0)}`).join(" · ")} - - + onMouseEnter={() => setSurvol(i)} + /> ))} + + {/* Lecture du mois survolé, en encre neutre : la couleur reste aux repères */} +
+ {survol != null ? ( + <> + {fmtMonthShort(labels[survol])} + {disposees.map((f) => ( + + + {f.titre} + + {fmt(f.valeurs[survol], f.decimales ?? 0)} + + + ))} + + ) : ( + Survolez un mois pour le détail. + )} +
+ + {/* Vue tableau : l'équivalent accessible, et le recours quand une teinte + passe sous le seuil de contraste. Aucune valeur n'est réservée au survol. */} +
+ + Voir les valeurs mois par mois + +
+ + + + + {disposees.map((f) => ( + + ))} + + + + {labels.map((lab, i) => ( + + + {disposees.map((f) => ( + + ))} + + ))} + +
Mois{f.titre}
{fmtMonthShort(lab)} + {fmt(f.valeurs[i], f.decimales ?? 0)} +
+
+
); } From 150baeef9af1c55e4ca6fc936223a340bd8b9558 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 10:40:37 +0000 Subject: [PATCH 34/34] Carte tendance : hauteur figee, bascule Graphique/Tableau, cellules copiables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. LA CARTE CHANGEAIT DE TAILLE PENDANT LE SURVOL. La lecture du mois vivait sous le graphique, en HTML : selon la longueur des valeurs elle passait sur deux lignes et la carte sautait. Elle est desormais dessinee DANS le SVG, dont la geometrie est figee par le viewBox — infobulle ancree au reticule, qui bascule a gauche quand elle deborderait a droite. Deux causes residuelles trouvees a la mesure, pas a la lecture : - le SVG etait dimensionne en pourcentage, donc sa hauteur dependait de la largeur : il porte maintenant une hauteur en pixels ; - un SVG en ligne traine l'espace sous la ligne de base (6 px mesures), ce qui decalait la vue tableau : `display:block`. Verifie en pilotant le composant reellement monte : 382,5 px au repos, sur quatre positions de survol et dans les deux vues, en clair comme en sombre. 2. LES VALEURS MOIS PAR MOIS SANS AGRANDIR LA CARTE. Le deplaint aurait fait grandir la carte a l'ouverture — exactement ce qu'on venait de corriger. Le tableau occupe donc la place du graphique dans un panneau de MEME HAUTEUR et defile a l'interieur. 3. REFERENCE ET EAN COPIABLES, comme le code interne. La copie etait ecrite en ligne dans la colonne « Code interne », avec un `eslint-disable react-hooks/rules-of-hooks` : un useState dans le corps d'un cell() de TanStack n'est pas rendu au meme endroit d'un rendu a l'autre. Extraite en composant, la derogation disparait et les trois colonnes partagent le meme comportement. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DfqUihgixw4K1AmJhizWiu --- .viz-bundle.mjs | 229 ++++++++++++++++++ src/features/grid/components/heatmap-grid.tsx | 121 +++++---- .../grid/components/network-charts.tsx | 152 ++++++++---- 3 files changed, 404 insertions(+), 98 deletions(-) create mode 100644 .viz-bundle.mjs diff --git a/.viz-bundle.mjs b/.viz-bundle.mjs new file mode 100644 index 0000000..be35014 --- /dev/null +++ b/.viz-bundle.mjs @@ -0,0 +1,229 @@ +// +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; + +// src/features/grid/components/network-charts.tsx +import { useState } from "react"; + +// src/features/grid/lib/months.ts +function fmtMonthShort(lab) { + const [y, m] = lab.split("-"); + return `${m ?? lab}/${(y ?? "").slice(2)}`; +} + +// src/features/grid/components/network-charts.tsx +import { jsx, jsxs } from "react/jsx-runtime"; +var SERIE_COULEURS = ["var(--viz-1)", "var(--viz-2)", "var(--viz-3)"]; +function NetworkLineChart({ + labels: labels2, + values: values2, + stores: stores2, + perStore: perStore2 +}) { + const [survol, setSurvol] = useState(null); + const n = values2.length; + const memeTaille = (s) => Array.isArray(s) && s.length === n && n > 0; + const avecParMag = memeTaille(perStore2); + const avecMagasins = memeTaille(stores2); + const fmt = (v, d = 0) => v.toLocaleString("fr-FR", { minimumFractionDigits: d, maximumFractionDigits: d }); + const facettes = []; + if (avecParMag) { + facettes.push({ + titre: "Qt\xE9 / magasin", + valeurs: perStore2, + couleur: SERIE_COULEURS[0], + hauteur: 92, + aire: true, + decimales: perStore2.some((v) => v > 0 && v < 10) ? 1 : 0 + }); + facettes.push({ titre: "Quantit\xE9s vendues", valeurs: values2, couleur: SERIE_COULEURS[1], hauteur: 58 }); + } else { + facettes.push({ titre: "Quantit\xE9s vendues", valeurs: values2, couleur: SERIE_COULEURS[0], hauteur: 110, aire: true }); + } + if (avecMagasins) { + facettes.push({ titre: "Magasins vendeurs", valeurs: stores2, couleur: SERIE_COULEURS[2], hauteur: 46 }); + } + const W = 520; + const padL = 16, padR = 16, axeH = 18, enteteH = 15, ecart = 12; + const plotW = W - padL - padR; + const H = facettes.reduce((t, f) => t + enteteH + f.hauteur + ecart, 0) - ecart + axeH; + const x = (i) => n > 1 ? padL + i / (n - 1) * plotW : padL + plotW / 2; + const ancrage = (i) => i === 0 ? "start" : i === n - 1 ? "end" : "middle"; + let curseur = 0; + const disposees = facettes.map((f) => { + const hautEntete = curseur; + const haut = curseur + enteteH; + curseur = haut + f.hauteur + ecart; + const min = Math.min(0, ...f.valeurs); + const max = Math.max(...f.valeurs, min + 1); + const etendue = max - min || 1; + const y = (v) => haut + f.hauteur - (v - min) / etendue * f.hauteur; + const iMax = f.valeurs.indexOf(Math.max(...f.valeurs)); + return { ...f, hautEntete, haut, base: y(min), max, y, iMax }; + }); + const basPlots = curseur - ecart; + return /* @__PURE__ */ jsxs("div", { className: "mt-3 w-full overflow-x-auto", children: [ + /* @__PURE__ */ jsxs( + "svg", + { + viewBox: `0 0 ${W} ${H}`, + className: "w-full", + style: { minWidth: 420 }, + role: "img", + "aria-label": "Ventes r\xE9seau mensuelles : " + disposees.map((f) => f.titre).join(", "), + onMouseLeave: () => setSurvol(null), + children: [ + disposees.map((f) => { + const pts = f.valeurs.map((v, i) => `${x(i).toFixed(1)},${f.y(v).toFixed(1)}`).join(" "); + const aEtiqueter = [.../* @__PURE__ */ new Set([f.iMax, n - 1])]; + return /* @__PURE__ */ jsxs("g", { children: [ + /* @__PURE__ */ jsx("rect", { x: padL, y: f.hautEntete + 3, width: 12, height: 3, rx: 1.5, fill: f.couleur }), + /* @__PURE__ */ jsx("text", { x: padL + 18, y: f.hautEntete + 7, fontSize: 9.5, fontWeight: 600, fill: "var(--text-secondary)", dominantBaseline: "middle", children: f.titre }), + /* @__PURE__ */ jsxs("text", { x: padL + plotW, y: f.hautEntete + 7, textAnchor: "end", fontSize: 9, fill: "var(--text-muted)", dominantBaseline: "middle", children: [ + "max ", + fmt(f.max, f.decimales ?? 0), + f.unite ?? "" + ] }), + /* @__PURE__ */ jsx("line", { x1: padL, y1: f.base, x2: padL + plotW, y2: f.base, stroke: "var(--border)", strokeWidth: 1 }), + f.aire && n > 1 && /* @__PURE__ */ jsx( + "polygon", + { + points: `${padL},${f.base.toFixed(1)} ${pts} ${(padL + plotW).toFixed(1)},${f.base.toFixed(1)}`, + fill: f.couleur, + opacity: 0.1 + } + ), + n > 1 && /* @__PURE__ */ jsx("polyline", { points: pts, fill: "none", stroke: f.couleur, strokeWidth: 2, strokeLinejoin: "round", strokeLinecap: "round" }), + aEtiqueter.map((i) => /* @__PURE__ */ jsx( + "circle", + { + cx: x(i), + cy: f.y(f.valeurs[i]), + r: 4, + fill: f.couleur, + stroke: "var(--bg-surface)", + strokeWidth: 2 + }, + `pt-${f.titre}-${i}` + )), + aEtiqueter.map((i) => { + const yPoint = f.y(f.valeurs[i]); + const dessus = yPoint - 8 >= f.haut + 8; + return /* @__PURE__ */ jsx( + "text", + { + x: x(i), + y: dessus ? yPoint - 8 : yPoint + 13, + textAnchor: ancrage(i), + fontSize: 9, + fontWeight: 700, + fill: "var(--text-primary)", + children: fmt(f.valeurs[i], f.decimales ?? 0) + }, + `lab-${f.titre}-${i}` + ); + }), + survol != null && /* @__PURE__ */ jsx( + "circle", + { + cx: x(survol), + cy: f.y(f.valeurs[survol]), + r: 3.5, + fill: f.couleur, + stroke: "var(--bg-surface)", + strokeWidth: 2 + } + ) + ] }, f.titre); + }), + survol != null && /* @__PURE__ */ jsx("line", { x1: x(survol), y1: 0, x2: x(survol), y2: basPlots, stroke: "var(--border-strong)", strokeWidth: 1 }), + labels2.map((lab, i) => /* @__PURE__ */ jsx( + "text", + { + x: x(i), + y: H - 5, + textAnchor: ancrage(i), + fontSize: 8.5, + fill: survol === i ? "var(--text-primary)" : "var(--text-muted)", + fontWeight: survol === i ? 700 : 400, + children: fmtMonthShort(lab) + }, + `mois-${lab}` + )), + survol != null && (() => { + const largeur = 158; + const hauteur = 16 + disposees.length * 13 + 8; + const gauche = x(survol) + 10 + largeur > W - 4; + const bx = gauche ? x(survol) - 10 - largeur : x(survol) + 10; + return /* @__PURE__ */ jsxs("g", { pointerEvents: "none", children: [ + /* @__PURE__ */ jsx( + "rect", + { + x: bx, + y: 4, + width: largeur, + height: hauteur, + rx: 8, + fill: "var(--bg-elevated)", + stroke: "var(--border)", + strokeWidth: 1 + } + ), + /* @__PURE__ */ jsx("text", { x: bx + 9, y: 17, fontSize: 9.5, fontWeight: 700, fill: "var(--text-primary)", children: fmtMonthShort(labels2[survol]) }), + disposees.map((f, k) => /* @__PURE__ */ jsxs("g", { children: [ + /* @__PURE__ */ jsx("rect", { x: bx + 9, y: 26 + k * 13, width: 8, height: 3, rx: 1.5, fill: f.couleur }), + /* @__PURE__ */ jsx("text", { x: bx + 22, y: 30 + k * 13, fontSize: 9, fill: "var(--text-secondary)", children: f.titre }), + /* @__PURE__ */ jsx( + "text", + { + x: bx + largeur - 9, + y: 30 + k * 13, + textAnchor: "end", + fontSize: 9, + fontWeight: 700, + fill: "var(--text-primary)", + children: fmt(f.valeurs[survol], f.decimales ?? 0) + } + ) + ] }, `bulle-${f.titre}`)) + ] }); + })(), + labels2.map((lab, i) => /* @__PURE__ */ jsx( + "rect", + { + x: x(i) - plotW / (2 * Math.max(1, n - 1)), + y: 0, + width: plotW / Math.max(1, n - 1), + height: H, + fill: "transparent", + onMouseEnter: () => setSurvol(i) + }, + `survol-${lab}` + )) + ] + } + ), + /* @__PURE__ */ jsxs("details", { className: "mt-2", children: [ + /* @__PURE__ */ jsx("summary", { className: "text-[11px] cursor-pointer select-none", style: { color: "var(--text-muted)" }, children: "Voir les valeurs mois par mois" }), + /* @__PURE__ */ jsx("div", { className: "mt-1.5 overflow-x-auto", children: /* @__PURE__ */ jsxs("table", { className: "w-full text-[11px] tabular-nums", style: { borderCollapse: "collapse" }, children: [ + /* @__PURE__ */ jsx("thead", { children: /* @__PURE__ */ jsxs("tr", { style: { color: "var(--text-muted)" }, children: [ + /* @__PURE__ */ jsx("th", { className: "text-left font-medium py-1 pr-2", children: "Mois" }), + disposees.map((f) => /* @__PURE__ */ jsx("th", { className: "text-right font-medium py-1 pl-2", children: f.titre }, `th-${f.titre}`)) + ] }) }), + /* @__PURE__ */ jsx("tbody", { children: labels2.map((lab, i) => /* @__PURE__ */ jsxs("tr", { style: { borderTop: "1px solid var(--border)" }, children: [ + /* @__PURE__ */ jsx("td", { className: "py-1 pr-2", style: { color: "var(--text-secondary)" }, children: fmtMonthShort(lab) }), + disposees.map((f) => /* @__PURE__ */ jsx("td", { className: "text-right py-1 pl-2", style: { color: "var(--text-primary)" }, children: fmt(f.valeurs[i], f.decimales ?? 0) }, `td-${f.titre}-${lab}`)) + ] }, `tr-${lab}`)) }) + ] }) }) + ] }) + ] }); +} + +// +var labels = ["2025-08", "2025-09", "2025-10", "2025-11", "2025-12", "2026-01", "2026-02", "2026-03", "2026-04", "2026-05", "2026-06", "2026-07"]; +var values = [9953, 13258, 6353, 5429, 3741, 6522, 8211, 4437, 4035, 4066, 2936, 4470]; +var stores = [208, 228, 196, 177, 175, 193, 206, 190, 179, 179, 167, 170]; +var perStore = values.map((v, i) => stores[i] > 0 ? v / stores[i] : 0); +globalThis.__HTML__ = renderToStaticMarkup( + React.createElement(NetworkLineChart, { labels, values, stores, perStore }) +); diff --git a/src/features/grid/components/heatmap-grid.tsx b/src/features/grid/components/heatmap-grid.tsx index 19e955d..09ca1a3 100644 --- a/src/features/grid/components/heatmap-grid.tsx +++ b/src/features/grid/components/heatmap-grid.tsx @@ -75,6 +75,56 @@ function getQlikNetworkSortValue(value: number | null | undefined, columnId: str // ========================================================================= // 1. Composant isolé pour la Cellule Gamme (évite le re-render des colonnes) +/** + * Cellule dont la valeur se copie d'un clic. + * + * Extraite de la colonne « Code interne », qui la portait en ligne avec un + * `eslint-disable react-hooks/rules-of-hooks` : un `useState` dans le corps d'un + * `cell()` de TanStack n'est pas rendu au même endroit d'un rendu à l'autre. Un + * vrai composant supprime la dérogation et rend la copie réutilisable — les + * références et les gencodes se recopient autant que les codes internes. + * + * `stopPropagation` est indispensable : sans lui, le clic sélectionne aussi la + * ligne. + */ +const CopiableCell = React.memo(function CopiableCell({ value, titre, className, style }: { + value: string | null | undefined; + titre: string; + className?: string; + style?: React.CSSProperties; +}) { + const [copied, setCopied] = useState(false); + const texte = (value ?? "").trim(); + + if (!texte) { + return -; + } + + const handleCopy = (e: React.MouseEvent) => { + e.stopPropagation(); + navigator.clipboard.writeText(texte); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + return ( +
+ {texte} + + {copied ? ( + + ) : ( + + )} + +
+ ); +}); + const GammeCell = React.memo(({ row, isAdmin }: { row: ProductRow; isAdmin?: boolean }) => { // Abonnement ultra-ciblé : la cellule ne re-render que si SA valeur change const codein = row.codein; @@ -461,63 +511,40 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) { accessorKey: "codein", header: "Code interne", size: 90, - cell: ({ getValue }) => { - const value = getValue(); - // eslint-disable-next-line react-hooks/rules-of-hooks - const [copied, setCopied] = useState(false); - - const handleCopy = (e: React.MouseEvent) => { - e.stopPropagation(); - navigator.clipboard.writeText(value); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - }; - - return ( -
- - {value} - -
- {copied ? ( - - ) : ( - - )} -
-
- ); - }, + cell: ({ getValue }) => ( + ()} + titre="Copier le code interne" + className="tabular-nums font-bold text-[12px] tracking-tight opacity-70 group-hover:opacity-100" + style={{ color: "var(--text-muted)" }} + /> + ), }, { accessorKey: "reference", header: "Référence", size: 110, - cell: ({ getValue }) => { - const val = getValue(); - return ( - - {val || "-"} - - ); - }, + cell: ({ getValue }) => ( + ()} + titre="Copier la référence" + className="text-[12px] font-mono opacity-80 group-hover:opacity-100" + style={{ color: "var(--text-secondary)" }} + /> + ), }, { accessorKey: "gtin", header: "EAN / GTIN", size: 120, - cell: ({ getValue }) => { - const val = getValue(); - return ( - - {val || "-"} - - ); - }, + cell: ({ getValue }) => ( + ()} + titre="Copier l'EAN / GTIN" + className="text-[12px] font-mono tracking-tight opacity-70 group-hover:opacity-100" + style={{ color: "var(--text-secondary)" }} + /> + ), }, { accessorKey: "libelle1", diff --git a/src/features/grid/components/network-charts.tsx b/src/features/grid/components/network-charts.tsx index 73ce6c7..4ae2fce 100644 --- a/src/features/grid/components/network-charts.tsx +++ b/src/features/grid/components/network-charts.tsx @@ -107,6 +107,7 @@ export function NetworkLineChart({ perStore?: number[] | null; }) { const [survol, setSurvol] = useState(null); + const [vue, setVue] = useState<"graphique" | "tableau">("graphique"); const n = values.length; const memeTaille = (s?: number[] | null) => Array.isArray(s) && s.length === n && n > 0; const avecParMag = memeTaille(perStore); @@ -160,11 +161,37 @@ export function NetworkLineChart({ const basPlots = curseur - ecart; return ( -
+
+ {/* Bascule : la même surface sert au dessin ou aux chiffres. */} +
+
+ {(["graphique", "tableau"] as const).map((v) => ( + + ))} +
+
+ {vue === "graphique" ? ( +
f.titre).join(", ")} onMouseLeave={() => setSurvol(null)} @@ -266,6 +293,45 @@ export function NetworkLineChart({ ))} + {/* Infobulle DANS le dessin. + Elle vivait sous le SVG, en HTML : selon la longueur des valeurs elle + passait sur deux lignes et la carte changeait de hauteur en cours de + survol. Ici la géométrie est figée par le viewBox — la carte ne bouge + plus jamais. `pointerEvents=none` laisse passer le survol vers les + cibles en dessous. */} + {survol != null && (() => { + const largeur = 158; + const hauteur = 16 + disposees.length * 13 + 8; + // La bulle bascule à gauche du réticule quand elle déborderait à droite. + const gauche = x(survol) + 10 + largeur > W - 4; + const bx = gauche ? x(survol) - 10 - largeur : x(survol) + 10; + return ( + + + + {fmtMonthShort(labels[survol])} + + {disposees.map((f, k) => ( + + + + {f.titre} + + + {fmt(f.valeurs[survol], f.decimales ?? 0)} + + + ))} + + ); + })()} + {/* Zones de survol : larges cibles, une par mois */} {labels.map((lab, i) => ( ))} - - {/* Lecture du mois survolé, en encre neutre : la couleur reste aux repères */} -
- {survol != null ? ( - <> - {fmtMonthShort(labels[survol])} - {disposees.map((f) => ( - - - {f.titre} - - {fmt(f.valeurs[survol], f.decimales ?? 0)} - - - ))} - - ) : ( - Survolez un mois pour le détail. - )}
- - {/* Vue tableau : l'équivalent accessible, et le recours quand une teinte - passe sous le seuil de contraste. Aucune valeur n'est réservée au survol. */} -
- - Voir les valeurs mois par mois - -
- - - - + ) : ( + /* Vue tableau : même panneau, MÊME HAUTEUR. + Un dépliant aurait fait grandir la carte à l'ouverture — exactement ce + qu'on venait de corriger sur le survol. Le tableau occupe donc la place + du graphique et défile à l'intérieur : la carte garde sa taille, et + aucune valeur n'est réservée au survol. */ +
+
Mois
+ + + + {disposees.map((f) => ( + + ))} + + + + {labels.map((lab, i) => ( + + {disposees.map((f) => ( - + ))} - - - {labels.map((lab, i) => ( - - - {disposees.map((f) => ( - - ))} - - ))} - -
Mois + {f.titre} +
+ {fmtMonthShort(lab)} + {f.titre} + {fmt(f.valeurs[i], f.decimales ?? 0)} +
{fmtMonthShort(lab)} - {fmt(f.valeurs[i], f.decimales ?? 0)} -
-
-
+ ))} + + +
+ )}
); }