feat(produits): recherche Qlik d'abord + fiche réseau complète, tendance 12 mois glissants

Recherche produit
- La page /produits interroge désormais Qlik Sense EN PREMIER
  (src/lib/qlik-search.ts) : recherche globale Engine sur les champs article
  (code + libellé détectés dynamiquement via FieldList), sélection des valeurs
  trouvées, puis liste des Article Code correspondants.
- Les métriques réseau des articles trouvés sont extraites sur 12 mois
  glissants et mises en cache (qlik_network_metrics), puis rapprochées du
  catalogue FF Nancy par code centrale (pgGetProduitsByCodeCentrale).
- Repli propre sur la recherche catalogue local si Qlik est injoignable, signalé
  dans l'UI. Résultats mis en cache mémoire 10 min, requête dédupliquée.
- Nouvelle route GET /api/produits/search (runtime nodejs, 300 s) ; la liste est
  chargée côté client avec état de chargement, l'extraction Qlik prenant
  plusieurs secondes.

Informations affichées
- Résultats : magasins vendeurs (+ % du réseau), quantité réseau, quantité par
  magasin, prix moyen réseau, CA par magasin, marge %, sparkline de tendance,
  fournisseur, et présence ou non au catalogue Nancy.
- Fiche : carte « Performance réseau · 12 mois glissants » en tête (8 indicateurs
  + détail mensuel qté / magasins / qté par magasin / CA / prix moyen / marge %),
  puis carte « Fournisseur » dédiée, puis identité, stock, nos ventes 12 mois.
- La fiche s'ouvre aussi par code centrale (?cc=) pour les produits que le réseau
  travaille et que Nancy ne référence pas — le libellé et le fournisseur Qlik
  sont persistés dans le cache réseau pour ce cas.

Tendance réseau
- computeNetworkTrend reconstruit sa fenêtre depuis la date du jour : 12 mois
  complets, mois en cours EXCLU (il est partiel et écrasait la pente). Les mois
  absents du cache valent 0 ; ceux antérieurs à la première extraction réelle
  sont écartés au lieu d'être inventés à 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DfqUihgixw4K1AmJhizWiu
This commit is contained in:
Claude committed 2026-07-29 05:04:34 +00:00
1 parent abc482c370
commit 203c96f6c9
16 files changed
+1550 -329

No files matched your search

+28
View File
@@ -14,6 +14,34 @@ La Foir'Fouille : **CA réseau, Qté vendue réseau, Nb magasins** par produit.
| Bouton UI | `src/features/grid/components/sync-qlik-button.tsx` |
| Jointure grille | `enrichWithNetworkMetrics()` dans `src/features/grid/api/get-product-rows.ts` (Phase 8) |
| Colonnes grille | CA réseau / Qté réseau / Magasins (/270) / % présence (`heatmap-grid.tsx`) |
| Recherche produit (Qlik d'abord) | `src/lib/qlik-search.ts` → `src/features/produits/api/search-produits.ts` → `GET /api/produits/search` |
## Recherche produit — Qlik d'abord
La page `/produits` ne cherche **plus** dans le catalogue FF Nancy en premier :
le réseau référence bien plus de produits que Nancy, et ce sont précisément
ceux-là qu'on veut voir.
1. `searchQlikArticles()` — recherche globale Engine (`SearchResults`) restreinte
aux champs article (code + libellé, détectés dynamiquement via `FieldList`).
Les valeurs trouvées sont sélectionnées dans leur champ, ce qui restreint la
dimension « Article Code » aux articles correspondants.
2. `fetchNetworkMetricsPlaywright()` — mesures réseau + détail mensuel sur la
fenêtre 12 mois glissants (mois en cours exclu), puis `upsertNetworkMetrics()`
pour que la fiche produit soit immédiatement servie depuis le cache.
3. `pgGetProduitsByCodeCentrale()` — rapprochement avec le catalogue Nancy.
Un code absent = produit réseau que nous ne référençons pas (`?cc=` sur la fiche).
Repli : si Qlik est injoignable, la recherche retombe sur `pgSearchProduits()` et
la réponse le signale (`source: "db"`). Résultats mis en cache mémoire 10 min.
## Tendance réseau = 12 mois glissants stricts
`computeNetworkTrend()` reconstruit sa fenêtre à partir de la date du jour :
12 mois complets, **mois en cours exclu** (partiel, il tirait la pente vers le bas).
Les mois de la fenêtre absents du cache valent 0 (produit non vendu) ; ceux
antérieurs au premier mois réellement extrait sont écartés au lieu d'être
inventés à 0 (extraction plus courte que 12 mois).
Retirés : ranking (champs/query/colonnes), analyse IA (routes `/api/ai/*`, `bulk-ai-analyzer`,
dossier `ai-copilot`), score (`score-engine.ts`, colonne score).
+3 -1
View File
@@ -114,7 +114,9 @@ async function main() {
ADD COLUMN IF NOT EXISTS "ca_par_magasin_reseau" numeric(16, 2),
ADD COLUMN IF NOT EXISTS "marge_pct_reseau" numeric(8, 4),
ADD COLUMN IF NOT EXISTS "qte_by_month" jsonb,
ADD COLUMN IF NOT EXISTS "metrics_by_month" jsonb;
ADD COLUMN IF NOT EXISTS "metrics_by_month" jsonb,
ADD COLUMN IF NOT EXISTS "libelle_reseau" varchar(255),
ADD COLUMN IF NOT EXISTS "fournisseur_reseau" varchar(255);
ALTER TABLE "qlik_network_metrics" DROP COLUMN IF EXISTS "rupture_pct_reseau";
ALTER TABLE "qlik_network_metrics" DROP COLUMN IF EXISTS "couverture_stock_reseau";
`);
+307 -216
View File
@@ -4,15 +4,15 @@ import { useMemo, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import {
ArrowLeft, Package, Warehouse, ShoppingCart, Globe, Layers,
RefreshCw, Loader2, CheckCircle, AlertCircle, WifiOff, LayoutGrid,
ArrowLeft, Package, Warehouse, ShoppingCart, Globe, Layers, Truck,
RefreshCw, Loader2, CheckCircle, AlertCircle, WifiOff, LayoutGrid, Info,
} from "lucide-react";
import { HeatmapCell } from "@/features/grid/components/heatmap-cell";
import { NetworkLineChart, DualLineChart, TrendSparkline } from "@/features/grid/components/network-charts";
import { computeNetworkTrend, trendLabel, TREND_COLOR, NB_MAGASINS_RESEAU } from "@/features/grid/lib/network-trend";
import { formatMonthLabel, formatDate, SITE_LABELS, ffMonthToQlik } from "@/features/grid/lib/months";
import { useQlikSyncJob } from "@/features/qlik-sync/use-qlik-sync-job";
import { computeComparatif, indiceVerdict } from "@/features/produits/lib/compare-reseau";
import { computeComparatif, indiceVerdict, normalizeMargePct } from "@/features/produits/lib/compare-reseau";
import type { ProduitFiche } from "@/features/produits/types";
import { OpportunitesFamille } from "./opportunites";
@@ -20,6 +20,8 @@ import { OpportunitesFamille } from "./opportunites";
const fmtEur = (v: number) =>
new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 }).format(v);
const fmtEur2 = (v: number) =>
new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 2 }).format(v);
const fmtQte = (v: number) => new Intl.NumberFormat("fr-FR", { maximumFractionDigits: 0 }).format(v);
const fmtDec = (v: number, d = 1) => new Intl.NumberFormat("fr-FR", { minimumFractionDigits: d, maximumFractionDigits: d }).format(v);
@@ -72,6 +74,29 @@ function Stat({ label, value, hint, color }: { label: string; value: string; hin
);
}
// ─── Détail mensuel réseau ───────────────────────────────────────────────────
/** Les mesures Qlik d'un mois, telles que stockées dans `metricsByMonth`. */
type MoisReseau = { qte: number; ca?: number; nbMag?: number; caMag?: number; margePct?: number };
/**
* Lignes du tableau « Détail mensuel réseau ». Deux d'entre elles sont dérivées
* (qté/magasin, prix moyen) : le cube Qlik ne les renvoie pas, mais ce sont les
* chiffres qu'on lit réellement pour arbitrer.
*/
const LIGNES_MENSUELLES_RESEAU: Array<{
label: string;
get: (v: MoisReseau) => number | undefined;
fmt: (n: number) => string;
}> = [
{ label: "Quantité", get: (v) => v.qte, fmt: (n) => fmtQte(n) },
{ label: "Magasins", get: (v) => v.nbMag, fmt: (n) => fmtQte(n) },
{ label: "Qté / mag.", get: (v) => (v.nbMag && v.nbMag > 0 ? v.qte / v.nbMag : undefined), fmt: (n) => fmtDec(n) },
{ label: "CA", get: (v) => v.ca, fmt: (n) => Math.round(n).toLocaleString("fr-FR") },
{ label: "Prix moyen", get: (v) => (v.ca != null && v.qte > 0 ? v.ca / v.qte : undefined), fmt: (n) => fmtEur2(n) },
{ label: "Marge %", get: (v) => normalizeMargePct(v.margePct) ?? undefined, fmt: (n) => `${fmtDec(n)} %` },
];
// ─── Bouton d'extraction Qlik pour un seul code centrale ─────────────────────
function SyncProduitButton({ codeCentrale }: { codeCentrale: string }) {
@@ -96,13 +121,13 @@ function SyncProduitButton({ codeCentrale }: { codeCentrale: string }) {
onClick={start}
disabled={status === "running"}
className="btn-action btn-action-secondary flex items-center gap-1.5 disabled:opacity-60"
title="Extraire les données réseau Qlik pour ce seul produit"
title="Ré-extraire les données réseau Qlik pour ce seul produit"
>
{status === "running" ? <Loader2 className="w-3.5 h-3.5 animate-spin" />
: status === "success" ? <CheckCircle className="w-3.5 h-3.5 text-emerald-500" />
: status === "error" ? <AlertCircle className="w-3.5 h-3.5 text-rose-500" />
: <RefreshCw className="w-3.5 h-3.5" />}
{status === "running" ? "Extraction…" : "Charger les données réseau"}
{status === "running" ? "Extraction…" : "Actualiser depuis Qlik"}
</button>
</div>
);
@@ -111,7 +136,7 @@ function SyncProduitButton({ codeCentrale }: { codeCentrale: string }) {
// ─── Fiche ───────────────────────────────────────────────────────────────────
export function ProduitFicheView({ fiche, backQuery }: { fiche: ProduitFiche; backQuery: string }) {
const { detail, fournisseurs, stock, commandesEnCours, gammes, months, reseau } = fiche;
const { detail, codeCentrale, libelleReseau, fournisseurReseau, fournisseurs, stock, commandesEnCours, gammes, months, reseau } = fiche;
const [magasin, setMagasin] = useState<string>("TOTAL");
const sitesDisponibles = useMemo(() => {
@@ -128,6 +153,14 @@ export function ProduitFicheView({ fiche, backQuery }: { fiche: ProduitFiche; ba
const verdict = indiceVerdict(comparatif.indiceQte);
const fournisseurPrincipal = fournisseurs.find((f) => f.principal) ?? fournisseurs[0];
/** Nom de fournisseur à afficher : catalogue Nancy d'abord, sinon champ Qlik. */
const fournisseurAffiche = fournisseurPrincipal?.nomfou || fournisseurReseau || null;
const titre = detail?.libelle1 || libelleReseau || codeCentrale || "Sans libellé";
// Indicateurs réseau dérivés (le cache ne stocke que les mesures brutes Qlik).
const qteParMagasinReseau = reseau && reseau.nbMagasinsReseau > 0 ? reseau.qteReseau / reseau.nbMagasinsReseau : null;
const prixMoyenReseau = reseau && reseau.qteReseau > 0 ? reseau.caReseau / reseau.qteReseau : null;
const margeReseau = normalizeMargePct(reseau?.margePctReseau);
return (
<div className="space-y-5">
@@ -143,12 +176,12 @@ export function ProduitFicheView({ fiche, backQuery }: { fiche: ProduitFiche; ba
Retour aux résultats
</Link>
<h2 className="text-[20px] font-semibold tracking-[-0.3px]" style={{ color: "var(--text-primary)" }}>
{detail.libelle1 || "Sans libellé"}
{titre}
</h2>
<p className="text-[12px] mt-0.5" style={{ color: "var(--text-muted)" }}>
<span className="font-mono">{detail.codein}</span>
{detail.code_centrale && <> · centrale <span className="font-mono">{detail.code_centrale}</span></>}
{fournisseurPrincipal && <> · {fournisseurPrincipal.nomfou}</>}
{detail && <><span className="font-mono">{detail.codein}</span>{" · "}</>}
{codeCentrale && <>centrale <span className="font-mono">{codeCentrale}</span></>}
{fournisseurAffiche && <> · <span style={{ color: "var(--text-secondary)" }}>{fournisseurAffiche}</span></>}
</p>
</div>
{fournisseurPrincipal?.codefou && (
@@ -163,197 +196,28 @@ export function ProduitFicheView({ fiche, backQuery }: { fiche: ProduitFiche; ba
)}
</div>
{/* Identité */}
<Card title="Identité" icon={Package}>
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
<Field label="Code article" value={detail.codein} mono />
<Field label="Code centrale" value={detail.code_centrale || "—"} mono />
<Field label="GTIN" value={detail.gtin || "—"} mono />
<Field label="Référence fou." value={detail.reference || "—"} mono />
<Field label="PCB" value={detail.pcb != null ? fmtQte(detail.pcb) : "—"} />
<Field label="Gamme actuelle" value={gammes[0]?.gamme_code ?? "—"} />
<Field label="Secteur" value={detail.libelle1nom ?? "—"} />
<Field label="Famille" value={detail.libelle2 ?? "—"} />
<Field label="Sous-famille" value={detail.libelle3 ?? "—"} />
<Field label="Prix d'achat" value={detail.pa != null ? fmtEur(detail.pa) : "—"} />
<Field label="PV central" value={detail.pv_central != null ? fmtEur(detail.pv_central) : "—"} />
<Field label="En commande" value={commandesEnCours > 0 ? fmtQte(commandesEnCours) : "—"} />
{!detail && (
<div
className="rounded-xl p-4 flex items-start gap-3 text-[13px]"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}
>
<Info className="w-4 h-4 mt-0.5 shrink-0" style={{ color: "var(--accent)" }} />
<div>
Ce produit est travaillé par le réseau mais <strong>n&apos;est pas référencé dans notre catalogue</strong>.
<div className="text-[12px] mt-0.5" style={{ color: "var(--text-muted)" }}>
Seules les données réseau Qlik sont disponibles : ni stock, ni ventes, ni gamme chez nous.
</div>
</div>
</div>
)}
{fournisseurs.length > 0 && (
<div className="mt-4 pt-3" style={{ borderTop: "1px solid var(--border)" }}>
<div className="text-[10px] font-semibold uppercase tracking-wide mb-1.5" style={{ color: "var(--text-muted)" }}>
Fournisseurs référencés
</div>
<div className="flex flex-wrap gap-1.5">
{fournisseurs.map((f) => (
<span
key={f.codefou}
className="rounded-lg px-2 py-1 text-[11px]"
style={{
background: f.principal ? "var(--accent-bg)" : "var(--bg-elevated)",
border: `1px solid ${f.principal ? "var(--accent-border)" : "var(--border)"}`,
color: f.principal ? "var(--accent)" : "var(--text-secondary)",
}}
title={f.reference ? `Réf. ${f.reference}` : undefined}
>
{f.nomfou}
{f.principal && <span className="ml-1 font-semibold">· principal</span>}
</span>
))}
</div>
</div>
)}
{gammes.length > 1 && (
<div className="mt-3 pt-3" style={{ borderTop: "1px solid var(--border)" }}>
<div className="text-[10px] font-semibold uppercase tracking-wide mb-1.5" style={{ color: "var(--text-muted)" }}>
Historique de gamme
</div>
<div className="flex flex-wrap gap-1.5">
{gammes.slice(0, 10).map((g, i) => (
<span
key={`${g.saison_no_id}-${i}`}
className="rounded-lg px-2 py-1 text-[11px]"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}
>
{g.saison_libelle || g.saison_code || `Saison ${g.saison_no_id}`}
<span className="ml-1.5 font-bold" style={{ color: "var(--text-primary)" }}>{g.gamme_code}</span>
</span>
))}
</div>
</div>
)}
</Card>
{/* Stock */}
<Card title="Stock temps réel" icon={Warehouse}>
{stock.length === 0 ? (
<p className="text-[13px]" style={{ color: "var(--text-muted)" }}>Aucune ligne de stock pour ce produit.</p>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{stock.map((s) => (
<div key={s.site} className="rounded-xl p-3" style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}>
<div className="flex items-center justify-between mb-2">
<span className="text-[12px] font-semibold" style={{ color: "var(--text-primary)" }}>
{SITE_LABELS[s.site]?.nom ?? s.site}
</span>
<span
className="text-[16px] font-black tabular-nums"
style={{ color: s.qte < 0 ? "var(--accent-error)" : "var(--text-primary)" }}
>
{fmtQte(s.qte)}
</span>
</div>
<div className="grid grid-cols-2 gap-2 text-[11px]" style={{ color: "var(--text-secondary)" }}>
<div>Dispo : <span className="tabular-nums">{fmtQte(s.stockdispo)}</span></div>
<div>Valeur : <span className="tabular-nums">{fmtEur(s.valstock)}</span></div>
<div>PRMP : <span className="tabular-nums">{s.prmp > 0 ? fmtEur(s.prmp) : "—"}</span></div>
<div>Dern. vente : {formatDate(s.dernierevente)}</div>
<div className="col-span-2">Dern. réception : {formatDate(s.dernierereception)}</div>
</div>
</div>
))}
</div>
)}
</Card>
{/* Ventes 12 mois glissants */}
{/* ─── Réseau Qlik — la donnée qui motive la recherche ─────────── */}
<Card
title="Ventes · 12 mois glissants"
icon={ShoppingCart}
action={
<div className="segment-control">
{["TOTAL", ...sitesDisponibles].map((code) => (
<button
key={code}
onClick={() => setMagasin(code)}
className={`segment-btn ${magasin === code ? "active" : ""}`}
>
{code === "TOTAL" ? "Total" : (SITE_LABELS[code]?.label ?? code)}
</button>
))}
</div>
}
>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-4">
<Stat label="Quantité vendue" value={fmtQte(totauxAffiches.qte)} />
<Stat label="CA (TTC)" value={fmtEur(totauxAffiches.ca)} />
<Stat label="Marge" value={fmtEur(totauxAffiches.marge)} />
<Stat
label="Taux de marge"
value={`${fmtDec(totauxAffiches.tauxMarge)} %`}
color={totauxAffiches.tauxMarge >= 40 ? "#22c55e" : totauxAffiches.tauxMarge >= 25 ? "#f59e0b" : undefined}
/>
</div>
<div className="overflow-x-auto">
<table className="w-full border-collapse text-[12px]">
<thead>
<tr>
<th className="px-2 py-1.5 text-left text-[10px] font-semibold uppercase" style={{ color: "var(--text-muted)" }}>
Mois
</th>
{months.map((m) => (
<th key={m} className="px-1 py-1.5 text-center text-[10px] font-semibold whitespace-nowrap" style={{ color: "var(--text-muted)" }}>
{formatMonthLabel(m)}
</th>
))}
</tr>
</thead>
<tbody>
<tr>
<td className="px-2 py-1 text-[11px] font-medium whitespace-nowrap" style={{ color: "var(--text-secondary)" }}>
Ventes
</td>
{months.map((m) => (
<td key={m} className="px-0.5 py-1 min-w-[46px]">
<HeatmapCell
value={serie[m]?.qte ?? 0}
tooltipStock={serie[m]?.stockFinMois ?? null}
tooltipReceptions={serie[m]?.qteRecue ?? null}
/>
</td>
))}
</tr>
<tr>
<td className="px-2 py-1 text-[11px] font-medium whitespace-nowrap" style={{ color: "var(--text-secondary)" }}>
CA (TTC)
</td>
{months.map((m) => (
<td key={m} className="px-0.5 py-1 text-center tabular-nums text-[11px]" style={{ color: "var(--text-muted)" }}>
{serie[m]?.ca ? Math.round(serie[m].ca).toLocaleString("fr-FR") : "—"}
</td>
))}
</tr>
<tr>
<td className="px-2 py-1 text-[11px] font-medium whitespace-nowrap" style={{ color: "var(--text-secondary)" }}>
Stock fin de mois
</td>
{months.map((m) => (
<td key={m} className="px-0.5 py-1 text-center tabular-nums text-[11px]" style={{ color: "var(--text-muted)" }}>
{serie[m]?.stockFinMois ? Math.round(serie[m].stockFinMois).toLocaleString("fr-FR") : "—"}
</td>
))}
</tr>
</tbody>
</table>
</div>
<NetworkLineChart
labels={months.map(ffMonthToQlik)}
values={months.map((m) => serie[m]?.qte ?? 0)}
color="#0ea5e9"
/>
</Card>
{/* Réseau Qlik */}
<Card
title="Réseau Qlik"
title="Performance réseau · 12 mois glissants"
icon={Globe}
action={detail.code_centrale ? <SyncProduitButton codeCentrale={detail.code_centrale} /> : undefined}
action={codeCentrale ? <SyncProduitButton codeCentrale={codeCentrale} /> : undefined}
>
{!detail.code_centrale ? (
{!codeCentrale ? (
<p className="text-[13px]" style={{ color: "var(--text-muted)" }}>
Ce produit n&apos;a pas de code centrale : il n&apos;est pas référencé centralement et n&apos;existe donc pas dans Qlik.
</p>
@@ -369,18 +233,29 @@ export function ProduitFicheView({ fiche, backQuery }: { fiche: ProduitFiche; ba
</div>
) : (
<>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">
<Stat label="CA réseau" value={fmtEur(reseau.caReseau)} />
<Stat label="Qté réseau" value={fmtQte(reseau.qteReseau)} />
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Stat
label="Magasins"
value={`${reseau.nbMagasinsReseau}`}
hint={`sur ${NB_MAGASINS_RESEAU} · ${fmtDec((reseau.nbMagasinsReseau / NB_MAGASINS_RESEAU) * 100, 0)} %`}
label="Magasins vendeurs"
value={fmtQte(reseau.nbMagasinsReseau)}
hint={`sur ${NB_MAGASINS_RESEAU} · ${fmtDec((reseau.nbMagasinsReseau / NB_MAGASINS_RESEAU) * 100, 0)} % du réseau`}
/>
<Stat label="Quantité réseau" value={fmtQte(reseau.qteReseau)} hint="12 mois glissants" />
<Stat
label="Qté / magasin"
value={qteParMagasinReseau != null ? fmtDec(qteParMagasinReseau) : "—"}
hint="par magasin qui le travaille"
/>
<Stat
label="Prix moyen réseau"
value={prixMoyenReseau != null ? fmtEur2(prixMoyenReseau) : "—"}
hint="CA réseau / quantité"
/>
<Stat label="CA réseau" value={fmtEur(reseau.caReseau)} />
<Stat label="CA / magasin" value={fmtEur(reseau.caParMagasinReseau)} />
<Stat
label="Marge % réseau"
value={comparatif.tauxMargeReseau != null ? `${fmtDec(comparatif.tauxMargeReseau)} %` : "—"}
value={margeReseau != null ? `${fmtDec(margeReseau)} %` : "—"}
color={margeReseau != null && margeReseau >= 40 ? "#22c55e" : margeReseau != null && margeReseau >= 25 ? "#f59e0b" : undefined}
/>
<Stat
label="Tendance 12 m"
@@ -390,8 +265,14 @@ export function ProduitFicheView({ fiche, backQuery }: { fiche: ProduitFiche; ba
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 mt-3">
<Field label="Libellé réseau" value={libelleReseau || "—"} />
<Field label="Fournisseur réseau" value={fournisseurReseau || "—"} />
</div>
<p className="text-[11px] mt-3" style={{ color: "var(--text-muted)" }}>
Période {reseau.periode ?? "12 mois"} · extrait le {formatDate(reseau.fetchedAt)}
Période {reseau.periode ?? "12 mois"} · extrait le {formatDate(reseau.fetchedAt)} ·
{" "}mois en cours exclu (incomplet).
</p>
{trend.hasData && (
@@ -421,18 +302,14 @@ export function ProduitFicheView({ fiche, backQuery }: { fiche: ProduitFiche; ba
</tr>
</thead>
<tbody>
{([
{ key: "qte", label: "Quantité", fmt: (v: number) => fmtQte(v) },
{ key: "ca", label: "CA", fmt: (v: number) => Math.round(v).toLocaleString("fr-FR") },
{ key: "nbMag", label: "Magasins", fmt: (v: number) => fmtQte(v) },
{ key: "margePct", label: "Marge %", fmt: (v: number) => `${fmtDec(Math.abs(v) <= 1 ? v * 100 : v)} %` },
] as const).map((row) => (
<tr key={row.key}>
{LIGNES_MENSUELLES_RESEAU.map((row) => (
<tr key={row.label}>
<td className="px-2 py-1 font-medium whitespace-nowrap" style={{ color: "var(--text-secondary)" }}>
{row.label}
</td>
{months.map((m) => {
const v = reseau.metricsByMonth?.[ffMonthToQlik(m)]?.[row.key];
const cell = reseau.metricsByMonth?.[ffMonthToQlik(m)];
const v = cell ? row.get(cell) : undefined;
return (
<td key={m} className="px-1 py-1 text-center tabular-nums" style={{ color: "var(--text-muted)" }}>
{v != null ? row.fmt(v) : "—"}
@@ -452,8 +329,222 @@ export function ProduitFicheView({ fiche, backQuery }: { fiche: ProduitFiche; ba
)}
</Card>
{/* Comparatif local vs réseau */}
{comparatif.hasReseau && (
{/* ─── Fournisseur ─────────────────────────────────────────────── */}
<Card title="Fournisseur" icon={Truck}>
{fournisseurs.length === 0 ? (
<p className="text-[13px]" style={{ color: "var(--text-muted)" }}>
{fournisseurReseau
? <>Aucun fournisseur référencé chez nous. Côté réseau, Qlik indique <strong>{fournisseurReseau}</strong>.</>
: "Aucun fournisseur référencé pour ce produit."}
</p>
) : (
<div className="space-y-2">
{fournisseurs.map((f) => (
<div
key={f.codefou}
className="rounded-xl p-3 flex flex-wrap items-center justify-between gap-3"
style={{
background: f.principal ? "var(--accent-bg)" : "var(--bg-elevated)",
border: `1px solid ${f.principal ? "var(--accent-border)" : "var(--border)"}`,
}}
>
<div className="min-w-0">
<div className="text-[14px] font-semibold" style={{ color: f.principal ? "var(--accent)" : "var(--text-primary)" }}>
{f.nomfou}
{f.principal && <span className="ml-2 text-[11px] font-medium">· principal</span>}
</div>
<div className="text-[11px] font-mono" style={{ color: "var(--text-muted)" }}>{f.codefou}</div>
</div>
<div className="flex items-center gap-5 text-[12px]" style={{ color: "var(--text-secondary)" }}>
<span>Réf. <span className="font-mono">{f.reference || "—"}</span></span>
<span>PCB <span className="tabular-nums">{f.pcb != null ? fmtQte(f.pcb) : "—"}</span></span>
<Link
href={`/grid?fournisseur=${encodeURIComponent(f.codefou)}&magasin=TOTAL`}
className="hover:underline"
style={{ color: "var(--accent)" }}
>
Grille
</Link>
</div>
</div>
))}
{fournisseurReseau && (
<p className="text-[11px]" style={{ color: "var(--text-muted)" }}>
Fournisseur côté Qlik : {fournisseurReseau}
</p>
)}
</div>
)}
</Card>
{/* ─── Identité catalogue (produits référencés uniquement) ──────── */}
{detail && (
<Card title="Identité" icon={Package}>
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
<Field label="Code article" value={detail.codein} mono />
<Field label="Code centrale" value={detail.code_centrale || "—"} mono />
<Field label="Fournisseur" value={fournisseurAffiche ?? "—"} />
<Field label="GTIN" value={detail.gtin || "—"} mono />
<Field label="Référence fou." value={detail.reference || "—"} mono />
<Field label="PCB" value={detail.pcb != null ? fmtQte(detail.pcb) : "—"} />
<Field label="Gamme actuelle" value={gammes[0]?.gamme_code ?? "—"} />
<Field label="Secteur" value={detail.libelle1nom ?? "—"} />
<Field label="Famille" value={detail.libelle2 ?? "—"} />
<Field label="Sous-famille" value={detail.libelle3 ?? "—"} />
<Field label="Prix d'achat" value={detail.pa != null ? fmtEur2(detail.pa) : "—"} />
<Field label="PV central" value={detail.pv_central != null ? fmtEur2(detail.pv_central) : "—"} />
<Field label="En commande" value={commandesEnCours > 0 ? fmtQte(commandesEnCours) : "—"} />
</div>
{gammes.length > 1 && (
<div className="mt-3 pt-3" style={{ borderTop: "1px solid var(--border)" }}>
<div className="text-[10px] font-semibold uppercase tracking-wide mb-1.5" style={{ color: "var(--text-muted)" }}>
Historique de gamme
</div>
<div className="flex flex-wrap gap-1.5">
{gammes.slice(0, 10).map((g, i) => (
<span
key={`${g.saison_no_id}-${i}`}
className="rounded-lg px-2 py-1 text-[11px]"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}
>
{g.saison_libelle || g.saison_code || `Saison ${g.saison_no_id}`}
<span className="ml-1.5 font-bold" style={{ color: "var(--text-primary)" }}>{g.gamme_code}</span>
</span>
))}
</div>
</div>
)}
</Card>
)}
{/* ─── Stock ───────────────────────────────────────────────────── */}
{detail && (
<Card title="Stock temps réel" icon={Warehouse}>
{stock.length === 0 ? (
<p className="text-[13px]" style={{ color: "var(--text-muted)" }}>Aucune ligne de stock pour ce produit.</p>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{stock.map((s) => (
<div key={s.site} className="rounded-xl p-3" style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}>
<div className="flex items-center justify-between mb-2">
<span className="text-[12px] font-semibold" style={{ color: "var(--text-primary)" }}>
{SITE_LABELS[s.site]?.nom ?? s.site}
</span>
<span
className="text-[16px] font-black tabular-nums"
style={{ color: s.qte < 0 ? "var(--accent-error)" : "var(--text-primary)" }}
>
{fmtQte(s.qte)}
</span>
</div>
<div className="grid grid-cols-2 gap-2 text-[11px]" style={{ color: "var(--text-secondary)" }}>
<div>Dispo : <span className="tabular-nums">{fmtQte(s.stockdispo)}</span></div>
<div>Valeur : <span className="tabular-nums">{fmtEur(s.valstock)}</span></div>
<div>PRMP : <span className="tabular-nums">{s.prmp > 0 ? fmtEur2(s.prmp) : "—"}</span></div>
<div>Dern. vente : {formatDate(s.dernierevente)}</div>
<div className="col-span-2">Dern. réception : {formatDate(s.dernierereception)}</div>
</div>
</div>
))}
</div>
)}
</Card>
)}
{/* ─── Nos ventes, 12 mois glissants ───────────────────────────── */}
{detail && (
<Card
title="Nos ventes · 12 mois glissants"
icon={ShoppingCart}
action={
<div className="segment-control">
{["TOTAL", ...sitesDisponibles].map((code) => (
<button
key={code}
onClick={() => setMagasin(code)}
className={`segment-btn ${magasin === code ? "active" : ""}`}
>
{code === "TOTAL" ? "Total" : (SITE_LABELS[code]?.label ?? code)}
</button>
))}
</div>
}
>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-4">
<Stat label="Quantité vendue" value={fmtQte(totauxAffiches.qte)} />
<Stat label="CA (TTC)" value={fmtEur(totauxAffiches.ca)} />
<Stat label="Marge" value={fmtEur(totauxAffiches.marge)} />
<Stat
label="Taux de marge"
value={`${fmtDec(totauxAffiches.tauxMarge)} %`}
color={totauxAffiches.tauxMarge >= 40 ? "#22c55e" : totauxAffiches.tauxMarge >= 25 ? "#f59e0b" : undefined}
/>
</div>
<div className="overflow-x-auto">
<table className="w-full border-collapse text-[12px]">
<thead>
<tr>
<th className="px-2 py-1.5 text-left text-[10px] font-semibold uppercase" style={{ color: "var(--text-muted)" }}>
Mois
</th>
{months.map((m) => (
<th key={m} className="px-1 py-1.5 text-center text-[10px] font-semibold whitespace-nowrap" style={{ color: "var(--text-muted)" }}>
{formatMonthLabel(m)}
</th>
))}
</tr>
</thead>
<tbody>
<tr>
<td className="px-2 py-1 text-[11px] font-medium whitespace-nowrap" style={{ color: "var(--text-secondary)" }}>
Ventes
</td>
{months.map((m) => (
<td key={m} className="px-0.5 py-1 min-w-[46px]">
<HeatmapCell
value={serie[m]?.qte ?? 0}
tooltipStock={serie[m]?.stockFinMois ?? null}
tooltipReceptions={serie[m]?.qteRecue ?? null}
/>
</td>
))}
</tr>
<tr>
<td className="px-2 py-1 text-[11px] font-medium whitespace-nowrap" style={{ color: "var(--text-secondary)" }}>
CA (TTC)
</td>
{months.map((m) => (
<td key={m} className="px-0.5 py-1 text-center tabular-nums text-[11px]" style={{ color: "var(--text-muted)" }}>
{serie[m]?.ca ? Math.round(serie[m].ca).toLocaleString("fr-FR") : "—"}
</td>
))}
</tr>
<tr>
<td className="px-2 py-1 text-[11px] font-medium whitespace-nowrap" style={{ color: "var(--text-secondary)" }}>
Stock fin de mois
</td>
{months.map((m) => (
<td key={m} className="px-0.5 py-1 text-center tabular-nums text-[11px]" style={{ color: "var(--text-muted)" }}>
{serie[m]?.stockFinMois ? Math.round(serie[m].stockFinMois).toLocaleString("fr-FR") : "—"}
</td>
))}
</tr>
</tbody>
</table>
</div>
<NetworkLineChart
labels={months.map(ffMonthToQlik)}
values={months.map((m) => serie[m]?.qte ?? 0)}
color="#0ea5e9"
/>
</Card>
)}
{/* ─── Comparatif local vs réseau ──────────────────────────────── */}
{detail && comparatif.hasReseau && (
<Card title="Nous vs. un magasin moyen du réseau" icon={Globe}>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Stat
@@ -523,8 +614,8 @@ export function ProduitFicheView({ fiche, backQuery }: { fiche: ProduitFiche; ba
</Card>
)}
{/* Opportunités de la même famille */}
{detail.nom_no_id != null && (
{/* ─── Opportunités de la même famille ─────────────────────────── */}
{detail?.nom_no_id != null && (
<Card title="Opportunités · même famille" icon={Layers}>
<OpportunitesFamille
nomNoId={detail.nom_no_id}
+28 -16
View File
@@ -1,5 +1,4 @@
import { pgSearchProduits } from "@/lib/pg-ff-client";
import { getProduitFiche } from "@/features/produits/api/get-produit-fiche";
import { getProduitFiche, getProduitFicheByCodeCentrale } from "@/features/produits/api/get-produit-fiche";
import { ProduitSearchBar } from "./search-bar";
import { ProduitResults } from "./results";
import { ProduitFicheView } from "./fiche";
@@ -7,22 +6,28 @@ import { ProduitFicheView } from "./fiche";
export const dynamic = "force-dynamic";
/**
* Recherche produit : par libellé ou par code centrale.
* Recherche produit — **Qlik d'abord**, catalogue FF Nancy ensuite.
*
* - `?q=…` → liste de résultats
* - `?codein=…` → fiche complète (URL partageable)
* - `?q=…` → liste de résultats (chargée côté client : l'extraction Qlik
* prend plusieurs secondes, on ne bloque pas le rendu serveur)
* - `?codein=…` → fiche d'un produit de notre catalogue (URL partageable)
* - `?cc=…` → fiche d'un produit du réseau par son code centrale, qu'il
* soit référencé chez nous ou non
*/
export default async function ProduitsPage(props: {
searchParams: Promise<{ q?: string; codein?: string }>;
searchParams: Promise<{ q?: string; codein?: string; cc?: string }>;
}) {
const searchParams = await props.searchParams;
const q = (searchParams.q ?? "").trim();
const codein = (searchParams.codein ?? "").trim();
const codeCentrale = (searchParams.cc ?? "").trim();
const fiche = codein ? await getProduitFiche(codein) : null;
// On ne relance pas la recherche quand une fiche est ouverte : la liste
// reste accessible via le lien retour, qui conserve `q`.
const results = !codein && q.length >= 3 ? await pgSearchProduits(q) : [];
const fiche = codein
? await getProduitFiche(codein)
: codeCentrale
? await getProduitFicheByCodeCentrale(codeCentrale)
: null;
const ficheDemandee = Boolean(codein || codeCentrale);
return (
<div className="w-full max-w-screen-2xl mx-auto space-y-6">
@@ -31,27 +36,34 @@ export default async function ProduitsPage(props: {
Recherche produit
</h1>
<p className="text-[13px]" style={{ color: "var(--text-muted)" }}>
Par libellé ou par code centrale — ventes sur 12 mois glissants et données réseau Qlik.
Recherche dans Qlik Sense (réseau) puis rapprochement avec notre catalogue —
performance réseau et ventes locales sur 12 mois glissants.
</p>
</header>
{/* key : remonte le champ quand l'URL change (retour arrière, lien partagé) */}
<ProduitSearchBar key={q} initialQuery={q} />
{codein && !fiche && (
{ficheDemandee && !fiche && (
<div
className="rounded-xl p-6 text-center text-[13px]"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-muted)" }}
>
Aucun produit trouvé pour le code article <span className="font-mono">{codein}</span>.
Aucun produit trouvé pour{" "}
{codein
? <>le code article <span className="font-mono">{codein}</span></>
: <>le code centrale <span className="font-mono">{codeCentrale}</span></>}.
{codeCentrale && !codein && (
<div className="mt-1">
Ce code n&apos;est ni dans notre catalogue, ni dans le cache réseau — relancez la recherche.
</div>
)}
</div>
)}
{fiche && <ProduitFicheView fiche={fiche} backQuery={q} />}
{!codein && (
<ProduitResults rows={results} query={q} />
)}
{!ficheDemandee && <ProduitResults query={q} />}
</div>
);
}
+272 -87
View File
@@ -1,13 +1,67 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { PackageSearch, Wifi, WifiOff } from "lucide-react";
import type { PgProduitSearchRow } from "@/lib/pg-ff-client";
import { PackageSearch, Loader2, RefreshCw, AlertTriangle, Database, Globe } from "lucide-react";
import { TrendSparkline } from "@/features/grid/components/network-charts";
import { computeNetworkTrend, NB_MAGASINS_RESEAU } from "@/features/grid/lib/network-trend";
import type { ProduitRechercheResultat, ProduitRechercheRow } from "@/features/produits/types";
function fmtQte(v: number): string {
return new Intl.NumberFormat("fr-FR", { maximumFractionDigits: 0 }).format(v);
}
const fmtQte = (v: number) => new Intl.NumberFormat("fr-FR", { maximumFractionDigits: 0 }).format(v);
const fmtDec = (v: number, d = 1) =>
new Intl.NumberFormat("fr-FR", { minimumFractionDigits: d, maximumFractionDigits: d }).format(v);
const fmtEur = (v: number) =>
new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 }).format(v);
const fmtEur2 = (v: number) =>
new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 2 }).format(v);
/**
* Résultats de recherche produit.
*
* La recherche interroge **Qlik d'abord** (`/api/produits/search`) : plusieurs
* secondes, parfois plus. On la lance donc côté client avec un état de
* chargement explicite, plutôt que de bloquer le rendu serveur de la page.
*/
export function ProduitResults({ query }: { query: string }) {
// `relance` mémorise la dernière demande explicite de rafraîchissement, pour
// ne forcer le contournement du cache serveur que sur CE terme-là.
const [relance, setRelance] = useState<{ n: number; q: string }>({ n: 0, q: "" });
const [resultat, setResultat] = useState<{
cle: string;
data: ProduitRechercheResultat | null;
error: string | null;
} | null>(null);
const force = relance.n > 0 && relance.q === query;
const cle = `${query}|${force ? relance.n : 0}`;
const tropCourt = query.trim().length < 3;
// L'état de chargement est **dérivé** (résultat pas encore aligné sur la
// requête courante) plutôt que posé en début d'effet : appeler setState
// synchronement dans un effet provoque un rendu en cascade.
const loading = !tropCourt && resultat?.cle !== cle;
useEffect(() => {
if (tropCourt) return;
let annule = false;
const url = `/api/produits/search?q=${encodeURIComponent(query)}${force ? "&force=1" : ""}`;
fetch(url, { cache: "no-store" })
.then(async (res) => {
const json = await res.json();
if (!res.ok) throw new Error(json.error || `HTTP ${res.status}`);
return json as ProduitRechercheResultat;
})
.then((data) => { if (!annule) setResultat({ cle, data, error: null }); })
.catch((e) => {
if (!annule) setResultat({ cle, data: null, error: e instanceof Error ? e.message : String(e) });
});
return () => { annule = true; };
}, [cle, query, force, tropCourt]);
const relancer = () => setRelance((r) => ({ n: r.n + 1, q: query }));
const data = resultat?.cle === cle ? resultat.data : null;
const error = resultat?.cle === cle ? resultat.error : null;
/** Liste des résultats de recherche. */
export function ProduitResults({ rows, query }: { rows: PgProduitSearchRow[]; query: string }) {
if (!query) {
return (
<div
@@ -16,111 +70,242 @@ export function ProduitResults({ rows, query }: { rows: PgProduitSearchRow[]; qu
>
<PackageSearch className="w-8 h-8 mx-auto mb-3" style={{ color: "var(--text-muted)" }} strokeWidth={1.5} />
<p className="text-[14px] font-medium" style={{ color: "var(--text-secondary)" }}>
Recherchez un produit
Recherchez un produit dans le réseau
</p>
<p className="text-[12px] mt-1" style={{ color: "var(--text-muted)" }}>
<p className="text-[12px] mt-1 max-w-lg mx-auto" style={{ color: "var(--text-muted)" }}>
La recherche interroge <strong>Qlik Sense</strong> (les ~{NB_MAGASINS_RESEAU} magasins du réseau),
puis rapproche chaque produit trouvé de notre catalogue.
Saisissez un libellé (ex. « poêle 28 ») ou un code centrale (ex. « 10000167303 »).
</p>
</div>
);
}
if (rows.length === 0) {
if (loading) {
return (
<div
className="rounded-xl p-8 text-center text-[13px]"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-muted)" }}
className="rounded-xl p-10 text-center"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}
>
Aucun produit ne correspond à « {query} ».
<Loader2 className="w-6 h-6 mx-auto mb-3 animate-spin" style={{ color: "var(--accent)" }} />
<p className="text-[13px] font-medium" style={{ color: "var(--text-secondary)" }}>
Interrogation de Qlik Sense…
</p>
<p className="text-[12px] mt-1" style={{ color: "var(--text-muted)" }}>
Recherche des articles du réseau puis extraction de leurs ventes sur 12 mois glissants.
Cela peut prendre jusqu&apos;à une minute.
</p>
</div>
);
}
return (
<div className="space-y-2">
<p className="text-[12px]" style={{ color: "var(--text-muted)" }}>
{rows.length} produit{rows.length > 1 ? "s" : ""} trouvé{rows.length > 1 ? "s" : ""}
{rows.length >= 50 && " (50 premiers — affinez la recherche)"}
</p>
if (error) {
return (
<div className="rounded-xl p-6" style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}>
<div className="flex items-start gap-3">
<AlertTriangle className="w-4 h-4 mt-0.5 shrink-0" style={{ color: "var(--accent-error)" }} />
<div className="min-w-0">
<p className="text-[13px] font-medium" style={{ color: "var(--text-primary)" }}>
La recherche a échoué
</p>
<p className="text-[12px] mt-0.5 break-words" style={{ color: "var(--text-muted)" }}>{error}</p>
<button onClick={relancer} className="btn-action btn-action-secondary mt-3 flex items-center gap-1.5">
<RefreshCw className="w-3.5 h-3.5" />
Réessayer
</button>
</div>
</div>
</div>
);
}
<div className="overflow-x-auto rounded-xl" style={{ border: "1px solid var(--border)" }}>
<table className="w-full text-[13px] border-collapse">
<thead>
<tr style={{ background: "var(--bg-elevated)" }}>
<Th>Libellé</Th>
<Th>Fournisseur</Th>
<Th>Code article</Th>
<Th>Code centrale</Th>
<Th>Famille</Th>
<Th align="right">Stock</Th>
<Th align="right">Qté réseau</Th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr
key={r.codein}
className="transition-colors hover:bg-[var(--bg-elevated)]"
style={{ borderTop: "1px solid var(--border)" }}
>
<td className="px-3 py-2">
if (!data) return null;
const { rows, source, qlikError, tronque, locauxHorsReseau, dureeMs } = data;
return (
<div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-[12px] flex items-center gap-1.5" style={{ color: "var(--text-muted)" }}>
{source === "qlik" ? (
<Globe className="w-3.5 h-3.5" style={{ color: "var(--accent)" }} />
) : (
<Database className="w-3.5 h-3.5" />
)}
{rows.length} produit{rows.length > 1 ? "s" : ""}
{source === "qlik" ? " trouvé(s) dans Qlik" : " trouvé(s) dans le catalogue local"}
{tronque && " (liste tronquée — affinez la recherche)"}
{" · "}{(dureeMs / 1000).toFixed(1)} s
</p>
<button onClick={relancer} className="btn-action btn-action-secondary flex items-center gap-1.5">
<RefreshCw className="w-3.5 h-3.5" />
Relancer
</button>
</div>
{source === "db" && (
<div
className="rounded-xl p-3 text-[12px] flex items-start gap-2"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}
>
<AlertTriangle className="w-3.5 h-3.5 mt-0.5 shrink-0" style={{ color: "var(--accent-error)" }} />
<span>
Qlik n&apos;a pas pu être interrogé, la recherche s&apos;est repliée sur le catalogue local
(les chiffres réseau affichés viennent du dernier cache).
{qlikError && <span className="block mt-0.5" style={{ color: "var(--text-muted)" }}>{qlikError}</span>}
</span>
</div>
)}
{source === "qlik" && qlikError && (
<div
className="rounded-xl p-3 text-[12px]"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}
>
Articles trouvés, mais l&apos;extraction des mesures réseau a échoué : {qlikError}
</div>
)}
{rows.length === 0 ? (
<div
className="rounded-xl p-8 text-center text-[13px]"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-muted)" }}
>
Aucun article du réseau ne correspond à « {query} ».
</div>
) : (
<ResultTable rows={rows} query={query} />
)}
{locauxHorsReseau.length > 0 && (
<details className="rounded-xl" style={{ border: "1px solid var(--border)" }}>
<summary className="cursor-pointer px-3 py-2.5 text-[12px]" style={{ color: "var(--text-secondary)" }}>
{locauxHorsReseau.length} produit{locauxHorsReseau.length > 1 ? "s" : ""} de notre catalogue
sans correspondance réseau (pas de code centrale, ou non vendu par le réseau)
</summary>
<div className="px-3 pb-3">
<ul className="space-y-1">
{locauxHorsReseau.slice(0, 30).map((l) => (
<li key={l.codein} className="text-[12px]">
<Link
href={`/produits?codein=${encodeURIComponent(r.codein)}&q=${encodeURIComponent(query)}`}
className="font-medium hover:underline"
href={`/produits?codein=${encodeURIComponent(l.codein)}&q=${encodeURIComponent(query)}`}
className="hover:underline"
style={{ color: "var(--accent)" }}
>
{r.libelle1 || "—"}
{l.libelle1 || l.codein}
</Link>
</td>
<td className="px-3 py-2" style={{ color: "var(--text-secondary)" }}>
{r.fournisseur || "—"}
</td>
<td className="px-3 py-2 font-mono text-[12px]" style={{ color: "var(--text-muted)" }}>
{r.codein}
</td>
<td className="px-3 py-2 font-mono text-[12px]" style={{ color: "var(--text-muted)" }}>
{r.code_centrale || "—"}
</td>
<td className="px-3 py-2 text-[12px]" style={{ color: "var(--text-secondary)" }}>
{r.nomenclature || "—"}
</td>
<td className="px-3 py-2 text-right tabular-nums" style={{ color: "var(--text-secondary)" }}>
{fmtQte(r.stock_total)}
</td>
<td className="px-3 py-2 text-right tabular-nums">
{r.has_reseau ? (
<span className="inline-flex items-center gap-1.5" style={{ color: "var(--text-secondary)" }}>
<Wifi className="w-3 h-3" style={{ color: "var(--accent-success)" }} />
{fmtQte(r.qte_reseau)}
<span className="text-[11px]" style={{ color: "var(--text-muted)" }}>
/ {r.nb_magasins_reseau} mag.
</span>
</span>
) : (
<span
className="inline-flex items-center gap-1.5 text-[11px]"
style={{ color: "var(--text-muted)" }}
title="Données réseau Qlik non synchronisées pour ce produit"
>
<WifiOff className="w-3 h-3" />
non synchro
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
<span style={{ color: "var(--text-muted)" }}>
{" · "}{l.fournisseur}{" · "}<span className="font-mono">{l.codein}</span>
</span>
</li>
))}
</ul>
</div>
</details>
)}
</div>
);
}
function Th({ children, align = "left" }: { children: React.ReactNode; align?: "left" | "right" }) {
function ResultTable({ rows, query }: { rows: ProduitRechercheRow[]; query: string }) {
return (
<div className="overflow-x-auto rounded-xl" style={{ border: "1px solid var(--border)" }}>
<table className="w-full text-[13px] border-collapse">
<thead>
<tr style={{ background: "var(--bg-elevated)" }}>
<Th>Libellé</Th>
<Th>Fournisseur</Th>
<Th>Code centrale</Th>
<Th align="right">Magasins</Th>
<Th align="right">Qté réseau</Th>
<Th align="right">Qté / mag.</Th>
<Th align="right">Prix moyen</Th>
<Th align="right">CA / mag.</Th>
<Th align="right">Marge %</Th>
<Th align="center">Tendance</Th>
<Th align="right">Nous</Th>
</tr>
</thead>
<tbody>
{rows.map((r) => {
const trend = computeNetworkTrend(r.qteByMonth);
const href = r.codein
? `/produits?codein=${encodeURIComponent(r.codein)}&q=${encodeURIComponent(query)}`
: `/produits?cc=${encodeURIComponent(r.codeCentrale)}&q=${encodeURIComponent(query)}`;
return (
<tr
key={r.codeCentrale}
className="transition-colors hover:bg-[var(--bg-elevated)]"
style={{ borderTop: "1px solid var(--border)" }}
>
<td className="px-3 py-2 max-w-[280px]">
<Link href={href} className="font-medium hover:underline" style={{ color: "var(--accent)" }}>
{r.libelle || "—"}
</Link>
{r.nomenclature && (
<div className="text-[11px] truncate" style={{ color: "var(--text-muted)" }}>{r.nomenclature}</div>
)}
</td>
<td className="px-3 py-2 text-[12px]" style={{ color: "var(--text-secondary)" }}>
{r.fournisseur || "—"}
</td>
<td className="px-3 py-2 font-mono text-[12px]" style={{ color: "var(--text-muted)" }}>
{r.codeCentrale}
</td>
<td className="px-3 py-2 text-right tabular-nums" style={{ color: "var(--text-secondary)" }}>
{fmtQte(r.nbMagasinsReseau)}
<span className="text-[11px] ml-1" style={{ color: "var(--text-muted)" }}>
{fmtDec(r.tauxPresence * 100, 0)} %
</span>
</td>
<td className="px-3 py-2 text-right tabular-nums font-semibold" style={{ color: "var(--text-primary)" }}>
{fmtQte(r.qteReseau)}
</td>
<td className="px-3 py-2 text-right tabular-nums" style={{ color: "var(--text-secondary)" }}>
{fmtDec(r.qteParMagasinReseau)}
</td>
<td className="px-3 py-2 text-right tabular-nums" style={{ color: "var(--text-secondary)" }}>
{r.prixMoyenReseau != null ? fmtEur2(r.prixMoyenReseau) : "—"}
</td>
<td className="px-3 py-2 text-right tabular-nums" style={{ color: "var(--text-secondary)" }}>
{r.caParMagasinReseau > 0 ? fmtEur(r.caParMagasinReseau) : "—"}
</td>
<td className="px-3 py-2 text-right tabular-nums" style={{ color: "var(--text-secondary)" }}>
{r.margePctReseau != null ? `${fmtDec(r.margePctReseau)} %` : "—"}
</td>
<td className="px-2 py-2 text-center">
{trend.hasData ? <TrendSparkline trend={trend} /> : <span style={{ color: "var(--text-muted)" }}>—</span>}
</td>
<td className="px-3 py-2 text-right text-[12px]">
{r.codein ? (
<span style={{ color: "var(--text-secondary)" }} title={`Code article ${r.codein}`}>
stock {fmtQte(r.stockLocal ?? 0)}
</span>
) : (
<span
className="rounded-md px-1.5 py-0.5 text-[11px]"
style={{ background: "var(--bg-elevated)", color: "var(--text-muted)", border: "1px solid var(--border)" }}
title="Le réseau travaille ce produit, notre catalogue ne le référence pas"
>
non référencé
</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
function Th({ children, align = "left" }: { children: React.ReactNode; align?: "left" | "right" | "center" }) {
// Classes littérales : Tailwind ne génère pas les classes construites dynamiquement.
const alignClass = align === "right" ? "text-right" : align === "center" ? "text-center" : "text-left";
return (
<th
// Classes littérales : Tailwind ne génère pas les classes construites dynamiquement.
className={`px-3 py-2.5 ${align === "right" ? "text-right" : "text-left"} text-[11px] font-semibold uppercase tracking-wide whitespace-nowrap`}
className={`px-3 py-2.5 ${alignClass} text-[11px] font-semibold uppercase tracking-wide whitespace-nowrap`}
style={{ color: "var(--text-muted)" }}
>
{children}
+4 -3
View File
@@ -7,9 +7,10 @@ import { Search, X } from "lucide-react";
/**
* Champ de recherche produit.
*
* Soumission **explicite** (Entrée ou bouton) et non à la frappe : la requête
* fait un ILIKE sans index sur `articles.libelle1`, on ne veut pas la déclencher
* à chaque caractère. Minimum 3 caractères, aligné sur `pgSearchProduits`.
* Soumission **explicite** (Entrée ou bouton) et non à la frappe : chaque
* recherche déclenche deux allers-retours Qlik Sense (recherche des articles
* puis extraction de leurs mesures sur 12 mois), soit plusieurs secondes.
* Minimum 3 caractères.
*
* L'appelant passe `key={q}` pour que le champ se resynchronise sur l'URL
* (retour arrière, lien partagé) par remontage, plutôt que par un effet qui
+43
View File
@@ -0,0 +1,43 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { rechercherProduits } from "@/features/produits/api/search-produits";
// L'extraction Qlik (Playwright + Engine) prend plusieurs secondes, parfois
// plus d'une minute quand le serveur Qlik est chargé.
export const maxDuration = 300;
// Playwright/Chromium exige le runtime Node.js (pas edge).
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* GET /api/produits/search?q=<terme>[&force=1]
*
* Recherche produit **Qlik d'abord** : les articles du réseau qui correspondent
* au terme, leurs métriques réseau sur 12 mois glissants, puis le rapprochement
* avec le catalogue FF Nancy.
*
* `force=1` ignore le cache mémoire de 10 min (bouton « relancer »).
*
* Le middleware Next ne protège pas `/api/*` : contrôle de session explicite.
*/
export async function GET(req: NextRequest) {
const session = await auth();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const q = (req.nextUrl.searchParams.get("q") ?? "").trim();
if (q.length < 3) {
return NextResponse.json({ error: "Saisissez au moins 3 caractères" }, { status: 400 });
}
const force = req.nextUrl.searchParams.get("force") === "1";
try {
const result = await rechercherProduits(q, { force });
return NextResponse.json({ success: true, ...result });
} catch (e) {
const message = (e as Error).message ?? String(e);
console.error("[api/produits/search]", message.slice(0, 300));
return NextResponse.json({ error: message.slice(0, 300) }, { status: 500 });
}
}
+9
View File
@@ -131,6 +131,15 @@ export const qlikNetworkMetrics = pgTable("qlik_network_metrics", {
* « Tendance / Réseau » de la Grille.
*/
metricsByMonth: jsonb("metrics_by_month"),
/**
* Libellé de l'article tel que Qlik le renvoie. Renseigné par la recherche
* produit (qui interroge la dimension libellé) ; la sync fournisseur ne
* l'écrase pas. Permet d'afficher une fiche pour un produit réseau que le
* catalogue Nancy ne référence pas.
*/
libelleReseau: varchar("libelle_reseau", { length: 255 }),
/** Fournisseur de l'article côté Qlik, même origine que `libelleReseau`. */
fournisseurReseau: varchar("fournisseur_reseau", { length: 255 }),
/** Dernière synchro depuis Qlik */
fetchedAt: timestamp("fetched_at").defaultNow(),
});
+48 -5
View File
@@ -23,6 +23,24 @@ export type NetworkTrend = {
/** Seuil de "forte" variation (±25%) pour distinguer hausse/forte hausse dans l'UI. */
export const TREND_STRONG = 0.25;
/**
* Les 12 mois complets glissants au format Qlik `"YYYY-MM"`, du plus ancien au
* plus récent. **Le mois en cours est exclu** : il est partiel, l'intégrer
* écraserait systématiquement la tendance vers le bas.
*
* Même fenêtre que `getLast12Months()` (clés FF `"YYYYMM"`) et que
* `buildGridNetworkQlikDateFilter()` (extraction Qlik) — les trois doivent
* rester alignés.
*/
export function buildRolling12QlikMonths(now: Date = new Date()): string[] {
const months: string[] = [];
for (let i = 12; i >= 1; i--) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
months.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`);
}
return months;
}
/** Seuil au-delà duquel on ne considère plus la tendance comme stable (±8%). */
export const TREND_FLAT = 0.08;
@@ -33,17 +51,42 @@ export const TREND_COLOR: Record<NetworkTrend["direction"], string> = {
};
/**
* Tendance réseau par RÉGRESSION LINÉAIRE (moindres carrés) sur les 12 derniers mois.
* Tendance réseau par RÉGRESSION LINÉAIRE (moindres carrés) sur les **12 mois
* complets glissants, mois en cours exclu**.
*
* La fenêtre est reconstruite à partir de la date du jour (et non des clés
* présentes dans `qteByMonth`) : c'est le seul moyen de garantir une tendance
* réellement glissante. Deux conséquences voulues :
* - un mois en cours (partiel) ou un mois plus vieux que 12 mois présent dans
* le cache est **ignoré** — sinon la pente est faussée par un mois tronqué ;
* - un mois de la fenêtre **absent** du cache vaut 0 : côté Qlik, un produit
* non vendu sur un mois ne produit tout simplement pas de ligne.
*
* Seule exception au remplissage par zéro : les mois **antérieurs au premier
* mois réellement extrait**. Ils traduisent une extraction plus courte que 12
* mois (`QLIK_SYNC_MONTHS_BACK`), pas une absence de ventes — les inventer à 0
* simulerait une croissance qui n'existe pas.
*
* Utilise tous les points (robuste au bruit d'un mois isolé). L'indicateur `pct` est la
* variation modélisée sur la période (pente × durée) rapportée à la moyenne.
* Direction : forte hausse >+25%, hausse >+8%, stable, baisse <−8%, forte baisse <−25%.
*/
export function computeNetworkTrend(qteByMonth?: Record<string, number> | null): NetworkTrend {
export function computeNetworkTrend(
qteByMonth?: Record<string, number> | null,
now: Date = new Date(),
): NetworkTrend {
const empty: NetworkTrend = { values: [], labels: [], direction: "flat", pct: null, hasData: false };
if (!qteByMonth) return empty;
let labels = Object.keys(qteByMonth).sort(); // "YYYY-MM" trie chronologiquement
if (labels.length === 0) return empty;
labels = labels.slice(-12); // 12 derniers mois
const presents = Object.keys(qteByMonth).sort(); // "YYYY-MM" trie chronologiquement
if (presents.length === 0) return empty;
const fenetre = buildRolling12QlikMonths(now);
// Aucun mois extrait ne tombe dans la fenêtre glissante (cache périmé) :
// rien de fiable à tracer.
const premierMoisExtrait = presents[0];
const labels = fenetre.filter((m) => m >= premierMoisExtrait);
if (labels.length === 0 || !labels.some((m) => qteByMonth[m] != null)) return empty;
const values = labels.map((l) => Number(qteByMonth[l]) || 0);
const n = values.length;
let pct: number | null = null;
@@ -2,6 +2,7 @@ import "server-only";
import {
pgGetProduitDetail,
pgGetCodeinByCodeCentrale,
pgGetFournisseursByCodein,
pgGetStockByCodein,
pgGetCommandesByCodein,
@@ -34,6 +35,55 @@ function totauxFrom(rows: ProduitMois[]): ProduitTotaux {
return { qte, ca, marge, tauxMarge: ca > 0 ? (marge / ca) * 100 : 0 };
}
/**
* Assemble la fiche d'un produit **à partir de son code centrale** : c'est la
* clé du réseau Qlik, seule identité disponible pour un produit que le réseau
* travaille mais que Nancy ne référence pas.
*
* Si le catalogue Nancy connaît ce code centrale, on rattache toute la partie
* locale (identité, fournisseurs, stock, gammes, 12 mois glissants de ventes).
* Sinon la fiche reste purement réseau, ce qui est un cas parfaitement normal
* depuis la recherche Qlik.
*
* Renvoie `null` uniquement si ni Qlik ni le catalogue ne connaissent le code.
*/
export async function getProduitFicheByCodeCentrale(codeCentrale: string): Promise<ProduitFiche | null> {
const cleaned = codeCentrale.trim();
if (!cleaned) return null;
const codein = await pgGetCodeinByCodeCentrale(cleaned);
if (codein) return getProduitFiche(codein);
// Produit réseau pur : aucune donnée locale à assembler.
const map = await getNetworkMetricsByCodeCentrale([cleaned]);
const reseau = map.get(cleaned) ?? null;
if (!reseau) return null;
const { dateDebut, dateFin } = buildLast12MonthsRange();
const months = buildMonthKeys();
const mensuelTotal: Record<string, ProduitMois> = {};
for (const m of months) mensuelTotal[m] = emptyMois(m);
return {
detail: null,
codeCentrale: cleaned,
libelleReseau: reseau.libelleReseau ?? "",
fournisseurReseau: reseau.fournisseurReseau ?? "",
fournisseurs: [],
stock: [],
commandesEnCours: 0,
gammes: [],
months,
mensuelTotal,
mensuelParSite: {},
totaux: totauxFrom(months.map((m) => mensuelTotal[m])),
totauxParSite: {},
sitesActifs: [],
reseau,
periode: { dateDebut, dateFin },
};
}
/**
* Assemble la fiche complète d'un produit : identité, fournisseurs, stock,
* gammes, 12 mois glissants de ventes (total et par magasin) et métriques
@@ -138,6 +188,9 @@ export async function getProduitFiche(codein: string): Promise<ProduitFiche | nu
return {
detail,
codeCentrale: detail.code_centrale ?? "",
libelleReseau: reseau?.libelleReseau ?? "",
fournisseurReseau: reseau?.fournisseurReseau ?? "",
fournisseurs,
stock,
commandesEnCours,
@@ -0,0 +1,247 @@
import "server-only";
import { searchQlikArticles, QLIK_SEARCH_MAX_RESULTS } from "@/lib/qlik-search";
import { fetchNetworkMetricsPlaywright } from "@/lib/qlik-playwright";
import type { NetworkMetric } from "@/lib/qlik-client";
import { upsertNetworkMetrics } from "@/lib/qlik-network-cache";
import { buildGridNetworkQlikDateFilter, envMonthsBack, QLIK_MONTHS_BACK_DEFAULT } from "@/lib/qlik-date-range";
import { pgGetProduitsByCodeCentrale, pgSearchProduits, type PgProduitSearchRow } from "@/lib/pg-ff-client";
import { NB_MAGASINS_RESEAU } from "@/features/grid/lib/network-trend";
import { normalizeMargePct } from "@/features/produits/lib/compare-reseau";
import type { ProduitRechercheResultat, ProduitRechercheRow } from "@/features/produits/types";
/**
* CollectFlow — Recherche produit **Qlik d'abord**, base FF Nancy ensuite.
*
* Déroulé :
* 1. Qlik Sense : quels articles du réseau correspondent au terme ?
* (`searchQlikArticles` — libellé ou code centrale)
* 2. Qlik Sense : métriques réseau 12 mois glissants de ces articles
* (`fetchNetworkMetricsPlaywright` — CA, qté, nb magasins, marge, mensuel)
* 3. Base FF Nancy : lesquels référençons-nous ? (`pgGetProduitsByCodeCentrale`)
*
* Si Qlik est injoignable, on se rabat sur la recherche catalogue local pour ne
* pas laisser l'utilisateur sans résultat — la réponse le signale explicitement
* (`source: "db"`).
*/
/** Durée de validité d'un résultat de recherche en cache mémoire. */
const CACHE_TTL_MS = 10 * 60 * 1000;
/**
* Cache mémoire des recherches. Une recherche coûte deux allers-retours Qlik
* (plusieurs secondes chacun) : sans ce cache, un retour arrière du navigateur
* ou un rafraîchissement relancerait toute l'extraction.
*/
const cache = new Map<string, { at: number; result: ProduitRechercheResultat }>();
/** Recherches en cours, pour ne pas lancer deux extractions identiques en parallèle. */
const enCours = new Map<string, Promise<ProduitRechercheResultat>>();
function cacheKey(term: string): string {
return term.trim().toLowerCase().replace(/\s+/g, " ");
}
/** Purge les entrées expirées (le volume reste faible, un balayage suffit). */
function purgeCache(): void {
const now = Date.now();
for (const [k, v] of cache) {
if (now - v.at > CACHE_TTL_MS) cache.delete(k);
}
}
export async function rechercherProduits(
term: string,
options: { force?: boolean } = {},
): Promise<ProduitRechercheResultat> {
const cleaned = term.trim();
if (cleaned.length < 3) {
return {
rows: [], source: "qlik", qlikError: null, tronque: false,
champUtilise: null, locauxHorsReseau: [], dureeMs: 0,
};
}
const key = cacheKey(cleaned);
purgeCache();
if (!options.force) {
const hit = cache.get(key);
if (hit) {
console.log(`[produits/search] "${cleaned}" — servi depuis le cache mémoire`);
return hit.result;
}
const running = enCours.get(key);
if (running) return running;
}
const p = executerRecherche(cleaned).finally(() => enCours.delete(key));
enCours.set(key, p);
const result = await p;
cache.set(key, { at: Date.now(), result });
return result;
}
async function executerRecherche(term: string): Promise<ProduitRechercheResultat> {
const started = Date.now();
// La recherche catalogue local tourne en parallèle : elle sert à la fois de
// repli si Qlik échoue, et de complément (produits Nancy sans code centrale,
// donc invisibles côté réseau).
const localPromise = pgSearchProduits(term).catch((e) => {
console.error("[produits/search] recherche locale échouée:", (e as Error).message);
return [] as PgProduitSearchRow[];
});
let qlik;
try {
qlik = await searchQlikArticles(term, QLIK_SEARCH_MAX_RESULTS);
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
console.error("[produits/search] Qlik indisponible, repli catalogue local:", message);
const locaux = await localPromise;
return {
rows: locaux.map(rowDepuisCatalogueSeul),
source: "db",
qlikError: messageQlikLisible(message),
tronque: locaux.length >= 50,
champUtilise: null,
locauxHorsReseau: [],
dureeMs: Date.now() - started,
};
}
const codes = qlik.matches.map((m) => m.codeCentrale);
if (codes.length === 0) {
const locaux = await localPromise;
return {
rows: [],
source: "qlik",
qlikError: null,
tronque: false,
champUtilise: qlik.champUtilise,
locauxHorsReseau: locaux,
dureeMs: Date.now() - started,
};
}
// Métriques réseau + détail mensuel sur la fenêtre 12 mois glissants (mois
// courant exclu) — même extracteur que la sync fournisseur, donc mêmes
// chiffres que la Grille.
const monthsBack = envMonthsBack("QLIK_SYNC_MONTHS_BACK", QLIK_MONTHS_BACK_DEFAULT);
const dateFilter = buildGridNetworkQlikDateFilter(new Date(), monthsBack);
let metrics = new Map<string, NetworkMetric>();
let metricsError: string | null = null;
try {
metrics = await fetchNetworkMetricsPlaywright(codes, undefined, dateFilter);
// Le libellé / fournisseur ne sortent que de la recherche : on les
// attache ici pour qu'ils soient persistés avec les mesures.
for (const m of qlik.matches) {
const metric = metrics.get(m.codeCentrale);
if (!metric) continue;
if (m.libelle) metric.libelleReseau = m.libelle;
if (m.fournisseur) metric.fournisseurReseau = m.fournisseur;
}
// On alimente le cache `qlik_network_metrics` au passage : la fiche
// produit ouverte depuis un résultat affichera les données réseau tout
// de suite, sans re-extraction.
if (metrics.size > 0) {
const n = await upsertNetworkMetrics([...metrics.values()]);
console.log(`[produits/search] ${n} ligne(s) réseau mises en cache`);
}
} catch (e) {
// Les identités trouvées restent affichables sans les mesures : mieux
// vaut une liste sans chiffres réseau qu'une page d'erreur.
metricsError = e instanceof Error ? e.message : String(e);
console.error("[produits/search] extraction des métriques réseau échouée:", metricsError);
}
const catalogue = await pgGetProduitsByCodeCentrale(codes);
const locaux = await localPromise;
const rows: ProduitRechercheRow[] = qlik.matches.map((m) => {
const net = metrics.get(m.codeCentrale);
const local = catalogue.get(m.codeCentrale);
const qteReseau = net?.qteReseau ?? 0;
const caReseau = net?.caReseau ?? 0;
const nbMag = net?.nbMagasinsReseau ?? 0;
return {
codeCentrale: m.codeCentrale,
libelle: local?.libelle1 || m.libelle || m.codeCentrale,
libelleReseau: m.libelle,
fournisseur: local?.fournisseur || m.fournisseur || "",
codein: local?.codein ?? null,
nomenclature: local?.nomenclature ?? "",
stockLocal: local ? Number(local.stock_total) || 0 : null,
qteReseau,
caReseau,
nbMagasinsReseau: nbMag,
tauxPresence: nbMag / NB_MAGASINS_RESEAU,
qteParMagasinReseau: nbMag > 0 ? qteReseau / nbMag : 0,
caParMagasinReseau: net?.caParMagasinReseau ?? 0,
prixMoyenReseau: qteReseau > 0 ? caReseau / qteReseau : null,
margePctReseau: normalizeMargePct(net?.margePctReseau),
qteByMonth: net?.qteByMonth ?? null,
periode: net?.periode ?? dateFilter.label,
};
});
// Les plus vendus du réseau d'abord : c'est l'information utile pour
// arbitrer, un produit que le réseau ne vend pas n'intéresse personne.
rows.sort((a, b) => b.qteReseau - a.qteReseau || a.libelle.localeCompare(b.libelle, "fr"));
const codesTrouves = new Set(codes);
const locauxHorsReseau = locaux.filter((l) => !l.code_centrale || !codesTrouves.has(l.code_centrale));
return {
rows,
source: "qlik",
qlikError: metricsError ? messageQlikLisible(metricsError) : null,
tronque: qlik.tronque,
champUtilise: qlik.champUtilise,
locauxHorsReseau,
dureeMs: Date.now() - started,
};
}
/** Ligne de repli : catalogue Nancy seul, sans données réseau fiables. */
function rowDepuisCatalogueSeul(r: PgProduitSearchRow): ProduitRechercheRow {
const qteReseau = Number(r.qte_reseau) || 0;
const nbMag = Number(r.nb_magasins_reseau) || 0;
return {
codeCentrale: r.code_centrale,
libelle: r.libelle1 || r.codein,
libelleReseau: "",
fournisseur: r.fournisseur,
codein: r.codein,
nomenclature: r.nomenclature,
stockLocal: Number(r.stock_total) || 0,
qteReseau,
caReseau: 0,
nbMagasinsReseau: nbMag,
tauxPresence: nbMag / NB_MAGASINS_RESEAU,
qteParMagasinReseau: nbMag > 0 ? qteReseau / nbMag : 0,
caParMagasinReseau: 0,
prixMoyenReseau: null,
margePctReseau: null,
qteByMonth: null,
periode: null,
};
}
/**
* Traduit les pannes Qlik récurrentes en message actionnable. Les autres
* erreurs sont renvoyées telles quelles (tronquées) pour rester diagnosticables.
*/
function messageQlikLisible(message: string): string {
const lower = message.toLowerCase();
if (lower.includes("out of memory") || lower.includes("not enough memory") || lower.includes('"code":6') || lower.includes('"code":3002')) {
return "Serveur Qlik saturé : mémoire insuffisante pour charger l'application. Réessayez plus tard.";
}
if (lower.includes("identifiants qlik manquants") || lower.includes("qlik_user")) {
return "Identifiants Qlik non configurés (Paramètres → Qlik).";
}
if (lower.includes("timeout") || lower.includes("econnrefused") || lower.includes("enotfound")) {
return "Serveur Qlik injoignable depuis CollectFlow (réseau ou VPN).";
}
return message.slice(0, 300);
}
+71 -1
View File
@@ -5,11 +5,71 @@
import type {
PgProduitDetailRow,
PgProduitFournisseurRow,
PgProduitSearchRow,
PgProduitStockRow,
PgGammeHistoryRow,
} from "@/lib/pg-ff-client";
import type { NetworkMetricCached } from "@/lib/qlik-network-cache";
/**
* Une ligne de résultat de recherche produit.
*
* La recherche part de **Qlik** : chaque ligne est d'abord un produit du réseau
* (identifié par son code centrale), éventuellement rapproché du catalogue FF
* Nancy. `codein === null` = le réseau travaille ce produit, nous ne le
* référençons pas.
*/
export interface ProduitRechercheRow {
/** Dimension Qlik « Article Code » = `articles.artcentrale`. */
codeCentrale: string;
/** Libellé affiché : catalogue Nancy si référencé, sinon libellé réseau. */
libelle: string;
/** Libellé tel que Qlik le renvoie (vide si l'app n'expose pas de champ libellé). */
libelleReseau: string;
/** Fournisseur : catalogue Nancy prioritaire (nom réel), sinon champ Qlik. */
fournisseur: string;
/** `null` si le produit n'est pas au catalogue Nancy. */
codein: string | null;
nomenclature: string;
/** Stock Nancy tous sites, `null` si produit non référencé. */
stockLocal: number | null;
// ─── Réseau Qlik, sur 12 mois glissants ─────────────────────────────────
qteReseau: number;
caReseau: number;
nbMagasinsReseau: number;
/** Part des magasins du réseau travaillant le produit (0..1). */
tauxPresence: number;
/** Quantité vendue par magasin qui travaille le produit. */
qteParMagasinReseau: number;
caParMagasinReseau: number;
/** CA réseau / quantité réseau — panier moyen unitaire, `null` si pas de vente. */
prixMoyenReseau: number | null;
/** Taux de marge réseau en points de % (déjà normalisé), `null` si absent. */
margePctReseau: number | null;
/** Quantité réseau par mois `"YYYY-MM"` — alimente la sparkline de tendance. */
qteByMonth: Record<string, number> | null;
periode: string | null;
}
export interface ProduitRechercheResultat {
rows: ProduitRechercheRow[];
/** `qlik` = recherche nominale ; `db` = repli catalogue local (Qlik indisponible). */
source: "qlik" | "db";
/** Message d'erreur Qlik quand on a dû se rabattre sur la base locale. */
qlikError: string | null;
/** true si Qlik a renvoyé plus d'articles que la limite affichée. */
tronque: boolean;
/** Champ Qlik réellement interrogé (diagnostic). */
champUtilise: string | null;
/**
* Produits du catalogue Nancy qui matchent le terme mais qu'aucun article
* Qlik ne couvre (pas de code centrale, ou non vendus par le réseau).
*/
locauxHorsReseau: PgProduitSearchRow[];
dureeMs: number;
}
/** Les métriques d'un mois, pour un périmètre donné (total ou un site). */
export interface ProduitMois {
/** Clé de mois FF, format "YYYYMM". */
@@ -31,7 +91,17 @@ export interface ProduitTotaux {
}
export interface ProduitFiche {
detail: PgProduitDetailRow;
/**
* Identité catalogue FF Nancy — `null` pour un produit que le réseau
* travaille mais que Nancy ne référence pas (fiche 100 % réseau).
*/
detail: PgProduitDetailRow | null;
/** Code centrale = clé Qlik. Toujours renseigné pour une fiche ouverte depuis la recherche. */
codeCentrale: string;
/** Libellé de l'article côté Qlik (peut être vide selon l'app Qlik). */
libelleReseau: string;
/** Fournisseur de l'article côté Qlik (peut être vide selon l'app Qlik). */
fournisseurReseau: string;
fournisseurs: PgProduitFournisseurRow[];
stock: PgProduitStockRow[];
commandesEnCours: number;
+99
View File
@@ -1387,6 +1387,105 @@ export async function pgSearchProduits(term: string, limit = 50): Promise<PgProd
}
}
/**
* Rapproche une liste de **codes centraux** (issus de Qlik) du catalogue FF Nancy.
*
* C'est l'étape 2 de la recherche produit : Qlik dit quels articles le réseau
* travaille, cette requête dit lesquels nous référençons — et avec quel
* `codein`, quel fournisseur et quel stock. Un code absent du résultat est un
* produit réseau que Nancy ne référence pas (cas normal et intéressant).
*
* Les codeins sont renvoyés indexés par code centrale ; un même code centrale
* peut porter plusieurs codeins (ré-créations d'article), on ne garde que le
* `no_id` le plus récent.
*/
export async function pgGetProduitsByCodeCentrale(
codesCentraux: string[],
): Promise<Map<string, PgProduitSearchRow>> {
const out = new Map<string, PgProduitSearchRow>();
const codes = [...new Set(codesCentraux.map((c) => c.trim()).filter(Boolean))];
if (codes.length === 0) return out;
try {
const result = await pgNoParallel(sql`
WITH matched AS (
SELECT DISTINCT ON (TRIM(a.artcentrale::text))
a.no_id,
TRIM(a.codein::text) AS codein,
TRIM(a.artcentrale::text) AS code_centrale,
COALESCE(a.libelle1, '') AS libelle1,
a.nom_no_id
FROM articles a
WHERE TRIM(a.artcentrale::text) IN (${sql.join(codes.map((c) => sql`${c}`), sql`, `)})
AND a.codein IS NOT NULL
ORDER BY TRIM(a.artcentrale::text), a.no_id DESC
)
SELECT
m.no_id,
m.codein,
m.code_centrale,
m.libelle1,
COALESCE(fi.nom, af.code, 'Sans fournisseur')::text AS fournisseur,
COALESCE(af.code, '')::text AS codefou,
COALESCE(n.code, '')::text AS nomenclature_code,
COALESCE(n.libelle, '')::text AS nomenclature,
COALESCE(st.stock_total, 0)::float AS stock_total,
TRUE AS has_reseau,
0::float AS qte_reseau,
0 AS nb_magasins_reseau
FROM matched m
LEFT JOIN LATERAL (
SELECT af1.code
FROM artfou1 af1
WHERE af1.art_no_id = m.no_id AND af1.preference = 1
ORDER BY af1.code
LIMIT 1
) af ON TRUE
LEFT JOIN fouident fi ON fi.code = af.code
LEFT JOIN nomenclature n ON n.no_id = m.nom_no_id
LEFT JOIN LATERAL (
SELECT SUM(cs.qte)::float AS stock_total
FROM cube_stock cs
WHERE cs.artnoid = m.no_id AND cs.site IN ('292', '579')
) st ON TRUE
`);
for (const row of result.rows as unknown as PgProduitSearchRow[]) {
out.set(row.code_centrale, row);
}
console.log(`[pg-ff] pgGetProduitsByCodeCentrale: ${codes.length} codes Qlik → ${out.size} trouvés dans le catalogue Nancy`);
return out;
} catch (e) {
console.error("[pg-ff] pgGetProduitsByCodeCentrale error:", (e as Error).message?.slice(0, 250));
return out;
}
}
/**
* `codein` de l'article portant ce code centrale, ou `null` si Nancy ne le
* référence pas. Utilisé par la fiche produit ouverte depuis un résultat Qlik
* (URL `?cc=…`), qui n'a que le code centrale.
*/
export async function pgGetCodeinByCodeCentrale(codeCentrale: string): Promise<string | null> {
const cleaned = codeCentrale.trim();
if (!cleaned) return null;
try {
const result = await pgNoParallel(sql`
SELECT TRIM(a.codein::text) AS codein
FROM articles a
WHERE TRIM(a.artcentrale::text) = ${cleaned}
AND a.codein IS NOT NULL
ORDER BY a.no_id DESC
LIMIT 1
`);
const row = (result.rows as unknown as { codein: string }[])[0];
return row?.codein ?? null;
} catch (e) {
console.error("[pg-ff] pgGetCodeinByCodeCentrale error:", (e as Error).message?.slice(0, 250));
return null;
}
}
/** Identité, prix et nomenclature d'un produit (une seule ligne). */
export interface PgProduitDetailRow {
no_id: number;
+8
View File
@@ -101,6 +101,14 @@ export interface NetworkMetric {
* sautée si le champ Qlik `Année` est absent).
*/
metricsByMonth?: Record<string, QlikMonthMetrics>;
/**
* Libellé de l'article côté Qlik. Renseigné uniquement par la recherche
* produit (`qlik-search.ts`), qui interroge la dimension libellé ; la sync
* fournisseur ne le connaît pas et le laisse `undefined`.
*/
libelleReseau?: string;
/** Fournisseur de l'article côté Qlik, même origine que `libelleReseau`. */
fournisseurReseau?: string;
}
/** Les mesures réseau Qlik pour un produit sur un mois donné. */
+12
View File
@@ -27,6 +27,10 @@ export interface NetworkMetricCached {
metricsByMonth: Record<string, QlikMonthMetrics> | null;
/** Période couverte par l'extraction (ex "2025-03_2026-02"). */
periode: string | null;
/** Libellé de l'article côté Qlik — `null` tant qu'aucune recherche produit ne l'a renseigné. */
libelleReseau: string | null;
/** Fournisseur de l'article côté Qlik, même origine que `libelleReseau`. */
fournisseurReseau: string | null;
fetchedAt: string | null;
}
@@ -53,6 +57,8 @@ export async function getNetworkMetricsByCodeCentrale(
qteByMonth: (r.qteByMonth as Record<string, number> | null) ?? null,
metricsByMonth: (r.metricsByMonth as Record<string, QlikMonthMetrics> | null) ?? null,
periode: r.periode ?? null,
libelleReseau: r.libelleReseau ?? null,
fournisseurReseau: r.fournisseurReseau ?? null,
fetchedAt: r.fetchedAt ? new Date(r.fetchedAt).toISOString() : null,
});
}
@@ -73,6 +79,8 @@ export async function upsertNetworkMetrics(metrics: NetworkMetric[]): Promise<nu
periode: m.periode ?? null,
qteByMonth: m.qteByMonth ?? null,
metricsByMonth: m.metricsByMonth ?? null,
libelleReseau: m.libelleReseau ?? null,
fournisseurReseau: m.fournisseurReseau ?? null,
fetchedAt: now,
}));
@@ -95,6 +103,10 @@ export async function upsertNetworkMetrics(metrics: NetworkMetric[]): Promise<nu
periode: sql`excluded.periode`,
qteByMonth: sql`excluded.qte_by_month`,
metricsByMonth: sql`excluded.metrics_by_month`,
// Seule la recherche produit connaît le libellé / le fournisseur
// réseau : une sync fournisseur ne doit pas les effacer.
libelleReseau: sql`COALESCE(excluded.libelle_reseau, ${qlikNetworkMetrics.libelleReseau})`,
fournisseurReseau: sql`COALESCE(excluded.fournisseur_reseau, ${qlikNetworkMetrics.fournisseurReseau})`,
fetchedAt: sql`excluded.fetched_at`,
},
});
+318
View File
@@ -0,0 +1,318 @@
/**
* CollectFlow — Recherche produit **dans Qlik Sense** (source primaire).
*
* La recherche produit part du réseau, pas du catalogue Nancy : on interroge
* d'abord l'app Qlik « Magasins Vision Consolidée » pour trouver les articles
* qui correspondent au terme saisi (libellé ou code centrale), puis l'appelant
* rapproche les codes centraux trouvés de la base FF Nancy.
*
* Pourquoi Qlik d'abord : le réseau (~270 magasins) référence beaucoup plus de
* produits que Nancy. Chercher dans la base locale d'abord masquait tous les
* produits que le réseau travaille et que nous ne référençons pas — c'est
* précisément ce qu'on veut voir.
*
* Technique : même plomberie que `qlik-playwright.ts` (Chromium headless +
* websocket Engine in-page avec le qlik-csrf-token), car le proxy Qlik refuse
* un websocket « raw » côté serveur.
*
* L'API Engine utilisée est la recherche globale (`SearchResults`) : elle
* renvoie, pour chaque champ, les valeurs qui matchent le terme. On sélectionne
* ensuite ces valeurs dans leur champ, ce qui restreint la dimension
* « Article Code » aux articles correspondants — sans avoir à connaître à
* l'avance le nom exact du champ libellé, qui varie selon les apps.
*/
import "server-only";
import { chromium, type Browser } from "playwright-core";
import { getQlikConfig, qlikNtlmSession, type QlikConfig } from "@/lib/qlik-client";
/** Un article trouvé côté Qlik. Seul `codeCentrale` est garanti. */
export interface QlikArticleMatch {
/** Valeur de la dimension « Article Code » = code centrale FF (`articles.artcentrale`). */
codeCentrale: string;
/** Libellé réseau, si l'app expose un champ libellé article. */
libelle: string;
/** Fournisseur réseau, si l'app expose un champ fournisseur. */
fournisseur: string;
}
export interface QlikSearchOutcome {
matches: QlikArticleMatch[];
/** Champ Qlik effectivement interrogé (diagnostic UI). */
champUtilise: string | null;
/** true si Qlik a renvoyé plus de résultats que la limite demandée. */
tronque: boolean;
}
/**
* Nombre maximum d'articles renvoyés par une recherche.
*
* Chaque code trouvé est ensuite ré-extrait avec son détail mensuel : au-delà
* de quelques dizaines, la seconde passe devient longue et le résultat
* illisible. L'utilisateur affine son terme.
*/
export const QLIK_SEARCH_MAX_RESULTS = 40;
/** Nombre maximum de valeurs de champ sélectionnées suite à la recherche. */
const MAX_FIELD_MATCHES = 400;
let browserPromise: Promise<Browser> | null = null;
async function getBrowser(): Promise<Browser> {
if (!browserPromise) {
const execPath = process.env.PLAYWRIGHT_CHROMIUM_PATH;
browserPromise = chromium.launch({
headless: true,
executablePath: execPath || undefined,
args: ["--no-sandbox", "--disable-dev-shm-usage", "--ignore-certificate-errors"],
}).catch((e) => { browserPromise = null; throw e; });
}
return browserPromise;
}
interface InPageSearchResult {
ok: boolean;
error?: string;
rows?: Array<[string, string, string]>;
champUtilise?: string | null;
total?: number;
/** Journal compact des étapes, remonté dans les logs serveur. */
trace?: string[];
}
/**
* Cherche dans Qlik les articles correspondant à `term`.
*
* Ne fait **aucune** mesure : on veut juste la liste des codes centraux (plus
* libellé / fournisseur si l'app les expose). Les métriques réseau sont
* ensuite extraites par `fetchNetworkMetricsPlaywright`, qui sait déjà produire
* le détail mensuel sur 12 mois glissants.
*/
export async function searchQlikArticles(
term: string,
limit: number = QLIK_SEARCH_MAX_RESULTS,
cfg: QlikConfig = getQlikConfig(),
): Promise<QlikSearchOutcome> {
const cleaned = term.trim();
if (cleaned.length < 3) return { matches: [], champUtilise: null, tronque: false };
if (!cfg.appNetwork) throw new Error("[qlik-search] QLIK_APP_NETWORK manquant");
if (!cfg.user || !cfg.password) throw new Error("[qlik-search] identifiants Qlik manquants");
const started = Date.now();
const sess = await qlikNtlmSession(cfg);
const [cookieName, ...rest] = sess.cookie.split("=");
const cookieValue = rest.join("=");
const browser = await getBrowser();
const ctx = await browser.newContext({
httpCredentials: { username: cfg.user, password: cfg.password, origin: `https://${cfg.host}` },
ignoreHTTPSErrors: true,
});
await ctx.addCookies([{ name: cookieName, value: cookieValue, domain: cfg.host, path: "/", secure: true }]);
try {
const page = await ctx.newPage();
page.on("console", (msg) => console.log(`[qlik-search][page] ${msg.text()}`));
let csrfToken: string | null = null;
let resolveToken: (() => void) | null = null;
const tokenReady = new Promise<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 Qlik ouvre son ws juste après */ });
await Promise.race([tokenReady, page.waitForTimeout(15000)]);
if (!csrfToken) throw new Error("[qlik-search] qlik-csrf-token introuvable (client Qlik non chargé ?)");
const result = (await page.evaluate(
({ app, dim, token, terme, limite, maxFieldMatches }: {
app: string; dim: string; token: string; terme: string; limite: number; maxFieldMatches: number;
}) =>
new Promise<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);
const trace: string[] = [];
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 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);
});
(async () => {
try {
await ready;
const open = await rpc("OpenDoc", { qDocName: app });
const doc = (open.qReturn as { qHandle: number }).qHandle;
trace.push("OpenDoc ok");
// ─── 1. Inventaire des champs ────────────────────────────
// Sert à repérer, sans connaître l'app par cœur, le champ
// libellé article et le champ fournisseur.
const fl = await rpc("CreateSessionObject", {
qProp: {
qInfo: { qType: "FieldList" },
qFieldListDef: { qShowSystem: false, qShowHidden: false, qShowSemantic: true, qShowSrcTables: true },
},
}, doc);
const fll = await rpc("GetLayout", {}, (fl.qReturn as { qHandle: number }).qHandle);
const champs = ((((fll.qLayout as { qFieldList?: { qItems?: Array<{ qName: string }> } }).qFieldList?.qItems) ?? [])
.map((f) => f.qName));
trace.push("champs=" + champs.length);
const has = (nom: string, re: RegExp) => re.test(nom);
const champCode = champs.find((c) => c === "Article Code")
?? champs.find((c) => has(c, /article/i) && has(c, /code/i))
?? "Article Code";
const champLibelle = champs.find((c) => has(c, /article/i) && has(c, /(libell|designation|désignation|denomination|dénomination)/i))
?? champs.find((c) => has(c, /(libell|designation|désignation)/i) && !has(c, /(magasin|region|région|famille|rayon|fournisseur)/i))
?? null;
const champFournisseur = champs.find((c) => has(c, /fournisseur/i))
?? champs.find((c) => has(c, /(fabricant|supplier)/i))
?? null;
trace.push("code=" + champCode + " libelle=" + champLibelle + " fournisseur=" + champFournisseur);
// ─── 2. Recherche globale Qlik ───────────────────────────
// On restreint aux champs article : sans restriction, un terme
// courant ("noir") matcherait des dizaines de champs sans
// rapport (couleur magasin, région…) et la sélection deviendrait
// absurde.
const champsRecherche = [champCode, ...(champLibelle ? [champLibelle] : [])];
const sr = await rpc("SearchResults", {
qOptions: { qSearchFields: champsRecherche, qContext: "Cleared" },
qTerms: [terme],
qPage: { qOffset: 0, qCount: champsRecherche.length, qMaxNbrFieldMatches: maxFieldMatches },
}, doc);
type Match = { qText?: string };
type Item = { qIdentifier?: string; qItemMatches?: Match[]; qTotalNumberOfMatches?: number };
type Group = { qItems?: Item[] };
const groupes = (((sr.qResult as { qSearchGroupArray?: Group[] })?.qSearchGroupArray) ?? []);
const parChamp = new Map<string, string[]>();
for (const g of groupes) {
for (const it of g.qItems ?? []) {
const champ = it.qIdentifier ?? "";
if (!champ) continue;
const vals = (it.qItemMatches ?? []).map((m) => String(m.qText ?? "")).filter(Boolean);
if (vals.length === 0) continue;
parChamp.set(champ, [...(parChamp.get(champ) ?? []), ...vals]);
}
}
trace.push("groupes=" + JSON.stringify([...parChamp].map(([c, v]) => c + ":" + v.length)));
// Priorité au champ libellé (une recherche texte), sinon le code.
const champChoisi = (champLibelle && parChamp.has(champLibelle)) ? champLibelle
: parChamp.has(champCode) ? champCode
: [...parChamp.keys()][0] ?? null;
if (!champChoisi) {
ws.close();
return resolve({ ok: true, rows: [], champUtilise: null, total: 0, trace });
}
const valeurs = [...new Set(parChamp.get(champChoisi) ?? [])].slice(0, maxFieldMatches);
// ─── 3. Sélection des valeurs trouvées ───────────────────
const gf = await rpc("GetField", { qFieldName: champChoisi }, doc);
const fh = (gf.qReturn as { qHandle: number }).qHandle;
await rpc("Clear", {}, fh);
await rpc("SelectValues", {
qFieldValues: valeurs.map((v) => ({ qText: v })),
qToggleMode: false,
qSoftLock: true,
}, fh);
trace.push("selection " + champChoisi + " = " + valeurs.length + " valeurs");
// ─── 4. Liste des articles correspondants ────────────────
// Cube sans mesure : uniquement l'identité de l'article. Les
// métriques réseau sont extraites juste après par
// fetchNetworkMetricsPlaywright (qui produit aussi le mensuel).
const dims: Array<{ qLibraryId?: string; qDef?: { qFieldDefs: string[] } }> = [{ qLibraryId: dim }];
if (champLibelle) dims.push({ qDef: { qFieldDefs: [champLibelle] } });
if (champFournisseur) dims.push({ qDef: { qFieldDefs: [champFournisseur] } });
const largeur = dims.length;
const hauteur = Math.min(limite, Math.floor(9000 / Math.max(largeur, 1)));
const cube = await rpc("CreateSessionObject", {
qProp: {
qInfo: { qType: "cf-search" },
qHyperCubeDef: {
qDimensions: dims.map((d) => ({ ...d, qNullSuppression: true })),
qMeasures: [],
qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: largeur, qHeight: hauteur }],
qSuppressMissing: true,
},
},
}, doc);
const ch = (cube.qReturn as { qHandle: number }).qHandle;
const layout = await rpc("GetLayout", {}, ch);
const hc = (layout.qLayout as {
qHyperCube: {
qSize: { qcy: number };
qDataPages?: Array<{ qMatrix?: Array<Array<{ qText?: string }>> }>;
};
}).qHyperCube;
const matrix = hc.qDataPages?.[0]?.qMatrix ?? [];
const rows: Array<[string, string, string]> = [];
for (const r of matrix) {
const code = String(r[0]?.qText ?? "").trim();
if (!code || code === "-") continue;
const libelle = champLibelle ? String(r[1]?.qText ?? "").trim() : "";
const idxFou = champLibelle ? 2 : 1;
const fournisseur = champFournisseur ? String(r[idxFou]?.qText ?? "").trim() : "";
rows.push([code, libelle === "-" ? "" : libelle, fournisseur === "-" ? "" : fournisseur]);
}
trace.push("articles=" + rows.length + "/" + hc.qSize.qcy);
ws.close();
resolve({ ok: true, rows, champUtilise: champChoisi, total: hc.qSize.qcy, trace });
} catch (e) {
try { ws.close(); } catch { /* noop */ }
resolve({ ok: false, error: String((e as Error)?.message || e), trace });
}
})();
}),
{
app: cfg.appNetwork,
dim: cfg.dimCodeArticleId,
token: csrfToken,
terme: cleaned,
limite: limit,
maxFieldMatches: MAX_FIELD_MATCHES,
},
)) as InPageSearchResult;
for (const t of result.trace ?? []) console.log(`[qlik-search] ${t}`);
if (!result.ok) throw new Error(`[qlik-search] ${result.error}`);
const matches: QlikArticleMatch[] = (result.rows ?? []).map(([codeCentrale, libelle, fournisseur]) => ({
codeCentrale, libelle, fournisseur,
}));
console.log(
`[qlik-search] "${cleaned}" → ${matches.length} article(s) (total Qlik ${result.total ?? 0}, champ=${result.champUtilise}) en ${Date.now() - started} ms`,
);
return {
matches,
champUtilise: result.champUtilise ?? null,
tronque: (result.total ?? 0) > matches.length,
};
} finally {
await ctx.close();
}
}