diff --git a/docs/qlik-integration.md b/docs/qlik-integration.md index aafdb8e..682900c 100644 --- a/docs/qlik-integration.md +++ b/docs/qlik-integration.md @@ -14,6 +14,34 @@ La Foir'Fouille : **CA réseau, Qté vendue réseau, Nb magasins** par produit. | Bouton UI | `src/features/grid/components/sync-qlik-button.tsx` | | Jointure grille | `enrichWithNetworkMetrics()` dans `src/features/grid/api/get-product-rows.ts` (Phase 8) | | Colonnes grille | CA réseau / Qté réseau / Magasins (/270) / % présence (`heatmap-grid.tsx`) | +| Recherche produit (Qlik d'abord) | `src/lib/qlik-search.ts` → `src/features/produits/api/search-produits.ts` → `GET /api/produits/search` | + +## Recherche produit — Qlik d'abord + +La page `/produits` ne cherche **plus** dans le catalogue FF Nancy en premier : +le réseau référence bien plus de produits que Nancy, et ce sont précisément +ceux-là qu'on veut voir. + +1. `searchQlikArticles()` — recherche 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. +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). + +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. + +## 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). 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/scripts/db-init.js b/scripts/db-init.js index 3fa350d..e7accbf 100644 --- a/scripts/db-init.js +++ b/scripts/db-init.js @@ -114,7 +114,9 @@ async function main() { ADD COLUMN IF NOT EXISTS "ca_par_magasin_reseau" numeric(16, 2), ADD COLUMN IF NOT EXISTS "marge_pct_reseau" numeric(8, 4), ADD COLUMN IF NOT EXISTS "qte_by_month" jsonb, - ADD COLUMN IF NOT EXISTS "metrics_by_month" jsonb; + ADD COLUMN IF NOT EXISTS "metrics_by_month" jsonb, + ADD COLUMN IF NOT EXISTS "libelle_reseau" varchar(255), + ADD COLUMN IF NOT EXISTS "fournisseur_reseau" varchar(255); 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 index 3a47948..78f4499 100644 --- a/src/app/(dashboard)/produits/fiche.tsx +++ b/src/app/(dashboard)/produits/fiche.tsx @@ -4,15 +4,15 @@ 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, + ArrowLeft, Package, Warehouse, ShoppingCart, Globe, Layers, Truck, + RefreshCw, Loader2, CheckCircle, AlertCircle, WifiOff, LayoutGrid, Info, } 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 { computeComparatif, indiceVerdict, normalizeMargePct } from "@/features/produits/lib/compare-reseau"; import type { ProduitFiche } from "@/features/produits/types"; import { OpportunitesFamille } from "./opportunites"; @@ -20,6 +20,8 @@ import { OpportunitesFamille } from "./opportunites"; 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); 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); @@ -72,6 +74,29 @@ function Stat({ label, value, hint, color }: { label: string; value: string; hin ); } +// ─── Détail mensuel réseau ─────────────────────────────────────────────────── + +/** Les mesures Qlik d'un mois, telles que stockées dans `metricsByMonth`. */ +type MoisReseau = { qte: number; ca?: number; nbMag?: number; caMag?: number; margePct?: number }; + +/** + * Lignes du tableau « Détail mensuel réseau ». Deux d'entre elles sont dérivées + * (qté/magasin, prix moyen) : le cube Qlik ne les renvoie pas, mais ce sont les + * chiffres qu'on lit réellement pour arbitrer. + */ +const LIGNES_MENSUELLES_RESEAU: Array<{ + label: string; + get: (v: MoisReseau) => number | undefined; + fmt: (n: number) => string; +}> = [ + { label: "Quantité", get: (v) => v.qte, fmt: (n) => fmtQte(n) }, + { label: "Magasins", get: (v) => v.nbMag, fmt: (n) => fmtQte(n) }, + { label: "Qté / mag.", get: (v) => (v.nbMag && v.nbMag > 0 ? v.qte / v.nbMag : undefined), fmt: (n) => fmtDec(n) }, + { label: "CA", get: (v) => v.ca, fmt: (n) => Math.round(n).toLocaleString("fr-FR") }, + { label: "Prix moyen", get: (v) => (v.ca != null && v.qte > 0 ? v.ca / v.qte : undefined), fmt: (n) => fmtEur2(n) }, + { label: "Marge %", get: (v) => normalizeMargePct(v.margePct) ?? undefined, fmt: (n) => `${fmtDec(n)} %` }, +]; + // ─── Bouton d'extraction Qlik pour un seul code centrale ───────────────────── function SyncProduitButton({ codeCentrale }: { codeCentrale: string }) { @@ -96,13 +121,13 @@ function SyncProduitButton({ codeCentrale }: { codeCentrale: string }) { onClick={start} disabled={status === "running"} className="btn-action btn-action-secondary flex items-center gap-1.5 disabled:opacity-60" - title="Extraire les données réseau Qlik pour ce seul produit" + title="Ré-extraire les données réseau Qlik pour ce seul produit" > {status === "running" ? : status === "success" ? : status === "error" ? : } - {status === "running" ? "Extraction…" : "Charger les données réseau"} + {status === "running" ? "Extraction…" : "Actualiser depuis Qlik"} ); @@ -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(); + } +}