From c565672259229994703470cd13b67ec371c74cd6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 09:06:56 +0000 Subject: [PATCH] =?UTF-8?q?feat(reseau):=20page=20Recherche=20r=C3=A9seau?= =?UTF-8?q?=20=E2=80=94=20chercher=20dans=20le=20catalogue=20Qlik?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La Grille part de NOTRE catalogue : on extrait nos codes centraux puis on demande à Qlik les métriques de ces codes. Un produit vendu par le réseau mais absent de FF Nancy était donc invisible par construction. Cette page inverse le sens : elle interroge Qlik d'abord, par libellé ou par code centrale, et affiche aussi les produits que nous ne référençons pas. - searchNetworkProductsPlaywright() : sélection par SelectValues sur « Article Code » (mode code) ou par SearchListObjectFor + AcceptListObjectSearch sur la dimension « Article » (mode plein texte, avec repli sur des noms de champs si la master dimension échoue). Cube [Article Code, Article, Mois] × 6 mesures, passe N-1 incluse pour obtenir 12 mois glissants contigus. Garde-fou à 300 produits/recherche. - /api/qlik/search : job asynchrone (POST + polling GET) comme la sync, une seule recherche à la fois pour ménager le moteur Qlik. Met les résultats en cache dans qlik_network_metrics et enrichit avec le catalogue local (badge « Chez moi » / « Réseau uniquement », stock). - Page /recherche-reseau : tableau triable, filtres, fiche produit avec la courbe 12 mois, export Excel. - Composants de tendance extraits de heatmap-grid vers features/network pour être partagés (comportement inchangé). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675 --- .../(dashboard)/recherche-reseau/client.tsx | 525 ++++++++++++++++++ src/app/(dashboard)/recherche-reseau/page.tsx | 17 + src/app/api/qlik/search/route.ts | 219 ++++++++ src/components/shared/sidebar.tsx | 3 +- src/features/grid/components/heatmap-grid.tsx | 138 +---- .../network/components/network-trend.tsx | 152 +++++ src/lib/pg-ff-client.ts | 49 ++ src/lib/qlik-client.ts | 14 + src/lib/qlik-playwright.ts | 470 +++++++++++++++- src/types/network-search.ts | 59 ++ 10 files changed, 1514 insertions(+), 132 deletions(-) create mode 100644 src/app/(dashboard)/recherche-reseau/client.tsx create mode 100644 src/app/(dashboard)/recherche-reseau/page.tsx create mode 100644 src/app/api/qlik/search/route.ts create mode 100644 src/features/network/components/network-trend.tsx create mode 100644 src/types/network-search.ts diff --git a/src/app/(dashboard)/recherche-reseau/client.tsx b/src/app/(dashboard)/recherche-reseau/client.tsx new file mode 100644 index 0000000..8f56abc --- /dev/null +++ b/src/app/(dashboard)/recherche-reseau/client.tsx @@ -0,0 +1,525 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + Search, Loader2, AlertTriangle, PackageSearch, FileDown, Info, + ChevronUp, ChevronDown, ChevronsUpDown, Store, Sparkles, +} from "lucide-react"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { + computeNetworkTrend, TrendSparkline, NetworkLineChart, TREND_COLOR, trendLabel, +} from "@/features/network/components/network-trend"; +import type { NetworkSearchJobState, NetworkSearchRow } from "@/types/network-search"; +import { cn } from "@/lib/utils"; + +// ─── Formatage ──────────────────────────────────────────────────────────────── +const fmtEur = (n?: number | null) => + n == null ? "-" : n.toLocaleString("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 }); +const fmtNum = (n?: number | null) => + n == null ? "-" : Math.round(n).toLocaleString("fr-FR"); +/** Qlik renvoie soit un ratio (0.32) soit déjà des points (32) — même règle que la Grille. */ +const toPct = (v: number) => (Math.abs(v) <= 1 ? v * 100 : v); + +// ─── Tri ────────────────────────────────────────────────────────────────────── +type SortKey = "libelle" | "codeCentrale" | "fournisseur" | "caReseau" | "qteReseau" + | "nbMagasinsReseau" | "caParMagasinReseau" | "margePctReseau" | "trend" | "stockTotal"; +type SortDir = "asc" | "desc"; + +/** Filtres : tout / seulement nos références / seulement les produits inconnus. */ +type Filter = "all" | "mine" | "network"; + +export function RechercheReseauClient() { + const [query, setQuery] = useState(""); + const [job, setJob] = useState(null); + const [error, setError] = useState(null); + const [elapsed, setElapsed] = useState(0); + const [filter, setFilter] = useState("all"); + const [sortKey, setSortKey] = useState("caReseau"); + const [sortDir, setSortDir] = useState("desc"); + const [modalRow, setModalRow] = useState(null); + const [exporting, setExporting] = useState(false); + const pollRef = useRef | null>(null); + + const isRunning = job?.status === "running"; + + // ── Lancement de la recherche ───────────────────────────────────────────── + const startSearch = useCallback(async () => { + const q = query.trim(); + if (!q || isRunning) return; + setError(null); + setElapsed(0); + try { + const res = await fetch("/api/qlik/search", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ query: q }), + }); + const data = await res.json(); + if (!res.ok) { + setError(data.error ?? `Erreur ${res.status}`); + return; + } + setJob(data as NetworkSearchJobState); + setFilter("all"); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } + }, [query, isRunning]); + + // ── Polling tant que le job tourne ──────────────────────────────────────── + useEffect(() => { + if (!isRunning || !job?.jobId) return; + let cancelled = false; + const tick = async () => { + try { + const res = await fetch(`/api/qlik/search?jobId=${encodeURIComponent(job.jobId!)}`); + const data = await res.json(); + if (cancelled) return; + if (res.ok) setJob(data as NetworkSearchJobState); + } catch { + // Réseau instable : on retentera au prochain tick. + } + if (!cancelled) pollRef.current = setTimeout(tick, 2000); + }; + pollRef.current = setTimeout(tick, 1500); + return () => { + cancelled = true; + if (pollRef.current) clearTimeout(pollRef.current); + }; + }, [isRunning, job?.jobId]); + + // Chronomètre d'attente (l'extraction Qlik prend typiquement 20-40 s). + useEffect(() => { + if (!isRunning) return; + const id = setInterval(() => setElapsed((e) => e + 1), 1000); + return () => clearInterval(id); + }, [isRunning]); + + const results = useMemo(() => job?.results ?? [], [job?.results]); + const counts = useMemo(() => ({ + all: results.length, + mine: results.filter((r) => r.chezMoi).length, + network: results.filter((r) => !r.chezMoi).length, + }), [results]); + + const rows = useMemo(() => { + const filtered = results.filter((r) => + filter === "all" ? true : filter === "mine" ? r.chezMoi : !r.chezMoi); + const dir = sortDir === "asc" ? 1 : -1; + const val = (r: NetworkSearchRow): number | string => { + switch (sortKey) { + case "libelle": return r.libelle.toLowerCase(); + case "codeCentrale": return r.codeCentrale; + case "fournisseur": return (r.fournisseur ?? r.nomfou ?? "").toLowerCase(); + case "trend": return computeNetworkTrend(r.qteByMonth).pct ?? -Infinity; + case "stockTotal": return r.stockTotal ?? -1; + default: return r[sortKey] ?? 0; + } + }; + return [...filtered].sort((a, b) => { + const va = val(a), vb = val(b); + if (typeof va === "string" || typeof vb === "string") { + return String(va).localeCompare(String(vb), "fr") * dir; + } + return (va - vb) * dir; + }); + }, [results, filter, sortKey, sortDir]); + + const toggleSort = (key: SortKey) => { + if (sortKey === key) setSortDir((d) => (d === "asc" ? "desc" : "asc")); + else { setSortKey(key); setSortDir(key === "libelle" || key === "codeCentrale" || key === "fournisseur" ? "asc" : "desc"); } + }; + + // ── Export Excel ────────────────────────────────────────────────────────── + const exportExcel = useCallback(async () => { + if (!rows.length || exporting) return; + setExporting(true); + try { + const ExcelJS = (await import("exceljs")).default; + const wb = new ExcelJS.Workbook(); + const ws = wb.addWorksheet("Recherche réseau"); + // Colonnes mensuelles = union des mois présents, triée chronologiquement. + const months = [...new Set(rows.flatMap((r) => Object.keys(r.qteByMonth ?? {})))].sort().slice(-12); + ws.columns = [ + { header: "Libellé", key: "libelle", width: 46 }, + { header: "Code centrale", key: "code", width: 15 }, + { header: "Fournisseur", key: "fou", width: 24 }, + { header: "Famille", key: "fam", width: 20 }, + { header: "Statut", key: "statut", width: 18 }, + { header: "CA réseau", key: "ca", width: 14 }, + { header: "Qté réseau", key: "qte", width: 12 }, + { header: "Nb magasins", key: "nbmag", width: 12 }, + { header: "CA / magasin", key: "camag", width: 14 }, + { header: "Marge %", key: "marge", width: 10 }, + { header: "Code interne", key: "codein", width: 14 }, + { header: "Stock 292", key: "s292", width: 10 }, + { header: "Stock 579", key: "s579", width: 10 }, + ...months.map((m) => ({ header: m, key: `m_${m}`, width: 9 })), + ]; + ws.getRow(1).font = { bold: true }; + for (const r of rows) { + const monthCells: Record = {}; + for (const m of months) monthCells[`m_${m}`] = r.qteByMonth?.[m] ?? 0; + ws.addRow({ + libelle: r.libelle, + code: r.codeCentrale, + fou: r.fournisseur ?? r.nomfou ?? "", + fam: r.famille ?? "", + statut: r.chezMoi ? "Chez moi" : "Réseau uniquement", + ca: Math.round(r.caReseau), + qte: Math.round(r.qteReseau), + nbmag: r.nbMagasinsReseau, + camag: Math.round(r.caParMagasinReseau), + marge: Number(toPct(r.margePctReseau).toFixed(1)), + codein: r.codein ?? "", + s292: r.stock292 ?? "", + s579: r.stock579 ?? "", + ...monthCells, + }); + } + const buffer = await wb.xlsx.writeBuffer(); + const blob = new Blob([buffer], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `recherche-reseau-${(job?.query ?? "").replace(/[^\w-]+/g, "_").slice(0, 40)}.xlsx`; + a.click(); + URL.revokeObjectURL(url); + } finally { + setExporting(false); + } + }, [rows, exporting, job?.query]); + + return ( +
+
+ {/* ── En-tête ── */} +
+

+ + Recherche réseau +

+

+ Cherche dans le catalogue Qlik du réseau — y compris les produits que nous ne + référençons pas. Ventes mois par mois sur 12 mois glissants. +

+
+ + {/* ── Barre de recherche ── */} +
+
+
+ + setQuery(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter") startSearch(); }} + placeholder="Nom du produit (ex. tapis anti) ou code centrale (ex. 10000459340)" + disabled={isRunning} + className="w-full rounded-xl pl-9 pr-3 py-2.5 text-[14px] outline-none disabled:opacity-60" + style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-primary)" }} + /> +
+ +
+

+ + L'interrogation de Qlik prend en général 20 à 40 secondes. Plusieurs codes centraux peuvent être + collés d'un coup (séparés par des espaces ou des virgules). +

+
+ + {/* ── Erreurs ── */} + {error && ( +
+ + {error} +
+ )} + {job?.status === "error" && job.error && ( +
+ + {job.error} +
+ )} + {job?.status === "success" && job.tooMany && ( +
+ + + Recherche trop large : {job.matchCount} produits correspondent + {job.maxProducts ? <> (maximum {job.maxProducts}) : null}. Précisez les termes pour + protéger le moteur Qlik. + +
+ )} + {job?.status === "success" && !job.tooMany && results.length === 0 && ( +
+ Aucun produit trouvé dans Qlik pour « {job.query} ». +
+ )} + + {/* ── Résultats ── */} + {results.length > 0 && ( + <> + {/* Filtres + résumé + export */} +
+ {([ + { key: "all" as Filter, label: "Tous", n: counts.all }, + { key: "mine" as Filter, label: "Chez moi", n: counts.mine }, + { key: "network" as Filter, label: "Réseau uniquement", n: counts.network }, + ]).map(({ key, label, n }) => ( + + ))} +
+ {job?.periode && ( + + Période Qlik : {job.periode} + + )} + +
+ + {/* Bandeau opportunités : produits forts au réseau que nous n'avons pas */} + {counts.network > 0 && filter !== "mine" && ( +
+ + + {counts.network} produit{counts.network > 1 ? "s" : ""} vendu + {counts.network > 1 ? "s" : ""} par le réseau que nous ne référençons pas — trier par + « CA / Mag » ou « Nb mag » fait remonter les meilleures opportunités d'assortiment. + +
+ )} + + + + )} +
+ + { if (!open) setModalRow(null); }}> + {modalRow !== null && } + +
+ ); +} + +// ─── Tableau des résultats ──────────────────────────────────────────────────── + +const COLUMNS: Array<{ key: SortKey; label: string; sub?: string; align: "left" | "center" | "right" }> = [ + { key: "libelle", label: "Produit", align: "left" }, + { key: "codeCentrale", label: "Code centrale", align: "left" }, + { key: "fournisseur", label: "Fournisseur", align: "left" }, + { key: "caReseau", label: "CA", sub: "Réseau", align: "right" }, + { key: "qteReseau", label: "Qté", sub: "Réseau", align: "right" }, + { key: "nbMagasinsReseau", label: "Nb mag", sub: "Réseau", align: "right" }, + { key: "caParMagasinReseau", label: "CA / Mag", sub: "Réseau", align: "right" }, + { key: "margePctReseau", label: "Marge %", sub: "Réseau", align: "right" }, + { key: "trend", label: "Tendance", sub: "12 mois", align: "center" }, + { key: "stockTotal", label: "Stock", sub: "292 + 579", align: "right" }, +]; + +function ResultsTable({ rows, sortKey, sortDir, onSort, onRowClick }: { + rows: NetworkSearchRow[]; + sortKey: SortKey; + sortDir: SortDir; + onSort: (k: SortKey) => void; + onRowClick: (r: NetworkSearchRow) => void; +}) { + return ( +
+
+ + + + {COLUMNS.map((c) => { + const active = sortKey === c.key; + const Icon = !active ? ChevronsUpDown : sortDir === "asc" ? ChevronUp : ChevronDown; + return ( + + ); + })} + + + + {rows.map((r) => { + const trend = computeNetworkTrend(r.qteByMonth); + const margePct = toPct(r.margePctReseau); + return ( + onRowClick(r)} + className="cursor-pointer hover:bg-[var(--bg-elevated)] transition-colors" + style={{ borderBottom: "1px solid var(--border)" }} + > + + + + + + + + + + + + ); + })} + +
onSort(c.key)} + className="px-3 py-2.5 font-semibold cursor-pointer select-none whitespace-nowrap" + style={{ color: "var(--text-secondary)", borderBottom: "1px solid var(--border)", textAlign: c.align }} + > + + + + {c.label} + {c.sub && <>
{c.sub}} +
+
+
+
+ {r.libelle} +
+ + {r.chezMoi ? `Chez moi${r.codein ? ` · ${r.codein}` : ""}` : "Réseau uniquement"} + +
{r.codeCentrale} + {r.fournisseur ?? r.nomfou ?? "-"} + {fmtEur(r.caReseau)}{fmtNum(r.qteReseau)}{fmtNum(r.nbMagasinsReseau)}{fmtEur(r.caParMagasinReseau)}= 30 ? "text-emerald-500" : margePct >= 15 ? "text-amber-500" : "text-rose-500")}> + {margePct.toLocaleString("fr-FR", { maximumFractionDigits: 1 })}% + + {r.chezMoi ? fmtNum(r.stockTotal ?? 0) : "-"} +
+
+
+ ); +} + +// ─── Fiche produit ──────────────────────────────────────────────────────────── + +/** Détail d'un produit : courbe des ventes réseau 12 mois + informations Qlik / locales. */ +function ProductDetailModal({ row }: { row: NetworkSearchRow }) { + const trend = computeNetworkTrend(row.qteByMonth); + const { values, labels, direction, pct } = trend; + const color = TREND_COLOR[direction]; + const margePct = toPct(row.margePctReseau); + + const infos: Array<{ label: string; value: string }> = [ + { label: "Code centrale", value: row.codeCentrale }, + { label: "Fournisseur", value: row.fournisseur ?? row.nomfou ?? "-" }, + ...(row.famille ? [{ label: "Famille", value: row.famille }] : []), + { label: "CA réseau", value: fmtEur(row.caReseau) }, + { label: "Qté réseau", value: fmtNum(row.qteReseau) }, + { label: "Nb magasins", value: fmtNum(row.nbMagasinsReseau) }, + { label: "CA / magasin", value: fmtEur(row.caParMagasinReseau) }, + { label: "Marge % réseau", value: `${margePct.toLocaleString("fr-FR", { maximumFractionDigits: 1 })}%` }, + ]; + if (row.chezMoi) { + infos.push({ label: "Code interne", value: row.codein ?? "-" }); + if (row.pa != null) infos.push({ label: "PA", value: fmtEur(row.pa) }); + if (row.pvCentral != null) infos.push({ label: "PV central", value: fmtEur(row.pvCentral) }); + infos.push({ label: "Stock 292", value: fmtNum(row.stock292 ?? 0) }); + infos.push({ label: "Stock 579", value: fmtNum(row.stock579 ?? 0) }); + } + + return ( + + + + {row.libelle} + +

+ + {row.chezMoi ? "Chez moi" : "Réseau uniquement"} + + Ventes réseau · {labels.length > 0 ? `${labels.length} derniers mois` : "12 derniers mois"} + {pct != null && ( + + · {pct >= 0 ? "+" : ""}{Math.round(pct * 100)}% · {trendLabel(pct)} + + )} +

+
+ + {!trend.hasData ? ( +
+ Pas de détail mensuel renvoyé par Qlik pour ce produit. +
+ ) : ( + + )} + +
+ {infos.map((i) => ( +
+ {i.label} + {i.value} +
+ ))} +
+ + {!row.chezMoi && ( +
+ + + Ce produit est vendu dans {fmtNum(row.nbMagasinsReseau)} magasin + {row.nbMagasinsReseau > 1 ? "s" : ""} du réseau et génère {fmtEur(row.caParMagasinReseau)} de + CA par magasin, alors que nous ne le référençons pas. + +
+ )} +
+ ); +} diff --git a/src/app/(dashboard)/recherche-reseau/page.tsx b/src/app/(dashboard)/recherche-reseau/page.tsx new file mode 100644 index 0000000..648134a --- /dev/null +++ b/src/app/(dashboard)/recherche-reseau/page.tsx @@ -0,0 +1,17 @@ +import { RechercheReseauClient } from "./client"; + +export const metadata = { + title: "Recherche réseau — CollectFlow", +}; + +/** + * Recherche dans le catalogue réseau Qlik. + * + * Contrairement à la Grille (qui part de NOTRE catalogue), cette page interroge + * Qlik en premier : elle affiche donc aussi les produits vendus par le réseau + * que FF Nancy ne référence pas. Tout le travail est fait côté client via + * /api/qlik/search (job asynchrone), l'extraction Qlik prenant ~20-40 s. + */ +export default function RechercheReseauPage() { + return ; +} diff --git a/src/app/api/qlik/search/route.ts b/src/app/api/qlik/search/route.ts new file mode 100644 index 0000000..b0afa30 --- /dev/null +++ b/src/app/api/qlik/search/route.ts @@ -0,0 +1,219 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; +import { searchNetworkProductsPlaywright } from "@/lib/qlik-playwright"; +import { upsertNetworkMetrics } from "@/lib/qlik-network-cache"; +import { pgGetArticlesByCodeCentrale, pgGetStockForCodeins, type StockBySite } from "@/lib/pg-ff-client"; +import { buildGridNetworkQlikDateFilter, envMonthsBack, QLIK_MONTHS_BACK_DEFAULT } from "@/lib/qlik-date-range"; +import type { NetworkSearchJobState, NetworkSearchRow } from "@/types/network-search"; + +// Comme la sync : l'ouverture de l'app Qlik + les cubes peuvent prendre plusieurs +// dizaines de secondes. On rend la main immédiatement et le client polle GET. +export const maxDuration = 300; +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +interface SearchJob extends NetworkSearchJobState { + jobId: string; + status: "running" | "success" | "error"; +} + +/** + * Store en mémoire (même parti pris que /api/qlik/sync : simple, perdu au restart). + * + * `activeJobId` sérialise les recherches : le moteur Qlik sature facilement, on ne + * lance donc **qu'une extraction à la fois** pour toute l'application. + */ +const jobs = new Map(); +let activeJobId: string | null = null; + +/** Ne garde que les N derniers jobs pour éviter une fuite mémoire. */ +function pruneJobs(keep = 20): void { + if (jobs.size <= keep) return; + const ordered = [...jobs.values()].sort((a, b) => String(a.startedAt).localeCompare(String(b.startedAt))); + for (const j of ordered.slice(0, jobs.size - keep)) { + if (j.jobId !== activeJobId) jobs.delete(j.jobId); + } +} + +function newJobId(): string { + return `qsearch_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`; +} + +/** Vérifie qu'une session est ouverte. Renvoie une 401 sinon. */ +async function requireSession(): Promise { + const session = await auth(); + if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + return null; +} + +/** + * Traduit une erreur Qlik en message actionnable. La saturation mémoire du serveur + * Qlik est le cas le plus fréquent et n'est PAS un bug CollectFlow : l'application + * Qlik ne se charge même pas. + */ +function humanizeQlikError(msg: string): string { + 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')) { + return "Serveur Qlik saturé : mémoire insuffisante pour charger l'application. Réessayez plus tard (ou faites libérer / augmenter la RAM du serveur Qlik)."; + } + return msg; +} + +/** Exécute la recherche Qlik puis enrichit avec le catalogue local. */ +async function runJob(job: SearchJob, maxProducts: number): Promise { + try { + const monthsBack = envMonthsBack("QLIK_SYNC_MONTHS_BACK", QLIK_MONTHS_BACK_DEFAULT); + const dateFilter = buildGridNetworkQlikDateFilter(new Date(), monthsBack); + console.log(`[api/qlik/search] job=${job.jobId} query="${job.query}" fenêtre=${dateFilter.label}`); + + const res = await searchNetworkProductsPlaywright(job.query, { maxProducts }, undefined, dateFilter); + job.mode = res.mode; + job.matchCount = res.matchCount; + job.periode = res.periode; + + if (res.tooMany) { + job.tooMany = true; + job.results = []; + job.status = "success"; + job.finishedAt = new Date().toISOString(); + console.log(`[api/qlik/search] job=${job.jobId} recherche trop large (${res.matchCount} > ${maxProducts})`); + return; + } + + const products = res.products; + console.log(`[api/qlik/search] job=${job.jobId} ← Qlik: ${products.length} produits`); + + // Mise en cache : accélère les recherches suivantes ET enrichit la Grille + // pour les codes que nous référençons. + if (products.length) { + try { + const n = await upsertNetworkMetrics(products); + console.log(`[api/qlik/search] job=${job.jobId} ${n} lignes mises en cache`); + } catch (e) { + // Non fatal : la recherche reste exploitable sans cache. + console.error(`[api/qlik/search] job=${job.jobId} cache KO:`, e instanceof Error ? e.message : String(e)); + } + } + + // Enrichissement local : qui connaît-on déjà, et avec quel stock ? + const locaux = await pgGetArticlesByCodeCentrale(products.map((p) => p.codeCentrale)); + const codeins = [...locaux.values()].map((a) => a.codein).filter(Boolean); + const stocks: Map = codeins.length + ? await pgGetStockForCodeins(codeins) + : new Map(); + + const rows: NetworkSearchRow[] = products.map((p) => { + const local = locaux.get(p.codeCentrale); + const stock = local?.codein ? stocks.get(local.codein) : undefined; + return { + codeCentrale: p.codeCentrale, + libelle: p.libelle || local?.libelle1 || p.codeCentrale, + fournisseur: p.fournisseur, + famille: p.famille, + caReseau: p.caReseau, + qteReseau: p.qteReseau, + nbMagasinsReseau: p.nbMagasinsReseau, + caParMagasinReseau: p.caParMagasinReseau, + margePctReseau: p.margePctReseau, + qteByMonth: p.qteByMonth ?? null, + chezMoi: Boolean(local), + codein: local?.codein, + libelleLocal: local?.libelle1, + codefou: local?.codefou, + nomfou: local?.nomfou, + pa: local?.pa != null ? Number(local.pa) : undefined, + pvCentral: local?.pv_central != null ? Number(local.pv_central) : undefined, + stock292: stock?.stock292, + stock579: stock?.stock579, + stockTotal: stock?.stockTotal, + }; + }); + + job.results = rows; + job.status = "success"; + job.finishedAt = new Date().toISOString(); + console.log(`[api/qlik/search] job=${job.jobId} terminé — ${rows.length} lignes (${rows.filter((r) => r.chezMoi).length} déjà référencées)`); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + console.error(`[api/qlik/search] job=${job.jobId}`, msg); + job.status = "error"; + job.error = humanizeQlikError(msg); + job.finishedAt = new Date().toISOString(); + } finally { + if (activeJobId === job.jobId) activeJobId = null; + pruneJobs(); + } +} + +/** + * GET /api/qlik/search?jobId=XXX + * État + résultats du job. Sans jobId, renvoie le job actif s'il y en a un. + */ +export async function GET(req: NextRequest) { + const denied = await requireSession(); + if (denied) return denied; + + const jobId = req.nextUrl.searchParams.get("jobId"); + const job = jobId ? jobs.get(jobId) : (activeJobId ? jobs.get(activeJobId) : undefined); + if (!job) { + const idle: NetworkSearchJobState = { jobId: null, status: "idle", query: "" }; + return NextResponse.json({ success: true, ...idle }); + } + return NextResponse.json({ success: true, ...job }); +} + +/** + * POST /api/qlik/search body: { query: string, maxProducts?: number } + * Démarre une recherche en arrière-plan et retourne immédiatement le job. + * Refuse (409) si une recherche tourne déjà — le moteur Qlik ne supporte pas + * plusieurs extractions concurrentes. + */ +export async function POST(req: NextRequest) { + const denied = await requireSession(); + if (denied) return denied; + + let body: { query?: unknown; maxProducts?: unknown }; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Corps JSON invalide" }, { status: 400 }); + } + + const query = String(body.query ?? "").trim(); + if (!query) return NextResponse.json({ error: "Param 'query' requis" }, { status: 400 }); + if (query.length > 120) return NextResponse.json({ error: "Recherche trop longue (120 caractères max)" }, { status: 400 }); + + const rawMax = Number(body.maxProducts); + const maxProducts = Number.isFinite(rawMax) ? Math.min(2000, Math.max(1, Math.trunc(rawMax))) : 300; + + const active = activeJobId ? jobs.get(activeJobId) : undefined; + if (active && active.status === "running") { + return NextResponse.json( + { error: `Une recherche est déjà en cours ("${active.query}"). Attendez qu'elle se termine.`, ...active }, + { status: 409 }, + ); + } + + const job: SearchJob = { + jobId: newJobId(), + status: "running", + query, + maxProducts, + startedAt: new Date().toISOString(), + }; + jobs.set(job.jobId, job); + activeJobId = job.jobId; + console.log(`[api/qlik/search] job=${job.jobId} démarré — query="${query}" max=${maxProducts}`); + + // Fire-and-forget : le client polle GET. runJob capture ses propres erreurs. + void runJob(job, maxProducts).catch((e) => { + const msg = e instanceof Error ? e.message : String(e); + console.error(`[api/qlik/search] job=${job.jobId} crash non géré`, msg); + job.status = "error"; + job.error = msg; + job.finishedAt = new Date().toISOString(); + if (activeJobId === job.jobId) activeJobId = null; + }); + + return NextResponse.json({ success: true, ...job }); +} diff --git a/src/components/shared/sidebar.tsx b/src/components/shared/sidebar.tsx index d9d9dc0..a2ae629 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, BarChart2, TrendingUp, Trophy, Megaphone, PackageMinus, PackageSearch, 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: "Recherche réseau", href: "/recherche-reseau" }, { 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/features/grid/components/heatmap-grid.tsx b/src/features/grid/components/heatmap-grid.tsx index 4f967a0..85c0fe6 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,6 +26,13 @@ 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"; +import { + computeNetworkTrend, + TrendSparkline, + NetworkLineChart, + TREND_COLOR, + TREND_STRONG, +} from "@/features/network/components/network-trend"; function getLast12Months(): string[] { const months: string[] = []; @@ -238,135 +245,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); diff --git a/src/features/network/components/network-trend.tsx b/src/features/network/components/network-trend.tsx new file mode 100644 index 0000000..f3b73cf --- /dev/null +++ b/src/features/network/components/network-trend.tsx @@ -0,0 +1,152 @@ +"use client"; + +/** + * CollectFlow — Tendance réseau Qlik (composants partagés). + * + * Extrait de `features/grid/components/heatmap-grid.tsx` pour être réutilisé par + * la page Recherche réseau. Le comportement est identique : seule la localisation + * du code change. + * + * Source des données : `qteByMonth` ({ "YYYY-MM": quantité }) produit par la + * synchronisation Qlik (mesure « Quantité N », complétée par « Quantité COMP » + * et une passe année N-1 pour obtenir 12 mois glissants contigus). + */ + +import { TrendingUp, TrendingDown, Minus } from "lucide-react"; + +export type NetworkTrend = { + values: number[]; // quantités mensuelles, ordre chronologique + labels: string[]; // "YYYY-MM" + direction: "up" | "down" | "flat"; + pct: number | null; // variation modélisée (régression linéaire) sur 12 mois + hasData: boolean; +}; + +/** + * Tendance réseau par RÉGRESSION LINÉAIRE (moindres carrés) sur les 12 derniers mois. + * Utilise tous les points (robuste au bruit d'un mois isolé). L'indicateur `pct` est la + * variation modélisée sur la période (pente × durée) rapportée à la moyenne. + * Direction : forte hausse >+25%, hausse >+8%, stable, baisse <−8%, forte baisse <−25%. + */ +export function computeNetworkTrend(qteByMonth?: Record | 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. */ +export const TREND_STRONG = 0.25; + +export const TREND_COLOR: Record = { + up: "#22c55e", + down: "#ef4444", + flat: "#94a3b8", +}; + +/** Libellé français de la tendance (« Forte hausse », « Stable »…). */ +export function trendLabel(pct: number): string { + if (pct > TREND_STRONG) return "Forte hausse"; + if (pct > 0.08) return "Hausse"; + if (pct < -TREND_STRONG) return "Forte baisse"; + if (pct < -0.08) return "Baisse"; + return "Stable"; +} + +/** 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)}% + + )} +
+ ); +} + +/** "2026-01" → "01/26" pour un axe compact. */ +export function fmtMonthShort(lab: string): string { + const [y, m] = lab.split("-"); + return `${m ?? lab}/${(y ?? "").slice(2)}`; +} + +/** 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])} + + + ))} + +
+ ); +} diff --git a/src/lib/pg-ff-client.ts b/src/lib/pg-ff-client.ts index 9b5da6c..9fefe20 100644 --- a/src/lib/pg-ff-client.ts +++ b/src/lib/pg-ff-client.ts @@ -157,6 +157,55 @@ export async function pgGetArticlesByFournisseur(codefou: string): Promise r.codein); } +/** Article local (FF Nancy) résolu depuis un code centrale — page Recherche réseau. */ +export interface PgArticleLocal { + codein: string; + codeCentrale: string; + libelle1?: string; + codefou?: string; + nomfou?: string; + pa?: number; + pv_central?: number; +} + +/** + * Résout des codes centraux vers NOS articles locaux. + * + * Sert à distinguer, dans les résultats de recherche Qlik, les produits que nous + * référençons (« Chez moi ») de ceux qui n'existent que dans le réseau. Un code + * absent de la Map = produit inconnu de FF Nancy. + */ +export async function pgGetArticlesByCodeCentrale(codes: string[]): Promise> { + const unique = [...new Set(codes.map(c => String(c ?? "").trim()).filter(Boolean))]; + if (unique.length === 0) return new Map(); + + const result = await pgNoParallel(sql` + SELECT DISTINCT ON (a.artcentrale) + TRIM(a.codein::text) AS codein, + a.artcentrale AS "codeCentrale", + a.libelle1, + af.code AS codefou, + fi.nom AS nomfou, + pa.pa, + ai.prix_vente_mini AS pv_central + FROM articles a + LEFT JOIN artfou1 af ON af.art_no_id = a.no_id + LEFT JOIN fouident fi ON fi.code = af.code + LEFT JOIN cube_pa pa ON pa.artnoid = a.no_id + LEFT JOIN article_infosup ai ON ai.artnoid = a.no_id + WHERE a.artcentrale IN (${sql.join(unique.map(c => sql`${c}`), sql`, `)}) + AND a.codein IS NOT NULL + ORDER BY a.artcentrale, af.no_id + `); + + const map = new Map(); + for (const r of result.rows as unknown as PgArticleLocal[]) { + if (r.codeCentrale) map.set(String(r.codeCentrale).trim(), r); + } + console.log(`[pg-ff] pgGetArticlesByCodeCentrale: ${map.size}/${unique.length} codes connus localement`); + return map; +} + // --------------------------------------------------------------------------- // 2. Données mensuelles (ventes + stock + réceptions) // --------------------------------------------------------------------------- diff --git a/src/lib/qlik-client.ts b/src/lib/qlik-client.ts index 49214f4..282aab6 100644 --- a/src/lib/qlik-client.ts +++ b/src/lib/qlik-client.ts @@ -93,6 +93,20 @@ export interface NetworkMetric { qteByMonth?: Record; } +/** + * Produit réseau tel que renvoyé par la **recherche** Qlik (page Recherche réseau). + * + * Différence clé avec `NetworkMetric` : ces produits viennent du catalogue Qlik + * du réseau, pas de notre catalogue local. Ils peuvent donc être totalement + * inconnus de FF Nancy — d'où le libellé, qui n'est pas récupérable en base. + */ +export interface NetworkProduct extends NetworkMetric { + /** Libellé article tel qu'il apparaît dans Qlik (dimension « Article »). */ + libelle: string; + fournisseur?: string; + famille?: string; +} + function makeXrfkey(): string { return randomBytes(12).toString("base64").replace(/[^a-zA-Z0-9]/g, "").slice(0, 16).padEnd(16, "0"); } diff --git a/src/lib/qlik-playwright.ts b/src/lib/qlik-playwright.ts index 16a491e..7ebf069 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 NetworkProduct } from "@/lib/qlik-client"; import { buildGridNetworkQlikDateFilter, getMonthRanges, type QlikDateFilter } from "@/lib/qlik-date-range"; let browserPromise: Promise | null = null; @@ -860,3 +860,471 @@ export async function fetchNetworkMetricsPlaywright( await ctx.close(); } } + +// ─────────────────────────────────────────────────────────────────────────────── +// Recherche produits dans le CATALOGUE RÉSEAU Qlik +// ─────────────────────────────────────────────────────────────────────────────── +// +// `fetchNetworkMetricsPlaywright` part de NOS codes centraux : elle ne peut donc +// jamais révéler un produit du réseau que FF Nancy ne référence pas. La recherche +// ci-dessous fait l'inverse — elle interroge Qlik d'abord : +// +// • mode "code" → SelectValues sur le champ « Article Code » (un ou plusieurs codes) +// • mode "label" → recherche plein texte sur la dimension « Article » via +// SearchListObjectFor + AcceptListObjectSearch (équivalent d'une +// saisie dans un filtre Qlik : tous les mots doivent matcher). +// +// Le mode "label" évite d'avoir à deviner le nom exact du champ libellé : on tente +// d'abord la master dimension par qLibraryId, puis une liste de noms de champs. + +/** Résultat d'une recherche réseau. */ +export interface NetworkSearchResult { + products: NetworkProduct[]; + /** Nb de valeurs « Article » trouvées par Qlik (mode label), ou nb de codes demandés. */ + matchCount: number; + /** true si la recherche a été refusée car trop large (matchCount > maxProducts). */ + tooMany: boolean; + mode: "code" | "label"; + periode?: string; +} + +interface InPageSearchResult { + ok: boolean; + /** [code, libelle, ca, qte, nbMag, caMag, margePct] — passe principale uniquement. */ + rows?: Array>; + monthly?: Record>; + meta?: Record; + matchCount?: number; + tooMany?: boolean; + error?: string; +} + +/** Découpe la saisie utilisateur en codes centraux si elle n'en contient QUE. */ +function parseCodeQuery(query: string): string[] | null { + const parts = query.trim().split(/[\s,;]+/).filter(Boolean); + if (parts.length === 0) return null; + // Un code centrale FF fait 11 chiffres (10000XXXXXX) ; on accepte 5+ chiffres + // pour tolérer des saisies partielles côté centrale. + if (!parts.every((p) => /^\d{5,}$/.test(p))) return null; + return [...new Set(parts)]; +} + +/** + * Cherche des produits dans le catalogue réseau Qlik par libellé ou par code centrale, + * et renvoie leurs métriques réseau + les quantités mois par mois (12 mois glissants). + */ +export async function searchNetworkProductsPlaywright( + query: string, + opts: { maxProducts?: number } = {}, + cfg: QlikConfig = getQlikConfig(), + dateFilter: QlikDateFilter | null = buildGridNetworkQlikDateFilter(), +): 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"); + const trimmed = query.trim(); + if (!trimmed) throw new Error("[qlik-pw] recherche vide"); + + const totalStart = Date.now(); + const codes = parseCodeQuery(trimmed); + const mode: "code" | "label" = codes ? "code" : "label"; + const maxProducts = Math.min(2000, Math.max(1, opts.maxProducts ?? envNumber("QLIK_SEARCH_MAX_PRODUCTS", 300, 1, 2000))); + const qlikSettleMs = envNumber("QLIK_SETTLE_MS", 150, 0, 2000); + const qlikCode15MaxAttempts = envNumber("QLIK_CODE15_MAX_ATTEMPTS", 4, 1, 8); + const qlikMoisDimId = (process.env.QLIK_MOIS_DIM_ID ?? "pfGAwTs").trim(); + const qlikMeasQteCompId = (process.env.QLIK_MEAS_QTE_COMP_ID ?? "96862880-76cd-4957-bf25-b96901f2ac5f").trim(); + const qlikDimArticleLabelId = (process.env.QLIK_DIM_ARTICLE_LABEL_ID ?? "BcFj").trim(); + // Noms de champs candidats pour le libellé article (repli si la master dimension échoue). + const labelFieldCandidates = [ + ...(process.env.QLIK_FIELD_ARTICLE_LABEL ? [process.env.QLIK_FIELD_ARTICLE_LABEL.trim()] : []), + "Article", "Libellé Article", "Libelle Article", "Article Libellé", "Désignation", + ]; + const yearN = new Date().getFullYear(); + + console.log(`[qlik-pw][search] mode=${mode} query="${trimmed}" max=${maxProducts}${dateFilter ? ` fenêtre=${dateFilter.label}` : ""}`); + 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-pw][search][page] ${msg.text()}`)); + page.on("pageerror", (e) => console.log(`[qlik-pw][search][pageerror] ${e.message}`)); + + 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 ouvre son ws ensuite */ }); + await Promise.race([tokenReady, page.waitForTimeout(15000)]); + if (!csrfToken) throw new Error("[qlik-pw] qlik-csrf-token introuvable (client Qlik non chargé ?)"); + + const allSerials = dateFilter ? getMonthRanges(dateFilter).flatMap((m) => m.dailySerials) : []; + + const result = (await page.evaluate( + ({ app, token, dimCode, dimLabelId, labelFields, mca, mqte, mnb, mcamag, mmarge, mqteComp, moisDimId, mode, codes, match, allSerials, maxProducts, settleMs, code15Attempts, yearN }: { + app: string; token: string; dimCode: string; dimLabelId: string; labelFields: string[]; + mca: string; mqte: string; mnb: string; mcamag: string; mmarge: string; mqteComp: string; + moisDimId: string; mode: "code" | "label"; codes: string[]; match: string; + allSerials: number[]; maxProducts: number; settleMs: number; code15Attempts: number; yearN: 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); + 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 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); + 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); + }); + const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + const msgOf = (e: unknown) => String((e as Error)?.message ?? e); + const isEngineCode15 = (e: unknown): boolean => /"code"\s*:\s*15\b/.test(msgOf(e)); + const rpcWithRetry = async (etape: string, method: string, params: unknown, handle: number): Promise<{ [k: string]: unknown }> => { + let lastErr: unknown; + for (let attempt = 1; attempt <= code15Attempts; attempt++) { + try { return await rpc(method, params, handle); } + catch (e) { + lastErr = e; + if (!isEngineCode15(e) || attempt === code15Attempts) throw e; + console.log("[search] retry code15 " + etape + ":" + method + " " + attempt + "/" + code15Attempts); + await sleep(settleMs * attempt); + } + } + throw lastErr; + }; + + (async () => { + try { + await ready; + const open = await rpc("OpenDoc", { qDocName: app }); + const doc = (open.qReturn as { qHandle: number }).qHandle; + + // Une recherche doit porter sur TOUT le catalogue : on repart d'un + // état sans sélection (l'app peut en avoir une enregistrée). Non + // fatal si l'Engine refuse. + try { await rpc("ClearAll", { qLockedAlso: false }, doc); await sleep(settleMs); } + catch (e) { console.log("[search] ClearAll ignoré: " + msgOf(e)); } + + // ── Résolution des mesures ET dimensions par TITRE ────────────── + const norm = (s: string) => s.replace(/\s+/g, " ").trim().toLowerCase(); + const listByTitle = async (qType: string, defKey: string, listKey: string): Promise> => { + const map = new Map(); + try { + const o = await rpc("CreateSessionObject", { qProp: { qInfo: { qType }, [defKey]: { qType: qType === "MeasureList" ? "measure" : "dimension", qData: { title: "/qMetaDef/title" } } } }, doc); + const l = await rpc("GetLayout", {}, (o.qReturn as { qHandle: number }).qHandle); + const layout = l.qLayout as Record }>; + for (const it of layout[listKey]?.qItems ?? []) { + const t = it.qMeta?.title ?? it.qData?.title ?? ""; + if (t) map.set(norm(t), it.qInfo.qId); + } + } catch (e) { console.log("[search] " + qType + " KO: " + msgOf(e)); } + return map; + }; + const measByTitle = await listByTitle("MeasureList", "qMeasureListDef", "qMeasureList"); + const dimByTitle = await listByTitle("DimensionList", "qDimensionListDef", "qDimensionList"); + const pickMeas = (title: string, fallback: string) => measByTitle.get(norm(title)) ?? fallback; + const rCa = pickMeas("CA N", mca); + const rQte = pickMeas("Quantité N", mqte); + const rNbMag = pickMeas("Magasin Ventes Nb N", mnb); + const rCamag = pickMeas("CA par Magasin N", mcamag); + const rMarge = pickMeas("Marge % N", mmarge); + const dimLabel = dimByTitle.get(norm("Article")) ?? dimLabelId; + const dimFou = dimByTitle.get(norm("Fournisseur")) ?? null; + const dimFam = dimByTitle.get(norm("Famille")) ?? null; + console.log("[search] dims: label=" + dimLabel + " fou=" + dimFou + " fam=" + dimFam); + + // ── Champs Date / Année ───────────────────────────────────────── + let dfh = -1, yfh = -1; + try { dfh = ((await rpc("GetField", { qFieldName: "Date" }, doc)).qReturn as { qHandle: number }).qHandle; } catch { dfh = -1; } + try { yfh = ((await rpc("GetField", { qFieldName: "Année" }, doc)).qReturn as { qHandle: number }).qHandle; } catch { yfh = -1; } + + // ── Sélection des articles recherchés ─────────────────────────── + let matchCount = -1; + if (mode === "code") { + const fh = ((await rpc("GetField", { qFieldName: "Article Code" }, doc)).qReturn as { qHandle: number }).qHandle; + await rpc("Clear", {}, fh); + await sleep(settleMs); + const sel = await rpc("SelectValues", { qFieldValues: codes.map((c) => ({ qText: c })), qToggleMode: false, qSoftLock: true }, fh); + console.log("[search] SelectValues Article Code (" + codes.length + ") → " + JSON.stringify(sel.qReturn)); + matchCount = codes.length; + await sleep(settleMs); + } else { + // Liste de recherche sur le libellé : master dimension d'abord, + // puis repli sur des noms de champs plausibles. + const defs: Array<{ label: string; def: Record }> = []; + // qHeight: 1 — on ne veut que la taille du résultat de recherche, + // mais certains Engine rejettent un qHeight nul. + const fetchDef = [{ qTop: 0, qLeft: 0, qWidth: 1, qHeight: 1 }]; + if (dimLabel) defs.push({ label: "libraryId:" + dimLabel, def: { qLibraryId: dimLabel, qInitialDataFetch: fetchDef } }); + for (const f of labelFields) defs.push({ label: "field:" + f, def: { qDef: { qFieldDefs: [f] }, qInitialDataFetch: fetchDef } }); + let lo: { handle: number; id: string } | null = null; + for (const d of defs) { + try { + const o = await rpc("CreateSessionObject", { qProp: { qInfo: { qType: "cf-search-lb" }, qListObjectDef: d.def } }, doc); + const ret = o.qReturn as { qHandle: number; qGenericId?: string; qId?: string }; + await rpc("GetLayout", {}, ret.qHandle); // valide que la définition tient + lo = { handle: ret.qHandle, id: String(ret.qGenericId ?? ret.qId ?? "") }; + console.log("[search] listObject OK via " + d.label); + break; + } catch (e) { console.log("[search] listObject KO via " + d.label + ": " + msgOf(e)); } + } + if (!lo) { ws.close(); return fail("impossible de construire la recherche sur le libellé article (dimension « Article » introuvable)"); } + + await rpcWithRetry("search", "SearchListObjectFor", { qPath: "/qListObjectDef", qMatch: match }, lo.handle); + await sleep(settleMs); + try { + const lol = await rpcWithRetry("searchLayout", "GetLayout", {}, lo.handle); + const cy = (lol.qLayout as { qListObject?: { qSize?: { qcy?: number } } })?.qListObject?.qSize?.qcy; + matchCount = typeof cy === "number" ? cy : -1; + } catch (e) { console.log("[search] layout recherche KO: " + msgOf(e)); } + console.log("[search] SearchListObjectFor \"" + match + "\" → " + matchCount + " valeur(s)"); + // Garde-fou : une recherche trop large ferait exploser le cube + // (et la mémoire du moteur Qlik, déjà sensible). + if (matchCount > maxProducts) { + try { if (lo.id) await rpc("DestroySessionObject", { qId: lo.id }, doc); } catch { /* noop */ } + ws.close(); + return resolve({ ok: true, tooMany: true, matchCount, rows: [], monthly: {}, meta: {} }); + } + await rpcWithRetry("accept", "AcceptListObjectSearch", { qPath: "/qListObjectDef", qToggleMode: false, qSoftLock: true }, lo.handle); + await sleep(settleMs); + try { if (lo.id) await rpc("DestroySessionObject", { qId: lo.id }, doc); } catch { /* noop */ } + } + + // ── Cube mensuel [Article Code, Article, Mois] × 6 mesures ────── + const normMois = (m: string): string => (/^\d{6}$/.test(m) ? m.slice(0, 4) + "-" + m.slice(4) : m); + const PAGEM = 1000; // 9 colonnes × 1000 = 9000 cellules < 10000 (limite Qlik) + const rows: Array> = []; + const monthlyByCode: Record> = {}; + + const fetchMonthCube = async (isMainPass: boolean): Promise => { + const o = await rpcWithRetry("createCube", "CreateSessionObject", { qProp: { qInfo: { qType: "cf-search-mois" }, qHyperCubeDef: { + qDimensions: [{ qLibraryId: dimCode }, { qLibraryId: dimLabel }, { qLibraryId: moisDimId }], + qMeasures: [{ qLibraryId: rCa }, { qLibraryId: rQte }, { qLibraryId: rNbMag }, { qLibraryId: rCamag }, { qLibraryId: rMarge }, { qLibraryId: mqteComp }], + qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 9, qHeight: PAGEM }], + } } }, doc); + const ret = o.qReturn as { qHandle: number; qGenericId?: string; qId?: string }; + const cubeId = String(ret.qGenericId ?? ret.qId ?? ""); + const onRow = (r: Array<{ qText?: string; qNum?: number }>) => { + const code = String(r[0]?.qText ?? "").trim(); + if (!code || code === "-") return; + const lib = String(r[1]?.qText ?? "").trim(); + const mm = normMois(String(r[2]?.qText ?? "")); + const ca = Number(r[3]?.qNum) || 0, qte = Number(r[4]?.qNum) || 0, nbMag = Number(r[5]?.qNum) || 0; + const caMag = Number(r[6]?.qNum) || 0, marge = Number(r[7]?.qNum) || 0, qteComp = Number(r[8]?.qNum) || 0; + // Les totaux réseau ne viennent QUE de la passe principale + // (sinon la passe N-1 doublerait CA/Qté). + if (isMainPass) rows.push([code, lib, ca, qte, nbMag, caMag, marge]); + const y = parseInt(mm.slice(0, 4), 10); + (monthlyByCode[code] ??= {}); + if (isMainPass && y === yearN) { + monthlyByCode[code][mm] = qte; + // COMP = même mois de l'année N-1 : comble la 1re moitié des 12 mois. + const mmPrev = String(y - 1) + mm.slice(4); + if (monthlyByCode[code][mmPrev] === undefined) monthlyByCode[code][mmPrev] = qteComp; + } else { + // Passe année N-1 : valeur directe, prioritaire sur COMP. + monthlyByCode[code][mm] = qte; + } + }; + try { + const layout = await rpcWithRetry("layout", "GetLayout", {}, ret.qHandle); + 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 first = hc.qDataPages?.find((p) => (p.qArea?.qTop ?? 0) === 0); + if (first?.qMatrix?.length) { for (const r of first.qMatrix) onRow(r); got += first.qMatrix.length; top = PAGEM; } + for (; top < size; top += PAGEM) { + const d = await rpcWithRetry("getCubeData", "GetHyperCubeData", { qPath: "/qHyperCubeDef", qPages: [{ qTop: top, qLeft: 0, qWidth: 9, qHeight: PAGEM }] }, ret.qHandle); + const matrix = (d.qDataPages as Array<{ qMatrix?: Array> }>)?.[0]?.qMatrix ?? []; + if (!matrix.length) break; + for (const r of matrix) onRow(r); + got += matrix.length; + if (matrix.length < PAGEM) break; + } + return got; + } finally { + try { if (cubeId) await rpc("DestroySessionObject", { qId: cubeId }, doc); } catch { /* noop */ } + } + }; + + // Une passe = (Année ?) + fenêtre Date 12 mois + cube mensuel. + // La sélection article posée plus haut reste active : on ne touche + // qu'aux champs Année / Date. + const runPass = async (selectYear: number | null): Promise => { + if (yfh !== -1) { + await rpc("Clear", {}, yfh); + await sleep(settleMs); + if (selectYear !== null) { + await rpc("SelectValues", { qFieldValues: [{ qNum: selectYear, qText: String(selectYear) }], qToggleMode: false, qSoftLock: true }, yfh); + await sleep(settleMs); + } + } + if (dfh !== -1 && allSerials.length) { + await rpc("Clear", {}, dfh); + await sleep(settleMs); + await rpc("SelectValues", { qFieldValues: allSerials.map((s) => ({ qNum: s, qText: String(s) })), qToggleMode: false, qSoftLock: true }, dfh); + await sleep(settleMs); + } + return fetchMonthCube(selectYear === null); + }; + + const gotMain = await runPass(null); + console.log("[search] passe principale — " + gotMain + " lignes, " + Object.keys(monthlyByCode).length + " codes"); + if (yfh !== -1) { + // Passe complémentaire : mois de N-1 hors de portée de COMP + // (ex. août→déc), indispensable pour 12 mois contigus. + try { + const gotPrev = await runPass(yearN - 1); + console.log("[search] passe N-1 — " + gotPrev + " lignes"); + } catch (e) { console.log("[search] passe N-1 ignorée: " + msgOf(e)); } + try { await rpc("Clear", {}, yfh); await sleep(settleMs); } catch { /* noop */ } + } + + // ── Métadonnées (fournisseur / famille) — best effort ─────────── + const meta: Record = {}; + if (dimFou || dimFam) { + try { + const extra: string[] = []; + if (dimFou) extra.push(dimFou); + if (dimFam) extra.push(dimFam); + const width = 1 + extra.length; + const o = await rpcWithRetry("metaCube", "CreateSessionObject", { qProp: { qInfo: { qType: "cf-search-meta" }, qHyperCubeDef: { + qDimensions: [{ qLibraryId: dimCode }, ...extra.map((q) => ({ qLibraryId: q }))], + qMeasures: [], + qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: width, qHeight: 1500 }], + } } }, doc); + const ret = o.qReturn as { qHandle: number; qGenericId?: string; qId?: string }; + const layout = await rpcWithRetry("metaLayout", "GetLayout", {}, ret.qHandle); + const hc = (layout.qLayout as { qHyperCube: { qDataPages?: Array<{ qMatrix?: Array> }> } }).qHyperCube; + for (const r of hc.qDataPages?.[0]?.qMatrix ?? []) { + const code = String(r[0]?.qText ?? "").trim(); + if (!code || code === "-") continue; + let i = 1; + const entry: { fournisseur?: string; famille?: string } = {}; + if (dimFou) { const v = String(r[i++]?.qText ?? "").trim(); if (v && v !== "-") entry.fournisseur = v; } + if (dimFam) { const v = String(r[i++]?.qText ?? "").trim(); if (v && v !== "-") entry.famille = v; } + meta[code] = entry; + } + try { await rpc("DestroySessionObject", { qId: String(ret.qGenericId ?? ret.qId ?? "") }, doc); } catch { /* noop */ } + } catch (e) { console.log("[search] méta fournisseur/famille ignorée: " + msgOf(e)); } + } + + ws.close(); + resolve({ ok: true, rows, monthly: monthlyByCode, meta, matchCount, tooMany: false }); + } catch (e) { try { ws.close(); } catch { /* noop */ } fail(msgOf(e)); } + })(); + }), + { + app: cfg.appNetwork, + token: csrfToken, + dimCode: cfg.dimCodeArticleId, + dimLabelId: qlikDimArticleLabelId, + labelFields: labelFieldCandidates, + mca: cfg.measCaId, + mqte: cfg.measQteId, + mnb: cfg.measNbMagId, + mcamag: cfg.measCaMagId, + mmarge: cfg.measMargePctId, + mqteComp: qlikMeasQteCompId, + moisDimId: qlikMoisDimId, + mode, + codes: codes ?? [], + match: trimmed, + allSerials, + maxProducts, + settleMs: qlikSettleMs, + code15Attempts: qlikCode15MaxAttempts, + yearN, + }, + )) as InPageSearchResult; + + if (!result.ok) throw new Error(`[qlik-pw][search] ${result.error}`); + const matchCount = result.matchCount ?? -1; + if (result.tooMany) { + console.log(`[qlik-pw][search] refusé — ${matchCount} valeurs > max ${maxProducts}`); + return { products: [], matchCount, tooMany: true, mode, periode: dateFilter?.label }; + } + + // Agrégation par code centrale (même pondération que la sync réseau : + // marge % moyennée par le CA). + const byCode = new Map(); + const margeWeight = new Map(); + for (const r of result.rows ?? []) { + const code = String(r[0]).trim(); + if (!code || code === "-") continue; + const libelle = String(r[1] ?? "").trim(); + const ca = Number(r[2]) || 0; + const qte = Number(r[3]) || 0; + const nbMag = Number(r[4]) || 0; + const margePct = Number(r[6]) || 0; + const prev = byCode.get(code); + if (!prev) { + byCode.set(code, { + codeCentrale: code, + libelle, + caReseau: ca, + qteReseau: qte, + nbMagasinsReseau: nbMag, + caParMagasinReseau: nbMag > 0 ? ca / nbMag : 0, + margePctReseau: margePct, + periode: dateFilter?.label, + }); + margeWeight.set(code, ca); + continue; + } + if (!prev.libelle && libelle) prev.libelle = libelle; + prev.caReseau += ca; + prev.qteReseau += qte; + prev.nbMagasinsReseau = Math.max(prev.nbMagasinsReseau, nbMag); + const weight = margeWeight.get(code) ?? 0; + const nextWeight = weight + ca; + prev.margePctReseau = nextWeight > 0 ? ((prev.margePctReseau * weight) + (margePct * ca)) / nextWeight : Math.max(prev.margePctReseau, margePct); + prev.caParMagasinReseau = prev.nbMagasinsReseau > 0 ? prev.caReseau / prev.nbMagasinsReseau : 0; + margeWeight.set(code, nextWeight); + } + + const monthly = result.monthly ?? {}; + const meta = result.meta ?? {}; + for (const [code, p] of byCode) { + const mm = monthly[code]; + if (mm && Object.keys(mm).length) p.qteByMonth = mm; + const m = meta[code]; + if (m?.fournisseur) p.fournisseur = m.fournisseur; + if (m?.famille) p.famille = m.famille; + } + + const products = [...byCode.values()].sort((a, b) => b.caReseau - a.caReseau); + console.log(`[qlik-pw][search] ${products.length} produits (match=${matchCount}) en ${Date.now() - totalStart}ms`); + return { products, matchCount: matchCount >= 0 ? matchCount : products.length, tooMany: false, mode, periode: dateFilter?.label }; + } finally { + await ctx.close(); + } +} diff --git a/src/types/network-search.ts b/src/types/network-search.ts new file mode 100644 index 0000000..c34f78e --- /dev/null +++ b/src/types/network-search.ts @@ -0,0 +1,59 @@ +/** + * CollectFlow — Types de la recherche réseau Qlik. + * + * Partagés entre la route `/api/qlik/search` et la page `/recherche-reseau`. + */ + +/** Une ligne de résultat : métriques Qlik du réseau + enrichissement local FF Nancy. */ +export interface NetworkSearchRow { + codeCentrale: string; + /** Libellé tel qu'il apparaît dans Qlik (seule source pour un produit non référencé). */ + libelle: string; + fournisseur?: string; + famille?: string; + + // ── Métriques réseau (Qlik, 12 mois glissants) ── + caReseau: number; + qteReseau: number; + nbMagasinsReseau: number; + caParMagasinReseau: number; + /** Ratio brut Qlik (0.32 = 32 %). */ + margePctReseau: number; + /** Quantité vendue réseau par mois : { "YYYY-MM": qté }. */ + qteByMonth: Record | null; + + // ── Enrichissement catalogue local ── + /** false = produit vendu par le réseau mais absent de notre catalogue. */ + chezMoi: boolean; + codein?: string; + libelleLocal?: string; + codefou?: string; + nomfou?: string; + pa?: number; + pvCentral?: number; + stock292?: number; + stock579?: number; + stockTotal?: number; +} + +export type NetworkSearchStatus = "idle" | "running" | "success" | "error"; + +/** État d'un job de recherche, tel que renvoyé par GET/POST /api/qlik/search. */ +export interface NetworkSearchJobState { + jobId: string | null; + status: NetworkSearchStatus; + query: string; + /** "code" = recherche par code centrale, "label" = recherche plein texte. */ + mode?: "code" | "label"; + /** Nb de valeurs « Article » trouvées par Qlik avant construction du cube. */ + matchCount?: number; + /** true = recherche trop large, refusée pour protéger le moteur Qlik. */ + tooMany?: boolean; + /** Nb max de produits accepté par recherche (affiché dans le message « trop large »). */ + maxProducts?: number; + periode?: string; + results?: NetworkSearchRow[]; + startedAt?: string; + finishedAt?: string; + error?: string; +}