From eeaae092821dc8d57351ee686e2470b529f5dcf1 Mon Sep 17 00:00:00 2001
From: Claude
+ {detail.codein}
+ {detail.code_centrale && <> · centrale {detail.code_centrale}>}
+ {fournisseurPrincipal && <> · {fournisseurPrincipal.nomfou}>}
+ Aucune ligne de stock pour ce produit.
+ Ce produit n'a pas de code centrale : il n'est pas référencé centralement et n'existe donc pas dans Qlik.
+
+ Période {reseau.periode ?? "12 mois"} · extrait le {formatDate(reseau.fetchedAt)}
+
+ Les mois sans CA ni marge proviennent de la mesure « Quantité COMP », qui ne porte que la quantité.
+
+ L'indice compare les quantités par magasin, seule base commune fiable.
+ Notre CA est TTC (issu des mouvements de caisse) ; la base du CA Qlik n'est pas
+ documentée — montants et marges sont donc donnés à titre indicatif.
+
+ Comparer ce produit aux autres références{familleLabel ? ` de « ${familleLabel} »` : " de la même famille"} :
+ lesquelles le réseau vend bien et que nous vendons peu ?
+ {error}
+ Aucune autre référence de cette famille n'a de données réseau en cache.
+ Synchronisez d'autres fournisseurs pour enrichir la comparaison.
+
+ Écart = quantité réseau par magasin − notre quantité par magasin, sur 12 mois.
+ Un écart positif signale une référence sous-exploitée chez nous.
+ Périmètre : les articles de notre catalogue ayant déjà des données réseau —
+ Qlik est interrogé par code article, un produit que Nancy ne référence pas ne peut pas apparaître ici.
+
+ Par libellé ou par code centrale — ventes sur 12 mois glissants et données réseau Qlik.
+
+ Recherchez un produit
+
+ Saisissez un libellé (ex. « poêle 28 ») ou un code centrale (ex. « 10000167303 »).
+
+ {rows.length} produit{rows.length > 1 ? "s" : ""} trouvé{rows.length > 1 ? "s" : ""}
+ {rows.length >= 50 && " (50 premiers — affinez la recherche)"}
+
+ Saisissez au moins 3 caractères.
+
+
+ {action}
+
+ {detail.libelle1 || "Sans libellé"}
+
+
+
+
+
+
+
+
+
+ Mois
+
+ {months.map((m) => (
+
+ {formatMonthLabel(m)}
+
+ ))}
+
+
+
+ Ventes
+
+ {months.map((m) => (
+
+
+ ))}
+
+
+
+ CA (TTC)
+
+ {months.map((m) => (
+
+ {serie[m]?.ca ? Math.round(serie[m].ca).toLocaleString("fr-FR") : "—"}
+
+ ))}
+
+
+
+
+ Stock fin de mois
+
+ {months.map((m) => (
+
+ {serie[m]?.stockFinMois ? Math.round(serie[m].stockFinMois).toLocaleString("fr-FR") : "—"}
+
+ ))}
+
+
+
+
+
+
+
+ {([
+ { 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) => (
+ Mesure
+ {months.map((m) => (
+
+ {formatMonthLabel(m)}
+
+ ))}
+
+
+ ))}
+
+
+ {row.label}
+
+ {months.map((m) => {
+ const v = reseau.metricsByMonth?.[ffMonthToQlik(m)]?.[row.key];
+ return (
+
+ {v != null ? row.fmt(v) : "—"}
+
+ );
+ })}
+
+
+
+
+
+
+
+ {others.map((r) => {
+ const opportunite = r.ecart_par_magasin > 0;
+ return (
+ Produit
+ Fournisseur
+ Réseau / mag.
+ Nous / mag.
+ Écart
+ Magasins
+
+
+ );
+ })}
+
+
+
+ {r.libelle1 || r.codein}
+
+
+ {r.fournisseur}
+
+ {fmtDec(r.qte_reseau_par_magasin)}
+
+
+ {fmtDec(r.qte_locale_par_magasin)}
+
+
+ {r.ecart_par_magasin > 0 ? "+" : ""}{fmtDec(r.ecart_par_magasin)}
+
+
+ {fmtQte(r.nb_magasins_reseau)}
+
+
+ Recherche produit
+
+
+
+
+
+
+
+
+ {rows.map((r) => (
+ Libellé
+ Fournisseur
+ Code article
+ Code centrale
+ Famille
+ Stock
+ Qté réseau
+
+
+ ))}
+
+
+
+ {r.libelle1 || "—"}
+
+
+
+ {r.fournisseur || "—"}
+
+
+ {r.codein}
+
+
+ {r.code_centrale || "—"}
+
+
+ {r.nomenclature || "—"}
+
+
+ {fmtQte(r.stock_total)}
+
+
+ {r.has_reseau ? (
+
+
+
+ {children}
+
+ );
+}
diff --git a/src/app/(dashboard)/produits/search-bar.tsx b/src/app/(dashboard)/produits/search-bar.tsx
new file mode 100644
index 0000000..2bd18bb
--- /dev/null
+++ b/src/app/(dashboard)/produits/search-bar.tsx
@@ -0,0 +1,79 @@
+"use client";
+
+import { useState } from "react";
+import { useRouter } from "next/navigation";
+import { Search, X } from "lucide-react";
+
+/**
+ * Champ de recherche produit.
+ *
+ * Soumission **explicite** (Entrée ou bouton) et non à la frappe : la requête
+ * fait un ILIKE sans index sur `articles.libelle1`, on ne veut pas la déclencher
+ * à chaque caractère. Minimum 3 caractères, aligné sur `pgSearchProduits`.
+ *
+ * L'appelant passe `key={q}` pour que le champ se resynchronise sur l'URL
+ * (retour arrière, lien partagé) par remontage, plutôt que par un effet qui
+ * appellerait setState en cascade.
+ */
+export function ProduitSearchBar({ initialQuery }: { initialQuery: string }) {
+ const router = useRouter();
+ const [term, setTerm] = useState(initialQuery);
+
+ const tooShort = term.trim().length > 0 && term.trim().length < 3;
+
+ function submit() {
+ const cleaned = term.trim();
+ if (cleaned.length < 3) return;
+ router.push(`/produits?q=${encodeURIComponent(cleaned)}`);
+ }
+
+ function clear() {
+ setTerm("");
+ router.push("/produits");
+ }
+
+ return (
+
- {detail.codein} - {detail.code_centrale && <> · centrale {detail.code_centrale}>} - {fournisseurPrincipal && <> · {fournisseurPrincipal.nomfou}>} + {detail && <>{detail.codein}{" · "}>} + {codeCentrale && <>centrale {codeCentrale}>} + {fournisseurAffiche && <> · {fournisseurAffiche}>}
Aucune ligne de stock pour ce produit.
- ) : ( -| - Mois - | - {months.map((m) => ( -- {formatMonthLabel(m)} - | - ))} -
|---|---|
| - Ventes - | - {months.map((m) => ( -
- |
- ))}
-
| - CA (TTC) - | - {months.map((m) => ( -- {serie[m]?.ca ? Math.round(serie[m].ca).toLocaleString("fr-FR") : "—"} - | - ))} -
| - Stock fin de mois - | - {months.map((m) => ( -- {serie[m]?.stockFinMois ? Math.round(serie[m].stockFinMois).toLocaleString("fr-FR") : "—"} - | - ))} -
Ce produit n'a pas de code centrale : il n'est pas référencé centralement et n'existe donc pas dans Qlik.
@@ -369,18 +233,29 @@ export function ProduitFicheView({ fiche, backQuery }: { fiche: ProduitFiche; ba ) : ( <> -- Période {reseau.periode ?? "12 mois"} · extrait le {formatDate(reseau.fetchedAt)} + Période {reseau.periode ?? "12 mois"} · extrait le {formatDate(reseau.fetchedAt)} · + {" "}mois en cours exclu (incomplet).
{trend.hasData && ( @@ -421,18 +302,14 @@ export function ProduitFicheView({ fiche, backQuery }: { fiche: ProduitFiche; ba - {([ - { key: "qte", label: "Quantité", fmt: (v: number) => fmtQte(v) }, - { key: "ca", label: "CA", fmt: (v: number) => Math.round(v).toLocaleString("fr-FR") }, - { key: "nbMag", label: "Magasins", fmt: (v: number) => fmtQte(v) }, - { key: "margePct", label: "Marge %", fmt: (v: number) => `${fmtDec(Math.abs(v) <= 1 ? v * 100 : v)} %` }, - ] as const).map((row) => ( -+ {fournisseurReseau + ? <>Aucun fournisseur référencé chez nous. Côté réseau, Qlik indique {fournisseurReseau}.> + : "Aucun fournisseur référencé pour ce produit."} +
+ ) : ( ++ Fournisseur côté Qlik : {fournisseurReseau} +
+ )} +Aucune ligne de stock pour ce produit.
+ ) : ( +| + Mois + | + {months.map((m) => ( ++ {formatMonthLabel(m)} + | + ))} +
|---|---|
| + Ventes + | + {months.map((m) => ( +
+ |
+ ))}
+
| + CA (TTC) + | + {months.map((m) => ( ++ {serie[m]?.ca ? Math.round(serie[m].ca).toLocaleString("fr-FR") : "—"} + | + ))} +
| + Stock fin de mois + | + {months.map((m) => ( ++ {serie[m]?.stockFinMois ? Math.round(serie[m].stockFinMois).toLocaleString("fr-FR") : "—"} + | + ))} +
- Par libellé ou par code centrale — ventes sur 12 mois glissants et données réseau Qlik. + Recherche dans Qlik Sense (réseau) puis rapprochement avec notre catalogue — + performance réseau et ventes locales sur 12 mois glissants.
{/* key : remonte le champ quand l'URL change (retour arrière, lien partagé) */}- Recherchez un produit + Recherchez un produit dans le réseau
-+
+ La recherche interroge Qlik Sense (les ~{NB_MAGASINS_RESEAU} magasins du réseau), + puis rapproche chaque produit trouvé de notre catalogue. Saisissez un libellé (ex. « poêle 28 ») ou un code centrale (ex. « 10000167303 »).
+ Interrogation de Qlik Sense… +
++ Recherche des articles du réseau puis extraction de leurs ventes sur 12 mois glissants. + Cela peut prendre jusqu'à une minute. +
- {rows.length} produit{rows.length > 1 ? "s" : ""} trouvé{rows.length > 1 ? "s" : ""} - {rows.length >= 50 && " (50 premiers — affinez la recherche)"} -
+ if (error) { + return ( ++ La recherche a échoué +
+{error}
+ +| Libellé | -Fournisseur | -Code article | -Code centrale | -Famille | -Stock | -Qté réseau | -
|---|---|---|---|---|---|---|
|
+ if (!data) return null;
+
+ const { rows, source, qlikError, tronque, locauxHorsReseau, dureeMs } = data;
+
+ return (
+
+
+
+
+ {source === "db" && (
+
+ {source === "qlik" ? (
+
+
+ )}
+
+ {source === "qlik" && qlikError && (
+
+ Articles trouvés, mais l'extraction des mesures réseau a échoué : {qlikError}
+
+ )}
+
+ {rows.length === 0 ? (
+
+ Aucun article du réseau ne correspond à « {query} ».
+
+ ) : (
+
+ + {locauxHorsReseau.length} produit{locauxHorsReseau.length > 1 ? "s" : ""} de notre catalogue + sans correspondance réseau (pas de code centrale, ou non vendu par le réseau) ++
+
|
- - {r.fournisseur || "—"} - | -- {r.codein} - | -- {r.code_centrale || "—"} - | -- {r.nomenclature || "—"} - | -- {fmtQte(r.stock_total)} - | -
- {r.has_reseau ? (
-
- |
-
| Libellé | +Fournisseur | +Code centrale | +Magasins | +Qté réseau | +Qté / mag. | +Prix moyen | +CA / mag. | +Marge % | +Tendance | +Nous | +
|---|---|---|---|---|---|---|---|---|---|---|
|
+
+ {r.libelle || "—"}
+
+ {r.nomenclature && (
+ {r.nomenclature}
+ )}
+ |
+ + {r.fournisseur || "—"} + | ++ {r.codeCentrale} + | ++ {fmtQte(r.nbMagasinsReseau)} + + {fmtDec(r.tauxPresence * 100, 0)} % + + | ++ {fmtQte(r.qteReseau)} + | ++ {fmtDec(r.qteParMagasinReseau)} + | ++ {r.prixMoyenReseau != null ? fmtEur2(r.prixMoyenReseau) : "—"} + | ++ {r.caParMagasinReseau > 0 ? fmtEur(r.caParMagasinReseau) : "—"} + | ++ {r.margePctReseau != null ? `${fmtDec(r.margePctReseau)} %` : "—"} + | +
+ {trend.hasData ? |
+ + {r.codein ? ( + + stock {fmtQte(r.stockLocal ?? 0)} + + ) : ( + + non référencé + + )} + | +
- Interrogation de Qlik Sense… + {etape ?? "Interrogation de Qlik Sense…"}
Recherche des articles du réseau puis extraction de leurs ventes sur 12 mois glissants. - Cela peut prendre jusqu'à une minute. + Cela peut prendre plus d'une minute — vous pouvez laisser la page ouverte.
); diff --git a/src/app/api/produits/search/route.ts b/src/app/api/produits/search/route.ts index 06c7c45..dae34e0 100644 --- a/src/app/api/produits/search/route.ts +++ b/src/app/api/produits/search/route.ts @@ -1,43 +1,70 @@ import { NextRequest, NextResponse } from "next/server"; import { auth } from "@/lib/auth"; -import { rechercherProduits } from "@/features/produits/api/search-produits"; +import { demarrerRecherche, etatRecherche } from "@/features/produits/api/search-produits"; -// L'extraction Qlik (Playwright + Engine) prend plusieurs secondes, parfois -// plus d'une minute quand le serveur Qlik est chargé. -export const maxDuration = 300; -// Playwright/Chromium exige le runtime Node.js (pas edge). +// Les réponses sont immédiates (le travail Qlik tourne en tâche de fond), mais on +// garde le runtime Node.js : Playwright/Chromium ne tourne pas en edge, et l'état +// des jobs vit dans la mémoire du process. export const runtime = "nodejs"; export const dynamic = "force-dynamic"; /** - * GET /api/produits/search?q=