mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-11 17:26:32 +02:00
feat(reseau): page Recherche réseau — chercher dans le catalogue Qlik
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675
This commit is contained in:
10 files changed
+1514
-132
No files matched your search
@@ -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<NetworkSearchJobState | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
const [filter, setFilter] = useState<Filter>("all");
|
||||
const [sortKey, setSortKey] = useState<SortKey>("caReseau");
|
||||
const [sortDir, setSortDir] = useState<SortDir>("desc");
|
||||
const [modalRow, setModalRow] = useState<NetworkSearchRow | null>(null);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const pollRef = useRef<ReturnType<typeof setTimeout> | 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<string, number> = {};
|
||||
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 (
|
||||
<div className="min-h-screen p-6" style={{ background: "var(--bg-base)" }}>
|
||||
<div className="mx-auto max-w-screen-2xl">
|
||||
{/* ── En-tête ── */}
|
||||
<div className="mb-5">
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2" style={{ color: "var(--text-primary)" }}>
|
||||
<PackageSearch className="w-6 h-6" style={{ color: "var(--accent)" }} />
|
||||
Recherche réseau
|
||||
</h1>
|
||||
<p className="text-[13px] mt-1" style={{ color: "var(--text-muted)" }}>
|
||||
Cherche dans le catalogue <strong>Qlik du réseau</strong> — y compris les produits que nous ne
|
||||
référençons pas. Ventes mois par mois sur 12 mois glissants.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ── Barre de recherche ── */}
|
||||
<div className="rounded-2xl p-4 mb-4" style={{ background: "var(--bg-surface)", border: "1px solid var(--border)" }}>
|
||||
<div className="flex flex-col sm:flex-row gap-2.5">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 pointer-events-none" style={{ color: "var(--text-muted)" }} />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => 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)" }}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={startSearch}
|
||||
disabled={isRunning || !query.trim()}
|
||||
className="rounded-xl px-5 py-2.5 text-[14px] font-semibold text-white disabled:opacity-50 flex items-center justify-center gap-2 shrink-0"
|
||||
style={{ background: "var(--accent)" }}
|
||||
>
|
||||
{isRunning ? <><Loader2 className="w-4 h-4 animate-spin" /> Recherche… {elapsed}s</> : <><Search className="w-4 h-4" /> Rechercher</>}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[11px] mt-2 flex items-start gap-1.5" style={{ color: "var(--text-muted)" }}>
|
||||
<Info className="w-3.5 h-3.5 shrink-0 mt-px" />
|
||||
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).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ── Erreurs ── */}
|
||||
{error && (
|
||||
<div className="rounded-xl p-3.5 mb-4 flex items-start gap-2.5 text-[13px]"
|
||||
style={{ background: "var(--accent-error-bg)", border: "1px solid var(--accent-error)", color: "var(--accent-error)" }}>
|
||||
<AlertTriangle className="w-4 h-4 shrink-0 mt-px" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
{job?.status === "error" && job.error && (
|
||||
<div className="rounded-xl p-3.5 mb-4 flex items-start gap-2.5 text-[13px]"
|
||||
style={{ background: "var(--accent-error-bg)", border: "1px solid var(--accent-error)", color: "var(--accent-error)" }}>
|
||||
<AlertTriangle className="w-4 h-4 shrink-0 mt-px" />
|
||||
<span>{job.error}</span>
|
||||
</div>
|
||||
)}
|
||||
{job?.status === "success" && job.tooMany && (
|
||||
<div className="rounded-xl p-3.5 mb-4 flex items-start gap-2.5 text-[13px]"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}>
|
||||
<AlertTriangle className="w-4 h-4 shrink-0 mt-px" />
|
||||
<span>
|
||||
Recherche trop large : <strong>{job.matchCount}</strong> produits correspondent
|
||||
{job.maxProducts ? <> (maximum {job.maxProducts})</> : null}. Précisez les termes pour
|
||||
protéger le moteur Qlik.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{job?.status === "success" && !job.tooMany && results.length === 0 && (
|
||||
<div className="rounded-xl p-6 text-center text-[13px]"
|
||||
style={{ background: "var(--bg-surface)", border: "1px solid var(--border)", color: "var(--text-muted)" }}>
|
||||
Aucun produit trouvé dans Qlik pour « {job.query} ».
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Résultats ── */}
|
||||
{results.length > 0 && (
|
||||
<>
|
||||
{/* Filtres + résumé + export */}
|
||||
<div className="flex flex-wrap items-center gap-2 mb-3">
|
||||
{([
|
||||
{ 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 }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setFilter(key)}
|
||||
className={cn("rounded-full px-3.5 py-1.5 text-[12px] font-semibold transition-colors")}
|
||||
style={{
|
||||
background: filter === key ? "var(--accent)" : "var(--bg-elevated)",
|
||||
color: filter === key ? "#fff" : "var(--text-secondary)",
|
||||
border: "1px solid var(--border)",
|
||||
}}
|
||||
>
|
||||
{label} <span className="opacity-70">({n})</span>
|
||||
</button>
|
||||
))}
|
||||
<div className="flex-1" />
|
||||
{job?.periode && (
|
||||
<span className="text-[11px]" style={{ color: "var(--text-muted)" }}>
|
||||
Période Qlik : {job.periode}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={exportExcel}
|
||||
disabled={exporting}
|
||||
className="rounded-xl px-3 py-1.5 text-[12px] font-semibold flex items-center gap-1.5 disabled:opacity-50"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}
|
||||
>
|
||||
{exporting ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <FileDown className="w-3.5 h-3.5" />}
|
||||
Excel
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Bandeau opportunités : produits forts au réseau que nous n'avons pas */}
|
||||
{counts.network > 0 && filter !== "mine" && (
|
||||
<div className="rounded-xl p-3 mb-3 flex items-start gap-2.5 text-[12px]"
|
||||
style={{ background: "var(--accent-bg)", border: "1px solid var(--accent-border)", color: "var(--text-secondary)" }}>
|
||||
<Sparkles className="w-4 h-4 shrink-0 mt-px" style={{ color: "var(--accent)" }} />
|
||||
<span>
|
||||
<strong>{counts.network}</strong> 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.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ResultsTable
|
||||
rows={rows}
|
||||
sortKey={sortKey}
|
||||
sortDir={sortDir}
|
||||
onSort={toggleSort}
|
||||
onRowClick={setModalRow}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog open={modalRow !== null} onOpenChange={(open) => { if (!open) setModalRow(null); }}>
|
||||
{modalRow !== null && <ProductDetailModal row={modalRow} />}
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 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 (
|
||||
<div className="rounded-2xl overflow-hidden" style={{ background: "var(--bg-surface)", border: "1px solid var(--border)" }}>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-[12px] border-collapse">
|
||||
<thead>
|
||||
<tr style={{ background: "var(--bg-elevated)" }}>
|
||||
{COLUMNS.map((c) => {
|
||||
const active = sortKey === c.key;
|
||||
const Icon = !active ? ChevronsUpDown : sortDir === "asc" ? ChevronUp : ChevronDown;
|
||||
return (
|
||||
<th
|
||||
key={c.key}
|
||||
onClick={() => 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 }}
|
||||
>
|
||||
<span className={cn("inline-flex items-center gap-1", c.align === "right" && "flex-row-reverse")}>
|
||||
<Icon className="w-3 h-3 shrink-0" style={{ opacity: active ? 1 : 0.35 }} />
|
||||
<span>
|
||||
{c.label}
|
||||
{c.sub && <><br /><span className="text-[9px] opacity-60 font-normal">{c.sub}</span></>}
|
||||
</span>
|
||||
</span>
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => {
|
||||
const trend = computeNetworkTrend(r.qteByMonth);
|
||||
const margePct = toPct(r.margePctReseau);
|
||||
return (
|
||||
<tr
|
||||
key={r.codeCentrale}
|
||||
onClick={() => onRowClick(r)}
|
||||
className="cursor-pointer hover:bg-[var(--bg-elevated)] transition-colors"
|
||||
style={{ borderBottom: "1px solid var(--border)" }}
|
||||
>
|
||||
<td className="px-3 py-2 max-w-[380px]">
|
||||
<div className="font-medium truncate" style={{ color: "var(--text-primary)" }} title={r.libelle}>
|
||||
{r.libelle}
|
||||
</div>
|
||||
<span
|
||||
className="inline-block mt-0.5 rounded px-1.5 py-px text-[9px] font-bold uppercase tracking-wide"
|
||||
style={r.chezMoi
|
||||
? { background: "var(--accent-bg)", color: "var(--accent)" }
|
||||
: { background: "rgba(245,158,11,0.15)", color: "#d97706" }}
|
||||
>
|
||||
{r.chezMoi ? `Chez moi${r.codein ? ` · ${r.codein}` : ""}` : "Réseau uniquement"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 tabular-nums" style={{ color: "var(--text-secondary)" }}>{r.codeCentrale}</td>
|
||||
<td className="px-3 py-2 max-w-[180px] truncate" style={{ color: "var(--text-secondary)" }} title={r.fournisseur ?? r.nomfou ?? ""}>
|
||||
{r.fournisseur ?? r.nomfou ?? "-"}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums font-semibold" style={{ color: "var(--text-primary)" }}>{fmtEur(r.caReseau)}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums" style={{ color: "var(--text-secondary)" }}>{fmtNum(r.qteReseau)}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums" style={{ color: "var(--text-secondary)" }}>{fmtNum(r.nbMagasinsReseau)}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums font-semibold" style={{ color: "var(--text-secondary)" }}>{fmtEur(r.caParMagasinReseau)}</td>
|
||||
<td className={cn("px-3 py-2 text-right tabular-nums font-bold",
|
||||
margePct >= 30 ? "text-emerald-500" : margePct >= 15 ? "text-amber-500" : "text-rose-500")}>
|
||||
{margePct.toLocaleString("fr-FR", { maximumFractionDigits: 1 })}%
|
||||
</td>
|
||||
<td className="px-3 py-2"><TrendSparkline trend={trend} /></td>
|
||||
<td className="px-3 py-2 text-right tabular-nums" style={{ color: "var(--text-secondary)" }}>
|
||||
{r.chezMoi ? fmtNum(r.stockTotal ?? 0) : "-"}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 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 (
|
||||
<DialogContent className="max-w-lg" style={{ background: "var(--bg-surface)", border: "1px solid var(--border)" }}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base leading-snug pr-6" style={{ color: "var(--text-primary)" }}>
|
||||
{row.libelle}
|
||||
</DialogTitle>
|
||||
<p className="text-[12px] mt-0.5 flex flex-wrap items-center gap-x-1.5" style={{ color: "var(--text-muted)" }}>
|
||||
<span
|
||||
className="rounded px-1.5 py-px text-[9px] font-bold uppercase tracking-wide"
|
||||
style={row.chezMoi
|
||||
? { background: "var(--accent-bg)", color: "var(--accent)" }
|
||||
: { background: "rgba(245,158,11,0.15)", color: "#d97706" }}
|
||||
>
|
||||
{row.chezMoi ? "Chez moi" : "Réseau uniquement"}
|
||||
</span>
|
||||
<span>Ventes réseau · {labels.length > 0 ? `${labels.length} derniers mois` : "12 derniers mois"}</span>
|
||||
{pct != null && (
|
||||
<span className="font-bold" style={{ color }}>
|
||||
· {pct >= 0 ? "+" : ""}{Math.round(pct * 100)}% · {trendLabel(pct)}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</DialogHeader>
|
||||
|
||||
{!trend.hasData ? (
|
||||
<div className="mt-3 rounded-xl p-4 text-center text-[13px]"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-muted)" }}>
|
||||
Pas de détail mensuel renvoyé par Qlik pour ce produit.
|
||||
</div>
|
||||
) : (
|
||||
<NetworkLineChart labels={labels} values={values} color={color} />
|
||||
)}
|
||||
|
||||
<div className="mt-3 grid grid-cols-2 gap-x-4 gap-y-1.5">
|
||||
{infos.map((i) => (
|
||||
<div key={i.label} className="flex items-baseline justify-between gap-2 text-[12px]">
|
||||
<span style={{ color: "var(--text-muted)" }}>{i.label}</span>
|
||||
<span className="font-semibold tabular-nums text-right" style={{ color: "var(--text-primary)" }}>{i.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!row.chezMoi && (
|
||||
<div className="mt-3 rounded-xl p-3 flex items-start gap-2 text-[12px]"
|
||||
style={{ background: "var(--accent-bg)", border: "1px solid var(--accent-border)", color: "var(--text-secondary)" }}>
|
||||
<Store className="w-4 h-4 shrink-0 mt-px" style={{ color: "var(--accent)" }} />
|
||||
<span>
|
||||
Ce produit est vendu dans <strong>{fmtNum(row.nbMagasinsReseau)}</strong> magasin
|
||||
{row.nbMagasinsReseau > 1 ? "s" : ""} du réseau et génère <strong>{fmtEur(row.caParMagasinReseau)}</strong> de
|
||||
CA par magasin, alors que nous ne le référençons pas.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
@@ -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 <RechercheReseauClient />;
|
||||
}
|
||||
@@ -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<string, SearchJob>();
|
||||
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<NextResponse | null> {
|
||||
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<void> {
|
||||
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<string, StockBySite> = codeins.length
|
||||
? await pgGetStockForCodeins(codeins)
|
||||
: new Map<string, StockBySite>();
|
||||
|
||||
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 });
|
||||
}
|
||||
@@ -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" },
|
||||
|
||||
@@ -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<string, number> | null): NetworkTrend {
|
||||
const empty: NetworkTrend = { values: [], labels: [], direction: "flat", pct: null, hasData: false };
|
||||
if (!qteByMonth) return empty;
|
||||
let labels = Object.keys(qteByMonth).sort(); // "YYYY-MM" trie chronologiquement
|
||||
if (labels.length === 0) return empty;
|
||||
labels = labels.slice(-12); // 12 derniers mois
|
||||
const values = labels.map((l) => Number(qteByMonth[l]) || 0);
|
||||
const n = values.length;
|
||||
let pct: number | null = null;
|
||||
let direction: NetworkTrend["direction"] = "flat";
|
||||
if (n >= 3) {
|
||||
const mx = (n - 1) / 2;
|
||||
const my = values.reduce((s, v) => s + v, 0) / n;
|
||||
let num = 0, den = 0;
|
||||
for (let i = 0; i < n; i++) { num += (i - mx) * (values[i] - my); den += (i - mx) * (i - mx); }
|
||||
const slope = den ? num / den : 0;
|
||||
if (my > 0) {
|
||||
pct = (slope * (n - 1)) / my; // variation modélisée sur toute la période / moyenne
|
||||
direction = pct > 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<NetworkTrend["direction"], string> = {
|
||||
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 <div className="text-center text-[12px]" style={{ color: "var(--text-secondary)" }}>-</div>;
|
||||
const { values, direction, pct } = trend;
|
||||
const color = TREND_COLOR[direction];
|
||||
const W = 46, H = 18, n = values.length;
|
||||
const max = Math.max(...values, 1), min = Math.min(...values, 0);
|
||||
const range = max - min || 1;
|
||||
const pts = values.map((v, i) => {
|
||||
const x = n > 1 ? (i / (n - 1)) * (W - 2) + 1 : W / 2;
|
||||
const y = H - 1 - ((v - min) / range) * (H - 2);
|
||||
return `${x.toFixed(1)},${y.toFixed(1)}`;
|
||||
}).join(" ");
|
||||
const Arrow = direction === "up" ? TrendingUp : direction === "down" ? TrendingDown : Minus;
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-0.5" title={pct != null ? `Tendance ${pct >= 0 ? "+" : ""}${(pct * 100).toFixed(0)}% sur 12 mois (régression) — cliquer pour le détail` : "Tendance réseau — cliquer pour le détail"}>
|
||||
<div className="flex items-center gap-1">
|
||||
<svg width={W} height={H} viewBox={`0 0 ${W} ${H}`} className="shrink-0" aria-hidden>
|
||||
{n > 1 && <polyline points={pts} fill="none" stroke={color} strokeWidth={1.5} strokeLinejoin="round" strokeLinecap="round" />}
|
||||
{n === 1 && <circle cx={W / 2} cy={H / 2} r={2} fill={color} />}
|
||||
</svg>
|
||||
<Arrow className="w-3.5 h-3.5 shrink-0" style={{ color }} strokeWidth={2.5} />
|
||||
</div>
|
||||
{pct != null && (
|
||||
<span className="text-[9px] font-bold tabular-nums leading-none" style={{ color }}>
|
||||
{pct >= 0 ? "+" : ""}{Math.round(pct * 100)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** "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 (
|
||||
<div className="mt-3 w-full overflow-x-auto">
|
||||
<svg viewBox={`0 0 ${W} ${H}`} className="w-full" style={{ minWidth: 380 }} role="img" aria-label="Ventes réseau mensuelles">
|
||||
{/* ligne de base */}
|
||||
<line x1={padL} y1={padT + plotH} x2={padL + plotW} y2={padT + plotH} stroke="var(--border)" strokeWidth={1} />
|
||||
{/* aire douce sous la courbe */}
|
||||
{n > 1 && <polygon points={areaPts} fill={color} opacity={0.08} />}
|
||||
{/* courbe */}
|
||||
{n > 1 && <polyline points={linePts} fill="none" stroke={color} strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" />}
|
||||
{values.map((v, i) => (
|
||||
<g key={labels[i]}>
|
||||
<circle cx={x(i)} cy={y(v)} r={2.6} fill={color} />
|
||||
{/* quantité au-dessus du point */}
|
||||
<text x={x(i)} y={y(v) - 6} textAnchor="middle" fontSize={9} fontWeight={700} fill="var(--text-primary)">
|
||||
{Math.round(v).toLocaleString("fr-FR")}
|
||||
</text>
|
||||
{/* mois sous l'axe */}
|
||||
<text x={x(i)} y={H - 10} textAnchor="middle" fontSize={8} fill="var(--text-muted)">
|
||||
{fmtMonthShort(labels[i])}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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);
|
||||
|
||||
@@ -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<string, number> | null): NetworkTrend {
|
||||
const empty: NetworkTrend = { values: [], labels: [], direction: "flat", pct: null, hasData: false };
|
||||
if (!qteByMonth) return empty;
|
||||
let labels = Object.keys(qteByMonth).sort(); // "YYYY-MM" trie chronologiquement
|
||||
if (labels.length === 0) return empty;
|
||||
labels = labels.slice(-12); // 12 derniers mois
|
||||
const values = labels.map((l) => Number(qteByMonth[l]) || 0);
|
||||
const n = values.length;
|
||||
let pct: number | null = null;
|
||||
let direction: NetworkTrend["direction"] = "flat";
|
||||
if (n >= 3) {
|
||||
const mx = (n - 1) / 2;
|
||||
const my = values.reduce((s, v) => s + v, 0) / n;
|
||||
let num = 0, den = 0;
|
||||
for (let i = 0; i < n; i++) { num += (i - mx) * (values[i] - my); den += (i - mx) * (i - mx); }
|
||||
const slope = den ? num / den : 0;
|
||||
if (my > 0) {
|
||||
pct = (slope * (n - 1)) / my; // variation modélisée sur toute la période / moyenne
|
||||
direction = pct > 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<NetworkTrend["direction"], string> = {
|
||||
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 <div className="text-center text-[12px]" style={{ color: "var(--text-secondary)" }}>-</div>;
|
||||
const { values, direction, pct } = trend;
|
||||
const color = TREND_COLOR[direction];
|
||||
const W = 46, H = 18, n = values.length;
|
||||
const max = Math.max(...values, 1), min = Math.min(...values, 0);
|
||||
const range = max - min || 1;
|
||||
const pts = values.map((v, i) => {
|
||||
const x = n > 1 ? (i / (n - 1)) * (W - 2) + 1 : W / 2;
|
||||
const y = H - 1 - ((v - min) / range) * (H - 2);
|
||||
return `${x.toFixed(1)},${y.toFixed(1)}`;
|
||||
}).join(" ");
|
||||
const Arrow = direction === "up" ? TrendingUp : direction === "down" ? TrendingDown : Minus;
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-0.5" title={pct != null ? `Tendance ${pct >= 0 ? "+" : ""}${(pct * 100).toFixed(0)}% sur 12 mois (régression) — cliquer pour le détail` : "Tendance réseau — cliquer pour le détail"}>
|
||||
<div className="flex items-center gap-1">
|
||||
<svg width={W} height={H} viewBox={`0 0 ${W} ${H}`} className="shrink-0" aria-hidden>
|
||||
{n > 1 && <polyline points={pts} fill="none" stroke={color} strokeWidth={1.5} strokeLinejoin="round" strokeLinecap="round" />}
|
||||
{n === 1 && <circle cx={W / 2} cy={H / 2} r={2} fill={color} />}
|
||||
</svg>
|
||||
<Arrow className="w-3.5 h-3.5 shrink-0" style={{ color }} strokeWidth={2.5} />
|
||||
</div>
|
||||
{pct != null && (
|
||||
<span className="text-[9px] font-bold tabular-nums leading-none" style={{ color }}>
|
||||
{pct >= 0 ? "+" : ""}{Math.round(pct * 100)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** "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 (
|
||||
<div className="mt-3 w-full overflow-x-auto">
|
||||
<svg viewBox={`0 0 ${W} ${H}`} className="w-full" style={{ minWidth: 380 }} role="img" aria-label="Ventes réseau mensuelles">
|
||||
{/* ligne de base */}
|
||||
<line x1={padL} y1={padT + plotH} x2={padL + plotW} y2={padT + plotH} stroke="var(--border)" strokeWidth={1} />
|
||||
{/* aire douce sous la courbe */}
|
||||
{n > 1 && <polygon points={areaPts} fill={color} opacity={0.08} />}
|
||||
{/* courbe */}
|
||||
{n > 1 && <polyline points={linePts} fill="none" stroke={color} strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" />}
|
||||
{values.map((v, i) => (
|
||||
<g key={labels[i]}>
|
||||
<circle cx={x(i)} cy={y(v)} r={2.6} fill={color} />
|
||||
{/* quantité au-dessus du point */}
|
||||
<text x={x(i)} y={y(v) - 6} textAnchor="middle" fontSize={9} fontWeight={700} fill="var(--text-primary)">
|
||||
{Math.round(v).toLocaleString("fr-FR")}
|
||||
</text>
|
||||
{/* mois sous l'axe */}
|
||||
<text x={x(i)} y={H - 10} textAnchor="middle" fontSize={8} fill="var(--text-muted)">
|
||||
{fmtMonthShort(labels[i])}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -157,6 +157,55 @@ export async function pgGetArticlesByFournisseur(codefou: string): Promise<PgArt
|
||||
return (result.rows as unknown as PgArticle[]).filter(r => 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<Map<string, PgArticleLocal>> {
|
||||
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<string, PgArticleLocal>();
|
||||
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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -93,6 +93,20 @@ export interface NetworkMetric {
|
||||
qteByMonth?: Record<string, number>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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");
|
||||
}
|
||||
|
||||
+469
-1
@@ -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<Browser> | 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<Array<string | number>>;
|
||||
monthly?: Record<string, Record<string, number>>;
|
||||
meta?: Record<string, { fournisseur?: string; famille?: string }>;
|
||||
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<NetworkSearchResult> {
|
||||
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<void>((r) => { resolveToken = r; });
|
||||
page.on("websocket", (ws) => {
|
||||
const m = ws.url().match(/qlik-csrf-token=([^&]+)/);
|
||||
if (m && !csrfToken) { csrfToken = decodeURIComponent(m[1]); resolveToken?.(); }
|
||||
});
|
||||
|
||||
await page.goto(`https://${cfg.host}/sense/app/${cfg.appNetwork}`, { waitUntil: "domcontentloaded", timeout: cfg.timeoutMs })
|
||||
.catch(() => { /* le client ouvre son ws ensuite */ });
|
||||
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<InPageSearchResult>((resolve) => {
|
||||
const loc = (window as unknown as { location: Location }).location;
|
||||
const url = `wss://${loc.host}/app/${app}?reloadUri=${encodeURIComponent(loc.href)}&qlik-csrf-token=${token}`;
|
||||
const ws = new WebSocket(url);
|
||||
let id = 0;
|
||||
const pend = new Map<number, { res: (v: { [k: string]: unknown }) => void; rej: (e: unknown) => void }>();
|
||||
const rpc = (method: string, params: unknown, handle = -1) => new Promise<{ [k: string]: unknown }>((res, rej) => {
|
||||
const i = ++id; pend.set(i, { res, rej });
|
||||
ws.send(JSON.stringify({ jsonrpc: "2.0", id: i, handle, method, params }));
|
||||
});
|
||||
const fail = (error: string) => resolve({ ok: false, error });
|
||||
const ready = new Promise<void>((res, rej) => {
|
||||
ws.onmessage = (ev: MessageEvent) => {
|
||||
const m = JSON.parse(ev.data as string);
|
||||
if (m.method === "OnConnected") return res();
|
||||
if (m.id && pend.has(m.id)) {
|
||||
const x = pend.get(m.id)!;
|
||||
pend.delete(m.id);
|
||||
if (m.error) x.rej(new Error(JSON.stringify(m.error)));
|
||||
else x.res(m.result);
|
||||
}
|
||||
};
|
||||
ws.onerror = () => rej(new Error("ws error (403 ?)"));
|
||||
setTimeout(() => rej(new Error("ws timeout")), 30000);
|
||||
});
|
||||
const sleep = (ms: number) => new Promise<void>((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<Map<string, string>> => {
|
||||
const map = new Map<string, string>();
|
||||
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<string, { qItems?: Array<{ qInfo: { qId: string }; qMeta?: { title?: string }; qData?: { title?: string } }> }>;
|
||||
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<string, unknown> }> = [];
|
||||
// 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<Array<string | number>> = [];
|
||||
const monthlyByCode: Record<string, Record<string, number>> = {};
|
||||
|
||||
const fetchMonthCube = async (isMainPass: boolean): Promise<number> => {
|
||||
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<Array<{ qText?: string; qNum?: number }>> }> } }).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<Array<{ qText?: string; qNum?: number }>> }>)?.[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<number> => {
|
||||
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<string, { fournisseur?: string; famille?: string }> = {};
|
||||
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<Array<{ qText?: string }>> }> } }).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<string, NetworkProduct>();
|
||||
const margeWeight = new Map<string, number>();
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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<string, number> | 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;
|
||||
}
|
||||
Reference in new issue
Block a user